import React, { useState, useEffect, useCallback, useRef } from 'react'; import { Terminal as TerminalIcon, Plus, Lock, Unlock, SplitSquareHorizontal, SplitSquareVertical, Loader2, AlertCircle, RefreshCw, X, SquarePlus, } from 'lucide-react'; import { useAppStore, type TerminalPanelContent, type TerminalTab } from '@/store/app-store'; import { useKeyboardShortcutsConfig } from '@/hooks/use-keyboard-shortcuts'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { Panel, PanelGroup, PanelResizeHandle } from 'react-resizable-panels'; import { TerminalPanel } from './terminal-view/terminal-panel'; import { DndContext, DragEndEvent, DragStartEvent, DragOverEvent, PointerSensor, useSensor, useSensors, closestCenter, DragOverlay, useDroppable, } from '@dnd-kit/core'; import { cn } from '@/lib/utils'; interface TerminalStatus { enabled: boolean; passwordRequired: boolean; platform: { platform: string; isWSL: boolean; defaultShell: string; arch: string; }; } // Tab component with drop target support function TerminalTabButton({ tab, isActive, onClick, onClose, isDropTarget, }: { tab: TerminalTab; isActive: boolean; onClick: () => void; onClose: () => void; isDropTarget: boolean; }) { const { setNodeRef, isOver } = useDroppable({ id: `tab-${tab.id}`, data: { type: 'tab', tabId: tab.id }, }); return (
{tab.name}
); } // New tab drop zone function NewTabDropZone({ isDropTarget }: { isDropTarget: boolean }) { const { setNodeRef, isOver } = useDroppable({ id: 'new-tab-zone', data: { type: 'new-tab' }, }); return (
); } export function TerminalView() { const { terminalState, setTerminalUnlocked, addTerminalToLayout, removeTerminalFromLayout, setActiveTerminalSession, swapTerminals, currentProject, addTerminalTab, removeTerminalTab, setActiveTerminalTab, moveTerminalToTab, setTerminalPanelFontSize, } = useAppStore(); const [status, setStatus] = useState(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const [password, setPassword] = useState(''); const [authLoading, setAuthLoading] = useState(false); const [authError, setAuthError] = useState(null); const [activeDragId, setActiveDragId] = useState(null); const [dragOverTabId, setDragOverTabId] = useState(null); const lastCreateTimeRef = useRef(0); const isCreatingRef = useRef(false); const serverUrl = import.meta.env.VITE_SERVER_URL || 'http://localhost:3008'; const CREATE_COOLDOWN_MS = 500; // Prevent rapid terminal creation // Helper to check if terminal creation should be debounced const canCreateTerminal = (debounceMessage: string): boolean => { const now = Date.now(); if (now - lastCreateTimeRef.current < CREATE_COOLDOWN_MS || isCreatingRef.current) { console.log(debounceMessage); return false; } lastCreateTimeRef.current = now; isCreatingRef.current = true; return true; }; // Get active tab const activeTab = terminalState.tabs.find((t) => t.id === terminalState.activeTabId); // DnD sensors with activation constraint to avoid accidental drags const sensors = useSensors( useSensor(PointerSensor, { activationConstraint: { distance: 8, }, }) ); // Handle drag start const handleDragStart = useCallback((event: DragStartEvent) => { setActiveDragId(event.active.id as string); }, []); // Handle drag over - track which tab we're hovering const handleDragOver = useCallback((event: DragOverEvent) => { const { over } = event; if (over?.data?.current?.type === 'tab') { setDragOverTabId(over.data.current.tabId); } else if (over?.data?.current?.type === 'new-tab') { setDragOverTabId('new'); } else { setDragOverTabId(null); } }, []); // Handle drag end const handleDragEnd = useCallback( (event: DragEndEvent) => { const { active, over } = event; setActiveDragId(null); setDragOverTabId(null); if (!over) return; const activeId = active.id as string; const overData = over.data?.current; // If dropped on a tab, move terminal to that tab if (overData?.type === 'tab') { moveTerminalToTab(activeId, overData.tabId); return; } // If dropped on new tab zone, create new tab with this terminal if (overData?.type === 'new-tab') { moveTerminalToTab(activeId, 'new'); return; } // Otherwise, swap terminals within current tab if (active.id !== over.id) { swapTerminals(activeId, over.id as string); } }, [swapTerminals, moveTerminalToTab] ); // Fetch terminal status const fetchStatus = useCallback(async () => { try { setLoading(true); setError(null); const response = await fetch(`${serverUrl}/api/terminal/status`); const data = await response.json(); if (data.success) { setStatus(data.data); if (!data.data.passwordRequired) { setTerminalUnlocked(true); } } else { setError(data.error || 'Failed to get terminal status'); } } catch (err) { setError('Failed to connect to server'); console.error('[Terminal] Status fetch error:', err); } finally { setLoading(false); } }, [serverUrl, setTerminalUnlocked]); useEffect(() => { fetchStatus(); }, [fetchStatus]); // Handle password authentication const handleAuth = async (e: React.FormEvent) => { e.preventDefault(); setAuthLoading(true); setAuthError(null); try { const response = await fetch(`${serverUrl}/api/terminal/auth`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ password }), }); const data = await response.json(); if (data.success) { setTerminalUnlocked(true, data.data.token); setPassword(''); } else { setAuthError(data.error || 'Authentication failed'); } } catch (err) { setAuthError('Failed to authenticate'); console.error('[Terminal] Auth error:', err); } finally { setAuthLoading(false); } }; // Create a new terminal session // targetSessionId: the terminal to split (if splitting an existing terminal) const createTerminal = async ( direction?: 'horizontal' | 'vertical', targetSessionId?: string ) => { if (!canCreateTerminal('[Terminal] Debounced terminal creation')) { return; } try { const headers: Record = { 'Content-Type': 'application/json', }; if (terminalState.authToken) { headers['X-Terminal-Token'] = terminalState.authToken; } const response = await fetch(`${serverUrl}/api/terminal/sessions`, { method: 'POST', headers, body: JSON.stringify({ cwd: currentProject?.path || undefined, cols: 80, rows: 24, }), }); const data = await response.json(); if (data.success) { addTerminalToLayout(data.data.id, direction, targetSessionId); } else { console.error('[Terminal] Failed to create session:', data.error); } } catch (err) { console.error('[Terminal] Create session error:', err); } finally { isCreatingRef.current = false; } }; // Create terminal in new tab const createTerminalInNewTab = async () => { if (!canCreateTerminal('[Terminal] Debounced terminal tab creation')) { return; } const tabId = addTerminalTab(); try { const headers: Record = { 'Content-Type': 'application/json', }; if (terminalState.authToken) { headers['X-Terminal-Token'] = terminalState.authToken; } const response = await fetch(`${serverUrl}/api/terminal/sessions`, { method: 'POST', headers, body: JSON.stringify({ cwd: currentProject?.path || undefined, cols: 80, rows: 24, }), }); const data = await response.json(); if (data.success) { // Add to the newly created tab const { addTerminalToTab } = useAppStore.getState(); addTerminalToTab(data.data.id, tabId); } } catch (err) { console.error('[Terminal] Create session error:', err); } finally { isCreatingRef.current = false; } }; // Kill a terminal session const killTerminal = async (sessionId: string) => { try { const headers: Record = {}; if (terminalState.authToken) { headers['X-Terminal-Token'] = terminalState.authToken; } await fetch(`${serverUrl}/api/terminal/sessions/${sessionId}`, { method: 'DELETE', headers, }); removeTerminalFromLayout(sessionId); } catch (err) { console.error('[Terminal] Kill session error:', err); } }; // Get keyboard shortcuts config const shortcuts = useKeyboardShortcutsConfig(); // Handle terminal-specific keyboard shortcuts useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { // Only handle shortcuts when terminal is unlocked and has an active session if (!terminalState.isUnlocked || !terminalState.activeSessionId) return; const isMac = navigator.platform.toUpperCase().indexOf('MAC') >= 0; const cmdOrCtrl = isMac ? e.metaKey : e.ctrlKey; // Parse shortcut string to check for match const matchesShortcut = (shortcutStr: string | undefined) => { if (!shortcutStr) return false; const parts = shortcutStr.toLowerCase().split('+'); const key = parts[parts.length - 1]; const needsCmd = parts.includes('cmd'); const needsShift = parts.includes('shift'); const needsAlt = parts.includes('alt'); // Check modifiers const cmdMatches = needsCmd ? cmdOrCtrl : !cmdOrCtrl; const shiftMatches = needsShift ? e.shiftKey : !e.shiftKey; const altMatches = needsAlt ? e.altKey : !e.altKey; return e.key.toLowerCase() === key && cmdMatches && shiftMatches && altMatches; }; // Split terminal right (Cmd+D / Ctrl+D) if (matchesShortcut(shortcuts.splitTerminalRight)) { e.preventDefault(); createTerminal('horizontal', terminalState.activeSessionId); return; } // Split terminal down (Cmd+Shift+D / Ctrl+Shift+D) if (matchesShortcut(shortcuts.splitTerminalDown)) { e.preventDefault(); createTerminal('vertical', terminalState.activeSessionId); return; } // Close terminal (Cmd+W / Ctrl+W) if (matchesShortcut(shortcuts.closeTerminal)) { e.preventDefault(); killTerminal(terminalState.activeSessionId); return; } }; window.addEventListener('keydown', handleKeyDown); return () => window.removeEventListener('keydown', handleKeyDown); }, [terminalState.isUnlocked, terminalState.activeSessionId, shortcuts]); // Collect all terminal IDs from a panel tree in order const getTerminalIds = (panel: TerminalPanelContent): string[] => { if (panel.type === 'terminal') { return [panel.sessionId]; } return panel.panels.flatMap(getTerminalIds); }; // Get a STABLE key for a panel - based only on terminal IDs, not tree structure // This prevents unnecessary remounts when layout structure changes const getPanelKey = (panel: TerminalPanelContent): string => { if (panel.type === 'terminal') { return panel.sessionId; } // Use joined terminal IDs - stable regardless of nesting depth return `group-${getTerminalIds(panel).join('-')}`; }; // Render panel content recursively const renderPanelContent = (content: TerminalPanelContent): React.ReactNode => { if (content.type === 'terminal') { // Use per-terminal fontSize or fall back to default const terminalFontSize = content.fontSize ?? terminalState.defaultFontSize; return ( setActiveTerminalSession(content.sessionId)} onClose={() => killTerminal(content.sessionId)} onSplitHorizontal={() => createTerminal('horizontal', content.sessionId)} onSplitVertical={() => createTerminal('vertical', content.sessionId)} isDragging={activeDragId === content.sessionId} isDropTarget={activeDragId !== null && activeDragId !== content.sessionId} fontSize={terminalFontSize} onFontSizeChange={(size) => setTerminalPanelFontSize(content.sessionId, size)} /> ); } const isHorizontal = content.direction === 'horizontal'; const defaultSizePerPanel = 100 / content.panels.length; return ( {content.panels.map((panel, index) => { const panelSize = panel.type === 'terminal' && panel.size ? panel.size : defaultSizePerPanel; const panelKey = getPanelKey(panel); return ( {index > 0 && ( )} {renderPanelContent(panel)} ); })} ); }; // Loading state if (loading) { return (
); } // Error state if (error) { return (

Terminal Unavailable

{error}

); } // Disabled state if (!status?.enabled) { return (

Terminal Disabled

Terminal access has been disabled. Set{' '} TERMINAL_ENABLED=true in your server .env file to enable it.

); } // Password gate if (status.passwordRequired && !terminalState.isUnlocked) { return (

Terminal Protected

Terminal access requires authentication. Enter the password to unlock.

setPassword(e.target.value)} disabled={authLoading} autoFocus /> {authError &&

{authError}

}
{status.platform && (

Platform: {status.platform.platform} {status.platform.isWSL && ' (WSL)'} {' | '}Shell: {status.platform.defaultShell}

)}
); } // No terminals yet - show welcome screen if (terminalState.tabs.length === 0) { return (

Terminal

Create a new terminal session to start executing commands. {currentProject && ( Working directory:{' '} {currentProject.path} )}

{status?.platform && (

Platform: {status.platform.platform} {status.platform.isWSL && ' (WSL)'} {' | '}Shell: {status.platform.defaultShell}

)}
); } // Terminal view with tabs return (
{/* Tab bar */}
{/* Tabs */}
{terminalState.tabs.map((tab) => ( setActiveTerminalTab(tab.id)} onClose={() => removeTerminalTab(tab.id)} isDropTarget={activeDragId !== null} /> ))} {/* New tab drop zone (visible when dragging) */} {activeDragId && } {/* New tab button */}
{/* Toolbar buttons */}
{/* Active tab content */}
{activeTab?.layout ? ( renderPanelContent(activeTab.layout) ) : (

This tab is empty

)}
{/* Drag overlay */} {activeDragId ? (
{dragOverTabId === 'new' ? 'New tab' : dragOverTabId ? 'Move to tab' : 'Terminal'}
) : null}
); }