/** * Terminal Toolbar * Displays high-frequency terminal actions and close button in the terminal status bar. * Dense actions support show / collapse / hide via right-click customize. */ import { Check, ChevronRight, Circle, Download, FileText, FolderInput, FolderSync, Highlighter, History, Languages, MoreVertical, Palette, Search, TextCursorInput, Upload, X, Zap, } from 'lucide-react'; import React, { useMemo, useState } from 'react'; import { useI18n } from '../../application/i18n/I18nProvider'; import { useToolbarItemLayout } from '../../application/state/useToolbarItemLayout'; import type { ToolbarItemLayoutDefaults } from '../../domain/toolbarItemLayout'; import { STORAGE_KEY_TERMINAL_TOOLBAR_LAYOUT } from '../../infrastructure/config/storageKeys'; import { Host, Snippet } from '../../types'; import { ScriptsSidePanel } from '../ScriptsSidePanel'; import { VaultDeleteConfirmDialog } from '../vault/VaultDeleteConfirmDialog'; import { Button } from '../ui/button'; import { Popover, PopoverClose, PopoverContent, PopoverTrigger } from '../ui/popover'; import { ToolbarCustomizeContextMenu } from '../ui/toolbar-item-layout'; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '../ui/tooltip'; import { cn } from '../../lib/utils'; import HostKeywordHighlightPopover from './HostKeywordHighlightPopover'; import { collectOwnedPluginMenus, comparePluginMenus, usePluginContributions } from '../../application/state/usePluginContributions'; import { buildTerminalPluginContributionContext } from '../../application/state/pluginContributionContexts'; import { PluginContributionIcon } from '../plugins/PluginContributionIcon'; import { isPluginHostProtocol } from '../../domain/pluginConnection'; export const TERMINAL_TOOLBAR_ITEM_IDS = [ 'highlight', 'sftp', 'ymodemSend', 'ymodemReceive', 'compose', 'search', 'sessionLog', 'scripts', 'history', 'configureOsc7', 'terminalSettings', 'recording', 'encoding', ] as const; export type TerminalToolbarItemId = (typeof TERMINAL_TOOLBAR_ITEM_IDS)[number]; /** Defaults mirror the previous fixed layout: primary actions shown, opener-style in ⋮. */ export const TERMINAL_TOOLBAR_LAYOUT_DEFAULTS: ToolbarItemLayoutDefaults = { order: [...TERMINAL_TOOLBAR_ITEM_IDS], placement: { highlight: 'show', sftp: 'show', ymodemSend: 'show', ymodemReceive: 'show', compose: 'show', search: 'show', sessionLog: 'show', scripts: 'show', history: 'collapse', configureOsc7: 'collapse', terminalSettings: 'collapse', recording: 'collapse', encoding: 'collapse', }, // Always-present action so reachability cannot rely on session-only ids. lockedIds: ['search'], }; export interface TerminalToolbarProps { sessionId: string; workspaceId?: string; status: 'connecting' | 'connected' | 'disconnected'; host?: Host; /** Popup/minimal mode: compose bar, search, and snippets only. */ compactToolbar?: boolean; snippets?: Snippet[]; snippetPackages?: string[]; onSnippetClick?: (snippet: Snippet) => void; /** Popup vault mutation path — compact toolbar has no AppSideEffects listener. */ onDeleteSnippets?: (ids: ReadonlySet) => void; onOpenSFTP: () => void; onSendYmodem?: () => void; onReceiveYmodem?: () => void; onOpenScripts: () => void; onOpenHistory?: () => void; onOpenTheme: () => void; onConfigureOsc7?: () => void; onUpdateHost?: (host: Host) => void; showClose?: boolean; onClose?: () => void; closeLabel?: string; // Search functionality isSearchOpen?: boolean; onToggleSearch?: () => void; // Manual session log showLogButton?: boolean; onToggleSessionLog?: () => void; isSessionLogging?: boolean; isSessionLogDisabled?: boolean; // Compose bar isComposeBarOpen?: boolean; onToggleComposeBar?: () => void; // Terminal encoding terminalEncoding?: 'utf-8' | 'gb18030'; onSetTerminalEncoding?: (encoding: 'utf-8' | 'gb18030') => void; recordingIndicator?: React.ReactNode; onStartRecording?: () => void; } export const TerminalToolbar: React.FC = ({ sessionId, workspaceId, status, host, compactToolbar = false, snippets = [], snippetPackages = [], onSnippetClick, onDeleteSnippets, onOpenSFTP, onSendYmodem, onReceiveYmodem, onOpenScripts, onOpenHistory, onOpenTheme, onConfigureOsc7, onUpdateHost, showClose, onClose, closeLabel, isSearchOpen, onToggleSearch, showLogButton = false, onToggleSessionLog, isSessionLogging = false, isSessionLogDisabled = false, isComposeBarOpen, onToggleComposeBar, terminalEncoding, onSetTerminalEncoding, recordingIndicator, onStartRecording, }) => { const { t } = useI18n(); const terminalContext = buildTerminalPluginContributionContext({ surface: 'terminal/toolbar', sessionId, status, hostId: host?.id, hostProtocol: host?.protocol ?? 'ssh', workspaceId, }); const statusBarContext = buildTerminalPluginContributionContext({ surface: 'statusBar', sessionId, status, hostId: host?.id, hostProtocol: host?.protocol ?? 'ssh', workspaceId, }); const pluginContributions = usePluginContributions({ context: terminalContext, menuContexts: { 'terminal/toolbar': terminalContext, statusBar: statusBarContext, }, }); const pluginToolbarMenus = collectOwnedPluginMenus(pluginContributions.snapshot.plugins) .filter((menu) => (menu.location === 'terminal/toolbar' || menu.location === 'statusBar') && menu.visible) .sort(comparePluginMenus); const [highlightPopoverOpen, setHighlightPopoverOpen] = useState(false); const [scriptsPopoverOpen, setScriptsPopoverOpen] = useState(false); // Owned outside the scripts Popover so portalled Dialog focus cannot dismiss // the menu and unmount ScriptsSidePanel before the user confirms. const [pendingScriptDeleteIds, setPendingScriptDeleteIds] = useState(null); const pendingScriptDeleteCount = (pendingScriptDeleteIds ?? []).filter((id) => snippets.some((snippet) => snippet.id === id), ).length; // Overflow popover + encoding submenu are both controlled so that // picking an encoding closes the whole chain, and so the parent popover // can ignore clicks that land in the submenu portal (otherwise the // submenu click would read as "outside" and dismiss the parent). const [overflowOpen, setOverflowOpen] = useState(false); const [encodingSubmenuOpen, setEncodingSubmenuOpen] = useState(false); const buttonBase = 'h-6 w-6 p-0 shadow-none border-none text-[color:var(--terminal-toolbar-fg)] bg-transparent hover:bg-transparent'; const isLocalTerminal = host?.protocol === 'local' || host?.id?.startsWith('local-'); const isSerialTerminal = host?.protocol === 'serial' || host?.id?.startsWith('serial-'); const isMoshSession = host?.protocol === 'mosh' || host?.moshEnabled; const isEtSession = host?.protocol === 'et' || host?.etEnabled; const isPluginConnection = isPluginHostProtocol(host?.protocol); // Local PTY inherits the OS locale and mosh/ET always use their own framing, // so the quick-switch menu only makes sense for sessions whose // backend decoder we actually control (SSH, telnet, serial). Hostname // isn't part of the gate — telnet/SSH targets pointed at localhost // (test daemons, forwarded endpoints) still have a real backend // decoder we can drive. const encodingSwitchSupported = !isLocalTerminal && !isMoshSession && !isEtSession && !isPluginConnection; const hidesSftp = isLocalTerminal || isSerialTerminal || isPluginConnection; const historySupported = !!onOpenHistory && !isLocalTerminal && !isSerialTerminal && !isPluginConnection && host?.protocol !== 'telnet'; const unavailableYmodemSendLabel = `${t('terminal.toolbar.sendYmodem')} - ${t('terminal.toolbar.availableAfterConnect')}`; const unavailableYmodemReceiveLabel = `${t('terminal.toolbar.receiveYmodem')} - ${t('terminal.toolbar.availableAfterConnect')}`; const menuItemClass = 'w-full flex items-center gap-2 px-2 py-1.5 text-xs rounded-sm hover:bg-secondary transition-colors'; const activeButtonStyle: React.CSSProperties = { backgroundColor: 'var(--terminal-toolbar-btn-active)', }; const toolbarLayout = useToolbarItemLayout( STORAGE_KEY_TERMINAL_TOOLBAR_LAYOUT, TERMINAL_TOOLBAR_LAYOUT_DEFAULTS, ); const availableIds = useMemo(() => { const ids: TerminalToolbarItemId[] = [ 'highlight', 'compose', 'search', 'scripts', 'terminalSettings', ]; if (!hidesSftp) ids.push('sftp'); if (isSerialTerminal) { ids.push('ymodemSend', 'ymodemReceive'); } if (showLogButton) ids.push('sessionLog'); if (historySupported) ids.push('history'); if (onConfigureOsc7 && !hidesSftp) ids.push('configureOsc7'); if (onStartRecording) ids.push('recording'); if (encodingSwitchSupported && onSetTerminalEncoding) ids.push('encoding'); return ids; }, [ encodingSwitchSupported, hidesSftp, historySupported, isSerialTerminal, onConfigureOsc7, onSetTerminalEncoding, onStartRecording, showLogButton, ]); const itemLabels = useMemo( (): Record => ({ highlight: t('terminal.toolbar.hostHighlight.title'), sftp: t('terminal.toolbar.openSftp'), ymodemSend: t('terminal.toolbar.sendYmodem'), ymodemReceive: t('terminal.toolbar.receiveYmodem'), compose: t('terminal.toolbar.composeBar'), search: t('terminal.toolbar.searchTerminal'), sessionLog: isSessionLogging ? t('terminal.toolbar.stopSessionLog') : t('terminal.toolbar.startSessionLog'), scripts: t('terminal.toolbar.scripts'), history: t('terminal.toolbar.history'), configureOsc7: t('terminal.toolbar.configureOsc7'), terminalSettings: t('terminal.toolbar.terminalSettings'), recording: t('scripts.recording.start'), encoding: t('terminal.toolbar.encoding'), }), [isSessionLogging, t], ); const itemIcons = useMemo( (): Record => ({ highlight: , sftp: , ymodemSend: , ymodemReceive: , compose: , search: , sessionLog: , scripts: , history: , configureOsc7: , terminalSettings: , recording: , encoding: , }), [], ); const customizeItems = useMemo( () => toolbarLayout.layout.order .filter((id): id is TerminalToolbarItemId => (availableIds as string[]).includes(id), ) .map((id) => ({ id, label: itemLabels[id], icon: itemIcons[id], locked: id === 'search', // Host-highlight popover is inline-only; collapse would be a silent hide. supportsCollapse: id !== 'highlight', })), [availableIds, itemIcons, itemLabels, toolbarLayout.layout.order], ); const { shown, collapsed } = useMemo(() => { const available = new Set(availableIds); const shownIds: string[] = []; const collapsedIds: string[] = []; for (const id of toolbarLayout.layout.order) { if (!available.has(id)) continue; let placement = toolbarLayout.layout.placement[id] ?? 'show'; // highlight is show/hide only — never leave it stranded as collapsed-but-null. if (id === 'highlight' && placement === 'collapse') placement = 'show'; if (placement === 'show') shownIds.push(id); else if (placement === 'collapse') collapsedIds.push(id); } return { shown: shownIds, collapsed: collapsedIds }; }, [availableIds, toolbarLayout.layout]); if (compactToolbar) { return (
{t('terminal.toolbar.composeBar')} {t('terminal.toolbar.searchTerminal')} {t('terminal.toolbar.scripts')} { onSnippetClick?.(snippet); setScriptsPopoverOpen(false); }} /> { if (!open) setPendingScriptDeleteIds(null); }} onConfirm={() => { const ids = (pendingScriptDeleteIds ?? []).filter((id) => snippets.some((snippet) => snippet.id === id), ); setPendingScriptDeleteIds(null); if (ids.length === 0) return; // Popup terminals own vault state locally (no AppSideEffects). onDeleteSnippets?.(new Set(ids)); // Still emit so ScriptsSidePanel can clear multi-select in the popover. window.dispatchEvent( new CustomEvent('netcatty:snippets:delete', { detail: { ids } }), ); }} />
); } const renderEncodingSubmenu = () => { if (!encodingSwitchSupported || !onSetTerminalEncoding) return null; return ( {(['utf-8', 'gb18030'] as const).map((enc) => { const isActive = terminalEncoding === enc; return ( ); })} ); }; const renderInline = (id: string): React.ReactNode => { switch (id as TerminalToolbarItemId) { case 'highlight': return ( ); case 'sftp': return ( {status === 'connected' ? t('terminal.toolbar.openSftp') : t('terminal.toolbar.availableAfterConnect')} ); case 'ymodemSend': return ( {status === 'connected' ? t('terminal.toolbar.sendYmodem') : t('terminal.toolbar.availableAfterConnect')} ); case 'ymodemReceive': return ( {status === 'connected' ? t('terminal.toolbar.receiveYmodem') : t('terminal.toolbar.availableAfterConnect')} ); case 'compose': return ( {t('terminal.toolbar.composeBar')} ); case 'search': return ( {t('terminal.toolbar.searchTerminal')} ); case 'sessionLog': return ( {isSessionLogging ? t('terminal.toolbar.stopSessionLog') : t('terminal.toolbar.startSessionLog')} ); case 'scripts': return ( {t('terminal.toolbar.scripts')} ); case 'history': return ( {status === 'connected' ? t('terminal.toolbar.history') : t('terminal.toolbar.availableAfterConnect')} ); case 'configureOsc7': return ( {status === 'connected' ? t('terminal.toolbar.configureOsc7') : t('terminal.toolbar.availableAfterConnect')} ); case 'terminalSettings': return ( {t('terminal.toolbar.terminalSettings')} ); case 'recording': if (status !== 'connected' || recordingIndicator) return null; return ( {t('scripts.recording.start')} ); case 'encoding': return ( {t('terminal.toolbar.encoding')} {(['utf-8', 'gb18030'] as const).map((enc) => { const isActive = terminalEncoding === enc; return ( ); })} ); default: return null; } }; const renderCollapsed = (id: string): React.ReactNode => { switch (id as TerminalToolbarItemId) { case 'highlight': return null; // popover-heavy; only useful inline case 'sftp': return ( ); case 'ymodemSend': return ( ); case 'ymodemReceive': return ( ); case 'compose': return ( ); case 'search': return ( ); case 'sessionLog': return ( ); case 'scripts': return ( ); case 'history': return ( ); case 'configureOsc7': return ( ); case 'terminalSettings': return ( ); case 'recording': if (status !== 'connected' || recordingIndicator || !onStartRecording) return null; return ( ); case 'encoding': return {renderEncodingSubmenu()}; default: return null; } }; const collapsedNodes = collapsed.map(renderCollapsed).filter(Boolean); const hasCollapsed = collapsedNodes.length > 0; return (
{ const placement = toolbarLayout.layout.placement[id] ?? 'show'; // Highlight cannot collapse; treat stored collapse as show for display. if (id === 'highlight' && placement === 'collapse') return 'show'; return placement; }} onSetPlacement={(id, placement) => toolbarLayout.setPlacement(id, placement, availableIds)} onMove={(id, direction) => toolbarLayout.move(id, direction, availableIds)} onReset={toolbarLayout.reset} t={t} className="inline-flex items-center min-h-6 min-w-6" > {shown.map(renderInline)} {/* ⋮ opens collapsed actions only */} {hasCollapsed && ( { setOverflowOpen(open); if (!open) setEncodingSubmenuOpen(false); }} > {t('terminal.toolbar.more')} { // Radix treats the submenu's portalled content as // "outside" this popover; without this guard a click // in the submenu would dismiss the parent. const target = e.target as Element | null; if (target?.closest('[data-encoding-submenu="true"]')) { e.preventDefault(); } }} > {collapsedNodes} )} {pluginToolbarMenus.map((menu) => ( {menu.title}{menu.shortcut ? ` (${menu.shortcut})` : ''} ))} {recordingIndicator} {showClose && onClose && ( {closeLabel ?? t('terminal.toolbar.closeSession')} )}
); }; export default TerminalToolbar;