mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-29 06:35:24 +00:00
feat(split-screen): redesign split screen with free-form panes and pane picker
This commit is contained in:
1 parent
5f3372f9f6
commit
81e7aa6274
46 files changed
+5027
-2537
No files matched your search
@@ -10,19 +10,16 @@ import {
|
||||
} from "@/components/dropdown-menu";
|
||||
import type { RailView } from "@/sidebar/AppRail";
|
||||
import { useRailItems } from "@/sidebar/rail-items";
|
||||
import type { SplitMode } from "@/types/ui-types";
|
||||
import { readHiddenRailTabs } from "@/sidebar/hidden-rail-tabs";
|
||||
import { RailBadge } from "@/sidebar/RailBadge";
|
||||
|
||||
export function MobileBottomBar({
|
||||
railView,
|
||||
sidebarOpen,
|
||||
splitMode,
|
||||
onRailClick,
|
||||
}: {
|
||||
railView: RailView;
|
||||
sidebarOpen: boolean;
|
||||
splitMode: SplitMode;
|
||||
onRailClick: (view: RailView) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
@@ -67,7 +64,6 @@ export function MobileBottomBar({
|
||||
<div className="md:hidden flex items-stretch shrink-0 bg-sidebar border-t border-border safe-bottom">
|
||||
{primaryItems.map((item) => {
|
||||
const active = sidebarOpen && railView === item.id;
|
||||
const hasDot = item.id === "split-screen" && splitMode !== "none";
|
||||
const Icon = item.icon;
|
||||
return (
|
||||
<button
|
||||
@@ -88,9 +84,6 @@ export function MobileBottomBar({
|
||||
<span className="max-w-full truncate px-0.5">
|
||||
{t(item.labelKey)}
|
||||
</span>
|
||||
{hasDot && (
|
||||
<span className="absolute top-1.5 right-[calc(50%-10px)] size-1.5 rounded-full bg-accent-brand" />
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -1,826 +0,0 @@
|
||||
import React, { useState, useRef, useEffect, memo, useCallback } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { X } from "lucide-react";
|
||||
import { splitDragState, notifyDragEnd } from "@/lib/splitDragging";
|
||||
import { tabIcon } from "@/shell/tabUtils";
|
||||
import type { Tab, SplitMode } from "@/types/ui-types";
|
||||
|
||||
// ─── useSplitDrag ─────────────────────────────────────────────────────────────
|
||||
|
||||
export type RowColSizes = number[][];
|
||||
|
||||
export function defaultSizes(mode: SplitMode): {
|
||||
rowSizes: number[];
|
||||
rowColSizes: RowColSizes;
|
||||
} {
|
||||
switch (mode) {
|
||||
case "2-way":
|
||||
return { rowSizes: [100], rowColSizes: [[50, 50]] };
|
||||
case "2-way-horizontal":
|
||||
return { rowSizes: [50, 50], rowColSizes: [[100], [100]] };
|
||||
case "3-way":
|
||||
return { rowSizes: [50, 50], rowColSizes: [[50, 50], [100]] };
|
||||
case "3-way-horizontal":
|
||||
return { rowSizes: [50, 50], rowColSizes: [[50, 50], [100]] };
|
||||
case "4-way":
|
||||
return {
|
||||
rowSizes: [50, 50],
|
||||
rowColSizes: [
|
||||
[50, 50],
|
||||
[50, 50],
|
||||
],
|
||||
};
|
||||
case "5-way":
|
||||
return {
|
||||
rowSizes: [50, 50],
|
||||
rowColSizes: [
|
||||
[33.3, 33.3, 33.4],
|
||||
[33.3, 66.7],
|
||||
],
|
||||
};
|
||||
case "6-way":
|
||||
return {
|
||||
rowSizes: [50, 50],
|
||||
rowColSizes: [
|
||||
[33.3, 33.3, 33.4],
|
||||
[33.3, 33.3, 33.4],
|
||||
],
|
||||
};
|
||||
default:
|
||||
return { rowSizes: [100], rowColSizes: [[100]] };
|
||||
}
|
||||
}
|
||||
|
||||
function useSplitDrag(
|
||||
rowSizes: number[],
|
||||
rowColSizes: RowColSizes,
|
||||
onRowSizesChange: (sizes: number[]) => void,
|
||||
onRowColSizesChange: (sizes: RowColSizes) => void,
|
||||
onReset?: () => void,
|
||||
) {
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
function reset() {
|
||||
onReset?.();
|
||||
}
|
||||
|
||||
function startDrag() {
|
||||
splitDragState.active = true;
|
||||
setIsDragging(true);
|
||||
}
|
||||
function endDrag() {
|
||||
splitDragState.active = false;
|
||||
setIsDragging(false);
|
||||
notifyDragEnd();
|
||||
}
|
||||
|
||||
function onRowDivider(e: React.MouseEvent, rowIdx: number) {
|
||||
e.preventDefault();
|
||||
const container = containerRef.current;
|
||||
if (!container) return;
|
||||
startDrag();
|
||||
const totalH = container.getBoundingClientRect().height;
|
||||
const startY = e.clientY;
|
||||
const a = rowSizes[rowIdx],
|
||||
b = rowSizes[rowIdx + 1];
|
||||
function onMove(ev: MouseEvent) {
|
||||
const na = Math.max(
|
||||
10,
|
||||
Math.min(a + b - 10, a + ((ev.clientY - startY) / totalH) * 100),
|
||||
);
|
||||
const n = [...rowSizes];
|
||||
n[rowIdx] = na;
|
||||
n[rowIdx + 1] = a + b - na;
|
||||
onRowSizesChange(n);
|
||||
}
|
||||
function onUp() {
|
||||
endDrag();
|
||||
window.removeEventListener("mousemove", onMove);
|
||||
window.removeEventListener("mouseup", onUp);
|
||||
}
|
||||
window.addEventListener("mousemove", onMove);
|
||||
window.addEventListener("mouseup", onUp);
|
||||
}
|
||||
|
||||
function onRowDividerTouch(e: React.TouchEvent, rowIdx: number) {
|
||||
const container = containerRef.current;
|
||||
if (!container) return;
|
||||
startDrag();
|
||||
const totalH = container.getBoundingClientRect().height;
|
||||
const startY = e.touches[0].clientY;
|
||||
const a = rowSizes[rowIdx],
|
||||
b = rowSizes[rowIdx + 1];
|
||||
function onMove(ev: TouchEvent) {
|
||||
const na = Math.max(
|
||||
10,
|
||||
Math.min(
|
||||
a + b - 10,
|
||||
a + ((ev.touches[0].clientY - startY) / totalH) * 100,
|
||||
),
|
||||
);
|
||||
const n = [...rowSizes];
|
||||
n[rowIdx] = na;
|
||||
n[rowIdx + 1] = a + b - na;
|
||||
onRowSizesChange(n);
|
||||
}
|
||||
function onUp() {
|
||||
endDrag();
|
||||
window.removeEventListener("touchmove", onMove);
|
||||
window.removeEventListener("touchend", onUp);
|
||||
}
|
||||
window.addEventListener("touchmove", onMove, { passive: false });
|
||||
window.addEventListener("touchend", onUp);
|
||||
}
|
||||
|
||||
function onColDivider(e: React.MouseEvent, rowIdx: number, colIdx: number) {
|
||||
e.preventDefault();
|
||||
const container = containerRef.current;
|
||||
if (!container) return;
|
||||
startDrag();
|
||||
const totalW = container.getBoundingClientRect().width;
|
||||
const startX = e.clientX;
|
||||
const cols = rowColSizes[rowIdx];
|
||||
const a = cols[colIdx],
|
||||
b = cols[colIdx + 1];
|
||||
function onMove(ev: MouseEvent) {
|
||||
const na = Math.max(
|
||||
10,
|
||||
Math.min(a + b - 10, a + ((ev.clientX - startX) / totalW) * 100),
|
||||
);
|
||||
const next = rowColSizes.map((r) => [...r]);
|
||||
next[rowIdx][colIdx] = na;
|
||||
next[rowIdx][colIdx + 1] = a + b - na;
|
||||
onRowColSizesChange(next);
|
||||
}
|
||||
function onUp() {
|
||||
endDrag();
|
||||
window.removeEventListener("mousemove", onMove);
|
||||
window.removeEventListener("mouseup", onUp);
|
||||
}
|
||||
window.addEventListener("mousemove", onMove);
|
||||
window.addEventListener("mouseup", onUp);
|
||||
}
|
||||
|
||||
function onColDividerTouch(
|
||||
e: React.TouchEvent,
|
||||
rowIdx: number,
|
||||
colIdx: number,
|
||||
) {
|
||||
const container = containerRef.current;
|
||||
if (!container) return;
|
||||
startDrag();
|
||||
const totalW = container.getBoundingClientRect().width;
|
||||
const startX = e.touches[0].clientX;
|
||||
const cols = rowColSizes[rowIdx];
|
||||
const a = cols[colIdx],
|
||||
b = cols[colIdx + 1];
|
||||
function onMove(ev: TouchEvent) {
|
||||
const na = Math.max(
|
||||
10,
|
||||
Math.min(
|
||||
a + b - 10,
|
||||
a + ((ev.touches[0].clientX - startX) / totalW) * 100,
|
||||
),
|
||||
);
|
||||
const next = rowColSizes.map((r) => [...r]);
|
||||
next[rowIdx][colIdx] = na;
|
||||
next[rowIdx][colIdx + 1] = a + b - na;
|
||||
onRowColSizesChange(next);
|
||||
}
|
||||
function onUp() {
|
||||
endDrag();
|
||||
window.removeEventListener("touchmove", onMove);
|
||||
window.removeEventListener("touchend", onUp);
|
||||
}
|
||||
window.addEventListener("touchmove", onMove, { passive: false });
|
||||
window.addEventListener("touchend", onUp);
|
||||
}
|
||||
|
||||
return {
|
||||
isDragging,
|
||||
containerRef,
|
||||
reset,
|
||||
onRowDivider,
|
||||
onRowDividerTouch,
|
||||
onColDivider,
|
||||
onColDividerTouch,
|
||||
};
|
||||
}
|
||||
|
||||
// ─── Dividers ─────────────────────────────────────────────────────────────────
|
||||
|
||||
function ColDivider({
|
||||
onMouseDown,
|
||||
onTouchStart,
|
||||
}: {
|
||||
onMouseDown: (e: React.MouseEvent) => void;
|
||||
onTouchStart: (e: React.TouchEvent) => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="relative w-0 shrink-0 z-10">
|
||||
<div
|
||||
onMouseDown={onMouseDown}
|
||||
onTouchStart={onTouchStart}
|
||||
className="absolute inset-y-0 -left-2 -right-2 cursor-col-resize flex items-center justify-center group"
|
||||
>
|
||||
<div className="w-px h-full bg-border group-hover:bg-accent-brand/60 transition-colors pointer-events-none" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function RowDivider({
|
||||
onMouseDown,
|
||||
onTouchStart,
|
||||
}: {
|
||||
onMouseDown: (e: React.MouseEvent) => void;
|
||||
onTouchStart: (e: React.TouchEvent) => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="relative h-0 w-full shrink-0 z-10">
|
||||
<div
|
||||
onMouseDown={onMouseDown}
|
||||
onTouchStart={onTouchStart}
|
||||
className="absolute inset-x-0 -top-2 -bottom-2 cursor-row-resize flex flex-col items-center justify-center group"
|
||||
>
|
||||
<div className="h-px w-full bg-border group-hover:bg-accent-brand/60 transition-colors pointer-events-none" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Pane ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
function PaneHeader({
|
||||
tab,
|
||||
paneIndex,
|
||||
isFocused,
|
||||
onClear,
|
||||
}: {
|
||||
tab: Tab | null;
|
||||
paneIndex: number;
|
||||
isFocused: boolean;
|
||||
onClear?: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div
|
||||
className={`flex items-center gap-1.5 px-2.5 h-7 shrink-0 border-b text-xs font-medium select-none transition-colors ${
|
||||
isFocused
|
||||
? "bg-accent-brand/10 border-accent-brand/40 text-accent-brand"
|
||||
: "bg-sidebar border-border text-muted-foreground"
|
||||
}`}
|
||||
>
|
||||
{isFocused && (
|
||||
<span className="w-1 h-3.5 rounded-full bg-accent-brand shrink-0" />
|
||||
)}
|
||||
{tab ? (
|
||||
<>
|
||||
<span className={isFocused ? "text-accent-brand" : "opacity-60"}>
|
||||
{tabIcon(tab.type)}
|
||||
</span>
|
||||
<span
|
||||
className={`truncate flex-1 ${isFocused ? "text-accent-brand font-semibold" : "text-foreground"}`}
|
||||
>
|
||||
{tab.type === "dashboard" ? "Dashboard" : tab.label}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
title={t("terminal.split.removeFromSplit")}
|
||||
aria-label={t("terminal.split.removeFromSplit")}
|
||||
className="ml-auto flex size-5 shrink-0 items-center justify-center rounded-sm text-muted-foreground hover:bg-muted hover:text-foreground"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
onClear?.();
|
||||
}}
|
||||
>
|
||||
<X className="size-3" />
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<span className="opacity-40">
|
||||
{t("splitScreen.paneEmpty", { index: paneIndex + 1 })}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function EmptyPane() {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center w-full h-full gap-2 text-muted-foreground/30 bg-background">
|
||||
<div className="grid grid-cols-2 gap-1">
|
||||
<div className="size-5 border-2 border-current rounded-sm" />
|
||||
<div className="size-5 border-2 border-current rounded-sm" />
|
||||
<div className="size-5 border-2 border-current rounded-sm" />
|
||||
<div className="size-5 border-2 border-current rounded-sm" />
|
||||
</div>
|
||||
<span className="text-xs">{t("splitScreen.noTabAssigned")}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const Pane = memo(function Pane({
|
||||
tab,
|
||||
paneIndex,
|
||||
isDragging,
|
||||
isFocused,
|
||||
onPaneContentRef,
|
||||
onPaneClick,
|
||||
onAssignPane,
|
||||
}: {
|
||||
tab: Tab | null;
|
||||
paneIndex: number;
|
||||
isDragging: boolean;
|
||||
isFocused: boolean;
|
||||
onPaneContentRef?: (paneIndex: number, el: HTMLDivElement | null) => void;
|
||||
onPaneClick?: (paneIndex: number) => void;
|
||||
onAssignPane?: (paneIndex: number, tabId: string) => void;
|
||||
}) {
|
||||
const [isDragOver, setIsDragOver] = useState(false);
|
||||
|
||||
const contentRef = useCallback(
|
||||
(el: HTMLDivElement | null) => {
|
||||
onPaneContentRef?.(paneIndex, el);
|
||||
},
|
||||
[paneIndex, onPaneContentRef],
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`relative flex flex-col w-full h-full min-w-0 min-h-0 overflow-hidden transition-colors ${
|
||||
isFocused ? "ring-1 ring-inset ring-accent-brand/30" : ""
|
||||
} ${isDragOver ? "ring-2 ring-inset ring-accent-brand" : ""}`}
|
||||
onClick={() => onPaneClick?.(paneIndex)}
|
||||
onDragOver={(e) => {
|
||||
e.preventDefault();
|
||||
setIsDragOver(true);
|
||||
}}
|
||||
onDragLeave={() => setIsDragOver(false)}
|
||||
onDrop={(e) => {
|
||||
e.preventDefault();
|
||||
setIsDragOver(false);
|
||||
const tabId = e.dataTransfer.getData("text/plain");
|
||||
if (tabId) onAssignPane?.(paneIndex, tabId);
|
||||
}}
|
||||
>
|
||||
<PaneHeader
|
||||
tab={tab}
|
||||
paneIndex={paneIndex}
|
||||
isFocused={isFocused}
|
||||
onClear={tab ? () => onAssignPane?.(paneIndex, "") : undefined}
|
||||
/>
|
||||
<div className="flex-1 min-h-0 overflow-hidden relative">
|
||||
{tab ? (
|
||||
<div ref={contentRef} className="absolute inset-0" />
|
||||
) : (
|
||||
<EmptyPane />
|
||||
)}
|
||||
{isDragOver && (
|
||||
<div className="absolute inset-0 z-20 flex items-center justify-center bg-accent-brand/10 border-2 border-dashed border-accent-brand pointer-events-none">
|
||||
<span className="text-xs font-medium text-accent-brand">
|
||||
Drop to assign
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{isDragging && (
|
||||
<div className="absolute inset-0 z-10" style={{ cursor: "inherit" }} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
// ─── Row (top-level so React never sees a new component type) ─────────────────
|
||||
|
||||
const Row = memo(function Row({
|
||||
rowIdx,
|
||||
paneIndices,
|
||||
rowHeight,
|
||||
colWidths,
|
||||
paneTabIds,
|
||||
tabs,
|
||||
isDragging,
|
||||
focusedPaneIndex,
|
||||
onColDivider,
|
||||
onColDividerTouch,
|
||||
onPaneContentRef,
|
||||
onPaneClick,
|
||||
onAssignPane,
|
||||
}: {
|
||||
rowIdx: number;
|
||||
paneIndices: number[];
|
||||
rowHeight: number;
|
||||
colWidths: number[];
|
||||
paneTabIds: (string | null)[];
|
||||
tabs: Tab[];
|
||||
isDragging: boolean;
|
||||
focusedPaneIndex: number | null;
|
||||
onColDivider: (e: React.MouseEvent, rowIdx: number, colIdx: number) => void;
|
||||
onColDividerTouch: (
|
||||
e: React.TouchEvent,
|
||||
rowIdx: number,
|
||||
colIdx: number,
|
||||
) => void;
|
||||
onPaneContentRef?: (paneIndex: number, el: HTMLDivElement | null) => void;
|
||||
onPaneClick?: (paneIndex: number) => void;
|
||||
onAssignPane?: (paneIndex: number, tabId: string) => void;
|
||||
}) {
|
||||
const widths = colWidths ?? [];
|
||||
return (
|
||||
<div className="flex min-h-0 w-full" style={{ height: `${rowHeight}%` }}>
|
||||
{paneIndices.map((pIdx, ci) => {
|
||||
const tabId = paneTabIds[pIdx];
|
||||
const tab =
|
||||
tabId != null ? (tabs.find((t) => t.id === tabId) ?? null) : null;
|
||||
return (
|
||||
<React.Fragment key={pIdx}>
|
||||
<div
|
||||
className="min-w-0 min-h-0 overflow-hidden"
|
||||
style={{ width: `${widths[ci] ?? 100 / paneIndices.length}%` }}
|
||||
>
|
||||
<Pane
|
||||
tab={tab}
|
||||
paneIndex={pIdx}
|
||||
isDragging={isDragging}
|
||||
isFocused={focusedPaneIndex === pIdx}
|
||||
onPaneContentRef={onPaneContentRef}
|
||||
onPaneClick={onPaneClick}
|
||||
onAssignPane={onAssignPane}
|
||||
/>
|
||||
</div>
|
||||
{ci < paneIndices.length - 1 && (
|
||||
<ColDivider
|
||||
onMouseDown={(e) => onColDivider(e, rowIdx, ci)}
|
||||
onTouchStart={(e) => onColDividerTouch(e, rowIdx, ci)}
|
||||
/>
|
||||
)}
|
||||
</React.Fragment>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
// ─── SplitView ────────────────────────────────────────────────────────────────
|
||||
|
||||
export const SplitView = memo(function SplitView({
|
||||
tabs,
|
||||
paneTabIds,
|
||||
splitMode,
|
||||
rowSizes,
|
||||
rowColSizes,
|
||||
onRowSizesChange,
|
||||
onRowColSizesChange,
|
||||
onReset,
|
||||
focusedPaneIndex,
|
||||
onTerminalResize,
|
||||
onPaneContentRef,
|
||||
onPaneClick,
|
||||
onAssignPane,
|
||||
}: {
|
||||
tabs: Tab[];
|
||||
paneTabIds: (string | null)[];
|
||||
splitMode: SplitMode;
|
||||
rowSizes: number[];
|
||||
rowColSizes: RowColSizes;
|
||||
onRowSizesChange: (sizes: number[]) => void;
|
||||
onRowColSizesChange: (sizes: RowColSizes) => void;
|
||||
onReset?: () => void;
|
||||
focusedPaneIndex?: number | null;
|
||||
onTerminalResize?: () => void;
|
||||
onPaneContentRef?: (paneIndex: number, el: HTMLDivElement | null) => void;
|
||||
onPaneClick?: (paneIndex: number) => void;
|
||||
onAssignPane?: (paneIndex: number, tabId: string) => void;
|
||||
}) {
|
||||
const {
|
||||
isDragging,
|
||||
containerRef,
|
||||
reset,
|
||||
onRowDivider,
|
||||
onRowDividerTouch,
|
||||
onColDivider,
|
||||
onColDividerTouch,
|
||||
} = useSplitDrag(
|
||||
rowSizes,
|
||||
rowColSizes,
|
||||
onRowSizesChange,
|
||||
onRowColSizesChange,
|
||||
onReset,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isDragging) {
|
||||
const id = requestAnimationFrame(() => onTerminalResize?.());
|
||||
return () => cancelAnimationFrame(id);
|
||||
}
|
||||
}, [isDragging, onTerminalResize]);
|
||||
|
||||
// Inline pane lookup for the non-Row layouts (3-way, 3-way-horizontal)
|
||||
function tab(idx: number): Tab | null {
|
||||
const tabId = paneTabIds[idx];
|
||||
return tabId != null ? (tabs.find((t) => t.id === tabId) ?? null) : null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="flex flex-col w-full h-full min-h-0 overflow-hidden relative"
|
||||
>
|
||||
<button
|
||||
onClick={reset}
|
||||
className="absolute top-1 right-1 z-20 text-xs text-muted-foreground hover:text-foreground bg-background/80 border border-border px-1.5 py-0.5 leading-tight"
|
||||
title="Reset to equal split"
|
||||
>
|
||||
Reset
|
||||
</button>
|
||||
|
||||
{splitMode === "2-way" && (
|
||||
<Row
|
||||
rowIdx={0}
|
||||
paneIndices={[0, 1]}
|
||||
rowHeight={rowSizes[0]}
|
||||
colWidths={rowColSizes[0] ?? []}
|
||||
paneTabIds={paneTabIds}
|
||||
tabs={tabs}
|
||||
isDragging={isDragging}
|
||||
focusedPaneIndex={focusedPaneIndex ?? null}
|
||||
onColDivider={onColDivider}
|
||||
onColDividerTouch={onColDividerTouch}
|
||||
onPaneContentRef={onPaneContentRef}
|
||||
onPaneClick={onPaneClick}
|
||||
onAssignPane={onAssignPane}
|
||||
/>
|
||||
)}
|
||||
|
||||
{splitMode === "2-way-horizontal" && (
|
||||
<div className="flex flex-col w-full h-full min-h-0">
|
||||
<Row
|
||||
rowIdx={0}
|
||||
paneIndices={[0]}
|
||||
rowHeight={rowSizes[0]}
|
||||
colWidths={rowColSizes[0] ?? []}
|
||||
paneTabIds={paneTabIds}
|
||||
tabs={tabs}
|
||||
isDragging={isDragging}
|
||||
focusedPaneIndex={focusedPaneIndex ?? null}
|
||||
onColDivider={onColDivider}
|
||||
onColDividerTouch={onColDividerTouch}
|
||||
onPaneContentRef={onPaneContentRef}
|
||||
onPaneClick={onPaneClick}
|
||||
onAssignPane={onAssignPane}
|
||||
/>
|
||||
<RowDivider
|
||||
onMouseDown={(e) => onRowDivider(e, 0)}
|
||||
onTouchStart={(e) => onRowDividerTouch(e, 0)}
|
||||
/>
|
||||
<Row
|
||||
rowIdx={1}
|
||||
paneIndices={[1]}
|
||||
rowHeight={rowSizes[1]}
|
||||
colWidths={rowColSizes[1] ?? []}
|
||||
paneTabIds={paneTabIds}
|
||||
tabs={tabs}
|
||||
isDragging={isDragging}
|
||||
focusedPaneIndex={focusedPaneIndex ?? null}
|
||||
onColDivider={onColDivider}
|
||||
onColDividerTouch={onColDividerTouch}
|
||||
onPaneContentRef={onPaneContentRef}
|
||||
onPaneClick={onPaneClick}
|
||||
onAssignPane={onAssignPane}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{splitMode === "3-way" && (
|
||||
<div className="flex w-full h-full min-h-0">
|
||||
<div
|
||||
className="min-w-0 min-h-0 overflow-hidden"
|
||||
style={{ width: `${rowColSizes[0][0]}%` }}
|
||||
>
|
||||
<Pane
|
||||
tab={tab(0)}
|
||||
paneIndex={0}
|
||||
isDragging={isDragging}
|
||||
isFocused={focusedPaneIndex === 0}
|
||||
onPaneContentRef={onPaneContentRef}
|
||||
onPaneClick={onPaneClick}
|
||||
onAssignPane={onAssignPane}
|
||||
/>
|
||||
</div>
|
||||
<ColDivider
|
||||
onMouseDown={(e) => onColDivider(e, 0, 0)}
|
||||
onTouchStart={(e) => onColDividerTouch(e, 0, 0)}
|
||||
/>
|
||||
<div className="flex flex-col flex-1 min-h-0">
|
||||
<div
|
||||
className="min-h-0 overflow-hidden"
|
||||
style={{ height: `${rowSizes[0]}%` }}
|
||||
>
|
||||
<Pane
|
||||
tab={tab(1)}
|
||||
paneIndex={1}
|
||||
isDragging={isDragging}
|
||||
isFocused={focusedPaneIndex === 1}
|
||||
onPaneContentRef={onPaneContentRef}
|
||||
onPaneClick={onPaneClick}
|
||||
onAssignPane={onAssignPane}
|
||||
/>
|
||||
</div>
|
||||
<RowDivider
|
||||
onMouseDown={(e) => onRowDivider(e, 0)}
|
||||
onTouchStart={(e) => onRowDividerTouch(e, 0)}
|
||||
/>
|
||||
<div
|
||||
className="min-h-0 overflow-hidden"
|
||||
style={{ height: `${rowSizes[1]}%` }}
|
||||
>
|
||||
<Pane
|
||||
tab={tab(2)}
|
||||
paneIndex={2}
|
||||
isDragging={isDragging}
|
||||
isFocused={focusedPaneIndex === 2}
|
||||
onPaneContentRef={onPaneContentRef}
|
||||
onPaneClick={onPaneClick}
|
||||
onAssignPane={onAssignPane}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{splitMode === "3-way-horizontal" && (
|
||||
<div className="flex flex-col w-full h-full min-h-0">
|
||||
<div
|
||||
className="flex min-h-0 overflow-hidden"
|
||||
style={{ height: `${rowSizes[0]}%` }}
|
||||
>
|
||||
<div
|
||||
className="min-w-0 min-h-0 overflow-hidden"
|
||||
style={{ width: `${rowColSizes[0][0]}%` }}
|
||||
>
|
||||
<Pane
|
||||
tab={tab(0)}
|
||||
paneIndex={0}
|
||||
isDragging={isDragging}
|
||||
isFocused={focusedPaneIndex === 0}
|
||||
onPaneContentRef={onPaneContentRef}
|
||||
onPaneClick={onPaneClick}
|
||||
onAssignPane={onAssignPane}
|
||||
/>
|
||||
</div>
|
||||
<ColDivider
|
||||
onMouseDown={(e) => onColDivider(e, 0, 0)}
|
||||
onTouchStart={(e) => onColDividerTouch(e, 0, 0)}
|
||||
/>
|
||||
<div className="flex-1 min-w-0 min-h-0 overflow-hidden">
|
||||
<Pane
|
||||
tab={tab(1)}
|
||||
paneIndex={1}
|
||||
isDragging={isDragging}
|
||||
isFocused={focusedPaneIndex === 1}
|
||||
onPaneContentRef={onPaneContentRef}
|
||||
onPaneClick={onPaneClick}
|
||||
onAssignPane={onAssignPane}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<RowDivider
|
||||
onMouseDown={(e) => onRowDivider(e, 0)}
|
||||
onTouchStart={(e) => onRowDividerTouch(e, 0)}
|
||||
/>
|
||||
<div className="flex-1 min-h-0 overflow-hidden">
|
||||
<Pane
|
||||
tab={tab(2)}
|
||||
paneIndex={2}
|
||||
isDragging={isDragging}
|
||||
isFocused={focusedPaneIndex === 2}
|
||||
onPaneContentRef={onPaneContentRef}
|
||||
onPaneClick={onPaneClick}
|
||||
onAssignPane={onAssignPane}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{splitMode === "4-way" && (
|
||||
<div className="flex flex-col w-full h-full min-h-0">
|
||||
<Row
|
||||
rowIdx={0}
|
||||
paneIndices={[0, 1]}
|
||||
rowHeight={rowSizes[0]}
|
||||
colWidths={rowColSizes[0] ?? []}
|
||||
paneTabIds={paneTabIds}
|
||||
tabs={tabs}
|
||||
isDragging={isDragging}
|
||||
focusedPaneIndex={focusedPaneIndex ?? null}
|
||||
onColDivider={onColDivider}
|
||||
onColDividerTouch={onColDividerTouch}
|
||||
onPaneContentRef={onPaneContentRef}
|
||||
onPaneClick={onPaneClick}
|
||||
onAssignPane={onAssignPane}
|
||||
/>
|
||||
<RowDivider
|
||||
onMouseDown={(e) => onRowDivider(e, 0)}
|
||||
onTouchStart={(e) => onRowDividerTouch(e, 0)}
|
||||
/>
|
||||
<Row
|
||||
rowIdx={1}
|
||||
paneIndices={[2, 3]}
|
||||
rowHeight={rowSizes[1]}
|
||||
colWidths={rowColSizes[1] ?? []}
|
||||
paneTabIds={paneTabIds}
|
||||
tabs={tabs}
|
||||
isDragging={isDragging}
|
||||
focusedPaneIndex={focusedPaneIndex ?? null}
|
||||
onColDivider={onColDivider}
|
||||
onColDividerTouch={onColDividerTouch}
|
||||
onPaneContentRef={onPaneContentRef}
|
||||
onPaneClick={onPaneClick}
|
||||
onAssignPane={onAssignPane}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{splitMode === "5-way" && (
|
||||
<div className="flex flex-col w-full h-full min-h-0">
|
||||
<Row
|
||||
rowIdx={0}
|
||||
paneIndices={[0, 1, 2]}
|
||||
rowHeight={rowSizes[0]}
|
||||
colWidths={rowColSizes[0] ?? []}
|
||||
paneTabIds={paneTabIds}
|
||||
tabs={tabs}
|
||||
isDragging={isDragging}
|
||||
focusedPaneIndex={focusedPaneIndex ?? null}
|
||||
onColDivider={onColDivider}
|
||||
onColDividerTouch={onColDividerTouch}
|
||||
onPaneContentRef={onPaneContentRef}
|
||||
onPaneClick={onPaneClick}
|
||||
onAssignPane={onAssignPane}
|
||||
/>
|
||||
<RowDivider
|
||||
onMouseDown={(e) => onRowDivider(e, 0)}
|
||||
onTouchStart={(e) => onRowDividerTouch(e, 0)}
|
||||
/>
|
||||
<Row
|
||||
rowIdx={1}
|
||||
paneIndices={[3, 4]}
|
||||
rowHeight={rowSizes[1]}
|
||||
colWidths={rowColSizes[1] ?? []}
|
||||
paneTabIds={paneTabIds}
|
||||
tabs={tabs}
|
||||
isDragging={isDragging}
|
||||
focusedPaneIndex={focusedPaneIndex ?? null}
|
||||
onColDivider={onColDivider}
|
||||
onColDividerTouch={onColDividerTouch}
|
||||
onPaneContentRef={onPaneContentRef}
|
||||
onPaneClick={onPaneClick}
|
||||
onAssignPane={onAssignPane}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{splitMode === "6-way" && (
|
||||
<div className="flex flex-col w-full h-full min-h-0">
|
||||
<Row
|
||||
rowIdx={0}
|
||||
paneIndices={[0, 1, 2]}
|
||||
rowHeight={rowSizes[0]}
|
||||
colWidths={rowColSizes[0] ?? []}
|
||||
paneTabIds={paneTabIds}
|
||||
tabs={tabs}
|
||||
isDragging={isDragging}
|
||||
focusedPaneIndex={focusedPaneIndex ?? null}
|
||||
onColDivider={onColDivider}
|
||||
onColDividerTouch={onColDividerTouch}
|
||||
onPaneContentRef={onPaneContentRef}
|
||||
onPaneClick={onPaneClick}
|
||||
onAssignPane={onAssignPane}
|
||||
/>
|
||||
<RowDivider
|
||||
onMouseDown={(e) => onRowDivider(e, 0)}
|
||||
onTouchStart={(e) => onRowDividerTouch(e, 0)}
|
||||
/>
|
||||
<Row
|
||||
rowIdx={1}
|
||||
paneIndices={[3, 4, 5]}
|
||||
rowHeight={rowSizes[1]}
|
||||
colWidths={rowColSizes[1] ?? []}
|
||||
paneTabIds={paneTabIds}
|
||||
tabs={tabs}
|
||||
isDragging={isDragging}
|
||||
focusedPaneIndex={focusedPaneIndex ?? null}
|
||||
onColDivider={onColDivider}
|
||||
onColDividerTouch={onColDividerTouch}
|
||||
onPaneContentRef={onPaneContentRef}
|
||||
onPaneClick={onPaneClick}
|
||||
onAssignPane={onAssignPane}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
+183
-87
@@ -19,10 +19,11 @@ import {
|
||||
ChevronUp,
|
||||
RefreshCw,
|
||||
X,
|
||||
LayoutPanelLeft,
|
||||
Plus,
|
||||
Minus,
|
||||
Pencil,
|
||||
Columns2,
|
||||
Rows2,
|
||||
Ungroup,
|
||||
Maximize2,
|
||||
Minimize2,
|
||||
FolderOpen,
|
||||
@@ -31,9 +32,25 @@ import {
|
||||
import { tabIcon } from "@/shell/tabUtils";
|
||||
import { isSessionTabType } from "@/shell/tab-registry";
|
||||
import { isElectron } from "@/lib/electron";
|
||||
import type { Tab, TabType, SplitMode } from "@/types/ui-types";
|
||||
import { SPLIT_MODES, PANE_COUNTS } from "@/lib/theme";
|
||||
import type { Tab, TabType } from "@/types/ui-types";
|
||||
import { ActionSlot } from "@/shell/ActionSlot";
|
||||
import {
|
||||
canJoinSplit,
|
||||
isSplitTab,
|
||||
type SplitSummary,
|
||||
type TabSplitAction,
|
||||
} from "@/shell/split/split-tabs";
|
||||
import { MAX_PANES } from "@/shell/split/split-tree";
|
||||
import { SplitLayoutMenu } from "@/shell/split/SplitLayoutMenu";
|
||||
import {
|
||||
beginSplitDrag,
|
||||
endSplitDrag,
|
||||
isSplitDragging,
|
||||
moveSplitDrag,
|
||||
} from "@/shell/split/split-drag";
|
||||
|
||||
/** How far below the bar a dragged tab has to go before it drags into the view. */
|
||||
const DRAG_OUT_DISTANCE = 16;
|
||||
|
||||
/**
|
||||
* Tabs holding a live connection that can be refreshed: the registered
|
||||
@@ -46,16 +63,13 @@ function isConnectionTab(type: TabType): boolean {
|
||||
export function TabBar({
|
||||
tabs,
|
||||
activeTabId,
|
||||
splitMode,
|
||||
paneTabIds,
|
||||
focusedPaneIndex,
|
||||
splits,
|
||||
activeSplitFull,
|
||||
onSetActiveTab,
|
||||
onCloseTab,
|
||||
onRefreshTab,
|
||||
onReorderTabs,
|
||||
onSplitTab,
|
||||
onAddToSplit,
|
||||
onRemoveFromSplit,
|
||||
onSplitAction,
|
||||
onRenameTab,
|
||||
onOpenFileManager,
|
||||
isAppFullscreen,
|
||||
@@ -66,16 +80,15 @@ export function TabBar({
|
||||
}: {
|
||||
tabs: Tab[];
|
||||
activeTabId: string;
|
||||
splitMode: SplitMode;
|
||||
paneTabIds: (string | null)[];
|
||||
focusedPaneIndex: number | null;
|
||||
/** Every split tab and its panes, for the "Add to split" entries. */
|
||||
splits: SplitSummary[];
|
||||
/** The active tab is a split with no room for another pane. */
|
||||
activeSplitFull: boolean;
|
||||
onSetActiveTab: (id: string) => void;
|
||||
onCloseTab: (id: string) => void;
|
||||
onRefreshTab: (id: string) => void;
|
||||
onReorderTabs: (tabs: Tab[]) => void;
|
||||
onSplitTab: (tabId: string, mode: SplitMode) => void;
|
||||
onAddToSplit: (tabId: string) => void;
|
||||
onRemoveFromSplit: (tabId: string) => void;
|
||||
onSplitAction: (action: TabSplitAction) => void;
|
||||
onRenameTab?: (tabId: string, newLabel: string) => void;
|
||||
onOpenFileManager?: (tabId: string) => void;
|
||||
isAppFullscreen: boolean;
|
||||
@@ -90,6 +103,7 @@ export function TabBar({
|
||||
const [dragTabId, setDragTabId] = useState<string | null>(null);
|
||||
const [dragTargetIndex, setDragTargetIndex] = useState<number | null>(null);
|
||||
const [dragPos, setDragPos] = useState<{ x: number; y: number } | null>(null);
|
||||
const [draggingOut, setDraggingOut] = useState(false);
|
||||
const [contextTabId, setContextTabId] = useState<string | null>(null);
|
||||
const [contextPos, setContextPos] = useState<{ x: number; y: number } | null>(
|
||||
null,
|
||||
@@ -125,8 +139,7 @@ export function TabBar({
|
||||
} | null>(null);
|
||||
const skipIndicatorAnimRef = useRef(false);
|
||||
|
||||
const isSplit = splitMode !== "none";
|
||||
const paneCount = PANE_COUNTS[splitMode];
|
||||
const activeIsSplit = isSplitTab(tabs.find((tab) => tab.id === activeTabId));
|
||||
|
||||
const measureIndicator = useCallback(() => {
|
||||
const el = activeTabId ? tabEls.current.get(activeTabId) : null;
|
||||
@@ -143,7 +156,7 @@ export function TabBar({
|
||||
// its pre-reorder position before this caught up, flashing at the old spot.
|
||||
useLayoutEffect(() => {
|
||||
measureIndicator();
|
||||
}, [measureIndicator, tabs, splitMode, dragTargetIndex]);
|
||||
}, [measureIndicator, tabs, dragTargetIndex]);
|
||||
|
||||
useEffect(() => {
|
||||
const el = tabBarRef.current;
|
||||
@@ -183,6 +196,35 @@ export function TabBar({
|
||||
if (Math.abs(e.clientX - d.startX) > 5) didDrag.current = true;
|
||||
|
||||
const barRect = tabBarRef.current.getBoundingClientRect();
|
||||
|
||||
// Dragged down out of the bar: the tab heads for the view to be split.
|
||||
const dragged = tabs.find((t) => t.id === d.id);
|
||||
if (
|
||||
canJoinSplit(dragged) &&
|
||||
e.clientY > barRect.bottom + DRAG_OUT_DISTANCE
|
||||
) {
|
||||
didDrag.current = true;
|
||||
if (isSplitDragging()) moveSplitDrag(e.clientX, e.clientY);
|
||||
else
|
||||
beginSplitDrag(
|
||||
{
|
||||
kind: "tab",
|
||||
tabId: d.id,
|
||||
label: dragged!.customLabel || dragged!.label,
|
||||
},
|
||||
e.clientX,
|
||||
e.clientY,
|
||||
);
|
||||
setDraggingOut(true);
|
||||
dragTargetRef.current = d.index;
|
||||
setDragTargetIndex(d.index);
|
||||
return;
|
||||
}
|
||||
if (isSplitDragging()) {
|
||||
endSplitDrag(false);
|
||||
setDraggingOut(false);
|
||||
}
|
||||
|
||||
const x = Math.max(
|
||||
barRect.left + 2,
|
||||
Math.min(barRect.right - d.width - 6, e.clientX - d.offsetX),
|
||||
@@ -212,7 +254,10 @@ export function TabBar({
|
||||
if (!dragData.current) return;
|
||||
const { id, index } = dragData.current;
|
||||
const to = dragTargetRef.current ?? index;
|
||||
if (commit && to !== index) {
|
||||
const droppedOut = isSplitDragging();
|
||||
if (droppedOut) endSplitDrag(commit);
|
||||
setDraggingOut(false);
|
||||
if (commit && !droppedOut && to !== index) {
|
||||
const next = [...tabs];
|
||||
if (next[0].id !== id) next.splice(to, 0, next.splice(index, 1)[0]);
|
||||
skipIndicatorAnimRef.current = true;
|
||||
@@ -308,9 +353,6 @@ export function TabBar({
|
||||
{tabs.map((tab, index) => {
|
||||
const active = tab.id === activeTabId;
|
||||
const isDragging = dragTabId === tab.id;
|
||||
const paneIdx = paneTabIds.indexOf(tab.id);
|
||||
const isInPane = paneIdx !== -1;
|
||||
const isFocusedPane = isInPane && paneIdx === focusedPaneIndex;
|
||||
let translateX = 0;
|
||||
if (
|
||||
dragTabId &&
|
||||
@@ -327,9 +369,6 @@ export function TabBar({
|
||||
translateX = draggedWidth;
|
||||
}
|
||||
|
||||
const showFocusIndicator = isFocusedPane && isSplit;
|
||||
const showInPaneIndicator = isInPane && isSplit && !isFocusedPane;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={tab.id}
|
||||
@@ -337,12 +376,6 @@ export function TabBar({
|
||||
if (el) tabEls.current.set(tab.id, el);
|
||||
else tabEls.current.delete(tab.id);
|
||||
}}
|
||||
draggable={isSplit && tab.type !== "dashboard"}
|
||||
onDragStart={(e) => {
|
||||
if (!isSplit || tab.type === "dashboard") return;
|
||||
e.dataTransfer.setData("text/plain", tab.id);
|
||||
e.dataTransfer.effectAllowed = "move";
|
||||
}}
|
||||
onClick={() =>
|
||||
!dragTabId && !didDrag.current && onSetActiveTab(tab.id)
|
||||
}
|
||||
@@ -415,14 +448,6 @@ export function TabBar({
|
||||
: `px-2.5 md:px-4 font-medium ${active ? "bg-surface text-foreground" : "text-muted-foreground hover:text-foreground hover:bg-surface"}`
|
||||
}`}
|
||||
>
|
||||
{/* Focused-pane indicator: brand accent bottom border overlay */}
|
||||
{showFocusIndicator && (
|
||||
<span className="absolute bottom-0 left-0 right-0 h-0.5 bg-accent-brand/70 z-10" />
|
||||
)}
|
||||
{/* In-pane (not focused) indicator: subtle dot */}
|
||||
{showInPaneIndicator && (
|
||||
<span className="absolute bottom-0.5 left-1/2 -translate-x-1/2 size-1 rounded-full bg-muted-foreground/40 z-10" />
|
||||
)}
|
||||
{showTabNumbers && tab.type !== "dashboard" && (
|
||||
<span className="shrink-0 text-[10px] tabular-nums text-muted-foreground/70">
|
||||
{tabs.slice(0, index).filter((t) => t.type !== "dashboard")
|
||||
@@ -483,6 +508,7 @@ export function TabBar({
|
||||
|
||||
{dragTabId &&
|
||||
dragPos &&
|
||||
!draggingOut &&
|
||||
(() => {
|
||||
const tab = tabs.find((t) => t.id === dragTabId)!;
|
||||
const active = tab.id === activeTabId;
|
||||
@@ -573,6 +599,13 @@ export function TabBar({
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<Separator orientation="vertical" />
|
||||
<SplitLayoutMenu
|
||||
activeIsSplit={activeIsSplit}
|
||||
canAddPane={!activeSplitFull}
|
||||
onSplit={(edge) => onSplitAction({ kind: "splitActive", edge })}
|
||||
onPreset={(presetId) => onSplitAction({ kind: "preset", presetId })}
|
||||
/>
|
||||
{onToggleRightDock && (
|
||||
<>
|
||||
<Separator orientation="vertical" />
|
||||
@@ -642,19 +675,26 @@ export function TabBar({
|
||||
(() => {
|
||||
const ctxTab = tabs.find((t) => t.id === contextTabId);
|
||||
if (!ctxTab) return null;
|
||||
const isInPane = paneTabIds.includes(contextTabId);
|
||||
const hasEmptySlot =
|
||||
isSplit && paneTabIds.slice(0, paneCount).some((p) => p === null);
|
||||
const joinable = canJoinSplit(ctxTab);
|
||||
const itemClass =
|
||||
"flex items-center gap-2 w-full px-3 py-1.5 text-xs text-left hover:bg-accent hover:text-accent-foreground disabled:opacity-50 disabled:pointer-events-none";
|
||||
const runSplit = (action: TabSplitAction) => {
|
||||
onSplitAction(action);
|
||||
setContextTabId(null);
|
||||
};
|
||||
const menuTop = Math.min(contextPos.y, window.innerHeight - 160);
|
||||
const menuLeft = Math.min(contextPos.x, window.innerWidth - 240);
|
||||
return (
|
||||
<div
|
||||
data-context-menu
|
||||
style={{
|
||||
position: "fixed",
|
||||
left: contextPos.x,
|
||||
top: contextPos.y,
|
||||
left: Math.max(4, menuLeft),
|
||||
top: Math.max(4, menuTop),
|
||||
maxHeight: window.innerHeight - Math.max(4, menuTop) - 8,
|
||||
zIndex: 10000,
|
||||
}}
|
||||
className="bg-popover border border-border shadow-lg py-1 min-w-[180px]"
|
||||
className="bg-popover border border-border shadow-lg py-1 min-w-[200px] max-w-[260px] overflow-y-auto"
|
||||
>
|
||||
<div className="px-2 py-1 text-xs font-semibold text-muted-foreground truncate max-w-[200px]">
|
||||
{ctxTab.label}
|
||||
@@ -719,53 +759,109 @@ export function TabBar({
|
||||
<Pencil className="size-3" />
|
||||
{t("nav.renameTab")}
|
||||
</button>
|
||||
{ctxTab.type !== "split-screen" && (
|
||||
{isSplitTab(ctxTab) && (
|
||||
<>
|
||||
<div className="h-px bg-border my-1" />
|
||||
<div className="px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wider text-muted-foreground">
|
||||
{t("terminal.split.splitTab")}
|
||||
</div>
|
||||
{SPLIT_MODES.filter((m) => m.id !== "none").map((mode) => (
|
||||
<button
|
||||
key={mode.id}
|
||||
className="flex items-center gap-2 w-full px-3 py-1.5 text-xs text-left hover:bg-accent hover:text-accent-foreground"
|
||||
onClick={() => {
|
||||
onSplitTab(contextTabId, mode.id);
|
||||
setContextTabId(null);
|
||||
}}
|
||||
>
|
||||
<LayoutPanelLeft className="size-3 text-muted-foreground" />
|
||||
{mode.label}
|
||||
</button>
|
||||
))}
|
||||
<button
|
||||
className={itemClass}
|
||||
onClick={() =>
|
||||
runSplit({ kind: "unsplit", splitTabId: ctxTab.id })
|
||||
}
|
||||
>
|
||||
<Ungroup className="size-3" />
|
||||
{t("splitScreen.unsplit")}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
{isSplit && (
|
||||
{joinable && (
|
||||
<>
|
||||
<div className="h-px bg-border my-1" />
|
||||
{isInPane ? (
|
||||
<button
|
||||
className="flex items-center gap-2 w-full px-3 py-1.5 text-xs text-left hover:bg-accent hover:text-accent-foreground text-muted-foreground"
|
||||
onClick={() => {
|
||||
onRemoveFromSplit(contextTabId);
|
||||
setContextTabId(null);
|
||||
}}
|
||||
>
|
||||
<Minus className="size-3" />
|
||||
{t("terminal.split.removeFromSplit")}
|
||||
</button>
|
||||
) : hasEmptySlot ? (
|
||||
<button
|
||||
className="flex items-center gap-2 w-full px-3 py-1.5 text-xs text-left hover:bg-accent hover:text-accent-foreground"
|
||||
onClick={() => {
|
||||
onAddToSplit(contextTabId);
|
||||
setContextTabId(null);
|
||||
}}
|
||||
>
|
||||
<Plus className="size-3" />
|
||||
{t("terminal.split.addToSplit")}
|
||||
</button>
|
||||
) : null}
|
||||
<button
|
||||
className={itemClass}
|
||||
onClick={() =>
|
||||
runSplit({
|
||||
kind: "split",
|
||||
tabId: ctxTab.id,
|
||||
edge: "right",
|
||||
})
|
||||
}
|
||||
>
|
||||
<Columns2 className="size-3" />
|
||||
{t("splitScreen.splitRight")}
|
||||
</button>
|
||||
<button
|
||||
className={itemClass}
|
||||
onClick={() =>
|
||||
runSplit({
|
||||
kind: "split",
|
||||
tabId: ctxTab.id,
|
||||
edge: "bottom",
|
||||
})
|
||||
}
|
||||
>
|
||||
<Rows2 className="size-3" />
|
||||
{t("splitScreen.splitDown")}
|
||||
</button>
|
||||
{splits.map((split) => (
|
||||
<div key={split.id} className="flex flex-col">
|
||||
<div className="h-px bg-border my-1" />
|
||||
<div className="px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wider text-muted-foreground truncate">
|
||||
{t("splitScreen.addToSplit", { split: split.label })}
|
||||
</div>
|
||||
{split.panes.map((pane) => (
|
||||
<button
|
||||
key={pane.id}
|
||||
className={`${itemClass} pl-5`}
|
||||
onClick={() =>
|
||||
runSplit({
|
||||
kind: "addToPane",
|
||||
tabId: ctxTab.id,
|
||||
splitTabId: split.id,
|
||||
paneId: pane.id,
|
||||
})
|
||||
}
|
||||
>
|
||||
<span
|
||||
className={`truncate ${pane.label ? "" : "text-muted-foreground"}`}
|
||||
>
|
||||
{pane.label
|
||||
? t("splitScreen.paneItem", {
|
||||
index: pane.index,
|
||||
label: pane.label,
|
||||
})
|
||||
: t("splitScreen.paneEmptyItem", {
|
||||
index: pane.index,
|
||||
})}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
{(["right", "bottom"] as const).map((edge) => (
|
||||
<button
|
||||
key={edge}
|
||||
className={`${itemClass} pl-5`}
|
||||
disabled={split.full}
|
||||
title={
|
||||
split.full
|
||||
? t("splitScreen.maxPanes", { count: MAX_PANES })
|
||||
: undefined
|
||||
}
|
||||
onClick={() =>
|
||||
runSplit({
|
||||
kind: "addPane",
|
||||
tabId: ctxTab.id,
|
||||
splitTabId: split.id,
|
||||
edge,
|
||||
})
|
||||
}
|
||||
>
|
||||
<Plus className="size-3" />
|
||||
{edge === "right"
|
||||
? t("splitScreen.newPaneRight")
|
||||
: t("splitScreen.newPaneBelow")}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
<div className="h-px bg-border my-1" />
|
||||
|
||||
@@ -1,10 +1,15 @@
|
||||
import type {
|
||||
Host,
|
||||
SplitState,
|
||||
Tab,
|
||||
WorkspacePayload,
|
||||
WorkspaceSplitSnapshot,
|
||||
WorkspaceTabSnapshot,
|
||||
} from "@/types/ui-types";
|
||||
import { createId } from "@/lib/create-id";
|
||||
import { getTabType } from "./tab-registry";
|
||||
import { isSplitTab, restoreSplitState } from "./split/split-tabs";
|
||||
import { listPanes, mapTabIds } from "./split/split-tree";
|
||||
|
||||
/**
|
||||
* Serializing and restoring the shell's arrangement: which tabs are open,
|
||||
@@ -56,13 +61,6 @@ function opensAsSingleton(type: string): boolean {
|
||||
return !!def && (!!def.singleton || !!def.hostless);
|
||||
}
|
||||
|
||||
function createSlotId(): string {
|
||||
if (typeof globalThis.crypto?.randomUUID === "function") {
|
||||
return globalThis.crypto.randomUUID();
|
||||
}
|
||||
return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}-${Math.random().toString(36).slice(2)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps live tabs to snapshots, with a fresh slotId per tab. slotId is a
|
||||
* stable key within the saved payload, distinct from Tab.id, which is
|
||||
@@ -70,7 +68,7 @@ function createSlotId(): string {
|
||||
*/
|
||||
export function buildLayoutTabSnapshots(
|
||||
tabs: Tab[],
|
||||
genSlotId: () => string = createSlotId,
|
||||
genSlotId: () => string = createId,
|
||||
): { snapshots: WorkspaceTabSnapshot[]; slotIdByTabId: Map<string, string> } {
|
||||
const capturable = tabs.filter((tab) => isCapturableTabType(tab.type));
|
||||
const slotIdByTabId = new Map(capturable.map((tab) => [tab.id, genSlotId()]));
|
||||
@@ -90,16 +88,6 @@ export function buildLayoutTabSnapshots(
|
||||
return { snapshots, slotIdByTabId };
|
||||
}
|
||||
|
||||
/** Remaps a slotId-keyed array (paneTabIds shape) to live tab ids. */
|
||||
export function remapSlotIds(
|
||||
slotIds: (string | null)[],
|
||||
slotIdToTabId: Map<string, string>,
|
||||
): (string | null)[] {
|
||||
return slotIds.map((slotId) =>
|
||||
slotId != null ? (slotIdToTabId.get(slotId) ?? null) : null,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The tab's plugin payload. Payloads saved before tabs carried `data` stored
|
||||
* the fleet a fleet-inventory tab showed as a field of its own.
|
||||
@@ -147,28 +135,74 @@ export function resolveLayoutTabTarget(
|
||||
export function buildLayoutPayload(input: {
|
||||
tabs: Tab[];
|
||||
activeTabId: string;
|
||||
splitMode: WorkspacePayload["splitMode"];
|
||||
paneTabIds: (string | null)[];
|
||||
rowSizes: number[];
|
||||
rowColSizes: number[][];
|
||||
genSlotId?: () => string;
|
||||
sidebar?: WorkspacePayload["sidebar"];
|
||||
}): WorkspacePayload {
|
||||
const genSlotId = input.genSlotId ?? createId;
|
||||
const { snapshots, slotIdByTabId } = buildLayoutTabSnapshots(
|
||||
input.tabs,
|
||||
input.genSlotId,
|
||||
genSlotId,
|
||||
);
|
||||
|
||||
const splits: WorkspaceSplitSnapshot[] = [];
|
||||
for (const tab of input.tabs) {
|
||||
if (!isSplitTab(tab)) continue;
|
||||
const root = mapTabIds(
|
||||
tab.split.root,
|
||||
(tabId) => slotIdByTabId.get(tabId) ?? null,
|
||||
);
|
||||
// A split whose tabs are all left out of layouts has nothing to restore.
|
||||
if (!listPanes(root).some((pane) => pane.tabId)) continue;
|
||||
const slotId = genSlotId();
|
||||
slotIdByTabId.set(tab.id, slotId);
|
||||
splits.push({
|
||||
slotId,
|
||||
label: tab.label,
|
||||
root,
|
||||
focusedPaneId: tab.split.focusedPaneId,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
version: 1,
|
||||
version: 2,
|
||||
tabs: snapshots,
|
||||
activeSlotId: slotIdByTabId.get(input.activeTabId) ?? null,
|
||||
splitMode: input.splitMode,
|
||||
paneTabIds: input.paneTabIds.map((tabId) =>
|
||||
tabId != null ? (slotIdByTabId.get(tabId) ?? null) : null,
|
||||
),
|
||||
rowSizes: input.rowSizes,
|
||||
rowColSizes: input.rowColSizes,
|
||||
splits,
|
||||
...(input.sidebar ? { sidebar: input.sidebar } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The splits a saved layout describes, with live tab ids. Version 1 payloads
|
||||
* held one split as a fixed mode; it comes back with no label or slotId, so
|
||||
* the caller names it.
|
||||
*/
|
||||
export function resolveLayoutSplits(
|
||||
payload: WorkspacePayload,
|
||||
slotIdToTabId: Map<string, string>,
|
||||
): { slotId: string | null; label: string | null; split: SplitState }[] {
|
||||
const resolve = (slotId: string) => slotIdToTabId.get(slotId) ?? null;
|
||||
if (Array.isArray(payload.splits)) {
|
||||
return payload.splits.flatMap((saved) => {
|
||||
const split = restoreSplitState(saved, resolve);
|
||||
return split ? [{ slotId: saved.slotId, label: saved.label, split }] : [];
|
||||
});
|
||||
}
|
||||
if (
|
||||
typeof payload.splitMode === "string" &&
|
||||
payload.splitMode !== "none" &&
|
||||
Array.isArray(payload.paneTabIds)
|
||||
) {
|
||||
const split = restoreSplitState(
|
||||
{
|
||||
mode: payload.splitMode,
|
||||
paneInstanceIds: payload.paneTabIds,
|
||||
rowSizes: payload.rowSizes,
|
||||
rowColSizes: payload.rowColSizes,
|
||||
},
|
||||
resolve,
|
||||
);
|
||||
return split ? [{ slotId: null, label: null, split }] : [];
|
||||
}
|
||||
return [];
|
||||
}
|
||||
@@ -0,0 +1,262 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Search, X } from "lucide-react";
|
||||
import { Input } from "@/components/input";
|
||||
import { Button } from "@/components/button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/dropdown-menu";
|
||||
import { tabIcon } from "@/shell/tabUtils";
|
||||
import {
|
||||
hostActionsFor,
|
||||
useHostActions,
|
||||
type HostActionDef,
|
||||
} from "@/sidebar/host-contributions";
|
||||
import type { Host, Tab } from "@/types/ui-types";
|
||||
|
||||
/** Hosts listed before a search narrows them down. */
|
||||
const HOST_LIMIT = 60;
|
||||
|
||||
export interface NewTabOption {
|
||||
type: string;
|
||||
label: string;
|
||||
icon?: React.ComponentType<{ className?: string }>;
|
||||
}
|
||||
|
||||
export type PickHostTarget =
|
||||
| { action: HostActionDef }
|
||||
| { action: HostActionDef; item: { id: string; label: string } };
|
||||
|
||||
function HostActionButton({
|
||||
host,
|
||||
action,
|
||||
onPick,
|
||||
}: {
|
||||
host: Host;
|
||||
action: HostActionDef;
|
||||
onPick: (target: PickHostTarget) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const Icon = action.icon;
|
||||
const title = action.label?.(host) ?? t(action.titleKey);
|
||||
const items = action.items?.(host) ?? [];
|
||||
const button = (
|
||||
<button
|
||||
type="button"
|
||||
title={title}
|
||||
aria-label={`${host.name}: ${title}`}
|
||||
className="flex size-6 shrink-0 items-center justify-center text-muted-foreground hover:bg-accent-brand/10 hover:text-accent-brand"
|
||||
onClick={items.length > 1 ? undefined : () => onPick({ action })}
|
||||
>
|
||||
<Icon className="size-3.5" />
|
||||
</button>
|
||||
);
|
||||
if (items.length <= 1) return button;
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>{button}</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-48">
|
||||
{items.map((item) => (
|
||||
<DropdownMenuItem
|
||||
key={item.id}
|
||||
onSelect={() => onPick({ action, item })}
|
||||
>
|
||||
{item.label}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* What an empty pane shows: the tabs that could move in, new tabs to start,
|
||||
* and every host with the ways to open it. Picking anything fills this pane.
|
||||
*/
|
||||
export function EmptyPanePicker({
|
||||
paneIndex,
|
||||
freeTabs,
|
||||
hosts,
|
||||
newTabOptions,
|
||||
onPickTab,
|
||||
onPickHost,
|
||||
onOpenType,
|
||||
onClosePane,
|
||||
}: {
|
||||
paneIndex: number;
|
||||
freeTabs: Tab[];
|
||||
hosts: Host[];
|
||||
newTabOptions: NewTabOption[];
|
||||
onPickTab: (tabId: string) => void;
|
||||
onPickHost: (host: Host, target: PickHostTarget) => void;
|
||||
onOpenType: (type: string) => void;
|
||||
onClosePane: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [query, setQuery] = useState("");
|
||||
const allActions = useHostActions();
|
||||
|
||||
const shownHosts = useMemo(() => {
|
||||
const q = query.trim().toLowerCase();
|
||||
const matches = q
|
||||
? hosts.filter((host) =>
|
||||
[host.name, host.ip, host.username, host.folder]
|
||||
.filter(Boolean)
|
||||
.some((field) => String(field).toLowerCase().includes(q)),
|
||||
)
|
||||
: hosts;
|
||||
return [...matches]
|
||||
.sort((a, b) => a.name.localeCompare(b.name))
|
||||
.slice(0, HOST_LIMIT);
|
||||
}, [hosts, query]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex size-full justify-center overflow-y-auto bg-background p-4"
|
||||
data-split-empty-pane
|
||||
>
|
||||
<div className="flex w-full max-w-md flex-col gap-4 self-start">
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="text-sm font-semibold text-foreground">
|
||||
{t("splitScreen.emptyTitle")}
|
||||
<span className="ml-2 text-xs font-normal text-muted-foreground">
|
||||
{t("splitScreen.paneNumber", { index: paneIndex })}
|
||||
</span>
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{t("splitScreen.emptyHint")}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<section className="flex flex-col gap-1.5">
|
||||
<span className="text-[10px] font-semibold uppercase tracking-wider text-muted-foreground">
|
||||
{t("splitScreen.openTabs")}
|
||||
</span>
|
||||
{freeTabs.length === 0 ? (
|
||||
<span className="text-xs text-muted-foreground/70">
|
||||
{t("splitScreen.noOpenTabs")}
|
||||
</span>
|
||||
) : (
|
||||
<div className="flex flex-col border border-border">
|
||||
{freeTabs.map((tab) => (
|
||||
<button
|
||||
key={tab.id}
|
||||
type="button"
|
||||
className="flex items-center gap-2 border-b border-border px-2.5 py-1.5 text-left text-xs last:border-b-0 hover:bg-accent-brand/10 hover:text-accent-brand"
|
||||
onClick={() => onPickTab(tab.id)}
|
||||
>
|
||||
<span className="shrink-0 text-muted-foreground">
|
||||
{tabIcon(tab.type)}
|
||||
</span>
|
||||
<span className="truncate">{tab.label}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{newTabOptions.length > 0 && (
|
||||
<section className="flex flex-col gap-1.5">
|
||||
<span className="text-[10px] font-semibold uppercase tracking-wider text-muted-foreground">
|
||||
{t("splitScreen.newSession")}
|
||||
</span>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{newTabOptions.map((option) => {
|
||||
const Icon = option.icon;
|
||||
return (
|
||||
<button
|
||||
key={option.type}
|
||||
type="button"
|
||||
className="flex items-center gap-1.5 border border-border px-2.5 py-1.5 text-xs hover:border-accent-brand/40 hover:bg-accent-brand/10 hover:text-accent-brand"
|
||||
onClick={() => onOpenType(option.type)}
|
||||
>
|
||||
{Icon && <Icon className="size-3.5" />}
|
||||
{option.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
<section className="flex flex-col gap-1.5">
|
||||
<span className="text-[10px] font-semibold uppercase tracking-wider text-muted-foreground">
|
||||
{t("splitScreen.hosts")}
|
||||
</span>
|
||||
<div className="relative">
|
||||
<Search className="pointer-events-none absolute left-2 top-1/2 size-3.5 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
value={query}
|
||||
onChange={(event) => setQuery(event.target.value)}
|
||||
placeholder={t("splitScreen.searchHosts")}
|
||||
aria-label={t("splitScreen.searchHosts")}
|
||||
className="h-8 rounded-none pl-7 text-xs"
|
||||
/>
|
||||
</div>
|
||||
{shownHosts.length === 0 ? (
|
||||
<span className="text-xs text-muted-foreground/70">
|
||||
{t("splitScreen.noHosts")}
|
||||
</span>
|
||||
) : (
|
||||
<div className="flex flex-col border border-border">
|
||||
{shownHosts.map((host) => {
|
||||
const actions = hostActionsFor(allActions, host);
|
||||
const connect = actions
|
||||
.filter((action) => action.kind === "connect")
|
||||
.sort((a, b) => (b.priority ?? 0) - (a.priority ?? 0));
|
||||
const open = actions.filter((action) => action.kind === "open");
|
||||
const primary = connect[0];
|
||||
return (
|
||||
<div
|
||||
key={host.id}
|
||||
className="flex items-center gap-2 border-b border-border px-2.5 py-1 last:border-b-0"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
disabled={!primary}
|
||||
className="flex min-w-0 flex-1 flex-col items-start text-left text-xs enabled:hover:text-accent-brand disabled:opacity-60"
|
||||
onClick={() =>
|
||||
primary && onPickHost(host, { action: primary })
|
||||
}
|
||||
>
|
||||
<span className="w-full truncate font-medium">
|
||||
{host.name}
|
||||
</span>
|
||||
<span className="w-full truncate text-[10px] text-muted-foreground">
|
||||
{host.username ? `${host.username}@` : ""}
|
||||
{host.ip}
|
||||
</span>
|
||||
</button>
|
||||
<div className="flex shrink-0 items-center">
|
||||
{[...connect, ...open].map((action) => (
|
||||
<HostActionButton
|
||||
key={action.id}
|
||||
host={host}
|
||||
action={action}
|
||||
onPick={(target) => onPickHost(host, target)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="self-start rounded-none text-xs text-muted-foreground"
|
||||
onClick={onClosePane}
|
||||
>
|
||||
<X className="size-3" />
|
||||
{t("splitScreen.closePane")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
import { useEffect, type RefObject } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
dropTargetAt,
|
||||
dropTargetRect,
|
||||
setSplitDropHitTest,
|
||||
useSplitDrag,
|
||||
} from "./split-drag";
|
||||
|
||||
/**
|
||||
* Drop zones over the main area while a tab or pane is dragged. Over a
|
||||
* split, each pane offers its four edges (split there) and its center (show
|
||||
* it there). Over a plain tab only the edges count, to start a new split.
|
||||
*/
|
||||
export function SplitDropOverlay({
|
||||
containerRef,
|
||||
}: {
|
||||
containerRef: RefObject<HTMLElement | null>;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const drag = useSplitDrag();
|
||||
|
||||
useEffect(
|
||||
() =>
|
||||
setSplitDropHitTest((x, y, source) => {
|
||||
const container = containerRef.current;
|
||||
if (!container) return null;
|
||||
const bounds = container.getBoundingClientRect();
|
||||
if (
|
||||
x < bounds.left ||
|
||||
x > bounds.right ||
|
||||
y < bounds.top ||
|
||||
y > bounds.bottom
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
const panes = Array.from(
|
||||
container.querySelectorAll<HTMLElement>("[data-split-pane-id]"),
|
||||
).filter((el) => el.getClientRects().length > 0);
|
||||
|
||||
if (panes.length === 0) {
|
||||
if (source.kind !== "tab") return null;
|
||||
const target = dropTargetAt(bounds, x, y);
|
||||
if (target === "center") return null;
|
||||
return { paneId: null, target, rect: dropTargetRect(bounds, target) };
|
||||
}
|
||||
|
||||
for (const el of panes) {
|
||||
const rect = el.getBoundingClientRect();
|
||||
if (
|
||||
x < rect.left ||
|
||||
x > rect.right ||
|
||||
y < rect.top ||
|
||||
y > rect.bottom
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
const paneId = el.dataset.splitPaneId ?? null;
|
||||
if (source.kind === "pane" && source.paneId === paneId) return null;
|
||||
const target = dropTargetAt(rect, x, y);
|
||||
return { paneId, target, rect: dropTargetRect(rect, target) };
|
||||
}
|
||||
return null;
|
||||
}),
|
||||
[containerRef],
|
||||
);
|
||||
|
||||
if (!drag) return null;
|
||||
const { hover } = drag;
|
||||
|
||||
return createPortal(
|
||||
<div className="pointer-events-none fixed inset-0 z-[9998]">
|
||||
{hover && (
|
||||
<div
|
||||
className="absolute flex items-center justify-center border-2 border-dashed border-accent-brand bg-accent-brand/15 transition-[left,top,width,height] duration-100"
|
||||
style={{
|
||||
left: hover.rect.left,
|
||||
top: hover.rect.top,
|
||||
width: hover.rect.width,
|
||||
height: hover.rect.height,
|
||||
}}
|
||||
>
|
||||
<span className="bg-background/90 px-2 py-1 text-xs font-medium text-accent-brand">
|
||||
{hover.target === "center"
|
||||
? t("splitScreen.dropHere")
|
||||
: t("splitScreen.dropSplit")}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
className="absolute max-w-48 truncate border border-border bg-popover px-2 py-1 text-xs text-foreground shadow-lg"
|
||||
style={{ left: drag.x + 12, top: drag.y + 12 }}
|
||||
>
|
||||
{drag.source.label}
|
||||
</div>
|
||||
</div>,
|
||||
document.body,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
import { useMemo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Columns2, LayoutPanelLeft, Rows2 } from "lucide-react";
|
||||
import { Button } from "@/components/button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuShortcut,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/dropdown-menu";
|
||||
import type { LayoutNode } from "@/types/ui-types";
|
||||
import {
|
||||
buildPreset,
|
||||
SPLIT_PRESETS,
|
||||
type SplitPresetId,
|
||||
} from "./split-presets";
|
||||
import { MAX_PANES, type PaneEdge } from "./split-tree";
|
||||
|
||||
function NodeThumb({ node }: { node: LayoutNode }) {
|
||||
if (node.kind === "pane") {
|
||||
return <div className="size-full border border-current" />;
|
||||
}
|
||||
return (
|
||||
<div
|
||||
className={`flex size-full gap-0.5 ${node.direction === "row" ? "flex-row" : "flex-col"}`}
|
||||
>
|
||||
{node.children.map((child, index) => (
|
||||
<div
|
||||
key={child.id}
|
||||
className="min-w-0 min-h-0"
|
||||
style={{ flex: `${node.sizes[index]} 1 0px` }}
|
||||
>
|
||||
<NodeThumb node={child} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** A small drawing of a preset's panes. */
|
||||
export function PresetThumb({ id }: { id: SplitPresetId }) {
|
||||
const node = useMemo(() => buildPreset(id), [id]);
|
||||
return <NodeThumb node={node} />;
|
||||
}
|
||||
|
||||
/**
|
||||
* The tab bar's split button: split the active tab right or down, or lay it
|
||||
* out as one of the presets.
|
||||
*/
|
||||
export function SplitLayoutMenu({
|
||||
activeIsSplit,
|
||||
canAddPane,
|
||||
onSplit,
|
||||
onPreset,
|
||||
}: {
|
||||
activeIsSplit: boolean;
|
||||
canAddPane: boolean;
|
||||
onSplit: (edge: Extract<PaneEdge, "right" | "bottom">) => void;
|
||||
onPreset: (id: SplitPresetId) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const fullTitle = canAddPane
|
||||
? undefined
|
||||
: t("splitScreen.maxPanes", { count: MAX_PANES });
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className={`h-full w-12.5 rounded-none border-y-0 border-border ${activeIsSplit ? "text-accent-brand" : "text-muted-foreground hover:text-foreground"}`}
|
||||
title={t("splitScreen.title")}
|
||||
aria-label={t("splitScreen.title")}
|
||||
>
|
||||
<LayoutPanelLeft className="size-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" sideOffset={1} className="w-64 p-1">
|
||||
<DropdownMenuItem
|
||||
disabled={!canAddPane}
|
||||
title={fullTitle}
|
||||
onSelect={() => onSplit("right")}
|
||||
>
|
||||
<Columns2 className="size-3.5" />
|
||||
{t("splitScreen.splitRight")}
|
||||
<DropdownMenuShortcut>Ctrl+Shift+\</DropdownMenuShortcut>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
disabled={!canAddPane}
|
||||
title={fullTitle}
|
||||
onSelect={() => onSplit("bottom")}
|
||||
>
|
||||
<Rows2 className="size-3.5" />
|
||||
{t("splitScreen.splitDown")}
|
||||
<DropdownMenuShortcut>Ctrl+Shift+-</DropdownMenuShortcut>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuLabel className="text-[10px] font-semibold uppercase tracking-wider text-muted-foreground">
|
||||
{activeIsSplit
|
||||
? t("splitScreen.applyLayout")
|
||||
: t("splitScreen.layouts")}
|
||||
</DropdownMenuLabel>
|
||||
<div className="grid grid-cols-4 gap-1 p-1">
|
||||
{SPLIT_PRESETS.map((preset) => (
|
||||
<DropdownMenuItem
|
||||
key={preset.id}
|
||||
className="flex h-12 items-center justify-center p-1.5 text-muted-foreground focus:text-accent-brand"
|
||||
title={t(preset.titleKey)}
|
||||
aria-label={t(preset.titleKey)}
|
||||
onSelect={() => onPreset(preset.id)}
|
||||
>
|
||||
<div className="h-8 w-11">
|
||||
<PresetThumb id={preset.id} />
|
||||
</div>
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</div>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuLabel className="text-[10px] font-semibold uppercase tracking-wider text-muted-foreground">
|
||||
{t("splitScreen.shortcuts")}
|
||||
</DropdownMenuLabel>
|
||||
<div className="flex flex-col gap-1 px-2 pb-1.5 text-xs text-muted-foreground">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span>{t("splitScreen.shortcutNavigate")}</span>
|
||||
<kbd className="font-mono text-[10px]">Alt+Arrows</kbd>
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span>{t("splitScreen.zoom")}</span>
|
||||
<kbd className="font-mono text-[10px]">Ctrl+Shift+Enter</kbd>
|
||||
</div>
|
||||
</div>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,606 @@
|
||||
import React, { memo, useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
ChevronDown,
|
||||
Columns2,
|
||||
Maximize2,
|
||||
Minimize2,
|
||||
MoreHorizontal,
|
||||
Rows2,
|
||||
} from "lucide-react";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuSub,
|
||||
DropdownMenuSubContent,
|
||||
DropdownMenuSubTrigger,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/dropdown-menu";
|
||||
import { tabIcon } from "@/shell/tabUtils";
|
||||
import type { LayoutNode, PaneNode, SplitNode, Tab } from "@/types/ui-types";
|
||||
import type { SplitTab } from "./split-tabs";
|
||||
import {
|
||||
listPanes,
|
||||
MAX_PANES,
|
||||
moveDivider,
|
||||
shownPanes,
|
||||
type PaneEdge,
|
||||
} from "./split-tree";
|
||||
import { beginSplitDrag, endSplitDrag, moveSplitDrag } from "./split-drag";
|
||||
|
||||
export interface SplitViewActions {
|
||||
focusPane: (paneId: string) => void;
|
||||
resize: (nodeId: string, sizes: number[]) => void;
|
||||
equalize: (nodeId: string) => void;
|
||||
splitPane: (
|
||||
paneId: string,
|
||||
edge: Extract<PaneEdge, "right" | "bottom">,
|
||||
) => void;
|
||||
closePane: (paneId: string) => void;
|
||||
closeSession: (tabId: string) => void;
|
||||
moveToTab: (paneId: string) => void;
|
||||
showInPane: (paneId: string, tabId: string | null) => void;
|
||||
swapPanes: (firstId: string, secondId: string) => void;
|
||||
toggleZoom: (paneId: string) => void;
|
||||
/** A divider drag finished, so sessions can fit their new size. */
|
||||
resizeEnd: () => void;
|
||||
}
|
||||
|
||||
interface PaneContext {
|
||||
splitTab: SplitTab;
|
||||
tabsById: Map<string, Tab>;
|
||||
/** Tabs outside any split, offered in each pane's switcher. */
|
||||
freeTabs: Tab[];
|
||||
paneIndexById: Map<string, number>;
|
||||
paneCount: number;
|
||||
isMobile: boolean;
|
||||
resizing: boolean;
|
||||
actions: SplitViewActions;
|
||||
onPaneContentRef: (paneId: string, el: HTMLDivElement | null) => void;
|
||||
renderEmptyPane: (paneId: string, paneIndex: number) => React.ReactNode;
|
||||
setResizing: (resizing: boolean) => void;
|
||||
}
|
||||
|
||||
const DRAG_THRESHOLD = 5;
|
||||
|
||||
function tabLabel(tab: Tab): string {
|
||||
return tab.customLabel || tab.label;
|
||||
}
|
||||
|
||||
function PaneHeader({
|
||||
pane,
|
||||
tab,
|
||||
focused,
|
||||
ctx,
|
||||
}: {
|
||||
pane: PaneNode;
|
||||
tab: Tab | null;
|
||||
focused: boolean;
|
||||
ctx: PaneContext;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const { actions, splitTab, paneIndexById, tabsById } = ctx;
|
||||
const index = paneIndexById.get(pane.id) ?? 0;
|
||||
const panes = listPanes(splitTab.split.root);
|
||||
const otherPanes = panes.filter((other) => other.id !== pane.id);
|
||||
const full = panes.length >= MAX_PANES;
|
||||
const zoomed = splitTab.split.zoomedPaneId === pane.id;
|
||||
const fullTitle = full
|
||||
? t("splitScreen.maxPanes", { count: MAX_PANES })
|
||||
: undefined;
|
||||
const drag = useRef<{ x: number; y: number; started: boolean } | null>(null);
|
||||
|
||||
const label = tab ? tabLabel(tab) : t("splitScreen.emptyTitle");
|
||||
|
||||
function onPointerDown(event: React.PointerEvent<HTMLDivElement>) {
|
||||
if (event.button !== 0 || ctx.isMobile || !tab) return;
|
||||
// The header's menus render in portals, and React still bubbles their
|
||||
// events up to here. Only presses on the header itself start a drag.
|
||||
const target = event.target as HTMLElement;
|
||||
if (!event.currentTarget.contains(target)) return;
|
||||
if (target.closest("button")) return;
|
||||
drag.current = { x: event.clientX, y: event.clientY, started: false };
|
||||
}
|
||||
|
||||
function onPointerMove(event: React.PointerEvent<HTMLDivElement>) {
|
||||
const state = drag.current;
|
||||
if (!state || !tab) return;
|
||||
// Released outside the header before it became a drag.
|
||||
if (!state.started && event.buttons === 0) {
|
||||
drag.current = null;
|
||||
return;
|
||||
}
|
||||
if (!state.started) {
|
||||
const moved = Math.hypot(
|
||||
event.clientX - state.x,
|
||||
event.clientY - state.y,
|
||||
);
|
||||
if (moved < DRAG_THRESHOLD) return;
|
||||
state.started = true;
|
||||
// Captured only once it is really a drag, so a plain click stays a click.
|
||||
event.currentTarget.setPointerCapture(event.pointerId);
|
||||
beginSplitDrag(
|
||||
{
|
||||
kind: "pane",
|
||||
splitTabId: splitTab.id,
|
||||
paneId: pane.id,
|
||||
label: tabLabel(tab),
|
||||
},
|
||||
event.clientX,
|
||||
event.clientY,
|
||||
);
|
||||
return;
|
||||
}
|
||||
moveSplitDrag(event.clientX, event.clientY);
|
||||
}
|
||||
|
||||
function finishDrag(commit: boolean) {
|
||||
const started = drag.current?.started;
|
||||
drag.current = null;
|
||||
if (started) endSplitDrag(commit);
|
||||
}
|
||||
|
||||
const buttonClass =
|
||||
"flex size-6 shrink-0 items-center justify-center text-muted-foreground hover:bg-muted hover:text-foreground disabled:pointer-events-none disabled:opacity-40";
|
||||
|
||||
return (
|
||||
<div
|
||||
data-split-pane-header
|
||||
onPointerDown={onPointerDown}
|
||||
onPointerMove={onPointerMove}
|
||||
onPointerUp={() => finishDrag(true)}
|
||||
onPointerCancel={() => finishDrag(false)}
|
||||
onLostPointerCapture={() => finishDrag(false)}
|
||||
className={`flex h-7 shrink-0 items-center gap-1 border-b pl-1 pr-0.5 text-xs select-none ${
|
||||
focused
|
||||
? "border-accent-brand/40 bg-accent-brand/10"
|
||||
: "border-border bg-sidebar"
|
||||
} ${tab && !ctx.isMobile ? "cursor-grab active:cursor-grabbing" : ""}`}
|
||||
>
|
||||
<span
|
||||
className={`flex h-4 min-w-4 shrink-0 items-center justify-center px-1 font-mono text-[10px] ${
|
||||
focused
|
||||
? "bg-accent-brand text-background"
|
||||
: "bg-muted text-muted-foreground"
|
||||
}`}
|
||||
>
|
||||
{index}
|
||||
</span>
|
||||
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className={`flex min-w-0 items-center gap-1.5 px-1 py-0.5 hover:bg-muted ${
|
||||
focused ? "text-accent-brand" : "text-foreground"
|
||||
}`}
|
||||
title={t("splitScreen.showHere")}
|
||||
>
|
||||
{tab && (
|
||||
<span className={focused ? "" : "text-muted-foreground"}>
|
||||
{tabIcon(tab.type)}
|
||||
</span>
|
||||
)}
|
||||
<span
|
||||
className={`truncate ${tab ? "font-medium" : "text-muted-foreground"}`}
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
<ChevronDown className="size-3 shrink-0 opacity-60" />
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start" className="w-60">
|
||||
<DropdownMenuLabel className="text-[10px] font-semibold uppercase tracking-wider text-muted-foreground">
|
||||
{t("splitScreen.showHere")}
|
||||
</DropdownMenuLabel>
|
||||
{ctx.freeTabs.length === 0 && (
|
||||
<DropdownMenuItem disabled>
|
||||
{t("splitScreen.noOpenTabs")}
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{ctx.freeTabs.map((free) => (
|
||||
<DropdownMenuItem
|
||||
key={free.id}
|
||||
onSelect={() => actions.showInPane(pane.id, free.id)}
|
||||
>
|
||||
{tabIcon(free.type)}
|
||||
<span className="truncate">{tabLabel(free)}</span>
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
{tab && (
|
||||
<>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
onSelect={() => actions.showInPane(pane.id, null)}
|
||||
>
|
||||
{t("splitScreen.clearPane")}
|
||||
</DropdownMenuItem>
|
||||
</>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
<div className="ml-auto flex shrink-0 items-center">
|
||||
{!ctx.isMobile && (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
className={buttonClass}
|
||||
disabled={full}
|
||||
title={fullTitle ?? t("splitScreen.splitRight")}
|
||||
aria-label={t("splitScreen.splitRight")}
|
||||
onClick={() => actions.splitPane(pane.id, "right")}
|
||||
>
|
||||
<Columns2 className="size-3.5" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={buttonClass}
|
||||
disabled={full}
|
||||
title={fullTitle ?? t("splitScreen.splitDown")}
|
||||
aria-label={t("splitScreen.splitDown")}
|
||||
onClick={() => actions.splitPane(pane.id, "bottom")}
|
||||
>
|
||||
<Rows2 className="size-3.5" />
|
||||
</button>
|
||||
{panes.length > 1 && (
|
||||
<button
|
||||
type="button"
|
||||
className={`${buttonClass} ${zoomed ? "text-accent-brand" : ""}`}
|
||||
title={zoomed ? t("splitScreen.unzoom") : t("splitScreen.zoom")}
|
||||
aria-label={
|
||||
zoomed ? t("splitScreen.unzoom") : t("splitScreen.zoom")
|
||||
}
|
||||
aria-pressed={zoomed}
|
||||
onClick={() => actions.toggleZoom(pane.id)}
|
||||
>
|
||||
{zoomed ? (
|
||||
<Minimize2 className="size-3.5" />
|
||||
) : (
|
||||
<Maximize2 className="size-3.5" />
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className={buttonClass}
|
||||
title={t("splitScreen.paneActions")}
|
||||
aria-label={t("splitScreen.paneActions")}
|
||||
>
|
||||
<MoreHorizontal className="size-3.5" />
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-52">
|
||||
{tab && (
|
||||
<>
|
||||
<DropdownMenuItem onSelect={() => actions.moveToTab(pane.id)}>
|
||||
{t("splitScreen.moveToTab")}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onSelect={() => actions.closeSession(tab.id)}>
|
||||
{t("splitScreen.closeSession")}
|
||||
</DropdownMenuItem>
|
||||
</>
|
||||
)}
|
||||
{otherPanes.length > 0 && (
|
||||
<DropdownMenuSub>
|
||||
<DropdownMenuSubTrigger>
|
||||
{t("splitScreen.swapWith")}
|
||||
</DropdownMenuSubTrigger>
|
||||
<DropdownMenuSubContent className="w-56">
|
||||
{otherPanes.map((other) => {
|
||||
const otherTab = other.tabId
|
||||
? tabsById.get(other.tabId)
|
||||
: undefined;
|
||||
const otherIndex = paneIndexById.get(other.id) ?? 0;
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
key={other.id}
|
||||
onSelect={() => actions.swapPanes(pane.id, other.id)}
|
||||
>
|
||||
<span className="truncate">
|
||||
{otherTab
|
||||
? t("splitScreen.paneItem", {
|
||||
index: otherIndex,
|
||||
label: tabLabel(otherTab),
|
||||
})
|
||||
: t("splitScreen.paneEmptyItem", {
|
||||
index: otherIndex,
|
||||
})}
|
||||
</span>
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
})}
|
||||
</DropdownMenuSubContent>
|
||||
</DropdownMenuSub>
|
||||
)}
|
||||
{(tab || otherPanes.length > 0) && <DropdownMenuSeparator />}
|
||||
<DropdownMenuItem
|
||||
variant="destructive"
|
||||
onSelect={() => actions.closePane(pane.id)}
|
||||
>
|
||||
{t("splitScreen.closePane")}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const PaneView = memo(function PaneView({
|
||||
pane,
|
||||
ctx,
|
||||
}: {
|
||||
pane: PaneNode;
|
||||
ctx: PaneContext;
|
||||
}) {
|
||||
const tab = pane.tabId ? (ctx.tabsById.get(pane.tabId) ?? null) : null;
|
||||
const focused = ctx.splitTab.split.focusedPaneId === pane.id;
|
||||
const { onPaneContentRef, actions } = ctx;
|
||||
const paneId = pane.id;
|
||||
const contentRef = useCallback(
|
||||
(el: HTMLDivElement | null) => onPaneContentRef(paneId, el),
|
||||
[paneId, onPaneContentRef],
|
||||
);
|
||||
const paneRef = useRef<HTMLDivElement>(null);
|
||||
const focusRef = useRef(actions.focusPane);
|
||||
focusRef.current = actions.focusPane;
|
||||
|
||||
// Native listeners, not React props: a tab's content is a portal that the
|
||||
// shell moves in here by hand, so React sends its events elsewhere.
|
||||
useEffect(() => {
|
||||
const el = paneRef.current;
|
||||
if (!el) return;
|
||||
const focus = () => focusRef.current(paneId);
|
||||
el.addEventListener("pointerdown", focus, true);
|
||||
el.addEventListener("focusin", focus);
|
||||
return () => {
|
||||
el.removeEventListener("pointerdown", focus, true);
|
||||
el.removeEventListener("focusin", focus);
|
||||
};
|
||||
}, [paneId]);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={paneRef}
|
||||
data-split-pane-id={pane.id}
|
||||
className="relative flex size-full min-h-0 min-w-0 flex-col overflow-hidden bg-background"
|
||||
>
|
||||
<PaneHeader pane={pane} tab={tab} focused={focused} ctx={ctx} />
|
||||
<div className="relative min-h-0 flex-1 overflow-hidden">
|
||||
{tab ? (
|
||||
<div ref={contentRef} className="absolute inset-0" />
|
||||
) : (
|
||||
ctx.renderEmptyPane(pane.id, ctx.paneIndexById.get(pane.id) ?? 0)
|
||||
)}
|
||||
{ctx.resizing && <div className="absolute inset-0 z-10" />}
|
||||
</div>
|
||||
{/* Drawn over the content, which would otherwise cover an inset ring. */}
|
||||
{focused && ctx.paneCount > 1 && (
|
||||
<div className="pointer-events-none absolute inset-0 z-30 border border-accent-brand/70" />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
function Divider({
|
||||
node,
|
||||
index,
|
||||
ctx,
|
||||
}: {
|
||||
node: SplitNode;
|
||||
index: number;
|
||||
ctx: PaneContext;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const row = node.direction === "row";
|
||||
const drag = useRef<{
|
||||
start: number;
|
||||
total: number;
|
||||
sizes: number[];
|
||||
} | null>(null);
|
||||
|
||||
function onPointerDown(event: React.PointerEvent<HTMLDivElement>) {
|
||||
if (event.button !== 0) return;
|
||||
const parent = event.currentTarget.parentElement?.parentElement;
|
||||
if (!parent) return;
|
||||
event.preventDefault();
|
||||
const rect = parent.getBoundingClientRect();
|
||||
drag.current = {
|
||||
start: row ? event.clientX : event.clientY,
|
||||
total: row ? rect.width : rect.height,
|
||||
sizes: node.sizes,
|
||||
};
|
||||
event.currentTarget.setPointerCapture(event.pointerId);
|
||||
ctx.setResizing(true);
|
||||
}
|
||||
|
||||
function onPointerMove(event: React.PointerEvent<HTMLDivElement>) {
|
||||
const state = drag.current;
|
||||
if (!state || state.total <= 0) return;
|
||||
const position = row ? event.clientX : event.clientY;
|
||||
const delta = ((position - state.start) / state.total) * 100;
|
||||
ctx.actions.resize(node.id, moveDivider(state.sizes, index, delta));
|
||||
}
|
||||
|
||||
function finish() {
|
||||
if (!drag.current) return;
|
||||
drag.current = null;
|
||||
ctx.setResizing(false);
|
||||
ctx.actions.resizeEnd();
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`relative z-20 shrink-0 bg-border ${row ? "w-px" : "h-px"}`}
|
||||
>
|
||||
<div
|
||||
role="separator"
|
||||
aria-orientation={row ? "vertical" : "horizontal"}
|
||||
title={t("splitScreen.dividerHint")}
|
||||
onPointerDown={onPointerDown}
|
||||
onPointerMove={onPointerMove}
|
||||
onPointerUp={finish}
|
||||
onPointerCancel={finish}
|
||||
onDoubleClick={() => {
|
||||
ctx.actions.equalize(node.id);
|
||||
ctx.actions.resizeEnd();
|
||||
}}
|
||||
className={`group absolute flex touch-none items-center justify-center ${
|
||||
row
|
||||
? "inset-y-0 -left-1.5 -right-1.5 cursor-col-resize"
|
||||
: "inset-x-0 -top-1.5 -bottom-1.5 cursor-row-resize"
|
||||
}`}
|
||||
>
|
||||
<div
|
||||
className={`pointer-events-none bg-transparent transition-colors group-hover:bg-accent-brand/60 ${
|
||||
row ? "h-full w-0.5" : "h-0.5 w-full"
|
||||
}`}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function NodeView({ node, ctx }: { node: LayoutNode; ctx: PaneContext }) {
|
||||
if (node.kind === "pane") return <PaneView pane={node} ctx={ctx} />;
|
||||
const row = node.direction === "row";
|
||||
return (
|
||||
<div
|
||||
className={`flex size-full min-h-0 min-w-0 ${row ? "flex-row" : "flex-col"}`}
|
||||
>
|
||||
{node.children.map((child, index) => (
|
||||
<React.Fragment key={child.id}>
|
||||
<div
|
||||
className="relative min-h-0 min-w-0 overflow-hidden"
|
||||
style={{ flex: `${node.sizes[index]} 1 0px` }}
|
||||
>
|
||||
<NodeView node={child} ctx={ctx} />
|
||||
</div>
|
||||
{index < node.children.length - 1 && (
|
||||
<Divider node={node} index={index} ctx={ctx} />
|
||||
)}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MobilePaneStrip({ ctx }: { ctx: PaneContext }) {
|
||||
const { t } = useTranslation();
|
||||
const panes = listPanes(ctx.splitTab.split.root);
|
||||
return (
|
||||
<div className="flex h-9 shrink-0 items-stretch overflow-x-auto border-b border-border bg-sidebar">
|
||||
{panes.map((pane) => {
|
||||
const tab = pane.tabId ? ctx.tabsById.get(pane.tabId) : undefined;
|
||||
const active = ctx.splitTab.split.focusedPaneId === pane.id;
|
||||
const index = ctx.paneIndexById.get(pane.id) ?? 0;
|
||||
return (
|
||||
<button
|
||||
key={pane.id}
|
||||
type="button"
|
||||
onClick={() => ctx.actions.focusPane(pane.id)}
|
||||
className={`flex min-w-0 max-w-40 shrink-0 items-center gap-1.5 border-r border-border px-3 text-xs ${
|
||||
active
|
||||
? "bg-accent-brand/10 text-accent-brand"
|
||||
: "text-muted-foreground"
|
||||
}`}
|
||||
>
|
||||
<span className="font-mono text-[10px]">{index}</span>
|
||||
<span className="truncate">
|
||||
{tab ? tabLabel(tab) : t("splitScreen.emptyTitle")}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* A split tab's panes. Each pane with a tab renders an empty host element;
|
||||
* the shell moves the tab's own DOM node into it, so switching panes never
|
||||
* remounts a session.
|
||||
*/
|
||||
export function SplitView({
|
||||
splitTab,
|
||||
tabs,
|
||||
isMobile,
|
||||
actions,
|
||||
onPaneContentRef,
|
||||
renderEmptyPane,
|
||||
}: {
|
||||
splitTab: SplitTab;
|
||||
tabs: Tab[];
|
||||
isMobile: boolean;
|
||||
actions: SplitViewActions;
|
||||
onPaneContentRef: (paneId: string, el: HTMLDivElement | null) => void;
|
||||
renderEmptyPane: (paneId: string, paneIndex: number) => React.ReactNode;
|
||||
}) {
|
||||
const [resizing, setResizing] = useState(false);
|
||||
const focusRef = useRef(actions.focusPane);
|
||||
focusRef.current = actions.focusPane;
|
||||
|
||||
// A click into an iframe (a web page tab) sends no pointer event to this
|
||||
// document, only a blur on the window. The focused iframe says which pane.
|
||||
useEffect(() => {
|
||||
const onBlur = () =>
|
||||
setTimeout(() => {
|
||||
const active = document.activeElement;
|
||||
if (!(active instanceof HTMLIFrameElement)) return;
|
||||
const pane = active.closest<HTMLElement>("[data-split-pane-id]");
|
||||
const paneId = pane?.dataset.splitPaneId;
|
||||
if (paneId) focusRef.current(paneId);
|
||||
}, 0);
|
||||
window.addEventListener("blur", onBlur);
|
||||
return () => window.removeEventListener("blur", onBlur);
|
||||
}, []);
|
||||
const panes = listPanes(splitTab.split.root);
|
||||
const paneIndexById = new Map(panes.map((pane, i) => [pane.id, i + 1]));
|
||||
const tabsById = new Map(tabs.map((tab) => [tab.id, tab]));
|
||||
const freeTabs = tabs.filter(
|
||||
(tab) =>
|
||||
!tab.parentSplitTabId &&
|
||||
tab.type !== "dashboard" &&
|
||||
tab.type !== splitTab.type,
|
||||
);
|
||||
const ctx: PaneContext = {
|
||||
splitTab,
|
||||
tabsById,
|
||||
freeTabs,
|
||||
paneIndexById,
|
||||
paneCount: panes.length,
|
||||
isMobile,
|
||||
resizing,
|
||||
actions,
|
||||
onPaneContentRef,
|
||||
renderEmptyPane,
|
||||
setResizing,
|
||||
};
|
||||
|
||||
const shown = shownPanes(splitTab.split, isMobile);
|
||||
const single = shown.length === 1 && panes.length > 1 ? shown[0] : null;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`flex size-full min-h-0 flex-col overflow-hidden ${resizing ? "select-none" : ""}`}
|
||||
data-split-view={splitTab.id}
|
||||
>
|
||||
{isMobile && panes.length > 1 && <MobilePaneStrip ctx={ctx} />}
|
||||
<div className="relative min-h-0 flex-1">
|
||||
{single ? (
|
||||
<PaneView key={single.id} pane={single} ctx={ctx} />
|
||||
) : (
|
||||
<NodeView node={splitTab.split.root} ctx={ctx} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
import { useSyncExternalStore } from "react";
|
||||
import type { DropTarget } from "./split-tree";
|
||||
|
||||
/**
|
||||
* Dragging a tab or a pane onto the main area to split it. The tab bar and
|
||||
* pane headers start a drag, the drop overlay over the main area answers
|
||||
* where the pointer is, and the shell's handler acts on the drop.
|
||||
*/
|
||||
|
||||
export type SplitDragSource =
|
||||
| { kind: "tab"; tabId: string; label: string }
|
||||
| { kind: "pane"; splitTabId: string; paneId: string; label: string };
|
||||
|
||||
export interface SplitDropHover {
|
||||
/** The pane under the pointer, or null over a view that is not split. */
|
||||
paneId: string | null;
|
||||
target: DropTarget;
|
||||
rect: { left: number; top: number; width: number; height: number };
|
||||
}
|
||||
|
||||
export interface SplitDragState {
|
||||
source: SplitDragSource;
|
||||
x: number;
|
||||
y: number;
|
||||
hover: SplitDropHover | null;
|
||||
}
|
||||
|
||||
type HitTest = (
|
||||
x: number,
|
||||
y: number,
|
||||
source: SplitDragSource,
|
||||
) => SplitDropHover | null;
|
||||
type DropHandler = (source: SplitDragSource, hover: SplitDropHover) => void;
|
||||
|
||||
let state: SplitDragState | null = null;
|
||||
let hitTest: HitTest | null = null;
|
||||
let dropHandler: DropHandler | null = null;
|
||||
const listeners = new Set<() => void>();
|
||||
|
||||
function emit() {
|
||||
for (const listener of listeners) listener();
|
||||
}
|
||||
|
||||
export function beginSplitDrag(source: SplitDragSource, x: number, y: number) {
|
||||
state = { source, x, y, hover: hitTest?.(x, y, source) ?? null };
|
||||
emit();
|
||||
}
|
||||
|
||||
export function moveSplitDrag(x: number, y: number) {
|
||||
if (!state) return;
|
||||
state = { ...state, x, y, hover: hitTest?.(x, y, state.source) ?? null };
|
||||
emit();
|
||||
}
|
||||
|
||||
/** Ends the drag, dropping it where it is when `commit` is true. */
|
||||
export function endSplitDrag(commit: boolean) {
|
||||
const ended = state;
|
||||
if (!ended) return;
|
||||
state = null;
|
||||
emit();
|
||||
if (commit && ended.hover) dropHandler?.(ended.source, ended.hover);
|
||||
}
|
||||
|
||||
export function isSplitDragging(): boolean {
|
||||
return state !== null;
|
||||
}
|
||||
|
||||
export function setSplitDropHitTest(next: HitTest): () => void {
|
||||
hitTest = next;
|
||||
return () => {
|
||||
if (hitTest === next) hitTest = null;
|
||||
};
|
||||
}
|
||||
|
||||
export function setSplitDropHandler(next: DropHandler): () => void {
|
||||
dropHandler = next;
|
||||
return () => {
|
||||
if (dropHandler === next) dropHandler = null;
|
||||
};
|
||||
}
|
||||
|
||||
function subscribe(listener: () => void) {
|
||||
listeners.add(listener);
|
||||
return () => listeners.delete(listener);
|
||||
}
|
||||
|
||||
export function useSplitDrag(): SplitDragState | null {
|
||||
return useSyncExternalStore(
|
||||
subscribe,
|
||||
() => state,
|
||||
() => null,
|
||||
);
|
||||
}
|
||||
|
||||
/** Share of a pane's width or height, from each edge, that splits on drop. */
|
||||
const EDGE_SHARE = 0.25;
|
||||
|
||||
/** Which zone of a rect a point falls in: an edge band, else the center. */
|
||||
export function dropTargetAt(
|
||||
rect: { left: number; top: number; width: number; height: number },
|
||||
x: number,
|
||||
y: number,
|
||||
): DropTarget {
|
||||
const fx = (x - rect.left) / rect.width;
|
||||
const fy = (y - rect.top) / rect.height;
|
||||
const distances: [DropTarget, number][] = [
|
||||
["left", fx],
|
||||
["right", 1 - fx],
|
||||
["top", fy],
|
||||
["bottom", 1 - fy],
|
||||
];
|
||||
const [edge, distance] = distances.reduce((best, entry) =>
|
||||
entry[1] < best[1] ? entry : best,
|
||||
);
|
||||
return distance < EDGE_SHARE ? edge : "center";
|
||||
}
|
||||
|
||||
/** The part of a rect a drop target covers, for drawing the highlight. */
|
||||
export function dropTargetRect(
|
||||
rect: { left: number; top: number; width: number; height: number },
|
||||
target: DropTarget,
|
||||
): SplitDropHover["rect"] {
|
||||
const { left, top, width, height } = rect;
|
||||
switch (target) {
|
||||
case "left":
|
||||
return { left, top, width: width / 2, height };
|
||||
case "right":
|
||||
return { left: left + width / 2, top, width: width / 2, height };
|
||||
case "top":
|
||||
return { left, top, width, height: height / 2 };
|
||||
case "bottom":
|
||||
return { left, top: top + height / 2, width, height: height / 2 };
|
||||
default:
|
||||
return { left, top, width, height };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
import {
|
||||
createPane,
|
||||
createSplitNode,
|
||||
createSplitState,
|
||||
listPanes,
|
||||
type LayoutNode,
|
||||
type SplitState,
|
||||
} from "./split-tree";
|
||||
|
||||
/** Starting layouts offered in the layout picker. Any pane can be split further. */
|
||||
export type SplitPresetId =
|
||||
| "columns-2"
|
||||
| "rows-2"
|
||||
| "columns-3"
|
||||
| "main-left-3"
|
||||
| "main-bottom-3"
|
||||
| "grid-4"
|
||||
| "grid-5"
|
||||
| "grid-6";
|
||||
|
||||
export interface SplitPreset {
|
||||
id: SplitPresetId;
|
||||
titleKey: string;
|
||||
panes: number;
|
||||
}
|
||||
|
||||
export const SPLIT_PRESETS: SplitPreset[] = [
|
||||
{ id: "columns-2", titleKey: "splitScreen.presets.columns2", panes: 2 },
|
||||
{ id: "rows-2", titleKey: "splitScreen.presets.rows2", panes: 2 },
|
||||
{ id: "columns-3", titleKey: "splitScreen.presets.columns3", panes: 3 },
|
||||
{ id: "main-left-3", titleKey: "splitScreen.presets.mainLeft3", panes: 3 },
|
||||
{
|
||||
id: "main-bottom-3",
|
||||
titleKey: "splitScreen.presets.mainBottom3",
|
||||
panes: 3,
|
||||
},
|
||||
{ id: "grid-4", titleKey: "splitScreen.presets.grid4", panes: 4 },
|
||||
{ id: "grid-5", titleKey: "splitScreen.presets.grid5", panes: 5 },
|
||||
{ id: "grid-6", titleKey: "splitScreen.presets.grid6", panes: 6 },
|
||||
];
|
||||
|
||||
type Sizes = { outer?: number[]; inner?: number[][] };
|
||||
|
||||
/**
|
||||
* Builds a preset's tree, filling panes in reading order from `tabIds`.
|
||||
* `sizes` lets the legacy mapping keep the user's divider positions.
|
||||
*/
|
||||
export function buildPreset(
|
||||
id: SplitPresetId,
|
||||
tabIds: (string | null)[] = [],
|
||||
sizes: Sizes = {},
|
||||
): LayoutNode {
|
||||
const p = (index: number) => createPane(tabIds[index] ?? null);
|
||||
const inner = (index: number) => sizes.inner?.[index];
|
||||
switch (id) {
|
||||
case "columns-2":
|
||||
return createSplitNode("row", [p(0), p(1)], inner(0));
|
||||
case "rows-2":
|
||||
return createSplitNode("column", [p(0), p(1)], sizes.outer);
|
||||
case "columns-3":
|
||||
return createSplitNode("row", [p(0), p(1), p(2)], inner(0));
|
||||
case "main-left-3": {
|
||||
const first = inner(0)?.[0];
|
||||
return createSplitNode(
|
||||
"row",
|
||||
[p(0), createSplitNode("column", [p(1), p(2)], sizes.outer)],
|
||||
first !== undefined ? [first, 100 - first] : undefined,
|
||||
);
|
||||
}
|
||||
case "main-bottom-3":
|
||||
return createSplitNode(
|
||||
"column",
|
||||
[createSplitNode("row", [p(0), p(1)], inner(0)), p(2)],
|
||||
sizes.outer,
|
||||
);
|
||||
case "grid-4":
|
||||
return createSplitNode(
|
||||
"column",
|
||||
[
|
||||
createSplitNode("row", [p(0), p(1)], inner(0)),
|
||||
createSplitNode("row", [p(2), p(3)], inner(1)),
|
||||
],
|
||||
sizes.outer,
|
||||
);
|
||||
case "grid-5":
|
||||
return createSplitNode(
|
||||
"column",
|
||||
[
|
||||
createSplitNode("row", [p(0), p(1), p(2)], inner(0)),
|
||||
createSplitNode("row", [p(3), p(4)], inner(1)),
|
||||
],
|
||||
sizes.outer,
|
||||
);
|
||||
case "grid-6":
|
||||
return createSplitNode(
|
||||
"column",
|
||||
[
|
||||
createSplitNode("row", [p(0), p(1), p(2)], inner(0)),
|
||||
createSplitNode("row", [p(3), p(4), p(5)], inner(1)),
|
||||
],
|
||||
sizes.outer,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-lays an existing split out as a preset, keeping its tabs in reading
|
||||
* order. Tabs past the preset's pane count drop out of the split.
|
||||
*/
|
||||
export function applyPreset(state: SplitState, id: SplitPresetId): SplitState {
|
||||
const tabIds = listPanes(state.root).flatMap((pane) =>
|
||||
pane.tabId ? [pane.tabId] : [],
|
||||
);
|
||||
return createSplitState(buildPreset(id, tabIds));
|
||||
}
|
||||
|
||||
/** Split modes from before the layout tree, still found in storage and old workspaces. */
|
||||
export type LegacySplitMode =
|
||||
| "2-way"
|
||||
| "2-way-horizontal"
|
||||
| "3-way"
|
||||
| "3-way-horizontal"
|
||||
| "4-way"
|
||||
| "5-way"
|
||||
| "6-way";
|
||||
|
||||
const LEGACY_PRESETS: Record<LegacySplitMode, SplitPresetId> = {
|
||||
"2-way": "columns-2",
|
||||
"2-way-horizontal": "rows-2",
|
||||
"3-way": "main-left-3",
|
||||
"3-way-horizontal": "main-bottom-3",
|
||||
"4-way": "grid-4",
|
||||
"5-way": "grid-5",
|
||||
"6-way": "grid-6",
|
||||
};
|
||||
|
||||
function validSizes(value: unknown): number[] | undefined {
|
||||
return Array.isArray(value) &&
|
||||
value.length > 0 &&
|
||||
value.every((n) => typeof n === "number" && n > 0)
|
||||
? value
|
||||
: undefined;
|
||||
}
|
||||
|
||||
/** Maps an old fixed-mode split onto the tree, keeping sizes where they fit. */
|
||||
export function fromLegacyConfig(
|
||||
mode: string,
|
||||
paneTabIds: (string | null)[],
|
||||
rowSizes?: unknown,
|
||||
rowColSizes?: unknown,
|
||||
): LayoutNode | null {
|
||||
const presetId = LEGACY_PRESETS[mode as LegacySplitMode];
|
||||
if (!presetId) return null;
|
||||
const inner = Array.isArray(rowColSizes)
|
||||
? rowColSizes.map((row) => validSizes(row) ?? [])
|
||||
: undefined;
|
||||
return buildPreset(presetId, paneTabIds, {
|
||||
outer: validSizes(rowSizes),
|
||||
inner: inner?.map((row) => (row.length > 0 ? row : undefined)) as
|
||||
number[][] | undefined,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,347 @@
|
||||
import type { LayoutNode, SplitState, Tab } from "@/types/ui-types";
|
||||
import {
|
||||
assignTab,
|
||||
clearTab,
|
||||
createSplitState,
|
||||
findPaneByTab,
|
||||
focusPane,
|
||||
listPanes,
|
||||
mapTabIds,
|
||||
normalizeState,
|
||||
tabIdsInSplit,
|
||||
} from "./split-tree";
|
||||
import { fromLegacyConfig, type SplitPresetId } from "./split-presets";
|
||||
|
||||
/**
|
||||
* Split tabs as they live in the shell's tab list. A split tab owns its
|
||||
* layout tree; every tab shown in one of its panes carries parentSplitTabId
|
||||
* so the tab bar hides it. These keep the two in step.
|
||||
*/
|
||||
|
||||
export const SPLIT_TAB_TYPE = "split-screen";
|
||||
|
||||
export type SplitTab = Tab & { split: SplitState };
|
||||
|
||||
export function isSplitTab(tab: Tab | undefined | null): tab is SplitTab {
|
||||
return !!tab && tab.type === SPLIT_TAB_TYPE && !!tab.split;
|
||||
}
|
||||
|
||||
/** Tabs a pane may hold: anything but the dashboard and other splits. */
|
||||
export function canJoinSplit(tab: Tab | undefined | null): boolean {
|
||||
return !!tab && tab.type !== "dashboard" && tab.type !== SPLIT_TAB_TYPE;
|
||||
}
|
||||
|
||||
export function splitTabOf(tabs: Tab[], tabId: string): SplitTab | undefined {
|
||||
const tab = tabs.find((t) => t.id === tabId);
|
||||
const parentId = tab?.parentSplitTabId;
|
||||
const parent = parentId ? tabs.find((t) => t.id === parentId) : undefined;
|
||||
if (isSplitTab(parent) && findPaneByTab(parent.split, tabId)) return parent;
|
||||
return tabs.find(
|
||||
(t): t is SplitTab => isSplitTab(t) && !!findPaneByTab(t.split, tabId),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Makes parentSplitTabId match the trees. Pane tab ids that no longer exist
|
||||
* are emptied, and a tab claimed by two splits stays with the first.
|
||||
*/
|
||||
export function syncSplitChildren(tabs: Tab[]): Tab[] {
|
||||
const joinable = new Set(tabs.filter(canJoinSplit).map((tab) => tab.id));
|
||||
const owner = new Map<string, string>();
|
||||
let changed = false;
|
||||
|
||||
const withSplits = tabs.map((tab) => {
|
||||
if (!isSplitTab(tab)) return tab;
|
||||
const split = normalizeState(
|
||||
tab.split,
|
||||
(id) => joinable.has(id) && !owner.has(id),
|
||||
);
|
||||
for (const id of tabIdsInSplit(split)) owner.set(id, tab.id);
|
||||
if (split === tab.split) return tab;
|
||||
changed = true;
|
||||
return { ...tab, split };
|
||||
});
|
||||
|
||||
const next = withSplits.map((tab) => {
|
||||
const parent = owner.get(tab.id);
|
||||
if (parent) {
|
||||
if (tab.parentSplitTabId === parent) return tab;
|
||||
changed = true;
|
||||
return { ...tab, parentSplitTabId: parent };
|
||||
}
|
||||
if (tab.parentSplitTabId === undefined) return tab;
|
||||
changed = true;
|
||||
const { parentSplitTabId: _released, ...released } = tab;
|
||||
return released;
|
||||
});
|
||||
|
||||
return changed ? next : tabs;
|
||||
}
|
||||
|
||||
/** Empties the panes holding these tabs in every split but `exceptSplitId`. */
|
||||
function releaseFromSplits(
|
||||
tabs: Tab[],
|
||||
tabIds: string[],
|
||||
exceptSplitId?: string,
|
||||
): Tab[] {
|
||||
if (tabIds.length === 0) return tabs;
|
||||
return tabs.map((tab) => {
|
||||
if (!isSplitTab(tab) || tab.id === exceptSplitId) return tab;
|
||||
let split = tab.split;
|
||||
for (const id of tabIds) split = clearTab(split, id);
|
||||
return split === tab.split ? tab : { ...tab, split };
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies `fn` to one split's state. Returning null closes the split and
|
||||
* sends its tabs back to the tab bar.
|
||||
*/
|
||||
export function updateSplit(
|
||||
tabs: Tab[],
|
||||
splitTabId: string,
|
||||
fn: (state: SplitState) => SplitState | null,
|
||||
): Tab[] {
|
||||
const target = tabs.find((tab) => tab.id === splitTabId);
|
||||
if (!isSplitTab(target)) return tabs;
|
||||
const next = fn(target.split);
|
||||
if (next === target.split) return tabs;
|
||||
if (!next) return closeSplitTab(tabs, splitTabId);
|
||||
const claimed = tabIdsInSplit(next);
|
||||
const released = releaseFromSplits(tabs, claimed, splitTabId);
|
||||
return syncSplitChildren(
|
||||
released.map((tab) =>
|
||||
tab.id === splitTabId ? { ...tab, split: next } : tab,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
export function makeSplitTab(
|
||||
instanceId: string,
|
||||
label: string,
|
||||
split: SplitState,
|
||||
openedAt = Date.now(),
|
||||
): SplitTab {
|
||||
return {
|
||||
id: `split-${instanceId}`,
|
||||
instanceId,
|
||||
type: SPLIT_TAB_TYPE,
|
||||
label,
|
||||
openedAt,
|
||||
split,
|
||||
};
|
||||
}
|
||||
|
||||
/** Adds a split tab, taking its tabs out of any other split. */
|
||||
export function addSplitTab(tabs: Tab[], splitTab: SplitTab): Tab[] {
|
||||
const released = releaseFromSplits(tabs, tabIdsInSplit(splitTab.split));
|
||||
return syncSplitChildren([...released, splitTab]);
|
||||
}
|
||||
|
||||
/** Removes a split tab. Its tabs go back to the tab bar. */
|
||||
export function closeSplitTab(tabs: Tab[], splitTabId: string): Tab[] {
|
||||
return syncSplitChildren(tabs.filter((tab) => tab.id !== splitTabId));
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes a closed tab. A pane that held it stays, empty and focused, so the
|
||||
* next thing the user opens can go straight into it.
|
||||
*/
|
||||
export function removeTab(tabs: Tab[], tabId: string): Tab[] {
|
||||
const without = tabs
|
||||
.filter((tab) => tab.id !== tabId)
|
||||
.map((tab) => {
|
||||
if (!isSplitTab(tab)) return tab;
|
||||
const pane = findPaneByTab(tab.split, tabId);
|
||||
if (!pane) return tab;
|
||||
return {
|
||||
...tab,
|
||||
split: focusPane(clearTab(tab.split, tabId), pane.id),
|
||||
};
|
||||
});
|
||||
return syncSplitChildren(without);
|
||||
}
|
||||
|
||||
/** Puts a tab in a pane of a split and focuses that pane. */
|
||||
export function placeTabInPane(
|
||||
tabs: Tab[],
|
||||
splitTabId: string,
|
||||
paneId: string,
|
||||
tabId: string,
|
||||
): Tab[] {
|
||||
if (!canJoinSplit(tabs.find((tab) => tab.id === tabId))) return tabs;
|
||||
return updateSplit(tabs, splitTabId, (state) =>
|
||||
focusPane(assignTab(state, paneId, tabId), paneId),
|
||||
);
|
||||
}
|
||||
|
||||
/** What the tab bar asks the shell to do with splits. */
|
||||
export type TabSplitAction =
|
||||
| { kind: "split"; tabId: string; edge: "right" | "bottom" }
|
||||
| { kind: "addToPane"; tabId: string; splitTabId: string; paneId: string }
|
||||
| {
|
||||
kind: "addPane";
|
||||
tabId: string;
|
||||
splitTabId: string;
|
||||
edge: "right" | "bottom";
|
||||
}
|
||||
| { kind: "unsplit"; splitTabId: string }
|
||||
| { kind: "splitActive"; edge: "right" | "bottom" }
|
||||
| { kind: "preset"; presetId: SplitPresetId };
|
||||
|
||||
/** The lowest N not already used by a "Split N" style label. */
|
||||
export function nextSplitNumber(
|
||||
tabs: Tab[],
|
||||
labelFor: (n: number) => string,
|
||||
): number {
|
||||
const used = new Set(
|
||||
tabs.filter((tab) => tab.type === SPLIT_TAB_TYPE).map((tab) => tab.label),
|
||||
);
|
||||
let n = 1;
|
||||
while (used.has(labelFor(n))) n += 1;
|
||||
return n;
|
||||
}
|
||||
|
||||
export interface SplitPaneSummary {
|
||||
id: string;
|
||||
/** 1-based, in reading order. */
|
||||
index: number;
|
||||
tabId: string | null;
|
||||
/** The tab's label, or null for an empty pane. */
|
||||
label: string | null;
|
||||
}
|
||||
|
||||
export interface SplitSummary {
|
||||
id: string;
|
||||
label: string;
|
||||
panes: SplitPaneSummary[];
|
||||
full: boolean;
|
||||
}
|
||||
|
||||
export function summarizeSplits(tabs: Tab[], maxPanes: number): SplitSummary[] {
|
||||
const byId = new Map(tabs.map((tab) => [tab.id, tab]));
|
||||
return tabs.filter(isSplitTab).map((tab) => {
|
||||
const panes = listPanes(tab.split.root);
|
||||
return {
|
||||
id: tab.id,
|
||||
label: tab.label,
|
||||
panes: panes.map((pane, i) => {
|
||||
const inPane = pane.tabId ? byId.get(pane.tabId) : undefined;
|
||||
return {
|
||||
id: pane.id,
|
||||
index: i + 1,
|
||||
tabId: pane.tabId,
|
||||
label: inPane ? inPane.customLabel || inPane.label : null,
|
||||
};
|
||||
}),
|
||||
full: panes.length >= maxPanes,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
// ─── Persistence ──────────────────────────────────────────────────────────────
|
||||
|
||||
/** Saved in localStorage; pane tab ids are tab instanceIds, which survive a reload. */
|
||||
export type PersistedSplitTab = {
|
||||
version: 2;
|
||||
instanceId: string;
|
||||
label: string;
|
||||
root: LayoutNode;
|
||||
focusedPaneId: string;
|
||||
};
|
||||
|
||||
/** The shape saved before the layout tree. */
|
||||
type LegacyPersistedSplitTab = {
|
||||
instanceId: string;
|
||||
label: string;
|
||||
mode: string;
|
||||
paneInstanceIds: (string | null)[];
|
||||
rowSizes?: number[];
|
||||
rowColSizes?: number[][];
|
||||
};
|
||||
|
||||
export function serializeSplitTabs(tabs: Tab[]): PersistedSplitTab[] {
|
||||
const instanceIdById = new Map(tabs.map((tab) => [tab.id, tab.instanceId]));
|
||||
return tabs.filter(isSplitTab).map((tab) => ({
|
||||
version: 2,
|
||||
instanceId: tab.instanceId,
|
||||
label: tab.label,
|
||||
root: mapTabIds(tab.split.root, (id) => instanceIdById.get(id) ?? null),
|
||||
focusedPaneId: tab.split.focusedPaneId,
|
||||
}));
|
||||
}
|
||||
|
||||
function isLayoutNode(value: unknown): value is LayoutNode {
|
||||
if (!value || typeof value !== "object") return false;
|
||||
const node = value as Partial<LayoutNode>;
|
||||
if (node.kind === "pane") return typeof node.id === "string";
|
||||
if (node.kind === "split") {
|
||||
return (
|
||||
typeof node.id === "string" &&
|
||||
(node.direction === "row" || node.direction === "column") &&
|
||||
Array.isArray(node.children) &&
|
||||
node.children.length > 0 &&
|
||||
Array.isArray(node.sizes) &&
|
||||
node.children.every(isLayoutNode)
|
||||
);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Rebuilds a saved split's tree with live tab ids. `resolve` maps whatever
|
||||
* the save used (instanceIds, workspace slotIds) to a live tab id. Null when
|
||||
* the entry is unreadable or none of its tabs came back.
|
||||
*/
|
||||
export function restoreSplitState(
|
||||
entry: unknown,
|
||||
resolve: (savedId: string) => string | null,
|
||||
): SplitState | null {
|
||||
if (!entry || typeof entry !== "object") return null;
|
||||
const saved = entry as Partial<PersistedSplitTab> &
|
||||
Partial<LegacyPersistedSplitTab>;
|
||||
let root: LayoutNode | null = null;
|
||||
let focusedPaneId: string | undefined;
|
||||
if (isLayoutNode(saved.root)) {
|
||||
root = mapTabIds(saved.root, resolve);
|
||||
focusedPaneId = saved.focusedPaneId;
|
||||
} else if (
|
||||
typeof saved.mode === "string" &&
|
||||
Array.isArray(saved.paneInstanceIds)
|
||||
) {
|
||||
root = fromLegacyConfig(
|
||||
saved.mode,
|
||||
saved.paneInstanceIds.map((id) => (id ? resolve(id) : null)),
|
||||
saved.rowSizes,
|
||||
saved.rowColSizes,
|
||||
);
|
||||
}
|
||||
if (!root) return null;
|
||||
const state = normalizeState(createSplitState(root, focusedPaneId));
|
||||
return tabIdsInSplit(state).length > 0 ? state : null;
|
||||
}
|
||||
|
||||
export function restoreSplitTabs(
|
||||
persisted: unknown[],
|
||||
tabs: Tab[],
|
||||
openedAt = Date.now(),
|
||||
): Tab[] {
|
||||
const tabIdByInstanceId = new Map(
|
||||
tabs.map((tab) => [tab.instanceId, tab.id]),
|
||||
);
|
||||
let next = tabs;
|
||||
for (const entry of persisted) {
|
||||
const saved = entry as { instanceId?: unknown; label?: unknown };
|
||||
if (typeof saved?.instanceId !== "string") continue;
|
||||
const instanceId = saved.instanceId;
|
||||
if (next.some((tab) => tab.instanceId === instanceId)) continue;
|
||||
const split = restoreSplitState(
|
||||
entry,
|
||||
(id) => tabIdByInstanceId.get(id) ?? null,
|
||||
);
|
||||
if (!split) continue;
|
||||
const label = typeof saved.label === "string" ? saved.label : "";
|
||||
next = addSplitTab(next, makeSplitTab(instanceId, label, split, openedAt));
|
||||
}
|
||||
return next;
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import { useSyncExternalStore } from "react";
|
||||
import type { SplitSummary } from "./split-tabs";
|
||||
|
||||
/**
|
||||
* Lets menus outside the shell (the host list) offer "Open in split" without
|
||||
* threading split state through every sidebar component. The shell publishes
|
||||
* its splits here and registers how to aim the next opened tab at a pane.
|
||||
*/
|
||||
|
||||
export type SplitOpenTarget =
|
||||
| { kind: "pane"; splitTabId: string; paneId: string }
|
||||
| { kind: "newPane"; splitTabId: string }
|
||||
/** A new split holding the active tab and the opened one. */
|
||||
| { kind: "newSplit" };
|
||||
|
||||
export interface SplitTargetsSnapshot {
|
||||
splits: SplitSummary[];
|
||||
/** The active tab can start a new split. */
|
||||
canStartSplit: boolean;
|
||||
}
|
||||
|
||||
type Opener = (target: SplitOpenTarget, open: () => void) => void;
|
||||
|
||||
const EMPTY: SplitTargetsSnapshot = { splits: [], canStartSplit: false };
|
||||
let snapshot: SplitTargetsSnapshot = EMPTY;
|
||||
let snapshotKey = "";
|
||||
let opener: Opener | null = null;
|
||||
const listeners = new Set<() => void>();
|
||||
|
||||
export function publishSplitTargets(next: SplitTargetsSnapshot) {
|
||||
const key = JSON.stringify(next);
|
||||
if (key === snapshotKey) return;
|
||||
snapshotKey = key;
|
||||
snapshot = next;
|
||||
for (const listener of listeners) listener();
|
||||
}
|
||||
|
||||
export function setSplitOpener(next: Opener): () => void {
|
||||
opener = next;
|
||||
return () => {
|
||||
if (opener === next) {
|
||||
opener = null;
|
||||
publishSplitTargets(EMPTY);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/** Runs `open` so whatever tab it opens lands in `target`. */
|
||||
export function openInSplit(target: SplitOpenTarget, open: () => void) {
|
||||
if (opener) opener(target, open);
|
||||
else open();
|
||||
}
|
||||
|
||||
function subscribe(listener: () => void) {
|
||||
listeners.add(listener);
|
||||
return () => listeners.delete(listener);
|
||||
}
|
||||
|
||||
export function useSplitTargets(): SplitTargetsSnapshot {
|
||||
return useSyncExternalStore(
|
||||
subscribe,
|
||||
() => snapshot,
|
||||
() => EMPTY,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,571 @@
|
||||
import { createId } from "@/lib/create-id";
|
||||
import type {
|
||||
LayoutNode,
|
||||
PaneNode,
|
||||
SplitDirection,
|
||||
SplitNode,
|
||||
SplitState,
|
||||
} from "@/types/ui-types";
|
||||
|
||||
/**
|
||||
* The layout of one split tab: a tree where every leaf is a pane holding at
|
||||
* most one tab, and every inner node lays its children out in a row (side by
|
||||
* side) or a column (stacked). All functions here are pure and return a new
|
||||
* state, so the shell can keep the whole thing on the split tab itself.
|
||||
*/
|
||||
|
||||
export const MAX_PANES = 8;
|
||||
/** Smallest share of its parent a pane or group can be dragged down to. */
|
||||
export const MIN_PANE_SIZE = 10;
|
||||
|
||||
export type {
|
||||
SplitDirection,
|
||||
PaneNode,
|
||||
SplitNode,
|
||||
LayoutNode,
|
||||
SplitState,
|
||||
} from "@/types/ui-types";
|
||||
export type PaneEdge = "left" | "right" | "top" | "bottom";
|
||||
export type DropTarget = PaneEdge | "center";
|
||||
export type NavDirection = "left" | "right" | "up" | "down";
|
||||
|
||||
export interface PaneRect {
|
||||
left: number;
|
||||
top: number;
|
||||
right: number;
|
||||
bottom: number;
|
||||
}
|
||||
|
||||
export function createPane(tabId: string | null = null): PaneNode {
|
||||
return { kind: "pane", id: createId(), tabId };
|
||||
}
|
||||
|
||||
export function evenSizes(count: number): number[] {
|
||||
return Array.from({ length: count }, () => 100 / count);
|
||||
}
|
||||
|
||||
export function createSplitNode(
|
||||
direction: SplitDirection,
|
||||
children: LayoutNode[],
|
||||
sizes?: number[],
|
||||
): SplitNode {
|
||||
return {
|
||||
kind: "split",
|
||||
id: createId(),
|
||||
direction,
|
||||
children,
|
||||
sizes:
|
||||
sizes && sizes.length === children.length
|
||||
? scaleTo100(sizes)
|
||||
: evenSizes(children.length),
|
||||
};
|
||||
}
|
||||
|
||||
export function createSplitState(
|
||||
root: LayoutNode,
|
||||
focusedPaneId?: string,
|
||||
): SplitState {
|
||||
const panes = listPanes(root);
|
||||
const focused =
|
||||
panes.find((pane) => pane.id === focusedPaneId) ??
|
||||
panes.find((pane) => pane.tabId === null) ??
|
||||
panes[0];
|
||||
return { root, focusedPaneId: focused.id, zoomedPaneId: null };
|
||||
}
|
||||
|
||||
export function listPanes(node: LayoutNode): PaneNode[] {
|
||||
if (node.kind === "pane") return [node];
|
||||
return node.children.flatMap(listPanes);
|
||||
}
|
||||
|
||||
export function paneCount(state: SplitState): number {
|
||||
return listPanes(state.root).length;
|
||||
}
|
||||
|
||||
export function findPane(
|
||||
state: SplitState,
|
||||
paneId: string,
|
||||
): PaneNode | undefined {
|
||||
return listPanes(state.root).find((pane) => pane.id === paneId);
|
||||
}
|
||||
|
||||
export function findPaneByTab(
|
||||
state: SplitState,
|
||||
tabId: string,
|
||||
): PaneNode | undefined {
|
||||
return listPanes(state.root).find((pane) => pane.tabId === tabId);
|
||||
}
|
||||
|
||||
export function emptyPanes(state: SplitState): PaneNode[] {
|
||||
return listPanes(state.root).filter((pane) => pane.tabId === null);
|
||||
}
|
||||
|
||||
export function tabIdsInSplit(state: SplitState): string[] {
|
||||
return listPanes(state.root).flatMap((pane) =>
|
||||
pane.tabId ? [pane.tabId] : [],
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The panes on screen: just the zoomed one, just the focused one when only
|
||||
* one fits (mobile), else all of them.
|
||||
*/
|
||||
export function shownPanes(state: SplitState, onePaneOnly = false): PaneNode[] {
|
||||
const panes = listPanes(state.root);
|
||||
const single =
|
||||
state.zoomedPaneId ?? (onePaneOnly ? state.focusedPaneId : null);
|
||||
const pane = single ? panes.find((p) => p.id === single) : undefined;
|
||||
return pane ? [pane] : panes;
|
||||
}
|
||||
|
||||
export function canAddPane(state: SplitState): boolean {
|
||||
return paneCount(state) < MAX_PANES;
|
||||
}
|
||||
|
||||
function edgeDirection(edge: PaneEdge): SplitDirection {
|
||||
return edge === "left" || edge === "right" ? "row" : "column";
|
||||
}
|
||||
|
||||
function isLeadingEdge(edge: PaneEdge): boolean {
|
||||
return edge === "left" || edge === "top";
|
||||
}
|
||||
|
||||
function scaleTo100(sizes: number[]): number[] {
|
||||
const total = sizes.reduce((sum, size) => sum + size, 0);
|
||||
if (!(total > 0)) return evenSizes(sizes.length);
|
||||
return sizes.map((size) => (size / total) * 100);
|
||||
}
|
||||
|
||||
function mapPanes(
|
||||
node: LayoutNode,
|
||||
fn: (pane: PaneNode) => PaneNode,
|
||||
): LayoutNode {
|
||||
if (node.kind === "pane") return fn(node);
|
||||
let changed = false;
|
||||
const children = node.children.map((child) => {
|
||||
const next = mapPanes(child, fn);
|
||||
if (next !== child) changed = true;
|
||||
return next;
|
||||
});
|
||||
return changed ? { ...node, children } : node;
|
||||
}
|
||||
|
||||
function mapSplit(
|
||||
node: LayoutNode,
|
||||
splitId: string,
|
||||
fn: (split: SplitNode) => SplitNode,
|
||||
): LayoutNode {
|
||||
if (node.kind === "pane") return node;
|
||||
if (node.id === splitId) return fn(node);
|
||||
let changed = false;
|
||||
const children = node.children.map((child) => {
|
||||
const next = mapSplit(child, splitId, fn);
|
||||
if (next !== child) changed = true;
|
||||
return next;
|
||||
});
|
||||
return changed ? { ...node, children } : node;
|
||||
}
|
||||
|
||||
/**
|
||||
* Collapses groups with one child, inlines a child group running the same
|
||||
* way as its parent, and rescales sizes. Keeps the tree in the one shape the
|
||||
* rest of the code expects after any edit.
|
||||
*/
|
||||
export function normalizeNode(node: LayoutNode): LayoutNode {
|
||||
if (node.kind === "pane") return node;
|
||||
const children: LayoutNode[] = [];
|
||||
const sizes: number[] = [];
|
||||
const baseSizes =
|
||||
node.sizes.length === node.children.length
|
||||
? node.sizes
|
||||
: evenSizes(node.children.length);
|
||||
let changed = baseSizes !== node.sizes;
|
||||
node.children.forEach((raw, index) => {
|
||||
const child = normalizeNode(raw);
|
||||
if (child !== raw) changed = true;
|
||||
const size = baseSizes[index] ?? 0;
|
||||
if (child.kind === "split" && child.direction === node.direction) {
|
||||
changed = true;
|
||||
child.children.forEach((grandchild, i) => {
|
||||
children.push(grandchild);
|
||||
sizes.push((size * child.sizes[i]) / 100);
|
||||
});
|
||||
} else {
|
||||
children.push(child);
|
||||
sizes.push(size);
|
||||
}
|
||||
});
|
||||
if (children.length === 1) return children[0];
|
||||
const total = sizes.reduce((sum, size) => sum + size, 0);
|
||||
if (!changed && Math.abs(total - 100) < 0.01) return node;
|
||||
return { ...node, children, sizes: scaleTo100(sizes) };
|
||||
}
|
||||
|
||||
function insertBeside(
|
||||
node: LayoutNode,
|
||||
paneId: string,
|
||||
edge: PaneEdge,
|
||||
newPane: PaneNode,
|
||||
): LayoutNode {
|
||||
const direction = edgeDirection(edge);
|
||||
if (node.kind === "pane") {
|
||||
if (node.id !== paneId) return node;
|
||||
return createSplitNode(
|
||||
direction,
|
||||
isLeadingEdge(edge) ? [newPane, node] : [node, newPane],
|
||||
);
|
||||
}
|
||||
const index = node.children.findIndex(
|
||||
(child) => child.kind === "pane" && child.id === paneId,
|
||||
);
|
||||
if (index !== -1 && node.direction === direction) {
|
||||
const children = [...node.children];
|
||||
const sizes = [...node.sizes];
|
||||
const half = sizes[index] / 2;
|
||||
const insertAt = isLeadingEdge(edge) ? index : index + 1;
|
||||
sizes[index] = half;
|
||||
children.splice(insertAt, 0, newPane);
|
||||
sizes.splice(insertAt, 0, half);
|
||||
return { ...node, children, sizes };
|
||||
}
|
||||
let changed = false;
|
||||
const children = node.children.map((child) => {
|
||||
const next = insertBeside(child, paneId, edge, newPane);
|
||||
if (next !== child) changed = true;
|
||||
return next;
|
||||
});
|
||||
return changed ? { ...node, children } : node;
|
||||
}
|
||||
|
||||
/** Removes `tabId` from every pane except `keepPaneId`: a tab lives in one pane. */
|
||||
function releaseTabElsewhere(
|
||||
node: LayoutNode,
|
||||
tabId: string,
|
||||
keepPaneId: string,
|
||||
): LayoutNode {
|
||||
return mapPanes(node, (pane) =>
|
||||
pane.tabId === tabId && pane.id !== keepPaneId
|
||||
? { ...pane, tabId: null }
|
||||
: pane,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds a pane next to `paneId`, holding `tabId` or empty. Null when the pane
|
||||
* does not exist or the split is full.
|
||||
*/
|
||||
export function splitPane(
|
||||
state: SplitState,
|
||||
paneId: string,
|
||||
edge: PaneEdge,
|
||||
tabId: string | null = null,
|
||||
): { state: SplitState; paneId: string } | null {
|
||||
if (!findPane(state, paneId) || !canAddPane(state)) return null;
|
||||
const newPane = createPane(tabId);
|
||||
let root = insertBeside(state.root, paneId, edge, newPane);
|
||||
if (tabId) root = releaseTabElsewhere(root, tabId, newPane.id);
|
||||
return {
|
||||
state: {
|
||||
...state,
|
||||
root: normalizeNode(root),
|
||||
focusedPaneId: newPane.id,
|
||||
zoomedPaneId: null,
|
||||
},
|
||||
paneId: newPane.id,
|
||||
};
|
||||
}
|
||||
|
||||
/** Adds a pane along one outer edge of the whole split. */
|
||||
export function addPaneAtEdge(
|
||||
state: SplitState,
|
||||
edge: PaneEdge,
|
||||
tabId: string | null = null,
|
||||
): { state: SplitState; paneId: string } | null {
|
||||
if (!canAddPane(state)) return null;
|
||||
const direction = edgeDirection(edge);
|
||||
const newPane = createPane(tabId);
|
||||
const leading = isLeadingEdge(edge);
|
||||
let root: LayoutNode;
|
||||
if (state.root.kind === "split" && state.root.direction === direction) {
|
||||
const count = state.root.children.length;
|
||||
const share = 100 / (count + 1);
|
||||
const scaled = state.root.sizes.map((size) => (size * count) / (count + 1));
|
||||
root = {
|
||||
...state.root,
|
||||
children: leading
|
||||
? [newPane, ...state.root.children]
|
||||
: [...state.root.children, newPane],
|
||||
sizes: leading ? [share, ...scaled] : [...scaled, share],
|
||||
};
|
||||
} else {
|
||||
root = createSplitNode(
|
||||
direction,
|
||||
leading ? [newPane, state.root] : [state.root, newPane],
|
||||
);
|
||||
}
|
||||
if (tabId) root = releaseTabElsewhere(root, tabId, newPane.id);
|
||||
return {
|
||||
state: {
|
||||
...state,
|
||||
root: normalizeNode(root),
|
||||
focusedPaneId: newPane.id,
|
||||
zoomedPaneId: null,
|
||||
},
|
||||
paneId: newPane.id,
|
||||
};
|
||||
}
|
||||
|
||||
function withoutPane(node: LayoutNode, paneId: string): LayoutNode | null {
|
||||
if (node.kind === "pane") return node.id === paneId ? null : node;
|
||||
const children: LayoutNode[] = [];
|
||||
const sizes: number[] = [];
|
||||
let changed = false;
|
||||
node.children.forEach((child, index) => {
|
||||
const next = withoutPane(child, paneId);
|
||||
if (next !== child) changed = true;
|
||||
if (next) {
|
||||
children.push(next);
|
||||
sizes.push(node.sizes[index]);
|
||||
}
|
||||
});
|
||||
if (!changed) return node;
|
||||
if (children.length === 0) return null;
|
||||
return { ...node, children, sizes };
|
||||
}
|
||||
|
||||
/** Removes a pane. Null when it was the last one, so the split should close. */
|
||||
export function removePane(
|
||||
state: SplitState,
|
||||
paneId: string,
|
||||
): SplitState | null {
|
||||
const before = listPanes(state.root);
|
||||
const index = before.findIndex((pane) => pane.id === paneId);
|
||||
if (index === -1) return state;
|
||||
const raw = withoutPane(state.root, paneId);
|
||||
if (!raw) return null;
|
||||
const root = normalizeNode(raw);
|
||||
const after = listPanes(root);
|
||||
const focusedPaneId =
|
||||
state.focusedPaneId !== paneId &&
|
||||
after.some((pane) => pane.id === state.focusedPaneId)
|
||||
? state.focusedPaneId
|
||||
: after[Math.max(0, index - 1)].id;
|
||||
return {
|
||||
root,
|
||||
focusedPaneId,
|
||||
zoomedPaneId: state.zoomedPaneId === paneId ? null : state.zoomedPaneId,
|
||||
};
|
||||
}
|
||||
|
||||
/** Puts a tab in a pane (or empties it with null), moving it out of any other pane. */
|
||||
export function assignTab(
|
||||
state: SplitState,
|
||||
paneId: string,
|
||||
tabId: string | null,
|
||||
): SplitState {
|
||||
if (!findPane(state, paneId)) return state;
|
||||
const root = mapPanes(state.root, (pane) => {
|
||||
if (pane.id === paneId)
|
||||
return pane.tabId === tabId ? pane : { ...pane, tabId };
|
||||
if (tabId && pane.tabId === tabId) return { ...pane, tabId: null };
|
||||
return pane;
|
||||
});
|
||||
return root === state.root ? state : { ...state, root };
|
||||
}
|
||||
|
||||
/** Empties whichever pane holds the tab, leaving the pane in place. */
|
||||
export function clearTab(state: SplitState, tabId: string): SplitState {
|
||||
const root = mapPanes(state.root, (pane) =>
|
||||
pane.tabId === tabId ? { ...pane, tabId: null } : pane,
|
||||
);
|
||||
return root === state.root ? state : { ...state, root };
|
||||
}
|
||||
|
||||
export function swapPanes(
|
||||
state: SplitState,
|
||||
firstId: string,
|
||||
secondId: string,
|
||||
): SplitState {
|
||||
const first = findPane(state, firstId);
|
||||
const second = findPane(state, secondId);
|
||||
if (!first || !second || firstId === secondId) return state;
|
||||
const root = mapPanes(state.root, (pane) => {
|
||||
if (pane.id === firstId) return { ...pane, tabId: second.tabId };
|
||||
if (pane.id === secondId) return { ...pane, tabId: first.tabId };
|
||||
return pane;
|
||||
});
|
||||
return { ...state, root };
|
||||
}
|
||||
|
||||
/**
|
||||
* Drops one pane onto another: the center swaps their contents, an edge
|
||||
* moves the dragged pane to that side of the target.
|
||||
*/
|
||||
export function movePane(
|
||||
state: SplitState,
|
||||
fromId: string,
|
||||
toId: string,
|
||||
target: DropTarget,
|
||||
): SplitState {
|
||||
if (fromId === toId) return state;
|
||||
const from = findPane(state, fromId);
|
||||
if (!from || !findPane(state, toId)) return state;
|
||||
if (target === "center") {
|
||||
return { ...swapPanes(state, fromId, toId), focusedPaneId: toId };
|
||||
}
|
||||
const removed = removePane(state, fromId);
|
||||
if (!removed) return state;
|
||||
const moved = splitPane(removed, toId, target, from.tabId);
|
||||
return moved ? moved.state : state;
|
||||
}
|
||||
|
||||
export function focusPane(state: SplitState, paneId: string): SplitState {
|
||||
if (state.focusedPaneId === paneId || !findPane(state, paneId)) return state;
|
||||
return {
|
||||
...state,
|
||||
focusedPaneId: paneId,
|
||||
// Moving focus out of a zoomed pane would leave the user looking at a
|
||||
// pane they are not typing into.
|
||||
zoomedPaneId: state.zoomedPaneId ? paneId : state.zoomedPaneId,
|
||||
};
|
||||
}
|
||||
|
||||
export function toggleZoom(state: SplitState, paneId: string): SplitState {
|
||||
if (!findPane(state, paneId)) return state;
|
||||
const zoomed = state.zoomedPaneId === paneId;
|
||||
return {
|
||||
...state,
|
||||
focusedPaneId: paneId,
|
||||
zoomedPaneId: zoomed ? null : paneId,
|
||||
};
|
||||
}
|
||||
|
||||
export function resizeSplit(
|
||||
state: SplitState,
|
||||
splitId: string,
|
||||
sizes: number[],
|
||||
): SplitState {
|
||||
const root = mapSplit(state.root, splitId, (split) =>
|
||||
sizes.length === split.children.length
|
||||
? { ...split, sizes: scaleTo100(sizes) }
|
||||
: split,
|
||||
);
|
||||
return root === state.root ? state : { ...state, root };
|
||||
}
|
||||
|
||||
export function equalizeSplit(state: SplitState, splitId: string): SplitState {
|
||||
const root = mapSplit(state.root, splitId, (split) => ({
|
||||
...split,
|
||||
sizes: evenSizes(split.children.length),
|
||||
}));
|
||||
return root === state.root ? state : { ...state, root };
|
||||
}
|
||||
|
||||
/**
|
||||
* Moves the divider between children `index` and `index + 1` by `delta`
|
||||
* percent, keeping both above MIN_PANE_SIZE.
|
||||
*/
|
||||
export function moveDivider(
|
||||
sizes: number[],
|
||||
index: number,
|
||||
delta: number,
|
||||
): number[] {
|
||||
const a = sizes[index];
|
||||
const b = sizes[index + 1];
|
||||
if (a === undefined || b === undefined) return sizes;
|
||||
const nextA = Math.max(
|
||||
MIN_PANE_SIZE,
|
||||
Math.min(a + b - MIN_PANE_SIZE, a + delta),
|
||||
);
|
||||
const next = [...sizes];
|
||||
next[index] = nextA;
|
||||
next[index + 1] = a + b - nextA;
|
||||
return next;
|
||||
}
|
||||
|
||||
/**
|
||||
* Repairs a state read from storage or left over after tabs closed: drops
|
||||
* tab ids that no longer exist, keeps each tab in one pane, fixes focus.
|
||||
*/
|
||||
export function normalizeState(
|
||||
state: SplitState,
|
||||
isLiveTab: (tabId: string) => boolean = () => true,
|
||||
): SplitState {
|
||||
const seen = new Set<string>();
|
||||
const root = normalizeNode(
|
||||
mapPanes(state.root, (pane) => {
|
||||
if (!pane.tabId) return pane;
|
||||
if (!isLiveTab(pane.tabId) || seen.has(pane.tabId)) {
|
||||
return { ...pane, tabId: null };
|
||||
}
|
||||
seen.add(pane.tabId);
|
||||
return pane;
|
||||
}),
|
||||
);
|
||||
const panes = listPanes(root);
|
||||
const focusedPaneId = panes.some((pane) => pane.id === state.focusedPaneId)
|
||||
? state.focusedPaneId
|
||||
: panes[0].id;
|
||||
const zoomedPaneId = panes.some((pane) => pane.id === state.zoomedPaneId)
|
||||
? (state.zoomedPaneId ?? null)
|
||||
: null;
|
||||
if (
|
||||
root === state.root &&
|
||||
focusedPaneId === state.focusedPaneId &&
|
||||
zoomedPaneId === (state.zoomedPaneId ?? null)
|
||||
) {
|
||||
return state;
|
||||
}
|
||||
return { root, focusedPaneId, zoomedPaneId };
|
||||
}
|
||||
|
||||
/** Rewrites every pane's tab id, e.g. live ids to saved instance ids and back. */
|
||||
export function mapTabIds(
|
||||
node: LayoutNode,
|
||||
fn: (tabId: string) => string | null,
|
||||
): LayoutNode {
|
||||
return mapPanes(node, (pane) =>
|
||||
pane.tabId ? { ...pane, tabId: fn(pane.tabId) } : pane,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The pane next to `paneId` in a direction, by on-screen position. Picks the
|
||||
* closest pane that overlaps along the other axis, then the one with the
|
||||
* most overlap, so it works for any tree shape.
|
||||
*/
|
||||
export function neighborPane(
|
||||
paneId: string,
|
||||
direction: NavDirection,
|
||||
rects: Record<string, PaneRect>,
|
||||
): string | null {
|
||||
const from = rects[paneId];
|
||||
if (!from) return null;
|
||||
let best: { id: string; distance: number; overlap: number } | null = null;
|
||||
for (const [id, rect] of Object.entries(rects)) {
|
||||
if (id === paneId) continue;
|
||||
let distance: number;
|
||||
let overlap: number;
|
||||
if (direction === "left" || direction === "right") {
|
||||
distance =
|
||||
direction === "right" ? rect.left - from.right : from.left - rect.right;
|
||||
overlap =
|
||||
Math.min(rect.bottom, from.bottom) - Math.max(rect.top, from.top);
|
||||
} else {
|
||||
distance =
|
||||
direction === "down" ? rect.top - from.bottom : from.top - rect.bottom;
|
||||
overlap =
|
||||
Math.min(rect.right, from.right) - Math.max(rect.left, from.left);
|
||||
}
|
||||
// A pixel of slack for the dividers and sub-pixel layout.
|
||||
if (distance < -2 || overlap <= 0) continue;
|
||||
if (
|
||||
!best ||
|
||||
distance < best.distance - 1 ||
|
||||
(Math.abs(distance - best.distance) <= 1 && overlap > best.overlap)
|
||||
) {
|
||||
best = { id, distance, overlap };
|
||||
}
|
||||
}
|
||||
return best?.id ?? null;
|
||||
}
|
||||
@@ -1,104 +0,0 @@
|
||||
import type { SplitMode, SplitTabConfig, Tab } from "@/types/ui-types";
|
||||
|
||||
const EMPTY_PANES = 6;
|
||||
|
||||
export type PersistedSplitTab = {
|
||||
instanceId: string;
|
||||
label: string;
|
||||
mode: Exclude<SplitMode, "none">;
|
||||
paneInstanceIds: (string | null)[];
|
||||
rowSizes: number[];
|
||||
rowColSizes: number[][];
|
||||
};
|
||||
|
||||
export function createSplitConfig(
|
||||
mode: Exclude<SplitMode, "none">,
|
||||
paneTabIds: (string | null)[],
|
||||
sizes: Pick<SplitTabConfig, "rowSizes" | "rowColSizes">,
|
||||
): SplitTabConfig {
|
||||
return {
|
||||
mode,
|
||||
paneTabIds: [...paneTabIds, ...Array(EMPTY_PANES).fill(null)].slice(
|
||||
0,
|
||||
EMPTY_PANES,
|
||||
),
|
||||
rowSizes: [...sizes.rowSizes],
|
||||
rowColSizes: sizes.rowColSizes.map((row) => [...row]),
|
||||
};
|
||||
}
|
||||
|
||||
export function assignTabsToSplit(
|
||||
tabs: Tab[],
|
||||
splitTabId: string,
|
||||
paneTabIds: (string | null)[],
|
||||
): Tab[] {
|
||||
const assigned = new Set(paneTabIds.filter((id): id is string => !!id));
|
||||
return tabs.map((tab) => {
|
||||
if (tab.id === splitTabId) return tab;
|
||||
if (assigned.has(tab.id)) return { ...tab, parentSplitTabId: splitTabId };
|
||||
if (tab.parentSplitTabId === splitTabId) {
|
||||
const { parentSplitTabId: _removed, ...released } = tab;
|
||||
return released;
|
||||
}
|
||||
return tab;
|
||||
});
|
||||
}
|
||||
|
||||
export function releaseSplitTabs(tabs: Tab[], splitTabId: string): Tab[] {
|
||||
return tabs
|
||||
.filter((tab) => tab.id !== splitTabId)
|
||||
.map((tab) => {
|
||||
if (tab.parentSplitTabId !== splitTabId) return tab;
|
||||
const { parentSplitTabId: _removed, ...released } = tab;
|
||||
return released;
|
||||
});
|
||||
}
|
||||
|
||||
export function serializeSplitTabs(tabs: Tab[]): PersistedSplitTab[] {
|
||||
const instanceIdById = new Map(tabs.map((tab) => [tab.id, tab.instanceId]));
|
||||
return tabs.flatMap((tab) => {
|
||||
if (tab.type !== "split-screen" || !tab.splitConfig) return [];
|
||||
return [
|
||||
{
|
||||
instanceId: tab.instanceId,
|
||||
label: tab.label,
|
||||
mode: tab.splitConfig.mode,
|
||||
paneInstanceIds: tab.splitConfig.paneTabIds.map((id) =>
|
||||
id ? (instanceIdById.get(id) ?? null) : null,
|
||||
),
|
||||
rowSizes: [...tab.splitConfig.rowSizes],
|
||||
rowColSizes: tab.splitConfig.rowColSizes.map((row) => [...row]),
|
||||
},
|
||||
];
|
||||
});
|
||||
}
|
||||
|
||||
export function restoreSplitTabs(
|
||||
persisted: PersistedSplitTab[],
|
||||
tabs: Tab[],
|
||||
openedAt = Date.now(),
|
||||
): Tab[] {
|
||||
const tabIdByInstanceId = new Map(
|
||||
tabs.map((tab) => [tab.instanceId, tab.id]),
|
||||
);
|
||||
let next = [...tabs];
|
||||
|
||||
for (const saved of persisted) {
|
||||
const id = `split-${saved.instanceId}`;
|
||||
const paneTabIds = saved.paneInstanceIds.map((instanceId) =>
|
||||
instanceId ? (tabIdByInstanceId.get(instanceId) ?? null) : null,
|
||||
);
|
||||
if (!paneTabIds.some(Boolean)) continue;
|
||||
const splitTab: Tab = {
|
||||
id,
|
||||
instanceId: saved.instanceId,
|
||||
type: "split-screen",
|
||||
label: saved.label,
|
||||
openedAt,
|
||||
splitConfig: createSplitConfig(saved.mode, paneTabIds, saved),
|
||||
};
|
||||
next = assignTabsToSplit([...next, splitTab], id, paneTabIds);
|
||||
}
|
||||
|
||||
return next;
|
||||
}
|
||||
@@ -42,6 +42,7 @@ export interface TabRenderProps {
|
||||
label: string;
|
||||
isVisible: boolean;
|
||||
isFocusedPane: boolean;
|
||||
inSplit?: boolean;
|
||||
handleRef: Ref<unknown>;
|
||||
shell: TabShellCallbacks;
|
||||
}
|
||||
|
||||
@@ -127,12 +127,14 @@ function RegisteredTab({
|
||||
tab,
|
||||
isVisible,
|
||||
isFocusedPane,
|
||||
inSplit,
|
||||
shell,
|
||||
}: {
|
||||
def: TabTypeDef;
|
||||
tab: Tab;
|
||||
isVisible: boolean;
|
||||
isFocusedPane: boolean;
|
||||
inSplit: boolean;
|
||||
shell: TabShellCallbacks;
|
||||
}) {
|
||||
const { host, label } = tab;
|
||||
@@ -157,6 +159,7 @@ function RegisteredTab({
|
||||
label={label}
|
||||
isVisible={isVisible}
|
||||
isFocusedPane={isFocusedPane}
|
||||
inSplit={inSplit}
|
||||
handleRef={tab.terminalRef as React.Ref<unknown>}
|
||||
shell={shell}
|
||||
/>
|
||||
@@ -181,11 +184,18 @@ export interface TabRenderContext {
|
||||
shell: TabShellCallbacks;
|
||||
isVisible?: boolean;
|
||||
isFocusedPane?: boolean;
|
||||
inSplit?: boolean;
|
||||
panelProps?: PromotedPanelProps;
|
||||
}
|
||||
|
||||
export function renderTabContent(tab: Tab, context: TabRenderContext) {
|
||||
const { shell, isVisible = true, isFocusedPane = true, panelProps } = context;
|
||||
const {
|
||||
shell,
|
||||
isVisible = true,
|
||||
isFocusedPane = true,
|
||||
inSplit = false,
|
||||
panelProps,
|
||||
} = context;
|
||||
|
||||
switch (tab.type) {
|
||||
case "dashboard":
|
||||
@@ -237,6 +247,7 @@ export function renderTabContent(tab: Tab, context: TabRenderContext) {
|
||||
tab={tab}
|
||||
isVisible={isVisible}
|
||||
isFocusedPane={isFocusedPane}
|
||||
inSplit={inSplit}
|
||||
shell={shell}
|
||||
/>
|
||||
);
|
||||
|
||||
Reference in new issue
Block a user