diff --git a/e2e/editor.spec.ts b/e2e/editor.spec.ts index 4f609c9a..6f6a79d5 100644 --- a/e2e/editor.spec.ts +++ b/e2e/editor.spec.ts @@ -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"); diff --git a/src/editor/core/controller.ts b/src/editor/core/controller.ts index 4291d070..7e9cd19b 100644 --- a/src/editor/core/controller.ts +++ b/src/editor/core/controller.ts @@ -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>(() => ({})); -/** 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>(() => ({})); 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; diff --git a/src/editor/core/view/FindBar.test.tsx b/src/editor/core/view/FindBar.test.tsx index fbe16630..20edece8 100644 --- a/src/editor/core/view/FindBar.test.tsx +++ b/src/editor/core/view/FindBar.test.tsx @@ -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( + {}} />, + ); + 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"); + }); +}); diff --git a/src/editor/core/view/FindBar.tsx b/src/editor/core/view/FindBar.tsx index 438bee70..fd926415 100644 --- a/src/editor/core/view/FindBar.tsx +++ b/src/editor/core/view/FindBar.tsx @@ -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>(() => ({ search: "", caseSensitive: false, @@ -51,6 +54,21 @@ export const useFindOptions = create>(() => ({ 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 | undefined>>(() => ({})); + +function optionsOf(id: string): Required { + 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) => Partial) { + 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(); +const tabTargets = new Map(); -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) => Partial) => { + 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 ( 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(null); @@ -306,7 +330,7 @@ export function FindPanel({ target, version, replace, seq, onReplaceShown, onClo const filterRef = useRef(null); const [historyMenu, setHistoryMenu] = useState<"find" | "replace" | null>(null); const [filterMenu, setFilterMenu] = useState(false); - const setOpts = (patch: Partial) => useFindOptions.setState(patch); + const setOpts = (patch: Partial) => 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) { diff --git a/src/features/vcs/__tests__/diffFind.test.tsx b/src/features/vcs/__tests__/diffFind.test.tsx index a42649bc..8931ed3d 100644 --- a/src/features/vcs/__tests__/diffFind.test.tsx +++ b/src/features/vcs/__tests__/diffFind.test.tsx @@ -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 = { 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 () => {