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