"use client"; import React, { useState, useEffect, useCallback, useMemo } 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, type KeyboardShortcut } 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 serverUrl = process.env.NEXT_PUBLIC_SERVER_URL || "http://localhost:3008"; // 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) => { 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); } }; // Create terminal in new tab const createTerminalInNewTab = async () => { 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); } }; // 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'); return ( e.key.toLowerCase() === key && cmdOrCtrl === needsCmd && e.shiftKey === needsShift && e.altKey === needsAlt ); }; // 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]); // Get a stable key for a panel const getPanelKey = (panel: TerminalPanelContent): string => { if (panel.type === "terminal") { return panel.sessionId; } return `split-${panel.direction}-${panel.panels.map(getPanelKey).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; 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}
); }