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
|
||||
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
|
||||
import { act, cleanup, fireEvent, render, screen } from "@testing-library/react";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { leaves } from "../../layout.js";
|
||||
@@ -93,4 +93,60 @@ describe("MultiView", () => {
|
||||
render(<MultiView store={s} />);
|
||||
expect(screen.getByTestId("terminal-s1")).toBeTruthy();
|
||||
});
|
||||
|
||||
describe("preview mode (docs/PREVIEW.md)", () => {
|
||||
it("shows a session no pane holds in a throwaway tab, marked as a preview", () => {
|
||||
const s = store();
|
||||
s.getState().openSession("s1");
|
||||
render(<MultiView store={s} sessions={new Map([["s1", session("s1")]])} />);
|
||||
|
||||
act(() => s.getState().previewSession("s9"));
|
||||
// s1's own tab is still in the strip — the preview never touched it.
|
||||
expect(screen.getByText("Tab 1 · 1")).toBeTruthy();
|
||||
const pane = document.querySelector('[data-session-id="s9"]');
|
||||
expect(pane).toBeTruthy();
|
||||
expect(pane!.querySelector(".bg-preview-tint")).toBeTruthy();
|
||||
expect(screen.getByText("preview")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("puts the layout back byte for byte once the preview ends", () => {
|
||||
const s = store();
|
||||
s.getState().openSession("s1");
|
||||
render(<MultiView store={s} sessions={new Map([["s1", session("s1")]])} />);
|
||||
const before = JSON.stringify(s.getState().layout);
|
||||
|
||||
act(() => s.getState().previewSession("s9"));
|
||||
act(() => s.getState().previewSession(null));
|
||||
|
||||
expect(JSON.stringify(s.getState().layout)).toBe(before);
|
||||
expect(screen.queryByText("preview")).toBeNull();
|
||||
});
|
||||
|
||||
it("does not grab the keyboard while previewing", () => {
|
||||
const s = store();
|
||||
s.getState().openSession("s1");
|
||||
render(<MultiView store={s} sessions={new Map([["s1", session("s1")]])} />);
|
||||
|
||||
act(() => s.getState().previewSession("s9"));
|
||||
const pane = document.querySelector('[data-session-id="s9"]') as HTMLElement;
|
||||
expect(pane.querySelector('[data-testid="terminal-s9"]')).toBeTruthy();
|
||||
// TerminalPane's `focus` prop is verified by TerminalPane's own tests;
|
||||
// here the preview must never end up as the active-tab's accent pane.
|
||||
expect(pane.className).not.toContain("border-accent");
|
||||
});
|
||||
|
||||
it("clicking into a previewed pane commits it: no more preview, nothing moves a second time", () => {
|
||||
const s = store();
|
||||
s.getState().openSession("s1");
|
||||
render(<MultiView store={s} sessions={new Map([["s1", session("s1")]])} />);
|
||||
|
||||
act(() => s.getState().previewSession("s9"));
|
||||
const pane = document.querySelector('[data-session-id="s9"]') as HTMLElement;
|
||||
fireEvent.pointerDown(pane);
|
||||
|
||||
expect(s.getState().preview).toBeNull();
|
||||
expect(screen.queryByText("preview")).toBeNull();
|
||||
expect(leaves(s.getState().layout.tabs[1].root).map((l) => l.sessionId)).toEqual(["s9"]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// React port of multiview.ts's tab + split tree (issue #249). Preview mode
|
||||
// (docs/PREVIEW.md) is issue #251; sidebar drag-to-dock and the mobile
|
||||
// React port of multiview.ts's tab + split tree (issue #249) plus its preview
|
||||
// mode (issue #251, docs/PREVIEW.md). Sidebar drag-to-dock and the mobile
|
||||
// single-pane collapse stay with whichever issue wires this into the shell
|
||||
// (#253) — this renders the tree itself: tabs, nested splits, pane chrome.
|
||||
|
||||
@@ -8,6 +8,7 @@ import { useEffect, useRef } from "react";
|
||||
import { useStore } from "zustand";
|
||||
|
||||
import { activeTab, type Dir, type Node } from "../../layout.js";
|
||||
import { usePreview } from "../../hooks/usePreview.js";
|
||||
import type { LayoutStore, LayoutStoreApi } from "../../stores/layoutStore.js";
|
||||
import type { Session, Uuid } from "../../types.js";
|
||||
import { PaneContainer } from "./PaneContainer.js";
|
||||
@@ -26,6 +27,8 @@ export interface MultiViewProps {
|
||||
export function MultiView({ store, sessions, onDisconnect, emptyState }: MultiViewProps) {
|
||||
const layout = useStore(store, (s: LayoutStore) => s.layout);
|
||||
const tab = activeTab(layout);
|
||||
const { previewedSessionId, commit: commitPreview } = usePreview({ store });
|
||||
const isPreviewing = previewedSessionId !== null;
|
||||
|
||||
const reconciledRef = useRef(false);
|
||||
useEffect(() => {
|
||||
@@ -37,9 +40,17 @@ export function MultiView({ store, sessions, onDisconnect, emptyState }: MultiVi
|
||||
return (
|
||||
<div className="mv flex h-full min-h-0 flex-col">
|
||||
<TabBar store={store} />
|
||||
<div className="mv-canvas min-h-0 flex-1">
|
||||
<div className="mv-canvas relative min-h-0 flex-1">
|
||||
{tab.root ? (
|
||||
<SplitNode node={tab.root} tab={tab} store={store} sessions={sessions} onDisconnect={onDisconnect} />
|
||||
<SplitNode
|
||||
node={tab.root}
|
||||
tab={tab}
|
||||
store={store}
|
||||
sessions={sessions}
|
||||
onDisconnect={onDisconnect}
|
||||
previewedSessionId={previewedSessionId}
|
||||
commitPreview={commitPreview}
|
||||
/>
|
||||
) : (
|
||||
(emptyState ?? (
|
||||
<div className="mv-empty flex h-full items-center justify-center p-sp-4 text-fs-13 text-faint">
|
||||
@@ -47,6 +58,14 @@ export function MultiView({ store, sessions, onDisconnect, emptyState }: MultiVi
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
{isPreviewing && (
|
||||
<>
|
||||
<div className="mv-preview-ring pointer-events-none absolute inset-0 rounded-r-2 border border-dashed border-preview-ring" />
|
||||
<div className="mv-preview-chip pointer-events-none absolute right-sp-2 top-sp-2 z-sticky rounded-r-pill bg-preview px-sp-2 py-[2px] text-fs-11 text-preview-ink">
|
||||
preview
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -57,6 +76,8 @@ interface TreeProps {
|
||||
store: LayoutStoreApi;
|
||||
sessions?: ReadonlyMap<Uuid, Session>;
|
||||
onDisconnect?: (sessionId: Uuid) => void;
|
||||
previewedSessionId: Uuid | null;
|
||||
commitPreview: (sessionId: Uuid) => void;
|
||||
}
|
||||
|
||||
function SplitNode({ node, ...rest }: TreeProps & { node: Node }) {
|
||||
@@ -127,21 +148,34 @@ function Pane({
|
||||
store,
|
||||
sessions,
|
||||
onDisconnect,
|
||||
previewedSessionId,
|
||||
commitPreview,
|
||||
}: TreeProps & { leaf: Extract<Node, { kind: "leaf" }>; isFocused: boolean }) {
|
||||
const session = leaf.sessionId ? sessions?.get(leaf.sessionId) : undefined;
|
||||
const isPreview = leaf.sessionId !== null && leaf.sessionId === previewedSessionId;
|
||||
const splitFocused = (dir: Dir) => store.getState().splitFocused(leaf.id, dir);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={
|
||||
"mv-pane flex h-full min-h-0 min-w-0 flex-col border " +
|
||||
(isFocused ? "border-accent" : "border-border-strong")
|
||||
(isFocused && !isPreview ? "border-accent" : "border-border-strong")
|
||||
}
|
||||
data-leaf-id={leaf.id}
|
||||
data-session-id={leaf.sessionId ?? undefined}
|
||||
onPointerDownCapture={() => store.getState().focusLeaf(leaf.id)}
|
||||
onPointerDownCapture={() => {
|
||||
// Clicking into a previewed pane is the operator choosing it
|
||||
// (docs/PREVIEW.md).
|
||||
if (isPreview) commitPreview(leaf.sessionId!);
|
||||
store.getState().focusLeaf(leaf.id);
|
||||
}}
|
||||
>
|
||||
<div className="mv-pane-header flex items-center justify-between gap-sp-2 border-b border-border-strong bg-surface px-sp-2 py-sp-1 text-fs-12">
|
||||
<div
|
||||
className={
|
||||
"mv-pane-header flex items-center justify-between gap-sp-2 border-b border-border-strong px-sp-2 py-sp-1 text-fs-12 " +
|
||||
(isPreview ? "bg-preview-tint text-preview" : "bg-surface")
|
||||
}
|
||||
>
|
||||
{leaf.sessionId ? (
|
||||
<PaneTitle sessionId={leaf.sessionId} session={session} />
|
||||
) : (
|
||||
@@ -189,7 +223,7 @@ function Pane({
|
||||
<PaneContainer
|
||||
sessionId={leaf.sessionId}
|
||||
session={session}
|
||||
focus={isFocused}
|
||||
focus={isFocused && previewedSessionId === null}
|
||||
onClosed={() => store.getState().closeSession(leaf.sessionId!)}
|
||||
/>
|
||||
) : (
|
||||
|
||||
@@ -13,6 +13,7 @@ export interface TabBarProps {
|
||||
|
||||
export function TabBar({ store }: TabBarProps) {
|
||||
const layout = useStore(store, (s: LayoutStore) => s.layout);
|
||||
const previewTempTabId = useStore(store, (s: LayoutStore) => s.preview?.tempTabId ?? null);
|
||||
const [draggingId, setDraggingId] = useState<string | null>(null);
|
||||
const [overId, setOverId] = useState<string | null>(null);
|
||||
|
||||
@@ -22,6 +23,9 @@ export function TabBar({ store }: TabBarProps) {
|
||||
const count = leaves(tab.root).filter((l) => l.sessionId).length;
|
||||
const isActive = tab.id === layout.activeTabId;
|
||||
const isDropTarget = overId === tab.id && draggingId !== null && draggingId !== tab.id;
|
||||
// A preview's throwaway tab is not a place to go — it goes on its
|
||||
// own the moment the preview ends (docs/PREVIEW.md).
|
||||
const isPreviewTab = tab.id === previewTempTabId;
|
||||
return (
|
||||
<button
|
||||
key={tab.id}
|
||||
@@ -32,7 +36,11 @@ export function TabBar({ store }: TabBarProps) {
|
||||
data-tab-id={tab.id}
|
||||
className={
|
||||
"mv-tab flex items-center gap-sp-1 rounded-t-r-1 px-sp-2 py-sp-1 " +
|
||||
(isActive ? "bg-surface-2 text-ink" : "text-faint hover:text-ink") +
|
||||
(isPreviewTab
|
||||
? "border border-dashed border-preview-ring italic text-preview"
|
||||
: isActive
|
||||
? "bg-surface-2 text-ink"
|
||||
: "text-faint hover:text-ink") +
|
||||
(isDropTarget ? " outline outline-2 outline-accent" : "")
|
||||
}
|
||||
onDragStart={(e) => {
|
||||
@@ -57,13 +65,16 @@ export function TabBar({ store }: TabBarProps) {
|
||||
setDraggingId(null);
|
||||
setOverId(null);
|
||||
}}
|
||||
onClick={() => store.getState().selectTab(tab.id)}
|
||||
onClick={() => {
|
||||
if (isPreviewTab) return;
|
||||
store.getState().selectTab(tab.id);
|
||||
}}
|
||||
>
|
||||
<span>
|
||||
{tab.title}
|
||||
{count ? ` · ${count}` : ""}
|
||||
</span>
|
||||
{layout.tabs.length > 1 && (
|
||||
{layout.tabs.length > 1 && !isPreviewTab && (
|
||||
<span
|
||||
className="mv-tab-close text-faint hover:text-ink"
|
||||
title="Close tab"
|
||||
|
||||
@@ -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.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,
|
||||
equalizeRatios,
|
||||
findLeaf,
|
||||
freshId,
|
||||
leaves,
|
||||
locateSession,
|
||||
removeLeaf,
|
||||
splitLeaf,
|
||||
type Dir,
|
||||
type Layout,
|
||||
type Tab,
|
||||
} from "../layout.js";
|
||||
import { loadLayout, saveLayout } from "../persistence.js";
|
||||
import type { Uuid } from "../types.js";
|
||||
@@ -35,13 +37,29 @@ export interface LayoutStoreOptions {
|
||||
scheduler?: Scheduler;
|
||||
}
|
||||
|
||||
/** A provisional preview (docs/PREVIEW.md): records exactly what
|
||||
* `previewSession` moved, so ending it restores the layout byte-for-byte. */
|
||||
export interface PreviewState {
|
||||
sessionId: Uuid;
|
||||
/** Tab that was active before the preview. */
|
||||
prevActiveTabId: string;
|
||||
/** Focused leaf to restore, when the preview stole focus in a real tab. */
|
||||
prevFocus: { tabId: string; leafId: string | null } | null;
|
||||
/** Throwaway tab built for a session that no pane was showing. */
|
||||
tempTabId: string | null;
|
||||
}
|
||||
|
||||
export interface LayoutState {
|
||||
layout: Layout;
|
||||
/** Session shown provisionally in the content pane, or null. A preview is
|
||||
* a view, not state: it never reaches `save` (docs/PREVIEW.md). */
|
||||
preview: PreviewState | null;
|
||||
}
|
||||
|
||||
export interface LayoutActions {
|
||||
/** Bring `sessionId` into view: focus it if already open, else fill the
|
||||
* focused pane (VS-Code-style replace), or seed an empty tab. */
|
||||
* focused pane (VS-Code-style replace), or seed an empty tab. Settles a
|
||||
* matching preview in place rather than moving it a second time. */
|
||||
openSession(sessionId: Uuid): void;
|
||||
/** Same, but never evicts a running pane: reuses an empty leaf or splits
|
||||
* beside the focused one. `newTab` opens its own tab instead. */
|
||||
@@ -66,6 +84,15 @@ export interface LayoutActions {
|
||||
* tab was closed is pruned instead of leaving a pane that can only ever
|
||||
* show a dead socket. Tabs that were already empty are left alone. */
|
||||
reconcile(liveIds: ReadonlySet<Uuid>): void;
|
||||
/** Show `sessionId` in the content pane without committing to it
|
||||
* (docs/PREVIEW.md): its own tab comes forward, or a throwaway tab
|
||||
* carries it. `null` puts back whatever was on screen before. Never
|
||||
* schedules a save while a preview is up. */
|
||||
previewSession(sessionId: Uuid | null): void;
|
||||
/** Keep what is being previewed: its throwaway tab becomes a real one, and
|
||||
* the focus it moved stands. Any other pending preview is simply
|
||||
* discarded. Returns whether `sessionId` was the one being previewed. */
|
||||
commitPreview(sessionId: Uuid): boolean;
|
||||
}
|
||||
|
||||
export type LayoutStore = LayoutState & LayoutActions;
|
||||
@@ -107,10 +134,51 @@ export function createLayoutStore(opts: LayoutStoreOptions = {}) {
|
||||
commit();
|
||||
}
|
||||
|
||||
// ---- preview (docs/PREVIEW.md): a view, not a layout change — never
|
||||
// schedules a save while it is up. ----
|
||||
|
||||
/** Undo the preview's moves. Mutates only — the caller re-renders /
|
||||
* persists as it sees fit. Returns whether a preview was active. */
|
||||
function restorePreview(): boolean {
|
||||
const p = get().preview;
|
||||
if (!p) return false;
|
||||
set({ preview: null });
|
||||
const layout = get().layout;
|
||||
if (p.tempTabId) {
|
||||
const i = layout.tabs.findIndex((t) => t.id === p.tempTabId);
|
||||
if (i >= 0) layout.tabs.splice(i, 1);
|
||||
} else if (p.prevFocus) {
|
||||
const t = layout.tabs.find((x) => x.id === p.prevFocus!.tabId);
|
||||
if (t) t.focusedLeafId = p.prevFocus.leafId;
|
||||
}
|
||||
layout.activeTabId = layout.tabs.some((t) => t.id === p.prevActiveTabId)
|
||||
? p.prevActiveTabId
|
||||
: layout.tabs[0].id;
|
||||
return true;
|
||||
}
|
||||
|
||||
function settlePreview(sessionId: Uuid): boolean {
|
||||
const p = get().preview;
|
||||
if (!p) return false;
|
||||
if (p.sessionId !== sessionId) {
|
||||
restorePreview();
|
||||
return false;
|
||||
}
|
||||
set({ preview: null });
|
||||
if (p.tempTabId) {
|
||||
const layout = get().layout;
|
||||
const t = layout.tabs.find((x) => x.id === p.tempTabId);
|
||||
if (t) t.title = `Tab ${layout.tabs.length}`;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
return {
|
||||
layout: load() ?? emptyLayout(),
|
||||
preview: null,
|
||||
|
||||
openSession(sessionId) {
|
||||
if (get().commitPreview(sessionId)) return;
|
||||
mutate((layout) => {
|
||||
const loc = locateSession(layout, sessionId);
|
||||
if (loc) {
|
||||
@@ -130,6 +198,7 @@ export function createLayoutStore(opts: LayoutStoreOptions = {}) {
|
||||
},
|
||||
|
||||
openSessionBeside(sessionId, placement) {
|
||||
restorePreview();
|
||||
mutate((layout) => {
|
||||
const loc = locateSession(layout, sessionId);
|
||||
if (loc) {
|
||||
@@ -158,6 +227,7 @@ export function createLayoutStore(opts: LayoutStoreOptions = {}) {
|
||||
},
|
||||
|
||||
closeSession(sessionId) {
|
||||
restorePreview();
|
||||
mutate((layout) => {
|
||||
const loc = locateSession(layout, sessionId);
|
||||
if (!loc) return;
|
||||
@@ -170,6 +240,7 @@ export function createLayoutStore(opts: LayoutStoreOptions = {}) {
|
||||
},
|
||||
|
||||
splitFocused(leafId, dir) {
|
||||
restorePreview();
|
||||
mutate((layout) => {
|
||||
const tab = activeTab(layout);
|
||||
tab.focusedLeafId = splitLeaf(tab, leafId, null, dir, true);
|
||||
@@ -178,6 +249,7 @@ export function createLayoutStore(opts: LayoutStoreOptions = {}) {
|
||||
},
|
||||
|
||||
closePane(leafId) {
|
||||
restorePreview();
|
||||
mutate((layout) => {
|
||||
const tab = activeTab(layout);
|
||||
removeLeaf(tab, leafId);
|
||||
@@ -196,24 +268,28 @@ export function createLayoutStore(opts: LayoutStoreOptions = {}) {
|
||||
},
|
||||
|
||||
selectTab(tabId) {
|
||||
restorePreview();
|
||||
mutate((layout) => {
|
||||
if (layout.tabs.some((t) => t.id === tabId)) layout.activeTabId = tabId;
|
||||
});
|
||||
},
|
||||
|
||||
addTab() {
|
||||
restorePreview();
|
||||
mutate((layout) => {
|
||||
addTabToLayout(layout);
|
||||
});
|
||||
},
|
||||
|
||||
closeTab(tabId) {
|
||||
restorePreview();
|
||||
mutate((layout) => {
|
||||
closeTabInLayout(layout, tabId);
|
||||
});
|
||||
},
|
||||
|
||||
reorderTab(fromId, toId) {
|
||||
restorePreview();
|
||||
mutate((layout) => {
|
||||
if (fromId === toId) return;
|
||||
const from = layout.tabs.findIndex((t) => t.id === fromId);
|
||||
@@ -248,6 +324,45 @@ export function createLayoutStore(opts: LayoutStoreOptions = {}) {
|
||||
for (const id of emptied) closeTabInLayout(layout, id);
|
||||
});
|
||||
},
|
||||
|
||||
previewSession(sessionId) {
|
||||
const current = get().preview;
|
||||
if (current?.sessionId === sessionId) return;
|
||||
if (sessionId === null) {
|
||||
if (restorePreview()) commit();
|
||||
return;
|
||||
}
|
||||
restorePreview();
|
||||
const layout = get().layout;
|
||||
const prevActiveTabId = layout.activeTabId;
|
||||
const loc = locateSession(layout, sessionId);
|
||||
let preview: PreviewState;
|
||||
if (loc) {
|
||||
preview = {
|
||||
sessionId,
|
||||
prevActiveTabId,
|
||||
prevFocus: { tabId: loc.tab.id, leafId: loc.tab.focusedLeafId },
|
||||
tempTabId: null,
|
||||
};
|
||||
layout.activeTabId = loc.tab.id;
|
||||
loc.tab.focusedLeafId = loc.leaf.id;
|
||||
} else {
|
||||
// Not on screen anywhere: a throwaway tab carries it, so no
|
||||
// existing pane is evicted and nothing the operator arranged moves.
|
||||
const tab: Tab = { id: freshId("t"), title: "Preview", root: null, focusedLeafId: null };
|
||||
tab.focusedLeafId = splitLeaf(tab, "", sessionId, "row", true);
|
||||
layout.tabs.push(tab);
|
||||
layout.activeTabId = tab.id;
|
||||
preview = { sessionId, prevActiveTabId, prevFocus: null, tempTabId: tab.id };
|
||||
}
|
||||
set({ layout: { ...layout }, preview });
|
||||
},
|
||||
|
||||
commitPreview(sessionId) {
|
||||
const ok = settlePreview(sessionId);
|
||||
if (ok) commit();
|
||||
return ok;
|
||||
},
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user