489 lines
15 KiB
TypeScript
489 lines
15 KiB
TypeScript
import { Workspace,WorkspaceNode,WorkspaceViewMode } from './models';
|
|
|
|
export type SplitDirection = 'horizontal' | 'vertical';
|
|
type SplitPosition = 'left' | 'right' | 'top' | 'bottom';
|
|
|
|
export type SplitHint = {
|
|
direction: SplitDirection;
|
|
position: SplitPosition;
|
|
targetSessionId?: string;
|
|
};
|
|
|
|
export const pruneWorkspaceNode = (node: WorkspaceNode, targetSessionId: string): WorkspaceNode | null => {
|
|
if (node.type === 'pane') {
|
|
return node.sessionId === targetSessionId ? null : node;
|
|
}
|
|
|
|
const nextChildren: WorkspaceNode[] = [];
|
|
const nextSizes: number[] = [];
|
|
const sizeList = node.sizes && node.sizes.length === node.children.length
|
|
? node.sizes
|
|
: node.children.map(() => 1 / node.children.length);
|
|
let removedDirectChild = false;
|
|
|
|
node.children.forEach((child, idx) => {
|
|
const pruned = pruneWorkspaceNode(child, targetSessionId);
|
|
if (pruned) {
|
|
nextChildren.push(pruned);
|
|
nextSizes.push(sizeList[idx] ?? 1 / node.children.length);
|
|
} else {
|
|
removedDirectChild = true;
|
|
}
|
|
});
|
|
|
|
if (nextChildren.length === 0) return null;
|
|
if (nextChildren.length === 1) return nextChildren[0];
|
|
|
|
// Only rebalance siblings to equal sizes when this level actually
|
|
// lost one of its direct children. If the prune happened deeper in
|
|
// one branch, this split's direct children are unchanged and their
|
|
// original ratios must be preserved (otherwise e.g. a root 0.8/0.2
|
|
// split gets rewritten to 0.5/0.5 when a grand-child pane closes).
|
|
if (removedDirectChild) {
|
|
const equalSize = 1 / nextChildren.length;
|
|
return { ...node, children: nextChildren, sizes: nextChildren.map(() => equalSize) };
|
|
}
|
|
|
|
// Preserve existing ratios; normalise defensively in case sibling
|
|
// subtrees changed shape (e.g. a split collapsed to a single pane).
|
|
const total = nextSizes.reduce((acc, n) => acc + n, 0) || 1;
|
|
const normalized = nextSizes.map(n => n / total);
|
|
return { ...node, children: nextChildren, sizes: normalized };
|
|
};
|
|
|
|
/**
|
|
* Append a new pane containing `sessionId` to the end of the workspace
|
|
* root's split. If the root already splits in the requested direction,
|
|
* the new pane becomes its last sibling and all sibling sizes are reset
|
|
* to equal. Otherwise the root is wrapped in a new split (same behaviour
|
|
* as the existing `insertPaneIntoWorkspace(root, id, { targetSessionId:
|
|
* undefined })` path) with two equal children.
|
|
*/
|
|
export const appendPaneToWorkspaceRoot = (
|
|
root: WorkspaceNode,
|
|
sessionId: string,
|
|
direction: SplitDirection = 'vertical',
|
|
): WorkspaceNode => {
|
|
const newPane: WorkspaceNode = { id: crypto.randomUUID(), type: 'pane', sessionId };
|
|
|
|
if (root.type === 'split' && root.direction === direction) {
|
|
const nextChildren = [...root.children, newPane];
|
|
const equalSize = 1 / nextChildren.length;
|
|
return {
|
|
...root,
|
|
children: nextChildren,
|
|
sizes: nextChildren.map(() => equalSize),
|
|
};
|
|
}
|
|
|
|
return {
|
|
id: crypto.randomUUID(),
|
|
type: 'split',
|
|
direction,
|
|
children: [root, newPane],
|
|
sizes: [0.5, 0.5],
|
|
};
|
|
};
|
|
|
|
const createSplitFromPane = (
|
|
existingPane: WorkspaceNode,
|
|
newPane: WorkspaceNode,
|
|
hint: SplitHint
|
|
): WorkspaceNode => {
|
|
const children = (hint.position === 'left' || hint.position === 'top') ? [newPane, existingPane] : [existingPane, newPane];
|
|
return {
|
|
id: crypto.randomUUID(),
|
|
type: 'split',
|
|
direction: hint.direction,
|
|
children,
|
|
sizes: [1, 1],
|
|
};
|
|
};
|
|
|
|
export const insertPaneIntoWorkspace = (
|
|
root: WorkspaceNode,
|
|
sessionId: string,
|
|
hint: SplitHint
|
|
): WorkspaceNode => {
|
|
const pane: WorkspaceNode = { id: crypto.randomUUID(), type: 'pane', sessionId };
|
|
|
|
if (!hint.targetSessionId) {
|
|
const children = (hint.position === 'left' || hint.position === 'top') ? [pane, root] : [root, pane];
|
|
return {
|
|
id: crypto.randomUUID(),
|
|
type: 'split',
|
|
direction: hint.direction,
|
|
children,
|
|
sizes: [1, 1],
|
|
};
|
|
}
|
|
|
|
const insertPane = (node: WorkspaceNode): WorkspaceNode => {
|
|
if (node.type === 'pane' && node.sessionId === hint.targetSessionId) {
|
|
return createSplitFromPane(node, pane, hint);
|
|
}
|
|
if (node.type === 'split') {
|
|
return { ...node, children: node.children.map(child => insertPane(child)) };
|
|
}
|
|
return node;
|
|
};
|
|
|
|
return insertPane(root);
|
|
};
|
|
|
|
export const createWorkspaceFromSessions = (
|
|
baseSessionId: string,
|
|
joiningSessionId: string,
|
|
hint: SplitHint
|
|
): Workspace => {
|
|
const basePane: WorkspaceNode = { id: crypto.randomUUID(), type: 'pane', sessionId: baseSessionId };
|
|
const newPane: WorkspaceNode = { id: crypto.randomUUID(), type: 'pane', sessionId: joiningSessionId };
|
|
const children = (hint.position === 'left' || hint.position === 'top') ? [newPane, basePane] : [basePane, newPane];
|
|
|
|
return {
|
|
id: `ws-${crypto.randomUUID()}`,
|
|
title: 'Workspace',
|
|
focusedSessionId: baseSessionId, // Initialize with the base session focused
|
|
focusSessionOrder: [baseSessionId, joiningSessionId],
|
|
root: {
|
|
id: crypto.randomUUID(),
|
|
type: 'split',
|
|
direction: hint.direction,
|
|
children,
|
|
sizes: [1, 1],
|
|
},
|
|
};
|
|
};
|
|
|
|
export const updateWorkspaceSplitSizes = (
|
|
root: WorkspaceNode,
|
|
splitId: string,
|
|
sizes: number[]
|
|
): WorkspaceNode => {
|
|
const patch = (node: WorkspaceNode): WorkspaceNode => {
|
|
if (node.type === 'split') {
|
|
if (node.id === splitId) {
|
|
return { ...node, sizes };
|
|
}
|
|
return { ...node, children: node.children.map(child => patch(child)) };
|
|
}
|
|
return node;
|
|
};
|
|
return patch(root);
|
|
};
|
|
|
|
export const resolveWorkspaceFocusSessionOrder = (
|
|
root: WorkspaceNode,
|
|
savedOrder?: string[],
|
|
): string[] => {
|
|
const sessionIds = collectSessionIds(root);
|
|
if (!savedOrder?.length) return sessionIds;
|
|
|
|
const sessionIdSet = new Set(sessionIds);
|
|
const ordered = savedOrder.filter((id, index) => (
|
|
sessionIdSet.has(id) && savedOrder.indexOf(id) === index
|
|
));
|
|
const orderedSet = new Set(ordered);
|
|
return [...ordered, ...sessionIds.filter((id) => !orderedSet.has(id))];
|
|
};
|
|
|
|
export const reorderWorkspaceFocusSessionOrder = (
|
|
root: WorkspaceNode,
|
|
savedOrder: string[] | undefined,
|
|
draggedSessionId: string,
|
|
targetSessionId: string,
|
|
position: 'before' | 'after' = 'before',
|
|
): string[] => {
|
|
if (draggedSessionId === targetSessionId) {
|
|
return resolveWorkspaceFocusSessionOrder(root, savedOrder);
|
|
}
|
|
|
|
const currentOrder = resolveWorkspaceFocusSessionOrder(root, savedOrder);
|
|
const draggedIndex = currentOrder.indexOf(draggedSessionId);
|
|
const targetIndex = currentOrder.indexOf(targetSessionId);
|
|
|
|
if (draggedIndex === -1 || targetIndex === -1) return currentOrder;
|
|
|
|
currentOrder.splice(draggedIndex, 1);
|
|
let insertIndex = targetIndex;
|
|
if (draggedIndex < targetIndex) insertIndex -= 1;
|
|
if (position === 'after') insertIndex += 1;
|
|
currentOrder.splice(insertIndex, 0, draggedSessionId);
|
|
|
|
return currentOrder;
|
|
};
|
|
|
|
/**
|
|
* Create a workspace from multiple session IDs.
|
|
* Used for snippet runner - creates a workspace with all sessions in a horizontal split.
|
|
*/
|
|
export const createWorkspaceFromSessionIds = (
|
|
sessionIds: string[],
|
|
options: {
|
|
title: string;
|
|
viewMode?: WorkspaceViewMode;
|
|
snippetId?: string;
|
|
}
|
|
): Workspace => {
|
|
if (sessionIds.length === 0) {
|
|
throw new Error('Cannot create workspace with no sessions');
|
|
}
|
|
|
|
if (sessionIds.length === 1) {
|
|
// Single pane workspace
|
|
return {
|
|
id: `ws-${crypto.randomUUID()}`,
|
|
title: options.title,
|
|
viewMode: options.viewMode,
|
|
snippetId: options.snippetId,
|
|
focusedSessionId: sessionIds[0],
|
|
focusSessionOrder: [sessionIds[0]],
|
|
root: {
|
|
id: crypto.randomUUID(),
|
|
type: 'pane',
|
|
sessionId: sessionIds[0],
|
|
},
|
|
};
|
|
}
|
|
|
|
// Multiple sessions - create a horizontal split
|
|
const children: WorkspaceNode[] = sessionIds.map(sessionId => ({
|
|
id: crypto.randomUUID(),
|
|
type: 'pane' as const,
|
|
sessionId,
|
|
}));
|
|
|
|
return {
|
|
id: `ws-${crypto.randomUUID()}`,
|
|
title: options.title,
|
|
viewMode: options.viewMode,
|
|
snippetId: options.snippetId,
|
|
focusedSessionId: sessionIds[0],
|
|
focusSessionOrder: sessionIds,
|
|
root: {
|
|
id: crypto.randomUUID(),
|
|
type: 'split',
|
|
direction: 'vertical', // Side by side
|
|
children,
|
|
sizes: children.map(() => 1),
|
|
},
|
|
};
|
|
};
|
|
|
|
/**
|
|
* Collect all session IDs from a workspace node tree.
|
|
*/
|
|
export const collectSessionIds = (node: WorkspaceNode): string[] => {
|
|
if (node.type === 'pane') {
|
|
return [node.sessionId];
|
|
}
|
|
return node.children.flatMap(child => collectSessionIds(child));
|
|
};
|
|
|
|
/**
|
|
* Find a pane node by session ID in the workspace tree.
|
|
*/
|
|
const _findPaneBySessionId = (node: WorkspaceNode, sessionId: string): WorkspaceNode | null => {
|
|
if (node.type === 'pane') {
|
|
return node.sessionId === sessionId ? node : null;
|
|
}
|
|
for (const child of node.children) {
|
|
const found = _findPaneBySessionId(child, sessionId);
|
|
if (found) return found;
|
|
}
|
|
return null;
|
|
};
|
|
|
|
/**
|
|
* Get the path to a session in the workspace tree.
|
|
* Returns an array of indices representing the path from root to the pane.
|
|
*/
|
|
const _getPathToSession = (node: WorkspaceNode, sessionId: string, path: number[] = []): number[] | null => {
|
|
if (node.type === 'pane') {
|
|
return node.sessionId === sessionId ? path : null;
|
|
}
|
|
for (let i = 0; i < node.children.length; i++) {
|
|
const result = _getPathToSession(node.children[i], sessionId, [...path, i]);
|
|
if (result) return result;
|
|
}
|
|
return null;
|
|
};
|
|
|
|
/**
|
|
* Get all panes with their positions for navigation.
|
|
*/
|
|
interface PanePosition {
|
|
sessionId: string;
|
|
path: number[];
|
|
// Calculated bounds (normalized 0-1)
|
|
x: number;
|
|
y: number;
|
|
width: number;
|
|
height: number;
|
|
}
|
|
|
|
const collectPanePositions = (
|
|
node: WorkspaceNode,
|
|
path: number[] = [],
|
|
bounds: { x: number; y: number; width: number; height: number } = { x: 0, y: 0, width: 1, height: 1 }
|
|
): PanePosition[] => {
|
|
if (node.type === 'pane') {
|
|
return [{
|
|
sessionId: node.sessionId,
|
|
path,
|
|
...bounds,
|
|
}];
|
|
}
|
|
|
|
const positions: PanePosition[] = [];
|
|
const sizes = node.sizes || node.children.map(() => 1 / node.children.length);
|
|
const totalSize = sizes.reduce((a, b) => a + b, 0) || 1;
|
|
|
|
let offset = 0;
|
|
for (let i = 0; i < node.children.length; i++) {
|
|
const sizeRatio = (sizes[i] || 1 / node.children.length) / totalSize;
|
|
|
|
let childBounds: { x: number; y: number; width: number; height: number };
|
|
if (node.direction === 'horizontal') {
|
|
// Top/bottom split
|
|
childBounds = {
|
|
x: bounds.x,
|
|
y: bounds.y + bounds.height * offset,
|
|
width: bounds.width,
|
|
height: bounds.height * sizeRatio,
|
|
};
|
|
} else {
|
|
// Left/right split
|
|
childBounds = {
|
|
x: bounds.x + bounds.width * offset,
|
|
y: bounds.y,
|
|
width: bounds.width * sizeRatio,
|
|
height: bounds.height,
|
|
};
|
|
}
|
|
|
|
positions.push(...collectPanePositions(node.children[i], [...path, i], childBounds));
|
|
offset += sizeRatio;
|
|
}
|
|
|
|
return positions;
|
|
};
|
|
|
|
export type FocusDirection = 'up' | 'down' | 'left' | 'right';
|
|
|
|
/**
|
|
* Find the next session to focus when moving in a direction.
|
|
* Returns the session ID to focus, or null if no valid target.
|
|
*/
|
|
export const getNextFocusSessionId = (
|
|
root: WorkspaceNode,
|
|
currentSessionId: string,
|
|
direction: FocusDirection
|
|
): string | null => {
|
|
const positions = collectPanePositions(root);
|
|
|
|
const current = positions.find(p => p.sessionId === currentSessionId);
|
|
if (!current) {
|
|
return null;
|
|
}
|
|
|
|
// Filter candidates based on direction
|
|
let candidates: PanePosition[] = [];
|
|
const otherPanes = positions.filter(p => p.sessionId !== currentSessionId);
|
|
|
|
switch (direction) {
|
|
case 'left':
|
|
// Find panes to the left
|
|
candidates = otherPanes.filter(p =>
|
|
p.x + p.width <= current.x + 0.001 // Allow small epsilon for floating point
|
|
);
|
|
// Wraparound: if no pane to the left, find the rightmost pane
|
|
if (candidates.length === 0 && otherPanes.length > 0) {
|
|
const maxX = Math.max(...otherPanes.map(p => p.x));
|
|
candidates = otherPanes.filter(p => p.x >= maxX - 0.001);
|
|
}
|
|
break;
|
|
case 'right':
|
|
// Find panes to the right
|
|
candidates = otherPanes.filter(p =>
|
|
p.x >= current.x + current.width - 0.001
|
|
);
|
|
// Wraparound: if no pane to the right, find the leftmost pane
|
|
if (candidates.length === 0 && otherPanes.length > 0) {
|
|
const minX = Math.min(...otherPanes.map(p => p.x));
|
|
candidates = otherPanes.filter(p => p.x <= minX + 0.001);
|
|
}
|
|
break;
|
|
case 'up':
|
|
// Find panes above
|
|
candidates = otherPanes.filter(p =>
|
|
p.y + p.height <= current.y + 0.001
|
|
);
|
|
// Wraparound: if no pane above, find the bottommost pane
|
|
if (candidates.length === 0 && otherPanes.length > 0) {
|
|
const maxY = Math.max(...otherPanes.map(p => p.y));
|
|
candidates = otherPanes.filter(p => p.y >= maxY - 0.001);
|
|
}
|
|
break;
|
|
case 'down':
|
|
// Find panes below
|
|
candidates = otherPanes.filter(p =>
|
|
p.y >= current.y + current.height - 0.001
|
|
);
|
|
// Wraparound: if no pane below, find the topmost pane
|
|
if (candidates.length === 0 && otherPanes.length > 0) {
|
|
const minY = Math.min(...otherPanes.map(p => p.y));
|
|
candidates = otherPanes.filter(p => p.y <= minY + 0.001);
|
|
}
|
|
break;
|
|
}
|
|
|
|
if (candidates.length === 0) return null;
|
|
|
|
// Calculate center point of current pane for scoring
|
|
const currentCenterX = current.x + current.width / 2;
|
|
const currentCenterY = current.y + current.height / 2;
|
|
|
|
// Find the closest candidate
|
|
// For left/right, prefer candidates that overlap vertically
|
|
// For up/down, prefer candidates that overlap horizontally
|
|
let best: PanePosition | null = null;
|
|
let bestScore = Infinity;
|
|
|
|
for (const candidate of candidates) {
|
|
const candidateCenterX = candidate.x + candidate.width / 2;
|
|
const candidateCenterY = candidate.y + candidate.height / 2;
|
|
|
|
let score: number;
|
|
|
|
if (direction === 'left' || direction === 'right') {
|
|
// Check vertical overlap
|
|
const overlapTop = Math.max(current.y, candidate.y);
|
|
const overlapBottom = Math.min(current.y + current.height, candidate.y + candidate.height);
|
|
const hasOverlap = overlapBottom > overlapTop;
|
|
|
|
// Distance is horizontal distance, but penalize if no overlap
|
|
const distance = Math.abs(candidateCenterX - currentCenterX);
|
|
const verticalPenalty = hasOverlap ? 0 : Math.abs(candidateCenterY - currentCenterY) * 2;
|
|
score = distance + verticalPenalty;
|
|
} else {
|
|
// Check horizontal overlap
|
|
const overlapLeft = Math.max(current.x, candidate.x);
|
|
const overlapRight = Math.min(current.x + current.width, candidate.x + candidate.width);
|
|
const hasOverlap = overlapRight > overlapLeft;
|
|
|
|
// Distance is vertical distance, but penalize if no overlap
|
|
const distance = Math.abs(candidateCenterY - currentCenterY);
|
|
const horizontalPenalty = hasOverlap ? 0 : Math.abs(candidateCenterX - currentCenterX) * 2;
|
|
score = distance + horizontalPenalty;
|
|
}
|
|
|
|
if (score < bestScore) {
|
|
bestScore = score;
|
|
best = candidate;
|
|
}
|
|
}
|
|
|
|
return best?.sessionId || null;
|
|
};
|