mirror of
https://github.com/binaricat/Netcatty.git
synced 2026-10-29 18:11:40 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bbc1a08848 | ||
|
|
e51d223a0e | ||
|
|
92fd917760 | ||
|
|
1146a94bbf | ||
|
|
e38b372d1f | ||
|
|
189db57625 | ||
|
|
6736f95532 | ||
|
|
fc641cb304 | ||
|
|
27dc1c62af | ||
|
|
ef0a07e560 | ||
|
|
fb3396e389 | ||
|
|
aced2a99f8 | ||
|
|
cfb534615c | ||
|
|
409fd30969 | ||
|
|
cd284ae4f3 | ||
|
|
be3658b24f | ||
|
|
072377777f | ||
|
|
27b62b2372 | ||
|
|
627e652f56 | ||
|
|
17c2eb6346 | ||
|
|
01102e2d66 | ||
|
|
81dcf849fb | ||
|
|
1dfe1379cd | ||
|
|
27ec58fa40 | ||
|
|
7ac878d00e | ||
|
|
f90f40e624 | ||
|
|
dfc9ed7f61 | ||
|
|
2c2d7e0b55 | ||
|
|
700d603e64 | ||
|
|
f2b3facd9f | ||
|
|
d1cd072502 | ||
|
|
60820acf9a | ||
|
|
7f08bec0b9 | ||
|
|
bbd4c698a5 | ||
|
|
60e057318f | ||
|
|
7408e960cc | ||
|
|
9cd756f44b | ||
|
|
cd12b04647 | ||
|
|
9671468412 | ||
|
|
e4f9303de5 | ||
|
|
c8a393f12b | ||
|
|
7114288d2f | ||
|
|
0382c089e1 | ||
|
|
e9f5bcc6dc | ||
|
|
f8c7dd44db | ||
|
|
a47de3cb52 | ||
|
|
0ccea42785 |
No files matched your search
@@ -72,6 +72,7 @@ import { useExternalMcpSessionSync } from '../state/useExternalMcpSessionSync';
|
||||
import {
|
||||
STORAGE_KEY_DEBUG_HOTKEYS,
|
||||
STORAGE_KEY_PORT_FORWARDING,
|
||||
STORAGE_KEY_STARTUP_LANDING,
|
||||
} from '../../infrastructure/config/storageKeys';
|
||||
import { getEffectiveKnownHosts } from '../../infrastructure/syncHelpers';
|
||||
import { toast } from '../../components/ui/toast';
|
||||
@@ -79,11 +80,12 @@ import { VaultSection } from '../../components/VaultView';
|
||||
import { KeyboardInteractiveRequest } from '../../components/KeyboardInteractiveModal';
|
||||
import { PassphraseRequest } from '../../components/PassphraseModal';
|
||||
import { classifyLocalShellType } from '../../lib/localShell';
|
||||
import { useDiscoveredShells, resolveShellSetting } from '../../lib/useDiscoveredShells';
|
||||
import { useDiscoveredShells, resolveShellSetting, ensureDiscoveredShells } from '../../lib/useDiscoveredShells';
|
||||
import { Host, HostProtocol, KnownHost, SerialConfig, Snippet, SSHKey, TerminalSession } from '../../types';
|
||||
import { resolveSnippetCommand } from '../../components/SnippetExecutionProvider';
|
||||
import { isScriptSnippet } from '../../domain/snippetScript.ts';
|
||||
import { collectSnippetDeleteIds } from '../../domain/snippetSelection.ts';
|
||||
import { shouldOpenLocalTerminalOnStartup, resolveStartupLandingSetting } from '../../domain/startupLanding';
|
||||
import { useAppStartupEffects } from './useAppStartupEffects';
|
||||
import { handleTrayJumpToSessionImpl, handleTrayTogglePortForwardImpl, handleTrayPanelConnectImpl, handleTrayPanelConnectRequestImpl, flushQueuedTrayPanelConnectHostsImpl, handleGlobalHotkeyKeyDownImpl, handleEscapeKeyDownImpl, handleKeyboardInteractiveSubmitImpl, handleKeyboardInteractiveCancelImpl, handlePassphraseSubmitImpl, handlePassphraseCancelImpl, handlePassphraseSkipImpl, createLocalTerminalWithCurrentShellImpl, splitSessionWithCurrentShellImpl, copySessionWithCurrentShellImpl, copyWorkspaceWithCurrentShellImpl, copySessionToNewWindowWithCurrentShellImpl, confirmIfBusyLocalTerminalImpl, closeTabsBatchImpl, executeHotkeyActionImpl, handleCreateLocalTerminalImpl, handleConnectToHostImpl, handleTerminalDataCaptureImpl, hasMultipleProtocolsImpl, handleHostConnectWithProtocolCheckImpl, handleProtocolSelectImpl, handleRootContextMenuImpl } from './AppHandlers';
|
||||
|
||||
@@ -200,6 +202,7 @@ export function AppSideEffects() {
|
||||
readPersistedHosts,
|
||||
groupConfigs,
|
||||
updateGroupConfigs,
|
||||
commitVaultGroupMutation,
|
||||
} = vaultState;
|
||||
|
||||
const hostsRef = useRef(hosts);
|
||||
@@ -1176,6 +1179,100 @@ export function AppSideEffects() {
|
||||
);
|
||||
}, [addConnectionLog, createLocalTerminal, terminalSettings, discoveredShells]);
|
||||
|
||||
// Cold-start landing: open a local terminal once when preferred and nothing
|
||||
// was restored. Wait for queued launch intents (deep links / Explorer open)
|
||||
// and shell discovery so we neither duplicate tabs nor mislabel WSL/Git Bash.
|
||||
const startupLocalTerminalAttemptedRef = useRef(false);
|
||||
const startupLaunchIntentReceivedRef = useRef(false);
|
||||
const sessionsLengthRef = useRef(sessions.length);
|
||||
const workspacesLengthRef = useRef(workspaces.length);
|
||||
sessionsLengthRef.current = sessions.length;
|
||||
workspacesLengthRef.current = workspaces.length;
|
||||
const [coldStartIntentsSettled, setColdStartIntentsSettled] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (isPeerSessionWindow) {
|
||||
setColdStartIntentsSettled(true);
|
||||
return;
|
||||
}
|
||||
const bridge = netcattyBridge.get();
|
||||
if (!bridge?.onColdStartIntentsSettled) {
|
||||
setColdStartIntentsSettled(true);
|
||||
return;
|
||||
}
|
||||
return bridge.onColdStartIntentsSettled(() => {
|
||||
setColdStartIntentsSettled(true);
|
||||
});
|
||||
}, [isPeerSessionWindow]);
|
||||
|
||||
useEffect(() => {
|
||||
if (startupLocalTerminalAttemptedRef.current) return;
|
||||
if (isPeerSessionWindow) {
|
||||
startupLocalTerminalAttemptedRef.current = true;
|
||||
return;
|
||||
}
|
||||
|
||||
const landing = resolveStartupLandingSetting(
|
||||
localStorageAdapter.readString(STORAGE_KEY_STARTUP_LANDING),
|
||||
);
|
||||
if (landing !== 'local-terminal') {
|
||||
startupLocalTerminalAttemptedRef.current = true;
|
||||
return;
|
||||
}
|
||||
if (sessions.length > 0 || workspaces.length > 0) {
|
||||
startupLocalTerminalAttemptedRef.current = true;
|
||||
return;
|
||||
}
|
||||
if (!coldStartIntentsSettled) return;
|
||||
|
||||
let cancelled = false;
|
||||
void (async () => {
|
||||
const shells = await ensureDiscoveredShells();
|
||||
if (cancelled || startupLocalTerminalAttemptedRef.current) return;
|
||||
|
||||
if (!shouldOpenLocalTerminalOnStartup({
|
||||
startupLanding: landing,
|
||||
hasRestoredSessionState:
|
||||
sessionsLengthRef.current > 0 || workspacesLengthRef.current > 0,
|
||||
isPeerSessionWindow: false,
|
||||
hasQueuedStartupIntent: startupLaunchIntentReceivedRef.current,
|
||||
})) {
|
||||
startupLocalTerminalAttemptedRef.current = true;
|
||||
return;
|
||||
}
|
||||
|
||||
const resolved = resolveShellSetting(
|
||||
terminalSettings.localShell,
|
||||
shells,
|
||||
terminalSettings.localShellArgs,
|
||||
);
|
||||
const matchedShell = shells.find((shell) => shell.id === terminalSettings.localShell);
|
||||
startupLocalTerminalAttemptedRef.current = true;
|
||||
handleCreateLocalTerminal(
|
||||
resolved
|
||||
? {
|
||||
command: resolved.command,
|
||||
args: resolved.args,
|
||||
name: matchedShell?.name,
|
||||
icon: matchedShell?.icon,
|
||||
}
|
||||
: undefined,
|
||||
);
|
||||
})();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [
|
||||
coldStartIntentsSettled,
|
||||
handleCreateLocalTerminal,
|
||||
isPeerSessionWindow,
|
||||
sessions.length,
|
||||
terminalSettings.localShell,
|
||||
terminalSettings.localShellArgs,
|
||||
workspaces.length,
|
||||
]);
|
||||
|
||||
const proxyProfileIdSet = useMemo(
|
||||
() => new Set(proxyProfiles.map((profile) => profile.id)),
|
||||
[proxyProfiles],
|
||||
@@ -1283,6 +1380,7 @@ export function AppSideEffects() {
|
||||
groupConfigs,
|
||||
updateGroupConfigs,
|
||||
updateManagedSources,
|
||||
commitVaultGroupMutation,
|
||||
updatePortForwardingRules: importPortForwardingRules,
|
||||
startTunnel,
|
||||
stopTunnel,
|
||||
@@ -1293,6 +1391,7 @@ export function AppSideEffects() {
|
||||
});
|
||||
|
||||
const _handleSshDeepLink = useEffectEvent((payload: { url?: string }) => {
|
||||
startupLaunchIntentReceivedRef.current = true;
|
||||
const rawUrl = payload?.url || '';
|
||||
const target = parseSshDeepLink(rawUrl);
|
||||
if (!target) {
|
||||
@@ -1350,6 +1449,7 @@ export function AppSideEffects() {
|
||||
}, [isPeerSessionWindow]);
|
||||
|
||||
const _handleTelnetDeepLink = useEffectEvent((payload: { url?: string }) => {
|
||||
startupLaunchIntentReceivedRef.current = true;
|
||||
const rawUrl = payload?.url || '';
|
||||
const target = parseTelnetDeepLink(rawUrl);
|
||||
if (!target) {
|
||||
@@ -1396,6 +1496,7 @@ export function AppSideEffects() {
|
||||
}, [isPeerSessionWindow]);
|
||||
|
||||
const _handleJmsDeepLink = useEffectEvent((payload: { url?: string }) => {
|
||||
startupLaunchIntentReceivedRef.current = true;
|
||||
const rawUrl = payload?.url || '';
|
||||
const target = parseJmsDeepLink(rawUrl);
|
||||
if (!target) {
|
||||
@@ -1435,6 +1536,7 @@ export function AppSideEffects() {
|
||||
}, [sessions]);
|
||||
|
||||
const _handleOpenTerminalPath = useEffectEvent((payload: { path?: string }) => {
|
||||
startupLaunchIntentReceivedRef.current = true;
|
||||
const localStartDir = typeof payload?.path === 'string' ? payload.path : '';
|
||||
if (!localStartDir.trim()) return;
|
||||
handleCreateLocalTerminal(undefined, { localStartDir });
|
||||
|
||||
@@ -258,7 +258,7 @@ function AppViewInner({ domains }: AppViewProps) {
|
||||
|
||||
const {
|
||||
addShellHistoryEntry, removeShellHistoryEntry, addSessionToWorkspace, addToWorkspaceDialog, appendHostToWorkspace, appendLocalTerminalToWorkspace,
|
||||
clearAndRemoveSource, clearAndRemoveSources, closeLogView, closeSession, closeTabsBatch, closeWorkspace, commitPluginImporterData, commitVaultImportTransaction, copySessionToNewWindowWithCurrentShell, copySessionWithCurrentShell, copyWorkspaceWithCurrentShell,
|
||||
clearAndRemoveSource, clearAndRemoveSources, closeLogView, closeSession, closeTabsBatch, closeWorkspace, commitPluginImporterData, commitVaultImportTransaction, commitVaultGroupMutation, copySessionToNewWindowWithCurrentShell, copySessionWithCurrentShell, copyWorkspaceWithCurrentShell,
|
||||
convertKnownHostToHost, createWorkspaceFromSessions, createWorkspaceFromTargets, createWorkspaceWithHosts,
|
||||
customGroups, currentTerminalTheme, deepLinkHostDraft, draggingSessionId, effectiveKnownHosts, editorTabs, editorWordWrap, emptyVaultConflict,
|
||||
followAppTerminalTheme,
|
||||
@@ -592,6 +592,7 @@ function AppViewInner({ domains }: AppViewProps) {
|
||||
onUpdateManagedSources={updateManagedSources}
|
||||
onReadPersistedManagedSources={readPersistedManagedSources}
|
||||
onCommitVaultImportTransaction={commitVaultImportTransaction}
|
||||
onCommitVaultGroupMutation={commitVaultGroupMutation}
|
||||
onClearAndRemoveManagedSource={clearAndRemoveSource}
|
||||
onClearAndRemoveManagedSources={clearAndRemoveSources}
|
||||
onUnmanageSource={unmanageSource}
|
||||
|
||||
@@ -38,6 +38,7 @@ export function VaultHost() {
|
||||
removeShellHistoryEntry: actions?.removeShellHistoryEntry,
|
||||
commitPluginImporterData: actions?.commitPluginImporterData,
|
||||
commitVaultImportTransaction: actions?.commitVaultImportTransaction,
|
||||
commitVaultGroupMutation: actions?.commitVaultGroupMutation,
|
||||
convertKnownHostToHost: actions?.convertKnownHostToHost,
|
||||
customGroups: vault.customGroups,
|
||||
deepLinkHostDraft: local.deepLinkHostDraft,
|
||||
|
||||
@@ -67,6 +67,7 @@ export function VaultPublisher({ children }: VaultPublisherProps) {
|
||||
readPersistedManagedSources,
|
||||
commitPluginImporterData,
|
||||
commitVaultImportTransaction,
|
||||
commitVaultGroupMutation,
|
||||
updateHostDistro,
|
||||
updateHostLastConnected,
|
||||
addShellHistoryEntry,
|
||||
@@ -138,6 +139,7 @@ export function VaultPublisher({ children }: VaultPublisherProps) {
|
||||
readPersistedManagedSources,
|
||||
commitPluginImporterData,
|
||||
commitVaultImportTransaction,
|
||||
commitVaultGroupMutation,
|
||||
updateHostDistro,
|
||||
updateHostLastConnected,
|
||||
addShellHistoryEntry,
|
||||
@@ -150,6 +152,7 @@ export function VaultPublisher({ children }: VaultPublisherProps) {
|
||||
addShellHistoryEntry,
|
||||
commitPluginImporterData,
|
||||
commitVaultImportTransaction,
|
||||
commitVaultGroupMutation,
|
||||
convertKnownHostToHost,
|
||||
importOrReuseKey,
|
||||
readPersistedHosts,
|
||||
|
||||
@@ -53,6 +53,7 @@ export const enAiMessages: Messages = {
|
||||
'ai.providers.apiKey.placeholder': 'Enter API key',
|
||||
'ai.providers.apiKey.decrypting': 'Decrypting...',
|
||||
'ai.providers.baseUrl': 'Base URL',
|
||||
'ai.providers.baseUrl.anthropicHelp': 'Anthropic-compatible: host with or without /v1 (for example https://gateway.example or https://gateway.example/v1). Detection and chat both use /v1/models and /v1/messages.',
|
||||
'ai.providers.skipTLSVerify': 'Skip TLS certificate verification (for self-signed certs)',
|
||||
'ai.providers.defaultModel': 'Default Model',
|
||||
'ai.providers.defaultModel.placeholder': 'e.g. gpt-4o, claude-sonnet-4-20250514',
|
||||
|
||||
@@ -173,6 +173,10 @@ export const enCoreMessages: Messages = {
|
||||
|
||||
// Settings > Session Restore
|
||||
'settings.sessionRestore.title': 'Session Restore',
|
||||
'settings.sessionRestore.startupLanding': 'On launch',
|
||||
'settings.sessionRestore.startupLandingDesc': 'Choose whether to open the host home page or a local terminal when Netcatty starts with no restored sessions.',
|
||||
'settings.sessionRestore.startupLanding.vault': 'Home (hosts)',
|
||||
'settings.sessionRestore.startupLanding.localTerminal': 'Local terminal',
|
||||
'settings.sessionRestore.restorePreviousSession': 'Restore previous tabs and workspaces',
|
||||
'settings.sessionRestore.restorePreviousSessionDesc': 'Reopen terminal tabs and workspace layouts from the last app session.',
|
||||
'settings.sessionRestore.restoreTerminalCwd': 'Restore terminal working directory',
|
||||
|
||||
@@ -18,8 +18,11 @@ export const enScriptsMessages = {
|
||||
'scripts.editor.modalSubtitle': 'Edit metadata and script code in a larger workspace.',
|
||||
'scripts.editor.lineCount': '{count} lines',
|
||||
'scripts.editor.resize': 'Resize editor',
|
||||
'scripts.targets.hint': 'Automatic triggers apply to selected hosts, or to all hosts when that option is enabled.',
|
||||
'scripts.targets.hint': 'Selected groups are resolved dynamically, so hosts added later are included automatically.',
|
||||
'scripts.targets.connectOrderHint': 'Run order for connect scripts is configured per host under Host details → Automation.',
|
||||
'scripts.targets.currentHostMismatch': 'This script is not assigned to the current host.',
|
||||
'hostDetails.automation.groupScripts': 'Inherited group scripts',
|
||||
'hostDetails.automation.groupScriptsHint': 'These scripts follow the host group dynamically and are ordered before the host-specific queue.',
|
||||
'scripts.actions.runNowHint': 'Run on selected targets, or on all connectable hosts when that option is enabled.',
|
||||
'scripts.actions.runParallel': 'Run on all tabs (parallel)',
|
||||
'scripts.actions.runSequential': 'Run on all tabs (sequential)',
|
||||
|
||||
@@ -659,6 +659,9 @@ export const enTerminalMessages: Messages = {
|
||||
'snippets.scriptEditor.modalTitle': 'Edit script',
|
||||
'snippets.targets.title': 'Targets',
|
||||
'snippets.targets.add': 'Add targets',
|
||||
'snippets.targets.selectHosts': 'Hosts',
|
||||
'snippets.targets.selectGroups': 'Groups',
|
||||
'snippets.targets.noGroups': 'No groups found',
|
||||
'snippets.targets.allHosts': 'Apply to all hosts',
|
||||
'snippets.targets.allHostsShort': 'All hosts',
|
||||
'snippets.targets.allHostsActive': 'Applies to every connectable host.',
|
||||
|
||||
@@ -34,6 +34,7 @@ export const ruAiMessages: Messages = {
|
||||
'ai.providers.apiKey.placeholder': 'Введите API-ключ',
|
||||
'ai.providers.apiKey.decrypting': 'Расшифровка...',
|
||||
'ai.providers.baseUrl': 'Базовый URL',
|
||||
'ai.providers.baseUrl.anthropicHelp': 'Anthropic-совместимый: хост с /v1 или без (например https://gateway.example или https://gateway.example/v1). Проверка и чат используют /v1/models и /v1/messages.',
|
||||
'ai.providers.skipTLSVerify': 'Пропустить проверку TLS-сертификата (для самоподписанных сертификатов)',
|
||||
'ai.providers.defaultModel': 'Модель по умолчанию',
|
||||
'ai.providers.defaultModel.placeholder': 'например, gpt-4o, claude-sonnet-4-20250514',
|
||||
|
||||
@@ -18,8 +18,11 @@ export const ruScriptsMessages = {
|
||||
'scripts.editor.modalSubtitle': 'Редактируйте метаданные и код скрипта в большом рабочем пространстве.',
|
||||
'scripts.editor.lineCount': '{count} строк',
|
||||
'scripts.editor.resize': 'Изменить размер редактора',
|
||||
'scripts.targets.hint': 'Автоматические триггеры применяются к выбранным хостам или ко всем хостам, если включена эта настройка.',
|
||||
'scripts.targets.hint': 'Выбранные группы разрешаются динамически, поэтому новые хосты включаются автоматически.',
|
||||
'scripts.targets.connectOrderHint': 'Порядок запуска скриптов подключения настраивается для каждого хоста в разделе «Сведения о хосте → Автоматизация».',
|
||||
'scripts.targets.currentHostMismatch': 'Этот скрипт не назначен текущему хосту.',
|
||||
'hostDetails.automation.groupScripts': 'Унаследованные скрипты группы',
|
||||
'hostDetails.automation.groupScriptsHint': 'Эти скрипты динамически следуют за группой хоста и выполняются перед очередью хоста.',
|
||||
'scripts.actions.runNowHint': 'Запускается для выбранных целей либо для всех доступных для подключения хостов, если включена эта настройка.',
|
||||
'scripts.actions.runParallel': 'Запустить на всех вкладках (параллельно)',
|
||||
'scripts.actions.runSequential': 'Запустить на всех вкладках (последовательно)',
|
||||
|
||||
@@ -681,6 +681,9 @@ export const ruTerminalMessages: Messages = {
|
||||
'snippets.field.variableDefault': 'по умолчанию {value}',
|
||||
'snippets.targets.title': 'Цели',
|
||||
'snippets.targets.add': 'Добавить цели',
|
||||
'snippets.targets.selectHosts': 'Хосты',
|
||||
'snippets.targets.selectGroups': 'Группы',
|
||||
'snippets.targets.noGroups': 'Группы не найдены',
|
||||
'snippets.history.title': 'История оболочки',
|
||||
'snippets.history.subtitle': '{count} команд',
|
||||
'snippets.history.emptyTitle': 'История оболочки пока пуста',
|
||||
|
||||
@@ -53,6 +53,7 @@ export const zhCNAiMessages: Messages = {
|
||||
'ai.providers.apiKey.placeholder': '输入 API Key',
|
||||
'ai.providers.apiKey.decrypting': '解密中...',
|
||||
'ai.providers.baseUrl': 'Base URL',
|
||||
'ai.providers.baseUrl.anthropicHelp': 'Anthropic 兼容:可填不带或带 /v1 的主机(例如 https://gateway.example 或 https://gateway.example/v1)。检测与聊天都会请求 /v1/models、/v1/messages。',
|
||||
'ai.providers.skipTLSVerify': '跳过 TLS 证书验证(用于自签名证书)',
|
||||
'ai.providers.defaultModel': '默认模型',
|
||||
'ai.providers.defaultModel.placeholder': '例如 gpt-4o, claude-sonnet-4-20250514',
|
||||
|
||||
@@ -170,6 +170,10 @@ export const zhCNCoreMessages: Messages = {
|
||||
|
||||
// Settings > Session Restore
|
||||
'settings.sessionRestore.title': '会话恢复',
|
||||
'settings.sessionRestore.startupLanding': '启动时打开',
|
||||
'settings.sessionRestore.startupLandingDesc': '当没有可恢复的会话时,选择启动后进入主机主页还是直接打开本地终端。',
|
||||
'settings.sessionRestore.startupLanding.vault': '主页(主机)',
|
||||
'settings.sessionRestore.startupLanding.localTerminal': '本地终端',
|
||||
'settings.sessionRestore.restorePreviousSession': '恢复上次的标签页和工作区',
|
||||
'settings.sessionRestore.restorePreviousSessionDesc': '启动时重新打开上次的终端标签页和工作区布局。',
|
||||
'settings.sessionRestore.restoreTerminalCwd': '恢复终端工作目录',
|
||||
|
||||
@@ -18,8 +18,11 @@ export const zhCNScriptsMessages = {
|
||||
'scripts.editor.modalSubtitle': '在更大的工作区中编辑脚本元数据和代码。',
|
||||
'scripts.editor.lineCount': '{count} 行',
|
||||
'scripts.editor.resize': '调整编辑器高度',
|
||||
'scripts.targets.hint': '自动触发作用于所选主机;勾选「对所有主机生效」则作用于全部可连接主机。',
|
||||
'scripts.targets.hint': '所选分组会动态匹配,之后加入分组的主机也会自动生效。',
|
||||
'scripts.targets.connectOrderHint': '连接脚本的执行顺序请在主机详情 → 自动化 中调整。',
|
||||
'scripts.targets.currentHostMismatch': '此脚本未分配给当前主机。',
|
||||
'hostDetails.automation.groupScripts': '分组继承脚本',
|
||||
'hostDetails.automation.groupScriptsHint': '这些脚本动态跟随主机所在分组,并在主机专属队列之前执行。',
|
||||
'scripts.actions.runNowHint': '在所选目标主机上运行;勾选「对所有主机生效」时在所有可连接主机上运行。',
|
||||
'scripts.actions.runParallel': '在所有标签页并行运行',
|
||||
'scripts.actions.runSequential': '在所有标签页顺序运行',
|
||||
|
||||
@@ -742,8 +742,11 @@ export const zhCNTerminalMessages: Messages = {
|
||||
'snippets.scriptEditor.expand': '弹窗编辑',
|
||||
'snippets.scriptEditor.resize': '调整编辑器高度',
|
||||
'snippets.scriptEditor.modalTitle': '编辑脚本',
|
||||
'snippets.targets.title': '目标主机',
|
||||
'snippets.targets.add': '添加目标主机',
|
||||
'snippets.targets.title': '执行目标',
|
||||
'snippets.targets.add': '添加目标',
|
||||
'snippets.targets.selectHosts': '选择主机',
|
||||
'snippets.targets.selectGroups': '选择分组',
|
||||
'snippets.targets.noGroups': '未找到分组',
|
||||
'snippets.targets.allHosts': '对所有主机生效',
|
||||
'snippets.targets.allHostsShort': '所有主机',
|
||||
'snippets.targets.allHostsActive': '将作用于所有可连接的主机。',
|
||||
|
||||
@@ -53,6 +53,7 @@ export const zhTWAiMessages: Messages = {
|
||||
'ai.providers.apiKey.placeholder': '輸入 API Key',
|
||||
'ai.providers.apiKey.decrypting': '解密中...',
|
||||
'ai.providers.baseUrl': 'Base URL',
|
||||
'ai.providers.baseUrl.anthropicHelp': 'Anthropic 相容:可填不帶或帶 /v1 的主機(例如 https://gateway.example 或 https://gateway.example/v1)。偵測與聊天都會請求 /v1/models、/v1/messages。',
|
||||
'ai.providers.skipTLSVerify': '跳過 TLS 憑證驗證(用於自我簽署憑證)',
|
||||
'ai.providers.defaultModel': '預設模型',
|
||||
'ai.providers.defaultModel.placeholder': '例如 gpt-4o, claude-sonnet-4-20250514',
|
||||
|
||||
@@ -170,6 +170,10 @@ export const zhTWCoreMessages: Messages = {
|
||||
|
||||
// Settings > Session Restore
|
||||
'settings.sessionRestore.title': '工作階段恢復',
|
||||
'settings.sessionRestore.startupLanding': '啟動時開啟',
|
||||
'settings.sessionRestore.startupLandingDesc': '當沒有可恢復的工作階段時,選擇啟動後進入主機主頁還是直接開啟本機終端。',
|
||||
'settings.sessionRestore.startupLanding.vault': '主頁(主機)',
|
||||
'settings.sessionRestore.startupLanding.localTerminal': '本機終端',
|
||||
'settings.sessionRestore.restorePreviousSession': '恢復上次的標籤頁和工作區',
|
||||
'settings.sessionRestore.restorePreviousSessionDesc': '啟動時重新開啟上次的終端標籤頁和工作區佈局。',
|
||||
'settings.sessionRestore.restoreTerminalCwd': '恢復終端工作目錄',
|
||||
|
||||
@@ -18,8 +18,11 @@ export const zhTWScriptsMessages = {
|
||||
'scripts.editor.modalSubtitle': '在更大的工作區中編輯指令碼的中繼資料與程式碼。',
|
||||
'scripts.editor.lineCount': '{count} 行',
|
||||
'scripts.editor.resize': '調整編輯器高度',
|
||||
'scripts.targets.hint': '自動觸發會作用於所選主機;勾選「對所有主機生效」則作用於全部可連線的主機。',
|
||||
'scripts.targets.hint': '所選分組會動態比對,之後加入分組的主機也會自動生效。',
|
||||
'scripts.targets.connectOrderHint': '連線指令碼的執行順序請在主機詳情 → 自動化 中調整。',
|
||||
'scripts.targets.currentHostMismatch': '此指令碼未分配給目前主機。',
|
||||
'hostDetails.automation.groupScripts': '分組繼承指令碼',
|
||||
'hostDetails.automation.groupScriptsHint': '這些指令碼會動態跟隨主機所在分組,並在主機專屬佇列之前執行。',
|
||||
'scripts.actions.runNowHint': '在所選目標主機上執行;勾選「對所有主機生效」時在所有可連線的主機上執行。',
|
||||
'scripts.actions.runParallel': '在所有標籤頁並行執行',
|
||||
'scripts.actions.runSequential': '在所有標籤頁依序執行',
|
||||
|
||||
@@ -739,8 +739,11 @@ export const zhTWTerminalMessages: Messages = {
|
||||
'snippets.scriptEditor.expand': '彈出視窗編輯',
|
||||
'snippets.scriptEditor.resize': '調整編輯器高度',
|
||||
'snippets.scriptEditor.modalTitle': '編輯指令碼',
|
||||
'snippets.targets.title': '目標主機',
|
||||
'snippets.targets.add': '新增目標主機',
|
||||
'snippets.targets.title': '執行目標',
|
||||
'snippets.targets.add': '新增目標',
|
||||
'snippets.targets.selectHosts': '選擇主機',
|
||||
'snippets.targets.selectGroups': '選擇分組',
|
||||
'snippets.targets.noGroups': '未找到分組',
|
||||
'snippets.history.title': 'Shell 歷史',
|
||||
'snippets.history.subtitle': '{count} 條指令',
|
||||
'snippets.history.emptyTitle': '暫無 Shell 歷史',
|
||||
|
||||
@@ -175,7 +175,7 @@ test("restore-only settings do not bump the cloud sync settings version", () =>
|
||||
const settingsVersionSource = settingsSource.slice(settingsVersionIndex);
|
||||
|
||||
assert.notEqual(settingsVersionIndex, -1);
|
||||
assert.doesNotMatch(settingsVersionSource, /restorePreviousSession|restoreTerminalCwd/);
|
||||
assert.doesNotMatch(settingsVersionSource, /restorePreviousSession|restoreTerminalCwd|startupLanding/);
|
||||
});
|
||||
|
||||
test("restore previous session re-arms after cross-window settings ipc sync", () => {
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
STORAGE_KEY_DISABLE_TERMINAL_FONT_ZOOM,
|
||||
STORAGE_KEY_RESTORE_PREVIOUS_SESSION,
|
||||
STORAGE_KEY_RESTORE_TERMINAL_CWD,
|
||||
STORAGE_KEY_STARTUP_LANDING,
|
||||
STORAGE_KEY_SESSION_LOGS_DIR,
|
||||
STORAGE_KEY_SESSION_LOGS_ENABLED,
|
||||
STORAGE_KEY_SESSION_LOGS_FORMAT,
|
||||
@@ -60,6 +61,7 @@ import {
|
||||
migrateIncomingTerminalFontId,
|
||||
} from './settingsStateDefaults';
|
||||
import { isTerminalSidePanelAutoOpenTab, type TerminalSidePanelAutoOpenTab } from '../../domain/terminalSidePanelAutoOpen';
|
||||
import { isStartupLanding, type StartupLanding } from '../../domain/startupLanding';
|
||||
import type { AppearanceSyncEvent } from './appearanceSync';
|
||||
|
||||
interface UseSettingsIpcSyncParams {
|
||||
@@ -102,6 +104,7 @@ interface UseSettingsIpcSyncParams {
|
||||
setDisableTerminalFontZoomState: Dispatch<SetStateAction<boolean>>;
|
||||
setRestorePreviousSessionState: Dispatch<SetStateAction<boolean>>;
|
||||
setRestoreTerminalCwdState: Dispatch<SetStateAction<boolean>>;
|
||||
setStartupLandingState: Dispatch<SetStateAction<StartupLanding>>;
|
||||
setSftpTransferConcurrencyState: Dispatch<SetStateAction<number>>;
|
||||
setSshTransportIdleTtlMsState: Dispatch<SetStateAction<number>>;
|
||||
}
|
||||
@@ -146,6 +149,7 @@ export function useSettingsIpcSync({
|
||||
setDisableTerminalFontZoomState,
|
||||
setRestorePreviousSessionState,
|
||||
setRestoreTerminalCwdState,
|
||||
setStartupLandingState,
|
||||
setSftpTransferConcurrencyState,
|
||||
setSshTransportIdleTtlMsState,
|
||||
}: UseSettingsIpcSyncParams) {
|
||||
@@ -304,6 +308,9 @@ export function useSettingsIpcSync({
|
||||
if (key === STORAGE_KEY_RESTORE_TERMINAL_CWD && typeof value === 'boolean') {
|
||||
setRestoreTerminalCwdState((prev) => (prev === value ? prev : value));
|
||||
}
|
||||
if (key === STORAGE_KEY_STARTUP_LANDING && isStartupLanding(value)) {
|
||||
setStartupLandingState((prev) => (prev === value ? prev : value));
|
||||
}
|
||||
if (key === STORAGE_KEY_SFTP_TRANSFER_CONCURRENCY && typeof value === 'number') {
|
||||
setSftpTransferConcurrencyState((prev) => (prev === value ? prev : value));
|
||||
}
|
||||
@@ -348,6 +355,7 @@ export function useSettingsIpcSync({
|
||||
setDisableTerminalFontZoomState,
|
||||
setRestorePreviousSessionState,
|
||||
setRestoreTerminalCwdState,
|
||||
setStartupLandingState,
|
||||
setSftpTransferConcurrencyState,
|
||||
setSshTransportIdleTtlMsState,
|
||||
setTerminalFontFamilyId,
|
||||
|
||||
@@ -85,6 +85,8 @@ export const DEFAULT_SHELL_ONLY_TAB_NUMBER_SHORTCUTS = false;
|
||||
export const DEFAULT_SHOW_TAB_NUMBER_BADGES = true;
|
||||
export const DEFAULT_DISABLE_TERMINAL_FONT_ZOOM = false;
|
||||
export { DEFAULT_RESTORE_PREVIOUS_SESSION } from './sessionRestoreSettings';
|
||||
export { DEFAULT_STARTUP_LANDING } from '../../domain/startupLanding';
|
||||
export type { StartupLanding } from '../../domain/startupLanding';
|
||||
|
||||
// Editor defaults
|
||||
export const DEFAULT_EDITOR_WORD_WRAP = false;
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
STORAGE_KEY_EXPLORER_CONTEXT_MENU_ENABLED,
|
||||
STORAGE_KEY_RESTORE_PREVIOUS_SESSION,
|
||||
STORAGE_KEY_RESTORE_TERMINAL_CWD,
|
||||
STORAGE_KEY_STARTUP_LANDING,
|
||||
STORAGE_KEY_SFTP_AUTO_OPEN_SIDEBAR,
|
||||
STORAGE_KEY_SFTP_FOLLOW_TERMINAL_CWD,
|
||||
STORAGE_KEY_SFTP_AUTO_SYNC,
|
||||
@@ -60,6 +61,7 @@ import {
|
||||
} from './settingsStateDefaults';
|
||||
import { isTerminalSidePanelAutoOpenTab, type TerminalSidePanelAutoOpenTab } from '../../domain/terminalSidePanelAutoOpen';
|
||||
import { isHostClickBehavior, type HostClickBehavior } from '../../domain/hostClickBehavior';
|
||||
import { isStartupLanding, type StartupLanding } from '../../domain/startupLanding';
|
||||
|
||||
interface UseSettingsStorageSyncParams {
|
||||
enabled?: boolean;
|
||||
@@ -97,6 +99,7 @@ interface UseSettingsStorageSyncParams {
|
||||
disableTerminalFontZoom: boolean;
|
||||
restorePreviousSession: boolean;
|
||||
restoreTerminalCwd: boolean;
|
||||
startupLanding: StartupLanding;
|
||||
editorWordWrap: boolean;
|
||||
sessionLogsEnabled: boolean;
|
||||
sessionLogsDir: string;
|
||||
@@ -145,6 +148,7 @@ interface UseSettingsStorageSyncParams {
|
||||
setDisableTerminalFontZoomState: Dispatch<SetStateAction<boolean>>;
|
||||
setRestorePreviousSessionState: Dispatch<SetStateAction<boolean>>;
|
||||
setRestoreTerminalCwdState: Dispatch<SetStateAction<boolean>>;
|
||||
setStartupLandingState: Dispatch<SetStateAction<StartupLanding>>;
|
||||
setEditorWordWrapState: Dispatch<SetStateAction<boolean>>;
|
||||
setSessionLogsEnabled: Dispatch<SetStateAction<boolean>>;
|
||||
setSessionLogsDir: Dispatch<SetStateAction<string>>;
|
||||
@@ -172,7 +176,7 @@ export function useSettingsStorageSync({
|
||||
terminalThemeId, followAppTerminalTheme, terminalFontFamilyId, terminalFontSize,
|
||||
sftpDoubleClickBehavior, sftpAutoSync, sftpShowHiddenFiles,
|
||||
sftpUseCompressedUpload, sftpSkipUnchanged, sftpAutoOpenSidebar, sftpFollowTerminalCwd, sftpDefaultViewMode,
|
||||
showRecentHosts, hostClickBehavior, showOnlyUngroupedHostsInRoot, showSftpTab, showHostTreeSidebar, terminalSidePanelAutoOpen, terminalSidePanelAutoOpenTab, shellOnlyTabNumberShortcuts, showTabNumberBadges, disableTerminalFontZoom, restorePreviousSession, restoreTerminalCwd,
|
||||
showRecentHosts, hostClickBehavior, showOnlyUngroupedHostsInRoot, showSftpTab, showHostTreeSidebar, terminalSidePanelAutoOpen, terminalSidePanelAutoOpenTab, shellOnlyTabNumberShortcuts, showTabNumberBadges, disableTerminalFontZoom, restorePreviousSession, restoreTerminalCwd, startupLanding,
|
||||
editorWordWrap, sessionLogsEnabled, sessionLogsDir, sessionLogsFormat, sessionLogsTimestampsEnabled, sshDebugLogsEnabled, sshDeepLinkEnabled, jmsDeepLinkEnabled, explorerContextMenuEnabled,
|
||||
globalHotkeyEnabled, autoUpdateEnabled, windowOpacity, appIconVariant,
|
||||
setTheme, setLightUiThemeId, setDarkUiThemeId, setAccentMode, applyIncomingCustomAccent,
|
||||
@@ -181,7 +185,7 @@ export function useSettingsStorageSync({
|
||||
setFollowAppTerminalThemeState, setTerminalFontFamilyId, setTerminalFontSize,
|
||||
setSftpDoubleClickBehavior, setSftpAutoSync, setSftpShowHiddenFiles,
|
||||
setSftpUseCompressedUpload, setSftpSkipUnchanged, setSftpAutoOpenSidebar, setSftpFollowTerminalCwd, setSftpDefaultViewMode,
|
||||
setShowRecentHostsState, setHostClickBehaviorState, setShowOnlyUngroupedHostsInRootState, setShowSftpTabState, setShowHostTreeSidebarState, setTerminalSidePanelAutoOpenState, setTerminalSidePanelAutoOpenTabState, setShellOnlyTabNumberShortcutsState, setShowTabNumberBadgesState, setDisableTerminalFontZoomState, setRestorePreviousSessionState, setRestoreTerminalCwdState,
|
||||
setShowRecentHostsState, setHostClickBehaviorState, setShowOnlyUngroupedHostsInRootState, setShowSftpTabState, setShowHostTreeSidebarState, setTerminalSidePanelAutoOpenState, setTerminalSidePanelAutoOpenTabState, setShellOnlyTabNumberShortcutsState, setShowTabNumberBadgesState, setDisableTerminalFontZoomState, setRestorePreviousSessionState, setRestoreTerminalCwdState, setStartupLandingState,
|
||||
setEditorWordWrapState, setSessionLogsEnabled, setSessionLogsDir, setSessionLogsFormat, setSessionLogsTimestampsEnabled, setSshDebugLogsEnabled, setSshDeepLinkEnabledState, setJmsDeepLinkEnabledState, setExplorerContextMenuEnabledState,
|
||||
setGlobalHotkeyEnabled, setWindowOpacity, setAppIconVariant, setAutoUpdateEnabled, setWorkspaceFocusStyleState,
|
||||
setSftpTransferConcurrencyState, setSshTransportIdleTtlMsState,
|
||||
@@ -196,7 +200,7 @@ export function useSettingsStorageSync({
|
||||
terminalThemeId, followAppTerminalTheme, terminalFontFamilyId, terminalFontSize,
|
||||
sftpDoubleClickBehavior, sftpAutoSync, sftpShowHiddenFiles,
|
||||
sftpUseCompressedUpload, sftpSkipUnchanged, sftpAutoOpenSidebar, sftpFollowTerminalCwd, sftpDefaultViewMode,
|
||||
showRecentHosts, hostClickBehavior, showOnlyUngroupedHostsInRoot, showSftpTab, showHostTreeSidebar, terminalSidePanelAutoOpen, terminalSidePanelAutoOpenTab, shellOnlyTabNumberShortcuts, showTabNumberBadges, disableTerminalFontZoom, restorePreviousSession, restoreTerminalCwd,
|
||||
showRecentHosts, hostClickBehavior, showOnlyUngroupedHostsInRoot, showSftpTab, showHostTreeSidebar, terminalSidePanelAutoOpen, terminalSidePanelAutoOpenTab, shellOnlyTabNumberShortcuts, showTabNumberBadges, disableTerminalFontZoom, restorePreviousSession, restoreTerminalCwd, startupLanding,
|
||||
editorWordWrap, sessionLogsEnabled, sessionLogsDir, sessionLogsFormat, sessionLogsTimestampsEnabled, sshDebugLogsEnabled, sshDeepLinkEnabled, jmsDeepLinkEnabled, explorerContextMenuEnabled,
|
||||
globalHotkeyEnabled, autoUpdateEnabled, windowOpacity, appIconVariant,
|
||||
});
|
||||
@@ -206,7 +210,7 @@ export function useSettingsStorageSync({
|
||||
terminalThemeId, followAppTerminalTheme, terminalFontFamilyId, terminalFontSize,
|
||||
sftpDoubleClickBehavior, sftpAutoSync, sftpShowHiddenFiles,
|
||||
sftpUseCompressedUpload, sftpSkipUnchanged, sftpAutoOpenSidebar, sftpFollowTerminalCwd, sftpDefaultViewMode,
|
||||
showRecentHosts, hostClickBehavior, showOnlyUngroupedHostsInRoot, showSftpTab, showHostTreeSidebar, terminalSidePanelAutoOpen, terminalSidePanelAutoOpenTab, shellOnlyTabNumberShortcuts, showTabNumberBadges, disableTerminalFontZoom, restorePreviousSession, restoreTerminalCwd,
|
||||
showRecentHosts, hostClickBehavior, showOnlyUngroupedHostsInRoot, showSftpTab, showHostTreeSidebar, terminalSidePanelAutoOpen, terminalSidePanelAutoOpenTab, shellOnlyTabNumberShortcuts, showTabNumberBadges, disableTerminalFontZoom, restorePreviousSession, restoreTerminalCwd, startupLanding,
|
||||
editorWordWrap, sessionLogsEnabled, sessionLogsDir, sessionLogsFormat, sessionLogsTimestampsEnabled, sshDebugLogsEnabled, sshDeepLinkEnabled, jmsDeepLinkEnabled, explorerContextMenuEnabled,
|
||||
globalHotkeyEnabled, autoUpdateEnabled, windowOpacity, appIconVariant,
|
||||
};
|
||||
@@ -480,6 +484,11 @@ export function useSettingsStorageSync({
|
||||
setRestoreTerminalCwdState(newValue);
|
||||
}
|
||||
}
|
||||
if (e.key === STORAGE_KEY_STARTUP_LANDING && e.newValue !== null) {
|
||||
if (isStartupLanding(e.newValue) && e.newValue !== s.startupLanding) {
|
||||
setStartupLandingState(e.newValue);
|
||||
}
|
||||
}
|
||||
// Sync global hotkey enabled setting from other windows
|
||||
if (e.key === STORAGE_KEY_GLOBAL_HOTKEY_ENABLED && e.newValue !== null) {
|
||||
const newValue = e.newValue === 'true';
|
||||
@@ -568,9 +577,11 @@ export function useSettingsStorageSync({
|
||||
setShowRecentHostsState,
|
||||
setShowSftpTabState,
|
||||
setShellOnlyTabNumberShortcutsState,
|
||||
setShowTabNumberBadgesState,
|
||||
setDisableTerminalFontZoomState,
|
||||
setRestorePreviousSessionState,
|
||||
setRestoreTerminalCwdState,
|
||||
setStartupLandingState,
|
||||
setTerminalFontFamilyId,
|
||||
setTerminalFontSize,
|
||||
setTerminalThemeDarkId,
|
||||
|
||||
@@ -162,7 +162,6 @@ export async function openTransferSftpSession(
|
||||
if (
|
||||
!wantDedicated
|
||||
&& options?.sourceSessionId
|
||||
&& !host.sftpSudo
|
||||
&& bridge.openSftpForSession
|
||||
) {
|
||||
try {
|
||||
|
||||
@@ -2,7 +2,7 @@ import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import type { Host } from "../../../domain/models";
|
||||
import { ensureRemoteSftpSession } from "./ensureRemoteSftpSession";
|
||||
import { ensureRemoteSftpSession, probeSftpSession } from "./ensureRemoteSftpSession";
|
||||
import type { SftpPane } from "./types";
|
||||
|
||||
const host = {
|
||||
@@ -37,6 +37,24 @@ const remotePane = (connectionId: string): SftpPane => ({
|
||||
|
||||
const noopReleaseConnection = async () => {};
|
||||
|
||||
test("SFTP session probe accepts a virtual root directory", async () => {
|
||||
const calls: Array<[string, string]> = [];
|
||||
const ok = await probeSftpSession({
|
||||
realpathSftp: async (sftpId, remotePath) => {
|
||||
calls.push([sftpId, remotePath]);
|
||||
return "/";
|
||||
},
|
||||
}, "sftp-jumpserver");
|
||||
|
||||
assert.equal(ok, true);
|
||||
assert.deepEqual(calls, [["sftp-jumpserver", "."]]);
|
||||
});
|
||||
|
||||
test("SFTP session probe does not require home discovery", async () => {
|
||||
const ok = await probeSftpSession(undefined, "sftp-live");
|
||||
assert.equal(ok, true);
|
||||
});
|
||||
|
||||
test("returns an existing mapped SFTP session without reconnecting", async () => {
|
||||
let connectCalls = 0;
|
||||
const sftpId = await ensureRemoteSftpSession({
|
||||
|
||||
@@ -3,6 +3,24 @@ import type { MutableRefObject } from "react";
|
||||
import { isSessionError } from "./errors";
|
||||
import type { SftpPane } from "./types";
|
||||
|
||||
export interface SftpSessionProbeBridge {
|
||||
realpathSftp?: (sftpId: string, path: string) => Promise<string>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Probe the SFTP protocol itself instead of using remote home discovery.
|
||||
* Restricted/chroot servers can deny SSH exec or expose '/' as their virtual
|
||||
* root while the SFTP session remains fully usable.
|
||||
*/
|
||||
export async function probeSftpSession(
|
||||
bridge: SftpSessionProbeBridge | null | undefined,
|
||||
sftpId: string,
|
||||
): Promise<boolean> {
|
||||
if (!bridge?.realpathSftp) return true;
|
||||
await bridge.realpathSftp(sftpId, ".");
|
||||
return true;
|
||||
}
|
||||
|
||||
export interface EnsureRemoteSftpSessionParams {
|
||||
side: "left" | "right";
|
||||
getActivePane: (side: "left" | "right") => SftpPane | null;
|
||||
|
||||
@@ -3,8 +3,12 @@
|
||||
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
|
||||
import {
|
||||
buildSftpConnectInFlightKey,
|
||||
buildSftpHomeDirCandidates,
|
||||
runSftpConnectOnceByKey,
|
||||
createSftpConnectionId,
|
||||
createPinnedReconnectSideResolver,
|
||||
openSftpConnectionOnce,
|
||||
@@ -15,12 +19,62 @@ import {
|
||||
resolvePinnedReconnectSide,
|
||||
} from "./useSftpConnections.ts";
|
||||
|
||||
test("home dir candidates prefer user home then root", () => {
|
||||
assert.deepEqual(buildSftpHomeDirCandidates("deploy"), ["/home/deploy", "/root"]);
|
||||
assert.deepEqual(buildSftpHomeDirCandidates("root"), ["/root"]);
|
||||
assert.deepEqual(buildSftpHomeDirCandidates(undefined), ["/root"]);
|
||||
assert.deepEqual(buildSftpHomeDirCandidates(null), ["/root"]);
|
||||
});
|
||||
|
||||
test("connection ids stay unique even when connects start in the same millisecond", () => {
|
||||
const ids = ["uuid-a", "uuid-b"];
|
||||
assert.equal(createSftpConnectionId("left", () => ids.shift()!), "left-uuid-a");
|
||||
assert.equal(createSftpConnectionId("left", () => ids.shift()!), "left-uuid-b");
|
||||
});
|
||||
|
||||
test("runSftpConnectOnceByKey shares an in-flight connect for the same tab and endpoint", async () => {
|
||||
const inFlight = new Map<string, Promise<void>>();
|
||||
let runs = 0;
|
||||
let releaseFirst: (() => void) | undefined;
|
||||
|
||||
const first = runSftpConnectOnceByKey(inFlight, "left:tab-1:host-key:ssh-session-1:/home", async () => {
|
||||
runs += 1;
|
||||
await new Promise<void>((resolve) => {
|
||||
releaseFirst = resolve;
|
||||
});
|
||||
});
|
||||
const second = runSftpConnectOnceByKey(inFlight, "left:tab-1:host-key:ssh-session-1:/home", async () => {
|
||||
runs += 1;
|
||||
});
|
||||
|
||||
assert.equal(runs, 1);
|
||||
releaseFirst?.();
|
||||
await Promise.all([first, second]);
|
||||
assert.equal(runs, 1);
|
||||
assert.equal(inFlight.size, 0);
|
||||
});
|
||||
|
||||
test("buildSftpConnectInFlightKey uses the allocated tab id for forced new tabs", () => {
|
||||
const first = buildSftpConnectInFlightKey({
|
||||
side: "left",
|
||||
tabId: "new-tab-a",
|
||||
targetConnectionKey: "host-key",
|
||||
sourceSessionId: "ssh-session-1",
|
||||
initialPath: "/home",
|
||||
forceNewTab: true,
|
||||
});
|
||||
const second = buildSftpConnectInFlightKey({
|
||||
side: "left",
|
||||
tabId: "new-tab-b",
|
||||
targetConnectionKey: "host-key",
|
||||
sourceSessionId: "ssh-session-1",
|
||||
initialPath: "/home",
|
||||
forceNewTab: true,
|
||||
});
|
||||
|
||||
assert.notEqual(first, second);
|
||||
});
|
||||
|
||||
const openOptions = {
|
||||
sessionId: "sftp-request-1",
|
||||
hostname: "192.168.9.138",
|
||||
@@ -73,6 +127,43 @@ test("openSftpWithSessionPreference falls back to normal SFTP when session reuse
|
||||
assert.deepEqual(calls, ["openForSession:ssh-session-1", "openSftp:sftp-request-1"]);
|
||||
});
|
||||
|
||||
test("openSftpWithSessionPreference tries session reuse for sudo SFTP before fresh auth", async () => {
|
||||
const calls: string[] = [];
|
||||
let passedOptions: NetcattySSHOptions | undefined;
|
||||
const sftpId = await openSftpWithSessionPreference({
|
||||
bridge: {
|
||||
openSftpForSession: async (sessionId: string, options?: NetcattySSHOptions) => {
|
||||
calls.push(`openForSession:${sessionId}`);
|
||||
passedOptions = options;
|
||||
return "sudo-session-backed-sftp";
|
||||
},
|
||||
openSftp: async () => {
|
||||
calls.push("openSftp");
|
||||
return "fresh-sftp";
|
||||
},
|
||||
},
|
||||
sourceSessionId: "ssh-session-1",
|
||||
openOptions: {
|
||||
...openOptions,
|
||||
sudo: true,
|
||||
password: "sudo-pass",
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(sftpId, "sudo-session-backed-sftp");
|
||||
assert.deepEqual(calls, ["openForSession:ssh-session-1"]);
|
||||
assert.equal(passedOptions?.sudo, true);
|
||||
assert.equal(passedOptions?.password, "sudo-pass");
|
||||
});
|
||||
|
||||
test("remote browse connect does not discard sourceSessionId for sudo hosts", () => {
|
||||
const source = readFileSync(new URL("./useSftpConnections.ts", import.meta.url), "utf8");
|
||||
assert.doesNotMatch(
|
||||
source,
|
||||
/host\.sftpSudo\s*\?\s*undefined\s*:\s*options\?\.sourceSessionId/,
|
||||
);
|
||||
});
|
||||
|
||||
test("openSftpWithSessionPreference opens normal SFTP without a source session", async () => {
|
||||
const calls: string[] = [];
|
||||
const sftpId = await openSftpWithSessionPreference({
|
||||
|
||||
@@ -97,6 +97,13 @@ export async function releaseSftpConnectionMetadata(params: {
|
||||
params.onRemoteSessionClosed?.(sftpId);
|
||||
}
|
||||
|
||||
/** Hardcoded home-path candidates when SSH exec / listable realpath fail. */
|
||||
export function buildSftpHomeDirCandidates(username?: string | null): string[] {
|
||||
if (username === "root") return ["/root"];
|
||||
if (username) return [`/home/${username}`, "/root"];
|
||||
return ["/root"];
|
||||
}
|
||||
|
||||
export function createSftpConnectionId(
|
||||
side: "left" | "right",
|
||||
randomUUID: () => string = () => crypto.randomUUID(),
|
||||
@@ -176,6 +183,41 @@ export function createPinnedReconnectSideResolver(
|
||||
};
|
||||
}
|
||||
|
||||
export function runSftpConnectOnceByKey(
|
||||
inFlight: Map<string, Promise<void>>,
|
||||
key: string,
|
||||
run: () => Promise<void>,
|
||||
): Promise<void> {
|
||||
const existing = inFlight.get(key);
|
||||
if (existing) return existing;
|
||||
|
||||
const promise = run().finally(() => {
|
||||
if (inFlight.get(key) === promise) {
|
||||
inFlight.delete(key);
|
||||
}
|
||||
});
|
||||
inFlight.set(key, promise);
|
||||
return promise;
|
||||
}
|
||||
|
||||
export function buildSftpConnectInFlightKey(params: {
|
||||
side: "left" | "right";
|
||||
tabId: string;
|
||||
targetConnectionKey: string;
|
||||
sourceSessionId?: string;
|
||||
initialPath?: string;
|
||||
forceNewTab?: boolean;
|
||||
}): string {
|
||||
return [
|
||||
params.side,
|
||||
params.tabId,
|
||||
params.targetConnectionKey,
|
||||
params.sourceSessionId ?? "",
|
||||
params.initialPath ?? "",
|
||||
params.forceNewTab ? "force-new-tab" : "",
|
||||
].join("\u0000");
|
||||
}
|
||||
|
||||
interface UseSftpConnectionsResult {
|
||||
connect: (side: "left" | "right", host: Host | "local", options?: SftpConnectOptions) => Promise<void>;
|
||||
disconnect: (side: "left" | "right") => Promise<void>;
|
||||
@@ -249,6 +291,7 @@ export const useSftpConnections = ({
|
||||
const [hostKeyVerification, setHostKeyVerification] = useState<SftpHostKeyVerificationState | null>(null);
|
||||
const hostKeyVerificationRef = useRef<(SftpHostKeyVerificationState & { requestId: string; sessionId: string }) | null>(null);
|
||||
const activeHostKeySessionsRef = useRef<Map<string, { side: "left" | "right"; tabId: string }>>(new Map());
|
||||
const connectInFlightRef = useRef<Map<string, Promise<void>>>(new Map());
|
||||
|
||||
const setPendingHostKeyVerification = useCallback((
|
||||
next: (SftpHostKeyVerificationState & { requestId: string; sessionId: string }) | null,
|
||||
@@ -426,21 +469,31 @@ export const useSftpConnections = ({
|
||||
// immediately, avoiding race conditions with deferred effects.
|
||||
options?.onTabCreated?.(activeTabId);
|
||||
|
||||
const connectionId = createSftpConnectionId(side);
|
||||
const connectInFlightKey = buildSftpConnectInFlightKey({
|
||||
side,
|
||||
tabId: activeTabId,
|
||||
targetConnectionKey,
|
||||
sourceSessionId: host === "local" ? undefined : options?.sourceSessionId,
|
||||
initialPath: effectiveInitialPath,
|
||||
forceNewTab: options?.forceNewTab,
|
||||
});
|
||||
|
||||
navSeqRef.current[side] += 1;
|
||||
const connectRequestId = navSeqRef.current[side];
|
||||
const getTargetPane = () => {
|
||||
const targetSide = resolveTargetSide();
|
||||
const tabs = targetSide === "left" ? leftTabsRef.current.tabs : rightTabsRef.current.tabs;
|
||||
return tabs.find((tab) => tab.id === activeTabId) ?? null;
|
||||
};
|
||||
const isTargetConnectionCurrent = () => {
|
||||
const pane = getTargetPane();
|
||||
if (!pane) return false;
|
||||
if (pane.connection?.id === connectionId) return true;
|
||||
return !pane.connection && navSeqRef.current[side] === connectRequestId;
|
||||
};
|
||||
return runSftpConnectOnceByKey(connectInFlightRef.current, connectInFlightKey, async () => {
|
||||
const connectionId = createSftpConnectionId(side);
|
||||
|
||||
navSeqRef.current[side] += 1;
|
||||
const connectRequestId = navSeqRef.current[side];
|
||||
const getTargetPane = () => {
|
||||
const targetSide = resolveTargetSide();
|
||||
const tabs = targetSide === "left" ? leftTabsRef.current.tabs : rightTabsRef.current.tabs;
|
||||
return tabs.find((tab) => tab.id === activeTabId) ?? null;
|
||||
};
|
||||
const isTargetConnectionCurrent = () => {
|
||||
const pane = getTargetPane();
|
||||
if (!pane) return false;
|
||||
if (pane.connection?.id === connectionId) return true;
|
||||
return !pane.connection && navSeqRef.current[side] === connectRequestId;
|
||||
};
|
||||
const isTargetConnectionAtPath = (path: string) => {
|
||||
const connection = getTargetPane()?.connection;
|
||||
if (!connection) return navSeqRef.current[side] === connectRequestId;
|
||||
@@ -573,9 +626,7 @@ export const useSftpConnections = ({
|
||||
sharedHostCache?.path,
|
||||
);
|
||||
|
||||
// Sudo never reuses a terminal shell; normalize before UI state so
|
||||
// reusedConnection (spinner/overlay) matches the actual open path.
|
||||
const sourceSessionId = host.sftpSudo ? undefined : options?.sourceSessionId;
|
||||
const sourceSessionId = options?.sourceSessionId;
|
||||
|
||||
const connection: SftpConnection = {
|
||||
id: connectionId,
|
||||
@@ -698,15 +749,7 @@ export const useSftpConnections = ({
|
||||
}
|
||||
|
||||
if (!detected) {
|
||||
const candidates: string[] = [];
|
||||
if (credentials.username === "root") {
|
||||
candidates.push("/root");
|
||||
} else if (credentials.username) {
|
||||
candidates.push(`/home/${credentials.username}`);
|
||||
candidates.push("/root");
|
||||
} else {
|
||||
candidates.push("/root");
|
||||
}
|
||||
const candidates = buildSftpHomeDirCandidates(credentials.username);
|
||||
const statSftp = bridge?.statSftp;
|
||||
if (statSftp) {
|
||||
for (const candidate of candidates) {
|
||||
@@ -784,6 +827,23 @@ export const useSftpConnections = ({
|
||||
// root also failed
|
||||
}
|
||||
}
|
||||
// Last resort: home candidates. Covers provisional "/" from realpath
|
||||
// when discovery treated root as home and listing it failed, so
|
||||
// /home/<user> or /root can still recover the session.
|
||||
if (!fallbackSucceeded) {
|
||||
for (const candidate of buildSftpHomeDirCandidates(credentials.username)) {
|
||||
if (candidate === startPath) continue;
|
||||
try {
|
||||
files = await listRemoteFiles(sftpId, candidate, filenameEncoding);
|
||||
startPath = candidate;
|
||||
homeDir = candidate;
|
||||
fallbackSucceeded = true;
|
||||
break;
|
||||
} catch {
|
||||
// Ignore missing/permission errors
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!fallbackSucceeded) {
|
||||
throw new Error("Cannot list any remote directory");
|
||||
}
|
||||
@@ -855,6 +915,7 @@ export const useSftpConnections = ({
|
||||
unsubSftpProgress?.();
|
||||
}
|
||||
}
|
||||
});
|
||||
},
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
|
||||
test("startup landing setting participates in cross-window settings sync", () => {
|
||||
const storageSyncSource = readFileSync(new URL("./settingsStorageSync.ts", import.meta.url), "utf8");
|
||||
const ipcSyncSource = readFileSync(new URL("./settingsIpcSync.ts", import.meta.url), "utf8");
|
||||
|
||||
assert.match(storageSyncSource, /STORAGE_KEY_STARTUP_LANDING/);
|
||||
assert.match(storageSyncSource, /setStartupLandingState/);
|
||||
assert.match(storageSyncSource, /e\.key === STORAGE_KEY_STARTUP_LANDING/);
|
||||
|
||||
assert.match(ipcSyncSource, /STORAGE_KEY_STARTUP_LANDING/);
|
||||
assert.match(ipcSyncSource, /setStartupLandingState/);
|
||||
assert.match(ipcSyncSource, /key === STORAGE_KEY_STARTUP_LANDING/);
|
||||
});
|
||||
|
||||
test("startup landing does not bump the cloud sync settings version", () => {
|
||||
const settingsSource = readFileSync(new URL("./useSettingsState.ts", import.meta.url), "utf8");
|
||||
const settingsVersionIndex = settingsSource.indexOf("settingsVersion: useMemo");
|
||||
const settingsVersionSource = settingsSource.slice(settingsVersionIndex);
|
||||
|
||||
assert.notEqual(settingsVersionIndex, -1);
|
||||
assert.doesNotMatch(settingsVersionSource, /startupLanding/);
|
||||
});
|
||||
|
||||
test("main window opens local terminal on cold start when preferred", () => {
|
||||
const sideEffectsSource = readFileSync(new URL("../app/AppSideEffects.tsx", import.meta.url), "utf8");
|
||||
|
||||
assert.match(sideEffectsSource, /startupLocalTerminalAttemptedRef/);
|
||||
assert.match(sideEffectsSource, /shouldOpenLocalTerminalOnStartup/);
|
||||
assert.match(sideEffectsSource, /STORAGE_KEY_STARTUP_LANDING/);
|
||||
assert.match(sideEffectsSource, /handleCreateLocalTerminal\(/);
|
||||
assert.match(sideEffectsSource, /onColdStartIntentsSettled/);
|
||||
assert.match(sideEffectsSource, /ensureDiscoveredShells/);
|
||||
assert.match(sideEffectsSource, /hasQueuedStartupIntent:\s*startupLaunchIntentReceivedRef\.current/);
|
||||
|
||||
const latchAt = sideEffectsSource.indexOf("if (startupLocalTerminalAttemptedRef.current) return;");
|
||||
const setLatchAt = sideEffectsSource.indexOf(
|
||||
"startupLocalTerminalAttemptedRef.current = true;",
|
||||
latchAt,
|
||||
);
|
||||
const createAt = sideEffectsSource.indexOf("handleCreateLocalTerminal(", setLatchAt);
|
||||
assert.notEqual(latchAt, -1);
|
||||
assert.notEqual(setLatchAt, -1);
|
||||
assert.notEqual(createAt, -1);
|
||||
assert.ok(setLatchAt < createAt, "StrictMode latch must be set before creating the terminal");
|
||||
});
|
||||
|
||||
test("settings system tab exposes startup landing control", () => {
|
||||
const tabSource = readFileSync(
|
||||
new URL("../../components/settings/tabs/SettingsSystemTab.tsx", import.meta.url),
|
||||
"utf8",
|
||||
);
|
||||
assert.match(tabSource, /system-startup-landing/);
|
||||
assert.match(tabSource, /settings\.sessionRestore\.startupLanding/);
|
||||
assert.match(tabSource, /local-terminal/);
|
||||
});
|
||||
@@ -0,0 +1,75 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import test from 'node:test';
|
||||
|
||||
import { createLatestPayloadSync } from './useExternalMcpSessionSync.ts';
|
||||
|
||||
const hookSource = readFileSync(new URL('./useExternalMcpSessionSync.ts', import.meta.url), 'utf8');
|
||||
const appSource = readFileSync(new URL('../app/AppSideEffects.tsx', import.meta.url), 'utf8');
|
||||
const preloadSource = readFileSync(new URL('../../electron/preload/api.cjs', import.meta.url), 'utf8');
|
||||
const handlerSource = readFileSync(
|
||||
new URL('../../electron/bridges/aiBridge/agentProcessHandlers.cjs', import.meta.url),
|
||||
'utf8',
|
||||
);
|
||||
|
||||
test('app live session sync stays mounted and is independent of External MCP enablement', () => {
|
||||
assert.match(appSource, /useExternalMcpSessionSync\(\{/);
|
||||
assert.match(hookSource, /bridge\.aiMcpUpdateLiveSessions\?\.\(nextPayload\)/);
|
||||
assert.match(hookSource, /liveSyncRef\.current\.push\(payload, serialized\)/);
|
||||
assert.match(hookSource, /bridge\.aiMcpUpdateSessions\?\.\(/);
|
||||
assert.match(hookSource, /externalSyncRef\.current\.push\(payload, serialized\)/);
|
||||
assert.doesNotMatch(
|
||||
hookSource,
|
||||
/if \(!enabled\)[\s\S]{0,300}aiMcpUpdateLiveSessions/,
|
||||
);
|
||||
});
|
||||
|
||||
test('live session sync has a complete renderer to main-process IPC path', () => {
|
||||
assert.match(preloadSource, /netcatty:ai:mcp:update-live-sessions/);
|
||||
assert.match(handlerSource, /netcatty:ai:mcp:update-live-sessions/);
|
||||
assert.match(handlerSource, /mcpServerBridge\.updateLiveSessionMetadata/);
|
||||
});
|
||||
|
||||
test('latest payload sync converges to A-B-A while B is in flight', async () => {
|
||||
let releaseB: (() => void) | undefined;
|
||||
const sent: string[] = [];
|
||||
const sync = createLatestPayloadSync(async (payload: string) => {
|
||||
sent.push(payload);
|
||||
if (payload === 'B') {
|
||||
await new Promise<void>((resolve) => {
|
||||
releaseB = resolve;
|
||||
});
|
||||
}
|
||||
return { ok: true };
|
||||
});
|
||||
|
||||
await sync.push('A');
|
||||
const sendingB = sync.push('B');
|
||||
void sync.push('A');
|
||||
releaseB?.();
|
||||
await sendingB;
|
||||
|
||||
assert.deepEqual(sent, ['A', 'B', 'A']);
|
||||
});
|
||||
|
||||
test('cancelling a payload sync drops a queued update', async () => {
|
||||
let releaseA: (() => void) | undefined;
|
||||
const sent: string[] = [];
|
||||
const sync = createLatestPayloadSync(async (payload: string) => {
|
||||
sent.push(payload);
|
||||
if (payload === 'A') {
|
||||
await new Promise<void>((resolve) => {
|
||||
releaseA = resolve;
|
||||
});
|
||||
}
|
||||
return { ok: true };
|
||||
});
|
||||
|
||||
const sendingA = sync.push('A');
|
||||
void sync.push('B');
|
||||
sync.cancel();
|
||||
releaseA?.();
|
||||
await sendingA;
|
||||
|
||||
assert.deepEqual(sent, ['A']);
|
||||
});
|
||||
@@ -17,6 +17,49 @@ type UseExternalMcpSessionSyncOptions = {
|
||||
portForwardingRules: PortForwardingRule[];
|
||||
};
|
||||
|
||||
export function createLatestPayloadSync<T>(
|
||||
send: (payload: T) => Promise<unknown>,
|
||||
) {
|
||||
let desired: { serialized: string; payload: T } | null = null;
|
||||
let acknowledged = '';
|
||||
let inFlight: Promise<void> | null = null;
|
||||
let cancelled = false;
|
||||
|
||||
const pump = async () => {
|
||||
while (!cancelled && desired && desired.serialized !== acknowledged) {
|
||||
const target = desired;
|
||||
try {
|
||||
const result = await send(target.payload);
|
||||
if (cancelled) break;
|
||||
if (result && typeof result === 'object' && 'ok' in result && (result as { ok?: boolean }).ok === false) {
|
||||
if (desired === target) break;
|
||||
continue;
|
||||
}
|
||||
acknowledged = target.serialized;
|
||||
} catch {
|
||||
if (desired === target) break;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
push(payload: T, serialized = JSON.stringify(payload)): Promise<void> {
|
||||
if (cancelled) return Promise.resolve();
|
||||
desired = { payload, serialized };
|
||||
if (!inFlight) {
|
||||
inFlight = pump().finally(() => {
|
||||
inFlight = null;
|
||||
});
|
||||
}
|
||||
return inFlight;
|
||||
},
|
||||
cancel(): void {
|
||||
cancelled = true;
|
||||
desired = null;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function isMainAppWindow(): boolean {
|
||||
if (typeof window === 'undefined') return false;
|
||||
const hash = window.location.hash || '';
|
||||
@@ -43,7 +86,6 @@ export function useExternalMcpSessionSync({
|
||||
void enabledTick;
|
||||
return readExternalMcpStoredEnabled();
|
||||
}, [enabledTick]);
|
||||
const syncGenerationRef = useRef(0);
|
||||
|
||||
useEffect(() => {
|
||||
const bump = () => setEnabledTick((value) => value + 1);
|
||||
@@ -77,34 +119,41 @@ export function useExternalMcpSessionSync({
|
||||
});
|
||||
}, [sessions, sessionHostsMap, hosts, portForwardingRules]);
|
||||
|
||||
const lastSentSerializedRef = useRef('');
|
||||
const liveSyncRef = useRef<ReturnType<typeof createLatestPayloadSync<typeof payload>> | null>(null);
|
||||
const externalSyncRef = useRef<ReturnType<typeof createLatestPayloadSync<typeof payload>> | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isMainAppWindow()) return;
|
||||
const bridge = netcattyBridge.get();
|
||||
if (!bridge?.aiMcpUpdateLiveSessions) return;
|
||||
const serialized = JSON.stringify(payload);
|
||||
liveSyncRef.current ??= createLatestPayloadSync((nextPayload) => (
|
||||
Promise.resolve(bridge.aiMcpUpdateLiveSessions?.(nextPayload))
|
||||
));
|
||||
void liveSyncRef.current.push(payload, serialized);
|
||||
}, [payload]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isMainAppWindow()) return;
|
||||
if (!enabled) {
|
||||
syncGenerationRef.current += 1;
|
||||
lastSentSerializedRef.current = '';
|
||||
// Recreate the synchronizer after re-enabling because disabling can
|
||||
// clear the main-process scope even when the renderer payload is equal.
|
||||
externalSyncRef.current?.cancel();
|
||||
externalSyncRef.current = null;
|
||||
return;
|
||||
}
|
||||
const bridge = netcattyBridge.get();
|
||||
if (!bridge?.aiMcpUpdateSessions) return;
|
||||
|
||||
const serialized = JSON.stringify(payload);
|
||||
if (serialized === lastSentSerializedRef.current) return;
|
||||
|
||||
const generation = ++syncGenerationRef.current;
|
||||
const timeoutId = window.setTimeout(() => {
|
||||
void Promise.resolve(bridge.aiMcpUpdateSessions?.(payload, EXTERNAL_MCP_CHAT_SESSION_ID))
|
||||
.then((result) => {
|
||||
if (generation !== syncGenerationRef.current) return;
|
||||
if (result && typeof result === 'object' && 'ok' in result && (result as { ok?: boolean }).ok === false) {
|
||||
return;
|
||||
}
|
||||
lastSentSerializedRef.current = serialized;
|
||||
})
|
||||
.catch(() => {
|
||||
// Leave the ref unset so the next effect can retry.
|
||||
});
|
||||
externalSyncRef.current ??= createLatestPayloadSync((nextPayload) => (
|
||||
Promise.resolve(bridge.aiMcpUpdateSessions?.(
|
||||
nextPayload,
|
||||
EXTERNAL_MCP_CHAT_SESSION_ID,
|
||||
))
|
||||
));
|
||||
void externalSyncRef.current.push(payload, serialized);
|
||||
}, 250);
|
||||
|
||||
return () => {
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import React from 'react';
|
||||
import { act, create, type ReactTestRenderer } from 'react-test-renderer';
|
||||
import {
|
||||
createDeferredOutputTriggerEventProcessor,
|
||||
createOutputTriggerScanBuffer,
|
||||
@@ -7,6 +9,7 @@ import {
|
||||
findMatchEndingAfter,
|
||||
hasApplicableOutputTriggerSnippet,
|
||||
inspectDroppedOutputOverflowAlternateScreenState,
|
||||
useOutputTriggers,
|
||||
} from './useOutputTriggers.ts';
|
||||
import { createTerminalOutputTriggerFilter } from '@/domain/terminalOutputTriggerFilter.ts';
|
||||
import type { Snippet } from '@/domain/models';
|
||||
@@ -93,6 +96,80 @@ test('hasApplicableOutputTriggerSnippet requires a runnable output trigger for t
|
||||
assert.equal(hasApplicableOutputTriggerSnippet(snippets, 'host-a'), true);
|
||||
});
|
||||
|
||||
test('hasApplicableOutputTriggerSnippet follows the host current group', () => {
|
||||
const snippets: Snippet[] = [{
|
||||
id: 'group-trigger',
|
||||
label: 'Group trigger',
|
||||
command: '',
|
||||
kind: 'script',
|
||||
trigger: 'onOutput',
|
||||
triggerPattern: 'READY',
|
||||
targetGroups: ['Production'],
|
||||
}];
|
||||
assert.equal(
|
||||
hasApplicableOutputTriggerSnippet(snippets, { id: 'host-a', group: 'Production/Web' }),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
hasApplicableOutputTriggerSnippet(snippets, { id: 'host-a', group: 'Staging' }),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test('equivalent host objects do not reset a pending output trigger match', async () => {
|
||||
const actEnvironment = globalThis as typeof globalThis & {
|
||||
IS_REACT_ACT_ENVIRONMENT?: boolean;
|
||||
};
|
||||
const previousActEnvironment = actEnvironment.IS_REACT_ACT_ENVIRONMENT;
|
||||
actEnvironment.IS_REACT_ACT_ENVIRONMENT = true;
|
||||
const snippets: Snippet[] = [{
|
||||
id: 'stable-host-trigger',
|
||||
label: 'Stable host trigger',
|
||||
command: '',
|
||||
kind: 'script',
|
||||
trigger: 'onOutput',
|
||||
triggerPattern: 'READY',
|
||||
targetGroups: ['Production'],
|
||||
}];
|
||||
let appendOutput: ((chunk: string) => void) | undefined;
|
||||
let runCount = 0;
|
||||
let renderer: ReactTestRenderer | null = null;
|
||||
const onRunScript = () => {
|
||||
runCount += 1;
|
||||
};
|
||||
|
||||
const Probe = ({ host }: { host: { id: string; group: string } }) => {
|
||||
appendOutput = useOutputTriggers({
|
||||
sessionId: 'session-1',
|
||||
host,
|
||||
snippets,
|
||||
onRunScript,
|
||||
}).appendOutput;
|
||||
return null;
|
||||
};
|
||||
|
||||
await act(async () => {
|
||||
renderer = create(React.createElement(Probe, {
|
||||
host: { id: 'host-a', group: 'Production/Web' },
|
||||
}));
|
||||
});
|
||||
try {
|
||||
await act(async () => {
|
||||
appendOutput?.('REA');
|
||||
renderer?.update(React.createElement(Probe, {
|
||||
host: { id: 'host-a', group: 'Production/Web' },
|
||||
}));
|
||||
appendOutput?.('DY');
|
||||
await new Promise((resolve) => setTimeout(resolve, 40));
|
||||
});
|
||||
} finally {
|
||||
await act(async () => renderer?.unmount());
|
||||
actEnvironment.IS_REACT_ACT_ENVIRONMENT = previousActEnvironment;
|
||||
}
|
||||
|
||||
assert.equal(runCount, 1);
|
||||
});
|
||||
|
||||
test('output trigger scan buffer consumes scanned content and only keeps overlap', () => {
|
||||
const buffer = createOutputTriggerScanBuffer(4);
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useCallback, useEffect, useMemo, useRef } from 'react';
|
||||
import type { Snippet } from '@/domain/models';
|
||||
import type { Host, Snippet } from '@/domain/models';
|
||||
import { snippetAppliesToOutputTrigger } from '@/domain/snippetTargets.ts';
|
||||
import { isScriptSnippet } from '@/domain/snippetScript.ts';
|
||||
import {
|
||||
@@ -19,7 +19,7 @@ const OUTPUT_TRIGGER_DROPPED_OVERFLOW_SCAN_WINDOW_CHARS = 2048;
|
||||
|
||||
type OutputTriggerContext = {
|
||||
sessionId: string;
|
||||
hostId?: string;
|
||||
host?: Pick<Host, 'id' | 'group'>;
|
||||
snippets: Snippet[];
|
||||
onRunScript: (snippet: Snippet, sessionId: string) => void | Promise<void>;
|
||||
};
|
||||
@@ -575,22 +575,31 @@ export function findMatchEndingAfter(text: string, pattern: string, minEndOffset
|
||||
return null;
|
||||
}
|
||||
|
||||
export function hasApplicableOutputTriggerSnippet(snippets: Snippet[], hostId?: string): boolean {
|
||||
export function hasApplicableOutputTriggerSnippet(
|
||||
snippets: Snippet[],
|
||||
host?: Pick<Host, 'id' | 'group'> | string,
|
||||
): boolean {
|
||||
return snippets.some((snippet) => (
|
||||
isScriptSnippet(snippet)
|
||||
&& snippet.trigger === 'onOutput'
|
||||
&& Boolean(snippet.triggerPattern)
|
||||
&& Boolean(snippet.id)
|
||||
&& snippetAppliesToOutputTrigger(snippet, hostId)
|
||||
&& snippetAppliesToOutputTrigger(snippet, host)
|
||||
));
|
||||
}
|
||||
|
||||
export function useOutputTriggers({
|
||||
sessionId,
|
||||
hostId,
|
||||
host,
|
||||
snippets,
|
||||
onRunScript,
|
||||
}: OutputTriggerContext) {
|
||||
const targetHostId = host?.id;
|
||||
const targetHostGroup = host?.group;
|
||||
const targetHost = useMemo(
|
||||
() => targetHostId ? { id: targetHostId, group: targetHostGroup } : undefined,
|
||||
[targetHostGroup, targetHostId],
|
||||
);
|
||||
const launchingRef = useRef(false);
|
||||
const lastTriggerMatchEndRef = useRef(new Map<string, number>());
|
||||
const serverOutputFilterRef = useRef(createTerminalOutputTriggerFilter());
|
||||
@@ -600,8 +609,8 @@ export function useOutputTriggers({
|
||||
const pendingDroppedOverflowFinalActionRef = useRef<'leave' | null>(null);
|
||||
const pendingDroppedOverflowScanStateResetRef = useRef(false);
|
||||
const hasOutputTriggers = useMemo(
|
||||
() => hasApplicableOutputTriggerSnippet(snippets, hostId),
|
||||
[hostId, snippets],
|
||||
() => hasApplicableOutputTriggerSnippet(snippets, targetHost),
|
||||
[snippets, targetHost],
|
||||
);
|
||||
|
||||
const scanOutput = useCallback((scannableText: string) => {
|
||||
@@ -621,7 +630,7 @@ export function useOutputTriggers({
|
||||
if (!isScriptSnippet(snippet) || snippet.trigger !== 'onOutput' || !snippet.triggerPattern || !snippet.id) {
|
||||
continue;
|
||||
}
|
||||
if (!snippetAppliesToOutputTrigger(snippet, hostId)) continue;
|
||||
if (!snippetAppliesToOutputTrigger(snippet, targetHost)) continue;
|
||||
try {
|
||||
const matched = findMatchEndingAfter(scanWindow.text, snippet.triggerPattern, scanWindow.minEndOffset);
|
||||
if (!matched) {
|
||||
@@ -647,7 +656,7 @@ export function useOutputTriggers({
|
||||
// ignore invalid regex
|
||||
}
|
||||
}
|
||||
}, [hasOutputTriggers, hostId, onRunScript, sessionId, snippets]);
|
||||
}, [hasOutputTriggers, onRunScript, sessionId, snippets, targetHost]);
|
||||
|
||||
const scanOutputRef = useRef(scanOutput);
|
||||
scanOutputRef.current = scanOutput;
|
||||
@@ -813,7 +822,7 @@ export function useOutputTriggers({
|
||||
outputTriggerScanSuppressedRef.current = false;
|
||||
pendingDroppedOverflowFinalActionRef.current = null;
|
||||
pendingDroppedOverflowScanStateResetRef.current = false;
|
||||
}, [sessionId, hostId, hasOutputTriggers, outputTriggerEventProcessor]);
|
||||
}, [sessionId, targetHost, hasOutputTriggers, outputTriggerEventProcessor]);
|
||||
|
||||
useEffect(() => () => {
|
||||
outputTriggerEventProcessor.reset();
|
||||
|
||||
@@ -52,6 +52,7 @@ import {
|
||||
STORAGE_KEY_SESSION_LOGS_ENABLED,
|
||||
STORAGE_KEY_RESTORE_PREVIOUS_SESSION,
|
||||
STORAGE_KEY_RESTORE_TERMINAL_CWD,
|
||||
STORAGE_KEY_STARTUP_LANDING,
|
||||
STORAGE_KEY_SESSION_LOGS_DIR,
|
||||
STORAGE_KEY_SESSION_LOGS_FORMAT,
|
||||
STORAGE_KEY_SESSION_LOGS_TIMESTAMPS_ENABLED,
|
||||
@@ -146,6 +147,10 @@ import {
|
||||
type HostClickBehavior,
|
||||
} from './settingsStateDefaults';
|
||||
import { isHostClickBehavior } from '../../domain/hostClickBehavior';
|
||||
import {
|
||||
resolveStartupLandingSetting,
|
||||
type StartupLanding,
|
||||
} from '../../domain/startupLanding';
|
||||
import { resolveRestorePreviousSessionSetting, resolveRestoreTerminalCwdSetting } from './sessionRestoreSettings';
|
||||
import { sessionRestoreStorage } from './sessionRestoreStorage';
|
||||
import { useSettingsStorageSync } from './settingsStorageSync';
|
||||
@@ -431,6 +436,9 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
|
||||
const stored = localStorageAdapter.readBoolean(STORAGE_KEY_RESTORE_TERMINAL_CWD);
|
||||
return resolveRestoreTerminalCwdSetting(stored);
|
||||
});
|
||||
const [startupLanding, setStartupLandingState] = useState<StartupLanding>(() => {
|
||||
return resolveStartupLandingSetting(readStoredString(STORAGE_KEY_STARTUP_LANDING));
|
||||
});
|
||||
const [sftpTransferConcurrency, setSftpTransferConcurrencyState] = useState<number>(() => {
|
||||
return resolveSftpTransferConcurrency(() =>
|
||||
localStorageAdapter.readNumber(STORAGE_KEY_SFTP_TRANSFER_CONCURRENCY),
|
||||
@@ -955,6 +963,7 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
|
||||
setRestorePreviousSessionState(resolveRestorePreviousSessionSetting(storedRestorePreviousSession));
|
||||
const storedRestoreTerminalCwd = localStorageAdapter.readBoolean(STORAGE_KEY_RESTORE_TERMINAL_CWD);
|
||||
setRestoreTerminalCwdState(resolveRestoreTerminalCwdSetting(storedRestoreTerminalCwd));
|
||||
setStartupLandingState(resolveStartupLandingSetting(readStoredString(STORAGE_KEY_STARTUP_LANDING)));
|
||||
|
||||
// Workspace focus style
|
||||
const storedFocusStyle = readStoredString(STORAGE_KEY_WORKSPACE_FOCUS_STYLE);
|
||||
@@ -1123,6 +1132,7 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
|
||||
setDisableTerminalFontZoomState,
|
||||
setRestorePreviousSessionState,
|
||||
setRestoreTerminalCwdState,
|
||||
setStartupLandingState,
|
||||
setSftpTransferConcurrencyState,
|
||||
setSshTransportIdleTtlMsState,
|
||||
});
|
||||
@@ -1153,7 +1163,7 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
|
||||
terminalThemeId, followAppTerminalTheme, terminalFontFamilyId, terminalFontSize,
|
||||
sftpDoubleClickBehavior, sftpAutoSync, sftpShowHiddenFiles,
|
||||
sftpUseCompressedUpload, sftpSkipUnchanged, sftpAutoOpenSidebar, sftpFollowTerminalCwd, sftpDefaultViewMode,
|
||||
showRecentHosts, hostClickBehavior, showOnlyUngroupedHostsInRoot, showSftpTab, showHostTreeSidebar, terminalSidePanelAutoOpen, terminalSidePanelAutoOpenTab, shellOnlyTabNumberShortcuts, showTabNumberBadges, disableTerminalFontZoom, restorePreviousSession, restoreTerminalCwd,
|
||||
showRecentHosts, hostClickBehavior, showOnlyUngroupedHostsInRoot, showSftpTab, showHostTreeSidebar, terminalSidePanelAutoOpen, terminalSidePanelAutoOpenTab, shellOnlyTabNumberShortcuts, showTabNumberBadges, disableTerminalFontZoom, restorePreviousSession, restoreTerminalCwd, startupLanding,
|
||||
editorWordWrap, sessionLogsEnabled, sessionLogsDir, sessionLogsFormat, sessionLogsTimestampsEnabled, sshDebugLogsEnabled, sshDeepLinkEnabled, jmsDeepLinkEnabled, explorerContextMenuEnabled,
|
||||
globalHotkeyEnabled, autoUpdateEnabled, windowOpacity, appIconVariant,
|
||||
setTheme, setLightUiThemeId, setDarkUiThemeId, setAccentMode,
|
||||
@@ -1163,7 +1173,7 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
|
||||
setFollowAppTerminalThemeState, setTerminalFontFamilyId, setTerminalFontSize: applyIncomingTerminalFontSize,
|
||||
setSftpDoubleClickBehavior, setSftpAutoSync, setSftpShowHiddenFiles,
|
||||
setSftpUseCompressedUpload, setSftpSkipUnchanged, setSftpAutoOpenSidebar, setSftpFollowTerminalCwd, setSftpDefaultViewMode,
|
||||
setShowRecentHostsState, setHostClickBehaviorState, setShowOnlyUngroupedHostsInRootState, setShowSftpTabState, setShowHostTreeSidebarState, setTerminalSidePanelAutoOpenState, setTerminalSidePanelAutoOpenTabState, setShellOnlyTabNumberShortcutsState, setShowTabNumberBadgesState, setDisableTerminalFontZoomState, setRestorePreviousSessionState, setRestoreTerminalCwdState,
|
||||
setShowRecentHostsState, setHostClickBehaviorState, setShowOnlyUngroupedHostsInRootState, setShowSftpTabState, setShowHostTreeSidebarState, setTerminalSidePanelAutoOpenState, setTerminalSidePanelAutoOpenTabState, setShellOnlyTabNumberShortcutsState, setShowTabNumberBadgesState, setDisableTerminalFontZoomState, setRestorePreviousSessionState, setRestoreTerminalCwdState, setStartupLandingState,
|
||||
setEditorWordWrapState, setSessionLogsEnabled, setSessionLogsDir, setSessionLogsFormat, setSessionLogsTimestampsEnabled, setSshDebugLogsEnabled, setSshDeepLinkEnabledState: applyIncomingSshDeepLinkEnabled, setJmsDeepLinkEnabledState: applyIncomingJmsDeepLinkEnabled, setExplorerContextMenuEnabledState: applyIncomingExplorerContextMenuEnabled,
|
||||
setGlobalHotkeyEnabled, setWindowOpacity: applyIncomingWindowOpacity, setAppIconVariant, setAutoUpdateEnabled, setWorkspaceFocusStyleState,
|
||||
setSftpTransferConcurrencyState, setSshTransportIdleTtlMsState,
|
||||
@@ -1360,6 +1370,13 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
|
||||
notifySettingsChanged(STORAGE_KEY_RESTORE_TERMINAL_CWD, enabled);
|
||||
}, [notifySettingsChanged]);
|
||||
|
||||
const setStartupLanding = useCallback((landing: StartupLanding) => {
|
||||
setStartupLandingState(landing);
|
||||
localStorageAdapter.writeString(STORAGE_KEY_STARTUP_LANDING, landing);
|
||||
if (!persistMountedRef.current) return;
|
||||
notifySettingsChanged(STORAGE_KEY_STARTUP_LANDING, landing);
|
||||
}, [notifySettingsChanged]);
|
||||
|
||||
// Apply and persist custom CSS
|
||||
useEffect(() => {
|
||||
applyCustomCssToDocument(customCSS);
|
||||
@@ -1964,6 +1981,8 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
|
||||
setRestorePreviousSession,
|
||||
restoreTerminalCwd,
|
||||
setRestoreTerminalCwd,
|
||||
startupLanding,
|
||||
setStartupLanding,
|
||||
sftpTransferConcurrency,
|
||||
setSftpTransferConcurrency,
|
||||
sshTransportIdleTtlMs,
|
||||
|
||||
@@ -30,7 +30,7 @@ import { buildSftpHostCredentials } from "./sftp/useSftpHostCredentials";
|
||||
import { useSftpFileWatch } from "./sftp/useSftpFileWatch";
|
||||
import { useSftpSessionCleanup } from "./sftp/useSftpSessionCleanup";
|
||||
import { useSftpSessionErrors } from "./sftp/useSftpSessionErrors";
|
||||
import { ensureRemoteSftpSession } from "./sftp/ensureRemoteSftpSession";
|
||||
import { ensureRemoteSftpSession, probeSftpSession } from "./sftp/ensureRemoteSftpSession";
|
||||
import { openTransferSftpSession } from "./sftp/dedicatedTransferResume";
|
||||
import {
|
||||
createTransferPoolKeyCache,
|
||||
@@ -316,9 +316,7 @@ export const useSftpState = (
|
||||
// Prefer borrowing the live (or parked) terminal SSH transport so MFA is
|
||||
// not repeated. Transport leases keep the shared conn alive after the
|
||||
// terminal tab closes until the transfer SFTP lease is returned.
|
||||
const sourceSessionId = !host.sftpSudo
|
||||
? resolveTransferSourceSessionId?.(host.id, host)
|
||||
: undefined;
|
||||
const sourceSessionId = resolveTransferSourceSessionId?.(host.id, host);
|
||||
if (sourceSessionId) {
|
||||
try {
|
||||
logger.info(
|
||||
@@ -608,16 +606,7 @@ export const useSftpState = (
|
||||
forceReconnect: ensureOptions?.forceReconnect,
|
||||
releaseConnection,
|
||||
tabId,
|
||||
probeSession: async (sftpId) => {
|
||||
// Lightweight liveness check; any session-error from the bridge
|
||||
// triggers a reconnect in ensureRemoteSftpSession.
|
||||
if (!bridge?.getSftpHomeDir) return true;
|
||||
const result = await bridge.getSftpHomeDir(sftpId);
|
||||
if (result && result.success === false) {
|
||||
throw new Error(result.error || "SFTP session not found");
|
||||
}
|
||||
return true;
|
||||
},
|
||||
probeSession: (sftpId) => probeSftpSession(bridge, sftpId),
|
||||
});
|
||||
},
|
||||
resolveConnectedHost: (tabId) => connectedHostByTabIdRef.current.get(tabId) ?? null,
|
||||
@@ -704,6 +693,12 @@ export const useSftpState = (
|
||||
const pane = getActivePane(side);
|
||||
if (!pane?.connection || pane.connection.isLocal) continue;
|
||||
if (sftpSessionsRef.current.has(pane.connection.id)) continue;
|
||||
const connectedHost = connectedHostByTabIdRef.current.get(pane.id) ?? null;
|
||||
const vaultHost = hosts.find((host) => host.id === pane.connection?.hostId) ?? null;
|
||||
const targetHost = connectedHost && connectedHost !== "local" ? connectedHost : vaultHost;
|
||||
const resolvedSourceSessionId = targetHost
|
||||
? resolveBrowseSourceSessionId?.(targetHost.id, targetHost)
|
||||
: undefined;
|
||||
try {
|
||||
await ensureRemoteSftpSession({
|
||||
side,
|
||||
@@ -713,16 +708,10 @@ export const useSftpState = (
|
||||
connect,
|
||||
resolveConnectedHost: (id) => connectedHostByTabIdRef.current.get(id) ?? null,
|
||||
resolveHostById: (hostId) => hosts.find((host) => host.id === hostId) ?? null,
|
||||
resolveSourceSessionId: resolveBrowseSourceSessionId,
|
||||
probeSession: async (sftpId) => {
|
||||
const bridge = netcattyBridge.get();
|
||||
if (!bridge?.getSftpHomeDir) return true;
|
||||
const result = await bridge.getSftpHomeDir(sftpId);
|
||||
if (result && result.success === false) {
|
||||
throw new Error(result.error || "SFTP session not found");
|
||||
}
|
||||
return true;
|
||||
},
|
||||
resolveSourceSessionId: resolvedSourceSessionId
|
||||
? () => resolvedSourceSessionId
|
||||
: resolveBrowseSourceSessionId,
|
||||
probeSession: (sftpId) => probeSftpSession(netcattyBridge.get(), sftpId),
|
||||
releaseConnection,
|
||||
});
|
||||
logger.info(`[SFTP] Restored browse session on ${side}`);
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
resolveVaultAgentEffectiveHost,
|
||||
resolveVaultAgentNotes,
|
||||
} from './useVaultAgentBridge';
|
||||
import type { Host } from '../../domain/models';
|
||||
import { publishNotesSnapshot } from './notesStore';
|
||||
|
||||
type Snapshot = Parameters<typeof haveSameVaultAgentSnapshot>[0];
|
||||
@@ -28,10 +29,10 @@ describe('haveSameVaultAgentSnapshot', () => {
|
||||
|
||||
describe('resolveVaultAgentEffectiveHost', () => {
|
||||
it('uses the latest snapshotted group defaults', () => {
|
||||
const host = {
|
||||
const host: Host = {
|
||||
id: 'host-1', label: 'Host', hostname: 'host.test', username: 'root',
|
||||
group: 'production', tags: [], os: 'linux',
|
||||
} as const;
|
||||
};
|
||||
|
||||
assert.equal(
|
||||
resolveVaultAgentEffectiveHost(host, [{ path: 'production', protocol: 'telnet' }], []).protocol,
|
||||
|
||||
@@ -16,6 +16,10 @@ import {
|
||||
resolveDefaultKeyPassphraseAliases,
|
||||
} from '../defaultKeyPassphrases';
|
||||
import { getNotesActions, getNotesSnapshot, subscribeNotes } from './notesStore';
|
||||
import type {
|
||||
VaultGroupMutationResult,
|
||||
VaultGroupMutationState,
|
||||
} from '../../domain/vaultGroupMutation';
|
||||
|
||||
export interface UseVaultAgentBridgeInput {
|
||||
hosts: Host[];
|
||||
@@ -29,12 +33,17 @@ export interface UseVaultAgentBridgeInput {
|
||||
terminalSettings?: Pick<TerminalSettings, 'keepaliveInterval' | 'keepaliveCountMax'>;
|
||||
updateHosts: (hosts: Host[]) => void;
|
||||
updateKeys: (keys: SSHKey[]) => Promise<unknown> | unknown;
|
||||
updateSnippets: (snippets: Snippet[]) => void;
|
||||
updateSnippets: (
|
||||
snippets: Snippet[] | ((current: Snippet[]) => Snippet[]),
|
||||
) => void;
|
||||
customGroups: string[];
|
||||
updateCustomGroups: (groups: string[]) => void;
|
||||
groupConfigs: GroupConfig[];
|
||||
updateGroupConfigs: (configs: GroupConfig[]) => void;
|
||||
updateManagedSources: (sources: ManagedSource[]) => void;
|
||||
commitVaultGroupMutation: (
|
||||
mutate: (current: VaultGroupMutationState) => VaultGroupMutationResult,
|
||||
) => Promise<VaultGroupMutationResult | { ok: false; superseded: true }>;
|
||||
updatePortForwardingRules: (rules: PortForwardingRule[]) => void;
|
||||
/** Optional override; defaults to notesStore so App need not subscribe to notes. */
|
||||
notes?: VaultNote[];
|
||||
@@ -185,6 +194,17 @@ export function useVaultAgentBridge(input: UseVaultAgentBridgeInput): void {
|
||||
vaultSnapshotRef.current.hosts = hosts;
|
||||
current.updateHosts(hosts);
|
||||
},
|
||||
commitVaultGroupMutation: async (mutate) => {
|
||||
const result = await current.commitVaultGroupMutation(mutate);
|
||||
if (result.ok) {
|
||||
vaultSnapshotRef.current.customGroups = result.state.groups;
|
||||
vaultSnapshotRef.current.groupConfigs = result.state.configs;
|
||||
vaultSnapshotRef.current.hosts = result.state.hosts;
|
||||
vaultSnapshotRef.current.managedSources = result.state.managedSources;
|
||||
vaultSnapshotRef.current.snippets = result.state.snippets;
|
||||
}
|
||||
return result;
|
||||
},
|
||||
saveKeyPassphrase: (keyPath, passphrase) => rememberKeyPassphrase({
|
||||
keyPath,
|
||||
passphrase,
|
||||
@@ -222,9 +242,11 @@ export function useVaultAgentBridge(input: UseVaultAgentBridgeInput): void {
|
||||
vaultSnapshotRef.current.notes = notes;
|
||||
applyUpdateNotes(notes);
|
||||
},
|
||||
updateSnippets: (nextSnippets) => {
|
||||
vaultSnapshotRef.current.snippets = nextSnippets;
|
||||
current.updateSnippets(nextSnippets);
|
||||
updateSnippets: (snippetUpdate) => {
|
||||
vaultSnapshotRef.current.snippets = typeof snippetUpdate === 'function'
|
||||
? snippetUpdate(vaultSnapshotRef.current.snippets)
|
||||
: snippetUpdate;
|
||||
current.updateSnippets(snippetUpdate);
|
||||
},
|
||||
startTunnel: current.startTunnel,
|
||||
stopTunnel: current.stopTunnel,
|
||||
|
||||
@@ -31,19 +31,16 @@ test("group deletion removes saved settings in the same Vault transaction", asyn
|
||||
managedSources: storedSources,
|
||||
onReadPersistedHosts: async () => [],
|
||||
onReadPersistedManagedSources: () => storedSources,
|
||||
onCommitVaultImportTransaction: async (
|
||||
_hosts,
|
||||
updateGroups,
|
||||
updateSources,
|
||||
updateGroupConfigs,
|
||||
) => {
|
||||
committedGroupConfigs = updateGroupConfigs?.(storedGroupConfigs) ?? storedGroupConfigs;
|
||||
return {
|
||||
status: "persisted",
|
||||
groups: updateGroups(storedGroups),
|
||||
sources: updateSources(storedSources),
|
||||
groupConfigs: committedGroupConfigs,
|
||||
};
|
||||
onCommitVaultGroupMutation: async (mutate) => {
|
||||
const result = mutate({
|
||||
groups: storedGroups,
|
||||
configs: storedGroupConfigs,
|
||||
hosts: [],
|
||||
managedSources: storedSources,
|
||||
snippets: [],
|
||||
});
|
||||
if (result.ok) committedGroupConfigs = result.state.configs;
|
||||
return result;
|
||||
},
|
||||
});
|
||||
return null;
|
||||
@@ -102,14 +99,9 @@ test("group deletion restores managed files when the persisted Vault cannot be r
|
||||
onClearAndRemoveManagedSources: async () => async () => {
|
||||
restoreCount += 1;
|
||||
},
|
||||
onCommitVaultImportTransaction: async () => {
|
||||
onCommitVaultGroupMutation: async () => {
|
||||
commitCount += 1;
|
||||
return {
|
||||
status: "persisted",
|
||||
groups: [],
|
||||
sources: [],
|
||||
groupConfigs: [],
|
||||
};
|
||||
throw new Error("unexpected commit");
|
||||
},
|
||||
});
|
||||
return null;
|
||||
@@ -187,21 +179,15 @@ test("group deletion retries file clearing when another managed source enters th
|
||||
restoreCount += 1;
|
||||
};
|
||||
},
|
||||
onCommitVaultImportTransaction: async (
|
||||
_hosts,
|
||||
updateGroups,
|
||||
updateSources,
|
||||
updateGroupConfigs,
|
||||
expectedHosts,
|
||||
) => {
|
||||
onCommitVaultGroupMutation: async (mutate) => {
|
||||
commitCount += 1;
|
||||
assert.deepEqual(expectedHosts, []);
|
||||
return {
|
||||
status: "persisted",
|
||||
groups: updateGroups(["Production"]),
|
||||
sources: updateSources([firstSource, concurrentSource]),
|
||||
groupConfigs: updateGroupConfigs?.([]) ?? [],
|
||||
};
|
||||
return mutate({
|
||||
groups: ["Production"],
|
||||
configs: [],
|
||||
hosts: [],
|
||||
managedSources: [firstSource, concurrentSource],
|
||||
snippets: [],
|
||||
});
|
||||
},
|
||||
});
|
||||
return null;
|
||||
@@ -228,3 +214,67 @@ test("group deletion retries file clearing when another managed source enters th
|
||||
assert.equal(restoreCount, 1);
|
||||
assert.equal(commitCount, 1);
|
||||
});
|
||||
|
||||
test("group deletion restores managed files and does not report success when commit fails", async () => {
|
||||
const actEnvironment = globalThis as typeof globalThis & {
|
||||
IS_REACT_ACT_ENVIRONMENT?: boolean;
|
||||
};
|
||||
const previousActEnvironment = actEnvironment.IS_REACT_ACT_ENVIRONMENT;
|
||||
actEnvironment.IS_REACT_ACT_ENVIRONMENT = true;
|
||||
|
||||
const source: ManagedSource = {
|
||||
id: "managed-production",
|
||||
type: "ssh_config",
|
||||
filePath: "/tmp/managed-production.conf",
|
||||
groupName: "Production",
|
||||
lastSyncedAt: 1,
|
||||
};
|
||||
let deleteGroups: ((paths: Iterable<string>) => Promise<void>) | undefined;
|
||||
let renderer: ReactTestRenderer | null = null;
|
||||
let restoreCount = 0;
|
||||
let reportedCount = 0;
|
||||
|
||||
const Probe = () => {
|
||||
deleteGroups = useVaultGroupDeletion({
|
||||
customGroups: ["Production"],
|
||||
hosts: [],
|
||||
groupConfigs: [],
|
||||
managedSources: [source],
|
||||
onReadPersistedHosts: async () => [],
|
||||
onReadPersistedManagedSources: () => [source],
|
||||
onClearAndRemoveManagedSources: async () => async () => {
|
||||
restoreCount += 1;
|
||||
},
|
||||
onCommitVaultGroupMutation: async () => {
|
||||
throw new Error("Vault quota exhausted");
|
||||
},
|
||||
onDeletedPaths: () => {
|
||||
reportedCount += 1;
|
||||
},
|
||||
});
|
||||
return null;
|
||||
};
|
||||
|
||||
let deletionError: unknown;
|
||||
try {
|
||||
await act(async () => {
|
||||
renderer = create(React.createElement(Probe));
|
||||
});
|
||||
try {
|
||||
await act(async () => {
|
||||
await deleteGroups?.(["Production"]);
|
||||
});
|
||||
} catch (error) {
|
||||
deletionError = error;
|
||||
}
|
||||
} finally {
|
||||
await act(async () => {
|
||||
renderer?.unmount();
|
||||
});
|
||||
actEnvironment.IS_REACT_ACT_ENVIRONMENT = previousActEnvironment;
|
||||
}
|
||||
|
||||
assert.match(String(deletionError), /quota exhausted/i);
|
||||
assert.equal(restoreCount, 1);
|
||||
assert.equal(reportedCount, 0);
|
||||
});
|
||||
@@ -1,7 +1,12 @@
|
||||
import { useCallback, useRef } from "react";
|
||||
|
||||
import type { GroupConfig, Host, ManagedSource } from "../../domain/models";
|
||||
import { removeSnippetTargetGroupPaths } from "../../domain/hostGroupPathMutations";
|
||||
import { buildVaultGroupDeletion } from "../../domain/vaultGroupDeletion";
|
||||
import type {
|
||||
VaultGroupMutationResult,
|
||||
VaultGroupMutationState,
|
||||
} from "../../domain/vaultGroupMutation";
|
||||
import {
|
||||
type VaultLockHandle,
|
||||
withVaultImportLock,
|
||||
@@ -26,7 +31,7 @@ export function useVaultGroupDeletion({
|
||||
managedSources,
|
||||
onReadPersistedHosts,
|
||||
onReadPersistedManagedSources,
|
||||
onCommitVaultImportTransaction,
|
||||
onCommitVaultGroupMutation,
|
||||
onClearAndRemoveManagedSource,
|
||||
onClearAndRemoveManagedSources,
|
||||
onDeletedPaths,
|
||||
@@ -37,22 +42,10 @@ export function useVaultGroupDeletion({
|
||||
managedSources: ManagedSource[];
|
||||
onReadPersistedHosts: () => Promise<Host[]>;
|
||||
onReadPersistedManagedSources: () => ManagedSource[];
|
||||
onCommitVaultImportTransaction: (
|
||||
hosts: Host[],
|
||||
updateGroups: (current: string[]) => string[],
|
||||
updateSources: (current: ManagedSource[]) => ManagedSource[],
|
||||
updateGroupConfigs?: (current: GroupConfig[]) => GroupConfig[],
|
||||
expectedHosts?: Host[],
|
||||
onCommitVaultGroupMutation: (
|
||||
mutate: (current: VaultGroupMutationState) => VaultGroupMutationResult,
|
||||
lock?: VaultLockHandle | null,
|
||||
) => Promise<
|
||||
| {
|
||||
status: "persisted";
|
||||
groups: string[];
|
||||
sources: ManagedSource[];
|
||||
groupConfigs: GroupConfig[];
|
||||
}
|
||||
| { status: "superseded" }
|
||||
>;
|
||||
) => Promise<VaultGroupMutationResult | { ok: false; superseded: true }>;
|
||||
onClearAndRemoveManagedSource?: (source: ManagedSource) => Promise<() => Promise<void>>;
|
||||
onClearAndRemoveManagedSources?: (sources: ManagedSource[]) => Promise<() => Promise<void>>;
|
||||
onDeletedPaths?: (selectedRoots: string[]) => void;
|
||||
@@ -120,29 +113,16 @@ export function useVaultGroupDeletion({
|
||||
}
|
||||
deletion = refreshedDeletion;
|
||||
|
||||
const expectedHosts = latestRef.current.hosts;
|
||||
const expectedSources = deletion.sourcesToRemove;
|
||||
const nextHosts = deletion.hosts.filter(
|
||||
(host) => !additionallyDeletedHostIds.has(host.id),
|
||||
);
|
||||
const transaction = await onCommitVaultImportTransaction(
|
||||
nextHosts,
|
||||
(currentGroups) => buildVaultGroupDeletion({
|
||||
selectedPaths,
|
||||
deleteHosts,
|
||||
customGroups: currentGroups,
|
||||
hosts: [],
|
||||
groupConfigs: [],
|
||||
managedSources: [],
|
||||
}).customGroups,
|
||||
(currentSources) => {
|
||||
const transaction = await onCommitVaultGroupMutation(
|
||||
(current) => {
|
||||
const currentDeletion = buildVaultGroupDeletion({
|
||||
selectedPaths,
|
||||
deleteHosts,
|
||||
customGroups: [],
|
||||
hosts: [],
|
||||
groupConfigs: [],
|
||||
managedSources: currentSources,
|
||||
customGroups: current.groups,
|
||||
hosts: current.hosts,
|
||||
groupConfigs: current.configs,
|
||||
managedSources: current.managedSources,
|
||||
});
|
||||
if (!managedSourceSnapshotsMatch(
|
||||
expectedSources,
|
||||
@@ -150,29 +130,40 @@ export function useVaultGroupDeletion({
|
||||
)) {
|
||||
throw RETRY_VAULT_GROUP_DELETION;
|
||||
}
|
||||
const removedIds = new Set(expectedSources.map((source) => source.id));
|
||||
return currentSources.filter((source) => !removedIds.has(source.id));
|
||||
return {
|
||||
ok: true,
|
||||
state: {
|
||||
groups: currentDeletion.customGroups,
|
||||
hosts: currentDeletion.hosts.filter(
|
||||
(host) => !additionallyDeletedHostIds.has(host.id),
|
||||
),
|
||||
configs: currentDeletion.groupConfigs,
|
||||
managedSources: current.managedSources.filter(
|
||||
(source) => !currentDeletion.sourcesToRemove.some(
|
||||
(removed) => removed.id === source.id,
|
||||
),
|
||||
),
|
||||
snippets: removeSnippetTargetGroupPaths(
|
||||
current.snippets,
|
||||
currentDeletion.selectedRoots,
|
||||
),
|
||||
},
|
||||
};
|
||||
},
|
||||
(currentGroupConfigs) => buildVaultGroupDeletion({
|
||||
selectedPaths,
|
||||
deleteHosts,
|
||||
customGroups: [],
|
||||
hosts: [],
|
||||
groupConfigs: currentGroupConfigs,
|
||||
managedSources: [],
|
||||
}).groupConfigs,
|
||||
expectedHosts,
|
||||
lock,
|
||||
);
|
||||
if (transaction.status === "superseded") {
|
||||
// Release the lock so concurrent host saves can finish before retry.
|
||||
if (!transaction.ok && "superseded" in transaction) {
|
||||
// Release the lock so concurrent saves can finish before retry.
|
||||
return { status: "retry" as const, restoreManagedFiles };
|
||||
}
|
||||
if ("error" in transaction) {
|
||||
throw new Error(transaction.error);
|
||||
}
|
||||
latestRef.current = {
|
||||
customGroups: transaction.groups,
|
||||
hosts: nextHosts,
|
||||
groupConfigs: transaction.groupConfigs,
|
||||
managedSources: transaction.sources,
|
||||
customGroups: transaction.state.groups,
|
||||
hosts: transaction.state.hosts,
|
||||
groupConfigs: transaction.state.configs,
|
||||
managedSources: transaction.state.managedSources,
|
||||
};
|
||||
return {
|
||||
status: "done" as const,
|
||||
@@ -205,7 +196,7 @@ export function useVaultGroupDeletion({
|
||||
}, [
|
||||
onClearAndRemoveManagedSource,
|
||||
onClearAndRemoveManagedSources,
|
||||
onCommitVaultImportTransaction,
|
||||
onCommitVaultGroupMutation,
|
||||
onDeletedPaths,
|
||||
onReadPersistedHosts,
|
||||
onReadPersistedManagedSources,
|
||||
|
||||
@@ -66,7 +66,7 @@ test("updateSnippets disk writes take the shared vault lock", () => {
|
||||
// and be visible to waitForPendingVaultWrites.
|
||||
assert.match(
|
||||
source,
|
||||
/const updateSnippets = useCallback\(\(\s*data: Snippet\[\],\s*options\?: \{ replace\?: boolean \},\s*\) => \{[\s\S]*withVaultImportLock\("vault", async \(\) => \{[\s\S]*localStorageAdapter\.write\(STORAGE_KEY_SNIPPETS, rebased\)/,
|
||||
/const updateSnippets = useCallback\(\([\s\S]*withVaultImportLock\("vault", async \(\) => \{[\s\S]*localStorageAdapter\.write\(STORAGE_KEY_SNIPPETS, rebased\)/,
|
||||
);
|
||||
assert.match(
|
||||
source,
|
||||
@@ -95,6 +95,24 @@ test("updateSnippets rebases onto the latest persisted snapshot under the lock",
|
||||
);
|
||||
});
|
||||
|
||||
test("updateSnippets functional updates derive from the latest in-memory snapshot", () => {
|
||||
// Group deletion can wait for managed-source cleanup. A script created while
|
||||
// it waits must still be present when the deletion callback removes the old
|
||||
// group path, so updater callbacks must not derive from the initiating render.
|
||||
assert.match(
|
||||
source,
|
||||
/data: Snippet\[\] \| \(\(current: Snippet\[\]\) => Snippet\[\]\)/,
|
||||
);
|
||||
assert.match(
|
||||
source,
|
||||
/const updater = typeof data === "function" \? data : null;[\s\S]*const current = snippetsRef\.current;[\s\S]*typeof data === "function" \? data\(current\) : data/,
|
||||
);
|
||||
assert.match(
|
||||
source,
|
||||
/updater\(rebaseSnippetVaultWrite\(\{\s*base,\s*ours: current,\s*theirs: latestPersisted,\s*\}\)\)/,
|
||||
);
|
||||
});
|
||||
|
||||
test("clearVaultData replaces snippets without additive rebase", () => {
|
||||
// Clear All Local Data must not preserve concurrent disk-only snippet adds
|
||||
// that rebaseSnippetVaultWrite would otherwise keep.
|
||||
@@ -108,7 +126,7 @@ test("clearVaultData replaces snippets without additive rebase", () => {
|
||||
);
|
||||
assert.match(
|
||||
source,
|
||||
/replace\s*\?\s*cleaned\s*:\s*rebaseSnippetVaultWrite/,
|
||||
/replace\s*\?\s*cleaned\s*:\s*updater\s*\?\s*updater\(rebaseSnippetVaultWrite/,
|
||||
);
|
||||
assert.match(
|
||||
source,
|
||||
@@ -145,7 +163,7 @@ test("updateSnippets keeps the persisted rebase ancestor across superseded saves
|
||||
);
|
||||
assert.match(
|
||||
source,
|
||||
/const base = snippetsWriteBaseRef\.current \?\? snippetsRef\.current/,
|
||||
/const current = snippetsRef\.current;\s*const base = snippetsWriteBaseRef\.current \?\? current/,
|
||||
);
|
||||
assert.match(
|
||||
source,
|
||||
|
||||
@@ -106,10 +106,15 @@ import {
|
||||
persistVaultImportMetadata,
|
||||
readStoredArray,
|
||||
} from "./vaultImportPersistence";
|
||||
import type {
|
||||
VaultGroupMutationResult,
|
||||
VaultGroupMutationState,
|
||||
} from "../../domain/vaultGroupMutation";
|
||||
import {
|
||||
commitPluginImporterTransaction,
|
||||
recoverPluginImporterTransaction,
|
||||
} from "./pluginImporterTransaction";
|
||||
import { commitVaultGroupMutationPersistence } from "./vaultGroupMutationPersistence";
|
||||
|
||||
type ExportableVaultData = {
|
||||
hosts: Host[];
|
||||
@@ -296,6 +301,7 @@ export const useVaultState = () => {
|
||||
const managedSourcesRef = useRef<ManagedSource[]>([]);
|
||||
const hostsRef = useRef<Host[]>([]);
|
||||
const snippetsRef = useRef<Snippet[]>([]);
|
||||
const groupConfigsRef = useRef<GroupConfig[]>([]);
|
||||
const notesRef = useRef<VaultNote[]>([]);
|
||||
const noteGroupsRef = useRef<string[]>([]);
|
||||
const notesPersistFailureNotifiedAtRef = useRef(0);
|
||||
@@ -303,6 +309,7 @@ export const useVaultState = () => {
|
||||
managedSourcesRef.current = managedSources;
|
||||
hostsRef.current = hosts;
|
||||
snippetsRef.current = snippets;
|
||||
groupConfigsRef.current = groupConfigs;
|
||||
notesRef.current = notes;
|
||||
noteGroupsRef.current = noteGroups;
|
||||
|
||||
@@ -314,6 +321,7 @@ export const useVaultState = () => {
|
||||
const identitiesWriteVersion = useRef(0);
|
||||
const proxyProfilesWriteVersion = useRef(0);
|
||||
const snippetsWriteVersion = useRef(0);
|
||||
const managedSourcesWriteVersion = useRef(0);
|
||||
// Tracks the latest local updateSnippets schedule. Storage events also bump
|
||||
// snippetsWriteVersion (to invalidate naive writers), so queued saves must
|
||||
// key supersede checks off this owner instead of that shared counter.
|
||||
@@ -340,6 +348,7 @@ export const useVaultState = () => {
|
||||
const identitiesWritePendingRef = useRef<Promise<unknown>>(Promise.resolve());
|
||||
const groupConfigsWritePendingRef = useRef<Promise<unknown>>(Promise.resolve());
|
||||
const snippetsWritePendingRef = useRef<Promise<unknown>>(Promise.resolve());
|
||||
const managedSourcesWritePendingRef = useRef<Promise<unknown>>(Promise.resolve());
|
||||
|
||||
const waitForPendingVaultWrites = useCallback(async () => {
|
||||
while (true) {
|
||||
@@ -365,6 +374,7 @@ export const useVaultState = () => {
|
||||
identitiesWritePendingRef.current,
|
||||
groupConfigsWritePendingRef.current,
|
||||
snippetsWritePendingRef.current,
|
||||
managedSourcesWritePendingRef.current,
|
||||
];
|
||||
await Promise.all(writePending);
|
||||
if (
|
||||
@@ -373,6 +383,7 @@ export const useVaultState = () => {
|
||||
&& writePending[2] === identitiesWritePendingRef.current
|
||||
&& writePending[3] === groupConfigsWritePendingRef.current
|
||||
&& writePending[4] === snippetsWritePendingRef.current
|
||||
&& writePending[5] === managedSourcesWritePendingRef.current
|
||||
&& encryptPending[0] === hostsEncryptPendingRef.current
|
||||
&& encryptPending[1] === keysEncryptPendingRef.current
|
||||
&& encryptPending[2] === identitiesEncryptPendingRef.current
|
||||
@@ -643,7 +654,7 @@ export const useVaultState = () => {
|
||||
}, []);
|
||||
|
||||
const updateSnippets = useCallback((
|
||||
data: Snippet[],
|
||||
data: Snippet[] | ((current: Snippet[]) => Snippet[]),
|
||||
options?: { replace?: boolean },
|
||||
) => {
|
||||
// Capture the pre-update snapshot for a 3-way rebase once we hold the lock.
|
||||
@@ -653,7 +664,9 @@ export const useVaultState = () => {
|
||||
// second edit does not rebase against the first save's optimistic memory.
|
||||
const replace =
|
||||
options?.replace === true || snippetsWriteReplaceRef.current;
|
||||
const base = snippetsWriteBaseRef.current ?? snippetsRef.current;
|
||||
const updater = typeof data === "function" ? data : null;
|
||||
const current = snippetsRef.current;
|
||||
const base = snippetsWriteBaseRef.current ?? current;
|
||||
if (options?.replace === true) {
|
||||
snippetsWriteReplaceRef.current = true;
|
||||
}
|
||||
@@ -667,7 +680,9 @@ export const useVaultState = () => {
|
||||
} else if (snippetsWriteBaseRef.current === null) {
|
||||
snippetsWriteBaseRef.current = base;
|
||||
}
|
||||
const cleaned = normalizeVaultOrder(data);
|
||||
const cleaned = normalizeVaultOrder(
|
||||
typeof data === "function" ? data(current) : data,
|
||||
);
|
||||
const ver = ++snippetsWriteVersion.current;
|
||||
snippetsWriteOwnerRef.current = ver;
|
||||
// Keep live snapshot ahead of React commit so same-tick readers (delete,
|
||||
@@ -681,20 +696,25 @@ export const useVaultState = () => {
|
||||
// skips the additive rebase so a concurrent disk-only add cannot survive.
|
||||
const writePromise = withVaultImportLock("vault", async () => {
|
||||
if (snippetsWriteOwnerRef.current !== ver) return "superseded" as const;
|
||||
const rebased = normalizeVaultOrder(
|
||||
replace
|
||||
? cleaned
|
||||
const latestPersisted = normalizeVaultOrder(
|
||||
readStoredArray<Snippet>(
|
||||
STORAGE_KEY_SNIPPETS,
|
||||
localStorageAdapter.readString(STORAGE_KEY_SNIPPETS),
|
||||
),
|
||||
);
|
||||
const rebased = normalizeVaultOrder(replace
|
||||
? cleaned
|
||||
: updater
|
||||
? updater(rebaseSnippetVaultWrite({
|
||||
base,
|
||||
ours: current,
|
||||
theirs: latestPersisted,
|
||||
}))
|
||||
: rebaseSnippetVaultWrite({
|
||||
base,
|
||||
ours: cleaned,
|
||||
theirs: normalizeVaultOrder(
|
||||
readStoredArray<Snippet>(
|
||||
STORAGE_KEY_SNIPPETS,
|
||||
localStorageAdapter.readString(STORAGE_KEY_SNIPPETS),
|
||||
),
|
||||
),
|
||||
}),
|
||||
);
|
||||
theirs: latestPersisted,
|
||||
}));
|
||||
const persisted = localStorageAdapter.write(STORAGE_KEY_SNIPPETS, rebased);
|
||||
if (!persisted) {
|
||||
// Keep the pre-update ancestor. Clearing it here would make the next
|
||||
@@ -852,6 +872,7 @@ export const useVaultState = () => {
|
||||
setCustomGroups(next);
|
||||
|
||||
const cleanedGroupConfigs = buildGroupConfigsForGroups(next, groupConfigs);
|
||||
groupConfigsRef.current = cleanedGroupConfigs;
|
||||
setGroupConfigs(cleanedGroupConfigs);
|
||||
const configsVer = ++groupConfigsWriteVersion.current;
|
||||
const encryptPromise = encryptGroupConfigs(cleanedGroupConfigs);
|
||||
@@ -880,13 +901,105 @@ export const useVaultState = () => {
|
||||
const next = typeof data === "function" ? data(managedSourcesRef.current) : data;
|
||||
managedSourcesRef.current = next;
|
||||
setManagedSources(next);
|
||||
void withVaultImportLock("vault", async () => {
|
||||
const ver = ++managedSourcesWriteVersion.current;
|
||||
const writePromise = withVaultImportLock("vault", async () => {
|
||||
// Latest ref wins if another update ran while waiting for the lock.
|
||||
if (managedSourcesRef.current !== next) return;
|
||||
localStorageAdapter.write(STORAGE_KEY_MANAGED_SOURCES, next);
|
||||
if (ver !== managedSourcesWriteVersion.current) return "superseded" as const;
|
||||
return localStorageAdapter.write(STORAGE_KEY_MANAGED_SOURCES, next)
|
||||
? "written" as const
|
||||
: "failed" as const;
|
||||
});
|
||||
managedSourcesWritePendingRef.current = writePromise;
|
||||
return writePromise;
|
||||
}, []);
|
||||
|
||||
const commitVaultGroupMutation = useCallback(async (
|
||||
mutate: (current: VaultGroupMutationState) => VaultGroupMutationResult,
|
||||
lock?: VaultLockHandle | null,
|
||||
): Promise<VaultGroupMutationResult | { ok: false; superseded: true }> => {
|
||||
const captureVersions = () => ({
|
||||
hosts: hostsWriteVersion.current,
|
||||
snippets: snippetsWriteVersion.current,
|
||||
groups: customGroupsWriteVersion.current,
|
||||
configs: groupConfigsWriteVersion.current,
|
||||
sources: managedSourcesWriteVersion.current,
|
||||
});
|
||||
const versionsAreCurrent = (versions: ReturnType<typeof captureVersions>) => (
|
||||
versions.hosts === hostsWriteVersion.current
|
||||
&& versions.snippets === snippetsWriteVersion.current
|
||||
&& versions.groups === customGroupsWriteVersion.current
|
||||
&& versions.configs === groupConfigsWriteVersion.current
|
||||
&& versions.sources === managedSourcesWriteVersion.current
|
||||
);
|
||||
const stateMatchesLiveSnapshot = (current: VaultGroupMutationState) => (
|
||||
JSON.stringify(current.hosts) === JSON.stringify(hostsRef.current)
|
||||
&& JSON.stringify(current.snippets) === JSON.stringify(snippetsRef.current)
|
||||
&& JSON.stringify(current.groups) === JSON.stringify(customGroupsRef.current)
|
||||
&& JSON.stringify(current.configs) === JSON.stringify(groupConfigsRef.current)
|
||||
&& JSON.stringify(current.managedSources) === JSON.stringify(managedSourcesRef.current)
|
||||
);
|
||||
const runCommit = async (
|
||||
versions: ReturnType<typeof captureVersions>,
|
||||
): Promise<VaultGroupMutationResult | { ok: false; superseded: true }> => {
|
||||
const result = await commitVaultGroupMutationPersistence({
|
||||
storage: localStorageAdapter,
|
||||
mutate,
|
||||
prepareState: (state) => {
|
||||
const groups = Array.from(new Set(state.groups));
|
||||
return {
|
||||
groups,
|
||||
configs: buildGroupConfigsForGroups(groups, state.configs),
|
||||
hosts: normalizeVaultOrder(state.hosts.map((host) => sanitizeHost(host))),
|
||||
managedSources: state.managedSources,
|
||||
snippets: normalizeVaultOrder(state.snippets),
|
||||
};
|
||||
},
|
||||
decryptHosts: async (items) => normalizeVaultOrder(
|
||||
(await decryptHosts(items)).map((host) => sanitizeHost(host)),
|
||||
),
|
||||
decryptConfigs: async (items) => normalizeVaultOrder(
|
||||
(await decryptGroupConfigs(items)).map(sanitizeGroupConfig),
|
||||
),
|
||||
encryptHosts,
|
||||
encryptConfigs: encryptGroupConfigs,
|
||||
isCurrent: () => versionsAreCurrent(versions),
|
||||
validateCurrent: lock ? stateMatchesLiveSnapshot : undefined,
|
||||
});
|
||||
if (!result.ok) return result;
|
||||
const nextState = result.state;
|
||||
++hostsWriteVersion.current;
|
||||
++snippetsWriteVersion.current;
|
||||
snippetsWriteOwnerRef.current = snippetsWriteVersion.current;
|
||||
++customGroupsWriteVersion.current;
|
||||
++groupConfigsWriteVersion.current;
|
||||
++managedSourcesWriteVersion.current;
|
||||
hostsRef.current = nextState.hosts;
|
||||
snippetsRef.current = nextState.snippets;
|
||||
customGroupsRef.current = nextState.groups;
|
||||
managedSourcesRef.current = nextState.managedSources;
|
||||
snippetsWriteBaseRef.current = null;
|
||||
snippetsWriteReplaceRef.current = false;
|
||||
setHosts(nextState.hosts);
|
||||
setSnippets(nextState.snippets);
|
||||
setCustomGroups(nextState.groups);
|
||||
setManagedSources(nextState.managedSources);
|
||||
setGroupConfigs(nextState.configs);
|
||||
groupConfigsRef.current = nextState.configs;
|
||||
return { ok: true, state: nextState };
|
||||
};
|
||||
|
||||
if (lock) {
|
||||
const versions = captureVersions();
|
||||
return withVaultImportLockIfNeeded("vault", () => runCommit(versions), lock);
|
||||
}
|
||||
while (true) {
|
||||
await waitForPendingVaultWrites();
|
||||
const versions = captureVersions();
|
||||
const result = await withVaultImportLock("vault", () => runCommit(versions));
|
||||
if (!("superseded" in result)) return result;
|
||||
}
|
||||
}, [waitForPendingVaultWrites]);
|
||||
|
||||
const readPersistedManagedSources = useCallback((): ManagedSource[] => (
|
||||
readStoredArray<ManagedSource>(
|
||||
STORAGE_KEY_MANAGED_SOURCES,
|
||||
@@ -996,6 +1109,8 @@ export const useVaultState = () => {
|
||||
++groupConfigsWriteVersion.current;
|
||||
customGroupsRef.current = result.groups;
|
||||
managedSourcesRef.current = result.sources;
|
||||
hostsRef.current = cleanedHosts;
|
||||
groupConfigsRef.current = nextGroupConfigs;
|
||||
setHosts(cleanedHosts);
|
||||
setCustomGroups(result.groups);
|
||||
setManagedSources(result.sources);
|
||||
@@ -1018,6 +1133,7 @@ export const useVaultState = () => {
|
||||
// sit in memory and re-persist with `fontFamilyOverride: true` until
|
||||
// the next reload. Mirrors updateHosts → sanitizeHost.
|
||||
const cleaned = normalizeVaultOrder(data.map(sanitizeGroupConfig));
|
||||
groupConfigsRef.current = cleaned;
|
||||
setGroupConfigs(cleaned);
|
||||
const ver = ++groupConfigsWriteVersion.current;
|
||||
const encryptPromise = encryptGroupConfigs(cleaned);
|
||||
@@ -1461,7 +1577,10 @@ export const useVaultState = () => {
|
||||
const savedManagedSources = localStorageAdapter.read<ManagedSource[]>(
|
||||
STORAGE_KEY_MANAGED_SOURCES,
|
||||
);
|
||||
if (savedManagedSources) setManagedSources(savedManagedSources);
|
||||
if (savedManagedSources) {
|
||||
managedSourcesRef.current = savedManagedSources;
|
||||
setManagedSources(savedManagedSources);
|
||||
}
|
||||
|
||||
// Load group configs
|
||||
const savedGroupConfigs = localStorageAdapter.read<GroupConfig[]>(STORAGE_KEY_GROUP_CONFIGS);
|
||||
@@ -1471,6 +1590,7 @@ export const useVaultState = () => {
|
||||
if (cancelled) return;
|
||||
if (gcVer === groupConfigsWriteVersion.current) {
|
||||
const sanitizedGC = normalizeVaultOrder(decryptedGC.map(sanitizeGroupConfig));
|
||||
groupConfigsRef.current = sanitizedGC;
|
||||
setGroupConfigs(sanitizedGC);
|
||||
encryptGroupConfigs(sanitizedGC).then((enc) => {
|
||||
if (gcVer === groupConfigsWriteVersion.current)
|
||||
@@ -1641,6 +1761,7 @@ export const useVaultState = () => {
|
||||
if (key === STORAGE_KEY_GROUPS) {
|
||||
const next = safeParse<string[]>(event.newValue) ?? [];
|
||||
++customGroupsWriteVersion.current;
|
||||
customGroupsRef.current = next;
|
||||
setCustomGroups(next);
|
||||
return;
|
||||
}
|
||||
@@ -1695,6 +1816,8 @@ export const useVaultState = () => {
|
||||
|
||||
if (key === STORAGE_KEY_MANAGED_SOURCES) {
|
||||
const next = safeParse<ManagedSource[]>(event.newValue) ?? [];
|
||||
++managedSourcesWriteVersion.current;
|
||||
managedSourcesRef.current = next;
|
||||
setManagedSources(next);
|
||||
return;
|
||||
}
|
||||
@@ -1705,8 +1828,11 @@ export const useVaultState = () => {
|
||||
const seq = ++groupConfigsReadSeq.current;
|
||||
const writeAtStart = groupConfigsWriteVersion.current;
|
||||
decryptGroupConfigs(next).then((dec) => {
|
||||
if (seq === groupConfigsReadSeq.current && writeAtStart === groupConfigsWriteVersion.current)
|
||||
setGroupConfigs(normalizeVaultOrder(dec.map(sanitizeGroupConfig)));
|
||||
if (seq === groupConfigsReadSeq.current && writeAtStart === groupConfigsWriteVersion.current) {
|
||||
const normalized = normalizeVaultOrder(dec.map(sanitizeGroupConfig));
|
||||
groupConfigsRef.current = normalized;
|
||||
setGroupConfigs(normalized);
|
||||
}
|
||||
});
|
||||
return;
|
||||
}
|
||||
@@ -1794,7 +1920,7 @@ export const useVaultState = () => {
|
||||
const importData = useCallback(
|
||||
(payload: Partial<ExportableVaultData>): Promise<void> => {
|
||||
const encryptedWrites: Promise<void>[] = [];
|
||||
if (payload.hosts) encryptedWrites.push(updateHosts(payload.hosts));
|
||||
if (payload.hosts) encryptedWrites.push(updateHosts(payload.hosts).then(() => undefined));
|
||||
if (payload.keys) encryptedWrites.push(updateKeys(payload.keys));
|
||||
if (payload.identities) encryptedWrites.push(updateIdentities(payload.identities));
|
||||
if (Array.isArray(payload.proxyProfiles)) encryptedWrites.push(updateProxyProfiles(payload.proxyProfiles));
|
||||
@@ -1952,6 +2078,8 @@ export const useVaultState = () => {
|
||||
]);
|
||||
customGroupsRef.current = nextGroups;
|
||||
snippetsRef.current = nextSnippets;
|
||||
hostsRef.current = nextHosts;
|
||||
groupConfigsRef.current = nextGroupConfigs;
|
||||
snippetsWriteBaseRef.current = null;
|
||||
snippetsWriteReplaceRef.current = false;
|
||||
setHosts(nextHosts);
|
||||
@@ -2008,6 +2136,7 @@ export const useVaultState = () => {
|
||||
updateManagedSources,
|
||||
readPersistedManagedSources,
|
||||
commitVaultImportTransaction,
|
||||
commitVaultGroupMutation,
|
||||
updateGroupConfigs,
|
||||
addShellHistoryEntry,
|
||||
clearShellHistory,
|
||||
|
||||
@@ -0,0 +1,220 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import type { Host, Snippet } from '../../domain/models';
|
||||
import {
|
||||
remapSnippetTargetGroupPaths,
|
||||
removeSnippetTargetGroupPaths,
|
||||
} from '../../domain/hostGroupPathMutations';
|
||||
import {
|
||||
STORAGE_KEY_GROUP_CONFIGS,
|
||||
STORAGE_KEY_GROUPS,
|
||||
STORAGE_KEY_HOSTS,
|
||||
STORAGE_KEY_MANAGED_SOURCES,
|
||||
STORAGE_KEY_SNIPPETS,
|
||||
} from '../../infrastructure/config/storageKeys';
|
||||
import { commitVaultGroupMutationPersistence } from './vaultGroupMutationPersistence';
|
||||
|
||||
const createStorage = (initial: Record<string, unknown>, failKey?: string) => {
|
||||
const values = new Map(Object.entries(initial).map(([key, value]) => [key, JSON.stringify(value)]));
|
||||
return {
|
||||
values,
|
||||
read<T>(key: string): T | null {
|
||||
const value = values.get(key);
|
||||
return value === undefined ? null : JSON.parse(value) as T;
|
||||
},
|
||||
readString: (key: string) => values.get(key) ?? null,
|
||||
write<T>(key: string, value: T) {
|
||||
if (key === failKey) return false;
|
||||
values.set(key, JSON.stringify(value));
|
||||
return true;
|
||||
},
|
||||
writeString(key: string, value: string) {
|
||||
values.set(key, value);
|
||||
return true;
|
||||
},
|
||||
remove(key: string) {
|
||||
values.delete(key);
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
const identity = async <T>(value: T): Promise<T> => value;
|
||||
const prepareState = <T>(value: T): T => value;
|
||||
|
||||
const initialHost: Host = {
|
||||
id: 'host-1', label: 'Host', hostname: 'host.test', username: 'root',
|
||||
group: 'prod', tags: [], os: 'linux',
|
||||
};
|
||||
const initialSnippet: Snippet = {
|
||||
id: 'script-1', label: 'Deploy', command: 'nct.log(1)', kind: 'script',
|
||||
targetGroups: ['prod'],
|
||||
};
|
||||
|
||||
function initialVault() {
|
||||
return {
|
||||
[STORAGE_KEY_HOSTS]: [initialHost],
|
||||
[STORAGE_KEY_GROUPS]: ['prod'],
|
||||
[STORAGE_KEY_MANAGED_SOURCES]: [],
|
||||
[STORAGE_KEY_GROUP_CONFIGS]: [{ path: 'prod', username: 'root' }],
|
||||
[STORAGE_KEY_SNIPPETS]: [initialSnippet],
|
||||
};
|
||||
}
|
||||
|
||||
test('group rename rolls back every Vault key when the snippet write fails', async () => {
|
||||
const initial = initialVault();
|
||||
const storage = createStorage(initial, STORAGE_KEY_SNIPPETS);
|
||||
|
||||
await assert.rejects(commitVaultGroupMutationPersistence({
|
||||
storage,
|
||||
mutate: (current) => ({
|
||||
ok: true,
|
||||
state: {
|
||||
...current,
|
||||
groups: ['production'],
|
||||
configs: current.configs.map((config) => ({ ...config, path: 'production' })),
|
||||
hosts: current.hosts.map((host) => ({ ...host, group: 'production' })),
|
||||
snippets: remapSnippetTargetGroupPaths(current.snippets, 'prod', 'production'),
|
||||
},
|
||||
}),
|
||||
prepareState,
|
||||
decryptHosts: identity,
|
||||
decryptConfigs: identity,
|
||||
encryptHosts: identity,
|
||||
encryptConfigs: identity,
|
||||
isCurrent: () => true,
|
||||
}), /rejected importer transaction/);
|
||||
|
||||
for (const [key, value] of Object.entries(initial)) {
|
||||
assert.deepEqual(storage.read(key), value);
|
||||
}
|
||||
});
|
||||
|
||||
test('group delete rolls back every Vault key when the snippet write fails', async () => {
|
||||
const initial = initialVault();
|
||||
const storage = createStorage(initial, STORAGE_KEY_SNIPPETS);
|
||||
|
||||
await assert.rejects(commitVaultGroupMutationPersistence({
|
||||
storage,
|
||||
mutate: (current) => ({
|
||||
ok: true,
|
||||
state: {
|
||||
...current,
|
||||
groups: [],
|
||||
configs: [],
|
||||
hosts: current.hosts.map((host) => ({ ...host, group: undefined })),
|
||||
snippets: removeSnippetTargetGroupPaths(current.snippets, ['prod']),
|
||||
},
|
||||
}),
|
||||
prepareState,
|
||||
decryptHosts: identity,
|
||||
decryptConfigs: identity,
|
||||
encryptHosts: identity,
|
||||
encryptConfigs: identity,
|
||||
isCurrent: () => true,
|
||||
}), /rejected importer transaction/);
|
||||
|
||||
for (const [key, value] of Object.entries(initial)) {
|
||||
assert.deepEqual(storage.read(key), value);
|
||||
}
|
||||
});
|
||||
|
||||
test('group rename preserves a concurrent snippet update from the locked snapshot', async () => {
|
||||
const concurrentSnippet = { ...initialSnippet, command: 'nct.log(2)' };
|
||||
const addedSnippet: Snippet = {
|
||||
id: 'script-2', label: 'Check', command: 'nct.log(3)', kind: 'script',
|
||||
targetGroups: ['prod/child'],
|
||||
};
|
||||
const storage = createStorage({
|
||||
...initialVault(),
|
||||
[STORAGE_KEY_SNIPPETS]: [concurrentSnippet, addedSnippet],
|
||||
});
|
||||
|
||||
const result = await commitVaultGroupMutationPersistence({
|
||||
storage,
|
||||
mutate: (current) => ({
|
||||
ok: true,
|
||||
state: {
|
||||
...current,
|
||||
groups: ['production'],
|
||||
snippets: remapSnippetTargetGroupPaths(current.snippets, 'prod', 'production'),
|
||||
},
|
||||
}),
|
||||
prepareState,
|
||||
decryptHosts: identity,
|
||||
decryptConfigs: identity,
|
||||
encryptHosts: identity,
|
||||
encryptConfigs: identity,
|
||||
isCurrent: () => true,
|
||||
});
|
||||
|
||||
assert.equal(result.ok, true);
|
||||
const saved = storage.read<Snippet[]>(STORAGE_KEY_SNIPPETS) ?? [];
|
||||
assert.equal(saved[0]?.command, 'nct.log(2)');
|
||||
assert.deepEqual(saved.map((snippet) => snippet.targetGroups), [
|
||||
['production'],
|
||||
['production/child'],
|
||||
]);
|
||||
});
|
||||
|
||||
test('group delete cleans targets from the latest persisted snippet snapshot', async () => {
|
||||
const concurrentSnippet = { ...initialSnippet, command: 'nct.log(2)' };
|
||||
const addedSnippet: Snippet = {
|
||||
id: 'script-2', label: 'Check', command: 'nct.log(3)', kind: 'script',
|
||||
targetGroups: ['prod/child', 'staging'],
|
||||
};
|
||||
const storage = createStorage({
|
||||
...initialVault(),
|
||||
[STORAGE_KEY_SNIPPETS]: [concurrentSnippet, addedSnippet],
|
||||
});
|
||||
|
||||
const result = await commitVaultGroupMutationPersistence({
|
||||
storage,
|
||||
mutate: (current) => ({
|
||||
ok: true,
|
||||
state: {
|
||||
...current,
|
||||
groups: [],
|
||||
snippets: removeSnippetTargetGroupPaths(current.snippets, ['prod']),
|
||||
},
|
||||
}),
|
||||
prepareState,
|
||||
decryptHosts: identity,
|
||||
decryptConfigs: identity,
|
||||
encryptHosts: identity,
|
||||
encryptConfigs: identity,
|
||||
isCurrent: () => true,
|
||||
});
|
||||
|
||||
assert.equal(result.ok, true);
|
||||
const saved = storage.read<Snippet[]>(STORAGE_KEY_SNIPPETS) ?? [];
|
||||
assert.equal(saved[0]?.command, 'nct.log(2)');
|
||||
assert.deepEqual(saved.map((snippet) => snippet.targetGroups), [
|
||||
[],
|
||||
['staging'],
|
||||
]);
|
||||
});
|
||||
|
||||
test('group mutation stops before writing when memory is ahead of persisted Vault state', async () => {
|
||||
const storage = createStorage(initialVault());
|
||||
let mutateCalled = false;
|
||||
|
||||
const result = await commitVaultGroupMutationPersistence({
|
||||
storage,
|
||||
mutate: (current) => {
|
||||
mutateCalled = true;
|
||||
return { ok: true, state: current };
|
||||
},
|
||||
prepareState,
|
||||
decryptHosts: identity,
|
||||
decryptConfigs: identity,
|
||||
encryptHosts: identity,
|
||||
encryptConfigs: identity,
|
||||
isCurrent: () => true,
|
||||
validateCurrent: (current) => current.snippets[0]?.command === 'nct.log(2)',
|
||||
});
|
||||
|
||||
assert.deepEqual(result, { ok: false, superseded: true });
|
||||
assert.equal(mutateCalled, false);
|
||||
assert.deepEqual(storage.read<Snippet[]>(STORAGE_KEY_SNIPPETS), [initialSnippet]);
|
||||
});
|
||||
@@ -0,0 +1,97 @@
|
||||
import type { GroupConfig, Host, ManagedSource, Snippet } from '../../domain/models';
|
||||
import type {
|
||||
VaultGroupMutationResult,
|
||||
VaultGroupMutationState,
|
||||
} from '../../domain/vaultGroupMutation';
|
||||
import {
|
||||
STORAGE_KEY_GROUP_CONFIGS,
|
||||
STORAGE_KEY_GROUPS,
|
||||
STORAGE_KEY_HOSTS,
|
||||
STORAGE_KEY_MANAGED_SOURCES,
|
||||
STORAGE_KEY_SNIPPETS,
|
||||
} from '../../infrastructure/config/storageKeys';
|
||||
import { commitPluginImporterTransaction } from './pluginImporterTransaction';
|
||||
import { readStoredArray } from './vaultImportPersistence';
|
||||
|
||||
type TransactionStorage = {
|
||||
read<T>(key: string): T | null;
|
||||
readString(key: string): string | null;
|
||||
write<T>(key: string, value: T): boolean;
|
||||
writeString(key: string, value: string): boolean;
|
||||
remove(key: string): void;
|
||||
};
|
||||
|
||||
const GROUP_MUTATION_KEYS = [
|
||||
STORAGE_KEY_HOSTS,
|
||||
STORAGE_KEY_GROUPS,
|
||||
STORAGE_KEY_MANAGED_SOURCES,
|
||||
STORAGE_KEY_GROUP_CONFIGS,
|
||||
STORAGE_KEY_SNIPPETS,
|
||||
] as const;
|
||||
|
||||
export async function commitVaultGroupMutationPersistence({
|
||||
storage,
|
||||
mutate,
|
||||
prepareState,
|
||||
decryptHosts,
|
||||
decryptConfigs,
|
||||
encryptHosts,
|
||||
encryptConfigs,
|
||||
isCurrent,
|
||||
validateCurrent,
|
||||
}: {
|
||||
storage: TransactionStorage;
|
||||
mutate: (current: VaultGroupMutationState) => VaultGroupMutationResult;
|
||||
prepareState: (state: VaultGroupMutationState) => VaultGroupMutationState;
|
||||
decryptHosts: (hosts: Host[]) => Promise<Host[]>;
|
||||
decryptConfigs: (configs: GroupConfig[]) => Promise<GroupConfig[]>;
|
||||
encryptHosts: (hosts: Host[]) => Promise<unknown>;
|
||||
encryptConfigs: (configs: GroupConfig[]) => Promise<unknown>;
|
||||
isCurrent: () => boolean;
|
||||
validateCurrent?: (current: VaultGroupMutationState) => boolean;
|
||||
}): Promise<VaultGroupMutationResult | { ok: false; superseded: true }> {
|
||||
const raw = new Map(GROUP_MUTATION_KEYS.map((key) => [key, storage.readString(key)]));
|
||||
const [hosts, configs] = await Promise.all([
|
||||
decryptHosts(readStoredArray<Host>(STORAGE_KEY_HOSTS, raw.get(STORAGE_KEY_HOSTS) ?? null)),
|
||||
decryptConfigs(readStoredArray<GroupConfig>(
|
||||
STORAGE_KEY_GROUP_CONFIGS,
|
||||
raw.get(STORAGE_KEY_GROUP_CONFIGS) ?? null,
|
||||
)),
|
||||
]);
|
||||
const current: VaultGroupMutationState = {
|
||||
groups: readStoredArray<string>(STORAGE_KEY_GROUPS, raw.get(STORAGE_KEY_GROUPS) ?? null),
|
||||
configs,
|
||||
hosts,
|
||||
managedSources: readStoredArray<ManagedSource>(
|
||||
STORAGE_KEY_MANAGED_SOURCES,
|
||||
raw.get(STORAGE_KEY_MANAGED_SOURCES) ?? null,
|
||||
),
|
||||
snippets: readStoredArray<Snippet>(STORAGE_KEY_SNIPPETS, raw.get(STORAGE_KEY_SNIPPETS) ?? null),
|
||||
};
|
||||
if (!isCurrent() || (validateCurrent && !validateCurrent(current))) {
|
||||
return { ok: false, superseded: true };
|
||||
}
|
||||
const mutation = mutate(current);
|
||||
if (!mutation.ok) return mutation;
|
||||
|
||||
const nextState = prepareState(mutation.state);
|
||||
const [encryptedHosts, encryptedConfigs] = await Promise.all([
|
||||
encryptHosts(nextState.hosts),
|
||||
encryptConfigs(nextState.configs),
|
||||
]);
|
||||
if (
|
||||
!isCurrent()
|
||||
|| GROUP_MUTATION_KEYS.some((key) => storage.readString(key) !== raw.get(key))
|
||||
) {
|
||||
return { ok: false, superseded: true };
|
||||
}
|
||||
|
||||
commitPluginImporterTransaction(storage, [
|
||||
[STORAGE_KEY_HOSTS, encryptedHosts],
|
||||
[STORAGE_KEY_GROUPS, nextState.groups],
|
||||
[STORAGE_KEY_MANAGED_SOURCES, nextState.managedSources],
|
||||
[STORAGE_KEY_GROUP_CONFIGS, encryptedConfigs],
|
||||
[STORAGE_KEY_SNIPPETS, nextState.snippets],
|
||||
]);
|
||||
return { ok: true, state: nextState };
|
||||
}
|
||||
@@ -12,7 +12,7 @@ export interface VaultHostTreeActions {
|
||||
onNewGroup: (parentPath?: string) => void;
|
||||
onRenameGroup: (groupPath: string) => void;
|
||||
onDeleteGroup: (groupPath: string) => void;
|
||||
commitInlineGroupRename: (name: string) => void;
|
||||
commitInlineGroupRename: (name: string) => boolean | void | Promise<boolean | void>;
|
||||
cancelInlineGroupEdit: () => void;
|
||||
commitInlineHostRename: (name: string) => void;
|
||||
cancelInlineHostEdit: () => void;
|
||||
|
||||
@@ -81,6 +81,7 @@ test('vault actions register and unregister', () => {
|
||||
readPersistedManagedSources: noop,
|
||||
commitPluginImporterData: noop,
|
||||
commitVaultImportTransaction: noop,
|
||||
commitVaultGroupMutation: noop,
|
||||
updateHostDistro: noop,
|
||||
updateHostLastConnected: noop,
|
||||
addShellHistoryEntry: noop,
|
||||
|
||||
@@ -58,6 +58,7 @@ export type VaultSnapshotActions = Pick<
|
||||
| 'readPersistedManagedSources'
|
||||
| 'commitPluginImporterData'
|
||||
| 'commitVaultImportTransaction'
|
||||
| 'commitVaultGroupMutation'
|
||||
| 'updateHostDistro'
|
||||
| 'updateHostLastConnected'
|
||||
| 'addShellHistoryEntry'
|
||||
|
||||
Binary file not shown.
@@ -290,7 +290,7 @@ interface HostTreeViewProps {
|
||||
onDeleteGroup: (groupPath: string) => void;
|
||||
moveHostToGroup: (hostId: string, groupPath: string | null) => void;
|
||||
moveGroup: (sourcePath: string, targetParent: string | null) => void;
|
||||
commitInlineGroupRename?: (name: string) => void;
|
||||
commitInlineGroupRename?: (name: string) => boolean | void | Promise<boolean | void>;
|
||||
cancelInlineGroupEdit?: () => void;
|
||||
managedGroupPaths?: Set<string>;
|
||||
onUnmanageGroup?: (groupPath: string) => void;
|
||||
@@ -330,7 +330,7 @@ interface TreeNodeProps {
|
||||
onDeleteGroup: (groupPath: string) => void;
|
||||
moveHostToGroup: (hostId: string, groupPath: string | null) => void;
|
||||
moveGroup: (sourcePath: string, targetParent: string | null) => void;
|
||||
commitInlineGroupRename?: (name: string) => void;
|
||||
commitInlineGroupRename?: (name: string) => boolean | void | Promise<boolean | void>;
|
||||
cancelInlineGroupEdit?: () => void;
|
||||
managedGroupPaths?: Set<string>;
|
||||
onUnmanageGroup?: (groupPath: string) => void;
|
||||
|
||||
@@ -0,0 +1,168 @@
|
||||
import { CheckSquare, FolderTree, Search, Square } from 'lucide-react';
|
||||
import React, { useMemo, useState } from 'react';
|
||||
import { useI18n } from '@/application/i18n/I18nProvider';
|
||||
import type { Host } from '@/domain/models';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { FixedSizeVirtualList } from '@/components/ui/FixedSizeVirtualList';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
export interface SelectGroupDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
hosts: Host[];
|
||||
customGroups?: string[];
|
||||
selectedGroupPaths: string[];
|
||||
onSelectionChange: (selectedGroupPaths: string[]) => void;
|
||||
}
|
||||
|
||||
const GROUP_ROW_HEIGHT = 48;
|
||||
|
||||
function collectGroupPaths(
|
||||
hosts: Host[],
|
||||
customGroups: string[],
|
||||
selectedGroupPaths: string[],
|
||||
): string[] {
|
||||
const paths = new Set<string>();
|
||||
for (const groupPath of [...customGroups, ...selectedGroupPaths]) {
|
||||
const parts = groupPath.split('/').filter(Boolean);
|
||||
for (let index = 1; index <= parts.length; index += 1) {
|
||||
paths.add(parts.slice(0, index).join('/'));
|
||||
}
|
||||
}
|
||||
for (const host of hosts) {
|
||||
const parts = host.group?.split('/').filter(Boolean) ?? [];
|
||||
for (let index = 1; index <= parts.length; index += 1) {
|
||||
paths.add(parts.slice(0, index).join('/'));
|
||||
}
|
||||
}
|
||||
return [...paths].sort((left, right) => left.localeCompare(right));
|
||||
}
|
||||
|
||||
function countHostsByGroupPath(hosts: Host[]): Map<string, number> {
|
||||
const counts = new Map<string, number>();
|
||||
for (const host of hosts) {
|
||||
if (host.protocol === 'serial') continue;
|
||||
const parts = host.group?.split('/').filter(Boolean) ?? [];
|
||||
for (let index = 1; index <= parts.length; index += 1) {
|
||||
const path = parts.slice(0, index).join('/');
|
||||
counts.set(path, (counts.get(path) ?? 0) + 1);
|
||||
}
|
||||
}
|
||||
return counts;
|
||||
}
|
||||
|
||||
export const SelectGroupDialog: React.FC<SelectGroupDialogProps> = ({
|
||||
open,
|
||||
onOpenChange,
|
||||
hosts,
|
||||
customGroups = [],
|
||||
selectedGroupPaths,
|
||||
onSelectionChange,
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const selected = useMemo(() => new Set(selectedGroupPaths), [selectedGroupPaths]);
|
||||
const groupPaths = useMemo(
|
||||
() => collectGroupPaths(hosts, customGroups, selectedGroupPaths),
|
||||
[customGroups, hosts, selectedGroupPaths],
|
||||
);
|
||||
const displayedPaths = useMemo(() => {
|
||||
const query = searchQuery.trim().toLocaleLowerCase();
|
||||
if (!query) return groupPaths;
|
||||
return groupPaths.filter((path) => path.toLocaleLowerCase().includes(query));
|
||||
}, [groupPaths, searchQuery]);
|
||||
const hostCountByPath = useMemo(() => countHostsByGroupPath(hosts), [hosts]);
|
||||
|
||||
const togglePath = (path: string) => {
|
||||
if (selected.has(path)) {
|
||||
onSelectionChange(selectedGroupPaths.filter((candidate) => candidate !== path));
|
||||
return;
|
||||
}
|
||||
onSelectionChange([...selectedGroupPaths, path]);
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-md h-[min(78vh,600px)] flex flex-col p-0 gap-0 overflow-hidden">
|
||||
<DialogHeader className="px-4 pt-4 pb-3 shrink-0 border-b border-border/60">
|
||||
<DialogTitle className="text-base">{t('snippets.targets.selectGroups')}</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="px-4 py-3 border-b border-border/60 shrink-0">
|
||||
<div className="relative">
|
||||
<Search
|
||||
size={14}
|
||||
className="absolute left-3 top-1/2 -translate-y-1/2 text-muted-foreground"
|
||||
/>
|
||||
<Input
|
||||
value={searchQuery}
|
||||
onChange={(event) => setSearchQuery(event.target.value)}
|
||||
placeholder={t('common.searchPlaceholder')}
|
||||
className="h-8 pl-8"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 min-h-0 p-3">
|
||||
{displayedPaths.length === 0 ? (
|
||||
<p className="py-10 text-center text-sm text-muted-foreground">
|
||||
{t('snippets.targets.noGroups')}
|
||||
</p>
|
||||
) : (
|
||||
<FixedSizeVirtualList
|
||||
className="h-full"
|
||||
items={displayedPaths}
|
||||
itemHeight={GROUP_ROW_HEIGHT}
|
||||
getItemKey={(path) => path}
|
||||
renderItem={(path) => {
|
||||
const isSelected = selected.has(path);
|
||||
const depth = Math.max(0, path.split('/').filter(Boolean).length - 1);
|
||||
const label = path.split('/').filter(Boolean).pop() ?? path;
|
||||
const hostCount = hostCountByPath.get(path) ?? 0;
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
'w-full h-11 flex items-center gap-2.5 rounded-lg px-2.5 text-left transition-colors',
|
||||
isSelected ? 'bg-primary/10 text-foreground' : 'hover:bg-muted/70',
|
||||
)}
|
||||
style={{ paddingLeft: `${10 + depth * 16}px` }}
|
||||
onClick={() => togglePath(path)}
|
||||
>
|
||||
{isSelected ? (
|
||||
<CheckSquare size={16} className="text-primary shrink-0" />
|
||||
) : (
|
||||
<Square size={16} className="text-muted-foreground shrink-0" />
|
||||
)}
|
||||
<FolderTree size={16} className="text-primary shrink-0" />
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block truncate text-sm font-medium">{label}</span>
|
||||
<span className="block truncate text-[11px] text-muted-foreground">{path}</span>
|
||||
</span>
|
||||
<span className="text-[11px] text-muted-foreground shrink-0">
|
||||
{t('vault.groups.hostsCount', { count: hostCount })}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DialogFooter className="px-4 py-3 border-t border-border/60 shrink-0">
|
||||
<Button className="w-full" onClick={() => onOpenChange(false)}>
|
||||
{t('selectHost.continueWithCount', { count: selectedGroupPaths.length })}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
@@ -627,6 +627,8 @@ const SettingsPageContent: React.FC<{ settings: SettingsState }> = ({ settings }
|
||||
setRestorePreviousSession={settings.setRestorePreviousSession}
|
||||
restoreTerminalCwd={settings.restoreTerminalCwd}
|
||||
setRestoreTerminalCwd={settings.setRestoreTerminalCwd}
|
||||
startupLanding={settings.startupLanding}
|
||||
setStartupLanding={settings.setStartupLanding}
|
||||
toggleWindowHotkey={settings.toggleWindowHotkey}
|
||||
setToggleWindowHotkey={settings.setToggleWindowHotkey}
|
||||
closeToTray={settings.closeToTray}
|
||||
|
||||
@@ -86,6 +86,7 @@ import { scheduleDeferredTerminalFocus } from "./systemManager/tmuxActionFocus";
|
||||
import {
|
||||
connectionKeyMatchesHost,
|
||||
findReusableSftpSidePanelTab,
|
||||
isPendingSameEndpointSshSession,
|
||||
shouldAcceptPendingSftpUpload,
|
||||
shouldResetSftpSidePanelSourceSession,
|
||||
shouldSkipSftpSidePanelAutoConnect,
|
||||
@@ -560,6 +561,12 @@ const SftpSidePanelInner: React.FC<SftpSidePanelProps> = ({
|
||||
activeHost.username,
|
||||
activeHost.sftpFileProtocol,
|
||||
);
|
||||
const pendingSameEndpointSession = sessions.find((session) => (
|
||||
isPendingSameEndpointSshSession(session, activeHost)
|
||||
));
|
||||
if (!activeSessionId && pendingSameEndpointSession) {
|
||||
return;
|
||||
}
|
||||
const sessionChanged = shouldResetSftpSidePanelSourceSession(
|
||||
lastSourceSessionIdRef.current,
|
||||
activeSessionId,
|
||||
@@ -737,7 +744,7 @@ const SftpSidePanelInner: React.FC<SftpSidePanelProps> = ({
|
||||
tabConnectionKeyMapRef.current.set(tabId, connectionKey);
|
||||
},
|
||||
});
|
||||
}, [activeHost, activeSessionId, initialLocation, interactiveWorkActive]);
|
||||
}, [activeHost, activeSessionId, initialLocation, interactiveWorkActive, sessions]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!activeHost || !isVisible) return;
|
||||
|
||||
@@ -18,7 +18,11 @@ import {
|
||||
type SnippetExportPayload,
|
||||
type SnippetImportConflictAction,
|
||||
} from '../domain/snippetTransfer';
|
||||
import { getRunnableHostsForSnippet, snippetHasRunTargets } from '../domain/snippetTargets.ts';
|
||||
import {
|
||||
getRunnableHostsForSnippet,
|
||||
resolveSnippetTargetGroupsForSave,
|
||||
snippetHasRunTargets,
|
||||
} from '../domain/snippetTargets.ts';
|
||||
import { removeHostConnectScript, syncHostsForSnippetTargetChange } from '../domain/hostConnectScripts.ts';
|
||||
import { flattenSnippetCommandPreview } from '../domain/snippetPreview.ts';
|
||||
import { deleteSelectedSnippetsFromVault } from '../domain/snippetSelection.ts';
|
||||
@@ -486,6 +490,7 @@ const SnippetsManager: React.FC<SnippetsManagerProps> = ({
|
||||
targets: [],
|
||||
});
|
||||
const [targetSelection, setTargetSelection] = useState<string[]>([]);
|
||||
const [targetGroupSelection, setTargetGroupSelection] = useState<string[]>([]);
|
||||
const [copiedId, setCopiedId] = useState<string | null>(null);
|
||||
const [selectedPackage, setSelectedPackage] = useState<string | null>(null);
|
||||
const [newPackageName, setNewPackageName] = useState('');
|
||||
@@ -735,6 +740,7 @@ const SnippetsManager: React.FC<SnippetsManagerProps> = ({
|
||||
if (snippet) {
|
||||
setEditingSnippet(snippet);
|
||||
setTargetSelection(snippet.targetsAllHosts ? [] : (snippet.targets || []));
|
||||
setTargetGroupSelection(snippet.targetsAllHosts ? [] : (snippet.targetGroups || []));
|
||||
} else {
|
||||
setEditingSnippet(asScript ? {
|
||||
label: '',
|
||||
@@ -751,6 +757,7 @@ const SnippetsManager: React.FC<SnippetsManagerProps> = ({
|
||||
targets: [],
|
||||
});
|
||||
setTargetSelection([]);
|
||||
setTargetGroupSelection([]);
|
||||
}
|
||||
setRightPanelMode('edit-snippet');
|
||||
}, [selectedPackage]);
|
||||
@@ -773,6 +780,10 @@ const SnippetsManager: React.FC<SnippetsManagerProps> = ({
|
||||
tags: editingSnippet.tags || [],
|
||||
package: editingSnippet.package || '',
|
||||
targets: editingSnippet.targetsAllHosts ? [] : targetSelection,
|
||||
targetGroups: resolveSnippetTargetGroupsForSave(
|
||||
editingSnippet,
|
||||
targetGroupSelection,
|
||||
),
|
||||
targetsAllHosts: editingSnippet.targetsAllHosts || undefined,
|
||||
shortkey: editingSnippet.shortkey,
|
||||
noAutoRun: editingSnippet.noAutoRun,
|
||||
@@ -784,7 +795,7 @@ const SnippetsManager: React.FC<SnippetsManagerProps> = ({
|
||||
trigger: editingSnippet.trigger,
|
||||
triggerPattern: editingSnippet.triggerPattern,
|
||||
};
|
||||
}, [editingSnippet, targetSelection]);
|
||||
}, [editingSnippet, targetGroupSelection, targetSelection]);
|
||||
|
||||
const syncHostsAfterSnippetSave = useCallback((
|
||||
savedSnippet: Snippet,
|
||||
@@ -855,6 +866,7 @@ const SnippetsManager: React.FC<SnippetsManagerProps> = ({
|
||||
setRightPanelMode('none');
|
||||
setEditingSnippet({ label: '', command: '', package: '', targets: [] });
|
||||
setTargetSelection([]);
|
||||
setTargetGroupSelection([]);
|
||||
};
|
||||
|
||||
const hostById = useMemo(() => (
|
||||
@@ -1663,6 +1675,8 @@ const SnippetsManager: React.FC<SnippetsManagerProps> = ({
|
||||
customGroups={customGroups}
|
||||
targetSelection={targetSelection}
|
||||
setTargetSelection={setTargetSelection}
|
||||
targetGroupSelection={targetGroupSelection}
|
||||
setTargetGroupSelection={setTargetGroupSelection}
|
||||
handleTargetSelect={handleTargetSelect}
|
||||
handleTargetSelectionChange={handleTargetSelectionChange}
|
||||
handleTargetPickerBack={handleTargetPickerBack}
|
||||
|
||||
@@ -3,6 +3,7 @@ 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';
|
||||
import { SelectGroupDialog } from './SelectGroupDialog';
|
||||
import { AsidePanel, AsidePanelContent, AsidePanelFooter } from './ui/aside-panel';
|
||||
import { Button } from './ui/button';
|
||||
import { Card } from './ui/card';
|
||||
@@ -17,7 +18,10 @@ import { Combobox } from './ui/combobox';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from './ui/select';
|
||||
import { HistoryItem } from './SnippetsHistoryItem';
|
||||
import type { Snippet } from '@/domain/models';
|
||||
import { getRunnableHostsForSnippet } from '@/domain/snippetTargets.ts';
|
||||
import {
|
||||
getRunnableHostsForSnippet,
|
||||
resolveSnippetTargetGroupsForSave,
|
||||
} from '@/domain/snippetTargets.ts';
|
||||
import { STORAGE_KEY_SNIPPETS_PANEL_WIDTH } from '@/infrastructure/config/storageKeys.ts';
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
@@ -29,6 +33,8 @@ export const SnippetsRightPanel: React.FC<SnippetsRightPanelProps> = ({
|
||||
customGroups,
|
||||
targetSelection,
|
||||
setTargetSelection,
|
||||
targetGroupSelection,
|
||||
setTargetGroupSelection,
|
||||
handleTargetSelect,
|
||||
handleTargetSelectionChange,
|
||||
handleTargetPickerBack,
|
||||
@@ -71,6 +77,7 @@ export const SnippetsRightPanel: React.FC<SnippetsRightPanelProps> = ({
|
||||
[editingSnippet?.command],
|
||||
);
|
||||
const [scriptEditorModalOpen, setScriptEditorModalOpen] = useState(false);
|
||||
const [groupPickerOpen, setGroupPickerOpen] = useState(false);
|
||||
const isEditingScript = isScriptSnippet(editingSnippet as import('../types').Snippet);
|
||||
const snippetsPanelResizeProps = {
|
||||
resizable: true as const,
|
||||
@@ -81,8 +88,12 @@ export const SnippetsRightPanel: React.FC<SnippetsRightPanelProps> = ({
|
||||
const runnableEditingSnippet = useMemo(() => ({
|
||||
...(editingSnippet as Snippet),
|
||||
targets: editingSnippet.targetsAllHosts ? [] : targetSelection,
|
||||
targetGroups: resolveSnippetTargetGroupsForSave(
|
||||
editingSnippet,
|
||||
targetGroupSelection,
|
||||
),
|
||||
targetsAllHosts: editingSnippet.targetsAllHosts || undefined,
|
||||
}), [editingSnippet, targetSelection]);
|
||||
}), [editingSnippet, targetGroupSelection, targetSelection]);
|
||||
|
||||
const runTargets = useMemo(
|
||||
() => getRunnableHostsForSnippet(runnableEditingSnippet, hosts),
|
||||
@@ -99,10 +110,12 @@ export const SnippetsRightPanel: React.FC<SnippetsRightPanelProps> = ({
|
||||
const handleTargetsAllHostsChange = (checked: boolean) => {
|
||||
if (checked) {
|
||||
setTargetSelection([]);
|
||||
setTargetGroupSelection([]);
|
||||
setEditingSnippet({
|
||||
...editingSnippet,
|
||||
targetsAllHosts: true,
|
||||
targets: [],
|
||||
targetGroups: undefined,
|
||||
});
|
||||
return;
|
||||
}
|
||||
@@ -137,6 +150,7 @@ export const SnippetsRightPanel: React.FC<SnippetsRightPanelProps> = ({
|
||||
|
||||
if (rightPanelMode === 'edit-snippet') {
|
||||
return (
|
||||
<>
|
||||
<AsidePanel
|
||||
open={true}
|
||||
onClose={handleClosePanel}
|
||||
@@ -266,7 +280,9 @@ export const SnippetsRightPanel: React.FC<SnippetsRightPanelProps> = ({
|
||||
<SnippetTargetsSection
|
||||
t={t}
|
||||
targetHosts={targetHosts}
|
||||
targetGroups={isEditingScript ? targetGroupSelection : []}
|
||||
onEditTargets={openTargetPicker}
|
||||
onEditGroups={isEditingScript ? () => setGroupPickerOpen(true) : undefined}
|
||||
hint={isEditingScript
|
||||
? (editingSnippet.trigger === 'onConnect'
|
||||
? t('scripts.targets.connectOrderHint')
|
||||
@@ -386,11 +402,29 @@ export const SnippetsRightPanel: React.FC<SnippetsRightPanelProps> = ({
|
||||
selectedHostIds={targetSelection}
|
||||
onSelectHost={handleTargetSelect}
|
||||
onSelectionChange={handleTargetSelectionChange}
|
||||
selectedGroupPaths={targetGroupSelection}
|
||||
onGroupSelectionChange={setTargetGroupSelection}
|
||||
targetsAllHosts={Boolean(editingSnippet.targetsAllHosts)}
|
||||
onTargetsAllHostsChange={handleTargetsAllHostsChange}
|
||||
/>
|
||||
) : null}
|
||||
</AsidePanel>
|
||||
<SelectGroupDialog
|
||||
open={groupPickerOpen}
|
||||
onOpenChange={setGroupPickerOpen}
|
||||
hosts={hosts}
|
||||
customGroups={customGroups}
|
||||
selectedGroupPaths={targetGroupSelection}
|
||||
onSelectionChange={(nextGroups) => {
|
||||
setTargetGroupSelection(nextGroups);
|
||||
setEditingSnippet({
|
||||
...editingSnippet,
|
||||
targetGroups: nextGroups,
|
||||
targetsAllHosts: undefined,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
+19
-8
@@ -121,6 +121,7 @@ import { useVaultSnapshotField } from "@/application/state/vaultSnapshotStore.ts
|
||||
import { netcattyBridge } from "@/infrastructure/services/netcattyBridge.ts";
|
||||
import { ScriptExecutionOverlay } from "./terminal/ScriptExecutionOverlay";
|
||||
import { isScriptSnippet } from "@/domain/snippetScript.ts";
|
||||
import { snippetCanRunInTerminal } from "@/domain/snippetTargets.ts";
|
||||
import { useOutputTriggers } from "@/application/state/useOutputTriggers.ts";
|
||||
import { TerminalComposeBar } from "./terminal/TerminalComposeBar";
|
||||
import { TerminalContextMenu } from "./terminal/TerminalContextMenu";
|
||||
@@ -427,7 +428,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
const scriptSessionName = sessionDisplayName || host.label;
|
||||
const outputTriggers = useOutputTriggers({
|
||||
sessionId,
|
||||
hostId: host.id,
|
||||
host,
|
||||
snippets,
|
||||
onRunScript: (snippet, sid) => runAutomationScript({
|
||||
snippet,
|
||||
@@ -438,7 +439,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
hostname: host.hostname,
|
||||
username: host.username,
|
||||
},
|
||||
}).catch((err) => {
|
||||
}).then(() => undefined).catch((err) => {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
toast.error(message.includes('Observer mode') ? t('scripts.observer.blocked') : message);
|
||||
throw err;
|
||||
@@ -1617,7 +1618,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
terminalCwdTracker,
|
||||
]);
|
||||
|
||||
const cleanupSession = async () => {
|
||||
const cleanupSession = async (options?: { retainOwnership?: boolean }) => {
|
||||
const closingSessionId = sessionRef.current;
|
||||
xtermRuntimeRef.current?.flushKittyKeyboardReleases();
|
||||
sessionRef.current = null;
|
||||
@@ -1645,7 +1646,10 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
// Still notify main so in-flight SSH passphrase prompts for this UI
|
||||
// sessionId are aborted even before a backend session was attached.
|
||||
try {
|
||||
await terminalBackend.closeSession(sessionId, { bootEpoch: resolveCloseBootEpoch() });
|
||||
await terminalBackend.closeSession(sessionId, {
|
||||
bootEpoch: resolveCloseBootEpoch(),
|
||||
...(options?.retainOwnership === true ? { retainOwnership: true } : {}),
|
||||
});
|
||||
} catch (err) {
|
||||
logger.warn("Failed to cancel pending session boot on disconnect", err);
|
||||
}
|
||||
@@ -1754,7 +1758,10 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
clearTerminalSessionFlowAck(closingSessionId);
|
||||
}
|
||||
try {
|
||||
await terminalBackend.closeSession(closingSessionId, { bootEpoch: resolveCloseBootEpoch() });
|
||||
await terminalBackend.closeSession(closingSessionId, {
|
||||
bootEpoch: resolveCloseBootEpoch(),
|
||||
...(options?.retainOwnership === true ? { retainOwnership: true } : {}),
|
||||
});
|
||||
} catch (err) {
|
||||
logger.warn("Failed to close SSH session", err);
|
||||
}
|
||||
@@ -3067,6 +3074,10 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
|
||||
const executeSnippet = useCallback(async (snippet: Snippet) => {
|
||||
if (isScriptSnippet(snippet)) {
|
||||
if (!snippetCanRunInTerminal(snippet, host)) {
|
||||
toast.error(t('scripts.targets.currentHostMismatch'));
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await runAutomationScript({
|
||||
snippet,
|
||||
@@ -3089,7 +3100,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
executeSnippetCommand(command, snippet.noAutoRun, {
|
||||
multiLineRunMode: snippet.multiLineRunMode,
|
||||
});
|
||||
}, [executeSnippetCommand, host.hostname, host.username, scriptSessionName, sessionId, t]);
|
||||
}, [executeSnippetCommand, host, scriptSessionName, sessionId, t]);
|
||||
|
||||
const onSnippetShortkeyRef = useRef(executeSnippet);
|
||||
onSnippetShortkeyRef.current = executeSnippet;
|
||||
@@ -3303,7 +3314,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
setPendingHostKeyRequestId(null);
|
||||
setError(null);
|
||||
setProgressLogs((prev) => [...prev, "Disconnected by user."]);
|
||||
void cleanupSession();
|
||||
void cleanupSession({ retainOwnership: true });
|
||||
updateStatus("disconnected");
|
||||
setChainProgress(null);
|
||||
setIsDisconnectedDialogDismissed(false);
|
||||
@@ -3472,7 +3483,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
}
|
||||
|
||||
try {
|
||||
await cleanupSession();
|
||||
await cleanupSession({ retainOwnership: true });
|
||||
} catch (error) {
|
||||
finishReconnectPreparation();
|
||||
throw error;
|
||||
|
||||
@@ -60,48 +60,66 @@ const renderVault = (sortMode: string | null, hosts: Host[]) => {
|
||||
return renderToStaticMarkup(
|
||||
React.createElement(
|
||||
I18nProvider,
|
||||
{ locale: "en" },
|
||||
React.createElement(
|
||||
TooltipProvider,
|
||||
null,
|
||||
React.createElement(VaultView, {
|
||||
hosts,
|
||||
keys: [],
|
||||
identities: [],
|
||||
proxyProfiles: [],
|
||||
snippets: [],
|
||||
snippetPackages: [],
|
||||
customGroups: [],
|
||||
knownHosts: [],
|
||||
managedSources: [],
|
||||
sessionCount: 0,
|
||||
hotkeyScheme: "mac",
|
||||
keyBindings: [],
|
||||
terminalThemeId: "default",
|
||||
terminalFontSize: 14,
|
||||
onOpenSettings: noop,
|
||||
onOpenQuickSwitcher: noop,
|
||||
onCreateLocalTerminal: noop,
|
||||
onDeleteHost: noop,
|
||||
onConnect: noop,
|
||||
onUpdateHosts: noop,
|
||||
onUpdateKeys: noop,
|
||||
onImportOrReuseKey: () => fallbackKey,
|
||||
onUpdateIdentities: noop,
|
||||
onUpdateProxyProfiles: noop,
|
||||
onUpdateSnippets: noop,
|
||||
onUpdateSnippetPackages: noop,
|
||||
onUpdateCustomGroups: noop,
|
||||
onUpdateKnownHosts: noop,
|
||||
onUpdateManagedSources: noop,
|
||||
onConvertKnownHost: noop,
|
||||
onOpenLogView: noop,
|
||||
groupConfigs: [],
|
||||
onUpdateGroupConfigs: noop,
|
||||
showRecentHosts: false,
|
||||
showOnlyUngroupedHostsInRoot: false,
|
||||
}),
|
||||
),
|
||||
{
|
||||
locale: "en",
|
||||
children: React.createElement(
|
||||
TooltipProvider,
|
||||
null,
|
||||
React.createElement(VaultView, {
|
||||
hosts,
|
||||
keys: [],
|
||||
identities: [],
|
||||
proxyProfiles: [],
|
||||
snippets: [],
|
||||
snippetPackages: [],
|
||||
customGroups: [],
|
||||
knownHosts: [],
|
||||
managedSources: [],
|
||||
sessionCount: 0,
|
||||
hotkeyScheme: "mac",
|
||||
keyBindings: [],
|
||||
terminalThemeId: "default",
|
||||
terminalFontSize: 14,
|
||||
onOpenSettings: noop,
|
||||
onOpenQuickSwitcher: noop,
|
||||
onCreateLocalTerminal: noop,
|
||||
onDeleteHost: noop,
|
||||
onConnect: noop,
|
||||
onUpdateHosts: noop,
|
||||
onUpdateKeys: noop,
|
||||
onImportOrReuseKey: () => fallbackKey,
|
||||
onUpdateIdentities: noop,
|
||||
onUpdateProxyProfiles: noop,
|
||||
onUpdateSnippets: noop,
|
||||
onUpdateSnippetPackages: noop,
|
||||
onUpdateCustomGroups: noop,
|
||||
onUpdateKnownHosts: noop,
|
||||
onUpdateManagedSources: noop,
|
||||
onReadPersistedHosts: async () => hosts,
|
||||
onReadPersistedManagedSources: () => [],
|
||||
onCommitPluginImporterData: async () => 0,
|
||||
onCommitVaultImportTransaction: async () => ({
|
||||
status: "persisted" as const,
|
||||
groups: [],
|
||||
sources: [],
|
||||
groupConfigs: [],
|
||||
}),
|
||||
onCommitVaultGroupMutation: async (mutate) => mutate({
|
||||
groups: [],
|
||||
configs: [],
|
||||
hosts,
|
||||
managedSources: [],
|
||||
snippets: [],
|
||||
}),
|
||||
onConvertKnownHost: noop,
|
||||
onOpenLogView: noop,
|
||||
groupConfigs: [],
|
||||
onUpdateGroupConfigs: noop,
|
||||
showRecentHosts: false,
|
||||
showOnlyUngroupedHostsInRoot: false,
|
||||
}),
|
||||
),
|
||||
},
|
||||
),
|
||||
);
|
||||
};
|
||||
|
||||
+91
-113
@@ -52,6 +52,10 @@ import type { VaultLockHandle } from "../application/state/vaultManagedImportLoc
|
||||
import { useTreeExpandedState } from "../application/state/useTreeExpandedState";
|
||||
import { useVaultGroupDeletion } from "../application/state/useVaultGroupDeletion";
|
||||
import type { VaultHostPersistenceResult } from "../application/state/vaultImportProgress";
|
||||
import type {
|
||||
VaultGroupMutationResult,
|
||||
VaultGroupMutationState,
|
||||
} from "../domain/vaultGroupMutation";
|
||||
import type { PluginImporterCommitRequest } from "../application/state/usePluginImporterCommit";
|
||||
import { buildVaultCsvCredentialOptions } from "../application/vaultCsvExportCredentials";
|
||||
import { sanitizeCredentialValue } from "../domain/credentials";
|
||||
@@ -69,6 +73,9 @@ import {
|
||||
upsertHostById,
|
||||
} from "../domain/host";
|
||||
import { exportHostsToCsvWithStats } from "../domain/vaultImport";
|
||||
import {
|
||||
remapSnippetTargetGroupPaths,
|
||||
} from "../domain/hostGroupPathMutations";
|
||||
import {
|
||||
reorderVaultItems,
|
||||
reorderVaultStrings,
|
||||
@@ -261,6 +268,10 @@ interface VaultViewProps {
|
||||
}
|
||||
| { status: "superseded" }
|
||||
>;
|
||||
onCommitVaultGroupMutation: (
|
||||
mutate: (current: VaultGroupMutationState) => VaultGroupMutationResult,
|
||||
lock?: VaultLockHandle | null,
|
||||
) => Promise<VaultGroupMutationResult | { ok: false; superseded: true }>;
|
||||
onClearAndRemoveManagedSource?: (source: ManagedSource) => Promise<() => Promise<void>>;
|
||||
onClearAndRemoveManagedSources?: (sources: ManagedSource[]) => Promise<() => Promise<void>>;
|
||||
onUnmanageSource?: (sourceId: string) => void;
|
||||
@@ -325,6 +336,7 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
|
||||
onUpdateManagedSources,
|
||||
onReadPersistedManagedSources,
|
||||
onCommitVaultImportTransaction,
|
||||
onCommitVaultGroupMutation,
|
||||
onClearAndRemoveManagedSource,
|
||||
onClearAndRemoveManagedSources,
|
||||
onUnmanageSource,
|
||||
@@ -951,7 +963,66 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
|
||||
setIsNewFolderOpen(false);
|
||||
};
|
||||
|
||||
const submitRenameGroup = () => {
|
||||
const commitGroupPathChange = useCallback(async (
|
||||
sourcePath: string,
|
||||
nextPath: string,
|
||||
replacementConfig?: GroupConfig,
|
||||
) => {
|
||||
try {
|
||||
return await onCommitVaultGroupMutation((current) => {
|
||||
if (!current.groups.includes(sourcePath)) {
|
||||
return { ok: false, error: `Group "${sourcePath}" was not found.` };
|
||||
}
|
||||
const belongsToTree = (path: string) => (
|
||||
path === sourcePath || path.startsWith(`${sourcePath}/`)
|
||||
);
|
||||
const rename = (path: string) => (
|
||||
path === sourcePath
|
||||
? nextPath
|
||||
: path.startsWith(`${sourcePath}/`)
|
||||
? nextPath + path.slice(sourcePath.length)
|
||||
: path
|
||||
);
|
||||
const occupied = new Set([
|
||||
...current.groups.filter((path) => !belongsToTree(path)),
|
||||
...current.configs.map((config) => config.path).filter((path) => !belongsToTree(path)),
|
||||
]);
|
||||
const collision = [
|
||||
...current.groups,
|
||||
...current.configs.map((config) => config.path),
|
||||
].filter(belongsToTree).map(rename).find((path) => occupied.has(path));
|
||||
if (collision) return { ok: false, error: `Group "${collision}" already exists.` };
|
||||
|
||||
const configs = current.configs.map((config) => {
|
||||
if (replacementConfig && config.path === sourcePath) {
|
||||
return { ...replacementConfig, path: nextPath };
|
||||
}
|
||||
return belongsToTree(config.path) ? { ...config, path: rename(config.path) } : config;
|
||||
});
|
||||
if (replacementConfig && !current.configs.some((config) => config.path === sourcePath)) {
|
||||
configs.push({ ...replacementConfig, path: nextPath });
|
||||
}
|
||||
return {
|
||||
ok: true,
|
||||
state: {
|
||||
groups: Array.from(new Set(current.groups.map(rename))),
|
||||
configs,
|
||||
hosts: current.hosts.map((host) => (
|
||||
host.group && belongsToTree(host.group) ? { ...host, group: rename(host.group) } : host
|
||||
)),
|
||||
managedSources: current.managedSources.map((source) => (
|
||||
belongsToTree(source.groupName) ? { ...source, groupName: rename(source.groupName) } : source
|
||||
)),
|
||||
snippets: remapSnippetTargetGroupPaths(current.snippets, sourcePath, nextPath),
|
||||
},
|
||||
};
|
||||
});
|
||||
} catch (error) {
|
||||
return { ok: false as const, error: error instanceof Error ? error.message : String(error) };
|
||||
}
|
||||
}, [onCommitVaultGroupMutation]);
|
||||
|
||||
const submitRenameGroup = async () => {
|
||||
if (!renameTargetPath) return;
|
||||
|
||||
const nextName = renameGroupName.trim();
|
||||
@@ -972,37 +1043,11 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
|
||||
return;
|
||||
}
|
||||
|
||||
const updatedGroups = customGroups.map((g) => {
|
||||
if (g === renameTargetPath) return nextPath;
|
||||
if (g.startsWith(renameTargetPath + "/"))
|
||||
return nextPath + g.slice(renameTargetPath.length);
|
||||
return g;
|
||||
});
|
||||
const updatedHosts = hosts.map((h) => {
|
||||
const g = h.group || "";
|
||||
if (g === renameTargetPath) return { ...h, group: nextPath };
|
||||
if (g.startsWith(renameTargetPath + "/"))
|
||||
return { ...h, group: nextPath + g.slice(renameTargetPath.length) };
|
||||
return h;
|
||||
});
|
||||
|
||||
// Update managed sources if any match the renamed group path
|
||||
const updatedManagedSources = managedSources.map((s) => {
|
||||
if (s.groupName === renameTargetPath)
|
||||
return { ...s, groupName: nextPath };
|
||||
if (s.groupName.startsWith(renameTargetPath + "/"))
|
||||
return {
|
||||
...s,
|
||||
groupName: nextPath + s.groupName.slice(renameTargetPath.length),
|
||||
};
|
||||
return s;
|
||||
});
|
||||
if (updatedManagedSources.some((s, i) => s !== managedSources[i])) {
|
||||
onUpdateManagedSources(updatedManagedSources);
|
||||
const result = await commitGroupPathChange(renameTargetPath, nextPath);
|
||||
if ("superseded" in result || "error" in result) {
|
||||
setRenameGroupError("superseded" in result ? t("common.error") : result.error);
|
||||
return;
|
||||
}
|
||||
|
||||
onUpdateCustomGroups(Array.from(new Set(updatedGroups)));
|
||||
onUpdateHosts(updatedHosts);
|
||||
if (
|
||||
selectedGroupPath &&
|
||||
(selectedGroupPath === renameTargetPath ||
|
||||
@@ -1026,7 +1071,7 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
|
||||
}, []);
|
||||
|
||||
const handleSaveGroupConfig = useCallback(
|
||||
(config: GroupConfig, _newName?: string, _newParent?: string | null) => {
|
||||
async (config: GroupConfig, _newName?: string, _newParent?: string | null) => {
|
||||
const oldPath = editingGroupPath!;
|
||||
const newPath = config.path; // Panel already computed the correct path
|
||||
|
||||
@@ -1044,41 +1089,11 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
|
||||
|
||||
// Handle path change (rename or parent change)
|
||||
if (newPath !== oldPath) {
|
||||
// Update groups, hosts, managed sources, and configs for path change
|
||||
const updatedGroups = customGroups.map((g) => {
|
||||
if (g === oldPath) return newPath;
|
||||
if (g.startsWith(oldPath + "/"))
|
||||
return newPath + g.slice(oldPath.length);
|
||||
return g;
|
||||
});
|
||||
const updatedHosts = hosts.map((h) => {
|
||||
const g = h.group || "";
|
||||
if (g === oldPath) return { ...h, group: newPath };
|
||||
if (g.startsWith(oldPath + "/"))
|
||||
return { ...h, group: newPath + g.slice(oldPath.length) };
|
||||
return h;
|
||||
});
|
||||
const updatedManagedSources = managedSources.map((s) => {
|
||||
if (s.groupName === oldPath) return { ...s, groupName: newPath };
|
||||
if (s.groupName.startsWith(oldPath + "/"))
|
||||
return {
|
||||
...s,
|
||||
groupName: newPath + s.groupName.slice(oldPath.length),
|
||||
};
|
||||
return s;
|
||||
});
|
||||
if (updatedManagedSources.some((s, i) => s !== managedSources[i])) {
|
||||
onUpdateManagedSources(updatedManagedSources);
|
||||
const result = await commitGroupPathChange(oldPath, newPath, config);
|
||||
if ("superseded" in result || "error" in result) {
|
||||
toast.error("superseded" in result ? t("common.error") : result.error);
|
||||
return;
|
||||
}
|
||||
onUpdateCustomGroups(Array.from(new Set(updatedGroups)));
|
||||
onUpdateHosts(updatedHosts);
|
||||
// Update child config paths too
|
||||
const finalConfigs = updatedConfigs.map((c) => {
|
||||
if (c.path.startsWith(oldPath + "/"))
|
||||
return { ...c, path: newPath + c.path.slice(oldPath.length) };
|
||||
return c;
|
||||
});
|
||||
onUpdateGroupConfigs(finalConfigs);
|
||||
if (selectedGroupPath === oldPath) setSelectedGroupPath(newPath);
|
||||
if (selectedGroupPath?.startsWith(oldPath + "/")) {
|
||||
setSelectedGroupPath(
|
||||
@@ -1096,13 +1111,9 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
|
||||
groupConfigs,
|
||||
editingGroupPath,
|
||||
customGroups,
|
||||
hosts,
|
||||
managedSources,
|
||||
selectedGroupPath,
|
||||
commitGroupPathChange,
|
||||
onUpdateGroupConfigs,
|
||||
onUpdateCustomGroups,
|
||||
onUpdateHosts,
|
||||
onUpdateManagedSources,
|
||||
t,
|
||||
],
|
||||
);
|
||||
@@ -1129,7 +1140,7 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
|
||||
managedSources,
|
||||
onReadPersistedHosts,
|
||||
onReadPersistedManagedSources,
|
||||
onCommitVaultImportTransaction,
|
||||
onCommitVaultGroupMutation,
|
||||
onClearAndRemoveManagedSource,
|
||||
onClearAndRemoveManagedSources,
|
||||
onDeletedPaths: handleDeletedGroupPaths,
|
||||
@@ -1138,7 +1149,7 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
|
||||
const deleteGroupPath = (path: string, deleteHosts: boolean = false) =>
|
||||
deleteGroupPaths([path], deleteHosts);
|
||||
|
||||
const moveGroup = (sourcePath: string, targetParent: string | null) => {
|
||||
const moveGroup = async (sourcePath: string, targetParent: string | null) => {
|
||||
const name = sourcePath.split("/").filter(Boolean).pop() || "";
|
||||
const newPath = targetParent ? `${targetParent}/${name}` : name;
|
||||
if (newPath === sourcePath || newPath.startsWith(sourcePath + "/")) return;
|
||||
@@ -1146,50 +1157,17 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
|
||||
toast.error(t("vault.groups.errors.duplicatePath"));
|
||||
return;
|
||||
}
|
||||
const updatedGroups = customGroups.map((g) => {
|
||||
if (g === sourcePath) return newPath;
|
||||
if (g.startsWith(sourcePath + "/"))
|
||||
return newPath + g.slice(sourcePath.length);
|
||||
return g;
|
||||
});
|
||||
const updatedHosts = hosts.map((h) => {
|
||||
const g = h.group || "";
|
||||
if (g === sourcePath) return { ...h, group: newPath };
|
||||
if (g.startsWith(sourcePath + "/"))
|
||||
return { ...h, group: newPath + g.slice(sourcePath.length) };
|
||||
return h;
|
||||
});
|
||||
// Update managed sources if any match the moved group path
|
||||
const updatedManagedSources = managedSources.map((s) => {
|
||||
if (s.groupName === sourcePath) return { ...s, groupName: newPath };
|
||||
if (s.groupName.startsWith(sourcePath + "/"))
|
||||
return {
|
||||
...s,
|
||||
groupName: newPath + s.groupName.slice(sourcePath.length),
|
||||
};
|
||||
return s;
|
||||
});
|
||||
if (updatedManagedSources.some((s, i) => s !== managedSources[i])) {
|
||||
onUpdateManagedSources(updatedManagedSources);
|
||||
}
|
||||
onUpdateCustomGroups(Array.from(new Set(updatedGroups)));
|
||||
onUpdateHosts(updatedHosts);
|
||||
// Update group configs for moved paths
|
||||
const updatedGroupConfigs = groupConfigs.map((c) => {
|
||||
if (c.path === sourcePath) return { ...c, path: newPath };
|
||||
if (c.path.startsWith(sourcePath + "/"))
|
||||
return { ...c, path: newPath + c.path.slice(sourcePath.length) };
|
||||
return c;
|
||||
});
|
||||
if (updatedGroupConfigs.some((c, i) => c !== groupConfigs[i])) {
|
||||
onUpdateGroupConfigs(updatedGroupConfigs);
|
||||
const result = await commitGroupPathChange(sourcePath, newPath);
|
||||
if ("superseded" in result || "error" in result) {
|
||||
toast.error("superseded" in result ? t("common.error") : result.error);
|
||||
return;
|
||||
}
|
||||
if (
|
||||
selectedGroupPath &&
|
||||
(selectedGroupPath === sourcePath ||
|
||||
selectedGroupPath.startsWith(sourcePath + "/"))
|
||||
) {
|
||||
setSelectedGroupPath(newPath);
|
||||
setSelectedGroupPath(newPath + selectedGroupPath.slice(sourcePath.length));
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1321,8 +1299,7 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
|
||||
hosts,
|
||||
managedSources,
|
||||
onUpdateCustomGroups,
|
||||
onUpdateHosts,
|
||||
onUpdateManagedSources,
|
||||
onCommitGroupPathChange: commitGroupPathChange,
|
||||
selectedGroupPath,
|
||||
setSelectedGroupPath,
|
||||
ensurePathExpanded: treeExpandedState.ensurePathExpanded,
|
||||
@@ -1688,6 +1665,7 @@ export const vaultViewAreEqual = (
|
||||
prev.onReadPersistedHosts === next.onReadPersistedHosts &&
|
||||
prev.onReadPersistedManagedSources === next.onReadPersistedManagedSources &&
|
||||
prev.onCommitVaultImportTransaction === next.onCommitVaultImportTransaction &&
|
||||
prev.onCommitVaultGroupMutation === next.onCommitVaultGroupMutation &&
|
||||
prev.onCommitPluginImporterData === next.onCommitPluginImporterData &&
|
||||
prev.showRecentHosts === next.showRecentHosts &&
|
||||
prev.hostClickBehavior === next.hostClickBehavior &&
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { Globe, Link2, Play, Trash2 } from 'lucide-react';
|
||||
import { FolderTree, Globe, Link2, Play, Trash2 } from 'lucide-react';
|
||||
import React, { useCallback, useMemo, useState, type DragEvent } from 'react';
|
||||
import type { Host, Snippet } from '@/domain/models';
|
||||
import {
|
||||
appendHostConnectScript,
|
||||
getEditableHostConnectScriptIds,
|
||||
getGlobalConnectScripts,
|
||||
getGroupConnectScriptsForHost,
|
||||
removeHostConnectScript,
|
||||
reorderHostConnectScript,
|
||||
} from '@/domain/hostConnectScripts.ts';
|
||||
@@ -63,6 +64,16 @@ export const HostDetailsScriptsSection: React.FC<HostDetailsScriptsSectionProps>
|
||||
() => getEditableHostConnectScriptIds(host, snippets),
|
||||
[host, snippets],
|
||||
);
|
||||
const groupScripts = useMemo(
|
||||
() => getGroupConnectScriptsForHost(host, snippets).filter(
|
||||
(script) => !script.id || !queueIds.includes(script.id),
|
||||
),
|
||||
[host, queueIds, snippets],
|
||||
);
|
||||
const groupScriptIds = useMemo(
|
||||
() => new Set(groupScripts.map((script) => script.id)),
|
||||
[groupScripts],
|
||||
);
|
||||
const queuedScripts = useMemo(
|
||||
() => queueIds
|
||||
.map((id) => scriptById(snippets, id))
|
||||
@@ -73,10 +84,11 @@ export const HostDetailsScriptsSection: React.FC<HostDetailsScriptsSectionProps>
|
||||
() => scripts.filter((script) => {
|
||||
if (!script.id) return false;
|
||||
if (script.targetsAllHosts) return false;
|
||||
if (groupScriptIds.has(script.id)) return false;
|
||||
if (queueIds.includes(script.id)) return false;
|
||||
return true;
|
||||
}),
|
||||
[queueIds, scripts],
|
||||
[groupScriptIds, queueIds, scripts],
|
||||
);
|
||||
const linkOptions = useMemo(
|
||||
() => linkableScripts.map((script) => ({
|
||||
@@ -170,6 +182,41 @@ export const HostDetailsScriptsSection: React.FC<HostDetailsScriptsSectionProps>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{groupScripts.length > 0 ? (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<FolderTree size={12} className="text-muted-foreground shrink-0" />
|
||||
<label className="text-xs text-muted-foreground">
|
||||
{t('hostDetails.automation.groupScripts')}
|
||||
</label>
|
||||
</div>
|
||||
<p className="text-[10px] text-muted-foreground leading-relaxed">
|
||||
{t('hostDetails.automation.groupScriptsHint')}
|
||||
</p>
|
||||
<div className="space-y-1.5">
|
||||
{groupScripts.map((script) => (
|
||||
<div
|
||||
key={script.id}
|
||||
className="flex items-center gap-2.5 px-2.5 py-2 rounded-md border border-border/50 bg-primary/5"
|
||||
>
|
||||
<VaultEntityIcon
|
||||
className={cn(vaultEntityIconSmClass, vaultAutomationScriptIconClass)}
|
||||
icon={<Play size={14} />}
|
||||
/>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-sm font-medium truncate">
|
||||
{script.label || t('scripts.running.unnamed')}
|
||||
</div>
|
||||
<div className="text-[10px] text-muted-foreground truncate">
|
||||
{script.targetGroups?.join(', ')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="space-y-2">
|
||||
{queuedScripts.length === 0 ? (
|
||||
<p className="text-[11px] text-muted-foreground">{t('hostDetails.automation.connectQueueEmpty')}</p>
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import React from 'react';
|
||||
import { act, create, type ReactTestRenderer } from 'react-test-renderer';
|
||||
|
||||
import { HostTreeGroupInlineRenameInput } from './HostTreeGroupInlineRenameInput';
|
||||
|
||||
test('inline group rename can retry after an asynchronous commit failure', async () => {
|
||||
const actEnvironment = globalThis as typeof globalThis & {
|
||||
IS_REACT_ACT_ENVIRONMENT?: boolean;
|
||||
};
|
||||
const previousActEnvironment = actEnvironment.IS_REACT_ACT_ENVIRONMENT;
|
||||
actEnvironment.IS_REACT_ACT_ENVIRONMENT = true;
|
||||
|
||||
let renderer: ReactTestRenderer | null = null;
|
||||
let attempts = 0;
|
||||
|
||||
try {
|
||||
await act(async () => {
|
||||
renderer = create(React.createElement(HostTreeGroupInlineRenameInput, {
|
||||
initialName: 'prod',
|
||||
onCommit: async () => {
|
||||
attempts += 1;
|
||||
return attempts > 1;
|
||||
},
|
||||
onCancel: () => undefined,
|
||||
}));
|
||||
});
|
||||
const input = renderer!.root.findByType('input');
|
||||
|
||||
await act(async () => {
|
||||
input.props.onKeyDown({
|
||||
key: 'Enter',
|
||||
preventDefault: () => undefined,
|
||||
stopPropagation: () => undefined,
|
||||
});
|
||||
await Promise.resolve();
|
||||
});
|
||||
await act(async () => {
|
||||
input.props.onKeyDown({
|
||||
key: 'Enter',
|
||||
preventDefault: () => undefined,
|
||||
stopPropagation: () => undefined,
|
||||
});
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
assert.equal(attempts, 2);
|
||||
} finally {
|
||||
await act(async () => {
|
||||
renderer?.unmount();
|
||||
});
|
||||
actEnvironment.IS_REACT_ACT_ENVIRONMENT = previousActEnvironment;
|
||||
}
|
||||
});
|
||||
@@ -4,7 +4,7 @@ import { cn } from '../../lib/utils';
|
||||
|
||||
type HostTreeGroupInlineRenameInputProps = {
|
||||
initialName: string;
|
||||
onCommit: (name: string) => void;
|
||||
onCommit: (name: string) => boolean | void | Promise<boolean | void>;
|
||||
onCancel: () => void;
|
||||
className?: string;
|
||||
style?: React.CSSProperties;
|
||||
@@ -19,7 +19,7 @@ export const HostTreeGroupInlineRenameInput: React.FC<HostTreeGroupInlineRenameI
|
||||
}) => {
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const [value, setValue] = useState(initialName);
|
||||
const committedRef = useRef(false);
|
||||
const submittingRef = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
const input = inputRef.current;
|
||||
@@ -28,15 +28,20 @@ export const HostTreeGroupInlineRenameInput: React.FC<HostTreeGroupInlineRenameI
|
||||
input.select();
|
||||
}, []);
|
||||
|
||||
const commit = () => {
|
||||
if (committedRef.current) return;
|
||||
committedRef.current = true;
|
||||
onCommit(value);
|
||||
const commit = async () => {
|
||||
if (submittingRef.current) return;
|
||||
submittingRef.current = true;
|
||||
try {
|
||||
const committed = await onCommit(value);
|
||||
if (committed === false) submittingRef.current = false;
|
||||
} catch {
|
||||
submittingRef.current = false;
|
||||
}
|
||||
};
|
||||
|
||||
const cancel = () => {
|
||||
if (committedRef.current) return;
|
||||
committedRef.current = true;
|
||||
if (submittingRef.current) return;
|
||||
submittingRef.current = true;
|
||||
onCancel();
|
||||
};
|
||||
|
||||
@@ -49,7 +54,7 @@ export const HostTreeGroupInlineRenameInput: React.FC<HostTreeGroupInlineRenameI
|
||||
onChange={(event) => setValue(event.target.value)}
|
||||
onBlur={() => {
|
||||
queueMicrotask(() => {
|
||||
commit();
|
||||
void commit();
|
||||
});
|
||||
}}
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
@@ -64,7 +69,7 @@ export const HostTreeGroupInlineRenameInput: React.FC<HostTreeGroupInlineRenameI
|
||||
event.stopPropagation();
|
||||
if (event.key === 'Enter') {
|
||||
event.preventDefault();
|
||||
commit();
|
||||
void commit();
|
||||
}
|
||||
if (event.key === 'Escape') {
|
||||
event.preventDefault();
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
} from '@/domain/snippetScript.ts';
|
||||
import {
|
||||
getRunnableHostsForSnippet,
|
||||
resolveSnippetTargetGroupsForSave,
|
||||
} from '@/domain/snippetTargets.ts';
|
||||
import {
|
||||
removeHostConnectScript,
|
||||
@@ -54,11 +55,13 @@ export const QuickScriptEditorDialog: React.FC<QuickScriptEditorDialogProps> = (
|
||||
const [open, setOpen] = useState(false);
|
||||
const [editingSnippet, setEditingSnippet] = useState<Partial<Snippet>>(createBlankScript);
|
||||
const [targetSelection, setTargetSelection] = useState<string[]>([]);
|
||||
const [targetGroupSelection, setTargetGroupSelection] = useState<string[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
const handler = () => {
|
||||
setEditingSnippet(createBlankScript());
|
||||
setTargetSelection([]);
|
||||
setTargetGroupSelection([]);
|
||||
setOpen(true);
|
||||
};
|
||||
window.addEventListener('netcatty:scripts:add', handler);
|
||||
@@ -71,6 +74,7 @@ export const QuickScriptEditorDialog: React.FC<QuickScriptEditorDialogProps> = (
|
||||
if (!snippet || !isScriptSnippet(snippet)) return;
|
||||
setEditingSnippet(snippet);
|
||||
setTargetSelection(snippet.targetsAllHosts ? [] : (snippet.targets ?? []));
|
||||
setTargetGroupSelection(snippet.targetsAllHosts ? [] : (snippet.targetGroups ?? []));
|
||||
setOpen(true);
|
||||
};
|
||||
window.addEventListener('netcatty:snippets:edit', handler);
|
||||
@@ -112,6 +116,7 @@ export const QuickScriptEditorDialog: React.FC<QuickScriptEditorDialogProps> = (
|
||||
if (detail.editAfterSave) {
|
||||
setEditingSnippet(snippet);
|
||||
setTargetSelection([]);
|
||||
setTargetGroupSelection([]);
|
||||
setOpen(true);
|
||||
}
|
||||
};
|
||||
@@ -132,8 +137,12 @@ export const QuickScriptEditorDialog: React.FC<QuickScriptEditorDialogProps> = (
|
||||
const runnableSnippet = useMemo(() => ({
|
||||
...(editingSnippet as Snippet),
|
||||
targets: editingSnippet.targetsAllHosts ? [] : targetSelection,
|
||||
targetGroups: resolveSnippetTargetGroupsForSave(
|
||||
editingSnippet,
|
||||
targetGroupSelection,
|
||||
),
|
||||
targetsAllHosts: editingSnippet.targetsAllHosts || undefined,
|
||||
}), [editingSnippet, targetSelection]);
|
||||
}), [editingSnippet, targetGroupSelection, targetSelection]);
|
||||
|
||||
const runTargets = useMemo(
|
||||
() => getRunnableHostsForSnippet(runnableSnippet, hosts),
|
||||
@@ -174,6 +183,10 @@ export const QuickScriptEditorDialog: React.FC<QuickScriptEditorDialogProps> = (
|
||||
tags: editingSnippet.tags ?? [],
|
||||
package: packagePath,
|
||||
targets: editingSnippet.targetsAllHosts ? [] : targetSelection,
|
||||
targetGroups: resolveSnippetTargetGroupsForSave(
|
||||
editingSnippet,
|
||||
targetGroupSelection,
|
||||
),
|
||||
targetsAllHosts: editingSnippet.targetsAllHosts || undefined,
|
||||
kind: 'script',
|
||||
language: editingSnippet.language ?? 'javascript',
|
||||
@@ -182,7 +195,7 @@ export const QuickScriptEditorDialog: React.FC<QuickScriptEditorDialogProps> = (
|
||||
triggerPattern: editingSnippet.triggerPattern,
|
||||
order: editingSnippet.order,
|
||||
};
|
||||
}, [editingSnippet, onCreatePackage, packages, targetSelection]);
|
||||
}, [editingSnippet, onCreatePackage, packages, targetGroupSelection, targetSelection]);
|
||||
|
||||
const persistSnippet = useCallback((): Snippet | null => {
|
||||
const savedSnippet = buildSavedSnippet();
|
||||
@@ -238,10 +251,12 @@ export const QuickScriptEditorDialog: React.FC<QuickScriptEditorDialogProps> = (
|
||||
const handleTargetsAllHostsChange = useCallback((checked: boolean) => {
|
||||
if (checked) {
|
||||
setTargetSelection([]);
|
||||
setTargetGroupSelection([]);
|
||||
setEditingSnippet((prev) => ({
|
||||
...prev,
|
||||
targetsAllHosts: true,
|
||||
targets: [],
|
||||
targetGroups: undefined,
|
||||
}));
|
||||
return;
|
||||
}
|
||||
@@ -266,6 +281,8 @@ export const QuickScriptEditorDialog: React.FC<QuickScriptEditorDialogProps> = (
|
||||
selectedHostIds={targetSelection}
|
||||
onSelectHost={handleSelectHost}
|
||||
onSelectionChange={handleSelectionChange}
|
||||
selectedGroupPaths={targetGroupSelection}
|
||||
onGroupSelectionChange={setTargetGroupSelection}
|
||||
targetsAllHosts={Boolean(editingSnippet.targetsAllHosts)}
|
||||
onTargetsAllHostsChange={handleTargetsAllHostsChange}
|
||||
/>
|
||||
|
||||
@@ -5,9 +5,11 @@ import type { Host, Snippet } from '@/domain/models';
|
||||
import { DEFAULT_SCRIPT_TEMPLATE } from '@/domain/snippetScript.ts';
|
||||
import { scheduleWindowInputFocus } from '@/application/state/windowInputFocus';
|
||||
import { SelectHostDialog } from '@/components/SelectHostDialog';
|
||||
import { SelectGroupDialog } from '@/components/SelectGroupDialog';
|
||||
import { ScriptCodeEditor } from './ScriptCodeEditor';
|
||||
import { ScriptMetaFields } from './ScriptMetaFields';
|
||||
import { SnippetTargetsSection } from '@/components/snippets/SnippetTargetsSection';
|
||||
import { resolveSnippetTargetGroupsForSave } from '@/domain/snippetTargets.ts';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Dialog, DialogContent, DialogTitle } from '@/components/ui/dialog';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
|
||||
@@ -26,6 +28,8 @@ export interface ScriptEditorModalProps {
|
||||
selectedHostIds: string[];
|
||||
onSelectHost: (host: Host) => void;
|
||||
onSelectionChange?: (selectedHostIds: string[]) => void;
|
||||
selectedGroupPaths?: string[];
|
||||
onGroupSelectionChange?: (selectedGroupPaths: string[]) => void;
|
||||
targetsAllHosts?: boolean;
|
||||
onTargetsAllHostsChange?: (checked: boolean) => void;
|
||||
}
|
||||
@@ -53,11 +57,14 @@ export const ScriptEditorModal: React.FC<ScriptEditorModalProps> = ({
|
||||
selectedHostIds,
|
||||
onSelectHost,
|
||||
onSelectionChange,
|
||||
selectedGroupPaths = [],
|
||||
onGroupSelectionChange,
|
||||
targetsAllHosts = false,
|
||||
onTargetsAllHostsChange,
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
const [targetPickerOpen, setTargetPickerOpen] = useState(false);
|
||||
const [groupPickerOpen, setGroupPickerOpen] = useState(false);
|
||||
|
||||
const handleOpenChange = useCallback((isOpen: boolean) => {
|
||||
if (!isOpen) {
|
||||
@@ -81,9 +88,10 @@ export const ScriptEditorModal: React.FC<ScriptEditorModalProps> = ({
|
||||
onChange({
|
||||
...snippet,
|
||||
targets: selectedHostIds,
|
||||
targetGroups: resolveSnippetTargetGroupsForSave(snippet, selectedGroupPaths),
|
||||
targetsAllHosts: undefined,
|
||||
});
|
||||
}, [onChange, selectedHostIds, snippet]);
|
||||
}, [onChange, selectedGroupPaths, selectedHostIds, snippet]);
|
||||
|
||||
const language = 'javascript';
|
||||
const editorValue = snippet.command || DEFAULT_SCRIPT_TEMPLATE;
|
||||
@@ -146,9 +154,13 @@ export const ScriptEditorModal: React.FC<ScriptEditorModalProps> = ({
|
||||
variant="embedded"
|
||||
t={t}
|
||||
targetHosts={targetHosts}
|
||||
targetGroups={selectedGroupPaths}
|
||||
onEditTargets={() => {
|
||||
if (!targetsAllHosts) setTargetPickerOpen(true);
|
||||
}}
|
||||
onEditGroups={onGroupSelectionChange ? () => {
|
||||
if (!targetsAllHosts) setGroupPickerOpen(true);
|
||||
} : undefined}
|
||||
hint={t('scripts.targets.hint')}
|
||||
targetsAllHosts={targetsAllHosts}
|
||||
onTargetsAllHostsChange={onTargetsAllHostsChange}
|
||||
@@ -192,6 +204,21 @@ export const ScriptEditorModal: React.FC<ScriptEditorModalProps> = ({
|
||||
onSelectionChange={onSelectionChange}
|
||||
onConfirm={handleTargetsConfirm}
|
||||
/>
|
||||
<SelectGroupDialog
|
||||
open={groupPickerOpen}
|
||||
onOpenChange={setGroupPickerOpen}
|
||||
hosts={hosts}
|
||||
customGroups={customGroups}
|
||||
selectedGroupPaths={selectedGroupPaths}
|
||||
onSelectionChange={(nextGroups) => {
|
||||
onGroupSelectionChange?.(nextGroups);
|
||||
onChange({
|
||||
...snippet,
|
||||
targetGroups: nextGroups,
|
||||
targetsAllHosts: undefined,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -98,6 +98,8 @@ interface SettingsSystemTabProps {
|
||||
setRestorePreviousSession: (enabled: boolean) => void;
|
||||
restoreTerminalCwd: boolean;
|
||||
setRestoreTerminalCwd: (enabled: boolean) => void;
|
||||
startupLanding: "vault" | "local-terminal";
|
||||
setStartupLanding: (landing: "vault" | "local-terminal") => void;
|
||||
toggleWindowHotkey: string;
|
||||
setToggleWindowHotkey: (hotkey: string) => void;
|
||||
closeToTray: boolean;
|
||||
@@ -139,6 +141,8 @@ const SettingsSystemTab: React.FC<SettingsSystemTabProps> = ({
|
||||
setRestorePreviousSession,
|
||||
restoreTerminalCwd,
|
||||
setRestoreTerminalCwd,
|
||||
startupLanding,
|
||||
setStartupLanding,
|
||||
toggleWindowHotkey,
|
||||
setToggleWindowHotkey,
|
||||
closeToTray,
|
||||
@@ -918,6 +922,24 @@ const SettingsSystemTab: React.FC<SettingsSystemTabProps> = ({
|
||||
|
||||
<SectionHeader title={t("settings.sessionRestore.title")} />
|
||||
<SettingCard className="space-y-4 py-4">
|
||||
<SettingRow
|
||||
anchorId="system-startup-landing"
|
||||
label={t("settings.sessionRestore.startupLanding")}
|
||||
description={t("settings.sessionRestore.startupLandingDesc")}
|
||||
>
|
||||
<Select
|
||||
value={startupLanding}
|
||||
onChange={(value) => {
|
||||
if (value === "vault" || value === "local-terminal") {
|
||||
setStartupLanding(value);
|
||||
}
|
||||
}}
|
||||
options={[
|
||||
{ value: "vault", label: t("settings.sessionRestore.startupLanding.vault") },
|
||||
{ value: "local-terminal", label: t("settings.sessionRestore.startupLanding.localTerminal") },
|
||||
]}
|
||||
/>
|
||||
</SettingRow>
|
||||
<SettingRow
|
||||
anchorId="system-session-restore"
|
||||
label={t("settings.sessionRestore.restorePreviousSession")}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { Check, ChevronDown, RefreshCw } from "lucide-react";
|
||||
import type { AIProviderId, ProviderStyle } from "../../../../infrastructure/ai/types";
|
||||
import { resolveProviderStyle } from "../../../../infrastructure/ai/types";
|
||||
import { buildModelDiscoveryHeaders, resolveModelsDiscoveryEndpoint } from "../../../../infrastructure/ai/modelDiscoveryHeaders";
|
||||
import { buildProviderProbeUrl } from "../../../../infrastructure/ai/providerConnectionProbe";
|
||||
import { useI18n } from "../../../../application/i18n/I18nProvider";
|
||||
import { Button } from "../../../ui/button";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "../../../ui/tooltip";
|
||||
@@ -150,7 +151,7 @@ export const ModelSelector: React.FC<{
|
||||
if (bridge.aiAllowlistAddHost && baseURL) {
|
||||
await bridge.aiAllowlistAddHost(baseURL);
|
||||
}
|
||||
const url = `${baseURL.replace(/\/+$/, "")}${effectiveModelsEndpoint}`;
|
||||
const url = buildProviderProbeUrl(baseURL, effectiveModelsEndpoint);
|
||||
const headers = buildModelDiscoveryHeaders(resolvedStyle, apiKey);
|
||||
const result = await bridge.aiFetch(url, "GET", headers, undefined, undefined, undefined, undefined, skipTLSVerify);
|
||||
if (!result.ok) {
|
||||
|
||||
@@ -509,6 +509,9 @@ export const ProviderConfigForm: React.FC<{
|
||||
placeholder={preset?.defaultBaseURL || "https://"}
|
||||
className="w-full h-8 rounded-md border border-input bg-background px-3 text-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
||||
/>
|
||||
{resolvedStyle === "anthropic" ? (
|
||||
<p className="text-[11px] text-muted-foreground/70">{t('ai.providers.baseUrl.anthropicHelp')}</p>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{/* Default Model */}
|
||||
|
||||
@@ -50,7 +50,7 @@ test("sftp host picker uses single-line quick switcher row layout", () => {
|
||||
assert.doesNotMatch(source, /text-xs text-muted-foreground truncate/);
|
||||
});
|
||||
|
||||
test("sftp host picker omits sourceSessionId for sudo connected hosts", () => {
|
||||
test("sftp host picker passes sourceSessionId for sudo connected hosts", () => {
|
||||
assert.match(source, /sftpSourceSessionIdForHost/);
|
||||
assert.match(
|
||||
source,
|
||||
@@ -60,11 +60,6 @@ test("sftp host picker omits sourceSessionId for sudo connected hosts", () => {
|
||||
source,
|
||||
/onSelectHost\(\s*item\.entry\.host,\s*sourceSessionId \? \{ sourceSessionId \} : undefined,\s*\)/,
|
||||
);
|
||||
// Must not always pass sessionId as a reuse hint (sudo would hide the spinner).
|
||||
assert.doesNotMatch(
|
||||
source,
|
||||
/onSelectHost\(item\.entry\.host, \{ sourceSessionId: item\.entry\.sessionId \}\)/,
|
||||
);
|
||||
});
|
||||
|
||||
test("sftp host picker virtualizes the host list", () => {
|
||||
|
||||
@@ -4,6 +4,7 @@ import type { SftpPane } from "../../application/state/sftp/types";
|
||||
import {
|
||||
connectionKeyMatchesHost,
|
||||
findReusableSftpSidePanelTab,
|
||||
isPendingSameEndpointSshSession,
|
||||
isRemoteSftpTabHealthy,
|
||||
shouldAcceptPendingSftpUpload,
|
||||
shouldResetSftpSidePanelSourceSession,
|
||||
@@ -77,6 +78,35 @@ test("shouldSkipSftpSidePanelAutoConnect rejects when the active tab has no endp
|
||||
);
|
||||
});
|
||||
|
||||
test("isPendingSameEndpointSshSession only waits for actively connecting SSH sessions", () => {
|
||||
const host = {
|
||||
id: "host-1",
|
||||
hostname: "server.example",
|
||||
port: 22,
|
||||
username: "root",
|
||||
};
|
||||
const baseSession = {
|
||||
hostId: "host-1",
|
||||
hostname: "server.example",
|
||||
port: 22,
|
||||
username: "root",
|
||||
protocol: "ssh",
|
||||
};
|
||||
|
||||
assert.equal(
|
||||
isPendingSameEndpointSshSession({ ...baseSession, status: "connecting" }, host),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
isPendingSameEndpointSshSession({ ...baseSession, status: "disconnected" }, host),
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
isPendingSameEndpointSshSession({ ...baseSession, status: "connected" }, host),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test("connectionKeyMatchesHost accepts host-id prefix keys", () => {
|
||||
assert.equal(connectionKeyMatchesHost("host-1:server:22:ssh::root", "host-1"), true);
|
||||
assert.equal(connectionKeyMatchesHost("host-2:server:22:ssh::root", "host-1"), false);
|
||||
|
||||
@@ -33,6 +33,36 @@ export function shouldSkipSftpSidePanelAutoConnect(
|
||||
return isRemoteSftpTabHealthy(activeTab, hasBackendSession);
|
||||
}
|
||||
|
||||
export function isPendingSameEndpointSshSession(
|
||||
session: {
|
||||
hostId?: string | null;
|
||||
status?: string;
|
||||
hostname?: string | null;
|
||||
port?: number | null;
|
||||
username?: string | null;
|
||||
protocol?: string | null;
|
||||
moshEnabled?: boolean;
|
||||
etEnabled?: boolean;
|
||||
},
|
||||
host: {
|
||||
id: string;
|
||||
hostname: string;
|
||||
port?: number | null;
|
||||
username: string;
|
||||
},
|
||||
): boolean {
|
||||
return Boolean(
|
||||
session.hostId === host.id
|
||||
&& session.status === "connecting"
|
||||
&& session.hostname === host.hostname
|
||||
&& (session.port ?? 22) === (host.port ?? 22)
|
||||
&& session.username === host.username
|
||||
&& (session.protocol === "ssh" || session.protocol === undefined)
|
||||
&& !session.moshEnabled
|
||||
&& !session.etEnabled,
|
||||
);
|
||||
}
|
||||
|
||||
/** Whether a stored endpoint key still belongs to the live connection's host. */
|
||||
export function connectionKeyMatchesHost(
|
||||
connectionKey: string | null | undefined,
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import React from 'react';
|
||||
import { renderToStaticMarkup } from 'react-dom/server';
|
||||
import { SnippetTargetsSection } from './SnippetTargetsSection';
|
||||
|
||||
const t = (key: string) => ({
|
||||
'snippets.targets.title': 'Targets',
|
||||
'snippets.targets.selectHosts': 'Select hosts',
|
||||
'snippets.targets.selectGroups': 'Select groups',
|
||||
'snippets.targets.add': 'Add targets',
|
||||
'snippets.targets.allHosts': 'All hosts',
|
||||
'snippets.targets.allHostsShort': 'All hosts',
|
||||
'snippets.targets.allHostsActive': 'Every host',
|
||||
}[key] ?? key);
|
||||
|
||||
test('renders dynamic group targets separately from explicit hosts', () => {
|
||||
const markup = renderToStaticMarkup(
|
||||
<SnippetTargetsSection
|
||||
t={t}
|
||||
targetHosts={[]}
|
||||
targetGroups={['Production/Web']}
|
||||
onEditTargets={() => undefined}
|
||||
onEditGroups={() => undefined}
|
||||
/>,
|
||||
);
|
||||
assert.match(markup, /Production\/Web/);
|
||||
assert.match(markup, /Select hosts/);
|
||||
assert.match(markup, /Select groups/);
|
||||
});
|
||||
|
||||
test('all-host mode hides host and group pickers', () => {
|
||||
const markup = renderToStaticMarkup(
|
||||
<SnippetTargetsSection
|
||||
t={t}
|
||||
targetHosts={[]}
|
||||
targetGroups={['Production']}
|
||||
onEditTargets={() => undefined}
|
||||
onEditGroups={() => undefined}
|
||||
targetsAllHosts
|
||||
onTargetsAllHostsChange={() => undefined}
|
||||
/>,
|
||||
);
|
||||
assert.doesNotMatch(markup, /Select hosts/);
|
||||
assert.doesNotMatch(markup, /Select groups/);
|
||||
assert.match(markup, /Every host/);
|
||||
});
|
||||
@@ -1,3 +1,4 @@
|
||||
import { FolderTree, Server } from 'lucide-react';
|
||||
import React from 'react';
|
||||
import { DistroAvatar } from '../DistroAvatar';
|
||||
import { Button } from '@/components/ui/button';
|
||||
@@ -10,6 +11,8 @@ export interface SnippetTargetsSectionProps {
|
||||
t: (key: string, params?: Record<string, unknown>) => string;
|
||||
targetHosts: Host[];
|
||||
onEditTargets: () => void;
|
||||
targetGroups?: string[];
|
||||
onEditGroups?: () => void;
|
||||
hint?: string;
|
||||
variant?: 'card' | 'embedded';
|
||||
targetsAllHosts?: boolean;
|
||||
@@ -25,6 +28,8 @@ const TargetsBody: React.FC<{
|
||||
t: SnippetTargetsSectionProps['t'];
|
||||
targetHosts: Host[];
|
||||
onEditTargets: () => void;
|
||||
targetGroups?: string[];
|
||||
onEditGroups?: () => void;
|
||||
hint?: string;
|
||||
embedded?: boolean;
|
||||
targetsAllHosts?: boolean;
|
||||
@@ -33,6 +38,8 @@ const TargetsBody: React.FC<{
|
||||
t,
|
||||
targetHosts,
|
||||
onEditTargets,
|
||||
targetGroups = [],
|
||||
onEditGroups,
|
||||
hint,
|
||||
embedded = false,
|
||||
targetsAllHosts = false,
|
||||
@@ -50,14 +57,28 @@ const TargetsBody: React.FC<{
|
||||
|
||||
<div className="flex items-center gap-1 min-w-0">
|
||||
{!targetsAllHosts ? (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className={cn(actionButtonClass(embedded), 'text-primary')}
|
||||
onClick={onEditTargets}
|
||||
>
|
||||
{targetHosts.length > 0 ? t('action.edit') : t('snippets.targets.add')}
|
||||
</Button>
|
||||
<>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className={cn(actionButtonClass(embedded), 'text-primary gap-1')}
|
||||
onClick={onEditTargets}
|
||||
>
|
||||
<Server size={12} />
|
||||
{t('snippets.targets.selectHosts')}
|
||||
</Button>
|
||||
{onEditGroups ? (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className={cn(actionButtonClass(embedded), 'text-primary gap-1')}
|
||||
onClick={onEditGroups}
|
||||
>
|
||||
<FolderTree size={12} />
|
||||
{t('snippets.targets.selectGroups')}
|
||||
</Button>
|
||||
) : null}
|
||||
</>
|
||||
) : null}
|
||||
|
||||
{onTargetsAllHostsChange ? (
|
||||
@@ -91,7 +112,7 @@ const TargetsBody: React.FC<{
|
||||
>
|
||||
{t('snippets.targets.allHostsActive')}
|
||||
</p>
|
||||
) : targetHosts.length === 0 ? (
|
||||
) : targetHosts.length === 0 && targetGroups.length === 0 ? (
|
||||
embedded ? null : (
|
||||
<Button
|
||||
variant="secondary"
|
||||
@@ -103,6 +124,18 @@ const TargetsBody: React.FC<{
|
||||
)
|
||||
) : (
|
||||
<div className={cn('gap-2', embedded ? 'flex flex-wrap' : 'space-y-2')}>
|
||||
{targetGroups.map((groupPath) => (
|
||||
<div
|
||||
key={`group:${groupPath}`}
|
||||
className={cn(
|
||||
'flex items-center gap-2 rounded-lg border border-border/60 bg-primary/5 max-w-full',
|
||||
embedded ? 'px-2 py-1.5 text-xs' : 'px-3 py-2 text-sm',
|
||||
)}
|
||||
>
|
||||
<FolderTree size={14} className="text-primary shrink-0" />
|
||||
<span className="truncate font-medium">{groupPath}</span>
|
||||
</div>
|
||||
))}
|
||||
{targetHosts.map((host) => (
|
||||
embedded ? (
|
||||
<div
|
||||
@@ -133,6 +166,8 @@ export const SnippetTargetsSection: React.FC<SnippetTargetsSectionProps> = ({
|
||||
t,
|
||||
targetHosts,
|
||||
onEditTargets,
|
||||
targetGroups,
|
||||
onEditGroups,
|
||||
hint,
|
||||
variant = 'card',
|
||||
targetsAllHosts,
|
||||
@@ -143,6 +178,8 @@ export const SnippetTargetsSection: React.FC<SnippetTargetsSectionProps> = ({
|
||||
t={t}
|
||||
targetHosts={targetHosts}
|
||||
onEditTargets={onEditTargets}
|
||||
targetGroups={targetGroups}
|
||||
onEditGroups={onEditGroups}
|
||||
hint={hint}
|
||||
embedded={variant === 'embedded'}
|
||||
targetsAllHosts={targetsAllHosts}
|
||||
|
||||
@@ -21,6 +21,7 @@ interface TerminalAutocompleteProps {
|
||||
termRef: RefObject<XTerm | null>;
|
||||
sessionId: string;
|
||||
hostId: string;
|
||||
hostGroup?: string;
|
||||
hostOs: "linux" | "windows" | "macos";
|
||||
settings?: Partial<AutocompleteSettings>;
|
||||
protocol?: string;
|
||||
@@ -63,6 +64,7 @@ export function TerminalAutocomplete({
|
||||
termRef,
|
||||
sessionId,
|
||||
hostId,
|
||||
hostGroup,
|
||||
hostOs,
|
||||
settings,
|
||||
protocol,
|
||||
@@ -119,6 +121,7 @@ export function TerminalAutocomplete({
|
||||
...(options.cwd ? { cwd: options.cwd } : {}),
|
||||
},
|
||||
hostOs,
|
||||
hostGroup,
|
||||
cwdSource: options.cwdSource,
|
||||
snippets: options.snippets,
|
||||
maximum: options.maxResults ?? 15,
|
||||
@@ -126,12 +129,13 @@ export function TerminalAutocomplete({
|
||||
signal: options.signal,
|
||||
onLatePathSuggestions: options.onLatePathSuggestions,
|
||||
});
|
||||
}, [allowHostStyleGreaterThanPrompt, hostId, hostOs, isPluginCompletionProviderAvailable, protocol, sensitiveInputActiveRef, sessionId, settings?.historyScope, status, workspaceId]);
|
||||
}, [allowHostStyleGreaterThanPrompt, hostGroup, hostId, hostOs, isPluginCompletionProviderAvailable, protocol, sensitiveInputActiveRef, sessionId, settings?.historyScope, status, workspaceId]);
|
||||
const autocomplete = useTerminalAutocomplete({
|
||||
termRef,
|
||||
containerRef,
|
||||
sessionId,
|
||||
hostId,
|
||||
hostGroup,
|
||||
hostOs,
|
||||
settings,
|
||||
onAcceptText,
|
||||
|
||||
@@ -7,7 +7,7 @@ import { HoverCard, HoverCardContent, HoverCardTrigger } from '../ui/hover-card'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '../ui/tooltip';
|
||||
import { formatNetSpeed } from './terminalHelpers';
|
||||
import { useServerStats } from '../../application/state/useServerStats';
|
||||
import { formatDiskCapacityGb, resolveTerminalDiskSummary } from './serverStatsFormat';
|
||||
import { formatDiskCapacityRange, resolveTerminalDiskSummary } from './serverStatsFormat';
|
||||
|
||||
interface TerminalServerStatsProps {
|
||||
sessionId: string;
|
||||
@@ -263,7 +263,7 @@ export const TerminalServerStats: React.FC<TerminalServerStatsProps> = ({
|
||||
diskSummary.percent !== null && diskSummary.percent >= 80 && diskSummary.percent < 90 && "text-amber-400"
|
||||
)}>
|
||||
{diskSummary.used !== null && diskSummary.total !== null && diskSummary.percent !== null
|
||||
? `${formatDiskCapacityGb(diskSummary.used)}/${formatDiskCapacityGb(diskSummary.total)}G (${diskSummary.percent}%)`
|
||||
? `${formatDiskCapacityRange(diskSummary.used, diskSummary.total)} (${diskSummary.percent}%)`
|
||||
: diskSummary.percent !== null
|
||||
? `${diskSummary.percent}%`
|
||||
: '--'}
|
||||
@@ -295,7 +295,7 @@ export const TerminalServerStats: React.FC<TerminalServerStatsProps> = ({
|
||||
"text-[11px] font-medium whitespace-nowrap",
|
||||
disk.percent >= 90 ? "text-red-400" : disk.percent >= 80 ? "text-amber-400" : "text-emerald-400"
|
||||
)}>
|
||||
{formatDiskCapacityGb(disk.used)}/{formatDiskCapacityGb(disk.total)}G ({disk.percent}%)
|
||||
{formatDiskCapacityRange(disk.used, disk.total)} ({disk.percent}%)
|
||||
</span>
|
||||
</div>
|
||||
<div className="w-full h-1.5 bg-muted rounded-full overflow-hidden">
|
||||
|
||||
@@ -394,7 +394,7 @@ test("manual disconnect keeps the session pane for reconnect", () => {
|
||||
assert.match(body, /isBootActiveRef\.current = false/);
|
||||
assert.match(body, /setIsCancelling\(true\)/);
|
||||
assert.match(body, /updateStatus\("disconnected"\)/);
|
||||
assert.match(body, /void cleanupSession\(\)/);
|
||||
assert.match(body, /void cleanupSession\(\{ retainOwnership: true \}\)/);
|
||||
assert.match(source, /trackSessionCleanup/);
|
||||
assert.doesNotMatch(body, /onCloseSession/);
|
||||
assert.match(source, /handleDisconnect: \(attachExistingSession \|\| compactToolbar\) \? undefined : handleDisconnect/);
|
||||
|
||||
@@ -993,6 +993,7 @@ function TerminalViewInner({ ctx }: { ctx: TerminalViewContext }) {
|
||||
termRef={termRef}
|
||||
sessionId={sessionId}
|
||||
hostId={host.id}
|
||||
hostGroup={host.group}
|
||||
hostOs={autocompleteHostOs}
|
||||
settings={autocompleteSettings}
|
||||
protocol={effectiveTerminalProtocol ?? resolveEffectiveTerminalProtocol(host)}
|
||||
|
||||
@@ -242,6 +242,7 @@ export async function getCompletions(
|
||||
input: string,
|
||||
options: {
|
||||
hostId?: string;
|
||||
hostGroup?: string;
|
||||
os?: "linux" | "windows" | "macos";
|
||||
maxResults?: number;
|
||||
/** Session ID for remote path completion */
|
||||
@@ -397,7 +398,10 @@ export async function getCompletions(
|
||||
// a snippet's label collides with an existing history entry's text, the
|
||||
// score-sort + final dedup below keeps the snippet (the higher-scored one).
|
||||
if (options.snippets && options.snippets.length > 0 && ctx.wordIndex === 0) {
|
||||
for (const snippetSuggestion of getSnippetSuggestions(input, options.snippets, { hostId })) {
|
||||
for (const snippetSuggestion of getSnippetSuggestions(input, options.snippets, {
|
||||
hostId,
|
||||
hostGroup: options.hostGroup,
|
||||
})) {
|
||||
suggestions.push(snippetSuggestion);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,31 +7,37 @@
|
||||
* Snippet so the accept path can run it through the canonical executeSnippetCommand.
|
||||
*/
|
||||
import type { Snippet } from "../../../domain/models";
|
||||
import { snippetAppliesToHost } from "../../../domain/snippetTargets";
|
||||
import { matchesSearchQuery } from "../../../lib/searchMatcher";
|
||||
import type { CompletionSuggestion } from "./completionEngine";
|
||||
|
||||
const SNIPPET_BASE_SCORE = 2000; // Above history (1000+freq) per "snippet > history".
|
||||
const SNIPPET_PREFIX_BONUS = 100;
|
||||
|
||||
function snippetAvailableForAutocomplete(snippet: Snippet, hostId?: string): boolean {
|
||||
function snippetAvailableForAutocomplete(
|
||||
snippet: Snippet,
|
||||
host: { hostId?: string; hostGroup?: string },
|
||||
): boolean {
|
||||
if (snippet.targetsAllHosts) return true;
|
||||
if (snippet.targets && snippet.targets.length > 0) {
|
||||
return hostId !== undefined && snippet.targets.includes(hostId);
|
||||
}
|
||||
return true;
|
||||
const hasScopedTargets = Boolean(
|
||||
snippet.targets?.length || snippet.targetGroups !== undefined,
|
||||
);
|
||||
if (!hasScopedTargets) return true;
|
||||
if (!host.hostId) return false;
|
||||
return snippetAppliesToHost(snippet, { id: host.hostId, group: host.hostGroup });
|
||||
}
|
||||
|
||||
export function getSnippetSuggestions(
|
||||
input: string,
|
||||
snippets: Snippet[],
|
||||
options: { hostId?: string } = {},
|
||||
options: { hostId?: string; hostGroup?: string } = {},
|
||||
): CompletionSuggestion[] {
|
||||
const needle = input.trim().toLowerCase();
|
||||
if (!needle || !Array.isArray(snippets)) return [];
|
||||
|
||||
const out: CompletionSuggestion[] = [];
|
||||
for (const snippet of snippets) {
|
||||
if (!snippetAvailableForAutocomplete(snippet, options.hostId)) continue;
|
||||
if (!snippetAvailableForAutocomplete(snippet, options)) continue;
|
||||
const label = (snippet.label || "").toLowerCase();
|
||||
const firstLine = (snippet.command || "").split("\n")[0].trim().toLowerCase();
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@ import type { AutocompleteHistoryScope, Snippet } from '../../../domain/models';
|
||||
export interface TerminalCompletionProviderRequest {
|
||||
input: string;
|
||||
session: NetcattyTerminalSessionSnapshot;
|
||||
hostGroup?: string;
|
||||
hostOs: 'linux' | 'windows' | 'macos';
|
||||
cwdSource?: AutocompleteCwdSource;
|
||||
snippets?: Snippet[];
|
||||
@@ -63,6 +64,7 @@ export async function provideTerminalCompletions(
|
||||
): Promise<CompletionSuggestion[]> {
|
||||
const builtInPromise = getCompletions(request.input, {
|
||||
hostId: request.session.hostId,
|
||||
hostGroup: request.hostGroup,
|
||||
os: request.hostOs,
|
||||
maxResults: request.maximum,
|
||||
sessionId: request.session.sessionId,
|
||||
|
||||
@@ -149,6 +149,7 @@ interface UseTerminalAutocompleteOptions {
|
||||
containerRef: RefObject<HTMLElement | null>;
|
||||
sessionId: string;
|
||||
hostId: string;
|
||||
hostGroup?: string;
|
||||
hostOs: "linux" | "windows" | "macos";
|
||||
settings?: Partial<AutocompleteSettings>;
|
||||
/** Callback to write text to the terminal session — replaces CustomEvent */
|
||||
@@ -200,6 +201,7 @@ export function useTerminalAutocomplete(
|
||||
containerRef,
|
||||
sessionId,
|
||||
hostId,
|
||||
hostGroup,
|
||||
hostOs,
|
||||
settings: userSettings,
|
||||
onAcceptText,
|
||||
@@ -237,6 +239,8 @@ export function useTerminalAutocomplete(
|
||||
onAcceptSnippetRef.current = onAcceptSnippet;
|
||||
const hostIdRef = useRef(hostId);
|
||||
hostIdRef.current = hostId;
|
||||
const hostGroupRef = useRef(hostGroup);
|
||||
hostGroupRef.current = hostGroup;
|
||||
const hostOsRef = useRef(hostOs);
|
||||
hostOsRef.current = hostOs;
|
||||
const sessionIdRef = useRef(sessionId);
|
||||
@@ -1048,6 +1052,7 @@ export function useTerminalAutocomplete(
|
||||
try {
|
||||
completions = await provideCompletionsRef.current(input, {
|
||||
hostId: hostIdRef.current,
|
||||
hostGroup: hostGroupRef.current,
|
||||
os: hostOsRef.current,
|
||||
maxResults: settingsRef.current.maxSuggestions,
|
||||
historyScope: settingsRef.current.historyScope,
|
||||
|
||||
@@ -19,6 +19,27 @@ test("getCompletions does not surface snippets past the command position", async
|
||||
assert.equal(out.find((s) => s.source === "snippet"), undefined);
|
||||
});
|
||||
|
||||
test("getCompletions applies dynamic group targets to the current host", async () => {
|
||||
const groupedSnippet: Snippet = {
|
||||
...deploySnippet,
|
||||
targetGroups: ["Production"],
|
||||
};
|
||||
|
||||
const matching = await getCompletions("dep", {
|
||||
hostId: "host-prod",
|
||||
hostGroup: "Production/Web",
|
||||
snippets: [groupedSnippet],
|
||||
});
|
||||
const outside = await getCompletions("dep", {
|
||||
hostId: "host-dev",
|
||||
hostGroup: "Development",
|
||||
snippets: [groupedSnippet],
|
||||
});
|
||||
|
||||
assert.ok(matching.some((suggestion) => suggestion.source === "snippet"));
|
||||
assert.equal(outside.some((suggestion) => suggestion.source === "snippet"), false);
|
||||
});
|
||||
|
||||
test("getCompletions returns more than 8 snippet matches when default maxSuggestions allows it", async () => {
|
||||
const snippets: Snippet[] = Array.from({ length: 20 }, (_, i) => ({
|
||||
id: `s${i}`,
|
||||
|
||||
@@ -1156,6 +1156,10 @@ test("user scroll during Enter keeps prior highlights mounted", async () => {
|
||||
getTranslatedLineIndexes().some((lineY) => lineY >= 10 && lineY < 20),
|
||||
"scrollback browsing during Enter should synchronously scan newly revealed lines",
|
||||
);
|
||||
assert.ok(
|
||||
decorationStates.some((state) => !state.isDisposed && state.line >= 10 && state.line < 13),
|
||||
"scrollback browsing during Enter should apply highlights on newly revealed lines",
|
||||
);
|
||||
raf.flush();
|
||||
|
||||
assert.equal(
|
||||
@@ -1417,7 +1421,9 @@ test("Enter dirty work continues after a queued full refresh", async () => {
|
||||
handlers.data?.("\r");
|
||||
setLineText(22, "redrawn without a keyword");
|
||||
handlers.writeParsed?.();
|
||||
await new Promise((resolve) => { setTimeout(resolve, 340); });
|
||||
// Idle Enter suppresses decoration mutation until the Enter guard clears
|
||||
// (~600ms) so prompt redraw cannot flash still-visible keywords.
|
||||
await new Promise((resolve) => { setTimeout(resolve, 850); });
|
||||
raf.flush();
|
||||
|
||||
const originalDisposed = originalDecoration.isDisposed;
|
||||
@@ -1587,15 +1593,99 @@ test("continuous Enter output still refreshes highlights periodically", async ()
|
||||
|
||||
handlers.data?.("\r");
|
||||
setLineText(22, "DEPLOY");
|
||||
for (let index = 0; index < 20; index += 1) {
|
||||
handlers.writeParsed?.();
|
||||
raf.flush();
|
||||
await new Promise((resolve) => { setTimeout(resolve, 50); });
|
||||
const internals = highlighter as unknown as {
|
||||
lastRefreshTime: number;
|
||||
lastUserInputAt: number;
|
||||
};
|
||||
// Sustained output must be a write burst, not spaced callbacks that look
|
||||
// like a multi-batch prompt redraw.
|
||||
internals.lastUserInputAt = Number.NEGATIVE_INFINITY;
|
||||
const originalPerformance = globalThis.performance;
|
||||
let simulatedNow = 0;
|
||||
Object.defineProperty(globalThis, "performance", {
|
||||
configurable: true,
|
||||
value: {
|
||||
now: () => simulatedNow,
|
||||
},
|
||||
});
|
||||
try {
|
||||
for (let index = 0; index < 12; index += 1) {
|
||||
simulatedNow += 10;
|
||||
internals.lastRefreshTime = Number.NEGATIVE_INFINITY;
|
||||
handlers.writeParsed?.();
|
||||
raf.flush();
|
||||
}
|
||||
} finally {
|
||||
Object.defineProperty(globalThis, "performance", {
|
||||
configurable: true,
|
||||
value: originalPerformance,
|
||||
});
|
||||
}
|
||||
|
||||
assert.ok(
|
||||
decorationStates.some(({ isDisposed }) => !isDisposed),
|
||||
"ongoing output should not postpone new highlights until the stream stops",
|
||||
"ongoing write-burst output should not postpone new highlights until the stream stops",
|
||||
);
|
||||
highlighter.dispose();
|
||||
} finally {
|
||||
raf.restore();
|
||||
}
|
||||
});
|
||||
|
||||
test("slow post-Enter output still applies highlights after the Enter guard", () => {
|
||||
const raf = installAnimationFrameQueue();
|
||||
try {
|
||||
const { term, decorationStates, handlers, setLineText } = createFakeTerminal("no match", {
|
||||
lineCount: 40,
|
||||
});
|
||||
term.buffer.active.viewportY = 20;
|
||||
term.buffer.active.baseY = 20;
|
||||
term.buffer.active.cursorY = 2;
|
||||
const highlighter = new KeywordHighlighter(term as never);
|
||||
highlighter.setRules([{
|
||||
id: "deploy",
|
||||
label: "Deploy",
|
||||
patterns: ["DEPLOY"],
|
||||
color: "#F87171",
|
||||
enabled: true,
|
||||
}], true);
|
||||
raf.flush();
|
||||
assert.equal(decorationStates.filter(({ isDisposed }) => !isDisposed).length, 0);
|
||||
|
||||
const internals = highlighter as unknown as {
|
||||
lastRefreshTime: number;
|
||||
lastUserInputAt: number;
|
||||
};
|
||||
const originalPerformance = globalThis.performance;
|
||||
let simulatedNow = 1_000;
|
||||
Object.defineProperty(globalThis, "performance", {
|
||||
configurable: true,
|
||||
value: {
|
||||
now: () => simulatedNow,
|
||||
},
|
||||
});
|
||||
try {
|
||||
handlers.data?.("\r");
|
||||
setLineText(22, "DEPLOY");
|
||||
internals.lastUserInputAt = Number.NEGATIVE_INFINITY;
|
||||
// Intervals above WRITE_BURST_INTERVAL_MS never reach the burst
|
||||
// threshold, and each write rearms the real idle timer.
|
||||
for (let index = 0; index < 10; index += 1) {
|
||||
simulatedNow += 80;
|
||||
internals.lastRefreshTime = Number.NEGATIVE_INFINITY;
|
||||
handlers.writeParsed?.();
|
||||
raf.flush();
|
||||
}
|
||||
} finally {
|
||||
Object.defineProperty(globalThis, "performance", {
|
||||
configurable: true,
|
||||
value: originalPerformance,
|
||||
});
|
||||
}
|
||||
|
||||
assert.ok(
|
||||
decorationStates.some(({ isDisposed }) => !isDisposed),
|
||||
"steady non-bursty output should highlight after the Enter window, not wait for a pause",
|
||||
);
|
||||
highlighter.dispose();
|
||||
} finally {
|
||||
@@ -1923,7 +2013,9 @@ test("Enter input still detects redraws away from the cursor", async () => {
|
||||
handlers.data?.("\r");
|
||||
setLineText(20, "redrawn without a keyword");
|
||||
handlers.writeParsed?.();
|
||||
await new Promise((resolve) => { setTimeout(resolve, 220); });
|
||||
// Idle Enter defers decoration dispose/apply until the Enter guard clears.
|
||||
await new Promise((resolve) => { setTimeout(resolve, 850); });
|
||||
raf.flush();
|
||||
|
||||
assert.equal(originalDecoration.isDisposed, true);
|
||||
highlighter.dispose();
|
||||
@@ -2245,6 +2337,301 @@ test("idle Enter scroll before writeParsed does not rescan visible keywords", ()
|
||||
}
|
||||
});
|
||||
|
||||
test("idle Enter scroll before buffer dims update does not rescan", () => {
|
||||
const raf = installAnimationFrameQueue();
|
||||
try {
|
||||
const {
|
||||
term,
|
||||
decorationStates,
|
||||
handlers,
|
||||
getTranslateCount,
|
||||
resetTranslateCount,
|
||||
refreshCalls,
|
||||
resetRefreshCalls,
|
||||
} = createFakeTerminal("hello DEPLOY world", { lineCount: 40 });
|
||||
term.buffer.active.viewportY = 20;
|
||||
term.buffer.active.baseY = 20;
|
||||
term.buffer.active.cursorY = 2;
|
||||
const highlighter = new KeywordHighlighter(term as never);
|
||||
highlighter.setRules([{
|
||||
id: "deploy",
|
||||
label: "Deploy",
|
||||
patterns: ["DEPLOY"],
|
||||
color: "#F87171",
|
||||
enabled: true,
|
||||
}], true);
|
||||
raf.flush();
|
||||
const existingDecorations = [...decorationStates];
|
||||
assert.ok(existingDecorations.length > 0);
|
||||
|
||||
const internals = highlighter as unknown as {
|
||||
lastWriteAt: number;
|
||||
lastRenderRange: { start: number; end: number } | null;
|
||||
};
|
||||
internals.lastWriteAt = performance.now() - 10_000;
|
||||
internals.lastRenderRange = null;
|
||||
resetTranslateCount();
|
||||
resetRefreshCalls();
|
||||
|
||||
// Ubuntu RTT: onScroll can fire while length/baseY/cursor still match the
|
||||
// last snapshot, so output-driven detection is false. Bottom-pinned Enter
|
||||
// must still defer — requiring hasOutputDrivenViewportChange reopens flash.
|
||||
handlers.data?.("\r");
|
||||
handlers.scroll?.();
|
||||
|
||||
assert.equal(
|
||||
getTranslateCount(),
|
||||
0,
|
||||
"Enter-pending bottom scroll without buffer-dim change must not rescan",
|
||||
);
|
||||
assert.deepEqual(
|
||||
refreshCalls,
|
||||
[],
|
||||
"Enter-pending bottom scroll without buffer-dim change must not repaint",
|
||||
);
|
||||
assert.equal(
|
||||
existingDecorations.filter(({ isDisposed }) => isDisposed).length,
|
||||
0,
|
||||
"Enter-pending bottom scroll must keep existing keyword decorations mounted",
|
||||
);
|
||||
highlighter.dispose();
|
||||
} finally {
|
||||
raf.restore();
|
||||
}
|
||||
});
|
||||
|
||||
test("idle Enter prompt redraw does not repaint existing keyword rows", async () => {
|
||||
const raf = installAnimationFrameQueue();
|
||||
try {
|
||||
const {
|
||||
term,
|
||||
decorationStates,
|
||||
handlers,
|
||||
setLineText,
|
||||
refreshCalls,
|
||||
resetRefreshCalls,
|
||||
} = createFakeTerminal("hello DEPLOY world", { lineCount: 40 });
|
||||
term.buffer.active.viewportY = 20;
|
||||
term.buffer.active.baseY = 20;
|
||||
term.buffer.active.cursorY = 2;
|
||||
const highlighter = new KeywordHighlighter(term as never);
|
||||
highlighter.setRules([
|
||||
{
|
||||
id: "deploy",
|
||||
label: "Deploy",
|
||||
patterns: ["DEPLOY"],
|
||||
color: "#F87171",
|
||||
enabled: true,
|
||||
},
|
||||
{
|
||||
id: "prompt",
|
||||
label: "Prompt",
|
||||
patterns: ["~", "#"],
|
||||
color: "#60A5FA",
|
||||
enabled: true,
|
||||
},
|
||||
], true);
|
||||
raf.flush();
|
||||
const existingDecorations = [...decorationStates];
|
||||
assert.ok(existingDecorations.length > 0);
|
||||
resetRefreshCalls();
|
||||
|
||||
handlers.data?.("\r");
|
||||
term.buffer.active.viewportY += 1;
|
||||
term.buffer.active.baseY += 1;
|
||||
term.buffer.active.length += 1;
|
||||
// New prompt line matches custom ~/# rules — applying those decorations
|
||||
// makes xterm repaint the full viewport and flashes still-visible keywords.
|
||||
setLineText(22, "user@host:~# ");
|
||||
handlers.scroll?.();
|
||||
handlers.writeParsed?.();
|
||||
await new Promise((resolve) => { setTimeout(resolve, 220); });
|
||||
raf.flush();
|
||||
|
||||
assert.equal(
|
||||
existingDecorations.filter(({ isDisposed }) => isDisposed).length,
|
||||
0,
|
||||
"idle Enter must keep prior keyword decorations mounted",
|
||||
);
|
||||
assert.deepEqual(
|
||||
refreshCalls,
|
||||
[],
|
||||
"idle Enter prompt redraw must not register decorations that force a viewport repaint",
|
||||
);
|
||||
highlighter.dispose();
|
||||
} finally {
|
||||
raf.restore();
|
||||
}
|
||||
});
|
||||
|
||||
test("idle Enter keeps suppression across split echo and prompt writes", async () => {
|
||||
const raf = installAnimationFrameQueue();
|
||||
try {
|
||||
const {
|
||||
term,
|
||||
decorationStates,
|
||||
handlers,
|
||||
setLineText,
|
||||
refreshCalls,
|
||||
resetRefreshCalls,
|
||||
} = createFakeTerminal("hello DEPLOY world", { lineCount: 40 });
|
||||
term.buffer.active.viewportY = 20;
|
||||
term.buffer.active.baseY = 20;
|
||||
term.buffer.active.cursorY = 2;
|
||||
const highlighter = new KeywordHighlighter(term as never);
|
||||
highlighter.setRules([
|
||||
{
|
||||
id: "deploy",
|
||||
label: "Deploy",
|
||||
patterns: ["DEPLOY"],
|
||||
color: "#F87171",
|
||||
enabled: true,
|
||||
},
|
||||
{
|
||||
id: "prompt",
|
||||
label: "Prompt",
|
||||
patterns: ["~", "#"],
|
||||
color: "#60A5FA",
|
||||
enabled: true,
|
||||
},
|
||||
], true);
|
||||
raf.flush();
|
||||
const existingDecorations = [...decorationStates];
|
||||
assert.ok(existingDecorations.length > 0);
|
||||
resetRefreshCalls();
|
||||
|
||||
handlers.data?.("\r");
|
||||
// Batch 1: newline echo advances the buffer.
|
||||
term.buffer.active.viewportY += 1;
|
||||
term.buffer.active.baseY += 1;
|
||||
term.buffer.active.length += 1;
|
||||
handlers.scroll?.();
|
||||
handlers.writeParsed?.();
|
||||
await new Promise((resolve) => { setTimeout(resolve, 40); });
|
||||
raf.flush();
|
||||
|
||||
// Batch 2: prompt text for the same idle Enter (custom ~/# matches).
|
||||
setLineText(22, "user@host:~# ");
|
||||
handlers.writeParsed?.();
|
||||
await new Promise((resolve) => { setTimeout(resolve, 40); });
|
||||
raf.flush();
|
||||
|
||||
// Batch 3: trailing mode/control write — still the same prompt redraw,
|
||||
// not sustained command output.
|
||||
handlers.writeParsed?.();
|
||||
await new Promise((resolve) => { setTimeout(resolve, 220); });
|
||||
raf.flush();
|
||||
|
||||
assert.equal(
|
||||
existingDecorations.filter(({ isDisposed }) => isDisposed).length,
|
||||
0,
|
||||
"split idle-Enter writes must keep prior keyword decorations mounted",
|
||||
);
|
||||
assert.deepEqual(
|
||||
refreshCalls,
|
||||
[],
|
||||
"a three-batch idle prompt must not register decorations that flash the viewport",
|
||||
);
|
||||
highlighter.dispose();
|
||||
} finally {
|
||||
raf.restore();
|
||||
}
|
||||
});
|
||||
|
||||
test("pre-Enter write burst does not lift idle-Enter decoration suppression", () => {
|
||||
const raf = installAnimationFrameQueue();
|
||||
try {
|
||||
const {
|
||||
term,
|
||||
decorationStates,
|
||||
handlers,
|
||||
setLineText,
|
||||
refreshCalls,
|
||||
resetRefreshCalls,
|
||||
} = createFakeTerminal("hello DEPLOY world", { lineCount: 40 });
|
||||
term.buffer.active.viewportY = 20;
|
||||
term.buffer.active.baseY = 20;
|
||||
term.buffer.active.cursorY = 2;
|
||||
const highlighter = new KeywordHighlighter(term as never);
|
||||
highlighter.setRules([
|
||||
{
|
||||
id: "deploy",
|
||||
label: "Deploy",
|
||||
patterns: ["DEPLOY"],
|
||||
color: "#F87171",
|
||||
enabled: true,
|
||||
},
|
||||
{
|
||||
id: "prompt",
|
||||
label: "Prompt",
|
||||
patterns: ["~", "#"],
|
||||
color: "#60A5FA",
|
||||
enabled: true,
|
||||
},
|
||||
], true);
|
||||
raf.flush();
|
||||
const existingDecorations = [...decorationStates];
|
||||
assert.ok(existingDecorations.length > 0);
|
||||
resetRefreshCalls();
|
||||
|
||||
const internals = highlighter as unknown as {
|
||||
recentWriteBurst: number;
|
||||
lastWriteAt: number;
|
||||
lastBurstDecayAt: number;
|
||||
};
|
||||
const originalPerformance = globalThis.performance;
|
||||
let simulatedNow = 1_000;
|
||||
Object.defineProperty(globalThis, "performance", {
|
||||
configurable: true,
|
||||
value: {
|
||||
now: () => simulatedNow,
|
||||
},
|
||||
});
|
||||
try {
|
||||
internals.recentWriteBurst = 8;
|
||||
internals.lastWriteAt = simulatedNow;
|
||||
internals.lastBurstDecayAt = simulatedNow;
|
||||
|
||||
handlers.data?.("\r");
|
||||
term.buffer.active.viewportY += 1;
|
||||
term.buffer.active.baseY += 1;
|
||||
term.buffer.active.length += 1;
|
||||
handlers.scroll?.();
|
||||
simulatedNow += 5;
|
||||
handlers.writeParsed?.();
|
||||
raf.flush();
|
||||
|
||||
setLineText(22, "user@host:~# ");
|
||||
simulatedNow += 5;
|
||||
handlers.writeParsed?.();
|
||||
raf.flush();
|
||||
|
||||
simulatedNow += 5;
|
||||
handlers.writeParsed?.();
|
||||
raf.flush();
|
||||
} finally {
|
||||
Object.defineProperty(globalThis, "performance", {
|
||||
configurable: true,
|
||||
value: originalPerformance,
|
||||
});
|
||||
}
|
||||
|
||||
assert.equal(
|
||||
existingDecorations.filter(({ isDisposed }) => isDisposed).length,
|
||||
0,
|
||||
"stale pre-Enter burst must not dispose still-visible keyword decorations",
|
||||
);
|
||||
assert.deepEqual(
|
||||
refreshCalls,
|
||||
[],
|
||||
"stale pre-Enter burst must not register prompt decorations that flash the viewport",
|
||||
);
|
||||
highlighter.dispose();
|
||||
} finally {
|
||||
raf.restore();
|
||||
}
|
||||
});
|
||||
|
||||
test("Enter without write clears pending so later user scroll can highlight", async () => {
|
||||
const raf = installAnimationFrameQueue();
|
||||
try {
|
||||
|
||||
@@ -123,6 +123,18 @@ export class KeywordHighlighter implements IDisposable {
|
||||
private enterQueuedWriteCancellationPending = false;
|
||||
private enterViewportScanInProgress = false;
|
||||
private enterViewportScanNeedsRepeat = false;
|
||||
/** True while idle-Enter output should not mutate decorations (xterm full-viewport repaint flash). */
|
||||
private enterSuppressDecorationMutation = false;
|
||||
/**
|
||||
* Whether onWriteParsed has already observed the current Enter submission.
|
||||
* Prompt redraw can span several writeParsed batches (control sequences,
|
||||
* prompt text, mode sets); callback count is not a sustained-output signal.
|
||||
*/
|
||||
private enterWriteParsedSeen = false;
|
||||
/** performance.now() when the current Enter started suppressing mutations. */
|
||||
private enterSuppressionStartedAt = 0;
|
||||
/** Viewport browsing state before the latest scroll handler ran. */
|
||||
private wasBrowsingScrollback = false;
|
||||
private static readonly DIRTY_SCAN_PADDING = XTERM_PERFORMANCE_CONFIG.highlighting.dirtyScanPadding;
|
||||
private static readonly INPUT_QUIET_MS = XTERM_PERFORMANCE_CONFIG.highlighting.inputQuietMs;
|
||||
private static readonly WRITE_BURST_INTERVAL_MS = 28;
|
||||
@@ -154,6 +166,17 @@ export class KeywordHighlighter implements IDisposable {
|
||||
if (data.includes("\r") || data.includes("\n")) {
|
||||
this.enterInputPending = true;
|
||||
this.enterQueuedWriteCancellationPending = true;
|
||||
// First prompt redraw after Enter must not register/dispose decorations:
|
||||
// xterm repaints the full viewport on decoration mutation and flashes
|
||||
// still-visible keyword highlights (custom ~/# rules make this obvious).
|
||||
this.enterSuppressDecorationMutation = true;
|
||||
this.enterWriteParsedSeen = false;
|
||||
this.enterSuppressionStartedAt = performance.now();
|
||||
// Pre-Enter burst must not lift mute on a split prompt. Keep
|
||||
// lastWriteAt so output-driven scroll detection still works before
|
||||
// the first post-Enter updateWriteBurst.
|
||||
this.recentWriteBurst = 0;
|
||||
this.lastBurstDecayAt = 0;
|
||||
// Time-bound Enter protection even when no echo/write arrives (echo
|
||||
// off, stalled PTY). onWriteParsed re-arms this on each write.
|
||||
this.scheduleEnterInputIdleClear();
|
||||
@@ -224,11 +247,25 @@ export class KeywordHighlighter implements IDisposable {
|
||||
const inputProtectionActive = this.isInputProtectionActive(performance.now());
|
||||
if (inputProtectionActive || this.enterInputPending) {
|
||||
if (this.enterInputPending) {
|
||||
if (this.enterViewportScanInProgress) {
|
||||
if (this.enterWriteParsedSeen) {
|
||||
this.updateWriteBurst();
|
||||
this.enterViewportScanNeedsRepeat = true;
|
||||
// Multi-batch prompt redraw is still not command output. Keep
|
||||
// decoration mute until a write burst or the Enter idle guard.
|
||||
if (this.shouldLiftEnterDecorationSuppression()) {
|
||||
this.enterSuppressDecorationMutation = false;
|
||||
}
|
||||
if (this.enterViewportScanInProgress) {
|
||||
this.enterViewportScanNeedsRepeat = true;
|
||||
} else {
|
||||
const buffer = this.term.buffer.active;
|
||||
this.addDirtyRange(buffer.viewportY, buffer.viewportY + this.term.rows - 1);
|
||||
this.enterViewportScanInProgress = true;
|
||||
}
|
||||
} else {
|
||||
this.enterWriteParsedSeen = true;
|
||||
this.markDirtyFromWrite({ includeViewportProbe: false });
|
||||
// Index the viewport while muted so multi-frame Enter continuation
|
||||
// can finish; decoration mutate stays suppressed for idle prompt.
|
||||
const buffer = this.term.buffer.active;
|
||||
this.addDirtyRange(buffer.viewportY, buffer.viewportY + this.term.rows - 1);
|
||||
this.enterViewportScanInProgress = true;
|
||||
@@ -266,6 +303,7 @@ export class KeywordHighlighter implements IDisposable {
|
||||
})
|
||||
);
|
||||
this.lastBufferSnapshot = this.readBufferSnapshot();
|
||||
this.wasBrowsingScrollback = this.isBrowsingScrollback();
|
||||
}
|
||||
|
||||
public setRules(rules: readonly RuntimeKeywordHighlightRule[], enabled: boolean) {
|
||||
@@ -430,6 +468,9 @@ export class KeywordHighlighter implements IDisposable {
|
||||
this.enterQueuedWriteCancellationPending = false;
|
||||
this.enterViewportScanInProgress = false;
|
||||
this.enterViewportScanNeedsRepeat = false;
|
||||
this.enterSuppressDecorationMutation = false;
|
||||
this.enterWriteParsedSeen = false;
|
||||
this.enterSuppressionStartedAt = 0;
|
||||
if (hadDecorations) {
|
||||
this.term.refresh(0, this.term.rows - 1);
|
||||
}
|
||||
@@ -715,16 +756,23 @@ export class KeywordHighlighter implements IDisposable {
|
||||
|
||||
private triggerViewportChangeRefresh() {
|
||||
const isBrowsingScrollback = this.isBrowsingScrollback();
|
||||
// End (or other jump-to-bottom) while Enter is pending: we were browsing
|
||||
// scrollback and just landed on the bottom without waiting for echo.
|
||||
// Distinguish that from idle-Enter echo still pinned at the bottom.
|
||||
const returningToBottomFromScrollback =
|
||||
this.wasBrowsingScrollback && !isBrowsingScrollback;
|
||||
this.wasBrowsingScrollback = isBrowsingScrollback;
|
||||
// Enter echo often emits onScroll before onWriteParsed. After an idle gap
|
||||
// lastWriteAt looks stale and lastRenderRange is usually null (cleared by
|
||||
// the previous write refresh), so the output-driven scroll path would
|
||||
// synchronously rescan the viewport and flash keywords still on screen.
|
||||
// Keep real scrollback browsing synchronous; only defer the bottom-pinned
|
||||
// viewport movement that can be caused by the pending Enter echo.
|
||||
// Keep real scrollback browsing synchronous; defer bottom-pinned Enter
|
||||
// echo even when buffer dims have not updated yet (Ubuntu RTT). Do not
|
||||
// require hasOutputDrivenViewportChange — that hole reopened the flash.
|
||||
if (
|
||||
this.enterInputPending
|
||||
&& !isBrowsingScrollback
|
||||
&& this.hasOutputDrivenViewportChange()
|
||||
&& !returningToBottomFromScrollback
|
||||
) {
|
||||
if (this.pendingRefreshReason === "scroll") {
|
||||
this.cancelQueuedRefreshSchedule();
|
||||
@@ -1086,6 +1134,9 @@ export class KeywordHighlighter implements IDisposable {
|
||||
this.enterInputIdleTimer = setTimeout(() => {
|
||||
this.enterInputIdleTimer = null;
|
||||
this.enterInputPending = false;
|
||||
this.enterSuppressDecorationMutation = false;
|
||||
this.enterWriteParsedSeen = false;
|
||||
this.enterSuppressionStartedAt = 0;
|
||||
// Catch up any viewport motion deferred while Enter protection blocked
|
||||
// scroll refresh (e.g. user scrolled during the post-Enter window).
|
||||
this.markVisibleRangeDirty();
|
||||
@@ -1093,6 +1144,23 @@ export class KeywordHighlighter implements IDisposable {
|
||||
}, KeywordHighlighter.ENTER_INPUT_GUARD_MS);
|
||||
}
|
||||
|
||||
/**
|
||||
* True when follow-up Enter writes look like sustained command output.
|
||||
* WriteParsed callback count alone cannot prove that — a split prompt may
|
||||
* need three (or more) batches — so a write burst lifts mute early.
|
||||
* Slow streams never burst and keep rearming the idle timer; after the
|
||||
* Enter window, prompt redraw is done and mutation is safe.
|
||||
*/
|
||||
private shouldLiftEnterDecorationSuppression(): boolean {
|
||||
if (!this.enterSuppressDecorationMutation) return false;
|
||||
const now = performance.now();
|
||||
if (this.isWriteBurstActive(now)) return true;
|
||||
return (
|
||||
this.enterSuppressionStartedAt > 0
|
||||
&& now - this.enterSuppressionStartedAt >= KeywordHighlighter.ENTER_INPUT_GUARD_MS
|
||||
);
|
||||
}
|
||||
|
||||
private isBrowsingScrollback(): boolean {
|
||||
const buffer = this.term.buffer.active;
|
||||
return buffer.viewportY < buffer.baseY;
|
||||
@@ -1516,16 +1584,25 @@ export class KeywordHighlighter implements IDisposable {
|
||||
if (end < start) return;
|
||||
const buffer = this.term.buffer.active;
|
||||
const pressure = getTerminalOutputPressure(this.term);
|
||||
// Idle Enter must not mutate prompt-area decorations (xterm full-viewport
|
||||
// flash). A real scrollback browse still needs new decorations: the scroll
|
||||
// path records lastRenderRange even when this guard skips apply.
|
||||
const allowEnterBrowseDecorations = this.enterSuppressDecorationMutation
|
||||
&& this.isBrowsingScrollback();
|
||||
for (let lineY = start; lineY <= end; lineY++) {
|
||||
const line = buffer.getLine(lineY);
|
||||
if (!line) {
|
||||
this.disposeLineDecorations(lineY);
|
||||
if (!this.enterSuppressDecorationMutation) {
|
||||
this.disposeLineDecorations(lineY);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
const lineText = line.translateToString(true); // true = trim right whitespace
|
||||
if (!lineText) {
|
||||
this.disposeLineDecorations(lineY);
|
||||
if (!this.enterSuppressDecorationMutation) {
|
||||
this.disposeLineDecorations(lineY);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -1536,7 +1613,9 @@ export class KeywordHighlighter implements IDisposable {
|
||||
: this.scanWrappedLine(buffer, lineY, line, lineText, wrappedBlockCache)
|
||||
: this.getCachedRanges(line, lineText);
|
||||
if (cachedRanges.length === 0) {
|
||||
this.disposeLineDecorations(lineY);
|
||||
if (!this.enterSuppressDecorationMutation) {
|
||||
this.disposeLineDecorations(lineY);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -1553,6 +1632,18 @@ export class KeywordHighlighter implements IDisposable {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Idle Enter: keep prior decorations mounted. Scrollback browse still
|
||||
// creates decorations on unindexed lines so matches are visible before
|
||||
// the 600ms guard; lines that already have decorations stay untouched.
|
||||
if (this.enterSuppressDecorationMutation) {
|
||||
if (!allowEnterBrowseDecorations) {
|
||||
continue;
|
||||
}
|
||||
if (existing && existing.decorations.length > 0) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
this.disposeLineDecorations(lineY, existing);
|
||||
this.applyLineDecorations(lineY, cachedRanges, signature, cursorAbsoluteY);
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import assert from "node:assert/strict";
|
||||
|
||||
import {
|
||||
formatDiskCapacityGb,
|
||||
formatDiskCapacityRange,
|
||||
resolveTerminalDiskSummary,
|
||||
} from "./serverStatsFormat.ts";
|
||||
|
||||
@@ -14,6 +15,32 @@ test("disk capacity uses at most two decimal places", () => {
|
||||
assert.equal(formatDiskCapacityGb(10.5), "10.5");
|
||||
});
|
||||
|
||||
test("disk capacity stays in G below a terabyte", () => {
|
||||
assert.equal(formatDiskCapacityRange(71.12, 878.15), "71.12/878.15G");
|
||||
assert.equal(formatDiskCapacityRange(0.01, 0.5), "0.01/0.5G");
|
||||
// 1023 GiB is still the largest value rendered in G.
|
||||
assert.equal(formatDiskCapacityRange(1, 1023), "1/1023G");
|
||||
});
|
||||
|
||||
test("disk capacity switches to T once a value reaches a terabyte", () => {
|
||||
// Both sides in terabytes: one shared suffix.
|
||||
assert.equal(formatDiskCapacityRange(8807.55, 118736.4), "8.6/115.95T");
|
||||
assert.equal(formatDiskCapacityRange(2048, 4096), "2/4T");
|
||||
});
|
||||
|
||||
test("disk capacity keeps the used figure when the sides differ in scale", () => {
|
||||
// A total-driven unit would render these as 0.13/6.93T and 0/6.93T,
|
||||
// discarding how much is actually in use.
|
||||
assert.equal(formatDiskCapacityRange(136.37, 7096.47), "136.37G/6.93T");
|
||||
assert.equal(formatDiskCapacityRange(3.25, 7096.47), "3.25G/6.93T");
|
||||
assert.equal(formatDiskCapacityRange(1, 1024), "1G/1T");
|
||||
});
|
||||
|
||||
test("disk capacity switches to P for petabyte-scale pools", () => {
|
||||
assert.equal(formatDiskCapacityRange(1_048_576, 2_097_152), "1/2P");
|
||||
assert.equal(formatDiskCapacityRange(512, 2_097_152), "512G/2P");
|
||||
});
|
||||
|
||||
test("terminal disk summary totals every mounted filesystem", () => {
|
||||
const summary = resolveTerminalDiskSummary({
|
||||
diskUsed: 9.285,
|
||||
|
||||
@@ -20,6 +20,42 @@ export function formatDiskCapacityGb(value: number): string {
|
||||
return Number(value.toFixed(2)).toString();
|
||||
}
|
||||
|
||||
/** Capacity units, ascending from the GiB the collectors report in. */
|
||||
const DISK_CAPACITY_UNITS = ["G", "T", "P"] as const;
|
||||
|
||||
/** Pick the largest unit that keeps a GiB value at or above 1. */
|
||||
export function resolveDiskCapacityUnit(valueGb: number): { unit: string; divisor: number } {
|
||||
let divisor = 1;
|
||||
let index = 0;
|
||||
while (
|
||||
Math.abs(valueGb) / divisor >= 1024
|
||||
&& index < DISK_CAPACITY_UNITS.length - 1
|
||||
) {
|
||||
divisor *= 1024;
|
||||
index += 1;
|
||||
}
|
||||
return { unit: DISK_CAPACITY_UNITS[index], divisor };
|
||||
}
|
||||
|
||||
/**
|
||||
* Render a used/total pair, both reported in GiB.
|
||||
*
|
||||
* Each side is scaled independently so a mostly-empty multi-terabyte pool keeps
|
||||
* its used figure — `136.37G/6.93T` rather than the `0.13/6.93T` a total-driven
|
||||
* unit would produce, or the unreadable `136.37/7096.47G` of a fixed one. When
|
||||
* both land in the same unit the suffix is written once, keeping the familiar
|
||||
* `71.12/878.15G` shape for ordinary disks.
|
||||
*/
|
||||
export function formatDiskCapacityRange(usedGb: number, totalGb: number): string {
|
||||
const used = resolveDiskCapacityUnit(usedGb);
|
||||
const total = resolveDiskCapacityUnit(totalGb);
|
||||
const usedText = formatDiskCapacityGb(usedGb / used.divisor);
|
||||
const totalText = formatDiskCapacityGb(totalGb / total.divisor);
|
||||
return used.unit === total.unit
|
||||
? `${usedText}/${totalText}${total.unit}`
|
||||
: `${usedText}${used.unit}/${totalText}${total.unit}`;
|
||||
}
|
||||
|
||||
export function resolveTerminalDiskSummary(
|
||||
stats: TerminalDiskStats,
|
||||
): TerminalDiskSummary {
|
||||
|
||||
@@ -42,6 +42,57 @@ test("filters by host targets when set", () => {
|
||||
assert.deepEqual(getSnippetSuggestions("restart", [scoped, global], { hostId: "host-2" }).map((o) => o.snippet?.id).sort(), ["g", "t"]);
|
||||
});
|
||||
|
||||
test("filters dynamic group targets using nested host group membership", () => {
|
||||
const scoped = snip({
|
||||
id: "grouped",
|
||||
label: "deploy-group",
|
||||
targetGroups: ["Production"],
|
||||
});
|
||||
const mixed = snip({
|
||||
id: "mixed",
|
||||
label: "deploy-mixed",
|
||||
targets: ["host-explicit"],
|
||||
targetGroups: ["Staging"],
|
||||
});
|
||||
|
||||
assert.deepEqual(
|
||||
getSnippetSuggestions("deploy", [scoped, mixed], {
|
||||
hostId: "host-prod",
|
||||
hostGroup: "Production/Web",
|
||||
}).map((item) => item.snippet?.id),
|
||||
["grouped"],
|
||||
);
|
||||
assert.deepEqual(
|
||||
getSnippetSuggestions("deploy", [scoped, mixed], {
|
||||
hostId: "host-staging",
|
||||
hostGroup: "Staging/API",
|
||||
}).map((item) => item.snippet?.id),
|
||||
["mixed"],
|
||||
);
|
||||
assert.deepEqual(
|
||||
getSnippetSuggestions("deploy", [scoped, mixed], {
|
||||
hostId: "host-dev",
|
||||
hostGroup: "Development",
|
||||
}),
|
||||
[],
|
||||
);
|
||||
});
|
||||
|
||||
test("does not surface a snippet with an explicitly empty group scope", () => {
|
||||
const disabled = snip({
|
||||
id: "disabled-group-scope",
|
||||
label: "deploy-disabled",
|
||||
targetGroups: [],
|
||||
});
|
||||
assert.deepEqual(
|
||||
getSnippetSuggestions("deploy", [disabled], {
|
||||
hostId: "host-prod",
|
||||
hostGroup: "Production",
|
||||
}),
|
||||
[],
|
||||
);
|
||||
});
|
||||
|
||||
test("no match returns empty; empty input returns empty", () => {
|
||||
assert.deepEqual(getSnippetSuggestions("zzz", [snip({})], {}), []);
|
||||
assert.deepEqual(getSnippetSuggestions("", [snip({})], {}), []);
|
||||
|
||||
@@ -164,7 +164,7 @@ test("reconnect preparation cancels an in-flight disconnected hibernate", () =>
|
||||
const claimIndex = reconnectBody.indexOf(
|
||||
"reconnectPreparationTokenRef.current = retryToken",
|
||||
);
|
||||
const cleanupIndex = reconnectBody.indexOf("await cleanupSession()");
|
||||
const cleanupIndex = reconnectBody.indexOf("await cleanupSession({ retainOwnership: true })");
|
||||
const startNewSessionIndex = reconnectBody.indexOf("const startNewSession = () =>");
|
||||
|
||||
assert.ok(claimIndex >= 0 && claimIndex < cleanupIndex);
|
||||
|
||||
@@ -2,6 +2,7 @@ import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import React from "react";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { act, create, type ReactTestRenderer } from "react-test-renderer";
|
||||
|
||||
import {
|
||||
VaultTreeGroupRow,
|
||||
@@ -118,3 +119,45 @@ test("VaultTreeInlineRenameInput uses shared inline edit marker", () => {
|
||||
assert.match(markup, /data-vault-tree-inline-edit="true"/);
|
||||
assert.match(markup, /value="Ops"/);
|
||||
});
|
||||
|
||||
test("VaultTreeInlineRenameInput can retry after an asynchronous commit failure", async () => {
|
||||
const actEnvironment = globalThis as typeof globalThis & {
|
||||
IS_REACT_ACT_ENVIRONMENT?: boolean;
|
||||
};
|
||||
const previousActEnvironment = actEnvironment.IS_REACT_ACT_ENVIRONMENT;
|
||||
actEnvironment.IS_REACT_ACT_ENVIRONMENT = true;
|
||||
let renderer: ReactTestRenderer | null = null;
|
||||
let attempts = 0;
|
||||
|
||||
try {
|
||||
await act(async () => {
|
||||
renderer = create(
|
||||
<VaultTreeInlineRenameInput
|
||||
initialName="Ops"
|
||||
onCommit={async () => {
|
||||
attempts += 1;
|
||||
return attempts > 1;
|
||||
}}
|
||||
onCancel={() => undefined}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
const input = renderer!.root.findByType("input");
|
||||
const pressEnter = async () => {
|
||||
input.props.onKeyDown({
|
||||
key: "Enter",
|
||||
preventDefault: () => undefined,
|
||||
stopPropagation: () => undefined,
|
||||
});
|
||||
await Promise.resolve();
|
||||
};
|
||||
await act(pressEnter);
|
||||
await act(pressEnter);
|
||||
assert.equal(attempts, 2);
|
||||
} finally {
|
||||
await act(async () => {
|
||||
renderer?.unmount();
|
||||
});
|
||||
actEnvironment.IS_REACT_ACT_ENVIRONMENT = previousActEnvironment;
|
||||
}
|
||||
});
|
||||
@@ -5,7 +5,7 @@ import { cn } from "../../lib/utils";
|
||||
|
||||
type VaultTreeInlineRenameInputProps = {
|
||||
initialName: string;
|
||||
onCommit: (name: string) => void;
|
||||
onCommit: (name: string) => boolean | void | Promise<boolean | void>;
|
||||
onCancel: () => void;
|
||||
className?: string;
|
||||
style?: React.CSSProperties;
|
||||
@@ -20,7 +20,7 @@ export const VaultTreeInlineRenameInput: React.FC<VaultTreeInlineRenameInputProp
|
||||
}) => {
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const [value, setValue] = useState(initialName);
|
||||
const committedRef = useRef(false);
|
||||
const submittingRef = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
const input = inputRef.current;
|
||||
@@ -29,15 +29,20 @@ export const VaultTreeInlineRenameInput: React.FC<VaultTreeInlineRenameInputProp
|
||||
input.select();
|
||||
}, []);
|
||||
|
||||
const commit = () => {
|
||||
if (committedRef.current) return;
|
||||
committedRef.current = true;
|
||||
onCommit(value);
|
||||
const commit = async () => {
|
||||
if (submittingRef.current) return;
|
||||
submittingRef.current = true;
|
||||
try {
|
||||
const committed = await onCommit(value);
|
||||
if (committed === false) submittingRef.current = false;
|
||||
} catch {
|
||||
submittingRef.current = false;
|
||||
}
|
||||
};
|
||||
|
||||
const cancel = () => {
|
||||
if (committedRef.current) return;
|
||||
committedRef.current = true;
|
||||
if (submittingRef.current) return;
|
||||
submittingRef.current = true;
|
||||
onCancel();
|
||||
};
|
||||
|
||||
@@ -51,7 +56,7 @@ export const VaultTreeInlineRenameInput: React.FC<VaultTreeInlineRenameInputProp
|
||||
onChange={(event) => setValue(event.target.value)}
|
||||
onBlur={() => {
|
||||
queueMicrotask(() => {
|
||||
commit();
|
||||
void commit();
|
||||
});
|
||||
}}
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
@@ -66,7 +71,7 @@ export const VaultTreeInlineRenameInput: React.FC<VaultTreeInlineRenameInputProp
|
||||
event.stopPropagation();
|
||||
if (event.key === "Enter") {
|
||||
event.preventDefault();
|
||||
commit();
|
||||
void commit();
|
||||
}
|
||||
if (event.key === "Escape") {
|
||||
event.preventDefault();
|
||||
@@ -91,7 +96,7 @@ type VaultTreeGroupRowProps = Omit<React.HTMLAttributes<HTMLDivElement>, "childr
|
||||
hasChildren?: boolean;
|
||||
editing?: boolean;
|
||||
editingInitialName?: string;
|
||||
onRenameCommit?: (name: string) => void;
|
||||
onRenameCommit?: (name: string) => boolean | void | Promise<boolean | void>;
|
||||
onRenameCancel?: () => void;
|
||||
actions?: React.ReactNode;
|
||||
labelActions?: React.ReactNode;
|
||||
@@ -190,7 +195,7 @@ type VaultTreeItemRowProps = Omit<React.HTMLAttributes<HTMLDivElement>, "childre
|
||||
actions?: React.ReactNode;
|
||||
editing?: boolean;
|
||||
editingInitialName?: string;
|
||||
onRenameCommit?: (name: string) => void;
|
||||
onRenameCommit?: (name: string) => boolean | void | Promise<boolean | void>;
|
||||
onRenameCancel?: () => void;
|
||||
content?: React.ReactNode;
|
||||
};
|
||||
|
||||
@@ -402,7 +402,7 @@ export function VaultViewLayout({ ctx }: { ctx: VaultViewLayoutContext }) {
|
||||
[treeViewGroupTree],
|
||||
);
|
||||
const visibleSelectableGroupPaths = React.useMemo(
|
||||
() => new globalThis.Set(
|
||||
() => new globalThis.Set<string>(
|
||||
viewMode === "tree"
|
||||
? visibleTreeGroupPaths
|
||||
: displayedGroups.map((group: { path: string }) => group.path),
|
||||
@@ -1685,11 +1685,16 @@ export function VaultViewLayout({ ctx }: { ctx: VaultViewLayoutContext }) {
|
||||
onClick={async () => {
|
||||
if (pendingDeleteGroupPaths.length > 0) {
|
||||
const isBulkDelete = bulkDeleteGroupPaths.length > 0;
|
||||
await deleteGroupPaths(
|
||||
pendingDeleteGroupPaths,
|
||||
deleteGroupWithHosts,
|
||||
isBulkDelete ? selectedHostIds : new Set(),
|
||||
);
|
||||
try {
|
||||
await deleteGroupPaths(
|
||||
pendingDeleteGroupPaths,
|
||||
deleteGroupWithHosts,
|
||||
isBulkDelete ? selectedHostIds : new Set<string>(),
|
||||
);
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : t("common.error"));
|
||||
return;
|
||||
}
|
||||
if (isBulkDelete) {
|
||||
const deletedItemCount =
|
||||
selectedHostIds.size + selectedGroupPaths.size;
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import React from 'react';
|
||||
import { act, create, type ReactTestRenderer } from 'react-test-renderer';
|
||||
|
||||
import { hostTreeInlineGroupEditStore } from '../../application/state/hostTreeInlineGroupEditStore';
|
||||
import { useHostTreeInlineGroupActions } from './useHostTreeInlineGroupActions';
|
||||
|
||||
test('inline group rename stays open when the Vault commit is superseded', async () => {
|
||||
const actEnvironment = globalThis as typeof globalThis & {
|
||||
IS_REACT_ACT_ENVIRONMENT?: boolean;
|
||||
};
|
||||
const previousActEnvironment = actEnvironment.IS_REACT_ACT_ENVIRONMENT;
|
||||
actEnvironment.IS_REACT_ACT_ENVIRONMENT = true;
|
||||
|
||||
let renderer: ReactTestRenderer | null = null;
|
||||
let startRename: ((groupPath: string) => void) | undefined;
|
||||
let commitRename: ((name: string) => Promise<boolean>) | undefined;
|
||||
const selectedPaths: Array<string | null> = [];
|
||||
|
||||
const Probe = () => {
|
||||
const actions = useHostTreeInlineGroupActions({
|
||||
customGroups: ['prod'],
|
||||
hosts: [],
|
||||
managedSources: [],
|
||||
onUpdateCustomGroups: () => undefined,
|
||||
onCommitGroupPathChange: async () => ({ ok: false, superseded: true }),
|
||||
selectedGroupPath: 'prod',
|
||||
setSelectedGroupPath: (path) => selectedPaths.push(path),
|
||||
ensurePathExpanded: () => undefined,
|
||||
unnamedGroupLabel: 'New group',
|
||||
t: (key) => key,
|
||||
});
|
||||
startRename = actions.startInlineRenameGroup;
|
||||
commitRename = actions.commitInlineGroupRename;
|
||||
return null;
|
||||
};
|
||||
|
||||
try {
|
||||
await act(async () => {
|
||||
renderer = create(React.createElement(Probe));
|
||||
});
|
||||
act(() => {
|
||||
startRename?.('prod');
|
||||
});
|
||||
await act(async () => {
|
||||
await commitRename?.('production');
|
||||
});
|
||||
|
||||
assert.deepEqual(selectedPaths, []);
|
||||
assert.equal(hostTreeInlineGroupEditStore.getEdit()?.groupPath, 'prod');
|
||||
} finally {
|
||||
hostTreeInlineGroupEditStore.clear();
|
||||
await act(async () => {
|
||||
renderer?.unmount();
|
||||
});
|
||||
actEnvironment.IS_REACT_ACT_ENVIRONMENT = previousActEnvironment;
|
||||
}
|
||||
});
|
||||
@@ -17,8 +17,13 @@ type UseHostTreeInlineGroupActionsParams = {
|
||||
hosts: Host[];
|
||||
managedSources: ManagedSource[];
|
||||
onUpdateCustomGroups: (groups: string[]) => void;
|
||||
onUpdateHosts: (hosts: Host[]) => void;
|
||||
onUpdateManagedSources: (sources: ManagedSource[]) => void;
|
||||
onCommitGroupPathChange: (
|
||||
sourcePath: string,
|
||||
nextPath: string,
|
||||
) => Promise<
|
||||
| { ok: true }
|
||||
| { ok: false; error?: string; superseded?: true }
|
||||
>;
|
||||
selectedGroupPath: string | null;
|
||||
setSelectedGroupPath: (path: string | null) => void;
|
||||
ensurePathExpanded: (path: string) => void;
|
||||
@@ -31,8 +36,7 @@ export function useHostTreeInlineGroupActions({
|
||||
hosts,
|
||||
managedSources,
|
||||
onUpdateCustomGroups,
|
||||
onUpdateHosts,
|
||||
onUpdateManagedSources,
|
||||
onCommitGroupPathChange,
|
||||
selectedGroupPath,
|
||||
setSelectedGroupPath,
|
||||
ensurePathExpanded,
|
||||
@@ -73,9 +77,9 @@ export function useHostTreeInlineGroupActions({
|
||||
hostTreeInlineGroupEditStore.clear();
|
||||
}, [customGroups, onUpdateCustomGroups]);
|
||||
|
||||
const commitInlineGroupRename = useCallback((rawName: string) => {
|
||||
const commitInlineGroupRename = useCallback(async (rawName: string): Promise<boolean> => {
|
||||
const edit = hostTreeInlineGroupEditStore.getEdit();
|
||||
if (!edit) return;
|
||||
if (!edit) return false;
|
||||
|
||||
const result = applyGroupPathRename({
|
||||
renameTargetPath: edit.groupPath,
|
||||
@@ -88,32 +92,32 @@ export function useHostTreeInlineGroupActions({
|
||||
if (result.ok === false) {
|
||||
if (result.error === 'unchanged') {
|
||||
hostTreeInlineGroupEditStore.clear();
|
||||
return;
|
||||
return true;
|
||||
}
|
||||
if (result.error === 'required') {
|
||||
if (edit.isNew) {
|
||||
cancelInlineGroupEdit();
|
||||
return;
|
||||
return true;
|
||||
}
|
||||
toast.error(t('vault.groups.errors.required'));
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
if (result.error === 'invalidChars') {
|
||||
toast.error(t('vault.groups.errors.invalidChars'));
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
if (result.error === 'duplicatePath') {
|
||||
toast.error(t('vault.groups.errors.duplicatePath'));
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
|
||||
if (result.updatedManagedSources.some((source, index) => source !== managedSources[index])) {
|
||||
onUpdateManagedSources(result.updatedManagedSources);
|
||||
const committed = await onCommitGroupPathChange(edit.groupPath, result.nextPath);
|
||||
if (!committed.ok) {
|
||||
toast.error(committed.error || t('common.error'));
|
||||
return false;
|
||||
}
|
||||
onUpdateCustomGroups(result.updatedGroups);
|
||||
onUpdateHosts(result.updatedHosts);
|
||||
|
||||
if (
|
||||
selectedGroupPath
|
||||
@@ -127,14 +131,13 @@ export function useHostTreeInlineGroupActions({
|
||||
}
|
||||
|
||||
hostTreeInlineGroupEditStore.clear();
|
||||
return true;
|
||||
}, [
|
||||
cancelInlineGroupEdit,
|
||||
customGroups,
|
||||
hosts,
|
||||
managedSources,
|
||||
onUpdateCustomGroups,
|
||||
onUpdateHosts,
|
||||
onUpdateManagedSources,
|
||||
onCommitGroupPathChange,
|
||||
selectedGroupPath,
|
||||
setSelectedGroupPath,
|
||||
t,
|
||||
|
||||
@@ -23,7 +23,7 @@ type RegisterVaultHostTreeActionsParams = {
|
||||
startInlineNewGroup: (parentPath?: string) => void;
|
||||
startInlineRenameGroup: (groupPath: string) => void;
|
||||
startInlineDeleteGroup: (groupPath: string) => void;
|
||||
commitInlineGroupRename: (name: string) => void;
|
||||
commitInlineGroupRename: (name: string) => boolean | void | Promise<boolean | void>;
|
||||
cancelInlineGroupEdit: () => void;
|
||||
commitInlineHostRename: (name: string) => void;
|
||||
cancelInlineHostEdit: () => void;
|
||||
|
||||
@@ -60,6 +60,30 @@ test('resolveConnectScriptsForHost runs globals before host queue and dedupes',
|
||||
assert.deepEqual(resolved.map((item) => item.id), ['global', 'both', 'host-only']);
|
||||
});
|
||||
|
||||
test('resolveConnectScriptsForHost dynamically inserts matching group scripts', () => {
|
||||
const snippets = [
|
||||
script({ id: 'global', targetsAllHosts: true, order: 1000 }),
|
||||
script({ id: 'group', targetGroups: ['Production'], order: 2000 }),
|
||||
script({ id: 'host-only', targets: ['host-a'], order: 3000 }),
|
||||
];
|
||||
const groupedHost = { ...host, group: 'Production/Web', connectScriptIds: ['host-only'] };
|
||||
assert.deepEqual(
|
||||
resolveConnectScriptsForHost(groupedHost, snippets).map((item) => item.id),
|
||||
['global', 'group', 'host-only'],
|
||||
);
|
||||
assert.deepEqual(
|
||||
resolveConnectScriptsForHost({ ...groupedHost, group: 'Staging' }, snippets).map((item) => item.id),
|
||||
['global', 'host-only'],
|
||||
);
|
||||
});
|
||||
|
||||
test('group scripts stay dynamic instead of being materialized into a new host queue', () => {
|
||||
const snippets = [script({ id: 'group', targetGroups: ['Production'] })];
|
||||
const groupedHost = { ...host, group: 'Production' };
|
||||
assert.deepEqual(migrateHostConnectScriptIds(groupedHost, snippets), []);
|
||||
assert.deepEqual(resolveConnectScriptsForHost(groupedHost, snippets).map((item) => item.id), ['group']);
|
||||
});
|
||||
|
||||
test('hasHostConnectAutomation covers host, global, and unresolved connect scripts', () => {
|
||||
assert.equal(
|
||||
hasHostConnectAutomation(
|
||||
|
||||
@@ -3,7 +3,8 @@ import { isScriptSnippet } from './snippetScript.ts';
|
||||
import {
|
||||
getScriptsLinkedToHost,
|
||||
linkHostToScript,
|
||||
snippetAppliesToHost,
|
||||
snippetTargetsHostExplicitly,
|
||||
snippetTargetsHostGroup,
|
||||
unlinkHostFromScripts,
|
||||
} from './snippetTargets.ts';
|
||||
import { sortByVaultOrder } from './vaultOrder.ts';
|
||||
@@ -64,7 +65,7 @@ export function migrateHostConnectScriptIds(host: Host, snippets: Snippet[]): st
|
||||
if (!scriptId || seen.has(scriptId)) return;
|
||||
const snippet = scriptById(snippets, scriptId);
|
||||
if (!snippet || !isOnConnectScript(snippet)) return;
|
||||
if (!snippetAppliesToHost(snippet, host.id) && !snippet.targetsAllHosts) return;
|
||||
if (!snippetTargetsHostExplicitly(snippet, host.id) && !snippet.targetsAllHosts) return;
|
||||
seen.add(scriptId);
|
||||
ordered.push(scriptId);
|
||||
};
|
||||
@@ -80,7 +81,7 @@ export function migrateHostConnectScriptIds(host: Host, snippets: Snippet[]): st
|
||||
for (const snippet of sortByVaultOrder(snippets)) {
|
||||
if (!isOnConnectScript(snippet)) continue;
|
||||
if (snippet.targetsAllHosts) continue;
|
||||
if (!snippetAppliesToHost(snippet, host.id)) continue;
|
||||
if (!snippetTargetsHostExplicitly(snippet, host.id)) continue;
|
||||
push(snippet.id);
|
||||
}
|
||||
|
||||
@@ -134,17 +135,32 @@ export function hasUnresolvedConnectScriptBindings(host: Host, snippets: Snippet
|
||||
return false;
|
||||
}
|
||||
|
||||
/** Resolve full onConnect run list: global scripts first, then host queue; dedupe favors host queue. */
|
||||
/** Group-scoped onConnect scripts inherited dynamically from the host's current group. */
|
||||
export function getGroupConnectScriptsForHost(host: Host, snippets: Snippet[]): Snippet[] {
|
||||
return sortByVaultOrder(
|
||||
snippets.filter(
|
||||
(snippet) => isOnConnectScript(snippet)
|
||||
&& !snippet.targetsAllHosts
|
||||
&& snippetTargetsHostGroup(snippet, host),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/** Resolve full onConnect run list: global, group-inherited, then host queue. */
|
||||
export function resolveConnectScriptsForHost(host: Host, snippets: Snippet[]): Snippet[] {
|
||||
const hostIds = getHostConnectScriptIds(host, snippets);
|
||||
const hostIdSet = new Set(hostIds);
|
||||
const globals = getGlobalConnectScripts(snippets).filter(
|
||||
(snippet) => snippet.id && !hostIdSet.has(snippet.id),
|
||||
);
|
||||
const inheritedIds = new Set(globals.map((snippet) => snippet.id));
|
||||
const groupScripts = getGroupConnectScriptsForHost(host, snippets).filter(
|
||||
(snippet) => snippet.id && !hostIdSet.has(snippet.id) && !inheritedIds.has(snippet.id),
|
||||
);
|
||||
const hostScripts = hostIds
|
||||
.map((id) => scriptById(snippets, id))
|
||||
.filter((snippet): snippet is Snippet => Boolean(snippet));
|
||||
return [...globals, ...hostScripts];
|
||||
return [...globals, ...groupScripts, ...hostScripts];
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -244,7 +260,7 @@ function connectQueueSnippetNeedsPromote(snippet: Snippet, hostId: string): bool
|
||||
if (!isScriptSnippet(snippet)) return false;
|
||||
if (snippet.trigger !== 'onConnect') return true;
|
||||
if (snippet.targetsAllHosts) return false;
|
||||
return !snippetAppliesToHost(snippet, hostId);
|
||||
return !snippetTargetsHostExplicitly(snippet, hostId);
|
||||
}
|
||||
|
||||
function snippetTargetsEqual(left: Snippet, right: Snippet): boolean {
|
||||
@@ -252,7 +268,11 @@ function snippetTargetsEqual(left: Snippet, right: Snippet): boolean {
|
||||
const leftTargets = left.targets ?? [];
|
||||
const rightTargets = right.targets ?? [];
|
||||
if (leftTargets.length !== rightTargets.length) return false;
|
||||
return leftTargets.every((id, index) => id === rightTargets[index]);
|
||||
if (!leftTargets.every((id, index) => id === rightTargets[index])) return false;
|
||||
const leftGroups = left.targetGroups ?? [];
|
||||
const rightGroups = right.targetGroups ?? [];
|
||||
if (leftGroups.length !== rightGroups.length) return false;
|
||||
return leftGroups.every((path, index) => path === rightGroups[index]);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -338,8 +358,8 @@ export function syncSnippetsForHostConnectQueueSave(
|
||||
!newlyAdded
|
||||
&& baselineItem
|
||||
&& isScriptSnippet(baselineItem)
|
||||
&& (Boolean(baselineItem.targetsAllHosts) || snippetAppliesToHost(baselineItem, hostId))
|
||||
&& !(Boolean(item.targetsAllHosts) || snippetAppliesToHost(item, hostId))
|
||||
&& (Boolean(baselineItem.targetsAllHosts) || snippetTargetsHostExplicitly(baselineItem, hostId))
|
||||
&& !(Boolean(item.targetsAllHosts) || snippetTargetsHostExplicitly(item, hostId))
|
||||
) {
|
||||
// Concurrent target removal for this host: do not re-link on save.
|
||||
demotedDropIds.add(scriptId);
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import {
|
||||
remapSnippetTargetGroupPaths,
|
||||
removeSnippetTargetGroupPaths,
|
||||
} from './hostGroupPathMutations.ts';
|
||||
import type { Snippet } from './models';
|
||||
|
||||
const snippets: Snippet[] = [{
|
||||
id: 'script-a',
|
||||
label: 'A',
|
||||
command: 'echo a',
|
||||
kind: 'script',
|
||||
targetGroups: ['Production', 'Production/Web', 'Staging'],
|
||||
}];
|
||||
|
||||
test('remapSnippetTargetGroupPaths follows group rename and descendants', () => {
|
||||
const next = remapSnippetTargetGroupPaths(snippets, 'Production', 'Platform');
|
||||
assert.deepEqual(next[0].targetGroups, ['Platform', 'Platform/Web', 'Staging']);
|
||||
});
|
||||
|
||||
test('removeSnippetTargetGroupPaths removes a deleted group subtree', () => {
|
||||
const next = removeSnippetTargetGroupPaths(snippets, ['Production']);
|
||||
assert.deepEqual(next[0].targetGroups, ['Staging']);
|
||||
});
|
||||
|
||||
test('removeSnippetTargetGroupPaths preserves an explicit empty scope', () => {
|
||||
const next = removeSnippetTargetGroupPaths([
|
||||
{ ...snippets[0], targetGroups: ['Production'] },
|
||||
], ['Production']);
|
||||
assert.deepEqual(next[0].targetGroups, []);
|
||||
});
|
||||
|
||||
test('remapSnippetTargetGroupPaths deduplicates rename collisions', () => {
|
||||
const next = remapSnippetTargetGroupPaths([
|
||||
{ ...snippets[0], targetGroups: ['Platform', 'Production'] },
|
||||
], 'Production', 'Platform');
|
||||
assert.deepEqual(next[0].targetGroups, ['Platform']);
|
||||
});
|
||||
|
||||
test('group path mutations normalize imported legacy paths', () => {
|
||||
const legacy = [{ ...snippets[0], targetGroups: [' Production\\Web ', 'Production//Web'] }];
|
||||
const renamed = remapSnippetTargetGroupPaths(legacy, 'Production/Web', 'Platform/Web');
|
||||
assert.deepEqual(renamed[0].targetGroups, ['Platform/Web']);
|
||||
const removed = removeSnippetTargetGroupPaths(legacy, ['Production/Web']);
|
||||
assert.deepEqual(removed[0].targetGroups, []);
|
||||
});
|
||||
@@ -1,4 +1,83 @@
|
||||
import type { Host, ManagedSource } from '../types';
|
||||
import type { Host, ManagedSource, Snippet } from '../types';
|
||||
|
||||
export function normalizeGroupTargetPath(value: string): string {
|
||||
return value
|
||||
.replace(/\\/g, '/')
|
||||
.split('/')
|
||||
.map((part) => part.trim())
|
||||
.filter(Boolean)
|
||||
.join('/');
|
||||
}
|
||||
|
||||
export function normalizeGroupTargetPaths(values: Iterable<string>): string[] {
|
||||
return Array.from(new Set(
|
||||
[...values].map(normalizeGroupTargetPath).filter(Boolean),
|
||||
));
|
||||
}
|
||||
|
||||
function replaceGroupPathPrefix(path: string, sourcePath: string, nextPath: string): string {
|
||||
if (path === sourcePath) return nextPath;
|
||||
if (path.startsWith(`${sourcePath}/`)) return nextPath + path.slice(sourcePath.length);
|
||||
return path;
|
||||
}
|
||||
|
||||
export function remapSnippetTargetGroupPaths(
|
||||
snippets: Snippet[],
|
||||
sourceValue: string,
|
||||
nextValue: string,
|
||||
): Snippet[] {
|
||||
const sourcePath = normalizeGroupTargetPath(sourceValue);
|
||||
const nextPath = normalizeGroupTargetPath(nextValue);
|
||||
if (!sourcePath || !nextPath || sourcePath === nextPath) return snippets;
|
||||
|
||||
let changed = false;
|
||||
const nextSnippets = snippets.map((snippet) => {
|
||||
if (!snippet.targetGroups?.length) return snippet;
|
||||
const targetGroups = normalizeGroupTargetPaths(
|
||||
snippet.targetGroups.map((path) => replaceGroupPathPrefix(
|
||||
normalizeGroupTargetPath(path), sourcePath, nextPath,
|
||||
)),
|
||||
);
|
||||
if (
|
||||
targetGroups.length === snippet.targetGroups.length
|
||||
&& targetGroups.every((path, index) => path === snippet.targetGroups?.[index])
|
||||
) return snippet;
|
||||
changed = true;
|
||||
return { ...snippet, targetGroups };
|
||||
});
|
||||
return changed ? nextSnippets : snippets;
|
||||
}
|
||||
|
||||
export function removeSnippetTargetGroupPaths(
|
||||
snippets: Snippet[],
|
||||
removedValues: Iterable<string>,
|
||||
): Snippet[] {
|
||||
const removedPaths = [...removedValues]
|
||||
.map(normalizeGroupTargetPath)
|
||||
.filter(Boolean);
|
||||
if (removedPaths.length === 0) return snippets;
|
||||
const isRemoved = (path: string) => removedPaths.some(
|
||||
(removed) => path === removed || path.startsWith(`${removed}/`),
|
||||
);
|
||||
|
||||
let changed = false;
|
||||
const nextSnippets = snippets.map((snippet) => {
|
||||
if (snippet.targetGroups === undefined) return snippet;
|
||||
const targetGroups = normalizeGroupTargetPaths(snippet.targetGroups).filter(
|
||||
(path) => !isRemoved(path),
|
||||
);
|
||||
if (
|
||||
targetGroups.length === snippet.targetGroups.length
|
||||
&& targetGroups.every((path, index) => path === snippet.targetGroups?.[index])
|
||||
) return snippet;
|
||||
changed = true;
|
||||
// An absent scope keeps the legacy onOutput "current session" behavior.
|
||||
// Preserve an explicit empty list when the last selected group disappears
|
||||
// so the script is disabled instead of silently widening to every host.
|
||||
return { ...snippet, targetGroups };
|
||||
});
|
||||
return changed ? nextSnippets : snippets;
|
||||
}
|
||||
|
||||
export function groupDisplayName(groupPath: string): string {
|
||||
return groupPath.split('/').filter(Boolean).pop() ?? groupPath;
|
||||
|
||||
@@ -347,6 +347,8 @@ export interface Snippet {
|
||||
tags?: string[];
|
||||
package?: string; // package path
|
||||
targets?: string[]; // host ids
|
||||
/** Group paths resolved against the latest host inventory when the snippet runs. */
|
||||
targetGroups?: string[];
|
||||
/** When true, script/snippet applies to every connectable host (no per-host picker). */
|
||||
targetsAllHosts?: boolean;
|
||||
shortkey?: string; // Keyboard shortcut to send this snippet in terminal (e.g., "F1", "Ctrl + F1")
|
||||
|
||||
@@ -15,11 +15,11 @@ const TRIGGER_GUIDE = `## Triggers and host targeting
|
||||
| trigger | Behavior |
|
||||
|---------|----------|
|
||||
| manual | Run from Vault or via scripts_run / snippets_run |
|
||||
| onConnect | Runs after SSH connect (global targetsAllHosts scripts first, then host connectScriptIds queue) |
|
||||
| onConnect | Runs after SSH connect (global targetsAllHosts, dynamic targetGroups, then host connectScriptIds queue) |
|
||||
| onOutput | Runs when terminal output matches triggerPattern (regex) |
|
||||
|
||||
Use \`targets\` (host id array) or \`targetsAllHosts: true\` to scope manual/onOutput runs.
|
||||
For per-host onConnect order, use \`host_connect_scripts_set\` or link targets and sync connect queue.`;
|
||||
Use \`targets\` (host id array), \`targetGroups\` (dynamic group path array), or \`targetsAllHosts: true\` to scope runs. Group paths include nested groups and are resolved against the latest host inventory.
|
||||
For per-host onConnect order, use \`host_connect_scripts_set\`. Group-scoped scripts remain inherited and are not copied into host queues.`;
|
||||
|
||||
const NCT_API = `## nct API reference
|
||||
|
||||
|
||||
@@ -813,6 +813,14 @@ export const SETTINGS_SEARCH_CATALOG: readonly SettingsSearchEntry[] = [
|
||||
labelKey: "settings.system.crashLogs.title",
|
||||
descriptionKey: "settings.system.crashLogs.description",
|
||||
},
|
||||
{
|
||||
id: "system-startup-landing",
|
||||
tab: "system",
|
||||
labelKey: "settings.sessionRestore.startupLanding",
|
||||
descriptionKey: "settings.sessionRestore.startupLandingDesc",
|
||||
sectionKey: "settings.sessionRestore.title",
|
||||
keywords: ["startup", "launch", "local terminal", "home"],
|
||||
},
|
||||
{
|
||||
id: "system-session-restore",
|
||||
tab: "system",
|
||||
|
||||
@@ -117,23 +117,23 @@ test("listSftpConnectedHosts includes hosts with SFTP sudo for picker display",
|
||||
assert.equal(result.length, 1);
|
||||
assert.equal(result[0]?.sessionId, "s-sudo");
|
||||
assert.equal(result[0]?.host.sftpSudo, true);
|
||||
// Transfer/open must still refuse shell reuse for sudo.
|
||||
// Sudo SFTP still opens a new subsystem/channel, but it should borrow the
|
||||
// already-authenticated SSH transport before falling back to fresh auth.
|
||||
assert.equal(
|
||||
resolveSftpTransferSourceSessionId(sessions, hostsById, "a", result[0]?.host),
|
||||
undefined,
|
||||
"s-sudo",
|
||||
);
|
||||
// Picker/connect must not pass sessionId as a reuse hint for sudo hosts.
|
||||
assert.equal(sftpSourceSessionIdForHost(result[0]?.host, result[0]?.sessionId), undefined);
|
||||
assert.equal(sftpSourceSessionIdForHost(result[0]?.host, result[0]?.sessionId), "s-sudo");
|
||||
});
|
||||
|
||||
test("sftpSourceSessionIdForHost keeps non-sudo reuse and drops sudo", () => {
|
||||
test("sftpSourceSessionIdForHost keeps sudo and non-sudo reuse hints", () => {
|
||||
assert.equal(
|
||||
sftpSourceSessionIdForHost(host({ id: "a", label: "Alpha" }), "s1"),
|
||||
"s1",
|
||||
);
|
||||
assert.equal(
|
||||
sftpSourceSessionIdForHost(host({ id: "a", label: "Alpha", sftpSudo: true }), "s1"),
|
||||
undefined,
|
||||
"s1",
|
||||
);
|
||||
assert.equal(sftpSourceSessionIdForHost(undefined, "s1"), "s1");
|
||||
assert.equal(sftpSourceSessionIdForHost(host({ id: "a", label: "Alpha" }), undefined), undefined);
|
||||
@@ -313,6 +313,34 @@ test("resolveSftpTransferSourceSessionId matches among multi-tab same hostId end
|
||||
assert.equal(resolveSftpTransferSourceSessionId(sessions, hostsById, "h1"), "s-new");
|
||||
});
|
||||
|
||||
test("resolveSftpTransferSourceSessionId ignores SFTP browse connection ids", () => {
|
||||
const vault = host({ id: "h1", label: "Prod", hostname: "prod.example.test", username: "alice", port: 22 });
|
||||
const hostsById = new Map([["h1", vault]]);
|
||||
const sessions = [
|
||||
session({
|
||||
id: "terminal-ssh-1",
|
||||
hostId: "h1",
|
||||
status: "connected",
|
||||
hostname: "prod.example.test",
|
||||
username: "alice",
|
||||
port: 22,
|
||||
}),
|
||||
session({
|
||||
id: "sftp-left-accidental-browse-id",
|
||||
hostId: "h1",
|
||||
status: "connected",
|
||||
hostname: "prod.example.test",
|
||||
username: "alice",
|
||||
port: 22,
|
||||
}),
|
||||
];
|
||||
|
||||
assert.equal(
|
||||
resolveSftpTransferSourceSessionId(sessions, hostsById, "h1", vault),
|
||||
"terminal-ssh-1",
|
||||
);
|
||||
});
|
||||
|
||||
test("sftpPickerSessionsEqual ignores title-only changes", () => {
|
||||
const prev = [session({ id: "s1", hostId: "a", status: "connected", dynamicTitle: "old" })];
|
||||
const next = [session({ id: "s1", hostId: "a", status: "connected", dynamicTitle: "new" })];
|
||||
|
||||
@@ -26,6 +26,7 @@ export type SftpPickerSessionFields = Pick<
|
||||
* Connecting sessions and Mosh/ET transports have no reusable ssh2 shell conn.
|
||||
*/
|
||||
const isReusableSftpSourceSession = (session: SftpPickerSessionFields): boolean => {
|
||||
if (session.id.startsWith("sftp-")) return false;
|
||||
if (session.status !== "connected") return false;
|
||||
if (session.moshEnabled || session.etEnabled) return false;
|
||||
const protocol = session.protocol;
|
||||
@@ -99,8 +100,8 @@ export const sftpPickerSessionsEqual = (
|
||||
* One entry per hostId — keeps the most recently listed SSH terminal session.
|
||||
*
|
||||
* Includes hosts with sftpSudo: they still belong under "Connected" when a
|
||||
* terminal is open, but connect paths must not reuse the shell (see
|
||||
* resolveSftpTransferSourceSessionId and useSftpConnections sudo guards).
|
||||
* terminal is open, and SFTP can borrow the already-authenticated SSH transport
|
||||
* before silently falling back to a fresh connection.
|
||||
*/
|
||||
export const listSftpConnectedHosts = (
|
||||
sessions: ReadonlyArray<SftpPickerSessionFields>,
|
||||
@@ -131,15 +132,14 @@ export const listSftpConnectedHosts = (
|
||||
};
|
||||
|
||||
/**
|
||||
* Drop terminal-session reuse when the host requires a dedicated sudo SFTP
|
||||
* channel. Used by the picker and browse connect path so UI state
|
||||
* (`reusedConnection`) and the actual open agree.
|
||||
* Return the live terminal session id that SFTP should try before fresh auth.
|
||||
* The main process validates that the hinted session really matches the target.
|
||||
*/
|
||||
export const sftpSourceSessionIdForHost = (
|
||||
host: Pick<Host, "sftpSudo"> | null | undefined,
|
||||
sourceSessionId: string | undefined,
|
||||
): string | undefined => {
|
||||
if (!sourceSessionId || host?.sftpSudo) return undefined;
|
||||
if (!sourceSessionId) return undefined;
|
||||
return sourceSessionId;
|
||||
};
|
||||
|
||||
@@ -160,7 +160,6 @@ export const resolveSftpTransferSourceSessionId = (
|
||||
hostId: string,
|
||||
host?: Pick<Host, "hostname" | "username" | "port" | "sftpSudo" | "protocol">,
|
||||
): string | undefined => {
|
||||
if (host?.sftpSudo) return undefined;
|
||||
let lastMatch: string | undefined;
|
||||
for (const session of sessions) {
|
||||
if (session.hostId !== hostId) continue;
|
||||
@@ -168,7 +167,6 @@ export const resolveSftpTransferSourceSessionId = (
|
||||
const vaultHost = hostsById.get(session.hostId);
|
||||
if (!vaultHost) continue;
|
||||
if (vaultHost.protocol === "serial" || isPluginHostProtocol(vaultHost.protocol)) continue;
|
||||
if (vaultHost.sftpSudo) continue;
|
||||
const liveHost = hostForLiveSession(vaultHost, session);
|
||||
if (host && !sftpHostEndpointsEqual(liveHost, host)) continue;
|
||||
lastMatch = session.id;
|
||||
|
||||
@@ -47,12 +47,37 @@ test('serializeSnippetForAgentGet includes script metadata', () => {
|
||||
kind: 'script',
|
||||
trigger: 'onConnect',
|
||||
targets: ['host-a'],
|
||||
targetGroups: ['Production'],
|
||||
language: 'javascript',
|
||||
};
|
||||
const serialized = serializeSnippetForAgentGet(snippet);
|
||||
assert.equal(serialized.kind, 'script');
|
||||
assert.equal(serialized.trigger, 'onConnect');
|
||||
assert.deepEqual(serialized.targets, ['host-a']);
|
||||
assert.deepEqual(serialized.targetGroups, ['Production']);
|
||||
});
|
||||
|
||||
test('agent serialization preserves missing and explicit empty group scopes', () => {
|
||||
const unscoped = serializeSnippetForAgentGet({
|
||||
id: 'legacy',
|
||||
label: 'Legacy',
|
||||
command: 'nct.log(1)',
|
||||
kind: 'script',
|
||||
trigger: 'onOutput',
|
||||
triggerPattern: 'READY',
|
||||
});
|
||||
const disabled = serializeSnippetForAgentGet({
|
||||
id: 'disabled',
|
||||
label: 'Disabled',
|
||||
command: 'nct.log(1)',
|
||||
kind: 'script',
|
||||
trigger: 'onOutput',
|
||||
triggerPattern: 'READY',
|
||||
targetGroups: [],
|
||||
});
|
||||
|
||||
assert.equal(unscoped.targetGroups, undefined);
|
||||
assert.deepEqual(disabled.targetGroups, []);
|
||||
});
|
||||
|
||||
test('buildSnippetFromAgentDraft validates onOutput triggerPattern', () => {
|
||||
@@ -172,6 +197,39 @@ test('applySnippetAgentPatch clears targetsAllHosts when explicit targets are se
|
||||
assert.deepEqual(patched.snippet.targets, ['host-a']);
|
||||
});
|
||||
|
||||
test('applySnippetAgentPatch supports dynamic groups alongside explicit hosts', () => {
|
||||
const existing: Snippet = {
|
||||
id: 'run',
|
||||
label: 'Run',
|
||||
command: 'nct.log(1)',
|
||||
kind: 'script',
|
||||
trigger: 'onConnect',
|
||||
targets: ['host-a'],
|
||||
};
|
||||
const patched = applySnippetAgentPatch(existing, { targetGroups: ['Production'] });
|
||||
assert.equal(patched.ok, true);
|
||||
if (!patched.ok) return;
|
||||
assert.deepEqual(patched.snippet.targets, ['host-a']);
|
||||
assert.deepEqual(patched.snippet.targetGroups, ['Production']);
|
||||
});
|
||||
|
||||
test('agent group targets normalize paths and preserve an explicit empty scope', () => {
|
||||
const built = buildSnippetFromAgentDraft({
|
||||
label: 'Run',
|
||||
command: 'nct.log(1)',
|
||||
kind: 'script',
|
||||
targetGroups: [' Production\\Web ', 'Production//Web'],
|
||||
}, []);
|
||||
assert.equal(built.ok, true);
|
||||
if (!built.ok) return;
|
||||
assert.deepEqual(built.snippet.targetGroups, ['Production/Web']);
|
||||
|
||||
const patched = applySnippetAgentPatch(built.snippet, { targetGroups: [] });
|
||||
assert.equal(patched.ok, true);
|
||||
if (!patched.ok) return;
|
||||
assert.deepEqual(patched.snippet.targetGroups, []);
|
||||
});
|
||||
|
||||
test('applySnippetAgentPatch updates multi-line run mode', () => {
|
||||
const existing: Snippet = {
|
||||
id: 'login',
|
||||
|
||||
Loaded 100 of 161 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user