Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ea8c42bfb2 |
@@ -1,5 +1,5 @@
|
|||||||
// @vitest-environment happy-dom
|
// @vitest-environment happy-dom
|
||||||
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
|
import { act, cleanup, fireEvent, render, screen } from "@testing-library/react";
|
||||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
import { leaves } from "../../layout.js";
|
import { leaves } from "../../layout.js";
|
||||||
@@ -93,4 +93,60 @@ describe("MultiView", () => {
|
|||||||
render(<MultiView store={s} />);
|
render(<MultiView store={s} />);
|
||||||
expect(screen.getByTestId("terminal-s1")).toBeTruthy();
|
expect(screen.getByTestId("terminal-s1")).toBeTruthy();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("preview mode (docs/PREVIEW.md)", () => {
|
||||||
|
it("shows a session no pane holds in a throwaway tab, marked as a preview", () => {
|
||||||
|
const s = store();
|
||||||
|
s.getState().openSession("s1");
|
||||||
|
render(<MultiView store={s} sessions={new Map([["s1", session("s1")]])} />);
|
||||||
|
|
||||||
|
act(() => s.getState().previewSession("s9"));
|
||||||
|
// s1's own tab is still in the strip — the preview never touched it.
|
||||||
|
expect(screen.getByText("Tab 1 · 1")).toBeTruthy();
|
||||||
|
const pane = document.querySelector('[data-session-id="s9"]');
|
||||||
|
expect(pane).toBeTruthy();
|
||||||
|
expect(pane!.querySelector(".bg-preview-tint")).toBeTruthy();
|
||||||
|
expect(screen.getByText("preview")).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("puts the layout back byte for byte once the preview ends", () => {
|
||||||
|
const s = store();
|
||||||
|
s.getState().openSession("s1");
|
||||||
|
render(<MultiView store={s} sessions={new Map([["s1", session("s1")]])} />);
|
||||||
|
const before = JSON.stringify(s.getState().layout);
|
||||||
|
|
||||||
|
act(() => s.getState().previewSession("s9"));
|
||||||
|
act(() => s.getState().previewSession(null));
|
||||||
|
|
||||||
|
expect(JSON.stringify(s.getState().layout)).toBe(before);
|
||||||
|
expect(screen.queryByText("preview")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not grab the keyboard while previewing", () => {
|
||||||
|
const s = store();
|
||||||
|
s.getState().openSession("s1");
|
||||||
|
render(<MultiView store={s} sessions={new Map([["s1", session("s1")]])} />);
|
||||||
|
|
||||||
|
act(() => s.getState().previewSession("s9"));
|
||||||
|
const pane = document.querySelector('[data-session-id="s9"]') as HTMLElement;
|
||||||
|
expect(pane.querySelector('[data-testid="terminal-s9"]')).toBeTruthy();
|
||||||
|
// TerminalPane's `focus` prop is verified by TerminalPane's own tests;
|
||||||
|
// here the preview must never end up as the active-tab's accent pane.
|
||||||
|
expect(pane.className).not.toContain("border-accent");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("clicking into a previewed pane commits it: no more preview, nothing moves a second time", () => {
|
||||||
|
const s = store();
|
||||||
|
s.getState().openSession("s1");
|
||||||
|
render(<MultiView store={s} sessions={new Map([["s1", session("s1")]])} />);
|
||||||
|
|
||||||
|
act(() => s.getState().previewSession("s9"));
|
||||||
|
const pane = document.querySelector('[data-session-id="s9"]') as HTMLElement;
|
||||||
|
fireEvent.pointerDown(pane);
|
||||||
|
|
||||||
|
expect(s.getState().preview).toBeNull();
|
||||||
|
expect(screen.queryByText("preview")).toBeNull();
|
||||||
|
expect(leaves(s.getState().layout.tabs[1].root).map((l) => l.sessionId)).toEqual(["s9"]);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
// React port of multiview.ts's tab + split tree (issue #249). Preview mode
|
// React port of multiview.ts's tab + split tree (issue #249) plus its preview
|
||||||
// (docs/PREVIEW.md) is issue #251; sidebar drag-to-dock and the mobile
|
// mode (issue #251, docs/PREVIEW.md). Sidebar drag-to-dock and the mobile
|
||||||
// single-pane collapse stay with whichever issue wires this into the shell
|
// single-pane collapse stay with whichever issue wires this into the shell
|
||||||
// (#253) — this renders the tree itself: tabs, nested splits, pane chrome.
|
// (#253) — this renders the tree itself: tabs, nested splits, pane chrome.
|
||||||
|
|
||||||
@@ -8,6 +8,7 @@ import { useEffect, useRef } from "react";
|
|||||||
import { useStore } from "zustand";
|
import { useStore } from "zustand";
|
||||||
|
|
||||||
import { activeTab, type Dir, type Node } from "../../layout.js";
|
import { activeTab, type Dir, type Node } from "../../layout.js";
|
||||||
|
import { usePreview } from "../../hooks/usePreview.js";
|
||||||
import type { LayoutStore, LayoutStoreApi } from "../../stores/layoutStore.js";
|
import type { LayoutStore, LayoutStoreApi } from "../../stores/layoutStore.js";
|
||||||
import type { Session, Uuid } from "../../types.js";
|
import type { Session, Uuid } from "../../types.js";
|
||||||
import { PaneContainer } from "./PaneContainer.js";
|
import { PaneContainer } from "./PaneContainer.js";
|
||||||
@@ -26,6 +27,8 @@ export interface MultiViewProps {
|
|||||||
export function MultiView({ store, sessions, onDisconnect, emptyState }: MultiViewProps) {
|
export function MultiView({ store, sessions, onDisconnect, emptyState }: MultiViewProps) {
|
||||||
const layout = useStore(store, (s: LayoutStore) => s.layout);
|
const layout = useStore(store, (s: LayoutStore) => s.layout);
|
||||||
const tab = activeTab(layout);
|
const tab = activeTab(layout);
|
||||||
|
const { previewedSessionId, commit: commitPreview } = usePreview({ store });
|
||||||
|
const isPreviewing = previewedSessionId !== null;
|
||||||
|
|
||||||
const reconciledRef = useRef(false);
|
const reconciledRef = useRef(false);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -37,9 +40,17 @@ export function MultiView({ store, sessions, onDisconnect, emptyState }: MultiVi
|
|||||||
return (
|
return (
|
||||||
<div className="mv flex h-full min-h-0 flex-col">
|
<div className="mv flex h-full min-h-0 flex-col">
|
||||||
<TabBar store={store} />
|
<TabBar store={store} />
|
||||||
<div className="mv-canvas min-h-0 flex-1">
|
<div className="mv-canvas relative min-h-0 flex-1">
|
||||||
{tab.root ? (
|
{tab.root ? (
|
||||||
<SplitNode node={tab.root} tab={tab} store={store} sessions={sessions} onDisconnect={onDisconnect} />
|
<SplitNode
|
||||||
|
node={tab.root}
|
||||||
|
tab={tab}
|
||||||
|
store={store}
|
||||||
|
sessions={sessions}
|
||||||
|
onDisconnect={onDisconnect}
|
||||||
|
previewedSessionId={previewedSessionId}
|
||||||
|
commitPreview={commitPreview}
|
||||||
|
/>
|
||||||
) : (
|
) : (
|
||||||
(emptyState ?? (
|
(emptyState ?? (
|
||||||
<div className="mv-empty flex h-full items-center justify-center p-sp-4 text-fs-13 text-faint">
|
<div className="mv-empty flex h-full items-center justify-center p-sp-4 text-fs-13 text-faint">
|
||||||
@@ -47,6 +58,14 @@ export function MultiView({ store, sessions, onDisconnect, emptyState }: MultiVi
|
|||||||
</div>
|
</div>
|
||||||
))
|
))
|
||||||
)}
|
)}
|
||||||
|
{isPreviewing && (
|
||||||
|
<>
|
||||||
|
<div className="mv-preview-ring pointer-events-none absolute inset-0 rounded-r-2 border border-dashed border-preview-ring" />
|
||||||
|
<div className="mv-preview-chip pointer-events-none absolute right-sp-2 top-sp-2 z-sticky rounded-r-pill bg-preview px-sp-2 py-[2px] text-fs-11 text-preview-ink">
|
||||||
|
preview
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -57,6 +76,8 @@ interface TreeProps {
|
|||||||
store: LayoutStoreApi;
|
store: LayoutStoreApi;
|
||||||
sessions?: ReadonlyMap<Uuid, Session>;
|
sessions?: ReadonlyMap<Uuid, Session>;
|
||||||
onDisconnect?: (sessionId: Uuid) => void;
|
onDisconnect?: (sessionId: Uuid) => void;
|
||||||
|
previewedSessionId: Uuid | null;
|
||||||
|
commitPreview: (sessionId: Uuid) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
function SplitNode({ node, ...rest }: TreeProps & { node: Node }) {
|
function SplitNode({ node, ...rest }: TreeProps & { node: Node }) {
|
||||||
@@ -127,21 +148,34 @@ function Pane({
|
|||||||
store,
|
store,
|
||||||
sessions,
|
sessions,
|
||||||
onDisconnect,
|
onDisconnect,
|
||||||
|
previewedSessionId,
|
||||||
|
commitPreview,
|
||||||
}: TreeProps & { leaf: Extract<Node, { kind: "leaf" }>; isFocused: boolean }) {
|
}: TreeProps & { leaf: Extract<Node, { kind: "leaf" }>; isFocused: boolean }) {
|
||||||
const session = leaf.sessionId ? sessions?.get(leaf.sessionId) : undefined;
|
const session = leaf.sessionId ? sessions?.get(leaf.sessionId) : undefined;
|
||||||
|
const isPreview = leaf.sessionId !== null && leaf.sessionId === previewedSessionId;
|
||||||
const splitFocused = (dir: Dir) => store.getState().splitFocused(leaf.id, dir);
|
const splitFocused = (dir: Dir) => store.getState().splitFocused(leaf.id, dir);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={
|
className={
|
||||||
"mv-pane flex h-full min-h-0 min-w-0 flex-col border " +
|
"mv-pane flex h-full min-h-0 min-w-0 flex-col border " +
|
||||||
(isFocused ? "border-accent" : "border-border-strong")
|
(isFocused && !isPreview ? "border-accent" : "border-border-strong")
|
||||||
}
|
}
|
||||||
data-leaf-id={leaf.id}
|
data-leaf-id={leaf.id}
|
||||||
data-session-id={leaf.sessionId ?? undefined}
|
data-session-id={leaf.sessionId ?? undefined}
|
||||||
onPointerDownCapture={() => store.getState().focusLeaf(leaf.id)}
|
onPointerDownCapture={() => {
|
||||||
|
// Clicking into a previewed pane is the operator choosing it
|
||||||
|
// (docs/PREVIEW.md).
|
||||||
|
if (isPreview) commitPreview(leaf.sessionId!);
|
||||||
|
store.getState().focusLeaf(leaf.id);
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<div className="mv-pane-header flex items-center justify-between gap-sp-2 border-b border-border-strong bg-surface px-sp-2 py-sp-1 text-fs-12">
|
<div
|
||||||
|
className={
|
||||||
|
"mv-pane-header flex items-center justify-between gap-sp-2 border-b border-border-strong px-sp-2 py-sp-1 text-fs-12 " +
|
||||||
|
(isPreview ? "bg-preview-tint text-preview" : "bg-surface")
|
||||||
|
}
|
||||||
|
>
|
||||||
{leaf.sessionId ? (
|
{leaf.sessionId ? (
|
||||||
<PaneTitle sessionId={leaf.sessionId} session={session} />
|
<PaneTitle sessionId={leaf.sessionId} session={session} />
|
||||||
) : (
|
) : (
|
||||||
@@ -189,7 +223,7 @@ function Pane({
|
|||||||
<PaneContainer
|
<PaneContainer
|
||||||
sessionId={leaf.sessionId}
|
sessionId={leaf.sessionId}
|
||||||
session={session}
|
session={session}
|
||||||
focus={isFocused}
|
focus={isFocused && previewedSessionId === null}
|
||||||
onClosed={() => store.getState().closeSession(leaf.sessionId!)}
|
onClosed={() => store.getState().closeSession(leaf.sessionId!)}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ export interface TabBarProps {
|
|||||||
|
|
||||||
export function TabBar({ store }: TabBarProps) {
|
export function TabBar({ store }: TabBarProps) {
|
||||||
const layout = useStore(store, (s: LayoutStore) => s.layout);
|
const layout = useStore(store, (s: LayoutStore) => s.layout);
|
||||||
|
const previewTempTabId = useStore(store, (s: LayoutStore) => s.preview?.tempTabId ?? null);
|
||||||
const [draggingId, setDraggingId] = useState<string | null>(null);
|
const [draggingId, setDraggingId] = useState<string | null>(null);
|
||||||
const [overId, setOverId] = useState<string | null>(null);
|
const [overId, setOverId] = useState<string | null>(null);
|
||||||
|
|
||||||
@@ -22,6 +23,9 @@ export function TabBar({ store }: TabBarProps) {
|
|||||||
const count = leaves(tab.root).filter((l) => l.sessionId).length;
|
const count = leaves(tab.root).filter((l) => l.sessionId).length;
|
||||||
const isActive = tab.id === layout.activeTabId;
|
const isActive = tab.id === layout.activeTabId;
|
||||||
const isDropTarget = overId === tab.id && draggingId !== null && draggingId !== tab.id;
|
const isDropTarget = overId === tab.id && draggingId !== null && draggingId !== tab.id;
|
||||||
|
// A preview's throwaway tab is not a place to go — it goes on its
|
||||||
|
// own the moment the preview ends (docs/PREVIEW.md).
|
||||||
|
const isPreviewTab = tab.id === previewTempTabId;
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
key={tab.id}
|
key={tab.id}
|
||||||
@@ -32,7 +36,11 @@ export function TabBar({ store }: TabBarProps) {
|
|||||||
data-tab-id={tab.id}
|
data-tab-id={tab.id}
|
||||||
className={
|
className={
|
||||||
"mv-tab flex items-center gap-sp-1 rounded-t-r-1 px-sp-2 py-sp-1 " +
|
"mv-tab flex items-center gap-sp-1 rounded-t-r-1 px-sp-2 py-sp-1 " +
|
||||||
(isActive ? "bg-surface-2 text-ink" : "text-faint hover:text-ink") +
|
(isPreviewTab
|
||||||
|
? "border border-dashed border-preview-ring italic text-preview"
|
||||||
|
: isActive
|
||||||
|
? "bg-surface-2 text-ink"
|
||||||
|
: "text-faint hover:text-ink") +
|
||||||
(isDropTarget ? " outline outline-2 outline-accent" : "")
|
(isDropTarget ? " outline outline-2 outline-accent" : "")
|
||||||
}
|
}
|
||||||
onDragStart={(e) => {
|
onDragStart={(e) => {
|
||||||
@@ -57,13 +65,16 @@ export function TabBar({ store }: TabBarProps) {
|
|||||||
setDraggingId(null);
|
setDraggingId(null);
|
||||||
setOverId(null);
|
setOverId(null);
|
||||||
}}
|
}}
|
||||||
onClick={() => store.getState().selectTab(tab.id)}
|
onClick={() => {
|
||||||
|
if (isPreviewTab) return;
|
||||||
|
store.getState().selectTab(tab.id);
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<span>
|
<span>
|
||||||
{tab.title}
|
{tab.title}
|
||||||
{count ? ` · ${count}` : ""}
|
{count ? ` · ${count}` : ""}
|
||||||
</span>
|
</span>
|
||||||
{layout.tabs.length > 1 && (
|
{layout.tabs.length > 1 && !isPreviewTab && (
|
||||||
<span
|
<span
|
||||||
className="mv-tab-close text-faint hover:text-ink"
|
className="mv-tab-close text-faint hover:text-ink"
|
||||||
title="Close tab"
|
title="Close tab"
|
||||||
|
|||||||
@@ -0,0 +1,174 @@
|
|||||||
|
// @vitest-environment happy-dom
|
||||||
|
import { act, cleanup, render } from "@testing-library/react";
|
||||||
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
import { leaves } from "../layout.js";
|
||||||
|
import { createLayoutStore, type LayoutStoreApi } from "../stores/layoutStore.js";
|
||||||
|
import { usePreview, type UsePreviewResult } from "./usePreview.js";
|
||||||
|
|
||||||
|
function fakeScheduler() {
|
||||||
|
const timers = new Map<number, () => void>();
|
||||||
|
let id = 0;
|
||||||
|
return {
|
||||||
|
scheduler: {
|
||||||
|
setTimer: (fn: () => void) => {
|
||||||
|
const h = ++id;
|
||||||
|
timers.set(h, fn);
|
||||||
|
return h;
|
||||||
|
},
|
||||||
|
clearTimer: (h: number) => {
|
||||||
|
timers.delete(h);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
flush() {
|
||||||
|
for (const fn of [...timers.values()]) fn();
|
||||||
|
timers.clear();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Hand-driven clock + timer queue for the preview schedule's own throttle,
|
||||||
|
* independent of the store's save-debounce scheduler above. */
|
||||||
|
function fakeClock() {
|
||||||
|
let now = 1_000;
|
||||||
|
const timers = new Map<number, { at: number; fn: () => void }>();
|
||||||
|
let handle = 1;
|
||||||
|
return {
|
||||||
|
now: () => now,
|
||||||
|
setTimer: (fn: () => void, ms: number) => {
|
||||||
|
const h = handle++;
|
||||||
|
timers.set(h, { at: now + ms, fn });
|
||||||
|
return h;
|
||||||
|
},
|
||||||
|
clearTimer: (h: number) => {
|
||||||
|
timers.delete(h);
|
||||||
|
},
|
||||||
|
advance(ms: number) {
|
||||||
|
now += ms;
|
||||||
|
for (const [h, t] of [...timers]) {
|
||||||
|
if (t.at <= now) {
|
||||||
|
timers.delete(h);
|
||||||
|
act(() => t.fn());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function newStore(): LayoutStoreApi {
|
||||||
|
return createLayoutStore({ load: () => null, save: vi.fn(), scheduler: fakeScheduler().scheduler });
|
||||||
|
}
|
||||||
|
|
||||||
|
let controls: UsePreviewResult;
|
||||||
|
|
||||||
|
function Probe({ store, clock }: { store: LayoutStoreApi; clock: ReturnType<typeof fakeClock> }) {
|
||||||
|
controls = usePreview({
|
||||||
|
store,
|
||||||
|
scheduleDeps: { now: clock.now, setTimer: clock.setTimer, clearTimer: clock.clearTimer },
|
||||||
|
});
|
||||||
|
return <div>{controls.previewedSessionId ?? "none"}</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
afterEach(cleanup);
|
||||||
|
|
||||||
|
describe("usePreview", () => {
|
||||||
|
it("shows the first preview immediately — no settling delay", () => {
|
||||||
|
const store = newStore();
|
||||||
|
store.getState().openSession("s1");
|
||||||
|
const clock = fakeClock();
|
||||||
|
const view = render(<Probe store={store} clock={clock} />);
|
||||||
|
|
||||||
|
act(() => controls.preview("s9"));
|
||||||
|
expect(view.container.textContent).toBe("s9");
|
||||||
|
expect(leaves(store.getState().layout.tabs[1]?.root ?? null).map((l) => l.sessionId)).toEqual(["s9"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("throttles a burst to one leading + one trailing pane swap", () => {
|
||||||
|
const store = newStore();
|
||||||
|
store.getState().openSession("s1");
|
||||||
|
const clock = fakeClock();
|
||||||
|
const view = render(<Probe store={store} clock={clock} />);
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
controls.preview("s1");
|
||||||
|
controls.preview("s8");
|
||||||
|
controls.preview("s9");
|
||||||
|
});
|
||||||
|
expect(view.container.textContent).toBe("s1");
|
||||||
|
clock.advance(120);
|
||||||
|
expect(view.container.textContent).toBe("s9");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("touch never previews", () => {
|
||||||
|
const store = newStore();
|
||||||
|
store.getState().openSession("s1");
|
||||||
|
const clock = fakeClock();
|
||||||
|
const view = render(<Probe store={store} clock={clock} />);
|
||||||
|
|
||||||
|
act(() => controls.previewFromPointer("s9", "touch"));
|
||||||
|
expect(view.container.textContent).toBe("none");
|
||||||
|
expect(store.getState().preview).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("previewFromPointer still previews for mouse/pen", () => {
|
||||||
|
const store = newStore();
|
||||||
|
store.getState().openSession("s1");
|
||||||
|
const clock = fakeClock();
|
||||||
|
const view = render(<Probe store={store} clock={clock} />);
|
||||||
|
|
||||||
|
act(() => controls.previewFromPointer("s9", "mouse"));
|
||||||
|
expect(view.container.textContent).toBe("s9");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("commit cancels a pending trailing preview and opens for real", () => {
|
||||||
|
const store = newStore();
|
||||||
|
store.getState().openSession("s1");
|
||||||
|
const clock = fakeClock();
|
||||||
|
const view = render(<Probe store={store} clock={clock} />);
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
controls.preview("s1"); // leading edge: previews the already-open s1
|
||||||
|
controls.preview("s9"); // queued, gap not elapsed yet
|
||||||
|
controls.commit("s9");
|
||||||
|
});
|
||||||
|
expect(view.container.textContent).toBe("none"); // committed: no longer a preview
|
||||||
|
expect(leaves(store.getState().layout.tabs[0].root).map((l) => l.sessionId)).toEqual(["s9"]);
|
||||||
|
|
||||||
|
clock.advance(120);
|
||||||
|
// The cancelled trailing preview never fires.
|
||||||
|
expect(view.container.textContent).toBe("none");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("end drops a preview immediately, bypassing the throttle", () => {
|
||||||
|
const store = newStore();
|
||||||
|
store.getState().openSession("s1");
|
||||||
|
const clock = fakeClock();
|
||||||
|
const view = render(<Probe store={store} clock={clock} />);
|
||||||
|
|
||||||
|
act(() => controls.preview("s9"));
|
||||||
|
expect(view.container.textContent).toBe("s9");
|
||||||
|
|
||||||
|
act(() => controls.end());
|
||||||
|
expect(view.container.textContent).toBe("none");
|
||||||
|
expect(store.getState().layout.tabs).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("cancels its pending trailing preview on unmount", () => {
|
||||||
|
const store = newStore();
|
||||||
|
store.getState().openSession("s1");
|
||||||
|
store.getState().addTab();
|
||||||
|
store.getState().openSession("s2"); // leading edge below will preview this, already on screen
|
||||||
|
const clock = fakeClock();
|
||||||
|
const view = render(<Probe store={store} clock={clock} />);
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
controls.preview("s2"); // leading edge: fires now
|
||||||
|
controls.preview("s9"); // queued trailing preview
|
||||||
|
});
|
||||||
|
expect(store.getState().preview?.sessionId).toBe("s2");
|
||||||
|
view.unmount();
|
||||||
|
clock.advance(120);
|
||||||
|
// The queued trailing preview must not fire after unmount.
|
||||||
|
expect(store.getState().preview?.sessionId).toBe("s2");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
// React port of previewSchedule.ts + multiview.ts's preview controller
|
||||||
|
// (issue #251). Owns the throttled schedule, hands the layout store the
|
||||||
|
// throttled result, and exposes the three ways a preview ends for real —
|
||||||
|
// see docs/PREVIEW.md, the contract this mirrors.
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useRef } from "react";
|
||||||
|
import { useStore } from "zustand";
|
||||||
|
|
||||||
|
import { createPreviewSchedule, type PreviewScheduleDeps } from "../previewSchedule.js";
|
||||||
|
import type { LayoutStore, LayoutStoreApi } from "../stores/layoutStore.js";
|
||||||
|
import type { Uuid } from "../types.js";
|
||||||
|
|
||||||
|
export interface UsePreviewOptions {
|
||||||
|
store: LayoutStoreApi;
|
||||||
|
/** Test seam for the schedule's clock/timers. */
|
||||||
|
scheduleDeps?: PreviewScheduleDeps;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UsePreviewResult {
|
||||||
|
/** The session currently shown as a preview, or null. */
|
||||||
|
previewedSessionId: Uuid | null;
|
||||||
|
/** Throttled: show `sessionId` in the content pane, or `null` to put back
|
||||||
|
* whatever was on screen (a highlight move, or the highlight leaving
|
||||||
|
* every session). Never delayed on the leading edge. */
|
||||||
|
preview(sessionId: Uuid | null): void;
|
||||||
|
/** Pointer-hover entry point: touch has no hover, so a touch pointer never
|
||||||
|
* previews (docs/PREVIEW.md). */
|
||||||
|
previewFromPointer(sessionId: Uuid | null, pointerType: string): void;
|
||||||
|
/** Choosing the previewed session — click/Enter on its row, click into its
|
||||||
|
* pane, or handing it the keyboard (Tab). Keeps exactly what is on
|
||||||
|
* screen: a throwaway tab becomes real, a focus move inside an existing
|
||||||
|
* tab stands. Nothing moves a second time. */
|
||||||
|
commit(sessionId: Uuid): void;
|
||||||
|
/** Any other real change — opening the command bar, a dialog, navigating
|
||||||
|
* away — ends a pending or active preview immediately, bypassing the
|
||||||
|
* throttle. */
|
||||||
|
end(): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Preview state management (docs/PREVIEW.md): wraps the layout store's
|
||||||
|
* `previewSession`/`commitPreview` with the 120ms pane-swap throttle so the
|
||||||
|
* UI never has to schedule it by hand. */
|
||||||
|
export function usePreview({ store, scheduleDeps }: UsePreviewOptions): UsePreviewResult {
|
||||||
|
const previewedSessionId = useStore(store, (s: LayoutStore) => s.preview?.sessionId ?? null);
|
||||||
|
|
||||||
|
const scheduleRef = useRef<ReturnType<typeof createPreviewSchedule> | null>(null);
|
||||||
|
if (!scheduleRef.current) {
|
||||||
|
scheduleRef.current = createPreviewSchedule((id) => store.getState().previewSession(id), scheduleDeps);
|
||||||
|
}
|
||||||
|
useEffect(() => {
|
||||||
|
const schedule = scheduleRef.current;
|
||||||
|
return () => schedule?.cancel();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const preview = useCallback((sessionId: Uuid | null) => {
|
||||||
|
scheduleRef.current?.(sessionId);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const previewFromPointer = useCallback(
|
||||||
|
(sessionId: Uuid | null, pointerType: string) => {
|
||||||
|
if (pointerType === "touch") return;
|
||||||
|
preview(sessionId);
|
||||||
|
},
|
||||||
|
[preview],
|
||||||
|
);
|
||||||
|
|
||||||
|
const commit = useCallback(
|
||||||
|
(sessionId: Uuid) => {
|
||||||
|
scheduleRef.current?.cancel();
|
||||||
|
store.getState().openSession(sessionId);
|
||||||
|
},
|
||||||
|
[store],
|
||||||
|
);
|
||||||
|
|
||||||
|
const end = useCallback(() => {
|
||||||
|
scheduleRef.current?.cancel();
|
||||||
|
store.getState().previewSession(null);
|
||||||
|
}, [store]);
|
||||||
|
|
||||||
|
return { previewedSessionId, preview, previewFromPointer, commit, end };
|
||||||
|
}
|
||||||
@@ -159,4 +159,133 @@ describe("layoutStore", () => {
|
|||||||
expect(save).toHaveBeenCalledTimes(1);
|
expect(save).toHaveBeenCalledTimes(1);
|
||||||
expect(save.mock.calls[0][0]).toBe(s.getState().layout);
|
expect(save.mock.calls[0][0]).toBe(s.getState().layout);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("previewSession (docs/PREVIEW.md)", () => {
|
||||||
|
it("brings a session's own tab forward, then puts the layout back", () => {
|
||||||
|
const s = store();
|
||||||
|
s.getState().openSession("s1");
|
||||||
|
s.getState().addTab();
|
||||||
|
s.getState().openSession("s2");
|
||||||
|
const before = JSON.stringify(s.getState().layout);
|
||||||
|
|
||||||
|
s.getState().previewSession("s1");
|
||||||
|
const tab1 = s.getState().layout.tabs.find((t) => leaves(t.root).some((l) => l.sessionId === "s1"))!;
|
||||||
|
expect(s.getState().layout.activeTabId).toBe(tab1.id);
|
||||||
|
expect(s.getState().preview?.sessionId).toBe("s1");
|
||||||
|
|
||||||
|
s.getState().previewSession(null);
|
||||||
|
expect(s.getState().preview).toBeNull();
|
||||||
|
expect(JSON.stringify(s.getState().layout)).toBe(before);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows a session no pane holds in a throwaway tab that leaves no trace", () => {
|
||||||
|
const s = store();
|
||||||
|
s.getState().openSession("s1");
|
||||||
|
const before = JSON.stringify(s.getState().layout);
|
||||||
|
|
||||||
|
s.getState().previewSession("s9");
|
||||||
|
expect(s.getState().layout.tabs).toHaveLength(2);
|
||||||
|
const tempTab = s.getState().layout.tabs[1];
|
||||||
|
expect(leaves(tempTab.root).map((l) => l.sessionId)).toEqual(["s9"]);
|
||||||
|
|
||||||
|
s.getState().previewSession(null);
|
||||||
|
expect(s.getState().layout.tabs).toHaveLength(1);
|
||||||
|
expect(JSON.stringify(s.getState().layout)).toBe(before);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("stepping between previews leaves only the newest one on screen", () => {
|
||||||
|
const s = store();
|
||||||
|
s.getState().openSession("s1");
|
||||||
|
const before = JSON.stringify(s.getState().layout);
|
||||||
|
|
||||||
|
s.getState().previewSession("s9");
|
||||||
|
s.getState().previewSession("s8");
|
||||||
|
expect(s.getState().layout.tabs).toHaveLength(2);
|
||||||
|
expect(s.getState().preview?.sessionId).toBe("s8");
|
||||||
|
|
||||||
|
s.getState().previewSession(null);
|
||||||
|
expect(JSON.stringify(s.getState().layout)).toBe(before);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("never persists while up, and saves again once settled back", () => {
|
||||||
|
const save = vi.fn();
|
||||||
|
const { scheduler, flush } = fakeScheduler();
|
||||||
|
const s = store({ save, scheduler });
|
||||||
|
s.getState().openSession("s1");
|
||||||
|
flush();
|
||||||
|
save.mockClear();
|
||||||
|
|
||||||
|
s.getState().previewSession("s9");
|
||||||
|
expect(save).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
s.getState().previewSession(null);
|
||||||
|
flush();
|
||||||
|
expect(save).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("commitPreview keeps the throwaway tab as a real one", () => {
|
||||||
|
const s = store();
|
||||||
|
s.getState().openSession("s1");
|
||||||
|
s.getState().previewSession("s9");
|
||||||
|
|
||||||
|
expect(s.getState().commitPreview("s9")).toBe(true);
|
||||||
|
expect(s.getState().preview).toBeNull();
|
||||||
|
expect(s.getState().layout.tabs).toHaveLength(2);
|
||||||
|
expect(leaves(s.getState().layout.tabs[1].root).map((l) => l.sessionId)).toEqual(["s9"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("commitPreview discards a non-matching preview and reports false", () => {
|
||||||
|
const s = store();
|
||||||
|
s.getState().openSession("s1");
|
||||||
|
s.getState().previewSession("s9");
|
||||||
|
|
||||||
|
expect(s.getState().commitPreview("s1")).toBe(false);
|
||||||
|
expect(s.getState().preview).toBeNull();
|
||||||
|
expect(s.getState().layout.tabs).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("openSession settles a matching preview instead of moving it a second time", () => {
|
||||||
|
const s = store();
|
||||||
|
s.getState().openSession("s1");
|
||||||
|
s.getState().previewSession("s9");
|
||||||
|
|
||||||
|
s.getState().openSession("s9");
|
||||||
|
expect(s.getState().preview).toBeNull();
|
||||||
|
expect(s.getState().layout.tabs).toHaveLength(2);
|
||||||
|
expect(leaves(s.getState().layout.tabs[0].root).map((l) => l.sessionId)).toEqual(["s1"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("any real layout change ends a preview first: splitFocused", () => {
|
||||||
|
const s = store();
|
||||||
|
s.getState().openSession("s1");
|
||||||
|
s.getState().previewSession("s9");
|
||||||
|
const first = leaves(s.getState().layout.tabs[0].root)[0].id;
|
||||||
|
|
||||||
|
s.getState().splitFocused(first, "row");
|
||||||
|
expect(s.getState().preview).toBeNull();
|
||||||
|
expect(s.getState().layout.tabs).toHaveLength(1);
|
||||||
|
expect(leaves(s.getState().layout.tabs[0].root).map((l) => l.sessionId).sort()).toEqual([null, "s1"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("any real layout change ends a preview first: addTab/selectTab/closeTab", () => {
|
||||||
|
const s = store();
|
||||||
|
s.getState().openSession("s1");
|
||||||
|
s.getState().previewSession("s9");
|
||||||
|
s.getState().addTab();
|
||||||
|
expect(s.getState().preview).toBeNull();
|
||||||
|
// The throwaway tab was discarded before the real tab was added.
|
||||||
|
expect(s.getState().layout.tabs).toHaveLength(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("closeSession drops the preview before closing the real pane", () => {
|
||||||
|
const s = store();
|
||||||
|
s.getState().openSession("s1");
|
||||||
|
s.getState().previewSession("s9");
|
||||||
|
|
||||||
|
s.getState().closeSession("s1");
|
||||||
|
expect(s.getState().preview).toBeNull();
|
||||||
|
expect(s.getState().layout.tabs).toHaveLength(1);
|
||||||
|
expect(s.getState().layout.tabs[0].root).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -11,12 +11,14 @@ import {
|
|||||||
emptyLayout,
|
emptyLayout,
|
||||||
equalizeRatios,
|
equalizeRatios,
|
||||||
findLeaf,
|
findLeaf,
|
||||||
|
freshId,
|
||||||
leaves,
|
leaves,
|
||||||
locateSession,
|
locateSession,
|
||||||
removeLeaf,
|
removeLeaf,
|
||||||
splitLeaf,
|
splitLeaf,
|
||||||
type Dir,
|
type Dir,
|
||||||
type Layout,
|
type Layout,
|
||||||
|
type Tab,
|
||||||
} from "../layout.js";
|
} from "../layout.js";
|
||||||
import { loadLayout, saveLayout } from "../persistence.js";
|
import { loadLayout, saveLayout } from "../persistence.js";
|
||||||
import type { Uuid } from "../types.js";
|
import type { Uuid } from "../types.js";
|
||||||
@@ -35,13 +37,29 @@ export interface LayoutStoreOptions {
|
|||||||
scheduler?: Scheduler;
|
scheduler?: Scheduler;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** A provisional preview (docs/PREVIEW.md): records exactly what
|
||||||
|
* `previewSession` moved, so ending it restores the layout byte-for-byte. */
|
||||||
|
export interface PreviewState {
|
||||||
|
sessionId: Uuid;
|
||||||
|
/** Tab that was active before the preview. */
|
||||||
|
prevActiveTabId: string;
|
||||||
|
/** Focused leaf to restore, when the preview stole focus in a real tab. */
|
||||||
|
prevFocus: { tabId: string; leafId: string | null } | null;
|
||||||
|
/** Throwaway tab built for a session that no pane was showing. */
|
||||||
|
tempTabId: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
export interface LayoutState {
|
export interface LayoutState {
|
||||||
layout: Layout;
|
layout: Layout;
|
||||||
|
/** Session shown provisionally in the content pane, or null. A preview is
|
||||||
|
* a view, not state: it never reaches `save` (docs/PREVIEW.md). */
|
||||||
|
preview: PreviewState | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface LayoutActions {
|
export interface LayoutActions {
|
||||||
/** Bring `sessionId` into view: focus it if already open, else fill the
|
/** Bring `sessionId` into view: focus it if already open, else fill the
|
||||||
* focused pane (VS-Code-style replace), or seed an empty tab. */
|
* focused pane (VS-Code-style replace), or seed an empty tab. Settles a
|
||||||
|
* matching preview in place rather than moving it a second time. */
|
||||||
openSession(sessionId: Uuid): void;
|
openSession(sessionId: Uuid): void;
|
||||||
/** Same, but never evicts a running pane: reuses an empty leaf or splits
|
/** Same, but never evicts a running pane: reuses an empty leaf or splits
|
||||||
* beside the focused one. `newTab` opens its own tab instead. */
|
* beside the focused one. `newTab` opens its own tab instead. */
|
||||||
@@ -66,6 +84,15 @@ export interface LayoutActions {
|
|||||||
* tab was closed is pruned instead of leaving a pane that can only ever
|
* tab was closed is pruned instead of leaving a pane that can only ever
|
||||||
* show a dead socket. Tabs that were already empty are left alone. */
|
* show a dead socket. Tabs that were already empty are left alone. */
|
||||||
reconcile(liveIds: ReadonlySet<Uuid>): void;
|
reconcile(liveIds: ReadonlySet<Uuid>): void;
|
||||||
|
/** Show `sessionId` in the content pane without committing to it
|
||||||
|
* (docs/PREVIEW.md): its own tab comes forward, or a throwaway tab
|
||||||
|
* carries it. `null` puts back whatever was on screen before. Never
|
||||||
|
* schedules a save while a preview is up. */
|
||||||
|
previewSession(sessionId: Uuid | null): void;
|
||||||
|
/** Keep what is being previewed: its throwaway tab becomes a real one, and
|
||||||
|
* the focus it moved stands. Any other pending preview is simply
|
||||||
|
* discarded. Returns whether `sessionId` was the one being previewed. */
|
||||||
|
commitPreview(sessionId: Uuid): boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type LayoutStore = LayoutState & LayoutActions;
|
export type LayoutStore = LayoutState & LayoutActions;
|
||||||
@@ -107,10 +134,51 @@ export function createLayoutStore(opts: LayoutStoreOptions = {}) {
|
|||||||
commit();
|
commit();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- preview (docs/PREVIEW.md): a view, not a layout change — never
|
||||||
|
// schedules a save while it is up. ----
|
||||||
|
|
||||||
|
/** Undo the preview's moves. Mutates only — the caller re-renders /
|
||||||
|
* persists as it sees fit. Returns whether a preview was active. */
|
||||||
|
function restorePreview(): boolean {
|
||||||
|
const p = get().preview;
|
||||||
|
if (!p) return false;
|
||||||
|
set({ preview: null });
|
||||||
|
const layout = get().layout;
|
||||||
|
if (p.tempTabId) {
|
||||||
|
const i = layout.tabs.findIndex((t) => t.id === p.tempTabId);
|
||||||
|
if (i >= 0) layout.tabs.splice(i, 1);
|
||||||
|
} else if (p.prevFocus) {
|
||||||
|
const t = layout.tabs.find((x) => x.id === p.prevFocus!.tabId);
|
||||||
|
if (t) t.focusedLeafId = p.prevFocus.leafId;
|
||||||
|
}
|
||||||
|
layout.activeTabId = layout.tabs.some((t) => t.id === p.prevActiveTabId)
|
||||||
|
? p.prevActiveTabId
|
||||||
|
: layout.tabs[0].id;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function settlePreview(sessionId: Uuid): boolean {
|
||||||
|
const p = get().preview;
|
||||||
|
if (!p) return false;
|
||||||
|
if (p.sessionId !== sessionId) {
|
||||||
|
restorePreview();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
set({ preview: null });
|
||||||
|
if (p.tempTabId) {
|
||||||
|
const layout = get().layout;
|
||||||
|
const t = layout.tabs.find((x) => x.id === p.tempTabId);
|
||||||
|
if (t) t.title = `Tab ${layout.tabs.length}`;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
layout: load() ?? emptyLayout(),
|
layout: load() ?? emptyLayout(),
|
||||||
|
preview: null,
|
||||||
|
|
||||||
openSession(sessionId) {
|
openSession(sessionId) {
|
||||||
|
if (get().commitPreview(sessionId)) return;
|
||||||
mutate((layout) => {
|
mutate((layout) => {
|
||||||
const loc = locateSession(layout, sessionId);
|
const loc = locateSession(layout, sessionId);
|
||||||
if (loc) {
|
if (loc) {
|
||||||
@@ -130,6 +198,7 @@ export function createLayoutStore(opts: LayoutStoreOptions = {}) {
|
|||||||
},
|
},
|
||||||
|
|
||||||
openSessionBeside(sessionId, placement) {
|
openSessionBeside(sessionId, placement) {
|
||||||
|
restorePreview();
|
||||||
mutate((layout) => {
|
mutate((layout) => {
|
||||||
const loc = locateSession(layout, sessionId);
|
const loc = locateSession(layout, sessionId);
|
||||||
if (loc) {
|
if (loc) {
|
||||||
@@ -158,6 +227,7 @@ export function createLayoutStore(opts: LayoutStoreOptions = {}) {
|
|||||||
},
|
},
|
||||||
|
|
||||||
closeSession(sessionId) {
|
closeSession(sessionId) {
|
||||||
|
restorePreview();
|
||||||
mutate((layout) => {
|
mutate((layout) => {
|
||||||
const loc = locateSession(layout, sessionId);
|
const loc = locateSession(layout, sessionId);
|
||||||
if (!loc) return;
|
if (!loc) return;
|
||||||
@@ -170,6 +240,7 @@ export function createLayoutStore(opts: LayoutStoreOptions = {}) {
|
|||||||
},
|
},
|
||||||
|
|
||||||
splitFocused(leafId, dir) {
|
splitFocused(leafId, dir) {
|
||||||
|
restorePreview();
|
||||||
mutate((layout) => {
|
mutate((layout) => {
|
||||||
const tab = activeTab(layout);
|
const tab = activeTab(layout);
|
||||||
tab.focusedLeafId = splitLeaf(tab, leafId, null, dir, true);
|
tab.focusedLeafId = splitLeaf(tab, leafId, null, dir, true);
|
||||||
@@ -178,6 +249,7 @@ export function createLayoutStore(opts: LayoutStoreOptions = {}) {
|
|||||||
},
|
},
|
||||||
|
|
||||||
closePane(leafId) {
|
closePane(leafId) {
|
||||||
|
restorePreview();
|
||||||
mutate((layout) => {
|
mutate((layout) => {
|
||||||
const tab = activeTab(layout);
|
const tab = activeTab(layout);
|
||||||
removeLeaf(tab, leafId);
|
removeLeaf(tab, leafId);
|
||||||
@@ -196,24 +268,28 @@ export function createLayoutStore(opts: LayoutStoreOptions = {}) {
|
|||||||
},
|
},
|
||||||
|
|
||||||
selectTab(tabId) {
|
selectTab(tabId) {
|
||||||
|
restorePreview();
|
||||||
mutate((layout) => {
|
mutate((layout) => {
|
||||||
if (layout.tabs.some((t) => t.id === tabId)) layout.activeTabId = tabId;
|
if (layout.tabs.some((t) => t.id === tabId)) layout.activeTabId = tabId;
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
addTab() {
|
addTab() {
|
||||||
|
restorePreview();
|
||||||
mutate((layout) => {
|
mutate((layout) => {
|
||||||
addTabToLayout(layout);
|
addTabToLayout(layout);
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
closeTab(tabId) {
|
closeTab(tabId) {
|
||||||
|
restorePreview();
|
||||||
mutate((layout) => {
|
mutate((layout) => {
|
||||||
closeTabInLayout(layout, tabId);
|
closeTabInLayout(layout, tabId);
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
reorderTab(fromId, toId) {
|
reorderTab(fromId, toId) {
|
||||||
|
restorePreview();
|
||||||
mutate((layout) => {
|
mutate((layout) => {
|
||||||
if (fromId === toId) return;
|
if (fromId === toId) return;
|
||||||
const from = layout.tabs.findIndex((t) => t.id === fromId);
|
const from = layout.tabs.findIndex((t) => t.id === fromId);
|
||||||
@@ -248,6 +324,45 @@ export function createLayoutStore(opts: LayoutStoreOptions = {}) {
|
|||||||
for (const id of emptied) closeTabInLayout(layout, id);
|
for (const id of emptied) closeTabInLayout(layout, id);
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
|
previewSession(sessionId) {
|
||||||
|
const current = get().preview;
|
||||||
|
if (current?.sessionId === sessionId) return;
|
||||||
|
if (sessionId === null) {
|
||||||
|
if (restorePreview()) commit();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
restorePreview();
|
||||||
|
const layout = get().layout;
|
||||||
|
const prevActiveTabId = layout.activeTabId;
|
||||||
|
const loc = locateSession(layout, sessionId);
|
||||||
|
let preview: PreviewState;
|
||||||
|
if (loc) {
|
||||||
|
preview = {
|
||||||
|
sessionId,
|
||||||
|
prevActiveTabId,
|
||||||
|
prevFocus: { tabId: loc.tab.id, leafId: loc.tab.focusedLeafId },
|
||||||
|
tempTabId: null,
|
||||||
|
};
|
||||||
|
layout.activeTabId = loc.tab.id;
|
||||||
|
loc.tab.focusedLeafId = loc.leaf.id;
|
||||||
|
} else {
|
||||||
|
// Not on screen anywhere: a throwaway tab carries it, so no
|
||||||
|
// existing pane is evicted and nothing the operator arranged moves.
|
||||||
|
const tab: Tab = { id: freshId("t"), title: "Preview", root: null, focusedLeafId: null };
|
||||||
|
tab.focusedLeafId = splitLeaf(tab, "", sessionId, "row", true);
|
||||||
|
layout.tabs.push(tab);
|
||||||
|
layout.activeTabId = tab.id;
|
||||||
|
preview = { sessionId, prevActiveTabId, prevFocus: null, tempTabId: tab.id };
|
||||||
|
}
|
||||||
|
set({ layout: { ...layout }, preview });
|
||||||
|
},
|
||||||
|
|
||||||
|
commitPreview(sessionId) {
|
||||||
|
const ok = settlePreview(sessionId);
|
||||||
|
if (ok) commit();
|
||||||
|
return ok;
|
||||||
|
},
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user