Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1b7ec1f54c | ||
|
|
52ae636dbb | ||
|
|
9d93e6e60e | ||
|
|
babdc678e0 |
@@ -0,0 +1,96 @@
|
||||
// @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();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,203 @@
|
||||
// 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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
// @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();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,49 @@
|
||||
// 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} />
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,48 @@
|
||||
// @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();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,39 @@
|
||||
// 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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
// @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]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,91 @@
|
||||
// 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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
// @vitest-environment happy-dom
|
||||
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import type { BranchInfo } from "../../types.js";
|
||||
import { BranchPicker, type BranchPickerProps } from "./BranchPicker.js";
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
function props(over: Partial<BranchPickerProps> = {}): BranchPickerProps {
|
||||
return {
|
||||
enabled: true,
|
||||
onToggleEnabled: vi.fn(),
|
||||
hint: null,
|
||||
baseRepo: "/repo",
|
||||
mode: "new",
|
||||
onModeChange: vi.fn(),
|
||||
newBranch: "",
|
||||
onNewBranchChange: vi.fn(),
|
||||
branches: undefined,
|
||||
existingBranch: "",
|
||||
onExistingBranchChange: vi.fn(),
|
||||
...over,
|
||||
};
|
||||
}
|
||||
|
||||
describe("BranchPicker", () => {
|
||||
it("shows the hint instead of the body while unresolved", () => {
|
||||
render(<BranchPicker {...props({ hint: "Choose a working directory first." })} />);
|
||||
expect(screen.getByText("Choose a working directory first.")).not.toBeNull();
|
||||
expect(screen.queryByText("New branch name")).toBeNull();
|
||||
});
|
||||
|
||||
it("shows the new-branch field by default and the repo line once resolved", () => {
|
||||
render(<BranchPicker {...props()} />);
|
||||
expect(screen.getByText("repo: /repo")).not.toBeNull();
|
||||
expect(screen.getByPlaceholderText("feat/foo")).not.toBeNull();
|
||||
expect(screen.queryByText("Pick a branch")).toBeNull();
|
||||
});
|
||||
|
||||
it("switches to the existing-branch select and reports the mode change", () => {
|
||||
const onModeChange = vi.fn();
|
||||
render(<BranchPicker {...props({ onModeChange })} />);
|
||||
|
||||
fireEvent.click(screen.getByLabelText("Existing branch"));
|
||||
|
||||
expect(onModeChange).toHaveBeenCalledWith("existing");
|
||||
});
|
||||
|
||||
it("disables a branch already held by another worktree, and the main checkout's HEAD", () => {
|
||||
const branches: BranchInfo[] = [
|
||||
{ name: "main", is_head: true },
|
||||
{ name: "feat/a", is_head: false, worktree: "/repos/a" },
|
||||
{ name: "feat/b", is_head: false },
|
||||
];
|
||||
render(<BranchPicker {...props({ mode: "existing", branches })} />);
|
||||
|
||||
const main = screen.getByRole("option", { name: /main — held by the main checkout/ }) as HTMLOptionElement;
|
||||
const a = screen.getByRole("option", { name: /feat\/a — held by \/repos\/a/ }) as HTMLOptionElement;
|
||||
const b = screen.getByRole("option", { name: "feat/b" }) as HTMLOptionElement;
|
||||
expect(main.disabled).toBe(true);
|
||||
expect(a.disabled).toBe(true);
|
||||
expect(b.disabled).toBe(false);
|
||||
});
|
||||
|
||||
it("shows a placeholder option when no local branches were found", () => {
|
||||
render(<BranchPicker {...props({ mode: "existing", branches: [] })} />);
|
||||
expect(screen.getByText("No local branches found.")).not.toBeNull();
|
||||
});
|
||||
|
||||
it("toggles the worktree checkbox", () => {
|
||||
const onToggleEnabled = vi.fn();
|
||||
render(<BranchPicker {...props({ onToggleEnabled })} />);
|
||||
|
||||
fireEvent.click(screen.getByLabelText("Create git worktree"));
|
||||
|
||||
expect(onToggleEnabled).toHaveBeenCalledWith(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,119 @@
|
||||
// The spawn form's worktree section (#61): create-worktree toggle, then
|
||||
// (once a base repo is known) new-branch-vs-existing-branch, mirroring
|
||||
// spawnForm.ts's `.adv-group`/`.wt-body`/`.wt-mode` markup.
|
||||
|
||||
import type { BranchInfo } from "../../types.js";
|
||||
import type { WorktreeBranchMode } from "../../spawnForm.js";
|
||||
|
||||
export interface BranchPickerProps {
|
||||
enabled: boolean;
|
||||
onToggleEnabled(enabled: boolean): void;
|
||||
/** Status line shown in place of the body — "Checking…", "not a repo",
|
||||
* "choose a directory first". `null` once the body itself can render. */
|
||||
hint: string | null;
|
||||
/** The repo a worktree would branch from, once resolved. */
|
||||
baseRepo: string | null;
|
||||
mode: WorktreeBranchMode;
|
||||
onModeChange(mode: WorktreeBranchMode): void;
|
||||
newBranch: string;
|
||||
onNewBranchChange(value: string): void;
|
||||
/** This cwd's local branches, or `undefined` while unknown/not yet loaded. */
|
||||
branches: BranchInfo[] | undefined;
|
||||
existingBranch: string;
|
||||
onExistingBranchChange(value: string): void;
|
||||
}
|
||||
|
||||
export function BranchPicker(props: BranchPickerProps) {
|
||||
return (
|
||||
<div
|
||||
className={`flex flex-col gap-sp-3 border-l-2 pl-sp-3 ${
|
||||
props.enabled ? "border-accent" : "border-border"
|
||||
}`}
|
||||
>
|
||||
<label className="flex cursor-pointer items-center gap-sp-2 text-fs-14 text-ink">
|
||||
<input
|
||||
type="checkbox"
|
||||
data-role="toggle"
|
||||
className="size-[18px] accent-accent"
|
||||
checked={props.enabled}
|
||||
onChange={(e) => props.onToggleEnabled(e.currentTarget.checked)}
|
||||
/>
|
||||
Create git worktree
|
||||
</label>
|
||||
|
||||
{props.hint && <p className="m-0 text-fs-12 text-muted">{props.hint}</p>}
|
||||
|
||||
{props.enabled && !props.hint && (
|
||||
<div className="flex flex-col gap-sp-3">
|
||||
{props.baseRepo && (
|
||||
<p className="m-0 font-mono text-fs-12 text-muted">repo: {props.baseRepo}</p>
|
||||
)}
|
||||
<div role="radiogroup" aria-label="Branch source" className="flex gap-sp-4">
|
||||
<label className="flex cursor-pointer items-center gap-sp-2 text-fs-14 text-ink">
|
||||
<input
|
||||
type="radio"
|
||||
name="worktree_branch_mode"
|
||||
checked={props.mode === "new"}
|
||||
onChange={() => props.onModeChange("new")}
|
||||
/>
|
||||
New branch
|
||||
</label>
|
||||
<label className="flex cursor-pointer items-center gap-sp-2 text-fs-14 text-ink">
|
||||
<input
|
||||
type="radio"
|
||||
name="worktree_branch_mode"
|
||||
checked={props.mode === "existing"}
|
||||
onChange={() => props.onModeChange("existing")}
|
||||
/>
|
||||
Existing branch
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{props.mode === "new" ? (
|
||||
<label className="flex flex-col gap-1 text-fs-12 text-muted">
|
||||
New branch name
|
||||
<input
|
||||
name="worktree_branch"
|
||||
type="text"
|
||||
autoComplete="off"
|
||||
placeholder="feat/foo"
|
||||
className="w-full rounded-r-2 border border-border bg-surface-2 px-sp-3 py-2 font-mono text-fs-15 text-ink"
|
||||
value={props.newBranch}
|
||||
onChange={(e) => props.onNewBranchChange(e.currentTarget.value)}
|
||||
/>
|
||||
</label>
|
||||
) : (
|
||||
<label className="flex flex-col gap-1 text-fs-12 text-muted">
|
||||
Pick a branch
|
||||
<select
|
||||
className="w-full rounded-r-2 border border-border bg-surface-2 px-sp-3 py-2 font-mono text-fs-15 text-ink"
|
||||
value={props.existingBranch}
|
||||
onChange={(e) => props.onExistingBranchChange(e.currentTarget.value)}
|
||||
>
|
||||
{branchOptions(props.branches)}
|
||||
</select>
|
||||
</label>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function branchOptions(branches: BranchInfo[] | undefined) {
|
||||
if (!branches || branches.length === 0) {
|
||||
return <option value="">No local branches found.</option>;
|
||||
}
|
||||
return branches.map((b) => {
|
||||
// A branch already checked out anywhere stays visible but unpickable —
|
||||
// git refuses a second checkout. That includes the main checkout's own
|
||||
// HEAD, which is commonly `main` and would otherwise be the default pick,
|
||||
// making every existing-branch spawn fail (#54 review).
|
||||
const held = b.worktree ?? (b.is_head ? "the main checkout" : null);
|
||||
return (
|
||||
<option key={b.name} value={b.name} disabled={held !== null} title={held ?? ""}>
|
||||
{held ? `${b.name} — held by ${held}` : b.name}
|
||||
</option>
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
// @vitest-environment happy-dom
|
||||
import { act, cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import { useState } from "react";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { DirAutocomplete } from "./DirAutocomplete.js";
|
||||
|
||||
vi.mock("../../api.js", () => ({
|
||||
listDir: vi.fn(),
|
||||
}));
|
||||
|
||||
import { listDir } from "../../api.js";
|
||||
|
||||
const mockListDir = vi.mocked(listDir);
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
function Harness({ initial = "" }: { initial?: string }) {
|
||||
const [value, setValue] = useState(initial);
|
||||
return <DirAutocomplete value={value} onChange={setValue} placeholder="cwd" />;
|
||||
}
|
||||
|
||||
// `fireEvent.focus`/`.blur` dispatch the event without moving the browser's
|
||||
// real focus, so `document.activeElement` (which the dropdown's fetch guards
|
||||
// against) never changes — calling the DOM method directly does both.
|
||||
function focus(el: HTMLElement): void {
|
||||
act(() => el.focus());
|
||||
}
|
||||
function blur(el: HTMLElement): void {
|
||||
act(() => el.blur());
|
||||
}
|
||||
|
||||
describe("DirAutocomplete", () => {
|
||||
it("lists entries matching the trailing segment on focus", async () => {
|
||||
mockListDir.mockResolvedValue({ path: "/home/n", entries: ["alpha", "beta"] });
|
||||
render(<Harness initial="/home/n/al" />);
|
||||
|
||||
focus(screen.getByPlaceholderText("cwd"));
|
||||
|
||||
expect(await screen.findByRole("option", { name: "alpha/" })).not.toBeNull();
|
||||
expect(screen.queryByRole("option", { name: "beta/" })).toBeNull();
|
||||
});
|
||||
|
||||
it("Tab commits the highlighted entry and does not close the field", async () => {
|
||||
mockListDir.mockResolvedValue({ path: "/home/n", entries: ["alpha"] });
|
||||
render(<Harness initial="/home/n/a" />);
|
||||
const input = screen.getByPlaceholderText("cwd") as HTMLInputElement;
|
||||
focus(input);
|
||||
await screen.findByRole("option", { name: "alpha/" });
|
||||
|
||||
fireEvent.keyDown(input, { key: "Tab" });
|
||||
|
||||
expect(input.value).toBe("/home/n/alpha/");
|
||||
});
|
||||
|
||||
it("arrows move the highlight and Enter accepts it without submitting", async () => {
|
||||
mockListDir.mockResolvedValue({ path: "/home/n", entries: ["alpha", "beta"] });
|
||||
render(<Harness initial="/home/n/" />);
|
||||
const input = screen.getByPlaceholderText("cwd") as HTMLInputElement;
|
||||
focus(input);
|
||||
await screen.findByRole("option", { name: "alpha/" });
|
||||
|
||||
fireEvent.keyDown(input, { key: "ArrowDown" });
|
||||
const notPrevented = fireEvent.keyDown(input, { key: "Enter" });
|
||||
|
||||
expect(input.value).toBe("/home/n/beta/");
|
||||
expect(notPrevented).toBe(false); // preventDefault called -> event "processed"
|
||||
});
|
||||
|
||||
it("Escape closes the dropdown and stops the key from bubbling", async () => {
|
||||
mockListDir.mockResolvedValue({ path: "/home/n", entries: ["alpha"] });
|
||||
const onKeyDown = vi.fn();
|
||||
render(
|
||||
<div onKeyDown={onKeyDown}>
|
||||
<Harness initial="/home/n/a" />
|
||||
</div>,
|
||||
);
|
||||
const input = screen.getByPlaceholderText("cwd");
|
||||
focus(input);
|
||||
await screen.findByRole("option", { name: "alpha/" });
|
||||
|
||||
fireEvent.keyDown(input, { key: "Escape" });
|
||||
|
||||
await waitFor(() => expect(screen.queryByRole("option")).toBeNull());
|
||||
expect(onKeyDown).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not reopen on refocus until the operator types again", async () => {
|
||||
mockListDir.mockResolvedValue({ path: "/home/n", entries: ["alpha"] });
|
||||
render(<Harness initial="/home/n/a" />);
|
||||
const input = screen.getByPlaceholderText("cwd");
|
||||
focus(input);
|
||||
await screen.findByRole("option", { name: "alpha/" });
|
||||
fireEvent.keyDown(input, { key: "Escape" });
|
||||
await waitFor(() => expect(screen.queryByRole("option")).toBeNull());
|
||||
|
||||
blur(input);
|
||||
focus(input);
|
||||
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
expect(screen.queryByRole("option")).toBeNull();
|
||||
|
||||
fireEvent.change(input, { target: { value: "/home/n/al" } });
|
||||
expect(await screen.findByRole("option", { name: "alpha/" })).not.toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,256 @@
|
||||
// React port of ../../dirAutocomplete.ts: shell-style directory autocomplete
|
||||
// on a path <input>. Lists one directory at a time over `GET /api/dirs`,
|
||||
// caches per directory, and filters the trailing path segment locally.
|
||||
//
|
||||
// Keyboard model mirrors `cd dir/sub<tab>`:
|
||||
// Tab commit the highlighted entry and let focus move on; with nothing
|
||||
// highlighted, plain tab navigation
|
||||
// ↑/↓ move the highlight
|
||||
// Enter accept the highlighted entry (and suppress form submit); with
|
||||
// nothing highlighted, bubble to the form unprevented
|
||||
// Esc close the dropdown, refocus the input, and stop the key from
|
||||
// bubbling — so dismissing the popup can't also close a parent modal
|
||||
//
|
||||
// Once dismissed (Esc or blur) the popup stays closed until the operator
|
||||
// types again — refocusing alone doesn't reopen it.
|
||||
|
||||
import { useCallback, useEffect, useRef, useState, type InputHTMLAttributes, type Ref } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
|
||||
import { listDir } from "../../api.js";
|
||||
import { splitPath } from "../../paths.js";
|
||||
|
||||
/** Most entries rendered at once — a long list is unhelpful and slow. */
|
||||
const MAX_VISIBLE = 60;
|
||||
/** Debounce before a network fetch when the input crosses into a new directory. */
|
||||
const FETCH_DEBOUNCE_MS = 90;
|
||||
|
||||
export interface DirAutocompleteProps
|
||||
extends Omit<InputHTMLAttributes<HTMLInputElement>, "value" | "onChange"> {
|
||||
value: string;
|
||||
onChange(value: string): void;
|
||||
/** Access to the underlying `<input>` — e.g. to focus/select it. */
|
||||
inputRef?: Ref<HTMLInputElement>;
|
||||
}
|
||||
|
||||
function assignRef(ref: Ref<HTMLInputElement> | undefined, el: HTMLInputElement | null): void {
|
||||
if (!ref) return;
|
||||
if (typeof ref === "function") ref(el);
|
||||
else (ref as { current: HTMLInputElement | null }).current = el;
|
||||
}
|
||||
|
||||
export function DirAutocomplete({ value, onChange, inputRef, ...inputProps }: DirAutocompleteProps) {
|
||||
const localRef = useRef<HTMLInputElement | null>(null);
|
||||
const cache = useRef(new Map<string, string[]>());
|
||||
const fetchSeq = useRef(0);
|
||||
const debounceTimer = useRef<number | undefined>(undefined);
|
||||
const dismissedRef = useRef(false);
|
||||
|
||||
const [filtered, setFiltered] = useState<string[]>([]);
|
||||
const [highlight, setHighlight] = useState(-1);
|
||||
const [open, setOpen] = useState(false);
|
||||
const [rect, setRect] = useState<{ left: number; top: number; width: number } | null>(null);
|
||||
|
||||
const setInputRef = useCallback(
|
||||
(el: HTMLInputElement | null) => {
|
||||
localRef.current = el;
|
||||
assignRef(inputRef, el);
|
||||
},
|
||||
[inputRef],
|
||||
);
|
||||
|
||||
const reposition = useCallback(() => {
|
||||
const el = localRef.current;
|
||||
if (!el) return;
|
||||
const r = el.getBoundingClientRect();
|
||||
setRect({ left: r.left, top: r.bottom + 4, width: r.width });
|
||||
}, []);
|
||||
|
||||
const close = useCallback(() => {
|
||||
setOpen(false);
|
||||
setFiltered([]);
|
||||
setHighlight(-1);
|
||||
}, []);
|
||||
|
||||
const dismiss = useCallback(() => {
|
||||
dismissedRef.current = true;
|
||||
close();
|
||||
}, [close]);
|
||||
|
||||
const applyFilter = useCallback(
|
||||
(entries: string[], prefix: string) => {
|
||||
// A fetch can outlive the dismissal it was meant to serve (Tab-commit
|
||||
// re-arms a refresh, then blur dismisses 120ms later). Showing it now
|
||||
// would anchor a dropdown under an unfocused input nothing closes.
|
||||
if (dismissedRef.current || document.activeElement !== localRef.current) {
|
||||
close();
|
||||
return;
|
||||
}
|
||||
const lower = prefix.toLowerCase();
|
||||
const next = entries.filter((e) => e.toLowerCase().startsWith(lower));
|
||||
setFiltered(next);
|
||||
setHighlight(next.length > 0 ? 0 : -1);
|
||||
if (next.length === 0) {
|
||||
close();
|
||||
return;
|
||||
}
|
||||
setOpen(true);
|
||||
reposition();
|
||||
},
|
||||
[close, reposition],
|
||||
);
|
||||
|
||||
const refresh = useCallback(
|
||||
(raw: string) => {
|
||||
const { dir, prefix } = splitPath(raw);
|
||||
const cached = cache.current.get(dir);
|
||||
if (cached) {
|
||||
applyFilter(cached, prefix);
|
||||
return;
|
||||
}
|
||||
const seq = ++fetchSeq.current;
|
||||
listDir(dir)
|
||||
.then((listing) => {
|
||||
cache.current.set(dir, listing.entries);
|
||||
if (seq !== fetchSeq.current) return; // a newer fetch superseded this one
|
||||
const cur = splitPath(localRef.current?.value ?? raw);
|
||||
if (cur.dir === dir) applyFilter(listing.entries, cur.prefix);
|
||||
})
|
||||
.catch(() => {
|
||||
if (seq === fetchSeq.current) close();
|
||||
});
|
||||
},
|
||||
[applyFilter, close],
|
||||
);
|
||||
|
||||
const scheduleRefresh = useCallback(
|
||||
(raw: string) => {
|
||||
window.clearTimeout(debounceTimer.current);
|
||||
debounceTimer.current = window.setTimeout(() => refresh(raw), FETCH_DEBOUNCE_MS);
|
||||
},
|
||||
[refresh],
|
||||
);
|
||||
|
||||
/** Complete `name` as the next path segment and append `/` so the child
|
||||
* directory's listing loads on the next interaction — the drill-in
|
||||
* gesture. Unlike `accept`, doesn't steal focus: Tab uses this to commit
|
||||
* and move to the next field in one gesture. */
|
||||
const commit = useCallback(
|
||||
(name: string) => {
|
||||
const { dir } = splitPath(value);
|
||||
onChange(`${dir}${name}/`);
|
||||
},
|
||||
[value, onChange],
|
||||
);
|
||||
|
||||
const accept = useCallback(
|
||||
(name: string) => {
|
||||
commit(name);
|
||||
localRef.current?.focus();
|
||||
},
|
||||
[commit],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
window.addEventListener("scroll", reposition, true);
|
||||
window.addEventListener("resize", reposition);
|
||||
return () => {
|
||||
window.removeEventListener("scroll", reposition, true);
|
||||
window.removeEventListener("resize", reposition);
|
||||
};
|
||||
}, [open, reposition]);
|
||||
|
||||
useEffect(() => () => window.clearTimeout(debounceTimer.current), []);
|
||||
|
||||
return (
|
||||
<>
|
||||
<input
|
||||
{...inputProps}
|
||||
ref={setInputRef}
|
||||
value={value}
|
||||
autoComplete="off"
|
||||
role="combobox"
|
||||
aria-expanded={open}
|
||||
aria-autocomplete="list"
|
||||
onChange={(e) => {
|
||||
dismissedRef.current = false;
|
||||
const next = e.currentTarget.value;
|
||||
onChange(next);
|
||||
scheduleRefresh(next);
|
||||
}}
|
||||
onFocus={(e) => {
|
||||
inputProps.onFocus?.(e);
|
||||
if (dismissedRef.current) return;
|
||||
refresh(value);
|
||||
}}
|
||||
onBlur={(e) => {
|
||||
inputProps.onBlur?.(e);
|
||||
// Delay so a dropdown mousedown can complete first.
|
||||
window.setTimeout(dismiss, 120);
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
inputProps.onKeyDown?.(e);
|
||||
if (e.defaultPrevented) return;
|
||||
if (e.key === "Escape") {
|
||||
if (open) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
dismiss();
|
||||
localRef.current?.focus();
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (e.key === "Tab") {
|
||||
if (!open || highlight < 0 || highlight >= filtered.length) return; // plain tab navigation
|
||||
commit(filtered[highlight]); // no preventDefault — focus moves to the next field
|
||||
return;
|
||||
}
|
||||
if (!open) return;
|
||||
if (e.key === "ArrowDown") {
|
||||
e.preventDefault();
|
||||
setHighlight((h) => Math.min(h + 1, filtered.length - 1));
|
||||
} else if (e.key === "ArrowUp") {
|
||||
e.preventDefault();
|
||||
setHighlight((h) => Math.max(h - 1, 0));
|
||||
} else if (e.key === "Enter" && highlight >= 0 && highlight < filtered.length) {
|
||||
e.preventDefault(); // accept the entry instead of submitting the form
|
||||
accept(filtered[highlight]);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{open &&
|
||||
rect &&
|
||||
createPortal(
|
||||
<ul
|
||||
role="listbox"
|
||||
className="dir-ac fixed z-modal max-h-[240px] overflow-y-auto rounded-r-2 border border-border-strong bg-surface py-sp-1 font-mono shadow-2"
|
||||
style={{ left: rect.left, top: rect.top, width: rect.width }}
|
||||
>
|
||||
{filtered.slice(0, MAX_VISIBLE).map((name, i) => (
|
||||
<li
|
||||
key={name}
|
||||
role="option"
|
||||
aria-selected={i === highlight}
|
||||
className={`cursor-pointer truncate px-sp-3 py-1 text-fs-12 text-ink ${
|
||||
i === highlight ? "bg-accent-tint" : ""
|
||||
}`}
|
||||
onMouseDown={(e) => {
|
||||
e.preventDefault(); // accept before the input's blur fires
|
||||
accept(name);
|
||||
}}
|
||||
>
|
||||
{name}/
|
||||
</li>
|
||||
))}
|
||||
{filtered.length > MAX_VISIBLE && (
|
||||
<li className="px-sp-3 py-1 text-fs-11 text-faint">
|
||||
+{filtered.length - MAX_VISIBLE} more — keep typing to narrow
|
||||
</li>
|
||||
)}
|
||||
</ul>,
|
||||
document.body,
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,383 @@
|
||||
// @vitest-environment happy-dom
|
||||
import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import type { BranchInfo, DaemonInfo, Project, Session, SpawnPreset } from "../../types.js";
|
||||
import { SpawnForm } from "./SpawnForm.js";
|
||||
|
||||
vi.mock("../../api.js", () => ({
|
||||
createSession: vi.fn(),
|
||||
fetchBranches: vi.fn(),
|
||||
fetchNames: vi.fn(),
|
||||
getDaemonInfo: vi.fn(),
|
||||
gitRoot: vi.fn(),
|
||||
listDir: vi.fn(),
|
||||
projects: vi.fn(),
|
||||
}));
|
||||
|
||||
import {
|
||||
createSession,
|
||||
fetchBranches,
|
||||
fetchNames,
|
||||
getDaemonInfo,
|
||||
gitRoot,
|
||||
listDir,
|
||||
projects as fetchProjects,
|
||||
} from "../../api.js";
|
||||
|
||||
const mockCreateSession = vi.mocked(createSession);
|
||||
const mockFetchBranches = vi.mocked(fetchBranches);
|
||||
const mockFetchNames = vi.mocked(fetchNames);
|
||||
const mockGetDaemonInfo = vi.mocked(getDaemonInfo);
|
||||
const mockGitRoot = vi.mocked(gitRoot);
|
||||
const mockListDir = vi.mocked(listDir);
|
||||
const mockFetchProjects = vi.mocked(fetchProjects);
|
||||
|
||||
const presets: SpawnPreset[] = [
|
||||
{ id: "claude", label: "Claude", command: "claude" },
|
||||
{ id: "opencode", label: "opencode", command: "opencode" },
|
||||
{ id: "zsh", label: "Shell", command: "zsh" },
|
||||
{ id: "custom", label: "Custom…", command: "" },
|
||||
];
|
||||
|
||||
function info(over: Partial<DaemonInfo> = {}): DaemonInfo {
|
||||
return { hostname: "h", version: "1", home_dir: "/home/n", presets, ...over };
|
||||
}
|
||||
|
||||
function project(over: Partial<Project> = {}): Project {
|
||||
return { path: "/repos/p", name: "p", last_activity: new Date().toISOString(), ...over };
|
||||
}
|
||||
|
||||
function session(over: Partial<Session> = {}): Session {
|
||||
return {
|
||||
id: "s1",
|
||||
cwd: "/repos/p",
|
||||
command: "claude",
|
||||
env: {},
|
||||
metadata: {},
|
||||
status: "Running",
|
||||
created_at: "2026-01-01T00:00:00Z",
|
||||
...over,
|
||||
};
|
||||
}
|
||||
|
||||
/** Defaults every test starts from: one known project, no branches, a fresh
|
||||
* generated handle. Individual tests override what they care about. */
|
||||
function stub(over: { projects?: Project[]; info?: DaemonInfo; names?: string[]; branches?: BranchInfo[] } = {}) {
|
||||
mockGetDaemonInfo.mockResolvedValue(over.info ?? info());
|
||||
mockFetchProjects.mockResolvedValue(over.projects ?? [project()]);
|
||||
mockFetchNames.mockResolvedValue({ names: over.names ?? ["amber-fox"] });
|
||||
mockFetchBranches.mockResolvedValue(over.branches ?? []);
|
||||
mockGitRoot.mockResolvedValue({});
|
||||
mockListDir.mockResolvedValue({ path: "", entries: [] });
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
async function settle() {
|
||||
await waitFor(() => expect(screen.queryByText("Loading projects…")).toBeNull());
|
||||
}
|
||||
|
||||
/** Everything worktree/name/structured-related lives behind the "Advanced"
|
||||
* disclosure, collapsed by default (#93) — mirrors the vanilla form exactly. */
|
||||
function openAdvanced() {
|
||||
fireEvent.click(screen.getByRole("button", { name: /Advanced/ }));
|
||||
}
|
||||
|
||||
describe("SpawnForm harness picker", () => {
|
||||
it("selects the first non-custom preset by default and reveals the custom input on Custom…", async () => {
|
||||
stub();
|
||||
render(<SpawnForm onSuccess={vi.fn()} />);
|
||||
await settle();
|
||||
|
||||
expect(screen.getByRole("button", { name: /^Claude/ }).className).toContain("border-accent");
|
||||
fireEvent.click(screen.getByRole("button", { name: "Custom…" }));
|
||||
|
||||
expect(screen.getByPlaceholderText("bash -l").hidden).toBe(false);
|
||||
});
|
||||
|
||||
it("resolves a seeded command to its matching preset", async () => {
|
||||
stub();
|
||||
render(<SpawnForm seed={{ command: "opencode" }} onSuccess={vi.fn()} />);
|
||||
await settle();
|
||||
|
||||
const opencode = screen.getByRole("button", { name: /opencode/ });
|
||||
expect(opencode.className).toContain("border-accent");
|
||||
});
|
||||
|
||||
it("drops an unmatched seeded command into the custom field", async () => {
|
||||
stub();
|
||||
render(<SpawnForm seed={{ command: "hx src/" }} onSuccess={vi.fn()} />);
|
||||
await settle();
|
||||
|
||||
const raw = screen.getByPlaceholderText("bash -l") as HTMLInputElement;
|
||||
expect(raw.hidden).toBe(false);
|
||||
expect(raw.value).toBe("hx src/");
|
||||
});
|
||||
});
|
||||
|
||||
describe("SpawnForm project picker", () => {
|
||||
it("lists discovered projects and reveals the worktree step on pick", async () => {
|
||||
stub({ projects: [project({ path: "/repos/a", name: "a" }), project({ path: "/repos/b", name: "b" })] });
|
||||
render(<SpawnForm onSuccess={vi.fn()} />);
|
||||
await settle();
|
||||
openAdvanced();
|
||||
|
||||
expect(screen.queryByText("Worktree")).toBeNull();
|
||||
fireEvent.click(screen.getByText("a"));
|
||||
|
||||
expect(screen.getByText("Worktree")).not.toBeNull();
|
||||
expect(screen.getByText("+ new").closest("button")?.getAttribute("data-active")).toBe("true");
|
||||
});
|
||||
|
||||
it("filters the project grid by name", async () => {
|
||||
stub({ projects: [project({ path: "/repos/alpha", name: "alpha" }), project({ path: "/repos/beta", name: "beta" })] });
|
||||
render(<SpawnForm onSuccess={vi.fn()} />);
|
||||
await settle();
|
||||
|
||||
fireEvent.change(screen.getByPlaceholderText("Filter projects…"), { target: { value: "alp" } });
|
||||
|
||||
expect(screen.getByText("alpha")).not.toBeNull();
|
||||
expect(screen.queryByText("beta")).toBeNull();
|
||||
});
|
||||
|
||||
it("falls back to the raw directory input when no projects are discovered", async () => {
|
||||
stub({ projects: [] });
|
||||
render(<SpawnForm onSuccess={vi.fn()} />);
|
||||
await settle();
|
||||
|
||||
expect((screen.getByPlaceholderText("/home/user/project") as HTMLInputElement).value).toBe("/home/n");
|
||||
});
|
||||
|
||||
it("Other directory… clears the picker and opens the raw input", async () => {
|
||||
stub({ projects: [project({ path: "/repos/a", name: "a" })] });
|
||||
render(<SpawnForm onSuccess={vi.fn()} />);
|
||||
await settle();
|
||||
fireEvent.click(screen.getByText("a"));
|
||||
expect(screen.getByText("a").closest("button")?.getAttribute("data-active")).toBe("true");
|
||||
|
||||
fireEvent.click(screen.getByText("Other directory…"));
|
||||
|
||||
expect(screen.getByText("a").closest("button")?.getAttribute("data-active")).toBeNull();
|
||||
expect((screen.getByPlaceholderText("/home/user/project") as HTMLInputElement).value).toBe("/repos/a");
|
||||
});
|
||||
});
|
||||
|
||||
describe("SpawnForm worktree defaults", () => {
|
||||
it("defaults the toggle on for a picked project and infers the repo without a gitroot probe", async () => {
|
||||
stub({ projects: [project({ path: "/repos/a", name: "a" })] });
|
||||
render(<SpawnForm onSuccess={vi.fn()} />);
|
||||
await settle();
|
||||
openAdvanced();
|
||||
|
||||
fireEvent.click(screen.getByText("a"));
|
||||
|
||||
expect(screen.getByText("repo: /repos/a")).not.toBeNull();
|
||||
expect(mockGitRoot).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("defaults the toggle off for a project that is itself a worktree (#91)", async () => {
|
||||
stub({
|
||||
projects: [
|
||||
project({ path: "/repos/main", name: "main" }),
|
||||
project({ path: "/repos/wt", name: "wt", is_worktree: true, base_repo: "/repos/main" }),
|
||||
],
|
||||
});
|
||||
render(<SpawnForm onSuccess={vi.fn()} />);
|
||||
await settle();
|
||||
openAdvanced();
|
||||
|
||||
fireEvent.click(screen.getByText("main"));
|
||||
fireEvent.click(screen.getByText("wt"));
|
||||
|
||||
expect((screen.getByLabelText("Create git worktree") as HTMLInputElement).checked).toBe(false);
|
||||
});
|
||||
|
||||
it("still probes gitroot for a free-text directory", async () => {
|
||||
stub();
|
||||
mockGitRoot.mockResolvedValue({ root: "/repos/found" });
|
||||
render(<SpawnForm onSuccess={vi.fn()} />);
|
||||
await settle();
|
||||
openAdvanced();
|
||||
fireEvent.click(screen.getByText("Other directory…"));
|
||||
|
||||
fireEvent.change(screen.getByPlaceholderText("/home/user/project"), {
|
||||
target: { value: "/repos/found/sub" },
|
||||
});
|
||||
|
||||
await waitFor(() => expect(mockGitRoot).toHaveBeenCalledWith("/repos/found/sub"));
|
||||
expect(await screen.findByText("repo: /repos/found")).not.toBeNull();
|
||||
});
|
||||
|
||||
it("populates existing branches, disabling the one held by another worktree", async () => {
|
||||
stub({
|
||||
projects: [project({ path: "/repos/a", name: "a" })],
|
||||
branches: [
|
||||
{ name: "main", is_head: true },
|
||||
{ name: "feat/x", is_head: false, worktree: "/repos/other" },
|
||||
{ name: "feat/y", is_head: false },
|
||||
],
|
||||
});
|
||||
render(<SpawnForm onSuccess={vi.fn()} />);
|
||||
await settle();
|
||||
openAdvanced();
|
||||
fireEvent.click(screen.getByText("a"));
|
||||
|
||||
fireEvent.click(screen.getByLabelText("Existing branch"));
|
||||
|
||||
const y = await screen.findByRole("option", { name: "feat/y" });
|
||||
expect((y as HTMLOptionElement).disabled).toBe(false);
|
||||
expect((screen.getByRole("option", { name: /main — held by the main checkout/ }) as HTMLOptionElement).disabled).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it("submits the first pickable branch when the operator never opens the select", async () => {
|
||||
stub({
|
||||
projects: [project({ path: "/repos/a", name: "a" })],
|
||||
branches: [
|
||||
{ name: "main", is_head: true },
|
||||
{ name: "feat/y", is_head: false },
|
||||
],
|
||||
});
|
||||
mockCreateSession.mockResolvedValue(session());
|
||||
const onSuccess = vi.fn();
|
||||
render(<SpawnForm onSuccess={onSuccess} />);
|
||||
await settle();
|
||||
openAdvanced();
|
||||
fireEvent.click(screen.getByText("a"));
|
||||
fireEvent.click(screen.getByLabelText("Existing branch"));
|
||||
await screen.findByRole("option", { name: "feat/y" });
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Spawn" }));
|
||||
|
||||
await waitFor(() => expect(onSuccess).toHaveBeenCalled());
|
||||
expect(mockCreateSession).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ worktree: expect.objectContaining({ branch: "feat/y", create_branch: false }) }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("SpawnForm structured view", () => {
|
||||
it("defaults on for a resolved Claude Code command and off for an unsupported one", async () => {
|
||||
stub();
|
||||
render(<SpawnForm onSuccess={vi.fn()} />);
|
||||
await settle();
|
||||
openAdvanced();
|
||||
|
||||
expect((screen.getByLabelText("Structured view") as HTMLInputElement).checked).toBe(true);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: /^Shell/ }));
|
||||
|
||||
expect(screen.queryByLabelText("Structured view")).toBeNull();
|
||||
});
|
||||
|
||||
it("keeps an unchecked box unchecked across a command change between two supported tools", async () => {
|
||||
stub();
|
||||
render(<SpawnForm onSuccess={vi.fn()} />);
|
||||
await settle();
|
||||
openAdvanced();
|
||||
fireEvent.click(screen.getByLabelText("Structured view"));
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: /opencode/ }));
|
||||
fireEvent.click(screen.getByRole("button", { name: /^Claude/ }));
|
||||
|
||||
expect((screen.getByLabelText("Structured view") as HTMLInputElement).checked).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("SpawnForm submit", () => {
|
||||
it("builds and posts the request, reporting the created session", async () => {
|
||||
stub({ projects: [project({ path: "/repos/a", name: "a" })] });
|
||||
const created = session();
|
||||
mockCreateSession.mockResolvedValue(created);
|
||||
const onSuccess = vi.fn();
|
||||
render(<SpawnForm onSuccess={onSuccess} />);
|
||||
await settle();
|
||||
fireEvent.click(screen.getByText("a"));
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Spawn" }));
|
||||
|
||||
await waitFor(() => expect(onSuccess).toHaveBeenCalledWith(created));
|
||||
expect(mockCreateSession).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ cwd: "/repos/a", command: "claude" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("shows the daemon's rejection and leaves the form open", async () => {
|
||||
stub({ projects: [project({ path: "/repos/a", name: "a" })] });
|
||||
mockCreateSession.mockRejectedValue(new Error("cwd is not a directory"));
|
||||
render(<SpawnForm onSuccess={vi.fn()} />);
|
||||
await settle();
|
||||
fireEvent.click(screen.getByText("a"));
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Spawn" }));
|
||||
|
||||
expect(await screen.findByText("cwd is not a directory")).not.toBeNull();
|
||||
});
|
||||
|
||||
it("rejects an existing-branch worktree with nothing picked", async () => {
|
||||
stub({ projects: [project({ path: "/repos/a", name: "a" })], branches: [] });
|
||||
render(<SpawnForm onSuccess={vi.fn()} />);
|
||||
await settle();
|
||||
openAdvanced();
|
||||
fireEvent.click(screen.getByText("a"));
|
||||
fireEvent.click(screen.getByLabelText("Existing branch"));
|
||||
await screen.findByText("No local branches found.");
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Spawn" }));
|
||||
|
||||
expect(await screen.findByText("Pick an existing branch.")).not.toBeNull();
|
||||
expect(mockCreateSession).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("SpawnForm Enter-to-submit (#97)", () => {
|
||||
it("submits from the session name field", async () => {
|
||||
stub({ projects: [project({ path: "/repos/a", name: "a" })] });
|
||||
mockCreateSession.mockResolvedValue(session());
|
||||
const onSuccess = vi.fn();
|
||||
render(<SpawnForm onSuccess={onSuccess} />);
|
||||
await settle();
|
||||
fireEvent.click(screen.getByText("a"));
|
||||
openAdvanced();
|
||||
|
||||
fireEvent.keyDown(screen.getByPlaceholderText("named after your first prompt"), { key: "Enter" });
|
||||
|
||||
await waitFor(() => expect(onSuccess).toHaveBeenCalled());
|
||||
});
|
||||
|
||||
it("first Enter picks a not-yet-active project box, the second submits", async () => {
|
||||
stub({ projects: [project({ path: "/repos/a", name: "a" }), project({ path: "/repos/b", name: "b" })] });
|
||||
mockCreateSession.mockResolvedValue(session());
|
||||
const onSuccess = vi.fn();
|
||||
render(<SpawnForm onSuccess={onSuccess} />);
|
||||
await settle();
|
||||
|
||||
const boxB = screen.getByText("b").closest("button")!;
|
||||
fireEvent.keyDown(boxB, { key: "Enter" });
|
||||
expect(onSuccess).not.toHaveBeenCalled();
|
||||
expect(boxB.getAttribute("data-active")).toBe("true");
|
||||
|
||||
fireEvent.keyDown(boxB, { key: "Enter" });
|
||||
await waitFor(() => expect(onSuccess).toHaveBeenCalled());
|
||||
});
|
||||
|
||||
it("toggles the worktree checkbox on Enter instead of submitting", async () => {
|
||||
stub({ projects: [project({ path: "/repos/a", name: "a" })] });
|
||||
render(<SpawnForm onSuccess={vi.fn()} />);
|
||||
await settle();
|
||||
openAdvanced();
|
||||
fireEvent.click(screen.getByText("a"));
|
||||
const checkbox = screen.getByLabelText("Create git worktree") as HTMLInputElement;
|
||||
expect(checkbox.checked).toBe(true);
|
||||
|
||||
fireEvent.keyDown(checkbox, { key: "Enter" });
|
||||
|
||||
expect(checkbox.checked).toBe(false);
|
||||
expect(mockCreateSession).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,853 @@
|
||||
// React port of ../../spawnForm.ts: the spawn form itself — preset tiles,
|
||||
// two-step project picker, name, structured-view checkbox, worktree. Mounted
|
||||
// inside SpawnModal.
|
||||
//
|
||||
// Working-directory picker (#95), unchanged from the vanilla form:
|
||||
// 1. Step one is one box per repository (name, then its parent directory).
|
||||
// A worktree is a box only when its main checkout is missing from the
|
||||
// scan.
|
||||
// 2. The filter keeps a repository when the query matches it or any of its
|
||||
// worktrees.
|
||||
// 3. Picking a box reveals step two: "+ new", then that repository's
|
||||
// worktrees newest-first. "Other directory…" clears both steps.
|
||||
// 4. "+ new" is the default selection — except on a box that is itself a
|
||||
// linked worktree, which defaults to that worktree.
|
||||
// 5. "+ new" means create: cwd is the repository, create-worktree on. An
|
||||
// existing worktree means run in it: cwd is that worktree, create-worktree
|
||||
// off (the daemon never adopts, docs/WORKTREES.md).
|
||||
// 6. Neither step probes /api/gitroot; only the free-text hatch does.
|
||||
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
|
||||
import {
|
||||
createSession,
|
||||
fetchBranches,
|
||||
fetchNames,
|
||||
getDaemonInfo,
|
||||
gitRoot,
|
||||
projects as fetchProjects,
|
||||
} from "../../api.js";
|
||||
import { parentDir } from "../../paths.js";
|
||||
import {
|
||||
filterProjects,
|
||||
groupProjects,
|
||||
isStale,
|
||||
sortProjects,
|
||||
type ProjectGroup,
|
||||
} from "../../projectList.js";
|
||||
import {
|
||||
buildSpawnRequest,
|
||||
FALLBACK_PRESETS,
|
||||
supportsStructuredView,
|
||||
type SpawnFormSeed,
|
||||
type SpawnFormValues,
|
||||
type WorktreeBranchMode,
|
||||
} from "../../spawnForm.js";
|
||||
import { splitCommand, toolIconSvg, TOOL_LABELS } from "../../toolIcon.js";
|
||||
import { abbreviateCwd } from "../../types.js";
|
||||
import type { BranchInfo, Project, Session, SpawnPreset } from "../../types.js";
|
||||
import { BranchPicker } from "./BranchPicker.js";
|
||||
import { DirAutocomplete } from "./DirAutocomplete.js";
|
||||
|
||||
/** Quiet period after raw-path typing before `/api/gitroot` fires (#74). Must
|
||||
* stay equal to `GITROOT_PROBE_DEBOUNCE_MS` in crates/maestro-tui/src/draft.rs
|
||||
* and to ../../spawnForm.ts's own constant, so every client debounces alike. */
|
||||
const GITROOT_PROBE_DEBOUNCE_MS = 250;
|
||||
|
||||
/** "new" (create a worktree) or the path of an existing worktree to run in. */
|
||||
type WorktreeChoice = "new" | string;
|
||||
|
||||
type BaseRepoResolution =
|
||||
| { kind: "disabled" }
|
||||
| { kind: "no-cwd" }
|
||||
| { kind: "found"; repo: string }
|
||||
| { kind: "probe" };
|
||||
|
||||
function resolveBaseRepoFor(cwd: string, wtEnabled: boolean, discovered: Project[]): BaseRepoResolution {
|
||||
if (!wtEnabled) return { kind: "disabled" };
|
||||
if (!cwd) return { kind: "no-cwd" };
|
||||
const project = discovered.find((p) => p.path === cwd);
|
||||
if (project) return { kind: "found", repo: project.base_repo ?? project.path };
|
||||
return { kind: "probe" };
|
||||
}
|
||||
|
||||
/** Every worktree of a group, including the group's own box when the scan
|
||||
* found no main checkout to nest it under. */
|
||||
function worktreesOf(group: ProjectGroup): Project[] {
|
||||
return [group.main, ...group.worktrees].filter((p) => p.is_worktree);
|
||||
}
|
||||
|
||||
/** Resolve a preset id to a tool icon id — the preset's command is the source
|
||||
* of truth, the same way the sidebar derives one. The "Custom…" preset (empty
|
||||
* command) has no tool to show. */
|
||||
function presetToolId(p: SpawnPreset): string | null {
|
||||
if (!p.command) return null;
|
||||
return splitCommand(p.command).tool;
|
||||
}
|
||||
|
||||
function harnessIcon(toolId: string): string | null {
|
||||
const small = toolIconSvg(toolId);
|
||||
if (!small) return null;
|
||||
// toolIconSvg renders at 13x13 (viewBox 0 0 16 16) for the sidebar; the
|
||||
// harness card wants the same mark at 24x24 so it breathes.
|
||||
return small.replace(/width="13"/g, 'width="24"').replace(/height="13"/g, 'height="24"');
|
||||
}
|
||||
|
||||
function pickerButtonClass(active: boolean): string {
|
||||
return `flex min-h-[32px] items-baseline justify-between gap-sp-3 rounded-r-2 border px-sp-3 py-1.5 text-left font-mono ${
|
||||
active ? "border-accent bg-accent-tint" : "border-border bg-surface-2 hover:border-border-strong"
|
||||
}`;
|
||||
}
|
||||
|
||||
export interface SpawnFormProps {
|
||||
seed?: SpawnFormSeed;
|
||||
/** Fired whenever any value changes, once the form's initial seed settles. */
|
||||
onChange?(values: SpawnFormValues): void;
|
||||
onSuccess(session: Session): void;
|
||||
}
|
||||
|
||||
export function SpawnForm({ seed, onChange, onSuccess }: SpawnFormProps) {
|
||||
const prefill = useRef(seed ?? {}).current;
|
||||
|
||||
const formRef = useRef<HTMLFormElement>(null);
|
||||
const filterInputRef = useRef<HTMLInputElement>(null);
|
||||
const customCommandRef = useRef<HTMLInputElement>(null);
|
||||
const rawCwdRef = useRef<HTMLInputElement>(null);
|
||||
const destroyedRef = useRef(false);
|
||||
useEffect(() => () => {
|
||||
destroyedRef.current = true;
|
||||
}, []);
|
||||
|
||||
// ---- harness / command -------------------------------------------------
|
||||
const [homeDir, setHomeDir] = useState("");
|
||||
const [infoLoading, setInfoLoading] = useState(true);
|
||||
const [presets, setPresets] = useState<SpawnPreset[]>(FALLBACK_PRESETS);
|
||||
const [selectedPresetId, setSelectedPresetId] = useState("");
|
||||
const [command, setCommand] = useState("");
|
||||
const [customCommand, setCustomCommand] = useState("");
|
||||
const [commandTouched, setCommandTouched] = useState(false);
|
||||
const [structuredView, setStructuredView] = useState(false);
|
||||
const [structuredTouched, setStructuredTouched] = useState(false);
|
||||
|
||||
const activePreset = useMemo(() => presets.find((p) => p.id === selectedPresetId), [presets, selectedPresetId]);
|
||||
const isCustomSelected = !activePreset || activePreset.command === "";
|
||||
|
||||
useEffect(() => {
|
||||
if (commandTouched && isCustomSelected) customCommandRef.current?.focus();
|
||||
}, [isCustomSelected, commandTouched]);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
getDaemonInfo()
|
||||
.then((info) => {
|
||||
if (cancelled) return;
|
||||
setHomeDir(info.home_dir ?? "");
|
||||
if (info.presets?.length) setPresets(info.presets);
|
||||
})
|
||||
.catch(() => {
|
||||
// keep fallbacks
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) setInfoLoading(false);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Pick a sensible starting command whenever the preset list changes and the
|
||||
// operator hasn't touched it yet: a seed's command (matched to a preset, or
|
||||
// dropped into custom), else the first non-custom preset.
|
||||
useEffect(() => {
|
||||
if (commandTouched) return;
|
||||
const wanted = prefill.command;
|
||||
if (wanted) {
|
||||
const match = presets.find((p) => p.command === wanted && p.command !== "");
|
||||
if (match) {
|
||||
setSelectedPresetId(match.id);
|
||||
setCommand(match.command);
|
||||
return;
|
||||
}
|
||||
const custom = presets.find((p) => p.command === "");
|
||||
setCustomCommand(wanted);
|
||||
setSelectedPresetId(custom ? custom.id : "custom");
|
||||
setCommand(wanted);
|
||||
return;
|
||||
}
|
||||
const first = presets.find((p) => p.command !== "");
|
||||
if (first) {
|
||||
setSelectedPresetId(first.id);
|
||||
setCommand(first.command);
|
||||
}
|
||||
// prefill is captured once at construction, not a reactive dependency.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [presets, commandTouched]);
|
||||
|
||||
const structuredSupported = useMemo(() => supportsStructuredView(command), [command]);
|
||||
useEffect(() => {
|
||||
if (!structuredSupported) setStructuredView(false);
|
||||
else if (!structuredTouched) setStructuredView(true);
|
||||
}, [structuredSupported, structuredTouched]);
|
||||
|
||||
// ---- project picker ------------------------------------------------------
|
||||
const [discovered, setDiscovered] = useState<Project[]>([]);
|
||||
const [projectsLoading, setProjectsLoading] = useState(true);
|
||||
const [filterQuery, setFilterQuery] = useState("");
|
||||
const [cwd, setCwd] = useState("");
|
||||
const [cwdRawVisible, setCwdRawVisible] = useState(false);
|
||||
const [rawFocusToken, setRawFocusToken] = useState(0);
|
||||
const [selectedGroupPath, setSelectedGroupPath] = useState<string | null>(null);
|
||||
const [worktreeChoice, setWorktreeChoice] = useState<WorktreeChoice>("new");
|
||||
|
||||
const groups = useMemo(() => groupProjects(sortProjects(discovered, new Date())), [discovered]);
|
||||
const visibleGroups = useMemo(
|
||||
() => groups.filter((g) => filterProjects([g.main, ...g.worktrees], filterQuery).length > 0),
|
||||
[groups, filterQuery],
|
||||
);
|
||||
const selectedGroup = useMemo(
|
||||
() => groups.find((g) => g.main.path === selectedGroupPath) ?? null,
|
||||
[groups, selectedGroupPath],
|
||||
);
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
fetchProjects()
|
||||
.then((list) => {
|
||||
if (!cancelled) setDiscovered(list);
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) setDiscovered([]);
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) setProjectsLoading(false);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
// ---- worktree (branch-only; base_repo inferred via scan or /gitroot) ----
|
||||
const [wtEnabled, setWtEnabled] = useState(prefill.worktreeEnabled ?? true);
|
||||
const [inferredBaseRepo, setInferredBaseRepo] = useState<string | null>(null);
|
||||
const [wtHint, setWtHint] = useState<string | null>(null);
|
||||
const [name, setName] = useState(prefill.name ?? "");
|
||||
const [branchEdited, setBranchEdited] = useState(Boolean(prefill.worktreeBranchEdited));
|
||||
const [branchMode, setBranchMode] = useState<WorktreeBranchMode>(prefill.worktreeBranchMode ?? "new");
|
||||
const [newBranch, setNewBranch] = useState(prefill.worktreeBranch ?? "");
|
||||
const [existingBranch, setExistingBranch] = useState("");
|
||||
const [branchCache, setBranchCache] = useState<{ cwd: string; branches: BranchInfo[] } | null>(null);
|
||||
|
||||
const [advancedCollapsed, setAdvancedCollapsed] = useState(true);
|
||||
const [error, setError] = useState("");
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [ready, setReady] = useState(false);
|
||||
|
||||
const probeTokenRef = useRef(0);
|
||||
const probeTimerRef = useRef<number | undefined>(undefined);
|
||||
|
||||
const applyBaseRepoResolution = useCallback((res: BaseRepoResolution) => {
|
||||
switch (res.kind) {
|
||||
case "disabled":
|
||||
setInferredBaseRepo(null);
|
||||
setWtHint(null);
|
||||
return;
|
||||
case "no-cwd":
|
||||
setInferredBaseRepo(null);
|
||||
setWtHint("Choose a working directory first.");
|
||||
return;
|
||||
case "found":
|
||||
setInferredBaseRepo(res.repo);
|
||||
setWtHint(null);
|
||||
setBranchCache(null);
|
||||
setExistingBranch("");
|
||||
return;
|
||||
case "probe":
|
||||
return; // caller schedules the probe
|
||||
}
|
||||
}, []);
|
||||
|
||||
const probeGitRoot = useCallback(async (path: string) => {
|
||||
const token = ++probeTokenRef.current;
|
||||
if (!path) {
|
||||
setInferredBaseRepo(null);
|
||||
setWtHint("Choose a working directory first.");
|
||||
return;
|
||||
}
|
||||
setWtHint("Checking for a git repository…");
|
||||
let root: string | undefined;
|
||||
try {
|
||||
root = (await gitRoot(path)).root;
|
||||
} catch {
|
||||
root = undefined;
|
||||
}
|
||||
if (token !== probeTokenRef.current || destroyedRef.current) return; // a newer probe superseded us
|
||||
if (root) {
|
||||
setInferredBaseRepo(root);
|
||||
setWtHint(null);
|
||||
setBranchCache(null);
|
||||
setExistingBranch("");
|
||||
} else {
|
||||
setInferredBaseRepo(null);
|
||||
// Publish the untick too, or a later submit fails with an inferred repo
|
||||
// that no longer matches an unticked box.
|
||||
setWtEnabled(false);
|
||||
setWtHint("Not inside a git repository — pick a dir in a repo to use a worktree.");
|
||||
}
|
||||
}, []);
|
||||
|
||||
const scheduleGitRootProbe = useCallback(
|
||||
(path: string) => {
|
||||
window.clearTimeout(probeTimerRef.current);
|
||||
probeTimerRef.current = window.setTimeout(() => {
|
||||
probeTimerRef.current = undefined;
|
||||
void probeGitRoot(path);
|
||||
}, GITROOT_PROBE_DEBOUNCE_MS);
|
||||
},
|
||||
[probeGitRoot],
|
||||
);
|
||||
useEffect(() => () => window.clearTimeout(probeTimerRef.current), []);
|
||||
|
||||
// Seed the form once the initial project scan and daemon info have both
|
||||
// settled — mirrors createSpawnForm's `loadProjects()`. Computed from local
|
||||
// values rather than reading state back, since the setState calls below
|
||||
// won't have committed yet within this same effect.
|
||||
const seededRef = useRef(false);
|
||||
useEffect(() => {
|
||||
if (infoLoading || projectsLoading || seededRef.current) return;
|
||||
seededRef.current = true;
|
||||
|
||||
const seedCwd = prefill.cwd ?? null;
|
||||
let finalCwd = "";
|
||||
let finalCwdRawVisible = false;
|
||||
let finalGroupPath: string | null = null;
|
||||
let finalChoice: WorktreeChoice = "new";
|
||||
|
||||
const restore = (path: string): boolean => {
|
||||
for (const group of groups) {
|
||||
const wt = worktreesOf(group).find((p) => p.path === path);
|
||||
const choice: WorktreeChoice | null = wt ? wt.path : group.main.path === path ? "new" : null;
|
||||
if (choice === null) continue;
|
||||
finalGroupPath = group.main.path;
|
||||
finalChoice = choice;
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
if (seedCwd) {
|
||||
if (prefill.cwdRawVisible === false) {
|
||||
finalCwd = seedCwd;
|
||||
if (!restore(seedCwd)) finalCwdRawVisible = true;
|
||||
} else {
|
||||
finalCwd = seedCwd;
|
||||
finalCwdRawVisible = true;
|
||||
}
|
||||
} else if (discovered.length === 0) {
|
||||
finalCwd = homeDir;
|
||||
finalCwdRawVisible = true;
|
||||
}
|
||||
|
||||
setCwd(finalCwd);
|
||||
setCwdRawVisible(finalCwdRawVisible);
|
||||
setSelectedGroupPath(finalGroupPath);
|
||||
setWorktreeChoice(finalChoice);
|
||||
|
||||
const res = resolveBaseRepoFor(finalCwd, wtEnabled, discovered);
|
||||
applyBaseRepoResolution(res);
|
||||
if (res.kind === "probe") scheduleGitRootProbe(finalCwd);
|
||||
|
||||
setReady(true);
|
||||
// groups/wtEnabled/discovered/homeDir are read for this one settle pass;
|
||||
// re-running on their later changes would re-seed a form the operator
|
||||
// has since edited.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [infoLoading, projectsLoading]);
|
||||
|
||||
// ---- name (also seeds the worktree branch) ------------------------------
|
||||
const skipInitialBranchSync = useRef(Boolean(prefill.worktreeBranch));
|
||||
useEffect(() => {
|
||||
// One-time construction seeding, mirroring spawnForm.ts precedence: an
|
||||
// explicit seed wins outright; otherwise the prefilled name; otherwise a
|
||||
// freshly generated handle (#53).
|
||||
if (prefill.worktreeBranch) return;
|
||||
if (!branchEdited && branchMode === "new" && prefill.name) return; // handled below, mount-only
|
||||
if (!branchEdited && branchMode === "new") {
|
||||
void fetchNames(1)
|
||||
.then((batch) => {
|
||||
const handle = batch.names[0];
|
||||
if (destroyedRef.current || !handle) return;
|
||||
setNewBranch((cur) => cur || handle);
|
||||
})
|
||||
.catch(() => {
|
||||
// the operator types their own branch
|
||||
});
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!skipInitialBranchSync.current && !branchEdited && branchMode === "new" && name) {
|
||||
setNewBranch(name);
|
||||
}
|
||||
skipInitialBranchSync.current = false;
|
||||
// A cleared name leaves the branch on its last value; only `name` changes
|
||||
// (typed or the mount-time prefill) drive this sync.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [name]);
|
||||
|
||||
// ---- existing-branch picker (#61): fetch once per cwd -------------------
|
||||
useEffect(() => {
|
||||
if (branchMode !== "existing" || !cwd || !inferredBaseRepo) return;
|
||||
if (branchCache?.cwd === cwd) return;
|
||||
let cancelled = false;
|
||||
const target = cwd;
|
||||
fetchBranches(target)
|
||||
.then((branches) => {
|
||||
if (cancelled || destroyedRef.current) return;
|
||||
setBranchCache({ cwd: target, branches });
|
||||
})
|
||||
.catch(() => {
|
||||
// the picker just stays empty; "new" mode still works
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [branchMode, cwd, inferredBaseRepo, branchCache]);
|
||||
|
||||
const branchesForCwd = branchCache?.cwd === cwd ? branchCache.branches : undefined;
|
||||
|
||||
// A native <select> defaults its displayed (and reported) value to the
|
||||
// first enabled option once nothing is explicitly selected. Derived at
|
||||
// render/submit time rather than synced via an effect, so there is no
|
||||
// window where the control shows one branch while state still holds "".
|
||||
const effectiveExistingBranch =
|
||||
existingBranch || branchesForCwd?.find((b) => !b.worktree && !b.is_head)?.name || "";
|
||||
|
||||
// ---- emit (#observability parity with spawnForm.ts's onChange) ---------
|
||||
useEffect(() => {
|
||||
if (!ready) return;
|
||||
onChange?.({
|
||||
command,
|
||||
cwd,
|
||||
name,
|
||||
structuredView,
|
||||
autoApprove: activePreset?.auto_approve ?? false,
|
||||
worktreeEnabled: wtEnabled,
|
||||
worktreeBranch: branchMode === "existing" ? effectiveExistingBranch : newBranch,
|
||||
worktreeBranchEdited: branchEdited,
|
||||
worktreeBranchMode: branchMode,
|
||||
cwdRawVisible,
|
||||
});
|
||||
}, [
|
||||
ready,
|
||||
command,
|
||||
cwd,
|
||||
name,
|
||||
structuredView,
|
||||
activePreset,
|
||||
wtEnabled,
|
||||
branchMode,
|
||||
effectiveExistingBranch,
|
||||
newBranch,
|
||||
branchEdited,
|
||||
cwdRawVisible,
|
||||
onChange,
|
||||
]);
|
||||
|
||||
// ---- handlers -------------------------------------------------------------
|
||||
const selectPreset = useCallback(
|
||||
(id: string) => {
|
||||
setCommandTouched(true);
|
||||
setSelectedPresetId(id);
|
||||
const preset = presets.find((p) => p.id === id);
|
||||
if (!preset || preset.command === "") setCommand(customCommand.trim());
|
||||
else setCommand(preset.command);
|
||||
},
|
||||
[presets, customCommand],
|
||||
);
|
||||
|
||||
const clearProjectSelection = useCallback(() => {
|
||||
setSelectedGroupPath(null);
|
||||
setWorktreeChoice("new");
|
||||
}, []);
|
||||
|
||||
const showRawCwd = useCallback(
|
||||
(prefillValue?: string, focus = true) => {
|
||||
setCwdRawVisible(true);
|
||||
if (prefillValue != null) setCwd(prefillValue);
|
||||
clearProjectSelection();
|
||||
if (focus) setRawFocusToken((t) => t + 1);
|
||||
},
|
||||
[clearProjectSelection],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (rawFocusToken === 0) return;
|
||||
rawCwdRef.current?.focus();
|
||||
rawCwdRef.current?.select();
|
||||
}, [rawFocusToken]);
|
||||
|
||||
const applyWorktreeChoice = useCallback(
|
||||
(group: ProjectGroup, choice: WorktreeChoice) => {
|
||||
const target = choice === "new" ? group.main : worktreesOf(group).find((p) => p.path === choice);
|
||||
if (!target) return;
|
||||
setWorktreeChoice(choice);
|
||||
setCwd(target.path);
|
||||
setBranchCache(null);
|
||||
setExistingBranch("");
|
||||
if (choice === "new") {
|
||||
setWtEnabled(true);
|
||||
applyBaseRepoResolution({ kind: "found", repo: group.main.base_repo ?? group.main.path });
|
||||
} else {
|
||||
setWtEnabled(false);
|
||||
applyBaseRepoResolution({ kind: "disabled" });
|
||||
}
|
||||
},
|
||||
[applyBaseRepoResolution],
|
||||
);
|
||||
|
||||
const selectProject = useCallback(
|
||||
(group: ProjectGroup) => {
|
||||
setSelectedGroupPath(group.main.path);
|
||||
setCwdRawVisible(false);
|
||||
// Rule 4: a box that is itself a linked worktree defaults to running there.
|
||||
applyWorktreeChoice(group, group.main.is_worktree ? group.main.path : "new");
|
||||
},
|
||||
[applyWorktreeChoice],
|
||||
);
|
||||
|
||||
const onWtEnabledChange = useCallback(
|
||||
(next: boolean) => {
|
||||
setWtEnabled(next);
|
||||
const res = resolveBaseRepoFor(cwd, next, discovered);
|
||||
applyBaseRepoResolution(res);
|
||||
if (res.kind === "probe") scheduleGitRootProbe(cwd);
|
||||
},
|
||||
[cwd, discovered, applyBaseRepoResolution, scheduleGitRootProbe],
|
||||
);
|
||||
|
||||
const onRawCwdChange = useCallback(
|
||||
(v: string) => {
|
||||
setCwd(v);
|
||||
setBranchCache(null);
|
||||
setExistingBranch("");
|
||||
if (wtEnabled) scheduleGitRootProbe(v);
|
||||
},
|
||||
[wtEnabled, scheduleGitRootProbe],
|
||||
);
|
||||
|
||||
const onSubmit = useCallback(
|
||||
async (e: React.FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault();
|
||||
setError("");
|
||||
const values: SpawnFormValues = {
|
||||
command,
|
||||
cwd,
|
||||
name,
|
||||
structuredView,
|
||||
autoApprove: activePreset?.auto_approve ?? false,
|
||||
worktreeEnabled: wtEnabled,
|
||||
worktreeBranch: branchMode === "existing" ? effectiveExistingBranch : newBranch,
|
||||
worktreeBranchEdited: branchEdited,
|
||||
worktreeBranchMode: branchMode,
|
||||
};
|
||||
const built = buildSpawnRequest(values, inferredBaseRepo);
|
||||
if ("error" in built) {
|
||||
setError(built.error);
|
||||
return;
|
||||
}
|
||||
setSubmitting(true);
|
||||
try {
|
||||
const session = await createSession(built.req);
|
||||
onSuccess(session);
|
||||
} catch (err) {
|
||||
setError((err as Error).message);
|
||||
setSubmitting(false);
|
||||
}
|
||||
},
|
||||
[
|
||||
command,
|
||||
cwd,
|
||||
name,
|
||||
structuredView,
|
||||
activePreset,
|
||||
wtEnabled,
|
||||
branchMode,
|
||||
effectiveExistingBranch,
|
||||
newBranch,
|
||||
branchEdited,
|
||||
inferredBaseRepo,
|
||||
onSuccess,
|
||||
],
|
||||
);
|
||||
|
||||
// ---- Enter submits from anywhere (#97) ------------------------------------
|
||||
const onFormKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent<HTMLFormElement>) => {
|
||||
if (e.key !== "Enter" || e.defaultPrevented) return;
|
||||
const target = e.target as HTMLElement;
|
||||
|
||||
if (target.dataset.role === "toggle") {
|
||||
e.preventDefault();
|
||||
(target as HTMLInputElement).click();
|
||||
return;
|
||||
}
|
||||
|
||||
if (target instanceof HTMLButtonElement && target.dataset.picker) {
|
||||
e.preventDefault();
|
||||
if (target.dataset.active === "true") {
|
||||
formRef.current?.requestSubmit();
|
||||
} else {
|
||||
target.click();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
e.preventDefault();
|
||||
if (target === filterInputRef.current) {
|
||||
// Resolve the filter to a project only when the current pick isn't
|
||||
// one of the matches: a filter typed past it means the operator
|
||||
// moved on, while a pick that still matches keeps its worktree choice.
|
||||
const stillMatches =
|
||||
selectedGroup != null && visibleGroups.some((g) => g.main.path === selectedGroup.main.path);
|
||||
if (!stillMatches && visibleGroups[0]) selectProject(visibleGroups[0]);
|
||||
}
|
||||
formRef.current?.requestSubmit();
|
||||
},
|
||||
[selectedGroup, visibleGroups, selectProject],
|
||||
);
|
||||
|
||||
return (
|
||||
<form
|
||||
ref={formRef}
|
||||
onSubmit={onSubmit}
|
||||
onKeyDown={onFormKeyDown}
|
||||
className="mx-auto flex w-full max-w-[60ch] flex-col gap-sp-2 overflow-auto p-sp-3"
|
||||
>
|
||||
<div className="flex flex-col gap-sp-2">
|
||||
<span className="text-fs-13 text-ink">Harness</span>
|
||||
<div className="grid grid-cols-[repeat(auto-fill,minmax(120px,1fr))] gap-sp-2">
|
||||
{presets.map((p) => {
|
||||
const isCustom = p.command === "";
|
||||
const toolId = presetToolId(p);
|
||||
const icon = isCustom ? null : toolId ? harnessIcon(toolId) : harnessIcon("shell");
|
||||
const label = TOOL_LABELS[toolId ?? ""] ?? p.label;
|
||||
const active = p.id === selectedPresetId;
|
||||
return (
|
||||
<button
|
||||
key={p.id}
|
||||
type="button"
|
||||
className={`flex flex-col items-center gap-sp-2 rounded-r-2 border px-sp-2 py-sp-3 text-center ${
|
||||
active
|
||||
? "border-accent bg-accent-tint"
|
||||
: isCustom
|
||||
? "border-dashed border-border text-muted"
|
||||
: "border-border bg-surface-2 hover:border-border-strong"
|
||||
}`}
|
||||
onClick={() => selectPreset(p.id)}
|
||||
>
|
||||
<span
|
||||
className={`flex h-8 w-8 items-center justify-center ${active ? "text-ink" : "text-muted"}`}
|
||||
aria-hidden={icon ? undefined : true}
|
||||
dangerouslySetInnerHTML={icon ? { __html: icon } : undefined}
|
||||
>
|
||||
{icon ? undefined : "…"}
|
||||
</span>
|
||||
<span className="max-w-full truncate text-fs-13 font-semibold">
|
||||
{isCustom ? "Custom…" : label}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<input
|
||||
ref={customCommandRef}
|
||||
className="w-full rounded-r-2 border border-border bg-surface-2 px-sp-3 py-2.5 font-mono text-fs-15 text-ink"
|
||||
name="command_raw"
|
||||
type="text"
|
||||
hidden={!isCustomSelected}
|
||||
placeholder="bash -l"
|
||||
autoComplete="off"
|
||||
value={customCommand}
|
||||
onChange={(e) => {
|
||||
const v = e.currentTarget.value;
|
||||
setCustomCommand(v);
|
||||
if (isCustomSelected) {
|
||||
setCommandTouched(true);
|
||||
setCommand(v.trim());
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-sp-2">
|
||||
<span className="text-fs-13 text-ink">Project</span>
|
||||
<input
|
||||
ref={filterInputRef}
|
||||
className="w-full rounded-r-2 border border-border bg-surface-2 px-sp-3 py-2.5 font-mono text-fs-15 text-ink"
|
||||
type="text"
|
||||
placeholder="Filter projects…"
|
||||
autoComplete="off"
|
||||
value={filterQuery}
|
||||
onChange={(e) => setFilterQuery(e.currentTarget.value)}
|
||||
/>
|
||||
{!ready ? (
|
||||
<p className="m-0 px-sp-3 py-1.5 text-fs-13 text-faint">Loading projects…</p>
|
||||
) : (
|
||||
<div className="grid max-h-[300px] grid-cols-2 gap-sp-2 overflow-y-auto p-px">
|
||||
{visibleGroups.map((group) => {
|
||||
const active = selectedGroup?.main.path === group.main.path;
|
||||
return (
|
||||
<button
|
||||
key={group.main.path}
|
||||
type="button"
|
||||
data-picker="project"
|
||||
data-active={active || undefined}
|
||||
className={`flex min-w-0 flex-col gap-0.5 rounded-r-2 border px-sp-3 py-sp-2 text-left ${
|
||||
active ? "border-accent bg-accent-tint" : "border-border bg-surface-2 hover:border-border-strong"
|
||||
} ${isStale(group.main, new Date()) ? "opacity-55" : ""}`}
|
||||
onClick={() => selectProject(group)}
|
||||
>
|
||||
<span className="truncate text-fs-13 font-semibold text-ink">{group.main.name}</span>
|
||||
<span className="break-words font-mono text-fs-12 text-muted">
|
||||
{abbreviateCwd(parentDir(group.main.path), homeDir)}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
{discovered.length > 0 && visibleGroups.length === 0 && (
|
||||
<p className="col-span-2 m-0 px-sp-3 py-1.5 text-fs-13 text-faint">No projects match.</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className="self-start bg-transparent px-0 py-1 text-fs-13 italic text-muted hover:text-ink"
|
||||
onClick={() => showRawCwd(cwd || homeDir)}
|
||||
>
|
||||
Other directory…
|
||||
</button>
|
||||
{cwdRawVisible && (
|
||||
<DirAutocomplete
|
||||
inputRef={rawCwdRef}
|
||||
className="w-full rounded-r-2 border border-border bg-surface-2 px-sp-3 py-2.5 font-mono text-fs-15 text-ink"
|
||||
name="cwd_raw"
|
||||
type="text"
|
||||
placeholder="/home/user/project"
|
||||
value={cwd}
|
||||
onChange={onRawCwdChange}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col border-t border-border pt-sp-2">
|
||||
<button
|
||||
type="button"
|
||||
className="flex w-full items-center gap-sp-1 py-sp-2 text-left text-fs-11 text-muted"
|
||||
aria-expanded={!advancedCollapsed}
|
||||
onClick={() => setAdvancedCollapsed((c) => !c)}
|
||||
>
|
||||
<span className={`inline-block transition-transform ${advancedCollapsed ? "-rotate-90" : ""}`}>▾</span>
|
||||
<span className="font-semibold">Advanced</span>
|
||||
<span className="ml-auto text-fs-12 text-muted">{wtEnabled ? "creates a worktree" : "no worktree"}</span>
|
||||
</button>
|
||||
{!advancedCollapsed && (
|
||||
<div className="flex flex-col gap-sp-3 py-sp-2">
|
||||
<label className="flex flex-col gap-1 text-fs-12 text-muted">
|
||||
Session name
|
||||
<input
|
||||
name="name"
|
||||
type="text"
|
||||
autoComplete="off"
|
||||
placeholder="named after your first prompt"
|
||||
className="w-full rounded-r-2 border border-border bg-surface-2 px-sp-3 py-2.5 font-mono text-fs-15 text-ink"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.currentTarget.value)}
|
||||
/>
|
||||
</label>
|
||||
|
||||
{structuredSupported && (
|
||||
<label className="flex cursor-pointer items-center gap-sp-2 text-fs-14 text-ink">
|
||||
<input
|
||||
type="checkbox"
|
||||
data-role="toggle"
|
||||
className="size-[18px] accent-accent"
|
||||
checked={structuredView}
|
||||
onChange={(e) => {
|
||||
setStructuredTouched(true);
|
||||
setStructuredView(e.currentTarget.checked);
|
||||
}}
|
||||
/>
|
||||
Structured view
|
||||
</label>
|
||||
)}
|
||||
|
||||
{selectedGroup && (
|
||||
<div className="flex flex-col gap-sp-2">
|
||||
<span className="text-fs-13 text-ink">Worktree</span>
|
||||
<div className="flex max-h-[200px] flex-col gap-sp-1 overflow-y-auto">
|
||||
<button
|
||||
type="button"
|
||||
data-picker="worktree"
|
||||
data-active={worktreeChoice === "new" || undefined}
|
||||
className={pickerButtonClass(worktreeChoice === "new")}
|
||||
onClick={() => applyWorktreeChoice(selectedGroup, "new")}
|
||||
>
|
||||
<span className="text-fs-13 font-semibold text-accent">+ new</span>
|
||||
<span className="text-right text-fs-12 text-muted">
|
||||
new worktree of {selectedGroup.main.name}
|
||||
</span>
|
||||
</button>
|
||||
{worktreesOf(selectedGroup).map((wt) => (
|
||||
<button
|
||||
key={wt.path}
|
||||
type="button"
|
||||
data-picker="worktree"
|
||||
data-active={worktreeChoice === wt.path || undefined}
|
||||
className={pickerButtonClass(worktreeChoice === wt.path)}
|
||||
onClick={() => applyWorktreeChoice(selectedGroup, wt.path)}
|
||||
>
|
||||
<span className="text-fs-13 font-semibold text-ink">{wt.name}</span>
|
||||
<span className="text-right text-fs-12 text-muted">{wt.path}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<BranchPicker
|
||||
enabled={wtEnabled}
|
||||
onToggleEnabled={onWtEnabledChange}
|
||||
hint={wtHint}
|
||||
baseRepo={inferredBaseRepo}
|
||||
mode={branchMode}
|
||||
onModeChange={setBranchMode}
|
||||
newBranch={newBranch}
|
||||
onNewBranchChange={(v) => {
|
||||
setBranchEdited(true);
|
||||
setNewBranch(v);
|
||||
}}
|
||||
branches={branchesForCwd}
|
||||
existingBranch={effectiveExistingBranch}
|
||||
onExistingBranchChange={setExistingBranch}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<p role="alert" aria-live="polite" className={`m-0 text-fs-13 text-danger ${error ? "rounded-r-2 bg-danger-tint px-sp-3 py-sp-2" : "min-h-[1.2em]"}`}>
|
||||
{error}
|
||||
</p>
|
||||
<div className="flex justify-end">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={submitting}
|
||||
className="rounded-r-1 bg-accent px-sp-4 py-sp-2 text-fs-13 font-semibold text-accent-ink disabled:opacity-60"
|
||||
>
|
||||
Spawn
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
// @vitest-environment happy-dom
|
||||
import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import type { DaemonInfo, Project, Session } from "../../types.js";
|
||||
import { SpawnModal } from "./SpawnModal.js";
|
||||
|
||||
vi.mock("../../api.js", () => ({
|
||||
createSession: vi.fn(),
|
||||
fetchBranches: vi.fn(),
|
||||
fetchNames: vi.fn(),
|
||||
getDaemonInfo: vi.fn(),
|
||||
gitRoot: vi.fn(),
|
||||
listDir: vi.fn(),
|
||||
projects: vi.fn(),
|
||||
}));
|
||||
|
||||
import { createSession, fetchNames, getDaemonInfo, listDir, projects as fetchProjects } from "../../api.js";
|
||||
|
||||
function info(): DaemonInfo {
|
||||
return { hostname: "h", version: "1", home_dir: "/home/n", presets: [{ id: "claude", label: "Claude", command: "claude" }] };
|
||||
}
|
||||
|
||||
function project(over: Partial<Project> = {}): Project {
|
||||
return { path: "/repos/p", name: "p", ...over };
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.mocked(getDaemonInfo).mockResolvedValue(info());
|
||||
vi.mocked(fetchProjects).mockResolvedValue([project()]);
|
||||
vi.mocked(fetchNames).mockResolvedValue({ names: ["amber-fox"] });
|
||||
vi.mocked(listDir).mockResolvedValue({ path: "", entries: [] });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
async function settle() {
|
||||
await waitFor(() => expect(screen.queryByText("Loading projects…")).toBeNull());
|
||||
}
|
||||
|
||||
describe("SpawnModal", () => {
|
||||
it("mounts the spawn form inside a dialog", async () => {
|
||||
render(<SpawnModal onSuccess={vi.fn()} onClose={vi.fn()} />);
|
||||
await settle();
|
||||
|
||||
expect(screen.getByRole("dialog")).not.toBeNull();
|
||||
expect(screen.getByRole("button", { name: "Spawn" })).not.toBeNull();
|
||||
});
|
||||
|
||||
it("titles a seeded open as a clone", async () => {
|
||||
render(<SpawnModal seed={{ command: "claude" }} onSuccess={vi.fn()} onClose={vi.fn()} />);
|
||||
await settle();
|
||||
|
||||
expect(screen.getByText("Spawn similar")).not.toBeNull();
|
||||
});
|
||||
|
||||
it("titles a plain open as a new session", async () => {
|
||||
render(<SpawnModal onSuccess={vi.fn()} onClose={vi.fn()} />);
|
||||
await settle();
|
||||
|
||||
expect(screen.getByText("Spawn session")).not.toBeNull();
|
||||
});
|
||||
|
||||
it("closes on Escape, on Cancel and on a backdrop click", async () => {
|
||||
const onClose = vi.fn();
|
||||
const { unmount } = render(<SpawnModal onSuccess={vi.fn()} onClose={onClose} />);
|
||||
await settle();
|
||||
|
||||
fireEvent.keyDown(screen.getByRole("dialog"), { key: "Escape" });
|
||||
expect(onClose).toHaveBeenCalledTimes(1);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Cancel" }));
|
||||
expect(onClose).toHaveBeenCalledTimes(2);
|
||||
unmount();
|
||||
|
||||
render(<SpawnModal onSuccess={vi.fn()} onClose={onClose} />);
|
||||
await settle();
|
||||
fireEvent.click(document.querySelector(".modal-backdrop")!);
|
||||
expect(onClose).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
it("closes before handing the created session to its caller", async () => {
|
||||
const created: Session = {
|
||||
id: "s1",
|
||||
cwd: "/repos/p",
|
||||
command: "claude",
|
||||
env: {},
|
||||
metadata: {},
|
||||
status: "Running",
|
||||
created_at: "2026-01-01T00:00:00Z",
|
||||
};
|
||||
vi.mocked(createSession).mockResolvedValue(created);
|
||||
const calls: string[] = [];
|
||||
const onClose = vi.fn(() => calls.push("close"));
|
||||
const onSuccess = vi.fn(() => calls.push("success"));
|
||||
render(<SpawnModal onSuccess={onSuccess} onClose={onClose} />);
|
||||
await settle();
|
||||
fireEvent.click(screen.getByText("p"));
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Spawn" }));
|
||||
|
||||
await waitFor(() => expect(onSuccess).toHaveBeenCalledWith(created));
|
||||
expect(calls).toEqual(["close", "success"]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,82 @@
|
||||
// React port of ../../spawnModal.ts: dialog chrome around SpawnForm. Escape,
|
||||
// the ✕ and a click on the backdrop all close it; SpawnForm's own DirAutocomplete
|
||||
// swallows Escape first, so dismissing a dropdown never also discards the form.
|
||||
|
||||
import { useEffect, useRef } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
|
||||
import type { Session } from "../../types.js";
|
||||
import { SpawnForm, type SpawnFormProps } from "./SpawnForm.js";
|
||||
|
||||
export interface SpawnModalProps {
|
||||
seed?: SpawnFormProps["seed"];
|
||||
/** Called with the created session after a successful submit — the modal
|
||||
* has already closed by then, so the caller decides where it lands. */
|
||||
onSuccess(session: Session): void;
|
||||
onClose(): void;
|
||||
}
|
||||
|
||||
export function SpawnModal({ seed, onSuccess, onClose }: SpawnModalProps) {
|
||||
const filterInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const handleSuccess = (session: Session) => {
|
||||
onClose();
|
||||
onSuccess(session);
|
||||
};
|
||||
|
||||
// The project filter owns the keyboard on open: typing narrows the list and
|
||||
// Tab from here reaches the harness tiles, matching the TUI's draft pane.
|
||||
useEffect(() => {
|
||||
const host = filterInputRef.current;
|
||||
host?.querySelector<HTMLInputElement>("input[placeholder='Filter projects…']")?.focus();
|
||||
}, []);
|
||||
|
||||
return createPortal(
|
||||
<div
|
||||
className="modal-backdrop fixed inset-0 z-modal flex items-center justify-center bg-bg/70 backdrop-blur-sm"
|
||||
onClick={(e) => {
|
||||
if (e.target === e.currentTarget) onClose();
|
||||
}}
|
||||
>
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="spawn-title"
|
||||
className="flex max-h-[92vh] w-full max-w-[560px] flex-col rounded-r-3 border border-border bg-surface shadow-2"
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Escape" && !e.defaultPrevented) {
|
||||
e.preventDefault();
|
||||
onClose();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<header className="flex items-center justify-between border-b border-border p-sp-3">
|
||||
<h2 id="spawn-title" className="m-0 text-fs-15 font-semibold text-ink">
|
||||
{seed ? "Spawn similar" : "Spawn session"}
|
||||
</h2>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Close"
|
||||
className="rounded-r-1 px-sp-2 py-1 text-fs-15 text-muted hover:text-ink"
|
||||
onClick={onClose}
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</header>
|
||||
<div ref={filterInputRef} className="min-h-0 flex-1 overflow-auto">
|
||||
<SpawnForm seed={seed} onSuccess={handleSuccess} />
|
||||
</div>
|
||||
<div className="flex justify-end gap-sp-2 border-t border-border p-sp-3">
|
||||
<button
|
||||
type="button"
|
||||
className="rounded-r-1 border border-border px-sp-3 py-sp-1 text-fs-12 text-ink"
|
||||
onClick={onClose}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
document.body,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
export { SpawnModal, type SpawnModalProps } from "./SpawnModal.js";
|
||||
export { SpawnForm, type SpawnFormProps } from "./SpawnForm.js";
|
||||
export { DirAutocomplete, type DirAutocompleteProps } from "./DirAutocomplete.js";
|
||||
export { BranchPicker, type BranchPickerProps } from "./BranchPicker.js";
|
||||
export { useFastForward, type UseFastForwardOptions } from "./useFastForward.js";
|
||||
// spawnSimilar.ts is pure seed-building logic with no DOM dependency — reused
|
||||
// as-is rather than re-implemented for React (#248).
|
||||
export { spawnSimilarSeed, spawnSimilarProjectSeed } from "../../spawnSimilar.js";
|
||||
@@ -0,0 +1,114 @@
|
||||
// @vitest-environment happy-dom
|
||||
import { act, renderHook } from "@testing-library/react";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import type { Project, Session, SpawnPreset } from "../../types.js";
|
||||
import { useFastForward } from "./useFastForward.js";
|
||||
|
||||
vi.mock("../../api.js", () => ({
|
||||
createSession: vi.fn(),
|
||||
fetchNames: vi.fn(),
|
||||
getDaemonInfo: vi.fn(),
|
||||
projects: vi.fn(),
|
||||
}));
|
||||
|
||||
import { createSession, fetchNames, getDaemonInfo, projects as fetchProjects } from "../../api.js";
|
||||
|
||||
const mockCreateSession = vi.mocked(createSession);
|
||||
const mockFetchNames = vi.mocked(fetchNames);
|
||||
const mockGetDaemonInfo = vi.mocked(getDaemonInfo);
|
||||
const mockFetchProjects = vi.mocked(fetchProjects);
|
||||
|
||||
const presets: SpawnPreset[] = [{ id: "claude", label: "Claude", command: "claude" }];
|
||||
|
||||
function project(over: Partial<Project> = {}): Project {
|
||||
return { path: "/repos/p", name: "p", base_repo: "/repos/p", last_command: "claude", ...over };
|
||||
}
|
||||
|
||||
function session(): Session {
|
||||
return {
|
||||
id: "s1",
|
||||
cwd: "/repos/p",
|
||||
command: "claude",
|
||||
env: {},
|
||||
metadata: {},
|
||||
status: "Running",
|
||||
created_at: "2026-01-01T00:00:00Z",
|
||||
};
|
||||
}
|
||||
|
||||
afterEach(() => vi.clearAllMocks());
|
||||
|
||||
describe("useFastForward", () => {
|
||||
it("spawns immediately once every default resolves", async () => {
|
||||
mockFetchProjects.mockResolvedValue([project()]);
|
||||
mockFetchNames.mockResolvedValue({ names: ["amber-fox"] });
|
||||
mockGetDaemonInfo.mockResolvedValue({ hostname: "h", version: "1", presets });
|
||||
const created = session();
|
||||
mockCreateSession.mockResolvedValue(created);
|
||||
const onSpawned = vi.fn();
|
||||
const onFallback = vi.fn();
|
||||
|
||||
const { result } = renderHook(() => useFastForward({ onSpawned, onFallback }));
|
||||
await act(() => result.current());
|
||||
|
||||
expect(mockCreateSession).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
cwd: "/repos/p",
|
||||
command: "claude",
|
||||
worktree: { base_repo: "/repos/p", branch: "amber-fox", create_branch: true },
|
||||
}),
|
||||
);
|
||||
expect(onSpawned).toHaveBeenCalledWith(created);
|
||||
expect(onFallback).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("falls back to a pre-filled seed when the daemon rejects the spawn", async () => {
|
||||
mockFetchProjects.mockResolvedValue([project()]);
|
||||
mockFetchNames.mockResolvedValue({ names: ["amber-fox"] });
|
||||
mockGetDaemonInfo.mockResolvedValue({ hostname: "h", version: "1", presets });
|
||||
mockCreateSession.mockRejectedValue(new Error("boom"));
|
||||
const onSpawned = vi.fn();
|
||||
const onFallback = vi.fn();
|
||||
|
||||
const { result } = renderHook(() => useFastForward({ onSpawned, onFallback }));
|
||||
await act(() => result.current());
|
||||
|
||||
expect(onSpawned).not.toHaveBeenCalled();
|
||||
expect(onFallback).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ cwd: "/repos/p", command: "claude", worktreeBranch: "amber-fox" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("falls back when nothing is known yet (no projects, no handle)", async () => {
|
||||
mockFetchProjects.mockResolvedValue([]);
|
||||
mockFetchNames.mockRejectedValue(new Error("exhausted"));
|
||||
mockGetDaemonInfo.mockResolvedValue({ hostname: "h", version: "1", presets });
|
||||
const onSpawned = vi.fn();
|
||||
const onFallback = vi.fn();
|
||||
|
||||
const { result } = renderHook(() => useFastForward({ onSpawned, onFallback }));
|
||||
await act(() => result.current());
|
||||
|
||||
expect(mockCreateSession).not.toHaveBeenCalled();
|
||||
expect(onFallback).toHaveBeenCalledWith(expect.objectContaining({ cwd: "" }));
|
||||
});
|
||||
|
||||
it("prefers the focused session's project over the freshest one", async () => {
|
||||
mockFetchProjects.mockResolvedValue([
|
||||
project({ path: "/repos/fresh", name: "fresh" }),
|
||||
project({ path: "/repos/current", name: "current", last_activity: "2020-01-01T00:00:00Z" }),
|
||||
]);
|
||||
mockFetchNames.mockResolvedValue({ names: ["h"] });
|
||||
mockGetDaemonInfo.mockResolvedValue({ hostname: "h", version: "1", presets });
|
||||
mockCreateSession.mockResolvedValue(session());
|
||||
const onSpawned = vi.fn();
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useFastForward({ focusedCwd: "/repos/current", onSpawned, onFallback: vi.fn() }),
|
||||
);
|
||||
await act(() => result.current());
|
||||
|
||||
expect(mockCreateSession).toHaveBeenCalledWith(expect.objectContaining({ cwd: "/repos/current" }));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,57 @@
|
||||
// React port of main.ts's `requestFastForward` (#67): one keystroke, every
|
||||
// spawn default resolved — most recent project (or the one the operator is
|
||||
// on), its last command, a fresh generated branch handle, worktree on with a
|
||||
// new branch. A failed POST, or any missing default, falls back to the same
|
||||
// pre-filled form `onFallback` opens, rather than a dead keystroke.
|
||||
|
||||
import { useCallback } from "react";
|
||||
|
||||
import { createSession, fetchNames, getDaemonInfo, projects as fetchProjects } from "../../api.js";
|
||||
import { buildFastForwardRequest, fastForwardSeed, resolveFastForward } from "../../fastForward.js";
|
||||
import { FALLBACK_PRESETS, type SpawnFormSeed } from "../../spawnForm.js";
|
||||
import type { Project, Session, SpawnPreset } from "../../types.js";
|
||||
|
||||
export interface UseFastForwardOptions {
|
||||
/** The project of the session the operator is currently on, if any — its
|
||||
* base repo for a worktree session, else its cwd. Same rule the plain
|
||||
* spawn modal's seed uses, so `N` lands where the operator currently is. */
|
||||
focusedCwd?: string;
|
||||
onSpawned(session: Session): void;
|
||||
onFallback(seed: SpawnFormSeed): void;
|
||||
}
|
||||
|
||||
async function resolvePresets(): Promise<SpawnPreset[]> {
|
||||
try {
|
||||
const info = await getDaemonInfo();
|
||||
if (info.presets?.length) return info.presets;
|
||||
} catch {
|
||||
// keep fallbacks
|
||||
}
|
||||
return FALLBACK_PRESETS;
|
||||
}
|
||||
|
||||
/** Returns a function to call on the fast-forward keystroke. */
|
||||
export function useFastForward(opts: UseFastForwardOptions): () => Promise<void> {
|
||||
const { focusedCwd, onSpawned, onFallback } = opts;
|
||||
|
||||
return useCallback(async () => {
|
||||
const [discovered, handle] = await Promise.all([
|
||||
fetchProjects().catch(() => [] as Project[]),
|
||||
fetchNames(1)
|
||||
.then((batch) => batch.names[0])
|
||||
.catch(() => undefined),
|
||||
]);
|
||||
const presets = await resolvePresets();
|
||||
const resolved = resolveFastForward(discovered, presets, handle, focusedCwd);
|
||||
const req = buildFastForwardRequest(resolved);
|
||||
if (req) {
|
||||
try {
|
||||
onSpawned(await createSession(req));
|
||||
return;
|
||||
} catch {
|
||||
// degrades to the pre-filled form below, never a silent dead key
|
||||
}
|
||||
}
|
||||
onFallback(fastForwardSeed(resolved));
|
||||
}, [focusedCwd, onSpawned, onFallback]);
|
||||
}
|
||||
@@ -15,9 +15,9 @@ import { splitCommand, toolIconSvg, TOOL_LABELS } from "./toolIcon.js";
|
||||
import { abbreviateCwd } from "./types.js";
|
||||
import type { BranchInfo, CreateSessionRequest, Project, Session, SpawnPreset } from "./types.js";
|
||||
|
||||
const STRUCTURED_VIEW_TOOLS = new Set(["claude", "codex"]);
|
||||
export const STRUCTURED_VIEW_TOOLS = new Set(["claude", "codex"]);
|
||||
|
||||
function supportsStructuredView(command: string): boolean {
|
||||
export function supportsStructuredView(command: string): boolean {
|
||||
const tool = splitCommand(command).tool;
|
||||
return tool !== null && STRUCTURED_VIEW_TOOLS.has(tool);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,162 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,253 @@
|
||||
// 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