diff --git a/App.tsx b/App.tsx index 0c3b2cb6..998e9422 100755 --- a/App.tsx +++ b/App.tsx @@ -59,7 +59,8 @@ import { KeyboardInteractiveRequest } from './components/KeyboardInteractiveModa import { PassphraseRequest } from './components/PassphraseModal'; import { classifyLocalShellType } from './lib/localShell'; import { useDiscoveredShells, resolveShellSetting } from './lib/useDiscoveredShells'; -import { Host, HostProtocol, KnownHost, SerialConfig, SSHKey, TerminalSession, TerminalTheme } from './types'; +import { Host, HostProtocol, KnownHost, SerialConfig, Snippet, SSHKey, TerminalSession, TerminalTheme } from './types'; +import { resolveSnippetCommand } from './components/SnippetExecutionProvider'; import { AppView } from './application/app/AppView'; import { useAppStartupEffects } from './application/app/useAppStartupEffects'; import { LogViewWrapper, SftpViewMount, TerminalLayerMount, VaultViewContainer } from './application/app/AppMounts'; @@ -244,6 +245,15 @@ function App({ settings }: { settings: SettingsState }) { copySession, } = useSessionState(); + const handleRunSnippet = useCallback( + async (snippet: Snippet, targetHosts: Host[]) => { + const command = await resolveSnippetCommand(snippet); + if (command === null) return; + runSnippet(snippet, targetHosts, command); + }, + [runSnippet], + ); + // isMacClient is used for window controls styling const isMacClient = typeof navigator !== 'undefined' && /Mac|Macintosh/.test(navigator.userAgent); @@ -932,7 +942,7 @@ function App({ settings }: { settings: SettingsState }) { [orderedTabs, editorTabs], ); - return ; + return ; } function AppWithProviders() { diff --git a/application/app/AppView.tsx b/application/app/AppView.tsx index df805761..ecd88002 100644 --- a/application/app/AppView.tsx +++ b/application/app/AppView.tsx @@ -12,6 +12,7 @@ import { KeyboardInteractiveModal } from '../../components/KeyboardInteractiveMo import { PassphraseModal } from '../../components/PassphraseModal'; import { TextEditorTabView } from '../../components/editor/TextEditorTabView'; import { UnsavedChangesProvider } from '../../components/editor/UnsavedChangesDialog'; +import { SnippetExecutionProvider } from '../../components/SnippetExecutionProvider'; import { Button } from '../../components/ui/button'; import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '../../components/ui/dialog'; import { Input } from '../../components/ui/input'; @@ -55,6 +56,7 @@ export function AppView({ ctx }: { ctx: AppViewContext }) { } = ctx; return ( + {({ prompt }) => { // Helper: close an editor tab and activate the neighbor (left-preference), or vault. @@ -549,5 +551,6 @@ export function AppView({ ctx }: { ctx: AppViewContext }) { ); }} + ); } diff --git a/application/i18n/locales/en/terminal.ts b/application/i18n/locales/en/terminal.ts index 2e2e9f35..d4caa201 100644 --- a/application/i18n/locales/en/terminal.ts +++ b/application/i18n/locales/en/terminal.ts @@ -544,6 +544,18 @@ export const enTerminalMessages: Messages = { 'snippets.shortkey.error.systemConflict': 'This shortcut conflicts with a system shortcut', 'snippets.shortkey.error.snippetConflict': 'This shortcut is already used by snippet: {name}', + 'snippets.variables.dialogTitle': 'Snippet variables', + 'snippets.variables.dialogDesc': 'Fill in values for "{label}" before running.', + 'snippets.variables.hint': 'Values are inserted as-is into the script (not shell-escaped).', + 'snippets.variables.preview': 'Preview', + 'snippets.variables.placeholder': 'Enter a value', + 'snippets.variables.placeholderDefault': 'Default: {value}', + 'snippets.variables.required': 'This variable is required', + 'snippets.variables.run': 'Run', + 'snippets.field.variablesHelp': 'Use {{name}} or {{name:default}} for placeholders in the script.', + 'snippets.field.variablesDetected': 'Variables', + 'snippets.field.variableDefault': 'default {value}', + // Serial Port 'serial.button': 'Serial', 'serial.modal.title': 'Connect to Serial Port', diff --git a/application/i18n/locales/zh-CN/terminal.ts b/application/i18n/locales/zh-CN/terminal.ts index 737a54ad..13843f7e 100644 --- a/application/i18n/locales/zh-CN/terminal.ts +++ b/application/i18n/locales/zh-CN/terminal.ts @@ -545,6 +545,18 @@ export const zhCNTerminalMessages: Messages = { 'snippets.shortkey.error.systemConflict': '此快捷键与系统快捷键冲突', 'snippets.shortkey.error.snippetConflict': '此快捷键已被代码片段使用:{name}', + 'snippets.variables.dialogTitle': '填写变量', + 'snippets.variables.dialogDesc': '运行「{label}」前请填写以下变量。', + 'snippets.variables.hint': '变量值将原样插入脚本(不会进行 shell 转义)。', + 'snippets.variables.preview': '预览', + 'snippets.variables.placeholder': '请输入', + 'snippets.variables.placeholderDefault': '默认:{value}', + 'snippets.variables.required': '请填写此变量', + 'snippets.variables.run': '运行', + 'snippets.field.variablesHelp': '在脚本中使用 {{名称}} 或 {{名称:默认值}} 定义变量。', + 'snippets.field.variablesDetected': '变量', + 'snippets.field.variableDefault': '默认 {value}', + // Serial Port 'serial.button': '串口', 'serial.modal.title': '连接串口', diff --git a/application/state/snippetVariableValues.ts b/application/state/snippetVariableValues.ts new file mode 100644 index 00000000..95ec2718 --- /dev/null +++ b/application/state/snippetVariableValues.ts @@ -0,0 +1,4 @@ +export { + readSnippetVariableValuesForSnippet, + saveSnippetVariableValues, +} from '../../infrastructure/persistence/snippetVariableValuesStorage'; diff --git a/application/state/useSessionState.ts b/application/state/useSessionState.ts index d33d688c..b880180a 100644 --- a/application/state/useSessionState.ts +++ b/application/state/useSessionState.ts @@ -731,8 +731,9 @@ export const useSessionState = () => { }, [workspaces]); // Run a snippet on multiple target hosts - creates a focus mode workspace - const runSnippet = useCallback((snippet: Snippet, targetHosts: Host[]) => { + const runSnippet = useCallback((snippet: Snippet, targetHosts: Host[], commandOverride?: string) => { if (targetHosts.length === 0) return; + const resolvedCommand = commandOverride ?? snippet.command; // Create sessions for each target host const newSessions: TerminalSession[] = targetHosts.map(host => ({ @@ -760,7 +761,7 @@ export const useSessionState = () => { ...s, workspaceId: workspace.id, // Store the command to run after connection - startupCommand: snippet.command, + startupCommand: resolvedCommand, noAutoRun: snippet.noAutoRun, })); diff --git a/components/QuickAddSnippetDialog.tsx b/components/QuickAddSnippetDialog.tsx index fcaffd26..906be40d 100644 --- a/components/QuickAddSnippetDialog.tsx +++ b/components/QuickAddSnippetDialog.tsx @@ -170,6 +170,7 @@ export const QuickAddSnippetDialog: React.FC = ({ onChange={(e) => setLabel(e.target.value)} placeholder={t('snippets.field.descriptionPlaceholder')} className="h-9" + spellCheck={false} /> diff --git a/components/ScriptsSidePanel.tsx b/components/ScriptsSidePanel.tsx index f412dc04..ed4c4ab3 100644 --- a/components/ScriptsSidePanel.tsx +++ b/components/ScriptsSidePanel.tsx @@ -25,7 +25,7 @@ import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from './ui/t interface ScriptsSidePanelProps { snippets: Snippet[]; packages: string[]; - onSnippetClick: (command: string, noAutoRun?: boolean) => void; + onSnippetClick: (snippet: Snippet) => void; isVisible?: boolean; } @@ -203,8 +203,8 @@ const ScriptsSidePanelInner: React.FC = ({ }, [normalizedPackages, snippets, expandedPaths, searchMatches]); const handleSnippetClick = useCallback( - (command: string, noAutoRun?: boolean) => { - onSnippetClick(command, noAutoRun); + (snippet: Snippet) => { + onSnippetClick(snippet); }, [onSnippetClick], ); @@ -282,7 +282,7 @@ const ScriptsSidePanelInner: React.FC = ({ snippet={s} depth={0} subtitle={s.package || t('terminal.toolbar.library')} - onClick={() => handleSnippetClick(s.command, s.noAutoRun)} + onClick={() => handleSnippetClick(s)} onEdit={() => handleEditSnippet(s)} onDelete={() => handleDeleteSnippet(s.id)} editLabel={t('action.edit')} @@ -305,7 +305,7 @@ const ScriptsSidePanelInner: React.FC = ({ key={`snip:${row.id}`} snippet={row.snippet} depth={row.depth} - onClick={() => handleSnippetClick(row.snippet.command, row.snippet.noAutoRun)} + onClick={() => handleSnippetClick(row.snippet)} onEdit={() => handleEditSnippet(row.snippet)} onDelete={() => handleDeleteSnippet(row.snippet.id)} editLabel={t('action.edit')} diff --git a/components/SnippetExecutionProvider.tsx b/components/SnippetExecutionProvider.tsx new file mode 100644 index 00000000..d1cc6867 --- /dev/null +++ b/components/SnippetExecutionProvider.tsx @@ -0,0 +1,234 @@ +import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { useI18n } from '../application/i18n/I18nProvider'; +import type { Snippet } from '../domain/models'; +import { + applySnippetVariables, + parseSnippetVariables, + previewSnippetCommand, + snippetHasVariables, + type SnippetVariableDef, +} from '../domain/snippetVariables'; +import { + readSnippetVariableValuesForSnippet, + saveSnippetVariableValues, +} from '../application/state/snippetVariableValues'; +import { Button } from './ui/button'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from './ui/dialog'; +import { Input } from './ui/input'; +import { Label } from './ui/label'; +import { ScrollArea } from './ui/scroll-area'; + +interface PendingPrompt { + snippet: Snippet; + variables: SnippetVariableDef[]; + resolve: (values: Record | null) => void; +} + +function buildInitialValues( + snippet: Snippet, + variables: SnippetVariableDef[], +): Record { + const cached = readSnippetVariableValuesForSnippet(snippet.id); + const values: Record = {}; + for (const def of variables) { + if (cached[def.name] !== undefined) { + values[def.name] = cached[def.name]; + } else if (def.defaultValue !== undefined) { + values[def.name] = def.defaultValue; + } else { + values[def.name] = ''; + } + } + return values; +} + +function isFormValid( + variables: SnippetVariableDef[], + values: Record, +): boolean { + for (const def of variables) { + const raw = values[def.name] ?? ''; + if (raw.trim() === '' && def.defaultValue === undefined) { + return false; + } + } + return true; +} + +export const SnippetExecutionProvider: React.FC<{ children: React.ReactNode }> = ({ + children, +}) => { + const { t } = useI18n(); + const [pending, setPending] = useState(null); + const [values, setValues] = useState>({}); + const pendingRef = useRef(null); + pendingRef.current = pending; + + const prompt = useCallback( + (snippet: Snippet) => + new Promise | null>((resolve) => { + const prior = pendingRef.current; + if (prior) prior.resolve(null); + + const variables = parseSnippetVariables(snippet.command); + if (variables.length === 0) { + resolve({}); + return; + } + + setValues(buildInitialValues(snippet, variables)); + setPending({ snippet, variables, resolve }); + }), + [], + ); + + useEffect(() => { + promptSnippetVariablesSingleton = prompt; + return () => { + promptSnippetVariablesSingleton = null; + }; + }, [prompt]); + + useEffect(() => () => { + const prior = pendingRef.current; + if (prior) { + prior.resolve(null); + pendingRef.current = null; + } + }, []); + + const preview = useMemo(() => { + if (!pending) return ''; + return previewSnippetCommand(pending.snippet.command, values); + }, [pending, values]); + + const canSubmit = pending ? isFormValid(pending.variables, values) : false; + + const closeWith = useCallback((result: Record | null) => { + if (!pending) return; + pending.resolve(result); + setPending(null); + }, [pending]); + + const handleSubmit = useCallback(() => { + if (!pending || !canSubmit) return; + const result = applySnippetVariables(pending.snippet.command, values); + if (!result.ok) return; + saveSnippetVariableValues(pending.snippet.id, values); + closeWith(values); + }, [pending, canSubmit, values, closeWith]); + + const handleKeyDown = useCallback( + (e: React.KeyboardEvent) => { + if (e.key === 'Enter' && !e.shiftKey && canSubmit) { + e.preventDefault(); + handleSubmit(); + } + }, + [canSubmit, handleSubmit], + ); + + return ( + <> + {children} + { if (!open) closeWith(null); }}> + + + {t('snippets.variables.dialogTitle')} + + {t('snippets.variables.dialogDesc', { label: pending?.snippet.label ?? '' })} + + + + {pending && ( +
+

{t('snippets.variables.hint')}

+
+ {pending.variables.map((def) => { + const raw = values[def.name] ?? ''; + const invalid = raw.trim() === '' && def.defaultValue === undefined; + return ( +
+ + { + const next = e.target.value; + setValues((prev) => ({ ...prev, [def.name]: next })); + }} + className={invalid ? 'border-destructive' : undefined} + autoFocus={def === pending.variables[0]} + /> + {invalid && ( +

{t('snippets.variables.required')}

+ )} +
+ ); + })} +
+ +
+

+ {t('snippets.variables.preview')} +

+ +
+                    {preview}
+                  
+
+
+
+ )} + + + + + +
+
+ + ); +}; + +let promptSnippetVariablesSingleton: + | ((snippet: Snippet) => Promise | null>) + | null = null; + +export async function resolveSnippetCommand(snippet: Snippet): Promise { + if (!snippetHasVariables(snippet.command)) { + return snippet.command; + } + + const promptFn = promptSnippetVariablesSingleton; + if (!promptFn) { + return snippet.command; + } + + const values = await promptFn(snippet); + if (values === null) { + return null; + } + + const result = applySnippetVariables(snippet.command, values); + if (!result.ok) { + return null; + } + return result.command; +} diff --git a/components/SnippetsRightPanel.tsx b/components/SnippetsRightPanel.tsx index d9a0b972..3fb317f4 100644 --- a/components/SnippetsRightPanel.tsx +++ b/components/SnippetsRightPanel.tsx @@ -1,4 +1,5 @@ -import React from 'react'; +import React, { useMemo } from 'react'; +import { parseSnippetVariables } from '../domain/snippetVariables'; import { Check, Clock, Keyboard, Loader2, Package, RotateCcw, Trash2 } from 'lucide-react'; import { cn } from '../lib/utils'; import SelectHostPanel from './SelectHostPanel'; @@ -54,6 +55,11 @@ export const SnippetsRightPanel: React.FC = ({ isLoadingMore, loadMoreHistory, }) => { + const detectedVariables = useMemo( + () => parseSnippetVariables(editingSnippet?.command || ''), + [editingSnippet?.command], + ); + if (rightPanelMode === 'select-targets') { return ( = ({ value={editingSnippet.label || ''} onChange={(e) => setEditingSnippet({ ...editingSnippet, label: e.target.value })} className="h-10" + spellCheck={false} /> @@ -165,6 +172,29 @@ export const SnippetsRightPanel: React.FC = ({ value={editingSnippet.command || ''} onChange={(e) => setEditingSnippet({ ...editingSnippet, command: e.target.value })} /> +

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

+ {detectedVariables.length > 0 && ( +
+ + {t('snippets.field.variablesDetected')}: + + {detectedVariables.map((variable) => ( + + {variable.name} + {variable.defaultValue !== undefined && ( + + ({t('snippets.field.variableDefault', { value: variable.defaultValue })}) + + )} + + ))} +
+ )} {/* No Auto Run */} diff --git a/components/Terminal.tsx b/components/Terminal.tsx index f138bde2..a62f12ba 100644 --- a/components/Terminal.tsx +++ b/components/Terminal.tsx @@ -11,8 +11,10 @@ import { logger } from "../lib/logger"; import { cn, normalizeLineEndings, wrapBracketedPaste } from "../lib/utils"; import { Host, + Snippet, TerminalSession, } from "../types"; +import { resolveSnippetCommand } from "./SnippetExecutionProvider"; import { shouldEnableNativeUserInputAutoScroll, shouldScrollOnTerminalInput, @@ -745,6 +747,15 @@ const TerminalComponent: React.FC = ({ term.focus(); }, [scrollToBottomAfterProgrammaticInput, terminalBackend, sessionId]); + const executeSnippet = useCallback(async (snippet: Snippet) => { + const command = await resolveSnippetCommand(snippet); + if (command === null) return; + executeSnippetCommand(command, snippet.noAutoRun); + }, [executeSnippetCommand]); + + const onSnippetShortkeyRef = useRef(executeSnippet); + onSnippetShortkeyRef.current = executeSnippet; + const terminalContextActions = useTerminalContextActions({ termRef, sourceSessionId: sessionId, @@ -975,9 +986,9 @@ const TerminalComponent: React.FC = ({ ['--terminal-ui-toolbar-btn-active' as never]: `var(--terminal-preview-toolbar-btn-active, color-mix(in srgb, ${effectiveTheme.colors.cursor} 78%, ${effectiveTheme.colors.background} 22%))`, }), [effectiveTheme.colors.background, effectiveTheme.colors.cursor, effectiveTheme.colors.foreground]); - useTerminalEffects({ CONNECTION_TIMEOUT, Error, XTERM_PERFORMANCE_CONFIG, applyUserCursorPreference, auth, autocompleteCloseRef, autocompleteInputRef, autocompleteKeyEventRef, captureTerminalLogData, clearTerminalCwd, commandBufferRef, connectionLogBufferRef, containerRef, createPromptLineBreakState, createReplaySafeTerminalLogSanitizer, createXTermRuntime, effectiveFontSize, effectiveFontWeight, effectiveTheme, error, executeSnippetCommand, fitAddonRef, fontFamilyId, fontSize, fontWeightFixupDoneRef, forceSyncRenderAfterResize, handleOsc52ReadRequest, handleTerminalDataCaptureOnce, hasConnectedRef, host, hotkeySchemeRef, identities, inWorkspace, isBroadcastEnabledRef, isFocusMode, isFocused, isLocalConnection, isNetworkDevice, isResizing, isRestoringSelectionRef, isSearchOpen, isSerialConnection, isVisible, isVisibleRef, keyBindingsRef, keys, knownCwdRef, lastFittedSizeRef, lastToastedErrorRef, logger, mouseTrackingRef, onBroadcastInputRef, onCommandExecuted, onHotkeyActionRef, onSnippetExecutorChange, onTerminalCwdChange, pendingAuthRef, pendingOutputScrollRef, prevIsResizingRef, primaryFontFamily, promptLineBreakStateRef, resizeSession, resolveHostAuth, resolvedFontFamily, safeFit, searchAddonRef, serialConfig, serialLineBufferRef, serializeAddonRef, sessionId, sessionRef, sessionStarters, setError, setHasMouseTracking, setHasSelection, setIsCancelling, setIsDisconnectedDialogDismissed, setIsSearchOpen, setNeedsHostKeyVerification, setPendingHostKeyInfo, setPendingHostKeyRequestId, setProgressLogs, setProgressValue, setShowLogs, setStatus, setTimeLeft, shouldEnableNativeUserInputAutoScroll, shouldProbeSessionCwd, snippetsRef, status, statusRef, t, teardown, termRef, terminalAltKeyOptions, terminalBackend, terminalContextActionsRef, terminalCwdTracker, terminalDataCapturedRef, terminalLogSanitizerRef, terminalSettings, terminalSettingsRef, toHostKeyInfo, toast, updateStatus, useEffect, useLayoutEffect, xtermRuntimeRef, zmodem, zmodemToastedRef }); + useTerminalEffects({ CONNECTION_TIMEOUT, Error, XTERM_PERFORMANCE_CONFIG, applyUserCursorPreference, auth, autocompleteCloseRef, autocompleteInputRef, autocompleteKeyEventRef, captureTerminalLogData, clearTerminalCwd, commandBufferRef, connectionLogBufferRef, containerRef, createPromptLineBreakState, createReplaySafeTerminalLogSanitizer, createXTermRuntime, effectiveFontSize, effectiveFontWeight, effectiveTheme, error, executeSnippetCommand, fitAddonRef, fontFamilyId, fontSize, fontWeightFixupDoneRef, forceSyncRenderAfterResize, handleOsc52ReadRequest, handleTerminalDataCaptureOnce, hasConnectedRef, host, hotkeySchemeRef, identities, inWorkspace, isBroadcastEnabledRef, isFocusMode, isFocused, isLocalConnection, isNetworkDevice, isResizing, isRestoringSelectionRef, isSearchOpen, isSerialConnection, isVisible, isVisibleRef, keyBindingsRef, keys, knownCwdRef, lastFittedSizeRef, lastToastedErrorRef, logger, mouseTrackingRef, onBroadcastInputRef, onCommandExecuted, onHotkeyActionRef, onSnippetShortkeyRef, onSnippetExecutorChange, onTerminalCwdChange, pendingAuthRef, pendingOutputScrollRef, prevIsResizingRef, primaryFontFamily, promptLineBreakStateRef, resizeSession, resolveHostAuth, resolvedFontFamily, safeFit, searchAddonRef, serialConfig, serialLineBufferRef, serializeAddonRef, sessionId, sessionRef, sessionStarters, setError, setHasMouseTracking, setHasSelection, setIsCancelling, setIsDisconnectedDialogDismissed, setIsSearchOpen, setNeedsHostKeyVerification, setPendingHostKeyInfo, setPendingHostKeyRequestId, setProgressLogs, setProgressValue, setShowLogs, setStatus, setTimeLeft, shouldEnableNativeUserInputAutoScroll, shouldProbeSessionCwd, snippetsRef, status, statusRef, t, teardown, termRef, terminalAltKeyOptions, terminalBackend, terminalContextActionsRef, terminalCwdTracker, terminalDataCapturedRef, terminalLogSanitizerRef, terminalSettings, terminalSettingsRef, toHostKeyInfo, toast, updateStatus, useEffect, useLayoutEffect, xtermRuntimeRef, zmodem, zmodemToastedRef }); - return ; + return ; }; const Terminal = memo(TerminalComponent); diff --git a/components/TerminalLayer.tsx b/components/TerminalLayer.tsx index 91d3b09a..8dcf02be 100644 --- a/components/TerminalLayer.tsx +++ b/components/TerminalLayer.tsx @@ -28,6 +28,8 @@ import { Tooltip, TooltipContent, TooltipTrigger } from './ui/tooltip'; import { useI18n } from '../application/i18n/I18nProvider'; import { SftpSidePanel } from './SftpSidePanel'; import { ScriptsSidePanel } from './ScriptsSidePanel'; +import { resolveSnippetCommand } from './SnippetExecutionProvider'; +import type { Snippet } from '../types'; import { ThemeSidePanel } from './terminal/ThemeSidePanel'; import { focusTerminalSessionInput } from './terminal/focusTerminalSession'; import { TerminalComposeBar } from './terminal/TerminalComposeBar'; @@ -846,6 +848,12 @@ const TerminalLayerInner: React.FC = ({ const textarea = pane?.querySelector('textarea.xterm-helper-textarea') as HTMLTextAreaElement | null; textarea?.focus(); }, [activeWorkspace?.focusedSessionId, activeSession?.id, terminalBackend]); + + const handleSnippetFromPanel = useCallback(async (snippet: Snippet) => { + const command = await resolveSnippetCommand(snippet); + if (command === null) return; + handleSnippetClickForFocusedSession(command, snippet.noAutoRun); + }, [handleSnippetClickForFocusedSession]); const { activeTopTabsThemeId, appliedPreviewSessionRef, @@ -958,7 +966,7 @@ const TerminalLayerInner: React.FC = ({ const prevFocusedSessionIdRef = useRef(undefined); useTerminalLayerEffects({ activeSidePanelTab, activeTabId, activeTabIdRef, activeTopTabsThemeId, activeWorkspace, activityTrackedSessions, appliedPreviewSessionRef, applyTerminalPreviewVars, applyTopTabsPreviewVars, cancelAnimationFrame, ChunkedEscapeFilter, clearTerminalPreviewVars, clearTimeout, clearTopTabsPreviewVars, document, dropHint, filterTabsMap, focusedSessionId, followAppTerminalTheme, getSessionActivityIdsToClear, handleOpenAI, handleToggleScriptsSidePanel, handleToggleSidePanel, hasNotifiableTerminalOutput, isFocusMode, isTerminalLayerVisible, lastSidePanelTabRef, Map, Math, onSessionData, onSplitSessionRef, onToggleBroadcastRef, onToggleWorkspaceViewModeRef, onUpdateSplitSizes, prevFocusedSessionIdRef, previewTargetSessionId, requestAnimationFrame, ResizeObserver, resizing, sessionActivityStore, sessions, Set, setDropHint, setResizing, setSftpHostForTab, setSftpInitialLocationForTab, setSftpPendingUploadsForTab, setSidePanelOpenTabs, setThemePreview, setTimeout, setupMcpApprovalBridge, setWorkspaceArea, sftpActiveHost, sftpHostForTab, shouldMarkSessionActivity, sidePanelOpenTabs, splitHorizontalHandlersRef, splitVerticalHandlersRef, terminalRendererCwdBySessionRef, themeCommitTimerRef, themePreview, toggleScriptsSidePanelRef, toggleSidePanelRef, validAIScopeTargetIds, validSessionActivityIds, visibleFocusedThemeId, window, workspaceBroadcastHandlersRef, workspaceFocusHandlersRef, workspaceInnerRef, workspaces }); - return ; + return ; }; export const TerminalLayer = memo(TerminalLayerInner, terminalLayerAreEqual); diff --git a/components/terminal/TerminalView.tsx b/components/terminal/TerminalView.tsx index 98e40ab6..e2683013 100644 --- a/components/terminal/TerminalView.tsx +++ b/components/terminal/TerminalView.tsx @@ -4,7 +4,7 @@ import React from 'react'; type TerminalViewContext = Record; export function TerminalView({ ctx }: { ctx: TerminalViewContext }) { - const { ArrowDownToLine, ArrowUpFromLine, Button, Copy, Cpu, HardDrive, HoverCard, HoverCardContent, HoverCardTrigger, Maximize2, MemoryStick, Radio, TerminalAutocomplete, TerminalComposeBar, TerminalConnectionDialog, TerminalContextMenu, TerminalSearchBar, Tooltip, TooltipContent, TooltipTrigger, ZmodemOverwriteDialog, ZmodemProgressIndicator, auth, autocompleteAcceptTextRef, autocompleteCloseRef, autocompleteHostOs, autocompleteInputRef, autocompleteKeyEventRef, autocompleteRepositionRef, autocompleteSettings, chainProgress, cn, containerRef, effectiveTheme, error, executeSnippetCommand, formatNetSpeed, handleCancelConnect, handleCloseDisconnectedSession, handleCloseSearch, handleDismissDisconnectedDialog, handleDragEnter, handleDragLeave, handleDragOver, handleDrop, handleFindNext, handleFindPrevious, handleHostKeyAddAndContinue, handleHostKeyClose, handleHostKeyContinue, handleOsc52ReadResponse, handleRetry, handleSearch, handleTopOverlayMouseDownCapture, hasMouseTracking, hasSelection, host, hotkeyScheme, inWorkspace, isBroadcastEnabled, isCancelling, isComposeBarOpen, isDraggingOver, isFocusMode, isLocalConnection, isSearchOpen, isVisible, keyBindings, keys, knownCwdRef, needsHostKeyVerification, onBroadcastInput, onCloseSession, onExpandToFocus, onSplitHorizontal, onSplitVertical, onToggleBroadcast, osc52ReadPromptVisible, pendingHostKeyInfo, progressLogs, progressValue, renderControls, scrollToBottomAfterProgrammaticInput, searchMatchCount, serverStats, sessionId, sessionRef, setIsComposeBarOpen, setShowLogs, shouldShowConnectionDialog, showLogs, snippets, status, statusDotTone, t, termRef, terminalBackend, terminalContextActions, terminalCwdTracker, terminalPreviewVars, terminalSettings, timeLeft, toast, zmodem } = ctx; + const { ArrowDownToLine, ArrowUpFromLine, Button, Copy, Cpu, HardDrive, HoverCard, HoverCardContent, HoverCardTrigger, Maximize2, MemoryStick, Radio, TerminalAutocomplete, TerminalComposeBar, TerminalConnectionDialog, TerminalContextMenu, TerminalSearchBar, Tooltip, TooltipContent, TooltipTrigger, ZmodemOverwriteDialog, ZmodemProgressIndicator, auth, autocompleteAcceptTextRef, autocompleteCloseRef, autocompleteHostOs, autocompleteInputRef, autocompleteKeyEventRef, autocompleteRepositionRef, autocompleteSettings, chainProgress, cn, containerRef, effectiveTheme, error, executeSnippet, formatNetSpeed, handleCancelConnect, handleCloseDisconnectedSession, handleCloseSearch, handleDismissDisconnectedDialog, handleDragEnter, handleDragLeave, handleDragOver, handleDrop, handleFindNext, handleFindPrevious, handleHostKeyAddAndContinue, handleHostKeyClose, handleHostKeyContinue, handleOsc52ReadResponse, handleRetry, handleSearch, handleTopOverlayMouseDownCapture, hasMouseTracking, hasSelection, host, hotkeyScheme, inWorkspace, isBroadcastEnabled, isCancelling, isComposeBarOpen, isDraggingOver, isFocusMode, isLocalConnection, isSearchOpen, isVisible, keyBindings, keys, knownCwdRef, needsHostKeyVerification, onBroadcastInput, onCloseSession, onExpandToFocus, onSplitHorizontal, onSplitVertical, onToggleBroadcast, osc52ReadPromptVisible, pendingHostKeyInfo, progressLogs, progressValue, renderControls, scrollToBottomAfterProgrammaticInput, searchMatchCount, serverStats, sessionId, sessionRef, setIsComposeBarOpen, setShowLogs, shouldShowConnectionDialog, showLogs, snippets, status, statusDotTone, t, termRef, terminalBackend, terminalContextActions, terminalCwdTracker, terminalPreviewVars, terminalSettings, timeLeft, toast, zmodem } = ctx; return ( terminalCwdTracker.getRendererCwd() ?? knownCwdRef.current} onAcceptText={(text) => autocompleteAcceptTextRef.current?.(text)} snippets={snippets} - onAcceptSnippet={(snippet) => executeSnippetCommand(snippet.command, snippet.noAutoRun)} + onAcceptSnippet={(snippet) => void executeSnippet(snippet)} visible={isVisible} themeColors={effectiveTheme.colors} containerRef={containerRef} diff --git a/components/terminal/runtime/createXTermRuntime.ts b/components/terminal/runtime/createXTermRuntime.ts index 9092aea0..0273063a 100644 --- a/components/terminal/runtime/createXTermRuntime.ts +++ b/components/terminal/runtime/createXTermRuntime.ts @@ -29,7 +29,7 @@ import { resolveHostTerminalFontWeight, } from "../../../domain/terminalAppearance"; import { logger } from "../../../lib/logger"; -import { isMacPlatform, normalizeLineEndings, wrapBracketedPaste } from "../../../lib/utils"; +import { isMacPlatform } from "../../../lib/utils"; import { netcattyBridge } from "../../../infrastructure/services/netcattyBridge"; import { clearTerminalViewport, @@ -64,7 +64,7 @@ import type { TerminalSettings, TerminalTheme, } from "../../../types"; -import { matchesKeyBinding } from "../../../domain/models"; +import { matchesKeyBinding, type Snippet } from "../../../domain/models"; type TerminalBackendApi = { openExternalAvailable: () => boolean; @@ -113,7 +113,8 @@ export type CreateXTermRuntimeContext = { >; // Snippets for shortkey support - snippetsRef?: RefObject<{ id: string; command: string; shortkey?: string }[]>; + snippetsRef?: RefObject<{ id: string; command: string; shortkey?: string; noAutoRun?: boolean }[]>; + onSnippetShortkeyRef?: RefObject<((snippet: Snippet) => void) | undefined>; sessionId: string; statusRef: RefObject; @@ -555,25 +556,9 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime if (id && ctx.statusRef.current === "connected") { e.preventDefault(); e.stopPropagation(); - // Send the snippet command to the terminal - let snippetData = normalizeLineEndings(snippet.command); - if (!snippet.noAutoRun) snippetData = `${snippetData}\r`; - // Broadcast the normalized (un-wrapped) data so each target - // session can apply its own bracket paste state - if (ctx.isBroadcastEnabledRef.current && ctx.onBroadcastInputRef.current) { - ctx.onBroadcastInputRef.current(snippetData, ctx.sessionId); - } - // Wrap for this terminal only, after broadcasting - const snippetIsMultiLine = snippetData.includes("\n"); - if (snippetIsMultiLine && term.modes.bracketedPasteMode && !ctx.terminalSettingsRef.current?.disableBracketedPaste) snippetData = wrapBracketedPaste(snippetData); - // Notify autocomplete with the final (possibly bracket-wrapped) - // bytes so its keystroke buffer can tell literal multi-line - // paste ("\x1b[200~...\x1b[201~") from the non-bracketed path - // where each \n executes an intermediate command (#814 P2). - ctx.onAutocompleteInput?.(snippetData); - ctx.terminalBackend.writeToSession(id, snippetData); - if (!snippet.noAutoRun) { - recordTerminalCommandExecution(snippet.command, ctx, term); + const runSnippet = ctx.onSnippetShortkeyRef?.current; + if (runSnippet) { + void runSnippet(snippet as Snippet); } return false; } diff --git a/components/terminal/useTerminalEffects.ts b/components/terminal/useTerminalEffects.ts index a9fbfa40..816f1d2c 100644 --- a/components/terminal/useTerminalEffects.ts +++ b/components/terminal/useTerminalEffects.ts @@ -3,7 +3,7 @@ type TerminalEffectsContext = Record; 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, effectiveFontSize, effectiveFontWeight, effectiveTheme, error, executeSnippetCommand, fitAddonRef, fontFamilyId, fontSize, fontWeightFixupDoneRef, forceSyncRenderAfterResize, handleOsc52ReadRequest, handleTerminalDataCaptureOnce, hasConnectedRef, host, hotkeySchemeRef, identities, inWorkspace, isBroadcastEnabledRef, isFocusMode, isFocused, isLocalConnection, isNetworkDevice, isResizing, isRestoringSelectionRef, isSearchOpen, isSerialConnection, isVisible, isVisibleRef, keyBindingsRef, keys, knownCwdRef, lastFittedSizeRef, lastToastedErrorRef, logger, mouseTrackingRef, onBroadcastInputRef, onCommandExecuted, onHotkeyActionRef, onSnippetExecutorChange, onTerminalCwdChange, pendingAuthRef, pendingOutputScrollRef, prevIsResizingRef, primaryFontFamily, promptLineBreakStateRef, resizeSession, resolveHostAuth, resolvedFontFamily, safeFit, searchAddonRef, serialConfig, serialLineBufferRef, serializeAddonRef, sessionId, sessionRef, sessionStarters, setError, setHasMouseTracking, setHasSelection, setIsCancelling, setIsDisconnectedDialogDismissed, setIsSearchOpen, setNeedsHostKeyVerification, setPendingHostKeyInfo, setPendingHostKeyRequestId, setProgressLogs, setProgressValue, setShowLogs, setStatus, setTimeLeft, shouldEnableNativeUserInputAutoScroll, shouldProbeSessionCwd, snippetsRef, status, statusRef, t, teardown, termRef, terminalAltKeyOptions, terminalBackend, terminalContextActionsRef, terminalCwdTracker, terminalDataCapturedRef, terminalLogSanitizerRef, terminalSettings, terminalSettingsRef, toHostKeyInfo, toast, updateStatus, useEffect, useLayoutEffect, xtermRuntimeRef, zmodem, zmodemToastedRef } = ctx; + const { CONNECTION_TIMEOUT, Error, XTERM_PERFORMANCE_CONFIG, applyUserCursorPreference, auth, autocompleteCloseRef, autocompleteInputRef, autocompleteKeyEventRef, captureTerminalLogData, clearTerminalCwd, commandBufferRef, connectionLogBufferRef, containerRef, createPromptLineBreakState, createReplaySafeTerminalLogSanitizer, createXTermRuntime, effectiveFontSize, effectiveFontWeight, effectiveTheme, error, executeSnippetCommand, fitAddonRef, fontFamilyId, fontSize, fontWeightFixupDoneRef, forceSyncRenderAfterResize, handleOsc52ReadRequest, handleTerminalDataCaptureOnce, hasConnectedRef, host, hotkeySchemeRef, identities, inWorkspace, isBroadcastEnabledRef, isFocusMode, isFocused, isLocalConnection, isNetworkDevice, isResizing, isRestoringSelectionRef, isSearchOpen, isSerialConnection, isVisible, isVisibleRef, keyBindingsRef, keys, knownCwdRef, lastFittedSizeRef, lastToastedErrorRef, logger, mouseTrackingRef, onBroadcastInputRef, onCommandExecuted, onHotkeyActionRef, onSnippetExecutorChange, onTerminalCwdChange, pendingAuthRef, pendingOutputScrollRef, prevIsResizingRef, primaryFontFamily, promptLineBreakStateRef, resizeSession, resolveHostAuth, resolvedFontFamily, safeFit, searchAddonRef, serialConfig, serialLineBufferRef, serializeAddonRef, sessionId, sessionRef, sessionStarters, setError, setHasMouseTracking, setHasSelection, setIsCancelling, setIsDisconnectedDialogDismissed, setIsSearchOpen, setNeedsHostKeyVerification, setPendingHostKeyInfo, setPendingHostKeyRequestId, setProgressLogs, setProgressValue, setShowLogs, setStatus, setTimeLeft, shouldEnableNativeUserInputAutoScroll, shouldProbeSessionCwd, onSnippetShortkeyRef, snippetsRef, status, statusRef, t, teardown, termRef, terminalAltKeyOptions, terminalBackend, terminalContextActionsRef, terminalCwdTracker, terminalDataCapturedRef, terminalLogSanitizerRef, terminalSettings, terminalSettingsRef, toHostKeyInfo, toast, updateStatus, useEffect, useLayoutEffect, xtermRuntimeRef, zmodem, zmodemToastedRef } = ctx; useEffect(() => { @@ -189,6 +189,7 @@ export function useTerminalEffects(ctx: TerminalEffectsContext) { isBroadcastEnabledRef, onBroadcastInputRef, snippetsRef, + onSnippetShortkeyRef, sessionId, statusRef, onCommandExecuted, diff --git a/components/terminalLayer/TerminalLayerView.tsx b/components/terminalLayer/TerminalLayerView.tsx index 7266371f..d2e1cd2a 100644 --- a/components/terminalLayer/TerminalLayerView.tsx +++ b/components/terminalLayer/TerminalLayerView.tsx @@ -4,7 +4,7 @@ import React from 'react'; type TerminalLayerViewContext = Record; export function TerminalLayerView({ ctx }: { ctx: TerminalLayerViewContext }) { - const { accentMode, activeResizers, activeSidePanelTab, activeTabId, activeWorkspace, AIChatPanelsHost, aiContextsByTabId, AIStateMaintenanceHost, AIStateProvider, Array, Button, cn, composeBarThemeColors, computeSplitHint, customAccent, draggingSessionId, dropHint, editorWordWrap, effectiveHosts, findSplitNode, focusedFontFamilyId, focusedFontFamilyOverridden, focusedFontSize, focusedFontSizeOverridden, focusedFontWeight, focusedFontWeightOverridden, focusedSessionId, focusedThemeOverridden, FolderTree, followAppTerminalTheme, fontSize, getTerminalCwd, handleAddKnownHost, handleBroadcastInput, handleCloseSession, handleCloseSidePanel, handleCommandExecuted, handleComposeSend, handleFontFamilyChangeForFocusedSession, handleFontFamilyResetForFocusedSession, handleFontSizeChangeForFocusedSession, handleFontSizeResetForFocusedSession, handleFontWeightChangeForFocusedSession, handleFontWeightResetForFocusedSession, handleOpenAI, handleOpenScripts, handleOpenSftp, handleOpenTheme, handleOsDetected, handlePendingUploadHandled, handleSessionExit, handleSftpInitialLocationApplied, handleSidePanelResizeStart, handleSnippetClickForFocusedSession, handleSnippetExecutorChange, handleStatusChange, handleTerminalCwdChange, handleTerminalDataCapture, handleThemeChangeForFocusedSession, handleThemeResetForFocusedSession, handleToggleSftpFromBar, handleToggleWorkspaceComposeBar, handleUpdateHost, handleWorkspaceDrop, hosts, hotkeyScheme, identities, isBroadcastEnabled, isComposeBarOpen, isFocusMode, isSidePanelOpenForCurrentTab, isTerminalLayerVisible, keyBindings, keys, knownHosts, MessageSquare, mountedAiTabIds, mountedSftpTabIds, onHotkeyAction, onSetWorkspaceFocusedSession, onSplitSession, Palette, PanelLeft, PanelRight, previewedOrVisibleThemeId, refocusActiveTerminalSession, refocusTerminalSession, renderFocusModeSidebar, resizing, resolveAIExecutorContext, resolvedPreviewTheme, ScriptsSidePanel, sessionChainHostsMap, sessionHostsMap, sessionLogConfig, sessions, setDropHint, setEditorWordWrap, setIsComposeBarOpen, setResizing, setSidePanelPosition, sftpActiveHost, sftpAutoSync, sftpDefaultViewMode, sftpDoubleClickBehavior, sftpInitialLocationForTab, sftpPendingUploadsForTab, sftpShowHiddenFiles, SftpSidePanel, sftpUseCompressedUpload, sidePanelPosition, sidePanelWidth, snippetPackages, snippets, splitHorizontalHandlersRef, splitVerticalHandlersRef, t, TerminalComposeBar, terminalFontFamilyId, TerminalPanesHost, terminalSettings, terminalTheme, themePreview, ThemeSidePanel, Tooltip, TooltipContent, TooltipTrigger, updateHosts, validAIScopeTargetIds, workspaceBroadcastHandlersRef, workspaceById, workspaceFocusHandlersRef, workspaceInnerRef, workspaceOuterRef, workspaceOverlayRef, workspaceRectsById, X, Zap } = ctx; + const { accentMode, activeResizers, activeSidePanelTab, activeTabId, activeWorkspace, AIChatPanelsHost, aiContextsByTabId, AIStateMaintenanceHost, AIStateProvider, Array, Button, cn, composeBarThemeColors, computeSplitHint, customAccent, draggingSessionId, dropHint, editorWordWrap, effectiveHosts, findSplitNode, focusedFontFamilyId, focusedFontFamilyOverridden, focusedFontSize, focusedFontSizeOverridden, focusedFontWeight, focusedFontWeightOverridden, focusedSessionId, focusedThemeOverridden, FolderTree, followAppTerminalTheme, fontSize, getTerminalCwd, handleAddKnownHost, handleBroadcastInput, handleCloseSession, handleCloseSidePanel, handleCommandExecuted, handleComposeSend, handleFontFamilyChangeForFocusedSession, handleFontFamilyResetForFocusedSession, handleFontSizeChangeForFocusedSession, handleFontSizeResetForFocusedSession, handleFontWeightChangeForFocusedSession, handleFontWeightResetForFocusedSession, handleOpenAI, handleOpenScripts, handleOpenSftp, handleOpenTheme, handleOsDetected, handlePendingUploadHandled, handleSessionExit, handleSftpInitialLocationApplied, handleSidePanelResizeStart, handleSnippetFromPanel, handleSnippetExecutorChange, handleStatusChange, handleTerminalCwdChange, handleTerminalDataCapture, handleThemeChangeForFocusedSession, handleThemeResetForFocusedSession, handleToggleSftpFromBar, handleToggleWorkspaceComposeBar, handleUpdateHost, handleWorkspaceDrop, hosts, hotkeyScheme, identities, isBroadcastEnabled, isComposeBarOpen, isFocusMode, isSidePanelOpenForCurrentTab, isTerminalLayerVisible, keyBindings, keys, knownHosts, MessageSquare, mountedAiTabIds, mountedSftpTabIds, onHotkeyAction, onSetWorkspaceFocusedSession, onSplitSession, Palette, PanelLeft, PanelRight, previewedOrVisibleThemeId, refocusActiveTerminalSession, refocusTerminalSession, renderFocusModeSidebar, resizing, resolveAIExecutorContext, resolvedPreviewTheme, ScriptsSidePanel, sessionChainHostsMap, sessionHostsMap, sessionLogConfig, sessions, setDropHint, setEditorWordWrap, setIsComposeBarOpen, setResizing, setSidePanelPosition, sftpActiveHost, sftpAutoSync, sftpDefaultViewMode, sftpDoubleClickBehavior, sftpInitialLocationForTab, sftpPendingUploadsForTab, sftpShowHiddenFiles, SftpSidePanel, sftpUseCompressedUpload, sidePanelPosition, sidePanelWidth, snippetPackages, snippets, splitHorizontalHandlersRef, splitVerticalHandlersRef, t, TerminalComposeBar, terminalFontFamilyId, TerminalPanesHost, terminalSettings, terminalTheme, themePreview, ThemeSidePanel, Tooltip, TooltipContent, TooltipTrigger, updateHosts, validAIScopeTargetIds, workspaceBroadcastHandlersRef, workspaceById, workspaceFocusHandlersRef, workspaceInnerRef, workspaceOuterRef, workspaceOverlayRef, workspaceRectsById, X, Zap } = ctx; return ( @@ -243,7 +243,7 @@ export function TerminalLayerView({ ctx }: { ctx: TerminalLayerViewContext }) { )} diff --git a/domain/snippetVariables.test.ts b/domain/snippetVariables.test.ts new file mode 100644 index 00000000..f41080fc --- /dev/null +++ b/domain/snippetVariables.test.ts @@ -0,0 +1,76 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +import { + applySnippetVariables, + parseSnippetVariables, + previewSnippetCommand, + snippetHasVariables, +} from "./snippetVariables.ts"; + +test("parseSnippetVariables finds all vars after snippetHasVariables (shared-regex lastIndex)", () => { + const command = "echo '{{test}}'\necho '{{test2}}'"; + assert.equal(snippetHasVariables(command), true); + assert.deepEqual(parseSnippetVariables(command).map((v) => v.name), ["test", "test2"]); +}); + +test("parseSnippetVariables returns empty for plain command", () => { + assert.deepEqual(parseSnippetVariables("ls -la"), []); + assert.equal(snippetHasVariables("ls -la"), false); +}); + +test("parseSnippetVariables dedupes by first occurrence order", () => { + assert.deepEqual(parseSnippetVariables("echo {{a}} and {{b}} and {{a}}"), [ + { name: "a" }, + { name: "b" }, + ]); +}); + +test("parseSnippetVariables reads default after colon", () => { + assert.deepEqual(parseSnippetVariables("fallocate -l {{内存大小:4}}G"), [ + { name: "内存大小", defaultValue: "4" }, + ]); +}); + +test("applySnippetVariables replaces all occurrences", () => { + const result = applySnippetVariables( + "fallocate -l {{内存大小:4}}G\nswapon {{内存大小:4}}", + { 内存大小: "8" }, + ); + assert.equal(result.ok, true); + if (result.ok) { + assert.equal(result.command, "fallocate -l 8G\nswapon 8"); + } +}); + +test("applySnippetVariables uses default when value empty", () => { + const result = applySnippetVariables("size {{n:2}}", { n: "" }); + assert.equal(result.ok, true); + if (result.ok) assert.equal(result.command, "size 2"); +}); + +test("applySnippetVariables reports missing required vars", () => { + const result = applySnippetVariables("echo {{name}}", {}); + assert.equal(result.ok, false); + if (!result.ok) assert.deepEqual(result.missing, ["name"]); +}); + +test("applySnippetVariables passes through command without variables", () => { + const result = applySnippetVariables("uptime", { x: "1" }); + assert.equal(result.ok, true); + if (result.ok) assert.equal(result.command, "uptime"); +}); + +test("previewSnippetCommand keeps placeholder for unfilled required", () => { + assert.equal( + previewSnippetCommand("echo {{a}}", {}), + "echo {{a}}", + ); +}); + +test("previewSnippetCommand shows resolved values", () => { + assert.equal( + previewSnippetCommand("echo {{a:hi}}", {}), + "echo hi", + ); +}); diff --git a/domain/snippetVariables.ts b/domain/snippetVariables.ts new file mode 100644 index 00000000..a43642ed --- /dev/null +++ b/domain/snippetVariables.ts @@ -0,0 +1,117 @@ +/** + * Parse and substitute {{variable}} / {{variable:default}} placeholders in snippet commands. + */ + +/** Non-global: safe to reuse; avoids lastIndex side effects across calls. */ +const VARIABLE_TOKEN = /\{\{([^}:]+)(?::([^}]*))?\}\}/; + +function variablePattern(): RegExp { + return /\{\{([^}:]+)(?::([^}]*))?\}\}/g; +} + +export interface SnippetVariableDef { + name: string; + defaultValue?: string; +} + +export function snippetHasVariables(command: string): boolean { + return VARIABLE_TOKEN.test(String(command ?? "")); +} + +export function parseSnippetVariables(command: string): SnippetVariableDef[] { + const text = String(command ?? ""); + const seen = new Set(); + const result: SnippetVariableDef[] = []; + + for (const match of text.matchAll(variablePattern())) { + const name = match[1]?.trim() ?? ""; + if (!name || seen.has(name)) continue; + seen.add(name); + const defaultRaw = match[2]; + result.push({ + name, + ...(defaultRaw !== undefined ? { defaultValue: defaultRaw } : {}), + }); + } + + return result; +} + +export type ApplySnippetVariablesResult = + | { ok: true; command: string } + | { ok: false; missing: string[] }; + +function resolveVariableValue( + def: SnippetVariableDef, + values: Record, +): string | undefined { + const raw = values[def.name]; + if (raw !== undefined && raw.trim() !== "") { + return raw; + } + if (def.defaultValue !== undefined) { + return def.defaultValue; + } + return undefined; +} + +export function applySnippetVariables( + command: string, + values: Record, +): ApplySnippetVariablesResult { + const defs = parseSnippetVariables(command); + if (defs.length === 0) { + return { ok: true, command: String(command ?? "") }; + } + + const missing: string[] = []; + const resolved: Record = {}; + + for (const def of defs) { + const value = resolveVariableValue(def, values); + if (value === undefined) { + missing.push(def.name); + } else { + resolved[def.name] = value; + } + } + + if (missing.length > 0) { + return { ok: false, missing }; + } + + let output = String(command ?? ""); + for (const def of defs) { + const value = resolved[def.name]; + const escapedName = def.name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + const pattern = new RegExp( + `\\{\\{${escapedName}(?::[^}]*)?\\}\\}`, + "g", + ); + output = output.replace(pattern, value); + } + + return { ok: true, command: output }; +} + +/** Preview resolved command for UI; unfilled required vars stay as placeholders. */ +export function previewSnippetCommand( + command: string, + values: Record, +): string { + const defs = parseSnippetVariables(command); + if (defs.length === 0) return String(command ?? ""); + + let output = String(command ?? ""); + for (const def of defs) { + const value = resolveVariableValue(def, values); + const replacement = value ?? `{{${def.name}}}`; + const escapedName = def.name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + const pattern = new RegExp( + `\\{\\{${escapedName}(?::[^}]*)?\\}\\}`, + "g", + ); + output = output.replace(pattern, replacement); + } + return output; +} diff --git a/infrastructure/config/storageKeys.ts b/infrastructure/config/storageKeys.ts index 3cf22f01..bbf0cdc7 100644 --- a/infrastructure/config/storageKeys.ts +++ b/infrastructure/config/storageKeys.ts @@ -4,6 +4,8 @@ export const STORAGE_KEY_GROUPS = 'netcatty_groups_v1'; export const STORAGE_KEY_CUSTOM_GROUPS = STORAGE_KEY_GROUPS; export const STORAGE_KEY_SNIPPETS = 'netcatty_snippets_v1'; export const STORAGE_KEY_SNIPPET_PACKAGES = 'netcatty_snippet_packages_v1'; +/** Last-filled values per snippet id for {{variable}} placeholders. */ +export const STORAGE_KEY_SNIPPET_VAR_VALUES = 'netcatty_snippet_var_values_v1'; export const STORAGE_KEY_THEME = 'netcatty_theme_v1'; export const STORAGE_KEY_COLOR = 'netcatty_color_v1'; export const STORAGE_KEY_ACCENT_MODE = 'netcatty_accent_mode_v1'; diff --git a/infrastructure/persistence/snippetVariableValuesStorage.ts b/infrastructure/persistence/snippetVariableValuesStorage.ts new file mode 100644 index 00000000..be5b6297 --- /dev/null +++ b/infrastructure/persistence/snippetVariableValuesStorage.ts @@ -0,0 +1,21 @@ +import { STORAGE_KEY_SNIPPET_VAR_VALUES } from '../config/storageKeys'; +import { localStorageAdapter } from './localStorageAdapter'; + +export type SnippetVariableValuesStore = Record>; + +export function readSnippetVariableValuesStore(): SnippetVariableValuesStore { + return localStorageAdapter.read(STORAGE_KEY_SNIPPET_VAR_VALUES) ?? {}; +} + +export function readSnippetVariableValuesForSnippet(snippetId: string): Record { + return readSnippetVariableValuesStore()[snippetId] ?? {}; +} + +export function saveSnippetVariableValues( + snippetId: string, + values: Record, +): void { + const store = readSnippetVariableValuesStore(); + store[snippetId] = { ...store[snippetId], ...values }; + localStorageAdapter.write(STORAGE_KEY_SNIPPET_VAR_VALUES, store); +}