feat(snippets): implement snippet variable handling and UI prompts (#1159)

This commit is contained in:
pplulee
2026-05-31 18:01:43 +08:00
committed by GitHub
parent 4d7c56e537
commit 03cd9bc968
20 changed files with 568 additions and 40 deletions

14
App.tsx
View File

@@ -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 <AppView ctx={{ accentMode, activeTabId, activeTerminalTheme, addShellHistoryEntry, addSessionToWorkspace, addToWorkspaceDialog, appendHostToWorkspace, appendLocalTerminalToWorkspace, clearAndRemoveSource, clearAndRemoveSources, clearUnsavedConnectionLogs, closeLogView, closeSession, closeTabsBatch, copySessionWithCurrentShell, closeWorkspace, connectionLogs, convertKnownHostToHost, createWorkspaceFromSessions, createWorkspaceFromTargets, createWorkspaceWithHosts, customAccent, customGroups, currentTerminalTheme, deleteConnectionLog, draggingSessionId, effectiveKnownHosts, editorTabs, editorWordWrap, emptyVaultConflict, followAppTerminalTheme, groupConfigs, handleAddKnownHost, handleConnectSerial, handleConnectToHost, handleCreateLocalTerminal, handleDeleteHost, handleEndSessionDrag, handleHostConnectWithProtocolCheck, handleHotkeyAction, handleKeyboardInteractiveCancel, handleKeyboardInteractiveSubmit, handleOpenQuickSwitcher, handleOpenSettings, handleRootContextMenu, handlePassphraseCancel, handlePassphraseSkip, handlePassphraseSubmit, handleProtocolSelect, handleRequestCloseEditorTabRef, handleSessionStatusChange, handleSyncNowManual, handleTerminalDataCapture, handleToggleTheme, handleUpdateHostFromTerminal, hostById, hosts, hotkeyScheme, identities, importOrReuseKey, isBroadcastEnabled, isCreateWorkspaceOpen, isMacClient, isQuickSwitcherOpen, keyBindings, keyboardInteractiveQueue, keys, logViews, managedSources, navigateToSection, openLogView, orderedTabsWithEditors, orphanSessions, passphraseQueue, protocolSelectHost, proxyProfiles, quickResults, quickSearch, reorderTabs, reorderWorkspaceSessions, resetSessionRename, resetWorkspaceRename, resolveEmptyVaultConflict, resolvedTheme, runSnippet, sessionLogsDir, sessionLogsEnabled, sessionLogsFormat, sessionRenameTarget, sessionRenameValue, sessions, setActiveTabId, setAddToWorkspaceDialog, setDraggingSessionId, setEditorWordWrap, setIsCreateWorkspaceOpen, setIsQuickSwitcherOpen, setNavigateToSection, setProtocolSelectHost, setQuickSearch, setSessionRenameValue, setTerminalFontFamilyId, setTerminalFontSize, setTerminalThemeId, setWorkspaceFocusedSession, setWorkspaceRenameValue, settings, sftpAutoOpenSidebar, sftpAutoSync, sftpDefaultViewMode, sftpDoubleClickBehavior, sftpShowHiddenFiles, sftpUseCompressedUpload, shellHistory, snippetPackages, snippets, splitSessionWithCurrentShell, startSessionRename, startWorkspaceRename, submitSessionRename, submitWorkspaceRename, t, terminalFontFamilyId, terminalFontSize, terminalSettings, terminalThemeId, toggleBroadcast, toggleConnectionLogSaved, toggleScriptsSidePanelRef, toggleSidePanelRef, toggleWorkspaceViewMode, unmanageSource, updateConnectionLog, updateCustomGroups, updateGroupConfigs, updateHostDistro, updateHosts, updateIdentities, updateKeys, updateKnownHosts, updateManagedSources, updateProxyProfiles, updateSnippetPackages, updateSnippets, updateSplitSizes, updateTerminalSetting, workspaceRenameTarget, workspaceRenameValue, workspaces, VaultViewContainer, SftpViewMount, TerminalLayerMount, LogViewWrapper }} />;
return <AppView ctx={{ accentMode, activeTabId, activeTerminalTheme, addShellHistoryEntry, addSessionToWorkspace, addToWorkspaceDialog, appendHostToWorkspace, appendLocalTerminalToWorkspace, clearAndRemoveSource, clearAndRemoveSources, clearUnsavedConnectionLogs, closeLogView, closeSession, closeTabsBatch, copySessionWithCurrentShell, closeWorkspace, connectionLogs, convertKnownHostToHost, createWorkspaceFromSessions, createWorkspaceFromTargets, createWorkspaceWithHosts, customAccent, customGroups, currentTerminalTheme, deleteConnectionLog, draggingSessionId, effectiveKnownHosts, editorTabs, editorWordWrap, emptyVaultConflict, followAppTerminalTheme, groupConfigs, handleAddKnownHost, handleConnectSerial, handleConnectToHost, handleCreateLocalTerminal, handleDeleteHost, handleEndSessionDrag, handleHostConnectWithProtocolCheck, handleHotkeyAction, handleKeyboardInteractiveCancel, handleKeyboardInteractiveSubmit, handleOpenQuickSwitcher, handleOpenSettings, handleRootContextMenu, handlePassphraseCancel, handlePassphraseSkip, handlePassphraseSubmit, handleProtocolSelect, handleRequestCloseEditorTabRef, handleSessionStatusChange, handleSyncNowManual, handleTerminalDataCapture, handleToggleTheme, handleUpdateHostFromTerminal, hostById, hosts, hotkeyScheme, identities, importOrReuseKey, isBroadcastEnabled, isCreateWorkspaceOpen, isMacClient, isQuickSwitcherOpen, keyBindings, keyboardInteractiveQueue, keys, logViews, managedSources, navigateToSection, openLogView, orderedTabsWithEditors, orphanSessions, passphraseQueue, protocolSelectHost, proxyProfiles, quickResults, quickSearch, reorderTabs, reorderWorkspaceSessions, resetSessionRename, resetWorkspaceRename, resolveEmptyVaultConflict, resolvedTheme, runSnippet: handleRunSnippet, sessionLogsDir, sessionLogsEnabled, sessionLogsFormat, sessionRenameTarget, sessionRenameValue, sessions, setActiveTabId, setAddToWorkspaceDialog, setDraggingSessionId, setEditorWordWrap, setIsCreateWorkspaceOpen, setIsQuickSwitcherOpen, setNavigateToSection, setProtocolSelectHost, setQuickSearch, setSessionRenameValue, setTerminalFontFamilyId, setTerminalFontSize, setTerminalThemeId, setWorkspaceFocusedSession, setWorkspaceRenameValue, settings, sftpAutoOpenSidebar, sftpAutoSync, sftpDefaultViewMode, sftpDoubleClickBehavior, sftpShowHiddenFiles, sftpUseCompressedUpload, shellHistory, snippetPackages, snippets, splitSessionWithCurrentShell, startSessionRename, startWorkspaceRename, submitSessionRename, submitWorkspaceRename, t, terminalFontFamilyId, terminalFontSize, terminalSettings, terminalThemeId, toggleBroadcast, toggleConnectionLogSaved, toggleScriptsSidePanelRef, toggleSidePanelRef, toggleWorkspaceViewMode, unmanageSource, updateConnectionLog, updateCustomGroups, updateGroupConfigs, updateHostDistro, updateHosts, updateIdentities, updateKeys, updateKnownHosts, updateManagedSources, updateProxyProfiles, updateSnippetPackages, updateSnippets, updateSplitSizes, updateTerminalSetting, workspaceRenameTarget, workspaceRenameValue, workspaces, VaultViewContainer, SftpViewMount, TerminalLayerMount, LogViewWrapper }} />;
}
function AppWithProviders() {

View File

@@ -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 (
<SnippetExecutionProvider>
<UnsavedChangesProvider>
{({ prompt }) => {
// Helper: close an editor tab and activate the neighbor (left-preference), or vault.
@@ -549,5 +551,6 @@ export function AppView({ ctx }: { ctx: AppViewContext }) {
);
}}
</UnsavedChangesProvider>
</SnippetExecutionProvider>
);
}

View File

@@ -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',

View File

@@ -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': '连接串口',

View File

@@ -0,0 +1,4 @@
export {
readSnippetVariableValuesForSnippet,
saveSnippetVariableValues,
} from '../../infrastructure/persistence/snippetVariableValuesStorage';

View File

@@ -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,
}));

View File

@@ -170,6 +170,7 @@ export const QuickAddSnippetDialog: React.FC<QuickAddSnippetDialogProps> = ({
onChange={(e) => setLabel(e.target.value)}
placeholder={t('snippets.field.descriptionPlaceholder')}
className="h-9"
spellCheck={false}
/>
</div>

View File

@@ -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<ScriptsSidePanelProps> = ({
}, [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<ScriptsSidePanelProps> = ({
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<ScriptsSidePanelProps> = ({
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')}

View File

@@ -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<string, string> | null) => void;
}
function buildInitialValues(
snippet: Snippet,
variables: SnippetVariableDef[],
): Record<string, string> {
const cached = readSnippetVariableValuesForSnippet(snippet.id);
const values: Record<string, string> = {};
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<string, string>,
): 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<PendingPrompt | null>(null);
const [values, setValues] = useState<Record<string, string>>({});
const pendingRef = useRef<PendingPrompt | null>(null);
pendingRef.current = pending;
const prompt = useCallback(
(snippet: Snippet) =>
new Promise<Record<string, string> | 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<string, string> | 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}
<Dialog open={!!pending} onOpenChange={(open) => { if (!open) closeWith(null); }}>
<DialogContent className="sm:max-w-[520px]" onKeyDown={handleKeyDown}>
<DialogHeader>
<DialogTitle>{t('snippets.variables.dialogTitle')}</DialogTitle>
<DialogDescription>
{t('snippets.variables.dialogDesc', { label: pending?.snippet.label ?? '' })}
</DialogDescription>
</DialogHeader>
{pending && (
<div className="space-y-4 py-1">
<p className="text-xs text-muted-foreground">{t('snippets.variables.hint')}</p>
<div className="space-y-3">
{pending.variables.map((def) => {
const raw = values[def.name] ?? '';
const invalid = raw.trim() === '' && def.defaultValue === undefined;
return (
<div key={def.name} className="space-y-1.5">
<Label htmlFor={`snippet-var-${def.name}`}>{def.name}</Label>
<Input
id={`snippet-var-${def.name}`}
value={raw}
placeholder={
def.defaultValue !== undefined
? t('snippets.variables.placeholderDefault', { value: def.defaultValue })
: t('snippets.variables.placeholder')
}
onChange={(e) => {
const next = e.target.value;
setValues((prev) => ({ ...prev, [def.name]: next }));
}}
className={invalid ? 'border-destructive' : undefined}
autoFocus={def === pending.variables[0]}
/>
{invalid && (
<p className="text-xs text-destructive">{t('snippets.variables.required')}</p>
)}
</div>
);
})}
</div>
<div className="space-y-1.5">
<p className="text-xs font-semibold text-muted-foreground">
{t('snippets.variables.preview')}
</p>
<ScrollArea className="max-h-32 rounded-md border border-border/60 bg-muted/30">
<pre className="p-3 text-[11px] font-mono whitespace-pre-wrap break-all">
{preview}
</pre>
</ScrollArea>
</div>
</div>
)}
<DialogFooter className="gap-2">
<Button variant="ghost" onClick={() => closeWith(null)}>
{t('common.cancel')}
</Button>
<Button variant="default" disabled={!canSubmit} onClick={handleSubmit}>
{t('snippets.variables.run')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
);
};
let promptSnippetVariablesSingleton:
| ((snippet: Snippet) => Promise<Record<string, string> | null>)
| null = null;
export async function resolveSnippetCommand(snippet: Snippet): Promise<string | null> {
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;
}

View File

@@ -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<SnippetsRightPanelProps> = ({
isLoadingMore,
loadMoreHistory,
}) => {
const detectedVariables = useMemo(
() => parseSnippetVariables(editingSnippet?.command || ''),
[editingSnippet?.command],
);
if (rightPanelMode === 'select-targets') {
return (
<SelectHostPanel
@@ -127,6 +133,7 @@ export const SnippetsRightPanel: React.FC<SnippetsRightPanelProps> = ({
value={editingSnippet.label || ''}
onChange={(e) => setEditingSnippet({ ...editingSnippet, label: e.target.value })}
className="h-10"
spellCheck={false}
/>
</Card>
@@ -165,6 +172,29 @@ export const SnippetsRightPanel: React.FC<SnippetsRightPanelProps> = ({
value={editingSnippet.command || ''}
onChange={(e) => setEditingSnippet({ ...editingSnippet, command: e.target.value })}
/>
<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>
))}
</div>
)}
</Card>
{/* No Auto Run */}

View File

@@ -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<TerminalProps> = ({
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<TerminalProps> = ({
['--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 <TerminalView ctx={{ 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 }} />;
return <TerminalView ctx={{ 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, 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 }} />;
};
const Terminal = memo(TerminalComponent);

View File

@@ -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<TerminalLayerProps> = ({
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<TerminalLayerProps> = ({
const prevFocusedSessionIdRef = useRef<string | undefined>(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 <TerminalLayerView ctx={{ 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 }} />;
return <TerminalLayerView ctx={{ 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, 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 }} />;
};
export const TerminalLayer = memo(TerminalLayerInner, terminalLayerAreEqual);

View File

@@ -4,7 +4,7 @@ import React from 'react';
type TerminalViewContext = Record<string, any>;
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 (
<TerminalContextMenu
hasSelection={hasSelection}
@@ -503,7 +503,7 @@ export function TerminalView({ ctx }: { ctx: TerminalViewContext }) {
getCwd={() => 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}

View File

@@ -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<TerminalSession["status"]>;
@@ -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;
}

View File

@@ -3,7 +3,7 @@
type TerminalEffectsContext = Record<string, any>;
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,

View File

@@ -4,7 +4,7 @@ import React from 'react';
type TerminalLayerViewContext = Record<string, any>;
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 (
<AIStateProvider>
<AIStateMaintenanceHost validAIScopeTargetIds={validAIScopeTargetIds} />
@@ -243,7 +243,7 @@ export function TerminalLayerView({ ctx }: { ctx: TerminalLayerViewContext }) {
<ScriptsSidePanel
snippets={snippets}
packages={snippetPackages}
onSnippetClick={handleSnippetClickForFocusedSession}
onSnippetClick={handleSnippetFromPanel}
/>
</div>
)}

View File

@@ -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",
);
});

117
domain/snippetVariables.ts Normal file
View File

@@ -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<string>();
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, string>,
): 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<string, string>,
): ApplySnippetVariablesResult {
const defs = parseSnippetVariables(command);
if (defs.length === 0) {
return { ok: true, command: String(command ?? "") };
}
const missing: string[] = [];
const resolved: Record<string, string> = {};
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, string>,
): 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;
}

View File

@@ -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';

View File

@@ -0,0 +1,21 @@
import { STORAGE_KEY_SNIPPET_VAR_VALUES } from '../config/storageKeys';
import { localStorageAdapter } from './localStorageAdapter';
export type SnippetVariableValuesStore = Record<string, Record<string, string>>;
export function readSnippetVariableValuesStore(): SnippetVariableValuesStore {
return localStorageAdapter.read<SnippetVariableValuesStore>(STORAGE_KEY_SNIPPET_VAR_VALUES) ?? {};
}
export function readSnippetVariableValuesForSnippet(snippetId: string): Record<string, string> {
return readSnippetVariableValuesStore()[snippetId] ?? {};
}
export function saveSnippetVariableValues(
snippetId: string,
values: Record<string, string>,
): void {
const store = readSnippetVariableValuesStore();
store[snippetId] = { ...store[snippetId], ...values };
localStorageAdapter.write(STORAGE_KEY_SNIPPET_VAR_VALUES, store);
}