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;
|
onDragEnd?: (data: T[]) => void;
|
||||||
useDragHandle?: boolean;
|
useDragHandle?: boolean;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
|
externalDndContext?: boolean;
|
||||||
|
activeId?: string | null;
|
||||||
|
getItemData?: (item: T, index: number) => Record<string, unknown>;
|
||||||
}): ReactElement {
|
}): ReactElement {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ function SortableItem<T>({
|
|||||||
activeId,
|
activeId,
|
||||||
useDragHandle,
|
useDragHandle,
|
||||||
disabled,
|
disabled,
|
||||||
|
itemData,
|
||||||
}: {
|
}: {
|
||||||
id: string;
|
id: string;
|
||||||
item: T;
|
item: T;
|
||||||
@@ -41,6 +42,7 @@ function SortableItem<T>({
|
|||||||
activeId: string | null;
|
activeId: string | null;
|
||||||
useDragHandle: boolean;
|
useDragHandle: boolean;
|
||||||
disabled: boolean;
|
disabled: boolean;
|
||||||
|
itemData?: Record<string, unknown>;
|
||||||
}): ReactElement {
|
}): ReactElement {
|
||||||
const {
|
const {
|
||||||
attributes,
|
attributes,
|
||||||
@@ -50,7 +52,7 @@ function SortableItem<T>({
|
|||||||
transform,
|
transform,
|
||||||
transition,
|
transition,
|
||||||
isDragging,
|
isDragging,
|
||||||
} = useSortable({ id, disabled });
|
} = useSortable({ id, disabled, data: itemData });
|
||||||
|
|
||||||
const drag = useCallback(() => {
|
const drag = useCallback(() => {
|
||||||
// dnd-kit handles drag initiation via listeners
|
// dnd-kit handles drag initiation via listeners
|
||||||
@@ -107,6 +109,9 @@ export function SortableInlineList<T>({
|
|||||||
disabled = false,
|
disabled = false,
|
||||||
activationDistance = 8,
|
activationDistance = 8,
|
||||||
onDragBegin,
|
onDragBegin,
|
||||||
|
externalDndContext = false,
|
||||||
|
activeId: externalActiveId = null,
|
||||||
|
getItemData,
|
||||||
}: {
|
}: {
|
||||||
data: T[];
|
data: T[];
|
||||||
keyExtractor: (item: T, index: number) => string;
|
keyExtractor: (item: T, index: number) => string;
|
||||||
@@ -116,10 +121,13 @@ export function SortableInlineList<T>({
|
|||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
activationDistance?: number;
|
activationDistance?: number;
|
||||||
onDragBegin?: () => void;
|
onDragBegin?: () => void;
|
||||||
|
externalDndContext?: boolean;
|
||||||
|
activeId?: string | null;
|
||||||
|
getItemData?: (item: T, index: number) => Record<string, unknown>;
|
||||||
}): ReactElement {
|
}): ReactElement {
|
||||||
const [activeId, setActiveId] = useState<string | null>(null);
|
const [activeId, setActiveId] = useState<string | null>(null);
|
||||||
const [dragItems, setDragItems] = useState<T[] | null>(null);
|
const [dragItems, setDragItems] = useState<T[] | null>(null);
|
||||||
const items = dragItems ?? data;
|
const items = externalDndContext ? data : dragItems ?? data;
|
||||||
|
|
||||||
const sensors = useSensors(
|
const sensors = useSensors(
|
||||||
useSensor(PointerSensor, {
|
useSensor(PointerSensor, {
|
||||||
@@ -175,6 +183,31 @@ export function SortableInlineList<T>({
|
|||||||
|
|
||||||
const ids = items.map((item, index) => keyExtractor(item, index));
|
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 (
|
return (
|
||||||
<DndContext
|
<DndContext
|
||||||
sensors={sensors}
|
sensors={sensors}
|
||||||
@@ -183,23 +216,7 @@ export function SortableInlineList<T>({
|
|||||||
onDragStart={handleDragStart}
|
onDragStart={handleDragStart}
|
||||||
onDragEnd={handleDragEnd}
|
onDragEnd={handleDragEnd}
|
||||||
>
|
>
|
||||||
<SortableContext items={ids} strategy={horizontalListSortingStrategy}>
|
{renderedItems}
|
||||||
{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>
|
|
||||||
</DndContext>
|
</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",
|
scope: "workspace",
|
||||||
delta: input.payload.delta,
|
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":
|
case "workspace.navigate.index":
|
||||||
if (!input.payload || typeof input.payload !== "object" || !("index" in input.payload)) {
|
if (!input.payload || typeof input.payload !== "object" || !("index" in input.payload)) {
|
||||||
return false;
|
return false;
|
||||||
|
|||||||
@@ -18,6 +18,17 @@ export type KeyboardActionId =
|
|||||||
| "workspace.tab.close.current"
|
| "workspace.tab.close.current"
|
||||||
| "workspace.tab.navigate.index"
|
| "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"
|
||||||
| "workspace.navigate.index"
|
| "workspace.navigate.index"
|
||||||
| "workspace.navigate.relative"
|
| "workspace.navigate.relative"
|
||||||
| "sidebar.toggle.left"
|
| "sidebar.toggle.left"
|
||||||
|
|||||||
@@ -13,7 +13,18 @@ export type KeyboardActionId =
|
|||||||
| "workspace.tab.new"
|
| "workspace.tab.new"
|
||||||
| "workspace.tab.close-current"
|
| "workspace.tab.close-current"
|
||||||
| "workspace.tab.navigate-index"
|
| "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 =
|
export type KeyboardActionDefinition =
|
||||||
| { id: "message-input.focus"; scope: KeyboardActionScope }
|
| { id: "message-input.focus"; scope: KeyboardActionScope }
|
||||||
@@ -24,7 +35,18 @@ export type KeyboardActionDefinition =
|
|||||||
| { id: "workspace.tab.new"; scope: KeyboardActionScope }
|
| { id: "workspace.tab.new"; scope: KeyboardActionScope }
|
||||||
| { id: "workspace.tab.close-current"; scope: KeyboardActionScope }
|
| { id: "workspace.tab.close-current"; scope: KeyboardActionScope }
|
||||||
| { id: "workspace.tab.navigate-index"; scope: KeyboardActionScope; index: number }
|
| { 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 = {
|
export type KeyboardActionHandler = {
|
||||||
handlerId: string;
|
handlerId: string;
|
||||||
|
|||||||
@@ -169,6 +169,42 @@ describe("keyboard-shortcuts", () => {
|
|||||||
context: { isMac: true, isTauri: true },
|
context: { isMac: true, isTauri: true },
|
||||||
action: "workspace.tab.close.current",
|
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",
|
name: "matches Cmd+B sidebar toggle on macOS",
|
||||||
event: { key: "b", code: "KeyB", metaKey: true },
|
event: { key: "b", code: "KeyB", metaKey: true },
|
||||||
@@ -242,6 +278,11 @@ describe("keyboard-shortcuts", () => {
|
|||||||
event: { key: "d", code: "KeyD", metaKey: true },
|
event: { key: "d", code: "KeyD", metaKey: true },
|
||||||
context: { isMac: true, focusScope: "terminal" },
|
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",
|
name: "keeps space typing available in message input",
|
||||||
event: { key: " ", code: "Space" },
|
event: { key: " ", code: "Space" },
|
||||||
@@ -278,6 +319,8 @@ describe("keyboard-shortcut help sections", () => {
|
|||||||
"workspace-jump-index": ["alt", "1-9"],
|
"workspace-jump-index": ["alt", "1-9"],
|
||||||
"workspace-tab-jump-index": ["alt", "shift", "1-9"],
|
"workspace-tab-jump-index": ["alt", "shift", "1-9"],
|
||||||
"workspace-tab-close-current": ["alt", "shift", "W"],
|
"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-jump-index": ["mod", "1-9"],
|
||||||
"workspace-tab-jump-index": ["alt", "1-9"],
|
"workspace-tab-jump-index": ["alt", "1-9"],
|
||||||
"workspace-tab-close-current": ["mod", "W"],
|
"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;
|
return event.metaKey || event.ctrlKey;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isMacCommand(event: KeyboardEvent): boolean {
|
||||||
|
return event.metaKey && !event.ctrlKey;
|
||||||
|
}
|
||||||
|
|
||||||
function parseDigit(event: KeyboardEvent): number | null {
|
function parseDigit(event: KeyboardEvent): number | null {
|
||||||
const code = event.code ?? "";
|
const code = event.code ?? "";
|
||||||
if (code.startsWith("Digit")) {
|
if (code.startsWith("Digit")) {
|
||||||
@@ -373,6 +377,204 @@ const SHORTCUT_BINDINGS: readonly KeyboardShortcutBinding[] = [
|
|||||||
keys: ["alt", "shift", "]"],
|
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",
|
id: "command-center-toggle",
|
||||||
action: "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 { ActivityIndicator, Pressable, ScrollView, Text, View, type LayoutChangeEvent } from "react-native";
|
||||||
import { Plus, X } from "lucide-react-native";
|
import { Plus, X } from "lucide-react-native";
|
||||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||||
@@ -27,6 +27,7 @@ const LOADING_TAB_LABEL_SKELETON_WIDTH = 80;
|
|||||||
type NewTabOptionId = "__new_tab_agent__";
|
type NewTabOptionId = "__new_tab_agent__";
|
||||||
|
|
||||||
type WorkspaceDesktopTabsRowProps = {
|
type WorkspaceDesktopTabsRowProps = {
|
||||||
|
paneId?: string;
|
||||||
tabs: WorkspaceTabDescriptor[];
|
tabs: WorkspaceTabDescriptor[];
|
||||||
activeTabKey: string;
|
activeTabKey: string;
|
||||||
normalizedServerId: string;
|
normalizedServerId: string;
|
||||||
@@ -47,6 +48,8 @@ type WorkspaceDesktopTabsRowProps = {
|
|||||||
onSelectNewTabOption: (optionId: NewTabOptionId) => void;
|
onSelectNewTabOption: (optionId: NewTabOptionId) => void;
|
||||||
newTabAgentOptionId: NewTabOptionId;
|
newTabAgentOptionId: NewTabOptionId;
|
||||||
onReorderTabs: (nextTabs: WorkspaceTabDescriptor[]) => void;
|
onReorderTabs: (nextTabs: WorkspaceTabDescriptor[]) => void;
|
||||||
|
externalDndContext?: boolean;
|
||||||
|
activeDragTabId?: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
function getFallbackTabLabel(tab: WorkspaceTabDescriptor): string {
|
function getFallbackTabLabel(tab: WorkspaceTabDescriptor): string {
|
||||||
@@ -59,7 +62,7 @@ function getFallbackTabLabel(tab: WorkspaceTabDescriptor): string {
|
|||||||
if (tab.target.kind === "file") {
|
if (tab.target.kind === "file") {
|
||||||
return tab.target.path.split("/").filter(Boolean).pop() ?? tab.target.path;
|
return tab.target.path.split("/").filter(Boolean).pop() ?? tab.target.path;
|
||||||
}
|
}
|
||||||
return "";
|
return "Agent";
|
||||||
}
|
}
|
||||||
|
|
||||||
function getCloseButtonTestId(tab: WorkspaceTabDescriptor): string {
|
function getCloseButtonTestId(tab: WorkspaceTabDescriptor): string {
|
||||||
@@ -96,7 +99,6 @@ function TabChip({
|
|||||||
onCloseTabsToLeft,
|
onCloseTabsToLeft,
|
||||||
onCloseTabsToRight,
|
onCloseTabsToRight,
|
||||||
onCloseOtherTabs,
|
onCloseOtherTabs,
|
||||||
onPresentationChange,
|
|
||||||
dragHandleProps,
|
dragHandleProps,
|
||||||
}: {
|
}: {
|
||||||
tab: WorkspaceTabDescriptor;
|
tab: WorkspaceTabDescriptor;
|
||||||
@@ -119,19 +121,14 @@ function TabChip({
|
|||||||
onCloseTabsToLeft: (tabId: string) => Promise<void> | void;
|
onCloseTabsToLeft: (tabId: string) => Promise<void> | void;
|
||||||
onCloseTabsToRight: (tabId: string) => Promise<void> | void;
|
onCloseTabsToRight: (tabId: string) => Promise<void> | void;
|
||||||
onCloseOtherTabs: (tabId: string) => Promise<void> | void;
|
onCloseOtherTabs: (tabId: string) => Promise<void> | void;
|
||||||
onPresentationChange: (tabKey: string, presentation: WorkspaceTabPresentation) => void;
|
|
||||||
dragHandleProps: any;
|
dragHandleProps: any;
|
||||||
}) {
|
}) {
|
||||||
const { theme } = useUnistyles();
|
|
||||||
const presentation = useWorkspaceTabPresentation({
|
const presentation = useWorkspaceTabPresentation({
|
||||||
tab,
|
tab,
|
||||||
serverId: normalizedServerId,
|
serverId: normalizedServerId,
|
||||||
workspaceId: normalizedWorkspaceId,
|
workspaceId: normalizedWorkspaceId,
|
||||||
});
|
});
|
||||||
|
const { theme } = useUnistyles();
|
||||||
useEffect(() => {
|
|
||||||
onPresentationChange(tab.key, presentation);
|
|
||||||
}, [onPresentationChange, presentation, tab.key]);
|
|
||||||
|
|
||||||
const tooltipLabel =
|
const tooltipLabel =
|
||||||
presentation.titleState === "loading" ? "Loading agent title" : presentation.label;
|
presentation.titleState === "loading" ? "Loading agent title" : presentation.label;
|
||||||
@@ -281,6 +278,7 @@ function TabChip({
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function WorkspaceDesktopTabsRow({
|
export function WorkspaceDesktopTabsRow({
|
||||||
|
paneId,
|
||||||
tabs,
|
tabs,
|
||||||
activeTabKey,
|
activeTabKey,
|
||||||
normalizedServerId,
|
normalizedServerId,
|
||||||
@@ -301,47 +299,12 @@ export function WorkspaceDesktopTabsRow({
|
|||||||
onSelectNewTabOption,
|
onSelectNewTabOption,
|
||||||
newTabAgentOptionId,
|
newTabAgentOptionId,
|
||||||
onReorderTabs,
|
onReorderTabs,
|
||||||
|
externalDndContext = false,
|
||||||
|
activeDragTabId = null,
|
||||||
}: WorkspaceDesktopTabsRowProps) {
|
}: WorkspaceDesktopTabsRowProps) {
|
||||||
const { theme } = useUnistyles();
|
const { theme } = useUnistyles();
|
||||||
const [tabsContainerWidth, setTabsContainerWidth] = useState<number>(0);
|
const [tabsContainerWidth, setTabsContainerWidth] = useState<number>(0);
|
||||||
const [tabsActionsWidth, setTabsActionsWidth] = 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 handleTabsContainerLayout = useCallback((event: LayoutChangeEvent) => {
|
||||||
const nextWidth = Math.round(event.nativeEvent.layout.width);
|
const nextWidth = Math.round(event.nativeEvent.layout.width);
|
||||||
@@ -371,14 +334,10 @@ export function WorkspaceDesktopTabsRow({
|
|||||||
const tabLabelLengths = useMemo(
|
const tabLabelLengths = useMemo(
|
||||||
() =>
|
() =>
|
||||||
tabs.map((tab) => {
|
tabs.map((tab) => {
|
||||||
const presentation = presentationsByKey.get(tab.key);
|
const label = getFallbackTabLabel(tab);
|
||||||
if (presentation?.titleState === "loading") {
|
|
||||||
return Math.max(1, Math.ceil(LOADING_TAB_LABEL_SKELETON_WIDTH / layoutMetrics.estimatedCharWidth));
|
|
||||||
}
|
|
||||||
const label = presentation?.label ?? getFallbackTabLabel(tab);
|
|
||||||
return label.length;
|
return label.length;
|
||||||
}),
|
}),
|
||||||
[layoutMetrics.estimatedCharWidth, presentationsByKey, tabs]
|
[tabs]
|
||||||
);
|
);
|
||||||
|
|
||||||
const { layout } = useWorkspaceTabLayout({
|
const { layout } = useWorkspaceTabLayout({
|
||||||
@@ -410,6 +369,17 @@ export function WorkspaceDesktopTabsRow({
|
|||||||
useDragHandle
|
useDragHandle
|
||||||
disabled={tabs.length < 2}
|
disabled={tabs.length < 2}
|
||||||
onDragEnd={onReorderTabs}
|
onDragEnd={onReorderTabs}
|
||||||
|
externalDndContext={externalDndContext}
|
||||||
|
activeId={activeDragTabId}
|
||||||
|
getItemData={
|
||||||
|
paneId
|
||||||
|
? (tab) => ({
|
||||||
|
kind: "workspace-tab",
|
||||||
|
paneId,
|
||||||
|
tabId: tab.tabId,
|
||||||
|
})
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
renderItem={({ item: tab, index, dragHandleProps }) => {
|
renderItem={({ item: tab, index, dragHandleProps }) => {
|
||||||
const isActive = tab.key === activeTabKey;
|
const isActive = tab.key === activeTabKey;
|
||||||
const isCloseHovered = hoveredCloseTabKey === tab.key;
|
const isCloseHovered = hoveredCloseTabKey === tab.key;
|
||||||
@@ -452,7 +422,6 @@ export function WorkspaceDesktopTabsRow({
|
|||||||
onCloseTabsToLeft={onCloseTabsToLeft}
|
onCloseTabsToLeft={onCloseTabsToLeft}
|
||||||
onCloseTabsToRight={onCloseTabsToRight}
|
onCloseTabsToRight={onCloseTabsToRight}
|
||||||
onCloseOtherTabs={onCloseOtherTabs}
|
onCloseOtherTabs={onCloseOtherTabs}
|
||||||
onPresentationChange={handlePresentationChange}
|
|
||||||
dragHandleProps={dragHandleProps}
|
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({
|
const model = deriveWorkspaceTabModel({
|
||||||
tabs: uiTabs,
|
tabs: [uiTabs[0]!, uiTabs[2]!, uiTabs[1]!],
|
||||||
tabOrder: ["draft_123", "agent_agent-a", "file_/repo/worktree/README.md"],
|
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(model.tabs.map((tab) => tab.descriptor.tabId)).toEqual([
|
expect(model.tabs.map((tab) => tab.descriptor.tabId)).toEqual([
|
||||||
@@ -43,11 +42,10 @@ describe("deriveWorkspaceTabModel", () => {
|
|||||||
it("applies stored order and appends unordered tabs deterministically", () => {
|
it("applies stored order and appends unordered tabs deterministically", () => {
|
||||||
const model = deriveWorkspaceTabModel({
|
const model = deriveWorkspaceTabModel({
|
||||||
tabs: [
|
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: "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([
|
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-a", target: { kind: "agent", agentId: "agent-a" }, createdAt: 1 },
|
||||||
{ tabId: "agent_agent-b", target: { kind: "agent", agentId: "agent-b" }, createdAt: 2 },
|
{ tabId: "agent_agent-b", target: { kind: "agent", agentId: "agent-b" }, createdAt: 2 },
|
||||||
],
|
],
|
||||||
tabOrder: ["agent_agent-a", "agent_agent-b"],
|
|
||||||
};
|
};
|
||||||
|
|
||||||
expect(
|
expect(
|
||||||
@@ -82,7 +79,6 @@ describe("deriveWorkspaceTabModel", () => {
|
|||||||
{ tabId: "agent_agent-a", target: { kind: "agent", agentId: "agent-a" }, createdAt: 1 },
|
{ 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: "agent_agent-b", target: { kind: "agent", agentId: "agent-b" }, createdAt: 2 },
|
||||||
],
|
],
|
||||||
tabOrder: ["agent_agent-a", "agent_agent-b"],
|
|
||||||
focusedTabId: "agent_agent-a",
|
focusedTabId: "agent_agent-a",
|
||||||
preferredTarget: { kind: "agent", agentId: "agent-b" },
|
preferredTarget: { kind: "agent", agentId: "agent-b" },
|
||||||
});
|
});
|
||||||
@@ -100,7 +96,6 @@ describe("deriveWorkspaceTabModel", () => {
|
|||||||
createdAt: 1,
|
createdAt: 1,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
tabOrder: ["draft_abc"],
|
|
||||||
preferredTarget: { kind: "agent", agentId: "agent-1" },
|
preferredTarget: { kind: "agent", agentId: "agent-1" },
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -123,7 +118,6 @@ describe("deriveWorkspaceTabModel", () => {
|
|||||||
createdAt: 2,
|
createdAt: 2,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
tabOrder: [],
|
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(model.tabs).toHaveLength(1);
|
expect(model.tabs).toHaveLength(1);
|
||||||
|
|||||||
@@ -111,52 +111,29 @@ export function buildWorkspaceTabId(target: WorkspaceTabTarget): string {
|
|||||||
|
|
||||||
export function deriveWorkspaceTabModel(input: {
|
export function deriveWorkspaceTabModel(input: {
|
||||||
tabs: WorkspaceTab[];
|
tabs: WorkspaceTab[];
|
||||||
tabOrder: string[];
|
|
||||||
focusedTabId?: string | null;
|
focusedTabId?: string | null;
|
||||||
preferredTarget?: WorkspaceTabTarget | null;
|
preferredTarget?: WorkspaceTabTarget | null;
|
||||||
}): WorkspaceTabModel {
|
}): WorkspaceTabModel {
|
||||||
const tabsById = new Map<string, WorkspaceDerivedTab>();
|
const tabs: WorkspaceDerivedTab[] = [];
|
||||||
|
const openTabIds = new Set<string>();
|
||||||
|
|
||||||
const normalizedTabs = input.tabs
|
for (const tab of input.tabs) {
|
||||||
.map((tab) => normalizeWorkspaceTab(tab))
|
const normalizedTab = normalizeWorkspaceTab(tab);
|
||||||
.filter((tab): tab is WorkspaceTab => tab !== null)
|
if (!normalizedTab || openTabIds.has(normalizedTab.tabId)) {
|
||||||
.sort((left, right) => left.createdAt - right.createdAt);
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
for (const tab of normalizedTabs) {
|
openTabIds.add(normalizedTab.tabId);
|
||||||
tabsById.set(tab.tabId, {
|
tabs.push({
|
||||||
descriptor: {
|
descriptor: {
|
||||||
key: tab.tabId,
|
key: normalizedTab.tabId,
|
||||||
tabId: tab.tabId,
|
tabId: normalizedTab.tabId,
|
||||||
kind: tab.target.kind,
|
kind: normalizedTab.target.kind,
|
||||||
target: tab.target,
|
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 focusedTabId = trimNonEmpty(input.focusedTabId);
|
||||||
const preferredTarget = input.preferredTarget ?? null;
|
const preferredTarget = input.preferredTarget ?? null;
|
||||||
const preferredTabId = (() => {
|
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 { Pressable, Text, View } from "react-native";
|
||||||
import { Check } from "lucide-react-native";
|
import { Check } from "lucide-react-native";
|
||||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||||
@@ -34,15 +34,26 @@ export function useWorkspaceTabPresentation(input: {
|
|||||||
workspaceId: input.workspaceId,
|
workspaceId: input.workspaceId,
|
||||||
});
|
});
|
||||||
|
|
||||||
return {
|
return useMemo(
|
||||||
key: input.tab.key,
|
() => ({
|
||||||
kind: input.tab.kind,
|
key: input.tab.key,
|
||||||
label: descriptor.label,
|
kind: input.tab.kind,
|
||||||
subtitle: descriptor.subtitle,
|
label: descriptor.label,
|
||||||
titleState: descriptor.titleState,
|
subtitle: descriptor.subtitle,
|
||||||
icon: descriptor.icon,
|
titleState: descriptor.titleState,
|
||||||
statusBucket: descriptor.statusBucket,
|
icon: descriptor.icon,
|
||||||
};
|
statusBucket: descriptor.statusBucket,
|
||||||
|
}),
|
||||||
|
[
|
||||||
|
descriptor.icon,
|
||||||
|
descriptor.label,
|
||||||
|
descriptor.statusBucket,
|
||||||
|
descriptor.subtitle,
|
||||||
|
descriptor.titleState,
|
||||||
|
input.tab.key,
|
||||||
|
input.tab.kind,
|
||||||
|
]
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
type WorkspaceTabIconProps = {
|
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