[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,284 @@
import { FolderTree, Globe, Link2, Play, Trash2 } from 'lucide-react';
import React, { useCallback, useMemo, useState, type DragEvent } from 'react';
import type { Host, Snippet } from '@/domain/models';
import {
appendHostConnectScript,
getEditableHostConnectScriptIds,
getGlobalConnectScripts,
getGroupConnectScriptsForHost,
removeHostConnectScript,
reorderHostConnectScript,
} from '@/domain/hostConnectScripts.ts';
import { isScriptSnippet } from '@/domain/snippetScript.ts';
import { cn } from '@/lib/utils';
import { getVaultDropPosition } from '@/components/vault/vaultReorderDrag';
import {
VaultEntityIcon,
vaultAutomationScriptIconClass,
vaultEntityIconSmClass,
} from '@/components/vault/VaultEntityIcon';
import { HostDetailsSection } from '../host-details';
import { Button } from '../ui/button';
import { Combobox } from '../ui/combobox';
const CONNECT_QUEUE_DRAG_TYPE = 'application/x-netcatty-connect-script-id';
export interface HostDetailsScriptsSectionProps {
host: Host;
onHostChange: (host: Host) => void;
snippets: Snippet[];
t: (key: string, params?: Record<string, unknown>) => string;
}
function triggerLabel(
snippet: Snippet,
t: HostDetailsScriptsSectionProps['t'],
): string {
if (snippet.trigger === 'onConnect') return t('scripts.trigger.onConnect');
if (snippet.trigger === 'onOutput') return t('scripts.trigger.onOutput');
return t('scripts.trigger.manual');
}
function scriptById(snippets: Snippet[], scriptId: string): Snippet | undefined {
return snippets.find((snippet) => snippet.id === scriptId && isScriptSnippet(snippet));
}
export const HostDetailsScriptsSection: React.FC<HostDetailsScriptsSectionProps> = ({
host,
onHostChange,
snippets,
t,
}) => {
const [draggingScriptId, setDraggingScriptId] = useState<string | null>(null);
const [dropIndicator, setDropIndicator] = useState<{ scriptId: string; position: 'before' | 'after' } | null>(null);
const scripts = useMemo(
() => snippets.filter(isScriptSnippet),
[snippets],
);
const globalScripts = useMemo(
() => getGlobalConnectScripts(snippets),
[snippets],
);
const queueIds = useMemo(
() => getEditableHostConnectScriptIds(host, snippets),
[host, snippets],
);
const groupScripts = useMemo(
() => getGroupConnectScriptsForHost(host, snippets).filter(
(script) => !script.id || !queueIds.includes(script.id),
),
[host, queueIds, snippets],
);
const groupScriptIds = useMemo(
() => new Set(groupScripts.map((script) => script.id)),
[groupScripts],
);
const queuedScripts = useMemo(
() => queueIds
.map((id) => scriptById(snippets, id))
.filter((snippet): snippet is Snippet => Boolean(snippet)),
[queueIds, snippets],
);
const linkableScripts = useMemo(
() => scripts.filter((script) => {
if (!script.id) return false;
if (script.targetsAllHosts) return false;
if (groupScriptIds.has(script.id)) return false;
if (queueIds.includes(script.id)) return false;
return true;
}),
[groupScriptIds, queueIds, scripts],
);
const linkOptions = useMemo(
() => linkableScripts.map((script) => ({
value: script.id!,
label: script.label || t('scripts.running.unnamed'),
})),
[linkableScripts, t],
);
const clearDragState = useCallback(() => {
setDraggingScriptId(null);
setDropIndicator(null);
}, []);
if (scripts.length === 0) return null;
const handleAddToQueue = (scriptId: string) => {
if (!scriptId) return;
const snippet = scriptById(snippets, scriptId);
if (!snippet) return;
onHostChange(appendHostConnectScript(host, scriptId, snippets));
};
const handleRemoveFromQueue = (scriptId: string) => {
onHostChange(removeHostConnectScript(host, scriptId, snippets));
};
const handleDragStart = (event: DragEvent<HTMLDivElement>, scriptId: string) => {
event.dataTransfer.effectAllowed = 'move';
event.dataTransfer.setData(CONNECT_QUEUE_DRAG_TYPE, scriptId);
setDraggingScriptId(scriptId);
};
const handleDragOver = (event: DragEvent<HTMLDivElement>, scriptId: string) => {
if (!event.dataTransfer.types.includes(CONNECT_QUEUE_DRAG_TYPE)) return;
event.preventDefault();
event.dataTransfer.dropEffect = 'move';
const position = getVaultDropPosition(event.currentTarget, event.clientX, event.clientY, false);
setDropIndicator((prev) => (
prev?.scriptId === scriptId && prev.position === position
? prev
: { scriptId, position }
));
};
const handleDrop = (event: DragEvent<HTMLDivElement>, targetScriptId: string) => {
event.preventDefault();
const draggedScriptId = event.dataTransfer.getData(CONNECT_QUEUE_DRAG_TYPE);
if (!draggedScriptId || draggedScriptId === targetScriptId) {
clearDragState();
return;
}
const position = getVaultDropPosition(event.currentTarget, event.clientX, event.clientY, false);
onHostChange(reorderHostConnectScript(host, draggedScriptId, targetScriptId, position, snippets));
clearDragState();
};
return (
<HostDetailsSection
icon={<Play size={14} className="text-muted-foreground" />}
title={t('hostDetails.section.automation')}
hint={t('hostDetails.automation.queueHint')}
>
<div className="space-y-4">
{globalScripts.length > 0 ? (
<div className="space-y-2">
<div className="flex items-center gap-1.5">
<Globe size={12} className="text-muted-foreground shrink-0" />
<label className="text-xs text-muted-foreground">{t('hostDetails.automation.globalScripts')}</label>
</div>
<p className="text-[10px] text-muted-foreground leading-relaxed">
{t('hostDetails.automation.globalScriptsHint')}
</p>
<div className="space-y-1.5">
{globalScripts.map((script) => (
<div
key={script.id}
className="flex items-center gap-2.5 px-2.5 py-2 rounded-md border border-border/50 bg-muted/20"
>
<VaultEntityIcon
className={cn(vaultEntityIconSmClass, vaultAutomationScriptIconClass)}
icon={<Play size={14} />}
/>
<div className="min-w-0 flex-1">
<div className="text-sm font-medium truncate">{script.label || t('scripts.running.unnamed')}</div>
<div className="text-[10px] text-muted-foreground truncate">{triggerLabel(script, t)}</div>
</div>
</div>
))}
</div>
</div>
) : null}
{groupScripts.length > 0 ? (
<div className="space-y-2">
<div className="flex items-center gap-1.5">
<FolderTree size={12} className="text-muted-foreground shrink-0" />
<label className="text-xs text-muted-foreground">
{t('hostDetails.automation.groupScripts')}
</label>
</div>
<p className="text-[10px] text-muted-foreground leading-relaxed">
{t('hostDetails.automation.groupScriptsHint')}
</p>
<div className="space-y-1.5">
{groupScripts.map((script) => (
<div
key={script.id}
className="flex items-center gap-2.5 px-2.5 py-2 rounded-md border border-border/50 bg-primary/5"
>
<VaultEntityIcon
className={cn(vaultEntityIconSmClass, vaultAutomationScriptIconClass)}
icon={<Play size={14} />}
/>
<div className="min-w-0 flex-1">
<div className="text-sm font-medium truncate">
{script.label || t('scripts.running.unnamed')}
</div>
<div className="text-[10px] text-muted-foreground truncate">
{script.targetGroups?.join(', ')}
</div>
</div>
</div>
))}
</div>
</div>
) : null}
<div className="space-y-2">
{queuedScripts.length === 0 ? (
<p className="text-[11px] text-muted-foreground">{t('hostDetails.automation.connectQueueEmpty')}</p>
) : (
<div className="space-y-1.5" onDragEnd={clearDragState}>
{queuedScripts.map((script) => {
const scriptId = script.id!;
const isDragging = draggingScriptId === scriptId;
const indicator = dropIndicator?.scriptId === scriptId ? dropIndicator.position : null;
return (
<div
key={scriptId}
draggable
data-vault-drop-position={indicator ?? undefined}
data-vault-drop-axis="y"
className={cn(
'vault-drop-indicator-row relative flex items-center gap-2.5 px-2.5 py-2 rounded-md border border-border/70 bg-background/60 transition-opacity cursor-grab active:cursor-grabbing',
isDragging && 'opacity-40',
)}
aria-label={t('hostDetails.automation.dragHandle')}
onDragStart={(event) => handleDragStart(event, scriptId)}
onDragOver={(event) => handleDragOver(event, scriptId)}
onDrop={(event) => handleDrop(event, scriptId)}
>
<VaultEntityIcon
className={cn(vaultEntityIconSmClass, vaultAutomationScriptIconClass)}
icon={<Play size={14} />}
/>
<div className="min-w-0 flex-1 select-none">
<div className="text-sm font-medium truncate">{script.label || t('scripts.running.unnamed')}</div>
<div className="text-[10px] text-muted-foreground truncate">{triggerLabel(script, t)}</div>
</div>
<Button
type="button"
variant="ghost"
size="icon"
className="h-7 w-7 shrink-0 text-muted-foreground hover:text-destructive"
onMouseDown={(event) => event.stopPropagation()}
onClick={() => handleRemoveFromQueue(scriptId)}
aria-label={t('hostDetails.automation.removeFromQueue')}
>
<Trash2 size={13} />
</Button>
</div>
);
})}
</div>
)}
{linkOptions.length > 0 ? (
<Combobox
options={linkOptions}
value=""
onValueChange={handleAddToQueue}
placeholder={t('hostDetails.automation.addToQueuePlaceholder')}
icon={<Link2 size={14} />}
triggerClassName="h-9"
/>
) : null}
</div>
</div>
</HostDetailsSection>
);
};

View File

@@ -0,0 +1,93 @@
import { FileText } from "lucide-react";
import React, { useEffect, useState } from "react";
import { useI18n } from "../../application/i18n/I18nProvider";
import { LazyMessageResponse } from "../ai-elements/LazyMessageResponse";
import { ScrollArea } from "../ui/scroll-area";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "../ui/tabs";
import { Textarea } from "../ui/textarea";
import { cn } from "../../lib/utils";
const PREVIEW_PROSE_CLASS =
"text-sm text-foreground/90 [&>*:first-child]:mt-0 [&>*:last-child]:mb-0";
function defaultNotesTab(notes: string, preferredTab?: "edit" | "preview"): "edit" | "preview" {
if (preferredTab) return preferredTab;
return notes.trim() ? "preview" : "edit";
}
export interface HostNotesEditorProps {
value: string;
onChange: (value: string) => void;
/** Changes when opening a different host (e.g. host id) to reset the active tab */
panelKey?: string;
className?: string;
showHeader?: boolean;
defaultTab?: "edit" | "preview";
}
export const HostNotesEditor: React.FC<HostNotesEditorProps> = ({
value,
onChange,
panelKey,
className,
showHeader = true,
defaultTab,
}) => {
const { t } = useI18n();
const [tab, setTab] = useState<"edit" | "preview">(() => defaultNotesTab(value, defaultTab));
useEffect(() => {
if (panelKey === undefined) return;
setTab(defaultNotesTab(value, defaultTab));
// Only reset tab when opening another host, not while editing notes.
// eslint-disable-next-line react-hooks/exhaustive-deps -- value read on panelKey change
}, [panelKey, defaultTab]);
const trimmed = value.trim();
return (
<div className={cn("space-y-2", className)}>
{showHeader && (
<>
<div className="flex items-center gap-2">
<FileText size={14} className="text-muted-foreground shrink-0" />
<p className="text-xs font-semibold">
{t("hostDetails.notes.label")}
</p>
</div>
<p className="text-xs text-muted-foreground">{t("hostDetails.notes.help")}</p>
</>
)}
<Tabs value={tab} onValueChange={(v) => setTab(v as "edit" | "preview")}>
<TabsList className="h-8 w-full">
<TabsTrigger value="edit" className="flex-1 text-xs">
{t("hostDetails.notes.tab.edit")}
</TabsTrigger>
<TabsTrigger value="preview" className="flex-1 text-xs">
{t("hostDetails.notes.tab.preview")}
</TabsTrigger>
</TabsList>
<TabsContent value="edit" className="mt-2">
<Textarea
placeholder={t("hostDetails.notes.placeholder")}
value={value}
onChange={(e) => onChange(e.target.value)}
className="min-h-[120px] text-sm"
rows={5}
/>
</TabsContent>
<TabsContent value="preview" className="mt-2">
<ScrollArea className="h-[120px] rounded-md border border-border/60 bg-muted/20 p-3">
{trimmed ? (
<LazyMessageResponse className={PREVIEW_PROSE_CLASS}>{trimmed}</LazyMessageResponse>
) : (
<p className="text-sm text-muted-foreground">
{t("hostDetails.notes.preview.empty")}
</p>
)}
</ScrollArea>
</TabsContent>
</Tabs>
</div>
);
};

View File

@@ -0,0 +1,50 @@
import { FileText } from "lucide-react";
import React from "react";
import { cn } from "../../lib/utils";
import { LazyMessageResponse } from "../ai-elements/LazyMessageResponse";
import { HoverCard, HoverCardContent, HoverCardTrigger } from "../ui/hover-card";
export interface HostNotesIndicatorProps {
notes?: string;
className?: string;
}
export const HostNotesIndicator: React.FC<HostNotesIndicatorProps> = ({
notes,
className,
}) => {
const trimmed = notes?.trim();
if (!trimmed) return null;
return (
<HoverCard openDelay={180} closeDelay={80}>
<HoverCardTrigger asChild>
<button
type="button"
className={cn(
"inline-flex h-4 w-4 shrink-0 items-center justify-center rounded border-0 bg-transparent p-0 text-muted-foreground transition-colors hover:text-foreground",
className,
)}
aria-label="Host notes"
onClick={(e) => e.stopPropagation()}
onPointerDown={(e) => e.stopPropagation()}
>
<FileText size={12} className="text-muted-foreground" aria-hidden />
</button>
</HoverCardTrigger>
<HoverCardContent
side="top"
align="start"
className="w-[320px] max-w-[calc(100vw-32px)] p-3"
onClick={(e) => e.stopPropagation()}
onPointerDown={(e) => e.stopPropagation()}
>
<div className="max-h-[240px] overflow-y-auto pr-1">
<LazyMessageResponse className="text-xs leading-relaxed text-popover-foreground/90 [&_h1]:text-sm [&_h1]:mt-2 [&_h1]:mb-1 [&_h2]:text-sm [&_h2]:mt-2 [&_h2]:mb-1 [&_h3]:text-xs [&_h3]:mt-1.5 [&_h3]:mb-1 [&_p]:my-1 [&_ul]:my-1 [&_ol]:my-1">
{trimmed}
</LazyMessageResponse>
</div>
</HoverCardContent>
</HoverCard>
);
};

View File

@@ -0,0 +1,18 @@
import test from "node:test";
import assert from "node:assert/strict";
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
const source = fs.readFileSync(
path.join(path.dirname(fileURLToPath(import.meta.url)), "HostTreeContextMenus.tsx"),
"utf8",
);
test("host context menu offers dual-pane SFTP without extra prop drilling", () => {
assert.match(source, /OpenDualPaneSftpMenuItem/);
assert.match(source, /requestOpenDualPaneSftp/);
assert.match(source, /vault\.hosts\.openSftp/);
assert.match(source, /useSettingsChromeStore/);
assert.match(source, /!showSftpTab\s*\|\|\s*!canOpenDualPaneSftp/);
});

View File

@@ -0,0 +1,162 @@
import { Copy, FileSymlink, Files, Folder, FolderOpen, Monitor, Pencil, Plus, Server, Settings2 } from 'lucide-react';
import React from 'react';
import { useI18n } from '../../application/i18n/I18nProvider';
import { requestOpenDualPaneSftp } from '../../application/state/sftp/sftpDualPaneOpenStore';
import { useSettingsChromeStore } from '../../application/state/settingsChromeStore';
import { sanitizeHost } from '../../domain/host';
import { isPluginHostProtocol } from '../../domain/pluginConnection';
import { canOpenDualPaneSftp } from '../../domain/sftpDualPaneOpen';
import type { Host } from '../../types';
import { ContextMenuContent, ContextMenuItem, ContextMenuShortcut } from '../ui/context-menu';
import { collectOwnedPluginMenus, comparePluginMenus, usePluginContributions } from '../../application/state/usePluginContributions';
import { PluginContributionIcon } from '../plugins/PluginContributionIcon';
export interface HostTreeHostContextMenuHandlers {
onConnect: (host: Host) => void;
onEditHost?: (host: Host) => void;
onRenameHost?: (host: Host) => void;
onDuplicateHost: (host: Host) => void;
onCopyHostname?: (host: Host) => void;
onCopyCredentials: (host: Host) => void;
onDeleteHost: (host: Host) => void;
}
export const OpenDualPaneSftpMenuItem: React.FC<{ host: Host }> = ({ host }) => {
const { t } = useI18n();
const { showSftpTab } = useSettingsChromeStore();
if (!showSftpTab || !canOpenDualPaneSftp(host)) return null;
return (
<ContextMenuItem onClick={() => requestOpenDualPaneSftp(host.id)}>
<Files className="mr-2 h-4 w-4" /> {t('vault.hosts.openSftp')}
</ContextMenuItem>
);
};
export const HostTreeHostContextMenuContent: React.FC<
HostTreeHostContextMenuHandlers & { host: Host }
> = ({
host,
onConnect,
onEditHost,
onRenameHost,
onDuplicateHost,
onCopyHostname,
onCopyCredentials,
onDeleteHost,
}) => {
const { t } = useI18n();
const safeHost = sanitizeHost(host);
const pluginContributions = usePluginContributions({
context: {
'netcatty.surface': 'host/context',
'host.id': safeHost.id,
'host.protocol': safeHost.protocol ?? 'ssh',
},
});
const pluginMenus = collectOwnedPluginMenus(pluginContributions.snapshot.plugins)
.filter((menu) => menu.location === 'host/context' && menu.visible)
.sort(comparePluginMenus);
const canCopyHostname = Boolean(onCopyHostname) && !isPluginHostProtocol(safeHost.protocol);
return (
<ContextMenuContent>
<ContextMenuItem onClick={() => onConnect(safeHost)}>
<Monitor className="mr-2 h-4 w-4" /> {t('vault.hosts.connect')}
</ContextMenuItem>
<OpenDualPaneSftpMenuItem host={safeHost} />
{onEditHost && (
<ContextMenuItem onClick={() => onEditHost(host)}>
<Settings2 className="mr-2 h-4 w-4" /> {t('terminal.layer.hostTree.editHost')}
</ContextMenuItem>
)}
{onRenameHost && (
<ContextMenuItem onClick={() => onRenameHost(host)}>
<Pencil className="mr-2 h-4 w-4" /> {t('common.rename')}
</ContextMenuItem>
)}
<ContextMenuItem onClick={() => onDuplicateHost(host)}>
<Copy className="mr-2 h-4 w-4" /> {t('action.duplicate')}
</ContextMenuItem>
{canCopyHostname ? (
<ContextMenuItem onClick={() => onCopyHostname?.(host)}>
<Copy className="mr-2 h-4 w-4" /> {t('terminal.statusbar.copyHostname.label')}
</ContextMenuItem>
) : null}
<ContextMenuItem onClick={() => onCopyCredentials(host)}>
<Server className="mr-2 h-4 w-4" /> {t('vault.hosts.copyCredentials')}
</ContextMenuItem>
<ContextMenuItem
onClick={() => onDeleteHost(host)}
className="text-destructive focus:text-destructive"
>
<Server className="mr-2 h-4 w-4" /> {t('action.delete')}
</ContextMenuItem>
{pluginMenus.map((menu) => (
<ContextMenuItem
key={menu.id}
disabled={!menu.enabled}
onClick={(event) => void pluginContributions.executeCommand(event.altKey && menu.alt ? menu.alt : menu.command, { hostId: safeHost.id }, {
'netcatty.surface': 'host/context',
'host.id': safeHost.id,
'host.protocol': safeHost.protocol ?? 'ssh',
}).catch(() => {})}
>
<PluginContributionIcon pluginId={menu.pluginId} icon={menu.icon} className="mr-2" />
{menu.title}
{menu.checked && <span className="ml-auto pl-4" aria-hidden="true"></span>}
{menu.shortcut && <ContextMenuShortcut>{menu.shortcut}</ContextMenuShortcut>}
</ContextMenuItem>
))}
</ContextMenuContent>
);
};
export interface HostTreeGroupContextMenuHandlers {
onNewHost?: (groupPath: string) => void;
onNewGroup: (parentPath?: string) => void;
onRenameGroup: (groupPath: string) => void;
onDeleteGroup: (groupPath: string) => void;
onUnmanageGroup?: (groupPath: string) => void;
}
export const HostTreeGroupContextMenuContent: React.FC<
HostTreeGroupContextMenuHandlers & { groupPath: string; isManaged: boolean }
> = ({
groupPath,
isManaged,
onNewHost,
onNewGroup,
onRenameGroup,
onDeleteGroup,
onUnmanageGroup,
}) => {
const { t } = useI18n();
return (
<ContextMenuContent>
{onNewHost && (
<ContextMenuItem onClick={() => onNewHost(groupPath)}>
<Plus className="mr-2 h-4 w-4" /> {t('terminal.layer.hostTree.newHostInGroup')}
</ContextMenuItem>
)}
<ContextMenuItem onClick={() => onNewGroup(groupPath)}>
<Folder className="mr-2 h-4 w-4" /> {t('vault.hosts.newGroup')}
</ContextMenuItem>
<ContextMenuItem onClick={() => onRenameGroup(groupPath)}>
<FolderOpen className="mr-2 h-4 w-4" /> {t('vault.groups.rename')}
</ContextMenuItem>
<ContextMenuItem
onClick={() => onDeleteGroup(groupPath)}
className="text-destructive focus:text-destructive"
>
<FolderOpen className="mr-2 h-4 w-4" /> {t('vault.groups.delete')}
</ContextMenuItem>
{isManaged && onUnmanageGroup && (
<ContextMenuItem onClick={() => onUnmanageGroup(groupPath)}>
<FileSymlink className="mr-2 h-4 w-4" /> {t('vault.managedSource.unmanage')}
</ContextMenuItem>
)}
</ContextMenuContent>
);
};

View File

@@ -0,0 +1,96 @@
import React, { useEffect, useState } from 'react';
import { useI18n } from '../../application/i18n/I18nProvider';
import {
hostTreeInlineGroupDeleteStore,
useHostTreeInlineGroupDeleteTarget,
} from '../../application/state/hostTreeInlineGroupDeleteStore';
import { Button } from '../ui/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '../ui/dialog';
type HostTreeGroupDeleteDialogProps = {
managedGroupPaths?: Set<string>;
onConfirmDelete: (groupPath: string, deleteHosts: boolean) => void | Promise<void>;
};
export const HostTreeGroupDeleteDialog: React.FC<HostTreeGroupDeleteDialogProps> = ({
managedGroupPaths,
onConfirmDelete,
}) => {
const { t } = useI18n();
const targetPath = useHostTreeInlineGroupDeleteTarget();
const [deleteHosts, setDeleteHosts] = useState(false);
const isOpen = Boolean(targetPath);
const isManaged = Boolean(targetPath && managedGroupPaths?.has(targetPath));
useEffect(() => {
if (!isOpen) {
setDeleteHosts(false);
}
}, [isOpen]);
return (
<Dialog
open={isOpen}
onOpenChange={(open) => {
if (!open) hostTreeInlineGroupDeleteStore.close();
}}
>
<DialogContent className="max-w-[calc(100vw-2rem)] overflow-hidden sm:max-w-lg">
<DialogHeader className="min-w-0 pr-6">
<DialogTitle className="truncate">{t('vault.groups.deleteDialogTitle')}</DialogTitle>
<DialogDescription className="break-words [overflow-wrap:anywhere]">
{isManaged
? t('vault.groups.deleteDialog.managedDesc')
: t('vault.groups.deleteDialog.desc')}
</DialogDescription>
</DialogHeader>
<div className="min-w-0 space-y-4 py-4">
{targetPath && (
<>
<p className="min-w-0 break-words text-sm text-muted-foreground [overflow-wrap:anywhere]">
{t('vault.groups.pathLabel')}:{' '}
<span className="font-mono">{targetPath}</span>
</p>
{!isManaged && (
<label className="flex items-center gap-2 text-sm cursor-pointer">
<input
type="checkbox"
checked={deleteHosts}
onChange={(event) => setDeleteHosts(event.target.checked)}
className="rounded border-border"
/>
<span>{t('vault.groups.deleteDialog.deleteHosts')}</span>
</label>
)}
</>
)}
</div>
<DialogFooter>
<Button variant="ghost" onClick={() => hostTreeInlineGroupDeleteStore.close()}>
{t('common.cancel')}
</Button>
<Button
variant="destructive"
onClick={() => {
if (!targetPath) return;
void Promise.resolve(onConfirmDelete(targetPath, isManaged || deleteHosts)).finally(() => {
hostTreeInlineGroupDeleteStore.close();
setDeleteHosts(false);
});
}}
>
{t('common.delete')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
};

View File

@@ -0,0 +1,55 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import React from 'react';
import { act, create, type ReactTestRenderer } from 'react-test-renderer';
import { HostTreeGroupInlineRenameInput } from './HostTreeGroupInlineRenameInput';
test('inline group rename can retry after an asynchronous commit failure', async () => {
const actEnvironment = globalThis as typeof globalThis & {
IS_REACT_ACT_ENVIRONMENT?: boolean;
};
const previousActEnvironment = actEnvironment.IS_REACT_ACT_ENVIRONMENT;
actEnvironment.IS_REACT_ACT_ENVIRONMENT = true;
let renderer: ReactTestRenderer | null = null;
let attempts = 0;
try {
await act(async () => {
renderer = create(React.createElement(HostTreeGroupInlineRenameInput, {
initialName: 'prod',
onCommit: async () => {
attempts += 1;
return attempts > 1;
},
onCancel: () => undefined,
}));
});
const input = renderer!.root.findByType('input');
await act(async () => {
input.props.onKeyDown({
key: 'Enter',
preventDefault: () => undefined,
stopPropagation: () => undefined,
});
await Promise.resolve();
});
await act(async () => {
input.props.onKeyDown({
key: 'Enter',
preventDefault: () => undefined,
stopPropagation: () => undefined,
});
await Promise.resolve();
});
assert.equal(attempts, 2);
} finally {
await act(async () => {
renderer?.unmount();
});
actEnvironment.IS_REACT_ACT_ENVIRONMENT = previousActEnvironment;
}
});

View File

@@ -0,0 +1,86 @@
import React, { useEffect, useRef, useState } from 'react';
import { cn } from '../../lib/utils';
type HostTreeGroupInlineRenameInputProps = {
initialName: string;
onCommit: (name: string) => boolean | void | Promise<boolean | void>;
onCancel: () => void;
className?: string;
style?: React.CSSProperties;
};
export const HostTreeGroupInlineRenameInput: React.FC<HostTreeGroupInlineRenameInputProps> = ({
initialName,
onCommit,
onCancel,
className,
style,
}) => {
const inputRef = useRef<HTMLInputElement>(null);
const [value, setValue] = useState(initialName);
const submittingRef = useRef(false);
useEffect(() => {
const input = inputRef.current;
if (!input) return;
input.focus();
input.select();
}, []);
const commit = async () => {
if (submittingRef.current) return;
submittingRef.current = true;
try {
const committed = await onCommit(value);
if (committed === false) submittingRef.current = false;
} catch {
submittingRef.current = false;
}
};
const cancel = () => {
if (submittingRef.current) return;
submittingRef.current = true;
onCancel();
};
return (
<input
ref={inputRef}
data-inline-group-edit="true"
value={value}
draggable={false}
onChange={(event) => setValue(event.target.value)}
onBlur={() => {
queueMicrotask(() => {
void commit();
});
}}
onClick={(event) => event.stopPropagation()}
onDoubleClick={(event) => event.stopPropagation()}
onMouseDown={(event) => event.stopPropagation()}
onPointerDown={(event) => event.stopPropagation()}
onDragStart={(event) => {
event.preventDefault();
event.stopPropagation();
}}
onKeyDown={(event) => {
event.stopPropagation();
if (event.key === 'Enter') {
event.preventDefault();
void commit();
}
if (event.key === 'Escape') {
event.preventDefault();
cancel();
}
}}
className={cn(
'min-w-0 flex-1 truncate select-text rounded-sm border border-primary/50 bg-background/80 px-1 py-0 text-sm font-medium outline-none ring-1 ring-primary/30',
className,
)}
style={style}
/>
);
};