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; 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) => void; onDrop: (event: DragEvent, sessionId: string) => void; onDragEnd: () => void; dynamicTabTitleMode?: DynamicTabTitleMode; t: (key: string) => string; }; const WorkspaceFocusSessionRow = memo(({ 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 (
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); }} >
{host ? ( ) : ( )}
{isRenaming ? ( ) : ( <>
{ e.stopPropagation(); onStartRename(session.id); }} > {resolveSessionTabTitle(session, dynamicTabTitleMode)}
{session.username}@{session.hostname}
)}
); }, (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 = ({ 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(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(null); const [sidebarRenameValue, setSidebarRenameValue] = useState(''); const theme = useMemo(() => { 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) => { event.stopPropagation(); const dropZone = event.currentTarget.closest('[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('[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) => { 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) => { 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) => { appendHostFromFocusSidebarDrop(event); }, [appendHostFromFocusSidebarDrop]); const handleFocusSidebarContainerDrop = useCallback((event: DragEvent) => { 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 (
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 }} />
{onRequestAddToWorkspace && ( {t('terminal.layer.addTerminal')} )} {t('terminal.layer.switchToSplitView')}
{visibleSessions.map((session) => ( ))}
); }; 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';