Extends layoutStore with previewSession/commitPreview, mirroring multiview.ts's preview controller: a throwaway tab for a session no pane holds, byte-for-byte restore on end, and every real layout-changing action drops an active preview first. Never schedules a save while a preview is up. Adds usePreview, wrapping the store actions with previewSchedule.ts's 120ms leading-edge throttle, plus a touch guard and an immediate `end()` for real changes. MultiView/TabBar render the preview hue on the pane titlebar, canvas ring/chip, and the throwaway tab (dashed, unclickable).
103 lines
3.7 KiB
TypeScript
103 lines
3.7 KiB
TypeScript
// The tab strip above the canvas (issue #249): click to switch, drag to
|
|
// reorder, a close ✕ per tab (hidden on the last one), a trailing + to add.
|
|
|
|
import { useState } from "react";
|
|
import { useStore } from "zustand";
|
|
|
|
import { leaves } from "../../layout.js";
|
|
import type { LayoutStore, LayoutStoreApi } from "../../stores/layoutStore.js";
|
|
|
|
export interface TabBarProps {
|
|
store: LayoutStoreApi;
|
|
}
|
|
|
|
export function TabBar({ store }: TabBarProps) {
|
|
const layout = useStore(store, (s: LayoutStore) => s.layout);
|
|
const previewTempTabId = useStore(store, (s: LayoutStore) => s.preview?.tempTabId ?? null);
|
|
const [draggingId, setDraggingId] = useState<string | null>(null);
|
|
const [overId, setOverId] = useState<string | null>(null);
|
|
|
|
return (
|
|
<div className="mv-tabs flex items-center gap-sp-1 overflow-x-auto border-b border-border-strong bg-surface px-sp-2 text-fs-12" role="tablist">
|
|
{layout.tabs.map((tab) => {
|
|
const count = leaves(tab.root).filter((l) => l.sessionId).length;
|
|
const isActive = tab.id === layout.activeTabId;
|
|
const isDropTarget = overId === tab.id && draggingId !== null && draggingId !== tab.id;
|
|
// A preview's throwaway tab is not a place to go — it goes on its
|
|
// own the moment the preview ends (docs/PREVIEW.md).
|
|
const isPreviewTab = tab.id === previewTempTabId;
|
|
return (
|
|
<button
|
|
key={tab.id}
|
|
type="button"
|
|
role="tab"
|
|
aria-selected={isActive}
|
|
draggable
|
|
data-tab-id={tab.id}
|
|
className={
|
|
"mv-tab flex items-center gap-sp-1 rounded-t-r-1 px-sp-2 py-sp-1 " +
|
|
(isPreviewTab
|
|
? "border border-dashed border-preview-ring italic text-preview"
|
|
: isActive
|
|
? "bg-surface-2 text-ink"
|
|
: "text-faint hover:text-ink") +
|
|
(isDropTarget ? " outline outline-2 outline-accent" : "")
|
|
}
|
|
onDragStart={(e) => {
|
|
setDraggingId(tab.id);
|
|
e.dataTransfer.setData("text/plain", tab.id);
|
|
e.dataTransfer.effectAllowed = "move";
|
|
}}
|
|
onDragOver={(e) => {
|
|
if (!draggingId || draggingId === tab.id) return;
|
|
e.preventDefault();
|
|
setOverId(tab.id);
|
|
}}
|
|
onDragLeave={() => setOverId((current) => (current === tab.id ? null : current))}
|
|
onDrop={(e) => {
|
|
e.preventDefault();
|
|
const fromId = e.dataTransfer.getData("text/plain") || draggingId;
|
|
setDraggingId(null);
|
|
setOverId(null);
|
|
if (fromId && fromId !== tab.id) store.getState().reorderTab(fromId, tab.id);
|
|
}}
|
|
onDragEnd={() => {
|
|
setDraggingId(null);
|
|
setOverId(null);
|
|
}}
|
|
onClick={() => {
|
|
if (isPreviewTab) return;
|
|
store.getState().selectTab(tab.id);
|
|
}}
|
|
>
|
|
<span>
|
|
{tab.title}
|
|
{count ? ` · ${count}` : ""}
|
|
</span>
|
|
{layout.tabs.length > 1 && !isPreviewTab && (
|
|
<span
|
|
className="mv-tab-close text-faint hover:text-ink"
|
|
title="Close tab"
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
store.getState().closeTab(tab.id);
|
|
}}
|
|
>
|
|
✕
|
|
</span>
|
|
)}
|
|
</button>
|
|
);
|
|
})}
|
|
<button
|
|
type="button"
|
|
className="mv-tab-add px-sp-2 py-sp-1 text-muted hover:text-ink"
|
|
title="New tab"
|
|
onClick={() => store.getState().addTab()}
|
|
>
|
|
+
|
|
</button>
|
|
</div>
|
|
);
|
|
}
|