720 lines
28 KiB
TypeScript
720 lines
28 KiB
TypeScript
|
|
import { Circle, Columns2, Plus, Search, Server } from 'lucide-react';
|
||
|
|
import React, { memo, useCallback, useMemo, useState, type DragEvent, type MouseEvent } from 'react';
|
||
|
|
|
||
|
|
import {
|
||
|
|
applySessionPresentation,
|
||
|
|
useSessionPresentationVersion,
|
||
|
|
} from '../../application/state/sessionPresentationStore';
|
||
|
|
import { useStoredNumber } from '../../application/state/useStoredNumber';
|
||
|
|
import { terminalReconnectRegistry } from '../../application/state/terminalReconnectRegistry';
|
||
|
|
import {
|
||
|
|
appendHostFromWorkspaceDrop,
|
||
|
|
FOCUS_SIDEBAR_SESSION_DRAG_TYPE,
|
||
|
|
resolveFocusSidebarDragKind,
|
||
|
|
} from '../../domain/focusSidebarHostDrop';
|
||
|
|
import { resolveWorkspaceFocusSessionOrder } from '../../domain/workspace';
|
||
|
|
import { resolveSessionTabTitle } from '../../domain/sessionTabTitle';
|
||
|
|
import type { DynamicTabTitleMode } from '../../domain/models';
|
||
|
|
import { STORAGE_KEY_WORKSPACE_FOCUS_SIDEBAR_WIDTH } from '../../infrastructure/config/storageKeys';
|
||
|
|
import { cn } from '../../lib/utils';
|
||
|
|
import type { Host, TerminalSession, TerminalTheme, Workspace } from '../../types';
|
||
|
|
import { DistroAvatar } from '../DistroAvatar';
|
||
|
|
import { SessionInlineRenameInput } from '../terminal/SessionInlineRenameInput';
|
||
|
|
import { SessionTabContextMenuContent } from '../top-tabs/SessionTabContextMenuContent';
|
||
|
|
import { Button } from '../ui/button';
|
||
|
|
import { ContextMenu, ContextMenuTrigger } from '../ui/context-menu';
|
||
|
|
import { Input } from '../ui/input';
|
||
|
|
import { ScrollArea } from '../ui/scroll-area';
|
||
|
|
import { Tooltip, TooltipContent, TooltipTrigger } from '../ui/tooltip';
|
||
|
|
|
||
|
|
interface TerminalFocusSidebarProps {
|
||
|
|
activeWorkspace: Workspace;
|
||
|
|
focusedSessionId: string | undefined;
|
||
|
|
onReorderWorkspaceSessions?: (workspaceId: string, draggedSessionId: string, targetSessionId: string, position: 'before' | 'after') => void;
|
||
|
|
onRequestAddToWorkspace?: (workspaceId: string) => void;
|
||
|
|
onAppendHostToWorkspace?: (workspaceId: string, hostId: string) => void;
|
||
|
|
onCloseSession: (sessionId: string) => void;
|
||
|
|
onCopySession?: (sessionId: string) => void;
|
||
|
|
onDuplicateSession?: (sessionId: string) => void;
|
||
|
|
onCopySessionToNewWindow?: (sessionId: string) => void;
|
||
|
|
onDetachSessionFromWorkspace?: (sessionId: string) => void;
|
||
|
|
onSetWorkspaceFocusedSession?: (workspaceId: string, sessionId: string) => void;
|
||
|
|
onToggleWorkspaceViewMode?: (workspaceId: string) => void;
|
||
|
|
onSubmitSessionRename: (sessionId: string, name: string) => void;
|
||
|
|
resolvedPreviewTheme: TerminalTheme;
|
||
|
|
sessionHostsMap: Map<string, Host>;
|
||
|
|
sessions: TerminalSession[];
|
||
|
|
dynamicTabTitleMode?: DynamicTabTitleMode;
|
||
|
|
t: (key: string) => string;
|
||
|
|
}
|
||
|
|
|
||
|
|
type FocusSidebarTheme = {
|
||
|
|
termBg: string;
|
||
|
|
termFg: string;
|
||
|
|
selectedBg: string;
|
||
|
|
selectedHoverBg: string;
|
||
|
|
unselectedHoverBg: string;
|
||
|
|
unselectedFg: string;
|
||
|
|
mutedFg: string;
|
||
|
|
separator: string;
|
||
|
|
};
|
||
|
|
|
||
|
|
type WorkspaceFocusSessionRowProps = {
|
||
|
|
session: TerminalSession;
|
||
|
|
host: Host | undefined;
|
||
|
|
isSelected: boolean;
|
||
|
|
isRenaming: boolean;
|
||
|
|
renameValue: string;
|
||
|
|
onStartRename: (sessionId: string) => void;
|
||
|
|
onSubmitRename: (name: string) => void;
|
||
|
|
onCancelRename: () => void;
|
||
|
|
onCloseSession: (sessionId: string) => void;
|
||
|
|
onCopySession?: (sessionId: string) => void;
|
||
|
|
onDuplicateSession?: (sessionId: string) => void;
|
||
|
|
onCopySessionToNewWindow?: (sessionId: string) => void;
|
||
|
|
onDetachSessionFromWorkspace?: (sessionId: string) => void;
|
||
|
|
isDragging: boolean;
|
||
|
|
dropPosition: 'before' | 'after' | null;
|
||
|
|
theme: FocusSidebarTheme;
|
||
|
|
onSelect: (sessionId: string) => void;
|
||
|
|
onDragStart: (event: DragEvent, sessionId: string) => void;
|
||
|
|
onDragOver: (event: DragEvent, sessionId: string) => void;
|
||
|
|
onHostDragLeave: (event: DragEvent<HTMLDivElement>) => void;
|
||
|
|
onDrop: (event: DragEvent, sessionId: string) => void;
|
||
|
|
onDragEnd: () => void;
|
||
|
|
dynamicTabTitleMode?: DynamicTabTitleMode;
|
||
|
|
t: (key: string) => string;
|
||
|
|
};
|
||
|
|
|
||
|
|
const WorkspaceFocusSessionRow = memo<WorkspaceFocusSessionRowProps>(({
|
||
|
|
session,
|
||
|
|
host,
|
||
|
|
isSelected,
|
||
|
|
isRenaming,
|
||
|
|
renameValue,
|
||
|
|
onStartRename,
|
||
|
|
onSubmitRename,
|
||
|
|
onCancelRename,
|
||
|
|
onCloseSession,
|
||
|
|
onCopySession,
|
||
|
|
onDuplicateSession,
|
||
|
|
onCopySessionToNewWindow,
|
||
|
|
onDetachSessionFromWorkspace,
|
||
|
|
isDragging,
|
||
|
|
dropPosition,
|
||
|
|
theme,
|
||
|
|
onSelect,
|
||
|
|
onDragStart,
|
||
|
|
onDragOver,
|
||
|
|
onHostDragLeave,
|
||
|
|
onDrop,
|
||
|
|
onDragEnd,
|
||
|
|
dynamicTabTitleMode,
|
||
|
|
t,
|
||
|
|
}) => {
|
||
|
|
const reconnectActive = React.useSyncExternalStore(
|
||
|
|
terminalReconnectRegistry.subscribe,
|
||
|
|
() => terminalReconnectRegistry.isActive(session.id),
|
||
|
|
() => false,
|
||
|
|
);
|
||
|
|
const {
|
||
|
|
termFg,
|
||
|
|
selectedBg,
|
||
|
|
selectedHoverBg,
|
||
|
|
unselectedHoverBg,
|
||
|
|
unselectedFg,
|
||
|
|
mutedFg,
|
||
|
|
} = theme;
|
||
|
|
|
||
|
|
const statusColor = session.status === 'connected'
|
||
|
|
? 'text-emerald-500'
|
||
|
|
: session.status === 'connecting'
|
||
|
|
? 'text-amber-500'
|
||
|
|
: 'text-red-500';
|
||
|
|
|
||
|
|
const restBg = isSelected ? selectedBg : 'transparent';
|
||
|
|
const hoverBg = isSelected ? selectedHoverBg : unselectedHoverBg;
|
||
|
|
const rowFg = isSelected ? termFg : unselectedFg;
|
||
|
|
|
||
|
|
return (
|
||
|
|
<ContextMenu>
|
||
|
|
<ContextMenuTrigger asChild>
|
||
|
|
<div
|
||
|
|
data-workspace-focus-session-id={session.id}
|
||
|
|
draggable
|
||
|
|
role="button"
|
||
|
|
tabIndex={0}
|
||
|
|
className={cn(
|
||
|
|
'relative flex w-full select-none items-center justify-start gap-2 rounded-md px-2 py-1.5 text-sm font-normal outline-none transition-colors hover:text-inherit focus-visible:ring-1',
|
||
|
|
isDragging && 'opacity-50',
|
||
|
|
)}
|
||
|
|
style={{
|
||
|
|
backgroundColor: restBg,
|
||
|
|
color: rowFg,
|
||
|
|
boxShadow: dropPosition
|
||
|
|
? `inset 0 ${dropPosition === 'before' ? '2px' : '-2px'} 0 ${termFg}`
|
||
|
|
: undefined,
|
||
|
|
}}
|
||
|
|
onContextMenu={() => onSelect(session.id)}
|
||
|
|
onDragStart={(event) => onDragStart(event, session.id)}
|
||
|
|
onDragOver={(event) => onDragOver(event, session.id)}
|
||
|
|
onDragLeave={onHostDragLeave}
|
||
|
|
onDrop={(event) => onDrop(event, session.id)}
|
||
|
|
onDragEnd={onDragEnd}
|
||
|
|
onMouseEnter={(event) => {
|
||
|
|
event.currentTarget.style.backgroundColor = hoverBg;
|
||
|
|
}}
|
||
|
|
onMouseLeave={(event) => {
|
||
|
|
event.currentTarget.style.backgroundColor = restBg;
|
||
|
|
}}
|
||
|
|
onClick={() => onSelect(session.id)}
|
||
|
|
onKeyDown={(event) => {
|
||
|
|
if (event.key !== 'Enter' && event.key !== ' ') return;
|
||
|
|
event.preventDefault();
|
||
|
|
onSelect(session.id);
|
||
|
|
}}
|
||
|
|
>
|
||
|
|
<div className="relative flex h-6 w-6 shrink-0 items-center justify-center self-center">
|
||
|
|
{host ? (
|
||
|
|
<DistroAvatar
|
||
|
|
host={host}
|
||
|
|
fallback={session.hostLabel}
|
||
|
|
size="sm"
|
||
|
|
className="!h-6 !w-6"
|
||
|
|
/>
|
||
|
|
) : (
|
||
|
|
<Server size={14} style={{ color: mutedFg }} />
|
||
|
|
)}
|
||
|
|
<Circle
|
||
|
|
size={5}
|
||
|
|
className={cn('absolute bottom-0 right-0 fill-current', statusColor)}
|
||
|
|
/>
|
||
|
|
</div>
|
||
|
|
<div className="flex min-h-6 min-w-0 flex-1 flex-col justify-center self-center text-left">
|
||
|
|
{isRenaming ? (
|
||
|
|
<SessionInlineRenameInput
|
||
|
|
initialName={renameValue}
|
||
|
|
onCommit={onSubmitRename}
|
||
|
|
onCancel={onCancelRename}
|
||
|
|
className="h-5 text-xs leading-4"
|
||
|
|
/>
|
||
|
|
) : (
|
||
|
|
<>
|
||
|
|
<div
|
||
|
|
className={cn('truncate text-xs leading-4', isSelected ? 'font-semibold' : 'font-medium')}
|
||
|
|
onDoubleClick={(e) => {
|
||
|
|
e.stopPropagation();
|
||
|
|
onStartRename(session.id);
|
||
|
|
}}
|
||
|
|
>
|
||
|
|
{resolveSessionTabTitle(session, dynamicTabTitleMode)}
|
||
|
|
</div>
|
||
|
|
<div className="mt-0.5 truncate text-[10px] leading-4" style={{ color: mutedFg }}>
|
||
|
|
{session.username}@{session.hostname}
|
||
|
|
</div>
|
||
|
|
</>
|
||
|
|
)}
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
</ContextMenuTrigger>
|
||
|
|
<SessionTabContextMenuContent
|
||
|
|
sessionId={session.id}
|
||
|
|
onCloseSession={onCloseSession}
|
||
|
|
onCopySession={onCopySession}
|
||
|
|
onDuplicateSession={onDuplicateSession}
|
||
|
|
onCopySessionToNewWindow={onCopySessionToNewWindow}
|
||
|
|
onDetachSession={onDetachSessionFromWorkspace}
|
||
|
|
onReconnectSession={terminalReconnectRegistry.request}
|
||
|
|
sessionStatus={session.status}
|
||
|
|
reconnectActive={reconnectActive}
|
||
|
|
onRenameSession={onStartRename}
|
||
|
|
t={t}
|
||
|
|
/>
|
||
|
|
</ContextMenu>
|
||
|
|
);
|
||
|
|
}, (prev, next) => (
|
||
|
|
prev.session === next.session
|
||
|
|
&& prev.host === next.host
|
||
|
|
&& prev.isSelected === next.isSelected
|
||
|
|
&& prev.isRenaming === next.isRenaming
|
||
|
|
&& prev.renameValue === next.renameValue
|
||
|
|
&& prev.isDragging === next.isDragging
|
||
|
|
&& prev.dropPosition === next.dropPosition
|
||
|
|
&& prev.theme === next.theme
|
||
|
|
&& prev.onSelect === next.onSelect
|
||
|
|
&& prev.onStartRename === next.onStartRename
|
||
|
|
&& prev.onSubmitRename === next.onSubmitRename
|
||
|
|
&& prev.onCancelRename === next.onCancelRename
|
||
|
|
&& prev.onCloseSession === next.onCloseSession
|
||
|
|
&& prev.onCopySession === next.onCopySession
|
||
|
|
&& prev.onDuplicateSession === next.onDuplicateSession
|
||
|
|
&& prev.onCopySessionToNewWindow === next.onCopySessionToNewWindow
|
||
|
|
&& prev.onDetachSessionFromWorkspace === next.onDetachSessionFromWorkspace
|
||
|
|
&& prev.onDragStart === next.onDragStart
|
||
|
|
&& prev.onDragOver === next.onDragOver
|
||
|
|
&& prev.onDrop === next.onDrop
|
||
|
|
&& prev.onDragEnd === next.onDragEnd
|
||
|
|
&& prev.dynamicTabTitleMode === next.dynamicTabTitleMode
|
||
|
|
&& prev.t === next.t
|
||
|
|
));
|
||
|
|
WorkspaceFocusSessionRow.displayName = 'WorkspaceFocusSessionRow';
|
||
|
|
|
||
|
|
const TerminalFocusSidebarInner: React.FC<TerminalFocusSidebarProps> = ({
|
||
|
|
activeWorkspace,
|
||
|
|
focusedSessionId,
|
||
|
|
onReorderWorkspaceSessions,
|
||
|
|
onRequestAddToWorkspace,
|
||
|
|
onAppendHostToWorkspace,
|
||
|
|
onCloseSession,
|
||
|
|
onCopySession,
|
||
|
|
onDuplicateSession,
|
||
|
|
onCopySessionToNewWindow,
|
||
|
|
onDetachSessionFromWorkspace,
|
||
|
|
onSetWorkspaceFocusedSession,
|
||
|
|
onToggleWorkspaceViewMode,
|
||
|
|
onSubmitSessionRename,
|
||
|
|
resolvedPreviewTheme,
|
||
|
|
sessionHostsMap,
|
||
|
|
sessions,
|
||
|
|
dynamicTabTitleMode,
|
||
|
|
t,
|
||
|
|
}) => {
|
||
|
|
const [focusSidebarSearch, setFocusSidebarSearch] = useState('');
|
||
|
|
const [focusSidebarDragSessionId, setFocusSidebarDragSessionId] = useState<string | null>(null);
|
||
|
|
const [focusSidebarHostDropActive, setFocusSidebarHostDropActive] = useState(false);
|
||
|
|
const [focusSidebarDropIndicator, setFocusSidebarDropIndicator] = useState<{
|
||
|
|
sessionId: string;
|
||
|
|
position: 'before' | 'after';
|
||
|
|
} | null>(null);
|
||
|
|
const [focusSidebarWidth, setFocusSidebarWidth, persistFocusSidebarWidth] = useStoredNumber(
|
||
|
|
STORAGE_KEY_WORKSPACE_FOCUS_SIDEBAR_WIDTH, 224, { min: 160, max: 480 },
|
||
|
|
);
|
||
|
|
|
||
|
|
const [sidebarRenameSessionId, setSidebarRenameSessionId] = useState<string | null>(null);
|
||
|
|
const [sidebarRenameValue, setSidebarRenameValue] = useState('');
|
||
|
|
|
||
|
|
const theme = useMemo<FocusSidebarTheme>(() => {
|
||
|
|
const termBg = resolvedPreviewTheme.colors.background;
|
||
|
|
const termFg = resolvedPreviewTheme.colors.foreground;
|
||
|
|
return {
|
||
|
|
termBg,
|
||
|
|
termFg,
|
||
|
|
selectedBg: `color-mix(in srgb, ${termFg} 10%, transparent)`,
|
||
|
|
selectedHoverBg: `color-mix(in srgb, ${termFg} 15%, transparent)`,
|
||
|
|
unselectedHoverBg: `color-mix(in srgb, ${termFg} 10%, transparent)`,
|
||
|
|
unselectedFg: `color-mix(in srgb, ${termFg} 75%, ${termBg} 25%)`,
|
||
|
|
mutedFg: `color-mix(in srgb, ${termFg} 55%, ${termBg} 45%)`,
|
||
|
|
separator: `color-mix(in srgb, ${termFg} 10%, ${termBg} 90%)`,
|
||
|
|
};
|
||
|
|
}, [resolvedPreviewTheme]);
|
||
|
|
|
||
|
|
// Live OSC / coding-CLI titles live in sessionPresentationStore (not sessions).
|
||
|
|
const presentationVersion = useSessionPresentationVersion();
|
||
|
|
const workspaceSessions = useMemo(() => {
|
||
|
|
void presentationVersion;
|
||
|
|
const sessionMap = new Map(sessions.map((session) => [session.id, session]));
|
||
|
|
return resolveWorkspaceFocusSessionOrder(activeWorkspace.root, activeWorkspace.focusSessionOrder)
|
||
|
|
.map((sessionId) => {
|
||
|
|
const session = sessionMap.get(sessionId);
|
||
|
|
return session ? applySessionPresentation(session) : undefined;
|
||
|
|
})
|
||
|
|
.filter((session): session is TerminalSession => Boolean(session));
|
||
|
|
}, [activeWorkspace, sessions, presentationVersion]);
|
||
|
|
|
||
|
|
const visibleSessions = useMemo(() => {
|
||
|
|
const term = focusSidebarSearch.trim().toLowerCase();
|
||
|
|
if (!term) return workspaceSessions;
|
||
|
|
return workspaceSessions.filter((session) => (
|
||
|
|
session.customName?.toLowerCase().includes(term)
|
||
|
|
|| session.hostLabel?.toLowerCase().includes(term)
|
||
|
|
|| session.dynamicTitle?.toLowerCase().includes(term)
|
||
|
|
|| session.hostname?.toLowerCase().includes(term)
|
||
|
|
|| session.username?.toLowerCase().includes(term)
|
||
|
|
));
|
||
|
|
}, [focusSidebarSearch, workspaceSessions]);
|
||
|
|
|
||
|
|
const handleFocusSidebarResizeStart = useCallback((event: MouseEvent) => {
|
||
|
|
event.preventDefault();
|
||
|
|
const startX = event.clientX;
|
||
|
|
const startWidth = focusSidebarWidth;
|
||
|
|
|
||
|
|
let lastWidth = startWidth;
|
||
|
|
let rafId: number | null = null;
|
||
|
|
const onMouseMove = (moveEvent: MouseEvent) => {
|
||
|
|
const delta = moveEvent.clientX - startX;
|
||
|
|
lastWidth = Math.max(160, Math.min(480, startWidth + delta));
|
||
|
|
if (rafId !== null) return;
|
||
|
|
rafId = requestAnimationFrame(() => {
|
||
|
|
rafId = null;
|
||
|
|
setFocusSidebarWidth(lastWidth);
|
||
|
|
});
|
||
|
|
};
|
||
|
|
const onMouseUp = () => {
|
||
|
|
if (rafId !== null) cancelAnimationFrame(rafId);
|
||
|
|
setFocusSidebarWidth(lastWidth);
|
||
|
|
persistFocusSidebarWidth(lastWidth);
|
||
|
|
window.removeEventListener('mousemove', onMouseMove);
|
||
|
|
window.removeEventListener('mouseup', onMouseUp);
|
||
|
|
};
|
||
|
|
window.addEventListener('mousemove', onMouseMove);
|
||
|
|
window.addEventListener('mouseup', onMouseUp);
|
||
|
|
}, [focusSidebarWidth, persistFocusSidebarWidth, setFocusSidebarWidth]);
|
||
|
|
|
||
|
|
const handleFocusSidebarDragStart = useCallback((event: DragEvent, sessionId: string) => {
|
||
|
|
event.stopPropagation();
|
||
|
|
event.dataTransfer.effectAllowed = 'move';
|
||
|
|
event.dataTransfer.setData(FOCUS_SIDEBAR_SESSION_DRAG_TYPE, sessionId);
|
||
|
|
setFocusSidebarDragSessionId(sessionId);
|
||
|
|
setFocusSidebarHostDropActive(false);
|
||
|
|
}, []);
|
||
|
|
|
||
|
|
const clearFocusSidebarHostDrop = useCallback(() => {
|
||
|
|
setFocusSidebarHostDropActive(false);
|
||
|
|
}, []);
|
||
|
|
|
||
|
|
const handleFocusSidebarHostDragLeave = useCallback((event: DragEvent<HTMLDivElement>) => {
|
||
|
|
event.stopPropagation();
|
||
|
|
const dropZone = event.currentTarget.closest<HTMLElement>('[data-focus-sidebar-drop-zone]');
|
||
|
|
const next = event.relatedTarget;
|
||
|
|
if (next instanceof Node && dropZone?.contains(next)) return;
|
||
|
|
clearFocusSidebarHostDrop();
|
||
|
|
}, [clearFocusSidebarHostDrop]);
|
||
|
|
|
||
|
|
const getFocusSidebarContainerDropTarget = useCallback((
|
||
|
|
container: HTMLElement,
|
||
|
|
clientY: number,
|
||
|
|
draggedSessionId: string,
|
||
|
|
): { sessionId: string; position: 'before' | 'after' } | null => {
|
||
|
|
const rows = Array.from(
|
||
|
|
container.querySelectorAll<HTMLElement>('[data-workspace-focus-session-id]'),
|
||
|
|
);
|
||
|
|
if (rows.length === 0) return null;
|
||
|
|
|
||
|
|
for (const row of rows) {
|
||
|
|
const sessionId = row.dataset.workspaceFocusSessionId;
|
||
|
|
if (!sessionId || sessionId === draggedSessionId) continue;
|
||
|
|
|
||
|
|
const rect = row.getBoundingClientRect();
|
||
|
|
if (clientY < rect.top) return { sessionId, position: 'before' };
|
||
|
|
if (clientY <= rect.bottom) {
|
||
|
|
return {
|
||
|
|
sessionId,
|
||
|
|
position: clientY < rect.top + rect.height / 2 ? 'before' : 'after',
|
||
|
|
};
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
const lastRow = [...rows].reverse().find((row) => (
|
||
|
|
row.dataset.workspaceFocusSessionId
|
||
|
|
&& row.dataset.workspaceFocusSessionId !== draggedSessionId
|
||
|
|
));
|
||
|
|
const lastSessionId = lastRow?.dataset.workspaceFocusSessionId;
|
||
|
|
return lastSessionId ? { sessionId: lastSessionId, position: 'after' } : null;
|
||
|
|
}, []);
|
||
|
|
|
||
|
|
const acceptFocusSidebarHostDrag = useCallback((event: DragEvent) => {
|
||
|
|
if (!onAppendHostToWorkspace) return false;
|
||
|
|
if (resolveFocusSidebarDragKind({
|
||
|
|
types: event.dataTransfer.types,
|
||
|
|
activeSessionDragId: focusSidebarDragSessionId,
|
||
|
|
}) !== 'host-append') {
|
||
|
|
return false;
|
||
|
|
}
|
||
|
|
event.preventDefault();
|
||
|
|
event.dataTransfer.dropEffect = 'copy';
|
||
|
|
setFocusSidebarDropIndicator(null);
|
||
|
|
setFocusSidebarHostDropActive(true);
|
||
|
|
return true;
|
||
|
|
}, [focusSidebarDragSessionId, onAppendHostToWorkspace]);
|
||
|
|
|
||
|
|
const handleFocusSidebarHostDragOver = useCallback((event: DragEvent<HTMLDivElement>) => {
|
||
|
|
acceptFocusSidebarHostDrag(event);
|
||
|
|
}, [acceptFocusSidebarHostDrag]);
|
||
|
|
|
||
|
|
const handleFocusSidebarDragOver = useCallback((event: DragEvent, targetSessionId: string) => {
|
||
|
|
if (acceptFocusSidebarHostDrag(event)) return;
|
||
|
|
|
||
|
|
const draggedSessionId = event.dataTransfer.getData(FOCUS_SIDEBAR_SESSION_DRAG_TYPE) || focusSidebarDragSessionId;
|
||
|
|
if (!draggedSessionId || draggedSessionId === targetSessionId) return;
|
||
|
|
|
||
|
|
event.preventDefault();
|
||
|
|
event.stopPropagation();
|
||
|
|
event.dataTransfer.dropEffect = 'move';
|
||
|
|
setFocusSidebarHostDropActive(false);
|
||
|
|
|
||
|
|
const rect = event.currentTarget.getBoundingClientRect();
|
||
|
|
const position = event.clientY < rect.top + rect.height / 2 ? 'before' : 'after';
|
||
|
|
setFocusSidebarDropIndicator({ sessionId: targetSessionId, position });
|
||
|
|
}, [acceptFocusSidebarHostDrag, focusSidebarDragSessionId]);
|
||
|
|
|
||
|
|
const handleFocusSidebarContainerDragOver = useCallback((event: DragEvent<HTMLDivElement>) => {
|
||
|
|
if (acceptFocusSidebarHostDrag(event)) return;
|
||
|
|
|
||
|
|
const draggedSessionId = event.dataTransfer.getData(FOCUS_SIDEBAR_SESSION_DRAG_TYPE) || focusSidebarDragSessionId;
|
||
|
|
if (!draggedSessionId) return;
|
||
|
|
|
||
|
|
const target = getFocusSidebarContainerDropTarget(event.currentTarget, event.clientY, draggedSessionId);
|
||
|
|
if (!target) return;
|
||
|
|
|
||
|
|
event.preventDefault();
|
||
|
|
event.dataTransfer.dropEffect = 'move';
|
||
|
|
setFocusSidebarHostDropActive(false);
|
||
|
|
setFocusSidebarDropIndicator(target);
|
||
|
|
}, [acceptFocusSidebarHostDrag, focusSidebarDragSessionId, getFocusSidebarContainerDropTarget]);
|
||
|
|
|
||
|
|
const appendHostFromFocusSidebarDrop = useCallback((event: DragEvent) => {
|
||
|
|
if (!onAppendHostToWorkspace) return false;
|
||
|
|
const handled = appendHostFromWorkspaceDrop({
|
||
|
|
types: event.dataTransfer.types,
|
||
|
|
getData: (type) => event.dataTransfer.getData(type),
|
||
|
|
activeSessionDragId: focusSidebarDragSessionId,
|
||
|
|
workspaceId: activeWorkspace.id,
|
||
|
|
onAppendHostToWorkspace,
|
||
|
|
});
|
||
|
|
if (!handled) return false;
|
||
|
|
|
||
|
|
event.preventDefault();
|
||
|
|
event.stopPropagation();
|
||
|
|
setFocusSidebarHostDropActive(false);
|
||
|
|
setFocusSidebarDropIndicator(null);
|
||
|
|
return true;
|
||
|
|
}, [activeWorkspace.id, focusSidebarDragSessionId, onAppendHostToWorkspace]);
|
||
|
|
|
||
|
|
const handleFocusSidebarHostDrop = useCallback((event: DragEvent<HTMLDivElement>) => {
|
||
|
|
appendHostFromFocusSidebarDrop(event);
|
||
|
|
}, [appendHostFromFocusSidebarDrop]);
|
||
|
|
|
||
|
|
const handleFocusSidebarContainerDrop = useCallback((event: DragEvent<HTMLDivElement>) => {
|
||
|
|
if (appendHostFromFocusSidebarDrop(event)) return;
|
||
|
|
|
||
|
|
const draggedSessionId = event.dataTransfer.getData(FOCUS_SIDEBAR_SESSION_DRAG_TYPE) || focusSidebarDragSessionId;
|
||
|
|
if (!draggedSessionId) return;
|
||
|
|
|
||
|
|
const target = focusSidebarDropIndicator
|
||
|
|
?? getFocusSidebarContainerDropTarget(event.currentTarget, event.clientY, draggedSessionId);
|
||
|
|
if (!target || target.sessionId === draggedSessionId) return;
|
||
|
|
|
||
|
|
event.preventDefault();
|
||
|
|
onReorderWorkspaceSessions?.(activeWorkspace.id, draggedSessionId, target.sessionId, target.position);
|
||
|
|
setFocusSidebarDragSessionId(null);
|
||
|
|
setFocusSidebarDropIndicator(null);
|
||
|
|
setFocusSidebarHostDropActive(false);
|
||
|
|
}, [
|
||
|
|
activeWorkspace.id,
|
||
|
|
appendHostFromFocusSidebarDrop,
|
||
|
|
focusSidebarDragSessionId,
|
||
|
|
focusSidebarDropIndicator,
|
||
|
|
getFocusSidebarContainerDropTarget,
|
||
|
|
onReorderWorkspaceSessions,
|
||
|
|
]);
|
||
|
|
|
||
|
|
const handleFocusSidebarDrop = useCallback((event: DragEvent, targetSessionId: string) => {
|
||
|
|
if (appendHostFromFocusSidebarDrop(event)) return;
|
||
|
|
|
||
|
|
const draggedSessionId = event.dataTransfer.getData(FOCUS_SIDEBAR_SESSION_DRAG_TYPE) || focusSidebarDragSessionId;
|
||
|
|
if (!draggedSessionId || draggedSessionId === targetSessionId) return;
|
||
|
|
|
||
|
|
event.preventDefault();
|
||
|
|
event.stopPropagation();
|
||
|
|
const rect = event.currentTarget.getBoundingClientRect();
|
||
|
|
const position = focusSidebarDropIndicator?.sessionId === targetSessionId
|
||
|
|
? focusSidebarDropIndicator.position
|
||
|
|
: event.clientY < rect.top + rect.height / 2 ? 'before' : 'after';
|
||
|
|
onReorderWorkspaceSessions?.(activeWorkspace.id, draggedSessionId, targetSessionId, position);
|
||
|
|
setFocusSidebarDragSessionId(null);
|
||
|
|
setFocusSidebarDropIndicator(null);
|
||
|
|
setFocusSidebarHostDropActive(false);
|
||
|
|
}, [
|
||
|
|
activeWorkspace.id,
|
||
|
|
appendHostFromFocusSidebarDrop,
|
||
|
|
focusSidebarDragSessionId,
|
||
|
|
focusSidebarDropIndicator,
|
||
|
|
onReorderWorkspaceSessions,
|
||
|
|
]);
|
||
|
|
|
||
|
|
const handleFocusSidebarDragEnd = useCallback(() => {
|
||
|
|
setFocusSidebarDragSessionId(null);
|
||
|
|
setFocusSidebarDropIndicator(null);
|
||
|
|
setFocusSidebarHostDropActive(false);
|
||
|
|
}, []);
|
||
|
|
|
||
|
|
const handleSelectSession = useCallback((sessionId: string) => {
|
||
|
|
onSetWorkspaceFocusedSession?.(activeWorkspace.id, sessionId);
|
||
|
|
}, [activeWorkspace.id, onSetWorkspaceFocusedSession]);
|
||
|
|
|
||
|
|
const handleLocalStartRename = useCallback((sessionId: string) => {
|
||
|
|
const session = sessions.find((s) => s.id === sessionId);
|
||
|
|
if (!session) return;
|
||
|
|
setSidebarRenameSessionId(sessionId);
|
||
|
|
setSidebarRenameValue(session.customName || session.hostLabel || '');
|
||
|
|
}, [sessions]);
|
||
|
|
|
||
|
|
const handleLocalSubmitRename = useCallback((name: string) => {
|
||
|
|
if (!sidebarRenameSessionId) return;
|
||
|
|
onSubmitSessionRename(sidebarRenameSessionId, name);
|
||
|
|
setSidebarRenameSessionId(null);
|
||
|
|
setSidebarRenameValue('');
|
||
|
|
}, [sidebarRenameSessionId, onSubmitSessionRename]);
|
||
|
|
|
||
|
|
const handleLocalCancelRename = useCallback(() => {
|
||
|
|
setSidebarRenameSessionId(null);
|
||
|
|
setSidebarRenameValue('');
|
||
|
|
}, []);
|
||
|
|
|
||
|
|
return (
|
||
|
|
<div
|
||
|
|
className={cn(
|
||
|
|
'flex-shrink-0 flex flex-col relative transition-[box-shadow,background-color]',
|
||
|
|
focusSidebarHostDropActive && 'ring-1 ring-inset',
|
||
|
|
)}
|
||
|
|
style={{
|
||
|
|
width: focusSidebarWidth,
|
||
|
|
backgroundColor: focusSidebarHostDropActive
|
||
|
|
? `color-mix(in srgb, ${theme.termFg} 8%, ${theme.termBg})`
|
||
|
|
: theme.termBg,
|
||
|
|
color: theme.termFg,
|
||
|
|
boxShadow: focusSidebarHostDropActive
|
||
|
|
? `inset 0 0 0 1px color-mix(in srgb, ${theme.termFg} 28%, transparent)`
|
||
|
|
: undefined,
|
||
|
|
['--terminal-workspace-sidebar-border' as string]: `1px solid ${theme.separator}`,
|
||
|
|
}}
|
||
|
|
data-section="terminal-workspace-sidebar"
|
||
|
|
data-focus-sidebar-drop-zone
|
||
|
|
data-host-drop-active={focusSidebarHostDropActive ? 'true' : 'false'}
|
||
|
|
onDragOver={handleFocusSidebarHostDragOver}
|
||
|
|
onDragLeave={handleFocusSidebarHostDragLeave}
|
||
|
|
onDrop={handleFocusSidebarHostDrop}
|
||
|
|
>
|
||
|
|
<div
|
||
|
|
className="absolute top-0 right-[-3px] h-full w-2 cursor-ew-resize z-30"
|
||
|
|
onMouseDown={handleFocusSidebarResizeStart}
|
||
|
|
/>
|
||
|
|
<div
|
||
|
|
className="h-9 flex items-center gap-1 px-1.5 flex-shrink-0"
|
||
|
|
style={{ borderBottom: `1px solid ${theme.separator}` }}
|
||
|
|
>
|
||
|
|
<div className="relative flex-1 min-w-0">
|
||
|
|
<Search
|
||
|
|
size={12}
|
||
|
|
className="absolute left-1 top-1/2 -translate-y-1/2 pointer-events-none"
|
||
|
|
style={{ color: theme.mutedFg }}
|
||
|
|
/>
|
||
|
|
<Input
|
||
|
|
value={focusSidebarSearch}
|
||
|
|
onChange={(event) => setFocusSidebarSearch(event.target.value)}
|
||
|
|
placeholder="Search terminals..."
|
||
|
|
className="h-7 pl-6 pr-1 text-xs bg-transparent border-0 shadow-none focus-visible:ring-0 focus-visible:ring-offset-0"
|
||
|
|
style={{ color: theme.termFg }}
|
||
|
|
/>
|
||
|
|
</div>
|
||
|
|
{onRequestAddToWorkspace && (
|
||
|
|
<Tooltip>
|
||
|
|
<TooltipTrigger asChild>
|
||
|
|
<Button
|
||
|
|
variant="ghost"
|
||
|
|
size="icon"
|
||
|
|
className="h-7 w-7 p-0 flex-shrink-0 hover:bg-transparent hover:text-inherit"
|
||
|
|
style={{ color: theme.mutedFg }}
|
||
|
|
onClick={() => onRequestAddToWorkspace(activeWorkspace.id)}
|
||
|
|
>
|
||
|
|
<Plus size={14} />
|
||
|
|
</Button>
|
||
|
|
</TooltipTrigger>
|
||
|
|
<TooltipContent>{t('terminal.layer.addTerminal')}</TooltipContent>
|
||
|
|
</Tooltip>
|
||
|
|
)}
|
||
|
|
<Tooltip>
|
||
|
|
<TooltipTrigger asChild>
|
||
|
|
<Button
|
||
|
|
variant="ghost"
|
||
|
|
size="icon"
|
||
|
|
className="h-7 w-7 p-0 flex-shrink-0 hover:bg-transparent hover:text-inherit"
|
||
|
|
style={{ color: theme.mutedFg }}
|
||
|
|
onClick={() => onToggleWorkspaceViewMode?.(activeWorkspace.id)}
|
||
|
|
>
|
||
|
|
<Columns2 size={14} />
|
||
|
|
</Button>
|
||
|
|
</TooltipTrigger>
|
||
|
|
<TooltipContent>{t('terminal.layer.switchToSplitView')}</TooltipContent>
|
||
|
|
</Tooltip>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<ScrollArea className="flex-1">
|
||
|
|
<div
|
||
|
|
className="p-2 space-y-1 min-h-full rounded-md"
|
||
|
|
onDragOver={handleFocusSidebarContainerDragOver}
|
||
|
|
onDrop={handleFocusSidebarContainerDrop}
|
||
|
|
>
|
||
|
|
{visibleSessions.map((session) => (
|
||
|
|
<WorkspaceFocusSessionRow
|
||
|
|
key={session.id}
|
||
|
|
session={session}
|
||
|
|
host={sessionHostsMap.get(session.id)}
|
||
|
|
isSelected={session.id === focusedSessionId}
|
||
|
|
isRenaming={sidebarRenameSessionId === session.id}
|
||
|
|
renameValue={sidebarRenameValue}
|
||
|
|
onStartRename={handleLocalStartRename}
|
||
|
|
onSubmitRename={handleLocalSubmitRename}
|
||
|
|
onCancelRename={handleLocalCancelRename}
|
||
|
|
onCloseSession={onCloseSession}
|
||
|
|
onCopySession={onCopySession}
|
||
|
|
onDuplicateSession={onDuplicateSession}
|
||
|
|
onCopySessionToNewWindow={onCopySessionToNewWindow}
|
||
|
|
onDetachSessionFromWorkspace={onDetachSessionFromWorkspace}
|
||
|
|
isDragging={focusSidebarDragSessionId === session.id}
|
||
|
|
dropPosition={
|
||
|
|
focusSidebarDropIndicator?.sessionId === session.id
|
||
|
|
? focusSidebarDropIndicator.position
|
||
|
|
: null
|
||
|
|
}
|
||
|
|
theme={theme}
|
||
|
|
onSelect={handleSelectSession}
|
||
|
|
onDragStart={handleFocusSidebarDragStart}
|
||
|
|
onDragOver={handleFocusSidebarDragOver}
|
||
|
|
onHostDragLeave={handleFocusSidebarHostDragLeave}
|
||
|
|
onDrop={handleFocusSidebarDrop}
|
||
|
|
onDragEnd={handleFocusSidebarDragEnd}
|
||
|
|
dynamicTabTitleMode={dynamicTabTitleMode}
|
||
|
|
t={t}
|
||
|
|
/>
|
||
|
|
))}
|
||
|
|
</div>
|
||
|
|
</ScrollArea>
|
||
|
|
</div>
|
||
|
|
);
|
||
|
|
};
|
||
|
|
|
||
|
|
function terminalFocusSidebarPropsEqual(
|
||
|
|
prev: TerminalFocusSidebarProps,
|
||
|
|
next: TerminalFocusSidebarProps,
|
||
|
|
): boolean {
|
||
|
|
if (prev.focusedSessionId !== next.focusedSessionId) return false;
|
||
|
|
if (prev.onSubmitSessionRename !== next.onSubmitSessionRename) return false;
|
||
|
|
if (prev.onCloseSession !== next.onCloseSession) return false;
|
||
|
|
if (prev.onCopySession !== next.onCopySession) return false;
|
||
|
|
if (prev.onDuplicateSession !== next.onDuplicateSession) return false;
|
||
|
|
if (prev.onCopySessionToNewWindow !== next.onCopySessionToNewWindow) return false;
|
||
|
|
if (prev.onDetachSessionFromWorkspace !== next.onDetachSessionFromWorkspace) return false;
|
||
|
|
if (prev.resolvedPreviewTheme !== next.resolvedPreviewTheme) return false;
|
||
|
|
if (prev.sessionHostsMap !== next.sessionHostsMap) return false;
|
||
|
|
if (prev.sessions !== next.sessions) return false;
|
||
|
|
if (prev.dynamicTabTitleMode !== next.dynamicTabTitleMode) return false;
|
||
|
|
if (prev.t !== next.t) return false;
|
||
|
|
if (prev.onReorderWorkspaceSessions !== next.onReorderWorkspaceSessions) return false;
|
||
|
|
if (prev.onRequestAddToWorkspace !== next.onRequestAddToWorkspace) return false;
|
||
|
|
if (prev.onAppendHostToWorkspace !== next.onAppendHostToWorkspace) return false;
|
||
|
|
if (prev.onSetWorkspaceFocusedSession !== next.onSetWorkspaceFocusedSession) return false;
|
||
|
|
if (prev.onToggleWorkspaceViewMode !== next.onToggleWorkspaceViewMode) return false;
|
||
|
|
const prevWs = prev.activeWorkspace;
|
||
|
|
const nextWs = next.activeWorkspace;
|
||
|
|
return (
|
||
|
|
prevWs.id === nextWs.id
|
||
|
|
&& prevWs.viewMode === nextWs.viewMode
|
||
|
|
&& prevWs.root === nextWs.root
|
||
|
|
&& prevWs.focusSessionOrder === nextWs.focusSessionOrder
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
export const TerminalFocusSidebar = memo(TerminalFocusSidebarInner, terminalFocusSidebarPropsEqual);
|
||
|
|
TerminalFocusSidebar.displayName = 'TerminalFocusSidebar';
|