Add split-pane layout store and navigation shortcuts
This commit is contained in:
@@ -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<K extends WorkspaceTabTarget["kind"] = WorkspaceTabTarget["kind"]> {
|
||||
kind: K;
|
||||
component: React.ComponentType;
|
||||
useDescriptor(
|
||||
target: Extract<WorkspaceTabTarget, { kind: K }>,
|
||||
context: { serverId: string; workspaceId: string },
|
||||
): PanelDescriptor;
|
||||
confirmClose?(
|
||||
target: Extract<WorkspaceTabTarget, { kind: K }>,
|
||||
context: { serverId: string; workspaceId: string },
|
||||
): Promise<boolean>;
|
||||
}
|
||||
```
|
||||
|
||||
### 2. Panel Registry
|
||||
|
||||
```typescript
|
||||
const panelRegistry = new Map<string, PanelRegistration>();
|
||||
|
||||
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 (
|
||||
<AgentReadyScreen
|
||||
serverId={serverId}
|
||||
agentId={target.agentId}
|
||||
showExplorerSidebar={false}
|
||||
wrapWithExplorerSidebarProvider={false}
|
||||
onOpenWorkspaceFile={openFileInWorkspace}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<TerminalPane
|
||||
serverId={serverId}
|
||||
cwd={workspaceId}
|
||||
selectedTerminalId={target.terminalId}
|
||||
onSelectedTerminalIdChange={(terminalId) => {
|
||||
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 (
|
||||
<WorkspaceDraftAgentTab
|
||||
serverId={serverId}
|
||||
workspaceId={workspaceId}
|
||||
tabId={tabId}
|
||||
draftId={target.draftId}
|
||||
onOpenWorkspaceFile={openFileInWorkspace}
|
||||
onCreated={(agentSnapshot) => {
|
||||
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 (
|
||||
<FilePane
|
||||
serverId={serverId}
|
||||
workspaceRoot={workspaceId}
|
||||
filePath={target.path}
|
||||
/>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## 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 (
|
||||
<TabChipChrome
|
||||
tabId={tabId}
|
||||
label={descriptor.label}
|
||||
subtitle={descriptor.subtitle}
|
||||
titleState={descriptor.titleState}
|
||||
icon={<descriptor.icon size={16} color={theme.colors.foregroundMuted} />}
|
||||
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 (
|
||||
<PaneProvider value={{ serverId, workspaceId, tabId, target, ...actions }}>
|
||||
<Component />
|
||||
</PaneProvider>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## 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 `<PaneContent>` 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
|
||||
@@ -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
|
||||
@@ -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<PointerState | null>(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 (
|
||||
<View
|
||||
role="separator"
|
||||
aria-orientation={direction === "horizontal" ? "vertical" : "horizontal"}
|
||||
style={[
|
||||
styles.handle,
|
||||
direction === "horizontal" ? styles.handleHorizontal : styles.handleVertical,
|
||||
hovered && {
|
||||
backgroundColor: theme.colors.surface2,
|
||||
},
|
||||
{
|
||||
cursor: direction === "horizontal" ? "col-resize" : "row-resize",
|
||||
} as any,
|
||||
]}
|
||||
onPointerDown={handlePointerDown}
|
||||
onPointerEnter={() => {
|
||||
setHovered(true);
|
||||
}}
|
||||
onPointerLeave={() => {
|
||||
setHovered(false);
|
||||
}}
|
||||
>
|
||||
<View
|
||||
style={[
|
||||
styles.handleGrip,
|
||||
direction === "horizontal" ? styles.handleGripHorizontal : styles.handleGripVertical,
|
||||
{
|
||||
backgroundColor: hovered ? theme.colors.accent : theme.colors.border,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
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,
|
||||
},
|
||||
}));
|
||||
@@ -12,6 +12,9 @@ export function SortableInlineList<T>({
|
||||
onDragEnd?: (data: T[]) => void;
|
||||
useDragHandle?: boolean;
|
||||
disabled?: boolean;
|
||||
externalDndContext?: boolean;
|
||||
activeId?: string | null;
|
||||
getItemData?: (item: T, index: number) => Record<string, unknown>;
|
||||
}): ReactElement {
|
||||
return (
|
||||
<>
|
||||
|
||||
@@ -33,6 +33,7 @@ function SortableItem<T>({
|
||||
activeId,
|
||||
useDragHandle,
|
||||
disabled,
|
||||
itemData,
|
||||
}: {
|
||||
id: string;
|
||||
item: T;
|
||||
@@ -41,6 +42,7 @@ function SortableItem<T>({
|
||||
activeId: string | null;
|
||||
useDragHandle: boolean;
|
||||
disabled: boolean;
|
||||
itemData?: Record<string, unknown>;
|
||||
}): ReactElement {
|
||||
const {
|
||||
attributes,
|
||||
@@ -50,7 +52,7 @@ function SortableItem<T>({
|
||||
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<T>({
|
||||
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<T>({
|
||||
disabled?: boolean;
|
||||
activationDistance?: number;
|
||||
onDragBegin?: () => void;
|
||||
externalDndContext?: boolean;
|
||||
activeId?: string | null;
|
||||
getItemData?: (item: T, index: number) => Record<string, unknown>;
|
||||
}): ReactElement {
|
||||
const [activeId, setActiveId] = useState<string | null>(null);
|
||||
const [dragItems, setDragItems] = useState<T[] | null>(null);
|
||||
const items = dragItems ?? data;
|
||||
const items = externalDndContext ? data : dragItems ?? data;
|
||||
|
||||
const sensors = useSensors(
|
||||
useSensor(PointerSensor, {
|
||||
@@ -175,6 +183,31 @@ export function SortableInlineList<T>({
|
||||
|
||||
const ids = items.map((item, index) => keyExtractor(item, index));
|
||||
|
||||
const renderedItems = (
|
||||
<SortableContext items={ids} strategy={horizontalListSortingStrategy}>
|
||||
{items.map((item, index) => {
|
||||
const id = keyExtractor(item, index);
|
||||
return (
|
||||
<SortableItem
|
||||
key={id}
|
||||
id={id}
|
||||
item={item}
|
||||
index={index}
|
||||
renderItem={renderItem}
|
||||
activeId={externalDndContext ? externalActiveId : activeId}
|
||||
useDragHandle={useDragHandle}
|
||||
disabled={disabled}
|
||||
itemData={getItemData?.(item, index)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</SortableContext>
|
||||
);
|
||||
|
||||
if (externalDndContext) {
|
||||
return renderedItems;
|
||||
}
|
||||
|
||||
return (
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
@@ -183,23 +216,7 @@ export function SortableInlineList<T>({
|
||||
onDragStart={handleDragStart}
|
||||
onDragEnd={handleDragEnd}
|
||||
>
|
||||
<SortableContext items={ids} strategy={horizontalListSortingStrategy}>
|
||||
{items.map((item, index) => {
|
||||
const id = keyExtractor(item, index);
|
||||
return (
|
||||
<SortableItem
|
||||
key={id}
|
||||
id={id}
|
||||
item={item}
|
||||
index={index}
|
||||
renderItem={renderItem}
|
||||
activeId={activeId}
|
||||
useDragHandle={useDragHandle}
|
||||
disabled={disabled}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</SortableContext>
|
||||
{renderedItems}
|
||||
</DndContext>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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<SetStateAction<string | null>>;
|
||||
setHoveredCloseTabKey: Dispatch<SetStateAction<string | null>>;
|
||||
isArchivingAgent: (input: { serverId: string; agentId: string }) => boolean;
|
||||
killTerminalPending: boolean;
|
||||
killTerminalId: string | null;
|
||||
onNavigateTab: (tabId: string) => void;
|
||||
onCloseTab: (tabId: string) => Promise<void> | void;
|
||||
onCopyResumeCommand: (agentId: string) => Promise<void> | void;
|
||||
onCopyAgentId: (agentId: string) => Promise<void> | void;
|
||||
onCloseTabsToLeft: (tabId: string, paneTabs: WorkspaceTabDescriptor[]) => Promise<void> | void;
|
||||
onCloseTabsToRight: (tabId: string, paneTabs: WorkspaceTabDescriptor[]) => Promise<void> | void;
|
||||
onCloseOtherTabs: (tabId: string, paneTabs: WorkspaceTabDescriptor[]) => Promise<void> | 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<SplitContainerProps, "layout" | "workspaceKey" | "uiTabs"> {
|
||||
node: SplitNode;
|
||||
tabsById: Map<string, WorkspaceTab>;
|
||||
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<string, WorkspaceTab>;
|
||||
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<string | null>(null);
|
||||
const [dropPreview, setDropPreview] = useState<SplitDropZoneHover | null>(null);
|
||||
|
||||
const sensors = useSensors(
|
||||
useSensor(PointerSensor, {
|
||||
activationConstraint: {
|
||||
distance: 8,
|
||||
},
|
||||
}),
|
||||
useSensor(KeyboardSensor, {
|
||||
coordinateGetter: sortableKeyboardCoordinates,
|
||||
})
|
||||
);
|
||||
|
||||
const tabsById = useMemo(() => {
|
||||
const next = new Map<string, WorkspaceTab>();
|
||||
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 (
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
collisionDetection={dropCollisionDetection}
|
||||
onDragStart={handleDragStart}
|
||||
onDragCancel={handleDragCancel}
|
||||
onDragEnd={handleDragEnd}
|
||||
>
|
||||
<SplitNodeView
|
||||
node={layout.root}
|
||||
tabsById={tabsById}
|
||||
focusedPaneId={layout.focusedPaneId}
|
||||
normalizedServerId={normalizedServerId}
|
||||
normalizedWorkspaceId={normalizedWorkspaceId}
|
||||
hoveredCloseTabKey={hoveredCloseTabKey}
|
||||
setHoveredTabKey={setHoveredTabKey}
|
||||
setHoveredCloseTabKey={setHoveredCloseTabKey}
|
||||
isArchivingAgent={isArchivingAgent}
|
||||
killTerminalPending={killTerminalPending}
|
||||
killTerminalId={killTerminalId}
|
||||
onNavigateTab={onNavigateTab}
|
||||
onCloseTab={onCloseTab}
|
||||
onCopyResumeCommand={onCopyResumeCommand}
|
||||
onCopyAgentId={onCopyAgentId}
|
||||
onCloseTabsToLeft={onCloseTabsToLeft}
|
||||
onCloseTabsToRight={onCloseTabsToRight}
|
||||
onCloseOtherTabs={onCloseOtherTabs}
|
||||
onSelectNewTabOption={onSelectNewTabOption}
|
||||
newTabAgentOptionId={newTabAgentOptionId}
|
||||
onOpenPaneTab={onOpenPaneTab}
|
||||
onRetargetTab={onRetargetTab}
|
||||
onOpenWorkspaceFile={onOpenWorkspaceFile}
|
||||
onFocusPane={onFocusPane}
|
||||
onSplitPane={onSplitPane}
|
||||
onMoveTabToPane={onMoveTabToPane}
|
||||
onResizeSplit={onResizeSplit}
|
||||
onReorderTabsInPane={onReorderTabsInPane}
|
||||
renderPaneEmptyState={renderPaneEmptyState}
|
||||
activeDragTabId={activeDragTabId}
|
||||
showDropZones={activeDragTabId !== null}
|
||||
dropPreview={dropPreview}
|
||||
onDropPreviewChange={setDropPreview}
|
||||
/>
|
||||
</DndContext>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<SplitPaneView
|
||||
pane={node.pane}
|
||||
tabsById={tabsById}
|
||||
focused={node.pane.id === focusedPaneId}
|
||||
normalizedServerId={normalizedServerId}
|
||||
normalizedWorkspaceId={normalizedWorkspaceId}
|
||||
hoveredCloseTabKey={hoveredCloseTabKey}
|
||||
setHoveredTabKey={setHoveredTabKey}
|
||||
setHoveredCloseTabKey={setHoveredCloseTabKey}
|
||||
isArchivingAgent={isArchivingAgent}
|
||||
killTerminalPending={killTerminalPending}
|
||||
killTerminalId={killTerminalId}
|
||||
onNavigateTab={onNavigateTab}
|
||||
onCloseTab={onCloseTab}
|
||||
onCopyResumeCommand={onCopyResumeCommand}
|
||||
onCopyAgentId={onCopyAgentId}
|
||||
onCloseTabsToLeft={onCloseTabsToLeft}
|
||||
onCloseTabsToRight={onCloseTabsToRight}
|
||||
onCloseOtherTabs={onCloseOtherTabs}
|
||||
onSelectNewTabOption={onSelectNewTabOption}
|
||||
newTabAgentOptionId={newTabAgentOptionId}
|
||||
onOpenPaneTab={onOpenPaneTab}
|
||||
onRetargetTab={onRetargetTab}
|
||||
onOpenWorkspaceFile={onOpenWorkspaceFile}
|
||||
onFocusPane={onFocusPane}
|
||||
onReorderTabsInPane={onReorderTabsInPane}
|
||||
renderPaneEmptyState={renderPaneEmptyState}
|
||||
activeDragTabId={activeDragTabId}
|
||||
showDropZones={showDropZones}
|
||||
dropPreview={dropPreview}
|
||||
onDropPreviewChange={onDropPreviewChange}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<View
|
||||
style={[
|
||||
styles.group,
|
||||
node.group.direction === "horizontal" ? styles.groupHorizontal : styles.groupVertical,
|
||||
]}
|
||||
>
|
||||
{node.group.children.map((child, index) => (
|
||||
<Fragment key={getNodeKey(child)}>
|
||||
<View style={[styles.groupChild, { flex: node.group.sizes[index] ?? 1 }]}>
|
||||
<SplitNodeView
|
||||
node={child}
|
||||
tabsById={tabsById}
|
||||
focusedPaneId={focusedPaneId}
|
||||
normalizedServerId={normalizedServerId}
|
||||
normalizedWorkspaceId={normalizedWorkspaceId}
|
||||
hoveredCloseTabKey={hoveredCloseTabKey}
|
||||
setHoveredTabKey={setHoveredTabKey}
|
||||
setHoveredCloseTabKey={setHoveredCloseTabKey}
|
||||
isArchivingAgent={isArchivingAgent}
|
||||
killTerminalPending={killTerminalPending}
|
||||
killTerminalId={killTerminalId}
|
||||
onNavigateTab={onNavigateTab}
|
||||
onCloseTab={onCloseTab}
|
||||
onCopyResumeCommand={onCopyResumeCommand}
|
||||
onCopyAgentId={onCopyAgentId}
|
||||
onCloseTabsToLeft={onCloseTabsToLeft}
|
||||
onCloseTabsToRight={onCloseTabsToRight}
|
||||
onCloseOtherTabs={onCloseOtherTabs}
|
||||
onSelectNewTabOption={onSelectNewTabOption}
|
||||
newTabAgentOptionId={newTabAgentOptionId}
|
||||
onOpenPaneTab={onOpenPaneTab}
|
||||
onRetargetTab={onRetargetTab}
|
||||
onOpenWorkspaceFile={onOpenWorkspaceFile}
|
||||
onFocusPane={onFocusPane}
|
||||
onSplitPane={onSplitPane}
|
||||
onMoveTabToPane={onMoveTabToPane}
|
||||
onResizeSplit={onResizeSplit}
|
||||
onReorderTabsInPane={onReorderTabsInPane}
|
||||
renderPaneEmptyState={renderPaneEmptyState}
|
||||
activeDragTabId={activeDragTabId}
|
||||
showDropZones={showDropZones}
|
||||
dropPreview={dropPreview}
|
||||
onDropPreviewChange={onDropPreviewChange}
|
||||
/>
|
||||
</View>
|
||||
{index < node.group.children.length - 1 ? (
|
||||
<ResizeHandle
|
||||
direction={node.group.direction}
|
||||
groupId={node.group.id}
|
||||
index={index}
|
||||
sizes={node.group.sizes}
|
||||
onResizeSplit={onResizeSplit}
|
||||
/>
|
||||
) : null}
|
||||
</Fragment>
|
||||
))}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<View
|
||||
style={[
|
||||
styles.pane,
|
||||
{
|
||||
borderColor: focused ? theme.colors.borderAccent : theme.colors.border,
|
||||
},
|
||||
]}
|
||||
onPointerDownCapture={() => {
|
||||
onFocusPane(pane.id);
|
||||
}}
|
||||
>
|
||||
<View
|
||||
style={[
|
||||
styles.paneTabs,
|
||||
focused
|
||||
? {
|
||||
borderTopColor: theme.colors.accent,
|
||||
}
|
||||
: null,
|
||||
]}
|
||||
>
|
||||
<WorkspaceDesktopTabsRow
|
||||
paneId={pane.id}
|
||||
tabs={paneTabs}
|
||||
activeTabKey={activeTabDescriptor?.key ?? ""}
|
||||
normalizedServerId={normalizedServerId}
|
||||
normalizedWorkspaceId={normalizedWorkspaceId}
|
||||
hoveredCloseTabKey={hoveredCloseTabKey}
|
||||
setHoveredTabKey={setHoveredTabKey}
|
||||
setHoveredCloseTabKey={setHoveredCloseTabKey}
|
||||
isArchivingAgent={isArchivingAgent}
|
||||
killTerminalPending={killTerminalPending}
|
||||
killTerminalId={killTerminalId}
|
||||
onNavigateTab={onNavigateTab}
|
||||
onCloseTab={onCloseTab}
|
||||
onCopyResumeCommand={onCopyResumeCommand}
|
||||
onCopyAgentId={onCopyAgentId}
|
||||
onCloseTabsToLeft={(tabId) => 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}
|
||||
/>
|
||||
</View>
|
||||
|
||||
<View style={styles.paneContent}>
|
||||
{activeTabDescriptor ? (
|
||||
<WorkspacePaneContent
|
||||
tab={activeTabDescriptor}
|
||||
normalizedServerId={normalizedServerId}
|
||||
normalizedWorkspaceId={normalizedWorkspaceId}
|
||||
onOpenTab={(target) => {
|
||||
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
|
||||
)}
|
||||
</View>
|
||||
|
||||
<SplitDropZone
|
||||
paneId={pane.id}
|
||||
active={showDropZones}
|
||||
preview={dropPreview}
|
||||
onHoverChange={onDropPreviewChange}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function collectPanesById(node: SplitNode): Map<string, SplitPane> {
|
||||
const next = new Map<string, SplitPane>();
|
||||
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<string, WorkspaceTab>
|
||||
): 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,
|
||||
},
|
||||
}));
|
||||
@@ -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<LayoutSize>({ 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 (
|
||||
<View
|
||||
ref={setNodeRef as any}
|
||||
style={[styles.overlay, isOver && styles.overlayActive]}
|
||||
onLayout={handleLayout}
|
||||
onPointerEnter={updateHover}
|
||||
onPointerMove={updateHover}
|
||||
onPointerLeave={() => {
|
||||
if (preview?.paneId === paneId) {
|
||||
onHoverChange(null);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{previewStyle ? <View pointerEvents="none" style={previewStyle} /> : null}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
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<SplitDropZonePosition, "center">; 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%",
|
||||
},
|
||||
}));
|
||||
@@ -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;
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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"],
|
||||
},
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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> | void;
|
||||
onCloseTabsToRight: (tabId: string) => Promise<void> | void;
|
||||
onCloseOtherTabs: (tabId: string) => Promise<void> | 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<number>(0);
|
||||
const [tabsActionsWidth, setTabsActionsWidth] = useState<number>(0);
|
||||
const [presentationsByKey, setPresentationsByKey] = useState<Map<string, WorkspaceTabPresentation>>(
|
||||
() => 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<string, WorkspaceTabPresentation>();
|
||||
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}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -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 (
|
||||
<PaneProvider
|
||||
value={{
|
||||
serverId: normalizedServerId,
|
||||
workspaceId: normalizedWorkspaceId,
|
||||
tabId: tab.tabId,
|
||||
target: tab.target,
|
||||
openTab: onOpenTab,
|
||||
closeCurrentTab: onCloseCurrentTab,
|
||||
retargetCurrentTab: onRetargetCurrentTab,
|
||||
openFileInWorkspace: onOpenWorkspaceFile,
|
||||
}}
|
||||
>
|
||||
<Component key={`${normalizedServerId}:${normalizedWorkspaceId}:${tab.tabId}`} />
|
||||
</PaneProvider>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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);
|
||||
|
||||
@@ -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<string, WorkspaceDerivedTab>();
|
||||
const tabs: WorkspaceDerivedTab[] = [];
|
||||
const openTabIds = new Set<string>();
|
||||
|
||||
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<string>();
|
||||
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 = (() => {
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -0,0 +1,610 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("@react-native-async-storage/async-storage", () => {
|
||||
const storage = new Map<string, string>();
|
||||
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<SplitNode, { kind: "group" }> {
|
||||
expect(node.kind).toBe("group");
|
||||
return node as Extract<SplitNode, { kind: "group" }>;
|
||||
}
|
||||
|
||||
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());
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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));
|
||||
}
|
||||
Reference in New Issue
Block a user