mirror of
https://github.com/l0ng-ai/openidea.git
synced 2026-11-07 00:07:29 +00:00
feat(search): select and open multiple search results (#670)
Search Everywhere and Recent Files lists now support multi-selection: Shift+Up/Down and Shift+click extend a range from the anchor, Cmd/Ctrl+click toggles a row, and Enter opens every selected file, symbol or text result in list order, leaving focus on the lead row. Actions in a mixed selection are skipped; a single selection behaves as before. Selection resets when the query or tab changes. The row-selection model used by Find in Files moves to src/ui so the shared List can opt into modifier selection. Fixes #647
This commit is contained in:
@@ -1,12 +1,17 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { act, fireEvent, render, screen, within } from "@testing-library/react";
|
||||
import { registerAction } from "@/platform/actions";
|
||||
import type { SeDeps } from "../everywhere/controller";
|
||||
|
||||
const defaultFindFiles: SeDeps["findFiles"] = async (q) =>
|
||||
q ? [{ path: "/p/src/OrderController.kt", relPath: "src/OrderController.kt", score: 1, indices: [4, 5, 6] }] : [];
|
||||
let findFiles = defaultFindFiles;
|
||||
let findSymbols: SeDeps["findSymbols"] = async () => [];
|
||||
let settings: NonNullable<SeDeps["settings"]> = async () => [];
|
||||
const fakeDeps = (): SeDeps => ({
|
||||
findFiles: async (q) =>
|
||||
q ? [{ path: "/p/src/OrderController.kt", relPath: "src/OrderController.kt", score: 1, indices: [4, 5, 6] }] : [],
|
||||
findSymbols: async () => [],
|
||||
findFiles: (...args) => findFiles(...args),
|
||||
findSymbols: (...args) => findSymbols(...args),
|
||||
settings: () => settings(),
|
||||
searchText: async () => {},
|
||||
cancel: () => {},
|
||||
nextRequestId: () => 1,
|
||||
@@ -18,6 +23,11 @@ const fakeDeps = (): SeDeps => ({
|
||||
now: () => 0,
|
||||
});
|
||||
|
||||
vi.mock("../common/open", async (orig) => ({
|
||||
...(await orig<typeof import("../common/open")>()),
|
||||
openLocation: vi.fn(async () => {}),
|
||||
}));
|
||||
|
||||
vi.mock("../everywhere/deps", () => ({
|
||||
ipcDeps: () => fakeDeps(),
|
||||
loadLanguages: async () => [
|
||||
@@ -42,6 +52,12 @@ vi.mock("../find/windowStore", async (orig) => ({
|
||||
}),
|
||||
}));
|
||||
|
||||
const contributions = await import("@/workbench/contributions");
|
||||
const { commands } = await import("@/platform/ipc");
|
||||
const { RecentPopupsOverlay } = await import("../recent/RecentPopups");
|
||||
const { showRecentPopup, hideRecentPopup, useRecentPopup } = await import("../recent/store");
|
||||
const { openLocation } = await import("../common/open");
|
||||
const { usePlatform } = await import("@/platform/platformInfo");
|
||||
const { allActions } = await import("@/platform/actions");
|
||||
(globalThis as unknown as { __actions: typeof allActions }).__actions = allActions;
|
||||
const { SearchEverywhereOverlay } = await import("../everywhere/SearchEverywhere");
|
||||
@@ -52,10 +68,18 @@ const { showFindPopup, hideFindPopup } = await import("../find/store");
|
||||
|
||||
const tick = () => act(() => new Promise((r) => setTimeout(r, 100)));
|
||||
|
||||
beforeEach(() => {
|
||||
findFiles = defaultFindFiles;
|
||||
findSymbols = async () => [];
|
||||
settings = async () => [];
|
||||
vi.mocked(openLocation).mockClear();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
act(() => {
|
||||
hideSearchEverywhere();
|
||||
hideFindPopup();
|
||||
hideRecentPopup();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -203,6 +227,254 @@ describe("Search Everywhere dialog", () => {
|
||||
});
|
||||
});
|
||||
|
||||
const duplicateFiles: Awaited<ReturnType<SeDeps["findFiles"]>> = ["one", "two", "three"].map((dir) => ({
|
||||
path: `/p/${dir}/index.ts`,
|
||||
relPath: `${dir}/index.ts`,
|
||||
score: 1,
|
||||
indices: [0, 1, 2, 3, 4],
|
||||
}));
|
||||
const resultOptions = () => within(document.getElementById("se-results")!).getAllByRole("option");
|
||||
const selectedOptions = () => resultOptions().filter((r) => r.getAttribute("aria-selected") === "true");
|
||||
async function showDuplicateFiles() {
|
||||
findFiles = async () => duplicateFiles;
|
||||
render(<SearchEverywhereOverlay />);
|
||||
act(() => showSearchEverywhere("files", { query: "index" }));
|
||||
await tick();
|
||||
return screen.getByLabelText("Search query");
|
||||
}
|
||||
function clickOption(index: number, modifiers = {}) {
|
||||
const row = resultOptions()[index]!;
|
||||
fireEvent.mouseDown(row, modifiers);
|
||||
fireEvent.click(row, modifiers);
|
||||
}
|
||||
|
||||
describe("Search Everywhere multi-selection", () => {
|
||||
it("extends, shrinks, crosses the anchor and clamps Shift selection; plain arrows select one", async () => {
|
||||
const input = await showDuplicateFiles();
|
||||
const key = (key: string, shiftKey = true) => fireEvent.keyDown(input, { key, shiftKey });
|
||||
key("ArrowDown", false);
|
||||
key("ArrowDown");
|
||||
expect(selectedOptions()).toEqual(resultOptions().slice(1));
|
||||
key("ArrowDown");
|
||||
expect(selectedOptions()).toHaveLength(2);
|
||||
key("ArrowUp");
|
||||
expect(selectedOptions()).toEqual([resultOptions()[1]]);
|
||||
key("ArrowUp");
|
||||
expect(selectedOptions()).toEqual(resultOptions().slice(0, 2));
|
||||
key("ArrowDown", false);
|
||||
expect(selectedOptions()).toEqual([resultOptions()[1]]);
|
||||
expect(screen.getByRole("listbox", { name: "Results" })).toHaveAttribute("aria-multiselectable", "true");
|
||||
});
|
||||
|
||||
it("Shift-click selects a range without opening; an ordinary click opens just that file", async () => {
|
||||
await showDuplicateFiles();
|
||||
clickOption(2, { shiftKey: true });
|
||||
expect(selectedOptions()).toHaveLength(3);
|
||||
expect(openLocation).not.toHaveBeenCalled();
|
||||
expect(useSearchEverywhere.getState().open).toBe(true);
|
||||
clickOption(1);
|
||||
await tick();
|
||||
expect(openLocation).toHaveBeenCalledExactlyOnceWith("/p/two/index.ts", null, { side: false });
|
||||
expect(useSearchEverywhere.getState().open).toBe(false);
|
||||
});
|
||||
|
||||
it.each(["macos", "windows", "linux"] as const)("uses the primary modifier to toggle a row on %s", async (os) => {
|
||||
const previous = usePlatform.getState().os;
|
||||
usePlatform.setState({ os });
|
||||
try {
|
||||
const input = await showDuplicateFiles();
|
||||
const modifier = os === "macos" ? { metaKey: true } : { ctrlKey: true };
|
||||
clickOption(2, modifier);
|
||||
expect(selectedOptions()).toEqual([resultOptions()[0], resultOptions()[2]]);
|
||||
clickOption(0, modifier);
|
||||
expect(selectedOptions()).toEqual([resultOptions()[2]]);
|
||||
expect(openLocation).not.toHaveBeenCalled();
|
||||
fireEvent.keyDown(input, { key: "Enter" });
|
||||
await tick();
|
||||
expect(openLocation).toHaveBeenCalledExactlyOnceWith("/p/three/index.ts", null, { side: false });
|
||||
} finally {
|
||||
usePlatform.setState({ os: previous });
|
||||
}
|
||||
});
|
||||
|
||||
it("awaits each open in list order, keeps tabs and restores an upward range's lead", async () => {
|
||||
const input = await showDuplicateFiles();
|
||||
fireEvent.keyDown(input, { key: "ArrowDown" });
|
||||
fireEvent.keyDown(input, { key: "ArrowDown" });
|
||||
fireEvent.keyDown(input, { key: "ArrowUp", shiftKey: true });
|
||||
let finishFirst!: () => void;
|
||||
vi.mocked(openLocation).mockImplementationOnce(
|
||||
() =>
|
||||
new Promise((r) => {
|
||||
finishFirst = r;
|
||||
}),
|
||||
);
|
||||
fireEvent.keyDown(input, { key: "Enter" });
|
||||
await tick();
|
||||
expect(openLocation).toHaveBeenCalledTimes(1);
|
||||
expect(useSearchEverywhere.getState().open).toBe(true);
|
||||
fireEvent.keyDown(input, { key: "Enter" });
|
||||
expect(openLocation).toHaveBeenCalledTimes(1);
|
||||
expect(openLocation).toHaveBeenNthCalledWith(1, "/p/two/index.ts", null, {
|
||||
preview: false,
|
||||
focus: false,
|
||||
side: false,
|
||||
});
|
||||
await act(async () => finishFirst());
|
||||
await tick();
|
||||
expect(vi.mocked(openLocation).mock.calls).toEqual([
|
||||
["/p/two/index.ts", null, { preview: false, focus: false, side: false }],
|
||||
["/p/three/index.ts", null, { preview: false, focus: false, side: false }],
|
||||
["/p/two/index.ts", null, { preview: false, focus: true, side: false }],
|
||||
]);
|
||||
expect(useSearchEverywhere.getState().open).toBe(false);
|
||||
});
|
||||
|
||||
it("resets selection immediately on query changes and tab switches", async () => {
|
||||
const input = await showDuplicateFiles();
|
||||
clickOption(2, { shiftKey: true });
|
||||
fireEvent.change(input, { target: { value: "index.t" } });
|
||||
expect(selectedOptions()).toHaveLength(1);
|
||||
await tick();
|
||||
clickOption(2, { shiftKey: true });
|
||||
fireEvent.click(screen.getByRole("tab", { name: "All" }));
|
||||
expect(selectedOptions()).toHaveLength(1);
|
||||
await tick();
|
||||
expect(selectedOptions()).toHaveLength(1);
|
||||
});
|
||||
|
||||
it.each(["classes", "symbols"] as const)("opens selected %s at their positions", async (tab) => {
|
||||
findSymbols = async () =>
|
||||
["First", "Second"].map((name, line) => ({
|
||||
name,
|
||||
kind: tab === "classes" ? "class" : "function",
|
||||
container: null,
|
||||
path: `/p/${name}.ts`,
|
||||
relPath: `${name}.ts`,
|
||||
score: 1,
|
||||
indices: [0],
|
||||
range: { start: { line, col: 2 }, end: { line, col: 5 } },
|
||||
}));
|
||||
render(<SearchEverywhereOverlay />);
|
||||
act(() => showSearchEverywhere(tab, { query: "s" }));
|
||||
await tick();
|
||||
const input = screen.getByLabelText("Search query");
|
||||
fireEvent.keyDown(input, { key: "ArrowDown", shiftKey: true });
|
||||
fireEvent.keyDown(input, { key: "Enter" });
|
||||
await tick();
|
||||
expect(openLocation).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
"/p/First.ts",
|
||||
{ line: 0, col: 2 },
|
||||
expect.objectContaining({ preview: false }),
|
||||
);
|
||||
expect(openLocation).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
"/p/Second.ts",
|
||||
{ line: 1, col: 2 },
|
||||
expect.objectContaining({ preview: false }),
|
||||
);
|
||||
});
|
||||
|
||||
it("ignores actions and settings in a mixed selection even when the lead is an action", async () => {
|
||||
const run = vi.fn();
|
||||
const dispose = registerAction({ id: "test.index", title: "Index", category: "Test", run });
|
||||
findFiles = async () => duplicateFiles.slice(0, 2);
|
||||
settings = async () => [{ pageId: "editor", title: "Index", path: ["Editor"] }];
|
||||
render(<SearchEverywhereOverlay />);
|
||||
act(() => showSearchEverywhere("all", { query: "index" }));
|
||||
await tick();
|
||||
const input = screen.getByLabelText("Search query");
|
||||
const count = resultOptions().length;
|
||||
for (let i = 1; i < count; i++) fireEvent.keyDown(input, { key: "ArrowDown" });
|
||||
for (let i = 1; i < count; i++) fireEvent.keyDown(input, { key: "ArrowUp", shiftKey: true });
|
||||
expect(selectedOptions()).toHaveLength(count);
|
||||
fireEvent.keyDown(input, { key: "Enter" });
|
||||
await tick();
|
||||
expect(run).not.toHaveBeenCalled();
|
||||
expect(vi.mocked(openLocation).mock.calls.map((c) => c[0])).toEqual([
|
||||
"/p/one/index.ts",
|
||||
"/p/two/index.ts",
|
||||
"/p/two/index.ts",
|
||||
]);
|
||||
dispose();
|
||||
});
|
||||
|
||||
it("does not execute a multi-selection containing only actions", async () => {
|
||||
const run = vi.fn();
|
||||
const disposers = ["One", "Two"].map((name) =>
|
||||
registerAction({ id: `test.index${name}`, title: `Index ${name}`, run }),
|
||||
);
|
||||
render(<SearchEverywhereOverlay />);
|
||||
act(() => showSearchEverywhere("actions", { query: "index" }));
|
||||
await tick();
|
||||
const input = screen.getByLabelText("Search query");
|
||||
fireEvent.keyDown(input, { key: "ArrowDown", shiftKey: true });
|
||||
fireEvent.keyDown(input, { key: "Enter" });
|
||||
await tick();
|
||||
expect(run).not.toHaveBeenCalled();
|
||||
expect(openLocation).not.toHaveBeenCalled();
|
||||
disposers.forEach((d) => d());
|
||||
});
|
||||
});
|
||||
|
||||
describe("Recent Files multi-selection", () => {
|
||||
async function showFiles() {
|
||||
vi.spyOn(commands, "recentFiles").mockResolvedValue({ status: "ok", data: duplicateFiles.map((f) => f.path) });
|
||||
render(<RecentPopupsOverlay />);
|
||||
act(() => showRecentPopup("files"));
|
||||
await tick();
|
||||
return screen.getByLabelText("Filter recent files");
|
||||
}
|
||||
const options = () => within(screen.getByRole("listbox", { name: "Recent files" })).getAllByRole("option");
|
||||
afterEach(() => vi.restoreAllMocks());
|
||||
|
||||
it("extends and shrinks a keyboard range, then opens its files in order", async () => {
|
||||
const input = await showFiles();
|
||||
fireEvent.keyDown(input, { key: "ArrowDown" });
|
||||
fireEvent.keyDown(input, { key: "ArrowUp", shiftKey: true });
|
||||
expect(options().filter((r) => r.getAttribute("aria-selected") === "true")).toHaveLength(2);
|
||||
fireEvent.keyDown(input, { key: "Enter" });
|
||||
await tick();
|
||||
expect(vi.mocked(openLocation).mock.calls.map((c) => c[0])).toEqual([
|
||||
"/p/one/index.ts",
|
||||
"/p/two/index.ts",
|
||||
"/p/one/index.ts",
|
||||
]);
|
||||
expect(useRecentPopup.getState().open).toBeNull();
|
||||
});
|
||||
|
||||
it("starts a range at the initially selected previous file", async () => {
|
||||
vi.spyOn(contributions, "maybeEditorService").mockReturnValue({
|
||||
activePath: () => "/p/one/index.ts",
|
||||
} as import("@/workbench/contributions").EditorService);
|
||||
const input = await showFiles();
|
||||
expect(options()[1]).toHaveAttribute("aria-selected", "true");
|
||||
fireEvent.keyDown(input, { key: "ArrowDown", shiftKey: true });
|
||||
expect(options()[0]).toHaveAttribute("aria-selected", "false");
|
||||
expect(options()[1]).toHaveAttribute("aria-selected", "true");
|
||||
expect(options()[2]).toHaveAttribute("aria-selected", "true");
|
||||
});
|
||||
|
||||
it("handles modifier clicks and resets on filter text changes", async () => {
|
||||
const input = await showFiles();
|
||||
const click = (i: number, modifiers: object) => {
|
||||
fireEvent.mouseDown(options()[i]!, modifiers);
|
||||
fireEvent.click(options()[i]!, modifiers);
|
||||
};
|
||||
click(2, { shiftKey: true });
|
||||
expect(options().filter((r) => r.getAttribute("aria-selected") === "true")).toHaveLength(3);
|
||||
click(1, { metaKey: true });
|
||||
expect(options().filter((r) => r.getAttribute("aria-selected") === "true")).toEqual([options()[0], options()[2]]);
|
||||
expect(openLocation).not.toHaveBeenCalled();
|
||||
fireEvent.change(input, { target: { value: "index" } });
|
||||
expect(options().filter((r) => r.getAttribute("aria-selected") === "true")).toHaveLength(1);
|
||||
click(1, {});
|
||||
await tick();
|
||||
expect(openLocation).toHaveBeenCalledExactlyOnceWith("/p/two/index.ts", null, { side: false });
|
||||
});
|
||||
});
|
||||
|
||||
describe("Find in Files dialog", () => {
|
||||
it("streams results and previews the replacement", async () => {
|
||||
render(<FindInFilesOverlay />);
|
||||
|
||||
@@ -82,3 +82,17 @@ export function saveJson(key: string, value: unknown) {
|
||||
/* storage unavailable */
|
||||
}
|
||||
}
|
||||
|
||||
/** Open persistent tabs in list order, then restore the lead's focus and position. */
|
||||
export async function openInListOrder<T>(
|
||||
items: readonly T[],
|
||||
lead: T | undefined,
|
||||
open: (item: T, options: Omit<OpenOptions, "selection">) => Promise<void>,
|
||||
side = false,
|
||||
afterOpen?: () => Promise<void>,
|
||||
): Promise<void> {
|
||||
for (const item of items) await open(item, { preview: false, focus: false, side });
|
||||
await afterOpen?.();
|
||||
const target = lead && items.includes(lead) ? lead : items.at(-1);
|
||||
if (target) await open(target, { preview: false, focus: true, side });
|
||||
}
|
||||
|
||||
@@ -18,12 +18,16 @@ import {
|
||||
Select,
|
||||
Toggle,
|
||||
listNavigate,
|
||||
selectRow,
|
||||
selectedRows,
|
||||
rowSelectionMode,
|
||||
type RowSelection,
|
||||
type MenuItem,
|
||||
T,
|
||||
} from "@/ui";
|
||||
import { useFileDecoration } from "@/workbench/contributions";
|
||||
import { highlightTokens, languageForPath, type CodeToken } from "@/editor/lsp/markdown";
|
||||
import { badgeKind, basename, openLocation, relDir } from "../common/open";
|
||||
import { badgeKind, basename, openInListOrder, openLocation, relDir } from "../common/open";
|
||||
import { parseLocationQuery } from "../common/query";
|
||||
import { CodePreview, type PreviewHighlight } from "../find/parts";
|
||||
import { SeController, type SeSnapshot } from "./controller";
|
||||
@@ -81,7 +85,8 @@ export function SearchEverywhereOverlay() {
|
||||
return open ? <SearchEverywhereDialog /> : null;
|
||||
}
|
||||
|
||||
type Selection = { key: string | null; index: number; moved: boolean };
|
||||
type Selection = RowSelection & { key: string | null; anchorKey: string | null; moved: boolean };
|
||||
const INITIAL_SELECTION: Selection = { key: null, anchorKey: null, lead: -1, anchor: -1, keys: null, moved: false };
|
||||
|
||||
function SearchEverywhereDialog() {
|
||||
const tx = useTx();
|
||||
@@ -100,7 +105,7 @@ function SearchEverywhereDialog() {
|
||||
const [extra, setExtra] = useState<Partial<Record<SectionId, number>>>({});
|
||||
const [snap, setSnap] = useState<SeSnapshot | null>(null);
|
||||
const [controller] = useState(() => new SeController(ipcDeps(ctx), setSnap));
|
||||
const [sel, setSel] = useState<Selection>({ key: null, index: -1, moved: false });
|
||||
const [sel, setSel] = useState<Selection>(INITIAL_SELECTION);
|
||||
const [menu, setMenu] = useState<{
|
||||
anchor: Element;
|
||||
items: MenuItem[];
|
||||
@@ -134,12 +139,19 @@ function SearchEverywhereDialog() {
|
||||
return () => clearTimeout(t);
|
||||
}, [query, debounced]);
|
||||
|
||||
// Reset selection immediately, including while the new query is debouncing.
|
||||
const [selectionInputs, setSelectionInputs] = useState({ query, tab });
|
||||
if (selectionInputs.query !== query || selectionInputs.tab !== tab) {
|
||||
setSelectionInputs({ query, tab });
|
||||
setSel(INITIAL_SELECTION);
|
||||
}
|
||||
|
||||
// New query / scope: forget "Show more" and the user's selection.
|
||||
const [prevInputs, setPrevInputs] = useState({ debounced, tab });
|
||||
if (prevInputs.debounced !== debounced || prevInputs.tab !== tab) {
|
||||
setPrevInputs({ debounced, tab });
|
||||
setExtra({});
|
||||
setSel({ key: null, index: -1, moved: false });
|
||||
setSel(INITIAL_SELECTION);
|
||||
}
|
||||
|
||||
// "/fi" lists scope commands instead of searching.
|
||||
@@ -177,7 +189,7 @@ function SearchEverywhereDialog() {
|
||||
() => listing ?? (snap ? buildRows(snap.sections, { headers: !parsed.text.trim(), mix }) : []),
|
||||
[listing, snap, parsed.text, mix],
|
||||
);
|
||||
const active = stableIndex(rows, sel.key, sel.index, sel.moved);
|
||||
const active = stableIndex(rows, sel.key, sel.lead, sel.moved);
|
||||
const activeRow = active >= 0 ? rows[active] : undefined;
|
||||
// Rows still answer an older query (debounce / in-flight search): Enter must wait.
|
||||
const settled =
|
||||
@@ -187,8 +199,20 @@ function SearchEverywhereDialog() {
|
||||
snap.tab === tab &&
|
||||
topResultSettled(snap.sections, mix);
|
||||
const pendingEnter = useRef<{ side: boolean } | null>(null);
|
||||
const opening = useRef(false);
|
||||
|
||||
const select = (index: number) => setSel({ key: rows[index]?.key ?? null, index, moved: true });
|
||||
const chosen = selectedRows(rows, { ...sel, lead: active });
|
||||
const select = (index: number, e?: KeyboardEvent | MouseEvent) => {
|
||||
const anchor = rows.findIndex((r) => r.key === sel.anchorKey);
|
||||
const next = selectRow(
|
||||
rows,
|
||||
{ ...sel, lead: active, anchor: anchor < 0 ? active : anchor },
|
||||
index,
|
||||
rowSelectionMode(e),
|
||||
isSectionRow,
|
||||
);
|
||||
setSel({ ...next, key: rows[next.lead]?.key ?? null, anchorKey: rows[next.anchor]?.key ?? null, moved: true });
|
||||
};
|
||||
/** Typed or picked query; "/files foo" switches to Files with "foo". */
|
||||
const setQuery = (value: string) => {
|
||||
history.current.pos = -1;
|
||||
@@ -246,24 +270,13 @@ function SearchEverywhereDialog() {
|
||||
case "more": {
|
||||
const step = tab === "all" ? MORE_STEP_ALL : MORE_STEP_TAB;
|
||||
setExtra((e) => ({ ...e, [row.section]: (e[row.section] ?? 0) + step }));
|
||||
setSel({ key: null, index: rows.indexOf(row), moved: true });
|
||||
setSel({ ...INITIAL_SELECTION, lead: rows.indexOf(row), moved: true });
|
||||
return;
|
||||
}
|
||||
case "file":
|
||||
return closeThen(
|
||||
() => void openLocation(row.path, row.line !== null ? { line: row.line, col: row.col ?? 0 } : null, { side }),
|
||||
);
|
||||
case "symbol":
|
||||
return closeThen(() => void openLocation(row.path, { line: row.line, col: row.col }, { side }));
|
||||
case "text":
|
||||
return closeThen(
|
||||
() =>
|
||||
void openLocation(
|
||||
row.path,
|
||||
{ line: row.line, col: row.col, endLine: row.line, endCol: row.endCol },
|
||||
{ side },
|
||||
),
|
||||
);
|
||||
return closeThen(() => void openResult(row, { side }));
|
||||
case "action":
|
||||
if (!row.enabled) return;
|
||||
pushRecentAction(row.id);
|
||||
@@ -277,11 +290,27 @@ function SearchEverywhereDialog() {
|
||||
}
|
||||
};
|
||||
|
||||
const activateSelection = (side = false) => {
|
||||
if (opening.current) return;
|
||||
if (chosen.length <= 1) return activate(chosen[0], side);
|
||||
const navigable = chosen.filter(isNavigationRow);
|
||||
if (!navigable.length) return close();
|
||||
opening.current = true;
|
||||
pushSearchHistory(tab, query);
|
||||
void openInListOrder(
|
||||
navigable,
|
||||
activeRow && isNavigationRow(activeRow) ? activeRow : undefined,
|
||||
openResult,
|
||||
side,
|
||||
() => new Promise((resolve) => closeThen(resolve)),
|
||||
);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const pending = pendingEnter.current;
|
||||
if (!pending || !settled) return;
|
||||
pendingEnter.current = null;
|
||||
activate(activeRow, pending.side);
|
||||
activateSelection(pending.side);
|
||||
});
|
||||
|
||||
/** IDEA's ⌥↵ on an action: record a shortcut for it (written to the active keymap). */
|
||||
@@ -291,7 +320,7 @@ function SearchEverywhereDialog() {
|
||||
const row = activeRow;
|
||||
if (!row || row.kind === "section" || row.kind === "more" || row.kind === "command") return;
|
||||
if (row.kind === "action") return assignShortcut(row.id);
|
||||
const anchor = listRef.current?.querySelector('[aria-selected="true"]') ?? inputRef.current;
|
||||
const anchor = listRef.current?.querySelector('[data-lead="true"]') ?? inputRef.current;
|
||||
if (!anchor) return;
|
||||
const copy = (text: string) => void navigator.clipboard?.writeText(text).catch(() => {});
|
||||
const items: MenuItem[] =
|
||||
@@ -331,7 +360,7 @@ function SearchEverywhereDialog() {
|
||||
closeThen(() => void executeAction("find.inPath", { query: parsed.text }));
|
||||
else if (listing) activate(activeRow);
|
||||
else if (!settled && !sel.moved) pendingEnter.current = { side: e.shiftKey };
|
||||
else activate(activeRow, e.shiftKey);
|
||||
else activateSelection(e.shiftKey);
|
||||
return;
|
||||
}
|
||||
if (e.altKey && (e.key === "ArrowUp" || e.key === "ArrowDown")) {
|
||||
@@ -347,15 +376,15 @@ function SearchEverywhereDialog() {
|
||||
const jump = groupJump(rows, active, e.key === "PageDown" || e.key === "ArrowDown" ? 1 : -1);
|
||||
if (jump !== null) {
|
||||
e.preventDefault();
|
||||
select(jump);
|
||||
select(jump, e);
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (!["ArrowDown", "ArrowUp", "PageDown", "PageUp"].includes(e.key)) return;
|
||||
const next = listNavigate(e.key, { items: rows, active, isSection: isSectionRow, wrap: true, pageSize: 10 });
|
||||
const next = listNavigate(e.key, { items: rows, active, isSection: isSectionRow, wrap: !e.shiftKey, pageSize: 10 });
|
||||
if (next === null) return;
|
||||
e.preventDefault();
|
||||
select(next);
|
||||
select(next, e);
|
||||
};
|
||||
|
||||
const maxList = preview ? MAX_LIST_PREVIEW : MAX_LIST;
|
||||
@@ -522,7 +551,8 @@ function SearchEverywhereDialog() {
|
||||
<span className={styles.section}>{r.kind === "section" ? <T>{r.title}</T> : ""}</span>
|
||||
)}
|
||||
activeIndex={active}
|
||||
onActiveChange={select}
|
||||
onSelect={select}
|
||||
isSelected={(r) => !!sel.keys?.has(r.key)}
|
||||
onActivate={(r, _i, e) => activate(r, (e as MouseEvent).shiftKey)}
|
||||
focusable={false}
|
||||
rowHeight={ROW}
|
||||
@@ -844,3 +874,21 @@ function FileRow({
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
type NavigationRow = Extract<SeRow, { kind: "file" | "symbol" | "text" }>;
|
||||
function isNavigationRow(row: SeRow): row is NavigationRow {
|
||||
return row.kind === "file" || row.kind === "symbol" || row.kind === "text";
|
||||
}
|
||||
function openResult(row: NavigationRow, options: Parameters<typeof openLocation>[2]): Promise<void> {
|
||||
if (row.kind === "file")
|
||||
return openLocation(row.path, row.line !== null ? { line: row.line, col: row.col ?? 0 } : null, options);
|
||||
return openLocation(
|
||||
row.path,
|
||||
{
|
||||
line: row.line,
|
||||
col: row.col,
|
||||
...(row.kind === "text" ? { endLine: row.line, endCol: row.endCol } : {}),
|
||||
},
|
||||
options,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,52 +1 @@
|
||||
/**
|
||||
* Multi-selection of the Find in Files results list: click / ↑↓ select one row,
|
||||
* ⇧ extends from the anchor, ⌘/Ctrl toggles a row (IDEA's results table).
|
||||
*/
|
||||
|
||||
export interface RowSelection {
|
||||
/** Active row (keyboard focus, preview). */
|
||||
lead: number;
|
||||
/** Start of ⇧ ranges. */
|
||||
anchor: number;
|
||||
/** Keys of the selected rows; `null` when only the lead is selected. */
|
||||
keys: ReadonlySet<string> | null;
|
||||
}
|
||||
|
||||
export const SINGLE_FIRST: RowSelection = { lead: 0, anchor: 0, keys: null };
|
||||
|
||||
export function selectRow(
|
||||
rows: ReadonlyArray<{ key: string }>,
|
||||
sel: RowSelection,
|
||||
index: number,
|
||||
mode: "single" | "range" | "toggle" = "single",
|
||||
): RowSelection {
|
||||
if (index < 0 || index >= rows.length) return sel;
|
||||
if (mode === "range") {
|
||||
const anchor = Math.min(Math.max(0, sel.anchor), rows.length - 1);
|
||||
const [a, b] = anchor <= index ? [anchor, index] : [index, anchor];
|
||||
return { lead: index, anchor, keys: new Set(rows.slice(a, b + 1).map((r) => r.key)) };
|
||||
}
|
||||
if (mode === "toggle") {
|
||||
const keys = new Set(sel.keys ?? (rows[sel.lead] ? [rows[sel.lead]!.key] : []));
|
||||
const key = rows[index]!.key;
|
||||
if (!keys.has(key)) return { lead: index, anchor: index, keys: keys.add(key) };
|
||||
keys.delete(key);
|
||||
const rest = rows.findLastIndex((r) => keys.has(r.key));
|
||||
if (rest < 0) return { lead: index, anchor: index, keys: null };
|
||||
return { lead: rest, anchor: rest, keys };
|
||||
}
|
||||
return { lead: index, anchor: index, keys: null };
|
||||
}
|
||||
|
||||
/** The selected rows in list order (the lead when nothing else is selected). */
|
||||
export function selectedRows<T extends { key: string }>(rows: readonly T[], sel: RowSelection): T[] {
|
||||
if (!rows.length) return [];
|
||||
const lead = Math.min(sel.lead, rows.length - 1);
|
||||
if (!sel.keys) return [rows[lead]!];
|
||||
const out = rows.filter((r) => sel.keys!.has(r.key));
|
||||
return out.length ? out : [rows[lead]!];
|
||||
}
|
||||
|
||||
export function isRowSelected(sel: RowSelection, key: string): boolean {
|
||||
return !!sel.keys?.has(key);
|
||||
}
|
||||
export { SINGLE_FIRST, isRowSelected, selectRow, selectedRows, type RowSelection } from "@/ui/rowSelection";
|
||||
|
||||
@@ -5,7 +5,21 @@ import { editorsFor, getNavigationHistory } from "@/editor/api";
|
||||
import { call, commands, type OutlineItem } from "@/platform/ipc";
|
||||
import { shortcutLabel } from "@/platform/keymap";
|
||||
import { useProject } from "@/platform/project";
|
||||
import { Checkbox, Dialog, HighlightedText, Icon, List, fileIconName, listNavigate, T } from "@/ui";
|
||||
import {
|
||||
Checkbox,
|
||||
Dialog,
|
||||
HighlightedText,
|
||||
Icon,
|
||||
List,
|
||||
fileIconName,
|
||||
listNavigate,
|
||||
selectRow,
|
||||
selectedRows,
|
||||
rowSelectionMode,
|
||||
SINGLE_FIRST,
|
||||
type RowSelection,
|
||||
T,
|
||||
} from "@/ui";
|
||||
import {
|
||||
maybeEditorService,
|
||||
toolWindowService,
|
||||
@@ -15,7 +29,7 @@ import {
|
||||
type ToolWindowContribution,
|
||||
} from "@/workbench/contributions";
|
||||
import { highlightTokens, languageForPath, type CodeToken } from "@/editor/lsp/markdown";
|
||||
import { basename, openLocation } from "../common/open";
|
||||
import { basename, openInListOrder, openLocation } from "../common/open";
|
||||
import { outlinePathAt } from "../structure/outline";
|
||||
import { fromNavigationHistory, isDismissed, mergeLocations, type RecentLocation } from "../common/locations";
|
||||
import { dismissLocation, useTracking } from "../common/tracking";
|
||||
@@ -73,6 +87,14 @@ function RecentFilesDialog() {
|
||||
const [column, setColumn] = useState<"files" | "tools">("files");
|
||||
const [fileIndex, setFileIndex] = useState<number | null>(null);
|
||||
const [toolIndex, setToolIndex] = useState(0);
|
||||
const opening = useRef(false);
|
||||
const [previousEditedOnly, setPreviousEditedOnly] = useState(editedOnly);
|
||||
const [fileSelection, setFileSelection] = useState<RowSelection>(SINGLE_FIRST);
|
||||
if (previousEditedOnly !== editedOnly) {
|
||||
setPreviousEditedOnly(editedOnly);
|
||||
setFileSelection(SINGLE_FIRST);
|
||||
setFileIndex(null);
|
||||
}
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
// The current editor: a file path, or the key of a non-file editor (Switcher lists those too).
|
||||
const activePath = useMemo(() => {
|
||||
@@ -106,6 +128,7 @@ function RecentFilesDialog() {
|
||||
),
|
||||
[paths, tabs, removed, query, root, edited, editedOnly],
|
||||
);
|
||||
const fileRows = useMemo(() => files.map((f) => ({ ...f, key: f.path })), [files]);
|
||||
const tools = useMemo(() => switcherToolWindows(toolWindows.values(), !!root, query), [toolWindows, root, query]);
|
||||
const fIndex = files.length
|
||||
? Math.min(fileIndex ?? initialFileIndex(files, activePath, query), files.length - 1)
|
||||
@@ -123,6 +146,31 @@ function RecentFilesDialog() {
|
||||
);
|
||||
};
|
||||
|
||||
const selectFile = (index: number, e?: KeyboardEvent | ReactMouseEvent) => {
|
||||
const next = selectRow(
|
||||
fileRows,
|
||||
{ ...fileSelection, lead: fIndex, anchor: fileSelection.keys ? fileSelection.anchor : fIndex },
|
||||
index,
|
||||
switcher ? "single" : rowSelectionMode(e),
|
||||
);
|
||||
setFileSelection(next);
|
||||
setFileIndex(next.lead);
|
||||
setColumn("files");
|
||||
};
|
||||
const openFiles = (side = false) => {
|
||||
if (opening.current) return;
|
||||
const chosen = selectedRows(fileRows, { ...fileSelection, lead: fIndex });
|
||||
if (chosen.length <= 1) return openFile(chosen[0], side);
|
||||
opening.current = true;
|
||||
void openInListOrder(
|
||||
chosen,
|
||||
fileRows[fIndex],
|
||||
(f, options) => openLocation(f.path, null, options),
|
||||
side,
|
||||
() => new Promise((resolve) => afterClose(resolve)),
|
||||
);
|
||||
};
|
||||
|
||||
const openTool = (tw: ToolWindowContribution | undefined) => {
|
||||
if (tw) afterClose(() => toolWindowService().show(tw.id, true));
|
||||
};
|
||||
@@ -143,6 +191,7 @@ function RecentFilesDialog() {
|
||||
if (!switcher) void call(commands.removeRecent(item.path)).catch(() => {});
|
||||
setRemoved((r) => new Set(r).add(item.path));
|
||||
setFileIndex(fIndex);
|
||||
setFileSelection({ lead: fIndex, anchor: fIndex, keys: null });
|
||||
};
|
||||
|
||||
// Switcher: each ⌃⇥ moves one row, past the end into the other column; releasing Ctrl opens the selection.
|
||||
@@ -182,7 +231,7 @@ function RecentFilesDialog() {
|
||||
// Nothing recent matches: carry the text over to Go to File.
|
||||
const text = query.trim();
|
||||
afterClose(() => showSearchEverywhere("files", { query: text }));
|
||||
} else if (effectiveColumn === "files") openFile(files[fIndex], e.shiftKey);
|
||||
} else if (effectiveColumn === "files") openFiles(e.shiftKey);
|
||||
else openTool(tools[tIndex]?.tw);
|
||||
return;
|
||||
}
|
||||
@@ -201,6 +250,7 @@ function RecentFilesDialog() {
|
||||
if (e.key === "Tab" || ((e.key === "ArrowLeft" || e.key === "ArrowRight") && !query)) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setFileSelection(SINGLE_FIRST);
|
||||
setColumn(
|
||||
e.key === "ArrowLeft"
|
||||
? "tools"
|
||||
@@ -215,8 +265,8 @@ function RecentFilesDialog() {
|
||||
if (!NAV_KEYS.includes(e.key)) return;
|
||||
e.preventDefault();
|
||||
if (effectiveColumn === "files") {
|
||||
const next = listNavigate(e.key, { items: files, active: fIndex, wrap: true });
|
||||
if (next !== null) setFileIndex(next);
|
||||
const next = listNavigate(e.key, { items: files, active: fIndex, wrap: switcher || !e.shiftKey });
|
||||
if (next !== null) selectFile(next, e);
|
||||
} else {
|
||||
const next = listNavigate(e.key, { items: tools, active: tIndex, wrap: true });
|
||||
if (next !== null) setToolIndex(next);
|
||||
@@ -248,6 +298,7 @@ function RecentFilesDialog() {
|
||||
onChange={(e) => {
|
||||
setQuery(e.target.value);
|
||||
setFileIndex(null);
|
||||
setFileSelection(SINGLE_FIRST);
|
||||
setToolIndex(0);
|
||||
}}
|
||||
onKeyDown={onKeyDown}
|
||||
@@ -280,6 +331,7 @@ function RecentFilesDialog() {
|
||||
getKey={(t) => t.tw.id}
|
||||
activeIndex={effectiveColumn === "tools" ? tIndex : -1}
|
||||
onActiveChange={(i) => {
|
||||
setFileSelection(SINGLE_FIRST);
|
||||
setToolIndex(i);
|
||||
setColumn("tools");
|
||||
}}
|
||||
@@ -307,10 +359,9 @@ function RecentFilesDialog() {
|
||||
items={files}
|
||||
getKey={(f) => f.path}
|
||||
activeIndex={effectiveColumn === "files" ? fIndex : -1}
|
||||
onActiveChange={(i) => {
|
||||
setFileIndex(i);
|
||||
setColumn("files");
|
||||
}}
|
||||
onActiveChange={switcher ? (i) => selectFile(i) : undefined}
|
||||
onSelect={switcher ? undefined : selectFile}
|
||||
isSelected={(f) => effectiveColumn === "files" && !!fileSelection.keys?.has(f.path)}
|
||||
onActivate={(f, _i, e) => openFile(f, e.shiftKey)}
|
||||
focusable={false}
|
||||
rowHeight={24}
|
||||
|
||||
+13
-3
@@ -16,6 +16,7 @@ import {
|
||||
} from "react";
|
||||
import clsx from "clsx";
|
||||
import { useVirtualizer } from "@tanstack/react-virtual";
|
||||
import { rowSelectionMode } from "./rowSelection";
|
||||
import styles from "./List.module.css";
|
||||
|
||||
export interface ListRowState {
|
||||
@@ -32,6 +33,8 @@ export interface ListProps<T> {
|
||||
activeIndex?: number;
|
||||
defaultActiveIndex?: number;
|
||||
onActiveChange?: (index: number) => void;
|
||||
/** Opt into modifier selection; modified clicks select without activating. */
|
||||
onSelect?: (index: number, e: KeyboardEvent | MouseEvent) => void;
|
||||
/** Rows selected besides the active one (multi-selection); drawn like the active row. */
|
||||
isSelected?: (item: T, index: number) => boolean;
|
||||
onActivate?: (item: T, index: number, e: KeyboardEvent | MouseEvent) => void;
|
||||
@@ -115,6 +118,7 @@ export function List<T>({
|
||||
activeIndex,
|
||||
defaultActiveIndex,
|
||||
onActiveChange,
|
||||
onSelect,
|
||||
isSelected,
|
||||
onActivate,
|
||||
onContextMenu,
|
||||
@@ -169,7 +173,8 @@ export function List<T>({
|
||||
const next = listNavigate(e.key, { items, active, isSection, pageSize });
|
||||
if (next === null) return;
|
||||
e.preventDefault();
|
||||
if (next !== active) setActive(next);
|
||||
if (onSelect) onSelect(next, e);
|
||||
else if (next !== active) setActive(next);
|
||||
};
|
||||
|
||||
if (!items.length && empty) return <div className={clsx(styles.list, styles.emptyWrap, className)}>{empty}</div>;
|
||||
@@ -178,6 +183,7 @@ export function List<T>({
|
||||
<div
|
||||
ref={scrollRef}
|
||||
role="listbox"
|
||||
aria-multiselectable={onSelect ? true : undefined}
|
||||
aria-label={aria["aria-label"]}
|
||||
aria-labelledby={aria["aria-labelledby"]}
|
||||
aria-activedescendant={
|
||||
@@ -209,6 +215,7 @@ export function List<T>({
|
||||
id={`${baseId}-${v.index}`}
|
||||
role="option"
|
||||
aria-selected={selected}
|
||||
data-lead={isActive || undefined}
|
||||
aria-posinset={optionIndex.idx[v.index]}
|
||||
aria-setsize={optionIndex.count}
|
||||
className={clsx(styles.row, selected && styles.active)}
|
||||
@@ -217,9 +224,12 @@ export function List<T>({
|
||||
onMouseDown={(e) => {
|
||||
if (e.button !== 0) return;
|
||||
if (!focusable) e.preventDefault();
|
||||
setActive(v.index);
|
||||
if (!onSelect) setActive(v.index);
|
||||
}}
|
||||
onClick={(e) => {
|
||||
onSelect?.(v.index, e);
|
||||
if (!onSelect || rowSelectionMode(e) === "single") onActivate?.(item, v.index, e);
|
||||
}}
|
||||
onClick={(e) => onActivate?.(item, v.index, e)}
|
||||
onContextMenu={onContextMenu ? (e) => onContextMenu(item, v.index, e) : undefined}
|
||||
>
|
||||
{renderItem(item, { active: isActive, index: v.index })}
|
||||
|
||||
@@ -33,3 +33,4 @@ export { lazyComponent, prefetchLazyComponents } from "./lazy";
|
||||
export { T } from "./T";
|
||||
export { selectableText, copyMenuFor, copyText } from "./selectableText";
|
||||
export { useSelectionBoundary } from "./useSelectionBoundary";
|
||||
export * from "./rowSelection";
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { selectRow, selectedRows } from "./rowSelection";
|
||||
|
||||
describe("list selection across sections", () => {
|
||||
it("selects only options across headers in either direction", () => {
|
||||
const rows = [
|
||||
{ key: "files", section: true },
|
||||
{ key: "a", section: false },
|
||||
{ key: "symbols", section: true },
|
||||
{ key: "b", section: false },
|
||||
{ key: "actions", section: true },
|
||||
{ key: "c", section: false },
|
||||
];
|
||||
const single = { lead: 3, anchor: 3, keys: null };
|
||||
const section = (row: (typeof rows)[number]) => row.section;
|
||||
const down = selectRow(rows, single, 5, "range", section);
|
||||
expect(selectedRows(rows, down).map((r) => r.key)).toEqual(["b", "c"]);
|
||||
const up = selectRow(rows, down, 1, "range", section);
|
||||
expect(selectedRows(rows, up).map((r) => r.key)).toEqual(["a", "b"]);
|
||||
expect(selectRow(rows, up, 2, "toggle", section)).toBe(up);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,74 @@
|
||||
import { hasPrimaryModifier } from "@/platform/keymap";
|
||||
|
||||
/**
|
||||
* Multi-selection of list rows: click / ↑↓ select one row,
|
||||
* ⇧ extends from the anchor, ⌘/Ctrl toggles a row.
|
||||
*/
|
||||
|
||||
export interface RowSelection {
|
||||
/** Active row (keyboard focus, preview). */
|
||||
lead: number;
|
||||
/** Start of ⇧ ranges. */
|
||||
anchor: number;
|
||||
/** Keys of the selected rows; `null` when only the lead is selected. */
|
||||
keys: ReadonlySet<string> | null;
|
||||
}
|
||||
|
||||
export const SINGLE_FIRST: RowSelection = { lead: 0, anchor: 0, keys: null };
|
||||
|
||||
export function selectRow<T extends { key: string }>(
|
||||
rows: readonly T[],
|
||||
sel: RowSelection,
|
||||
index: number,
|
||||
mode: "single" | "range" | "toggle" = "single",
|
||||
isSection?: (row: T) => boolean,
|
||||
): RowSelection {
|
||||
if (index < 0 || index >= rows.length || isSection?.(rows[index]!)) return sel;
|
||||
if (mode === "range") {
|
||||
const anchor = Math.min(Math.max(0, sel.anchor), rows.length - 1);
|
||||
const [a, b] = anchor <= index ? [anchor, index] : [index, anchor];
|
||||
return {
|
||||
lead: index,
|
||||
anchor,
|
||||
keys: new Set(
|
||||
rows
|
||||
.slice(a, b + 1)
|
||||
.filter((r) => !isSection?.(r))
|
||||
.map((r) => r.key),
|
||||
),
|
||||
};
|
||||
}
|
||||
if (mode === "toggle") {
|
||||
const keys = new Set(sel.keys ?? (rows[sel.lead] ? [rows[sel.lead]!.key] : []));
|
||||
const key = rows[index]!.key;
|
||||
if (!keys.has(key)) return { lead: index, anchor: index, keys: keys.add(key) };
|
||||
keys.delete(key);
|
||||
const rest = rows.findLastIndex((r) => keys.has(r.key));
|
||||
if (rest < 0) return { lead: index, anchor: index, keys: null };
|
||||
return { lead: rest, anchor: rest, keys };
|
||||
}
|
||||
return { lead: index, anchor: index, keys: null };
|
||||
}
|
||||
|
||||
/** The selected rows in list order (the lead when nothing else is selected). */
|
||||
export function selectedRows<T extends { key: string }>(rows: readonly T[], sel: RowSelection): T[] {
|
||||
if (!rows.length || sel.lead < 0) return [];
|
||||
const lead = Math.min(sel.lead, rows.length - 1);
|
||||
if (!sel.keys) return [rows[lead]!];
|
||||
const out = rows.filter((r) => sel.keys!.has(r.key));
|
||||
return out.length ? out : [rows[lead]!];
|
||||
}
|
||||
|
||||
export function isRowSelected(sel: RowSelection, key: string): boolean {
|
||||
return !!sel.keys?.has(key);
|
||||
}
|
||||
|
||||
export function rowSelectionMode(e?: {
|
||||
shiftKey: boolean;
|
||||
metaKey: boolean;
|
||||
ctrlKey: boolean;
|
||||
type: string;
|
||||
}): "single" | "range" | "toggle" {
|
||||
if (e?.shiftKey) return "range";
|
||||
return e?.type === "click" && hasPrimaryModifier(e) ? "toggle" : "single";
|
||||
}
|
||||
Reference in New Issue
Block a user