[Init] Initial commit - NetMesh terminal manager
Some checks failed
build-packages / resolve bundled mosh-client (push) Has been cancelled
build-packages / resolve bundled et-client (push) Has been cancelled
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
build-packages / update Nix release metadata (push) Has been cancelled
build-packages / bump homebrew tap (push) Has been cancelled
test / lint-and-test (push) Has been cancelled
AI automation / Route event (push) Has been cancelled
AI automation / Hand reopened issue to maintainers (push) Has been cancelled
AI automation / Clean source issue state (push) Has been cancelled
AI automation / Reconcile handoffs (push) Has been cancelled
AI automation / Classify issue (push) Has been cancelled
AI automation / Claude Code smoke (push) Has been cancelled
AI automation / Review issue follow-up (push) Has been cancelled
AI automation / Publish issue follow-up (push) Has been cancelled
AI automation / Implement with Claude Code (push) Has been cancelled
AI automation / Publish implement PR (push) Has been cancelled
AI automation / Continue queued issue comments (push) Has been cancelled
AI automation / Codex review loop (push) Has been cancelled
AI automation / Publish Codex fix (push) Has been cancelled
AI automation / Clear Codex dispatch marker (push) Has been cancelled
AI automation / Own PR re-request Codex (push) Has been cancelled
AI automation / External PR re-request Codex (push) Has been cancelled
AI automation / Poll Codex reaction / retry (push) Has been cancelled
build-et-binaries / build-linux-x64 (push) Has been cancelled
build-et-binaries / build-linux-arm64 (push) Has been cancelled
build-et-binaries / build-macos-universal (push) Has been cancelled
build-et-binaries / build-windows-x64 (push) Has been cancelled
build-et-binaries / release (push) Has been cancelled

This commit is contained in:
2026-09-13 18:24:01 +08:00
commit 3c72efcb7f
3255 changed files with 907009 additions and 0 deletions

View File

@@ -0,0 +1,37 @@
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import test from 'node:test';
const source = readFileSync(new URL('./TerminalFocusSidebar.tsx', import.meta.url), 'utf8');
test('focus sidebar row memo refreshes when dynamic title mode changes', () => {
assert.match(source, /prev\.dynamicTabTitleMode === next\.dynamicTabTitleMode/);
});
test('focus sidebar accepts host-id drops for append-to-workspace', () => {
assert.match(source, /onAppendHostToWorkspace/);
assert.match(source, /resolveFocusSidebarDragKind/);
assert.match(source, /appendHostFromWorkspaceDrop/);
assert.match(source, /dropEffect = 'copy'/);
assert.match(source, /data-host-drop-active/);
});
test('the entire focus sidebar is the host drop target', () => {
const sidebarStart = source.indexOf('data-section="terminal-workspace-sidebar"');
const scrollAreaStart = source.indexOf('<ScrollArea className="flex-1">', sidebarStart);
assert.ok(sidebarStart >= 0 && scrollAreaStart > sidebarStart);
const sidebarOpeningTag = source.slice(sidebarStart, scrollAreaStart);
assert.match(sidebarOpeningTag, /data-focus-sidebar-drop-zone/);
assert.match(sidebarOpeningTag, /onDragOver=\{handleFocusSidebarHostDragOver\}/);
assert.match(sidebarOpeningTag, /onDrop=\{handleFocusSidebarHostDrop\}/);
assert.doesNotMatch(source.slice(scrollAreaStart), /\sdata-focus-sidebar-drop-zone(?:\s|>)/);
});
test('focus sidebar clears host-drop feedback when the drag leaves from a session row', () => {
assert.match(source, /data-focus-sidebar-drop-zone/);
assert.match(source, /onDragLeave=\{handleFocusSidebarHostDragLeave\}/);
assert.match(source, /onHostDragLeave=\{handleFocusSidebarHostDragLeave\}/);
assert.match(source, /dropZone\?\.contains\(next\)/);
assert.match(source, /clearFocusSidebarHostDrop\(\)/);
});

View File

@@ -0,0 +1,719 @@
import { Circle, Columns2, Plus, Search, Server } from 'lucide-react';
import React, { memo, useCallback, useMemo, useState, type DragEvent, type MouseEvent } from 'react';
import {
applySessionPresentation,
useSessionPresentationVersion,
} from '../../application/state/sessionPresentationStore';
import { useStoredNumber } from '../../application/state/useStoredNumber';
import { terminalReconnectRegistry } from '../../application/state/terminalReconnectRegistry';
import {
appendHostFromWorkspaceDrop,
FOCUS_SIDEBAR_SESSION_DRAG_TYPE,
resolveFocusSidebarDragKind,
} from '../../domain/focusSidebarHostDrop';
import { resolveWorkspaceFocusSessionOrder } from '../../domain/workspace';
import { resolveSessionTabTitle } from '../../domain/sessionTabTitle';
import type { DynamicTabTitleMode } from '../../domain/models';
import { STORAGE_KEY_WORKSPACE_FOCUS_SIDEBAR_WIDTH } from '../../infrastructure/config/storageKeys';
import { cn } from '../../lib/utils';
import type { Host, TerminalSession, TerminalTheme, Workspace } from '../../types';
import { DistroAvatar } from '../DistroAvatar';
import { SessionInlineRenameInput } from '../terminal/SessionInlineRenameInput';
import { SessionTabContextMenuContent } from '../top-tabs/SessionTabContextMenuContent';
import { Button } from '../ui/button';
import { ContextMenu, ContextMenuTrigger } from '../ui/context-menu';
import { Input } from '../ui/input';
import { ScrollArea } from '../ui/scroll-area';
import { Tooltip, TooltipContent, TooltipTrigger } from '../ui/tooltip';
interface TerminalFocusSidebarProps {
activeWorkspace: Workspace;
focusedSessionId: string | undefined;
onReorderWorkspaceSessions?: (workspaceId: string, draggedSessionId: string, targetSessionId: string, position: 'before' | 'after') => void;
onRequestAddToWorkspace?: (workspaceId: string) => void;
onAppendHostToWorkspace?: (workspaceId: string, hostId: string) => void;
onCloseSession: (sessionId: string) => void;
onCopySession?: (sessionId: string) => void;
onDuplicateSession?: (sessionId: string) => void;
onCopySessionToNewWindow?: (sessionId: string) => void;
onDetachSessionFromWorkspace?: (sessionId: string) => void;
onSetWorkspaceFocusedSession?: (workspaceId: string, sessionId: string) => void;
onToggleWorkspaceViewMode?: (workspaceId: string) => void;
onSubmitSessionRename: (sessionId: string, name: string) => void;
resolvedPreviewTheme: TerminalTheme;
sessionHostsMap: Map<string, Host>;
sessions: TerminalSession[];
dynamicTabTitleMode?: DynamicTabTitleMode;
t: (key: string) => string;
}
type FocusSidebarTheme = {
termBg: string;
termFg: string;
selectedBg: string;
selectedHoverBg: string;
unselectedHoverBg: string;
unselectedFg: string;
mutedFg: string;
separator: string;
};
type WorkspaceFocusSessionRowProps = {
session: TerminalSession;
host: Host | undefined;
isSelected: boolean;
isRenaming: boolean;
renameValue: string;
onStartRename: (sessionId: string) => void;
onSubmitRename: (name: string) => void;
onCancelRename: () => void;
onCloseSession: (sessionId: string) => void;
onCopySession?: (sessionId: string) => void;
onDuplicateSession?: (sessionId: string) => void;
onCopySessionToNewWindow?: (sessionId: string) => void;
onDetachSessionFromWorkspace?: (sessionId: string) => void;
isDragging: boolean;
dropPosition: 'before' | 'after' | null;
theme: FocusSidebarTheme;
onSelect: (sessionId: string) => void;
onDragStart: (event: DragEvent, sessionId: string) => void;
onDragOver: (event: DragEvent, sessionId: string) => void;
onHostDragLeave: (event: DragEvent<HTMLDivElement>) => void;
onDrop: (event: DragEvent, sessionId: string) => void;
onDragEnd: () => void;
dynamicTabTitleMode?: DynamicTabTitleMode;
t: (key: string) => string;
};
const WorkspaceFocusSessionRow = memo<WorkspaceFocusSessionRowProps>(({
session,
host,
isSelected,
isRenaming,
renameValue,
onStartRename,
onSubmitRename,
onCancelRename,
onCloseSession,
onCopySession,
onDuplicateSession,
onCopySessionToNewWindow,
onDetachSessionFromWorkspace,
isDragging,
dropPosition,
theme,
onSelect,
onDragStart,
onDragOver,
onHostDragLeave,
onDrop,
onDragEnd,
dynamicTabTitleMode,
t,
}) => {
const reconnectActive = React.useSyncExternalStore(
terminalReconnectRegistry.subscribe,
() => terminalReconnectRegistry.isActive(session.id),
() => false,
);
const {
termFg,
selectedBg,
selectedHoverBg,
unselectedHoverBg,
unselectedFg,
mutedFg,
} = theme;
const statusColor = session.status === 'connected'
? 'text-emerald-500'
: session.status === 'connecting'
? 'text-amber-500'
: 'text-red-500';
const restBg = isSelected ? selectedBg : 'transparent';
const hoverBg = isSelected ? selectedHoverBg : unselectedHoverBg;
const rowFg = isSelected ? termFg : unselectedFg;
return (
<ContextMenu>
<ContextMenuTrigger asChild>
<div
data-workspace-focus-session-id={session.id}
draggable
role="button"
tabIndex={0}
className={cn(
'relative flex w-full select-none items-center justify-start gap-2 rounded-md px-2 py-1.5 text-sm font-normal outline-none transition-colors hover:text-inherit focus-visible:ring-1',
isDragging && 'opacity-50',
)}
style={{
backgroundColor: restBg,
color: rowFg,
boxShadow: dropPosition
? `inset 0 ${dropPosition === 'before' ? '2px' : '-2px'} 0 ${termFg}`
: undefined,
}}
onContextMenu={() => onSelect(session.id)}
onDragStart={(event) => onDragStart(event, session.id)}
onDragOver={(event) => onDragOver(event, session.id)}
onDragLeave={onHostDragLeave}
onDrop={(event) => onDrop(event, session.id)}
onDragEnd={onDragEnd}
onMouseEnter={(event) => {
event.currentTarget.style.backgroundColor = hoverBg;
}}
onMouseLeave={(event) => {
event.currentTarget.style.backgroundColor = restBg;
}}
onClick={() => onSelect(session.id)}
onKeyDown={(event) => {
if (event.key !== 'Enter' && event.key !== ' ') return;
event.preventDefault();
onSelect(session.id);
}}
>
<div className="relative flex h-6 w-6 shrink-0 items-center justify-center self-center">
{host ? (
<DistroAvatar
host={host}
fallback={session.hostLabel}
size="sm"
className="!h-6 !w-6"
/>
) : (
<Server size={14} style={{ color: mutedFg }} />
)}
<Circle
size={5}
className={cn('absolute bottom-0 right-0 fill-current', statusColor)}
/>
</div>
<div className="flex min-h-6 min-w-0 flex-1 flex-col justify-center self-center text-left">
{isRenaming ? (
<SessionInlineRenameInput
initialName={renameValue}
onCommit={onSubmitRename}
onCancel={onCancelRename}
className="h-5 text-xs leading-4"
/>
) : (
<>
<div
className={cn('truncate text-xs leading-4', isSelected ? 'font-semibold' : 'font-medium')}
onDoubleClick={(e) => {
e.stopPropagation();
onStartRename(session.id);
}}
>
{resolveSessionTabTitle(session, dynamicTabTitleMode)}
</div>
<div className="mt-0.5 truncate text-[10px] leading-4" style={{ color: mutedFg }}>
{session.username}@{session.hostname}
</div>
</>
)}
</div>
</div>
</ContextMenuTrigger>
<SessionTabContextMenuContent
sessionId={session.id}
onCloseSession={onCloseSession}
onCopySession={onCopySession}
onDuplicateSession={onDuplicateSession}
onCopySessionToNewWindow={onCopySessionToNewWindow}
onDetachSession={onDetachSessionFromWorkspace}
onReconnectSession={terminalReconnectRegistry.request}
sessionStatus={session.status}
reconnectActive={reconnectActive}
onRenameSession={onStartRename}
t={t}
/>
</ContextMenu>
);
}, (prev, next) => (
prev.session === next.session
&& prev.host === next.host
&& prev.isSelected === next.isSelected
&& prev.isRenaming === next.isRenaming
&& prev.renameValue === next.renameValue
&& prev.isDragging === next.isDragging
&& prev.dropPosition === next.dropPosition
&& prev.theme === next.theme
&& prev.onSelect === next.onSelect
&& prev.onStartRename === next.onStartRename
&& prev.onSubmitRename === next.onSubmitRename
&& prev.onCancelRename === next.onCancelRename
&& prev.onCloseSession === next.onCloseSession
&& prev.onCopySession === next.onCopySession
&& prev.onDuplicateSession === next.onDuplicateSession
&& prev.onCopySessionToNewWindow === next.onCopySessionToNewWindow
&& prev.onDetachSessionFromWorkspace === next.onDetachSessionFromWorkspace
&& prev.onDragStart === next.onDragStart
&& prev.onDragOver === next.onDragOver
&& prev.onDrop === next.onDrop
&& prev.onDragEnd === next.onDragEnd
&& prev.dynamicTabTitleMode === next.dynamicTabTitleMode
&& prev.t === next.t
));
WorkspaceFocusSessionRow.displayName = 'WorkspaceFocusSessionRow';
const TerminalFocusSidebarInner: React.FC<TerminalFocusSidebarProps> = ({
activeWorkspace,
focusedSessionId,
onReorderWorkspaceSessions,
onRequestAddToWorkspace,
onAppendHostToWorkspace,
onCloseSession,
onCopySession,
onDuplicateSession,
onCopySessionToNewWindow,
onDetachSessionFromWorkspace,
onSetWorkspaceFocusedSession,
onToggleWorkspaceViewMode,
onSubmitSessionRename,
resolvedPreviewTheme,
sessionHostsMap,
sessions,
dynamicTabTitleMode,
t,
}) => {
const [focusSidebarSearch, setFocusSidebarSearch] = useState('');
const [focusSidebarDragSessionId, setFocusSidebarDragSessionId] = useState<string | null>(null);
const [focusSidebarHostDropActive, setFocusSidebarHostDropActive] = useState(false);
const [focusSidebarDropIndicator, setFocusSidebarDropIndicator] = useState<{
sessionId: string;
position: 'before' | 'after';
} | null>(null);
const [focusSidebarWidth, setFocusSidebarWidth, persistFocusSidebarWidth] = useStoredNumber(
STORAGE_KEY_WORKSPACE_FOCUS_SIDEBAR_WIDTH, 224, { min: 160, max: 480 },
);
const [sidebarRenameSessionId, setSidebarRenameSessionId] = useState<string | null>(null);
const [sidebarRenameValue, setSidebarRenameValue] = useState('');
const theme = useMemo<FocusSidebarTheme>(() => {
const termBg = resolvedPreviewTheme.colors.background;
const termFg = resolvedPreviewTheme.colors.foreground;
return {
termBg,
termFg,
selectedBg: `color-mix(in srgb, ${termFg} 10%, transparent)`,
selectedHoverBg: `color-mix(in srgb, ${termFg} 15%, transparent)`,
unselectedHoverBg: `color-mix(in srgb, ${termFg} 10%, transparent)`,
unselectedFg: `color-mix(in srgb, ${termFg} 75%, ${termBg} 25%)`,
mutedFg: `color-mix(in srgb, ${termFg} 55%, ${termBg} 45%)`,
separator: `color-mix(in srgb, ${termFg} 10%, ${termBg} 90%)`,
};
}, [resolvedPreviewTheme]);
// Live OSC / coding-CLI titles live in sessionPresentationStore (not sessions).
const presentationVersion = useSessionPresentationVersion();
const workspaceSessions = useMemo(() => {
void presentationVersion;
const sessionMap = new Map(sessions.map((session) => [session.id, session]));
return resolveWorkspaceFocusSessionOrder(activeWorkspace.root, activeWorkspace.focusSessionOrder)
.map((sessionId) => {
const session = sessionMap.get(sessionId);
return session ? applySessionPresentation(session) : undefined;
})
.filter((session): session is TerminalSession => Boolean(session));
}, [activeWorkspace, sessions, presentationVersion]);
const visibleSessions = useMemo(() => {
const term = focusSidebarSearch.trim().toLowerCase();
if (!term) return workspaceSessions;
return workspaceSessions.filter((session) => (
session.customName?.toLowerCase().includes(term)
|| session.hostLabel?.toLowerCase().includes(term)
|| session.dynamicTitle?.toLowerCase().includes(term)
|| session.hostname?.toLowerCase().includes(term)
|| session.username?.toLowerCase().includes(term)
));
}, [focusSidebarSearch, workspaceSessions]);
const handleFocusSidebarResizeStart = useCallback((event: MouseEvent) => {
event.preventDefault();
const startX = event.clientX;
const startWidth = focusSidebarWidth;
let lastWidth = startWidth;
let rafId: number | null = null;
const onMouseMove = (moveEvent: MouseEvent) => {
const delta = moveEvent.clientX - startX;
lastWidth = Math.max(160, Math.min(480, startWidth + delta));
if (rafId !== null) return;
rafId = requestAnimationFrame(() => {
rafId = null;
setFocusSidebarWidth(lastWidth);
});
};
const onMouseUp = () => {
if (rafId !== null) cancelAnimationFrame(rafId);
setFocusSidebarWidth(lastWidth);
persistFocusSidebarWidth(lastWidth);
window.removeEventListener('mousemove', onMouseMove);
window.removeEventListener('mouseup', onMouseUp);
};
window.addEventListener('mousemove', onMouseMove);
window.addEventListener('mouseup', onMouseUp);
}, [focusSidebarWidth, persistFocusSidebarWidth, setFocusSidebarWidth]);
const handleFocusSidebarDragStart = useCallback((event: DragEvent, sessionId: string) => {
event.stopPropagation();
event.dataTransfer.effectAllowed = 'move';
event.dataTransfer.setData(FOCUS_SIDEBAR_SESSION_DRAG_TYPE, sessionId);
setFocusSidebarDragSessionId(sessionId);
setFocusSidebarHostDropActive(false);
}, []);
const clearFocusSidebarHostDrop = useCallback(() => {
setFocusSidebarHostDropActive(false);
}, []);
const handleFocusSidebarHostDragLeave = useCallback((event: DragEvent<HTMLDivElement>) => {
event.stopPropagation();
const dropZone = event.currentTarget.closest<HTMLElement>('[data-focus-sidebar-drop-zone]');
const next = event.relatedTarget;
if (next instanceof Node && dropZone?.contains(next)) return;
clearFocusSidebarHostDrop();
}, [clearFocusSidebarHostDrop]);
const getFocusSidebarContainerDropTarget = useCallback((
container: HTMLElement,
clientY: number,
draggedSessionId: string,
): { sessionId: string; position: 'before' | 'after' } | null => {
const rows = Array.from(
container.querySelectorAll<HTMLElement>('[data-workspace-focus-session-id]'),
);
if (rows.length === 0) return null;
for (const row of rows) {
const sessionId = row.dataset.workspaceFocusSessionId;
if (!sessionId || sessionId === draggedSessionId) continue;
const rect = row.getBoundingClientRect();
if (clientY < rect.top) return { sessionId, position: 'before' };
if (clientY <= rect.bottom) {
return {
sessionId,
position: clientY < rect.top + rect.height / 2 ? 'before' : 'after',
};
}
}
const lastRow = [...rows].reverse().find((row) => (
row.dataset.workspaceFocusSessionId
&& row.dataset.workspaceFocusSessionId !== draggedSessionId
));
const lastSessionId = lastRow?.dataset.workspaceFocusSessionId;
return lastSessionId ? { sessionId: lastSessionId, position: 'after' } : null;
}, []);
const acceptFocusSidebarHostDrag = useCallback((event: DragEvent) => {
if (!onAppendHostToWorkspace) return false;
if (resolveFocusSidebarDragKind({
types: event.dataTransfer.types,
activeSessionDragId: focusSidebarDragSessionId,
}) !== 'host-append') {
return false;
}
event.preventDefault();
event.dataTransfer.dropEffect = 'copy';
setFocusSidebarDropIndicator(null);
setFocusSidebarHostDropActive(true);
return true;
}, [focusSidebarDragSessionId, onAppendHostToWorkspace]);
const handleFocusSidebarHostDragOver = useCallback((event: DragEvent<HTMLDivElement>) => {
acceptFocusSidebarHostDrag(event);
}, [acceptFocusSidebarHostDrag]);
const handleFocusSidebarDragOver = useCallback((event: DragEvent, targetSessionId: string) => {
if (acceptFocusSidebarHostDrag(event)) return;
const draggedSessionId = event.dataTransfer.getData(FOCUS_SIDEBAR_SESSION_DRAG_TYPE) || focusSidebarDragSessionId;
if (!draggedSessionId || draggedSessionId === targetSessionId) return;
event.preventDefault();
event.stopPropagation();
event.dataTransfer.dropEffect = 'move';
setFocusSidebarHostDropActive(false);
const rect = event.currentTarget.getBoundingClientRect();
const position = event.clientY < rect.top + rect.height / 2 ? 'before' : 'after';
setFocusSidebarDropIndicator({ sessionId: targetSessionId, position });
}, [acceptFocusSidebarHostDrag, focusSidebarDragSessionId]);
const handleFocusSidebarContainerDragOver = useCallback((event: DragEvent<HTMLDivElement>) => {
if (acceptFocusSidebarHostDrag(event)) return;
const draggedSessionId = event.dataTransfer.getData(FOCUS_SIDEBAR_SESSION_DRAG_TYPE) || focusSidebarDragSessionId;
if (!draggedSessionId) return;
const target = getFocusSidebarContainerDropTarget(event.currentTarget, event.clientY, draggedSessionId);
if (!target) return;
event.preventDefault();
event.dataTransfer.dropEffect = 'move';
setFocusSidebarHostDropActive(false);
setFocusSidebarDropIndicator(target);
}, [acceptFocusSidebarHostDrag, focusSidebarDragSessionId, getFocusSidebarContainerDropTarget]);
const appendHostFromFocusSidebarDrop = useCallback((event: DragEvent) => {
if (!onAppendHostToWorkspace) return false;
const handled = appendHostFromWorkspaceDrop({
types: event.dataTransfer.types,
getData: (type) => event.dataTransfer.getData(type),
activeSessionDragId: focusSidebarDragSessionId,
workspaceId: activeWorkspace.id,
onAppendHostToWorkspace,
});
if (!handled) return false;
event.preventDefault();
event.stopPropagation();
setFocusSidebarHostDropActive(false);
setFocusSidebarDropIndicator(null);
return true;
}, [activeWorkspace.id, focusSidebarDragSessionId, onAppendHostToWorkspace]);
const handleFocusSidebarHostDrop = useCallback((event: DragEvent<HTMLDivElement>) => {
appendHostFromFocusSidebarDrop(event);
}, [appendHostFromFocusSidebarDrop]);
const handleFocusSidebarContainerDrop = useCallback((event: DragEvent<HTMLDivElement>) => {
if (appendHostFromFocusSidebarDrop(event)) return;
const draggedSessionId = event.dataTransfer.getData(FOCUS_SIDEBAR_SESSION_DRAG_TYPE) || focusSidebarDragSessionId;
if (!draggedSessionId) return;
const target = focusSidebarDropIndicator
?? getFocusSidebarContainerDropTarget(event.currentTarget, event.clientY, draggedSessionId);
if (!target || target.sessionId === draggedSessionId) return;
event.preventDefault();
onReorderWorkspaceSessions?.(activeWorkspace.id, draggedSessionId, target.sessionId, target.position);
setFocusSidebarDragSessionId(null);
setFocusSidebarDropIndicator(null);
setFocusSidebarHostDropActive(false);
}, [
activeWorkspace.id,
appendHostFromFocusSidebarDrop,
focusSidebarDragSessionId,
focusSidebarDropIndicator,
getFocusSidebarContainerDropTarget,
onReorderWorkspaceSessions,
]);
const handleFocusSidebarDrop = useCallback((event: DragEvent, targetSessionId: string) => {
if (appendHostFromFocusSidebarDrop(event)) return;
const draggedSessionId = event.dataTransfer.getData(FOCUS_SIDEBAR_SESSION_DRAG_TYPE) || focusSidebarDragSessionId;
if (!draggedSessionId || draggedSessionId === targetSessionId) return;
event.preventDefault();
event.stopPropagation();
const rect = event.currentTarget.getBoundingClientRect();
const position = focusSidebarDropIndicator?.sessionId === targetSessionId
? focusSidebarDropIndicator.position
: event.clientY < rect.top + rect.height / 2 ? 'before' : 'after';
onReorderWorkspaceSessions?.(activeWorkspace.id, draggedSessionId, targetSessionId, position);
setFocusSidebarDragSessionId(null);
setFocusSidebarDropIndicator(null);
setFocusSidebarHostDropActive(false);
}, [
activeWorkspace.id,
appendHostFromFocusSidebarDrop,
focusSidebarDragSessionId,
focusSidebarDropIndicator,
onReorderWorkspaceSessions,
]);
const handleFocusSidebarDragEnd = useCallback(() => {
setFocusSidebarDragSessionId(null);
setFocusSidebarDropIndicator(null);
setFocusSidebarHostDropActive(false);
}, []);
const handleSelectSession = useCallback((sessionId: string) => {
onSetWorkspaceFocusedSession?.(activeWorkspace.id, sessionId);
}, [activeWorkspace.id, onSetWorkspaceFocusedSession]);
const handleLocalStartRename = useCallback((sessionId: string) => {
const session = sessions.find((s) => s.id === sessionId);
if (!session) return;
setSidebarRenameSessionId(sessionId);
setSidebarRenameValue(session.customName || session.hostLabel || '');
}, [sessions]);
const handleLocalSubmitRename = useCallback((name: string) => {
if (!sidebarRenameSessionId) return;
onSubmitSessionRename(sidebarRenameSessionId, name);
setSidebarRenameSessionId(null);
setSidebarRenameValue('');
}, [sidebarRenameSessionId, onSubmitSessionRename]);
const handleLocalCancelRename = useCallback(() => {
setSidebarRenameSessionId(null);
setSidebarRenameValue('');
}, []);
return (
<div
className={cn(
'flex-shrink-0 flex flex-col relative transition-[box-shadow,background-color]',
focusSidebarHostDropActive && 'ring-1 ring-inset',
)}
style={{
width: focusSidebarWidth,
backgroundColor: focusSidebarHostDropActive
? `color-mix(in srgb, ${theme.termFg} 8%, ${theme.termBg})`
: theme.termBg,
color: theme.termFg,
boxShadow: focusSidebarHostDropActive
? `inset 0 0 0 1px color-mix(in srgb, ${theme.termFg} 28%, transparent)`
: undefined,
['--terminal-workspace-sidebar-border' as string]: `1px solid ${theme.separator}`,
}}
data-section="terminal-workspace-sidebar"
data-focus-sidebar-drop-zone
data-host-drop-active={focusSidebarHostDropActive ? 'true' : 'false'}
onDragOver={handleFocusSidebarHostDragOver}
onDragLeave={handleFocusSidebarHostDragLeave}
onDrop={handleFocusSidebarHostDrop}
>
<div
className="absolute top-0 right-[-3px] h-full w-2 cursor-ew-resize z-30"
onMouseDown={handleFocusSidebarResizeStart}
/>
<div
className="h-9 flex items-center gap-1 px-1.5 flex-shrink-0"
style={{ borderBottom: `1px solid ${theme.separator}` }}
>
<div className="relative flex-1 min-w-0">
<Search
size={12}
className="absolute left-1 top-1/2 -translate-y-1/2 pointer-events-none"
style={{ color: theme.mutedFg }}
/>
<Input
value={focusSidebarSearch}
onChange={(event) => setFocusSidebarSearch(event.target.value)}
placeholder="Search terminals..."
className="h-7 pl-6 pr-1 text-xs bg-transparent border-0 shadow-none focus-visible:ring-0 focus-visible:ring-offset-0"
style={{ color: theme.termFg }}
/>
</div>
{onRequestAddToWorkspace && (
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-7 w-7 p-0 flex-shrink-0 hover:bg-transparent hover:text-inherit"
style={{ color: theme.mutedFg }}
onClick={() => onRequestAddToWorkspace(activeWorkspace.id)}
>
<Plus size={14} />
</Button>
</TooltipTrigger>
<TooltipContent>{t('terminal.layer.addTerminal')}</TooltipContent>
</Tooltip>
)}
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-7 w-7 p-0 flex-shrink-0 hover:bg-transparent hover:text-inherit"
style={{ color: theme.mutedFg }}
onClick={() => onToggleWorkspaceViewMode?.(activeWorkspace.id)}
>
<Columns2 size={14} />
</Button>
</TooltipTrigger>
<TooltipContent>{t('terminal.layer.switchToSplitView')}</TooltipContent>
</Tooltip>
</div>
<ScrollArea className="flex-1">
<div
className="p-2 space-y-1 min-h-full rounded-md"
onDragOver={handleFocusSidebarContainerDragOver}
onDrop={handleFocusSidebarContainerDrop}
>
{visibleSessions.map((session) => (
<WorkspaceFocusSessionRow
key={session.id}
session={session}
host={sessionHostsMap.get(session.id)}
isSelected={session.id === focusedSessionId}
isRenaming={sidebarRenameSessionId === session.id}
renameValue={sidebarRenameValue}
onStartRename={handleLocalStartRename}
onSubmitRename={handleLocalSubmitRename}
onCancelRename={handleLocalCancelRename}
onCloseSession={onCloseSession}
onCopySession={onCopySession}
onDuplicateSession={onDuplicateSession}
onCopySessionToNewWindow={onCopySessionToNewWindow}
onDetachSessionFromWorkspace={onDetachSessionFromWorkspace}
isDragging={focusSidebarDragSessionId === session.id}
dropPosition={
focusSidebarDropIndicator?.sessionId === session.id
? focusSidebarDropIndicator.position
: null
}
theme={theme}
onSelect={handleSelectSession}
onDragStart={handleFocusSidebarDragStart}
onDragOver={handleFocusSidebarDragOver}
onHostDragLeave={handleFocusSidebarHostDragLeave}
onDrop={handleFocusSidebarDrop}
onDragEnd={handleFocusSidebarDragEnd}
dynamicTabTitleMode={dynamicTabTitleMode}
t={t}
/>
))}
</div>
</ScrollArea>
</div>
);
};
function terminalFocusSidebarPropsEqual(
prev: TerminalFocusSidebarProps,
next: TerminalFocusSidebarProps,
): boolean {
if (prev.focusedSessionId !== next.focusedSessionId) return false;
if (prev.onSubmitSessionRename !== next.onSubmitSessionRename) return false;
if (prev.onCloseSession !== next.onCloseSession) return false;
if (prev.onCopySession !== next.onCopySession) return false;
if (prev.onDuplicateSession !== next.onDuplicateSession) return false;
if (prev.onCopySessionToNewWindow !== next.onCopySessionToNewWindow) return false;
if (prev.onDetachSessionFromWorkspace !== next.onDetachSessionFromWorkspace) return false;
if (prev.resolvedPreviewTheme !== next.resolvedPreviewTheme) return false;
if (prev.sessionHostsMap !== next.sessionHostsMap) return false;
if (prev.sessions !== next.sessions) return false;
if (prev.dynamicTabTitleMode !== next.dynamicTabTitleMode) return false;
if (prev.t !== next.t) return false;
if (prev.onReorderWorkspaceSessions !== next.onReorderWorkspaceSessions) return false;
if (prev.onRequestAddToWorkspace !== next.onRequestAddToWorkspace) return false;
if (prev.onAppendHostToWorkspace !== next.onAppendHostToWorkspace) return false;
if (prev.onSetWorkspaceFocusedSession !== next.onSetWorkspaceFocusedSession) return false;
if (prev.onToggleWorkspaceViewMode !== next.onToggleWorkspaceViewMode) return false;
const prevWs = prev.activeWorkspace;
const nextWs = next.activeWorkspace;
return (
prevWs.id === nextWs.id
&& prevWs.viewMode === nextWs.viewMode
&& prevWs.root === nextWs.root
&& prevWs.focusSessionOrder === nextWs.focusSessionOrder
);
}
export const TerminalFocusSidebar = memo(TerminalFocusSidebarInner, terminalFocusSidebarPropsEqual);
TerminalFocusSidebar.displayName = 'TerminalFocusSidebar';

View File

@@ -0,0 +1,253 @@
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import test from 'node:test';
import type { Host } from '../../types';
const sidebarSource = readFileSync(new URL('./TerminalHostTreeSidebar.tsx', import.meta.url), 'utf8');
const storage = new Map<string, string>();
Object.defineProperty(globalThis, 'localStorage', {
configurable: true,
value: {
getItem: (key: string) => storage.get(key) ?? null,
setItem: (key: string, value: string) => storage.set(key, value),
removeItem: (key: string) => storage.delete(key),
},
});
const {
applyTerminalHostTreeHostRename,
shouldShowTerminalHostHoverCard,
getTerminalHostTreeHiddenSurfaceShellWidth,
getTerminalHostTreeInitialLayoutWidth,
getTerminalHostTreeLayoutTargetWidth,
getTerminalHostTreeMeasuredLayoutWidth,
resolveTerminalHostTreeDragCapabilities,
getTerminalHostTreeSidebarPanelStyle,
getTerminalHostTreeSidebarShellStyle,
isTerminalHostTreeSidebarVisible,
} = await import('./TerminalHostTreeSidebar.tsx');
const { TERMINAL_HOST_TREE_WIDTH_TRANSITION } = await import('../../application/state/terminalHostTreeAnimation.ts');
const host: Host = {
id: 'host-1',
label: 'Ubuntu',
hostname: '10.2.0.124',
username: 'root',
port: 22,
protocol: 'ssh',
tags: [],
os: 'linux',
createdAt: 1,
};
test('host tree sidebar is visually hidden when disabled even if it remains open', () => {
assert.equal(isTerminalHostTreeSidebarVisible(true, false), false);
});
test('host tree sidebar visibility still follows open state when enabled', () => {
assert.equal(isTerminalHostTreeSidebarVisible(true, true), true);
assert.equal(isTerminalHostTreeSidebarVisible(false, true), false);
});
test('host tree sidebar stays collapsed behind root pages', () => {
assert.equal(isTerminalHostTreeSidebarVisible(true, true, false), false);
});
test('host tree layout target follows visible surface state', () => {
assert.equal(getTerminalHostTreeLayoutTargetWidth(true, 240), 240);
assert.equal(getTerminalHostTreeLayoutTargetWidth(false, 240), 0);
});
test('host tree hidden surface shell keeps the open width for return navigation', () => {
assert.equal(getTerminalHostTreeHiddenSurfaceShellWidth(true, true, 240), 240);
assert.equal(getTerminalHostTreeHiddenSurfaceShellWidth(false, true, 240), 0);
assert.equal(getTerminalHostTreeHiddenSurfaceShellWidth(true, false, 240), 0);
});
test('host tree layout starts collapsed so first mount can animate open', () => {
assert.equal(getTerminalHostTreeInitialLayoutWidth(), 0);
});
test('host tree layout sync can sample the current shell width before targeting', () => {
assert.equal(getTerminalHostTreeMeasuredLayoutWidth({
getBoundingClientRect: () => ({ width: 84 }),
} as unknown as HTMLElement, 240), 84);
assert.equal(getTerminalHostTreeMeasuredLayoutWidth({
getBoundingClientRect: () => ({ width: -12 }),
} as unknown as HTMLElement, 240), 0);
assert.equal(getTerminalHostTreeMeasuredLayoutWidth(null, 240), 240);
});
test('host tree layout width follows the animated shell via ResizeObserver', () => {
const source = readFileSync(new URL('./TerminalHostTreeSidebar.tsx', import.meta.url), 'utf8');
assert.match(source, /new ResizeObserver/);
assert.match(source, /syncLayoutWidthFromShell/);
assert.doesNotMatch(source, /performance\.now\(\)/);
});
test('host tree keeps shell width while hidden behind root pages', () => {
const source = readFileSync(new URL('./TerminalHostTreeSidebar.tsx', import.meta.url), 'utf8');
assert.match(source, /isResizing \|\| !surfaceVisible/);
assert.match(source, /const hiddenSurfaceShellWidth = getTerminalHostTreeHiddenSurfaceShellWidth/);
assert.match(source, /if \(!surfaceVisible\) \{\s*setShellWidth\(hiddenSurfaceShellWidth\);\s*terminalHostTreeStore\.setLayoutWidth\(0\);/);
assert.doesNotMatch(source, /if \(!surfaceVisible\) \{\s*setShellWidth\(0\);/);
});
test('host tree sidebar memo tracks surface visibility and theme changes', () => {
const source = readFileSync(new URL('./TerminalHostTreeSidebar.tsx', import.meta.url), 'utf8');
assert.match(source, /prev\.surfaceVisible === next\.surfaceVisible/);
assert.match(source, /themeFingerprint\(prev\.resolvedPreviewTheme\) === themeFingerprint\(next\.resolvedPreviewTheme\)/);
});
test('host tree sidebar wires app-level host creation and editing actions', () => {
assert.match(sidebarSource, /onNewHost\?: \(defaultGroup\?: string\) => void/);
assert.match(sidebarSource, /onEditHost\?: \(host: Host\) => void/);
assert.match(sidebarSource, /onEditHost=\{onEditHost\}/);
assert.match(sidebarSource, /onNewHost=\{\(groupPath\) => onNewHost\?\.\(groupPath\)\}/);
});
test('host tree sidebar exposes new host only from unused root space', () => {
assert.match(sidebarSource, /data-section="terminal-host-tree-root-context"/);
assert.match(sidebarSource, /closest\('\[data-row-type\]'\)/);
assert.match(sidebarSource, /<ContextMenuItem onClick=\{\(\) => onNewHost\?\.\(\)\}>/);
});
test('host tree sidebar clips the panel instead of fading it out while closing', () => {
const theme = {
termBg: '#000000',
termFg: '#ffffff',
mutedFg: '#999999',
separator: '#333333',
rowHoverBg: '#111111',
rowActiveBg: '#222222',
rowDropBg: '#444444',
folderFg: '#cccccc',
};
assert.deepEqual(getTerminalHostTreeSidebarShellStyle(false, 0, TERMINAL_HOST_TREE_WIDTH_TRANSITION), {
width: 0,
transition: TERMINAL_HOST_TREE_WIDTH_TRANSITION,
pointerEvents: 'none',
});
assert.equal(getTerminalHostTreeSidebarPanelStyle({
isVisible: false,
displayWidth: 240,
panelTransition: 'border-color 220ms ease-out',
theme,
}).width, 240);
assert.equal(getTerminalHostTreeSidebarPanelStyle({
isVisible: false,
displayWidth: 240,
panelTransition: 'border-color 220ms ease-out',
theme,
}).opacity, 1);
});
test('host tree sidebar colors can be overridden by immediate preview styles', () => {
const theme = {
termBg: 'var(--terminal-host-tree-bg, #000000)',
termFg: 'var(--terminal-host-tree-fg, #ffffff)',
mutedFg: 'var(--terminal-host-tree-muted, #999999)',
separator: 'var(--terminal-host-tree-separator, #333333)',
rowHoverBg: 'var(--terminal-host-tree-hover-bg, #111111)',
rowActiveBg: 'var(--terminal-host-tree-active-bg, #222222)',
rowDropBg: 'var(--terminal-host-tree-drop-bg, #444444)',
folderFg: 'var(--terminal-host-tree-folder-fg, #cccccc)',
};
const style = getTerminalHostTreeSidebarPanelStyle({
isVisible: true,
displayWidth: 240,
panelTransition: 'border-color 220ms ease-out',
theme,
});
assert.equal(style.backgroundColor, theme.termBg);
assert.equal(style.color, theme.termFg);
assert.equal(style.borderRight, `1px solid ${theme.separator}`);
});
test('host tree host inline rename trims and updates the matching host label', () => {
const result = applyTerminalHostTreeHostRename([host], 'host-1', ' web-01 ');
assert.equal(result.changed, true);
assert.equal(result.hosts[0].label, 'web-01');
});
test('host tree host inline rename rejects empty names without changing hosts', () => {
const hosts = [host];
const result = applyTerminalHostTreeHostRename(hosts, 'host-1', ' ');
assert.equal(result.changed, false);
assert.equal(result.reason, 'required');
assert.equal(result.hosts, hosts);
});
test('filtered host rows can drag outward without enabling filtered tree reorder', () => {
assert.deepEqual(resolveTerminalHostTreeDragCapabilities({
kind: 'host',
canReorder: false,
isInlineEditing: false,
}), {
draggable: true,
canAcceptDrop: false,
});
assert.deepEqual(resolveTerminalHostTreeDragCapabilities({
kind: 'group',
canReorder: false,
isInlineEditing: false,
}), {
draggable: false,
canAcceptDrop: false,
});
});
test('host tree hover card is hidden while the same host is inline editing', () => {
assert.equal(shouldShowTerminalHostHoverCard('host-1', null), true);
assert.equal(shouldShowTerminalHostHoverCard('host-1', 'host-2'), true);
assert.equal(shouldShowTerminalHostHoverCard('host-1', 'host-1'), false);
});
test('host tree hover card renders markdown notes and keeps host details out of the header subtitle', () => {
const source = readFileSync(new URL('./TerminalHostTreeSidebar.tsx', import.meta.url), 'utf8');
assert.match(source, /<LazyMessageResponse/);
assert.match(source, /size="sm"/);
assert.match(source, /items-center gap-2/);
assert.match(source, /flex min-h-6 min-w-0 items-center/);
assert.match(source, /truncate text-\[15px\] font-semibold leading-5/);
assert.match(source, /details\.host/);
assert.doesNotMatch(source, /text-muted-foreground">\{host\.hostname\}/);
assert.match(source, /host-tree-notes-scroll/);
assert.match(source, /overflow-y-auto/);
assert.doesNotMatch(source, /details\.lastConnected/);
});
test('host tree row icons, labels, and protocol badges share centered line boxes', () => {
const source = readFileSync(new URL('./TerminalHostTreeSidebar.tsx', import.meta.url), 'utf8');
assert.match(source, /flex h-5 shrink-0 items-center justify-center">\s*<DistroAvatar/);
assert.match(source, /flex min-w-0 flex-1 items-center truncate leading-5/);
assert.match(source, /flex shrink-0 items-center text-\[10px\] leading-4 uppercase/);
assert.match(source, /flex h-5 w-4 shrink-0 items-center/);
assert.match(source, /flex h-5 shrink-0 items-center">\s*\{isExpanded/);
assert.match(source, /flex min-w-0 flex-1 items-center truncate leading-5">\{node\.name\}/);
});
test('filtered host rows can still start host-id drag for focus-sidebar append', () => {
const source = readFileSync(new URL('./TerminalHostTreeSidebar.tsx', import.meta.url), 'utf8');
const hostRowStart = source.indexOf('data-row-type="host"');
const groupRowStart = source.indexOf('data-row-type="group"');
assert.ok(hostRowStart >= 0 && groupRowStart > hostRowStart);
const hostRowBlock = source.slice(hostRowStart, groupRowStart);
assert.match(hostRowBlock, /draggable=\{dragCapabilities\.draggable\}/);
assert.match(hostRowBlock, /effectAllowed = canReorder \? 'copyMove' : 'copy'/);
assert.match(hostRowBlock, /if \(!dragCapabilities\.draggable\) return;/);
assert.match(source, /const canReorder = Boolean\(menuActions\) && !searchActive && !tagsActive;/);
assert.match(source, /canAcceptDrop: input\.canReorder/);
});

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,133 @@
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import test from 'node:test';
import React from 'react';
import { renderToStaticMarkup } from 'react-dom/server';
import { I18nProvider } from '../../application/i18n/I18nProvider';
import {
HOST_TREE_TOOLBAR_LAYOUT_DEFAULTS,
TerminalHostTreeToolbar,
TERMINAL_HOST_TREE_TOOLBAR_MIN_REQUIRED_WIDTH,
} from './TerminalHostTreeToolbar';
import { TooltipProvider } from '../ui/tooltip';
const toolbarSource = readFileSync(new URL('./TerminalHostTreeToolbar.tsx', import.meta.url), 'utf8');
const menuSource = readFileSync(new URL('../host/HostTreeContextMenus.tsx', import.meta.url), 'utf8');
test('host tree toolbar keeps the close button outside the compact action row', () => {
const source = readFileSync(new URL('./TerminalHostTreeToolbar.tsx', import.meta.url), 'utf8');
// Actions use ToolbarCustomizeContextMenu's dataSection prop → data-section at runtime.
assert.match(source, /dataSection="terminal-host-tree-toolbar-actions"/);
assert.match(source, /data-section="terminal-host-tree-toolbar-close"/);
assert.match(source, /data-section="terminal-host-tree-toolbar"/);
assert.match(source, /backgroundColor: theme\.termBg/);
assert.doesNotMatch(source, /terminal-host-tree-toolbar-actions-fade/);
});
test('host tree toolbar uses shared show/collapse/hide layout like terminal toolbars', () => {
assert.match(toolbarSource, /useToolbarItemLayout/);
assert.match(toolbarSource, /ToolbarCustomizeContextMenu/);
assert.match(toolbarSource, /ToolbarOverflowMenu/);
assert.match(toolbarSource, /STORAGE_KEY_TERMINAL_HOST_TREE_TOOLBAR_LAYOUT/);
assert.deepEqual(HOST_TREE_TOOLBAR_LAYOUT_DEFAULTS.order, [
'newHost',
'search',
'tags',
'localShell',
'newGroup',
'expandAll',
'collapseAll',
]);
assert.equal(HOST_TREE_TOOLBAR_LAYOUT_DEFAULTS.placement?.localShell, 'show');
assert.equal(HOST_TREE_TOOLBAR_LAYOUT_DEFAULTS.placement?.newGroup, 'collapse');
assert.equal(HOST_TREE_TOOLBAR_LAYOUT_DEFAULTS.placement?.expandAll, 'collapse');
assert.equal(HOST_TREE_TOOLBAR_LAYOUT_DEFAULTS.placement?.collapseAll, 'collapse');
});
test('host tree toolbar keeps every default primary action reachable at min width', () => {
assert.ok(TERMINAL_HOST_TREE_TOOLBAR_MIN_REQUIRED_WIDTH <= 176);
assert.match(toolbarSource, /aria-label=\{itemLabels\.localShell\}/);
assert.match(toolbarSource, /onClick=\{onCreateLocalTerminal\}/);
assert.match(toolbarSource, /<Terminal size=\{14\} \/>/);
assert.doesNotMatch(toolbarSource, /import \{[^}]*TerminalSquare[^}]*\} from 'lucide-react'/);
assert.doesNotMatch(toolbarSource, /<TerminalSquare\b/);
assert.match(toolbarSource, /data-section="terminal-host-tree-local-shell"/);
// Local shell is borderless (glyph + button chrome).
assert.match(toolbarSource, /localShellButtonClass/);
assert.match(toolbarSource, /rounded-none p-0 shadow-none border-none/);
});
test('host tree toolbar exposes host creation alongside the context menus', () => {
assert.match(toolbarSource, /onNewHost: \(\) => void/);
assert.match(toolbarSource, /disabled=\{!canNewHost\}/);
assert.match(toolbarSource, /onClick=\{onNewHost\}/);
assert.match(toolbarSource, /<Plus size=\{14\} \/>/);
assert.match(toolbarSource, /terminal\.layer\.hostTree\.newHost/);
});
test('host tree toolbar gives every default icon-only control an accessible name', () => {
const markup = renderToStaticMarkup(
React.createElement(
I18nProvider,
{ locale: 'en' },
React.createElement(
TooltipProvider,
null,
React.createElement(TerminalHostTreeToolbar, {
theme: {
termBg: '#000',
termFg: '#fff',
mutedFg: '#aaa',
separator: '#333',
rowHoverBg: '#222',
},
expandedPanel: null,
onExpandedPanelChange: () => undefined,
search: '',
onSearchChange: () => undefined,
allTags: [],
selectedTags: [],
onSelectedTagsChange: () => undefined,
onNewHost: () => undefined,
onNewRootGroup: () => undefined,
onCreateLocalTerminal: () => undefined,
onExpandAll: () => undefined,
onCollapseAll: () => undefined,
onCollapse: () => undefined,
}),
),
),
);
assert.match(markup, /aria-label="New host"/);
assert.match(markup, /aria-label="Search"/);
assert.match(markup, /aria-label="Filter by tags"/);
assert.match(markup, /aria-label="Local shell"/);
assert.match(markup, /aria-label="More actions"/);
assert.match(markup, /aria-label="Collapse host list"/);
assert.match(markup, /data-section="terminal-host-tree-local-shell"/);
});
test('shared host tree menus expose optional full edit and group host creation actions', () => {
assert.match(menuSource, /onEditHost\?: \(host: Host\) => void/);
assert.match(menuSource, /onNewHost\?: \(groupPath: string\) => void/);
assert.match(menuSource, /terminal\.layer\.hostTree\.editHost/);
assert.match(menuSource, /terminal\.layer\.hostTree\.newHostInGroup/);
});
test('host tree sidebar wires expand/collapse and host creation availability', () => {
const source = readFileSync(new URL('./TerminalHostTreeSidebar.tsx', import.meta.url), 'utf8');
assert.match(source, /canExpandCollapse=\{canExpandCollapse\}/);
assert.match(source, /canNewHost=\{Boolean\(onNewHost\)\}/);
assert.doesNotMatch(source, /shouldCompactTerminalHostTreeToolbar/);
});
test('top tabs do not show a local-terminal utility button', () => {
const topTabsSource = readFileSync(new URL('../TopTabs.tsx', import.meta.url), 'utf8');
assert.doesNotMatch(topTabsSource, /top-tabs-new-local-terminal/);
assert.doesNotMatch(topTabsSource, /showTopTabsLocalTerminal/);
assert.doesNotMatch(topTabsSource, /onCreateLocalTerminal/);
});

View File

@@ -0,0 +1,620 @@
import {
Check,
Expand,
FolderPlus,
Minimize2,
Plus,
Search,
Tag,
Terminal,
X,
} from 'lucide-react';
import React, { useCallback, useEffect, useMemo, useRef } from 'react';
import { useI18n } from '../../application/i18n/I18nProvider';
import { useToolbarItemLayout } from '../../application/state/useToolbarItemLayout';
import type { ToolbarItemLayoutDefaults } from '../../domain/toolbarItemLayout';
import { STORAGE_KEY_TERMINAL_HOST_TREE_TOOLBAR_LAYOUT } from '../../infrastructure/config/storageKeys';
import { cn } from '../../lib/utils';
import { Button } from '../ui/button';
import { Input } from '../ui/input';
import {
ToolbarCustomizeContextMenu,
ToolbarOverflowMenu,
} from '../ui/toolbar-item-layout';
import { Tooltip, TooltipContent, TooltipTrigger } from '../ui/tooltip';
export type HostTreeToolbarPanel = 'search' | 'tags' | null;
export const HOST_TREE_TOOLBAR_ITEM_IDS = [
'newHost',
'search',
'tags',
'localShell',
'newGroup',
'expandAll',
'collapseAll',
] as const;
export type HostTreeToolbarItemId = (typeof HOST_TREE_TOOLBAR_ITEM_IDS)[number];
/**
* Defaults match the previous fixed layout after #2625:
* primary new host / search / tags / local shell; group + expand/collapse in ⋮.
*/
export const HOST_TREE_TOOLBAR_LAYOUT_DEFAULTS: ToolbarItemLayoutDefaults = {
order: [...HOST_TREE_TOOLBAR_ITEM_IDS],
placement: {
newHost: 'show',
search: 'show',
tags: 'show',
localShell: 'show',
newGroup: 'collapse',
expandAll: 'collapse',
collapseAll: 'collapse',
},
};
type ToolbarTheme = {
termBg: string;
termFg: string;
mutedFg: string;
separator: string;
rowHoverBg: string;
};
interface TerminalHostTreeToolbarProps {
theme: ToolbarTheme;
expandedPanel: HostTreeToolbarPanel;
onExpandedPanelChange: (panel: HostTreeToolbarPanel) => void;
search: string;
onSearchChange: (value: string) => void;
allTags: string[];
selectedTags: string[];
onSelectedTagsChange: (tags: string[]) => void;
onNewHost: () => void;
canNewHost?: boolean;
onNewRootGroup: () => void;
canNewGroup?: boolean;
onCreateLocalTerminal: () => void;
canCreateLocalTerminal?: boolean;
onExpandAll: () => void;
onCollapseAll: () => void;
canExpandCollapse?: boolean;
onCollapse: () => void;
}
const iconButtonClass =
'netcatty-tab h-6 w-6 shrink-0 rounded-md p-0 shadow-none border-none hover:bg-transparent';
/** Local shell uses the borderless Terminal glyph (not TerminalSquare). */
const localShellButtonClass =
'netcatty-tab h-6 w-6 shrink-0 rounded-none p-0 shadow-none border-none bg-transparent hover:bg-transparent';
const overflowMenuItemClass =
'flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left text-xs hover:bg-muted disabled:cursor-not-allowed disabled:opacity-50';
/**
* Primary defaults (new host, search, tags, local shell, more) + close must fit
* the sidebar min width. Users can hide/collapse items via right-click customize.
*/
export const TERMINAL_HOST_TREE_TOOLBAR_MIN_REQUIRED_WIDTH = 176;
export const TerminalHostTreeToolbar: React.FC<TerminalHostTreeToolbarProps> = ({
theme,
expandedPanel,
onExpandedPanelChange,
search,
onSearchChange,
allTags,
selectedTags,
onSelectedTagsChange,
onNewHost,
canNewHost = true,
onNewRootGroup,
canNewGroup = true,
onCreateLocalTerminal,
canCreateLocalTerminal = true,
onExpandAll,
onCollapseAll,
canExpandCollapse = true,
onCollapse,
}) => {
const { t } = useI18n();
const searchInputRef = useRef<HTMLInputElement>(null);
const toolbarLayout = useToolbarItemLayout(
STORAGE_KEY_TERMINAL_HOST_TREE_TOOLBAR_LAYOUT,
HOST_TREE_TOOLBAR_LAYOUT_DEFAULTS,
);
const togglePanel = (panel: Exclude<HostTreeToolbarPanel, null>) => {
onExpandedPanelChange(expandedPanel === panel ? null : panel);
};
const hasTagFilters = selectedTags.length > 0;
const hasSearch = search.trim().length > 0;
useEffect(() => {
if (expandedPanel !== 'search') return;
const frame = requestAnimationFrame(() => {
searchInputRef.current?.focus();
});
return () => cancelAnimationFrame(frame);
}, [expandedPanel]);
const toggleTag = (tag: string) => {
if (selectedTags.includes(tag)) {
onSelectedTagsChange(selectedTags.filter((item) => item !== tag));
} else {
onSelectedTagsChange([...selectedTags, tag]);
}
};
const availableIds = useMemo(() => [...HOST_TREE_TOOLBAR_ITEM_IDS], []);
const itemLabels = useMemo(
(): Record<HostTreeToolbarItemId, string> => ({
newHost: t('terminal.layer.hostTree.newHost'),
search: t('terminal.layer.hostTree.searchButton'),
tags: t('terminal.layer.hostTree.tagsButton'),
localShell: t('terminal.layer.hostTree.localShell'),
newGroup: t('terminal.layer.hostTree.newGroup'),
expandAll: t('vault.tree.expandAll'),
collapseAll: t('vault.tree.collapseAll'),
}),
[t],
);
const itemIcons = useMemo(
(): Record<HostTreeToolbarItemId, React.ReactNode> => ({
newHost: <Plus size={14} />,
search: <Search size={14} />,
tags: <Tag size={14} />,
localShell: <Terminal size={14} />,
newGroup: <FolderPlus size={14} />,
expandAll: <Expand size={14} />,
collapseAll: <Minimize2 size={14} />,
}),
[],
);
const customizeItems = useMemo(
() =>
toolbarLayout.layout.order
.filter((id): id is HostTreeToolbarItemId =>
(availableIds as string[]).includes(id),
)
.map((id) => ({
id,
label: itemLabels[id],
icon: itemIcons[id],
})),
[availableIds, itemIcons, itemLabels, toolbarLayout.layout.order],
);
const setPlacement = useCallback(
(id: string, placement: 'show' | 'collapse' | 'hide') => {
toolbarLayout.setPlacement(id, placement, availableIds);
},
[availableIds, toolbarLayout],
);
const moveItem = useCallback(
(id: string, direction: 'earlier' | 'later') => {
toolbarLayout.move(id, direction, availableIds);
},
[availableIds, toolbarLayout],
);
const { shown, collapsed } = toolbarLayout.partition(availableIds);
const renderInline = (id: string): React.ReactNode => {
switch (id as HostTreeToolbarItemId) {
case 'newHost':
return (
<Tooltip key={id}>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className={iconButtonClass}
style={{ color: theme.mutedFg }}
disabled={!canNewHost}
onClick={onNewHost}
aria-label={itemLabels.newHost}
>
<Plus size={14} />
</Button>
</TooltipTrigger>
<TooltipContent side="bottom">{itemLabels.newHost}</TooltipContent>
</Tooltip>
);
case 'search':
return (
<Tooltip key={id}>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className={iconButtonClass}
style={{
color: expandedPanel === 'search' || hasSearch ? theme.termFg : theme.mutedFg,
}}
onClick={() => togglePanel('search')}
aria-label={itemLabels.search}
>
<Search size={14} />
</Button>
</TooltipTrigger>
<TooltipContent side="bottom">{itemLabels.search}</TooltipContent>
</Tooltip>
);
case 'tags':
return (
<Tooltip key={id}>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className={iconButtonClass}
style={{
color: expandedPanel === 'tags' || hasTagFilters ? theme.termFg : theme.mutedFg,
}}
onClick={() => togglePanel('tags')}
aria-label={itemLabels.tags}
>
<Tag size={14} />
</Button>
</TooltipTrigger>
<TooltipContent side="bottom">{itemLabels.tags}</TooltipContent>
</Tooltip>
);
case 'localShell':
return (
<Tooltip key={id}>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className={localShellButtonClass}
style={{ color: theme.mutedFg }}
disabled={!canCreateLocalTerminal}
onClick={onCreateLocalTerminal}
aria-label={itemLabels.localShell}
data-section="terminal-host-tree-local-shell"
>
<Terminal size={14} />
</Button>
</TooltipTrigger>
<TooltipContent side="bottom">{itemLabels.localShell}</TooltipContent>
</Tooltip>
);
case 'newGroup':
return (
<Tooltip key={id}>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className={iconButtonClass}
style={{ color: theme.mutedFg }}
disabled={!canNewGroup}
onClick={onNewRootGroup}
aria-label={itemLabels.newGroup}
>
<FolderPlus size={14} />
</Button>
</TooltipTrigger>
<TooltipContent side="bottom">{itemLabels.newGroup}</TooltipContent>
</Tooltip>
);
case 'expandAll':
return (
<Tooltip key={id}>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className={iconButtonClass}
style={{ color: theme.mutedFg }}
disabled={!canExpandCollapse}
onClick={onExpandAll}
aria-label={itemLabels.expandAll}
>
<Expand size={14} />
</Button>
</TooltipTrigger>
<TooltipContent side="bottom">{itemLabels.expandAll}</TooltipContent>
</Tooltip>
);
case 'collapseAll':
return (
<Tooltip key={id}>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className={iconButtonClass}
style={{ color: theme.mutedFg }}
disabled={!canExpandCollapse}
onClick={onCollapseAll}
aria-label={itemLabels.collapseAll}
>
<Minimize2 size={14} />
</Button>
</TooltipTrigger>
<TooltipContent side="bottom">{itemLabels.collapseAll}</TooltipContent>
</Tooltip>
);
default:
return null;
}
};
const renderCollapsed = (id: string): React.ReactNode => {
switch (id as HostTreeToolbarItemId) {
case 'newHost':
return (
<button
key={id}
type="button"
className={overflowMenuItemClass}
disabled={!canNewHost}
onClick={onNewHost}
>
<Plus size={14} />
{itemLabels.newHost}
</button>
);
case 'search':
return (
<button
key={id}
type="button"
className={overflowMenuItemClass}
onClick={() => togglePanel('search')}
>
<Search size={14} />
{itemLabels.search}
</button>
);
case 'tags':
return (
<button
key={id}
type="button"
className={overflowMenuItemClass}
onClick={() => togglePanel('tags')}
>
<Tag size={14} />
{itemLabels.tags}
</button>
);
case 'localShell':
return (
<button
key={id}
type="button"
className={overflowMenuItemClass}
disabled={!canCreateLocalTerminal}
onClick={onCreateLocalTerminal}
>
<Terminal size={14} />
{itemLabels.localShell}
</button>
);
case 'newGroup':
return (
<button
key={id}
type="button"
className={overflowMenuItemClass}
disabled={!canNewGroup}
onClick={onNewRootGroup}
>
<FolderPlus size={14} />
{itemLabels.newGroup}
</button>
);
case 'expandAll':
return (
<button
key={id}
type="button"
className={overflowMenuItemClass}
disabled={!canExpandCollapse}
onClick={onExpandAll}
>
<Expand size={14} />
{itemLabels.expandAll}
</button>
);
case 'collapseAll':
return (
<button
key={id}
type="button"
className={overflowMenuItemClass}
disabled={!canExpandCollapse}
onClick={onCollapseAll}
>
<Minimize2 size={14} />
{itemLabels.collapseAll}
</button>
);
default:
return null;
}
};
const overflowNodes = collapsed.map(renderCollapsed).filter(Boolean);
return (
<div className="flex-shrink-0">
<div
className="flex h-9 shrink-0 min-w-0 items-center gap-0.5 px-1.5 py-1"
style={{
backgroundColor: theme.termBg,
borderBottom: `1px solid ${theme.separator}`,
}}
data-section="terminal-host-tree-toolbar"
>
<ToolbarCustomizeContextMenu
items={customizeItems}
placementOf={(id) => toolbarLayout.layout.placement[id] ?? 'show'}
onSetPlacement={setPlacement}
onMove={moveItem}
onReset={toolbarLayout.reset}
t={t}
className="relative flex min-w-0 flex-1 items-center overflow-hidden"
dataSection="terminal-host-tree-toolbar-actions"
>
<div className="flex items-center gap-1" style={{ color: theme.mutedFg }}>
{shown.map(renderInline)}
<ToolbarOverflowMenu
hasItems={overflowNodes.length > 0}
label={t('terminal.toolbar.more')}
orientation="horizontal"
buttonClassName={iconButtonClass}
contentClassName="w-44"
align="start"
>
<div className="flex flex-col">{overflowNodes}</div>
</ToolbarOverflowMenu>
</div>
</ToolbarCustomizeContextMenu>
<div
className="flex shrink-0 items-center"
data-section="terminal-host-tree-toolbar-close"
>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className={cn(iconButtonClass, 'mr-0.5')}
style={{ color: theme.mutedFg }}
onClick={onCollapse}
aria-label={t('terminal.layer.hostTree.collapse')}
>
<X size={15} />
</Button>
</TooltipTrigger>
<TooltipContent side="bottom">{t('terminal.layer.hostTree.collapse')}</TooltipContent>
</Tooltip>
</div>
</div>
<div
className={cn(
'overflow-hidden transition-[max-height,opacity] duration-200 ease-out',
expandedPanel === 'search' ? 'max-h-9 opacity-100' : 'max-h-0 opacity-0',
)}
style={{
backgroundColor: theme.termBg,
borderBottom: expandedPanel === 'search' ? `1px solid ${theme.separator}` : undefined,
}}
>
<div className="h-9 flex items-center gap-0.5 px-1.5" style={{ backgroundColor: theme.termBg }}>
<div className="relative flex-1 min-w-0">
<Search
size={12}
className="absolute left-1 top-1/2 -translate-y-1/2 pointer-events-none"
style={{ color: theme.mutedFg }}
/>
<Input
ref={searchInputRef}
value={search}
onChange={(event) => onSearchChange(event.target.value)}
placeholder={t('terminal.layer.hostTree.search')}
className="h-7 pl-6 pr-1 text-xs bg-transparent border-0 shadow-none focus-visible:ring-0 focus-visible:ring-offset-0"
style={{ color: theme.termFg }}
/>
</div>
{hasSearch && (
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className={iconButtonClass}
style={{ color: theme.mutedFg }}
onClick={() => {
onSearchChange('');
searchInputRef.current?.focus();
}}
aria-label={t('common.clear')}
>
<X size={14} />
</Button>
</TooltipTrigger>
<TooltipContent side="bottom">{t('common.clear')}</TooltipContent>
</Tooltip>
)}
</div>
</div>
<div
className={cn(
'overflow-hidden transition-[max-height,opacity] duration-200 ease-out',
expandedPanel === 'tags' ? 'max-h-40 opacity-100' : 'max-h-0 opacity-0',
)}
style={{
backgroundColor: theme.termBg,
borderBottom: expandedPanel === 'tags' ? `1px solid ${theme.separator}` : undefined,
}}
>
<div
className="max-h-40 overflow-y-auto overflow-x-hidden py-1 [scrollbar-width:none] [-ms-overflow-style:none] [&::-webkit-scrollbar]:hidden"
style={{ backgroundColor: theme.termBg }}
>
{allTags.length === 0 ? (
<div className="px-3 py-3 text-center text-xs" style={{ color: theme.mutedFg }}>
{t('terminal.layer.hostTree.tagsEmpty')}
</div>
) : (
<>
{hasTagFilters && (
<button
type="button"
className="w-full px-3 py-1.5 text-left text-xs"
style={{ color: theme.mutedFg }}
onClick={() => onSelectedTagsChange([])}
>
{t('terminal.layer.hostTree.clearTags')}
</button>
)}
{allTags.map((tag) => {
const isSelected = selectedTags.includes(tag);
return (
<button
key={tag}
type="button"
className="flex w-full min-w-0 items-center gap-2 px-3 py-1.5 text-left text-xs"
style={{ color: theme.termFg }}
onMouseEnter={(event) => {
event.currentTarget.style.backgroundColor = theme.rowHoverBg;
}}
onMouseLeave={(event) => {
event.currentTarget.style.backgroundColor = '';
}}
onClick={() => toggleTag(tag)}
>
<span
className={cn(
'h-2.5 w-2.5 shrink-0 rounded-full border',
isSelected ? 'bg-current border-current' : 'border-current opacity-50',
)}
style={{ color: theme.termFg }}
/>
<span className="min-w-0 flex-1 truncate">{tag}</span>
{isSelected && <Check size={12} className="shrink-0" />}
</button>
);
})}
</>
)}
</div>
</div>
</div>
);
};

View File

@@ -0,0 +1,40 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import React, { memo } from 'react';
import { TerminalFocusSidebar } from './TerminalFocusSidebar';
import { terminalLayerFocusSidebarPropsEqual } from './terminalLayerViewMemo';
type FocusSidebarContext = Record<string, any>;
function TerminalLayerFocusSidebarSectionInner({ ctx }: { ctx: FocusSidebarContext }) {
if (!ctx.isFocusMode || !ctx.activeWorkspace) return null;
return (
<TerminalFocusSidebar
activeWorkspace={ctx.activeWorkspace}
focusedSessionId={ctx.focusedSessionId}
onReorderWorkspaceSessions={ctx.onReorderWorkspaceSessions}
onRequestAddToWorkspace={ctx.onRequestAddToWorkspace}
onAppendHostToWorkspace={ctx.onAppendHostToWorkspace}
onCloseSession={ctx.handleCloseSession}
onCopySession={ctx.onCopySession}
onDuplicateSession={ctx.onDuplicateSession}
onCopySessionToNewWindow={ctx.onCopySessionToNewWindow}
onDetachSessionFromWorkspace={ctx.onRemoveSessionFromWorkspace}
onSetWorkspaceFocusedSession={ctx.onSetWorkspaceFocusedSession}
onToggleWorkspaceViewMode={ctx.onToggleWorkspaceViewMode}
onSubmitSessionRename={ctx.onSubmitSessionRename}
resolvedPreviewTheme={ctx.resolvedPreviewTheme}
sessionHostsMap={ctx.sessionHostsMap}
sessions={ctx.sessions}
dynamicTabTitleMode={ctx.terminalSettings?.dynamicTabTitleMode}
t={ctx.t}
/>
);
}
export const TerminalLayerFocusSidebarSection = memo(
TerminalLayerFocusSidebarSectionInner,
(prev, next) => terminalLayerFocusSidebarPropsEqual(prev.ctx, next.ctx),
);
TerminalLayerFocusSidebarSection.displayName = 'TerminalLayerFocusSidebarSection';

View File

@@ -0,0 +1,360 @@
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import test from 'node:test';
import { JSDOM } from 'jsdom';
import {
normalizeTerminalSidePanelTabOrder,
reorderTerminalSidePanelTab,
fitTerminalSidePanelTabs,
TERMINAL_SIDE_PANEL_TAB_DEFAULT_ORDER,
} from '../../application/state/terminalSidePanelTabs.ts';
import {
getTerminalSidePanelShellWidth,
listenForSidePanelPaneFocus,
resolveMagnifiedSidePanelHosts,
} from './TerminalLayerSidePanelSection.tsx';
import {
getFocusedPortalDescendant,
movePersistentPortalNode,
resolveSidePanelPortalTarget,
} from './terminalLayerSidePanelSlots.tsx';
test('AI side panel shell can be force-hidden for layout isolation', () => {
assert.equal(getTerminalSidePanelShellWidth({
activeSidePanelTab: 'ai',
forceHideAiShell: true,
isSidePanelOpenForCurrentTab: true,
resizePreviewWidth: null,
sidePanelWidth: 420,
}), 0);
});
test('non-AI side panels keep their open width', () => {
assert.equal(getTerminalSidePanelShellWidth({
activeSidePanelTab: 'sftp',
forceHideAiShell: true,
isSidePanelOpenForCurrentTab: true,
resizePreviewWidth: null,
sidePanelWidth: 420,
}), 420);
});
test('resize preview width is still honored for visible side panels', () => {
assert.equal(getTerminalSidePanelShellWidth({
activeSidePanelTab: 'theme',
forceHideAiShell: true,
isSidePanelOpenForCurrentTab: true,
resizePreviewWidth: 512,
sidePanelWidth: 420,
}), 512);
});
test('closed side panel shell has no width', () => {
assert.equal(getTerminalSidePanelShellWidth({
activeSidePanelTab: null,
forceHideAiShell: true,
isSidePanelOpenForCurrentTab: false,
resizePreviewWidth: null,
sidePanelWidth: 420,
}), 0);
});
test('pointer and keyboard focus from portaled tool content focus the owning pane', () => {
const host = new EventTarget();
let focusCount = 0;
const stopListening = listenForSidePanelPaneFocus(host, () => {
focusCount += 1;
});
host.dispatchEvent(new Event('pointerdown'));
assert.equal(focusCount, 1);
host.dispatchEvent(new Event('focusin'));
assert.equal(focusCount, 2);
stopListening();
host.dispatchEvent(new Event('pointerdown'));
host.dispatchEvent(new Event('focusin'));
assert.equal(focusCount, 2);
});
test('side panel tab order falls back to the default order', () => {
assert.deepEqual(normalizeTerminalSidePanelTabOrder(null), TERMINAL_SIDE_PANEL_TAB_DEFAULT_ORDER);
// Partial / dirty lists keep known ids in order and append the rest of the defaults.
assert.deepEqual(normalizeTerminalSidePanelTabOrder(['scripts', 'bad-tab']), [
'scripts',
'sftp',
'history',
'theme',
'system',
'notes',
'ai',
]);
});
test('side panel tab order accepts a stored permutation', () => {
const stored = ['scripts', 'sftp', 'history', 'theme', 'system', 'notes', 'ai'];
assert.deepEqual(normalizeTerminalSidePanelTabOrder(stored), stored);
});
test('side panel tab order moves the dragged tab before the target tab', () => {
assert.deepEqual(
reorderTerminalSidePanelTab(
TERMINAL_SIDE_PANEL_TAB_DEFAULT_ORDER,
'notes',
'scripts',
),
['sftp', 'notes', 'scripts', 'history', 'theme', 'system', 'ai'],
);
});
test('side panel tab order can move the dragged tab after the target tab', () => {
assert.deepEqual(
reorderTerminalSidePanelTab(
TERMINAL_SIDE_PANEL_TAB_DEFAULT_ORDER,
'scripts',
'ai',
'after',
),
['sftp', 'history', 'theme', 'system', 'notes', 'ai', 'scripts'],
);
});
test('narrow side panels keep the active tool visible and move extra tools into overflow', () => {
const fitted = fitTerminalSidePanelTabs({
shown: [...TERMINAL_SIDE_PANEL_TAB_DEFAULT_ORDER],
collapsed: [],
active: 'ai',
maxShown: 2,
});
assert.deepEqual(fitted.shown, ['sftp', 'ai']);
assert.deepEqual(fitted.collapsed, ['scripts', 'history', 'theme', 'system', 'notes']);
});
test('notes side panel forwards repeated open-note requests', () => {
const layerSource = readFileSync(new URL('../TerminalLayer.tsx', import.meta.url), 'utf8');
const slotsSource = readFileSync(new URL('./terminalLayerSidePanelSlots.tsx', import.meta.url), 'utf8');
assert.match(layerSource, /notesOpenRequestIdRef\.current \+= 1/);
assert.match(layerSource, /next\.set\(tabId, \{ noteId, requestId \}\)/);
assert.match(slotsSource, /openNoteRequestId=\{openNoteRequest\?\.requestId \?\? null\}/);
});
test('system monitoring only pauses for hidden remote tabs when hibernation is enabled', () => {
const source = readFileSync(new URL('./terminalLayerSidePanelSlots.tsx', import.meta.url), 'utf8');
// resolveSystemSidebarSession feeds systemSession (via live overlay when active).
assert.match(source, /resolveSystemSidebarSession\(/);
assert.match(source, /const systemSession = /);
assert.match(source, /shouldKeepTerminalBackgroundWorkActive\([\s\S]*systemHost\?\.protocol,[\s\S]*isTabActive/);
assert.doesNotMatch(source, /useSidePanelLiveSnapshotForTab\(tabId, keepSystemWorkActive\)/);
assert.match(source, /isVisible=\{keepSystemWorkActive\}/);
});
test('side panel tab bar and borders use inline resolved terminal theme colors', () => {
const sectionSource = readFileSync(new URL('./TerminalLayerSidePanelSection.tsx', import.meta.url), 'utf8');
assert.match(sectionSource, /buildSidePanelChromeThemeFromTerminalTheme/);
assert.match(sectionSource, /backgroundColor: sidePanelTheme\.termBg/);
assert.match(sectionSource, /borderBottom: `1px solid \$\{sidePanelTheme\.separator\}`/);
assert.match(sectionSource, /borderLeft: `1px solid \$\{sidePanelTheme\.separator\}`/);
assert.doesNotMatch(sectionSource, /terminalAppearanceSidePanelStyle/);
assert.doesNotMatch(sectionSource, /var\(--terminal-sidepanel-border\)/);
});
test('side panel content scopes app color utilities to the resolved terminal theme', () => {
const sectionSource = readFileSync(new URL('./TerminalLayerSidePanelSection.tsx', import.meta.url), 'utf8');
assert.match(sectionSource, /buildTerminalSidePanelCssVars/);
assert.match(sectionSource, /\.\.\.sidePanelCssVars/);
});
test('side panel sets color-scheme from the terminal theme so native inputs match light panels', () => {
const sectionSource = readFileSync(new URL('./TerminalLayerSidePanelSection.tsx', import.meta.url), 'utf8');
// When a light terminal theme remaps --background under a dark app chrome,
// form controls (Codex/AI composer textarea) still follow color-scheme.
assert.match(
sectionSource,
/colorScheme:\s*resolvedSidePanelTerminalTheme\.type/,
);
});
test('a visible tool without a ready pane host stays in the hidden parking host', () => {
const parkingHost = { id: 'parking' };
const paneHost = { id: 'pane' };
assert.equal(resolveSidePanelPortalTarget(true, paneHost, parkingHost), paneHost);
assert.equal(resolveSidePanelPortalTarget(true, null, parkingHost), parkingHost);
assert.equal(resolveSidePanelPortalTarget(false, paneHost, parkingHost), parkingHost);
assert.equal(resolveSidePanelPortalTarget(true, null, null), null);
});
test('magnifying a side pane moves only its stable portal host into the overlay', () => {
const scriptsHost = { id: 'scripts-host' };
const sftpHost = { id: 'sftp-host' };
const overlayHost = { id: 'overlay-host' };
const paneHosts = new Map([
['scripts', scriptsHost],
['sftp', sftpHost],
]);
const resolved = resolveMagnifiedSidePanelHosts(
paneHosts,
{ id: 'pane-scripts', type: 'pane', tool: 'scripts' },
overlayHost,
);
assert.equal(resolved.get('scripts'), overlayHost);
assert.equal(resolved.get('sftp'), sftpHost);
assert.equal(paneHosts.get('scripts'), scriptsHost);
});
test('moving a stable portal host restores focus to its active input', () => {
const dom = new JSDOM('<!doctype html><html><body><div id="first"></div><div id="second"></div></body></html>');
const previousDocument = globalThis.document;
const previousHTMLElement = globalThis.HTMLElement;
globalThis.document = dom.window.document;
globalThis.HTMLElement = dom.window.HTMLElement;
try {
const first = dom.window.document.getElementById('first') as HTMLElement;
const second = dom.window.document.getElementById('second') as HTMLElement;
const mountNode = dom.window.document.createElement('div');
const input = dom.window.document.createElement('input');
mountNode.appendChild(input);
first.appendChild(mountNode);
input.focus();
const focusToRestore = getFocusedPortalDescendant(mountNode, dom.window.document.activeElement);
mountNode.remove();
assert.notEqual(dom.window.document.activeElement, input);
movePersistentPortalNode(mountNode, second, focusToRestore);
assert.equal(dom.window.document.activeElement, input);
} finally {
globalThis.document = previousDocument;
globalThis.HTMLElement = previousHTMLElement;
dom.window.close();
}
});
test('the covered side-panel shell cannot receive keyboard focus', () => {
const source = readFileSync(new URL('./TerminalLayerSidePanelSection.tsx', import.meta.url), 'utf8');
assert.match(source, /const activeMagnifiedPane =/);
assert.match(source, /inert=\{activeMagnifiedPane \? true : undefined\}/);
});
test('split pane hosts strictly clip each tool and do not use the side panel root as a portal fallback', () => {
const sectionSource = readFileSync(new URL('./TerminalLayerSidePanelSection.tsx', import.meta.url), 'utf8');
const slotsSource = readFileSync(new URL('./terminalLayerSidePanelSlots.tsx', import.meta.url), 'utf8');
assert.match(sectionSource, /terminal-side-panel-pane-content/);
assert.match(sectionSource, /overflow-hidden \[contain:strict\]/);
assert.match(sectionSource, /terminal-side-panel-parking/);
assert.match(slotsSource, /paneHosts\.get\(tool\)/);
assert.match(slotsSource, /document\.createElement\('div'\)/);
assert.match(slotsSource, /target\.appendChild\(mountNode\)/);
assert.match(slotsSource, /focusRestoreRef\.current/);
assert.match(slotsSource, /movePersistentPortalNode\(mountNode, target, focusedDescendant\)/);
assert.match(slotsSource, /createPortal\(children, mountNode, portalKey\)/);
assert.doesNotMatch(slotsSource, /document\.querySelector\([^)]*terminal-side-panel/);
});
test('the shared toolbar owns split controls while panes only render minimal chrome', () => {
const sectionSource = readFileSync(new URL('./TerminalLayerSidePanelSection.tsx', import.meta.url), 'utf8');
assert.match(sectionSource, /<SidePanelSplitMenu[\s\S]*direction="horizontal"/);
assert.match(sectionSource, /<SidePanelSplitMenu[\s\S]*direction="vertical"/);
assert.match(sectionSource, /data-section="terminal-side-panel-pane"/);
assert.match(sectionSource, /paneCount > 1/);
});
test('split icons depict the same pane arrangement as their actions', () => {
const sectionSource = readFileSync(new URL('./TerminalLayerSidePanelSection.tsx', import.meta.url), 'utf8');
assert.match(
sectionSource,
/direction === 'horizontal'\s*\? <SplitSquareVertical size=\{15\} \/>\s*: <SplitSquareHorizontal size=\{15\} \/>/,
);
});
test('side panel layout state is initialized before callbacks read it', () => {
const layerSource = readFileSync(new URL('../TerminalLayer.tsx', import.meta.url), 'utf8');
const layoutStateIndex = layerSource.indexOf('} = useTerminalSidePanelLayoutState();');
const statusCallbackIndex = layerSource.indexOf('const handleStatusChange = useCallback');
assert.notEqual(layoutStateIndex, -1);
assert.notEqual(statusCallbackIndex, -1);
assert.ok(layoutStateIndex < statusCallbackIndex);
});
test('split dragging previews locally, commits once, and cleans up on every exit path', () => {
const sectionSource = readFileSync(new URL('./TerminalLayerSidePanelSection.tsx', import.meta.url), 'utf8');
assert.match(sectionSource, /const updatePreview = \(\) => \{[\s\S]*setPreviewSizes\(next\)/);
assert.match(sectionSource, /const finish = \(\) => \{[\s\S]*onResize\(node\.id, latestSizes\)/);
const previewBody = sectionSource.slice(
sectionSource.indexOf('const updatePreview = () => {'),
sectionSource.indexOf('const onMouseMove = (moveEvent: MouseEvent) => {'),
);
assert.doesNotMatch(previewBody, /onResize/);
assert.match(sectionSource, /window\.addEventListener\('blur', finish\)/);
assert.match(sectionSource, /resizeCleanupRef\.current\?\.\(\)/);
assert.match(sectionSource, /terminalLayoutSuppressStore\.end\(\)/);
});
test('split resizers do not consume visible gutter space', () => {
const sectionSource = readFileSync(new URL('./TerminalLayerSidePanelSection.tsx', import.meta.url), 'utf8');
assert.match(sectionSource, /group relative w-px shrink-0 cursor-ew-resize/);
assert.match(sectionSource, /group relative h-px shrink-0 cursor-ns-resize/);
assert.match(sectionSource, /after:w-2/);
assert.match(sectionSource, /after:h-2/);
assert.doesNotMatch(sectionSource, /group relative w-1 shrink-0 cursor-ew-resize/);
assert.doesNotMatch(sectionSource, /group relative h-1 shrink-0 cursor-ns-resize/);
});
test('side panel resize uses the expanded width limit and protects terminal space', () => {
const layerSource = readFileSync(new URL('../TerminalLayer.tsx', import.meta.url), 'utf8');
const sectionSource = readFileSync(new URL('./TerminalLayerSidePanelSection.tsx', import.meta.url), 'utf8');
assert.match(layerSource, /TERMINAL_SIDE_PANEL_MAX_WIDTH/);
assert.match(sectionSource, /clampTerminalSidePanelWidth/);
assert.match(sectionSource, /terminalLayer\.getBoundingClientRect\(\)\.width/);
assert.match(sectionSource, /terminal-workspace-sidebar/);
assert.match(sectionSource, /new ResizeObserver/);
assert.match(sectionSource, /shellRef\.current\?\.getBoundingClientRect\(\)\.width \?\? shellWidth/);
assert.doesNotMatch(sectionSource, /window\.innerWidth[,)\n]/);
assert.doesNotMatch(sectionSource, /100vw/);
assert.doesNotMatch(sectionSource, /const startWidth = sidePanelWidth/);
assert.doesNotMatch(sectionSource, /Math\.min\(800,/);
});
test('side panel width dragging cleans up on mouseup, blur, and unmount', () => {
const sectionSource = readFileSync(new URL('./TerminalLayerSidePanelSection.tsx', import.meta.url), 'utf8');
assert.match(sectionSource, /shellResizeCleanupRef\.current\?\.\(\)/);
assert.match(sectionSource, /window\.addEventListener\('mouseup', finish\)/);
assert.match(sectionSource, /window\.addEventListener\('blur', finish\)/);
assert.match(sectionSource, /window\.removeEventListener\('blur', finish\)/);
});
test('split resizing preserves nested minimums without rerendering on every pixel', () => {
const sectionSource = readFileSync(new URL('./TerminalLayerSidePanelSection.tsx', import.meta.url), 'utf8');
assert.match(sectionSource, /getSidePanelSplitResizeBounds/);
assert.match(sectionSource, /getSidePanelNodeMinimumPixels\(activeSidePanelLayout\.root, 'vertical'\)/);
assert.match(sectionSource, /focusedPaneSplitAvailability/);
assert.doesNotMatch(sectionSource, /focusedPaneSize/);
});
test('available-width observer releases a removed focus sidebar immediately', () => {
const sectionSource = readFileSync(new URL('./TerminalLayerSidePanelSection.tsx', import.meta.url), 'utf8');
assert.match(sectionSource, /focusSidebar !== observedFocusSidebar/);
assert.match(sectionSource, /if \(observedFocusSidebar\) resizeObserver\.unobserve\(observedFocusSidebar\)/);
assert.match(sectionSource, /observedFocusSidebar = null/);
});

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,261 @@
import assert from "node:assert/strict";
import { registerHooks } from "node:module";
import test from "node:test";
import React from "react";
import { act, create, type ReactTestRenderer } from "react-test-renderer";
registerHooks({
resolve(specifier, context, nextResolve) {
if (specifier === "../Terminal" && context.parentURL?.endsWith("/TerminalLayerSupport.tsx")) {
return {
url: "data:text/javascript,export default function Terminal(){return null}",
shortCircuit: true,
};
}
return nextResolve(specifier, context);
},
load(url, context, nextLoad) {
if (url.endsWith(".css")) {
return { format: "module", source: "export default {};", shortCircuit: true };
}
return nextLoad(url, context);
},
});
const { useWorkspaceDetachPointerDrag } = await import("./TerminalLayerSupport");
(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean })
.IS_REACT_ACT_ENVIRONMENT = true;
type Listener = EventListenerOrEventListenerObject;
function createDragDom() {
const documentListeners = new Map<string, Set<Listener>>();
const windowListeners = new Map<string, Set<Listener>>();
const appendedElements = new Set<{ remove: () => void }>();
const addListener = (listeners: Map<string, Set<Listener>>, type: string, listener: Listener) => {
const entries = listeners.get(type) ?? new Set<Listener>();
entries.add(listener);
listeners.set(type, entries);
};
const removeListener = (listeners: Map<string, Set<Listener>>, type: string, listener: Listener) => {
listeners.get(type)?.delete(listener);
};
const dispatch = (listeners: Map<string, Set<Listener>>, type: string, event: Event) => {
for (const listener of [...(listeners.get(type) ?? [])]) {
if (typeof listener === "function") listener(event);
else listener.handleEvent(event);
}
};
const fakeWindow = {
addEventListener: (type: string, listener: Listener) => addListener(windowListeners, type, listener),
removeEventListener: (type: string, listener: Listener) => removeListener(windowListeners, type, listener),
};
const fakeDocument = {
body: {
appendChild: (element: { remove: () => void }) => appendedElements.add(element),
},
createElement: () => {
const element = {
style: {} as Record<string, string>,
textContent: "",
remove: () => appendedElements.delete(element),
};
return element;
},
querySelector: () => null,
addEventListener: (type: string, listener: Listener) => addListener(documentListeners, type, listener),
removeEventListener: (type: string, listener: Listener) => removeListener(documentListeners, type, listener),
defaultView: fakeWindow,
};
return {
document: fakeDocument as unknown as Document,
documentListenerCount: (type: string) => documentListeners.get(type)?.size ?? 0,
windowListenerCount: (type: string) => windowListeners.get(type)?.size ?? 0,
dispatchDocument: (type: string, event: Event) => dispatch(documentListeners, type, event),
dispatchWindow: (type: string, event: Event) => dispatch(windowListeners, type, event),
appendedElements,
};
}
test("pointer drag cleanup runs when the pane unmounts without pointerup", async () => {
const dom = createDragDom();
let pointerDown: ((event: React.PointerEvent<HTMLElement>) => void) | null = null;
let dragStartCount = 0;
let dragEndCount = 0;
function Harness() {
pointerDown = useWorkspaceDetachPointerDrag({
inActiveWorkspace: true,
session: { id: "session-1", workspaceId: "workspace-1" } as never,
workspaceById: new Map(),
onStartSessionDrag: () => { dragStartCount += 1; },
onEndSessionDrag: () => { dragEndCount += 1; },
});
return null;
}
let renderer: ReactTestRenderer;
await act(async () => {
renderer = create(React.createElement(Harness));
});
const pointerTarget = { ownerDocument: dom.document };
await act(async () => {
pointerDown!({
button: 0,
clientX: 0,
clientY: 0,
currentTarget: pointerTarget,
preventDefault: () => undefined,
stopPropagation: () => undefined,
} as unknown as React.PointerEvent<HTMLElement>);
});
assert.equal(dom.documentListenerCount("pointermove"), 1);
assert.equal(dom.documentListenerCount("pointerup"), 1);
assert.equal(dom.documentListenerCount("pointercancel"), 1);
assert.equal(dom.windowListenerCount("blur"), 1);
assert.equal(dom.appendedElements.size, 0);
assert.equal(dragStartCount, 0);
await act(async () => {
renderer!.unmount();
});
assert.equal(dom.documentListenerCount("pointermove"), 0);
assert.equal(dom.documentListenerCount("pointerup"), 0);
assert.equal(dom.documentListenerCount("pointercancel"), 0);
assert.equal(dom.windowListenerCount("blur"), 0);
assert.equal(dom.appendedElements.size, 0);
assert.equal(dragEndCount, 0);
});
test("unmount removes an active drag overlay and ends drag state", async () => {
const dom = createDragDom();
let pointerDown: ((event: React.PointerEvent<HTMLElement>) => void) | null = null;
let dragStartCount = 0;
let dragEndCount = 0;
function Harness() {
pointerDown = useWorkspaceDetachPointerDrag({
inActiveWorkspace: true,
session: { id: "session-1", workspaceId: "workspace-1" } as never,
workspaceById: new Map(),
onStartSessionDrag: () => { dragStartCount += 1; },
onEndSessionDrag: () => { dragEndCount += 1; },
});
return null;
}
let renderer: ReactTestRenderer;
await act(async () => {
renderer = create(React.createElement(Harness));
});
await act(async () => {
pointerDown!({
button: 0,
clientX: 0,
clientY: 0,
currentTarget: { ownerDocument: dom.document },
preventDefault: () => undefined,
stopPropagation: () => undefined,
} as unknown as React.PointerEvent<HTMLElement>);
dom.dispatchDocument("pointermove", { clientX: 10, clientY: 10 } as PointerEvent);
});
assert.equal(dom.appendedElements.size, 2);
assert.equal(dragStartCount, 1);
await act(async () => {
renderer!.unmount();
});
assert.equal(dom.documentListenerCount("pointermove"), 0);
assert.equal(dom.documentListenerCount("pointerup"), 0);
assert.equal(dom.documentListenerCount("pointercancel"), 0);
assert.equal(dom.windowListenerCount("blur"), 0);
assert.equal(dom.appendedElements.size, 0);
assert.equal(dragEndCount, 1);
});
test("all pointer drag exit paths share one idempotent cleanup", async () => {
const dom = createDragDom();
let pointerDown: ((event: React.PointerEvent<HTMLElement>) => void) | null = null;
let dragStartCount = 0;
let dragEndCount = 0;
function Harness() {
pointerDown = useWorkspaceDetachPointerDrag({
inActiveWorkspace: true,
session: { id: "session-1", workspaceId: "workspace-1" } as never,
workspaceById: new Map(),
onStartSessionDrag: () => { dragStartCount += 1; },
onEndSessionDrag: () => { dragEndCount += 1; },
});
return null;
}
let renderer: ReactTestRenderer;
await act(async () => {
renderer = create(React.createElement(Harness));
});
const beginDrag = () => {
pointerDown!({
button: 0,
clientX: 0,
clientY: 0,
currentTarget: { ownerDocument: dom.document },
preventDefault: () => undefined,
stopPropagation: () => undefined,
} as unknown as React.PointerEvent<HTMLElement>);
dom.dispatchDocument("pointermove", { clientX: 10, clientY: 10 } as PointerEvent);
};
const assertClean = () => {
assert.equal(dom.documentListenerCount("pointermove"), 0);
assert.equal(dom.documentListenerCount("pointerup"), 0);
assert.equal(dom.documentListenerCount("pointercancel"), 0);
assert.equal(dom.windowListenerCount("blur"), 0);
assert.equal(dom.appendedElements.size, 0);
};
await act(async () => {
beginDrag();
dom.dispatchDocument("pointercancel", {} as PointerEvent);
});
assertClean();
assert.equal(dragStartCount, 1);
assert.equal(dragEndCount, 1);
await act(async () => {
beginDrag();
beginDrag();
});
assert.equal(dragStartCount, 3);
assert.equal(dragEndCount, 2, "starting again must clean the previous drag exactly once");
assert.equal(dom.documentListenerCount("pointermove"), 1);
assert.equal(dom.appendedElements.size, 2);
await act(async () => {
dom.dispatchWindow("blur", {} as Event);
});
assertClean();
assert.equal(dragEndCount, 3);
await act(async () => {
beginDrag();
dom.dispatchDocument("pointerup", { clientX: 10, clientY: 10 } as PointerEvent);
});
assertClean();
assert.equal(dragStartCount, 4);
assert.equal(dragEndCount, 4);
await act(async () => {
renderer!.unmount();
});
assert.equal(dragEndCount, 4, "unmount after cleanup must not end the drag twice");
});

View File

@@ -0,0 +1,19 @@
import assert from "node:assert/strict";
import test from "node:test";
import { MAX_INCOMPLETE_TERMINAL_CONTROL_SEQUENCE_CHARS } from "../terminal/runtime/terminalControlSequenceLimits";
import { ChunkedEscapeFilter } from "./activityEscapeFilter";
test("activity escape filter bounds unterminated OSC data and recovers on the next chunk", () => {
const filter = new ChunkedEscapeFilter();
const chunk = "x".repeat(1024);
let emitted = "";
emitted += filter.feed("\x1b]0;");
for (let size = 0; size <= MAX_INCOMPLETE_TERMINAL_CONTROL_SEQUENCE_CHARS; size += chunk.length) {
emitted += filter.feed(chunk);
}
assert.ok(emitted.length >= MAX_INCOMPLETE_TERMINAL_CONTROL_SEQUENCE_CHARS);
assert.equal(filter.feed("normal output"), "normal output");
});

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,42 @@
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import test from 'node:test';
const source = readFileSync(new URL('./TerminalLayerTabBridge.tsx', import.meta.url), 'utf8');
test('terminal layer bridge does not dock the shared host tree', () => {
assert.doesNotMatch(source, /hostTreeDockedInLayer/);
});
test('terminal layer is visible only for terminal sessions or workspaces', () => {
assert.match(source, /const isVisible = Boolean\(activeSession \|\| activeWorkspace \|\| s\.draggingSessionId\)/);
});
test('terminal panes can gate cwd restore per session host resolution', () => {
const supportSource = readFileSync(new URL('./TerminalLayerSupport.tsx', import.meta.url), 'utf8');
const layerSource = readFileSync(new URL('../TerminalLayer.tsx', import.meta.url), 'utf8');
assert.match(supportSource, /sessionHostResolved: boolean/);
assert.match(supportSource, /restoreTerminalCwd=\{restoreTerminalCwd && sessionHostResolved\}/);
assert.match(layerSource, /session\.protocol === 'local'/);
});
test('terminal layer bridge refreshes when terminal settings change', () => {
assert.match(source, /terminalSettings: s\.terminalSettings/);
assert.match(source, /\[\s*[\s\S]*s\.terminalSettings[\s\S]*\]\);/);
});
test('terminal layer bridge passes vault open callbacks into the side panel context', () => {
assert.match(source, /onOpenVaultNoteFromChat: s\.onOpenVaultNoteFromChat/);
assert.match(source, /onOpenVaultHostFromChat: s\.onOpenVaultHostFromChat/);
assert.match(source, /onOpenVaultSectionFromChat: s\.onOpenVaultSectionFromChat/);
});
test('terminal layer bridge updates side panel live state after render', () => {
assert.match(source, /const sidePanelLiveSnapshot = useMemo<SidePanelLiveSnapshot>/);
assert.match(
source,
/useLayoutEffect\(\(\) => \{\s*sidePanelLiveStore\.update\(sidePanelLiveSnapshot\);\s*\}, \[sidePanelLiveSnapshot\]\);/,
);
assert.doesNotMatch(source, /sidePanelLiveStore\.update\(\{\s*sftpActiveHost/);
});

View File

@@ -0,0 +1,704 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import React, { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useSyncExternalStore } from 'react';
import { useActiveTabId } from '../../application/state/activeTabStore';
import { sessionCapabilitiesStore } from '../../application/state/sessionCapabilitiesStore';
import { useSystemManagerBackend } from '../../application/state/useSystemManagerBackend';
import { isTerminalSessionEligibleForSftpReuse } from '../../application/state/terminalConnectionReuse';
import { resolveSystemSidebarSession } from '../../domain/systemManager/resolveSystemSession';
import type { TerminalContextReader } from '../../domain/terminalContextRead';
import { useSystemCapabilitiesWarmup } from '../../application/state/useSystemManager';
import { cn } from '../../lib/utils';
import type { Host, TerminalSession, Workspace } from '../../types';
import { resolveTerminalHibernateEnabled } from '../../domain/terminalHibernate';
import { shouldMeasureTerminalLayerLayout } from '../terminalPaneVisibility';
import { TerminalLayerView } from './TerminalLayerView';
import { useTerminalAiContexts } from '../../application/state/useTerminalAiContexts';
import { useTerminalLayerEffects } from './useTerminalLayerEffects';
import { useTerminalThemePanelState } from './useTerminalThemePanelState';
import { useManualTerminalChromeSurfaceInjection } from '../../application/state/useManualTerminalChromeSurfaceInjection';
import { sidePanelLiveStore, type SidePanelLiveSnapshot } from '../../application/state/sidePanelLiveStore';
import { terminalCwdStore } from '../../application/state/terminalCwdStore';
import { useTerminalWorkspaceLayout } from './useTerminalWorkspaceLayout';
import type { SidePanelTab } from './TerminalLayerSupport';
import {
collectSidePanelPanes,
type SidePanelLayout,
} from '../../domain/sidePanelLayout';
type StableRef = React.MutableRefObject<Record<string, any>>;
export function TerminalLayerTabBridge({ stableRef }: { stableRef: StableRef }) {
const s = stableRef.current;
const activeTabId = useActiveTabId();
// Subscribe to cwd store so OSC 7 updates reach the side-panel live snapshot
// without TerminalLayerInner setState.
const terminalCwdVersion = useSyncExternalStore(
terminalCwdStore.subscribe,
terminalCwdStore.getVersion,
terminalCwdStore.getVersion,
);
const systemBackend = useSystemManagerBackend();
const terminalContextReadersRef = useRef<Map<string, TerminalContextReader>>(new Map());
s.activeTabIdRef.current = activeTabId;
const workspaceById = s.workspaceById as Map<string, Workspace>;
const sessions = s.sessions as TerminalSession[];
const sessionHostsMap = s.sessionHostsMap as Map<string, Host>;
const sftpHostForTab = s.sftpHostForTab as Map<string, Host>;
const sidePanelOpenTabs = s.sidePanelOpenTabs as Map<string, SidePanelTab>;
const sidePanelLayouts = s.sidePanelLayouts as Map<string, SidePanelLayout>;
const showHostTreeSidebar = s.showHostTreeSidebar as boolean | undefined;
const activeWorkspace = useMemo(
() => (activeTabId ? workspaceById.get(activeTabId) : undefined),
[activeTabId, workspaceById],
);
const activeSession = useMemo(
() => sessions.find((session) => session.id === activeTabId),
[activeTabId, sessions],
);
const isFocusMode = activeWorkspace?.viewMode === 'focus';
const focusedSessionId = activeWorkspace?.focusedSessionId;
const hibernateHiddenTabs = resolveTerminalHibernateEnabled(s.terminalSettings);
const localWorkspaceIds = useMemo(() => new Set(
sessions
.filter((session) => session.workspaceId && sessionHostsMap.get(session.id)?.protocol === 'local')
.map((session) => session.workspaceId as string),
), [sessionHostsMap, sessions]);
const shouldKeepHiddenWorkspaceLaidOut = useCallback(
(workspace: Workspace) => !hibernateHiddenTabs || localWorkspaceIds.has(workspace.id),
[hibernateHiddenTabs, localWorkspaceIds],
);
const keepHiddenLayoutActive = !hibernateHiddenTabs || localWorkspaceIds.size > 0;
const effectiveFocusedSessionId = useMemo((): string | null => {
if (activeWorkspace) {
if (focusedSessionId) return focusedSessionId;
return sessions.find((session) => session.workspaceId === activeWorkspace.id)?.id ?? null;
}
return activeSession?.id ?? null;
}, [activeSession?.id, activeWorkspace, focusedSessionId, sessions]);
s.activeWorkspaceRef.current = activeWorkspace;
s.activeSessionRef.current = activeSession;
s.focusedSessionIdRef.current = focusedSessionId;
const isVisible = Boolean(activeSession || activeWorkspace || s.draggingSessionId);
const isTerminalLayerVisible = isVisible || !!s.draggingSessionId;
const {
activeResizers,
computeSplitHint,
dropHint,
findSplitNode,
handleWorkspaceDrop,
resizing,
setDropHint,
setResizing,
setWorkspaceArea,
workspaceArea,
workspaceInnerRef,
workspaceOuterRef,
workspaceOverlayRef,
workspaceRectsById,
} = useTerminalWorkspaceLayout({
activeSession,
activeWorkspace,
isFocusMode,
shouldKeepHiddenWorkspaceLaidOut,
onAddSessionToWorkspace: s.onAddSessionToWorkspace,
onCreateWorkspaceFromSessions: s.onCreateWorkspaceFromSessions,
onSetDraggingSessionId: s.onSetDraggingSessionId,
onUpdateSplitSizes: s.onUpdateSplitSizes,
sessions,
workspaces: s.workspaces,
});
const isSidePanelOpenForCurrentTab = activeTabId ? sidePanelOpenTabs.has(activeTabId) : false;
const activeSidePanelTab = activeTabId ? sidePanelOpenTabs.get(activeTabId) ?? null : null;
const activeSidePanelLayout = activeTabId ? sidePanelLayouts.get(activeTabId) ?? null : null;
const activeSidePanelTools = useMemo(
() => new Set(activeSidePanelLayout ? collectSidePanelPanes(activeSidePanelLayout.root).map((pane) => pane.tool) : []),
[activeSidePanelLayout],
);
const isSftpOpenForCurrentTab = activeSidePanelTools.has('sftp');
const activeHostIdForSidebar = useMemo(() => {
const sessionId = activeWorkspace ? focusedSessionId : activeSession?.id;
if (!sessionId) return null;
return sessionHostsMap.get(sessionId)?.id
?? sessions.find((session) => session.id === sessionId)?.hostId
?? null;
}, [activeWorkspace, focusedSessionId, activeSession, sessionHostsMap, sessions]);
const sftpActiveHost = useMemo((): Host | null => {
if (!isSftpOpenForCurrentTab || !activeTabId) return null;
if (activeWorkspace && focusedSessionId) {
return sessionHostsMap.get(focusedSessionId) ?? sftpHostForTab.get(activeTabId) ?? null;
}
if (activeSession) {
return sessionHostsMap.get(activeSession.id) ?? sftpHostForTab.get(activeTabId) ?? null;
}
return sftpHostForTab.get(activeTabId) ?? null;
}, [activeSession, activeTabId, activeWorkspace, focusedSessionId, isSftpOpenForCurrentTab, sessionHostsMap, sftpHostForTab]);
// Keep the same-endpoint SSH session id across disconnected/connecting so
// SftpSidePanel can observe status transitions and rebind after Start over.
// Transport reuse for openSftp still requires status === "connected".
const activeTerminalSessionIdForSftp = useMemo((): string | null => {
if (!isSftpOpenForCurrentTab || !sftpActiveHost) return null;
const sessionId = activeWorkspace ? focusedSessionId : activeSession?.id;
if (!sessionId) return null;
const session = sessions.find((candidate) => candidate.id === sessionId);
if (!session || !isTerminalSessionEligibleForSftpReuse(session)) return null;
const sessionHost = sessionHostsMap.get(session.id);
if (!sessionHost) return null;
const sameEndpoint =
sessionHost.hostname === sftpActiveHost.hostname
&& (sessionHost.port || 22) === (sftpActiveHost.port || 22)
&& (sessionHost.username || 'root') === (sftpActiveHost.username || 'root');
return sameEndpoint ? session.id : null;
}, [activeSession?.id, activeWorkspace, focusedSessionId, isSftpOpenForCurrentTab, sessions, sessionHostsMap, sftpActiveHost]);
const linkedTerminalSessionIdForSftp = useMemo((): string | null => {
if (!isSftpOpenForCurrentTab) return null;
if (activeTerminalSessionIdForSftp) return activeTerminalSessionIdForSftp;
return activeWorkspace ? (focusedSessionId ?? null) : (activeSession?.id ?? null);
}, [
activeSession?.id,
activeTerminalSessionIdForSftp,
activeWorkspace,
focusedSessionId,
isSftpOpenForCurrentTab,
]);
// Recomputed when terminalCwdVersion changes (useSyncExternalStore above).
const activeTerminalCwd = linkedTerminalSessionIdForSftp
? (
terminalCwdStore.getCwd(linkedTerminalSessionIdForSftp)
?? s.terminalRendererCwdBySessionRef.current.get(linkedTerminalSessionIdForSftp)
?? null
)
: null;
const activeTerminalCwdSource = linkedTerminalSessionIdForSftp
? (
terminalCwdStore.getSource(linkedTerminalSessionIdForSftp)
?? s.terminalRendererCwdSourceBySessionRef.current.get(linkedTerminalSessionIdForSftp)
)
: undefined;
const activeTerminalCwdTrusted = activeTerminalCwdSource === 'osc7'
|| activeTerminalCwdSource === 'backend-strict';
void terminalCwdVersion;
const historySessionId = effectiveFocusedSessionId;
const activeTerminalSessionForSystem = useMemo(
() => resolveSystemSidebarSession(sessions, activeWorkspace, focusedSessionId, activeSession),
[activeSession, activeWorkspace, focusedSessionId, sessions],
);
const activeSystemSessionHost = useMemo((): Host | null => {
const id = activeTerminalSessionForSystem?.id;
if (!id) return null;
return sessionHostsMap.get(id) ?? null;
}, [activeTerminalSessionForSystem?.id, sessionHostsMap]);
const systemWarmupSessionIds = useMemo(() => {
if (!activeTabId || !activeSidePanelTools.has('system')) return [];
const session = activeTerminalSessionForSystem;
if (!session || session.status !== 'connected') return [];
return [session.id];
}, [activeSidePanelTools, activeTabId, activeTerminalSessionForSystem]);
useSystemCapabilitiesWarmup(
systemWarmupSessionIds,
systemBackend,
systemWarmupSessionIds.length > 0,
(s.terminalSettings?.systemManagerProcessRefreshInterval ?? 3) * 1000,
);
useEffect(() => {
sessionCapabilitiesStore.prune(new Set(sessions.map((session) => session.id)));
}, [sessions]);
const focusedHost = useMemo((): Host | null => {
if (!historySessionId) return null;
return sessionHostsMap.get(historySessionId) ?? null;
}, [historySessionId, sessionHostsMap]);
const themeState = useTerminalThemePanelState({
activeSession,
activeSidePanelTab: activeSidePanelTools.has('theme') ? 'theme' : activeSidePanelTab,
activeWorkspace,
followAppTerminalTheme: s.followAppTerminalTheme,
focusedSessionId,
fontSize: s.fontSize,
hostMap: s.hostMap,
isSidePanelOpenForCurrentTab,
isVisible,
onUpdateHost: s.onUpdateHost,
onUpdateTerminalFontFamilyId: s.onUpdateTerminalFontFamilyId,
onUpdateTerminalFontSize: s.onUpdateTerminalFontSize,
onUpdateSessionFontSize: s.onUpdateSessionFontSize,
onClearSessionFontSizeOverride: s.onClearSessionFontSizeOverride,
onUpdateTerminalFontWeight: s.onUpdateTerminalFontWeight,
onUpdateTerminalThemeId: s.onUpdateTerminalThemeId,
pickTheme: s.pickTerminalTheme,
clearIntent: s.clearThemeIntent,
resolveFocusedAppearance: s.resolveSessionAppearance,
sessionHostsMap,
terminalFontFamilyId: s.terminalFontFamilyId,
terminalSettings: s.terminalSettings,
terminalTheme: s.terminalTheme,
});
useManualTerminalChromeSurfaceInjection(
themeState.resolvedPreviewTheme,
!s.followAppTerminalTheme && isTerminalLayerVisible,
);
const sidePanelLiveSnapshot = useMemo<SidePanelLiveSnapshot>(() => ({
sftpActiveHost,
activeTerminalSessionIdForSftp,
activeTerminalCwd,
activeTerminalCwdTrusted,
activeWorkspace,
activeTerminalSessionForSystem: activeTerminalSessionForSystem ?? null,
activeSystemSessionHost,
focusedHost,
focusedSessionId: effectiveFocusedSessionId,
historySessionId,
resolvedPreviewTheme: themeState.resolvedPreviewTheme,
previewedOrVisibleThemeId: themeState.previewedOrVisibleThemeId,
focusedFontFamilyId: themeState.focusedFontFamilyId,
focusedFontFamilyOverridden: themeState.focusedFontFamilyOverridden,
focusedFontSize: themeState.focusedFontSize,
focusedFontSizeOverridden: themeState.focusedFontSizeOverridden,
focusedFontWeight: themeState.focusedFontWeight,
focusedFontWeightOverridden: themeState.focusedFontWeightOverridden,
focusedThemeOverridden: themeState.focusedThemeOverridden,
}), [
activeSystemSessionHost,
activeTerminalCwd,
activeTerminalCwdTrusted,
activeTerminalSessionForSystem,
activeTerminalSessionIdForSftp,
activeWorkspace,
effectiveFocusedSessionId,
focusedHost,
historySessionId,
sftpActiveHost,
themeState.focusedFontFamilyId,
themeState.focusedFontFamilyOverridden,
themeState.focusedFontSize,
themeState.focusedFontSizeOverridden,
themeState.focusedFontWeight,
themeState.focusedFontWeightOverridden,
themeState.focusedThemeOverridden,
themeState.previewedOrVisibleThemeId,
themeState.resolvedPreviewTheme,
]);
useLayoutEffect(() => {
sidePanelLiveStore.update(sidePanelLiveSnapshot);
}, [sidePanelLiveSnapshot]);
const { aiContextsByTabId, resolveAIExecutorContext } = useTerminalAiContexts({
hosts: s.hosts,
hostsRef: s.hostsRef,
portForwardingRules: s.portForwardingRules,
portForwardingRulesRef: s.portForwardingRulesRef,
mountedAiTabIds: s.mountedAiTabIds,
sessionHostsMap,
sessions,
sessionsRef: s.sessionsRef,
terminalContextReadersRef,
workspaces: s.workspaces,
workspacesRef: s.workspacesRef,
});
const handleTerminalContextReaderChange = React.useCallback((
sessionId: string,
reader: TerminalContextReader | null,
) => {
if (reader) {
terminalContextReadersRef.current.set(sessionId, reader);
} else {
terminalContextReadersRef.current.delete(sessionId);
}
}, []);
const prevFocusedSessionIdRef = useRef<string | undefined>(undefined);
useTerminalLayerEffects({
activeSidePanelTab,
activeSidePanelLayout,
activeTabId,
activeTabIdRef: s.activeTabIdRef,
activeWorkspace,
activityTrackedSessions: s.activityTrackedSessions,
cancelAnimationFrame,
ChunkedEscapeFilter: s.ChunkedEscapeFilter,
clearTimeout,
document,
dropHint,
effectiveHosts: s.effectiveHosts,
filterTabsMap: s.filterTabsMap,
onConnectToHost: s.onConnectToHost,
focusedSessionId,
getSessionActivityIdsToClear: s.getSessionActivityIdsToClear,
handleToggleAiFromTopBar: s.handleToggleAiFromTopBar,
handleToggleSystemFromTopBar: s.handleToggleSystemFromTopBar,
handleToggleScriptsSidePanel: s.handleToggleScriptsSidePanel,
handleToggleSidePanel: s.handleToggleSidePanel,
hasNotifiableTerminalOutput: s.hasNotifiableTerminalOutput,
isComposeBarOpen: s.isComposeBarOpen,
isFocusMode,
isTerminalLayerVisible,
shouldMeasureTerminalLayerLayout: shouldMeasureTerminalLayerLayout({
isTerminalLayerVisible,
keepHiddenLayoutActive,
workspaceArea,
}),
lastSidePanelTabRef: s.lastSidePanelTabRef,
Map,
Math,
onSessionData: s.onSessionData,
onSplitSessionRef: s.onSplitSessionRef,
onToggleBroadcastRef: s.onToggleBroadcastRef,
onToggleWorkspaceViewModeRef: s.onToggleWorkspaceViewModeRef,
prevFocusedSessionIdRef,
refocusActiveTerminalSession: s.refocusActiveTerminalSession,
requestAnimationFrame,
ResizeObserver,
sessionActivityStore: s.sessionActivityStore,
sessionHostsMap,
sessions,
Set,
setDropHint,
setSftpHostForTab: s.setSftpHostForTab,
setSftpInitialLocationForTab: s.setSftpInitialLocationForTab,
setSftpPendingUploadsForTab: s.setSftpPendingUploadsForTab,
setAiMountedTabIds: s.setAiMountedTabIds,
setNotesMountedTabIds: s.setNotesMountedTabIds,
setScriptsMountedTabIds: s.setScriptsMountedTabIds,
setSystemMountedTabIds: s.setSystemMountedTabIds,
setThemeMountedTabIds: s.setThemeMountedTabIds,
setSidePanelOpenTabs: s.setSidePanelOpenTabs,
setSidePanelLayouts: s.setSidePanelLayouts,
setTimeout,
setWorkspaceArea,
sidePanelPosition: s.sidePanelPosition,
sidePanelWidth: s.sidePanelWidth,
sftpActiveHost,
sftpHostForTab,
sftpPaneClosedTabIdsRef: s.sftpPaneClosedTabIdsRef,
shouldMarkSessionActivity: s.shouldMarkSessionActivity,
sidePanelOpenTabs,
splitHorizontalHandlersRef: s.splitHorizontalHandlersRef,
splitVerticalHandlersRef: s.splitVerticalHandlersRef,
terminalRendererCwdBySessionRef: s.terminalRendererCwdBySessionRef,
toggleScriptsSidePanelRef: s.toggleScriptsSidePanelRef,
toggleSidePanelRef: s.toggleSidePanelRef,
validAIScopeTargetIds: s.validAIScopeTargetIds,
validSessionActivityIds: s.validSessionActivityIds,
window,
workspaceBroadcastHandlersRef: s.workspaceBroadcastHandlersRef,
workspaceFocusHandlersRef: s.workspaceFocusHandlersRef,
workspaceInnerRef,
workspaces: s.workspaces,
});
const ctx = useMemo(() => ({
accentMode: s.accentMode,
activeHostIdForSidebar,
activeResizers,
activeSidePanelTab,
activeSidePanelLayout,
activeTabId,
activeTerminalCwd,
activeTerminalCwdTrusted,
activeTerminalSessionIdForSftp,
activeWorkspace,
AIChatPanelsHost: s.AIChatPanelsHost,
AISidePanelStateRoot: s.AISidePanelStateRoot,
aiContextsByTabId,
Array: s.Array,
Button: s.Button,
cn,
composeBarThemeColors: themeState.composeBarThemeColors,
computeSplitHint,
customAccent: s.customAccent,
customGroups: s.customGroups,
draggingSessionId: s.draggingSessionId,
dropHint,
editorWordWrap: s.editorWordWrap,
effectiveHosts: s.effectiveHosts,
findSplitNode,
focusedFontFamilyId: themeState.focusedFontFamilyId,
focusedFontFamilyOverridden: themeState.focusedFontFamilyOverridden,
focusedFontSize: themeState.focusedFontSize,
focusedFontSizeOverridden: themeState.focusedFontSizeOverridden,
focusedFontWeight: themeState.focusedFontWeight,
focusedFontWeightOverridden: themeState.focusedFontWeightOverridden,
focusedHost,
focusedSessionId,
focusedThemeOverridden: themeState.focusedThemeOverridden,
FolderTree: s.FolderTree,
followAppTerminalTheme: s.followAppTerminalTheme,
handleHistoryPaste: s.handleHistoryPaste,
handleHistoryDelete: s.handleHistoryDelete,
handleHistoryRun: s.handleHistoryRun,
handleFocusSidePanelPane: s.handleFocusSidePanelPane,
handleMagnifySidePanelPane: s.handleMagnifySidePanelPane,
handleRestoreMagnifiedPane: s.handleRestoreMagnifiedPane,
handleMagnifyTerminalPane: s.handleMagnifyTerminalPane,
handleTerminalPaneInteraction: s.handleTerminalPaneInteraction,
handleSplitSidePanelPane: s.handleSplitSidePanelPane,
handleCloseSidePanelPane: s.handleCloseSidePanelPane,
handleResizeSidePanelSplit: s.handleResizeSidePanelSplit,
fontSize: s.fontSize,
getTerminalCwd: s.getTerminalCwd,
handleAddKnownHost: s.handleAddKnownHost,
handleAddSelectionToAI: s.handleAddSelectionToAI,
handleBroadcastInput: s.handleBroadcastInput,
handleCloseSession: s.handleCloseSession,
handleCloseSidePanel: s.handleCloseSidePanel,
handleCommandExecuted: s.handleCommandExecuted,
handleCommandSubmitted: s.handleCommandSubmitted,
handleComposeSend: s.handleComposeSend,
handleFontFamilyChangeForFocusedSession: themeState.handleFontFamilyChangeForFocusedSession,
handleFontFamilyResetForFocusedSession: themeState.handleFontFamilyResetForFocusedSession,
handleFontSizeChangeForFocusedSession: themeState.handleFontSizeChangeForFocusedSession,
handleFontSizeResetForFocusedSession: themeState.handleFontSizeResetForFocusedSession,
handleFontWeightChangeForFocusedSession: themeState.handleFontWeightChangeForFocusedSession,
handleFontWeightResetForFocusedSession: themeState.handleFontWeightResetForFocusedSession,
handleOpenAI: s.handleOpenAI,
handleOpenNotes: s.handleOpenNotes,
handleOpenSystem: s.handleOpenSystem,
handleOpenHistory: s.handleOpenHistory,
handleOpenScripts: s.handleOpenScripts,
activeTerminalSessionForSystem,
activeSystemSessionHost,
handleOpenSftp: s.handleOpenSftp,
handleOpenTheme: s.handleOpenTheme,
handleBackFromNotes: s.handleBackFromNotes,
handleOpenHostFromNotes: s.handleOpenHostFromNotes,
History: s.History,
historySessionId,
HistorySidePanel: s.HistorySidePanel,
// remoteHistory / shellHistory are owned by History side-panel slot stores
hibernateHiddenTabs,
handleOsDetected: s.handleOsDetected,
handlePendingTerminalSelectionConsumed: s.handlePendingTerminalSelectionConsumed,
handlePendingUploadHandled: s.handlePendingUploadHandled,
handleSessionExit: s.handleSessionExit,
handleSftpCurrentPathChange: s.handleSftpCurrentPathChange,
handleSftpActiveTransfersChange: s.handleSftpActiveTransfersChange,
handleSftpActiveExternalEditsChange: s.handleSftpActiveExternalEditsChange,
handleSftpInitialLocationApplied: s.handleSftpInitialLocationApplied,
persistSidePanelWidth: s.persistSidePanelWidth,
setSidePanelWidth: s.setSidePanelWidth,
handleSnippetClickForFocusedSession: s.handleSnippetClickForFocusedSession,
handleSnippetFromPanel: s.handleSnippetFromPanel,
handleRunScriptFromPanel: s.handleRunScriptFromPanel,
handleRunScriptOnWorkspace: s.handleRunScriptOnWorkspace,
handleStartRecordingFromPanel: s.handleStartRecordingFromPanel,
handleStopScriptRun: s.handleStopScriptRun,
handlePauseScriptRun: s.handlePauseScriptRun,
handleResumeScriptRun: s.handleResumeScriptRun,
handleSnippetExecutorChange: s.handleSnippetExecutorChange,
handleBroadcastInterruptPriorityChange: s.handleBroadcastInterruptPriorityChange,
handleProgrammaticCommandLogRewriteChange: s.handleProgrammaticCommandLogRewriteChange,
handleStatusChange: s.handleStatusChange,
handleTerminalCwdChange: s.handleTerminalCwdChange,
handleTerminalTitleChange: s.handleTerminalTitleChange,
handleTerminalBell: s.handleTerminalBell,
handleTerminalOutput: s.handleTerminalOutput,
handleTerminalDataCapture: s.handleTerminalDataCapture,
handleTerminalContextReaderChange,
handleTerminalFontSizeChange: s.handleTerminalFontSizeChange,
handleThemeChangeForFocusedSession: themeState.handleThemeChangeForFocusedSession,
handleThemeResetForFocusedSession: themeState.handleThemeResetForFocusedSession,
handleToggleSftpFromBar: s.handleToggleSftpFromBar,
handleToggleWorkspaceComposeBar: s.handleToggleWorkspaceComposeBar,
handleUpdateHost: s.handleUpdateHost,
handleWorkspaceDrop,
hosts: s.hosts,
hotkeyScheme: s.hotkeyScheme,
disableTerminalFontZoom: s.disableTerminalFontZoom,
restoreTerminalCwd: s.restoreTerminalCwd,
identities: s.identities,
isBroadcastEnabled: s.isBroadcastEnabled,
isComposeBarOpen: s.isComposeBarOpen,
isFocusMode,
isSidePanelOpenForCurrentTab,
isTerminalLayerVisible,
keyBindings: s.keyBindings,
keys: s.keys,
knownHosts: s.knownHosts,
MessageSquare: s.MessageSquare,
magnifiedPane: s.magnifiedPane,
mountedAiTabIds: s.mountedAiTabIds,
mountedSftpTabIds: s.mountedSftpTabIds,
notesMountedTabIds: s.notesMountedTabIds,
notesOpenNoteByTab: s.notesOpenNoteByTab,
NotesManager: s.NotesManager,
scriptsMountedTabIds: s.scriptsMountedTabIds,
systemMountedTabIds: s.systemMountedTabIds,
themeMountedTabIds: s.themeMountedTabIds,
onConnectToHost: s.onConnectToHost,
onCreateLocalTerminal: s.onCreateLocalTerminal,
onHotkeyAction: s.onHotkeyAction,
onReorderWorkspaceSessions: s.onReorderWorkspaceSessions,
onReorderTabs: s.onReorderTabs,
onCopySession: s.onCopySession,
onDuplicateSession: s.onDuplicateSession,
onCopySessionToNewWindow: s.onCopySessionToNewWindow,
onUpdateSessionRestoreCwd: s.onUpdateSessionRestoreCwd,
onUpdateSessionDynamicTitle: s.onUpdateSessionDynamicTitle,
onUpdateSessionCodingCliProvider: s.onUpdateSessionCodingCliProvider,
onRequestAddToWorkspace: s.onRequestAddToWorkspace,
onAppendHostToWorkspace: s.onAppendHostToWorkspace,
onSetWorkspaceFocusedSession: s.onSetWorkspaceFocusedSession,
onStartSessionRename: s.onStartSessionRename,
onSubmitSessionRename: s.onSubmitSessionRename,
onRemoveSessionFromWorkspace: s.onRemoveSessionFromWorkspace,
onOpenVaultNoteFromChat: s.onOpenVaultNoteFromChat,
onOpenVaultHostFromChat: s.onOpenVaultHostFromChat,
onOpenVaultSectionFromChat: s.onOpenVaultSectionFromChat,
onOpenVaultSnippetFromChat: s.onOpenVaultSnippetFromChat,
onStartSessionDrag: s.onStartSessionDrag,
onEndSessionDrag: s.onEndSessionDrag,
onSplitSession: s.onSplitSession,
onToggleWorkspaceViewMode: s.onToggleWorkspaceViewMode,
Palette: s.Palette,
PanelLeft: s.PanelLeft,
PanelRight: s.PanelRight,
pendingTerminalSelectionForAI: s.pendingTerminalSelectionForAI,
previewedOrVisibleThemeId: themeState.previewedOrVisibleThemeId,
refocusActiveTerminalSession: s.refocusActiveTerminalSession,
refocusTerminalSession: s.refocusTerminalSession,
resizing,
resolveAIExecutorContext,
resolvedPreviewTheme: themeState.resolvedPreviewTheme,
ScriptsSidePanel: s.ScriptsSidePanel,
sessionChainHostsMap: s.sessionChainHostsMap,
sessionHostsMap,
resolvedSessionHostIds: s.resolvedSessionHostIds,
sessionLogConfig: s.sessionLogConfig,
sessionSudoAutofillPasswordsMap: s.sessionSudoAutofillPasswordsMap,
sessionSudoAutofillCandidatesMap: s.sessionSudoAutofillCandidatesMap,
sessions,
setDropHint,
setEditorWordWrap: s.setEditorWordWrap,
setIsComposeBarOpen: s.setIsComposeBarOpen,
setResizing,
showHostTreeSidebar,
setSidePanelPosition: s.setSidePanelPosition,
setSftpFollowTerminalCwd: s.setSftpFollowTerminalCwd,
sftpActiveHost,
sftpHostForTab,
sftpAutoSync: s.sftpAutoSync,
sftpDefaultViewMode: s.sftpDefaultViewMode,
sftpDoubleClickBehavior: s.sftpDoubleClickBehavior,
sftpFollowTerminalCwd: s.sftpFollowTerminalCwd,
sftpInitialLocationForTab: s.sftpInitialLocationForTab,
sftpPendingUploadsForTab: s.sftpPendingUploadsForTab,
sftpPaneClosedTabIdsRef: s.sftpPaneClosedTabIdsRef,
sftpShowHiddenFiles: s.sftpShowHiddenFiles,
SftpSidePanel: s.SftpSidePanel,
sftpUseCompressedUpload: s.sftpUseCompressedUpload,
sidePanelPosition: s.sidePanelPosition,
sidePanelWidth: s.sidePanelWidth,
sidePanelOpenTabs,
sidePanelLayouts,
snippetPackages: s.snippetPackages,
snippets: s.snippets,
updateSnippetPackages: s.updateSnippetPackages,
updateSnippets: s.updateSnippets,
splitHorizontalHandlersRef: s.splitHorizontalHandlersRef,
splitVerticalHandlersRef: s.splitVerticalHandlersRef,
sshDebugLogsEnabled: s.sshDebugLogsEnabled,
t: s.t,
TerminalComposeBar: s.TerminalComposeBar,
terminalFontFamilyId: s.terminalFontFamilyId,
TerminalPanesHost: s.TerminalPanesHost,
terminalSettings: s.terminalSettings,
terminalTheme: s.terminalTheme,
terminalThemeId: s.terminalThemeId,
resolveSessionAppearance: s.resolveSessionAppearance,
hostMap: s.hostMap,
ThemeSidePanel: s.ThemeSidePanel,
Tooltip: s.Tooltip,
TooltipContent: s.TooltipContent,
TooltipTrigger: s.TooltipTrigger,
updateHosts: s.updateHosts,
validAIScopeTargetIds: s.validAIScopeTargetIds,
workspaceBroadcastHandlersRef: s.workspaceBroadcastHandlersRef,
workspaceById,
isGlobalBroadcastEnabled: s.isGlobalBroadcastEnabled,
canUseGlobalBroadcast: s.canUseGlobalBroadcast,
onToggleGlobalBroadcast: s.onToggleGlobalBroadcastRef.current,
// AI scope maintenance (merge/dissolve handoff) needs the full list; do not
// rely on workspaceById alone — SidePanelStateRoot reads ctx.workspaces.
workspaces: s.workspaces,
workspaceFocusHandlersRef: s.workspaceFocusHandlersRef,
workspaceInnerRef,
workspaceOuterRef,
workspaceOverlayRef,
workspaceRectsById,
X: s.X,
Zap: s.Zap,
// stableRef fields are intentionally omitted from deps — they update every parent render.
// eslint-disable-next-line react-hooks/exhaustive-deps
}), [
activeHostIdForSidebar,
activeResizers,
activeSidePanelLayout,
activeSidePanelTab,
activeTabId,
activeTerminalCwd,
activeTerminalCwdTrusted,
activeTerminalSessionIdForSftp,
activeWorkspace,
aiContextsByTabId,
computeSplitHint,
dropHint,
focusedHost,
focusedSessionId,
s.restoreTerminalCwd,
handleWorkspaceDrop,
handleTerminalContextReaderChange,
hibernateHiddenTabs,
historySessionId,
isFocusMode,
isSidePanelOpenForCurrentTab,
isTerminalLayerVisible,
s.magnifiedPane,
resizing,
resolveAIExecutorContext,
sessionHostsMap,
sidePanelLayouts,
s.resolvedSessionHostIds,
sessions,
s.terminalSettings,
showHostTreeSidebar,
sftpActiveHost,
s.sftpFollowTerminalCwd,
themeState,
workspaceById,
s.workspaces,
workspaceInnerRef,
workspaceOuterRef,
workspaceOverlayRef,
workspaceRectsById,
s.terminalTheme,
s.resolveSessionAppearance,
s.hostMap,
s.isGlobalBroadcastEnabled,
s.canUseGlobalBroadcast,
s.onToggleGlobalBroadcastRef,
]);
return <TerminalLayerView ctx={ctx} />;
}

View File

@@ -0,0 +1,42 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import React, { memo } from 'react';
import { TerminalLayerFocusSidebarSection } from './TerminalLayerFocusSidebarSection';
import { TerminalLayerSidePanelSection } from './TerminalLayerSidePanelSection';
import { TerminalLayerWorkspaceSection } from './TerminalLayerWorkspaceSection';
import { terminalLayerViewCtxEqual } from './terminalLayerViewMemo';
import { useTerminalHostTreeLayoutWidth } from '../../application/state/terminalHostTreeStore';
import { resolveTerminalLayerSurfaceStyle } from '../terminalPaneVisibility';
type TerminalLayerViewContext = Record<string, any>;
function TerminalLayerViewInner({ ctx }: { ctx: TerminalLayerViewContext }) {
const hostTreeLayoutWidth = useTerminalHostTreeLayoutWidth();
const surfaceStyle = resolveTerminalLayerSurfaceStyle(
ctx.isTerminalLayerVisible,
ctx.hibernateHiddenTabs,
);
return (
<div
ref={ctx.workspaceOuterRef}
className="absolute inset-0 bg-background flex min-h-0"
data-section="terminal-workspace"
inert={ctx.isTerminalLayerVisible ? undefined : true}
style={{
...surfaceStyle,
left: hostTreeLayoutWidth,
}}
>
<TerminalLayerSidePanelSection ctx={ctx} />
<TerminalLayerFocusSidebarSection ctx={ctx} />
<TerminalLayerWorkspaceSection ctx={ctx} />
</div>
);
}
export const TerminalLayerView = memo(
TerminalLayerViewInner,
(prev, next) => terminalLayerViewCtxEqual(prev.ctx, next.ctx),
);
TerminalLayerView.displayName = 'TerminalLayerView';

View File

@@ -0,0 +1,23 @@
import test from "node:test";
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
const source = readFileSync(
new URL("./TerminalLayerWorkspaceSection.tsx", import.meta.url),
"utf8",
);
test("workspace section uses live store only for activeWorkspace (no stale ctx fallback)", () => {
// After view memo omits activeWorkspace from equality, ctx can lag behind
// live when switching workspace → solo session (live clears to undefined).
// `live.x ?? ctx.x` would keep the old workspace for compose bar / resizers.
assert.match(source, /sidePanelLiveStore\.getSnapshot/);
assert.match(source, /const activeWorkspace = live\.activeWorkspace;/);
assert.match(source, /const focusedSessionId = live\.focusedSessionId;/);
assert.doesNotMatch(source, /live\.activeWorkspace\s*\?\?/);
assert.doesNotMatch(source, /live\.focusedSessionId\s*\?\?/);
assert.doesNotMatch(source, /ctx\.activeWorkspace/);
assert.doesNotMatch(source, /ctx\.focusedSessionId/);
// Focus mode must follow live workspace, not stale ctx.isFocusMode.
assert.match(source, /activeWorkspace\?\.viewMode === ['"]focus['"]/);
});

View File

@@ -0,0 +1,161 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import test from "node:test";
import React from "react";
import { renderToStaticMarkup } from "react-dom/server";
import { TerminalLayerWorkspaceSection } from "./TerminalLayerWorkspaceSection.tsx";
test("terminal keyboard focus updates the pane selected for magnification", () => {
const source = readFileSync(new URL("./TerminalLayerSupport.tsx", import.meta.url), "utf8");
assert.match(source, /onClick=\{handlePaneClick\}/);
assert.match(source, /onFocusCapture=\{handlePaneClick\}/);
assert.match(source, /const isCoveredByMagnification = isVisible/);
assert.match(source, /inert=\{isVisible && !isCoveredByMagnification \? undefined : true\}/);
});
test("closing a magnified terminal clears the overlay selection", () => {
const source = readFileSync(new URL("../TerminalLayer.tsx", import.meta.url), "utf8");
assert.match(source, /isPaneMagnificationSelectionValid\(current, terminalPanes, sidePanelPanes\)/);
assert.match(source, /current\?\.target\.kind === 'terminal' && current\.target\.sessionId === sessionId/);
});
test("terminal panes expose focus mode and temporary magnification as separate actions", () => {
const source = readFileSync(new URL("TerminalLayerSupport.tsx", import.meta.url), "utf8");
assert.match(source, /workspaceFocusHandlersRef,\s+workspaceBroadcastHandlersRef,/);
assert.match(source, /if \(isMagnified\) \{\s+handleTogglePaneMagnification\(\);\s+\}\s+workspaceFocusHandler\?\.\(\);/);
assert.match(source, /onExpandToFocus=\{inActiveWorkspace && !isFocusMode \? handleExpandToFocus : undefined\}/);
assert.match(source, /onTogglePaneMagnification=\{inActiveWorkspace && \(!isFocusMode \|\| isMagnified\) \? handleTogglePaneMagnification : undefined\}/);
});
test("focus mode cannot start temporary magnification but can restore stale magnification", () => {
const source = readFileSync(new URL("../TerminalLayer.tsx", import.meta.url), "utf8");
assert.match(source, /const hasCurrentMagnification = magnifiedPaneRef\.current\?\.tabId === tabId;/);
assert.match(source, /if \(workspace\?\.viewMode === 'focus' && !hasCurrentMagnification\) return null;/);
});
test("closing a magnified terminal restores its pane instead of ending the session", () => {
const terminalSource = readFileSync(new URL("../Terminal.tsx", import.meta.url), "utf8");
const viewSource = readFileSync(new URL("../terminal/TerminalView.tsx", import.meta.url), "utf8");
const toolbarSource = readFileSync(new URL("../terminal/TerminalToolbar.tsx", import.meta.url), "utf8");
assert.match(viewSource, /renderControls\(\{ showClose: inWorkspace, restorePaneLayout: isPaneMagnified \}\)/);
assert.match(terminalSource, /opts\?: \{ showClose\?: boolean; restorePaneLayout\?: boolean \}/);
assert.match(terminalSource, /if \(opts\?\.restorePaneLayout\) \{\s+onTogglePaneMagnification\?\.\(\);\s+return;\s+\}/);
assert.match(terminalSource, /closeLabel=\{t\(opts\?\.restorePaneLayout\s+\? 'terminal\.paneMagnification\.restore'\s+: 'terminal\.toolbar\.closeSession'\)\}/);
assert.match(toolbarSource, /aria-label=\{closeLabel \?\? t\('terminal\.toolbar\.closeSession'\)\}/);
});
test("workspace section passes resolved session host ids to terminal panes", () => {
const resolvedSessionHostIds = new Set(["session-1"]);
let sawResolvedIds = false;
const TerminalPanesHost = (props: { resolvedSessionHostIds?: Set<string> }) => {
sawResolvedIds = true;
assert.equal(props.resolvedSessionHostIds, resolvedSessionHostIds);
assert.equal(props.resolvedSessionHostIds?.has("session-1"), true);
return null;
};
const ref = { current: null };
const noop = () => {};
const ctx = {
workspaceInnerRef: ref,
workspaceOverlayRef: ref,
draggingSessionId: null,
isFocusMode: false,
dropHint: null,
setDropHint: noop,
computeSplitHint: () => null,
handleWorkspaceDrop: noop,
TerminalPanesHost,
sessions: [],
sessionHostsMap: new Map(),
sessionChainHostsMap: new Map(),
sessionSudoAutofillPasswordsMap: new Map(),
sessionSudoAutofillCandidatesMap: new Map(),
resolvedSessionHostIds,
workspaceById: new Map(),
workspaceRectsById: new Map(),
isTerminalLayerVisible: true,
workspaceFocusHandlersRef: { current: new Map() },
workspaceBroadcastHandlersRef: { current: new Map() },
splitHorizontalHandlersRef: { current: new Map() },
splitVerticalHandlersRef: { current: new Map() },
themePreview: { targetSessionId: null, targetHostId: null, globalPreview: false, themeId: null },
keys: [],
identities: [],
snippets: [],
knownHosts: [],
terminalFontFamilyId: "default",
fontSize: 14,
terminalTheme: {},
followAppTerminalTheme: false,
accentMode: "theme",
customAccent: "",
terminalSettings: {},
hotkeyScheme: "mac",
disableTerminalFontZoom: false,
restoreTerminalCwd: false,
keyBindings: [],
resizing: null,
isComposeBarOpen: false,
sessionLogConfig: undefined,
sshDebugLogsEnabled: false,
onHotkeyAction: noop,
handleTerminalFontSizeChange: noop,
handleOpenSftp: noop,
handleTerminalCwdChange: noop,
handleTerminalTitleChange: noop,
handleTerminalBell: noop,
handleTerminalOutput: noop,
handleOpenScripts: noop,
handleOpenHistory: noop,
handleOpenSystem: noop,
handleOpenTheme: noop,
handleCloseSession: noop,
handleStatusChange: noop,
handleSessionExit: noop,
handleTerminalDataCapture: noop,
handleOsDetected: noop,
handleUpdateHost: noop,
handleAddKnownHost: noop,
handleCommandExecuted: noop,
handleCommandSubmitted: noop,
onSetWorkspaceFocusedSession: noop,
onSplitSession: noop,
isBroadcastEnabled: () => false,
handleBroadcastInput: noop,
handleBroadcastInterruptPriorityChange: noop,
handleToggleWorkspaceComposeBar: noop,
handleSnippetExecutorChange: noop,
handleProgrammaticCommandLogRewriteChange: noop,
handleAddSelectionToAI: noop,
activeResizers: [],
activeWorkspace: null,
composeBarThemeColors: null,
findSplitNode: () => null,
focusedSessionId: null,
handleComposeSend: noop,
handleSnippetFromPanel: noop,
refocusTerminalSession: noop,
setIsComposeBarOpen: noop,
setResizing: noop,
TerminalComposeBar: () => null,
Array,
cn: (...values: unknown[]) => values.filter(Boolean).join(" "),
onStartSessionRename: noop,
onRemoveSessionFromWorkspace: noop,
onReorderTabs: noop,
onStartSessionDrag: noop,
onEndSessionDrag: noop,
};
renderToStaticMarkup(React.createElement(TerminalLayerWorkspaceSection, { ctx }));
assert.equal(sawResolvedIds, true);
});

View File

@@ -0,0 +1,362 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import React, { memo, useEffect, useState, useSyncExternalStore } from 'react';
import { createPortal } from 'react-dom';
import {
getSidePanelLiveSnapshot,
sidePanelLiveStore,
} from '../../application/state/sidePanelLiveStore';
import { getPaneMagnificationShortcutLabel } from '../../domain/paneMagnification';
import { terminalLayerWorkspaceCtxEqual } from './terminalLayerViewMemo';
type WorkspaceContext = Record<string, any>;
function TerminalLayerWorkspaceSectionInner({ ctx }: { ctx: WorkspaceContext }) {
// Active workspace / focused session come ONLY from the live store so top-tab
// switches do not force a TerminalLayerView ctx rebuild just to flip them.
// Do not fall back to ctx.* — after memo omits activeWorkspace from equality,
// ctx can still hold a previous workspace after live has been cleared
// (workspace → solo session). Falling back would keep compose bar / resizer
// handlers bound to a stale workspace.
// Panes already derive visibility from activeTabStore themselves.
const live = useSyncExternalStore(
sidePanelLiveStore.subscribe,
sidePanelLiveStore.getSnapshot,
() => getSidePanelLiveSnapshot(false),
);
const activeWorkspace = live.activeWorkspace;
const focusedSessionId = live.focusedSessionId;
const isFocusMode = activeWorkspace?.viewMode === 'focus';
const {
workspaceInnerRef,
workspaceOuterRef,
activeTabId,
workspaceOverlayRef,
draggingSessionId,
dropHint,
setDropHint,
computeSplitHint,
handleWorkspaceDrop,
TerminalPanesHost,
sessions,
sessionHostsMap,
sessionChainHostsMap,
sessionSudoAutofillPasswordsMap,
sessionSudoAutofillCandidatesMap,
resolvedSessionHostIds,
workspaceById,
workspaceRectsById,
isTerminalLayerVisible,
magnifiedPane,
handleMagnifyTerminalPane,
handleTerminalPaneInteraction,
handleRestoreMagnifiedPane,
workspaceFocusHandlersRef,
workspaceBroadcastHandlersRef,
splitHorizontalHandlersRef,
splitVerticalHandlersRef,
resolveSessionAppearance,
hostMap,
keys,
identities,
snippets,
knownHosts,
terminalFontFamilyId,
fontSize,
terminalTheme,
followAppTerminalTheme,
accentMode,
customAccent,
terminalSettings,
hotkeyScheme,
disableTerminalFontZoom,
restoreTerminalCwd,
keyBindings,
resizing,
isComposeBarOpen,
sessionLogConfig,
sshDebugLogsEnabled,
onHotkeyAction,
handleTerminalFontSizeChange,
handleOpenSftp,
handleTerminalCwdChange,
handleTerminalTitleChange,
handleTerminalBell,
handleTerminalOutput,
handleTerminalContextReaderChange,
handleOpenScripts,
handleOpenHistory,
handleOpenSystem,
handleOpenTheme,
handleCloseSession,
handleStatusChange,
handleSessionExit,
handleTerminalDataCapture,
handleOsDetected,
handleUpdateHost,
handleAddKnownHost,
handleCommandExecuted,
handleCommandSubmitted,
onSetWorkspaceFocusedSession,
onSplitSession,
isBroadcastEnabled,
handleBroadcastInput,
handleBroadcastInterruptPriorityChange,
handleToggleWorkspaceComposeBar,
handleSnippetExecutorChange,
handleProgrammaticCommandLogRewriteChange,
handleAddSelectionToAI,
activeResizers,
composeBarThemeColors,
findSplitNode,
handleComposeSend,
handleSnippetFromPanel,
refocusTerminalSession,
setIsComposeBarOpen,
setResizing,
TerminalComposeBar,
Array,
cn,
onStartSessionRename,
onRemoveSessionFromWorkspace,
onReorderTabs,
onStartSessionDrag,
onEndSessionDrag,
isGlobalBroadcastEnabled,
canUseGlobalBroadcast,
onToggleGlobalBroadcast,
t,
} = ctx;
const activeMagnifiedTerminal = !!activeTabId && magnifiedPane?.tabId === activeTabId
&& magnifiedPane.target.kind === 'terminal'
? magnifiedPane
: null;
const paneMagnificationShortcutLabel = getPaneMagnificationShortcutLabel(keyBindings, hotkeyScheme);
const [showMagnificationHint, setShowMagnificationHint] = useState(false);
useEffect(() => {
if (!activeMagnifiedTerminal) {
setShowMagnificationHint(false);
return undefined;
}
setShowMagnificationHint(true);
const timerId = window.setTimeout(() => setShowMagnificationHint(false), 1800);
return () => window.clearTimeout(timerId);
}, [activeMagnifiedTerminal]);
return (
<div className="flex-1 min-h-0 flex flex-col">
<div ref={workspaceInnerRef} className="flex-1 min-h-0 overflow-hidden relative">
{draggingSessionId && !isFocusMode && (
<div
ref={workspaceOverlayRef}
className="absolute inset-0 z-30"
onDragOver={(e) => {
if (isFocusMode) return;
if (!e.dataTransfer.types.includes('session-id')) return;
e.preventDefault();
e.stopPropagation();
const hint = computeSplitHint(e);
setDropHint(hint);
}}
onDragLeave={(e) => {
if (!e.dataTransfer.types.includes('session-id')) return;
setDropHint(null);
}}
onDrop={(e) => {
e.preventDefault();
e.stopPropagation();
handleWorkspaceDrop(e);
}}
>
{dropHint && (
<div className="absolute inset-0 pointer-events-none">
<div
className="absolute bg-emerald-600/35 border border-emerald-400/70 backdrop-blur-sm transition-all duration-150"
style={{
width: dropHint.rect ? `${dropHint.rect.w}px` : dropHint.direction === 'vertical' ? '50%' : '100%',
height: dropHint.rect ? `${dropHint.rect.h}px` : dropHint.direction === 'vertical' ? '100%' : '50%',
left: dropHint.rect ? `${dropHint.rect.x}px` : dropHint.direction === 'vertical' ? (dropHint.position === 'left' ? 0 : '50%') : 0,
top: dropHint.rect ? `${dropHint.rect.y}px` : dropHint.direction === 'vertical' ? 0 : (dropHint.position === 'top' ? 0 : '50%'),
}}
/>
</div>
)}
</div>
)}
<TerminalPanesHost
sessions={sessions}
sessionHostsMap={sessionHostsMap}
sessionChainHostsMap={sessionChainHostsMap}
sessionSudoAutofillPasswordsMap={sessionSudoAutofillPasswordsMap}
sessionSudoAutofillCandidatesMap={sessionSudoAutofillCandidatesMap}
resolvedSessionHostIds={resolvedSessionHostIds}
workspaceById={workspaceById}
workspaceRectsById={workspaceRectsById}
isTerminalLayerVisible={isTerminalLayerVisible}
magnifiedPane={magnifiedPane}
onMagnifyTerminalPane={handleMagnifyTerminalPane}
onTerminalPaneInteraction={handleTerminalPaneInteraction}
workspaceFocusHandlersRef={workspaceFocusHandlersRef}
workspaceBroadcastHandlersRef={workspaceBroadcastHandlersRef}
splitHorizontalHandlersRef={splitHorizontalHandlersRef}
splitVerticalHandlersRef={splitVerticalHandlersRef}
resolveSessionAppearance={resolveSessionAppearance}
hostMap={hostMap}
keys={keys}
identities={identities}
snippets={snippets}
knownHosts={knownHosts}
terminalFontFamilyId={terminalFontFamilyId}
fontSize={fontSize}
terminalTheme={terminalTheme}
followAppTerminalTheme={followAppTerminalTheme}
accentMode={accentMode}
customAccent={customAccent}
terminalSettings={terminalSettings}
hotkeyScheme={hotkeyScheme}
disableTerminalFontZoom={disableTerminalFontZoom}
restoreTerminalCwd={restoreTerminalCwd}
keyBindings={keyBindings}
isResizing={!!resizing}
isComposeBarOpen={isComposeBarOpen}
sessionLog={sessionLogConfig}
sshDebugLogEnabled={sshDebugLogsEnabled}
onHotkeyAction={onHotkeyAction}
onTerminalFontSizeChange={handleTerminalFontSizeChange}
onOpenSftp={handleOpenSftp}
onTerminalCwdChange={handleTerminalCwdChange}
onTerminalTitleChange={handleTerminalTitleChange}
onTerminalBell={handleTerminalBell}
onTerminalOutput={handleTerminalOutput}
onTerminalContextReaderChange={handleTerminalContextReaderChange}
onOpenScripts={handleOpenScripts}
onOpenHistory={handleOpenHistory}
onOpenSystem={handleOpenSystem}
onOpenTheme={handleOpenTheme}
onCloseSession={handleCloseSession}
onStatusChange={handleStatusChange}
onSessionExit={handleSessionExit}
onTerminalDataCapture={handleTerminalDataCapture}
onOsDetected={handleOsDetected}
onUpdateHost={handleUpdateHost}
onAddKnownHost={handleAddKnownHost}
onCommandExecuted={handleCommandExecuted}
onCommandSubmitted={handleCommandSubmitted}
onSetWorkspaceFocusedSession={onSetWorkspaceFocusedSession}
onSplitSession={onSplitSession}
isBroadcastEnabled={isBroadcastEnabled}
onBroadcastInput={handleBroadcastInput}
onBroadcastInterruptPriorityChange={handleBroadcastInterruptPriorityChange}
onToggleWorkspaceComposeBar={handleToggleWorkspaceComposeBar}
onSnippetExecutorChange={handleSnippetExecutorChange}
onProgrammaticCommandLogRewriteChange={handleProgrammaticCommandLogRewriteChange}
onAddSelectionToAI={handleAddSelectionToAI}
onStartSessionRename={onStartSessionRename}
onRemoveSessionFromWorkspace={onRemoveSessionFromWorkspace}
onReorderTabs={onReorderTabs}
onStartSessionDrag={onStartSessionDrag}
onEndSessionDrag={onEndSessionDrag}
isGlobalBroadcastEnabled={isGlobalBroadcastEnabled}
canUseGlobalBroadcast={canUseGlobalBroadcast}
onToggleGlobalBroadcast={onToggleGlobalBroadcast}
/>
{!isFocusMode && activeResizers.map((handle: any) => {
const isVertical = handle.direction === 'vertical';
const left = isVertical ? handle.rect.x - 3 : handle.rect.x;
const top = isVertical ? handle.rect.y : handle.rect.y - 3;
const width = isVertical ? handle.rect.w + 6 : handle.rect.w;
const height = isVertical ? handle.rect.h : handle.rect.h + 6;
return (
<div
key={handle.id}
className={cn('absolute group', isVertical ? 'cursor-ew-resize' : 'cursor-ns-resize')}
data-section="terminal-split-resizer"
data-split-direction={handle.direction}
style={{
left: `${left}px`,
top: `${top}px`,
width: `${width}px`,
height: `${height}px`,
zIndex: 25,
}}
onMouseDown={(e) => {
e.preventDefault();
e.stopPropagation();
const ws = activeWorkspace;
if (!ws) return;
const split = findSplitNode(ws.root, handle.splitId);
const childCount = split && split.type === 'split' ? split.children.length : 0;
const sizes = split && split.type === 'split' && split.sizes && split.sizes.length === childCount
? split.sizes
: Array(childCount).fill(1);
setResizing({
workspaceId: ws.id,
splitId: handle.splitId,
index: handle.index,
direction: handle.direction,
startSizes: sizes.length ? sizes : [1, 1],
startArea: handle.splitArea,
startClient: { x: e.clientX, y: e.clientY },
});
}}
>
<div
data-section="terminal-split-resizer-bar"
className={cn(
'absolute bg-border/70 group-hover:bg-primary/60 transition-colors',
isVertical ? 'w-px h-full left-1/2 -translate-x-1/2' : 'h-px w-full top-1/2 -translate-y-1/2',
)}
/>
</div>
);
})}
</div>
{activeMagnifiedTerminal && workspaceOuterRef.current && createPortal(
<>
<div
className="absolute inset-0 z-40 bg-background/55 backdrop-blur-[1px]"
aria-hidden="true"
data-section="pane-magnification-backdrop"
/>
{showMagnificationHint && (
<button
type="button"
className="absolute bottom-4 right-4 z-[60] rounded border border-border/70 bg-background/90 px-2 py-1 text-[10px] text-muted-foreground shadow-sm animate-in fade-in duration-150"
onClick={handleRestoreMagnifiedPane}
data-section="pane-magnification-hint"
aria-label={t('terminal.paneMagnification.restore')}
>
{t('terminal.paneMagnification.hint')}: {t('terminal.layer.terminal')}
{paneMagnificationShortcutLabel ? ` · ${paneMagnificationShortcutLabel} / Esc` : ' · Esc'}
</button>
)}
</>,
workspaceOuterRef.current,
)}
{activeWorkspace && isComposeBarOpen && (
<TerminalComposeBar
onSend={handleComposeSend}
onSnippetClick={(snippet) => void handleSnippetFromPanel(snippet)}
snippets={snippets}
onClose={() => {
setIsComposeBarOpen(false);
refocusTerminalSession(focusedSessionId);
}}
isBroadcastEnabled={isBroadcastEnabled?.(activeWorkspace.id)}
themeColors={composeBarThemeColors}
/>
)}
</div>
);
}
export const TerminalLayerWorkspaceSection = memo(
TerminalLayerWorkspaceSectionInner,
(prev, next) => terminalLayerWorkspaceCtxEqual(prev.ctx, next.ctx),
);
TerminalLayerWorkspaceSection.displayName = 'TerminalLayerWorkspaceSection';

View File

@@ -0,0 +1,49 @@
import React, { memo } from 'react';
import { formatHostPort } from '../../domain/host';
import type { Host } from '../../types';
import { DistroAvatar } from '../DistroAvatar';
import { Tooltip, TooltipContent, TooltipTrigger } from '../ui/tooltip';
interface WorkspaceSidebarHostHeaderProps {
host: Host;
section?: string;
}
export const WorkspaceSidebarHostHeader = memo(function WorkspaceSidebarHostHeader({
host,
section = 'terminal-sidebar-host-header',
}: WorkspaceSidebarHostHeaderProps) {
const username = host.username || 'root';
const port = host.port || 22;
return (
<div
className="shrink-0 border-b border-border/50 bg-muted/20 px-3 py-1.5"
data-section={section}
>
<div className="flex items-center gap-2 min-w-0">
<DistroAvatar
host={host}
fallback={host.label.slice(0, 2).toUpperCase()}
size="sm"
className="h-5 w-5 rounded-sm shrink-0"
/>
<Tooltip>
<TooltipTrigger asChild>
<div className="min-w-0 flex-1 max-w-[calc(100%-1.75rem)] text-[11px] leading-5 truncate cursor-default">
<span className="font-medium">{host.label}</span>
<span className="mx-1 text-muted-foreground">·</span>
<span className="font-mono text-muted-foreground">
{username}@{host.hostname}:{port}
</span>
</div>
</TooltipTrigger>
<TooltipContent>
{`${host.label} · ${username}@${formatHostPort(host.hostname, port)}`}
</TooltipContent>
</Tooltip>
</div>
</div>
);
});

View File

@@ -0,0 +1,42 @@
import { canRetainIncompleteTerminalControlSequence } from "../terminal/runtime/terminalControlSequenceLimits";
// eslint-disable-next-line no-control-regex
const TERMINAL_OSC_SEQUENCE_REGEX = new RegExp("\\u001B\\][^\\u0007\\u001B]*(?:\\u0007|\\u001B\\\\)", "g");
// eslint-disable-next-line no-control-regex
const TERMINAL_ESCAPE_SEQUENCE_REGEX = new RegExp("\\u001B(?:[@-Z\\\\-_]|\\[[0-?]*[ -/]*[@-~])", "g");
// eslint-disable-next-line no-control-regex
const TERMINAL_CONTROL_CHAR_REGEX = new RegExp("[\\u0000-\\u0008\\u000B-\\u001F\\u007F]", "g");
// eslint-disable-next-line no-control-regex
const INCOMPLETE_ESCAPE_TAIL_REGEX = new RegExp("\\u001B(?:\\][^\\u0007\\u001B]*(?:\\u001B)?|\\[[0-?]*[ -/]*)?$");
const stripTerminalControlSequences = (data: string): string => data
.replace(TERMINAL_OSC_SEQUENCE_REGEX, "")
.replace(TERMINAL_ESCAPE_SEQUENCE_REGEX, "")
.replace(TERMINAL_CONTROL_CHAR_REGEX, "");
export class ChunkedEscapeFilter {
private pending = "";
feed(chunk: string): string {
const data = this.pending + chunk;
const tailMatch = INCOMPLETE_ESCAPE_TAIL_REGEX.exec(data);
if (tailMatch) {
const incomplete = tailMatch[0];
if (canRetainIncompleteTerminalControlSequence(incomplete)) {
this.pending = incomplete;
return stripTerminalControlSequences(data.slice(0, tailMatch.index));
}
// Fail open after the safety budget. This filter is only an activity
// classifier; retaining an unterminated OSC/DCS forever is worse than
// treating its payload as visible activity once.
this.pending = "";
return `${stripTerminalControlSequences(data.slice(0, tailMatch.index))}${incomplete}`;
}
this.pending = "";
return stripTerminalControlSequences(data);
}
}
export const hasNotifiableTerminalOutput = (filter: ChunkedEscapeFilter, chunk: string): boolean => (
filter.feed(chunk).trim().length > 0
);

View File

@@ -0,0 +1,34 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { resolveAiNoteArtifactPanelIntent } from './aiNoteArtifactPanelIntent.ts';
test('AI note artifact opens the notes side panel for the active tab and returns to AI', () => {
assert.deepEqual(
resolveAiNoteArtifactPanelIntent({
activeTabId: 'session-1',
currentPanel: 'ai',
noteId: 'note-1',
}),
{
kind: 'openNotesSidePanel',
tabId: 'session-1',
noteId: 'note-1',
returnPanel: 'ai',
},
);
});
test('AI note artifact falls back when there is no active side-panel tab', () => {
assert.deepEqual(
resolveAiNoteArtifactPanelIntent({
activeTabId: '',
currentPanel: null,
noteId: 'note-1',
}),
{
kind: 'fallback',
noteId: 'note-1',
},
);
});

View File

@@ -0,0 +1,34 @@
import type { SidePanelTab } from './TerminalLayerSupport';
export type AiNoteArtifactPanelIntent =
| {
kind: 'openNotesSidePanel';
tabId: string;
noteId: string;
returnPanel: SidePanelTab | null;
}
| {
kind: 'fallback';
noteId: string;
};
export function resolveAiNoteArtifactPanelIntent({
activeTabId,
currentPanel,
noteId,
}: {
activeTabId: string | null | undefined;
currentPanel: SidePanelTab | null;
noteId: string;
}): AiNoteArtifactPanelIntent {
if (!activeTabId) {
return { kind: 'fallback', noteId };
}
return {
kind: 'openNotesSidePanel',
tabId: activeTabId,
noteId,
returnPanel: currentPanel && currentPanel !== 'notes' ? currentPanel : null,
};
}

View File

@@ -0,0 +1,52 @@
import test from "node:test";
import assert from "node:assert/strict";
import { shouldProbeCommandCwd } from "./commandCwdProbe";
test("probes command cwd for session restore even when the SFTP panel is not visible", () => {
assert.equal(
shouldProbeCommandCwd({
restoreTerminalCwd: true,
visibleSftpHost: null,
sessionHost: { sftpFollowTerminalCwd: false },
globalSftpFollowTerminalCwd: false,
}),
true,
);
});
test("does not probe command cwd when neither session restore nor SFTP follow cwd needs it", () => {
assert.equal(
shouldProbeCommandCwd({
restoreTerminalCwd: false,
visibleSftpHost: null,
sessionHost: { sftpFollowTerminalCwd: true },
globalSftpFollowTerminalCwd: true,
}),
false,
);
});
test("probes command cwd for visible SFTP follow cwd using host override", () => {
assert.equal(
shouldProbeCommandCwd({
restoreTerminalCwd: false,
visibleSftpHost: { sftpFollowTerminalCwd: true },
sessionHost: { sftpFollowTerminalCwd: false },
globalSftpFollowTerminalCwd: false,
}),
true,
);
});
test("visible SFTP host override can disable command cwd probing", () => {
assert.equal(
shouldProbeCommandCwd({
restoreTerminalCwd: false,
visibleSftpHost: { sftpFollowTerminalCwd: false },
sessionHost: { sftpFollowTerminalCwd: true },
globalSftpFollowTerminalCwd: true,
}),
false,
);
});

View File

@@ -0,0 +1,28 @@
import { resolveHostFollowTerminalCwd, resolveSftpFollowTerminalCwdTargetHost } from "../../domain/sftpFollowTerminalCwd";
type FollowTerminalCwdHost = {
sftpFollowTerminalCwd?: boolean;
};
type ShouldProbeCommandCwdOptions = {
restoreTerminalCwd: boolean;
visibleSftpHost?: FollowTerminalCwdHost | null;
sessionHost?: FollowTerminalCwdHost | null;
globalSftpFollowTerminalCwd: boolean;
};
export const shouldProbeCommandCwd = ({
restoreTerminalCwd,
visibleSftpHost,
sessionHost,
globalSftpFollowTerminalCwd,
}: ShouldProbeCommandCwdOptions): boolean => {
if (restoreTerminalCwd) return true;
if (!visibleSftpHost) return false;
const followHost = resolveSftpFollowTerminalCwdTargetHost(visibleSftpHost, sessionHost);
return resolveHostFollowTerminalCwd(
followHost?.sftpFollowTerminalCwd,
globalSftpFollowTerminalCwd,
);
};

View File

@@ -0,0 +1,316 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import test from "node:test";
import type { TransferTask } from "../../domain/models";
import {
closeSidePanelPane,
collectSidePanelPanes,
createSidePanelLayout,
splitSidePanelPane,
} from "../../domain/sidePanelLayout.ts";
import {
SFTP_TRANSFER_HISTORY_RETENTION_MS,
countTransfersRetainingSftpOwner,
isTransferRetainingSftpOwner,
listInvalidSftpPanelTabIds,
listTerminalTabIdsWithRetainingTransfers,
resolveSftpActiveTransfersCount,
shouldCloseSftpSidePanel,
shouldClearSftpPanelAfterTransferChange,
shouldKeepSftpMountedAfterClose,
shouldKeepSftpBrowseSessionInteractive,
shouldMarkSftpPaneClosed,
shouldScheduleSftpRetainedPanelCleanup,
terminalSftpTransferOwnerId,
} from "./sftpPanelLifecycle.ts";
test("reopening focused SFTP does not close the other split panes", () => {
assert.equal(shouldCloseSftpSidePanel({
shouldKeepOpen: false,
isOpen: true,
isSameEndpoint: true,
paneCount: 2,
}), false);
assert.equal(shouldCloseSftpSidePanel({
shouldKeepOpen: false,
isOpen: true,
isSameEndpoint: true,
paneCount: 1,
}), true);
});
test("single-pane SFTP close uses the shared full-panel cleanup and stops opening work", () => {
const layerSource = readFileSync(new URL("../TerminalLayer.tsx", import.meta.url), "utf8");
assert.match(
layerSource,
/if \(isClosing\) \{\s*closeTerminalSidePanelTab\(tabId\);\s*return;\s*\}/,
);
assert.match(layerSource, /const handleCloseSidePanel = useCallback[\s\S]*closeTerminalSidePanelTab\(activeTabId\)/);
assert.match(layerSource, /closeTerminalSidePanelTab[\s\S]*setAiMountedTabIds[\s\S]*setNotesOpenNoteByTab/);
});
test("all SFTP reopen paths clear the split-close marker", () => {
const layerSource = readFileSync(new URL("../TerminalLayer.tsx", import.meta.url), "utf8");
const effectsSource = readFileSync(new URL("./useTerminalLayerEffects.ts", import.meta.url), "utf8");
assert.match(layerSource, /if \(targetPanel === 'sftp'\) \{\s*sftpPaneClosedTabIdsRef\.current\.delete\(tabId\)/);
assert.match(effectsSource, /const applySftpTargetOnTab[\s\S]*sftpPaneClosedTabIdsRef\.current\.delete\(tabId\)/);
assert.match(effectsSource, /navigation\.kind === 'local-copy-panel'[\s\S]*sftpPaneClosedTabIdsRef\.current\.delete\(currentTabId!\)/);
});
function task(
partial: Partial<TransferTask> & Pick<TransferTask, "id" | "status">,
): Pick<TransferTask, "id" | "status" | "parentTaskId" | "ownerId"> {
return {
id: partial.id,
status: partial.status,
parentTaskId: partial.parentTaskId,
ownerId: partial.ownerId,
};
}
test("closing the panel keeps SFTP mounted while a transfer is active", () => {
assert.equal(shouldKeepSftpMountedAfterClose({ activeTransfersCount: 1 }), true);
assert.equal(shouldKeepSftpMountedAfterClose({ activeTransfersCount: 3 }), true);
});
test("closing the panel keeps SFTP mounted while an external editor temp is open", () => {
assert.equal(shouldKeepSftpMountedAfterClose({
activeTransfersCount: 0,
activeExternalEditCount: 1,
}), true);
assert.equal(shouldKeepSftpMountedAfterClose({
activeTransfersCount: 0,
activeExternalEditCount: 0,
}), false);
});
test("closing an idle panel still releases its SFTP state", () => {
assert.equal(shouldKeepSftpMountedAfterClose({ activeTransfersCount: 0 }), false);
});
test("closing SFTP keeps another tool from reviving its browse session", () => {
assert.equal(shouldKeepSftpBrowseSessionInteractive({
sidePanelOpen: true,
retainedAfterClose: false,
sftpPaneClosed: false,
}), true);
assert.equal(shouldKeepSftpBrowseSessionInteractive({
sidePanelOpen: true,
retainedAfterClose: true,
sftpPaneClosed: false,
}), false);
assert.equal(shouldKeepSftpBrowseSessionInteractive({
sidePanelOpen: true,
retainedAfterClose: false,
sftpPaneClosed: true,
}), false);
assert.equal(shouldKeepSftpBrowseSessionInteractive({
sidePanelOpen: false,
retainedAfterClose: true,
sftpPaneClosed: false,
}), false);
});
test("closing only the SFTP split pane parks its browse session while the other pane stays open", () => {
let layout = createSidePanelLayout("sftp", "pane-sftp");
layout = splitSidePanelPane(layout, "pane-sftp", "history", "vertical", {
paneId: "pane-history",
splitId: "split-root",
}, 400);
const sftpPane = collectSidePanelPanes(layout.root).find((pane) => pane.tool === "sftp");
assert.ok(sftpPane);
const remaining = closeSidePanelPane(layout, sftpPane.id);
assert.ok(remaining);
assert.deepEqual(collectSidePanelPanes(remaining.root).map((pane) => pane.tool), ["history"]);
let sftpPaneClosed = shouldMarkSftpPaneClosed({
closingPaneTool: sftpPane.tool,
closesWholePanel: false,
});
assert.equal(shouldKeepSftpBrowseSessionInteractive({
sidePanelOpen: true,
retainedAfterClose: false,
sftpPaneClosed,
}), false);
sftpPaneClosed = false;
assert.equal(shouldKeepSftpBrowseSessionInteractive({
sidePanelOpen: true,
retainedAfterClose: false,
sftpPaneClosed,
}), true);
assert.equal(shouldMarkSftpPaneClosed({
closingPaneTool: sftpPane.tool,
closesWholePanel: true,
}), false);
});
test("a transfer retained by close keeps its history after completion", () => {
assert.equal(shouldClearSftpPanelAfterTransferChange({
activeTransfersCount: 0,
activeExternalEditCount: 0,
panelOpen: false,
retainedAfterClose: true,
}), false);
assert.equal(shouldScheduleSftpRetainedPanelCleanup({
activeTransfersCount: 0,
activeExternalEditCount: 0,
retainedAfterClose: true,
}), true);
assert.ok(SFTP_TRANSFER_HISTORY_RETENTION_MS > 0);
});
test("retained cleanup waits while an external editor temp is still open", () => {
assert.equal(shouldClearSftpPanelAfterTransferChange({
activeTransfersCount: 0,
activeExternalEditCount: 1,
panelOpen: false,
retainedAfterClose: false,
}), false);
assert.equal(shouldScheduleSftpRetainedPanelCleanup({
activeTransfersCount: 0,
activeExternalEditCount: 1,
retainedAfterClose: true,
}), false);
});
test("retained cleanup is scheduled even if close state has not committed yet", () => {
assert.equal(shouldScheduleSftpRetainedPanelCleanup({
activeTransfersCount: 0,
retainedAfterClose: true,
}), true);
});
test("closing a terminal tab finds every retained SFTP resource for cleanup", () => {
assert.deepEqual(listInvalidSftpPanelTabIds({
mountedTabIds: ["closed-tab", "open-tab"],
activeTransferTabIds: [],
retainedTabIds: ["closed-tab"],
openingTabIds: [],
cleanupTimerTabIds: ["closed-tab"],
validTabIds: new Set(["open-tab"]),
}), ["closed-tab"]);
});
test("closing a terminal tab keeps its hidden SFTP owner mounted until active transfers finish", () => {
assert.deepEqual(listInvalidSftpPanelTabIds({
mountedTabIds: ["closed-tab"],
activeTransferTabIds: ["closed-tab"],
retainedTabIds: [],
openingTabIds: [],
cleanupTimerTabIds: [],
validTabIds: new Set(),
}), []);
});
test("a reopening panel is not cleared before its open state commits", () => {
assert.equal(shouldClearSftpPanelAfterTransferChange({
activeTransfersCount: 0,
activeExternalEditCount: 0,
panelOpen: true,
retainedAfterClose: false,
}), false);
});
test("an unretained hidden idle panel can be released", () => {
assert.equal(shouldClearSftpPanelAfterTransferChange({
activeTransfersCount: 0,
activeExternalEditCount: 0,
panelOpen: false,
retainedAfterClose: false,
}), true);
});
test("terminal owner id is stable for retain lookups", () => {
assert.equal(terminalSftpTransferOwnerId("tab-1"), "terminal:tab-1");
});
test("store unfinished tasks retain the owner even when the panel report is still zero", () => {
const ownerId = terminalSftpTransferOwnerId("tab-a");
const storeTasks = [
task({ id: "t1", status: "transferring", ownerId }),
task({ id: "t2", status: "completed", ownerId }),
task({ id: "child", status: "transferring", ownerId, parentTaskId: "t1" }),
];
assert.equal(isTransferRetainingSftpOwner(storeTasks[0]!), true);
assert.equal(isTransferRetainingSftpOwner(storeTasks[1]!), false);
assert.equal(isTransferRetainingSftpOwner(storeTasks[2]!), false);
assert.equal(countTransfersRetainingSftpOwner(storeTasks, ownerId), 1);
assert.equal(resolveSftpActiveTransfersCount({
reportedCount: 0,
storeTasks,
ownerId,
}), 1);
assert.equal(shouldKeepSftpMountedAfterClose({
activeTransfersCount: resolveSftpActiveTransfersCount({
reportedCount: 0,
storeTasks,
ownerId,
}),
}), true);
});
test("paused and failed top-level tasks still retain the hidden SFTP owner", () => {
const ownerId = terminalSftpTransferOwnerId("tab-b");
const storeTasks = [
task({ id: "paused", status: "paused", ownerId }),
task({ id: "failed", status: "failed", ownerId }),
];
assert.equal(countTransfersRetainingSftpOwner(storeTasks, ownerId), 2);
});
test("listTerminalTabIdsWithRetainingTransfers only returns terminal owners with unfinished work", () => {
assert.deepEqual(listTerminalTabIdsWithRetainingTransfers([
task({ id: "a", status: "transferring", ownerId: "terminal:tab-1" }),
task({ id: "b", status: "completed", ownerId: "terminal:tab-2" }),
task({ id: "c", status: "queued", ownerId: "main-sftp-view" }),
task({ id: "d", status: "paused", ownerId: "terminal:tab-3" }),
]).sort(), ["tab-1", "tab-3"]);
});
test("terminal side panel reports transfer activity and uses store-backed retain on close", () => {
const layerSource = readFileSync(new URL("../TerminalLayer.tsx", import.meta.url), "utf8");
const panelSource = readFileSync(new URL("../SftpSidePanel.tsx", import.meta.url), "utf8");
const transferLifecycleSource = readFileSync(
new URL("../../application/state/sftp/useSftpTransferLifecycle.ts", import.meta.url),
"utf8",
);
const slotsSource = readFileSync(new URL("./terminalLayerSidePanelSlots.tsx", import.meta.url), "utf8");
const stateSource = readFileSync(new URL("../../application/state/useSftpState.ts", import.meta.url), "utf8");
assert.match(panelSource, /useReportSftpTransferOwnerActivity\(\{/);
// Unmount reports store-backed unfinished count — never force-zero while work lives.
assert.match(transferLifecycleSource, /sftpTransferCenterStore\.getSnapshot\(\)\.tasks/);
assert.match(transferLifecycleSource, /onChangeRef\.current\?\.\(unfinished\)/);
assert.doesNotMatch(panelSource, /useEffect\(\(\) => \(\) => \{\s*onActiveTransfersChange\?\.\(0\);\s*\}, \[onActiveTransfersChange\]\)/);
assert.match(panelSource, /interactive:\s*isBrowseSessionInteractive\(\{/);
assert.match(panelSource, /surfaceVisible:\s*isVisible/);
assert.match(panelSource, /ownerPanelOpen/);
assert.match(panelSource, /useEditorTabPresenceRevision\(\)/);
assert.match(panelSource, /hasOwnedEditorTab/);
assert.match(panelSource, /hasActiveExternalEdit/);
assert.match(slotsSource, /onActiveTransfersChange=\{handleActiveTransfersChange\}/);
assert.match(slotsSource, /onActiveExternalEditsChange=\{handleActiveExternalEditsChange\}/);
assert.match(panelSource, /onActiveExternalEditsChange/);
assert.match(layerSource, /resolveTabActiveTransfersCount/);
assert.match(layerSource, /terminalSftpTransferOwnerId/);
assert.match(layerSource, /listTerminalTabIdsWithRetainingTransfers/);
assert.match(layerSource, /shouldKeepSftpMountedAfterClose\(\{/);
assert.match(layerSource, /activeExternalEditCount/);
assert.match(layerSource, /sftpActiveExternalEditsByTabRef/);
assert.match(layerSource, /sftpRetainedAfterCloseTabIdsRef/);
assert.match(layerSource, /sftpRetainedCleanupTimersRef/);
// Hidden UI parks browse channels only after the side panel closes;
// tool switches keep browse warm via ownerPanelOpen. Transfers keep pool /
// leased sessions. External editor temps must also block park (closeSftp
// deletes those files).
assert.match(stateSource, /shouldParkBrowseSessions/);
assert.match(stateSource, /activeExternalEditCount/);
assert.match(stateSource, /takeBrowseSessionsForClose/);
assert.match(stateSource, /shouldRestoreBrowseSessions/);
assert.match(slotsSource, /sftpRetainedAfterCloseTabIdsRef/);
assert.match(slotsSource, /sftpPaneClosedTabIdsRef/);
assert.match(slotsSource, /shouldKeepSftpBrowseSessionInteractive\(/);
});

View File

@@ -0,0 +1,143 @@
import type { TransferTask } from "../../domain/models";
export const SFTP_TRANSFER_HISTORY_RETENTION_MS = 10 * 60 * 1000;
/** Terminal side-panel transfer owner id for a workspace/session tab. */
export function terminalSftpTransferOwnerId(tabId: string): string {
return `terminal:${tabId}`;
}
/**
* Tasks that must keep the hidden SFTP owner mounted (orchestration lives in
* useSftpState / useSftpTransfers). Matches activeTransfersCount semantics:
* everything except completed/cancelled top-level rows.
*/
export function isTransferRetainingSftpOwner(
task: Pick<TransferTask, "status" | "parentTaskId">,
): boolean {
if (task.parentTaskId) return false;
return task.status !== "completed" && task.status !== "cancelled";
}
export function countTransfersRetainingSftpOwner(
tasks: readonly Pick<TransferTask, "status" | "parentTaskId" | "ownerId">[],
ownerId: string,
): number {
return tasks.filter(
(task) => (task.ownerId ?? "") === ownerId && isTransferRetainingSftpOwner(task),
).length;
}
/**
* Prefer the live panel report, but never under-count unfinished work already
* published to the global transfer center (avoids close-before-layout-effect races).
*/
export function resolveSftpActiveTransfersCount(params: {
reportedCount: number;
storeTasks: readonly Pick<TransferTask, "status" | "parentTaskId" | "ownerId">[];
ownerId: string;
}): number {
const reported = Math.max(0, params.reportedCount);
const fromStore = countTransfersRetainingSftpOwner(params.storeTasks, params.ownerId);
return Math.max(reported, fromStore);
}
/** Tab ids whose terminal:* owner still has unfinished work in the store. */
export function listTerminalTabIdsWithRetainingTransfers(
tasks: readonly Pick<TransferTask, "status" | "parentTaskId" | "ownerId">[],
): string[] {
const tabIds = new Set<string>();
for (const task of tasks) {
if (!isTransferRetainingSftpOwner(task)) continue;
const ownerId = task.ownerId ?? "";
if (!ownerId.startsWith("terminal:")) continue;
tabIds.add(ownerId.slice("terminal:".length));
}
return [...tabIds];
}
export function shouldKeepSftpMountedAfterClose(params: {
activeTransfersCount: number;
/** External-editor temps still need the browse session (closeSftp deletes them). */
activeExternalEditCount?: number;
}): boolean {
return params.activeTransfersCount > 0
|| (params.activeExternalEditCount ?? 0) > 0;
}
/**
* A different side-panel tool keeps SFTP warm only when the SFTP owner was
* never closed. A retained-after-close mount is kept for transfers/editor
* cleanup, but its browse session must still be allowed to park.
*/
export function shouldKeepSftpBrowseSessionInteractive(params: {
sidePanelOpen: boolean;
retainedAfterClose: boolean;
sftpPaneClosed: boolean;
}): boolean {
return params.sidePanelOpen
&& !params.retainedAfterClose
&& !params.sftpPaneClosed;
}
export function shouldMarkSftpPaneClosed(params: {
closingPaneTool: string | null | undefined;
closesWholePanel: boolean;
}): boolean {
return !params.closesWholePanel && params.closingPaneTool === 'sftp';
}
export function shouldCloseSftpSidePanel(params: {
shouldKeepOpen: boolean;
isOpen: boolean;
isSameEndpoint: boolean;
paneCount: number;
}): boolean {
return !params.shouldKeepOpen
&& params.isOpen
&& params.isSameEndpoint
&& params.paneCount <= 1;
}
export function shouldClearSftpPanelAfterTransferChange(params: {
activeTransfersCount: number;
activeExternalEditCount?: number;
panelOpen: boolean;
retainedAfterClose: boolean;
}): boolean {
return params.activeTransfersCount <= 0
&& (params.activeExternalEditCount ?? 0) <= 0
&& !params.panelOpen
&& !params.retainedAfterClose;
}
export function shouldScheduleSftpRetainedPanelCleanup(params: {
activeTransfersCount: number;
activeExternalEditCount?: number;
retainedAfterClose: boolean;
}): boolean {
return params.activeTransfersCount <= 0
&& (params.activeExternalEditCount ?? 0) <= 0
&& params.retainedAfterClose;
}
export function listInvalidSftpPanelTabIds(params: {
mountedTabIds: Iterable<string>;
activeTransferTabIds: Iterable<string>;
retainedTabIds: Iterable<string>;
openingTabIds: Iterable<string>;
cleanupTimerTabIds: Iterable<string>;
validTabIds: ReadonlySet<string>;
}): string[] {
const activeTransferTabIds = new Set(params.activeTransferTabIds);
const trackedTabIds = new Set([
...params.mountedTabIds,
...activeTransferTabIds,
...params.retainedTabIds,
...params.openingTabIds,
...params.cleanupTimerTabIds,
]);
return [...trackedTabIds].filter((tabId) => (
!params.validTabIds.has(tabId) && !activeTransferTabIds.has(tabId)
));
}

View File

@@ -0,0 +1,85 @@
import assert from "node:assert/strict";
import test from "node:test";
import type { Host, TerminalSession } from "../../types";
import { resolveTerminalFontSizeUpdateTarget } from "./terminalFontSizeUpdate";
const host = (overrides: Partial<Host> = {}): Host => ({
id: "host-1",
label: "Host",
hostname: "example.com",
username: "alice",
group: "",
tags: [],
os: "linux",
protocol: "ssh",
...overrides,
});
const session = (overrides: Partial<TerminalSession> = {}): TerminalSession => ({
id: "session-1",
hostId: "host-1",
hostLabel: "Host",
hostname: "example.com",
username: "alice",
status: "connected",
protocol: "ssh",
...overrides,
});
test("ephemeral sessions keep font zoom on the session when the host is missing", () => {
assert.deepEqual(
resolveTerminalFontSizeUpdateTarget({
session: session({ ephemeralHost: true }),
sessionHost: host(),
rawHost: null,
}),
{ kind: "session" },
);
});
test("workspace sessions keep font zoom on the session", () => {
assert.deepEqual(
resolveTerminalFontSizeUpdateTarget({
session: session({ workspaceId: "workspace-1" }),
sessionHost: host(),
rawHost: host(),
}),
{ kind: "session" },
);
});
test("local sessions update the global font size", () => {
assert.deepEqual(
resolveTerminalFontSizeUpdateTarget({
session: session({ protocol: "local" }),
sessionHost: host({ id: "local-session-1", protocol: "local" }),
rawHost: null,
}),
{ kind: "global" },
);
});
test("saved remote hosts update the host override", () => {
const savedHost = host();
assert.deepEqual(
resolveTerminalFontSizeUpdateTarget({
session: session(),
sessionHost: savedHost,
rawHost: savedHost,
}),
{ kind: "host", host: savedHost },
);
});
test("in-memory ephemeral hosts keep font zoom on the session", () => {
assert.deepEqual(
resolveTerminalFontSizeUpdateTarget({
session: session(),
sessionHost: host({ ephemeral: true }),
rawHost: host({ ephemeral: true }),
}),
{ kind: "session" },
);
});

View File

@@ -0,0 +1,30 @@
import { isSavedVaultHost } from "../../domain/ephemeralHosts";
import type { Host, TerminalSession } from "../../types";
export type TerminalFontSizeUpdateTarget =
| { kind: "none" }
| { kind: "global" }
| { kind: "session" }
| { kind: "host"; host: Host };
export function resolveTerminalFontSizeUpdateTarget({
session,
sessionHost,
rawHost,
}: {
session?: TerminalSession;
sessionHost?: Host | null;
rawHost?: Host | null;
}): TerminalFontSizeUpdateTarget {
if (session?.workspaceId || session?.ephemeralHost) return { kind: "session" };
if (!sessionHost) return { kind: "none" };
const usesGlobalFontSize =
sessionHost.protocol === "local"
|| sessionHost.id?.startsWith("local-")
|| !rawHost;
if (usesGlobalFontSize) return { kind: "global" };
if (!isSavedVaultHost(rawHost)) return { kind: "session" };
return { kind: "host", host: rawHost };
}

View File

@@ -0,0 +1,33 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
canUseDirectSessionWriteFallback,
resolveFallbackSessionProtocol,
} from "./terminalLayerSessionRouting";
test("resolveFallbackSessionProtocol defaults missing orphan protocol to ssh", () => {
assert.equal(resolveFallbackSessionProtocol({}), "ssh");
});
test("resolveFallbackSessionProtocol preserves explicit local protocol", () => {
assert.equal(resolveFallbackSessionProtocol({ protocol: "local" }), "local");
});
test("canUseDirectSessionWriteFallback blocks restored disconnected sessions", () => {
assert.equal(
canUseDirectSessionWriteFallback({
status: "disconnected",
restoreState: "restored-disconnected",
}),
false,
);
});
test("canUseDirectSessionWriteFallback allows connected sessions", () => {
assert.equal(canUseDirectSessionWriteFallback({ status: "connected" }), true);
});
test("canUseDirectSessionWriteFallback preserves existing connecting fallback writes", () => {
assert.equal(canUseDirectSessionWriteFallback({ status: "connecting" }), true);
});

View File

@@ -0,0 +1,17 @@
import type { TerminalSession } from "../../types";
type SessionRoutingProtocol = Pick<TerminalSession, "protocol">;
type DirectWriteSessionState = Pick<TerminalSession, "status"> & {
restoreState?: TerminalSession["restoreState"];
};
export function resolveFallbackSessionProtocol(
session: SessionRoutingProtocol,
): NonNullable<TerminalSession["protocol"]> {
return session.protocol ?? "ssh";
}
export function canUseDirectSessionWriteFallback(session: DirectWriteSessionState): boolean {
return session.restoreState !== "restored-disconnected";
}

View File

@@ -0,0 +1,15 @@
import { cn } from '../../lib/utils';
export function sidePanelHiddenPanelClassName(hidden: boolean): string {
return cn(
'absolute inset-0 z-10',
hidden && 'hidden [content-visibility:hidden] [contain:strict]',
);
}
export function sidePanelHiddenNotesPanelClassName(hidden: boolean): string {
return cn(
'absolute inset-0 z-20 bg-background text-foreground',
hidden && 'hidden [content-visibility:hidden] [contain:strict]',
);
}

View File

@@ -0,0 +1,797 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import React, { memo, useCallback, useRef, useSyncExternalStore } from 'react';
import { createPortal } from 'react-dom';
import { activeTabStore } from '../../application/state/activeTabStore';
import { getSftpCurrentPathMemoryKey } from '../../application/state/sftp/sftpReopenLocation';
import {
getSidePanelLiveSnapshot,
SIDE_PANEL_INACTIVE_LIVE_SNAPSHOT,
subscribeSidePanelLiveSnapshot,
} from '../../application/state/sidePanelLiveStore';
import {
getEmptyNotesSnapshot,
getNotesSnapshot,
subscribeNotes,
subscribeNotesNoop,
useNotesStore,
} from '../../application/state/notesStore';
import {
getShellHistorySnapshot,
subscribeShellHistory,
} from '../../application/state/shellHistoryStore';
import { useScriptExecution } from '../../application/state/useScriptExecution';
import { useRemoteHistoryState } from '../../application/state/useRemoteHistoryState';
import { resolveSystemSidebarSession } from '../../domain/systemManager/resolveSystemSession';
import { shouldKeepTerminalBackgroundWorkActive } from '../../domain/terminalHibernate';
import { resolveTerminalFontFamilyId } from '../../infrastructure/config/fonts';
import type { Host, TerminalSession, Workspace } from '../../types';
import { SystemManagerSidePanel } from '../systemManager/SystemManagerSidePanel';
import { resolveSftpFollowTerminalCwdTargetHost } from '../../domain/sftpFollowTerminalCwd';
import { AI_PANEL_FORCE_HIDE_SHELL } from '../ai/aiPanelDiagnostics';
import type { SidePanelTab } from './TerminalLayerSupport';
import {
collectSidePanelPanes,
sidePanelLayoutHasTool,
type SidePanelLayout,
} from '../../domain/sidePanelLayout';
import { sidePanelHiddenNotesPanelClassName, sidePanelHiddenPanelClassName } from './terminalLayerSidePanelHiddenWrapper';
import { shouldKeepSftpBrowseSessionInteractive } from './sftpPanelLifecycle';
type SidePanelStableContext = Record<string, any> & {
sftpPaneClosedTabIdsRef: React.MutableRefObject<Set<string>>;
};
const navigatorPlatform = typeof navigator !== 'undefined' ? navigator.platform : '';
const EMPTY_VAULT_NOTES: never[] = [];
const EMPTY_VAULT_HOSTS: never[] = [];
const EMPTY_VAULT_SNIPPETS: never[] = [];
const EMPTY_SHELL_HISTORY = Object.freeze([]) as readonly never[];
const subscribeShellHistoryNoop = () => () => {};
const getEmptyShellHistorySnapshot = () => EMPTY_SHELL_HISTORY;
function useSidePanelLiveSnapshotForTab(tabId: string, subscribe: boolean) {
const retainedSnapshot = useRef({ tabId, snapshot: SIDE_PANEL_INACTIVE_LIVE_SNAPSHOT });
const getSnapshot = useCallback(() => {
if (!subscribe) return SIDE_PANEL_INACTIVE_LIVE_SNAPSHOT;
const snapshot = getSidePanelLiveSnapshot(true);
const snapshotTabId = snapshot.activeWorkspace?.id ?? snapshot.focusedSessionId;
// Tab visibility changes before the live publisher commits its next snapshot.
// Keep this panel's last context until its own terminal/workspace catches up.
if (snapshotTabId === tabId) retainedSnapshot.current = { tabId, snapshot };
return retainedSnapshot.current.tabId === tabId
? retainedSnapshot.current.snapshot
: SIDE_PANEL_INACTIVE_LIVE_SNAPSHOT;
}, [subscribe, tabId]);
return useSyncExternalStore(
(listener) => subscribeSidePanelLiveSnapshot(subscribe, listener),
getSnapshot,
getSnapshot,
);
}
function SidePanelSftpSlotInner({
tabId,
ctx,
isVisible,
ownerPanelOpen,
}: {
tabId: string;
ctx: SidePanelStableContext;
isVisible: boolean;
/** Side panel still open for this tab (may be showing another tool). */
ownerPanelOpen: boolean;
}) {
const live = useSidePanelLiveSnapshotForTab(tabId, isVisible);
const {
SftpSidePanel,
effectiveHosts,
hosts,
sessions,
keys,
identities,
knownHosts,
updateHosts,
handleAddKnownHost,
sftpDefaultViewMode,
sftpHostForTab,
sftpInitialLocationForTab,
sftpPendingUploadsForTab,
handleSftpInitialLocationApplied,
handleSftpCurrentPathChange,
handleSftpActiveTransfersChange,
handleSftpActiveExternalEditsChange,
handlePendingUploadHandled,
sftpDoubleClickBehavior,
sftpAutoSync,
sftpShowHiddenFiles,
sftpUseCompressedUpload,
hotkeyScheme,
keyBindings,
editorWordWrap,
setEditorWordWrap,
getTerminalCwd,
sftpFollowTerminalCwd,
setSftpFollowTerminalCwd,
refocusActiveTerminalSession,
terminalSettings,
} = ctx;
const storedSftpHost = sftpHostForTab.get(tabId) ?? null;
const panelActiveHost = isVisible
? (live.sftpActiveHost ?? storedSftpHost)
: storedSftpHost;
const panelActiveSessionId = isVisible ? live.activeTerminalSessionIdForSftp : null;
const panelFocusedSessionId = isVisible ? live.focusedSessionId : null;
// Only the head of the per-tab pending upload queue is surfaced at a time;
// the panel advances the queue by reporting each request as handled.
const pendingUpload = sftpPendingUploadsForTab.get(tabId)?.[0] ?? null;
const handleFollowTerminalCwdChange = useCallback((enabled: boolean, visibleHost?: Host | null) => {
const isActive = activeTabStore.getActiveTabId() === tabId;
const stored = (sftpHostForTab as Map<string, Host>).get(tabId) ?? null;
const snapshot = getSidePanelLiveSnapshot(isActive);
const activeHost = isActive ? (snapshot.sftpActiveHost ?? stored) : stored;
const targetHost = resolveSftpFollowTerminalCwdTargetHost(visibleHost, activeHost);
if (!targetHost?.id) {
setSftpFollowTerminalCwd(enabled);
return;
}
let updated = false;
const nextHosts = (hosts as Host[]).map((host) => {
if (host.id !== targetHost.id) return host;
updated = true;
return { ...host, sftpFollowTerminalCwd: enabled };
});
if (updated) {
updateHosts(nextHosts);
} else {
setSftpFollowTerminalCwd(enabled);
}
}, [hosts, sftpHostForTab, setSftpFollowTerminalCwd, tabId, updateHosts]);
const handleInitialLocationApplied = useCallback(
(location: { hostId: string; path: string }) => {
handleSftpInitialLocationApplied(tabId, location);
},
[handleSftpInitialLocationApplied, tabId],
);
const handlePendingUploadHandledForTab = useCallback(
(requestId: string) => {
handlePendingUploadHandled(tabId, requestId);
},
[handlePendingUploadHandled, tabId],
);
const handleCurrentPathChange = useCallback(
(location: { hostId: string; connectionKey: string; path: string }) => {
handleSftpCurrentPathChange(
getSftpCurrentPathMemoryKey({
tabId,
activeTerminalSessionIdForSftp: panelActiveSessionId,
focusedSessionId: panelFocusedSessionId,
}),
location,
);
},
[handleSftpCurrentPathChange, panelActiveSessionId, panelFocusedSessionId, tabId],
);
const handleActiveTransfersChange = useCallback(
(count: number) => {
handleSftpActiveTransfersChange(tabId, count);
},
[handleSftpActiveTransfersChange, tabId],
);
const handleActiveExternalEditsChange = useCallback(
(count: number) => {
handleSftpActiveExternalEditsChange(tabId, count);
},
[handleSftpActiveExternalEditsChange, tabId],
);
return (
<div className={sidePanelHiddenPanelClassName(!isVisible)}>
<SftpSidePanel
transferOwnerId={`terminal:${tabId}`}
hosts={effectiveHosts}
writableHosts={hosts}
sessions={sessions}
keys={keys}
identities={identities}
knownHosts={knownHosts}
updateHosts={updateHosts}
onAddKnownHost={handleAddKnownHost}
sftpDefaultViewMode={sftpDefaultViewMode}
activeHost={panelActiveHost}
activeSessionId={panelActiveSessionId}
focusedSessionId={panelFocusedSessionId}
initialLocation={isVisible ? (sftpInitialLocationForTab.get(tabId) ?? null) : null}
onInitialLocationApplied={handleInitialLocationApplied}
onCurrentPathChange={handleCurrentPathChange}
onActiveTransfersChange={handleActiveTransfersChange}
onActiveExternalEditsChange={handleActiveExternalEditsChange}
showWorkspaceHostHeader={isVisible && !!live.activeWorkspace}
isVisible={isVisible}
ownerPanelOpen={ownerPanelOpen}
renderOverlays={isVisible}
pendingUpload={pendingUpload}
onPendingUploadHandled={handlePendingUploadHandledForTab}
sftpDoubleClickBehavior={sftpDoubleClickBehavior}
sftpAutoSync={isVisible ? sftpAutoSync : false}
sftpShowHiddenFiles={sftpShowHiddenFiles}
sftpUseCompressedUpload={sftpUseCompressedUpload}
hotkeyScheme={hotkeyScheme}
keyBindings={keyBindings}
editorWordWrap={editorWordWrap}
setEditorWordWrap={setEditorWordWrap}
onGetTerminalCwd={getTerminalCwd}
activeTerminalCwd={isVisible ? live.activeTerminalCwd : null}
activeTerminalCwdTrusted={isVisible ? live.activeTerminalCwdTrusted : false}
sftpFollowTerminalCwd={sftpFollowTerminalCwd}
onSftpFollowTerminalCwdChange={handleFollowTerminalCwdChange}
onRequestTerminalFocus={refocusActiveTerminalSession}
terminalSettings={terminalSettings}
/>
</div>
);
}
export const SidePanelSftpSlot = memo(SidePanelSftpSlotInner);
SidePanelSftpSlot.displayName = 'SidePanelSftpSlot';
function SidePanelSystemSlotInner({
tabId,
ctx,
isTabActive,
isVisible,
isSelected,
}: {
tabId: string;
ctx: SidePanelStableContext;
isTabActive: boolean;
isVisible: boolean;
isSelected: boolean;
}) {
// When this tab is active, prefer live store so focus changes do not require
// workspaceById identity churn through the stable side-panel ctx.
const live = useSidePanelLiveSnapshotForTab(tabId, isVisible);
const sessions = ctx.sessions as TerminalSession[];
const sessionHostsMap = ctx.sessionHostsMap as Map<string, Host>;
const workspace = (ctx.workspaceById as Map<string, Workspace>).get(tabId);
const standaloneSession = sessions.find((session) => session.id === tabId);
const resolvedSession = resolveSystemSidebarSession(
sessions,
workspace,
workspace?.focusedSessionId,
standaloneSession,
);
const systemSession = (isVisible
? (live.activeTerminalSessionForSystem ?? resolvedSession)
: resolvedSession) ?? null;
const systemHost = (isVisible && live.activeSystemSessionHost)
? live.activeSystemSessionHost
: (systemSession ? sessionHostsMap.get(systemSession.id) ?? null : null);
const keepSystemWorkActive = isSelected
&& shouldKeepTerminalBackgroundWorkActive(
ctx.terminalSettings,
systemHost?.protocol,
isTabActive,
);
const {
refocusActiveTerminalSession,
snippets,
terminalSettings,
} = ctx;
return (
<div className={sidePanelHiddenPanelClassName(!isVisible)}>
<SystemManagerSidePanel
key={systemSession?.id ?? 'system-none'}
session={systemSession ?? null}
sessionHost={systemHost}
showWorkspaceHostHeader={isVisible && !!workspace}
isVisible={keepSystemWorkActive}
terminalSettings={terminalSettings}
snippets={snippets}
onRequestTerminalFocus={refocusActiveTerminalSession}
/>
</div>
);
}
export const SidePanelSystemSlot = memo(SidePanelSystemSlotInner);
SidePanelSystemSlot.displayName = 'SidePanelSystemSlot';
function SidePanelScriptsSlotInner({
tabId,
ctx,
isVisible,
}: {
tabId: string;
ctx: SidePanelStableContext;
isVisible: boolean;
}) {
const live = useSidePanelLiveSnapshotForTab(tabId, isVisible);
// Subscribe only while visible so retained scripts slots skip log thrash.
const { runs: scriptRuns } = useScriptExecution({ enabled: isVisible });
const {
ScriptsSidePanel,
snippets,
snippetPackages,
updateSnippets,
updateSnippetPackages,
handleSnippetFromPanel,
handleRunScriptFromPanel,
handleRunScriptOnWorkspace,
handleStartRecordingFromPanel,
handleStopScriptRun,
handlePauseScriptRun,
handleResumeScriptRun,
} = ctx;
return (
<div className={sidePanelHiddenPanelClassName(!isVisible)}>
<ScriptsSidePanel
snippets={snippets}
packages={snippetPackages}
onSnippetsChange={updateSnippets}
onPackagesChange={updateSnippetPackages}
onSnippetClick={handleSnippetFromPanel}
onRunScript={handleRunScriptFromPanel}
onRunScriptOnWorkspace={handleRunScriptOnWorkspace}
onStartRecording={handleStartRecordingFromPanel}
runs={scriptRuns as import('@/types/global/netcatty-bridge-script.d.ts').ScriptRun[]}
onStopRun={handleStopScriptRun}
onPauseRun={handlePauseScriptRun}
onResumeRun={handleResumeScriptRun}
focusedSessionId={live.focusedSessionId ?? undefined}
isVisible={isVisible}
/>
</div>
);
}
export const SidePanelScriptsSlot = memo(SidePanelScriptsSlotInner);
SidePanelScriptsSlot.displayName = 'SidePanelScriptsSlot';
function SidePanelThemeSlotInner({
tabId,
ctx,
isVisible,
}: {
tabId: string;
ctx: SidePanelStableContext;
isVisible: boolean;
}) {
// Only subscribe while the theme panel is visible — not merely tab-active —
// so cwd/focus live ticks do not thrash a retained ThemeSidePanel.
const live = useSidePanelLiveSnapshotForTab(tabId, isVisible);
const {
ThemeSidePanel,
followAppTerminalTheme,
terminalTheme,
terminalThemeId,
terminalFontFamilyId,
handleThemeChangeForFocusedSession,
handleThemeResetForFocusedSession,
handleFontFamilyChangeForFocusedSession,
handleFontFamilyResetForFocusedSession,
handleFontSizeChangeForFocusedSession,
handleFontSizeResetForFocusedSession,
handleFontWeightChangeForFocusedSession,
handleFontWeightResetForFocusedSession,
} = ctx;
return (
<div className={sidePanelHiddenPanelClassName(!isVisible)}>
<ThemeSidePanel
followAppTerminalTheme={followAppTerminalTheme}
currentThemeId={live.previewedOrVisibleThemeId}
globalThemeId={terminalThemeId ?? terminalTheme.id}
currentFontFamilyId={resolveTerminalFontFamilyId(live.focusedFontFamilyId, navigatorPlatform)}
globalFontFamilyId={resolveTerminalFontFamilyId(terminalFontFamilyId, navigatorPlatform)}
currentFontSize={live.focusedFontSize}
currentFontWeight={live.focusedFontWeight}
canResetTheme={followAppTerminalTheme ? false : live.focusedThemeOverridden}
canResetFontFamily={live.focusedFontFamilyOverridden}
canResetFontSize={live.focusedFontSizeOverridden}
canResetFontWeight={live.focusedFontWeightOverridden}
onThemeChange={handleThemeChangeForFocusedSession}
onThemeReset={handleThemeResetForFocusedSession}
onFontFamilyChange={handleFontFamilyChangeForFocusedSession}
onFontFamilyReset={handleFontFamilyResetForFocusedSession}
onFontSizeChange={handleFontSizeChangeForFocusedSession}
onFontSizeReset={handleFontSizeResetForFocusedSession}
onFontWeightChange={handleFontWeightChangeForFocusedSession}
onFontWeightReset={handleFontWeightResetForFocusedSession}
isVisible={isVisible}
/>
</div>
);
}
export const SidePanelThemeSlot = memo(SidePanelThemeSlotInner);
SidePanelThemeSlot.displayName = 'SidePanelThemeSlot';
function SidePanelNotesSlotInner({
tabId,
ctx,
isVisible,
}: {
tabId: string;
ctx: SidePanelStableContext;
isVisible: boolean;
}) {
const openNoteRequest = (ctx.notesOpenNoteByTab as Map<string, { noteId: string; requestId: number }>).get(tabId) ?? null;
// Gate subscription while Notes is hidden so vault edits (and the full-page
// notebook) do not re-render this retained side-panel mount.
const {
notes,
noteGroups,
updateNotes,
updateNoteGroups,
} = useNotesStore({ enabled: isVisible });
const {
NotesManager,
hosts,
handleOpenHostFromNotes,
} = ctx;
return (
<div
className={sidePanelHiddenNotesPanelClassName(!isVisible)}
data-section={isVisible ? 'terminal-notes-panel' : undefined}
>
<NotesManager
notes={notes}
noteGroups={noteGroups}
hosts={hosts}
onUpdateNotes={updateNotes}
onUpdateNoteGroups={updateNoteGroups}
onOpenHost={handleOpenHostFromNotes}
displayMode="sidebar"
isActive={isVisible}
openNoteId={openNoteRequest?.noteId ?? null}
openNoteRequestId={openNoteRequest?.requestId ?? null}
/>
</div>
);
}
export const SidePanelNotesSlot = memo(SidePanelNotesSlotInner);
SidePanelNotesSlot.displayName = 'SidePanelNotesSlot';
function SidePanelHistorySlotInner({
activeTabId,
ctx,
isVisible,
}: {
activeTabId: string | null;
ctx: SidePanelStableContext;
isVisible: boolean;
}) {
const live = useSidePanelLiveSnapshotForTab(activeTabId ?? '', isVisible);
// Own remote-history state here so fetch/loading does not re-render TerminalLayer.
const remoteHistory = useRemoteHistoryState();
// Gate store subscription while History is hidden so command appends do not
// re-render this retained mount (panel still mounts for fast reopen).
const shellHistory = useSyncExternalStore(
isVisible ? subscribeShellHistory : subscribeShellHistoryNoop,
isVisible ? getShellHistorySnapshot : getEmptyShellHistorySnapshot,
isVisible ? getShellHistorySnapshot : getEmptyShellHistorySnapshot,
);
const {
HistorySidePanel,
handleHistoryPaste,
handleHistoryDelete,
handleHistoryRun,
} = ctx;
if (!isVisible) return null;
return (
<div className="absolute inset-0 z-10">
<HistorySidePanel
focusedHost={live.focusedHost}
focusedSessionId={live.historySessionId}
state={remoteHistory.getState(live.focusedHost?.id, live.historySessionId)}
globalEntries={shellHistory as import('../../domain/models').ShellHistoryEntry[]}
onFetch={remoteHistory.fetch}
onDeleteGlobalEntry={handleHistoryDelete}
onPasteToTerminal={handleHistoryPaste}
onRunInTerminal={handleHistoryRun}
isVisible
/>
</div>
);
}
export const SidePanelHistorySlot = memo(SidePanelHistorySlotInner);
SidePanelHistorySlot.displayName = 'SidePanelHistorySlot';
function SidePanelAiSlotInner({
activeTabId,
ctx,
isVisible,
}: {
activeTabId: string | null;
ctx: SidePanelStableContext;
isVisible: boolean;
}) {
const {
AIChatPanelsHost,
AISidePanelStateRoot,
mountedAiTabIds,
aiContextsByTabId,
resolveAIExecutorContext,
pendingTerminalSelectionForAI,
handlePendingTerminalSelectionConsumed,
hosts,
snippets,
onOpenVaultNoteFromChat,
onOpenVaultHostFromChat,
onOpenVaultSectionFromChat,
onOpenVaultSnippetFromChat,
validAIScopeTargetIds,
} = ctx;
const workspaces = ctx.workspaces as Workspace[];
// Gate notes subscription while AI is hidden so note edits do not re-render
// retained AI mounts (panel still mounts for fast reopen).
const notesSnapshot = useSyncExternalStore(
isVisible ? subscribeNotes : subscribeNotesNoop,
isVisible ? getNotesSnapshot : getEmptyNotesSnapshot,
isVisible ? getNotesSnapshot : getEmptyNotesSnapshot,
);
const activeLayout = activeTabId
? (ctx.sidePanelLayouts as Map<string, SidePanelLayout>).get(activeTabId)
: null;
const hideVisibleShell = (
AI_PANEL_FORCE_HIDE_SHELL
&& isVisible
&& (!activeLayout || collectSidePanelPanes(activeLayout.root).length <= 1)
);
// Keep the AI state root mounted even with zero panel hosts so workspace
// merge/detach can seed and hand off scoped chats before orphan cleanup.
if (mountedAiTabIds.length === 0 || hideVisibleShell) {
return (
<AISidePanelStateRoot
validAIScopeTargetIds={validAIScopeTargetIds}
workspaces={workspaces}
>
{null}
</AISidePanelStateRoot>
);
}
// Only the visible AI panel needs vault catalogs for artifact navigation.
// Hidden retained panels keep session state without re-binding huge hosts/notes.
const injectVaultCatalog = isVisible;
const notes = injectVaultCatalog
? (notesSnapshot.notes as import('../../domain/models').VaultNote[])
: EMPTY_VAULT_NOTES;
return (
<AISidePanelStateRoot
validAIScopeTargetIds={validAIScopeTargetIds}
workspaces={workspaces}
>
<AIChatPanelsHost
mountedTabIds={mountedAiTabIds}
activeTabId={activeTabId}
activeSidePanelTab={isVisible ? 'ai' : null}
contextsByTabId={aiContextsByTabId}
resolveExecutorContext={resolveAIExecutorContext}
pendingTerminalSelection={pendingTerminalSelectionForAI}
onPendingTerminalSelectionConsumed={handlePendingTerminalSelectionConsumed}
notes={notes}
hosts={injectVaultCatalog ? hosts : EMPTY_VAULT_HOSTS}
snippets={injectVaultCatalog ? snippets : EMPTY_VAULT_SNIPPETS}
onOpenVaultNoteFromChat={onOpenVaultNoteFromChat}
onOpenVaultHostFromChat={onOpenVaultHostFromChat}
onOpenVaultSectionFromChat={onOpenVaultSectionFromChat}
onOpenVaultSnippetFromChat={onOpenVaultSnippetFromChat}
/>
</AISidePanelStateRoot>
);
}
export const SidePanelAiSlot = memo(SidePanelAiSlotInner);
SidePanelAiSlot.displayName = 'SidePanelAiSlot';
export function getFocusedPortalDescendant(
mountNode: HTMLElement,
activeElement: Element | null,
): HTMLElement | null {
return activeElement instanceof HTMLElement && mountNode.contains(activeElement)
? activeElement
: null;
}
export function movePersistentPortalNode(
mountNode: HTMLElement,
target: HTMLElement,
focusToRestore: HTMLElement | null,
) {
target.appendChild(mountNode);
if (focusToRestore && document.activeElement !== focusToRestore) {
focusToRestore.focus({ preventScroll: true });
}
}
function PersistentSidePanelPortal({
portalKey,
target,
children,
}: {
portalKey: string;
target: HTMLElement | null;
children: React.ReactNode;
}) {
const [mountNode] = React.useState(() => {
const node = document.createElement('div');
node.className = 'absolute inset-0 overflow-hidden';
node.dataset.sidePanelPortal = portalKey;
return node;
});
const focusRestoreRef = React.useRef<HTMLElement | null>(null);
// The React portal always targets the same detached node. Moving that node
// between a pane host and the hidden parking host preserves the mounted
// subtree (including active SFTP/AI state) instead of remounting it whenever
// the focused pane changes.
React.useLayoutEffect(() => {
if (!target) return;
const activeElement = document.activeElement;
const focusedDescendant = focusRestoreRef.current
?? getFocusedPortalDescendant(mountNode, activeElement);
movePersistentPortalNode(mountNode, target, focusedDescendant);
focusRestoreRef.current = null;
return () => {
if (mountNode.parentNode !== target) return;
const currentActiveElement = document.activeElement;
focusRestoreRef.current = getFocusedPortalDescendant(mountNode, currentActiveElement);
mountNode.remove();
};
}, [mountNode, target]);
return createPortal(children, mountNode, portalKey);
}
export function resolveSidePanelPortalTarget<T>(
isVisible: boolean,
paneHost: T | null | undefined,
parkingHost: T | null,
): T | null {
return isVisible ? (paneHost ?? parkingHost) : parkingHost;
}
export function SidePanelMountedContent({
ctx,
paneHosts,
parkingHost,
}: {
ctx: SidePanelStableContext;
paneHosts: ReadonlyMap<SidePanelTab, HTMLElement>;
parkingHost: HTMLElement | null;
}) {
const {
mountedSftpTabIds,
systemMountedTabIds,
scriptsMountedTabIds,
themeMountedTabIds,
notesMountedTabIds,
} = ctx;
const activeTabId = useSyncExternalStore(
activeTabStore.subscribe,
activeTabStore.getActiveTabId,
activeTabStore.getActiveTabId,
);
const layouts = ctx.sidePanelLayouts as Map<string, SidePanelLayout>;
const openTabs = ctx.sidePanelOpenTabs as Map<string, SidePanelTab>;
const retainedAfterCloseTabIdsRef = ctx.sftpRetainedAfterCloseTabIdsRef as
| React.MutableRefObject<ReadonlySet<string>>
| undefined;
const sftpPaneClosedTabIdsRef = ctx.sftpPaneClosedTabIdsRef;
const isSftpOwnerPanelOpen = (tabId: string) => shouldKeepSftpBrowseSessionInteractive({
sidePanelOpen: openTabs.has(tabId),
retainedAfterClose: retainedAfterCloseTabIdsRef?.current.has(tabId) ?? false,
sftpPaneClosed: sftpPaneClosedTabIdsRef.current.has(tabId),
});
const isToolVisible = (tabId: string, tool: SidePanelTab) => (
activeTabId === tabId && sidePanelLayoutHasTool(layouts.get(tabId), tool)
);
const portalTarget = (tabId: string, tool: SidePanelTab) => (
resolveSidePanelPortalTarget(isToolVisible(tabId, tool), paneHosts.get(tool), parkingHost)
);
const activeLayout = activeTabId ? layouts.get(activeTabId) : undefined;
const historyVisible = !!activeTabId && sidePanelLayoutHasTool(activeLayout, 'history');
const aiVisible = !!activeTabId && sidePanelLayoutHasTool(activeLayout, 'ai');
return (
<>
{mountedSftpTabIds.map((tabId: string) => (
<PersistentSidePanelPortal
key={`sftp-${tabId}`}
portalKey={`sftp-${tabId}`}
target={portalTarget(tabId, 'sftp')}
>
<SidePanelSftpSlot
tabId={tabId}
ctx={ctx}
isVisible={isToolVisible(tabId, 'sftp')}
ownerPanelOpen={isSftpOwnerPanelOpen(tabId)}
/>
</PersistentSidePanelPortal>
))}
{systemMountedTabIds.map((tabId: string) => {
const isSelected = sidePanelLayoutHasTool(layouts.get(tabId), 'system');
const isVisible = activeTabId === tabId && isSelected;
return (
<PersistentSidePanelPortal
key={`system-${tabId}`}
portalKey={`system-${tabId}`}
target={resolveSidePanelPortalTarget(isVisible, paneHosts.get('system'), parkingHost)}
>
<SidePanelSystemSlot
tabId={tabId}
ctx={ctx}
isTabActive={activeTabId === tabId}
isVisible={isVisible}
isSelected={isSelected}
/>
</PersistentSidePanelPortal>
);
})}
{scriptsMountedTabIds.map((tabId: string) => (
<PersistentSidePanelPortal
key={`scripts-${tabId}`}
portalKey={`scripts-${tabId}`}
target={portalTarget(tabId, 'scripts')}
>
<SidePanelScriptsSlot tabId={tabId} ctx={ctx} isVisible={isToolVisible(tabId, 'scripts')} />
</PersistentSidePanelPortal>
))}
<PersistentSidePanelPortal
portalKey="history-active"
target={resolveSidePanelPortalTarget(historyVisible, paneHosts.get('history'), parkingHost)}
>
<SidePanelHistorySlot activeTabId={activeTabId} ctx={ctx} isVisible={historyVisible} />
</PersistentSidePanelPortal>
{themeMountedTabIds.map((tabId: string) => (
<PersistentSidePanelPortal
key={`theme-${tabId}`}
portalKey={`theme-${tabId}`}
target={portalTarget(tabId, 'theme')}
>
<SidePanelThemeSlot tabId={tabId} ctx={ctx} isVisible={isToolVisible(tabId, 'theme')} />
</PersistentSidePanelPortal>
))}
{notesMountedTabIds.map((tabId: string) => (
<PersistentSidePanelPortal
key={`notes-${tabId}`}
portalKey={`notes-${tabId}`}
target={portalTarget(tabId, 'notes')}
>
<SidePanelNotesSlot tabId={tabId} ctx={ctx} isVisible={isToolVisible(tabId, 'notes')} />
</PersistentSidePanelPortal>
))}
<PersistentSidePanelPortal
portalKey="ai-host"
target={resolveSidePanelPortalTarget(aiVisible, paneHosts.get('ai'), parkingHost)}
>
<SidePanelAiSlot activeTabId={activeTabId} ctx={ctx} isVisible={aiVisible} />
</PersistentSidePanelPortal>
</>
);
}

View File

@@ -0,0 +1,404 @@
import test from "node:test";
import assert from "node:assert/strict";
import type { Workspace } from "../../types";
import {
terminalLayerFocusSidebarPropsEqual,
terminalLayerSidePanelCtxEqual,
terminalLayerSidePanelStableCtxEqual,
terminalLayerViewCtxEqual,
terminalLayerWorkspaceCtxEqual,
} from "./terminalLayerViewMemo.ts";
const workspace = (overrides: Partial<Workspace> = {}): Workspace => ({
id: "workspace-1",
title: "Workspace",
viewMode: "split",
focusedSessionId: "session-1",
focusSessionOrder: ["session-1", "session-2"],
root: {
id: "split-1",
type: "split",
direction: "vertical",
sizes: [1, 1],
children: [
{ id: "pane-1", type: "pane", sessionId: "session-1" },
{ id: "pane-2", type: "pane", sessionId: "session-2" },
],
},
...overrides,
});
const cloneWorkspace = (value: Workspace): Workspace => JSON.parse(JSON.stringify(value));
test("terminal layer memo skips equivalent active workspace objects", () => {
const prevWorkspace = workspace();
const nextWorkspace = cloneWorkspace(prevWorkspace);
const baseCtx = {
activeWorkspace: prevWorkspace,
activeResizers: [
{
id: "split-1-0",
splitId: "split-1",
index: 0,
direction: "vertical",
rect: { x: 10, y: 0, w: 4, h: 100 },
splitArea: { w: 200, h: 100 },
},
],
draggingSessionId: null,
workspaceRectsById: new Map([
[
"workspace-1",
{
"session-1": { x: 0, y: 0, w: 100, h: 100 },
"session-2": { x: 100, y: 0, w: 100, h: 100 },
},
],
]),
};
assert.equal(
terminalLayerWorkspaceCtxEqual(
baseCtx,
{ ...baseCtx, activeWorkspace: nextWorkspace },
),
true,
);
assert.equal(
terminalLayerViewCtxEqual(
baseCtx,
{ ...baseCtx, activeWorkspace: nextWorkspace },
),
true,
);
});
test("terminal layer memo ignores activeWorkspace-only tab flips (live store owns them)", () => {
const prevWorkspace = workspace();
const nextWorkspace = cloneWorkspace(prevWorkspace);
nextWorkspace.root = {
...nextWorkspace.root,
type: "split",
sizes: [2, 1],
};
// activeWorkspace is read from sidePanelLiveStore in WorkspaceSection; ctx
// equality must not force a full layer rebuild solely because the active
// workspace object identity/root changed on a tab switch.
assert.equal(
terminalLayerWorkspaceCtxEqual(
{ activeWorkspace: prevWorkspace },
{ activeWorkspace: nextWorkspace },
),
true,
);
assert.equal(
terminalLayerViewCtxEqual(
{ activeWorkspace: prevWorkspace },
{ activeWorkspace: nextWorkspace },
),
true,
);
});
test("terminal layer memo re-renders when active resizers change", () => {
const base = {
activeResizers: [
{
id: "split-1-0",
splitId: "split-1",
index: 0,
direction: "vertical",
rect: { x: 10, y: 0, w: 4, h: 100 },
splitArea: { w: 200, h: 100 },
},
],
};
const next = {
activeResizers: [
{
id: "split-1-0",
splitId: "split-1",
index: 0,
direction: "vertical",
rect: { x: 40, y: 0, w: 4, h: 100 },
splitArea: { w: 200, h: 100 },
},
],
};
assert.equal(terminalLayerWorkspaceCtxEqual(base, next), false);
});
test("terminal layer side panel stable ctx ignores linked terminal cwd changes", () => {
const baseCtx = {
mountedSftpTabIds: ["workspace-1"],
sidePanelOpenTabs: new Map([["workspace-1", "sftp"]]),
activeTerminalCwd: "/home/user",
sftpFollowTerminalCwd: true,
};
assert.equal(
terminalLayerSidePanelStableCtxEqual(
baseCtx,
{ ...baseCtx, activeTerminalCwd: "/home/user/project" },
),
true,
);
assert.equal(
terminalLayerSidePanelCtxEqual(
baseCtx,
{ ...baseCtx, activeTerminalCwd: "/home/user/project" },
),
false,
);
});
test("terminal layer side panel stable ctx re-renders when SFTP-relevant session fields change", () => {
const baseCtx = {
mountedSftpTabIds: ["workspace-1"],
sidePanelOpenTabs: new Map([["workspace-1", "sftp"]]),
sessions: [{ id: "s1", hostId: "h1", protocol: "ssh", status: "connecting" }],
};
assert.equal(
terminalLayerSidePanelStableCtxEqual(
baseCtx,
{
...baseCtx,
sessions: [{ id: "s1", hostId: "h1", protocol: "ssh", status: "connected" }],
},
),
false,
);
});
test("terminal layer side panel stable ctx ignores session title-only updates", () => {
const baseCtx = {
mountedSftpTabIds: ["workspace-1"],
sidePanelOpenTabs: new Map([["workspace-1", "sftp"]]),
sessions: [{
id: "s1",
hostId: "h1",
protocol: "ssh",
status: "connected",
dynamicTitle: "old",
}],
};
assert.equal(
terminalLayerSidePanelStableCtxEqual(
baseCtx,
{
...baseCtx,
sessions: [{
id: "s1",
hostId: "h1",
protocol: "ssh",
status: "connected",
dynamicTitle: "new title from OSC",
}],
},
),
true,
);
});
test("terminal layer side panel stable ctx re-renders when session tab ownership changes", () => {
const baseCtx = {
sessions: [{ id: "s1", hostId: "h1", protocol: "local", status: "connected", workspaceId: "ws-1" }],
};
assert.equal(
terminalLayerSidePanelStableCtxEqual(
baseCtx,
{
...baseCtx,
sessions: [{ id: "s1", hostId: "h1", protocol: "local", status: "connected", workspaceId: "ws-2" }],
},
),
false,
);
});
test("terminal layer side panel stable ctx tracks session hosts and workspaces", () => {
const baseCtx = {
sessionHostsMap: new Map([["s1", { protocol: "ssh" }]]),
workspaceById: new Map([["ws-1", workspace()]]),
};
assert.equal(
terminalLayerSidePanelStableCtxEqual(baseCtx, {
...baseCtx,
sessionHostsMap: new Map([["s1", { protocol: "local" }]]),
}),
false,
);
// Focus-only workspace identity changes must not bust stable side-panel equal
// (System/SFTP follow focus via sidePanelLiveStore).
assert.equal(
terminalLayerSidePanelStableCtxEqual(baseCtx, {
...baseCtx,
workspaceById: new Map([["ws-1", workspace({ focusedSessionId: "session-2" })]]),
}),
true,
);
assert.equal(
terminalLayerSidePanelStableCtxEqual(baseCtx, {
...baseCtx,
workspaceById: new Map([["ws-1", workspace({ title: "Renamed" })]]),
}),
false,
);
});
test("terminal layer side panel stable ctx re-renders when session transport flags change", () => {
const baseCtx = {
mountedSftpTabIds: ["workspace-1"],
sidePanelOpenTabs: new Map([["workspace-1", "sftp"]]),
sessions: [{ id: "s1", hostId: "h1", protocol: "ssh", status: "connected" }],
};
assert.equal(
terminalLayerSidePanelStableCtxEqual(
baseCtx,
{
...baseCtx,
sessions: [{
id: "s1",
hostId: "h1",
protocol: "ssh",
status: "connected",
moshEnabled: true,
}],
},
),
false,
);
});
test("terminal layer side panel live equal still tracks cwd; view ignores live cwd", () => {
const baseCtx = {
mountedSftpTabIds: ["workspace-1"],
activeTerminalCwd: "/home/user",
sftpFollowTerminalCwd: true,
};
// Live equal still sees cwd (for diagnostic / full side-panel equal helpers).
assert.equal(
terminalLayerSidePanelCtxEqual(
baseCtx,
{ ...baseCtx, activeTerminalCwd: "/home/user/project" },
),
false,
);
// View equal uses stable keys only — cwd flows through sidePanelLiveStore.
assert.equal(
terminalLayerViewCtxEqual(
baseCtx,
{ ...baseCtx, activeTerminalCwd: "/home/user/project" },
),
true,
);
});
test("terminal layer side panel re-renders when follow terminal cwd setting changes", () => {
const baseCtx = {
mountedSftpTabIds: ["workspace-1"],
activeTerminalCwd: "/home/user",
sftpFollowTerminalCwd: false,
};
assert.equal(
terminalLayerSidePanelCtxEqual(
baseCtx,
{ ...baseCtx, sftpFollowTerminalCwd: true },
),
false,
);
assert.equal(
terminalLayerViewCtxEqual(
baseCtx,
{ ...baseCtx, sftpFollowTerminalCwd: true },
),
false,
);
});
test("terminal layer side panel stable ctx re-renders when knownHosts change", () => {
const baseCtx = {
knownHosts: [{ id: "kh-1", hostname: "a.example", port: 22 }],
};
assert.equal(
terminalLayerSidePanelStableCtxEqual(
baseCtx,
{ ...baseCtx, knownHosts: [{ id: "kh-2", hostname: "b.example", port: 22 }] },
),
false,
);
});
test("terminal layer side panel re-renders when vault note open callback changes", () => {
const baseCtx = {
mountedAiTabIds: ["workspace-1"],
onOpenVaultNoteFromChat: () => {},
};
assert.equal(
terminalLayerSidePanelCtxEqual(
baseCtx,
{ ...baseCtx, onOpenVaultNoteFromChat: () => {} },
),
false,
);
});
test("terminal layer focus sidebar re-renders when dynamic tab title mode changes", () => {
const baseCtx = {
isFocusMode: true,
activeWorkspace: workspace(),
focusedSessionId: "session-1",
resolvedPreviewTheme: {},
sessionHostsMap: new Map(),
sessions: [],
terminalSettings: { dynamicTabTitleMode: "agent" },
};
assert.equal(
terminalLayerFocusSidebarPropsEqual(
baseCtx,
{ ...baseCtx, terminalSettings: { dynamicTabTitleMode: "off" } },
),
false,
);
assert.equal(
terminalLayerViewCtxEqual(
baseCtx,
{ ...baseCtx, terminalSettings: { dynamicTabTitleMode: "off" } },
),
false,
);
});
test("terminal layer focus sidebar re-renders when host-append handler changes", () => {
const baseCtx = {
isFocusMode: true,
activeWorkspace: workspace(),
focusedSessionId: "session-1",
resolvedPreviewTheme: {},
sessionHostsMap: new Map(),
sessions: [],
terminalSettings: { dynamicTabTitleMode: "agent" },
onAppendHostToWorkspace: undefined as
| ((workspaceId: string, hostId: string) => void)
| undefined,
};
assert.equal(
terminalLayerFocusSidebarPropsEqual(
baseCtx,
{ ...baseCtx, onAppendHostToWorkspace: () => {} },
),
false,
);
});

View File

@@ -0,0 +1,511 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import { aiPanelContextsEqual } from '../../domain/aiPanelContextsEqual';
import { sftpPickerSessionsEqual } from '../../domain/sftpConnectedHosts';
type Ctx = Record<string, any>;
function eq(prev: Ctx, next: Ctx, key: string): boolean {
return prev[key] === next[key];
}
function rectEqual(a: any, b: any): boolean {
if (a === b) return true;
if (!a || !b) return false;
return a.x === b.x && a.y === b.y && a.w === b.w && a.h === b.h;
}
function rectRecordEqual(a: any, b: any): boolean {
if (a === b) return true;
if (!a || !b) return false;
const aKeys = Object.keys(a);
const bKeys = Object.keys(b);
if (aKeys.length !== bKeys.length) return false;
for (const key of aKeys) {
if (!rectEqual(a[key], b[key])) return false;
}
return true;
}
function workspaceRectsByIdEqual(a: any, b: any): boolean {
if (a === b) return true;
if (!(a instanceof Map) || !(b instanceof Map)) return false;
if (a.size !== b.size) return false;
for (const [workspaceId, rects] of a) {
if (!rectRecordEqual(rects, b.get(workspaceId))) return false;
}
return true;
}
function resizerHandleEqual(a: any, b: any): boolean {
if (a === b) return true;
if (!a || !b) return false;
return a.id === b.id
&& a.splitId === b.splitId
&& a.index === b.index
&& a.direction === b.direction
&& rectEqual(a.rect, b.rect)
&& rectEqual(a.splitArea, b.splitArea);
}
function resizerHandlesEqual(a: any, b: any): boolean {
if (a === b) return true;
if (!Array.isArray(a) || !Array.isArray(b)) return false;
if (a.length !== b.length) return false;
for (let i = 0; i < a.length; i += 1) {
if (!resizerHandleEqual(a[i], b[i])) return false;
}
return true;
}
function arrayEqual(a: any, b: any): boolean {
if (a === b) return true;
if (!Array.isArray(a) || !Array.isArray(b)) return false;
if (a.length !== b.length) return false;
for (let i = 0; i < a.length; i += 1) {
if (a[i] !== b[i]) return false;
}
return true;
}
function workspaceNodeEqual(a: any, b: any): boolean {
if (a === b) return true;
if (!a || !b) return false;
if (a.id !== b.id || a.type !== b.type) return false;
if (a.type === 'pane') {
return a.sessionId === b.sessionId;
}
if (a.direction !== b.direction) return false;
if (!arrayEqual(a.sizes, b.sizes)) return false;
if (!Array.isArray(a.children) || !Array.isArray(b.children)) return false;
if (a.children.length !== b.children.length) return false;
for (let i = 0; i < a.children.length; i += 1) {
if (!workspaceNodeEqual(a.children[i], b.children[i])) return false;
}
return true;
}
function activeWorkspaceEqual(a: any, b: any): boolean {
if (a === b) return true;
if (!a || !b) return false;
return a.id === b.id
&& a.title === b.title
&& a.viewMode === b.viewMode
&& a.focusedSessionId === b.focusedSessionId
&& a.snippetId === b.snippetId
&& arrayEqual(a.focusSessionOrder, b.focusSessionOrder)
&& workspaceNodeEqual(a.root, b.root);
}
function terminalThemeEqual(prev: Ctx, next: Ctx, key: string): boolean {
if (key !== 'terminalTheme') return prev[key] === next[key];
const a = prev.terminalTheme;
const b = next.terminalTheme;
if (a === b) return true;
if (!a || !b) return false;
return a.id === b.id
&& a.colors.background === b.colors.background
&& a.colors.foreground === b.colors.foreground
&& a.colors.cursor === b.colors.cursor;
}
function workspaceCtxKeyEqual(prev: Ctx, next: Ctx, key: string): boolean {
if (key === 'computeSplitHint' || key === 'handleWorkspaceDrop') {
if (!prev.draggingSessionId && !next.draggingSessionId) return true;
}
if (key === 'activeWorkspace') {
return activeWorkspaceEqual(prev.activeWorkspace, next.activeWorkspace);
}
if (key === 'workspaceRectsById') {
return workspaceRectsByIdEqual(prev.workspaceRectsById, next.workspaceRectsById);
}
if (key === 'activeResizers') {
return resizerHandlesEqual(prev.activeResizers, next.activeResizers);
}
if (key === 'terminalTheme') {
return terminalThemeEqual(prev, next, key);
}
return prev[key] === next[key];
}
function scriptRunsEqual(a: any, b: any): boolean {
if (a === b) return true;
if (!Array.isArray(a) || !Array.isArray(b)) return false;
if (a.length !== b.length) return false;
for (let i = 0; i < a.length; i += 1) {
const prevRun = a[i];
const nextRun = b[i];
if (prevRun === nextRun) continue;
if (!prevRun || !nextRun) return false;
if (
prevRun.runId !== nextRun.runId
|| prevRun.status !== nextRun.status
|| prevRun.stepIndex !== nextRun.stepIndex
|| prevRun.waitingFor !== nextRun.waitingFor
|| prevRun.error !== nextRun.error
|| prevRun.logs?.length !== nextRun.logs?.length
) {
return false;
}
}
return true;
}
function sidePanelCtxKeyEqual(prev: Ctx, next: Ctx, key: string): boolean {
if (key === 'scriptRuns') {
return scriptRunsEqual(prev.scriptRuns, next.scriptRuns);
}
if (key === 'activeWorkspace') {
return activeWorkspaceEqual(prev.activeWorkspace, next.activeWorkspace);
}
if (key === 'terminalTheme') {
return terminalThemeEqual(prev, next, key);
}
if (key === 'sessions') {
// Connected picker and retained system panels need transport and tab ownership,
// while title/cwd churn can still be ignored.
if (prev.sessions === next.sessions) return true;
if (!sftpPickerSessionsEqual(prev.sessions, next.sessions)) return false;
if (!Array.isArray(prev.sessions) || !Array.isArray(next.sessions)) return false;
return prev.sessions.every((session: any, index: number) => (
session.workspaceId === next.sessions[index]?.workspaceId
));
}
if (key === 'workspaceById') {
// Focus-only workspace map identity changes must not invalidate every mounted
// side-panel slot; System reads focused session from sidePanelLiveStore.
return sidePanelWorkspaceByIdEqual(prev.workspaceById, next.workspaceById);
}
if (key === 'aiContextsByTabId') {
return aiPanelContextsEqual(prev.aiContextsByTabId, next.aiContextsByTabId);
}
return prev[key] === next[key];
}
function sidePanelWorkspaceByIdEqual(a: any, b: any): boolean {
if (a === b) return true;
if (!(a instanceof Map) || !(b instanceof Map)) return false;
if (a.size !== b.size) return false;
for (const [workspaceId, prevWorkspace] of a) {
const nextWorkspace = b.get(workspaceId);
if (!nextWorkspace) return false;
if (prevWorkspace === nextWorkspace) continue;
if (prevWorkspace.id !== nextWorkspace.id) return false;
if (prevWorkspace.title !== nextWorkspace.title) return false;
if (prevWorkspace.viewMode !== nextWorkspace.viewMode) return false;
if (prevWorkspace.snippetId !== nextWorkspace.snippetId) return false;
if (!workspaceNodeEqual(prevWorkspace.root, nextWorkspace.root)) return false;
}
return true;
}
// Live fields that are also published via sidePanelLiveStore (or dedicated
// stores like scriptRunsStore). Slots subscribe to those stores directly, so
// TerminalLayerView must NOT re-render on these ticks — only chrome-stable keys.
const SIDE_PANEL_LIVE_CTX_KEYS = [
'activeTerminalSessionForSystem',
'activeSystemSessionHost',
'focusedHost',
'focusedSessionId',
'historySessionId',
'resolvedPreviewTheme',
'previewedOrVisibleThemeId',
'sftpActiveHost',
'activeTerminalSessionIdForSftp',
'activeTerminalCwd',
'activeTerminalCwdTrusted',
'activeWorkspace',
'focusedFontFamilyId',
'focusedFontFamilyOverridden',
'focusedFontSize',
'focusedFontSizeOverridden',
'focusedFontWeight',
'focusedFontWeightOverridden',
'focusedThemeOverridden',
] as const;
const SIDE_PANEL_STABLE_CTX_KEYS = [
'mountedSftpTabIds',
'mountedAiTabIds',
'notesMountedTabIds',
'notesOpenNoteByTab',
'scriptsMountedTabIds',
'systemMountedTabIds',
'themeMountedTabIds',
'handleHistoryPaste',
'handleHistoryDelete',
'handleHistoryRun',
'handleOpenHistory',
'HistorySidePanel',
'History',
'sidePanelWidth',
'sidePanelPosition',
'sidePanelOpenTabs',
'sidePanelLayouts',
'sftpHostForTab',
'sftpPaneClosedTabIdsRef',
'sftpRetainedAfterCloseTabIdsRef',
'effectiveHosts',
'hosts',
// SFTP Connected picker reads live terminal sessions from stable ctx.
'sessions',
'sessionHostsMap',
'workspaceById',
'keys',
'identities',
'knownHosts',
'updateHosts',
'handleAddKnownHost',
'updateSnippets',
'updateSnippetPackages',
'sftpDefaultViewMode',
'sftpInitialLocationForTab',
'sftpPendingUploadsForTab',
'handleSftpCurrentPathChange',
'handleSftpActiveTransfersChange',
'handleSftpActiveExternalEditsChange',
'sftpDoubleClickBehavior',
'sftpAutoSync',
'sftpShowHiddenFiles',
'sftpUseCompressedUpload',
'sftpFollowTerminalCwd',
'setSftpFollowTerminalCwd',
'hotkeyScheme',
'keyBindings',
'editorWordWrap',
'setEditorWordWrap',
'getTerminalCwd',
'refocusActiveTerminalSession',
'terminalSettings',
'snippets',
'snippetPackages',
'handleSnippetFromPanel',
'handleRunScriptFromPanel',
'handleRunScriptOnWorkspace',
'handleStartRecordingFromPanel',
'handleStopScriptRun',
'handlePauseScriptRun',
'handleResumeScriptRun',
'followAppTerminalTheme',
'terminalTheme',
'terminalThemeId',
'terminalFontFamilyId',
'handleThemeChangeForFocusedSession',
'handleThemeResetForFocusedSession',
'handleFontFamilyChangeForFocusedSession',
'handleFontFamilyResetForFocusedSession',
'handleFontSizeChangeForFocusedSession',
'handleFontSizeResetForFocusedSession',
'handleFontWeightChangeForFocusedSession',
'handleFontWeightResetForFocusedSession',
'aiContextsByTabId',
'resolveAIExecutorContext',
'pendingTerminalSelectionForAI',
'handlePendingTerminalSelectionConsumed',
'setSidePanelWidth',
'persistSidePanelWidth',
'handleToggleSftpFromBar',
'handleOpenScripts',
'handleOpenTheme',
'handleOpenAI',
'handleOpenNotes',
'handleBackFromNotes',
'handleOpenHostFromNotes',
'handleOpenSystem',
'handleCloseSidePanel',
'handleFocusSidePanelPane',
'handleMagnifySidePanelPane',
'handleRestoreMagnifiedPane',
'handleSplitSidePanelPane',
'handleCloseSidePanelPane',
'handleResizeSidePanelSplit',
'setSidePanelPosition',
'handleSftpInitialLocationApplied',
'handlePendingUploadHandled',
'validAIScopeTargetIds',
'magnifiedPane',
'AISidePanelStateRoot',
'NotesManager',
// notes / noteGroups / updateNotes / updateNoteGroups come from notesStore.
'onOpenVaultNoteFromChat',
'onOpenVaultHostFromChat',
'onOpenVaultSectionFromChat',
'onOpenVaultSnippetFromChat',
't',
] as const;
const WORKSPACE_CTX_KEYS = [
'activeTabId',
'workspaceInnerRef',
'workspaceOuterRef',
'workspaceOverlayRef',
'draggingSessionId',
'isFocusMode',
'dropHint',
'setDropHint',
'computeSplitHint',
'handleWorkspaceDrop',
'sessions',
'sessionHostsMap',
'sessionChainHostsMap',
'sessionSudoAutofillPasswordsMap',
'sessionSudoAutofillCandidatesMap',
'workspaceById',
'workspaceRectsById',
'isTerminalLayerVisible',
'magnifiedPane',
'handleMagnifyTerminalPane',
'handleTerminalPaneInteraction',
'handleRestoreMagnifiedPane',
'workspaceFocusHandlersRef',
'workspaceBroadcastHandlersRef',
'splitHorizontalHandlersRef',
'splitVerticalHandlersRef',
'resolveSessionAppearance',
'hostMap',
'keys',
'identities',
'snippets',
'knownHosts',
'terminalFontFamilyId',
'fontSize',
'terminalTheme',
'followAppTerminalTheme',
// accentMode / customAccent come from appearanceChromeStore (Terminal leaf).
'terminalSettings',
'hotkeyScheme',
'disableTerminalFontZoom',
'restoreTerminalCwd',
'keyBindings',
'resizing',
'isComposeBarOpen',
'sessionLogConfig',
'sshDebugLogsEnabled',
'onHotkeyAction',
'handleTerminalFontSizeChange',
'handleOpenSftp',
'handleTerminalCwdChange',
'handleTerminalTitleChange',
'handleTerminalBell',
'handleTerminalOutput',
'handleTerminalContextReaderChange',
'handleOpenScripts',
'handleOpenHistory',
'handleOpenSystem',
'handleOpenTheme',
'handleCloseSession',
'handleStatusChange',
'handleSessionExit',
'handleTerminalDataCapture',
'handleOsDetected',
'handleUpdateHost',
'handleAddKnownHost',
'handleCommandExecuted',
'handleCommandSubmitted',
'onSetWorkspaceFocusedSession',
'onSplitSession',
'isBroadcastEnabled',
'handleBroadcastInput',
'handleBroadcastInterruptPriorityChange',
'handleToggleWorkspaceComposeBar',
'handleSnippetExecutorChange',
'handleProgrammaticCommandLogRewriteChange',
'handleAddSelectionToAI',
'activeResizers',
// activeWorkspace / focusedSessionId: TerminalLayerWorkspaceSection reads
// these from sidePanelLiveStore so tab switches do not invalidate workspace
// chrome solely because the active tab id changed.
'composeBarThemeColors',
'findSplitNode',
'handleComposeSend',
'handleSnippetFromPanel',
'refocusTerminalSession',
'setIsComposeBarOpen',
'TerminalComposeBar',
'setResizing',
'Array',
'cn',
'onStartSessionRename',
'onRemoveSessionFromWorkspace',
'onReorderTabs',
'onStartSessionDrag',
'onEndSessionDrag',
'isGlobalBroadcastEnabled',
'canUseGlobalBroadcast',
'onToggleGlobalBroadcast',
] as const;
export function terminalLayerSidePanelStableCtxEqual(prev: Ctx, next: Ctx): boolean {
for (const key of SIDE_PANEL_STABLE_CTX_KEYS as unknown as string[]) {
if (!sidePanelCtxKeyEqual(prev, next, key)) return false;
}
return true;
}
export function terminalLayerSidePanelCtxEqual(prev: Ctx, next: Ctx): boolean {
if (!terminalLayerSidePanelStableCtxEqual(prev, next)) return false;
for (const key of SIDE_PANEL_LIVE_CTX_KEYS as unknown as string[]) {
if (!sidePanelCtxKeyEqual(prev, next, key)) return false;
}
return true;
}
export function terminalLayerWorkspaceCtxEqual(prev: Ctx, next: Ctx): boolean {
for (const key of WORKSPACE_CTX_KEYS as unknown as string[]) {
if (!workspaceCtxKeyEqual(prev, next, key)) return false;
}
return true;
}
export function terminalLayerViewCtxEqual(prev: Ctx, next: Ctx): boolean {
if (prev.isTerminalLayerVisible !== next.isTerminalLayerVisible) return false;
if (prev.hibernateHiddenTabs !== next.hibernateHiddenTabs) return false;
if (prev.isComposeBarOpen !== next.isComposeBarOpen) return false;
// activeWorkspace / focusedSessionId intentionally omitted: live store +
// focus-sidebar equal handle those without forcing a full layer rebuild on
// every top-tab switch between terminal sessions.
if (prev.handleComposeSend !== next.handleComposeSend) return false;
if (prev.refocusTerminalSession !== next.refocusTerminalSession) return false;
if (prev.setIsComposeBarOpen !== next.setIsComposeBarOpen) return false;
if (prev.isBroadcastEnabled !== next.isBroadcastEnabled) return false;
if (prev.composeBarThemeColors !== next.composeBarThemeColors) return false;
if (prev.workspaceOuterRef !== next.workspaceOuterRef) return false;
// Use stable side-panel equal only: LIVE fields (cwd, theme focus, etc.) flow
// through sidePanelLiveStore and must not rebuild side-panel chrome.
return terminalLayerSidePanelStableCtxEqual(prev, next)
&& terminalLayerFocusSidebarPropsEqual(prev, next)
&& terminalLayerWorkspaceCtxEqual(prev, next);
}
export function terminalLayerFocusSidebarPropsEqual(prev: Ctx, next: Ctx): boolean {
if (prev.isFocusMode !== next.isFocusMode) return false;
if (!prev.isFocusMode) return true;
const prevWs = prev.activeWorkspace;
const nextWs = next.activeWorkspace;
if (Boolean(prevWs) !== Boolean(nextWs)) return false;
if (prevWs && nextWs) {
if (prevWs.id !== nextWs.id) return false;
if (prevWs.viewMode !== nextWs.viewMode) return false;
if (prevWs.root !== nextWs.root) return false;
if (prevWs.focusSessionOrder !== nextWs.focusSessionOrder) return false;
}
return eq(prev, next, 'focusedSessionId')
&& eq(prev, next, 'resolvedPreviewTheme')
&& eq(prev, next, 'sessionHostsMap')
&& eq(prev, next, 'sessions')
&& prev.terminalSettings?.dynamicTabTitleMode === next.terminalSettings?.dynamicTabTitleMode
&& eq(prev, next, 't')
&& eq(prev, next, 'onReorderWorkspaceSessions')
&& eq(prev, next, 'onRequestAddToWorkspace')
&& eq(prev, next, 'onAppendHostToWorkspace')
&& eq(prev, next, 'handleCloseSession')
&& eq(prev, next, 'onCopySession')
&& eq(prev, next, 'onDuplicateSession')
&& eq(prev, next, 'onCopySessionToNewWindow')
&& eq(prev, next, 'onRemoveSessionFromWorkspace')
&& eq(prev, next, 'onSetWorkspaceFocusedSession')
&& eq(prev, next, 'onToggleWorkspaceViewMode')
&& eq(prev, next, 'onSubmitSessionRename');
}

View File

@@ -0,0 +1 @@
export const TERMINAL_SIDE_PANEL_INNER_HEADER_CLASS = 'h-8 shrink-0';

View File

@@ -0,0 +1,67 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
emptyTerminalThemePreview,
listThemePreviewSessionIds,
resolvePaneThemePreviewId,
} from "./terminalThemePreview";
const preview = {
targetSessionId: "session-a",
targetHostId: "host-1",
globalPreview: false,
themeId: "dracula",
};
test("follow-app mode never applies per-pane theme preview ids", () => {
assert.equal(
resolvePaneThemePreviewId(true, preview, "session-a", "host-1"),
undefined,
);
});
test("host-scoped preview applies to every session on the same host", () => {
assert.equal(
resolvePaneThemePreviewId(false, preview, "session-b", "host-1"),
"dracula",
);
assert.equal(
resolvePaneThemePreviewId(false, preview, "session-c", "host-2"),
undefined,
);
});
test("global preview applies to all sessions", () => {
const globalPreview = { ...preview, globalPreview: true, targetHostId: null };
assert.equal(
resolvePaneThemePreviewId(false, globalPreview, "session-z", "host-9"),
"dracula",
);
});
test("listThemePreviewSessionIds skips follow-app previews", () => {
const sessions = [{ id: "session-a" }, { id: "session-b" }];
const sessionHostsMap = new Map([
["session-a", { id: "host-1" }],
["session-b", { id: "host-1" }],
]);
assert.deepEqual(
listThemePreviewSessionIds(sessions, sessionHostsMap, preview, true),
[],
);
assert.deepEqual(
listThemePreviewSessionIds(sessions, sessionHostsMap, preview, false),
["session-a", "session-b"],
);
});
test("emptyTerminalThemePreview returns a cleared preview state", () => {
assert.deepEqual(emptyTerminalThemePreview(), {
targetSessionId: null,
targetHostId: null,
globalPreview: false,
themeId: null,
});
});

View File

@@ -0,0 +1,42 @@
export type TerminalThemePreviewState = {
targetSessionId: string | null;
targetHostId: string | null;
globalPreview: boolean;
themeId: string | null;
};
export const emptyTerminalThemePreview = (): TerminalThemePreviewState => ({
targetSessionId: null,
targetHostId: null,
globalPreview: false,
themeId: null,
});
/** Which terminal panes should render a theme preview (sidebar list uses themeId separately). */
export function resolvePaneThemePreviewId(
followAppTerminalTheme: boolean,
themePreview: TerminalThemePreviewState,
sessionId: string,
hostId: string,
): string | undefined {
if (followAppTerminalTheme || !themePreview.themeId) return undefined;
if (themePreview.globalPreview) return themePreview.themeId;
if (sessionId === themePreview.targetSessionId) return themePreview.themeId;
if (themePreview.targetHostId && hostId === themePreview.targetHostId) return themePreview.themeId;
return undefined;
}
export function listThemePreviewSessionIds(
sessions: ReadonlyArray<{ id: string }>,
sessionHostsMap: Map<string, { id: string }>,
themePreview: TerminalThemePreviewState,
followAppTerminalTheme: boolean,
): string[] {
if (followAppTerminalTheme || !themePreview.themeId) return [];
return sessions.flatMap((session) => {
const hostId = sessionHostsMap.get(session.id)?.id ?? '';
return resolvePaneThemePreviewId(followAppTerminalTheme, themePreview, session.id, hostId)
? [session.id]
: [];
});
}

View File

@@ -0,0 +1,2 @@
/** @deprecated Import from `@/application/state/useTerminalAiContexts` instead. */
export { useTerminalAiContexts } from "../../application/state/useTerminalAiContexts";

View File

@@ -0,0 +1,164 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import test from "node:test";
import {
clearTerminalSessionRuntimeState,
pruneTerminalTabMemoryState,
pruneTerminalSessionRuntimeState,
} from "./useTerminalLayerEffects";
const source = readFileSync(new URL("./useTerminalLayerEffects.ts", import.meta.url), "utf8");
test("theme preview DOM effects were removed in favor of ThemeRuntime injection", () => {
assert.doesNotMatch(source, /themePreview/);
assert.doesNotMatch(source, /applyTerminalPreviewVars/);
assert.doesNotMatch(source, /clearHostTreePreviewVars/);
assert.doesNotMatch(source, /applyTopTabsPreviewVars/);
assert.doesNotMatch(source, /themeCommitTimerRef/);
});
test("terminal activity filter stays in sync before notification guards", () => {
const subscriptionIndex = source.indexOf("return onSessionData(session.id, (chunk) => {");
const filterIndex = source.indexOf("const hasNotifiableOutput = hasNotifiableTerminalOutput(filter, chunk);", subscriptionIndex);
const visibleGuardIndex = source.indexOf("if (!shouldMarkSessionActivity(activeTabIdRef.current, session))", subscriptionIndex);
const alreadyActiveGuardIndex = source.indexOf("if (sessionActivityStore.getSnapshot()[session.id])", subscriptionIndex);
assert.notEqual(subscriptionIndex, -1);
assert.notEqual(filterIndex, -1);
assert.notEqual(visibleGuardIndex, -1);
assert.notEqual(alreadyActiveGuardIndex, -1);
assert.ok(filterIndex < visibleGuardIndex);
assert.ok(filterIndex < alreadyActiveGuardIndex);
});
test("side panel layout changes remeasure workspace before paint", () => {
assert.match(source, /import \{ useCallback, useEffect, useLayoutEffect, useRef \} from 'react';/);
const commentIndex = source.indexOf("Discrete layout changes (side panel toggle");
const layoutEffectIndex = source.indexOf("useLayoutEffect(() => {", commentIndex);
const shellWidthDependencyIndex = source.indexOf("sidePanelShellWidth,", layoutEffectIndex);
assert.notEqual(commentIndex, -1);
assert.notEqual(layoutEffectIndex, -1);
assert.notEqual(shellWidthDependencyIndex, -1);
assert.ok(commentIndex < layoutEffectIndex);
});
test("transfer navigation helper is used for open-target and resume routing", () => {
assert.match(source, /resolveSftpTransferNavigationTarget/);
assert.match(source, /resolveSftpTransferNavigationPath/);
assert.match(source, /pickHostForTransferNavigation/);
assert.match(source, /isTransferNavigationTerminalTabId/);
assert.match(source, /navigation\.kind === 'local-copy-panel'/);
assert.match(source, /navigation\.kind === 'local-path'/);
// No terminal tab → connect host then open SFTP at target path.
assert.match(source, /onConnectToHost/);
assert.match(source, /openHostThenSftp/);
assert.match(source, /allowLiveUploadFallback/);
});
const createSessionRuntimeState = () => ({
terminalRendererCwdBySessionRef: {
current: new Map([
["closed", "/closed"],
["live", "/live"],
]),
},
terminalRendererCwdSourceBySessionRef: {
current: new Map([
["closed", "backend" as const],
["live", "osc7" as const],
]),
},
terminalOsc7SignalBySessionRef: {
current: new Map([
["closed", 4],
["live", 7],
]),
},
cwdProbeGenerationRef: {
current: new Map([
["closed", 2],
["live", 3],
]),
},
cwdProbeCancelersRef: {
current: new Map<string, () => void>(),
},
});
test("closing a terminal session cancels its probe and deletes only its runtime state", () => {
const state = createSessionRuntimeState();
let closedCancelCount = 0;
let liveCancelCount = 0;
state.cwdProbeCancelersRef.current.set("closed", () => { closedCancelCount += 1; });
state.cwdProbeCancelersRef.current.set("live", () => { liveCancelCount += 1; });
clearTerminalSessionRuntimeState(state, "closed");
assert.equal(closedCancelCount, 1);
assert.equal(liveCancelCount, 0);
for (const runtimeMap of [
state.terminalRendererCwdBySessionRef.current,
state.terminalRendererCwdSourceBySessionRef.current,
state.terminalOsc7SignalBySessionRef.current,
state.cwdProbeGenerationRef.current,
state.cwdProbeCancelersRef.current,
]) {
assert.equal(runtimeMap.has("closed"), false);
assert.equal(runtimeMap.has("live"), true);
}
clearTerminalSessionRuntimeState(state, "closed");
assert.equal(closedCancelCount, 1, "repeated cleanup must not cancel the same probe twice");
});
test("session pruning preserves reconnecting sessions and unmount cleanup removes the rest", () => {
const state = createSessionRuntimeState();
let closedCancelCount = 0;
let liveCancelCount = 0;
state.cwdProbeCancelersRef.current.set("closed", () => { closedCancelCount += 1; });
state.cwdProbeCancelersRef.current.set("live", () => { liveCancelCount += 1; });
pruneTerminalSessionRuntimeState(state, new Set(["live"]));
assert.equal(closedCancelCount, 1);
assert.equal(liveCancelCount, 0);
assert.equal(state.cwdProbeGenerationRef.current.has("live"), true);
pruneTerminalSessionRuntimeState(state, new Set());
assert.equal(liveCancelCount, 1);
for (const runtimeMap of [
state.terminalRendererCwdBySessionRef.current,
state.terminalRendererCwdSourceBySessionRef.current,
state.terminalOsc7SignalBySessionRef.current,
state.cwdProbeGenerationRef.current,
state.cwdProbeCancelersRef.current,
]) {
assert.equal(runtimeMap.size, 0);
}
});
test("tab memory pruning releases side-panel and SFTP paths for closed sessions", () => {
const state = {
lastSidePanelTabRef: { current: new Map([["closed", "sftp"], ["live", "scripts"]]) },
notesReturnTabRef: { current: new Map([["closed", "notes"], ["live", "sftp"]]) },
sftpLastPathForSourceRef: {
current: new Map([
["closed", { hostId: "host-1", connectionKey: "closed", path: "/old" }],
["live", { hostId: "host-2", connectionKey: "live", path: "/current" }],
]),
},
};
pruneTerminalTabMemoryState(state, new Set(["live"]));
for (const memoryMap of [
state.lastSidePanelTabRef.current,
state.notesReturnTabRef.current,
state.sftpLastPathForSourceRef.current,
]) {
assert.equal(memoryMap.has("closed"), false);
assert.equal(memoryMap.has("live"), true);
}
});

View File

@@ -0,0 +1,781 @@
/* eslint-disable @typescript-eslint/no-explicit-any, react-hooks/exhaustive-deps */
import { useCallback, useEffect, useLayoutEffect, useRef } from 'react';
import type { MutableRefObject } from 'react';
import { terminalLayoutSuppressStore } from '../../application/state/terminalLayoutSuppressStore';
import { terminalCwdStore } from '../../application/state/terminalCwdStore';
import { useSftpBackend } from '../../application/state/useSftpBackend';
import {
isTransferNavigationTerminalTabId,
pickHostForTransferNavigation,
resolveSftpTransferNavigationHostLabel,
resolveSftpTransferNavigationPath,
resolveSftpTransferNavigationTarget,
} from '../../domain/sftpTransferNavigation';
import { collectSidePanelPanes, sidePanelLayoutHasTool } from '../../domain/sidePanelLayout';
import { collectSessionIds } from '../../domain/workspace';
import {
moveSidePanelTabMap,
moveSidePanelTabSet,
remapMountedSidePanelTabIds,
remapSidePanelTabMap,
type SidePanelTabRemap,
} from '../../domain/workspaceSidePanelTabRemap';
import { AI_PANEL_FORCE_HIDE_SHELL } from '../ai/aiPanelDiagnostics';
import { toast } from '../ui/toast';
import { getTerminalSidePanelShellWidth } from './TerminalLayerSidePanelSection';
import type { RendererCwdSource } from '../terminal/sftpCwd';
type TerminalLayerEffectsContext = Record<string, any> & {
sftpPaneClosedTabIdsRef: MutableRefObject<Set<string>>;
};
type RuntimeStateRef<T> = { current: Map<string, T> };
export type TerminalSessionRuntimeState = {
terminalRendererCwdBySessionRef: RuntimeStateRef<string>;
terminalRendererCwdSourceBySessionRef?: RuntimeStateRef<RendererCwdSource>;
terminalOsc7SignalBySessionRef: RuntimeStateRef<number>;
cwdProbeGenerationRef: RuntimeStateRef<number>;
cwdProbeCancelersRef: RuntimeStateRef<() => void>;
};
export function clearTerminalSessionRuntimeState(
state: TerminalSessionRuntimeState,
sessionId: string,
): void {
const cancelProbe = state.cwdProbeCancelersRef.current.get(sessionId);
state.cwdProbeCancelersRef.current.delete(sessionId);
state.cwdProbeGenerationRef.current.delete(sessionId);
state.terminalOsc7SignalBySessionRef.current.delete(sessionId);
state.terminalRendererCwdBySessionRef.current.delete(sessionId);
state.terminalRendererCwdSourceBySessionRef?.current.delete(sessionId);
// Keep terminalCwdStore in sync so SFTP follow does not reuse a closed session path.
terminalCwdStore.setCwd(sessionId, null);
if (cancelProbe) {
try {
cancelProbe();
} catch {
// Session teardown is best-effort: one faulty canceler must not retain state.
}
}
}
export function pruneTerminalSessionRuntimeState(
state: TerminalSessionRuntimeState,
liveSessionIds: ReadonlySet<string>,
): void {
const trackedSessionIds = new Set<string>([
...state.terminalRendererCwdBySessionRef.current.keys(),
...(state.terminalRendererCwdSourceBySessionRef?.current.keys() ?? []),
...state.terminalOsc7SignalBySessionRef.current.keys(),
...state.cwdProbeGenerationRef.current.keys(),
...state.cwdProbeCancelersRef.current.keys(),
]);
for (const sessionId of trackedSessionIds) {
if (!liveSessionIds.has(sessionId)) {
clearTerminalSessionRuntimeState(state, sessionId);
}
}
}
type TabMemoryRef = { current: Map<string, unknown> };
export type TerminalTabMemoryState = {
lastSidePanelTabRef: TabMemoryRef;
notesReturnTabRef: TabMemoryRef;
sftpLastPathForSourceRef: TabMemoryRef;
};
export function pruneTerminalTabMemoryState(
state: TerminalTabMemoryState,
liveTabIds: ReadonlySet<string>,
): void {
for (const memoryRef of [
state.lastSidePanelTabRef,
state.notesReturnTabRef,
state.sftpLastPathForSourceRef,
]) {
for (const tabId of memoryRef.current.keys()) {
if (!liveTabIds.has(tabId)) memoryRef.current.delete(tabId);
}
}
}
export function useTerminalLayerEffects(ctx: TerminalLayerEffectsContext) {
const { openPath } = useSftpBackend();
const { activeSidePanelTab, activeSidePanelLayout, activeTabId, activeTabIdRef, activeWorkspace, activityTrackedSessions, cancelAnimationFrame, ChunkedEscapeFilter, clearTopTabsPreviewVars, document, dropHint, effectiveHosts, filterTabsMap, focusedSessionId, getSessionActivityIdsToClear, handleToggleAiFromTopBar, handleToggleSystemFromTopBar, handleToggleScriptsSidePanel, handleToggleSidePanel, hasNotifiableTerminalOutput, isComposeBarOpen, isFocusMode, isTerminalLayerVisible, lastSidePanelTabRef, Map, onConnectToHost, onSessionData, onSplitSessionRef, onToggleBroadcastRef, onToggleWorkspaceViewModeRef, prevFocusedSessionIdRef, refocusActiveTerminalSession, requestAnimationFrame, ResizeObserver, sessionActivityStore, sessions, Set, setAiMountedTabIds, setDropHint, setNotesMountedTabIds, setScriptsMountedTabIds, setSystemMountedTabIds, setSftpHostForTab, setSftpInitialLocationForTab, setSftpPendingUploadsForTab, setSidePanelOpenTabs, setSidePanelLayouts, setThemeMountedTabIds, setWorkspaceArea, shouldMeasureTerminalLayerLayout, sidePanelPosition, sidePanelWidth, sftpActiveHost, sftpHostForTab, sftpPaneClosedTabIdsRef, shouldMarkSessionActivity, sidePanelOpenTabs, splitHorizontalHandlersRef, splitVerticalHandlersRef, toggleScriptsSidePanelRef, toggleSidePanelRef, validAIScopeTargetIds, validSessionActivityIds, window, workspaceBroadcastHandlersRef, workspaceFocusHandlersRef, workspaceInnerRef, workspaces } = ctx;
const activeWorkspaceId = activeWorkspace?.id;
const activeWorkspaceViewMode = activeWorkspace?.viewMode;
const previousWorkspacesRef = useRef(workspaces);
const previousSessionWorkspaceRef = useRef(
new Map(sessions.map((session: { id: string; workspaceId?: string }) => [session.id, session.workspaceId])),
);
useEffect(() => {
const previousWorkspaces = previousWorkspacesRef.current;
const previousIds = new Set(previousWorkspaces.map((workspace: { id: string }) => workspace.id));
const nextIds = new Set(workspaces.map((workspace: { id: string }) => workspace.id));
const previousSessionWorkspace = previousSessionWorkspaceRef.current;
const remaps: SidePanelTabRemap[] = [];
for (const workspace of workspaces) {
if (previousIds.has(workspace.id)) continue;
remaps.push({
kind: 'promote',
fromTabIds: collectSessionIds(workspace.root),
toTabId: workspace.id,
preferredFromTabId: workspace.focusedSessionId,
});
}
for (const session of sessions) {
const previousWorkspaceId = previousSessionWorkspace.get(session.id);
if (!session.workspaceId || session.workspaceId === previousWorkspaceId) continue;
// Orphan (or other workspace) joined an existing workspace tab.
if (previousIds.has(session.workspaceId)) {
const workspace = workspaces.find((entry: { id: string }) => entry.id === session.workspaceId);
const focusedSessionId = workspace?.focusedSessionId;
remaps.push({
kind: 'promote',
fromTabIds: focusedSessionId
? [focusedSessionId, session.id]
: [session.id],
toTabId: session.workspaceId,
preferredFromTabId: focusedSessionId ?? session.id,
});
}
}
for (const workspace of previousWorkspaces) {
if (nextIds.has(workspace.id)) continue;
const memberTerminalIds = collectSessionIds(workspace.root)
.filter((sessionId: string) => validAIScopeTargetIds.has(sessionId));
remaps.push({
kind: 'demote',
fromTabId: workspace.id,
toTabIds: memberTerminalIds,
preferredToTabId: (
memberTerminalIds.includes(workspace.focusedSessionId)
? workspace.focusedSessionId
: memberTerminalIds[0]
),
});
}
previousWorkspacesRef.current = workspaces;
previousSessionWorkspaceRef.current = new Map(
sessions.map((session: { id: string; workspaceId?: string }) => [session.id, session.workspaceId]),
);
if (remaps.length === 0) return;
setSidePanelOpenTabs((prev: Map<string, any>) => {
let next = prev;
for (const remap of remaps) {
next = remapSidePanelTabMap(next, remap);
}
return next;
});
// Copy split trees with the open-tool map so reconcile does not replace a
// multi-pane layout with a fresh single-tool root on the destination tab.
setSidePanelLayouts((prev: Map<string, any>) => {
let next = prev;
for (const remap of remaps) {
next = remapSidePanelTabMap(next, remap);
}
return next;
});
// SFTP portals/transfers are keyed by tab id — move ownership instead of
// cloning so the workspace panel keeps the live browser + transfer owner.
setSftpHostForTab((prev: Map<string, any>) => {
let next = prev;
for (const remap of remaps) {
next = moveSidePanelTabMap(next, remap);
}
return next;
});
setSftpInitialLocationForTab((prev: Map<string, any>) => {
let next = prev;
for (const remap of remaps) {
next = moveSidePanelTabMap(next, remap);
}
return next;
});
setSftpPendingUploadsForTab((prev: Map<string, any>) => {
let next = prev;
for (const remap of remaps) {
next = moveSidePanelTabMap(next, remap);
}
return next;
});
let sftpOwners = sftpHostForTab as ReadonlyMap<string, any>;
for (const remap of remaps) {
sftpPaneClosedTabIdsRef.current = moveSidePanelTabSet(
sftpPaneClosedTabIdsRef.current,
remap,
{ ownerTabIds: new Set(sftpOwners.keys()) },
);
sftpOwners = moveSidePanelTabMap(sftpOwners, remap);
}
setAiMountedTabIds((prev: string[]) => {
let next = prev;
for (const remap of remaps) {
next = remapMountedSidePanelTabIds(next, remap);
}
return next;
});
setNotesMountedTabIds((prev: string[]) => {
let next = prev;
for (const remap of remaps) {
next = remapMountedSidePanelTabIds(next, remap);
}
return next;
});
setScriptsMountedTabIds((prev: string[]) => {
let next = prev;
for (const remap of remaps) {
next = remapMountedSidePanelTabIds(next, remap);
}
return next;
});
setSystemMountedTabIds((prev: string[]) => {
let next = prev;
for (const remap of remaps) {
next = remapMountedSidePanelTabIds(next, remap);
}
return next;
});
setThemeMountedTabIds((prev: string[]) => {
let next = prev;
for (const remap of remaps) {
next = remapMountedSidePanelTabIds(next, remap);
}
return next;
});
}, [
sessions,
setAiMountedTabIds,
setNotesMountedTabIds,
setScriptsMountedTabIds,
setSftpHostForTab,
setSftpInitialLocationForTab,
setSftpPendingUploadsForTab,
setSidePanelLayouts,
setSidePanelOpenTabs,
setSystemMountedTabIds,
setThemeMountedTabIds,
validAIScopeTargetIds,
workspaces,
]);
const isSidePanelOpenForCurrentTab = activeTabId ? sidePanelOpenTabs.has(activeTabId) : false;
const sidePanelShellWidth = getTerminalSidePanelShellWidth({
activeSidePanelTab,
forceHideAiShell: AI_PANEL_FORCE_HIDE_SHELL
&& (!activeSidePanelLayout || collectSidePanelPanes(activeSidePanelLayout.root).length <= 1),
isSidePanelOpenForCurrentTab,
resizePreviewWidth: null,
sidePanelWidth,
});
const activityEscapeFiltersRef = useRef<any>(new Map());
const remeasureScheduledRef = useRef(false);
const layoutEffectSnapshotRef = useRef({
workspaceId: undefined as string | undefined,
viewMode: undefined as string | undefined,
composeBarOpen: false,
shellWidth: 0,
width: 0,
height: 0,
});
const remeasureWorkspaceArea = useCallback(() => {
const el = workspaceInnerRef.current;
if (!el) return;
const width = el.clientWidth;
const height = el.clientHeight;
if (width <= 0 || height <= 0) return;
setWorkspaceArea((prev) => (
prev.width === width && prev.height === height
? prev
: { width, height }
));
}, [setWorkspaceArea, workspaceInnerRef]);
const scheduleWorkspaceAreaRemeasure = useCallback(() => {
if (remeasureScheduledRef.current) return;
remeasureScheduledRef.current = true;
remeasureWorkspaceArea();
requestAnimationFrame(() => {
remeasureWorkspaceArea();
requestAnimationFrame(() => {
remeasureWorkspaceArea();
remeasureScheduledRef.current = false;
});
});
}, [remeasureWorkspaceArea, requestAnimationFrame]);
useEffect(() => {
if (!isTerminalLayerVisible) {
if (typeof clearTopTabsPreviewVars === 'function') {
clearTopTabsPreviewVars();
}
}
}, [clearTopTabsPreviewVars, isTerminalLayerVisible]);
useEffect(() => {
sidePanelOpenTabs.forEach((tab, tabId) => {
lastSidePanelTabRef.current.set(tabId, tab);
});
}, [sidePanelOpenTabs]);
useEffect(() => {
const validSessionIds = new Set(sessions.map((session) => session.id));
for (const [id] of splitHorizontalHandlersRef.current) {
if (!validSessionIds.has(id)) {
splitHorizontalHandlersRef.current.delete(id);
}
}
for (const [id] of splitVerticalHandlersRef.current) {
if (!validSessionIds.has(id)) {
splitVerticalHandlersRef.current.delete(id);
}
}
for (const session of sessions) {
if (!splitHorizontalHandlersRef.current.has(session.id)) {
splitHorizontalHandlersRef.current.set(session.id, () => {
onSplitSessionRef.current?.(session.id, 'horizontal');
});
}
if (!splitVerticalHandlersRef.current.has(session.id)) {
splitVerticalHandlersRef.current.set(session.id, () => {
onSplitSessionRef.current?.(session.id, 'vertical');
});
}
}
}, [sessions]);
useEffect(() => {
const validWorkspaceIds = new Set(workspaces.map((workspace) => workspace.id));
for (const [id] of workspaceFocusHandlersRef.current) {
if (!validWorkspaceIds.has(id)) {
workspaceFocusHandlersRef.current.delete(id);
}
}
for (const [id] of workspaceBroadcastHandlersRef.current) {
if (!validWorkspaceIds.has(id)) {
workspaceBroadcastHandlersRef.current.delete(id);
}
}
for (const workspace of workspaces) {
if (!workspaceFocusHandlersRef.current.has(workspace.id)) {
workspaceFocusHandlersRef.current.set(workspace.id, () => {
onToggleWorkspaceViewModeRef.current?.(workspace.id);
});
}
if (!workspaceBroadcastHandlersRef.current.has(workspace.id)) {
workspaceBroadcastHandlersRef.current.set(workspace.id, () => {
onToggleBroadcastRef.current?.(workspace.id);
});
}
}
}, [workspaces]);
useEffect(() => {
setSidePanelOpenTabs(prev => filterTabsMap(prev, validAIScopeTargetIds));
setSftpHostForTab(prev => filterTabsMap(prev, validAIScopeTargetIds));
setSftpInitialLocationForTab(prev => filterTabsMap(prev, validAIScopeTargetIds));
setSftpPendingUploadsForTab(prev => filterTabsMap(prev, validAIScopeTargetIds));
setAiMountedTabIds((prev) => prev.filter((tabId) => validAIScopeTargetIds.has(tabId)));
setNotesMountedTabIds((prev) => prev.filter((tabId) => validAIScopeTargetIds.has(tabId)));
setScriptsMountedTabIds((prev) => prev.filter((tabId) => validAIScopeTargetIds.has(tabId)));
setSystemMountedTabIds((prev) => prev.filter((tabId) => validAIScopeTargetIds.has(tabId)));
setThemeMountedTabIds((prev) => prev.filter((tabId) => validAIScopeTargetIds.has(tabId)));
sessionActivityStore.prune(validSessionActivityIds);
}, [validSessionActivityIds, validAIScopeTargetIds]);
useEffect(() => {
if (!workspaceInnerRef.current) return;
const el = workspaceInnerRef.current;
const updateSize = () => {
// Ignore zero-size reads while the layer is hidden so split rects are
// not recomputed from a 1×1 fallback until the real layout is available.
if (!shouldMeasureTerminalLayerLayout) return;
remeasureWorkspaceArea();
};
updateSize();
const observer = new ResizeObserver(() => updateSize());
observer.observe(el);
// Re-measure when a drag ends so pane rects match the committed layout.
const unsubscribeSuppress = terminalLayoutSuppressStore.subscribe(() => {
if (!terminalLayoutSuppressStore.getActive()) {
scheduleWorkspaceAreaRemeasure();
}
});
return () => {
unsubscribeSuppress();
observer.disconnect();
};
}, [remeasureWorkspaceArea, scheduleWorkspaceAreaRemeasure, shouldMeasureTerminalLayerLayout, workspaceInnerRef]);
// Discrete layout changes (side panel toggle, compose bar, workspace tab/view mode)
// can miss a ResizeObserver tick; host-tree width is handled by the observer
// because it updates continuously during drag.
useLayoutEffect(() => {
if (!shouldMeasureTerminalLayerLayout) return;
const el = workspaceInnerRef.current;
const width = el?.clientWidth ?? 0;
const height = el?.clientHeight ?? 0;
const prev = layoutEffectSnapshotRef.current;
const dimensionsUnchanged = width > 0
&& height > 0
&& prev.width === width
&& prev.height === height
&& prev.shellWidth === sidePanelShellWidth;
if (
dimensionsUnchanged
&& prev.workspaceId === activeWorkspaceId
&& prev.viewMode === activeWorkspaceViewMode
&& prev.composeBarOpen === isComposeBarOpen
) {
return;
}
layoutEffectSnapshotRef.current = {
workspaceId: activeWorkspaceId,
viewMode: activeWorkspaceViewMode,
composeBarOpen: isComposeBarOpen,
shellWidth: sidePanelShellWidth,
width,
height,
};
scheduleWorkspaceAreaRemeasure();
}, [
activeWorkspaceId,
activeWorkspaceViewMode,
isComposeBarOpen,
scheduleWorkspaceAreaRemeasure,
shouldMeasureTerminalLayerLayout,
sidePanelPosition,
sidePanelShellWidth,
]);
// Keep sftpHostForTab in sync with focus changes in workspace mode
// so that the toggle check uses the currently displayed host.
useEffect(() => {
if (!activeTabId || !sftpActiveHost) return;
if (!sidePanelLayoutHasTool(activeSidePanelLayout, 'sftp')) return;
const stored = sftpHostForTab.get(activeTabId);
if (stored?.id === sftpActiveHost.id
&& stored?.hostname === sftpActiveHost.hostname
&& stored?.port === sftpActiveHost.port
&& stored?.protocol === sftpActiveHost.protocol) return;
setSftpHostForTab(prev => {
const next = new Map(prev);
next.set(activeTabId, sftpActiveHost);
return next;
});
}, [activeSidePanelLayout, activeTabId, sftpActiveHost, sftpHostForTab]);
useEffect(() => {
if (!toggleScriptsSidePanelRef) return;
toggleScriptsSidePanelRef.current = handleToggleScriptsSidePanel;
return () => {
toggleScriptsSidePanelRef.current = null;
};
}, [toggleScriptsSidePanelRef, handleToggleScriptsSidePanel]);
useEffect(() => {
if (!toggleSidePanelRef) return;
toggleSidePanelRef.current = handleToggleSidePanel;
return () => {
toggleSidePanelRef.current = null;
};
}, [toggleSidePanelRef, handleToggleSidePanel]);
// Listen for global AI panel toggle (from TopTabs button). Uses the toggle
// handler so a second click on an already-open AI panel closes it.
useEffect(() => {
const handler = () => handleToggleAiFromTopBar();
window.addEventListener('netcatty:toggle-ai-panel', handler);
return () => window.removeEventListener('netcatty:toggle-ai-panel', handler);
}, [handleToggleAiFromTopBar]);
// Listen for global System Manager panel toggle (from TopTabs button).
useEffect(() => {
const handler = () => handleToggleSystemFromTopBar();
window.addEventListener('netcatty:toggle-system-panel', handler);
return () => window.removeEventListener('netcatty:toggle-system-panel', handler);
}, [handleToggleSystemFromTopBar]);
useEffect(() => {
const applySftpTargetOnTab = (tabId: string, host: any, targetDirectory: string) => {
sftpPaneClosedTabIdsRef.current.delete(tabId);
// Bump initialLocation even when the host is already selected so the
// path-navigation effect re-runs after reopen.
setSftpHostForTab((prev: Map<string, any>) => new Map(prev).set(tabId, host));
setSftpInitialLocationForTab((prev: Map<string, any>) => {
const next = new Map(prev);
next.delete(tabId);
next.set(tabId, {
hostId: host.id,
path: targetDirectory,
});
return next;
});
setSidePanelOpenTabs((prev: Map<string, any>) => new Map(prev).set(tabId, 'sftp'));
};
/** When the user is on vault/editor (or no tab), open the host then attach SFTP. */
const openHostThenSftp = (host: any, targetDirectory: string) => {
if (typeof onConnectToHost !== 'function') {
toast.error('Could not open target folder', 'SFTP');
return;
}
const previousTabId = activeTabIdRef.current;
let sessionOrTabId: string | void;
try {
sessionOrTabId = onConnectToHost(host);
} catch {
toast.error('Could not open target folder', 'SFTP');
return;
}
const tryApply = (tabId: string | null | undefined) => {
if (!isTransferNavigationTerminalTabId(tabId)) return false;
applySftpTargetOnTab(tabId!, host, targetDirectory);
return true;
};
// connectToHost returns the new session id, which is also the top-tab id
// for non-workspace connections.
if (typeof sessionOrTabId === 'string' && tryApply(sessionOrTabId)) return;
const openWhenTabReady = (attempt = 0) => {
const tabId = activeTabIdRef.current;
if (tabId && tabId !== previousTabId && tryApply(tabId)) return;
if (attempt >= 12) {
// Last chance: use whatever terminal tab is active now.
if (!tryApply(activeTabIdRef.current)) {
toast.error('Could not open target folder', 'SFTP');
}
return;
}
window.setTimeout(() => openWhenTabReady(attempt + 1), 16);
};
openWhenTabReady();
};
const handler = (event: Event) => {
const detail = (event as CustomEvent).detail;
const task = detail?.task ?? detail;
const forResume = detail?.forResume === true;
if (!task) return;
const navigation = resolveSftpTransferNavigationTarget(task, forResume);
if (navigation.kind === 'local-path') {
const localDir = resolveSftpTransferNavigationPath(task, false);
void openPath(localDir).then((result) => {
if (!result?.success) {
toast.error(result?.error || 'Could not open target folder', 'SFTP');
}
}).catch(() => {
toast.error('Could not open target folder', 'SFTP');
});
return;
}
const currentTabId = activeTabIdRef.current;
if (navigation.kind === 'local-copy-panel') {
if (!isTransferNavigationTerminalTabId(currentTabId)) {
// Local-copy has no remote host to connect; need an existing terminal tab.
if (forResume) return;
toast.error('Open a terminal tab first to browse this transfer', 'SFTP');
return;
}
sftpPaneClosedTabIdsRef.current.delete(currentTabId!);
setSidePanelOpenTabs((prev: Map<string, any>) => new Map(prev).set(currentTabId!, 'sftp'));
return;
}
// Prefer vault id/label; then live SFTP panel hosts (drag-drop uploads
// often lack targetHostLabel, and open-folder should still jump the pane).
const hostLabel = resolveSftpTransferNavigationHostLabel(task, navigation.useSourcePath)
|| task.targetHostLabel
|| task.sourceHostLabel;
const liveHosts: any[] = [];
if (currentTabId && sftpHostForTab?.get?.(currentTabId)) liveHosts.push(sftpHostForTab.get(currentTabId));
if (sftpActiveHost) liveHosts.push(sftpActiveHost);
if (sftpHostForTab && typeof sftpHostForTab.values === 'function') {
for (const candidate of sftpHostForTab.values()) liveHosts.push(candidate);
}
const host = pickHostForTransferNavigation({
hostId: navigation.hostId,
hostLabel,
vaultHosts: effectiveHosts ?? [],
liveHosts,
// In-progress uploads almost always target the currently open SFTP host.
allowLiveUploadFallback: !forResume && task.direction === 'upload',
});
if (!host) {
// Dedicated resume opens its own vault session without the panel.
// Opening the destination folder needs a resolvable host.
if (!forResume) {
toast.error('Could not open target folder', 'SFTP');
}
return;
}
const targetDirectory = resolveSftpTransferNavigationPath(task, navigation.useSourcePath);
// Already on a terminal/workspace tab → open SFTP there.
if (isTransferNavigationTerminalTabId(currentTabId)) {
applySftpTargetOnTab(currentTabId!, host, targetDirectory);
return;
}
// No terminal tab (vault / editor / empty): open the target host first,
// then land the SFTP panel on the transfer directory.
openHostThenSftp(host, targetDirectory);
};
window.addEventListener('netcatty:open-sftp-transfer-target', handler);
return () => window.removeEventListener('netcatty:open-sftp-transfer-target', handler);
}, [activeTabIdRef, effectiveHosts, onConnectToHost, openPath, setSftpHostForTab, setSftpInitialLocationForTab, setSidePanelOpenTabs, sftpActiveHost, sftpHostForTab, window]);
useEffect(() => {
const sessionIdsToClear = getSessionActivityIdsToClear(activeTabId, sessions);
if (sessionIdsToClear.length === 1) {
sessionActivityStore.clearTab(sessionIdsToClear[0]);
return;
}
if (sessionIdsToClear.length > 1) {
sessionActivityStore.clearTabs(sessionIdsToClear);
}
}, [activeTabId, sessions]);
useEffect(() => {
const activeSessionIds = new Set(activityTrackedSessions.map((session) => session.id));
for (const sessionId of activityEscapeFiltersRef.current.keys()) {
if (!activeSessionIds.has(sessionId)) {
activityEscapeFiltersRef.current.delete(sessionId);
}
}
const unsubscribers = activityTrackedSessions.map((session) => {
let filter = activityEscapeFiltersRef.current.get(session.id);
if (!filter) {
filter = new ChunkedEscapeFilter();
activityEscapeFiltersRef.current.set(session.id, filter);
}
return onSessionData(session.id, (chunk) => {
const hasNotifiableOutput = hasNotifiableTerminalOutput(filter, chunk);
if (!shouldMarkSessionActivity(activeTabIdRef.current, session)) {
return;
}
if (sessionActivityStore.getSnapshot()[session.id]) {
return;
}
if (!hasNotifiableOutput) return;
sessionActivityStore.setTabActive(session.id, true);
});
});
return () => {
for (const unsubscribe of unsubscribers) {
unsubscribe();
}
};
}, [activityTrackedSessions, onSessionData]);
// MCP/SDK approval IPC is owned by AppWithProviders so External MCP approvals
// work before TerminalLayer lazy-mounts. Do not re-subscribe here.
useEffect(() => {
if (isFocusMode && dropHint) {
setDropHint(null);
}
}, [isFocusMode, dropHint]);
const wasTerminalLayerVisibleRef = useRef(false);
const prevActiveTabIdRef = useRef<string | undefined>(undefined);
// Restore keyboard focus after switching work tabs.
// Call the existing focus primitive directly — it already schedules rAF +
// retry (focusTerminalSessionInput). An outer rAF here only delayed focus.
useEffect(() => {
if (!isTerminalLayerVisible) {
prevActiveTabIdRef.current = activeTabId;
return;
}
const tabChanged =
prevActiveTabIdRef.current !== undefined &&
prevActiveTabIdRef.current !== activeTabId;
prevActiveTabIdRef.current = activeTabId;
if (!tabChanged || !refocusActiveTerminalSession) return;
refocusActiveTerminalSession();
}, [activeTabId, isTerminalLayerVisible, refocusActiveTerminalSession]);
// When focusedSessionId changes or terminal layer becomes visible,
// focus the corresponding terminal to restore :focus-within CSS state
useEffect(() => {
// Only handle split view mode (not focus mode)
if (isFocusMode || !focusedSessionId || !activeWorkspace) {
wasTerminalLayerVisibleRef.current = isTerminalLayerVisible;
return;
}
// Trigger on focusedSessionId change OR when layer becomes visible again
const sessionChanged = prevFocusedSessionIdRef.current !== focusedSessionId;
const layerBecameVisible = isTerminalLayerVisible && !wasTerminalLayerVisibleRef.current;
wasTerminalLayerVisibleRef.current = isTerminalLayerVisible;
if (!sessionChanged && !layerBecameVisible) return;
const prevFocusedId = sessionChanged ? prevFocusedSessionIdRef.current : undefined;
prevFocusedSessionIdRef.current = focusedSessionId;
// First, blur the currently focused terminal immediately
if (prevFocusedId) {
const prevPane = document.querySelector(`[data-session-id="${prevFocusedId}"]`);
if (prevPane) {
const prevTextarea = prevPane.querySelector('textarea.xterm-helper-textarea') as HTMLTextAreaElement | null;
if (prevTextarea) {
prevTextarea.blur();
}
}
}
const focusTarget = () => {
const targetPane = document.querySelector(`[data-session-id="${focusedSessionId}"]`);
if (targetPane) {
const textarea = targetPane.querySelector('textarea.xterm-helper-textarea') as HTMLTextAreaElement | null;
if (textarea && document.activeElement !== textarea) {
textarea.focus();
}
}
};
// One sync attempt + one rAF; avoid the historical 50ms third focus.
focusTarget();
let rafId: number | null = null;
if (typeof requestAnimationFrame === 'function') {
rafId = requestAnimationFrame(focusTarget);
}
return () => {
if (rafId !== null && typeof cancelAnimationFrame === 'function') {
cancelAnimationFrame(rafId);
}
};
}, [focusedSessionId, isFocusMode, activeWorkspace, isTerminalLayerVisible]);
}

View File

@@ -0,0 +1,48 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import test from "node:test";
const source = readFileSync(new URL("./useTerminalThemePanelState.ts", import.meta.url), "utf8");
test("follow-app side panel theme changes delegate to ThemeRuntime pickTheme", () => {
assert.match(source, /pickTheme\(themeId\)/);
assert.match(source, /if \(followAppTerminalTheme\) \{/);
assert.doesNotMatch(source, /onUpdateFollowAppTerminalThemeId/);
assert.doesNotMatch(source, /setThemePreview/);
assert.doesNotMatch(source, /applyTerminalPreviewVars/);
});
test("manual side panel theme changes persist host overrides and use runtime pick intent", () => {
assert.match(source, /pickTheme\(themeId, \{/);
assert.match(source, /scopeHostId:/);
assert.match(source, /onUpdateHost\(\{ \.\.\.rawFocusedHost, theme: themeId, themeOverride: true \}\)/);
assert.doesNotMatch(source, /startTransition\(\(\) => \{[\s\S]*onUpdateHost\(\{ \.\.\.rawFocusedHost, theme: themeId/);
});
test("follow-app keeps runtime intent until the side panel closes", () => {
assert.match(source, /isSidePanelOpenForCurrentTab/);
assert.match(source, /if \(!followAppTerminalTheme && activeSidePanelTab !== 'theme'\)/);
assert.match(source, /clearIntent\(\)/);
});
test("follow-app theme list selection tracks global runtime theme id", () => {
assert.match(source, /listSelectedThemeId = followAppTerminalTheme/);
assert.match(source, /terminalTheme\.id/);
});
test("manual theme list selection reads focused appearance from runtime", () => {
assert.match(source, /resolveFocusedAppearance\(focusedHostScope\)/);
assert.match(source, /focusedAppearance\.themeId/);
assert.match(source, /resolvedPreviewTheme = focusedAppearance\.theme/);
});
test("focusedAppearance recomputes from appearanceChromeStore accent churn", () => {
assert.match(source, /useAppearanceChromeStore\(\)/);
assert.match(source, /appearanceChrome\.accentMode/);
assert.match(source, /appearanceChrome\.customAccent/);
});
test("closing the theme tab clears runtime user intent", () => {
assert.match(source, /if \(isSidePanelOpenForCurrentTab\)/);
assert.match(source, /clearIntent\(\)/);
});

View File

@@ -0,0 +1,323 @@
import { startTransition, useCallback, useEffect, useMemo } from 'react';
import { useAppearanceChromeStore } from '../../application/state/appearanceChromeStore';
import type { ResolvedAppearance, TerminalAppearanceHostScope } from '../../domain/terminalAppearanceRuntime';
import {
clearHostFontFamilyOverride,
clearHostFontSizeOverride,
clearHostFontWeightOverride,
clearHostThemeOverride,
hasHostFontFamilyOverride,
hasHostFontSizeOverride,
hasHostFontWeightOverride,
hasHostThemeOverride,
resolveHostTerminalFontFamilyId,
resolveHostTerminalFontSize,
resolveHostTerminalFontWeight,
resolveHostTerminalThemeId,
} from '../../domain/terminalAppearance';
import { isSavedVaultHost } from '../../domain/ephemeralHosts';
import { isSameResolvedTerminalFont } from '../../infrastructure/config/fonts';
import type { Host, TerminalSession, TerminalTheme, Workspace } from '../../types';
import { getScopedTopTabsThemeId } from '../terminalTopTabsTheme';
import type { SidePanelTab } from './TerminalLayerSupport';
const navigatorPlatform = typeof navigator !== 'undefined' ? navigator.platform : '';
interface UseTerminalThemePanelStateOptions {
activeSession: TerminalSession | undefined;
activeSidePanelTab: SidePanelTab | null;
activeWorkspace: Workspace | undefined;
clearIntent: () => void;
followAppTerminalTheme: boolean;
focusedSessionId: string | undefined;
fontSize: number;
hostMap: Map<string, Host>;
isSidePanelOpenForCurrentTab: boolean;
isVisible: boolean;
onUpdateHost: (host: Host) => void;
onUpdateTerminalFontFamilyId?: (fontFamilyId: string) => void;
onUpdateTerminalFontSize?: (fontSize: number) => void;
onUpdateTerminalFontWeight?: (fontWeight: number) => void;
onUpdateTerminalThemeId?: (themeId: string) => void;
onUpdateSessionFontSize?: (sessionId: string, fontSize: number) => void;
onClearSessionFontSizeOverride?: (sessionId: string) => void;
pickTheme: (themeId: string) => void;
resolveFocusedAppearance: (hostScope: TerminalAppearanceHostScope) => ResolvedAppearance;
sessionHostsMap: Map<string, Host>;
terminalFontFamilyId: string;
terminalSettings?: { fontWeight?: number };
terminalTheme: TerminalTheme;
}
export function useTerminalThemePanelState({
activeSession,
activeSidePanelTab,
activeWorkspace,
clearIntent,
followAppTerminalTheme,
focusedSessionId,
fontSize,
hostMap,
isSidePanelOpenForCurrentTab,
isVisible,
onUpdateHost,
onUpdateTerminalFontFamilyId,
onUpdateTerminalFontSize,
onUpdateTerminalFontWeight,
onUpdateTerminalThemeId,
onUpdateSessionFontSize,
onClearSessionFontSizeOverride,
pickTheme,
resolveFocusedAppearance,
sessionHostsMap,
terminalFontFamilyId,
terminalSettings,
terminalTheme,
}: UseTerminalThemePanelStateOptions) {
useEffect(() => {
if (isSidePanelOpenForCurrentTab) {
if (!followAppTerminalTheme && activeSidePanelTab !== 'theme') {
clearIntent();
}
return;
}
clearIntent();
}, [activeSidePanelTab, clearIntent, followAppTerminalTheme, isSidePanelOpenForCurrentTab]);
const focusedHost = useMemo((): Host | null => {
if (activeWorkspace && focusedSessionId) {
return sessionHostsMap.get(focusedSessionId) ?? null;
}
if (activeSession) {
return sessionHostsMap.get(activeSession.id) ?? null;
}
return null;
}, [activeWorkspace, focusedSessionId, activeSession, sessionHostsMap]);
const isFocusedHostLocal = useMemo(() => {
return focusedHost?.protocol === 'local' || !!focusedHost?.id?.startsWith('local-');
}, [focusedHost]);
const isFocusedHostEphemeral = useMemo(() => {
if (isFocusedHostLocal) return true;
if (!focusedHost) return true;
return !isSavedVaultHost(hostMap.get(focusedHost.id));
}, [focusedHost, isFocusedHostLocal, hostMap]);
const rawFocusedHost = useMemo(() => {
if (!focusedHost) return null;
return hostMap.get(focusedHost.id) ?? null;
}, [focusedHost, hostMap]);
const focusedHostScope = useMemo((): TerminalAppearanceHostScope => ({
host: focusedHost,
isEphemeral: isFocusedHostEphemeral,
}), [focusedHost, isFocusedHostEphemeral]);
// Subscribe to accent store so compose-bar / manual chrome recompute when
// color-picker changes, even though resolveFocusedAppearance identity is
// intentionally stable across accent drag (and TerminalLayer memo ignores
// accent props).
const appearanceChrome = useAppearanceChromeStore();
const focusedAppearance = useMemo(() => {
// resolveFocusedAppearance reads accent from the chrome store; pin these
// deps so compose-bar chrome updates while focusedHostScope stays stable.
void appearanceChrome.accentMode;
void appearanceChrome.customAccent;
return resolveFocusedAppearance(focusedHostScope);
}, [
appearanceChrome.accentMode,
appearanceChrome.customAccent,
focusedHostScope,
resolveFocusedAppearance,
]);
const previewTargetSessionId = activeWorkspace?.focusedSessionId ?? activeSession?.id ?? null;
const focusedThemeId = resolveHostTerminalThemeId(focusedHost, terminalTheme.id);
const focusedFontFamilyId = resolveHostTerminalFontFamilyId(focusedHost, terminalFontFamilyId);
const focusedFontSize = resolveHostTerminalFontSize(focusedHost, fontSize);
const focusedThemeOverridden = hasHostThemeOverride(focusedHost);
const focusedFontFamilyOverridden = hasHostFontFamilyOverride(focusedHost);
const focusedFontSizeOverridden = hasHostFontSizeOverride(focusedHost);
const focusedFontWeight = resolveHostTerminalFontWeight(focusedHost, terminalSettings?.fontWeight ?? 400);
const focusedFontWeightOverridden = hasHostFontWeightOverride(focusedHost);
const visibleFocusedThemeId = followAppTerminalTheme ? terminalTheme.id : focusedThemeId;
const listSelectedThemeId = followAppTerminalTheme
? terminalTheme.id
: focusedAppearance.themeId;
const previewedOrVisibleThemeId = listSelectedThemeId;
const resolvedPreviewTheme = focusedAppearance.theme;
const activeTopTabsThemeId = useMemo(
() => getScopedTopTabsThemeId({
activeSidePanelTab,
activeThemePreviewId: null,
activeWorkspace,
followAppTerminalTheme,
isVisible,
previewTargetSessionId,
previewedOrVisibleThemeId,
resolveSessionThemeId: (sessionId) => {
const host = sessionHostsMap.get(sessionId) ?? null;
const isEphemeral = !host || !isSavedVaultHost(hostMap.get(host.id));
return resolveFocusedAppearance({ host, isEphemeral }).themeId;
},
}),
[
activeSidePanelTab,
activeWorkspace,
followAppTerminalTheme,
hostMap,
isVisible,
previewTargetSessionId,
previewedOrVisibleThemeId,
resolveFocusedAppearance,
sessionHostsMap,
],
);
const handleThemeChangeForFocusedSession = useCallback((themeId: string) => {
if (themeId === listSelectedThemeId) return;
if (!focusedHost && !followAppTerminalTheme) return;
if (followAppTerminalTheme) {
pickTheme(themeId);
return;
}
pickTheme(themeId, {
followApp: false,
scopeHostId: rawFocusedHost?.id ?? focusedHost?.id ?? null,
});
if (isFocusedHostEphemeral) {
onUpdateTerminalThemeId?.(themeId);
} else if (rawFocusedHost) {
onUpdateHost({ ...rawFocusedHost, theme: themeId, themeOverride: true });
}
}, [
focusedHost,
followAppTerminalTheme,
isFocusedHostEphemeral,
listSelectedThemeId,
onUpdateHost,
onUpdateTerminalThemeId,
pickTheme,
rawFocusedHost,
]);
const handleThemeResetForFocusedSession = useCallback(() => {
clearIntent();
if (!focusedHost || isFocusedHostEphemeral || !rawFocusedHost) return;
onUpdateHost(clearHostThemeOverride(rawFocusedHost));
}, [clearIntent, focusedHost, isFocusedHostEphemeral, onUpdateHost, rawFocusedHost]);
const handleFontFamilyChangeForFocusedSession = useCallback((fontFamilyId: string) => {
if (!focusedHost || isSameResolvedTerminalFont(fontFamilyId, focusedFontFamilyId, navigatorPlatform)) return;
startTransition(() => {
if (isFocusedHostEphemeral) {
onUpdateTerminalFontFamilyId?.(fontFamilyId);
return;
}
if (rawFocusedHost) {
onUpdateHost({ ...rawFocusedHost, fontFamily: fontFamilyId, fontFamilyOverride: true });
}
});
}, [focusedHost, focusedFontFamilyId, isFocusedHostEphemeral, onUpdateTerminalFontFamilyId, onUpdateHost, rawFocusedHost]);
const handleFontFamilyResetForFocusedSession = useCallback(() => {
if (!focusedHost || isFocusedHostEphemeral || !rawFocusedHost) return;
onUpdateHost(clearHostFontFamilyOverride(rawFocusedHost));
}, [focusedHost, isFocusedHostEphemeral, onUpdateHost, rawFocusedHost]);
const handleFontSizeChangeForFocusedSession = useCallback((newFontSize: number) => {
if (!focusedHost || newFontSize === focusedFontSize) return;
startTransition(() => {
if (activeWorkspace && focusedSessionId) {
onUpdateSessionFontSize?.(focusedSessionId, newFontSize);
return;
}
if (isFocusedHostEphemeral) {
// Ephemeral hosts cannot persist host-level overrides; keep the
// change per-session (same path Ctrl+zoom uses) when possible.
const targetSessionId = focusedSessionId ?? activeSession?.id;
if (targetSessionId) {
onUpdateSessionFontSize?.(targetSessionId, newFontSize);
return;
}
onUpdateTerminalFontSize?.(newFontSize);
return;
}
if (rawFocusedHost) {
onUpdateHost({ ...rawFocusedHost, fontSize: newFontSize, fontSizeOverride: true });
}
});
}, [activeSession, activeWorkspace, focusedHost, focusedFontSize, focusedSessionId, isFocusedHostEphemeral, onUpdateSessionFontSize, onUpdateTerminalFontSize, onUpdateHost, rawFocusedHost]);
const handleFontSizeResetForFocusedSession = useCallback(() => {
if (!focusedHost) return;
if (activeWorkspace && focusedSessionId) {
onClearSessionFontSizeOverride?.(focusedSessionId);
return;
}
if (isFocusedHostEphemeral) {
const targetSessionId = focusedSessionId ?? activeSession?.id;
if (targetSessionId) onClearSessionFontSizeOverride?.(targetSessionId);
return;
}
if (!rawFocusedHost) return;
onUpdateHost(clearHostFontSizeOverride(rawFocusedHost));
}, [activeSession, activeWorkspace, focusedHost, focusedSessionId, isFocusedHostEphemeral, onClearSessionFontSizeOverride, onUpdateHost, rawFocusedHost]);
const handleFontWeightChangeForFocusedSession = useCallback((newFontWeight: number) => {
if (!focusedHost || newFontWeight === focusedFontWeight) return;
startTransition(() => {
if (isFocusedHostEphemeral) {
onUpdateTerminalFontWeight?.(newFontWeight);
return;
}
const rawHost = hostMap.get(focusedHost.id);
if (rawHost) {
onUpdateHost({ ...rawHost, fontWeight: newFontWeight, fontWeightOverride: true });
}
});
}, [focusedHost, focusedFontWeight, isFocusedHostEphemeral, onUpdateTerminalFontWeight, onUpdateHost, hostMap]);
const handleFontWeightResetForFocusedSession = useCallback(() => {
if (!focusedHost || isFocusedHostEphemeral) return;
const rawHost = hostMap.get(focusedHost.id);
if (rawHost) {
onUpdateHost(clearHostFontWeightOverride(rawHost));
}
}, [focusedHost, isFocusedHostEphemeral, onUpdateHost, hostMap]);
const composeBarThemeColors = useMemo(() => {
if (!activeWorkspace || !focusedSessionId) return terminalTheme.colors;
return resolvedPreviewTheme.colors;
}, [activeWorkspace, focusedSessionId, resolvedPreviewTheme.colors, terminalTheme.colors]);
return {
activeTopTabsThemeId,
composeBarThemeColors,
focusedFontFamilyId,
focusedFontFamilyOverridden,
focusedFontSize,
focusedFontSizeOverridden,
focusedFontWeight,
focusedFontWeightOverridden,
focusedThemeOverridden,
handleFontFamilyChangeForFocusedSession,
handleFontFamilyResetForFocusedSession,
handleFontSizeChangeForFocusedSession,
handleFontSizeResetForFocusedSession,
handleFontWeightChangeForFocusedSession,
handleFontWeightResetForFocusedSession,
handleThemeChangeForFocusedSession,
handleThemeResetForFocusedSession,
previewedOrVisibleThemeId,
previewTargetSessionId,
resolvedPreviewTheme,
visibleFocusedThemeId,
};
}

View File

@@ -0,0 +1,363 @@
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;
shouldKeepHiddenWorkspaceLaidOut: (workspace: Workspace) => 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,
shouldKeepHiddenWorkspaceLaidOut,
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 liveWorkspaceIds = new Set(workspaces.map((workspace) => workspace.id));
for (const workspaceId of workspaceRectsCacheRef.current.keys()) {
if (!liveWorkspaceIds.has(workspaceId)) {
workspaceRectsCacheRef.current.delete(workspaceId);
}
}
for (const workspace of workspaces) {
if (shouldKeepHiddenWorkspaceLaidOut(workspace)) {
if (workspace.id === activeWorkspace?.id) continue;
const layoutWorkspace = workspaceForLayout(workspace);
const previewKey = resizing?.workspaceId === workspace.id
? `${resizing.workspaceId}:${resizing.splitId}:${resizePreviewDelta}`
: 'still';
const cached = workspaceRectsCacheRef.current.get(workspace.id);
const cachedSizeIsUsable = !!cached && cached.width > 0 && cached.height > 0;
if (
cached
&& cached.root === layoutWorkspace.root
&& cached.previewKey === previewKey
&& (cachedSizeIsUsable || workspaceArea.width <= 0 || workspaceArea.height <= 0)
) {
map.set(workspace.id, cached.rects);
continue;
}
const layoutSize = cachedSizeIsUsable
? { width: cached.width, height: cached.height }
: workspaceArea;
if (layoutSize.width <= 0 || layoutSize.height <= 0) continue;
const rects = computeWorkspaceRects(layoutWorkspace, layoutSize);
workspaceRectsCacheRef.current.set(workspace.id, {
root: layoutWorkspace.root,
previewKey,
width: layoutSize.width,
height: layoutSize.height,
rects,
});
map.set(workspace.id, rects);
}
}
if (!activeWorkspace) return map;
const workspace = workspaces.find((candidate) => candidate.id === activeWorkspace.id) ?? activeWorkspace;
const layoutWorkspace = workspaceForLayout(workspace);
const previewKey = resizing?.workspaceId === workspace.id
? `${resizing.workspaceId}:${resizing.splitId}:${resizePreviewDelta}`
: 'still';
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, shouldKeepHiddenWorkspaceLaidOut, 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,
workspaceArea,
workspaceInnerRef,
workspaceOuterRef,
workspaceOverlayRef,
workspaceRectsById,
};
}

View File

@@ -0,0 +1,54 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
computeResizeBoundary,
computeSplitSizesFromDelta,
patchWorkspaceSplitSizes,
type WorkspaceResizeSession,
} from './workspaceSplitResize';
import type { Workspace } from '../../types';
const session: WorkspaceResizeSession = {
workspaceId: 'ws-1',
splitId: 'split-1',
index: 0,
direction: 'horizontal',
startSizes: [0.5, 0.5],
startArea: { x: 0, y: 0, w: 800, h: 600 },
startClient: { x: 0, y: 300 },
};
test('computeSplitSizesFromDelta keeps normalized sizes', () => {
const sizes = computeSplitSizesFromDelta(session, 60);
assert.equal(sizes.length, 2);
assert.ok(Math.abs(sizes[0] + sizes[1] - 1) < 0.0001);
assert.ok(sizes[0] > 0.5);
});
test('computeResizeBoundary moves down when dragging split divider downward', () => {
const base = computeResizeBoundary(session, 0);
const moved = computeResizeBoundary(session, 80);
assert.ok(base != null && moved != null);
assert.ok(moved > base);
});
test('patchWorkspaceSplitSizes updates only the targeted split node', () => {
const workspace: Workspace = {
id: 'ws-1',
title: 'ws-1',
root: {
id: 'split-1',
type: 'split',
direction: 'horizontal',
sizes: [0.5, 0.5],
children: [
{ id: 'pane-a', type: 'pane', sessionId: 'a' },
{ id: 'pane-b', type: 'pane', sessionId: 'b' },
],
},
};
const next = patchWorkspaceSplitSizes(workspace, 'split-1', [0.7, 0.3]);
assert.notEqual(next, workspace);
assert.deepEqual(next.root.type === 'split' ? next.root.sizes : null, [0.7, 0.3]);
});

View File

@@ -0,0 +1,92 @@
import type { Workspace, WorkspaceNode } from '../../types';
export type WorkspaceResizeSession = {
workspaceId: string;
splitId: string;
index: number;
direction: 'vertical' | 'horizontal';
startSizes: number[];
startArea: { x: number; y: number; w: number; h: number };
startClient: { x: number; y: number };
};
function clampAdjacentPaneSizes(
pxSizes: number[],
index: number,
delta: number,
dimension: number,
): number[] {
const i = index;
let a = pxSizes[i] + delta;
let b = pxSizes[i + 1] - delta;
const minPx = Math.min(120, dimension / 2);
if (a < minPx) {
const diff = minPx - a;
a = minPx;
b -= diff;
}
if (b < minPx) {
const diff = minPx - b;
b = minPx;
a -= diff;
}
const next = [...pxSizes];
next[i] = Math.max(minPx, a);
next[i + 1] = Math.max(minPx, b);
return next;
}
export function computeSplitPxSizesFromDelta(
session: WorkspaceResizeSession,
delta: number,
): number[] | null {
const dimension = session.direction === 'vertical' ? session.startArea.w : session.startArea.h;
if (dimension <= 0) return null;
const total = session.startSizes.reduce((acc, n) => acc + n, 0) || 1;
const pxSizes = session.startSizes.map((s) => (s / total) * dimension);
return clampAdjacentPaneSizes(pxSizes, session.index, delta, dimension);
}
export function computeSplitSizesFromDelta(
session: WorkspaceResizeSession,
delta: number,
): number[] {
const pxSizes = computeSplitPxSizesFromDelta(session, delta);
if (!pxSizes) return [...session.startSizes];
const totalPx = pxSizes.reduce((acc, n) => acc + n, 0) || 1;
return pxSizes.map((n) => n / totalPx);
}
/** Workspace-local pixel coordinate of the split boundary after applying delta. */
export function computeResizeBoundary(
session: WorkspaceResizeSession,
delta: number,
): number | null {
const pxSizes = computeSplitPxSizesFromDelta(session, delta);
if (!pxSizes) return null;
let offset = 0;
for (let i = 0; i <= session.index; i += 1) {
offset += pxSizes[i];
}
return session.direction === 'vertical'
? session.startArea.x + offset
: session.startArea.y + offset;
}
export function patchWorkspaceSplitSizes(
workspace: Workspace,
splitId: string,
sizes: number[],
): Workspace {
const patch = (node: WorkspaceNode): WorkspaceNode => {
if (node.type === 'pane') return node;
const children = node.children.map(patch);
if (node.id === splitId) {
return { ...node, children, sizes: [...sizes] };
}
const childrenChanged = children.some((child, idx) => child !== node.children[idx]);
return childrenChanged ? { ...node, children } : node;
};
const root = patch(workspace.root);
return root === workspace.root ? workspace : { ...workspace, root };
}