Files
NetMesh/components/scripts/QuickScriptEditorDialog.tsx
zhaolei 3c72efcb7f
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
[Init] Initial commit - NetMesh terminal manager
2026-09-13 18:24:01 +08:00

291 lines
9.7 KiB
TypeScript

import React, { useCallback, useEffect, useMemo, useState } from 'react';
import type { Host, Snippet } from '@/domain/models';
import {
DEFAULT_SCRIPT_TEMPLATE,
isScriptSnippet,
} from '@/domain/snippetScript.ts';
import {
getRunnableHostsForSnippet,
resolveSnippetTargetGroupsForSave,
} from '@/domain/snippetTargets.ts';
import {
removeHostConnectScript,
syncHostsForSnippetTargetChange,
} from '@/domain/hostConnectScripts.ts';
import { ScriptEditorModal } from '@/components/scripts/ScriptEditorModal';
import { toast } from '@/components/ui/toast';
import { useI18n } from '@/application/i18n/I18nProvider';
export interface QuickScriptEditorDialogProps {
snippets: Snippet[];
packages: string[];
hosts: Host[];
customGroups?: string[];
onCreateSnippet: (snippet: Snippet) => void;
onUpdateSnippet: (snippet: Snippet) => void;
onCreatePackage?: (packagePath: string) => void;
onUpdateHosts?: (hosts: Host[]) => void;
onRunSnippet?: (snippet: Snippet, targetHosts: Host[]) => void;
}
function createBlankScript(): Partial<Snippet> {
return {
label: '',
command: DEFAULT_SCRIPT_TEMPLATE,
package: '',
targets: [],
kind: 'script',
language: 'javascript',
trigger: 'manual',
};
}
export const QuickScriptEditorDialog: React.FC<QuickScriptEditorDialogProps> = ({
snippets,
packages,
hosts,
customGroups = [],
onCreateSnippet,
onUpdateSnippet,
onCreatePackage,
onUpdateHosts,
onRunSnippet,
}) => {
const { t } = useI18n();
const [open, setOpen] = useState(false);
const [editingSnippet, setEditingSnippet] = useState<Partial<Snippet>>(createBlankScript);
const [targetSelection, setTargetSelection] = useState<string[]>([]);
const [targetGroupSelection, setTargetGroupSelection] = useState<string[]>([]);
useEffect(() => {
const handler = () => {
setEditingSnippet(createBlankScript());
setTargetSelection([]);
setTargetGroupSelection([]);
setOpen(true);
};
window.addEventListener('netcatty:scripts:add', handler);
return () => window.removeEventListener('netcatty:scripts:add', handler);
}, []);
useEffect(() => {
const handler = (event: Event) => {
const snippet = (event as CustomEvent<{ snippet?: Snippet }>).detail?.snippet;
if (!snippet || !isScriptSnippet(snippet)) return;
setEditingSnippet(snippet);
setTargetSelection(snippet.targetsAllHosts ? [] : (snippet.targets ?? []));
setTargetGroupSelection(snippet.targetsAllHosts ? [] : (snippet.targetGroups ?? []));
setOpen(true);
};
window.addEventListener('netcatty:snippets:edit', handler);
return () => window.removeEventListener('netcatty:snippets:edit', handler);
}, []);
useEffect(() => {
const handler = (event: Event) => {
const detail = (event as CustomEvent<{
name: string;
packagePath: string;
code: string;
editAfterSave: boolean;
}>).detail;
if (!detail?.code?.trim()) return;
const packagePath = detail.packagePath?.trim() ?? '';
if (packagePath && !packages.includes(packagePath)) {
onCreatePackage?.(packagePath);
}
const snippet: Snippet = {
id: crypto.randomUUID(),
label: detail.name?.trim() || 'Recorded script',
command: detail.code,
package: packagePath,
targets: [],
kind: 'script',
language: 'javascript',
trigger: 'manual',
};
onCreateSnippet(snippet);
toast.success(t('scripts.recording.savedNamed', { name: snippet.label }));
window.dispatchEvent(new CustomEvent('netcatty:scripts:saved', {
detail: { snippetId: snippet.id, packagePath },
}));
if (detail.editAfterSave) {
setEditingSnippet(snippet);
setTargetSelection([]);
setTargetGroupSelection([]);
setOpen(true);
}
};
window.addEventListener('netcatty:scripts:save-recorded', handler);
return () => window.removeEventListener('netcatty:scripts:save-recorded', handler);
}, [onCreatePackage, onCreateSnippet, packages, t]);
const hostById = useMemo(
() => new Map(hosts.map((host) => [host.id, host])),
[hosts],
);
const targetHosts = useMemo(
() => targetSelection.map((id) => hostById.get(id)).filter(Boolean) as Host[],
[hostById, targetSelection],
);
const runnableSnippet = useMemo(() => ({
...(editingSnippet as Snippet),
targets: editingSnippet.targetsAllHosts ? [] : targetSelection,
targetGroups: resolveSnippetTargetGroupsForSave(
editingSnippet,
targetGroupSelection,
),
targetsAllHosts: editingSnippet.targetsAllHosts || undefined,
}), [editingSnippet, targetGroupSelection, targetSelection]);
const runTargets = useMemo(
() => getRunnableHostsForSnippet(runnableSnippet, hosts),
[hosts, runnableSnippet],
);
const canRun = Boolean(editingSnippet.command?.trim()) && runTargets.length > 0;
const syncHostsAfterSave = useCallback((savedSnippet: Snippet, nextSnippets: Snippet[]) => {
if (!onUpdateHosts || !savedSnippet.id) return;
const original = snippets.find((item) => item.id === savedSnippet.id);
const prevTargetIds = original?.targetsAllHosts ? [] : (original?.targets ?? []);
let nextHosts = hosts;
if (isScriptSnippet(savedSnippet) && savedSnippet.trigger === 'onConnect') {
nextHosts = syncHostsForSnippetTargetChange(hosts, savedSnippet, prevTargetIds, nextSnippets);
} else if (original && isScriptSnippet(original) && original.trigger === 'onConnect') {
nextHosts = hosts.map((item) => removeHostConnectScript(item, savedSnippet.id!, nextSnippets));
}
const changed = nextHosts.length !== hosts.length
|| nextHosts.some((host, index) => host !== hosts[index]);
if (changed) {
onUpdateHosts(nextHosts);
}
}, [hosts, onUpdateHosts, snippets]);
const buildSavedSnippet = useCallback((): Snippet | null => {
if (!editingSnippet.label?.trim() || !editingSnippet.command?.trim()) return null;
const packagePath = editingSnippet.package?.trim() ?? '';
if (packagePath && !packages.includes(packagePath)) {
onCreatePackage?.(packagePath);
}
return {
id: editingSnippet.id || crypto.randomUUID(),
label: editingSnippet.label.trim(),
command: editingSnippet.command,
tags: editingSnippet.tags ?? [],
package: packagePath,
targets: editingSnippet.targetsAllHosts ? [] : targetSelection,
targetGroups: resolveSnippetTargetGroupsForSave(
editingSnippet,
targetGroupSelection,
),
targetsAllHosts: editingSnippet.targetsAllHosts || undefined,
kind: 'script',
language: editingSnippet.language ?? 'javascript',
description: editingSnippet.description,
trigger: editingSnippet.trigger ?? 'manual',
triggerPattern: editingSnippet.triggerPattern,
order: editingSnippet.order,
};
}, [editingSnippet, onCreatePackage, packages, targetGroupSelection, targetSelection]);
const persistSnippet = useCallback((): Snippet | null => {
const savedSnippet = buildSavedSnippet();
if (!savedSnippet) return null;
const nextSnippets = snippets.some((item) => item.id === savedSnippet.id)
? snippets.map((item) => (item.id === savedSnippet.id ? savedSnippet : item))
: [...snippets, savedSnippet];
if (snippets.some((item) => item.id === savedSnippet.id)) {
onUpdateSnippet(savedSnippet);
} else {
onCreateSnippet(savedSnippet);
}
syncHostsAfterSave(savedSnippet, nextSnippets);
return savedSnippet;
}, [buildSavedSnippet, onCreateSnippet, onUpdateSnippet, snippets, syncHostsAfterSave]);
const handleSave = useCallback(() => {
if (!persistSnippet()) return;
setOpen(false);
}, [persistSnippet]);
const handleRun = useCallback(() => {
const savedSnippet = persistSnippet();
if (!savedSnippet) return;
const targets = getRunnableHostsForSnippet(savedSnippet, hosts);
if (targets.length === 0) {
toast.error(t('scripts.actions.noRunnableHosts'));
return;
}
if (onRunSnippet) {
onRunSnippet(savedSnippet, targets);
} else {
window.dispatchEvent(new CustomEvent('netcatty:scripts:run-now', {
detail: { snippet: savedSnippet },
}));
}
setOpen(false);
}, [hosts, onRunSnippet, persistSnippet, t]);
const handleSelectHost = useCallback((host: Host) => {
setTargetSelection((prev) => (
prev.includes(host.id)
? prev.filter((id) => id !== host.id)
: [...prev, host.id]
));
}, []);
const handleSelectionChange = useCallback((nextSelectedHostIds: string[]) => {
setTargetSelection(nextSelectedHostIds);
}, []);
const handleTargetsAllHostsChange = useCallback((checked: boolean) => {
if (checked) {
setTargetSelection([]);
setTargetGroupSelection([]);
setEditingSnippet((prev) => ({
...prev,
targetsAllHosts: true,
targets: [],
targetGroups: undefined,
}));
return;
}
setEditingSnippet((prev) => ({
...prev,
targetsAllHosts: undefined,
}));
}, []);
return (
<ScriptEditorModal
open={open}
onClose={() => setOpen(false)}
snippet={editingSnippet as Snippet}
onChange={setEditingSnippet}
onSave={handleSave}
canRun={canRun}
onRun={canRun ? handleRun : undefined}
targetHosts={targetHosts}
hosts={hosts}
customGroups={customGroups}
selectedHostIds={targetSelection}
onSelectHost={handleSelectHost}
onSelectionChange={handleSelectionChange}
selectedGroupPaths={targetGroupSelection}
onGroupSelectionChange={setTargetGroupSelection}
targetsAllHosts={Boolean(editingSnippet.targetsAllHosts)}
onTargetsAllHostsChange={handleTargetsAllHostsChange}
/>
);
};