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)