import { Copy, FileCode, FileText, LayoutGrid, Minus, Server, Square, Terminal, TerminalSquare, Usb, X } from 'lucide-react'; import React, { memo, useCallback, useEffect, useLayoutEffect, useMemo, useState } from 'react'; import { activeTabStore, useActiveTabId, useIsTabActive } from '../../application/state/activeTabStore'; import { useAnySessionActivity, useSessionActivity, } from '../../application/state/sessionActivityStore'; import { usePresentedSession } from '../../application/state/sessionPresentationStore'; import { useEditorTabDirty, type EditorTabChrome, } from '../../application/state/editorTabStore'; import type { LogView } from '../../application/state/logViewState'; import { useWindowControls } from '../../application/state/useWindowControls'; import { terminalReconnectRegistry } from '../../application/state/terminalReconnectRegistry'; import { useI18n } from '../../application/i18n/I18nProvider'; import { getEffectiveHostDistro } from '../../domain/host'; import { resolveHostIconAppearance, resolveHostIconColorAppearance } from '../../domain/hostIcon'; import { resolveSessionCodingCliProvider } from '../../domain/codingCliProviderMatch'; import type { CodingCliProvider } from '../../domain/codingCliProviders'; import { resolveCodingCliActivityPhase, type CodingCliActivityPhase } from '../../domain/codingCliTitleParse'; import { resolveSessionTabTitle, resolveWorkspaceTabLabel } from '../../domain/sessionTabTitle'; import type { DynamicTabTitleMode } from '../../domain/models'; import { CodingCliProviderIcon } from '../icons/CodingCliProviderIcon'; import { cn } from '../../lib/utils'; import { Host, TerminalSession, Workspace } from '../../types'; import { DISTRO_LOGOS, DISTRO_COLORS } from '../DistroAvatar'; import { getShellIconPath, isMonochromeShellIcon } from '../../lib/useDiscoveredShells'; import { handleTabMiddleClickClose, handleTabMiddleMouseDown } from '../../lib/tabInteractions'; import { ContextMenu, ContextMenuContent, ContextMenuItem, ContextMenuSeparator, ContextMenuTrigger } from '../ui/context-menu'; import { Tooltip, TooltipContent, TooltipTrigger } from '../ui/tooltip'; import { SessionTabContextMenuContent } from './SessionTabContextMenuContent'; import { renderHostIconGlyph } from '../hostIconRenderer'; import type { PluginViewTab } from '../../application/state/pluginViewTabStore'; import { PluginContributionIcon } from '../plugins/PluginContributionIcon'; // File extensions that render the code-file icon instead of the plain text icon. const CODE_EXTENSIONS_RE = /\.(js|jsx|ts|tsx|py|rb|go|rs|c|cpp|cs|java|php|sh|bash|zsh|fish|lua|r|scala|swift|kt|html|css|scss|less|json|yaml|yml|toml|xml|sql|graphql|gql|md|mdx|conf|ini|env|tf|hcl|dockerfile)$/i; export function activateLogViewTab(logViewId: string): void { activeTabStore.setActiveTabId(logViewId); } const localOsId = (() => { if (typeof navigator === 'undefined') return 'linux'; const ua = navigator.userAgent; if (/Mac/i.test(ua)) return 'macos'; if (/Win/i.test(ua)) return 'windows'; return 'linux'; })(); // Lightweight OS/distro icon for session tabs — matches DistroAvatar "sm" style const SessionTabIcon: React.FC<{ host: Host | undefined; session: Pick; isActive: boolean; protocol?: string; shellIcon?: string; dynamicTabTitleMode?: DynamicTabTitleMode; }> = memo(({ host, session, isActive, protocol, shellIcon, dynamicTabTitleMode }) => { const boxBase = "shrink-0 h-4 w-4 rounded flex items-center justify-center"; const iconSize = "h-2.5 w-2.5"; const fallbackStyle = { color: isActive ? 'var(--top-tabs-accent, hsl(var(--accent)))' : 'var(--top-tabs-muted, hsl(var(--muted-foreground)))' }; const codingCliIconState = resolveSessionTabCodingCliIconState(session, host, dynamicTabTitleMode); if (codingCliIconState) { const { provider: codingCliProvider, activityPhase } = codingCliIconState; return ( ); } // Serial protocol → USB icon if (protocol === 'serial' || host?.protocol === 'serial') { return (
); } // Local protocol → shell-specific icon if available, else OS-specific icon if (protocol === 'local' || host?.protocol === 'local' || (!protocol && !host)) { // Use shell icon from discovery when available const iconId = shellIcon || host?.localShellIcon; if (iconId) { return ( {iconId} ); } const logo = DISTRO_LOGOS[localOsId]; const bg = DISTRO_COLORS[localOsId] || DISTRO_COLORS.default; if (logo) { return (
{localOsId}
); } return (
); } if (host) { const customAppearance = resolveHostIconAppearance(host); if (customAppearance) { return (
{renderHostIconGlyph(customAppearance.iconId, iconSize)}
); } } // Try distro logo with brand background color if (host) { const distro = getEffectiveHostDistro(host); const logo = DISTRO_LOGOS[distro]; if (logo) { const bg = DISTRO_COLORS[distro] || DISTRO_COLORS.default; const customColor = resolveHostIconColorAppearance(host); return (
{distro
); } } // Fallback: generic server icon for remote, terminal for unknown if (host && host.protocol !== 'local') { return (
); } return ; }); SessionTabIcon.displayName = 'SessionTabIcon'; export function resolveSessionTabCodingCliIconState( session: Pick< TerminalSession, | 'dynamicTitle' | 'startupCommand' | 'customName' | 'hostLabel' | 'localShell' | 'localShellName' | 'codingCliProviderId' >, host: Host | undefined, dynamicTabTitleMode: DynamicTabTitleMode = 'agent', ): { provider: CodingCliProvider; activityPhase: CodingCliActivityPhase } | null { if (dynamicTabTitleMode === 'off') { const provider = resolveSessionCodingCliProvider({ ...session, dynamicTitle: undefined, }, host); return provider ? { provider, activityPhase: 'idle' } : null; } const provider = resolveSessionCodingCliProvider(session, host); if (!provider) return null; return { provider, activityPhase: resolveCodingCliActivityPhase(session.dynamicTitle, provider.id), }; } export const sessionStatusDot = (status: TerminalSession['status'], hasActivity: boolean) => { const tone = status === 'connected' ? "bg-emerald-400" : status === 'connecting' ? "bg-amber-400" : "bg-rose-500"; return ( ); }; const getSessionTopTabAddress = ( session: Pick, ): string | null => { const protocol = session.protocol ?? 'ssh'; if ( session.moshEnabled || session.etEnabled || (protocol !== 'ssh' && protocol !== 'telnet') || !session.hostname ) { return null; } return session.hostname; }; export const formatSessionTopTabTooltip = ( session: Pick, ): string | null => { const address = getSessionTopTabAddress(session); if (!address) return null; return `${session.username ? `${session.username}@` : ''}${address}${session.port ? `:${session.port}` : ''}`; }; export const formatSessionTopTabLabel = ( session: Pick< TerminalSession, 'customName' | 'hostLabel' | 'dynamicTitle' | 'codingCliProviderId' | 'protocol' | 'hostname' | 'moshEnabled' | 'etEnabled' >, dynamicTabTitleMode?: DynamicTabTitleMode, ): string => { return resolveSessionTabTitle(session, dynamicTabTitleMode); }; export const createTopTabCopyDoubleClickHandler = ( onCopySession: (sessionId: string) => void, sessionId: string, ): React.MouseEventHandler => () => onCopySession(sessionId); export const stopCloseButtonDoubleClickPropagation = ( event: Pick, ): void => { event.stopPropagation(); }; // Custom window controls for Windows/Linux (frameless window) export const WindowControls: React.FC = memo(() => { const { minimize, maximize, close, isMaximized: fetchIsMaximized } = useWindowControls(); const [isMaximized, setIsMaximized] = useState(false); useEffect(() => { // Check initial maximized state fetchIsMaximized().then(v => setIsMaximized(!!v)); // Listen for window resize to update maximized state (debounced to avoid IPC storm) let resizeTimer: ReturnType | null = null; const handleResize = () => { if (resizeTimer) clearTimeout(resizeTimer); resizeTimer = setTimeout(() => { fetchIsMaximized().then(v => setIsMaximized(!!v)); }, 200); }; window.addEventListener('resize', handleResize); return () => { window.removeEventListener('resize', handleResize); if (resizeTimer) clearTimeout(resizeTimer); }; }, [fetchIsMaximized]); const handleMinimize = () => { minimize(); }; const handleMaximize = async () => { const result = await maximize(); setIsMaximized(!!result); }; const handleClose = () => { close(); }; const controlClassName = 'window-control-btn app-no-drag'; const closeControlClassName = 'window-control-btn window-control-btn--close app-no-drag'; return (
); }); WindowControls.displayName = 'WindowControls'; type TranslateFn = ReturnType['t']; type RenderBulkCloseItems = (anchorId: string) => React.ReactNode; /** 1-9 badge aligned with Cmd/Ctrl+[1...9] tab switching. */ export const TabShortcutNumberBadge: React.FC<{ number: number }> = memo(({ number }) => ( )); TabShortcutNumberBadge.displayName = 'TabShortcutNumberBadge'; const TOP_TAB_COMFORT_EDGE_RATIO = 0.22; const TOP_TAB_COMFORT_EDGE_MIN = 72; const TOP_TAB_COMFORT_EDGE_MAX = 160; export function scrollTopTabIntoComfortView( container: HTMLDivElement | null, tab: HTMLElement | null, behavior: ScrollBehavior = 'smooth', ) { if (!container || !tab) return; if (container.scrollWidth <= container.clientWidth) return; const containerRect = container.getBoundingClientRect(); const tabRect = tab.getBoundingClientRect(); const edgeBuffer = Math.min( TOP_TAB_COMFORT_EDGE_MAX, Math.max(TOP_TAB_COMFORT_EDGE_MIN, containerRect.width * TOP_TAB_COMFORT_EDGE_RATIO), ); const isNearLeft = tabRect.left < containerRect.left + edgeBuffer; const isNearRight = tabRect.right > containerRect.right - edgeBuffer; if (!isNearLeft && !isNearRight) return; const tabCenter = tabRect.left - containerRect.left + container.scrollLeft + tabRect.width / 2; const maxScrollLeft = container.scrollWidth - container.clientWidth; const targetLeft = Math.max( 0, Math.min(maxScrollLeft, tabCenter - container.clientWidth / 2), ); if (Math.abs(container.scrollLeft - targetLeft) < 1) return; container.scrollTo({ left: targetLeft, behavior }); } interface ActiveTabAutoScrollerProps { tabsContainerRef: React.RefObject; updateScrollState: () => void; } export const ActiveTabAutoScroller: React.FC = memo(({ tabsContainerRef, updateScrollState, }) => { const activeTabId = useActiveTabId(); useLayoutEffect(() => { if (!activeTabId || activeTabId === 'vault' || activeTabId === 'sftp') return; const container = tabsContainerRef.current; if (!container) return; const activeTabElement = container.querySelector(`[data-tab-id="${activeTabId}"]`) as HTMLElement | null; scrollTopTabIntoComfortView(container, activeTabElement, 'smooth'); const scrollStateTimer = setTimeout(updateScrollState, 260); return () => clearTimeout(scrollStateTimer); }, [activeTabId, tabsContainerRef, updateScrollState]); return null; }); ActiveTabAutoScroller.displayName = 'ActiveTabAutoScroller'; interface RootTopTabProps { tabId: 'vault' | 'sftp'; label: string; icon: React.ReactNode; className?: string; compact?: boolean; shortcutNumber?: number; } export const RootTopTab: React.FC = memo(({ tabId, label, icon, className, compact = false, shortcutNumber }) => { const isActive = useIsTabActive(tabId); // The Vaults tab is the app's persistent "home", so keep its selected state // visually flat — no active background fill (the label/icon still brighten to // the active foreground for subtle feedback). Other root tabs (SFTP) keep the // normal filled active state. const suppressActiveBg = tabId === 'vault'; const handleClick = useCallback((e: React.MouseEvent) => { // Flat tabs never change their React-managed backgroundColor (transparent // when inactive AND active), so React can't diff transparent → transparent // to clear the hover fill that onMouseEnter wrote imperatively. Clicking // straight from a hover would otherwise leave a stuck highlight, so reset // it here before activating. if (suppressActiveBg) { e.currentTarget.style.backgroundColor = 'transparent'; } activeTabStore.setActiveTabId(tabId); }, [tabId, suppressActiveBg]); return (
{ if (!isActive) { e.currentTarget.style.backgroundColor = 'color-mix(in srgb, var(--top-tabs-active-bg, hsl(var(--background))) 40%, transparent)'; e.currentTarget.style.color = 'var(--top-tabs-fg, hsl(var(--foreground)))'; } }} onMouseLeave={(e) => { if (!isActive) { e.currentTarget.style.backgroundColor = 'transparent'; e.currentTarget.style.color = 'var(--top-tabs-muted, hsl(var(--muted-foreground)))'; } }} > {shortcutNumber != null ? : icon} {label}
); }); RootTopTab.displayName = 'RootTopTab'; interface PluginViewTopTabProps { tab: PluginViewTab; onClose(tabId: string): void; renderBulkCloseItems: RenderBulkCloseItems; t: TranslateFn; isBeingDragged: boolean; isDraggingForReorder: boolean; shiftStyle: React.CSSProperties; showDropIndicatorBefore: boolean; showDropIndicatorAfter: boolean; onTabDragStart(e: React.DragEvent, tabId: string): void; onTabDragEnd(): void; onTabDragOver(e: React.DragEvent, tabId: string): void; onTabDragLeave(e: React.DragEvent): void; onTabDrop(e: React.DragEvent, tabId: string): void; tabAnimationClass?: string; shortcutNumber?: number; } export const PluginViewTopTab: React.FC = memo(({ tab, onClose, renderBulkCloseItems, t, isBeingDragged, isDraggingForReorder, shiftStyle, showDropIndicatorBefore, showDropIndicatorAfter, onTabDragStart, onTabDragEnd, onTabDragOver, onTabDragLeave, onTabDrop, tabAnimationClass, shortcutNumber, }) => { const isActive = useIsTabActive(tab.id); const close = useCallback((event?: React.MouseEvent) => { event?.stopPropagation(); onClose(tab.id); }, [onClose, tab.id]); const tabBody = (
activeTabStore.setActiveTabId(tab.id)} onMouseDown={handleTabMiddleMouseDown} onAuxClick={(event) => handleTabMiddleClickClose(event, close)} draggable onDragStart={(event) => onTabDragStart(event, tab.id)} onDragEnd={onTabDragEnd} onDragOver={(event) => onTabDragOver(event, tab.id)} onDragLeave={onTabDragLeave} onDrop={(event) => onTabDrop(event, tab.id)} className={cn( 'netcatty-tab relative h-7 min-w-[140px] max-w-[240px] flex-shrink-0 cursor-pointer items-center justify-between gap-2 overflow-hidden rounded-t-md pl-3 pr-2 text-xs font-semibold app-no-drag', 'flex transition-transform duration-150', isBeingDragged && isDraggingForReorder && 'scale-95 opacity-40', tabAnimationClass, )} style={{ ...shiftStyle, backgroundColor: isActive ? 'var(--top-tabs-active-bg, hsl(var(--background)))' : 'transparent', color: isActive ? 'var(--top-tabs-fg, hsl(var(--foreground)))' : 'var(--top-tabs-muted, hsl(var(--muted-foreground)))', }} > {showDropIndicatorBefore && isDraggingForReorder &&
} {showDropIndicatorAfter && isDraggingForReorder &&
}
{shortcutNumber != null ? : } {tab.title}
); return ( {tabBody} {tab.pluginName} onClose(tab.id)}>{t('common.close')} {renderBulkCloseItems(tab.id)} ); }); PluginViewTopTab.displayName = 'PluginViewTopTab'; interface EditorTopTabProps { tabId: string; editorTab: EditorTabChrome; host: Host | undefined; suffix: string; onRequestCloseEditorTab: (editorTabId: string) => void; isBeingDragged: boolean; isDraggingForReorder: boolean; shiftStyle: React.CSSProperties; showDropIndicatorBefore: boolean; showDropIndicatorAfter: boolean; onTabDragStart: (e: React.DragEvent, tabId: string) => void; onTabDragEnd: () => void; onTabDragOver: (e: React.DragEvent, tabId: string) => void; onTabDragLeave: (e: React.DragEvent) => void; onTabDrop: (e: React.DragEvent, targetTabId: string) => void; tabAnimationClass?: string; shortcutNumber?: number; } export const EditorTopTab: React.FC = memo(({ tabId, editorTab, host, suffix, onRequestCloseEditorTab, isBeingDragged, isDraggingForReorder, shiftStyle, showDropIndicatorBefore, showDropIndicatorAfter, onTabDragStart, onTabDragEnd, onTabDragOver, onTabDragLeave, onTabDrop, tabAnimationClass, shortcutNumber, }) => { const isActive = useIsTabActive(tabId); // Dirty is store-driven so App/TopTabs structure can stay presence-only. const dirty = useEditorTabDirty(editorTab.id); const tooltip = `${host?.label ?? editorTab.hostId}@${host?.hostname ?? ''}:${editorTab.remotePath}`; const FileIcon = CODE_EXTENSIONS_RE.test(editorTab.fileName) ? FileCode : FileText; const handleClick = useCallback(() => { activeTabStore.setActiveTabId(tabId); }, [tabId]); const handleClose = useCallback((e: React.MouseEvent) => { e.stopPropagation(); onRequestCloseEditorTab(editorTab.id); }, [editorTab.id, onRequestCloseEditorTab]); return (
handleTabMiddleClickClose(e, () => onRequestCloseEditorTab(editorTab.id))} draggable onDragStart={(e) => onTabDragStart(e, tabId)} onDragEnd={onTabDragEnd} onDragOver={(e) => onTabDragOver(e, tabId)} onDragLeave={onTabDragLeave} onDrop={(e) => onTabDrop(e, tabId)} className={cn( "netcatty-tab relative h-7 pl-3 pr-2 min-w-[140px] max-w-[240px] rounded-t-md overflow-hidden text-xs font-semibold cursor-pointer flex items-center justify-between gap-2 app-no-drag flex-shrink-0", "transition-transform duration-150", isBeingDragged && isDraggingForReorder ? "opacity-40 scale-95" : "", tabAnimationClass, )} style={{ ...shiftStyle, backgroundColor: isActive ? 'var(--top-tabs-active-bg, hsl(var(--background)))' : 'transparent', color: isActive ? 'var(--top-tabs-fg, hsl(var(--foreground)))' : 'var(--top-tabs-muted, hsl(var(--muted-foreground)))', }} onMouseEnter={(e) => { if (!isActive) { e.currentTarget.style.backgroundColor = 'color-mix(in srgb, var(--top-tabs-active-bg, hsl(var(--background))) 40%, transparent)'; e.currentTarget.style.color = 'var(--top-tabs-fg, hsl(var(--foreground)))'; } }} onMouseLeave={(e) => { if (!isActive) { e.currentTarget.style.backgroundColor = 'transparent'; e.currentTarget.style.color = 'var(--top-tabs-muted, hsl(var(--muted-foreground)))'; } }} > {showDropIndicatorBefore && isDraggingForReorder && (
)} {showDropIndicatorAfter && isDraggingForReorder && (
)}
{shortcutNumber != null ? ( ) : ( )} {dirty && } {editorTab.fileName} {suffix && {suffix}}
{tooltip} ); }); EditorTopTab.displayName = 'EditorTopTab'; interface SessionTopTabProps { session: TerminalSession; host: Host | undefined; isBeingDragged: boolean; isDraggingForReorder: boolean; shiftStyle: React.CSSProperties; showDropIndicatorBefore: boolean; showDropIndicatorAfter: boolean; onTabDragStart: (e: React.DragEvent, tabId: string) => void; onTabDragEnd: () => void; onTabDragOver: (e: React.DragEvent, tabId: string) => void; onTabDragLeave: (e: React.DragEvent) => void; onTabDrop: (e: React.DragEvent, targetTabId: string) => void; onCloseSession: (sessionId: string, e?: React.MouseEvent) => void; onRenameSession: (sessionId: string) => void; onCopySession: (sessionId: string) => void; onDuplicateSession?: (sessionId: string) => void; onCopySessionToNewWindow: (sessionId: string) => void; onEditHost?: (host: Host) => void; renderBulkCloseItems: RenderBulkCloseItems; dynamicTabTitleMode?: DynamicTabTitleMode; t: TranslateFn; tabAnimationClass?: string; shortcutNumber?: number; } export const SessionTopTab: React.FC = memo(({ session: sessionProp, host, isBeingDragged, isDraggingForReorder, shiftStyle, showDropIndicatorBefore, showDropIndicatorAfter, onTabDragStart, onTabDragEnd, onTabDragOver, onTabDragLeave, onTabDrop, onCloseSession, onRenameSession, onCopySession, onDuplicateSession, onCopySessionToNewWindow, onEditHost, renderBulkCloseItems, dynamicTabTitleMode, t, tabAnimationClass, shortcutNumber, }) => { // Per-session presentation: sibling title/provider updates do not re-render this tab. const session = usePresentedSession(sessionProp); const reconnectActive = React.useSyncExternalStore( terminalReconnectRegistry.subscribe, () => terminalReconnectRegistry.isActive(session.id), () => false, ); const isActive = useIsTabActive(session.id); // Per-session store snapshot so sibling activity dots do not re-render this tab. const hasActivity = useSessionActivity(session.id); const handleClick = useCallback(() => { activeTabStore.setActiveTabId(session.id); }, [session.id]); const handleDoubleClick = useMemo( () => createTopTabCopyDoubleClickHandler(onCopySession, session.id), [onCopySession, session.id], ); const addressTooltip = formatSessionTopTabTooltip(session); const tabTitle = formatSessionTopTabLabel(session, dynamicTabTitleMode); const tabBody = (
handleTabMiddleClickClose(e, () => onCloseSession(session.id))} draggable onDragStart={(e) => onTabDragStart(e, session.id)} onDragEnd={onTabDragEnd} onDragOver={(e) => onTabDragOver(e, session.id)} onDragLeave={onTabDragLeave} onDrop={(e) => onTabDrop(e, session.id)} className={cn( "netcatty-tab relative h-7 pl-3 pr-2 min-w-[140px] max-w-[240px] rounded-t-md overflow-hidden text-xs font-semibold cursor-pointer flex items-center justify-between gap-2 app-no-drag flex-shrink-0", "transition-transform duration-150", isBeingDragged && isDraggingForReorder ? "opacity-40 scale-95" : "", tabAnimationClass, )} style={{ ...shiftStyle, backgroundColor: isActive ? 'var(--top-tabs-active-bg, hsl(var(--background)))' : 'transparent', color: isActive ? 'var(--top-tabs-fg, hsl(var(--foreground)))' : 'var(--top-tabs-muted, hsl(var(--muted-foreground)))', }} onMouseEnter={(e) => { if (!isActive) { e.currentTarget.style.backgroundColor = 'color-mix(in srgb, var(--top-tabs-active-bg, hsl(var(--background))) 40%, transparent)'; e.currentTarget.style.color = 'var(--top-tabs-fg, hsl(var(--foreground)))'; } }} onMouseLeave={(e) => { if (!isActive) { e.currentTarget.style.backgroundColor = 'transparent'; e.currentTarget.style.color = 'var(--top-tabs-muted, hsl(var(--muted-foreground)))'; } }} > {showDropIndicatorBefore && isDraggingForReorder && (
)} {showDropIndicatorAfter && isDraggingForReorder && (
)}
{shortcutNumber != null ? ( ) : ( )} {tabTitle}
{sessionStatusDot(session.status, hasActivity)}
); const tabTrigger = ( addressTooltip ? ( {tabBody} {addressTooltip} ) : ( {tabBody} ) ); return ( {tabTrigger} ); }); SessionTopTab.displayName = 'SessionTopTab'; interface WorkspaceTopTabProps { workspace: Workspace; paneCount: number; workspaceSessionIds: readonly string[]; isBeingDragged: boolean; isDraggingForReorder: boolean; shiftStyle: React.CSSProperties; showDropIndicatorBefore: boolean; showDropIndicatorAfter: boolean; isHostDropTarget: boolean; onTabDragStart: (e: React.DragEvent, tabId: string) => void; onTabDragEnd: () => void; onTabDragOver: (e: React.DragEvent, tabId: string) => void; onTabDragLeave: (e: React.DragEvent) => void; onTabDrop: (e: React.DragEvent, targetTabId: string) => void; onRenameWorkspace: (workspaceId: string) => void; onCopyWorkspace: (workspaceId: string) => void; onCloseWorkspace: (workspaceId: string) => void; onDetachSessionFromWorkspace?: (workspaceId: string, sessionId: string) => void; /** Sessions for Detach menu; each menu item applies live presentation itself. */ workspaceSessions?: TerminalSession[]; dynamicTabTitleMode?: DynamicTabTitleMode; renderBulkCloseItems: RenderBulkCloseItems; t: TranslateFn; tabAnimationClass?: string; shortcutNumber?: number; } /** * Detach-menu row that subscribes to presentation for one session only, so * agent title updates stay live without remapping the whole TopTabs bar. */ const WorkspaceDetachSessionMenuItem: React.FC<{ session: TerminalSession; workspaceId: string; dynamicTabTitleMode?: DynamicTabTitleMode; onDetach: (workspaceId: string, sessionId: string) => void; t: TranslateFn; }> = memo(({ session: sessionProp, workspaceId, dynamicTabTitleMode, onDetach, t }) => { const session = usePresentedSession(sessionProp); const label = resolveSessionTabTitle(session, dynamicTabTitleMode); return ( onDetach(workspaceId, session.id)}> {t('terminal.menu.detachSession', { name: label })} ); }); WorkspaceDetachSessionMenuItem.displayName = 'WorkspaceDetachSessionMenuItem'; export const WorkspaceTopTab: React.FC = memo(({ workspace, paneCount, workspaceSessionIds, workspaceSessions, dynamicTabTitleMode, isBeingDragged, isDraggingForReorder, shiftStyle, showDropIndicatorBefore, showDropIndicatorAfter, isHostDropTarget, onTabDragStart, onTabDragEnd, onTabDragOver, onTabDragLeave, onTabDrop, onRenameWorkspace, onCopyWorkspace, onCloseWorkspace, onDetachSessionFromWorkspace, renderBulkCloseItems, t, tabAnimationClass, shortcutNumber, }) => { const isActive = useIsTabActive(workspace.id); const hasActivity = useAnySessionActivity(workspaceSessionIds); const handleClick = useCallback(() => { activeTabStore.setActiveTabId(workspace.id); }, [workspace.id]); const handleDoubleClick = useMemo( () => createTopTabCopyDoubleClickHandler(onCopyWorkspace, workspace.id), [onCopyWorkspace, workspace.id], ); const detachSessions = workspaceSessions ?? []; const tabLabel = resolveWorkspaceTabLabel(workspace, detachSessions); return (
handleTabMiddleClickClose(e, () => onCloseWorkspace(workspace.id))} draggable onDragStart={(e) => onTabDragStart(e, workspace.id)} onDragEnd={onTabDragEnd} onDragOver={(e) => onTabDragOver(e, workspace.id)} onDragLeave={onTabDragLeave} onDrop={(e) => onTabDrop(e, workspace.id)} className={cn( "netcatty-tab relative h-7 pl-3 pr-2 min-w-[150px] max-w-[260px] rounded-t-md overflow-hidden text-xs font-semibold cursor-pointer flex items-center justify-between gap-2 app-no-drag flex-shrink-0", "transition-transform duration-150", isBeingDragged && isDraggingForReorder ? "opacity-40 scale-95" : "", isHostDropTarget && "ring-1 ring-inset", tabAnimationClass, )} style={{ ...shiftStyle, backgroundColor: isActive ? 'var(--top-tabs-active-bg, hsl(var(--background)))' : 'transparent', color: isActive ? 'var(--top-tabs-fg, hsl(var(--foreground)))' : 'var(--top-tabs-muted, hsl(var(--muted-foreground)))', }} onMouseEnter={(e) => { if (!isActive) { e.currentTarget.style.backgroundColor = 'color-mix(in srgb, var(--top-tabs-active-bg, hsl(var(--background))) 40%, transparent)'; e.currentTarget.style.color = 'var(--top-tabs-fg, hsl(var(--foreground)))'; } }} onMouseLeave={(e) => { if (!isActive) { e.currentTarget.style.backgroundColor = 'transparent'; e.currentTarget.style.color = 'var(--top-tabs-muted, hsl(var(--muted-foreground)))'; } }} > {showDropIndicatorBefore && isDraggingForReorder && (
)} {showDropIndicatorAfter && isDraggingForReorder && (
)}
{shortcutNumber != null ? ( ) : ( )} {tabLabel}
{hasActivity && sessionStatusDot('connected', true)}
{paneCount}
onRenameWorkspace(workspace.id)}> {t('common.rename')} onCopyWorkspace(workspace.id)}> {t('tabs.copyTab')} {onDetachSessionFromWorkspace && detachSessions.map((session) => ( ))} {onDetachSessionFromWorkspace && detachSessions.length > 0 && ( )} onCloseWorkspace(workspace.id)}> {t('common.close')} {renderBulkCloseItems(workspace.id)} ); }); WorkspaceTopTab.displayName = 'WorkspaceTopTab'; interface LogViewTopTabProps { logView: LogView; onCloseLogView: (logViewId: string) => void; isBeingDragged: boolean; isDraggingForReorder: boolean; shiftStyle: React.CSSProperties; showDropIndicatorBefore: boolean; showDropIndicatorAfter: boolean; onTabDragStart: (e: React.DragEvent, tabId: string) => void; onTabDragEnd: () => void; onTabDragOver: (e: React.DragEvent, tabId: string) => void; onTabDragLeave: (e: React.DragEvent) => void; onTabDrop: (e: React.DragEvent, targetTabId: string) => void; t: TranslateFn; tabAnimationClass?: string; shortcutNumber?: number; } export const LogViewTopTab: React.FC = memo(({ logView, onCloseLogView, isBeingDragged, isDraggingForReorder, shiftStyle, showDropIndicatorBefore, showDropIndicatorAfter, onTabDragStart, onTabDragEnd, onTabDragOver, onTabDragLeave, onTabDrop, t, tabAnimationClass, shortcutNumber, }) => { const isActive = useIsTabActive(logView.id); const isLocal = logView.log.protocol === 'local' || logView.log.hostname === 'localhost'; const handleClick = useCallback(() => { activateLogViewTab(logView.id); }, [logView.id]); const handleClose = useCallback((e: React.MouseEvent) => { e.stopPropagation(); onCloseLogView(logView.id); }, [logView.id, onCloseLogView]); return (
handleTabMiddleClickClose(e, () => onCloseLogView(logView.id))} draggable onDragStart={(e) => onTabDragStart(e, logView.id)} onDragEnd={onTabDragEnd} onDragOver={(e) => onTabDragOver(e, logView.id)} onDragLeave={onTabDragLeave} onDrop={(e) => onTabDrop(e, logView.id)} className={cn( "netcatty-tab relative h-7 pl-3 pr-2 min-w-[140px] max-w-[240px] rounded-t-md overflow-hidden text-xs font-semibold cursor-pointer flex items-center justify-between gap-2 app-no-drag flex-shrink-0", "transition-transform duration-150", isBeingDragged && isDraggingForReorder ? "opacity-40 scale-95" : "", tabAnimationClass, )} style={{ ...shiftStyle, backgroundColor: isActive ? 'var(--top-tabs-active-bg, hsl(var(--background)))' : 'transparent', color: isActive ? 'var(--top-tabs-fg, hsl(var(--foreground)))' : 'var(--top-tabs-muted, hsl(var(--muted-foreground)))', }} onMouseEnter={(e) => { if (!isActive) { e.currentTarget.style.backgroundColor = 'color-mix(in srgb, var(--top-tabs-active-bg, hsl(var(--background))) 40%, transparent)'; e.currentTarget.style.color = 'var(--top-tabs-fg, hsl(var(--foreground)))'; } }} onMouseLeave={(e) => { if (!isActive) { e.currentTarget.style.backgroundColor = 'transparent'; e.currentTarget.style.color = 'var(--top-tabs-muted, hsl(var(--muted-foreground)))'; } }} > {showDropIndicatorBefore && isDraggingForReorder && (
)} {showDropIndicatorAfter && isDraggingForReorder && (
)}
{shortcutNumber != null ? ( ) : ( )} {t('tabs.logPrefix')} {isLocal ? t('tabs.logLocal') : logView.log.hostname}
); }); LogViewTopTab.displayName = 'LogViewTopTab';