дзеркало
https://github.com/l0ng-ai/openidea.git
синхронізовано 2026-11-09 03:05:40 +02:00
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
Цей коміт міститься в:
1 джерело
53bebe4836
коміт
d4b354732c
16 files changed
+613
-18
No files matched your search
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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<number, LineMatch>();
|
||||
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()];
|
||||
}
|
||||
@@ -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(<FindPanel target={target} version={0} replace={false} seq={0} onClose={() => {}} />);
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -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<Required<FindOptions>>(() => ({
|
||||
@@ -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<string, FindTarget>();
|
||||
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<HTMLInputElement>(null);
|
||||
const findBoxRef = useRef<HTMLDivElement>(null);
|
||||
const replaceBoxRef = useRef<HTMLDivElement>(null);
|
||||
const panelRef = useRef<HTMLDivElement>(null);
|
||||
const moreRef = useRef<HTMLButtonElement>(null);
|
||||
const scrollToResults = useFindPreferences((s) => s.scrollToResults);
|
||||
const [moreMenu, setMoreMenu] = useState(false);
|
||||
const filterRef = useRef<HTMLButtonElement>(null);
|
||||
const [historyMenu, setHistoryMenu] = useState<"find" | "replace" | null>(null);
|
||||
const [filterMenu, setFilterMenu] = useState(false);
|
||||
const setOpts = (patch: Partial<FindOptions>) => useFindOptions.setState(patch);
|
||||
|
||||
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 (
|
||||
<div
|
||||
ref={panelRef}
|
||||
onFocusCapture={() => {
|
||||
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)}
|
||||
/>
|
||||
<IconButton ref={moreRef} icon="more" title="More" size="small" onClick={() => setMoreMenu(true)} />
|
||||
</div>
|
||||
<IconButton icon="close" title="Close" shortcut="⎋" size="small" className={styles.findClose} onClick={close} />
|
||||
{replace && (
|
||||
@@ -519,6 +658,14 @@ export function FindPanel({ target, version, replace, seq, onReplaceShown, onClo
|
||||
aria-label="Recent searches"
|
||||
onClose={() => setHistoryMenu(null)}
|
||||
/>
|
||||
<Menu
|
||||
open={moreMenu}
|
||||
anchor={moreRef}
|
||||
items={moreItems}
|
||||
placement="bottom-end"
|
||||
aria-label="Find options"
|
||||
onClose={() => setMoreMenu(false)}
|
||||
/>
|
||||
<Menu
|
||||
open={filterMenu}
|
||||
anchor={filterRef}
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
import { afterEach, expect, it, vi } from "vitest";
|
||||
import { fireEvent, render, screen } from "@testing-library/react";
|
||||
import { provideEditorService } from "@/workbench/contributions";
|
||||
import { FindToolWindow } from "../find/FindToolWindow";
|
||||
import { disposeFindWindow, openEditorResults, useFindViewOptions } from "../find/windowStore";
|
||||
|
||||
afterEach(disposeFindWindow);
|
||||
|
||||
it("shows highlighted result lines and navigates on a single click", async () => {
|
||||
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(<FindToolWindow />);
|
||||
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 },
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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 }),
|
||||
|
||||
@@ -396,6 +396,7 @@ export const intellijMacos: Record<string, string[]> = {
|
||||
"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"],
|
||||
};
|
||||
@@ -394,6 +394,7 @@ export const intellijWindows: Record<string, string[]> = {
|
||||
"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"],
|
||||
};
|
||||
@@ -383,6 +383,7 @@ export const vscodeMacos: Record<string, string[]> = {
|
||||
"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"],
|
||||
};
|
||||
@@ -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}”的出现位置"
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
Reference in new issue
Заблокувати користувача