[Init] Initial commit - NetMesh terminal manager
Some checks failed
build-packages / resolve bundled mosh-client (push) Has been cancelled
build-packages / resolve bundled et-client (push) Has been cancelled
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
build-packages / update Nix release metadata (push) Has been cancelled
build-packages / bump homebrew tap (push) Has been cancelled
test / lint-and-test (push) Has been cancelled
AI automation / Route event (push) Has been cancelled
AI automation / Hand reopened issue to maintainers (push) Has been cancelled
AI automation / Clean source issue state (push) Has been cancelled
AI automation / Reconcile handoffs (push) Has been cancelled
AI automation / Classify issue (push) Has been cancelled
AI automation / Claude Code smoke (push) Has been cancelled
AI automation / Review issue follow-up (push) Has been cancelled
AI automation / Publish issue follow-up (push) Has been cancelled
AI automation / Implement with Claude Code (push) Has been cancelled
AI automation / Publish implement PR (push) Has been cancelled
AI automation / Continue queued issue comments (push) Has been cancelled
AI automation / Codex review loop (push) Has been cancelled
AI automation / Publish Codex fix (push) Has been cancelled
AI automation / Clear Codex dispatch marker (push) Has been cancelled
AI automation / Own PR re-request Codex (push) Has been cancelled
AI automation / External PR re-request Codex (push) Has been cancelled
AI automation / Poll Codex reaction / retry (push) Has been cancelled
build-et-binaries / build-linux-x64 (push) Has been cancelled
build-et-binaries / build-linux-arm64 (push) Has been cancelled
build-et-binaries / build-macos-universal (push) Has been cancelled
build-et-binaries / build-windows-x64 (push) Has been cancelled
build-et-binaries / release (push) Has been cancelled
Some checks failed
build-packages / resolve bundled mosh-client (push) Has been cancelled
build-packages / resolve bundled et-client (push) Has been cancelled
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
build-packages / update Nix release metadata (push) Has been cancelled
build-packages / bump homebrew tap (push) Has been cancelled
test / lint-and-test (push) Has been cancelled
AI automation / Route event (push) Has been cancelled
AI automation / Hand reopened issue to maintainers (push) Has been cancelled
AI automation / Clean source issue state (push) Has been cancelled
AI automation / Reconcile handoffs (push) Has been cancelled
AI automation / Classify issue (push) Has been cancelled
AI automation / Claude Code smoke (push) Has been cancelled
AI automation / Review issue follow-up (push) Has been cancelled
AI automation / Publish issue follow-up (push) Has been cancelled
AI automation / Implement with Claude Code (push) Has been cancelled
AI automation / Publish implement PR (push) Has been cancelled
AI automation / Continue queued issue comments (push) Has been cancelled
AI automation / Codex review loop (push) Has been cancelled
AI automation / Publish Codex fix (push) Has been cancelled
AI automation / Clear Codex dispatch marker (push) Has been cancelled
AI automation / Own PR re-request Codex (push) Has been cancelled
AI automation / External PR re-request Codex (push) Has been cancelled
AI automation / Poll Codex reaction / retry (push) Has been cancelled
build-et-binaries / build-linux-x64 (push) Has been cancelled
build-et-binaries / build-linux-arm64 (push) Has been cancelled
build-et-binaries / build-macos-universal (push) Has been cancelled
build-et-binaries / build-windows-x64 (push) Has been cancelled
build-et-binaries / release (push) Has been cancelled
This commit is contained in:
290
components/scripts/QuickScriptEditorDialog.tsx
Normal file
290
components/scripts/QuickScriptEditorDialog.tsx
Normal 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}
|
||||
/>
|
||||
);
|
||||
};
|
||||
34
components/scripts/ScriptAutomationRoot.tsx
Normal file
34
components/scripts/ScriptAutomationRoot.tsx
Normal 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 />;
|
||||
}
|
||||
199
components/scripts/ScriptCodeEditor.tsx
Normal file
199
components/scripts/ScriptCodeEditor.tsx
Normal 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';
|
||||
529
components/scripts/ScriptDialogHost.test.tsx
Normal file
529
components/scripts/ScriptDialogHost.test.tsx
Normal 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/);
|
||||
});
|
||||
550
components/scripts/ScriptDialogHost.tsx
Normal file
550
components/scripts/ScriptDialogHost.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
224
components/scripts/ScriptEditorModal.tsx
Normal file
224
components/scripts/ScriptEditorModal.tsx
Normal 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,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
163
components/scripts/ScriptEditorPanel.tsx
Normal file
163
components/scripts/ScriptEditorPanel.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
135
components/scripts/ScriptMetaFields.tsx
Normal file
135
components/scripts/ScriptMetaFields.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
102
components/scripts/ScriptRecordingHelpDialog.tsx
Normal file
102
components/scripts/ScriptRecordingHelpDialog.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
171
components/scripts/ScriptRunList.tsx
Normal file
171
components/scripts/ScriptRunList.tsx
Normal 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);
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
72
components/scripts/ScriptRunLogDialog.tsx
Normal file
72
components/scripts/ScriptRunLogDialog.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
95
components/scripts/ScriptSaveRecordingDialog.tsx
Normal file
95
components/scripts/ScriptSaveRecordingDialog.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user