feat(scripts): terminal script automation, recording, and side panel (#1763)

* feat(scripts): add terminal script automation, recording, and side panel

Introduce nct script runtime in the main process with terminal-side recording,
execution overlay, Monaco editor, connect-script queues, and scripts side panel
integration. Fix recording to wire scriptRecorderRef into xterm, use waitForPrompt
for root/user shells, and save recorded scripts through the global editor dialog.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(scripts): address Bugbot review findings for recording and side panel

Refresh script run state in side panel memo, guard toolbar recording against
multi-session conflicts, reset connect scripts on reconnect, resolve focused
session via getActiveTerminalSessionId, and avoid wiping host edit form when
snippets list updates.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(scripts): prevent pending script re-run and improve workspace run feedback

Track one-shot pendingScript execution separately from onConnect queue so
reconnects do not replay Run-now scripts. Fall back to focused session when
running on all tabs outside a workspace, and toast when every tab is still
connecting.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(scripts): consume connect-script window once and filter serial hosts

Mark the onConnect script evaluation as consumed even when the queue is
empty so later snippet/host edits do not rerun automation mid-session.
Restore serial-host filtering in SelectHostPanel confirm handler.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(scripts): grace period before giving up on connect-script queue

Only mark onConnect automation as consumed after scripts run or a short
post-connect grace window expires, so late-loading snippets still trigger
while mid-session Vault edits do not.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(scripts): gate connect-script consumption on vault hydration

Replace the grace timer with vault-init and unresolved host script binding
checks so late-loading snippets still run onConnect automation while
mid-session edits stay suppressed. Clear pending script run state on
disconnect so reconnect retries workspace pending scripts.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(scripts): honor run targets and retry failed connect automation

Route QuickScriptEditorDialog Run through onRunSnippet for multi-host
workspaces. Defer connect/pending script completion markers until each
script succeeds and retry the queue after failures.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(scripts): stabilize pending runs and global connect queue sync

Keep pending script run state across reconnects, run onConnect scripts
before one-shot workspace pending scripts, and strip per-host queue entries
when an onConnect script targets all hosts.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(scripts): defer host queue snippet sync and guard connect runs

Persist snippet target links only when the host panel is saved, block
duplicate connect-script scheduling while a batch is running, and require
runnable hosts before showing Run in the snippets editor footer.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(scripts): retry connect failures and surface output-trigger errors

Use a retry tick in connect-script effect deps, toast automation failures
from output triggers, and require runnable hosts before QuickScript Run.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(scripts): track id-less pending runs and re-arm output triggers

Remember anonymous pendingScript objects after first run and allow
onOutput triggers to fire again when new terminal output arrives.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(scripts): unblock paused connect runs and pass session meta

Resolve waitForScriptRun on pause, run workspace scripts per session
with hostname context, defer output-trigger arming until runs start, and
add noRunnableHosts i18n for QuickScript Run.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(scripts): serialize output triggers and defer empty connect finalize

Fire at most one onOutput trigger per scan while a run is active, and
only finalize an empty connect queue after vault snippets are loaded.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(scripts): guard output launch race and script run waits

Block overlapping onOutput launches, timeout stuck waitForScriptRun
promises, and disable vault Run when no connectable targets exist.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(scripts): match output triggers on new chunks only

Scan onOutput patterns against recent terminal output instead of buffer
length dedupe, and restore overflow-visible on SelectHostPanel new-host flow.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(scripts): await sequential workspace runs and output launch

Wait for each tab script to finish in sequential workspace mode, and hold
output-trigger launch guard until the session run becomes active.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(scripts): wait through pause and hold output trigger until done

Keep connect and sequential workspace queues blocked until scripts
complete or fail, and retain output-trigger guard until the launched run
finishes.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(scripts): harden output trigger wait helpers

Add explicit timeouts to output-trigger wait helpers and stop rescanning
the buffer after script completion to avoid sticky-match rerun loops.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(scripts): allow empty connect queue and match output in new chunks

Treat an empty connectScriptIds array as an explicit cleared queue instead
of re-migrating from snippet targets, and only fire onOutput triggers when
the latest terminal output contains the pattern.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(scripts): require fresh output overlap for onOutput triggers

Only fire output-matched scripts when the regex match extends into the
latest terminal chunk, preventing reruns from stale buffer text.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(scripts): promote manual scripts added to host connect queue

Diff connect queue changes from raw connectScriptIds so saving a host
updates manual scripts to onConnect when they are newly queued.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(scripts): dedupe output triggers by match position

Track the buffer end offset of each output trigger match and ignore
stale re-scans, with a longer wait for run registration before retry.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(scripts): record output trigger matches only after run starts

Propagate automation launch failures to output triggers and commit match
dedupe only once the session run is registered as active.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(scripts): address Codex review on wait cursor and sensitive codegen

Track scan offset so waitFor/waitForAny only match output after the previous wait, and emit sendLine after sensitive prompt in recorded scripts.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(scripts): harden onOutput triggers against PTY echo and local input

Filter server output before pattern matching so onOutput fires only after
Enter on real stdout, not on keystroke/paste echo, OSC/SGR sequences, or
alternate-screen apps. Add dev diagnostics and scope triggers to the active
session when no explicit host targets are configured.

Co-authored-by: Cursor <cursoragent@cursor.com>

* chore(scripts): remove onOutput trigger debug logging

Drop temporary diagnostics module and console traces now that PTY echo
filtering is verified; keep the production trigger pipeline unchanged.

Co-authored-by: Cursor <cursoragent@cursor.com>

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
陈大猫andCursor authored and GitHub committed 2026-06-28 23:48:12 +08:00
1 parent 9d98853091
commit 6b9b71ac8a
112 files changed
+8776 -680

No files matched your search

+9 -1
View File
@@ -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() {
<I18nProvider locale={settings.uiLanguage}>
<ToastProvider>
<TooltipProvider delayDuration={300}>
<ScriptAutomationRoot />
<App settings={settings} />
</TooltipProvider>
</ToastProvider>
+16
View File
@@ -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])))
}
/>
<QuickScriptEditorDialog
snippets={snippets}
packages={snippetPackages}
hosts={hosts}
customGroups={customGroups}
onCreateSnippet={(snippet) => 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"
+2
View File
@@ -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;
+1 -1
View File
@@ -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',
+123
View File
@@ -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.',
};
+28 -20
View File
@@ -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
+1
View File
@@ -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.',
+1
View File
@@ -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': 'Добавить пакет',
+1
View File
@@ -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 и выбранный цвет для этого хоста.',
+2
View File
@@ -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;
+1 -1
View File
@@ -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': '日志',
+123
View File
@@ -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': '拖动条目即可调整连接时的运行顺序。',
};
+28 -20
View File
@@ -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
+1
View File
@@ -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 发行版图标和所选颜色显示此主机。',
@@ -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<RunsListener>();
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<ScriptRun['status']>(['completed', 'failed']);
export function waitForScriptRun(
runId: string,
options: { signal?: AbortSignal; timeoutMs?: number } = {},
): Promise<ScriptRun> {
const timeoutMs = options.timeoutMs ?? 3_600_000;
return new Promise((resolve, reject) => {
let settled = false;
let timeoutId: ReturnType<typeof setTimeout> | 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<void> {
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);
}
+30
View File
@@ -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<Listener>();
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();
}
+3
View File
@@ -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
@@ -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);
});
+132
View File
@@ -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<void>;
};
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<string, number>());
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<typeof import('@/infrastructure/scripts/screenSnapshotRegistry.ts').captureScreenSnapshot>,
) {
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());
};
}
+52
View File
@@ -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<ScriptRun[]>([]);
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,
};
}
+125
View File
@@ -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<number | null>(null);
const inputBufferRef = useRef('');
const lastStepAtRef = useRef<number>(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,
};
}
+7 -3
View File
@@ -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]);
+2
View File
@@ -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);
}
};
+9
View File
@@ -0,0 +1,9 @@
let vaultInitialized = false;
export function isVaultInitialized(): boolean {
return vaultInitialized;
}
export function setVaultInitialized(value: boolean): void {
vaultInitialized = value;
}
+14 -2
View File
@@ -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(
<DistroAvatar
host={{ ...baseHost, distro: "ubuntu" }}
fallback="U"
size="xs"
/>,
);
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(
<DistroAvatar
@@ -44,8 +57,7 @@ test("DistroAvatar tree size uses compact host icon corners", () => {
/>,
);
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", () => {
+22 -12
View File
@@ -93,14 +93,24 @@ const DistroAvatarInner: React.FC<DistroAvatarProps> = ({
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<DistroAvatarProps> = ({
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<DistroAvatarProps> = ({
return (
<div
className={cn(
"shrink-0 rounded flex items-center justify-center bg-amber-600 text-white dark:bg-amber-400 dark:text-slate-950",
"shrink-0 flex items-center justify-center bg-amber-600 text-white dark:bg-amber-400 dark:text-slate-950",
containerClass,
className,
)}
@@ -135,7 +145,7 @@ const DistroAvatarInner: React.FC<DistroAvatarProps> = ({
return (
<div
className={cn(
"shrink-0 rounded flex items-center justify-center text-white",
"shrink-0 flex items-center justify-center text-white",
containerClass,
className,
)}
@@ -150,7 +160,7 @@ const DistroAvatarInner: React.FC<DistroAvatarProps> = ({
return (
<div
className={cn(
"shrink-0 rounded flex items-center justify-center overflow-hidden",
"shrink-0 flex items-center justify-center overflow-hidden",
containerClass,
!customColor && bg,
className,
@@ -170,7 +180,7 @@ const DistroAvatarInner: React.FC<DistroAvatarProps> = ({
return (
<div
className={cn(
"shrink-0 rounded flex items-center justify-center bg-primary text-primary-foreground",
"shrink-0 flex items-center justify-center bg-primary text-primary-foreground",
containerClass,
className,
)}
+9 -20
View File
@@ -1,7 +1,6 @@
import React from "react";
import { ChevronDown, Eye, EyeOff, FileKey, FolderLock, FolderOpen, Key, KeyRound, MapPin, Plus, Shield, Trash2, User, X } from "lucide-react";
import { ChevronDown, Eye, EyeOff, FileKey, FolderLock, FolderOpen, Key, KeyRound, MapPin, Plus, Shapes, Shield, Trash2, User, X } from "lucide-react";
import type { Host } from "../types";
import { DistroAvatar } from "./DistroAvatar";
import { HostIconPicker } from "./HostIconPicker";
import { Button } from "./ui/button";
import { Combobox } from "./ui/combobox";
@@ -51,23 +50,12 @@ export const HostDetailsConnectionSections: React.FC<HostDetailsConnectionSectio
icon={<MapPin size={14} className="text-muted-foreground" />}
title={t("hostDetails.section.address")}
>
<div className="flex items-center gap-2">
<DistroAvatar
host={form as Host}
fallback={
form.label?.slice(0, 2).toUpperCase() ||
form.hostname?.slice(0, 2).toUpperCase() ||
"H"
}
className="h-10 w-10"
/>
<Input
placeholder={t("hostDetails.hostname.placeholder")}
value={form.hostname}
onChange={(e) => update("hostname", e.target.value)}
className="h-10 flex-1"
/>
</div>
<Input
placeholder={t("hostDetails.hostname.placeholder")}
value={form.hostname}
onChange={(e) => update("hostname", e.target.value)}
className="h-10 w-full"
/>
</HostDetailsSection>
<HostDetailsSection
@@ -630,11 +618,12 @@ export const HostDetailsConnectionSections: React.FC<HostDetailsConnectionSectio
</HostDetailsSection>
<HostDetailsSection
icon={<DistroAvatar host={form as Host} fallback="H" size="sm" />}
icon={<Shapes size={14} className="text-muted-foreground" />}
title={t("hostDetails.icon.sectionTitle")}
hint={t("hostDetails.icon.desc")}
>
<HostIconPicker
previewHost={form as Host}
distroMode={form.distroMode}
manualDistro={form.manualDistro}
effectiveDistro={effectiveFormDistro}
+81 -9
View File
@@ -30,7 +30,7 @@ import {
resolveHostTerminalFontSize,
resolveHostTerminalThemeId,
} from "../domain/terminalAppearance";
import { EnvVar, GroupConfig, Host, Identity, ManagedSource, ProxyConfig, ProxyProfile, SSHKey } from "../types";
import { EnvVar, GroupConfig, Host, Identity, ManagedSource, ProxyConfig, ProxyProfile, Snippet, SSHKey } from "../types";
import { DISTRO_COLORS, DISTRO_LOGOS } from "./DistroAvatar";
import ThemeSelectPanel from "./ThemeSelectPanel";
import {
@@ -65,6 +65,10 @@ import {
ProxyPanel,
} from "./host-details";
import { HostNotesEditor } from "./host/HostNotesEditor";
import { HostDetailsScriptsSection } from "./host/HostDetailsScriptsSection";
import { ensureHostConnectScriptIds, getHostConnectScriptIds, prepareSnippetForHostConnectQueue } from "@/domain/hostConnectScripts.ts";
import { isScriptSnippet } from "@/domain/snippetScript.ts";
import { unlinkHostFromScripts } from "@/domain/snippetTargets.ts";
type CredentialType = "sshid" | "key" | "certificate" | "localKeyFile" | null;
type SubPanel =
@@ -96,6 +100,8 @@ interface HostDetailsPanelProps {
groupConfigs?: GroupConfig[];
layout?: AsidePanelLayout;
onImportKey?: (draft: Partial<SSHKey>) => SSHKey;
snippets?: Snippet[];
onSnippetsChange?: (snippets: Snippet[]) => void;
}
const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
@@ -118,6 +124,8 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
groupConfigs = [],
layout = "overlay",
onImportKey,
snippets = [],
onSnippetsChange,
}) => {
const { t } = useI18n();
const { checkSshAgent } = useApplicationBackend();
@@ -175,15 +183,38 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
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 = <K extends keyof Host>(key: K, value: Host[K]) => {
setForm((prev) => ({ ...prev, [key]: value }));
@@ -464,6 +495,38 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
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<HostDetailsPanelProps> = ({
getDistroOptionLabel={getDistroOptionLabel}
/>
{onSnippetsChange ? (
<HostDetailsScriptsSection
host={form}
onHostChange={setForm}
snippets={snippets}
t={t}
/>
) : null}
<HostDetailsAdvancedSections
t={t}
form={form}
+21
View File
@@ -10,8 +10,10 @@ import {
isHostIconId,
resolveHostIconDefaultColorHex,
} from "../domain/hostIcon";
import type { Host } from "../domain/models";
import type { HostIconColorId, HostIconColorMode, HostIconId, HostIconMode } from "../domain/models";
import { cn } from "../lib/utils";
import { DistroAvatar } from "./DistroAvatar";
import { renderHostIconGlyph } from "./hostIconRenderer";
import { Input } from "./ui/input";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "./ui/select";
@@ -24,6 +26,7 @@ type DistroOption = {
};
type HostIconPickerProps = {
previewHost?: Host;
distroMode?: "auto" | "manual";
manualDistro?: string;
effectiveDistro?: string;
@@ -94,6 +97,7 @@ const colorHexForId = (colorId?: HostIconColorId) =>
HOST_ICON_COLORS.find((color) => color.id === colorId)?.hex || HOST_ICON_COLORS[0].hex;
export const HostIconPicker: React.FC<HostIconPickerProps> = ({
previewHost,
distroMode,
manualDistro,
effectiveDistro,
@@ -205,6 +209,23 @@ export const HostIconPicker: React.FC<HostIconPickerProps> = ({
return (
<div className="space-y-4">
{previewHost ? (
<div
className="flex items-center justify-center rounded-xl border border-border/60 bg-secondary/20 py-5"
aria-label={t("hostDetails.icon.preview")}
>
<DistroAvatar
host={previewHost}
fallback={
previewHost.label?.slice(0, 2).toUpperCase()
|| previewHost.hostname?.slice(0, 2).toUpperCase()
|| "H"
}
size="lg"
/>
</div>
) : null}
<div className="grid gap-2 md:grid-cols-2">
<div className="space-y-1">
<span className="text-xs text-muted-foreground">{t("hostDetails.icon.source")}</span>
+1 -3
View File
@@ -457,9 +457,7 @@ const HostTreeItem: React.FC<HostTreeItemProps> = ({
<div className="mr-2 h-4 w-4 flex-shrink-0" />
)}
icon={(
<div className="mr-2 flex-shrink-0">
<DistroAvatar host={host} fallback={(host.os || "L")[0].toUpperCase()} size="tree" />
</div>
<DistroAvatar host={host} fallback={(host.os || "L")[0].toUpperCase()} size="tree" />
)}
content={(
<div className="min-w-0 flex-1 leading-tight">
+2 -1
View File
@@ -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<QuickAddSnippetDialogProps> = ({
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 ?? '');
+190 -7
View File
@@ -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<ScriptsSidePanelProps> = ({
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<Set<string>>(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<ScriptsSidePanelProps> = ({
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<ScriptsSidePanelProps> = ({
]);
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<ScriptsSidePanelProps> = ({
className="h-full flex flex-col bg-background overflow-hidden"
data-section="snippets-panel"
>
{/* Sub view tabs */}
<div className="shrink-0 px-2 py-1 border-b border-border/50 flex items-center gap-1">
<button
type="button"
className={cn(
'flex-1 h-7 rounded-md text-xs',
subView === 'library' ? 'bg-accent text-foreground' : 'text-muted-foreground hover:bg-muted/50',
)}
onClick={() => setSubView('library')}
>
{t('scripts.sidePanel.library')}
</button>
<button
type="button"
className={cn(
'flex-1 h-7 rounded-md text-xs',
subView === 'running' ? 'bg-accent text-foreground' : 'text-muted-foreground hover:bg-muted/50',
)}
onClick={() => setSubView('running')}
>
{t('scripts.sidePanel.running')}
</button>
</div>
{subView === 'running' ? (
<div className="flex-1 min-h-0 overflow-auto">
<ScriptRunList
runs={sessionRuns}
onStop={onStopRun ?? (() => {})}
onPause={onPauseRun ?? (() => {})}
onResume={onResumeRun ?? (() => {})}
/>
</div>
) : (
<>
{/* Search + Add */}
<div className="shrink-0 px-2 py-1.5 border-b border-border/50 flex items-center gap-1.5">
<div className="relative flex-1 min-w-0">
@@ -623,6 +733,14 @@ const ScriptsSidePanelInner: React.FC<ScriptsSidePanelProps> = ({
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<ScriptsSidePanelProps> = ({
/>
)}
</div>
<div className="shrink-0 px-2 py-2 border-t border-border/50 flex items-center gap-2">
<button
type="button"
onClick={handleAddScript}
className="flex-1 h-8 rounded-md text-xs bg-secondary/60 hover:bg-secondary"
>
{t('scripts.sidePanel.newScript')}
</button>
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
onClick={onStartRecording}
disabled={!canStartRecording}
className={cn(
'flex-1 h-8 rounded-md text-xs disabled:opacity-50',
isRecordingFocusedSession
? 'bg-red-500/15 text-red-500 hover:bg-red-500/25'
: 'bg-secondary/60 hover:bg-secondary',
)}
>
{recordingButtonLabel}
</button>
</TooltipTrigger>
<TooltipContent side="top" className="max-w-[240px]">
{recordingDisabledReason === 'unavailable'
? t('scripts.recording.unavailableHint')
: recordingDisabledReason === 'noSession'
? t('scripts.recording.noSession')
: isRecordingFocusedSession
? t('scripts.recording.activeHint')
: t('scripts.recording.startHint')}
</TooltipContent>
</Tooltip>
<ScriptRecordingHelpDialog />
</div>
{isRecordingFocusedSession ? (
<p className="shrink-0 px-2 pb-2 text-[10px] text-muted-foreground leading-relaxed">
{t('scripts.recording.activeHint')}
</p>
) : null}
</>
)}
</div>
</TooltipProvider>
);
@@ -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<SnippetRowProps>(({
onClick,
onEdit,
onDelete,
onRunParallel,
onRunSequential,
runParallelLabel,
runSequentialLabel,
editLabel,
deleteLabel,
}) => (
@@ -735,7 +904,11 @@ const SnippetRow = memo<SnippetRowProps>(({
{/* Hidden chevron column mirrors PackageRow's layout so the
snippet icon lines up exactly with the package icon above. */}
<ChevronRight size={12} className="shrink-0 opacity-0" aria-hidden />
<FileCode size={12} className="shrink-0 text-muted-foreground" />
{isScriptSnippet(snippet) ? (
<Play size={12} className="shrink-0 text-primary" />
) : (
<Zap size={12} className="shrink-0 text-muted-foreground" />
)}
<span className="flex-1 min-w-0 truncate text-xs font-medium">{snippet.label}</span>
{subtitle && (
<span className="shrink-0 max-w-[40%] truncate text-[10px] text-muted-foreground">
@@ -754,6 +927,16 @@ const SnippetRow = memo<SnippetRowProps>(({
</div>
</ContextMenuTrigger>
<ContextMenuContent>
{onRunParallel ? (
<ContextMenuItem onClick={onRunParallel}>
<Layers className="mr-2 h-4 w-4" /> {runParallelLabel}
</ContextMenuItem>
) : null}
{onRunSequential ? (
<ContextMenuItem onClick={onRunSequential}>
<Layers className="mr-2 h-4 w-4" /> {runSequentialLabel}
</ContextMenuItem>
) : null}
<ContextMenuItem onClick={onEdit}>
<Edit2 className="mr-2 h-4 w-4" /> {editLabel}
</ContextMenuItem>
+44
View File
@@ -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<SelectHostPanelContentProps, 'className'> {
open: boolean;
onOpenChange: (open: boolean) => void;
title?: string;
}
export const SelectHostDialog: React.FC<SelectHostDialogProps> = ({
open,
onOpenChange,
title,
onConfirm,
...contentProps
}) => {
const { t } = useI18n();
const dialogTitle = title ?? t('snippets.targets.add');
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-md h-[min(85vh,640px)] flex flex-col p-0 gap-0 overflow-hidden">
<DialogHeader className="px-4 pt-4 pb-2 shrink-0 border-b border-border/60">
<DialogTitle className="text-base">{dialogTitle}</DialogTitle>
</DialogHeader>
<SelectHostPanelContent
{...contentProps}
className="flex-1 min-h-0"
onConfirm={() => {
onConfirm();
onOpenChange(false);
}}
/>
</DialogContent>
</Dialog>
);
};
+39 -374
View File
@@ -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<SelectHostPanelProps> = ({
@@ -67,155 +49,26 @@ const SelectHostPanel: React.FC<SelectHostPanelProps> = ({
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<string | null>(null);
const [sortMode, setSortMode] = useState<SortMode>("az");
const [selectedTags, setSelectedTags] = useState<string[]>([]);
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<string>();
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<string>();
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<string, number>();
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<string>();
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 (
<TooltipProvider delayDuration={300}>
<AsidePanel
open={true}
onClose={onBack}
@@ -223,218 +76,30 @@ const SelectHostPanel: React.FC<SelectHostPanelProps> = ({
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 */}
<div className="px-4 py-3 flex items-center gap-2 border-b border-border/60 shrink-0">
{(onNewHost || onSaveHost) && (
<Button
variant="secondary"
size="sm"
className="h-8 gap-1.5"
onClick={() => {
if (onSaveHost) {
setShowNewHostPanel(true);
} else if (onNewHost) {
onNewHost();
}
}}
>
<Plus size={14} />
{t('selectHost.newHost')}
</Button>
)}
<div className="relative flex-1 max-w-xs">
<Search
size={14}
className="absolute left-3 top-1/2 -translate-y-1/2 text-muted-foreground"
/>
<Input
placeholder={t('common.searchPlaceholder')}
className="h-8 pl-8"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
/>
</div>
<div className="ml-auto flex items-center gap-1">
<TagFilterDropdown
allTags={allTags}
selectedTags={selectedTags}
onChange={setSelectedTags}
/>
<SortDropdown value={sortMode} onChange={setSortMode} />
</div>
</div>
{/* Content */}
<ScrollArea className="flex-1 min-w-0">
<div className="p-3 space-y-3">
{/* Breadcrumbs */}
{currentPath && (
<div className="flex items-center gap-1 text-xs text-muted-foreground">
<button
onClick={() => setCurrentPath(null)}
className="text-primary hover:underline"
>
{t("vault.hosts.allHosts")}
</button>
{breadcrumbs.map((crumb, index) => (
<React.Fragment key={crumb.path}>
<ChevronRight size={12} className="shrink-0 opacity-50" />
<button
onClick={() => setCurrentPath(crumb.path)}
className={cn(
"hover:underline",
index === breadcrumbs.length - 1
? "text-foreground font-medium"
: "text-primary",
)}
>
{crumb.name}
</button>
</React.Fragment>
))}
</div>
)}
{groupsWithCounts.length > 0 && (
<div>
<h4 className="text-xs font-semibold mb-2 text-muted-foreground">{t("vault.groups.title")}</h4>
<div className="space-y-1">
{groupsWithCounts.map((group) => (
<div
key={group.path}
className="flex items-center gap-2.5 px-2.5 py-2 rounded-lg hover:bg-muted/70 cursor-pointer transition-colors"
onClick={() => setCurrentPath(group.path)}
>
<div className="h-8 w-8 rounded-lg bg-primary/15 text-primary flex items-center justify-center shrink-0">
<LayoutGrid size={15} />
</div>
<div className="flex-1 min-w-0">
<div className="text-[13px] font-medium truncate">{group.name}</div>
<div className="text-[11px] text-muted-foreground">
{t("vault.groups.hostsCount", { count: group.count })}
</div>
</div>
</div>
))}
</div>
</div>
)}
{/* Hosts Section */}
{filteredHosts.length > 0 && (
<div>
<h4 className="text-xs font-semibold mb-2 text-muted-foreground">{t("vault.nav.hosts")}</h4>
<div className="space-y-1">
{filteredHosts.map((host) => {
const isSelected = selectedHostIdSet.has(host.id);
const connectionStr = `${host.username}@${host.hostname}:${host.port || 22}`;
return (
<div
key={host.id}
className={cn(
"flex items-center gap-2.5 px-2.5 py-2 rounded-lg cursor-pointer transition-colors",
isSelected
? "bg-muted"
: "hover:bg-muted/70",
)}
onClick={() => onSelect(host)}
>
<DistroAvatar
host={host}
fallback={host.os[0].toUpperCase()}
size="md"
/>
<div className="flex-1 min-w-0">
<Tooltip>
<TooltipTrigger asChild>
<div className="text-[13px] font-medium truncate">
{host.label}
</div>
</TooltipTrigger>
<TooltipContent side="top" align="start">
<p>{host.label}</p>
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<div className="text-[11px] text-muted-foreground truncate">
{connectionStr}
</div>
</TooltipTrigger>
<TooltipContent side="top" align="start">
<p>{connectionStr}</p>
</TooltipContent>
</Tooltip>
</div>
{isSelected && (
<Check size={14} className="text-primary shrink-0" />
)}
</div>
);
})}
</div>
</div>
)}
{/* Empty state */}
{groupsWithCounts.length === 0 && filteredHosts.length === 0 && (
<div className="text-center py-12 text-muted-foreground">
<p>{t("selectHost.noHostsFound")}</p>
</div>
)}
</div>
</ScrollArea>
{/* Footer */}
<div className="px-4 py-3 border-t border-border/60 shrink-0">
<Button
className="w-full"
disabled={selectedHostIds.length === 0}
onClick={() => {
if (onContinue) {
onContinue();
} else {
const host = selectableHosts.find((h) => selectedHostIdSet.has(h.id));
if (host) {
onSelect(host);
}
}
}}
>
{multiSelect
? t('selectHost.continueWithCount', { count: selectedHostIds.length })
: t('selectHost.continue')}
</Button>
</div>
{/* New Host Panel Overlay */}
{showNewHostPanel && onSaveHost && (
<HostDetailsPanel
initialData={null}
availableKeys={availableKeys}
identities={identities}
proxyProfiles={proxyProfiles}
groups={customGroups}
managedSources={managedSources}
allHosts={hosts}
onSave={(host) => {
onSaveHost(host);
setShowNewHostPanel(false);
}}
onCancel={() => setShowNewHostPanel(false)}
onCreateGroup={onCreateGroup}
/>
)}
<SelectHostPanelContent
hosts={hosts}
customGroups={customGroups}
selectedHostIds={selectedHostIds}
multiSelect={multiSelect}
onSelect={onSelect}
onConfirm={handleConfirm}
onNewHost={onNewHost}
availableKeys={availableKeys}
identities={identities}
proxyProfiles={proxyProfiles}
managedSources={managedSources}
onSaveHost={onSaveHost}
onCreateGroup={onCreateGroup}
onNewHostPanelOpenChange={setNewHostPanelOpen}
className="flex-1 min-h-0"
/>
</AsidePanel>
</TooltipProvider>
);
};
+382
View File
@@ -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<SelectHostPanelContentProps> = ({
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<string | null>(null);
const [sortMode, setSortMode] = useState<SortMode>('az');
const [selectedTags, setSelectedTags] = useState<string[]>([]);
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<string>();
selectableHosts.forEach((host) => {
host.tags?.forEach((tag) => tagSet.add(tag));
});
return Array.from(tagSet).sort();
}, [selectableHosts]);
const allGroupPaths = useMemo(() => {
const pathSet = new Set<string>();
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<string, number>();
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<string>();
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 (
<TooltipProvider delayDuration={300}>
<div className={cn('flex flex-col flex-1 min-h-0 min-w-0', className)}>
<div className="px-4 py-3 flex items-center gap-2 border-b border-border/60 shrink-0">
{(onNewHost || onSaveHost) ? (
<Button
variant="secondary"
size="sm"
className="h-8 gap-1.5"
onClick={() => {
if (onSaveHost) {
setShowNewHostPanel(true);
} else if (onNewHost) {
onNewHost();
}
}}
>
<Plus size={14} />
{t('selectHost.newHost')}
</Button>
) : null}
<div className="relative flex-1 max-w-xs">
<Search
size={14}
className="absolute left-3 top-1/2 -translate-y-1/2 text-muted-foreground"
/>
<Input
placeholder={t('common.searchPlaceholder')}
className="h-8 pl-8"
value={searchQuery}
onChange={(event) => setSearchQuery(event.target.value)}
/>
</div>
<div className="ml-auto flex items-center gap-1">
<TagFilterDropdown
allTags={allTags}
selectedTags={selectedTags}
onChange={setSelectedTags}
/>
<SortDropdown value={sortMode} onChange={setSortMode} />
</div>
</div>
<ScrollArea className="flex-1 min-h-0 min-w-0">
<div className="p-3 space-y-3">
{currentPath ? (
<div className="flex items-center gap-1 text-xs text-muted-foreground">
<button
type="button"
onClick={() => setCurrentPath(null)}
className="text-primary hover:underline"
>
{t('vault.hosts.allHosts')}
</button>
{breadcrumbs.map((crumb, index) => (
<React.Fragment key={crumb.path}>
<ChevronRight size={12} className="shrink-0 opacity-50" />
<button
type="button"
onClick={() => setCurrentPath(crumb.path)}
className={cn(
'hover:underline',
index === breadcrumbs.length - 1
? 'text-foreground font-medium'
: 'text-primary',
)}
>
{crumb.name}
</button>
</React.Fragment>
))}
</div>
) : null}
{groupsWithCounts.length > 0 ? (
<div>
<h4 className="text-xs font-semibold mb-2 text-muted-foreground">{t('vault.groups.title')}</h4>
<div className="space-y-1">
{groupsWithCounts.map((group) => (
<div
key={group.path}
className="flex items-center gap-2.5 px-2.5 py-2 rounded-lg hover:bg-muted/70 cursor-pointer transition-colors"
onClick={() => setCurrentPath(group.path)}
>
<div className="h-8 w-8 rounded-lg bg-primary/15 text-primary flex items-center justify-center shrink-0">
<LayoutGrid size={15} />
</div>
<div className="flex-1 min-w-0">
<div className="text-[13px] font-medium truncate">{group.name}</div>
<div className="text-[11px] text-muted-foreground">
{t('vault.groups.hostsCount', { count: group.count })}
</div>
</div>
</div>
))}
</div>
</div>
) : null}
{filteredHosts.length > 0 ? (
<div>
<h4 className="text-xs font-semibold mb-2 text-muted-foreground">{t('vault.nav.hosts')}</h4>
<div className="space-y-1">
{filteredHosts.map((host) => {
const isSelected = selectedHostIdSet.has(host.id);
const connectionStr = `${host.username}@${host.hostname}:${host.port || 22}`;
return (
<div
key={host.id}
className={cn(
'flex items-center gap-2.5 px-2.5 py-2 rounded-lg cursor-pointer transition-colors',
isSelected ? 'bg-muted' : 'hover:bg-muted/70',
)}
onClick={() => onSelect(host)}
>
<DistroAvatar
host={host}
fallback={host.os[0].toUpperCase()}
size="md"
/>
<div className="flex-1 min-w-0">
<Tooltip>
<TooltipTrigger asChild>
<div className="text-[13px] font-medium truncate">{host.label}</div>
</TooltipTrigger>
<TooltipContent side="top" align="start">
<p>{host.label}</p>
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<div className="text-[11px] text-muted-foreground truncate">{connectionStr}</div>
</TooltipTrigger>
<TooltipContent side="top" align="start">
<p>{connectionStr}</p>
</TooltipContent>
</Tooltip>
</div>
{isSelected ? <Check size={14} className="text-primary shrink-0" /> : null}
</div>
);
})}
</div>
</div>
) : null}
{groupsWithCounts.length === 0 && filteredHosts.length === 0 ? (
<div className="text-center py-12 text-muted-foreground">
<p>{t('selectHost.noHostsFound')}</p>
</div>
) : null}
</div>
</ScrollArea>
<div className="px-4 py-3 border-t border-border/60 shrink-0">
<Button
className="w-full"
disabled={selectedHostIds.length === 0}
onClick={onConfirm}
>
{multiSelect
? t('selectHost.continueWithCount', { count: selectedHostIds.length })
: t('selectHost.continue')}
</Button>
</div>
{showNewHostPanel && onSaveHost ? (
<HostDetailsPanel
initialData={null}
availableKeys={availableKeys}
identities={identities}
proxyProfiles={proxyProfiles}
groups={customGroups}
managedSources={managedSources}
allHosts={hosts}
onSave={(host) => {
onSaveHost(host);
setShowNewHostPanel(false);
}}
onCancel={() => setShowNewHostPanel(false)}
onCreateGroup={onCreateGroup}
/>
) : null}
</div>
</TooltipProvider>
);
};
+130 -33
View File
@@ -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<SnippetsManagerProps> = ({
proxyProfiles = [],
managedSources = [],
onSaveHost,
onUpdateHosts,
onCreateGroup,
}) => {
const { t } = useI18n();
@@ -663,38 +669,110 @@ const SnippetsManager: React.FC<SnippetsManagerProps> = ({
};
}, [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<SnippetsManagerProps> = ({
};
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<SnippetsManagerProps> = ({
hosts={hosts}
customGroups={customGroups}
targetSelection={targetSelection}
setTargetSelection={setTargetSelection}
handleTargetSelect={handleTargetSelect}
handleTargetPickerBack={handleTargetPickerBack}
availableKeys={availableKeys}
@@ -1441,7 +1529,8 @@ const SnippetsManager: React.FC<SnippetsManagerProps> = ({
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<SnippetsManagerProps> = ({
hasMoreHistory={hasMoreHistory}
isLoadingMore={isLoadingMore}
loadMoreHistory={loadMoreHistory}
onRunSnippet={onRunSnippet}
/>
);
@@ -1480,6 +1570,9 @@ const SnippetsManager: React.FC<SnippetsManagerProps> = ({
<Button onClick={() => handleEdit()} size="sm" className="h-10 px-3">
<Plus size={14} className="mr-2" /> {t('snippets.action.newSnippet')}
</Button>
<Button onClick={() => handleEdit(undefined, true)} size="sm" variant="secondary" className={vaultHeaderSecondaryButtonClass}>
<Play size={14} className="mr-2" /> {t('snippets.action.newScript')}
</Button>
<Button
onClick={() => {
setNewPackageName('');
@@ -1771,8 +1864,12 @@ const SnippetsManager: React.FC<SnippetsManagerProps> = ({
</div>
)}
<VaultEntityIcon
className={vaultSnippetIconClass}
icon={<FileCode size={18} />}
className={isScriptSnippet(snippet) ? vaultAutomationScriptIconClass : vaultSnippetIconClass}
icon={isScriptSnippet(snippet) ? (
<Play size={18} />
) : (
<Zap size={18} />
)}
/>
<div className="w-0 flex-1">
<div className="text-sm font-semibold truncate">{snippet.label}</div>
@@ -1808,14 +1905,14 @@ const SnippetsManager: React.FC<SnippetsManagerProps> = ({
<ContextMenuContent>
<ContextMenuItem
onClick={() => {
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}
>
<Play className="mr-2 h-4 w-4" /> {t('action.run')}
</ContextMenuItem>
+138 -72
View File
@@ -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<string, any>;
@@ -21,6 +27,7 @@ export const SnippetsRightPanel: React.FC<SnippetsRightPanelProps> = ({
hosts,
customGroups,
targetSelection,
setTargetSelection,
handleTargetSelect,
handleTargetPickerBack,
availableKeys,
@@ -32,7 +39,8 @@ export const SnippetsRightPanel: React.FC<SnippetsRightPanelProps> = ({
handleClosePanel,
editingSnippet,
onDelete,
handleSubmit,
handleSave,
handleSaveAndRun,
setEditingSnippet,
packageOptions,
selectedPackage,
@@ -54,11 +62,53 @@ export const SnippetsRightPanel: React.FC<SnippetsRightPanelProps> = ({
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<SnippetsRightPanelProps> = ({
onCreateGroup={onCreateGroup}
title={t('snippets.targets.add')}
layout="inline"
{...snippetsPanelResizeProps}
/>
);
}
@@ -86,8 +137,11 @@ export const SnippetsRightPanel: React.FC<SnippetsRightPanelProps> = ({
<AsidePanel
open={true}
onClose={handleClosePanel}
title={editingSnippet.id ? t('snippets.panel.editTitle') : t('snippets.panel.newTitle')}
title={isEditingScript
? t(editingSnippet.id ? 'snippets.panel.editAutomationTitle' : 'snippets.panel.newAutomationTitle')
: t(editingSnippet.id ? 'snippets.panel.editTitle' : 'snippets.panel.newTitle')}
layout="inline"
{...snippetsPanelResizeProps}
actions={
<>
{editingSnippet.id && (
@@ -115,7 +169,7 @@ export const SnippetsRightPanel: React.FC<SnippetsRightPanelProps> = ({
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<SnippetsRightPanelProps> = ({
}
>
<AsidePanelContent>
{/* Action Description */}
{!isEditingScript ? (
<Card className="p-3 space-y-2 bg-card border-border/80">
<p className="text-xs font-semibold text-muted-foreground">{t('snippets.field.description')}</p>
<Input
@@ -136,6 +190,7 @@ export const SnippetsRightPanel: React.FC<SnippetsRightPanelProps> = ({
spellCheck={false}
/>
</Card>
) : null}
{/* Package */}
<Card className="p-3 space-y-2 bg-card border-border/80">
@@ -163,40 +218,64 @@ export const SnippetsRightPanel: React.FC<SnippetsRightPanelProps> = ({
/>
</Card>
{/* Script */}
<Card className="p-3 space-y-2 bg-card border-border/80">
<SnippetScriptEditor
label={t('snippets.field.scriptRequired')}
placeholder="ls -l"
value={editingSnippet.command || ''}
onChange={(command) => setEditingSnippet({ ...editingSnippet, command })}
{/* Script / Snippet body */}
{isEditingScript ? (
<ScriptEditorPanel
snippet={editingSnippet as import('../types').Snippet}
onChange={setEditingSnippet}
canRun={canRunEditingScript}
onExpand={() => setScriptEditorModalOpen(true)}
onRun={canRunEditingScript ? runEditingScript : undefined}
/>
<p className="text-[11px] text-muted-foreground leading-relaxed">
{t('snippets.field.variablesHelp')}
</p>
{detectedVariables.length > 0 && (
<div className="flex flex-wrap items-center gap-1.5 pt-0.5">
<span className="text-[10px] font-semibold text-muted-foreground shrink-0">
{t('snippets.field.variablesDetected')}:
</span>
{detectedVariables.map((variable) => (
<span
key={variable.name}
className="text-[10px] px-2 py-0.5 rounded-full bg-primary/10 text-primary font-mono"
>
{variable.name}
{variable.defaultValue !== undefined && (
<span className="text-muted-foreground font-sans ml-1">
({t('snippets.field.variableDefault', { value: variable.defaultValue })})
) : (
<Card className="p-3 space-y-2 bg-card border-border/80">
<SnippetScriptEditor
label={t('snippets.field.scriptRequired')}
placeholder="ls -l"
value={editingSnippet.command || ''}
onChange={(command) => setEditingSnippet({ ...editingSnippet, command })}
/>
<p className="text-[11px] text-muted-foreground leading-relaxed">
{t('snippets.field.variablesHelp')}
</p>
{detectedVariables.length > 0 && (
<div className="flex flex-wrap items-center gap-1.5 pt-0.5">
<span className="text-[10px] font-semibold text-muted-foreground shrink-0">
{t('snippets.field.variablesDetected')}:
</span>
{detectedVariables.map((variable) => (
<span
key={variable.name}
className="text-[10px] px-2 py-0.5 rounded-full bg-primary/10 text-primary font-mono"
>
{variable.name}
{variable.defaultValue !== undefined && (
<span className="text-muted-foreground font-sans ml-1">
({t('snippets.field.variableDefault', { value: variable.defaultValue })})
</span>
)}
</span>
)}
</span>
))}
</div>
)}
))}
</div>
)}
</Card>
)}
{/* No Auto Run */}
<SnippetTargetsSection
t={t}
targetHosts={targetHosts}
onEditTargets={openTargetPicker}
hint={isEditingScript
? (editingSnippet.trigger === 'onConnect'
? t('scripts.targets.connectOrderHint')
: t('scripts.targets.hint'))
: undefined}
targetsAllHosts={Boolean(editingSnippet.targetsAllHosts)}
onTargetsAllHostsChange={handleTargetsAllHostsChange}
/>
{!isEditingScript ? (
<>
<label className="flex items-center gap-2 cursor-pointer px-1">
<input
type="checkbox"
@@ -254,52 +333,38 @@ export const SnippetsRightPanel: React.FC<SnippetsRightPanelProps> = ({
)}
<p className="text-[11px] text-muted-foreground">{t('snippets.shortkey.hint')}</p>
</Card>
{/* Targets */}
<Card className="p-3 space-y-3 bg-card border-border/80">
<div className="flex items-center justify-between">
<p className="text-xs font-semibold text-muted-foreground">{t('snippets.targets.title')}</p>
<Button variant="ghost" size="sm" className="h-6 px-2 text-xs text-primary" onClick={openTargetPicker}>
{t('action.edit')}
</Button>
</div>
{targetHosts.length === 0 ? (
<Button
variant="secondary"
className="w-full h-10"
onClick={openTargetPicker}
>
{t('snippets.targets.add')}
</Button>
) : (
<div className="space-y-2">
{targetHosts.map((h) => (
<div key={h.id} className="flex items-center gap-3 px-3 py-2 bg-background/60 border border-border/70 rounded-lg">
<DistroAvatar host={h} fallback={h.os[0].toUpperCase()} className="h-10 w-10" />
<div className="min-w-0 flex-1">
<div className="text-sm font-semibold truncate">{h.hostname}</div>
<div className="text-[11px] text-muted-foreground truncate">
{h.protocol || 'ssh'}, {h.username}
</div>
</div>
</div>
))}
</div>
)}
</Card>
</>
) : null}
</AsidePanelContent>
{/* Footer */}
<AsidePanelFooter>
<Button
className="w-full"
onClick={handleSubmit}
onClick={canRunEditingScript ? handleSaveAndRun : handleSave}
disabled={!editingSnippet.label || !editingSnippet.command}
>
{editingSnippet.targets?.length ? t('action.run') : t('common.save')}
{canRunEditingScript ? t('action.run') : t('common.save')}
</Button>
</AsidePanelFooter>
{isEditingScript ? (
<ScriptEditorModal
open={scriptEditorModalOpen}
onClose={() => 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}
</AsidePanel>
);
}
@@ -314,6 +379,7 @@ export const SnippetsRightPanel: React.FC<SnippetsRightPanelProps> = ({
showBackButton={true}
onBack={handleClosePanel}
layout="inline"
{...snippetsPanelResizeProps}
>
{/* History List */}
<div
+367 -5
View File
@@ -55,6 +55,25 @@ import { TerminalConnectionDialog } from "./terminal/TerminalConnectionDialog";
import { HostKeyInfo } from "./terminal/TerminalHostKeyVerification";
import { createKnownHostFromHostKeyInfo, toHostKeyInfo } from "./terminal/hostKeyVerification";
import { TerminalToolbar } from "./terminal/TerminalToolbar";
import { ScriptRecordingIndicator } from "./terminal/ScriptRecordingIndicator";
import { ScriptSaveRecordingDialog } from "./scripts/ScriptSaveRecordingDialog";
import { registerScreenSnapshotProvider } from "@/infrastructure/scripts/screenSnapshotRegistry.ts";
import { useScriptRecorder } from "@/application/state/useScriptRecorder.ts";
import { getScriptRecordingSnapshot, setScriptRecordingState } from "@/application/state/scriptRecordingStore.ts";
import {
runAutomationScript,
runConnectScriptsSequential,
subscribeScriptRuns,
pauseScriptRun,
resumeScriptRun,
stopScriptRun,
} from "@/application/state/scriptAutomationCoordinator.ts";
import { resolveConnectScriptsForHost, hasUnresolvedConnectScriptBindings } from "@/domain/hostConnectScripts.ts";
import { isVaultInitialized } from "@/application/state/vaultInitStore.ts";
import { netcattyBridge } from "@/infrastructure/services/netcattyBridge.ts";
import { ScriptExecutionOverlay } from "./terminal/ScriptExecutionOverlay";
import { isScriptSnippet } from "@/domain/snippetScript.ts";
import { useOutputTriggers } from "@/application/state/useOutputTriggers.ts";
import { TerminalComposeBar } from "./terminal/TerminalComposeBar";
import { TerminalContextMenu } from "./terminal/TerminalContextMenu";
import { TerminalSearchBar } from "./terminal/TerminalSearchBar";
@@ -187,6 +206,8 @@ const TerminalComponent: React.FC<TerminalProps> = ({
restoreTerminalCwd = false,
startupCommand,
noAutoRun,
pendingScriptId,
pendingScript,
reuseConnectionFromSessionId,
serialConfig,
hotkeyScheme = "disabled",
@@ -246,6 +267,62 @@ const TerminalComponent: React.FC<TerminalProps> = ({
// 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<string>());
const connectScriptsInFlightRef = useRef(false);
const pendingScriptRunIdRef = useRef<string | null>(null);
const pendingScriptHandledRef = useRef<Snippet | null>(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<import('@/types/global/netcatty-bridge-script.d.ts').ScriptRun | undefined>(undefined);
const dismissedScriptRunIdRef = useRef<string | null>(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<HTMLDivElement>(null);
const termRef = useRef<XTerm | null>(null);
@@ -618,6 +695,12 @@ const TerminalComponent: React.FC<TerminalProps> = ({
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<TerminalProps> = ({
} 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<TerminalProps> = ({
},
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<TerminalProps> = ({
});
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<TerminalProps> = ({
}
}, []);
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<TerminalProps> = ({
}, [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<TerminalProps> = ({
};
}, [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 }) => (
<TerminalToolbar
status={status}
@@ -1853,6 +2170,21 @@ const TerminalComponent: React.FC<TerminalProps> = ({
onToggleComposeBar={inWorkspace ? onToggleComposeBar : () => setIsComposeBarOpen(prev => !prev)}
terminalEncoding={terminalEncoding}
onSetTerminalEncoding={handleSetTerminalEncoding}
recordingIndicator={recorder.isRecording ? (
<ScriptRecordingIndicator
elapsedMs={recorder.elapsedMs}
isPaused={recorder.isPaused}
onPause={recorder.pauseRecording}
onResume={recorder.resumeRecording}
onStop={() => {
void recorder.stopRecording().then(({ code }) => {
setRecordedCode(code);
setSaveRecordingOpen(true);
});
}}
/>
) : undefined}
onStartRecording={status === 'connected' ? handleToolbarRecordingToggle : undefined}
/>
), [
compactToolbar,
@@ -1863,6 +2195,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
handleSetTerminalEncoding,
handleToggleSessionLog,
handleToggleSearch,
handleToolbarRecordingToggle,
host,
inWorkspace,
isLocalConnection,
@@ -1884,6 +2217,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
snippets,
status,
terminalEncoding,
recorder,
]);
const statusDotTone =
@@ -1929,6 +2263,9 @@ const TerminalComponent: React.FC<TerminalProps> = ({
onCommandExecuted,
onCommandSubmitted,
commandBufferRef,
scriptRecorderRef: recorderRef,
passwordPromptActiveRef,
onOutputTriggerUserInputRef: noteOutputTriggerUserInputRef,
promptLineBreakStateRef,
sudoAutofillRef,
setIsSearchOpen,
@@ -2076,9 +2413,34 @@ const TerminalComponent: React.FC<TerminalProps> = ({
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 <TerminalView ctx={{ Activity, ArrowDownToLine, ArrowUpFromLine, Button, Clock3, Copy, Cpu, HardDrive, HoverCard, HoverCardContent, HoverCardTrigger, Maximize2, MemoryStick, Radio, Sparkles, SquareArrowOutUpRight, TerminalAutocomplete, TerminalComposeBar, TerminalConnectionDialog, TerminalContextMenu, TerminalSearchBar, Tooltip, TooltipContent, TooltipTrigger, ZmodemOverwriteDialog, ZmodemProgressIndicator, auth, autocompleteAcceptTextRef, autocompleteCloseRef, autocompleteHostOs, autocompleteInputRef, autocompleteKeyEventRef, autocompleteRepositionRef, autocompleteSettings, chainProgress, cn, compactToolbar, lineTimestampsAvailable, containerRef, effectiveFontSize, effectiveFontWeight, effectiveTheme, error, executeSnippet, executeSnippetCommand, handleAddSelectionToAI, handleCancelConnect, handleCloseDisconnectedSession, handleCloseSearch, handleDismissDisconnectedDialog, handleDragEnter, handleDragLeave, handleDragOver, handleDrop, handleFindNext, handleFindPrevious, handleHostKeyAddAndContinue, handleHostKeyClose, handleHostKeyContinue, handleOsc52ReadResponse, handleOsc7SetupConfirm, handleOsc7SetupOpenChange, handleReceiveYmodem, handleRetry, handleSearch, handleSendYmodem, handleTopOverlayMouseDownCapture, hasMouseTracking, hasSelection, host, hotkeyScheme, inWorkspace, isBroadcastEnabled, isCancelling, isComposeBarOpen, isDraggingOver, isFocusMode, isLocalConnection, remoteDragDropUsesZmodem, isSerialConnection, isSearchOpen, isSupportedOs, isSystemSidebarEligible, isVisible, keyBindings, keys, knownCwdRef, needsHostKeyVerification, onAddSelectionToAI, onBroadcastInput, onCloseSession, onDetach, onDetachDragEnd, onDetachDragStart, onDetachPointerDown, onEndSessionDrag, onExpandToFocus, onOpenSystem, onRename, onSplitHorizontal, onSplitVertical, onStartSessionDrag, onToggleBroadcast, onUpdateHost: handleUpdateHostFromTerminal, osc52ReadPromptVisible, osc7SetupOpen, osc7SetupRunning, pendingHostKeyInfo, progressLogs, progressValue, renderControls, resolvedFontFamily, restoreState, scrollToBottomAfterProgrammaticInput, searchMatchCount, 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 }} />;
return (
<>
<TerminalView ctx={{ Activity, ArrowDownToLine, ArrowUpFromLine, Button, Clock3, Copy, Cpu, HardDrive, HoverCard, HoverCardContent, HoverCardTrigger, Maximize2, MemoryStick, Radio, Sparkles, SquareArrowOutUpRight, TerminalAutocomplete, TerminalComposeBar, TerminalConnectionDialog, TerminalContextMenu, TerminalSearchBar, Tooltip, TooltipContent, TooltipTrigger, ZmodemOverwriteDialog, ZmodemProgressIndicator, auth, autocompleteAcceptTextRef, autocompleteCloseRef, autocompleteHostOs, autocompleteInputRef, autocompleteKeyEventRef, autocompleteRepositionRef, autocompleteSettings, chainProgress, cn, compactToolbar, lineTimestampsAvailable, containerRef, effectiveFontSize, effectiveFontWeight, effectiveTheme, error, executeSnippet, executeSnippetCommand, handleAddSelectionToAI, handleCancelConnect, handleCloseDisconnectedSession, handleCloseSearch, handleDismissDisconnectedDialog, handleDragEnter, handleDragLeave, handleDragOver, handleDrop, handleFindNext, handleFindPrevious, handleHostKeyAddAndContinue, handleHostKeyClose, handleHostKeyContinue, handleOsc52ReadResponse, handleOsc7SetupConfirm, handleOsc7SetupOpenChange, handleReceiveYmodem, handleRetry, handleSearch, handleSendYmodem, handleTopOverlayMouseDownCapture, hasMouseTracking, hasSelection, host, hotkeyScheme, inWorkspace, isBroadcastEnabled, isCancelling, isComposeBarOpen, isDraggingOver, isFocusMode, isLocalConnection, remoteDragDropUsesZmodem, isSerialConnection, isSearchOpen, isSupportedOs, isSystemSidebarEligible, isVisible, keyBindings, keys, knownCwdRef, needsHostKeyVerification, onAddSelectionToAI, onBroadcastInput, onCloseSession, onDetach, onDetachDragEnd, onDetachDragStart, onDetachPointerDown, onEndSessionDrag, onExpandToFocus, onOpenSystem, onRename, onSplitHorizontal, onSplitVertical, onStartSessionDrag, onToggleBroadcast, onUpdateHost: handleUpdateHostFromTerminal, osc52ReadPromptVisible, osc7SetupOpen, osc7SetupRunning, pendingHostKeyInfo, progressLogs, progressValue, renderControls, resolvedFontFamily, restoreState, scrollToBottomAfterProgrammaticInput, searchMatchCount, scriptExecutionOverlay: activeScriptRun ? (
<ScriptExecutionOverlay
run={activeScriptRun}
onPause={() => { 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 }} />
<ScriptSaveRecordingDialog
open={saveRecordingOpen}
code={recordedCode}
packages={snippetPackages}
defaultName={`recorded-${new Date().toISOString().slice(0, 10)}`}
onClose={() => 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);
+153 -1
View File
@@ -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<TerminalLayerProps> = ({
hosts,
portForwardingRules = [],
@@ -197,6 +223,7 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
onRemoveSessionFromWorkspace,
}) => {
const { t } = useI18n();
const { runs } = useScriptExecution();
const terminalRendererCwdBySessionRef = useRef<Map<string, string>>(new Map());
const stableRef = useRef<Record<string, unknown>>({});
const activeTabIdRef = useRef(activeTabStore.getActiveTabId());
@@ -1270,10 +1297,128 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
);
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<TerminalLayerProps> = ({
persistSidePanelWidth,
handleSnippetClickForFocusedSession,
handleSnippetFromPanel,
handleRunScriptFromPanel,
handleRunScriptOnWorkspace,
handleStartRecordingFromPanel,
scriptRuns: runs,
handleStopScriptRun: stopScriptRun,
handlePauseScriptRun: pauseScriptRun,
handleResumeScriptRun: resumeScriptRun,
handleSnippetExecutorChange,
handleProgrammaticCommandLogRewriteChange,
handleStatusChange,
@@ -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, unknown>) => 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<HostDetailsScriptsSectionProps> = ({
host,
onHostChange,
snippets,
t,
}) => {
const [draggingScriptId, setDraggingScriptId] = useState<string | null>(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<HTMLDivElement>, scriptId: string) => {
event.dataTransfer.effectAllowed = 'move';
event.dataTransfer.setData(CONNECT_QUEUE_DRAG_TYPE, scriptId);
setDraggingScriptId(scriptId);
};
const handleDragOver = (event: DragEvent<HTMLDivElement>, 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<HTMLDivElement>, 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 (
<HostDetailsSection
icon={<Play size={14} className="text-muted-foreground" />}
title={t('hostDetails.section.automation')}
hint={t('hostDetails.automation.queueHint')}
>
<div className="space-y-4">
{globalScripts.length > 0 ? (
<div className="space-y-2">
<div className="flex items-center gap-1.5">
<Globe size={12} className="text-muted-foreground shrink-0" />
<label className="text-xs text-muted-foreground">{t('hostDetails.automation.globalScripts')}</label>
</div>
<p className="text-[10px] text-muted-foreground leading-relaxed">
{t('hostDetails.automation.globalScriptsHint')}
</p>
<div className="space-y-1.5">
{globalScripts.map((script) => (
<div
key={script.id}
className="flex items-center gap-2.5 px-2.5 py-2 rounded-md border border-border/50 bg-muted/20"
>
<VaultEntityIcon
className={cn(vaultEntityIconSmClass, vaultAutomationScriptIconClass)}
icon={<Play size={14} />}
/>
<div className="min-w-0 flex-1">
<div className="text-sm font-medium truncate">{script.label || t('scripts.running.unnamed')}</div>
<div className="text-[10px] text-muted-foreground truncate">{triggerLabel(script, t)}</div>
</div>
</div>
))}
</div>
</div>
) : null}
<div className="space-y-2">
{queuedScripts.length === 0 ? (
<p className="text-[11px] text-muted-foreground">{t('hostDetails.automation.connectQueueEmpty')}</p>
) : (
<div className="space-y-1.5" onDragEnd={clearDragState}>
{queuedScripts.map((script) => {
const scriptId = script.id!;
const isDragging = draggingScriptId === scriptId;
const indicator = dropIndicator?.scriptId === scriptId ? dropIndicator.position : null;
return (
<div
key={scriptId}
draggable
data-vault-drop-position={indicator ?? undefined}
data-vault-drop-axis="y"
className={cn(
'vault-drop-indicator-row relative flex items-center gap-2.5 px-2.5 py-2 rounded-md border border-border/70 bg-background/60 transition-opacity cursor-grab active:cursor-grabbing',
isDragging && 'opacity-40',
)}
aria-label={t('hostDetails.automation.dragHandle')}
onDragStart={(event) => handleDragStart(event, scriptId)}
onDragOver={(event) => handleDragOver(event, scriptId)}
onDrop={(event) => handleDrop(event, scriptId)}
>
<VaultEntityIcon
className={cn(vaultEntityIconSmClass, vaultAutomationScriptIconClass)}
icon={<Play size={14} />}
/>
<div className="min-w-0 flex-1 select-none">
<div className="text-sm font-medium truncate">{script.label || t('scripts.running.unnamed')}</div>
<div className="text-[10px] text-muted-foreground truncate">{triggerLabel(script, t)}</div>
</div>
<Button
type="button"
variant="ghost"
size="icon"
className="h-7 w-7 shrink-0 text-muted-foreground hover:text-destructive"
onMouseDown={(event) => event.stopPropagation()}
onClick={() => handleRemoveFromQueue(scriptId)}
aria-label={t('hostDetails.automation.removeFromQueue')}
>
<Trash2 size={13} />
</Button>
</div>
);
})}
</div>
)}
{linkOptions.length > 0 ? (
<Combobox
options={linkOptions}
value=""
onValueChange={handleAddToQueue}
placeholder={t('hostDetails.automation.addToQueuePlaceholder')}
icon={<Link2 size={14} />}
triggerClassName="h-9"
/>
) : null}
</div>
</div>
</HostDetailsSection>
);
};
@@ -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<Snippet> {
return {
label: '',
command: DEFAULT_SCRIPT_TEMPLATE,
package: '',
targets: [],
kind: 'script',
language: 'javascript',
trigger: 'manual',
};
}
export const QuickScriptEditorDialog: React.FC<QuickScriptEditorDialogProps> = ({
snippets,
packages,
hosts,
customGroups = [],
onCreateSnippet,
onUpdateSnippet,
onCreatePackage,
onUpdateHosts,
onRunSnippet,
}) => {
const { t } = useI18n();
const [open, setOpen] = useState(false);
const [editingSnippet, setEditingSnippet] = useState<Partial<Snippet>>(createBlankScript);
const [targetSelection, setTargetSelection] = useState<string[]>([]);
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 (
<ScriptEditorModal
open={open}
onClose={() => setOpen(false)}
snippet={editingSnippet as Snippet}
onChange={setEditingSnippet}
onSave={handleSave}
canRun={canRun}
onRun={canRun ? handleRun : undefined}
targetHosts={targetHosts}
hosts={hosts}
customGroups={customGroups}
selectedHostIds={targetSelection}
onSelectHost={handleSelectHost}
targetsAllHosts={Boolean(editingSnippet.targetsAllHosts)}
onTargetsAllHostsChange={handleTargetsAllHostsChange}
/>
);
};
@@ -0,0 +1,34 @@
import { useEffect } from 'react';
import { ScriptDialogHost } from '@/components/scripts/ScriptDialogHost.tsx';
import { captureScreenSnapshot } from '@/infrastructure/scripts/screenSnapshotRegistry.ts';
import { setupScriptBridgeListeners } from '@/application/state/useOutputTriggers.ts';
import { netcattyBridge } from '@/infrastructure/services/netcattyBridge.ts';
import { setScriptRuns } from '@/application/state/scriptAutomationCoordinator.ts';
import type { Snippet } from '@/domain/models';
export function ScriptAutomationRoot() {
useEffect(() => {
const disposeBridge = setupScriptBridgeListeners(captureScreenSnapshot);
const bridge = netcattyBridge.get();
bridge?.scriptGetRuns?.().then(setScriptRuns).catch(() => {});
const disposeRuns = bridge?.onScriptRunsUpdated?.(({ runs }) => {
setScriptRuns(runs);
});
return () => {
disposeBridge();
disposeRuns?.();
};
}, []);
useEffect(() => {
const handler = (event: Event) => {
const snippet = (event as CustomEvent<{ snippet: Snippet }>).detail?.snippet;
if (!snippet) return;
window.dispatchEvent(new CustomEvent('netcatty:scripts:run-on-focused', { detail: { snippet } }));
};
window.addEventListener('netcatty:scripts:run-now', handler);
return () => window.removeEventListener('netcatty:scripts:run-now', handler);
}, []);
return <ScriptDialogHost />;
}
+93
View File
@@ -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<ScriptCodeEditorProps> = ({
value,
onChange,
language,
fill = false,
height = 240,
minimap = false,
active = true,
}) => {
const monaco = useMonaco();
const themeName = useNetcattyMonacoTheme(monaco ?? undefined);
const editorRef = useRef<Monaco.editor.IStandaloneCodeEditor | null>(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 (
<div className={fill ? 'h-full min-h-0 relative' : 'relative'} style={fill ? undefined : { height }}>
<Editor
height={editorHeight}
language={language}
value={value}
onChange={(next) => onChange(next ?? '')}
onMount={handleMount}
theme={themeName}
loading={(
<div className="absolute inset-0 flex items-center justify-center bg-background">
<Loader2 size={24} className="animate-spin text-muted-foreground" />
</div>
)}
options={{
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 },
}}
/>
</div>
);
};
+90
View File
@@ -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<ScriptDialogRequest | null>(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 (
<Dialog open onOpenChange={(open) => {
if (!open) {
void respond(request.type === 'waitForTimeout' ? 'abort' : undefined, true);
}
}}
>
<DialogContent>
<DialogHeader>
<DialogTitle>
{request.type === 'waitForTimeout'
? t('scripts.dialog.waitForTimeoutTitle')
: t('scripts.dialog.title')}
</DialogTitle>
<DialogDescription>{request.message}</DialogDescription>
</DialogHeader>
{request.type === 'prompt' ? (
<Input
value={promptValue}
onChange={(event) => setPromptValue(event.target.value)}
autoFocus
/>
) : null}
<DialogFooter>
{request.type === 'waitForTimeout' ? (
<>
<Button variant="outline" onClick={() => void respond('abort')}>
{t('scripts.dialog.abort')}
</Button>
<Button variant="secondary" onClick={() => void respond('skip')}>
{t('scripts.dialog.skip')}
</Button>
<Button onClick={() => void respond('retry')}>
{t('scripts.dialog.retry')}
</Button>
</>
) : request.type === 'confirm' ? (
<>
<Button variant="outline" onClick={() => void respond(false)}>{t('common.cancel')}</Button>
<Button onClick={() => void respond(true)}>{t('scripts.dialog.ok')}</Button>
</>
) : request.type === 'prompt' ? (
<>
<Button variant="outline" onClick={() => void respond(undefined, true)}>{t('common.cancel')}</Button>
<Button onClick={() => void respond(promptValue)}>{t('scripts.dialog.ok')}</Button>
</>
) : (
<Button onClick={() => void respond(undefined)}>{t('scripts.dialog.ok')}</Button>
)}
</DialogFooter>
</DialogContent>
</Dialog>
);
}
+194
View File
@@ -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<ScriptEditorModalProps> = ({
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 (
<>
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogContent
className="max-w-5xl h-[85vh] flex flex-col p-0 gap-0 overflow-hidden"
hideCloseButton
>
<DialogTitle className="sr-only">{title}</DialogTitle>
<div className="h-full flex flex-col min-h-0">
<div className="h-9 px-3 py-1.5 border-b border-border/60 flex-shrink-0">
<div className="flex h-full items-center justify-between gap-3">
<span className="text-sm font-semibold leading-none truncate">{title}</span>
<div className="flex h-6 items-center gap-1.5">
<Button
variant="outline"
size="sm"
className="h-6 px-2 text-xs"
onClick={handleSave}
>
{t('scripts.actions.save')}
</Button>
{onRun ? (
<Tooltip>
<TooltipTrigger asChild>
<Button
size="sm"
className="h-6 px-2 text-xs gap-1"
onClick={onRun}
disabled={!canRun}
>
<Play size={12} />
{t('scripts.actions.runNow')}
</Button>
</TooltipTrigger>
<TooltipContent>{t('scripts.actions.runNowHint')}</TooltipContent>
</Tooltip>
) : null}
<Button
variant="ghost"
size="icon"
className="h-6 w-6"
onClick={handleClose}
>
<X size={13} />
</Button>
</div>
</div>
</div>
<div className="px-4 py-3 border-b border-border/40 flex-shrink-0 bg-muted/20 space-y-3">
<ScriptMetaFields snippet={snippet} onChange={onChange} layout="toolbar" />
<SnippetTargetsSection
variant="embedded"
t={t}
targetHosts={targetHosts}
onEditTargets={() => {
if (!targetsAllHosts) setTargetPickerOpen(true);
}}
hint={t('scripts.targets.hint')}
targetsAllHosts={targetsAllHosts}
onTargetsAllHostsChange={onTargetsAllHostsChange}
/>
</div>
<div className="flex-1 min-h-0 relative">
{open ? (
<ScriptCodeEditor
value={editorValue}
onChange={(command) => onChange({ ...snippet, command })}
language={language}
fill
minimap
active={open}
/>
) : (
<div className="absolute inset-0 flex items-center justify-center bg-background">
<Loader2 size={24} className="animate-spin text-muted-foreground" />
</div>
)}
</div>
<div className="px-4 py-2 border-t border-border/60 flex items-center justify-between text-xs text-muted-foreground bg-muted/30 flex-shrink-0">
<span>JavaScript</span>
<span>{t('scripts.editor.lineCount', { count: lineCount })}</span>
</div>
</div>
</DialogContent>
</Dialog>
<SelectHostDialog
open={targetPickerOpen}
onOpenChange={setTargetPickerOpen}
title={t('snippets.targets.add')}
hosts={hosts}
customGroups={customGroups}
selectedHostIds={selectedHostIds}
multiSelect
onSelect={onSelectHost}
onConfirm={handleTargetsConfirm}
/>
</>
);
};
+163
View File
@@ -0,0 +1,163 @@
import { Maximize2, Play } from 'lucide-react';
import React, { useCallback, useEffect, useRef, useState } from 'react';
import { useI18n } from '@/application/i18n/I18nProvider';
import type { Snippet } from '@/domain/models';
import { DEFAULT_SCRIPT_TEMPLATE } from '@/domain/snippetScript.ts';
import { STORAGE_KEY_SCRIPT_EDITOR_HEIGHT } from '@/infrastructure/config/storageKeys.ts';
import { localStorageAdapter } from '@/infrastructure/persistence/localStorageAdapter.ts';
import { ScriptCodeEditor } from './ScriptCodeEditor';
import { ScriptMetaFields } from './ScriptMetaFields';
import { Button } from '@/components/ui/button';
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
const DEFAULT_HEIGHT = 200;
const MIN_HEIGHT = 120;
const MAX_HEIGHT = 480;
function clampHeight(height: number): number {
return Math.max(MIN_HEIGHT, Math.min(MAX_HEIGHT, height));
}
function readStoredHeight(): number {
const stored = localStorageAdapter.readNumber(STORAGE_KEY_SCRIPT_EDITOR_HEIGHT);
if (stored === null) return DEFAULT_HEIGHT;
return clampHeight(stored);
}
function countLines(content: string): number {
if (!content) return 1;
let lines = 1;
for (let i = 0; i < content.length; i += 1) {
if (content.charCodeAt(i) === 10) lines += 1;
}
return lines;
}
export interface ScriptEditorPanelProps {
snippet: Snippet;
onChange: (snippet: Snippet) => void;
onRun?: () => void;
canRun?: boolean;
onExpand?: () => void;
}
export const ScriptEditorPanel: React.FC<ScriptEditorPanelProps> = ({
snippet,
onChange,
onRun,
canRun = false,
onExpand,
}) => {
const { t } = useI18n();
const [height, setHeight] = useState(readStoredHeight);
const dragRef = useRef<{ startY: number; startHeight: number } | null>(null);
const heightRef = useRef(height);
heightRef.current = height;
const language = 'javascript';
const editorValue = snippet.command || DEFAULT_SCRIPT_TEMPLATE;
const lineCount = countLines(editorValue);
const handleResizeStart = useCallback((event: React.MouseEvent) => {
event.preventDefault();
dragRef.current = { startY: event.clientY, startHeight: heightRef.current };
document.body.style.cursor = 'ns-resize';
document.body.style.userSelect = 'none';
}, []);
useEffect(() => {
const onMove = (event: MouseEvent) => {
if (!dragRef.current) return;
const delta = event.clientY - dragRef.current.startY;
setHeight(clampHeight(dragRef.current.startHeight + delta));
};
const onUp = () => {
if (dragRef.current) {
localStorageAdapter.writeNumber(STORAGE_KEY_SCRIPT_EDITOR_HEIGHT, heightRef.current);
}
dragRef.current = null;
document.body.style.cursor = '';
document.body.style.userSelect = '';
};
window.addEventListener('mousemove', onMove);
window.addEventListener('mouseup', onUp);
return () => {
window.removeEventListener('mousemove', onMove);
window.removeEventListener('mouseup', onUp);
document.body.style.cursor = '';
document.body.style.userSelect = '';
};
}, []);
return (
<div className="space-y-4">
<ScriptMetaFields snippet={snippet} onChange={onChange} layout="stack" />
<div className="space-y-1.5">
<div className="flex items-center justify-between gap-2 min-h-7">
<div className="flex items-baseline gap-2 min-w-0">
<p className="text-xs font-semibold text-muted-foreground shrink-0">{t('scripts.meta.code')}</p>
<span className="text-[10px] text-muted-foreground/80 truncate">
{t('scripts.editor.lineCount', { count: lineCount })}
</span>
</div>
<div className="flex items-center gap-1 shrink-0">
{onRun ? (
<Tooltip>
<TooltipTrigger asChild>
<Button
type="button"
variant="ghost"
size="sm"
className="h-7 px-2 text-xs gap-1 text-muted-foreground hover:text-foreground"
onClick={onRun}
disabled={!canRun}
>
<Play size={13} />
{t('scripts.actions.runNow')}
</Button>
</TooltipTrigger>
<TooltipContent>{t('scripts.actions.runNowHint')}</TooltipContent>
</Tooltip>
) : null}
{onExpand ? (
<Tooltip>
<TooltipTrigger asChild>
<Button
type="button"
variant="ghost"
size="sm"
className="h-7 px-2 text-xs gap-1 text-muted-foreground hover:text-foreground"
onClick={onExpand}
>
<Maximize2 size={13} />
{t('scripts.actions.openEditor')}
</Button>
</TooltipTrigger>
<TooltipContent>{t('scripts.actions.openEditorHint')}</TooltipContent>
</Tooltip>
) : null}
</div>
</div>
<div className="relative rounded-md border border-border/60 overflow-hidden bg-background">
<ScriptCodeEditor
value={editorValue}
onChange={(command) => onChange({ ...snippet, command })}
language={language}
height={height}
/>
<div
role="separator"
aria-orientation="horizontal"
aria-label={t('scripts.editor.resize')}
className="absolute bottom-0 left-0 right-0 z-10 flex h-2.5 cursor-ns-resize items-center justify-center hover:bg-muted/30"
onMouseDown={handleResizeStart}
>
<div className="h-0.5 w-10 rounded-full bg-border/80" />
</div>
</div>
</div>
</div>
);
};
+135
View File
@@ -0,0 +1,135 @@
import React, { useMemo } from 'react';
import { useI18n } from '@/application/i18n/I18nProvider';
import type { ScriptTrigger, Snippet } from '@/domain/models';
import { Input } from '@/components/ui/input';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Textarea } from '@/components/ui/textarea';
export interface ScriptMetaFieldsProps {
snippet: Snippet;
onChange: (snippet: Snippet) => void;
layout?: 'stack' | 'toolbar';
}
export const ScriptMetaFields: React.FC<ScriptMetaFieldsProps> = ({
snippet,
onChange,
layout = 'stack',
}) => {
const { t } = useI18n();
const triggerOptions = useMemo(() => ([
{ value: 'manual', label: t('scripts.trigger.manual') },
{ value: 'onConnect', label: t('scripts.trigger.onConnect') },
{ value: 'onOutput', label: t('scripts.trigger.onOutput') },
]), [t]);
if (layout === 'toolbar') {
return (
<div className="flex flex-col gap-2 shrink-0">
<div className="flex flex-wrap items-end gap-2">
<div className="flex-1 min-w-[160px] space-y-1">
<label className="text-[11px] font-medium text-muted-foreground">{t('scripts.meta.name')}</label>
<Input
value={snippet.label}
onChange={(event) => onChange({ ...snippet, label: event.target.value })}
className="h-8"
/>
</div>
<div className="w-[148px] space-y-1">
<label className="text-[11px] font-medium text-muted-foreground">{t('scripts.meta.trigger')}</label>
<Select
value={snippet.trigger || 'manual'}
onValueChange={(value) => onChange({
...snippet,
trigger: value as ScriptTrigger,
})}
>
<SelectTrigger className="h-8"><SelectValue /></SelectTrigger>
<SelectContent>
{triggerOptions.map((option) => (
<SelectItem key={option.value} value={option.value}>{option.label}</SelectItem>
))}
</SelectContent>
</Select>
</div>
{snippet.trigger === 'onOutput' ? (
<div className="flex-1 min-w-[180px] space-y-1">
<label className="text-[11px] font-medium text-muted-foreground">{t('scripts.meta.triggerPattern')}</label>
<Input
value={snippet.triggerPattern || ''}
onChange={(event) => onChange({ ...snippet, triggerPattern: event.target.value })}
placeholder="sudo.*password"
className="h-8 font-mono text-xs"
/>
<p className="text-[10px] text-muted-foreground leading-relaxed">{t('scripts.trigger.onOutputHint')}</p>
</div>
) : null}
</div>
<div className="space-y-1">
<label className="text-[11px] font-medium text-muted-foreground">{t('scripts.meta.description')}</label>
<Input
value={snippet.description || ''}
onChange={(event) => onChange({ ...snippet, description: event.target.value })}
className="h-8"
placeholder={t('scripts.meta.descriptionPlaceholder')}
/>
</div>
</div>
);
}
return (
<div className="space-y-3">
<div className="space-y-1.5">
<label className="text-xs font-semibold text-muted-foreground">{t('scripts.meta.name')}</label>
<Input
value={snippet.label}
onChange={(event) => onChange({ ...snippet, label: event.target.value })}
className="h-9"
/>
</div>
<div className="space-y-1.5">
<label className="text-xs font-semibold text-muted-foreground">{t('scripts.meta.description')}</label>
<Textarea
value={snippet.description || ''}
onChange={(event) => onChange({ ...snippet, description: event.target.value })}
rows={2}
className="min-h-0 resize-none"
placeholder={t('scripts.meta.descriptionPlaceholder')}
/>
</div>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1.5">
<label className="text-xs font-semibold text-muted-foreground">{t('scripts.meta.trigger')}</label>
<Select
value={snippet.trigger || 'manual'}
onValueChange={(value) => onChange({
...snippet,
trigger: value as ScriptTrigger,
})}
>
<SelectTrigger className="h-9"><SelectValue /></SelectTrigger>
<SelectContent>
{triggerOptions.map((option) => (
<SelectItem key={option.value} value={option.value}>{option.label}</SelectItem>
))}
</SelectContent>
</Select>
</div>
{snippet.trigger === 'onOutput' ? (
<div className="space-y-1.5 col-span-2">
<label className="text-xs font-semibold text-muted-foreground">{t('scripts.meta.triggerPattern')}</label>
<Input
value={snippet.triggerPattern || ''}
onChange={(event) => onChange({ ...snippet, triggerPattern: event.target.value })}
placeholder="sudo.*password"
className="h-9 font-mono text-xs"
/>
<p className="text-[11px] text-muted-foreground leading-relaxed">{t('scripts.trigger.onOutputHint')}</p>
</div>
) : null}
</div>
</div>
);
};
@@ -0,0 +1,102 @@
import { CircleHelp } from 'lucide-react';
import React, { useState } from 'react';
import { useI18n } from '@/application/i18n/I18nProvider';
import { cn } from '@/lib/utils';
import { Button } from '@/components/ui/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
const HELP_STEP_KEYS = [
'scripts.recording.helpStep1',
'scripts.recording.helpStep2',
'scripts.recording.helpStep3',
'scripts.recording.helpStep4',
'scripts.recording.helpStep5',
] as const;
const HELP_TIP_KEYS = [
'scripts.recording.helpTip1',
'scripts.recording.helpTip2',
'scripts.recording.helpTip3',
'scripts.recording.helpTip4',
] as const;
export interface ScriptRecordingHelpDialogProps {
triggerClassName?: string;
}
export const ScriptRecordingHelpDialog: React.FC<ScriptRecordingHelpDialogProps> = ({
triggerClassName,
}) => {
const { t } = useI18n();
const [open, setOpen] = useState(false);
return (
<>
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
onClick={() => setOpen(true)}
aria-label={t('scripts.recording.helpTitle')}
className={triggerClassName ?? 'shrink-0 h-8 w-8 flex items-center justify-center rounded-md text-muted-foreground hover:text-foreground hover:bg-muted/60 transition-colors'}
>
<CircleHelp size={15} />
</button>
</TooltipTrigger>
<TooltipContent side="top">{t('scripts.recording.helpTitle')}</TooltipContent>
</Tooltip>
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent className="max-w-lg">
<DialogHeader>
<DialogTitle>{t('scripts.recording.helpTitle')}</DialogTitle>
<DialogDescription>{t('scripts.recording.helpIntro')}</DialogDescription>
</DialogHeader>
<div className="space-y-4 text-sm">
<ol className="list-none">
{HELP_STEP_KEYS.map((key, index) => (
<li
key={key}
className={cn(
'flex items-start gap-3 py-3',
index < HELP_STEP_KEYS.length - 1 && 'border-b border-border/50',
)}
>
<span className="shrink-0 flex h-6 w-6 items-center justify-center rounded-full bg-primary/10 text-primary text-xs font-semibold tabular-nums leading-none mt-px">
{index + 1}
</span>
<span className="min-w-0 flex-1 text-foreground text-sm leading-relaxed">{t(key)}</span>
</li>
))}
</ol>
<div className="rounded-md border border-border/60 bg-muted/30 px-3 py-2.5 space-y-2">
<p className="text-xs font-semibold text-muted-foreground">{t('scripts.recording.helpTipsTitle')}</p>
<ul className="space-y-1.5 text-xs text-muted-foreground leading-relaxed">
{HELP_TIP_KEYS.map((key) => (
<li key={key} className="flex gap-2">
<span className="shrink-0">•</span>
<span>{t(key)}</span>
</li>
))}
</ul>
</div>
</div>
<DialogFooter>
<Button onClick={() => setOpen(false)}>{t('common.close')}</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
);
};
+171
View File
@@ -0,0 +1,171 @@
import { Check, FileText, Loader2, Pause, Play, Square, X } from 'lucide-react';
import React, { useMemo, useState } from 'react';
import { useI18n } from '@/application/i18n/I18nProvider';
import type { ScriptRun } from '@/types/global/netcatty-bridge-script.d.ts';
import { Button } from '@/components/ui/button';
import { cn } from '@/lib/utils.ts';
import { ScriptRunLogDialog } from './ScriptRunLogDialog';
export interface ScriptRunListProps {
runs: ScriptRun[];
onStop: (runId: string) => void;
onPause: (runId: string) => void;
onResume: (runId: string) => void;
}
function formatDuration(startedAt: number, endedAt?: number) {
const ms = (endedAt ?? Date.now()) - startedAt;
const seconds = Math.floor(ms / 1000);
const minutes = Math.floor(seconds / 60);
const rest = seconds % 60;
return `${String(minutes).padStart(2, '0')}:${String(rest).padStart(2, '0')}`;
}
function ScriptStatusIcon({ status }: { status: ScriptRun['status'] }) {
if (status === 'completed') {
return <Check size={14} className="text-emerald-500 shrink-0" aria-hidden />;
}
if (status === 'failed') {
return <X size={14} className="text-destructive shrink-0" aria-hidden />;
}
return <Loader2 size={14} className="animate-spin text-primary shrink-0" aria-hidden />;
}
function resolveRunMeta(run: ScriptRun, t: (key: string, params?: Record<string, string | number>) => string) {
const elapsed = formatDuration(run.startedAt, run.endedAt);
const parts: string[] = [t(`scripts.running.status.${run.status}`)];
if ((run.stepIndex ?? 0) > 0 || run.status === 'running' || run.status === 'paused') {
parts.push(t('scripts.running.operationsCount', { count: run.stepIndex ?? 0 }));
}
if (run.status === 'running' || run.status === 'paused') {
if (run.progressMode === 'determinate' && run.progressTotal) {
parts.push(t('scripts.running.determinateProgress', {
label: run.progressLabel || t('scripts.running.progressFallback'),
current: run.progressCurrent ?? 0,
total: run.progressTotal,
}));
} else if (run.activityLabel) {
parts.push(run.activityLabel);
} else if (run.waitingFor) {
parts.push(t('scripts.running.waitingFor', { pattern: run.waitingFor }));
}
}
parts.push(t('scripts.running.elapsed', { elapsed }));
return parts.join(' · ');
}
function sortRuns(runs: ScriptRun[]): ScriptRun[] {
const rank = (status: ScriptRun['status']) => {
if (status === 'running') return 0;
if (status === 'paused') return 1;
return 2;
};
return [...runs].sort((a, b) => {
const rankDiff = rank(a.status) - rank(b.status);
if (rankDiff !== 0) return rankDiff;
return b.startedAt - a.startedAt;
});
}
export const ScriptRunList: React.FC<ScriptRunListProps> = ({
runs,
onStop,
onPause,
onResume,
}) => {
const { t } = useI18n();
const [logRunId, setLogRunId] = useState<string | null>(null);
const sortedRuns = useMemo(() => sortRuns(runs), [runs]);
const logRun = sortedRuns.find((run) => run.runId === logRunId) ?? null;
if (runs.length === 0) {
return (
<div className="px-3 py-8 text-center text-sm text-muted-foreground">
{t('scripts.running.empty')}
</div>
);
}
return (
<>
<div className="py-1">
{sortedRuns.map((run) => {
const label = run.scriptLabel || run.scriptId || t('scripts.running.unnamed');
const isActive = run.status === 'running' || run.status === 'paused';
return (
<div
key={run.runId}
className="flex items-center gap-2 px-2 py-1.5 hover:bg-accent/40 transition-colors"
>
<ScriptStatusIcon status={run.status} />
<div className="min-w-0 flex-1">
<div className="truncate text-xs font-medium">{label}</div>
<div className="truncate text-[10px] text-muted-foreground">
{resolveRunMeta(run, t)}
</div>
{run.error ? (
<div className="truncate text-[10px] text-destructive">{run.error}</div>
) : null}
</div>
<div className="flex items-center gap-0.5 shrink-0">
<Button
size="icon"
variant="ghost"
className="h-7 w-7"
aria-label={t('scripts.running.viewLogs')}
onClick={() => setLogRunId(run.runId)}
>
<FileText size={14} />
</Button>
{run.status === 'running' ? (
<Button
size="icon"
variant="ghost"
className="h-7 w-7"
aria-label={t('scripts.running.pause')}
onClick={() => onPause(run.runId)}
>
<Pause size={14} />
</Button>
) : null}
{run.status === 'paused' ? (
<Button
size="icon"
variant="ghost"
className="h-7 w-7"
aria-label={t('scripts.running.resume')}
onClick={() => onResume(run.runId)}
>
<Play size={14} />
</Button>
) : null}
{isActive ? (
<Button
size="icon"
variant="ghost"
className={cn('h-7 w-7', isActive && 'text-destructive hover:text-destructive')}
aria-label={t('scripts.running.stop')}
onClick={() => onStop(run.runId)}
>
<Square size={14} />
</Button>
) : null}
</div>
</div>
);
})}
</div>
<ScriptRunLogDialog
run={logRun}
open={Boolean(logRun)}
onOpenChange={(open) => {
if (!open) setLogRunId(null);
}}
/>
</>
);
};
+72
View File
@@ -0,0 +1,72 @@
import React from 'react';
import { useI18n } from '@/application/i18n/I18nProvider';
import type { ScriptRun } from '@/types/global/netcatty-bridge-script.d.ts';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { cn } from '@/lib/utils.ts';
export interface ScriptRunLogDialogProps {
run: ScriptRun | null;
open: boolean;
onOpenChange: (open: boolean) => void;
}
function formatDuration(startedAt: number, endedAt?: number) {
const ms = (endedAt ?? Date.now()) - startedAt;
const seconds = Math.floor(ms / 1000);
const minutes = Math.floor(seconds / 60);
const rest = seconds % 60;
return `${String(minutes).padStart(2, '0')}:${String(rest).padStart(2, '0')}`;
}
export const ScriptRunLogDialog: React.FC<ScriptRunLogDialogProps> = ({
run,
open,
onOpenChange,
}) => {
const { t } = useI18n();
if (!run) return null;
const label = run.scriptLabel || run.scriptId || t('scripts.running.unnamed');
const elapsed = formatDuration(run.startedAt, run.endedAt);
const statusLabel = t(`scripts.running.status.${run.status}`);
const logText = run.logs.map((entry) => entry.message).join('\n');
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-2xl">
<DialogHeader>
<DialogTitle>{t('scripts.running.logTitle', { name: label })}</DialogTitle>
</DialogHeader>
<div className="space-y-3">
<div className="flex flex-wrap items-center gap-x-2 gap-y-1 text-xs text-muted-foreground">
<span>{statusLabel}</span>
<span aria-hidden>·</span>
<span>{t('scripts.running.operationsCount', { count: run.stepIndex ?? 0 })}</span>
<span aria-hidden>·</span>
<span>{t('scripts.running.elapsed', { elapsed })}</span>
</div>
{run.error ? (
<div className="text-xs text-destructive">{run.error}</div>
) : null}
{run.waitingFor ? (
<div className="text-xs text-muted-foreground">
{t('scripts.running.waitingFor', { pattern: run.waitingFor })}
</div>
) : null}
<pre className={cn(
'text-xs bg-secondary/40 rounded-md p-3 max-h-[min(60vh,420px)] overflow-auto whitespace-pre-wrap font-mono leading-relaxed',
!logText && 'text-muted-foreground italic',
)}
>
{logText || t('scripts.running.logEmpty')}
</pre>
</div>
</DialogContent>
</Dialog>
);
};
@@ -0,0 +1,95 @@
import React, { useMemo, useState } from 'react';
import { useI18n } from '@/application/i18n/I18nProvider';
import { Button } from '@/components/ui/button';
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import { Input } from '@/components/ui/input';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
const ROOT_PACKAGE_VALUE = '__root__';
function toSelectValue(packagePath: string): string {
return packagePath || ROOT_PACKAGE_VALUE;
}
function fromSelectValue(value: string): string {
return value === ROOT_PACKAGE_VALUE ? '' : value;
}
export interface ScriptSaveRecordingDialogProps {
open: boolean;
code: string;
packages: string[];
defaultName?: string;
onClose: () => void;
onSave: (payload: { name: string; packagePath: string; code: string; editAfterSave: boolean }) => void;
}
export const ScriptSaveRecordingDialog: React.FC<ScriptSaveRecordingDialogProps> = ({
open,
code,
packages,
defaultName,
onClose,
onSave,
}) => {
const { t } = useI18n();
const [name, setName] = useState(defaultName || '');
const [packageSelectValue, setPackageSelectValue] = useState(ROOT_PACKAGE_VALUE);
const packageOptions = useMemo(() => {
const unique = Array.from(new Set(packages.filter(Boolean)));
return [ROOT_PACKAGE_VALUE, ...unique];
}, [packages]);
React.useEffect(() => {
if (!open) return;
setName(defaultName || '');
setPackageSelectValue(toSelectValue(packages[0] || ''));
}, [defaultName, open, packages]);
const handleSave = (editAfterSave: boolean) => {
onSave({
name,
packagePath: fromSelectValue(packageSelectValue),
code,
editAfterSave,
});
};
return (
<Dialog open={open} onOpenChange={(next) => { if (!next) onClose(); }}>
<DialogContent className="max-w-2xl">
<DialogHeader>
<DialogTitle>{t('scripts.recording.saveTitle')}</DialogTitle>
</DialogHeader>
<div className="space-y-3">
<Input
value={name}
onChange={(event) => setName(event.target.value)}
placeholder={t('scripts.recording.namePlaceholder')}
/>
<Select value={packageSelectValue} onValueChange={setPackageSelectValue}>
<SelectTrigger><SelectValue placeholder={t('scripts.recording.packagePlaceholder')} /></SelectTrigger>
<SelectContent>
{packageOptions.map((value) => (
<SelectItem key={value} value={value}>
{value === ROOT_PACKAGE_VALUE ? t('scripts.recording.rootPackage') : value}
</SelectItem>
))}
</SelectContent>
</Select>
<pre className="text-xs bg-secondary/40 rounded p-3 max-h-48 overflow-auto whitespace-pre-wrap">{code}</pre>
</div>
<DialogFooter>
<Button variant="outline" onClick={onClose}>{t('common.cancel')}</Button>
<Button variant="outline" onClick={() => handleSave(false)}>
{t('scripts.recording.save')}
</Button>
<Button onClick={() => handleSave(true)}>
{t('scripts.recording.saveAndEdit')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
};
@@ -0,0 +1,162 @@
import React from 'react';
import { DistroAvatar } from '../DistroAvatar';
import { Button } from '@/components/ui/button';
import { Card } from '@/components/ui/card';
import { hostDisplayTitle } from '@/domain/hostDisplay.ts';
import { cn } from '@/lib/utils';
import type { Host } from '@/domain/models';
export interface SnippetTargetsSectionProps {
t: (key: string, params?: Record<string, unknown>) => string;
targetHosts: Host[];
onEditTargets: () => void;
hint?: string;
variant?: 'card' | 'embedded';
targetsAllHosts?: boolean;
onTargetsAllHostsChange?: (checked: boolean) => void;
}
const actionButtonClass = (embedded: boolean) => cn(
'shrink-0 rounded-md transition-colors',
embedded ? 'h-6 px-2 text-[11px]' : 'h-6 px-2 text-xs',
);
const TargetsBody: React.FC<{
t: SnippetTargetsSectionProps['t'];
targetHosts: Host[];
onEditTargets: () => void;
hint?: string;
embedded?: boolean;
targetsAllHosts?: boolean;
onTargetsAllHostsChange?: (checked: boolean) => void;
}> = ({
t,
targetHosts,
onEditTargets,
hint,
embedded = false,
targetsAllHosts = false,
onTargetsAllHostsChange,
}) => (
<>
<div className="flex items-center justify-between gap-3">
<p className={cn(
'font-semibold text-muted-foreground shrink-0',
embedded ? 'text-[11px]' : 'text-xs',
)}
>
{t('snippets.targets.title')}
</p>
<div className="flex items-center gap-1 min-w-0">
{!targetsAllHosts ? (
<Button
variant="ghost"
size="sm"
className={cn(actionButtonClass(embedded), 'text-primary')}
onClick={onEditTargets}
>
{targetHosts.length > 0 ? t('action.edit') : t('snippets.targets.add')}
</Button>
) : null}
{onTargetsAllHostsChange ? (
<button
type="button"
aria-pressed={targetsAllHosts}
aria-label={t('snippets.targets.allHosts')}
className={cn(
actionButtonClass(embedded),
targetsAllHosts
? 'bg-muted text-foreground font-medium'
: 'text-muted-foreground hover:bg-muted/60 hover:text-foreground',
)}
onClick={() => onTargetsAllHostsChange(!targetsAllHosts)}
>
{t('snippets.targets.allHostsShort')}
</button>
) : null}
</div>
</div>
{hint && !targetsAllHosts ? (
<p className="text-[11px] text-muted-foreground leading-relaxed">{hint}</p>
) : null}
{targetsAllHosts ? (
<p className={cn(
'text-muted-foreground/80',
embedded ? 'text-[10px]' : 'text-[11px]',
)}
>
{t('snippets.targets.allHostsActive')}
</p>
) : targetHosts.length === 0 ? (
embedded ? null : (
<Button
variant="secondary"
className="w-full h-10"
onClick={onEditTargets}
>
{t('snippets.targets.add')}
</Button>
)
) : (
<div className={cn('gap-2', embedded ? 'flex flex-wrap' : 'space-y-2')}>
{targetHosts.map((host) => (
embedded ? (
<div
key={host.id}
className="flex items-center gap-2 px-2 py-1.5 rounded-lg border border-border/60 bg-background/60 text-xs max-w-full"
>
<DistroAvatar host={host} fallback={host.os[0].toUpperCase()} size="sm" />
<span className="truncate font-medium">{hostDisplayTitle(host)}</span>
</div>
) : (
<div
key={host.id}
className="flex items-center gap-3 px-3 py-2 bg-background/60 border border-border/70 rounded-lg"
>
<DistroAvatar host={host} fallback={host.os[0].toUpperCase()} size="log" />
<div className="min-w-0 flex-1 text-sm font-semibold truncate">
{hostDisplayTitle(host)}
</div>
</div>
)
))}
</div>
)}
</>
);
export const SnippetTargetsSection: React.FC<SnippetTargetsSectionProps> = ({
t,
targetHosts,
onEditTargets,
hint,
variant = 'card',
targetsAllHosts,
onTargetsAllHostsChange,
}) => {
const body = (
<TargetsBody
t={t}
targetHosts={targetHosts}
onEditTargets={onEditTargets}
hint={hint}
embedded={variant === 'embedded'}
targetsAllHosts={targetsAllHosts}
onTargetsAllHostsChange={onTargetsAllHostsChange}
/>
);
if (variant === 'embedded') {
return <div className="space-y-2">{body}</div>;
}
return (
<Card className="p-3 space-y-3 bg-card border-border/80">
{body}
</Card>
);
};
@@ -0,0 +1,262 @@
import { Check, Loader2, Pause, Play, Square, X } from 'lucide-react';
import React, { useEffect, useMemo, useState } from 'react';
import { useI18n } from '@/application/i18n/I18nProvider';
import type { ScriptRun } from '@/types/global/netcatty-bridge-script.d.ts';
import { Button } from '@/components/ui/button';
import { cn } from '@/lib/utils.ts';
export interface ScriptExecutionOverlayProps {
run: ScriptRun;
onPause: () => void;
onResume: () => void;
onStop: () => void;
onDismiss: () => void;
}
function formatElapsed(ms: number) {
const seconds = Math.max(0, Math.floor(ms / 1000));
const minutes = Math.floor(seconds / 60);
const rest = seconds % 60;
if (minutes > 0) {
return `${minutes}:${String(rest).padStart(2, '0')}`;
}
return `${rest}s`;
}
function resolveWaitingPattern(
run: ScriptRun,
t: (key: string, params?: Record<string, string | number>) => string,
) {
if (!run.waitingFor) return undefined;
if (run.waitingFor === 'shell prompt' || run.waitingFor.includes(' | ')) {
return t('scripts.running.waitingForShellPrompt');
}
return run.waitingFor;
}
function isLowValueActivityLabel(label?: string) {
if (!label) return true;
const normalized = label.trim().toLowerCase();
return normalized === 'log' || normalized.startsWith('sleep ');
}
function DotSeparator() {
return <span className="text-muted-foreground/35 px-0.5">·</span>;
}
function Muted({ children }: { children: React.ReactNode }) {
return <span className="text-muted-foreground">{children}</span>;
}
function Accent({ children, className }: { children: React.ReactNode; className?: string }) {
return (
<span className={cn('text-primary font-semibold tabular-nums', className)}>
{children}
</span>
);
}
function ScriptStatusIcon({ status }: { status: ScriptRun['status'] }) {
const iconClass = 'block';
const boxClass = 'inline-flex h-3.5 w-3.5 shrink-0 items-center justify-center';
if (status === 'completed') {
return (
<span className={boxClass} aria-hidden>
<Check size={14} className={cn(iconClass, 'text-emerald-500')} />
</span>
);
}
if (status === 'failed') {
return (
<span className={boxClass} aria-hidden>
<X size={14} className={cn(iconClass, 'text-destructive')} />
</span>
);
}
return (
<span className={boxClass} aria-hidden>
<Loader2 size={14} className={cn(iconClass, 'animate-spin text-primary')} />
</span>
);
}
function ScriptStatusLine({
run,
elapsedMs,
lastSent,
t,
}: {
run: ScriptRun;
elapsedMs: number;
lastSent?: string;
t: (key: string, params?: Record<string, string | number>) => string;
}) {
const label = run.scriptLabel || t('scripts.running.unnamed');
const opCount = run.stepIndex ?? 0;
const elapsed = formatElapsed(elapsedMs);
const waitingPattern = resolveWaitingPattern(run, t);
const isFinished = run.status === 'completed' || run.status === 'failed';
const opsSegment = (
<>
<Muted>{t('scripts.running.opsPrefix')}</Muted>
<Accent>{opCount}</Accent>
<Muted>{t('scripts.running.opsSuffix')}</Muted>
</>
);
const elapsedSegment = <Accent>{elapsed}</Accent>;
const activitySegment = !isLowValueActivityLabel(run.activityLabel) ? (
<>
<DotSeparator />
<span className="text-foreground/90">{run.activityLabel}</span>
</>
) : null;
const progressSegment = run.progressMode === 'determinate' && run.progressTotal ? (
<>
<DotSeparator />
<Muted>{run.progressLabel || t('scripts.running.progressFallback')}</Muted>
{' '}
<Accent>
{run.progressCurrent ?? 0}
/
{run.progressTotal}
</Accent>
</>
) : null;
const pausedSegment = run.status === 'paused' ? (
<>
<DotSeparator />
<span className="text-amber-500 font-medium">{t('scripts.running.status.paused')}</span>
</>
) : null;
const waitingSegment = waitingPattern ? (
<>
<DotSeparator />
<Muted>{t('scripts.running.waitingForLabel')}</Muted>
{' '}
<span className="text-amber-500 font-medium">{waitingPattern}</span>
</>
) : null;
const lastSentSegment = !waitingPattern && lastSent ? (
<>
<DotSeparator />
<Muted>{t('scripts.running.lastSentLabel')}</Muted>
{' '}
<span className="text-primary/90 font-mono">{lastSent}</span>
</>
) : null;
return (
<span className="flex min-w-0 flex-1 items-center gap-1.5 leading-none">
<ScriptStatusIcon status={run.status} />
<span className="shrink-0 whitespace-nowrap font-semibold text-foreground">{label}</span>
<span className="inline-flex min-w-0 flex-1 items-center truncate">
{(isFinished || opCount > 0) ? (
<>
<DotSeparator />
{opsSegment}
</>
) : null}
<DotSeparator />
{elapsedSegment}
{!isFinished ? (
<>
{progressSegment}
{activitySegment}
{pausedSegment}
{waitingSegment}
{lastSentSegment}
</>
) : null}
</span>
</span>
);
}
export const ScriptExecutionOverlay: React.FC<ScriptExecutionOverlayProps> = ({
run,
onPause,
onResume,
onStop,
onDismiss,
}) => {
const { t } = useI18n();
const [tick, setTick] = useState(0);
const isFinished = run.status === 'completed' || run.status === 'failed';
useEffect(() => {
if (isFinished) return undefined;
const timer = window.setInterval(() => setTick((value) => value + 1), 1000);
return () => window.clearInterval(timer);
}, [isFinished, run.runId]);
void tick;
const elapsedMs = run.elapsedMs
?? (run.endedAt ? run.endedAt - run.startedAt : Date.now() - run.startedAt);
const lastSent = [...(run.logs || [])].reverse().find((entry) => entry.message.startsWith('→ '))?.message.slice(2);
const errorMessage = run.status === 'failed' ? run.error : undefined;
const statusLine = useMemo(
() => (
<ScriptStatusLine run={run} elapsedMs={elapsedMs} lastSent={lastSent} t={t} />
),
[run, elapsedMs, lastSent, t],
);
return (
<div
className="absolute left-2 right-2 z-25 rounded-md border shadow-md backdrop-blur-md pointer-events-auto px-3 py-2"
style={{
top: 34,
backgroundColor: 'color-mix(in srgb, var(--terminal-ui-bg) 92%, transparent)',
borderColor: 'var(--terminal-ui-border)',
color: 'var(--terminal-ui-fg)',
}}
data-section="script-execution-overlay"
>
<div className="flex items-center gap-2 min-w-0">
<div className="min-w-0 flex flex-1 items-center text-[11px] leading-none">
{statusLine}
</div>
{errorMessage ? (
<div
className="min-w-0 max-w-[42%] shrink text-[11px] leading-none text-destructive truncate text-right"
title={errorMessage}
>
{errorMessage}
</div>
) : null}
<div className="flex items-center gap-1 shrink-0">
{isFinished ? (
<Button size="icon" variant="ghost" className="h-7 w-7" onClick={onDismiss} title={t('scripts.running.dismiss')}>
<X size={14} />
</Button>
) : (
<>
{run.status === 'running' ? (
<Button size="icon" variant="ghost" className="h-7 w-7" onClick={onPause}>
<Pause size={14} />
</Button>
) : null}
{run.status === 'paused' ? (
<Button size="icon" variant="ghost" className="h-7 w-7" onClick={onResume}>
<Play size={14} />
</Button>
) : null}
<Button size="icon" variant="ghost" className="h-7 w-7" onClick={onStop}>
<Square size={14} />
</Button>
</>
)}
</div>
</div>
</div>
);
};
@@ -0,0 +1,51 @@
import { Pause, Play, Square } from 'lucide-react';
import React from 'react';
import { useI18n } from '@/application/i18n/I18nProvider';
import { Button } from '@/components/ui/button';
import { cn } from '@/lib/utils.ts';
export interface ScriptRecordingIndicatorProps {
elapsedMs: number;
isPaused: boolean;
onPause: () => void;
onResume: () => void;
onStop: () => void;
}
function formatElapsed(ms: number) {
const seconds = Math.floor(ms / 1000);
const minutes = Math.floor(seconds / 60);
const rest = seconds % 60;
return `${String(minutes).padStart(2, '0')}:${String(rest).padStart(2, '0')}`;
}
export const ScriptRecordingIndicator: React.FC<ScriptRecordingIndicatorProps> = ({
elapsedMs,
isPaused,
onPause,
onResume,
onStop,
}) => {
const { t } = useI18n();
return (
<div className="flex items-center gap-2 text-xs">
<span className="flex items-center gap-1 text-red-500 font-medium">
<span className={cn('inline-block h-2 w-2 rounded-full bg-red-500', !isPaused && 'animate-pulse')} />
REC
</span>
<span className="tabular-nums text-muted-foreground">{formatElapsed(elapsedMs)}</span>
{isPaused ? (
<Button size="icon" variant="ghost" className="h-7 w-7" onClick={onResume} title={t('scripts.recording.resume')}>
<Play size={14} />
</Button>
) : (
<Button size="icon" variant="ghost" className="h-7 w-7" onClick={onPause} title={t('scripts.recording.pause')}>
<Pause size={14} />
</Button>
)}
<Button size="icon" variant="ghost" className="h-7 w-7" onClick={onStop} title={t('scripts.recording.stop')}>
<Square size={14} />
</Button>
</div>
);
};
+15 -1
View File
@@ -2,7 +2,7 @@
* Terminal Toolbar
* Displays high-frequency terminal actions and close button in the terminal status bar.
*/
import { Check, ChevronRight, Download, FileText, FolderInput, FolderSync, History, Languages, MoreVertical, X, Zap, Palette, Search, TextCursorInput, Upload } from 'lucide-react';
import { Check, ChevronRight, Download, FileText, FolderInput, FolderSync, History, Languages, MoreVertical, X, Zap, Palette, Search, TextCursorInput, Upload, Circle } from 'lucide-react';
import React, { useState } from 'react';
import { useI18n } from '../../application/i18n/I18nProvider';
import { Host, Snippet } from '../../types';
@@ -45,6 +45,8 @@ export interface TerminalToolbarProps {
// Terminal encoding
terminalEncoding?: 'utf-8' | 'gb18030';
onSetTerminalEncoding?: (encoding: 'utf-8' | 'gb18030') => void;
recordingIndicator?: React.ReactNode;
onStartRecording?: () => void;
}
export const TerminalToolbar: React.FC<TerminalToolbarProps> = ({
@@ -74,6 +76,8 @@ export const TerminalToolbar: React.FC<TerminalToolbarProps> = ({
onToggleComposeBar,
terminalEncoding,
onSetTerminalEncoding,
recordingIndicator,
onStartRecording,
}) => {
const { t } = useI18n();
const [highlightPopoverOpen, setHighlightPopoverOpen] = useState(false);
@@ -399,6 +403,14 @@ export const TerminalToolbar: React.FC<TerminalToolbarProps> = ({
<span className="flex-1 text-left truncate">{t("terminal.toolbar.terminalSettings")}</span>
</button>
</PopoverClose>
{onStartRecording && status === 'connected' && !recordingIndicator ? (
<PopoverClose asChild>
<button type="button" className={menuItemClass} onClick={onStartRecording}>
<Circle size={12} className="shrink-0 text-red-500" />
<span className="flex-1 text-left truncate">{t('scripts.recording.start')}</span>
</button>
</PopoverClose>
) : null}
{encodingSwitchSupported && onSetTerminalEncoding && (
<Popover open={encodingSubmenuOpen} onOpenChange={setEncodingSubmenuOpen}>
<PopoverTrigger asChild>
@@ -453,6 +465,8 @@ export const TerminalToolbar: React.FC<TerminalToolbarProps> = ({
</PopoverContent>
</Popover>
{recordingIndicator}
{showClose && onClose && (
<Tooltip>
<TooltipTrigger asChild>
+3 -1
View File
@@ -97,7 +97,7 @@ function terminalViewCtxEqual(
}
function TerminalViewInner({ ctx }: { ctx: TerminalViewContext }) {
const { Activity, Button, Clock3, Copy, Maximize2, Radio, Sparkles, SquareArrowOutUpRight, TerminalAutocomplete, TerminalComposeBar, TerminalConnectionDialog, TerminalContextMenu, TerminalSearchBar, Tooltip, TooltipContent, TooltipTrigger, ZmodemOverwriteDialog, ZmodemProgressIndicator, auth, autocompleteAcceptTextRef, autocompleteCloseRef, autocompleteHostOs, autocompleteInputRef, autocompleteKeyEventRef, autocompleteRepositionRef, autocompleteSettings, chainProgress, cn, compactToolbar, lineTimestampsAvailable, containerRef, effectiveFontSize, effectiveFontWeight, effectiveTheme, error, executeSnippet, executeSnippetCommand, handleAddSelectionToAI, handleCancelConnect, handleCloseDisconnectedSession, handleCloseSearch, handleDismissDisconnectedDialog, handleDragEnter, handleDragLeave, handleDragOver, handleDrop, handleFindNext, handleFindPrevious, handleHostKeyAddAndContinue, handleHostKeyClose, handleHostKeyContinue, handleOsc52ReadResponse, handleOsc7SetupConfirm, handleOsc7SetupOpenChange, handleReceiveYmodem, handleRetry, handleSearch, handleSendYmodem, handleTopOverlayMouseDownCapture, hasMouseTracking, hasSelection, host, hotkeyScheme, inWorkspace, isBroadcastEnabled, isCancelling, isComposeBarOpen, isDraggingOver, isFocusMode, isLocalConnection, remoteDragDropUsesZmodem, isSerialConnection, isSearchOpen, isSupportedOs, isSystemSidebarEligible, isVisible, keyBindings, keys, knownCwdRef, needsHostKeyVerification, onCloseSession, onDetach, onDetachPointerDown, onExpandToFocus, onOpenSystem, onRename, onSplitHorizontal, onSplitVertical, onToggleBroadcast, onUpdateHost, osc52ReadPromptVisible, osc7SetupOpen, osc7SetupRunning, pendingHostKeyInfo, progressLogs, progressValue, renderControls, resolvedFontFamily, restoreState, searchMatchCount, selectionOverlayPosition, sessionDisplayName, sessionId, sessionRef, setIsComposeBarOpen, setShowLogs, shouldShowConnectionDialog, showLogs, showSelectionAIAction, snippets, status, statusDotTone, sudoHintRef, sudoHintText, t, termRef, terminalContextActions, terminalCwdTracker, terminalPreviewVars, terminalSettings, timeLeft, toast, zmodem } = ctx;
const { Activity, Button, Clock3, Copy, Maximize2, Radio, Sparkles, SquareArrowOutUpRight, TerminalAutocomplete, TerminalComposeBar, TerminalConnectionDialog, TerminalContextMenu, TerminalSearchBar, Tooltip, TooltipContent, TooltipTrigger, ZmodemOverwriteDialog, ZmodemProgressIndicator, auth, autocompleteAcceptTextRef, autocompleteCloseRef, autocompleteHostOs, autocompleteInputRef, autocompleteKeyEventRef, autocompleteRepositionRef, autocompleteSettings, chainProgress, cn, compactToolbar, lineTimestampsAvailable, containerRef, effectiveFontSize, effectiveFontWeight, effectiveTheme, error, executeSnippet, executeSnippetCommand, handleAddSelectionToAI, handleCancelConnect, handleCloseDisconnectedSession, handleCloseSearch, handleDismissDisconnectedDialog, handleDragEnter, handleDragLeave, handleDragOver, handleDrop, handleFindNext, handleFindPrevious, handleHostKeyAddAndContinue, handleHostKeyClose, handleHostKeyContinue, handleOsc52ReadResponse, handleOsc7SetupConfirm, handleOsc7SetupOpenChange, handleReceiveYmodem, handleRetry, handleSearch, handleSendYmodem, handleTopOverlayMouseDownCapture, hasMouseTracking, hasSelection, host, hotkeyScheme, inWorkspace, isBroadcastEnabled, isCancelling, isComposeBarOpen, isDraggingOver, isFocusMode, isLocalConnection, remoteDragDropUsesZmodem, isSerialConnection, isSearchOpen, isSupportedOs, isSystemSidebarEligible, isVisible, keyBindings, keys, knownCwdRef, needsHostKeyVerification, onCloseSession, onDetach, onDetachPointerDown, onExpandToFocus, onOpenSystem, onRename, onSplitHorizontal, onSplitVertical, onToggleBroadcast, onUpdateHost, osc52ReadPromptVisible, osc7SetupOpen, osc7SetupRunning, pendingHostKeyInfo, progressLogs, progressValue, renderControls, resolvedFontFamily, restoreState, scriptExecutionOverlay, searchMatchCount, selectionOverlayPosition, sessionDisplayName, sessionId, sessionRef, setIsComposeBarOpen, setShowLogs, shouldShowConnectionDialog, showLogs, showSelectionAIAction, snippets, status, statusDotTone, sudoHintRef, sudoHintText, t, termRef, terminalContextActions, terminalCwdTracker, terminalPreviewVars, terminalSettings, timeLeft, toast, zmodem } = ctx;
const ymodemActionEnabled = shouldEnableYmodemAction({
isSerialConnection,
status,
@@ -472,6 +472,8 @@ function TerminalViewInner({ ctx }: { ctx: TerminalViewContext }) {
sudoHintText={sudoHintText}
/>
{scriptExecutionOverlay}
{/* OSC-52 clipboard read prompt */}
{osc52ReadPromptVisible && (
<div
@@ -7,15 +7,11 @@
*/
import type { Snippet } from "../../../domain/models";
import type { CompletionSuggestion } from "./completionEngine";
import { snippetAppliesToHost } from "../../../domain/snippetTargets";
const SNIPPET_BASE_SCORE = 2000; // Above history (1000+freq) per "snippet > history".
const SNIPPET_PREFIX_BONUS = 100;
function appliesToHost(snippet: Snippet, hostId?: string): boolean {
if (!snippet.targets || snippet.targets.length === 0) return true;
return hostId !== undefined && snippet.targets.includes(hostId);
}
export function getSnippetSuggestions(
input: string,
snippets: Snippet[],
@@ -26,7 +22,7 @@ export function getSnippetSuggestions(
const out: CompletionSuggestion[] = [];
for (const snippet of snippets) {
if (!appliesToHost(snippet, options.hostId)) continue;
if (!snippetAppliesToHost(snippet, options.hostId)) continue;
const label = (snippet.label || "").toLowerCase();
const firstLine = (snippet.command || "").split("\n")[0].trim().toLowerCase();
@@ -189,6 +189,15 @@ export type CreateXTermRuntimeContext = {
) => void;
commandBufferRef: RefObject<string>;
promptLineBreakStateRef?: RefObject<PromptLineBreakState>;
scriptRecorderRef?: RefObject<{
isRecording: boolean;
recordInput: (data: string) => void;
recordBackspace: () => void;
recordClearLine: () => void;
recordEnter: (options?: { sensitive?: boolean }) => Promise<void>;
} | undefined>;
passwordPromptActiveRef?: RefObject<boolean>;
onOutputTriggerUserInputRef?: RefObject<((data: string) => void) | undefined>;
sudoAutofillRef?: RefObject<SudoPasswordAutofill | null>;
setIsSearchOpen: Dispatch<SetStateAction<boolean>>;
@@ -1049,6 +1058,14 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
hasBroadcastInputHandler: !!onBroadcastInput,
});
if (ctx.statusRef.current === "connected" && (data === "\r" || data === "\n")) {
if (ctx.scriptRecorderRef?.current?.isRecording) {
void ctx.scriptRecorderRef.current.recordEnter({
sensitive: ctx.passwordPromptActiveRef?.current,
});
if (ctx.passwordPromptActiveRef) {
ctx.passwordPromptActiveRef.current = false;
}
}
const recordedCommand = recordTerminalCommandExecution(ctx.commandBufferRef.current, ctx, term);
handledSubmittedInput = true;
if (!willBroadcastInput) {
@@ -1086,7 +1103,10 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
handleSerialLineModeInput(dataToWrite, {
bufferRef: ctx.serialLineBufferRef,
localEcho: ctx.serialLocalEcho,
writeToSession: (nextData) => ctx.terminalBackend.writeToSession(id, nextData),
writeToSession: (nextData) => {
ctx.onOutputTriggerUserInputRef?.current?.(nextData);
ctx.terminalBackend.writeToSession(id, nextData);
},
writeToTerminal: writeLocalTerminalData,
});
} else {
@@ -1096,6 +1116,7 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
if (dataToWrite === "\x7f" && ctx.host.backspaceBehavior === "ctrl-h") {
outData = "\x08";
}
ctx.onOutputTriggerUserInputRef?.current?.(outData);
ctx.terminalBackend.writeToSession(id, outData);
// Local echo for serial connections only when explicitly enabled
@@ -1135,18 +1156,24 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
// input is written so sudo can receive a one-time prompt marker.
} else if (data === "\x7f" || data === "\b") {
ctx.commandBufferRef.current = ctx.commandBufferRef.current.slice(0, -1);
ctx.scriptRecorderRef?.current?.recordBackspace();
} else if (data === "\x03") {
ctx.commandBufferRef.current = "";
ctx.scriptRecorderRef?.current?.recordClearLine();
} else if (data === "\x15") {
ctx.commandBufferRef.current = "";
ctx.scriptRecorderRef?.current?.recordClearLine();
} else if (data.length === 1 && data.charCodeAt(0) >= 32) {
ctx.commandBufferRef.current += data;
ctx.scriptRecorderRef?.current?.recordInput(data);
} else if (data.length > 1 && !data.startsWith("\x1b")) {
ctx.commandBufferRef.current += data;
ctx.scriptRecorderRef?.current?.recordInput(data);
} else {
const pastedLine = getSingleBracketedPasteLine(data);
if (pastedLine) {
ctx.commandBufferRef.current += pastedLine;
ctx.scriptRecorderRef?.current?.recordInput(pastedLine);
}
}
}
+2
View File
@@ -137,6 +137,8 @@ export interface TerminalProps {
restoreTerminalCwd?: boolean;
startupCommand?: string;
noAutoRun?: boolean;
pendingScriptId?: string;
pendingScript?: Snippet;
// When this tab was created from a connected SSH session, the id of the
// source session whose authenticated connection should be reused for a new
// shell channel — skipping a second MFA prompt (issue #1204).
+2
View File
@@ -41,6 +41,8 @@ export const terminalPropsAreEqual = (
&& prev.sessionDisplayName === next.sessionDisplayName
&& prev.startupCommand === next.startupCommand
&& prev.noAutoRun === next.noAutoRun
&& prev.pendingScriptId === next.pendingScriptId
&& prev.pendingScript === next.pendingScript
&& prev.reuseConnectionFromSessionId === next.reuseConnectionFromSessionId
&& prev.serialConfig === next.serialConfig
&& prev.hotkeyScheme === next.hotkeyScheme
+4 -1
View File
@@ -88,7 +88,7 @@ export function resolveSelectionOverlayPosition(term: any, container: HTMLElemen
}
export function useTerminalEffects(ctx: TerminalEffectsContext) {
const { 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, isFocusMode, isFocused, isLocalConnection, isNetworkDevice, isResizing, isRestoringSelectionRef, isSearchOpen, isSerialConnection, isVisible, isVisibleRef, keyBindingsRef, keys, knownCwdRef, lastFittedSizeRef, lastToastedErrorRef, logger, mouseTrackingRef, onBroadcastInputRef, onBroadcastInterruptPriorityChange, onCommandExecuted, onCommandSubmitted, onHotkeyActionRef, 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, onSnippetShortkeyRef, 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 } = ctx;
const { 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, isFocusMode, isFocused, isLocalConnection, isNetworkDevice, isResizing, isRestoringSelectionRef, isSearchOpen, isSerialConnection, isVisible, isVisibleRef, keyBindingsRef, keys, knownCwdRef, lastFittedSizeRef, lastToastedErrorRef, logger, mouseTrackingRef, onBroadcastInputRef, onBroadcastInterruptPriorityChange, onCommandExecuted, onCommandSubmitted, onHotkeyActionRef, onOutputTriggerUserInputRef, onSnippetExecutorChange, onTerminalCwdChange, onTerminalTitleChange, onTerminalBell, onTerminalFontSizeChange, paneLayoutKey, passwordPromptActiveRef, pendingAuthRef, pendingOutputScrollRef, prepareRestoredReconnect, prevIsResizingRef, promptLineBreakStateRef, resizeSession, resolveHostAuth, resolvedFontFamily, safeFit, scriptRecorderRef, 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, onSnippetShortkeyRef, 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 } = ctx;
// Remember the last layout we successfully refit while visible so revisiting
// the same workspace tab does not replay expensive force-fit/WebGL recovery.
@@ -296,6 +296,9 @@ export function useTerminalEffects(ctx: TerminalEffectsContext) {
onCommandSubmitted,
commandBufferRef,
promptLineBreakStateRef,
scriptRecorderRef,
passwordPromptActiveRef,
onOutputTriggerUserInputRef,
sudoAutofillRef,
setIsSearchOpen,
// Serial-specific options
@@ -183,7 +183,6 @@ test('host tree hover card renders markdown notes and keeps host details out of
assert.match(source, /<LazyMessageResponse/);
assert.match(source, /size="sm"/);
assert.match(source, /items-center gap-2/);
assert.match(source, /className="rounded"/);
assert.match(source, /flex h-5 min-w-0 items-center/);
assert.match(source, /translate-y-px truncate text-\[15px\] font-semibold leading-none/);
assert.match(source, /details\.host/);
@@ -196,10 +195,10 @@ test('host tree hover card renders markdown notes and keeps host details out of
test('host tree row icons, labels, and protocol badges share centered line boxes', () => {
const source = readFileSync(new URL('./TerminalHostTreeSidebar.tsx', import.meta.url), 'utf8');
assert.match(source, /flex h-5 shrink-0 items-center">\s*<DistroAvatar/);
assert.match(source, /flex h-5 min-w-0 flex-1 translate-y-px items-center truncate leading-none">\{row\.host\.label\}/);
assert.match(source, /flex h-5 shrink-0 translate-y-px items-center text-\[10px\] leading-none uppercase/);
assert.match(source, /flex h-5 w-4 shrink-0 items-center justify-center/);
assert.match(source, /flex h-5 shrink-0 items-center justify-center">\s*<DistroAvatar/);
assert.match(source, /flex min-w-0 flex-1 items-center truncate leading-none/);
assert.match(source, /flex shrink-0 items-center text-\[10px\] leading-none uppercase/);
assert.match(source, /flex h-5 w-4 shrink-0 items-center/);
assert.match(source, /flex h-5 shrink-0 items-center">\s*\{isExpanded/);
assert.match(source, /flex h-5 min-w-0 flex-1 translate-y-px items-center truncate leading-none">\{node\.name\}/);
assert.match(source, /flex min-w-0 flex-1 items-center truncate leading-none">\{node\.name\}/);
});
@@ -272,7 +272,6 @@ const TerminalHostTreeHostHoverCard: React.FC<{ host: Host }> = ({ host }) => {
host={host}
size="sm"
fallback={host.label.slice(0, 1).toUpperCase()}
className="rounded"
/>
<div className="flex h-5 min-w-0 items-center">
<div className="translate-y-px truncate text-[15px] font-semibold leading-none">{host.label}</div>
@@ -407,7 +406,7 @@ const HostTreeFlatRowItem = memo<HostTreeFlatRowProps>(({
}}
>
<span className="flex h-5 w-4 shrink-0 items-center" />
<span className="flex h-5 shrink-0 items-center">
<span className="flex h-5 shrink-0 items-center justify-center">
<DistroAvatar host={row.host} size="xs" fallback={row.host.label.slice(0, 1).toUpperCase()} />
</span>
{isInlineEditing && menuActions && inlineEditInitialName ? (
@@ -420,14 +419,14 @@ const HostTreeFlatRowItem = memo<HostTreeFlatRowProps>(({
/>
) : (
<span
className="flex h-5 min-w-0 flex-1 translate-y-px items-center truncate leading-none"
className="flex min-w-0 flex-1 items-center truncate leading-none"
style={{ color: theme.termFg }}
>
{row.host.label}
</span>
)}
{row.host.protocol && row.host.protocol !== 'ssh' && (
<span className="flex h-5 shrink-0 translate-y-px items-center text-[10px] leading-none uppercase opacity-70" style={{ color: theme.mutedFg }}>
<span className="flex shrink-0 items-center text-[10px] leading-none uppercase opacity-70" style={{ color: theme.mutedFg }}>
{row.host.protocol}
</span>
)}
@@ -555,7 +554,7 @@ const HostTreeFlatRowItem = memo<HostTreeFlatRowProps>(({
style={{ color: theme.termFg }}
/>
) : (
<span className="flex h-5 min-w-0 flex-1 translate-y-px items-center truncate leading-none">{node.name}</span>
<span className="flex min-w-0 flex-1 items-center truncate leading-none">{node.name}</span>
)}
</div>
);
@@ -85,11 +85,11 @@ test('side panel tab order can move the dragged tab after the target tab', () =>
test('notes side panel forwards repeated open-note requests', () => {
const layerSource = readFileSync(new URL('../TerminalLayer.tsx', import.meta.url), 'utf8');
const sectionSource = readFileSync(new URL('./TerminalLayerSidePanelSection.tsx', import.meta.url), 'utf8');
const slotsSource = readFileSync(new URL('./terminalLayerSidePanelSlots.tsx', import.meta.url), 'utf8');
assert.match(layerSource, /notesOpenRequestIdRef\.current \+= 1/);
assert.match(layerSource, /next\.set\(tabId, \{ noteId, requestId \}\)/);
assert.match(sectionSource, /openNoteRequestId=\{openNoteRequest\?\.requestId \?\? null\}/);
assert.match(slotsSource, /openNoteRequestId=\{openNoteRequest\?\.requestId \?\? null\}/);
});
test('side panel tab bar and borders use inline resolved terminal theme colors', () => {
@@ -1,5 +1,5 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import { Activity, FolderTree, History, MessageSquare, NotebookText, Palette, PanelLeft, PanelRight, X, Zap } from 'lucide-react';
import { Activity, FolderTree, History, MessageSquare, NotebookText, Palette, PanelLeft, PanelRight, Play, X } from 'lucide-react';
import { buildSidePanelChromeThemeFromTerminalTheme } from '../../infrastructure/theme/terminalAppearanceTokens';
import { injectTerminalLayerChromeSurfaceVars } from '../../infrastructure/theme/terminalAppearanceVars';
import React, { memo, useCallback, useLayoutEffect, useMemo, useRef, useState } from 'react';
@@ -234,7 +234,7 @@ function TerminalLayerSidePanelInner({ ctx }: { ctx: SidePanelContext }) {
const sidePanelTabItems = useMemo(() => [
{ id: 'sftp' as const, label: t('terminal.layer.sftp'), icon: <FolderTree size={15} />, onClick: handleToggleSftpFromBar },
{ id: 'scripts' as const, label: t('terminal.layer.scripts'), icon: <Zap size={15} />, onClick: handleOpenScripts },
{ id: 'scripts' as const, label: t('terminal.layer.scripts'), icon: <Play size={15} />, onClick: handleOpenScripts },
{ id: 'history' as const, label: t('terminal.layer.history'), icon: <History size={15} />, onClick: handleOpenHistory },
{ id: 'theme' as const, label: t('terminal.layer.theme'), icon: <Palette size={15} />, onClick: handleOpenTheme },
{ id: 'system' as const, label: t('terminal.layer.system'), icon: <Activity size={15} />, onClick: handleOpenSystem },
@@ -1315,6 +1315,8 @@ const TerminalPane: React.FC<TerminalPaneProps> = memo(({
restoreTerminalCwd={restoreTerminalCwd && sessionHostResolved}
startupCommand={session.startupCommand}
noAutoRun={session.noAutoRun}
pendingScriptId={session.pendingScriptId}
pendingScript={session.pendingScript}
reuseConnectionFromSessionId={session.reuseConnectionFromSessionId}
serialConfig={session.serialConfig}
hotkeyScheme={hotkeyScheme}
@@ -46,6 +46,13 @@ export function TerminalLayerTabBridge({ stableRef }: { stableRef: StableRef })
);
const isFocusMode = activeWorkspace?.viewMode === 'focus';
const focusedSessionId = activeWorkspace?.focusedSessionId;
const effectiveFocusedSessionId = useMemo((): string | null => {
if (activeWorkspace) {
if (focusedSessionId) return focusedSessionId;
return sessions.find((session) => session.workspaceId === activeWorkspace.id)?.id ?? null;
}
return activeSession?.id ?? null;
}, [activeSession?.id, activeWorkspace, focusedSessionId, sessions]);
s.activeWorkspaceRef.current = activeWorkspace;
s.activeSessionRef.current = activeSession;
@@ -137,7 +144,7 @@ export function TerminalLayerTabBridge({ stableRef }: { stableRef: StableRef })
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [linkedTerminalSessionIdForSftp, s.terminalCwdRevision]);
const historySessionId = (activeWorkspace ? focusedSessionId : activeSession?.id) ?? null;
const historySessionId = effectiveFocusedSessionId;
const activeTerminalSessionForSystem = useMemo(
() => resolveSystemSidebarSession(sessions, activeWorkspace, focusedSessionId, activeSession),
[activeSession, activeWorkspace, focusedSessionId, sessions],
@@ -216,6 +223,7 @@ export function TerminalLayerTabBridge({ stableRef }: { stableRef: StableRef })
activeTerminalSessionForSystem: activeTerminalSessionForSystem ?? null,
activeSystemSessionHost,
focusedHost,
focusedSessionId: effectiveFocusedSessionId,
historySessionId,
resolvedPreviewTheme: themeState.resolvedPreviewTheme,
previewedOrVisibleThemeId: themeState.previewedOrVisibleThemeId,
@@ -400,6 +408,13 @@ export function TerminalLayerTabBridge({ stableRef }: { stableRef: StableRef })
setSidePanelWidth: s.setSidePanelWidth,
handleSnippetClickForFocusedSession: s.handleSnippetClickForFocusedSession,
handleSnippetFromPanel: s.handleSnippetFromPanel,
handleRunScriptFromPanel: s.handleRunScriptFromPanel,
handleRunScriptOnWorkspace: s.handleRunScriptOnWorkspace,
handleStartRecordingFromPanel: s.handleStartRecordingFromPanel,
scriptRuns: s.scriptRuns,
handleStopScriptRun: s.handleStopScriptRun,
handlePauseScriptRun: s.handlePauseScriptRun,
handleResumeScriptRun: s.handleResumeScriptRun,
handleSnippetExecutorChange: s.handleSnippetExecutorChange,
handleBroadcastInterruptPriorityChange: s.handleBroadcastInterruptPriorityChange,
handleProgrammaticCommandLogRewriteChange: s.handleProgrammaticCommandLogRewriteChange,
@@ -580,6 +595,7 @@ export function TerminalLayerTabBridge({ stableRef }: { stableRef: StableRef })
s.terminalTheme,
s.resolveSessionAppearance,
s.hostMap,
s.scriptRuns,
]);
return <TerminalLayerView ctx={ctx} />;
@@ -209,6 +209,7 @@ function SidePanelScriptsSlotInner({
const sidePanelOpenTabs = ctx.sidePanelOpenTabs as Map<string, SidePanelTab>;
const sidePanelTab = useSidePanelTabType(tabId, sidePanelOpenTabs);
const isVisible = isTabActive && sidePanelTab === 'scripts';
const live = useSidePanelLiveSnapshotForTab(tabId, isVisible);
const {
ScriptsSidePanel,
@@ -217,6 +218,13 @@ function SidePanelScriptsSlotInner({
updateSnippets,
updateSnippetPackages,
handleSnippetFromPanel,
handleRunScriptFromPanel,
handleRunScriptOnWorkspace,
handleStartRecordingFromPanel,
scriptRuns,
handleStopScriptRun,
handlePauseScriptRun,
handleResumeScriptRun,
} = ctx;
return (
@@ -227,6 +235,14 @@ function SidePanelScriptsSlotInner({
onSnippetsChange={updateSnippets}
onPackagesChange={updateSnippetPackages}
onSnippetClick={handleSnippetFromPanel}
onRunScript={handleRunScriptFromPanel}
onRunScriptOnWorkspace={handleRunScriptOnWorkspace}
onStartRecording={handleStartRecordingFromPanel}
runs={scriptRuns}
onStopRun={handleStopScriptRun}
onPauseRun={handlePauseScriptRun}
onResumeRun={handleResumeScriptRun}
focusedSessionId={live.focusedSessionId ?? undefined}
isVisible={isVisible}
/>
</div>
@@ -125,7 +125,33 @@ function workspaceCtxKeyEqual(prev: Ctx, next: Ctx, key: string): boolean {
return prev[key] === next[key];
}
function scriptRunsEqual(a: any, b: any): boolean {
if (a === b) return true;
if (!Array.isArray(a) || !Array.isArray(b)) return false;
if (a.length !== b.length) return false;
for (let i = 0; i < a.length; i += 1) {
const prevRun = a[i];
const nextRun = b[i];
if (prevRun === nextRun) continue;
if (!prevRun || !nextRun) return false;
if (
prevRun.runId !== nextRun.runId
|| prevRun.status !== nextRun.status
|| prevRun.stepIndex !== nextRun.stepIndex
|| prevRun.waitingFor !== nextRun.waitingFor
|| prevRun.error !== nextRun.error
|| prevRun.logs?.length !== nextRun.logs?.length
) {
return false;
}
}
return true;
}
function sidePanelCtxKeyEqual(prev: Ctx, next: Ctx, key: string): boolean {
if (key === 'scriptRuns') {
return scriptRunsEqual(prev.scriptRuns, next.scriptRuns);
}
if (key === 'activeWorkspace') {
return activeWorkspaceEqual(prev.activeWorkspace, next.activeWorkspace);
}
@@ -139,7 +165,9 @@ const SIDE_PANEL_LIVE_CTX_KEYS = [
'activeTerminalSessionForSystem',
'activeSystemSessionHost',
'focusedHost',
'focusedSessionId',
'historySessionId',
'scriptRuns',
'resolvedPreviewTheme',
'previewedOrVisibleThemeId',
'sftpActiveHost',
@@ -200,6 +228,12 @@ const SIDE_PANEL_STABLE_CTX_KEYS = [
'snippets',
'snippetPackages',
'handleSnippetFromPanel',
'handleRunScriptFromPanel',
'handleRunScriptOnWorkspace',
'handleStartRecordingFromPanel',
'handleStopScriptRun',
'handlePauseScriptRun',
'handleResumeScriptRun',
'followAppTerminalTheme',
'terminalTheme',
'terminalThemeId',
+101 -4
View File
@@ -1,9 +1,42 @@
import { ArrowLeft, MoreVertical, X } from 'lucide-react';
import React, { createContext, ReactNode, useCallback, useContext, useMemo, useState } from 'react';
import { cn } from '../../lib/utils';
import { localStorageAdapter } from '@/infrastructure/persistence/localStorageAdapter';
import { Popover, PopoverContent, PopoverTrigger } from './popover';
import { ScrollArea } from './scroll-area';
export const DEFAULT_ASIDE_INLINE_WIDTH_PX = 380;
const MIN_ASIDE_INLINE_WIDTH_PX = 320;
const MAX_ASIDE_INLINE_WIDTH_PX = 720;
export function clampAsideInlineWidth(width: number): number {
return Math.max(MIN_ASIDE_INLINE_WIDTH_PX, Math.min(MAX_ASIDE_INLINE_WIDTH_PX, width));
}
function parseInlineWidthPx(width: string): number {
const arbitraryWidthMatch = width.match(/w-\[(.+)\]/);
if (arbitraryWidthMatch) {
const raw = arbitraryWidthMatch[1].trim();
const parsed = parseInt(raw, 10);
if (!Number.isNaN(parsed)) return clampAsideInlineWidth(parsed);
}
switch (width) {
case 'w-full':
case 'w-screen':
return DEFAULT_ASIDE_INLINE_WIDTH_PX;
default:
return DEFAULT_ASIDE_INLINE_WIDTH_PX;
}
}
function readPersistedAsideWidth(storageKey: string | undefined, fallback: number): number {
if (!storageKey) return fallback;
const stored = localStorageAdapter.readNumber(storageKey);
if (stored === null) return fallback;
return clampAsideInlineWidth(stored);
}
// Types
interface AsideContentItem {
id: string;
@@ -45,6 +78,9 @@ interface AsidePanelProps {
className?: string;
width?: string;
layout?: AsidePanelLayout;
resizable?: boolean;
persistWidthStorageKey?: string;
resizeAriaLabel?: string;
/**
* Optional stable identifier emitted as `data-section` on the panel
* root. Used as a targeting hook for Custom CSS (Settings → Appearance).
@@ -292,28 +328,89 @@ export const AsidePanel: React.FC<AsidePanelProps> = ({
className,
width = 'w-[380px]',
layout = 'overlay',
resizable = false,
persistWidthStorageKey,
resizeAriaLabel,
dataSection,
}) => {
if (!open) return null;
const fallbackWidthPx = parseInlineWidthPx(width);
const [inlineWidthPx, setInlineWidthPx] = useState(() =>
resizable ? readPersistedAsideWidth(persistWidthStorageKey, fallbackWidthPx) : fallbackWidthPx,
);
const [isResizing, setIsResizing] = useState(false);
const effectiveInlineWidthPx = resizable ? inlineWidthPx : fallbackWidthPx;
const inlineWidth = resolveInlineWidth(width);
const inlineStyle = layout === 'inline'
? ({
width: inlineWidth,
['--aside-inline-width' as string]: inlineWidth,
width: `${effectiveInlineWidthPx}px`,
['--aside-inline-width' as string]: `${effectiveInlineWidthPx}px`,
} as React.CSSProperties)
: undefined;
const handleResizeStart = useCallback((event: React.PointerEvent<HTMLDivElement>) => {
if (!resizable || layout !== 'inline') return;
event.preventDefault();
event.stopPropagation();
const startX = event.clientX;
const startWidth = inlineWidthPx;
const previousCursor = document.body.style.cursor;
const previousUserSelect = document.body.style.userSelect;
setIsResizing(true);
document.body.style.cursor = 'col-resize';
document.body.style.userSelect = 'none';
const handlePointerMove = (moveEvent: PointerEvent) => {
setInlineWidthPx(clampAsideInlineWidth(startWidth + startX - moveEvent.clientX));
};
const handlePointerUp = (upEvent: PointerEvent) => {
const nextWidth = clampAsideInlineWidth(startWidth + startX - upEvent.clientX);
setInlineWidthPx(nextWidth);
if (persistWidthStorageKey) {
localStorageAdapter.writeNumber(persistWidthStorageKey, nextWidth);
}
setIsResizing(false);
document.body.style.cursor = previousCursor;
document.body.style.userSelect = previousUserSelect;
window.removeEventListener('pointermove', handlePointerMove);
window.removeEventListener('pointerup', handlePointerUp);
window.removeEventListener('pointercancel', handlePointerUp);
};
window.addEventListener('pointermove', handlePointerMove);
window.addEventListener('pointerup', handlePointerUp);
window.addEventListener('pointercancel', handlePointerUp);
}, [inlineWidthPx, layout, persistWidthStorageKey, resizable]);
if (!open) return null;
return (
<div className={cn(
layout === 'inline'
? "relative split-panel-enter shrink-0 h-full min-h-0 max-w-full border-l border-border/60 bg-background z-30 flex flex-col app-no-drag overflow-hidden shadow-[-16px_0_32px_hsl(var(--foreground)/0.08)]"
: "absolute right-0 top-0 bottom-0 max-w-full border-l border-border/60 bg-background z-30 flex flex-col app-no-drag overflow-hidden",
layout === 'overlay' && width,
isResizing && layout === 'inline' && 'transition-none',
className
)}
style={inlineStyle}
data-section={dataSection}>
{resizable && layout === 'inline' ? (
<div
role="separator"
aria-orientation="vertical"
aria-label={resizeAriaLabel}
className={cn(
'absolute left-0 top-0 z-40 h-full w-2 -translate-x-1/2 cursor-col-resize',
'after:absolute after:left-1/2 after:top-2 after:h-[calc(100%-16px)] after:w-px after:-translate-x-1/2 after:bg-border/0 after:transition-colors',
'hover:after:bg-border/70',
isResizing && 'after:bg-primary/70',
)}
onPointerDown={handleResizeStart}
/>
) : null}
{title && (
<AsidePanelHeader
title={title}
+5 -1
View File
@@ -10,8 +10,12 @@ type VaultEntityIconProps = {
export const vaultEntityIconClass =
"h-11 w-11 rounded-xl flex items-center justify-center shrink-0";
export const vaultEntityIconSmClass =
"h-8 w-8 rounded-xl flex items-center justify-center shrink-0";
export const vaultPrimaryIconClass = "bg-primary text-primary-foreground";
export const vaultSnippetIconClass = "bg-amber-600 text-white dark:bg-amber-400 dark:text-slate-950";
export const vaultSnippetIconClass = "bg-sky-700 text-white dark:bg-sky-400 dark:text-slate-950";
export const vaultAutomationScriptIconClass = "bg-violet-700 text-white dark:bg-violet-400 dark:text-slate-950";
export const vaultKeyIconClass = "bg-cyan-600 text-white dark:bg-cyan-400 dark:text-slate-950";
export const vaultCertificateIconClass = "bg-teal-600 text-white dark:bg-teal-400 dark:text-slate-950";
export const vaultIdentityIconClass = "bg-emerald-600 text-white dark:bg-emerald-400 dark:text-slate-950";
+2 -2
View File
@@ -220,7 +220,7 @@ export const VaultTreeItemRow: React.FC<VaultTreeItemRowProps> = ({
{...props}
>
{leading ?? <div className="mr-1 h-5 w-4 flex-shrink-0" />}
{icon ?? <FileText size={14} className="mr-2 shrink-0 text-muted-foreground" />}
{icon ? <div className="mr-2 flex shrink-0 items-center self-center">{icon}</div> : <FileText size={14} className="mr-2 shrink-0 text-muted-foreground" />}
{content ?? (
<div className="min-w-0 flex-1">
{editing && onRenameCommit && onRenameCancel ? (
@@ -230,7 +230,7 @@ export const VaultTreeItemRow: React.FC<VaultTreeItemRowProps> = ({
onCancel={onRenameCancel}
/>
) : (
<div className="flex h-5 min-w-0 translate-y-px items-center truncate leading-none">{label}</div>
<div className="flex min-w-0 items-center truncate leading-none">{label}</div>
)}
{detail && <div className="truncate text-xs text-muted-foreground">{detail}</div>}
</div>
+5 -2
View File
@@ -207,10 +207,10 @@ export function VaultViewLayout({ ctx }: { ctx: VaultViewLayoutContext }) {
}}
>
<FileCode size={16} className="flex-shrink-0" />
{!sidebarCollapsed && t("vault.nav.snippets")}
{!sidebarCollapsed && t("vault.nav.scripts")}
</RippleButton>
</TooltipTrigger>
{sidebarCollapsed && <TooltipContent side="right">{t("vault.nav.snippets")}</TooltipContent>}
{sidebarCollapsed && <TooltipContent side="right">{t("vault.nav.scripts")}</TooltipContent>}
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
@@ -604,6 +604,7 @@ export function VaultViewLayout({ ctx }: { ctx: VaultViewLayoutContext }) {
proxyProfiles={proxyProfiles}
managedSources={managedSources}
onSaveHost={(host) => onUpdateHosts([...hosts, host])}
onUpdateHosts={onUpdateHosts}
onCreateGroup={(groupPath) =>
onUpdateCustomGroups(
Array.from(new Set([...customGroups, groupPath])),
@@ -785,6 +786,8 @@ export function VaultViewLayout({ ctx }: { ctx: VaultViewLayoutContext }) {
terminalFontSize={terminalFontSize}
groupDefaults={editingHostGroupDefaults}
groupConfigs={groupConfigs}
snippets={snippets}
onSnippetsChange={onUpdateSnippets}
onImportKey={onImportOrReuseKey}
onSave={(host) => {
const latestHost = hosts.find((entry: { id: string }) => entry.id === host.id);
+11 -2
View File
@@ -1,5 +1,6 @@
import { Host, TerminalSettings } from './models';
import { Host, Snippet, TerminalSettings } from './models';
import { sanitizeHostIconFields } from './hostIcon';
import { migrateHostConnectScriptIds } from './hostConnectScripts.ts';
import { migrateDeprecatedFontOverride } from '../infrastructure/config/fonts';
export type HostLabelRenameResult =
@@ -348,7 +349,7 @@ export const resolveHostKeepalive = (
};
};
export const sanitizeHost = (host: Host): Host => {
export const sanitizeHost = (host: Host, snippets: Snippet[] = []): Host => {
const cleanHostname = (host.hostname || '').trim().split(/\s+/)[0];
const cleanDistro = normalizeDistroId(host.distro);
const cleanManualDistro = normalizeDistroId(host.manualDistro);
@@ -361,6 +362,13 @@ export const sanitizeHost = (host: Host): Host => {
const cleanHostIcon = sanitizeHostIconFields(host);
const migrated = migrateDeprecatedFontOverride(host);
const cleanNotes = host.notes?.trim() || undefined;
const connectScriptIds = host.connectScriptIds ?? (
snippets.length > 0
? migrateHostConnectScriptIds(host, snippets)
: host.loginScriptId
? [host.loginScriptId]
: undefined
);
return {
...migrated,
hostname: cleanHostname,
@@ -374,5 +382,6 @@ export const sanitizeHost = (host: Host): Host => {
iconColorCustom: undefined,
...cleanHostIcon,
notes: cleanNotes,
connectScriptIds: connectScriptIds && connectScriptIds.length > 0 ? connectScriptIds : undefined,
};
};
+118
View File
@@ -0,0 +1,118 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import type { Host, Snippet } from './models';
import {
appendHostConnectScript,
getGlobalConnectScripts,
getHostConnectScriptIds,
migrateHostConnectScriptIds,
moveHostConnectScript,
reorderHostConnectScript,
removeHostConnectScript,
resolveConnectScriptsForHost,
syncHostsForSnippetTargetChange,
} from './hostConnectScripts.ts';
const host: Host = {
id: 'host-a',
label: 'A',
hostname: 'a.example',
username: 'root',
os: 'linux',
protocol: 'ssh',
tags: [],
};
const script = (overrides: Partial<Snippet>): Snippet => ({
id: 's-default',
label: 'default',
command: 'nct.log("x");',
kind: 'script',
trigger: 'onConnect',
...overrides,
});
test('migrateHostConnectScriptIds prefers loginScriptId then linked onConnect scripts', () => {
const snippets = [
script({ id: 'login', targets: ['host-a'], order: 1000 }),
script({ id: 'linked', targets: ['host-a'], order: 2000 }),
script({ id: 'other', targets: ['host-a'], order: 3000 }),
];
const migrated = migrateHostConnectScriptIds({ ...host, loginScriptId: 'login' }, snippets);
assert.deepEqual(migrated, ['login', 'linked', 'other']);
});
test('resolveConnectScriptsForHost runs globals before host queue and dedupes', () => {
const snippets = [
script({ id: 'global', targetsAllHosts: true, order: 1000, label: 'Global' }),
script({ id: 'host-only', targets: ['host-a'], order: 2000, label: 'Host' }),
script({ id: 'both', targetsAllHosts: true, targets: ['host-a'], order: 3000, label: 'Both' }),
];
const resolved = resolveConnectScriptsForHost(
{ ...host, connectScriptIds: ['both', 'host-only'] },
snippets,
);
assert.deepEqual(resolved.map((item) => item.id), ['global', 'both', 'host-only']);
});
test('append and move update host connectScriptIds order', () => {
const snippets = [
script({ id: 'a', targets: ['host-a'] }),
script({ id: 'b', targets: ['host-a'] }),
];
let next = appendHostConnectScript(host, 'a', snippets);
next = appendHostConnectScript(next, 'b', snippets);
assert.deepEqual(getHostConnectScriptIds(next, snippets), ['a', 'b']);
next = moveHostConnectScript(next, 'b', 'up', snippets);
assert.deepEqual(getHostConnectScriptIds(next, snippets), ['b', 'a']);
});
test('syncHostsForSnippetTargetChange appends and removes queue entries', () => {
const snippets = [script({ id: 'run', targets: ['host-a'], trigger: 'onConnect' })];
const hosts = syncHostsForSnippetTargetChange(
[host],
script({ id: 'run', targets: ['host-a'], trigger: 'onConnect' }),
[],
snippets,
);
assert.deepEqual(hosts[0].connectScriptIds, ['run']);
const removed = syncHostsForSnippetTargetChange(
hosts,
script({ id: 'run', targets: [], trigger: 'onConnect' }),
['host-a'],
snippets,
);
assert.deepEqual(removed[0].connectScriptIds, []);
});
test('getGlobalConnectScripts sorts by order', () => {
const snippets = [
script({ id: 'z', targetsAllHosts: true, order: 2000, label: 'Z' }),
script({ id: 'a', targetsAllHosts: true, order: 1000, label: 'A' }),
];
assert.deepEqual(getGlobalConnectScripts(snippets).map((item) => item.id), ['a', 'z']);
});
test('reorderHostConnectScript moves item before or after target', () => {
const snippets = [
script({ id: 'a', targets: ['host-a'] }),
script({ id: 'b', targets: ['host-a'] }),
script({ id: 'c', targets: ['host-a'] }),
];
const base = { ...host, connectScriptIds: ['a', 'b', 'c'] };
const movedAfter = reorderHostConnectScript(base, 'a', 'c', 'after', snippets);
assert.deepEqual(getHostConnectScriptIds(movedAfter, snippets), ['b', 'c', 'a']);
const movedBefore = reorderHostConnectScript(base, 'c', 'a', 'before', snippets);
assert.deepEqual(getHostConnectScriptIds(movedBefore, snippets), ['c', 'a', 'b']);
});
test('removeHostConnectScript clears empty queue', () => {
const snippets = [script({ id: 'only', targets: ['host-a'] })];
const updated = removeHostConnectScript(
{ ...host, connectScriptIds: ['only'] },
'only',
snippets,
);
assert.deepEqual(updated.connectScriptIds, []);
});
+218
View File
@@ -0,0 +1,218 @@
import type { Host, Snippet } from './models';
import { isScriptSnippet } from './snippetScript.ts';
import {
getScriptsLinkedToHost,
linkHostToScript,
snippetAppliesToHost,
} from './snippetTargets.ts';
import { sortByVaultOrder } from './vaultOrder.ts';
function isOnConnectScript(snippet: Snippet): boolean {
return isScriptSnippet(snippet) && snippet.trigger === 'onConnect' && Boolean(snippet.id);
}
function scriptById(snippets: Snippet[], scriptId: string): Snippet | undefined {
return snippets.find((snippet) => snippet.id === scriptId && isScriptSnippet(snippet));
}
function pruneConnectScriptIds(ids: string[], snippets: Snippet[]): string[] {
const seen = new Set<string>();
const result: string[] = [];
for (const id of ids) {
if (!id || seen.has(id)) continue;
const snippet = scriptById(snippets, id);
if (!snippet || !isOnConnectScript(snippet)) continue;
seen.add(id);
result.push(id);
}
return result;
}
/** Global onConnect scripts (targetsAllHosts), sorted by vault order. */
export function getGlobalConnectScripts(snippets: Snippet[]): Snippet[] {
return sortByVaultOrder(
snippets.filter(
(snippet) => isOnConnectScript(snippet) && Boolean(snippet.targetsAllHosts),
),
);
}
/** Derive initial connectScriptIds from legacy host + snippet bindings. */
export function migrateHostConnectScriptIds(host: Host, snippets: Snippet[]): string[] {
const ordered: string[] = [];
const seen = new Set<string>();
const push = (scriptId?: string) => {
if (!scriptId || seen.has(scriptId)) return;
const snippet = scriptById(snippets, scriptId);
if (!snippet || !isOnConnectScript(snippet)) return;
if (!snippetAppliesToHost(snippet, host.id) && !snippet.targetsAllHosts) return;
seen.add(scriptId);
ordered.push(scriptId);
};
push(host.loginScriptId);
for (const snippet of getScriptsLinkedToHost(snippets, host.id)) {
if (snippet.trigger === 'onConnect') {
push(snippet.id);
}
}
for (const snippet of sortByVaultOrder(snippets)) {
if (!isOnConnectScript(snippet)) continue;
if (snippet.targetsAllHosts) continue;
if (!snippetAppliesToHost(snippet, host.id)) continue;
push(snippet.id);
}
return ordered;
}
/** Effective ordered script IDs for a host (lazy migrate + prune). */
export function getHostConnectScriptIds(host: Host, snippets: Snippet[]): string[] {
if (host.connectScriptIds !== undefined) {
return pruneConnectScriptIds(host.connectScriptIds, snippets);
}
return migrateHostConnectScriptIds(host, snippets);
}
export function ensureHostConnectScriptIds(host: Host, snippets: Snippet[]): Host {
if (host.connectScriptIds !== undefined) {
const pruned = pruneConnectScriptIds(host.connectScriptIds, snippets);
if (pruned.length === host.connectScriptIds.length
&& pruned.every((id, index) => id === host.connectScriptIds![index])) {
return host;
}
return { ...host, connectScriptIds: pruned };
}
const migrated = migrateHostConnectScriptIds(host, snippets);
return migrated.length > 0 ? { ...host, connectScriptIds: migrated } : host;
}
/** True when host references connect scripts that are not present in snippets yet. */
export function hasUnresolvedConnectScriptBindings(host: Host, snippets: Snippet[]): boolean {
const candidateIds = new Set<string>();
if (host.loginScriptId) candidateIds.add(host.loginScriptId);
for (const id of host.connectScriptIds ?? []) {
if (id) candidateIds.add(id);
}
for (const id of candidateIds) {
if (!snippets.some((snippet) => snippet.id === id)) {
return true;
}
}
return false;
}
/** Resolve full onConnect run list: global scripts first, then host queue; dedupe favors host queue. */
export function resolveConnectScriptsForHost(host: Host, snippets: Snippet[]): Snippet[] {
const hostIds = getHostConnectScriptIds(host, snippets);
const hostIdSet = new Set(hostIds);
const globals = getGlobalConnectScripts(snippets).filter(
(snippet) => snippet.id && !hostIdSet.has(snippet.id),
);
const hostScripts = hostIds
.map((id) => scriptById(snippets, id))
.filter((snippet): snippet is Snippet => Boolean(snippet));
return [...globals, ...hostScripts];
}
export function appendHostConnectScript(host: Host, scriptId: string, snippets: Snippet[]): Host {
const snippet = scriptById(snippets, scriptId);
if (!snippet) return host;
const current = getHostConnectScriptIds(host, snippets);
if (current.includes(scriptId)) {
return { ...host, connectScriptIds: current };
}
return { ...host, connectScriptIds: [...current, scriptId] };
}
export function removeHostConnectScript(host: Host, scriptId: string, snippets: Snippet[]): Host {
const current = getHostConnectScriptIds(host, snippets);
const next = current.filter((id) => id !== scriptId);
return { ...host, connectScriptIds: next };
}
export function moveHostConnectScript(
host: Host,
scriptId: string,
direction: 'up' | 'down',
snippets: Snippet[],
): Host {
const current = [...getHostConnectScriptIds(host, snippets)];
const index = current.indexOf(scriptId);
if (index === -1) return host;
const targetIndex = direction === 'up' ? index - 1 : index + 1;
if (targetIndex < 0 || targetIndex >= current.length) return host;
[current[index], current[targetIndex]] = [current[targetIndex], current[index]];
return { ...host, connectScriptIds: current };
}
export function reorderHostConnectScript(
host: Host,
draggedScriptId: string,
targetScriptId: string,
position: 'before' | 'after',
snippets: Snippet[],
): Host {
if (draggedScriptId === targetScriptId) return host;
const current = [...getHostConnectScriptIds(host, snippets)];
const fromIndex = current.indexOf(draggedScriptId);
const targetIndex = current.indexOf(targetScriptId);
if (fromIndex === -1 || targetIndex === -1) return host;
current.splice(fromIndex, 1);
let insertIndex = current.indexOf(targetScriptId);
if (insertIndex === -1) return host;
if (position === 'after') insertIndex += 1;
current.splice(insertIndex, 0, draggedScriptId);
return { ...host, connectScriptIds: current };
}
export function prepareSnippetForHostConnectQueue(snippet: Snippet, hostId: string): Snippet {
if (!isScriptSnippet(snippet)) return snippet;
return {
...linkHostToScript(snippet, hostId),
trigger: 'onConnect',
};
}
export function syncHostsForSnippetTargetChange(
hosts: Host[],
snippet: Snippet,
prevTargetIds: string[] | undefined,
snippets: Snippet[],
): Host[] {
if (!isScriptSnippet(snippet) || snippet.trigger !== 'onConnect' || !snippet.id) {
return hosts;
}
if (snippet.targetsAllHosts) {
return hosts.map((host) => removeHostConnectScript(host, snippet.id!, snippets));
}
const prev = new Set(prevTargetIds ?? []);
const next = new Set(snippet.targets ?? []);
const added = [...next].filter((id) => !prev.has(id));
const removed = [...prev].filter((id) => !next.has(id));
if (added.length === 0 && removed.length === 0) return hosts;
return hosts.map((host) => {
let updated = host;
if (added.includes(host.id)) {
updated = appendHostConnectScript(updated, snippet.id!, snippets);
}
if (removed.includes(host.id)) {
updated = removeHostConnectScript(updated, snippet.id!, snippets);
}
return updated;
});
}
export function connectScriptsForHostFromSnippets(
hostId: string,
snippets: Snippet[],
): Snippet[] {
const host = { id: hostId } as Host;
return resolveConnectScriptsForHost(host, snippets);
}
+5
View File
@@ -0,0 +1,5 @@
import type { Host } from './models';
export function hostDisplayTitle(host: Pick<Host, 'label' | 'hostname'>): string {
return host.label?.trim() || host.hostname;
}
+27 -1
View File
@@ -148,6 +148,12 @@ export interface Host {
x11Forwarding?: boolean;
createdAt?: number; // Timestamp when host was created
startupCommand?: string;
/** Script id (kind=script) to run automatically after connect. */
loginScriptId?: string;
/** Ordered onConnect script IDs for this host (canonical run order). */
connectScriptIds?: string[];
/** Output regex triggers that launch scripts on terminal output. */
outputTriggers?: HostOutputTrigger[];
hostChaining?: string; // Deprecated: use hostChain instead
proxy?: string; // Deprecated: use proxyConfig instead
proxyProfileId?: string; // Reference to reusable proxy profile
@@ -274,16 +280,35 @@ export interface Identity {
order?: number;
}
export type SnippetKind = 'snippet' | 'script';
export type ScriptLanguage = 'javascript' | 'python';
export type ScriptTrigger = 'manual' | 'onConnect' | 'onOutput';
export interface Snippet {
id: string;
label: string;
command: string; // Multi-line script
command: string; // Multi-line script or automation script source
tags?: string[];
package?: string; // package path
targets?: string[]; // host ids
/** When true, script/snippet applies to every connectable host (no per-host picker). */
targetsAllHosts?: boolean;
shortkey?: string; // Keyboard shortcut to send this snippet in terminal (e.g., "F1", "Ctrl + F1")
noAutoRun?: boolean; // If true, paste command without executing (no trailing Enter)
order?: number;
/** Default 'snippet' — static text paste. 'script' runs via nct automation engine. */
kind?: SnippetKind;
language?: ScriptLanguage;
description?: string;
trigger?: ScriptTrigger;
/** Regex pattern when trigger is 'onOutput'. */
triggerPattern?: string;
}
export interface HostOutputTrigger {
id: string;
pattern: string;
scriptId: string;
}
export interface VaultNote {
@@ -331,6 +356,7 @@ export interface GroupConfig {
proxyConfig?: ProxyConfig;
hostChain?: HostChainConfig;
startupCommand?: string;
loginScriptId?: string;
legacyAlgorithms?: boolean;
skipEcdsaHostKey?: boolean;
algorithms?: HostAlgorithmOverrides;
+5 -1
View File
@@ -1,4 +1,4 @@
import type { SerialConfig } from './connection';
import type { SerialConfig, Snippet } from './connection';
import type { CodingCliProviderId } from '../codingCliProviders';
import {
normalizeHibernateHiddenTabsDelaySec,
@@ -417,6 +417,10 @@ export interface TerminalSession {
hostname: string;
status: 'connecting' | 'connected' | 'disconnected';
workspaceId?: string;
/** Script to auto-run after connect (multi-host script runner). */
pendingScriptId?: string;
/** Snapshot used by "Run now" so unsaved editor changes run exactly as shown. */
pendingScript?: Snippet;
startupCommand?: string; // Command to run after connection (for snippet runner)
noAutoRun?: boolean; // If true, paste command without auto-executing
// Connection-time protocol overrides (used instead of looking up from hosts)
+18
View File
@@ -0,0 +1,18 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { getSnippetKind, isScriptSnippet, scriptContainsWriteOperations } from './snippetScript.ts';
test('getSnippetKind defaults to snippet', () => {
assert.equal(getSnippetKind({}), 'snippet');
assert.equal(getSnippetKind({ kind: undefined }), 'snippet');
});
test('isScriptSnippet detects script kind', () => {
assert.equal(isScriptSnippet({ kind: 'script' }), true);
assert.equal(isScriptSnippet({ kind: 'snippet' }), false);
});
test('scriptContainsWriteOperations detects terminal writes', () => {
assert.equal(scriptContainsWriteOperations("await nct.screen.sendLine('ls');"), true);
assert.equal(scriptContainsWriteOperations("await nct.screen.waitFor('$ ');"), false);
});
+177
View File
@@ -0,0 +1,177 @@
import type { Snippet, SnippetKind } from './models';
export function getSnippetKind(snippet: Pick<Snippet, 'kind'>): SnippetKind {
return snippet.kind === 'script' ? 'script' : 'snippet';
}
export function isScriptSnippet(snippet: Pick<Snippet, 'kind'>): boolean {
return getSnippetKind(snippet) === 'script';
}
/** Common interactive shell prompts (root uses #, regular user uses $). */
export const DEFAULT_SHELL_PROMPT_PATTERNS = ['# ', '$ ', '~# ', '~$ ', '% '];
/** Default wait after each recorded command — matches waitForPrompt in generated scripts. */
export const DEFAULT_RECORDING_PROMPT_TIMEOUT_MS = 30000;
/** Regex matching a shell prompt on the last line (~# / ~$ / user@host:path#). */
export const SHELL_PROMPT_END_REGEX = /(?:~[#$]\s*|[@][^\n]{0,120}[:][^\n]{0,120}[#$%]\s*)$/m;
/** Minimal smoke script for verifying manual run and onOutput triggers. */
export const SCRIPT_SMOKE_TEST = `// === Smoke test ===
// Manual: trigger=manual, pick a target host, click "Run now"
// onOutput: trigger=onOutput, pattern=NETCATTY_SMOKE, save, connect to target host, then run: echo NETCATTY_SMOKE
//
await nct.screen.waitForPrompt(30000);
await nct.screen.sendLine('echo netcatty-smoke-ok');
nct.log('Smoke test passed');
await nct.dialog.alert('Netcatty script smoke test OK');
`;
/** Full integration test for onConnect / manual run; dialog API enabled by default. */
export const SCRIPT_INTEGRATION_TEST = `// Netcatty Integration Test — onConnect / manual full API exercise
// Trigger: onConnect or Run now | Permission: Auto or Confirm (dialogs need non-Observer)
const CONFIG = {
SAMPLE_COUNT: 8,
SAMPLE_INTERVAL_MS: 2000,
PROMPT_TIMEOUT_MS: 60000,
STEP_TIMEOUT_MS: 20000,
RUN_DIALOGS: true,
RUN_SESSION_LOG: false,
RUN_SCREEN_CLEAR: false,
};
async function main() {
const tag = \`nc-it-\${Date.now().toString(36)}\`;
nct.log('=== Netcatty Integration Test START ===');
nct.log(\`tag=\${tag} nct.version=\${nct.version}\`);
nct.log(\`session: host=\${nct.session.hostname} user=\${nct.session.username} connected=\${nct.session.connected}\`);
if (!nct.session.connected) {
throw new Error('Session not connected');
}
nct.log('[1/12] waitForPrompt');
await nct.screen.waitForPrompt(CONFIG.PROMPT_TIMEOUT_MS);
nct.log('[2/12] sendLine + waitFor literal');
await nct.screen.sendLine(\`echo "\${tag}_BOOTSTRAP_OK"\`);
await nct.screen.waitFor(\`\${tag}_BOOTSTRAP_OK\`, CONFIG.STEP_TIMEOUT_MS);
nct.log('[3/12] waitForAny');
await nct.screen.sendLine('echo ANY_CHECK && uname -s');
await nct.screen.waitForAny(['ANY_CHECK', /Linux/], CONFIG.STEP_TIMEOUT_MS);
nct.log('[4/12] getText / rows / cols / currentRow');
const text = await nct.screen.getText();
const lineCount = text.split('\\n').filter(Boolean).length;
nct.log(\`captured \${lineCount} lines, rows=\${nct.screen.rows} cols=\${nct.screen.cols} cursorRow=\${nct.screen.currentRow}\`);
nct.log('[5/12] send raw + Enter');
await nct.screen.send(\`echo -n "\${tag}_RAW"\`);
await nct.screen.sendLine('');
await nct.screen.waitFor(\`\${tag}_RAW\`, CONFIG.STEP_TIMEOUT_MS);
nct.log('[6/12] waitFor regex');
await nct.screen.sendLine(\`echo BUILD_ID=\${tag}\`);
await nct.screen.waitFor(new RegExp(\`BUILD_ID=\${tag}\`), CONFIG.STEP_TIMEOUT_MS);
nct.log('[7/12] session.sleep / nct.sleep');
await nct.session.sleep(800);
await nct.sleep(800);
nct.log('[8/12] progress loop health sampling');
nct.progress.start('Health sampling', CONFIG.SAMPLE_COUNT);
for (let i = 1; i <= CONFIG.SAMPLE_COUNT; i += 1) {
const cmd = [
\`echo "== Sample \${i}/\${CONFIG.SAMPLE_COUNT} =="\`,
'date',
'uptime',
"free -h | awk 'NR==2{print $1,$2,$3,$4,$7}'",
"df -h / | awk 'NR==2{print $1,$2,$3,$5,$6}'",
\`echo "\${tag}_SAMPLE_\${i}_DONE"\`,
].join(' && ');
await nct.screen.sendLine(cmd);
await nct.screen.waitFor(new RegExp(\`\${tag}_SAMPLE_\${i}_DONE\`), CONFIG.STEP_TIMEOUT_MS);
await nct.screen.waitForPrompt(CONFIG.PROMPT_TIMEOUT_MS);
nct.progress.step(\`sample \${i}/\${CONFIG.SAMPLE_COUNT}\`);
nct.log(\`sample \${i}/\${CONFIG.SAMPLE_COUNT} ok\`);
if (i < CONFIG.SAMPLE_COUNT) {
await nct.session.sleep(CONFIG.SAMPLE_INTERVAL_MS);
}
}
nct.progress.done();
nct.log('[9/12] nested loop activity mode');
const checks = ['whoami', 'id -u', 'pwd', 'echo $SHELL'];
for (const check of checks) {
await nct.screen.sendLine(check);
await nct.screen.waitForPrompt(CONFIG.PROMPT_TIMEOUT_MS);
}
if (CONFIG.RUN_SESSION_LOG) {
nct.log('[10/12] startLog / stopLog');
await nct.session.startLog(\`./netcatty-it-\${tag}.log\`);
await nct.screen.sendLine(\`echo "\${tag}_LOGGED"\`);
await nct.screen.waitFor(\`\${tag}_LOGGED\`, CONFIG.STEP_TIMEOUT_MS);
await nct.session.stopLog();
} else {
nct.log('[10/12] startLog/stopLog skipped');
}
if (CONFIG.RUN_SCREEN_CLEAR) {
nct.log('[11/12] screen.clear');
await nct.screen.clear();
await nct.session.sleep(500);
await nct.screen.waitForPrompt(CONFIG.PROMPT_TIMEOUT_MS);
} else {
nct.log('[11/12] screen.clear skipped');
}
if (CONFIG.RUN_DIALOGS) {
nct.log('[12/12] dialog confirm / prompt / alert');
const go = await nct.dialog.confirm(\`Integration test \${tag} finished OK. Continue to prompt/alert?\`);
nct.log(\`confirm => \${go}\`);
if (go) {
const note = await nct.dialog.prompt('Optional note:', 'all-good');
nct.log(\`prompt => \${note}\`);
await nct.dialog.alert(\`Done. note=\${note}\`);
} else {
nct.log('confirm declined — skipping prompt/alert');
}
} else {
nct.log('[12/12] dialog skipped');
}
await nct.screen.sendLine(\`echo "=== \${tag} ALL_PASSED ==="\`);
await nct.screen.waitFor(\`\${tag} ALL_PASSED\`, CONFIG.STEP_TIMEOUT_MS);
nct.log('=== Netcatty Integration Test PASSED ===');
}
await main();
`;
export const DEFAULT_SCRIPT_TEMPLATE = `// Netcatty automation script - async JS in the active terminal session
//
// nct.screen.waitForPrompt(ms?) wait for shell prompt (# root / $ user)
// nct.screen.waitFor(pattern, ms?) wait for output (string or /regex/, default 30s)
// nct.screen.waitForAny([patterns], ms?) wait until any pattern matches
// nct.screen.sendLine(cmd) type command + Enter; send(text) raw keys only
// nct.screen.getText(start?, end?) | clear()
// nct.session.sleep(ms) | startLog(path) | stopLog() | disconnect()
// nct.dialog.confirm(msg)->bool | prompt(msg, def?)->string | alert(msg)
// nct.progress.start(label, total) opt-in determinate progress for loops
// nct.progress.step(detail?) | set(n, detail?) | done()
// nct.log(msg) run log panel. Type "nct." in editor for autocomplete snippets.
//
await nct.screen.waitForPrompt(30000);
await nct.screen.sendLine('echo hello');
nct.log('Done');
`;
const SCRIPT_WRITE_PATTERN = /nct\.screen\.(send(?:Line)?|clear)\s*\(|nct\.session\.(disconnect|startLog)\s*\(/;
export function scriptContainsWriteOperations(content: string): boolean {
return SCRIPT_WRITE_PATTERN.test(String(content || ''));
}
+91
View File
@@ -0,0 +1,91 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
getRunnableHostsForSnippet,
snippetAppliesToHost,
snippetAppliesToOutputTrigger,
snippetHasRunTargets,
} from './snippetTargets.ts';
import type { Host, Snippet } from './models';
const baseSnippet: Snippet = {
id: 's1',
label: 'test',
command: 'echo hi',
package: '',
};
const hosts: Host[] = [
{
id: 'host-a',
label: 'A',
hostname: 'a.example',
username: 'root',
os: 'linux',
protocol: 'ssh',
},
{
id: 'host-b',
label: 'B',
hostname: 'b.example',
username: 'root',
os: 'linux',
protocol: 'serial',
},
];
test('snippetAppliesToHost returns false when targets are empty', () => {
assert.equal(snippetAppliesToHost(baseSnippet, 'host-a'), false);
assert.equal(snippetAppliesToHost({ ...baseSnippet, targets: [] }, 'host-a'), false);
});
test('snippetAppliesToHost matches only listed hosts', () => {
const snippet = { ...baseSnippet, targets: ['host-a', 'host-b'] };
assert.equal(snippetAppliesToHost(snippet, 'host-a'), true);
assert.equal(snippetAppliesToHost(snippet, 'host-c'), false);
assert.equal(snippetAppliesToHost(snippet, undefined), false);
});
test('snippetAppliesToHost matches all hosts when targetsAllHosts is set', () => {
const snippet = { ...baseSnippet, targetsAllHosts: true };
assert.equal(snippetAppliesToHost(snippet, 'host-a'), true);
assert.equal(snippetAppliesToHost(snippet, 'host-c'), true);
assert.equal(snippetAppliesToHost(snippet, undefined), false);
});
test('snippetHasRunTargets requires explicit scope', () => {
assert.equal(snippetHasRunTargets(baseSnippet), false);
assert.equal(snippetHasRunTargets({ ...baseSnippet, targets: ['host-a'] }), true);
assert.equal(snippetHasRunTargets({ ...baseSnippet, targetsAllHosts: true }), true);
});
test('snippetAppliesToOutputTrigger applies to current session when targets are unset', () => {
const snippet = { ...baseSnippet, trigger: 'onConnect' as const };
assert.equal(snippetAppliesToOutputTrigger(snippet, 'host-a'), false);
const output = { ...baseSnippet, trigger: 'onOutput' as const };
assert.equal(snippetAppliesToOutputTrigger(output, 'host-a'), true);
assert.equal(snippetAppliesToOutputTrigger(output, undefined), false);
});
test('snippetAppliesToOutputTrigger respects explicit host targets', () => {
const output = {
...baseSnippet,
trigger: 'onOutput' as const,
targets: ['host-a'],
};
assert.equal(snippetAppliesToOutputTrigger(output, 'host-a'), true);
assert.equal(snippetAppliesToOutputTrigger(output, 'host-b'), false);
});
test('getRunnableHostsForSnippet excludes serial hosts and respects scope', () => {
assert.deepEqual(
getRunnableHostsForSnippet({ ...baseSnippet, targets: ['host-a', 'host-b'] }, hosts),
[hosts[0]],
);
assert.deepEqual(
getRunnableHostsForSnippet({ ...baseSnippet, targetsAllHosts: true }, hosts),
[hosts[0]],
);
assert.deepEqual(getRunnableHostsForSnippet(baseSnippet, hosts), []);
});
+115
View File
@@ -0,0 +1,115 @@
import type { Host, Snippet } from './models';
import { isScriptSnippet } from './snippetScript.ts';
/** Whether the snippet has a runnable scope (explicit targets or all-hosts flag). */
export function snippetHasRunTargets(snippet: Pick<Snippet, 'targets' | 'targetsAllHosts'>): boolean {
if (snippet.targetsAllHosts) return true;
return Boolean(snippet.targets && snippet.targets.length > 0);
}
/** Connectable hosts for manual run / save-and-run. */
export function getRunnableHostsForSnippet(
snippet: Pick<Snippet, 'targets' | 'targetsAllHosts'>,
hosts: Host[],
): Host[] {
const connectable = hosts.filter((host) => host.protocol !== 'serial');
if (snippet.targetsAllHosts) return connectable;
const idSet = new Set(snippet.targets ?? []);
return connectable.filter((host) => idSet.has(host.id));
}
/** Whether a snippet/script applies to the given host based on targets or all-hosts flag. */
export function snippetAppliesToHost(snippet: Snippet, hostId?: string): boolean {
if (hostId === undefined) return false;
if (snippet.targetsAllHosts) return true;
if (!snippet.targets || snippet.targets.length === 0) return false;
return snippet.targets.includes(hostId);
}
/**
* onOutput triggers listen on the active terminal session. When no explicit targets
* are configured, they apply to whichever host the session is connected to.
*/
export function snippetAppliesToOutputTrigger(
snippet: Pick<Snippet, 'trigger' | 'targets' | 'targetsAllHosts'>,
hostId?: string,
): boolean {
if (snippet.trigger !== 'onOutput') return false;
if (hostId === undefined) return false;
if (snippet.targetsAllHosts) return true;
if (snippet.targets && snippet.targets.length > 0) {
return snippet.targets.includes(hostId);
}
return true;
}
/** Scripts explicitly linked to a host via targets (excludes all-hosts scripts). */
export function getScriptsLinkedToHost(snippets: Snippet[], hostId: string): Snippet[] {
return snippets.filter(
(snippet) => isScriptSnippet(snippet)
&& !snippet.targetsAllHosts
&& Boolean(snippet.targets?.includes(hostId)),
);
}
export function getLoginScriptForHost(snippets: Snippet[], hostId: string): Snippet | undefined {
return snippets.find(
(snippet) => isScriptSnippet(snippet)
&& snippet.trigger === 'onConnect'
&& snippetAppliesToHost(snippet, hostId),
);
}
export function linkHostToScript(snippet: Snippet, hostId: string): Snippet {
const targets = snippet.targets ?? [];
if (targets.includes(hostId)) {
return { ...snippet, targetsAllHosts: undefined };
}
return {
...snippet,
targets: [...targets, hostId],
targetsAllHosts: undefined,
};
}
export function unlinkHostFromScript(snippet: Snippet, hostId: string): Snippet {
const targets = snippet.targets ?? [];
if (!targets.includes(hostId)) return snippet;
const nextTargets = targets.filter((id) => id !== hostId);
return { ...snippet, targets: nextTargets.length > 0 ? nextTargets : undefined };
}
export function linkHostToScripts(snippets: Snippet[], hostId: string, scriptId: string): Snippet[] {
return snippets.map((snippet) => (
snippet.id === scriptId ? linkHostToScript(snippet, hostId) : snippet
));
}
export function unlinkHostFromScripts(snippets: Snippet[], hostId: string, scriptId: string): Snippet[] {
return snippets.map((snippet) => (
snippet.id === scriptId ? unlinkHostFromScript(snippet, hostId) : snippet
));
}
/** @deprecated Prefer host.connectScriptIds queue via HostDetails automation panel. */
export function setLoginScriptForHost(
snippets: Snippet[],
hostId: string,
scriptId?: string,
): Snippet[] {
const cleared = snippets.map((snippet) => {
if (!isScriptSnippet(snippet) || snippet.trigger !== 'onConnect') return snippet;
if (!snippet.targets?.includes(hostId)) return snippet;
return unlinkHostFromScript(snippet, hostId);
});
if (!scriptId) return cleared;
return cleared.map((snippet) => {
if (!isScriptSnippet(snippet) || snippet.id !== scriptId) return snippet;
return {
...linkHostToScript(snippet, hostId),
trigger: 'onConnect' as const,
};
});
}
-2
View File
@@ -39,8 +39,6 @@ test("buildSnippetExportPayload removes host target bindings", () => {
command: "sudo systemctl restart nginx",
tags: [],
package: "ops/web",
noAutoRun: undefined,
shortkey: undefined,
},
]);
});
+41 -8
View File
@@ -2,7 +2,8 @@ import type { Snippet } from "./models";
import { normalizeVaultOrder } from "./vaultOrder";
export const SNIPPET_EXPORT_KIND = "netcatty.snippets" as const;
export const SNIPPET_EXPORT_VERSION = 1 as const;
export const SNIPPET_EXPORT_VERSION = 2 as const;
export const SNIPPET_EXPORT_VERSION_LEGACY = 1 as const;
export type SnippetImportConflictAction = "skip" | "overwrite";
@@ -13,11 +14,16 @@ export type SnippetExportItem = {
package?: string;
shortkey?: string;
noAutoRun?: boolean;
kind?: "snippet" | "script";
language?: "javascript" | "python";
description?: string;
trigger?: "manual" | "onConnect" | "onOutput";
triggerPattern?: string;
};
export type SnippetExportPayload = {
kind: typeof SNIPPET_EXPORT_KIND;
version: typeof SNIPPET_EXPORT_VERSION;
version: typeof SNIPPET_EXPORT_VERSION | typeof SNIPPET_EXPORT_VERSION_LEGACY;
exportedAt: string;
snippetPackages: string[];
snippets: SnippetExportItem[];
@@ -94,8 +100,13 @@ const toExportItem = (snippet: Snippet): SnippetExportItem => ({
command: snippet.command,
tags: Array.isArray(snippet.tags) ? [...snippet.tags] : [],
package: snippet.package || "",
shortkey: snippet.shortkey,
noAutoRun: snippet.noAutoRun,
...(snippet.shortkey ? { shortkey: snippet.shortkey } : {}),
...(snippet.noAutoRun ? { noAutoRun: snippet.noAutoRun } : {}),
...(snippet.kind ? { kind: snippet.kind } : {}),
...(snippet.language ? { language: snippet.language } : {}),
...(snippet.description ? { description: snippet.description } : {}),
...(snippet.trigger ? { trigger: snippet.trigger } : {}),
...(snippet.triggerPattern ? { triggerPattern: snippet.triggerPattern } : {}),
});
export const buildSnippetExportPayload = ({
@@ -139,11 +150,28 @@ const sanitizeImportItem = (value: unknown): SnippetExportItem | null => {
? value.shortkey.trim()
: undefined,
noAutoRun: value.noAutoRun === true ? true : undefined,
kind: value.kind === "script" ? "script" : undefined,
language: value.language === "javascript" || value.language === "python"
? value.language
: undefined,
description: typeof value.description === "string" && value.description.trim()
? value.description.trim()
: undefined,
trigger: value.trigger === "onConnect" || value.trigger === "onOutput"
? value.trigger
: undefined,
triggerPattern: typeof value.triggerPattern === "string" && value.triggerPattern.trim()
? value.triggerPattern.trim()
: undefined,
};
};
const parseSnippetImportObject = (parsed: Record<string, unknown>): SnippetExportPayload => {
if (parsed.kind !== SNIPPET_EXPORT_KIND || parsed.version !== SNIPPET_EXPORT_VERSION) {
const version = parsed.version;
if (parsed.kind !== SNIPPET_EXPORT_KIND) {
throw new Error("Unsupported snippet import file.");
}
if (version !== SNIPPET_EXPORT_VERSION && version !== SNIPPET_EXPORT_VERSION_LEGACY) {
throw new Error("Unsupported snippet import file.");
}
if (!Array.isArray(parsed.snippets)) {
@@ -156,7 +184,7 @@ const parseSnippetImportObject = (parsed: Record<string, unknown>): SnippetExpor
return {
kind: SNIPPET_EXPORT_KIND,
version: SNIPPET_EXPORT_VERSION,
version: version === SNIPPET_EXPORT_VERSION ? SNIPPET_EXPORT_VERSION : SNIPPET_EXPORT_VERSION_LEGACY,
exportedAt: typeof parsed.exportedAt === "string" ? parsed.exportedAt : "",
snippetPackages: collectSnippetPackagePaths(
snippets,
@@ -214,8 +242,13 @@ const toImportedSnippet = (item: SnippetExportItem, id: string, order?: number):
tags: item.tags || [],
package: item.package || "",
targets: [],
shortkey: item.shortkey,
noAutoRun: item.noAutoRun,
...(item.shortkey ? { shortkey: item.shortkey } : {}),
...(item.noAutoRun ? { noAutoRun: item.noAutoRun } : {}),
...(item.kind ? { kind: item.kind } : {}),
...(item.language ? { language: item.language } : {}),
...(item.description ? { description: item.description } : {}),
...(item.trigger ? { trigger: item.trigger } : {}),
...(item.triggerPattern ? { triggerPattern: item.triggerPattern } : {}),
order,
});
+121
View File
@@ -0,0 +1,121 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { createTerminalOutputTriggerFilter } from './terminalOutputTriggerFilter.ts';
test('processServerChunk strips echoed user input before scanning', () => {
const filter = createTerminalOutputTriggerFilter();
filter.noteUserInput('hello');
const result = filter.processServerChunk('hello world');
assert.equal(result.scannableText, '');
assert.equal(result.meta.dropReason, 'editing-input-line');
});
test('processServerChunk strips echoed input across chunks after submit', () => {
const filter = createTerminalOutputTriggerFilter();
filter.noteUserInput('abc');
filter.noteUserInput('\r');
filter.processServerChunk('abc\r\n');
const result = filter.processServerChunk(' done');
assert.equal(result.scannableText, ' done');
});
test('processServerChunk clears stale echo when server output diverges after submit', () => {
const filter = createTerminalOutputTriggerFilter();
filter.noteUserInput('secret');
filter.noteUserInput('\r');
const result = filter.processServerChunk('Password:');
assert.equal(result.scannableText, 'Password:');
});
test('processServerChunk suppresses text while alternate screen is active', () => {
const filter = createTerminalOutputTriggerFilter();
filter.processServerChunk('\x1b[?1049h');
assert.equal(filter.processServerChunk('vim buffer').scannableText, '');
assert.equal(filter.processServerChunk('vim buffer').alternateScreenActive, true);
filter.processServerChunk('\x1b[?1049l');
assert.equal(filter.processServerChunk('back to shell').scannableText, 'back to shell');
assert.equal(filter.processServerChunk('back to shell').alternateScreenActive, false);
});
test('processServerChunk keeps command stdout after enter clears pending echo', () => {
const filter = createTerminalOutputTriggerFilter();
filter.noteUserInput('echo NETCATTY_TRIGGER_PROBE');
filter.noteUserInput('\r');
const result = filter.processServerChunk('NETCATTY_TRIGGER_PROBE\r\n');
assert.equal(result.scannableText, 'NETCATTY_TRIGGER_PROBE\r\n');
});
test('processServerChunk strips delayed submitted command echo but keeps stdout', () => {
const filter = createTerminalOutputTriggerFilter();
filter.noteUserInput('echo NETCATTY_TRIGGER_PROBE');
filter.noteUserInput('\r');
const result = filter.processServerChunk('echo NETCATTY_TRIGGER_PROBE\r\nNETCATTY_TRIGGER_PROBE\r\n');
assert.equal(result.scannableText, 'NETCATTY_TRIGGER_PROBE\r\n');
});
test('processServerChunk strips delayed bare submitted command echo', () => {
const filter = createTerminalOutputTriggerFilter();
filter.noteUserInput('NETCATTY_TRIGGER_PROBE');
filter.noteUserInput('\r');
const result = filter.processServerChunk('NETCATTY_TRIGGER_PROBE\r\n');
assert.equal(result.scannableText, '');
});
test('processServerChunk keeps command-not-found stderr after enter', () => {
const filter = createTerminalOutputTriggerFilter();
filter.noteUserInput('netcatty-trigger-probe');
filter.noteUserInput('\r');
const result = filter.processServerChunk('netcatty-trigger-probe: command not found\r\n');
assert.equal(result.scannableText, 'netcatty-trigger-probe: command not found\r\n');
});
test('noteUserInput tracks remapped backspace bytes sent to the session', () => {
const filter = createTerminalOutputTriggerFilter();
filter.noteUserInput('\x08');
const result = filter.processServerChunk('\x08 \x08');
assert.equal(result.scannableText, ' \x08');
});
test('processServerChunk strips OSC cwd sequences without blocking later stdout', () => {
const filter = createTerminalOutputTriggerFilter();
filter.noteUserInput('echo NETCATTY_TRIGGER_PROBE');
filter.noteUserInput('\r');
filter.processServerChunk('\x1b]7;file://VM-4-16-ubuntu/root\x07\x1b[?2004h');
const result = filter.processServerChunk('\r\n\x1b[?2004l\rNETCATTY_TRIGGER_PROBE\r\n');
assert.match(result.scannableText, /NETCATTY_TRIGGER_PROBE/);
assert.notEqual(result.meta.dropReason, 'pending-escape');
});
test('processServerChunk strips split OSC sequences across chunks', () => {
const filter = createTerminalOutputTriggerFilter();
assert.equal(filter.processServerChunk('\x1b]7;file://host/root').scannableText, '');
const result = filter.processServerChunk('/root\x07prompt# ');
assert.equal(result.scannableText, 'prompt# ');
});
test('processServerChunk suppresses scan while command line is being edited', () => {
const filter = createTerminalOutputTriggerFilter();
filter.noteUserInput('echo NETCATTY_TRIGGER_PROBE');
const result = filter.processServerChunk('\x1b[7mNETCATTY_TRIGGER_PROBE\x1b[27m');
assert.equal(result.scannableText, '');
assert.equal(result.meta.dropReason, 'editing-input-line');
});
test('processServerChunk strips reverse-video echo without leaking SGR params', () => {
const filter = createTerminalOutputTriggerFilter();
filter.noteUserInput('NETCATTY_TRIGGER_PROBE');
const result = filter.processServerChunk('\x1b[7mNETCATTY_TRIGGER_PROBE\x1b[27m');
assert.equal(result.scannableText, '');
assert.equal(result.meta.dropReason, 'editing-input-line');
});
test('processServerChunk ignores bracketed paste wrappers when matching echo', () => {
const filter = createTerminalOutputTriggerFilter();
filter.noteUserInput('\x1b[200~echo NETCATTY_TRIGGER_PROBE\x1b[201~');
filter.noteUserInput('\r');
filter.processServerChunk('echo NETCATTY_TRIGGER_PROBE\r\n');
const result = filter.processServerChunk('NETCATTY_TRIGGER_PROBE\r\n');
assert.equal(result.scannableText, 'NETCATTY_TRIGGER_PROBE\r\n');
});
+375
View File
@@ -0,0 +1,375 @@
const MAX_PENDING_ECHO = 4096;
const ALTERNATE_SCREEN_MODES = new Set([47, 1047, 1049]);
export type TerminalOutputChunkMeta = {
rawLen: number;
dropReason?:
| 'empty-chunk'
| 'awaiting-submitted-echo'
| 'alternate-screen-active'
| 'pending-escape'
| 'user-echo-only'
| 'control-only'
| 'editing-input-line';
submittedEchoLine?: string;
submittedEchoCandidateLen: number;
afterSubmittedLen: number;
afterUserEchoLen: number;
scannableLen: number;
};
export type TerminalOutputTriggerFilter = {
noteUserInput: (data: string) => void;
processServerChunk: (chunk: string) => {
scannableText: string;
alternateScreenActive: boolean;
meta: TerminalOutputChunkMeta;
};
reset: () => void;
};
function getAlternateScreenAction(sequence: string): 'enter' | 'leave' | null {
if (!sequence.startsWith('\x1b[') || sequence.length < 3) return null;
const final = sequence.at(-1);
if (final !== 'h' && final !== 'l') return null;
const params = sequence.slice(2, -1);
if (!params.startsWith('?')) return null;
const modes = params
.slice(1)
.split(';')
.map((part) => Number.parseInt(part, 10))
.filter(Number.isFinite);
if (!modes.some((mode) => ALTERNATE_SCREEN_MODES.has(mode))) {
return null;
}
return final === 'h' ? 'enter' : 'leave';
}
function readCsiSequence(input: string, startIndex: number): { sequence: string; end: number } | null {
if (input[startIndex] !== '\x1b' || input[startIndex + 1] !== '[') return null;
for (let index = startIndex + 2; index < input.length; index += 1) {
const code = input.charCodeAt(index);
if (code >= 0x40 && code <= 0x7e) {
return {
sequence: input.slice(startIndex, index + 1),
end: index + 1,
};
}
}
return null;
}
/** OSC: ESC ] payload BEL (0x07) or ST (ESC \\). */
function readOscSequence(input: string, startIndex: number): { sequence: string; end: number } | null {
if (input[startIndex] !== '\x1b' || input[startIndex + 1] !== ']') return null;
for (let index = startIndex + 2; index < input.length; index += 1) {
if (input[index] === '\x07') {
return {
sequence: input.slice(startIndex, index + 1),
end: index + 1,
};
}
if (input[index] === '\x1b' && input[index + 1] === '\\') {
return {
sequence: input.slice(startIndex, index + 2),
end: index + 2,
};
}
}
return null;
}
function readEscapeSequence(input: string, startIndex: number): { sequence: string; end: number } | null {
if (input[startIndex] !== '\x1b') return null;
if (startIndex + 1 >= input.length) return null;
const next = input[startIndex + 1];
if (next === '[') return readCsiSequence(input, startIndex);
if (next === ']') return readOscSequence(input, startIndex);
const nextCode = next.charCodeAt(0);
if (nextCode >= 0x40 && nextCode <= 0x7e) {
return {
sequence: input.slice(startIndex, startIndex + 2),
end: startIndex + 2,
};
}
return null;
}
function stripUserEcho(pendingEcho: string, chunk: string): { pendingEcho: string; filtered: string } {
if (!pendingEcho || !chunk) {
return { pendingEcho, filtered: chunk };
}
let echoIndex = 0;
let chunkIndex = 0;
let matchedPrintable = 0;
while (echoIndex < pendingEcho.length && chunkIndex < chunk.length) {
if (chunk[chunkIndex] === '\x1b') {
const sequence = readEscapeSequence(chunk, chunkIndex);
if (!sequence) break;
chunkIndex = sequence.end;
continue;
}
if (pendingEcho[echoIndex] === chunk[chunkIndex]) {
echoIndex += 1;
chunkIndex += 1;
matchedPrintable += 1;
continue;
}
break;
}
if (matchedPrintable > 0) {
return {
pendingEcho: pendingEcho.slice(echoIndex),
filtered: chunk.slice(chunkIndex),
};
}
return {
pendingEcho: '',
filtered: chunk,
};
}
function isPrintableInput(char: string): boolean {
const code = char.codePointAt(0);
return code !== undefined && code >= 0x20 && code !== 0x7f;
}
function applyInputToLine(line: string, data: string): { line: string; submittedLine: string | null } {
let nextLine = line;
let submittedLine: string | null = null;
for (const char of stripInputControlWrappers(data)) {
if (char === '\r' || char === '\n') {
submittedLine = nextLine;
nextLine = '';
continue;
}
if (char === '\x7f' || char === '\b') {
nextLine = nextLine.slice(0, -1);
continue;
}
if (isPrintableInput(char)) {
nextLine += char;
}
}
return { line: nextLine, submittedLine };
}
const BRACKETED_PASTE_START = '\x1b[200~';
const BRACKETED_PASTE_END = '\x1b[201~';
function stripInputControlWrappers(data: string): string {
return data.split(BRACKETED_PASTE_START).join('').split(BRACKETED_PASTE_END).join('');
}
function appendPendingEcho(pendingEcho: string, data: string): string {
let next = pendingEcho;
let index = 0;
const sanitized = stripInputControlWrappers(data);
while (index < sanitized.length) {
const char = sanitized[index];
if (char === '\r' || char === '\n') {
index += 1;
continue;
}
if (char === '\x1b') {
const sequence = readEscapeSequence(sanitized, index);
if (sequence) {
index = sequence.end;
continue;
}
index += 1;
continue;
}
if (char === '\x7f') {
next = next.slice(0, -1);
index += 1;
continue;
}
if (char === '\x08') {
next += char;
index += 1;
continue;
}
if (isPrintableInput(char)) {
next += char;
index += 1;
continue;
}
index += 1;
}
return next.slice(-MAX_PENDING_ECHO);
}
function stripSubmittedLineEcho(
submittedLine: string | null,
candidate: string,
chunk: string,
): { submittedLine: string | null; candidate: string; filtered: string } {
if (submittedLine === null || submittedLine.length === 0) {
return { submittedLine: null, candidate: '', filtered: chunk };
}
const nextCandidate = candidate + chunk;
const echoVariants = [
`${submittedLine}\r\n`,
`${submittedLine}\n`,
`${submittedLine}\r`,
];
const fullEcho = echoVariants.find((variant) => nextCandidate.startsWith(variant));
if (fullEcho) {
return {
submittedLine: null,
candidate: '',
filtered: nextCandidate.slice(fullEcho.length),
};
}
if (echoVariants.some((variant) => variant.startsWith(nextCandidate))) {
return { submittedLine, candidate: nextCandidate, filtered: '' };
}
return {
submittedLine: null,
candidate: '',
filtered: nextCandidate,
};
}
export function createTerminalOutputTriggerFilter(): TerminalOutputTriggerFilter {
let pendingEcho = '';
let currentInputLine = '';
let submittedEchoLine: string | null = null;
let submittedEchoCandidate = '';
let alternateScreenActive = false;
let pendingEscape = '';
const reset = () => {
pendingEcho = '';
currentInputLine = '';
submittedEchoLine = null;
submittedEchoCandidate = '';
alternateScreenActive = false;
pendingEscape = '';
};
const noteUserInput = (data: string) => {
if (!data) return;
const inputState = applyInputToLine(currentInputLine, data);
currentInputLine = inputState.line;
if (inputState.submittedLine !== null) {
submittedEchoLine = inputState.submittedLine;
submittedEchoCandidate = '';
pendingEcho = '';
return;
}
pendingEcho = appendPendingEcho(pendingEcho, data);
};
const processServerChunk = (chunk: string) => {
const input = pendingEscape ? `${pendingEscape}${chunk}` : chunk;
pendingEscape = '';
const submitted = stripSubmittedLineEcho(submittedEchoLine, submittedEchoCandidate, input);
submittedEchoLine = submitted.submittedLine;
submittedEchoCandidate = submitted.candidate;
if (!submitted.filtered) {
return {
scannableText: '',
alternateScreenActive,
meta: {
rawLen: chunk.length,
dropReason: chunk.length === 0
? 'empty-chunk'
: 'awaiting-submitted-echo',
submittedEchoLine: submittedEchoLine ?? undefined,
submittedEchoCandidateLen: submittedEchoCandidate.length,
afterSubmittedLen: 0,
afterUserEchoLen: 0,
scannableLen: 0,
},
};
}
const { pendingEcho: nextPendingEcho, filtered } = stripUserEcho(pendingEcho, submitted.filtered);
pendingEcho = nextPendingEcho;
let scannableText = '';
let hitPendingEscape = false;
for (let index = 0; index < filtered.length; index += 1) {
if (filtered[index] !== '\x1b') {
if (!alternateScreenActive) {
scannableText += filtered[index];
}
continue;
}
const sequence = readEscapeSequence(filtered, index);
if (!sequence) {
pendingEscape = filtered.slice(index);
hitPendingEscape = true;
break;
}
if (sequence.sequence.startsWith('\x1b[')) {
const alternateScreenAction = getAlternateScreenAction(sequence.sequence);
if (alternateScreenAction === 'enter') {
alternateScreenActive = true;
pendingEcho = '';
} else if (alternateScreenAction === 'leave') {
alternateScreenActive = false;
pendingEcho = '';
}
}
index = sequence.end - 1;
}
let dropReason: TerminalOutputChunkMeta['dropReason'];
if (currentInputLine.length > 0) {
scannableText = '';
dropReason = 'editing-input-line';
} else if (!scannableText) {
if (hitPendingEscape) {
dropReason = 'pending-escape';
} else if (alternateScreenActive) {
dropReason = 'alternate-screen-active';
} else if (filtered.length === 0) {
dropReason = 'user-echo-only';
} else {
dropReason = 'control-only';
}
}
return {
scannableText,
alternateScreenActive,
meta: {
rawLen: chunk.length,
dropReason,
submittedEchoLine: submittedEchoLine ?? undefined,
submittedEchoCandidateLen: submittedEchoCandidate.length,
afterSubmittedLen: submitted.filtered.length,
afterUserEchoLen: filtered.length,
scannableLen: scannableText.length,
},
};
};
return {
noteUserInput,
processServerChunk,
reset,
};
}
@@ -4,12 +4,20 @@ const { trackEmitted } = require("./terminalFlowAck.cjs");
let getSession = null;
let outputChannel = null;
/** @type {Set<(sessionId: string, data: string) => void>} */
const dataTaps = new Set();
function configureTerminalSessionDataEmitter(options = {}) {
getSession = typeof options.getSession === "function" ? options.getSession : null;
outputChannel = options.outputChannel || null;
}
function addTerminalDataTap(listener) {
if (typeof listener !== "function") return () => {};
dataTaps.add(listener);
return () => dataTaps.delete(listener);
}
function emitTerminalSessionData(contents, sessionId, data) {
if (getSession && sessionId && data) {
const session = getSession(sessionId);
@@ -17,6 +25,15 @@ function emitTerminalSessionData(contents, sessionId, data) {
trackEmitted(session, typeof data === "string" ? data.length : 0);
}
}
if (sessionId && data) {
for (const tap of dataTaps) {
try {
tap(sessionId, data);
} catch (err) {
console.warn("[emitTerminalSessionData] tap failed", err);
}
}
}
if (outputChannel?.send?.(sessionId, data)) return;
contents?.send("netcatty:data", { sessionId, data });
}
@@ -24,4 +41,5 @@ function emitTerminalSessionData(contents, sessionId, data) {
module.exports = {
configureTerminalSessionDataEmitter,
emitTerminalSessionData,
addTerminalDataTap,
};
+584
View File
@@ -0,0 +1,584 @@
"use strict";
const path = require("node:path");
const { randomUUID } = require("node:crypto");
const { createScriptRuntime } = require("../scripts/scriptRuntime.cjs");
const { stepsToJavaScript } = require("../scripts/scriptCodegen.cjs");
const {
appendSessionOutput,
getOrCreateBuffer,
removeSessionBuffer,
} = require("../scripts/sessionOutputBuffer.cjs");
const { addTerminalDataTap } = require("../bridges/emitTerminalSessionData.cjs");
const sessionLogStreamManager = require("./sessionLogStreamManager.cjs");
let sessions = null;
let electronModule = null;
let terminalBridge = null;
let terminalWorkerManager = null;
let getMainWindow = null;
/** @type {Map<string, object>} */
const runs = new Map();
/** @type {Map<string, object>} */
const recordings = new Map();
/** @type {Map<string, { resolve, reject, type }>} */
const pendingDialogs = new Map();
/** @type {Map<string, { resolve, reject, sessionId }>} */
const pendingScreenSnapshots = new Map();
/** @type {Map<string, symbol>} */
const scriptLogTokens = new Map();
/** @type {Map<string, Promise<void>>} */
const sessionRunChains = new Map();
/** @type {Map<string, { connected?: boolean, hostname?: string, username?: string }>} */
const rendererSessionMetaById = new Map();
function enqueueSessionRun(sessionId, task) {
const previous = sessionRunChains.get(sessionId) || Promise.resolve();
const next = previous
.catch(() => {})
.then(() => task());
sessionRunChains.set(
sessionId,
next.then(() => {}, () => {}),
);
return next;
}
function init(deps) {
sessions = deps.sessions;
electronModule = deps.electronModule;
terminalBridge = deps.terminalBridge;
terminalWorkerManager = deps.terminalWorkerManager || null;
getMainWindow = deps.getMainWindow;
addTerminalDataTap((sessionId, data) => {
appendSessionOutput(sessionId, data);
});
terminalWorkerManager?.addOutputTap?.((sessionId, data) => {
appendSessionOutput(sessionId, data);
});
}
function broadcastRuns() {
const win = getMainWindow?.();
if (!win?.webContents) return;
win.webContents.send("netcatty:script:runs-updated", {
runs: Array.from(runs.values()).map(serializeRun),
});
}
function serializeRun(run) {
const now = Date.now();
const elapsedMs = run.endedAt
? run.endedAt - run.startedAt
: Math.max(0, now - run.startedAt);
return {
runId: run.runId,
scriptId: run.scriptId,
scriptLabel: run.scriptLabel,
sessionId: run.sessionId,
status: run.status,
startedAt: run.startedAt,
endedAt: run.endedAt,
currentStep: run.currentStep,
stepIndex: run.stepIndex,
totalSteps: run.totalSteps,
progressMode: run.progressMode || "activity",
activityLabel: run.activityLabel,
progressLabel: run.progressLabel,
progressCurrent: run.progressCurrent,
progressTotal: run.progressTotal,
elapsedMs,
waitingFor: run.waitingFor,
logs: run.logs.slice(-200),
error: run.error,
};
}
function isWorkerSessionOpen(sessionId) {
return Boolean(sessionId && terminalWorkerManager?.hasOpenSession?.(sessionId));
}
function rememberRendererSessionMeta(sessionId, meta) {
if (!sessionId || !meta || typeof meta !== "object") return;
const prev = rendererSessionMetaById.get(sessionId) || {};
rendererSessionMetaById.set(sessionId, { ...prev, ...meta });
}
function hasActiveOutputBuffer(sessionId) {
try {
return Boolean(String(getOrCreateBuffer(sessionId).getText() || "").trim());
} catch {
return false;
}
}
function isSessionConnected(sessionId) {
const session = sessions?.get(sessionId);
if (session) {
return session.status !== "disconnected";
}
const rendererMeta = rendererSessionMetaById.get(sessionId);
return Boolean(
rendererMeta?.connected
|| isWorkerSessionOpen(sessionId)
|| hasActiveOutputBuffer(sessionId),
);
}
function getSessionMeta(sessionId) {
const session = sessions?.get(sessionId);
const rendererMeta = rendererSessionMetaById.get(sessionId);
if (session) {
return {
connected: session.status !== "disconnected",
hostname: session.hostname || session.hostLabel || rendererMeta?.hostname || "",
username: session.username || rendererMeta?.username || "",
};
}
if (isSessionConnected(sessionId)) {
return {
connected: true,
hostname: rendererMeta?.hostname || "",
username: rendererMeta?.username || "",
};
}
return { connected: false, hostname: "", username: "" };
}
function notifyScriptSessionInput(sessionId, data) {
const session = sessions?.get(sessionId);
const webContents = session?.webContentsId
? electronModule.webContents.fromId(session.webContentsId)
: getMainWindow?.()?.webContents;
webContents?.send("netcatty:script:session-input", { sessionId, data });
}
function writeToSession(sessionId, data, options = {}) {
const payload = {
sessionId,
data,
automated: options.automated !== false,
};
const webContentsId = getMainWindow?.()?.webContents?.id;
if (terminalWorkerManager) {
terminalWorkerManager.send("netcatty:write", payload, { webContentsId });
} else {
terminalBridge?.writeToSession?.(
{ sender: getMainWindow?.()?.webContents },
payload,
);
}
if (options.automated !== false && data && data !== "\x03") {
notifyScriptSessionInput(sessionId, data);
}
}
async function requestScreenSnapshot(sessionId) {
const session = sessions?.get(sessionId);
const webContents = session?.webContentsId
? electronModule.webContents.fromId(session.webContentsId)
: getMainWindow?.()?.webContents;
if (!webContents) {
return {
rows: 24,
cols: 80,
currentRow: 0,
lines: getOrCreateBuffer(sessionId).getText().split("\n"),
};
}
const requestId = randomUUID();
return new Promise((resolve, reject) => {
const timer = setTimeout(() => {
pendingScreenSnapshots.delete(requestId);
resolve({
rows: 24,
cols: 80,
currentRow: 0,
lines: getOrCreateBuffer(sessionId).getText().split("\n"),
});
}, 3000);
pendingScreenSnapshots.set(requestId, {
sessionId,
resolve: (value) => {
clearTimeout(timer);
resolve(value);
},
reject: (err) => {
clearTimeout(timer);
reject(err);
},
});
webContents.send("netcatty:script:screen-snapshot-request", { requestId, sessionId });
});
}
function showDialog(type, message, defaultValue, extras = {}) {
const win = getMainWindow?.();
const webContents = win?.webContents;
if (!webContents) {
if (type === "confirm") return Promise.resolve(false);
if (type === "prompt") return Promise.resolve(defaultValue || "");
if (type === "waitForTimeout") return Promise.resolve("abort");
return Promise.resolve(undefined);
}
const requestId = randomUUID();
return new Promise((resolve, reject) => {
const timer = setTimeout(() => {
pendingDialogs.delete(requestId);
reject(new Error("Dialog timed out"));
}, 120000);
pendingDialogs.set(requestId, {
type,
resolve: (value) => {
clearTimeout(timer);
resolve(value);
},
reject: (err) => {
clearTimeout(timer);
reject(err);
},
});
webContents.send("netcatty:script:dialog-request", {
requestId,
type,
message,
defaultValue,
...extras,
});
});
}
function showWaitForTimeoutDialog(pattern, timeoutMs) {
return showDialog(
"waitForTimeout",
`Timed out waiting for "${pattern}" after ${timeoutMs}ms`,
undefined,
{ pattern, timeoutMs },
);
}
function syncOutputBufferFromSnapshot(sessionId) {
return requestScreenSnapshot(sessionId).then((snapshot) => {
const screenText = (snapshot.lines || []).join("\n");
if (!screenText) return;
const buffer = getOrCreateBuffer(sessionId);
const existing = buffer.getText();
if (!existing) {
buffer.append(screenText.endsWith("\n") ? screenText : `${screenText}\n`);
return;
}
const tail = screenText.slice(-8192);
if (!tail) return;
const existingTail = existing.slice(-8192);
if (tail === existingTail || existing.endsWith(tail)) return;
if (existingTail && tail.includes(existingTail)) {
buffer.append(tail.slice(tail.indexOf(existingTail) + existingTail.length));
return;
}
if (!existing.includes(tail.trim())) {
buffer.append(tail.startsWith("\n") ? tail : `\n${tail}`);
}
}).catch(() => {});
}
async function runScriptOnSession({
runId,
scriptId,
scriptLabel,
sessionId,
content,
permissionMode = "auto",
sessionMeta,
}) {
rememberRendererSessionMeta(sessionId, sessionMeta);
const run = {
runId,
scriptId,
scriptLabel,
sessionId,
status: "running",
startedAt: Date.now(),
endedAt: undefined,
currentStep: undefined,
stepIndex: 0,
totalSteps: undefined,
progressMode: "activity",
activityLabel: undefined,
progressLabel: undefined,
progressCurrent: undefined,
progressTotal: undefined,
waitingFor: undefined,
logs: [],
paused: false,
aborted: false,
};
runs.set(runId, run);
broadcastRuns();
const runtime = createScriptRuntime({
sessionId,
runId,
appVersion: electronModule?.app?.getVersion?.(),
appendLog: (id, message) => {
const entry = runs.get(id);
if (!entry) return;
entry.logs.push({ at: Date.now(), message });
broadcastRuns();
},
writeToSession,
getOutputBuffer: getOrCreateBuffer,
getScreenSnapshot: requestScreenSnapshot,
getSessionMeta,
showDialog,
showWaitForTimeoutDialog,
disconnectSession: async (sid) => {
if (terminalWorkerManager) {
terminalWorkerManager.send("netcatty:close", { sessionId: sid });
} else {
terminalBridge?.closeSession?.({ sender: {} }, { sessionId: sid });
}
},
startSessionLog: async (sid, logPath) => {
const session = sessions?.get(sid);
const defaultDir = electronModule?.app?.getPath?.("documents") || process.cwd();
const filePath = logPath
? path.resolve(String(logPath))
: path.join(defaultDir, `netcatty-script-${Date.now()}.log`);
const result = sessionLogStreamManager.startStreamToFile(sid, {
filePath,
hostLabel: session?.hostname || session?.hostLabel || "script",
format: "raw",
stopRequiresToken: true,
});
if (!result.ok) {
throw new Error(result.error || "Failed to start script log");
}
scriptLogTokens.set(sid, result.token);
},
stopSessionLog: async (sid) => {
const token = scriptLogTokens.get(sid);
if (token) {
sessionLogStreamManager.stopStream(sid, token);
scriptLogTokens.delete(sid);
}
},
isPaused: () => Boolean(runs.get(runId)?.paused),
isAborted: () => Boolean(runs.get(runId)?.aborted),
permissionMode,
startedAt: run.startedAt,
onStatusChange: (id, patch) => {
const entry = runs.get(id);
if (!entry) return;
Object.assign(entry, patch);
broadcastRuns();
},
});
try {
await syncOutputBufferFromSnapshot(sessionId);
await runtime.execute(content);
if (run.aborted) {
run.status = "failed";
run.error = run.error || "Stopped by user";
} else {
run.status = "completed";
}
run.endedAt = Date.now();
run.progressMode = "activity";
run.progressLabel = undefined;
run.progressCurrent = undefined;
run.progressTotal = undefined;
} catch (err) {
run.status = "failed";
run.endedAt = Date.now();
run.progressMode = "activity";
run.progressLabel = undefined;
run.progressCurrent = undefined;
run.progressTotal = undefined;
run.error = err?.message || String(err);
run.logs.push({ at: Date.now(), message: run.error });
} finally {
broadcastRuns();
}
}
async function handleScriptRun(_event, payload = {}) {
const {
scriptId,
scriptLabel,
content,
sessionId,
sessionIds,
mode = "parallel",
permissionMode = "auto",
} = payload;
const targets = Array.isArray(sessionIds) && sessionIds.length > 0
? sessionIds
: sessionId
? [sessionId]
: [];
if (targets.length === 0) {
throw new Error("No target session for script run");
}
if (!content || !String(content).trim()) {
throw new Error("Script content is empty");
}
const runIds = [];
const queueRun = (sid) => {
const runId = randomUUID();
runIds.push(runId);
return enqueueSessionRun(sid, () => runScriptOnSession({
runId,
scriptId,
scriptLabel,
sessionId: sid,
content,
permissionMode,
sessionMeta: payload.sessionMeta,
}));
};
if (mode === "sequential") {
for (const sid of targets) {
await queueRun(sid);
}
} else {
await Promise.all(targets.map((sid) => queueRun(sid)));
}
return { runIds, runId: runIds[0] };
}
function handleScriptStop(_event, payload = {}) {
const run = runs.get(payload.runId);
if (!run) return { ok: false };
run.aborted = true;
run.paused = false;
run.status = "failed";
run.error = "Stopped by user";
run.endedAt = Date.now();
run.waitingFor = undefined;
getOrCreateBuffer(run.sessionId).abortWaiters("Stopped by user");
broadcastRuns();
return { ok: true };
}
function handleScriptPause(_event, payload = {}) {
const run = runs.get(payload.runId);
if (!run) return { ok: false };
run.paused = true;
run.status = "paused";
broadcastRuns();
return { ok: true };
}
function handleScriptResume(_event, payload = {}) {
const run = runs.get(payload.runId);
if (!run) return { ok: false };
run.paused = false;
run.status = "running";
broadcastRuns();
return { ok: true };
}
function handleScriptGetRuns(_event, payload = {}) {
const all = Array.from(runs.values()).map(serializeRun);
if (payload.sessionId) {
return all.filter((run) => run.sessionId === payload.sessionId);
}
return all;
}
function handleScriptDialogResponse(_event, payload = {}) {
const pending = pendingDialogs.get(payload.requestId);
if (!pending) return { ok: false };
pendingDialogs.delete(payload.requestId);
if (payload.cancelled) {
pending.reject(new Error("Dialog cancelled"));
return { ok: true };
}
if (pending.type === "confirm") {
pending.resolve(Boolean(payload.value));
} else if (pending.type === "prompt") {
pending.resolve(typeof payload.value === "string" ? payload.value : "");
} else if (pending.type === "waitForTimeout") {
pending.resolve(typeof payload.value === "string" ? payload.value : "abort");
} else {
pending.resolve(undefined);
}
return { ok: true };
}
function handleScriptScreenSnapshotResponse(_event, payload = {}) {
const pending = pendingScreenSnapshots.get(payload.requestId);
if (!pending) return { ok: false };
pendingScreenSnapshots.delete(payload.requestId);
pending.resolve(payload.snapshot || {
rows: 24,
cols: 80,
currentRow: 0,
lines: getOrCreateBuffer(pending.sessionId).getText().split("\n"),
});
return { ok: true };
}
function handleRecordingStart(_event, payload = {}) {
const { sessionId } = payload;
if (!sessionId) throw new Error("sessionId required");
recordings.set(sessionId, {
sessionId,
startedAt: Date.now(),
steps: [],
lastTimestamp: Date.now(),
});
return { ok: true };
}
function handleRecordingStop(_event, payload = {}) {
const { sessionId } = payload;
const recording = recordings.get(sessionId);
if (!recording) {
return { steps: [], code: "" };
}
recordings.delete(sessionId);
const code = stepsToJavaScript(recording.steps, new Date(recording.startedAt).toISOString());
return { steps: recording.steps, code };
}
function handleRecordingAppendStep(_event, payload = {}) {
const { sessionId, step } = payload;
const recording = recordings.get(sessionId);
if (!recording || !step) return { ok: false };
const now = Date.now();
const gap = now - recording.lastTimestamp;
if (gap > 1000 && step.type === "send") {
recording.steps.push({ type: "sleep", value: gap });
}
recording.steps.push(step);
recording.lastTimestamp = now;
return { ok: true };
}
function registerHandlers(ipcMain) {
ipcMain.handle("netcatty:script:run", handleScriptRun);
ipcMain.handle("netcatty:script:stop", handleScriptStop);
ipcMain.handle("netcatty:script:pause", handleScriptPause);
ipcMain.handle("netcatty:script:resume", handleScriptResume);
ipcMain.handle("netcatty:script:get-runs", handleScriptGetRuns);
ipcMain.handle("netcatty:script:dialog-response", handleScriptDialogResponse);
ipcMain.handle("netcatty:script:screen-snapshot-response", handleScriptScreenSnapshotResponse);
ipcMain.handle("netcatty:script:recording:start", handleRecordingStart);
ipcMain.handle("netcatty:script:recording:stop", handleRecordingStop);
ipcMain.handle("netcatty:script:recording:append-step", handleRecordingAppendStep);
}
module.exports = {
init,
registerHandlers,
appendSessionOutput,
removeSessionBuffer,
};
+263
View File
@@ -0,0 +1,263 @@
const assert = require("node:assert/strict");
const test = require("node:test");
const scriptBridge = require("./scriptBridge.cjs");
test("script run writes through terminal worker manager when enabled", async () => {
const handlers = new Map();
const workerSends = [];
const terminalWrites = [];
scriptBridge.init({
sessions: new Map(),
electronModule: {
app: {
getVersion: () => "test",
getPath: () => process.cwd(),
},
},
terminalBridge: {
writeToSession(_event, payload) {
terminalWrites.push(payload);
},
},
terminalWorkerManager: {
addOutputTap() {
return () => {};
},
send(channel, payload, options) {
workerSends.push({ channel, payload, options });
},
},
getMainWindow: () => null,
});
scriptBridge.registerHandlers({
handle(channel, handler) {
handlers.set(channel, handler);
},
});
await handlers.get("netcatty:script:run")({}, {
scriptId: "script-1",
scriptLabel: "Smoke",
sessionId: "session-1",
content: "await nct.screen.sendLine('echo hi');",
permissionMode: "auto",
});
assert.deepEqual(terminalWrites, []);
assert.equal(workerSends.length, 1);
assert.equal(workerSends[0].channel, "netcatty:write");
assert.deepEqual(workerSends[0].payload, {
sessionId: "session-1",
data: "echo hi\r",
automated: true,
});
});
test("script run completion stores actual executed step count", async () => {
const handlers = new Map();
const sentRunUpdates = [];
scriptBridge.init({
sessions: new Map(),
electronModule: {
app: {
getVersion: () => "test",
getPath: () => process.cwd(),
},
},
terminalBridge: {
writeToSession() {},
},
getMainWindow: () => ({
webContents: {
send(channel, payload) {
if (channel === "netcatty:script:runs-updated") {
sentRunUpdates.push(payload.runs);
}
},
},
}),
});
scriptBridge.registerHandlers({
handle(channel, handler) {
handlers.set(channel, handler);
},
});
await handlers.get("netcatty:script:run")({}, {
scriptId: "script-loop",
scriptLabel: "Loop",
sessionId: "session-1",
content: "for (let i = 0; i < 3; i += 1) { nct.log(`step ${i}`); }",
permissionMode: "auto",
});
const finalRun = sentRunUpdates.at(-1).find((run) => run.scriptId === "script-loop");
assert.equal(finalRun.status, "completed");
assert.equal(finalRun.stepIndex, 3);
assert.equal(finalRun.progressMode, "activity");
assert.equal(finalRun.totalSteps, undefined);
});
test("same session script runs are serialized through the session mutex", async () => {
const handlers = new Map();
const writeOrder = [];
scriptBridge.init({
sessions: new Map(),
electronModule: {
app: {
getVersion: () => "test",
getPath: () => process.cwd(),
},
},
terminalBridge: {
writeToSession(_event, payload) {
const marker = String(payload.data || "").match(/slow-run|fast-run/);
if (marker) writeOrder.push(marker[0]);
},
},
terminalWorkerManager: null,
getMainWindow: () => null,
});
scriptBridge.registerHandlers({
handle(channel, handler) {
handlers.set(channel, handler);
},
});
const runHandler = handlers.get("netcatty:script:run");
await Promise.all([
runHandler({}, {
scriptId: "slow",
scriptLabel: "Slow",
sessionId: "session-mutex",
content: `
await nct.sleep(40);
await nct.screen.sendLine('slow-run');
`,
permissionMode: "auto",
}),
runHandler({}, {
scriptId: "fast",
scriptLabel: "Fast",
sessionId: "session-mutex",
content: "await nct.screen.sendLine('fast-run');",
permissionMode: "auto",
}),
]);
assert.deepEqual(writeOrder, ["slow-run", "fast-run"]);
});
test("script run treats worker-managed sessions as connected", async () => {
const handlers = new Map();
const sentRunUpdates = [];
scriptBridge.init({
sessions: new Map(),
electronModule: {
app: {
getVersion: () => "test",
getPath: () => process.cwd(),
},
},
terminalBridge: {
writeToSession() {},
},
terminalWorkerManager: {
hasOpenSession(sessionId) {
return sessionId === "session-worker";
},
addOutputTap() {
return () => {};
},
send() {},
},
getMainWindow: () => ({
webContents: {
send(channel, payload) {
if (channel === "netcatty:script:runs-updated") {
sentRunUpdates.push(payload.runs);
}
},
},
}),
});
scriptBridge.registerHandlers({
handle(channel, handler) {
handlers.set(channel, handler);
},
});
await handlers.get("netcatty:script:run")({}, {
scriptId: "connected-check",
scriptLabel: "Connected check",
sessionId: "session-worker",
sessionMeta: { connected: true, hostname: "worker-host", username: "root" },
content: `
if (!nct.session.connected) {
throw new Error("Session not connected");
}
nct.log("connected ok");
`,
permissionMode: "auto",
});
const finalRun = sentRunUpdates.at(-1).find((run) => run.scriptId === "connected-check");
assert.equal(finalRun.status, "completed");
assert.match(finalRun.logs.map((entry) => entry.message).join("\n"), /connected ok/);
});
test("script run uses renderer sessionMeta when main-process session map is empty", async () => {
const handlers = new Map();
const sentRunUpdates = [];
scriptBridge.init({
sessions: new Map(),
electronModule: {
app: {
getVersion: () => "test",
getPath: () => process.cwd(),
},
},
terminalBridge: {
writeToSession() {},
},
terminalWorkerManager: null,
getMainWindow: () => ({
webContents: {
send(channel, payload) {
if (channel === "netcatty:script:runs-updated") {
sentRunUpdates.push(payload.runs);
}
},
},
}),
});
scriptBridge.registerHandlers({
handle(channel, handler) {
handlers.set(channel, handler);
},
});
await handlers.get("netcatty:script:run")({}, {
scriptId: "renderer-meta",
scriptLabel: "Renderer meta",
sessionId: "session-renderer",
sessionMeta: { connected: true, hostname: "10.0.0.1", username: "root" },
content: `
if (!nct.session.connected) {
throw new Error("Session not connected");
}
nct.log(\`\${nct.session.hostname}@\${nct.session.username}\`);
`,
permissionMode: "auto",
});
const finalRun = sentRunUpdates.at(-1).find((run) => run.scriptId === "renderer-meta");
assert.equal(finalRun.status, "completed");
assert.match(finalRun.logs.map((entry) => entry.message).join("\n"), /10\.0\.0\.1@root/);
});
@@ -36,6 +36,7 @@ function createTerminalWorkerManager(options = {}) {
const outputPortReady = new Set();
const sessionWebContentsIds = new Map();
const urgentInputWebContentsIds = new Set();
const outputTaps = new Set();
const maxPendingOutputChunks = Number.isFinite(options.maxPendingOutputChunks)
? Math.max(0, Math.trunc(options.maxPendingOutputChunks))
: 512;
@@ -97,6 +98,17 @@ function createTerminalWorkerManager(options = {}) {
return sendOutputOverLegacyIpc(sessionId, data);
}
function notifyOutputTaps(sessionId, data) {
if (!sessionId || !data || outputTaps.size === 0) return;
for (const tap of outputTaps) {
try {
tap(sessionId, data);
} catch (err) {
console.warn("[terminalWorkerManager] output tap failed", err);
}
}
}
function openOutputSession(sessionId, webContentsId) {
if (!sessionId || !webContentsId) return;
if (closedSessions.has(sessionId)) {
@@ -192,6 +204,7 @@ function createTerminalWorkerManager(options = {}) {
}
if (message.kind === "output") {
if (closedSessions.has(message.sessionId)) return;
notifyOutputTaps(message.sessionId, message.data);
if (outputPortPending.has(message.sessionId)) {
bufferOutput(message.sessionId, message.data);
return;
@@ -205,6 +218,12 @@ function createTerminalWorkerManager(options = {}) {
}
return;
}
if (message.kind === "output-tap") {
if (!closedSessions.has(message.sessionId)) {
notifyOutputTaps(message.sessionId, message.data);
}
return;
}
if (message.kind === "output-port-ready") {
outputPortPending.delete(message.sessionId);
if (!closedSessions.has(message.sessionId)) {
@@ -325,6 +344,18 @@ function createTerminalWorkerManager(options = {}) {
ensureStarted,
request,
send,
hasOpenSession(sessionId) {
return Boolean(
sessionId
&& sessionWebContentsIds.has(sessionId)
&& !closedSessions.has(sessionId),
);
},
addOutputTap(listener) {
if (typeof listener !== "function") return () => {};
outputTaps.add(listener);
return () => outputTaps.delete(listener);
},
stop,
};
}
@@ -428,6 +428,68 @@ test("worker output is routed through the dedicated terminal output channel", ()
assert.deepEqual(routed, [{ sessionId: "session-1", data: "hello" }]);
});
test("worker output notifies output taps before renderer routing", () => {
const child = new FakeChild();
const routed = [];
const tapped = [];
const manager = createTerminalWorkerManager({
utilityProcess: {
fork() {
return child;
},
},
terminalOutputChannel: {
send(sessionId, data) {
routed.push({ sessionId, data });
return true;
},
},
workerScriptPath: "/worker.cjs",
});
manager.addOutputTap((sessionId, data) => tapped.push({ sessionId, data }));
manager.ensureStarted();
child.emit("message", {
kind: "output",
sessionId: "session-1",
data: "hello",
});
assert.deepEqual(tapped, [{ sessionId: "session-1", data: "hello" }]);
assert.deepEqual(routed, [{ sessionId: "session-1", data: "hello" }]);
});
test("worker output-tap messages notify taps without duplicate renderer routing", () => {
const child = new FakeChild();
const routed = [];
const tapped = [];
const manager = createTerminalWorkerManager({
utilityProcess: {
fork() {
return child;
},
},
terminalOutputChannel: {
send(sessionId, data) {
routed.push({ sessionId, data });
return true;
},
},
workerScriptPath: "/worker.cjs",
});
manager.addOutputTap((sessionId, data) => tapped.push({ sessionId, data }));
manager.ensureStarted();
child.emit("message", {
kind: "output-tap",
sessionId: "session-1",
data: "direct-port-output",
});
assert.deepEqual(tapped, [{ sessionId: "session-1", data: "direct-port-output" }]);
assert.deepEqual(routed, []);
});
test("worker buffers early output until the output port is opened", async () => {
const child = new FakeChild();
const routed = [];
+10
View File
@@ -189,6 +189,16 @@ function createBridgeRegistrar(context) {
portForwardingBridge.registerHandlers(ipcMain);
terminalBridge.registerHandlers(ipcMain, { terminalWorkerManager });
const scriptBridge = require("../bridges/scriptBridge.cjs");
scriptBridge.init({
sessions,
electronModule,
terminalBridge,
terminalWorkerManager,
getMainWindow: () => win,
});
scriptBridge.registerHandlers(ipcMain);
const { createSystemManagerBridge } = require("../bridges/systemManagerBridge.cjs");
const systemManagerBridge = createSystemManagerBridge({
getSessions: () => sessions,
+33
View File
@@ -1202,6 +1202,39 @@ function createPreloadApi(ctx) {
};
ipcRenderer.on("netcatty:ai:agent:exit", handler);
return () => ipcRenderer.removeListener("netcatty:ai:agent:exit", handler);
},
scriptRun: async (params) => ipcRenderer.invoke("netcatty:script:run", params),
scriptStop: async (runId) => ipcRenderer.invoke("netcatty:script:stop", { runId }),
scriptPause: async (runId) => ipcRenderer.invoke("netcatty:script:pause", { runId }),
scriptResume: async (runId) => ipcRenderer.invoke("netcatty:script:resume", { runId }),
scriptGetRuns: async (sessionId) => ipcRenderer.invoke("netcatty:script:get-runs", sessionId ? { sessionId } : {}),
scriptDialogResponse: async (requestId, value, cancelled) =>
ipcRenderer.invoke("netcatty:script:dialog-response", { requestId, value, cancelled }),
scriptScreenSnapshotResponse: async (requestId, snapshot) =>
ipcRenderer.invoke("netcatty:script:screen-snapshot-response", { requestId, snapshot }),
scriptRecordingStart: async (sessionId) => ipcRenderer.invoke("netcatty:script:recording:start", { sessionId }),
scriptRecordingStop: async (sessionId) => ipcRenderer.invoke("netcatty:script:recording:stop", { sessionId }),
scriptRecordingAppendStep: async (sessionId, step) =>
ipcRenderer.invoke("netcatty:script:recording:append-step", { sessionId, step }),
onScriptRunsUpdated: (cb) => {
const handler = (_event, payload) => cb(payload);
ipcRenderer.on("netcatty:script:runs-updated", handler);
return () => ipcRenderer.removeListener("netcatty:script:runs-updated", handler);
},
onScriptDialogRequest: (cb) => {
const handler = (_event, payload) => cb(payload);
ipcRenderer.on("netcatty:script:dialog-request", handler);
return () => ipcRenderer.removeListener("netcatty:script:dialog-request", handler);
},
onScriptScreenSnapshotRequest: (cb) => {
const handler = (_event, payload) => cb(payload);
ipcRenderer.on("netcatty:script:screen-snapshot-request", handler);
return () => ipcRenderer.removeListener("netcatty:script:screen-snapshot-request", handler);
},
onScriptSessionInput: (cb) => {
const handler = (_event, payload) => cb(payload);
ipcRenderer.on("netcatty:script:session-input", handler);
return () => ipcRenderer.removeListener("netcatty:script:session-input", handler);
},
};
}
+41
View File
@@ -0,0 +1,41 @@
"use strict";
const HEADER = "// Auto-generated by Netcatty Script Recorder\n";
function escapeJsString(value) {
return JSON.stringify(String(value ?? ""));
}
function stepsToJavaScript(steps, recordedAt) {
const lines = [
HEADER.trimEnd(),
recordedAt ? `// Recorded on ${recordedAt}` : "",
"",
"async function main() {",
].filter(Boolean);
for (let index = 0; index < steps.length; index += 1) {
const step = steps[index];
if (step.type === "send") {
if (step.sensitive) {
lines.push(` const sensitiveValue${index} = await nct.dialog.prompt("Enter sensitive value", "");`);
lines.push(` await nct.screen.sendLine(sensitiveValue${index});`);
continue;
}
lines.push(` await nct.screen.sendLine(${escapeJsString(step.value)});`);
} else if (step.type === "waitFor") {
lines.push(` await nct.screen.waitFor(${escapeJsString(step.value)}, ${step.timeoutMs || 5000});`);
} else if (step.type === "waitForPrompt") {
lines.push(` await nct.screen.waitForPrompt(${step.timeoutMs || 30000});`);
} else if (step.type === "sleep") {
lines.push(` await nct.session.sleep(${Number(step.value) || 1000});`);
}
}
lines.push("}", "", "await main();");
return lines.join("\n");
}
module.exports = {
stepsToJavaScript,
};
+461
View File
@@ -0,0 +1,461 @@
"use strict";
const vm = require("node:vm");
const { shellPromptPatterns } = require("./shellPromptPatterns.cjs");
function wrapScriptSource(source) {
const trimmed = String(source || "").trim();
if (!trimmed) {
throw new Error("Script content is empty");
}
if (/^\(\s*async\s*\(\s*\)\s*=>/m.test(trimmed) || /^\(\s*async\s+function\s*\(\s*\)\s*\{/m.test(trimmed)) {
return trimmed;
}
const hasMainFunction = /\basync\s+function\s+main\s*\(/m.test(trimmed) || /\bfunction\s+main\s*\(/m.test(trimmed);
if (hasMainFunction) {
const body = trimmed.replace(/\n\s*;?\s*await\s+main\s*\(\s*\)\s*;?\s*$/m, "").trimEnd();
const invokeMain = /\basync\s+function\s+main\s*\(/m.test(body)
? "await main();"
: "await Promise.resolve(main());";
return `(async () => {\n${body}\n${invokeMain}\n})();`;
}
return `(async () => {\n${trimmed}\n})();`;
}
function truncateActivityLabel(value, max = 80) {
const text = String(value ?? "");
return text.length > max ? `${text.slice(0, max - 1)}…` : text;
}
function createScriptRuntime(deps) {
const {
sessionId,
runId,
appendLog,
writeToSession,
getOutputBuffer,
getScreenSnapshot,
getSessionMeta,
showDialog,
showWaitForTimeoutDialog,
disconnectSession,
startSessionLog,
stopSessionLog,
onStatusChange,
isPaused,
permissionMode = "auto",
startedAt = Date.now(),
} = deps;
let stepIndex = 0;
let progressMode = "activity";
let progressLabel;
let progressCurrent = 0;
let progressTotal = 0;
let screenSnapshot = {
rows: 24,
cols: 80,
currentRow: 0,
lines: [],
};
function emitStatus(patch = {}) {
onStatusChange?.(runId, {
progressMode,
progressLabel: progressMode === "determinate" ? progressLabel : undefined,
progressCurrent: progressMode === "determinate" ? progressCurrent : undefined,
progressTotal: progressMode === "determinate" ? progressTotal : undefined,
elapsedMs: Math.max(0, Date.now() - startedAt),
stepIndex,
status: "running",
...patch,
});
}
function assertWriteAllowed(operation) {
if (permissionMode !== "observer") return;
throw new Error(`Observer mode: ${operation} is disabled. Switch to Confirm or Auto mode.`);
}
async function trackStep(label) {
stepIndex += 1;
const activityLabel = truncateActivityLabel(label);
emitStatus({
activityLabel,
currentStep: activityLabel,
});
}
async function refreshScreenSnapshot() {
if (typeof getScreenSnapshot === "function") {
try {
screenSnapshot = await getScreenSnapshot(sessionId);
} catch {
// fall back to output buffer text
}
}
return screenSnapshot;
}
async function waitForPromptWithRecovery(timeoutMs = 60000) {
let stepTracked = false;
while (true) {
if (deps.isAborted?.()) {
throw new Error("Script stopped");
}
if (!stepTracked) {
await trackStep("waitForPrompt");
stepTracked = true;
}
onStatusChange?.(runId, { waitingFor: "shell prompt", status: "running", elapsedMs: Math.max(0, Date.now() - startedAt) });
try {
return await getOutputBuffer(sessionId).waitForAny(
shellPromptPatterns(),
timeoutMs,
() => Boolean(deps.isAborted?.()),
);
} catch (err) {
if (!String(err?.message || err).includes("timed out")) {
throw err;
}
onStatusChange?.(runId, { status: "paused", waitingFor: "shell prompt", elapsedMs: Math.max(0, Date.now() - startedAt) });
const action = await showWaitForTimeoutDialog?.("shell prompt", timeoutMs);
onStatusChange?.(runId, { status: "running" });
if (action === "retry") {
continue;
}
if (action === "skip") {
return -1;
}
throw new Error("Script stopped by user");
} finally {
onStatusChange?.(runId, { waitingFor: undefined, status: "running", elapsedMs: Math.max(0, Date.now() - startedAt) });
}
}
}
async function waitForAnyWithRecovery(patterns, timeoutMs = 30000) {
const label = Array.isArray(patterns)
? patterns.map((pattern) => (pattern instanceof RegExp ? pattern.source : String(pattern))).join(" | ")
: String(patterns);
let stepTracked = false;
while (true) {
if (deps.isAborted?.()) {
throw new Error("Script stopped");
}
if (!stepTracked) {
await trackStep(`waitForAny ${truncateActivityLabel(label, 60)}`);
stepTracked = true;
}
onStatusChange?.(runId, { waitingFor: label, status: "running", elapsedMs: Math.max(0, Date.now() - startedAt) });
try {
return await getOutputBuffer(sessionId).waitForAny(
patterns,
timeoutMs,
() => Boolean(deps.isAborted?.()),
);
} catch (err) {
if (!String(err?.message || err).includes("timed out")) {
throw err;
}
onStatusChange?.(runId, { status: "paused", waitingFor: label, elapsedMs: Math.max(0, Date.now() - startedAt) });
const action = await showWaitForTimeoutDialog?.(label, timeoutMs);
onStatusChange?.(runId, { status: "running" });
if (action === "retry") {
continue;
}
if (action === "skip") {
return -1;
}
throw new Error("Script stopped by user");
} finally {
onStatusChange?.(runId, { waitingFor: undefined, status: "running", elapsedMs: Math.max(0, Date.now() - startedAt) });
}
}
}
async function waitForWithRecovery(pattern, timeoutMs = 30000) {
const patternLabel = pattern instanceof RegExp ? pattern.source : String(pattern);
let stepTracked = false;
while (true) {
if (deps.isAborted?.()) {
throw new Error("Script stopped");
}
if (!stepTracked) {
await trackStep(`waitFor ${truncateActivityLabel(patternLabel, 60)}`);
stepTracked = true;
}
onStatusChange?.(runId, { waitingFor: patternLabel, status: "running", elapsedMs: Math.max(0, Date.now() - startedAt) });
try {
return await getOutputBuffer(sessionId).waitFor(
pattern,
timeoutMs,
() => Boolean(deps.isAborted?.()),
);
} catch (err) {
if (!String(err?.message || err).includes("timed out")) {
throw err;
}
onStatusChange?.(runId, { status: "paused", waitingFor: patternLabel, elapsedMs: Math.max(0, Date.now() - startedAt) });
const action = await showWaitForTimeoutDialog?.(patternLabel, timeoutMs);
onStatusChange?.(runId, { status: "running" });
if (action === "retry") {
continue;
}
if (action === "skip") {
return "";
}
throw new Error("Script stopped by user");
} finally {
onStatusChange?.(runId, { waitingFor: undefined, status: "running", elapsedMs: Math.max(0, Date.now() - startedAt) });
}
}
}
const progressApi = {
start(label, total) {
progressMode = "determinate";
progressLabel = truncateActivityLabel(label || "Progress", 60);
progressTotal = Math.max(1, Number(total) || 1);
progressCurrent = 0;
emitStatus({
progressMode,
progressLabel,
progressCurrent,
progressTotal,
activityLabel: progressLabel,
});
},
set(current, detail) {
if (progressMode !== "determinate") return;
progressCurrent = Math.max(0, Math.min(progressTotal, Number(current) || 0));
const patch = {
progressCurrent,
progressTotal,
progressLabel,
};
if (detail !== undefined && detail !== null && detail !== "") {
patch.activityLabel = truncateActivityLabel(detail);
}
emitStatus(patch);
},
step(detail) {
if (progressMode !== "determinate") return;
progressCurrent = Math.min(progressTotal, progressCurrent + 1);
const patch = {
progressCurrent,
progressTotal,
progressLabel,
};
if (detail !== undefined && detail !== null && detail !== "") {
patch.activityLabel = truncateActivityLabel(detail);
}
emitStatus(patch);
},
done() {
if (progressMode !== "determinate") return;
progressCurrent = progressTotal;
emitStatus({
progressCurrent,
progressTotal,
progressLabel,
});
progressMode = "activity";
progressLabel = undefined;
progressCurrent = 0;
progressTotal = 0;
emitStatus({
progressMode: "activity",
progressLabel: undefined,
progressCurrent: undefined,
progressTotal: undefined,
});
},
};
const sessionApi = {
get connected() {
const meta = getSessionMeta?.(sessionId);
return Boolean(meta?.connected);
},
get hostname() {
return getSessionMeta?.(sessionId)?.hostname || "";
},
get username() {
return getSessionMeta?.(sessionId)?.username || "";
},
sleep(ms) {
const delay = Math.max(0, Number(ms) || 0);
return trackStep(`sleep ${delay}ms`).then(() => interruptibleSleep(delay, deps.isAborted));
},
async startLog(path) {
assertWriteAllowed("session.startLog");
await trackStep("startLog");
await startSessionLog?.(sessionId, path);
},
async stopLog() {
await trackStep("stopLog");
await stopSessionLog?.(sessionId);
},
async disconnect() {
assertWriteAllowed("session.disconnect");
await trackStep("disconnect");
await disconnectSession?.(sessionId);
},
};
const screenApi = {
async send(text) {
assertWriteAllowed("screen.send");
await waitIfPaused();
const payload = String(text ?? "");
await trackStep(`send: ${truncateActivityLabel(formatScriptInputForLog(payload), 60)}`);
appendLog(runId, `→ ${formatScriptInputForLog(payload)}`);
writeToSession(sessionId, payload, { automated: true });
},
async sendLine(text) {
assertWriteAllowed("screen.sendLine");
await waitIfPaused();
const line = String(text ?? "");
await trackStep(`sendLine: ${truncateActivityLabel(line, 60)}`);
appendLog(runId, `→ ${line}`);
writeToSession(sessionId, `${line}\r`, { automated: true });
},
waitFor(pattern, timeoutMs = 30000) {
return waitForWithRecovery(pattern, timeoutMs);
},
waitForPrompt(timeoutMs = 60000) {
return waitForPromptWithRecovery(timeoutMs);
},
async waitForAny(patterns, timeoutMs = 30000) {
return waitForAnyWithRecovery(patterns, timeoutMs);
},
async getText(startRow, endRow) {
await refreshScreenSnapshot();
const lines = screenSnapshot.lines || [];
const start = typeof startRow === "number" ? Math.max(0, startRow) : 0;
const end = typeof endRow === "number" ? Math.min(lines.length - 1, endRow) : lines.length - 1;
if (lines.length === 0) {
return getOutputBuffer(sessionId).getText();
}
return lines.slice(start, end + 1).join("\n");
},
get currentRow() {
return screenSnapshot.currentRow ?? 0;
},
get rows() {
return screenSnapshot.rows ?? 24;
},
get cols() {
return screenSnapshot.cols ?? 80;
},
async clear() {
assertWriteAllowed("screen.clear");
await trackStep("clear");
writeToSession(sessionId, "\x1b[2J\x1b[H", { automated: true });
},
};
const dialogApi = {
alert(message) {
return showDialog("alert", String(message ?? ""));
},
confirm(message) {
return showDialog("confirm", String(message ?? ""));
},
prompt(message, defaultValue = "") {
return showDialog("prompt", String(message ?? ""), String(defaultValue ?? ""));
},
};
const nct = {
session: sessionApi,
screen: screenApi,
dialog: dialogApi,
progress: progressApi,
version: deps.appVersion || "0.0.0",
sleep: sessionApi.sleep.bind(sessionApi),
log(message) {
stepIndex += 1;
emitStatus({
activityLabel: "log",
currentStep: "log",
});
appendLog(runId, String(message ?? ""));
},
};
async function waitIfPaused() {
while (isPaused?.()) {
if (deps.isAborted?.()) {
throw new Error("Script stopped");
}
onStatusChange?.(runId, { status: "paused", elapsedMs: Math.max(0, Date.now() - startedAt) });
await interruptibleSleep(100, deps.isAborted);
}
onStatusChange?.(runId, { status: "running", elapsedMs: Math.max(0, Date.now() - startedAt) });
}
async function execute(source) {
if (deps.isAborted?.()) {
throw new Error("Script stopped");
}
const wrapped = wrapScriptSource(source);
const sandbox = {
nct,
console: {
log: (...args) => {
appendLog(runId, args.map((arg) => String(arg)).join(" "));
},
},
};
vm.createContext(sandbox);
const script = new vm.Script(wrapped, { filename: `script-${runId}.js` });
const result = script.runInContext(sandbox, { displayErrors: true });
if (result && typeof result.then === "function") {
await result;
}
}
return { execute, nct };
}
function interruptibleSleep(ms, isAborted) {
const delay = Math.max(0, Number(ms) || 0);
if (!isAborted) {
return new Promise((resolve) => setTimeout(resolve, delay));
}
return new Promise((resolve, reject) => {
const startedAt = Date.now();
const timer = setInterval(() => {
if (isAborted()) {
clearInterval(timer);
reject(new Error("Script stopped"));
return;
}
if (Date.now() - startedAt >= delay) {
clearInterval(timer);
resolve(undefined);
}
}, 50);
});
}
function formatScriptInputForLog(data) {
return String(data ?? "")
.replace(/\r/g, "\\r")
.replace(/\n/g, "\\n")
.replace(/\x03/g, "^C")
.replace(/\x1b/g, "\\e");
}
module.exports = {
createScriptRuntime,
wrapScriptSource,
interruptibleSleep,
formatScriptInputForLog,
};
+153
View File
@@ -0,0 +1,153 @@
"use strict";
const test = require("node:test");
const assert = require("node:assert/strict");
const { createScriptRuntime, wrapScriptSource, interruptibleSleep } = require("./scriptRuntime.cjs");
test("wrapScriptSource wraps async main scripts in async IIFE", () => {
const wrapped = wrapScriptSource(`
// generated
async function main() {
await nct.log('hi');
}
await main();
`);
assert.match(wrapped, /^\(async \(\) => \{/);
assert.match(wrapped, /await main\(\);\n\}\)\(\);$/);
});
test("wrapScriptSource wraps bare statements in async IIFE", () => {
const wrapped = wrapScriptSource("await nct.log('hi');");
assert.match(wrapped, /async \(\) =>/);
});
test("interruptibleSleep rejects when aborted", async () => {
let aborted = false;
const pending = interruptibleSleep(5000, () => aborted);
setTimeout(() => {
aborted = true;
}, 50);
await assert.rejects(pending, /Script stopped/);
});
test("createScriptRuntime executes async main script", async () => {
const logs = [];
const runtime = createScriptRuntime({
sessionId: "s1",
runId: "r1",
appendLog: (_id, message) => logs.push(message),
writeToSession: () => {},
getOutputBuffer: () => ({
waitFor: async () => "ok",
waitForAny: async () => 0,
getText: () => "",
}),
getSessionMeta: () => ({ connected: true, hostname: "host", username: "user" }),
showDialog: async () => true,
isPaused: () => false,
isAborted: () => false,
onStatusChange: () => {},
});
await runtime.execute(`
async function main() {
nct.log('from-main');
}
await main();
`);
assert.deepEqual(logs, ["from-main"]);
});
test("createScriptRuntime executes simple log script", async () => {
const logs = [];
const runtime = createScriptRuntime({
sessionId: "s1",
runId: "r1",
appendLog: (_id, message) => logs.push(message),
writeToSession: () => {},
getOutputBuffer: () => ({
waitFor: async () => "ok",
waitForAny: async () => 0,
getText: () => "",
}),
getSessionMeta: () => ({ connected: true, hostname: "host", username: "user" }),
showDialog: async () => true,
isPaused: () => false,
isAborted: () => false,
onStatusChange: () => {},
});
await runtime.execute("nct.log('hello');");
assert.deepEqual(logs, ["hello"]);
});
test("createScriptRuntime reports activity labels for loops without X/Y totals", async () => {
const statuses = [];
const runtime = createScriptRuntime({
sessionId: "s1",
runId: "r1",
appendLog: () => {},
writeToSession: () => {},
getOutputBuffer: () => ({
waitFor: async () => "ok",
waitForAny: async () => 0,
getText: () => "",
}),
getSessionMeta: () => ({ connected: true, hostname: "host", username: "user" }),
showDialog: async () => true,
isPaused: () => false,
isAborted: () => false,
onStatusChange: (_id, patch) => statuses.push(patch),
});
await runtime.execute("for (let i = 0; i < 3; i += 1) { nct.log(`step ${i}`); }");
const last = statuses.at(-1);
assert.equal(last.stepIndex, 3);
assert.equal(last.activityLabel, "log");
assert.equal(last.progressMode, "activity");
assert.equal(last.totalSteps, undefined);
assert.equal(last.currentStep, "log");
});
test("createScriptRuntime supports explicit determinate progress API", async () => {
const statuses = [];
const runtime = createScriptRuntime({
sessionId: "s1",
runId: "r1",
appendLog: () => {},
writeToSession: () => {},
getOutputBuffer: () => ({
waitFor: async () => "ok",
waitForAny: async () => 0,
getText: () => "",
}),
getSessionMeta: () => ({ connected: true, hostname: "host", username: "user" }),
showDialog: async () => true,
isPaused: () => false,
isAborted: () => false,
onStatusChange: (_id, patch) => statuses.push(patch),
});
await runtime.execute(`
nct.progress.start('Sampling', 3);
for (let i = 0; i < 3; i += 1) {
nct.progress.step('item ' + i);
}
nct.progress.done();
nct.log('finished');
`);
const during = statuses.find((patch) => patch.progressMode === "determinate" && patch.progressCurrent === 2);
assert.ok(during);
assert.equal(during.progressLabel, "Sampling");
assert.equal(during.progressTotal, 3);
assert.equal(during.activityLabel, "item 1");
const afterDone = statuses.filter((patch) => patch.progressMode === "activity").at(-1);
assert.ok(afterDone);
assert.equal(afterDone.progressCurrent, undefined);
assert.equal(afterDone.progressTotal, undefined);
});
+30
View File
@@ -0,0 +1,30 @@
"use strict";
const STEP_MATCHERS = [
{ regex: /await\s+nct\.screen\.waitForPrompt\s*\(/g, label: "waitForPrompt" },
{ regex: /await\s+nct\.screen\.waitForAny\s*\(/g, label: "waitForAny" },
{ regex: /await\s+nct\.screen\.waitFor\s*\(/g, label: "waitFor" },
{ regex: /await\s+nct\.screen\.sendLine\s*\(/g, label: "sendLine" },
{ regex: /await\s+nct\.screen\.send\s*\(/g, label: "send" },
{ regex: /await\s+nct\.screen\.clear\s*\(/g, label: "clear" },
{ regex: /await\s+nct\.session\.sleep\s*\(/g, label: "sleep" },
{ regex: /await\s+nct\.sleep\s*\(/g, label: "sleep" },
{ regex: /await\s+nct\.session\.disconnect\s*\(/g, label: "disconnect" },
{ regex: /await\s+nct\.session\.startLog\s*\(/g, label: "startLog" },
{ regex: /nct\.log\s*\(/g, label: "log" },
];
function countScriptSteps(source) {
const text = String(source || "");
let total = 0;
for (const matcher of STEP_MATCHERS) {
const matches = text.match(matcher.regex);
if (matches) total += matches.length;
}
return Math.max(total, 1);
}
module.exports = {
STEP_MATCHERS,
countScriptSteps,
};
@@ -0,0 +1,14 @@
"use strict";
const test = require("node:test");
const assert = require("node:assert/strict");
const { countScriptSteps } = require("./scriptStepCounter.cjs");
test("countScriptSteps counts common nct calls", () => {
const source = `
await nct.screen.waitFor('$ ', 5000);
await nct.screen.sendLine('ls');
nct.log('done');
`;
assert.equal(countScriptSteps(source), 3);
});
+259
View File
@@ -0,0 +1,259 @@
"use strict";
const DEFAULT_BUFFER_SIZE = 1024 * 1024;
function isRegExpLike(pattern) {
return Boolean(
pattern
&& typeof pattern === "object"
&& typeof pattern.exec === "function"
&& typeof pattern.test === "function",
);
}
function compilePattern(pattern) {
if (pattern instanceof RegExp || isRegExpLike(pattern)) return pattern;
if (typeof pattern !== "string") {
throw new TypeError("waitFor pattern must be a string or RegExp");
}
const slashMatch = pattern.match(/^\/(.+)\/([gimsuy]*)$/);
if (slashMatch) {
return new RegExp(slashMatch[1], slashMatch[2]);
}
const escaped = pattern.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
return new RegExp(escaped);
}
function tryMatch(text, pattern) {
const regex = compilePattern(pattern);
const match = regex.exec(text);
if (!match) return null;
return match[0];
}
function tryMatchWithEnd(text, pattern) {
const regex = compilePattern(pattern);
const match = regex.exec(text);
if (!match || match.index === undefined) return null;
return {
value: match[0],
endOffset: match.index + match[0].length,
};
}
class SessionOutputBuffer {
constructor(sessionId, maxSize = DEFAULT_BUFFER_SIZE) {
this.sessionId = sessionId;
this.maxSize = maxSize;
this.chunks = [];
this.totalLength = 0;
this.scanOffset = 0;
this.waiters = [];
}
append(data) {
if (!data) return;
this.chunks.push(String(data));
this.totalLength += this.chunks[this.chunks.length - 1].length;
while (this.totalLength > this.maxSize && this.chunks.length > 1) {
const removed = this.chunks.shift();
const removedLength = removed.length;
this.totalLength -= removedLength;
this.scanOffset = Math.max(0, this.scanOffset - removedLength);
}
this.flushWaiters();
}
getText() {
return this.chunks.join("");
}
getPendingText() {
return this.getText().slice(this.scanOffset);
}
tryMatchPending(pattern) {
return tryMatchWithEnd(this.getPendingText(), pattern);
}
advanceScanOffset(endOffset) {
const absoluteEnd = this.scanOffset + endOffset;
this.scanOffset = Math.min(absoluteEnd, this.getText().length);
}
clear() {
this.chunks = [];
this.totalLength = 0;
this.scanOffset = 0;
}
flushWaiters() {
if (this.waiters.length === 0) return;
const remaining = [];
for (const waiter of this.waiters) {
if (waiter.custom) {
if (!waiter.custom.check()) {
remaining.push(waiter);
}
continue;
}
const matched = this.tryMatchPending(waiter.pattern);
if (matched !== null) {
this.advanceScanOffset(matched.endOffset);
clearTimeout(waiter.timer);
if (waiter.abortInterval) clearInterval(waiter.abortInterval);
waiter.resolve(matched.value);
} else {
remaining.push(waiter);
}
}
this.waiters = remaining;
}
waitFor(pattern, timeoutMs = 30000, shouldAbort) {
const immediate = this.tryMatchPending(pattern);
if (immediate !== null) {
this.advanceScanOffset(immediate.endOffset);
return Promise.resolve(immediate.value);
}
return new Promise((resolve, reject) => {
const waiter = {
pattern,
resolve,
reject,
shouldAbort,
timer: setTimeout(() => {
this.waiters = this.waiters.filter((entry) => entry !== waiter);
if (waiter.abortInterval) clearInterval(waiter.abortInterval);
reject(new Error(`waitFor timed out after ${timeoutMs}ms`));
}, timeoutMs),
};
if (typeof shouldAbort === "function") {
waiter.abortInterval = setInterval(() => {
if (!shouldAbort()) return;
clearTimeout(waiter.timer);
clearInterval(waiter.abortInterval);
this.waiters = this.waiters.filter((entry) => entry !== waiter);
reject(new Error("Script stopped"));
}, 100);
}
this.waiters.push(waiter);
});
}
abortWaiters(reason = "Script stopped") {
for (const waiter of this.waiters) {
clearTimeout(waiter.timer);
if (waiter.abortInterval) clearInterval(waiter.abortInterval);
waiter.reject?.(new Error(reason));
}
this.waiters = [];
}
async waitForAny(patterns, timeoutMs = 30000, shouldAbort) {
if (!Array.isArray(patterns) || patterns.length === 0) {
throw new TypeError("waitForAny requires a non-empty patterns array");
}
for (let index = 0; index < patterns.length; index += 1) {
const matched = this.tryMatchPending(patterns[index]);
if (matched !== null) {
this.advanceScanOffset(matched.endOffset);
return index;
}
}
return new Promise((resolve, reject) => {
const waiter = {
patterns,
resolve,
reject,
shouldAbort,
timer: null,
interval: null,
check: () => {
for (let index = 0; index < patterns.length; index += 1) {
const matched = this.tryMatchPending(patterns[index]);
if (matched !== null) {
this.advanceScanOffset(matched.endOffset);
clearTimeout(waiter.timer);
if (waiter.interval) clearInterval(waiter.interval);
if (waiter.abortInterval) clearInterval(waiter.abortInterval);
this.waiters = this.waiters.filter((entry) => entry.custom !== waiter);
resolve(index);
return true;
}
}
return false;
},
};
waiter.timer = setTimeout(() => {
if (waiter.interval) clearInterval(waiter.interval);
if (waiter.abortInterval) clearInterval(waiter.abortInterval);
this.waiters = this.waiters.filter((entry) => entry.custom !== waiter);
reject(new Error(`waitForAny timed out after ${timeoutMs}ms`));
}, timeoutMs);
waiter.interval = setInterval(() => {
waiter.check();
}, 50);
if (typeof shouldAbort === "function") {
waiter.abortInterval = setInterval(() => {
if (!shouldAbort()) return;
clearTimeout(waiter.timer);
clearInterval(waiter.interval);
clearInterval(waiter.abortInterval);
this.waiters = this.waiters.filter((entry) => entry.custom !== waiter);
reject(new Error("Script stopped"));
}, 100);
}
this.waiters.push({
pattern: patterns[0],
resolve: () => {},
reject: () => {},
timer: waiter.timer,
custom: waiter,
});
});
}
dispose() {
for (const waiter of this.waiters) {
clearTimeout(waiter.timer);
waiter.reject?.(new Error("Session output buffer disposed"));
}
this.waiters = [];
this.chunks = [];
this.totalLength = 0;
this.scanOffset = 0;
}
}
const buffers = new Map();
function getOrCreateBuffer(sessionId) {
if (!buffers.has(sessionId)) {
buffers.set(sessionId, new SessionOutputBuffer(sessionId));
}
return buffers.get(sessionId);
}
function appendSessionOutput(sessionId, data) {
getOrCreateBuffer(sessionId).append(data);
}
function removeSessionBuffer(sessionId) {
const buffer = buffers.get(sessionId);
if (buffer) {
buffer.dispose();
buffers.delete(sessionId);
}
}
module.exports = {
SessionOutputBuffer,
appendSessionOutput,
getOrCreateBuffer,
removeSessionBuffer,
tryMatch,
compilePattern,
};
Loaded 100 of 112 files, more files were not shown because too many files have changed in this diff. Show more