Compare commits
10
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bc87ddee06 | ||
|
|
554c9ebb31 | ||
|
|
af5ad1a227 | ||
|
|
8304ef9a49 | ||
|
|
cdba6d07e3 | ||
|
|
ea8c42bfb2 | ||
|
|
e0edbc09d2 | ||
|
|
e9e98e0c49 | ||
|
|
02f1844650 | ||
|
|
1b7ec1f54c |
@@ -0,0 +1,68 @@
|
|||||||
|
// @vitest-environment happy-dom
|
||||||
|
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
|
||||||
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
import { ContextMenu } from "./ContextMenu.js";
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
cleanup();
|
||||||
|
});
|
||||||
|
|
||||||
|
function labels(): string[] {
|
||||||
|
return [...document.querySelectorAll(".context-menu-item")].map((b) => b.textContent ?? "");
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("ContextMenu (#125)", () => {
|
||||||
|
it("renders one item per entry, in order", () => {
|
||||||
|
render(
|
||||||
|
<ContextMenu
|
||||||
|
x={10}
|
||||||
|
y={20}
|
||||||
|
items={[
|
||||||
|
{ label: "Rename…", onSelect: () => {} },
|
||||||
|
{ label: "Kill session", onSelect: () => {}, danger: true },
|
||||||
|
]}
|
||||||
|
onClose={vi.fn()}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
expect(labels()).toEqual(["Rename…", "Kill session"]);
|
||||||
|
expect(document.querySelector(".context-menu-item.is-danger")?.textContent).toBe("Kill session");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("runs the selected entry and closes", () => {
|
||||||
|
const chosen = vi.fn();
|
||||||
|
const onClose = vi.fn();
|
||||||
|
render(<ContextMenu x={0} y={0} items={[{ label: "Rename…", onSelect: chosen }]} onClose={onClose} />);
|
||||||
|
fireEvent.click(screen.getByRole("menuitem"));
|
||||||
|
expect(chosen).toHaveBeenCalledOnce();
|
||||||
|
expect(onClose).toHaveBeenCalledOnce();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("closes on Escape and on a press outside it", () => {
|
||||||
|
const onClose = vi.fn();
|
||||||
|
render(<ContextMenu x={0} y={0} items={[{ label: "Rename…", onSelect: () => {} }]} onClose={onClose} />);
|
||||||
|
fireEvent.keyDown(document, { key: "Escape" });
|
||||||
|
expect(onClose).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
fireEvent(document.body, new Event("pointerdown", { bubbles: true }));
|
||||||
|
expect(onClose).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("stays open for a press inside itself", () => {
|
||||||
|
const onClose = vi.fn();
|
||||||
|
render(<ContextMenu x={0} y={0} items={[{ label: "Rename…", onSelect: () => {} }]} onClose={onClose} />);
|
||||||
|
const menu = document.querySelector(".context-menu") as HTMLElement;
|
||||||
|
fireEvent(menu, new Event("pointerdown", { bubbles: true }));
|
||||||
|
expect(onClose).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("closes on scroll and on resize", () => {
|
||||||
|
const onClose = vi.fn();
|
||||||
|
render(<ContextMenu x={0} y={0} items={[{ label: "Rename…", onSelect: () => {} }]} onClose={onClose} />);
|
||||||
|
fireEvent.scroll(window);
|
||||||
|
expect(onClose).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
fireEvent.resize(window);
|
||||||
|
expect(onClose).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
// React port of ../../contextMenu.ts (#125), the general-purpose version —
|
||||||
|
// unlike the sidebar's own menu, this one also closes on scroll/resize, since
|
||||||
|
// a menu anchored to coordinates goes stale the moment the page moves under it.
|
||||||
|
|
||||||
|
import { useEffect, useLayoutEffect, useRef, useState } from "react";
|
||||||
|
import { createPortal } from "react-dom";
|
||||||
|
|
||||||
|
export interface ContextMenuItem {
|
||||||
|
label: string;
|
||||||
|
onSelect(): void;
|
||||||
|
/** Renders the entry in the destructive style. */
|
||||||
|
danger?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ContextMenuProps {
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
items: ContextMenuItem[];
|
||||||
|
onClose(): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ContextMenu({ x, y, items, onClose }: ContextMenuProps) {
|
||||||
|
const ref = useRef<HTMLDivElement>(null);
|
||||||
|
const [pos, setPos] = useState({ left: x, top: y });
|
||||||
|
|
||||||
|
// Nudge back inside the viewport once the menu has a measurable size.
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
const rect = ref.current?.getBoundingClientRect();
|
||||||
|
if (!rect) return;
|
||||||
|
const maxX = window.innerWidth - rect.width;
|
||||||
|
const maxY = window.innerHeight - rect.height;
|
||||||
|
setPos({ left: Math.max(0, Math.min(x, maxX)), top: Math.max(0, Math.min(y, maxY)) });
|
||||||
|
}, [x, y]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const onKeyDown = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === "Escape") onClose();
|
||||||
|
};
|
||||||
|
const onPointerDown = (e: Event) => {
|
||||||
|
if (!ref.current?.contains(e.target as Node)) onClose();
|
||||||
|
};
|
||||||
|
document.addEventListener("keydown", onKeyDown, true);
|
||||||
|
window.addEventListener("pointerdown", onPointerDown, true);
|
||||||
|
window.addEventListener("scroll", onClose, true);
|
||||||
|
window.addEventListener("resize", onClose);
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener("keydown", onKeyDown, true);
|
||||||
|
window.removeEventListener("pointerdown", onPointerDown, true);
|
||||||
|
window.removeEventListener("scroll", onClose, true);
|
||||||
|
window.removeEventListener("resize", onClose);
|
||||||
|
};
|
||||||
|
}, [onClose]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
(ref.current?.firstElementChild as HTMLElement | null)?.focus();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return createPortal(
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
role="menu"
|
||||||
|
className="context-menu fixed z-modal min-w-[160px] rounded-r-2 border border-border-strong bg-surface py-sp-1 shadow-2"
|
||||||
|
style={{ left: pos.left, top: pos.top }}
|
||||||
|
>
|
||||||
|
{items.map((item) => (
|
||||||
|
<button
|
||||||
|
key={item.label}
|
||||||
|
type="button"
|
||||||
|
role="menuitem"
|
||||||
|
className={`context-menu-item block w-full px-sp-3 py-sp-1 text-left text-fs-12 hover:bg-surface-2 ${
|
||||||
|
item.danger ? "is-danger text-danger" : "text-ink"
|
||||||
|
}`}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
onClose();
|
||||||
|
item.onSelect();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{item.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>,
|
||||||
|
document.body,
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export { ContextMenu, type ContextMenuItem, type ContextMenuProps } from "./ContextMenu.js";
|
||||||
@@ -0,0 +1,215 @@
|
|||||||
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
import { GRACE_MS, POLL_MS, compareIdentity, createDaemonLinkStore, formatRelease, type Scheduler } from "./DaemonLink.js";
|
||||||
|
import type { DaemonInfo } from "../../types.js";
|
||||||
|
|
||||||
|
function info(commit: string, instance: string, date = "2026-09-02T14:03:11+01:00"): DaemonInfo {
|
||||||
|
return {
|
||||||
|
hostname: "konishi",
|
||||||
|
version: "0.1.0",
|
||||||
|
presets: [],
|
||||||
|
build: { commit, commit_date: date, dirty: false },
|
||||||
|
instance,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Scheduler whose timers only fire when the test says so, recording the
|
||||||
|
* delay each was scheduled with. */
|
||||||
|
function manual(): Scheduler & { run(): void; pending(): number; delays: number[] } {
|
||||||
|
let next = 1;
|
||||||
|
const timers = new Map<number, () => void>();
|
||||||
|
const delays: number[] = [];
|
||||||
|
return {
|
||||||
|
delays,
|
||||||
|
setTimer(fn, ms) {
|
||||||
|
const h = next++;
|
||||||
|
timers.set(h, fn);
|
||||||
|
delays.push(ms);
|
||||||
|
return h;
|
||||||
|
},
|
||||||
|
clearTimer(h) {
|
||||||
|
timers.delete(h);
|
||||||
|
},
|
||||||
|
run() {
|
||||||
|
const due = [...timers.entries()];
|
||||||
|
timers.clear();
|
||||||
|
for (const [, fn] of due) fn();
|
||||||
|
},
|
||||||
|
pending: () => timers.size,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("compareIdentity", () => {
|
||||||
|
it("reads a changed commit as an upgrade", () => {
|
||||||
|
expect(compareIdentity(info("aaa", "i1"), info("bbb", "i2"))).toBe("upgraded");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reads a changed instance on the same commit as a plain restart", () => {
|
||||||
|
expect(compareIdentity(info("aaa", "i1"), info("aaa", "i2"))).toBe("restarted");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reads an unchanged process as no event at all", () => {
|
||||||
|
expect(compareIdentity(info("aaa", "i1"), info("aaa", "i1"))).toBe("same");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to the instance when the build is unknown", () => {
|
||||||
|
// A daemon built outside a checkout reports empty commits; the instance is
|
||||||
|
// still enough to see the restart, and never claims a phantom upgrade.
|
||||||
|
expect(compareIdentity(info("", "i1"), info("", "i2"))).toBe("restarted");
|
||||||
|
expect(compareIdentity(info("", "i1"), info("", "i1"))).toBe("same");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("formatRelease", () => {
|
||||||
|
it("pairs the short hash with a readable date", () => {
|
||||||
|
expect(formatRelease({ commit: "f89be4a716c7", commit_date: "2026-09-02T14:03:11Z", dirty: false })).toBe(
|
||||||
|
"f89be4a716c7 · 2026-09-02",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("marks a dirty build", () => {
|
||||||
|
expect(formatRelease({ commit: "f89be4a", commit_date: "", dirty: true })).toBe("f89be4a (dirty)");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("says so when the release is unknown", () => {
|
||||||
|
expect(formatRelease(undefined)).toBe("unknown release");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("daemon link store", () => {
|
||||||
|
it("starts in the connecting state until the first probe lands", async () => {
|
||||||
|
const store = createDaemonLinkStore({
|
||||||
|
fetchInfo: async () => info("aaa", "i1"),
|
||||||
|
scheduler: manual(),
|
||||||
|
reload: () => {},
|
||||||
|
});
|
||||||
|
expect(store.getState().state).toBe("connecting");
|
||||||
|
await store.getState().probe();
|
||||||
|
expect(store.getState().state).toBe("online");
|
||||||
|
expect(store.getState().build?.commit).toBe("aaa");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("holds the modal back during the grace period, then blocks", async () => {
|
||||||
|
const sched = manual();
|
||||||
|
const store = createDaemonLinkStore({
|
||||||
|
fetchInfo: async () => {
|
||||||
|
throw new Error("down");
|
||||||
|
},
|
||||||
|
scheduler: sched,
|
||||||
|
reload: () => {},
|
||||||
|
graceMs: 1000,
|
||||||
|
});
|
||||||
|
store.getState().noteDisconnected();
|
||||||
|
// Still inside the grace window: the dot goes amber, nothing blocks.
|
||||||
|
expect(store.getState().state).toBe("reconnecting");
|
||||||
|
sched.run();
|
||||||
|
await Promise.resolve();
|
||||||
|
expect(store.getState().state).toBe("down");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("unlocks without reloading when the same build comes back", async () => {
|
||||||
|
const sched = manual();
|
||||||
|
const reload = vi.fn();
|
||||||
|
let reply: DaemonInfo | null = info("aaa", "i1");
|
||||||
|
const store = createDaemonLinkStore({
|
||||||
|
fetchInfo: async () => {
|
||||||
|
if (!reply) throw new Error("down");
|
||||||
|
return reply;
|
||||||
|
},
|
||||||
|
scheduler: sched,
|
||||||
|
reload,
|
||||||
|
});
|
||||||
|
await store.getState().probe();
|
||||||
|
|
||||||
|
reply = null;
|
||||||
|
store.getState().noteDisconnected();
|
||||||
|
sched.run(); // grace expires → blocked, poll scheduled
|
||||||
|
await Promise.resolve();
|
||||||
|
expect(store.getState().state).toBe("down");
|
||||||
|
|
||||||
|
// Same commit, new process: a restart, not an upgrade — the page stays.
|
||||||
|
reply = info("aaa", "i2");
|
||||||
|
await store.getState().probe();
|
||||||
|
expect(reload).not.toHaveBeenCalled();
|
||||||
|
expect(store.getState().state).toBe("online");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reloads the page when the daemon comes back on a new commit", async () => {
|
||||||
|
const sched = manual();
|
||||||
|
const reload = vi.fn();
|
||||||
|
let reply = info("aaa", "i1");
|
||||||
|
const store = createDaemonLinkStore({
|
||||||
|
fetchInfo: async () => reply,
|
||||||
|
scheduler: sched,
|
||||||
|
reload,
|
||||||
|
});
|
||||||
|
await store.getState().probe();
|
||||||
|
|
||||||
|
store.getState().noteDisconnected();
|
||||||
|
reply = info("bbb", "i2");
|
||||||
|
await store.getState().probe();
|
||||||
|
expect(reload).toHaveBeenCalledTimes(1);
|
||||||
|
// Left blocked deliberately: the reload is what ends this page's life, and
|
||||||
|
// flashing an unlocked UI in between would let the user act on stale code.
|
||||||
|
expect(store.getState().state).toBe("updating");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps polling on a scheduled cadence while the daemon is unreachable", async () => {
|
||||||
|
const sched = manual();
|
||||||
|
let attempts = 0;
|
||||||
|
const store = createDaemonLinkStore({
|
||||||
|
fetchInfo: async () => {
|
||||||
|
attempts += 1;
|
||||||
|
throw new Error("down");
|
||||||
|
},
|
||||||
|
scheduler: sched,
|
||||||
|
reload: () => {},
|
||||||
|
});
|
||||||
|
store.getState().noteDisconnected();
|
||||||
|
sched.run(); // grace → first poll
|
||||||
|
await Promise.resolve();
|
||||||
|
await Promise.resolve();
|
||||||
|
const first = attempts;
|
||||||
|
expect(first).toBeGreaterThan(0);
|
||||||
|
sched.run(); // next poll tick
|
||||||
|
await Promise.resolve();
|
||||||
|
await Promise.resolve();
|
||||||
|
expect(attempts).toBeGreaterThan(first);
|
||||||
|
store.getState().stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("notifies subscribers on every state change", async () => {
|
||||||
|
const sched = manual();
|
||||||
|
const seen: string[] = [];
|
||||||
|
const store = createDaemonLinkStore({
|
||||||
|
fetchInfo: async () => info("aaa", "i1"),
|
||||||
|
scheduler: sched,
|
||||||
|
reload: () => {},
|
||||||
|
});
|
||||||
|
store.subscribe((s) => seen.push(s.state));
|
||||||
|
await store.getState().probe();
|
||||||
|
store.getState().noteDisconnected();
|
||||||
|
sched.run();
|
||||||
|
await Promise.resolve();
|
||||||
|
expect(seen).toContain("reconnecting");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("schedules the grace timer and poll at the documented defaults", async () => {
|
||||||
|
const sched = manual();
|
||||||
|
const store = createDaemonLinkStore({
|
||||||
|
fetchInfo: async () => {
|
||||||
|
throw new Error("down");
|
||||||
|
},
|
||||||
|
scheduler: sched,
|
||||||
|
reload: () => {},
|
||||||
|
});
|
||||||
|
expect(GRACE_MS).toBe(1200);
|
||||||
|
expect(POLL_MS).toBe(700);
|
||||||
|
store.getState().noteDisconnected();
|
||||||
|
expect(sched.delays).toEqual([GRACE_MS]);
|
||||||
|
sched.run(); // grace fires → schedules the first poll
|
||||||
|
await Promise.resolve();
|
||||||
|
await Promise.resolve();
|
||||||
|
expect(sched.delays).toEqual([GRACE_MS, POLL_MS]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
// @vitest-environment happy-dom
|
||||||
|
import { cleanup, render } from "@testing-library/react";
|
||||||
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
import { createDaemonLinkStore } from "./DaemonLink.js";
|
||||||
|
import { DaemonLink } from "./DaemonLink.js";
|
||||||
|
|
||||||
|
afterEach(() => cleanup());
|
||||||
|
|
||||||
|
describe("DaemonLink", () => {
|
||||||
|
it("probes once on mount, regardless of the initial connected value", async () => {
|
||||||
|
const fetchInfo = vi.fn().mockResolvedValue({ hostname: "h", version: "1", presets: [] });
|
||||||
|
const store = createDaemonLinkStore({ fetchInfo, scheduler: { setTimer: () => 0, clearTimer: () => {} } });
|
||||||
|
render(<DaemonLink store={store} connected={false} />);
|
||||||
|
await Promise.resolve();
|
||||||
|
expect(fetchInfo).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not treat the initial connected value as a transition", async () => {
|
||||||
|
const store = createDaemonLinkStore({
|
||||||
|
fetchInfo: async () => ({ hostname: "h", version: "1", presets: [] }),
|
||||||
|
scheduler: { setTimer: () => 0, clearTimer: () => {} },
|
||||||
|
});
|
||||||
|
render(<DaemonLink store={store} connected={true} />);
|
||||||
|
await Promise.resolve();
|
||||||
|
// Only the mount probe ran — no extra noteConnected() probe on top of it.
|
||||||
|
expect(store.getState().state).toBe("online");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("forwards a later connected drop to noteDisconnected", async () => {
|
||||||
|
const store = createDaemonLinkStore({
|
||||||
|
fetchInfo: async () => ({ hostname: "h", version: "1", presets: [] }),
|
||||||
|
scheduler: { setTimer: () => 0, clearTimer: () => {} },
|
||||||
|
});
|
||||||
|
const { rerender } = render(<DaemonLink store={store} connected={true} />);
|
||||||
|
await Promise.resolve();
|
||||||
|
expect(store.getState().state).toBe("online");
|
||||||
|
|
||||||
|
rerender(<DaemonLink store={store} connected={false} />);
|
||||||
|
expect(store.getState().state).toBe("reconnecting");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("forwards a later connected recovery to noteConnected", async () => {
|
||||||
|
const fetchInfo = vi.fn().mockResolvedValue({ hostname: "h", version: "1", presets: [] });
|
||||||
|
const store = createDaemonLinkStore({ fetchInfo, scheduler: { setTimer: () => 0, clearTimer: () => {} } });
|
||||||
|
const { rerender } = render(<DaemonLink store={store} connected={true} />);
|
||||||
|
await Promise.resolve();
|
||||||
|
expect(store.getState().state).toBe("online");
|
||||||
|
|
||||||
|
// The socket dropped: noteDisconnected() moves off "online" so the coming
|
||||||
|
// recovery isn't a no-op.
|
||||||
|
rerender(<DaemonLink store={store} connected={false} />);
|
||||||
|
expect(store.getState().state).toBe("reconnecting");
|
||||||
|
fetchInfo.mockClear();
|
||||||
|
|
||||||
|
rerender(<DaemonLink store={store} connected={true} />);
|
||||||
|
await Promise.resolve();
|
||||||
|
expect(fetchInfo).toHaveBeenCalledTimes(1);
|
||||||
|
expect(store.getState().state).toBe("online");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("stops the store's polling on unmount", () => {
|
||||||
|
const store = createDaemonLinkStore({
|
||||||
|
fetchInfo: async () => ({ hostname: "h", version: "1", presets: [] }),
|
||||||
|
});
|
||||||
|
const stop = vi.spyOn(store.getState(), "stop");
|
||||||
|
const { unmount } = render(<DaemonLink store={store} connected={false} />);
|
||||||
|
unmount();
|
||||||
|
expect(stop).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,234 @@
|
|||||||
|
// React port of ../../daemonLink.ts. The link to the daemon behind this page:
|
||||||
|
// is it up, and which release is it? `RestartOverlay` (./RestartOverlay.tsx)
|
||||||
|
// renders what this reports. See docs/RESTART.md for the restart contract.
|
||||||
|
|
||||||
|
import { useEffect, useRef } from "react";
|
||||||
|
import { create } from "zustand";
|
||||||
|
|
||||||
|
import { getDaemonInfo } from "../../api.js";
|
||||||
|
import type { BuildInfo, DaemonInfo } from "../../types.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* - `connecting` — first probe hasn't landed yet.
|
||||||
|
* - `online` — the daemon answered and is the same process, or a resolved new one.
|
||||||
|
* - `reconnecting` — the socket dropped, but within the grace window; a blip
|
||||||
|
* shouldn't throw a modal in the user's face.
|
||||||
|
* - `down` — unreachable past the grace window: the overlay blocks the UI and
|
||||||
|
* the poll runs until it answers.
|
||||||
|
* - `updating` — it answered on a different commit; the page is reloading and
|
||||||
|
* stays blocked until it does.
|
||||||
|
*/
|
||||||
|
export type LinkState = "connecting" | "online" | "reconnecting" | "down" | "updating";
|
||||||
|
|
||||||
|
/** What happened between two `/api/info` replies. */
|
||||||
|
export type RestartKind = "same" | "restarted" | "upgraded";
|
||||||
|
|
||||||
|
/** How long a dropped socket may stay dropped before the UI blocks. Long enough
|
||||||
|
* to ride out a wifi hiccup, short enough that a real restart is visible. */
|
||||||
|
export const GRACE_MS = 1200;
|
||||||
|
/** Cadence of the `/api/info` poll while the daemon is unreachable. */
|
||||||
|
export const POLL_MS = 700;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compare two daemon replies.
|
||||||
|
*
|
||||||
|
* The commit decides first: a different one means new code, which for a browser
|
||||||
|
* means new assets. The per-process `instance` catches the rest — a restart of
|
||||||
|
* the same build, which needs no reload. A daemon built outside a git checkout
|
||||||
|
* reports an empty commit, so an empty-vs-empty pair never counts as an
|
||||||
|
* upgrade; the instance still reports the restart.
|
||||||
|
*/
|
||||||
|
export function compareIdentity(before: DaemonInfo, after: DaemonInfo): RestartKind {
|
||||||
|
const a = before.build?.commit ?? "";
|
||||||
|
const b = after.build?.commit ?? "";
|
||||||
|
if (a && b && a !== b) return "upgraded";
|
||||||
|
if (before.instance && after.instance && before.instance !== after.instance) return "restarted";
|
||||||
|
return "same";
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One line naming a release: `f89be4a716c7 · 2026-09-02`, with `(dirty)` when
|
||||||
|
* the build carried uncommitted changes. Empty builds say so rather than
|
||||||
|
* rendering a blank tooltip. */
|
||||||
|
export function formatRelease(build: BuildInfo | undefined): string {
|
||||||
|
if (!build?.commit) return "unknown release";
|
||||||
|
const day = build.commit_date ? build.commit_date.slice(0, 10) : "";
|
||||||
|
const parts = [build.commit];
|
||||||
|
if (day) parts.push(`· ${day}`);
|
||||||
|
if (build.dirty) parts.push("(dirty)");
|
||||||
|
return parts.join(" ");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Schedules a probe/grace timer; returns a handle `clearTimer` understands. */
|
||||||
|
export interface Scheduler {
|
||||||
|
setTimer(fn: () => void, ms: number): number;
|
||||||
|
clearTimer(handle: number): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const browserScheduler: Scheduler = {
|
||||||
|
setTimer: (fn, ms) => window.setTimeout(fn, ms),
|
||||||
|
clearTimer: (h) => window.clearTimeout(h),
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface DaemonLinkState {
|
||||||
|
state: LinkState;
|
||||||
|
/** The release the daemon last reported. Undefined until the first probe,
|
||||||
|
* and on a daemon too old to carry one. */
|
||||||
|
build?: BuildInfo;
|
||||||
|
/** Hostname the daemon reports, for the indicator's tooltip. */
|
||||||
|
hostname?: string;
|
||||||
|
/** How many probes have failed in the current outage — the overlay counts
|
||||||
|
* attempts so a long restart doesn't look frozen. */
|
||||||
|
attempts: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DaemonLinkActions {
|
||||||
|
/** One `/api/info` round-trip. Resolves `true` when the daemon answered. */
|
||||||
|
probe(): Promise<boolean>;
|
||||||
|
/** The events socket dropped. Starts the grace timer and the poll. */
|
||||||
|
noteDisconnected(): void;
|
||||||
|
/** The events socket came back. It carries no identity of its own, so the
|
||||||
|
* state only clears once a probe confirms *which* daemon answered — a
|
||||||
|
* reconnect to an upgraded daemon must reload, not resume. */
|
||||||
|
noteConnected(): void;
|
||||||
|
/** Stop polling. */
|
||||||
|
stop(): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type DaemonLinkStore = DaemonLinkState & DaemonLinkActions;
|
||||||
|
|
||||||
|
export interface DaemonLinkOptions {
|
||||||
|
/** Injectable for tests; defaults to `GET /api/info`. */
|
||||||
|
fetchInfo?: () => Promise<DaemonInfo>;
|
||||||
|
scheduler?: Scheduler;
|
||||||
|
/** What an upgrade does. Injectable so tests don't navigate. */
|
||||||
|
reload?: () => void;
|
||||||
|
graceMs?: number;
|
||||||
|
pollMs?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Builds an isolated daemon-link store — same shape as
|
||||||
|
* {@link ../../stores/catalogStore.js!createCatalogStore}: both a React hook
|
||||||
|
* and a plain object (`getState`/`subscribe`), so it drives the overlay
|
||||||
|
* whether or not React is the one calling `noteConnected`/`noteDisconnected`. */
|
||||||
|
export function createDaemonLinkStore(opts: DaemonLinkOptions = {}) {
|
||||||
|
const fetchInfo = opts.fetchInfo ?? getDaemonInfo;
|
||||||
|
const scheduler = opts.scheduler ?? browserScheduler;
|
||||||
|
const reload = opts.reload ?? (() => window.location.reload());
|
||||||
|
const graceMs = opts.graceMs ?? GRACE_MS;
|
||||||
|
const pollMs = opts.pollMs ?? POLL_MS;
|
||||||
|
|
||||||
|
let identity: DaemonInfo | null = null;
|
||||||
|
let graceTimer: number | null = null;
|
||||||
|
let pollTimer: number | null = null;
|
||||||
|
|
||||||
|
return create<DaemonLinkStore>((set, get) => {
|
||||||
|
function clearTimers(): void {
|
||||||
|
if (graceTimer !== null) {
|
||||||
|
scheduler.clearTimer(graceTimer);
|
||||||
|
graceTimer = null;
|
||||||
|
}
|
||||||
|
if (pollTimer !== null) {
|
||||||
|
scheduler.clearTimer(pollTimer);
|
||||||
|
pollTimer = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function probe(): Promise<boolean> {
|
||||||
|
let info: DaemonInfo;
|
||||||
|
try {
|
||||||
|
info = await fetchInfo();
|
||||||
|
} catch {
|
||||||
|
set((s) => ({ attempts: s.attempts + 1 }));
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const before = identity;
|
||||||
|
identity = info;
|
||||||
|
if (before && compareIdentity(before, info) === "upgraded") {
|
||||||
|
// Stay blocked through the reload: an unlocked frame here would let the
|
||||||
|
// user drive new server code from the bundle it just replaced.
|
||||||
|
set({ state: "updating", build: info.build, hostname: info.hostname });
|
||||||
|
reload();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
clearTimers();
|
||||||
|
set({ state: "online", build: info.build, hostname: info.hostname, attempts: 0 });
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function poll(): Promise<void> {
|
||||||
|
const state = get().state;
|
||||||
|
if (state === "online" || state === "updating") return;
|
||||||
|
const ok = await probe();
|
||||||
|
if (ok || pollTimer !== null) return;
|
||||||
|
pollTimer = scheduler.setTimer(() => {
|
||||||
|
pollTimer = null;
|
||||||
|
void poll();
|
||||||
|
}, pollMs);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
state: "connecting",
|
||||||
|
build: undefined,
|
||||||
|
hostname: undefined,
|
||||||
|
attempts: 0,
|
||||||
|
|
||||||
|
probe,
|
||||||
|
|
||||||
|
noteDisconnected() {
|
||||||
|
const state = get().state;
|
||||||
|
if (state === "down" || state === "updating") return;
|
||||||
|
set({ state: "reconnecting", attempts: 0 });
|
||||||
|
if (graceTimer === null) {
|
||||||
|
graceTimer = scheduler.setTimer(() => {
|
||||||
|
graceTimer = null;
|
||||||
|
if (get().state === "reconnecting") set({ state: "down" });
|
||||||
|
void poll();
|
||||||
|
}, graceMs);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
noteConnected() {
|
||||||
|
const state = get().state;
|
||||||
|
if (state === "online" || state === "updating") return;
|
||||||
|
void probe();
|
||||||
|
},
|
||||||
|
|
||||||
|
stop: clearTimers,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export type DaemonLinkStoreApi = ReturnType<typeof createDaemonLinkStore>;
|
||||||
|
|
||||||
|
export interface DaemonLinkProps {
|
||||||
|
/** The link store this instance drives — `RestartOverlay` (and, later, the
|
||||||
|
* sidebar's status indicator) read the same one. */
|
||||||
|
store: DaemonLinkStoreApi;
|
||||||
|
/** The catalog store's `connected` field (docs/RESTART.md): the events
|
||||||
|
* socket dying is the earliest signal a restart happened, ahead of any
|
||||||
|
* poll. Pass `useStore(catalogStore, (s) => s.connected)`. */
|
||||||
|
connected: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Renders nothing — the controller that wires a `DaemonLinkStore` to the
|
||||||
|
* catalog's socket state. Mount once near the app root; `RestartOverlay`
|
||||||
|
* reads the store it drives.
|
||||||
|
*/
|
||||||
|
export function DaemonLink({ store, connected }: DaemonLinkProps): null {
|
||||||
|
const mounted = useRef(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!mounted.current) {
|
||||||
|
mounted.current = true;
|
||||||
|
void store.getState().probe();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (connected) store.getState().noteConnected();
|
||||||
|
else store.getState().noteDisconnected();
|
||||||
|
}, [store, connected]);
|
||||||
|
|
||||||
|
useEffect(() => () => store.getState().stop(), [store]);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
// @vitest-environment happy-dom
|
||||||
|
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
|
||||||
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
import { createDaemonLinkStore, type DaemonLinkStoreApi } from "./DaemonLink.js";
|
||||||
|
import { RestartOverlay } from "./RestartOverlay.js";
|
||||||
|
|
||||||
|
afterEach(() => cleanup());
|
||||||
|
|
||||||
|
function storeAt(state: "connecting" | "online" | "reconnecting" | "down" | "updating"): DaemonLinkStoreApi {
|
||||||
|
const store = createDaemonLinkStore({ scheduler: { setTimer: () => 0, clearTimer: () => {} } });
|
||||||
|
store.setState({ state });
|
||||||
|
return store;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("RestartOverlay", () => {
|
||||||
|
it("renders nothing while connecting, online, or reconnecting", () => {
|
||||||
|
for (const state of ["connecting", "online", "reconnecting"] as const) {
|
||||||
|
const { unmount } = render(<RestartOverlay store={storeAt(state)} />);
|
||||||
|
expect(screen.queryByRole("alertdialog")).toBeNull();
|
||||||
|
unmount();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("blocks with a restarting message when down", () => {
|
||||||
|
render(<RestartOverlay store={storeAt("down")} />);
|
||||||
|
const dialog = screen.getByRole("alertdialog");
|
||||||
|
expect(dialog.textContent).toContain("Maestro is restarting");
|
||||||
|
expect(dialog.textContent).toContain("Waiting for the daemon to come back…");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("counts attempts once more than one probe has failed", () => {
|
||||||
|
const store = storeAt("down");
|
||||||
|
store.setState({ attempts: 3 });
|
||||||
|
render(<RestartOverlay store={store} />);
|
||||||
|
expect(screen.getByRole("alertdialog").textContent).toContain("(3 attempts)");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("says it is reloading when updating", () => {
|
||||||
|
render(<RestartOverlay store={storeAt("updating")} />);
|
||||||
|
const dialog = screen.getByRole("alertdialog");
|
||||||
|
expect(dialog.textContent).toContain("Maestro was updated");
|
||||||
|
expect(dialog.textContent).toContain("Reloading this page onto the new release…");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("names the release from the store's build", () => {
|
||||||
|
const store = storeAt("down");
|
||||||
|
store.setState({ build: { commit: "f89be4a", commit_date: "2026-09-02T00:00:00Z", dirty: false } });
|
||||||
|
render(<RestartOverlay store={store} />);
|
||||||
|
expect(screen.getByRole("alertdialog").textContent).toContain("f89be4a · 2026-09-02");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("clears the moment the store reports the link is back", () => {
|
||||||
|
const store = storeAt("down");
|
||||||
|
const { rerender } = render(<RestartOverlay store={store} />);
|
||||||
|
expect(screen.queryByRole("alertdialog")).not.toBeNull();
|
||||||
|
store.setState({ state: "online" });
|
||||||
|
rerender(<RestartOverlay store={store} />);
|
||||||
|
expect(screen.queryByRole("alertdialog")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("swallows keyboard shortcuts while blocking, but not once cleared", () => {
|
||||||
|
const store = storeAt("down");
|
||||||
|
const handler = vi.fn();
|
||||||
|
window.addEventListener("keydown", handler);
|
||||||
|
const { rerender } = render(<RestartOverlay store={store} />);
|
||||||
|
|
||||||
|
fireEvent.keyDown(window, { key: "n" });
|
||||||
|
expect(handler).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
store.setState({ state: "online" });
|
||||||
|
rerender(<RestartOverlay store={store} />);
|
||||||
|
fireEvent.keyDown(window, { key: "n" });
|
||||||
|
expect(handler).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
window.removeEventListener("keydown", handler);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
// React port of ../../restartOverlay.ts. Blocking overlay for a daemon that
|
||||||
|
// went away — see docs/RESTART.md.
|
||||||
|
//
|
||||||
|
// Rendered while the link is `down` or `updating`, gone the moment it is
|
||||||
|
// back. It owns no polling of its own: `DaemonLink` polls, this only renders
|
||||||
|
// what the store reports.
|
||||||
|
|
||||||
|
import { useEffect } from "react";
|
||||||
|
import { useStore } from "zustand";
|
||||||
|
|
||||||
|
import { formatRelease, type DaemonLinkStoreApi } from "./DaemonLink.js";
|
||||||
|
|
||||||
|
export interface RestartOverlayProps {
|
||||||
|
/** The same store a sibling `DaemonLink` drives. */
|
||||||
|
store: DaemonLinkStoreApi;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The poll runs while this is up; saying how many tries have gone by is what
|
||||||
|
* separates "still restarting" from "wedged" for someone staring at it. */
|
||||||
|
function attemptText(attempts: number): string {
|
||||||
|
if (attempts <= 1) return "Waiting for the daemon to come back…";
|
||||||
|
return `Waiting for the daemon to come back… (${attempts} attempts)`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function swallow(e: KeyboardEvent): void {
|
||||||
|
e.stopImmediatePropagation();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RestartOverlay({ store }: RestartOverlayProps) {
|
||||||
|
const state = useStore(store, (s) => s.state);
|
||||||
|
const build = useStore(store, (s) => s.build);
|
||||||
|
const attempts = useStore(store, (s) => s.attempts);
|
||||||
|
|
||||||
|
const blocking = state === "down" || state === "updating";
|
||||||
|
|
||||||
|
// The app's shortcuts live on `window`, so hiding the UI behind a backdrop
|
||||||
|
// does not stop them: `n` would still open a spawn form against a daemon
|
||||||
|
// that is not there. Swallow keys in the capture phase until the link is
|
||||||
|
// back, without `preventDefault` so the browser's own chords still work.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!blocking) return;
|
||||||
|
window.addEventListener("keydown", swallow, true);
|
||||||
|
return () => window.removeEventListener("keydown", swallow, true);
|
||||||
|
}, [blocking]);
|
||||||
|
|
||||||
|
if (!blocking) return null;
|
||||||
|
|
||||||
|
const updating = state === "updating";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="modal-backdrop restart-backdrop">
|
||||||
|
<div className="modal restart-modal" role="alertdialog" aria-modal="true" aria-labelledby="restart-title">
|
||||||
|
<div className="restart-spinner" aria-hidden="true" />
|
||||||
|
<h2 id="restart-title">{updating ? "Maestro was updated" : "Maestro is restarting"}</h2>
|
||||||
|
<p className="restart-detail">
|
||||||
|
{updating ? "Reloading this page onto the new release…" : attemptText(attempts)}
|
||||||
|
</p>
|
||||||
|
<p className="restart-release">{formatRelease(build)}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
// @vitest-environment happy-dom
|
||||||
|
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
|
||||||
|
import { afterEach, describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
import { keyCaps } from "../../commands.js";
|
||||||
|
import { groupedCommands } from "../../help.js";
|
||||||
|
import { HelpOverlay } from "./HelpOverlay.js";
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
cleanup();
|
||||||
|
});
|
||||||
|
|
||||||
|
function press(key: string, init: KeyboardEventInit = {}): void {
|
||||||
|
fireEvent.keyDown(window, { key, ...init });
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("HelpOverlay", () => {
|
||||||
|
it("opens on ? and closes on a second ?", () => {
|
||||||
|
render(<HelpOverlay />);
|
||||||
|
expect(screen.queryByRole("dialog")).toBeNull();
|
||||||
|
press("?", { shiftKey: true });
|
||||||
|
expect(screen.queryByRole("dialog")).not.toBeNull();
|
||||||
|
press("?", { shiftKey: true });
|
||||||
|
expect(screen.queryByRole("dialog")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ignores ? while typing and when a non-shift modifier is held", () => {
|
||||||
|
render(<HelpOverlay />);
|
||||||
|
const input = document.createElement("input");
|
||||||
|
document.body.appendChild(input);
|
||||||
|
fireEvent.keyDown(input, { key: "?", shiftKey: true });
|
||||||
|
expect(screen.queryByRole("dialog")).toBeNull();
|
||||||
|
|
||||||
|
press("?", { ctrlKey: true });
|
||||||
|
expect(screen.queryByRole("dialog")).toBeNull();
|
||||||
|
input.remove();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("closes on Escape, on the close button, and on a click outside the panel", () => {
|
||||||
|
render(<HelpOverlay />);
|
||||||
|
|
||||||
|
press("?", { shiftKey: true });
|
||||||
|
press("Escape");
|
||||||
|
expect(screen.queryByRole("dialog")).toBeNull();
|
||||||
|
|
||||||
|
press("?", { shiftKey: true });
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Close" }));
|
||||||
|
expect(screen.queryByRole("dialog")).toBeNull();
|
||||||
|
|
||||||
|
press("?", { shiftKey: true });
|
||||||
|
fireEvent.click(screen.getByRole("dialog"));
|
||||||
|
expect(screen.queryByRole("dialog")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("stays open for a click inside the panel", () => {
|
||||||
|
render(<HelpOverlay />);
|
||||||
|
press("?", { shiftKey: true });
|
||||||
|
fireEvent.click(document.querySelector(".help-panel") as HTMLElement);
|
||||||
|
expect(screen.queryByRole("dialog")).not.toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders exactly the commands the registry has bound, grouped", () => {
|
||||||
|
render(<HelpOverlay />);
|
||||||
|
press("?", { shiftKey: true });
|
||||||
|
|
||||||
|
const groups = groupedCommands();
|
||||||
|
expect([...document.querySelectorAll(".help-group-title")].map((h) => h.textContent)).toEqual(
|
||||||
|
groups.map((g) => g.title),
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
[...document.querySelectorAll<HTMLElement>(".help-row")].map((r) => r.dataset.commandId),
|
||||||
|
).toEqual(groups.flatMap((g) => g.items.map((c) => c.id)));
|
||||||
|
expect([...document.querySelectorAll(".help-desc")].map((d) => d.textContent)).toEqual(
|
||||||
|
groups.flatMap((g) => g.items.map((c) => c.label)),
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
[...document.querySelectorAll(".help-row")].map((r) =>
|
||||||
|
[...r.querySelectorAll("kbd")].map((k) => k.textContent).join(" "),
|
||||||
|
),
|
||||||
|
).toEqual(groups.flatMap((g) => g.items.map((c) => keyCaps(c.keys[0]).join(" "))));
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
// React port of ../../help.ts: the `?` overlay renders the command registry
|
||||||
|
// (commands.ts) directly — the same list that dispatches the keys — so a
|
||||||
|
// binding can never be documented as one thing and do another.
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { createPortal } from "react-dom";
|
||||||
|
|
||||||
|
import { CommandSet, defineCommands, keyCaps } from "../../commands.js";
|
||||||
|
import { groupedCommands } from "../../help.js";
|
||||||
|
|
||||||
|
export function HelpOverlay() {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const helpKeys = new CommandSet(defineCommands({ "help.toggle": () => setOpen((o) => !o) }));
|
||||||
|
const overlayKeys = new CommandSet(defineCommands({ "help.close": () => setOpen(false) }));
|
||||||
|
const onKeydown = (e: KeyboardEvent) => {
|
||||||
|
if (open && overlayKeys.handle(e)) return;
|
||||||
|
helpKeys.handle(e);
|
||||||
|
};
|
||||||
|
window.addEventListener("keydown", onKeydown);
|
||||||
|
return () => window.removeEventListener("keydown", onKeydown);
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
|
if (!open) return null;
|
||||||
|
|
||||||
|
const close = () => setOpen(false);
|
||||||
|
|
||||||
|
return createPortal(
|
||||||
|
<div
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-label="Keyboard shortcuts"
|
||||||
|
className="help-overlay fixed inset-0 z-modal flex items-center justify-center bg-bg/70"
|
||||||
|
onClick={(e) => {
|
||||||
|
if (e.target === e.currentTarget) close();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="help-panel max-h-[80vh] w-full max-w-[560px] overflow-auto rounded-r-3 border border-border bg-surface p-sp-4 shadow-2">
|
||||||
|
<header className="help-head flex items-center justify-between">
|
||||||
|
<h2 className="help-title m-0 text-fs-15 font-semibold text-ink">Keyboard shortcuts</h2>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label="Close"
|
||||||
|
className="help-close rounded-r-1 px-sp-2 py-1 text-fs-15 text-muted hover:text-ink"
|
||||||
|
onClick={close}
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
</header>
|
||||||
|
{groupedCommands().map((group) => (
|
||||||
|
<section key={group.title} className="help-group mt-sp-3">
|
||||||
|
<h3 className="help-group-title text-fs-12 font-semibold text-muted">{group.title}</h3>
|
||||||
|
<dl className="help-list mt-sp-1">
|
||||||
|
{group.items.map((cmd) => (
|
||||||
|
<div
|
||||||
|
key={cmd.id}
|
||||||
|
className="help-row flex items-center justify-between gap-sp-3 py-sp-1"
|
||||||
|
data-command-id={cmd.id}
|
||||||
|
>
|
||||||
|
<dt className="help-keys m-0 flex gap-sp-1">
|
||||||
|
{keyCaps(cmd.keys[0]).map((cap, i) => (
|
||||||
|
<kbd key={i} className="rounded-r-1 border border-border px-sp-1 text-fs-11">
|
||||||
|
{cap}
|
||||||
|
</kbd>
|
||||||
|
))}
|
||||||
|
</dt>
|
||||||
|
<dd className="help-desc m-0 text-fs-12 text-ink">{cmd.label}</dd>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</dl>
|
||||||
|
</section>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>,
|
||||||
|
document.body,
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
// @vitest-environment happy-dom
|
||||||
|
import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||||
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
import { KillDialog } from "./KillDialog.js";
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
cleanup();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("KillDialog", () => {
|
||||||
|
it("focuses the kill button, so Enter confirms", () => {
|
||||||
|
render(<KillDialog name="alpha" blockers={null} onConfirm={() => Promise.resolve()} onCancel={vi.fn()} />);
|
||||||
|
expect(document.activeElement).toBe(screen.getByRole("button", { name: "Kill" }));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("focuses the force button too", () => {
|
||||||
|
render(
|
||||||
|
<KillDialog
|
||||||
|
name="alpha"
|
||||||
|
blockers={null}
|
||||||
|
force
|
||||||
|
reason="worktree is dirty."
|
||||||
|
onConfirm={() => Promise.resolve()}
|
||||||
|
onCancel={vi.fn()}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
expect(document.activeElement).toBe(screen.getByRole("button", { name: "Force kill" }));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("names the blockers in the confirm body", () => {
|
||||||
|
render(
|
||||||
|
<KillDialog
|
||||||
|
name="alpha"
|
||||||
|
blockers={{ uncommitted_files: 2, untracked_files: 0, unpushed_commits: 0 }}
|
||||||
|
onConfirm={() => Promise.resolve()}
|
||||||
|
onCancel={vi.fn()}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
expect(screen.getByText(/will be refused unless it is forced/)).not.toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("cancels on Cancel, on Escape and on a backdrop click", () => {
|
||||||
|
const onCancel = vi.fn();
|
||||||
|
const first = render(<KillDialog name="alpha" blockers={null} onConfirm={() => Promise.resolve()} onCancel={onCancel} />);
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Cancel" }));
|
||||||
|
expect(onCancel).toHaveBeenCalledTimes(1);
|
||||||
|
first.unmount();
|
||||||
|
|
||||||
|
const second = render(<KillDialog name="alpha" blockers={null} onConfirm={() => Promise.resolve()} onCancel={onCancel} />);
|
||||||
|
fireEvent.keyDown(screen.getByRole("dialog"), { key: "Escape" });
|
||||||
|
expect(onCancel).toHaveBeenCalledTimes(2);
|
||||||
|
second.unmount();
|
||||||
|
|
||||||
|
render(<KillDialog name="alpha" blockers={null} onConfirm={() => Promise.resolve()} onCancel={onCancel} />);
|
||||||
|
fireEvent.click(screen.getByRole("dialog"));
|
||||||
|
expect(onCancel).toHaveBeenCalledTimes(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("disables the button while pending and shows a rejection inline, so the press can be retried", async () => {
|
||||||
|
let reject: (e: Error) => void = () => {};
|
||||||
|
const onConfirm = vi.fn(() => new Promise<void>((_, r) => (reject = r)));
|
||||||
|
render(<KillDialog name="alpha" blockers={null} onConfirm={onConfirm} onCancel={vi.fn()} />);
|
||||||
|
|
||||||
|
const kill = screen.getByRole("button", { name: "Kill" }) as HTMLButtonElement;
|
||||||
|
fireEvent.click(kill);
|
||||||
|
expect(onConfirm).toHaveBeenCalledOnce();
|
||||||
|
expect(kill.disabled).toBe(true);
|
||||||
|
|
||||||
|
reject(new Error("daemon unreachable"));
|
||||||
|
await waitFor(() => expect(kill.disabled).toBe(false));
|
||||||
|
expect(screen.getByText("Failed to kill: daemon unreachable")).not.toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
// React port of ../../killDialog.ts: the two kill prompts (#81, #160). The
|
||||||
|
// daemon refuses an unforced kill over a worktree holding unsaved work; the
|
||||||
|
// same component in `force` mode is the override that follows that refusal.
|
||||||
|
|
||||||
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
import { createPortal } from "react-dom";
|
||||||
|
|
||||||
|
import { formatInTheWay, type InTheWay } from "../../types.js";
|
||||||
|
|
||||||
|
export interface KillDialogProps {
|
||||||
|
/** Session name, as the sidebar shows it. */
|
||||||
|
name: string;
|
||||||
|
/** What the card last saw in the way, when anything was. */
|
||||||
|
blockers: InTheWay | null;
|
||||||
|
/** Set once the daemon has refused an unforced kill: this pass forces it,
|
||||||
|
* and `reason` carries the daemon's refusal message. */
|
||||||
|
force?: boolean;
|
||||||
|
/** Message from the daemon's refusal, shown when there are no blockers to
|
||||||
|
* spell out. */
|
||||||
|
reason?: string;
|
||||||
|
/** Run the kill (or the forced kill). A rejection is rendered inline, and
|
||||||
|
* the dialog stays open so the press can be retried. */
|
||||||
|
onConfirm(): Promise<void>;
|
||||||
|
onCancel(): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function KillDialog(props: KillDialogProps) {
|
||||||
|
const { name, blockers, force, reason, onConfirm, onCancel } = props;
|
||||||
|
const confirmRef = useRef<HTMLButtonElement>(null);
|
||||||
|
const [pending, setPending] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// The acting button takes the focus in both modes: the operator already
|
||||||
|
// asked for the kill, so Enter confirms it.
|
||||||
|
useEffect(() => confirmRef.current?.focus(), []);
|
||||||
|
|
||||||
|
const handleConfirm = () => {
|
||||||
|
setPending(true);
|
||||||
|
setError(null);
|
||||||
|
void onConfirm().catch((e: unknown) => {
|
||||||
|
setPending(false);
|
||||||
|
setError(`Failed to kill: ${(e as Error).message}`);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const body = blockers
|
||||||
|
? force
|
||||||
|
? `Its worktree still has ${formatInTheWay(blockers)}. Forcing the kill removes the session and leaves the worktree on disk.`
|
||||||
|
: `Its worktree still has ${formatInTheWay(blockers)}. The kill will be refused unless it is forced.`
|
||||||
|
: force
|
||||||
|
? `${reason ?? ""} Forcing the kill removes the session and leaves the worktree on disk.`
|
||||||
|
: "The session and everything running in it go away. Its worktree stays on disk.";
|
||||||
|
|
||||||
|
return createPortal(
|
||||||
|
<div
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-label={`Kill ${name}`}
|
||||||
|
className="kill-dialog fixed inset-0 z-modal flex items-center justify-center bg-bg/70"
|
||||||
|
onClick={(e) => {
|
||||||
|
if (e.target === e.currentTarget) onCancel();
|
||||||
|
}}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Escape") onCancel();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="kill-dialog-panel min-w-[280px] max-w-[420px] rounded-r-2 border border-border-strong bg-surface p-sp-4 shadow-2">
|
||||||
|
<h2 className="kill-dialog-title m-0 text-fs-15 font-semibold text-ink">Kill "{name}"?</h2>
|
||||||
|
<p className="kill-dialog-body mt-sp-2 text-fs-13 text-ink">{body}</p>
|
||||||
|
{error && <p className="kill-dialog-error mt-sp-2 text-fs-12 text-danger">{error}</p>}
|
||||||
|
<div className="kill-dialog-actions mt-sp-3 flex justify-end gap-sp-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="kill-dialog-cancel rounded-r-1 border border-border px-sp-3 py-sp-1 text-fs-12 text-ink"
|
||||||
|
onClick={onCancel}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
ref={confirmRef}
|
||||||
|
type="button"
|
||||||
|
disabled={pending}
|
||||||
|
className={`rounded-r-1 bg-danger px-sp-3 py-sp-1 text-fs-12 text-accent-ink disabled:opacity-60 ${
|
||||||
|
force ? "kill-dialog-force" : "kill-dialog-kill"
|
||||||
|
}`}
|
||||||
|
onClick={handleConfirm}
|
||||||
|
>
|
||||||
|
{force ? "Force kill" : "Kill"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>,
|
||||||
|
document.body,
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
export { KillDialog, type KillDialogProps } from "./KillDialog.js";
|
||||||
|
export { HelpOverlay } from "./HelpOverlay.js";
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
// @vitest-environment happy-dom
|
||||||
|
import { cleanup, render } from "@testing-library/react";
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
import { useViewport } from "./useViewport.js";
|
||||||
|
|
||||||
|
function Probe() {
|
||||||
|
useViewport();
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
class FakeVisualViewport {
|
||||||
|
height = 800;
|
||||||
|
offsetTop = 0;
|
||||||
|
private listeners: Record<string, Array<() => void>> = { resize: [], scroll: [] };
|
||||||
|
|
||||||
|
addEventListener(type: string, cb: () => void): void {
|
||||||
|
this.listeners[type].push(cb);
|
||||||
|
}
|
||||||
|
removeEventListener(type: string, cb: () => void): void {
|
||||||
|
this.listeners[type] = this.listeners[type].filter((l) => l !== cb);
|
||||||
|
}
|
||||||
|
fire(type: "resize" | "scroll"): void {
|
||||||
|
for (const l of this.listeners[type]) l();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let vv: FakeVisualViewport;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vv = new FakeVisualViewport();
|
||||||
|
vi.stubGlobal("visualViewport", vv);
|
||||||
|
vi.stubGlobal("requestAnimationFrame", (cb: FrameRequestCallback) => {
|
||||||
|
cb(0);
|
||||||
|
return 0;
|
||||||
|
});
|
||||||
|
vi.stubGlobal("cancelAnimationFrame", () => {});
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
cleanup();
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
document.documentElement.className = "";
|
||||||
|
document.documentElement.removeAttribute("style");
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("useViewport", () => {
|
||||||
|
it("mirrors the visual viewport's height and offset onto CSS custom properties", () => {
|
||||||
|
vv.height = 640;
|
||||||
|
vv.offsetTop = 12;
|
||||||
|
render(<Probe />);
|
||||||
|
const root = document.documentElement.style;
|
||||||
|
expect(root.getPropertyValue("--app-vh")).toBe("640px");
|
||||||
|
expect(root.getPropertyValue("--app-top")).toBe("12px");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("no-ops when visualViewport is unavailable", () => {
|
||||||
|
vi.stubGlobal("visualViewport", undefined);
|
||||||
|
expect(() => render(<Probe />)).not.toThrow();
|
||||||
|
expect(document.documentElement.style.getPropertyValue("--app-vh")).toBe("");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("marks kb-open once an editable shrinks the viewport well past the baseline", () => {
|
||||||
|
render(<Probe />);
|
||||||
|
const input = document.createElement("input");
|
||||||
|
document.body.appendChild(input);
|
||||||
|
input.focus();
|
||||||
|
|
||||||
|
vv.height = 800 - 200;
|
||||||
|
vv.fire("resize");
|
||||||
|
expect(document.documentElement.classList.contains("kb-open")).toBe(true);
|
||||||
|
|
||||||
|
input.blur();
|
||||||
|
vv.height = 800;
|
||||||
|
vv.fire("resize");
|
||||||
|
expect(document.documentElement.classList.contains("kb-open")).toBe(false);
|
||||||
|
input.remove();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
// React port of ../../viewport.ts. Binds the app shell's height and position
|
||||||
|
// to the *visual* viewport rather than the layout one, so a pinned bottom bar
|
||||||
|
// (the attach view's special-key bar, NAP-342) lands above an open on-screen
|
||||||
|
// keyboard instead of behind it. See viewport.ts for the full mechanism.
|
||||||
|
|
||||||
|
import { useEffect } from "react";
|
||||||
|
|
||||||
|
const KB_OPEN_THRESHOLD = 150;
|
||||||
|
|
||||||
|
export function useViewport(): void {
|
||||||
|
useEffect(() => {
|
||||||
|
const vv = window.visualViewport;
|
||||||
|
if (!vv) return;
|
||||||
|
|
||||||
|
const root = document.documentElement;
|
||||||
|
let raf = 0;
|
||||||
|
let baseline = vv.height;
|
||||||
|
|
||||||
|
const isEditing = () => {
|
||||||
|
const a = document.activeElement as HTMLElement | null;
|
||||||
|
if (!a) return false;
|
||||||
|
return a.tagName === "TEXTAREA" || a.tagName === "INPUT" || a.isContentEditable;
|
||||||
|
};
|
||||||
|
|
||||||
|
const sync = () => {
|
||||||
|
raf = 0;
|
||||||
|
root.style.setProperty("--app-vh", `${Math.round(vv.height)}px`);
|
||||||
|
root.style.setProperty("--app-top", `${Math.round(vv.offsetTop)}px`);
|
||||||
|
|
||||||
|
// Nothing focused ⇒ keyboard can't be open ⇒ this height is the baseline.
|
||||||
|
const editing = isEditing();
|
||||||
|
if (!editing) baseline = vv.height;
|
||||||
|
const kbOpen = editing && baseline - vv.height > KB_OPEN_THRESHOLD;
|
||||||
|
root.classList.toggle("kb-open", kbOpen);
|
||||||
|
};
|
||||||
|
const schedule = () => {
|
||||||
|
if (raf) return;
|
||||||
|
raf = requestAnimationFrame(sync);
|
||||||
|
};
|
||||||
|
|
||||||
|
vv.addEventListener("resize", schedule);
|
||||||
|
vv.addEventListener("scroll", schedule);
|
||||||
|
sync();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
vv.removeEventListener("resize", schedule);
|
||||||
|
vv.removeEventListener("scroll", schedule);
|
||||||
|
if (raf) cancelAnimationFrame(raf);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
}
|
||||||
@@ -0,0 +1,154 @@
|
|||||||
|
// @vitest-environment happy-dom
|
||||||
|
import { act, cleanup, fireEvent, render } from "@testing-library/react";
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
import { OPEN_SPAWN_EVENT, SPAWN_SIMILAR_EVENT } from "../../catalog.js";
|
||||||
|
import { TOGGLE_DRAWER_EVENT } from "../../drawer.js";
|
||||||
|
import { Drawer } from "./Drawer.js";
|
||||||
|
|
||||||
|
/** Force the mobile media query on, whatever happy-dom's viewport says
|
||||||
|
* (mirrors ../layout/MultiView's own `forceMobile` in multiview.test.ts). */
|
||||||
|
function forceMobile(matches: boolean): void {
|
||||||
|
vi.spyOn(window, "matchMedia").mockImplementation(
|
||||||
|
(query: string) =>
|
||||||
|
({
|
||||||
|
matches: query.includes("859") && matches,
|
||||||
|
media: query,
|
||||||
|
onchange: null,
|
||||||
|
addEventListener() {},
|
||||||
|
removeEventListener() {},
|
||||||
|
addListener() {},
|
||||||
|
removeListener() {},
|
||||||
|
dispatchEvent: () => false,
|
||||||
|
}) as unknown as MediaQueryList,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function shell(): HTMLElement {
|
||||||
|
return document.querySelector(".app-shell") as HTMLElement;
|
||||||
|
}
|
||||||
|
|
||||||
|
function touch(type: "touchstart" | "touchend", x: number, y: number): void {
|
||||||
|
const touches = [{ clientX: x, clientY: y }];
|
||||||
|
const event = new Event(type, { bubbles: true }) as unknown as {
|
||||||
|
touches: typeof touches;
|
||||||
|
changedTouches: typeof touches;
|
||||||
|
};
|
||||||
|
event.touches = touches;
|
||||||
|
event.changedTouches = touches;
|
||||||
|
act(() => {
|
||||||
|
document.dispatchEvent(event as unknown as Event);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => forceMobile(true));
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
cleanup();
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
function openViaToggle(): void {
|
||||||
|
act(() => {
|
||||||
|
document.dispatchEvent(new CustomEvent(TOGGLE_DRAWER_EVENT));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("Drawer (#35)", () => {
|
||||||
|
it("toggles open on the toggle event, off on desktop by class alone", () => {
|
||||||
|
render(
|
||||||
|
<Drawer sidebar={<div className="tree-leaf">row</div>}>
|
||||||
|
<div>content</div>
|
||||||
|
</Drawer>,
|
||||||
|
);
|
||||||
|
expect(shell().classList.contains("drawer-open")).toBe(false);
|
||||||
|
openViaToggle();
|
||||||
|
expect(shell().classList.contains("drawer-open")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("closes when a session row is tapped", () => {
|
||||||
|
render(
|
||||||
|
<Drawer sidebar={<div className="tree-leaf">row</div>}>
|
||||||
|
<div>content</div>
|
||||||
|
</Drawer>,
|
||||||
|
);
|
||||||
|
openViaToggle();
|
||||||
|
fireEvent.click(document.querySelector(".tree-leaf") as HTMLElement);
|
||||||
|
expect(shell().classList.contains("drawer-open")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("closes when a spawn is requested", () => {
|
||||||
|
render(
|
||||||
|
<Drawer sidebar={<div className="tree-leaf">row</div>}>
|
||||||
|
<div>content</div>
|
||||||
|
</Drawer>,
|
||||||
|
);
|
||||||
|
openViaToggle();
|
||||||
|
act(() => {
|
||||||
|
document.dispatchEvent(new CustomEvent(OPEN_SPAWN_EVENT));
|
||||||
|
});
|
||||||
|
expect(shell().classList.contains("drawer-open")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("closes when spawn similar is requested", () => {
|
||||||
|
render(
|
||||||
|
<Drawer sidebar={<div className="tree-leaf">row</div>}>
|
||||||
|
<div>content</div>
|
||||||
|
</Drawer>,
|
||||||
|
);
|
||||||
|
openViaToggle();
|
||||||
|
act(() => {
|
||||||
|
document.dispatchEvent(new CustomEvent(SPAWN_SIMILAR_EVENT, { detail: {} }));
|
||||||
|
});
|
||||||
|
expect(shell().classList.contains("drawer-open")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("closes on a backdrop click", () => {
|
||||||
|
render(
|
||||||
|
<Drawer sidebar={<div className="tree-leaf">row</div>}>
|
||||||
|
<div>content</div>
|
||||||
|
</Drawer>,
|
||||||
|
);
|
||||||
|
openViaToggle();
|
||||||
|
fireEvent.click(document.querySelector(".mv-backdrop") as HTMLElement);
|
||||||
|
expect(shell().classList.contains("drawer-open")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("opens on a rightward swipe and closes on a leftward one, only on mobile", () => {
|
||||||
|
render(
|
||||||
|
<Drawer sidebar={<div className="tree-leaf">row</div>}>
|
||||||
|
<div>content</div>
|
||||||
|
</Drawer>,
|
||||||
|
);
|
||||||
|
touch("touchstart", 0, 100);
|
||||||
|
touch("touchend", 100, 100);
|
||||||
|
expect(shell().classList.contains("drawer-open")).toBe(true);
|
||||||
|
|
||||||
|
touch("touchstart", 100, 100);
|
||||||
|
touch("touchend", 0, 100);
|
||||||
|
expect(shell().classList.contains("drawer-open")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ignores swipes on desktop", () => {
|
||||||
|
forceMobile(false);
|
||||||
|
render(
|
||||||
|
<Drawer sidebar={<div className="tree-leaf">row</div>}>
|
||||||
|
<div>content</div>
|
||||||
|
</Drawer>,
|
||||||
|
);
|
||||||
|
touch("touchstart", 0, 100);
|
||||||
|
touch("touchend", 100, 100);
|
||||||
|
expect(shell().classList.contains("drawer-open")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ignores a mostly-vertical or too-short gesture", () => {
|
||||||
|
render(
|
||||||
|
<Drawer sidebar={<div className="tree-leaf">row</div>}>
|
||||||
|
<div>content</div>
|
||||||
|
</Drawer>,
|
||||||
|
);
|
||||||
|
touch("touchstart", 0, 0);
|
||||||
|
touch("touchend", 10, 100);
|
||||||
|
expect(shell().classList.contains("drawer-open")).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
// React port of ../../drawer.ts (#35): the mobile off-canvas sidebar. On
|
||||||
|
// desktop the `drawer-open` class is inert — CSS gates the drawer styling to
|
||||||
|
// narrow viewports (styles.css, `.app-shell`).
|
||||||
|
//
|
||||||
|
// Anything that puts a new view in the content area must close the drawer, or
|
||||||
|
// it renders behind the sidebar and the tap reads as a dead button — this
|
||||||
|
// listens for the same TOGGLE_DRAWER_EVENT/OPEN_SPAWN_EVENT/SPAWN_SIMILAR_EVENT
|
||||||
|
// the rest of the shell already raises, so it needs no wiring from callers.
|
||||||
|
|
||||||
|
import type { ReactNode } from "react";
|
||||||
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
|
||||||
|
import { OPEN_SPAWN_EVENT, SPAWN_SIMILAR_EVENT } from "../../catalog.js";
|
||||||
|
import { TOGGLE_DRAWER_EVENT } from "../../drawer.js";
|
||||||
|
|
||||||
|
export interface DrawerProps {
|
||||||
|
sidebar: ReactNode;
|
||||||
|
children: ReactNode;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Drawer({ sidebar, children, className }: DrawerProps) {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const sidebarRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const onToggle = () => setOpen((o) => !o);
|
||||||
|
const onClose = () => setOpen(false);
|
||||||
|
document.addEventListener(TOGGLE_DRAWER_EVENT, onToggle);
|
||||||
|
document.addEventListener(OPEN_SPAWN_EVENT, onClose);
|
||||||
|
document.addEventListener(SPAWN_SIMILAR_EVENT, onClose);
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener(TOGGLE_DRAWER_EVENT, onToggle);
|
||||||
|
document.removeEventListener(OPEN_SPAWN_EVENT, onClose);
|
||||||
|
document.removeEventListener(SPAWN_SIMILAR_EVENT, onClose);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Horizontal swipes drive the drawer (right opens, left closes) rather than
|
||||||
|
// the browser's back/forward navigation. Only a clearly horizontal gesture
|
||||||
|
// past a threshold counts, so vertical terminal scrolling (which owns its
|
||||||
|
// own touch handler) is untouched.
|
||||||
|
useEffect(() => {
|
||||||
|
const mobileMq = window.matchMedia("(max-width: 859px)");
|
||||||
|
let sx = 0;
|
||||||
|
let sy = 0;
|
||||||
|
let tracking = false;
|
||||||
|
|
||||||
|
const onTouchStart = (e: TouchEvent) => {
|
||||||
|
tracking = mobileMq.matches && e.touches.length === 1;
|
||||||
|
if (tracking) {
|
||||||
|
sx = e.touches[0].clientX;
|
||||||
|
sy = e.touches[0].clientY;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const onTouchEnd = (e: TouchEvent) => {
|
||||||
|
if (!tracking) return;
|
||||||
|
tracking = false;
|
||||||
|
const t = e.changedTouches[0];
|
||||||
|
const dx = t.clientX - sx;
|
||||||
|
const dy = t.clientY - sy;
|
||||||
|
if (Math.abs(dx) < 60 || Math.abs(dx) < Math.abs(dy) * 1.5) return;
|
||||||
|
setOpen((o) => {
|
||||||
|
if (dx > 0 && !o) return true;
|
||||||
|
if (dx < 0 && o) return false;
|
||||||
|
return o;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
document.addEventListener("touchstart", onTouchStart, { passive: true });
|
||||||
|
document.addEventListener("touchend", onTouchEnd, { passive: true });
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener("touchstart", onTouchStart);
|
||||||
|
document.removeEventListener("touchend", onTouchEnd);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`app-shell flex h-full min-h-0 ${open ? "drawer-open" : ""} ${className ?? ""}`}>
|
||||||
|
<div
|
||||||
|
ref={sidebarRef}
|
||||||
|
className="sidebar flex h-full min-h-0 flex-col bg-surface"
|
||||||
|
onClick={(e) => {
|
||||||
|
if ((e.target as HTMLElement).closest(".tree-leaf")) setOpen(false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{sidebar}
|
||||||
|
</div>
|
||||||
|
<section className="content min-h-0 flex-1">{children}</section>
|
||||||
|
<div className="mv-backdrop" onClick={() => setOpen(false)} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,152 @@
|
|||||||
|
// @vitest-environment happy-dom
|
||||||
|
import { act, 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();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("preview mode (docs/PREVIEW.md)", () => {
|
||||||
|
it("shows a session no pane holds in a throwaway tab, marked as a preview", () => {
|
||||||
|
const s = store();
|
||||||
|
s.getState().openSession("s1");
|
||||||
|
render(<MultiView store={s} sessions={new Map([["s1", session("s1")]])} />);
|
||||||
|
|
||||||
|
act(() => s.getState().previewSession("s9"));
|
||||||
|
// s1's own tab is still in the strip — the preview never touched it.
|
||||||
|
expect(screen.getByText("Tab 1 · 1")).toBeTruthy();
|
||||||
|
const pane = document.querySelector('[data-session-id="s9"]');
|
||||||
|
expect(pane).toBeTruthy();
|
||||||
|
expect(pane!.querySelector(".bg-preview-tint")).toBeTruthy();
|
||||||
|
expect(screen.getByText("preview")).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("puts the layout back byte for byte once the preview ends", () => {
|
||||||
|
const s = store();
|
||||||
|
s.getState().openSession("s1");
|
||||||
|
render(<MultiView store={s} sessions={new Map([["s1", session("s1")]])} />);
|
||||||
|
const before = JSON.stringify(s.getState().layout);
|
||||||
|
|
||||||
|
act(() => s.getState().previewSession("s9"));
|
||||||
|
act(() => s.getState().previewSession(null));
|
||||||
|
|
||||||
|
expect(JSON.stringify(s.getState().layout)).toBe(before);
|
||||||
|
expect(screen.queryByText("preview")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not grab the keyboard while previewing", () => {
|
||||||
|
const s = store();
|
||||||
|
s.getState().openSession("s1");
|
||||||
|
render(<MultiView store={s} sessions={new Map([["s1", session("s1")]])} />);
|
||||||
|
|
||||||
|
act(() => s.getState().previewSession("s9"));
|
||||||
|
const pane = document.querySelector('[data-session-id="s9"]') as HTMLElement;
|
||||||
|
expect(pane.querySelector('[data-testid="terminal-s9"]')).toBeTruthy();
|
||||||
|
// TerminalPane's `focus` prop is verified by TerminalPane's own tests;
|
||||||
|
// here the preview must never end up as the active-tab's accent pane.
|
||||||
|
expect(pane.className).not.toContain("border-accent");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("clicking into a previewed pane commits it: no more preview, nothing moves a second time", () => {
|
||||||
|
const s = store();
|
||||||
|
s.getState().openSession("s1");
|
||||||
|
render(<MultiView store={s} sessions={new Map([["s1", session("s1")]])} />);
|
||||||
|
|
||||||
|
act(() => s.getState().previewSession("s9"));
|
||||||
|
const pane = document.querySelector('[data-session-id="s9"]') as HTMLElement;
|
||||||
|
fireEvent.pointerDown(pane);
|
||||||
|
|
||||||
|
expect(s.getState().preview).toBeNull();
|
||||||
|
expect(screen.queryByText("preview")).toBeNull();
|
||||||
|
expect(leaves(s.getState().layout.tabs[1].root).map((l) => l.sessionId)).toEqual(["s9"]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,237 @@
|
|||||||
|
// React port of multiview.ts's tab + split tree (issue #249) plus its preview
|
||||||
|
// mode (issue #251, docs/PREVIEW.md). Sidebar drag-to-dock and the mobile
|
||||||
|
// single-pane collapse stay with whichever issue wires this into the shell
|
||||||
|
// (#253) — this renders the tree itself: tabs, nested splits, pane chrome.
|
||||||
|
|
||||||
|
import type { ReactNode } from "react";
|
||||||
|
import { useEffect, useRef } from "react";
|
||||||
|
import { useStore } from "zustand";
|
||||||
|
|
||||||
|
import { activeTab, type Dir, type Node } from "../../layout.js";
|
||||||
|
import { usePreview } from "../../hooks/usePreview.js";
|
||||||
|
import type { LayoutStore, LayoutStoreApi } from "../../stores/layoutStore.js";
|
||||||
|
import type { Session, Uuid } from "../../types.js";
|
||||||
|
import { PaneContainer } from "./PaneContainer.js";
|
||||||
|
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 { previewedSessionId, commit: commitPreview } = usePreview({ store });
|
||||||
|
const isPreviewing = previewedSessionId !== null;
|
||||||
|
|
||||||
|
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 relative min-h-0 flex-1">
|
||||||
|
{tab.root ? (
|
||||||
|
<SplitNode
|
||||||
|
node={tab.root}
|
||||||
|
tab={tab}
|
||||||
|
store={store}
|
||||||
|
sessions={sessions}
|
||||||
|
onDisconnect={onDisconnect}
|
||||||
|
previewedSessionId={previewedSessionId}
|
||||||
|
commitPreview={commitPreview}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
(emptyState ?? (
|
||||||
|
<div className="mv-empty flex h-full items-center justify-center p-sp-4 text-fs-13 text-faint">
|
||||||
|
No session open — pick one from the sidebar, or spawn a new one.
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
{isPreviewing && (
|
||||||
|
<>
|
||||||
|
<div className="mv-preview-ring pointer-events-none absolute inset-0 rounded-r-2 border border-dashed border-preview-ring" />
|
||||||
|
<div className="mv-preview-chip pointer-events-none absolute right-sp-2 top-sp-2 z-sticky rounded-r-pill bg-preview px-sp-2 py-[2px] text-fs-11 text-preview-ink">
|
||||||
|
preview
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TreeProps {
|
||||||
|
tab: { id: string; focusedLeafId: string | null };
|
||||||
|
store: LayoutStoreApi;
|
||||||
|
sessions?: ReadonlyMap<Uuid, Session>;
|
||||||
|
onDisconnect?: (sessionId: Uuid) => void;
|
||||||
|
previewedSessionId: Uuid | null;
|
||||||
|
commitPreview: (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,
|
||||||
|
previewedSessionId,
|
||||||
|
commitPreview,
|
||||||
|
}: TreeProps & { leaf: Extract<Node, { kind: "leaf" }>; isFocused: boolean }) {
|
||||||
|
const session = leaf.sessionId ? sessions?.get(leaf.sessionId) : undefined;
|
||||||
|
const isPreview = leaf.sessionId !== null && leaf.sessionId === previewedSessionId;
|
||||||
|
const splitFocused = (dir: Dir) => store.getState().splitFocused(leaf.id, dir);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={
|
||||||
|
"mv-pane flex h-full min-h-0 min-w-0 flex-col border " +
|
||||||
|
(isFocused && !isPreview ? "border-accent" : "border-border-strong")
|
||||||
|
}
|
||||||
|
data-leaf-id={leaf.id}
|
||||||
|
data-session-id={leaf.sessionId ?? undefined}
|
||||||
|
onPointerDownCapture={() => {
|
||||||
|
// Clicking into a previewed pane is the operator choosing it
|
||||||
|
// (docs/PREVIEW.md).
|
||||||
|
if (isPreview) commitPreview(leaf.sessionId!);
|
||||||
|
store.getState().focusLeaf(leaf.id);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={
|
||||||
|
"mv-pane-header flex items-center justify-between gap-sp-2 border-b border-border-strong px-sp-2 py-sp-1 text-fs-12 " +
|
||||||
|
(isPreview ? "bg-preview-tint text-preview" : "bg-surface")
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{leaf.sessionId ? (
|
||||||
|
<PaneTitle sessionId={leaf.sessionId} session={session} />
|
||||||
|
) : (
|
||||||
|
<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 && previewedSessionId === null}
|
||||||
|
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,102 @@
|
|||||||
|
// 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 previewTempTabId = useStore(store, (s: LayoutStore) => s.preview?.tempTabId ?? null);
|
||||||
|
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;
|
||||||
|
// A preview's throwaway tab is not a place to go — it goes on its
|
||||||
|
// own the moment the preview ends (docs/PREVIEW.md).
|
||||||
|
const isPreviewTab = tab.id === previewTempTabId;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={tab.id}
|
||||||
|
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 " +
|
||||||
|
(isPreviewTab
|
||||||
|
? "border border-dashed border-preview-ring italic text-preview"
|
||||||
|
: isActive
|
||||||
|
? "bg-surface-2 text-ink"
|
||||||
|
: "text-faint hover:text-ink") +
|
||||||
|
(isDropTarget ? " outline outline-2 outline-accent" : "")
|
||||||
|
}
|
||||||
|
onDragStart={(e) => {
|
||||||
|
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={() => {
|
||||||
|
if (isPreviewTab) return;
|
||||||
|
store.getState().selectTab(tab.id);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span>
|
||||||
|
{tab.title}
|
||||||
|
{count ? ` · ${count}` : ""}
|
||||||
|
</span>
|
||||||
|
{layout.tabs.length > 1 && !isPreviewTab && (
|
||||||
|
<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,82 @@
|
|||||||
|
// @vitest-environment happy-dom
|
||||||
|
import { act, cleanup, render, waitFor } from "@testing-library/react";
|
||||||
|
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
import { useHashRouter, type Route, type UseHashRouterResult } from "./useHashRouter.js";
|
||||||
|
|
||||||
|
let controls: UseHashRouterResult;
|
||||||
|
|
||||||
|
function Probe() {
|
||||||
|
controls = useHashRouter();
|
||||||
|
return <div>{controls.route.kind === "attach" ? `attach:${controls.route.sessionId}` : "catalog"}</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
location.hash = "";
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(cleanup);
|
||||||
|
|
||||||
|
describe("useHashRouter", () => {
|
||||||
|
it("starts on catalog for an empty hash", () => {
|
||||||
|
const view = render(<Probe />);
|
||||||
|
expect(view.container.textContent).toBe("catalog");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("resolves #/attach/:id to the attach route", () => {
|
||||||
|
location.hash = "#/attach/s1";
|
||||||
|
const view = render(<Probe />);
|
||||||
|
expect(view.container.textContent).toBe("attach:s1");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("decodes an encoded session id", () => {
|
||||||
|
location.hash = `#/attach/${encodeURIComponent("weird id/x")}`;
|
||||||
|
const view = render(<Probe />);
|
||||||
|
expect(view.container.textContent).toBe("attach:weird id/x");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to catalog for an unknown route", () => {
|
||||||
|
location.hash = "#/nonsense";
|
||||||
|
const view = render(<Probe />);
|
||||||
|
expect(view.container.textContent).toBe("catalog");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("follows a hashchange fired outside the hook", async () => {
|
||||||
|
const view = render(<Probe />);
|
||||||
|
// happy-dom dispatches `hashchange` on a real timer, like a browser — not
|
||||||
|
// synchronously with the assignment.
|
||||||
|
location.hash = "#/attach/s9";
|
||||||
|
await waitFor(() => expect(view.container.textContent).toBe("attach:s9"));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("navigate updates the hash and the route", async () => {
|
||||||
|
const view = render(<Probe />);
|
||||||
|
act(() => controls.navigate({ kind: "attach", sessionId: "s2" } satisfies Route));
|
||||||
|
expect(location.hash).toBe("#/attach/s2");
|
||||||
|
await waitFor(() => expect(view.container.textContent).toBe("attach:s2"));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("navigate back to catalog sets the root hash", async () => {
|
||||||
|
location.hash = "#/attach/s1";
|
||||||
|
const view = render(<Probe />);
|
||||||
|
act(() => controls.navigate({ kind: "catalog" }));
|
||||||
|
expect(location.hash).toBe("#/");
|
||||||
|
await waitFor(() => expect(view.container.textContent).toBe("catalog"));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("navigating to the already-current route still updates state", () => {
|
||||||
|
location.hash = "#/attach/s1";
|
||||||
|
const view = render(<Probe />);
|
||||||
|
act(() => controls.navigate({ kind: "attach", sessionId: "s1" } satisfies Route));
|
||||||
|
expect(view.container.textContent).toBe("attach:s1");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("stops listening after unmount", async () => {
|
||||||
|
const view = render(<Probe />);
|
||||||
|
view.unmount();
|
||||||
|
location.hash = "#/attach/s3";
|
||||||
|
// No assertion beyond "doesn't throw" — a leaked listener would only ever
|
||||||
|
// surface as noise in a later test's act() warnings.
|
||||||
|
await new Promise((r) => setTimeout(r, 0));
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
// React port of main.ts's `parseRoute`/`dispatch` pair (issue #240). Same two
|
||||||
|
// routes, same hash shape (`#/`, `#/attach/:id`) — kept so a deep link or a
|
||||||
|
// bookmark from the vanilla shell still resolves.
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useState } from "react";
|
||||||
|
|
||||||
|
export type Route = { kind: "catalog" } | { kind: "attach"; sessionId: string };
|
||||||
|
|
||||||
|
function parseHash(hash: string): Route {
|
||||||
|
const raw = hash.replace(/^#/, "");
|
||||||
|
if (raw === "" || raw === "/") return { kind: "catalog" };
|
||||||
|
const m = raw.match(/^\/attach\/([^/?#]+)$/);
|
||||||
|
if (m) return { kind: "attach", sessionId: decodeURIComponent(m[1]) };
|
||||||
|
return { kind: "catalog" };
|
||||||
|
}
|
||||||
|
|
||||||
|
function routeToHash(route: Route): string {
|
||||||
|
return route.kind === "catalog" ? "#/" : `#/attach/${encodeURIComponent(route.sessionId)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UseHashRouterResult {
|
||||||
|
route: Route;
|
||||||
|
navigate(route: Route): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useHashRouter(): UseHashRouterResult {
|
||||||
|
const [route, setRoute] = useState<Route>(() => parseHash(location.hash));
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const onHashChange = () => setRoute(parseHash(location.hash));
|
||||||
|
window.addEventListener("hashchange", onHashChange);
|
||||||
|
return () => window.removeEventListener("hashchange", onHashChange);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const navigate = useCallback((next: Route) => {
|
||||||
|
const hash = routeToHash(next);
|
||||||
|
// Setting an unchanged hash never fires `hashchange`, so the state update
|
||||||
|
// that would otherwise ride on that event has to happen here instead.
|
||||||
|
if (location.hash === hash) {
|
||||||
|
setRoute(next);
|
||||||
|
} else {
|
||||||
|
location.hash = hash;
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return { route, navigate };
|
||||||
|
}
|
||||||
@@ -0,0 +1,174 @@
|
|||||||
|
// @vitest-environment happy-dom
|
||||||
|
import { act, cleanup, render } from "@testing-library/react";
|
||||||
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
import { leaves } from "../layout.js";
|
||||||
|
import { createLayoutStore, type LayoutStoreApi } from "../stores/layoutStore.js";
|
||||||
|
import { usePreview, type UsePreviewResult } from "./usePreview.js";
|
||||||
|
|
||||||
|
function fakeScheduler() {
|
||||||
|
const timers = new Map<number, () => void>();
|
||||||
|
let id = 0;
|
||||||
|
return {
|
||||||
|
scheduler: {
|
||||||
|
setTimer: (fn: () => void) => {
|
||||||
|
const h = ++id;
|
||||||
|
timers.set(h, fn);
|
||||||
|
return h;
|
||||||
|
},
|
||||||
|
clearTimer: (h: number) => {
|
||||||
|
timers.delete(h);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
flush() {
|
||||||
|
for (const fn of [...timers.values()]) fn();
|
||||||
|
timers.clear();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Hand-driven clock + timer queue for the preview schedule's own throttle,
|
||||||
|
* independent of the store's save-debounce scheduler above. */
|
||||||
|
function fakeClock() {
|
||||||
|
let now = 1_000;
|
||||||
|
const timers = new Map<number, { at: number; fn: () => void }>();
|
||||||
|
let handle = 1;
|
||||||
|
return {
|
||||||
|
now: () => now,
|
||||||
|
setTimer: (fn: () => void, ms: number) => {
|
||||||
|
const h = handle++;
|
||||||
|
timers.set(h, { at: now + ms, fn });
|
||||||
|
return h;
|
||||||
|
},
|
||||||
|
clearTimer: (h: number) => {
|
||||||
|
timers.delete(h);
|
||||||
|
},
|
||||||
|
advance(ms: number) {
|
||||||
|
now += ms;
|
||||||
|
for (const [h, t] of [...timers]) {
|
||||||
|
if (t.at <= now) {
|
||||||
|
timers.delete(h);
|
||||||
|
act(() => t.fn());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function newStore(): LayoutStoreApi {
|
||||||
|
return createLayoutStore({ load: () => null, save: vi.fn(), scheduler: fakeScheduler().scheduler });
|
||||||
|
}
|
||||||
|
|
||||||
|
let controls: UsePreviewResult;
|
||||||
|
|
||||||
|
function Probe({ store, clock }: { store: LayoutStoreApi; clock: ReturnType<typeof fakeClock> }) {
|
||||||
|
controls = usePreview({
|
||||||
|
store,
|
||||||
|
scheduleDeps: { now: clock.now, setTimer: clock.setTimer, clearTimer: clock.clearTimer },
|
||||||
|
});
|
||||||
|
return <div>{controls.previewedSessionId ?? "none"}</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
afterEach(cleanup);
|
||||||
|
|
||||||
|
describe("usePreview", () => {
|
||||||
|
it("shows the first preview immediately — no settling delay", () => {
|
||||||
|
const store = newStore();
|
||||||
|
store.getState().openSession("s1");
|
||||||
|
const clock = fakeClock();
|
||||||
|
const view = render(<Probe store={store} clock={clock} />);
|
||||||
|
|
||||||
|
act(() => controls.preview("s9"));
|
||||||
|
expect(view.container.textContent).toBe("s9");
|
||||||
|
expect(leaves(store.getState().layout.tabs[1]?.root ?? null).map((l) => l.sessionId)).toEqual(["s9"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("throttles a burst to one leading + one trailing pane swap", () => {
|
||||||
|
const store = newStore();
|
||||||
|
store.getState().openSession("s1");
|
||||||
|
const clock = fakeClock();
|
||||||
|
const view = render(<Probe store={store} clock={clock} />);
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
controls.preview("s1");
|
||||||
|
controls.preview("s8");
|
||||||
|
controls.preview("s9");
|
||||||
|
});
|
||||||
|
expect(view.container.textContent).toBe("s1");
|
||||||
|
clock.advance(120);
|
||||||
|
expect(view.container.textContent).toBe("s9");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("touch never previews", () => {
|
||||||
|
const store = newStore();
|
||||||
|
store.getState().openSession("s1");
|
||||||
|
const clock = fakeClock();
|
||||||
|
const view = render(<Probe store={store} clock={clock} />);
|
||||||
|
|
||||||
|
act(() => controls.previewFromPointer("s9", "touch"));
|
||||||
|
expect(view.container.textContent).toBe("none");
|
||||||
|
expect(store.getState().preview).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("previewFromPointer still previews for mouse/pen", () => {
|
||||||
|
const store = newStore();
|
||||||
|
store.getState().openSession("s1");
|
||||||
|
const clock = fakeClock();
|
||||||
|
const view = render(<Probe store={store} clock={clock} />);
|
||||||
|
|
||||||
|
act(() => controls.previewFromPointer("s9", "mouse"));
|
||||||
|
expect(view.container.textContent).toBe("s9");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("commit cancels a pending trailing preview and opens for real", () => {
|
||||||
|
const store = newStore();
|
||||||
|
store.getState().openSession("s1");
|
||||||
|
const clock = fakeClock();
|
||||||
|
const view = render(<Probe store={store} clock={clock} />);
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
controls.preview("s1"); // leading edge: previews the already-open s1
|
||||||
|
controls.preview("s9"); // queued, gap not elapsed yet
|
||||||
|
controls.commit("s9");
|
||||||
|
});
|
||||||
|
expect(view.container.textContent).toBe("none"); // committed: no longer a preview
|
||||||
|
expect(leaves(store.getState().layout.tabs[0].root).map((l) => l.sessionId)).toEqual(["s9"]);
|
||||||
|
|
||||||
|
clock.advance(120);
|
||||||
|
// The cancelled trailing preview never fires.
|
||||||
|
expect(view.container.textContent).toBe("none");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("end drops a preview immediately, bypassing the throttle", () => {
|
||||||
|
const store = newStore();
|
||||||
|
store.getState().openSession("s1");
|
||||||
|
const clock = fakeClock();
|
||||||
|
const view = render(<Probe store={store} clock={clock} />);
|
||||||
|
|
||||||
|
act(() => controls.preview("s9"));
|
||||||
|
expect(view.container.textContent).toBe("s9");
|
||||||
|
|
||||||
|
act(() => controls.end());
|
||||||
|
expect(view.container.textContent).toBe("none");
|
||||||
|
expect(store.getState().layout.tabs).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("cancels its pending trailing preview on unmount", () => {
|
||||||
|
const store = newStore();
|
||||||
|
store.getState().openSession("s1");
|
||||||
|
store.getState().addTab();
|
||||||
|
store.getState().openSession("s2"); // leading edge below will preview this, already on screen
|
||||||
|
const clock = fakeClock();
|
||||||
|
const view = render(<Probe store={store} clock={clock} />);
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
controls.preview("s2"); // leading edge: fires now
|
||||||
|
controls.preview("s9"); // queued trailing preview
|
||||||
|
});
|
||||||
|
expect(store.getState().preview?.sessionId).toBe("s2");
|
||||||
|
view.unmount();
|
||||||
|
clock.advance(120);
|
||||||
|
// The queued trailing preview must not fire after unmount.
|
||||||
|
expect(store.getState().preview?.sessionId).toBe("s2");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
// React port of previewSchedule.ts + multiview.ts's preview controller
|
||||||
|
// (issue #251). Owns the throttled schedule, hands the layout store the
|
||||||
|
// throttled result, and exposes the three ways a preview ends for real —
|
||||||
|
// see docs/PREVIEW.md, the contract this mirrors.
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useRef } from "react";
|
||||||
|
import { useStore } from "zustand";
|
||||||
|
|
||||||
|
import { createPreviewSchedule, type PreviewScheduleDeps } from "../previewSchedule.js";
|
||||||
|
import type { LayoutStore, LayoutStoreApi } from "../stores/layoutStore.js";
|
||||||
|
import type { Uuid } from "../types.js";
|
||||||
|
|
||||||
|
export interface UsePreviewOptions {
|
||||||
|
store: LayoutStoreApi;
|
||||||
|
/** Test seam for the schedule's clock/timers. */
|
||||||
|
scheduleDeps?: PreviewScheduleDeps;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UsePreviewResult {
|
||||||
|
/** The session currently shown as a preview, or null. */
|
||||||
|
previewedSessionId: Uuid | null;
|
||||||
|
/** Throttled: show `sessionId` in the content pane, or `null` to put back
|
||||||
|
* whatever was on screen (a highlight move, or the highlight leaving
|
||||||
|
* every session). Never delayed on the leading edge. */
|
||||||
|
preview(sessionId: Uuid | null): void;
|
||||||
|
/** Pointer-hover entry point: touch has no hover, so a touch pointer never
|
||||||
|
* previews (docs/PREVIEW.md). */
|
||||||
|
previewFromPointer(sessionId: Uuid | null, pointerType: string): void;
|
||||||
|
/** Choosing the previewed session — click/Enter on its row, click into its
|
||||||
|
* pane, or handing it the keyboard (Tab). Keeps exactly what is on
|
||||||
|
* screen: a throwaway tab becomes real, a focus move inside an existing
|
||||||
|
* tab stands. Nothing moves a second time. */
|
||||||
|
commit(sessionId: Uuid): void;
|
||||||
|
/** Any other real change — opening the command bar, a dialog, navigating
|
||||||
|
* away — ends a pending or active preview immediately, bypassing the
|
||||||
|
* throttle. */
|
||||||
|
end(): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Preview state management (docs/PREVIEW.md): wraps the layout store's
|
||||||
|
* `previewSession`/`commitPreview` with the 120ms pane-swap throttle so the
|
||||||
|
* UI never has to schedule it by hand. */
|
||||||
|
export function usePreview({ store, scheduleDeps }: UsePreviewOptions): UsePreviewResult {
|
||||||
|
const previewedSessionId = useStore(store, (s: LayoutStore) => s.preview?.sessionId ?? null);
|
||||||
|
|
||||||
|
const scheduleRef = useRef<ReturnType<typeof createPreviewSchedule> | null>(null);
|
||||||
|
if (!scheduleRef.current) {
|
||||||
|
scheduleRef.current = createPreviewSchedule((id) => store.getState().previewSession(id), scheduleDeps);
|
||||||
|
}
|
||||||
|
useEffect(() => {
|
||||||
|
const schedule = scheduleRef.current;
|
||||||
|
return () => schedule?.cancel();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const preview = useCallback((sessionId: Uuid | null) => {
|
||||||
|
scheduleRef.current?.(sessionId);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const previewFromPointer = useCallback(
|
||||||
|
(sessionId: Uuid | null, pointerType: string) => {
|
||||||
|
if (pointerType === "touch") return;
|
||||||
|
preview(sessionId);
|
||||||
|
},
|
||||||
|
[preview],
|
||||||
|
);
|
||||||
|
|
||||||
|
const commit = useCallback(
|
||||||
|
(sessionId: Uuid) => {
|
||||||
|
scheduleRef.current?.cancel();
|
||||||
|
store.getState().openSession(sessionId);
|
||||||
|
},
|
||||||
|
[store],
|
||||||
|
);
|
||||||
|
|
||||||
|
const end = useCallback(() => {
|
||||||
|
scheduleRef.current?.cancel();
|
||||||
|
store.getState().previewSession(null);
|
||||||
|
}, [store]);
|
||||||
|
|
||||||
|
return { previewedSessionId, preview, previewFromPointer, commit, end };
|
||||||
|
}
|
||||||
@@ -0,0 +1,291 @@
|
|||||||
|
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);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("previewSession (docs/PREVIEW.md)", () => {
|
||||||
|
it("brings a session's own tab forward, then puts the layout back", () => {
|
||||||
|
const s = store();
|
||||||
|
s.getState().openSession("s1");
|
||||||
|
s.getState().addTab();
|
||||||
|
s.getState().openSession("s2");
|
||||||
|
const before = JSON.stringify(s.getState().layout);
|
||||||
|
|
||||||
|
s.getState().previewSession("s1");
|
||||||
|
const tab1 = s.getState().layout.tabs.find((t) => leaves(t.root).some((l) => l.sessionId === "s1"))!;
|
||||||
|
expect(s.getState().layout.activeTabId).toBe(tab1.id);
|
||||||
|
expect(s.getState().preview?.sessionId).toBe("s1");
|
||||||
|
|
||||||
|
s.getState().previewSession(null);
|
||||||
|
expect(s.getState().preview).toBeNull();
|
||||||
|
expect(JSON.stringify(s.getState().layout)).toBe(before);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows a session no pane holds in a throwaway tab that leaves no trace", () => {
|
||||||
|
const s = store();
|
||||||
|
s.getState().openSession("s1");
|
||||||
|
const before = JSON.stringify(s.getState().layout);
|
||||||
|
|
||||||
|
s.getState().previewSession("s9");
|
||||||
|
expect(s.getState().layout.tabs).toHaveLength(2);
|
||||||
|
const tempTab = s.getState().layout.tabs[1];
|
||||||
|
expect(leaves(tempTab.root).map((l) => l.sessionId)).toEqual(["s9"]);
|
||||||
|
|
||||||
|
s.getState().previewSession(null);
|
||||||
|
expect(s.getState().layout.tabs).toHaveLength(1);
|
||||||
|
expect(JSON.stringify(s.getState().layout)).toBe(before);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("stepping between previews leaves only the newest one on screen", () => {
|
||||||
|
const s = store();
|
||||||
|
s.getState().openSession("s1");
|
||||||
|
const before = JSON.stringify(s.getState().layout);
|
||||||
|
|
||||||
|
s.getState().previewSession("s9");
|
||||||
|
s.getState().previewSession("s8");
|
||||||
|
expect(s.getState().layout.tabs).toHaveLength(2);
|
||||||
|
expect(s.getState().preview?.sessionId).toBe("s8");
|
||||||
|
|
||||||
|
s.getState().previewSession(null);
|
||||||
|
expect(JSON.stringify(s.getState().layout)).toBe(before);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("never persists while up, and saves again once settled back", () => {
|
||||||
|
const save = vi.fn();
|
||||||
|
const { scheduler, flush } = fakeScheduler();
|
||||||
|
const s = store({ save, scheduler });
|
||||||
|
s.getState().openSession("s1");
|
||||||
|
flush();
|
||||||
|
save.mockClear();
|
||||||
|
|
||||||
|
s.getState().previewSession("s9");
|
||||||
|
expect(save).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
s.getState().previewSession(null);
|
||||||
|
flush();
|
||||||
|
expect(save).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("commitPreview keeps the throwaway tab as a real one", () => {
|
||||||
|
const s = store();
|
||||||
|
s.getState().openSession("s1");
|
||||||
|
s.getState().previewSession("s9");
|
||||||
|
|
||||||
|
expect(s.getState().commitPreview("s9")).toBe(true);
|
||||||
|
expect(s.getState().preview).toBeNull();
|
||||||
|
expect(s.getState().layout.tabs).toHaveLength(2);
|
||||||
|
expect(leaves(s.getState().layout.tabs[1].root).map((l) => l.sessionId)).toEqual(["s9"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("commitPreview discards a non-matching preview and reports false", () => {
|
||||||
|
const s = store();
|
||||||
|
s.getState().openSession("s1");
|
||||||
|
s.getState().previewSession("s9");
|
||||||
|
|
||||||
|
expect(s.getState().commitPreview("s1")).toBe(false);
|
||||||
|
expect(s.getState().preview).toBeNull();
|
||||||
|
expect(s.getState().layout.tabs).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("openSession settles a matching preview instead of moving it a second time", () => {
|
||||||
|
const s = store();
|
||||||
|
s.getState().openSession("s1");
|
||||||
|
s.getState().previewSession("s9");
|
||||||
|
|
||||||
|
s.getState().openSession("s9");
|
||||||
|
expect(s.getState().preview).toBeNull();
|
||||||
|
expect(s.getState().layout.tabs).toHaveLength(2);
|
||||||
|
expect(leaves(s.getState().layout.tabs[0].root).map((l) => l.sessionId)).toEqual(["s1"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("any real layout change ends a preview first: splitFocused", () => {
|
||||||
|
const s = store();
|
||||||
|
s.getState().openSession("s1");
|
||||||
|
s.getState().previewSession("s9");
|
||||||
|
const first = leaves(s.getState().layout.tabs[0].root)[0].id;
|
||||||
|
|
||||||
|
s.getState().splitFocused(first, "row");
|
||||||
|
expect(s.getState().preview).toBeNull();
|
||||||
|
expect(s.getState().layout.tabs).toHaveLength(1);
|
||||||
|
expect(leaves(s.getState().layout.tabs[0].root).map((l) => l.sessionId).sort()).toEqual([null, "s1"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("any real layout change ends a preview first: addTab/selectTab/closeTab", () => {
|
||||||
|
const s = store();
|
||||||
|
s.getState().openSession("s1");
|
||||||
|
s.getState().previewSession("s9");
|
||||||
|
s.getState().addTab();
|
||||||
|
expect(s.getState().preview).toBeNull();
|
||||||
|
// The throwaway tab was discarded before the real tab was added.
|
||||||
|
expect(s.getState().layout.tabs).toHaveLength(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("closeSession drops the preview before closing the real pane", () => {
|
||||||
|
const s = store();
|
||||||
|
s.getState().openSession("s1");
|
||||||
|
s.getState().previewSession("s9");
|
||||||
|
|
||||||
|
s.getState().closeSession("s1");
|
||||||
|
expect(s.getState().preview).toBeNull();
|
||||||
|
expect(s.getState().layout.tabs).toHaveLength(1);
|
||||||
|
expect(s.getState().layout.tabs[0].root).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,368 @@
|
|||||||
|
// 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,
|
||||||
|
freshId,
|
||||||
|
leaves,
|
||||||
|
locateSession,
|
||||||
|
removeLeaf,
|
||||||
|
splitLeaf,
|
||||||
|
type Dir,
|
||||||
|
type Layout,
|
||||||
|
type Tab,
|
||||||
|
} from "../layout.js";
|
||||||
|
import { loadLayout, saveLayout } from "../persistence.js";
|
||||||
|
import type { Uuid } from "../types.js";
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A provisional preview (docs/PREVIEW.md): records exactly what
|
||||||
|
* `previewSession` moved, so ending it restores the layout byte-for-byte. */
|
||||||
|
export interface PreviewState {
|
||||||
|
sessionId: Uuid;
|
||||||
|
/** Tab that was active before the preview. */
|
||||||
|
prevActiveTabId: string;
|
||||||
|
/** Focused leaf to restore, when the preview stole focus in a real tab. */
|
||||||
|
prevFocus: { tabId: string; leafId: string | null } | null;
|
||||||
|
/** Throwaway tab built for a session that no pane was showing. */
|
||||||
|
tempTabId: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LayoutState {
|
||||||
|
layout: Layout;
|
||||||
|
/** Session shown provisionally in the content pane, or null. A preview is
|
||||||
|
* a view, not state: it never reaches `save` (docs/PREVIEW.md). */
|
||||||
|
preview: PreviewState | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LayoutActions {
|
||||||
|
/** Bring `sessionId` into view: focus it if already open, else fill the
|
||||||
|
* focused pane (VS-Code-style replace), or seed an empty tab. Settles a
|
||||||
|
* matching preview in place rather than moving it a second time. */
|
||||||
|
openSession(sessionId: Uuid): void;
|
||||||
|
/** Same, but never evicts a running pane: reuses an empty leaf or splits
|
||||||
|
* beside the focused one. `newTab` opens its own tab instead. */
|
||||||
|
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;
|
||||||
|
/** Show `sessionId` in the content pane without committing to it
|
||||||
|
* (docs/PREVIEW.md): its own tab comes forward, or a throwaway tab
|
||||||
|
* carries it. `null` puts back whatever was on screen before. Never
|
||||||
|
* schedules a save while a preview is up. */
|
||||||
|
previewSession(sessionId: Uuid | null): void;
|
||||||
|
/** Keep what is being previewed: its throwaway tab becomes a real one, and
|
||||||
|
* the focus it moved stands. Any other pending preview is simply
|
||||||
|
* discarded. Returns whether `sessionId` was the one being previewed. */
|
||||||
|
commitPreview(sessionId: Uuid): boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type LayoutStore = LayoutState & LayoutActions;
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- preview (docs/PREVIEW.md): a view, not a layout change — never
|
||||||
|
// schedules a save while it is up. ----
|
||||||
|
|
||||||
|
/** Undo the preview's moves. Mutates only — the caller re-renders /
|
||||||
|
* persists as it sees fit. Returns whether a preview was active. */
|
||||||
|
function restorePreview(): boolean {
|
||||||
|
const p = get().preview;
|
||||||
|
if (!p) return false;
|
||||||
|
set({ preview: null });
|
||||||
|
const layout = get().layout;
|
||||||
|
if (p.tempTabId) {
|
||||||
|
const i = layout.tabs.findIndex((t) => t.id === p.tempTabId);
|
||||||
|
if (i >= 0) layout.tabs.splice(i, 1);
|
||||||
|
} else if (p.prevFocus) {
|
||||||
|
const t = layout.tabs.find((x) => x.id === p.prevFocus!.tabId);
|
||||||
|
if (t) t.focusedLeafId = p.prevFocus.leafId;
|
||||||
|
}
|
||||||
|
layout.activeTabId = layout.tabs.some((t) => t.id === p.prevActiveTabId)
|
||||||
|
? p.prevActiveTabId
|
||||||
|
: layout.tabs[0].id;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function settlePreview(sessionId: Uuid): boolean {
|
||||||
|
const p = get().preview;
|
||||||
|
if (!p) return false;
|
||||||
|
if (p.sessionId !== sessionId) {
|
||||||
|
restorePreview();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
set({ preview: null });
|
||||||
|
if (p.tempTabId) {
|
||||||
|
const layout = get().layout;
|
||||||
|
const t = layout.tabs.find((x) => x.id === p.tempTabId);
|
||||||
|
if (t) t.title = `Tab ${layout.tabs.length}`;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
layout: load() ?? emptyLayout(),
|
||||||
|
preview: null,
|
||||||
|
|
||||||
|
openSession(sessionId) {
|
||||||
|
if (get().commitPreview(sessionId)) return;
|
||||||
|
mutate((layout) => {
|
||||||
|
const loc = locateSession(layout, sessionId);
|
||||||
|
if (loc) {
|
||||||
|
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) {
|
||||||
|
restorePreview();
|
||||||
|
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) {
|
||||||
|
restorePreview();
|
||||||
|
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) {
|
||||||
|
restorePreview();
|
||||||
|
mutate((layout) => {
|
||||||
|
const tab = activeTab(layout);
|
||||||
|
tab.focusedLeafId = splitLeaf(tab, leafId, null, dir, true);
|
||||||
|
equalizeRatios(tab.root);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
closePane(leafId) {
|
||||||
|
restorePreview();
|
||||||
|
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) {
|
||||||
|
restorePreview();
|
||||||
|
mutate((layout) => {
|
||||||
|
if (layout.tabs.some((t) => t.id === tabId)) layout.activeTabId = tabId;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
addTab() {
|
||||||
|
restorePreview();
|
||||||
|
mutate((layout) => {
|
||||||
|
addTabToLayout(layout);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
closeTab(tabId) {
|
||||||
|
restorePreview();
|
||||||
|
mutate((layout) => {
|
||||||
|
closeTabInLayout(layout, tabId);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
reorderTab(fromId, toId) {
|
||||||
|
restorePreview();
|
||||||
|
mutate((layout) => {
|
||||||
|
if (fromId === toId) return;
|
||||||
|
const from = layout.tabs.findIndex((t) => t.id === fromId);
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
previewSession(sessionId) {
|
||||||
|
const current = get().preview;
|
||||||
|
if (current?.sessionId === sessionId) return;
|
||||||
|
if (sessionId === null) {
|
||||||
|
if (restorePreview()) commit();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
restorePreview();
|
||||||
|
const layout = get().layout;
|
||||||
|
const prevActiveTabId = layout.activeTabId;
|
||||||
|
const loc = locateSession(layout, sessionId);
|
||||||
|
let preview: PreviewState;
|
||||||
|
if (loc) {
|
||||||
|
preview = {
|
||||||
|
sessionId,
|
||||||
|
prevActiveTabId,
|
||||||
|
prevFocus: { tabId: loc.tab.id, leafId: loc.tab.focusedLeafId },
|
||||||
|
tempTabId: null,
|
||||||
|
};
|
||||||
|
layout.activeTabId = loc.tab.id;
|
||||||
|
loc.tab.focusedLeafId = loc.leaf.id;
|
||||||
|
} else {
|
||||||
|
// Not on screen anywhere: a throwaway tab carries it, so no
|
||||||
|
// existing pane is evicted and nothing the operator arranged moves.
|
||||||
|
const tab: Tab = { id: freshId("t"), title: "Preview", root: null, focusedLeafId: null };
|
||||||
|
tab.focusedLeafId = splitLeaf(tab, "", sessionId, "row", true);
|
||||||
|
layout.tabs.push(tab);
|
||||||
|
layout.activeTabId = tab.id;
|
||||||
|
preview = { sessionId, prevActiveTabId, prevFocus: null, tempTabId: tab.id };
|
||||||
|
}
|
||||||
|
set({ layout: { ...layout }, preview });
|
||||||
|
},
|
||||||
|
|
||||||
|
commitPreview(sessionId) {
|
||||||
|
const ok = settlePreview(sessionId);
|
||||||
|
if (ok) commit();
|
||||||
|
return ok;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user