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
Цей коміт міститься в:
l0ng-ai authored and GitHub committed 2026-11-01 16:05:16 +08:00
1 джерело 5c0a5239fa
коміт d3e63d8ff0
9 files changed
+535 -94

No files matched your search

+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 })}
+1
Переглянути файл
@@ -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
Переглянути файл
@@ -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
Переглянути файл
@@ -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";
}