feat(split-screen): redesign split screen with free-form panes and pane picker

This commit is contained in:
LukeGus committed 2026-09-28 19:49:04 -05:00
1 parent 5f3372f9f6
commit 81e7aa6274
46 files changed
+5027 -2537

No files matched your search

+6
View File
@@ -226,7 +226,13 @@ export interface TabProps {
sshHost?: Record<string, unknown>;
label: string;
isVisible: boolean;
/** The pane the user is working in, or the active tab outside a split. */
isFocusedPane: boolean;
/**
* Shown in a split pane next to other tabs. The shell moves focus between
* panes itself, so a tab should not grab focus on its own while this is set.
*/
inSplit?: boolean;
/** Forward to a component exposing `refresh()` or `disconnect()`. */
handleRef: Ref<unknown>;
shell: ShellApi;
@@ -3088,6 +3088,7 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
"BracketLeft",
"Backslash",
"Minus",
"Enter",
];
if (globalCodes.includes(e.code)) {
e.stopPropagation();
@@ -54,6 +54,7 @@ export function TerminalTabContent({
label,
isVisible,
isFocusedPane,
inSplit = false,
handleRef,
shell,
}: TabProps) {
@@ -98,7 +99,7 @@ export function TerminalTabContent({
initialPath={tab.data?.initialPath ?? tab.initialFilePath}
title={label}
showTitle={false}
splitScreen={false}
splitScreen={inSplit}
onClose={() => shell.closeTab(tab.id)}
onTitleChange={
host.terminalConfig?.useSSHTitle
@@ -0,0 +1,67 @@
import { cleanup, render, waitFor } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
import type { ReactNode } from "react";
import type { TabProps } from "@termix/plugin-sdk/frontend";
const captured = vi.hoisted(() => ({ props: [] as Record<string, unknown>[] }));
vi.mock("../../../src/frontend/terminal/Terminal", () => ({
Terminal: (props: Record<string, unknown>) => {
captured.props.push(props);
return null;
},
}));
vi.mock(
"../../../src/frontend/terminal/command-history/CommandHistoryContext",
() => ({
CommandHistoryProvider: ({ children }: { children: ReactNode }) => (
<>{children}</>
),
}),
);
vi.mock("@termix/plugin-sdk/ui", () => ({
useIsMobile: () => false,
useTabsSafe: () => ({ previewTerminalTheme: null }),
}));
import { TerminalTabContent } from "../../../src/frontend/terminal/TerminalTabContent";
afterEach(() => {
cleanup();
captured.props = [];
});
function renderTab(inSplit?: boolean) {
const props = {
tab: { id: "t1", instanceId: "i1", type: "terminal", label: "web-01" },
host: { id: "1", name: "web-01", ip: "10.0.0.1", port: 22 },
sshHost: { id: 1 },
label: "web-01",
isVisible: true,
isFocusedPane: true,
inSplit,
handleRef: null,
shell: {
closeTab: vi.fn(),
renameTab: vi.fn(),
openTab: vi.fn(),
},
} as unknown as TabProps;
render(<TerminalTabContent {...props} />);
}
describe("TerminalTabContent in a split", () => {
it("tells the terminal it is in a split, so it does not grab focus", async () => {
renderTab(true);
await waitFor(() => expect(captured.props.length).toBeGreaterThan(0));
expect(captured.props.at(-1)?.splitScreen).toBe(true);
});
it("lets a terminal on its own take focus as before", async () => {
renderTab(undefined);
await waitFor(() => expect(captured.props.length).toBeGreaterThan(0));
expect(captured.props.at(-1)?.splitScreen).toBe(false);
});
});
+1 -1
View File
@@ -41,7 +41,7 @@ export function activate(app: TermixApp): void {
id: "workspaces",
icon: LayoutTemplate,
titleKey: "nav.workspaces",
after: "split-screen",
after: "macros",
permission: "use",
});
app.registerPanel("workspaces", Panel);
-98
View File
@@ -1,98 +0,0 @@
import { readFileSync } from "node:fs";
import { runInNewContext } from "node:vm";
import ts from "typescript";
import { expect, it, vi } from "vitest";
import {
assignTabsToSplit,
createSplitConfig,
} from "../src/ui/shell/splitTabUtils";
import type { Tab, SplitMode } from "../src/types/ui-types";
const source = readFileSync(
new URL("../src/ui/AppShell.tsx", import.meta.url),
"utf8",
);
const ast = ts.createSourceFile(
"AppShell.tsx",
source,
ts.ScriptTarget.Latest,
true,
ts.ScriptKind.TSX,
);
const functions: string[] = [];
function visit(node: ts.Node) {
if (
ts.isFunctionDeclaration(node) &&
["splitTabQuick", "selectSplitMode"].includes(node.name?.text ?? "")
)
functions.push(node.getText(ast));
ts.forEachChild(node, visit);
}
visit(ast);
const code = ts.transpileModule(functions.join("\n"), {
compilerOptions: { target: ts.ScriptTarget.ES2022 },
}).outputText;
function shell(crypto: object, activeTabId = "terminal-1") {
let tabs = [
{ id: "dashboard", type: "dashboard" },
{ id: "terminal-1", type: "terminal" },
{ id: "terminal-2", type: "terminal" },
] as Tab[];
const setActiveTabId = vi.fn();
const setSplitMode = vi.fn();
const context = {
tabs,
activeTabId,
crypto,
PANE_COUNTS: { "2-way": 2, "4-way": 4 },
defaultSizes: () => ({ rowSizes: [100], rowColSizes: [[50, 50]] }),
assignTabsToSplit,
createSplitConfig,
setTabs: (update: (tabs: Tab[]) => Tab[]) => {
tabs = update(tabs);
},
setActiveTabId,
setSplitMode,
setPaneTabIds: vi.fn(),
setRowSizes: vi.fn(),
setRowColSizes: vi.fn(),
changeSplitMode: vi.fn(),
doCloseTab: vi.fn(),
};
const select = runInNewContext(`${code}\nselectSplitMode`, context) as (
mode: SplitMode,
) => void;
return { select, getTabs: () => tabs, setActiveTabId, setSplitMode };
}
it.each(["terminal-1", "dashboard"])(
"selects a split layout without randomUUID from %s",
(active) => {
const state = shell({}, active);
state.select("2-way");
const tabs = state.getTabs();
const split = tabs.find((tab) => tab.type === "split-screen")!;
expect(split).toBeDefined();
expect(split.instanceId).toBeTruthy();
expect(split.splitConfig?.paneTabIds.slice(0, 2)).toEqual([
"terminal-1",
"terminal-2",
]);
expect(tabs.find((tab) => tab.id === "terminal-1")?.parentSplitTabId).toBe(
split.id,
);
expect(state.setActiveTabId).toHaveBeenCalledWith(split.id);
expect(state.setSplitMode).toHaveBeenCalledWith("2-way");
},
);
it("uses native UUIDs when available", () => {
const randomUUID = vi.fn(() => "native-id");
const state = shell({ randomUUID });
state.select("2-way");
expect(randomUUID).toHaveBeenCalledOnce();
expect(
state.getTabs().find((tab) => tab.type === "split-screen")?.instanceId,
).toBe("native-id");
});
-2
View File
@@ -453,8 +453,6 @@ export interface TerminalRefHandle {
openFileManager?: () => void;
}
export type SplitLayout = "2h" | "2v" | "3l" | "3r" | "3t" | "4grid";
// ============================================================================
// EXPRESS REQUEST TYPES
// ============================================================================
-1
View File
@@ -137,7 +137,6 @@ const CORE_HIDEABLE_RAIL_VIEWS = [
"quick-connect",
"ssh-tools",
"macros",
"split-screen",
];
const SIMPLE_HIDDEN_RAIL_TABS = CORE_HIDEABLE_RAIL_VIEWS.filter(
+48 -24
View File
@@ -185,8 +185,8 @@ export type Tab = {
initialPath?: string;
/** Payload owned by the tab's plugin, e.g. which fleet or endpoint it shows. */
data?: Record<string, unknown>;
/** Present only on a split-screen container tab. Pane ids reference live child tabs. */
splitConfig?: SplitTabConfig;
/** Present only on a split-screen container tab. Pane tab ids reference live child tabs. */
split?: SplitState;
/** Hides this session from the top-level tab bar while it belongs to a split tab. */
parentSplitTabId?: string;
terminalRef?: import("react").RefObject<{
@@ -252,23 +252,35 @@ export type UiFontId =
| "caskaydia-cove";
/** A tools panel view: core's own, or a rail panel a plugin registered. */
export type ToolsTab = "ssh-tools" | "macros" | "split-screen" | (string & {});
export type SplitMode =
| "none"
| "2-way"
| "2-way-horizontal"
| "3-way"
| "3-way-horizontal"
| "4-way"
| "5-way"
| "6-way";
export type ToolsTab = "ssh-tools" | "macros" | (string & {});
export type SplitTabConfig = {
mode: Exclude<SplitMode, "none">;
paneTabIds: (string | null)[];
rowSizes: number[];
rowColSizes: number[][];
};
/** "row" lays children side by side, "column" stacks them. */
export type SplitDirection = "row" | "column";
export interface PaneNode {
kind: "pane";
id: string;
tabId: string | null;
}
export interface SplitNode {
kind: "split";
id: string;
direction: SplitDirection;
children: LayoutNode[];
/** Percent of the parent per child, summing to 100. */
sizes: number[];
}
export type LayoutNode = PaneNode | SplitNode;
/** The layout of one split tab. */
export interface SplitState {
root: LayoutNode;
focusedPaneId: string;
/** A pane shown alone, filling the split, until unzoomed. */
zoomedPaneId?: string | null;
}
export type WorkspaceTabSnapshot = {
/** Stable key within the saved tab list, not the live Tab.id (which is regenerated on every open). */
@@ -295,15 +307,27 @@ type WorkspaceDockState = {
width: number;
};
/** One split tab in a saved layout, its pane tab ids swapped for slotIds. */
export type WorkspaceSplitSnapshot = {
slotId: string;
label: string;
root: LayoutNode;
focusedPaneId: string;
};
export type WorkspacePayload = {
version: 1;
version: 1 | 2;
tabs: WorkspaceTabSnapshot[];
/** A tab's slotId, or a split's. */
activeSlotId: string | null;
splitMode: SplitMode;
/** Indexed identically to AppShell's paneTabIds (length 6), holding slotId instead of a live Tab.id. */
paneTabIds: (string | null)[];
rowSizes: number[];
rowColSizes: number[][];
/** Every split tab. Version 2 and later. */
splits?: WorkspaceSplitSnapshot[];
/** Version 1: the one split, as a fixed mode. */
splitMode?: string;
/** Version 1: indexed by the mode's pane order, holding slotIds. */
paneTabIds?: (string | null)[];
rowSizes?: number[];
rowColSizes?: number[][];
/** Sidebar arrangement, so a workspace restores the whole layout and not just tabs. */
sidebar?: {
left: WorkspaceDockState;
+880 -512
View File
File diff suppressed because it is too large. Load diff
+7
View File
@@ -0,0 +1,7 @@
/** A random id, with a fallback for insecure contexts that lack randomUUID. */
export function createId(): 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)}`;
}
-10
View File
@@ -1,10 +0,0 @@
// Module-level flag: true while a split pane divider is being dragged.
// TerminalTab reads this to suppress fit() calls during drag.
export const splitDragState = { active: false };
// Callbacks registered by terminal instances to trigger a fit after drag ends.
const fitCallbacks = new Set<() => void>();
export function notifyDragEnd() {
for (const fn of fitCallbacks) fn();
}
-23
View File
@@ -2,7 +2,6 @@ import type {
DashboardCardConfig,
FontSizeId,
UiFontId,
SplitMode,
} from "@/types/ui-types";
export const DASHBOARD_CARDS: DashboardCardConfig[] = [
@@ -134,25 +133,3 @@ export const FOLDER_COLORS = [
"#ec4899",
"#6b7280",
];
export const SPLIT_MODES: { id: SplitMode; label: string }[] = [
{ id: "none", label: "None" },
{ id: "2-way", label: "2-Way" },
{ id: "2-way-horizontal", label: "2-Way (H)" },
{ id: "3-way", label: "3-Way (V)" },
{ id: "3-way-horizontal", label: "3-Way (H)" },
{ id: "4-way", label: "4-Way" },
{ id: "5-way", label: "5-Way" },
{ id: "6-way", label: "6-Way" },
];
export const PANE_COUNTS: Record<SplitMode, number> = {
none: 0,
"2-way": 2,
"2-way-horizontal": 2,
"3-way": 3,
"3-way-horizontal": 3,
"4-way": 4,
"5-way": 5,
"6-way": 6,
};
+51 -37
View File
@@ -217,7 +217,7 @@
"workflow_palette": "Command palette",
"workflow_palette_desc": "Jump to any server or action by typing. Tap Shift twice to open it.",
"workflow_split": "Split screen",
"workflow_split_desc": "Show up to four tabs side by side to watch logs while you work.",
"workflow_split_desc": "Show several tabs side by side, for example to watch logs while you work.",
"workflow_dock": "Side panel",
"workflow_dock_desc": "Middle click or right click a sidebar icon to open it as a tab or pin it to the right.",
"securityTitle": "Keeping your logins safe",
@@ -266,12 +266,10 @@
"serverStats": "Host Metrics",
"admin": "Admin",
"userProfile": "User Profile",
"splitScreen": "Split Screen",
"confirmClose": "Close this active session?",
"close": "Close",
"cancel": "Cancel",
"sshManager": "SSH Manager",
"cannotSplitTab": "Cannot split this tab",
"hostTabTitle": "{{username}}@{{ip}}:{{port}}",
"copyPassword": "Copy Password",
"copySudoPassword": "Copy Sudo Password",
@@ -1035,9 +1033,55 @@
"pluginActions": "More actions"
},
"splitScreen": {
"paneEmpty": "Pane {{index}} - empty",
"noTabAssigned": "No tab assigned",
"focusedPane": "Active pane"
"title": "Split screen",
"defaultLabel": "Split {{number}}",
"splitRight": "Split right",
"splitDown": "Split down",
"addToSplit": "Add to {{split}}",
"paneEmptyItem": "Pane {{index}}: empty",
"paneItem": "Pane {{index}}: {{label}}",
"paneNumber": "Pane {{index}}",
"newPaneRight": "New pane on the right",
"newPaneBelow": "New pane below",
"emptyTitle": "Empty pane",
"emptyHint": "Pick something to show here. While this pane is selected, anything you open from the sidebar also opens here.",
"openTabs": "Open tabs",
"noOpenTabs": "No other tabs are open",
"hosts": "Hosts",
"searchHosts": "Search hosts",
"noHosts": "No hosts match",
"newSession": "New",
"closePane": "Close pane",
"closeSession": "Close session",
"moveToTab": "Move to its own tab",
"swapWith": "Swap with",
"showHere": "Show in this pane",
"clearPane": "Empty this pane",
"zoom": "Zoom pane",
"unzoom": "Restore layout",
"unsplit": "Unsplit",
"layouts": "Layouts",
"applyLayout": "Change layout",
"newSplit": "New split",
"maxPanes": "A split can hold up to {{count}} panes",
"dropSplit": "Drop to split",
"dropHere": "Drop to show here",
"paneActions": "Pane actions",
"dividerHint": "Drag to resize. Double-click to even out.",
"openInSplit": "Open in split",
"openInNewSplit": "New split with the current tab",
"shortcuts": "Shortcuts",
"shortcutNavigate": "Move between panes",
"presets": {
"columns2": "Two side by side",
"rows2": "Two stacked",
"columns3": "Three side by side",
"mainLeft3": "One left, two right",
"mainBottom3": "Two top, one bottom",
"grid4": "Grid of four",
"grid5": "Three over two",
"grid6": "Grid of six"
}
},
"connections": {
"noConnections": "No connections",
@@ -1101,12 +1145,7 @@
"connectionLogEmpty": "No connection logs yet",
"connectionLogWaiting": "Waiting for connection logs...",
"connectionLogCopied": "Connection logs copied to clipboard",
"connectionLogCopyFailed": "Failed to copy logs to clipboard",
"split": {
"splitTab": "Split Tab",
"addToSplit": "Add to Split",
"removeFromSplit": "Remove from Split"
}
"connectionLogCopyFailed": "Failed to copy logs to clipboard"
},
"transfer": {
"copyToHost": "Copy to host…",
@@ -1807,31 +1846,6 @@
"enableRightClickCopyPaste": "Enable right-click copy/paste",
"copyOnSelect": "Copy selected text automatically"
},
"splitScreen": {
"layoutTitle": "Layout",
"selectLayoutAbove": "Select a layout above",
"selectLayoutHint": "Choose how many panes to display",
"panesTitle": "Panes",
"openTabsTitle": "Open Tabs",
"dragTabsHint": "Drag tabs into panes above, or use Quick Assign",
"dropHere": "Drop here",
"emptyPane": "Empty",
"dashboard": "Dashboard",
"clearSplitScreen": "Clear Split Screen",
"quickAssign": "Quick Assign",
"alreadyAssigned": "Pane {{index}}",
"splitTab": "Split Tab",
"addToSplit": "Add to Split",
"removeFromSplit": "Remove from Split",
"assignToPane": "Assign to pane",
"hotkeysTitle": "Keyboard Shortcuts",
"hotkeysSplitRight": "Toggle 2-way split",
"hotkeysSplitBelow": "Toggle 3-way split",
"hotkeysNavigatePane": "Navigate panes",
"hotkeysNextTab": "Next tab",
"hotkeysPrevTab": "Previous tab",
"hotkeysJumpTab": "Jump to tab"
},
"snippets": {
"title": "Snippets",
"createSnippetTitle": "Create Snippet",
+2
View File
@@ -253,6 +253,7 @@ export async function renderPlugin(
label={type}
isVisible
isFocusedPane
inSplit={false}
handleRef={null}
shell={shell}
{...props}
@@ -276,6 +277,7 @@ export async function renderPlugin(
label={tab.label}
isVisible
isFocusedPane
inSplit={false}
handleRef={null}
shell={shell}
/>,
-7
View File
@@ -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>
);
})}
-826
View File
@@ -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
View File
@@ -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" />
+64 -30
View File
@@ -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 [];
}
+262
View File
@@ -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>
);
}
+100
View File
@@ -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,
);
}
+138
View File
@@ -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>
);
}
+606
View File
@@ -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>
);
}
+136
View File
@@ -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 };
}
}
+162
View File
@@ -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,
});
}
+347
View File
@@ -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;
}
+65
View File
@@ -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,
);
}
+571
View File
@@ -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;
}
-104
View File
@@ -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;
}
+1
View File
@@ -42,6 +42,7 @@ export interface TabRenderProps {
label: string;
isVisible: boolean;
isFocusedPane: boolean;
inSplit?: boolean;
handleRef: Ref<unknown>;
shell: TabShellCallbacks;
}
+12 -1
View File
@@ -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}
/>
);
+1 -10
View File
@@ -10,7 +10,7 @@ import {
SquareArrowOutUpRight,
User,
} from "lucide-react";
import type { SplitMode, TabType, ToolsTab } from "@/types/ui-types";
import type { TabType, ToolsTab } from "@/types/ui-types";
import { Skeleton } from "@/components/skeleton";
import { readRailPreference, setRailPreference } from "./rail-preferences";
import { useRailItems, type RailItemDef } from "./rail-items";
@@ -35,7 +35,6 @@ type RailItem =
view: RailView;
icon: React.ReactNode;
title: string;
dot?: boolean;
promotable?: boolean;
rightDockable?: boolean;
useBadge?: () => number | null | undefined;
@@ -51,7 +50,6 @@ type RailItem =
function buildRailButtons(
items: RailItemDef[],
splitMode: SplitMode,
t: (key: string) => string,
hidden: Set<string>,
): RailItem[] {
@@ -71,7 +69,6 @@ function buildRailButtons(
view: item.id as RailView,
icon: <Icon size={16} />,
title: t(item.labelKey),
dot: item.id === "split-screen" ? splitMode !== "none" : undefined,
promotable: item.promotable,
rightDockable: item.rightDockable,
useBadge: item.useBadge,
@@ -108,7 +105,6 @@ const btnStyle = { margin: `0 ${rem(4)}`, padding: `0 ${rem(8)}` };
export function AppRail({
railView,
sidebarOpen,
splitMode,
username,
isAdmin,
pluginsSettled = true,
@@ -119,7 +115,6 @@ export function AppRail({
}: {
railView: RailView;
sidebarOpen: boolean;
splitMode: SplitMode;
username: string;
isAdmin: boolean;
/** False while plugins are still registering their rail items. */
@@ -215,7 +210,6 @@ export function AppRail({
const railExpanded = pinned || (expandOnHover && hovered);
const railButtons = buildRailButtons(
railItems.filter((item) => item.placement !== "footer"),
splitMode,
t,
hiddenTabs,
);
@@ -363,9 +357,6 @@ export function AppRail({
>
{item.title}
</span>
{item.dot && (
<span className="absolute top-0.5 right-0.5 size-1.5 rounded-full bg-accent-brand" />
)}
</button>
),
)}
-414
View File
@@ -1,414 +0,0 @@
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { Button } from "@/components/button";
import { Separator } from "@/components/separator";
import { LayoutPanelLeft, X, ChevronDown } from "lucide-react";
import { PANE_COUNTS, SPLIT_MODES } from "@/lib/theme";
import { tabJumpHotkeyKeys } from "@/lib/tab-jump-hotkey";
import { tabIcon } from "@/shell/tabUtils";
import type { Tab, SplitMode } from "@/types/ui-types";
const LAYOUT_PREVIEWS: Record<SplitMode, React.ReactNode> = {
none: <div className="size-full border-2 border-current" />,
"2-way": (
<div className="flex gap-0.5 size-full">
<div className="flex-1 border-2 border-current" />
<div className="flex-1 border-2 border-current" />
</div>
),
"2-way-horizontal": (
<div className="flex flex-col gap-0.5 size-full">
<div className="flex-1 border-2 border-current" />
<div className="flex-1 border-2 border-current" />
</div>
),
"3-way": (
<div className="flex gap-0.5 size-full">
<div className="flex-1 border-2 border-current" />
<div className="flex flex-col flex-1 gap-0.5">
<div className="flex-1 border-2 border-current" />
<div className="flex-1 border-2 border-current" />
</div>
</div>
),
"3-way-horizontal": (
<div className="flex flex-col gap-0.5 size-full">
<div className="flex gap-0.5 flex-1">
<div className="flex-1 border-2 border-current" />
<div className="flex-1 border-2 border-current" />
</div>
<div className="flex-1 border-2 border-current" />
</div>
),
"4-way": (
<div className="grid grid-cols-2 grid-rows-2 gap-0.5 size-full">
<div className="border-2 border-current" />
<div className="border-2 border-current" />
<div className="border-2 border-current" />
<div className="border-2 border-current" />
</div>
),
"5-way": (
<div className="flex flex-col gap-0.5 size-full">
<div className="flex gap-0.5 flex-1">
<div className="flex-1 border-2 border-current" />
<div className="flex-1 border-2 border-current" />
<div className="flex-1 border-2 border-current" />
</div>
<div className="flex gap-0.5 flex-1">
<div className="flex-1 border-2 border-current" />
<div className="flex-[2] border-2 border-current" />
</div>
</div>
),
"6-way": (
<div className="grid grid-cols-3 grid-rows-2 gap-0.5 size-full">
<div className="border-2 border-current" />
<div className="border-2 border-current" />
<div className="border-2 border-current" />
<div className="border-2 border-current" />
<div className="border-2 border-current" />
<div className="border-2 border-current" />
</div>
),
};
export function SplitScreenPanel({
tabs,
splitMode,
setSplitMode,
paneTabIds,
setPaneTabIds,
onAssignPane,
}: {
tabs: Tab[];
splitMode: SplitMode;
setSplitMode: (m: SplitMode) => void;
paneTabIds: (string | null)[];
setPaneTabIds: (ids: (string | null)[]) => void;
onAssignPane: (paneIndex: number, tabId: string) => void;
}) {
const { t } = useTranslation();
const paneCount = PANE_COUNTS[splitMode];
const [draggingTabId, setDraggingTabId] = useState<string | null>(null);
const [dragOverPane, setDragOverPane] = useState<number | null>(null);
const [quickAssignPane, setQuickAssignPane] = useState<number | null>(null);
function handleDrop(paneIndex: number) {
if (draggingTabId === null) return;
onAssignPane(paneIndex, draggingTabId);
setDraggingTabId(null);
setDragOverPane(null);
}
function clearPane(paneIndex: number) {
const next = [...paneTabIds];
next[paneIndex] = null;
setPaneTabIds(next);
}
function resetAll() {
setSplitMode("none");
setPaneTabIds(Array(6).fill(null));
}
const activeCount = paneTabIds
.slice(0, Math.max(paneCount, 0))
.filter(Boolean).length;
return (
<div className="flex flex-col gap-3 p-3">
<div className="flex flex-col gap-2">
<div className="flex items-center justify-between">
<span className="text-xs font-bold uppercase tracking-widest text-muted-foreground">
{t("newUi.sidebar.splitScreen.layoutTitle")}
</span>
{splitMode !== "none" && (
<span className="text-xs border border-accent-brand/40 text-accent-brand px-1.5 py-0.5 leading-tight">
{splitMode}
</span>
)}
</div>
<div className="grid grid-cols-3 gap-2">
{SPLIT_MODES.map((mode) => (
<button
key={mode.id}
onClick={() => setSplitMode(mode.id)}
className={`flex flex-col items-center gap-1.5 p-2 border transition-colors ${
splitMode === mode.id
? "border-accent-brand/40 bg-accent-brand/10 text-accent-brand"
: "border-border text-muted-foreground hover:text-foreground hover:border-muted-foreground/50"
}`}
>
<div
className={`w-10 h-7 ${splitMode === mode.id ? "text-accent-brand" : "text-muted-foreground/40"}`}
>
{LAYOUT_PREVIEWS[mode.id]}
</div>
<span className="text-xs font-semibold">{mode.label}</span>
</button>
))}
</div>
</div>
{splitMode === "none" ? (
<div className="flex flex-col items-center justify-center gap-2 py-6 text-center border border-dashed border-border">
<LayoutPanelLeft className="size-8 text-muted-foreground/30" />
<span className="text-sm text-muted-foreground">
{t("newUi.sidebar.splitScreen.selectLayoutAbove")}
</span>
<span className="text-xs text-muted-foreground/60">
{t("newUi.sidebar.splitScreen.selectLayoutHint")}
</span>
</div>
) : (
<>
<Separator />
<div className="flex flex-col gap-2">
<div className="flex items-center justify-between">
<span className="text-xs font-bold uppercase tracking-widest text-muted-foreground">
{t("newUi.sidebar.splitScreen.panesTitle")}
</span>
<span className="text-xs text-muted-foreground">
{activeCount}/{paneCount} assigned
</span>
</div>
<div className="grid grid-cols-2 gap-1.5">
{Array.from({ length: paneCount }).map((_, i) => {
const assignedId = paneTabIds[i];
const assignedTab = assignedId
? tabs.find((t) => t.id === assignedId)
: null;
const isOver = dragOverPane === i;
const isQuickAssignOpen = quickAssignPane === i;
return (
<div key={i} className="flex flex-col gap-0.5">
<div
onDragOver={(e) => {
e.preventDefault();
setDragOverPane(i);
}}
onDragLeave={() => setDragOverPane(null)}
onDrop={() => handleDrop(i)}
className={`relative flex flex-col items-center justify-center gap-1 p-2 min-h-[52px] border transition-colors ${
isOver
? "border-accent-brand bg-accent-brand/10"
: assignedTab
? "border-accent-brand/30 bg-accent-brand/5"
: "border-dashed border-border/60 bg-muted/5 hover:border-border hover:bg-muted/10"
}`}
>
<span className="absolute top-1 left-1.5 text-[10px] text-muted-foreground/40 font-mono leading-none">
{i + 1}
</span>
{assignedTab ? (
<>
<div className="flex items-center gap-1 px-1 w-full justify-center">
<span className="text-accent-brand shrink-0">
{tabIcon(assignedTab.type)}
</span>
<span className="text-xs font-semibold truncate max-w-[70px] text-foreground">
{assignedTab.type === "dashboard"
? t("newUi.sidebar.splitScreen.dashboard")
: assignedTab.label}
</span>
</div>
<button
onClick={() => clearPane(i)}
className="absolute top-0.5 right-0.5 size-4 flex items-center justify-center text-muted-foreground hover:text-foreground"
>
<X className="size-2.5" />
</button>
</>
) : (
<span className="text-xs text-muted-foreground/40">
{isOver
? t("newUi.sidebar.splitScreen.dropHere")
: t("newUi.sidebar.splitScreen.emptyPane")}
</span>
)}
</div>
{/* Quick assign button */}
{!assignedTab && (
<div className="relative">
<button
onClick={() =>
setQuickAssignPane(isQuickAssignOpen ? null : i)
}
className="flex items-center justify-center gap-1 w-full py-0.5 text-[10px] text-muted-foreground/60 hover:text-accent-brand hover:bg-accent-brand/5 border border-dashed border-border/40 hover:border-accent-brand/30 transition-colors"
>
{t("newUi.sidebar.splitScreen.quickAssign")}
<ChevronDown
className={`size-2.5 transition-transform ${isQuickAssignOpen ? "rotate-180" : ""}`}
/>
</button>
{isQuickAssignOpen && (
<div className="absolute top-full left-0 right-0 z-20 bg-popover border border-border shadow-md mt-0.5 max-h-40 overflow-y-auto">
{tabs
.filter(
(tab) =>
tab.type !== "dashboard" &&
!paneTabIds.includes(tab.id),
)
.map((tab) => (
<button
key={tab.id}
onClick={() => {
onAssignPane(i, tab.id);
setQuickAssignPane(null);
}}
className="flex items-center gap-2 w-full px-2 py-1.5 text-xs text-left hover:bg-accent hover:text-accent-foreground"
>
<span className="text-muted-foreground shrink-0">
{tabIcon(tab.type)}
</span>
<span className="truncate">{tab.label}</span>
</button>
))}
{tabs.filter(
(tab) =>
tab.type !== "dashboard" &&
!paneTabIds.includes(tab.id),
).length === 0 && (
<div className="px-2 py-2 text-xs text-muted-foreground/60">
All tabs assigned
</div>
)}
</div>
)}
</div>
)}
</div>
);
})}
</div>
</div>
<Separator />
<div className="flex flex-col gap-2">
<span className="text-xs font-bold uppercase tracking-widest text-muted-foreground">
{t("newUi.sidebar.splitScreen.openTabsTitle")}
</span>
<span className="text-xs text-muted-foreground/60">
{t("newUi.sidebar.splitScreen.dragTabsHint")}
</span>
<div className="flex flex-col gap-1 mt-0.5">
{tabs.map((tab) => {
const paneIdx = paneTabIds.indexOf(tab.id);
const isAssigned = paneIdx !== -1;
return (
<div
key={tab.id}
draggable={!isAssigned}
onDragStart={() => !isAssigned && setDraggingTabId(tab.id)}
onDragEnd={() => {
setDraggingTabId(null);
setDragOverPane(null);
}}
className={`flex items-center gap-2 px-2.5 py-2 border select-none transition-colors ${
isAssigned
? "border-accent-brand/20 bg-accent-brand/5 opacity-60 cursor-default"
: draggingTabId === tab.id
? "border-accent-brand/40 bg-accent-brand/10 text-accent-brand cursor-grabbing"
: "border-border hover:border-muted-foreground/40 hover:bg-muted/30 cursor-grab active:cursor-grabbing"
}`}
>
<span className="text-muted-foreground shrink-0">
{tabIcon(tab.type)}
</span>
<span className="text-xs font-medium flex-1 truncate">
{tab.type === "dashboard"
? t("newUi.sidebar.splitScreen.dashboard")
: tab.label}
</span>
{isAssigned ? (
<span className="text-[10px] text-accent-brand/60 font-mono shrink-0">
{t("newUi.sidebar.splitScreen.alreadyAssigned", {
index: paneIdx + 1,
})}
</span>
) : (
<div className="grid grid-cols-2 gap-px opacity-30 shrink-0">
<div className="size-1 bg-muted-foreground rounded-full" />
<div className="size-1 bg-muted-foreground rounded-full" />
<div className="size-1 bg-muted-foreground rounded-full" />
<div className="size-1 bg-muted-foreground rounded-full" />
</div>
)}
</div>
);
})}
</div>
</div>
<Button
variant="outline"
size="sm"
className="w-full text-xs text-muted-foreground"
onClick={resetAll}
>
<X className="size-3" />
{t("newUi.sidebar.splitScreen.clearSplitScreen")}
</Button>
</>
)}
<Separator />
<div className="flex flex-col gap-2">
<span className="text-xs font-bold uppercase tracking-widest text-muted-foreground">
{t("newUi.sidebar.splitScreen.hotkeysTitle")}
</span>
<div className="flex flex-col gap-1">
{[
{
keys: ["Ctrl", "Shift", "\\"],
label: t("newUi.sidebar.splitScreen.hotkeysSplitRight"),
},
{
keys: ["Ctrl", "Shift", "-"],
label: t("newUi.sidebar.splitScreen.hotkeysSplitBelow"),
},
{
keys: ["Alt", "↑↓←→"],
label: t("newUi.sidebar.splitScreen.hotkeysNavigatePane"),
},
{
keys: ["Ctrl", "Shift", "]"],
label: t("newUi.sidebar.splitScreen.hotkeysNextTab"),
},
{
keys: ["Ctrl", "Shift", "["],
label: t("newUi.sidebar.splitScreen.hotkeysPrevTab"),
},
{
keys: tabJumpHotkeyKeys(),
label: t("newUi.sidebar.splitScreen.hotkeysJumpTab"),
},
].map(({ keys, label }) => (
<div
key={label}
className="flex items-center justify-between gap-2"
>
<span className="text-xs text-muted-foreground/70 flex-1 min-w-0 truncate">
{label}
</span>
<div className="flex items-center gap-0.5 shrink-0">
{keys.map((k, i) => (
<kbd
key={i}
className="inline-flex items-center px-1 py-0.5 text-[10px] font-mono bg-muted border border-border/60 text-muted-foreground leading-none"
>
{k}
</kbd>
))}
</div>
</div>
))}
</div>
</div>
</div>
);
}
-7
View File
@@ -3,7 +3,6 @@ import {
Cloud,
Hammer,
KeyRound,
LayoutPanelLeft,
Plug,
Server,
Settings,
@@ -112,12 +111,6 @@ export const RAIL_ITEMS: RailItemDef[] = [
promotable: true,
rightDockable: true,
},
{
id: "split-screen",
icon: LayoutPanelLeft,
labelKey: "nav.splitScreen",
separatorAfter: true,
},
{
id: "sync",
icon: Cloud,
+91
View File
@@ -13,6 +13,7 @@ import {
GripVertical,
Key,
KeyRound,
LayoutPanelLeft,
Link,
MemoryStick,
MoreHorizontal,
@@ -28,6 +29,7 @@ import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuSub,
DropdownMenuSubContent,
@@ -87,6 +89,12 @@ import {
} from "@/sidebar/host-contributions";
import { shell } from "@/plugin-host/shell-bridge";
import type { TabShellCallbacks } from "@/shell/tab-registry";
import {
openInSplit,
useSplitTargets,
type SplitOpenTarget,
} from "@/shell/split/split-targets";
import { MAX_PANES } from "@/shell/split/split-tree";
export function statusCheckEnabled(host: Host): boolean {
return host.statusCheckEnabled !== false;
@@ -276,6 +284,7 @@ export function HostItem({
const pluginActions = hostActionsFor(allHostActions, host);
const badges = hostBadgesFor(useHostBadges(), host);
const pluginMenuItems = hostMenuItemsFor(useHostContextMenuItems(), host);
const splitTargets = useSplitTargets();
const statusScheme = useStatusColorScheme();
const { initialLoadComplete } = useServerStatusMeta();
const statusCheckOn = statusCheckEnabled(host);
@@ -647,6 +656,88 @@ export function HostItem({
{t("hosts.openInNewTab")}
</DropdownMenuItem>
)}
{defaultAction &&
(splitTargets.canStartSplit || splitTargets.splits.length > 0) && (
<DropdownMenuSub>
<DropdownMenuSubTrigger>
<LayoutPanelLeft className="size-3.5 mr-2" />
{t("splitScreen.openInSplit")}
</DropdownMenuSubTrigger>
<DropdownMenuSubContent className="max-w-72">
{(() => {
const openAt = (target: SplitOpenTarget) =>
openInSplit(target, () =>
openHostTab(defaultAction, { forceNewTab: true }),
);
return (
<>
{splitTargets.canStartSplit && (
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
openAt({ kind: "newSplit" });
}}
>
{t("splitScreen.openInNewSplit")}
</DropdownMenuItem>
)}
{splitTargets.splits.map((split) => (
<div key={split.id}>
<DropdownMenuSeparator />
<DropdownMenuLabel className="truncate text-[10px] font-semibold uppercase tracking-wider text-muted-foreground">
{split.label}
</DropdownMenuLabel>
{split.panes.map((pane) => (
<DropdownMenuItem
key={pane.id}
onClick={(e) => {
e.stopPropagation();
openAt({
kind: "pane",
splitTabId: split.id,
paneId: pane.id,
});
}}
>
<span className="truncate">
{pane.label
? t("splitScreen.paneItem", {
index: pane.index,
label: pane.label,
})
: t("splitScreen.paneEmptyItem", {
index: pane.index,
})}
</span>
</DropdownMenuItem>
))}
<DropdownMenuItem
disabled={split.full}
title={
split.full
? t("splitScreen.maxPanes", {
count: MAX_PANES,
})
: undefined
}
onClick={(e) => {
e.stopPropagation();
openAt({
kind: "newPane",
splitTabId: split.id,
});
}}
>
{t("splitScreen.newPaneRight")}
</DropdownMenuItem>
</div>
))}
</>
);
})()}
</DropdownMenuSubContent>
</DropdownMenuSub>
)}
<DropdownMenuSeparator />
{onEditHost && (
<DropdownMenuItem
+19
View File
@@ -0,0 +1,19 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { createId } from "@/lib/create-id";
afterEach(() => vi.unstubAllGlobals());
describe("createId", () => {
it("uses randomUUID when available", () => {
vi.stubGlobal("crypto", { randomUUID: () => "uuid-1" });
expect(createId()).toBe("uuid-1");
});
it("falls back without randomUUID, e.g. over plain http", () => {
vi.stubGlobal("crypto", {});
const first = createId();
const second = createId();
expect(first).toBeTruthy();
expect(second).not.toBe(first);
});
});
-148
View File
@@ -1,148 +0,0 @@
import { describe, it, expect, vi, afterEach } from "vitest";
import { render, screen, cleanup, fireEvent } from "@testing-library/react";
import type { Tab } from "@/types/ui-types";
vi.mock("react-i18next", () => ({
useTranslation: () => ({
t: (key: string, opts?: Record<string, unknown>) =>
opts ? `${key}:${JSON.stringify(opts)}` : key,
}),
}));
import { SplitView, defaultSizes } from "../../shell/SplitView";
function makeTab(overrides: Partial<Tab> = {}): Tab {
return {
id: "tab-1",
instanceId: "instance-1",
type: "terminal",
label: "web-01",
openedAt: 0,
...overrides,
} as Tab;
}
afterEach(() => {
cleanup();
});
describe("defaultSizes", () => {
it("returns the expected default split for 2-way", () => {
expect(defaultSizes("2-way")).toEqual({
rowSizes: [100],
rowColSizes: [[50, 50]],
});
});
it("returns stacked defaults for a horizontal 2-way layout", () => {
expect(defaultSizes("2-way-horizontal")).toEqual({
rowSizes: [50, 50],
rowColSizes: [[100], [100]],
});
});
it("returns a single full pane for 'none'", () => {
expect(defaultSizes("none")).toEqual({
rowSizes: [100],
rowColSizes: [[100]],
});
});
});
describe("SplitView - controlled rowSizes/rowColSizes", () => {
it("renders panes using externally-supplied non-default sizes, not the built-in default", () => {
const tabs = [makeTab({ id: "t1" }), makeTab({ id: "t2", label: "t2" })];
const { container } = render(
<SplitView
tabs={tabs}
paneTabIds={["t1", "t2", null, null, null, null]}
splitMode="2-way"
rowSizes={[100]}
rowColSizes={[[30, 70]]}
onRowSizesChange={() => {}}
onRowColSizesChange={() => {}}
/>,
);
// The first pane's flex column width should reflect the supplied 30%,
// not the 50/50 default - this is the regression guard for the
// rowSizes/rowColSizes lift-up out of SplitView's old internal state.
const widthStyled = container.querySelectorAll('[style*="width: 30%"]');
expect(widthStyled.length).toBeGreaterThan(0);
});
it("calls onReset (not an internal reset) when the Reset button is clicked", () => {
const onReset = vi.fn();
const tabs = [makeTab({ id: "t1" })];
render(
<SplitView
tabs={tabs}
paneTabIds={["t1", null, null, null, null, null]}
splitMode="2-way"
rowSizes={[100]}
rowColSizes={[[30, 70]]}
onRowSizesChange={() => {}}
onRowColSizesChange={() => {}}
onReset={onReset}
/>,
);
fireEvent.click(screen.getByTitle("Reset to equal split"));
expect(onReset).toHaveBeenCalledTimes(1);
});
it("releases a session from its pane from the pane header", () => {
const onAssignPane = vi.fn();
render(
<SplitView
tabs={[makeTab({ id: "t1" })]}
paneTabIds={["t1", null, null, null, null, null]}
splitMode="2-way"
rowSizes={[100]}
rowColSizes={[[50, 50]]}
onRowSizesChange={() => {}}
onRowColSizesChange={() => {}}
onAssignPane={onAssignPane}
/>,
);
fireEvent.click(screen.getByLabelText("terminal.split.removeFromSplit"));
expect(onAssignPane).toHaveBeenCalledWith(0, "");
});
it("does not mutate rowSizes/rowColSizes props locally - reflects prop changes on rerender", () => {
const tabs = [makeTab({ id: "t1" }), makeTab({ id: "t2", label: "t2" })];
const { container, rerender } = render(
<SplitView
tabs={tabs}
paneTabIds={["t1", "t2", null, null, null, null]}
splitMode="2-way"
rowSizes={[100]}
rowColSizes={[[50, 50]]}
onRowSizesChange={() => {}}
onRowColSizesChange={() => {}}
/>,
);
expect(
container.querySelectorAll('[style*="width: 50%"]').length,
).toBeGreaterThan(0);
rerender(
<SplitView
tabs={tabs}
paneTabIds={["t1", "t2", null, null, null, null]}
splitMode="2-way"
rowSizes={[100]}
rowColSizes={[[20, 80]]}
onRowSizesChange={() => {}}
onRowColSizesChange={() => {}}
/>,
);
expect(
container.querySelectorAll('[style*="width: 20%"]').length,
).toBeGreaterThan(0);
});
});
+120 -40
View File
@@ -1,9 +1,14 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { cleanup, fireEvent, render } from "@testing-library/react";
import type { ComponentProps } from "react";
import type { Tab } from "@/types/ui-types";
import type { SplitSummary } from "@/shell/split/split-tabs";
vi.mock("react-i18next", () => ({
useTranslation: () => ({ t: (key: string) => key }),
useTranslation: () => ({
t: (key: string, opts?: Record<string, unknown>) =>
opts ? `${key}:${JSON.stringify(opts)}` : key,
}),
}));
vi.mock("@/lib/electron", () => ({ isElectron: () => true }));
@@ -17,52 +22,33 @@ const tabs = [
{ id: "terminal-1", type: "terminal", label: "web-01" },
] as Tab[];
function renderTabBar(activeTabId: string, onSetActiveTab = vi.fn()) {
return render(
<TabBar
tabs={tabs}
activeTabId={activeTabId}
splitMode="none"
paneTabIds={[]}
focusedPaneIndex={null}
onSetActiveTab={onSetActiveTab}
onCloseTab={() => {}}
onRefreshTab={() => {}}
onReorderTabs={() => {}}
onSplitTab={() => {}}
onAddToSplit={() => {}}
onRemoveFromSplit={() => {}}
isAppFullscreen={false}
onToggleAppFullscreen={() => {}}
/>,
);
function props(
overrides: Partial<ComponentProps<typeof TabBar>> = {},
): ComponentProps<typeof TabBar> {
return {
tabs,
activeTabId: "dashboard",
splits: [],
activeSplitFull: false,
onSetActiveTab: () => {},
onCloseTab: () => {},
onRefreshTab: () => {},
onReorderTabs: () => {},
onSplitAction: () => {},
isAppFullscreen: false,
onToggleAppFullscreen: () => {},
...overrides,
};
}
describe("TabBar workspace continuity", () => {
it("keeps one shared indicator on the active workspace", () => {
const { container, rerender } = renderTabBar("dashboard");
const { container, rerender } = render(<TabBar {...props()} />);
expect(
container.querySelector('[data-tab-indicator="dashboard"]'),
).toBeTruthy();
rerender(
<TabBar
tabs={tabs}
activeTabId="terminal-1"
splitMode="none"
paneTabIds={[]}
focusedPaneIndex={null}
onSetActiveTab={() => {}}
onCloseTab={() => {}}
onRefreshTab={() => {}}
onReorderTabs={() => {}}
onSplitTab={() => {}}
onAddToSplit={() => {}}
onRemoveFromSplit={() => {}}
isAppFullscreen={false}
onToggleAppFullscreen={() => {}}
/>,
);
rerender(<TabBar {...props({ activeTabId: "terminal-1" })} />);
expect(container.querySelectorAll("[data-tab-indicator]")).toHaveLength(1);
expect(
@@ -72,10 +58,104 @@ describe("TabBar workspace continuity", () => {
it("activates a workspace immediately when its tab is clicked", () => {
const onSetActiveTab = vi.fn();
const { getByText } = renderTabBar("dashboard", onSetActiveTab);
const { getByText } = render(<TabBar {...props({ onSetActiveTab })} />);
fireEvent.click(getByText("web-01"));
expect(onSetActiveTab).toHaveBeenCalledWith("terminal-1");
});
});
describe("TabBar split menu", () => {
const split: SplitSummary = {
id: "split-1",
label: "Split 1",
full: false,
panes: [
{ id: "p1", index: 1, tabId: "terminal-9", label: "db-01" },
{ id: "p2", index: 2, tabId: null, label: null },
],
};
function openMenu(overrides: Partial<ComponentProps<typeof TabBar>> = {}) {
const onSplitAction = vi.fn();
const view = render(<TabBar {...props({ onSplitAction, ...overrides })} />);
fireEvent.contextMenu(view.getByText("web-01"));
return { ...view, onSplitAction };
}
it("splits the clicked tab right or down", () => {
const { getByText, onSplitAction } = openMenu();
fireEvent.click(getByText("splitScreen.splitDown"));
expect(onSplitAction).toHaveBeenCalledWith({
kind: "split",
tabId: "terminal-1",
edge: "bottom",
});
});
it("lists every pane of every split, so the target is always clear", () => {
const { getByText, onSplitAction } = openMenu({ splits: [split] });
expect(
getByText('splitScreen.addToSplit:{"split":"Split 1"}'),
).toBeTruthy();
expect(
getByText('splitScreen.paneItem:{"index":1,"label":"db-01"}'),
).toBeTruthy();
fireEvent.click(getByText('splitScreen.paneEmptyItem:{"index":2}'));
expect(onSplitAction).toHaveBeenCalledWith({
kind: "addToPane",
tabId: "terminal-1",
splitTabId: "split-1",
paneId: "p2",
});
});
it("adds a new pane to an existing split", () => {
const { getByText, onSplitAction } = openMenu({ splits: [split] });
fireEvent.click(getByText("splitScreen.newPaneBelow"));
expect(onSplitAction).toHaveBeenCalledWith({
kind: "addPane",
tabId: "terminal-1",
splitTabId: "split-1",
edge: "bottom",
});
});
it("disables new panes on a full split", () => {
const { getByText } = openMenu({ splits: [{ ...split, full: true }] });
expect(
(
getByText("splitScreen.newPaneRight").closest(
"button",
) as HTMLButtonElement
).disabled,
).toBe(true);
});
it("offers unsplit on a split tab instead of splitting it", () => {
const splitTab = {
id: "split-1",
type: "split-screen",
label: "Split 1",
split: {
root: { kind: "pane", id: "p1", tabId: null },
focusedPaneId: "p1",
},
} as Tab;
const onSplitAction = vi.fn();
const { getByText, queryByText } = render(
<TabBar {...props({ tabs: [...tabs, splitTab], onSplitAction })} />,
);
fireEvent.contextMenu(getByText("Split 1"));
expect(queryByText("splitScreen.splitRight")).toBeNull();
fireEvent.click(getByText("splitScreen.unsplit"));
expect(onSplitAction).toHaveBeenCalledWith({
kind: "unsplit",
splitTabId: "split-1",
});
});
});
+142 -49
View File
@@ -2,12 +2,24 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import {
buildLayoutPayload as buildWorkspacePayload,
buildLayoutTabSnapshots as buildWorkspaceTabSnapshots,
remapSlotIds,
resolveLayoutSplits,
resolveLayoutTabTarget as resolveWorkspaceTabTarget,
snapshotData,
} from "@/shell/shell-layout";
import { registerTabType, resetTabTypes } from "@/shell/tab-registry";
import type { Host, Tab, WorkspaceTabSnapshot } from "@/types/ui-types";
import { makeSplitTab } from "@/shell/split/split-tabs";
import {
createPane,
createSplitNode,
createSplitState,
listPanes,
} from "@/shell/split/split-tree";
import type {
Host,
Tab,
WorkspacePayload,
WorkspaceTabSnapshot,
} from "@/types/ui-types";
// Plugin tab types take part in layouts only while registered, so the ones
// these cases use are registered the way their plugins would.
@@ -130,20 +142,6 @@ describe("buildWorkspaceTabSnapshots", () => {
});
});
describe("remapSlotIds", () => {
it("maps slot ids to live tab ids, preserving nulls and unresolved slots", () => {
const map = new Map([
["slot-a", "new-tab-1"],
["slot-b", "new-tab-2"],
]);
const result = remapSlotIds(
["slot-a", null, "slot-b", "slot-missing"],
map,
);
expect(result).toEqual(["new-tab-1", null, "new-tab-2", null]);
});
});
describe("resolveWorkspaceTabTarget", () => {
const hosts: Host[] = [makeHost({ id: "1", syncId: "sync-web-01" })];
@@ -221,50 +219,71 @@ describe("resolveWorkspaceTabTarget", () => {
});
});
function splitOf(id: string, tabIds: (string | null)[]): Tab {
const root = createSplitNode(
"row",
tabIds.map((tabId) => createPane(tabId)),
);
const split = makeSplitTab(id, `Split ${id}`, createSplitState(root), 0);
return split;
}
describe("buildWorkspacePayload", () => {
it("builds a full payload including pane assignment remapped to slot ids", () => {
it("saves every split with its pane tabs swapped for slot ids", () => {
let counter = 0;
const genSlotId = () => `slot-${counter++}`;
const host = makeHost();
const tabs: Tab[] = [
makeTab({ id: "t1", type: "terminal", host }),
makeTab({ id: "t2", type: "files", host }),
makeTab({ id: "t1", type: "terminal", host, parentSplitTabId: "a" }),
makeTab({ id: "t2", type: "files", host, parentSplitTabId: "a" }),
makeTab({ id: "t3", type: "terminal", host, parentSplitTabId: "b" }),
splitOf("a", ["t1", "t2", null]),
splitOf("b", ["t3"]),
];
const payload = buildWorkspacePayload({
tabs,
activeTabId: "t1",
splitMode: "2-way",
paneTabIds: ["t1", "t2", null, null, null, null],
rowSizes: [100],
rowColSizes: [[50, 50]],
activeTabId: "split-b",
genSlotId,
});
expect(payload.version).toBe(1);
expect(payload.activeSlotId).toBe("slot-0");
expect(payload.paneTabIds).toEqual([
expect(payload.version).toBe(2);
expect(payload.tabs.map((tab) => tab.slotId)).toEqual([
"slot-0",
"slot-1",
"slot-2",
]);
expect(payload.splits).toHaveLength(2);
const [first, second] = payload.splits!;
expect(first.label).toBe("Split a");
expect(listPanes(first.root).map((pane) => pane.tabId)).toEqual([
"slot-0",
"slot-1",
null,
null,
null,
null,
]);
expect(payload.splitMode).toBe("2-way");
expect(payload.rowSizes).toEqual([100]);
expect(payload.rowColSizes).toEqual([[50, 50]]);
expect(listPanes(second.root).map((pane) => pane.tabId)).toEqual([
"slot-2",
]);
expect(payload.activeSlotId).toBe(second.slotId);
expect(payload.splitMode).toBeUndefined();
});
it("leaves out a split none of whose tabs are saved", () => {
const payload = buildWorkspacePayload({
tabs: [
makeTab({ id: "w", type: "web-endpoint", parentSplitTabId: "a" }),
splitOf("a", ["w", null]),
],
activeTabId: "split-a",
});
expect(payload.splits).toEqual([]);
expect(payload.activeSlotId).toBeNull();
});
it("sets activeSlotId to null when the active tab is not capturable", () => {
const payload = buildWorkspacePayload({
tabs: [makeTab({ id: "t1", type: "host-manager" })],
activeTabId: "t1",
splitMode: "none",
paneTabIds: [null, null, null, null, null, null],
rowSizes: [100],
rowColSizes: [[100]],
});
expect(payload.activeSlotId).toBeNull();
expect(payload.tabs).toHaveLength(0);
@@ -274,10 +293,6 @@ describe("buildWorkspacePayload", () => {
const payload = buildWorkspacePayload({
tabs: [],
activeTabId: "dashboard",
splitMode: "none",
paneTabIds: [null, null, null, null, null, null],
rowSizes: [100],
rowColSizes: [[100]],
sidebar: {
left: { view: "hosts", open: true, width: 320 },
right: { view: "history", open: true, width: 240 },
@@ -290,18 +305,96 @@ describe("buildWorkspacePayload", () => {
});
});
it("omits sidebar when not supplied so older payloads stay unchanged", () => {
it("omits sidebar when not supplied", () => {
const payload = buildWorkspacePayload({
tabs: [],
activeTabId: "dashboard",
splitMode: "none",
paneTabIds: [null, null, null, null, null, null],
rowSizes: [100],
rowColSizes: [[100]],
});
expect(payload.sidebar).toBeUndefined();
});
});
describe("resolveLayoutSplits", () => {
it("rebuilds each saved split with live tab ids", () => {
const tabs: Tab[] = [
makeTab({ id: "t1", type: "terminal", parentSplitTabId: "a" }),
makeTab({ id: "t2", type: "terminal", parentSplitTabId: "a" }),
splitOf("a", ["t1", "t2"]),
];
const payload = buildWorkspacePayload({
tabs,
activeTabId: "split-a",
genSlotId: (() => {
let n = 0;
return () => `slot-${n++}`;
})(),
});
expect(payload.sidebar).toBeUndefined();
expect(payload.version).toBe(1);
const live = new Map([
["slot-0", "new-1"],
["slot-1", "new-2"],
]);
const [restored] = resolveLayoutSplits(payload, live);
expect(restored.label).toBe("Split a");
expect(restored.slotId).toBe(payload.splits![0].slotId);
expect(listPanes(restored.split.root).map((pane) => pane.tabId)).toEqual([
"new-1",
"new-2",
]);
});
it("drops a saved split none of whose tabs came back", () => {
const payload = buildWorkspacePayload({
tabs: [
makeTab({ id: "t1", type: "terminal", parentSplitTabId: "a" }),
splitOf("a", ["t1"]),
],
activeTabId: "split-a",
});
expect(resolveLayoutSplits(payload, new Map())).toEqual([]);
});
it("reads the single fixed-mode split of a version 1 payload", () => {
const payload: WorkspacePayload = {
version: 1,
tabs: [],
activeSlotId: "slot-a",
splitMode: "3-way",
paneTabIds: ["slot-a", "slot-b", "slot-c", null, null, null],
rowSizes: [30, 70],
rowColSizes: [[40, 60], [100]],
};
const live = new Map([
["slot-a", "a"],
["slot-b", "b"],
["slot-c", "c"],
]);
const [restored] = resolveLayoutSplits(payload, live);
expect(restored.label).toBeNull();
expect(restored.slotId).toBeNull();
const root = restored.split.root;
expect(root.kind).toBe("split");
if (root.kind !== "split") return;
expect(root.direction).toBe("row");
expect(root.sizes[0]).toBeCloseTo(40);
expect(listPanes(root).map((pane) => pane.tabId)).toEqual(["a", "b", "c"]);
});
it("finds no splits in a version 1 payload without one", () => {
expect(
resolveLayoutSplits(
{
version: 1,
tabs: [],
activeSlotId: null,
splitMode: "none",
paneTabIds: [],
rowSizes: [100],
rowColSizes: [[100]],
},
new Map(),
),
).toEqual([]);
});
});
+227
View File
@@ -0,0 +1,227 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import type { Tab } from "@/types/ui-types";
vi.mock("react-i18next", () => ({
useTranslation: () => ({
t: (key: string, opts?: Record<string, unknown>) =>
opts ? `${key}:${JSON.stringify(opts)}` : key,
}),
}));
import { SplitView, type SplitViewActions } from "@/shell/split/SplitView";
import { makeSplitTab } from "@/shell/split/split-tabs";
import {
createPane,
createSplitNode,
createSplitState,
listPanes,
toggleZoom,
} from "@/shell/split/split-tree";
afterEach(cleanup);
function tab(id: string, label: string): Tab {
return {
id,
instanceId: id,
type: "terminal",
label,
openedAt: 0,
parentSplitTabId: "split-s",
} as Tab;
}
function actions(): SplitViewActions {
return {
focusPane: vi.fn(),
resize: vi.fn(),
equalize: vi.fn(),
splitPane: vi.fn(),
closePane: vi.fn(),
closeSession: vi.fn(),
moveToTab: vi.fn(),
showInPane: vi.fn(),
swapPanes: vi.fn(),
toggleZoom: vi.fn(),
resizeEnd: vi.fn(),
};
}
function setup({
mobile = false,
zoom = false,
}: { mobile?: boolean; zoom?: boolean } = {}) {
const root = createSplitNode("row", [
createPane("t1"),
createSplitNode("column", [createPane("t2"), createPane(null)]),
]);
let state = createSplitState(root, listPanes(root)[0].id);
if (zoom) state = toggleZoom(state, listPanes(root)[1].id);
const splitTab = makeSplitTab("s", "Split 1", state, 0);
const tabs = [tab("t1", "web-01"), tab("t2", "db-01"), splitTab];
const act = actions();
const onPaneContentRef = vi.fn();
const renderEmptyPane = vi.fn((paneId: string, index: number) => (
<div data-testid={`empty-${index}`}>{paneId}</div>
));
const view = render(
<SplitView
splitTab={splitTab}
tabs={tabs}
isMobile={mobile}
actions={act}
onPaneContentRef={onPaneContentRef}
renderEmptyPane={renderEmptyPane}
/>,
);
return {
...view,
act,
onPaneContentRef,
panes: listPanes(state.root),
};
}
describe("SplitView", () => {
it("renders every pane of the tree with a numbered header", () => {
const { container, panes } = setup();
const rendered = Array.from(
container.querySelectorAll("[data-split-pane-id]"),
).map((el) => el.getAttribute("data-split-pane-id"));
expect(rendered).toEqual(panes.map((pane) => pane.id));
expect(screen.getByText("web-01")).toBeTruthy();
expect(screen.getByText("db-01")).toBeTruthy();
});
it("hands each filled pane's content element to the shell", () => {
const { onPaneContentRef, panes } = setup();
const registered = onPaneContentRef.mock.calls
.filter(([, el]) => el)
.map(([paneId]) => paneId);
expect(registered).toEqual([panes[0].id, panes[1].id]);
});
it("shows the picker in an empty pane, with its number", () => {
const { panes } = setup();
expect(screen.getByTestId("empty-3").textContent).toBe(panes[2].id);
});
it("focuses a pane when it is pressed", () => {
const { container, act, panes } = setup();
const pane = container.querySelector(
`[data-split-pane-id="${panes[1].id}"]`,
)!;
fireEvent.pointerDown(pane);
expect(act.focusPane).toHaveBeenCalledWith(panes[1].id);
});
it("focuses a pane from a click inside content the shell moved in", () => {
const { act, onPaneContentRef, panes } = setup();
const contentEl = onPaneContentRef.mock.calls.find(
([paneId, el]) => paneId === panes[1].id && el,
)![1] as HTMLDivElement;
// What the shell does with a tab's portal node.
const tabNode = document.createElement("div");
contentEl.appendChild(tabNode);
fireEvent.pointerDown(tabNode);
expect(act.focusPane).toHaveBeenCalledWith(panes[1].id);
});
it("focuses a pane when keyboard focus moves into it", () => {
const { act, onPaneContentRef, panes } = setup();
const contentEl = onPaneContentRef.mock.calls.find(
([paneId, el]) => paneId === panes[0].id && el,
)![1] as HTMLDivElement;
const input = document.createElement("textarea");
contentEl.appendChild(input);
input.focus();
expect(act.focusPane).toHaveBeenCalledWith(panes[0].id);
});
it("outlines only the focused pane, above its content", () => {
const { container, panes } = setup();
const outlined = Array.from(
container.querySelectorAll("[data-split-pane-id]"),
).filter((el) => el.querySelector(".border-accent-brand\\/70"));
expect(outlined.map((el) => el.getAttribute("data-split-pane-id"))).toEqual(
[panes[0].id],
);
});
it("runs the pane menu's actions", async () => {
const user = userEvent.setup();
const { container, act, panes } = setup();
const pane = container.querySelector(
`[data-split-pane-id="${panes[0].id}"]`,
)!;
await user.click(
pane.querySelector('[aria-label="splitScreen.paneActions"]')!,
);
await user.click(await screen.findByText("splitScreen.closePane"));
expect(act.closePane).toHaveBeenCalledWith(panes[0].id);
await user.click(
pane.querySelector('[aria-label="splitScreen.paneActions"]')!,
);
await user.click(await screen.findByText("splitScreen.moveToTab"));
expect(act.moveToTab).toHaveBeenCalledWith(panes[0].id);
});
it("switches a pane's tab from the header dropdown", async () => {
const user = userEvent.setup();
const { container, act, panes } = setup();
const pane = container.querySelector(
`[data-split-pane-id="${panes[0].id}"]`,
)!;
await user.click(pane.querySelector('[title="splitScreen.showHere"]')!);
await user.click(await screen.findByText("splitScreen.clearPane"));
expect(act.showInPane).toHaveBeenCalledWith(panes[0].id, null);
});
it("splits a pane from its header", () => {
const { container, act, panes } = setup();
const pane = container.querySelector(
`[data-split-pane-id="${panes[0].id}"]`,
)!;
fireEvent.click(
pane.querySelector('[aria-label="splitScreen.splitDown"]')!,
);
expect(act.splitPane).toHaveBeenCalledWith(panes[0].id, "bottom");
});
it("toggles zoom from the header", () => {
const { container, act, panes } = setup();
const pane = container.querySelector(
`[data-split-pane-id="${panes[0].id}"]`,
)!;
fireEvent.click(pane.querySelector('[aria-label="splitScreen.zoom"]')!);
expect(act.toggleZoom).toHaveBeenCalledWith(panes[0].id);
});
it("evens out a group on a divider double-click", () => {
const { container, act } = setup();
const dividers = container.querySelectorAll('[role="separator"]');
expect(dividers).toHaveLength(2);
fireEvent.doubleClick(dividers[0]);
expect(act.equalize).toHaveBeenCalled();
expect(act.resizeEnd).toHaveBeenCalled();
});
it("shows only the zoomed pane", () => {
const { container, panes } = setup({ zoom: true });
const rendered = Array.from(
container.querySelectorAll("[data-split-pane-id]"),
).map((el) => el.getAttribute("data-split-pane-id"));
expect(rendered).toEqual([panes[1].id]);
});
it("on mobile shows one pane and a strip to switch panes", () => {
const { container, act, panes } = setup({ mobile: true });
expect(container.querySelectorAll("[data-split-pane-id]")).toHaveLength(1);
fireEvent.click(screen.getAllByText("db-01")[0]);
expect(act.focusPane).toHaveBeenCalledWith(panes[1].id);
});
});
@@ -0,0 +1,74 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import {
beginSplitDrag,
dropTargetAt,
dropTargetRect,
endSplitDrag,
isSplitDragging,
moveSplitDrag,
setSplitDropHandler,
setSplitDropHitTest,
} from "@/shell/split/split-drag";
const rect = { left: 0, top: 0, width: 200, height: 100 };
afterEach(() => endSplitDrag(false));
describe("dropTargetAt", () => {
it("picks the nearest edge band, else the center", () => {
expect(dropTargetAt(rect, 10, 50)).toBe("left");
expect(dropTargetAt(rect, 190, 50)).toBe("right");
expect(dropTargetAt(rect, 100, 5)).toBe("top");
expect(dropTargetAt(rect, 100, 95)).toBe("bottom");
expect(dropTargetAt(rect, 100, 50)).toBe("center");
});
});
describe("dropTargetRect", () => {
it("covers the half the new pane would take", () => {
expect(dropTargetRect(rect, "right")).toEqual({
left: 100,
top: 0,
width: 100,
height: 100,
});
expect(dropTargetRect(rect, "top")).toEqual({
left: 0,
top: 0,
width: 200,
height: 50,
});
expect(dropTargetRect(rect, "center")).toEqual(rect);
});
});
describe("split drag", () => {
it("drops on the hovered target when committed", () => {
const hover = { paneId: "p1", target: "left" as const, rect };
const disposeHit = setSplitDropHitTest(() => hover);
const onDrop = vi.fn();
const disposeDrop = setSplitDropHandler(onDrop);
const source = { kind: "tab" as const, tabId: "t1", label: "web-01" };
beginSplitDrag(source, 1, 1);
moveSplitDrag(2, 2);
expect(isSplitDragging()).toBe(true);
endSplitDrag(true);
expect(isSplitDragging()).toBe(false);
expect(onDrop).toHaveBeenCalledWith(source, hover);
disposeHit();
disposeDrop();
});
it("does nothing when cancelled or over no target", () => {
const disposeHit = setSplitDropHitTest(() => null);
const onDrop = vi.fn();
const disposeDrop = setSplitDropHandler(onDrop);
beginSplitDrag({ kind: "tab", tabId: "t1", label: "x" }, 0, 0);
endSplitDrag(true);
expect(onDrop).not.toHaveBeenCalled();
disposeHit();
disposeDrop();
});
});
+268
View File
@@ -0,0 +1,268 @@
import { describe, expect, it } from "vitest";
import type { Tab } from "@/types/ui-types";
import {
addSplitTab,
closeSplitTab,
isSplitTab,
makeSplitTab,
nextSplitNumber,
placeTabInPane,
removeTab,
restoreSplitTabs,
serializeSplitTabs,
splitTabOf,
summarizeSplits,
syncSplitChildren,
updateSplit,
type SplitTab,
} from "@/shell/split/split-tabs";
import {
createPane,
createSplitNode,
createSplitState,
listPanes,
removePane,
} from "@/shell/split/split-tree";
function tab(id: string, overrides: Partial<Tab> = {}): Tab {
return {
id,
instanceId: `inst-${id}`,
type: "terminal",
label: id,
openedAt: 0,
...overrides,
} as Tab;
}
function split(id: string, tabIds: (string | null)[]): SplitTab {
return makeSplitTab(
id,
`Split ${id}`,
createSplitState(
createSplitNode(
"row",
tabIds.map((tabId) => createPane(tabId)),
),
),
0,
);
}
function paneTabs(tabs: Tab[], splitId: string) {
const found = tabs.find((t) => t.id === splitId);
if (!isSplitTab(found)) return null;
return listPanes(found.split.root).map((pane) => pane.tabId);
}
const dashboard = tab("dashboard", { type: "dashboard" });
describe("syncSplitChildren", () => {
it("marks tabs in panes and releases the rest", () => {
const tabs = syncSplitChildren([
tab("a"),
tab("b", { parentSplitTabId: "split-x" }),
split("x", ["a", null]),
]);
expect(tabs.find((t) => t.id === "a")?.parentSplitTabId).toBe("split-x");
expect(tabs.find((t) => t.id === "b")?.parentSplitTabId).toBeUndefined();
});
it("keeps a tab claimed by two splits with the first", () => {
const tabs = syncSplitChildren([
tab("a"),
split("x", ["a"]),
split("y", ["a", null]),
]);
expect(paneTabs(tabs, "split-x")).toEqual(["a"]);
expect(paneTabs(tabs, "split-y")).toEqual([null, null]);
});
it("never holds the dashboard or another split in a pane", () => {
const tabs = syncSplitChildren([
dashboard,
split("inner", [null]),
split("x", ["dashboard", "split-inner"]),
]);
expect(paneTabs(tabs, "split-x")).toEqual([null, null]);
});
it("returns the same array when nothing changes", () => {
const tabs = syncSplitChildren([tab("a"), split("x", ["a"])]);
expect(syncSplitChildren(tabs)).toBe(tabs);
});
});
describe("adding and closing", () => {
it("adds a split and takes its tabs out of other splits", () => {
let tabs = addSplitTab([tab("a"), tab("b")], split("x", ["a", "b"]));
tabs = addSplitTab(tabs, split("y", ["b", null]));
expect(paneTabs(tabs, "split-x")).toEqual(["a", null]);
expect(paneTabs(tabs, "split-y")).toEqual(["b", null]);
expect(splitTabOf(tabs, "b")?.id).toBe("split-y");
});
it("releases every tab when a split closes", () => {
const tabs = closeSplitTab(
addSplitTab([tab("a"), tab("b")], split("x", ["a", "b"])),
"split-x",
);
expect(tabs.map((t) => t.id)).toEqual(["a", "b"]);
expect(tabs.every((t) => !t.parentSplitTabId)).toBe(true);
});
it("leaves an empty, focused pane when a tab in it closes", () => {
const tabs = removeTab(
addSplitTab([tab("a"), tab("b")], split("x", ["a", "b"])),
"b",
);
const s = tabs.find((t) => t.id === "split-x") as SplitTab;
const panes = listPanes(s.split.root);
expect(panes.map((pane) => pane.tabId)).toEqual(["a", null]);
expect(s.split.focusedPaneId).toBe(panes[1].id);
expect(tabs.some((t) => t.id === "b")).toBe(false);
});
it("closes the split when an update removes its last pane", () => {
const tabs = addSplitTab([tab("a")], split("x", ["a"]));
const next = updateSplit(tabs, "split-x", (state) =>
removePane(state, state.focusedPaneId),
);
expect(next.some((t) => t.id === "split-x")).toBe(false);
expect(next.find((t) => t.id === "a")?.parentSplitTabId).toBeUndefined();
});
});
describe("placeTabInPane", () => {
it("fills the pane and focuses it", () => {
const tabs = addSplitTab([tab("a"), tab("b")], split("x", ["a", null]));
const s = tabs.find((t) => t.id === "split-x") as SplitTab;
const empty = listPanes(s.split.root)[1];
const next = placeTabInPane(tabs, "split-x", empty.id, "b");
const updated = next.find((t) => t.id === "split-x") as SplitTab;
expect(paneTabs(next, "split-x")).toEqual(["a", "b"]);
expect(updated.split.focusedPaneId).toBe(empty.id);
expect(next.find((t) => t.id === "b")?.parentSplitTabId).toBe("split-x");
});
it("sends a replaced tab back to the tab bar", () => {
const tabs = addSplitTab([tab("a"), tab("b")], split("x", ["a"]));
const s = tabs.find((t) => t.id === "split-x") as SplitTab;
const next = placeTabInPane(
tabs,
"split-x",
listPanes(s.split.root)[0].id,
"b",
);
expect(next.find((t) => t.id === "a")?.parentSplitTabId).toBeUndefined();
});
it("refuses the dashboard", () => {
const tabs = addSplitTab([dashboard], split("x", [null]));
const s = tabs.find((t) => t.id === "split-x") as SplitTab;
const pane = listPanes(s.split.root)[0];
expect(placeTabInPane(tabs, "split-x", pane.id, "dashboard")).toBe(tabs);
});
});
describe("labels and summaries", () => {
it("picks the lowest unused split number", () => {
const labelFor = (n: number) => `Split ${n}`;
const tabs = [
{ ...split("a", [null]), label: "Split 1" },
{ ...split("b", [null]), label: "Split 3" },
];
expect(nextSplitNumber(tabs, labelFor)).toBe(2);
});
it("summarizes panes with their tab labels", () => {
const tabs = addSplitTab(
[tab("a", { label: "web-01", customLabel: "prod" })],
split("x", ["a", null]),
);
const [summary] = summarizeSplits(tabs, 8);
expect(summary.panes.map((pane) => [pane.index, pane.label])).toEqual([
[1, "prod"],
[2, null],
]);
expect(summary.full).toBe(false);
expect(summarizeSplits(tabs, 2)[0].full).toBe(true);
});
});
describe("persistence", () => {
it("round-trips a split through instance ids", () => {
const tabs = addSplitTab([tab("a"), tab("b")], split("x", ["a", null]));
const saved = serializeSplitTabs(tabs);
expect(saved[0].version).toBe(2);
expect(listPanes(saved[0].root).map((pane) => pane.tabId)).toEqual([
"inst-a",
null,
]);
// After a reload, tabs come back with new live ids but the same instanceIds.
const reloaded = [
tab("a2", { instanceId: "inst-a" }),
tab("b2", { instanceId: "inst-b" }),
];
const restored = restoreSplitTabs(
JSON.parse(JSON.stringify(saved)),
reloaded,
);
const restoredSplit = restored.find(isSplitTab)!;
expect(restoredSplit.label).toBe("Split x");
expect(listPanes(restoredSplit.split.root).map((p) => p.tabId)).toEqual([
"a2",
null,
]);
expect(restored.find((t) => t.id === "a2")?.parentSplitTabId).toBe(
restoredSplit.id,
);
});
it("restores the fixed-mode shape saved before the layout tree", () => {
const restored = restoreSplitTabs(
[
{
instanceId: "old",
label: "Split 1",
mode: "3-way-horizontal",
paneInstanceIds: ["inst-a", "inst-b", "inst-c", null, null, null],
rowSizes: [50, 50],
rowColSizes: [[50, 50], [100]],
},
],
[tab("a"), tab("b"), tab("c")],
);
const restoredSplit = restored.find(isSplitTab)!;
expect(restoredSplit.id).toBe("split-old");
expect(listPanes(restoredSplit.split.root).map((p) => p.tabId)).toEqual([
"a",
"b",
"c",
]);
});
it("skips entries whose tabs are gone or that are unreadable", () => {
const restored = restoreSplitTabs(
[
{
instanceId: "x",
label: "Gone",
mode: "2-way",
paneInstanceIds: ["nope"],
},
{ instanceId: "y", label: "Junk", root: { kind: "weird" } },
"not an object",
],
[tab("a")],
);
expect(restored.some(isSplitTab)).toBe(false);
});
it("does not restore a split that is already open", () => {
const tabs = addSplitTab([tab("a")], split("x", ["a"]));
const saved = serializeSplitTabs(tabs);
expect(restoreSplitTabs(saved, tabs)).toBe(tabs);
});
});
+373
View File
@@ -0,0 +1,373 @@
import { describe, expect, it } from "vitest";
import {
addPaneAtEdge,
assignTab,
canAddPane,
clearTab,
createPane,
createSplitNode,
createSplitState,
equalizeSplit,
findPaneByTab,
focusPane,
listPanes,
MAX_PANES,
MIN_PANE_SIZE,
moveDivider,
movePane,
neighborPane,
normalizeNode,
normalizeState,
removePane,
resizeSplit,
shownPanes,
splitPane,
swapPanes,
toggleZoom,
type LayoutNode,
type SplitState,
} from "@/shell/split/split-tree";
import {
applyPreset,
buildPreset,
fromLegacyConfig,
SPLIT_PRESETS,
} from "@/shell/split/split-presets";
function tabsOf(node: LayoutNode) {
return listPanes(node).map((pane) => pane.tabId);
}
function shape(node: LayoutNode): unknown {
if (node.kind === "pane") return node.tabId ?? "_";
return { [node.direction]: node.children.map(shape) };
}
function twoColumns(): SplitState {
return createSplitState(
createSplitNode("row", [createPane("a"), createPane("b")]),
);
}
describe("createSplitState", () => {
it("focuses the first empty pane, so the next pick fills it", () => {
const state = createSplitState(
createSplitNode("row", [createPane("a"), createPane(null)]),
);
expect(listPanes(state.root)[1].id).toBe(state.focusedPaneId);
});
});
describe("splitPane", () => {
it("wraps a lone pane in a new group", () => {
const state = createSplitState(createPane("a"));
const result = splitPane(state, state.focusedPaneId, "right", "b");
expect(shape(result!.state.root)).toEqual({ row: ["a", "b"] });
expect(result!.state.focusedPaneId).toBe(result!.paneId);
});
it("adds a sibling when splitting along the group's own direction", () => {
const state = twoColumns();
const [a] = listPanes(state.root);
const result = splitPane(state, a.id, "left", "c")!;
expect(shape(result.state.root)).toEqual({ row: ["c", "a", "b"] });
const root = result.state.root;
if (root.kind !== "split") throw new Error("expected a group");
expect(root.sizes).toEqual([25, 25, 50]);
});
it("nests a group when splitting across the direction", () => {
const state = twoColumns();
const [, b] = listPanes(state.root);
const result = splitPane(state, b.id, "bottom")!;
expect(shape(result.state.root)).toEqual({
row: ["a", { column: ["b", "_"] }],
});
});
it("moves a tab out of its old pane when it is split into a new one", () => {
const state = twoColumns();
const [a] = listPanes(state.root);
const result = splitPane(state, a.id, "top", "b")!;
expect(tabsOf(result.state.root)).toEqual(["b", "a", null]);
});
it("refuses to go past the pane limit", () => {
let state = createSplitState(createPane("t0"));
for (let i = 1; i < MAX_PANES; i++) {
state = splitPane(state, state.focusedPaneId, "right", `t${i}`)!.state;
}
expect(canAddPane(state)).toBe(false);
expect(splitPane(state, state.focusedPaneId, "right")).toBeNull();
expect(addPaneAtEdge(state, "bottom")).toBeNull();
});
it("clears zoom so the new pane is visible", () => {
const state = toggleZoom(twoColumns(), listPanes(twoColumns().root)[0].id);
const zoomed = { ...state, zoomedPaneId: state.focusedPaneId };
const result = splitPane(zoomed, zoomed.focusedPaneId, "right")!;
expect(result.state.zoomedPaneId).toBeNull();
});
});
describe("addPaneAtEdge", () => {
it("appends to a root running the same way, sharing space evenly", () => {
const result = addPaneAtEdge(twoColumns(), "right", "c")!;
const root = result.state.root;
expect(shape(root)).toEqual({ row: ["a", "b", "c"] });
if (root.kind !== "split") throw new Error("expected a group");
root.sizes.forEach((size) => expect(size).toBeCloseTo(100 / 3));
});
it("wraps the whole layout when adding across it", () => {
const result = addPaneAtEdge(twoColumns(), "bottom")!;
expect(shape(result.state.root)).toEqual({
column: [{ row: ["a", "b"] }, "_"],
});
});
});
describe("removePane", () => {
it("collapses a group left with one child", () => {
const state = twoColumns();
const [a] = listPanes(state.root);
const next = removePane(state, a.id)!;
expect(next.root.kind).toBe("pane");
expect(tabsOf(next.root)).toEqual(["b"]);
});
it("flattens a nested group into a parent running the same way", () => {
const root = createSplitNode("row", [
createPane("a"),
createSplitNode("column", [
createPane("b"),
createSplitNode("row", [createPane("c"), createPane("d")]),
]),
]);
const state = createSplitState(root);
const b = findPaneByTab(state, "b")!;
const next = removePane(state, b.id)!;
expect(shape(next.root)).toEqual({ row: ["a", "c", "d"] });
});
it("returns null for the last pane, so the split closes", () => {
const state = createSplitState(createPane("a"));
expect(removePane(state, state.focusedPaneId)).toBeNull();
});
it("moves focus to a neighbour when the focused pane goes", () => {
const state = twoColumns();
const [a, b] = listPanes(state.root);
const next = removePane(focusPane(state, b.id), b.id)!;
expect(next.focusedPaneId).toBe(a.id);
});
});
describe("tab placement", () => {
it("assigns a tab to one pane only", () => {
const state = twoColumns();
const [, b] = listPanes(state.root);
const next = assignTab(state, b.id, "a");
expect(tabsOf(next.root)).toEqual([null, "a"]);
});
it("empties a pane without removing it", () => {
const next = clearTab(twoColumns(), "a");
expect(tabsOf(next.root)).toEqual([null, "b"]);
});
it("swaps two panes' tabs", () => {
const state = twoColumns();
const [a, b] = listPanes(state.root);
expect(tabsOf(swapPanes(state, a.id, b.id).root)).toEqual(["b", "a"]);
});
it("moves a pane to another pane's edge", () => {
const state = createSplitState(
createSplitNode("row", [
createPane("a"),
createPane("b"),
createPane("c"),
]),
);
const a = findPaneByTab(state, "a")!;
const c = findPaneByTab(state, "c")!;
const next = movePane(state, a.id, c.id, "bottom");
expect(shape(next.root)).toEqual({ row: ["b", { column: ["c", "a"] }] });
});
it("swaps on a center drop", () => {
const state = twoColumns();
const [a, b] = listPanes(state.root);
expect(tabsOf(movePane(state, a.id, b.id, "center").root)).toEqual([
"b",
"a",
]);
});
});
describe("sizes", () => {
it("keeps both sides of a divider above the minimum", () => {
expect(moveDivider([50, 50], 0, 80)).toEqual([
100 - MIN_PANE_SIZE,
MIN_PANE_SIZE,
]);
expect(moveDivider([50, 50], 0, -80)).toEqual([
MIN_PANE_SIZE,
100 - MIN_PANE_SIZE,
]);
});
it("resizes and evens out one group", () => {
const state = twoColumns();
const id = state.root.id;
const resized = resizeSplit(state, id, [30, 70]);
const root = resized.root;
if (root.kind !== "split") throw new Error("expected a group");
expect(root.sizes).toEqual([30, 70]);
const even = equalizeSplit(resized, id).root;
if (even.kind !== "split") throw new Error("expected a group");
expect(even.sizes).toEqual([50, 50]);
});
it("ignores sizes that do not match the children", () => {
const state = twoColumns();
expect(resizeSplit(state, state.root.id, [100])).toBe(state);
});
});
describe("normalize", () => {
it("keeps the same object when nothing needs fixing", () => {
const state = twoColumns();
expect(normalizeState(state)).toBe(state);
expect(normalizeNode(state.root)).toBe(state.root);
});
it("empties panes whose tabs are gone and drops duplicates", () => {
const state = createSplitState(
createSplitNode("row", [
createPane("a"),
createPane("a"),
createPane("gone"),
]),
);
const next = normalizeState(state, (id) => id !== "gone");
expect(tabsOf(next.root)).toEqual(["a", null, null]);
});
it("repairs a focus that points nowhere", () => {
const state = { ...twoColumns(), focusedPaneId: "missing" };
const next = normalizeState(state);
expect(next.focusedPaneId).toBe(listPanes(next.root)[0].id);
});
});
describe("zoom and shown panes", () => {
it("shows only the zoomed pane", () => {
const state = twoColumns();
const [a] = listPanes(state.root);
const zoomed = toggleZoom(state, a.id);
expect(shownPanes(zoomed).map((pane) => pane.id)).toEqual([a.id]);
expect(toggleZoom(zoomed, a.id).zoomedPaneId).toBeNull();
});
it("shows only the focused pane when one pane fits", () => {
const state = twoColumns();
const [, b] = listPanes(state.root);
const focused = focusPane(state, b.id);
expect(shownPanes(focused, true).map((pane) => pane.id)).toEqual([b.id]);
expect(shownPanes(focused, false)).toHaveLength(2);
});
it("follows focus while zoomed", () => {
const state = twoColumns();
const [a, b] = listPanes(state.root);
const moved = focusPane(toggleZoom(state, a.id), b.id);
expect(moved.zoomedPaneId).toBe(b.id);
});
});
describe("neighborPane", () => {
// +---+---+
// | a | b |
// | +---+
// | | c |
// +---+---+
const rects = {
a: { left: 0, top: 0, right: 100, bottom: 200 },
b: { left: 101, top: 0, right: 200, bottom: 100 },
c: { left: 101, top: 101, right: 200, bottom: 200 },
};
it("finds panes by position in any tree shape", () => {
expect(neighborPane("a", "right", rects)).toBe("b");
expect(neighborPane("b", "down", rects)).toBe("c");
expect(neighborPane("c", "up", rects)).toBe("b");
expect(neighborPane("c", "left", rects)).toBe("a");
});
it("returns null at the edge", () => {
expect(neighborPane("a", "left", rects)).toBeNull();
expect(neighborPane("b", "up", rects)).toBeNull();
});
});
describe("presets", () => {
it("builds each preset with its pane count", () => {
for (const preset of SPLIT_PRESETS) {
expect(listPanes(buildPreset(preset.id))).toHaveLength(preset.panes);
}
});
it("re-lays a split out, keeping its tabs in order", () => {
const next = applyPreset(twoColumns(), "grid-4");
expect(tabsOf(next.root)).toEqual(["a", "b", null, null]);
});
it("drops tabs past the preset's pane count", () => {
const state = createSplitState(buildPreset("grid-4", ["a", "b", "c", "d"]));
expect(tabsOf(applyPreset(state, "columns-2").root)).toEqual(["a", "b"]);
});
});
describe("fromLegacyConfig", () => {
it.each([
["2-way", { row: ["a", "b"] }],
["2-way-horizontal", { column: ["a", "b"] }],
["3-way", { row: ["a", { column: ["b", "c"] }] }],
["3-way-horizontal", { column: [{ row: ["a", "b"] }, "c"] }],
["4-way", { column: [{ row: ["a", "b"] }, { row: ["c", "d"] }] }],
["5-way", { column: [{ row: ["a", "b", "c"] }, { row: ["d", "e"] }] }],
["6-way", { column: [{ row: ["a", "b", "c"] }, { row: ["d", "e", "f"] }] }],
])("maps %s onto the tree", (mode, expected) => {
const root = fromLegacyConfig(mode, ["a", "b", "c", "d", "e", "f"]);
expect(shape(root!)).toEqual(expected);
});
it("keeps saved divider positions", () => {
const root = fromLegacyConfig(
"4-way",
["a", "b", "c", "d"],
[30, 70],
[
[20, 80],
[50, 50],
],
)!;
if (root.kind !== "split") throw new Error("expected a group");
expect(root.sizes).toEqual([30, 70]);
const top = root.children[0];
if (top.kind !== "split") throw new Error("expected a group");
expect(top.sizes).toEqual([20, 80]);
});
it("falls back to even sizes when the saved ones do not fit", () => {
const root = fromLegacyConfig("2-way", ["a", "b"], "junk", [[1, 2, 3]])!;
if (root.kind !== "split") throw new Error("expected a group");
expect(root.sizes).toEqual([50, 50]);
});
it("returns null for an unknown mode", () => {
expect(fromLegacyConfig("none", [])).toBeNull();
});
});
-103
View File
@@ -1,103 +0,0 @@
import { describe, expect, it } from "vitest";
import type { Tab } from "@/types/ui-types";
import {
assignTabsToSplit,
createSplitConfig,
releaseSplitTabs,
restoreSplitTabs,
serializeSplitTabs,
} from "@/shell/splitTabUtils";
const session = (id: string): Tab => ({
id,
instanceId: `instance-${id}`,
type: "terminal",
label: id,
openedAt: 1,
});
describe("split tab state", () => {
it("moves assigned sessions under a split tab and releases removed panes", () => {
const split: Tab = {
id: "split-1",
instanceId: "split-instance",
type: "split-screen",
label: "Split #1",
openedAt: 1,
splitConfig: createSplitConfig("2-way", ["a", "b"], {
rowSizes: [100],
rowColSizes: [[50, 50]],
}),
};
const assigned = assignTabsToSplit(
[session("a"), session("b"), split],
split.id,
["a", "b"],
);
expect(assigned.slice(0, 2).map((tab) => tab.parentSplitTabId)).toEqual([
split.id,
split.id,
]);
const updated = assignTabsToSplit(assigned, split.id, ["a", null]);
expect(updated.find((tab) => tab.id === "a")?.parentSplitTabId).toBe(
split.id,
);
expect(updated.find((tab) => tab.id === "b")?.parentSplitTabId).toBe(
undefined,
);
});
it("releases child sessions when the split tab closes", () => {
const tabs = assignTabsToSplit(
[
session("a"),
{
id: "split-1",
instanceId: "split-instance",
type: "split-screen",
label: "Split #1",
openedAt: 1,
},
],
"split-1",
["a"],
);
expect(releaseSplitTabs(tabs, "split-1")).toEqual([session("a")]);
});
it("persists pane membership by stable instance id", () => {
const members = [session("a"), session("b")];
const split: Tab = {
id: "split-1",
instanceId: "workspace-1",
type: "split-screen",
label: "Production",
openedAt: 1,
splitConfig: createSplitConfig("2-way", ["a", "b"], {
rowSizes: [100],
rowColSizes: [[40, 60]],
}),
};
const saved = serializeSplitTabs([...members, split]);
const restoredMembers = [
{ ...session("new-a"), instanceId: "instance-a" },
{ ...session("new-b"), instanceId: "instance-b" },
];
const restored = restoreSplitTabs(saved, restoredMembers, 2);
const restoredSplit = restored.find((tab) => tab.type === "split-screen");
expect(restoredSplit?.label).toBe("Production");
expect(restoredSplit?.splitConfig?.paneTabIds.slice(0, 2)).toEqual([
"new-a",
"new-b",
]);
expect(restoredMembers.map((tab) => tab.id)).toEqual(["new-a", "new-b"]);
expect(
restored
.filter((tab) => tab.type === "terminal")
.map((tab) => tab.parentSplitTabId),
).toEqual(["split-workspace-1", "split-workspace-1"]);
});
});
-1
View File
@@ -27,7 +27,6 @@ function renderRail(onRailClick = vi.fn()) {
<AppRail
railView="hosts"
sidebarOpen={false}
splitMode="none"
username="alice"
isAdmin={false}
onRailClick={onRailClick}
-1
View File
@@ -60,7 +60,6 @@ describe("RAIL_ITEMS", () => {
"quick-connect",
"ssh-tools",
"macros",
"split-screen",
"sync",
]);
});