CommandBar, ResultList, ActionPage and useCommandBar over the same model docs/COMMAND-BAR.md specifies: sigils, empty-query session order, the drill-in action page, and the keys. The highlight now walks the rows in the order they render. The legacy bar indexed the highlight by display order but activated by rank order, so Enter could act on a different row than the one lit.
198 lines
6.7 KiB
TypeScript
198 lines
6.7 KiB
TypeScript
import { describe, expect, it } from "vitest";
|
|
|
|
import type { Command } from "../../commands.js";
|
|
import type { Session } from "../../types.js";
|
|
import {
|
|
barGroups,
|
|
barRows,
|
|
commandBarCandidates,
|
|
dedupeCommands,
|
|
directoryBarCandidates,
|
|
directoryCandidate,
|
|
highlightSegments,
|
|
orderSessionsForBar,
|
|
parseBarQuery,
|
|
sessionActionCandidates,
|
|
sessionBarCandidates,
|
|
sessionCandidate,
|
|
} from "./model.js";
|
|
|
|
function session(id: string, over: Partial<Session> = {}): Session {
|
|
return {
|
|
id,
|
|
cwd: "/home/me/dev/api",
|
|
command: "bash",
|
|
env: {},
|
|
metadata: { name: id },
|
|
status: "Running",
|
|
created_at: "2026-01-01T00:00:00Z",
|
|
...over,
|
|
};
|
|
}
|
|
|
|
function command(over: Partial<Command> = {}): Command {
|
|
return {
|
|
id: "a",
|
|
label: "A command",
|
|
group: "Test",
|
|
keys: ["a"],
|
|
scope: "global",
|
|
run: () => {},
|
|
...over,
|
|
};
|
|
}
|
|
|
|
describe("empty-query ordering", () => {
|
|
it("sorts most recently focused first", () => {
|
|
const older = session("older", { last_focused_at: "2026-01-01T10:00:00Z" });
|
|
const newer = session("newer", { last_focused_at: "2026-01-01T11:00:00Z" });
|
|
expect(orderSessionsForBar([older, newer]).map((s) => s.id)).toEqual(["newer", "older"]);
|
|
});
|
|
|
|
it("sorts a never-focused session after every focused one, newest created first", () => {
|
|
const focused = session("focused", { last_focused_at: "2026-01-01T09:00:00Z" });
|
|
const old = session("old", { created_at: "2026-01-01T00:00:00Z" });
|
|
const fresh = session("fresh", { created_at: "2026-01-02T00:00:00Z" });
|
|
expect(orderSessionsForBar([old, fresh, focused]).map((s) => s.id)).toEqual([
|
|
"focused",
|
|
"fresh",
|
|
"old",
|
|
]);
|
|
});
|
|
|
|
it("sorts dead sessions after live ones regardless of recency", () => {
|
|
const dead = session("dead", { status: "Dead", last_focused_at: "2026-01-01T12:00:00Z" });
|
|
const live = session("live", { last_focused_at: "2026-01-01T10:00:00Z" });
|
|
expect(orderSessionsForBar([dead, live]).map((s) => s.id)).toEqual(["live", "dead"]);
|
|
});
|
|
|
|
it("treats an empty last_focused_at as never focused", () => {
|
|
const blank = session("blank", { last_focused_at: "" });
|
|
const focused = session("focused", { last_focused_at: "2026-01-01T10:00:00Z" });
|
|
expect(orderSessionsForBar([blank, focused]).map((s) => s.id)).toEqual(["focused", "blank"]);
|
|
});
|
|
});
|
|
|
|
describe("session candidates", () => {
|
|
it("carries the candidate table's fields, with the directory abbreviated", () => {
|
|
const s = session("api", {
|
|
metadata: { name: "api" },
|
|
current_cwd: "/home/me/dev/api",
|
|
command: "claude --resume",
|
|
});
|
|
expect(sessionCandidate(s, "/home/me").fields).toEqual({
|
|
name: "api",
|
|
directory: "~/dev/api",
|
|
command: "claude --resume",
|
|
tool: "claude",
|
|
});
|
|
});
|
|
|
|
it("leaves the tool out when the program has no mark, and groups a worktree by its repo", () => {
|
|
const s = session("edit", {
|
|
metadata: {},
|
|
command: "hx src/",
|
|
worktree: { path: "/home/me/dev/api/wt/x", base_repo: "/home/me/dev/api" },
|
|
});
|
|
const candidate = sessionCandidate(s, "/home/me");
|
|
expect(candidate.fields.tool).toBeUndefined();
|
|
expect(candidate.fields.directory).toBe("~/dev/api");
|
|
// No `metadata.name`: the sidebar's fallback is the command line.
|
|
expect(candidate.fields.name).toBe("hx src/");
|
|
});
|
|
|
|
it("builds candidates in the empty-query order", () => {
|
|
const a = session("a", { last_focused_at: "2026-01-01T10:00:00Z" });
|
|
const b = session("b", { last_focused_at: "2026-01-01T11:00:00Z" });
|
|
expect(sessionBarCandidates([a, b]).map((c) => c.session.id)).toEqual(["b", "a"]);
|
|
});
|
|
});
|
|
|
|
describe("rows and grouping", () => {
|
|
it("an empty query keeps every candidate in input order", () => {
|
|
const candidates = sessionBarCandidates([session("a"), session("b")]);
|
|
expect(barRows("", candidates).map((r) => r.score)).toEqual([0, 0]);
|
|
});
|
|
|
|
it("groups by kind without reordering the ranking", () => {
|
|
const rows = barRows("kill", [
|
|
...sessionBarCandidates([session("killer-app", { metadata: { name: "killer-app" } })]),
|
|
...commandBarCandidates([command({ label: "Kill selected session" })]),
|
|
]);
|
|
const groups = barGroups(rows);
|
|
expect(groups.map((g) => g.title)).toEqual(["Sessions", "Commands"]);
|
|
expect(groups.flatMap((g) => g.results)).toEqual(rows);
|
|
});
|
|
});
|
|
|
|
describe("parsing the query line", () => {
|
|
it("reads a sigil only in first position", () => {
|
|
expect(parseBarQuery(">help")).toEqual({ sigil: ">", query: "help" });
|
|
expect(parseBarQuery("~dev")).toEqual({ sigil: "~", query: "dev" });
|
|
});
|
|
|
|
it("leaves a sigil character elsewhere in the query literal", () => {
|
|
expect(parseBarQuery("~dev/~scratch")).toEqual({ sigil: "~", query: "dev/~scratch" });
|
|
expect(parseBarQuery("a>b")).toEqual({ sigil: null, query: "a>b" });
|
|
});
|
|
|
|
it("returns to the mixed list when the sigil is backspaced away", () => {
|
|
expect(parseBarQuery("")).toEqual({ sigil: null, query: "" });
|
|
});
|
|
});
|
|
|
|
describe("command candidates", () => {
|
|
it("dedupes by action, not by chord", () => {
|
|
const run = () => {};
|
|
const cmds = [
|
|
command({ id: "one", label: "Do it", keys: ["j"], run }),
|
|
command({ id: "two", label: "Do it", keys: ["down"], run }),
|
|
];
|
|
expect(commandBarCandidates(cmds)).toHaveLength(1);
|
|
expect(dedupeCommands(cmds)).toHaveLength(1);
|
|
});
|
|
|
|
it("matches on the label only, never on the chord", () => {
|
|
const candidates = commandBarCandidates([command({ label: "Show help", keys: ["ctrl-q"] })]);
|
|
expect(barRows("ctrlq", candidates)).toEqual([]);
|
|
expect(barRows("show", candidates)).toHaveLength(1);
|
|
});
|
|
});
|
|
|
|
describe("directory candidates", () => {
|
|
it("abbreviates the path it shows and keeps the one it spawns in", () => {
|
|
const candidate = directoryCandidate("/home/me/dev/api", "/home/me");
|
|
expect(candidate.fields.path).toBe("~/dev/api");
|
|
expect(candidate.path).toBe("/home/me/dev/api");
|
|
});
|
|
|
|
it("expands a typed `~` and dedupes by the directory spawned in", () => {
|
|
const candidates = directoryBarCandidates(
|
|
["~/dev/api", "/home/me/dev/api", "", "/srv/x"],
|
|
"/home/me",
|
|
);
|
|
expect(candidates.map((c) => c.path)).toEqual(["/home/me/dev/api", "/srv/x"]);
|
|
});
|
|
});
|
|
|
|
describe("the action page's candidates", () => {
|
|
it("offers no action beyond the three", () => {
|
|
expect(sessionActionCandidates().map((c) => c.action.id)).toEqual([
|
|
"kill",
|
|
"rename",
|
|
"spawn-similar",
|
|
]);
|
|
});
|
|
});
|
|
|
|
describe("highlight segments", () => {
|
|
it("splits the text on the matcher's ranges", () => {
|
|
expect(highlightSegments("web-search", [[0, 1], [4, 5]])).toEqual([
|
|
{ text: "w", hit: true },
|
|
{ text: "eb-", hit: false },
|
|
{ text: "s", hit: true },
|
|
{ text: "earch", hit: false },
|
|
]);
|
|
});
|
|
});
|