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
bincxz ef364b69fd Fix theme header button alignment 2026-08-12 17:50:10 +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
陈大猫 4beccc3d61 Merge pull request #2928 from binaricat/cursor/issue-2926-31561097764
fix(terminal): re-run onConnect scripts on manual reconnect
2026-08-12 16:42:39 +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
bincxz 33b3beaea0 Merge remote-tracking branch 'origin/main' into codex/pr-2928 2026-08-12 16:14:56 +08:00
bincxz e65af2fd7b fix(terminal): safely rerun connect scripts on manual reconnect 2026-08-12 16:14:43 +08:00
netcatty-bot 9cd756f44b fix: address Codex review on PR #2931 2026-08-12 07:49:44 +00:00
陈大猫 5d63a9476c Merge pull request #2932 from binaricat/codex/fix-2924-dropbear-stats-limit
fix(ssh): keep server stats safe for Dropbear
2026-08-12 15:35:20 +08:00
bincxz 58c72ace32 fix(ssh): keep server stats safe for Dropbear 2026-08-12 15:29:14 +08:00
netcatty-bot cd12b04647 fix(#2879): automated Cursor CLI fix 2026-08-12 07:07:55 +00:00
陈大猫 75cbda7edc Merge pull request #2917 from binaricat/cursor/issue-2899-31554398579
fix(ssh): stop parked reconnect PID probes
2026-08-12 14:55:52 +08:00
bincxz 6f6d8dc36d fix(ssh): recover cwd safely after parked reconnect 2026-08-12 14:42:25 +08:00
netcatty-bot d0f819efb0 fix: address Codex review on PR #2917 2026-08-12 06:36:25 +00:00
netcatty-bot 9671468412 fix: address Codex review on PR #2925 2026-08-12 06:36:20 +00:00
陈大猫 380e96eae5 Merge pull request #2929 from binaricat/codex/fix-docker-tmux-popup-timeout
fix(terminal): start command popups after vault hydration
2026-08-12 14:12:01 +08:00
netcatty-bot e4f9303de5 fix: address Codex review on PR #2925 2026-08-12 06:04:52 +00:00
netcatty-bot 02ce011934 fix: address Codex review on PR #2917 2026-08-12 06:03:20 +00:00
bincxz 8e45763e4c fix(terminal): start command popups after vault hydration 2026-08-12 13:53:54 +08:00
netcatty-bot 0d92c5d738 fix: address Codex review on PR #2928 2026-08-12 05:26:14 +00:00
netcatty-bot c8a393f12b fix: address Codex review on PR #2925 2026-08-12 05:25:45 +00:00
bincxz 6bf4268e8e fix(ssh): scope parked reconnect cwd guard 2026-08-12 13:17:50 +08:00
netcatty-bot 7114288d2f fix: address Codex review on PR #2925 2026-08-12 04:46:21 +00:00
bincxz f02b645af7 fix(ssh): avoid stale cwd after parked reconnect 2026-08-12 12:44:11 +08:00
netcatty-bot eb3cd06278 fix: address Codex review on PR #2928 2026-08-12 04:42:43 +00:00
bincxz 7ed0b32489 Merge remote-tracking branch 'origin/main' into codex/pr-2917-final
# Conflicts:
#	electron/bridges/sshBridge.connectionReuse.test.cjs
2026-08-12 12:36:46 +08:00
陈大猫 2fe46cbf6f Merge pull request #2918 from binaricat/cursor/issue-2916-31555268705
fix(workspace): allow dragging hosts into focus-mode sidebar
2026-08-12 12:24:21 +08:00
bincxz 53ebbec58c fix(workspace): expand focus sidebar host drop target 2026-08-12 12:16:52 +08: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 253ce57d84 fix: address Codex review on PR #2917 2026-08-12 04:02:40 +00:00
netcatty-bot 9beb7d3108 fix(#2926): automated Cursor CLI fix 2026-08-12 04:00:36 +00:00
陈大猫 525a78e83e Merge pull request #2921 from binaricat/cursor/issue-2919-31558580686
fix(ai): sync side panel color-scheme for light Codex composer
2026-08-12 11:57:04 +08:00
陈大猫 0e94950a5c Merge pull request #2927 from binaricat/cursor/issue-2923-31560157438
fix(ssh): skip idle-park reconnect PID discovery that breaks bastions
2026-08-12 11:56:18 +08:00
陈大猫 a2ea0f6464 Merge pull request #2913 from binaricat/cursor/issue-2912-31541806461
fix(system): exclude NFS/CIFS mounts from overview disk totals
2026-08-12 11:52:44 +08:00
netcatty-bot 42b174abb3 fix(#2923): automated Cursor CLI fix 2026-08-12 03:49:12 +00:00
bincxz e2ad0f526e fix(workspace): complete host drop paths 2026-08-12 11:44:35 +08:00
netcatty-bot f8c7dd44db fix(#2922): automated Cursor CLI fix 2026-08-12 03:43:57 +00:00
bincxz cfeee2ec89 fix(ssh): skip redundant PID discovery on reconnect 2026-08-12 11:39:58 +08:00
bincxz be3e971064 fix(system): filter legacy distributed filesystem sources 2026-08-12 11:35:37 +08:00
陈大猫 35bb83f57c Merge pull request #2914 from binaricat/cursor/issue-2863-31551484962
fix(zmodem): fail skipped uploads and auto-overwrite on terminal drag-drop
2026-08-12 11:34:37 +08:00
netcatty-bot 5279ac2836 fix(#2919): automated Cursor CLI fix 2026-08-12 03:16:44 +00:00
bincxz 793ba54bda fix(system): cover generic FUSE and scoped IPv6 mounts 2026-08-12 11:14:40 +08:00
bincxz a1ca1a3443 fix(zmodem): preserve drag-drop overwrite targets 2026-08-12 11:14:03 +08:00
netcatty-bot 3c528c6ab5 fix: address Codex review on PR #2917 2026-08-12 03:13:17 +00:00
netcatty-bot 6dd703afce fix: address Codex review on PR #2918 2026-08-12 03:12:52 +00:00
netcatty-bot 10c399f47b fix(#2916): automated Cursor CLI fix 2026-08-12 02:13:50 +00:00
netcatty-bot a4ec7bab61 fix: address Codex review on PR #2914 2026-08-12 02:11:24 +00:00
netcatty-bot 79215d8896 fix(#2899): automated Cursor CLI fix 2026-08-12 02:02:41 +00:00
netcatty-bot 2012fc53c7 fix: address Codex review on PR #2914 2026-08-12 01:24:14 +00:00
yintian a47de3cb52 feat(terminal): 添加动态组目标支持到代码片段功能
- 在工具规范中添加 targetGroups 字段定义
- 扩展终端自动补全引擎以支持 hostGroup 参数传递
- 实现基于嵌套主机组成员资格的动态组目标过滤
- 更新 CLI 工具以支持 --target-groups 参数解析
- 添加代码片段导入导出中的动态组范围保留功能
- 增加相关测试用例验证组目标匹配逻辑
2026-08-12 09:23:27 +08:00
netcatty-bot 2be738b8c2 fix(#2863): automated Cursor CLI fix 2026-08-12 01:04:52 +00:00
netcatty-bot 93d7f8e1ec fix: address Codex review on PR #2913 2026-08-11 23:16:26 +00:00
netcatty-bot 924c9a0efa fix: address Codex review on PR #2913 2026-08-11 22:56:23 +00:00
netcatty-bot 0d9da150ec fix(#2912): automated Cursor CLI fix 2026-08-11 22:39:50 +00:00
陈大猫 d307a2ebc3 Merge pull request #2910 from binaricat/automation/nix-release-v1.1.78
Update Nix release metadata for v1.1.78
2026-08-12 01:14:30 +08:00
github-actions[bot] d97439f878 Update Nix release metadata for v1.1.78 2026-08-11 17:10:46 +00:00
陈大猫 8285d7010b Merge pull request #2909 from binaricat/perf/notes-tree-resize-dom
perf(notes): drive tree width resize from the DOM
2026-08-12 00:50:36 +08:00
bincxz 1f0ddd2bdb perf(notes): drive tree width resize from the DOM
Pointermove used to setTreeWidth every pixel and re-render NotesManager
including MDXEditor. Apply live width via rAF + aside.style, commit React
state and storage only on pointerup.
2026-08-12 00:48:29 +08:00
陈大猫 98db9e6fea Merge pull request #2908 from binaricat/fix/notes-tree-sidebar-ellipsis
fix(notes): truncate tree titles when sidebar is narrowed
2026-08-12 00:35:03 +08:00
陈大猫 8e53ec829f Merge pull request #2906 from binaricat/codex/fix-issue-2865-terminal-font-shortcuts
[Bug] Keep Ctrl+=/-/0 scoped to terminal font size
2026-08-12 00:32:02 +08:00
netcatty-bot 549c5534d5 fix: address Codex review on PR #2908 2026-08-11 16:30:13 +00:00
bincxz 8fae84148c fix: keep popup shortcuts outside issue scope 2026-08-12 00:17:20 +08:00
bincxz 6d14f1e3d6 fix: keep terminal focus routing across window focus changes 2026-08-12 00:08:29 +08:00
bincxz aebe25ae70 fix(notes): truncate tree titles when sidebar is narrowed
Radix ScrollArea's display:table viewport child blocked flex shrink, so
narrowing the notes tree clipped long titles mid-glyph. Constrain the
tree width chain and ellipsize labels so content adapts with the panel.
2026-08-12 00:08:22 +08:00
bincxz df045bf144 Revert "fix: address Codex review on PR #2906"
This reverts commit a1b7f3708a.
2026-08-11 23:57:57 +08:00
bincxz 32447885d9 Revert "fix: complete terminal shortcut routing"
This reverts commit c921e7d9a4.
2026-08-11 23:57:57 +08:00
bincxz 195a23e380 Revert "fix: honor configured terminal shortcuts"
This reverts commit a5bf1765d4.
2026-08-11 23:57:57 +08:00
bincxz 459ac09ebb Revert "fix: keep popup zoom setting visible"
This reverts commit e96384cc7f.
2026-08-11 23:57:57 +08:00
bincxz a1ea4cc546 Revert "fix: preserve terminal focus after toolbar clicks"
This reverts commit 01b9f745b6.
2026-08-11 23:57:57 +08:00
bincxz 25224cef7b Revert "fix: match physical shortcut keys"
This reverts commit e3b9e71d8f.
2026-08-11 23:57:57 +08:00
bincxz 452058bd42 Revert "fix: match logical shortcut keys by layout"
This reverts commit 50c25d8789.
2026-08-11 23:57:57 +08:00
bincxz 50c25d8789 fix: match logical shortcut keys by layout 2026-08-11 23:51:38 +08:00
陈大猫 96a38593bf Merge pull request #2907 from jingyaogong/main
[fix] preserve snippet execution results
2026-08-11 23:48:44 +08:00
bincxz e3b9e71d8f fix: match physical shortcut keys 2026-08-11 23:44:04 +08:00
bincxz 01b9f745b6 fix: preserve terminal focus after toolbar clicks 2026-08-11 23:30:24 +08:00
bincxz e96384cc7f fix: keep popup zoom setting visible 2026-08-11 23:08:27 +08:00
bincxz a5bf1765d4 fix: honor configured terminal shortcuts 2026-08-11 23:03:14 +08:00
bincxz c921e7d9a4 fix: complete terminal shortcut routing 2026-08-11 23:01:32 +08:00
netcatty-bot a1b7f3708a fix: address Codex review on PR #2906 2026-08-11 14:47:01 +00:00
jingyaogong 6a4a1ffd92 [fix] preserve snippet execution results 2026-08-11 22:41:47 +08:00
bincxz 105107755b fix: route terminal font shortcuts by focus 2026-08-11 22:14:06 +08:00
陈大猫 028e86b111 Merge pull request #2905 from binaricat/codex/fix-fullscreen-context-menu
fix: honor fullscreen app context menu setting
2026-08-11 21:48:00 +08:00
陈大猫 b2886ed16b Merge pull request #2904 from binaricat/codex/follow-up-pr-2900-custom-shortcut-preview 2026-08-11 21:19:12 +08:00
netcatty-bot 57e11822f2 fix: address Codex review on PR #2904 2026-08-11 12:39:55 +00:00
netcatty-bot ff05c33fc9 fix: address Codex review on PR #2904 2026-08-11 12:15:44 +00:00
bincxz a2ff82187b fix(tabs): follow custom shortcut bindings for previews 2026-08-11 19:42:10 +08:00
yintian 0ccea42785 feat(vault): 添加动态组路径支持用于自动化脚本
- 在脚本配置中添加 targetGroups 字段用于指定动态组路径
- 更新工具规范定义以支持动态组路径参数
- 实现组范围的连接脚本动态继承功能
- 更新 CLI 适配器以支持 --target-groups 参数
- 添加测试用例验证动态组路径映射和解析
- 更新国际化资源文件添加相关提示文本
- 重构脚本目标匹配逻辑分离显式主机和组路径匹配
- 实现组路径变更时的重映射和删除处理逻辑
- 更新 UI 组件支持组路径选择和显示功能
- 修改文档描述更新动态组路径使用说明
2026-08-11 17:31:23 +08:00
232 changed files with 10597 additions and 999 deletions

No files matched your search

@@ -272,6 +272,55 @@ test('next, previous, and number shortcuts include native plugin view tabs', ()
assert.deepEqual(selected, [pluginTabId, 'session-1', pluginTabId]);
});
test('switchToTab uses physical Digit code when Shift remaps e.key', () => {
let activeTabId = 'session-1';
const noop = () => {};
const context = {
IS_DEV: false,
MOVE_FOCUS_DEBOUNCE_MS: 0,
activeTabStore: { getActiveTabId: () => activeTabId },
addConnectionLogRef: { current: noop },
closeSession: noop,
closeTabInFlightRef: { current: false },
closeWorkspace: noop,
collectSessionIds: () => [],
confirmIfBusyLocalTerminal: async () => true,
createLocalTerminalWithCurrentShell: noop,
editorTabs: [],
fromEditorTabId: () => null,
handleOpenSettingsRef: { current: noop },
handleRequestCloseEditorTabRef: { current: noop },
isEditorTabId: () => false,
isQuickSwitcherOpen: false,
lastMoveFocusTimeRef: { current: 0 },
moveFocusInWorkspace: noop,
orderedTabs: ['session-1', 'session-2', 'session-3'],
resolveCloseIntent: () => ({ kind: 'noop' }),
resolveSnippetsShortcutIntent: () => ({ kind: 'noop' }),
sessions: [],
setActiveTabId: (id: string) => { activeTabId = id; },
setAddToWorkspaceDialog: noop,
setIsQuickSwitcherOpen: noop,
setNavigateToSection: noop,
settings: { showSftpTab: false, shellOnlyTabNumberShortcuts: false },
splitSessionWithCurrentShell: noop,
systemInfoRef: { current: { username: 'user', hostname: 'host' } },
toEditorTabId: (id: string) => `editor:${id}`,
toggleBroadcast: noop,
toggleScriptsSidePanelRef: { current: noop },
toggleSidePanelRef: { current: noop },
toggleWorkspaceViewMode: noop,
workspaces: [],
};
executeHotkeyActionImpl(
() => context,
'switchToTab',
{ key: '@', code: 'Digit3', ctrlKey: true, shiftKey: true } as KeyboardEvent,
);
assert.equal(activeTabId, 'session-2');
});
test('next tab includes pinned tabs when shell-only shortcut mode is disabled', () => {
let activeTabId = '';
const noop = () => {};
+5 -6
View File
@@ -7,6 +7,7 @@ import { getEffectiveHostDistro, classifyDistroId, shouldProbeSessionCwd } from
import { sanitizeHostIconFields } from '../../domain/hostIcon';
import { resolveEffectiveTerminalProtocol } from '../../domain/terminalProtocol';
import { getTerminalPassthroughActions } from '../state/useGlobalHotkeys';
import { tabShortcutDigitFromEvent } from '../../domain/models/keyBindings';
import { buildNumberShortcutTabTargets } from './tabShortcutTargets';
import { captureInheritedCwd } from '../state/inheritedCwd';
@@ -680,12 +681,10 @@ export function executeHotkeyActionImpl(getCtx: AppContextGetter, action: string
});
switch (action) {
case 'switchToTab': {
// Get the number key pressed (1-9)
const num = parseInt(e.key, 10);
if (num >= 1 && num <= 9) {
if (num <= shortcutTabs.length) {
setActiveTabId(shortcutTabs[num - 1]);
}
// Prefer physical Digit code so Shift+[1...9] works when e.key is "!" etc.
const num = tabShortcutDigitFromEvent(e);
if (num !== null && num <= shortcutTabs.length) {
setActiveTabId(shortcutTabs[num - 1]);
}
break;
}
+107 -1
View File
@@ -18,6 +18,7 @@ import {
import { getNotesSnapshot } from '../state/notesStore';
import { useVaultAgentBridge } from '../state/useVaultAgentBridge';
import { useWindowControls } from '../state/useWindowControls';
import { useTerminalKeyboardFocus } from '../state/useTerminalKeyboardFocus';
import { useEditorTabChromeList } from '../state/editorTabStore';
import {
isPluginViewTabId,
@@ -71,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';
@@ -78,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';
@@ -132,12 +135,15 @@ export function AppSideEffects() {
terminalSettings,
hotkeyScheme,
keyBindings,
disableTerminalFontZoom,
isHotkeyRecording,
showSftpTab,
shellOnlyTabNumberShortcuts,
workspaceFocusStyle,
} = settings;
useTerminalKeyboardFocus(hotkeyScheme !== 'disabled' && !disableTerminalFontZoom);
const discoveredShells = useDiscoveredShells();
// Sync workspace focus indicator style to DOM for CSS targeting
@@ -196,6 +202,7 @@ export function AppSideEffects() {
readPersistedHosts,
groupConfigs,
updateGroupConfigs,
commitVaultGroupMutation,
} = vaultState;
const hostsRef = useRef(hosts);
@@ -1172,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],
@@ -1279,6 +1380,7 @@ export function AppSideEffects() {
groupConfigs,
updateGroupConfigs,
updateManagedSources,
commitVaultGroupMutation,
updatePortForwardingRules: importPortForwardingRules,
startTunnel,
stopTunnel,
@@ -1289,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) {
@@ -1346,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) {
@@ -1392,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) {
@@ -1431,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 });
+35 -9
View File
@@ -34,6 +34,7 @@ import { useMainWindowInputFocusRecovery } from '../state/useMainWindowInputFocu
import { useExternalMcpToggleState } from '../state/useExternalMcpToggleState';
import { selectPluginThemeTokens } from '../state/pluginContributionEnvironment';
import { netcattyBridge } from '../../infrastructure/services/netcattyBridge';
import { resolveEffectiveTerminalHost } from '../../domain/terminalHostResolution';
import { pluginViewTabStore, usePluginViewTabs } from '../state/pluginViewTabStore';
import { buildPluginSettingScopeCatalog } from '../state/usePluginSettingScopeCatalog';
import { useWorkSurfaceHostEditor } from '../state/useWorkSurfaceHostEditor';
@@ -257,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,
@@ -317,6 +318,29 @@ function AppViewInner({ domains }: AppViewProps) {
setAddToWorkspaceDialog({ mode: 'append', workspaceId });
}, [setAddToWorkspaceDialog]);
const validProxyProfileIds = useMemo(
() => new Set(proxyProfiles.map((profile) => profile.id)),
[proxyProfiles],
);
const resolveWorkspaceAppendHost = useCallback((host: typeof hosts[number]) => (
resolveEffectiveTerminalHost({
host,
groupConfigs,
proxyProfiles,
validProxyProfileIds,
})
), [groupConfigs, proxyProfiles, validProxyProfileIds]);
const handleAppendHostToWorkspace = useCallback((workspaceId: string, hostId: string) => {
const host = hosts.find((entry) => entry.id === hostId);
if (!host) return;
const ws = workspaces.find((entry) => entry.id === workspaceId);
if (!ws) return;
const rootDir = ws.root.type === 'split' ? ws.root.direction : 'vertical';
appendHostToWorkspace(workspaceId, resolveWorkspaceAppendHost(host), rootDir);
}, [appendHostToWorkspace, hosts, resolveWorkspaceAppendHost, workspaces]);
const isPeerSessionWindow = typeof window !== 'undefined'
&& window.location.hash.startsWith('#/session-window');
const externalMcpToggle = useExternalMcpToggleState();
@@ -471,8 +495,10 @@ function AppViewInner({ domains }: AppViewProps) {
onEndSessionDrag={handleEndSessionDrag}
onReorderTabs={reorderWorkTabs}
onRemoveSessionFromWorkspace={removeSessionFromWorkspace}
onAppendHostToWorkspace={handleAppendHostToWorkspace}
showSftpTab={showSftpTab}
showHostTreeSidebar={showHostTreeSidebar}
switchTabKeyBinding={keyBindings.find((binding) => binding.action === 'switchToTab') ?? null}
dynamicTabTitleMode={dynamicTabTitleMode}
editorTabs={editorTabs}
pluginViewTabs={pluginViewTabs}
@@ -566,6 +592,7 @@ function AppViewInner({ domains }: AppViewProps) {
onUpdateManagedSources={updateManagedSources}
onReadPersistedManagedSources={readPersistedManagedSources}
onCommitVaultImportTransaction={commitVaultImportTransaction}
onCommitVaultGroupMutation={commitVaultGroupMutation}
onClearAndRemoveManagedSource={clearAndRemoveSource}
onClearAndRemoveManagedSources={clearAndRemoveSources}
onUnmanageSource={unmanageSource}
@@ -658,6 +685,7 @@ function AppViewInner({ domains }: AppViewProps) {
onCreateWorkspaceFromSessions={createWorkspaceFromSessions}
onAddSessionToWorkspace={addSessionToWorkspace}
onRequestAddToWorkspace={handleRequestAddToWorkspace}
onAppendHostToWorkspace={handleAppendHostToWorkspace}
onUpdateSplitSizes={updateSplitSizes}
onSetDraggingSessionId={setDraggingSessionId}
onToggleWorkspaceViewMode={toggleWorkspaceViewMode}
@@ -779,13 +807,7 @@ function AppViewInner({ domains }: AppViewProps) {
<AddToWorkspaceDialog
open
onOpenChange={(open) => { if (!open) setAddToWorkspaceDialog(null); }}
// Filter serial hosts only in append mode — appendHostToWorkspace
// has no serial code path. Create mode goes through
// createWorkspaceFromTargets, which builds a SerialConfig-backed
// session for serial hosts, so those should remain pickable.
hosts={addToWorkspaceDialog.mode === 'append'
? hosts.filter((h) => h.protocol !== 'serial')
: hosts}
hosts={hosts}
workspaceTitle={
addToWorkspaceDialog.mode === 'append'
? workspaces.find((w) => w.id === addToWorkspaceDialog.workspaceId)?.title
@@ -804,7 +826,11 @@ function AppViewInner({ domains }: AppViewProps) {
if (target.kind === 'local') {
appendLocalTerminalToWorkspace(addToWorkspaceDialog.workspaceId, undefined, rootDir);
} else {
appendHostToWorkspace(addToWorkspaceDialog.workspaceId, target.host, rootDir);
appendHostToWorkspace(
addToWorkspaceDialog.workspaceId,
resolveWorkspaceAppendHost(target.host),
rootDir,
);
}
}
} else {
@@ -0,0 +1,19 @@
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import test from 'node:test';
const source = readFileSync(new URL('./AppView.tsx', import.meta.url), 'utf8');
test('workspace append resolves group defaults before creating host sessions', () => {
assert.match(source, /resolveEffectiveTerminalHost\(\{/);
assert.match(source, /groupConfigs,/);
assert.match(source, /proxyProfiles,/);
assert.match(
source,
/appendHostToWorkspace\(workspaceId, resolveWorkspaceAppendHost\(host\), rootDir\)/,
);
assert.match(
source,
/appendHostToWorkspace\([\s\S]*?resolveWorkspaceAppendHost\(target\.host\),[\s\S]*?rootDir/,
);
});
+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 歷史',
@@ -1,11 +1,14 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
runConnectScriptsSequential,
selectScriptOverlayRun,
setScriptRuns,
waitForScriptRun,
} from './scriptAutomationCoordinator.ts';
import type { ScriptRun } from '@/types/global/netcatty-bridge-script.d.ts';
import type { Snippet } from '@/domain/models';
import { netcattyBridge } from '@/infrastructure/services/netcattyBridge.ts';
test('waitForScriptRun resolves when run is already completed on subscribe', async () => {
const runId = 'run-already-done';
@@ -64,3 +67,182 @@ test('selectScriptOverlayRun does not resurface older completed runs after dismi
dismissedRunIds.add(latestRun.runId);
assert.equal(selectScriptOverlayRun([olderRun, latestRun], 'sess1', dismissedRunIds), undefined);
});
test('runConnectScriptsSequential cancels only its own queued run and waits for stop', async () => {
const originalGet = netcattyBridge.get;
const sessionId = 'sess-connect-abort';
let queuedRunId: string | undefined;
let releaseStop: (() => void) | undefined;
const scriptStopCalls: string[] = [];
const storage = new Map<string, string>();
Object.defineProperty(globalThis, 'localStorage', {
configurable: true,
value: {
getItem: (key: string) => storage.get(key) ?? null,
setItem: (key: string, value: string) => { storage.set(key, String(value)); },
removeItem: (key: string) => { storage.delete(key); },
clear: () => { storage.clear(); },
},
});
setScriptRuns([{
runId: 'unrelated-active-run',
scriptId: 'manual-script',
sessionId,
status: 'running',
startedAt: Date.now(),
logs: [],
}]);
netcattyBridge.get = () => ({
scriptRun: async (params) => {
queuedRunId = params.runId;
assert.equal(params.returnWhenQueued, true);
return { runId: params.runId!, runIds: [params.runId!] };
},
scriptStop: (id: string) => new Promise((resolve) => {
scriptStopCalls.push(id);
releaseStop = () => resolve({ ok: true });
}),
}) as ReturnType<typeof netcattyBridge.get>;
const controller = new AbortController();
let stopCurrentRun: (() => Promise<void>) | null = null;
const snippet: Snippet = {
id: 'connect-script',
label: 'Connect',
command: 'nct.session.sleep(60)',
kind: 'script',
};
try {
const running = runConnectScriptsSequential({
scripts: [snippet],
sessionId,
signal: controller.signal,
onCancelableRunChange: (stop) => { stopCurrentRun = stop; },
});
void running.catch(() => {});
await Promise.resolve();
controller.abort();
for (let attempt = 0; attempt < 10 && scriptStopCalls.length === 0; attempt += 1) {
await new Promise<void>((resolve) => setImmediate(resolve));
}
assert.deepEqual(scriptStopCalls, [queuedRunId]);
assert.notEqual(queuedRunId, 'unrelated-active-run');
assert.equal(typeof stopCurrentRun, 'function');
let settled = false;
void running.finally(() => { settled = true; }).catch(() => {});
await Promise.resolve();
assert.equal(settled, false);
releaseStop?.();
await assert.rejects(
() => running,
(error: unknown) => error instanceof DOMException && error.name === 'AbortError',
);
} finally {
netcattyBridge.get = originalGet;
setScriptRuns([]);
Reflect.deleteProperty(globalThis, 'localStorage');
}
});
test('runConnectScriptsSequential retries the exact stop after a transient failure', async () => {
const originalGet = netcattyBridge.get;
const storage = new Map<string, string>();
const stopCalls: string[] = [];
let stopCurrentRun: (() => Promise<void>) | null = null;
Object.defineProperty(globalThis, 'localStorage', {
configurable: true,
value: {
getItem: (key: string) => storage.get(key) ?? null,
setItem: (key: string, value: string) => { storage.set(key, String(value)); },
removeItem: (key: string) => { storage.delete(key); },
clear: () => { storage.clear(); },
},
});
netcattyBridge.get = () => ({
scriptRun: async (params) => ({ runId: params.runId!, runIds: [params.runId!] }),
scriptStop: async (id: string) => {
stopCalls.push(id);
return { ok: stopCalls.length > 1 };
},
}) as ReturnType<typeof netcattyBridge.get>;
const controller = new AbortController();
try {
const running = runConnectScriptsSequential({
scripts: [{ id: 'retry-stop', label: 'Retry stop', command: 'await nct.sleep(60)', kind: 'script' }],
sessionId: 'sess-retry-stop',
signal: controller.signal,
onCancelableRunChange: (stop) => { stopCurrentRun = stop; },
});
void running.catch(() => {});
for (let attempt = 0; attempt < 10 && !stopCurrentRun; attempt += 1) {
await new Promise<void>((resolve) => setImmediate(resolve));
}
controller.abort();
await assert.rejects(() => running, /could not be stopped/);
assert.equal(typeof stopCurrentRun, 'function');
await stopCurrentRun!();
assert.equal(stopCalls.length, 2);
} finally {
netcattyBridge.get = originalGet;
setScriptRuns([]);
Reflect.deleteProperty(globalThis, 'localStorage');
}
});
test('runConnectScriptsSequential does not treat a script error named Aborted as cancellation', async () => {
const originalGet = netcattyBridge.get;
const storage = new Map<string, string>();
const scriptStopCalls: string[] = [];
Object.defineProperty(globalThis, 'localStorage', {
configurable: true,
value: {
getItem: (key: string) => storage.get(key) ?? null,
setItem: (key: string, value: string) => { storage.set(key, String(value)); },
removeItem: (key: string) => { storage.delete(key); },
clear: () => { storage.clear(); },
},
});
netcattyBridge.get = () => ({
scriptRun: async (params) => {
setScriptRuns([{
runId: params.runId!,
scriptId: params.scriptId,
sessionId: params.sessionId!,
status: 'failed',
startedAt: Date.now() - 10,
endedAt: Date.now(),
error: 'Aborted',
logs: [],
}]);
return { runId: params.runId!, runIds: [params.runId!] };
},
scriptStop: async (id: string) => {
scriptStopCalls.push(id);
return { ok: true };
},
}) as ReturnType<typeof netcattyBridge.get>;
try {
await assert.rejects(
() => runConnectScriptsSequential({
scripts: [{ id: 'fails', label: 'Fails', command: "throw new Error('Aborted')", kind: 'script' }],
sessionId: 'sess-real-error',
signal: new AbortController().signal,
}),
/Aborted/,
);
assert.deepEqual(scriptStopCalls, []);
} finally {
netcattyBridge.get = originalGet;
setScriptRuns([]);
Reflect.deleteProperty(globalThis, 'localStorage');
}
});
@@ -83,6 +83,8 @@ export function getActiveScriptRunForSession(sessionId: string): ScriptRun | und
}
export async function runAutomationScript(params: {
runId?: string;
returnWhenQueued?: boolean;
snippet: Snippet;
sessionId: string;
sessionIds?: string[];
@@ -104,6 +106,8 @@ export async function runAutomationScript(params: {
throw new Error('Script bridge unavailable');
}
return bridge.scriptRun({
runId: params.runId,
returnWhenQueued: params.returnWhenQueued,
scriptId: params.snippet.id,
scriptLabel: params.snippet.label,
content: params.snippet.command,
@@ -176,6 +180,7 @@ export async function runConnectScriptsSequential(params: {
scripts: Snippet[];
sessionId: string;
signal?: AbortSignal;
onCancelableRunChange?: (stopCurrentRun: (() => Promise<void>) | null) => void;
onScriptStart?: (snippet: Snippet) => void;
onScriptComplete?: (snippet: Snippet) => void;
sessionMeta?: {
@@ -185,18 +190,63 @@ export async function runConnectScriptsSequential(params: {
username?: string;
};
}): Promise<void> {
for (const snippet of params.scripts) {
const throwIfAborted = () => {
if (params.signal?.aborted) {
throw new Error('Aborted');
throw new DOMException('Connect script run cancelled', 'AbortError');
}
};
for (const snippet of params.scripts) {
throwIfAborted();
const runId = crypto.randomUUID();
let stopPromise: Promise<void> | undefined;
let stopped = false;
params.onScriptStart?.(snippet);
const { runId } = await runAutomationScript({
const queueAccepted = runAutomationScript({
runId,
returnWhenQueued: true,
snippet,
sessionId: params.sessionId,
sessionMeta: params.sessionMeta,
});
await waitForScriptRun(runId, { signal: params.signal });
params.onScriptComplete?.(snippet);
const stopThisRun = () => {
if (stopPromise) return stopPromise;
const attempt = queueAccepted.then(async () => {
const result = await stopScriptRun(runId);
if (!result.ok) {
throw new Error(`Connect script run could not be stopped: ${runId}`);
}
stopped = true;
});
stopPromise = attempt;
void attempt.catch(() => {
if (stopPromise === attempt) stopPromise = undefined;
});
return attempt;
};
params.onCancelableRunChange?.(stopThisRun);
const onAbort = () => {
void stopThisRun().catch(() => {});
};
params.signal?.addEventListener('abort', onAbort, { once: true });
try {
await queueAccepted;
throwIfAborted();
await waitForScriptRun(runId, { signal: params.signal });
params.onScriptComplete?.(snippet);
} catch (err) {
if (params.signal?.aborted) {
await stopThisRun();
throw new DOMException('Connect script run cancelled', 'AbortError');
}
throw err;
} finally {
params.signal?.removeEventListener('abort', onAbort);
if (!params.signal?.aborted || stopped) {
params.onCancelableRunChange?.(null);
}
}
}
}
+35 -5
View File
@@ -2,9 +2,9 @@ import assert from "node:assert/strict";
import test from "node:test";
import type { Host } from "../../domain/models";
import { applyGroupDefaults } from "../../domain/groupConfig";
import { prepareSerialConfigForSavedHost } from "../../domain/serialBackspace";
import { buildTelnetDeepLinkConnectionHost } from "../../domain/telnetDeepLink";
import { resolveEffectiveTerminalHost } from "../../domain/terminalHostResolution";
import {
createHostTerminalSession,
createSerialTerminalSession,
@@ -74,7 +74,34 @@ test("serial session factories snapshot effective legacy Backspace behavior", ()
assert.equal(explicitDefaultSession.serialConfig?.backspaceBehavior, "default");
});
test("saved quick-connect hosts can inherit Ctrl-H after moving into a group", () => {
test("workspace host factory creates a complete serial session", () => {
const session = createWorkspaceHostTerminalSession("session-serial", host({
protocol: "serial",
hostname: "COM7",
port: 57600,
username: "",
serialConfig: {
path: "COM7",
baudRate: 57600,
dataBits: 7,
stopBits: 2,
parity: "even",
},
}), "workspace-1");
assert.equal(session.workspaceId, "workspace-1");
assert.equal(session.protocol, "serial");
assert.deepEqual(session.serialConfig, {
path: "COM7",
baudRate: 57600,
dataBits: 7,
stopBits: 2,
parity: "even",
backspaceBehavior: "default",
});
});
test("workspace append snapshots serial Backspace behavior inherited from a group", () => {
const savedHost = host({
protocol: "serial",
hostname: "COM3",
@@ -87,12 +114,15 @@ test("saved quick-connect hosts can inherit Ctrl-H after moving into a group", (
backspaceBehavior: "default",
}),
});
const effectiveHost = applyGroupDefaults(savedHost, {
backspaceBehavior: "ctrl-h",
const effectiveHost = resolveEffectiveTerminalHost({
host: savedHost,
groupConfigs: [{ path: "network", backspaceBehavior: "ctrl-h" }],
proxyProfiles: [],
});
const session = createHostTerminalSession("session-1", effectiveHost);
const session = createWorkspaceHostTerminalSession("session-1", effectiveHost, "workspace-1");
assert.equal(session.workspaceId, "workspace-1");
assert.equal(session.serialConfig?.backspaceBehavior, "ctrl-h");
});
@@ -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,98 @@
const TERMINAL_KEYBOARD_TARGET_SELECTOR =
".xterm, .xterm-helper-textarea, .xterm-screen, .xterm-viewport";
type FocusDocument = Pick<Document, "activeElement" | "addEventListener" | "removeEventListener">;
type FocusWindow = Pick<Window, "addEventListener" | "removeEventListener">;
export const isTerminalKeyboardTarget = (target: EventTarget | null): boolean => {
const element = target as (Element & {
closest?: (selector: string) => Element | null;
}) | null;
return Boolean(element?.closest?.(TERMINAL_KEYBOARD_TARGET_SELECTOR));
};
export function installTerminalKeyboardFocusTracking(
documentRef: FocusDocument,
windowRef: FocusWindow,
onFocusChange: (focused: boolean) => void,
): () => void {
let pendingPointerTarget: EventTarget | null | undefined;
let pendingPointerClearTimer: ReturnType<typeof setTimeout> | undefined;
let isTracking = true;
let windowFocused = true;
const clearPendingPointerTarget = () => {
if (pendingPointerClearTimer !== undefined) {
clearTimeout(pendingPointerClearTimer);
pendingPointerClearTimer = undefined;
}
pendingPointerTarget = undefined;
};
const publishForTarget = (target: EventTarget | null) => {
if (!isTracking) return;
onFocusChange(windowFocused && isTerminalKeyboardTarget(target));
};
const handlePointerDown = (event: Event) => {
clearPendingPointerTarget();
pendingPointerTarget = event.target;
pendingPointerClearTimer = setTimeout(() => {
const pointerTarget = pendingPointerTarget;
clearPendingPointerTarget();
if (pointerTarget !== undefined) {
publishForTarget(documentRef.activeElement);
}
}, 0);
};
const handleFocusIn = (event: Event) => {
clearPendingPointerTarget();
publishForTarget(event.target);
};
const handleFocusOut = () => {
queueMicrotask(() => {
if (!isTracking) return;
const pointerTarget = pendingPointerTarget;
clearPendingPointerTarget();
if (!windowFocused) {
onFocusChange(false);
return;
}
if (pointerTarget !== undefined) {
publishForTarget(documentRef.activeElement);
return;
}
publishForTarget(documentRef.activeElement);
});
};
const handleWindowBlur = () => {
windowFocused = false;
clearPendingPointerTarget();
onFocusChange(false);
};
const handleWindowFocus = () => {
windowFocused = true;
publishForTarget(documentRef.activeElement);
};
publishForTarget(documentRef.activeElement);
documentRef.addEventListener("pointerdown", handlePointerDown, true);
documentRef.addEventListener("focusin", handleFocusIn, true);
documentRef.addEventListener("focusout", handleFocusOut, true);
windowRef.addEventListener("blur", handleWindowBlur);
windowRef.addEventListener("focus", handleWindowFocus);
return () => {
isTracking = false;
documentRef.removeEventListener("pointerdown", handlePointerDown, true);
documentRef.removeEventListener("focusin", handleFocusIn, true);
documentRef.removeEventListener("focusout", handleFocusOut, true);
windowRef.removeEventListener("blur", handleWindowBlur);
windowRef.removeEventListener("focus", handleWindowFocus);
clearPendingPointerTarget();
};
}
@@ -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();
-5
View File
@@ -797,11 +797,6 @@ export const useSessionState = ({
host: Host,
direction: SplitDirection = 'vertical',
): string | null => {
// Serial hosts use a different session constructor; they currently
// only enter workspaces via createSerialSession + drag, so reject
// them here to avoid a partially-constructed session.
if (host.protocol === 'serial') return null;
// Cheap early-exit using the ref when the workspace is clearly
// absent. The authoritative check lives inside the setWorkspaces
// updater below so we also cover the concurrent-close race.
@@ -105,4 +105,8 @@ test('workspace host creation paths use the host session snapshot factory', () =
source,
/createWorkspaceHostTerminalSession\(newSessionId,\s*host,\s*workspaceId\)/,
);
assert.doesNotMatch(
source,
/if \(host\.protocol === 'serial'\) return null/,
);
});
+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}`);
@@ -2,35 +2,58 @@ import assert from 'node:assert/strict';
import test from 'node:test';
import {
getShortcutModifierRequirements,
isShortcutModifierHeld,
shouldReleaseShortcutModifier,
type ShortcutModifierEvent,
} from './useShortcutModifierHeld.ts';
const event = (overrides: Partial<ShortcutModifierEvent> = {}): ShortcutModifierEvent => ({
key: 'a',
metaKey: false,
ctrlKey: false,
altKey: false,
shiftKey: false,
...overrides,
});
test('shortcut modifier state follows the configured Mac or PC scheme', () => {
assert.equal(isShortcutModifierHeld(event({ key: 'Meta' }), 'mac'), true);
assert.equal(isShortcutModifierHeld(event({ metaKey: true }), 'mac'), true);
assert.equal(isShortcutModifierHeld(event({ ctrlKey: true }), 'mac'), false);
test('shortcut modifier requirements follow the effective Mac or PC binding', () => {
const mac = getShortcutModifierRequirements('⌘ + [1...9]', 'mac');
assert.deepEqual(mac, { metaKey: true, ctrlKey: false, altKey: false, shiftKey: false });
assert.equal(isShortcutModifierHeld(event({ metaKey: true }), mac), true);
assert.equal(isShortcutModifierHeld(event({ ctrlKey: true }), mac), false);
assert.equal(isShortcutModifierHeld(event({ key: 'Control' }), 'pc'), true);
assert.equal(isShortcutModifierHeld(event({ ctrlKey: true }), 'pc'), true);
assert.equal(isShortcutModifierHeld(event({ metaKey: true }), 'pc'), false);
assert.equal(isShortcutModifierHeld(event({ metaKey: true, ctrlKey: true }), 'disabled'), false);
const pc = getShortcutModifierRequirements('Alt + Shift + [1...9]', 'pc');
assert.deepEqual(pc, { metaKey: false, ctrlKey: false, altKey: true, shiftKey: true });
assert.equal(isShortcutModifierHeld(event({ altKey: true, shiftKey: true }), pc), true);
assert.equal(isShortcutModifierHeld(event({ altKey: true }), pc), false);
assert.equal(isShortcutModifierHeld(event({ altKey: true, metaKey: true, shiftKey: true }), pc), false);
});
test('shortcut modifier state clears on modifier release or a missed modifier keyup', () => {
assert.equal(shouldReleaseShortcutModifier(event({ key: 'Meta' }), 'mac'), true);
assert.equal(shouldReleaseShortcutModifier(event({ key: 'a', metaKey: true }), 'mac'), false);
assert.equal(shouldReleaseShortcutModifier(event({ key: 'a' }), 'mac'), true);
test('shortcut number preview is unavailable for disabled, non-range, or modifier-free bindings', () => {
assert.equal(getShortcutModifierRequirements('Disabled', 'mac'), null);
assert.equal(getShortcutModifierRequirements('⌘ + K', 'mac'), null);
assert.equal(getShortcutModifierRequirements('[1...9]', 'mac'), null);
assert.equal(getShortcutModifierRequirements('⌘ + [1...9]', 'disabled'), null);
});
assert.equal(shouldReleaseShortcutModifier(event({ key: 'Control' }), 'pc'), true);
assert.equal(shouldReleaseShortcutModifier(event({ key: 'a', ctrlKey: true }), 'pc'), false);
assert.equal(shouldReleaseShortcutModifier(event({ key: 'a' }), 'pc'), true);
test('shortcut modifier state clears when the required combination stops matching', () => {
const requirements = getShortcutModifierRequirements('⌘ + [1...9]', 'mac');
assert.equal(shouldReleaseShortcutModifier(event({ metaKey: true }), requirements), false);
assert.equal(shouldReleaseShortcutModifier(event(), requirements), true);
});
test('shortcut modifier held state tracks exact combo when extras are pressed or released', () => {
const requirements = getShortcutModifierRequirements('⌘ + [1...9]', 'mac');
// Already matching, then an extra modifier: preview must clear.
assert.equal(isShortcutModifierHeld(event({ metaKey: true }), requirements), true);
assert.equal(
isShortcutModifierHeld(event({ metaKey: true, shiftKey: true }), requirements),
false,
);
// Extra pressed first, then released while required modifiers remain: preview re-arms.
assert.equal(
isShortcutModifierHeld(event({ metaKey: true, altKey: true }), requirements),
false,
);
assert.equal(isShortcutModifierHeld(event({ metaKey: true }), requirements), true);
});
+75 -33
View File
@@ -1,51 +1,93 @@
import { useEffect, useState } from 'react';
import { useEffect, useMemo, useState } from 'react';
import type { HotkeyScheme } from '../../domain/models/keyBindings';
import { parseKeyCombo, type HotkeyScheme } from '../../domain/models/keyBindings';
export type ShortcutModifierEvent = Pick<KeyboardEvent, 'key' | 'metaKey' | 'ctrlKey'>;
export type ShortcutModifierEvent = Pick<KeyboardEvent, 'metaKey' | 'ctrlKey' | 'altKey' | 'shiftKey'>;
function getModifierKey(scheme: Exclude<HotkeyScheme, 'disabled'>): 'Meta' | 'Control' {
return scheme === 'mac' ? 'Meta' : 'Control';
export type ShortcutModifierRequirements = Readonly<ShortcutModifierEvent>;
const MODIFIER_TOKENS = {
mac: {
'⌘': 'metaKey',
'⌃': 'ctrlKey',
'⌥': 'altKey',
Shift: 'shiftKey',
},
pc: {
Ctrl: 'ctrlKey',
Alt: 'altKey',
Shift: 'shiftKey',
Win: 'metaKey',
},
} as const;
/**
* Resolve the modifier combination for the effective [1...9] binding.
* A non-range or disabled binding cannot reveal tab numbers because the
* dispatcher does not use it for the number-switch action.
*/
export function getShortcutModifierRequirements(
keyBinding: string | null,
scheme: HotkeyScheme,
): ShortcutModifierRequirements | null {
if (scheme === 'disabled' || !keyBinding || !keyBinding.includes('[1...9]')) return null;
const parsed = parseKeyCombo(keyBinding);
if (!parsed || parsed.modifiers.length === 0) return null;
const requirements: Record<keyof ShortcutModifierEvent, boolean> = {
metaKey: false,
ctrlKey: false,
altKey: false,
shiftKey: false,
};
const tokenMap = MODIFIER_TOKENS[scheme];
for (const modifier of parsed.modifiers) {
const flag = tokenMap[modifier as keyof typeof tokenMap];
if (!flag) return null;
requirements[flag] = true;
}
return requirements;
}
function hasModifierFlag(event: ShortcutModifierEvent, scheme: Exclude<HotkeyScheme, 'disabled'>): boolean {
return scheme === 'mac' ? event.metaKey : event.ctrlKey;
}
/** Whether a keyboard event indicates that the active shortcut modifier is held. */
/** Whether a keyboard event has exactly the modifiers required by the binding. */
export function isShortcutModifierHeld(
event: ShortcutModifierEvent,
scheme: HotkeyScheme,
requirements: ShortcutModifierRequirements | null,
): boolean {
if (scheme === 'disabled') return false;
return event.key === getModifierKey(scheme) || hasModifierFlag(event, scheme);
if (!requirements) return false;
return event.metaKey === requirements.metaKey
&& event.ctrlKey === requirements.ctrlKey
&& event.altKey === requirements.altKey
&& event.shiftKey === requirements.shiftKey;
}
/** Whether a keyup event means the active shortcut modifier is no longer held. */
/** Whether a keyup event means the required modifier combination is no longer held. */
export function shouldReleaseShortcutModifier(
event: ShortcutModifierEvent,
scheme: HotkeyScheme,
requirements: ShortcutModifierRequirements | null,
): boolean {
if (scheme === 'disabled') return true;
return event.key === getModifierKey(scheme) || !hasModifierFlag(event, scheme);
return !isShortcutModifierHeld(event, requirements);
}
export function useShortcutModifierHeld(scheme: HotkeyScheme): boolean {
export function useShortcutModifierHeld(
keyBinding: string | null,
scheme: HotkeyScheme,
): boolean {
const [isHeld, setIsHeld] = useState(false);
const requirements = useMemo(
() => getShortcutModifierRequirements(keyBinding, scheme),
[keyBinding, scheme],
);
useEffect(() => {
setIsHeld(false);
if (scheme === 'disabled') return;
if (!requirements) return;
const handleKeyDown = (event: KeyboardEvent) => {
if (isShortcutModifierHeld(event, scheme)) {
setIsHeld(true);
}
};
const handleKeyUp = (event: KeyboardEvent) => {
if (shouldReleaseShortcutModifier(event, scheme)) {
setIsHeld(false);
}
// Sync on every modifier transition so extras clear the preview and
// releasing an extra modifier can re-arm when the exact combo remains.
const syncHeldState = (event: KeyboardEvent) => {
setIsHeld(isShortcutModifierHeld(event, requirements));
};
const clearHeldState = () => setIsHeld(false);
const handleVisibilityChange = () => {
@@ -54,18 +96,18 @@ export function useShortcutModifierHeld(scheme: HotkeyScheme): boolean {
}
};
window.addEventListener('keydown', handleKeyDown, true);
window.addEventListener('keyup', handleKeyUp, true);
window.addEventListener('keydown', syncHeldState, true);
window.addEventListener('keyup', syncHeldState, true);
window.addEventListener('blur', clearHeldState);
document.addEventListener('visibilitychange', handleVisibilityChange);
return () => {
window.removeEventListener('keydown', handleKeyDown, true);
window.removeEventListener('keyup', handleKeyUp, true);
window.removeEventListener('keydown', syncHeldState, true);
window.removeEventListener('keyup', syncHeldState, true);
window.removeEventListener('blur', clearHeldState);
document.removeEventListener('visibilitychange', handleVisibilityChange);
};
}, [scheme]);
}, [requirements]);
return isHeld;
}
@@ -0,0 +1,74 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
installTerminalKeyboardFocusTracking,
isTerminalKeyboardTarget,
} from "./terminalKeyboardFocus";
const targetWithClosest = (matches: boolean) => ({
closest: (selector: string) => {
assert.match(selector, /\.xterm/);
return matches ? {} : null;
},
}) as unknown as EventTarget;
test("terminal keyboard target detection recognizes xterm descendants", () => {
assert.equal(isTerminalKeyboardTarget(targetWithClosest(true)), true);
assert.equal(isTerminalKeyboardTarget(targetWithClosest(false)), false);
assert.equal(isTerminalKeyboardTarget(null), false);
});
test("terminal keyboard focus tracking publishes deduplicated focus transitions and cleans up", async () => {
const listeners = new Map<string, (event?: Event) => void>();
const removed: string[] = [];
const body = targetWithClosest(false);
const terminal = targetWithClosest(true);
const documentRef = {
activeElement: body as unknown as Element,
addEventListener: (type: string, listener: (event?: Event) => void) => {
listeners.set(type, listener);
},
removeEventListener: (type: string) => {
removed.push(type);
},
} as unknown as Document;
const windowRef = {
addEventListener: (type: string, listener: (event?: Event) => void) => {
listeners.set(`window:${type}`, listener);
},
removeEventListener: (type: string) => {
removed.push(`window:${type}`);
},
} as unknown as Window;
const changes: boolean[] = [];
const cleanup = installTerminalKeyboardFocusTracking(documentRef, windowRef, (focused) => {
if (changes.at(-1) !== focused) changes.push(focused);
});
assert.deepEqual(changes, [false]);
listeners.get("pointerdown")?.({ target: terminal } as unknown as Event);
listeners.get("focusin")?.({ target: terminal } as unknown as Event);
assert.deepEqual(changes, [false, true]);
(documentRef as unknown as { activeElement: Element }).activeElement = terminal as unknown as Element;
listeners.get("pointerdown")?.({ target: body } as unknown as Event);
await new Promise((resolve) => setTimeout(resolve, 0));
assert.deepEqual(changes, [false, true]);
(documentRef as unknown as { activeElement: Element }).activeElement = body as unknown as Element;
listeners.get("focusout")?.();
await Promise.resolve();
assert.deepEqual(changes, [false, true, false]);
listeners.get("window:blur")?.();
assert.deepEqual(changes, [false, true, false]);
(documentRef as unknown as { activeElement: Element }).activeElement = terminal as unknown as Element;
listeners.get("window:focus")?.();
assert.deepEqual(changes, [false, true, false, true]);
cleanup();
assert.deepEqual(removed.sort(), ["focusin", "focusout", "pointerdown", "window:blur", "window:focus"]);
});
@@ -0,0 +1,33 @@
import { useEffect } from "react";
import { netcattyBridge } from "../../infrastructure/services/netcattyBridge";
import { installTerminalKeyboardFocusTracking } from "./terminalKeyboardFocus";
export function useTerminalKeyboardFocus(enabled = true): void {
useEffect(() => {
if (!enabled) return undefined;
const bridge = netcattyBridge.get();
if (!bridge?.setTerminalKeyboardFocus) return undefined;
let lastPublished: boolean | undefined;
const publish = (focused: boolean) => {
if (focused === lastPublished) return;
lastPublished = focused;
try {
bridge.setTerminalKeyboardFocus?.(focused);
} catch {
// Browser preview or a disposed Electron bridge.
}
};
const cleanupTracking = installTerminalKeyboardFocusTracking(
document,
window,
publish,
);
return () => {
cleanupTracking();
publish(false);
};
}, [enabled]);
}
@@ -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.
+7 -3
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;
@@ -759,7 +759,11 @@ const HostTreeItem: React.FC<HostTreeItemProps> = ({
: -1}
onFocus={() => onActiveTreeItemChange(`host:${host.id}`)}
draggable={!isMultiSelectMode}
onDragStart={(e) => e.dataTransfer.setData("host-id", host.id)}
onDragStart={(e) => {
// copyMove: vault reorder uses move; focus-sidebar append uses copy.
e.dataTransfer.effectAllowed = "copyMove";
e.dataTransfer.setData("host-id", host.id);
}}
onClick={() => {
const action = resolveHostActivateAction({
behavior: normalizedHostClickBehavior,
+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,
});
}}
/>
</>
);
}
+133 -27
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";
@@ -225,8 +226,16 @@ import {
} from "./terminal/terminalRuntimeMount";
import type { CreateXTermRuntimeContext } from "./terminal/runtime/createXTermRuntime";
import { TerminalView } from "./terminal/TerminalView";
import {
cancelConnectAutomationBatch,
createConnectAutomationBatch,
trackConnectAutomationStop,
type ConnectAutomationBatch,
} from "./terminal/connectAutomationBatch";
import {
getInitialTerminalStatus,
resolveTerminalVaultInitialized,
shouldResetConnectAutomationOnReconnect,
shouldSuppressHostStartupCommandOnReconnect,
shouldStartTerminalBackend,
} from "./terminal/restoredSessionGate";
@@ -270,6 +279,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
sessionId,
workspaceId,
restoreState,
vaultInitializedOverride,
pendingInitialCwd,
shellType,
lastCwd,
@@ -359,10 +369,15 @@ const TerminalComponent: React.FC<TerminalProps> = ({
}, []);
// Reactive vault-ready flag so restored panes re-arm boot after hydration
// (module isVaultInitialized() alone is not a React dependency).
const vaultInitialized = useVaultSnapshotField("isVaultInitialized");
const sharedVaultInitialized = useVaultSnapshotField("isVaultInitialized");
const vaultInitialized = resolveTerminalVaultInitialized(
sharedVaultInitialized,
vaultInitializedOverride,
);
const connectScriptsConsumedRef = useRef(false);
const connectScriptsCompletedIdsRef = useRef(new Set<string>());
const connectScriptsInFlightRef = useRef(false);
const connectScriptsBatchRef = useRef<ConnectAutomationBatch | null>(null);
const pendingScriptRunIdRef = useRef<string | null>(null);
const pendingScriptHandledRef = useRef<Snippet | null>(null);
const pendingScriptRef = useRef(pendingScript);
@@ -413,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,
@@ -424,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;
@@ -1603,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;
@@ -1631,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);
}
@@ -1740,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);
}
@@ -2511,9 +2532,19 @@ const TerminalComponent: React.FC<TerminalProps> = ({
if (!shouldEvaluateConnect && !hasPendingWork) return;
if (connectScriptsInFlightRef.current) return;
const batch = createConnectAutomationBatch();
connectScriptsBatchRef.current = batch;
connectScriptsInFlightRef.current = true;
const batchStillActive = () => (
!batch.controller.signal.aborted && connectScriptsBatchRef.current === batch
);
// Defer until xterm has rendered login output and the main-process output tap
// has populated SessionOutputBuffer (avoids waitForPrompt racing an empty buffer).
let timerFired = false;
const timer = window.setTimeout(() => {
timerFired = true;
if (!batchStillActive()) return;
const runPending = Boolean(pendingOne);
const connectQueueNow = connectScriptsConsumedRef.current
? []
@@ -2542,6 +2573,10 @@ const TerminalComponent: React.FC<TerminalProps> = ({
})) {
connectScriptsConsumedRef.current = true;
}
if (connectScriptsBatchRef.current === batch) {
connectScriptsBatchRef.current = null;
connectScriptsInFlightRef.current = false;
}
return;
}
@@ -2550,11 +2585,13 @@ const TerminalComponent: React.FC<TerminalProps> = ({
connectQueueNow.map((item) => item.id).filter((id): id is string => Boolean(id)),
);
connectScriptsInFlightRef.current = true;
void runConnectScriptsSequential({
scripts: scriptsToRun,
sessionId,
signal: batch.controller.signal,
onCancelableRunChange: (stopCurrentRun) => (
trackConnectAutomationStop(batch, stopCurrentRun)
),
sessionMeta: {
connected: true,
name: scriptSessionName,
@@ -2562,6 +2599,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
username: host.username,
},
onScriptComplete: (snippet) => {
if (!batchStillActive()) return;
if (snippet.id && connectIdsInBatch.has(snippet.id)) {
connectScriptsCompletedIdsRef.current.add(snippet.id);
}
@@ -2575,6 +2613,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
},
})
.then(() => {
if (!batchStillActive()) return;
const resolvedAfterRun = resolveConnectScriptsForHost(host, snippets);
const doneAfterRun = resolvedAfterRun.length === 0
|| resolvedAfterRun.every(
@@ -2585,6 +2624,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
}
})
.catch(async (err) => {
if (!batchStillActive()) return;
const message = err instanceof Error ? err.message : String(err);
toast.error(message.includes('Observer mode') ? t('scripts.observer.blocked') : message);
connectScriptsConsumedRef.current = true;
@@ -2600,6 +2640,10 @@ const TerminalComponent: React.FC<TerminalProps> = ({
await runConnectScriptsSequential({
scripts: [pendingScriptToMark],
sessionId,
signal: batch.controller.signal,
onCancelableRunChange: (stopCurrentRun) => (
trackConnectAutomationStop(batch, stopCurrentRun)
),
sessionMeta: {
connected: true,
name: scriptSessionName,
@@ -2607,6 +2651,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
username: host.username,
},
onScriptComplete: (snippet) => {
if (!batchStillActive()) return;
if (snippet.id) {
pendingScriptRunIdRef.current = snippet.id;
} else {
@@ -2615,16 +2660,32 @@ const TerminalComponent: React.FC<TerminalProps> = ({
},
});
} catch (pendingErr) {
if (!batchStillActive()) return;
const pendingMessage = pendingErr instanceof Error ? pendingErr.message : String(pendingErr);
toast.error(pendingMessage.includes('Observer mode') ? t('scripts.observer.blocked') : pendingMessage);
}
})
.finally(() => {
connectScriptsInFlightRef.current = false;
if (
!batch.controller.signal.aborted
&& connectScriptsBatchRef.current === batch
) {
connectScriptsBatchRef.current = null;
connectScriptsInFlightRef.current = false;
}
});
}, 400);
return () => window.clearTimeout(timer);
return () => {
window.clearTimeout(timer);
if (!timerFired && connectScriptsBatchRef.current === batch) {
batch.controller.abort();
connectScriptsBatchRef.current = null;
connectScriptsInFlightRef.current = false;
} else if (statusRef.current !== "connected") {
void cancelConnectAutomationBatch(batch).catch(() => {});
}
};
}, [effectiveTerminalProtocol, host, isPendingScriptAlreadyHandled, moshShellReady, pendingScript, pendingScriptId, scriptSessionName, sessionId, snippets, status, t]);
useEffect(() => {
@@ -3013,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,
@@ -3035,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;
@@ -3249,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);
@@ -3352,20 +3417,8 @@ const TerminalComponent: React.FC<TerminalProps> = ({
return;
}
if (!termRef.current) return;
if (mode === "manual") {
clearAutoReconnect();
prepareRestoredReconnect();
// A clone's first connection can fail (auth/host-key/transport) before the
// inherited `cd` is consumed. prepareRestoredReconnect() just cleared the
// intent for non-restored sessions, so re-arm it here; the callback no-ops
// once the cwd was consumed or when there is no pending inherited cwd.
prepareInitialCwdIntent();
} else {
restoreCwdIntentRef.current = null;
suppressHostStartupCommandRef.current = shouldSuppressHostStartupCommandOnReconnect("automatic");
}
// Claim the retry before awaiting close. A close/cancel/unmount during the
// awaited backend cleanup invalidates this token and stops the continuation.
// Claim the retry before awaiting either script cancellation or backend
// cleanup. A second reconnect/cancel invalidates this continuation.
const retryToken = Symbol("retry");
retryTokenRef.current = retryToken;
reconnectPreparationTokenRef.current = retryToken;
@@ -3376,8 +3429,61 @@ const TerminalComponent: React.FC<TerminalProps> = ({
}
};
const connectAutomationBatch = connectScriptsBatchRef.current;
if (connectAutomationBatch) {
try {
await cancelConnectAutomationBatch(connectAutomationBatch);
} catch (error) {
finishReconnectPreparation();
const message = error instanceof Error ? error.message : String(error);
toast.error(message);
if (mode === "auto" && retryTokenStillCurrent()) {
// Return to disconnected so the existing auto-reconnect loop can
// schedule another attempt, including another exact stop request.
updateStatus("disconnected");
}
return;
}
if (!retryTokenStillCurrent()) {
finishReconnectPreparation();
return;
}
if (connectScriptsBatchRef.current === connectAutomationBatch) {
connectScriptsBatchRef.current = null;
connectScriptsInFlightRef.current = false;
}
}
if (mode === "manual") {
clearAutoReconnect();
// Manual reconnect skips the disconnected status transition that normally
// clears these refs, so onConnect scripts would otherwise stay consumed.
if (shouldResetConnectAutomationOnReconnect(
restoreState === "restored-disconnected" ? "restored" : "manual",
)) {
// The prior batch is now stopped, so its callbacks cannot mutate these
// newly allocated guards for the fresh manual connection.
connectScriptsConsumedRef.current = false;
connectScriptsCompletedIdsRef.current = new Set();
connectScriptsInFlightRef.current = false;
}
prepareRestoredReconnect();
// A clone's first connection can fail (auth/host-key/transport) before the
// inherited `cd` is consumed. prepareRestoredReconnect() just cleared the
// intent for non-restored sessions, so re-arm it here; the callback no-ops
// once the cwd was consumed or when there is no pending inherited cwd.
prepareInitialCwdIntent();
} else {
// An automatic reconnect replaces the transport but remains the same user
// session. Stop old automation above, then preserve the existing policy of
// not starting onConnect scripts again on the replacement transport.
connectScriptsConsumedRef.current = true;
restoreCwdIntentRef.current = null;
suppressHostStartupCommandRef.current = shouldSuppressHostStartupCommandOnReconnect("automatic");
}
try {
await cleanupSession();
await cleanupSession({ retainOwnership: true });
} catch (error) {
finishReconnectPreparation();
throw error;
+2
View File
@@ -220,6 +220,7 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
onCreateWorkspaceFromSessions,
onAddSessionToWorkspace,
onRequestAddToWorkspace,
onAppendHostToWorkspace,
onUpdateSplitSizes,
onSetDraggingSessionId,
onToggleWorkspaceViewMode,
@@ -2107,6 +2108,7 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
onCopySession,
onCopySessionToNewWindow,
onRequestAddToWorkspace,
onAppendHostToWorkspace,
onSessionData,
onSetDraggingSessionId,
onSetWorkspaceFocusedSession,
+4
View File
@@ -141,6 +141,10 @@ test('popup terminals resolve complete host config and pass jump hosts into Term
assert.match(source, /onDeleteSnippets=\{deleteSelectedSnippets\}/);
});
test('popup terminals use their window-local vault readiness', () => {
assert.match(source, /vaultInitializedOverride=\{vaultInitialized\}/);
});
test('popup provider tree mounts the plugin authentication host', () => {
assert.match(source, /import \{ PluginAuthenticationHost \} from '\.\/plugins\/PluginAuthenticationHost';/);
assert.match(
+1
View File
@@ -467,6 +467,7 @@ function TerminalPopupPageInner() {
accentMode={settings.accentMode}
customAccent={settings.customAccent}
terminalSettings={settings.terminalSettings}
vaultInitializedOverride={vaultInitialized}
disableTerminalFontZoom={settings.disableTerminalFontZoom}
sessionId={sessionId}
startupCommand={isAttachMode ? undefined : config.startupCommand}
+8
View File
@@ -361,6 +361,14 @@ test("workspace session drag data is recognized with a dedicated drag type", ()
assert.equal(getWorkspaceSessionDragId(transfer), "session-1");
});
test("TopTabs wires host drops to workspace tabs without replacing tab reorder", () => {
assert.match(topTabsSource, /appendHostFromWorkspaceDrop/);
assert.match(topTabsSource, /workspaceMap\.has\(targetTabId\)/);
assert.match(topTabsSource, /dropEffect = 'copy'/);
assert.match(topTabsSource, /isHostDropTarget=\{hostDropWorkspaceId === workspace\.id\}/);
assert.match(appViewSource, /<TopTabs[\s\S]*?onAppendHostToWorkspace=\{handleAppendHostToWorkspace\}/);
});
test("workspace session drag id falls back to the legacy session id", () => {
const transfer = {
types: ["session-id"],
+62 -5
View File
@@ -7,7 +7,11 @@ import { buildTabShortcutNumberById } from '../application/app/tabShortcutTarget
import { useShortcutModifierHeld } from '../application/state/useShortcutModifierHeld';
import type { EditorTabChrome } from '../application/state/editorTabStore';
import { collectSessionIds } from '../domain/workspace';
import type { DynamicTabTitleMode } from '../domain/models';
import {
appendHostFromWorkspaceDrop,
resolveFocusSidebarDragKind,
} from '../domain/focusSidebarHostDrop';
import type { DynamicTabTitleMode, KeyBinding } from '../domain/models';
import { getTopTabInsertionTarget, getWorkspaceSessionDragId, hasWorkspaceSessionDrag } from '../application/state/terminalDragData';
import {
@@ -156,8 +160,10 @@ interface TopTabsProps {
sessionId: string,
tabInsertionTarget?: { tabId: string; position: 'before' | 'after'; additionalTabIds?: readonly string[] },
) => void;
onAppendHostToWorkspace?: (workspaceId: string, hostId: string) => void;
showSftpTab: boolean;
showHostTreeSidebar: boolean;
switchTabKeyBinding: Pick<KeyBinding, 'mac' | 'pc'> | null;
dynamicTabTitleMode?: DynamicTabTitleMode;
editorTabs: readonly EditorTabChrome[];
pluginViewTabs: readonly PluginViewTab[];
@@ -200,8 +206,10 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
onEndSessionDrag,
onReorderTabs,
onRemoveSessionFromWorkspace,
onAppendHostToWorkspace,
showSftpTab,
showHostTreeSidebar,
switchTabKeyBinding,
dynamicTabTitleMode,
editorTabs,
pluginViewTabs,
@@ -216,7 +224,12 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
showTabNumberBadges,
shellOnlyTabNumberShortcuts,
} = useSettingsChromeStore();
const shortcutModifierHeld = useShortcutModifierHeld(hotkeyScheme);
const switchTabKey = hotkeyScheme === 'mac'
? switchTabKeyBinding?.mac ?? null
: hotkeyScheme === 'pc'
? switchTabKeyBinding?.pc ?? null
: null;
const shortcutModifierHeld = useShortcutModifierHeld(switchTabKey, hotkeyScheme);
const tabShortcutNumbers = useMemo(() => {
// Keep the tab bar quiet until the modifier for the active shortcut scheme
// is held, while retaining the existing setting as the master toggle.
@@ -253,6 +266,7 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
// Tab reorder drag state
const [dropIndicator, setDropIndicator] = useState<{ tabId: string; position: 'before' | 'after' } | null>(null);
const [isDraggingForReorder, setIsDraggingForReorder] = useState(false);
const [hostDropWorkspaceId, setHostDropWorkspaceId] = useState<string | null>(null);
const draggedTabIdRef = useRef<string | null>(null);
const [isWindowFullscreen, setIsWindowFullscreen] = useState(false);
@@ -514,12 +528,28 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
draggedTabIdRef.current = null;
setDropIndicator(null);
setIsDraggingForReorder(false);
setHostDropWorkspaceId(null);
onEndSessionDrag();
}, [onEndSessionDrag]);
const handleTabDragOver = useCallback((e: React.DragEvent, tabId: string) => {
const dragKind = resolveFocusSidebarDragKind({ types: e.dataTransfer.types });
if (dragKind === 'host-append') {
if (!onAppendHostToWorkspace || !workspaceMap.has(tabId)) {
setHostDropWorkspaceId(null);
return;
}
e.preventDefault();
e.stopPropagation();
e.dataTransfer.dropEffect = 'copy';
setDropIndicator(null);
setHostDropWorkspaceId(tabId);
return;
}
e.preventDefault();
e.dataTransfer.dropEffect = 'move';
setHostDropWorkspaceId(null);
if (hasWorkspaceSessionDrag(e.dataTransfer)) {
setDropIndicator(null);
@@ -537,16 +567,40 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
// Always update drop indicator on drag over to ensure it doesn't get stuck
setDropIndicator({ tabId, position });
}, []);
}, [onAppendHostToWorkspace, workspaceMap]);
const handleTabDragLeave = useCallback((_e: React.DragEvent) => {
const handleTabDragLeave = useCallback((e: React.DragEvent) => {
const next = e.relatedTarget;
if (next instanceof Node && e.currentTarget.contains(next)) return;
setHostDropWorkspaceId(null);
// Don't clear drop indicator on drag leave - let onDragOver manage it
// This prevents the indicator from flickering/disappearing during fast drags
// The indicator will be cleared when drag ends or on drop
}, []);
const handleTabDrop = useCallback((e: React.DragEvent, targetTabId: string) => {
const dragKind = resolveFocusSidebarDragKind({ types: e.dataTransfer.types });
if (dragKind === 'host-append') {
const handled = Boolean(
onAppendHostToWorkspace
&& workspaceMap.has(targetTabId)
&& appendHostFromWorkspaceDrop({
types: e.dataTransfer.types,
getData: (type) => e.dataTransfer.getData(type),
workspaceId: targetTabId,
onAppendHostToWorkspace,
}),
);
setHostDropWorkspaceId(null);
setDropIndicator(null);
if (!handled) return;
e.preventDefault();
e.stopPropagation();
return;
}
e.preventDefault();
setHostDropWorkspaceId(null);
if (hasWorkspaceSessionDrag(e.dataTransfer)) {
const draggedSessionId = getWorkspaceSessionDragId(e.dataTransfer);
const draggedSession = sessions.find((s) => s.id === draggedSessionId);
@@ -575,7 +629,7 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
setDropIndicator(null);
setIsDraggingForReorder(false);
}, [dropIndicator, onEndSessionDrag, onRemoveSessionFromWorkspace, onReorderTabs, sessions, workspaces]);
}, [dropIndicator, onAppendHostToWorkspace, onEndSessionDrag, onRemoveSessionFromWorkspace, onReorderTabs, sessions, workspaceMap, workspaces]);
const handleTabBarDrop = useCallback((e: React.DragEvent) => {
if (!hasWorkspaceSessionDrag(e.dataTransfer)) return;
@@ -865,6 +919,7 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
shiftStyle={shiftStyle}
showDropIndicatorBefore={showDropIndicatorBefore}
showDropIndicatorAfter={showDropIndicatorAfter}
isHostDropTarget={hostDropWorkspaceId === workspace.id}
onTabDragStart={handleTabDragStart}
onTabDragEnd={handleTabDragEnd}
onTabDragOver={handleTabDragOver}
@@ -1201,6 +1256,7 @@ export const topTabsAreEqual = (prev: TopTabsProps, next: TopTabsProps): boolean
prev.onCopySession === next.onCopySession &&
prev.onCopySessionToNewWindow === next.onCopySessionToNewWindow &&
prev.onEditHost === next.onEditHost &&
prev.onAppendHostToWorkspace === next.onAppendHostToWorkspace &&
prev.onCopyWorkspace === next.onCopyWorkspace &&
prev.onOpenSettings === next.onOpenSettings &&
prev.externalMcpEnabled === next.externalMcpEnabled &&
@@ -1213,6 +1269,7 @@ export const topTabsAreEqual = (prev: TopTabsProps, next: TopTabsProps): boolean
prev.onThemeChange === next.onThemeChange &&
prev.showSftpTab === next.showSftpTab &&
prev.showHostTreeSidebar === next.showHostTreeSidebar &&
prev.switchTabKeyBinding === next.switchTabKeyBinding &&
prev.dynamicTabTitleMode === next.dynamicTabTitleMode &&
prev.hostById === next.hostById
);
+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();
@@ -8,6 +8,25 @@ const layoutSource = readFileSync(
"utf8",
);
test("notes tree width resize avoids React setState on every pointermove", () => {
// Dragging the sidebar used to call setTreeWidth per pixel and re-render the
// whole NotesManager (including MDXEditor). Live width must stay on the DOM
// until pointerup commits state + localStorage.
assert.match(managerSource, /treeAsideRef/);
assert.match(managerSource, /requestAnimationFrame/);
assert.match(managerSource, /aside\.style\.width = `\$\{width\}px`/);
assert.match(
managerSource,
/const handlePointerMove = \(moveEvent: PointerEvent\) => \{[\s\S]*?requestAnimationFrame/,
);
assert.doesNotMatch(
managerSource,
/const handlePointerMove = \(moveEvent: PointerEvent\) => \{\s*\n\s*setTreeWidth\(/,
);
assert.match(managerSource, /persistTreeWidth\(nextWidth\)/);
assert.match(managerSource, /isTreeResizing && "pointer-events-none"/);
});
test("note content drafts stay in refs so MDX keystrokes do not rebuild the shell", () => {
assert.doesNotMatch(
managerSource,
+31
View File
@@ -8,6 +8,7 @@ import { I18nProvider } from "../../application/i18n/I18nProvider.tsx";
import type { VaultNote } from "../../types.ts";
import { TooltipProvider } from "../ui/tooltip.tsx";
import {
clampNotesTreeWidth,
getFallbackNoteSelectionState,
getNoteActionTargetGroup,
getNoteGroupSelectionState,
@@ -83,6 +84,36 @@ test("NotesManager balances folder and note tree icon sizes", () => {
assert.match(markup, /width="16" height="16"[^>]*class="lucide lucide-file-text/);
});
test("NotesManager tree scroll area constrains width so titles can truncate", () => {
const markup = renderNotes();
// Radix ScrollArea viewport child is display:table by default; force block +
// min-w-0 so narrowing the notes sidebar ellipsizes long titles instead of
// clipping them. React encodes `&` / `>` in class attributes.
assert.match(
markup,
/data-radix-scroll-area-viewport\](?:&gt;|>)div\]:!block/,
);
assert.match(
markup,
/data-radix-scroll-area-viewport\](?:&gt;|>)div\]:!min-w-0/,
);
assert.match(markup, /data-notes-drop-zone="root"/);
assert.match(markup, /min-h-full min-w-0 space-y-1 overflow-hidden/);
// Aside stays unclipped so the resize handle's outer half remains hit-testable;
// truncation is enforced by the inner tree shell + ScrollArea constraints.
assert.match(markup, /aside class="[^"]*min-w-0[^"]*flex-col bg-background/);
assert.match(markup, /flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden/);
assert.match(markup, /role="separator"/);
assert.match(markup, /translate-x-1\/2 cursor-col-resize/);
});
test("clampNotesTreeWidth keeps the sidebar within the design range", () => {
assert.equal(clampNotesTreeWidth(100), 160);
assert.equal(clampNotesTreeWidth(300), 300);
assert.equal(clampNotesTreeWidth(900), 520);
});
test("NotesManager selection helpers keep note and folder selection exclusive", () => {
const notes = [note(), note({ id: "note-2", title: "Deploy", group: "Deploy" })];
const noteSelection = getNoteSelectionState(notes[0], false);
+72 -18
View File
@@ -100,11 +100,16 @@ type NotesToolbarPanel = "search" | null;
const toolbarIconButtonClass = "netcatty-tab h-6 w-6 shrink-0 rounded-md p-0 hover:bg-transparent";
const menuItemClass = "flex h-8 w-full items-center rounded-md px-3 text-left text-sm hover:bg-secondary";
const NOTES_TREE_DEFAULT_WIDTH = 300;
const NOTES_TREE_MIN_WIDTH = 220;
/** Narrow enough for nested folders + ellipsis; toolbar scrolls if needed. */
const NOTES_TREE_MIN_WIDTH = 160;
const NOTES_TREE_MAX_WIDTH = 520;
const NOTE_DRAG_TYPE = "application/x-netcatty-note-id";
const NOTE_GROUP_DRAG_TYPE = "application/x-netcatty-note-group-path";
export function clampNotesTreeWidth(value: number): number {
return Math.max(NOTES_TREE_MIN_WIDTH, Math.min(NOTES_TREE_MAX_WIDTH, value));
}
export const normalizeNoteEditorMode = (value: string | null): NoteEditorMode | null =>
value === "edit" || value === "preview" ? value : null;
@@ -388,6 +393,9 @@ export const NotesManager: React.FC<NotesManagerProps> = ({
NOTES_TREE_DEFAULT_WIDTH,
{ min: NOTES_TREE_MIN_WIDTH, max: NOTES_TREE_MAX_WIDTH },
);
const treeAsideRef = useRef<HTMLElement | null>(null);
const treeWidthRef = useRef(treeWidth);
treeWidthRef.current = treeWidth;
const searchInputRef = useRef<HTMLInputElement>(null);
const importFileInputRef = useRef<HTMLInputElement>(null);
const isImportingMarkdownRef = useRef(false);
@@ -1232,7 +1240,7 @@ export const NotesManager: React.FC<NotesManagerProps> = ({
return (
<div
key={`new-folder-${parent || "root"}`}
className="flex h-7 items-center px-2 text-sm"
className="flex h-7 min-w-0 items-center px-2 text-sm"
style={{ paddingLeft: depth * 16 + 4 }}
>
<div className="mr-1 h-5 w-4 shrink-0" />
@@ -1444,23 +1452,53 @@ export const NotesManager: React.FC<NotesManagerProps> = ({
event.stopPropagation();
const startX = event.clientX;
const startWidth = treeWidth;
const startWidth = treeWidthRef.current;
const previousCursor = document.body.style.cursor;
const previousUserSelect = document.body.style.userSelect;
const aside = treeAsideRef.current;
let frame = 0;
let latestWidth = startWidth;
// Avoid setState on every pointermove — NotesManager owns the MDX editor and
// re-rendering it per pixel makes sidebar resize feel stuck.
setIsTreeResizing(true);
document.body.style.cursor = "col-resize";
document.body.style.userSelect = "none";
if (aside) {
aside.style.willChange = "width";
}
const clampWidth = (value: number) =>
Math.max(NOTES_TREE_MIN_WIDTH, Math.min(NOTES_TREE_MAX_WIDTH, value));
const handlePointerMove = (moveEvent: PointerEvent) => {
setTreeWidth(clampWidth(startWidth + moveEvent.clientX - startX));
const applyWidth = (width: number) => {
latestWidth = width;
if (aside) {
aside.style.width = `${width}px`;
}
};
const handlePointerUp = (upEvent: PointerEvent) => {
const nextWidth = clampWidth(startWidth + upEvent.clientX - startX);
const handlePointerMove = (moveEvent: PointerEvent) => {
const nextWidth = clampNotesTreeWidth(startWidth + moveEvent.clientX - startX);
if (frame) {
latestWidth = nextWidth;
return;
}
latestWidth = nextWidth;
frame = window.requestAnimationFrame(() => {
frame = 0;
applyWidth(latestWidth);
});
};
const handlePointerUp = () => {
if (frame) {
window.cancelAnimationFrame(frame);
frame = 0;
}
const nextWidth = clampNotesTreeWidth(latestWidth);
applyWidth(nextWidth);
if (aside) {
aside.style.willChange = "";
}
treeWidthRef.current = nextWidth;
setTreeWidth(nextWidth);
persistTreeWidth(nextWidth);
setIsTreeResizing(false);
@@ -1474,7 +1512,7 @@ export const NotesManager: React.FC<NotesManagerProps> = ({
window.addEventListener("pointermove", handlePointerMove);
window.addEventListener("pointerup", handlePointerUp);
window.addEventListener("pointercancel", handlePointerUp);
}, [persistTreeWidth, setTreeWidth, treeWidth]);
}, [persistTreeWidth, setTreeWidth]);
return (
<div className="relative flex h-full min-h-0 flex-col overflow-hidden">
@@ -1491,16 +1529,20 @@ export const NotesManager: React.FC<NotesManagerProps> = ({
<div className="flex min-h-0 flex-1">
{shouldShowNotesTree && (
<aside
ref={treeAsideRef}
className={cn(
"relative flex flex-col bg-background",
isSidebarMode ? "min-w-0 flex-1" : "shrink-0 border-r border-border/60",
// min-w-0 on the aside; overflow-hidden stays on the inner tree shell
// so the resize separator's translate-x-1/2 hit area is not clipped.
"relative flex min-w-0 flex-col bg-background",
isSidebarMode ? "flex-1" : "shrink-0 border-r border-border/60",
)}
style={isSidebarMode ? undefined : { width: treeWidth }}
>
<div className="flex-shrink-0">
<div className="flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden">
<div className="min-w-0 flex-shrink-0 overflow-hidden">
<div className={cn(
TERMINAL_SIDE_PANEL_INNER_HEADER_CLASS,
"flex items-center gap-1 border-b border-border/60 px-1.5",
"flex min-w-0 items-center gap-1 overflow-x-auto border-b border-border/60 px-1.5 [scrollbar-width:none] [&::-webkit-scrollbar]:hidden",
)}>
<Tooltip>
<TooltipTrigger asChild>
@@ -1645,9 +1687,13 @@ export const NotesManager: React.FC<NotesManagerProps> = ({
</div>
</div>
</div>
<ScrollArea className="flex-1">
{/* Radix ScrollArea viewport child defaults to display:table, which
expands to content width and blocks title ellipsis on narrow trees.
Force block + min-w-0 so rows shrink with the sidebar (same pattern
as AsidePanelContent). */}
<ScrollArea className="min-w-0 flex-1 [&>[data-radix-scroll-area-viewport]>div]:!block [&>[data-radix-scroll-area-viewport]>div]:!min-w-0">
<div
className="min-h-full space-y-1 px-1.5 pt-1.5 pb-4"
className="min-h-full min-w-0 space-y-1 overflow-hidden px-1.5 pt-1.5 pb-4"
data-notes-drop-zone="root"
onDragOver={(event) => {
if (!hasNotesTreeDrag(event.dataTransfer)) return;
@@ -1667,6 +1713,7 @@ export const NotesManager: React.FC<NotesManagerProps> = ({
)}
</div>
</ScrollArea>
</div>
{!isSidebarMode && (
<div
role="separator"
@@ -1685,7 +1732,14 @@ export const NotesManager: React.FC<NotesManagerProps> = ({
)}
{!isSidebarMode && (
<main className="flex min-w-0 flex-1 flex-col bg-background">
<main
className={cn(
"flex min-w-0 flex-1 flex-col bg-background",
// Keep the editor out of hit-testing / paint thrash while the tree
// width is driven from the DOM during resize.
isTreeResizing && "pointer-events-none",
)}
>
{selectedNoteView ? (
<>
<div className="flex min-h-[54px] shrink-0 items-center gap-3 px-8 pt-6 pb-1" data-note-title-row>
+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)}
@@ -13,3 +13,10 @@ test("hidden selected theme uses the normal theme item row", () => {
assert.match(source, /hiddenSelectedTheme && \(\s*<ThemeItem/);
assert.doesNotMatch(source, /terminal\.hiddenTheme\.title[\s\S]*terminal\.hiddenTheme\.desc/);
});
test("theme side panel tabs are vertically centered in the header", () => {
assert.match(
source,
/TERMINAL_SIDE_PANEL_INNER_HEADER_CLASS, 'flex items-center px-1\.5 gap-0\.5 border-b'/,
);
});
+1 -1
View File
@@ -311,7 +311,7 @@ const ThemeSidePanelInner: React.FC<ThemeSidePanelProps> = ({
>
{/* Tab Bar */}
<div
className={cn(TERMINAL_SIDE_PANEL_INNER_HEADER_CLASS, 'flex px-1.5 gap-0.5 border-b')}
className={cn(TERMINAL_SIDE_PANEL_INNER_HEADER_CLASS, 'flex items-center px-1.5 gap-0.5 border-b')}
style={{ borderColor: 'var(--terminal-panel-border)' }}
>
<button
@@ -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,
Loaded 100 of 232 files, more files were not shown because too many files have changed in this diff. Show more