Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
af5ad1a227 | ||
|
|
8304ef9a49 | ||
|
|
cdba6d07e3 | ||
|
|
ea8c42bfb2 |
@@ -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,82 @@
|
||||
// @vitest-environment happy-dom
|
||||
import { act, cleanup, render, waitFor } from "@testing-library/react";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
|
||||
import { useHashRouter, type Route, type UseHashRouterResult } from "./useHashRouter.js";
|
||||
|
||||
let controls: UseHashRouterResult;
|
||||
|
||||
function Probe() {
|
||||
controls = useHashRouter();
|
||||
return <div>{controls.route.kind === "attach" ? `attach:${controls.route.sessionId}` : "catalog"}</div>;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
location.hash = "";
|
||||
});
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
describe("useHashRouter", () => {
|
||||
it("starts on catalog for an empty hash", () => {
|
||||
const view = render(<Probe />);
|
||||
expect(view.container.textContent).toBe("catalog");
|
||||
});
|
||||
|
||||
it("resolves #/attach/:id to the attach route", () => {
|
||||
location.hash = "#/attach/s1";
|
||||
const view = render(<Probe />);
|
||||
expect(view.container.textContent).toBe("attach:s1");
|
||||
});
|
||||
|
||||
it("decodes an encoded session id", () => {
|
||||
location.hash = `#/attach/${encodeURIComponent("weird id/x")}`;
|
||||
const view = render(<Probe />);
|
||||
expect(view.container.textContent).toBe("attach:weird id/x");
|
||||
});
|
||||
|
||||
it("falls back to catalog for an unknown route", () => {
|
||||
location.hash = "#/nonsense";
|
||||
const view = render(<Probe />);
|
||||
expect(view.container.textContent).toBe("catalog");
|
||||
});
|
||||
|
||||
it("follows a hashchange fired outside the hook", async () => {
|
||||
const view = render(<Probe />);
|
||||
// happy-dom dispatches `hashchange` on a real timer, like a browser — not
|
||||
// synchronously with the assignment.
|
||||
location.hash = "#/attach/s9";
|
||||
await waitFor(() => expect(view.container.textContent).toBe("attach:s9"));
|
||||
});
|
||||
|
||||
it("navigate updates the hash and the route", async () => {
|
||||
const view = render(<Probe />);
|
||||
act(() => controls.navigate({ kind: "attach", sessionId: "s2" } satisfies Route));
|
||||
expect(location.hash).toBe("#/attach/s2");
|
||||
await waitFor(() => expect(view.container.textContent).toBe("attach:s2"));
|
||||
});
|
||||
|
||||
it("navigate back to catalog sets the root hash", async () => {
|
||||
location.hash = "#/attach/s1";
|
||||
const view = render(<Probe />);
|
||||
act(() => controls.navigate({ kind: "catalog" }));
|
||||
expect(location.hash).toBe("#/");
|
||||
await waitFor(() => expect(view.container.textContent).toBe("catalog"));
|
||||
});
|
||||
|
||||
it("navigating to the already-current route still updates state", () => {
|
||||
location.hash = "#/attach/s1";
|
||||
const view = render(<Probe />);
|
||||
act(() => controls.navigate({ kind: "attach", sessionId: "s1" } satisfies Route));
|
||||
expect(view.container.textContent).toBe("attach:s1");
|
||||
});
|
||||
|
||||
it("stops listening after unmount", async () => {
|
||||
const view = render(<Probe />);
|
||||
view.unmount();
|
||||
location.hash = "#/attach/s3";
|
||||
// No assertion beyond "doesn't throw" — a leaked listener would only ever
|
||||
// surface as noise in a later test's act() warnings.
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,47 @@
|
||||
// React port of main.ts's `parseRoute`/`dispatch` pair (issue #240). Same two
|
||||
// routes, same hash shape (`#/`, `#/attach/:id`) — kept so a deep link or a
|
||||
// bookmark from the vanilla shell still resolves.
|
||||
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
|
||||
export type Route = { kind: "catalog" } | { kind: "attach"; sessionId: string };
|
||||
|
||||
function parseHash(hash: string): Route {
|
||||
const raw = hash.replace(/^#/, "");
|
||||
if (raw === "" || raw === "/") return { kind: "catalog" };
|
||||
const m = raw.match(/^\/attach\/([^/?#]+)$/);
|
||||
if (m) return { kind: "attach", sessionId: decodeURIComponent(m[1]) };
|
||||
return { kind: "catalog" };
|
||||
}
|
||||
|
||||
function routeToHash(route: Route): string {
|
||||
return route.kind === "catalog" ? "#/" : `#/attach/${encodeURIComponent(route.sessionId)}`;
|
||||
}
|
||||
|
||||
export interface UseHashRouterResult {
|
||||
route: Route;
|
||||
navigate(route: Route): void;
|
||||
}
|
||||
|
||||
export function useHashRouter(): UseHashRouterResult {
|
||||
const [route, setRoute] = useState<Route>(() => parseHash(location.hash));
|
||||
|
||||
useEffect(() => {
|
||||
const onHashChange = () => setRoute(parseHash(location.hash));
|
||||
window.addEventListener("hashchange", onHashChange);
|
||||
return () => window.removeEventListener("hashchange", onHashChange);
|
||||
}, []);
|
||||
|
||||
const navigate = useCallback((next: Route) => {
|
||||
const hash = routeToHash(next);
|
||||
// Setting an unchanged hash never fires `hashchange`, so the state update
|
||||
// that would otherwise ride on that event has to happen here instead.
|
||||
if (location.hash === hash) {
|
||||
setRoute(next);
|
||||
} else {
|
||||
location.hash = hash;
|
||||
}
|
||||
}, []);
|
||||
|
||||
return { route, navigate };
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
// @vitest-environment happy-dom
|
||||
import { act, cleanup, render } from "@testing-library/react";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { leaves } from "../layout.js";
|
||||
import { createLayoutStore, type LayoutStoreApi } from "../stores/layoutStore.js";
|
||||
import { usePreview, type UsePreviewResult } from "./usePreview.js";
|
||||
|
||||
function fakeScheduler() {
|
||||
const timers = new Map<number, () => void>();
|
||||
let id = 0;
|
||||
return {
|
||||
scheduler: {
|
||||
setTimer: (fn: () => void) => {
|
||||
const h = ++id;
|
||||
timers.set(h, fn);
|
||||
return h;
|
||||
},
|
||||
clearTimer: (h: number) => {
|
||||
timers.delete(h);
|
||||
},
|
||||
},
|
||||
flush() {
|
||||
for (const fn of [...timers.values()]) fn();
|
||||
timers.clear();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** Hand-driven clock + timer queue for the preview schedule's own throttle,
|
||||
* independent of the store's save-debounce scheduler above. */
|
||||
function fakeClock() {
|
||||
let now = 1_000;
|
||||
const timers = new Map<number, { at: number; fn: () => void }>();
|
||||
let handle = 1;
|
||||
return {
|
||||
now: () => now,
|
||||
setTimer: (fn: () => void, ms: number) => {
|
||||
const h = handle++;
|
||||
timers.set(h, { at: now + ms, fn });
|
||||
return h;
|
||||
},
|
||||
clearTimer: (h: number) => {
|
||||
timers.delete(h);
|
||||
},
|
||||
advance(ms: number) {
|
||||
now += ms;
|
||||
for (const [h, t] of [...timers]) {
|
||||
if (t.at <= now) {
|
||||
timers.delete(h);
|
||||
act(() => t.fn());
|
||||
}
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function newStore(): LayoutStoreApi {
|
||||
return createLayoutStore({ load: () => null, save: vi.fn(), scheduler: fakeScheduler().scheduler });
|
||||
}
|
||||
|
||||
let controls: UsePreviewResult;
|
||||
|
||||
function Probe({ store, clock }: { store: LayoutStoreApi; clock: ReturnType<typeof fakeClock> }) {
|
||||
controls = usePreview({
|
||||
store,
|
||||
scheduleDeps: { now: clock.now, setTimer: clock.setTimer, clearTimer: clock.clearTimer },
|
||||
});
|
||||
return <div>{controls.previewedSessionId ?? "none"}</div>;
|
||||
}
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
describe("usePreview", () => {
|
||||
it("shows the first preview immediately — no settling delay", () => {
|
||||
const store = newStore();
|
||||
store.getState().openSession("s1");
|
||||
const clock = fakeClock();
|
||||
const view = render(<Probe store={store} clock={clock} />);
|
||||
|
||||
act(() => controls.preview("s9"));
|
||||
expect(view.container.textContent).toBe("s9");
|
||||
expect(leaves(store.getState().layout.tabs[1]?.root ?? null).map((l) => l.sessionId)).toEqual(["s9"]);
|
||||
});
|
||||
|
||||
it("throttles a burst to one leading + one trailing pane swap", () => {
|
||||
const store = newStore();
|
||||
store.getState().openSession("s1");
|
||||
const clock = fakeClock();
|
||||
const view = render(<Probe store={store} clock={clock} />);
|
||||
|
||||
act(() => {
|
||||
controls.preview("s1");
|
||||
controls.preview("s8");
|
||||
controls.preview("s9");
|
||||
});
|
||||
expect(view.container.textContent).toBe("s1");
|
||||
clock.advance(120);
|
||||
expect(view.container.textContent).toBe("s9");
|
||||
});
|
||||
|
||||
it("touch never previews", () => {
|
||||
const store = newStore();
|
||||
store.getState().openSession("s1");
|
||||
const clock = fakeClock();
|
||||
const view = render(<Probe store={store} clock={clock} />);
|
||||
|
||||
act(() => controls.previewFromPointer("s9", "touch"));
|
||||
expect(view.container.textContent).toBe("none");
|
||||
expect(store.getState().preview).toBeNull();
|
||||
});
|
||||
|
||||
it("previewFromPointer still previews for mouse/pen", () => {
|
||||
const store = newStore();
|
||||
store.getState().openSession("s1");
|
||||
const clock = fakeClock();
|
||||
const view = render(<Probe store={store} clock={clock} />);
|
||||
|
||||
act(() => controls.previewFromPointer("s9", "mouse"));
|
||||
expect(view.container.textContent).toBe("s9");
|
||||
});
|
||||
|
||||
it("commit cancels a pending trailing preview and opens for real", () => {
|
||||
const store = newStore();
|
||||
store.getState().openSession("s1");
|
||||
const clock = fakeClock();
|
||||
const view = render(<Probe store={store} clock={clock} />);
|
||||
|
||||
act(() => {
|
||||
controls.preview("s1"); // leading edge: previews the already-open s1
|
||||
controls.preview("s9"); // queued, gap not elapsed yet
|
||||
controls.commit("s9");
|
||||
});
|
||||
expect(view.container.textContent).toBe("none"); // committed: no longer a preview
|
||||
expect(leaves(store.getState().layout.tabs[0].root).map((l) => l.sessionId)).toEqual(["s9"]);
|
||||
|
||||
clock.advance(120);
|
||||
// The cancelled trailing preview never fires.
|
||||
expect(view.container.textContent).toBe("none");
|
||||
});
|
||||
|
||||
it("end drops a preview immediately, bypassing the throttle", () => {
|
||||
const store = newStore();
|
||||
store.getState().openSession("s1");
|
||||
const clock = fakeClock();
|
||||
const view = render(<Probe store={store} clock={clock} />);
|
||||
|
||||
act(() => controls.preview("s9"));
|
||||
expect(view.container.textContent).toBe("s9");
|
||||
|
||||
act(() => controls.end());
|
||||
expect(view.container.textContent).toBe("none");
|
||||
expect(store.getState().layout.tabs).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("cancels its pending trailing preview on unmount", () => {
|
||||
const store = newStore();
|
||||
store.getState().openSession("s1");
|
||||
store.getState().addTab();
|
||||
store.getState().openSession("s2"); // leading edge below will preview this, already on screen
|
||||
const clock = fakeClock();
|
||||
const view = render(<Probe store={store} clock={clock} />);
|
||||
|
||||
act(() => {
|
||||
controls.preview("s2"); // leading edge: fires now
|
||||
controls.preview("s9"); // queued trailing preview
|
||||
});
|
||||
expect(store.getState().preview?.sessionId).toBe("s2");
|
||||
view.unmount();
|
||||
clock.advance(120);
|
||||
// The queued trailing preview must not fire after unmount.
|
||||
expect(store.getState().preview?.sessionId).toBe("s2");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,81 @@
|
||||
// React port of previewSchedule.ts + multiview.ts's preview controller
|
||||
// (issue #251). Owns the throttled schedule, hands the layout store the
|
||||
// throttled result, and exposes the three ways a preview ends for real —
|
||||
// see docs/PREVIEW.md, the contract this mirrors.
|
||||
|
||||
import { useCallback, useEffect, useRef } from "react";
|
||||
import { useStore } from "zustand";
|
||||
|
||||
import { createPreviewSchedule, type PreviewScheduleDeps } from "../previewSchedule.js";
|
||||
import type { LayoutStore, LayoutStoreApi } from "../stores/layoutStore.js";
|
||||
import type { Uuid } from "../types.js";
|
||||
|
||||
export interface UsePreviewOptions {
|
||||
store: LayoutStoreApi;
|
||||
/** Test seam for the schedule's clock/timers. */
|
||||
scheduleDeps?: PreviewScheduleDeps;
|
||||
}
|
||||
|
||||
export interface UsePreviewResult {
|
||||
/** The session currently shown as a preview, or null. */
|
||||
previewedSessionId: Uuid | null;
|
||||
/** Throttled: show `sessionId` in the content pane, or `null` to put back
|
||||
* whatever was on screen (a highlight move, or the highlight leaving
|
||||
* every session). Never delayed on the leading edge. */
|
||||
preview(sessionId: Uuid | null): void;
|
||||
/** Pointer-hover entry point: touch has no hover, so a touch pointer never
|
||||
* previews (docs/PREVIEW.md). */
|
||||
previewFromPointer(sessionId: Uuid | null, pointerType: string): void;
|
||||
/** Choosing the previewed session — click/Enter on its row, click into its
|
||||
* pane, or handing it the keyboard (Tab). Keeps exactly what is on
|
||||
* screen: a throwaway tab becomes real, a focus move inside an existing
|
||||
* tab stands. Nothing moves a second time. */
|
||||
commit(sessionId: Uuid): void;
|
||||
/** Any other real change — opening the command bar, a dialog, navigating
|
||||
* away — ends a pending or active preview immediately, bypassing the
|
||||
* throttle. */
|
||||
end(): void;
|
||||
}
|
||||
|
||||
/** Preview state management (docs/PREVIEW.md): wraps the layout store's
|
||||
* `previewSession`/`commitPreview` with the 120ms pane-swap throttle so the
|
||||
* UI never has to schedule it by hand. */
|
||||
export function usePreview({ store, scheduleDeps }: UsePreviewOptions): UsePreviewResult {
|
||||
const previewedSessionId = useStore(store, (s: LayoutStore) => s.preview?.sessionId ?? null);
|
||||
|
||||
const scheduleRef = useRef<ReturnType<typeof createPreviewSchedule> | null>(null);
|
||||
if (!scheduleRef.current) {
|
||||
scheduleRef.current = createPreviewSchedule((id) => store.getState().previewSession(id), scheduleDeps);
|
||||
}
|
||||
useEffect(() => {
|
||||
const schedule = scheduleRef.current;
|
||||
return () => schedule?.cancel();
|
||||
}, []);
|
||||
|
||||
const preview = useCallback((sessionId: Uuid | null) => {
|
||||
scheduleRef.current?.(sessionId);
|
||||
}, []);
|
||||
|
||||
const previewFromPointer = useCallback(
|
||||
(sessionId: Uuid | null, pointerType: string) => {
|
||||
if (pointerType === "touch") return;
|
||||
preview(sessionId);
|
||||
},
|
||||
[preview],
|
||||
);
|
||||
|
||||
const commit = useCallback(
|
||||
(sessionId: Uuid) => {
|
||||
scheduleRef.current?.cancel();
|
||||
store.getState().openSession(sessionId);
|
||||
},
|
||||
[store],
|
||||
);
|
||||
|
||||
const end = useCallback(() => {
|
||||
scheduleRef.current?.cancel();
|
||||
store.getState().previewSession(null);
|
||||
}, [store]);
|
||||
|
||||
return { previewedSessionId, preview, previewFromPointer, commit, end };
|
||||
}
|
||||
@@ -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