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
291 lines
9.7 KiB
TypeScript
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}
|
|
/>
|
|
);
|
|
};
|