[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
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:
284
components/host/HostDetailsScriptsSection.tsx
Normal file
284
components/host/HostDetailsScriptsSection.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
93
components/host/HostNotesEditor.tsx
Normal file
93
components/host/HostNotesEditor.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
50
components/host/HostNotesIndicator.tsx
Normal file
50
components/host/HostNotesIndicator.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
18
components/host/HostTreeContextMenus.test.ts
Normal file
18
components/host/HostTreeContextMenus.test.ts
Normal 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/);
|
||||
});
|
||||
162
components/host/HostTreeContextMenus.tsx
Normal file
162
components/host/HostTreeContextMenus.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
96
components/host/HostTreeGroupDeleteDialog.tsx
Normal file
96
components/host/HostTreeGroupDeleteDialog.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
55
components/host/HostTreeGroupInlineRenameInput.test.tsx
Normal file
55
components/host/HostTreeGroupInlineRenameInput.test.tsx
Normal 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;
|
||||
}
|
||||
});
|
||||
86
components/host/HostTreeGroupInlineRenameInput.tsx
Normal file
86
components/host/HostTreeGroupInlineRenameInput.tsx
Normal 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}
|
||||
/>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user