mirror of
https://github.com/binaricat/Netcatty.git
synced 2026-10-09 15:58:34 +00:00
b66e7b8fff
Reduces toolbar button sizes for a more compact interface and adjusts icon dimensions for visual consistency. Prevents drag and drop actions in terminal focus mode to avoid unintended workspace changes, improving user experience during focused workflows.
791 lines
32 KiB
TypeScript
791 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}
|
|
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';
|