import { Check, Expand, FolderPlus, Minimize2, Plus, Search, Tag, Terminal, X, } from 'lucide-react'; import React, { useCallback, useEffect, useMemo, useRef } from 'react'; import { useI18n } from '../../application/i18n/I18nProvider'; import { useToolbarItemLayout } from '../../application/state/useToolbarItemLayout'; import type { ToolbarItemLayoutDefaults } from '../../domain/toolbarItemLayout'; import { STORAGE_KEY_TERMINAL_HOST_TREE_TOOLBAR_LAYOUT } from '../../infrastructure/config/storageKeys'; import { cn } from '../../lib/utils'; import { Button } from '../ui/button'; import { Input } from '../ui/input'; import { ToolbarCustomizeContextMenu, ToolbarOverflowMenu, } from '../ui/toolbar-item-layout'; import { Tooltip, TooltipContent, TooltipTrigger } from '../ui/tooltip'; export type HostTreeToolbarPanel = 'search' | 'tags' | null; export const HOST_TREE_TOOLBAR_ITEM_IDS = [ 'newHost', 'search', 'tags', 'localShell', 'newGroup', 'expandAll', 'collapseAll', ] as const; export type HostTreeToolbarItemId = (typeof HOST_TREE_TOOLBAR_ITEM_IDS)[number]; /** * Defaults match the previous fixed layout after #2625: * primary new host / search / tags / local shell; group + expand/collapse in ⋮. */ export const HOST_TREE_TOOLBAR_LAYOUT_DEFAULTS: ToolbarItemLayoutDefaults = { order: [...HOST_TREE_TOOLBAR_ITEM_IDS], placement: { newHost: 'show', search: 'show', tags: 'show', localShell: 'show', newGroup: 'collapse', expandAll: 'collapse', collapseAll: 'collapse', }, }; type ToolbarTheme = { termBg: string; termFg: string; mutedFg: string; separator: string; rowHoverBg: string; }; interface TerminalHostTreeToolbarProps { theme: ToolbarTheme; expandedPanel: HostTreeToolbarPanel; onExpandedPanelChange: (panel: HostTreeToolbarPanel) => void; search: string; onSearchChange: (value: string) => void; allTags: string[]; selectedTags: string[]; onSelectedTagsChange: (tags: string[]) => void; onNewHost: () => void; canNewHost?: boolean; onNewRootGroup: () => void; canNewGroup?: boolean; onCreateLocalTerminal: () => void; canCreateLocalTerminal?: boolean; onExpandAll: () => void; onCollapseAll: () => void; canExpandCollapse?: boolean; onCollapse: () => void; } const iconButtonClass = 'netcatty-tab h-6 w-6 shrink-0 rounded-md p-0 shadow-none border-none hover:bg-transparent'; /** Local shell uses the borderless Terminal glyph (not TerminalSquare). */ const localShellButtonClass = 'netcatty-tab h-6 w-6 shrink-0 rounded-none p-0 shadow-none border-none bg-transparent hover:bg-transparent'; const overflowMenuItemClass = 'flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left text-xs hover:bg-muted disabled:cursor-not-allowed disabled:opacity-50'; /** * Primary defaults (new host, search, tags, local shell, more) + close must fit * the sidebar min width. Users can hide/collapse items via right-click customize. */ export const TERMINAL_HOST_TREE_TOOLBAR_MIN_REQUIRED_WIDTH = 176; export const TerminalHostTreeToolbar: React.FC = ({ theme, expandedPanel, onExpandedPanelChange, search, onSearchChange, allTags, selectedTags, onSelectedTagsChange, onNewHost, canNewHost = true, onNewRootGroup, canNewGroup = true, onCreateLocalTerminal, canCreateLocalTerminal = true, onExpandAll, onCollapseAll, canExpandCollapse = true, onCollapse, }) => { const { t } = useI18n(); const searchInputRef = useRef(null); const toolbarLayout = useToolbarItemLayout( STORAGE_KEY_TERMINAL_HOST_TREE_TOOLBAR_LAYOUT, HOST_TREE_TOOLBAR_LAYOUT_DEFAULTS, ); const togglePanel = (panel: Exclude) => { onExpandedPanelChange(expandedPanel === panel ? null : panel); }; const hasTagFilters = selectedTags.length > 0; const hasSearch = search.trim().length > 0; useEffect(() => { if (expandedPanel !== 'search') return; const frame = requestAnimationFrame(() => { searchInputRef.current?.focus(); }); return () => cancelAnimationFrame(frame); }, [expandedPanel]); const toggleTag = (tag: string) => { if (selectedTags.includes(tag)) { onSelectedTagsChange(selectedTags.filter((item) => item !== tag)); } else { onSelectedTagsChange([...selectedTags, tag]); } }; const availableIds = useMemo(() => [...HOST_TREE_TOOLBAR_ITEM_IDS], []); const itemLabels = useMemo( (): Record => ({ newHost: t('terminal.layer.hostTree.newHost'), search: t('terminal.layer.hostTree.searchButton'), tags: t('terminal.layer.hostTree.tagsButton'), localShell: t('terminal.layer.hostTree.localShell'), newGroup: t('terminal.layer.hostTree.newGroup'), expandAll: t('vault.tree.expandAll'), collapseAll: t('vault.tree.collapseAll'), }), [t], ); const itemIcons = useMemo( (): Record => ({ newHost: , search: , tags: , localShell: , newGroup: , expandAll: , collapseAll: , }), [], ); const customizeItems = useMemo( () => toolbarLayout.layout.order .filter((id): id is HostTreeToolbarItemId => (availableIds as string[]).includes(id), ) .map((id) => ({ id, label: itemLabels[id], icon: itemIcons[id], })), [availableIds, itemIcons, itemLabels, toolbarLayout.layout.order], ); const setPlacement = useCallback( (id: string, placement: 'show' | 'collapse' | 'hide') => { toolbarLayout.setPlacement(id, placement, availableIds); }, [availableIds, toolbarLayout], ); const moveItem = useCallback( (id: string, direction: 'earlier' | 'later') => { toolbarLayout.move(id, direction, availableIds); }, [availableIds, toolbarLayout], ); const { shown, collapsed } = toolbarLayout.partition(availableIds); const renderInline = (id: string): React.ReactNode => { switch (id as HostTreeToolbarItemId) { case 'newHost': return ( {itemLabels.newHost} ); case 'search': return ( {itemLabels.search} ); case 'tags': return ( {itemLabels.tags} ); case 'localShell': return ( {itemLabels.localShell} ); case 'newGroup': return ( {itemLabels.newGroup} ); case 'expandAll': return ( {itemLabels.expandAll} ); case 'collapseAll': return ( {itemLabels.collapseAll} ); default: return null; } }; const renderCollapsed = (id: string): React.ReactNode => { switch (id as HostTreeToolbarItemId) { case 'newHost': return ( ); case 'search': return ( ); case 'tags': return ( ); case 'localShell': return ( ); case 'newGroup': return ( ); case 'expandAll': return ( ); case 'collapseAll': return ( ); default: return null; } }; const overflowNodes = collapsed.map(renderCollapsed).filter(Boolean); return (
toolbarLayout.layout.placement[id] ?? 'show'} onSetPlacement={setPlacement} onMove={moveItem} onReset={toolbarLayout.reset} t={t} className="relative flex min-w-0 flex-1 items-center overflow-hidden" dataSection="terminal-host-tree-toolbar-actions" >
{shown.map(renderInline)} 0} label={t('terminal.toolbar.more')} orientation="horizontal" buttonClassName={iconButtonClass} contentClassName="w-44" align="start" >
{overflowNodes}
{t('terminal.layer.hostTree.collapse')}
onSearchChange(event.target.value)} placeholder={t('terminal.layer.hostTree.search')} 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 }} />
{hasSearch && ( {t('common.clear')} )}
{allTags.length === 0 ? (
{t('terminal.layer.hostTree.tagsEmpty')}
) : ( <> {hasTagFilters && ( )} {allTags.map((tag) => { const isSelected = selectedTags.includes(tag); return ( ); })} )}
); };