fix(editor): give each file its own find bar, query and caret (#1296)

The find bar belonged to the pane and every bar shared one query, and its effect re-ran the search and moved the caret whenever the active tab changed, so switching files dragged the previous file's search into the next one and lost its caret. Each tab now has its own find bar with its own query and options, as with IDEA's per-editor search session. The last search only pre-fills a newly opened bar (or the selection does) and drives Find Next without a bar; showing a bar again only highlights, and the caret moves only when the query or an option changes in that bar. Bars of closed tabs are dropped.

Co-authored-by: jaw52 <53564781+jaw52@users.noreply.github.com>
This commit is contained in:
Jaw
2026-11-04 23:11:05 +08:00
committed by GitHub
parent d032addb17
commit b31edc3482
5 changed files with 212 additions and 67 deletions
+43
View File
@@ -26,6 +26,49 @@ test("⌘F opens the find bar and highlights matches; Escape returns to the edit
await expect(editor.locator(".cm-content")).toBeFocused();
});
test("each file keeps its own find bar, query and caret", async ({ workbench: page }) => {
const find = page.getByRole("textbox", { name: "Find" });
const caret = page.getByRole("button", { name: /^\d+:\d+/ });
const tab = (name: string) => page.getByRole("tablist", { name: "Editor tabs" }).getByRole("tab", { name });
const a = await openFromTree(page, "build.gradle.kts");
await a.locator(".cm-content").click();
await page.keyboard.press("Meta+f");
await find.fill("Order");
await expect(caret).toHaveText(/\(5 chars\)/);
const caretA = await caret.textContent();
// Another file has no bar until ⌘F; opening one starts from the last search without moving the caret.
const b = await openFromTree(page, "settings.gradle.kts");
await expect(find).toBeHidden();
await b.locator(".cm-line", { hasText: "@PostMapping" }).click();
const caretB = await caret.textContent();
await page.keyboard.press("Meta+f");
await expect(find).toHaveValue("Order");
await expect(caret).toHaveText(caretB!);
await find.fill("status");
await expect(caret).toHaveText(/\(6 chars\)/);
const caretB2 = await caret.textContent();
// A third file starts from the latest search.
await openFromTree(page, "README.md");
await expect(find).toBeHidden();
await page.keyboard.press("Meta+f");
await expect(find).toHaveValue("status");
// Switching back shows each file's own query, caret untouched.
await tab("build.gradle.kts").click();
await expect(find).toHaveValue("Order");
await expect(caret).toHaveText(caretA!);
await tab("settings.gradle.kts").click();
await expect(find).toHaveValue("status");
await expect(caret).toHaveText(caretB2!);
await find.press("Escape");
await expect(find).toBeHidden();
await tab("build.gradle.kts").click();
await expect(find).toHaveValue("Order");
});
test("opening several files creates tabs and ⌘W closes the active one", async ({ workbench: page }) => {
await openFromTree(page, "build.gradle.kts");
await openFromTree(page, "settings.gradle.kts");
+5 -4
View File
@@ -143,7 +143,7 @@ export interface EditorStatus {
export const useEditorStatus = create<{ status: EditorStatus | null }>(() => ({ status: null }));
/** Per-tab open errors (file missing, too large…). */
export const useTabErrors = create<Record<string, string>>(() => ({}));
/** Per-pane find bar state (open / replace mode); the bar keeps its own query. */
/** Per-tab find bar state (open / replace mode), like IDEA's per-editor search session. */
export const useFindBars = create<Record<string, { replace: boolean; seq: number } | undefined>>(() => ({}));
export const useGotoLine = create<{ open: boolean }>(() => ({ open: false }));
@@ -387,6 +387,8 @@ export class EditorController implements SlotHost {
slot.dispose();
}
}
const bars = Object.entries(useFindBars.getState()).filter(([id, bar]) => bar && !live.has(id));
if (bars.length) useFindBars.setState(Object.fromEntries(bars.map(([id]) => [id, undefined])));
for (const id of Object.keys(useTabErrors.getState()))
if (!live.has(id))
useTabErrors.setState((s) => {
@@ -629,9 +631,8 @@ export class EditorController implements SlotHost {
}
onEscape(slot: EditorSlot): boolean {
const loc = this.locateTab(slot.tabId);
if (loc && useFindBars.getState()[loc.paneId]) {
useFindBars.setState({ [loc.paneId]: undefined });
if (useFindBars.getState()[slot.tabId]) {
useFindBars.setState({ [slot.tabId]: undefined });
return true;
}
return false;
+65 -1
View File
@@ -9,7 +9,7 @@ import { getContext } from "@/platform/context";
import { provideFindResults } from "@/workbench/contributions";
import { registerEditorActions } from "../actions";
import { findExtension, findResultLines } from "../cm/find";
import { FindPanel, useFindOptions, useFindPreferences, type FindTarget } from "./FindBar";
import { FindPanel, seedFind, useFindOptions, useFindPreferences, useFindSessions, type FindTarget } from "./FindBar";
const disposers: Array<() => void> = [];
afterEach(() => {
@@ -18,6 +18,7 @@ afterEach(() => {
.reverse()
.forEach((dispose) => dispose());
useFindOptions.setState({ search: "", regexp: false, caseSensitive: false, wholeWord: false });
useFindSessions.setState({}, true);
useFindPreferences.setState({ scrollToResults: true });
useKeymap.setState({ preset: "intellij-macos", overrides: {} });
});
@@ -147,3 +148,66 @@ describe("Find bar More actions", () => {
expect(view.state.selection.main.empty).toBe(false);
});
});
describe("Find bars on different editors", () => {
function editor(id: string, doc: string) {
const view = new EditorView({
parent: document.body,
state: EditorState.create({ doc, extensions: [findExtension()] }),
});
const target: FindTarget = { id, view: () => view, watch: () => () => {}, isOpen: () => true };
disposers.push(() => view.destroy());
return { view, target };
}
const panel = (target: FindTarget) => {
const r = render(
<FindPanel key={target.id} target={target} version={target.id} replace={false} seq={0} onClose={() => {}} />,
);
return { ...r, field: screen.getByRole("textbox", { name: "Find" }) };
};
it("keeps each editor's query and caret", () => {
const a = editor("a", "alpha\nbeta\nalpha");
const b = editor("b", "one\ntwo\nalpha\nthree");
disposers.push(registerEditorActions(), installKeyboardDispatcher());
seedFind(a.target, false);
const barA = panel(a.target);
setSearch(barA.field, "alpha");
expect(a.view.state.sliceDoc(a.view.state.selection.main.from, a.view.state.selection.main.to)).toBe("alpha");
barA.unmount();
// B has its caret on "two"; opening its bar starts from A's search without moving the caret.
b.view.dispatch({ selection: { anchor: 5 } });
seedFind(b.target, false);
const barB = panel(b.target);
expect(barB.field).toHaveValue("alpha");
expect(b.view.state.selection.main.head).toBe(5);
setSearch(barB.field, "three");
expect(b.view.state.sliceDoc(b.view.state.selection.main.from, b.view.state.selection.main.to)).toBe("three");
barB.unmount();
// Back on A: its own query, and showing the bar again leaves the caret where it was.
a.view.dispatch({ selection: { anchor: 8 } });
const againA = panel(a.target);
expect(againA.field).toHaveValue("alpha");
expect(a.view.state.selection.main.head).toBe(8);
expect(screen.getByText("?/2")).toBeInTheDocument();
againA.unmount();
expect(useFindSessions.getState().b?.search).toBe("three");
// A new bar starts from the last search, from whichever editor it came.
expect(useFindOptions.getState().search).toBe("three");
});
it("re-seeds an open bar from a new selection without touching other bars", () => {
const a = editor("a", "alpha beta");
const b = editor("b", "gamma");
seedFind(b.target, false);
act(() => useFindSessions.setState({ b: { ...useFindSessions.getState().b!, search: "gamma" } }));
seedFind(a.target, false);
a.view.dispatch({ selection: { anchor: 6, head: 10 } });
seedFind(a.target, false);
expect(useFindSessions.getState().a?.search).toBe("beta");
expect(useFindSessions.getState().b?.search).toBe("gamma");
});
});
+93 -61
View File
@@ -41,7 +41,10 @@ import { relativePath, useProject } from "@/platform/project";
import { tx } from "@/platform/i18n";
import { bindingsFor, hasPrimaryModifier, normalizeBinding, registerKeyClaim, shortcutLabel } from "@/platform/keymap";
/** The query is shared by all panes and survives closing the bar, like IDEA. */
/**
* The last search (IDEA's find-in-file model): a newly opened bar starts from it and ⌘G
* uses it without a bar. Each open bar then keeps its own copy (`useFindSessions`).
*/
export const useFindOptions = create<Required<FindOptions>>(() => ({
search: "",
caseSensitive: false,
@@ -51,6 +54,21 @@ export const useFindOptions = create<Required<FindOptions>>(() => ({
context: "any",
preserveCase: false,
}));
/** The query and options of each open bar, by target id: bars on other files don't share them. */
export const useFindSessions = create<Record<string, Required<FindOptions> | undefined>>(() => ({}));
function optionsOf(id: string): Required<FindOptions> {
return useFindSessions.getState()[id] ?? useFindOptions.getState();
}
/** Changes a bar's options; like IDEA, the last search follows them. */
function updateOptions(id: string, patch: (o: Required<FindOptions>) => Partial<FindOptions>) {
const current = optionsOf(id);
const next = { ...current, ...patch(current) };
useFindSessions.setState({ [id]: next });
useFindOptions.setState(next);
}
interface Counts {
total: number;
current: number;
@@ -76,31 +94,26 @@ export interface FindTarget {
results?(options: FindOptions): FindResultsSource | null;
}
function activeView(paneId: string): EditorView | null {
const pane = useTabs.getState().panes[paneId];
if (!pane?.active) return null;
return editorController().slotFor(pane.active)?.view ?? null;
}
const activeTab = (paneId: string) => useTabs.getState().panes[paneId]?.active ?? null;
const paneTargets = new Map<string, FindTarget>();
const tabTargets = new Map<string, FindTarget>();
function paneTarget(paneId: string): FindTarget {
let target = paneTargets.get(paneId);
/** The editor of a tab: each tab has its own bar, like IDEA's per-editor search session. */
function tabTarget(tabId: string): FindTarget {
let target = tabTargets.get(tabId);
if (!target) {
target = {
id: paneId,
view: () => activeView(paneId),
id: tabId,
view: () => editorController().slotFor(tabId)?.view ?? null,
watch(fn) {
const tab = useTabs.getState().panes[paneId]?.active;
const slot = tab ? editorController().slotFor(tab) : null;
const slot = editorController().slotFor(tabId);
if (!slot) return () => {};
slot.listeners.add(fn);
return () => void slot.listeners.delete(fn);
},
isOpen: () => !!useFindBars.getState()[paneId],
isOpen: () => !!useFindBars.getState()[tabId],
results(options) {
const tab = useTabs.getState().panes[paneId]?.active;
const slot = tab ? editorController().slotFor(tab) : null;
const slot = editorController().slotFor(tabId);
if (!slot) return null;
const state = slot.currentState();
const scope = state.field(findScope, false) ?? { from: 0, to: state.doc.length };
@@ -136,7 +149,7 @@ function paneTarget(paneId: string): FindTarget {
};
},
};
paneTargets.set(paneId, target);
tabTargets.set(tabId, target);
}
return target;
}
@@ -151,19 +164,16 @@ export function findPanelTarget(arg?: unknown): FindTarget | undefined {
export function openFindBarResults(arg?: unknown) {
const target = findPanelTarget(arg);
const opts = useFindOptions.getState();
if (!target) return;
const opts = optionsOf(target.id);
if (!opts.search || (opts.regexp && !regexValid(opts.search))) return;
const source = target?.results?.({ ...opts });
const source = target.results?.({ ...opts });
if (source?.read().matches.length) {
rememberQuery(false);
rememberQuery(target.id, false);
showFindResults(source);
}
}
export function closeFindBar(paneId: string) {
useFindBars.setState({ [paneId]: undefined });
}
/** Longest selection that becomes the query. */
const MAX_QUERY = 10_000;
@@ -174,48 +184,59 @@ const MAX_QUERY = 10_000;
* multi-line query isn't kept for a later search without a selection.
*/
export function openFindBar(paneId: string, replace: boolean) {
seedFind(paneTarget(paneId), replace);
useFindBars.setState((s) => ({ [paneId]: { replace, seq: (s[paneId]?.seq ?? 0) + 1 } }));
const tabId = activeTab(paneId);
if (!tabId) return;
seedFind(tabTarget(tabId), replace);
useFindBars.setState((s) => ({ [tabId]: { replace, seq: (s[tabId]?.seq ?? 0) + 1 } }));
}
/** The query and scope a bar opening over `target`'s selection starts with (see openFindBar). */
/**
* The query and scope a bar opening over `target`'s selection starts with (see openFindBar).
* As in IDEA, a new bar copies the last search; an open one keeps its own query.
*/
export function seedFind(target: FindTarget, replace: boolean) {
const view = target.view();
const sel = view?.state.selection.main;
const open = !!useFindSessions.getState()[target.id];
const set = (patch: (o: Required<FindOptions>) => Partial<FindOptions>) => {
if (open) updateOptions(target.id, patch);
else
useFindSessions.setState({ [target.id]: { ...useFindOptions.getState(), ...patch(useFindOptions.getState()) } });
};
if (view && sel && !sel.empty) {
const text = view.state.sliceDoc(sel.from, sel.to);
const multiline = text.includes("\n");
openSelections.set(target.id, { from: sel.from, to: sel.to });
if (multiline && replace) {
view.dispatch({ effects: setFindScope.of({ from: sel.from, to: sel.to }) });
useFindOptions.setState({ search: "" });
set(() => ({ search: "" }));
} else {
view.dispatch({ effects: setFindScope.of(null) });
if (text.length < MAX_QUERY)
useFindOptions.setState((o) => ({ search: o.regexp ? text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&") : text }));
set((o) => ({ search: o.regexp ? text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&") : text }));
else set(() => ({}));
}
} else if (useFindOptions.getState().search.includes("\n")) {
useFindOptions.setState({ search: "" });
}
} else set((o) => (o.search.includes("\n") ? { search: "" } : {}));
}
/** Show / Hide Replace on the open bar, keeping its query and scope. */
function setReplaceShown(paneId: string, replace: boolean) {
useFindBars.setState((s) => ({ [paneId]: { replace, seq: s[paneId]?.seq ?? 0 } }));
function setReplaceShown(tabId: string, replace: boolean) {
useFindBars.setState((s) => ({ [tabId]: { replace, seq: s[tabId]?.seq ?? 0 } }));
}
/** ⌘G / ⇧⌘G: next / previous occurrence of the last search, with or without the bar. */
/** ⌘G / ⇧⌘G: next / previous occurrence of the bar's search, or of the last one without a bar. */
export function findAgain(paneId: string, forward: boolean) {
const target = paneTarget(paneId);
const view = target.view();
if (!view) return;
const tabId = activeTab(paneId);
if (!tabId) return;
const target = tabTarget(tabId);
if (!target.view()) return;
if (!searchAgain(target, forward)) openFindBar(paneId, false);
}
/** Next / previous occurrence of the last search in `target`; false without a usable query. */
/** Next / previous occurrence of `target`'s search; false without a usable query. */
export function searchAgain(target: FindTarget, forward: boolean): boolean {
const view = target.view();
const opts = useFindOptions.getState();
const opts = optionsOf(target.id);
if (!view || !opts.search || (opts.regexp && !regexValid(opts.search))) return false;
setQuery(view, opts);
if (forward) findNext(view);
@@ -244,7 +265,7 @@ function toggleScope(target: FindTarget) {
function recount(target: FindTarget) {
const view = target.view();
if (!view) return;
const opts = useFindOptions.getState();
const opts = optionsOf(target.id);
const r = countMatches(view.state);
const { from, to } = view.state.selection.main;
useFindCounts.setState({ [target.id]: opts.search ? { ...r, excluded: isExcluded(view.state, from, to) } : null });
@@ -252,26 +273,28 @@ function recount(target: FindTarget) {
view.dispatch({ effects: setFindMarks.of(opts.search ? r.starts : []) });
}
function rememberQuery(replace: boolean) {
const o = useFindOptions.getState();
function rememberQuery(id: string, replace: boolean) {
const o = optionsOf(id);
rememberSearch("find", o.search);
if (replace) rememberSearch("replace", o.replace);
}
export function FindBar({ paneId }: { paneId: string }) {
const bar = useFindBars((s) => s[paneId]);
const activeTab = useTabs((s) => s.panes[paneId]?.active ?? null);
if (!bar) return null;
const tabId = useTabs((s) => s.panes[paneId]?.active ?? null);
const bar = useFindBars((s) => (tabId ? s[tabId] : undefined));
if (!tabId || !bar) return null;
const target = tabTarget(tabId);
return (
<FindPanel
target={paneTarget(paneId)}
version={activeTab}
key={tabId}
target={target}
version={tabId}
replace={bar.replace}
seq={bar.seq}
onReplaceShown={(replace) => setReplaceShown(paneId, replace)}
onReplaceShown={(replace) => setReplaceShown(tabId, replace)}
onClose={() => {
closeFindBar(paneId);
activeView(paneId)?.focus();
useFindBars.setState({ [tabId]: undefined });
target.view()?.focus();
}}
/>
);
@@ -292,7 +315,8 @@ export interface FindPanelProps {
export function FindPanel({ target, version, replace, seq, onReplaceShown, onClose }: FindPanelProps) {
const id = target.id;
const opts = useFindOptions();
const lastOptions = useFindOptions();
const opts = useFindSessions((s) => s[id]) ?? lastOptions;
const count = useFindCounts((s) => s[id] ?? null);
const scoped = useFindScoped((s) => !!s[id]);
const inputRef = useRef<HTMLInputElement>(null);
@@ -306,7 +330,7 @@ export function FindPanel({ target, version, replace, seq, onReplaceShown, onClo
const filterRef = useRef<HTMLButtonElement>(null);
const [historyMenu, setHistoryMenu] = useState<"find" | "replace" | null>(null);
const [filterMenu, setFilterMenu] = useState(false);
const setOpts = (patch: Partial<FindOptions>) => useFindOptions.setState(patch);
const setOpts = (patch: Partial<FindOptions>) => updateOptions(id, () => patch);
useLayoutEffect(() => {
panelTargets.set(id, target);
@@ -353,14 +377,19 @@ export function FindPanel({ target, version, replace, seq, onReplaceShown, onClo
inputRef.current?.select();
}, [seq]);
// Push the query into the editor; incremental search selects the nearest match.
// Push the query into the editor. Like IDEA, only a change of the query (typing, options)
// selects the nearest match: showing the bar again over its editor leaves the caret alone.
const shown = useRef<{ target: FindTarget; version: unknown; opts: FindOptions } | null>(null);
useEffect(() => {
const view = target.view();
if (!view) return;
const prev = shown.current;
const changed = prev?.target === target && prev.version === version && prev.opts !== opts;
shown.current = { target, version, opts };
const valid = !opts.regexp || regexValid(opts.search);
setQuery(view, valid ? opts : { ...opts, search: "" });
view.dispatch({ effects: setFindOpen.of(true) });
if (valid && opts.search && useFindPreferences.getState().scrollToResults) {
if (changed && valid && opts.search && useFindPreferences.getState().scrollToResults) {
const from = view.state.selection.main.from;
view.dispatch({ selection: { anchor: from } });
findNext(view);
@@ -385,13 +414,16 @@ export function FindPanel({ target, version, replace, seq, onReplaceShown, onClo
// Closing clears highlights, marks and the selection scope (not React's dev-mode remount).
useEffect(
() => () => {
if (!target.isOpen()) target.view()?.dispatch({ effects: [setFindOpen.of(false)] });
if (target.isOpen()) return;
target.view()?.dispatch({ effects: [setFindOpen.of(false)] });
useFindSessions.setState({ [target.id]: undefined });
tabTargets.delete(target.id);
},
[target],
);
const close = () => {
rememberQuery(replace);
rememberQuery(id, replace);
onClose();
};
const run = (cmd: (v: EditorView) => boolean) => {
@@ -400,15 +432,15 @@ export function FindPanel({ target, version, replace, seq, onReplaceShown, onClo
recount(target);
};
const doReplace = () => {
rememberQuery(true);
rememberQuery(id, true);
run((v) => replaceNext(v, opts.preserveCase));
};
const doReplaceAll = () => {
rememberQuery(true);
rememberQuery(id, true);
run((v) => replaceAll(v, opts.preserveCase));
};
const toggle = (key: "caseSensitive" | "wholeWord" | "regexp" | "preserveCase") =>
useFindOptions.setState((o) => ({ [key]: !o[key] }));
updateOptions(id, (o) => ({ [key]: !o[key] }));
const onKeyDown = (e: KeyboardEvent, field: "find" | "replace") => {
const input = e.target as HTMLTextAreaElement | HTMLInputElement;
@@ -428,7 +460,7 @@ export function FindPanel({ target, version, replace, seq, onReplaceShown, onClo
if (hasPrimaryModifier(e)) doReplaceAll();
else doReplace();
} else {
if (e.key === "Enter") rememberQuery(false);
if (e.key === "Enter") rememberQuery(id, false);
run(back ? findPrevious : findNext);
}
} else if (e.altKey && !e.metaKey && !e.ctrlKey) {
+6 -1
View File
@@ -4,6 +4,7 @@ import userEvent from "@testing-library/user-event";
import { EditorView } from "@codemirror/view";
import type { EditorInput } from "@/workbench/contributions";
import { useKeymap } from "@/platform/keymap";
import { useFindOptions, useFindSessions } from "@/editor/core/view/FindBar";
const texts: Record<string, string> = { HEAD: "foo\nbar\nfoo\n", abc123: "foo\nbaz\nfoo\n" };
@@ -72,7 +73,11 @@ const press = async (index: number, code: string, shift = false) => {
return v;
};
afterEach(() => useKeymap.setState({ overrides: {} }));
afterEach(() => {
useKeymap.setState({ overrides: {} });
useFindOptions.setState({ search: "", replace: "" });
useFindSessions.setState({}, true);
});
describe("Find in the diff viewer", () => {
it("preserves external focus when loading and changing a working-tree preview", async () => {