[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,290 @@
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}
/>
);
};

View File

@@ -0,0 +1,34 @@
import { useEffect } from 'react';
import { ScriptDialogHost } from '@/components/scripts/ScriptDialogHost.tsx';
import { captureScreenSnapshot } from '@/infrastructure/scripts/screenSnapshotRegistry.ts';
import { setupScriptBridgeListeners } from '@/application/state/useOutputTriggers.ts';
import { netcattyBridge } from '@/infrastructure/services/netcattyBridge.ts';
import { setScriptRuns } from '@/application/state/scriptAutomationCoordinator.ts';
import type { Snippet } from '@/domain/models';
export function ScriptAutomationRoot() {
useEffect(() => {
const disposeBridge = setupScriptBridgeListeners(captureScreenSnapshot);
const bridge = netcattyBridge.get();
bridge?.scriptGetRuns?.().then(setScriptRuns).catch(() => {});
const disposeRuns = bridge?.onScriptRunsUpdated?.(({ runs }) => {
setScriptRuns(runs);
});
return () => {
disposeBridge();
disposeRuns?.();
};
}, []);
useEffect(() => {
const handler = (event: Event) => {
const snippet = (event as CustomEvent<{ snippet: Snippet }>).detail?.snippet;
if (!snippet) return;
window.dispatchEvent(new CustomEvent('netcatty:scripts:run-on-focused', { detail: { snippet } }));
};
window.addEventListener('netcatty:scripts:run-now', handler);
return () => window.removeEventListener('netcatty:scripts:run-now', handler);
}, []);
return <ScriptDialogHost />;
}

View File

@@ -0,0 +1,199 @@
import Editor, { loader, type Monaco, type OnMount, useMonaco } from '@monaco-editor/react';
import { Loader2 } from 'lucide-react';
import React, { useCallback, useEffect, useImperativeHandle, useRef } from 'react';
import { useClipboardBackend } from '@/application/state/useClipboardBackend';
import {
buildMonacoPasteEdits,
pasteForMonacoEditorCommand,
readClipboardTextWithFallbacks,
} from '@/infrastructure/monaco/monacoClipboardPaste';
import { useNetcattyMonacoTheme } from '@/infrastructure/monaco/useNetcattyMonacoTheme';
import { registerNctMonacoCompletionProvider } from '@/infrastructure/scripts/nctMonacoCompletion.ts';
const viteEnv = import.meta.env ?? { BASE_URL: '/' };
const monacoBasePath = viteEnv.DEV
? './node_modules/monaco-editor/min/vs'
: `${viteEnv.BASE_URL}monaco/vs`;
loader.config({ paths: { vs: monacoBasePath } });
export interface ScriptCodeEditorProps {
value: string;
onChange: (value: string) => void;
language: 'javascript' | 'python' | 'shell';
/** Fill parent flex container (modal). Parent must have explicit height. */
fill?: boolean;
/** Fixed pixel height (sidebar). Ignored when fill is true. */
height?: number;
minimap?: boolean;
/** Re-layout when container becomes visible (e.g. dialog open). */
active?: boolean;
/** Move keyboard focus into the editor after it mounts. */
autoFocus?: boolean;
/** Accessible name announced by screen readers. */
ariaLabel?: string;
/** Hint shown while the editor is empty. */
placeholder?: string;
/** Let Tab move to the next control instead of inserting indentation. */
tabFocusMode?: boolean;
/** Run the surrounding form's submit action for Cmd/Ctrl+Enter. */
onSubmitShortcut?: () => void;
}
export interface ScriptCodeEditorHandle {
focus: () => void;
}
export const ScriptCodeEditor = React.forwardRef<ScriptCodeEditorHandle, ScriptCodeEditorProps>(({
value,
onChange,
language,
fill = false,
height = 240,
minimap = false,
active = true,
autoFocus = false,
ariaLabel,
placeholder,
tabFocusMode = false,
onSubmitShortcut,
}, forwardedRef) => {
const monaco = useMonaco();
const themeName = useNetcattyMonacoTheme(monaco ?? undefined);
const { readClipboardText: readClipboardTextFromBridge } = useClipboardBackend();
const editorRef = useRef<Monaco.editor.IStandaloneCodeEditor | null>(null);
const completionDisposableRef = useRef<{ dispose: () => void } | null>(null);
const onSubmitShortcutRef = useRef(onSubmitShortcut);
onSubmitShortcutRef.current = onSubmitShortcut;
const handlePasteRef = useRef<() => Promise<void>>(() => Promise.resolve());
const readClipboardTextRef = useRef<() => Promise<string | null>>(() => Promise.resolve(null));
useImperativeHandle(forwardedRef, () => ({
focus: () => editorRef.current?.focus(),
}), []);
useEffect(() => () => {
completionDisposableRef.current?.dispose();
completionDisposableRef.current = null;
}, []);
useEffect(() => {
if (!active || !editorRef.current) return;
const frame = requestAnimationFrame(() => {
editorRef.current?.layout();
});
return () => cancelAnimationFrame(frame);
}, [active, fill, height]);
const readClipboardText = useCallback(async (): Promise<string | null> => (
readClipboardTextWithFallbacks({
readNavigator: navigator.clipboard?.readText
? () => navigator.clipboard.readText()
: undefined,
readBridge: readClipboardTextFromBridge,
})
), [readClipboardTextFromBridge]);
useEffect(() => {
readClipboardTextRef.current = readClipboardText;
}, [readClipboardText]);
const handlePaste = useCallback(async () => {
const editor = editorRef.current;
if (!editor) return;
const text = await readClipboardText();
if (text === null) {
// Clipboard read unavailable; fall back to Monaco's native paste.
editor.trigger('keyboard', 'editor.action.clipboardPasteAction', null);
return;
}
if (!text) return;
const selections = editor.getSelections();
if (!selections || selections.length === 0) return;
editor.executeEdits('netcatty-paste', buildMonacoPasteEdits(text, selections));
editor.focus();
}, [readClipboardText]);
useEffect(() => {
handlePasteRef.current = handlePaste;
}, [handlePaste]);
const handleMount: OnMount = useCallback((editor, monacoInstance) => {
editorRef.current = editor;
completionDisposableRef.current?.dispose();
completionDisposableRef.current = language === 'javascript'
? registerNctMonacoCompletionProvider(monacoInstance)
: null;
if (onSubmitShortcut) {
editor.addCommand(
monacoInstance.KeyMod.CtrlCmd | monacoInstance.KeyCode.Enter,
() => onSubmitShortcutRef.current?.(),
);
}
// Fallback paste path for Electron where Monaco clipboardPasteAction can fail.
// When focus is in the find/replace widget, paste into that input instead of the body.
editor.addCommand(
monacoInstance.KeyMod.CtrlCmd | monacoInstance.KeyCode.KeyV,
() => {
void pasteForMonacoEditorCommand({
activeElement: document.activeElement,
readClipboardText: () => readClipboardTextRef.current(),
pasteIntoEditor: () => handlePasteRef.current(),
});
},
);
requestAnimationFrame(() => editor.layout());
if (autoFocus) editor.focus();
}, [autoFocus, language, onSubmitShortcut]);
const editorHeight = fill ? '100%' : `${height}px`;
return (
<div className={fill ? 'h-full min-h-0 relative' : 'relative'} style={fill ? undefined : { height }}>
<Editor
height={editorHeight}
language={language}
value={value}
onChange={(next) => onChange(next ?? '')}
onMount={handleMount}
theme={themeName}
loading={(
<div className="absolute inset-0 flex items-center justify-center bg-background">
<Loader2 size={24} className="animate-spin text-muted-foreground" />
</div>
)}
options={{
// Prefer native context menu in Electron so right-click Paste uses OS clipboard path.
contextmenu: false,
minimap: { enabled: minimap },
fontSize: 13,
lineNumbers: 'on',
wordWrap: 'on',
scrollBeyondLastLine: false,
automaticLayout: true,
tabSize: 2,
insertSpaces: true,
folding: true,
renderLineHighlight: 'line',
padding: { top: 8, bottom: 8 },
bracketPairColorization: { enabled: true },
ariaLabel,
tabFocusMode,
}}
/>
{placeholder && !value ? (
<span
aria-hidden
className="pointer-events-none absolute left-[52px] top-2 z-10 font-mono text-[13px] text-muted-foreground"
>
{placeholder}
</span>
) : null}
</div>
);
});
ScriptCodeEditor.displayName = 'ScriptCodeEditor';

View File

@@ -0,0 +1,529 @@
import test from "node:test";
import assert from "node:assert/strict";
import React from "react";
import { renderToStaticMarkup } from "react-dom/server";
import type { ScriptDialogRequest } from "../../types/global/netcatty-bridge-script.d.ts";
import {
applyFormValue,
getDialogFieldDomId,
getInitialFormValues,
getVisibleDialogFormFields,
normalizeDialogFormSubmitValues,
ScriptDialogFormBody,
ScriptDialogFormFields,
validateDialogFormValues,
} from "./ScriptDialogHost.tsx";
const formRequest: ScriptDialogRequest = {
requestId: "dialog-1",
type: "form",
message: "Choose options",
form: {
title: "Deploy",
message: "Choose options",
fields: [
{
type: "select",
name: "env",
label: "Environment",
options: [
{ label: "Development", value: "dev" },
{ label: "Production", value: "prod", description: "Use carefully" },
],
defaultValue: "dev",
},
{
type: "checkbox",
name: "restart",
label: "Restart service",
defaultValue: true,
},
{
type: "radio",
name: "mode",
label: "Mode",
options: [
{ label: "Safe", value: "safe" },
{ label: "Fast", value: "fast" },
],
defaultValue: "safe",
},
{
type: "textarea",
name: "notes",
label: "Notes",
defaultValue: "initial note",
required: false,
},
{
type: "number",
name: "retries",
label: "Retries",
defaultValue: 3,
min: 0,
step: 1,
},
],
},
};
test("script dialog form derives initial values from fields", () => {
assert.deepEqual(getInitialFormValues(formRequest), {
env: "dev",
restart: true,
mode: "safe",
notes: "initial note",
retries: 3,
});
});
test("script dialog form value helper preserves previous values for submit payload", () => {
const initial = getInitialFormValues(formRequest);
const withEnv = applyFormValue(initial, "env", "prod");
const withRestart = applyFormValue(withEnv, "restart", false);
const withMode = applyFormValue(withRestart, "mode", "fast");
const withNotes = applyFormValue(withMode, "notes", "ship it");
const submitted = normalizeDialogFormSubmitValues(
formRequest.form!,
applyFormValue(withNotes, "retries", "5"),
);
assert.deepEqual(submitted, {
env: "prod",
restart: false,
mode: "fast",
notes: "ship it",
retries: 5,
});
});
test("script dialog form validates required text and number fields", () => {
const emptyRequiredRequest: ScriptDialogRequest = {
...formRequest,
form: {
...formRequest.form!,
fields: [
...formRequest.form!.fields,
{ type: "textarea", name: "requiredNotes", label: "Required notes", defaultValue: "" },
{ type: "number", name: "requiredCount", label: "Required count" },
],
},
};
const values = getInitialFormValues(emptyRequiredRequest);
assert.deepEqual(validateDialogFormValues(emptyRequiredRequest.form!, values, "Required"), {
requiredNotes: "Required",
requiredCount: "Required",
});
});
test("script dialog form only requires checkboxes when explicitly marked required", () => {
const form = {
message: "Confirm",
fields: [
{
type: "checkbox" as const,
name: "optionalFlag",
label: "Optional flag",
defaultValue: false,
},
{
type: "checkbox" as const,
name: "confirmDanger",
label: "I understand",
defaultValue: false,
required: true,
},
],
};
assert.deepEqual(validateDialogFormValues(form, {
optionalFlag: false,
confirmDanger: false,
}, "Required"), {
confirmDanger: "Required",
});
assert.deepEqual(validateDialogFormValues(form, {
optionalFlag: false,
confirmDanger: true,
}, "Required"), {});
});
test("script dialog form validates number min max and step before submit", () => {
const form = {
message: "Number limits",
fields: [{
type: "number" as const,
name: "delayMs",
label: "Delay",
defaultValue: 500,
min: 0,
max: 5000,
step: 100,
required: false,
}],
};
const messages = {
required: "Required",
numberInvalid: "Invalid",
numberMin: (min: number) => `Min ${min}`,
numberMax: (max: number) => `Max ${max}`,
numberStep: (step: number) => `Step ${step}`,
};
assert.deepEqual(validateDialogFormValues(form, { delayMs: -1 }, messages), {
delayMs: "Min 0",
});
assert.deepEqual(validateDialogFormValues(form, { delayMs: 5001 }, messages), {
delayMs: "Max 5000",
});
assert.deepEqual(validateDialogFormValues(form, { delayMs: 550 }, messages), {
delayMs: "Step 100",
});
assert.deepEqual(validateDialogFormValues(form, { delayMs: "" }, messages), {});
assert.deepEqual(validateDialogFormValues(form, { delayMs: 5000 }, messages), {});
const defaultBasedForm = {
message: "Number limits",
fields: [{
type: "number" as const,
name: "oddCount",
label: "Odd count",
defaultValue: 5,
step: 2,
}],
};
assert.deepEqual(validateDialogFormValues(defaultBasedForm, { oddCount: 5 }, messages), {});
assert.deepEqual(validateDialogFormValues(defaultBasedForm, { oddCount: 7 }, messages), {});
assert.deepEqual(validateDialogFormValues(defaultBasedForm, { oddCount: 6 }, messages), {
oddCount: "Step 2",
});
});
test("script dialog form applies visibleWhen to rendering validation and submit payload", () => {
const conditionalRequest: ScriptDialogRequest = {
...formRequest,
form: {
...formRequest.form!,
fields: [
{
type: "select",
name: "target",
label: "Target",
options: [
{ label: "Local", value: "local" },
{ label: "Remote", value: "remote" },
],
defaultValue: "local",
},
{
type: "textarea",
name: "host",
label: "Remote host",
defaultValue: "",
visibleWhen: { field: "target", equals: "remote" },
},
{
type: "checkbox",
name: "confirmRemote",
label: "Confirm remote",
defaultValue: false,
visibleWhen: { field: "target", notEquals: "local" },
},
{
type: "textarea",
name: "localNote",
label: "Local note",
defaultValue: "local only",
required: false,
visibleWhen: { field: "target", equals: "local" },
},
{
type: "textarea",
name: "remoteDetail",
label: "Remote detail",
defaultValue: "hidden by hidden controller",
required: false,
visibleWhen: { field: "confirmRemote", truthy: true },
},
],
},
};
const localValues = getInitialFormValues(conditionalRequest);
const localVisibleNames = getVisibleDialogFormFields(conditionalRequest.form!, localValues).map((field) => field.name);
assert.deepEqual(localVisibleNames, ["target", "localNote"]);
assert.deepEqual(validateDialogFormValues(conditionalRequest.form!, localValues, "Required"), {});
assert.deepEqual(normalizeDialogFormSubmitValues(conditionalRequest.form!, localValues), {
target: "local",
localNote: "local only",
});
const remoteValues = applyFormValue(applyFormValue(localValues, "target", "remote"), "confirmRemote", true);
const remoteVisibleNames = getVisibleDialogFormFields(conditionalRequest.form!, remoteValues).map((field) => field.name);
assert.deepEqual(remoteVisibleNames, ["target", "host", "confirmRemote", "remoteDetail"]);
assert.deepEqual(validateDialogFormValues(conditionalRequest.form!, remoteValues, "Required"), {
host: "Required",
});
assert.deepEqual(normalizeDialogFormSubmitValues(conditionalRequest.form!, applyFormValue(remoteValues, "host", "example.com")), {
target: "remote",
host: "example.com",
confirmRemote: true,
remoteDetail: "hidden by hidden controller",
});
});
test("script dialog form does not show fields chained from hidden controllers", () => {
const request: ScriptDialogRequest = {
...formRequest,
form: {
...formRequest.form!,
fields: [
{
type: "select",
name: "target",
label: "Target",
options: [
{ label: "Local", value: "local" },
{ label: "Remote", value: "remote" },
],
defaultValue: "local",
},
{
type: "checkbox",
name: "advanced",
label: "Advanced",
defaultValue: true,
visibleWhen: { field: "target", equals: "remote" },
},
{
type: "textarea",
name: "advancedNote",
label: "Advanced note",
defaultValue: "should stay hidden",
visibleWhen: { field: "advanced", truthy: true },
},
],
},
};
assert.deepEqual(
getVisibleDialogFormFields(request.form!, getInitialFormValues(request)).map((field) => field.name),
["target"],
);
assert.deepEqual(normalizeDialogFormSubmitValues(request.form!, getInitialFormValues(request)), {
target: "local",
});
});
test("script dialog form fields render select checkbox radio textarea and number controls", () => {
const values = applyFormValue(getInitialFormValues(formRequest), "env", "prod");
const markup = renderToStaticMarkup(
<ScriptDialogFormFields
form={formRequest.form!}
formValues={values}
onValueChange={() => {}}
/>,
);
assert.match(markup, /Environment/);
assert.match(markup, /Use carefully/);
assert.match(markup, /id="script-dialog-env-label"/);
assert.match(markup, /aria-labelledby="script-dialog-env-label"/);
assert.match(markup, /Restart service/);
assert.match(markup, /type="checkbox"[^>]*checked=""/);
assert.match(markup, /type="radio"[^>]*checked=""[^>]*value="safe"/);
assert.match(markup, /type="radio"[^>]*value="fast"/);
assert.match(markup, /<textarea[^>]*>initial note<\/textarea>/);
assert.match(markup, /type="number"[^>]*min="0"[^>]*step="1"[^>]*value="3"/);
});
test("script dialog form fields do not render hidden visibleWhen controls", () => {
const request: ScriptDialogRequest = {
...formRequest,
form: {
...formRequest.form!,
fields: [
{
type: "select",
name: "target",
label: "Target",
options: [
{ label: "Local", value: "local" },
{ label: "Remote", value: "remote" },
],
defaultValue: "local",
},
{
type: "textarea",
name: "host",
label: "Remote host",
defaultValue: "",
visibleWhen: { field: "target", equals: "remote" },
},
],
},
};
const markup = renderToStaticMarkup(
<ScriptDialogFormFields
form={request.form!}
formValues={getInitialFormValues(request)}
onValueChange={() => {}}
/>,
);
assert.match(markup, /Target/);
assert.doesNotMatch(markup, /Remote host/);
});
test("script dialog form body renders fields inside a constrained scroll area", () => {
const values = getInitialFormValues(formRequest);
const markup = renderToStaticMarkup(
<ScriptDialogFormBody
form={formRequest.form!}
formValues={values}
onValueChange={() => {}}
/>,
);
assert.match(markup, /data-radix-scroll-area-viewport/);
assert.match(markup, /min-h-0/);
assert.match(markup, /Environment/);
});
test("script dialog form fields render required errors", () => {
const values = getInitialFormValues(formRequest);
const markup = renderToStaticMarkup(
<ScriptDialogFormFields
form={formRequest.form!}
formValues={values}
formErrors={{ retries: "Required" }}
onValueChange={() => {}}
/>,
);
assert.match(markup, /aria-invalid="true"/);
assert.match(markup, /id="script-dialog-retries-error"/);
assert.match(markup, /aria-describedby="script-dialog-retries-error"/);
assert.match(markup, /Required/);
});
test("script dialog form fields associate checkbox errors with the input", () => {
const form = {
message: "Confirm",
fields: [{
type: "checkbox" as const,
name: "confirmDanger",
label: "I understand",
description: "Required before continuing",
defaultValue: false,
required: true,
}],
};
const markup = renderToStaticMarkup(
<ScriptDialogFormFields
form={form}
formValues={{ confirmDanger: false }}
formErrors={{ confirmDanger: "Required" }}
onValueChange={() => {}}
/>,
);
assert.match(markup, /id="script-dialog-confirmDanger-description"/);
assert.match(markup, /id="script-dialog-confirmDanger-error"/);
assert.match(
markup,
/aria-describedby="script-dialog-confirmDanger-description script-dialog-confirmDanger-error"/,
);
assert.match(markup, /aria-invalid="true"/);
});
test("script dialog form field DOM ids encode names with spaces", () => {
const form = {
message: "Confirm",
fields: [{
type: "checkbox" as const,
name: "confirm danger",
label: "I understand",
description: "Required before continuing",
defaultValue: false,
required: true,
}],
};
const markup = renderToStaticMarkup(
<ScriptDialogFormFields
form={form}
formValues={{ "confirm danger": false }}
formErrors={{ "confirm danger": "Required" }}
onValueChange={() => {}}
/>,
);
assert.equal(getDialogFieldDomId("confirm danger"), "script-dialog-confirm%20danger");
assert.match(markup, /id="script-dialog-confirm%20danger"/);
assert.match(
markup,
/aria-describedby="script-dialog-confirm%20danger-description script-dialog-confirm%20danger-error"/,
);
assert.doesNotMatch(markup, /script-dialog-confirm danger/);
});
test("script dialog form control ids encode spaced names for radio text and number fields", () => {
const form = {
message: "Spaced names",
fields: [
{
type: "radio" as const,
name: "run mode",
label: "Run mode",
description: "Choose mode",
options: [
{ label: "Safe", value: "safe" },
{ label: "Fast", value: "fast" },
],
defaultValue: "safe",
},
{
type: "textarea" as const,
name: "release notes",
label: "Release notes",
defaultValue: "",
},
{
type: "number" as const,
name: "retry count",
label: "Retry count",
defaultValue: 3,
},
],
};
const markup = renderToStaticMarkup(
<ScriptDialogFormFields
form={form}
formValues={{
"run mode": "safe",
"release notes": "",
"retry count": 3,
}}
formErrors={{
"run mode": "Required",
"release notes": "Required",
"retry count": "Required",
}}
onValueChange={() => {}}
/>,
);
assert.match(markup, /id="script-dialog-run%20mode-0"/);
assert.match(markup, /name="script-dialog-run%20mode"/);
assert.match(markup, /id="script-dialog-release%20notes"/);
assert.match(markup, /for="script-dialog-release%20notes"/);
assert.match(markup, /id="script-dialog-retry%20count"/);
assert.match(markup, /for="script-dialog-retry%20count"/);
assert.doesNotMatch(markup, /script-dialog-run mode/);
assert.doesNotMatch(markup, /script-dialog-release notes/);
assert.doesNotMatch(markup, /script-dialog-retry count/);
});

View File

@@ -0,0 +1,550 @@
import { useCallback, useEffect, useState } from 'react';
import { useI18n } from '@/application/i18n/I18nProvider';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { ScrollArea } from '@/components/ui/scroll-area';
import { Textarea } from '@/components/ui/textarea';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { netcattyBridge } from '@/infrastructure/services/netcattyBridge.ts';
import type {
ScriptDialogCondition,
ScriptDialogField,
ScriptDialogForm,
ScriptDialogFormValue,
ScriptDialogRequest,
} from '@/types/global/netcatty-bridge-script.d.ts';
type FormValues = Record<string, ScriptDialogFormValue>;
type FormErrors = Record<string, string>;
type FormValidationMessages = string | {
required: string;
numberInvalid: string;
numberMin: (min: number) => string;
numberMax: (max: number) => string;
numberStep: (step: number) => string;
};
export function getInitialFormValues(request: ScriptDialogRequest): FormValues {
if (request.type !== 'form' || !request.form) return {};
return Object.fromEntries(
request.form.fields.map((field) => [field.name, field.defaultValue]),
);
}
export function applyFormValue(values: FormValues, name: string, value: ScriptDialogFormValue): FormValues {
return { ...values, [name]: value };
}
export function getDialogFieldDomId(name: string): string {
return `script-dialog-${encodeURIComponent(name || 'field')}`;
}
function resolveValidationMessages(messages: FormValidationMessages = 'Required') {
if (typeof messages === 'string') {
return {
required: messages,
numberInvalid: messages,
numberMin: () => messages,
numberMax: () => messages,
numberStep: () => messages,
};
}
return messages;
}
function matchesNumberStep(value: number, step: number, base = 0) {
const quotient = (value - base) / step;
return Math.abs(quotient - Math.round(quotient)) < 1e-9;
}
function getNumberStepBase(field: Extract<ScriptDialogField, { type: 'number' }>) {
return field.min ?? field.defaultValue ?? 0;
}
function getConditionFieldValue(
form: ScriptDialogForm,
values: FormValues,
fieldName: string,
): ScriptDialogFormValue {
const field = form.fields.find((candidate) => candidate.name === fieldName);
const value = values[fieldName];
if (field?.type === 'number') {
if (value === undefined || value === '') return undefined;
const numberValue = typeof value === 'number' ? value : Number(value);
return Number.isFinite(numberValue) ? numberValue : value;
}
if (field?.type === 'checkbox') {
return Boolean(value);
}
return value;
}
export function evaluateDialogCondition(
form: ScriptDialogForm,
values: FormValues,
condition: ScriptDialogCondition,
): boolean {
const value = getConditionFieldValue(form, values, condition.field);
if ('equals' in condition) {
return value === condition.equals;
}
if ('notEquals' in condition) {
return value !== condition.notEquals;
}
if ('truthy' in condition) {
return Boolean(value);
}
return !value;
}
export function isDialogFieldVisible(
form: ScriptDialogForm,
field: ScriptDialogField,
values: FormValues,
): boolean {
return getVisibleDialogFormFields(form, values).some((visibleField) => visibleField.name === field.name);
}
export function getVisibleDialogFormFields(form: ScriptDialogForm, values: FormValues): ScriptDialogField[] {
const visibleFields: ScriptDialogField[] = [];
const visibleNames = new Set<string>();
for (const field of form.fields) {
if (!field.visibleWhen) {
visibleFields.push(field);
visibleNames.add(field.name);
continue;
}
if (!visibleNames.has(field.visibleWhen.field)) {
continue;
}
if (evaluateDialogCondition(form, values, field.visibleWhen)) {
visibleFields.push(field);
visibleNames.add(field.name);
}
}
return visibleFields;
}
export function validateDialogFormValues(
form: ScriptDialogForm,
values: FormValues,
messages: FormValidationMessages = 'Required',
): FormErrors {
const validationMessages = resolveValidationMessages(messages);
const errors: FormErrors = {};
for (const field of getVisibleDialogFormFields(form, values)) {
const value = values[field.name];
if (field.type === 'checkbox') {
if (field.required === true && !value) {
errors[field.name] = validationMessages.required;
}
continue;
}
if (field.type === 'number') {
const isEmpty = value === undefined || value === '';
if (isEmpty) {
if (field.required !== false) {
errors[field.name] = validationMessages.required;
}
continue;
}
const numberValue = typeof value === 'number' ? value : Number(value);
if (!Number.isFinite(numberValue)) {
errors[field.name] = validationMessages.numberInvalid;
continue;
}
if (field.min !== undefined && numberValue < field.min) {
errors[field.name] = validationMessages.numberMin(field.min);
continue;
}
if (field.max !== undefined && numberValue > field.max) {
errors[field.name] = validationMessages.numberMax(field.max);
continue;
}
if (field.step !== undefined && !matchesNumberStep(numberValue, field.step, getNumberStepBase(field))) {
errors[field.name] = validationMessages.numberStep(field.step);
}
continue;
}
if (field.required === false) continue;
if (value === undefined || String(value).trim() === '') {
errors[field.name] = validationMessages.required;
}
}
return errors;
}
export function normalizeDialogFormSubmitValues(form: ScriptDialogForm, values: FormValues): FormValues {
const next: FormValues = {};
for (const field of getVisibleDialogFormFields(form, values)) {
const value = values[field.name];
if (field.type === 'number') {
if (value === undefined || value === '') {
next[field.name] = undefined;
continue;
}
const numberValue = typeof value === 'number' ? value : Number(value);
next[field.name] = Number.isFinite(numberValue) ? numberValue : undefined;
continue;
}
next[field.name] = value;
}
return next;
}
export function ScriptDialogFormFields({
form,
formValues,
formErrors = {},
onValueChange,
}: {
form: ScriptDialogForm;
formValues: FormValues;
formErrors?: FormErrors;
onValueChange: (name: string, value: ScriptDialogFormValue) => void;
}) {
const renderFormField = (field: ScriptDialogField) => {
const inputId = getDialogFieldDomId(field.name);
const fieldError = formErrors[field.name];
const descriptionId = field.description ? `${inputId}-description` : undefined;
const errorId = fieldError ? `${inputId}-error` : undefined;
const describedBy = [descriptionId, errorId].filter(Boolean).join(' ') || undefined;
const fieldDescription = field.description ? (
<p id={descriptionId} className="text-xs text-muted-foreground">{field.description}</p>
) : null;
const inlineDescription = field.description ? (
<span id={descriptionId} className="mt-1 block text-xs text-muted-foreground">{field.description}</span>
) : null;
const errorMessage = fieldError ? (
<p id={errorId} className="text-xs text-destructive">{fieldError}</p>
) : null;
const inlineErrorMessage = fieldError ? (
<span id={errorId} className="mt-1 block text-xs text-destructive">{fieldError}</span>
) : null;
if (field.type === 'select') {
const labelId = `${inputId}-label`;
const selectedValue = String(formValues[field.name] ?? field.defaultValue);
const selectedOption = field.options.find((option) => option.value === selectedValue);
const selectedDescriptionId = selectedOption?.description ? `${inputId}-selected-description` : undefined;
const selectDescribedBy = [describedBy, selectedDescriptionId].filter(Boolean).join(' ') || undefined;
return (
<div key={field.name} className="space-y-2">
<Label id={labelId} htmlFor={inputId}>{field.label}</Label>
<Select
value={selectedValue}
onValueChange={(value) => onValueChange(field.name, value)}
>
<SelectTrigger
id={inputId}
aria-labelledby={labelId}
aria-describedby={selectDescribedBy}
aria-invalid={fieldError ? true : undefined}
className="w-full"
>
<SelectValue />
</SelectTrigger>
<SelectContent>
{field.options.map((option) => (
<SelectItem
key={option.value}
value={option.value}
disabled={option.disabled}
textValue={option.label}
>
<span className="flex min-w-0 flex-col">
<span className="truncate">{option.label}</span>
{option.description ? (
<span className="truncate text-xs text-muted-foreground">{option.description}</span>
) : null}
</span>
</SelectItem>
))}
</SelectContent>
</Select>
{selectedOption?.description ? (
<p id={selectedDescriptionId} className="text-xs text-muted-foreground">{selectedOption.description}</p>
) : null}
{fieldDescription}
{errorMessage}
</div>
);
}
if (field.type === 'radio') {
const selectedValue = String(formValues[field.name] ?? field.defaultValue);
return (
<fieldset
key={field.name}
className="space-y-2"
aria-describedby={describedBy}
aria-invalid={fieldError ? true : undefined}
>
<legend className="text-sm font-medium leading-5">{field.label}</legend>
{fieldDescription}
<div className="space-y-2">
{field.options.map((option, index) => {
const optionInputId = `${inputId}-${index}`;
return (
<label
key={option.value}
htmlFor={optionInputId}
className="flex items-start gap-2 rounded-md border border-border/60 px-3 py-2 text-sm"
>
<input
id={optionInputId}
type="radio"
name={inputId}
value={option.value}
checked={selectedValue === option.value}
disabled={option.disabled}
onChange={(event) => onValueChange(field.name, event.target.value)}
className="mt-0.5 h-4 w-4 accent-primary"
/>
<span className="min-w-0">
<span className="block">{option.label}</span>
{option.description ? (
<span className="block text-xs text-muted-foreground">{option.description}</span>
) : null}
</span>
</label>
);
})}
</div>
{errorMessage}
</fieldset>
);
}
if (field.type === 'textarea') {
return (
<div key={field.name} className="space-y-2">
<Label htmlFor={inputId}>{field.label}</Label>
<Textarea
id={inputId}
value={String(formValues[field.name] ?? field.defaultValue)}
placeholder={field.placeholder}
onChange={(event) => onValueChange(field.name, event.target.value)}
aria-describedby={describedBy}
aria-invalid={fieldError ? true : undefined}
/>
{fieldDescription}
{errorMessage}
</div>
);
}
if (field.type === 'number') {
return (
<div key={field.name} className="space-y-2">
<Label htmlFor={inputId}>{field.label}</Label>
<Input
id={inputId}
type="number"
value={formValues[field.name] ?? field.defaultValue ?? ''}
placeholder={field.placeholder}
min={field.min}
max={field.max}
step={field.step}
onChange={(event) => onValueChange(field.name, event.target.value)}
aria-describedby={describedBy}
aria-invalid={fieldError ? true : undefined}
/>
{fieldDescription}
{errorMessage}
</div>
);
}
return (
<div key={field.name} className="space-y-2">
<label htmlFor={inputId} className="flex items-start gap-2 text-sm">
<input
id={inputId}
type="checkbox"
checked={Boolean(formValues[field.name] ?? field.defaultValue)}
onChange={(event) => onValueChange(field.name, event.target.checked)}
aria-describedby={describedBy}
aria-invalid={fieldError ? true : undefined}
className="mt-0.5 h-4 w-4 accent-primary"
/>
<span className="min-w-0">
<span className="block font-medium leading-5">{field.label}</span>
{inlineDescription}
{inlineErrorMessage}
</span>
</label>
</div>
);
};
return (
<div className="space-y-4">
{getVisibleDialogFormFields(form, formValues).map(renderFormField)}
</div>
);
}
export function ScriptDialogFormBody({
form,
formValues,
formErrors,
onValueChange,
}: {
form: ScriptDialogForm;
formValues: FormValues;
formErrors?: FormErrors;
onValueChange: (name: string, value: ScriptDialogFormValue) => void;
}) {
return (
<ScrollArea className="min-h-0 pr-3">
<ScriptDialogFormFields
form={form}
formValues={formValues}
formErrors={formErrors}
onValueChange={onValueChange}
/>
</ScrollArea>
);
}
export function ScriptDialogHost() {
const { t } = useI18n();
const [request, setRequest] = useState<ScriptDialogRequest | null>(null);
const [promptValue, setPromptValue] = useState('');
const [formValues, setFormValues] = useState<FormValues>({});
const [formErrors, setFormErrors] = useState<FormErrors>({});
useEffect(() => {
const dispose = netcattyBridge.get()?.onScriptDialogRequest?.((payload) => {
setRequest(payload);
setPromptValue(payload.defaultValue ?? '');
setFormValues(getInitialFormValues(payload));
setFormErrors({});
});
return dispose;
}, []);
const respond = useCallback(async (value?: unknown, cancelled = false) => {
if (!request) return;
await netcattyBridge.get()?.scriptDialogResponse?.(request.requestId, value, cancelled);
setRequest(null);
}, [request]);
if (!request) return null;
const form = request.type === 'form' ? request.form : undefined;
const dialogTitle = request.type === 'waitForTimeout'
? t('scripts.dialog.waitForTimeoutTitle')
: form?.title || t('scripts.dialog.title');
const message = form?.message ?? request.message;
const setFormValue = (name: string, value: ScriptDialogFormValue) => {
setFormValues((current) => applyFormValue(current, name, value));
setFormErrors((current) => {
if (!current[name]) return current;
const { [name]: _removed, ...rest } = current;
return rest;
});
};
const submitForm = () => {
if (!form) return;
const errors = validateDialogFormValues(form, formValues, {
required: t('scripts.dialog.required'),
numberInvalid: t('scripts.dialog.numberInvalid'),
numberMin: (min) => t('scripts.dialog.numberMin', { min }),
numberMax: (max) => t('scripts.dialog.numberMax', { max }),
numberStep: (step) => t('scripts.dialog.numberStep', { step }),
});
if (Object.keys(errors).length > 0) {
setFormErrors(errors);
return;
}
const submitValues = normalizeDialogFormSubmitValues(form, formValues);
void respond(submitValues);
};
return (
<Dialog open onOpenChange={(open) => {
if (!open) {
void respond(request.type === 'waitForTimeout' ? 'abort' : undefined, true);
}
}}
>
<DialogContent className={form ? 'max-h-[85vh] grid-rows-[auto_minmax(0,1fr)_auto] overflow-hidden' : undefined}>
<DialogHeader>
<DialogTitle>{dialogTitle}</DialogTitle>
{message ? <DialogDescription>{message}</DialogDescription> : null}
</DialogHeader>
{request.type === 'prompt' ? (
<Input
type={request.sensitive ? 'password' : 'text'}
value={promptValue}
onChange={(event) => setPromptValue(event.target.value)}
autoFocus
/>
) : null}
{form ? (
<ScriptDialogFormBody
form={form}
formValues={formValues}
formErrors={formErrors}
onValueChange={setFormValue}
/>
) : null}
<DialogFooter>
{request.type === 'waitForTimeout' ? (
<>
<Button variant="outline" onClick={() => void respond('abort')}>
{t('scripts.dialog.abort')}
</Button>
<Button variant="secondary" onClick={() => void respond('skip')}>
{t('scripts.dialog.skip')}
</Button>
<Button onClick={() => void respond('retry')}>
{t('scripts.dialog.retry')}
</Button>
</>
) : request.type === 'confirm' ? (
<>
<Button variant="outline" onClick={() => void respond(false)}>{t('common.cancel')}</Button>
<Button onClick={() => void respond(true)}>{t('scripts.dialog.ok')}</Button>
</>
) : request.type === 'prompt' ? (
<>
<Button variant="outline" onClick={() => void respond(undefined, true)}>{t('common.cancel')}</Button>
<Button onClick={() => void respond(promptValue)}>{t('scripts.dialog.ok')}</Button>
</>
) : request.type === 'form' ? (
<>
<Button variant="outline" onClick={() => void respond(undefined, true)}>
{form?.cancelLabel || t('common.cancel')}
</Button>
<Button onClick={submitForm}>
{form?.submitLabel || t('scripts.dialog.ok')}
</Button>
</>
) : (
<Button onClick={() => void respond(undefined)}>{t('scripts.dialog.ok')}</Button>
)}
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,224 @@
import { Loader2, Play, X } from 'lucide-react';
import React, { useCallback, useMemo, useState } from 'react';
import { useI18n } from '@/application/i18n/I18nProvider';
import type { Host, Snippet } from '@/domain/models';
import { DEFAULT_SCRIPT_TEMPLATE } from '@/domain/snippetScript.ts';
import { scheduleWindowInputFocus } from '@/application/state/windowInputFocus';
import { SelectHostDialog } from '@/components/SelectHostDialog';
import { SelectGroupDialog } from '@/components/SelectGroupDialog';
import { ScriptCodeEditor } from './ScriptCodeEditor';
import { ScriptMetaFields } from './ScriptMetaFields';
import { SnippetTargetsSection } from '@/components/snippets/SnippetTargetsSection';
import { resolveSnippetTargetGroupsForSave } from '@/domain/snippetTargets.ts';
import { Button } from '@/components/ui/button';
import { Dialog, DialogContent, DialogTitle } from '@/components/ui/dialog';
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
export interface ScriptEditorModalProps {
open: boolean;
onClose: () => void;
snippet: Snippet;
onChange: (snippet: Snippet) => void;
onSave?: () => void;
onRun?: () => void;
canRun?: boolean;
targetHosts: Host[];
hosts: Host[];
customGroups?: string[];
selectedHostIds: string[];
onSelectHost: (host: Host) => void;
onSelectionChange?: (selectedHostIds: string[]) => void;
selectedGroupPaths?: string[];
onGroupSelectionChange?: (selectedGroupPaths: string[]) => void;
targetsAllHosts?: boolean;
onTargetsAllHostsChange?: (checked: boolean) => void;
}
function countLines(content: string): number {
if (!content) return 1;
let lines = 1;
for (let i = 0; i < content.length; i += 1) {
if (content.charCodeAt(i) === 10) lines += 1;
}
return lines;
}
export const ScriptEditorModal: React.FC<ScriptEditorModalProps> = ({
open,
onClose,
snippet,
onChange,
onSave,
onRun,
canRun = false,
targetHosts,
hosts,
customGroups = [],
selectedHostIds,
onSelectHost,
onSelectionChange,
selectedGroupPaths = [],
onGroupSelectionChange,
targetsAllHosts = false,
onTargetsAllHostsChange,
}) => {
const { t } = useI18n();
const [targetPickerOpen, setTargetPickerOpen] = useState(false);
const [groupPickerOpen, setGroupPickerOpen] = useState(false);
const handleOpenChange = useCallback((isOpen: boolean) => {
if (!isOpen) {
onClose();
scheduleWindowInputFocus();
}
}, [onClose]);
const handleClose = useCallback(() => {
onClose();
scheduleWindowInputFocus();
}, [onClose]);
const handleSave = useCallback(() => {
onSave?.();
onClose();
scheduleWindowInputFocus();
}, [onClose, onSave]);
const handleTargetsConfirm = useCallback(() => {
onChange({
...snippet,
targets: selectedHostIds,
targetGroups: resolveSnippetTargetGroupsForSave(snippet, selectedGroupPaths),
targetsAllHosts: undefined,
});
}, [onChange, selectedGroupPaths, selectedHostIds, snippet]);
const language = 'javascript';
const editorValue = snippet.command || DEFAULT_SCRIPT_TEMPLATE;
const title = snippet.label?.trim() || t('scripts.editor.modalTitle');
const lineCount = useMemo(() => countLines(editorValue), [editorValue]);
return (
<>
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogContent
className="max-w-5xl h-[85vh] flex flex-col p-0 gap-0 overflow-hidden"
hideCloseButton
>
<DialogTitle className="sr-only">{title}</DialogTitle>
<div className="h-full flex flex-col min-h-0">
<div className="h-9 px-3 py-1.5 border-b border-border/60 flex-shrink-0">
<div className="flex h-full items-center justify-between gap-3">
<span className="truncate text-sm font-semibold leading-5">{title}</span>
<div className="flex h-6 items-center gap-1.5">
<Button
variant="outline"
size="sm"
className="h-6 px-2 text-xs"
onClick={handleSave}
>
{t('scripts.actions.save')}
</Button>
{onRun ? (
<Tooltip>
<TooltipTrigger asChild>
<Button
size="sm"
className="h-6 px-2 text-xs gap-1"
onClick={onRun}
disabled={!canRun}
>
<Play size={12} />
{t('scripts.actions.runNow')}
</Button>
</TooltipTrigger>
<TooltipContent>{t('scripts.actions.runNowHint')}</TooltipContent>
</Tooltip>
) : null}
<Button
variant="ghost"
size="icon"
className="h-6 w-6"
onClick={handleClose}
>
<X size={13} />
</Button>
</div>
</div>
</div>
<div className="px-4 py-3 border-b border-border/40 flex-shrink-0 bg-muted/20 space-y-3">
<ScriptMetaFields snippet={snippet} onChange={onChange} layout="toolbar" />
<SnippetTargetsSection
variant="embedded"
t={t}
targetHosts={targetHosts}
targetGroups={selectedGroupPaths}
onEditTargets={() => {
if (!targetsAllHosts) setTargetPickerOpen(true);
}}
onEditGroups={onGroupSelectionChange ? () => {
if (!targetsAllHosts) setGroupPickerOpen(true);
} : undefined}
hint={t('scripts.targets.hint')}
targetsAllHosts={targetsAllHosts}
onTargetsAllHostsChange={onTargetsAllHostsChange}
/>
</div>
<div className="flex-1 min-h-0 relative">
{open ? (
<ScriptCodeEditor
value={editorValue}
onChange={(command) => onChange({ ...snippet, command })}
language={language}
fill
minimap
active={open}
/>
) : (
<div className="absolute inset-0 flex items-center justify-center bg-background">
<Loader2 size={24} className="animate-spin text-muted-foreground" />
</div>
)}
</div>
<div className="px-4 py-2 border-t border-border/60 flex items-center justify-between text-xs text-muted-foreground bg-muted/30 flex-shrink-0">
<span>JavaScript</span>
<span>{t('scripts.editor.lineCount', { count: lineCount })}</span>
</div>
</div>
</DialogContent>
</Dialog>
<SelectHostDialog
open={targetPickerOpen}
onOpenChange={setTargetPickerOpen}
title={t('snippets.targets.add')}
hosts={hosts}
customGroups={customGroups}
selectedHostIds={selectedHostIds}
multiSelect
onSelect={onSelectHost}
onSelectionChange={onSelectionChange}
onConfirm={handleTargetsConfirm}
/>
<SelectGroupDialog
open={groupPickerOpen}
onOpenChange={setGroupPickerOpen}
hosts={hosts}
customGroups={customGroups}
selectedGroupPaths={selectedGroupPaths}
onSelectionChange={(nextGroups) => {
onGroupSelectionChange?.(nextGroups);
onChange({
...snippet,
targetGroups: nextGroups,
targetsAllHosts: undefined,
});
}}
/>
</>
);
};

View File

@@ -0,0 +1,163 @@
import { Maximize2, Play } from 'lucide-react';
import React, { useCallback, useEffect, useRef, useState } from 'react';
import { useI18n } from '@/application/i18n/I18nProvider';
import type { Snippet } from '@/domain/models';
import { DEFAULT_SCRIPT_TEMPLATE } from '@/domain/snippetScript.ts';
import { STORAGE_KEY_SCRIPT_EDITOR_HEIGHT } from '@/infrastructure/config/storageKeys.ts';
import { localStorageAdapter } from '@/infrastructure/persistence/localStorageAdapter.ts';
import { ScriptCodeEditor } from './ScriptCodeEditor';
import { ScriptMetaFields } from './ScriptMetaFields';
import { Button } from '@/components/ui/button';
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
const DEFAULT_HEIGHT = 200;
const MIN_HEIGHT = 120;
const MAX_HEIGHT = 480;
function clampHeight(height: number): number {
return Math.max(MIN_HEIGHT, Math.min(MAX_HEIGHT, height));
}
function readStoredHeight(): number {
const stored = localStorageAdapter.readNumber(STORAGE_KEY_SCRIPT_EDITOR_HEIGHT);
if (stored === null) return DEFAULT_HEIGHT;
return clampHeight(stored);
}
function countLines(content: string): number {
if (!content) return 1;
let lines = 1;
for (let i = 0; i < content.length; i += 1) {
if (content.charCodeAt(i) === 10) lines += 1;
}
return lines;
}
export interface ScriptEditorPanelProps {
snippet: Snippet;
onChange: (snippet: Snippet) => void;
onRun?: () => void;
canRun?: boolean;
onExpand?: () => void;
}
export const ScriptEditorPanel: React.FC<ScriptEditorPanelProps> = ({
snippet,
onChange,
onRun,
canRun = false,
onExpand,
}) => {
const { t } = useI18n();
const [height, setHeight] = useState(readStoredHeight);
const dragRef = useRef<{ startY: number; startHeight: number } | null>(null);
const heightRef = useRef(height);
heightRef.current = height;
const language = 'javascript';
const editorValue = snippet.command || DEFAULT_SCRIPT_TEMPLATE;
const lineCount = countLines(editorValue);
const handleResizeStart = useCallback((event: React.MouseEvent) => {
event.preventDefault();
dragRef.current = { startY: event.clientY, startHeight: heightRef.current };
document.body.style.cursor = 'ns-resize';
document.body.style.userSelect = 'none';
}, []);
useEffect(() => {
const onMove = (event: MouseEvent) => {
if (!dragRef.current) return;
const delta = event.clientY - dragRef.current.startY;
setHeight(clampHeight(dragRef.current.startHeight + delta));
};
const onUp = () => {
if (dragRef.current) {
localStorageAdapter.writeNumber(STORAGE_KEY_SCRIPT_EDITOR_HEIGHT, heightRef.current);
}
dragRef.current = null;
document.body.style.cursor = '';
document.body.style.userSelect = '';
};
window.addEventListener('mousemove', onMove);
window.addEventListener('mouseup', onUp);
return () => {
window.removeEventListener('mousemove', onMove);
window.removeEventListener('mouseup', onUp);
document.body.style.cursor = '';
document.body.style.userSelect = '';
};
}, []);
return (
<div className="space-y-4">
<ScriptMetaFields snippet={snippet} onChange={onChange} layout="stack" />
<div className="space-y-1.5">
<div className="flex items-center justify-between gap-2 min-h-7">
<div className="flex items-baseline gap-2 min-w-0">
<p className="text-xs font-semibold text-muted-foreground shrink-0">{t('scripts.meta.code')}</p>
<span className="text-[10px] text-muted-foreground/80 truncate">
{t('scripts.editor.lineCount', { count: lineCount })}
</span>
</div>
<div className="flex items-center gap-1 shrink-0">
{onRun ? (
<Tooltip>
<TooltipTrigger asChild>
<Button
type="button"
variant="ghost"
size="sm"
className="h-7 px-2 text-xs gap-1 text-muted-foreground hover:text-foreground"
onClick={onRun}
disabled={!canRun}
>
<Play size={13} />
{t('scripts.actions.runNow')}
</Button>
</TooltipTrigger>
<TooltipContent>{t('scripts.actions.runNowHint')}</TooltipContent>
</Tooltip>
) : null}
{onExpand ? (
<Tooltip>
<TooltipTrigger asChild>
<Button
type="button"
variant="ghost"
size="sm"
className="h-7 px-2 text-xs gap-1 text-muted-foreground hover:text-foreground"
onClick={onExpand}
>
<Maximize2 size={13} />
{t('scripts.actions.openEditor')}
</Button>
</TooltipTrigger>
<TooltipContent>{t('scripts.actions.openEditorHint')}</TooltipContent>
</Tooltip>
) : null}
</div>
</div>
<div className="relative rounded-md border border-border/60 overflow-hidden bg-background">
<ScriptCodeEditor
value={editorValue}
onChange={(command) => onChange({ ...snippet, command })}
language={language}
height={height}
/>
<div
role="separator"
aria-orientation="horizontal"
aria-label={t('scripts.editor.resize')}
className="absolute bottom-0 left-0 right-0 z-10 flex h-2.5 cursor-ns-resize items-center justify-center hover:bg-muted/30"
onMouseDown={handleResizeStart}
>
<div className="h-0.5 w-10 rounded-full bg-border/80" />
</div>
</div>
</div>
</div>
);
};

View File

@@ -0,0 +1,135 @@
import React, { useMemo } from 'react';
import { useI18n } from '@/application/i18n/I18nProvider';
import type { ScriptTrigger, Snippet } from '@/domain/models';
import { Input } from '@/components/ui/input';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Textarea } from '@/components/ui/textarea';
export interface ScriptMetaFieldsProps {
snippet: Snippet;
onChange: (snippet: Snippet) => void;
layout?: 'stack' | 'toolbar';
}
export const ScriptMetaFields: React.FC<ScriptMetaFieldsProps> = ({
snippet,
onChange,
layout = 'stack',
}) => {
const { t } = useI18n();
const triggerOptions = useMemo(() => ([
{ value: 'manual', label: t('scripts.trigger.manual') },
{ value: 'onConnect', label: t('scripts.trigger.onConnect') },
{ value: 'onOutput', label: t('scripts.trigger.onOutput') },
]), [t]);
if (layout === 'toolbar') {
return (
<div className="flex flex-col gap-2 shrink-0">
<div className="grid grid-cols-1 sm:grid-cols-[minmax(0,1fr)_148px] gap-2 items-end">
<div className="min-w-0 space-y-1">
<label className="text-[11px] font-medium text-muted-foreground">{t('scripts.meta.name')}</label>
<Input
value={snippet.label}
onChange={(event) => onChange({ ...snippet, label: event.target.value })}
className="h-8"
/>
</div>
<div className="space-y-1">
<label className="text-[11px] font-medium text-muted-foreground">{t('scripts.meta.trigger')}</label>
<Select
value={snippet.trigger || 'manual'}
onValueChange={(value) => onChange({
...snippet,
trigger: value as ScriptTrigger,
})}
>
<SelectTrigger className="h-8"><SelectValue /></SelectTrigger>
<SelectContent>
{triggerOptions.map((option) => (
<SelectItem key={option.value} value={option.value}>{option.label}</SelectItem>
))}
</SelectContent>
</Select>
</div>
{snippet.trigger === 'onOutput' ? (
<div className="col-span-full space-y-1">
<label className="text-[11px] font-medium text-muted-foreground">{t('scripts.meta.triggerPattern')}</label>
<Input
value={snippet.triggerPattern || ''}
onChange={(event) => onChange({ ...snippet, triggerPattern: event.target.value })}
placeholder="sudo.*password"
className="h-8 font-mono text-xs"
/>
<p className="text-[10px] text-muted-foreground leading-relaxed">{t('scripts.trigger.onOutputHint')}</p>
</div>
) : null}
</div>
<div className="space-y-1">
<label className="text-[11px] font-medium text-muted-foreground">{t('scripts.meta.description')}</label>
<Input
value={snippet.description || ''}
onChange={(event) => onChange({ ...snippet, description: event.target.value })}
className="h-8"
placeholder={t('scripts.meta.descriptionPlaceholder')}
/>
</div>
</div>
);
}
return (
<div className="space-y-3">
<div className="space-y-1.5">
<label className="text-xs font-semibold text-muted-foreground">{t('scripts.meta.name')}</label>
<Input
value={snippet.label}
onChange={(event) => onChange({ ...snippet, label: event.target.value })}
className="h-9"
/>
</div>
<div className="space-y-1.5">
<label className="text-xs font-semibold text-muted-foreground">{t('scripts.meta.description')}</label>
<Textarea
value={snippet.description || ''}
onChange={(event) => onChange({ ...snippet, description: event.target.value })}
rows={2}
className="min-h-0 resize-none"
placeholder={t('scripts.meta.descriptionPlaceholder')}
/>
</div>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1.5">
<label className="text-xs font-semibold text-muted-foreground">{t('scripts.meta.trigger')}</label>
<Select
value={snippet.trigger || 'manual'}
onValueChange={(value) => onChange({
...snippet,
trigger: value as ScriptTrigger,
})}
>
<SelectTrigger className="h-9"><SelectValue /></SelectTrigger>
<SelectContent>
{triggerOptions.map((option) => (
<SelectItem key={option.value} value={option.value}>{option.label}</SelectItem>
))}
</SelectContent>
</Select>
</div>
{snippet.trigger === 'onOutput' ? (
<div className="space-y-1.5 col-span-2">
<label className="text-xs font-semibold text-muted-foreground">{t('scripts.meta.triggerPattern')}</label>
<Input
value={snippet.triggerPattern || ''}
onChange={(event) => onChange({ ...snippet, triggerPattern: event.target.value })}
placeholder="sudo.*password"
className="h-9 font-mono text-xs"
/>
<p className="text-[11px] text-muted-foreground leading-relaxed">{t('scripts.trigger.onOutputHint')}</p>
</div>
) : null}
</div>
</div>
);
};

View File

@@ -0,0 +1,102 @@
import { CircleHelp } from 'lucide-react';
import React, { useState } from 'react';
import { useI18n } from '@/application/i18n/I18nProvider';
import { cn } from '@/lib/utils';
import { Button } from '@/components/ui/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
const HELP_STEP_KEYS = [
'scripts.recording.helpStep1',
'scripts.recording.helpStep2',
'scripts.recording.helpStep3',
'scripts.recording.helpStep4',
'scripts.recording.helpStep5',
] as const;
const HELP_TIP_KEYS = [
'scripts.recording.helpTip1',
'scripts.recording.helpTip2',
'scripts.recording.helpTip3',
'scripts.recording.helpTip4',
] as const;
export interface ScriptRecordingHelpDialogProps {
triggerClassName?: string;
}
export const ScriptRecordingHelpDialog: React.FC<ScriptRecordingHelpDialogProps> = ({
triggerClassName,
}) => {
const { t } = useI18n();
const [open, setOpen] = useState(false);
return (
<>
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
onClick={() => setOpen(true)}
aria-label={t('scripts.recording.helpTitle')}
className={triggerClassName ?? 'shrink-0 h-8 w-8 flex items-center justify-center rounded-md text-muted-foreground hover:text-foreground hover:bg-muted/60 transition-colors'}
>
<CircleHelp size={15} />
</button>
</TooltipTrigger>
<TooltipContent side="top">{t('scripts.recording.helpTitle')}</TooltipContent>
</Tooltip>
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent className="max-w-lg">
<DialogHeader>
<DialogTitle>{t('scripts.recording.helpTitle')}</DialogTitle>
<DialogDescription>{t('scripts.recording.helpIntro')}</DialogDescription>
</DialogHeader>
<div className="space-y-4 text-sm">
<ol className="list-none">
{HELP_STEP_KEYS.map((key, index) => (
<li
key={key}
className={cn(
'flex items-start gap-3 py-3',
index < HELP_STEP_KEYS.length - 1 && 'border-b border-border/50',
)}
>
<span className="shrink-0 flex h-6 w-6 items-center justify-center rounded-full bg-primary/10 text-primary text-xs font-semibold tabular-nums leading-none mt-px">
{index + 1}
</span>
<span className="min-w-0 flex-1 text-foreground text-sm leading-relaxed">{t(key)}</span>
</li>
))}
</ol>
<div className="rounded-md border border-border/60 bg-muted/30 px-3 py-2.5 space-y-2">
<p className="text-xs font-semibold text-muted-foreground">{t('scripts.recording.helpTipsTitle')}</p>
<ul className="space-y-1.5 text-xs text-muted-foreground leading-relaxed">
{HELP_TIP_KEYS.map((key) => (
<li key={key} className="flex gap-2">
<span className="shrink-0"></span>
<span>{t(key)}</span>
</li>
))}
</ul>
</div>
</div>
<DialogFooter>
<Button onClick={() => setOpen(false)}>{t('common.close')}</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
);
};

View File

@@ -0,0 +1,171 @@
import { Check, FileText, Loader2, Pause, Play, Square, X } from 'lucide-react';
import React, { useMemo, useState } from 'react';
import { useI18n } from '@/application/i18n/I18nProvider';
import type { ScriptRun } from '@/types/global/netcatty-bridge-script.d.ts';
import { Button } from '@/components/ui/button';
import { cn } from '@/lib/utils.ts';
import { ScriptRunLogDialog } from './ScriptRunLogDialog';
export interface ScriptRunListProps {
runs: ScriptRun[];
onStop: (runId: string) => void;
onPause: (runId: string) => void;
onResume: (runId: string) => void;
}
function formatDuration(startedAt: number, endedAt?: number) {
const ms = (endedAt ?? Date.now()) - startedAt;
const seconds = Math.floor(ms / 1000);
const minutes = Math.floor(seconds / 60);
const rest = seconds % 60;
return `${String(minutes).padStart(2, '0')}:${String(rest).padStart(2, '0')}`;
}
function ScriptStatusIcon({ status }: { status: ScriptRun['status'] }) {
if (status === 'completed') {
return <Check size={14} className="text-emerald-500 shrink-0" aria-hidden />;
}
if (status === 'failed') {
return <X size={14} className="text-destructive shrink-0" aria-hidden />;
}
return <Loader2 size={14} className="animate-spin text-primary shrink-0" aria-hidden />;
}
function resolveRunMeta(run: ScriptRun, t: (key: string, params?: Record<string, string | number>) => string) {
const elapsed = formatDuration(run.startedAt, run.endedAt);
const parts: string[] = [t(`scripts.running.status.${run.status}`)];
if ((run.stepIndex ?? 0) > 0 || run.status === 'running' || run.status === 'paused') {
parts.push(t('scripts.running.operationsCount', { count: run.stepIndex ?? 0 }));
}
if (run.status === 'running' || run.status === 'paused') {
if (run.progressMode === 'determinate' && run.progressTotal) {
parts.push(t('scripts.running.determinateProgress', {
label: run.progressLabel || t('scripts.running.progressFallback'),
current: run.progressCurrent ?? 0,
total: run.progressTotal,
}));
} else if (run.activityLabel) {
parts.push(run.activityLabel);
} else if (run.waitingFor) {
parts.push(t('scripts.running.waitingFor', { pattern: run.waitingFor }));
}
}
parts.push(t('scripts.running.elapsed', { elapsed }));
return parts.join(' · ');
}
function sortRuns(runs: ScriptRun[]): ScriptRun[] {
const rank = (status: ScriptRun['status']) => {
if (status === 'running') return 0;
if (status === 'paused') return 1;
return 2;
};
return [...runs].sort((a, b) => {
const rankDiff = rank(a.status) - rank(b.status);
if (rankDiff !== 0) return rankDiff;
return b.startedAt - a.startedAt;
});
}
export const ScriptRunList: React.FC<ScriptRunListProps> = ({
runs,
onStop,
onPause,
onResume,
}) => {
const { t } = useI18n();
const [logRunId, setLogRunId] = useState<string | null>(null);
const sortedRuns = useMemo(() => sortRuns(runs), [runs]);
const logRun = sortedRuns.find((run) => run.runId === logRunId) ?? null;
if (runs.length === 0) {
return (
<div className="px-3 py-8 text-center text-sm text-muted-foreground">
{t('scripts.running.empty')}
</div>
);
}
return (
<>
<div className="py-1">
{sortedRuns.map((run) => {
const label = run.scriptLabel || run.scriptId || t('scripts.running.unnamed');
const isActive = run.status === 'running' || run.status === 'paused';
return (
<div
key={run.runId}
className="flex items-center gap-2 px-2 py-1.5 hover:bg-accent/40 transition-colors"
>
<ScriptStatusIcon status={run.status} />
<div className="min-w-0 flex-1">
<div className="truncate text-xs font-medium">{label}</div>
<div className="truncate text-[10px] text-muted-foreground">
{resolveRunMeta(run, t)}
</div>
{run.error ? (
<div className="truncate text-[10px] text-destructive">{run.error}</div>
) : null}
</div>
<div className="flex items-center gap-0.5 shrink-0">
<Button
size="icon"
variant="ghost"
className="h-7 w-7"
aria-label={t('scripts.running.viewLogs')}
onClick={() => setLogRunId(run.runId)}
>
<FileText size={14} />
</Button>
{run.status === 'running' ? (
<Button
size="icon"
variant="ghost"
className="h-7 w-7"
aria-label={t('scripts.running.pause')}
onClick={() => onPause(run.runId)}
>
<Pause size={14} />
</Button>
) : null}
{run.status === 'paused' ? (
<Button
size="icon"
variant="ghost"
className="h-7 w-7"
aria-label={t('scripts.running.resume')}
onClick={() => onResume(run.runId)}
>
<Play size={14} />
</Button>
) : null}
{isActive ? (
<Button
size="icon"
variant="ghost"
className={cn('h-7 w-7', isActive && 'text-destructive hover:text-destructive')}
aria-label={t('scripts.running.stop')}
onClick={() => onStop(run.runId)}
>
<Square size={14} />
</Button>
) : null}
</div>
</div>
);
})}
</div>
<ScriptRunLogDialog
run={logRun}
open={Boolean(logRun)}
onOpenChange={(open) => {
if (!open) setLogRunId(null);
}}
/>
</>
);
};

View File

@@ -0,0 +1,72 @@
import React from 'react';
import { useI18n } from '@/application/i18n/I18nProvider';
import type { ScriptRun } from '@/types/global/netcatty-bridge-script.d.ts';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { cn } from '@/lib/utils.ts';
export interface ScriptRunLogDialogProps {
run: ScriptRun | null;
open: boolean;
onOpenChange: (open: boolean) => void;
}
function formatDuration(startedAt: number, endedAt?: number) {
const ms = (endedAt ?? Date.now()) - startedAt;
const seconds = Math.floor(ms / 1000);
const minutes = Math.floor(seconds / 60);
const rest = seconds % 60;
return `${String(minutes).padStart(2, '0')}:${String(rest).padStart(2, '0')}`;
}
export const ScriptRunLogDialog: React.FC<ScriptRunLogDialogProps> = ({
run,
open,
onOpenChange,
}) => {
const { t } = useI18n();
if (!run) return null;
const label = run.scriptLabel || run.scriptId || t('scripts.running.unnamed');
const elapsed = formatDuration(run.startedAt, run.endedAt);
const statusLabel = t(`scripts.running.status.${run.status}`);
const logText = run.logs.map((entry) => entry.message).join('\n');
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-2xl">
<DialogHeader>
<DialogTitle>{t('scripts.running.logTitle', { name: label })}</DialogTitle>
</DialogHeader>
<div className="space-y-3">
<div className="flex flex-wrap items-center gap-x-2 gap-y-1 text-xs text-muted-foreground">
<span>{statusLabel}</span>
<span aria-hidden>·</span>
<span>{t('scripts.running.operationsCount', { count: run.stepIndex ?? 0 })}</span>
<span aria-hidden>·</span>
<span>{t('scripts.running.elapsed', { elapsed })}</span>
</div>
{run.error ? (
<div className="text-xs text-destructive">{run.error}</div>
) : null}
{run.waitingFor ? (
<div className="text-xs text-muted-foreground">
{t('scripts.running.waitingFor', { pattern: run.waitingFor })}
</div>
) : null}
<pre className={cn(
'text-xs bg-secondary/40 rounded-md p-3 max-h-[min(60vh,420px)] overflow-auto whitespace-pre-wrap font-mono leading-relaxed',
!logText && 'text-muted-foreground italic',
)}
>
{logText || t('scripts.running.logEmpty')}
</pre>
</div>
</DialogContent>
</Dialog>
);
};

View File

@@ -0,0 +1,95 @@
import React, { useMemo, useState } from 'react';
import { useI18n } from '@/application/i18n/I18nProvider';
import { Button } from '@/components/ui/button';
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import { Input } from '@/components/ui/input';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
const ROOT_PACKAGE_VALUE = '__root__';
function toSelectValue(packagePath: string): string {
return packagePath || ROOT_PACKAGE_VALUE;
}
function fromSelectValue(value: string): string {
return value === ROOT_PACKAGE_VALUE ? '' : value;
}
export interface ScriptSaveRecordingDialogProps {
open: boolean;
code: string;
packages: string[];
defaultName?: string;
onClose: () => void;
onSave: (payload: { name: string; packagePath: string; code: string; editAfterSave: boolean }) => void;
}
export const ScriptSaveRecordingDialog: React.FC<ScriptSaveRecordingDialogProps> = ({
open,
code,
packages,
defaultName,
onClose,
onSave,
}) => {
const { t } = useI18n();
const [name, setName] = useState(defaultName || '');
const [packageSelectValue, setPackageSelectValue] = useState(ROOT_PACKAGE_VALUE);
const packageOptions = useMemo(() => {
const unique = Array.from(new Set(packages.filter(Boolean)));
return [ROOT_PACKAGE_VALUE, ...unique];
}, [packages]);
React.useEffect(() => {
if (!open) return;
setName(defaultName || '');
setPackageSelectValue(toSelectValue(packages[0] || ''));
}, [defaultName, open, packages]);
const handleSave = (editAfterSave: boolean) => {
onSave({
name,
packagePath: fromSelectValue(packageSelectValue),
code,
editAfterSave,
});
};
return (
<Dialog open={open} onOpenChange={(next) => { if (!next) onClose(); }}>
<DialogContent className="max-w-2xl">
<DialogHeader>
<DialogTitle>{t('scripts.recording.saveTitle')}</DialogTitle>
</DialogHeader>
<div className="space-y-3">
<Input
value={name}
onChange={(event) => setName(event.target.value)}
placeholder={t('scripts.recording.namePlaceholder')}
/>
<Select value={packageSelectValue} onValueChange={setPackageSelectValue}>
<SelectTrigger><SelectValue placeholder={t('scripts.recording.packagePlaceholder')} /></SelectTrigger>
<SelectContent>
{packageOptions.map((value) => (
<SelectItem key={value} value={value}>
{value === ROOT_PACKAGE_VALUE ? t('scripts.recording.rootPackage') : value}
</SelectItem>
))}
</SelectContent>
</Select>
<pre className="text-xs bg-secondary/40 rounded p-3 max-h-48 overflow-auto whitespace-pre-wrap">{code}</pre>
</div>
<DialogFooter>
<Button variant="outline" onClick={onClose}>{t('common.cancel')}</Button>
<Button variant="outline" onClick={() => handleSave(false)}>
{t('scripts.recording.save')}
</Button>
<Button onClick={() => handleSave(true)}>
{t('scripts.recording.saveAndEdit')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
};