* perf(terminal): smooth layout drags and faster tab switching Defer xterm refit during split, sidebar, and host-tree drags while keeping pane containers in sync with live layout measurements. Refactor TerminalLayer into focused sections with TabBridge/memo optimizations and add the terminal host tree sidebar. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(terminal): keep side panels alive and guard session attach races Prevent terminal boot unmount from leaking backend sessions, keep SFTP/scripts/theme/AI state when switching side tabs, and defer heavy SFTP UI mount so first entry stays responsive. Co-authored-by: Cursor <cursoragent@cursor.com> * perf(terminal): reduce tab switch jank --------- Co-authored-by: Cursor <cursoragent@cursor.com>
328 lines
13 KiB
TypeScript
328 lines
13 KiB
TypeScript
import { type DragEvent, useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
|
|
|
import { terminalLayoutSuppressStore } from '../../application/state/terminalLayoutSuppressStore';
|
|
import type { TerminalSession, Workspace, WorkspaceNode } from '../../types';
|
|
import type { ResizerHandle, SplitHint, WorkspaceRect } from './TerminalLayerSupport';
|
|
import {
|
|
computeSplitSizesFromDelta,
|
|
patchWorkspaceSplitSizes,
|
|
type WorkspaceResizeSession,
|
|
} from './workspaceSplitResize';
|
|
|
|
// Pure recursive lookup — module-level so its identity is stable across renders
|
|
// (it was previously recreated every render, churning the workspace-section memo).
|
|
function findSplitNode(node: WorkspaceNode, splitId: string): WorkspaceNode | null {
|
|
if (node.type === 'split') {
|
|
if (node.id === splitId) return node;
|
|
for (const child of node.children) {
|
|
const found = findSplitNode(child, splitId);
|
|
if (found) return found;
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
interface UseTerminalWorkspaceLayoutOptions {
|
|
activeSession: TerminalSession | undefined;
|
|
activeWorkspace: Workspace | undefined;
|
|
isFocusMode: boolean;
|
|
onAddSessionToWorkspace: (workspaceId: string, sessionId: string, hint: Exclude<SplitHint, null>) => void;
|
|
onCreateWorkspaceFromSessions: (baseSessionId: string, joiningSessionId: string, hint: Exclude<SplitHint, null>) => void;
|
|
onSetDraggingSessionId: (id: string | null) => void;
|
|
onUpdateSplitSizes: (workspaceId: string, splitId: string, sizes: number[]) => void;
|
|
sessions: TerminalSession[];
|
|
workspaces: Workspace[];
|
|
}
|
|
|
|
export function useTerminalWorkspaceLayout({
|
|
activeSession,
|
|
activeWorkspace,
|
|
isFocusMode,
|
|
onAddSessionToWorkspace,
|
|
onCreateWorkspaceFromSessions,
|
|
onSetDraggingSessionId,
|
|
onUpdateSplitSizes,
|
|
sessions,
|
|
workspaces,
|
|
}: UseTerminalWorkspaceLayoutOptions) {
|
|
const [workspaceArea, setWorkspaceArea] = useState<{ width: number; height: number }>({ width: 0, height: 0 });
|
|
|
|
const workspaceOuterRef = useRef<HTMLDivElement>(null);
|
|
|
|
const workspaceInnerRef = useRef<HTMLDivElement>(null);
|
|
|
|
const workspaceOverlayRef = useRef<HTMLDivElement>(null);
|
|
|
|
const [dropHint, setDropHint] = useState<SplitHint>(null);
|
|
|
|
const [resizing, setResizing] = useState<WorkspaceResizeSession | null>(null);
|
|
const [resizePreviewDelta, setResizePreviewDelta] = useState(0);
|
|
const resizePreviewDeltaRef = useRef(0);
|
|
|
|
useEffect(() => {
|
|
if (!resizing) return;
|
|
|
|
terminalLayoutSuppressStore.begin();
|
|
resizePreviewDeltaRef.current = 0;
|
|
setResizePreviewDelta(0);
|
|
|
|
let rafId: number | null = null;
|
|
const onMove = (e: MouseEvent) => {
|
|
const delta = resizing.direction === 'vertical'
|
|
? e.clientX - resizing.startClient.x
|
|
: e.clientY - resizing.startClient.y;
|
|
if (rafId !== null) return;
|
|
rafId = requestAnimationFrame(() => {
|
|
rafId = null;
|
|
resizePreviewDeltaRef.current = delta;
|
|
setResizePreviewDelta(delta);
|
|
});
|
|
};
|
|
const onUp = () => {
|
|
if (rafId !== null) cancelAnimationFrame(rafId);
|
|
const finalSizes = computeSplitSizesFromDelta(resizing, resizePreviewDeltaRef.current);
|
|
onUpdateSplitSizes(resizing.workspaceId, resizing.splitId, finalSizes);
|
|
setResizing(null);
|
|
};
|
|
|
|
document.body.style.userSelect = 'none';
|
|
document.body.style.cursor = resizing.direction === 'vertical' ? 'ew-resize' : 'ns-resize';
|
|
window.addEventListener('mousemove', onMove);
|
|
window.addEventListener('mouseup', onUp);
|
|
return () => {
|
|
if (rafId !== null) cancelAnimationFrame(rafId);
|
|
document.body.style.userSelect = '';
|
|
document.body.style.cursor = '';
|
|
window.removeEventListener('mousemove', onMove);
|
|
window.removeEventListener('mouseup', onUp);
|
|
terminalLayoutSuppressStore.end();
|
|
};
|
|
}, [resizing, onUpdateSplitSizes]);
|
|
|
|
const computeWorkspaceRects = useCallback((workspace?: Workspace, size?: { width: number; height: number }): Record<string, WorkspaceRect> => {
|
|
if (!workspace) return {} as Record<string, WorkspaceRect>;
|
|
const wTotal = size?.width || 1;
|
|
const hTotal = size?.height || 1;
|
|
const rects: Record<string, WorkspaceRect> = {};
|
|
const walk = (node: WorkspaceNode, area: WorkspaceRect) => {
|
|
if (node.type === 'pane') {
|
|
rects[node.sessionId] = area;
|
|
return;
|
|
}
|
|
const isVertical = node.direction === 'vertical';
|
|
const sizes = (node.sizes && node.sizes.length === node.children.length ? node.sizes : Array(node.children.length).fill(1));
|
|
const total = sizes.reduce((acc, n) => acc + n, 0) || 1;
|
|
let offset = 0;
|
|
node.children.forEach((child, idx) => {
|
|
const share = sizes[idx] / total;
|
|
const childArea = isVertical
|
|
? { x: area.x + area.w * offset, y: area.y, w: area.w * share, h: area.h }
|
|
: { x: area.x, y: area.y + area.h * offset, w: area.w, h: area.h * share };
|
|
walk(child, childArea);
|
|
offset += share;
|
|
});
|
|
};
|
|
walk(workspace.root, { x: 0, y: 0, w: wTotal, h: hTotal });
|
|
return rects;
|
|
}, []);
|
|
|
|
const workspaceForLayout = useCallback((workspace: Workspace): Workspace => {
|
|
if (!resizing || resizing.workspaceId !== workspace.id) return workspace;
|
|
const previewSizes = computeSplitSizesFromDelta(resizing, resizePreviewDelta);
|
|
return patchWorkspaceSplitSizes(workspace, resizing.splitId, previewSizes);
|
|
}, [resizePreviewDelta, resizing]);
|
|
|
|
const workspaceRectsCacheRef = useRef(new Map<string, {
|
|
root: Workspace['root'];
|
|
previewKey: string;
|
|
width: number;
|
|
height: number;
|
|
rects: Record<string, WorkspaceRect>;
|
|
}>());
|
|
|
|
const workspaceRectsById = useMemo(
|
|
() => {
|
|
const map = new Map<string, Record<string, WorkspaceRect>>();
|
|
const previewKey = resizing
|
|
? `${resizing.workspaceId}:${resizing.splitId}:${resizePreviewDelta}`
|
|
: 'still';
|
|
const liveWorkspaceIds = new Set(workspaces.map((workspace) => workspace.id));
|
|
for (const workspaceId of workspaceRectsCacheRef.current.keys()) {
|
|
if (!liveWorkspaceIds.has(workspaceId)) {
|
|
workspaceRectsCacheRef.current.delete(workspaceId);
|
|
}
|
|
}
|
|
|
|
// Hidden workspaces do not need fresh geometry. Recomputing every
|
|
// workspace on a side-panel width change makes tab switches fan out to
|
|
// panes that cannot currently be seen.
|
|
if (!activeWorkspace) return map;
|
|
|
|
const workspace = workspaces.find((candidate) => candidate.id === activeWorkspace.id) ?? activeWorkspace;
|
|
const layoutWorkspace = workspaceForLayout(workspace);
|
|
const cached = workspaceRectsCacheRef.current.get(workspace.id);
|
|
if (
|
|
cached
|
|
&& cached.root === layoutWorkspace.root
|
|
&& cached.previewKey === previewKey
|
|
&& cached.width === workspaceArea.width
|
|
&& cached.height === workspaceArea.height
|
|
) {
|
|
map.set(workspace.id, cached.rects);
|
|
return map;
|
|
}
|
|
|
|
const rects = computeWorkspaceRects(layoutWorkspace, workspaceArea);
|
|
workspaceRectsCacheRef.current.set(workspace.id, {
|
|
root: layoutWorkspace.root,
|
|
previewKey,
|
|
width: workspaceArea.width,
|
|
height: workspaceArea.height,
|
|
rects,
|
|
});
|
|
map.set(workspace.id, rects);
|
|
return map;
|
|
},
|
|
[activeWorkspace, computeWorkspaceRects, resizePreviewDelta, resizing, workspaceArea, workspaceForLayout, workspaces],
|
|
);
|
|
|
|
const activeWorkspaceRects = useMemo<Record<string, WorkspaceRect>>(
|
|
() => activeWorkspace ? workspaceRectsById.get(activeWorkspace.id) ?? {} : {},
|
|
[activeWorkspace, workspaceRectsById]
|
|
);
|
|
|
|
const collectResizers = useCallback((workspace?: Workspace, size?: { width: number; height: number }): ResizerHandle[] => {
|
|
if (!workspace || !size?.width || !size?.height) return [];
|
|
const resizers: ResizerHandle[] = [];
|
|
const walk = (node: WorkspaceNode, area: { x: number; y: number; w: number; h: number }) => {
|
|
if (node.type === 'pane') return;
|
|
const isVertical = node.direction === 'vertical';
|
|
const sizes = (node.sizes && node.sizes.length === node.children.length ? node.sizes : Array(node.children.length).fill(1));
|
|
const total = sizes.reduce((acc, n) => acc + n, 0) || 1;
|
|
let offset = 0;
|
|
node.children.forEach((child, idx) => {
|
|
const share = sizes[idx] / total;
|
|
const childArea = isVertical
|
|
? { x: area.x + area.w * offset, y: area.y, w: area.w * share, h: area.h }
|
|
: { x: area.x, y: area.y + area.h * offset, w: area.w, h: area.h * share };
|
|
if (idx < node.children.length - 1) {
|
|
const boundary = isVertical ? childArea.x + childArea.w : childArea.y + childArea.h;
|
|
const rect = isVertical
|
|
? { x: boundary - 2, y: area.y, w: 4, h: area.h }
|
|
: { x: area.x, y: boundary - 2, w: area.w, h: 4 };
|
|
resizers.push({
|
|
id: `${node.id}-${idx}`,
|
|
splitId: node.id,
|
|
index: idx,
|
|
direction: node.direction,
|
|
rect,
|
|
splitArea: { w: area.w, h: area.h },
|
|
});
|
|
}
|
|
walk(child, childArea);
|
|
offset += share;
|
|
});
|
|
};
|
|
walk(workspace.root, { x: 0, y: 0, w: size.width, h: size.height });
|
|
return resizers;
|
|
}, []);
|
|
|
|
const activeResizers = useMemo(
|
|
() => collectResizers(
|
|
activeWorkspace ? workspaceForLayout(activeWorkspace) : undefined,
|
|
workspaceArea,
|
|
),
|
|
[activeWorkspace, workspaceArea, collectResizers, workspaceForLayout],
|
|
);
|
|
|
|
const computeSplitHint = useCallback((e: DragEvent): SplitHint => {
|
|
if (isFocusMode) return null;
|
|
const surface = workspaceOverlayRef.current || workspaceInnerRef.current || workspaceOuterRef.current;
|
|
if (!surface || !workspaceArea.width || !workspaceArea.height) return null;
|
|
const rect = surface.getBoundingClientRect();
|
|
const localX = e.clientX - rect.left;
|
|
const localY = e.clientY - rect.top;
|
|
if (localX < 0 || localX > rect.width || localY < 0 || localY > rect.height) return null;
|
|
|
|
let targetSessionId: string | undefined;
|
|
let targetRect: WorkspaceRect | undefined;
|
|
const workspaceEntries = Object.entries(activeWorkspaceRects) as Array<[string, WorkspaceRect]>;
|
|
workspaceEntries.forEach(([sessionId, area]) => {
|
|
if (targetSessionId) return;
|
|
if (
|
|
localX >= area.x &&
|
|
localX <= area.x + area.w &&
|
|
localY >= area.y &&
|
|
localY <= area.y + area.h
|
|
) {
|
|
targetSessionId = sessionId;
|
|
targetRect = area;
|
|
}
|
|
});
|
|
|
|
const baseRect: WorkspaceRect = targetRect || { x: 0, y: 0, w: rect.width, h: rect.height };
|
|
const relX = (localX - baseRect.x) / baseRect.w;
|
|
const relY = (localY - baseRect.y) / baseRect.h;
|
|
|
|
const prefersVertical = Math.abs(relX - 0.5) > Math.abs(relY - 0.5);
|
|
const direction = prefersVertical ? 'vertical' : 'horizontal';
|
|
const position = prefersVertical
|
|
? (relX < 0.5 ? 'left' : 'right')
|
|
: (relY < 0.5 ? 'top' : 'bottom');
|
|
|
|
const previewRect: WorkspaceRect = { ...baseRect };
|
|
if (direction === 'vertical') {
|
|
previewRect.w = baseRect.w / 2;
|
|
previewRect.x = position === 'left' ? baseRect.x : baseRect.x + baseRect.w / 2;
|
|
} else {
|
|
previewRect.h = baseRect.h / 2;
|
|
previewRect.y = position === 'top' ? baseRect.y : baseRect.y + baseRect.h / 2;
|
|
}
|
|
|
|
return {
|
|
direction,
|
|
position,
|
|
targetSessionId,
|
|
rect: previewRect,
|
|
};
|
|
}, [isFocusMode, workspaceArea, activeWorkspaceRects, workspaceOverlayRef, workspaceInnerRef, workspaceOuterRef]);
|
|
|
|
const handleWorkspaceDrop = useCallback((e: DragEvent) => {
|
|
if (isFocusMode) return;
|
|
const draggedSessionId = e.dataTransfer.getData('session-id');
|
|
if (!draggedSessionId) return;
|
|
e.preventDefault();
|
|
const hint = computeSplitHint(e);
|
|
setDropHint(null);
|
|
onSetDraggingSessionId(null);
|
|
if (!hint) return;
|
|
|
|
if (activeWorkspace) {
|
|
const draggedSession = sessions.find(s => s.id === draggedSessionId);
|
|
if (!draggedSession || draggedSession.workspaceId) return;
|
|
onAddSessionToWorkspace(activeWorkspace.id, draggedSessionId, hint);
|
|
return;
|
|
}
|
|
|
|
if (activeSession) {
|
|
onCreateWorkspaceFromSessions(activeSession.id, draggedSessionId, hint);
|
|
}
|
|
}, [isFocusMode, computeSplitHint, setDropHint, onSetDraggingSessionId, activeWorkspace, sessions, onAddSessionToWorkspace, activeSession, onCreateWorkspaceFromSessions]);
|
|
|
|
return {
|
|
activeResizers,
|
|
computeSplitHint,
|
|
dropHint,
|
|
findSplitNode,
|
|
handleWorkspaceDrop,
|
|
resizing,
|
|
setDropHint,
|
|
setResizing,
|
|
setWorkspaceArea,
|
|
workspaceInnerRef,
|
|
workspaceOuterRef,
|
|
workspaceOverlayRef,
|
|
workspaceRectsById,
|
|
};
|
|
}
|