Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ee61cea64f | ||
|
|
7e21371e47 |
@@ -21,7 +21,7 @@ import {
|
|||||||
type MatchRange,
|
type MatchRange,
|
||||||
type MatchResult,
|
type MatchResult,
|
||||||
type SessionCandidate,
|
type SessionCandidate,
|
||||||
} from "./matcher.js";
|
} from "./utils/matcher.js";
|
||||||
import { splitPath } from "./paths.js";
|
import { splitPath } from "./paths.js";
|
||||||
import { splitCommand } from "./toolIcon.js";
|
import { splitCommand } from "./toolIcon.js";
|
||||||
import { abbreviateCwd, sessionName, sessionTreePath, type Session } from "./types.js";
|
import { abbreviateCwd, sessionName, sessionTreePath, type Session } from "./types.js";
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
// A session's action page (docs/COMMAND-BAR.md, "Drill-in"): the same overlay
|
||||||
|
// over kill, rename and spawn similar, still fuzzy searchable. The page names
|
||||||
|
// the session it acts on, so the target is never ambiguous.
|
||||||
|
|
||||||
|
import { sessionName, type Session } from "../../types.js";
|
||||||
|
import { ResultList } from "./ResultList.js";
|
||||||
|
import type { BarGroup } from "./model.js";
|
||||||
|
|
||||||
|
export interface ActionPageProps {
|
||||||
|
session: Session;
|
||||||
|
/** The three actions, ranked by the query line — one kind, so no headers. */
|
||||||
|
groups: BarGroup[];
|
||||||
|
highlighted: number;
|
||||||
|
onActivate(index: number): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ActionPage({ session, groups, highlighted, onActivate }: ActionPageProps) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div
|
||||||
|
role="presentation"
|
||||||
|
className="cmdbar-page-header border-b border-border px-sp-3 py-sp-1 text-fs-11 text-muted"
|
||||||
|
>
|
||||||
|
{`Actions for "${sessionName(session)}"`}
|
||||||
|
</div>
|
||||||
|
<ResultList
|
||||||
|
groups={groups}
|
||||||
|
showHeaders={false}
|
||||||
|
highlighted={highlighted}
|
||||||
|
onActivate={onActivate}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,407 @@
|
|||||||
|
// @vitest-environment happy-dom
|
||||||
|
import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||||
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
import {
|
||||||
|
KILL_SESSION_EVENT,
|
||||||
|
RENAME_SESSION_EVENT,
|
||||||
|
SESSION_SELECT_EVENT,
|
||||||
|
SPAWN_SIMILAR_EVENT,
|
||||||
|
} from "../../catalog.js";
|
||||||
|
import { COMMAND_SPECS, type Command } from "../../commands.js";
|
||||||
|
import type { Session } from "../../types.js";
|
||||||
|
import { CommandBar, SPAWN_DIRECTORY_EVENT, type CommandBarProps } from "./CommandBar.js";
|
||||||
|
|
||||||
|
vi.mock("../../api.js", async (importOriginal) => ({
|
||||||
|
...(await importOriginal<Record<string, unknown>>()),
|
||||||
|
listDir: vi.fn(async () => ({ path: "", entries: [] })),
|
||||||
|
projects: vi.fn(async () => []),
|
||||||
|
recentDirs: vi.fn(async () => ({ dirs: [] })),
|
||||||
|
}));
|
||||||
|
|
||||||
|
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: "cmd",
|
||||||
|
label: "A command",
|
||||||
|
group: "Test",
|
||||||
|
keys: ["a"],
|
||||||
|
scope: "global",
|
||||||
|
run: () => {},
|
||||||
|
...over,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const sessions = [
|
||||||
|
session("api", { current_cwd: "/home/me/dev/api", last_focused_at: "2026-01-01T11:00:00Z" }),
|
||||||
|
session("worker", {
|
||||||
|
current_cwd: "/home/me/dev/worker",
|
||||||
|
last_focused_at: "2026-01-01T10:00:00Z",
|
||||||
|
}),
|
||||||
|
session("notes", { current_cwd: "/home/me/notes" }),
|
||||||
|
];
|
||||||
|
|
||||||
|
function open(over: Partial<CommandBarProps> = {}) {
|
||||||
|
return render(
|
||||||
|
<CommandBar
|
||||||
|
sessions={sessions}
|
||||||
|
homeDir="/home/me"
|
||||||
|
commands={[]}
|
||||||
|
onOpenSession={() => {}}
|
||||||
|
onClose={() => {}}
|
||||||
|
{...over}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function input(): HTMLInputElement {
|
||||||
|
return screen.getByRole("combobox") as HTMLInputElement;
|
||||||
|
}
|
||||||
|
|
||||||
|
function type(value: string): void {
|
||||||
|
fireEvent.change(input(), { target: { value } });
|
||||||
|
}
|
||||||
|
|
||||||
|
function press(key: string, init: Record<string, unknown> = {}): void {
|
||||||
|
fireEvent.keyDown(input(), { key, ...init });
|
||||||
|
}
|
||||||
|
|
||||||
|
function rowNames(): string[] {
|
||||||
|
return [...document.querySelectorAll(".cmdbar-row-name")].map((r) => r.textContent ?? "");
|
||||||
|
}
|
||||||
|
|
||||||
|
function activeName(): string | undefined {
|
||||||
|
return document.querySelector(".cmdbar-row.is-active .cmdbar-row-name")?.textContent ?? undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
cleanup();
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("the overlay", () => {
|
||||||
|
it("is a registry command, so the help overlay lists it", () => {
|
||||||
|
const spec = COMMAND_SPECS.find((s) => s.id === "bar.open");
|
||||||
|
expect(spec?.keys).toEqual(["ctrl-k"]);
|
||||||
|
expect(spec?.scope).toBe("global");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("lists sessions by recency under a kind header on an empty query", () => {
|
||||||
|
open();
|
||||||
|
expect([...document.querySelectorAll(".cmdbar-group-title")].map((h) => h.textContent)).toEqual([
|
||||||
|
"Sessions",
|
||||||
|
]);
|
||||||
|
expect(rowNames()).toEqual(["api", "worker", "notes"]);
|
||||||
|
expect(activeName()).toBe("api");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ranks a typed query through the matcher and highlights the matched range", () => {
|
||||||
|
open();
|
||||||
|
type("wor");
|
||||||
|
expect(rowNames()).toEqual(["worker"]);
|
||||||
|
expect([...document.querySelectorAll(".cmdbar-row-name mark")].map((m) => m.textContent)).toEqual(
|
||||||
|
["wor"],
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("highlights inside the directory when that is the field that matched", () => {
|
||||||
|
open();
|
||||||
|
type("dev/wor");
|
||||||
|
expect(rowNames()).toEqual(["worker"]);
|
||||||
|
expect(
|
||||||
|
[...document.querySelectorAll(".cmdbar-row-meta mark")].map((m) => m.textContent).join(""),
|
||||||
|
).toBe("dev/wor");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders a no-results state rather than an empty list", () => {
|
||||||
|
open();
|
||||||
|
type("zzz");
|
||||||
|
expect(rowNames()).toEqual([]);
|
||||||
|
expect(screen.getByText("No matches")).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("moves the highlight with the arrows and ctrl-p/ctrl-n, without wrapping", () => {
|
||||||
|
open();
|
||||||
|
press("ArrowUp");
|
||||||
|
expect(activeName()).toBe("api");
|
||||||
|
press("ArrowDown");
|
||||||
|
expect(activeName()).toBe("worker");
|
||||||
|
press("n", { ctrlKey: true });
|
||||||
|
expect(activeName()).toBe("notes");
|
||||||
|
press("ArrowDown");
|
||||||
|
expect(activeName()).toBe("notes");
|
||||||
|
press("p", { ctrlKey: true });
|
||||||
|
expect(activeName()).toBe("worker");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("puts the highlight back on the best row whenever the query changes", () => {
|
||||||
|
open();
|
||||||
|
press("ArrowDown");
|
||||||
|
expect(activeName()).toBe("worker");
|
||||||
|
type("o");
|
||||||
|
expect(activeName()).toBe(rowNames()[0]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("opens the highlighted session through the caller's open path on Enter", () => {
|
||||||
|
const onOpenSession = vi.fn();
|
||||||
|
const onClose = vi.fn();
|
||||||
|
open({ onOpenSession, onClose });
|
||||||
|
press("ArrowDown");
|
||||||
|
press("Enter");
|
||||||
|
expect(onOpenSession).toHaveBeenCalledWith("worker");
|
||||||
|
expect(onClose).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("raises the shared select event when the caller supplies no handler", () => {
|
||||||
|
const listener = vi.fn();
|
||||||
|
document.addEventListener(SESSION_SELECT_EVENT, listener);
|
||||||
|
open({ onOpenSession: undefined });
|
||||||
|
press("Enter");
|
||||||
|
document.removeEventListener(SESSION_SELECT_EVENT, listener);
|
||||||
|
expect((listener.mock.calls[0][0] as CustomEvent).detail).toBe("api");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("Enter on an empty result list does nothing", () => {
|
||||||
|
const onOpenSession = vi.fn();
|
||||||
|
const onClose = vi.fn();
|
||||||
|
open({ onOpenSession, onClose });
|
||||||
|
type("zzz");
|
||||||
|
press("Enter");
|
||||||
|
expect(onOpenSession).not.toHaveBeenCalled();
|
||||||
|
expect(onClose).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("closes on Escape and on a press outside the panel", () => {
|
||||||
|
const onClose = vi.fn();
|
||||||
|
open({ onClose });
|
||||||
|
press("Escape");
|
||||||
|
expect(onClose).toHaveBeenCalledTimes(1);
|
||||||
|
fireEvent.mouseDown(document.querySelector(".cmdbar-overlay") as HTMLElement);
|
||||||
|
expect(onClose).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("stays open for a press inside the panel", () => {
|
||||||
|
const onClose = vi.fn();
|
||||||
|
open({ onClose });
|
||||||
|
fireEvent.mouseDown(document.querySelector(".cmdbar-panel") as HTMLElement);
|
||||||
|
expect(onClose).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns the keyboard to whatever held it when it opened", () => {
|
||||||
|
const before = document.createElement("input");
|
||||||
|
document.body.appendChild(before);
|
||||||
|
before.focus();
|
||||||
|
const view = open();
|
||||||
|
expect(document.activeElement).toBe(input());
|
||||||
|
view.unmount();
|
||||||
|
expect(document.activeElement).toBe(before);
|
||||||
|
before.remove();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("clicking a row opens that session", () => {
|
||||||
|
const onOpenSession = vi.fn();
|
||||||
|
open({ onOpenSession });
|
||||||
|
fireEvent.click(document.querySelectorAll(".cmdbar-row")[1]);
|
||||||
|
expect(onOpenSession).toHaveBeenCalledWith("worker");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("the sigils", () => {
|
||||||
|
const commands = [
|
||||||
|
command({ id: "help.toggle", label: "Show help", keys: ["?"] }),
|
||||||
|
command({ id: "spawn.open", label: "Open the spawn form", keys: ["n"], run: vi.fn() }),
|
||||||
|
];
|
||||||
|
|
||||||
|
it("lists every command in registry order under `>`, with its chord and no header", () => {
|
||||||
|
open({ commands });
|
||||||
|
type(">");
|
||||||
|
expect(rowNames()).toEqual(["Show help", "Open the spawn form"]);
|
||||||
|
expect(document.querySelectorAll(".cmdbar-group-title")).toHaveLength(0);
|
||||||
|
expect([...document.querySelectorAll(".cmdbar-row-keys kbd")].map((k) => k.textContent)).toEqual(
|
||||||
|
["?", "n"],
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("matches a command on its label and never on its chord", () => {
|
||||||
|
open({ commands });
|
||||||
|
type(">help");
|
||||||
|
expect(rowNames()).toEqual(["Show help"]);
|
||||||
|
type(">?");
|
||||||
|
expect(rowNames()).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("runs the highlighted command on Enter", () => {
|
||||||
|
const run = vi.fn();
|
||||||
|
open({ commands: [command({ label: "Run me", run })] });
|
||||||
|
type(">run");
|
||||||
|
press("Enter");
|
||||||
|
expect(run).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps the list mixed with no sigil, and returns to it when the sigil goes", () => {
|
||||||
|
open({ commands });
|
||||||
|
type(">show");
|
||||||
|
expect(rowNames()).toEqual(["Show help"]);
|
||||||
|
type("");
|
||||||
|
expect(rowNames()).toEqual(["api", "worker", "notes"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("lists recent dirs then projects under `~`", async () => {
|
||||||
|
open({ loadDirectories: async () => ["/home/me/dev/api", "/srv/x"] });
|
||||||
|
type("~");
|
||||||
|
await waitFor(() => expect(rowNames()).toEqual(["~/dev/api", "/srv/x"]));
|
||||||
|
expect(document.querySelectorAll(".cmdbar-group-title")).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("fills the list in from /api/dirs as a path is typed", async () => {
|
||||||
|
const listDirEntries = vi.fn(async () => ["maestro", "api"]);
|
||||||
|
open({ loadDirectories: async () => ["/home/me/notes"], listDirEntries });
|
||||||
|
// The second `~` is a literal query character and reaches the matcher.
|
||||||
|
type("~~/dev/mae");
|
||||||
|
await waitFor(() => expect(rowNames()).toEqual(["~/dev/maestro"]));
|
||||||
|
expect(listDirEntries).toHaveBeenCalledWith("~/dev/");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("spawns in the highlighted directory on Enter", async () => {
|
||||||
|
const onSpawnDirectory = vi.fn();
|
||||||
|
open({ loadDirectories: async () => ["/srv/x"], onSpawnDirectory });
|
||||||
|
type("~");
|
||||||
|
await waitFor(() => expect(rowNames()).toEqual(["/srv/x"]));
|
||||||
|
press("Enter");
|
||||||
|
expect(onSpawnDirectory).toHaveBeenCalledWith("/srv/x");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("raises the spawn event when the caller supplies no handler", async () => {
|
||||||
|
const listener = vi.fn();
|
||||||
|
document.addEventListener(SPAWN_DIRECTORY_EVENT, listener);
|
||||||
|
open({ loadDirectories: async () => ["/srv/x"] });
|
||||||
|
type("~");
|
||||||
|
await waitFor(() => expect(rowNames()).toEqual(["/srv/x"]));
|
||||||
|
press("Enter");
|
||||||
|
document.removeEventListener(SPAWN_DIRECTORY_EVENT, listener);
|
||||||
|
expect((listener.mock.calls[0][0] as CustomEvent).detail).toBe("/srv/x");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("the session action page", () => {
|
||||||
|
function drill(over: Partial<CommandBarProps> = {}) {
|
||||||
|
const view = open(over);
|
||||||
|
press("Tab");
|
||||||
|
return view;
|
||||||
|
}
|
||||||
|
|
||||||
|
it("lists kill, rename and spawn similar for the highlighted session", () => {
|
||||||
|
drill();
|
||||||
|
expect(rowNames()).toEqual(["Kill session", "Rename session", "Spawn similar"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("names the session being acted on", () => {
|
||||||
|
drill();
|
||||||
|
expect(screen.getByText('Actions for "api"')).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("drops the kind headers, since the page is one kind", () => {
|
||||||
|
drill();
|
||||||
|
expect(document.querySelectorAll(".cmdbar-group-title")).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("stays fuzzy-searchable over the three actions", () => {
|
||||||
|
drill();
|
||||||
|
type("ren");
|
||||||
|
expect(rowNames()).toEqual(["Rename session"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does nothing on a command row", () => {
|
||||||
|
open({ commands: [command({ label: "Show help" })] });
|
||||||
|
type(">show");
|
||||||
|
press("Tab");
|
||||||
|
expect(rowNames()).toEqual(["Show help"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does nothing on a directory row", async () => {
|
||||||
|
open({ loadDirectories: async () => ["/srv/x"] });
|
||||||
|
type("~");
|
||||||
|
await waitFor(() => expect(rowNames()).toEqual(["/srv/x"]));
|
||||||
|
press("Tab");
|
||||||
|
expect(rowNames()).toEqual(["/srv/x"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns to the result list on Escape, with the previous query intact", () => {
|
||||||
|
const onClose = vi.fn();
|
||||||
|
open({ onClose });
|
||||||
|
type("wor");
|
||||||
|
press("Tab");
|
||||||
|
expect(rowNames()).toEqual(["Kill session", "Rename session", "Spawn similar"]);
|
||||||
|
press("Escape");
|
||||||
|
expect(onClose).not.toHaveBeenCalled();
|
||||||
|
expect(input().value).toBe("wor");
|
||||||
|
expect(rowNames()).toEqual(["worker"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns to the result list on backspace at an empty query", () => {
|
||||||
|
drill();
|
||||||
|
fireEvent.keyDown(input(), { key: "Backspace" });
|
||||||
|
expect(rowNames()).toEqual(["api", "worker", "notes"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps backspace ordinary while the page's query has text", () => {
|
||||||
|
drill();
|
||||||
|
type("ren");
|
||||||
|
fireEvent.keyDown(input(), { key: "Backspace" });
|
||||||
|
expect(rowNames()).toEqual(["Rename session"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("closes the bar when the page's Escape has already been used", () => {
|
||||||
|
const onClose = vi.fn();
|
||||||
|
drill({ onClose });
|
||||||
|
press("Escape");
|
||||||
|
press("Escape");
|
||||||
|
expect(onClose).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("raises kill, rename and spawn similar with the session, closing the bar", () => {
|
||||||
|
for (const [query, key] of [
|
||||||
|
["kill", "onKillSession"],
|
||||||
|
["rename", "onRenameSession"],
|
||||||
|
["spawn", "onSpawnSimilar"],
|
||||||
|
] as const) {
|
||||||
|
const handler = vi.fn();
|
||||||
|
const onClose = vi.fn();
|
||||||
|
drill({ [key]: handler, onClose });
|
||||||
|
type(query);
|
||||||
|
press("Enter");
|
||||||
|
expect(handler).toHaveBeenCalledWith(expect.objectContaining({ id: "api" }));
|
||||||
|
expect(onClose).toHaveBeenCalled();
|
||||||
|
cleanup();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("hands each action to the flow it already has, by default", () => {
|
||||||
|
for (const [query, event] of [
|
||||||
|
["kill", KILL_SESSION_EVENT],
|
||||||
|
["rename", RENAME_SESSION_EVENT],
|
||||||
|
["spawn", SPAWN_SIMILAR_EVENT],
|
||||||
|
] as const) {
|
||||||
|
const listener = vi.fn();
|
||||||
|
document.addEventListener(event, listener);
|
||||||
|
drill();
|
||||||
|
type(query);
|
||||||
|
press("Enter");
|
||||||
|
document.removeEventListener(event, listener);
|
||||||
|
expect((listener.mock.calls[0][0] as CustomEvent).detail.id).toBe("api");
|
||||||
|
cleanup();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,379 @@
|
|||||||
|
// The `ctrl-k` command bar (docs/COMMAND-BAR.md): a query line over a result
|
||||||
|
// list. Sessions and commands rank together by default; `>` narrows to
|
||||||
|
// commands, `~` to directories. `tab` on a session drills into its action page.
|
||||||
|
//
|
||||||
|
// The bar picks an action and hands off — it never reimplements one. Each
|
||||||
|
// handler defaults to the event the flow that already implements it listens
|
||||||
|
// on, so the React bar and the legacy shell raise the same flows.
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
|
import { createPortal } from "react-dom";
|
||||||
|
|
||||||
|
import {
|
||||||
|
KILL_SESSION_EVENT,
|
||||||
|
RENAME_SESSION_EVENT,
|
||||||
|
SESSION_SELECT_EVENT,
|
||||||
|
SPAWN_SIMILAR_EVENT,
|
||||||
|
} from "../../catalog.js";
|
||||||
|
import { publishedCommands, type Command } from "../../commands.js";
|
||||||
|
import { sessionName, type Session } from "../../types.js";
|
||||||
|
import { ActionPage } from "./ActionPage.js";
|
||||||
|
import { ResultList } from "./ResultList.js";
|
||||||
|
import {
|
||||||
|
barGroups,
|
||||||
|
barRows,
|
||||||
|
commandBarCandidates,
|
||||||
|
dirEntryPaths,
|
||||||
|
directoryBarCandidates,
|
||||||
|
isActionCandidate,
|
||||||
|
listDirEntries as defaultListDirEntries,
|
||||||
|
loadBarDirectories,
|
||||||
|
parseBarQuery,
|
||||||
|
probeDir,
|
||||||
|
sessionActionCandidates,
|
||||||
|
sessionBarCandidates,
|
||||||
|
PLACEHOLDERS,
|
||||||
|
type BarCandidate,
|
||||||
|
type BarResult,
|
||||||
|
type DirectoryBarCandidate,
|
||||||
|
type SessionAction,
|
||||||
|
} from "./model.js";
|
||||||
|
|
||||||
|
/** Raised when Enter lands on a directory row and no handler was given. 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";
|
||||||
|
|
||||||
|
export interface CommandBarProps {
|
||||||
|
/** Sessions to build candidates from, as the store has them right now. */
|
||||||
|
sessions: readonly Session[];
|
||||||
|
/** The daemon's home dir, for the directory field's `~/…` abbreviation. */
|
||||||
|
homeDir?: string;
|
||||||
|
/** 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[]>;
|
||||||
|
onOpenSession?(sessionId: string): void;
|
||||||
|
onSpawnDirectory?(path: string): void;
|
||||||
|
onKillSession?(session: Session): void;
|
||||||
|
onRenameSession?(session: Session): void;
|
||||||
|
onSpawnSimilar?(session: Session): void;
|
||||||
|
/** Closing returns the keyboard to whatever held it when the bar opened. */
|
||||||
|
onClose(): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function raise(name: string, detail: unknown): void {
|
||||||
|
document.dispatchEvent(new CustomEvent(name, { detail }));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CommandBar(props: CommandBarProps) {
|
||||||
|
const {
|
||||||
|
sessions,
|
||||||
|
homeDir,
|
||||||
|
commands,
|
||||||
|
loadDirectories = loadBarDirectories,
|
||||||
|
listDirEntries = defaultListDirEntries,
|
||||||
|
onClose,
|
||||||
|
} = props;
|
||||||
|
|
||||||
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const panelRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
const [raw, setRaw] = useState("");
|
||||||
|
const [highlighted, setHighlighted] = useState(0);
|
||||||
|
// The action page: `page` is the session being acted on, `stashed` the
|
||||||
|
// result-list query it was drilled into from — backing out restores it, so
|
||||||
|
// the round trip costs the operator nothing.
|
||||||
|
const [page, setPage] = useState<Session | null>(null);
|
||||||
|
const [stashed, setStashed] = useState("");
|
||||||
|
|
||||||
|
// `~`'s candidates: the recents-and-projects list, fetched once, plus
|
||||||
|
// whatever `/api/dirs` has filled in for the directory being typed.
|
||||||
|
const [directories, setDirectories] = useState<DirectoryBarCandidate[]>([]);
|
||||||
|
const [probed, setProbed] = useState<DirectoryBarCandidate[]>([]);
|
||||||
|
const requestedRef = useRef(false);
|
||||||
|
const listingsRef = useRef(new Map<string, string[]>());
|
||||||
|
|
||||||
|
// On the action page the query line is over the three actions, so there is
|
||||||
|
// no sigil to read: `>` and `~` are ordinary characters there.
|
||||||
|
const parsed = useMemo(() => (page ? { sigil: null, query: raw } : parseBarQuery(raw)), [page, raw]);
|
||||||
|
|
||||||
|
const sessionCandidates = useMemo(
|
||||||
|
() => sessionBarCandidates(sessions, homeDir),
|
||||||
|
[sessions, homeDir],
|
||||||
|
);
|
||||||
|
const commandCandidates = useMemo(
|
||||||
|
() => commandBarCandidates(commands ?? publishedCommands()),
|
||||||
|
[commands],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Closing returns the keyboard to whatever held it when the bar opened: a
|
||||||
|
// round trip through the bar never silently moves focus.
|
||||||
|
useEffect(() => {
|
||||||
|
const previouslyFocused = document.activeElement as HTMLElement | null;
|
||||||
|
inputRef.current?.focus();
|
||||||
|
return () => {
|
||||||
|
if (previouslyFocused?.isConnected) previouslyFocused.focus();
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// The recents-and-projects list, the first time `~` is used. No directories
|
||||||
|
// is a shorter list, not an error state.
|
||||||
|
useEffect(() => {
|
||||||
|
if (parsed.sigil !== "~" || requestedRef.current) return;
|
||||||
|
requestedRef.current = true;
|
||||||
|
let live = true;
|
||||||
|
loadDirectories()
|
||||||
|
.then((paths) => {
|
||||||
|
if (live) setDirectories(directoryBarCandidates(paths, homeDir));
|
||||||
|
})
|
||||||
|
.catch(() => {});
|
||||||
|
return () => {
|
||||||
|
live = false;
|
||||||
|
};
|
||||||
|
}, [parsed.sigil, loadDirectories, homeDir]);
|
||||||
|
|
||||||
|
// 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.
|
||||||
|
const dir = parsed.sigil === "~" ? probeDir(parsed.query) : "";
|
||||||
|
useEffect(() => {
|
||||||
|
if (dir === "") {
|
||||||
|
setProbed([]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const cached = listingsRef.current.get(dir);
|
||||||
|
if (cached) {
|
||||||
|
setProbed(directoryBarCandidates(dirEntryPaths(dir, cached), homeDir));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let live = true;
|
||||||
|
setProbed([]);
|
||||||
|
listDirEntries(dir)
|
||||||
|
.then((entries) => {
|
||||||
|
listingsRef.current.set(dir, entries);
|
||||||
|
if (live) setProbed(directoryBarCandidates(dirEntryPaths(dir, entries), homeDir));
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
/* an unreadable directory contributes nothing */
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
live = false;
|
||||||
|
};
|
||||||
|
}, [dir, listDirEntries, homeDir]);
|
||||||
|
|
||||||
|
/** 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. */
|
||||||
|
const candidates: BarCandidate[] = useMemo(() => {
|
||||||
|
if (page) return sessionActionCandidates();
|
||||||
|
switch (parsed.sigil) {
|
||||||
|
case ">":
|
||||||
|
return [...commandCandidates];
|
||||||
|
case "~": {
|
||||||
|
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];
|
||||||
|
}
|
||||||
|
}, [page, parsed.sigil, parsed.query, sessionCandidates, commandCandidates, directories, probed]);
|
||||||
|
|
||||||
|
const groups = useMemo(
|
||||||
|
() => barGroups(barRows(parsed.query, candidates)),
|
||||||
|
[parsed.query, candidates],
|
||||||
|
);
|
||||||
|
// Grouping is a rendering of the ranking, so the flattened groups are the
|
||||||
|
// rows in the order they appear — which is the order the highlight walks and
|
||||||
|
// Enter acts on.
|
||||||
|
const rows: BarResult[] = useMemo(() => groups.flatMap((g) => g.results), [groups]);
|
||||||
|
|
||||||
|
// The highlight starts on the globally best row every time the list changes:
|
||||||
|
// an old index would point at a different result.
|
||||||
|
useEffect(() => {
|
||||||
|
setHighlighted(0);
|
||||||
|
}, [rows]);
|
||||||
|
|
||||||
|
/** The highlight does not wrap: it stops at the first and last row. */
|
||||||
|
const move = useCallback(
|
||||||
|
(delta: number) => {
|
||||||
|
setHighlighted((at) => Math.min(rows.length - 1, Math.max(0, at + delta)));
|
||||||
|
},
|
||||||
|
[rows.length],
|
||||||
|
);
|
||||||
|
|
||||||
|
const runAction = useCallback(
|
||||||
|
(action: SessionAction, session: Session) => {
|
||||||
|
onClose();
|
||||||
|
switch (action.id) {
|
||||||
|
case "kill":
|
||||||
|
(props.onKillSession ?? ((s: Session) => raise(KILL_SESSION_EVENT, s)))(session);
|
||||||
|
return;
|
||||||
|
case "rename":
|
||||||
|
(props.onRenameSession ?? ((s: Session) => raise(RENAME_SESSION_EVENT, s)))(session);
|
||||||
|
return;
|
||||||
|
case "spawn-similar":
|
||||||
|
(props.onSpawnSimilar ?? ((s: Session) => raise(SPAWN_SIMILAR_EVENT, s)))(session);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[onClose, props.onKillSession, props.onRenameSession, props.onSpawnSimilar],
|
||||||
|
);
|
||||||
|
|
||||||
|
const activate = useCallback(
|
||||||
|
(index: number) => {
|
||||||
|
const result = rows[index];
|
||||||
|
if (!result) return;
|
||||||
|
const candidate = result.candidate;
|
||||||
|
if (isActionCandidate(candidate)) {
|
||||||
|
// The page cannot be up without a session to act on.
|
||||||
|
if (page) runAction(candidate.action, page);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
onClose();
|
||||||
|
switch (candidate.kind) {
|
||||||
|
case "session":
|
||||||
|
(props.onOpenSession ?? ((id: string) => raise(SESSION_SELECT_EVENT, id)))(
|
||||||
|
candidate.session.id,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
case "command":
|
||||||
|
candidate.command.run();
|
||||||
|
return;
|
||||||
|
case "directory":
|
||||||
|
(props.onSpawnDirectory ?? ((p: string) => raise(SPAWN_DIRECTORY_EVENT, p)))(
|
||||||
|
candidate.path,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[rows, page, runAction, onClose, props.onOpenSession, props.onSpawnDirectory],
|
||||||
|
);
|
||||||
|
|
||||||
|
/** `tab`: drill into the highlighted session's action page. No effect on any
|
||||||
|
* other kind, and none on the page itself, which has no session rows. */
|
||||||
|
const drillIn = useCallback(() => {
|
||||||
|
const candidate = rows[highlighted]?.candidate;
|
||||||
|
if (page || candidate?.kind !== "session") return;
|
||||||
|
setPage(candidate.session);
|
||||||
|
setStashed(raw);
|
||||||
|
setRaw("");
|
||||||
|
}, [rows, highlighted, page, raw]);
|
||||||
|
|
||||||
|
/** Escape, or backspace at an empty query: back to the result list with the
|
||||||
|
* query the drill-in was made from. */
|
||||||
|
const leavePage = useCallback(() => {
|
||||||
|
setPage(null);
|
||||||
|
setRaw(stashed);
|
||||||
|
setStashed("");
|
||||||
|
}, [stashed]);
|
||||||
|
|
||||||
|
const onKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||||
|
switch (e.key) {
|
||||||
|
case "Escape":
|
||||||
|
e.preventDefault();
|
||||||
|
// On an action page Escape is one level up, not the way out.
|
||||||
|
if (page) leavePage();
|
||||||
|
else onClose();
|
||||||
|
return;
|
||||||
|
case "Tab":
|
||||||
|
e.preventDefault();
|
||||||
|
drillIn();
|
||||||
|
return;
|
||||||
|
case "Backspace":
|
||||||
|
// Ordinary editing, except at an empty query on the action page.
|
||||||
|
if (page && raw === "") {
|
||||||
|
e.preventDefault();
|
||||||
|
leavePage();
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
case "Enter":
|
||||||
|
e.preventDefault();
|
||||||
|
activate(highlighted);
|
||||||
|
return;
|
||||||
|
case "ArrowDown":
|
||||||
|
e.preventDefault();
|
||||||
|
move(1);
|
||||||
|
return;
|
||||||
|
case "ArrowUp":
|
||||||
|
e.preventDefault();
|
||||||
|
move(-1);
|
||||||
|
return;
|
||||||
|
case "n":
|
||||||
|
if (e.ctrlKey) {
|
||||||
|
e.preventDefault();
|
||||||
|
move(1);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
case "p":
|
||||||
|
if (e.ctrlKey) {
|
||||||
|
e.preventDefault();
|
||||||
|
move(-1);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const activeRowId = rows.length > 0 ? `cmdbar-row-${highlighted}` : undefined;
|
||||||
|
|
||||||
|
return createPortal(
|
||||||
|
<div
|
||||||
|
className="cmdbar-overlay fixed inset-0 z-modal flex items-start justify-center bg-bg/70 pt-[12vh] backdrop-blur-sm max-[600px]:items-end max-[600px]:pt-0"
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-label="Command bar"
|
||||||
|
onMouseDown={(e) => {
|
||||||
|
// A press on the scrim closes; one inside the panel is left alone.
|
||||||
|
if (!panelRef.current?.contains(e.target as Node)) onClose();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
ref={panelRef}
|
||||||
|
className="cmdbar-panel flex w-full max-w-[560px] flex-col overflow-hidden rounded-r-3 border border-border bg-surface shadow-2 max-[600px]:max-w-none max-[600px]:rounded-b-none"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
ref={inputRef}
|
||||||
|
type="text"
|
||||||
|
role="combobox"
|
||||||
|
aria-expanded="true"
|
||||||
|
aria-controls="cmdbar-results"
|
||||||
|
aria-label="Search sessions, commands and directories"
|
||||||
|
aria-activedescendant={activeRowId}
|
||||||
|
autoComplete="off"
|
||||||
|
spellCheck={false}
|
||||||
|
className="cmdbar-input w-full border-b border-border bg-transparent px-sp-3 py-sp-2 text-fs-15 text-ink outline-none"
|
||||||
|
placeholder={page ? `Act on ${sessionName(page)}…` : PLACEHOLDERS[parsed.sigil ?? "mixed"]}
|
||||||
|
value={raw}
|
||||||
|
onChange={(e) => setRaw(e.currentTarget.value)}
|
||||||
|
onKeyDown={onKeyDown}
|
||||||
|
/>
|
||||||
|
{page ? (
|
||||||
|
<ActionPage
|
||||||
|
session={page}
|
||||||
|
groups={groups}
|
||||||
|
highlighted={highlighted}
|
||||||
|
onActivate={activate}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<ResultList
|
||||||
|
groups={groups}
|
||||||
|
// While a sigil is in force there is one kind, so the header is
|
||||||
|
// dropped.
|
||||||
|
showHeaders={parsed.sigil === null}
|
||||||
|
highlighted={highlighted}
|
||||||
|
onActivate={activate}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>,
|
||||||
|
document.body,
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,171 @@
|
|||||||
|
// The bar's result list: kind headers over rank-ordered rows
|
||||||
|
// (docs/COMMAND-BAR.md, "Structure"). Headers are labels, never rows — the
|
||||||
|
// highlight walks the rows alone, in the order they are rendered.
|
||||||
|
|
||||||
|
import { useEffect, useRef } from "react";
|
||||||
|
|
||||||
|
import { keyCaps } from "../../commands.js";
|
||||||
|
import {
|
||||||
|
highlightSegments,
|
||||||
|
isActionCandidate,
|
||||||
|
metaText,
|
||||||
|
rowName,
|
||||||
|
type BarGroup,
|
||||||
|
type BarResult,
|
||||||
|
} from "./model.js";
|
||||||
|
|
||||||
|
export interface ResultListProps {
|
||||||
|
groups: BarGroup[];
|
||||||
|
/** Dropped while a sigil is in force or on the action page: one kind needs
|
||||||
|
* no header. */
|
||||||
|
showHeaders: boolean;
|
||||||
|
/** Index into the flattened rows, in the order they render. */
|
||||||
|
highlighted: number;
|
||||||
|
onActivate(index: number): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function Highlighted({ text, ranges }: { text: string; ranges: BarResult["ranges"] }) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{highlightSegments(text, ranges).map((segment, i) =>
|
||||||
|
segment.hit ? (
|
||||||
|
<mark key={i} className="cmdbar-hit bg-transparent font-semibold text-accent">
|
||||||
|
{segment.text}
|
||||||
|
</mark>
|
||||||
|
) : (
|
||||||
|
<span key={i}>{segment.text}</span>
|
||||||
|
),
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Row({
|
||||||
|
result,
|
||||||
|
index,
|
||||||
|
active,
|
||||||
|
onActivate,
|
||||||
|
}: {
|
||||||
|
result: BarResult;
|
||||||
|
index: number;
|
||||||
|
active: boolean;
|
||||||
|
onActivate(index: number): void;
|
||||||
|
}) {
|
||||||
|
const ref = useRef<HTMLDivElement>(null);
|
||||||
|
const candidate = result.candidate;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (active) ref.current?.scrollIntoView({ block: "nearest" });
|
||||||
|
}, [active]);
|
||||||
|
|
||||||
|
const name = rowName(candidate);
|
||||||
|
const nameMatched =
|
||||||
|
result.field === "name" || result.field === "label" || result.field === "path";
|
||||||
|
const meta = metaText(result);
|
||||||
|
const dead = candidate.kind === "session" && candidate.session.status === "Dead";
|
||||||
|
const chord =
|
||||||
|
candidate.kind === "command" && !isActionCandidate(candidate)
|
||||||
|
? candidate.command.keys[0]
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
role="option"
|
||||||
|
id={`cmdbar-row-${index}`}
|
||||||
|
aria-selected={active}
|
||||||
|
data-index={index}
|
||||||
|
data-kind={candidate.kind}
|
||||||
|
data-session-id={candidate.kind === "session" ? candidate.session.id : undefined}
|
||||||
|
data-action-id={isActionCandidate(candidate) ? candidate.action.id : undefined}
|
||||||
|
data-command-id={
|
||||||
|
candidate.kind === "command" && !isActionCandidate(candidate)
|
||||||
|
? candidate.command.id
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
data-path={candidate.kind === "directory" ? candidate.path : undefined}
|
||||||
|
className={[
|
||||||
|
"cmdbar-row flex cursor-pointer items-baseline gap-sp-2 rounded-r-1 px-sp-3 py-sp-1 text-fs-13",
|
||||||
|
active ? "is-active bg-surface-2 text-ink" : "text-ink",
|
||||||
|
dead ? "is-dead opacity-60" : "",
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(" ")}
|
||||||
|
onClick={() => onActivate(index)}
|
||||||
|
>
|
||||||
|
<span className="cmdbar-row-name truncate">
|
||||||
|
{nameMatched ? <Highlighted text={name} ranges={result.ranges} /> : name}
|
||||||
|
</span>
|
||||||
|
{chord !== undefined ? (
|
||||||
|
<span className="cmdbar-row-keys ml-auto flex gap-1">
|
||||||
|
{keyCaps(chord).map((cap, i) => (
|
||||||
|
<kbd
|
||||||
|
key={i}
|
||||||
|
className="rounded-r-1 border border-border px-1 text-fs-11 text-muted"
|
||||||
|
>
|
||||||
|
{cap}
|
||||||
|
</kbd>
|
||||||
|
))}
|
||||||
|
</span>
|
||||||
|
) : meta ? (
|
||||||
|
<span className="cmdbar-row-meta ml-auto truncate text-fs-11 text-muted">
|
||||||
|
{result.field !== undefined && result.field !== "name" ? (
|
||||||
|
<Highlighted text={meta} ranges={result.ranges} />
|
||||||
|
) : (
|
||||||
|
meta
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ResultList({ groups, showHeaders, highlighted, onActivate }: ResultListProps) {
|
||||||
|
if (groups.length === 0) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
id="cmdbar-results"
|
||||||
|
role="listbox"
|
||||||
|
aria-label="Results"
|
||||||
|
className="cmdbar-results max-h-[50vh] overflow-auto p-sp-1"
|
||||||
|
>
|
||||||
|
<div className="cmdbar-empty px-sp-3 py-sp-2 text-fs-12 text-muted">No matches</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let index = 0;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
id="cmdbar-results"
|
||||||
|
role="listbox"
|
||||||
|
aria-label="Results"
|
||||||
|
className="cmdbar-results max-h-[50vh] overflow-auto p-sp-1"
|
||||||
|
>
|
||||||
|
{groups.map((group) => (
|
||||||
|
<div key={group.kind}>
|
||||||
|
{showHeaders ? (
|
||||||
|
<div
|
||||||
|
role="presentation"
|
||||||
|
className="cmdbar-group-title px-sp-3 pt-sp-2 pb-1 text-fs-11 uppercase tracking-wide text-muted"
|
||||||
|
>
|
||||||
|
{group.title}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{group.results.map((result) => {
|
||||||
|
const at = index++;
|
||||||
|
return (
|
||||||
|
<Row
|
||||||
|
key={at}
|
||||||
|
result={result}
|
||||||
|
index={at}
|
||||||
|
active={at === highlighted}
|
||||||
|
onActivate={onActivate}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
export { CommandBar, SPAWN_DIRECTORY_EVENT, type CommandBarProps } from "./CommandBar.js";
|
||||||
|
export { ResultList, type ResultListProps } from "./ResultList.js";
|
||||||
|
export { ActionPage, type ActionPageProps } from "./ActionPage.js";
|
||||||
|
export { useCommandBar, type CommandBarControls, type UseCommandBarOptions } from "./useCommandBar.js";
|
||||||
|
export * from "./model.js";
|
||||||
@@ -0,0 +1,197 @@
|
|||||||
|
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 },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,366 @@
|
|||||||
|
// The command bar's pure model (docs/COMMAND-BAR.md), lifted from
|
||||||
|
// ../../commandBar.ts for the React port (#247): what a candidate is, the
|
||||||
|
// order an empty query lists them in, how a query line splits into a sigil and
|
||||||
|
// a query, and how a ranked list groups for rendering.
|
||||||
|
//
|
||||||
|
// Nothing here touches the DOM or the network apart from `loadBarDirectories`,
|
||||||
|
// which is the one place the `~` list comes from.
|
||||||
|
|
||||||
|
import { listDir, projects as fetchProjects, recentDirs } from "../../api.js";
|
||||||
|
import type { Command } from "../../commands.js";
|
||||||
|
import { splitPath } from "../../paths.js";
|
||||||
|
import { splitCommand } from "../../toolIcon.js";
|
||||||
|
import { abbreviateCwd, sessionName, sessionTreePath, type Session } from "../../types.js";
|
||||||
|
import {
|
||||||
|
matchAndRank,
|
||||||
|
type CandidateKind,
|
||||||
|
type CommandCandidate,
|
||||||
|
type DirectoryCandidate,
|
||||||
|
type MatchRange,
|
||||||
|
type MatchResult,
|
||||||
|
type SessionCandidate,
|
||||||
|
} from "../../utils/matcher.js";
|
||||||
|
|
||||||
|
/** 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 };
|
||||||
|
|
||||||
|
/** One row of a session's action page. It is a `command` to the matcher — a
|
||||||
|
* single `label` field, ranked exactly like a registry command — and carries
|
||||||
|
* the action it stands for instead of a registry entry. */
|
||||||
|
export type ActionBarCandidate = CommandCandidate & { action: SessionAction };
|
||||||
|
|
||||||
|
export type BarCandidate =
|
||||||
|
| SessionBarCandidate
|
||||||
|
| CommandBarCandidate
|
||||||
|
| ActionBarCandidate
|
||||||
|
| DirectoryBarCandidate;
|
||||||
|
export type BarResult = MatchResult<BarCandidate>;
|
||||||
|
|
||||||
|
/** One kind's slice of the ranked list, in rank order. */
|
||||||
|
export interface BarGroup<C extends BarCandidate = BarCandidate> {
|
||||||
|
kind: CandidateKind;
|
||||||
|
title: string;
|
||||||
|
results: MatchResult<C>[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const KIND_TITLES: Record<CandidateKind, string> = {
|
||||||
|
session: "Sessions",
|
||||||
|
command: "Commands",
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const PLACEHOLDERS: Record<"mixed" | BarSigil, string> = {
|
||||||
|
mixed: "Go to a session…",
|
||||||
|
">": "Run a command…",
|
||||||
|
"~": "Spawn in a directory…",
|
||||||
|
};
|
||||||
|
|
||||||
|
/** 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 };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- the session action page ----
|
||||||
|
|
||||||
|
/** The actions a session's action page offers, and no others: the bar picks
|
||||||
|
* one and hands off to the flow that already implements it
|
||||||
|
* (docs/COMMAND-BAR.md, "Drill-in"). */
|
||||||
|
export type SessionActionId = "kill" | "rename" | "spawn-similar";
|
||||||
|
|
||||||
|
export interface SessionAction {
|
||||||
|
id: SessionActionId;
|
||||||
|
label: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SESSION_ACTIONS: readonly SessionAction[] = [
|
||||||
|
{ id: "kill", label: "Kill session" },
|
||||||
|
{ id: "rename", label: "Rename session" },
|
||||||
|
{ id: "spawn-similar", label: "Spawn similar" },
|
||||||
|
];
|
||||||
|
|
||||||
|
/** The action page's candidates, in the order above — which is the order an
|
||||||
|
* empty query lists them in, since the matcher keeps input order. */
|
||||||
|
export function sessionActionCandidates(): ActionBarCandidate[] {
|
||||||
|
return SESSION_ACTIONS.map((action) => ({
|
||||||
|
kind: "command",
|
||||||
|
action,
|
||||||
|
fields: { label: action.label },
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Narrow a `command` candidate to an action row. Both share the matcher's
|
||||||
|
* `command` kind, so what separates them is what they carry. */
|
||||||
|
export function isActionCandidate(candidate: BarCandidate): candidate is ActionBarCandidate {
|
||||||
|
return candidate.kind === "command" && "action" in candidate;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- candidates ----
|
||||||
|
|
||||||
|
function focusedAt(s: Session): number | null {
|
||||||
|
const stamp = s.last_focused_at;
|
||||||
|
if (typeof stamp !== "string" || stamp === "") return null;
|
||||||
|
const ms = Date.parse(stamp);
|
||||||
|
return Number.isNaN(ms) ? null : ms;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createdAt(s: Session): number {
|
||||||
|
const ms = Date.parse(s.created_at ?? "");
|
||||||
|
return Number.isNaN(ms) ? 0 : ms;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The empty-query order (docs/COMMAND-BAR.md, "Empty query"): most recently
|
||||||
|
* focused first; a session that has never been focused sorts after every
|
||||||
|
* session that has, most recently created first among those; and a dead
|
||||||
|
* session sorts after every live one regardless of recency, because a session
|
||||||
|
* you cannot attach to is not where you just were.
|
||||||
|
*
|
||||||
|
* This is also the input order for a non-empty query, which is what the
|
||||||
|
* matcher's last tie-break preserves. */
|
||||||
|
export function orderSessionsForBar(sessions: readonly Session[]): Session[] {
|
||||||
|
return [...sessions].sort((a, b) => {
|
||||||
|
const deadA = a.status === "Dead" ? 1 : 0;
|
||||||
|
const deadB = b.status === "Dead" ? 1 : 0;
|
||||||
|
if (deadA !== deadB) return deadA - deadB;
|
||||||
|
|
||||||
|
const focusA = focusedAt(a);
|
||||||
|
const focusB = focusedAt(b);
|
||||||
|
if ((focusA === null) !== (focusB === null)) return focusA === null ? 1 : -1;
|
||||||
|
if (focusA !== null && focusB !== null && focusA !== focusB) return focusB - focusA;
|
||||||
|
|
||||||
|
return createdAt(b) - createdAt(a);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Build one session's candidate: the fields docs/COMMAND-BAR.md's candidate
|
||||||
|
* table names, with the directory abbreviated the way the sidebar shows it. */
|
||||||
|
export function sessionCandidate(s: Session, homeDir?: string): SessionBarCandidate {
|
||||||
|
const treePath = sessionTreePath(s);
|
||||||
|
const directory = treePath ? abbreviateCwd(treePath, homeDir) : undefined;
|
||||||
|
const tool = splitCommand(s.command).tool ?? undefined;
|
||||||
|
return {
|
||||||
|
kind: "session",
|
||||||
|
session: s,
|
||||||
|
fields: {
|
||||||
|
name: sessionName(s),
|
||||||
|
...(directory ? { directory } : {}),
|
||||||
|
...(s.command ? { command: s.command } : {}),
|
||||||
|
...(tool ? { tool } : {}),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Every session as a candidate, in the empty-query order. */
|
||||||
|
export function sessionBarCandidates(
|
||||||
|
sessions: readonly Session[],
|
||||||
|
homeDir?: string,
|
||||||
|
): SessionBarCandidate[] {
|
||||||
|
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];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One directory's children as candidate paths, for filling the `~` list in as
|
||||||
|
* a path is typed. The directory being listed comes from the query itself. */
|
||||||
|
export function dirEntryPaths(dir: string, entries: readonly string[]): string[] {
|
||||||
|
return entries.map((entry) => `${dir}${entry}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The directory a `~` query names, or `""` when it names none yet. */
|
||||||
|
export function probeDir(query: string): string {
|
||||||
|
return splitPath(query).dir;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** `GET /api/dirs`, in the shape the bar wants it. */
|
||||||
|
export function listDirEntries(dir: string): Promise<string[]> {
|
||||||
|
return listDir(dir).then((listing) => listing.entries);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 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<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);
|
||||||
|
if (!group) {
|
||||||
|
group = { kind, title: KIND_TITLES[kind], results: [] };
|
||||||
|
groups.push(group);
|
||||||
|
}
|
||||||
|
group.results.push(result);
|
||||||
|
}
|
||||||
|
return groups;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 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<C extends BarCandidate>(
|
||||||
|
query: string,
|
||||||
|
candidates: readonly C[],
|
||||||
|
): MatchResult<C>[] {
|
||||||
|
return matchAndRank(query, candidates);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- row text ----
|
||||||
|
|
||||||
|
/** The name line of a row: the session's name, the command's label, or the
|
||||||
|
* directory's path. */
|
||||||
|
export 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. */
|
||||||
|
export function metaText(result: BarResult): string | undefined {
|
||||||
|
if (result.candidate.kind !== "session") return undefined;
|
||||||
|
const fields = result.candidate.fields;
|
||||||
|
switch (result.field) {
|
||||||
|
case "directory":
|
||||||
|
return fields.directory;
|
||||||
|
case "command":
|
||||||
|
return fields.command;
|
||||||
|
case "tool":
|
||||||
|
return fields.tool;
|
||||||
|
default:
|
||||||
|
return fields.directory;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One run of a row's text, flagged when the matcher matched inside it. The
|
||||||
|
* React rendering of `renderHighlighted`: ranges come from the matcher as
|
||||||
|
* `[start, end)` character pairs over the field that won. */
|
||||||
|
export interface HighlightSegment {
|
||||||
|
text: string;
|
||||||
|
hit: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function highlightSegments(
|
||||||
|
text: string,
|
||||||
|
ranges: readonly MatchRange[],
|
||||||
|
): HighlightSegment[] {
|
||||||
|
const out: HighlightSegment[] = [];
|
||||||
|
let cursor = 0;
|
||||||
|
for (const [start, end] of ranges) {
|
||||||
|
if (start > cursor) out.push({ text: text.slice(cursor, start), hit: false });
|
||||||
|
out.push({ text: text.slice(start, end), hit: true });
|
||||||
|
cursor = end;
|
||||||
|
}
|
||||||
|
if (cursor < text.length) out.push({ text: text.slice(cursor), hit: false });
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
export { sessionName };
|
||||||
|
export type { CandidateKind, MatchRange, MatchResult };
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
// @vitest-environment happy-dom
|
||||||
|
import { act, cleanup, render } from "@testing-library/react";
|
||||||
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
import { OPEN_COMMAND_BAR_EVENT } from "../../catalog.js";
|
||||||
|
import { useCommandBar, type UseCommandBarOptions } from "./useCommandBar.js";
|
||||||
|
|
||||||
|
let controls: ReturnType<typeof useCommandBar>;
|
||||||
|
|
||||||
|
function Probe(opts: UseCommandBarOptions) {
|
||||||
|
controls = useCommandBar(opts);
|
||||||
|
return <div>{controls.open ? "open" : "closed"}</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ctrlK(target: EventTarget = window): void {
|
||||||
|
act(() => {
|
||||||
|
target.dispatchEvent(
|
||||||
|
new KeyboardEvent("keydown", { key: "k", ctrlKey: true, bubbles: true, cancelable: true }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
afterEach(cleanup);
|
||||||
|
|
||||||
|
describe("useCommandBar", () => {
|
||||||
|
it("opens on ctrl-k, from the capture phase so an attached xterm never sees it", () => {
|
||||||
|
const view = render(<Probe />);
|
||||||
|
ctrlK();
|
||||||
|
expect(view.container.textContent).toBe("open");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("stays deaf while the bar is already up — its own input owns the keys", () => {
|
||||||
|
const onOpen = vi.fn();
|
||||||
|
render(<Probe onOpen={onOpen} />);
|
||||||
|
ctrlK();
|
||||||
|
ctrlK();
|
||||||
|
expect(onOpen).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("leaves a form field's own Ctrl-K alone", () => {
|
||||||
|
const field = document.createElement("input");
|
||||||
|
document.body.appendChild(field);
|
||||||
|
const view = render(<Probe />);
|
||||||
|
ctrlK(field);
|
||||||
|
expect(view.container.textContent).toBe("closed");
|
||||||
|
field.remove();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("opens from the sidebar's search field, the touch entry point", () => {
|
||||||
|
const view = render(<Probe />);
|
||||||
|
act(() => {
|
||||||
|
document.dispatchEvent(new CustomEvent(OPEN_COMMAND_BAR_EVENT));
|
||||||
|
});
|
||||||
|
expect(view.container.textContent).toBe("open");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("runs the caller's hook before opening, so a preview is discarded first", () => {
|
||||||
|
const onOpen = vi.fn();
|
||||||
|
render(<Probe onOpen={onOpen} />);
|
||||||
|
ctrlK();
|
||||||
|
expect(onOpen).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("closes on demand", () => {
|
||||||
|
const view = render(<Probe />);
|
||||||
|
ctrlK();
|
||||||
|
act(() => controls.closeBar());
|
||||||
|
expect(view.container.textContent).toBe("closed");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("binds nothing while disabled — another overlay owns the keyboard", () => {
|
||||||
|
const view = render(<Probe enabled={false} />);
|
||||||
|
ctrlK();
|
||||||
|
expect(view.container.textContent).toBe("closed");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
// Opening and closing the bar (docs/COMMAND-BAR.md, "Opening"). `ctrl-k` is a
|
||||||
|
// registry command like any other, so the `?` overlay lists it without a
|
||||||
|
// second list; the touch entry point — the sidebar's search field — raises
|
||||||
|
// `OPEN_COMMAND_BAR_EVENT` and lands on the same open.
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useState } from "react";
|
||||||
|
|
||||||
|
import { OPEN_COMMAND_BAR_EVENT } from "../../catalog.js";
|
||||||
|
import { CommandSet, defineCommands, isBarTypingTarget } from "../../commands.js";
|
||||||
|
|
||||||
|
export interface UseCommandBarOptions {
|
||||||
|
/** Run before the bar opens — the shell discards a preview here, since the
|
||||||
|
* bar is not previewed into (docs/PREVIEW.md). */
|
||||||
|
onOpen?(): void;
|
||||||
|
/** Off while another overlay owns the keyboard (the restart overlay, the
|
||||||
|
* help dialog). Defaults to on. */
|
||||||
|
enabled?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CommandBarControls {
|
||||||
|
open: boolean;
|
||||||
|
openBar(): void;
|
||||||
|
closeBar(): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useCommandBar(opts: UseCommandBarOptions = {}): CommandBarControls {
|
||||||
|
const { onOpen, enabled = true } = opts;
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
|
||||||
|
const openBar = useCallback(() => {
|
||||||
|
onOpen?.();
|
||||||
|
setOpen(true);
|
||||||
|
}, [onOpen]);
|
||||||
|
|
||||||
|
const closeBar = useCallback(() => setOpen(false), []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!enabled) return;
|
||||||
|
const keys = new CommandSet(defineCommands({ "bar.open": openBar }));
|
||||||
|
|
||||||
|
// Capture phase: an attached xterm swallows every keystroke it is given, so
|
||||||
|
// the bar's binding has to be seen ahead of the terminal.
|
||||||
|
const onKeydown = (e: KeyboardEvent) => {
|
||||||
|
// The bar owns the keyboard while it is up; its own input handles the keys.
|
||||||
|
if (open) return;
|
||||||
|
// The pane's hidden textarea is not a typing target for the bar: ctrl-k
|
||||||
|
// is seen ahead of xterm and opens the bar. A real form field — the spawn
|
||||||
|
// form's inputs — keeps its own Ctrl+K.
|
||||||
|
if (isBarTypingTarget(e.target)) return;
|
||||||
|
keys.handle(e);
|
||||||
|
};
|
||||||
|
const onTouchEntry = () => {
|
||||||
|
if (!open) openBar();
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener("keydown", onKeydown, true);
|
||||||
|
document.addEventListener(OPEN_COMMAND_BAR_EVENT, onTouchEntry);
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener("keydown", onKeydown, true);
|
||||||
|
document.removeEventListener(OPEN_COMMAND_BAR_EVENT, onTouchEntry);
|
||||||
|
};
|
||||||
|
}, [enabled, open, openBar]);
|
||||||
|
|
||||||
|
return { open, openBar, closeBar };
|
||||||
|
}
|
||||||
@@ -1,96 +0,0 @@
|
|||||||
// @vitest-environment happy-dom
|
|
||||||
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
|
|
||||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
||||||
|
|
||||||
import { leaves } from "../../layout.js";
|
|
||||||
import { createLayoutStore } from "../../stores/layoutStore.js";
|
|
||||||
import type { Session } from "../../types.js";
|
|
||||||
import { MultiView } from "./MultiView.js";
|
|
||||||
|
|
||||||
vi.mock("../terminal/TerminalPane.js", () => ({
|
|
||||||
TerminalPane: ({ sessionId }: { sessionId: string }) => <div data-testid={`terminal-${sessionId}`} />,
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("../structured/StructuredPane.js", () => ({
|
|
||||||
StructuredPane: ({ sessionId }: { sessionId: string }) => <div data-testid={`structured-${sessionId}`} />,
|
|
||||||
}));
|
|
||||||
|
|
||||||
afterEach(cleanup);
|
|
||||||
|
|
||||||
function store() {
|
|
||||||
return createLayoutStore({
|
|
||||||
load: () => null,
|
|
||||||
save: vi.fn(),
|
|
||||||
scheduler: { setTimer: () => 1, clearTimer: () => {} },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function session(id: string, over: Partial<Session> = {}): Session {
|
|
||||||
return {
|
|
||||||
id,
|
|
||||||
cwd: "/home/n",
|
|
||||||
command: "claude",
|
|
||||||
env: {},
|
|
||||||
metadata: {},
|
|
||||||
status: "Running",
|
|
||||||
created_at: "2026-01-01T00:00:00Z",
|
|
||||||
...over,
|
|
||||||
} as Session;
|
|
||||||
}
|
|
||||||
|
|
||||||
describe("MultiView", () => {
|
|
||||||
it("shows the empty state when the active tab has no panes", () => {
|
|
||||||
const s = store();
|
|
||||||
render(<MultiView store={s} />);
|
|
||||||
expect(screen.getByText(/No session open/)).toBeTruthy();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("renders a pane per leaf, dispatching to the terminal or structured body", () => {
|
|
||||||
const s = store();
|
|
||||||
s.getState().openSession("s1");
|
|
||||||
s.getState().openSessionBeside("s2");
|
|
||||||
const sessions = new Map([
|
|
||||||
["s1", session("s1")],
|
|
||||||
["s2", session("s2", { view: "Structured" })],
|
|
||||||
]);
|
|
||||||
render(<MultiView store={s} sessions={sessions} />);
|
|
||||||
expect(screen.getByTestId("terminal-s1")).toBeTruthy();
|
|
||||||
expect(screen.getByTestId("structured-s2")).toBeTruthy();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("split right adds a second pane to the tree", () => {
|
|
||||||
const s = store();
|
|
||||||
s.getState().openSession("s1");
|
|
||||||
render(<MultiView store={s} sessions={new Map([["s1", session("s1")]])} />);
|
|
||||||
fireEvent.click(screen.getByTitle("Split right"));
|
|
||||||
expect(screen.getAllByText("Empty pane")).toHaveLength(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("close pane removes it", () => {
|
|
||||||
const s = store();
|
|
||||||
s.getState().openSession("s1");
|
|
||||||
const leafId = leaves(s.getState().layout.tabs[0].root)[0].id;
|
|
||||||
s.getState().splitFocused(leafId, "row"); // adds an empty leaf beside s1
|
|
||||||
render(<MultiView store={s} sessions={new Map([["s1", session("s1")]])} />);
|
|
||||||
// splitFocused(after: true) puts the fresh empty leaf second — close it.
|
|
||||||
fireEvent.click(screen.getAllByTitle("Close pane")[1]);
|
|
||||||
expect(screen.queryByText("Empty pane")).toBeNull();
|
|
||||||
expect(screen.getByTestId("terminal-s1")).toBeTruthy();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("prunes a leaf whose session no longer exists, once the live set is known", () => {
|
|
||||||
const s = store();
|
|
||||||
s.getState().openSession("s1");
|
|
||||||
s.getState().openSessionBeside("dead");
|
|
||||||
render(<MultiView store={s} sessions={new Map([["s1", session("s1")]])} />);
|
|
||||||
expect(screen.getByTestId("terminal-s1")).toBeTruthy();
|
|
||||||
expect(screen.queryByTestId("terminal-dead")).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("does not touch the layout while the live set is still unknown", () => {
|
|
||||||
const s = store();
|
|
||||||
s.getState().openSession("s1");
|
|
||||||
render(<MultiView store={s} />);
|
|
||||||
expect(screen.getByTestId("terminal-s1")).toBeTruthy();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,203 +0,0 @@
|
|||||||
// React port of multiview.ts's tab + split tree (issue #249). Preview mode
|
|
||||||
// (docs/PREVIEW.md) is issue #251; sidebar drag-to-dock and the mobile
|
|
||||||
// single-pane collapse stay with whichever issue wires this into the shell
|
|
||||||
// (#253) — this renders the tree itself: tabs, nested splits, pane chrome.
|
|
||||||
|
|
||||||
import type { ReactNode } from "react";
|
|
||||||
import { useEffect, useRef } from "react";
|
|
||||||
import { useStore } from "zustand";
|
|
||||||
|
|
||||||
import { activeTab, type Dir, type Node } from "../../layout.js";
|
|
||||||
import type { LayoutStore, LayoutStoreApi } from "../../stores/layoutStore.js";
|
|
||||||
import type { Session, Uuid } from "../../types.js";
|
|
||||||
import { PaneContainer } from "./PaneContainer.js";
|
|
||||||
import { PaneTitle } from "./PaneTitle.js";
|
|
||||||
import { TabBar } from "./TabBar.js";
|
|
||||||
|
|
||||||
export interface MultiViewProps {
|
|
||||||
store: LayoutStoreApi;
|
|
||||||
/** Live sessions, keyed by id — drives pane titles and (once known) boot
|
|
||||||
* reconciliation. Passing `undefined` defers reconciliation. */
|
|
||||||
sessions?: ReadonlyMap<Uuid, Session>;
|
|
||||||
onDisconnect?: (sessionId: Uuid) => void;
|
|
||||||
emptyState?: ReactNode;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function MultiView({ store, sessions, onDisconnect, emptyState }: MultiViewProps) {
|
|
||||||
const layout = useStore(store, (s: LayoutStore) => s.layout);
|
|
||||||
const tab = activeTab(layout);
|
|
||||||
|
|
||||||
const reconciledRef = useRef(false);
|
|
||||||
useEffect(() => {
|
|
||||||
if (reconciledRef.current || !sessions) return;
|
|
||||||
reconciledRef.current = true;
|
|
||||||
store.getState().reconcile(new Set(sessions.keys()));
|
|
||||||
}, [sessions, store]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="mv flex h-full min-h-0 flex-col">
|
|
||||||
<TabBar store={store} />
|
|
||||||
<div className="mv-canvas min-h-0 flex-1">
|
|
||||||
{tab.root ? (
|
|
||||||
<SplitNode node={tab.root} tab={tab} store={store} sessions={sessions} onDisconnect={onDisconnect} />
|
|
||||||
) : (
|
|
||||||
(emptyState ?? (
|
|
||||||
<div className="mv-empty flex h-full items-center justify-center p-sp-4 text-fs-13 text-faint">
|
|
||||||
No session open — pick one from the sidebar, or spawn a new one.
|
|
||||||
</div>
|
|
||||||
))
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
interface TreeProps {
|
|
||||||
tab: { id: string; focusedLeafId: string | null };
|
|
||||||
store: LayoutStoreApi;
|
|
||||||
sessions?: ReadonlyMap<Uuid, Session>;
|
|
||||||
onDisconnect?: (sessionId: Uuid) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
function SplitNode({ node, ...rest }: TreeProps & { node: Node }) {
|
|
||||||
if (node.kind === "leaf") {
|
|
||||||
return <Pane leaf={node} isFocused={rest.tab.focusedLeafId === node.id} {...rest} />;
|
|
||||||
}
|
|
||||||
return <SplitContainer node={node} {...rest} />;
|
|
||||||
}
|
|
||||||
|
|
||||||
function SplitContainer({ node, ...rest }: TreeProps & { node: Extract<Node, { kind: "split" }> }) {
|
|
||||||
const slotA = useRef<HTMLDivElement | null>(null);
|
|
||||||
const slotB = useRef<HTMLDivElement | null>(null);
|
|
||||||
const containerRef = useRef<HTMLDivElement | null>(null);
|
|
||||||
|
|
||||||
const onDividerPointerDown = (e: React.PointerEvent<HTMLDivElement>) => {
|
|
||||||
e.preventDefault();
|
|
||||||
const divider = e.currentTarget;
|
|
||||||
const container = containerRef.current;
|
|
||||||
if (!container) return;
|
|
||||||
divider.setPointerCapture(e.pointerId);
|
|
||||||
const rect = container.getBoundingClientRect();
|
|
||||||
const move = (ev: PointerEvent) => {
|
|
||||||
const frac =
|
|
||||||
node.dir === "row" ? (ev.clientX - rect.left) / rect.width : (ev.clientY - rect.top) / rect.height;
|
|
||||||
node.ratio = Math.max(0.1, Math.min(0.9, frac));
|
|
||||||
if (slotA.current) slotA.current.style.flexGrow = String(node.ratio);
|
|
||||||
if (slotB.current) slotB.current.style.flexGrow = String(1 - node.ratio);
|
|
||||||
};
|
|
||||||
const up = (ev: PointerEvent) => {
|
|
||||||
try {
|
|
||||||
divider.releasePointerCapture(ev.pointerId);
|
|
||||||
} catch {
|
|
||||||
// already released
|
|
||||||
}
|
|
||||||
divider.removeEventListener("pointermove", move);
|
|
||||||
divider.removeEventListener("pointerup", up);
|
|
||||||
rest.store.getState().commitLayout();
|
|
||||||
};
|
|
||||||
divider.addEventListener("pointermove", move);
|
|
||||||
divider.addEventListener("pointerup", up);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
ref={containerRef}
|
|
||||||
className={`mv-split flex h-full min-h-0 w-full ${node.dir === "row" ? "flex-row" : "flex-col"}`}
|
|
||||||
>
|
|
||||||
<div ref={slotA} className="mv-slot min-h-0 min-w-0" style={{ flex: `${node.ratio} 1 0` }}>
|
|
||||||
<SplitNode node={node.a} {...rest} />
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
className={
|
|
||||||
"mv-divider flex-none bg-border-strong hover:bg-accent " +
|
|
||||||
(node.dir === "row" ? "w-[3px] cursor-col-resize" : "h-[3px] cursor-row-resize")
|
|
||||||
}
|
|
||||||
onPointerDown={onDividerPointerDown}
|
|
||||||
/>
|
|
||||||
<div ref={slotB} className="mv-slot min-h-0 min-w-0" style={{ flex: `${1 - node.ratio} 1 0` }}>
|
|
||||||
<SplitNode node={node.b} {...rest} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function Pane({
|
|
||||||
leaf,
|
|
||||||
isFocused,
|
|
||||||
store,
|
|
||||||
sessions,
|
|
||||||
onDisconnect,
|
|
||||||
}: TreeProps & { leaf: Extract<Node, { kind: "leaf" }>; isFocused: boolean }) {
|
|
||||||
const session = leaf.sessionId ? sessions?.get(leaf.sessionId) : undefined;
|
|
||||||
const splitFocused = (dir: Dir) => store.getState().splitFocused(leaf.id, dir);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className={
|
|
||||||
"mv-pane flex h-full min-h-0 min-w-0 flex-col border " +
|
|
||||||
(isFocused ? "border-accent" : "border-border-strong")
|
|
||||||
}
|
|
||||||
data-leaf-id={leaf.id}
|
|
||||||
data-session-id={leaf.sessionId ?? undefined}
|
|
||||||
onPointerDownCapture={() => store.getState().focusLeaf(leaf.id)}
|
|
||||||
>
|
|
||||||
<div className="mv-pane-header flex items-center justify-between gap-sp-2 border-b border-border-strong bg-surface px-sp-2 py-sp-1 text-fs-12">
|
|
||||||
{leaf.sessionId ? (
|
|
||||||
<PaneTitle sessionId={leaf.sessionId} session={session} />
|
|
||||||
) : (
|
|
||||||
<span className="text-faint">Empty pane</span>
|
|
||||||
)}
|
|
||||||
<div className="mv-pane-tools flex items-center gap-sp-1">
|
|
||||||
{leaf.sessionId && onDisconnect && (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="mv-tool rounded-r-1 px-sp-1 text-faint hover:bg-surface-3 hover:text-ink"
|
|
||||||
title="Disconnect"
|
|
||||||
onClick={() => onDisconnect(leaf.sessionId!)}
|
|
||||||
>
|
|
||||||
⏻
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="mv-tool rounded-r-1 px-sp-1 text-faint hover:bg-surface-3 hover:text-ink"
|
|
||||||
title="Split right"
|
|
||||||
onClick={() => splitFocused("row")}
|
|
||||||
>
|
|
||||||
▥
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="mv-tool rounded-r-1 px-sp-1 text-faint hover:bg-surface-3 hover:text-ink"
|
|
||||||
title="Split down"
|
|
||||||
onClick={() => splitFocused("col")}
|
|
||||||
>
|
|
||||||
▤
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="mv-tool rounded-r-1 px-sp-1 text-faint hover:bg-surface-3 hover:text-ink"
|
|
||||||
title="Close pane"
|
|
||||||
onClick={() => store.getState().closePane(leaf.id)}
|
|
||||||
>
|
|
||||||
✕
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="mv-pane-host min-h-0 flex-1">
|
|
||||||
{leaf.sessionId ? (
|
|
||||||
<PaneContainer
|
|
||||||
sessionId={leaf.sessionId}
|
|
||||||
session={session}
|
|
||||||
focus={isFocused}
|
|
||||||
onClosed={() => store.getState().closeSession(leaf.sessionId!)}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<div className="mv-pane-empty flex h-full items-center justify-center p-sp-4 text-fs-13 text-faint">
|
|
||||||
Empty pane — pick a session from the sidebar, or spawn one.
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,54 +0,0 @@
|
|||||||
// @vitest-environment happy-dom
|
|
||||||
import { cleanup, render, screen } from "@testing-library/react";
|
|
||||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
||||||
|
|
||||||
import type { Session } from "../../types.js";
|
|
||||||
import { PaneContainer } from "./PaneContainer.js";
|
|
||||||
|
|
||||||
vi.mock("../terminal/TerminalPane.js", () => ({
|
|
||||||
TerminalPane: ({ sessionId, focus }: { sessionId: string; focus?: boolean }) => (
|
|
||||||
<div data-testid="terminal-pane" data-session-id={sessionId} data-focus={String(focus)} />
|
|
||||||
),
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("../structured/StructuredPane.js", () => ({
|
|
||||||
StructuredPane: ({ sessionId, focus }: { sessionId: string; focus?: boolean }) => (
|
|
||||||
<div data-testid="structured-pane" data-session-id={sessionId} data-focus={String(focus)} />
|
|
||||||
),
|
|
||||||
}));
|
|
||||||
|
|
||||||
afterEach(cleanup);
|
|
||||||
|
|
||||||
function session(over: Partial<Session> = {}): Session {
|
|
||||||
return {
|
|
||||||
id: "s1",
|
|
||||||
cwd: "/home/n",
|
|
||||||
command: "claude",
|
|
||||||
env: {},
|
|
||||||
metadata: {},
|
|
||||||
status: "Running",
|
|
||||||
created_at: "2026-01-01T00:00:00Z",
|
|
||||||
...over,
|
|
||||||
} as Session;
|
|
||||||
}
|
|
||||||
|
|
||||||
describe("PaneContainer", () => {
|
|
||||||
it("renders TerminalPane when the session has no view (defaults to Terminal)", () => {
|
|
||||||
render(<PaneContainer sessionId="s1" session={session()} />);
|
|
||||||
expect(screen.getByTestId("terminal-pane")).toBeTruthy();
|
|
||||||
expect(screen.queryByTestId("structured-pane")).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("renders TerminalPane when the session isn't known yet", () => {
|
|
||||||
render(<PaneContainer sessionId="s1" />);
|
|
||||||
expect(screen.getByTestId("terminal-pane")).toBeTruthy();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("renders StructuredPane for a Structured session", () => {
|
|
||||||
render(<PaneContainer sessionId="s1" session={session({ view: "Structured" })} focus={false} />);
|
|
||||||
const pane = screen.getByTestId("structured-pane");
|
|
||||||
expect(pane.dataset.sessionId).toBe("s1");
|
|
||||||
expect(pane.dataset.focus).toBe("false");
|
|
||||||
expect(screen.queryByTestId("terminal-pane")).toBeNull();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,49 +0,0 @@
|
|||||||
// Dispatches a leaf's session to the right body — TerminalPane for a PTY
|
|
||||||
// session, StructuredPane for an ACP one (session.view, #133/#140/#249).
|
|
||||||
|
|
||||||
import { forwardRef, useImperativeHandle, useRef } from "react";
|
|
||||||
|
|
||||||
import type { Session, Uuid } from "../../types.js";
|
|
||||||
import { StructuredPane, type StructuredPaneHandle } from "../structured/StructuredPane.js";
|
|
||||||
import { TerminalPane, type TerminalPaneHandle } from "../terminal/TerminalPane.js";
|
|
||||||
|
|
||||||
export interface PaneContainerHandle {
|
|
||||||
focusTerminal(): void;
|
|
||||||
/** No-op for a structured pane — it has no PTY (pane.ts's PaneConnection contract). */
|
|
||||||
sendInput(data: string): void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface PaneContainerProps {
|
|
||||||
sessionId: Uuid;
|
|
||||||
session?: Session;
|
|
||||||
/** Grabs the keyboard once connected. A sidebar preview passes `false`. */
|
|
||||||
focus?: boolean;
|
|
||||||
onClosed?: () => void;
|
|
||||||
className?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const PaneContainer = forwardRef<PaneContainerHandle, PaneContainerProps>(function PaneContainer(
|
|
||||||
{ sessionId, session, focus, onClosed, className },
|
|
||||||
ref,
|
|
||||||
) {
|
|
||||||
const terminalRef = useRef<TerminalPaneHandle>(null);
|
|
||||||
const structuredRef = useRef<StructuredPaneHandle>(null);
|
|
||||||
|
|
||||||
useImperativeHandle(
|
|
||||||
ref,
|
|
||||||
() => ({
|
|
||||||
focusTerminal: () => {
|
|
||||||
terminalRef.current?.focusTerminal();
|
|
||||||
structuredRef.current?.focusComposer();
|
|
||||||
},
|
|
||||||
sendInput: (data: string) => terminalRef.current?.sendInput(data),
|
|
||||||
}),
|
|
||||||
[],
|
|
||||||
);
|
|
||||||
|
|
||||||
return session?.view === "Structured" ? (
|
|
||||||
<StructuredPane ref={structuredRef} sessionId={sessionId} focus={focus} onClosed={onClosed} className={className} />
|
|
||||||
) : (
|
|
||||||
<TerminalPane ref={terminalRef} sessionId={sessionId} focus={focus} onClosed={onClosed} className={className} />
|
|
||||||
);
|
|
||||||
});
|
|
||||||
@@ -1,48 +0,0 @@
|
|||||||
// @vitest-environment happy-dom
|
|
||||||
import { cleanup, render, screen } from "@testing-library/react";
|
|
||||||
import { afterEach, describe, expect, it } from "vitest";
|
|
||||||
|
|
||||||
import type { Session } from "../../types.js";
|
|
||||||
import { PaneTitle } from "./PaneTitle.js";
|
|
||||||
|
|
||||||
afterEach(cleanup);
|
|
||||||
|
|
||||||
function session(over: Partial<Session> = {}): Session {
|
|
||||||
return {
|
|
||||||
id: "s1",
|
|
||||||
cwd: "/home/n",
|
|
||||||
command: "claude",
|
|
||||||
env: {},
|
|
||||||
metadata: {},
|
|
||||||
status: "Running",
|
|
||||||
created_at: "2026-01-01T00:00:00Z",
|
|
||||||
...over,
|
|
||||||
} as Session;
|
|
||||||
}
|
|
||||||
|
|
||||||
describe("PaneTitle", () => {
|
|
||||||
it("falls back to a short session id when the session isn't known yet", () => {
|
|
||||||
render(<PaneTitle sessionId="abcdef12-3456-7890-abcd-ef1234567890" />);
|
|
||||||
expect(screen.getByText(/abcdef12/)).toBeTruthy();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("shows the name alone when the command matches it", () => {
|
|
||||||
render(<PaneTitle sessionId="s1" session={session({ command: "claude", metadata: { name: "claude" } })} />);
|
|
||||||
expect(screen.getByText("claude")).toBeTruthy();
|
|
||||||
expect(document.querySelector(".at-cmd")).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("shows the command alongside a custom name", () => {
|
|
||||||
render(
|
|
||||||
<PaneTitle sessionId="s1" session={session({ command: "hx src/main.rs", metadata: { name: "editor" } })} />,
|
|
||||||
);
|
|
||||||
expect(screen.getByText("editor")).toBeTruthy();
|
|
||||||
expect(document.querySelector(".at-cmd")).not.toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("never injects markup from a session name", () => {
|
|
||||||
render(<PaneTitle sessionId="s1" session={session({ metadata: { name: "<img onerror=x>" } })} />);
|
|
||||||
expect(document.querySelector("img")).toBeNull();
|
|
||||||
expect(screen.getByText("<img onerror=x>")).toBeTruthy();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,39 +0,0 @@
|
|||||||
// React port of paneTitle.ts: a pane header's session identity — the name,
|
|
||||||
// then the command as its tool mark when recognised, same as the sidebar
|
|
||||||
// card's own CommandFragment (SessionCard.tsx). JSX escapes text nodes, so
|
|
||||||
// unlike the vanilla string-builder this needs no manual HTML escaping.
|
|
||||||
|
|
||||||
import { splitCommand, toolIconSvg } from "../../toolIcon.js";
|
|
||||||
import { sessionName, type Session, type Uuid } from "../../types.js";
|
|
||||||
|
|
||||||
export interface PaneTitleProps {
|
|
||||||
sessionId: Uuid;
|
|
||||||
session?: Session;
|
|
||||||
className?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function PaneTitle({ sessionId, session, className }: PaneTitleProps) {
|
|
||||||
if (!session) {
|
|
||||||
return (
|
|
||||||
<span className={className ?? "at-title"} title={sessionId}>
|
|
||||||
session <span className="sid">{sessionId.slice(0, 8)}</span>
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const name = sessionName(session);
|
|
||||||
const showCommand = Boolean(session.command) && session.command !== name;
|
|
||||||
const { tool, program } = splitCommand(session.command);
|
|
||||||
const icon = tool ? toolIconSvg(tool) : null;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<span className={className ?? "at-title"} title={`${name} · ${session.command} (${sessionId})`}>
|
|
||||||
<span className="at-name">{name}</span>
|
|
||||||
{showCommand && (
|
|
||||||
<span className="at-cmd">
|
|
||||||
{icon ? <span className="contents" dangerouslySetInnerHTML={{ __html: icon }} /> : <span>{program}</span>}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,88 +0,0 @@
|
|||||||
// @vitest-environment happy-dom
|
|
||||||
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
|
|
||||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
||||||
|
|
||||||
import { createLayoutStore } from "../../stores/layoutStore.js";
|
|
||||||
import { TabBar } from "./TabBar.js";
|
|
||||||
|
|
||||||
afterEach(cleanup);
|
|
||||||
|
|
||||||
function store() {
|
|
||||||
return createLayoutStore({
|
|
||||||
load: () => null,
|
|
||||||
save: vi.fn(),
|
|
||||||
scheduler: { setTimer: () => 1, clearTimer: () => {} },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function dataTransfer() {
|
|
||||||
const data = new Map<string, string>();
|
|
||||||
return {
|
|
||||||
setData: (type: string, value: string) => data.set(type, value),
|
|
||||||
getData: (type: string) => data.get(type) ?? "",
|
|
||||||
effectAllowed: "",
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
describe("TabBar", () => {
|
|
||||||
it("renders one tab per layout tab, marking the active one", () => {
|
|
||||||
const s = store();
|
|
||||||
s.getState().addTab();
|
|
||||||
render(<TabBar store={s} />);
|
|
||||||
const tabs = screen.getAllByRole("tab");
|
|
||||||
expect(tabs).toHaveLength(2);
|
|
||||||
expect(tabs[1].getAttribute("aria-selected")).toBe("true");
|
|
||||||
expect(tabs[0].getAttribute("aria-selected")).toBe("false");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("shows the open-session count next to a tab's title", () => {
|
|
||||||
const s = store();
|
|
||||||
s.getState().openSession("s1");
|
|
||||||
render(<TabBar store={s} />);
|
|
||||||
expect(screen.getByText(/· 1/)).toBeTruthy();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("clicking a tab selects it", () => {
|
|
||||||
const s = store();
|
|
||||||
s.getState().addTab();
|
|
||||||
const [first] = s.getState().layout.tabs;
|
|
||||||
render(<TabBar store={s} />);
|
|
||||||
fireEvent.click(screen.getAllByRole("tab")[0]);
|
|
||||||
expect(s.getState().layout.activeTabId).toBe(first.id);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("the + button adds a tab", () => {
|
|
||||||
const s = store();
|
|
||||||
render(<TabBar store={s} />);
|
|
||||||
fireEvent.click(screen.getByTitle("New tab"));
|
|
||||||
expect(s.getState().layout.tabs).toHaveLength(2);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("hides the close control on the last remaining tab", () => {
|
|
||||||
const s = store();
|
|
||||||
render(<TabBar store={s} />);
|
|
||||||
expect(screen.queryByTitle("Close tab")).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("closing a tab removes it", () => {
|
|
||||||
const s = store();
|
|
||||||
s.getState().addTab();
|
|
||||||
render(<TabBar store={s} />);
|
|
||||||
fireEvent.click(screen.getAllByTitle("Close tab")[0]);
|
|
||||||
expect(s.getState().layout.tabs).toHaveLength(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("dragging a tab onto another reorders them", () => {
|
|
||||||
const s = store();
|
|
||||||
s.getState().addTab();
|
|
||||||
s.getState().addTab();
|
|
||||||
const [a, b, c] = s.getState().layout.tabs;
|
|
||||||
render(<TabBar store={s} />);
|
|
||||||
const tabs = screen.getAllByRole("tab");
|
|
||||||
const dt = dataTransfer();
|
|
||||||
fireEvent.dragStart(tabs[0], { dataTransfer: dt });
|
|
||||||
fireEvent.dragOver(tabs[2], { dataTransfer: dt });
|
|
||||||
fireEvent.drop(tabs[2], { dataTransfer: dt });
|
|
||||||
expect(s.getState().layout.tabs.map((t) => t.id)).toEqual([b.id, c.id, a.id]);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,91 +0,0 @@
|
|||||||
// The tab strip above the canvas (issue #249): click to switch, drag to
|
|
||||||
// reorder, a close ✕ per tab (hidden on the last one), a trailing + to add.
|
|
||||||
|
|
||||||
import { useState } from "react";
|
|
||||||
import { useStore } from "zustand";
|
|
||||||
|
|
||||||
import { leaves } from "../../layout.js";
|
|
||||||
import type { LayoutStore, LayoutStoreApi } from "../../stores/layoutStore.js";
|
|
||||||
|
|
||||||
export interface TabBarProps {
|
|
||||||
store: LayoutStoreApi;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function TabBar({ store }: TabBarProps) {
|
|
||||||
const layout = useStore(store, (s: LayoutStore) => s.layout);
|
|
||||||
const [draggingId, setDraggingId] = useState<string | null>(null);
|
|
||||||
const [overId, setOverId] = useState<string | null>(null);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="mv-tabs flex items-center gap-sp-1 overflow-x-auto border-b border-border-strong bg-surface px-sp-2 text-fs-12" role="tablist">
|
|
||||||
{layout.tabs.map((tab) => {
|
|
||||||
const count = leaves(tab.root).filter((l) => l.sessionId).length;
|
|
||||||
const isActive = tab.id === layout.activeTabId;
|
|
||||||
const isDropTarget = overId === tab.id && draggingId !== null && draggingId !== tab.id;
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
key={tab.id}
|
|
||||||
type="button"
|
|
||||||
role="tab"
|
|
||||||
aria-selected={isActive}
|
|
||||||
draggable
|
|
||||||
data-tab-id={tab.id}
|
|
||||||
className={
|
|
||||||
"mv-tab flex items-center gap-sp-1 rounded-t-r-1 px-sp-2 py-sp-1 " +
|
|
||||||
(isActive ? "bg-surface-2 text-ink" : "text-faint hover:text-ink") +
|
|
||||||
(isDropTarget ? " outline outline-2 outline-accent" : "")
|
|
||||||
}
|
|
||||||
onDragStart={(e) => {
|
|
||||||
setDraggingId(tab.id);
|
|
||||||
e.dataTransfer.setData("text/plain", tab.id);
|
|
||||||
e.dataTransfer.effectAllowed = "move";
|
|
||||||
}}
|
|
||||||
onDragOver={(e) => {
|
|
||||||
if (!draggingId || draggingId === tab.id) return;
|
|
||||||
e.preventDefault();
|
|
||||||
setOverId(tab.id);
|
|
||||||
}}
|
|
||||||
onDragLeave={() => setOverId((current) => (current === tab.id ? null : current))}
|
|
||||||
onDrop={(e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
const fromId = e.dataTransfer.getData("text/plain") || draggingId;
|
|
||||||
setDraggingId(null);
|
|
||||||
setOverId(null);
|
|
||||||
if (fromId && fromId !== tab.id) store.getState().reorderTab(fromId, tab.id);
|
|
||||||
}}
|
|
||||||
onDragEnd={() => {
|
|
||||||
setDraggingId(null);
|
|
||||||
setOverId(null);
|
|
||||||
}}
|
|
||||||
onClick={() => store.getState().selectTab(tab.id)}
|
|
||||||
>
|
|
||||||
<span>
|
|
||||||
{tab.title}
|
|
||||||
{count ? ` · ${count}` : ""}
|
|
||||||
</span>
|
|
||||||
{layout.tabs.length > 1 && (
|
|
||||||
<span
|
|
||||||
className="mv-tab-close text-faint hover:text-ink"
|
|
||||||
title="Close tab"
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
store.getState().closeTab(tab.id);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
✕
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="mv-tab-add px-sp-2 py-sp-1 text-muted hover:text-ink"
|
|
||||||
title="New tab"
|
|
||||||
onClick={() => store.getState().addTab()}
|
|
||||||
>
|
|
||||||
+
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -7,7 +7,7 @@ import { beforeAll, describe, expect, it } from "vitest";
|
|||||||
|
|
||||||
import { OPEN_COMMAND_BAR_EVENT, renderSidebar } from "./catalog.js";
|
import { OPEN_COMMAND_BAR_EVENT, renderSidebar } from "./catalog.js";
|
||||||
import { sessionBarCandidates } from "./commandBar.js";
|
import { sessionBarCandidates } from "./commandBar.js";
|
||||||
import { matchAndRank } from "./matcher.js";
|
import { matchAndRank } from "./utils/matcher.js";
|
||||||
import type { CatalogListener, CatalogStore } from "./events.js";
|
import type { CatalogListener, CatalogStore } from "./events.js";
|
||||||
import type { Session } from "./types.js";
|
import type { Session } from "./types.js";
|
||||||
|
|
||||||
|
|||||||
@@ -1,162 +0,0 @@
|
|||||||
import { describe, expect, it, vi } from "vitest";
|
|
||||||
|
|
||||||
import { leaves } from "../layout.js";
|
|
||||||
import { createLayoutStore, type LayoutStoreOptions } from "./layoutStore.js";
|
|
||||||
|
|
||||||
function fakeScheduler() {
|
|
||||||
const timers = new Map<number, () => void>();
|
|
||||||
let id = 0;
|
|
||||||
return {
|
|
||||||
scheduler: {
|
|
||||||
setTimer: (fn: () => void) => {
|
|
||||||
const h = ++id;
|
|
||||||
timers.set(h, fn);
|
|
||||||
return h;
|
|
||||||
},
|
|
||||||
clearTimer: (h: number) => {
|
|
||||||
timers.delete(h);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
flush() {
|
|
||||||
for (const fn of [...timers.values()]) fn();
|
|
||||||
timers.clear();
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function store(opts: Partial<LayoutStoreOptions> = {}) {
|
|
||||||
return createLayoutStore({
|
|
||||||
load: () => null,
|
|
||||||
save: vi.fn(),
|
|
||||||
scheduler: fakeScheduler().scheduler,
|
|
||||||
...opts,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
describe("layoutStore", () => {
|
|
||||||
it("boots with a single empty tab when nothing is persisted", () => {
|
|
||||||
const { layout } = store().getState();
|
|
||||||
expect(layout.tabs).toHaveLength(1);
|
|
||||||
expect(layout.tabs[0].root).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("boots from a persisted layout when one loads", () => {
|
|
||||||
const persisted = store().getState().layout;
|
|
||||||
persisted.tabs[0].title = "Restored";
|
|
||||||
const s = store({ load: () => persisted });
|
|
||||||
expect(s.getState().layout.tabs[0].title).toBe("Restored");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("openSession seeds the active tab from empty", () => {
|
|
||||||
const s = store();
|
|
||||||
s.getState().openSession("s1");
|
|
||||||
expect(leaves(s.getState().layout.tabs[0].root).map((l) => l.sessionId)).toEqual(["s1"]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("openSession focuses an already-open session instead of moving it", () => {
|
|
||||||
const s = store();
|
|
||||||
s.getState().openSession("s1");
|
|
||||||
s.getState().openSessionBeside("s2");
|
|
||||||
s.getState().openSession("s1");
|
|
||||||
const ids = leaves(s.getState().layout.tabs[0].root)
|
|
||||||
.map((l) => l.sessionId)
|
|
||||||
.sort();
|
|
||||||
expect(ids).toEqual(["s1", "s2"]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("openSessionBeside splits next to the focused pane instead of replacing it", () => {
|
|
||||||
const s = store();
|
|
||||||
s.getState().openSession("s1");
|
|
||||||
s.getState().openSessionBeside("s2");
|
|
||||||
expect(leaves(s.getState().layout.tabs[0].root)).toHaveLength(2);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("openSessionBeside(newTab) opens its own tab", () => {
|
|
||||||
const s = store();
|
|
||||||
s.getState().openSession("s1");
|
|
||||||
s.getState().openSessionBeside("s2", { newTab: true });
|
|
||||||
expect(s.getState().layout.tabs).toHaveLength(2);
|
|
||||||
expect(leaves(s.getState().layout.tabs[1].root).map((l) => l.sessionId)).toEqual(["s2"]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("closeSession removes the pane and closes a tab left with nothing else", () => {
|
|
||||||
const s = store();
|
|
||||||
s.getState().openSession("s1");
|
|
||||||
s.getState().addTab();
|
|
||||||
s.getState().openSession("s2");
|
|
||||||
s.getState().closeSession("s2");
|
|
||||||
expect(s.getState().layout.tabs).toHaveLength(1);
|
|
||||||
expect(leaves(s.getState().layout.tabs[0].root).map((l) => l.sessionId)).toEqual(["s1"]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("splitFocused adds a leaf beside the given one and focuses it", () => {
|
|
||||||
const s = store();
|
|
||||||
s.getState().openSession("s1");
|
|
||||||
const first = leaves(s.getState().layout.tabs[0].root)[0].id;
|
|
||||||
s.getState().splitFocused(first, "row");
|
|
||||||
const tab = s.getState().layout.tabs[0];
|
|
||||||
expect(leaves(tab.root)).toHaveLength(2);
|
|
||||||
expect(tab.focusedLeafId).not.toBe(first);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("closePane collapses the split back to the surviving leaf", () => {
|
|
||||||
const s = store();
|
|
||||||
s.getState().openSession("s1");
|
|
||||||
const first = leaves(s.getState().layout.tabs[0].root)[0].id;
|
|
||||||
s.getState().splitFocused(first, "row");
|
|
||||||
s.getState().closePane(first);
|
|
||||||
const remaining = leaves(s.getState().layout.tabs[0].root);
|
|
||||||
expect(remaining).toHaveLength(1);
|
|
||||||
expect(remaining[0].sessionId).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("addTab/selectTab/closeTab manage the tab strip", () => {
|
|
||||||
const s = store();
|
|
||||||
s.getState().addTab();
|
|
||||||
expect(s.getState().layout.tabs).toHaveLength(2);
|
|
||||||
const [first, second] = s.getState().layout.tabs;
|
|
||||||
s.getState().selectTab(first.id);
|
|
||||||
expect(s.getState().layout.activeTabId).toBe(first.id);
|
|
||||||
s.getState().closeTab(second.id);
|
|
||||||
expect(s.getState().layout.tabs).toHaveLength(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("reorderTab moves a tab to sit where another one is", () => {
|
|
||||||
const s = store();
|
|
||||||
s.getState().addTab();
|
|
||||||
s.getState().addTab();
|
|
||||||
const [a, b, c] = s.getState().layout.tabs;
|
|
||||||
s.getState().reorderTab(c.id, a.id);
|
|
||||||
expect(s.getState().layout.tabs.map((t) => t.id)).toEqual([c.id, a.id, b.id]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("reconcile drops leaves for sessions no longer live, and closes a tab left with nothing else", () => {
|
|
||||||
const s = store();
|
|
||||||
s.getState().openSession("s1");
|
|
||||||
s.getState().addTab();
|
|
||||||
s.getState().openSession("dead");
|
|
||||||
s.getState().reconcile(new Set(["s1"]));
|
|
||||||
const { layout } = s.getState();
|
|
||||||
expect(layout.tabs).toHaveLength(1);
|
|
||||||
expect(leaves(layout.tabs[0].root).map((l) => l.sessionId)).toEqual(["s1"]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("reconcile leaves an intentionally empty tab alone", () => {
|
|
||||||
const s = store();
|
|
||||||
s.getState().addTab();
|
|
||||||
s.getState().reconcile(new Set());
|
|
||||||
expect(s.getState().layout.tabs).toHaveLength(2);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("debounces persistence to a single write once idle", () => {
|
|
||||||
const save = vi.fn();
|
|
||||||
const { scheduler, flush } = fakeScheduler();
|
|
||||||
const s = store({ save, scheduler });
|
|
||||||
s.getState().openSession("s1");
|
|
||||||
s.getState().openSessionBeside("s2");
|
|
||||||
expect(save).not.toHaveBeenCalled();
|
|
||||||
flush();
|
|
||||||
expect(save).toHaveBeenCalledTimes(1);
|
|
||||||
expect(save.mock.calls[0][0]).toBe(s.getState().layout);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,253 +0,0 @@
|
|||||||
// Zustand wrapper around ../layout.js's layout tree model (issue #249).
|
|
||||||
// Actions mutate the tree in place via layout.js's own functions, then
|
|
||||||
// re-wrap it so `useStore` selectors relying on referential equality notice.
|
|
||||||
|
|
||||||
import { create } from "zustand";
|
|
||||||
|
|
||||||
import {
|
|
||||||
activeTab,
|
|
||||||
addTab as addTabToLayout,
|
|
||||||
closeTab as closeTabInLayout,
|
|
||||||
emptyLayout,
|
|
||||||
equalizeRatios,
|
|
||||||
findLeaf,
|
|
||||||
leaves,
|
|
||||||
locateSession,
|
|
||||||
removeLeaf,
|
|
||||||
splitLeaf,
|
|
||||||
type Dir,
|
|
||||||
type Layout,
|
|
||||||
} from "../layout.js";
|
|
||||||
import { loadLayout, saveLayout } from "../persistence.js";
|
|
||||||
import type { Uuid } from "../types.js";
|
|
||||||
import type { Scheduler } from "./catalogStore.js";
|
|
||||||
|
|
||||||
const SAVE_DEBOUNCE_MS = 400;
|
|
||||||
|
|
||||||
const browserScheduler: Scheduler = {
|
|
||||||
setTimer: (fn, ms) => window.setTimeout(fn, ms),
|
|
||||||
clearTimer: (h) => window.clearTimeout(h),
|
|
||||||
};
|
|
||||||
|
|
||||||
export interface LayoutStoreOptions {
|
|
||||||
load?: () => Layout | null;
|
|
||||||
save?: (layout: Layout) => void;
|
|
||||||
scheduler?: Scheduler;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface LayoutState {
|
|
||||||
layout: Layout;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface LayoutActions {
|
|
||||||
/** Bring `sessionId` into view: focus it if already open, else fill the
|
|
||||||
* focused pane (VS-Code-style replace), or seed an empty tab. */
|
|
||||||
openSession(sessionId: Uuid): void;
|
|
||||||
/** Same, but never evicts a running pane: reuses an empty leaf or splits
|
|
||||||
* beside the focused one. `newTab` opens its own tab instead. */
|
|
||||||
openSessionBeside(sessionId: Uuid, opts?: { newTab?: boolean }): void;
|
|
||||||
/** Drop the pane showing `sessionId` (closed/killed elsewhere). */
|
|
||||||
closeSession(sessionId: Uuid): void;
|
|
||||||
splitFocused(leafId: string, dir: Dir): void;
|
|
||||||
closePane(leafId: string): void;
|
|
||||||
focusLeaf(leafId: string): void;
|
|
||||||
selectTab(tabId: string): void;
|
|
||||||
addTab(): void;
|
|
||||||
closeTab(tabId: string): void;
|
|
||||||
/** Tab-strip drag-and-drop reordering: moves `fromId` to sit where `toId`
|
|
||||||
* currently is. */
|
|
||||||
reorderTab(fromId: string, toId: string): void;
|
|
||||||
/** Persist whatever a divider drag left on a split's `ratio` — the drag
|
|
||||||
* itself mutates the node directly (same trade as the vanilla version),
|
|
||||||
* this just re-wraps and schedules the save. */
|
|
||||||
commitLayout(): void;
|
|
||||||
/** Boot reconciliation: drop every leaf whose session isn't in `liveIds`,
|
|
||||||
* and close any tab that held nothing else. A session killed while its
|
|
||||||
* tab was closed is pruned instead of leaving a pane that can only ever
|
|
||||||
* show a dead socket. Tabs that were already empty are left alone. */
|
|
||||||
reconcile(liveIds: ReadonlySet<Uuid>): void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export type LayoutStore = LayoutState & LayoutActions;
|
|
||||||
export type LayoutStoreApi = ReturnType<typeof createLayoutStore>;
|
|
||||||
|
|
||||||
/** Builds an isolated layout store — load/save/scheduler are injectable
|
|
||||||
* (tests), or default to the real localStorage persistence.ts + browser
|
|
||||||
* timers. Same shape as {@link createCatalogStore}. */
|
|
||||||
export function createLayoutStore(opts: LayoutStoreOptions = {}) {
|
|
||||||
const load = opts.load ?? loadLayout;
|
|
||||||
const save = opts.save ?? saveLayout;
|
|
||||||
const scheduler = opts.scheduler ?? browserScheduler;
|
|
||||||
|
|
||||||
let saveTimer: number | null = null;
|
|
||||||
|
|
||||||
return create<LayoutStore>((set, get) => {
|
|
||||||
function touch(): void {
|
|
||||||
// A shallow clone, not the same reference back: `useStore` (and any
|
|
||||||
// other Object.is-based selector) needs the identity to change to
|
|
||||||
// notice a mutation made in place on the tree underneath.
|
|
||||||
set({ layout: { ...get().layout } });
|
|
||||||
}
|
|
||||||
|
|
||||||
function schedulePersist(): void {
|
|
||||||
if (saveTimer !== null) scheduler.clearTimer(saveTimer);
|
|
||||||
saveTimer = scheduler.setTimer(() => {
|
|
||||||
saveTimer = null;
|
|
||||||
save(get().layout);
|
|
||||||
}, SAVE_DEBOUNCE_MS);
|
|
||||||
}
|
|
||||||
|
|
||||||
function commit(): void {
|
|
||||||
touch();
|
|
||||||
schedulePersist();
|
|
||||||
}
|
|
||||||
|
|
||||||
function mutate(fn: (layout: Layout) => void): void {
|
|
||||||
fn(get().layout);
|
|
||||||
commit();
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
layout: load() ?? emptyLayout(),
|
|
||||||
|
|
||||||
openSession(sessionId) {
|
|
||||||
mutate((layout) => {
|
|
||||||
const loc = locateSession(layout, sessionId);
|
|
||||||
if (loc) {
|
|
||||||
layout.activeTabId = loc.tab.id;
|
|
||||||
loc.tab.focusedLeafId = loc.leaf.id;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const tab = activeTab(layout);
|
|
||||||
if (!tab.root) {
|
|
||||||
tab.focusedLeafId = splitLeaf(tab, "", sessionId, "row", true);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const target = (tab.focusedLeafId && findLeaf(tab.root, tab.focusedLeafId)) || leaves(tab.root)[0];
|
|
||||||
target.sessionId = sessionId;
|
|
||||||
tab.focusedLeafId = target.id;
|
|
||||||
});
|
|
||||||
},
|
|
||||||
|
|
||||||
openSessionBeside(sessionId, placement) {
|
|
||||||
mutate((layout) => {
|
|
||||||
const loc = locateSession(layout, sessionId);
|
|
||||||
if (loc) {
|
|
||||||
layout.activeTabId = loc.tab.id;
|
|
||||||
loc.tab.focusedLeafId = loc.leaf.id;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (placement?.newTab) {
|
|
||||||
const fresh = addTabToLayout(layout);
|
|
||||||
fresh.focusedLeafId = splitLeaf(fresh, "", sessionId, "row", true);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const tab = activeTab(layout);
|
|
||||||
if (!tab.root || !tab.focusedLeafId || !findLeaf(tab.root, tab.focusedLeafId)) {
|
|
||||||
tab.focusedLeafId = splitLeaf(tab, "", sessionId, "row", true);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const target = findLeaf(tab.root, tab.focusedLeafId)!;
|
|
||||||
if (!target.sessionId) {
|
|
||||||
target.sessionId = sessionId;
|
|
||||||
} else {
|
|
||||||
tab.focusedLeafId = splitLeaf(tab, target.id, sessionId, "row", true);
|
|
||||||
equalizeRatios(tab.root);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
},
|
|
||||||
|
|
||||||
closeSession(sessionId) {
|
|
||||||
mutate((layout) => {
|
|
||||||
const loc = locateSession(layout, sessionId);
|
|
||||||
if (!loc) return;
|
|
||||||
removeLeaf(loc.tab, loc.leaf.id);
|
|
||||||
if (loc.tab.focusedLeafId === loc.leaf.id) {
|
|
||||||
loc.tab.focusedLeafId = leaves(loc.tab.root)[0]?.id ?? null;
|
|
||||||
}
|
|
||||||
if (!loc.tab.root) closeTabInLayout(layout, loc.tab.id);
|
|
||||||
});
|
|
||||||
},
|
|
||||||
|
|
||||||
splitFocused(leafId, dir) {
|
|
||||||
mutate((layout) => {
|
|
||||||
const tab = activeTab(layout);
|
|
||||||
tab.focusedLeafId = splitLeaf(tab, leafId, null, dir, true);
|
|
||||||
equalizeRatios(tab.root);
|
|
||||||
});
|
|
||||||
},
|
|
||||||
|
|
||||||
closePane(leafId) {
|
|
||||||
mutate((layout) => {
|
|
||||||
const tab = activeTab(layout);
|
|
||||||
removeLeaf(tab, leafId);
|
|
||||||
if (tab.focusedLeafId === leafId) {
|
|
||||||
tab.focusedLeafId = leaves(tab.root)[0]?.id ?? null;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
},
|
|
||||||
|
|
||||||
focusLeaf(leafId) {
|
|
||||||
mutate((layout) => {
|
|
||||||
const tab = activeTab(layout);
|
|
||||||
if (tab.focusedLeafId === leafId) return;
|
|
||||||
tab.focusedLeafId = leafId;
|
|
||||||
});
|
|
||||||
},
|
|
||||||
|
|
||||||
selectTab(tabId) {
|
|
||||||
mutate((layout) => {
|
|
||||||
if (layout.tabs.some((t) => t.id === tabId)) layout.activeTabId = tabId;
|
|
||||||
});
|
|
||||||
},
|
|
||||||
|
|
||||||
addTab() {
|
|
||||||
mutate((layout) => {
|
|
||||||
addTabToLayout(layout);
|
|
||||||
});
|
|
||||||
},
|
|
||||||
|
|
||||||
closeTab(tabId) {
|
|
||||||
mutate((layout) => {
|
|
||||||
closeTabInLayout(layout, tabId);
|
|
||||||
});
|
|
||||||
},
|
|
||||||
|
|
||||||
reorderTab(fromId, toId) {
|
|
||||||
mutate((layout) => {
|
|
||||||
if (fromId === toId) return;
|
|
||||||
const from = layout.tabs.findIndex((t) => t.id === fromId);
|
|
||||||
const to = layout.tabs.findIndex((t) => t.id === toId);
|
|
||||||
if (from < 0 || to < 0) return;
|
|
||||||
const [moved] = layout.tabs.splice(from, 1);
|
|
||||||
layout.tabs.splice(to, 0, moved);
|
|
||||||
});
|
|
||||||
},
|
|
||||||
|
|
||||||
commitLayout() {
|
|
||||||
commit();
|
|
||||||
},
|
|
||||||
|
|
||||||
reconcile(liveIds) {
|
|
||||||
mutate((layout) => {
|
|
||||||
const emptied: string[] = [];
|
|
||||||
for (const tab of [...layout.tabs]) {
|
|
||||||
let prunedAny = false;
|
|
||||||
for (const leaf of leaves(tab.root)) {
|
|
||||||
if (leaf.sessionId && !liveIds.has(leaf.sessionId)) {
|
|
||||||
removeLeaf(tab, leaf.id);
|
|
||||||
prunedAny = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (!prunedAny) continue;
|
|
||||||
if (!tab.focusedLeafId || !findLeaf(tab.root, tab.focusedLeafId)) {
|
|
||||||
tab.focusedLeafId = leaves(tab.root)[0]?.id ?? null;
|
|
||||||
}
|
|
||||||
if (!tab.root) emptied.push(tab.id);
|
|
||||||
}
|
|
||||||
for (const id of emptied) closeTabInLayout(layout, id);
|
|
||||||
});
|
|
||||||
},
|
|
||||||
};
|
|
||||||
});
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user