дзеркало
https://github.com/l0ng-ai/openidea.git
синхронізовано 2026-11-09 11:15:40 +02: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
Цей коміт міститься в:
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 })}
|
||||
|
||||
@@ -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
Заблокувати користувача