From d3e63d8ff0febf8a8f82a58efa20aa9e846553e5 Mon Sep 17 00:00:00 2001 From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Date: Sun, 1 Nov 2026 16:05:16 +0800 Subject: [PATCH] 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 --- .../search/__tests__/dialogs.test.tsx | 280 +++++++++++++++++- src/features/search/common/open.ts | 14 + .../search/everywhere/SearchEverywhere.tsx | 100 +++++-- src/features/search/find/rowSelection.ts | 53 +--- src/features/search/recent/RecentPopups.tsx | 69 ++++- src/ui/List.tsx | 16 +- src/ui/index.ts | 1 + src/ui/rowSelection.test.ts | 22 ++ src/ui/rowSelection.ts | 74 +++++ 9 files changed, 535 insertions(+), 94 deletions(-) create mode 100644 src/ui/rowSelection.test.ts create mode 100644 src/ui/rowSelection.ts diff --git a/src/features/search/__tests__/dialogs.test.tsx b/src/features/search/__tests__/dialogs.test.tsx index 3e10bacd..202c6dff 100644 --- a/src/features/search/__tests__/dialogs.test.tsx +++ b/src/features/search/__tests__/dialogs.test.tsx @@ -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 = 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()), + 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> = ["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(); + 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(); + 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(); + 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(); + 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(); + 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(); diff --git a/src/features/search/common/open.ts b/src/features/search/common/open.ts index c5f52601..a11da385 100644 --- a/src/features/search/common/open.ts +++ b/src/features/search/common/open.ts @@ -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( + items: readonly T[], + lead: T | undefined, + open: (item: T, options: Omit) => Promise, + side = false, + afterOpen?: () => Promise, +): Promise { + 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 }); +} diff --git a/src/features/search/everywhere/SearchEverywhere.tsx b/src/features/search/everywhere/SearchEverywhere.tsx index f5bd6c41..c5c50460 100644 --- a/src/features/search/everywhere/SearchEverywhere.tsx +++ b/src/features/search/everywhere/SearchEverywhere.tsx @@ -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 ? : 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>>({}); const [snap, setSnap] = useState(null); const [controller] = useState(() => new SeController(ipcDeps(ctx), setSnap)); - const [sel, setSel] = useState({ key: null, index: -1, moved: false }); + const [sel, setSel] = useState(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() { {r.kind === "section" ? {r.title} : ""} )} 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; +function isNavigationRow(row: SeRow): row is NavigationRow { + return row.kind === "file" || row.kind === "symbol" || row.kind === "text"; +} +function openResult(row: NavigationRow, options: Parameters[2]): Promise { + 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, + ); +} diff --git a/src/features/search/find/rowSelection.ts b/src/features/search/find/rowSelection.ts index 19d35b58..6930c007 100644 --- a/src/features/search/find/rowSelection.ts +++ b/src/features/search/find/rowSelection.ts @@ -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 | 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(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"; diff --git a/src/features/search/recent/RecentPopups.tsx b/src/features/search/recent/RecentPopups.tsx index efab57b6..a6b79e08 100644 --- a/src/features/search/recent/RecentPopups.tsx +++ b/src/features/search/recent/RecentPopups.tsx @@ -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(null); const [toolIndex, setToolIndex] = useState(0); + const opening = useRef(false); + const [previousEditedOnly, setPreviousEditedOnly] = useState(editedOnly); + const [fileSelection, setFileSelection] = useState(SINGLE_FIRST); + if (previousEditedOnly !== editedOnly) { + setPreviousEditedOnly(editedOnly); + setFileSelection(SINGLE_FIRST); + setFileIndex(null); + } const inputRef = useRef(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} diff --git a/src/ui/List.tsx b/src/ui/List.tsx index 5a361ddc..71417a26 100644 --- a/src/ui/List.tsx +++ b/src/ui/List.tsx @@ -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 { 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({ activeIndex, defaultActiveIndex, onActiveChange, + onSelect, isSelected, onActivate, onContextMenu, @@ -169,7 +173,8 @@ export function List({ 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
{empty}
; @@ -178,6 +183,7 @@ export function List({
({ 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({ 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 })} diff --git a/src/ui/index.ts b/src/ui/index.ts index 21427a32..66997518 100644 --- a/src/ui/index.ts +++ b/src/ui/index.ts @@ -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"; diff --git a/src/ui/rowSelection.test.ts b/src/ui/rowSelection.test.ts new file mode 100644 index 00000000..c06936ae --- /dev/null +++ b/src/ui/rowSelection.test.ts @@ -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); + }); +}); diff --git a/src/ui/rowSelection.ts b/src/ui/rowSelection.ts new file mode 100644 index 00000000..05c8261d --- /dev/null +++ b/src/ui/rowSelection.ts @@ -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 | null; +} + +export const SINGLE_FIRST: RowSelection = { lead: 0, anchor: 0, keys: null }; + +export function selectRow( + 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(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"; +}