Files
automaker/apps/ui/src/components/views/terminal-view.tsx
Kacper 26236d3d5b feat: enhance ESLint configuration and improve component error handling
- Updated ESLint configuration to include support for `.mjs` and `.cjs` file types, adding necessary global variables for Node.js and browser environments.
- Introduced a new `vite-env.d.ts` file to define environment variables for Vite, improving type safety.
- Refactored error handling in `file-browser-dialog.tsx`, `description-image-dropzone.tsx`, and `feature-image-upload.tsx` to omit error parameters, simplifying the catch blocks.
- Removed unused bug report button functionality from the sidebar, streamlining the component structure.
- Adjusted various components to improve code readability and maintainability, including updates to type imports and component props.

These changes aim to enhance the development experience by improving linting support and simplifying error handling across components.
2025-12-21 23:08:08 +01:00

708 lines
23 KiB
TypeScript

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 (
<div
ref={setNodeRef}
className={cn(
'flex items-center gap-1 px-3 py-1.5 text-sm rounded-t-md border-b-2 cursor-pointer transition-colors',
isActive
? 'bg-background border-brand-500 text-foreground'
: 'bg-muted border-transparent text-muted-foreground hover:text-foreground hover:bg-accent',
isOver && isDropTarget && 'ring-2 ring-green-500'
)}
onClick={onClick}
>
<TerminalIcon className="h-3 w-3" />
<span className="max-w-24 truncate">{tab.name}</span>
<button
className="ml-1 p-0.5 rounded hover:bg-accent text-muted-foreground hover:text-destructive"
onClick={(e) => {
e.stopPropagation();
onClose();
}}
>
<X className="h-3 w-3" />
</button>
</div>
);
}
// New tab drop zone
function NewTabDropZone({ isDropTarget }: { isDropTarget: boolean }) {
const { setNodeRef, isOver } = useDroppable({
id: 'new-tab-zone',
data: { type: 'new-tab' },
});
return (
<div
ref={setNodeRef}
className={cn(
'flex items-center justify-center px-3 py-1.5 rounded-t-md border-2 border-dashed transition-all',
isOver && isDropTarget
? 'border-green-500 bg-green-500/10 text-green-500'
: 'border-transparent text-muted-foreground hover:border-border'
)}
>
<SquarePlus className="h-4 w-4" />
</div>
);
}
export function TerminalView() {
const {
terminalState,
setTerminalUnlocked,
addTerminalToLayout,
removeTerminalFromLayout,
setActiveTerminalSession,
swapTerminals,
currentProject,
addTerminalTab,
removeTerminalTab,
setActiveTerminalTab,
moveTerminalToTab,
setTerminalPanelFontSize,
} = useAppStore();
const [status, setStatus] = useState<TerminalStatus | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [password, setPassword] = useState('');
const [authLoading, setAuthLoading] = useState(false);
const [authError, setAuthError] = useState<string | null>(null);
const [activeDragId, setActiveDragId] = useState<string | null>(null);
const [dragOverTabId, setDragOverTabId] = useState<string | null>(null);
const lastCreateTimeRef = useRef<number>(0);
const isCreatingRef = useRef<boolean>(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<string, string> = {
'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<string, string> = {
'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<string, string> = {};
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 (
<TerminalPanel
key={content.sessionId}
sessionId={content.sessionId}
authToken={terminalState.authToken}
isActive={terminalState.activeSessionId === content.sessionId}
onFocus={() => 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 (
<PanelGroup direction={content.direction}>
{content.panels.map((panel, index) => {
const panelSize =
panel.type === 'terminal' && panel.size ? panel.size : defaultSizePerPanel;
const panelKey = getPanelKey(panel);
return (
<React.Fragment key={panelKey}>
{index > 0 && (
<PanelResizeHandle
key={`handle-${panelKey}`}
className={
isHorizontal
? 'w-1 h-full bg-border hover:bg-brand-500 transition-colors data-[resize-handle-state=hover]:bg-brand-500 data-[resize-handle-state=drag]:bg-brand-500'
: 'h-1 w-full bg-border hover:bg-brand-500 transition-colors data-[resize-handle-state=hover]:bg-brand-500 data-[resize-handle-state=drag]:bg-brand-500'
}
/>
)}
<Panel id={panelKey} order={index} defaultSize={panelSize} minSize={30}>
{renderPanelContent(panel)}
</Panel>
</React.Fragment>
);
})}
</PanelGroup>
);
};
// Loading state
if (loading) {
return (
<div className="flex-1 flex items-center justify-center">
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
</div>
);
}
// Error state
if (error) {
return (
<div className="flex-1 flex flex-col items-center justify-center text-center p-6">
<div className="p-4 rounded-full bg-destructive/10 mb-4">
<AlertCircle className="h-12 w-12 text-destructive" />
</div>
<h2 className="text-lg font-medium mb-2">Terminal Unavailable</h2>
<p className="text-muted-foreground max-w-md mb-4">{error}</p>
<Button variant="outline" onClick={fetchStatus}>
<RefreshCw className="h-4 w-4 mr-2" />
Retry
</Button>
</div>
);
}
// Disabled state
if (!status?.enabled) {
return (
<div className="flex-1 flex flex-col items-center justify-center text-center p-6">
<div className="p-4 rounded-full bg-muted/50 mb-4">
<TerminalIcon className="h-12 w-12 text-muted-foreground" />
</div>
<h2 className="text-lg font-medium mb-2">Terminal Disabled</h2>
<p className="text-muted-foreground max-w-md">
Terminal access has been disabled. Set{' '}
<code className="px-1.5 py-0.5 rounded bg-muted">TERMINAL_ENABLED=true</code> in your
server .env file to enable it.
</p>
</div>
);
}
// Password gate
if (status.passwordRequired && !terminalState.isUnlocked) {
return (
<div className="flex-1 flex flex-col items-center justify-center text-center p-6">
<div className="p-4 rounded-full bg-muted/50 mb-4">
<Lock className="h-12 w-12 text-muted-foreground" />
</div>
<h2 className="text-lg font-medium mb-2">Terminal Protected</h2>
<p className="text-muted-foreground max-w-md mb-6">
Terminal access requires authentication. Enter the password to unlock.
</p>
<form onSubmit={handleAuth} className="w-full max-w-xs space-y-4">
<Input
type="password"
placeholder="Enter password"
value={password}
onChange={(e) => setPassword(e.target.value)}
disabled={authLoading}
autoFocus
/>
{authError && <p className="text-sm text-destructive">{authError}</p>}
<Button type="submit" className="w-full" disabled={authLoading || !password}>
{authLoading ? (
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
) : (
<Unlock className="h-4 w-4 mr-2" />
)}
Unlock Terminal
</Button>
</form>
{status.platform && (
<p className="text-xs text-muted-foreground mt-6">
Platform: {status.platform.platform}
{status.platform.isWSL && ' (WSL)'}
{' | '}Shell: {status.platform.defaultShell}
</p>
)}
</div>
);
}
// No terminals yet - show welcome screen
if (terminalState.tabs.length === 0) {
return (
<div className="flex-1 flex flex-col items-center justify-center text-center p-6">
<div className="p-4 rounded-full bg-brand-500/10 mb-4">
<TerminalIcon className="h-12 w-12 text-brand-500" />
</div>
<h2 className="text-lg font-medium mb-2">Terminal</h2>
<p className="text-muted-foreground max-w-md mb-6">
Create a new terminal session to start executing commands.
{currentProject && (
<span className="block mt-2 text-sm">
Working directory:{' '}
<code className="px-1.5 py-0.5 rounded bg-muted">{currentProject.path}</code>
</span>
)}
</p>
<Button onClick={() => createTerminal()}>
<Plus className="h-4 w-4 mr-2" />
New Terminal
</Button>
{status?.platform && (
<p className="text-xs text-muted-foreground mt-6">
Platform: {status.platform.platform}
{status.platform.isWSL && ' (WSL)'}
{' | '}Shell: {status.platform.defaultShell}
</p>
)}
</div>
);
}
// Terminal view with tabs
return (
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragStart={handleDragStart}
onDragOver={handleDragOver}
onDragEnd={handleDragEnd}
>
<div className="flex-1 flex flex-col overflow-hidden">
{/* Tab bar */}
<div className="flex items-center bg-card border-b border-border px-2">
{/* Tabs */}
<div className="flex items-center gap-1 flex-1 overflow-x-auto py-1">
{terminalState.tabs.map((tab) => (
<TerminalTabButton
key={tab.id}
tab={tab}
isActive={tab.id === terminalState.activeTabId}
onClick={() => setActiveTerminalTab(tab.id)}
onClose={() => removeTerminalTab(tab.id)}
isDropTarget={activeDragId !== null}
/>
))}
{/* New tab drop zone (visible when dragging) */}
{activeDragId && <NewTabDropZone isDropTarget={true} />}
{/* New tab button */}
<button
className="flex items-center justify-center p-1.5 rounded hover:bg-accent text-muted-foreground hover:text-foreground"
onClick={createTerminalInNewTab}
title="New Tab"
>
<Plus className="h-4 w-4" />
</button>
</div>
{/* Toolbar buttons */}
<div className="flex items-center gap-1 pl-2 border-l border-border">
<Button
variant="ghost"
size="sm"
className="h-7 px-2 text-muted-foreground hover:text-foreground"
onClick={() => createTerminal('horizontal')}
title="Split Right"
>
<SplitSquareHorizontal className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="sm"
className="h-7 px-2 text-muted-foreground hover:text-foreground"
onClick={() => createTerminal('vertical')}
title="Split Down"
>
<SplitSquareVertical className="h-4 w-4" />
</Button>
</div>
</div>
{/* Active tab content */}
<div className="flex-1 overflow-hidden bg-background">
{activeTab?.layout ? (
renderPanelContent(activeTab.layout)
) : (
<div className="flex-1 flex flex-col items-center justify-center text-center p-6">
<p className="text-muted-foreground mb-4">This tab is empty</p>
<Button variant="outline" size="sm" onClick={() => createTerminal()}>
<Plus className="h-4 w-4 mr-2" />
New Terminal
</Button>
</div>
)}
</div>
</div>
{/* Drag overlay */}
<DragOverlay dropAnimation={null} zIndex={1000}>
{activeDragId ? (
<div className="relative inline-flex items-center gap-2 px-3.5 py-2 bg-card border-2 border-brand-500 rounded-lg shadow-xl pointer-events-none overflow-hidden">
<TerminalIcon className="h-4 w-4 text-brand-500 shrink-0" />
<span className="text-sm font-medium text-foreground whitespace-nowrap">
{dragOverTabId === 'new' ? 'New tab' : dragOverTabId ? 'Move to tab' : 'Terminal'}
</span>
</div>
) : null}
</DragOverlay>
</DndContext>
);
}