mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-01-31 06:42:03 +00:00
Users with existing persisted state won't have the new terminal shortcuts, so guard against undefined values. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
679 lines
21 KiB
TypeScript
679 lines
21 KiB
TypeScript
"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 (
|
|
<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 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<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);
|
|
}
|
|
};
|
|
|
|
// Create terminal in new tab
|
|
const createTerminalInNewTab = async () => {
|
|
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);
|
|
}
|
|
};
|
|
|
|
// 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');
|
|
|
|
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 (
|
|
<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;
|
|
|
|
return (
|
|
<React.Fragment key={getPanelKey(panel)}>
|
|
{index > 0 && (
|
|
<PanelResizeHandle
|
|
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 defaultSize={panelSize} minSize={15}>
|
|
{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>
|
|
);
|
|
}
|