дзеркало
https://github.com/l0ng-ai/openidea.git
синхронізовано 2026-11-09 19:25:54 +02:00
fix(search): Find / Replace in Files popup layout, footer and platform shortcuts (#1227)
The Find / Replace in Files popup gets the popup surface and spacing,
the match count and empty states ("Clear option(s)"), the footer with
"Open results in new tab", a resizable popup with a remembered results /
preview split, file mask suggestions from the project's file types
(new index_file_extensions), mnemonics and inline icons. Shortcuts are
spelled for the platform: Linux showed macOS glyphs in the popup, the
shared SearchField, the find bars and Search Everywhere. Replace keeps
its Preview diff.
Closes #1219
Co-authored-by: fuqiuluo <[email protected]>
Цей коміт міститься в:
1 джерело
5d19baf06b
коміт
948e93e1a2
48 files changed
+1067
-361
No files matched your search
@@ -2,7 +2,7 @@
|
||||
import { type KeyboardEvent, type RefObject } from "react";
|
||||
import clsx from "clsx";
|
||||
import { IconButton, SearchField } from "@/ui";
|
||||
import { hasPrimaryModifier } from "@/platform/keymap";
|
||||
import { formatBinding, hasPrimaryModifier } from "@/platform/keymap";
|
||||
import { tx } from "@/platform/i18n";
|
||||
import type { FindOptions } from "./find";
|
||||
import styles from "./Run.module.css";
|
||||
@@ -97,7 +97,7 @@ export function ConsoleFindBar({
|
||||
id: "case",
|
||||
icon: "case",
|
||||
title: "Match Case",
|
||||
shortcut: "⌥C",
|
||||
shortcut: formatBinding("Alt+C"),
|
||||
pressed: options.caseSensitive,
|
||||
onToggle: () => toggle("caseSensitive"),
|
||||
},
|
||||
@@ -105,7 +105,7 @@ export function ConsoleFindBar({
|
||||
id: "word",
|
||||
icon: "word",
|
||||
title: "Words",
|
||||
shortcut: "⌥W",
|
||||
shortcut: formatBinding("Alt+W"),
|
||||
pressed: options.wholeWord,
|
||||
onToggle: () => toggle("wholeWord"),
|
||||
},
|
||||
@@ -113,7 +113,7 @@ export function ConsoleFindBar({
|
||||
id: "regex",
|
||||
icon: "regex",
|
||||
title: "Regex",
|
||||
shortcut: "⌥X",
|
||||
shortcut: formatBinding("Alt+X"),
|
||||
pressed: options.regex,
|
||||
onToggle: () => toggle("regex"),
|
||||
},
|
||||
@@ -125,7 +125,7 @@ export function ConsoleFindBar({
|
||||
<IconButton
|
||||
icon="arrow-up"
|
||||
title="Previous Occurrence"
|
||||
shortcut="⇧↵"
|
||||
shortcut={formatBinding("Shift+Enter")}
|
||||
size="small"
|
||||
disabled={count === 0}
|
||||
onClick={onPrevious}
|
||||
|
||||
@@ -65,6 +65,7 @@ const { showSearchEverywhere, hideSearchEverywhere, useSearchEverywhere, pushSea
|
||||
await import("../everywhere/store");
|
||||
const { FindInFilesOverlay } = await import("../find/FindInFiles");
|
||||
const { showFindPopup, hideFindPopup } = await import("../find/store");
|
||||
const { defaultPreset, useKeymap } = await import("@/platform/keymap");
|
||||
|
||||
const tick = () => act(() => new Promise((r) => setTimeout(r, 100)));
|
||||
|
||||
@@ -476,13 +477,40 @@ describe("Recent Files multi-selection", () => {
|
||||
});
|
||||
|
||||
describe("Find in Files dialog", () => {
|
||||
it("streams results and previews the replacement", async () => {
|
||||
it("streams results and highlights the matches while replacing, as IDEA", async () => {
|
||||
render(<FindInFilesOverlay />);
|
||||
act(() => showFindPopup(true, { query: "order" }));
|
||||
await act(() => new Promise((r) => setTimeout(r, 250)));
|
||||
expect(screen.getByText("1 match in 1 file")).toBeInTheDocument();
|
||||
fireEvent.change(screen.getByLabelText("Replace with"), { target: { value: "item" } });
|
||||
expect(screen.getByText("item", { ignore: "script, style, textarea" })).toBeInTheDocument();
|
||||
const matches = screen.getByRole("listbox", { name: "Matches" });
|
||||
expect(matches.querySelector("mark")).not.toBeNull();
|
||||
expect(matches.querySelector("del, ins")).toBeNull();
|
||||
expect(screen.getByRole("button", { name: "Replace All" })).toBeEnabled();
|
||||
});
|
||||
|
||||
it.each([
|
||||
["macos", ["⌥C", "⌥↓", "⇧⌘↵"], ["⌘↵", "⌥⇧↵"]],
|
||||
["linux", ["Alt+C", "Alt+Down", "Ctrl+Shift+Enter"], ["Ctrl+Enter", "Alt+Shift+Enter"]],
|
||||
["windows", ["Alt+C", "Alt+Down", "Ctrl+Shift+Enter"], ["Ctrl+Enter", "Alt+Shift+Enter"]],
|
||||
] as const)("spells its shortcuts the %s way", (os, [matchCase, history, newLine], [openKey, replaceAllKey]) => {
|
||||
const previous = { os: usePlatform.getState().os, preset: useKeymap.getState().preset };
|
||||
usePlatform.setState({ os });
|
||||
useKeymap.setState({ preset: defaultPreset() });
|
||||
try {
|
||||
render(<FindInFilesOverlay />);
|
||||
act(() => showFindPopup(true, { query: "order" }));
|
||||
const shortcut = (name: string) =>
|
||||
screen.getAllByRole("button", { name }).map((b) => b.getAttribute("data-tooltip-shortcut"));
|
||||
expect(shortcut("Match Case")).toEqual([matchCase]);
|
||||
expect(shortcut("Recent Searches")).toEqual([history, history]);
|
||||
expect(shortcut("New Line")).toEqual([newLine, newLine]);
|
||||
expect(document.querySelector("footer")!.textContent).toContain(`${openKey}Open in Find Window`);
|
||||
expect(screen.getByRole("button", { name: "Replace All" })).toHaveAttribute("title", replaceAllKey);
|
||||
if (os !== "macos") expect(document.querySelector("[role=dialog]")!.innerHTML).not.toMatch(/[⌘⌥⇧↵]/);
|
||||
} finally {
|
||||
usePlatform.setState({ os: previous.os });
|
||||
useKeymap.setState({ preset: previous.preset });
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,46 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const extensions = [
|
||||
{ extension: "rs", files: 40 },
|
||||
{ extension: "md", files: 12 },
|
||||
{ extension: "lock", files: 9 },
|
||||
{ extension: "toml", files: 6 },
|
||||
{ extension: "png", files: 5 },
|
||||
{ extension: "json", files: 3 },
|
||||
{ extension: "yml", files: 2 },
|
||||
{ extension: "sh", files: 1 },
|
||||
];
|
||||
const languages = [
|
||||
{ id: "rust", extensions: ["rs"] },
|
||||
{ id: "markdown", extensions: ["md", "markdown"] },
|
||||
{ id: "toml", extensions: ["toml"] },
|
||||
{ id: "json", extensions: ["json"] },
|
||||
{ id: "yaml", extensions: ["yml", "yaml"] },
|
||||
{ id: "shell", extensions: ["sh"] },
|
||||
];
|
||||
|
||||
vi.mock("@/platform/ipc", async (orig) => ({
|
||||
...(await orig<typeof import("@/platform/ipc")>()),
|
||||
call: async (p: unknown) => p,
|
||||
commands: {
|
||||
indexFileExtensions: () => Promise.resolve(extensions),
|
||||
languages: () => Promise.resolve(languages),
|
||||
},
|
||||
}));
|
||||
|
||||
const { recentMasks, refreshProjectMasks, rememberMask, useProjectMasks } = await import("../find/store");
|
||||
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
useProjectMasks.setState({ masks: [] });
|
||||
});
|
||||
|
||||
describe("file mask suggestions", () => {
|
||||
it("are the project's most common language file types, recent masks first", async () => {
|
||||
await refreshProjectMasks();
|
||||
expect(useProjectMasks.getState().masks).toEqual(["*.rs", "*.md", "*.toml", "*.json", "*.yml"]);
|
||||
rememberMask("*.toml");
|
||||
rememberMask("!*Test*");
|
||||
expect(recentMasks()).toEqual(["!*Test*", "*.toml", "*.rs", "*.md", "*.json", "*.yml"]);
|
||||
});
|
||||
});
|
||||
@@ -63,8 +63,18 @@ vi.mock("@/platform/ipc", async (orig) => ({
|
||||
}));
|
||||
|
||||
const { FindInFilesOverlay } = await import("../find/FindInFiles");
|
||||
const { findHistory, hideFindPopup, rememberFindString, runFindPopupCommand, showFindPopup, useFindPopup } =
|
||||
await import("../find/store");
|
||||
const {
|
||||
findHistory,
|
||||
hideFindPopup,
|
||||
rememberFindString,
|
||||
runFindPopupCommand,
|
||||
showFindPopup,
|
||||
useFindPopup,
|
||||
useProjectMasks,
|
||||
} = await import("../find/store");
|
||||
|
||||
const { formatBinding } = await import("@/platform/keymap");
|
||||
const { usePlatform } = await import("@/platform/platformInfo");
|
||||
|
||||
const settle = () => act(() => new Promise((r) => setTimeout(r, 250)));
|
||||
const selectedRows = () =>
|
||||
@@ -92,6 +102,7 @@ beforeEach(() => {
|
||||
preserveCase: false,
|
||||
context: "any",
|
||||
replacement: "",
|
||||
newTab: false,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -206,7 +217,8 @@ describe("Find in Files popup", () => {
|
||||
});
|
||||
|
||||
describe("outdated popup results", () => {
|
||||
const results = () => screen.getByRole("listbox", { name: "Matches" }).parentElement!;
|
||||
// The results box, whether it holds the list or the empty text.
|
||||
const results = () => document.querySelector<HTMLElement>("[aria-busy]")!;
|
||||
const rowText = () =>
|
||||
within(screen.getByRole("listbox", { name: "Matches" }))
|
||||
.getAllByRole("option")
|
||||
@@ -255,13 +267,14 @@ describe("outdated popup results", () => {
|
||||
expect(results()).not.toHaveAttribute("data-stale");
|
||||
expect(rowText()).toHaveLength(1);
|
||||
expect(screen.getByText("1 match in 1 file")).toBeInTheDocument();
|
||||
expect(results().querySelector(replace ? "ins" : "mark")).not.toBeNull();
|
||||
expect(results().querySelector("mark")).not.toBeNull();
|
||||
expect(results().querySelector("del, ins")).toBeNull();
|
||||
if (replace) expect(screen.getByRole("button", { name: "Replace" })).toBeEnabled();
|
||||
await act(async () => finish(null));
|
||||
},
|
||||
);
|
||||
|
||||
it.each(["Replace", "Replace All in This File", "Replace All", "Enter", "All shortcut", "All mnemonic"])(
|
||||
it.each(["Replace", "Replace All", "Enter", "All shortcut", "All mnemonic"])(
|
||||
"blocks %s during debounce and validation",
|
||||
async (action) => {
|
||||
render(<FindInFilesOverlay />);
|
||||
@@ -318,12 +331,12 @@ describe("outdated popup results", () => {
|
||||
await settle();
|
||||
expect(results()).toHaveAttribute("data-stale", "true");
|
||||
expect(rowText()).toHaveLength(3);
|
||||
const box = results();
|
||||
await act(async () => finish(null));
|
||||
expect(box).not.toHaveAttribute("data-stale");
|
||||
expect(screen.getByText("No matches")).toBeInTheDocument();
|
||||
expect(results()).not.toHaveAttribute("data-stale");
|
||||
// IDEA's header shows a count only while something matches.
|
||||
expect(screen.queryByText(/\bmatch(es)? in\b/)).not.toBeInTheDocument();
|
||||
expect(screen.getByText("Nothing found")).toBeInTheDocument();
|
||||
expect(within(box).queryAllByRole("option")).toHaveLength(0);
|
||||
expect(within(results()).queryAllByRole("option")).toHaveLength(0);
|
||||
expect(screen.getByRole("button", { name: "Replace" })).toBeDisabled();
|
||||
});
|
||||
|
||||
@@ -354,3 +367,145 @@ describe("outdated popup results", () => {
|
||||
expect(replaced).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("footer, as IDEA", () => {
|
||||
it("remembers Open results in new tab and passes it on; ⌥B toggles it", () => {
|
||||
render(<FindInFilesOverlay />);
|
||||
act(() => showFindPopup(false, { query: "order" }));
|
||||
const box = screen.getByRole("checkbox", { name: "Open results in new tab" });
|
||||
expect(box).not.toBeChecked();
|
||||
fireEvent.keyDown(field(), { key: "b", code: "KeyB", altKey: true });
|
||||
expect(box).toBeChecked();
|
||||
expect(localStorage.getItem("openidea.search.findNewTab.v1")).toBe("true");
|
||||
fireEvent.click(box);
|
||||
expect(useFindPopup.getState().newTab).toBe(false);
|
||||
});
|
||||
|
||||
it("keeps OpenIDEA's Replace preview: a diff in place of the code preview", async () => {
|
||||
render(<FindInFilesOverlay />);
|
||||
act(() => showFindPopup(false, { query: "order" }));
|
||||
expect(screen.queryByRole("checkbox", { name: "Preview" })).not.toBeInTheDocument();
|
||||
act(() => showFindPopup(true, { query: "order" }));
|
||||
await settle();
|
||||
expect(screen.getByRole("region", { name: "Preview" })).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole("checkbox", { name: "Preview" }));
|
||||
expect(screen.queryByRole("region", { name: "Preview" })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("names the result keys only for a multi-line query", () => {
|
||||
render(<FindInFilesOverlay />);
|
||||
act(() => showFindPopup(false, { query: "order" }));
|
||||
expect(screen.queryByText(/to select usages$/)).not.toBeInTheDocument();
|
||||
fireEvent.change(field(), { target: { value: "order\nitem" } });
|
||||
expect(screen.getByText(/^Use .+ and .+ to select usages$/)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("mnemonics, as IDEA", () => {
|
||||
it("⌥P / ⌥M / ⌥D / ⌥S pick the scope tab, whose letter is underlined off macOS", () => {
|
||||
const os = usePlatform.getState().os;
|
||||
usePlatform.setState({ os: "linux" });
|
||||
try {
|
||||
render(<FindInFilesOverlay />);
|
||||
act(() => showFindPopup(false, { query: "order" }));
|
||||
expect(screen.getByRole("tab", { name: "In Project" }).querySelector("u")?.textContent).toBe("P");
|
||||
for (const [code, tab] of [
|
||||
["KeyD", "directory"],
|
||||
["KeyS", "scope"],
|
||||
["KeyM", "module"],
|
||||
["KeyP", "project"],
|
||||
] as const) {
|
||||
fireEvent.keyDown(field(), { key: code.slice(3).toLowerCase(), code, altKey: true });
|
||||
expect(useFindPopup.getState().scopeTab).toBe(tab);
|
||||
}
|
||||
} finally {
|
||||
usePlatform.setState({ os });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("file mask, as IDEA", () => {
|
||||
it("turned on with nothing typed takes the first suggestion", () => {
|
||||
useProjectMasks.setState({ masks: ["*.rs", "*.toml"] });
|
||||
render(<FindInFilesOverlay />);
|
||||
act(() => showFindPopup(false));
|
||||
expect(screen.getByLabelText("File mask")).toHaveAttribute("placeholder", "*.rs");
|
||||
fireEvent.click(screen.getByRole("checkbox", { name: "File mask:" }));
|
||||
expect([useFindPopup.getState().maskEnabled, useFindPopup.getState().mask]).toEqual([true, "*.rs"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("layout, as IDEA", () => {
|
||||
it("shows the preview only once something matches", async () => {
|
||||
render(<FindInFilesOverlay />);
|
||||
act(() => showFindPopup(false));
|
||||
expect(screen.queryByRole("region", { name: "Preview" })).not.toBeInTheDocument();
|
||||
fireEvent.change(field(), { target: { value: "order" } });
|
||||
await settle();
|
||||
expect(screen.getByRole("region", { name: "Preview" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("is resizable and opens at the size it was left at", () => {
|
||||
localStorage.setItem("openidea.search.findPopupSize.v1", JSON.stringify({ w: 700, h: 520 }));
|
||||
render(<FindInFilesOverlay />);
|
||||
act(() => showFindPopup(false));
|
||||
const dialog = screen.getByRole("dialog");
|
||||
expect([dialog.style.width, dialog.style.height]).toEqual(["700px", "520px"]);
|
||||
expect(within(dialog).getByRole("separator", { name: "Resize" })).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("empty results, as IDEA", () => {
|
||||
const findNothing = () => {
|
||||
search = async () => null;
|
||||
};
|
||||
|
||||
it("asks for a query while there is none", () => {
|
||||
render(<FindInFilesOverlay />);
|
||||
act(() => showFindPopup(false));
|
||||
expect(screen.getByText("Type search query to find in files")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("names the one option in use and clears it", () => {
|
||||
useFindPopup.setState({ maskEnabled: true, mask: "*.rs" });
|
||||
render(<FindInFilesOverlay />);
|
||||
act(() => showFindPopup(false));
|
||||
expect(screen.getByText("No search text entered.")).toBeInTheDocument();
|
||||
expect(screen.getByText("Search option used: File mask")).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole("button", { name: "Clear option" }));
|
||||
expect(useFindPopup.getState().maskEnabled).toBe(false);
|
||||
expect(screen.getByText("Type search query to find in files")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("lists every option in use with its key, and ⇧⌥⌫ clears them all", async () => {
|
||||
findNothing();
|
||||
useFindPopup.setState({ caseSensitive: true, wholeWord: true, context: "inComments" });
|
||||
render(<FindInFilesOverlay />);
|
||||
act(() => showFindPopup(false, { query: "absent" }));
|
||||
await settle();
|
||||
expect(screen.getByText("Nothing found.")).toBeInTheDocument();
|
||||
expect(screen.getByText("Search options used:")).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText(`Match Case ${formatBinding("Alt+C")} Words ${formatBinding("Alt+W")} Filter`),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "Clear all options" })).toBeInTheDocument();
|
||||
fireEvent.keyDown(field(), { key: "Backspace", altKey: true, shiftKey: true });
|
||||
const s = useFindPopup.getState();
|
||||
expect([s.caseSensitive, s.wholeWord, s.isRegex, s.maskEnabled, s.context]).toEqual([
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
"any",
|
||||
]);
|
||||
});
|
||||
|
||||
it("offers Search with Regex for a query that reads as one", async () => {
|
||||
findNothing();
|
||||
render(<FindInFilesOverlay />);
|
||||
act(() => showFindPopup(false, { query: "fo+.*bar" }));
|
||||
await settle();
|
||||
fireEvent.click(screen.getByRole("button", { name: "Search with Regex" }));
|
||||
expect(useFindPopup.getState().isRegex).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -39,22 +39,29 @@ afterEach(disposeFindWindow);
|
||||
|
||||
describe("Find tool window tabs", () => {
|
||||
it("reuses the selected tab unless it is pinned", () => {
|
||||
const a = openInFindWindow(q("a"), "a", null, deps);
|
||||
const b = openInFindWindow(q("b"), "b", null, deps);
|
||||
const a = openInFindWindow(q("a"), "a", { deps });
|
||||
const b = openInFindWindow(q("b"), "b", { deps });
|
||||
expect(useFindWindow.getState().tabs.map((t) => t.title)).toEqual(["b"]);
|
||||
expect(useFindWindow.getState().active).toBe(b);
|
||||
expect(b).not.toBe(a);
|
||||
|
||||
pinFindTab(b, true);
|
||||
const c = openInFindWindow(q("c"), "c", null, deps);
|
||||
const c = openInFindWindow(q("c"), "c", { deps });
|
||||
expect(useFindWindow.getState().tabs.map((t) => t.title)).toEqual(["b", "c"]);
|
||||
expect(useFindWindow.getState().active).toBe(c);
|
||||
|
||||
// Selecting the pinned tab again opens the next search beside it.
|
||||
useFindWindow.setState({ active: b });
|
||||
openInFindWindow(q("d"), "d", null, deps);
|
||||
openInFindWindow(q("d"), "d", { deps });
|
||||
expect(useFindWindow.getState().tabs.map((t) => t.title)).toEqual(["b", "c", "d"]);
|
||||
});
|
||||
|
||||
it("keeps the selected tab with IDEA's Open results in new tab", () => {
|
||||
openInFindWindow(q("a"), "a", { deps });
|
||||
const b = openInFindWindow(q("b"), "b", { deps, newTab: true });
|
||||
expect(useFindWindow.getState().tabs.map((t) => t.title)).toEqual(["a", "b"]);
|
||||
expect(useFindWindow.getState().active).toBe(b);
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildFindTree", () => {
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
*/
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { Button, Checkbox, Input, Popup, T, type PopupAnchor } from "@/ui";
|
||||
import { recentMasks } from "../find/store";
|
||||
import { recentMasks, refreshProjectMasks, useProjectMasks } from "../find/store";
|
||||
import { KIND_LABELS, kindsFor, type FilterTab, type SeFilters } from "./filters";
|
||||
import { loadLanguages } from "./deps";
|
||||
import { setSearchEverywhereFilter } from "./store";
|
||||
@@ -135,6 +135,8 @@ export function FilterPopup({
|
||||
|
||||
function MaskFilter({ mask, onClose }: { mask: string; onClose: () => void }) {
|
||||
const [value, setValue] = useState(mask);
|
||||
const projectMasks = useProjectMasks((p) => p.masks);
|
||||
useEffect(() => void refreshProjectMasks(), []);
|
||||
const apply = (m: string) => setSearchEverywhereFilter("text", { mask: m.trim() });
|
||||
return (
|
||||
<div className={styles.filterGroup}>
|
||||
@@ -145,7 +147,7 @@ function MaskFilter({ mask, onClose }: { mask: string; onClose: () => void }) {
|
||||
id="se-file-mask"
|
||||
data-autofocus
|
||||
value={value}
|
||||
placeholder="*.ts, !*.test.*"
|
||||
placeholder={projectMasks[0] ?? ""}
|
||||
spellCheck={false}
|
||||
onChange={(e) => {
|
||||
setValue(e.target.value);
|
||||
|
||||
@@ -4,7 +4,7 @@ import clsx from "clsx";
|
||||
import { actionToggled, executeAction, executeActionFrom, getAction } from "@/platform/actions";
|
||||
import type { ProjectFileSet } from "@/platform/ipc";
|
||||
import { tx as translate, useTx } from "@/platform/i18n";
|
||||
import { hasPrimaryModifier, registerKeyClaim, shortcutLabel } from "@/platform/keymap";
|
||||
import { formatBinding, hasPrimaryModifier, hostBinding, registerKeyClaim, shortcutLabel } from "@/platform/keymap";
|
||||
import {
|
||||
Checkbox,
|
||||
Dialog,
|
||||
@@ -98,6 +98,7 @@ const INITIAL_SELECTION: Selection = { key: null, anchorKey: null, lead: -1, anc
|
||||
function SearchEverywhereDialog() {
|
||||
const tx = useTx();
|
||||
const tab = useSearchEverywhere((s) => s.tab);
|
||||
const hint = hintFor(tab, tx);
|
||||
const query = useSearchEverywhere((s) => s.query);
|
||||
const includeNonProject = useSearchEverywhere((s) => s.includeNonProject);
|
||||
const fileSet = useSearchEverywhere((s) => s.fileSet);
|
||||
@@ -339,7 +340,7 @@ function SearchEverywhereDialog() {
|
||||
? [{ label: "Open Settings Page", run: () => activate(row) }]
|
||||
: [
|
||||
{ label: "Open", run: () => activate(row) },
|
||||
{ label: "Open to the Side", shortcut: "⇧↵", run: () => activate(row, true) },
|
||||
{ label: "Open to the Side", shortcut: formatBinding("Shift+Enter"), run: () => activate(row, true) },
|
||||
{ kind: "separator" },
|
||||
{ label: "Copy Path", run: () => copy(row.path) },
|
||||
{ label: "Copy Path from Project Root", run: () => copy(row.rel) },
|
||||
@@ -503,7 +504,7 @@ function SearchEverywhereDialog() {
|
||||
size="small"
|
||||
className={styles.headerButton}
|
||||
title={tx("Open in Find Tool Window")}
|
||||
shortcut={tab === "text" ? "⌘↵" : undefined}
|
||||
shortcut={tab === "text" ? formatBinding(hostBinding("Meta+Enter")) : undefined}
|
||||
onClick={() => closeThen(() => void executeAction("find.inPath", { query: parsed.text }))}
|
||||
/>
|
||||
</div>
|
||||
@@ -513,7 +514,7 @@ function SearchEverywhereDialog() {
|
||||
type="button"
|
||||
className={styles.magnifier}
|
||||
aria-label={tx("Search history")}
|
||||
title={tx("Search history (⌥↑ / ⌥↓)")}
|
||||
title={tx`Search history (${formatBinding("Alt+Up")} / ${formatBinding("Alt+Down")})`}
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
onClick={(e) => openHistory(e.currentTarget)}
|
||||
>
|
||||
@@ -539,9 +540,9 @@ function SearchEverywhereDialog() {
|
||||
onKeyDown={onKeyDown}
|
||||
/>
|
||||
{snap?.loading && !emptyQuery && <Spinner size={14} className={styles.spinner} />}
|
||||
{hintFor(tab) && (
|
||||
{hint && (
|
||||
<span className={styles.hint} aria-hidden>
|
||||
<T>{hintFor(tab)}</T>
|
||||
{hint}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
@@ -645,7 +646,7 @@ function describe(row: SeRow | undefined): string {
|
||||
case "action": {
|
||||
const a = getAction(row.id);
|
||||
const text = a?.description ?? (row.category ? `${row.category} | ${row.title}` : row.title);
|
||||
return `${text} · ${translate("⌥↵ to assign a shortcut")}`;
|
||||
return `${text} · ${translate`${formatBinding("Alt+Enter")} to assign a shortcut`}`;
|
||||
}
|
||||
case "setting":
|
||||
return [...row.path, row.title].map((p) => translate(p)).join(" | ");
|
||||
@@ -674,12 +675,12 @@ function previewOf(row: SeRow | undefined): { path: string; line: number; highli
|
||||
}
|
||||
}
|
||||
|
||||
function hintFor(tab: SeTab): string {
|
||||
function hintFor(tab: SeTab, tx: ReturnType<typeof useTx>): string {
|
||||
switch (tab) {
|
||||
case "all":
|
||||
return "Type / to see commands";
|
||||
return tx("Type / to see commands");
|
||||
case "text":
|
||||
return "⌘↵ to open in Find tool window";
|
||||
return tx`${formatBinding(hostBinding("Meta+Enter"))} to open in Find tool window`;
|
||||
default:
|
||||
return "";
|
||||
}
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
/* IDEA's Find in Files is a popup: Popup.background, not the dialog surface. */
|
||||
.dialog {
|
||||
max-height: min(86vh, calc(90vh - 24px));
|
||||
max-height: calc(90vh - 12px);
|
||||
background: var(--bg-popup, var(--bg-elev));
|
||||
font-family: var(--font-ui);
|
||||
}
|
||||
.dialog > :global([data-dialog-body]) {
|
||||
display: flex;
|
||||
@@ -17,13 +20,14 @@
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
height: 34px;
|
||||
padding: 0 8px 0 12px;
|
||||
height: 42px;
|
||||
padding: 0 14px 0 20px;
|
||||
border-bottom: 1px solid var(--border-popup);
|
||||
color: var(--fg-subtle);
|
||||
}
|
||||
.title {
|
||||
color: var(--fg-emphasis);
|
||||
font-weight: 500;
|
||||
color: var(--fg-strong);
|
||||
font-weight: 600;
|
||||
}
|
||||
.status {
|
||||
display: inline-flex;
|
||||
@@ -39,7 +43,8 @@
|
||||
.mask {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
height: var(--h-control-small);
|
||||
height: var(--h-control);
|
||||
background: var(--bg-field);
|
||||
border: 1px solid var(--border-strong);
|
||||
border-radius: var(--radius-small);
|
||||
padding-left: 6px;
|
||||
@@ -53,18 +58,18 @@
|
||||
border: 0;
|
||||
outline: none;
|
||||
color: var(--fg);
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--fs-small);
|
||||
}
|
||||
.fields {
|
||||
flex: none;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
padding: 0 12px 8px;
|
||||
gap: 8px;
|
||||
padding: 8px 8px 0;
|
||||
}
|
||||
.field {
|
||||
/* The query fields sit on the popup surface, framed only by their border. */
|
||||
.fields .field {
|
||||
width: 100%;
|
||||
background: transparent;
|
||||
}
|
||||
.field input {
|
||||
font-family: var(--font-mono);
|
||||
@@ -75,13 +80,15 @@
|
||||
font-size: var(--fs-small);
|
||||
padding-left: 2px;
|
||||
}
|
||||
/* Scope tabs line up with the title; the selected one is a neutral pressed pill, as in IDEA. */
|
||||
.scopeRow {
|
||||
--bg-tab-selected: var(--bg-toolbar-pressed);
|
||||
--border-tab-selected: transparent;
|
||||
flex: none;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 0 12px 0 6px;
|
||||
border-bottom: 1px solid var(--border-popup);
|
||||
padding: 8px 14px 4px 20px;
|
||||
}
|
||||
.scopeDetail {
|
||||
flex: 1;
|
||||
@@ -107,15 +114,24 @@
|
||||
.dirInput:focus {
|
||||
border-color: var(--accent);
|
||||
}
|
||||
/* Results alone (empty state) or results over the preview; grows with the popup. */
|
||||
.split {
|
||||
flex: 1 1 auto;
|
||||
height: 500px;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.split > * {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
.results {
|
||||
flex: 0 1 242px;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
height: 242px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 2px 0;
|
||||
font-family: var(--font-mono);
|
||||
font-weight: var(--fw-code);
|
||||
}
|
||||
.results > * {
|
||||
flex: 1;
|
||||
@@ -131,6 +147,8 @@
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
font-family: var(--font-mono);
|
||||
font-weight: var(--fw-code);
|
||||
}
|
||||
.rowFile {
|
||||
flex: none;
|
||||
@@ -138,7 +156,6 @@
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
color: var(--fg-subtle);
|
||||
font-family: var(--font-ui);
|
||||
}
|
||||
.rowFileActive {
|
||||
color: var(--fg-muted);
|
||||
@@ -153,22 +170,24 @@
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
height: 24px;
|
||||
padding: 0 12px;
|
||||
height: 36px;
|
||||
padding: 0 20px;
|
||||
border-top: 1px solid var(--border-popup);
|
||||
border-bottom: 1px solid var(--border-popup);
|
||||
font-size: var(--fs-small);
|
||||
color: var(--fg-subtle);
|
||||
}
|
||||
.previewPath {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.previewName {
|
||||
color: var(--fg-strong);
|
||||
margin-right: 8px;
|
||||
}
|
||||
.previewBox {
|
||||
flex: 0 1 220px;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
height: 220px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
@@ -179,9 +198,8 @@
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
min-height: 38px;
|
||||
padding: 6px 12px;
|
||||
border-top: 1px solid var(--border-popup);
|
||||
min-height: 46px;
|
||||
padding: 8px 20px;
|
||||
}
|
||||
.footer > button,
|
||||
.footer > label {
|
||||
@@ -204,3 +222,35 @@
|
||||
opacity: 0.55;
|
||||
text-decoration: line-through;
|
||||
}
|
||||
.empty {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
padding: 0 20px;
|
||||
text-align: center;
|
||||
line-height: 1.6;
|
||||
}
|
||||
.emptyOptions {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
margin-top: 1.2em;
|
||||
}
|
||||
.link {
|
||||
padding: 0;
|
||||
border: 0;
|
||||
background: none;
|
||||
color: var(--link);
|
||||
font: inherit;
|
||||
cursor: pointer;
|
||||
}
|
||||
.link:hover {
|
||||
color: var(--link-hover);
|
||||
}
|
||||
/* IDEA's disabled "Ctrl+Enter" label before Open in Find Window. */
|
||||
.okHint {
|
||||
flex: none;
|
||||
color: var(--fg-faint);
|
||||
white-space: nowrap;
|
||||
}
|
||||
@@ -3,7 +3,8 @@ import clsx from "clsx";
|
||||
import { openEditorPaths, useEditorRegistry } from "@/editor/api";
|
||||
import { executeAction } from "@/platform/actions";
|
||||
import { setContext } from "@/platform/context";
|
||||
import { useTxTemplate, tx } from "@/platform/i18n";
|
||||
import { t, useTxTemplate, tx } from "@/platform/i18n";
|
||||
import { formatBinding, hostBinding, shortcutLabel } from "@/platform/keymap";
|
||||
import { call, commands, isTauri, type SearchScope } from "@/platform/ipc";
|
||||
import { useProject } from "@/platform/project";
|
||||
import { useProjectModules } from "@/platform/projectRoots";
|
||||
@@ -15,9 +16,13 @@ import {
|
||||
IconButton,
|
||||
List,
|
||||
Menu,
|
||||
Mnemonic,
|
||||
SearchField,
|
||||
Select,
|
||||
Spinner,
|
||||
SplitGroup,
|
||||
SplitHandle,
|
||||
SplitPanel,
|
||||
Tabs,
|
||||
listNavigate,
|
||||
type MenuItem,
|
||||
@@ -59,12 +64,19 @@ import {
|
||||
hideFindPopup,
|
||||
recentMasks,
|
||||
rememberFindString,
|
||||
refreshProjectMasks,
|
||||
rememberMask,
|
||||
resolveDirectory,
|
||||
resolveFindScope,
|
||||
setFindPopupCommandHandler,
|
||||
loadFindPopupSize,
|
||||
loadFindPopupSplit,
|
||||
saveFindPopupSize,
|
||||
saveFindPopupSplit,
|
||||
setFindPopupNewTab,
|
||||
setFindPopupPinned,
|
||||
useFindPopup,
|
||||
useProjectMasks,
|
||||
type FindOptions,
|
||||
type BuiltinScope,
|
||||
type FindPopupCommand,
|
||||
@@ -89,18 +101,19 @@ async function chooseDirectory(current: string): Promise<string | null> {
|
||||
|
||||
const POPUP_MAX_MATCHES = 2000;
|
||||
const SEARCH_DEBOUNCE_MS = 150;
|
||||
const ROW = 22;
|
||||
const ROW = 24;
|
||||
|
||||
export function FindInFilesOverlay() {
|
||||
const open = useFindPopup((s) => s.open);
|
||||
return open ? <FindInFilesDialog /> : null;
|
||||
}
|
||||
|
||||
const SCOPE_TABS: Array<{ id: ScopeTab; label: string }> = [
|
||||
{ id: "project", label: "In Project" },
|
||||
{ id: "module", label: "Module" },
|
||||
{ id: "directory", label: "Directory" },
|
||||
{ id: "scope", label: "Scope" },
|
||||
/** IDEA's scope tabs and their Alt mnemonics. */
|
||||
const SCOPE_TABS: Array<{ id: ScopeTab; label: string; mnemonic: string }> = [
|
||||
{ id: "project", label: "In Project", mnemonic: "P" },
|
||||
{ id: "module", label: "Module", mnemonic: "M" },
|
||||
{ id: "directory", label: "Directory", mnemonic: "D" },
|
||||
{ id: "scope", label: "Scope", mnemonic: "S" },
|
||||
];
|
||||
|
||||
const NAMED_SCOPES: Array<{ value: BuiltinScope; label: string }> = [
|
||||
@@ -134,11 +147,18 @@ function FindInFilesDialog() {
|
||||
const [historyMenu, setHistoryMenu] = useState<"find" | "replace" | null>(null);
|
||||
const [contextMenu, setContextMenu] = useState(false);
|
||||
const [previewVersion, setPreviewVersion] = useState(0);
|
||||
// OpenIDEA's Replace › Preview: the preview shows the replacement as a diff.
|
||||
const [previewMode, setPreviewMode] = useState(false);
|
||||
const [diffPreview, setDiffPreview] = useState<{ key: string; lines: PreviewLine[] } | null>(null);
|
||||
const [initialSize] = useState(loadFindPopupSize);
|
||||
const [split, setSplit] = useState(loadFindPopupSplit);
|
||||
const keepSplit = (layout: Record<string, number>) => {
|
||||
setSplit(layout);
|
||||
saveFindPopupSplit(layout);
|
||||
};
|
||||
const [marks, setMarks] = useState<ReplaceMarks>(NO_MARKS);
|
||||
const [running, setRunning] = useState<ReplaceRun | null>(null);
|
||||
const [progress, setProgress] = useState<ReplaceProgress | null>(null);
|
||||
const [previewMode, setPreviewMode] = useState(false);
|
||||
const [diffPreview, setDiffPreview] = useState<{ key: string; lines: PreviewLine[] } | null>(null);
|
||||
const busy = !!running;
|
||||
const patternRef = useRef<HTMLInputElement>(null);
|
||||
const patternBoxRef = useRef<HTMLDivElement>(null);
|
||||
@@ -149,6 +169,11 @@ function FindInFilesDialog() {
|
||||
const set = (patch: Partial<FindOptions>) => useFindPopup.setState(patch);
|
||||
|
||||
useEffect(() => () => session.dispose(), [session]);
|
||||
const projectMasks = useProjectMasks((p) => p.masks);
|
||||
useEffect(() => void refreshProjectMasks(), []);
|
||||
/** Turning the mask on with nothing typed takes the first suggestion, as IDEA's combo box shows one. */
|
||||
const enableMask = (on: boolean) =>
|
||||
set(on && !s.mask.trim() ? { maskEnabled: true, mask: recentMasks()[0] ?? "" } : { maskEnabled: on });
|
||||
useEffect(() => {
|
||||
if (modules.length && !modules.some((m) => m.path === s.module))
|
||||
useFindPopup.setState({ module: modules[0]!.path });
|
||||
@@ -258,7 +283,11 @@ function FindInFilesDialog() {
|
||||
if (s.maskEnabled) rememberMask(s.mask);
|
||||
const pinned = buildQuery(options, scope, 0);
|
||||
close();
|
||||
openInFindWindow(pinned, describeQuery(pinned, root), s.replace ? s.replacement : null, undefined, s.preserveCase);
|
||||
openInFindWindow(pinned, describeQuery(pinned, root), {
|
||||
replacement: s.replace ? s.replacement : null,
|
||||
preserveCase: s.preserveCase,
|
||||
newTab: s.newTab,
|
||||
});
|
||||
};
|
||||
|
||||
// Check again after a confirmation: its captured rows may have been superseded.
|
||||
@@ -350,23 +379,6 @@ function FindInFilesDialog() {
|
||||
if (i >= 0) setIndex(i);
|
||||
}
|
||||
};
|
||||
const skip = () => {
|
||||
const i = nextPending(rows, rows.indexOf(chosen[chosen.length - 1]!), marks);
|
||||
if (i >= 0) setIndex(i);
|
||||
};
|
||||
const replaceInFile = async () => {
|
||||
if (!selected) return;
|
||||
const next = await replaceUsages(
|
||||
pendingUsages(
|
||||
rows.filter((r) => r.path === selected.path),
|
||||
marks,
|
||||
),
|
||||
);
|
||||
if (next) {
|
||||
const i = nextPending(rows, active, next);
|
||||
if (i >= 0) setIndex(i);
|
||||
}
|
||||
};
|
||||
const replaceAll = async (usages: Usage[]) => {
|
||||
const done = await replaceUsages(usages);
|
||||
if (done) close();
|
||||
@@ -413,19 +425,41 @@ function FindInFilesDialog() {
|
||||
| undefined;
|
||||
if (toggle) set({ [toggle]: !s[toggle] });
|
||||
else if (code === "KeyK") {
|
||||
set({ maskEnabled: !s.maskEnabled });
|
||||
enableMask(!s.maskEnabled);
|
||||
if (!s.maskEnabled) setTimeout(() => maskInputRef.current?.select(), 0);
|
||||
} else if (code === "KeyE" && s.replace) set({ preserveCase: !s.preserveCase });
|
||||
else if (code === "KeyB") setFindPopupNewTab(!s.newTab);
|
||||
else if (SCOPE_TABS.some((t) => code === `Key${t.mnemonic}`))
|
||||
selectScope(SCOPE_TABS.find((t) => code === `Key${t.mnemonic}`)!.id);
|
||||
else if (code === "KeyA" && s.replace) {
|
||||
if (rows.length && !busy) void confirmReplaceAll();
|
||||
} else return false;
|
||||
return true;
|
||||
};
|
||||
|
||||
const selectScope = (id: ScopeTab) => {
|
||||
// IDEA starts the Directory scope at the current file's folder.
|
||||
if (id === "directory" && !s.directory.trim()) {
|
||||
const active = maybeEditorService()?.activePath();
|
||||
const folders = project?.workspace?.projects ?? (root ? [root] : []);
|
||||
const dir = active && folders.some((r) => active.startsWith(r + "/")) ? dirname(active) : root;
|
||||
set({ scopeTab: "directory", directory: dir ?? "" });
|
||||
} else set({ scopeTab: id });
|
||||
};
|
||||
|
||||
/** IDEA's "Clear all options" (⇧⌥⌫): back to a plain search everywhere. */
|
||||
const clearOptions = () =>
|
||||
set({ caseSensitive: false, wholeWord: false, isRegex: false, maskEnabled: false, context: "any" });
|
||||
|
||||
const onKeyDown = (e: KeyboardEvent<HTMLDivElement>) => {
|
||||
const target = e.target as HTMLElement;
|
||||
const inField =
|
||||
(target.tagName === "INPUT" || target.tagName === "TEXTAREA") && target.closest("[data-find-field]");
|
||||
if (e.key === "Backspace" && e.altKey && e.shiftKey && !e.metaKey && !e.ctrlKey) {
|
||||
e.preventDefault();
|
||||
clearOptions();
|
||||
return;
|
||||
}
|
||||
if (e.key === "Enter" && e.altKey && e.shiftKey && !e.metaKey && !e.ctrlKey) {
|
||||
// ⇧⌥↵: Replace All.
|
||||
e.preventDefault();
|
||||
@@ -499,12 +533,75 @@ function FindInFilesDialog() {
|
||||
run: () => set({ context: c.value }),
|
||||
}));
|
||||
const pendingSelected = s.replace && !stale ? pendingUsages(chosen, marks).length : 0;
|
||||
// A multi-line query takes the arrow keys, so IDEA names the keys that still move through results.
|
||||
const nextKey = shortcutLabel("find.popup.nextResult");
|
||||
const previousKey = shortcutLabel("find.popup.previousResult");
|
||||
const navigationHint =
|
||||
s.pattern.includes("\n") && nextKey && previousKey ? tx`Use ${nextKey} and ${previousKey} to select usages` : "";
|
||||
|
||||
const resultsBox = (
|
||||
<div
|
||||
className={clsx(styles.results, stale && styles.outdated)}
|
||||
data-stale={stale || undefined}
|
||||
aria-busy={stale || state.status === "searching"}
|
||||
>
|
||||
<List<MatchRow>
|
||||
items={rows}
|
||||
getKey={(r, i) => `${r.key}:${i}`}
|
||||
activeIndex={active}
|
||||
isSelected={(r) => isRowSelected(sel, r.key)}
|
||||
onActivate={(r, i, e) => {
|
||||
if (e.type === "click" && (e as MouseEvent).detail < 2) return onRowClick(i, e as MouseEvent);
|
||||
openRow(r, e.shiftKey);
|
||||
}}
|
||||
focusable={false}
|
||||
rowHeight={ROW}
|
||||
aria-label="Matches"
|
||||
empty={
|
||||
<EmptyResults state={state} options={options} onClear={clearOptions} onRegex={() => set({ isRegex: true })} />
|
||||
}
|
||||
renderItem={(r, st) => {
|
||||
const state = s.replace ? groupState(r, marks) : "pending";
|
||||
return (
|
||||
<>
|
||||
<span
|
||||
className={clsx(
|
||||
styles.rowText,
|
||||
state === "done" && styles.rowDone,
|
||||
state === "excluded" && styles.rowExcluded,
|
||||
)}
|
||||
>
|
||||
<MatchText
|
||||
text={r.text}
|
||||
ranges={stale ? [] : r.ranges}
|
||||
source={{ path: r.path, line: r.line, col: r.textCol }}
|
||||
/>
|
||||
</span>
|
||||
<span className={clsx(styles.rowFile, st.active && styles.rowFileActive)}>
|
||||
{state === "done" && (
|
||||
<span className={styles.rowLine}>
|
||||
<T>replaced ·</T>{" "}
|
||||
</span>
|
||||
)}
|
||||
{workspace ? r.relPath : basename(r.relPath)} <span className={styles.rowLine}>{r.line + 1}</span>
|
||||
</span>
|
||||
</>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open
|
||||
onClose={close}
|
||||
width={820}
|
||||
resizable="both"
|
||||
minWidth={560}
|
||||
minHeight={400}
|
||||
initialSize={initialSize}
|
||||
onResized={saveFindPopupSize}
|
||||
position="top"
|
||||
transparentBackdrop
|
||||
// IDEA's Find in Files is a modeless dialog: it survives switching to another app.
|
||||
@@ -521,13 +618,17 @@ function FindInFilesDialog() {
|
||||
{summary}
|
||||
</span>
|
||||
<span className={styles.spacer} />
|
||||
<Checkbox checked={s.maskEnabled} onChange={(v) => set({ maskEnabled: v })} label="File mask:" />
|
||||
<Checkbox
|
||||
checked={s.maskEnabled}
|
||||
onChange={enableMask}
|
||||
label={<Mnemonic text={tx("File mask:")} char="K" />}
|
||||
/>
|
||||
<div ref={maskRef} className={clsx(styles.mask, !s.maskEnabled && styles.maskOff)}>
|
||||
<input
|
||||
ref={maskInputRef}
|
||||
className={styles.maskInput}
|
||||
value={s.mask}
|
||||
placeholder="*.kt, !*Test*"
|
||||
placeholder={projectMasks[0] ?? ""}
|
||||
aria-label={tx("File mask")}
|
||||
spellCheck={false}
|
||||
onChange={(e) => set({ mask: e.target.value, maskEnabled: true })}
|
||||
@@ -557,7 +658,7 @@ function FindInFilesDialog() {
|
||||
data-autofocus
|
||||
value={s.pattern}
|
||||
onChange={(v) => set({ pattern: v })}
|
||||
placeholder={s.isRegex ? "Regular expression" : "Text to find"}
|
||||
placeholder="Search"
|
||||
aria-label="Text to find"
|
||||
invalid={state.status === "invalid"}
|
||||
className={styles.field}
|
||||
@@ -568,7 +669,7 @@ function FindInFilesDialog() {
|
||||
id: "case",
|
||||
icon: "case",
|
||||
title: "Match Case",
|
||||
shortcut: "⌥C",
|
||||
shortcut: formatBinding("Alt+C"),
|
||||
pressed: s.caseSensitive,
|
||||
onToggle: (v) => set({ caseSensitive: v }),
|
||||
},
|
||||
@@ -576,7 +677,7 @@ function FindInFilesDialog() {
|
||||
id: "word",
|
||||
icon: "word",
|
||||
title: "Words",
|
||||
shortcut: "⌥W",
|
||||
shortcut: formatBinding("Alt+W"),
|
||||
pressed: s.wholeWord,
|
||||
onToggle: (v) => set({ wholeWord: v }),
|
||||
},
|
||||
@@ -584,7 +685,7 @@ function FindInFilesDialog() {
|
||||
id: "regex",
|
||||
icon: "regex",
|
||||
title: "Regex",
|
||||
shortcut: "⌥X",
|
||||
shortcut: formatBinding("Alt+X"),
|
||||
pressed: s.isRegex,
|
||||
onToggle: (v) => set({ isRegex: v }),
|
||||
},
|
||||
@@ -598,7 +699,7 @@ function FindInFilesDialog() {
|
||||
value={s.replacement}
|
||||
onChange={(v) => set({ replacement: v })}
|
||||
leadingIcon="replace"
|
||||
placeholder="Replace with"
|
||||
placeholder="Replace"
|
||||
aria-label="Replace with"
|
||||
className={styles.field}
|
||||
multiline
|
||||
@@ -608,7 +709,7 @@ function FindInFilesDialog() {
|
||||
id: "preserveCase",
|
||||
icon: "preserve-case",
|
||||
title: "Preserve Case",
|
||||
shortcut: "⌥E",
|
||||
shortcut: formatBinding("Alt+E"),
|
||||
pressed: s.preserveCase,
|
||||
onToggle: (v) => set({ preserveCase: v }),
|
||||
},
|
||||
@@ -621,17 +722,13 @@ function FindInFilesDialog() {
|
||||
<Tabs
|
||||
variant="compact"
|
||||
aria-label="Search scope"
|
||||
items={SCOPE_TABS.map((t) => ({ id: t.id, label: t.label }))}
|
||||
items={SCOPE_TABS.map((t) => ({
|
||||
id: t.id,
|
||||
label: <Mnemonic text={tx(t.label)} char={t.mnemonic} />,
|
||||
text: t.label,
|
||||
}))}
|
||||
activeId={s.scopeTab}
|
||||
onSelect={(id) => {
|
||||
// IDEA starts the Directory scope at the current file's folder.
|
||||
if (id === "directory" && !s.directory.trim()) {
|
||||
const active = maybeEditorService()?.activePath();
|
||||
const folders = project?.workspace?.projects ?? (root ? [root] : []);
|
||||
const dir = active && folders.some((r) => active.startsWith(r + "/")) ? dirname(active) : root;
|
||||
set({ scopeTab: "directory", directory: dir ?? "" });
|
||||
} else set({ scopeTab: id as ScopeTab });
|
||||
}}
|
||||
onSelect={(id) => selectScope(id as ScopeTab)}
|
||||
/>
|
||||
<div className={styles.scopeDetail}>
|
||||
{s.scopeTab === "directory" && (
|
||||
@@ -706,128 +803,82 @@ function FindInFilesDialog() {
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className={clsx(styles.results, stale && styles.outdated)}
|
||||
data-stale={stale || undefined}
|
||||
aria-busy={stale || state.status === "searching"}
|
||||
>
|
||||
<List<MatchRow>
|
||||
items={rows}
|
||||
getKey={(r, i) => `${r.key}:${i}`}
|
||||
activeIndex={active}
|
||||
isSelected={(r) => isRowSelected(sel, r.key)}
|
||||
onActivate={(r, i, e) => {
|
||||
if (e.type === "click" && (e as MouseEvent).detail < 2) return onRowClick(i, e as MouseEvent);
|
||||
openRow(r, e.shiftKey);
|
||||
}}
|
||||
focusable={false}
|
||||
rowHeight={ROW}
|
||||
aria-label="Matches"
|
||||
empty={<span>{emptyText(state, s.pattern)}</span>}
|
||||
renderItem={(r, st) => {
|
||||
const state = s.replace ? groupState(r, marks) : "pending";
|
||||
return (
|
||||
<>
|
||||
<span
|
||||
className={clsx(
|
||||
styles.rowText,
|
||||
state === "done" && styles.rowDone,
|
||||
state === "excluded" && styles.rowExcluded,
|
||||
)}
|
||||
>
|
||||
<MatchText
|
||||
text={r.text}
|
||||
ranges={stale ? [] : r.ranges}
|
||||
source={{ path: r.path, line: r.line, col: r.textCol }}
|
||||
active={st.active}
|
||||
replacement={!stale && s.replace && state !== "done" ? s.replacement : null}
|
||||
query={query}
|
||||
preserveCase={s.preserveCase}
|
||||
<div className={styles.split}>
|
||||
{rows.length ? (
|
||||
<SplitGroup orientation="vertical" id="find-popup-split" defaultLayout={split} onLayoutChanged={keepSplit}>
|
||||
<SplitPanel id="results" minSize={ROW * 3} defaultSize="48%">
|
||||
{resultsBox}
|
||||
</SplitPanel>
|
||||
<SplitHandle hairline={false} />
|
||||
<SplitPanel id="preview" minSize={96}>
|
||||
<div className={styles.previewHeader}>
|
||||
{selected ? (
|
||||
<span className={styles.previewPath}>
|
||||
<span className={styles.previewName}>{basename(selected.relPath)}</span>
|
||||
{selected.relPath.includes("/") && dirname(selected.relPath)}
|
||||
</span>
|
||||
) : (
|
||||
<span />
|
||||
)}
|
||||
</div>
|
||||
<div className={styles.previewBox}>
|
||||
{!stale && previewMode && s.replace && selected ? (
|
||||
<ReplaceDiffPreview
|
||||
lines={diffPreview?.key === diffPreviewKey ? diffPreview.lines : null}
|
||||
line={selected.line}
|
||||
/>
|
||||
</span>
|
||||
<span className={clsx(styles.rowFile, st.active && styles.rowFileActive)}>
|
||||
{state === "done" && (
|
||||
<span className={styles.rowLine}>
|
||||
<T>replaced ·</T>{" "}
|
||||
</span>
|
||||
)}
|
||||
{workspace ? r.relPath : basename(r.relPath)} <span className={styles.rowLine}>{r.line + 1}</span>
|
||||
</span>
|
||||
</>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className={styles.previewHeader}>
|
||||
{selected ? (
|
||||
<>
|
||||
<span className={styles.previewPath}>{selected.relPath}</span>
|
||||
<span className={styles.rowLine}>
|
||||
{selected.line + 1}:{selected.col + 1}
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
<CodePreview
|
||||
path={selected?.path ?? null}
|
||||
line={selected?.line ?? 0}
|
||||
highlights={fileHighlights}
|
||||
version={previewVersion}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</SplitPanel>
|
||||
</SplitGroup>
|
||||
) : (
|
||||
<span />
|
||||
)}
|
||||
</div>
|
||||
<div className={styles.previewBox}>
|
||||
{!stale && previewMode && s.replace && selected ? (
|
||||
<ReplaceDiffPreview
|
||||
lines={diffPreview?.key === diffPreviewKey ? diffPreview.lines : null}
|
||||
line={selected.line}
|
||||
/>
|
||||
) : (
|
||||
<CodePreview
|
||||
path={selected?.path ?? null}
|
||||
line={selected?.line ?? 0}
|
||||
highlights={fileHighlights}
|
||||
version={previewVersion}
|
||||
/>
|
||||
resultsBox
|
||||
)}
|
||||
</div>
|
||||
<footer className={styles.footer}>
|
||||
<span className={styles.hint}>
|
||||
{s.replace ? (
|
||||
<T>↵ Replace Selected · ⇧⌥↵ Replace All · ⌘↵ Open in Find Window</T>
|
||||
) : (
|
||||
<T>↵ Open · ⇧↵ Open to the side · ⌘↵ Open in Find Window</T>
|
||||
)}
|
||||
</span>
|
||||
<Checkbox
|
||||
checked={s.newTab}
|
||||
onChange={setFindPopupNewTab}
|
||||
label={<Mnemonic text={tx("Open results in new tab")} char="B" />}
|
||||
/>
|
||||
{s.replace && <Checkbox checked={previewMode} onChange={setPreviewMode} label="Preview" />}
|
||||
{navigationHint && <span className={styles.hint}>{navigationHint}</span>}
|
||||
<span className={styles.spacer} />
|
||||
{s.replace && progress && (
|
||||
<span className={styles.hint} aria-live="polite">
|
||||
{tx`Replacing… ${progress.done}/${progress.total} files`}
|
||||
</span>
|
||||
)}
|
||||
{s.replace && running && (
|
||||
<Button size="small" onClick={() => running.cancel()}>
|
||||
<Button onClick={() => running.cancel()}>
|
||||
<T>Stop</T>
|
||||
</Button>
|
||||
)}
|
||||
<span className={styles.okHint}>{formatBinding(hostBinding("Meta+Enter"))}</span>
|
||||
<Button disabled={!s.pattern} onClick={pin}>
|
||||
<T>Open in Find Window</T>
|
||||
</Button>
|
||||
{s.replace && (
|
||||
<>
|
||||
<Checkbox checked={previewMode} onChange={setPreviewMode} label="Preview" />
|
||||
<Button size="small" variant="secondary" disabled={stale || !selected || busy} onClick={skip}>
|
||||
<T>Skip</T>
|
||||
</Button>
|
||||
<Button size="small" disabled={stale || !selected || busy} onClick={() => void replaceSelected()}>
|
||||
{pendingSelected > 1 ? txt(`Replace ${pendingSelected} Occurrences`) : <T>Replace</T>}
|
||||
</Button>
|
||||
<Button size="small" disabled={stale || !selected || busy} onClick={() => void replaceInFile()}>
|
||||
<T>Replace All in This File</T>
|
||||
</Button>
|
||||
<Button
|
||||
size="small"
|
||||
title="Replace All (⇧⌥↵, ⌥A)"
|
||||
title={formatBinding("Shift+Alt+Enter")}
|
||||
disabled={stale || !rows.length || busy || state.status === "searching"}
|
||||
onClick={() => void confirmReplaceAll()}
|
||||
>
|
||||
<T>Replace All</T>
|
||||
<Mnemonic text={tx("Replace All")} char="A" />
|
||||
</Button>
|
||||
<Button variant="primary" disabled={stale || !selected || busy} onClick={() => void replaceSelected()}>
|
||||
{pendingSelected > 1 ? txt(`Replace ${pendingSelected} Occurrences`) : <T>Replace</T>}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
<Button size="small" variant={s.replace ? "secondary" : "primary"} disabled={!s.pattern} onClick={pin}>
|
||||
<T>Open in Find Window</T>
|
||||
</Button>
|
||||
</footer>
|
||||
</div>
|
||||
<Menu
|
||||
@@ -857,20 +908,89 @@ function FindInFilesDialog() {
|
||||
);
|
||||
}
|
||||
|
||||
/** IDEA's header count: shown once something matched. */
|
||||
function statusText(s: FindState): string {
|
||||
if (s.status === "invalid") return "Invalid pattern";
|
||||
if (s.status === "error") return s.error ?? "Search failed";
|
||||
if (s.status === "idle") return "";
|
||||
const files = s.files.length;
|
||||
const more = s.summary?.limitHit ? "+" : "";
|
||||
if (!s.matchCount) return s.status === "done" ? "No matches" : "Searching…";
|
||||
return `${s.matchCount}${more} match${s.matchCount === 1 ? "" : "es"} in ${files}${more} file${files === 1 ? "" : "s"}`;
|
||||
if (!s.matchCount) return "";
|
||||
const key = s.summary?.limitHit ? "search.find.matchesIncomplete" : "search.find.matches";
|
||||
return t(key, { matches: s.matchCount, files: s.files.length });
|
||||
}
|
||||
|
||||
function emptyText(s: FindState, pattern: string): string {
|
||||
if (!pattern) return "Type text to search the project";
|
||||
if (s.status === "invalid") return s.error ?? "Invalid pattern";
|
||||
if (s.status === "searching" || s.status === "validating") return "Searching…";
|
||||
if (s.status === "error") return s.error ?? "Search failed";
|
||||
return "Nothing found";
|
||||
const REGEX_CHARS = /[.$|()[\]{}^?*+\\]/;
|
||||
|
||||
/** Whether text with regex syntax in it is also a valid pattern: IDEA then offers "Search with Regex". */
|
||||
function couldBeRegex(text: string): boolean {
|
||||
if (!REGEX_CHARS.test(text)) return false;
|
||||
try {
|
||||
new RegExp(text);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** IDEA's empty results text: why nothing is listed, the search options in use and how to clear them. */
|
||||
function EmptyResults({
|
||||
state,
|
||||
options: o,
|
||||
onClear,
|
||||
onRegex,
|
||||
}: {
|
||||
state: FindState;
|
||||
options: FindOptions;
|
||||
onClear: () => void;
|
||||
onRegex: () => void;
|
||||
}) {
|
||||
const masked = o.maskEnabled && !!o.mask.trim();
|
||||
if (!o.pattern && !masked) return <div className={styles.empty}>{tx("Type search query to find in files")}</div>;
|
||||
if (state.status === "invalid") return <div className={styles.empty}>{state.error ?? tx("Invalid pattern")}</div>;
|
||||
if (state.status === "error") return <div className={styles.empty}>{state.error ?? tx("Search failed")}</div>;
|
||||
if (o.pattern && state.status !== "done") return <div className={styles.empty}>{tx("Searching…")}</div>;
|
||||
const used = [
|
||||
o.caseSensitive && { label: "Match Case", key: "Alt+C" },
|
||||
o.wholeWord && { label: "Words", key: "Alt+W" },
|
||||
o.isRegex && { label: "Regex", key: "Alt+X" },
|
||||
masked && { label: "File mask", key: "Alt+K" },
|
||||
o.context !== "any" && { label: "Filter", key: "" },
|
||||
].filter((u) => !!u);
|
||||
const regex = !!o.pattern && !o.isRegex && couldBeRegex(o.pattern);
|
||||
const clearKey = formatBinding("Shift+Alt+Backspace");
|
||||
return (
|
||||
<div className={styles.empty}>
|
||||
<div>
|
||||
{o.pattern ? tx("Nothing found") : tx("No search text entered")}
|
||||
{used.length || regex ? "." : ""}
|
||||
</div>
|
||||
{regex ? (
|
||||
<div className={styles.emptyOptions}>
|
||||
<div>
|
||||
<button type="button" className={styles.link} onClick={onRegex}>
|
||||
<T>Search with Regex</T>
|
||||
</button>{" "}
|
||||
{formatBinding("Alt+X")}
|
||||
</div>
|
||||
</div>
|
||||
) : used.length ? (
|
||||
<div className={styles.emptyOptions}>
|
||||
{used.length === 1 ? (
|
||||
<div>{tx`Search option used: ${tx(used[0]!.label)}`}</div>
|
||||
) : (
|
||||
<>
|
||||
<div>
|
||||
<T>Search options used:</T>
|
||||
</div>
|
||||
<div>
|
||||
{used.map((u) => [tx(u.label), u.key && formatBinding(u.key)].filter(Boolean).join(" ")).join(" ")}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
<div>
|
||||
<button type="button" className={styles.link} onClick={onClear}>
|
||||
{used.length === 1 ? <T>Clear option</T> : <T>Clear all options</T>}
|
||||
</button>{" "}
|
||||
{clearKey}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -3,7 +3,7 @@ import clsx from "clsx";
|
||||
import { create } from "zustand";
|
||||
import { executeAction } from "@/platform/actions";
|
||||
import type { FileMatches } from "@/platform/ipc";
|
||||
import { shortcutLabel } from "@/platform/keymap";
|
||||
import { formatBinding, hostBinding, shortcutLabel } from "@/platform/keymap";
|
||||
import {
|
||||
Button,
|
||||
EmptyState,
|
||||
@@ -53,7 +53,7 @@ import {
|
||||
type FindTab,
|
||||
} from "./windowStore";
|
||||
import styles from "./FindToolWindow.module.css";
|
||||
import { tx } from "@/platform/i18n";
|
||||
import { t, tx } from "@/platform/i18n";
|
||||
|
||||
type Node =
|
||||
| { kind: "dir"; key: string; label: string; count: number; children: Node[] }
|
||||
@@ -205,7 +205,7 @@ export function FindToolWindow() {
|
||||
if (!tab) {
|
||||
return (
|
||||
<EmptyState title="No search results" icon="search">
|
||||
{tx`Use Find in Files (${shortcutLabel("find.inPath") ?? "⇧⌘F"}) and choose “Open in Find Window”.`}
|
||||
{tx`Use Find in Files (${shortcutLabel("find.inPath") ?? formatBinding(hostBinding("Meta+Shift+F"))}) and choose “Open in Find Window”.`}
|
||||
</EmptyState>
|
||||
);
|
||||
}
|
||||
@@ -376,8 +376,10 @@ function FindTabView({ tab }: { tab: FindTab }) {
|
||||
? (st.error ?? tx("Search failed"))
|
||||
: progress
|
||||
? tx`Replacing… ${progress.done}/${progress.total} files`
|
||||
: tx`${`${st.matchCount}${st.summary?.limitHit ? "+" : ""}`} matches in ${st.files.length} files` +
|
||||
(marks.excluded.size ? ` · ${tx`${marks.excluded.size} excluded`}` : "")}
|
||||
: t(st.summary?.limitHit ? "search.find.matchesIncomplete" : "search.find.matches", {
|
||||
matches: st.matchCount,
|
||||
files: st.files.length,
|
||||
}) + (marks.excluded.size ? ` · ${tx`${marks.excluded.size} excluded`}` : "")}
|
||||
</span>
|
||||
<span className={styles.spacer} />
|
||||
<Button size="small" variant="ghost" disabled={!selection.size} onClick={exclude} title="Exclude / include (⌫)">
|
||||
@@ -543,7 +545,6 @@ function FindNodeContent({ node, state, tab }: { node: Node; state: TreeRowState
|
||||
text={node.text}
|
||||
ranges={node.ranges}
|
||||
source={{ path: node.path, line: node.line, col: node.textCol }}
|
||||
active={state.selected}
|
||||
replacement={tab.replacement}
|
||||
query={tab.query}
|
||||
preserveCase={!!tab.preserveCase}
|
||||
|
||||
@@ -2,12 +2,13 @@
|
||||
white-space: pre;
|
||||
}
|
||||
.match {
|
||||
background: var(--accent-soft);
|
||||
color: var(--fg-strong);
|
||||
background: var(--bg-search-match);
|
||||
color: var(--fg-search-match, inherit);
|
||||
border-radius: 2px;
|
||||
}
|
||||
.matchActive {
|
||||
background: color-mix(in srgb, var(--accent) 30%, transparent);
|
||||
/* IDEA paints a hit in one colour over the syntax colours, selected row or not. */
|
||||
.match :global([class*="tok-"]) {
|
||||
color: inherit;
|
||||
}
|
||||
.removed {
|
||||
color: var(--fg-subtle);
|
||||
|
||||
@@ -19,7 +19,6 @@ import { tx } from "@/platform/i18n";
|
||||
export function MatchText({
|
||||
text,
|
||||
ranges,
|
||||
active,
|
||||
replacement,
|
||||
query,
|
||||
preserveCase = false,
|
||||
@@ -27,7 +26,6 @@ export function MatchText({
|
||||
}: {
|
||||
text: string;
|
||||
ranges: ReadonlyArray<readonly [number, number]>;
|
||||
active?: boolean;
|
||||
replacement?: string | null;
|
||||
query?: Pick<SearchQuery, "pattern" | "isRegex" | "caseSensitive"> | null;
|
||||
preserveCase?: boolean;
|
||||
@@ -51,7 +49,7 @@ export function MatchText({
|
||||
);
|
||||
} else {
|
||||
parts.push(
|
||||
<mark key={i} className={clsx(styles.match, active && styles.matchActive)}>
|
||||
<mark key={i} className={styles.match}>
|
||||
{tokenSpans(text, s, e, tokens, "")}
|
||||
</mark>,
|
||||
);
|
||||
|
||||
@@ -1,6 +1,13 @@
|
||||
import { create } from "zustand";
|
||||
import type { ContextKeys } from "@/platform/context";
|
||||
import type { ProjectFileSet, SearchContext, SearchQuery, SearchScope } from "@/platform/ipc";
|
||||
import {
|
||||
call,
|
||||
commands,
|
||||
type ProjectFileSet,
|
||||
type SearchContext,
|
||||
type SearchQuery,
|
||||
type SearchScope,
|
||||
} from "@/platform/ipc";
|
||||
import { SEARCH_CONTEXTS, customScopes, findCustomScope, rememberSearch, searchHistory } from "@/platform/search";
|
||||
import { loadJson, saveJson } from "../common/open";
|
||||
|
||||
@@ -52,9 +59,12 @@ interface FindPopupState extends FindOptions {
|
||||
session: number;
|
||||
/** Title-bar pin: the popup stays open when a result is opened or the user clicks outside. */
|
||||
pinned: boolean;
|
||||
/** IDEA's "Open results in new tab": Open in Find Window keeps the active Find tab. */
|
||||
newTab: boolean;
|
||||
}
|
||||
|
||||
const PINNED_KEY = "openidea.search.findPinned.v1";
|
||||
const NEW_TAB_KEY = "openidea.search.findNewTab.v1";
|
||||
|
||||
export const useFindPopup = create<FindPopupState>(() => ({
|
||||
open: false,
|
||||
@@ -74,6 +84,7 @@ export const useFindPopup = create<FindPopupState>(() => ({
|
||||
namedScope: "projectFiles",
|
||||
context: "any",
|
||||
pinned: loadJson<unknown>(PINNED_KEY, false) === true,
|
||||
newTab: loadJson<unknown>(NEW_TAB_KEY, false) === true,
|
||||
}));
|
||||
|
||||
export function setFindPopupPinned(pinned: boolean) {
|
||||
@@ -81,6 +92,35 @@ export function setFindPopupPinned(pinned: boolean) {
|
||||
saveJson(PINNED_KEY, pinned);
|
||||
}
|
||||
|
||||
export function setFindPopupNewTab(newTab: boolean) {
|
||||
useFindPopup.setState({ newTab });
|
||||
saveJson(NEW_TAB_KEY, newTab);
|
||||
}
|
||||
|
||||
const SIZE_KEY = "openidea.search.findPopupSize.v1";
|
||||
const SPLIT_KEY = "openidea.search.findPopupSplit.v1";
|
||||
|
||||
/** The popup's size and its results / preview split, remembered like IDEA's. */
|
||||
export function loadFindPopupSize(): { w: number; h: number } | null {
|
||||
const v = loadJson<{ w?: unknown; h?: unknown } | null>(SIZE_KEY, null);
|
||||
return typeof v?.w === "number" && typeof v.h === "number" ? { w: v.w, h: v.h } : null;
|
||||
}
|
||||
|
||||
export function saveFindPopupSize(size: { w: number; h: number }) {
|
||||
saveJson(SIZE_KEY, size);
|
||||
}
|
||||
|
||||
export function loadFindPopupSplit(): Record<string, number> | undefined {
|
||||
const v = loadJson<unknown>(SPLIT_KEY, null);
|
||||
return v && typeof v === "object" && Object.values(v).every((n) => typeof n === "number")
|
||||
? (v as Record<string, number>)
|
||||
: undefined;
|
||||
}
|
||||
|
||||
export function saveFindPopupSplit(layout: Record<string, number>) {
|
||||
saveJson(SPLIT_KEY, layout);
|
||||
}
|
||||
|
||||
/**
|
||||
* Keys the popup handles that are also global shortcuts (⌘G / ⇧⌘G, F4 / ⌘↓, ⌥↓):
|
||||
* hidden actions bound later in the keymap win while the popup is open and
|
||||
@@ -138,12 +178,34 @@ export function hideFindPopup() {
|
||||
}
|
||||
|
||||
const MASKS_KEY = "openidea.search.fileMasks.v1";
|
||||
const DEFAULT_MASKS = ["*.ts,*.tsx", "*.rs", "*.kt", "*.java", "*.py", "*.go", "*.md", "*.json", "!*.test.*"];
|
||||
const PROJECT_MASKS = 5;
|
||||
|
||||
/**
|
||||
* `*.ext` masks of the project's most common language file types. IDEA seeds its
|
||||
* mask list with the IDE's languages; a multi-language IDE takes the project's.
|
||||
*/
|
||||
export const useProjectMasks = create<{ masks: string[] }>(() => ({ masks: [] }));
|
||||
|
||||
export async function refreshProjectMasks() {
|
||||
try {
|
||||
const [counts, languages] = await Promise.all([call(commands.indexFileExtensions()), commands.languages()]);
|
||||
const known = new Set(languages.flatMap((l) => l.extensions.map((e) => e.toLowerCase())));
|
||||
const masks = counts
|
||||
.filter((c) => known.has(c.extension))
|
||||
.slice(0, PROJECT_MASKS)
|
||||
.map((c) => `*.${c.extension}`);
|
||||
useProjectMasks.setState({ masks });
|
||||
} catch {
|
||||
/* no project index (welcome screen, tests) */
|
||||
}
|
||||
}
|
||||
|
||||
/** Masks to offer: the ones used recently, then the project's file types. */
|
||||
export function recentMasks(): string[] {
|
||||
const v = loadJson<unknown>(MASKS_KEY, []);
|
||||
const recent = Array.isArray(v) ? v.filter((x): x is string => typeof x === "string") : [];
|
||||
return [...recent, ...DEFAULT_MASKS.filter((m) => !recent.includes(m))];
|
||||
const project = useProjectMasks.getState().masks;
|
||||
return [...recent, ...project.filter((m) => !recent.includes(m))];
|
||||
}
|
||||
|
||||
export function rememberMask(mask: string) {
|
||||
|
||||
@@ -49,19 +49,25 @@ function patchTab(id: number, patch: Partial<FindTab>) {
|
||||
useFindWindow.setState((s) => ({ tabs: s.tabs.map((t) => (t.id === id ? { ...t, ...patch } : t)) }));
|
||||
}
|
||||
|
||||
export interface OpenInFindWindowOptions {
|
||||
replacement?: string | null;
|
||||
preserveCase?: boolean;
|
||||
/** IDEA's "Open results in new tab": keep the active tab instead of replacing it. */
|
||||
newTab?: boolean;
|
||||
deps?: FindDeps;
|
||||
}
|
||||
|
||||
export function openInFindWindow(
|
||||
query: SearchQuery,
|
||||
title: string,
|
||||
replacement: string | null = null,
|
||||
deps = ipcFindDeps(),
|
||||
preserveCase = false,
|
||||
{ replacement = null, preserveCase = false, newTab = false, deps = ipcFindDeps() }: OpenInFindWindowOptions = {},
|
||||
) {
|
||||
const id = nextTab++;
|
||||
const session = new FindSession(deps, (state) => patchTab(id, { state }));
|
||||
sessions.set(id, session);
|
||||
useFindWindow.setState((s) => {
|
||||
const tab: FindTab = { id, title, query, replacement, preserveCase, state: IDLE };
|
||||
const reused = s.tabs.find((t) => t.id === s.active && !t.pinned);
|
||||
const reused = newTab ? undefined : s.tabs.find((t) => t.id === s.active && !t.pinned);
|
||||
if (reused) {
|
||||
sessions.get(reused.id)?.dispose();
|
||||
sessions.delete(reused.id);
|
||||
@@ -82,15 +88,17 @@ export function openInFindWindow(
|
||||
|
||||
/** Use the normal Find session/tab lifecycle for live editor results. */
|
||||
export function openEditorResults(source: FindResultsSource) {
|
||||
const id = openInFindWindow(source.query, source.title, null, {
|
||||
validate: async () => {},
|
||||
nextRequestId,
|
||||
cancel: () => {},
|
||||
start: async (_request, _query, onFile) => {
|
||||
const file = source.read();
|
||||
const matches = countFileMatches(file);
|
||||
if (matches) onFile(file);
|
||||
return { files: matches ? 1 : 0, matches, limitHit: false, cancelled: false, elapsedMs: 0 };
|
||||
const id = openInFindWindow(source.query, source.title, {
|
||||
deps: {
|
||||
validate: async () => {},
|
||||
nextRequestId,
|
||||
cancel: () => {},
|
||||
start: async (_request, _query, onFile) => {
|
||||
const file = source.read();
|
||||
const matches = countFileMatches(file);
|
||||
if (matches) onFile(file);
|
||||
return { files: matches ? 1 : 0, matches, limitHit: false, cancelled: false, elapsedMs: 0 };
|
||||
},
|
||||
},
|
||||
});
|
||||
patchTab(id, { editorResults: true });
|
||||
|
||||
@@ -6,7 +6,7 @@ import { IconButton, SearchField } from "@/ui";
|
||||
import type { TerminalSearchOptions, TerminalSearchResult } from "./session";
|
||||
import { activeSession } from "./store";
|
||||
import styles from "./Terminal.module.css";
|
||||
import { hasPrimaryModifier } from "@/platform/keymap";
|
||||
import { formatBinding, hasPrimaryModifier } from "@/platform/keymap";
|
||||
import { tx } from "@/platform/i18n";
|
||||
|
||||
export const useTerminalFind = create<{ open: boolean; seq: number; query: string; options: TerminalSearchOptions }>(
|
||||
@@ -125,7 +125,7 @@ export function TerminalFindBar({ sessionKey }: { sessionKey: string | null }) {
|
||||
id: "case",
|
||||
icon: "case",
|
||||
title: "Match Case",
|
||||
shortcut: "⌥C",
|
||||
shortcut: formatBinding("Alt+C"),
|
||||
pressed: options.caseSensitive,
|
||||
onToggle: () => toggle("caseSensitive"),
|
||||
},
|
||||
@@ -133,7 +133,7 @@ export function TerminalFindBar({ sessionKey }: { sessionKey: string | null }) {
|
||||
id: "word",
|
||||
icon: "word",
|
||||
title: "Words",
|
||||
shortcut: "⌥W",
|
||||
shortcut: formatBinding("Alt+W"),
|
||||
pressed: options.wholeWord,
|
||||
onToggle: () => toggle("wholeWord"),
|
||||
},
|
||||
@@ -141,7 +141,7 @@ export function TerminalFindBar({ sessionKey }: { sessionKey: string | null }) {
|
||||
id: "regex",
|
||||
icon: "regex",
|
||||
title: "Regex",
|
||||
shortcut: "⌥X",
|
||||
shortcut: formatBinding("Alt+X"),
|
||||
pressed: options.regex,
|
||||
onToggle: () => toggle("regex"),
|
||||
},
|
||||
@@ -153,7 +153,7 @@ export function TerminalFindBar({ sessionKey }: { sessionKey: string | null }) {
|
||||
<IconButton
|
||||
icon="arrow-up"
|
||||
title="Previous Occurrence"
|
||||
shortcut="⇧↵"
|
||||
shortcut={formatBinding("Shift+Enter")}
|
||||
size="small"
|
||||
disabled={!query}
|
||||
onClick={() => find("previous")}
|
||||
|
||||
Reference in new issue
Заблокувати користувача