From 30a225ce9f8514bbf57e304d5637a7a5b26f40b6 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Wed, 18 Mar 2026 01:36:54 +0700 Subject: [PATCH] Add split-pane layout store and navigation shortcuts --- docs/PANEL-REFACTOR-PLAN.md | 341 +++++ docs/SPLIT-PANES-PLAN.md | 275 ++++ packages/app/src/components/resize-handle.tsx | 154 +++ .../sortable-inline-list.native.tsx | 3 + .../components/sortable-inline-list.web.tsx | 55 +- .../app/src/components/split-container.tsx | 663 +++++++++ .../app/src/components/split-drop-zone.tsx | 214 +++ .../app/src/hooks/use-keyboard-shortcuts.ts | 15 + packages/app/src/keyboard/actions.ts | 11 + .../keyboard/keyboard-action-dispatcher.ts | 26 +- .../src/keyboard/keyboard-shortcuts.test.ts | 45 + .../app/src/keyboard/keyboard-shortcuts.ts | 202 +++ .../workspace/workspace-desktop-tabs-row.tsx | 75 +- .../workspace/workspace-pane-content.tsx | 47 + .../screens/workspace/workspace-screen.tsx | 702 ++++++---- .../workspace/workspace-tab-model.test.ts | 12 +- .../screens/workspace/workspace-tab-model.ts | 49 +- .../workspace/workspace-tab-presentation.tsx | 31 +- .../src/stores/workspace-layout-store.test.ts | 610 ++++++++ .../app/src/stores/workspace-layout-store.ts | 1221 +++++++++++++++++ .../app/src/utils/split-navigation.test.ts | 83 ++ packages/app/src/utils/split-navigation.ts | 251 ++++ 22 files changed, 4710 insertions(+), 375 deletions(-) create mode 100644 docs/PANEL-REFACTOR-PLAN.md create mode 100644 docs/SPLIT-PANES-PLAN.md create mode 100644 packages/app/src/components/resize-handle.tsx create mode 100644 packages/app/src/components/split-container.tsx create mode 100644 packages/app/src/components/split-drop-zone.tsx create mode 100644 packages/app/src/screens/workspace/workspace-pane-content.tsx create mode 100644 packages/app/src/stores/workspace-layout-store.test.ts create mode 100644 packages/app/src/stores/workspace-layout-store.ts create mode 100644 packages/app/src/utils/split-navigation.test.ts create mode 100644 packages/app/src/utils/split-navigation.ts diff --git a/docs/PANEL-REFACTOR-PLAN.md b/docs/PANEL-REFACTOR-PLAN.md new file mode 100644 index 000000000..fdf9feafc --- /dev/null +++ b/docs/PANEL-REFACTOR-PLAN.md @@ -0,0 +1,341 @@ +# Panel Interface Refactor Plan + +**Goal:** Replace the hardcoded panel switch statements with a registry-based panel interface. This is a pure refactor — all product surfaces stay identical. The motivation is to prepare for split panes (VSCode-style), where each split independently renders panels. + +## The Problem + +The workspace screen (`packages/app/src/screens/workspace/workspace-screen.tsx`, ~2084 lines) has a `renderContent()` function (line 1437) that switches on `target.kind` to render each panel type with bespoke props. The same pattern repeats in: + +- `workspace-tab-model.ts` — switches on `target.kind` to build tab descriptors (labels, subtitles, status) +- `workspace-tab-presentation.tsx` — switches on kind for icons and status indicators + +Every new panel type requires editing 3+ files. This must become a registry where panels self-register. + +## Target Architecture + +### 1. PanelRegistration Interface + +```typescript +// panels/panel-registry.ts + +interface PanelDescriptor { + label: string; + subtitle: string; + titleState: "ready" | "loading"; + icon: React.ComponentType<{ size: number; color: string }>; + statusBucket: SidebarStateBucket | null; +} + +interface PanelRegistration { + kind: K; + component: React.ComponentType; + useDescriptor( + target: Extract, + context: { serverId: string; workspaceId: string }, + ): PanelDescriptor; + confirmClose?( + target: Extract, + context: { serverId: string; workspaceId: string }, + ): Promise; +} +``` + +### 2. Panel Registry + +```typescript +const panelRegistry = new Map(); + +function registerPanel(registration: PanelRegistration): void { + panelRegistry.set(registration.kind, registration); +} + +function getPanelRegistration(kind: string): PanelRegistration | undefined { + return panelRegistry.get(kind); +} +``` + +### 3. PaneContext + +Every panel gets workspace-level context via `usePaneContext()`. No prop drilling of serverId/workspaceId through panel-specific props. + +```typescript +interface PaneContextValue { + serverId: string; + workspaceId: string; + tabId: string; + target: WorkspaceTabTarget; + openTab(target: WorkspaceTabTarget): void; + closeCurrentTab(): void; + retargetCurrentTab(target: WorkspaceTabTarget): void; + openFileInWorkspace(filePath: string): void; +} +``` + +### 4. WorkspaceTabTarget stays unchanged + +```typescript +type WorkspaceTabTarget = + | { kind: "draft"; draftId: string } + | { kind: "agent"; agentId: string } + | { kind: "terminal"; terminalId: string } + | { kind: "file"; path: string }; +``` + +No store migration needed. `serverId` and `workspaceId` come from the pane context, not the target. + +## Panel Implementations + +Each panel type gets its own file that exports a `PanelRegistration`. Panels use `usePaneContext()` for workspace-level context and read their own data from stores directly. + +### Agent Panel Example + +```typescript +// panels/agent-panel.ts + +function useAgentPanelDescriptor( + target: { kind: "agent"; agentId: string }, + context: { serverId: string }, +): PanelDescriptor { + const agent = useSessionStore( + (s) => s.agentsByServer.get(context.serverId)?.get(target.agentId) ?? null, + ); + const provider = agent?.provider ?? "codex"; + const label = resolveAgentLabel(agent?.title); + return { + label: label ?? "", + subtitle: `${formatProviderLabel(provider)} agent`, + titleState: label ? "ready" : "loading", + icon: agentIconForProvider(provider), + statusBucket: agent ? deriveAgentStatusBucket(agent) : null, + }; +} + +function AgentPanel() { + const { serverId, target, openFileInWorkspace } = usePaneContext(); + invariant(target.kind === "agent", "AgentPanel requires agent target"); + return ( + + ); +} + +export const agentPanelRegistration: PanelRegistration<"agent"> = { + kind: "agent", + component: AgentPanel, + useDescriptor: useAgentPanelDescriptor, + async confirmClose(target, context) { + const agent = useSessionStore.getState().agentsByServer.get(context.serverId)?.get(target.agentId); + if (agent?.status === "running") { + return confirmDialog({ title: "Agent is still running. Close anyway?" }); + } + return true; + }, +}; +``` + +### Terminal Panel Example + +```typescript +function useTerminalPanelDescriptor( + target: { kind: "terminal"; terminalId: string }, + _context: { serverId: string; workspaceId: string }, +): PanelDescriptor { + // read terminal data from appropriate store + return { + label: "Terminal", + subtitle: "Terminal", + titleState: "ready", + icon: TerminalIcon, + statusBucket: null, + }; +} + +function TerminalPanel() { + const { serverId, workspaceId, target, openTab } = usePaneContext(); + invariant(target.kind === "terminal", "TerminalPanel requires terminal target"); + return ( + { + if (terminalId) { + openTab({ kind: "terminal", terminalId }); + } + }} + hideHeader + manageTerminalDirectorySubscription={false} + /> + ); +} +``` + +### Draft Panel Example + +```typescript +function useDraftPanelDescriptor( + _target: { kind: "draft"; draftId: string }, + _context: { serverId: string; workspaceId: string }, +): PanelDescriptor { + return { + label: "New Agent", + subtitle: "New Agent", + titleState: "ready", + icon: PencilIcon, + statusBucket: null, + }; +} + +function DraftPanel() { + const { serverId, workspaceId, tabId, target, openFileInWorkspace, retargetCurrentTab } = usePaneContext(); + invariant(target.kind === "draft", "DraftPanel requires draft target"); + return ( + { + retargetCurrentTab({ kind: "agent", agentId: agentSnapshot.id }); + }} + /> + ); +} +``` + +### File Panel Example + +```typescript +function useFilePanelDescriptor( + target: { kind: "file"; path: string }, + _context: { serverId: string; workspaceId: string }, +): PanelDescriptor { + const fileName = target.path.split("/").filter(Boolean).pop() ?? target.path; + return { + label: fileName, + subtitle: target.path, + titleState: "ready", + icon: FileTextIcon, + statusBucket: null, + }; +} + +function FilePanel() { + const { serverId, workspaceId, target } = usePaneContext(); + invariant(target.kind === "file", "FilePanel requires file target"); + return ( + + ); +} +``` + +## How the Tab Bar Uses It + +Each tab chip calls the panel's `useDescriptor` hook: + +```typescript +function TabChip({ tabId, target, serverId, workspaceId }: { + tabId: string; + target: WorkspaceTabTarget; + serverId: string; + workspaceId: string; +}) { + const registration = getPanelRegistration(target.kind); + invariant(registration, `No panel registration for kind: ${target.kind}`); + const descriptor = registration.useDescriptor(target, { serverId, workspaceId }); + return ( + } + statusBucket={descriptor.statusBucket} + /> + ); +} +``` + +## How the Workspace Screen Renders Content + +Replaces the entire `renderContent()` switch: + +```typescript +function PaneContent({ tabId, target, serverId, workspaceId }: { + tabId: string; + target: WorkspaceTabTarget; + serverId: string; + workspaceId: string; +}) { + const registration = getPanelRegistration(target.kind); + if (!registration) return null; + const Component = registration.component; + return ( + + + + ); +} +``` + +## Implementation Steps + +### Step 1: Create panel registry infrastructure + +Create the following new files: + +- `packages/app/src/panels/panel-registry.ts` — `PanelRegistration`, `PanelDescriptor` types, registry map, `registerPanel()`, `getPanelRegistration()` +- `packages/app/src/panels/pane-context.ts` — `PaneContextValue` type, React context, `PaneProvider`, `usePaneContext()` hook + +### Step 2: Create panel registration files + +Move panel-specific logic out of workspace-screen, workspace-tab-model, and workspace-tab-presentation into self-contained panel modules: + +- `packages/app/src/panels/agent-panel.ts` — agent component wrapper + `useDescriptor` + `confirmClose` +- `packages/app/src/panels/draft-panel.ts` — draft component wrapper + `useDescriptor` +- `packages/app/src/panels/terminal-panel.ts` — terminal component wrapper + `useDescriptor` +- `packages/app/src/panels/file-panel.ts` — file component wrapper + `useDescriptor` +- `packages/app/src/panels/register-panels.ts` — imports all panels, calls `registerPanel()` for each + +### Step 3: Refactor workspace-tab-model.ts + +Replace the per-kind descriptor derivation in `deriveWorkspaceTabModel()` with calls to `getPanelRegistration(target.kind).useDescriptor(...)`. + +Note: `deriveWorkspaceTabModel` is a pure function, not a hook. The `useDescriptor` hooks are called from React components (the tab bar). The model derivation may need to be restructured — the tab bar calls `useDescriptor` per tab, and the model just handles ordering and active-tab resolution. + +### Step 4: Refactor workspace-screen.tsx renderContent() + +Replace the `renderContent()` switch with `` that uses the registry. Wire up the `PaneProvider` with the action callbacks that currently live as inline functions in the workspace screen. + +### Step 5: Refactor workspace-tab-presentation.tsx + +Move icon components and status derivation into each panel's registration. The shared `WorkspaceTabIcon` component becomes a thin wrapper that calls `registration.useDescriptor()` and renders the icon from the descriptor. + +### Step 6: Verify + +- `npm run typecheck` must pass +- All existing tab behavior must work identically: open, close, reorder, retarget, keyboard shortcuts, context menus +- Mobile tab switcher must work unchanged +- No visual regressions in tab bar, icons, status indicators + +## Constraints + +- **Pure refactor** — zero user-visible behavior changes +- **No new features** — no splits, no new panel types, no new keyboard shortcuts +- **WorkspaceTabTarget stays unchanged** — no store migration +- **workspace-tabs-store.ts stays unchanged** — the store is not part of this refactor +- **Do not create index.ts barrel files** — project convention +- **Use `invariant` from `tiny-invariant`** for asserting panel target kinds +- **Use `function` declarations** — project convention (no arrow function components) +- **Use `interface` over `type` where possible** — project convention +- **Run `npm run typecheck` after every change** — project rule diff --git a/docs/SPLIT-PANES-PLAN.md b/docs/SPLIT-PANES-PLAN.md new file mode 100644 index 000000000..9896e8398 --- /dev/null +++ b/docs/SPLIT-PANES-PLAN.md @@ -0,0 +1,275 @@ +# Split Panes Plan + +**Goal:** VSCode-style split panes for the workspace screen. Users can drag tabs to edges to create horizontal/vertical splits, resize splits, and navigate between panes with keyboard shortcuts. Desktop/web only — mobile uses the same store but never creates splits (single pane). + +## Data Model + +### Core Types + +```typescript +interface SplitPane { + id: string; + tabIds: string[]; + focusedTabId: string | null; +} + +interface SplitGroup { + id: string; + direction: "horizontal" | "vertical"; + children: SplitNode[]; + sizes: number[]; // proportional, sum to 1, same length as children +} + +type SplitNode = + | { kind: "pane"; pane: SplitPane } + | { kind: "group"; group: SplitGroup }; + +interface WorkspaceLayout { + root: SplitNode; + focusedPaneId: string; +} +``` + +### Design Decisions + +- **Single store replaces the flat tab store.** The layout store owns tabs, tab order (per pane), and focused tab (per pane). No separate flat tab store. +- **Mobile is just a single-pane tree.** Same store, same code paths. Mobile never calls split operations, so the tree never grows beyond one pane. +- **Focused pane concept.** Common operations (`openTab`, `closeTab`, `focusTab`) route to the focused pane automatically. No `paneId` parameter needed for everyday use. +- **`PaneContext` doesn't need `paneId`.** Split-specific operations (drag-drop, resize) are wired directly in split UI components that know their pane ID from tree rendering. +- **Max depth: 4 levels.** +- **Proportional sizes** that sum to 1. Minimum proportion per child: 0.1 (10%). + +### Default State + +Every workspace starts with: + +```typescript +{ + root: { kind: "pane", pane: { id: "main", tabIds: [], focusedTabId: null } }, + focusedPaneId: "main", +} +``` + +### Migration + +Version 6 migration from the current flat tab store. Wraps existing `tabIds`, `tabOrder`, and `focusedTabId` into a single-pane tree. + +## Store Actions + +### Everyday Operations (pane-agnostic) + +These don't take a `paneId`. Mobile code only uses these. + +```typescript +openTab(workspaceKey: string, target: WorkspaceTabTarget): string | null; +closeTab(workspaceKey: string, tabId: string): void; +focusTab(workspaceKey: string, tabId: string): void; +retargetTab(workspaceKey: string, tabId: string, target: WorkspaceTabTarget): string | null; +reorderTabs(workspaceKey: string, tabIds: string[]): void; // within focused pane +getWorkspaceTabs(workspaceKey: string): WorkspaceTab[]; // all tabs across all panes +``` + +- `openTab` creates the tab and adds it to the focused pane. +- `closeTab` finds the tab in any pane, removes it. If that was the last tab in the pane, collapses the pane. +- `focusTab` finds the tab in any pane, focuses it and focuses that pane. + +### Split Operations (desktop only) + +```typescript +splitPane(workspaceKey: string, input: { + tabId: string; + targetPaneId: string; + position: "left" | "right" | "top" | "bottom"; +}): string | null; // new pane ID, or null if depth cap hit + +moveTabToPane(workspaceKey: string, tabId: string, toPaneId: string): void; +focusPane(workspaceKey: string, paneId: string): void; +resizeSplit(workspaceKey: string, groupId: string, sizes: number[]): void; +reorderTabsInPane(workspaceKey: string, paneId: string, tabIds: string[]): void; +``` + +## Tree Transformations + +### splitPane + +**Position mapping:** +- `left` / `right` → `horizontal` direction +- `top` / `bottom` → `vertical` direction +- `left` / `top` → new pane inserted before target +- `right` / `bottom` → new pane inserted after target + +**Optimization:** If the target pane's parent group has the same direction, insert as a sibling into that group instead of nesting. This keeps the tree flat. + +``` +Before: horizontal([A, B]) +Split B right with tab X + +Optimized: horizontal([A, B, C]) ← insert into existing group +Naive: horizontal([A, horizontal([B, C])]) ← wastes depth +``` + +**Steps:** +1. Check depth — reject if would exceed 4 levels +2. Remove `tabId` from source pane (could be same or different pane) +3. Create new pane: `{ id: generateId(), tabIds: [tabId], focusedTabId: tabId }` +4. If parent group has same direction → insert new pane adjacent to target in parent's children, split target's size proportion 50/50 between target and new pane +5. Else → replace target node with new group `{ direction, children: [target, newPane], sizes: [0.5, 0.5] }` (order based on position) +6. If source pane is now empty → collapse it +7. Set `focusedPaneId` to new pane + +### collapsePane + +Triggered when a pane's last tab is removed or moved out. + +``` +Before: horizontal([A, B, C]) sizes [0.3, 0.4, 0.3] +B loses last tab + +After: horizontal([A, C]) sizes [0.5, 0.5] (renormalized) +``` + +**Steps:** +1. Remove pane from parent group's children +2. Remove corresponding entry from parent's sizes +3. Renormalize sizes to sum to 1 +4. If parent group now has 1 child → unwrap: replace group with its single remaining child +5. Unwrap can cascade up the tree +6. Move focus to nearest sibling + +### moveTabToPane + +Tab dragged from one pane to another existing pane. + +1. Remove `tabId` from source pane's `tabIds` +2. Insert into target pane's `tabIds` at drop position (or end) +3. Set target pane's `focusedTabId` to the moved tab +4. If source pane is now empty → collapsePane +5. Set `focusedPaneId` to target pane + +### resizeSplit + +User drags a divider between panes. + +1. Find group by ID +2. Update the two adjacent sizes based on drag delta +3. Clamp each child to minimum proportion (0.1) +4. Renormalize so sizes sum to 1 + +## Keyboard Shortcuts + +| Action | Shortcut | +|---|---| +| Split right | `Cmd+\` | +| Split down | `Cmd+Shift+\` | +| Focus pane left | `Cmd+Shift+←` | +| Focus pane right | `Cmd+Shift+→` | +| Focus pane up | `Cmd+Shift+↑` | +| Focus pane down | `Cmd+Shift+↓` | +| Move tab to pane left | `Cmd+Shift+Alt+←` | +| Move tab to pane right | `Cmd+Shift+Alt+→` | +| Move tab to pane up | `Cmd+Shift+Alt+↑` | +| Move tab to pane down | `Cmd+Shift+Alt+↓` | +| Close pane | `Cmd+Shift+W` | + +Existing tab shortcuts unchanged — `Cmd+T`, `Cmd+W`, `Alt+Shift+[/]`, `Alt+1-9` — they operate on the focused pane's tabs. + +## Drag and Drop UX + +### Drop Zones + +When dragging a tab over a pane, the pane is divided into 5 drop zones: +- **Center** (inner 40%) — move tab to this pane (add to existing tab list) +- **Left edge** (leftmost 15%) — split left +- **Right edge** (rightmost 15%) — split right +- **Top edge** (topmost 15%) — split up +- **Bottom edge** (bottommost 15%) — split down + +### Overlay Preview + +On hover over a drop zone, show a semi-transparent overlay rectangle covering the half of the pane where the new split would appear. The overlay uses the theme's accent color at low opacity. + +### Cross-Pane Tab Drag + +Tabs can be dragged: +- Within a pane's tab bar → reorder (existing behavior via SortableInlineList) +- From one pane's tab bar to another pane's tab bar → move tab to that pane +- From a tab bar to a pane's drop zone → split + +When dragging the last tab out of a pane, the pane collapses after the drop completes. + +## Implementation Steps + +### Step 1: Layout Store + +Create `packages/app/src/stores/workspace-layout-store.ts`: +- `WorkspaceLayout`, `SplitNode`, `SplitPane`, `SplitGroup` types +- Zustand store with AsyncStorage persistence +- Everyday actions: `openTab`, `closeTab`, `focusTab`, `retargetTab`, `reorderTabs` +- Tree helpers: `findPaneById`, `findPaneContainingTab`, `getTreeDepth`, `collectAllTabs` +- Version 6 migration from flat tab store + +### Step 2: Migrate Workspace Screen to Layout Store + +Replace all `useWorkspaceTabsStore` usage in workspace-screen with the new layout store. Mobile and desktop both use the layout store — mobile just never splits. All existing behavior preserved. + +### Step 3: Split Tree Transformations + +Add to the layout store: +- `splitPane` with the parent-direction optimization and depth check +- `collapsePane` with unwrap cascading +- `moveTabToPane` +- `resizeSplit` + +Pure tree transformation functions, tested independently. + +### Step 4: Split Container Component + +Create `packages/app/src/components/split-container.tsx`: +- Recursive component that renders `SplitNode` +- Groups render as flex containers with direction from `SplitGroup.direction` +- Panes render tab bar + active panel content (using the panel registry) +- Resize handles between children of a group + +### Step 5: Drop Zones and Overlay + +Create `packages/app/src/components/split-drop-zone.tsx`: +- Overlay that appears during tab drag +- Divides pane into 5 zones (center + 4 edges) +- Shows preview rectangle on hover +- Calls `splitPane` or `moveTabToPane` on drop + +### Step 6: Cross-Pane Drag + +Extend the existing dnd-kit setup: +- Tab bar items remain draggable (existing) +- Pane drop zones become droppable targets +- Tab bar of other panes become droppable targets (move to pane) +- DndContext wraps the entire split container (not individual panes) + +### Step 7: Keyboard Shortcuts + +Register new actions in `keyboard/actions.ts`: +- `workspace.pane.split.right`, `workspace.pane.split.down` +- `workspace.pane.focus.left/right/up/down` +- `workspace.pane.move-tab.left/right/up/down` +- `workspace.pane.close` + +Add bindings in `keyboard-shortcuts.ts` and handlers in the workspace screen. + +### Step 8: Pane Focus Navigation + +Implement spatial navigation for `focus.left/right/up/down`: +- Walk the tree to find the focused pane's position in the layout +- Find the nearest pane in the requested direction +- Focus it + +Same logic for `move-tab` shortcuts — find adjacent pane, call `moveTabToPane`. + +## Constraints + +- Mobile stays single-pane — same store, no special casing +- Max 4 levels of nesting +- Minimum pane size: 10% of parent +- `PaneContext` interface unchanged — no `paneId` added +- Panel registry unchanged — panels don't know about splits +- Existing tab shortcuts work on focused pane, unchanged diff --git a/packages/app/src/components/resize-handle.tsx b/packages/app/src/components/resize-handle.tsx new file mode 100644 index 000000000..f6cdf8eaa --- /dev/null +++ b/packages/app/src/components/resize-handle.tsx @@ -0,0 +1,154 @@ +import { useCallback, useRef, useState } from "react"; +import { View } from "react-native"; +import { StyleSheet, useUnistyles } from "react-native-unistyles"; + +export interface ResizeHandleProps { + direction: "horizontal" | "vertical"; + groupId: string; + index: number; + sizes: number[]; + onResizeSplit: (groupId: string, sizes: number[]) => void; +} + +interface PointerState { + containerSize: number; + pointerStart: number; + leftSize: number; + rightSize: number; +} + +export function ResizeHandle({ + direction, + groupId, + index, + sizes, + onResizeSplit, +}: ResizeHandleProps) { + const { theme } = useUnistyles(); + const pointerStateRef = useRef(null); + const [hovered, setHovered] = useState(false); + + const handlePointerDown = useCallback( + (event: any) => { + const handleElement = event.currentTarget as HTMLElement | null; + const containerElement = handleElement?.parentElement ?? null; + if (!containerElement) { + return; + } + + const rect = containerElement.getBoundingClientRect(); + const containerSize = direction === "horizontal" ? rect.width : rect.height; + if (containerSize <= 0) { + return; + } + + pointerStateRef.current = { + containerSize, + pointerStart: direction === "horizontal" ? event.clientX : event.clientY, + leftSize: sizes[index] ?? 0, + rightSize: sizes[index + 1] ?? 0, + }; + + const previousCursor = document.body.style.cursor; + const nextCursor = direction === "horizontal" ? "col-resize" : "row-resize"; + document.body.style.cursor = nextCursor; + event.preventDefault(); + + function cleanup() { + pointerStateRef.current = null; + document.body.style.cursor = previousCursor; + window.removeEventListener("pointermove", handlePointerMove); + window.removeEventListener("pointerup", handlePointerUp); + } + + function handlePointerMove(moveEvent: PointerEvent) { + const pointerState = pointerStateRef.current; + if (!pointerState) { + return; + } + + const pointerCurrent = + direction === "horizontal" ? moveEvent.clientX : moveEvent.clientY; + const deltaRatio = + (pointerCurrent - pointerState.pointerStart) / pointerState.containerSize; + + const nextSizes = sizes.slice(); + nextSizes[index] = pointerState.leftSize + deltaRatio; + nextSizes[index + 1] = pointerState.rightSize - deltaRatio; + onResizeSplit(groupId, nextSizes); + } + + function handlePointerUp() { + cleanup(); + } + + window.addEventListener("pointermove", handlePointerMove); + window.addEventListener("pointerup", handlePointerUp, { once: true }); + }, + [direction, groupId, index, onResizeSplit, sizes] + ); + + return ( + { + setHovered(true); + }} + onPointerLeave={() => { + setHovered(false); + }} + > + + + ); +} + +const styles = StyleSheet.create((theme) => ({ + handle: { + position: "relative", + alignItems: "center", + justifyContent: "center", + flexShrink: 0, + backgroundColor: "transparent", + }, + handleHorizontal: { + width: 4, + alignSelf: "stretch", + }, + handleVertical: { + height: 4, + width: "100%", + }, + handleGrip: { + opacity: 0.6, + borderRadius: theme.borderRadius.full, + }, + handleGripHorizontal: { + width: 2, + height: "100%", + }, + handleGripVertical: { + width: "100%", + height: 2, + }, +})); diff --git a/packages/app/src/components/sortable-inline-list.native.tsx b/packages/app/src/components/sortable-inline-list.native.tsx index 32099a6c4..070fae26e 100644 --- a/packages/app/src/components/sortable-inline-list.native.tsx +++ b/packages/app/src/components/sortable-inline-list.native.tsx @@ -12,6 +12,9 @@ export function SortableInlineList({ onDragEnd?: (data: T[]) => void; useDragHandle?: boolean; disabled?: boolean; + externalDndContext?: boolean; + activeId?: string | null; + getItemData?: (item: T, index: number) => Record; }): ReactElement { return ( <> diff --git a/packages/app/src/components/sortable-inline-list.web.tsx b/packages/app/src/components/sortable-inline-list.web.tsx index 4996ee427..15dc6a8dd 100644 --- a/packages/app/src/components/sortable-inline-list.web.tsx +++ b/packages/app/src/components/sortable-inline-list.web.tsx @@ -33,6 +33,7 @@ function SortableItem({ activeId, useDragHandle, disabled, + itemData, }: { id: string; item: T; @@ -41,6 +42,7 @@ function SortableItem({ activeId: string | null; useDragHandle: boolean; disabled: boolean; + itemData?: Record; }): ReactElement { const { attributes, @@ -50,7 +52,7 @@ function SortableItem({ transform, transition, isDragging, - } = useSortable({ id, disabled }); + } = useSortable({ id, disabled, data: itemData }); const drag = useCallback(() => { // dnd-kit handles drag initiation via listeners @@ -107,6 +109,9 @@ export function SortableInlineList({ disabled = false, activationDistance = 8, onDragBegin, + externalDndContext = false, + activeId: externalActiveId = null, + getItemData, }: { data: T[]; keyExtractor: (item: T, index: number) => string; @@ -116,10 +121,13 @@ export function SortableInlineList({ disabled?: boolean; activationDistance?: number; onDragBegin?: () => void; + externalDndContext?: boolean; + activeId?: string | null; + getItemData?: (item: T, index: number) => Record; }): ReactElement { const [activeId, setActiveId] = useState(null); const [dragItems, setDragItems] = useState(null); - const items = dragItems ?? data; + const items = externalDndContext ? data : dragItems ?? data; const sensors = useSensors( useSensor(PointerSensor, { @@ -175,6 +183,31 @@ export function SortableInlineList({ const ids = items.map((item, index) => keyExtractor(item, index)); + const renderedItems = ( + + {items.map((item, index) => { + const id = keyExtractor(item, index); + return ( + + ); + })} + + ); + + if (externalDndContext) { + return renderedItems; + } + return ( ({ onDragStart={handleDragStart} onDragEnd={handleDragEnd} > - - {items.map((item, index) => { - const id = keyExtractor(item, index); - return ( - - ); - })} - + {renderedItems} ); } diff --git a/packages/app/src/components/split-container.tsx b/packages/app/src/components/split-container.tsx new file mode 100644 index 000000000..1fce18186 --- /dev/null +++ b/packages/app/src/components/split-container.tsx @@ -0,0 +1,663 @@ +import { Fragment, useCallback, useMemo, useState, type Dispatch, type ReactNode, type SetStateAction } from "react"; +import { + DndContext, + KeyboardSensor, + PointerSensor, + closestCenter, + pointerWithin, + useSensor, + useSensors, + type CollisionDetection, + type DragEndEvent, + type DragStartEvent, +} from "@dnd-kit/core"; +import { arrayMove, sortableKeyboardCoordinates } from "@dnd-kit/sortable"; +import { View } from "react-native"; +import { StyleSheet, useUnistyles } from "react-native-unistyles"; +import { ResizeHandle } from "@/components/resize-handle"; +import { SplitDropZone, type SplitDropZoneHover } from "@/components/split-drop-zone"; +import { WorkspacePaneContent } from "@/screens/workspace/workspace-pane-content"; +import { WorkspaceDesktopTabsRow } from "@/screens/workspace/workspace-desktop-tabs-row"; +import { deriveWorkspaceTabModel } from "@/screens/workspace/workspace-tab-model"; +import type { WorkspaceTabDescriptor } from "@/screens/workspace/workspace-tabs-types"; +import type { SplitNode, SplitPane, WorkspaceLayout } from "@/stores/workspace-layout-store"; +import type { WorkspaceTab, WorkspaceTabTarget } from "@/stores/workspace-tabs-store"; + +interface SplitContainerProps { + layout: WorkspaceLayout; + workspaceKey: string; + normalizedServerId: string; + normalizedWorkspaceId: string; + uiTabs: WorkspaceTab[]; + hoveredCloseTabKey: string | null; + setHoveredTabKey: Dispatch>; + setHoveredCloseTabKey: Dispatch>; + isArchivingAgent: (input: { serverId: string; agentId: string }) => boolean; + killTerminalPending: boolean; + killTerminalId: string | null; + onNavigateTab: (tabId: string) => void; + onCloseTab: (tabId: string) => Promise | void; + onCopyResumeCommand: (agentId: string) => Promise | void; + onCopyAgentId: (agentId: string) => Promise | void; + onCloseTabsToLeft: (tabId: string, paneTabs: WorkspaceTabDescriptor[]) => Promise | void; + onCloseTabsToRight: (tabId: string, paneTabs: WorkspaceTabDescriptor[]) => Promise | void; + onCloseOtherTabs: (tabId: string, paneTabs: WorkspaceTabDescriptor[]) => Promise | void; + onSelectNewTabOption: (optionId: "__new_tab_agent__") => void; + newTabAgentOptionId?: "__new_tab_agent__"; + onOpenPaneTab: (input: { paneId: string; target: WorkspaceTabTarget }) => void; + onRetargetTab: (tabId: string, target: WorkspaceTabTarget) => void; + onOpenWorkspaceFile: (input: { paneId: string; filePath: string }) => void; + onFocusPane: (paneId: string) => void; + onSplitPane: (input: { + tabId: string; + targetPaneId: string; + position: "left" | "right" | "top" | "bottom"; + }) => void; + onMoveTabToPane: (tabId: string, toPaneId: string) => void; + onResizeSplit: (groupId: string, sizes: number[]) => void; + onReorderTabsInPane: (paneId: string, tabIds: string[]) => void; + renderPaneEmptyState?: () => ReactNode; +} + +interface WorkspaceTabDragData { + kind: "workspace-tab"; + paneId: string; + tabId: string; +} + +interface SplitPaneDropData { + kind: "split-pane-drop"; + paneId: string; +} + +interface SplitNodeViewProps + extends Omit { + node: SplitNode; + tabsById: Map; + focusedPaneId: string; + activeDragTabId: string | null; + showDropZones: boolean; + dropPreview: SplitDropZoneHover | null; + onDropPreviewChange: (hover: SplitDropZoneHover | null) => void; +} + +interface SplitPaneViewProps + extends Omit< + SplitNodeViewProps, + | "node" + | "tabsById" + | "focusedPaneId" + | "activeDragTabId" + | "showDropZones" + | "dropPreview" + | "onDropPreviewChange" + | "onSplitPane" + | "onMoveTabToPane" + | "onResizeSplit" + > { + pane: SplitPane; + tabsById: Map; + focused: boolean; + activeDragTabId: string | null; + showDropZones: boolean; + dropPreview: SplitDropZoneHover | null; + onDropPreviewChange: (hover: SplitDropZoneHover | null) => void; +} + +const dropCollisionDetection: CollisionDetection = (args) => { + const pointerHits = pointerWithin(args); + const tabHits = pointerHits.filter( + (entry) => entry.data?.droppableContainer.data.current?.kind === "workspace-tab" + ); + if (tabHits.length > 0) { + return tabHits; + } + + const paneHits = pointerHits.filter( + (entry) => entry.data?.droppableContainer.data.current?.kind === "split-pane-drop" + ); + if (paneHits.length > 0) { + return paneHits; + } + + return closestCenter(args); +}; + +export function SplitContainer({ + layout, + normalizedServerId, + normalizedWorkspaceId, + uiTabs, + hoveredCloseTabKey, + setHoveredTabKey, + setHoveredCloseTabKey, + isArchivingAgent, + killTerminalPending, + killTerminalId, + onNavigateTab, + onCloseTab, + onCopyResumeCommand, + onCopyAgentId, + onCloseTabsToLeft, + onCloseTabsToRight, + onCloseOtherTabs, + onSelectNewTabOption, + newTabAgentOptionId = "__new_tab_agent__", + onOpenPaneTab, + onRetargetTab, + onOpenWorkspaceFile, + onFocusPane, + onSplitPane, + onMoveTabToPane, + onResizeSplit, + onReorderTabsInPane, + renderPaneEmptyState = () => null, +}: SplitContainerProps) { + const [activeDragTabId, setActiveDragTabId] = useState(null); + const [dropPreview, setDropPreview] = useState(null); + + const sensors = useSensors( + useSensor(PointerSensor, { + activationConstraint: { + distance: 8, + }, + }), + useSensor(KeyboardSensor, { + coordinateGetter: sortableKeyboardCoordinates, + }) + ); + + const tabsById = useMemo(() => { + const next = new Map(); + for (const tab of uiTabs) { + next.set(tab.tabId, tab); + } + return next; + }, [uiTabs]); + + const panesById = useMemo(() => collectPanesById(layout.root), [layout.root]); + + const handleDragStart = useCallback((event: DragStartEvent) => { + const data = event.active.data.current as WorkspaceTabDragData | undefined; + if (data?.kind !== "workspace-tab") { + setActiveDragTabId(null); + setDropPreview(null); + return; + } + setActiveDragTabId(data.tabId); + }, []); + + const handleDragCancel = useCallback(() => { + setActiveDragTabId(null); + setDropPreview(null); + }, []); + + const handleDragEnd = useCallback( + (event: DragEndEvent) => { + const activeData = event.active.data.current as WorkspaceTabDragData | undefined; + const overData = event.over?.data.current as + | WorkspaceTabDragData + | SplitPaneDropData + | undefined; + + setActiveDragTabId(null); + + if (activeData?.kind !== "workspace-tab" || !event.over) { + setDropPreview(null); + return; + } + + if (overData?.kind === "workspace-tab") { + const sourcePane = panesById.get(activeData.paneId) ?? null; + const targetPane = panesById.get(overData.paneId) ?? null; + if (!sourcePane || !targetPane) { + setDropPreview(null); + return; + } + + const sourceTabs = getPaneTabDescriptors(sourcePane, tabsById); + const targetTabs = getPaneTabDescriptors(targetPane, tabsById); + const sourceIndex = sourceTabs.findIndex((tab) => tab.tabId === activeData.tabId); + const targetIndex = targetTabs.findIndex((tab) => tab.tabId === overData.tabId); + if (sourceIndex < 0 || targetIndex < 0) { + setDropPreview(null); + return; + } + + if (activeData.paneId === overData.paneId) { + if (sourceIndex !== targetIndex) { + const nextTabs = arrayMove(sourceTabs, sourceIndex, targetIndex); + onReorderTabsInPane(activeData.paneId, nextTabs.map((tab) => tab.tabId)); + } + setDropPreview(null); + return; + } + + const nextTargetTabIds = targetTabs.map((tab) => tab.tabId); + nextTargetTabIds.splice(targetIndex, 0, activeData.tabId); + onMoveTabToPane(activeData.tabId, overData.paneId); + onReorderTabsInPane(overData.paneId, nextTargetTabIds); + setDropPreview(null); + return; + } + + if (overData?.kind === "split-pane-drop" && dropPreview?.paneId === overData.paneId) { + if (dropPreview.position === "center") { + if (activeData.paneId !== overData.paneId) { + onMoveTabToPane(activeData.tabId, overData.paneId); + } + setDropPreview(null); + return; + } + + onSplitPane({ + tabId: activeData.tabId, + targetPaneId: overData.paneId, + position: dropPreview.position, + }); + } + + setDropPreview(null); + }, + [dropPreview, onMoveTabToPane, onReorderTabsInPane, onSplitPane, panesById, tabsById] + ); + + return ( + + + + ); +} + +function SplitNodeView({ + node, + tabsById, + focusedPaneId, + normalizedServerId, + normalizedWorkspaceId, + hoveredCloseTabKey, + setHoveredTabKey, + setHoveredCloseTabKey, + isArchivingAgent, + killTerminalPending, + killTerminalId, + onNavigateTab, + onCloseTab, + onCopyResumeCommand, + onCopyAgentId, + onCloseTabsToLeft, + onCloseTabsToRight, + onCloseOtherTabs, + onSelectNewTabOption, + newTabAgentOptionId, + onOpenPaneTab, + onRetargetTab, + onOpenWorkspaceFile, + onFocusPane, + onSplitPane, + onMoveTabToPane, + onResizeSplit, + onReorderTabsInPane, + renderPaneEmptyState, + activeDragTabId, + showDropZones, + dropPreview, + onDropPreviewChange, +}: SplitNodeViewProps) { + if (node.kind === "pane") { + return ( + + ); + } + + return ( + + {node.group.children.map((child, index) => ( + + + + + {index < node.group.children.length - 1 ? ( + + ) : null} + + ))} + + ); +} + +function SplitPaneView({ + pane, + tabsById, + focused, + normalizedServerId, + normalizedWorkspaceId, + hoveredCloseTabKey, + setHoveredTabKey, + setHoveredCloseTabKey, + isArchivingAgent, + killTerminalPending, + killTerminalId, + onNavigateTab, + onCloseTab, + onCopyResumeCommand, + onCopyAgentId, + onCloseTabsToLeft, + onCloseTabsToRight, + onCloseOtherTabs, + onSelectNewTabOption, + newTabAgentOptionId, + onOpenPaneTab, + onRetargetTab, + onOpenWorkspaceFile, + onFocusPane, + onReorderTabsInPane, + renderPaneEmptyState, + activeDragTabId, + showDropZones, + dropPreview, + onDropPreviewChange, +}: SplitPaneViewProps) { + const { theme } = useUnistyles(); + const paneTabs = useMemo(() => getPaneTabDescriptors(pane, tabsById), [pane, tabsById]); + const paneModel = useMemo( + () => + deriveWorkspaceTabModel({ + tabs: paneTabs.map((tab) => ({ + tabId: tab.tabId, + target: tab.target, + createdAt: 0, + })), + focusedTabId: pane.focusedTabId, + }), + [pane.focusedTabId, paneTabs] + ); + const activeTabDescriptor = paneModel.activeTab + ? paneTabs.find((tab) => tab.tabId === paneModel.activeTab?.descriptor.tabId) ?? null + : null; + + return ( + { + onFocusPane(pane.id); + }} + > + + onCloseTabsToLeft(tabId, paneTabs)} + onCloseTabsToRight={(tabId) => onCloseTabsToRight(tabId, paneTabs)} + onCloseOtherTabs={(tabId) => onCloseOtherTabs(tabId, paneTabs)} + onSelectNewTabOption={onSelectNewTabOption} + newTabAgentOptionId={newTabAgentOptionId ?? "__new_tab_agent__"} + onReorderTabs={(nextTabs) => { + onReorderTabsInPane(pane.id, nextTabs.map((tab) => tab.tabId)); + }} + externalDndContext + activeDragTabId={activeDragTabId} + /> + + + + {activeTabDescriptor ? ( + { + onOpenPaneTab({ + paneId: pane.id, + target, + }); + }} + onCloseCurrentTab={() => { + void onCloseTab(activeTabDescriptor.tabId); + }} + onRetargetCurrentTab={(target) => { + onRetargetTab(activeTabDescriptor.tabId, target); + }} + onOpenWorkspaceFile={(filePath) => { + onOpenWorkspaceFile({ + paneId: pane.id, + filePath, + }); + }} + /> + ) : ( + renderPaneEmptyState?.() ?? null + )} + + + + + ); +} + +function collectPanesById(node: SplitNode): Map { + const next = new Map(); + function visit(current: SplitNode) { + if (current.kind === "pane") { + next.set(current.pane.id, current.pane); + return; + } + for (const child of current.group.children) { + visit(child); + } + } + visit(node); + return next; +} + +function getPaneTabDescriptors( + pane: SplitPane, + tabsById: Map +): WorkspaceTabDescriptor[] { + const next: WorkspaceTabDescriptor[] = []; + for (const tabId of pane.tabIds) { + const tab = tabsById.get(tabId); + if (!tab) { + continue; + } + next.push({ + key: tab.tabId, + tabId: tab.tabId, + kind: tab.target.kind, + target: tab.target, + }); + } + return next; +} + +function getNodeKey(node: SplitNode): string { + if (node.kind === "pane") { + return node.pane.id; + } + return node.group.id; +} + +const styles = StyleSheet.create((theme) => ({ + group: { + flex: 1, + minWidth: 0, + minHeight: 0, + }, + groupHorizontal: { + flexDirection: "row", + }, + groupVertical: { + flexDirection: "column", + }, + groupChild: { + flexBasis: 0, + minWidth: 0, + minHeight: 0, + }, + pane: { + position: "relative", + flex: 1, + minWidth: 0, + minHeight: 0, + backgroundColor: theme.colors.surface0, + borderWidth: 1, + overflow: "hidden", + }, + paneTabs: { + borderTopWidth: 2, + borderTopColor: "transparent", + }, + paneContent: { + flex: 1, + minWidth: 0, + minHeight: 0, + }, +})); diff --git a/packages/app/src/components/split-drop-zone.tsx b/packages/app/src/components/split-drop-zone.tsx new file mode 100644 index 000000000..4d0cb2c83 --- /dev/null +++ b/packages/app/src/components/split-drop-zone.tsx @@ -0,0 +1,214 @@ +import { useCallback, useMemo, useState } from "react"; +import { useDroppable } from "@dnd-kit/core"; +import { View, type LayoutChangeEvent } from "react-native"; +import { StyleSheet, useUnistyles } from "react-native-unistyles"; + +export type SplitDropZonePosition = "center" | "left" | "right" | "top" | "bottom"; + +export interface SplitDropZoneHover { + paneId: string; + position: SplitDropZonePosition; +} + +export interface SplitDropZoneProps { + paneId: string; + active: boolean; + preview: SplitDropZoneHover | null; + onHoverChange: (hover: SplitDropZoneHover | null) => void; +} + +interface LayoutSize { + width: number; + height: number; +} + +const EDGE_RATIO = 0.15; +const CENTER_RATIO = 0.4; + +export function buildSplitDropZoneId(paneId: string): string { + return `split-pane-drop:${paneId}`; +} + +export function SplitDropZone({ + paneId, + active, + preview, + onHoverChange, +}: SplitDropZoneProps) { + const { theme } = useUnistyles(); + const [layoutSize, setLayoutSize] = useState({ width: 0, height: 0 }); + const { setNodeRef, isOver } = useDroppable({ + id: buildSplitDropZoneId(paneId), + disabled: !active, + data: { + kind: "split-pane-drop", + paneId, + }, + }); + + const handleLayout = useCallback((event: LayoutChangeEvent) => { + const width = Math.round(event.nativeEvent.layout.width); + const height = Math.round(event.nativeEvent.layout.height); + setLayoutSize((current) => + current.width === width && current.height === height ? current : { width, height } + ); + }, []); + + const updateHover = useCallback( + (event: any) => { + if (!active || layoutSize.width <= 0 || layoutSize.height <= 0) { + return; + } + const locationX = Number(event.nativeEvent.locationX ?? 0); + const locationY = Number(event.nativeEvent.locationY ?? 0); + onHoverChange({ + paneId, + position: resolveDropPosition({ + width: layoutSize.width, + height: layoutSize.height, + x: locationX, + y: locationY, + }), + }); + }, + [active, layoutSize.height, layoutSize.width, onHoverChange, paneId] + ); + + const previewStyle = useMemo(() => { + if (!preview || preview.paneId !== paneId) { + return null; + } + return [ + styles.preview, + getPreviewStyle(preview.position), + { + backgroundColor: theme.colors.accent, + }, + ]; + }, [paneId, preview, theme.colors.accent]); + + if (!active) { + return null; + } + + return ( + { + if (preview?.paneId === paneId) { + onHoverChange(null); + } + }} + > + {previewStyle ? : null} + + ); +} + +function resolveDropPosition(input: { + width: number; + height: number; + x: number; + y: number; +}): SplitDropZonePosition { + const centerInsetX = input.width * ((1 - CENTER_RATIO) / 2); + const centerInsetY = input.height * ((1 - CENTER_RATIO) / 2); + const insideCenterX = + input.x >= centerInsetX && input.x <= input.width - centerInsetX; + const insideCenterY = + input.y >= centerInsetY && input.y <= input.height - centerInsetY; + + if (insideCenterX && insideCenterY) { + return "center"; + } + + const edgeThresholdX = input.width * EDGE_RATIO; + const edgeThresholdY = input.height * EDGE_RATIO; + if (input.x <= edgeThresholdX) { + return "left"; + } + if (input.x >= input.width - edgeThresholdX) { + return "right"; + } + if (input.y <= edgeThresholdY) { + return "top"; + } + if (input.y >= input.height - edgeThresholdY) { + return "bottom"; + } + + const distances = [ + { position: "left", distance: input.x }, + { position: "right", distance: input.width - input.x }, + { position: "top", distance: input.y }, + { position: "bottom", distance: input.height - input.y }, + ] satisfies Array<{ position: Exclude; distance: number }>; + distances.sort((left, right) => left.distance - right.distance); + return distances[0]?.position ?? "center"; +} + +function getPreviewStyle(position: SplitDropZonePosition) { + if (position === "left") { + return styles.previewLeft; + } + if (position === "right") { + return styles.previewRight; + } + if (position === "top") { + return styles.previewTop; + } + if (position === "bottom") { + return styles.previewBottom; + } + return styles.previewCenter; +} + +const styles = StyleSheet.create((theme) => ({ + overlay: { + ...StyleSheet.absoluteFillObject, + zIndex: 40, + }, + overlayActive: { + backgroundColor: theme.colors.surface0, + opacity: 0.02, + }, + preview: { + position: "absolute", + borderRadius: theme.borderRadius.md, + opacity: 0.16, + }, + previewLeft: { + left: 0, + top: 0, + bottom: 0, + width: "50%", + }, + previewRight: { + right: 0, + top: 0, + bottom: 0, + width: "50%", + }, + previewTop: { + left: 0, + top: 0, + right: 0, + height: "50%", + }, + previewBottom: { + left: 0, + right: 0, + bottom: 0, + height: "50%", + }, + previewCenter: { + left: "30%", + top: "30%", + right: "30%", + bottom: "30%", + }, +})); diff --git a/packages/app/src/hooks/use-keyboard-shortcuts.ts b/packages/app/src/hooks/use-keyboard-shortcuts.ts index c566cb86d..883c55ea9 100644 --- a/packages/app/src/hooks/use-keyboard-shortcuts.ts +++ b/packages/app/src/hooks/use-keyboard-shortcuts.ts @@ -165,6 +165,21 @@ export function useKeyboardShortcuts({ scope: "workspace", delta: input.payload.delta, }); + case "workspace.pane.split.right": + case "workspace.pane.split.down": + case "workspace.pane.focus.left": + case "workspace.pane.focus.right": + case "workspace.pane.focus.up": + case "workspace.pane.focus.down": + case "workspace.pane.move-tab.left": + case "workspace.pane.move-tab.right": + case "workspace.pane.move-tab.up": + case "workspace.pane.move-tab.down": + case "workspace.pane.close": + return keyboardActionDispatcher.dispatch({ + id: input.action, + scope: "workspace", + }); case "workspace.navigate.index": if (!input.payload || typeof input.payload !== "object" || !("index" in input.payload)) { return false; diff --git a/packages/app/src/keyboard/actions.ts b/packages/app/src/keyboard/actions.ts index 62f64ef3d..1925f1226 100644 --- a/packages/app/src/keyboard/actions.ts +++ b/packages/app/src/keyboard/actions.ts @@ -18,6 +18,17 @@ export type KeyboardActionId = | "workspace.tab.close.current" | "workspace.tab.navigate.index" | "workspace.tab.navigate.relative" + | "workspace.pane.split.right" + | "workspace.pane.split.down" + | "workspace.pane.focus.left" + | "workspace.pane.focus.right" + | "workspace.pane.focus.up" + | "workspace.pane.focus.down" + | "workspace.pane.move-tab.left" + | "workspace.pane.move-tab.right" + | "workspace.pane.move-tab.up" + | "workspace.pane.move-tab.down" + | "workspace.pane.close" | "workspace.navigate.index" | "workspace.navigate.relative" | "sidebar.toggle.left" diff --git a/packages/app/src/keyboard/keyboard-action-dispatcher.ts b/packages/app/src/keyboard/keyboard-action-dispatcher.ts index 708c2a03e..67a26af9b 100644 --- a/packages/app/src/keyboard/keyboard-action-dispatcher.ts +++ b/packages/app/src/keyboard/keyboard-action-dispatcher.ts @@ -13,7 +13,18 @@ export type KeyboardActionId = | "workspace.tab.new" | "workspace.tab.close-current" | "workspace.tab.navigate-index" - | "workspace.tab.navigate-relative"; + | "workspace.tab.navigate-relative" + | "workspace.pane.split.right" + | "workspace.pane.split.down" + | "workspace.pane.focus.left" + | "workspace.pane.focus.right" + | "workspace.pane.focus.up" + | "workspace.pane.focus.down" + | "workspace.pane.move-tab.left" + | "workspace.pane.move-tab.right" + | "workspace.pane.move-tab.up" + | "workspace.pane.move-tab.down" + | "workspace.pane.close"; export type KeyboardActionDefinition = | { id: "message-input.focus"; scope: KeyboardActionScope } @@ -24,7 +35,18 @@ export type KeyboardActionDefinition = | { id: "workspace.tab.new"; scope: KeyboardActionScope } | { id: "workspace.tab.close-current"; scope: KeyboardActionScope } | { id: "workspace.tab.navigate-index"; scope: KeyboardActionScope; index: number } - | { id: "workspace.tab.navigate-relative"; scope: KeyboardActionScope; delta: 1 | -1 }; + | { id: "workspace.tab.navigate-relative"; scope: KeyboardActionScope; delta: 1 | -1 } + | { id: "workspace.pane.split.right"; scope: KeyboardActionScope } + | { id: "workspace.pane.split.down"; scope: KeyboardActionScope } + | { id: "workspace.pane.focus.left"; scope: KeyboardActionScope } + | { id: "workspace.pane.focus.right"; scope: KeyboardActionScope } + | { id: "workspace.pane.focus.up"; scope: KeyboardActionScope } + | { id: "workspace.pane.focus.down"; scope: KeyboardActionScope } + | { id: "workspace.pane.move-tab.left"; scope: KeyboardActionScope } + | { id: "workspace.pane.move-tab.right"; scope: KeyboardActionScope } + | { id: "workspace.pane.move-tab.up"; scope: KeyboardActionScope } + | { id: "workspace.pane.move-tab.down"; scope: KeyboardActionScope } + | { id: "workspace.pane.close"; scope: KeyboardActionScope }; export type KeyboardActionHandler = { handlerId: string; diff --git a/packages/app/src/keyboard/keyboard-shortcuts.test.ts b/packages/app/src/keyboard/keyboard-shortcuts.test.ts index 15acafab2..f7a38a1b9 100644 --- a/packages/app/src/keyboard/keyboard-shortcuts.test.ts +++ b/packages/app/src/keyboard/keyboard-shortcuts.test.ts @@ -169,6 +169,42 @@ describe("keyboard-shortcuts", () => { context: { isMac: true, isTauri: true }, action: "workspace.tab.close.current", }, + { + name: "matches Cmd+Backslash to split pane right on macOS", + event: { key: "\\", code: "Backslash", metaKey: true }, + context: { isMac: true }, + action: "workspace.pane.split.right", + }, + { + name: "matches Cmd+Shift+Backslash to split pane down on macOS", + event: { key: "|", code: "Backslash", metaKey: true, shiftKey: true }, + context: { isMac: true }, + action: "workspace.pane.split.down", + }, + { + name: "matches Cmd+Shift+ArrowRight to focus pane right on macOS", + event: { key: "ArrowRight", code: "ArrowRight", metaKey: true, shiftKey: true }, + context: { isMac: true }, + action: "workspace.pane.focus.right", + }, + { + name: "matches Cmd+Shift+Alt+ArrowDown to move tab down on macOS", + event: { + key: "ArrowDown", + code: "ArrowDown", + metaKey: true, + shiftKey: true, + altKey: true, + }, + context: { isMac: true }, + action: "workspace.pane.move-tab.down", + }, + { + name: "matches Cmd+Shift+W to close pane on macOS", + event: { key: "W", code: "KeyW", metaKey: true, shiftKey: true }, + context: { isMac: true }, + action: "workspace.pane.close", + }, { name: "matches Cmd+B sidebar toggle on macOS", event: { key: "b", code: "KeyB", metaKey: true }, @@ -242,6 +278,11 @@ describe("keyboard-shortcuts", () => { event: { key: "d", code: "KeyD", metaKey: true }, context: { isMac: true, focusScope: "terminal" }, }, + { + name: "does not bind pane shortcuts on non-mac platforms", + event: { key: "\\", code: "Backslash", ctrlKey: true }, + context: { isMac: false }, + }, { name: "keeps space typing available in message input", event: { key: " ", code: "Space" }, @@ -278,6 +319,8 @@ describe("keyboard-shortcut help sections", () => { "workspace-jump-index": ["alt", "1-9"], "workspace-tab-jump-index": ["alt", "shift", "1-9"], "workspace-tab-close-current": ["alt", "shift", "W"], + "workspace-pane-split-right": ["mod", "\\"], + "workspace-pane-close": ["mod", "shift", "W"], }, }, { @@ -289,6 +332,8 @@ describe("keyboard-shortcut help sections", () => { "workspace-jump-index": ["mod", "1-9"], "workspace-tab-jump-index": ["alt", "1-9"], "workspace-tab-close-current": ["mod", "W"], + "workspace-pane-split-right": ["mod", "\\"], + "workspace-pane-close": ["mod", "shift", "W"], }, }, { diff --git a/packages/app/src/keyboard/keyboard-shortcuts.ts b/packages/app/src/keyboard/keyboard-shortcuts.ts index cb19e7372..09bc44e41 100644 --- a/packages/app/src/keyboard/keyboard-shortcuts.ts +++ b/packages/app/src/keyboard/keyboard-shortcuts.ts @@ -71,6 +71,10 @@ function isMod(event: KeyboardEvent): boolean { return event.metaKey || event.ctrlKey; } +function isMacCommand(event: KeyboardEvent): boolean { + return event.metaKey && !event.ctrlKey; +} + function parseDigit(event: KeyboardEvent): number | null { const code = event.code ?? ""; if (code.startsWith("Digit")) { @@ -373,6 +377,204 @@ const SHORTCUT_BINDINGS: readonly KeyboardShortcutBinding[] = [ keys: ["alt", "shift", "]"], }, }, + { + id: "workspace-pane-split-right-cmd-backslash", + action: "workspace.pane.split.right", + matches: (event) => + isMacCommand(event) && + !event.altKey && + !event.shiftKey && + event.code === "Backslash", + when: (context) => + context.isMac && context.focusScope !== "terminal" && !context.commandCenterOpen, + help: { + id: "workspace-pane-split-right", + section: "global", + label: "Split pane right", + keys: ["mod", "\\"], + when: (context) => context.isMac, + }, + }, + { + id: "workspace-pane-split-down-cmd-shift-backslash", + action: "workspace.pane.split.down", + matches: (event) => + isMacCommand(event) && + !event.altKey && + event.shiftKey && + event.code === "Backslash", + when: (context) => + context.isMac && context.focusScope !== "terminal" && !context.commandCenterOpen, + help: { + id: "workspace-pane-split-down", + section: "global", + label: "Split pane down", + keys: ["mod", "shift", "\\"], + when: (context) => context.isMac, + }, + }, + { + id: "workspace-pane-focus-left-cmd-shift-left", + action: "workspace.pane.focus.left", + matches: (event) => + isMacCommand(event) && + !event.altKey && + event.shiftKey && + event.code === "ArrowLeft", + when: (context) => + context.isMac && context.focusScope !== "terminal" && !context.commandCenterOpen, + help: { + id: "workspace-pane-focus-left", + section: "global", + label: "Focus pane left", + keys: ["mod", "shift", "Left"], + when: (context) => context.isMac, + }, + }, + { + id: "workspace-pane-focus-right-cmd-shift-right", + action: "workspace.pane.focus.right", + matches: (event) => + isMacCommand(event) && + !event.altKey && + event.shiftKey && + event.code === "ArrowRight", + when: (context) => + context.isMac && context.focusScope !== "terminal" && !context.commandCenterOpen, + help: { + id: "workspace-pane-focus-right", + section: "global", + label: "Focus pane right", + keys: ["mod", "shift", "Right"], + when: (context) => context.isMac, + }, + }, + { + id: "workspace-pane-focus-up-cmd-shift-up", + action: "workspace.pane.focus.up", + matches: (event) => + isMacCommand(event) && + !event.altKey && + event.shiftKey && + event.code === "ArrowUp", + when: (context) => + context.isMac && context.focusScope !== "terminal" && !context.commandCenterOpen, + help: { + id: "workspace-pane-focus-up", + section: "global", + label: "Focus pane up", + keys: ["mod", "shift", "Up"], + when: (context) => context.isMac, + }, + }, + { + id: "workspace-pane-focus-down-cmd-shift-down", + action: "workspace.pane.focus.down", + matches: (event) => + isMacCommand(event) && + !event.altKey && + event.shiftKey && + event.code === "ArrowDown", + when: (context) => + context.isMac && context.focusScope !== "terminal" && !context.commandCenterOpen, + help: { + id: "workspace-pane-focus-down", + section: "global", + label: "Focus pane down", + keys: ["mod", "shift", "Down"], + when: (context) => context.isMac, + }, + }, + { + id: "workspace-pane-move-tab-left-cmd-shift-alt-left", + action: "workspace.pane.move-tab.left", + matches: (event) => + isMacCommand(event) && + event.altKey && + event.shiftKey && + event.code === "ArrowLeft", + when: (context) => + context.isMac && context.focusScope !== "terminal" && !context.commandCenterOpen, + help: { + id: "workspace-pane-move-tab-left", + section: "global", + label: "Move tab left", + keys: ["mod", "shift", "alt", "Left"], + when: (context) => context.isMac, + }, + }, + { + id: "workspace-pane-move-tab-right-cmd-shift-alt-right", + action: "workspace.pane.move-tab.right", + matches: (event) => + isMacCommand(event) && + event.altKey && + event.shiftKey && + event.code === "ArrowRight", + when: (context) => + context.isMac && context.focusScope !== "terminal" && !context.commandCenterOpen, + help: { + id: "workspace-pane-move-tab-right", + section: "global", + label: "Move tab right", + keys: ["mod", "shift", "alt", "Right"], + when: (context) => context.isMac, + }, + }, + { + id: "workspace-pane-move-tab-up-cmd-shift-alt-up", + action: "workspace.pane.move-tab.up", + matches: (event) => + isMacCommand(event) && + event.altKey && + event.shiftKey && + event.code === "ArrowUp", + when: (context) => + context.isMac && context.focusScope !== "terminal" && !context.commandCenterOpen, + help: { + id: "workspace-pane-move-tab-up", + section: "global", + label: "Move tab up", + keys: ["mod", "shift", "alt", "Up"], + when: (context) => context.isMac, + }, + }, + { + id: "workspace-pane-move-tab-down-cmd-shift-alt-down", + action: "workspace.pane.move-tab.down", + matches: (event) => + isMacCommand(event) && + event.altKey && + event.shiftKey && + event.code === "ArrowDown", + when: (context) => + context.isMac && context.focusScope !== "terminal" && !context.commandCenterOpen, + help: { + id: "workspace-pane-move-tab-down", + section: "global", + label: "Move tab down", + keys: ["mod", "shift", "alt", "Down"], + when: (context) => context.isMac, + }, + }, + { + id: "workspace-pane-close-cmd-shift-w", + action: "workspace.pane.close", + matches: (event) => + isMacCommand(event) && + !event.altKey && + event.shiftKey && + (event.code === "KeyW" || event.key.toLowerCase() === "w"), + when: (context) => + context.isMac && context.focusScope !== "terminal" && !context.commandCenterOpen, + help: { + id: "workspace-pane-close", + section: "global", + label: "Close pane", + keys: ["mod", "shift", "W"], + when: (context) => context.isMac, + }, + }, { id: "command-center-toggle", action: "command-center.toggle", diff --git a/packages/app/src/screens/workspace/workspace-desktop-tabs-row.tsx b/packages/app/src/screens/workspace/workspace-desktop-tabs-row.tsx index 499667e35..03fae048f 100644 --- a/packages/app/src/screens/workspace/workspace-desktop-tabs-row.tsx +++ b/packages/app/src/screens/workspace/workspace-desktop-tabs-row.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useMemo, useState, type Dispatch, type SetStateAction } from "react"; +import { useCallback, useMemo, useState, type Dispatch, type SetStateAction } from "react"; import { ActivityIndicator, Pressable, ScrollView, Text, View, type LayoutChangeEvent } from "react-native"; import { Plus, X } from "lucide-react-native"; import { StyleSheet, useUnistyles } from "react-native-unistyles"; @@ -27,6 +27,7 @@ const LOADING_TAB_LABEL_SKELETON_WIDTH = 80; type NewTabOptionId = "__new_tab_agent__"; type WorkspaceDesktopTabsRowProps = { + paneId?: string; tabs: WorkspaceTabDescriptor[]; activeTabKey: string; normalizedServerId: string; @@ -47,6 +48,8 @@ type WorkspaceDesktopTabsRowProps = { onSelectNewTabOption: (optionId: NewTabOptionId) => void; newTabAgentOptionId: NewTabOptionId; onReorderTabs: (nextTabs: WorkspaceTabDescriptor[]) => void; + externalDndContext?: boolean; + activeDragTabId?: string | null; }; function getFallbackTabLabel(tab: WorkspaceTabDescriptor): string { @@ -59,7 +62,7 @@ function getFallbackTabLabel(tab: WorkspaceTabDescriptor): string { if (tab.target.kind === "file") { return tab.target.path.split("/").filter(Boolean).pop() ?? tab.target.path; } - return ""; + return "Agent"; } function getCloseButtonTestId(tab: WorkspaceTabDescriptor): string { @@ -96,7 +99,6 @@ function TabChip({ onCloseTabsToLeft, onCloseTabsToRight, onCloseOtherTabs, - onPresentationChange, dragHandleProps, }: { tab: WorkspaceTabDescriptor; @@ -119,19 +121,14 @@ function TabChip({ onCloseTabsToLeft: (tabId: string) => Promise | void; onCloseTabsToRight: (tabId: string) => Promise | void; onCloseOtherTabs: (tabId: string) => Promise | void; - onPresentationChange: (tabKey: string, presentation: WorkspaceTabPresentation) => void; dragHandleProps: any; }) { - const { theme } = useUnistyles(); const presentation = useWorkspaceTabPresentation({ tab, serverId: normalizedServerId, workspaceId: normalizedWorkspaceId, }); - - useEffect(() => { - onPresentationChange(tab.key, presentation); - }, [onPresentationChange, presentation, tab.key]); + const { theme } = useUnistyles(); const tooltipLabel = presentation.titleState === "loading" ? "Loading agent title" : presentation.label; @@ -281,6 +278,7 @@ function TabChip({ } export function WorkspaceDesktopTabsRow({ + paneId, tabs, activeTabKey, normalizedServerId, @@ -301,47 +299,12 @@ export function WorkspaceDesktopTabsRow({ onSelectNewTabOption, newTabAgentOptionId, onReorderTabs, + externalDndContext = false, + activeDragTabId = null, }: WorkspaceDesktopTabsRowProps) { const { theme } = useUnistyles(); const [tabsContainerWidth, setTabsContainerWidth] = useState(0); const [tabsActionsWidth, setTabsActionsWidth] = useState(0); - const [presentationsByKey, setPresentationsByKey] = useState>( - () => new Map() - ); - - const handlePresentationChange = useCallback( - (tabKey: string, presentation: WorkspaceTabPresentation) => { - setPresentationsByKey((current) => { - const existing = current.get(tabKey); - if ( - existing?.label === presentation.label && - existing?.subtitle === presentation.subtitle && - existing?.titleState === presentation.titleState && - existing?.statusBucket === presentation.statusBucket && - existing?.icon === presentation.icon - ) { - return current; - } - const next = new Map(current); - next.set(tabKey, presentation); - return next; - }); - }, - [] - ); - - useEffect(() => { - setPresentationsByKey((current) => { - const next = new Map(); - for (const tab of tabs) { - const presentation = current.get(tab.key); - if (presentation) { - next.set(tab.key, presentation); - } - } - return next.size === current.size ? current : next; - }); - }, [tabs]); const handleTabsContainerLayout = useCallback((event: LayoutChangeEvent) => { const nextWidth = Math.round(event.nativeEvent.layout.width); @@ -371,14 +334,10 @@ export function WorkspaceDesktopTabsRow({ const tabLabelLengths = useMemo( () => tabs.map((tab) => { - const presentation = presentationsByKey.get(tab.key); - if (presentation?.titleState === "loading") { - return Math.max(1, Math.ceil(LOADING_TAB_LABEL_SKELETON_WIDTH / layoutMetrics.estimatedCharWidth)); - } - const label = presentation?.label ?? getFallbackTabLabel(tab); + const label = getFallbackTabLabel(tab); return label.length; }), - [layoutMetrics.estimatedCharWidth, presentationsByKey, tabs] + [tabs] ); const { layout } = useWorkspaceTabLayout({ @@ -410,6 +369,17 @@ export function WorkspaceDesktopTabsRow({ useDragHandle disabled={tabs.length < 2} onDragEnd={onReorderTabs} + externalDndContext={externalDndContext} + activeId={activeDragTabId} + getItemData={ + paneId + ? (tab) => ({ + kind: "workspace-tab", + paneId, + tabId: tab.tabId, + }) + : undefined + } renderItem={({ item: tab, index, dragHandleProps }) => { const isActive = tab.key === activeTabKey; const isCloseHovered = hoveredCloseTabKey === tab.key; @@ -452,7 +422,6 @@ export function WorkspaceDesktopTabsRow({ onCloseTabsToLeft={onCloseTabsToLeft} onCloseTabsToRight={onCloseTabsToRight} onCloseOtherTabs={onCloseOtherTabs} - onPresentationChange={handlePresentationChange} dragHandleProps={dragHandleProps} /> ); diff --git a/packages/app/src/screens/workspace/workspace-pane-content.tsx b/packages/app/src/screens/workspace/workspace-pane-content.tsx new file mode 100644 index 000000000..41fbadbe4 --- /dev/null +++ b/packages/app/src/screens/workspace/workspace-pane-content.tsx @@ -0,0 +1,47 @@ +import invariant from "tiny-invariant"; +import { PaneProvider } from "@/panels/pane-context"; +import { getPanelRegistration } from "@/panels/panel-registry"; +import { ensurePanelsRegistered } from "@/panels/register-panels"; +import type { WorkspaceTabDescriptor } from "@/screens/workspace/workspace-tabs-types"; + +export interface WorkspacePaneContentProps { + tab: WorkspaceTabDescriptor; + normalizedServerId: string; + normalizedWorkspaceId: string; + onOpenTab: (target: WorkspaceTabDescriptor["target"]) => void; + onCloseCurrentTab: () => void; + onRetargetCurrentTab: (target: WorkspaceTabDescriptor["target"]) => void; + onOpenWorkspaceFile: (filePath: string) => void; +} + +export function WorkspacePaneContent({ + tab, + normalizedServerId, + normalizedWorkspaceId, + onOpenTab, + onCloseCurrentTab, + onRetargetCurrentTab, + onOpenWorkspaceFile, +}: WorkspacePaneContentProps) { + ensurePanelsRegistered(); + const registration = getPanelRegistration(tab.kind); + invariant(registration, `No panel registration for kind: ${tab.kind}`); + const Component = registration.component; + + return ( + + + + ); +} diff --git a/packages/app/src/screens/workspace/workspace-screen.tsx b/packages/app/src/screens/workspace/workspace-screen.tsx index 7f9636a3c..80d292ca5 100644 --- a/packages/app/src/screens/workspace/workspace-screen.tsx +++ b/packages/app/src/screens/workspace/workspace-screen.tsx @@ -41,6 +41,7 @@ import { TooltipTrigger, } from "@/components/ui/tooltip"; import { ExplorerSidebar } from "@/components/explorer-sidebar"; +import { SplitContainer } from "@/components/split-container"; import { SourceControlPanelIcon } from "@/components/icons/source-control-panel-icon"; import { WorkspaceGitActions } from "@/screens/workspace/workspace-git-actions"; import { ExplorerSidebarAnimationProvider } from "@/contexts/explorer-sidebar-animation-context"; @@ -52,8 +53,13 @@ import { } from "@/stores/session-store"; import { buildWorkspaceTabPersistenceKey, - useWorkspaceTabsStore, -} from "@/stores/workspace-tabs-store"; + collectAllTabs, + findPaneById, + useWorkspaceLayoutStore, + type SplitPane, + type WorkspaceLayout, +} from "@/stores/workspace-layout-store"; +import type { WorkspaceTab, WorkspaceTabTarget } from "@/stores/workspace-tabs-store"; import { useKeyboardActionHandler } from "@/hooks/use-keyboard-action-handler"; import type { KeyboardActionDefinition } from "@/keyboard/keyboard-action-dispatcher"; import { useCreateFlowStore } from "@/stores/create-flow-store"; @@ -69,16 +75,12 @@ import { checkoutStatusQueryKey, type CheckoutStatusPayload, } from "@/hooks/use-checkout-status-query"; -import { PaneProvider } from "@/panels/pane-context"; -import { ensurePanelsRegistered } from "@/panels/register-panels"; -import { getPanelRegistration } from "@/panels/panel-registry"; import type { ListTerminalsResponse } from "@server/shared/messages"; import { upsertTerminalListEntry } from "@/utils/terminal-list"; import { confirmDialog } from "@/utils/confirm-dialog"; import { useArchiveAgent } from "@/hooks/use-archive-agent"; import { buildProviderCommand } from "@/utils/provider-command-templates"; import { generateDraftId } from "@/stores/draft-keys"; -import { WorkspaceDesktopTabsRow } from "@/screens/workspace/workspace-desktop-tabs-row"; import { useWorkspaceTabPresentation, WorkspaceTabIcon, @@ -96,15 +98,21 @@ import { import { deriveWorkspaceTabModel, } from "@/screens/workspace/workspace-tab-model"; +import { WorkspacePaneContent } from "@/screens/workspace/workspace-pane-content"; import { buildBulkCloseConfirmationMessage, classifyBulkClosableTabs, } from "@/screens/workspace/workspace-bulk-close"; +import { findAdjacentPane } from "@/utils/split-navigation"; const TERMINALS_QUERY_STALE_TIME = 5_000; const NEW_TAB_AGENT_OPTION_ID = "__new_tab_agent__"; -const EMPTY_UI_TABS: ReturnType["uiTabsByWorkspace"][string] = []; -const EMPTY_TAB_ORDER: string[] = []; +const EMPTY_UI_TABS: WorkspaceTab[] = []; + +interface FocusedPaneTabState { + tabs: WorkspaceTab[]; + focusedTabId: string | null; +} type WorkspaceScreenProps = { serverId: string; @@ -169,6 +177,82 @@ function getFallbackTabOptionDescription(tab: WorkspaceTabDescriptor): string { return tab.target.path; } +function workspaceTabTargetsEqual(left: WorkspaceTabTarget, right: WorkspaceTabTarget): boolean { + if (left.kind !== right.kind) { + return false; + } + if (left.kind === "draft" && right.kind === "draft") { + return left.draftId === right.draftId; + } + if (left.kind === "agent" && right.kind === "agent") { + return left.agentId === right.agentId; + } + if (left.kind === "terminal" && right.kind === "terminal") { + return left.terminalId === right.terminalId; + } + if (left.kind === "file" && right.kind === "file") { + return left.path === right.path; + } + return false; +} + +function getFocusedPaneTabState(input: { + layout: WorkspaceLayout | null; + tabs: WorkspaceTab[]; +}): FocusedPaneTabState { + const { layout, tabs } = input; + if (!layout) { + return { + tabs, + focusedTabId: null, + }; + } + + const focusedPane = findPaneById(layout.root, layout.focusedPaneId); + if (!focusedPane) { + return { + tabs, + focusedTabId: null, + }; + } + + const tabsById = new Map(); + for (const tab of tabs) { + tabsById.set(tab.tabId, tab); + } + + const orderedTabs: WorkspaceTab[] = []; + for (const tabId of focusedPane.tabIds) { + const tab = tabsById.get(tabId); + if (tab) { + orderedTabs.push(tab); + } + } + + return { + tabs: orderedTabs, + focusedTabId: focusedPane.focusedTabId, + }; +} + +function getEffectiveFocusedTabId(input: FocusedPaneTabState): string | null { + return trimNonEmpty(input.focusedTabId) ?? input.tabs[0]?.tabId ?? null; +} + +function getFocusedPane(layout: WorkspaceLayout | null): SplitPane | null { + if (!layout) { + return null; + } + return findPaneById(layout.root, layout.focusedPaneId); +} + +function getFocusedPaneActiveTabId(pane: SplitPane | null): string | null { + if (!pane) { + return null; + } + return trimNonEmpty(pane.focusedTabId) ?? pane.tabIds[0] ?? null; +} + type MobileWorkspaceTabSwitcherProps = { tabs: WorkspaceTabDescriptor[]; activeTabKey: string; @@ -465,46 +549,6 @@ const MobileWorkspaceTabSwitcher = memo(function MobileWorkspaceTabSwitcher({ ); }); -function PaneContent({ - tab, - normalizedServerId, - normalizedWorkspaceId, - onOpenTab, - onCloseCurrentTab, - onRetargetCurrentTab, - onOpenWorkspaceFile, -}: { - tab: WorkspaceTabDescriptor; - normalizedServerId: string; - normalizedWorkspaceId: string; - onOpenTab: (target: WorkspaceTabDescriptor["target"]) => void; - onCloseCurrentTab: () => void; - onRetargetCurrentTab: (target: WorkspaceTabDescriptor["target"]) => void; - onOpenWorkspaceFile: (filePath: string) => void; -}) { - ensurePanelsRegistered(); - const registration = getPanelRegistration(tab.kind); - invariant(registration, `No panel registration for kind: ${tab.kind}`); - const Component = registration.component; - - return ( - - - - ); -} - export function WorkspaceScreen({ serverId, workspaceId, @@ -609,19 +653,18 @@ function WorkspaceScreenContent({ void queryClient.invalidateQueries({ queryKey: terminalsQueryKey }); if (createdTerminal) { - const tabId = useWorkspaceTabsStore + const workspaceKey = buildWorkspaceTabPersistenceKey({ + serverId: normalizedServerId, + workspaceId: normalizedWorkspaceId, + }); + if (!workspaceKey) { + return; + } + const tabId = useWorkspaceLayoutStore .getState() - .openOrFocusTab({ - serverId: normalizedServerId, - workspaceId: normalizedWorkspaceId, - target: { kind: "terminal", terminalId: createdTerminal.id }, - }); + .openTab(workspaceKey, { kind: "terminal", terminalId: createdTerminal.id }); if (tabId) { - useWorkspaceTabsStore.getState().focusTab({ - serverId: normalizedServerId, - workspaceId: normalizedWorkspaceId, - tabId, - }); + useWorkspaceLayoutStore.getState().focusTab(workspaceKey, tabId); } } }, @@ -798,26 +841,22 @@ function WorkspaceScreenContent({ [normalizedServerId, normalizedWorkspaceId] ); - const uiTabs = useWorkspaceTabsStore((state) => - persistenceKey - ? state.uiTabsByWorkspace[persistenceKey] ?? EMPTY_UI_TABS - : EMPTY_UI_TABS + const workspaceLayout = useWorkspaceLayoutStore((state) => + persistenceKey ? state.layoutByWorkspace[persistenceKey] ?? null : null ); - const tabOrder = useWorkspaceTabsStore((state) => - persistenceKey - ? state.tabOrderByWorkspace[persistenceKey] ?? EMPTY_TAB_ORDER - : EMPTY_TAB_ORDER + const uiTabs = useMemo( + () => (workspaceLayout ? collectAllTabs(workspaceLayout.root) : EMPTY_UI_TABS), + [workspaceLayout] ); - const focusedTabId = useWorkspaceTabsStore((state) => - persistenceKey ? state.focusedTabIdByWorkspace[persistenceKey] ?? "" : "" - ); - const openDraftTab = useWorkspaceTabsStore((state) => state.openDraftTab); - const ensureTab = useWorkspaceTabsStore((state) => state.ensureTab); - const openOrFocusTab = useWorkspaceTabsStore((state) => state.openOrFocusTab); - const focusTab = useWorkspaceTabsStore((state) => state.focusTab); - const closeWorkspaceTab = useWorkspaceTabsStore((state) => state.closeTab); - const retargetWorkspaceTab = useWorkspaceTabsStore((state) => state.retargetTab); - const reorderWorkspaceTabs = useWorkspaceTabsStore((state) => state.reorderTabs); + const openWorkspaceTab = useWorkspaceLayoutStore((state) => state.openTab); + const focusWorkspaceTab = useWorkspaceLayoutStore((state) => state.focusTab); + const closeWorkspaceTab = useWorkspaceLayoutStore((state) => state.closeTab); + const retargetWorkspaceTab = useWorkspaceLayoutStore((state) => state.retargetTab); + const splitWorkspacePane = useWorkspaceLayoutStore((state) => state.splitPane); + const moveWorkspaceTabToPane = useWorkspaceLayoutStore((state) => state.moveTabToPane); + const focusWorkspacePane = useWorkspaceLayoutStore((state) => state.focusPane); + const resizeWorkspaceSplit = useWorkspaceLayoutStore((state) => state.resizeSplit); + const reorderWorkspaceTabsInPane = useWorkspaceLayoutStore((state) => state.reorderTabsInPane); const pendingByDraftId = useCreateFlowStore((state) => state.pendingByDraftId); const consumedOpenIntentsRef = useRef(new Set()); const pendingCloseTabIdsRef = useRef(new Set()); @@ -832,27 +871,58 @@ function WorkspaceScreenContent({ [normalizedServerId, normalizedWorkspaceId, openIntent] ); - const openWorkspaceDraftTab = useCallback( - (input?: { draftId?: string; focus?: boolean }) => { - if (!normalizedServerId || !normalizedWorkspaceId) { + const focusedPaneTabState = useMemo( + () => + getFocusedPaneTabState({ + layout: workspaceLayout, + tabs: uiTabs, + }), + [uiTabs, workspaceLayout] + ); + + const ensureWorkspaceTab = useCallback( + function ensureWorkspaceTab(target: WorkspaceTabTarget): string | null { + if (!persistenceKey) { return null; } - const tabId = openDraftTab({ - serverId: normalizedServerId, - workspaceId: normalizedWorkspaceId, - draftId: trimNonEmpty(input?.draftId) ?? generateDraftId(), - }); - if (tabId && input?.focus !== false) { - focusTab({ - serverId: normalizedServerId, - workspaceId: normalizedWorkspaceId, - tabId, - }); + const existingTab = + uiTabs.find((tab) => workspaceTabTargetsEqual(tab.target, target)) ?? null; + if (existingTab) { + return existingTab.tabId; + } + + const previousFocusedTabId = getEffectiveFocusedTabId(focusedPaneTabState); + const tabId = openWorkspaceTab(persistenceKey, target); + if (tabId && previousFocusedTabId && previousFocusedTabId !== tabId) { + focusWorkspaceTab(persistenceKey, previousFocusedTabId); } return tabId; }, - [focusTab, normalizedServerId, normalizedWorkspaceId, openDraftTab] + [focusWorkspaceTab, focusedPaneTabState, openWorkspaceTab, persistenceKey, uiTabs] + ); + + const openWorkspaceDraftTab = useCallback( + function openWorkspaceDraftTab(input?: { draftId?: string; focus?: boolean }) { + if (!persistenceKey) { + return null; + } + + const target: WorkspaceTabTarget = { + kind: "draft", + draftId: trimNonEmpty(input?.draftId) ?? generateDraftId(), + }; + if (input?.focus === false) { + return ensureWorkspaceTab(target); + } + + const tabId = openWorkspaceTab(persistenceKey, target); + if (tabId) { + focusWorkspaceTab(persistenceKey, tabId); + } + return tabId; + }, + [ensureWorkspaceTab, focusWorkspaceTab, openWorkspaceTab, persistenceKey] ); useEffect(() => { @@ -897,16 +967,13 @@ function WorkspaceScreenContent({ return; } - const tabId = openOrFocusTab({ - serverId: normalizedServerId, - workspaceId: normalizedWorkspaceId, - target: - openIntent.kind === "agent" - ? { kind: "agent", agentId: openIntent.agentId } - : openIntent.kind === "terminal" - ? { kind: "terminal", terminalId: openIntent.terminalId } - : { kind: "file", path: openIntent.path }, - }); + const target: WorkspaceTabTarget = + openIntent.kind === "agent" + ? { kind: "agent", agentId: openIntent.agentId } + : openIntent.kind === "terminal" + ? { kind: "terminal", terminalId: openIntent.terminalId } + : { kind: "file", path: openIntent.path }; + const tabId = openWorkspaceTab(persistenceKey, target); if (tabId) { setResolvedOpenIntentKey(intentKey); } @@ -914,10 +981,8 @@ function WorkspaceScreenContent({ currentOpenIntentKey, openIntent, openWorkspaceDraftTab, - openOrFocusTab, + openWorkspaceTab, persistenceKey, - normalizedServerId, - normalizedWorkspaceId, resolvedOpenIntentKey, ]); @@ -926,7 +991,7 @@ function WorkspaceScreenContent({ : null; useEffect(() => { - if (!normalizedServerId || !normalizedWorkspaceId) { + if (!normalizedServerId || !normalizedWorkspaceId || !persistenceKey) { return; } @@ -954,49 +1019,32 @@ function WorkspaceScreenContent({ ) { continue; } - ensureTab({ - serverId: normalizedServerId, - workspaceId: normalizedWorkspaceId, - target: { kind: "agent", agentId: agent.id }, - }); + ensureWorkspaceTab({ kind: "agent", agentId: agent.id }); } for (const terminal of terminals) { - ensureTab({ - serverId: normalizedServerId, - workspaceId: normalizedWorkspaceId, - target: { kind: "terminal", terminalId: terminal.id }, - }); + ensureWorkspaceTab({ kind: "terminal", terminalId: terminal.id }); } const canPruneAgentTabs = hasHydratedAgents; const canPruneTerminalTabs = terminalsQuery.isSuccess; for (const tab of uiTabs) { if (canPruneAgentTabs && tab.target.kind === "agent" && !agentIds.has(tab.target.agentId)) { - closeWorkspaceTab({ - serverId: normalizedServerId, - workspaceId: normalizedWorkspaceId, - tabId: tab.tabId, - }); + closeWorkspaceTab(persistenceKey, tab.tabId); } if ( canPruneTerminalTabs && tab.target.kind === "terminal" && !terminalIds.has(tab.target.terminalId) ) { - closeWorkspaceTab({ - serverId: normalizedServerId, - workspaceId: normalizedWorkspaceId, - tabId: tab.tabId, - }); + closeWorkspaceTab(persistenceKey, tab.tabId); } } }, [ closeWorkspaceTab, - ensureTab, + ensureWorkspaceTab, hasHydratedAgents, - normalizedServerId, - normalizedWorkspaceId, pendingByDraftId, + persistenceKey, terminals, terminalsQuery.isSuccess, uiTabs, @@ -1006,9 +1054,8 @@ function WorkspaceScreenContent({ const tabModel = useMemo( () => deriveWorkspaceTabModel({ - tabs: uiTabs, - tabOrder, - focusedTabId, + tabs: focusedPaneTabState.tabs, + focusedTabId: focusedPaneTabState.focusedTabId, preferredTarget: unresolvedOpenIntent?.kind === "agent" ? { kind: "agent", agentId: unresolvedOpenIntent.agentId } @@ -1020,7 +1067,7 @@ function WorkspaceScreenContent({ ? { kind: "file", path: unresolvedOpenIntent.path } : null, }), - [focusedTabId, tabOrder, uiTabs, unresolvedOpenIntent] + [focusedPaneTabState, unresolvedOpenIntent] ); const activeTabId = tabModel.activeTabId; @@ -1028,8 +1075,8 @@ function WorkspaceScreenContent({ if (!activeTabId || !persistenceKey) { return; } - focusTab({ serverId: normalizedServerId, workspaceId: normalizedWorkspaceId, tabId: activeTabId }); - }, [activeTabId, focusTab, normalizedServerId, normalizedWorkspaceId, persistenceKey]); + focusWorkspaceTab(persistenceKey, activeTabId); + }, [activeTabId, focusWorkspaceTab, persistenceKey]); const activeTab = tabModel.activeTab; @@ -1038,29 +1085,14 @@ function WorkspaceScreenContent({ [tabModel.tabs] ); - const handleReorderTabs = useCallback( - (nextTabs: WorkspaceTabDescriptor[]) => { - reorderWorkspaceTabs({ - serverId: normalizedServerId, - workspaceId: normalizedWorkspaceId, - tabIds: nextTabs.map((tab) => tab.tabId), - }); - }, - [normalizedServerId, normalizedWorkspaceId, reorderWorkspaceTabs] - ); - const navigateToTabId = useCallback( - (tabId: string) => { - if (!tabId || !normalizedServerId || !normalizedWorkspaceId) { + function navigateToTabId(tabId: string) { + if (!tabId || !persistenceKey) { return; } - focusTab({ - serverId: normalizedServerId, - workspaceId: normalizedWorkspaceId, - tabId, - }); + focusWorkspaceTab(persistenceKey, tabId); }, - [focusTab, normalizedServerId, normalizedWorkspaceId] + [focusWorkspaceTab, persistenceKey] ); const emptyWorkspaceSeedRef = useRef(null); @@ -1098,20 +1130,19 @@ function WorkspaceScreenContent({ ]); const handleOpenFileFromExplorer = useCallback( - (filePath: string) => { + function handleOpenFileFromExplorer(filePath: string) { if (isMobile) { closeToAgent(); } - const tabId = openOrFocusTab({ - serverId: normalizedServerId, - workspaceId: normalizedWorkspaceId, - target: { kind: "file", path: filePath }, - }); + if (!persistenceKey) { + return; + } + const tabId = openWorkspaceTab(persistenceKey, { kind: "file", path: filePath }); if (tabId) { navigateToTabId(tabId); } }, - [closeToAgent, isMobile, navigateToTabId, normalizedServerId, normalizedWorkspaceId, openOrFocusTab] + [closeToAgent, isMobile, navigateToTabId, openWorkspaceTab, persistenceKey] ); const handleOpenFileFromChat = useCallback( @@ -1138,6 +1169,19 @@ function WorkspaceScreenContent({ return map; }, [tabs]); + const allTabDescriptorsById = useMemo(() => { + const map = new Map(); + for (const tab of uiTabs) { + map.set(tab.tabId, { + key: tab.tabId, + tabId: tab.tabId, + kind: tab.target.kind, + target: tab.target, + }); + } + return map; + }, [uiTabs]); + const activeTabKey = activeTabId ?? ""; const tabSwitcherOptions = useMemo( @@ -1240,19 +1284,16 @@ function WorkspaceScreenContent({ } ); - closeWorkspaceTab({ - serverId: normalizedServerId, - workspaceId: normalizedWorkspaceId, - tabId, - }); + if (persistenceKey) { + closeWorkspaceTab(persistenceKey, tabId); + } }, }); }, [ closeWorkspaceTab, killTerminalMutation, - normalizedServerId, - normalizedWorkspaceId, + persistenceKey, queryClient, runCloseFlowForTab, terminalsQueryKey, @@ -1286,11 +1327,9 @@ function WorkspaceScreenContent({ await archiveAgent({ serverId: normalizedServerId, agentId }); setHoveredTabKey((current) => (current === tabId ? null : current)); setHoveredCloseTabKey((current) => (current === tabId ? null : current)); - closeWorkspaceTab({ - serverId: normalizedServerId, - workspaceId: normalizedWorkspaceId, - tabId, - }); + if (persistenceKey) { + closeWorkspaceTab(persistenceKey, tabId); + } }, }); }, @@ -1299,27 +1338,25 @@ function WorkspaceScreenContent({ closeWorkspaceTab, isArchivingAgent, normalizedServerId, - normalizedWorkspaceId, + persistenceKey, runCloseFlowForTab, ] ); const handleCloseDraftOrFileTab = useCallback( - (tabId: string) => { + function handleCloseDraftOrFileTab(tabId: string) { setHoveredTabKey((current) => (current === tabId ? null : current)); setHoveredCloseTabKey((current) => (current === tabId ? null : current)); - closeWorkspaceTab({ - serverId: normalizedServerId, - workspaceId: normalizedWorkspaceId, - tabId, - }); + if (persistenceKey) { + closeWorkspaceTab(persistenceKey, tabId); + } }, - [closeWorkspaceTab, normalizedServerId, normalizedWorkspaceId] + [closeWorkspaceTab, persistenceKey] ); const handleCloseTabById = useCallback( async (tabId: string) => { - const tab = tabByKey.get(tabId); + const tab = allTabDescriptorsById.get(tabId); if (!tab) { return; } @@ -1333,7 +1370,7 @@ function WorkspaceScreenContent({ } handleCloseDraftOrFileTab(tabId); }, - [handleCloseAgentTab, handleCloseDraftOrFileTab, handleCloseTerminalTab, tabByKey] + [allTabDescriptorsById, handleCloseAgentTab, handleCloseDraftOrFileTab, handleCloseTerminalTab] ); const handleCopyAgentId = useCallback( @@ -1439,11 +1476,9 @@ function WorkspaceScreenContent({ terminals: current.terminals.filter((terminal) => terminal.id !== terminalId), }; }); - closeWorkspaceTab({ - serverId: normalizedServerId, - workspaceId: normalizedWorkspaceId, - tabId, - }); + if (persistenceKey) { + closeWorkspaceTab(persistenceKey, tabId); + } } catch (error) { console.warn(`[WorkspaceScreen] Failed to close terminal tab ${logLabel}`, { terminalId, error }); } @@ -1455,22 +1490,18 @@ function WorkspaceScreenContent({ } try { await archiveAgent({ serverId: normalizedServerId, agentId }); - closeWorkspaceTab({ - serverId: normalizedServerId, - workspaceId: normalizedWorkspaceId, - tabId, - }); + if (persistenceKey) { + closeWorkspaceTab(persistenceKey, tabId); + } } catch (error) { console.warn(`[WorkspaceScreen] Failed to archive agent tab ${logLabel}`, { agentId, error }); } } for (const { tabId } of groups.otherTabs) { - closeWorkspaceTab({ - serverId: normalizedServerId, - workspaceId: normalizedWorkspaceId, - tabId, - }); + if (persistenceKey) { + closeWorkspaceTab(persistenceKey, tabId); + } } const closedKeys = new Set(tabsToClose.map((tab) => tab.key)); @@ -1482,52 +1513,73 @@ function WorkspaceScreenContent({ closeWorkspaceTab, killTerminalMutation, normalizedServerId, - normalizedWorkspaceId, + persistenceKey, queryClient, terminalsQueryKey, ] ); - const handleCloseTabsToLeft = useCallback( - async (tabId: string) => { - const index = tabs.findIndex((tab) => tab.tabId === tabId); + const handleCloseTabsToLeftInPane = useCallback( + async (tabId: string, paneTabs: WorkspaceTabDescriptor[]) => { + const index = paneTabs.findIndex((tab) => tab.tabId === tabId); if (index < 0) { return; } await handleBulkCloseTabs({ - tabsToClose: tabs.slice(0, index), + tabsToClose: paneTabs.slice(0, index), title: "Close tabs to the left?", logLabel: "to the left", }); }, - [handleBulkCloseTabs, tabs] + [handleBulkCloseTabs] ); - const handleCloseTabsToRight = useCallback( + const handleCloseTabsToLeft = useCallback( async (tabId: string) => { - const index = tabs.findIndex((tab) => tab.tabId === tabId); + await handleCloseTabsToLeftInPane(tabId, tabs); + }, + [handleCloseTabsToLeftInPane, tabs] + ); + + const handleCloseTabsToRightInPane = useCallback( + async (tabId: string, paneTabs: WorkspaceTabDescriptor[]) => { + const index = paneTabs.findIndex((tab) => tab.tabId === tabId); if (index < 0) { return; } await handleBulkCloseTabs({ - tabsToClose: tabs.slice(index + 1), + tabsToClose: paneTabs.slice(index + 1), title: "Close tabs to the right?", logLabel: "to the right", }); }, - [handleBulkCloseTabs, tabs] + [handleBulkCloseTabs] ); - const handleCloseOtherTabs = useCallback( + const handleCloseTabsToRight = useCallback( async (tabId: string) => { - const tabsToClose = tabs.filter((tab) => tab.tabId !== tabId); + await handleCloseTabsToRightInPane(tabId, tabs); + }, + [handleCloseTabsToRightInPane, tabs] + ); + + const handleCloseOtherTabsInPane = useCallback( + async (tabId: string, paneTabs: WorkspaceTabDescriptor[]) => { + const tabsToClose = paneTabs.filter((tab) => tab.tabId !== tabId); await handleBulkCloseTabs({ tabsToClose, title: "Close other tabs?", logLabel: "from close other tabs", }); }, - [handleBulkCloseTabs, tabs] + [handleBulkCloseTabs] + ); + + const handleCloseOtherTabs = useCallback( + async (tabId: string) => { + await handleCloseOtherTabsInPane(tabId, tabs); + }, + [handleCloseOtherTabsInPane, tabs] ); const handleWorkspaceTabAction = useCallback( @@ -1567,6 +1619,103 @@ function WorkspaceScreenContent({ [activeTabId, handleCloseTabById, handleCreateDraftTab, navigateToTabId, tabs] ); + const handleWorkspacePaneAction = useCallback( + (action: KeyboardActionDefinition): boolean => { + if (!persistenceKey || !workspaceLayout) { + return true; + } + + const focusedPane = getFocusedPane(workspaceLayout); + if (!focusedPane) { + return true; + } + + if (action.id === "workspace.pane.split.right") { + const activePaneTabId = getFocusedPaneActiveTabId(focusedPane); + if (activePaneTabId) { + splitWorkspacePane(persistenceKey, { + tabId: activePaneTabId, + targetPaneId: focusedPane.id, + position: "right", + }); + } + return true; + } + + if (action.id === "workspace.pane.split.down") { + const activePaneTabId = getFocusedPaneActiveTabId(focusedPane); + if (activePaneTabId) { + splitWorkspacePane(persistenceKey, { + tabId: activePaneTabId, + targetPaneId: focusedPane.id, + position: "bottom", + }); + } + return true; + } + + if ( + action.id === "workspace.pane.focus.left" || + action.id === "workspace.pane.focus.right" || + action.id === "workspace.pane.focus.up" || + action.id === "workspace.pane.focus.down" + ) { + const direction = action.id.split(".").pop(); + if ( + direction === "left" || + direction === "right" || + direction === "up" || + direction === "down" + ) { + const adjacentPaneId = findAdjacentPane(workspaceLayout.root, focusedPane.id, direction); + if (adjacentPaneId) { + focusWorkspacePane(persistenceKey, adjacentPaneId); + } + } + return true; + } + + if ( + action.id === "workspace.pane.move-tab.left" || + action.id === "workspace.pane.move-tab.right" || + action.id === "workspace.pane.move-tab.up" || + action.id === "workspace.pane.move-tab.down" + ) { + const direction = action.id.split(".").pop(); + if ( + direction === "left" || + direction === "right" || + direction === "up" || + direction === "down" + ) { + const activePaneTabId = getFocusedPaneActiveTabId(focusedPane); + const adjacentPaneId = findAdjacentPane(workspaceLayout.root, focusedPane.id, direction); + if (activePaneTabId && adjacentPaneId) { + moveWorkspaceTabToPane(persistenceKey, activePaneTabId, adjacentPaneId); + } + } + return true; + } + + if (action.id === "workspace.pane.close") { + for (const tabId of focusedPane.tabIds) { + closeWorkspaceTab(persistenceKey, tabId); + } + return true; + } + + return false; + }, + [ + closeWorkspaceTab, + focusWorkspacePane, + moveWorkspaceTabToPane, + persistenceKey, + splitWorkspacePane, + workspaceLayout, + ] + ); + useKeyboardActionHandler({ handlerId: `workspace-tab-actions:${normalizedServerId}:${normalizedWorkspaceId}`, actions: [ @@ -1581,6 +1730,27 @@ function WorkspaceScreenContent({ handle: handleWorkspaceTabAction, }); + useKeyboardActionHandler({ + handlerId: `workspace-pane-actions:${normalizedServerId}:${normalizedWorkspaceId}`, + actions: [ + "workspace.pane.split.right", + "workspace.pane.split.down", + "workspace.pane.focus.left", + "workspace.pane.focus.right", + "workspace.pane.focus.up", + "workspace.pane.focus.down", + "workspace.pane.move-tab.left", + "workspace.pane.move-tab.right", + "workspace.pane.move-tab.up", + "workspace.pane.move-tab.down", + "workspace.pane.close", + ] as const, + enabled: Boolean(normalizedServerId && normalizedWorkspaceId), + priority: 100, + isActive: () => isScreenFocused, + handle: handleWorkspacePaneAction, + }); + const activeTabDescriptor = activeTab?.descriptor ?? null; const content = shouldRenderMissingWorkspaceDescriptor({ workspace: workspaceDescriptor, @@ -1602,16 +1772,15 @@ function WorkspaceScreenContent({ ) ) : ( - { - const tabId = openOrFocusTab({ - serverId: normalizedServerId, - workspaceId: normalizedWorkspaceId, - target, - }); + if (!persistenceKey) { + return; + } + const tabId = openWorkspaceTab(persistenceKey, target); if (tabId) { navigateToTabId(tabId); } @@ -1622,12 +1791,10 @@ function WorkspaceScreenContent({ } }} onRetargetCurrentTab={(target) => { - retargetWorkspaceTab({ - serverId: normalizedServerId, - workspaceId: normalizedWorkspaceId, - tabId: activeTabDescriptor.tabId, - target, - }); + if (!persistenceKey) { + return; + } + retargetWorkspaceTab(persistenceKey, activeTabDescriptor.tabId, target); }} onOpenWorkspaceFile={(filePath) => { handleOpenFileFromChat({ filePath }); @@ -1835,28 +2002,7 @@ function WorkspaceScreenContent({ onCloseOtherTabs={handleCloseOtherTabs} /> ) : ( - + null )} @@ -1865,7 +2011,71 @@ function WorkspaceScreenContent({ {content} ) : ( - {content} + + {workspaceLayout && persistenceKey ? ( + { + focusWorkspacePane(persistenceKey, paneId); + const tabId = openWorkspaceTab(persistenceKey, target); + if (tabId) { + navigateToTabId(tabId); + } + }} + onRetargetTab={(tabId, target) => { + retargetWorkspaceTab(persistenceKey, tabId, target); + }} + onOpenWorkspaceFile={({ paneId, filePath }) => { + focusWorkspacePane(persistenceKey, paneId); + const tabId = openWorkspaceTab(persistenceKey, { kind: "file", path: filePath }); + if (tabId) { + navigateToTabId(tabId); + } + }} + onFocusPane={(paneId) => { + focusWorkspacePane(persistenceKey, paneId); + }} + onSplitPane={(input) => { + splitWorkspacePane(persistenceKey, input); + }} + onMoveTabToPane={(tabId, toPaneId) => { + moveWorkspaceTabToPane(persistenceKey, tabId, toPaneId); + }} + onResizeSplit={(groupId, sizes) => { + resizeWorkspaceSplit(persistenceKey, groupId, sizes); + }} + onReorderTabsInPane={(paneId, tabIds) => { + reorderWorkspaceTabsInPane(persistenceKey, paneId, tabIds); + }} + renderPaneEmptyState={() => ( + + No tabs in this pane. + + )} + /> + ) : ( + content + )} + )} diff --git a/packages/app/src/screens/workspace/workspace-tab-model.test.ts b/packages/app/src/screens/workspace/workspace-tab-model.test.ts index 25f312c6c..febf38bf1 100644 --- a/packages/app/src/screens/workspace/workspace-tab-model.test.ts +++ b/packages/app/src/screens/workspace/workspace-tab-model.test.ts @@ -23,8 +23,7 @@ describe("deriveWorkspaceTabModel", () => { ]; const model = deriveWorkspaceTabModel({ - tabs: uiTabs, - tabOrder: ["draft_123", "agent_agent-a", "file_/repo/worktree/README.md"], + tabs: [uiTabs[0]!, uiTabs[2]!, uiTabs[1]!], }); expect(model.tabs.map((tab) => tab.descriptor.tabId)).toEqual([ @@ -43,11 +42,10 @@ describe("deriveWorkspaceTabModel", () => { it("applies stored order and appends unordered tabs deterministically", () => { const model = deriveWorkspaceTabModel({ tabs: [ - { tabId: "agent_agent-a", target: { kind: "agent", agentId: "agent-a" }, createdAt: 1 }, - { tabId: "agent_agent-b", target: { kind: "agent", agentId: "agent-b" }, createdAt: 2 }, { tabId: "terminal_term-1", target: { kind: "terminal", terminalId: "term-1" }, createdAt: 3 }, + { tabId: "agent_agent-b", target: { kind: "agent", agentId: "agent-b" }, createdAt: 2 }, + { tabId: "agent_agent-a", target: { kind: "agent", agentId: "agent-a" }, createdAt: 1 }, ], - tabOrder: ["terminal_term-1", "agent_agent-b"], }); expect(model.tabs.map((tab) => tab.descriptor.tabId)).toEqual([ @@ -63,7 +61,6 @@ describe("deriveWorkspaceTabModel", () => { { tabId: "agent_agent-a", target: { kind: "agent", agentId: "agent-a" }, createdAt: 1 }, { tabId: "agent_agent-b", target: { kind: "agent", agentId: "agent-b" }, createdAt: 2 }, ], - tabOrder: ["agent_agent-a", "agent_agent-b"], }; expect( @@ -82,7 +79,6 @@ describe("deriveWorkspaceTabModel", () => { { tabId: "agent_agent-a", target: { kind: "agent", agentId: "agent-a" }, createdAt: 1 }, { tabId: "agent_agent-b", target: { kind: "agent", agentId: "agent-b" }, createdAt: 2 }, ], - tabOrder: ["agent_agent-a", "agent_agent-b"], focusedTabId: "agent_agent-a", preferredTarget: { kind: "agent", agentId: "agent-b" }, }); @@ -100,7 +96,6 @@ describe("deriveWorkspaceTabModel", () => { createdAt: 1, }, ], - tabOrder: ["draft_abc"], preferredTarget: { kind: "agent", agentId: "agent-1" }, }); @@ -123,7 +118,6 @@ describe("deriveWorkspaceTabModel", () => { createdAt: 2, }, ], - tabOrder: [], }); expect(model.tabs).toHaveLength(1); diff --git a/packages/app/src/screens/workspace/workspace-tab-model.ts b/packages/app/src/screens/workspace/workspace-tab-model.ts index 2a5ba4096..e13b2a808 100644 --- a/packages/app/src/screens/workspace/workspace-tab-model.ts +++ b/packages/app/src/screens/workspace/workspace-tab-model.ts @@ -111,52 +111,29 @@ export function buildWorkspaceTabId(target: WorkspaceTabTarget): string { export function deriveWorkspaceTabModel(input: { tabs: WorkspaceTab[]; - tabOrder: string[]; focusedTabId?: string | null; preferredTarget?: WorkspaceTabTarget | null; }): WorkspaceTabModel { - const tabsById = new Map(); + const tabs: WorkspaceDerivedTab[] = []; + const openTabIds = new Set(); - const normalizedTabs = input.tabs - .map((tab) => normalizeWorkspaceTab(tab)) - .filter((tab): tab is WorkspaceTab => tab !== null) - .sort((left, right) => left.createdAt - right.createdAt); + for (const tab of input.tabs) { + const normalizedTab = normalizeWorkspaceTab(tab); + if (!normalizedTab || openTabIds.has(normalizedTab.tabId)) { + continue; + } - for (const tab of normalizedTabs) { - tabsById.set(tab.tabId, { + openTabIds.add(normalizedTab.tabId); + tabs.push({ descriptor: { - key: tab.tabId, - tabId: tab.tabId, - kind: tab.target.kind, - target: tab.target, + key: normalizedTab.tabId, + tabId: normalizedTab.tabId, + kind: normalizedTab.target.kind, + target: normalizedTab.target, }, }); } - const orderedTabIds: string[] = []; - const used = new Set(); - for (const tabId of input.tabOrder) { - const normalizedTabId = trimNonEmpty(tabId); - if (!normalizedTabId || used.has(normalizedTabId) || !tabsById.has(normalizedTabId)) { - continue; - } - used.add(normalizedTabId); - orderedTabIds.push(normalizedTabId); - } - - for (const tabId of tabsById.keys()) { - if (used.has(tabId)) { - continue; - } - used.add(tabId); - orderedTabIds.push(tabId); - } - - const tabs = orderedTabIds - .map((tabId) => tabsById.get(tabId) ?? null) - .filter((tab): tab is WorkspaceDerivedTab => tab !== null); - - const openTabIds = new Set(tabs.map((tab) => tab.descriptor.tabId)); const focusedTabId = trimNonEmpty(input.focusedTabId); const preferredTarget = input.preferredTarget ?? null; const preferredTabId = (() => { diff --git a/packages/app/src/screens/workspace/workspace-tab-presentation.tsx b/packages/app/src/screens/workspace/workspace-tab-presentation.tsx index 84c57b4d8..550087b82 100644 --- a/packages/app/src/screens/workspace/workspace-tab-presentation.tsx +++ b/packages/app/src/screens/workspace/workspace-tab-presentation.tsx @@ -1,4 +1,4 @@ -import type { ReactElement, ReactNode } from "react"; +import { useMemo, type ReactElement, type ReactNode } from "react"; import { Pressable, Text, View } from "react-native"; import { Check } from "lucide-react-native"; import { StyleSheet, useUnistyles } from "react-native-unistyles"; @@ -34,15 +34,26 @@ export function useWorkspaceTabPresentation(input: { workspaceId: input.workspaceId, }); - return { - key: input.tab.key, - kind: input.tab.kind, - label: descriptor.label, - subtitle: descriptor.subtitle, - titleState: descriptor.titleState, - icon: descriptor.icon, - statusBucket: descriptor.statusBucket, - }; + return useMemo( + () => ({ + key: input.tab.key, + kind: input.tab.kind, + label: descriptor.label, + subtitle: descriptor.subtitle, + titleState: descriptor.titleState, + icon: descriptor.icon, + statusBucket: descriptor.statusBucket, + }), + [ + descriptor.icon, + descriptor.label, + descriptor.statusBucket, + descriptor.subtitle, + descriptor.titleState, + input.tab.key, + input.tab.kind, + ] + ); } type WorkspaceTabIconProps = { diff --git a/packages/app/src/stores/workspace-layout-store.test.ts b/packages/app/src/stores/workspace-layout-store.test.ts new file mode 100644 index 000000000..50f0b53cf --- /dev/null +++ b/packages/app/src/stores/workspace-layout-store.test.ts @@ -0,0 +1,610 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("@react-native-async-storage/async-storage", () => { + const storage = new Map(); + return { + default: { + getItem: vi.fn(async (key: string) => storage.get(key) ?? null), + setItem: vi.fn(async (key: string, value: string) => { + storage.set(key, value); + }), + removeItem: vi.fn(async (key: string) => { + storage.delete(key); + }), + }, + }; +}); + +import type { WorkspaceTab } from "@/stores/workspace-tabs-store"; +import { + buildWorkspaceTabPersistenceKey, + collectAllPanes, + collectAllTabs, + createDefaultLayout, + findPaneById, + findPaneContainingTab, + getTreeDepth, + insertSplit, + removePaneFromTree, + removeTabFromTree, + useWorkspaceLayoutStore, + type SplitNode, +} from "@/stores/workspace-layout-store"; + +const SERVER_ID = "server-1"; +const WORKSPACE_ID = "/repo/worktree"; + +function createTab(tabId: string): WorkspaceTab { + return { + tabId, + target: { kind: "draft", draftId: tabId }, + createdAt: 1, + }; +} + +function createPane(input: { + id: string; + tabIds: string[]; + focusedTabId?: string | null; +}): SplitNode { + const tabs = input.tabIds.map((tabId) => createTab(tabId)); + return { + kind: "pane", + pane: { + id: input.id, + tabIds: input.tabIds, + focusedTabId: input.focusedTabId ?? input.tabIds[input.tabIds.length - 1] ?? null, + tabs, + } as any, + }; +} + +function createWorkspaceKey(): string { + const key = buildWorkspaceTabPersistenceKey({ + serverId: SERVER_ID, + workspaceId: WORKSPACE_ID, + }); + expect(key).toBeTruthy(); + return key as string; +} + +function expectGroup(node: SplitNode): Extract { + expect(node.kind).toBe("group"); + return node as Extract; +} + +describe("workspace-layout-store helpers", () => { + it("finds panes and tabs across nested groups", () => { + const root: SplitNode = { + kind: "group", + group: { + id: "group-root", + direction: "horizontal", + sizes: [0.4, 0.6], + children: [ + createPane({ id: "left", tabIds: ["tab-a", "tab-b"], focusedTabId: "tab-a" }), + { + kind: "group", + group: { + id: "group-right", + direction: "vertical", + sizes: [0.5, 0.5], + children: [ + createPane({ id: "top-right", tabIds: ["tab-c"] }), + createPane({ id: "bottom-right", tabIds: ["tab-d"] }), + ], + }, + }, + ], + }, + }; + + expect(findPaneById(root, "top-right")?.tabIds).toEqual(["tab-c"]); + expect(findPaneContainingTab(root, "tab-b")?.id).toBe("left"); + expect(getTreeDepth(root)).toBe(3); + expect(collectAllPanes(root).map((pane) => pane.id)).toEqual([ + "left", + "top-right", + "bottom-right", + ]); + expect(collectAllTabs(root).map((tab) => tab.tabId)).toEqual([ + "tab-a", + "tab-b", + "tab-c", + "tab-d", + ]); + }); +}); + +describe("workspace-layout-store tree transforms", () => { + beforeEach(() => { + vi.restoreAllMocks(); + }); + + it("insertSplit reuses a matching parent direction instead of nesting", () => { + vi.spyOn(globalThis.crypto, "randomUUID") + .mockReturnValueOnce("11111111-1111-1111-1111-111111111111") + .mockReturnValueOnce("22222222-2222-2222-2222-222222222222"); + + const root: SplitNode = { + kind: "group", + group: { + id: "group-root", + direction: "horizontal", + sizes: [0.25, 0.75], + children: [ + createPane({ id: "left", tabIds: ["tab-a"] }), + createPane({ id: "right", tabIds: ["tab-b", "tab-c"] }), + ], + }, + }; + + const nextRoot = insertSplit(root, "right", "tab-c", "right"); + const nextGroup = expectGroup(nextRoot); + + expect(nextGroup.group.direction).toBe("horizontal"); + expect(nextGroup.group.children).toHaveLength(3); + expect(nextGroup.group.sizes).toEqual([0.25, 0.375, 0.375]); + expect(collectAllPanes(nextRoot).map((pane) => pane.id)).toEqual([ + "left", + "right", + "pane_11111111-1111-1111-1111-111111111111", + ]); + expect(findPaneById(nextRoot, "right")?.tabIds).toEqual(["tab-b"]); + expect(findPaneById(nextRoot, "pane_11111111-1111-1111-1111-111111111111")?.tabIds).toEqual([ + "tab-c", + ]); + }); + + it("removePaneFromTree unwraps single-child groups and renormalizes siblings", () => { + const root: SplitNode = { + kind: "group", + group: { + id: "group-root", + direction: "horizontal", + sizes: [0.2, 0.8], + children: [ + createPane({ id: "left", tabIds: ["tab-a"] }), + { + kind: "group", + group: { + id: "group-right", + direction: "vertical", + sizes: [0.5, 0.5], + children: [ + createPane({ id: "top-right", tabIds: ["tab-b"] }), + createPane({ id: "bottom-right", tabIds: ["tab-c"] }), + ], + }, + }, + ], + }, + }; + + const nextRoot = removePaneFromTree(root, "top-right"); + const nextGroup = expectGroup(nextRoot); + + expect(nextGroup.group.sizes).toEqual([0.2, 0.8]); + expect(collectAllPanes(nextRoot).map((pane) => pane.id)).toEqual(["left", "bottom-right"]); + expect(nextGroup.group.children[1]).toEqual(createPane({ id: "bottom-right", tabIds: ["tab-c"] })); + }); + + it("removeTabFromTree collapses empty panes but keeps the final root pane", () => { + const splitRoot: SplitNode = { + kind: "group", + group: { + id: "group-root", + direction: "horizontal", + sizes: [0.5, 0.5], + children: [ + createPane({ id: "left", tabIds: ["tab-a"] }), + createPane({ id: "right", tabIds: ["tab-b"] }), + ], + }, + }; + + const collapsed = removeTabFromTree(splitRoot, "tab-a"); + expect(collapsed).toEqual(createPane({ id: "right", tabIds: ["tab-b"] })); + + const singlePaneRoot = createPane({ id: "main", tabIds: ["tab-a"] }); + const emptied = removeTabFromTree(singlePaneRoot, "tab-a"); + expect(emptied).toEqual(createPane({ id: "main", tabIds: [], focusedTabId: null })); + }); +}); + +describe("workspace-layout-store actions", () => { + beforeEach(() => { + useWorkspaceLayoutStore.setState({ layoutByWorkspace: {} }); + vi.restoreAllMocks(); + }); + + it("opens tabs into the focused pane and focuses duplicate opens instead of creating them", () => { + vi.spyOn(globalThis.crypto, "randomUUID").mockReturnValue( + "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa" + ); + const workspaceKey = createWorkspaceKey(); + const store = useWorkspaceLayoutStore.getState(); + + const firstTabId = store.openTab(workspaceKey, { kind: "file", path: "/repo/worktree/a.ts" }); + const secondTabId = store.openTab(workspaceKey, { kind: "file", path: "/repo/worktree/b.ts" }); + const splitPaneId = store.splitPane(workspaceKey, { + tabId: secondTabId!, + targetPaneId: "main", + position: "right", + }); + + expect(splitPaneId).toBe("pane_aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"); + + store.focusPane(workspaceKey, "main"); + const duplicateTabId = store.openTab(workspaceKey, { kind: "file", path: "/repo/worktree/b.ts" }); + const layout = useWorkspaceLayoutStore.getState().layoutByWorkspace[workspaceKey]!; + + expect(firstTabId).toBe("file_/repo/worktree/a.ts"); + expect(secondTabId).toBe("file_/repo/worktree/b.ts"); + expect(duplicateTabId).toBe(secondTabId); + expect(layout.focusedPaneId).toBe("pane_aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"); + expect(collectAllTabs(layout.root).map((tab) => tab.tabId)).toEqual([ + "file_/repo/worktree/a.ts", + "file_/repo/worktree/b.ts", + ]); + }); + + it("focusTab moves workspace focus to the pane containing the tab", () => { + vi.spyOn(globalThis.crypto, "randomUUID").mockReturnValue( + "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb" + ); + const workspaceKey = createWorkspaceKey(); + const store = useWorkspaceLayoutStore.getState(); + + const fileTabId = store.openTab(workspaceKey, { kind: "file", path: "/repo/worktree/a.ts" }); + const terminalTabId = store.openTab(workspaceKey, { kind: "terminal", terminalId: "term-1" }); + const splitPaneId = store.splitPane(workspaceKey, { + tabId: terminalTabId!, + targetPaneId: "main", + position: "right", + }); + + store.focusTab(workspaceKey, fileTabId!); + let layout = useWorkspaceLayoutStore.getState().layoutByWorkspace[workspaceKey]!; + expect(layout.focusedPaneId).toBe("main"); + + store.focusTab(workspaceKey, terminalTabId!); + layout = useWorkspaceLayoutStore.getState().layoutByWorkspace[workspaceKey]!; + expect(splitPaneId).toBe("pane_bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb"); + expect(layout.focusedPaneId).toBe(splitPaneId); + expect(findPaneById(layout.root, splitPaneId!)?.focusedTabId).toBe(terminalTabId); + }); + + it("retargetTab updates the existing tab target without moving it to a different pane", () => { + vi.spyOn(globalThis.crypto, "randomUUID").mockReturnValue( + "12121212-1212-1212-1212-121212121212" + ); + const workspaceKey = createWorkspaceKey(); + const store = useWorkspaceLayoutStore.getState(); + + store.openTab(workspaceKey, { kind: "file", path: "/repo/worktree/a.ts" }); + const secondTabId = store.openTab(workspaceKey, { kind: "file", path: "/repo/worktree/b.ts" }); + const splitPaneId = store.splitPane(workspaceKey, { + tabId: secondTabId!, + targetPaneId: "main", + position: "right", + }); + + const nextTabId = store.retargetTab(workspaceKey, secondTabId!, { + kind: "agent", + agentId: "agent-1", + }); + const layout = useWorkspaceLayoutStore.getState().layoutByWorkspace[workspaceKey]!; + const splitPane = findPaneById(layout.root, splitPaneId!); + const retargetedTab = collectAllTabs(layout.root).find((tab) => tab.tabId === secondTabId); + + expect(splitPaneId).toBe("pane_12121212-1212-1212-1212-121212121212"); + expect(nextTabId).toBe(secondTabId); + expect(splitPane?.tabIds).toEqual([secondTabId!]); + expect(findPaneContainingTab(layout.root, secondTabId!)?.id).toBe(splitPaneId); + expect(retargetedTab).toEqual({ + tabId: secondTabId, + target: { kind: "agent", agentId: "agent-1" }, + createdAt: expect.any(Number), + }); + }); + + it("reorderTabs reorders tabs within the focused pane", () => { + const workspaceKey = createWorkspaceKey(); + const store = useWorkspaceLayoutStore.getState(); + + const firstTabId = store.openTab(workspaceKey, { kind: "file", path: "/repo/worktree/a.ts" }); + const secondTabId = store.openTab(workspaceKey, { kind: "file", path: "/repo/worktree/b.ts" }); + const thirdTabId = store.openTab(workspaceKey, { kind: "file", path: "/repo/worktree/c.ts" }); + + store.reorderTabs(workspaceKey, [thirdTabId!, firstTabId!]); + const layout = useWorkspaceLayoutStore.getState().layoutByWorkspace[workspaceKey]!; + + expect(findPaneById(layout.root, "main")).toEqual({ + id: "main", + tabIds: [thirdTabId!, firstTabId!, secondTabId!], + focusedTabId: thirdTabId, + tabs: [ + { tabId: thirdTabId, target: { kind: "file", path: "/repo/worktree/c.ts" }, createdAt: expect.any(Number) }, + { tabId: firstTabId, target: { kind: "file", path: "/repo/worktree/a.ts" }, createdAt: expect.any(Number) }, + { tabId: secondTabId, target: { kind: "file", path: "/repo/worktree/b.ts" }, createdAt: expect.any(Number) }, + ], + }); + }); + + it("reorderTabsInPane reorders tabs in the requested pane without changing focused pane", () => { + vi.spyOn(globalThis.crypto, "randomUUID").mockReturnValue( + "34343434-3434-3434-3434-343434343434" + ); + const workspaceKey = createWorkspaceKey(); + const store = useWorkspaceLayoutStore.getState(); + + store.openTab(workspaceKey, { kind: "file", path: "/repo/worktree/a.ts" }); + const secondTabId = store.openTab(workspaceKey, { kind: "file", path: "/repo/worktree/b.ts" }); + const thirdTabId = store.openTab(workspaceKey, { kind: "file", path: "/repo/worktree/c.ts" }); + const fourthTabId = store.openTab(workspaceKey, { kind: "file", path: "/repo/worktree/d.ts" }); + const splitPaneId = store.splitPane(workspaceKey, { + tabId: thirdTabId!, + targetPaneId: "main", + position: "right", + }); + + store.moveTabToPane(workspaceKey, fourthTabId!, splitPaneId!); + store.focusPane(workspaceKey, "main"); + store.reorderTabsInPane(workspaceKey, splitPaneId!, [fourthTabId!, thirdTabId!]); + const layout = useWorkspaceLayoutStore.getState().layoutByWorkspace[workspaceKey]!; + + expect(splitPaneId).toBe("pane_34343434-3434-3434-3434-343434343434"); + expect(layout.focusedPaneId).toBe("main"); + expect(findPaneById(layout.root, splitPaneId!)).toEqual({ + id: splitPaneId, + tabIds: [fourthTabId!, thirdTabId!], + focusedTabId: fourthTabId, + tabs: [ + { tabId: fourthTabId, target: { kind: "file", path: "/repo/worktree/d.ts" }, createdAt: expect.any(Number) }, + { tabId: thirdTabId, target: { kind: "file", path: "/repo/worktree/c.ts" }, createdAt: expect.any(Number) }, + ], + }); + }); + + it("focusPane switches workspace focus to a different pane", () => { + vi.spyOn(globalThis.crypto, "randomUUID").mockReturnValue( + "56565656-5656-5656-5656-565656565656" + ); + const workspaceKey = createWorkspaceKey(); + const store = useWorkspaceLayoutStore.getState(); + + store.openTab(workspaceKey, { kind: "file", path: "/repo/worktree/a.ts" }); + const secondTabId = store.openTab(workspaceKey, { kind: "file", path: "/repo/worktree/b.ts" }); + const splitPaneId = store.splitPane(workspaceKey, { + tabId: secondTabId!, + targetPaneId: "main", + position: "right", + }); + + store.focusPane(workspaceKey, "main"); + let layout = useWorkspaceLayoutStore.getState().layoutByWorkspace[workspaceKey]!; + expect(layout.focusedPaneId).toBe("main"); + + store.focusPane(workspaceKey, splitPaneId!); + layout = useWorkspaceLayoutStore.getState().layoutByWorkspace[workspaceKey]!; + + expect(splitPaneId).toBe("pane_56565656-5656-5656-5656-565656565656"); + expect(layout.focusedPaneId).toBe(splitPaneId); + }); + + it("closeTab collapses an emptied pane and keeps the nearest sibling focused", () => { + vi.spyOn(globalThis.crypto, "randomUUID").mockReturnValue( + "cccccccc-cccc-cccc-cccc-cccccccccccc" + ); + const workspaceKey = createWorkspaceKey(); + const store = useWorkspaceLayoutStore.getState(); + + store.openTab(workspaceKey, { kind: "file", path: "/repo/worktree/a.ts" }); + const secondTabId = store.openTab(workspaceKey, { kind: "file", path: "/repo/worktree/b.ts" }); + const splitPaneId = store.splitPane(workspaceKey, { + tabId: secondTabId!, + targetPaneId: "main", + position: "right", + }); + + store.closeTab(workspaceKey, secondTabId!); + const layout = useWorkspaceLayoutStore.getState().layoutByWorkspace[workspaceKey]!; + + expect(splitPaneId).toBe("pane_cccccccc-cccc-cccc-cccc-cccccccccccc"); + expect(layout.focusedPaneId).toBe("main"); + expect(collectAllPanes(layout.root).map((pane) => pane.id)).toEqual(["main"]); + }); + + it("splitPane enforces the maximum depth of four", () => { + vi.spyOn(globalThis.crypto, "randomUUID") + .mockReturnValueOnce("11111111-1111-1111-1111-111111111111") + .mockReturnValueOnce("22222222-2222-2222-2222-222222222222") + .mockReturnValueOnce("33333333-3333-3333-3333-333333333333") + .mockReturnValueOnce("44444444-4444-4444-4444-444444444444") + .mockReturnValueOnce("55555555-5555-5555-5555-555555555555") + .mockReturnValueOnce("66666666-6666-6666-6666-666666666666") + .mockReturnValueOnce("77777777-7777-7777-7777-777777777777") + .mockReturnValueOnce("88888888-8888-8888-8888-888888888888"); + + const workspaceKey = createWorkspaceKey(); + const store = useWorkspaceLayoutStore.getState(); + const a = store.openTab(workspaceKey, { kind: "file", path: "/repo/worktree/a.ts" }); + const b = store.openTab(workspaceKey, { kind: "file", path: "/repo/worktree/b.ts" }); + const c = store.openTab(workspaceKey, { kind: "file", path: "/repo/worktree/c.ts" }); + const d = store.openTab(workspaceKey, { kind: "file", path: "/repo/worktree/d.ts" }); + const e = store.openTab(workspaceKey, { kind: "file", path: "/repo/worktree/e.ts" }); + + expect(a).toBeTruthy(); + const pane1 = store.splitPane(workspaceKey, { + tabId: b!, + targetPaneId: "main", + position: "right", + }); + const pane2 = store.splitPane(workspaceKey, { + tabId: c!, + targetPaneId: pane1!, + position: "bottom", + }); + const pane3 = store.splitPane(workspaceKey, { + tabId: d!, + targetPaneId: pane2!, + position: "right", + }); + const pane4 = store.splitPane(workspaceKey, { + tabId: e!, + targetPaneId: pane3!, + position: "bottom", + }); + + const layout = useWorkspaceLayoutStore.getState().layoutByWorkspace[workspaceKey]!; + expect(pane1).toBe("pane_11111111-1111-1111-1111-111111111111"); + expect(pane2).toBe("pane_33333333-3333-3333-3333-333333333333"); + expect(pane3).toBe("pane_55555555-5555-5555-5555-555555555555"); + expect(pane4).toBeNull(); + expect(getTreeDepth(layout.root)).toBe(4); + }); + + it("moveTabToPane collapses the source pane when its last tab moves out", () => { + vi.spyOn(globalThis.crypto, "randomUUID").mockReturnValue( + "dddddddd-dddd-dddd-dddd-dddddddddddd" + ); + const workspaceKey = createWorkspaceKey(); + const store = useWorkspaceLayoutStore.getState(); + + const leftTabId = store.openTab(workspaceKey, { kind: "file", path: "/repo/worktree/a.ts" }); + const rightTabId = store.openTab(workspaceKey, { kind: "file", path: "/repo/worktree/b.ts" }); + const splitPaneId = store.splitPane(workspaceKey, { + tabId: rightTabId!, + targetPaneId: "main", + position: "right", + }); + + store.moveTabToPane(workspaceKey, leftTabId!, splitPaneId!); + const layout = useWorkspaceLayoutStore.getState().layoutByWorkspace[workspaceKey]!; + + expect(layout.focusedPaneId).toBe(splitPaneId); + expect(collectAllPanes(layout.root).map((pane) => pane.id)).toEqual([splitPaneId!]); + expect(findPaneById(layout.root, splitPaneId!)?.tabIds).toEqual([ + "file_/repo/worktree/b.ts", + "file_/repo/worktree/a.ts", + ]); + }); + + it("closeTab cascades group unwrapping when an inner split collapses to a single pane", () => { + vi.spyOn(globalThis.crypto, "randomUUID") + .mockReturnValueOnce("78787878-7878-7878-7878-787878787878") + .mockReturnValueOnce("89898989-8989-8989-8989-898989898989") + .mockReturnValueOnce("9a9a9a9a-9a9a-9a9a-9a9a-9a9a9a9a9a9a"); + + const workspaceKey = createWorkspaceKey(); + const store = useWorkspaceLayoutStore.getState(); + + store.openTab(workspaceKey, { kind: "file", path: "/repo/worktree/a.ts" }); + const secondTabId = store.openTab(workspaceKey, { kind: "file", path: "/repo/worktree/b.ts" }); + const thirdTabId = store.openTab(workspaceKey, { kind: "file", path: "/repo/worktree/c.ts" }); + const paneBId = store.splitPane(workspaceKey, { + tabId: secondTabId!, + targetPaneId: "main", + position: "right", + }); + const paneCId = store.splitPane(workspaceKey, { + tabId: thirdTabId!, + targetPaneId: paneBId!, + position: "bottom", + }); + + store.closeTab(workspaceKey, secondTabId!); + const layout = useWorkspaceLayoutStore.getState().layoutByWorkspace[workspaceKey]!; + const rootGroup = expectGroup(layout.root); + + expect(paneBId).toBe("pane_78787878-7878-7878-7878-787878787878"); + expect(paneCId).toBe("pane_89898989-8989-8989-8989-898989898989"); + expect(layout.focusedPaneId).toBe(paneCId); + expect(rootGroup.group.direction).toBe("horizontal"); + expect(rootGroup.group.children).toEqual([ + createPane({ id: "main", tabIds: ["file_/repo/worktree/a.ts"] }), + createPane({ id: paneCId!, tabIds: ["file_/repo/worktree/c.ts"] }), + ]); + expect(rootGroup.group.sizes).toEqual([0.5, 0.5]); + }); + + it("openTab focuses the existing tab instead of creating a duplicate entry", () => { + vi.spyOn(globalThis.crypto, "randomUUID").mockReturnValue( + "abababab-abab-abab-abab-abababababab" + ); + const workspaceKey = createWorkspaceKey(); + const store = useWorkspaceLayoutStore.getState(); + + store.openTab(workspaceKey, { kind: "file", path: "/repo/worktree/a.ts" }); + const secondTabId = store.openTab(workspaceKey, { kind: "file", path: "/repo/worktree/b.ts" }); + const splitPaneId = store.splitPane(workspaceKey, { + tabId: secondTabId!, + targetPaneId: "main", + position: "right", + }); + + store.focusPane(workspaceKey, "main"); + const duplicateTabId = store.openTab(workspaceKey, { kind: "file", path: "/repo/worktree/b.ts" }); + const layout = useWorkspaceLayoutStore.getState().layoutByWorkspace[workspaceKey]!; + + expect(splitPaneId).toBe("pane_abababab-abab-abab-abab-abababababab"); + expect(duplicateTabId).toBe(secondTabId); + expect(layout.focusedPaneId).toBe(splitPaneId); + expect(collectAllTabs(layout.root).map((tab) => tab.tabId)).toEqual([ + "file_/repo/worktree/a.ts", + "file_/repo/worktree/b.ts", + ]); + }); + + it("resizeSplit keeps sizes normalized while enforcing the minimum proportion", () => { + vi.spyOn(globalThis.crypto, "randomUUID") + .mockReturnValueOnce("eeeeeeee-eeee-eeee-eeee-eeeeeeeeeeee") + .mockReturnValueOnce("ffffffff-ffff-ffff-ffff-ffffffffffff"); + + const workspaceKey = createWorkspaceKey(); + const store = useWorkspaceLayoutStore.getState(); + + const a = store.openTab(workspaceKey, { kind: "file", path: "/repo/worktree/a.ts" }); + const b = store.openTab(workspaceKey, { kind: "file", path: "/repo/worktree/b.ts" }); + const c = store.openTab(workspaceKey, { kind: "file", path: "/repo/worktree/c.ts" }); + + expect(a).toBeTruthy(); + const rightPaneId = store.splitPane(workspaceKey, { + tabId: b!, + targetPaneId: "main", + position: "right", + }); + const farRightPaneId = store.splitPane(workspaceKey, { + tabId: c!, + targetPaneId: rightPaneId!, + position: "right", + }); + + const splitRoot = useWorkspaceLayoutStore.getState().layoutByWorkspace[workspaceKey]!.root; + const splitGroup = expectGroup(splitRoot); + store.resizeSplit(workspaceKey, splitGroup.group.id, [0.01, 0.01, 0.98]); + + const resizedRoot = useWorkspaceLayoutStore.getState().layoutByWorkspace[workspaceKey]!.root; + const resizedGroup = expectGroup(resizedRoot); + const total = resizedGroup.group.sizes.reduce((sum, size) => sum + size, 0); + + expect(rightPaneId).toBe("pane_eeeeeeee-eeee-eeee-eeee-eeeeeeeeeeee"); + expect(farRightPaneId).toBe("pane_ffffffff-ffff-ffff-ffff-ffffffffffff"); + expect(resizedGroup.group.sizes[0]).toBeGreaterThanOrEqual(0.1); + expect(resizedGroup.group.sizes[1]).toBeGreaterThanOrEqual(0.1); + expect(resizedGroup.group.sizes[2]).toBeGreaterThanOrEqual(0.1); + expect(total).toBeCloseTo(1, 10); + }); + + it("closing the last tab keeps a single empty pane in the layout", () => { + const workspaceKey = createWorkspaceKey(); + const store = useWorkspaceLayoutStore.getState(); + + const tabId = store.openTab(workspaceKey, { kind: "draft", draftId: "draft-1" }); + store.closeTab(workspaceKey, tabId!); + const layout = useWorkspaceLayoutStore.getState().layoutByWorkspace[workspaceKey]!; + + expect(layout).toEqual(createDefaultLayout()); + }); +}); diff --git a/packages/app/src/stores/workspace-layout-store.ts b/packages/app/src/stores/workspace-layout-store.ts new file mode 100644 index 000000000..07dae7033 --- /dev/null +++ b/packages/app/src/stores/workspace-layout-store.ts @@ -0,0 +1,1221 @@ +import AsyncStorage from "@react-native-async-storage/async-storage"; +import invariant from "tiny-invariant"; +import { create } from "zustand"; +import { createJSONStorage, persist } from "zustand/middleware"; +import { + buildWorkspaceTabPersistenceKey, + type WorkspaceTab, + type WorkspaceTabTarget, +} from "@/stores/workspace-tabs-store"; + +export { buildWorkspaceTabPersistenceKey }; + +export interface SplitPane { + id: string; + tabIds: string[]; + focusedTabId: string | null; +} + +export interface SplitGroup { + id: string; + direction: "horizontal" | "vertical"; + children: SplitNode[]; + sizes: number[]; +} + +export type SplitNode = { kind: "pane"; pane: SplitPane } | { kind: "group"; group: SplitGroup }; + +export interface WorkspaceLayout { + root: SplitNode; + focusedPaneId: string; +} + +interface SplitPaneInternal extends SplitPane { + tabs: WorkspaceTab[]; +} + +interface SplitGroupInternal extends Omit { + children: SplitNodeInternal[]; +} + +type SplitNodeInternal = + | { kind: "pane"; pane: SplitPaneInternal } + | { kind: "group"; group: SplitGroupInternal }; + +interface WorkspaceLayoutStore { + layoutByWorkspace: Record; + openTab: (workspaceKey: string, target: WorkspaceTabTarget) => string | null; + closeTab: (workspaceKey: string, tabId: string) => void; + focusTab: (workspaceKey: string, tabId: string) => void; + retargetTab: (workspaceKey: string, tabId: string, target: WorkspaceTabTarget) => string | null; + reorderTabs: (workspaceKey: string, tabIds: string[]) => void; + getWorkspaceTabs: (workspaceKey: string) => WorkspaceTab[]; + splitPane: ( + workspaceKey: string, + input: { + tabId: string; + targetPaneId: string; + position: "left" | "right" | "top" | "bottom"; + } + ) => string | null; + moveTabToPane: (workspaceKey: string, tabId: string, toPaneId: string) => void; + focusPane: (workspaceKey: string, paneId: string) => void; + resizeSplit: (workspaceKey: string, groupId: string, sizes: number[]) => void; + reorderTabsInPane: (workspaceKey: string, paneId: string, tabIds: string[]) => void; +} + +const DEFAULT_PANE_ID = "main"; +const MIN_SPLIT_SIZE = 0.1; +const MAX_TREE_DEPTH = 4; + +function trimNonEmpty(value: string | null | undefined): string | null { + if (typeof value !== "string") { + return null; + } + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : null; +} + +function normalizeTabTarget(value: WorkspaceTabTarget | null | undefined): WorkspaceTabTarget | null { + if (!value || typeof value !== "object" || typeof value.kind !== "string") { + return null; + } + if (value.kind === "draft") { + const draftId = trimNonEmpty(value.draftId); + return draftId ? { kind: "draft", draftId } : null; + } + if (value.kind === "agent") { + const agentId = trimNonEmpty(value.agentId); + return agentId ? { kind: "agent", agentId } : null; + } + if (value.kind === "terminal") { + const terminalId = trimNonEmpty(value.terminalId); + return terminalId ? { kind: "terminal", terminalId } : null; + } + if (value.kind === "file") { + const path = trimNonEmpty(value.path); + return path ? { kind: "file", path: path.replace(/\\/g, "/") } : null; + } + return null; +} + +function tabTargetsEqual(left: WorkspaceTabTarget, right: WorkspaceTabTarget): boolean { + if (left.kind !== right.kind) { + return false; + } + if (left.kind === "draft" && right.kind === "draft") { + return left.draftId === right.draftId; + } + if (left.kind === "agent" && right.kind === "agent") { + return left.agentId === right.agentId; + } + if (left.kind === "terminal" && right.kind === "terminal") { + return left.terminalId === right.terminalId; + } + if (left.kind === "file" && right.kind === "file") { + return left.path === right.path; + } + return false; +} + +function buildDeterministicTabId(target: WorkspaceTabTarget): string { + if (target.kind === "draft") { + return target.draftId; + } + if (target.kind === "agent") { + return `agent_${target.agentId}`; + } + if (target.kind === "terminal") { + return `terminal_${target.terminalId}`; + } + return `file_${target.path}`; +} + +function normalizeTabIds(list: unknown): string[] { + if (!Array.isArray(list)) { + return []; + } + const next: string[] = []; + const seen = new Set(); + for (const value of list) { + const tabId = trimNonEmpty(typeof value === "string" ? value : null); + if (!tabId || seen.has(tabId)) { + continue; + } + seen.add(tabId); + next.push(tabId); + } + return next; +} + +function generateNodeId(prefix: "pane" | "group"): string { + const randomValue = + typeof globalThis.crypto?.randomUUID === "function" + ? globalThis.crypto.randomUUID() + : `${Date.now()}-${Math.random().toString(16).slice(2)}`; + return `${prefix}_${randomValue}`; +} + +function createPaneNode(input: { id: string; tabs?: WorkspaceTab[]; focusedTabId?: string | null }): SplitNodeInternal { + const normalizedTabs = normalizeWorkspaceTabs(input.tabs ?? []); + const tabIds = normalizedTabs.map((tab) => tab.tabId); + const focusedTabId = tabIds.includes(input.focusedTabId ?? "") + ? (input.focusedTabId ?? null) + : tabIds[tabIds.length - 1] ?? null; + + return { + kind: "pane", + pane: { + id: input.id, + tabs: normalizedTabs, + tabIds, + focusedTabId, + }, + }; +} + +function createGroupNode(input: { + id: string; + direction: "horizontal" | "vertical"; + children: SplitNodeInternal[]; + sizes?: number[]; +}): SplitNodeInternal { + return { + kind: "group", + group: { + id: input.id, + direction: input.direction, + children: input.children, + sizes: normalizeSizes({ + sizes: input.sizes ?? input.children.map(() => 1 / Math.max(input.children.length, 1)), + count: input.children.length, + }), + }, + }; +} + +function normalizeWorkspaceTab(value: unknown): WorkspaceTab | null { + if (!value || typeof value !== "object") { + return null; + } + + const tab = value as WorkspaceTab; + const target = normalizeTabTarget(tab.target); + const tabId = trimNonEmpty(tab.tabId) ?? (target ? buildDeterministicTabId(target) : null); + if (!target || !tabId) { + return null; + } + + return { + tabId, + target, + createdAt: typeof tab.createdAt === "number" ? tab.createdAt : Date.now(), + }; +} + +function normalizeWorkspaceTabs(input: unknown): WorkspaceTab[] { + if (!Array.isArray(input)) { + return []; + } + const next: WorkspaceTab[] = []; + const seen = new Set(); + for (const value of input) { + const tab = normalizeWorkspaceTab(value); + if (!tab || seen.has(tab.tabId)) { + continue; + } + seen.add(tab.tabId); + next.push(tab); + } + return next; +} + +function normalizeSizes(input: { sizes: number[]; count: number }): number[] { + if (input.count <= 0) { + return []; + } + + const raw = input.sizes.slice(0, input.count); + while (raw.length < input.count) { + raw.push(1); + } + + const sanitized = raw.map((value) => (Number.isFinite(value) && value > 0 ? value : 1)); + const total = sanitized.reduce((sum, value) => sum + value, 0); + if (total <= 0) { + return Array.from({ length: input.count }, () => 1 / input.count); + } + return sanitized.map((value) => value / total); +} + +function clampNormalizedSizes(sizes: number[]): number[] { + if (sizes.length === 0) { + return []; + } + + const normalized = normalizeSizes({ sizes, count: sizes.length }); + if (sizes.length === 1) { + return [1]; + } + if (sizes.length * MIN_SPLIT_SIZE > 1) { + return Array.from({ length: sizes.length }, () => 1 / sizes.length); + } + + const nextSizes = Array.from({ length: sizes.length }, () => 0); + const unlocked = new Set(normalized.map((_, index) => index)); + let remainingTotal = 1; + + while (unlocked.size > 0) { + let unlockedWeight = 0; + for (const index of unlocked) { + unlockedWeight += normalized[index] ?? 0; + } + + if (unlockedWeight <= 0) { + const evenShare = remainingTotal / unlocked.size; + for (const index of unlocked) { + nextSizes[index] = evenShare; + } + break; + } + + const nextLocked: number[] = []; + for (const index of unlocked) { + const proposedSize = ((normalized[index] ?? 0) / unlockedWeight) * remainingTotal; + if (proposedSize < MIN_SPLIT_SIZE) { + nextLocked.push(index); + } + } + + if (nextLocked.length === 0) { + for (const index of unlocked) { + nextSizes[index] = ((normalized[index] ?? 0) / unlockedWeight) * remainingTotal; + } + break; + } + + for (const index of nextLocked) { + nextSizes[index] = MIN_SPLIT_SIZE; + unlocked.delete(index); + remainingTotal -= MIN_SPLIT_SIZE; + } + } + + return normalizeSizes({ sizes: nextSizes, count: nextSizes.length }); +} + +function asInternalNode(node: SplitNode): SplitNodeInternal { + return node as SplitNodeInternal; +} + +function asInternalLayout(layout: WorkspaceLayout): { root: SplitNodeInternal; focusedPaneId: string } { + return layout as { root: SplitNodeInternal; focusedPaneId: string }; +} + +function findPanePathById(node: SplitNodeInternal, paneId: string, path: number[] = []): number[] | null { + if (node.kind === "pane") { + return node.pane.id === paneId ? path : null; + } + for (let index = 0; index < node.group.children.length; index += 1) { + const childPath = findPanePathById(node.group.children[index], paneId, [...path, index]); + if (childPath) { + return childPath; + } + } + return null; +} + +function findPanePathContainingTab(node: SplitNodeInternal, tabId: string, path: number[] = []): number[] | null { + if (node.kind === "pane") { + return node.pane.tabs.some((tab) => tab.tabId === tabId) ? path : null; + } + for (let index = 0; index < node.group.children.length; index += 1) { + const childPath = findPanePathContainingTab(node.group.children[index], tabId, [...path, index]); + if (childPath) { + return childPath; + } + } + return null; +} + +function findGroupPathById(node: SplitNodeInternal, groupId: string, path: number[] = []): number[] | null { + if (node.kind === "pane") { + return null; + } + if (node.group.id === groupId) { + return path; + } + for (let index = 0; index < node.group.children.length; index += 1) { + const childPath = findGroupPathById(node.group.children[index], groupId, [...path, index]); + if (childPath) { + return childPath; + } + } + return null; +} + +function getNodeAtPath(node: SplitNodeInternal, path: number[]): SplitNodeInternal { + let current = node; + for (const index of path) { + invariant(current.kind === "group", "Expected group while traversing split tree"); + current = current.group.children[index]; + } + return current; +} + +function replaceNodeAtPath( + node: SplitNodeInternal, + path: number[], + updater: (node: SplitNodeInternal) => SplitNodeInternal +): SplitNodeInternal { + if (path.length === 0) { + return updater(node); + } + + invariant(node.kind === "group", "Expected group while replacing split tree node"); + const [index, ...rest] = path; + const nextChildren = node.group.children.map((child, childIndex) => + childIndex === index ? replaceNodeAtPath(child, rest, updater) : child + ); + + return createGroupNode({ + id: node.group.id, + direction: node.group.direction, + children: nextChildren, + sizes: node.group.sizes, + }); +} + +function insertChildIntoGroup( + groupNode: SplitNodeInternal, + input: { index: number; node: SplitNodeInternal; sizes: number[] } +): SplitNodeInternal { + invariant(groupNode.kind === "group", "Expected group for split insertion"); + const nextChildren = groupNode.group.children.slice(); + nextChildren.splice(input.index, 0, input.node); + return createGroupNode({ + id: groupNode.group.id, + direction: groupNode.group.direction, + children: nextChildren, + sizes: input.sizes, + }); +} + +function listPaneIds(node: SplitNodeInternal): string[] { + if (node.kind === "pane") { + return [node.pane.id]; + } + const next: string[] = []; + for (const child of node.group.children) { + next.push(...listPaneIds(child)); + } + return next; +} + +function findNearestSiblingPaneId(root: SplitNodeInternal, paneId: string): string | null { + const path = findPanePathById(root, paneId); + if (!path || path.length === 0) { + return null; + } + + for (let depth = path.length - 1; depth >= 0; depth -= 1) { + const parentPath = path.slice(0, depth); + const childIndex = path[depth]!; + const parentNode = getNodeAtPath(root, parentPath); + invariant(parentNode.kind === "group", "Expected parent group for pane lookup"); + + for (let index = childIndex - 1; index >= 0; index -= 1) { + const paneIds = listPaneIds(parentNode.group.children[index]); + if (paneIds.length > 0) { + return paneIds[paneIds.length - 1] ?? null; + } + } + + for (let index = childIndex + 1; index < parentNode.group.children.length; index += 1) { + const paneIds = listPaneIds(parentNode.group.children[index]); + if (paneIds.length > 0) { + return paneIds[0] ?? null; + } + } + } + + return null; +} + +function normalizePaneAfterTabChange(pane: SplitPaneInternal): SplitPaneInternal { + const tabs = normalizeWorkspaceTabs(pane.tabs); + const tabIds = tabs.map((tab) => tab.tabId); + const focusedTabId = tabIds.includes(pane.focusedTabId ?? "") + ? pane.focusedTabId + : tabIds[tabIds.length - 1] ?? null; + + return { + id: pane.id, + tabs, + tabIds, + focusedTabId, + }; +} + +function normalizeNode(node: unknown): SplitNodeInternal | null { + if (!node || typeof node !== "object") { + return null; + } + + if ((node as SplitNode).kind === "pane") { + const rawPane = (node as { pane?: SplitPaneInternal }).pane; + const paneId = trimNonEmpty(rawPane?.id); + if (!paneId) { + return null; + } + const tabs = normalizeWorkspaceTabs(rawPane?.tabs); + const tabIds = normalizeTabIds(rawPane?.tabIds); + const mergedTabs = (() => { + if (tabs.length > 0) { + return tabs; + } + return tabIds.map((tabId) => ({ + tabId, + target: { kind: "draft", draftId: tabId } as WorkspaceTabTarget, + createdAt: Date.now(), + })); + })(); + return createPaneNode({ + id: paneId, + tabs: mergedTabs, + focusedTabId: trimNonEmpty(rawPane?.focusedTabId) ?? null, + }); + } + + if ((node as SplitNode).kind === "group") { + const rawGroup = (node as { group?: SplitGroupInternal }).group; + if (!rawGroup) { + return null; + } + const groupId = trimNonEmpty(rawGroup?.id); + const direction = rawGroup?.direction; + if (!groupId || (direction !== "horizontal" && direction !== "vertical")) { + return null; + } + + const children = Array.isArray(rawGroup.children) + ? rawGroup.children.map((child) => normalizeNode(child)).filter((child): child is SplitNodeInternal => child !== null) + : []; + if (children.length === 0) { + return null; + } + if (children.length === 1) { + return children[0] ?? null; + } + + return createGroupNode({ + id: groupId, + direction, + children, + sizes: Array.isArray(rawGroup.sizes) ? rawGroup.sizes : [], + }); + } + + return null; +} + +function normalizeLayout(layout: unknown): WorkspaceLayout { + if (!layout || typeof layout !== "object") { + return createDefaultLayout(); + } + + const rawLayout = layout as WorkspaceLayout; + const root = normalizeNode(rawLayout.root) ?? asInternalNode(createDefaultLayout().root); + const focusedPaneId = trimNonEmpty(rawLayout.focusedPaneId); + const resolvedFocusedPaneId = + (focusedPaneId && findPaneById(root, focusedPaneId)?.id) ?? + collectAllPanes(root)[0]?.id ?? + DEFAULT_PANE_ID; + + return { + root, + focusedPaneId: resolvedFocusedPaneId, + }; +} + +function getWorkspaceLayout(state: Record, workspaceKey: string): WorkspaceLayout { + return normalizeLayout(state[workspaceKey] ?? createDefaultLayout()); +} + +function reorderTabsForPane(input: { pane: SplitPaneInternal; tabIds: string[] }): SplitPaneInternal { + const nextIds = normalizeTabIds(input.tabIds); + const byId = new Map(input.pane.tabs.map((tab) => [tab.tabId, tab])); + const reordered: WorkspaceTab[] = []; + const seen = new Set(); + + for (const tabId of nextIds) { + const tab = byId.get(tabId); + if (!tab || seen.has(tabId)) { + continue; + } + seen.add(tabId); + reordered.push(tab); + } + + for (const tab of input.pane.tabs) { + if (seen.has(tab.tabId)) { + continue; + } + seen.add(tab.tabId); + reordered.push(tab); + } + + return normalizePaneAfterTabChange({ + ...input.pane, + tabs: reordered, + }); +} + +function removePaneByPath(root: SplitNodeInternal, path: number[]): SplitNodeInternal { + if (path.length === 0) { + invariant(root.kind === "pane", "Expected pane at root while removing pane"); + return createPaneNode({ id: root.pane.id }); + } + + const parentPath = path.slice(0, -1); + const removeIndex = path[path.length - 1]!; + const parentNode = getNodeAtPath(root, parentPath); + invariant(parentNode.kind === "group", "Expected parent group while removing pane"); + + const nextParentChildren = parentNode.group.children.filter((_, index) => index !== removeIndex); + invariant(nextParentChildren.length > 0, "Split tree cannot remove the final pane"); + + const nextParentNode = + nextParentChildren.length === 1 + ? nextParentChildren[0]! + : createGroupNode({ + id: parentNode.group.id, + direction: parentNode.group.direction, + children: nextParentChildren, + sizes: parentNode.group.sizes.filter((_, index) => index !== removeIndex), + }); + + return replaceNodeAtPath(root, parentPath, () => nextParentNode); +} + +function detachTabFromTree( + root: SplitNodeInternal, + input: { tabId: string; preserveEmptyPaneId?: string | null } +): { root: SplitNodeInternal; tab: WorkspaceTab | null; sourcePaneId: string | null } { + const panePath = findPanePathContainingTab(root, input.tabId); + if (!panePath) { + return { root, tab: null, sourcePaneId: null }; + } + + const paneNode = getNodeAtPath(root, panePath); + invariant(paneNode.kind === "pane", "Expected pane while detaching tab"); + const tab = paneNode.pane.tabs.find((entry) => entry.tabId === input.tabId) ?? null; + if (!tab) { + return { root, tab: null, sourcePaneId: paneNode.pane.id }; + } + + const nextPane = normalizePaneAfterTabChange({ + ...paneNode.pane, + tabs: paneNode.pane.tabs.filter((entry) => entry.tabId !== input.tabId), + }); + + const nextRoot = replaceNodeAtPath(root, panePath, () => ({ kind: "pane", pane: nextPane })); + if (nextPane.tabs.length > 0 || nextPane.id === input.preserveEmptyPaneId) { + return { root: nextRoot, tab, sourcePaneId: paneNode.pane.id }; + } + + return { + root: removePaneByPath(nextRoot, panePath), + tab, + sourcePaneId: paneNode.pane.id, + }; +} + +function insertTabIntoPane( + root: SplitNodeInternal, + input: { paneId: string; tab: WorkspaceTab; focusTabId?: string | null } +): SplitNodeInternal { + const panePath = findPanePathById(root, input.paneId); + invariant(panePath, `Pane not found: ${input.paneId}`); + return replaceNodeAtPath(root, panePath, (node) => { + invariant(node.kind === "pane", "Expected pane while inserting tab"); + const existingIndex = node.pane.tabs.findIndex((tab) => tab.tabId === input.tab.tabId); + const nextTabs = + existingIndex >= 0 + ? node.pane.tabs.map((tab, index) => (index === existingIndex ? input.tab : tab)) + : [...node.pane.tabs, input.tab]; + return { + kind: "pane", + pane: normalizePaneAfterTabChange({ + ...node.pane, + tabs: nextTabs, + focusedTabId: input.focusTabId ?? input.tab.tabId, + }), + }; + }); +} + +function focusTabInPane(root: SplitNodeInternal, paneId: string, tabId: string): SplitNodeInternal { + const panePath = findPanePathById(root, paneId); + invariant(panePath, `Pane not found: ${paneId}`); + return replaceNodeAtPath(root, panePath, (node) => { + invariant(node.kind === "pane", "Expected pane while focusing tab"); + return { + kind: "pane", + pane: normalizePaneAfterTabChange({ + ...node.pane, + focusedTabId: tabId, + }), + }; + }); +} + +function updateTabInTree( + root: SplitNodeInternal, + input: { tabId: string; target: WorkspaceTabTarget } +): SplitNodeInternal { + const panePath = findPanePathContainingTab(root, input.tabId); + invariant(panePath, `Tab not found: ${input.tabId}`); + return replaceNodeAtPath(root, panePath, (node) => { + invariant(node.kind === "pane", "Expected pane while retargeting tab"); + return { + kind: "pane", + pane: normalizePaneAfterTabChange({ + ...node.pane, + tabs: node.pane.tabs.map((tab) => + tab.tabId === input.tabId ? { ...tab, target: input.target } : tab + ), + }), + }; + }); +} + +function updateGroupSizesInTree( + root: SplitNodeInternal, + input: { groupId: string; sizes: number[] } +): SplitNodeInternal { + const groupPath = findGroupPathById(root, input.groupId); + if (!groupPath) { + return root; + } + return replaceNodeAtPath(root, groupPath, (node) => { + invariant(node.kind === "group", "Expected group while resizing split"); + if (input.sizes.length !== node.group.children.length) { + return node; + } + return createGroupNode({ + id: node.group.id, + direction: node.group.direction, + children: node.group.children, + sizes: clampNormalizedSizes(input.sizes), + }); + }); +} + +function updatePaneInTree( + root: SplitNodeInternal, + input: { paneId: string; updater: (pane: SplitPaneInternal) => SplitPaneInternal } +): SplitNodeInternal { + const panePath = findPanePathById(root, input.paneId); + if (!panePath) { + return root; + } + return replaceNodeAtPath(root, panePath, (node) => { + invariant(node.kind === "pane", "Expected pane while updating pane"); + return { + kind: "pane", + pane: normalizePaneAfterTabChange(input.updater(node.pane)), + }; + }); +} + +function insertSplitInternal( + root: SplitNodeInternal, + targetPaneId: string, + tabId: string, + position: "left" | "right" | "top" | "bottom" +): { root: SplitNodeInternal; newPaneId: string } { + const direction = position === "left" || position === "right" ? "horizontal" : "vertical"; + const insertAfter = position === "right" || position === "bottom"; + + const targetPathBeforeDetach = findPanePathById(root, targetPaneId); + invariant(targetPathBeforeDetach, `Target pane not found: ${targetPaneId}`); + + const detached = detachTabFromTree(root, { tabId, preserveEmptyPaneId: targetPaneId }); + invariant(detached.tab, `Tab not found: ${tabId}`); + + const targetPath = findPanePathById(detached.root, targetPaneId); + invariant(targetPath, `Target pane not found after detach: ${targetPaneId}`); + const targetNode = getNodeAtPath(detached.root, targetPath); + invariant(targetNode.kind === "pane", "Expected target pane after detach"); + + const newPaneId = generateNodeId("pane"); + const newPaneNode = createPaneNode({ + id: newPaneId, + tabs: [detached.tab], + focusedTabId: detached.tab.tabId, + }); + + const parentPath = targetPath.slice(0, -1); + const targetIndex = targetPath[targetPath.length - 1] ?? 0; + const parentNode = parentPath.length > 0 ? getNodeAtPath(detached.root, parentPath) : null; + + if (parentNode?.kind === "group" && parentNode.group.direction === direction) { + const targetSize = parentNode.group.sizes[targetIndex] ?? 0; + const nextSizes = parentNode.group.sizes.slice(); + const insertIndex = insertAfter ? targetIndex + 1 : targetIndex; + nextSizes.splice(insertIndex, 0, targetSize / 2); + nextSizes[targetIndex + (insertAfter ? 0 : 1)] = targetSize / 2; + + return { + root: replaceNodeAtPath(detached.root, parentPath, () => + insertChildIntoGroup(parentNode, { + index: insertIndex, + node: newPaneNode, + sizes: nextSizes, + }) + ), + newPaneId, + }; + } + + const newGroup = createGroupNode({ + id: generateNodeId("group"), + direction, + children: insertAfter ? [targetNode, newPaneNode] : [newPaneNode, targetNode], + sizes: [0.5, 0.5], + }); + + return { + root: replaceNodeAtPath(detached.root, targetPath, () => newGroup), + newPaneId, + }; +} + +export function findPaneById(root: SplitNode, paneId: string): SplitPane | null { + const internalRoot = asInternalNode(root); + if (internalRoot.kind === "pane") { + return internalRoot.pane.id === paneId ? internalRoot.pane : null; + } + for (const child of internalRoot.group.children) { + const pane = findPaneById(child, paneId); + if (pane) { + return pane; + } + } + return null; +} + +export function findPaneContainingTab(root: SplitNode, tabId: string): SplitPane | null { + const internalRoot = asInternalNode(root); + if (internalRoot.kind === "pane") { + return internalRoot.pane.tabs.some((tab) => tab.tabId === tabId) ? internalRoot.pane : null; + } + for (const child of internalRoot.group.children) { + const pane = findPaneContainingTab(child, tabId); + if (pane) { + return pane; + } + } + return null; +} + +export function getTreeDepth(node: SplitNode): number { + const internalNode = asInternalNode(node); + if (internalNode.kind === "pane") { + return 1; + } + return 1 + Math.max(...internalNode.group.children.map((child) => getTreeDepth(child))); +} + +export function collectAllTabs(root: SplitNode): WorkspaceTab[] { + const internalRoot = asInternalNode(root); + if (internalRoot.kind === "pane") { + return internalRoot.pane.tabs.slice(); + } + return internalRoot.group.children.flatMap((child) => collectAllTabs(child)); +} + +export function collectAllPanes(root: SplitNode): SplitPane[] { + const internalRoot = asInternalNode(root); + if (internalRoot.kind === "pane") { + return [internalRoot.pane]; + } + return internalRoot.group.children.flatMap((child) => collectAllPanes(child)); +} + +export function createDefaultLayout(): WorkspaceLayout { + return { + root: createPaneNode({ id: DEFAULT_PANE_ID }), + focusedPaneId: DEFAULT_PANE_ID, + }; +} + +export function insertSplit( + root: SplitNode, + targetPaneId: string, + tabId: string, + position: "left" | "right" | "top" | "bottom" +): SplitNode { + return insertSplitInternal(asInternalNode(root), targetPaneId, tabId, position).root; +} + +export function removePaneFromTree(root: SplitNode, paneId: string): SplitNode { + const internalRoot = asInternalNode(root); + const panePath = findPanePathById(internalRoot, paneId); + if (!panePath) { + return root; + } + return removePaneByPath(internalRoot, panePath); +} + +export function removeTabFromTree(root: SplitNode, tabId: string): SplitNode { + return detachTabFromTree(asInternalNode(root), { tabId }).root; +} + +export const useWorkspaceLayoutStore = create()( + persist( + (set, get) => ({ + layoutByWorkspace: {}, + openTab: (workspaceKey, target) => { + const normalizedWorkspaceKey = trimNonEmpty(workspaceKey); + const normalizedTarget = normalizeTabTarget(target); + if (!normalizedWorkspaceKey || !normalizedTarget) { + return null; + } + + const layout = asInternalLayout(getWorkspaceLayout(get().layoutByWorkspace, normalizedWorkspaceKey)); + const existingTab = collectAllTabs(layout.root).find((tab) => tabTargetsEqual(tab.target, normalizedTarget)); + if (existingTab) { + get().focusTab(normalizedWorkspaceKey, existingTab.tabId); + return existingTab.tabId; + } + + const focusedPane = + findPaneById(layout.root, layout.focusedPaneId) ?? + collectAllPanes(layout.root)[0] ?? + findPaneById(createDefaultLayout().root, DEFAULT_PANE_ID); + invariant(focusedPane, "Workspace layout must always have a pane"); + + const tabId = buildDeterministicTabId(normalizedTarget); + const nextTab: WorkspaceTab = { + tabId, + target: normalizedTarget, + createdAt: Date.now(), + }; + + const nextRoot = insertTabIntoPane(layout.root, { + paneId: focusedPane.id, + tab: nextTab, + focusTabId: tabId, + }); + + set((state) => ({ + layoutByWorkspace: { + ...state.layoutByWorkspace, + [normalizedWorkspaceKey]: { + root: nextRoot, + focusedPaneId: focusedPane.id, + }, + }, + })); + + return tabId; + }, + closeTab: (workspaceKey, tabId) => { + const normalizedWorkspaceKey = trimNonEmpty(workspaceKey); + const normalizedTabId = trimNonEmpty(tabId); + if (!normalizedWorkspaceKey || !normalizedTabId) { + return; + } + + set((state) => { + const layout = asInternalLayout(getWorkspaceLayout(state.layoutByWorkspace, normalizedWorkspaceKey)); + const pane = findPaneContainingTab(layout.root, normalizedTabId); + if (!pane) { + return state; + } + + const fallbackPaneId = findNearestSiblingPaneId(layout.root, pane.id); + const nextRoot = removeTabFromTree(layout.root, normalizedTabId) as SplitNodeInternal; + const nextFocusedPaneId = + findPaneById(nextRoot, layout.focusedPaneId)?.id ?? + (fallbackPaneId && findPaneById(nextRoot, fallbackPaneId)?.id) ?? + collectAllPanes(nextRoot)[0]?.id ?? + DEFAULT_PANE_ID; + + return { + layoutByWorkspace: { + ...state.layoutByWorkspace, + [normalizedWorkspaceKey]: { + root: nextRoot, + focusedPaneId: nextFocusedPaneId, + }, + }, + }; + }); + }, + focusTab: (workspaceKey, tabId) => { + const normalizedWorkspaceKey = trimNonEmpty(workspaceKey); + const normalizedTabId = trimNonEmpty(tabId); + if (!normalizedWorkspaceKey || !normalizedTabId) { + return; + } + + set((state) => { + const layout = asInternalLayout(getWorkspaceLayout(state.layoutByWorkspace, normalizedWorkspaceKey)); + const pane = findPaneContainingTab(layout.root, normalizedTabId); + if (!pane) { + return state; + } + const nextRoot = focusTabInPane(layout.root, pane.id, normalizedTabId); + return { + layoutByWorkspace: { + ...state.layoutByWorkspace, + [normalizedWorkspaceKey]: { + root: nextRoot, + focusedPaneId: pane.id, + }, + }, + }; + }); + }, + retargetTab: (workspaceKey, tabId, target) => { + const normalizedWorkspaceKey = trimNonEmpty(workspaceKey); + const normalizedTabId = trimNonEmpty(tabId); + const normalizedTarget = normalizeTabTarget(target); + if (!normalizedWorkspaceKey || !normalizedTabId || !normalizedTarget) { + return null; + } + + let resolvedTabId: string | null = null; + set((state) => { + const layout = asInternalLayout(getWorkspaceLayout(state.layoutByWorkspace, normalizedWorkspaceKey)); + const pane = findPaneContainingTab(layout.root, normalizedTabId); + if (!pane) { + return state; + } + const currentTab = collectAllTabs(layout.root).find((tab) => tab.tabId === normalizedTabId) ?? null; + if (currentTab && tabTargetsEqual(currentTab.target, normalizedTarget)) { + resolvedTabId = normalizedTabId; + return state; + } + resolvedTabId = normalizedTabId; + return { + layoutByWorkspace: { + ...state.layoutByWorkspace, + [normalizedWorkspaceKey]: { + root: updateTabInTree(layout.root, { + tabId: normalizedTabId, + target: normalizedTarget, + }), + focusedPaneId: layout.focusedPaneId, + }, + }, + }; + }); + + return resolvedTabId; + }, + reorderTabs: (workspaceKey, tabIds) => { + const normalizedWorkspaceKey = trimNonEmpty(workspaceKey); + if (!normalizedWorkspaceKey) { + return; + } + + set((state) => { + const layout = asInternalLayout(getWorkspaceLayout(state.layoutByWorkspace, normalizedWorkspaceKey)); + if (!findPaneById(layout.root, layout.focusedPaneId)) { + return state; + } + return { + layoutByWorkspace: { + ...state.layoutByWorkspace, + [normalizedWorkspaceKey]: { + root: updatePaneInTree(layout.root, { + paneId: layout.focusedPaneId, + updater: (pane) => reorderTabsForPane({ pane, tabIds }), + }), + focusedPaneId: layout.focusedPaneId, + }, + }, + }; + }); + }, + getWorkspaceTabs: (workspaceKey) => { + const normalizedWorkspaceKey = trimNonEmpty(workspaceKey); + if (!normalizedWorkspaceKey) { + return []; + } + const layout = asInternalLayout(getWorkspaceLayout(get().layoutByWorkspace, normalizedWorkspaceKey)); + return collectAllTabs(layout.root); + }, + splitPane: (workspaceKey, input) => { + const normalizedWorkspaceKey = trimNonEmpty(workspaceKey); + const normalizedTabId = trimNonEmpty(input.tabId); + const normalizedTargetPaneId = trimNonEmpty(input.targetPaneId); + if (!normalizedWorkspaceKey || !normalizedTabId || !normalizedTargetPaneId) { + return null; + } + + let newPaneId: string | null = null; + set((state) => { + const layout = asInternalLayout(getWorkspaceLayout(state.layoutByWorkspace, normalizedWorkspaceKey)); + if (!findPaneById(layout.root, normalizedTargetPaneId)) { + return state; + } + if (!findPaneContainingTab(layout.root, normalizedTabId)) { + return state; + } + + const result = insertSplitInternal( + layout.root, + normalizedTargetPaneId, + normalizedTabId, + input.position + ); + if (getTreeDepth(result.root) > MAX_TREE_DEPTH) { + return state; + } + + newPaneId = result.newPaneId; + return { + layoutByWorkspace: { + ...state.layoutByWorkspace, + [normalizedWorkspaceKey]: { + root: result.root, + focusedPaneId: result.newPaneId, + }, + }, + }; + }); + + return newPaneId; + }, + moveTabToPane: (workspaceKey, tabId, toPaneId) => { + const normalizedWorkspaceKey = trimNonEmpty(workspaceKey); + const normalizedTabId = trimNonEmpty(tabId); + const normalizedToPaneId = trimNonEmpty(toPaneId); + if (!normalizedWorkspaceKey || !normalizedTabId || !normalizedToPaneId) { + return; + } + + set((state) => { + const layout = asInternalLayout(getWorkspaceLayout(state.layoutByWorkspace, normalizedWorkspaceKey)); + const sourcePane = findPaneContainingTab(layout.root, normalizedTabId); + if (!sourcePane || !findPaneById(layout.root, normalizedToPaneId)) { + return state; + } + + const detached = detachTabFromTree(layout.root, { + tabId: normalizedTabId, + preserveEmptyPaneId: sourcePane.id === normalizedToPaneId ? normalizedToPaneId : null, + }); + if (!detached.tab) { + return state; + } + + const nextRoot = insertTabIntoPane(detached.root, { + paneId: normalizedToPaneId, + tab: detached.tab, + focusTabId: normalizedTabId, + }); + + return { + layoutByWorkspace: { + ...state.layoutByWorkspace, + [normalizedWorkspaceKey]: { + root: nextRoot, + focusedPaneId: normalizedToPaneId, + }, + }, + }; + }); + }, + focusPane: (workspaceKey, paneId) => { + const normalizedWorkspaceKey = trimNonEmpty(workspaceKey); + const normalizedPaneId = trimNonEmpty(paneId); + if (!normalizedWorkspaceKey || !normalizedPaneId) { + return; + } + set((state) => { + const layout = getWorkspaceLayout(state.layoutByWorkspace, normalizedWorkspaceKey); + if (!findPaneById(layout.root, normalizedPaneId)) { + return state; + } + return { + layoutByWorkspace: { + ...state.layoutByWorkspace, + [normalizedWorkspaceKey]: { + root: layout.root, + focusedPaneId: normalizedPaneId, + }, + }, + }; + }); + }, + resizeSplit: (workspaceKey, groupId, sizes) => { + const normalizedWorkspaceKey = trimNonEmpty(workspaceKey); + const normalizedGroupId = trimNonEmpty(groupId); + if (!normalizedWorkspaceKey || !normalizedGroupId) { + return; + } + + set((state) => { + const layout = asInternalLayout(getWorkspaceLayout(state.layoutByWorkspace, normalizedWorkspaceKey)); + const nextRoot = updateGroupSizesInTree(layout.root, { + groupId: normalizedGroupId, + sizes, + }); + return { + layoutByWorkspace: { + ...state.layoutByWorkspace, + [normalizedWorkspaceKey]: { + root: nextRoot, + focusedPaneId: layout.focusedPaneId, + }, + }, + }; + }); + }, + reorderTabsInPane: (workspaceKey, paneId, tabIds) => { + const normalizedWorkspaceKey = trimNonEmpty(workspaceKey); + const normalizedPaneId = trimNonEmpty(paneId); + if (!normalizedWorkspaceKey || !normalizedPaneId) { + return; + } + + set((state) => { + const layout = asInternalLayout(getWorkspaceLayout(state.layoutByWorkspace, normalizedWorkspaceKey)); + if (!findPaneById(layout.root, normalizedPaneId)) { + return state; + } + return { + layoutByWorkspace: { + ...state.layoutByWorkspace, + [normalizedWorkspaceKey]: { + root: updatePaneInTree(layout.root, { + paneId: normalizedPaneId, + updater: (pane) => reorderTabsForPane({ pane, tabIds }), + }), + focusedPaneId: layout.focusedPaneId, + }, + }, + }; + }); + }, + }), + { + name: "workspace-layout-state", + version: 1, + storage: createJSONStorage(() => AsyncStorage), + partialize: (state) => { + const layoutByWorkspace: Record = {}; + for (const key in state.layoutByWorkspace) { + layoutByWorkspace[key] = normalizeLayout(state.layoutByWorkspace[key]); + } + return { layoutByWorkspace }; + }, + } + ) +); diff --git a/packages/app/src/utils/split-navigation.test.ts b/packages/app/src/utils/split-navigation.test.ts new file mode 100644 index 000000000..f80b1ad6e --- /dev/null +++ b/packages/app/src/utils/split-navigation.test.ts @@ -0,0 +1,83 @@ +import { describe, expect, it } from "vitest"; +import type { SplitNode } from "@/stores/workspace-layout-store"; +import { findAdjacentPane } from "./split-navigation"; + +function createPaneNode(id: string): SplitNode { + return { + kind: "pane", + pane: { + id, + tabIds: [], + focusedTabId: null, + }, + }; +} + +function createGroupNode(input: { + direction: "horizontal" | "vertical"; + sizes: number[]; + children: SplitNode[]; +}): SplitNode { + return { + kind: "group", + group: { + id: `${input.direction}-group`, + direction: input.direction, + sizes: input.sizes, + children: input.children, + }, + }; +} + +describe("findAdjacentPane", () => { + it("finds direct horizontal and vertical neighbors in nested layouts", () => { + const root = createGroupNode({ + direction: "horizontal", + sizes: [0.25, 0.5, 0.25], + children: [ + createPaneNode("left"), + createGroupNode({ + direction: "vertical", + sizes: [0.5, 0.5], + children: [createPaneNode("top-middle"), createPaneNode("bottom-middle")], + }), + createPaneNode("right"), + ], + }); + + expect(findAdjacentPane(root, "top-middle", "left")).toBe("left"); + expect(findAdjacentPane(root, "top-middle", "right")).toBe("right"); + expect(findAdjacentPane(root, "top-middle", "down")).toBe("bottom-middle"); + expect(findAdjacentPane(root, "bottom-middle", "up")).toBe("top-middle"); + }); + + it("returns null when there is no pane in the requested direction", () => { + const root = createGroupNode({ + direction: "horizontal", + sizes: [0.5, 0.5], + children: [createPaneNode("left"), createPaneNode("right")], + }); + + expect(findAdjacentPane(root, "left", "left")).toBeNull(); + expect(findAdjacentPane(root, "right", "right")).toBeNull(); + expect(findAdjacentPane(root, "left", "up")).toBeNull(); + }); + + it("prefers the closest overlapping pane when multiple candidates exist", () => { + const root = createGroupNode({ + direction: "vertical", + sizes: [0.5, 0.5], + children: [ + createPaneNode("top"), + createGroupNode({ + direction: "horizontal", + sizes: [0.5, 0.5], + children: [createPaneNode("bottom-left"), createPaneNode("bottom-right")], + }), + ], + }); + + expect(findAdjacentPane(root, "top", "down")).toBe("bottom-left"); + expect(findAdjacentPane(root, "bottom-right", "up")).toBe("top"); + }); +}); diff --git a/packages/app/src/utils/split-navigation.ts b/packages/app/src/utils/split-navigation.ts new file mode 100644 index 000000000..caed34cd4 --- /dev/null +++ b/packages/app/src/utils/split-navigation.ts @@ -0,0 +1,251 @@ +import type { SplitNode } from "@/stores/workspace-layout-store"; + +const ROOT_MIN = 0; +const ROOT_MAX = 1; +const FLOAT_TOLERANCE = 0.000001; + +export interface PaneBounds { + paneId: string; + left: number; + top: number; + right: number; + bottom: number; + centerX: number; + centerY: number; +} + +interface PaneCandidate { + paneId: string; + primaryDistance: number; + secondaryDistance: number; + centerDistance: number; + overlap: number; +} + +export function findAdjacentPane( + root: SplitNode, + focusedPaneId: string, + direction: "left" | "right" | "up" | "down" +): string | null { + const panes = collectPaneBounds(root, { + left: ROOT_MIN, + top: ROOT_MIN, + right: ROOT_MAX, + bottom: ROOT_MAX, + }); + const focusedPane = panes.find((pane) => pane.paneId === focusedPaneId) ?? null; + if (!focusedPane) { + return null; + } + + const candidates = panes + .filter((pane) => pane.paneId !== focusedPaneId) + .map((pane) => buildCandidate({ pane, focusedPane, direction })) + .filter((candidate): candidate is PaneCandidate => candidate !== null) + .sort(compareCandidates); + + return candidates[0]?.paneId ?? null; +} + +function compareCandidates(left: PaneCandidate, right: PaneCandidate): number { + if (left.primaryDistance !== right.primaryDistance) { + return left.primaryDistance - right.primaryDistance; + } + if (left.secondaryDistance !== right.secondaryDistance) { + return left.secondaryDistance - right.secondaryDistance; + } + if (left.overlap !== right.overlap) { + return right.overlap - left.overlap; + } + if (left.centerDistance !== right.centerDistance) { + return left.centerDistance - right.centerDistance; + } + return left.paneId.localeCompare(right.paneId); +} + +function buildCandidate(input: { + pane: PaneBounds; + focusedPane: PaneBounds; + direction: "left" | "right" | "up" | "down"; +}): PaneCandidate | null { + const { pane, focusedPane, direction } = input; + if (direction === "left") { + const primaryDistance = focusedPane.left - pane.right; + if (primaryDistance < -FLOAT_TOLERANCE) { + return null; + } + const overlap = getOverlapLength({ + startA: pane.top, + endA: pane.bottom, + startB: focusedPane.top, + endB: focusedPane.bottom, + }); + return { + paneId: pane.paneId, + primaryDistance, + secondaryDistance: getGapLength({ + startA: pane.top, + endA: pane.bottom, + startB: focusedPane.top, + endB: focusedPane.bottom, + }), + centerDistance: Math.abs(pane.centerY - focusedPane.centerY), + overlap, + }; + } + if (direction === "right") { + const primaryDistance = pane.left - focusedPane.right; + if (primaryDistance < -FLOAT_TOLERANCE) { + return null; + } + const overlap = getOverlapLength({ + startA: pane.top, + endA: pane.bottom, + startB: focusedPane.top, + endB: focusedPane.bottom, + }); + return { + paneId: pane.paneId, + primaryDistance, + secondaryDistance: getGapLength({ + startA: pane.top, + endA: pane.bottom, + startB: focusedPane.top, + endB: focusedPane.bottom, + }), + centerDistance: Math.abs(pane.centerY - focusedPane.centerY), + overlap, + }; + } + if (direction === "up") { + const primaryDistance = focusedPane.top - pane.bottom; + if (primaryDistance < -FLOAT_TOLERANCE) { + return null; + } + const overlap = getOverlapLength({ + startA: pane.left, + endA: pane.right, + startB: focusedPane.left, + endB: focusedPane.right, + }); + return { + paneId: pane.paneId, + primaryDistance, + secondaryDistance: getGapLength({ + startA: pane.left, + endA: pane.right, + startB: focusedPane.left, + endB: focusedPane.right, + }), + centerDistance: Math.abs(pane.centerX - focusedPane.centerX), + overlap, + }; + } + + const primaryDistance = pane.top - focusedPane.bottom; + if (primaryDistance < -FLOAT_TOLERANCE) { + return null; + } + const overlap = getOverlapLength({ + startA: pane.left, + endA: pane.right, + startB: focusedPane.left, + endB: focusedPane.right, + }); + return { + paneId: pane.paneId, + primaryDistance, + secondaryDistance: getGapLength({ + startA: pane.left, + endA: pane.right, + startB: focusedPane.left, + endB: focusedPane.right, + }), + centerDistance: Math.abs(pane.centerX - focusedPane.centerX), + overlap, + }; +} + +function collectPaneBounds( + node: SplitNode, + bounds: { left: number; top: number; right: number; bottom: number } +): PaneBounds[] { + if (node.kind === "pane") { + return [ + { + paneId: node.pane.id, + left: bounds.left, + top: bounds.top, + right: bounds.right, + bottom: bounds.bottom, + centerX: (bounds.left + bounds.right) / 2, + centerY: (bounds.top + bounds.bottom) / 2, + }, + ]; + } + + const panes: PaneBounds[] = []; + const totalWidth = bounds.right - bounds.left; + const totalHeight = bounds.bottom - bounds.top; + let offset = 0; + + for (let index = 0; index < node.group.children.length; index += 1) { + const child = node.group.children[index]; + const size = node.group.sizes[index] ?? 0; + + if (node.group.direction === "horizontal") { + const childLeft = bounds.left + totalWidth * offset; + offset += size; + const childRight = bounds.left + totalWidth * offset; + panes.push( + ...collectPaneBounds(child, { + left: childLeft, + top: bounds.top, + right: childRight, + bottom: bounds.bottom, + }) + ); + continue; + } + + const childTop = bounds.top + totalHeight * offset; + offset += size; + const childBottom = bounds.top + totalHeight * offset; + panes.push( + ...collectPaneBounds(child, { + left: bounds.left, + top: childTop, + right: bounds.right, + bottom: childBottom, + }) + ); + } + + return panes; +} + +function getGapLength(input: { + startA: number; + endA: number; + startB: number; + endB: number; +}): number { + const { startA, endA, startB, endB } = input; + if (endA < startB) { + return startB - endA; + } + if (endB < startA) { + return startA - endB; + } + return 0; +} + +function getOverlapLength(input: { + startA: number; + endA: number; + startB: number; + endB: number; +}): number { + const { startA, endA, startB, endB } = input; + return Math.max(0, Math.min(endA, endB) - Math.max(startA, startB)); +}