mirror of
https://github.com/binaricat/Netcatty.git
synced 2026-10-09 15:58:34 +00:00
ccf707df5a
Co-authored-by: binaricat <16399091+binaricat@users.noreply.github.com>
792 lines
32 KiB
TypeScript
792 lines
32 KiB
TypeScript
import { Circle, LayoutGrid, Server } from 'lucide-react';
|
|
import React, { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
|
import { useActiveTabId } from '../application/state/activeTabStore';
|
|
import { useTerminalBackend } from '../application/state/useTerminalBackend';
|
|
import { collectSessionIds } from '../domain/workspace';
|
|
import { SplitDirection } from '../domain/workspace';
|
|
import { KeyBinding, TerminalSettings } from '../domain/models';
|
|
import { cn } from '../lib/utils';
|
|
import { Host, Identity, KnownHost, SSHKey, Snippet, TerminalSession, TerminalTheme, Workspace, WorkspaceNode } from '../types';
|
|
import { DistroAvatar } from './DistroAvatar';
|
|
import Terminal from './Terminal';
|
|
import { Button } from './ui/button';
|
|
import { ScrollArea } from './ui/scroll-area';
|
|
|
|
type WorkspaceRect = { x: number; y: number; w: number; h: number };
|
|
|
|
type SplitHint = {
|
|
direction: 'horizontal' | 'vertical';
|
|
position: 'left' | 'right' | 'top' | 'bottom';
|
|
targetSessionId?: string;
|
|
rect?: { x: number; y: number; w: number; h: number };
|
|
} | null;
|
|
|
|
type ResizerHandle = {
|
|
id: string;
|
|
splitId: string;
|
|
index: number;
|
|
direction: 'vertical' | 'horizontal';
|
|
rect: { x: number; y: number; w: number; h: number };
|
|
splitArea: { w: number; h: number };
|
|
};
|
|
|
|
interface TerminalLayerProps {
|
|
hosts: Host[];
|
|
keys: SSHKey[];
|
|
identities: Identity[];
|
|
snippets: Snippet[];
|
|
sessions: TerminalSession[];
|
|
workspaces: Workspace[];
|
|
knownHosts?: KnownHost[];
|
|
draggingSessionId: string | null;
|
|
terminalTheme: TerminalTheme;
|
|
terminalSettings?: TerminalSettings;
|
|
terminalFontFamilyId: string;
|
|
fontSize?: number;
|
|
hotkeyScheme?: 'disabled' | 'mac' | 'pc';
|
|
keyBindings?: KeyBinding[];
|
|
onHotkeyAction?: (action: string, event: KeyboardEvent) => void;
|
|
onUpdateTerminalThemeId?: (themeId: string) => void;
|
|
onUpdateTerminalFontFamilyId?: (fontFamilyId: string) => void;
|
|
onUpdateTerminalFontSize?: (fontSize: number) => void;
|
|
onCloseSession: (sessionId: string, e?: React.MouseEvent) => void;
|
|
onUpdateSessionStatus: (sessionId: string, status: TerminalSession['status']) => void;
|
|
onUpdateHostDistro: (hostId: string, distro: string) => void;
|
|
onUpdateHost: (host: Host) => void;
|
|
onAddKnownHost?: (knownHost: KnownHost) => void;
|
|
onCommandExecuted?: (command: string, hostId: string, hostLabel: string, sessionId: string) => void;
|
|
onTerminalDataCapture?: (sessionId: string, data: string) => void;
|
|
onCreateWorkspaceFromSessions: (baseSessionId: string, joiningSessionId: string, hint: Exclude<SplitHint, null>) => void;
|
|
onAddSessionToWorkspace: (workspaceId: string, sessionId: string, hint: Exclude<SplitHint, null>) => void;
|
|
onUpdateSplitSizes: (workspaceId: string, splitId: string, sizes: number[]) => void;
|
|
onSetDraggingSessionId: (id: string | null) => void;
|
|
onToggleWorkspaceViewMode?: (workspaceId: string) => void;
|
|
onSetWorkspaceFocusedSession?: (workspaceId: string, sessionId: string) => void;
|
|
onSplitSession?: (sessionId: string, direction: SplitDirection) => void;
|
|
// Broadcast mode
|
|
isBroadcastEnabled?: (workspaceId: string) => boolean;
|
|
onToggleBroadcast?: (workspaceId: string) => void;
|
|
}
|
|
|
|
const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
|
hosts,
|
|
keys,
|
|
identities,
|
|
snippets,
|
|
sessions,
|
|
workspaces,
|
|
knownHosts = [],
|
|
draggingSessionId,
|
|
terminalTheme,
|
|
terminalSettings,
|
|
terminalFontFamilyId,
|
|
fontSize = 14,
|
|
hotkeyScheme = 'disabled',
|
|
keyBindings = [],
|
|
onHotkeyAction,
|
|
onUpdateTerminalThemeId,
|
|
onUpdateTerminalFontFamilyId,
|
|
onUpdateTerminalFontSize,
|
|
onCloseSession,
|
|
onUpdateSessionStatus,
|
|
onUpdateHostDistro,
|
|
onUpdateHost,
|
|
onAddKnownHost,
|
|
onCommandExecuted,
|
|
onTerminalDataCapture,
|
|
onCreateWorkspaceFromSessions,
|
|
onAddSessionToWorkspace,
|
|
onUpdateSplitSizes,
|
|
onSetDraggingSessionId,
|
|
onToggleWorkspaceViewMode,
|
|
onSetWorkspaceFocusedSession,
|
|
onSplitSession,
|
|
isBroadcastEnabled,
|
|
onToggleBroadcast,
|
|
}) => {
|
|
// Subscribe to activeTabId from external store
|
|
const activeTabId = useActiveTabId();
|
|
const isVaultActive = activeTabId === 'vault';
|
|
const isSftpActive = activeTabId === 'sftp';
|
|
const isVisible = (!isVaultActive && !isSftpActive) || !!draggingSessionId;
|
|
|
|
// Stable callback references for Terminal components
|
|
const handleCloseSession = useCallback((sessionId: string) => {
|
|
onCloseSession(sessionId);
|
|
}, [onCloseSession]);
|
|
|
|
const handleStatusChange = useCallback((sessionId: string, status: TerminalSession['status']) => {
|
|
onUpdateSessionStatus(sessionId, status);
|
|
}, [onUpdateSessionStatus]);
|
|
|
|
const handleSessionExit = useCallback((sessionId: string) => {
|
|
onUpdateSessionStatus(sessionId, 'disconnected');
|
|
}, [onUpdateSessionStatus]);
|
|
|
|
const handleOsDetected = useCallback((hostId: string, distro: string) => {
|
|
onUpdateHostDistro(hostId, distro);
|
|
}, [onUpdateHostDistro]);
|
|
|
|
const handleUpdateHost = useCallback((host: Host) => {
|
|
onUpdateHost(host);
|
|
}, [onUpdateHost]);
|
|
|
|
const handleAddKnownHost = useCallback((knownHost: KnownHost) => {
|
|
onAddKnownHost?.(knownHost);
|
|
}, [onAddKnownHost]);
|
|
|
|
const handleCommandExecuted = useCallback((command: string, hostId: string, hostLabel: string, sessionId: string) => {
|
|
onCommandExecuted?.(command, hostId, hostLabel, sessionId);
|
|
}, [onCommandExecuted]);
|
|
|
|
const handleTerminalDataCapture = useCallback((sessionId: string, data: string) => {
|
|
onTerminalDataCapture?.(sessionId, data);
|
|
}, [onTerminalDataCapture]);
|
|
|
|
// Terminal backend for broadcast writes
|
|
const terminalBackend = useTerminalBackend();
|
|
|
|
const [workspaceArea, setWorkspaceArea] = useState<{ width: number; height: number }>({ width: 0, height: 0 });
|
|
const workspaceOuterRef = useRef<HTMLDivElement>(null);
|
|
const workspaceInnerRef = useRef<HTMLDivElement>(null);
|
|
const workspaceOverlayRef = useRef<HTMLDivElement>(null);
|
|
const [dropHint, setDropHint] = useState<SplitHint>(null);
|
|
const [resizing, setResizing] = useState<{
|
|
workspaceId: string;
|
|
splitId: string;
|
|
index: number;
|
|
direction: 'vertical' | 'horizontal';
|
|
startSizes: number[];
|
|
startArea: { w: number; h: number };
|
|
startClient: { x: number; y: number };
|
|
} | null>(null);
|
|
|
|
const activeWorkspace = useMemo(() => workspaces.find(w => w.id === activeTabId), [workspaces, activeTabId]);
|
|
const activeSession = useMemo(() => sessions.find(s => s.id === activeTabId), [sessions, activeTabId]);
|
|
|
|
// Handle broadcast input - write to all other sessions in the same workspace
|
|
const handleBroadcastInput = useCallback((data: string, sourceSessionId: string) => {
|
|
if (!activeWorkspace) return;
|
|
|
|
// Get all session IDs in this workspace
|
|
const workspaceSessionIds = sessions
|
|
.filter(s => s.workspaceId === activeWorkspace.id && s.id !== sourceSessionId)
|
|
.map(s => s.id);
|
|
|
|
// Write to all other sessions
|
|
for (const targetSessionId of workspaceSessionIds) {
|
|
terminalBackend.writeToSession(targetSessionId, data);
|
|
}
|
|
}, [activeWorkspace, sessions, terminalBackend]);
|
|
|
|
// Pre-compute host lookup map for O(1) access
|
|
const hostMap = useMemo(() => {
|
|
const map = new Map<string, Host>();
|
|
for (const h of hosts) map.set(h.id, h);
|
|
return map;
|
|
}, [hosts]);
|
|
|
|
// Pre-compute fallback hosts to avoid creating new objects on every render
|
|
const sessionHostsMap = useMemo(() => {
|
|
const map = new Map<string, Host>();
|
|
for (const session of sessions) {
|
|
const existingHost = hostMap.get(session.hostId);
|
|
if (existingHost) {
|
|
// Apply session-time protocol overrides to the host
|
|
const hostWithOverrides: Host = {
|
|
...existingHost,
|
|
// Use session protocol settings if provided (from connection-time selection)
|
|
protocol: session.protocol ?? existingHost.protocol,
|
|
port: session.port ?? existingHost.port,
|
|
moshEnabled: session.moshEnabled ?? existingHost.moshEnabled,
|
|
};
|
|
map.set(session.id, hostWithOverrides);
|
|
} else {
|
|
// Create stable fallback host object
|
|
map.set(session.id, {
|
|
id: session.hostId,
|
|
label: session.hostLabel || 'Local Terminal',
|
|
hostname: session.hostname || 'localhost',
|
|
username: session.username || 'local',
|
|
port: session.port ?? 22,
|
|
os: 'linux',
|
|
group: '',
|
|
tags: [],
|
|
protocol: session.protocol ?? 'local' as const,
|
|
moshEnabled: session.moshEnabled,
|
|
});
|
|
}
|
|
}
|
|
return map;
|
|
}, [sessions, hostMap]);
|
|
|
|
const computeWorkspaceRects = useCallback((workspace?: Workspace, size?: { width: number; height: number }): Record<string, WorkspaceRect> => {
|
|
if (!workspace) return {} as Record<string, WorkspaceRect>;
|
|
const wTotal = size?.width || 1;
|
|
const hTotal = size?.height || 1;
|
|
const rects: Record<string, WorkspaceRect> = {};
|
|
const walk = (node: WorkspaceNode, area: WorkspaceRect) => {
|
|
if (node.type === 'pane') {
|
|
rects[node.sessionId] = area;
|
|
return;
|
|
}
|
|
const isVertical = node.direction === 'vertical';
|
|
const sizes = (node.sizes && node.sizes.length === node.children.length ? node.sizes : Array(node.children.length).fill(1));
|
|
const total = sizes.reduce((acc, n) => acc + n, 0) || 1;
|
|
let offset = 0;
|
|
node.children.forEach((child, idx) => {
|
|
const share = sizes[idx] / total;
|
|
const childArea = isVertical
|
|
? { x: area.x + area.w * offset, y: area.y, w: area.w * share, h: area.h }
|
|
: { x: area.x, y: area.y + area.h * offset, w: area.w, h: area.h * share };
|
|
walk(child, childArea);
|
|
offset += share;
|
|
});
|
|
};
|
|
walk(workspace.root, { x: 0, y: 0, w: wTotal, h: hTotal });
|
|
return rects;
|
|
}, []);
|
|
|
|
const activeWorkspaceRects = useMemo<Record<string, WorkspaceRect>>(
|
|
() => computeWorkspaceRects(activeWorkspace, workspaceArea),
|
|
[activeWorkspace, workspaceArea, computeWorkspaceRects]
|
|
);
|
|
|
|
useEffect(() => {
|
|
if (!workspaceInnerRef.current) return;
|
|
const el = workspaceInnerRef.current;
|
|
const updateSize = () => setWorkspaceArea({ width: el.clientWidth, height: el.clientHeight });
|
|
updateSize();
|
|
const observer = new ResizeObserver(() => updateSize());
|
|
observer.observe(el);
|
|
return () => observer.disconnect();
|
|
}, [activeWorkspace]);
|
|
|
|
const collectResizers = useCallback((workspace?: Workspace, size?: { width: number; height: number }): ResizerHandle[] => {
|
|
if (!workspace || !size?.width || !size?.height) return [];
|
|
const resizers: ResizerHandle[] = [];
|
|
const walk = (node: WorkspaceNode, area: { x: number; y: number; w: number; h: number }) => {
|
|
if (node.type === 'pane') return;
|
|
const isVertical = node.direction === 'vertical';
|
|
const sizes = (node.sizes && node.sizes.length === node.children.length ? node.sizes : Array(node.children.length).fill(1));
|
|
const total = sizes.reduce((acc, n) => acc + n, 0) || 1;
|
|
let offset = 0;
|
|
node.children.forEach((child, idx) => {
|
|
const share = sizes[idx] / total;
|
|
const childArea = isVertical
|
|
? { x: area.x + area.w * offset, y: area.y, w: area.w * share, h: area.h }
|
|
: { x: area.x, y: area.y + area.h * offset, w: area.w, h: area.h * share };
|
|
if (idx < node.children.length - 1) {
|
|
const boundary = isVertical ? childArea.x + childArea.w : childArea.y + childArea.h;
|
|
const rect = isVertical
|
|
? { x: boundary - 2, y: area.y, w: 4, h: area.h }
|
|
: { x: area.x, y: boundary - 2, w: area.w, h: 4 };
|
|
resizers.push({
|
|
id: `${node.id}-${idx}`,
|
|
splitId: node.id,
|
|
index: idx,
|
|
direction: node.direction,
|
|
rect,
|
|
splitArea: { w: area.w, h: area.h },
|
|
});
|
|
}
|
|
walk(child, childArea);
|
|
offset += share;
|
|
});
|
|
};
|
|
walk(workspace.root, { x: 0, y: 0, w: size.width, h: size.height });
|
|
return resizers;
|
|
}, []);
|
|
|
|
const activeResizers = useMemo(() => collectResizers(activeWorkspace, workspaceArea), [activeWorkspace, workspaceArea, collectResizers]);
|
|
|
|
const computeSplitHint = (e: React.DragEvent): SplitHint => {
|
|
if (isFocusMode) return null;
|
|
const surface = workspaceOverlayRef.current || workspaceInnerRef.current || workspaceOuterRef.current;
|
|
if (!surface || !workspaceArea.width || !workspaceArea.height) return null;
|
|
const rect = surface.getBoundingClientRect();
|
|
const localX = e.clientX - rect.left;
|
|
const localY = e.clientY - rect.top;
|
|
if (localX < 0 || localX > rect.width || localY < 0 || localY > rect.height) return null;
|
|
|
|
let targetSessionId: string | undefined;
|
|
let targetRect: WorkspaceRect | undefined;
|
|
const workspaceEntries = Object.entries(activeWorkspaceRects) as Array<[string, WorkspaceRect]>;
|
|
workspaceEntries.forEach(([sessionId, area]) => {
|
|
if (targetSessionId) return;
|
|
if (
|
|
localX >= area.x &&
|
|
localX <= area.x + area.w &&
|
|
localY >= area.y &&
|
|
localY <= area.y + area.h
|
|
) {
|
|
targetSessionId = sessionId;
|
|
targetRect = area;
|
|
}
|
|
});
|
|
|
|
const baseRect: WorkspaceRect = targetRect || { x: 0, y: 0, w: rect.width, h: rect.height };
|
|
const relX = (localX - baseRect.x) / baseRect.w;
|
|
const relY = (localY - baseRect.y) / baseRect.h;
|
|
|
|
const prefersVertical = Math.abs(relX - 0.5) > Math.abs(relY - 0.5);
|
|
const direction = prefersVertical ? 'vertical' : 'horizontal';
|
|
const position = prefersVertical
|
|
? (relX < 0.5 ? 'left' : 'right')
|
|
: (relY < 0.5 ? 'top' : 'bottom');
|
|
|
|
const previewRect: WorkspaceRect = { ...baseRect };
|
|
if (direction === 'vertical') {
|
|
previewRect.w = baseRect.w / 2;
|
|
previewRect.x = position === 'left' ? baseRect.x : baseRect.x + baseRect.w / 2;
|
|
} else {
|
|
previewRect.h = baseRect.h / 2;
|
|
previewRect.y = position === 'top' ? baseRect.y : baseRect.y + baseRect.h / 2;
|
|
}
|
|
|
|
return {
|
|
direction,
|
|
position,
|
|
targetSessionId,
|
|
rect: previewRect,
|
|
};
|
|
};
|
|
|
|
useEffect(() => {
|
|
if (!resizing) return;
|
|
const onMove = (e: MouseEvent) => {
|
|
const dimension = resizing.direction === 'vertical' ? resizing.startArea.w : resizing.startArea.h;
|
|
if (dimension <= 0) return;
|
|
const total = resizing.startSizes.reduce((acc, n) => acc + n, 0) || 1;
|
|
const pxSizes = resizing.startSizes.map(s => (s / total) * dimension);
|
|
const i = resizing.index;
|
|
const delta = (resizing.direction === 'vertical' ? e.clientX - resizing.startClient.x : e.clientY - resizing.startClient.y);
|
|
let a = pxSizes[i] + delta;
|
|
let b = pxSizes[i + 1] - delta;
|
|
const minPx = Math.min(120, dimension / 2);
|
|
if (a < minPx) {
|
|
const diff = minPx - a;
|
|
a = minPx;
|
|
b -= diff;
|
|
}
|
|
if (b < minPx) {
|
|
const diff = minPx - b;
|
|
b = minPx;
|
|
a -= diff;
|
|
}
|
|
const newPxSizes = [...pxSizes];
|
|
newPxSizes[i] = Math.max(minPx, a);
|
|
newPxSizes[i + 1] = Math.max(minPx, b);
|
|
const totalPx = newPxSizes.reduce((acc, n) => acc + n, 0) || 1;
|
|
const newSizes = newPxSizes.map(n => n / totalPx);
|
|
onUpdateSplitSizes(resizing.workspaceId, resizing.splitId, newSizes);
|
|
};
|
|
const onUp = () => setResizing(null);
|
|
window.addEventListener('mousemove', onMove);
|
|
window.addEventListener('mouseup', onUp);
|
|
return () => {
|
|
window.removeEventListener('mousemove', onMove);
|
|
window.removeEventListener('mouseup', onUp);
|
|
};
|
|
}, [resizing, onUpdateSplitSizes]);
|
|
|
|
const handleWorkspaceDrop = (e: React.DragEvent) => {
|
|
if (isFocusMode) return;
|
|
const draggedSessionId = e.dataTransfer.getData('session-id');
|
|
if (!draggedSessionId) return;
|
|
e.preventDefault();
|
|
const hint = computeSplitHint(e);
|
|
setDropHint(null);
|
|
onSetDraggingSessionId(null);
|
|
if (!hint) return;
|
|
|
|
if (activeWorkspace) {
|
|
const draggedSession = sessions.find(s => s.id === draggedSessionId);
|
|
if (!draggedSession || draggedSession.workspaceId) return;
|
|
onAddSessionToWorkspace(activeWorkspace.id, draggedSessionId, hint);
|
|
return;
|
|
}
|
|
|
|
if (activeSession) {
|
|
onCreateWorkspaceFromSessions(activeSession.id, draggedSessionId, hint);
|
|
}
|
|
};
|
|
|
|
const findSplitNode = (node: WorkspaceNode, splitId: string): WorkspaceNode | null => {
|
|
if (node.type === 'split') {
|
|
if (node.id === splitId) return node;
|
|
for (const child of node.children) {
|
|
const found = findSplitNode(child, splitId);
|
|
if (found) return found;
|
|
}
|
|
}
|
|
return null;
|
|
};
|
|
|
|
const isTerminalLayerVisible = isVisible || !!draggingSessionId;
|
|
|
|
// Check if active workspace is in focus mode
|
|
const isFocusMode = activeWorkspace?.viewMode === 'focus';
|
|
const focusedSessionId = activeWorkspace?.focusedSessionId;
|
|
|
|
useEffect(() => {
|
|
if (isFocusMode && dropHint) {
|
|
setDropHint(null);
|
|
}
|
|
}, [isFocusMode, dropHint]);
|
|
|
|
// Track previous focusedSessionId to detect changes
|
|
const prevFocusedSessionIdRef = useRef<string | undefined>(undefined);
|
|
|
|
// When focusedSessionId changes in split view, focus the corresponding terminal
|
|
useEffect(() => {
|
|
// Only handle split view mode (not focus mode)
|
|
if (isFocusMode || !focusedSessionId || !activeWorkspace) return;
|
|
|
|
// Only trigger when focusedSessionId actually changes
|
|
if (prevFocusedSessionIdRef.current === focusedSessionId) return;
|
|
const prevFocusedId = prevFocusedSessionIdRef.current;
|
|
prevFocusedSessionIdRef.current = focusedSessionId;
|
|
|
|
// First, blur the currently focused terminal immediately
|
|
if (prevFocusedId) {
|
|
const prevPane = document.querySelector(`[data-session-id="${prevFocusedId}"]`);
|
|
if (prevPane) {
|
|
const prevTextarea = prevPane.querySelector('textarea.xterm-helper-textarea') as HTMLTextAreaElement | null;
|
|
if (prevTextarea) {
|
|
prevTextarea.blur();
|
|
}
|
|
}
|
|
}
|
|
|
|
// Focus the new terminal multiple times to fight against xterm's focus restoration
|
|
const focusTarget = () => {
|
|
const targetPane = document.querySelector(`[data-session-id="${focusedSessionId}"]`);
|
|
if (targetPane) {
|
|
const textarea = targetPane.querySelector('textarea.xterm-helper-textarea') as HTMLTextAreaElement | null;
|
|
if (textarea) {
|
|
textarea.focus();
|
|
}
|
|
}
|
|
};
|
|
|
|
// Focus immediately
|
|
focusTarget();
|
|
|
|
// Focus again after short delays to override any competing focus attempts
|
|
const timer1 = setTimeout(focusTarget, 10);
|
|
const timer2 = setTimeout(focusTarget, 50);
|
|
const timer3 = setTimeout(focusTarget, 100);
|
|
|
|
return () => {
|
|
clearTimeout(timer1);
|
|
clearTimeout(timer2);
|
|
clearTimeout(timer3);
|
|
};
|
|
}, [focusedSessionId, isFocusMode, activeWorkspace]);
|
|
|
|
// Get sessions for the active workspace in focus mode
|
|
const workspaceSessionIds = useMemo(() => {
|
|
if (!activeWorkspace) return [];
|
|
return collectSessionIds(activeWorkspace.root);
|
|
}, [activeWorkspace]);
|
|
|
|
const workspaceSessions = useMemo(() => {
|
|
return sessions.filter(s => workspaceSessionIds.includes(s.id));
|
|
}, [sessions, workspaceSessionIds]);
|
|
|
|
// Render focus mode sidebar
|
|
const renderFocusModeSidebar = () => {
|
|
if (!activeWorkspace || !isFocusMode) return null;
|
|
|
|
return (
|
|
<div className="w-56 flex-shrink-0 bg-secondary/50 border-r border-border/50 flex flex-col">
|
|
{/* Header with view toggle */}
|
|
<div className="h-10 flex items-center justify-between px-3 border-b border-border/50">
|
|
<span className="text-xs font-medium text-muted-foreground">
|
|
Terminals · {workspaceSessions.length}
|
|
</span>
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
className="h-7 w-7 p-0"
|
|
onClick={() => onToggleWorkspaceViewMode?.(activeWorkspace.id)}
|
|
title="Switch to Split View"
|
|
>
|
|
<LayoutGrid size={14} />
|
|
</Button>
|
|
</div>
|
|
|
|
{/* Session list */}
|
|
<ScrollArea className="flex-1">
|
|
<div className="p-2 space-y-1">
|
|
{workspaceSessions.map(session => {
|
|
const host = sessionHostsMap.get(session.id);
|
|
const isSelected = session.id === focusedSessionId;
|
|
const statusColor = session.status === 'connected'
|
|
? 'text-emerald-500'
|
|
: session.status === 'connecting'
|
|
? 'text-amber-500'
|
|
: 'text-red-500';
|
|
|
|
return (
|
|
<div
|
|
key={session.id}
|
|
className={cn(
|
|
"flex items-center gap-2 px-2 py-1.5 rounded-md cursor-pointer transition-colors",
|
|
isSelected
|
|
? "bg-primary/15 border border-primary/30"
|
|
: "hover:bg-secondary/80 border border-transparent"
|
|
)}
|
|
onClick={() => onSetWorkspaceFocusedSession?.(activeWorkspace.id, session.id)}
|
|
>
|
|
<div className="relative">
|
|
{host ? (
|
|
<DistroAvatar host={host} fallback={session.hostLabel} size="sm" />
|
|
) : (
|
|
<Server size={16} className="text-muted-foreground" />
|
|
)}
|
|
<Circle
|
|
size={6}
|
|
className={cn("absolute -bottom-0.5 -right-0.5 fill-current", statusColor)}
|
|
/>
|
|
</div>
|
|
<div className="flex-1 min-w-0">
|
|
<div className="text-xs font-medium truncate">{session.hostLabel}</div>
|
|
<div className="text-[10px] text-muted-foreground truncate">
|
|
{session.username}@{session.hostname}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
</ScrollArea>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
return (
|
|
<div
|
|
ref={workspaceOuterRef}
|
|
className="absolute inset-0 bg-background flex"
|
|
style={{ display: isTerminalLayerVisible ? 'flex' : 'none', zIndex: isTerminalLayerVisible ? 10 : 0 }}
|
|
>
|
|
{/* Focus mode sidebar */}
|
|
{isFocusMode && renderFocusModeSidebar()}
|
|
|
|
{draggingSessionId && !isFocusMode && (
|
|
<div
|
|
ref={workspaceOverlayRef}
|
|
className="absolute inset-0 z-30"
|
|
onDragOver={(e) => {
|
|
if (isFocusMode) return;
|
|
if (!e.dataTransfer.types.includes('session-id')) return;
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
const hint = computeSplitHint(e);
|
|
setDropHint(hint);
|
|
}}
|
|
onDragLeave={(e) => {
|
|
if (!e.dataTransfer.types.includes('session-id')) return;
|
|
setDropHint(null);
|
|
}}
|
|
onDrop={(e) => {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
handleWorkspaceDrop(e);
|
|
}}
|
|
>
|
|
{dropHint && (
|
|
<div className="absolute inset-0 pointer-events-none">
|
|
<div
|
|
className="absolute bg-emerald-600/35 border border-emerald-400/70 backdrop-blur-sm transition-all duration-150"
|
|
style={{
|
|
width: dropHint.rect ? `${dropHint.rect.w}px` : dropHint.direction === 'vertical' ? '50%' : '100%',
|
|
height: dropHint.rect ? `${dropHint.rect.h}px` : dropHint.direction === 'vertical' ? '100%' : '50%',
|
|
left: dropHint.rect ? `${dropHint.rect.x}px` : dropHint.direction === 'vertical' ? (dropHint.position === 'left' ? 0 : '50%') : 0,
|
|
top: dropHint.rect ? `${dropHint.rect.y}px` : dropHint.direction === 'vertical' ? 0 : (dropHint.position === 'top' ? 0 : '50%'),
|
|
}}
|
|
/>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
<div ref={workspaceInnerRef} className={cn("absolute overflow-hidden", isFocusMode ? "left-56 right-0 top-0 bottom-0" : "inset-0")}>
|
|
{sessions.map(session => {
|
|
// Use pre-computed host to avoid creating new objects on every render
|
|
const host = sessionHostsMap.get(session.id)!;
|
|
const inActiveWorkspace = !!activeWorkspace && session.workspaceId === activeWorkspace.id;
|
|
const isActiveSolo = activeTabId === session.id && !activeWorkspace && isTerminalLayerVisible;
|
|
|
|
// In focus mode, only the focused session is visible
|
|
const isFocusedInWorkspace = isFocusMode && inActiveWorkspace && session.id === focusedSessionId;
|
|
const isSplitViewVisible = !isFocusMode && inActiveWorkspace;
|
|
|
|
const isVisible = ((isFocusedInWorkspace || isSplitViewVisible || isActiveSolo) && isTerminalLayerVisible);
|
|
|
|
// In focus mode, use full area; in split mode, use computed rects
|
|
const rect = (isSplitViewVisible && !isFocusMode) ? activeWorkspaceRects[session.id] : null;
|
|
|
|
const layoutStyle = rect
|
|
? {
|
|
left: `${rect.x}px`,
|
|
top: `${rect.y}px`,
|
|
width: `${rect.w}px`,
|
|
height: `${rect.h}px`,
|
|
}
|
|
: { left: 0, top: 0, width: '100%', height: '100%' };
|
|
|
|
const style: React.CSSProperties = { ...layoutStyle };
|
|
|
|
if (!isVisible) {
|
|
style.display = 'none';
|
|
}
|
|
|
|
// Check if this pane is the focused one in the workspace
|
|
const isFocusedPane = inActiveWorkspace && !isFocusMode && session.id === focusedSessionId;
|
|
|
|
return (
|
|
<div
|
|
key={session.id}
|
|
data-session-id={session.id}
|
|
className={cn(
|
|
"absolute bg-background",
|
|
inActiveWorkspace && "workspace-pane",
|
|
isVisible && "z-10",
|
|
isFocusedPane && "ring-1 ring-primary/50 ring-inset"
|
|
)}
|
|
style={style}
|
|
tabIndex={-1}
|
|
onClick={() => {
|
|
// Set focused session when clicking on a pane in split view
|
|
if (inActiveWorkspace && !isFocusMode && activeWorkspace) {
|
|
onSetWorkspaceFocusedSession?.(activeWorkspace.id, session.id);
|
|
}
|
|
}}
|
|
>
|
|
<Terminal
|
|
host={host}
|
|
keys={keys}
|
|
identities={identities}
|
|
snippets={snippets}
|
|
allHosts={hosts}
|
|
knownHosts={knownHosts}
|
|
isVisible={isVisible}
|
|
inWorkspace={inActiveWorkspace}
|
|
isResizing={!!resizing}
|
|
isFocusMode={isFocusMode}
|
|
isFocused={isFocusedPane}
|
|
fontFamilyId={terminalFontFamilyId}
|
|
fontSize={fontSize}
|
|
terminalTheme={terminalTheme}
|
|
terminalSettings={terminalSettings}
|
|
sessionId={session.id}
|
|
startupCommand={session.startupCommand}
|
|
serialConfig={session.serialConfig}
|
|
onUpdateTerminalThemeId={onUpdateTerminalThemeId}
|
|
onUpdateTerminalFontFamilyId={onUpdateTerminalFontFamilyId}
|
|
onUpdateTerminalFontSize={onUpdateTerminalFontSize}
|
|
hotkeyScheme={hotkeyScheme}
|
|
keyBindings={keyBindings}
|
|
onHotkeyAction={onHotkeyAction}
|
|
onCloseSession={handleCloseSession}
|
|
onStatusChange={handleStatusChange}
|
|
onSessionExit={handleSessionExit}
|
|
onTerminalDataCapture={handleTerminalDataCapture}
|
|
onOsDetected={handleOsDetected}
|
|
onUpdateHost={handleUpdateHost}
|
|
onAddKnownHost={handleAddKnownHost}
|
|
onCommandExecuted={handleCommandExecuted}
|
|
onExpandToFocus={inActiveWorkspace && !isFocusMode && activeWorkspace ? () => onToggleWorkspaceViewMode?.(activeWorkspace.id) : undefined}
|
|
onSplitHorizontal={onSplitSession ? () => onSplitSession(session.id, 'horizontal') : undefined}
|
|
onSplitVertical={onSplitSession ? () => onSplitSession(session.id, 'vertical') : undefined}
|
|
isBroadcastEnabled={inActiveWorkspace && activeWorkspace ? isBroadcastEnabled?.(activeWorkspace.id) : false}
|
|
onToggleBroadcast={inActiveWorkspace && activeWorkspace ? () => onToggleBroadcast?.(activeWorkspace.id) : undefined}
|
|
onBroadcastInput={inActiveWorkspace && activeWorkspace && isBroadcastEnabled?.(activeWorkspace.id) ? handleBroadcastInput : undefined}
|
|
/>
|
|
</div>
|
|
);
|
|
})}
|
|
{/* Only show resizers in split view mode, not in focus mode */}
|
|
{!isFocusMode && activeResizers.map(handle => {
|
|
const isVertical = handle.direction === 'vertical';
|
|
// Expand hit area perpendicular to the split line, but stay within bounds
|
|
// Vertical split (left-right): expand horizontally, keep vertical bounds
|
|
// Horizontal split (top-bottom): expand vertically, keep horizontal bounds
|
|
const left = isVertical ? handle.rect.x - 3 : handle.rect.x;
|
|
const top = isVertical ? handle.rect.y : handle.rect.y - 3;
|
|
const width = isVertical ? handle.rect.w + 6 : handle.rect.w;
|
|
const height = isVertical ? handle.rect.h : handle.rect.h + 6;
|
|
|
|
return (
|
|
<div
|
|
key={handle.id}
|
|
className={cn("absolute group", isVertical ? "cursor-ew-resize" : "cursor-ns-resize")}
|
|
style={{
|
|
left: `${left}px`,
|
|
top: `${top}px`,
|
|
width: `${width}px`,
|
|
height: `${height}px`,
|
|
zIndex: 25,
|
|
}}
|
|
onMouseDown={(e) => {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
const ws = activeWorkspace;
|
|
if (!ws) return;
|
|
const split = findSplitNode(ws.root, handle.splitId);
|
|
const childCount = split && split.type === 'split' ? split.children.length : 0;
|
|
const sizes = split && split.type === 'split' && split.sizes && split.sizes.length === childCount
|
|
? split.sizes
|
|
: Array(childCount).fill(1);
|
|
setResizing({
|
|
workspaceId: ws.id,
|
|
splitId: handle.splitId,
|
|
index: handle.index,
|
|
direction: handle.direction,
|
|
startSizes: sizes.length ? sizes : [1, 1],
|
|
startArea: handle.splitArea,
|
|
startClient: { x: e.clientX, y: e.clientY },
|
|
});
|
|
}}
|
|
>
|
|
<div
|
|
className={cn(
|
|
"absolute bg-border/70 group-hover:bg-primary/60 transition-colors",
|
|
isVertical ? "w-px h-full left-1/2 -translate-x-1/2" : "h-px w-full top-1/2 -translate-y-1/2"
|
|
)}
|
|
/>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
// Only re-render when data props change - activeTabId/isVisible are now managed internally via store subscription
|
|
const terminalLayerAreEqual = (prev: TerminalLayerProps, next: TerminalLayerProps): boolean => {
|
|
return (
|
|
prev.hosts === next.hosts &&
|
|
prev.keys === next.keys &&
|
|
prev.snippets === next.snippets &&
|
|
prev.sessions === next.sessions &&
|
|
prev.workspaces === next.workspaces &&
|
|
prev.draggingSessionId === next.draggingSessionId &&
|
|
prev.terminalTheme === next.terminalTheme &&
|
|
prev.terminalSettings === next.terminalSettings &&
|
|
prev.fontSize === next.fontSize &&
|
|
prev.hotkeyScheme === next.hotkeyScheme &&
|
|
prev.keyBindings === next.keyBindings &&
|
|
prev.onHotkeyAction === next.onHotkeyAction &&
|
|
prev.onUpdateHost === next.onUpdateHost &&
|
|
prev.onToggleWorkspaceViewMode === next.onToggleWorkspaceViewMode &&
|
|
prev.onSetWorkspaceFocusedSession === next.onSetWorkspaceFocusedSession &&
|
|
prev.onSplitSession === next.onSplitSession
|
|
);
|
|
};
|
|
|
|
export const TerminalLayer = memo(TerminalLayerInner, terminalLayerAreEqual);
|
|
TerminalLayer.displayName = 'TerminalLayer';
|