feat(snippets): implement snippet variable handling and UI prompts (#1159)
This commit is contained in:
14
App.tsx
14
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 <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() {
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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': '连接串口',
|
||||
|
||||
4
application/state/snippetVariableValues.ts
Normal file
4
application/state/snippetVariableValues.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export {
|
||||
readSnippetVariableValuesForSnippet,
|
||||
saveSnippetVariableValues,
|
||||
} from '../../infrastructure/persistence/snippetVariableValuesStorage';
|
||||
@@ -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,
|
||||
}));
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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')}
|
||||
|
||||
234
components/SnippetExecutionProvider.tsx
Normal file
234
components/SnippetExecutionProvider.tsx
Normal 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;
|
||||
}
|
||||
@@ -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 */}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
76
domain/snippetVariables.test.ts
Normal file
76
domain/snippetVariables.test.ts
Normal 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
117
domain/snippetVariables.ts
Normal 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;
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
21
infrastructure/persistence/snippetVariableValuesStorage.ts
Normal file
21
infrastructure/persistence/snippetVariableValuesStorage.ts
Normal 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);
|
||||
}
|
||||
Reference in New Issue
Block a user