diff --git a/src/editor/core/actions.ts b/src/editor/core/actions.ts index 75a52f74..018dd05b 100644 --- a/src/editor/core/actions.ts +++ b/src/editor/core/actions.ts @@ -782,6 +782,8 @@ export function registerEditorActions(): () => void { category: "Navigate", keywords: ["breadcrumbs"], when: "projectOpen", + // ⌘↑ / ⌘↓ inside a popup (Search Everywhere's previous / next group) stay with it. + keyWhen: "!popupOpen", run: () => { if (activeSlot()) return openNavBarPopup(); // No editor: the status bar breadcrumbs (StatusItems › Breadcrumbs) take focus. diff --git a/src/features/search/__tests__/everywhereGroups.test.ts b/src/features/search/__tests__/everywhereGroups.test.ts new file mode 100644 index 00000000..42697f9c --- /dev/null +++ b/src/features/search/__tests__/everywhereGroups.test.ts @@ -0,0 +1,42 @@ +import { describe, expect, it } from "vitest"; +import { groupJump, type SeRow } from "../everywhere/model"; + +const section = (id: string) => ({ kind: "section", key: `#${id}`, section: id, title: id }) as unknown as SeRow; +const more = (id: string) => ({ kind: "more", key: `more:${id}`, section: id }) as unknown as SeRow; +const item = (key: string, id: string) => ({ kind: "file", key, section: id }) as unknown as SeRow; + +// 0 Classes, 1 a, 2 b, 3 Files, 4 c, 5 more, 6 Actions, 7 d +const rows = [ + section("classes"), + item("a", "classes"), + item("b", "classes"), + section("files"), + item("c", "files"), + more("files"), + section("actions"), + item("d", "actions"), +]; + +describe("groupJump", () => { + it("moves to the first item of the next group", () => { + expect(groupJump(rows, 1, 1)).toBe(4); + expect(groupJump(rows, 5, 1)).toBe(7); + expect(groupJump(rows, 7, 1)).toBe(7); + }); + + it("moves to the start of the current group, then the previous one", () => { + expect(groupJump(rows, 5, -1)).toBe(4); + expect(groupJump(rows, 4, -1)).toBe(1); + expect(groupJump(rows, 2, -1)).toBe(1); + }); + + it("finds groups without header rows", () => { + const flat = [item("a", "classes"), item("b", "classes"), item("c", "files")]; + expect(groupJump(flat, 0, 1)).toBe(2); + expect(groupJump(flat, 2, -1)).toBe(0); + }); + + it("is null with a single group", () => { + expect(groupJump([item("a", "files"), item("b", "files")], 0, 1)).toBeNull(); + }); +}); diff --git a/src/features/search/everywhere/SearchEverywhere.tsx b/src/features/search/everywhere/SearchEverywhere.tsx index d55ff6ec..0ddb04ed 100644 --- a/src/features/search/everywhere/SearchEverywhere.tsx +++ b/src/features/search/everywhere/SearchEverywhere.tsx @@ -36,6 +36,7 @@ import { SECTION_TITLES, TABS, buildRows, + groupJump, isSectionRow, parseCommand, stableIndex, @@ -338,6 +339,18 @@ function SearchEverywhereDialog() { browseHistory(e.key === "ArrowUp" ? 1 : -1); return; } + const groupKey = + e.key === "PageDown" || + e.key === "PageUp" || + (hasPrimaryModifier(e) && (e.key === "ArrowDown" || e.key === "ArrowUp")); + if (groupKey) { + const jump = groupJump(rows, active, e.key === "PageDown" || e.key === "ArrowDown" ? 1 : -1); + if (jump !== null) { + e.preventDefault(); + select(jump); + return; + } + } if (!["ArrowDown", "ArrowUp", "PageDown", "PageUp"].includes(e.key)) return; const next = listNavigate(e.key, { items: rows, active, isSection: isSectionRow, wrap: true, pageSize: 10 }); if (next === null) return; diff --git a/src/features/search/everywhere/model.ts b/src/features/search/everywhere/model.ts index bbe92888..3b085f0c 100644 --- a/src/features/search/everywhere/model.ts +++ b/src/features/search/everywhere/model.ts @@ -453,6 +453,25 @@ export function buildRows( export const isSectionRow = (r: SeRow) => r.kind === "section"; +/** + * Next / Previous Group (PageDown / PageUp, ⌘↓ / ⌘↑): the first item of the + * next section, or of the current (then previous) one; null without sections. + */ +export function groupJump(rows: readonly SeRow[], active: number, dir: 1 | -1): number | null { + // Groups start where the section changes (the All tab may show no header rows). + const starts: number[] = []; + let last: string | null = null; + rows.forEach((r, i) => { + if (isSectionRow(r) || r.kind === "command" || !("section" in r)) return; + if (r.section !== last) starts.push(i); + last = r.section; + }); + if (starts.length < 2) return null; + if (dir === 1) return starts.find((s) => s > active) ?? starts[starts.length - 1]!; + const current = [...starts].reverse().find((s) => s <= active) ?? starts[0]!; + return current < active ? current : ([...starts].reverse().find((s) => s < active) ?? starts[0]!); +} + // --------------------------------------------------------------------------- // "/" commands