Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ea8c42bfb2 |
@@ -1,68 +0,0 @@
|
|||||||
// @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);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,86 +0,0 @@
|
|||||||
// 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,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
export { ContextMenu, type ContextMenuItem, type ContextMenuProps } from "./ContextMenu.js";
|
|
||||||
@@ -1,82 +0,0 @@
|
|||||||
// @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(" "))));
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,78 +0,0 @@
|
|||||||
// 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,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,74 +0,0 @@
|
|||||||
// @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();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,95 +0,0 @@
|
|||||||
// 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,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
export { KillDialog, type KillDialogProps } from "./KillDialog.js";
|
|
||||||
export { HelpOverlay } from "./HelpOverlay.js";
|
|
||||||
@@ -1,79 +0,0 @@
|
|||||||
// @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();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,51 +0,0 @@
|
|||||||
// 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);
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
}
|
|
||||||
@@ -1,154 +0,0 @@
|
|||||||
// @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);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,93 +0,0 @@
|
|||||||
// 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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
// @vitest-environment happy-dom
|
// @vitest-environment happy-dom
|
||||||
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
|
import { act, cleanup, fireEvent, render, screen } from "@testing-library/react";
|
||||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
import { leaves } from "../../layout.js";
|
import { leaves } from "../../layout.js";
|
||||||
@@ -93,4 +93,60 @@ describe("MultiView", () => {
|
|||||||
render(<MultiView store={s} />);
|
render(<MultiView store={s} />);
|
||||||
expect(screen.getByTestId("terminal-s1")).toBeTruthy();
|
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"]);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
// React port of multiview.ts's tab + split tree (issue #249). Preview mode
|
// React port of multiview.ts's tab + split tree (issue #249) plus its preview
|
||||||
// (docs/PREVIEW.md) is issue #251; sidebar drag-to-dock and the mobile
|
// 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
|
// single-pane collapse stay with whichever issue wires this into the shell
|
||||||
// (#253) — this renders the tree itself: tabs, nested splits, pane chrome.
|
// (#253) — this renders the tree itself: tabs, nested splits, pane chrome.
|
||||||
|
|
||||||
@@ -8,6 +8,7 @@ import { useEffect, useRef } from "react";
|
|||||||
import { useStore } from "zustand";
|
import { useStore } from "zustand";
|
||||||
|
|
||||||
import { activeTab, type Dir, type Node } from "../../layout.js";
|
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 { LayoutStore, LayoutStoreApi } from "../../stores/layoutStore.js";
|
||||||
import type { Session, Uuid } from "../../types.js";
|
import type { Session, Uuid } from "../../types.js";
|
||||||
import { PaneContainer } from "./PaneContainer.js";
|
import { PaneContainer } from "./PaneContainer.js";
|
||||||
@@ -26,6 +27,8 @@ export interface MultiViewProps {
|
|||||||
export function MultiView({ store, sessions, onDisconnect, emptyState }: MultiViewProps) {
|
export function MultiView({ store, sessions, onDisconnect, emptyState }: MultiViewProps) {
|
||||||
const layout = useStore(store, (s: LayoutStore) => s.layout);
|
const layout = useStore(store, (s: LayoutStore) => s.layout);
|
||||||
const tab = activeTab(layout);
|
const tab = activeTab(layout);
|
||||||
|
const { previewedSessionId, commit: commitPreview } = usePreview({ store });
|
||||||
|
const isPreviewing = previewedSessionId !== null;
|
||||||
|
|
||||||
const reconciledRef = useRef(false);
|
const reconciledRef = useRef(false);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -37,9 +40,17 @@ export function MultiView({ store, sessions, onDisconnect, emptyState }: MultiVi
|
|||||||
return (
|
return (
|
||||||
<div className="mv flex h-full min-h-0 flex-col">
|
<div className="mv flex h-full min-h-0 flex-col">
|
||||||
<TabBar store={store} />
|
<TabBar store={store} />
|
||||||
<div className="mv-canvas min-h-0 flex-1">
|
<div className="mv-canvas relative min-h-0 flex-1">
|
||||||
{tab.root ? (
|
{tab.root ? (
|
||||||
<SplitNode node={tab.root} tab={tab} store={store} sessions={sessions} onDisconnect={onDisconnect} />
|
<SplitNode
|
||||||
|
node={tab.root}
|
||||||
|
tab={tab}
|
||||||
|
store={store}
|
||||||
|
sessions={sessions}
|
||||||
|
onDisconnect={onDisconnect}
|
||||||
|
previewedSessionId={previewedSessionId}
|
||||||
|
commitPreview={commitPreview}
|
||||||
|
/>
|
||||||
) : (
|
) : (
|
||||||
(emptyState ?? (
|
(emptyState ?? (
|
||||||
<div className="mv-empty flex h-full items-center justify-center p-sp-4 text-fs-13 text-faint">
|
<div className="mv-empty flex h-full items-center justify-center p-sp-4 text-fs-13 text-faint">
|
||||||
@@ -47,6 +58,14 @@ export function MultiView({ store, sessions, onDisconnect, emptyState }: MultiVi
|
|||||||
</div>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -57,6 +76,8 @@ interface TreeProps {
|
|||||||
store: LayoutStoreApi;
|
store: LayoutStoreApi;
|
||||||
sessions?: ReadonlyMap<Uuid, Session>;
|
sessions?: ReadonlyMap<Uuid, Session>;
|
||||||
onDisconnect?: (sessionId: Uuid) => void;
|
onDisconnect?: (sessionId: Uuid) => void;
|
||||||
|
previewedSessionId: Uuid | null;
|
||||||
|
commitPreview: (sessionId: Uuid) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
function SplitNode({ node, ...rest }: TreeProps & { node: Node }) {
|
function SplitNode({ node, ...rest }: TreeProps & { node: Node }) {
|
||||||
@@ -127,21 +148,34 @@ function Pane({
|
|||||||
store,
|
store,
|
||||||
sessions,
|
sessions,
|
||||||
onDisconnect,
|
onDisconnect,
|
||||||
|
previewedSessionId,
|
||||||
|
commitPreview,
|
||||||
}: TreeProps & { leaf: Extract<Node, { kind: "leaf" }>; isFocused: boolean }) {
|
}: TreeProps & { leaf: Extract<Node, { kind: "leaf" }>; isFocused: boolean }) {
|
||||||
const session = leaf.sessionId ? sessions?.get(leaf.sessionId) : undefined;
|
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);
|
const splitFocused = (dir: Dir) => store.getState().splitFocused(leaf.id, dir);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={
|
className={
|
||||||
"mv-pane flex h-full min-h-0 min-w-0 flex-col border " +
|
"mv-pane flex h-full min-h-0 min-w-0 flex-col border " +
|
||||||
(isFocused ? "border-accent" : "border-border-strong")
|
(isFocused && !isPreview ? "border-accent" : "border-border-strong")
|
||||||
}
|
}
|
||||||
data-leaf-id={leaf.id}
|
data-leaf-id={leaf.id}
|
||||||
data-session-id={leaf.sessionId ?? undefined}
|
data-session-id={leaf.sessionId ?? undefined}
|
||||||
onPointerDownCapture={() => store.getState().focusLeaf(leaf.id)}
|
onPointerDownCapture={() => {
|
||||||
|
// Clicking into a previewed pane is the operator choosing it
|
||||||
|
// (docs/PREVIEW.md).
|
||||||
|
if (isPreview) commitPreview(leaf.sessionId!);
|
||||||
|
store.getState().focusLeaf(leaf.id);
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<div className="mv-pane-header flex items-center justify-between gap-sp-2 border-b border-border-strong bg-surface px-sp-2 py-sp-1 text-fs-12">
|
<div
|
||||||
|
className={
|
||||||
|
"mv-pane-header flex items-center justify-between gap-sp-2 border-b border-border-strong px-sp-2 py-sp-1 text-fs-12 " +
|
||||||
|
(isPreview ? "bg-preview-tint text-preview" : "bg-surface")
|
||||||
|
}
|
||||||
|
>
|
||||||
{leaf.sessionId ? (
|
{leaf.sessionId ? (
|
||||||
<PaneTitle sessionId={leaf.sessionId} session={session} />
|
<PaneTitle sessionId={leaf.sessionId} session={session} />
|
||||||
) : (
|
) : (
|
||||||
@@ -189,7 +223,7 @@ function Pane({
|
|||||||
<PaneContainer
|
<PaneContainer
|
||||||
sessionId={leaf.sessionId}
|
sessionId={leaf.sessionId}
|
||||||
session={session}
|
session={session}
|
||||||
focus={isFocused}
|
focus={isFocused && previewedSessionId === null}
|
||||||
onClosed={() => store.getState().closeSession(leaf.sessionId!)}
|
onClosed={() => store.getState().closeSession(leaf.sessionId!)}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ export interface TabBarProps {
|
|||||||
|
|
||||||
export function TabBar({ store }: TabBarProps) {
|
export function TabBar({ store }: TabBarProps) {
|
||||||
const layout = useStore(store, (s: LayoutStore) => s.layout);
|
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 [draggingId, setDraggingId] = useState<string | null>(null);
|
||||||
const [overId, setOverId] = useState<string | null>(null);
|
const [overId, setOverId] = useState<string | null>(null);
|
||||||
|
|
||||||
@@ -22,6 +23,9 @@ export function TabBar({ store }: TabBarProps) {
|
|||||||
const count = leaves(tab.root).filter((l) => l.sessionId).length;
|
const count = leaves(tab.root).filter((l) => l.sessionId).length;
|
||||||
const isActive = tab.id === layout.activeTabId;
|
const isActive = tab.id === layout.activeTabId;
|
||||||
const isDropTarget = overId === tab.id && draggingId !== null && draggingId !== tab.id;
|
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 (
|
return (
|
||||||
<button
|
<button
|
||||||
key={tab.id}
|
key={tab.id}
|
||||||
@@ -32,7 +36,11 @@ export function TabBar({ store }: TabBarProps) {
|
|||||||
data-tab-id={tab.id}
|
data-tab-id={tab.id}
|
||||||
className={
|
className={
|
||||||
"mv-tab flex items-center gap-sp-1 rounded-t-r-1 px-sp-2 py-sp-1 " +
|
"mv-tab flex items-center gap-sp-1 rounded-t-r-1 px-sp-2 py-sp-1 " +
|
||||||
(isActive ? "bg-surface-2 text-ink" : "text-faint hover:text-ink") +
|
(isPreviewTab
|
||||||
|
? "border border-dashed border-preview-ring italic text-preview"
|
||||||
|
: isActive
|
||||||
|
? "bg-surface-2 text-ink"
|
||||||
|
: "text-faint hover:text-ink") +
|
||||||
(isDropTarget ? " outline outline-2 outline-accent" : "")
|
(isDropTarget ? " outline outline-2 outline-accent" : "")
|
||||||
}
|
}
|
||||||
onDragStart={(e) => {
|
onDragStart={(e) => {
|
||||||
@@ -57,13 +65,16 @@ export function TabBar({ store }: TabBarProps) {
|
|||||||
setDraggingId(null);
|
setDraggingId(null);
|
||||||
setOverId(null);
|
setOverId(null);
|
||||||
}}
|
}}
|
||||||
onClick={() => store.getState().selectTab(tab.id)}
|
onClick={() => {
|
||||||
|
if (isPreviewTab) return;
|
||||||
|
store.getState().selectTab(tab.id);
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<span>
|
<span>
|
||||||
{tab.title}
|
{tab.title}
|
||||||
{count ? ` · ${count}` : ""}
|
{count ? ` · ${count}` : ""}
|
||||||
</span>
|
</span>
|
||||||
{layout.tabs.length > 1 && (
|
{layout.tabs.length > 1 && !isPreviewTab && (
|
||||||
<span
|
<span
|
||||||
className="mv-tab-close text-faint hover:text-ink"
|
className="mv-tab-close text-faint hover:text-ink"
|
||||||
title="Close tab"
|
title="Close tab"
|
||||||
|
|||||||
@@ -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 };
|
||||||
|
}
|
||||||
@@ -159,4 +159,133 @@ describe("layoutStore", () => {
|
|||||||
expect(save).toHaveBeenCalledTimes(1);
|
expect(save).toHaveBeenCalledTimes(1);
|
||||||
expect(save.mock.calls[0][0]).toBe(s.getState().layout);
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -11,12 +11,14 @@ import {
|
|||||||
emptyLayout,
|
emptyLayout,
|
||||||
equalizeRatios,
|
equalizeRatios,
|
||||||
findLeaf,
|
findLeaf,
|
||||||
|
freshId,
|
||||||
leaves,
|
leaves,
|
||||||
locateSession,
|
locateSession,
|
||||||
removeLeaf,
|
removeLeaf,
|
||||||
splitLeaf,
|
splitLeaf,
|
||||||
type Dir,
|
type Dir,
|
||||||
type Layout,
|
type Layout,
|
||||||
|
type Tab,
|
||||||
} from "../layout.js";
|
} from "../layout.js";
|
||||||
import { loadLayout, saveLayout } from "../persistence.js";
|
import { loadLayout, saveLayout } from "../persistence.js";
|
||||||
import type { Uuid } from "../types.js";
|
import type { Uuid } from "../types.js";
|
||||||
@@ -35,13 +37,29 @@ export interface LayoutStoreOptions {
|
|||||||
scheduler?: Scheduler;
|
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 {
|
export interface LayoutState {
|
||||||
layout: Layout;
|
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 {
|
export interface LayoutActions {
|
||||||
/** Bring `sessionId` into view: focus it if already open, else fill the
|
/** Bring `sessionId` into view: focus it if already open, else fill the
|
||||||
* focused pane (VS-Code-style replace), or seed an empty tab. */
|
* focused pane (VS-Code-style replace), or seed an empty tab. Settles a
|
||||||
|
* matching preview in place rather than moving it a second time. */
|
||||||
openSession(sessionId: Uuid): void;
|
openSession(sessionId: Uuid): void;
|
||||||
/** Same, but never evicts a running pane: reuses an empty leaf or splits
|
/** Same, but never evicts a running pane: reuses an empty leaf or splits
|
||||||
* beside the focused one. `newTab` opens its own tab instead. */
|
* beside the focused one. `newTab` opens its own tab instead. */
|
||||||
@@ -66,6 +84,15 @@ export interface LayoutActions {
|
|||||||
* tab was closed is pruned instead of leaving a pane that can only ever
|
* 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. */
|
* show a dead socket. Tabs that were already empty are left alone. */
|
||||||
reconcile(liveIds: ReadonlySet<Uuid>): void;
|
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 LayoutStore = LayoutState & LayoutActions;
|
||||||
@@ -107,10 +134,51 @@ export function createLayoutStore(opts: LayoutStoreOptions = {}) {
|
|||||||
commit();
|
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 {
|
return {
|
||||||
layout: load() ?? emptyLayout(),
|
layout: load() ?? emptyLayout(),
|
||||||
|
preview: null,
|
||||||
|
|
||||||
openSession(sessionId) {
|
openSession(sessionId) {
|
||||||
|
if (get().commitPreview(sessionId)) return;
|
||||||
mutate((layout) => {
|
mutate((layout) => {
|
||||||
const loc = locateSession(layout, sessionId);
|
const loc = locateSession(layout, sessionId);
|
||||||
if (loc) {
|
if (loc) {
|
||||||
@@ -130,6 +198,7 @@ export function createLayoutStore(opts: LayoutStoreOptions = {}) {
|
|||||||
},
|
},
|
||||||
|
|
||||||
openSessionBeside(sessionId, placement) {
|
openSessionBeside(sessionId, placement) {
|
||||||
|
restorePreview();
|
||||||
mutate((layout) => {
|
mutate((layout) => {
|
||||||
const loc = locateSession(layout, sessionId);
|
const loc = locateSession(layout, sessionId);
|
||||||
if (loc) {
|
if (loc) {
|
||||||
@@ -158,6 +227,7 @@ export function createLayoutStore(opts: LayoutStoreOptions = {}) {
|
|||||||
},
|
},
|
||||||
|
|
||||||
closeSession(sessionId) {
|
closeSession(sessionId) {
|
||||||
|
restorePreview();
|
||||||
mutate((layout) => {
|
mutate((layout) => {
|
||||||
const loc = locateSession(layout, sessionId);
|
const loc = locateSession(layout, sessionId);
|
||||||
if (!loc) return;
|
if (!loc) return;
|
||||||
@@ -170,6 +240,7 @@ export function createLayoutStore(opts: LayoutStoreOptions = {}) {
|
|||||||
},
|
},
|
||||||
|
|
||||||
splitFocused(leafId, dir) {
|
splitFocused(leafId, dir) {
|
||||||
|
restorePreview();
|
||||||
mutate((layout) => {
|
mutate((layout) => {
|
||||||
const tab = activeTab(layout);
|
const tab = activeTab(layout);
|
||||||
tab.focusedLeafId = splitLeaf(tab, leafId, null, dir, true);
|
tab.focusedLeafId = splitLeaf(tab, leafId, null, dir, true);
|
||||||
@@ -178,6 +249,7 @@ export function createLayoutStore(opts: LayoutStoreOptions = {}) {
|
|||||||
},
|
},
|
||||||
|
|
||||||
closePane(leafId) {
|
closePane(leafId) {
|
||||||
|
restorePreview();
|
||||||
mutate((layout) => {
|
mutate((layout) => {
|
||||||
const tab = activeTab(layout);
|
const tab = activeTab(layout);
|
||||||
removeLeaf(tab, leafId);
|
removeLeaf(tab, leafId);
|
||||||
@@ -196,24 +268,28 @@ export function createLayoutStore(opts: LayoutStoreOptions = {}) {
|
|||||||
},
|
},
|
||||||
|
|
||||||
selectTab(tabId) {
|
selectTab(tabId) {
|
||||||
|
restorePreview();
|
||||||
mutate((layout) => {
|
mutate((layout) => {
|
||||||
if (layout.tabs.some((t) => t.id === tabId)) layout.activeTabId = tabId;
|
if (layout.tabs.some((t) => t.id === tabId)) layout.activeTabId = tabId;
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
addTab() {
|
addTab() {
|
||||||
|
restorePreview();
|
||||||
mutate((layout) => {
|
mutate((layout) => {
|
||||||
addTabToLayout(layout);
|
addTabToLayout(layout);
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
closeTab(tabId) {
|
closeTab(tabId) {
|
||||||
|
restorePreview();
|
||||||
mutate((layout) => {
|
mutate((layout) => {
|
||||||
closeTabInLayout(layout, tabId);
|
closeTabInLayout(layout, tabId);
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
reorderTab(fromId, toId) {
|
reorderTab(fromId, toId) {
|
||||||
|
restorePreview();
|
||||||
mutate((layout) => {
|
mutate((layout) => {
|
||||||
if (fromId === toId) return;
|
if (fromId === toId) return;
|
||||||
const from = layout.tabs.findIndex((t) => t.id === fromId);
|
const from = layout.tabs.findIndex((t) => t.id === fromId);
|
||||||
@@ -248,6 +324,45 @@ export function createLayoutStore(opts: LayoutStoreOptions = {}) {
|
|||||||
for (const id of emptied) closeTabInLayout(layout, id);
|
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