Compare commits

..
Author SHA1 Message Date
Miguel Palhas ea8c42bfb2 feat(maestro): port preview mode to React (#251)
Extends layoutStore with previewSession/commitPreview, mirroring
multiview.ts's preview controller: a throwaway tab for a session no pane
holds, byte-for-byte restore on end, and every real layout-changing action
drops an active preview first. Never schedules a save while a preview is up.

Adds usePreview, wrapping the store actions with previewSchedule.ts's
120ms leading-edge throttle, plus a touch guard and an immediate `end()`
for real changes. MultiView/TabBar render the preview hue on the pane
titlebar, canvas ring/chip, and the throwaway tab (dashed, unclickable).
2026-09-09 01:39:05 +01:00
Miguel Palhas e9e98e0c49 Closes #249: port layout system to React 2026-09-09 01:23:04 +01:00
Miguel Palhas 02f1844650 Closes #247: port command bar (ctrl-k) to React 2026-09-09 01:23:01 +01:00
Miguel Palhas ee61cea64f feat(maestro): port the command bar to React (#247)
CommandBar, ResultList, ActionPage and useCommandBar over the same
model docs/COMMAND-BAR.md specifies: sigils, empty-query session order,
the drill-in action page, and the keys.

The highlight now walks the rows in the order they render. The legacy
bar indexed the highlight by display order but activated by rank order,
so Enter could act on a different row than the one lit.
2026-09-09 01:14:28 +01:00
Miguel Palhas 7e21371e47 refactor(maestro): lift the matcher into a shared utils module
The match-and-rank algorithm is pure and both the legacy bar and the
React port rank with it (#247).
2026-09-09 01:14:28 +01:00
20 changed files with 2315 additions and 15 deletions
+1 -1
View File
@@ -21,7 +21,7 @@ import {
type MatchRange,
type MatchResult,
type SessionCandidate,
} from "./matcher.js";
} from "./utils/matcher.js";
import { splitPath } from "./paths.js";
import { splitCommand } from "./toolIcon.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>
);
}
+5
View File
@@ -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";
+197
View File
@@ -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 },
]);
});
});
+366
View File
@@ -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 };
}
+57 -1
View File
@@ -1,5 +1,5 @@
// @vitest-environment happy-dom
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
import { act, cleanup, fireEvent, render, screen } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
import { leaves } from "../../layout.js";
@@ -93,4 +93,60 @@ describe("MultiView", () => {
render(<MultiView store={s} />);
expect(screen.getByTestId("terminal-s1")).toBeTruthy();
});
describe("preview mode (docs/PREVIEW.md)", () => {
it("shows a session no pane holds in a throwaway tab, marked as a preview", () => {
const s = store();
s.getState().openSession("s1");
render(<MultiView store={s} sessions={new Map([["s1", session("s1")]])} />);
act(() => s.getState().previewSession("s9"));
// s1's own tab is still in the strip — the preview never touched it.
expect(screen.getByText("Tab 1 · 1")).toBeTruthy();
const pane = document.querySelector('[data-session-id="s9"]');
expect(pane).toBeTruthy();
expect(pane!.querySelector(".bg-preview-tint")).toBeTruthy();
expect(screen.getByText("preview")).toBeTruthy();
});
it("puts the layout back byte for byte once the preview ends", () => {
const s = store();
s.getState().openSession("s1");
render(<MultiView store={s} sessions={new Map([["s1", session("s1")]])} />);
const before = JSON.stringify(s.getState().layout);
act(() => s.getState().previewSession("s9"));
act(() => s.getState().previewSession(null));
expect(JSON.stringify(s.getState().layout)).toBe(before);
expect(screen.queryByText("preview")).toBeNull();
});
it("does not grab the keyboard while previewing", () => {
const s = store();
s.getState().openSession("s1");
render(<MultiView store={s} sessions={new Map([["s1", session("s1")]])} />);
act(() => s.getState().previewSession("s9"));
const pane = document.querySelector('[data-session-id="s9"]') as HTMLElement;
expect(pane.querySelector('[data-testid="terminal-s9"]')).toBeTruthy();
// TerminalPane's `focus` prop is verified by TerminalPane's own tests;
// here the preview must never end up as the active-tab's accent pane.
expect(pane.className).not.toContain("border-accent");
});
it("clicking into a previewed pane commits it: no more preview, nothing moves a second time", () => {
const s = store();
s.getState().openSession("s1");
render(<MultiView store={s} sessions={new Map([["s1", session("s1")]])} />);
act(() => s.getState().previewSession("s9"));
const pane = document.querySelector('[data-session-id="s9"]') as HTMLElement;
fireEvent.pointerDown(pane);
expect(s.getState().preview).toBeNull();
expect(screen.queryByText("preview")).toBeNull();
expect(leaves(s.getState().layout.tabs[1].root).map((l) => l.sessionId)).toEqual(["s9"]);
});
});
});
+42 -8
View File
@@ -1,5 +1,5 @@
// 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
// React port of multiview.ts's tab + split tree (issue #249) plus its preview
// mode (issue #251, docs/PREVIEW.md). 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.
@@ -8,6 +8,7 @@ import { useEffect, useRef } from "react";
import { useStore } from "zustand";
import { activeTab, type Dir, type Node } from "../../layout.js";
import { usePreview } from "../../hooks/usePreview.js";
import type { LayoutStore, LayoutStoreApi } from "../../stores/layoutStore.js";
import type { Session, Uuid } from "../../types.js";
import { PaneContainer } from "./PaneContainer.js";
@@ -26,6 +27,8 @@ export interface MultiViewProps {
export function MultiView({ store, sessions, onDisconnect, emptyState }: MultiViewProps) {
const layout = useStore(store, (s: LayoutStore) => s.layout);
const tab = activeTab(layout);
const { previewedSessionId, commit: commitPreview } = usePreview({ store });
const isPreviewing = previewedSessionId !== null;
const reconciledRef = useRef(false);
useEffect(() => {
@@ -37,9 +40,17 @@ export function MultiView({ store, sessions, onDisconnect, emptyState }: MultiVi
return (
<div className="mv flex h-full min-h-0 flex-col">
<TabBar store={store} />
<div className="mv-canvas min-h-0 flex-1">
<div className="mv-canvas relative min-h-0 flex-1">
{tab.root ? (
<SplitNode node={tab.root} tab={tab} store={store} sessions={sessions} onDisconnect={onDisconnect} />
<SplitNode
node={tab.root}
tab={tab}
store={store}
sessions={sessions}
onDisconnect={onDisconnect}
previewedSessionId={previewedSessionId}
commitPreview={commitPreview}
/>
) : (
(emptyState ?? (
<div className="mv-empty flex h-full items-center justify-center p-sp-4 text-fs-13 text-faint">
@@ -47,6 +58,14 @@ export function MultiView({ store, sessions, onDisconnect, emptyState }: MultiVi
</div>
))
)}
{isPreviewing && (
<>
<div className="mv-preview-ring pointer-events-none absolute inset-0 rounded-r-2 border border-dashed border-preview-ring" />
<div className="mv-preview-chip pointer-events-none absolute right-sp-2 top-sp-2 z-sticky rounded-r-pill bg-preview px-sp-2 py-[2px] text-fs-11 text-preview-ink">
preview
</div>
</>
)}
</div>
</div>
);
@@ -57,6 +76,8 @@ interface TreeProps {
store: LayoutStoreApi;
sessions?: ReadonlyMap<Uuid, Session>;
onDisconnect?: (sessionId: Uuid) => void;
previewedSessionId: Uuid | null;
commitPreview: (sessionId: Uuid) => void;
}
function SplitNode({ node, ...rest }: TreeProps & { node: Node }) {
@@ -127,21 +148,34 @@ function Pane({
store,
sessions,
onDisconnect,
previewedSessionId,
commitPreview,
}: TreeProps & { leaf: Extract<Node, { kind: "leaf" }>; isFocused: boolean }) {
const session = leaf.sessionId ? sessions?.get(leaf.sessionId) : undefined;
const isPreview = leaf.sessionId !== null && leaf.sessionId === previewedSessionId;
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")
(isFocused && !isPreview ? "border-accent" : "border-border-strong")
}
data-leaf-id={leaf.id}
data-session-id={leaf.sessionId ?? undefined}
onPointerDownCapture={() => store.getState().focusLeaf(leaf.id)}
onPointerDownCapture={() => {
// Clicking into a previewed pane is the operator choosing it
// (docs/PREVIEW.md).
if (isPreview) commitPreview(leaf.sessionId!);
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">
<div
className={
"mv-pane-header flex items-center justify-between gap-sp-2 border-b border-border-strong px-sp-2 py-sp-1 text-fs-12 " +
(isPreview ? "bg-preview-tint text-preview" : "bg-surface")
}
>
{leaf.sessionId ? (
<PaneTitle sessionId={leaf.sessionId} session={session} />
) : (
@@ -189,7 +223,7 @@ function Pane({
<PaneContainer
sessionId={leaf.sessionId}
session={session}
focus={isFocused}
focus={isFocused && previewedSessionId === null}
onClosed={() => store.getState().closeSession(leaf.sessionId!)}
/>
) : (
+14 -3
View File
@@ -13,6 +13,7 @@ export interface TabBarProps {
export function TabBar({ store }: TabBarProps) {
const layout = useStore(store, (s: LayoutStore) => s.layout);
const previewTempTabId = useStore(store, (s: LayoutStore) => s.preview?.tempTabId ?? null);
const [draggingId, setDraggingId] = useState<string | null>(null);
const [overId, setOverId] = useState<string | null>(null);
@@ -22,6 +23,9 @@ export function TabBar({ store }: TabBarProps) {
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;
// A preview's throwaway tab is not a place to go — it goes on its
// own the moment the preview ends (docs/PREVIEW.md).
const isPreviewTab = tab.id === previewTempTabId;
return (
<button
key={tab.id}
@@ -32,7 +36,11 @@ export function TabBar({ store }: TabBarProps) {
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") +
(isPreviewTab
? "border border-dashed border-preview-ring italic text-preview"
: isActive
? "bg-surface-2 text-ink"
: "text-faint hover:text-ink") +
(isDropTarget ? " outline outline-2 outline-accent" : "")
}
onDragStart={(e) => {
@@ -57,13 +65,16 @@ export function TabBar({ store }: TabBarProps) {
setDraggingId(null);
setOverId(null);
}}
onClick={() => store.getState().selectTab(tab.id)}
onClick={() => {
if (isPreviewTab) return;
store.getState().selectTab(tab.id);
}}
>
<span>
{tab.title}
{count ? ` · ${count}` : ""}
</span>
{layout.tabs.length > 1 && (
{layout.tabs.length > 1 && !isPreviewTab && (
<span
className="mv-tab-close text-faint hover:text-ink"
title="Close tab"
+174
View File
@@ -0,0 +1,174 @@
// @vitest-environment happy-dom
import { act, cleanup, render } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
import { leaves } from "../layout.js";
import { createLayoutStore, type LayoutStoreApi } from "../stores/layoutStore.js";
import { usePreview, type UsePreviewResult } from "./usePreview.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();
},
};
}
/** Hand-driven clock + timer queue for the preview schedule's own throttle,
* independent of the store's save-debounce scheduler above. */
function fakeClock() {
let now = 1_000;
const timers = new Map<number, { at: number; fn: () => void }>();
let handle = 1;
return {
now: () => now,
setTimer: (fn: () => void, ms: number) => {
const h = handle++;
timers.set(h, { at: now + ms, fn });
return h;
},
clearTimer: (h: number) => {
timers.delete(h);
},
advance(ms: number) {
now += ms;
for (const [h, t] of [...timers]) {
if (t.at <= now) {
timers.delete(h);
act(() => t.fn());
}
}
},
};
}
function newStore(): LayoutStoreApi {
return createLayoutStore({ load: () => null, save: vi.fn(), scheduler: fakeScheduler().scheduler });
}
let controls: UsePreviewResult;
function Probe({ store, clock }: { store: LayoutStoreApi; clock: ReturnType<typeof fakeClock> }) {
controls = usePreview({
store,
scheduleDeps: { now: clock.now, setTimer: clock.setTimer, clearTimer: clock.clearTimer },
});
return <div>{controls.previewedSessionId ?? "none"}</div>;
}
afterEach(cleanup);
describe("usePreview", () => {
it("shows the first preview immediately — no settling delay", () => {
const store = newStore();
store.getState().openSession("s1");
const clock = fakeClock();
const view = render(<Probe store={store} clock={clock} />);
act(() => controls.preview("s9"));
expect(view.container.textContent).toBe("s9");
expect(leaves(store.getState().layout.tabs[1]?.root ?? null).map((l) => l.sessionId)).toEqual(["s9"]);
});
it("throttles a burst to one leading + one trailing pane swap", () => {
const store = newStore();
store.getState().openSession("s1");
const clock = fakeClock();
const view = render(<Probe store={store} clock={clock} />);
act(() => {
controls.preview("s1");
controls.preview("s8");
controls.preview("s9");
});
expect(view.container.textContent).toBe("s1");
clock.advance(120);
expect(view.container.textContent).toBe("s9");
});
it("touch never previews", () => {
const store = newStore();
store.getState().openSession("s1");
const clock = fakeClock();
const view = render(<Probe store={store} clock={clock} />);
act(() => controls.previewFromPointer("s9", "touch"));
expect(view.container.textContent).toBe("none");
expect(store.getState().preview).toBeNull();
});
it("previewFromPointer still previews for mouse/pen", () => {
const store = newStore();
store.getState().openSession("s1");
const clock = fakeClock();
const view = render(<Probe store={store} clock={clock} />);
act(() => controls.previewFromPointer("s9", "mouse"));
expect(view.container.textContent).toBe("s9");
});
it("commit cancels a pending trailing preview and opens for real", () => {
const store = newStore();
store.getState().openSession("s1");
const clock = fakeClock();
const view = render(<Probe store={store} clock={clock} />);
act(() => {
controls.preview("s1"); // leading edge: previews the already-open s1
controls.preview("s9"); // queued, gap not elapsed yet
controls.commit("s9");
});
expect(view.container.textContent).toBe("none"); // committed: no longer a preview
expect(leaves(store.getState().layout.tabs[0].root).map((l) => l.sessionId)).toEqual(["s9"]);
clock.advance(120);
// The cancelled trailing preview never fires.
expect(view.container.textContent).toBe("none");
});
it("end drops a preview immediately, bypassing the throttle", () => {
const store = newStore();
store.getState().openSession("s1");
const clock = fakeClock();
const view = render(<Probe store={store} clock={clock} />);
act(() => controls.preview("s9"));
expect(view.container.textContent).toBe("s9");
act(() => controls.end());
expect(view.container.textContent).toBe("none");
expect(store.getState().layout.tabs).toHaveLength(1);
});
it("cancels its pending trailing preview on unmount", () => {
const store = newStore();
store.getState().openSession("s1");
store.getState().addTab();
store.getState().openSession("s2"); // leading edge below will preview this, already on screen
const clock = fakeClock();
const view = render(<Probe store={store} clock={clock} />);
act(() => {
controls.preview("s2"); // leading edge: fires now
controls.preview("s9"); // queued trailing preview
});
expect(store.getState().preview?.sessionId).toBe("s2");
view.unmount();
clock.advance(120);
// The queued trailing preview must not fire after unmount.
expect(store.getState().preview?.sessionId).toBe("s2");
});
});
+81
View File
@@ -0,0 +1,81 @@
// React port of previewSchedule.ts + multiview.ts's preview controller
// (issue #251). Owns the throttled schedule, hands the layout store the
// throttled result, and exposes the three ways a preview ends for real —
// see docs/PREVIEW.md, the contract this mirrors.
import { useCallback, useEffect, useRef } from "react";
import { useStore } from "zustand";
import { createPreviewSchedule, type PreviewScheduleDeps } from "../previewSchedule.js";
import type { LayoutStore, LayoutStoreApi } from "../stores/layoutStore.js";
import type { Uuid } from "../types.js";
export interface UsePreviewOptions {
store: LayoutStoreApi;
/** Test seam for the schedule's clock/timers. */
scheduleDeps?: PreviewScheduleDeps;
}
export interface UsePreviewResult {
/** The session currently shown as a preview, or null. */
previewedSessionId: Uuid | null;
/** Throttled: show `sessionId` in the content pane, or `null` to put back
* whatever was on screen (a highlight move, or the highlight leaving
* every session). Never delayed on the leading edge. */
preview(sessionId: Uuid | null): void;
/** Pointer-hover entry point: touch has no hover, so a touch pointer never
* previews (docs/PREVIEW.md). */
previewFromPointer(sessionId: Uuid | null, pointerType: string): void;
/** Choosing the previewed session — click/Enter on its row, click into its
* pane, or handing it the keyboard (Tab). Keeps exactly what is on
* screen: a throwaway tab becomes real, a focus move inside an existing
* tab stands. Nothing moves a second time. */
commit(sessionId: Uuid): void;
/** Any other real change — opening the command bar, a dialog, navigating
* away — ends a pending or active preview immediately, bypassing the
* throttle. */
end(): void;
}
/** Preview state management (docs/PREVIEW.md): wraps the layout store's
* `previewSession`/`commitPreview` with the 120ms pane-swap throttle so the
* UI never has to schedule it by hand. */
export function usePreview({ store, scheduleDeps }: UsePreviewOptions): UsePreviewResult {
const previewedSessionId = useStore(store, (s: LayoutStore) => s.preview?.sessionId ?? null);
const scheduleRef = useRef<ReturnType<typeof createPreviewSchedule> | null>(null);
if (!scheduleRef.current) {
scheduleRef.current = createPreviewSchedule((id) => store.getState().previewSession(id), scheduleDeps);
}
useEffect(() => {
const schedule = scheduleRef.current;
return () => schedule?.cancel();
}, []);
const preview = useCallback((sessionId: Uuid | null) => {
scheduleRef.current?.(sessionId);
}, []);
const previewFromPointer = useCallback(
(sessionId: Uuid | null, pointerType: string) => {
if (pointerType === "touch") return;
preview(sessionId);
},
[preview],
);
const commit = useCallback(
(sessionId: Uuid) => {
scheduleRef.current?.cancel();
store.getState().openSession(sessionId);
},
[store],
);
const end = useCallback(() => {
scheduleRef.current?.cancel();
store.getState().previewSession(null);
}, [store]);
return { previewedSessionId, preview, previewFromPointer, commit, end };
}
+1 -1
View File
@@ -7,7 +7,7 @@ import { beforeAll, describe, expect, it } from "vitest";
import { OPEN_COMMAND_BAR_EVENT, renderSidebar } from "./catalog.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 { Session } from "./types.js";
+129
View File
@@ -159,4 +159,133 @@ describe("layoutStore", () => {
expect(save).toHaveBeenCalledTimes(1);
expect(save.mock.calls[0][0]).toBe(s.getState().layout);
});
describe("previewSession (docs/PREVIEW.md)", () => {
it("brings a session's own tab forward, then puts the layout back", () => {
const s = store();
s.getState().openSession("s1");
s.getState().addTab();
s.getState().openSession("s2");
const before = JSON.stringify(s.getState().layout);
s.getState().previewSession("s1");
const tab1 = s.getState().layout.tabs.find((t) => leaves(t.root).some((l) => l.sessionId === "s1"))!;
expect(s.getState().layout.activeTabId).toBe(tab1.id);
expect(s.getState().preview?.sessionId).toBe("s1");
s.getState().previewSession(null);
expect(s.getState().preview).toBeNull();
expect(JSON.stringify(s.getState().layout)).toBe(before);
});
it("shows a session no pane holds in a throwaway tab that leaves no trace", () => {
const s = store();
s.getState().openSession("s1");
const before = JSON.stringify(s.getState().layout);
s.getState().previewSession("s9");
expect(s.getState().layout.tabs).toHaveLength(2);
const tempTab = s.getState().layout.tabs[1];
expect(leaves(tempTab.root).map((l) => l.sessionId)).toEqual(["s9"]);
s.getState().previewSession(null);
expect(s.getState().layout.tabs).toHaveLength(1);
expect(JSON.stringify(s.getState().layout)).toBe(before);
});
it("stepping between previews leaves only the newest one on screen", () => {
const s = store();
s.getState().openSession("s1");
const before = JSON.stringify(s.getState().layout);
s.getState().previewSession("s9");
s.getState().previewSession("s8");
expect(s.getState().layout.tabs).toHaveLength(2);
expect(s.getState().preview?.sessionId).toBe("s8");
s.getState().previewSession(null);
expect(JSON.stringify(s.getState().layout)).toBe(before);
});
it("never persists while up, and saves again once settled back", () => {
const save = vi.fn();
const { scheduler, flush } = fakeScheduler();
const s = store({ save, scheduler });
s.getState().openSession("s1");
flush();
save.mockClear();
s.getState().previewSession("s9");
expect(save).not.toHaveBeenCalled();
s.getState().previewSession(null);
flush();
expect(save).toHaveBeenCalledTimes(1);
});
it("commitPreview keeps the throwaway tab as a real one", () => {
const s = store();
s.getState().openSession("s1");
s.getState().previewSession("s9");
expect(s.getState().commitPreview("s9")).toBe(true);
expect(s.getState().preview).toBeNull();
expect(s.getState().layout.tabs).toHaveLength(2);
expect(leaves(s.getState().layout.tabs[1].root).map((l) => l.sessionId)).toEqual(["s9"]);
});
it("commitPreview discards a non-matching preview and reports false", () => {
const s = store();
s.getState().openSession("s1");
s.getState().previewSession("s9");
expect(s.getState().commitPreview("s1")).toBe(false);
expect(s.getState().preview).toBeNull();
expect(s.getState().layout.tabs).toHaveLength(1);
});
it("openSession settles a matching preview instead of moving it a second time", () => {
const s = store();
s.getState().openSession("s1");
s.getState().previewSession("s9");
s.getState().openSession("s9");
expect(s.getState().preview).toBeNull();
expect(s.getState().layout.tabs).toHaveLength(2);
expect(leaves(s.getState().layout.tabs[0].root).map((l) => l.sessionId)).toEqual(["s1"]);
});
it("any real layout change ends a preview first: splitFocused", () => {
const s = store();
s.getState().openSession("s1");
s.getState().previewSession("s9");
const first = leaves(s.getState().layout.tabs[0].root)[0].id;
s.getState().splitFocused(first, "row");
expect(s.getState().preview).toBeNull();
expect(s.getState().layout.tabs).toHaveLength(1);
expect(leaves(s.getState().layout.tabs[0].root).map((l) => l.sessionId).sort()).toEqual([null, "s1"]);
});
it("any real layout change ends a preview first: addTab/selectTab/closeTab", () => {
const s = store();
s.getState().openSession("s1");
s.getState().previewSession("s9");
s.getState().addTab();
expect(s.getState().preview).toBeNull();
// The throwaway tab was discarded before the real tab was added.
expect(s.getState().layout.tabs).toHaveLength(2);
});
it("closeSession drops the preview before closing the real pane", () => {
const s = store();
s.getState().openSession("s1");
s.getState().previewSession("s9");
s.getState().closeSession("s1");
expect(s.getState().preview).toBeNull();
expect(s.getState().layout.tabs).toHaveLength(1);
expect(s.getState().layout.tabs[0].root).toBeNull();
});
});
});
+116 -1
View File
@@ -11,12 +11,14 @@ import {
emptyLayout,
equalizeRatios,
findLeaf,
freshId,
leaves,
locateSession,
removeLeaf,
splitLeaf,
type Dir,
type Layout,
type Tab,
} from "../layout.js";
import { loadLayout, saveLayout } from "../persistence.js";
import type { Uuid } from "../types.js";
@@ -35,13 +37,29 @@ export interface LayoutStoreOptions {
scheduler?: Scheduler;
}
/** A provisional preview (docs/PREVIEW.md): records exactly what
* `previewSession` moved, so ending it restores the layout byte-for-byte. */
export interface PreviewState {
sessionId: Uuid;
/** Tab that was active before the preview. */
prevActiveTabId: string;
/** Focused leaf to restore, when the preview stole focus in a real tab. */
prevFocus: { tabId: string; leafId: string | null } | null;
/** Throwaway tab built for a session that no pane was showing. */
tempTabId: string | null;
}
export interface LayoutState {
layout: Layout;
/** Session shown provisionally in the content pane, or null. A preview is
* a view, not state: it never reaches `save` (docs/PREVIEW.md). */
preview: PreviewState | null;
}
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. */
* focused pane (VS-Code-style replace), or seed an empty tab. Settles a
* matching preview in place rather than moving it a second time. */
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. */
@@ -66,6 +84,15 @@ export interface LayoutActions {
* 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;
/** Show `sessionId` in the content pane without committing to it
* (docs/PREVIEW.md): its own tab comes forward, or a throwaway tab
* carries it. `null` puts back whatever was on screen before. Never
* schedules a save while a preview is up. */
previewSession(sessionId: Uuid | null): void;
/** Keep what is being previewed: its throwaway tab becomes a real one, and
* the focus it moved stands. Any other pending preview is simply
* discarded. Returns whether `sessionId` was the one being previewed. */
commitPreview(sessionId: Uuid): boolean;
}
export type LayoutStore = LayoutState & LayoutActions;
@@ -107,10 +134,51 @@ export function createLayoutStore(opts: LayoutStoreOptions = {}) {
commit();
}
// ---- preview (docs/PREVIEW.md): a view, not a layout change — never
// schedules a save while it is up. ----
/** Undo the preview's moves. Mutates only — the caller re-renders /
* persists as it sees fit. Returns whether a preview was active. */
function restorePreview(): boolean {
const p = get().preview;
if (!p) return false;
set({ preview: null });
const layout = get().layout;
if (p.tempTabId) {
const i = layout.tabs.findIndex((t) => t.id === p.tempTabId);
if (i >= 0) layout.tabs.splice(i, 1);
} else if (p.prevFocus) {
const t = layout.tabs.find((x) => x.id === p.prevFocus!.tabId);
if (t) t.focusedLeafId = p.prevFocus.leafId;
}
layout.activeTabId = layout.tabs.some((t) => t.id === p.prevActiveTabId)
? p.prevActiveTabId
: layout.tabs[0].id;
return true;
}
function settlePreview(sessionId: Uuid): boolean {
const p = get().preview;
if (!p) return false;
if (p.sessionId !== sessionId) {
restorePreview();
return false;
}
set({ preview: null });
if (p.tempTabId) {
const layout = get().layout;
const t = layout.tabs.find((x) => x.id === p.tempTabId);
if (t) t.title = `Tab ${layout.tabs.length}`;
}
return true;
}
return {
layout: load() ?? emptyLayout(),
preview: null,
openSession(sessionId) {
if (get().commitPreview(sessionId)) return;
mutate((layout) => {
const loc = locateSession(layout, sessionId);
if (loc) {
@@ -130,6 +198,7 @@ export function createLayoutStore(opts: LayoutStoreOptions = {}) {
},
openSessionBeside(sessionId, placement) {
restorePreview();
mutate((layout) => {
const loc = locateSession(layout, sessionId);
if (loc) {
@@ -158,6 +227,7 @@ export function createLayoutStore(opts: LayoutStoreOptions = {}) {
},
closeSession(sessionId) {
restorePreview();
mutate((layout) => {
const loc = locateSession(layout, sessionId);
if (!loc) return;
@@ -170,6 +240,7 @@ export function createLayoutStore(opts: LayoutStoreOptions = {}) {
},
splitFocused(leafId, dir) {
restorePreview();
mutate((layout) => {
const tab = activeTab(layout);
tab.focusedLeafId = splitLeaf(tab, leafId, null, dir, true);
@@ -178,6 +249,7 @@ export function createLayoutStore(opts: LayoutStoreOptions = {}) {
},
closePane(leafId) {
restorePreview();
mutate((layout) => {
const tab = activeTab(layout);
removeLeaf(tab, leafId);
@@ -196,24 +268,28 @@ export function createLayoutStore(opts: LayoutStoreOptions = {}) {
},
selectTab(tabId) {
restorePreview();
mutate((layout) => {
if (layout.tabs.some((t) => t.id === tabId)) layout.activeTabId = tabId;
});
},
addTab() {
restorePreview();
mutate((layout) => {
addTabToLayout(layout);
});
},
closeTab(tabId) {
restorePreview();
mutate((layout) => {
closeTabInLayout(layout, tabId);
});
},
reorderTab(fromId, toId) {
restorePreview();
mutate((layout) => {
if (fromId === toId) return;
const from = layout.tabs.findIndex((t) => t.id === fromId);
@@ -248,6 +324,45 @@ export function createLayoutStore(opts: LayoutStoreOptions = {}) {
for (const id of emptied) closeTabInLayout(layout, id);
});
},
previewSession(sessionId) {
const current = get().preview;
if (current?.sessionId === sessionId) return;
if (sessionId === null) {
if (restorePreview()) commit();
return;
}
restorePreview();
const layout = get().layout;
const prevActiveTabId = layout.activeTabId;
const loc = locateSession(layout, sessionId);
let preview: PreviewState;
if (loc) {
preview = {
sessionId,
prevActiveTabId,
prevFocus: { tabId: loc.tab.id, leafId: loc.tab.focusedLeafId },
tempTabId: null,
};
layout.activeTabId = loc.tab.id;
loc.tab.focusedLeafId = loc.leaf.id;
} else {
// Not on screen anywhere: a throwaway tab carries it, so no
// existing pane is evicted and nothing the operator arranged moves.
const tab: Tab = { id: freshId("t"), title: "Preview", root: null, focusedLeafId: null };
tab.focusedLeafId = splitLeaf(tab, "", sessionId, "row", true);
layout.tabs.push(tab);
layout.activeTabId = tab.id;
preview = { sessionId, prevActiveTabId, prevFocus: null, tempTabId: tab.id };
}
set({ layout: { ...layout }, preview });
},
commitPreview(sessionId) {
const ok = settlePreview(sessionId);
if (ok) commit();
return ok;
},
};
});
}