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:
1 parent
9d98853091
commit
6b9b71ac8a
112 files changed
+8776
-680
No files matched your search
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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;
|
||||
@@ -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',
|
||||
|
||||
@@ -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.',
|
||||
};
|
||||
@@ -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
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -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': 'Добавить пакет',
|
||||
|
||||
@@ -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 и выбранный цвет для этого хоста.',
|
||||
|
||||
@@ -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;
|
||||
@@ -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': '日志',
|
||||
|
||||
@@ -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': '拖动条目即可调整连接时的运行顺序。',
|
||||
};
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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());
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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]);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
let vaultInitialized = false;
|
||||
|
||||
export function isVaultInitialized(): boolean {
|
||||
return vaultInitialized;
|
||||
}
|
||||
|
||||
export function setVaultInitialized(value: boolean): void {
|
||||
vaultInitialized = value;
|
||||
}
|
||||
@@ -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
@@ -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,
|
||||
)}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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 ?? '');
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
@@ -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
@@ -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);
|
||||
|
||||
@@ -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 />;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -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);
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
@@ -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,
|
||||
};
|
||||
};
|
||||
@@ -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, []);
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import type { Host } from './models';
|
||||
|
||||
export function hostDisplayTitle(host: Pick<Host, 'label' | 'hostname'>): string {
|
||||
return host.label?.trim() || host.hostname;
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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 || ''));
|
||||
}
|
||||
@@ -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), []);
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -39,8 +39,6 @@ test("buildSnippetExportPayload removes host target bindings", () => {
|
||||
command: "sudo systemctl restart nginx",
|
||||
tags: [],
|
||||
package: "ops/web",
|
||||
noAutoRun: undefined,
|
||||
shortkey: undefined,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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 = [];
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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
Reference in new issue
Block a user