mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-01-31 20:03:37 +00:00
- Added new endpoints for terminal settings: GET and PUT /settings to retrieve and update terminal configurations. - Implemented session limit checks during session creation, returning a 429 status when the limit is reached. - Introduced a new TerminalSection in settings view for customizing terminal appearance and behavior, including font family, default font size, line height, and screen reader mode. - Added support for new terminal features such as search functionality and improved error handling with a TerminalErrorBoundary component. - Updated terminal layout persistence to include session IDs for reconnection and enhanced terminal state management. - Introduced new keyboard shortcuts for terminal actions, including creating new terminal tabs. - Enhanced UI with scrollbar theming for terminal components.
1404 lines
48 KiB
TypeScript
1404 lines
48 KiB
TypeScript
|
|
import React, { useState, useEffect, useCallback, useMemo, useRef } from "react";
|
|
import {
|
|
Terminal as TerminalIcon,
|
|
Plus,
|
|
Lock,
|
|
Unlock,
|
|
SplitSquareHorizontal,
|
|
SplitSquareVertical,
|
|
Loader2,
|
|
AlertCircle,
|
|
RefreshCw,
|
|
X,
|
|
SquarePlus,
|
|
Settings,
|
|
} from "lucide-react";
|
|
import { useAppStore, type TerminalPanelContent, type TerminalTab, type PersistedTerminalPanel } from "@/store/app-store";
|
|
import { Button } from "@/components/ui/button";
|
|
import { Input } from "@/components/ui/input";
|
|
import { Label } from "@/components/ui/label";
|
|
import { Slider } from "@/components/ui/slider";
|
|
import {
|
|
Popover,
|
|
PopoverContent,
|
|
PopoverTrigger,
|
|
} from "@/components/ui/popover";
|
|
import { TERMINAL_FONT_OPTIONS } from "@/config/terminal-themes";
|
|
import { toast } from "sonner";
|
|
import {
|
|
Panel,
|
|
PanelGroup,
|
|
PanelResizeHandle,
|
|
} from "react-resizable-panels";
|
|
import { TerminalPanel } from "./terminal-view/terminal-panel";
|
|
import { TerminalErrorBoundary } from "./terminal-view/terminal-error-boundary";
|
|
import {
|
|
DndContext,
|
|
DragEndEvent,
|
|
DragStartEvent,
|
|
DragOverEvent,
|
|
PointerSensor,
|
|
TouchSensor,
|
|
KeyboardSensor,
|
|
useSensor,
|
|
useSensors,
|
|
closestCenter,
|
|
DragOverlay,
|
|
useDroppable,
|
|
useDraggable,
|
|
defaultDropAnimationSideEffects,
|
|
} 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 drag-drop support and double-click to rename
|
|
function TerminalTabButton({
|
|
tab,
|
|
isActive,
|
|
onClick,
|
|
onClose,
|
|
onRename,
|
|
isDropTarget,
|
|
isDraggingTab,
|
|
}: {
|
|
tab: TerminalTab;
|
|
isActive: boolean;
|
|
onClick: () => void;
|
|
onClose: () => void;
|
|
onRename: (newName: string) => void;
|
|
isDropTarget: boolean;
|
|
isDraggingTab: boolean;
|
|
}) {
|
|
const [isEditing, setIsEditing] = useState(false);
|
|
const [editName, setEditName] = useState(tab.name);
|
|
const inputRef = useRef<HTMLInputElement>(null);
|
|
|
|
const { setNodeRef: setDropRef, isOver } = useDroppable({
|
|
id: `tab-${tab.id}`,
|
|
data: { type: "tab", tabId: tab.id },
|
|
});
|
|
|
|
const {
|
|
attributes: dragAttributes,
|
|
listeners: dragListeners,
|
|
setNodeRef: setDragRef,
|
|
isDragging,
|
|
} = useDraggable({
|
|
id: `drag-tab-${tab.id}`,
|
|
data: { type: "drag-tab", tabId: tab.id },
|
|
});
|
|
|
|
// Combine refs
|
|
const setRefs = (node: HTMLDivElement | null) => {
|
|
setDropRef(node);
|
|
setDragRef(node);
|
|
};
|
|
|
|
// Focus input when entering edit mode
|
|
useEffect(() => {
|
|
if (isEditing && inputRef.current) {
|
|
inputRef.current.focus();
|
|
inputRef.current.select();
|
|
}
|
|
}, [isEditing]);
|
|
|
|
const handleDoubleClick = (e: React.MouseEvent) => {
|
|
e.stopPropagation();
|
|
setEditName(tab.name);
|
|
setIsEditing(true);
|
|
};
|
|
|
|
const handleKeyDown = (e: React.KeyboardEvent) => {
|
|
e.stopPropagation();
|
|
if (e.key === "Enter") {
|
|
e.preventDefault();
|
|
finishEditing();
|
|
} else if (e.key === "Escape") {
|
|
e.preventDefault();
|
|
setIsEditing(false);
|
|
setEditName(tab.name);
|
|
}
|
|
};
|
|
|
|
const finishEditing = () => {
|
|
const trimmedName = editName.trim();
|
|
if (trimmedName && trimmedName !== tab.name) {
|
|
onRename(trimmedName);
|
|
}
|
|
setIsEditing(false);
|
|
};
|
|
|
|
return (
|
|
<div
|
|
ref={setRefs}
|
|
{...dragAttributes}
|
|
{...dragListeners}
|
|
className={cn(
|
|
"flex items-center gap-1 px-3 py-1.5 text-sm rounded-t-md border-b-2 cursor-grab active:cursor-grabbing transition-colors select-none",
|
|
isActive
|
|
? "bg-background border-brand-500 text-foreground"
|
|
: "bg-muted border-transparent text-muted-foreground hover:text-foreground hover:bg-accent",
|
|
isOver && isDropTarget && isDraggingTab && "ring-2 ring-blue-500 bg-blue-500/10",
|
|
isDragging && "opacity-50"
|
|
)}
|
|
onClick={onClick}
|
|
onDoubleClick={handleDoubleClick}
|
|
>
|
|
<TerminalIcon className="h-3 w-3" />
|
|
{isEditing ? (
|
|
<input
|
|
ref={inputRef}
|
|
type="text"
|
|
value={editName}
|
|
onChange={(e) => setEditName(e.target.value)}
|
|
onKeyDown={handleKeyDown}
|
|
onBlur={finishEditing}
|
|
onClick={(e) => e.stopPropagation()}
|
|
className="w-20 px-1 py-0 text-sm bg-background border border-border rounded focus:outline-none focus:ring-1 focus:ring-brand-500"
|
|
/>
|
|
) : (
|
|
<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,
|
|
renameTerminalTab,
|
|
reorderTerminalTabs,
|
|
moveTerminalToTab,
|
|
setTerminalPanelFontSize,
|
|
setTerminalTabLayout,
|
|
toggleTerminalMaximized,
|
|
saveTerminalLayout,
|
|
getPersistedTerminalLayout,
|
|
clearTerminalState,
|
|
setTerminalDefaultFontSize,
|
|
setTerminalDefaultRunScript,
|
|
setTerminalFontFamily,
|
|
setTerminalLineHeight,
|
|
updateTerminalPanelSizes,
|
|
} = 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 [activeDragTabId, setActiveDragTabId] = useState<string | null>(null);
|
|
const [dragOverTabId, setDragOverTabId] = useState<string | null>(null);
|
|
const lastCreateTimeRef = useRef<number>(0);
|
|
const isCreatingRef = useRef<boolean>(false);
|
|
const prevProjectPathRef = useRef<string | null>(null);
|
|
const isRestoringLayoutRef = useRef<boolean>(false);
|
|
const [newSessionIds, setNewSessionIds] = useState<Set<string>>(new Set());
|
|
const [serverSessionInfo, setServerSessionInfo] = useState<{ current: number; max: number } | null>(null);
|
|
const hasShownHighRamWarningRef = useRef<boolean>(false);
|
|
|
|
// Show warning when 20+ terminals are open
|
|
useEffect(() => {
|
|
if (serverSessionInfo && serverSessionInfo.current >= 20 && !hasShownHighRamWarningRef.current) {
|
|
hasShownHighRamWarningRef.current = true;
|
|
toast.warning("Many terminals open", {
|
|
description: `${serverSessionInfo.current} terminals open. Each uses system resources (processes, memory). Consider closing unused terminals.`,
|
|
duration: 8000,
|
|
});
|
|
}
|
|
// Reset warning flag when session count drops below 20
|
|
if (serverSessionInfo && serverSessionInfo.current < 20) {
|
|
hasShownHighRamWarningRef.current = false;
|
|
}
|
|
}, [serverSessionInfo]);
|
|
|
|
// Get the default run script from terminal settings
|
|
const defaultRunScript = useAppStore((state) => state.terminalState.defaultRunScript);
|
|
|
|
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,
|
|
},
|
|
}),
|
|
useSensor(TouchSensor, {
|
|
activationConstraint: {
|
|
delay: 200,
|
|
tolerance: 5,
|
|
},
|
|
}),
|
|
useSensor(KeyboardSensor)
|
|
);
|
|
|
|
// Handle drag start
|
|
const handleDragStart = useCallback((event: DragStartEvent) => {
|
|
const activeId = event.active.id as string;
|
|
const activeData = event.active.data?.current;
|
|
|
|
if (activeData?.type === "drag-tab") {
|
|
// Tab being dragged
|
|
setActiveDragTabId(activeData.tabId);
|
|
setActiveDragId(null);
|
|
} else {
|
|
// Terminal panel being dragged
|
|
setActiveDragId(activeId);
|
|
setActiveDragTabId(null);
|
|
}
|
|
}, []);
|
|
|
|
// 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;
|
|
const activeData = active.data?.current;
|
|
|
|
// Reset drag states
|
|
setActiveDragId(null);
|
|
setActiveDragTabId(null);
|
|
setDragOverTabId(null);
|
|
|
|
if (!over) return;
|
|
|
|
const overData = over.data?.current;
|
|
|
|
// Handle tab-to-tab drag (reordering)
|
|
if (activeData?.type === "drag-tab" && overData?.type === "tab") {
|
|
const fromTabId = activeData.tabId as string;
|
|
const toTabId = overData.tabId as string;
|
|
if (fromTabId !== toTabId) {
|
|
reorderTerminalTabs(fromTabId, toTabId);
|
|
}
|
|
return;
|
|
}
|
|
|
|
// Handle terminal panel drops
|
|
const activeId = active.id as string;
|
|
|
|
// 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, reorderTerminalTabs]);
|
|
|
|
const handleDragCancel = useCallback(() => {
|
|
setActiveDragId(null);
|
|
setActiveDragTabId(null);
|
|
setDragOverTabId(null);
|
|
}, []);
|
|
|
|
// 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]);
|
|
|
|
// Fetch server session settings
|
|
const fetchServerSettings = useCallback(async () => {
|
|
if (!terminalState.isUnlocked) return;
|
|
try {
|
|
const headers: Record<string, string> = {};
|
|
if (terminalState.authToken) {
|
|
headers["X-Terminal-Token"] = terminalState.authToken;
|
|
}
|
|
const response = await fetch(`${serverUrl}/api/terminal/settings`, { headers });
|
|
const data = await response.json();
|
|
if (data.success) {
|
|
setServerSessionInfo({ current: data.data.currentSessions, max: data.data.maxSessions });
|
|
}
|
|
} catch (err) {
|
|
console.error("[Terminal] Failed to fetch server settings:", err);
|
|
}
|
|
}, [serverUrl, terminalState.isUnlocked, terminalState.authToken]);
|
|
|
|
useEffect(() => {
|
|
fetchStatus();
|
|
}, [fetchStatus]);
|
|
|
|
// Fetch server settings when terminal is unlocked
|
|
useEffect(() => {
|
|
if (terminalState.isUnlocked) {
|
|
fetchServerSettings();
|
|
}
|
|
}, [terminalState.isUnlocked, fetchServerSettings]);
|
|
|
|
// Handle project switching - save and restore terminal layouts
|
|
useEffect(() => {
|
|
const currentPath = currentProject?.path || null;
|
|
const prevPath = prevProjectPathRef.current;
|
|
|
|
// Skip if no change or if we're restoring layout
|
|
if (currentPath === prevPath || isRestoringLayoutRef.current) {
|
|
return;
|
|
}
|
|
|
|
// Save layout for previous project (if there was one and has terminals)
|
|
if (prevPath && terminalState.tabs.length > 0) {
|
|
saveTerminalLayout(prevPath);
|
|
}
|
|
|
|
// Update the previous project ref
|
|
prevProjectPathRef.current = currentPath;
|
|
|
|
// If no current project, just clear terminals
|
|
if (!currentPath) {
|
|
clearTerminalState();
|
|
return;
|
|
}
|
|
|
|
// Check for saved layout for this project
|
|
const savedLayout = getPersistedTerminalLayout(currentPath);
|
|
|
|
if (savedLayout && savedLayout.tabs.length > 0) {
|
|
// Restore the saved layout - try to reconnect to existing sessions
|
|
isRestoringLayoutRef.current = true;
|
|
|
|
// Clear existing terminals first (only client state, sessions stay on server)
|
|
clearTerminalState();
|
|
|
|
// Create terminals and build layout - try to reconnect or create new
|
|
const restoreLayout = async () => {
|
|
let failedSessions = 0;
|
|
let totalSessions = 0;
|
|
let reconnectedSessions = 0;
|
|
|
|
try {
|
|
const headers: Record<string, string> = {
|
|
"Content-Type": "application/json",
|
|
};
|
|
// Get fresh auth token from store
|
|
const authToken = useAppStore.getState().terminalState.authToken;
|
|
if (authToken) {
|
|
headers["X-Terminal-Token"] = authToken;
|
|
}
|
|
|
|
// Helper to check if a session still exists on server
|
|
const checkSessionExists = async (sessionId: string): Promise<boolean> => {
|
|
try {
|
|
const response = await fetch(`${serverUrl}/api/terminal/sessions/${sessionId}`, {
|
|
method: "GET",
|
|
headers,
|
|
});
|
|
const data = await response.json();
|
|
return data.success === true;
|
|
} catch {
|
|
return false;
|
|
}
|
|
};
|
|
|
|
// Helper to create a new terminal session
|
|
const createSession = async (): Promise<string | null> => {
|
|
try {
|
|
const response = await fetch(`${serverUrl}/api/terminal/sessions`, {
|
|
method: "POST",
|
|
headers,
|
|
body: JSON.stringify({
|
|
cwd: currentPath,
|
|
cols: 80,
|
|
rows: 24,
|
|
}),
|
|
});
|
|
const data = await response.json();
|
|
return data.success ? data.data.id : null;
|
|
} catch (err) {
|
|
console.error("[Terminal] Failed to create terminal session:", err);
|
|
return null;
|
|
}
|
|
};
|
|
|
|
// Recursively rebuild the layout - reuse existing sessions or create new
|
|
const rebuildLayout = async (
|
|
persisted: PersistedTerminalPanel
|
|
): Promise<TerminalPanelContent | null> => {
|
|
if (persisted.type === "terminal") {
|
|
totalSessions++;
|
|
let sessionId: string | null = null;
|
|
|
|
// If we have a saved sessionId, try to reconnect to it
|
|
if (persisted.sessionId) {
|
|
const exists = await checkSessionExists(persisted.sessionId);
|
|
if (exists) {
|
|
sessionId = persisted.sessionId;
|
|
reconnectedSessions++;
|
|
}
|
|
}
|
|
|
|
// If no saved session or it's gone, create a new one
|
|
if (!sessionId) {
|
|
sessionId = await createSession();
|
|
}
|
|
|
|
if (!sessionId) {
|
|
failedSessions++;
|
|
return null;
|
|
}
|
|
|
|
return {
|
|
type: "terminal",
|
|
sessionId,
|
|
size: persisted.size,
|
|
fontSize: persisted.fontSize,
|
|
};
|
|
}
|
|
|
|
// It's a split - rebuild all child panels
|
|
const childPanels: TerminalPanelContent[] = [];
|
|
for (const childPersisted of persisted.panels) {
|
|
const rebuilt = await rebuildLayout(childPersisted);
|
|
if (rebuilt) {
|
|
childPanels.push(rebuilt);
|
|
}
|
|
}
|
|
|
|
// If no children were rebuilt, return null
|
|
if (childPanels.length === 0) return null;
|
|
|
|
// If only one child, return it directly (collapse the split)
|
|
if (childPanels.length === 1) return childPanels[0];
|
|
|
|
return {
|
|
type: "split",
|
|
id: persisted.id || `split-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
|
|
direction: persisted.direction,
|
|
panels: childPanels,
|
|
size: persisted.size,
|
|
};
|
|
};
|
|
|
|
// For each saved tab, rebuild the layout
|
|
for (let tabIndex = 0; tabIndex < savedLayout.tabs.length; tabIndex++) {
|
|
const savedTab = savedLayout.tabs[tabIndex];
|
|
|
|
// Create the tab first
|
|
const newTabId = addTerminalTab(savedTab.name);
|
|
|
|
if (savedTab.layout) {
|
|
const rebuiltLayout = await rebuildLayout(savedTab.layout);
|
|
if (rebuiltLayout) {
|
|
const { setTerminalTabLayout } = useAppStore.getState();
|
|
setTerminalTabLayout(newTabId, rebuiltLayout);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Set active tab based on saved index
|
|
if (savedLayout.tabs.length > 0) {
|
|
const { setActiveTerminalTab } = useAppStore.getState();
|
|
const newTabs = useAppStore.getState().terminalState.tabs;
|
|
if (newTabs.length > savedLayout.activeTabIndex) {
|
|
setActiveTerminalTab(newTabs[savedLayout.activeTabIndex].id);
|
|
}
|
|
}
|
|
|
|
if (failedSessions > 0) {
|
|
toast.error("Some terminals failed to restore", {
|
|
description: `${failedSessions} of ${totalSessions} terminal sessions could not be created. The server may be unavailable.`,
|
|
duration: 5000,
|
|
});
|
|
} else if (reconnectedSessions > 0) {
|
|
toast.success("Terminals restored", {
|
|
description: `Reconnected to ${reconnectedSessions} existing session${reconnectedSessions > 1 ? "s" : ""}`,
|
|
duration: 3000,
|
|
});
|
|
}
|
|
} catch (err) {
|
|
console.error("[Terminal] Failed to restore terminal layout:", err);
|
|
toast.error("Failed to restore terminals", {
|
|
description: "Could not restore terminal layout. Please try creating new terminals.",
|
|
duration: 5000,
|
|
});
|
|
} finally {
|
|
isRestoringLayoutRef.current = false;
|
|
}
|
|
};
|
|
|
|
restoreLayout();
|
|
}
|
|
}, [currentProject?.path, saveTerminalLayout, getPersistedTerminalLayout, clearTerminalState, addTerminalTab, serverUrl]);
|
|
|
|
// Save terminal layout whenever it changes (debounced via the effect)
|
|
// Also save when tabs become empty so closed terminals stay closed on refresh
|
|
useEffect(() => {
|
|
if (currentProject?.path && !isRestoringLayoutRef.current) {
|
|
saveTerminalLayout(currentProject.path);
|
|
}
|
|
}, [terminalState.tabs, currentProject?.path, saveTerminalLayout]);
|
|
|
|
// 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);
|
|
// Mark this session as new for running initial command
|
|
if (defaultRunScript) {
|
|
setNewSessionIds(prev => new Set(prev).add(data.data.id));
|
|
}
|
|
// Refresh session count
|
|
fetchServerSettings();
|
|
} else {
|
|
// Handle session limit error with a helpful toast
|
|
if (response.status === 429 || data.error?.includes("Maximum")) {
|
|
toast.error("Terminal session limit reached", {
|
|
description: data.details || `Please close unused terminals. Limit: ${data.maxSessions || "unknown"}`,
|
|
});
|
|
} else {
|
|
console.error("[Terminal] Failed to create session:", data.error);
|
|
toast.error("Failed to create terminal", {
|
|
description: data.error || "Unknown error",
|
|
});
|
|
}
|
|
}
|
|
} catch (err) {
|
|
console.error("[Terminal] Create session error:", err);
|
|
toast.error("Failed to create terminal", {
|
|
description: "Could not connect to server",
|
|
});
|
|
} 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);
|
|
// Mark this session as new for running initial command
|
|
if (defaultRunScript) {
|
|
setNewSessionIds(prev => new Set(prev).add(data.data.id));
|
|
}
|
|
// Refresh session count
|
|
fetchServerSettings();
|
|
} else {
|
|
// Remove the empty tab that was created
|
|
const { removeTerminalTab } = useAppStore.getState();
|
|
removeTerminalTab(tabId);
|
|
|
|
// Handle session limit error with a helpful toast
|
|
if (response.status === 429 || data.error?.includes("Maximum")) {
|
|
toast.error("Terminal session limit reached", {
|
|
description: data.details || `Please close unused terminals. Limit: ${data.maxSessions || "unknown"}`,
|
|
});
|
|
} else {
|
|
toast.error("Failed to create terminal", {
|
|
description: data.error || "Unknown error",
|
|
});
|
|
}
|
|
}
|
|
} catch (err) {
|
|
console.error("[Terminal] Create session error:", err);
|
|
// Remove the empty tab on error
|
|
const { removeTerminalTab } = useAppStore.getState();
|
|
removeTerminalTab(tabId);
|
|
toast.error("Failed to create terminal", {
|
|
description: "Could not connect to server",
|
|
});
|
|
} 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;
|
|
}
|
|
|
|
const response = await fetch(`${serverUrl}/api/terminal/sessions/${sessionId}`, {
|
|
method: "DELETE",
|
|
headers,
|
|
});
|
|
|
|
// Always remove from UI - even if server says 404 (session may have already exited)
|
|
removeTerminalFromLayout(sessionId);
|
|
|
|
if (!response.ok && response.status !== 404) {
|
|
// Log non-404 errors but still proceed with UI cleanup
|
|
const data = await response.json().catch(() => ({}));
|
|
console.error("[Terminal] Server failed to kill session:", data.error || response.statusText);
|
|
}
|
|
|
|
// Refresh session count
|
|
fetchServerSettings();
|
|
} catch (err) {
|
|
console.error("[Terminal] Kill session error:", err);
|
|
// Still remove from UI on network error - better UX than leaving broken terminal
|
|
removeTerminalFromLayout(sessionId);
|
|
}
|
|
};
|
|
|
|
// Kill all terminals in a tab and then remove the tab
|
|
const killTerminalTab = async (tabId: string) => {
|
|
const tab = terminalState.tabs.find((t) => t.id === tabId);
|
|
if (!tab) return;
|
|
|
|
// Collect all session IDs from the tab's layout
|
|
const collectSessionIds = (
|
|
node: TerminalPanelContent | null
|
|
): string[] => {
|
|
if (!node) return [];
|
|
if (node.type === "terminal") return [node.sessionId];
|
|
return node.panels.flatMap(collectSessionIds);
|
|
};
|
|
|
|
const sessionIds = collectSessionIds(tab.layout);
|
|
|
|
// Kill all sessions on the server
|
|
const headers: Record<string, string> = {};
|
|
if (terminalState.authToken) {
|
|
headers["X-Terminal-Token"] = terminalState.authToken;
|
|
}
|
|
|
|
await Promise.all(
|
|
sessionIds.map(async (sessionId) => {
|
|
try {
|
|
await fetch(`${serverUrl}/api/terminal/sessions/${sessionId}`, {
|
|
method: "DELETE",
|
|
headers,
|
|
});
|
|
} catch (err) {
|
|
console.error(`[Terminal] Failed to kill session ${sessionId}:`, err);
|
|
}
|
|
})
|
|
);
|
|
|
|
// Now remove the tab from state
|
|
removeTerminalTab(tabId);
|
|
// Refresh session count
|
|
fetchServerSettings();
|
|
};
|
|
|
|
// NOTE: Terminal keyboard shortcuts (Alt+D, Alt+S, Alt+W) are handled in
|
|
// terminal-panel.tsx via attachCustomKeyEventHandler. This is more reliable
|
|
// because it uses event.code (keyboard-layout independent) instead of event.key
|
|
// which can produce special characters when Alt is pressed on some systems.
|
|
// See: terminal-panel.tsx lines 319-399 for the shortcut handlers.
|
|
|
|
// 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 - uses the stable id for splits
|
|
// This prevents unnecessary remounts when layout structure changes
|
|
const getPanelKey = (panel: TerminalPanelContent): string => {
|
|
if (panel.type === "terminal") {
|
|
return panel.sessionId;
|
|
}
|
|
// Use the stable id for split nodes
|
|
return panel.id;
|
|
};
|
|
|
|
const findTerminalFontSize = useCallback((sessionId: string): number => {
|
|
const findInPanel = (panel: TerminalPanelContent): number | null => {
|
|
if (panel.type === "terminal") {
|
|
if (panel.sessionId === sessionId) {
|
|
return panel.fontSize ?? terminalState.defaultFontSize;
|
|
}
|
|
return null;
|
|
}
|
|
for (const child of panel.panels) {
|
|
const found = findInPanel(child);
|
|
if (found !== null) return found;
|
|
}
|
|
return null;
|
|
};
|
|
|
|
// Search across all tabs
|
|
for (const tab of terminalState.tabs) {
|
|
if (tab.layout) {
|
|
const found = findInPanel(tab.layout);
|
|
if (found !== null) return found;
|
|
}
|
|
}
|
|
return terminalState.defaultFontSize;
|
|
}, [terminalState.tabs, terminalState.defaultFontSize]);
|
|
|
|
// Handler for when a terminal has run its initial command
|
|
const handleCommandRan = useCallback((sessionId: string) => {
|
|
setNewSessionIds(prev => {
|
|
const next = new Set(prev);
|
|
next.delete(sessionId);
|
|
return next;
|
|
});
|
|
}, []);
|
|
|
|
// Navigate between terminal panes with Ctrl+Alt+Arrow keys
|
|
const navigateToTerminal = useCallback((direction: "next" | "prev") => {
|
|
if (!activeTab?.layout) return;
|
|
|
|
const terminalIds = getTerminalIds(activeTab.layout);
|
|
if (terminalIds.length <= 1) return;
|
|
|
|
const currentIndex = terminalIds.indexOf(terminalState.activeSessionId || "");
|
|
if (currentIndex === -1) {
|
|
// If no terminal is active, focus the first one
|
|
setActiveTerminalSession(terminalIds[0]);
|
|
return;
|
|
}
|
|
|
|
let newIndex: number;
|
|
if (direction === "next") {
|
|
newIndex = (currentIndex + 1) % terminalIds.length;
|
|
} else {
|
|
newIndex = (currentIndex - 1 + terminalIds.length) % terminalIds.length;
|
|
}
|
|
|
|
setActiveTerminalSession(terminalIds[newIndex]);
|
|
}, [activeTab?.layout, terminalState.activeSessionId, setActiveTerminalSession]);
|
|
|
|
// Handle global keyboard shortcuts for pane navigation
|
|
useEffect(() => {
|
|
const handleKeyDown = (e: KeyboardEvent) => {
|
|
// Ctrl+Alt+Arrow (or Cmd+Alt+Arrow on Mac) for pane navigation
|
|
if ((e.ctrlKey || e.metaKey) && e.altKey && !e.shiftKey) {
|
|
if (e.key === "ArrowRight" || e.key === "ArrowDown") {
|
|
e.preventDefault();
|
|
navigateToTerminal("next");
|
|
} else if (e.key === "ArrowLeft" || e.key === "ArrowUp") {
|
|
e.preventDefault();
|
|
navigateToTerminal("prev");
|
|
}
|
|
}
|
|
};
|
|
|
|
window.addEventListener("keydown", handleKeyDown);
|
|
return () => window.removeEventListener("keydown", handleKeyDown);
|
|
}, [navigateToTerminal]);
|
|
|
|
// 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;
|
|
// Only run command on new sessions (not restored ones)
|
|
const isNewSession = newSessionIds.has(content.sessionId);
|
|
return (
|
|
<TerminalErrorBoundary
|
|
key={`boundary-${content.sessionId}`}
|
|
sessionId={content.sessionId}
|
|
onRestart={() => {
|
|
// When terminal crashes and is restarted, recreate the session
|
|
killTerminal(content.sessionId);
|
|
createTerminal();
|
|
}}
|
|
>
|
|
<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)}
|
|
onNewTab={createTerminalInNewTab}
|
|
isDragging={activeDragId === content.sessionId}
|
|
isDropTarget={activeDragId !== null && activeDragId !== content.sessionId}
|
|
fontSize={terminalFontSize}
|
|
onFontSizeChange={(size) => setTerminalPanelFontSize(content.sessionId, size)}
|
|
runCommandOnConnect={isNewSession ? defaultRunScript : undefined}
|
|
onCommandRan={() => handleCommandRan(content.sessionId)}
|
|
isMaximized={terminalState.maximizedSessionId === content.sessionId}
|
|
onToggleMaximize={() => toggleTerminalMaximized(content.sessionId)}
|
|
/>
|
|
</TerminalErrorBoundary>
|
|
);
|
|
}
|
|
|
|
const isHorizontal = content.direction === "horizontal";
|
|
const defaultSizePerPanel = 100 / content.panels.length;
|
|
|
|
const handleLayoutChange = (sizes: number[]) => {
|
|
if (!activeTab) return;
|
|
const panelKeys = content.panels.map(getPanelKey);
|
|
updateTerminalPanelSizes(activeTab.id, panelKeys, sizes);
|
|
};
|
|
|
|
return (
|
|
<PanelGroup direction={content.direction} onLayout={handleLayoutChange}>
|
|
{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}
|
|
onDragCancel={handleDragCancel}
|
|
>
|
|
<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={() => killTerminalTab(tab.id)}
|
|
onRename={(newName) => renameTerminalTab(tab.id, newName)}
|
|
isDropTarget={activeDragId !== null || activeDragTabId !== null}
|
|
isDraggingTab={activeDragTabId !== null}
|
|
/>
|
|
))}
|
|
|
|
{(activeDragId || activeDragTabId) && (
|
|
<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>
|
|
|
|
{/* Global Terminal Settings */}
|
|
<Popover>
|
|
<PopoverTrigger asChild>
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
className="h-7 px-2 text-muted-foreground hover:text-foreground"
|
|
title="Terminal Settings"
|
|
>
|
|
<Settings className="h-4 w-4" />
|
|
</Button>
|
|
</PopoverTrigger>
|
|
<PopoverContent className="w-80" align="end">
|
|
<div className="space-y-4">
|
|
<div className="space-y-2">
|
|
<h4 className="font-medium text-sm">Terminal Settings</h4>
|
|
<p className="text-xs text-muted-foreground">
|
|
Configure global terminal appearance
|
|
</p>
|
|
</div>
|
|
|
|
{/* Default Font Size */}
|
|
<div className="space-y-2">
|
|
<div className="flex items-center justify-between">
|
|
<Label className="text-sm">Default Font Size</Label>
|
|
<span className="text-sm text-muted-foreground">{terminalState.defaultFontSize}px</span>
|
|
</div>
|
|
<Slider
|
|
value={[terminalState.defaultFontSize]}
|
|
min={8}
|
|
max={24}
|
|
step={1}
|
|
onValueChange={([value]) => setTerminalDefaultFontSize(value)}
|
|
onValueCommit={() => {
|
|
toast.info("Font size changed", {
|
|
description: "New terminals will use this size",
|
|
});
|
|
}}
|
|
/>
|
|
</div>
|
|
|
|
{/* Font Family */}
|
|
<div className="space-y-2">
|
|
<Label className="text-sm">Font Family</Label>
|
|
<select
|
|
value={terminalState.fontFamily}
|
|
onChange={(e) => {
|
|
setTerminalFontFamily(e.target.value);
|
|
toast.info("Font family changed", {
|
|
description: "Restart terminal for changes to take effect",
|
|
});
|
|
}}
|
|
className={cn(
|
|
"w-full px-2 py-1.5 rounded-md text-sm",
|
|
"bg-accent/50 border border-border",
|
|
"text-foreground",
|
|
"focus:outline-none focus:ring-2 focus:ring-ring"
|
|
)}
|
|
>
|
|
{TERMINAL_FONT_OPTIONS.map((font) => (
|
|
<option key={font.value} value={font.value}>
|
|
{font.label}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
|
|
{/* Line Height */}
|
|
<div className="space-y-2">
|
|
<div className="flex items-center justify-between">
|
|
<Label className="text-sm">Line Height</Label>
|
|
<span className="text-sm text-muted-foreground">{terminalState.lineHeight.toFixed(1)}</span>
|
|
</div>
|
|
<Slider
|
|
value={[terminalState.lineHeight]}
|
|
min={1.0}
|
|
max={2.0}
|
|
step={0.1}
|
|
onValueChange={([value]) => setTerminalLineHeight(value)}
|
|
onValueCommit={() => {
|
|
toast.info("Line height changed", {
|
|
description: "Restart terminal for changes to take effect",
|
|
});
|
|
}}
|
|
/>
|
|
</div>
|
|
|
|
{/* Default Run Script */}
|
|
<div className="space-y-2">
|
|
<Label className="text-sm">Default Run Script</Label>
|
|
<Input
|
|
value={terminalState.defaultRunScript}
|
|
onChange={(e) => setTerminalDefaultRunScript(e.target.value)}
|
|
placeholder="e.g., claude, npm run dev"
|
|
className="h-8 text-sm"
|
|
/>
|
|
<p className="text-xs text-muted-foreground">
|
|
Command to run when opening new terminals
|
|
</p>
|
|
</div>
|
|
|
|
</div>
|
|
</PopoverContent>
|
|
</Popover>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Active tab content */}
|
|
<div className="flex-1 overflow-hidden bg-background">
|
|
{terminalState.maximizedSessionId ? (
|
|
// When a terminal is maximized, render only that terminal
|
|
<TerminalErrorBoundary
|
|
key={`boundary-maximized-${terminalState.maximizedSessionId}`}
|
|
sessionId={terminalState.maximizedSessionId}
|
|
onRestart={() => {
|
|
const sessionId = terminalState.maximizedSessionId!;
|
|
toggleTerminalMaximized(sessionId);
|
|
killTerminal(sessionId);
|
|
createTerminal();
|
|
}}
|
|
>
|
|
<TerminalPanel
|
|
key={`maximized-${terminalState.maximizedSessionId}`}
|
|
sessionId={terminalState.maximizedSessionId}
|
|
authToken={terminalState.authToken}
|
|
isActive={true}
|
|
onFocus={() => setActiveTerminalSession(terminalState.maximizedSessionId!)}
|
|
onClose={() => killTerminal(terminalState.maximizedSessionId!)}
|
|
onSplitHorizontal={() => createTerminal("horizontal", terminalState.maximizedSessionId!)}
|
|
onSplitVertical={() => createTerminal("vertical", terminalState.maximizedSessionId!)}
|
|
onNewTab={createTerminalInNewTab}
|
|
isDragging={false}
|
|
isDropTarget={false}
|
|
fontSize={findTerminalFontSize(terminalState.maximizedSessionId)}
|
|
onFontSizeChange={(size) => setTerminalPanelFontSize(terminalState.maximizedSessionId!, size)}
|
|
isMaximized={true}
|
|
onToggleMaximize={() => toggleTerminalMaximized(terminalState.maximizedSessionId!)}
|
|
/>
|
|
</TerminalErrorBoundary>
|
|
) : 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={{
|
|
sideEffects: defaultDropAnimationSideEffects({
|
|
styles: { active: { opacity: "0.5" } },
|
|
}),
|
|
}}
|
|
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>
|
|
);
|
|
}
|