feat(maestro): add the web command bar's > and ~ sigils
`>` narrows the bar to commands, matched on the label only with the chord shown beside each row and deduped by action. `~` narrows to directories over recent dirs and the project list, filled in from /api/dirs as a path is typed; Enter fast-forward-spawns there, falling back to the pre-filled spawn form when a default cannot resolve. Without a sigil the list stays mixed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
e6a254b734
commit
9fe9fe4628
+205
-5
@@ -6,13 +6,19 @@ import {
|
||||
barGroups,
|
||||
barRows,
|
||||
closeCommandBar,
|
||||
commandBarCandidates,
|
||||
dedupeCommands,
|
||||
directoryBarCandidates,
|
||||
directoryCandidate,
|
||||
isCommandBarOpen,
|
||||
openCommandBar,
|
||||
orderSessionsForBar,
|
||||
parseBarQuery,
|
||||
sessionBarCandidates,
|
||||
sessionCandidate,
|
||||
SPAWN_DIRECTORY_EVENT,
|
||||
} from "./commandBar.js";
|
||||
import { COMMAND_SPECS } from "./commands.js";
|
||||
import { COMMAND_SPECS, type Command } from "./commands.js";
|
||||
import type { Session } from "./types.js";
|
||||
|
||||
function session(id: string, over: Partial<Session> = {}): Session {
|
||||
@@ -173,8 +179,12 @@ describe("the overlay", () => {
|
||||
session("notes", { metadata: { name: "notes" }, current_cwd: "/home/me/notes" }),
|
||||
];
|
||||
|
||||
// Sessions only: the mixed list's command half is the sigil describe's
|
||||
// business, and the registry is whatever another test file happened to bind.
|
||||
function open(over: Parameters<typeof openCommandBar>[0] | null = null) {
|
||||
return openCommandBar(over ?? { sessions, homeDir: "/home/me", onOpenSession: () => {} });
|
||||
return openCommandBar(
|
||||
over ?? { sessions, homeDir: "/home/me", commands: [], onOpenSession: () => {} },
|
||||
);
|
||||
}
|
||||
|
||||
it("is a registry command, so the help overlay lists it", () => {
|
||||
@@ -252,7 +262,7 @@ describe("the overlay", () => {
|
||||
const seen: string[] = [];
|
||||
const listener = (e: Event) => seen.push((e as CustomEvent<string>).detail);
|
||||
document.addEventListener(SESSION_SELECT_EVENT, listener);
|
||||
openCommandBar({ sessions, homeDir: "/home/me" });
|
||||
openCommandBar({ sessions, homeDir: "/home/me", commands: [] });
|
||||
press("ArrowDown");
|
||||
press("Enter");
|
||||
document.removeEventListener(SESSION_SELECT_EVENT, listener);
|
||||
@@ -262,7 +272,7 @@ describe("the overlay", () => {
|
||||
|
||||
it("Enter on an empty result list does nothing", () => {
|
||||
const onOpenSession = vi.fn();
|
||||
open({ sessions, homeDir: "/home/me", onOpenSession });
|
||||
open({ sessions, homeDir: "/home/me", commands: [], onOpenSession });
|
||||
type("zzz");
|
||||
press("Enter");
|
||||
expect(onOpenSession).not.toHaveBeenCalled();
|
||||
@@ -301,9 +311,199 @@ describe("the overlay", () => {
|
||||
|
||||
it("clicking a row opens that session", () => {
|
||||
const onOpenSession = vi.fn();
|
||||
open({ sessions, homeDir: "/home/me", onOpenSession });
|
||||
open({ sessions, homeDir: "/home/me", commands: [], onOpenSession });
|
||||
(document.querySelectorAll(".cmdbar-row")[1] as HTMLElement).click();
|
||||
expect(onOpenSession).toHaveBeenCalledWith("worker");
|
||||
expect(isCommandBarOpen()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// ---- the sigils (#150) ----
|
||||
|
||||
function command(id: string, label: string, keys: string[], run = () => {}): Command {
|
||||
return { id, label, group: "Test", keys, scope: "global", run };
|
||||
}
|
||||
|
||||
const flush = () => new Promise((resolve) => setTimeout(resolve, 0));
|
||||
|
||||
function rowKeys(): string[][] {
|
||||
return [...document.querySelectorAll(".cmdbar-row-keys")].map((el) =>
|
||||
[...el.querySelectorAll("kbd")].map((k) => k.textContent ?? ""),
|
||||
);
|
||||
}
|
||||
|
||||
describe("parsing the query line", () => {
|
||||
it("reads a sigil only in first position", () => {
|
||||
expect(parseBarQuery(">kill")).toEqual({ sigil: ">", query: "kill" });
|
||||
expect(parseBarQuery("~/dev")).toEqual({ sigil: "~", query: "/dev" });
|
||||
expect(parseBarQuery("")).toEqual({ sigil: null, query: "" });
|
||||
expect(parseBarQuery("api")).toEqual({ sigil: null, query: "api" });
|
||||
});
|
||||
|
||||
it("leaves a sigil character elsewhere in the query literal", () => {
|
||||
expect(parseBarQuery("api>x")).toEqual({ sigil: null, query: "api>x" });
|
||||
// The case that matters: the second `~` of a directory query reaches the
|
||||
// matcher.
|
||||
expect(parseBarQuery("~~/dev")).toEqual({ sigil: "~", query: "~/dev" });
|
||||
});
|
||||
|
||||
it("returns to the mixed list when the sigil is backspaced away", () => {
|
||||
expect(parseBarQuery(">").sigil).toBe(">");
|
||||
expect(parseBarQuery("").sigil).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("command candidates", () => {
|
||||
it("dedupes by action, not by chord", () => {
|
||||
const run = () => {};
|
||||
const kill = command("a.kill", "Kill the session", ["x"], run);
|
||||
const alias = command("b.kill", "Kill the session", ["ctrl-x"], run);
|
||||
const other = command("c.rename", "Rename the session", ["r"]);
|
||||
expect(dedupeCommands([kill, alias, other]).map((c) => c.id)).toEqual(["a.kill", "c.rename"]);
|
||||
});
|
||||
|
||||
it("matches on the label only, and keeps the chord for display", () => {
|
||||
const candidates = commandBarCandidates([command("a", "Open the spawn form", ["ctrl-k"])]);
|
||||
expect(candidates[0].fields).toEqual({ label: "Open the spawn form" });
|
||||
expect(candidates[0].command.keys).toEqual(["ctrl-k"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("directory candidates", () => {
|
||||
it("abbreviates the path it shows and keeps the one it spawns in", () => {
|
||||
const c = directoryCandidate("/home/me/dev/api", "/home/me");
|
||||
expect(c).toEqual({ kind: "directory", path: "/home/me/dev/api", fields: { path: "~/dev/api" } });
|
||||
});
|
||||
|
||||
it("expands a typed `~` and dedupes by the directory spawned in", () => {
|
||||
const paths = ["~/dev/api", "/home/me/dev/api", "/tmp/scratch"];
|
||||
expect(directoryBarCandidates(paths, "/home/me").map((c) => c.path)).toEqual([
|
||||
"/home/me/dev/api",
|
||||
"/tmp/scratch",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("the sigils in the overlay", () => {
|
||||
const sessions = [session("api", { metadata: { name: "api" }, current_cwd: "/home/me/dev/api" })];
|
||||
const ran: string[] = [];
|
||||
const commands = [
|
||||
command("spawn.open", "Open the spawn form", ["n"], () => ran.push("spawn.open")),
|
||||
command("list.kill", "Kill the highlighted session", ["x"], () => ran.push("list.kill")),
|
||||
];
|
||||
|
||||
afterEach(() => {
|
||||
ran.length = 0;
|
||||
});
|
||||
|
||||
function open(over: Partial<Parameters<typeof openCommandBar>[0]> = {}) {
|
||||
return openCommandBar({
|
||||
sessions,
|
||||
homeDir: "/home/me",
|
||||
commands,
|
||||
onOpenSession: () => {},
|
||||
loadDirectories: async () => [],
|
||||
listDirEntries: async () => [],
|
||||
onSpawnDirectory: () => {},
|
||||
...over,
|
||||
});
|
||||
}
|
||||
|
||||
it("lists every command in registry order under `>`, with its chord and no header", () => {
|
||||
open();
|
||||
type(">");
|
||||
expect(rowNames()).toEqual(["Open the spawn form", "Kill the highlighted session"]);
|
||||
expect(rowKeys()).toEqual([["n"], ["x"]]);
|
||||
expect(document.querySelectorAll(".cmdbar-group-title")).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("matches a command on its label and never on its chord", () => {
|
||||
open();
|
||||
type(">kill");
|
||||
expect(rowNames()).toEqual(["Kill the highlighted session"]);
|
||||
// `n` and `x` are chords, not label text: querying one finds nothing that
|
||||
// the label does not also justify.
|
||||
type(">ctrlx");
|
||||
expect(rowNames()).toEqual([]);
|
||||
});
|
||||
|
||||
it("runs the highlighted command on Enter", () => {
|
||||
open();
|
||||
type(">kill");
|
||||
press("Enter");
|
||||
expect(ran).toEqual(["list.kill"]);
|
||||
expect(isCommandBarOpen()).toBe(false);
|
||||
});
|
||||
|
||||
it("keeps the list mixed with no sigil, and returns to it when the sigil goes", () => {
|
||||
open();
|
||||
type("s");
|
||||
expect([...document.querySelectorAll(".cmdbar-group-title")].map((h) => h.textContent)).toEqual([
|
||||
"Sessions",
|
||||
"Commands",
|
||||
]);
|
||||
type(">spawn");
|
||||
expect(rowNames()).toEqual(["Open the spawn form"]);
|
||||
// Backspacing the sigil away is ordinary editing.
|
||||
type("spawn");
|
||||
expect(rowNames()).toContain("Open the spawn form");
|
||||
type("");
|
||||
expect(rowNames()).toEqual(["api"]);
|
||||
});
|
||||
|
||||
it("lists recent dirs then projects under `~`", async () => {
|
||||
open({ loadDirectories: async () => ["/home/me/dev/api", "/srv/www"] });
|
||||
type("~");
|
||||
await flush();
|
||||
expect(rowNames()).toEqual(["~/dev/api", "/srv/www"]);
|
||||
expect(document.querySelectorAll(".cmdbar-group-title")).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("fills the list in from /api/dirs as a path is typed", async () => {
|
||||
const asked: string[] = [];
|
||||
open({
|
||||
loadDirectories: async () => ["/home/me/notes"],
|
||||
listDirEntries: async (dir) => {
|
||||
asked.push(dir);
|
||||
return ["maestro", "api"];
|
||||
},
|
||||
});
|
||||
// The second `~` is a literal query character and reaches the matcher.
|
||||
type("~~/dev/mae");
|
||||
await flush();
|
||||
expect(asked).toEqual(["~/dev/"]);
|
||||
expect(rowNames()).toEqual(["~/dev/maestro"]);
|
||||
});
|
||||
|
||||
it("spawns in the highlighted directory on Enter", async () => {
|
||||
const spawned: string[] = [];
|
||||
open({
|
||||
loadDirectories: async () => ["~/dev/api"],
|
||||
onSpawnDirectory: (path) => spawned.push(path),
|
||||
});
|
||||
type("~api");
|
||||
await flush();
|
||||
press("Enter");
|
||||
expect(spawned).toEqual(["/home/me/dev/api"]);
|
||||
expect(isCommandBarOpen()).toBe(false);
|
||||
});
|
||||
|
||||
it("raises the spawn event when the caller supplies no handler", async () => {
|
||||
const seen: string[] = [];
|
||||
const listener = (e: Event) => seen.push((e as CustomEvent<string>).detail);
|
||||
document.addEventListener(SPAWN_DIRECTORY_EVENT, listener);
|
||||
openCommandBar({
|
||||
sessions,
|
||||
homeDir: "/home/me",
|
||||
commands,
|
||||
onOpenSession: () => {},
|
||||
loadDirectories: async () => ["/home/me/dev/api"],
|
||||
listDirEntries: async () => [],
|
||||
});
|
||||
type("~");
|
||||
await flush();
|
||||
press("Enter");
|
||||
document.removeEventListener(SPAWN_DIRECTORY_EVENT, listener);
|
||||
expect(seen).toEqual(["/home/me/dev/api"]);
|
||||
});
|
||||
});
|
||||
|
||||
+316
-30
@@ -1,33 +1,47 @@
|
||||
// The `ctrl-k` command bar (docs/COMMAND-BAR.md): a query line over a result
|
||||
// list, opened from the sidebar, closed with Escape.
|
||||
//
|
||||
// Sessions are the only kind here (#149); the sigils, the action page and the
|
||||
// terminal-focus binding land in #150-#152.
|
||||
// Three kinds live here: sessions and commands, ranked together by default,
|
||||
// and directories, which need the `~` sigil. The action page and the
|
||||
// terminal-focus binding land in #151-#152.
|
||||
|
||||
import { listDir, projects as fetchProjects, recentDirs } from "./api.js";
|
||||
import { SESSION_SELECT_EVENT } from "./catalog.js";
|
||||
import { keyCaps, publishedCommands, type Command } from "./commands.js";
|
||||
import {
|
||||
matchAndRank,
|
||||
type CandidateKind,
|
||||
type CommandCandidate,
|
||||
type DirectoryCandidate,
|
||||
type MatchRange,
|
||||
type MatchResult,
|
||||
type SessionCandidate,
|
||||
} from "./matcher.js";
|
||||
import { splitPath } from "./paths.js";
|
||||
import { splitCommand } from "./toolIcon.js";
|
||||
import { abbreviateCwd, sessionName, sessionTreePath, type Session } from "./types.js";
|
||||
|
||||
/** A matcher candidate that remembers which session it came from, so a result
|
||||
* can be acted on without a second lookup. The extra property is invisible to
|
||||
* the matcher, which only ever reads `kind` and `fields`. */
|
||||
/** A matcher candidate that remembers what it came from, so a result can be
|
||||
* acted on without a second lookup. The extra property is invisible to the
|
||||
* matcher, which only ever reads `kind` and `fields`. */
|
||||
export type SessionBarCandidate = SessionCandidate & { session: Session };
|
||||
export type CommandBarCandidate = CommandCandidate & { command: Command };
|
||||
/** `fields.path` is the abbreviated path the operator reads and the matcher
|
||||
* ranks; `path` is the one a spawn is made in. */
|
||||
export type DirectoryBarCandidate = DirectoryCandidate & { path: string };
|
||||
|
||||
export type BarCandidate = SessionBarCandidate;
|
||||
export type BarCandidate = SessionBarCandidate | CommandBarCandidate | DirectoryBarCandidate;
|
||||
export type BarResult = MatchResult<BarCandidate>;
|
||||
|
||||
/** Raised when Enter lands on a directory row. The bar picks the directory; the
|
||||
* shell owns the spawn, which is the same fast-forward path `N` takes. */
|
||||
export const SPAWN_DIRECTORY_EVENT = "cmdbar-spawn-directory";
|
||||
|
||||
/** One kind's slice of the ranked list, in rank order. */
|
||||
export interface BarGroup {
|
||||
export interface BarGroup<C extends BarCandidate = BarCandidate> {
|
||||
kind: CandidateKind;
|
||||
title: string;
|
||||
results: BarResult[];
|
||||
results: MatchResult<C>[];
|
||||
}
|
||||
|
||||
const KIND_TITLES: Record<CandidateKind, string> = {
|
||||
@@ -36,6 +50,29 @@ const KIND_TITLES: Record<CandidateKind, string> = {
|
||||
directory: "Directories",
|
||||
};
|
||||
|
||||
// ---- the sigils ----
|
||||
|
||||
export type BarSigil = ">" | "~";
|
||||
|
||||
export interface BarQuery {
|
||||
/** The sigil in force, or null for the mixed list. */
|
||||
sigil: BarSigil | null;
|
||||
/** What the matcher sees: everything after a sigil, or the whole query. */
|
||||
query: string;
|
||||
}
|
||||
|
||||
/** Split a raw query line into its sigil and the query proper.
|
||||
*
|
||||
* A sigil is only a sigil in first position: `>` or `~` anywhere else is a
|
||||
* literal query character, which is what a directory query with a second `~`
|
||||
* in it depends on. Backspacing past a sigil needs no special case either —
|
||||
* the first character goes and the query is a mixed one again. */
|
||||
export function parseBarQuery(raw: string): BarQuery {
|
||||
const first = raw[0];
|
||||
if (first === ">" || first === "~") return { sigil: first, query: raw.slice(1) };
|
||||
return { sigil: null, query: raw };
|
||||
}
|
||||
|
||||
// ---- candidates ----
|
||||
|
||||
function focusedAt(s: Session): number | null {
|
||||
@@ -99,14 +136,92 @@ export function sessionBarCandidates(
|
||||
return orderSessionsForBar(sessions).map((s) => sessionCandidate(s, homeDir));
|
||||
}
|
||||
|
||||
/** Drop the commands that would list twice. Several chords can be bound to one
|
||||
* action — the registry already carries its aliases on one entry, but nothing
|
||||
* stops two entries running the same handler, and listed naively that is two
|
||||
* identical rows. The dedupe is by **action**, never by chord: the chord is
|
||||
* displayed, so a command with two of them is still one row. */
|
||||
export function dedupeCommands(commands: readonly Command[]): Command[] {
|
||||
const seenRun = new Set<Command["run"]>();
|
||||
const seenLabel = new Set<string>();
|
||||
const kept: Command[] = [];
|
||||
for (const command of commands) {
|
||||
if (seenRun.has(command.run) || seenLabel.has(command.label)) continue;
|
||||
seenRun.add(command.run);
|
||||
seenLabel.add(command.label);
|
||||
kept.push(command);
|
||||
}
|
||||
return kept;
|
||||
}
|
||||
|
||||
/** Every bound command as a candidate, in registry order. Only the label is a
|
||||
* field: the chord is displayed beside the row, never matched, because
|
||||
* matching it would make `>` results depend on a user's rebinds. */
|
||||
export function commandBarCandidates(commands: readonly Command[]): CommandBarCandidate[] {
|
||||
return dedupeCommands(commands).map((command) => ({
|
||||
kind: "command",
|
||||
command,
|
||||
fields: { label: command.label },
|
||||
}));
|
||||
}
|
||||
|
||||
/** `~/dev/api` back to `/home/me/dev/api` — the inverse of `abbreviateCwd`, for
|
||||
* a path the operator typed or `/api/dirs` completed. */
|
||||
export function expandHome(path: string, homeDir?: string): string {
|
||||
if (!homeDir) return path;
|
||||
if (path === "~") return homeDir;
|
||||
return path.startsWith("~/") ? `${homeDir}${path.slice(1)}` : path;
|
||||
}
|
||||
|
||||
export function directoryCandidate(path: string, homeDir?: string): DirectoryBarCandidate {
|
||||
const absolute = expandHome(path, homeDir);
|
||||
return { kind: "directory", path: absolute, fields: { path: abbreviateCwd(absolute, homeDir) } };
|
||||
}
|
||||
|
||||
/** Candidates for `~`, deduped by the directory they spawn in and keeping the
|
||||
* first mention of each — recent dirs (newest first) ahead of the project
|
||||
* list, which is the empty-query order the contract asks for. */
|
||||
export function directoryBarCandidates(
|
||||
paths: readonly string[],
|
||||
homeDir?: string,
|
||||
): DirectoryBarCandidate[] {
|
||||
const seen = new Set<string>();
|
||||
const out: DirectoryBarCandidate[] = [];
|
||||
for (const path of paths) {
|
||||
if (path === "") continue;
|
||||
const candidate = directoryCandidate(path, homeDir);
|
||||
if (seen.has(candidate.path)) continue;
|
||||
seen.add(candidate.path);
|
||||
out.push(candidate);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** The directory list the bar starts from: what the spawn form already knows —
|
||||
* `GET /api/recent-dirs` and the project list — either alone if the other
|
||||
* fails. */
|
||||
export async function loadBarDirectories(): Promise<string[]> {
|
||||
const [recents, discovered] = await Promise.all([
|
||||
recentDirs()
|
||||
.then((r) => r.dirs.map((d) => d.cwd))
|
||||
.catch(() => [] as string[]),
|
||||
fetchProjects()
|
||||
.then((list) => list.map((p) => p.path))
|
||||
.catch(() => [] as string[]),
|
||||
]);
|
||||
return [...recents, ...discovered];
|
||||
}
|
||||
|
||||
// ---- grouping ----
|
||||
|
||||
/** Bucket a ranked list by kind for rendering. Grouping is a rendering of the
|
||||
* ranking, never a reordering of it: a group holds its matches in rank order,
|
||||
* and groups appear in the order their best-scoring member does — so the first
|
||||
* row under the first header is the globally best result. */
|
||||
export function barGroups(results: readonly BarResult[]): BarGroup[] {
|
||||
const groups: BarGroup[] = [];
|
||||
export function barGroups<C extends BarCandidate>(
|
||||
results: readonly MatchResult<C>[],
|
||||
): BarGroup<C>[] {
|
||||
const groups: BarGroup<C>[] = [];
|
||||
for (const result of results) {
|
||||
const kind = result.candidate.kind;
|
||||
let group = groups.find((g) => g.kind === kind);
|
||||
@@ -122,7 +237,10 @@ export function barGroups(results: readonly BarResult[]): BarGroup[] {
|
||||
/** The rows a query produces, flat and in rank order — what the highlight walks
|
||||
* and what Enter acts on. Headers are labels, not rows, so they are never in
|
||||
* this list. */
|
||||
export function barRows(query: string, candidates: readonly BarCandidate[]): BarResult[] {
|
||||
export function barRows<C extends BarCandidate>(
|
||||
query: string,
|
||||
candidates: readonly C[],
|
||||
): MatchResult<C>[] {
|
||||
return matchAndRank(query, candidates);
|
||||
}
|
||||
|
||||
@@ -145,9 +263,25 @@ export function renderHighlighted(text: string, ranges: readonly MatchRange[]):
|
||||
return frag;
|
||||
}
|
||||
|
||||
/** The secondary line under a row's name: the field the match was found in when
|
||||
* that was not the name, so a session found through its directory says so. */
|
||||
/** The name line of a row: the session's name, the command's label, or the
|
||||
* directory's path. */
|
||||
function rowName(candidate: BarCandidate): string {
|
||||
switch (candidate.kind) {
|
||||
case "session":
|
||||
return candidate.fields.name;
|
||||
case "command":
|
||||
return candidate.fields.label;
|
||||
case "directory":
|
||||
return candidate.fields.path;
|
||||
}
|
||||
}
|
||||
|
||||
/** The secondary line under a session row: the field the match was found in
|
||||
* when that was not the name, so a session found through its directory says
|
||||
* so. Only sessions have one — a command shows keycaps instead, and a
|
||||
* directory's path is already the name. */
|
||||
function metaText(result: BarResult): string | undefined {
|
||||
if (result.candidate.kind !== "session") return undefined;
|
||||
const fields = result.candidate.fields;
|
||||
switch (result.field) {
|
||||
case "directory":
|
||||
@@ -162,22 +296,44 @@ function metaText(result: BarResult): string | undefined {
|
||||
}
|
||||
|
||||
function renderRow(result: BarResult, index: number): HTMLElement {
|
||||
const candidate = result.candidate;
|
||||
const row = document.createElement("div");
|
||||
row.className = "cmdbar-row";
|
||||
row.setAttribute("role", "option");
|
||||
row.id = `cmdbar-row-${index}`;
|
||||
row.dataset.index = String(index);
|
||||
row.dataset.sessionId = result.candidate.session.id;
|
||||
if (result.candidate.session.status === "Dead") row.classList.add("is-dead");
|
||||
row.dataset.kind = candidate.kind;
|
||||
if (candidate.kind === "session") {
|
||||
row.dataset.sessionId = candidate.session.id;
|
||||
if (candidate.session.status === "Dead") row.classList.add("is-dead");
|
||||
} else if (candidate.kind === "command") {
|
||||
row.dataset.commandId = candidate.command.id;
|
||||
} else {
|
||||
row.dataset.path = candidate.path;
|
||||
}
|
||||
|
||||
const name = document.createElement("span");
|
||||
name.className = "cmdbar-row-name";
|
||||
const nameText = result.candidate.fields.name;
|
||||
name.append(
|
||||
result.field === "name" ? renderHighlighted(nameText, result.ranges) : nameText,
|
||||
);
|
||||
const nameText = rowName(candidate);
|
||||
const nameMatched =
|
||||
result.field === "name" || result.field === "label" || result.field === "path";
|
||||
name.append(nameMatched ? renderHighlighted(nameText, result.ranges) : nameText);
|
||||
row.appendChild(name);
|
||||
|
||||
// The chord is teaching material, not a match target: it is rendered from
|
||||
// the live registry entry and never fed to the matcher.
|
||||
if (candidate.kind === "command") {
|
||||
const keys = document.createElement("span");
|
||||
keys.className = "cmdbar-row-keys";
|
||||
for (const cap of keyCaps(candidate.command.keys[0])) {
|
||||
const kbd = document.createElement("kbd");
|
||||
kbd.textContent = cap;
|
||||
keys.appendChild(kbd);
|
||||
}
|
||||
row.appendChild(keys);
|
||||
return row;
|
||||
}
|
||||
|
||||
const meta = metaText(result);
|
||||
if (meta) {
|
||||
const el = document.createElement("span");
|
||||
@@ -189,6 +345,12 @@ function renderRow(result: BarResult, index: number): HTMLElement {
|
||||
return row;
|
||||
}
|
||||
|
||||
const PLACEHOLDERS: Record<"mixed" | BarSigil, string> = {
|
||||
mixed: "Go to a session…",
|
||||
">": "Run a command…",
|
||||
"~": "Spawn in a directory…",
|
||||
};
|
||||
|
||||
// ---- the overlay ----
|
||||
|
||||
export interface CommandBarOptions {
|
||||
@@ -200,6 +362,18 @@ export interface CommandBarOptions {
|
||||
* sidebar's click event — which is what the contract requires; tests pass
|
||||
* their own. */
|
||||
onOpenSession?: (sessionId: string) => void;
|
||||
/** The command registry to list under `>`. Defaults to every bound command,
|
||||
* in registry order. */
|
||||
commands?: readonly Command[];
|
||||
/** The directory list behind `~`, fetched once on first use. */
|
||||
loadDirectories?: () => Promise<string[]>;
|
||||
/** One directory's subdirectories, for filling the list in as a path is
|
||||
* typed. Defaults to `GET /api/dirs`. */
|
||||
listDirEntries?: (dir: string) => Promise<string[]>;
|
||||
/** Spawn a session in the highlighted directory. Defaults to raising
|
||||
* `SPAWN_DIRECTORY_EVENT`, which the shell turns into a fast-forward
|
||||
* spawn. */
|
||||
onSpawnDirectory?: (path: string) => void;
|
||||
}
|
||||
|
||||
interface OpenBar {
|
||||
@@ -228,8 +402,21 @@ export function openCommandBar(opts: CommandBarOptions): HTMLElement {
|
||||
const openSession =
|
||||
opts.onOpenSession ??
|
||||
((id: string) => document.dispatchEvent(new CustomEvent(SESSION_SELECT_EVENT, { detail: id })));
|
||||
const spawnDirectory =
|
||||
opts.onSpawnDirectory ??
|
||||
((path: string) =>
|
||||
document.dispatchEvent(new CustomEvent(SPAWN_DIRECTORY_EVENT, { detail: path })));
|
||||
const loadDirectories = opts.loadDirectories ?? loadBarDirectories;
|
||||
const listEntries =
|
||||
opts.listDirEntries ?? ((dir: string) => listDir(dir).then((listing) => listing.entries));
|
||||
|
||||
const candidates: BarCandidate[] = sessionBarCandidates(opts.sessions, opts.homeDir);
|
||||
const sessionCandidates: SessionBarCandidate[] = sessionBarCandidates(
|
||||
opts.sessions,
|
||||
opts.homeDir,
|
||||
);
|
||||
const commandCandidates: CommandBarCandidate[] = commandBarCandidates(
|
||||
opts.commands ?? publishedCommands(),
|
||||
);
|
||||
|
||||
const el = document.createElement("div");
|
||||
el.className = "cmdbar-overlay";
|
||||
@@ -246,10 +433,10 @@ export function openCommandBar(opts: CommandBarOptions): HTMLElement {
|
||||
input.setAttribute("role", "combobox");
|
||||
input.setAttribute("aria-expanded", "true");
|
||||
input.setAttribute("aria-controls", "cmdbar-results");
|
||||
input.setAttribute("aria-label", "Search sessions");
|
||||
input.setAttribute("aria-label", "Search sessions, commands and directories");
|
||||
input.setAttribute("autocomplete", "off");
|
||||
input.spellcheck = false;
|
||||
input.placeholder = "Go to a session…";
|
||||
input.placeholder = PLACEHOLDERS.mixed;
|
||||
|
||||
const results = document.createElement("div");
|
||||
results.className = "cmdbar-results";
|
||||
@@ -263,6 +450,90 @@ export function openCommandBar(opts: CommandBarOptions): HTMLElement {
|
||||
let rows: BarResult[] = [];
|
||||
let highlighted = 0;
|
||||
|
||||
// `~`'s candidates: the recents-and-projects list, fetched once, plus
|
||||
// whatever `/api/dirs` has filled in for the directory being typed.
|
||||
let directories: DirectoryBarCandidate[] = [];
|
||||
let directoriesRequested = false;
|
||||
let probed: DirectoryBarCandidate[] = [];
|
||||
let probedDir: string | null = null;
|
||||
const listings = new Map<string, string[]>();
|
||||
|
||||
function alive(): boolean {
|
||||
return current?.el === el;
|
||||
}
|
||||
|
||||
/** Fetch the recents-and-projects list the first time `~` is used. */
|
||||
function ensureDirectories(): void {
|
||||
if (directoriesRequested) return;
|
||||
directoriesRequested = true;
|
||||
loadDirectories()
|
||||
.then((paths) => {
|
||||
directories = directoryBarCandidates(paths, opts.homeDir);
|
||||
if (alive()) render();
|
||||
})
|
||||
.catch(() => {
|
||||
/* no directories is a shorter list, not an error state */
|
||||
});
|
||||
}
|
||||
|
||||
/** Fill the list in from `GET /api/dirs` once the query looks like a path:
|
||||
* the directory it names is listed and its children join the candidates, so
|
||||
* `~` reaches a directory that was never spawned in. */
|
||||
function probeDirectories(query: string): void {
|
||||
const { dir } = splitPath(query);
|
||||
if (dir === "") {
|
||||
probed = [];
|
||||
probedDir = null;
|
||||
return;
|
||||
}
|
||||
if (dir === probedDir) return;
|
||||
probedDir = dir;
|
||||
const cached = listings.get(dir);
|
||||
if (cached) {
|
||||
probed = directoryBarCandidates(
|
||||
cached.map((entry) => `${dir}${entry}`),
|
||||
opts.homeDir,
|
||||
);
|
||||
return;
|
||||
}
|
||||
probed = [];
|
||||
listEntries(dir)
|
||||
.then((entries) => {
|
||||
listings.set(dir, entries);
|
||||
if (!alive() || probedDir !== dir) return;
|
||||
probed = directoryBarCandidates(
|
||||
entries.map((entry) => `${dir}${entry}`),
|
||||
opts.homeDir,
|
||||
);
|
||||
render();
|
||||
})
|
||||
.catch(() => {
|
||||
/* an unreadable directory contributes nothing */
|
||||
});
|
||||
}
|
||||
|
||||
/** What the matcher sees for the query line as it stands. With no sigil the
|
||||
* list is mixed — sessions and commands together — except on an empty query,
|
||||
* where the bar is a session switcher. */
|
||||
function candidatesFor(parsed: BarQuery): BarCandidate[] {
|
||||
switch (parsed.sigil) {
|
||||
case ">":
|
||||
return [...commandCandidates];
|
||||
case "~": {
|
||||
ensureDirectories();
|
||||
probeDirectories(parsed.query);
|
||||
const seen = new Set<string>();
|
||||
return [...directories, ...probed].filter((c) =>
|
||||
seen.has(c.path) ? false : (seen.add(c.path), true),
|
||||
);
|
||||
}
|
||||
default:
|
||||
return parsed.query === ""
|
||||
? [...sessionCandidates]
|
||||
: [...sessionCandidates, ...commandCandidates];
|
||||
}
|
||||
}
|
||||
|
||||
function paintHighlight(): void {
|
||||
for (const row of results.querySelectorAll<HTMLElement>(".cmdbar-row")) {
|
||||
const active = Number(row.dataset.index) === highlighted;
|
||||
@@ -277,7 +548,9 @@ export function openCommandBar(opts: CommandBarOptions): HTMLElement {
|
||||
}
|
||||
|
||||
function render(): void {
|
||||
rows = barRows(input.value, candidates);
|
||||
const parsed = parseBarQuery(input.value);
|
||||
input.placeholder = PLACEHOLDERS[parsed.sigil ?? "mixed"];
|
||||
rows = barRows(parsed.query, candidatesFor(parsed));
|
||||
// The highlight starts on the globally best row every time the list
|
||||
// changes: an old index would point at a different result.
|
||||
highlighted = 0;
|
||||
@@ -294,11 +567,14 @@ export function openCommandBar(opts: CommandBarOptions): HTMLElement {
|
||||
|
||||
let index = 0;
|
||||
for (const group of barGroups(rows)) {
|
||||
const header = document.createElement("div");
|
||||
header.className = "cmdbar-group-title";
|
||||
header.setAttribute("role", "presentation");
|
||||
header.textContent = group.title;
|
||||
results.appendChild(header);
|
||||
// While a sigil is in force there is one kind, so the header is dropped.
|
||||
if (parsed.sigil === null) {
|
||||
const header = document.createElement("div");
|
||||
header.className = "cmdbar-group-title";
|
||||
header.setAttribute("role", "presentation");
|
||||
header.textContent = group.title;
|
||||
results.appendChild(header);
|
||||
}
|
||||
for (const result of group.results) {
|
||||
const at = index;
|
||||
const row = renderRow(result, at);
|
||||
@@ -320,9 +596,19 @@ export function openCommandBar(opts: CommandBarOptions): HTMLElement {
|
||||
function activate(index: number): void {
|
||||
const result = rows[index];
|
||||
if (!result) return;
|
||||
const sessionId = result.candidate.session.id;
|
||||
const candidate = result.candidate;
|
||||
close();
|
||||
openSession(sessionId);
|
||||
switch (candidate.kind) {
|
||||
case "session":
|
||||
openSession(candidate.session.id);
|
||||
return;
|
||||
case "command":
|
||||
candidate.command.run();
|
||||
return;
|
||||
case "directory":
|
||||
spawnDirectory(candidate.path);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
function onKeydown(e: KeyboardEvent): void {
|
||||
|
||||
+54
-8
@@ -8,7 +8,7 @@ import { installDrawer, installDrawerSwipe, requestToggleDrawer } from "./drawer
|
||||
import { installHudKeybind } from "./debug.js";
|
||||
import { installHelpKeybind, isHelpOpen } from "./help.js";
|
||||
import { CommandSet, DEFAULT_LEADER, defineCommands, isTypingTarget } from "./commands.js";
|
||||
import { isCommandBarOpen, openCommandBar } from "./commandBar.js";
|
||||
import { isCommandBarOpen, openCommandBar, SPAWN_DIRECTORY_EVENT } from "./commandBar.js";
|
||||
import { DaemonLink } from "./daemonLink.js";
|
||||
import { CatalogStore } from "./events.js";
|
||||
import { installRestartOverlay } from "./restartOverlay.js";
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
createSession,
|
||||
fetchNames,
|
||||
getDaemonInfo,
|
||||
gitRoot,
|
||||
projects as fetchProjects,
|
||||
} from "./api.js";
|
||||
import {
|
||||
@@ -337,6 +338,16 @@ function mountSpawned(session: Session, placement?: { newTab?: boolean }): void
|
||||
// a new branch named after that name. Anything unresolvable (no projects
|
||||
// known yet, no command, no generated name) opens the form pre-filled with
|
||||
// what did resolve instead.
|
||||
async function spawnPresets(): Promise<SpawnPreset[]> {
|
||||
try {
|
||||
const info = await getDaemonInfo();
|
||||
if (info.presets?.length) return info.presets;
|
||||
} catch {
|
||||
/* keep fallbacks */
|
||||
}
|
||||
return FALLBACK_PRESETS;
|
||||
}
|
||||
|
||||
async function requestFastForward(): Promise<void> {
|
||||
const [discovered, name] = await Promise.all([
|
||||
fetchProjects().catch(() => [] as Project[]),
|
||||
@@ -344,13 +355,7 @@ async function requestFastForward(): Promise<void> {
|
||||
.then((batch) => batch.names[0])
|
||||
.catch(() => undefined),
|
||||
]);
|
||||
let presets: SpawnPreset[] = FALLBACK_PRESETS;
|
||||
try {
|
||||
const info = await getDaemonInfo();
|
||||
if (info.presets?.length) presets = info.presets;
|
||||
} catch {
|
||||
/* keep fallbacks */
|
||||
}
|
||||
const presets = await spawnPresets();
|
||||
const resolved = resolveFastForward(discovered, presets, name, focusedProjectCwd());
|
||||
const req = buildFastForwardRequest(resolved);
|
||||
if (req) {
|
||||
@@ -372,6 +377,47 @@ async function requestFastForward(): Promise<void> {
|
||||
});
|
||||
}
|
||||
|
||||
// Enter on a `~` row in the command bar (docs/COMMAND-BAR.md): the same
|
||||
// fast-forward spawn, aimed at the chosen directory instead of the most recent
|
||||
// project. A directory the project scan never saw stands in as its own project;
|
||||
// one outside any repo spawns without a worktree, since there is no repo to cut
|
||||
// a branch from. Anything still unresolved opens the pre-filled form.
|
||||
async function requestSpawnInDirectory(path: string): Promise<void> {
|
||||
const [discovered, name] = await Promise.all([
|
||||
fetchProjects().catch(() => [] as Project[]),
|
||||
fetchNames(1)
|
||||
.then((batch) => batch.names[0])
|
||||
.catch(() => undefined),
|
||||
]);
|
||||
const presets = await spawnPresets();
|
||||
const known = discovered.find((p) => p.path === path);
|
||||
const project: Project = known ?? { path, name: path.split("/").filter(Boolean).pop() ?? path };
|
||||
const resolved = resolveFastForward([project], presets, name, path);
|
||||
const req = buildFastForwardRequest(resolved);
|
||||
if (req) {
|
||||
const inRepo =
|
||||
known !== undefined ||
|
||||
(await gitRoot(path)
|
||||
.then((r) => !!r.root)
|
||||
.catch(() => false));
|
||||
if (!inRepo) delete req.worktree;
|
||||
try {
|
||||
mountSpawned(await createSession(req));
|
||||
return;
|
||||
} catch {
|
||||
// A failed POST degrades to the pre-filled form, never a dead row.
|
||||
}
|
||||
}
|
||||
openSpawnModal({
|
||||
seed: fastForwardSeed(resolved),
|
||||
onSuccess: (session) => mountSpawned(session),
|
||||
});
|
||||
}
|
||||
|
||||
document.addEventListener(SPAWN_DIRECTORY_EVENT, (ev) => {
|
||||
void requestSpawnInDirectory((ev as CustomEvent<string>).detail);
|
||||
});
|
||||
|
||||
// The global half of the command registry (#148). Two sets, because they
|
||||
// differ in one thing: the help overlay owns the keyboard while it is up, so
|
||||
// the plain keys go deaf behind it while the focus chord does not — an
|
||||
|
||||
@@ -1517,6 +1517,25 @@ input.leaf-rename:focus {
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
/* A command row's chord, shown so the bar teaches the keymap. */
|
||||
.cmdbar-row-keys {
|
||||
flex: 1 1 auto;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: var(--sp-1);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.cmdbar-row-keys kbd {
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--fs-11);
|
||||
color: var(--muted);
|
||||
background: var(--surface-2);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--r-1);
|
||||
padding: 0 var(--sp-1);
|
||||
}
|
||||
|
||||
.cmdbar-hit {
|
||||
background: none;
|
||||
color: var(--accent);
|
||||
|
||||
Reference in New Issue
Block a user