Compare commits

...
Author SHA1 Message Date
netcatty-bot bbc1a08848 fix: address Codex review on PR #2965 2026-08-13 09:40:27 +00:00
netcatty-bot e51d223a0e fix(#2963): automated Cursor CLI fix 2026-08-13 09:16:11 +00:00
陈大猫 92fd917760 Merge pull request #2957 from LJAYi/codex/fix-macos-small-icon-frame
fix(mac): remove bright frame from small icons
2026-08-13 15:32:59 +08:00
LJAYi 1146a94bbf fix(mac): remove bright frame from small icons 2026-08-13 13:45:16 +08:00
陈大猫 e38b372d1f Merge pull request #2897 from yintian710/main
feat(vault): 添加动态组路径支持用于自动化脚本
2026-08-12 22:24:12 +08:00
bincxz 189db57625 fix(vault): finish dynamic group targeting 2026-08-12 22:00:18 +08:00
陈大猫 6736f95532 Merge pull request #2949 from atoz03/feat/disk-capacity-t-p-units
feat(terminal): scale mounted-disk capacity into T and P units
2026-08-12 21:21:42 +08:00
atoz03andClaude Opus 5 fc641cb304 feat(terminal): scale mounted-disk capacity into T and P units
Multi-terabyte pools rendered as raw gigabytes (118736.4G), which is hard
to read at a glance. Scale each side of the used/total pair independently
so a mostly-empty pool keeps its used figure (136.37G/6.93T instead of
0.13/6.93T), and emit a single shared suffix when both land in the same
unit so ordinary disks keep the familiar 71.12/878.15G shape.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-12 06:06:39 -07:00
yintian 27dc1c62af feat(vault): 添加组变更提交功能以支持原子操作
- 在多个组件中引入 commitVaultGroupMutation 函数
- 更新 useHostTreeInlineGroupActions 钩子以使用新的提交机制
- 修改 vault 组件以传递新的组变更提交函数
- 实现组重命名和删除操作的统一提交逻辑
- 添加相关测试用例验证组变更提交功能
- 更新类型定义以包含新的组变更状态管理接口
2026-08-12 20:37:10 +08:00
陈大猫 ef0a07e560 Merge pull request #2948 from binaricat/automation/nix-release-v1.1.79
Update Nix release metadata for v1.1.79
2026-08-12 19:47:46 +08:00
github-actions[bot] fb3396e389 Update Nix release metadata for v1.1.79 2026-08-12 11:43:42 +00:00
陈大猫 aced2a99f8 Merge pull request #2946 from binaricat/cursor/issue-2944-31588973204
fix(ai): align Anthropic-compat Base URL for detect and chat
2026-08-12 19:23:48 +08:00
陈大猫 cfb534615c Merge pull request #2931 from binaricat/cursor/issue-2879-31571007873
fix(terminal): stop idle-Enter keyword highlight flash after #2880 hole
2026-08-12 19:23:30 +08:00
bincxz 409fd30969 fix(ai): only append /v1 for bare Anthropic Base URLs
Codex P2: unconditional normalize broke custom proxy prefixes such as
https://proxy.example/anthropic that already complete the SDK base.
Restrict /v1 inference to scheme+host origins (Claude Code style) and
leave other path prefixes unchanged.
2026-08-12 19:19:31 +08:00
netcatty-bot cd284ae4f3 fix(#2944): automated Cursor CLI fix 2026-08-12 11:03:00 +00:00
陈大猫 be3658b24f Merge pull request #2945 from binaricat/codex/fix-sftp-sudo-session-fallback
fix(sftp): session-backed sudo 对齐 openSftp 回退
2026-08-12 19:01:39 +08:00
bincxz 072377777f fix(sftp): align session-backed sudo open with openSftp fallbacks
Reject sudo+forced-SCP before channel open, and fall back to the standard
SFTP subsystem on exit 127 so reused SSH transports do not re-prompt MFA
on hosts without a standalone sftp-server (e.g. ESXi).
2026-08-12 18:57:00 +08:00
陈大猫 27b62b2372 Merge pull request #2938 from myderr/codex/fix-sftp-sudo-mfa-reuse
fix(sftp): 复用 SSH 会话打开 sudo SFTP
2026-08-12 18:25:49 +08:00
bincxz 627e652f56 merge: sync main and resolve SFTP connection test conflict
Keep sudo MFA session reuse plus home-dir candidate recovery from #2941.
2026-08-12 18:25:28 +08:00
陈大猫 17c2eb6346 Merge pull request #2941 from binaricat/codex/fix-sftp-root-provisional
fix(sftp): keep home candidates when realpath root is not listable
2026-08-12 18:22:52 +08:00
ydm 01102e2d66 merge: 同步 main 并解决 SFTP 复用冲突 2026-08-12 18:19:45 +08:00
bincxz 81dcf849fb fix(sftp): keep home candidates when realpath root is not listable
Accept realpath('/') as home only after a successful readdir so virtual/chroot
roots from #2934 still work, while non-listable root no longer skips
/home/<user> and /root probing. Also retry those candidates if listing fails.
2026-08-12 18:18:14 +08:00
陈大猫 1dfe1379cd Merge pull request #2934 from pplulee/codex/fix-sftp-virtual-root
修复 SFTP 虚拟根目录会话的传输失败
2026-08-12 18:10:02 +08:00
ydm 27ec58fa40 fix(sftp): 修复侧栏自动连接去重边界 2026-08-12 18:09:59 +08:00
陈大猫 7ac878d00e Merge pull request #2939 from binaricat/fix/show-tab-number-badges-exhaustive-deps
fix: include setShowTabNumberBadgesState in storage sync deps
2026-08-12 18:02:00 +08:00
bincxz f90f40e624 fix: include setShowTabNumberBadgesState in storage sync deps
The storage event listener uses setShowTabNumberBadgesState but
omitted it from the useEffect dependency array, which triggered
react-hooks/exhaustive-deps.
2026-08-12 18:01:07 +08:00
ydm dfc9ed7f61 fix(sftp): 复用 SSH 会话打开 sudo SFTP 2026-08-12 17:58:45 +08:00
陈大猫 2c2d7e0b55 Merge pull request #2937 from binaricat/codex/fix-theme-header-button-alignment
Fix theme header button alignment
2026-08-12 17:54:23 +08:00
陈大猫 700d603e64 Merge pull request #2925 from binaricat/cursor/issue-2922-31559806317
fix(ai): keep host_open sessions after sidebar scope sync
2026-08-12 17:40:46 +08:00
netcatty-bot f2b3facd9f fix: address Codex review on PR #2925 2026-08-12 09:32:41 +00:00
netcatty-bot d1cd072502 fix: address Codex review on PR #2931 2026-08-12 09:26:44 +00:00
netcatty-bot 60820acf9a fix: address Codex review on PR #2931 2026-08-12 08:58:01 +00:00
bincxz 7f08bec0b9 Merge remote-tracking branch 'origin/main' into codex/pr-2925-review
# Conflicts:
#	electron/bridges/terminalBridge.sessionActivity.test.cjs
2026-08-12 16:50:25 +08:00
bincxz bbd4c698a5 fix(ai): harden host_open session lifecycle 2026-08-12 16:42:43 +08:00
pplulee 60e057318f fix: handle SFTP virtual root sessions 2026-08-12 16:29:35 +08:00
netcatty-bot 7408e960cc fix: address Codex review on PR #2931 2026-08-12 08:20:21 +00:00
netcatty-bot 9cd756f44b fix: address Codex review on PR #2931 2026-08-12 07:49:44 +00:00
netcatty-bot cd12b04647 fix(#2879): automated Cursor CLI fix 2026-08-12 07:07:55 +00:00
netcatty-bot 9671468412 fix: address Codex review on PR #2925 2026-08-12 06:36:20 +00:00
netcatty-bot e4f9303de5 fix: address Codex review on PR #2925 2026-08-12 06:04:52 +00:00
netcatty-bot c8a393f12b fix: address Codex review on PR #2925 2026-08-12 05:25:45 +00:00
netcatty-bot 7114288d2f fix: address Codex review on PR #2925 2026-08-12 04:46:21 +00:00
netcatty-bot 0382c089e1 fix: address Codex review on PR #2925 2026-08-12 04:05:21 +00:00
bincxz e9f5bcc6dc fix(vault): harden dynamic group script scopes 2026-08-12 12:04:42 +08:00
netcatty-bot f8c7dd44db fix(#2922): automated Cursor CLI fix 2026-08-12 03:43:57 +00:00
yintian a47de3cb52 feat(terminal): 添加动态组目标支持到代码片段功能
- 在工具规范中添加 targetGroups 字段定义
- 扩展终端自动补全引擎以支持 hostGroup 参数传递
- 实现基于嵌套主机组成员资格的动态组目标过滤
- 更新 CLI 工具以支持 --target-groups 参数解析
- 添加代码片段导入导出中的动态组范围保留功能
- 增加相关测试用例验证组目标匹配逻辑
2026-08-12 09:23:27 +08:00
yintian 0ccea42785 feat(vault): 添加动态组路径支持用于自动化脚本
- 在脚本配置中添加 targetGroups 字段用于指定动态组路径
- 更新工具规范定义以支持动态组路径参数
- 实现组范围的连接脚本动态继承功能
- 更新 CLI 适配器以支持 --target-groups 参数
- 添加测试用例验证动态组路径映射和解析
- 更新国际化资源文件添加相关提示文本
- 重构脚本目标匹配逻辑分离显式主机和组路径匹配
- 实现组路径变更时的重映射和删除处理逻辑
- 更新 UI 组件支持组路径选择和显示功能
- 修改文档描述更新动态组路径使用说明
2026-08-11 17:31:23 +08:00
161 changed files with 6539 additions and 654 deletions

No files matched your search

+103 -1
View File
@@ -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 });
+2 -1
View File
@@ -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}
+1
View File
@@ -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,
+1
View File
@@ -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',
+4
View File
@@ -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',
+4 -1
View File
@@ -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)',
+3
View File
@@ -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.',
+1
View File
@@ -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',
+4 -1
View File
@@ -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': 'Запустить на всех вкладках (последовательно)',
+3
View File
@@ -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': 'История оболочки пока пуста',
+1
View File
@@ -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',
+4
View File
@@ -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': '恢复终端工作目录',
+4 -1
View File
@@ -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': '在所有标签页顺序运行',
+5 -2
View File
@@ -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': '将作用于所有可连接的主机。',
+1
View File
@@ -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',
+4
View File
@@ -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': '恢復終端工作目錄',
+4 -1
View File
@@ -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': '在所有標籤頁依序執行',
+5 -2
View File
@@ -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", () => {
+8
View File
@@ -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;
+15 -4
View File
@@ -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({
+87 -26
View File
@@ -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']);
});
+67 -18
View File
@@ -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);
+19 -10
View File
@@ -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();
+21 -2
View File
@@ -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,
+13 -24
View File
@@ -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,
+26 -4
View File
@@ -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);
});
+44 -53
View File
@@ -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,
+150 -21
View File
@@ -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,
+1
View File
@@ -58,6 +58,7 @@ export type VaultSnapshotActions = Pick<
| 'readPersistedManagedSources'
| 'commitPluginImporterData'
| 'commitVaultImportTransaction'
| 'commitVaultGroupMutation'
| 'updateHostDistro'
| 'updateHostLastConnected'
| 'addShellHistoryEntry'
BIN
View File
Binary file not shown.
+2 -2
View File
@@ -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;
+168
View File
@@ -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>
);
};
+2
View File
@@ -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}
+8 -1
View File
@@ -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;
+16 -2
View File
@@ -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}
+36 -2
View File
@@ -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
View File
@@ -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 -42
View File
@@ -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
View File
@@ -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 &&
+49 -2
View File
@@ -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();
+19 -2
View File
@@ -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}
/>
+28 -1
View File
@@ -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 */}
+1 -6
View File
@@ -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/);
});
+46 -9
View File
@@ -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}
+5 -1
View File
@@ -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,
+3 -3
View File
@@ -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">
+1 -1
View File
@@ -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/);
+1
View File
@@ -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}`,
+394 -7
View File
@@ -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 {
+99 -8
View File
@@ -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,
+36
View File
@@ -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);
+43
View File
@@ -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;
}
});
+17 -12
View File
@@ -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;
};
+11 -6
View File
@@ -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;
+24
View File
@@ -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(
+29 -9
View File
@@ -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);
+47
View File
@@ -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, []);
});
+80 -1
View File
@@ -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;
+2
View File
@@ -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")
+3 -3
View File
@@ -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
+8
View File
@@ -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",
+34 -6
View File
@@ -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" })];
+6 -8
View File
@@ -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;
+58
View File
@@ -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