From d4b354732c10dc9cb47e6495ba428f4413a15cef Mon Sep 17 00:00:00 2001 From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Date: Sat, 31 Oct 2026 12:09:18 +0800 Subject: [PATCH] feat(find): open in-file results in the Find window (#527) The in-file Find bar had no way to see all matches at once. Its new More menu adds Open Results in Find Window (Alt+F7 while the bar has focus), which lists the current query's matches as an Occurrences tab in the Find tool window, using the open editor text and the case, word, regex and selection options. The menu also has the multiple-cursor occurrence actions and a Scroll to Results During Typing toggle. Fixes #523 --- src/editor/core/actions.ts | 39 ++++- src/editor/core/cm/find.test.ts | 116 ++++++++++++++ src/editor/core/cm/find.ts | 24 +++ src/editor/core/view/FindBar.test.tsx | 149 +++++++++++++++++ src/editor/core/view/FindBar.tsx | 151 +++++++++++++++++- .../search/__tests__/editorResults.test.tsx | 45 ++++++ .../search/__tests__/findWindow.test.ts | 31 +++- src/features/search/find/FindToolWindow.tsx | 12 +- src/features/search/find/session.ts | 5 +- src/features/search/find/windowStore.ts | 22 ++- src/features/search/index.ts | 5 +- src/platform/keymaps/intellij-macos.ts | 1 + src/platform/keymaps/intellij-windows.ts | 1 + src/platform/keymaps/vscode-macos.ts | 1 + src/platform/locales/text/zh.json | 7 +- src/workbench/contributions.ts | 22 ++- 16 files changed, 613 insertions(+), 18 deletions(-) create mode 100644 src/editor/core/view/FindBar.test.tsx create mode 100644 src/features/search/__tests__/editorResults.test.tsx diff --git a/src/editor/core/actions.ts b/src/editor/core/actions.ts index 8de51c9e9..ee4ee2c27 100644 --- a/src/editor/core/actions.ts +++ b/src/editor/core/actions.ts @@ -31,7 +31,7 @@ import { type SimpleRange, } from "./model/selectionStack"; import type { EditorSlot } from "./model/slot"; -import { findAgain, openFindBar } from "./view/FindBar"; +import { findAgain, findPanelTarget, openFindBar, openFindBarResults, useFindPreferences } from "./view/FindBar"; import { collapseAll, collapseRecursively, @@ -479,6 +479,19 @@ export function registerEditorActions(): () => void { const pane = s.panes[s.activePane]; return pane?.active ? { paneId: pane.id, tabId: pane.active } : null; }; + const occurrence = (id: string, title: string, run: (view: EditorView) => unknown): Action => ({ + id, + title, + category: "Edit", + when: `${TEXT_WHEN} || findBarFocus || findBarOpen && popupOpen`, + run: ({ arg, ctx }) => { + const view = typeof arg === "string" || ctx.findBarFocus ? findPanelTarget(arg)?.view() : null; + if (view) { + run(view); + view.focus(); + } else withView(run)(); + }, + }); return registerActions([ schemeSwitch("editor.toggleSemanticHighlighting", "Semantic Highlighting", "semanticHighlighting", [ "rainbow variables", @@ -552,10 +565,8 @@ export function registerEditorActions(): () => void { category: "Folding", keywords: ["unfold"], }), - text("code.addCaretNextOccurrence", "Add Selection for Next Occurrence", (v) => addNextOccurrence(v), { - category: "Edit", - }), - text("code.selectAllOccurrences", "Select All Occurrences", (v) => selectAllOccurrences(v), { category: "Edit" }), + occurrence("code.addCaretNextOccurrence", "Add Selection for Next Occurrence", addNextOccurrence), + occurrence("code.selectAllOccurrences", "Select All Occurrences", selectAllOccurrences), text("code.completeStatement", "Complete Current Statement", (v, slot) => completeCurrentStatement(v, slot)), text("code.startNewLine", "Start New Line", (v) => startNewLine(v)), text("code.startNewLineBefore", "Start New Line Before Current", (v) => startNewLineBefore(v)), @@ -623,7 +634,7 @@ export function registerEditorActions(): () => void { text("code.addCaretsToLineEnds", "Add Carets to Ends of Selected Lines", (v) => addCaretsToLineEnds(v), { category: "Edit", }), - text("code.unselectOccurrence", "Unselect Occurrence", (v) => unselectOccurrence(v), { category: "Edit" }), + occurrence("code.unselectOccurrence", "Unselect Occurrence", unselectOccurrence), text("code.pasteFromHistory", "Paste from History…", (v) => pasteFromHistory(v), { category: "Edit", keywords: ["clipboard", "paste multiple"], @@ -717,6 +728,22 @@ export function registerEditorActions(): () => void { if (slot) toggleReadOnly(slot.model); }, }, + { + id: "find.openInWindow", + title: "Open Results in Find Window", + category: "Find", + when: "findBarHasResults", + keyWhen: "findBarFocus", + run: ({ arg }) => openFindBarResults(arg), + }, + { + id: "find.scrollToResults", + title: "Scroll to Results During Typing", + category: "Find", + when: "findBarOpen", + toggled: () => useFindPreferences.getState().scrollToResults, + run: () => useFindPreferences.setState((s) => ({ scrollToResults: !s.scrollToResults })), + }, { id: "find.inFile", title: "Find", diff --git a/src/editor/core/cm/find.test.ts b/src/editor/core/cm/find.test.ts index 091e5adf9..b56e7fd5d 100644 --- a/src/editor/core/cm/find.test.ts +++ b/src/editor/core/cm/find.test.ts @@ -6,6 +6,7 @@ import { StreamLanguage } from "@codemirror/language"; import { literalKindAt } from "./literals"; import { countMatches, + findResultLines, excludeCurrent, findExcluded, isExcluded, @@ -119,6 +120,17 @@ describe("search context, exclude and preserve case", () => { } const starts = (v: EditorView) => countMatches(v.state).starts; + it.each(["inComments", "inStringLiterals", "exceptComments", "exceptCommentsAndStringLiterals"] as const)( + "exports the same %s syntax filter as the bar", + (context) => { + const v = makeCode({ context }); + const lines = findResultLines(v.state, { ...base, search: "foo", context }); + expect(lines.flatMap((m) => m.ranges.map(([from]) => v.state.doc.line(m.line + 1).from + from))).toEqual( + starts(v), + ); + }, + ); + it("filters matches by comment / string literal", () => { expect(starts(makeCode({}))).toEqual([0, 5, 14, 18]); expect(starts(makeCode({ context: "inComments" }))).toEqual([14]); @@ -160,3 +172,107 @@ describe("search context, exclude and preserve case", () => { expect(sel(w)).toEqual([4, 7]); }); }); + +describe("in-file Find result lines", () => { + it.each([ + [ + { search: "foo" }, + [ + [ + 0, + [ + [3, 6], + [7, 10], + [11, 14], + ], + ], + [1, [[0, 3]]], + ], + ], + [ + { search: "foo", caseSensitive: true }, + [ + [ + 0, + [ + [3, 6], + [11, 14], + ], + ], + [1, [[0, 3]]], + ], + ], + [ + { search: "foo", wholeWord: true }, + [ + [ + 0, + [ + [3, 6], + [7, 10], + ], + ], + [1, [[0, 3]]], + ], + ], + [ + { search: "f.o", regexp: true, caseSensitive: true }, + [ + [ + 0, + [ + [3, 6], + [11, 14], + ], + ], + [1, [[0, 3]]], + ], + ], + [{ search: "missing" }, []], + [{ search: "" }, []], + [{ search: "[", regexp: true }, []], + ] as const)("preserves matching options %j and UTF-16 columns", (options, expected) => { + const v = make("😀 foo FOO foobar\nfoo", options); + expect(findResultLines(v.state, { ...useOptions, ...options }).map((m) => [m.line, m.ranges])).toEqual(expected); + }); + + const useOptions: FindOptions = { search: "foo", regexp: false, caseSensitive: false, wholeWord: false, replace: "" }; + + it("searches edited text in the selection scope, grouping ranges on the same line", () => { + const v = make("foo\nfoo foo\nfoo", useOptions, { from: 4, to: 11 }); + v.dispatch({ changes: { from: 4, to: 7, insert: "FOO" } }); + expect(findResultLines(v.state, useOptions)).toEqual([ + { + line: 1, + text: "FOO foo", + textOffset: 0, + ranges: [ + [0, 3], + [4, 7], + ], + }, + ]); + }); + + it("splits multiline matches into highlighted result lines", () => { + const v = make("before foo\nbar after", { search: "foo\nbar" }); + expect(findResultLines(v.state, { ...useOptions, search: "foo\nbar" })).toEqual([ + { line: 0, text: "before foo", textOffset: 0, ranges: [[7, 10]] }, + { line: 1, text: "bar after", textOffset: 0, ranges: [[0, 3]] }, + ]); + }); + + it("retains excluded occurrences", () => { + const v = make("foo foo foo", useOptions); + v.dispatch({ effects: toggleFindExcluded.of({ from: 4, to: 7 }) }); + expect(findResultLines(v.state, useOptions)[0]?.ranges).toEqual([ + [0, 3], + [8, 11], + ]); + }); + + it("exports every match beyond the bar counter limit", () => { + const v = make("foo\n".repeat(10_005), useOptions); + expect(findResultLines(v.state, useOptions)).toHaveLength(10_005); + }); +}); diff --git a/src/editor/core/cm/find.ts b/src/editor/core/cm/find.ts index 27644fe65..9c7358d4d 100644 --- a/src/editor/core/cm/find.ts +++ b/src/editor/core/cm/find.ts @@ -28,6 +28,7 @@ import { setSearchQuery, } from "@codemirror/search"; import { scrollbarMarks, type ScrollbarMark } from "@/editor/api"; +import type { LineMatch } from "@/platform/ipc"; import { contextAccepts, preserveCase, type SearchContext } from "@/platform/search"; import { literalKindAt, literalsExtension } from "./literals"; @@ -376,3 +377,26 @@ export function excludeCurrent(view: EditorView): boolean { if (!excluded) step(view, true); return true; } + +/** Result lines from the same query, scope and syntax filters used by the bar. */ +export function findResultLines(state: EditorState, options: FindOptions, scope = scopeOf(state)) { + const query = makeQuery(options); + const lines = new Map(); + if (!query.valid) return []; + const cursor = query.getCursor(state, Math.min(scope.from, state.doc.length), Math.min(scope.to, state.doc.length)); + for (let r = cursor.next(); !r.done; r = cursor.next()) { + const { from, to } = r.value; + const first = state.doc.lineAt(from); + const last = state.doc.lineAt(Math.max(from, to - 1)); + for (let number = first.number; number <= last.number; number++) { + const line = state.doc.line(number); + let result = lines.get(number); + if (!result) { + result = { line: number - 1, text: line.text, textOffset: 0, ranges: [] }; + lines.set(number, result); + } + result.ranges.push([Math.max(from, line.from) - line.from, Math.min(to, line.to) - line.from]); + } + } + return [...lines.values()]; +} diff --git a/src/editor/core/view/FindBar.test.tsx b/src/editor/core/view/FindBar.test.tsx new file mode 100644 index 000000000..fbe166300 --- /dev/null +++ b/src/editor/core/view/FindBar.test.tsx @@ -0,0 +1,149 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { act, fireEvent, render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { EditorState } from "@codemirror/state"; +import { EditorView } from "@codemirror/view"; +import { executeAction, registerAction } from "@/platform/actions"; +import { installKeyboardDispatcher, useKeymap } from "@/platform/keymap"; +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"; + +const disposers: Array<() => void> = []; +afterEach(() => { + disposers + .splice(0) + .reverse() + .forEach((dispose) => dispose()); + useFindOptions.setState({ search: "", regexp: false, caseSensitive: false, wholeWord: false }); + useFindPreferences.setState({ scrollToResults: true }); + useKeymap.setState({ preset: "intellij-macos", overrides: {} }); +}); + +function setup() { + const view = new EditorView({ + parent: document.body, + state: EditorState.create({ + doc: "foo\nFOO\nfoo", + extensions: [findExtension(), EditorState.allowMultipleSelections.of(true)], + }), + }); + const target: FindTarget = { + id: "test-find", + view: () => view, + watch: () => () => {}, + isOpen: () => true, + results: (options) => ({ + title: `Occurrences of '${options.search}'`, + query: { + pattern: options.search, + isRegex: options.regexp, + caseSensitive: options.caseSensitive, + wholeWord: options.wholeWord, + include: [], + exclude: [], + scope: { kind: "files", paths: ["/a.ts"] }, + maxMatches: 0, + }, + read: () => ({ path: "/a.ts", relPath: "a.ts", matches: findResultLines(view.state, options) }), + }), + }; + disposers.push(() => view.destroy(), registerEditorActions(), installKeyboardDispatcher()); + const panel = render( {}} />); + disposers.push(panel.unmount); + return { view, field: screen.getByRole("textbox", { name: "Find" }) }; +} + +const setSearch = (field: HTMLElement, value: string) => fireEvent.change(field, { target: { value } }); + +describe("Find bar More actions", () => { + it("opens current results from the menu and reserves Alt+F7 while focused", async () => { + const user = userEvent.setup(); + const open = vi.fn(); + const usages = vi.fn(); + disposers.push(provideFindResults(open), registerAction({ id: "find.usages", title: "Find Usages", run: usages })); + const { field } = setup(); + setSearch(field, "foo"); + await user.click(screen.getByRole("button", { name: "More" })); + await user.click(screen.getByRole("menuitem", { name: /Open Results in Find Window/ })); + expect(open).toHaveBeenCalledOnce(); + expect(open.mock.calls[0]![0].read().matches).toHaveLength(3); + field.focus(); + fireEvent.keyDown(field, { key: "F7", code: "F7", altKey: true }); + expect(open).toHaveBeenCalledTimes(2); + expect(usages).not.toHaveBeenCalled(); + setSearch(field, "missing"); + fireEvent.keyDown(field, { key: "F7", code: "F7", altKey: true }); + expect(open).toHaveBeenCalledTimes(2); + expect(usages).not.toHaveBeenCalled(); + expect(getContext().findBarHasResults).toBe(false); + }); + + it.each(["intellij-macos", "intellij-windows", "vscode-macos", "vscode-windows", "eclipse-macos", "eclipse-windows"])( + "exports with Alt+F7 in the %s preset", + (preset) => { + useKeymap.setState({ preset }); + const open = vi.fn(); + disposers.push(provideFindResults(open)); + const { field } = setup(); + setSearch(field, "foo"); + fireEvent.keyDown(field, { key: "F7", code: "F7", altKey: true }); + expect(open).toHaveBeenCalledOnce(); + }, + ); + + it("honors a rebound shortcut and releases it outside the bar", () => { + const open = vi.fn(); + const other = vi.fn(); + useKeymap.setState({ overrides: { "find.openInWindow": ["Alt+F8"], "find.usages": ["Alt+F8"] } }); + disposers.push(provideFindResults(open), registerAction({ id: "find.usages", title: "Find Usages", run: other })); + const { field, view } = setup(); + setSearch(field, "foo"); + fireEvent.keyDown(field, { key: "F8", code: "F8", altKey: true }); + expect(open).toHaveBeenCalledOnce(); + view.focus(); + fireEvent.keyDown(view.contentDOM, { key: "F8", code: "F8", altKey: true }); + expect(other).toHaveBeenCalledOnce(); + expect(open).toHaveBeenCalledOnce(); + }); + + it.each(["", "missing", "["])("disables export for %j", async (query) => { + const user = userEvent.setup(); + const { field } = setup(); + if (query === "[") act(() => useFindOptions.setState({ regexp: true })); + setSearch(field, query); + await user.click(screen.getByRole("button", { name: "More" })); + expect(screen.getByRole("menuitem", { name: /Open Results in Find Window/ })).toHaveAttribute( + "aria-disabled", + "true", + ); + }); + + it("runs existing multiple cursor actions on the searched editor", async () => { + const user = userEvent.setup(); + const { field, view } = setup(); + setSearch(field, "foo"); + await user.click(screen.getByRole("button", { name: "More" })); + await user.click(screen.getByRole("menuitem", { name: /Select All Occurrences/ })); + expect(view.state.selection.ranges).toHaveLength(2); + field.focus(); + await user.click(screen.getByRole("button", { name: "More" })); + await user.click(screen.getByRole("menuitem", { name: /Unselect Occurrence/ })); + expect(view.state.selection.ranges).toHaveLength(1); + }); + + it("turns off incremental selection while retaining results and explicit navigation", async () => { + const { field, view } = setup(); + await act(async () => { + await executeAction("find.scrollToResults"); + }); + expect(useFindPreferences.getState().scrollToResults).toBe(false); + setSearch(field, "foo"); + expect(view.state.selection.main.empty).toBe(true); + await waitFor(() => expect(screen.getByText("?/3")).toBeInTheDocument()); + fireEvent.keyDown(field, { key: "Enter", code: "Enter" }); + expect(view.state.selection.main.empty).toBe(false); + }); +}); diff --git a/src/editor/core/view/FindBar.tsx b/src/editor/core/view/FindBar.tsx index df139ed0e..438bee707 100644 --- a/src/editor/core/view/FindBar.tsx +++ b/src/editor/core/view/FindBar.tsx @@ -21,6 +21,7 @@ import { findNext, findPrevious, findScope, + findResultLines, isExcluded, regexValid, replaceAll, @@ -33,7 +34,12 @@ import { type FindScope, } from "../cm/find"; import styles from "./EditorArea.module.css"; -import { hasPrimaryModifier } from "@/platform/keymap"; +import { executeAction } from "@/platform/actions"; +import { setContext } from "@/platform/context"; +import { showFindResults, type FindResultsSource } from "@/workbench/contributions"; +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. */ export const useFindOptions = create>(() => ({ @@ -67,6 +73,7 @@ export interface FindTarget { watch(fn: (u: { docChanged: boolean; selectionSet: boolean }) => void): () => void; /** Still open: an unmount that isn't a close (React's dev-mode remount) keeps the highlights. */ isOpen(): boolean; + results?(options: FindOptions): FindResultsSource | null; } function activeView(paneId: string): EditorView | null { @@ -91,12 +98,68 @@ function paneTarget(paneId: string): FindTarget { return () => void slot.listeners.delete(fn); }, isOpen: () => !!useFindBars.getState()[paneId], + results(options) { + const tab = useTabs.getState().panes[paneId]?.active; + const slot = tab ? editorController().slotFor(tab) : null; + if (!slot) return null; + const state = slot.currentState(); + const scope = state.field(findScope, false) ?? { from: 0, to: state.doc.length }; + const path = slot.ctx.path; + const root = useProject.getState().project?.root; + return { + title: tx`Occurrences of '${options.search}'`, + query: { + pattern: options.search, + isRegex: options.regexp, + caseSensitive: options.caseSensitive, + wholeWord: options.wholeWord, + context: options.context, + include: [], + exclude: [], + scope: { kind: "files", paths: [path] }, + maxMatches: 0, + }, + read: () => { + const current = slot.disposed ? state : slot.currentState(); + return { + path, + relPath: root ? relativePath(root, path) : path, + matches: findResultLines( + current, + options, + state.field(findScope, false) + ? (current.field(findScope, false) ?? scope) + : { from: 0, to: current.doc.length }, + ), + }; + }, + }; + }, }; paneTargets.set(paneId, target); } return target; } +export const useFindPreferences = create(() => ({ scrollToResults: true })); +const panelTargets = new Map(); +let focusedTarget: FindTarget | undefined; + +export function findPanelTarget(arg?: unknown): FindTarget | undefined { + return typeof arg === "string" ? panelTargets.get(arg) : focusedTarget; +} + +export function openFindBarResults(arg?: unknown) { + const target = findPanelTarget(arg); + const opts = useFindOptions.getState(); + if (!opts.search || (opts.regexp && !regexValid(opts.search))) return; + const source = target?.results?.({ ...opts }); + if (source?.read().matches.length) { + rememberQuery(false); + showFindResults(source); + } +} + export function closeFindBar(paneId: string) { useFindBars.setState({ [paneId]: undefined }); } @@ -236,11 +299,54 @@ export function FindPanel({ target, version, replace, seq, onReplaceShown, onClo const replaceRef = useRef(null); const findBoxRef = useRef(null); const replaceBoxRef = useRef(null); + const panelRef = useRef(null); + const moreRef = useRef(null); + const scrollToResults = useFindPreferences((s) => s.scrollToResults); + const [moreMenu, setMoreMenu] = useState(false); const filterRef = useRef(null); const [historyMenu, setHistoryMenu] = useState<"find" | "replace" | null>(null); const [filterMenu, setFilterMenu] = useState(false); const setOpts = (patch: Partial) => useFindOptions.setState(patch); + useLayoutEffect(() => { + panelTargets.set(id, target); + setContext("findBarOpen", true); + return () => { + panelTargets.delete(id); + setContext("findBarOpen", panelTargets.size > 0); + if (focusedTarget === target) { + focusedTarget = undefined; + setContext("findBarFocus", false); + setContext("findBarHasResults", false); + } + }; + }, [id, target]); + + useEffect(() => { + if (focusedTarget === target) + setContext( + "findBarHasResults", + !!target.results && !!opts.search && (!opts.regexp || regexValid(opts.search)) && !!count?.total, + ); + }, [target, opts, count]); + + // Reserve this bar's shortcut even when there are no results to export. + useEffect( + () => + registerKeyClaim((e, stroke) => { + if ( + !panelRef.current?.contains(e.target as Node) || + !bindingsFor("find.openInWindow").some((binding) => normalizeBinding(binding) === stroke) + ) + return false; + e.preventDefault(); + e.stopPropagation(); + void executeAction("find.openInWindow", id); + return true; + }), + [id], + ); + // Opening (or ⌘F again): focus + select the field. useLayoutEffect(() => { inputRef.current?.focus(); @@ -254,7 +360,7 @@ export function FindPanel({ target, version, replace, seq, onReplaceShown, onClo const valid = !opts.regexp || regexValid(opts.search); setQuery(view, valid ? opts : { ...opts, search: "" }); view.dispatch({ effects: setFindOpen.of(true) }); - if (valid && opts.search) { + if (valid && opts.search && useFindPreferences.getState().scrollToResults) { const from = view.state.selection.main.from; view.dispatch({ selection: { anchor: from } }); findNext(view); @@ -387,10 +493,42 @@ export function FindPanel({ target, version, replace, seq, onReplaceShown, onClo run: () => setOpts({ context: c.value }), })), ]; + const invoke = (action: string) => void executeAction(action, id); + const moreItems: MenuItem[] = [ + { + label: "Open Results in Find Window", + shortcut: shortcutLabel("find.openInWindow"), + disabled: !target.results || !opts.search || invalid || !count?.total, + run: () => invoke("find.openInWindow"), + }, + { kind: "separator" }, + { kind: "header", label: "Multiple Cursors" }, + ...( + [ + ["code.addCaretNextOccurrence", "Add Selection for Next Occurrence"], + ["code.unselectOccurrence", "Unselect Occurrence"], + ["code.selectAllOccurrences", "Select All Occurrences"], + ] as const + ).map(([action, label]) => ({ label, shortcut: shortcutLabel(action), run: () => invoke(action) })), + { kind: "separator" }, + { label: "Scroll to Results During Typing", checked: scrollToResults, run: () => invoke("find.scrollToResults") }, + ]; const canReplace = !!opts.search && !invalid; return (
{ + focusedTarget = target; + setContext("findBarFocus", true); + setContext( + "findBarHasResults", + !!target.results && !!opts.search && (!opts.regexp || regexValid(opts.search)) && !!count?.total, + ); + }} + onBlurCapture={(e) => { + if (!panelRef.current?.contains(e.relatedTarget as Node | null)) setContext("findBarFocus", false); + }} className={clsx(styles.find, onReplaceShown && styles.findExpandable)} role="search" aria-label={replace ? "Replace in file" : "Find in file"} @@ -468,6 +606,7 @@ export function FindPanel({ target, version, replace, seq, onReplaceShown, onClo pressed={scoped || opts.context !== "any"} onClick={() => setFilterMenu(true)} /> + setMoreMenu(true)} />
{replace && ( @@ -519,6 +658,14 @@ export function FindPanel({ target, version, replace, seq, onReplaceShown, onClo aria-label="Recent searches" onClose={() => setHistoryMenu(null)} /> + setMoreMenu(false)} + /> { + const openFile = vi.fn(async () => {}); + provideEditorService({ + openFile, + openInput: async () => {}, + activePath: () => null, + activeCursor: () => null, + saveAll: async () => {}, + }); + useFindViewOptions.setState({ preview: false }); + openEditorResults({ + title: "Occurrences of 'foo'", + query: { + pattern: "foo", + isRegex: false, + wholeWord: true, + caseSensitive: true, + include: [], + exclude: [], + scope: { kind: "files", paths: ["/p/a.ts"] }, + maxMatches: 0, + }, + read: () => ({ + path: "/p/a.ts", + relPath: "a.ts", + matches: [{ line: 5, text: " foo()", textOffset: 0, ranges: [[2, 5]] }], + }), + }); + render(); + const match = await screen.findByRole("treeitem", { name: /6.*foo/ }); + expect(match.querySelector("mark")).toHaveTextContent("foo"); + fireEvent.mouseDown(match, { button: 0 }); + expect(openFile).toHaveBeenCalledWith("/p/a.ts", { + focus: false, + selection: { line: 5, col: 2, endLine: 5, endCol: 5 }, + }); +}); diff --git a/src/features/search/__tests__/findWindow.test.ts b/src/features/search/__tests__/findWindow.test.ts index 5bd3e02c1..2b9ca9808 100644 --- a/src/features/search/__tests__/findWindow.test.ts +++ b/src/features/search/__tests__/findWindow.test.ts @@ -1,8 +1,15 @@ -import { afterEach, describe, expect, it } from "vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; import type { FileMatches, SearchQuery } from "@/platform/ipc"; import { buildFindTree } from "../find/FindToolWindow"; import type { FindDeps } from "../find/session"; -import { disposeFindWindow, openInFindWindow, pinFindTab, useFindWindow } from "../find/windowStore"; +import { + disposeFindWindow, + openEditorResults, + openInFindWindow, + pinFindTab, + rerunFindTab, + useFindWindow, +} from "../find/windowStore"; const q = (pattern: string): SearchQuery => ({ pattern, @@ -67,3 +74,23 @@ describe("buildFindTree", () => { ]); }); }); + +describe("editor-backed Find results", () => { + it("uses the shared session and recomputes live results on rerun without IPC", async () => { + const current = file("unsaved.ts"); + current.matches[0]!.text = "foo foo"; + current.matches[0]!.ranges.push([4, 7]); + const read = vi.fn(() => current); + const id = openEditorResults({ title: "Occurrences of 'foo'", query: q("foo"), read }); + await vi.waitFor(() => expect(useFindWindow.getState().tabs[0]?.state.status).toBe("done")); + expect(useFindWindow.getState().tabs[0]?.state.files).toEqual([current]); + expect(useFindWindow.getState().tabs[0]?.state.matchCount).toBe(2); + const tree = buildFindTree(useFindWindow.getState().tabs[0]!.state.files); + expect(tree[0]).toMatchObject({ kind: "file", count: 2 }); + read.mockReturnValue({ ...file("unsaved.ts"), matches: [] }); + rerunFindTab(id); + await vi.waitFor(() => expect(useFindWindow.getState().tabs[0]?.state.files).toEqual([])); + expect(read).toHaveBeenCalledTimes(2); + expect(useFindWindow.getState().tabs[0]?.state.summary?.matches).toBe(0); + }); +}); diff --git a/src/features/search/find/FindToolWindow.tsx b/src/features/search/find/FindToolWindow.tsx index 9dae9f0d5..db1ae34c1 100644 --- a/src/features/search/find/FindToolWindow.tsx +++ b/src/features/search/find/FindToolWindow.tsx @@ -24,6 +24,7 @@ import { import { notifications, useOccurrenceNavigator } from "@/workbench/contributions"; import { confirm } from "@/workbench/messages"; import { basename, openLocation, relDir } from "../common/open"; +import { countFileMatches } from "./session"; import { CodePreview, MatchText, type PreviewHighlight } from "./parts"; import { NO_MARKS, @@ -104,18 +105,19 @@ export function buildFindTree(files: readonly FileMatches[], groupByDirectory = starts: m.ranges.map(([s]) => ({ line: m.line, col: s + m.textOffset })), }; }); + const count = countFileMatches(f); const file: Node = { kind: "file", key: `f:${f.path}`, path: f.path, name: basename(f.relPath), dir, - count: f.matches.length, + count, children, }; if (d) { d.children.push(file); - d.count += f.matches.length; + d.count += count; } else flat.push(file); } if (!groupByDirectory) { @@ -434,6 +436,12 @@ function FindTabView({ tab }: { tab: FindTab }) { onSelectionChange={(next, lead) => { setSelection(next); setLeadNode(lead); + if (tab.editorResults && lead?.kind === "match") + void openLocation( + lead.path, + { line: lead.line, col: lead.col, endLine: lead.line, endCol: lead.endCol }, + { focus: false }, + ); }} multiSelect onActivate={(n) => { diff --git a/src/features/search/find/session.ts b/src/features/search/find/session.ts index 51b40e2d7..5cc0e9970 100644 --- a/src/features/search/find/session.ts +++ b/src/features/search/find/session.ts @@ -31,6 +31,9 @@ export interface FindDeps { nextRequestId(): RequestId; } +export const countFileMatches = (file: FileMatches): number => + file.matches.reduce((count, line) => count + line.ranges.length, 0); + export const IDLE: FindState = { status: "idle", query: null, files: [], matchCount: 0, summary: null, error: null }; function errorMessage(e: unknown): string { @@ -86,7 +89,7 @@ export class FindSession { const summary = await this.deps.start(request, query, (f) => { if (seq !== this.seq) return; files.push(f); - matchCount += f.matches.length; + matchCount += countFileMatches(f); this.update({ ...this.state, files: [...files], matchCount }); }); if (seq !== this.seq) return; diff --git a/src/features/search/find/windowStore.ts b/src/features/search/find/windowStore.ts index c92bb6645..de8aee1d7 100644 --- a/src/features/search/find/windowStore.ts +++ b/src/features/search/find/windowStore.ts @@ -4,11 +4,12 @@ */ import { create } from "zustand"; import { call, commands, nextRequestId, type SearchQuery } from "@/platform/ipc"; +import type { FindResultsSource } from "@/workbench/contributions"; import type { MenuItem } from "@/ui"; import { loadJson, saveJson } from "../common/open"; import { maybeToolWindowService } from "../common/services"; import { cancelRequest, streamSearch } from "../common/textSearch"; -import { FindSession, IDLE, type FindDeps, type FindState } from "./session"; +import { FindSession, IDLE, countFileMatches, type FindDeps, type FindState } from "./session"; export interface FindTab { id: number; @@ -20,6 +21,8 @@ export interface FindTab { /** Kept when a new search runs (Pin Tab). */ pinned?: boolean; state: FindState; + /** In-file results reveal their line on selection while keeping focus in the tree. */ + editorResults?: boolean; } interface FindWindowState { @@ -77,6 +80,23 @@ export function openInFindWindow( return id; } +/** 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 }; + }, + }); + patchTab(id, { editorResults: true }); + return id; +} + export function pinFindTab(id: number, pinned: boolean) { patchTab(id, { pinned }); } diff --git a/src/features/search/index.ts b/src/features/search/index.ts index 759e5c80b..dc7b2ec8f 100644 --- a/src/features/search/index.ts +++ b/src/features/search/index.ts @@ -11,7 +11,7 @@ import { onIdeEvent } from "@/platform/ipc"; import { useProject } from "@/platform/project"; import { EDIT_SCOPES_ACTION } from "@/platform/search"; import { isVirtualUri } from "@/platform/virtualDocs"; -import { registerMenuGroup, registerOverlay, registerToolWindow } from "@/workbench/contributions"; +import { provideFindResults, registerMenuGroup, registerOverlay, registerToolWindow } from "@/workbench/contributions"; import { BookmarkPopupsOverlay } from "./bookmarks/BookmarkPopups"; import { bookmarkGutter } from "./bookmarks/gutter"; import { @@ -38,7 +38,7 @@ import { hideSearchEverywhere, showSearchEverywhere } from "./everywhere/store"; import { FindInFilesOverlay } from "./find/FindInFiles"; import { ScopesDialogOverlay, showScopesDialog } from "./find/ScopesDialog"; import { hideFindPopup, parseFindArg, runFindPopupCommand, showFindPopup, useFindPopup } from "./find/store"; -import { disposeFindWindow, rerunActiveFindTab, useFindHeaderMenu } from "./find/windowStore"; +import { disposeFindWindow, openEditorResults, rerunActiveFindTab, useFindHeaderMenu } from "./find/windowStore"; import { RecentPopupsOverlay } from "./recent/RecentPopups"; import { cycleSwitcher, hideRecentPopup, showRecentPopup } from "./recent/store"; import { FileStructureOverlay, useFileStructurePopup } from "./structure/StructureView"; @@ -123,6 +123,7 @@ export function activate(): void | (() => void) { const disposers: Array<() => void> = [ installTracking(), + provideFindResults(openEditorResults), registerOverlay({ id: "search.everywhere", component: SearchEverywhereOverlay }), registerOverlay({ id: "search.recent", component: RecentPopupsOverlay }), diff --git a/src/platform/keymaps/intellij-macos.ts b/src/platform/keymaps/intellij-macos.ts index ba090b4b9..8df2664a0 100644 --- a/src/platform/keymaps/intellij-macos.ts +++ b/src/platform/keymaps/intellij-macos.ts @@ -396,6 +396,7 @@ export const intellijMacos: Record = { "find.popup.openResult": ["F4", "Meta+Down"], "find.popup.recentSearches": ["Alt+Down"], // Search fields (find bar, Find in Files): last, so they win over editor keys while one is focused. + "find.openInWindow": ["Alt+F7"], "find.field.newLine": ["Meta+Shift+Enter"], "find.field.recentSearches": ["Alt+Down"], }; diff --git a/src/platform/keymaps/intellij-windows.ts b/src/platform/keymaps/intellij-windows.ts index cc9c0163f..64bb4ffb7 100644 --- a/src/platform/keymaps/intellij-windows.ts +++ b/src/platform/keymaps/intellij-windows.ts @@ -394,6 +394,7 @@ export const intellijWindows: Record = { "find.popup.openResult": ["F4"], "find.popup.recentSearches": ["Alt+Down"], // Search fields (find bar, Find in Files): last, so they win over editor keys while one is focused. + "find.openInWindow": ["Alt+F7"], "find.field.newLine": ["Ctrl+Shift+Enter"], "find.field.recentSearches": ["Alt+Down"], }; diff --git a/src/platform/keymaps/vscode-macos.ts b/src/platform/keymaps/vscode-macos.ts index a30459d6b..e9df6a033 100644 --- a/src/platform/keymaps/vscode-macos.ts +++ b/src/platform/keymaps/vscode-macos.ts @@ -383,6 +383,7 @@ export const vscodeMacos: Record = { "agents.showToolWindow": [], "agents.sendToAgent": [], // Search fields (find bar, Find in Files): last, so they win over editor keys while one is focused. + "find.openInWindow": ["Alt+F7"], "find.field.newLine": ["Meta+Shift+Enter"], "find.field.recentSearches": ["Alt+Down"], }; diff --git a/src/platform/locales/text/zh.json b/src/platform/locales/text/zh.json index e34a71919..0defeaa62 100644 --- a/src/platform/locales/text/zh.json +++ b/src/platform/locales/text/zh.json @@ -5249,5 +5249,10 @@ "A scheme named '{name}' already exists.": "名为“{name}”的配色方案已存在。", "Applied {applied} values; skipped {skipped} unsupported or invalid values.": "已应用 {applied} 个值;跳过了 {skipped} 个不支持或无效的值。", "Delete color scheme '{name}'?": "删除配色方案“{name}”?", - "Overwrite": "覆盖" + "Overwrite": "覆盖", + "Open Results in Find Window": "在查找窗口中打开结果", + "Multiple Cursors": "多个光标", + "Scroll to Results During Typing": "键入时滚动到结果", + "Find options": "查找选项", + "Occurrences of '{0}'": "“{0}”的出现位置" } diff --git a/src/workbench/contributions.ts b/src/workbench/contributions.ts index c2c14da99..e393b2e28 100644 --- a/src/workbench/contributions.ts +++ b/src/workbench/contributions.ts @@ -17,7 +17,7 @@ import { type ReactNode, type RefObject, } from "react"; -import type { RunConfig } from "@/bindings"; +import type { FileMatches, SearchQuery, RunConfig } from "@/bindings"; import type { MenuItem } from "@/ui/Menu"; import type { TreeReorder } from "@/ui/Tree"; import { create } from "zustand"; @@ -622,6 +622,26 @@ export const maybeEditorService = (): EditorService | undefined => services.edit /** Creates terminal widgets; provided by the terminal feature. */ export const embeddedTerminal = (): EmbeddedTerminalFactory | undefined => services.embeddedTerminal; +/** Editor-backed results, recomputed on rerun without reading files from disk. */ +export interface FindResultsSource { + title: string; + query: SearchQuery; + read(): FileMatches; +} + +let findResultsOpener: ((source: FindResultsSource) => void) | undefined; + +export function provideFindResults(opener: (source: FindResultsSource) => void): () => void { + findResultsOpener = opener; + return () => { + if (findResultsOpener === opener) findResultsOpener = undefined; + }; +} + +export function showFindResults(source: FindResultsSource) { + findResultsOpener?.(source); +} + // --------------------------------------------------------------------------- // File decorations: colour / badge / tooltip for a path, shown by the project // tree, editor tabs, Search Everywhere, etc. Providers (VCS status, problems)