feat(search): Jump between result groups in Search Everywhere

Page Down / Page Up and ⌘↓ / ⌘↑ (Ctrl+Down / Ctrl+Up on Windows) move the
selection to the first result of the next or previous group (Classes,
Files, Symbols, Actions…), including in the All tab, where groups have no
header rows. ⌘↑ inside a popup no longer jumps to the navigation bar.
This commit is contained in:
l0ng-ai
2026-10-29 02:22:30 +08:00
parent aeb190ffda
commit e17a41fae9
4 changed files with 76 additions and 0 deletions
+2
View File
@@ -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.
@@ -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();
});
});
@@ -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;
+19
View File
@@ -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