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:
l0ng-ai
2026-11-01 16:05:16 +08:00
committed by GitHub
parent 5c0a5239fa
commit d3e63d8ff0
9 changed files with 535 additions and 94 deletions
+276 -4
View File
@@ -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 />);
+14
View File
@@ -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
View File
@@ -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";
+60 -9
View File
@@ -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
View File
@@ -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 })}
+1
View File
@@ -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";
+22
View File
@@ -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);
});
});
+74
View File
@@ -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";
}