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"]);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user