diff --git a/App.tsx b/App.tsx index 92d5e96c2..cb89ff92e 100755 --- a/App.tsx +++ b/App.tsx @@ -66,8 +66,10 @@ import { getHostSearchMatch } from './lib/searchMatcher'; import { useDiscoveredShells, resolveShellSetting } from './lib/useDiscoveredShells'; import { Host, HostProtocol, KnownHost, SerialConfig, Snippet, SSHKey, TerminalSession } from './types'; import { resolveSnippetCommand } from './components/SnippetExecutionProvider'; -import { AppView } from './application/app/AppView'; +import { isScriptSnippet } from './domain/snippetScript.ts'; +import { ScriptAutomationRoot } from './components/scripts/ScriptAutomationRoot'; import { AppActiveTabChrome } from './application/app/AppActiveTabChrome'; +import { AppView } from './application/app/AppView'; import { useAppStartupEffects } from './application/app/useAppStartupEffects'; import { LogViewWrapper, SftpViewMount, TerminalLayerMount, VaultViewContainer } from './application/app/AppMounts'; import { handleTrayJumpToSessionImpl, handleTrayTogglePortForwardImpl, handleTrayPanelConnectImpl, handleGlobalHotkeyKeyDownImpl, handleEscapeKeyDownImpl, handleKeyboardInteractiveSubmitImpl, handleKeyboardInteractiveCancelImpl, handlePassphraseSubmitImpl, handlePassphraseCancelImpl, handlePassphraseSkipImpl, createLocalTerminalWithCurrentShellImpl, splitSessionWithCurrentShellImpl, copySessionWithCurrentShellImpl, copySessionToNewWindowWithCurrentShellImpl, confirmIfBusyLocalTerminalImpl, closeTabsBatchImpl, executeHotkeyActionImpl, handleCreateLocalTerminalImpl, handleConnectToHostImpl, handleTerminalDataCaptureImpl, hasMultipleProtocolsImpl, handleHostConnectWithProtocolCheckImpl, handleProtocolSelectImpl, handleToggleThemeImpl, handleRootContextMenuImpl } from './application/app/AppHandlers'; @@ -284,6 +286,11 @@ function App({ settings }: { settings: SettingsState }) { const handleRunSnippet = useCallback( async (snippet: Snippet, targetHosts: Host[]) => { + if (targetHosts.length === 0) return; + if (isScriptSnippet(snippet)) { + runSnippet(snippet, targetHosts); + return; + } const command = await resolveSnippetCommand(snippet); if (command === null) return; runSnippet(snippet, targetHosts, command); @@ -1204,6 +1211,7 @@ function AppWithProviders() { + diff --git a/application/app/AppView.tsx b/application/app/AppView.tsx index 553e42aab..d2e7df8c5 100644 --- a/application/app/AppView.tsx +++ b/application/app/AppView.tsx @@ -8,6 +8,7 @@ import { useTerminalHostTreeLayoutWidth } from '../state/terminalHostTreeStore'; import { TopTabs } from '../../components/TopTabs'; import { VaultView } from '../../components/VaultView'; import { QuickAddSnippetDialog } from '../../components/QuickAddSnippetDialog'; +import { QuickScriptEditorDialog } from '../../components/scripts/QuickScriptEditorDialog'; import { AddToWorkspaceDialog } from '../../components/workspace/AddToWorkspaceDialog'; import { KeyboardInteractiveModal } from '../../components/KeyboardInteractiveModal'; import { PassphraseModal } from '../../components/PassphraseModal'; @@ -463,6 +464,21 @@ export function AppView({ ctx }: { ctx: AppViewContext }) { updateSnippetPackages(Array.from(new Set([...snippetPackages, pkg]))) } /> + updateSnippets([...snippets, snippet])} + onUpdateSnippet={(snippet) => + updateSnippets(snippets.map((s) => (s.id === snippet.id ? snippet : s))) + } + onCreatePackage={(pkg) => + updateSnippetPackages(Array.from(new Set([...snippetPackages, pkg]))) + } + onUpdateHosts={updateHosts} + onRunSnippet={runSnippet} + /> {/* Root-mounted AddToWorkspaceDialog — triggered by the focus-mode "+" button (mode='append') or QuickSwitcher's "New Workspace" diff --git a/application/i18n/locales/en.ts b/application/i18n/locales/en.ts index 9b903dc9e..078471470 100644 --- a/application/i18n/locales/en.ts +++ b/application/i18n/locales/en.ts @@ -4,6 +4,7 @@ import { enVaultMessages } from './en/vault'; import { enTerminalMessages } from './en/terminal'; import { enAiMessages } from './en/ai'; import { enSystemManagerMessages } from './en/systemManager'; +import { enScriptsMessages } from './en/scripts'; export type { Messages } from './types'; @@ -13,6 +14,7 @@ const en: Messages = { ...enTerminalMessages, ...enAiMessages, ...enSystemManagerMessages, + ...enScriptsMessages, }; export default en; diff --git a/application/i18n/locales/en/core.ts b/application/i18n/locales/en/core.ts index da9d7a000..96d8eba0c 100644 --- a/application/i18n/locales/en/core.ts +++ b/application/i18n/locales/en/core.ts @@ -647,7 +647,7 @@ export const enCoreMessages: Messages = { 'vault.nav.keychain': 'Keychain', 'vault.nav.proxies': 'Proxies', 'vault.nav.portForwarding': 'Port Forwarding', - 'vault.nav.snippets': 'Snippets', + 'vault.nav.snippets': 'Scripts', 'vault.nav.notes': 'Notes', 'vault.nav.knownHosts': 'Known Hosts', 'vault.nav.logs': 'Logs', diff --git a/application/i18n/locales/en/scripts.ts b/application/i18n/locales/en/scripts.ts new file mode 100644 index 000000000..321251766 --- /dev/null +++ b/application/i18n/locales/en/scripts.ts @@ -0,0 +1,123 @@ +export const enScriptsMessages = { + 'scripts.meta.name': 'Name', + 'scripts.meta.language': 'Language', + 'scripts.meta.description': 'Description', + 'scripts.meta.descriptionPlaceholder': 'Optional notes about this script', + 'scripts.meta.trigger': 'Trigger', + 'scripts.meta.triggerPattern': 'Output pattern (regex)', + 'scripts.meta.code': 'Script', + 'scripts.trigger.manual': 'Manual run', + 'scripts.trigger.onConnect': 'Run on connect', + 'scripts.trigger.onOutput': 'Run on output match', + 'scripts.trigger.onOutputHint': 'Fires when server output matches the pattern (user keystroke echo is ignored). With no target hosts configured, listens on the current connected session; with targets, only those hosts. Disabled in alternate-screen apps such as vim or htop. Suppressed while another script is running on this session; rechecked after it finishes. For in-flow waits, use nct.screen.waitFor in script code.', + 'scripts.actions.save': 'Save', + 'scripts.actions.runNow': 'Run now', + 'scripts.actions.openEditor': 'Open editor', + 'scripts.actions.openEditorHint': 'Edit script in a larger window', + 'scripts.editor.modalTitle': 'Script editor', + 'scripts.editor.modalSubtitle': 'Edit metadata and script code in a larger workspace.', + 'scripts.editor.lineCount': '{count} lines', + 'scripts.editor.resize': 'Resize editor', + 'scripts.targets.hint': 'Automatic triggers apply to selected hosts, or to all hosts when that option is enabled.', + 'scripts.targets.connectOrderHint': 'Run order for connect scripts is configured per host under Host details → Automation.', + 'scripts.actions.runNowHint': 'Run on selected targets, or on all connectable hosts when that option is enabled.', + 'scripts.actions.runParallel': 'Run on all tabs (parallel)', + 'scripts.actions.runSequential': 'Run on all tabs (sequential)', + 'scripts.actions.skippedConnectingSessions': '{count} tab(s) still connecting and were skipped', + 'scripts.actions.noRunnableHosts': 'No connectable hosts match this script\'s targets', + 'scripts.sidePanel.library': 'Library', + 'scripts.sidePanel.running': 'Running', + 'scripts.sidePanel.newScript': 'New script', + 'scripts.running.empty': 'No scripts are running in this session.', + 'scripts.running.unnamed': 'Untitled script', + 'scripts.running.status.running': 'Running', + 'scripts.running.status.paused': 'Paused', + 'scripts.running.status.completed': 'Completed', + 'scripts.running.status.failed': 'Failed', + 'scripts.running.waitingFor': 'Waiting for {pattern}', + 'scripts.running.waitingForLabel': 'Waiting for', + 'scripts.running.waitingForShellPrompt': 'shell prompt (# or $)', + 'scripts.running.lastSent': 'Sent: {command}', + 'scripts.recording.start': 'Start recording', + 'scripts.recording.active': 'Stop recording', + 'scripts.recording.startHint': 'Record actions in the focused terminal and generate nct script code', + 'scripts.recording.unavailableHint': 'Recording is not available here — use the scripts sidebar on the right', + 'scripts.recording.activeHint': 'Recording this terminal. Type commands as usual; click Stop or use the REC control in the toolbar to finish and save.', + 'scripts.recording.started': 'Recording started — operate in the terminal', + 'scripts.recording.noSession': 'Connect a terminal first (single tab or workspace)', + 'scripts.recording.alreadyActive': 'Another terminal is already recording', + 'scripts.recording.stop': 'Stop recording', + 'scripts.recording.pause': 'Pause recording', + 'scripts.recording.resume': 'Resume recording', + 'scripts.recording.saveTitle': 'Save recorded script', + 'scripts.recording.namePlaceholder': 'Script name', + 'scripts.recording.packagePlaceholder': 'Save to folder', + 'scripts.recording.rootPackage': 'Root', + 'scripts.recording.save': 'Save', + 'scripts.recording.saveAndEdit': 'Save and edit', + 'scripts.recording.helpTitle': 'How to record a script', + 'scripts.recording.helpIntro': 'Recording turns what you do in the terminal into a reusable automation script — useful for deploys, health checks, and other repetitive tasks.', + 'scripts.recording.helpStep1': 'Connect to a host. A standalone terminal tab or a terminal inside a workspace both work — open the scripts sidebar on the right.', + 'scripts.recording.helpStep2': 'Click Start recording. A red REC badge in the terminal toolbar means recording is active.', + 'scripts.recording.helpStep3': 'Type commands in the terminal as you normally would. Each Enter press is captured as one step.', + 'scripts.recording.helpStep4': 'When you are done, click Stop recording, or use the stop control next to REC in the terminal toolbar.', + 'scripts.recording.helpStep5': 'Name the script and save. Choose Save and edit if you want to fine-tune the generated code in the script editor.', + 'scripts.recording.helpTipsTitle': 'Tips', + 'scripts.recording.helpTip1': 'Pauses longer than 1 second between actions are recorded as wait time so playback is not too fast.', + 'scripts.recording.helpTip2': 'After each command, recording waits for a shell prompt (such as $ or #) before the next step.', + 'scripts.recording.helpTip3': 'Password input is marked sensitive and is not stored in plain text in the script.', + 'scripts.recording.helpTip4': 'For vim, menus, or other interactive flows, record the main commands first, then refine the script manually.', + 'scripts.dialog.title': 'Script', + 'scripts.dialog.ok': 'OK', + 'scripts.dialog.waitForTimeoutTitle': 'Wait timed out', + 'scripts.dialog.retry': 'Retry', + 'scripts.dialog.skip': 'Skip', + 'scripts.dialog.abort': 'Abort', + 'scripts.running.stepProgress': 'Step {current} / {total}', + 'scripts.running.determinateProgress': '{label} {current}/{total}', + 'scripts.running.progressFallback': 'Progress', + 'scripts.running.operationsCount': '{count} operations', + 'scripts.running.opsPrefix': '', + 'scripts.running.opsSuffix': ' operations', + 'scripts.running.elapsedLabel': 'Elapsed', + 'scripts.running.lastSentLabel': 'Sent:', + 'scripts.running.elapsed': '{elapsed}', + 'scripts.running.completedSummary': 'Completed · {count} operations · {elapsed}', + 'scripts.running.dismissHint': 'Tap close to dismiss', + 'scripts.running.dismiss': 'Close', + 'scripts.running.viewLogs': 'View logs', + 'scripts.running.logTitle': '{name} · Run log', + 'scripts.running.logEmpty': 'No log output yet', + 'scripts.running.pause': 'Pause', + 'scripts.running.resume': 'Resume', + 'scripts.running.stop': 'Stop', + 'scripts.recording.saved': 'Script saved', + 'scripts.recording.savedNamed': 'Saved "{name}"', + 'scripts.observer.blocked': 'Observer mode blocks scripts that write to the terminal.', + 'vault.section.scripts': 'Scripts', + 'vault.nav.scripts': 'Scripts', + 'snippets.action.newScript': 'New automation script', + 'hostDetails.section.automation': 'Automation', + 'hostDetails.automation.loginScript': 'Login script', + 'hostDetails.automation.loginScriptPlaceholder': 'Select a script', + 'hostDetails.automation.none': 'None', + 'hostDetails.automation.outputTriggers': 'Output triggers', + 'hostDetails.automation.addTrigger': 'Add trigger', + 'hostDetails.automation.triggerPatternPlaceholder': 'Regex pattern', + 'hostDetails.automation.linkedScripts': 'Linked scripts', + 'hostDetails.automation.linkedScriptsEmpty': 'No scripts linked to this host yet.', + 'hostDetails.automation.linkScriptPlaceholder': 'Link existing script…', + 'hostDetails.automation.unlink': 'Unlink', + 'hostDetails.automation.equivalenceHint': 'Links add this host to the script\'s target list in Scripts. Both views stay in sync.', + 'hostDetails.automation.queueHint': 'Scripts run in order when this host connects. Global scripts run first, then this queue.', + 'hostDetails.automation.globalScripts': 'Global connect scripts', + 'hostDetails.automation.globalScriptsHint': 'Runs first on every connect. Reorder in Scripts library sort order.', + 'hostDetails.automation.connectQueue': 'This host\'s run queue', + 'hostDetails.automation.connectQueueEmpty': 'No connect scripts queued for this host yet.', + 'hostDetails.automation.addToQueuePlaceholder': 'Add script to queue…', + 'hostDetails.automation.moveUp': 'Move up', + 'hostDetails.automation.moveDown': 'Move down', + 'hostDetails.automation.removeFromQueue': 'Remove from queue', + 'hostDetails.automation.dragHandle': 'Drag to reorder', + 'hostDetails.automation.queueDragHint': 'Drag items to reorder the connect queue.', +}; diff --git a/application/i18n/locales/en/terminal.ts b/application/i18n/locales/en/terminal.ts index f9573b299..ef42e3447 100644 --- a/application/i18n/locales/en/terminal.ts +++ b/application/i18n/locales/en/terminal.ts @@ -579,24 +579,30 @@ export const enTerminalMessages: Messages = { 'keychain.edit.exportToHost': 'Export to host', // Snippets - 'snippets.searchPlaceholder': 'Search snippets...', + 'snippets.searchPlaceholder': 'Search scripts...', 'snippets.action.newSnippet': 'New Snippet', - 'snippets.action.newPackage': 'New Package', + 'snippets.action.newPackage': 'New Script Package', 'snippets.action.import': 'Import', 'snippets.action.selectSnippets': 'Select snippets', 'snippets.panel.newTitle': 'New Snippet', 'snippets.panel.editTitle': 'Edit Snippet', + 'snippets.panel.newAutomationTitle': 'New Automation Script', + 'snippets.panel.editAutomationTitle': 'Edit Automation Script', + 'snippets.panel.resizeWidth': 'Resize panel width', 'snippets.field.description': 'Action description', 'snippets.field.descriptionPlaceholder': 'Example: check network load', - 'snippets.field.package': 'Add a Package', - 'snippets.field.packagePlaceholder': 'Select or create package', - 'snippets.field.createPackage': 'Create Package', + 'snippets.field.package': 'Add a script package', + 'snippets.field.packagePlaceholder': 'Select or create script package', + 'snippets.field.createPackage': 'Create Script Package', 'snippets.field.scriptRequired': 'Script *', 'snippets.scriptEditor.expand': 'Open in dialog', 'snippets.scriptEditor.resize': 'Resize editor height', 'snippets.scriptEditor.modalTitle': 'Edit script', 'snippets.targets.title': 'Targets', 'snippets.targets.add': 'Add targets', + 'snippets.targets.allHosts': 'Apply to all hosts', + 'snippets.targets.allHostsShort': 'All hosts', + 'snippets.targets.allHostsActive': 'Applies to every connectable host.', 'snippets.history.title': 'Shell History', 'snippets.history.subtitle': '{count} commands', 'snippets.history.emptyTitle': 'No shell history yet', @@ -609,15 +615,17 @@ export const enTerminalMessages: Messages = { 'snippets.history.time.minutesAgo': '{count}m ago', 'snippets.history.time.hoursAgo': '{count}h ago', 'snippets.history.time.daysAgo': '{count}d ago', - 'snippets.breadcrumb.allPackages': 'All packages', + 'snippets.breadcrumb.allPackages': 'All script packages', 'snippets.breadcrumb.separator': '›', - 'snippets.empty.title': 'Create snippet', - 'snippets.empty.desc': 'Save your most used commands as snippets to reuse them in one click.', + 'snippets.empty.title': 'Create scripts', + 'snippets.empty.desc': 'Save common commands as code snippets, or write automation scripts for repeatable operations.', 'snippets.search.noResults.title': 'No matches', - 'snippets.search.noResults.desc': 'No snippets or packages match "{query}". Try a different search term or clear the search to browse.', - 'snippets.section.packages': 'Packages', - 'snippets.section.snippets': 'Snippets', - 'snippets.package.count': '{count} snippet(s)', + 'snippets.search.noResults.desc': 'No scripts or script packages match "{query}". Try a different search term or clear the search to browse.', + 'snippets.section.packages': 'Script Packages', + 'snippets.section.snippets': 'Scripts', + 'snippets.kind.codeSnippet': 'Code snippet', + 'snippets.kind.automationScript': 'Automation script', + 'snippets.package.count': '{count} script(s)', 'snippets.commandFallback': 'Command', 'snippets.view.grid': 'Grid', 'snippets.view.list': 'List', @@ -626,7 +634,7 @@ export const enTerminalMessages: Messages = { 'snippets.selection.deselectAll': 'Deselect all', 'snippets.selection.exportSelected': 'Export selected ({count})', 'snippets.export.snippet': 'Export snippet', - 'snippets.export.package': 'Export package', + 'snippets.export.package': 'Export script package', 'snippets.export.toast.empty': 'No snippets to export.', 'snippets.export.toast.successTitle': 'Export ready', 'snippets.export.toast.success': 'Exported {count} snippet(s).', @@ -642,26 +650,26 @@ export const enTerminalMessages: Messages = { 'snippets.import.modal.chooseFile': 'Choose file', 'snippets.import.modal.downloadExamples': 'Download samples', 'snippets.import.modal.multipleFiles': '{count} files selected', - 'snippets.import.modal.parsedSummary': '{files} file(s), {total} snippet(s), {packages} package(s), {conflicts} duplicate command(s). Host bindings are ignored.', + 'snippets.import.modal.parsedSummary': '{files} file(s), {total} script(s), {packages} script package(s), {conflicts} duplicate command(s). Host bindings are ignored.', 'snippets.import.modal.confirm': 'Confirm import', 'snippets.import.conflict.title': 'Import snippets?', 'snippets.import.conflict.desc': '{file} contains {total} snippet(s). {conflicts} duplicate command(s) already exist.', 'snippets.import.conflict.hostBindingsNote': 'Host bindings are not imported or exported with snippets.', 'snippets.import.conflict.skip': 'Skip duplicates', 'snippets.import.conflict.overwrite': 'Overwrite duplicates', - 'snippets.packageDialog.title': 'New Package', + 'snippets.packageDialog.title': 'New Script Package', 'snippets.packageDialog.parent': 'Parent: {parent}', 'snippets.packageDialog.root': 'Root', 'snippets.packageDialog.placeholder': 'e.g. ops/maintenance', - 'snippets.packageDialog.hint': 'Use "/" to create nested packages.', + 'snippets.packageDialog.hint': 'Use "/" to create nested script packages.', // Snippets Rename Dialog - 'snippets.renameDialog.title': 'Rename Package', + 'snippets.renameDialog.title': 'Rename Script Package', 'snippets.renameDialog.currentPath': 'Current path: {path}', 'snippets.renameDialog.placeholder': 'Enter new name', - 'snippets.renameDialog.error.empty': 'Package name cannot be empty', - 'snippets.renameDialog.error.duplicate': 'A package with this name already exists', - 'snippets.renameDialog.error.invalidChars': 'Package name can only contain letters, numbers, hyphens, and underscores', + 'snippets.renameDialog.error.empty': 'Script package name cannot be empty', + 'snippets.renameDialog.error.duplicate': 'A script package with this name already exists', + 'snippets.renameDialog.error.invalidChars': 'Script package name can only contain letters, numbers, hyphens, and underscores', 'snippets.field.noAutoRun': 'Paste only (do not auto-execute)', // Snippet Shortkey diff --git a/application/i18n/locales/en/vault.ts b/application/i18n/locales/en/vault.ts index f988c3ce6..70a57fb4a 100644 --- a/application/i18n/locales/en/vault.ts +++ b/application/i18n/locales/en/vault.ts @@ -522,6 +522,7 @@ export const enVaultMessages: Messages = { 'hostDetails.icon.colorManual': 'Manual', 'hostDetails.icon.colorChoice': 'Color', 'hostDetails.icon.autoColorLabel': 'Current color', + 'hostDetails.icon.preview': 'Icon preview', 'hostDetails.icon.showLibrary': 'Show icon library', 'hostDetails.icon.hideLibrary': 'Hide icon library', 'hostDetails.icon.autoUsesDistro': 'Use Linux Distribution icon and selected color for this host.', diff --git a/application/i18n/locales/ru/terminal.ts b/application/i18n/locales/ru/terminal.ts index 9458b61bc..58fe84102 100644 --- a/application/i18n/locales/ru/terminal.ts +++ b/application/i18n/locales/ru/terminal.ts @@ -600,6 +600,7 @@ export const ruTerminalMessages: Messages = { 'snippets.action.selectSnippets': 'Выбрать сниппеты', 'snippets.panel.newTitle': 'Новый сниппет', 'snippets.panel.editTitle': 'Редактировать сниппет', + 'snippets.panel.resizeWidth': 'Изменить ширину панели', 'snippets.field.description': 'Описание действия', 'snippets.field.descriptionPlaceholder': 'Например: проверить сетевую нагрузку', 'snippets.field.package': 'Добавить пакет', diff --git a/application/i18n/locales/ru/vault.ts b/application/i18n/locales/ru/vault.ts index 2c0462f56..ab6c4e0e4 100644 --- a/application/i18n/locales/ru/vault.ts +++ b/application/i18n/locales/ru/vault.ts @@ -557,6 +557,7 @@ export const ruVaultMessages: Messages = { 'hostDetails.icon.colorManual': 'Вручную', 'hostDetails.icon.colorChoice': 'Цвет', 'hostDetails.icon.autoColorLabel': 'Текущий цвет', + 'hostDetails.icon.preview': 'Предпросмотр значка', 'hostDetails.icon.showLibrary': 'Показать библиотеку значков', 'hostDetails.icon.hideLibrary': 'Скрыть библиотеку значков', 'hostDetails.icon.autoUsesDistro': 'Использует значок дистрибутива Linux и выбранный цвет для этого хоста.', diff --git a/application/i18n/locales/zh-CN.ts b/application/i18n/locales/zh-CN.ts index 55e292265..65b269d30 100644 --- a/application/i18n/locales/zh-CN.ts +++ b/application/i18n/locales/zh-CN.ts @@ -4,6 +4,7 @@ import { zhCNVaultMessages } from './zh-CN/vault'; import { zhCNTerminalMessages } from './zh-CN/terminal'; import { zhCNAiMessages } from './zh-CN/ai'; import { zhCnSystemManagerMessages } from './zh-CN/systemManager'; +import { zhCNScriptsMessages } from './zh-CN/scripts'; export type { Messages } from './types'; @@ -13,6 +14,7 @@ const zhCN: Messages = { ...zhCNTerminalMessages, ...zhCNAiMessages, ...zhCnSystemManagerMessages, + ...zhCNScriptsMessages, }; export default zhCN; diff --git a/application/i18n/locales/zh-CN/core.ts b/application/i18n/locales/zh-CN/core.ts index 11c5315b5..9c64894f5 100644 --- a/application/i18n/locales/zh-CN/core.ts +++ b/application/i18n/locales/zh-CN/core.ts @@ -376,7 +376,7 @@ export const zhCNCoreMessages: Messages = { 'vault.nav.keychain': '钥匙串', 'vault.nav.proxies': '代理', 'vault.nav.portForwarding': '端口转发', - 'vault.nav.snippets': '代码片段', + 'vault.nav.snippets': '脚本', 'vault.nav.notes': '笔记', 'vault.nav.knownHosts': '已知主机', 'vault.nav.logs': '日志', diff --git a/application/i18n/locales/zh-CN/scripts.ts b/application/i18n/locales/zh-CN/scripts.ts new file mode 100644 index 000000000..d272647c5 --- /dev/null +++ b/application/i18n/locales/zh-CN/scripts.ts @@ -0,0 +1,123 @@ +export const zhCNScriptsMessages = { + 'scripts.meta.name': '名称', + 'scripts.meta.language': '语言', + 'scripts.meta.description': '描述', + 'scripts.meta.descriptionPlaceholder': '可选,脚本的简要说明', + 'scripts.meta.trigger': '触发方式', + 'scripts.meta.triggerPattern': '输出匹配(正则)', + 'scripts.meta.code': '脚本代码', + 'scripts.trigger.manual': '手动运行', + 'scripts.trigger.onConnect': '连接时运行', + 'scripts.trigger.onOutput': '输出匹配时运行', + 'scripts.trigger.onOutputHint': '仅在服务端输出匹配正则时触发(会忽略本地按键回显)。未配置目标主机时,对当前已连接会话生效;配置目标后仅监听对应主机。进入备用屏幕的应用(如 vim、htop)期间不会触发。本 session 有其他脚本运行期间会抑制;脚本结束后再检查 buffer。流程内等待请用 nct.screen.waitFor。', + 'scripts.actions.save': '保存', + 'scripts.actions.runNow': '立即运行', + 'scripts.actions.openEditor': '弹窗编辑', + 'scripts.actions.openEditorHint': '在更大的窗口中编辑脚本', + 'scripts.editor.modalTitle': '脚本编辑器', + 'scripts.editor.modalSubtitle': '在更大的工作区中编辑脚本元数据和代码。', + 'scripts.editor.lineCount': '{count} 行', + 'scripts.editor.resize': '调整编辑器高度', + 'scripts.targets.hint': '自动触发作用于所选主机;勾选「对所有主机生效」则作用于全部可连接主机。', + 'scripts.targets.connectOrderHint': '连接脚本的执行顺序请在主机详情 → 自动化 中调整。', + 'scripts.actions.runNowHint': '在所选目标主机上运行;勾选「对所有主机生效」时在所有可连接主机上运行。', + 'scripts.actions.runParallel': '在所有标签页并行运行', + 'scripts.actions.runSequential': '在所有标签页顺序运行', + 'scripts.actions.skippedConnectingSessions': '有 {count} 个标签仍在连接中,已跳过', + 'scripts.actions.noRunnableHosts': '没有与脚本目标匹配的可连接主机', + 'scripts.sidePanel.library': '脚本库', + 'scripts.sidePanel.running': '运行中', + 'scripts.sidePanel.newScript': '新建脚本', + 'scripts.running.empty': '当前会话没有正在运行的脚本。', + 'scripts.running.unnamed': '未命名脚本', + 'scripts.running.status.running': '运行中', + 'scripts.running.status.paused': '已暂停', + 'scripts.running.status.completed': '已完成', + 'scripts.running.status.failed': '失败', + 'scripts.running.waitingFor': '等待 {pattern}', + 'scripts.running.waitingForLabel': '等待', + 'scripts.running.waitingForShellPrompt': 'Shell 提示符(# 或 $)', + 'scripts.running.lastSent': '已发送:{command}', + 'scripts.recording.start': '开始录制', + 'scripts.recording.active': '停止录制', + 'scripts.recording.startHint': '在当前终端会话中录制操作,自动生成 nct 脚本代码', + 'scripts.recording.unavailableHint': '此入口不支持录制,请用终端右侧脚本侧栏', + 'scripts.recording.activeHint': '正在录制当前终端。在终端中正常输入命令;点「停止录制」或终端工具栏的 REC 结束并保存。', + 'scripts.recording.started': '已开始录制,请在终端中操作', + 'scripts.recording.noSession': '请先连接终端(单机标签或工作区均可)', + 'scripts.recording.alreadyActive': '已有其他终端正在录制', + 'scripts.recording.stop': '停止录制', + 'scripts.recording.pause': '暂停录制', + 'scripts.recording.resume': '继续录制', + 'scripts.recording.saveTitle': '保存录制的脚本', + 'scripts.recording.namePlaceholder': '脚本名称', + 'scripts.recording.packagePlaceholder': '保存到文件夹', + 'scripts.recording.rootPackage': '根目录', + 'scripts.recording.save': '保存', + 'scripts.recording.saveAndEdit': '保存并编辑', + 'scripts.recording.helpTitle': '如何录制脚本', + 'scripts.recording.helpIntro': '录制会把你在终端里的操作自动转成可重复运行的自动化脚本,适合部署、巡检等重复任务。', + 'scripts.recording.helpStep1': '连接主机。单机终端标签或工作区里的终端都可以,打开右侧脚本侧栏即可。', + 'scripts.recording.helpStep2': '点击「开始录制」。终端工具栏出现红色 REC 表示已开始。', + 'scripts.recording.helpStep3': '在终端里像平时一样输入命令,按 Enter 执行。每次回车都会记为一步。', + 'scripts.recording.helpStep4': '全部操作完成后,点击「停止录制」,或点终端工具栏 REC 旁的停止按钮。', + 'scripts.recording.helpStep5': '填写脚本名称并保存。需要微调时可选「保存并编辑」,在脚本编辑器里修改生成的代码。', + 'scripts.recording.helpTipsTitle': '小提示', + 'scripts.recording.helpTip1': '两次操作之间停顿超过 1 秒,会自动加入等待时间,避免回放太快。', + 'scripts.recording.helpTip2': '每条命令后会等待 shell 提示符(如 $ 或 #)出现,再记录下一步,减少乱序。', + 'scripts.recording.helpTip3': '密码输入会被标记为敏感内容,不会明文写入脚本。', + 'scripts.recording.helpTip4': 'vim、交互式菜单等复杂操作,建议先录主干命令,再在编辑器里手动补充。', + 'scripts.dialog.title': '脚本', + 'scripts.dialog.ok': '确定', + 'scripts.dialog.waitForTimeoutTitle': '等待超时', + 'scripts.dialog.retry': '重试', + 'scripts.dialog.skip': '跳过', + 'scripts.dialog.abort': '终止', + 'scripts.running.stepProgress': '步骤 {current} / {total}', + 'scripts.running.determinateProgress': '{label} {current}/{total}', + 'scripts.running.progressFallback': '进度', + 'scripts.running.operationsCount': '已执行 {count} 个操作', + 'scripts.running.opsPrefix': '已执行 ', + 'scripts.running.opsSuffix': ' 个操作', + 'scripts.running.elapsedLabel': '耗时', + 'scripts.running.lastSentLabel': '已发送:', + 'scripts.running.elapsed': '耗时 {elapsed}', + 'scripts.running.completedSummary': '已完成 · 共 {count} 个操作 · {elapsed}', + 'scripts.running.dismissHint': '点击关闭', + 'scripts.running.dismiss': '关闭', + 'scripts.running.viewLogs': '查看日志', + 'scripts.running.logTitle': '{name} · 运行日志', + 'scripts.running.logEmpty': '暂无日志输出', + 'scripts.running.pause': '暂停', + 'scripts.running.resume': '继续', + 'scripts.running.stop': '停止', + 'scripts.recording.saved': '脚本已保存', + 'scripts.recording.savedNamed': '已保存「{name}」', + 'scripts.observer.blocked': 'Observer 模式禁止执行会写入终端的脚本。', + 'vault.section.scripts': '脚本', + 'vault.nav.scripts': '脚本', + 'snippets.action.newScript': '新建自动化脚本', + 'hostDetails.section.automation': '自动化', + 'hostDetails.automation.loginScript': '登录脚本', + 'hostDetails.automation.loginScriptPlaceholder': '选择脚本', + 'hostDetails.automation.none': '无', + 'hostDetails.automation.outputTriggers': '输出触发器', + 'hostDetails.automation.addTrigger': '添加触发器', + 'hostDetails.automation.triggerPatternPlaceholder': '正则表达式', + 'hostDetails.automation.linkedScripts': '已关联脚本', + 'hostDetails.automation.linkedScriptsEmpty': '尚未关联任何脚本。', + 'hostDetails.automation.linkScriptPlaceholder': '关联已有脚本…', + 'hostDetails.automation.unlink': '取消关联', + 'hostDetails.automation.equivalenceHint': '此处关联会将本主机加入 Scripts 中该脚本的目标列表,两侧视图同步。', + 'hostDetails.automation.queueHint': '连接本主机时按顺序运行脚本。全局脚本最先执行,再执行下方队列。', + 'hostDetails.automation.globalScripts': '全局连接脚本', + 'hostDetails.automation.globalScriptsHint': '每次连接时最先运行。顺序在脚本库排序中调整。', + 'hostDetails.automation.connectQueue': '本主机运行队列', + 'hostDetails.automation.connectQueueEmpty': '尚未为本主机配置连接脚本队列。', + 'hostDetails.automation.addToQueuePlaceholder': '添加脚本到队列…', + 'hostDetails.automation.moveUp': '上移', + 'hostDetails.automation.moveDown': '下移', + 'hostDetails.automation.removeFromQueue': '从队列移除', + 'hostDetails.automation.dragHandle': '拖拽排序', + 'hostDetails.automation.queueDragHint': '拖动条目即可调整连接时的运行顺序。', +}; diff --git a/application/i18n/locales/zh-CN/terminal.ts b/application/i18n/locales/zh-CN/terminal.ts index 6675657b5..9d9c20b69 100644 --- a/application/i18n/locales/zh-CN/terminal.ts +++ b/application/i18n/locales/zh-CN/terminal.ts @@ -602,24 +602,30 @@ export const zhCNTerminalMessages: Messages = { 'keychain.edit.exportToHost': '导出到主机', // Snippets - 'snippets.searchPlaceholder': '搜索代码片段...', + 'snippets.searchPlaceholder': '搜索脚本...', 'snippets.action.newSnippet': '新建代码片段', - 'snippets.action.newPackage': '新建代码包', + 'snippets.action.newPackage': '新建脚本包', 'snippets.action.import': '导入', 'snippets.action.selectSnippets': '选择代码片段', 'snippets.panel.newTitle': '新建代码片段', 'snippets.panel.editTitle': '编辑代码片段', + 'snippets.panel.newAutomationTitle': '新建自动化脚本', + 'snippets.panel.editAutomationTitle': '编辑自动化脚本', + 'snippets.panel.resizeWidth': '调整面板宽度', 'snippets.field.description': '描述', 'snippets.field.descriptionPlaceholder': '例如:check network load', - 'snippets.field.package': '添加代码包', - 'snippets.field.packagePlaceholder': '选择或创建代码包', - 'snippets.field.createPackage': '创建代码包', + 'snippets.field.package': '添加脚本包', + 'snippets.field.packagePlaceholder': '选择或创建脚本包', + 'snippets.field.createPackage': '创建脚本包', 'snippets.field.scriptRequired': '脚本 *', 'snippets.scriptEditor.expand': '弹窗编辑', 'snippets.scriptEditor.resize': '调整编辑器高度', 'snippets.scriptEditor.modalTitle': '编辑脚本', 'snippets.targets.title': '目标主机', 'snippets.targets.add': '添加目标主机', + 'snippets.targets.allHosts': '对所有主机生效', + 'snippets.targets.allHostsShort': '所有主机', + 'snippets.targets.allHostsActive': '将作用于所有可连接的主机。', 'snippets.history.title': 'Shell 历史', 'snippets.history.subtitle': '{count} 条命令', 'snippets.history.emptyTitle': '暂无 Shell 历史', @@ -632,15 +638,17 @@ export const zhCNTerminalMessages: Messages = { 'snippets.history.time.minutesAgo': '{count} 分钟前', 'snippets.history.time.hoursAgo': '{count} 小时前', 'snippets.history.time.daysAgo': '{count} 天前', - 'snippets.breadcrumb.allPackages': '全部代码包', + 'snippets.breadcrumb.allPackages': '全部脚本包', 'snippets.breadcrumb.separator': '›', - 'snippets.empty.title': '创建代码片段', - 'snippets.empty.desc': '将常用命令保存为代码片段,一键复用。', + 'snippets.empty.title': '创建脚本', + 'snippets.empty.desc': '将常用命令保存为代码片段,或编写自动化脚本来处理重复运维任务。', 'snippets.search.noResults.title': '无匹配结果', - 'snippets.search.noResults.desc': '没有代码片段或代码包与"{query}"匹配。换一个关键字,或清除搜索进行浏览。', - 'snippets.section.packages': '代码包', - 'snippets.section.snippets': '代码片段', - 'snippets.package.count': '{count} 个代码片段', + 'snippets.search.noResults.desc': '没有脚本或脚本包与"{query}"匹配。换一个关键字,或清除搜索进行浏览。', + 'snippets.section.packages': '脚本包', + 'snippets.section.snippets': '脚本', + 'snippets.kind.codeSnippet': '代码片段', + 'snippets.kind.automationScript': '自动化脚本', + 'snippets.package.count': '{count} 个脚本', 'snippets.commandFallback': '命令', 'snippets.view.grid': '网格', 'snippets.view.list': '列表', @@ -649,7 +657,7 @@ export const zhCNTerminalMessages: Messages = { 'snippets.selection.deselectAll': '取消选择', 'snippets.selection.exportSelected': '导出选中({count})', 'snippets.export.snippet': '导出代码片段', - 'snippets.export.package': '导出代码包', + 'snippets.export.package': '导出脚本包', 'snippets.export.toast.empty': '没有可导出的代码片段。', 'snippets.export.toast.successTitle': '导出已准备好', 'snippets.export.toast.success': '已导出 {count} 个代码片段。', @@ -665,26 +673,26 @@ export const zhCNTerminalMessages: Messages = { 'snippets.import.modal.chooseFile': '选择文件', 'snippets.import.modal.downloadExamples': '下载示例文件', 'snippets.import.modal.multipleFiles': '已选择 {count} 个文件', - 'snippets.import.modal.parsedSummary': '{files} 个文件,{total} 个代码片段,{packages} 个代码包,{conflicts} 个重复命令。主机绑定会被忽略。', + 'snippets.import.modal.parsedSummary': '{files} 个文件,{total} 个脚本,{packages} 个脚本包,{conflicts} 个重复命令。主机绑定会被忽略。', 'snippets.import.modal.confirm': '确认导入', 'snippets.import.conflict.title': '导入代码片段?', 'snippets.import.conflict.desc': '“{file}”包含 {total} 个代码片段,其中 {conflicts} 个命令已存在。', 'snippets.import.conflict.hostBindingsNote': '代码片段的主机绑定不会被导入或导出。', 'snippets.import.conflict.skip': '跳过重复项', 'snippets.import.conflict.overwrite': '覆盖重复项', - 'snippets.packageDialog.title': '新建代码包', + 'snippets.packageDialog.title': '新建脚本包', 'snippets.packageDialog.parent': '父级:{parent}', 'snippets.packageDialog.root': '根目录', 'snippets.packageDialog.placeholder': '例如:ops/maintenance', - 'snippets.packageDialog.hint': '使用 "/" 创建嵌套代码包。', + 'snippets.packageDialog.hint': '使用 "/" 创建嵌套脚本包。', // Snippets Rename Dialog - 'snippets.renameDialog.title': '重命名代码包', + 'snippets.renameDialog.title': '重命名脚本包', 'snippets.renameDialog.currentPath': '当前路径:{path}', 'snippets.renameDialog.placeholder': '输入新名称', - 'snippets.renameDialog.error.empty': '代码包名称不能为空', - 'snippets.renameDialog.error.duplicate': '已存在同名的代码包', - 'snippets.renameDialog.error.invalidChars': '代码包名称只能包含字母、数字、连字符和下划线', + 'snippets.renameDialog.error.empty': '脚本包名称不能为空', + 'snippets.renameDialog.error.duplicate': '已存在同名的脚本包', + 'snippets.renameDialog.error.invalidChars': '脚本包名称只能包含字母、数字、连字符和下划线', 'snippets.field.noAutoRun': '仅粘贴(不自动执行)', // Snippet Shortkey diff --git a/application/i18n/locales/zh-CN/vault.ts b/application/i18n/locales/zh-CN/vault.ts index 1e968d53e..3529e159d 100644 --- a/application/i18n/locales/zh-CN/vault.ts +++ b/application/i18n/locales/zh-CN/vault.ts @@ -96,6 +96,7 @@ export const zhCNVaultMessages: Messages = { 'hostDetails.icon.colorManual': '手动', 'hostDetails.icon.colorChoice': '颜色', 'hostDetails.icon.autoColorLabel': '当前颜色', + 'hostDetails.icon.preview': '图标预览', 'hostDetails.icon.showLibrary': '展开图标库', 'hostDetails.icon.hideLibrary': '收起图标库', 'hostDetails.icon.autoUsesDistro': '使用 Linux 发行版图标和所选颜色显示此主机。', diff --git a/application/state/scriptAutomationCoordinator.ts b/application/state/scriptAutomationCoordinator.ts new file mode 100644 index 000000000..b54900e21 --- /dev/null +++ b/application/state/scriptAutomationCoordinator.ts @@ -0,0 +1,170 @@ +import type { Snippet } from '@/domain/models'; +import { isScriptSnippet, scriptContainsWriteOperations } from '@/domain/snippetScript.ts'; +import { localStorageAdapter } from '@/infrastructure/persistence/localStorageAdapter.ts'; +import { STORAGE_KEY_AI_PERMISSION_MODE } from '@/infrastructure/config/storageKeys.ts'; +import type { AIPermissionMode } from '@/infrastructure/ai/types.ts'; +import { netcattyBridge } from '@/infrastructure/services/netcattyBridge.ts'; +import type { ScriptRun } from '@/types/global/netcatty-bridge-script.d.ts'; + +type RunsListener = (runs: ScriptRun[]) => void; + +let runs: ScriptRun[] = []; +const runsListeners = new Set(); + +function readPermissionMode(): AIPermissionMode { + const stored = localStorageAdapter.readString(STORAGE_KEY_AI_PERMISSION_MODE); + if (stored === 'observer' || stored === 'confirm' || stored === 'auto') return stored; + return 'confirm'; +} + +export function subscribeScriptRuns(listener: RunsListener): () => void { + runsListeners.add(listener); + listener(runs); + return () => runsListeners.delete(listener); +} + +export function setScriptRuns(nextRuns: ScriptRun[]) { + runs = nextRuns; + runsListeners.forEach((listener) => listener(runs)); +} + +export function getActiveScriptRunForSession(sessionId: string): ScriptRun | undefined { + return runs.find((run) => + run.sessionId === sessionId && (run.status === 'running' || run.status === 'paused'), + ); +} + +export async function runAutomationScript(params: { + snippet: Snippet; + sessionId: string; + sessionIds?: string[]; + mode?: 'sequential' | 'parallel'; + sessionMeta?: { + connected?: boolean; + hostname?: string; + username?: string; + }; +}): Promise<{ runId: string; runIds: string[] }> { + const permissionMode = readPermissionMode(); + if (permissionMode === 'observer' && scriptContainsWriteOperations(params.snippet.command)) { + throw new Error('Observer mode blocks scripts that write to the terminal.'); + } + + const bridge = netcattyBridge.get(); + if (!bridge?.scriptRun) { + throw new Error('Script bridge unavailable'); + } + return bridge.scriptRun({ + scriptId: params.snippet.id, + scriptLabel: params.snippet.label, + content: params.snippet.command, + sessionId: params.sessionId, + sessionIds: params.sessionIds, + mode: params.mode, + permissionMode, + sessionMeta: params.sessionMeta, + }); +} + +const TERMINAL_SCRIPT_STATUSES = new Set(['completed', 'failed']); + +export function waitForScriptRun( + runId: string, + options: { signal?: AbortSignal; timeoutMs?: number } = {}, +): Promise { + const timeoutMs = options.timeoutMs ?? 3_600_000; + + return new Promise((resolve, reject) => { + let settled = false; + let timeoutId: ReturnType | undefined; + + const finish = (handler: () => void) => { + if (settled) return; + settled = true; + if (timeoutId !== undefined) { + clearTimeout(timeoutId); + } + unsubscribe(); + options.signal?.removeEventListener('abort', onAbort); + handler(); + }; + + const onAbort = () => { + finish(() => reject(new Error('Aborted'))); + }; + + const settleRun = (run: ScriptRun | undefined) => { + if (!run || !TERMINAL_SCRIPT_STATUSES.has(run.status)) return; + if (run.status === 'completed') { + finish(() => resolve(run)); + return; + } + finish(() => reject(new Error(run.error || 'Script failed'))); + }; + + const unsubscribe = subscribeScriptRuns((currentRuns) => { + settleRun(currentRuns.find((entry) => entry.runId === runId)); + }); + + timeoutId = setTimeout(() => { + finish(() => reject(new Error('Script run timed out'))); + }, timeoutMs); + + options.signal?.addEventListener('abort', onAbort, { once: true }); + }); +} + +export async function runConnectScriptsSequential(params: { + scripts: Snippet[]; + sessionId: string; + signal?: AbortSignal; + onScriptStart?: (snippet: Snippet) => void; + onScriptComplete?: (snippet: Snippet) => void; + sessionMeta?: { + connected?: boolean; + hostname?: string; + username?: string; + }; +}): Promise { + for (const snippet of params.scripts) { + if (params.signal?.aborted) { + throw new Error('Aborted'); + } + params.onScriptStart?.(snippet); + const { runId } = await runAutomationScript({ + snippet, + sessionId: params.sessionId, + sessionMeta: params.sessionMeta, + }); + await waitForScriptRun(runId, { signal: params.signal }); + params.onScriptComplete?.(snippet); + } +} + +export async function runSnippetOrScript(params: { + snippet: Snippet; + sessionId: string; + runSnippetText: (command: string, noAutoRun?: boolean) => void; + command: string; +}) { + if (isScriptSnippet(params.snippet)) { + await runAutomationScript({ + snippet: params.snippet, + sessionId: params.sessionId, + }); + return; + } + params.runSnippetText(params.command, params.snippet.noAutoRun); +} + +export async function stopScriptRun(runId: string) { + await netcattyBridge.get()?.scriptStop?.(runId); +} + +export async function pauseScriptRun(runId: string) { + await netcattyBridge.get()?.scriptPause?.(runId); +} + +export async function resumeScriptRun(runId: string) { + await netcattyBridge.get()?.scriptResume?.(runId); +} diff --git a/application/state/scriptRecordingStore.ts b/application/state/scriptRecordingStore.ts new file mode 100644 index 000000000..4d04ebebb --- /dev/null +++ b/application/state/scriptRecordingStore.ts @@ -0,0 +1,30 @@ +type ScriptRecordingSnapshot = { + sessionId: string | null; + isPaused: boolean; +}; + +type Listener = () => void; + +let snapshot: ScriptRecordingSnapshot = { sessionId: null, isPaused: false }; +const listeners = new Set(); + +function emit() { + for (const listener of listeners) { + listener(); + } +} + +export function getScriptRecordingSnapshot(): ScriptRecordingSnapshot { + return snapshot; +} + +export function subscribeScriptRecording(listener: Listener): () => void { + listeners.add(listener); + return () => listeners.delete(listener); +} + +export function setScriptRecordingState(sessionId: string | null, isPaused = false): void { + if (snapshot.sessionId === sessionId && snapshot.isPaused === isPaused) return; + snapshot = { sessionId, isPaused }; + emit(); +} diff --git a/application/state/sidePanelLiveStore.ts b/application/state/sidePanelLiveStore.ts index e88f0ef3e..fabb579f9 100644 --- a/application/state/sidePanelLiveStore.ts +++ b/application/state/sidePanelLiveStore.ts @@ -8,6 +8,7 @@ export type SidePanelLiveSnapshot = { activeTerminalSessionForSystem: TerminalSession | null; activeSystemSessionHost: Host | null; focusedHost: Host | null; + focusedSessionId: string | null; historySessionId: string | null; resolvedPreviewTheme: TerminalTheme | null; previewedOrVisibleThemeId: string | undefined; @@ -28,6 +29,7 @@ const EMPTY_SNAPSHOT: SidePanelLiveSnapshot = { activeTerminalSessionForSystem: null, activeSystemSessionHost: null, focusedHost: null, + focusedSessionId: null, historySessionId: null, resolvedPreviewTheme: null, previewedOrVisibleThemeId: undefined, @@ -52,6 +54,7 @@ function liveSnapshotEqual(a: SidePanelLiveSnapshot, b: SidePanelLiveSnapshot): && a.activeTerminalSessionForSystem === b.activeTerminalSessionForSystem && a.activeSystemSessionHost === b.activeSystemSessionHost && a.focusedHost === b.focusedHost + && a.focusedSessionId === b.focusedSessionId && a.historySessionId === b.historySessionId && a.resolvedPreviewTheme === b.resolvedPreviewTheme && a.previewedOrVisibleThemeId === b.previewedOrVisibleThemeId diff --git a/application/state/useOutputTriggers.test.ts b/application/state/useOutputTriggers.test.ts new file mode 100644 index 000000000..f59f33e2d --- /dev/null +++ b/application/state/useOutputTriggers.test.ts @@ -0,0 +1,29 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { findMatchEndingAfter } from './useOutputTriggers.ts'; + +test('findMatchEndingAfter skips stale overlap matches and finds current output', () => { + const text = 'NETCATTY_TRIGGER_PROBE old\r\nprompt# NETCATTY_TRIGGER_PROBE: command not found\r\n'; + const minEndOffset = 'NETCATTY_TRIGGER_PROBE old\r\n'.length; + const match = findMatchEndingAfter(text, 'NETCATTY_TRIGGER_PROBE', minEndOffset); + assert.deepEqual(match, { + value: 'NETCATTY_TRIGGER_PROBE', + endOffset: 'NETCATTY_TRIGGER_PROBE old\r\nprompt# NETCATTY_TRIGGER_PROBE'.length, + }); +}); + +test('findMatchEndingAfter supports regex patterns', () => { + const text = 'old done\r\nservice ready: 200\r\n'; + const minEndOffset = 'old done\r\n'.length; + const match = findMatchEndingAfter(text, 'ready:\\s+\\d+', minEndOffset); + assert.deepEqual(match, { + value: 'ready: 200', + endOffset: 'old done\r\nservice ready: 200'.length, + }); +}); + +test('findMatchEndingAfter returns null when matches are only stale', () => { + const text = 'NETCATTY_TRIGGER_PROBE old\r\nnew output\r\n'; + const minEndOffset = 'NETCATTY_TRIGGER_PROBE old\r\n'.length; + assert.equal(findMatchEndingAfter(text, 'NETCATTY_TRIGGER_PROBE', minEndOffset), null); +}); diff --git a/application/state/useOutputTriggers.ts b/application/state/useOutputTriggers.ts new file mode 100644 index 000000000..fb25ff217 --- /dev/null +++ b/application/state/useOutputTriggers.ts @@ -0,0 +1,132 @@ +import { useCallback, useEffect, useRef } from 'react'; +import type { Snippet } from '@/domain/models'; +import { snippetAppliesToOutputTrigger } from '@/domain/snippetTargets.ts'; +import { isScriptSnippet } from '@/domain/snippetScript.ts'; +import { createTerminalOutputTriggerFilter } from '@/domain/terminalOutputTriggerFilter.ts'; +import { netcattyBridge } from '@/infrastructure/services/netcattyBridge.ts'; +import { getActiveScriptRunForSession } from '@/application/state/scriptAutomationCoordinator.ts'; + +type OutputTriggerContext = { + sessionId: string; + hostId?: string; + snippets: Snippet[]; + onRunScript: (snippet: Snippet, sessionId: string) => void | Promise; +}; + +function isSessionScriptRunActive(sessionId: string): boolean { + return Boolean(getActiveScriptRunForSession(sessionId)); +} + +export function findMatchEndingAfter(text: string, pattern: string, minEndOffset: number): { value: string; endOffset: number } | null { + const source = new RegExp(pattern); + for (let startOffset = 0; startOffset <= text.length;) { + const match = source.exec(text.slice(startOffset)); + if (!match || match.index === undefined) return null; + const absoluteStart = startOffset + match.index; + const absoluteEnd = absoluteStart + match[0].length; + if (absoluteEnd > minEndOffset) { + return { value: match[0], endOffset: absoluteEnd }; + } + startOffset = Math.max(absoluteStart + 1, absoluteEnd); + } + return null; +} + +export function useOutputTriggers({ + sessionId, + hostId, + snippets, + onRunScript, +}: OutputTriggerContext) { + const bufferRef = useRef(''); + const launchingRef = useRef(false); + const lastTriggerMatchEndRef = useRef(new Map()); + const serverOutputFilterRef = useRef(createTerminalOutputTriggerFilter()); + + const scanBuffer = useCallback((recentChunk: string) => { + if (!recentChunk || isSessionScriptRunActive(sessionId) || launchingRef.current) { + return; + } + + const text = bufferRef.current; + const overlap = 64; + const chunkWithOverlap = text.slice(Math.max(0, text.length - recentChunk.length - overlap)); + const chunkStartInSlice = Math.max(0, chunkWithOverlap.length - recentChunk.length); + const chunkBaseOffset = text.length - chunkWithOverlap.length; + + for (const snippet of snippets) { + if (isSessionScriptRunActive(sessionId) || launchingRef.current) { + return; + } + if (!isScriptSnippet(snippet) || snippet.trigger !== 'onOutput' || !snippet.triggerPattern || !snippet.id) { + continue; + } + if (!snippetAppliesToOutputTrigger(snippet, hostId)) continue; + try { + const matched = findMatchEndingAfter(chunkWithOverlap, snippet.triggerPattern, chunkStartInSlice); + if (!matched) { + continue; + } + const matchEnd = chunkBaseOffset + matched.endOffset; + const lastMatchEnd = lastTriggerMatchEndRef.current.get(snippet.id) ?? -1; + if (matchEnd <= lastMatchEnd) { + continue; + } + const matchedSnippetId = snippet.id; + launchingRef.current = true; + lastTriggerMatchEndRef.current.set(matchedSnippetId, matchEnd); + void Promise.resolve(onRunScript(snippet, sessionId)) + .catch(() => { + // Failed starts can retry on the next matching output chunk. + }) + .finally(() => { + launchingRef.current = false; + }); + return; + } catch { + // ignore invalid regex + } + } + }, [hostId, onRunScript, sessionId, snippets]); + + const appendOutput = useCallback((chunk: string) => { + if (!chunk) return; + const { scannableText, alternateScreenActive } = serverOutputFilterRef.current.processServerChunk(chunk); + if (!scannableText || alternateScreenActive) { + return; + } + bufferRef.current = (bufferRef.current + scannableText).slice(-8192); + scanBuffer(scannableText); + }, [scanBuffer]); + + const noteUserInput = useCallback((data: string) => { + if (!data) return; + serverOutputFilterRef.current.noteUserInput(data); + }, []); + + useEffect(() => { + bufferRef.current = ''; + launchingRef.current = false; + lastTriggerMatchEndRef.current = new Map(); + serverOutputFilterRef.current.reset(); + }, [sessionId, hostId]); + + return { appendOutput, noteUserInput }; +} + +export function setupScriptBridgeListeners( + getSnapshot: (sessionId: string) => ReturnType, +) { + const disposers: Array<() => void> = []; + + disposers.push( + netcattyBridge.get()?.onScriptScreenSnapshotRequest?.(({ requestId, sessionId }) => { + const snapshot = getSnapshot(sessionId); + void netcattyBridge.get()?.scriptScreenSnapshotResponse?.(requestId, snapshot); + }) ?? (() => {}), + ); + + return () => { + disposers.forEach((dispose) => dispose()); + }; +} diff --git a/application/state/useScriptExecution.ts b/application/state/useScriptExecution.ts new file mode 100644 index 000000000..759c73528 --- /dev/null +++ b/application/state/useScriptExecution.ts @@ -0,0 +1,52 @@ +import { useCallback, useEffect, useRef, useState } from 'react'; +import type { ScriptRun, ScriptRunParams } from '@/types/global/netcatty-bridge-script.d.ts'; +import { netcattyBridge } from '@/infrastructure/services/netcattyBridge.ts'; + +export function useScriptExecution() { + const [runs, setRuns] = useState([]); + const runsRef = useRef(runs); + runsRef.current = runs; + + useEffect(() => { + const bridge = netcattyBridge.get(); + if (!bridge?.scriptGetRuns) return undefined; + bridge.scriptGetRuns().then(setRuns).catch(() => {}); + const dispose = bridge.onScriptRunsUpdated?.(({ runs: nextRuns }) => { + setRuns(nextRuns); + }); + return dispose; + }, []); + + const runScript = useCallback(async (params: ScriptRunParams) => { + const bridge = netcattyBridge.get(); + if (!bridge?.scriptRun) { + throw new Error('Script bridge unavailable'); + } + return bridge.scriptRun(params); + }, []); + + const stopRun = useCallback(async (runId: string) => { + await netcattyBridge.get()?.scriptStop?.(runId); + }, []); + + const pauseRun = useCallback(async (runId: string) => { + await netcattyBridge.get()?.scriptPause?.(runId); + }, []); + + const resumeRun = useCallback(async (runId: string) => { + await netcattyBridge.get()?.scriptResume?.(runId); + }, []); + + const getRunsForSession = useCallback((sessionId: string) => { + return runsRef.current.filter((run) => run.sessionId === sessionId); + }, []); + + return { + runs, + runScript, + stopRun, + pauseRun, + resumeRun, + getRunsForSession, + }; +} diff --git a/application/state/useScriptRecorder.ts b/application/state/useScriptRecorder.ts new file mode 100644 index 000000000..c517c995a --- /dev/null +++ b/application/state/useScriptRecorder.ts @@ -0,0 +1,125 @@ +import { useCallback, useEffect, useRef, useState } from 'react'; +import { netcattyBridge } from '@/infrastructure/services/netcattyBridge.ts'; +import { DEFAULT_RECORDING_PROMPT_TIMEOUT_MS } from '@/domain/snippetScript.ts'; +import type { ScriptRecordingStep } from '@/types/global/netcatty-bridge-script.d.ts'; + +export function useScriptRecorder(sessionId: string | undefined) { + const [isRecording, setIsRecording] = useState(false); + const [isPaused, setIsPaused] = useState(false); + const [elapsedMs, setElapsedMs] = useState(0); + const startedAtRef = useRef(null); + const inputBufferRef = useRef(''); + const lastStepAtRef = useRef(Date.now()); + const isRecordingRef = useRef(false); + const isPausedRef = useRef(false); + const sessionIdRef = useRef(sessionId); + + sessionIdRef.current = sessionId; + isRecordingRef.current = isRecording; + isPausedRef.current = isPaused; + + useEffect(() => { + if (!isRecording || isPaused) return undefined; + const timer = window.setInterval(() => { + if (startedAtRef.current) { + setElapsedMs(Date.now() - startedAtRef.current); + } + }, 250); + return () => window.clearInterval(timer); + }, [isRecording, isPaused]); + + const startRecording = useCallback(async () => { + const sid = sessionIdRef.current; + const bridge = netcattyBridge.get(); + if (!sid || !bridge?.scriptRecordingStart) return; + await bridge.scriptRecordingStart(sid); + startedAtRef.current = Date.now(); + lastStepAtRef.current = Date.now(); + inputBufferRef.current = ''; + setElapsedMs(0); + setIsPaused(false); + isPausedRef.current = false; + isRecordingRef.current = true; + setIsRecording(true); + }, []); + + const stopRecording = useCallback(async () => { + const sid = sessionIdRef.current; + const bridge = netcattyBridge.get(); + if (!sid || !bridge?.scriptRecordingStop) { + return { steps: [] as ScriptRecordingStep[], code: '' }; + } + const result = await bridge.scriptRecordingStop(sid); + isRecordingRef.current = false; + isPausedRef.current = false; + setIsRecording(false); + setIsPaused(false); + startedAtRef.current = null; + return result; + }, []); + + const pauseRecording = useCallback(() => { + isPausedRef.current = true; + setIsPaused(true); + }, []); + + const resumeRecording = useCallback(() => { + isPausedRef.current = false; + setIsPaused(false); + }, []); + + const appendStep = useCallback(async (step: ScriptRecordingStep) => { + const sid = sessionIdRef.current; + if (!sid || !isRecordingRef.current || isPausedRef.current) return; + await netcattyBridge.get()?.scriptRecordingAppendStep?.(sid, step); + lastStepAtRef.current = Date.now(); + }, []); + + const recordInput = useCallback((data: string) => { + if (!isRecordingRef.current || isPausedRef.current) return; + inputBufferRef.current += data; + }, []); + + const recordBackspace = useCallback(() => { + if (!isRecordingRef.current || isPausedRef.current) return; + inputBufferRef.current = inputBufferRef.current.slice(0, -1); + }, []); + + const recordClearLine = useCallback(() => { + if (!isRecordingRef.current || isPausedRef.current) return; + inputBufferRef.current = ''; + }, []); + + const recordEnter = useCallback(async (options?: { sensitive?: boolean }) => { + const sid = sessionIdRef.current; + if (!isRecordingRef.current || isPausedRef.current || !sid) return; + const line = inputBufferRef.current; + inputBufferRef.current = ''; + const now = Date.now(); + const gap = now - lastStepAtRef.current; + if (gap > 1000) { + await appendStep({ type: 'sleep', value: gap }); + } + await appendStep({ + type: 'send', + value: line, + sensitive: options?.sensitive, + }); + await appendStep({ type: 'waitForPrompt', timeoutMs: DEFAULT_RECORDING_PROMPT_TIMEOUT_MS }); + lastStepAtRef.current = now; + }, [appendStep]); + + return { + isRecording, + isPaused, + elapsedMs, + startRecording, + stopRecording, + pauseRecording, + resumeRecording, + recordInput, + recordBackspace, + recordClearLine, + recordEnter, + }; +} diff --git a/application/state/useSessionState.ts b/application/state/useSessionState.ts index 56a92cdf0..cc1c84d1f 100644 --- a/application/state/useSessionState.ts +++ b/application/state/useSessionState.ts @@ -2,6 +2,7 @@ import { MouseEvent,useCallback,useEffect,useMemo,useRef,useState } from 'react' import { ConnectionLog,Host,SerialConfig,Snippet,TerminalSession,Workspace,WorkspaceViewMode } from '../../domain/models'; import { addLogView, getLogViewTabId, removeLogView, type LogView } from './logViewState'; import { createHostTerminalSession, createLocalTerminalSession, createSerialTerminalSession, type LocalTerminalOptions } from './sessionFactories'; +import { isScriptSnippet } from '../../domain/snippetScript.ts'; import { appendPaneToWorkspaceRoot, collectSessionIds, @@ -967,9 +968,12 @@ export const useSessionState = ({ const sessionsWithWorkspace = newSessions.map(s => ({ ...s, workspaceId: workspace.id, - // Store the command to run after connection - startupCommand: resolvedCommand, - noAutoRun: snippet.noAutoRun, + ...(isScriptSnippet(snippet) + ? { pendingScriptId: snippet.id, pendingScript: snippet } + : { + startupCommand: resolvedCommand, + noAutoRun: snippet.noAutoRun, + }), })); setSessions(prev => [...prev, ...sessionsWithWorkspace]); diff --git a/application/state/useVaultState.ts b/application/state/useVaultState.ts index 8a7299cd5..90af1bfd6 100644 --- a/application/state/useVaultState.ts +++ b/application/state/useVaultState.ts @@ -51,6 +51,7 @@ import { } from "../../domain/connectionLogTerminalData"; import { getNextVaultOrder, normalizeVaultOrder } from "../../domain/vaultOrder"; import { loadSanitizedShellHistory } from "./shellHistoryPersistence"; +import { setVaultInitialized } from "./vaultInitStore"; import { decryptGroupConfigs, decryptHosts, @@ -801,6 +802,7 @@ export const useVaultState = () => { } } finally { setIsInitialized(true); + setVaultInitialized(true); } }; diff --git a/application/state/vaultInitStore.ts b/application/state/vaultInitStore.ts new file mode 100644 index 000000000..d668b5b3f --- /dev/null +++ b/application/state/vaultInitStore.ts @@ -0,0 +1,9 @@ +let vaultInitialized = false; + +export function isVaultInitialized(): boolean { + return vaultInitialized; +} + +export function setVaultInitialized(value: boolean): void { + vaultInitialized = value; +} diff --git a/components/DistroAvatar.test.tsx b/components/DistroAvatar.test.tsx index f24969ef1..d9da9c48f 100644 --- a/components/DistroAvatar.test.tsx +++ b/components/DistroAvatar.test.tsx @@ -35,6 +35,19 @@ test("DistroAvatar keeps serial hosts on the USB icon", () => { assert.doesNotMatch(markup, /background-color:#2563EB/i); }); +test("DistroAvatar xs size uses compact corners without appearing circular", () => { + const markup = renderToStaticMarkup( + , + ); + + assert.match(markup, /h-4 w-4 rounded-\[4px\]/); + assert.doesNotMatch(markup, /rounded-full/); +}); + test("DistroAvatar tree size uses compact host icon corners", () => { const markup = renderToStaticMarkup( { />, ); - assert.match(markup, /h-6 w-6 rounded/); - assert.doesNotMatch(markup, /rounded-md/); + assert.match(markup, /h-6 w-6 rounded-\[6px\]/); }); test("DistroAvatar keeps distro icon and applies custom palette color when icon mode is automatic", () => { diff --git a/components/DistroAvatar.tsx b/components/DistroAvatar.tsx index 47e288bdf..5ba52c95d 100644 --- a/components/DistroAvatar.tsx +++ b/components/DistroAvatar.tsx @@ -93,14 +93,24 @@ const DistroAvatarInner: React.FC = ({ const [errored, setErrored] = React.useState(false); const bg = DISTRO_COLORS[distro] || DISTRO_COLORS.default; - // Size variants — rounded rects (same corner style as SessionTabIcon in TopTabItems) + // Corner radii ~27% of edge (matches lg rounded-xl on 44px) — avoids circles on small sizes + const radiusClasses = { + xs: "rounded-[4px]", + sm: "rounded-[5px]", + md: "rounded-lg", + tree: "rounded-[6px]", + log: "rounded-[10px]", + lg: "rounded-xl", + } as const; + + // Size variants — rounded rects aligned with vault host list (lg = rounded-xl) const sizeClasses = { - xs: "h-4 w-4 rounded", - sm: "h-5 w-5 rounded", - md: "h-8 w-8 rounded", - tree: "h-6 w-6 rounded", - log: "h-9 w-9 rounded-xl", - lg: "h-11 w-11 rounded-xl", + xs: "h-4 w-4", + sm: "h-5 w-5", + md: "h-8 w-8", + tree: "h-6 w-6", + log: "h-9 w-9", + lg: "h-11 w-11", }; const iconSizes = { xs: "h-2.5 w-2.5", @@ -111,7 +121,7 @@ const DistroAvatarInner: React.FC = ({ lg: "h-5 w-5", }; - const containerClass = sizeClasses[size]; + const containerClass = cn(sizeClasses[size], radiusClasses[size]); const iconSize = iconSizes[size]; // Show USB icon for serial hosts @@ -119,7 +129,7 @@ const DistroAvatarInner: React.FC = ({ return (
= ({ return (
= ({ return (
= ({ return (
} title={t("hostDetails.section.address")} > -
- - update("hostname", e.target.value)} - className="h-10 flex-1" - /> -
+ update("hostname", e.target.value)} + className="h-10 w-full" + /> } + icon={} title={t("hostDetails.icon.sectionTitle")} hint={t("hostDetails.icon.desc")} > ) => SSHKey; + snippets?: Snippet[]; + onSnippetsChange?: (snippets: Snippet[]) => void; } const HostDetailsPanel: React.FC = ({ @@ -118,6 +124,8 @@ const HostDetailsPanel: React.FC = ({ groupConfigs = [], layout = "overlay", onImportKey, + snippets = [], + onSnippetsChange, }) => { const { t } = useI18n(); const { checkSshAgent } = useApplicationBackend(); @@ -175,15 +183,38 @@ const HostDetailsPanel: React.FC = ({ const [groupInputValue, setGroupInputValue] = useState(form.group || ""); + const initialHostId = initialData?.id; + useEffect(() => { - if (initialData) { - setForm(normalizePrimaryTelnetState(initialData)); - setGroupInputValue(initialData.group || ""); - setPendingReferenceKeyPath(null); - setShowPassword(false); - setShowTelnetPassword(false); - } - }, [initialData]); + if (!initialData) return; + const normalized = normalizePrimaryTelnetState(initialData); + setForm( + snippets.length > 0 + ? ensureHostConnectScriptIds(normalized, snippets) + : normalized, + ); + setGroupInputValue(initialData.group || ""); + setPendingReferenceKeyPath(null); + setShowPassword(false); + setShowTelnetPassword(false); + // Reset only when opening a different host — not when snippets list updates. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [initialHostId]); + + useEffect(() => { + if (!initialData || snippets.length === 0) return; + setForm((prev) => { + if (prev.id !== initialData.id) return prev; + const synced = ensureHostConnectScriptIds(prev, snippets); + if ( + synced.connectScriptIds === prev.connectScriptIds + && synced.loginScriptId === prev.loginScriptId + ) { + return prev; + } + return synced; + }); + }, [initialData, snippets]); const update = (key: K, value: Host[K]) => { setForm((prev) => ({ ...prev, [key]: value })); @@ -464,6 +495,38 @@ const HostDetailsPanel: React.FC = ({ if ((cleaned.protocol && cleaned.protocol !== "ssh") || cleaned.moshEnabled || cleaned.etEnabled) { delete cleaned.x11Forwarding; } + if (onSnippetsChange && initialData) { + const hostId = cleaned.id; + const savedQueueIds = initialData.connectScriptIds ?? getHostConnectScriptIds(initialData, snippets); + const finalQueueIds = cleaned.connectScriptIds ?? getHostConnectScriptIds(cleaned, snippets); + const savedSet = new Set(savedQueueIds); + const finalSet = new Set(finalQueueIds); + let nextSnippets = snippets; + let changed = false; + + for (const scriptId of finalQueueIds) { + if (!savedSet.has(scriptId)) { + nextSnippets = nextSnippets.map((item) => ( + item.id === scriptId && isScriptSnippet(item) + ? prepareSnippetForHostConnectQueue(item, hostId) + : item + )); + changed = true; + } + } + for (const scriptId of savedQueueIds) { + if (!finalSet.has(scriptId)) { + const unlinked = unlinkHostFromScripts(nextSnippets, hostId, scriptId); + if (unlinked !== nextSnippets) { + nextSnippets = unlinked; + changed = true; + } + } + } + if (changed) { + onSnippetsChange(nextSnippets); + } + } onSave(cleaned); }; @@ -828,6 +891,15 @@ const HostDetailsPanel: React.FC = ({ getDistroOptionLabel={getDistroOptionLabel} /> + {onSnippetsChange ? ( + + ) : null} + HOST_ICON_COLORS.find((color) => color.id === colorId)?.hex || HOST_ICON_COLORS[0].hex; export const HostIconPicker: React.FC = ({ + previewHost, distroMode, manualDistro, effectiveDistro, @@ -205,6 +209,23 @@ export const HostIconPicker: React.FC = ({ return (
+ {previewHost ? ( +
+ +
+ ) : null} +
{t("hostDetails.icon.source")} diff --git a/components/HostTreeView.tsx b/components/HostTreeView.tsx index d10aa58b3..f19732a6e 100644 --- a/components/HostTreeView.tsx +++ b/components/HostTreeView.tsx @@ -457,9 +457,7 @@ const HostTreeItem: React.FC = ({
)} icon={( -
- -
+ )} content={(
diff --git a/components/QuickAddSnippetDialog.tsx b/components/QuickAddSnippetDialog.tsx index f7b369ce7..0a709e403 100644 --- a/components/QuickAddSnippetDialog.tsx +++ b/components/QuickAddSnippetDialog.tsx @@ -12,6 +12,7 @@ import { Package } from 'lucide-react'; import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useI18n } from '../application/i18n/I18nProvider'; import type { Snippet } from '../domain/models'; +import { isScriptSnippet } from '../domain/snippetScript.ts'; import { Button } from './ui/button'; import { Combobox } from './ui/combobox'; import { @@ -77,7 +78,7 @@ export const QuickAddSnippetDialog: React.FC = ({ const handler = (e: Event) => { const detail = (e as CustomEvent<{ snippet?: Snippet }>).detail; const snippet = detail?.snippet; - if (!snippet) return; + if (!snippet || isScriptSnippet(snippet)) return; setEditing(snippet); setLabel(snippet.label ?? ''); setCommand(snippet.command ?? ''); diff --git a/components/ScriptsSidePanel.tsx b/components/ScriptsSidePanel.tsx index 12d1ac254..0f773861a 100644 --- a/components/ScriptsSidePanel.tsx +++ b/components/ScriptsSidePanel.tsx @@ -7,12 +7,17 @@ * list of matching snippets regardless of package nesting. */ -import { ChevronRight, Edit2, FileCode, Package, Plus, Search, Trash2, Zap } from 'lucide-react'; -import React, { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { ChevronRight, Edit2, Layers, Package, Play, Plus, Search, Trash2, Zap } from 'lucide-react'; +import React, { memo, useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore } from 'react'; import { useI18n } from '../application/i18n/I18nProvider'; +import { getScriptRecordingSnapshot, subscribeScriptRecording } from '../application/state/scriptRecordingStore.ts'; import { reorderVaultItems, reorderVaultStrings, sortByVaultOrder } from '../domain/vaultOrder'; +import { isScriptSnippet } from '../domain/snippetScript.ts'; import { cn } from '../lib/utils'; import { Snippet } from '../types'; +import type { ScriptRun } from '../types/global/netcatty-bridge-script.d.ts'; +import { ScriptRunList } from './scripts/ScriptRunList'; +import { ScriptRecordingHelpDialog } from './scripts/ScriptRecordingHelpDialog'; import { ContextMenu, ContextMenuContent, @@ -34,9 +39,17 @@ interface ScriptsSidePanelProps { snippets: Snippet[]; packages: string[]; onSnippetClick: (snippet: Snippet) => void; + onRunScript?: (snippet: Snippet) => void; + onRunScriptOnWorkspace?: (snippet: Snippet, mode: 'sequential' | 'parallel') => void; onSnippetsChange?: (snippets: Snippet[]) => void; onPackagesChange?: (packages: string[]) => void; isVisible?: boolean; + runs?: ScriptRun[]; + onStopRun?: (runId: string) => void; + onPauseRun?: (runId: string) => void; + onResumeRun?: (runId: string) => void; + onStartRecording?: () => void; + focusedSessionId?: string; } type TreeRow = @@ -221,14 +234,47 @@ const ScriptsSidePanelInner: React.FC = ({ snippets, packages, onSnippetClick, + onRunScript, + onRunScriptOnWorkspace, onSnippetsChange, onPackagesChange, isVisible = true, + runs = [], + onStopRun, + onPauseRun, + onResumeRun, + onStartRecording, + focusedSessionId, }) => { const { t } = useI18n(); const [search, setSearch] = useState(''); + const [subView, setSubView] = useState<'library' | 'running'>('library'); const [expandedPaths, setExpandedPaths] = useState>(new Set()); + useEffect(() => { + const handler = (event: Event) => { + const detail = (event as CustomEvent<{ packagePath?: string }>).detail; + setSubView('library'); + setSearch(''); + if (detail?.packagePath) { + setExpandedPaths((prev) => { + const next = new Set(prev); + let path = detail.packagePath || ''; + while (path) { + next.add(path); + const slash = path.lastIndexOf('/'); + if (slash < 0) break; + path = path.slice(0, slash); + } + next.add(''); + return next; + }); + } + }; + window.addEventListener('netcatty:scripts:saved', handler); + return () => window.removeEventListener('netcatty:scripts:saved', handler); + }, []); + // Normalize the package list + derive ancestor packages implied by each path // (e.g. package "a/b/c" implies roots "a" and "a/b" even when not listed). const normalizedPackages = useMemo(() => { @@ -343,11 +389,39 @@ const ScriptsSidePanelInner: React.FC = ({ const handleSnippetClick = useCallback( (snippet: Snippet) => { + if (isScriptSnippet(snippet)) { + onRunScript?.(snippet); + setSubView('running'); + return; + } onSnippetClick(snippet); }, - [onSnippetClick], + [onRunScript, onSnippetClick], ); + const sessionRuns = useMemo(() => { + if (!focusedSessionId) return runs; + return runs.filter((run) => run.sessionId === focusedSessionId); + }, [focusedSessionId, runs]); + + const recordingState = useSyncExternalStore( + subscribeScriptRecording, + getScriptRecordingSnapshot, + getScriptRecordingSnapshot, + ); + const isRecordingFocusedSession = Boolean( + focusedSessionId && recordingState.sessionId === focusedSessionId, + ); + const canStartRecording = Boolean(onStartRecording && focusedSessionId); + const recordingDisabledReason = !onStartRecording + ? 'unavailable' + : !focusedSessionId + ? 'noSession' + : null; + const recordingButtonLabel = isRecordingFocusedSession + ? t('scripts.recording.active') + : t('scripts.recording.start'); + const moveSnippetToPackage = useCallback((snippetId: string, packagePath: string | null) => { if (!onSnippetsChange) return; const targetPackage = packagePath || ''; @@ -506,12 +580,13 @@ const ScriptsSidePanelInner: React.FC = ({ ]); const handleAddSnippet = useCallback(() => { - // Let the App shell listen and navigate to the Snippets section with - // the "add" panel pre-opened, so the user does not have to leave the - // terminal to jump back and click "New Snippet". window.dispatchEvent(new CustomEvent('netcatty:snippets:add')); }, []); + const handleAddScript = useCallback(() => { + window.dispatchEvent(new CustomEvent('netcatty:scripts:add')); + }, []); + const handleEditSnippet = useCallback((snippet: Snippet) => { window.dispatchEvent( new CustomEvent('netcatty:snippets:edit', { detail: { snippet } }), @@ -534,6 +609,41 @@ const ScriptsSidePanelInner: React.FC = ({ className="h-full flex flex-col bg-background overflow-hidden" data-section="snippets-panel" > + {/* Sub view tabs */} +
+ + +
+ + {subView === 'running' ? ( +
+ {})} + onPause={onPauseRun ?? (() => {})} + onResume={onResumeRun ?? (() => {})} + /> +
+ ) : ( + <> {/* Search + Add */}
@@ -623,6 +733,14 @@ const ScriptsSidePanelInner: React.FC = ({ onClick={() => handleSnippetClick(item.row.snippet)} onEdit={() => handleEditSnippet(item.row.snippet)} onDelete={() => handleDeleteSnippet(item.row.snippet.id)} + onRunParallel={isScriptSnippet(item.row.snippet) && onRunScriptOnWorkspace + ? () => onRunScriptOnWorkspace(item.row.snippet, 'parallel') + : undefined} + onRunSequential={isScriptSnippet(item.row.snippet) && onRunScriptOnWorkspace + ? () => onRunScriptOnWorkspace(item.row.snippet, 'sequential') + : undefined} + runParallelLabel={t('scripts.actions.runParallel')} + runSequentialLabel={t('scripts.actions.runSequential')} editLabel={t('action.edit')} deleteLabel={t('action.delete')} /> @@ -631,6 +749,49 @@ const ScriptsSidePanelInner: React.FC = ({ /> )}
+
+ + + + + + + {recordingDisabledReason === 'unavailable' + ? t('scripts.recording.unavailableHint') + : recordingDisabledReason === 'noSession' + ? t('scripts.recording.noSession') + : isRecordingFocusedSession + ? t('scripts.recording.activeHint') + : t('scripts.recording.startHint')} + + + +
+ {isRecordingFocusedSession ? ( +

+ {t('scripts.recording.activeHint')} +

+ ) : null} + + )}
); @@ -690,6 +851,10 @@ interface SnippetRowProps { onClick: () => void; onEdit: () => void; onDelete: () => void; + onRunParallel?: () => void; + onRunSequential?: () => void; + runParallelLabel?: string; + runSequentialLabel?: string; editLabel: string; deleteLabel: string; } @@ -706,6 +871,10 @@ const SnippetRow = memo(({ onClick, onEdit, onDelete, + onRunParallel, + onRunSequential, + runParallelLabel, + runSequentialLabel, editLabel, deleteLabel, }) => ( @@ -735,7 +904,11 @@ const SnippetRow = memo(({ {/* Hidden chevron column mirrors PackageRow's layout so the snippet icon lines up exactly with the package icon above. */} - + {isScriptSnippet(snippet) ? ( + + ) : ( + + )} {snippet.label} {subtitle && ( @@ -754,6 +927,16 @@ const SnippetRow = memo(({
+ {onRunParallel ? ( + + {runParallelLabel} + + ) : null} + {onRunSequential ? ( + + {runSequentialLabel} + + ) : null} {editLabel} diff --git a/components/SelectHostDialog.tsx b/components/SelectHostDialog.tsx new file mode 100644 index 000000000..887b27e08 --- /dev/null +++ b/components/SelectHostDialog.tsx @@ -0,0 +1,44 @@ +import React from 'react'; +import { useI18n } from '@/application/i18n/I18nProvider'; +import { SelectHostPanelContent, type SelectHostPanelContentProps } from './SelectHostPanelContent'; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, +} from './ui/dialog'; + +export interface SelectHostDialogProps extends Omit { + open: boolean; + onOpenChange: (open: boolean) => void; + title?: string; +} + +export const SelectHostDialog: React.FC = ({ + open, + onOpenChange, + title, + onConfirm, + ...contentProps +}) => { + const { t } = useI18n(); + const dialogTitle = title ?? t('snippets.targets.add'); + + return ( + + + + {dialogTitle} + + { + onConfirm(); + onOpenChange(false); + }} + /> + + + ); +}; diff --git a/components/SelectHostPanel.tsx b/components/SelectHostPanel.tsx index a191e5193..1c68bdd32 100644 --- a/components/SelectHostPanel.tsx +++ b/components/SelectHostPanel.tsx @@ -1,30 +1,10 @@ -import { - Check, - ChevronRight, - LayoutGrid, - Plus, - Search, -} from "lucide-react"; -import React, { useMemo, useState } from "react"; +import React, { useState } from "react"; import { cn } from "../lib/utils"; -import { matchesHostSearchQuery, matchesSearchQuery } from "../lib/searchMatcher"; import { useI18n } from "../application/i18n/I18nProvider"; import { Host, ProxyProfile, SSHKey } from "../types"; import { ManagedSource } from "../domain/models"; -import { DistroAvatar } from "./DistroAvatar"; -import HostDetailsPanel from "./HostDetailsPanel"; import { AsidePanel, type AsidePanelLayout } from "./ui/aside-panel"; -import { Button } from "./ui/button"; -import { Input } from "./ui/input"; -import { ScrollArea } from "./ui/scroll-area"; -import { SortDropdown, SortMode } from "./ui/sort-dropdown"; -import { TagFilterDropdown } from "./ui/tag-filter-dropdown"; -import { - Tooltip, - TooltipContent, - TooltipProvider, - TooltipTrigger, -} from "./ui/tooltip"; +import { SelectHostPanelContent } from "./SelectHostPanelContent"; interface SelectHostPanelProps { hosts: Host[]; @@ -35,7 +15,6 @@ interface SelectHostPanelProps { onBack: () => void; onContinue?: () => void; onNewHost?: () => void; - // Props for inline host creation availableKeys?: SSHKey[]; identities?: import('../domain/models').Identity[]; proxyProfiles?: ProxyProfile[]; @@ -46,6 +25,9 @@ interface SelectHostPanelProps { subtitle?: string; className?: string; layout?: AsidePanelLayout; + resizable?: boolean; + persistWidthStorageKey?: string; + resizeAriaLabel?: string; } const SelectHostPanel: React.FC = ({ @@ -67,155 +49,26 @@ const SelectHostPanel: React.FC = ({ subtitle, className, layout = "overlay", + resizable = false, + persistWidthStorageKey, + resizeAriaLabel, }) => { const { t } = useI18n(); + const [newHostPanelOpen, setNewHostPanelOpen] = useState(false); const panelTitle = title ?? t("selectHost.title"); - const [searchQuery, setSearchQuery] = useState(""); - const [currentPath, setCurrentPath] = useState(null); - const [sortMode, setSortMode] = useState("az"); - const [selectedTags, setSelectedTags] = useState([]); - const [showNewHostPanel, setShowNewHostPanel] = useState(false); - const selectableHosts = useMemo( - () => hosts.filter((host) => host.protocol !== "serial"), - [hosts] - ); - const selectedHostIdSet = useMemo( - () => new Set(selectedHostIds), - [selectedHostIds], - ); - - // Get all unique tags from hosts - const allTags = useMemo(() => { - const tagSet = new Set(); - selectableHosts.forEach((h) => { - if (h.tags) { - h.tags.forEach((tag) => tagSet.add(tag)); - } - }); - return Array.from(tagSet).sort(); - }, [selectableHosts]); - - // Get unique group paths from both hosts and customGroups - const allGroupPaths = useMemo(() => { - const pathSet = new Set(); - selectableHosts.forEach((h) => { - if (h.group) { - // Add all parent paths as well - const parts = h.group.split("/"); - for (let i = 1; i <= parts.length; i++) { - pathSet.add(parts.slice(0, i).join("/")); - } - } - }); - customGroups.forEach((g) => pathSet.add(g)); - return Array.from(pathSet).sort(); - }, [selectableHosts, customGroups]); - - const groupHostCounts = useMemo(() => { - const counts = new Map(); - selectableHosts.forEach((host) => { - if (!host.group) return; - const parts = host.group.split("/"); - for (let i = 1; i <= parts.length; i += 1) { - const path = parts.slice(0, i).join("/"); - counts.set(path, (counts.get(path) ?? 0) + 1); - } - }); - return counts; - }, [selectableHosts]); - - // Get groups at current level - const groupsWithCounts = useMemo(() => { - const prefix = currentPath ? `${currentPath}/` : ""; - const groups: { path: string; name: string; count: number }[] = []; - const seen = new Set(); - - allGroupPaths.forEach((path) => { - if (currentPath === null) { - // Root level - get top-level groups - const topLevel = path.split("/")[0]; - if (!seen.has(topLevel)) { - seen.add(topLevel); - const count = groupHostCounts.get(topLevel) ?? 0; - groups.push({ path: topLevel, name: topLevel, count }); - } - } else if (path.startsWith(prefix) && path !== currentPath) { - // Subgroups - const rest = path.slice(prefix.length); - const nextLevel = rest.split("/")[0]; - const fullPath = `${prefix}${nextLevel}`; - if (!seen.has(fullPath)) { - seen.add(fullPath); - const count = groupHostCounts.get(fullPath) ?? 0; - groups.push({ path: fullPath, name: nextLevel, count }); - } - } - }); - - return groups; - }, [allGroupPaths, currentPath, groupHostCounts]); - - // Get hosts at current level with filtering and sorting - const filteredHosts = useMemo(() => { - let result = selectableHosts; - - // Filter by current path - if (currentPath) { - result = result.filter( - (h) => - h.group === currentPath || h.group?.startsWith(`${currentPath}/`), - ); + const handleConfirm = () => { + if (onContinue) { + onContinue(); + return; } - - // Filter by search - if (searchQuery) { - result = result.filter( - (h) => - matchesHostSearchQuery(searchQuery, h) || - matchesSearchQuery(searchQuery, h.username, h.notes), - ); - } - - // Filter by tags - if (selectedTags.length > 0) { - result = result.filter( - (h) => h.tags && selectedTags.some((tag) => h.tags.includes(tag)), - ); - } - - // Sort hosts - result = [...result].sort((a, b) => { - switch (sortMode) { - case "az": - return a.label.localeCompare(b.label); - case "za": - return b.label.localeCompare(a.label); - case "newest": - // Use id as proxy for creation time (UUIDs are time-sortable or fall back to label) - return b.id.localeCompare(a.id); - case "oldest": - return a.id.localeCompare(b.id); - default: - return 0; - } - }); - - return result; - }, [selectableHosts, currentPath, searchQuery, selectedTags, sortMode]); - - // Build breadcrumb from current path - const breadcrumbs = useMemo(() => { - if (!currentPath) return []; - const parts = currentPath.split("/"); - return parts.map((part, index) => ({ - name: part, - path: parts.slice(0, index + 1).join("/"), - })); - }, [currentPath]); + const host = hosts + .filter((entry) => entry.protocol !== "serial") + .find((entry) => selectedHostIds.includes(entry.id)); + if (host) onSelect(host); + }; return ( - = ({ subtitle={subtitle} showBackButton={true} onBack={onBack} - className={cn( - layout === "overlay" && "z-40", - showNewHostPanel && "overflow-visible", - className, - )} + className={cn(layout === "overlay" && "z-40", newHostPanelOpen && "overflow-visible", className)} layout={layout} + resizable={resizable} + persistWidthStorageKey={persistWidthStorageKey} + resizeAriaLabel={resizeAriaLabel} > - - {/* Toolbar */} -
- {(onNewHost || onSaveHost) && ( - - )} -
- - setSearchQuery(e.target.value)} - /> -
-
- - -
-
- - {/* Content */} - -
- {/* Breadcrumbs */} - {currentPath && ( -
- - {breadcrumbs.map((crumb, index) => ( - - - - - ))} -
- )} - {groupsWithCounts.length > 0 && ( -
-

{t("vault.groups.title")}

-
- {groupsWithCounts.map((group) => ( -
setCurrentPath(group.path)} - > -
- -
-
-
{group.name}
-
- {t("vault.groups.hostsCount", { count: group.count })} -
-
-
- ))} -
-
- )} - - {/* Hosts Section */} - {filteredHosts.length > 0 && ( -
-

{t("vault.nav.hosts")}

-
- {filteredHosts.map((host) => { - const isSelected = selectedHostIdSet.has(host.id); - const connectionStr = `${host.username}@${host.hostname}:${host.port || 22}`; - - return ( -
onSelect(host)} - > - -
- - -
- {host.label} -
-
- -

{host.label}

-
-
- - -
- {connectionStr} -
-
- -

{connectionStr}

-
-
-
- {isSelected && ( - - )} -
- ); - })} -
-
- )} - - {/* Empty state */} - {groupsWithCounts.length === 0 && filteredHosts.length === 0 && ( -
-

{t("selectHost.noHostsFound")}

-
- )} -
-
- - {/* Footer */} -
- -
- - {/* New Host Panel Overlay */} - {showNewHostPanel && onSaveHost && ( - { - onSaveHost(host); - setShowNewHostPanel(false); - }} - onCancel={() => setShowNewHostPanel(false)} - onCreateGroup={onCreateGroup} - /> - )} +
-
); }; diff --git a/components/SelectHostPanelContent.tsx b/components/SelectHostPanelContent.tsx new file mode 100644 index 000000000..074270497 --- /dev/null +++ b/components/SelectHostPanelContent.tsx @@ -0,0 +1,382 @@ +import { + Check, + ChevronRight, + LayoutGrid, + Plus, + Search, +} from 'lucide-react'; +import React, { useEffect, useMemo, useState } from 'react'; +import { cn } from '../lib/utils'; +import { matchesHostSearchQuery, matchesSearchQuery } from '../lib/searchMatcher'; +import { useI18n } from '../application/i18n/I18nProvider'; +import { Host, ProxyProfile, SSHKey } from '../types'; +import { ManagedSource } from '../domain/models'; +import { DistroAvatar } from './DistroAvatar'; +import HostDetailsPanel from './HostDetailsPanel'; +import { Button } from './ui/button'; +import { Input } from './ui/input'; +import { ScrollArea } from './ui/scroll-area'; +import { SortDropdown, SortMode } from './ui/sort-dropdown'; +import { TagFilterDropdown } from './ui/tag-filter-dropdown'; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from './ui/tooltip'; + +export interface SelectHostPanelContentProps { + hosts: Host[]; + customGroups?: string[]; + selectedHostIds: string[]; + multiSelect?: boolean; + onSelect: (host: Host) => void; + onConfirm: () => void; + onNewHost?: () => void; + availableKeys?: SSHKey[]; + identities?: import('../domain/models').Identity[]; + proxyProfiles?: ProxyProfile[]; + managedSources?: ManagedSource[]; + onSaveHost?: (host: Host) => void; + onCreateGroup?: (groupPath: string) => void; + onNewHostPanelOpenChange?: (open: boolean) => void; + className?: string; +} + +/** Shared host-picker body used by aside panel and dialog variants. */ +export const SelectHostPanelContent: React.FC = ({ + hosts, + customGroups = [], + selectedHostIds, + multiSelect = false, + onSelect, + onConfirm, + onNewHost, + availableKeys = [], + identities = [], + proxyProfiles = [], + managedSources = [], + onSaveHost, + onCreateGroup, + onNewHostPanelOpenChange, + className, +}) => { + const { t } = useI18n(); + const [searchQuery, setSearchQuery] = useState(''); + const [currentPath, setCurrentPath] = useState(null); + const [sortMode, setSortMode] = useState('az'); + const [selectedTags, setSelectedTags] = useState([]); + const [showNewHostPanel, setShowNewHostPanel] = useState(false); + + useEffect(() => { + onNewHostPanelOpenChange?.(showNewHostPanel); + }, [onNewHostPanelOpenChange, showNewHostPanel]); + + const selectableHosts = useMemo( + () => hosts.filter((host) => host.protocol !== 'serial'), + [hosts], + ); + const selectedHostIdSet = useMemo( + () => new Set(selectedHostIds), + [selectedHostIds], + ); + + const allTags = useMemo(() => { + const tagSet = new Set(); + selectableHosts.forEach((host) => { + host.tags?.forEach((tag) => tagSet.add(tag)); + }); + return Array.from(tagSet).sort(); + }, [selectableHosts]); + + const allGroupPaths = useMemo(() => { + const pathSet = new Set(); + selectableHosts.forEach((host) => { + if (host.group) { + const parts = host.group.split('/'); + for (let i = 1; i <= parts.length; i += 1) { + pathSet.add(parts.slice(0, i).join('/')); + } + } + }); + customGroups.forEach((group) => pathSet.add(group)); + return Array.from(pathSet).sort(); + }, [selectableHosts, customGroups]); + + const groupHostCounts = useMemo(() => { + const counts = new Map(); + selectableHosts.forEach((host) => { + if (!host.group) return; + const parts = host.group.split('/'); + for (let i = 1; i <= parts.length; i += 1) { + const path = parts.slice(0, i).join('/'); + counts.set(path, (counts.get(path) ?? 0) + 1); + } + }); + return counts; + }, [selectableHosts]); + + const groupsWithCounts = useMemo(() => { + const prefix = currentPath ? `${currentPath}/` : ''; + const groups: { path: string; name: string; count: number }[] = []; + const seen = new Set(); + + allGroupPaths.forEach((path) => { + if (currentPath === null) { + const topLevel = path.split('/')[0]; + if (!seen.has(topLevel)) { + seen.add(topLevel); + groups.push({ path: topLevel, name: topLevel, count: groupHostCounts.get(topLevel) ?? 0 }); + } + } else if (path.startsWith(prefix) && path !== currentPath) { + const rest = path.slice(prefix.length); + const nextLevel = rest.split('/')[0]; + const fullPath = `${prefix}${nextLevel}`; + if (!seen.has(fullPath)) { + seen.add(fullPath); + groups.push({ path: fullPath, name: nextLevel, count: groupHostCounts.get(fullPath) ?? 0 }); + } + } + }); + + return groups; + }, [allGroupPaths, currentPath, groupHostCounts]); + + const filteredHosts = useMemo(() => { + let result = selectableHosts; + + if (currentPath) { + result = result.filter( + (host) => host.group === currentPath || host.group?.startsWith(`${currentPath}/`), + ); + } + + if (searchQuery) { + result = result.filter( + (host) => + matchesHostSearchQuery(searchQuery, host) + || matchesSearchQuery(searchQuery, host.username, host.notes), + ); + } + + if (selectedTags.length > 0) { + result = result.filter( + (host) => host.tags && selectedTags.some((tag) => host.tags.includes(tag)), + ); + } + + result = [...result].sort((a, b) => { + switch (sortMode) { + case 'az': + return a.label.localeCompare(b.label); + case 'za': + return b.label.localeCompare(a.label); + case 'newest': + return b.id.localeCompare(a.id); + case 'oldest': + return a.id.localeCompare(b.id); + default: + return 0; + } + }); + + return result; + }, [selectableHosts, currentPath, searchQuery, selectedTags, sortMode]); + + const breadcrumbs = useMemo(() => { + if (!currentPath) return []; + const parts = currentPath.split('/'); + return parts.map((part, index) => ({ + name: part, + path: parts.slice(0, index + 1).join('/'), + })); + }, [currentPath]); + + return ( + +
+
+ {(onNewHost || onSaveHost) ? ( + + ) : null} +
+ + setSearchQuery(event.target.value)} + /> +
+
+ + +
+
+ + +
+ {currentPath ? ( +
+ + {breadcrumbs.map((crumb, index) => ( + + + + + ))} +
+ ) : null} + + {groupsWithCounts.length > 0 ? ( +
+

{t('vault.groups.title')}

+
+ {groupsWithCounts.map((group) => ( +
setCurrentPath(group.path)} + > +
+ +
+
+
{group.name}
+
+ {t('vault.groups.hostsCount', { count: group.count })} +
+
+
+ ))} +
+
+ ) : null} + + {filteredHosts.length > 0 ? ( +
+

{t('vault.nav.hosts')}

+
+ {filteredHosts.map((host) => { + const isSelected = selectedHostIdSet.has(host.id); + const connectionStr = `${host.username}@${host.hostname}:${host.port || 22}`; + + return ( +
onSelect(host)} + > + +
+ + +
{host.label}
+
+ +

{host.label}

+
+
+ + +
{connectionStr}
+
+ +

{connectionStr}

+
+
+
+ {isSelected ? : null} +
+ ); + })} +
+
+ ) : null} + + {groupsWithCounts.length === 0 && filteredHosts.length === 0 ? ( +
+

{t('selectHost.noHostsFound')}

+
+ ) : null} +
+
+ +
+ +
+ + {showNewHostPanel && onSaveHost ? ( + { + onSaveHost(host); + setShowNewHostPanel(false); + }} + onCancel={() => setShowNewHostPanel(false)} + onCreateGroup={onCreateGroup} + /> + ) : null} +
+
+ ); +}; diff --git a/components/SnippetsManager.tsx b/components/SnippetsManager.tsx index 53ecf73e6..64418b3f4 100644 --- a/components/SnippetsManager.tsx +++ b/components/SnippetsManager.tsx @@ -1,4 +1,4 @@ -import { CheckSquare, ChevronDown, Clock, Copy, Download, Edit2, FileCode, FolderPlus, LayoutGrid, List as ListIcon, Package, Play, Plus, Search, Square, Trash2, Upload, X } from 'lucide-react'; +import { CheckSquare, ChevronDown, Clock, Copy, Download, Edit2, FileCode, FolderPlus, LayoutGrid, List as ListIcon, Package, Play, Plus, Search, Square, Trash2, Upload, X, Zap } from 'lucide-react'; import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useI18n } from '../application/i18n/I18nProvider'; import { useStoredViewMode } from '../application/state/useStoredViewMode'; @@ -14,6 +14,9 @@ import { type SnippetExportPayload, type SnippetImportConflictAction, } from '../domain/snippetTransfer'; +import { getRunnableHostsForSnippet, snippetHasRunTargets } from '../domain/snippetTargets.ts'; +import { removeHostConnectScript, syncHostsForSnippetTargetChange } from '../domain/hostConnectScripts.ts'; +import { DEFAULT_SCRIPT_TEMPLATE, isScriptSnippet } from '../domain/snippetScript.ts'; import { reorderVaultItems, reorderVaultStrings, sortByVaultOrder } from '../domain/vaultOrder'; import { Button } from './ui/button'; import { ComboboxOption } from './ui/combobox'; @@ -34,6 +37,7 @@ import { } from './vault/VaultPageHeader'; import { VaultEntityIcon, + vaultAutomationScriptIconClass, vaultPrimaryIconClass, vaultSnippetIconClass, } from './vault/VaultEntityIcon'; @@ -64,6 +68,7 @@ interface SnippetsManagerProps { proxyProfiles?: ProxyProfile[]; managedSources?: ManagedSource[]; onSaveHost?: (host: Host) => void; + onUpdateHosts?: (hosts: Host[]) => void; onCreateGroup?: (groupPath: string) => void; } @@ -448,6 +453,7 @@ const SnippetsManager: React.FC = ({ proxyProfiles = [], managedSources = [], onSaveHost, + onUpdateHosts, onCreateGroup, }) => { const { t } = useI18n(); @@ -663,38 +669,110 @@ const SnippetsManager: React.FC = ({ }; }, [isRecordingShortkey, isMac, validateShortkey]); - const handleEdit = (snippet?: Snippet) => { + const handleEdit = useCallback((snippet?: Snippet, asScript = false) => { if (snippet) { setEditingSnippet(snippet); - setTargetSelection(snippet.targets || []); + setTargetSelection(snippet.targetsAllHosts ? [] : (snippet.targets || [])); } else { - setEditingSnippet({ + setEditingSnippet(asScript ? { + label: '', + command: DEFAULT_SCRIPT_TEMPLATE, + package: selectedPackage || '', + targets: [], + kind: 'script', + language: 'javascript', + trigger: 'manual', + } : { label: '', command: '', package: selectedPackage || '', - targets: [] + targets: [], }); setTargetSelection([]); } setRightPanelMode('edit-snippet'); - }; + }, [selectedPackage]); - const handleSubmit = () => { - if (editingSnippet.label && editingSnippet.command) { - onSave({ - id: editingSnippet.id || crypto.randomUUID(), - label: editingSnippet.label, - command: editingSnippet.command, - tags: editingSnippet.tags || [], - package: editingSnippet.package || '', - targets: targetSelection, - shortkey: editingSnippet.shortkey, - noAutoRun: editingSnippet.noAutoRun, - order: editingSnippet.order, - }); - setRightPanelMode('none'); + + const buildSavedSnippet = useCallback((): Snippet | null => { + if (!editingSnippet.label || !editingSnippet.command) return null; + return { + id: editingSnippet.id || crypto.randomUUID(), + label: editingSnippet.label, + command: editingSnippet.command, + tags: editingSnippet.tags || [], + package: editingSnippet.package || '', + targets: editingSnippet.targetsAllHosts ? [] : targetSelection, + targetsAllHosts: editingSnippet.targetsAllHosts || undefined, + shortkey: editingSnippet.shortkey, + noAutoRun: editingSnippet.noAutoRun, + order: editingSnippet.order, + kind: editingSnippet.kind, + language: editingSnippet.language, + description: editingSnippet.description, + trigger: editingSnippet.trigger, + triggerPattern: editingSnippet.triggerPattern, + }; + }, [editingSnippet, targetSelection]); + + const syncHostsAfterSnippetSave = 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 handleSave = useCallback(() => { + const savedSnippet = buildSavedSnippet(); + if (!savedSnippet) return; + const nextSnippets = snippets.find((ex) => ex.id === savedSnippet.id) + ? snippets.map((ex) => (ex.id === savedSnippet.id ? savedSnippet : ex)) + : [...snippets, savedSnippet]; + onSave(savedSnippet); + syncHostsAfterSnippetSave(savedSnippet, nextSnippets); + setRightPanelMode('none'); + }, [buildSavedSnippet, onSave, snippets, syncHostsAfterSnippetSave]); + + const handleSaveAndRun = useCallback(() => { + const savedSnippet = buildSavedSnippet(); + if (!savedSnippet) return; + const nextSnippets = snippets.find((ex) => ex.id === savedSnippet.id) + ? snippets.map((ex) => (ex.id === savedSnippet.id ? savedSnippet : ex)) + : [...snippets, savedSnippet]; + onSave(savedSnippet); + syncHostsAfterSnippetSave(savedSnippet, nextSnippets); + const runTargets = getRunnableHostsForSnippet(savedSnippet, hosts); + if (snippetHasRunTargets(savedSnippet) && runTargets.length > 0) { + onRunSnippet?.(savedSnippet, runTargets); + } + setRightPanelMode('none'); + }, [buildSavedSnippet, hosts, onRunSnippet, onSave, snippets, syncHostsAfterSnippetSave]); + + useEffect(() => { + const handler = (event: KeyboardEvent) => { + if (rightPanelMode !== 'edit-snippet') return; + if (!(event.metaKey || event.ctrlKey) || event.key.toLowerCase() !== 's') return; + event.preventDefault(); + handleSave(); + }; + window.addEventListener('keydown', handler); + return () => window.removeEventListener('keydown', handler); + }, [handleSave, rightPanelMode]); const handleCopy = (id: string, text: string) => { navigator.clipboard.writeText(text); @@ -723,9 +801,18 @@ const SnippetsManager: React.FC = ({ }; const handleTargetSelect = (host: Host) => { - setTargetSelection((prev) => - prev.includes(host.id) ? prev.filter((id) => id !== host.id) : [...prev, host.id] - ); + if (editingSnippet.targetsAllHosts) { + setEditingSnippet((prev) => ({ ...prev, targetsAllHosts: undefined })); + } + setTargetSelection((prev) => { + const next = prev.includes(host.id) ? prev.filter((id) => id !== host.id) : [...prev, host.id]; + setEditingSnippet((snippet) => ({ + ...snippet, + targetsAllHosts: undefined, + targets: next, + })); + return next; + }); }; const handleTargetPickerBack = () => { @@ -1430,6 +1517,7 @@ const SnippetsManager: React.FC = ({ hosts={hosts} customGroups={customGroups} targetSelection={targetSelection} + setTargetSelection={setTargetSelection} handleTargetSelect={handleTargetSelect} handleTargetPickerBack={handleTargetPickerBack} availableKeys={availableKeys} @@ -1441,7 +1529,8 @@ const SnippetsManager: React.FC = ({ handleClosePanel={handleClosePanel} editingSnippet={editingSnippet} onDelete={onDelete} - handleSubmit={handleSubmit} + handleSave={handleSave} + handleSaveAndRun={handleSaveAndRun} setEditingSnippet={setEditingSnippet} packageOptions={packageOptions} selectedPackage={selectedPackage} @@ -1463,6 +1552,7 @@ const SnippetsManager: React.FC = ({ hasMoreHistory={hasMoreHistory} isLoadingMore={isLoadingMore} loadMoreHistory={loadMoreHistory} + onRunSnippet={onRunSnippet} /> ); @@ -1480,6 +1570,9 @@ const SnippetsManager: React.FC = ({ +
)} } + className={isScriptSnippet(snippet) ? vaultAutomationScriptIconClass : vaultSnippetIconClass} + icon={isScriptSnippet(snippet) ? ( + + ) : ( + + )} />
{snippet.label}
@@ -1808,14 +1905,14 @@ const SnippetsManager: React.FC = ({ { - const targetHostsList = (snippet.targets || []) - .map(id => hostById.get(id)) - .filter((h): h is Host => Boolean(h)); - if (targetHostsList.length > 0) { - onRunSnippet?.(snippet, targetHostsList); + const runTargets = getRunnableHostsForSnippet(snippet, hosts); + if (runTargets.length > 0) { + onRunSnippet?.(snippet, runTargets); + return; } + toast.error(t('scripts.actions.noRunnableHosts')); }} - disabled={!snippet.targets?.length} + disabled={getRunnableHostsForSnippet(snippet, hosts).length === 0} > {t('action.run')} diff --git a/components/SnippetsRightPanel.tsx b/components/SnippetsRightPanel.tsx index 3c18edc63..296cd2ea1 100644 --- a/components/SnippetsRightPanel.tsx +++ b/components/SnippetsRightPanel.tsx @@ -1,4 +1,4 @@ -import React, { useMemo } from 'react'; +import React, { useMemo, useState } from 'react'; import { parseSnippetVariables } from '../domain/snippetVariables'; import { Check, Clock, Keyboard, Loader2, Package, RotateCcw, Trash2 } from 'lucide-react'; import { cn } from '../lib/utils'; @@ -8,10 +8,16 @@ import { Button } from './ui/button'; import { Card } from './ui/card'; import { Input } from './ui/input'; import { SnippetScriptEditor } from './snippets/SnippetScriptEditor'; +import { SnippetTargetsSection } from './snippets/SnippetTargetsSection'; +import { ScriptEditorPanel } from './scripts/ScriptEditorPanel'; +import { ScriptEditorModal } from './scripts/ScriptEditorModal'; +import { isScriptSnippet } from '../domain/snippetScript.ts'; import { Tooltip, TooltipContent, TooltipTrigger } from './ui/tooltip'; import { Combobox } from './ui/combobox'; -import { DistroAvatar } from './DistroAvatar'; import { HistoryItem } from './SnippetsHistoryItem'; +import type { Snippet } from '@/domain/models'; +import { getRunnableHostsForSnippet } from '@/domain/snippetTargets.ts'; +import { STORAGE_KEY_SNIPPETS_PANEL_WIDTH } from '@/infrastructure/config/storageKeys.ts'; // eslint-disable-next-line @typescript-eslint/no-explicit-any type SnippetsRightPanelProps = Record; @@ -21,6 +27,7 @@ export const SnippetsRightPanel: React.FC = ({ hosts, customGroups, targetSelection, + setTargetSelection, handleTargetSelect, handleTargetPickerBack, availableKeys, @@ -32,7 +39,8 @@ export const SnippetsRightPanel: React.FC = ({ handleClosePanel, editingSnippet, onDelete, - handleSubmit, + handleSave, + handleSaveAndRun, setEditingSnippet, packageOptions, selectedPackage, @@ -54,11 +62,53 @@ export const SnippetsRightPanel: React.FC = ({ hasMoreHistory, isLoadingMore, loadMoreHistory, + onRunSnippet, }) => { const detectedVariables = useMemo( () => parseSnippetVariables(editingSnippet?.command || ''), [editingSnippet?.command], ); + const [scriptEditorModalOpen, setScriptEditorModalOpen] = useState(false); + const isEditingScript = isScriptSnippet(editingSnippet as import('../types').Snippet); + const snippetsPanelResizeProps = { + resizable: true as const, + persistWidthStorageKey: STORAGE_KEY_SNIPPETS_PANEL_WIDTH, + resizeAriaLabel: t('snippets.panel.resizeWidth'), + }; + + const runnableEditingSnippet = useMemo(() => ({ + ...(editingSnippet as Snippet), + targets: editingSnippet.targetsAllHosts ? [] : targetSelection, + targetsAllHosts: editingSnippet.targetsAllHosts || undefined, + }), [editingSnippet, targetSelection]); + + const runTargets = useMemo( + () => getRunnableHostsForSnippet(runnableEditingSnippet, hosts), + [hosts, runnableEditingSnippet], + ); + + const runEditingScript = () => { + if (runTargets.length === 0) return; + onRunSnippet?.(runnableEditingSnippet, runTargets); + }; + + const canRunEditingScript = Boolean(editingSnippet.command?.trim()) && runTargets.length > 0; + + const handleTargetsAllHostsChange = (checked: boolean) => { + if (checked) { + setTargetSelection([]); + setEditingSnippet({ + ...editingSnippet, + targetsAllHosts: true, + targets: [], + }); + return; + } + setEditingSnippet({ + ...editingSnippet, + targetsAllHosts: undefined, + }); + }; if (rightPanelMode === 'select-targets') { return ( @@ -77,6 +127,7 @@ export const SnippetsRightPanel: React.FC = ({ onCreateGroup={onCreateGroup} title={t('snippets.targets.add')} layout="inline" + {...snippetsPanelResizeProps} /> ); } @@ -86,8 +137,11 @@ export const SnippetsRightPanel: React.FC = ({ {editingSnippet.id && ( @@ -115,7 +169,7 @@ export const SnippetsRightPanel: React.FC = ({ variant="ghost" size="icon" className="h-8 w-8" - onClick={handleSubmit} + onClick={handleSave} disabled={!editingSnippet.label || !editingSnippet.command} aria-label={t('common.save')} > @@ -125,7 +179,7 @@ export const SnippetsRightPanel: React.FC = ({ } > - {/* Action Description */} + {!isEditingScript ? (

{t('snippets.field.description')}

= ({ spellCheck={false} />
+ ) : null} {/* Package */} @@ -163,40 +218,64 @@ export const SnippetsRightPanel: React.FC = ({ /> - {/* Script */} - - setEditingSnippet({ ...editingSnippet, command })} + {/* Script / Snippet body */} + {isEditingScript ? ( + setScriptEditorModalOpen(true)} + onRun={canRunEditingScript ? runEditingScript : undefined} /> -

- {t('snippets.field.variablesHelp')} -

- {detectedVariables.length > 0 && ( -
- - {t('snippets.field.variablesDetected')}: - - {detectedVariables.map((variable) => ( - - {variable.name} - {variable.defaultValue !== undefined && ( - - ({t('snippets.field.variableDefault', { value: variable.defaultValue })}) + ) : ( + + setEditingSnippet({ ...editingSnippet, command })} + /> +

+ {t('snippets.field.variablesHelp')} +

+ {detectedVariables.length > 0 && ( +
+ + {t('snippets.field.variablesDetected')}: + + {detectedVariables.map((variable) => ( + + {variable.name} + {variable.defaultValue !== undefined && ( + + ({t('snippets.field.variableDefault', { value: variable.defaultValue })}) + + )} - )} - - ))} -
- )} + ))} +
+ )}
+ )} - {/* No Auto Run */} + + + {!isEditingScript ? ( + <>
{/* Footer */} + {isEditingScript ? ( + setScriptEditorModalOpen(false)} + snippet={editingSnippet as import('../types').Snippet} + onChange={setEditingSnippet} + onSave={handleSave} + canRun={canRunEditingScript} + onRun={canRunEditingScript ? runEditingScript : undefined} + targetHosts={targetHosts} + hosts={hosts} + customGroups={customGroups} + selectedHostIds={targetSelection} + onSelectHost={handleTargetSelect} + targetsAllHosts={Boolean(editingSnippet.targetsAllHosts)} + onTargetsAllHostsChange={handleTargetsAllHostsChange} + /> + ) : null}
); } @@ -314,6 +379,7 @@ export const SnippetsRightPanel: React.FC = ({ showBackButton={true} onBack={handleClosePanel} layout="inline" + {...snippetsPanelResizeProps} > {/* History List */}
= ({ restoreTerminalCwd = false, startupCommand, noAutoRun, + pendingScriptId, + pendingScript, reuseConnectionFromSessionId, serialConfig, hotkeyScheme = "disabled", @@ -246,6 +267,62 @@ const TerminalComponent: React.FC = ({ // Timeout for connection - increased to 120s to allow time for keyboard-interactive (2FA) authentication const CONNECTION_TIMEOUT = 120000; const { t } = useI18n(); + const connectScriptsConsumedRef = useRef(false); + const connectScriptsCompletedIdsRef = useRef(new Set()); + const connectScriptsInFlightRef = useRef(false); + const pendingScriptRunIdRef = useRef(null); + const pendingScriptHandledRef = useRef(null); + const [connectScriptRetryTick, setConnectScriptRetryTick] = useState(0); + const [saveRecordingOpen, setSaveRecordingOpen] = useState(false); + const [recordedCode, setRecordedCode] = useState(''); + const recorder = useScriptRecorder(sessionId); + const recorderRef = useRef(recorder); + recorderRef.current = recorder; + const passwordPromptActiveRef = useRef(false); + const [activeScriptRun, setActiveScriptRun] = useState(undefined); + const dismissedScriptRunIdRef = useRef(null); + + useEffect(() => { + return subscribeScriptRuns((runs) => { + const sessionRuns = runs.filter((run) => run.sessionId === sessionId); + const liveRun = sessionRuns.find((run) => run.status === 'running' || run.status === 'paused'); + if (liveRun) { + dismissedScriptRunIdRef.current = null; + setActiveScriptRun(liveRun); + return; + } + + const finishedRun = sessionRuns + .filter((run) => + (run.status === 'completed' || run.status === 'failed') + && run.runId !== dismissedScriptRunIdRef.current, + ) + .sort((a, b) => (b.endedAt ?? 0) - (a.endedAt ?? 0))[0]; + + setActiveScriptRun(finishedRun); + }); + }, [sessionId]); + + const dismissScriptOverlay = useCallback(() => { + if (activeScriptRun) { + dismissedScriptRunIdRef.current = activeScriptRun.runId; + } + setActiveScriptRun(undefined); + }, [activeScriptRun]); + const outputTriggers = useOutputTriggers({ + sessionId, + hostId: host.id, + snippets, + onRunScript: (snippet, sid) => runAutomationScript({ snippet, sessionId: sid }).catch((err) => { + const message = err instanceof Error ? err.message : String(err); + toast.error(message.includes('Observer mode') ? t('scripts.observer.blocked') : message); + throw err; + }), + }); + const appendOutputTriggerOutputRef = useRef(outputTriggers.appendOutput); + appendOutputTriggerOutputRef.current = outputTriggers.appendOutput; + const noteOutputTriggerUserInputRef = useRef(outputTriggers.noteUserInput); + noteOutputTriggerUserInputRef.current = outputTriggers.noteUserInput; const availableFonts = useAvailableFonts(); const containerRef = useRef(null); const termRef = useRef(null); @@ -618,6 +695,12 @@ const TerminalComponent: React.FC = ({ break; } else if (ch === "\r" || ch === "\n") { const rawCommand = commandBufferRef.current; + if (recorderRef.current.isRecording) { + void recorderRef.current.recordEnter({ + sensitive: passwordPromptActiveRef.current, + }); + passwordPromptActiveRef.current = false; + } recordTerminalCommandExecution(rawCommand, { host, sessionId, @@ -629,11 +712,14 @@ const TerminalComponent: React.FC = ({ } else if (ch === "\x15") { // Ctrl+U: clear line — reset command buffer (fuzzy match sends this) commandBufferRef.current = ""; + recorderRef.current.recordClearLine(); } else if (ch === "\b" || ch === "\x7f") { // Backspace: remove last character (Windows fuzzy replacement uses \b) commandBufferRef.current = commandBufferRef.current.slice(0, -1); + recorderRef.current.recordBackspace(); } else if (ch.charCodeAt(0) >= 32) { commandBufferRef.current += ch; + recorderRef.current.recordInput(ch); } } } @@ -1253,8 +1339,19 @@ const TerminalComponent: React.FC = ({ }, onTerminalDataCapture: handleTerminalDataCaptureOnce, onTerminalOutput: onTerminalOutput - ? (chunk: string) => onTerminalOutput(sessionId, chunk) - : undefined, + ? (chunk: string) => { + if (/password|passphrase|口令/i.test(chunk)) { + passwordPromptActiveRef.current = true; + } + appendOutputTriggerOutputRef.current(chunk); + onTerminalOutput(sessionId, chunk); + } + : (chunk: string) => { + if (/password|passphrase|口令/i.test(chunk)) { + passwordPromptActiveRef.current = true; + } + appendOutputTriggerOutputRef.current(chunk); + }, onTerminalLogData: captureTerminalLogData, onProgrammaticCommandLogRewrite: queueProgrammaticCommandLogRewrite, onOsDetected, @@ -1267,6 +1364,190 @@ const TerminalComponent: React.FC = ({ }); sessionStartersRef.current = sessionStarters; + useEffect(() => { + if (status === 'disconnected') { + connectScriptsConsumedRef.current = false; + connectScriptsCompletedIdsRef.current = new Set(); + } + }, [status]); + + useEffect(() => { + pendingScriptRunIdRef.current = null; + pendingScriptHandledRef.current = null; + }, [pendingScript?.id, pendingScriptId]); + + const isPendingScriptAlreadyHandled = useCallback((snippet: Snippet) => { + if (snippet.id) { + return pendingScriptRunIdRef.current === snippet.id; + } + return pendingScriptHandledRef.current === snippet; + }, []); + + useEffect(() => { + if (status !== 'connected') return; + + let pendingOne: Snippet | undefined; + if (pendingScript && isScriptSnippet(pendingScript)) { + if (!isPendingScriptAlreadyHandled(pendingScript)) { + pendingOne = pendingScript; + } + } else if (pendingScriptId) { + const script = snippets.find((item) => item.id === pendingScriptId && isScriptSnippet(item)); + if (script && !isPendingScriptAlreadyHandled(script)) { + pendingOne = script; + } + } + + const shouldEvaluateConnect = !connectScriptsConsumedRef.current; + const hasPendingWork = Boolean(pendingOne); + if (!shouldEvaluateConnect && !hasPendingWork) return; + if (connectScriptsInFlightRef.current) return; + + // Defer until xterm has rendered login output and the main-process output tap + // has populated SessionOutputBuffer (avoids waitForPrompt racing an empty buffer). + const timer = window.setTimeout(() => { + const runPending = Boolean(pendingOne); + const connectQueueNow = connectScriptsConsumedRef.current + ? [] + : resolveConnectScriptsForHost(host, snippets).filter( + (item) => item.id && !connectScriptsCompletedIdsRef.current.has(item.id), + ); + + const scriptsToRun: Snippet[] = []; + for (const item of connectQueueNow) { + scriptsToRun.push(item); + } + if (runPending && pendingOne && !scriptsToRun.some((entry) => entry.id === pendingOne.id)) { + scriptsToRun.push(pendingOne); + } + + const resolvedConnectScripts = resolveConnectScriptsForHost(host, snippets); + const allConnectScriptsDone = resolvedConnectScripts.length === 0 + || resolvedConnectScripts.every( + (item) => item.id && connectScriptsCompletedIdsRef.current.has(item.id), + ); + + if (scriptsToRun.length === 0) { + if ( + !connectScriptsConsumedRef.current + && allConnectScriptsDone + && isVaultInitialized() + && snippets.length > 0 + && !hasUnresolvedConnectScriptBindings(host, snippets) + ) { + connectScriptsConsumedRef.current = true; + } + return; + } + + const pendingScriptToMark = runPending ? pendingOne : undefined; + const connectIdsInBatch = new Set( + connectQueueNow.map((item) => item.id).filter((id): id is string => Boolean(id)), + ); + + connectScriptsInFlightRef.current = true; + + void runConnectScriptsSequential({ + scripts: scriptsToRun, + sessionId, + sessionMeta: { + connected: true, + hostname: host.hostname, + username: host.username, + }, + onScriptComplete: (snippet) => { + if (snippet.id && connectIdsInBatch.has(snippet.id)) { + connectScriptsCompletedIdsRef.current.add(snippet.id); + } + if (pendingScriptToMark && snippet === pendingScriptToMark) { + if (snippet.id) { + pendingScriptRunIdRef.current = snippet.id; + } else { + pendingScriptHandledRef.current = snippet; + } + } + }, + }) + .then(() => { + const resolvedAfterRun = resolveConnectScriptsForHost(host, snippets); + const doneAfterRun = resolvedAfterRun.length === 0 + || resolvedAfterRun.every( + (item) => item.id && connectScriptsCompletedIdsRef.current.has(item.id), + ); + if (doneAfterRun) { + connectScriptsConsumedRef.current = true; + } + }) + .catch((err) => { + const message = err instanceof Error ? err.message : String(err); + toast.error(message.includes('Observer mode') ? t('scripts.observer.blocked') : message); + setConnectScriptRetryTick((tick) => tick + 1); + }) + .finally(() => { + connectScriptsInFlightRef.current = false; + }); + }, 400); + + return () => window.clearTimeout(timer); + }, [host, isPendingScriptAlreadyHandled, pendingScript, pendingScriptId, sessionId, snippets, status, t, connectScriptRetryTick]); + + useEffect(() => { + return registerScreenSnapshotProvider(sessionId, () => { + const term = termRef.current; + if (!term?.buffer?.active) { + return { rows: 24, cols: 80, currentRow: 0, lines: [] }; + } + const buffer = term.buffer.active; + const lines: string[] = []; + for (let row = 0; row < term.rows; row += 1) { + lines.push(buffer.getLine(buffer.viewportY + row)?.translateToString(true) ?? ''); + } + return { + rows: term.rows, + cols: term.cols, + currentRow: buffer.baseY + buffer.cursorY, + lines, + }; + }); + }, [sessionId]); + + useEffect(() => { + const startHandler = (event: Event) => { + const detail = (event as CustomEvent<{ sessionId?: string }>).detail; + if (detail?.sessionId !== sessionId) return; + void recorderRef.current.startRecording(); + }; + const stopHandler = (event: Event) => { + const detail = (event as CustomEvent<{ sessionId?: string }>).detail; + if (detail?.sessionId !== sessionId) return; + if (!recorderRef.current.isRecording) return; + void recorderRef.current.stopRecording().then(({ code }) => { + setRecordedCode(code); + setSaveRecordingOpen(true); + }); + }; + window.addEventListener('netcatty:script:recording:start', startHandler); + window.addEventListener('netcatty:script:recording:stop', stopHandler); + return () => { + window.removeEventListener('netcatty:script:recording:start', startHandler); + window.removeEventListener('netcatty:script:recording:stop', stopHandler); + }; + }, [sessionId]); + + useEffect(() => { + if (recorder.isRecording) { + setScriptRecordingState(sessionId, recorder.isPaused); + } else if (getScriptRecordingSnapshot().sessionId === sessionId) { + setScriptRecordingState(null); + } + }, [recorder.isRecording, recorder.isPaused, sessionId]); + + useEffect(() => () => { + if (getScriptRecordingSnapshot().sessionId === sessionId) { + setScriptRecordingState(null); + } + }, [sessionId]); + useEffect(() => { setConnectionReuseFellBack(false); if (!reuseConnectionFromSessionId) return undefined; @@ -1381,6 +1662,20 @@ const TerminalComponent: React.FC = ({ } }, []); + useEffect(() => { + const bridge = netcattyBridge.get(); + const dispose = bridge?.onScriptSessionInput?.(({ sessionId: sid, data }) => { + if (sid !== sessionId) return; + scrollToBottomAfterProgrammaticInput(data); + }); + return dispose; + }, [scrollToBottomAfterProgrammaticInput, sessionId]); + + useEffect(() => { + if (!activeScriptRun) return; + termRef.current?.scrollToBottom(); + }, [activeScriptRun]); + const broadcastUserPasteData = useCallback((data: string) => { if (sessionRef.current && isBroadcastEnabledRef.current && onBroadcastInputRef.current) { onBroadcastInputRef.current(data, sessionId); @@ -1435,10 +1730,19 @@ const TerminalComponent: React.FC = ({ }, [prepareProgrammaticSudoInput, scrollToBottomAfterProgrammaticInput, terminalBackend, sessionId]); const executeSnippet = useCallback(async (snippet: Snippet) => { + if (isScriptSnippet(snippet)) { + try { + await runAutomationScript({ snippet, sessionId }); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + toast.error(message.includes('Observer mode') ? t('scripts.observer.blocked') : message); + } + return; + } const command = await resolveSnippetCommand(snippet); if (command === null) return; executeSnippetCommand(command, snippet.noAutoRun); - }, [executeSnippetCommand]); + }, [executeSnippetCommand, sessionId, t]); const onSnippetShortkeyRef = useRef(executeSnippet); onSnippetShortkeyRef.current = executeSnippet; @@ -1820,6 +2124,19 @@ const TerminalComponent: React.FC = ({ }; }, [getManualSessionLogStatus, sessionId, status]); + const handleToolbarRecordingToggle = useCallback(() => { + const recording = getScriptRecordingSnapshot(); + if (recording.sessionId && recording.sessionId !== sessionId) { + toast.error(t('scripts.recording.alreadyActive')); + return; + } + if (recording.sessionId === sessionId) { + window.dispatchEvent(new CustomEvent('netcatty:script:recording:stop', { detail: { sessionId } })); + return; + } + window.dispatchEvent(new CustomEvent('netcatty:script:recording:start', { detail: { sessionId } })); + }, [sessionId, t]); + const renderControls = useCallback((opts?: { showClose?: boolean }) => ( = ({ onToggleComposeBar={inWorkspace ? onToggleComposeBar : () => setIsComposeBarOpen(prev => !prev)} terminalEncoding={terminalEncoding} onSetTerminalEncoding={handleSetTerminalEncoding} + recordingIndicator={recorder.isRecording ? ( + { + void recorder.stopRecording().then(({ code }) => { + setRecordedCode(code); + setSaveRecordingOpen(true); + }); + }} + /> + ) : undefined} + onStartRecording={status === 'connected' ? handleToolbarRecordingToggle : undefined} /> ), [ compactToolbar, @@ -1863,6 +2195,7 @@ const TerminalComponent: React.FC = ({ handleSetTerminalEncoding, handleToggleSessionLog, handleToggleSearch, + handleToolbarRecordingToggle, host, inWorkspace, isLocalConnection, @@ -1884,6 +2217,7 @@ const TerminalComponent: React.FC = ({ snippets, status, terminalEncoding, + recorder, ]); const statusDotTone = @@ -1929,6 +2263,9 @@ const TerminalComponent: React.FC = ({ onCommandExecuted, onCommandSubmitted, commandBufferRef, + scriptRecorderRef: recorderRef, + passwordPromptActiveRef, + onOutputTriggerUserInputRef: noteOutputTriggerUserInputRef, promptLineBreakStateRef, sudoAutofillRef, setIsSearchOpen, @@ -2076,9 +2413,34 @@ const TerminalComponent: React.FC = ({ onWake: wakeFromHibernateRuntime, }); - useTerminalEffects({ CONNECTION_TIMEOUT, Error, XTERM_PERFORMANCE_CONFIG, applyUserCursorPreference, auth, autocompleteCloseRef, autocompleteInputRef, autocompleteKeyEventRef, captureTerminalLogData, clearTerminalCwd, commandBufferRef, connectionLogBufferRef, containerRef, createPromptLineBreakState, createReplaySafeTerminalLogSanitizer, createXTermRuntime, deferTerminalResizeRef, disableTerminalFontZoomRef, effectiveFontSize, effectiveFontWeight, effectiveTheme, error, executeSnippetCommand, finalizeTerminalLogData, fitAddonRef, fontFamilyId, fontSize, fontWeightFixupDoneRef, forceCloseHibernatedSession, forceSyncRenderAfterResize, handleOsc52ReadRequest, handleTerminalDataCaptureOnce, hasConnectedRef, hasRuntimeRef, host, hotkeySchemeRef, hibernatedRef, identities, inWorkspace, isBootActiveRef, isBroadcastEnabledRef, isComposeBarOpen: effectiveComposeBarOpen, isFocusMode, isFocused, isLocalConnection, isNetworkDevice, isResizing: deferTerminalResize, isRestoringSelectionRef, isSearchOpen, isSerialConnection, isVisible, isVisibleRef, keyBindingsRef, keys, knownCwdRef, lastFittedSizeRef, lastToastedErrorRef, logger, mouseTrackingRef, onBroadcastInputRef, onBroadcastInterruptPriorityChange, onCommandExecuted, onCommandSubmitted, onHotkeyActionRef, onSnippetShortkeyRef, onSnippetExecutorChange, onTerminalCwdChange, onTerminalTitleChange, onTerminalBell, onTerminalFontSizeChange, paneLayoutKey, pendingAuthRef, pendingOutputScrollRef, prepareRestoredReconnect, prevIsResizingRef, promptLineBreakStateRef, resizeSession, resolveHostAuth, resolvedFontFamily, safeFit, searchAddonRef, serialConfig, serialLineBufferRef, serializeAddonRef, sessionId, sessionRef, sessionStarters, setError, setHasMouseTracking, setHasSelection, setIsCancelling, setIsDisconnectedDialogDismissed, setIsSearchOpen, setNeedsHostKeyVerification, setPendingHostKeyInfo, setPendingHostKeyRequestId, setProgressLogs, setProgressValue, setSelectionOverlayPosition, setShowLogs, setStatus, setTimeLeft, shouldEnableNativeUserInputAutoScroll, shouldProbeSessionCwd, shouldStartTerminalBackend, snippetsRef, status, statusRef, sudoAutofillRef, t, teardown, telnetLocalEchoRef, termRef, terminalAltKeyOptions, terminalBackend, terminalContextActionsRef, terminalCwdTracker, terminalDataCapturedRef, terminalLogSanitizerRef, terminalSettings, terminalSettingsRef, toHostKeyInfo, toast, updateStatus, useEffect, useLayoutEffect, xtermRuntimeRef, zmodem, zmodemToastedRef, restoreState }); + useTerminalEffects({ CONNECTION_TIMEOUT, Error, XTERM_PERFORMANCE_CONFIG, applyUserCursorPreference, auth, autocompleteCloseRef, autocompleteInputRef, autocompleteKeyEventRef, captureTerminalLogData, clearTerminalCwd, commandBufferRef, connectionLogBufferRef, containerRef, createPromptLineBreakState, createReplaySafeTerminalLogSanitizer, createXTermRuntime, deferTerminalResizeRef, disableTerminalFontZoomRef, effectiveFontSize, effectiveFontWeight, effectiveTheme, error, executeSnippetCommand, finalizeTerminalLogData, fitAddonRef, fontFamilyId, fontSize, fontWeightFixupDoneRef, forceCloseHibernatedSession, forceSyncRenderAfterResize, handleOsc52ReadRequest, handleTerminalDataCaptureOnce, hasConnectedRef, hasRuntimeRef, host, hotkeySchemeRef, hibernatedRef, identities, inWorkspace, isBootActiveRef, isBroadcastEnabledRef, isComposeBarOpen: effectiveComposeBarOpen, isFocusMode, isFocused, isLocalConnection, isNetworkDevice, isResizing: deferTerminalResize, isRestoringSelectionRef, isSearchOpen, isSerialConnection, isVisible, isVisibleRef, keyBindingsRef, keys, knownCwdRef, lastFittedSizeRef, lastToastedErrorRef, logger, mouseTrackingRef, onBroadcastInputRef, onBroadcastInterruptPriorityChange, onCommandExecuted, onCommandSubmitted, onHotkeyActionRef, onOutputTriggerUserInputRef: noteOutputTriggerUserInputRef, onSnippetShortkeyRef, onSnippetExecutorChange, onTerminalCwdChange, onTerminalTitleChange, onTerminalBell, onTerminalFontSizeChange, paneLayoutKey, passwordPromptActiveRef, pendingAuthRef, pendingOutputScrollRef, prepareRestoredReconnect, prevIsResizingRef, promptLineBreakStateRef, resizeSession, resolveHostAuth, resolvedFontFamily, safeFit, scriptRecorderRef: recorderRef, searchAddonRef, serialConfig, serialLineBufferRef, serializeAddonRef, sessionId, sessionRef, sessionStarters, setError, setHasMouseTracking, setHasSelection, setIsCancelling, setIsDisconnectedDialogDismissed, setIsSearchOpen, setNeedsHostKeyVerification, setPendingHostKeyInfo, setPendingHostKeyRequestId, setProgressLogs, setProgressValue, setSelectionOverlayPosition, setShowLogs, setStatus, setTimeLeft, shouldEnableNativeUserInputAutoScroll, shouldProbeSessionCwd, shouldStartTerminalBackend, snippetsRef, status, statusRef, sudoAutofillRef, t, teardown, telnetLocalEchoRef, termRef, terminalAltKeyOptions, terminalBackend, terminalContextActionsRef, terminalCwdTracker, terminalDataCapturedRef, terminalLogSanitizerRef, terminalSettings, terminalSettingsRef, toHostKeyInfo, toast, updateStatus, useEffect, useLayoutEffect, xtermRuntimeRef, zmodem, zmodemToastedRef, restoreState }); - return ; + return ( + <> + { void pauseScriptRun(activeScriptRun.runId); }} + onResume={() => { void resumeScriptRun(activeScriptRun.runId); }} + onStop={() => { void stopScriptRun(activeScriptRun.runId); }} + onDismiss={dismissScriptOverlay} + /> + ) : null, selectionOverlayPosition, sessionDisplayName, sessionId, sessionRef, setIsComposeBarOpen, setShowLogs, shouldShowConnectionDialog, showLogs, showSelectionAIAction, snippets, status, statusDotTone, sudoHintRef, sudoHintText: t("terminal.sudoHint.pressEnter"), t, termRef, terminalBackend, terminalContextActions, terminalCwdTracker, terminalPreviewVars, terminalSettings, timeLeft, toast, zmodem }} /> + setSaveRecordingOpen(false)} + onSave={({ name, packagePath, code, editAfterSave }) => { + window.dispatchEvent(new CustomEvent('netcatty:scripts:save-recorded', { + detail: { name, packagePath, code, editAfterSave }, + })); + setSaveRecordingOpen(false); + }} + /> + + ); }; const Terminal = memo(TerminalComponent, terminalPropsAreEqual); diff --git a/components/TerminalLayer.tsx b/components/TerminalLayer.tsx index 33fa1fe53..fd9cbca87 100644 --- a/components/TerminalLayer.tsx +++ b/components/TerminalLayer.tsx @@ -1,6 +1,7 @@ import { FolderTree, History, MessageSquare, PanelLeft, PanelRight, Palette, X, Zap } from 'lucide-react'; import React, { memo, startTransition, useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { activeTabStore } from '../application/state/activeTabStore'; +import { getScriptRecordingSnapshot } from '../application/state/scriptRecordingStore.ts'; import { canReuseTerminalConnection } from '../application/state/terminalConnectionReuse'; import { resolveTerminalSessionExitIntent, type TerminalSessionExitEvent } from '../application/state/resolveTerminalSessionExitIntent'; import { prewarmAIStateStorageSnapshots } from '../application/state/aiStateSnapshots'; @@ -38,6 +39,7 @@ import { resolveTerminalSessionHost, } from '../domain/terminalHostResolution'; import { Tooltip, TooltipContent, TooltipTrigger } from './ui/tooltip'; +import { toast } from './ui/toast'; import { useI18n } from '../application/i18n/I18nProvider'; import { SftpSidePanel } from './SftpSidePanel'; import { ScriptsSidePanel } from './ScriptsSidePanel'; @@ -46,6 +48,15 @@ import { NotesManager } from './notes/NotesManager'; import { useRemoteHistoryState } from '../application/state/useRemoteHistoryState'; import { resolveSnippetCommand } from './SnippetExecutionProvider'; import type { Snippet } from '../types'; +import { isScriptSnippet } from '../domain/snippetScript.ts'; +import { useScriptExecution } from '../application/state/useScriptExecution'; +import { + pauseScriptRun, + resumeScriptRun, + runAutomationScript, + stopScriptRun, + waitForScriptRun, +} from '../application/state/scriptAutomationCoordinator'; import { ThemeSidePanel } from './terminal/ThemeSidePanel'; import { focusTerminalSessionInput } from './terminal/focusTerminalSession'; import { TerminalComposeBar } from './terminal/TerminalComposeBar'; @@ -98,6 +109,21 @@ const removeMountedSidePanelTabId = ( tabId: string, ): string[] => tabIds.filter((id) => id !== tabId); +function buildScriptSessionMeta( + sessionId: string, + sessions: TerminalSession[], + hosts: Host[], +) { + const session = sessions.find((entry) => entry.id === sessionId); + if (!session) return undefined; + const host = hosts.find((entry) => entry.id === session.hostId); + return { + connected: session.status === 'connected', + hostname: host?.hostname ?? session.hostname, + username: host?.username ?? session.username, + }; +} + const TerminalLayerInner: React.FC = ({ hosts, portForwardingRules = [], @@ -197,6 +223,7 @@ const TerminalLayerInner: React.FC = ({ onRemoveSessionFromWorkspace, }) => { const { t } = useI18n(); + const { runs } = useScriptExecution(); const terminalRendererCwdBySessionRef = useRef>(new Map()); const stableRef = useRef>({}); const activeTabIdRef = useRef(activeTabStore.getActiveTabId()); @@ -1270,10 +1297,128 @@ const TerminalLayerInner: React.FC = ({ ); const handleSnippetFromPanel = useCallback(async (snippet: Snippet) => { + const sessionId = getActiveTerminalSessionId(); + if (!sessionId) return; + if (isScriptSnippet(snippet)) { + try { + await runAutomationScript({ + snippet, + sessionId, + sessionMeta: buildScriptSessionMeta(sessionId, sessionsRef.current, hosts), + }); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + toast.error(message.includes('Observer mode') ? t('scripts.observer.blocked') : message); + } + return; + } const command = await resolveSnippetCommand(snippet); if (command === null) return; handleSnippetClickForFocusedSession(command, snippet.noAutoRun); - }, [handleSnippetClickForFocusedSession]); + }, [getActiveTerminalSessionId, handleSnippetClickForFocusedSession, hosts, t]); + + const handleRunScriptFromPanel = useCallback(async (snippet: Snippet) => { + const sessionId = getActiveTerminalSessionId(); + if (!sessionId) return; + try { + await runAutomationScript({ + snippet, + sessionId, + sessionMeta: buildScriptSessionMeta(sessionId, sessionsRef.current, hosts), + }); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + toast.error(message.includes('Observer mode') ? t('scripts.observer.blocked') : message); + } + }, [getActiveTerminalSessionId, hosts, t]); + + const handleRunScriptOnWorkspace = useCallback(async ( + snippet: Snippet, + mode: 'sequential' | 'parallel' = 'parallel', + ) => { + const workspace = activeWorkspaceRef.current; + if (!workspace) { + const sessionId = getActiveTerminalSessionId(); + if (!sessionId) { + toast.error(t('scripts.recording.noSession')); + return; + } + try { + await runAutomationScript({ + snippet, + sessionId, + sessionMeta: buildScriptSessionMeta(sessionId, sessionsRef.current, hosts), + }); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + toast.error(message.includes('Observer mode') ? t('scripts.observer.blocked') : message); + } + return; + } + const workspaceSessions = sessionsRef.current.filter((session) => session.workspaceId === workspace.id); + const sessionIds = workspaceSessions + .filter((session) => session.status === 'connected') + .map((session) => session.id); + const skippedConnecting = workspaceSessions.filter((session) => session.status === 'connecting').length; + if (sessionIds.length === 0) { + if (skippedConnecting > 0) { + toast.info(t('scripts.actions.skippedConnectingSessions', { count: skippedConnecting })); + } else { + toast.error(t('scripts.recording.noSession')); + } + return; + } + if (skippedConnecting > 0) { + toast.info(t('scripts.actions.skippedConnectingSessions', { count: skippedConnecting })); + } + try { + const runOnSession = (sid: string) => runAutomationScript({ + snippet, + sessionId: sid, + sessionMeta: buildScriptSessionMeta(sid, sessionsRef.current, hosts), + }); + if (mode === 'sequential') { + for (const sid of sessionIds) { + const { runId } = await runOnSession(sid); + await waitForScriptRun(runId); + } + } else { + await Promise.all(sessionIds.map((sid) => runOnSession(sid))); + } + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + toast.error(message.includes('Observer mode') ? t('scripts.observer.blocked') : message); + } + }, [getActiveTerminalSessionId, hosts, t]); + + useEffect(() => { + const handler = (event: Event) => { + const snippet = (event as CustomEvent<{ snippet: Snippet }>).detail?.snippet; + if (!snippet) return; + void handleRunScriptFromPanel(snippet); + }; + window.addEventListener('netcatty:scripts:run-on-focused', handler); + return () => window.removeEventListener('netcatty:scripts:run-on-focused', handler); + }, [handleRunScriptFromPanel]); + + const handleStartRecordingFromPanel = useCallback(() => { + const sessionId = getActiveTerminalSessionId(); + if (!sessionId) { + toast.error(t('scripts.recording.noSession')); + return; + } + const recording = getScriptRecordingSnapshot(); + if (recording.sessionId === sessionId) { + window.dispatchEvent(new CustomEvent('netcatty:script:recording:stop', { detail: { sessionId } })); + return; + } + if (recording.sessionId) { + toast.error(t('scripts.recording.alreadyActive')); + return; + } + window.dispatchEvent(new CustomEvent('netcatty:script:recording:start', { detail: { sessionId } })); + toast.info(t('scripts.recording.started')); + }, [getActiveTerminalSessionId, t]); const handleComposeSend = useCallback((text: string) => { const activeWorkspace = activeWorkspaceRef.current; @@ -1388,6 +1533,13 @@ const TerminalLayerInner: React.FC = ({ persistSidePanelWidth, handleSnippetClickForFocusedSession, handleSnippetFromPanel, + handleRunScriptFromPanel, + handleRunScriptOnWorkspace, + handleStartRecordingFromPanel, + scriptRuns: runs, + handleStopScriptRun: stopScriptRun, + handlePauseScriptRun: pauseScriptRun, + handleResumeScriptRun: resumeScriptRun, handleSnippetExecutorChange, handleProgrammaticCommandLogRewriteChange, handleStatusChange, diff --git a/components/host/HostDetailsScriptsSection.tsx b/components/host/HostDetailsScriptsSection.tsx new file mode 100644 index 000000000..3b078444e --- /dev/null +++ b/components/host/HostDetailsScriptsSection.tsx @@ -0,0 +1,236 @@ +import { Globe, Link2, Play, Trash2 } from 'lucide-react'; +import React, { useCallback, useMemo, useState, type DragEvent } from 'react'; +import type { Host, Snippet } from '@/domain/models'; +import { + appendHostConnectScript, + getGlobalConnectScripts, + getHostConnectScriptIds, + removeHostConnectScript, + reorderHostConnectScript, +} from '@/domain/hostConnectScripts.ts'; +import { isScriptSnippet } from '@/domain/snippetScript.ts'; +import { cn } from '@/lib/utils'; +import { getVaultDropPosition } from '@/components/vault/vaultReorderDrag'; +import { + VaultEntityIcon, + vaultAutomationScriptIconClass, + vaultEntityIconSmClass, +} from '@/components/vault/VaultEntityIcon'; +import { HostDetailsSection } from '../host-details'; +import { Button } from '../ui/button'; +import { Combobox } from '../ui/combobox'; + +const CONNECT_QUEUE_DRAG_TYPE = 'application/x-netcatty-connect-script-id'; + +export interface HostDetailsScriptsSectionProps { + host: Host; + onHostChange: (host: Host) => void; + snippets: Snippet[]; + t: (key: string, params?: Record) => string; +} + +function triggerLabel( + snippet: Snippet, + t: HostDetailsScriptsSectionProps['t'], +): string { + if (snippet.trigger === 'onConnect') return t('scripts.trigger.onConnect'); + if (snippet.trigger === 'onOutput') return t('scripts.trigger.onOutput'); + return t('scripts.trigger.manual'); +} + +function scriptById(snippets: Snippet[], scriptId: string): Snippet | undefined { + return snippets.find((snippet) => snippet.id === scriptId && isScriptSnippet(snippet)); +} + +export const HostDetailsScriptsSection: React.FC = ({ + host, + onHostChange, + snippets, + t, +}) => { + const [draggingScriptId, setDraggingScriptId] = useState(null); + const [dropIndicator, setDropIndicator] = useState<{ scriptId: string; position: 'before' | 'after' } | null>(null); + + const scripts = useMemo( + () => snippets.filter(isScriptSnippet), + [snippets], + ); + const globalScripts = useMemo( + () => getGlobalConnectScripts(snippets), + [snippets], + ); + const queueIds = useMemo( + () => getHostConnectScriptIds(host, snippets), + [host, snippets], + ); + const queuedScripts = useMemo( + () => queueIds + .map((id) => scriptById(snippets, id)) + .filter((snippet): snippet is Snippet => Boolean(snippet)), + [queueIds, snippets], + ); + const linkableScripts = useMemo( + () => scripts.filter((script) => { + if (script.targetsAllHosts) return false; + if (script.id && queueIds.includes(script.id)) return false; + return true; + }), + [queueIds, scripts], + ); + const linkOptions = useMemo( + () => linkableScripts.map((script) => ({ + value: script.id, + label: script.label || t('scripts.running.unnamed'), + })), + [linkableScripts, t], + ); + + const clearDragState = useCallback(() => { + setDraggingScriptId(null); + setDropIndicator(null); + }, []); + + if (scripts.length === 0) return null; + + const handleAddToQueue = (scriptId: string) => { + if (!scriptId) return; + const snippet = scriptById(snippets, scriptId); + if (!snippet) return; + onHostChange(appendHostConnectScript(host, scriptId, snippets)); + }; + + const handleRemoveFromQueue = (scriptId: string) => { + onHostChange(removeHostConnectScript(host, scriptId, snippets)); + }; + + const handleDragStart = (event: DragEvent, scriptId: string) => { + event.dataTransfer.effectAllowed = 'move'; + event.dataTransfer.setData(CONNECT_QUEUE_DRAG_TYPE, scriptId); + setDraggingScriptId(scriptId); + }; + + const handleDragOver = (event: DragEvent, scriptId: string) => { + if (!event.dataTransfer.types.includes(CONNECT_QUEUE_DRAG_TYPE)) return; + event.preventDefault(); + event.dataTransfer.dropEffect = 'move'; + const position = getVaultDropPosition(event.currentTarget, event.clientX, event.clientY, false); + setDropIndicator((prev) => ( + prev?.scriptId === scriptId && prev.position === position + ? prev + : { scriptId, position } + )); + }; + + const handleDrop = (event: DragEvent, targetScriptId: string) => { + event.preventDefault(); + const draggedScriptId = event.dataTransfer.getData(CONNECT_QUEUE_DRAG_TYPE); + if (!draggedScriptId || draggedScriptId === targetScriptId) { + clearDragState(); + return; + } + const position = getVaultDropPosition(event.currentTarget, event.clientX, event.clientY, false); + onHostChange(reorderHostConnectScript(host, draggedScriptId, targetScriptId, position, snippets)); + clearDragState(); + }; + + return ( + } + title={t('hostDetails.section.automation')} + hint={t('hostDetails.automation.queueHint')} + > +
+ {globalScripts.length > 0 ? ( +
+
+ + +
+

+ {t('hostDetails.automation.globalScriptsHint')} +

+
+ {globalScripts.map((script) => ( +
+ } + /> +
+
{script.label || t('scripts.running.unnamed')}
+
{triggerLabel(script, t)}
+
+
+ ))} +
+
+ ) : null} + +
+ {queuedScripts.length === 0 ? ( +

{t('hostDetails.automation.connectQueueEmpty')}

+ ) : ( +
+ {queuedScripts.map((script) => { + const scriptId = script.id!; + const isDragging = draggingScriptId === scriptId; + const indicator = dropIndicator?.scriptId === scriptId ? dropIndicator.position : null; + + return ( +
handleDragStart(event, scriptId)} + onDragOver={(event) => handleDragOver(event, scriptId)} + onDrop={(event) => handleDrop(event, scriptId)} + > + } + /> +
+
{script.label || t('scripts.running.unnamed')}
+
{triggerLabel(script, t)}
+
+ +
+ ); + })} +
+ )} + + {linkOptions.length > 0 ? ( + } + triggerClassName="h-9" + /> + ) : null} +
+
+
+ ); +}; diff --git a/components/scripts/QuickScriptEditorDialog.tsx b/components/scripts/QuickScriptEditorDialog.tsx new file mode 100644 index 000000000..88ec80ee5 --- /dev/null +++ b/components/scripts/QuickScriptEditorDialog.tsx @@ -0,0 +1,268 @@ +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, +} 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 { + return { + label: '', + command: DEFAULT_SCRIPT_TEMPLATE, + package: '', + targets: [], + kind: 'script', + language: 'javascript', + trigger: 'manual', + }; +} + +export const QuickScriptEditorDialog: React.FC = ({ + snippets, + packages, + hosts, + customGroups = [], + onCreateSnippet, + onUpdateSnippet, + onCreatePackage, + onUpdateHosts, + onRunSnippet, +}) => { + const { t } = useI18n(); + const [open, setOpen] = useState(false); + const [editingSnippet, setEditingSnippet] = useState>(createBlankScript); + const [targetSelection, setTargetSelection] = useState([]); + + useEffect(() => { + const handler = () => { + setEditingSnippet(createBlankScript()); + setTargetSelection([]); + 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 ?? [])); + 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([]); + 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, + targetsAllHosts: editingSnippet.targetsAllHosts || undefined, + }), [editingSnippet, 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, + 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, 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 handleTargetsAllHostsChange = useCallback((checked: boolean) => { + if (checked) { + setTargetSelection([]); + setEditingSnippet((prev) => ({ + ...prev, + targetsAllHosts: true, + targets: [], + })); + return; + } + setEditingSnippet((prev) => ({ + ...prev, + targetsAllHosts: undefined, + })); + }, []); + + return ( + 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} + targetsAllHosts={Boolean(editingSnippet.targetsAllHosts)} + onTargetsAllHostsChange={handleTargetsAllHostsChange} + /> + ); +}; diff --git a/components/scripts/ScriptAutomationRoot.tsx b/components/scripts/ScriptAutomationRoot.tsx new file mode 100644 index 000000000..998297021 --- /dev/null +++ b/components/scripts/ScriptAutomationRoot.tsx @@ -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 ; +} diff --git a/components/scripts/ScriptCodeEditor.tsx b/components/scripts/ScriptCodeEditor.tsx new file mode 100644 index 000000000..5fd355b9b --- /dev/null +++ b/components/scripts/ScriptCodeEditor.tsx @@ -0,0 +1,93 @@ +import Editor, { loader, type Monaco, type OnMount, useMonaco } from '@monaco-editor/react'; +import { Loader2 } from 'lucide-react'; +import React, { useCallback, useEffect, useRef } from 'react'; +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'; + /** 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; +} + +export const ScriptCodeEditor: React.FC = ({ + value, + onChange, + language, + fill = false, + height = 240, + minimap = false, + active = true, +}) => { + const monaco = useMonaco(); + const themeName = useNetcattyMonacoTheme(monaco ?? undefined); + const editorRef = useRef(null); + const completionDisposableRef = useRef<{ dispose: () => void } | null>(null); + + 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 handleMount: OnMount = useCallback((editor, monacoInstance) => { + editorRef.current = editor; + completionDisposableRef.current?.dispose(); + completionDisposableRef.current = registerNctMonacoCompletionProvider(monacoInstance); + requestAnimationFrame(() => editor.layout()); + }, []); + + const editorHeight = fill ? '100%' : `${height}px`; + + return ( +
+ onChange(next ?? '')} + onMount={handleMount} + theme={themeName} + loading={( +
+ +
+ )} + options={{ + 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 }, + }} + /> +
+ ); +}; diff --git a/components/scripts/ScriptDialogHost.tsx b/components/scripts/ScriptDialogHost.tsx new file mode 100644 index 000000000..d867bee51 --- /dev/null +++ b/components/scripts/ScriptDialogHost.tsx @@ -0,0 +1,90 @@ +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 { netcattyBridge } from '@/infrastructure/services/netcattyBridge.ts'; +import type { ScriptDialogRequest } from '@/types/global/netcatty-bridge-script.d.ts'; + +export function ScriptDialogHost() { + const { t } = useI18n(); + const [request, setRequest] = useState(null); + const [promptValue, setPromptValue] = useState(''); + + useEffect(() => { + const dispose = netcattyBridge.get()?.onScriptDialogRequest?.((payload) => { + setRequest(payload); + setPromptValue(payload.defaultValue ?? ''); + }); + 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; + + return ( + { + if (!open) { + void respond(request.type === 'waitForTimeout' ? 'abort' : undefined, true); + } + }} + > + + + + {request.type === 'waitForTimeout' + ? t('scripts.dialog.waitForTimeoutTitle') + : t('scripts.dialog.title')} + + {request.message} + + {request.type === 'prompt' ? ( + setPromptValue(event.target.value)} + autoFocus + /> + ) : null} + + {request.type === 'waitForTimeout' ? ( + <> + + + + + ) : request.type === 'confirm' ? ( + <> + + + + ) : request.type === 'prompt' ? ( + <> + + + + ) : ( + + )} + + + + ); +} diff --git a/components/scripts/ScriptEditorModal.tsx b/components/scripts/ScriptEditorModal.tsx new file mode 100644 index 000000000..201f53cfa --- /dev/null +++ b/components/scripts/ScriptEditorModal.tsx @@ -0,0 +1,194 @@ +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 { ScriptCodeEditor } from './ScriptCodeEditor'; +import { ScriptMetaFields } from './ScriptMetaFields'; +import { SnippetTargetsSection } from '@/components/snippets/SnippetTargetsSection'; +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; + 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 = ({ + open, + onClose, + snippet, + onChange, + onSave, + onRun, + canRun = false, + targetHosts, + hosts, + customGroups = [], + selectedHostIds, + onSelectHost, + targetsAllHosts = false, + onTargetsAllHostsChange, +}) => { + const { t } = useI18n(); + const [targetPickerOpen, setTargetPickerOpen] = 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, + targetsAllHosts: undefined, + }); + }, [onChange, 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 ( + <> + + + {title} + +
+
+
+ {title} +
+ + {onRun ? ( + + + + + {t('scripts.actions.runNowHint')} + + ) : null} + +
+
+
+ +
+ + { + if (!targetsAllHosts) setTargetPickerOpen(true); + }} + hint={t('scripts.targets.hint')} + targetsAllHosts={targetsAllHosts} + onTargetsAllHostsChange={onTargetsAllHostsChange} + /> +
+ +
+ {open ? ( + onChange({ ...snippet, command })} + language={language} + fill + minimap + active={open} + /> + ) : ( +
+ +
+ )} +
+ +
+ JavaScript + {t('scripts.editor.lineCount', { count: lineCount })} +
+
+
+
+ + + + ); +}; diff --git a/components/scripts/ScriptEditorPanel.tsx b/components/scripts/ScriptEditorPanel.tsx new file mode 100644 index 000000000..25f757074 --- /dev/null +++ b/components/scripts/ScriptEditorPanel.tsx @@ -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 = ({ + 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 ( +
+ + +
+
+
+

{t('scripts.meta.code')}

+ + {t('scripts.editor.lineCount', { count: lineCount })} + +
+
+ {onRun ? ( + + + + + {t('scripts.actions.runNowHint')} + + ) : null} + {onExpand ? ( + + + + + {t('scripts.actions.openEditorHint')} + + ) : null} +
+
+ +
+ onChange({ ...snippet, command })} + language={language} + height={height} + /> +
+
+
+
+
+
+ ); +}; diff --git a/components/scripts/ScriptMetaFields.tsx b/components/scripts/ScriptMetaFields.tsx new file mode 100644 index 000000000..60bb83f8e --- /dev/null +++ b/components/scripts/ScriptMetaFields.tsx @@ -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 = ({ + 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 ( +
+
+
+ + onChange({ ...snippet, label: event.target.value })} + className="h-8" + /> +
+
+ + +
+ {snippet.trigger === 'onOutput' ? ( +
+ + onChange({ ...snippet, triggerPattern: event.target.value })} + placeholder="sudo.*password" + className="h-8 font-mono text-xs" + /> +

{t('scripts.trigger.onOutputHint')}

+
+ ) : null} +
+
+ + onChange({ ...snippet, description: event.target.value })} + className="h-8" + placeholder={t('scripts.meta.descriptionPlaceholder')} + /> +
+
+ ); + } + + return ( +
+
+ + onChange({ ...snippet, label: event.target.value })} + className="h-9" + /> +
+
+ +