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]>
Цей коміт міститься в:
fuqiuluoтаfuqiuluo authored and GitHub committed 2026-11-05 02:45:36 +08:00
1 джерело 5d19baf06b
коміт 948e93e1a2
48 files changed
+1067 -361

No files matched your search

+5 -5
Переглянути файл
@@ -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}
+30 -2
Переглянути файл
@@ -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 });
}
});
});
+46
Переглянути файл
@@ -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"]);
});
});
+164 -9
Переглянути файл
@@ -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);
});
});
+11 -4
Переглянути файл
@@ -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", () => {
+4 -2
Переглянути файл
@@ -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);
+11 -10
Переглянути файл
@@ -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 "";
}
+77 -27
Переглянути файл
@@ -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;
}
+278 -158
Переглянути файл
@@ -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>
);
}
+7 -6
Переглянути файл
@@ -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}
+5 -4
Переглянути файл
@@ -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);
+1 -3
Переглянути файл
@@ -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>,
);
+65 -3
Переглянути файл
@@ -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) {
+21 -13
Переглянути файл
@@ -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 });
+5 -5
Переглянути файл
@@ -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")}