[Init] Initial commit - NetMesh terminal manager
Some checks failed
build-packages / resolve bundled mosh-client (push) Has been cancelled
build-packages / resolve bundled et-client (push) Has been cancelled
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
build-packages / update Nix release metadata (push) Has been cancelled
build-packages / bump homebrew tap (push) Has been cancelled
test / lint-and-test (push) Has been cancelled
AI automation / Route event (push) Has been cancelled
AI automation / Hand reopened issue to maintainers (push) Has been cancelled
AI automation / Clean source issue state (push) Has been cancelled
AI automation / Reconcile handoffs (push) Has been cancelled
AI automation / Classify issue (push) Has been cancelled
AI automation / Claude Code smoke (push) Has been cancelled
AI automation / Review issue follow-up (push) Has been cancelled
AI automation / Publish issue follow-up (push) Has been cancelled
AI automation / Implement with Claude Code (push) Has been cancelled
AI automation / Publish implement PR (push) Has been cancelled
AI automation / Continue queued issue comments (push) Has been cancelled
AI automation / Codex review loop (push) Has been cancelled
AI automation / Publish Codex fix (push) Has been cancelled
AI automation / Clear Codex dispatch marker (push) Has been cancelled
AI automation / Own PR re-request Codex (push) Has been cancelled
AI automation / External PR re-request Codex (push) Has been cancelled
AI automation / Poll Codex reaction / retry (push) Has been cancelled
build-et-binaries / build-linux-x64 (push) Has been cancelled
build-et-binaries / build-linux-arm64 (push) Has been cancelled
build-et-binaries / build-macos-universal (push) Has been cancelled
build-et-binaries / build-windows-x64 (push) Has been cancelled
build-et-binaries / release (push) Has been cancelled

This commit is contained in:
2026-09-13 18:24:01 +08:00
commit 3c72efcb7f
3255 changed files with 907009 additions and 0 deletions

View File

@@ -0,0 +1,620 @@
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<TerminalHostTreeToolbarProps> = ({
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<HTMLInputElement>(null);
const toolbarLayout = useToolbarItemLayout(
STORAGE_KEY_TERMINAL_HOST_TREE_TOOLBAR_LAYOUT,
HOST_TREE_TOOLBAR_LAYOUT_DEFAULTS,
);
const togglePanel = (panel: Exclude<HostTreeToolbarPanel, null>) => {
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<HostTreeToolbarItemId, string> => ({
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<HostTreeToolbarItemId, React.ReactNode> => ({
newHost: <Plus size={14} />,
search: <Search size={14} />,
tags: <Tag size={14} />,
localShell: <Terminal size={14} />,
newGroup: <FolderPlus size={14} />,
expandAll: <Expand size={14} />,
collapseAll: <Minimize2 size={14} />,
}),
[],
);
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 (
<Tooltip key={id}>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className={iconButtonClass}
style={{ color: theme.mutedFg }}
disabled={!canNewHost}
onClick={onNewHost}
aria-label={itemLabels.newHost}
>
<Plus size={14} />
</Button>
</TooltipTrigger>
<TooltipContent side="bottom">{itemLabels.newHost}</TooltipContent>
</Tooltip>
);
case 'search':
return (
<Tooltip key={id}>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className={iconButtonClass}
style={{
color: expandedPanel === 'search' || hasSearch ? theme.termFg : theme.mutedFg,
}}
onClick={() => togglePanel('search')}
aria-label={itemLabels.search}
>
<Search size={14} />
</Button>
</TooltipTrigger>
<TooltipContent side="bottom">{itemLabels.search}</TooltipContent>
</Tooltip>
);
case 'tags':
return (
<Tooltip key={id}>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className={iconButtonClass}
style={{
color: expandedPanel === 'tags' || hasTagFilters ? theme.termFg : theme.mutedFg,
}}
onClick={() => togglePanel('tags')}
aria-label={itemLabels.tags}
>
<Tag size={14} />
</Button>
</TooltipTrigger>
<TooltipContent side="bottom">{itemLabels.tags}</TooltipContent>
</Tooltip>
);
case 'localShell':
return (
<Tooltip key={id}>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className={localShellButtonClass}
style={{ color: theme.mutedFg }}
disabled={!canCreateLocalTerminal}
onClick={onCreateLocalTerminal}
aria-label={itemLabels.localShell}
data-section="terminal-host-tree-local-shell"
>
<Terminal size={14} />
</Button>
</TooltipTrigger>
<TooltipContent side="bottom">{itemLabels.localShell}</TooltipContent>
</Tooltip>
);
case 'newGroup':
return (
<Tooltip key={id}>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className={iconButtonClass}
style={{ color: theme.mutedFg }}
disabled={!canNewGroup}
onClick={onNewRootGroup}
aria-label={itemLabels.newGroup}
>
<FolderPlus size={14} />
</Button>
</TooltipTrigger>
<TooltipContent side="bottom">{itemLabels.newGroup}</TooltipContent>
</Tooltip>
);
case 'expandAll':
return (
<Tooltip key={id}>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className={iconButtonClass}
style={{ color: theme.mutedFg }}
disabled={!canExpandCollapse}
onClick={onExpandAll}
aria-label={itemLabels.expandAll}
>
<Expand size={14} />
</Button>
</TooltipTrigger>
<TooltipContent side="bottom">{itemLabels.expandAll}</TooltipContent>
</Tooltip>
);
case 'collapseAll':
return (
<Tooltip key={id}>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className={iconButtonClass}
style={{ color: theme.mutedFg }}
disabled={!canExpandCollapse}
onClick={onCollapseAll}
aria-label={itemLabels.collapseAll}
>
<Minimize2 size={14} />
</Button>
</TooltipTrigger>
<TooltipContent side="bottom">{itemLabels.collapseAll}</TooltipContent>
</Tooltip>
);
default:
return null;
}
};
const renderCollapsed = (id: string): React.ReactNode => {
switch (id as HostTreeToolbarItemId) {
case 'newHost':
return (
<button
key={id}
type="button"
className={overflowMenuItemClass}
disabled={!canNewHost}
onClick={onNewHost}
>
<Plus size={14} />
{itemLabels.newHost}
</button>
);
case 'search':
return (
<button
key={id}
type="button"
className={overflowMenuItemClass}
onClick={() => togglePanel('search')}
>
<Search size={14} />
{itemLabels.search}
</button>
);
case 'tags':
return (
<button
key={id}
type="button"
className={overflowMenuItemClass}
onClick={() => togglePanel('tags')}
>
<Tag size={14} />
{itemLabels.tags}
</button>
);
case 'localShell':
return (
<button
key={id}
type="button"
className={overflowMenuItemClass}
disabled={!canCreateLocalTerminal}
onClick={onCreateLocalTerminal}
>
<Terminal size={14} />
{itemLabels.localShell}
</button>
);
case 'newGroup':
return (
<button
key={id}
type="button"
className={overflowMenuItemClass}
disabled={!canNewGroup}
onClick={onNewRootGroup}
>
<FolderPlus size={14} />
{itemLabels.newGroup}
</button>
);
case 'expandAll':
return (
<button
key={id}
type="button"
className={overflowMenuItemClass}
disabled={!canExpandCollapse}
onClick={onExpandAll}
>
<Expand size={14} />
{itemLabels.expandAll}
</button>
);
case 'collapseAll':
return (
<button
key={id}
type="button"
className={overflowMenuItemClass}
disabled={!canExpandCollapse}
onClick={onCollapseAll}
>
<Minimize2 size={14} />
{itemLabels.collapseAll}
</button>
);
default:
return null;
}
};
const overflowNodes = collapsed.map(renderCollapsed).filter(Boolean);
return (
<div className="flex-shrink-0">
<div
className="flex h-9 shrink-0 min-w-0 items-center gap-0.5 px-1.5 py-1"
style={{
backgroundColor: theme.termBg,
borderBottom: `1px solid ${theme.separator}`,
}}
data-section="terminal-host-tree-toolbar"
>
<ToolbarCustomizeContextMenu
items={customizeItems}
placementOf={(id) => 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"
>
<div className="flex items-center gap-1" style={{ color: theme.mutedFg }}>
{shown.map(renderInline)}
<ToolbarOverflowMenu
hasItems={overflowNodes.length > 0}
label={t('terminal.toolbar.more')}
orientation="horizontal"
buttonClassName={iconButtonClass}
contentClassName="w-44"
align="start"
>
<div className="flex flex-col">{overflowNodes}</div>
</ToolbarOverflowMenu>
</div>
</ToolbarCustomizeContextMenu>
<div
className="flex shrink-0 items-center"
data-section="terminal-host-tree-toolbar-close"
>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className={cn(iconButtonClass, 'mr-0.5')}
style={{ color: theme.mutedFg }}
onClick={onCollapse}
aria-label={t('terminal.layer.hostTree.collapse')}
>
<X size={15} />
</Button>
</TooltipTrigger>
<TooltipContent side="bottom">{t('terminal.layer.hostTree.collapse')}</TooltipContent>
</Tooltip>
</div>
</div>
<div
className={cn(
'overflow-hidden transition-[max-height,opacity] duration-200 ease-out',
expandedPanel === 'search' ? 'max-h-9 opacity-100' : 'max-h-0 opacity-0',
)}
style={{
backgroundColor: theme.termBg,
borderBottom: expandedPanel === 'search' ? `1px solid ${theme.separator}` : undefined,
}}
>
<div className="h-9 flex items-center gap-0.5 px-1.5" style={{ backgroundColor: theme.termBg }}>
<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
ref={searchInputRef}
value={search}
onChange={(event) => 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 }}
/>
</div>
{hasSearch && (
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className={iconButtonClass}
style={{ color: theme.mutedFg }}
onClick={() => {
onSearchChange('');
searchInputRef.current?.focus();
}}
aria-label={t('common.clear')}
>
<X size={14} />
</Button>
</TooltipTrigger>
<TooltipContent side="bottom">{t('common.clear')}</TooltipContent>
</Tooltip>
)}
</div>
</div>
<div
className={cn(
'overflow-hidden transition-[max-height,opacity] duration-200 ease-out',
expandedPanel === 'tags' ? 'max-h-40 opacity-100' : 'max-h-0 opacity-0',
)}
style={{
backgroundColor: theme.termBg,
borderBottom: expandedPanel === 'tags' ? `1px solid ${theme.separator}` : undefined,
}}
>
<div
className="max-h-40 overflow-y-auto overflow-x-hidden py-1 [scrollbar-width:none] [-ms-overflow-style:none] [&::-webkit-scrollbar]:hidden"
style={{ backgroundColor: theme.termBg }}
>
{allTags.length === 0 ? (
<div className="px-3 py-3 text-center text-xs" style={{ color: theme.mutedFg }}>
{t('terminal.layer.hostTree.tagsEmpty')}
</div>
) : (
<>
{hasTagFilters && (
<button
type="button"
className="w-full px-3 py-1.5 text-left text-xs"
style={{ color: theme.mutedFg }}
onClick={() => onSelectedTagsChange([])}
>
{t('terminal.layer.hostTree.clearTags')}
</button>
)}
{allTags.map((tag) => {
const isSelected = selectedTags.includes(tag);
return (
<button
key={tag}
type="button"
className="flex w-full min-w-0 items-center gap-2 px-3 py-1.5 text-left text-xs"
style={{ color: theme.termFg }}
onMouseEnter={(event) => {
event.currentTarget.style.backgroundColor = theme.rowHoverBg;
}}
onMouseLeave={(event) => {
event.currentTarget.style.backgroundColor = '';
}}
onClick={() => toggleTag(tag)}
>
<span
className={cn(
'h-2.5 w-2.5 shrink-0 rounded-full border',
isSelected ? 'bg-current border-current' : 'border-current opacity-50',
)}
style={{ color: theme.termFg }}
/>
<span className="min-w-0 flex-1 truncate">{tag}</span>
{isSelected && <Check size={12} className="shrink-0" />}
</button>
);
})}
</>
)}
</div>
</div>
</div>
);
};