[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
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:
37
components/terminalLayer/TerminalFocusSidebar.test.ts
Normal file
37
components/terminalLayer/TerminalFocusSidebar.test.ts
Normal 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\(\)/);
|
||||
});
|
||||
719
components/terminalLayer/TerminalFocusSidebar.tsx
Normal file
719
components/terminalLayer/TerminalFocusSidebar.tsx
Normal 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';
|
||||
253
components/terminalLayer/TerminalHostTreeSidebar.test.ts
Normal file
253
components/terminalLayer/TerminalHostTreeSidebar.test.ts
Normal 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/);
|
||||
});
|
||||
1223
components/terminalLayer/TerminalHostTreeSidebar.tsx
Normal file
1223
components/terminalLayer/TerminalHostTreeSidebar.tsx
Normal file
File diff suppressed because it is too large
Load Diff
133
components/terminalLayer/TerminalHostTreeToolbar.test.ts
Normal file
133
components/terminalLayer/TerminalHostTreeToolbar.test.ts
Normal 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/);
|
||||
});
|
||||
620
components/terminalLayer/TerminalHostTreeToolbar.tsx
Normal file
620
components/terminalLayer/TerminalHostTreeToolbar.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
@@ -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';
|
||||
360
components/terminalLayer/TerminalLayerSidePanelSection.test.ts
Normal file
360
components/terminalLayer/TerminalLayerSidePanelSection.test.ts
Normal 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/);
|
||||
});
|
||||
1320
components/terminalLayer/TerminalLayerSidePanelSection.tsx
Normal file
1320
components/terminalLayer/TerminalLayerSidePanelSection.tsx
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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");
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
1883
components/terminalLayer/TerminalLayerSupport.tsx
Normal file
1883
components/terminalLayer/TerminalLayerSupport.tsx
Normal file
File diff suppressed because it is too large
Load Diff
42
components/terminalLayer/TerminalLayerTabBridge.test.ts
Normal file
42
components/terminalLayer/TerminalLayerTabBridge.test.ts
Normal 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/);
|
||||
});
|
||||
704
components/terminalLayer/TerminalLayerTabBridge.tsx
Normal file
704
components/terminalLayer/TerminalLayerTabBridge.tsx
Normal 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} />;
|
||||
}
|
||||
42
components/terminalLayer/TerminalLayerView.tsx
Normal file
42
components/terminalLayer/TerminalLayerView.tsx
Normal 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';
|
||||
@@ -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['"]/);
|
||||
});
|
||||
161
components/terminalLayer/TerminalLayerWorkspaceSection.test.ts
Normal file
161
components/terminalLayer/TerminalLayerWorkspaceSection.test.ts
Normal 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);
|
||||
});
|
||||
362
components/terminalLayer/TerminalLayerWorkspaceSection.tsx
Normal file
362
components/terminalLayer/TerminalLayerWorkspaceSection.tsx
Normal 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';
|
||||
49
components/terminalLayer/WorkspaceSidebarHostHeader.tsx
Normal file
49
components/terminalLayer/WorkspaceSidebarHostHeader.tsx
Normal 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>
|
||||
);
|
||||
});
|
||||
42
components/terminalLayer/activityEscapeFilter.ts
Normal file
42
components/terminalLayer/activityEscapeFilter.ts
Normal 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
|
||||
);
|
||||
34
components/terminalLayer/aiNoteArtifactPanelIntent.test.ts
Normal file
34
components/terminalLayer/aiNoteArtifactPanelIntent.test.ts
Normal 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',
|
||||
},
|
||||
);
|
||||
});
|
||||
34
components/terminalLayer/aiNoteArtifactPanelIntent.ts
Normal file
34
components/terminalLayer/aiNoteArtifactPanelIntent.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
52
components/terminalLayer/commandCwdProbe.test.ts
Normal file
52
components/terminalLayer/commandCwdProbe.test.ts
Normal 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,
|
||||
);
|
||||
});
|
||||
28
components/terminalLayer/commandCwdProbe.ts
Normal file
28
components/terminalLayer/commandCwdProbe.ts
Normal 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,
|
||||
);
|
||||
};
|
||||
316
components/terminalLayer/sftpPanelLifecycle.test.ts
Normal file
316
components/terminalLayer/sftpPanelLifecycle.test.ts
Normal 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\(/);
|
||||
});
|
||||
143
components/terminalLayer/sftpPanelLifecycle.ts
Normal file
143
components/terminalLayer/sftpPanelLifecycle.ts
Normal 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)
|
||||
));
|
||||
}
|
||||
85
components/terminalLayer/terminalFontSizeUpdate.test.ts
Normal file
85
components/terminalLayer/terminalFontSizeUpdate.test.ts
Normal 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" },
|
||||
);
|
||||
});
|
||||
30
components/terminalLayer/terminalFontSizeUpdate.ts
Normal file
30
components/terminalLayer/terminalFontSizeUpdate.ts
Normal 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 };
|
||||
}
|
||||
33
components/terminalLayer/terminalLayerSessionRouting.test.ts
Normal file
33
components/terminalLayer/terminalLayerSessionRouting.test.ts
Normal 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);
|
||||
});
|
||||
17
components/terminalLayer/terminalLayerSessionRouting.ts
Normal file
17
components/terminalLayer/terminalLayerSessionRouting.ts
Normal 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";
|
||||
}
|
||||
@@ -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]',
|
||||
);
|
||||
}
|
||||
797
components/terminalLayer/terminalLayerSidePanelSlots.tsx
Normal file
797
components/terminalLayer/terminalLayerSidePanelSlots.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
404
components/terminalLayer/terminalLayerViewMemo.test.ts
Normal file
404
components/terminalLayer/terminalLayerViewMemo.test.ts
Normal 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,
|
||||
);
|
||||
});
|
||||
511
components/terminalLayer/terminalLayerViewMemo.ts
Normal file
511
components/terminalLayer/terminalLayerViewMemo.ts
Normal 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');
|
||||
}
|
||||
1
components/terminalLayer/terminalSidePanelChrome.ts
Normal file
1
components/terminalLayer/terminalSidePanelChrome.ts
Normal file
@@ -0,0 +1 @@
|
||||
export const TERMINAL_SIDE_PANEL_INNER_HEADER_CLASS = 'h-8 shrink-0';
|
||||
67
components/terminalLayer/terminalThemePreview.test.ts
Normal file
67
components/terminalLayer/terminalThemePreview.test.ts
Normal 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,
|
||||
});
|
||||
});
|
||||
42
components/terminalLayer/terminalThemePreview.ts
Normal file
42
components/terminalLayer/terminalThemePreview.ts
Normal 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]
|
||||
: [];
|
||||
});
|
||||
}
|
||||
2
components/terminalLayer/useTerminalAiContexts.ts
Normal file
2
components/terminalLayer/useTerminalAiContexts.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
/** @deprecated Import from `@/application/state/useTerminalAiContexts` instead. */
|
||||
export { useTerminalAiContexts } from "../../application/state/useTerminalAiContexts";
|
||||
164
components/terminalLayer/useTerminalLayerEffects.test.ts
Normal file
164
components/terminalLayer/useTerminalLayerEffects.test.ts
Normal 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);
|
||||
}
|
||||
});
|
||||
781
components/terminalLayer/useTerminalLayerEffects.ts
Normal file
781
components/terminalLayer/useTerminalLayerEffects.ts
Normal 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]);
|
||||
}
|
||||
48
components/terminalLayer/useTerminalThemePanelState.test.ts
Normal file
48
components/terminalLayer/useTerminalThemePanelState.test.ts
Normal 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\(\)/);
|
||||
});
|
||||
323
components/terminalLayer/useTerminalThemePanelState.ts
Normal file
323
components/terminalLayer/useTerminalThemePanelState.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
363
components/terminalLayer/useTerminalWorkspaceLayout.ts
Normal file
363
components/terminalLayer/useTerminalWorkspaceLayout.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
54
components/terminalLayer/workspaceSplitResize.test.ts
Normal file
54
components/terminalLayer/workspaceSplitResize.test.ts
Normal 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]);
|
||||
});
|
||||
92
components/terminalLayer/workspaceSplitResize.ts
Normal file
92
components/terminalLayer/workspaceSplitResize.ts
Normal 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 };
|
||||
}
|
||||
Reference in New Issue
Block a user