diff --git a/packages/plugin-sdk/src/frontend.ts b/packages/plugin-sdk/src/frontend.ts index a0103452f..132afd137 100644 --- a/packages/plugin-sdk/src/frontend.ts +++ b/packages/plugin-sdk/src/frontend.ts @@ -226,7 +226,13 @@ export interface TabProps { sshHost?: Record; 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; shell: ShellApi; diff --git a/plugins/ssh-terminal/src/frontend/terminal/Terminal.tsx b/plugins/ssh-terminal/src/frontend/terminal/Terminal.tsx index dc5ac912a..da716f7ef 100644 --- a/plugins/ssh-terminal/src/frontend/terminal/Terminal.tsx +++ b/plugins/ssh-terminal/src/frontend/terminal/Terminal.tsx @@ -3088,6 +3088,7 @@ const TerminalInner = forwardRef( "BracketLeft", "Backslash", "Minus", + "Enter", ]; if (globalCodes.includes(e.code)) { e.stopPropagation(); diff --git a/plugins/ssh-terminal/src/frontend/terminal/TerminalTabContent.tsx b/plugins/ssh-terminal/src/frontend/terminal/TerminalTabContent.tsx index 84b54329b..3e2089549 100644 --- a/plugins/ssh-terminal/src/frontend/terminal/TerminalTabContent.tsx +++ b/plugins/ssh-terminal/src/frontend/terminal/TerminalTabContent.tsx @@ -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 diff --git a/plugins/ssh-terminal/tests/frontend/terminal/TerminalTabContent.test.tsx b/plugins/ssh-terminal/tests/frontend/terminal/TerminalTabContent.test.tsx new file mode 100644 index 000000000..3f4d67368 --- /dev/null +++ b/plugins/ssh-terminal/tests/frontend/terminal/TerminalTabContent.test.tsx @@ -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[] })); + +vi.mock("../../../src/frontend/terminal/Terminal", () => ({ + Terminal: (props: Record) => { + 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(); +} + +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); + }); +}); diff --git a/plugins/workspaces/src/frontend/index.tsx b/plugins/workspaces/src/frontend/index.tsx index 3ea8cb77d..de90af8ee 100644 --- a/plugins/workspaces/src/frontend/index.tsx +++ b/plugins/workspaces/src/frontend/index.tsx @@ -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); diff --git a/scripts/split-screen-selection.test.ts b/scripts/split-screen-selection.test.ts deleted file mode 100644 index 5d0995941..000000000 --- a/scripts/split-screen-selection.test.ts +++ /dev/null @@ -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"); -}); diff --git a/src/types/index.ts b/src/types/index.ts index 8780f8d69..d5a4f7bad 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -453,8 +453,6 @@ export interface TerminalRefHandle { openFileManager?: () => void; } -export type SplitLayout = "2h" | "2v" | "3l" | "3r" | "3t" | "4grid"; - // ============================================================================ // EXPRESS REQUEST TYPES // ============================================================================ diff --git a/src/types/ui-preferences.ts b/src/types/ui-preferences.ts index 56f5bc8ca..3906fc7ed 100644 --- a/src/types/ui-preferences.ts +++ b/src/types/ui-preferences.ts @@ -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( diff --git a/src/types/ui-types.ts b/src/types/ui-types.ts index 933216d90..12cd67afd 100644 --- a/src/types/ui-types.ts +++ b/src/types/ui-types.ts @@ -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; - /** 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; - 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; diff --git a/src/ui/AppShell.tsx b/src/ui/AppShell.tsx index c0fcbda83..2e935057a 100644 --- a/src/ui/AppShell.tsx +++ b/src/ui/AppShell.tsx @@ -9,11 +9,14 @@ import { Sheet, SheetContent } from "@/components/sheet"; import { ChevronLeft, ChevronRight, + Columns2, Maximize2, Minimize2, PanelRight, RotateCcw, + Rows2, SquareArrowOutUpRight, + X, } from "lucide-react"; import { useState, @@ -42,7 +45,12 @@ import { MultiPanelHint } from "@/sidebar/MultiPanelHint"; import { OnboardingDialog } from "@/onboarding/OnboardingDialog"; import { UI_ONBOARDING_VERSION } from "@/types/ui-preferences"; import { useUiPreferencesContext } from "@/contexts/UiPreferencesContext"; -import { defaultSizes, SplitView, type RowColSizes } from "@/shell/SplitView"; +import { SplitView, type SplitViewActions } from "@/shell/split/SplitView"; +import { SplitDropOverlay } from "@/shell/split/SplitDropOverlay"; +import { + EmptyPanePicker, + type PickHostTarget, +} from "@/shell/split/EmptyPanePicker"; import { renderTabContent } from "@/shell/tabUtils"; import { TabBar } from "@/shell/TabBar"; import { dispatchCtrlW, isShiftKey } from "@/lib/app-keyboard-shortcuts"; @@ -67,11 +75,6 @@ const QuickConnectPanel = lazy(() => default: m.QuickConnectPanel, })), ); -const SplitScreenPanel = lazy(() => - import("@/sidebar/SplitScreenPanel").then((m) => ({ - default: m.SplitScreenPanel, - })), -); // Secondary rail panels — load on first open, not with the shell critical path. const SshToolsPanel = lazy(() => @@ -118,13 +121,12 @@ import type { Tab, TabType, Host, - SplitMode, HostFolder, ThemeId, FontSizeId, WorkspacePayload, } from "@/types/ui-types"; -import { applyAccentColor, applyFontSize, PANE_COUNTS } from "@/lib/theme"; +import { applyAccentColor, applyFontSize } from "@/lib/theme"; import { globalShortcutHandler } from "@/lib/global-shortcut-handler"; import { getTabJumpDigit } from "@/lib/tab-jump-hotkey"; import { useTheme } from "@/components/theme-provider"; @@ -146,7 +148,7 @@ import { } from "@/main-axios"; import { buildLayoutPayload, - remapSlotIds, + resolveLayoutSplits, resolveLayoutTabTarget, snapshotData, } from "@/shell/shell-layout"; @@ -161,20 +163,75 @@ import { changeAppLanguage, consumeLoginLanguage } from "@/i18n/i18n"; import { quickConnectHostToPayload } from "@/sidebar/quick-connect-host"; import { buildHostTree } from "@/sidebar/build-host-tree"; import { - assignTabsToSplit, - createSplitConfig, - releaseSplitTabs, + addSplitTab, + canJoinSplit, + closeSplitTab, + isSplitTab, + makeSplitTab, + nextSplitNumber, + placeTabInPane, + removeTab, restoreSplitTabs, serializeSplitTabs, - type PersistedSplitTab, -} from "@/shell/splitTabUtils"; + splitTabOf, + summarizeSplits, + updateSplit, + type SplitTab, + type TabSplitAction, +} from "@/shell/split/split-tabs"; +import { + addPaneAtEdge, + assignTab, + canAddPane, + createPane, + createSplitNode, + createSplitState, + equalizeSplit, + findPane, + findPaneByTab, + focusPane, + listPanes, + MAX_PANES, + movePane, + neighborPane, + removePane, + resizeSplit, + shownPanes, + splitPane, + swapPanes, + toggleZoom, + type NavDirection, + type PaneEdge, + type PaneRect, + type SplitState, +} from "@/shell/split/split-tree"; +import { + applyPreset, + buildPreset, + fromLegacyConfig, + type SplitPresetId, +} from "@/shell/split/split-presets"; +import { + setSplitDropHandler, + type SplitDragSource, + type SplitDropHover, +} from "@/shell/split/split-drag"; +import { + publishSplitTargets, + setSplitOpener, + type SplitOpenTarget, +} from "@/shell/split/split-targets"; +import { registerPaletteEntry } from "@/shell/palette-registry"; +import { createId } from "@/lib/create-id"; import { canRestoreTabType, getTabType, isPersistentTabType, isSessionTabType, + useTabTypes, type TabShellCallbacks, } from "@/shell/tab-registry"; +import { runHostAction } from "@/sidebar/host-contributions"; import { getPanel, usePanels } from "@/shell/panel-registry"; import { invokeAction } from "@/shell/action-registry"; import { usePluginStore } from "@/plugin-host/plugin-store"; @@ -204,6 +261,7 @@ export function AppShell({ // Re-render when plugins add or remove rail items, panels or tabs. useRailItems(); const registeredPanels = usePanels(); + const tabTypes = useTabTypes(); const { settled: pluginsSettled } = usePluginStore(); const [tabs, setTabs] = useState([ { @@ -223,32 +281,9 @@ export function AppShell({ // Flips to true once the initial DB read (restore or skip) is done — sync must not fire before this const [tabsReady, setTabsReady] = useState(false); const [commandPaletteOpen, setCommandPaletteOpen] = useState(false); - const [splitMode, setSplitMode] = useState("none"); - // paneTabIds holds live tab.id values, which change on every restore, so we - // can't restore it from storage directly. It starts empty and gets filled in - // once by the reconciliation effect below, keyed off the stable instanceId - // values saved in termix_paneInstanceIds. - const [paneTabIds, setPaneTabIds] = useState<(string | null)[]>(() => - Array(6).fill(null), - ); + // Split tabs are restored once their child tabs have stable live ids. const paneLayoutRestoredRef = useRef(false); const splitTabsRestoredRef = useRef(false); - useEffect(() => { - paneTabIdsRef.current = paneTabIds; - }, [paneTabIds]); - const [rowSizes, setRowSizes] = useState( - () => defaultSizes("none").rowSizes, - ); - const [rowColSizes, setRowColSizes] = useState( - () => defaultSizes("none").rowColSizes, - ); - const changeSplitMode = useCallback((mode: SplitMode) => { - setSplitMode(mode); - const d = defaultSizes(mode); - setRowSizes(d.rowSizes); - setRowColSizes(d.rowColSizes); - }, []); - const [focusedPaneIndex, setFocusedPaneIndex] = useState(null); const [realHostTree, setRealHostTree] = useState(null); const [hostsLoading, setHostsLoading] = useState(true); const [allHosts, setAllHosts] = useState([]); @@ -432,12 +467,13 @@ export function AppShell({ const activeTabIdRef = useRef(activeTabId); const closeActiveTabRef = useRef<() => void>(() => {}); const globalKeybindingsRef = useRef([]); - const splitModeRef = useRef(splitMode); - const focusedPaneIndexRef = useRef(null); - const paneContentElsRef = useRef<(HTMLDivElement | null)[]>( - Array(6).fill(null), - ); - const paneTabIdsRef = useRef<(string | null)[]>(Array(6).fill(null)); + // Split actions for handlers registered once, e.g. the global hotkeys. + const splitActionsRef = useRef<{ + splitActive: (edge: "right" | "bottom") => void; + navigate: (direction: NavDirection) => boolean; + zoomFocused: () => boolean; + closeFocusedPane: () => void; + } | null>(null); useEffect(() => { tabsRef.current = tabs; }, [tabs]); @@ -478,13 +514,15 @@ export function AppShell({ return; } if (type === "reconnectSession") { - const tabId = activeTabIdRef.current; + const tabId = focusedSessionTabId(); if (!tabId) return; const termRef = terminalRefs.current.get(tabId); (termRef?.current as TabHandle | null)?.reconnect?.(); return; } - const currentTabs = tabsRef.current; + const currentTabs = tabsRef.current.filter( + (tab) => !tab.parentSplitTabId, + ); if (currentTabs.length < 2) return; const index = currentTabs.findIndex( (tab) => tab.id === activeTabIdRef.current, @@ -530,60 +568,105 @@ export function AppShell({ closeActiveTabRef.current(); }); }, []); - const skipSplitSyncRef = useRef(false); - const activeTabAvailable = tabs.some((tab) => tab.id === activeTabId); - useEffect(() => { - const active = tabs.find((tab) => tab.id === activeTabId); - const config = active?.type === "split-screen" ? active.splitConfig : null; - skipSplitSyncRef.current = true; - if (!config) { - setSplitMode("none"); - setPaneTabIds(Array(6).fill(null)); - setFocusedPaneIndex(null); - return; - } - setSplitMode(config.mode); - setPaneTabIds(config.paneTabIds); - setRowSizes(config.rowSizes); - setRowColSizes(config.rowColSizes); - setFocusedPaneIndex(0); - }, [activeTabId, activeTabAvailable]); + /** The tab the user is working in: the focused pane's inside a split. */ + function focusedSessionTabId(): string | null { + const id = activeTabIdRef.current; + const active = tabsRef.current.find((tab) => tab.id === id); + if (!isSplitTab(active)) return id || null; + return findPane(active.split, active.split.focusedPaneId)?.tabId ?? null; + } - useEffect(() => { - if (skipSplitSyncRef.current) { - skipSplitSyncRef.current = false; + // ─── Where opened tabs go ───────────────────────────────────────────────── + + const PENDING_TARGET_MS = 60_000; + /** + * The pane the next opened tab should fill, set when the user picks one + * (the empty pane picker, "Open in split"). `fresh` marks a pane created in + * the same click, which the last committed tabs do not show yet. + */ + const pendingPaneTargetRef = useRef<{ + splitTabId: string; + paneId: string; + at: number; + fresh: boolean; + } | null>(null); + // Set while a saved layout is applied, so its tabs stay where it puts them. + const suppressPaneTargetRef = useRef(false); + + function targetPane(splitTabId: string, paneId: string, fresh = false) { + pendingPaneTargetRef.current = { + splitTabId, + paneId, + at: Date.now(), + fresh, + }; + } + + /** + * Shows a newly opened (or re-focused) tab. It fills the pane the user + * picked, else the focused pane of the active split when that is empty, + * else it simply becomes the active tab. + */ + const placeOpenedTabRef = useRef<(tabId: string) => void>(() => {}); + placeOpenedTabRef.current = (tabId: string) => { + const current = tabsRef.current; + const pending = pendingPaneTargetRef.current; + pendingPaneTargetRef.current = null; + if (suppressPaneTargetRef.current || tabId === "dashboard") { + setActiveTabId(tabId); return; } - if (splitMode === "none") return; - setTabs((prev) => { - const active = prev.find((tab) => tab.id === activeTabId); - if (active?.type !== "split-screen") return prev; - const config = createSplitConfig(splitMode, paneTabIds, { - rowSizes, - rowColSizes, - }); - const updated = prev.map((tab) => - tab.id === activeTabId ? { ...tab, splitConfig: config } : tab, - ); - return assignTabsToSplit(updated, activeTabId, paneTabIds); - }); - }, [activeTabId, paneTabIds, rowColSizes, rowSizes, splitMode]); + const existing = current.find((tab) => tab.id === tabId); + + let target: { splitTabId: string; paneId: string } | null = null; + if (pending && Date.now() - pending.at < PENDING_TARGET_MS) { + const split = current.find((tab) => tab.id === pending.splitTabId); + if ( + pending.fresh || + (isSplitTab(split) && findPane(split.split, pending.paneId)) + ) { + target = pending; + } + } + // Opening a session that already sits in a split shows it there. + if (!target && existing?.parentSplitTabId) { + setActiveTabId(tabId); + return; + } + if (!target) { + const active = current.find((tab) => tab.id === activeTabIdRef.current); + if (isSplitTab(active)) { + const focused = findPane(active.split, active.split.focusedPaneId); + if (focused && focused.tabId === null) { + target = { splitTabId: active.id, paneId: focused.id }; + } + } + } + if (!target || (existing && !canJoinSplit(existing))) { + setActiveTabId(tabId); + return; + } + const { splitTabId, paneId } = target; + setTabs((prev) => placeTabInPane(prev, splitTabId, paneId, tabId)); + setActiveTabId(splitTabId); + }; + // Panels like history and snippets act on "the terminal you're working in". // Once those panels can themselves be the active tab, activeTabId points at // the panel and the lookup misses, so remember the last terminal instead. + // In a split, the tab being worked in is the focused pane's. + const workingTabId = (() => { + const active = tabs.find((t) => t.id === activeTabId); + if (!isSplitTab(active)) return activeTabId; + return findPane(active.split, active.split.focusedPaneId)?.tabId ?? ""; + })(); const [lastTerminalTabId, setLastTerminalTabId] = useState(activeTabId); useEffect(() => { - const active = tabs.find((t) => t.id === activeTabId); - if (active && getTabType(active.type)?.commandTarget) { - setLastTerminalTabId(active.id); + const working = tabs.find((t) => t.id === workingTabId); + if (working && getTabType(working.type)?.commandTarget) { + setLastTerminalTabId(working.id); } - }, [activeTabId, tabs]); - useEffect(() => { - splitModeRef.current = splitMode; - }, [splitMode]); - useEffect(() => { - focusedPaneIndexRef.current = focusedPaneIndex; - }, [focusedPaneIndex]); + }, [workingTabId, tabs]); const [commandPaletteShortcutEnabled, setCommandPaletteShortcutEnabled] = useState(() => { const v = localStorage.getItem("commandPaletteShortcutEnabled"); @@ -595,12 +678,10 @@ export function AppShell({ const terminalRefs = useRef>>( new Map(), ); - const [paneContentEls, setPaneContentEls] = useState< - (HTMLDivElement | null)[] - >(Array(6).fill(null)); - useEffect(() => { - paneContentElsRef.current = paneContentEls; - }, [paneContentEls]); + // Each shown pane's content element, by pane id. The version bump + // re-renders so the placement effect moves tab nodes into new panes. + const paneElsRef = useRef>(new Map()); + const [, setPaneElsVersion] = useState(0); // Stable per-tab DOM nodes — created once per tab, never destroyed while the tab lives. // We always portal each tab's content into its own node, then move that node between @@ -608,6 +689,8 @@ export function AppShell({ // target never changes (changing the target causes a remount). const tabNodesRef = useRef>(new Map()); const normalViewRef = useRef(null); + // The area below the tab bar, where tabs and panes are dropped to split. + const mainAreaRef = useRef(null); // Tab id the enter animation has already played for, so a re-render while // the tab stays active (there can be several right after a switch) doesn't // replay it — only a genuine switch to a different tab should. @@ -650,13 +733,16 @@ export function AppShell({ .filter((t): t is Tab => t !== undefined); const onPaneContentRef = useCallback( - (paneIndex: number, el: HTMLDivElement | null) => { - setPaneContentEls((prev) => { - if (prev[paneIndex] === el) return prev; - const next = [...prev]; - next[paneIndex] = el; - return next; - }); + (paneId: string, el: HTMLDivElement | null) => { + const els = paneElsRef.current; + if (el) { + if (els.get(paneId) === el) return; + els.set(paneId, el); + } else { + if (!els.has(paneId)) return; + els.delete(paneId); + } + setPaneElsVersion((version) => version + 1); }, [], ); @@ -727,103 +813,34 @@ export function AppShell({ } } - // Ctrl+Shift+\ — toggle 2-way split (side by side) - if (e.ctrlKey && e.shiftKey && !e.altKey && e.code === "Backslash") { - e.preventDefault(); - if (splitModeRef.current !== "none") { - selectSplitMode("none"); - } else { - selectSplitMode("2-way"); - } - return; - } - - // Ctrl+Shift+- — toggle 3-way-horizontal split (top/bottom) - if (e.ctrlKey && e.shiftKey && !e.altKey && e.code === "Minus") { - e.preventDefault(); - if (splitModeRef.current !== "none") { - selectSplitMode("none"); - } else { - selectSplitMode("3-way-horizontal"); - } - return; - } - - // Alt+Arrow — navigate between panes in split mode - if (e.altKey && !e.ctrlKey && !e.shiftKey && !e.metaKey) { - if ( - e.code === "ArrowLeft" || - e.code === "ArrowRight" || - e.code === "ArrowUp" || - e.code === "ArrowDown" - ) { - if (splitModeRef.current === "none") return; - const count = PANE_COUNTS[splitModeRef.current]; - if (count < 2) return; + // Ctrl+Shift+\ splits right, Ctrl+Shift+- splits down, Ctrl+Shift+Enter + // zooms the focused pane. + if (e.ctrlKey && e.shiftKey && !e.altKey && !e.metaKey) { + if (e.code === "Backslash" || e.code === "Minus") { e.preventDefault(); - const current = focusedPaneIndexRef.current ?? 0; - const mode = splitModeRef.current; - const dir = e.code; + splitActionsRef.current?.splitActive( + e.code === "Backslash" ? "right" : "bottom", + ); + return; + } + if (e.code === "Enter" && splitActionsRef.current?.zoomFocused()) { + e.preventDefault(); + return; + } + } - // Layout-aware navigation maps: [left, right, up, down] per pane index. - // null means no movement in that direction. - const navMap: Record = { - "2-way": [ - [null, 1, null, null], - [0, null, null, null], - ], - "2-way-horizontal": [ - [null, null, null, 1], - [null, null, 0, null], - ], - "3-way": [ - [null, 1, null, null], - [0, null, null, 2], - [0, null, 1, null], - ], - "3-way-horizontal": [ - [null, 1, null, 2], - [0, null, null, 2], - [null, null, 0, null], - ], - "4-way": [ - [null, 1, null, 2], - [0, null, null, 3], - [null, 3, 0, null], - [2, null, 1, null], - ], - "5-way": [ - [null, 1, null, 3], - [0, 2, null, 4], - [1, null, null, 4], - [null, 4, 0, null], - [3, null, 1, null], - ], - "6-way": [ - [null, 1, null, 3], - [0, 2, null, 4], - [1, null, null, 5], - [null, 4, 0, null], - [3, 5, 1, null], - [4, null, 2, null], - ], - }; - - const paneNav = navMap[mode]?.[current]; - const dirIndex = - { ArrowLeft: 0, ArrowRight: 1, ArrowUp: 2, ArrowDown: 3 }[dir] ?? - -1; - const next = paneNav?.[dirIndex] ?? null; - if (next === null) return; - - focusedPaneIndexRef.current = next; - setFocusedPaneIndex(next); - // Physically move DOM focus into the target pane's terminal - const tabId = paneTabIdsRef.current[next]; - if (tabId) { - const termRef = terminalRefs.current.get(tabId); - (termRef?.current as TabHandle | null)?.focus?.(); - } + // Alt+Arrow moves between panes of the active split + if (e.altKey && !e.ctrlKey && !e.shiftKey && !e.metaKey) { + const direction = ( + { + ArrowLeft: "left", + ArrowRight: "right", + ArrowUp: "up", + ArrowDown: "down", + } as Record + )[e.code]; + if (direction && splitActionsRef.current?.navigate(direction)) { + e.preventDefault(); return; } } @@ -831,7 +848,9 @@ export function AppShell({ // Cmd+1..9 on macOS, Alt+1..9 elsewhere — jump directly to the tab at that position const tabDigit = getTabJumpDigit(e); if (tabDigit !== null) { - const currentTabs = tabsRef.current; + const currentTabs = tabsRef.current.filter( + (tab) => !tab.parentSplitTabId, + ); const index = tabDigit - 1; if (index < currentTabs.length) { e.preventDefault(); @@ -844,7 +863,9 @@ export function AppShell({ if (e.ctrlKey && e.shiftKey && !e.altKey && !e.metaKey) { if (e.code === "BracketRight" || e.code === "BracketLeft") { e.preventDefault(); - const currentTabs = tabsRef.current; + const currentTabs = tabsRef.current.filter( + (tab) => !tab.parentSplitTabId, + ); if (currentTabs.length < 2) return; const currentId = activeTabIdRef.current; const idx = currentTabs.findIndex((t) => t.id === currentId); @@ -1165,10 +1186,6 @@ export function AppShell({ return buildLayoutPayload({ tabs, activeTabId, - splitMode, - paneTabIds, - rowSizes, - rowColSizes, sidebar: { left: { view: railView, open: sidebarOpen, width: sidebarWidth }, right: { @@ -1188,85 +1205,101 @@ export function AppShell({ for (const tab of [...tabsRef.current]) { doCloseTab(tab.id); } + // Tabs reopened here must not be pulled into a pane of the old layout. + suppressPaneTargetRef.current = true; + pendingPaneTargetRef.current = null; + try { + const slotIdToNewTabId = new Map(); + const skippedTabs: string[] = []; - const slotIdToNewTabId = new Map(); - const skippedTabs: string[] = []; + for (const snapshot of payload.tabs) { + const target = resolveLayoutTabTarget(snapshot, allHostsRef.current); - for (const snapshot of payload.tabs) { - const target = resolveLayoutTabTarget(snapshot, allHostsRef.current); + if (target.kind === "skip") { + skippedTabs.push(snapshot.hostNameSnapshot || snapshot.label); + continue; + } - if (target.kind === "skip") { - skippedTabs.push(snapshot.hostNameSnapshot || snapshot.label); - continue; + if (target.kind === "singleton") { + openSingletonTab( + snapshot.type, + undefined, + target.host, + snapshotData(snapshot), + ); + slotIdToNewTabId.set(snapshot.slotId, snapshot.type); + continue; + } + + if (target.kind === "host") { + const newTabId = openTab(target.host, snapshot.type, { + instanceId: createId(), + restoredSessionId: null, + savedLabel: snapshot.customLabel ?? snapshot.label, + initialFilePath: snapshot.initialFilePath, + initialPath: snapshot.initialPath, + }); + slotIdToNewTabId.set(snapshot.slotId, newTabId); + } } - if (target.kind === "singleton") { - openSingletonTab( - snapshot.type, - undefined, - target.host, - snapshotData(snapshot), + const splitIdBySlotId = new Map(); + const splitIdByTabId = new Map(); + let firstSplitId: string | null = null; + resolveLayoutSplits(payload, slotIdToNewTabId).forEach((saved, index) => { + const splitTab = makeSplitTab( + createId(), + saved.label || name || splitLabel(index + 1), + saved.split, ); - slotIdToNewTabId.set(snapshot.slotId, snapshot.type); - continue; + if (saved.slotId) splitIdBySlotId.set(saved.slotId, splitTab.id); + for (const pane of listPanes(saved.split.root)) { + if (pane.tabId) splitIdByTabId.set(pane.tabId, splitTab.id); + } + firstSplitId ??= splitTab.id; + setTabs((prev) => addSplitTab(prev, splitTab)); + }); + + // A tab that now sits in a split is shown through its split. + const activeSlot = payload.activeSlotId; + const activeTabInLayout = activeSlot + ? slotIdToNewTabId.get(activeSlot) + : undefined; + const activeId = + (activeSlot ? splitIdBySlotId.get(activeSlot) : undefined) ?? + (activeTabInLayout + ? (splitIdByTabId.get(activeTabInLayout) ?? activeTabInLayout) + : undefined) ?? + firstSplitId ?? + "dashboard"; + setActiveTabId(activeId); + + // Older payloads predate the sidebar field, so leave the docks alone then. + const sidebar = payload.sidebar; + if (sidebar) { + if (sidebar.left.view) { + setRailView( + (sidebar.left.view === "split-screen" + ? "hosts" + : sidebar.left.view) as RailView, + ); + } + setSidebarOpen(sidebar.left.open); + if (sidebar.left.width) setSidebarWidth(sidebar.left.width); + + const right = sidebar.right.open ? sidebar.right.view : null; + setRightRailView( + right && rightDockableIds().includes(right) + ? (right as RailView) + : null, + ); + if (sidebar.right.width) setRightSidebarWidth(sidebar.right.width); } - if (target.kind === "host") { - const newTabId = openTab(target.host, snapshot.type, { - instanceId: crypto.randomUUID(), - restoredSessionId: null, - savedLabel: snapshot.customLabel ?? snapshot.label, - initialFilePath: snapshot.initialFilePath, - initialPath: snapshot.initialPath, - }); - slotIdToNewTabId.set(snapshot.slotId, newTabId); - } + return { skipped: skippedTabs }; + } finally { + suppressPaneTargetRef.current = false; } - - const restoredPaneIds = remapSlotIds(payload.paneTabIds, slotIdToNewTabId); - let restoredSplitTabId: string | null = null; - if (payload.splitMode !== "none" && restoredPaneIds.some(Boolean)) { - const instanceId = crypto.randomUUID(); - restoredSplitTabId = `split-${instanceId}`; - const splitTab: Tab = { - id: restoredSplitTabId, - instanceId, - type: "split-screen", - label: name, - openedAt: Date.now(), - splitConfig: createSplitConfig( - payload.splitMode, - restoredPaneIds, - payload, - ), - }; - setTabs((prev) => - assignTabsToSplit([...prev, splitTab], splitTab.id, restoredPaneIds), - ); - } - - const activeId = payload.activeSlotId - ? (slotIdToNewTabId.get(payload.activeSlotId) ?? "dashboard") - : "dashboard"; - setActiveTabId(restoredSplitTabId ?? activeId); - - // Older payloads predate the sidebar field, so leave the docks alone then. - const sidebar = payload.sidebar; - if (sidebar) { - if (sidebar.left.view) setRailView(sidebar.left.view as RailView); - setSidebarOpen(sidebar.left.open); - if (sidebar.left.width) setSidebarWidth(sidebar.left.width); - - const right = sidebar.right.open ? sidebar.right.view : null; - setRightRailView( - right && rightDockableIds().includes(right) - ? (right as RailView) - : null, - ); - if (sidebar.right.width) setRightSidebarWidth(sidebar.right.width); - } - - return { skipped: skippedTabs }; } // On load: always read saved tabs from DB so background sessions are preserved across refreshes. @@ -1365,8 +1398,8 @@ export function AppShell({ if (tabsReady) notifyShellReady(); }, [tabsReady]); - // Restore named split tabs once their child sessions have stable live ids. The old - // singleton keys are migrated once into Split #1 so existing layouts are preserved. + // Restore split tabs once their child sessions have stable live ids. The + // oldest single-split keys are migrated once into a first split. useEffect(() => { if (!tabsReady || paneLayoutRestoredRef.current) return; paneLayoutRestoredRef.current = true; @@ -1374,60 +1407,41 @@ export function AppShell({ try { const savedSplitTabs = JSON.parse( localStorage.getItem("termix_splitTabs") ?? "[]", - ) as PersistedSplitTab[]; + ) as unknown; if (Array.isArray(savedSplitTabs) && savedSplitTabs.length > 0) { setTabs((prev) => restoreSplitTabs(savedSplitTabs, prev)); - splitTabsRestoredRef.current = true; return; } - const savedInstanceIds: (string | null)[] = JSON.parse( + const savedInstanceIds = JSON.parse( localStorage.getItem("termix_paneInstanceIds") ?? "null", + ) as unknown; + const savedMode = localStorage.getItem("termix_splitMode"); + if (!Array.isArray(savedInstanceIds) || !savedMode) return; + const savedSizes = JSON.parse( + localStorage.getItem("termix_paneSizes") ?? "null", + ) as { rowSizes?: unknown; rowColSizes?: unknown } | null; + const tabIdByInstanceId = new Map( + tabs.map((tab) => [tab.instanceId, tab.id]), ); - const savedMode = localStorage.getItem("termix_splitMode") as SplitMode; - if ( - !Array.isArray(savedInstanceIds) || - !savedMode || - savedMode === "none" - ) { - splitTabsRestoredRef.current = true; - return; - } - - const restored = savedInstanceIds.map((instanceId) => { - if (instanceId == null) return null; - return tabs.find((t) => t.instanceId === instanceId)?.id ?? null; - }); - if (restored.some((id) => id != null)) { - let sizes = defaultSizes(savedMode); - try { - const savedSizes = JSON.parse( - localStorage.getItem("termix_paneSizes") ?? "null", - ) as { rowSizes?: number[]; rowColSizes?: RowColSizes } | null; - if ( - Array.isArray(savedSizes?.rowSizes) && - Array.isArray(savedSizes?.rowColSizes) - ) { - sizes = { - rowSizes: savedSizes.rowSizes, - rowColSizes: savedSizes.rowColSizes, - }; - } - } catch { - // silently fail - } - const instanceId = crypto.randomUUID(); - const id = `split-${instanceId}`; - const splitTab: Tab = { - id, - instanceId, - type: "split-screen", - label: "Split #1", - openedAt: Date.now(), - splitConfig: createSplitConfig(savedMode, restored, sizes), - }; - setTabs((prev) => assignTabsToSplit([...prev, splitTab], id, restored)); - setActiveTabId(id); + const root = fromLegacyConfig( + savedMode, + savedInstanceIds.map((instanceId) => + typeof instanceId === "string" + ? (tabIdByInstanceId.get(instanceId) ?? null) + : null, + ), + savedSizes?.rowSizes, + savedSizes?.rowColSizes, + ); + if (root && listPanes(root).some((pane) => pane.tabId)) { + const splitTab = makeSplitTab( + createId(), + splitLabel(1), + createSplitState(root), + ); + setTabs((prev) => addSplitTab(prev, splitTab)); + setActiveTabId(splitTab.id); } } catch { // silently fail @@ -1470,10 +1484,6 @@ export function AppShell({ if (tabsReady) notifyTabsChanged(); }, [ tabs, - paneTabIds, - splitMode, - rowSizes, - rowColSizes, tabsReady, railView, sidebarOpen, @@ -1511,16 +1521,12 @@ export function AppShell({ JSON.stringify(t.data ?? null) === dataKey, ); if (existing) { - setActiveTabId(existing.id); + placeOpenedTabRef.current(existing.id); return existing.id; } } const tabId = `${host.name}-${type}-${Date.now()}`; - const instanceId = - restore?.instanceId ?? - (typeof crypto.randomUUID === "function" - ? crypto.randomUUID() - : `${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}-${Math.random().toString(36).slice(2)}`); + const instanceId = restore?.instanceId ?? createId(); const openedAt = Date.now(); const ref = isSessionTabType(type) ? createRef() : undefined; if (ref) terminalRefs.current.set(tabId, ref); @@ -1597,7 +1603,7 @@ export function AppShell({ }, ]; }); - setActiveTabId(tabId); + placeOpenedTabRef.current(tabId); if (isPersistentTabType(type)) { addOpenTab({ @@ -1643,10 +1649,7 @@ export function AppShell({ /** A tab type that opens a fresh tab every time (a local shell). */ function openMultiInstanceTab(type: TabType): string { - const instanceId = - typeof crypto.randomUUID === "function" - ? crypto.randomUUID() - : `${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`; + const instanceId = createId(); const id = `${type}-${instanceId}`; const titleKey = getTabType(type)?.titleKey; const title = titleKey ? t(titleKey) : type; @@ -1663,7 +1666,7 @@ export function AppShell({ }, ]; }); - setActiveTabId(id); + placeOpenedTabRef.current(id); return id; } @@ -1750,7 +1753,7 @@ export function AppShell({ }, ]; }); - setActiveTabId(id); + placeOpenedTabRef.current(id); if (isPersistentTabType(type)) { addOpenTab({ id, @@ -1802,6 +1805,10 @@ export function AppShell({ } terminalRefs.current.delete(id); + if (isSplitTab(tabToClose)) { + unsplit(id); + return; + } if (id === activeTabId) { const remaining = tabs.filter( (tab) => tab.id !== id && !tab.parentSplitTabId, @@ -1810,26 +1817,9 @@ export function AppShell({ remaining.length > 0 ? remaining[remaining.length - 1].id : "dashboard", ); } - setPaneTabIds((prev) => prev.map((p) => (p === id ? null : p))); setTabs((prev) => { - const next = - tabToClose?.type === "split-screen" - ? releaseSplitTabs(prev, id) - : prev - .filter((tab) => tab.id !== id) - .map((tab) => - tab.type === "split-screen" && tab.splitConfig - ? { - ...tab, - splitConfig: { - ...tab.splitConfig, - paneTabIds: tab.splitConfig.paneTabIds.map((paneId) => - paneId === id ? null : paneId, - ), - }, - } - : tab, - ); + // A pane that held the tab stays, empty and focused, ready for the next one. + const next = removeTab(prev, id); if (next.length === 0) return [ { @@ -1890,8 +1880,17 @@ export function AppShell({ doCloseTab(id); } + // In a split, closing acts on the focused pane: its session, or the pane + // itself once it is empty. closeActiveTabRef.current = () => { const id = activeTabIdRef.current; + const active = tabsRef.current.find((tab) => tab.id === id); + if (isSplitTab(active)) { + const tabId = focusedSessionTabId(); + if (tabId) closeTab(tabId); + else closePaneOf(active.id, active.split.focusedPaneId); + return; + } if (id !== "dashboard") closeTab(id); }; @@ -1907,102 +1906,338 @@ export function AppShell({ } } - function splitTabQuick(tabId: string, mode: SplitMode) { - if (mode === "none") return; - const count = PANE_COUNTS[mode]; - const paneIds: (string | null)[] = Array(6).fill(null); - paneIds[0] = tabId; - let slot = 1; - for (const tab of tabs) { - if (slot >= count) break; - if ( - tab.id !== tabId && - tab.type !== "dashboard" && - tab.type !== "split-screen" && - !tab.parentSplitTabId - ) { - paneIds[slot++] = tab.id; - } - } - const splitNumber = - tabs.filter((tab) => tab.type === "split-screen").length + 1; - const instanceId = - typeof crypto.randomUUID === "function" - ? crypto.randomUUID() - : `${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}-${Math.random().toString(36).slice(2)}`; - const id = `split-${instanceId}`; - const sizes = defaultSizes(mode); - const splitTab: Tab = { - id, - instanceId, - type: "split-screen", - label: `Split #${splitNumber}`, - openedAt: Date.now(), - splitConfig: createSplitConfig(mode, paneIds, sizes), - }; - setTabs((prev) => assignTabsToSplit([...prev, splitTab], id, paneIds)); - setActiveTabId(id); - setSplitMode(mode); - setPaneTabIds(paneIds); - setRowSizes(sizes.rowSizes); - setRowColSizes(sizes.rowColSizes); - } + // ─── Split screen ──────────────────────────────────────────────────────── - function addTabToSplit(tabId: string) { - if (splitMode === "none") { - splitTabQuick(tabId, "2-way"); - return; - } - setPaneTabIds((prev) => { - // Remove from any current slot first - const next = prev.map((p) => (p === tabId ? null : p)); - // Find first empty slot within the current pane count - const count = PANE_COUNTS[splitMode]; - for (let i = 0; i < count; i++) { - if (!next[i]) { - next[i] = tabId; - break; - } - } - return next; - }); - } + const splitLabel = (number: number) => + t("splitScreen.defaultLabel", { number }); - function removeTabFromSplit(tabId: string) { - setPaneTabIds((prev) => prev.map((p) => (p === tabId ? null : p))); - } - - function selectSplitMode(mode: SplitMode) { - const active = tabs.find((tab) => tab.id === activeTabId); - if (mode === "none") { - if (active?.type === "split-screen") doCloseTab(active.id); - return; - } - if (active?.type === "split-screen") { - changeSplitMode(mode); - return; - } - if (active && active.type !== "dashboard") { - splitTabQuick(active.id, mode); - return; - } - const firstSession = tabs.find( - (tab) => - tab.type !== "dashboard" && - tab.type !== "split-screen" && - !tab.parentSplitTabId, + /** + * Edits a split with a change computed from the last committed state, for + * edits that create panes and so have to know the new pane's id. + */ + function commitSplit( + splitTabId: string, + fn: (state: SplitState) => SplitState | null, + ): SplitState | null { + const current = tabsRef.current.find((tab) => tab.id === splitTabId); + if (!isSplitTab(current)) return null; + const next = fn(current.split); + setTabs((prev) => + updateSplit(prev, splitTabId, (state) => + state === current.split ? next : fn(state), + ), ); - if (firstSession) splitTabQuick(firstSession.id, mode); + return next; } - function assignPane(paneIndex: number, tabId: string) { - setPaneTabIds((prev) => { - const next = prev.map((p) => (p === tabId ? null : p)); - next[paneIndex] = tabId || null; - return next; - }); + function editSplit( + splitTabId: string, + fn: (state: SplitState) => SplitState | null, + ) { + setTabs((prev) => updateSplit(prev, splitTabId, fn)); } + function warnSplitFull() { + toast.error(t("splitScreen.maxPanes", { count: MAX_PANES })); + } + + function openSplitTab(state: SplitState): SplitTab { + const number = nextSplitNumber(tabsRef.current, splitLabel); + const splitTab = makeSplitTab(createId(), splitLabel(number), state); + setTabs((prev) => addSplitTab(prev, splitTab)); + setActiveTabId(splitTab.id); + return splitTab; + } + + /** A new split of `tabId` (or of an empty pane) with a fresh pane at `edge`. */ + function startSplit(tabId: string | null, edge: PaneEdge): SplitTab { + const own = createPane(tabId); + const fresh = createPane(); + const leading = edge === "left" || edge === "top"; + const root = createSplitNode( + edge === "left" || edge === "right" ? "row" : "column", + leading ? [fresh, own] : [own, fresh], + ); + return openSplitTab(createSplitState(root, fresh.id)); + } + + function splitPaneOf(splitTabId: string, paneId: string, edge: PaneEdge) { + commitSplit(splitTabId, (state) => { + const result = splitPane(state, paneId, edge); + if (!result) warnSplitFull(); + return result ? result.state : state; + }); + setActiveTabId(splitTabId); + } + + /** + * Splits a tab: a split grows at its focused pane, a tab inside a split + * grows at its own pane, anything else becomes a new split. + */ + function splitTabAt(tabId: string, edge: PaneEdge) { + const current = tabsRef.current; + const tab = current.find((t) => t.id === tabId); + if (!tab) return; + if (isSplitTab(tab)) { + splitPaneOf(tab.id, tab.split.focusedPaneId, edge); + return; + } + const parent = splitTabOf(current, tabId); + const pane = parent ? findPaneByTab(parent.split, tabId) : undefined; + if (parent && pane) { + splitPaneOf(parent.id, pane.id, edge); + return; + } + startSplit(canJoinSplit(tab) ? tab.id : null, edge); + } + + function applyLayoutPreset(presetId: SplitPresetId) { + const active = tabsRef.current.find( + (tab) => tab.id === activeTabIdRef.current, + ); + if (isSplitTab(active)) { + commitSplit(active.id, (state) => applyPreset(state, presetId)); + return; + } + const root = buildPreset( + presetId, + active && canJoinSplit(active) ? [active.id] : [], + ); + openSplitTab(createSplitState(root)); + } + + /** Closes a split tab, sending its tabs back to the tab bar. */ + function unsplit(splitTabId: string) { + const split = tabsRef.current.find((tab) => tab.id === splitTabId); + if (!isSplitTab(split)) return; + const focusedTabId = + findPane(split.split, split.split.focusedPaneId)?.tabId ?? + listPanes(split.split.root).find((pane) => pane.tabId)?.tabId ?? + null; + setTabs((prev) => closeSplitTab(prev, splitTabId)); + if (activeTabIdRef.current === splitTabId) { + const fallback = tabsRef.current.filter( + (tab) => tab.id !== splitTabId && !tab.parentSplitTabId, + ); + setActiveTabId( + focusedTabId ?? fallback[fallback.length - 1]?.id ?? "dashboard", + ); + } + } + + /** Removes a pane; its tab, if any, goes back to the tab bar. */ + function closePaneOf(splitTabId: string, paneId: string) { + const split = tabsRef.current.find((tab) => tab.id === splitTabId); + if (!isSplitTab(split)) return; + if (listPanes(split.split.root).length <= 1) { + unsplit(splitTabId); + return; + } + editSplit(splitTabId, (state) => removePane(state, paneId)); + } + + function handleTabSplitAction(action: TabSplitAction) { + switch (action.kind) { + case "split": + splitTabAt(action.tabId, action.edge); + break; + case "splitActive": + splitTabAt(activeTabIdRef.current, action.edge); + break; + case "preset": + applyLayoutPreset(action.presetId); + break; + case "unsplit": + unsplit(action.splitTabId); + break; + case "addToPane": + setTabs((prev) => + placeTabInPane(prev, action.splitTabId, action.paneId, action.tabId), + ); + setActiveTabId(action.splitTabId); + break; + case "addPane": + commitSplit(action.splitTabId, (state) => { + const result = addPaneAtEdge(state, action.edge, action.tabId); + if (!result) warnSplitFull(); + return result ? result.state : state; + }); + setActiveTabId(action.splitTabId); + break; + } + } + + /** "Open in split" from the host list: aim the next tab, then open it. */ + const splitOpenerRef = useRef< + (target: SplitOpenTarget, open: () => void) => void + >(() => {}); + splitOpenerRef.current = (target, open) => { + if (target.kind === "pane") { + targetPane(target.splitTabId, target.paneId); + } else if (target.kind === "newPane") { + const split = tabsRef.current.find((tab) => tab.id === target.splitTabId); + if (!isSplitTab(split)) return; + const result = addPaneAtEdge(split.split, "right"); + if (!result) { + warnSplitFull(); + return; + } + editSplit(split.id, (state) => + state === split.split ? result.state : state, + ); + setActiveTabId(split.id); + targetPane(split.id, result.paneId, true); + } else { + const active = tabsRef.current.find( + (tab) => tab.id === activeTabIdRef.current, + ); + if (!active || !canJoinSplit(active)) return; + const splitTab = startSplit(active.id, "right"); + targetPane(splitTab.id, splitTab.split.focusedPaneId, true); + } + open(); + }; + + /** A tab or pane dropped on the main area. */ + const splitDropRef = useRef< + (source: SplitDragSource, hover: SplitDropHover) => void + >(() => {}); + splitDropRef.current = (source, hover) => { + if (source.kind === "pane") { + const toPaneId = hover.paneId; + if (!toPaneId) return; + editSplit(source.splitTabId, (state) => + movePane(state, source.paneId, toPaneId, hover.target), + ); + return; + } + const active = tabsRef.current.find( + (tab) => tab.id === activeTabIdRef.current, + ); + if (hover.paneId === null) { + if (hover.target === "center") return; + const other = + active && canJoinSplit(active) && active.id !== source.tabId + ? active.id + : null; + const dragged = createPane(source.tabId); + const rest = createPane(other); + const leading = hover.target === "left" || hover.target === "top"; + const root = createSplitNode( + hover.target === "left" || hover.target === "right" ? "row" : "column", + leading ? [dragged, rest] : [rest, dragged], + ); + openSplitTab(createSplitState(root, dragged.id)); + return; + } + if (!isSplitTab(active)) return; + const paneId = hover.paneId; + if (hover.target === "center") { + setTabs((prev) => placeTabInPane(prev, active.id, paneId, source.tabId)); + return; + } + const edge = hover.target; + commitSplit(active.id, (state) => { + const result = splitPane(state, paneId, edge, source.tabId); + if (!result) warnSplitFull(); + return result ? result.state : state; + }); + }; + + splitActionsRef.current = { + splitActive: (edge) => splitTabAt(activeTabIdRef.current, edge), + navigate: (direction) => { + const active = tabsRef.current.find( + (tab) => tab.id === activeTabIdRef.current, + ); + if (!isSplitTab(active)) return false; + const view = document.querySelector(`[data-split-view="${active.id}"]`); + if (!view) return false; + const rects: Record = {}; + view + .querySelectorAll("[data-split-pane-id]") + .forEach((el) => { + const id = el.dataset.splitPaneId; + if (id) rects[id] = el.getBoundingClientRect(); + }); + const next = neighborPane(active.split.focusedPaneId, direction, rects); + if (next) editSplit(active.id, (state) => focusPane(state, next)); + return true; + }, + zoomFocused: () => { + const active = tabsRef.current.find( + (tab) => tab.id === activeTabIdRef.current, + ); + if (!isSplitTab(active) || listPanes(active.split.root).length < 2) { + return false; + } + editSplit(active.id, (state) => toggleZoom(state, state.focusedPaneId)); + return true; + }, + closeFocusedPane: () => { + const active = tabsRef.current.find( + (tab) => tab.id === activeTabIdRef.current, + ); + if (isSplitTab(active)) { + closePaneOf(active.id, active.split.focusedPaneId); + } + }, + }; + + useEffect(() => { + const disposeOpener = setSplitOpener((target, open) => + splitOpenerRef.current(target, open), + ); + const disposeDrop = setSplitDropHandler((source, hover) => + splitDropRef.current(source, hover), + ); + const activeIsSplit = () => + isSplitTab( + tabsRef.current.find((tab) => tab.id === activeTabIdRef.current), + ); + const entries = [ + registerPaletteEntry({ + id: "core.split.right", + titleKey: "splitScreen.splitRight", + icon: Columns2, + keywords: ["split", "pane"], + scope: "global", + run: () => splitActionsRef.current?.splitActive("right"), + }), + registerPaletteEntry({ + id: "core.split.down", + titleKey: "splitScreen.splitDown", + icon: Rows2, + keywords: ["split", "pane"], + scope: "global", + run: () => splitActionsRef.current?.splitActive("bottom"), + }), + registerPaletteEntry({ + id: "core.split.zoom", + titleKey: "splitScreen.zoom", + icon: Maximize2, + keywords: ["split", "pane"], + scope: "global", + when: activeIsSplit, + run: () => void splitActionsRef.current?.zoomFocused(), + }), + registerPaletteEntry({ + id: "core.split.closePane", + titleKey: "splitScreen.closePane", + icon: X, + keywords: ["split", "pane"], + scope: "global", + when: activeIsSplit, + run: () => splitActionsRef.current?.closeFocusedPane(), + }), + ]; + return () => { + disposeOpener(); + disposeDrop(); + for (const dispose of entries) dispose(); + }; + }, []); + // ─── Rail / sidebar ────────────────────────────────────────────────────── // Moving a panel to the right dock rather than copying it: two live copies of @@ -2108,14 +2343,100 @@ export function AppShell({ return id; }, [tabs]); + const activeTab = tabs.find((tab) => tab.id === activeTabId); + const activeSplit = isSplitTab(activeTab) ? activeTab : null; + // The pane each on-screen tab sits in, for the active split only. + const paneIdByTabId = new Map(); + if (activeSplit) { + for (const pane of shownPanes(activeSplit.split, isMobile)) { + if (pane.tabId) paneIdByTabId.set(pane.tabId, pane.id); + } + } + // Changes when panes appear, move or change tab, but not on a divider drag, + // which fits the terminals itself once it ends. + const splitShapeKey = activeSplit + ? `${activeSplit.id}|${activeSplit.split.zoomedPaneId ?? ""}|${[ + ...paneIdByTabId.entries(), + ].join(",")}|${listPanes(activeSplit.split.root).length}` + : ""; + useEffect(() => { const id = resizeAllTerminals(); return () => cancelAnimationFrame(id); - }, [splitMode, sidebarWidth, sidebarOpen, rightSidebarWidth, rightRailView]); + }, [ + splitShapeKey, + sidebarWidth, + sidebarOpen, + rightSidebarWidth, + rightRailView, + ]); - const isSplit = - splitMode !== "none" && - tabs.some((tab) => tab.id === activeTabId && tab.type === "split-screen"); + // A tab inside a split is never shown on its own: activating one (tab jump, + // the connections panel, an existing session) shows its split instead, + // focused on its pane. Also falls back when the active tab is gone. + useLayoutEffect(() => { + if (!activeTab) { + if (!tabsReady) return; + const topLevel = tabs.filter((tab) => !tab.parentSplitTabId); + setActiveTabId(topLevel[topLevel.length - 1]?.id ?? "dashboard"); + return; + } + if (!activeTab.parentSplitTabId) return; + const parent = splitTabOf(tabs, activeTab.id); + if (!parent) return; + const childId = activeTab.id; + setTabs((prev) => + updateSplit(prev, parent.id, (state) => { + const pane = findPaneByTab(state, childId); + return pane ? focusPane(state, pane.id) : state; + }), + ); + setActiveTabId(parent.id); + }, [activeTab, tabs, tabsReady]); + + // Moving focus to a pane moves keyboard focus into its session, unless the + // user is in a menu or already typing in that pane. + const focusedPaneId = activeSplit?.split.focusedPaneId ?? null; + const focusedPaneTabId = focusedPaneId + ? (findPane(activeSplit!.split, focusedPaneId)?.tabId ?? null) + : null; + useEffect(() => { + if (!focusedPaneId || !focusedPaneTabId) return; + let attempts = 0; + let timer: ReturnType | undefined; + const attempt = () => { + const focused = document.activeElement as HTMLElement | null; + const paneEl = paneElsRef.current.get(focusedPaneId); + if (focused && paneEl?.contains(focused)) return; + if ( + focused?.closest?.( + '[data-split-pane-header], [role="menu"], [role="dialog"]', + ) + ) { + return; + } + const tab = tabsRef.current.find((t) => t.id === focusedPaneTabId); + const handle = tab?.terminalRef?.current as TabHandle | null | undefined; + if (handle?.focus) { + handle.focus(); + return; + } + // A session opened into the pane a moment ago may not have mounted yet. + if (attempts++ < 10) timer = setTimeout(attempt, 100); + }; + const frame = requestAnimationFrame(attempt); + return () => { + cancelAnimationFrame(frame); + if (timer) clearTimeout(timer); + }; + }, [focusedPaneId, focusedPaneTabId]); + + useEffect(() => { + publishSplitTargets({ + splits: summarizeSplits(tabs, MAX_PANES), + canStartSplit: canJoinSplit(activeTab) && !activeTab?.parentSplitTabId, + }); + }, [tabs, activeTab]); // Move each tab's stable DOM node to the right container (pane or normal-view). // This is vanilla DOM so React's portal target never changes — changing the portal @@ -2140,9 +2461,9 @@ export function AppShell({ for (const tab of tabs) { const isTerminal = !!getTabType(tab.type)?.ownBackground; const node = getTabNode(tab.id, isTerminal); - const paneIdx = isSplit ? paneTabIds.indexOf(tab.id) : -1; - const inPane = paneIdx !== -1; - const paneEl = inPane ? paneContentEls[paneIdx] : null; + const paneId = paneIdByTabId.get(tab.id); + const paneEl = paneId ? paneElsRef.current.get(paneId) : undefined; + const inPane = !!paneId; const activeInline = !inPane && tab.id === activeTabId; if (inPane && paneEl) { @@ -2211,8 +2532,8 @@ export function AppShell({ // What history/snippets/ssh-tools should act on. Falls back to the remembered // terminal when the active tab isn't one, and drops it once it's closed. - const targetTerminalTabId = terminalTabs.some((t) => t.id === activeTabId) - ? activeTabId + const targetTerminalTabId = terminalTabs.some((t) => t.id === workingTabId) + ? workingTabId : terminalTabs.some((t) => t.id === lastTerminalTabId) ? lastTerminalTabId : ""; @@ -2353,24 +2674,6 @@ export function AppShell({ )} - {railView === "split-screen" && ( -
- - tab.type !== "split-screen" && - (!tab.parentSplitTabId || - tab.parentSplitTabId === activeTabId), - )} - splitMode={splitMode} - setSplitMode={selectSplitMode} - paneTabIds={paneTabIds} - setPaneTabIds={setPaneTabIds} - onAssignPane={assignPane} - /> -
- )} - {registeredPanels.map((panel) => { const shown = railView === panel.id; // A kept-mounted panel lives in the owning dock only, so two live @@ -2510,6 +2813,83 @@ export function AppShell({ ); + function splitViewActions(splitTabId: string): SplitViewActions { + return { + focusPane: (paneId) => + editSplit(splitTabId, (state) => focusPane(state, paneId)), + resize: (nodeId, sizes) => + editSplit(splitTabId, (state) => resizeSplit(state, nodeId, sizes)), + equalize: (nodeId) => + editSplit(splitTabId, (state) => equalizeSplit(state, nodeId)), + splitPane: (paneId, edge) => splitPaneOf(splitTabId, paneId, edge), + closePane: (paneId) => closePaneOf(splitTabId, paneId), + closeSession: (tabId) => closeTab(tabId), + moveToTab: (paneId) => closePaneOf(splitTabId, paneId), + showInPane: (paneId, tabId) => { + if (tabId) { + setTabs((prev) => placeTabInPane(prev, splitTabId, paneId, tabId)); + } else { + editSplit(splitTabId, (state) => assignTab(state, paneId, null)); + } + }, + swapPanes: (firstId, secondId) => + editSplit(splitTabId, (state) => swapPanes(state, firstId, secondId)), + toggleZoom: (paneId) => + editSplit(splitTabId, (state) => toggleZoom(state, paneId)), + resizeEnd: () => resizeAllTerminals(), + }; + } + + // Picking a host for a pane always starts a new session, so it never pulls + // a session out of the tab bar or another split. + const pickerShell: TabShellCallbacks = { + ...shellCallbacks, + openTab: (host, type, options) => + shellCallbacks.openTab(host, type, { ...options, forceNewTab: true }), + }; + + function renderEmptyPane( + splitTabId: string, + paneId: string, + paneIndex: number, + ) { + return ( + !tab.parentSplitTabId && canJoinSplit(tab), + )} + hosts={allHosts} + newTabOptions={tabTypes + .filter((def) => def.multiInstance) + .map((def) => ({ + type: def.id, + label: def.titleKey ? t(def.titleKey) : def.id, + icon: def.icon, + }))} + onPickTab={(tabId) => + setTabs((prev) => placeTabInPane(prev, splitTabId, paneId, tabId)) + } + onPickHost={(host, pick: PickHostTarget) => { + targetPane(splitTabId, paneId); + if ("item" in pick) { + const item = pick.action + .items?.(host) + .find((candidate) => candidate.id === pick.item.id); + item?.run(host, pickerShell); + } else { + runHostAction(pick.action, host, pickerShell); + } + }} + onOpenType={(type) => { + targetPane(splitTabId, paneId); + openMultiInstanceTab(type); + }} + onClosePane={() => closePaneOf(splitTabId, paneId)} + /> + ); + } + const sidebarPanelContent = renderSidebarPanels(railView); // Sidebar header — shared @@ -2626,7 +3006,6 @@ export function AppShell({ { const targetTab = tabs.find((t) => t.id === tabId); @@ -2720,61 +3098,52 @@ export function AppShell({ onToggleRightDock={isMobile ? undefined : toggleRightDock} showTabNumbers={showTabNumbers} /> -
- {/* Split view — always mounted when not mobile, hidden via CSS when inactive */} - {!isMobile && ( -
+
+ {activeSplit && ( +
changeSplitMode(splitMode)} - focusedPaneIndex={focusedPaneIndex} - onTerminalResize={resizeAllTerminals} + isMobile={isMobile} + actions={splitViewActions(activeSplit.id)} onPaneContentRef={onPaneContentRef} - onPaneClick={setFocusedPaneIndex} - onAssignPane={assignPane} + renderEmptyPane={(paneId, paneIndex) => + renderEmptyPane(activeSplit.id, paneId, paneIndex) + } />
)} + {/* Normal-view container. Tab nodes are appended here (or to pane elements) by the DOM-placement effect above. React portals each tab's content into its stable per-tab node so the component is never remounted. - When split is active, shown on top only if the active tab is not in a pane. */} + Hidden while a split is active. */}
{tabsByPortalOrder.map((tab) => { const tabNode = getTabNode( tab.id, !!getTabType(tab.type)?.ownBackground, ); - const paneIdx = isSplit ? paneTabIds.indexOf(tab.id) : -1; - const inPane = paneIdx !== -1; + const paneId = paneIdByTabId.get(tab.id); + const inPane = !!paneId; const activeInline = !inPane && tab.id === activeTabId; const isFocusedPane = inPane - ? paneIdx === (focusedPaneIndex ?? 0) + ? paneId === focusedPaneId : activeInline; return createPortal( renderTabContent(tab, { shell: shellCallbacks, isVisible: inPane || activeInline, isFocusedPane, + inSplit: inPane, panelProps: { terminalTabs, targetTerminalTabId, @@ -2796,7 +3165,6 @@ export function AppShell({
diff --git a/src/ui/lib/create-id.ts b/src/ui/lib/create-id.ts new file mode 100644 index 000000000..1d7258845 --- /dev/null +++ b/src/ui/lib/create-id.ts @@ -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)}`; +} diff --git a/src/ui/lib/splitDragging.ts b/src/ui/lib/splitDragging.ts deleted file mode 100644 index 329435658..000000000 --- a/src/ui/lib/splitDragging.ts +++ /dev/null @@ -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(); -} diff --git a/src/ui/lib/theme.ts b/src/ui/lib/theme.ts index 37ff1443f..062c8a4d2 100644 --- a/src/ui/lib/theme.ts +++ b/src/ui/lib/theme.ts @@ -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 = { - 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, -}; diff --git a/src/ui/locales/en.json b/src/ui/locales/en.json index f44a08bc0..9c5beff8b 100644 --- a/src/ui/locales/en.json +++ b/src/ui/locales/en.json @@ -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", diff --git a/src/ui/plugin-host/testing-host.tsx b/src/ui/plugin-host/testing-host.tsx index 388af1507..e29110411 100644 --- a/src/ui/plugin-host/testing-host.tsx +++ b/src/ui/plugin-host/testing-host.tsx @@ -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} />, diff --git a/src/ui/shell/MobileBottomBar.tsx b/src/ui/shell/MobileBottomBar.tsx index 07bbc3ac9..32850ee06 100644 --- a/src/ui/shell/MobileBottomBar.tsx +++ b/src/ui/shell/MobileBottomBar.tsx @@ -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({
{primaryItems.map((item) => { const active = sidebarOpen && railView === item.id; - const hasDot = item.id === "split-screen" && splitMode !== "none"; const Icon = item.icon; return ( ); })} diff --git a/src/ui/shell/SplitView.tsx b/src/ui/shell/SplitView.tsx deleted file mode 100644 index 6b2cde84e..000000000 --- a/src/ui/shell/SplitView.tsx +++ /dev/null @@ -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(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 ( -
-
-
-
-
- ); -} - -function RowDivider({ - onMouseDown, - onTouchStart, -}: { - onMouseDown: (e: React.MouseEvent) => void; - onTouchStart: (e: React.TouchEvent) => void; -}) { - return ( -
-
-
-
-
- ); -} - -// ─── Pane ───────────────────────────────────────────────────────────────────── - -function PaneHeader({ - tab, - paneIndex, - isFocused, - onClear, -}: { - tab: Tab | null; - paneIndex: number; - isFocused: boolean; - onClear?: () => void; -}) { - const { t } = useTranslation(); - return ( -
- {isFocused && ( - - )} - {tab ? ( - <> - - {tabIcon(tab.type)} - - - {tab.type === "dashboard" ? "Dashboard" : tab.label} - - - - ) : ( - - {t("splitScreen.paneEmpty", { index: paneIndex + 1 })} - - )} -
- ); -} - -function EmptyPane() { - const { t } = useTranslation(); - return ( -
-
-
-
-
-
-
- {t("splitScreen.noTabAssigned")} -
- ); -} - -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 ( -
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); - }} - > - onAssignPane?.(paneIndex, "") : undefined} - /> -
- {tab ? ( -
- ) : ( - - )} - {isDragOver && ( -
- - Drop to assign - -
- )} -
- {isDragging && ( -
- )} -
- ); -}); - -// ─── 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 ( -
- {paneIndices.map((pIdx, ci) => { - const tabId = paneTabIds[pIdx]; - const tab = - tabId != null ? (tabs.find((t) => t.id === tabId) ?? null) : null; - return ( - -
- -
- {ci < paneIndices.length - 1 && ( - onColDivider(e, rowIdx, ci)} - onTouchStart={(e) => onColDividerTouch(e, rowIdx, ci)} - /> - )} -
- ); - })} -
- ); -}); - -// ─── 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 ( -
- - - {splitMode === "2-way" && ( - - )} - - {splitMode === "2-way-horizontal" && ( -
- - onRowDivider(e, 0)} - onTouchStart={(e) => onRowDividerTouch(e, 0)} - /> - -
- )} - - {splitMode === "3-way" && ( -
-
- -
- onColDivider(e, 0, 0)} - onTouchStart={(e) => onColDividerTouch(e, 0, 0)} - /> -
-
- -
- onRowDivider(e, 0)} - onTouchStart={(e) => onRowDividerTouch(e, 0)} - /> -
- -
-
-
- )} - - {splitMode === "3-way-horizontal" && ( -
-
-
- -
- onColDivider(e, 0, 0)} - onTouchStart={(e) => onColDividerTouch(e, 0, 0)} - /> -
- -
-
- onRowDivider(e, 0)} - onTouchStart={(e) => onRowDividerTouch(e, 0)} - /> -
- -
-
- )} - - {splitMode === "4-way" && ( -
- - onRowDivider(e, 0)} - onTouchStart={(e) => onRowDividerTouch(e, 0)} - /> - -
- )} - - {splitMode === "5-way" && ( -
- - onRowDivider(e, 0)} - onTouchStart={(e) => onRowDividerTouch(e, 0)} - /> - -
- )} - - {splitMode === "6-way" && ( -
- - onRowDivider(e, 0)} - onTouchStart={(e) => onRowDividerTouch(e, 0)} - /> - -
- )} -
- ); -}); diff --git a/src/ui/shell/TabBar.tsx b/src/ui/shell/TabBar.tsx index e91491ca3..884d20030 100644 --- a/src/ui/shell/TabBar.tsx +++ b/src/ui/shell/TabBar.tsx @@ -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(null); const [dragTargetIndex, setDragTargetIndex] = useState(null); const [dragPos, setDragPos] = useState<{ x: number; y: number } | null>(null); + const [draggingOut, setDraggingOut] = useState(false); const [contextTabId, setContextTabId] = useState(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 (
{ - 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 && ( - - )} - {/* In-pane (not focused) indicator: subtle dot */} - {showInPaneIndicator && ( - - )} {showTabNumbers && tab.type !== "dashboard" && ( {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({ ))} + + onSplitAction({ kind: "splitActive", edge })} + onPreset={(presetId) => onSplitAction({ kind: "preset", presetId })} + /> {onToggleRightDock && ( <> @@ -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 (
{ctxTab.label} @@ -719,53 +759,109 @@ export function TabBar({ {t("nav.renameTab")} - {ctxTab.type !== "split-screen" && ( + {isSplitTab(ctxTab) && ( <>
-
- {t("terminal.split.splitTab")} -
- {SPLIT_MODES.filter((m) => m.id !== "none").map((mode) => ( - - ))} + )} - {isSplit && ( + {joinable && ( <>
- {isInPane ? ( - - ) : hasEmptySlot ? ( - - ) : null} + + + {splits.map((split) => ( +
+
+
+ {t("splitScreen.addToSplit", { split: split.label })} +
+ {split.panes.map((pane) => ( + + ))} + {(["right", "bottom"] as const).map((edge) => ( + + ))} +
+ ))} )}
diff --git a/src/ui/shell/shell-layout.ts b/src/ui/shell/shell-layout.ts index 77d7b8b10..875f34a43 100644 --- a/src/ui/shell/shell-layout.ts +++ b/src/ui/shell/shell-layout.ts @@ -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 } { 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 | 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, +): { 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 []; +} diff --git a/src/ui/shell/split/EmptyPanePicker.tsx b/src/ui/shell/split/EmptyPanePicker.tsx new file mode 100644 index 000000000..1fdb27377 --- /dev/null +++ b/src/ui/shell/split/EmptyPanePicker.tsx @@ -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 = ( + + ); + if (items.length <= 1) return button; + return ( + + {button} + + {items.map((item) => ( + onPick({ action, item })} + > + {item.label} + + ))} + + + ); +} + +/** + * 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 ( +
+
+
+ + {t("splitScreen.emptyTitle")} + + {t("splitScreen.paneNumber", { index: paneIndex })} + + + + {t("splitScreen.emptyHint")} + +
+ +
+ + {t("splitScreen.openTabs")} + + {freeTabs.length === 0 ? ( + + {t("splitScreen.noOpenTabs")} + + ) : ( +
+ {freeTabs.map((tab) => ( + + ))} +
+ )} +
+ + {newTabOptions.length > 0 && ( +
+ + {t("splitScreen.newSession")} + +
+ {newTabOptions.map((option) => { + const Icon = option.icon; + return ( + + ); + })} +
+
+ )} + +
+ + {t("splitScreen.hosts")} + +
+ + setQuery(event.target.value)} + placeholder={t("splitScreen.searchHosts")} + aria-label={t("splitScreen.searchHosts")} + className="h-8 rounded-none pl-7 text-xs" + /> +
+ {shownHosts.length === 0 ? ( + + {t("splitScreen.noHosts")} + + ) : ( +
+ {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 ( +
+ +
+ {[...connect, ...open].map((action) => ( + onPickHost(host, target)} + /> + ))} +
+
+ ); + })} +
+ )} +
+ + +
+
+ ); +} diff --git a/src/ui/shell/split/SplitDropOverlay.tsx b/src/ui/shell/split/SplitDropOverlay.tsx new file mode 100644 index 000000000..52ede913b --- /dev/null +++ b/src/ui/shell/split/SplitDropOverlay.tsx @@ -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; +}) { + 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("[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( +
+ {hover && ( +
+ + {hover.target === "center" + ? t("splitScreen.dropHere") + : t("splitScreen.dropSplit")} + +
+ )} +
+ {drag.source.label} +
+
, + document.body, + ); +} diff --git a/src/ui/shell/split/SplitLayoutMenu.tsx b/src/ui/shell/split/SplitLayoutMenu.tsx new file mode 100644 index 000000000..545c6c957 --- /dev/null +++ b/src/ui/shell/split/SplitLayoutMenu.tsx @@ -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
; + } + return ( +
+ {node.children.map((child, index) => ( +
+ +
+ ))} +
+ ); +} + +/** A small drawing of a preset's panes. */ +export function PresetThumb({ id }: { id: SplitPresetId }) { + const node = useMemo(() => buildPreset(id), [id]); + return ; +} + +/** + * 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) => void; + onPreset: (id: SplitPresetId) => void; +}) { + const { t } = useTranslation(); + const fullTitle = canAddPane + ? undefined + : t("splitScreen.maxPanes", { count: MAX_PANES }); + return ( + + + + + + onSplit("right")} + > + + {t("splitScreen.splitRight")} + Ctrl+Shift+\ + + onSplit("bottom")} + > + + {t("splitScreen.splitDown")} + Ctrl+Shift+- + + + + {activeIsSplit + ? t("splitScreen.applyLayout") + : t("splitScreen.layouts")} + +
+ {SPLIT_PRESETS.map((preset) => ( + onPreset(preset.id)} + > +
+ +
+
+ ))} +
+ + + {t("splitScreen.shortcuts")} + +
+
+ {t("splitScreen.shortcutNavigate")} + Alt+Arrows +
+
+ {t("splitScreen.zoom")} + Ctrl+Shift+Enter +
+
+
+
+ ); +} diff --git a/src/ui/shell/split/SplitView.tsx b/src/ui/shell/split/SplitView.tsx new file mode 100644 index 000000000..edc8af336 --- /dev/null +++ b/src/ui/shell/split/SplitView.tsx @@ -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, + ) => 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; + /** Tabs outside any split, offered in each pane's switcher. */ + freeTabs: Tab[]; + paneIndexById: Map; + 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) { + 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) { + 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 ( +
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" : ""}`} + > + + {index} + + + + + + + + + {t("splitScreen.showHere")} + + {ctx.freeTabs.length === 0 && ( + + {t("splitScreen.noOpenTabs")} + + )} + {ctx.freeTabs.map((free) => ( + actions.showInPane(pane.id, free.id)} + > + {tabIcon(free.type)} + {tabLabel(free)} + + ))} + {tab && ( + <> + + actions.showInPane(pane.id, null)} + > + {t("splitScreen.clearPane")} + + + )} + + + +
+ {!ctx.isMobile && ( + <> + + + {panes.length > 1 && ( + + )} + + )} + + + + + + {tab && ( + <> + actions.moveToTab(pane.id)}> + {t("splitScreen.moveToTab")} + + actions.closeSession(tab.id)}> + {t("splitScreen.closeSession")} + + + )} + {otherPanes.length > 0 && ( + + + {t("splitScreen.swapWith")} + + + {otherPanes.map((other) => { + const otherTab = other.tabId + ? tabsById.get(other.tabId) + : undefined; + const otherIndex = paneIndexById.get(other.id) ?? 0; + return ( + actions.swapPanes(pane.id, other.id)} + > + + {otherTab + ? t("splitScreen.paneItem", { + index: otherIndex, + label: tabLabel(otherTab), + }) + : t("splitScreen.paneEmptyItem", { + index: otherIndex, + })} + + + ); + })} + + + )} + {(tab || otherPanes.length > 0) && } + actions.closePane(pane.id)} + > + {t("splitScreen.closePane")} + + + +
+
+ ); +} + +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(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 ( +
+ +
+ {tab ? ( +
+ ) : ( + ctx.renderEmptyPane(pane.id, ctx.paneIndexById.get(pane.id) ?? 0) + )} + {ctx.resizing &&
} +
+ {/* Drawn over the content, which would otherwise cover an inset ring. */} + {focused && ctx.paneCount > 1 && ( +
+ )} +
+ ); +}); + +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) { + 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) { + 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 ( +
+
{ + 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" + }`} + > +
+
+
+ ); +} + +function NodeView({ node, ctx }: { node: LayoutNode; ctx: PaneContext }) { + if (node.kind === "pane") return ; + const row = node.direction === "row"; + return ( +
+ {node.children.map((child, index) => ( + +
+ +
+ {index < node.children.length - 1 && ( + + )} +
+ ))} +
+ ); +} + +function MobilePaneStrip({ ctx }: { ctx: PaneContext }) { + const { t } = useTranslation(); + const panes = listPanes(ctx.splitTab.split.root); + return ( +
+ {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 ( + + ); + })} +
+ ); +} + +/** + * 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("[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 ( +
+ {isMobile && panes.length > 1 && } +
+ {single ? ( + + ) : ( + + )} +
+
+ ); +} diff --git a/src/ui/shell/split/split-drag.ts b/src/ui/shell/split/split-drag.ts new file mode 100644 index 000000000..7e690ad13 --- /dev/null +++ b/src/ui/shell/split/split-drag.ts @@ -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 }; + } +} diff --git a/src/ui/shell/split/split-presets.ts b/src/ui/shell/split/split-presets.ts new file mode 100644 index 000000000..a1418adfa --- /dev/null +++ b/src/ui/shell/split/split-presets.ts @@ -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 = { + "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, + }); +} diff --git a/src/ui/shell/split/split-tabs.ts b/src/ui/shell/split/split-tabs.ts new file mode 100644 index 000000000..236073a02 --- /dev/null +++ b/src/ui/shell/split/split-tabs.ts @@ -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(); + 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; + 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 & + Partial; + 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; +} diff --git a/src/ui/shell/split/split-targets.ts b/src/ui/shell/split/split-targets.ts new file mode 100644 index 000000000..71003b4f5 --- /dev/null +++ b/src/ui/shell/split/split-targets.ts @@ -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, + ); +} diff --git a/src/ui/shell/split/split-tree.ts b/src/ui/shell/split/split-tree.ts new file mode 100644 index 000000000..6a70efa6b --- /dev/null +++ b/src/ui/shell/split/split-tree.ts @@ -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(); + 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 | 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; +} diff --git a/src/ui/shell/splitTabUtils.ts b/src/ui/shell/splitTabUtils.ts deleted file mode 100644 index 41b8a21b1..000000000 --- a/src/ui/shell/splitTabUtils.ts +++ /dev/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; - paneInstanceIds: (string | null)[]; - rowSizes: number[]; - rowColSizes: number[][]; -}; - -export function createSplitConfig( - mode: Exclude, - paneTabIds: (string | null)[], - sizes: Pick, -): 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; -} diff --git a/src/ui/shell/tab-registry.ts b/src/ui/shell/tab-registry.ts index 22b096e74..fa0df8d6b 100644 --- a/src/ui/shell/tab-registry.ts +++ b/src/ui/shell/tab-registry.ts @@ -42,6 +42,7 @@ export interface TabRenderProps { label: string; isVisible: boolean; isFocusedPane: boolean; + inSplit?: boolean; handleRef: Ref; shell: TabShellCallbacks; } diff --git a/src/ui/shell/tabUtils.tsx b/src/ui/shell/tabUtils.tsx index 572fe4899..8012bfa7d 100644 --- a/src/ui/shell/tabUtils.tsx +++ b/src/ui/shell/tabUtils.tsx @@ -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} 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} /> ); diff --git a/src/ui/sidebar/AppRail.tsx b/src/ui/sidebar/AppRail.tsx index 8e9b1edca..a1b292997 100644 --- a/src/ui/sidebar/AppRail.tsx +++ b/src/ui/sidebar/AppRail.tsx @@ -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, ): RailItem[] { @@ -71,7 +69,6 @@ function buildRailButtons( view: item.id as RailView, icon: , 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} - {item.dot && ( - - )} ), )} diff --git a/src/ui/sidebar/SplitScreenPanel.tsx b/src/ui/sidebar/SplitScreenPanel.tsx deleted file mode 100644 index 3ee17a14a..000000000 --- a/src/ui/sidebar/SplitScreenPanel.tsx +++ /dev/null @@ -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 = { - none:
, - "2-way": ( -
-
-
-
- ), - "2-way-horizontal": ( -
-
-
-
- ), - "3-way": ( -
-
-
-
-
-
-
- ), - "3-way-horizontal": ( -
-
-
-
-
-
-
- ), - "4-way": ( -
-
-
-
-
-
- ), - "5-way": ( -
-
-
-
-
-
-
-
-
-
-
- ), - "6-way": ( -
-
-
-
-
-
-
-
- ), -}; - -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(null); - const [dragOverPane, setDragOverPane] = useState(null); - const [quickAssignPane, setQuickAssignPane] = useState(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 ( -
-
-
- - {t("newUi.sidebar.splitScreen.layoutTitle")} - - {splitMode !== "none" && ( - - {splitMode} - - )} -
-
- {SPLIT_MODES.map((mode) => ( - - ))} -
-
- - {splitMode === "none" ? ( -
- - - {t("newUi.sidebar.splitScreen.selectLayoutAbove")} - - - {t("newUi.sidebar.splitScreen.selectLayoutHint")} - -
- ) : ( - <> - -
-
- - {t("newUi.sidebar.splitScreen.panesTitle")} - - - {activeCount}/{paneCount} assigned - -
-
- {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 ( -
-
{ - 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" - }`} - > - - {i + 1} - - {assignedTab ? ( - <> -
- - {tabIcon(assignedTab.type)} - - - {assignedTab.type === "dashboard" - ? t("newUi.sidebar.splitScreen.dashboard") - : assignedTab.label} - -
- - - ) : ( - - {isOver - ? t("newUi.sidebar.splitScreen.dropHere") - : t("newUi.sidebar.splitScreen.emptyPane")} - - )} -
- - {/* Quick assign button */} - {!assignedTab && ( -
- - {isQuickAssignOpen && ( -
- {tabs - .filter( - (tab) => - tab.type !== "dashboard" && - !paneTabIds.includes(tab.id), - ) - .map((tab) => ( - - ))} - {tabs.filter( - (tab) => - tab.type !== "dashboard" && - !paneTabIds.includes(tab.id), - ).length === 0 && ( -
- All tabs assigned -
- )} -
- )} -
- )} -
- ); - })} -
-
- - - -
- - {t("newUi.sidebar.splitScreen.openTabsTitle")} - - - {t("newUi.sidebar.splitScreen.dragTabsHint")} - -
- {tabs.map((tab) => { - const paneIdx = paneTabIds.indexOf(tab.id); - const isAssigned = paneIdx !== -1; - return ( -
!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" - }`} - > - - {tabIcon(tab.type)} - - - {tab.type === "dashboard" - ? t("newUi.sidebar.splitScreen.dashboard") - : tab.label} - - {isAssigned ? ( - - {t("newUi.sidebar.splitScreen.alreadyAssigned", { - index: paneIdx + 1, - })} - - ) : ( -
-
-
-
-
-
- )} -
- ); - })} -
-
- - - - )} - - - -
- - {t("newUi.sidebar.splitScreen.hotkeysTitle")} - -
- {[ - { - 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 }) => ( -
- - {label} - -
- {keys.map((k, i) => ( - - {k} - - ))} -
-
- ))} -
-
-
- ); -} diff --git a/src/ui/sidebar/rail-items.ts b/src/ui/sidebar/rail-items.ts index de231967f..69702c5b3 100644 --- a/src/ui/sidebar/rail-items.ts +++ b/src/ui/sidebar/rail-items.ts @@ -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, diff --git a/src/ui/sidebar/tree/HostItem/HostItem.tsx b/src/ui/sidebar/tree/HostItem/HostItem.tsx index dfee64d6b..c9ece024b 100644 --- a/src/ui/sidebar/tree/HostItem/HostItem.tsx +++ b/src/ui/sidebar/tree/HostItem/HostItem.tsx @@ -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")} )} + {defaultAction && + (splitTargets.canStartSplit || splitTargets.splits.length > 0) && ( + + + + {t("splitScreen.openInSplit")} + + + {(() => { + const openAt = (target: SplitOpenTarget) => + openInSplit(target, () => + openHostTab(defaultAction, { forceNewTab: true }), + ); + return ( + <> + {splitTargets.canStartSplit && ( + { + e.stopPropagation(); + openAt({ kind: "newSplit" }); + }} + > + {t("splitScreen.openInNewSplit")} + + )} + {splitTargets.splits.map((split) => ( +
+ + + {split.label} + + {split.panes.map((pane) => ( + { + e.stopPropagation(); + openAt({ + kind: "pane", + splitTabId: split.id, + paneId: pane.id, + }); + }} + > + + {pane.label + ? t("splitScreen.paneItem", { + index: pane.index, + label: pane.label, + }) + : t("splitScreen.paneEmptyItem", { + index: pane.index, + })} + + + ))} + { + e.stopPropagation(); + openAt({ + kind: "newPane", + splitTabId: split.id, + }); + }} + > + {t("splitScreen.newPaneRight")} + +
+ ))} + + ); + })()} +
+
+ )} {onEditHost && ( 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); + }); +}); diff --git a/src/ui/tests/shell/SplitView.test.tsx b/src/ui/tests/shell/SplitView.test.tsx deleted file mode 100644 index 88ba96b48..000000000 --- a/src/ui/tests/shell/SplitView.test.tsx +++ /dev/null @@ -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) => - opts ? `${key}:${JSON.stringify(opts)}` : key, - }), -})); - -import { SplitView, defaultSizes } from "../../shell/SplitView"; - -function makeTab(overrides: Partial = {}): 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( - {}} - 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( - {}} - 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( - {}} - 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( - {}} - onRowColSizesChange={() => {}} - />, - ); - expect( - container.querySelectorAll('[style*="width: 50%"]').length, - ).toBeGreaterThan(0); - - rerender( - {}} - onRowColSizesChange={() => {}} - />, - ); - expect( - container.querySelectorAll('[style*="width: 20%"]').length, - ).toBeGreaterThan(0); - }); -}); diff --git a/src/ui/tests/shell/TabBar.test.tsx b/src/ui/tests/shell/TabBar.test.tsx index e925498b7..52582ace0 100644 --- a/src/ui/tests/shell/TabBar.test.tsx +++ b/src/ui/tests/shell/TabBar.test.tsx @@ -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) => + 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( - {}} - onRefreshTab={() => {}} - onReorderTabs={() => {}} - onSplitTab={() => {}} - onAddToSplit={() => {}} - onRemoveFromSplit={() => {}} - isAppFullscreen={false} - onToggleAppFullscreen={() => {}} - />, - ); +function props( + overrides: Partial> = {}, +): ComponentProps { + 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(); expect( container.querySelector('[data-tab-indicator="dashboard"]'), ).toBeTruthy(); - rerender( - {}} - onCloseTab={() => {}} - onRefreshTab={() => {}} - onReorderTabs={() => {}} - onSplitTab={() => {}} - onAddToSplit={() => {}} - onRemoveFromSplit={() => {}} - isAppFullscreen={false} - onToggleAppFullscreen={() => {}} - />, - ); + rerender(); 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(); 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> = {}) { + const onSplitAction = vi.fn(); + const view = render(); + 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( + , + ); + fireEvent.contextMenu(getByText("Split 1")); + + expect(queryByText("splitScreen.splitRight")).toBeNull(); + fireEvent.click(getByText("splitScreen.unsplit")); + expect(onSplitAction).toHaveBeenCalledWith({ + kind: "unsplit", + splitTabId: "split-1", + }); + }); +}); diff --git a/src/ui/tests/shell/shell-layout.test.ts b/src/ui/tests/shell/shell-layout.test.ts index 0f2376416..352f4d872 100644 --- a/src/ui/tests/shell/shell-layout.test.ts +++ b/src/ui/tests/shell/shell-layout.test.ts @@ -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([]); }); }); diff --git a/src/ui/tests/shell/split/SplitView.test.tsx b/src/ui/tests/shell/split/SplitView.test.tsx new file mode 100644 index 000000000..290ffb770 --- /dev/null +++ b/src/ui/tests/shell/split/SplitView.test.tsx @@ -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) => + 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) => ( +
{paneId}
+ )); + const view = render( + , + ); + 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); + }); +}); diff --git a/src/ui/tests/shell/split/split-drag.test.ts b/src/ui/tests/shell/split/split-drag.test.ts new file mode 100644 index 000000000..f4a8da80c --- /dev/null +++ b/src/ui/tests/shell/split/split-drag.test.ts @@ -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(); + }); +}); diff --git a/src/ui/tests/shell/split/split-tabs.test.ts b/src/ui/tests/shell/split/split-tabs.test.ts new file mode 100644 index 000000000..c10f1979e --- /dev/null +++ b/src/ui/tests/shell/split/split-tabs.test.ts @@ -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 { + 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); + }); +}); diff --git a/src/ui/tests/shell/split/split-tree.test.ts b/src/ui/tests/shell/split/split-tree.test.ts new file mode 100644 index 000000000..f799e3fa1 --- /dev/null +++ b/src/ui/tests/shell/split/split-tree.test.ts @@ -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(); + }); +}); diff --git a/src/ui/tests/shell/splitTabUtils.test.ts b/src/ui/tests/shell/splitTabUtils.test.ts deleted file mode 100644 index 7c371338f..000000000 --- a/src/ui/tests/shell/splitTabUtils.test.ts +++ /dev/null @@ -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"]); - }); -}); diff --git a/src/ui/tests/sidebar/AppRail.test.tsx b/src/ui/tests/sidebar/AppRail.test.tsx index a457d84d3..6b4e60edb 100644 --- a/src/ui/tests/sidebar/AppRail.test.tsx +++ b/src/ui/tests/sidebar/AppRail.test.tsx @@ -27,7 +27,6 @@ function renderRail(onRailClick = vi.fn()) { { "quick-connect", "ssh-tools", "macros", - "split-screen", "sync", ]); });