Compare commits

..
Author SHA1 Message Date
bincxz bd111b1264 perf(terminal): keep quiet catch-up to a single viewport paint
Catch-up already refreshes visible slices. A trailing recolorVisible()
caused a second settle paint and tripped the atomic-repaint gate.
2026-08-13 21:26:02 +08:00
bincxz 891bce6551 perf(terminal): treat pinned-bottom recycle as output scroll
Once scrollback is full, baseY and viewportY stay put while rows recycle.
That was treated as a user scroll, so every flood write rematched the
visible area. Keep pinned-to-bottom updates off the rematch path.
2026-08-13 20:55:16 +08:00
bincxz cfc4984ad2 perf(terminal): stamp no-match rows without cell snapshots
Catch-up only needs a generation and fingerprint when a line has no
keyword. Allocate typed-array originals when a cell is actually colored.
2026-08-13 20:33:47 +08:00
bincxz 2f0249f74c fix(terminal): recolor the row retained by clear
xterm clear keeps the cursor row after we restore originals. Recolor that
surviving line so enabled rules still apply until the next write.
2026-08-13 20:20:35 +08:00
bincxz 0a5e3ef57e perf(terminal): avoid flood-path registerMarker after trim
Once a catch-up range exists, later bypassed writes only keep the earliest
bound. A disposed marker collapses to 0 without allocating a new one on
every saturated-scrollback chunk.
2026-08-13 20:12:55 +08:00
bincxz 45ef6c2c9f fix(terminal): rematch flood rows after empty-line stamps
A hot-path \r\n write stamped the following empty cursor row as current.
The next flood chunk reused that BufferLine with ERROR text, and quiet
catch-up skipped it. Fingerprint the stamped content so filled or
recycled identical rows are recolored.
2026-08-13 20:07:06 +08:00
bincxz fd0fe8bbdd fix(terminal): color cells for partial grapheme matches
A match that ends inside a combining cell maps both endpoints to the same
xterm column. Color that cell instead of building an empty range.
2026-08-13 17:52:05 +08:00
bincxz e6e7cb8140 fix(terminal): accept plugin #RRGGBBAA highlight colors
Plugin decorations allow 8-digit hex. Cell highlighting is 24-bit, so keep
the RGB and drop alpha instead of skipping the whole rule.
2026-08-13 17:21:44 +08:00
bincxz bcbe93c831 fix(terminal): rematch recycled rows and cap wrapped catch-up
Quiet catch-up now treats a recycled BufferLine as stale when its
saved highlight colors are gone, so repeated keywords on a full
scrollback still color. Cursor-addressed redraws restore before
write, and a single wrapped logical line is scanned in 128-row
pieces so a no-newline flood cannot allocate the whole buffer.

Related to #2784
Related to #2879
2026-08-13 16:21:16 +08:00
bincxz 2f2ef273c4 fix(ci): stop resetting highlight catch-up on every flood write
The throughput gate was comparing cell-color wrapping to raw xterm
and failing at ~18% on DOM CI. Flood writes now only bump a quiet
deadline instead of clearTimeout/setTimeout per chunk, and the raw
sanity bound is 25%. The product check remains 10% versus main.

Related to #2784
Related to #2879
2026-08-13 16:05:39 +08:00
bincxz 16234dfc44 fix(terminal): keep keyword originals across append, clear, and alt screen
Same-line appends no longer throw away saved colors just because the
line fingerprint changed. Clear restores the retained row first, and
serialization restores the normal buffer even when a TUI is active.
Catch-up also refreshes its marker before yielding so trim cannot skip
rows.

Related to #2784
Related to #2879
2026-08-13 15:57:02 +08:00
bincxz 668744a936 perf(terminal): merge flood writes on a 16ms burst window
Electerm keeps log tails smooth by coalescing attach writes for one
frame after a flush. Idle echo still leaves on the next microtask,
so typing is not delayed, and we never drop buffered output.

Related to #2784
Related to #2879
2026-08-13 15:40:51 +08:00
bincxz bd02de4157 perf(terminal): keep flood and scroll off the highlight hot path
Pinned log tails were rematching the whole viewport on every
output-driven scroll, and every write allocated a marker. Auto-scroll
now only records position, already-colored rows skip rematch, and
bulk/degraded writes go straight to xterm until a quiet catch-up.

Related to #2784
Related to #2879
2026-08-13 15:28:28 +08:00
bincxz ee88adf854 fix(terminal): keep in-place keyword coloring correct under load
Enter no longer restyles the previous row, and write/catch-up
positions now follow xterm markers so saturated scrollback cannot
skip a just-written match. Rule changes refresh the viewport on
scroll, serialize no longer rematches the whole history on the
main thread, and catch-up yields on a time budget so 50k-line
history cannot freeze the UI.

Related to #2784
Related to #2879
2026-08-13 15:13:23 +08:00
bincxz 98dea3e370 fix(terminal): color keywords in place without history rebuild
The previous approach rewrote the stream and replayed a second
terminal, which forced private xterm state snapshots and still
risked flicker-adjacent reset work. Writes now stay pristine and
only dirty cells get a new foreground. Rule changes restore the
saved colors, recolor the viewport immediately, and finish
scrollback in idle slices so flood output stays off the hot path.

Related to #2784
Related to #2879
2026-08-13 14:41:13 +08:00
bincxz afba5acf4b fix(terminal): serialize recolor side effects 2026-08-13 13:48:44 +08:00
bincxz 4176acd2b7 fix(terminal): retain saved colors across recolor 2026-08-13 13:36:51 +08:00
bincxz 6650c3c07d fix(terminal): close recolor state gaps 2026-08-13 13:26:03 +08:00
bincxz 6f81a62e21 fix(terminal): retain live state during recolor 2026-08-13 12:55:11 +08:00
bincxz 65ffa11b73 fix(terminal): preserve protocol state on recolor 2026-08-13 12:39:14 +08:00
bincxz c310931713 fix(terminal): preserve parser state on recolor 2026-08-13 12:10:09 +08:00
bincxz 82286166cf perf(terminal): slice history serialization 2026-08-13 12:02:34 +08:00
bincxz 8a0a0ce2ca fix(terminal): preserve line state across clear 2026-08-13 11:47:39 +08:00
bincxz 63b59504e2 fix(terminal): drain highlights before exit capture 2026-08-13 11:44:51 +08:00
bincxz 673843149b fix(terminal): preserve highlight rebuild ordering 2026-08-13 11:42:29 +08:00
bincxz 9ee4531483 fix(ci): load throughput baseline from PR base 2026-08-13 11:35:47 +08:00
bincxz 3a8a63273b fix(terminal): mirror history wipes in highlighter 2026-08-13 11:31:02 +08:00
bincxz c12d338000 fix(terminal): bound highlight catch-up backlog 2026-08-13 11:22:12 +08:00
bincxz 2662a9f196 perf(terminal): bound deferred highlight work 2026-08-13 10:54:13 +08:00
bincxz dabcec4c2c perf(terminal): defer highlight catch-up under load 2026-08-13 09:58:34 +08:00
netcatty-bot c54b711793 fix: address Codex review on PR #2951 2026-08-12 18:33:50 +00:00
netcatty-bot 6e5896593b fix: address Codex review on PR #2951 2026-08-12 18:07:56 +00:00
bincxz 9d941d0594 fix(terminal): avoid keyword highlight flicker 2026-08-13 01:28:17 +08:00
陈大猫 e38b372d1f Merge pull request #2897 from yintian710/main
feat(vault): 添加动态组路径支持用于自动化脚本
2026-08-12 22:24:12 +08:00
bincxz 189db57625 fix(vault): finish dynamic group targeting 2026-08-12 22:00:18 +08:00
陈大猫 6736f95532 Merge pull request #2949 from atoz03/feat/disk-capacity-t-p-units
feat(terminal): scale mounted-disk capacity into T and P units
2026-08-12 21:21:42 +08:00
atoz03andClaude Opus 5 fc641cb304 feat(terminal): scale mounted-disk capacity into T and P units
Multi-terabyte pools rendered as raw gigabytes (118736.4G), which is hard
to read at a glance. Scale each side of the used/total pair independently
so a mostly-empty pool keeps its used figure (136.37G/6.93T instead of
0.13/6.93T), and emit a single shared suffix when both land in the same
unit so ordinary disks keep the familiar 71.12/878.15G shape.

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

No files matched your search

+11 -2
View File
@@ -21,6 +21,8 @@ jobs:
steps:
- name: Checkout
uses: actions/checkout@v7
with:
fetch-depth: 0
- name: Setup Node
uses: actions/setup-node@v7
@@ -50,11 +52,18 @@ jobs:
- name: Test
run: npm test
- name: Test terminal decoration performance
- name: Test terminal keyword highlight performance
env:
NETCATTY_TERMINAL_PERF_SHOW_WINDOW: "1"
# GitHub-hosted runners do not configure Electron's SUID sandbox helper.
run: xvfb-run -a ./node_modules/.bin/electron --no-sandbox scripts/xterm-decoration-performance.live.test.cjs
run: xvfb-run -a ./node_modules/.bin/electron --no-sandbox scripts/xterm-keyword-highlight-performance.live.test.cjs
- name: Test terminal keyword highlight throughput
env:
NETCATTY_TERMINAL_PERF_MAIN_REF: ${{ github.event.pull_request.base.sha || 'HEAD^' }}
NETCATTY_TERMINAL_PERF_ROUNDS: "3"
NETCATTY_TERMINAL_PERF_SHOW_WINDOW: "1"
run: xvfb-run -a ./node_modules/.bin/electron --no-sandbox scripts/xterm-keyword-highlight-throughput.live.test.cjs
- name: Build
run: npm run build
+2
View File
@@ -200,6 +200,7 @@ export function AppSideEffects() {
readPersistedHosts,
groupConfigs,
updateGroupConfigs,
commitVaultGroupMutation,
} = vaultState;
const hostsRef = useRef(hosts);
@@ -1283,6 +1284,7 @@ export function AppSideEffects() {
groupConfigs,
updateGroupConfigs,
updateManagedSources,
commitVaultGroupMutation,
updatePortForwardingRules: importPortForwardingRules,
startTunnel,
stopTunnel,
+2 -1
View File
@@ -258,7 +258,7 @@ function AppViewInner({ domains }: AppViewProps) {
const {
addShellHistoryEntry, removeShellHistoryEntry, addSessionToWorkspace, addToWorkspaceDialog, appendHostToWorkspace, appendLocalTerminalToWorkspace,
clearAndRemoveSource, clearAndRemoveSources, closeLogView, closeSession, closeTabsBatch, closeWorkspace, commitPluginImporterData, commitVaultImportTransaction, copySessionToNewWindowWithCurrentShell, copySessionWithCurrentShell, copyWorkspaceWithCurrentShell,
clearAndRemoveSource, clearAndRemoveSources, closeLogView, closeSession, closeTabsBatch, closeWorkspace, commitPluginImporterData, commitVaultImportTransaction, commitVaultGroupMutation, copySessionToNewWindowWithCurrentShell, copySessionWithCurrentShell, copyWorkspaceWithCurrentShell,
convertKnownHostToHost, createWorkspaceFromSessions, createWorkspaceFromTargets, createWorkspaceWithHosts,
customGroups, currentTerminalTheme, deepLinkHostDraft, draggingSessionId, effectiveKnownHosts, editorTabs, editorWordWrap, emptyVaultConflict,
followAppTerminalTheme,
@@ -592,6 +592,7 @@ function AppViewInner({ domains }: AppViewProps) {
onUpdateManagedSources={updateManagedSources}
onReadPersistedManagedSources={readPersistedManagedSources}
onCommitVaultImportTransaction={commitVaultImportTransaction}
onCommitVaultGroupMutation={commitVaultGroupMutation}
onClearAndRemoveManagedSource={clearAndRemoveSource}
onClearAndRemoveManagedSources={clearAndRemoveSources}
onUnmanageSource={unmanageSource}
+1
View File
@@ -38,6 +38,7 @@ export function VaultHost() {
removeShellHistoryEntry: actions?.removeShellHistoryEntry,
commitPluginImporterData: actions?.commitPluginImporterData,
commitVaultImportTransaction: actions?.commitVaultImportTransaction,
commitVaultGroupMutation: actions?.commitVaultGroupMutation,
convertKnownHostToHost: actions?.convertKnownHostToHost,
customGroups: vault.customGroups,
deepLinkHostDraft: local.deepLinkHostDraft,
@@ -67,6 +67,7 @@ export function VaultPublisher({ children }: VaultPublisherProps) {
readPersistedManagedSources,
commitPluginImporterData,
commitVaultImportTransaction,
commitVaultGroupMutation,
updateHostDistro,
updateHostLastConnected,
addShellHistoryEntry,
@@ -138,6 +139,7 @@ export function VaultPublisher({ children }: VaultPublisherProps) {
readPersistedManagedSources,
commitPluginImporterData,
commitVaultImportTransaction,
commitVaultGroupMutation,
updateHostDistro,
updateHostLastConnected,
addShellHistoryEntry,
@@ -150,6 +152,7 @@ export function VaultPublisher({ children }: VaultPublisherProps) {
addShellHistoryEntry,
commitPluginImporterData,
commitVaultImportTransaction,
commitVaultGroupMutation,
convertKnownHostToHost,
importOrReuseKey,
readPersistedHosts,
+1
View File
@@ -53,6 +53,7 @@ export const enAiMessages: Messages = {
'ai.providers.apiKey.placeholder': 'Enter API key',
'ai.providers.apiKey.decrypting': 'Decrypting...',
'ai.providers.baseUrl': 'Base URL',
'ai.providers.baseUrl.anthropicHelp': 'Anthropic-compatible: host with or without /v1 (for example https://gateway.example or https://gateway.example/v1). Detection and chat both use /v1/models and /v1/messages.',
'ai.providers.skipTLSVerify': 'Skip TLS certificate verification (for self-signed certs)',
'ai.providers.defaultModel': 'Default Model',
'ai.providers.defaultModel.placeholder': 'e.g. gpt-4o, claude-sonnet-4-20250514',
+4 -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 -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 -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
View File
@@ -568,6 +568,7 @@ export function useSettingsStorageSync({
setShowRecentHostsState,
setShowSftpTabState,
setShellOnlyTabNumberShortcutsState,
setShowTabNumberBadgesState,
setDisableTerminalFontZoomState,
setRestorePreviousSessionState,
setRestoreTerminalCwdState,
@@ -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,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();
+13 -24
View File
@@ -30,7 +30,7 @@ import { buildSftpHostCredentials } from "./sftp/useSftpHostCredentials";
import { useSftpFileWatch } from "./sftp/useSftpFileWatch";
import { useSftpSessionCleanup } from "./sftp/useSftpSessionCleanup";
import { useSftpSessionErrors } from "./sftp/useSftpSessionErrors";
import { ensureRemoteSftpSession } from "./sftp/ensureRemoteSftpSession";
import { ensureRemoteSftpSession, probeSftpSession } from "./sftp/ensureRemoteSftpSession";
import { openTransferSftpSession } from "./sftp/dedicatedTransferResume";
import {
createTransferPoolKeyCache,
@@ -316,9 +316,7 @@ export const useSftpState = (
// Prefer borrowing the live (or parked) terminal SSH transport so MFA is
// not repeated. Transport leases keep the shared conn alive after the
// terminal tab closes until the transfer SFTP lease is returned.
const sourceSessionId = !host.sftpSudo
? resolveTransferSourceSessionId?.(host.id, host)
: undefined;
const sourceSessionId = resolveTransferSourceSessionId?.(host.id, host);
if (sourceSessionId) {
try {
logger.info(
@@ -608,16 +606,7 @@ export const useSftpState = (
forceReconnect: ensureOptions?.forceReconnect,
releaseConnection,
tabId,
probeSession: async (sftpId) => {
// Lightweight liveness check; any session-error from the bridge
// triggers a reconnect in ensureRemoteSftpSession.
if (!bridge?.getSftpHomeDir) return true;
const result = await bridge.getSftpHomeDir(sftpId);
if (result && result.success === false) {
throw new Error(result.error || "SFTP session not found");
}
return true;
},
probeSession: (sftpId) => probeSftpSession(bridge, sftpId),
});
},
resolveConnectedHost: (tabId) => connectedHostByTabIdRef.current.get(tabId) ?? null,
@@ -704,6 +693,12 @@ export const useSftpState = (
const pane = getActivePane(side);
if (!pane?.connection || pane.connection.isLocal) continue;
if (sftpSessionsRef.current.has(pane.connection.id)) continue;
const connectedHost = connectedHostByTabIdRef.current.get(pane.id) ?? null;
const vaultHost = hosts.find((host) => host.id === pane.connection?.hostId) ?? null;
const targetHost = connectedHost && connectedHost !== "local" ? connectedHost : vaultHost;
const resolvedSourceSessionId = targetHost
? resolveBrowseSourceSessionId?.(targetHost.id, targetHost)
: undefined;
try {
await ensureRemoteSftpSession({
side,
@@ -713,16 +708,10 @@ export const useSftpState = (
connect,
resolveConnectedHost: (id) => connectedHostByTabIdRef.current.get(id) ?? null,
resolveHostById: (hostId) => hosts.find((host) => host.id === hostId) ?? null,
resolveSourceSessionId: resolveBrowseSourceSessionId,
probeSession: async (sftpId) => {
const bridge = netcattyBridge.get();
if (!bridge?.getSftpHomeDir) return true;
const result = await bridge.getSftpHomeDir(sftpId);
if (result && result.success === false) {
throw new Error(result.error || "SFTP session not found");
}
return true;
},
resolveSourceSessionId: resolvedSourceSessionId
? () => resolvedSourceSessionId
: resolveBrowseSourceSessionId,
probeSession: (sftpId) => probeSftpSession(netcattyBridge.get(), sftpId),
releaseConnection,
});
logger.info(`[SFTP] Restored browse session on ${side}`);
@@ -5,6 +5,7 @@ import {
resolveVaultAgentEffectiveHost,
resolveVaultAgentNotes,
} from './useVaultAgentBridge';
import type { Host } from '../../domain/models';
import { publishNotesSnapshot } from './notesStore';
type Snapshot = Parameters<typeof haveSameVaultAgentSnapshot>[0];
@@ -28,10 +29,10 @@ describe('haveSameVaultAgentSnapshot', () => {
describe('resolveVaultAgentEffectiveHost', () => {
it('uses the latest snapshotted group defaults', () => {
const host = {
const host: Host = {
id: 'host-1', label: 'Host', hostname: 'host.test', username: 'root',
group: 'production', tags: [], os: 'linux',
} as const;
};
assert.equal(
resolveVaultAgentEffectiveHost(host, [{ path: 'production', protocol: 'telnet' }], []).protocol,
+26 -4
View File
@@ -16,6 +16,10 @@ import {
resolveDefaultKeyPassphraseAliases,
} from '../defaultKeyPassphrases';
import { getNotesActions, getNotesSnapshot, subscribeNotes } from './notesStore';
import type {
VaultGroupMutationResult,
VaultGroupMutationState,
} from '../../domain/vaultGroupMutation';
export interface UseVaultAgentBridgeInput {
hosts: Host[];
@@ -29,12 +33,17 @@ export interface UseVaultAgentBridgeInput {
terminalSettings?: Pick<TerminalSettings, 'keepaliveInterval' | 'keepaliveCountMax'>;
updateHosts: (hosts: Host[]) => void;
updateKeys: (keys: SSHKey[]) => Promise<unknown> | unknown;
updateSnippets: (snippets: Snippet[]) => void;
updateSnippets: (
snippets: Snippet[] | ((current: Snippet[]) => Snippet[]),
) => void;
customGroups: string[];
updateCustomGroups: (groups: string[]) => void;
groupConfigs: GroupConfig[];
updateGroupConfigs: (configs: GroupConfig[]) => void;
updateManagedSources: (sources: ManagedSource[]) => void;
commitVaultGroupMutation: (
mutate: (current: VaultGroupMutationState) => VaultGroupMutationResult,
) => Promise<VaultGroupMutationResult | { ok: false; superseded: true }>;
updatePortForwardingRules: (rules: PortForwardingRule[]) => void;
/** Optional override; defaults to notesStore so App need not subscribe to notes. */
notes?: VaultNote[];
@@ -185,6 +194,17 @@ export function useVaultAgentBridge(input: UseVaultAgentBridgeInput): void {
vaultSnapshotRef.current.hosts = hosts;
current.updateHosts(hosts);
},
commitVaultGroupMutation: async (mutate) => {
const result = await current.commitVaultGroupMutation(mutate);
if (result.ok) {
vaultSnapshotRef.current.customGroups = result.state.groups;
vaultSnapshotRef.current.groupConfigs = result.state.configs;
vaultSnapshotRef.current.hosts = result.state.hosts;
vaultSnapshotRef.current.managedSources = result.state.managedSources;
vaultSnapshotRef.current.snippets = result.state.snippets;
}
return result;
},
saveKeyPassphrase: (keyPath, passphrase) => rememberKeyPassphrase({
keyPath,
passphrase,
@@ -222,9 +242,11 @@ export function useVaultAgentBridge(input: UseVaultAgentBridgeInput): void {
vaultSnapshotRef.current.notes = notes;
applyUpdateNotes(notes);
},
updateSnippets: (nextSnippets) => {
vaultSnapshotRef.current.snippets = nextSnippets;
current.updateSnippets(nextSnippets);
updateSnippets: (snippetUpdate) => {
vaultSnapshotRef.current.snippets = typeof snippetUpdate === 'function'
? snippetUpdate(vaultSnapshotRef.current.snippets)
: snippetUpdate;
current.updateSnippets(snippetUpdate);
},
startTunnel: current.startTunnel,
stopTunnel: current.stopTunnel,
@@ -31,19 +31,16 @@ test("group deletion removes saved settings in the same Vault transaction", asyn
managedSources: storedSources,
onReadPersistedHosts: async () => [],
onReadPersistedManagedSources: () => storedSources,
onCommitVaultImportTransaction: async (
_hosts,
updateGroups,
updateSources,
updateGroupConfigs,
) => {
committedGroupConfigs = updateGroupConfigs?.(storedGroupConfigs) ?? storedGroupConfigs;
return {
status: "persisted",
groups: updateGroups(storedGroups),
sources: updateSources(storedSources),
groupConfigs: committedGroupConfigs,
};
onCommitVaultGroupMutation: async (mutate) => {
const result = mutate({
groups: storedGroups,
configs: storedGroupConfigs,
hosts: [],
managedSources: storedSources,
snippets: [],
});
if (result.ok) committedGroupConfigs = result.state.configs;
return result;
},
});
return null;
@@ -102,14 +99,9 @@ test("group deletion restores managed files when the persisted Vault cannot be r
onClearAndRemoveManagedSources: async () => async () => {
restoreCount += 1;
},
onCommitVaultImportTransaction: async () => {
onCommitVaultGroupMutation: async () => {
commitCount += 1;
return {
status: "persisted",
groups: [],
sources: [],
groupConfigs: [],
};
throw new Error("unexpected commit");
},
});
return null;
@@ -187,21 +179,15 @@ test("group deletion retries file clearing when another managed source enters th
restoreCount += 1;
};
},
onCommitVaultImportTransaction: async (
_hosts,
updateGroups,
updateSources,
updateGroupConfigs,
expectedHosts,
) => {
onCommitVaultGroupMutation: async (mutate) => {
commitCount += 1;
assert.deepEqual(expectedHosts, []);
return {
status: "persisted",
groups: updateGroups(["Production"]),
sources: updateSources([firstSource, concurrentSource]),
groupConfigs: updateGroupConfigs?.([]) ?? [],
};
return mutate({
groups: ["Production"],
configs: [],
hosts: [],
managedSources: [firstSource, concurrentSource],
snippets: [],
});
},
});
return null;
@@ -228,3 +214,67 @@ test("group deletion retries file clearing when another managed source enters th
assert.equal(restoreCount, 1);
assert.equal(commitCount, 1);
});
test("group deletion restores managed files and does not report success when commit fails", async () => {
const actEnvironment = globalThis as typeof globalThis & {
IS_REACT_ACT_ENVIRONMENT?: boolean;
};
const previousActEnvironment = actEnvironment.IS_REACT_ACT_ENVIRONMENT;
actEnvironment.IS_REACT_ACT_ENVIRONMENT = true;
const source: ManagedSource = {
id: "managed-production",
type: "ssh_config",
filePath: "/tmp/managed-production.conf",
groupName: "Production",
lastSyncedAt: 1,
};
let deleteGroups: ((paths: Iterable<string>) => Promise<void>) | undefined;
let renderer: ReactTestRenderer | null = null;
let restoreCount = 0;
let reportedCount = 0;
const Probe = () => {
deleteGroups = useVaultGroupDeletion({
customGroups: ["Production"],
hosts: [],
groupConfigs: [],
managedSources: [source],
onReadPersistedHosts: async () => [],
onReadPersistedManagedSources: () => [source],
onClearAndRemoveManagedSources: async () => async () => {
restoreCount += 1;
},
onCommitVaultGroupMutation: async () => {
throw new Error("Vault quota exhausted");
},
onDeletedPaths: () => {
reportedCount += 1;
},
});
return null;
};
let deletionError: unknown;
try {
await act(async () => {
renderer = create(React.createElement(Probe));
});
try {
await act(async () => {
await deleteGroups?.(["Production"]);
});
} catch (error) {
deletionError = error;
}
} finally {
await act(async () => {
renderer?.unmount();
});
actEnvironment.IS_REACT_ACT_ENVIRONMENT = previousActEnvironment;
}
assert.match(String(deletionError), /quota exhausted/i);
assert.equal(restoreCount, 1);
assert.equal(reportedCount, 0);
});
+44 -53
View File
@@ -1,7 +1,12 @@
import { useCallback, useRef } from "react";
import type { GroupConfig, Host, ManagedSource } from "../../domain/models";
import { removeSnippetTargetGroupPaths } from "../../domain/hostGroupPathMutations";
import { buildVaultGroupDeletion } from "../../domain/vaultGroupDeletion";
import type {
VaultGroupMutationResult,
VaultGroupMutationState,
} from "../../domain/vaultGroupMutation";
import {
type VaultLockHandle,
withVaultImportLock,
@@ -26,7 +31,7 @@ export function useVaultGroupDeletion({
managedSources,
onReadPersistedHosts,
onReadPersistedManagedSources,
onCommitVaultImportTransaction,
onCommitVaultGroupMutation,
onClearAndRemoveManagedSource,
onClearAndRemoveManagedSources,
onDeletedPaths,
@@ -37,22 +42,10 @@ export function useVaultGroupDeletion({
managedSources: ManagedSource[];
onReadPersistedHosts: () => Promise<Host[]>;
onReadPersistedManagedSources: () => ManagedSource[];
onCommitVaultImportTransaction: (
hosts: Host[],
updateGroups: (current: string[]) => string[],
updateSources: (current: ManagedSource[]) => ManagedSource[],
updateGroupConfigs?: (current: GroupConfig[]) => GroupConfig[],
expectedHosts?: Host[],
onCommitVaultGroupMutation: (
mutate: (current: VaultGroupMutationState) => VaultGroupMutationResult,
lock?: VaultLockHandle | null,
) => Promise<
| {
status: "persisted";
groups: string[];
sources: ManagedSource[];
groupConfigs: GroupConfig[];
}
| { status: "superseded" }
>;
) => Promise<VaultGroupMutationResult | { ok: false; superseded: true }>;
onClearAndRemoveManagedSource?: (source: ManagedSource) => Promise<() => Promise<void>>;
onClearAndRemoveManagedSources?: (sources: ManagedSource[]) => Promise<() => Promise<void>>;
onDeletedPaths?: (selectedRoots: string[]) => void;
@@ -120,29 +113,16 @@ export function useVaultGroupDeletion({
}
deletion = refreshedDeletion;
const expectedHosts = latestRef.current.hosts;
const expectedSources = deletion.sourcesToRemove;
const nextHosts = deletion.hosts.filter(
(host) => !additionallyDeletedHostIds.has(host.id),
);
const transaction = await onCommitVaultImportTransaction(
nextHosts,
(currentGroups) => buildVaultGroupDeletion({
selectedPaths,
deleteHosts,
customGroups: currentGroups,
hosts: [],
groupConfigs: [],
managedSources: [],
}).customGroups,
(currentSources) => {
const transaction = await onCommitVaultGroupMutation(
(current) => {
const currentDeletion = buildVaultGroupDeletion({
selectedPaths,
deleteHosts,
customGroups: [],
hosts: [],
groupConfigs: [],
managedSources: currentSources,
customGroups: current.groups,
hosts: current.hosts,
groupConfigs: current.configs,
managedSources: current.managedSources,
});
if (!managedSourceSnapshotsMatch(
expectedSources,
@@ -150,29 +130,40 @@ export function useVaultGroupDeletion({
)) {
throw RETRY_VAULT_GROUP_DELETION;
}
const removedIds = new Set(expectedSources.map((source) => source.id));
return currentSources.filter((source) => !removedIds.has(source.id));
return {
ok: true,
state: {
groups: currentDeletion.customGroups,
hosts: currentDeletion.hosts.filter(
(host) => !additionallyDeletedHostIds.has(host.id),
),
configs: currentDeletion.groupConfigs,
managedSources: current.managedSources.filter(
(source) => !currentDeletion.sourcesToRemove.some(
(removed) => removed.id === source.id,
),
),
snippets: removeSnippetTargetGroupPaths(
current.snippets,
currentDeletion.selectedRoots,
),
},
};
},
(currentGroupConfigs) => buildVaultGroupDeletion({
selectedPaths,
deleteHosts,
customGroups: [],
hosts: [],
groupConfigs: currentGroupConfigs,
managedSources: [],
}).groupConfigs,
expectedHosts,
lock,
);
if (transaction.status === "superseded") {
// Release the lock so concurrent host saves can finish before retry.
if (!transaction.ok && "superseded" in transaction) {
// Release the lock so concurrent saves can finish before retry.
return { status: "retry" as const, restoreManagedFiles };
}
if ("error" in transaction) {
throw new Error(transaction.error);
}
latestRef.current = {
customGroups: transaction.groups,
hosts: nextHosts,
groupConfigs: transaction.groupConfigs,
managedSources: transaction.sources,
customGroups: transaction.state.groups,
hosts: transaction.state.hosts,
groupConfigs: transaction.state.configs,
managedSources: transaction.state.managedSources,
};
return {
status: "done" as const,
@@ -205,7 +196,7 @@ export function useVaultGroupDeletion({
}, [
onClearAndRemoveManagedSource,
onClearAndRemoveManagedSources,
onCommitVaultImportTransaction,
onCommitVaultGroupMutation,
onDeletedPaths,
onReadPersistedHosts,
onReadPersistedManagedSources,
@@ -66,7 +66,7 @@ test("updateSnippets disk writes take the shared vault lock", () => {
// and be visible to waitForPendingVaultWrites.
assert.match(
source,
/const updateSnippets = useCallback\(\(\s*data: Snippet\[\],\s*options\?: \{ replace\?: boolean \},\s*\) => \{[\s\S]*withVaultImportLock\("vault", async \(\) => \{[\s\S]*localStorageAdapter\.write\(STORAGE_KEY_SNIPPETS, rebased\)/,
/const updateSnippets = useCallback\(\([\s\S]*withVaultImportLock\("vault", async \(\) => \{[\s\S]*localStorageAdapter\.write\(STORAGE_KEY_SNIPPETS, rebased\)/,
);
assert.match(
source,
@@ -95,6 +95,24 @@ test("updateSnippets rebases onto the latest persisted snapshot under the lock",
);
});
test("updateSnippets functional updates derive from the latest in-memory snapshot", () => {
// Group deletion can wait for managed-source cleanup. A script created while
// it waits must still be present when the deletion callback removes the old
// group path, so updater callbacks must not derive from the initiating render.
assert.match(
source,
/data: Snippet\[\] \| \(\(current: Snippet\[\]\) => Snippet\[\]\)/,
);
assert.match(
source,
/const updater = typeof data === "function" \? data : null;[\s\S]*const current = snippetsRef\.current;[\s\S]*typeof data === "function" \? data\(current\) : data/,
);
assert.match(
source,
/updater\(rebaseSnippetVaultWrite\(\{\s*base,\s*ours: current,\s*theirs: latestPersisted,\s*\}\)\)/,
);
});
test("clearVaultData replaces snippets without additive rebase", () => {
// Clear All Local Data must not preserve concurrent disk-only snippet adds
// that rebaseSnippetVaultWrite would otherwise keep.
@@ -108,7 +126,7 @@ test("clearVaultData replaces snippets without additive rebase", () => {
);
assert.match(
source,
/replace\s*\?\s*cleaned\s*:\s*rebaseSnippetVaultWrite/,
/replace\s*\?\s*cleaned\s*:\s*updater\s*\?\s*updater\(rebaseSnippetVaultWrite/,
);
assert.match(
source,
@@ -145,7 +163,7 @@ test("updateSnippets keeps the persisted rebase ancestor across superseded saves
);
assert.match(
source,
/const base = snippetsWriteBaseRef\.current \?\? snippetsRef\.current/,
/const current = snippetsRef\.current;\s*const base = snippetsWriteBaseRef\.current \?\? current/,
);
assert.match(
source,
+150 -21
View File
@@ -106,10 +106,15 @@ import {
persistVaultImportMetadata,
readStoredArray,
} from "./vaultImportPersistence";
import type {
VaultGroupMutationResult,
VaultGroupMutationState,
} from "../../domain/vaultGroupMutation";
import {
commitPluginImporterTransaction,
recoverPluginImporterTransaction,
} from "./pluginImporterTransaction";
import { commitVaultGroupMutationPersistence } from "./vaultGroupMutationPersistence";
type ExportableVaultData = {
hosts: Host[];
@@ -296,6 +301,7 @@ export const useVaultState = () => {
const managedSourcesRef = useRef<ManagedSource[]>([]);
const hostsRef = useRef<Host[]>([]);
const snippetsRef = useRef<Snippet[]>([]);
const groupConfigsRef = useRef<GroupConfig[]>([]);
const notesRef = useRef<VaultNote[]>([]);
const noteGroupsRef = useRef<string[]>([]);
const notesPersistFailureNotifiedAtRef = useRef(0);
@@ -303,6 +309,7 @@ export const useVaultState = () => {
managedSourcesRef.current = managedSources;
hostsRef.current = hosts;
snippetsRef.current = snippets;
groupConfigsRef.current = groupConfigs;
notesRef.current = notes;
noteGroupsRef.current = noteGroups;
@@ -314,6 +321,7 @@ export const useVaultState = () => {
const identitiesWriteVersion = useRef(0);
const proxyProfilesWriteVersion = useRef(0);
const snippetsWriteVersion = useRef(0);
const managedSourcesWriteVersion = useRef(0);
// Tracks the latest local updateSnippets schedule. Storage events also bump
// snippetsWriteVersion (to invalidate naive writers), so queued saves must
// key supersede checks off this owner instead of that shared counter.
@@ -340,6 +348,7 @@ export const useVaultState = () => {
const identitiesWritePendingRef = useRef<Promise<unknown>>(Promise.resolve());
const groupConfigsWritePendingRef = useRef<Promise<unknown>>(Promise.resolve());
const snippetsWritePendingRef = useRef<Promise<unknown>>(Promise.resolve());
const managedSourcesWritePendingRef = useRef<Promise<unknown>>(Promise.resolve());
const waitForPendingVaultWrites = useCallback(async () => {
while (true) {
@@ -365,6 +374,7 @@ export const useVaultState = () => {
identitiesWritePendingRef.current,
groupConfigsWritePendingRef.current,
snippetsWritePendingRef.current,
managedSourcesWritePendingRef.current,
];
await Promise.all(writePending);
if (
@@ -373,6 +383,7 @@ export const useVaultState = () => {
&& writePending[2] === identitiesWritePendingRef.current
&& writePending[3] === groupConfigsWritePendingRef.current
&& writePending[4] === snippetsWritePendingRef.current
&& writePending[5] === managedSourcesWritePendingRef.current
&& encryptPending[0] === hostsEncryptPendingRef.current
&& encryptPending[1] === keysEncryptPendingRef.current
&& encryptPending[2] === identitiesEncryptPendingRef.current
@@ -643,7 +654,7 @@ export const useVaultState = () => {
}, []);
const updateSnippets = useCallback((
data: Snippet[],
data: Snippet[] | ((current: Snippet[]) => Snippet[]),
options?: { replace?: boolean },
) => {
// Capture the pre-update snapshot for a 3-way rebase once we hold the lock.
@@ -653,7 +664,9 @@ export const useVaultState = () => {
// second edit does not rebase against the first save's optimistic memory.
const replace =
options?.replace === true || snippetsWriteReplaceRef.current;
const base = snippetsWriteBaseRef.current ?? snippetsRef.current;
const updater = typeof data === "function" ? data : null;
const current = snippetsRef.current;
const base = snippetsWriteBaseRef.current ?? current;
if (options?.replace === true) {
snippetsWriteReplaceRef.current = true;
}
@@ -667,7 +680,9 @@ export const useVaultState = () => {
} else if (snippetsWriteBaseRef.current === null) {
snippetsWriteBaseRef.current = base;
}
const cleaned = normalizeVaultOrder(data);
const cleaned = normalizeVaultOrder(
typeof data === "function" ? data(current) : data,
);
const ver = ++snippetsWriteVersion.current;
snippetsWriteOwnerRef.current = ver;
// Keep live snapshot ahead of React commit so same-tick readers (delete,
@@ -681,20 +696,25 @@ export const useVaultState = () => {
// skips the additive rebase so a concurrent disk-only add cannot survive.
const writePromise = withVaultImportLock("vault", async () => {
if (snippetsWriteOwnerRef.current !== ver) return "superseded" as const;
const rebased = normalizeVaultOrder(
replace
? cleaned
const latestPersisted = normalizeVaultOrder(
readStoredArray<Snippet>(
STORAGE_KEY_SNIPPETS,
localStorageAdapter.readString(STORAGE_KEY_SNIPPETS),
),
);
const rebased = normalizeVaultOrder(replace
? cleaned
: updater
? updater(rebaseSnippetVaultWrite({
base,
ours: current,
theirs: latestPersisted,
}))
: rebaseSnippetVaultWrite({
base,
ours: cleaned,
theirs: normalizeVaultOrder(
readStoredArray<Snippet>(
STORAGE_KEY_SNIPPETS,
localStorageAdapter.readString(STORAGE_KEY_SNIPPETS),
),
),
}),
);
theirs: latestPersisted,
}));
const persisted = localStorageAdapter.write(STORAGE_KEY_SNIPPETS, rebased);
if (!persisted) {
// Keep the pre-update ancestor. Clearing it here would make the next
@@ -852,6 +872,7 @@ export const useVaultState = () => {
setCustomGroups(next);
const cleanedGroupConfigs = buildGroupConfigsForGroups(next, groupConfigs);
groupConfigsRef.current = cleanedGroupConfigs;
setGroupConfigs(cleanedGroupConfigs);
const configsVer = ++groupConfigsWriteVersion.current;
const encryptPromise = encryptGroupConfigs(cleanedGroupConfigs);
@@ -880,13 +901,105 @@ export const useVaultState = () => {
const next = typeof data === "function" ? data(managedSourcesRef.current) : data;
managedSourcesRef.current = next;
setManagedSources(next);
void withVaultImportLock("vault", async () => {
const ver = ++managedSourcesWriteVersion.current;
const writePromise = withVaultImportLock("vault", async () => {
// Latest ref wins if another update ran while waiting for the lock.
if (managedSourcesRef.current !== next) return;
localStorageAdapter.write(STORAGE_KEY_MANAGED_SOURCES, next);
if (ver !== managedSourcesWriteVersion.current) return "superseded" as const;
return localStorageAdapter.write(STORAGE_KEY_MANAGED_SOURCES, next)
? "written" as const
: "failed" as const;
});
managedSourcesWritePendingRef.current = writePromise;
return writePromise;
}, []);
const commitVaultGroupMutation = useCallback(async (
mutate: (current: VaultGroupMutationState) => VaultGroupMutationResult,
lock?: VaultLockHandle | null,
): Promise<VaultGroupMutationResult | { ok: false; superseded: true }> => {
const captureVersions = () => ({
hosts: hostsWriteVersion.current,
snippets: snippetsWriteVersion.current,
groups: customGroupsWriteVersion.current,
configs: groupConfigsWriteVersion.current,
sources: managedSourcesWriteVersion.current,
});
const versionsAreCurrent = (versions: ReturnType<typeof captureVersions>) => (
versions.hosts === hostsWriteVersion.current
&& versions.snippets === snippetsWriteVersion.current
&& versions.groups === customGroupsWriteVersion.current
&& versions.configs === groupConfigsWriteVersion.current
&& versions.sources === managedSourcesWriteVersion.current
);
const stateMatchesLiveSnapshot = (current: VaultGroupMutationState) => (
JSON.stringify(current.hosts) === JSON.stringify(hostsRef.current)
&& JSON.stringify(current.snippets) === JSON.stringify(snippetsRef.current)
&& JSON.stringify(current.groups) === JSON.stringify(customGroupsRef.current)
&& JSON.stringify(current.configs) === JSON.stringify(groupConfigsRef.current)
&& JSON.stringify(current.managedSources) === JSON.stringify(managedSourcesRef.current)
);
const runCommit = async (
versions: ReturnType<typeof captureVersions>,
): Promise<VaultGroupMutationResult | { ok: false; superseded: true }> => {
const result = await commitVaultGroupMutationPersistence({
storage: localStorageAdapter,
mutate,
prepareState: (state) => {
const groups = Array.from(new Set(state.groups));
return {
groups,
configs: buildGroupConfigsForGroups(groups, state.configs),
hosts: normalizeVaultOrder(state.hosts.map((host) => sanitizeHost(host))),
managedSources: state.managedSources,
snippets: normalizeVaultOrder(state.snippets),
};
},
decryptHosts: async (items) => normalizeVaultOrder(
(await decryptHosts(items)).map((host) => sanitizeHost(host)),
),
decryptConfigs: async (items) => normalizeVaultOrder(
(await decryptGroupConfigs(items)).map(sanitizeGroupConfig),
),
encryptHosts,
encryptConfigs: encryptGroupConfigs,
isCurrent: () => versionsAreCurrent(versions),
validateCurrent: lock ? stateMatchesLiveSnapshot : undefined,
});
if (!result.ok) return result;
const nextState = result.state;
++hostsWriteVersion.current;
++snippetsWriteVersion.current;
snippetsWriteOwnerRef.current = snippetsWriteVersion.current;
++customGroupsWriteVersion.current;
++groupConfigsWriteVersion.current;
++managedSourcesWriteVersion.current;
hostsRef.current = nextState.hosts;
snippetsRef.current = nextState.snippets;
customGroupsRef.current = nextState.groups;
managedSourcesRef.current = nextState.managedSources;
snippetsWriteBaseRef.current = null;
snippetsWriteReplaceRef.current = false;
setHosts(nextState.hosts);
setSnippets(nextState.snippets);
setCustomGroups(nextState.groups);
setManagedSources(nextState.managedSources);
setGroupConfigs(nextState.configs);
groupConfigsRef.current = nextState.configs;
return { ok: true, state: nextState };
};
if (lock) {
const versions = captureVersions();
return withVaultImportLockIfNeeded("vault", () => runCommit(versions), lock);
}
while (true) {
await waitForPendingVaultWrites();
const versions = captureVersions();
const result = await withVaultImportLock("vault", () => runCommit(versions));
if (!("superseded" in result)) return result;
}
}, [waitForPendingVaultWrites]);
const readPersistedManagedSources = useCallback((): ManagedSource[] => (
readStoredArray<ManagedSource>(
STORAGE_KEY_MANAGED_SOURCES,
@@ -996,6 +1109,8 @@ export const useVaultState = () => {
++groupConfigsWriteVersion.current;
customGroupsRef.current = result.groups;
managedSourcesRef.current = result.sources;
hostsRef.current = cleanedHosts;
groupConfigsRef.current = nextGroupConfigs;
setHosts(cleanedHosts);
setCustomGroups(result.groups);
setManagedSources(result.sources);
@@ -1018,6 +1133,7 @@ export const useVaultState = () => {
// sit in memory and re-persist with `fontFamilyOverride: true` until
// the next reload. Mirrors updateHosts → sanitizeHost.
const cleaned = normalizeVaultOrder(data.map(sanitizeGroupConfig));
groupConfigsRef.current = cleaned;
setGroupConfigs(cleaned);
const ver = ++groupConfigsWriteVersion.current;
const encryptPromise = encryptGroupConfigs(cleaned);
@@ -1461,7 +1577,10 @@ export const useVaultState = () => {
const savedManagedSources = localStorageAdapter.read<ManagedSource[]>(
STORAGE_KEY_MANAGED_SOURCES,
);
if (savedManagedSources) setManagedSources(savedManagedSources);
if (savedManagedSources) {
managedSourcesRef.current = savedManagedSources;
setManagedSources(savedManagedSources);
}
// Load group configs
const savedGroupConfigs = localStorageAdapter.read<GroupConfig[]>(STORAGE_KEY_GROUP_CONFIGS);
@@ -1471,6 +1590,7 @@ export const useVaultState = () => {
if (cancelled) return;
if (gcVer === groupConfigsWriteVersion.current) {
const sanitizedGC = normalizeVaultOrder(decryptedGC.map(sanitizeGroupConfig));
groupConfigsRef.current = sanitizedGC;
setGroupConfigs(sanitizedGC);
encryptGroupConfigs(sanitizedGC).then((enc) => {
if (gcVer === groupConfigsWriteVersion.current)
@@ -1641,6 +1761,7 @@ export const useVaultState = () => {
if (key === STORAGE_KEY_GROUPS) {
const next = safeParse<string[]>(event.newValue) ?? [];
++customGroupsWriteVersion.current;
customGroupsRef.current = next;
setCustomGroups(next);
return;
}
@@ -1695,6 +1816,8 @@ export const useVaultState = () => {
if (key === STORAGE_KEY_MANAGED_SOURCES) {
const next = safeParse<ManagedSource[]>(event.newValue) ?? [];
++managedSourcesWriteVersion.current;
managedSourcesRef.current = next;
setManagedSources(next);
return;
}
@@ -1705,8 +1828,11 @@ export const useVaultState = () => {
const seq = ++groupConfigsReadSeq.current;
const writeAtStart = groupConfigsWriteVersion.current;
decryptGroupConfigs(next).then((dec) => {
if (seq === groupConfigsReadSeq.current && writeAtStart === groupConfigsWriteVersion.current)
setGroupConfigs(normalizeVaultOrder(dec.map(sanitizeGroupConfig)));
if (seq === groupConfigsReadSeq.current && writeAtStart === groupConfigsWriteVersion.current) {
const normalized = normalizeVaultOrder(dec.map(sanitizeGroupConfig));
groupConfigsRef.current = normalized;
setGroupConfigs(normalized);
}
});
return;
}
@@ -1794,7 +1920,7 @@ export const useVaultState = () => {
const importData = useCallback(
(payload: Partial<ExportableVaultData>): Promise<void> => {
const encryptedWrites: Promise<void>[] = [];
if (payload.hosts) encryptedWrites.push(updateHosts(payload.hosts));
if (payload.hosts) encryptedWrites.push(updateHosts(payload.hosts).then(() => undefined));
if (payload.keys) encryptedWrites.push(updateKeys(payload.keys));
if (payload.identities) encryptedWrites.push(updateIdentities(payload.identities));
if (Array.isArray(payload.proxyProfiles)) encryptedWrites.push(updateProxyProfiles(payload.proxyProfiles));
@@ -1952,6 +2078,8 @@ export const useVaultState = () => {
]);
customGroupsRef.current = nextGroups;
snippetsRef.current = nextSnippets;
hostsRef.current = nextHosts;
groupConfigsRef.current = nextGroupConfigs;
snippetsWriteBaseRef.current = null;
snippetsWriteReplaceRef.current = false;
setHosts(nextHosts);
@@ -2008,6 +2136,7 @@ export const useVaultState = () => {
updateManagedSources,
readPersistedManagedSources,
commitVaultImportTransaction,
commitVaultGroupMutation,
updateGroupConfigs,
addShellHistoryEntry,
clearShellHistory,
@@ -0,0 +1,220 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import type { Host, Snippet } from '../../domain/models';
import {
remapSnippetTargetGroupPaths,
removeSnippetTargetGroupPaths,
} from '../../domain/hostGroupPathMutations';
import {
STORAGE_KEY_GROUP_CONFIGS,
STORAGE_KEY_GROUPS,
STORAGE_KEY_HOSTS,
STORAGE_KEY_MANAGED_SOURCES,
STORAGE_KEY_SNIPPETS,
} from '../../infrastructure/config/storageKeys';
import { commitVaultGroupMutationPersistence } from './vaultGroupMutationPersistence';
const createStorage = (initial: Record<string, unknown>, failKey?: string) => {
const values = new Map(Object.entries(initial).map(([key, value]) => [key, JSON.stringify(value)]));
return {
values,
read<T>(key: string): T | null {
const value = values.get(key);
return value === undefined ? null : JSON.parse(value) as T;
},
readString: (key: string) => values.get(key) ?? null,
write<T>(key: string, value: T) {
if (key === failKey) return false;
values.set(key, JSON.stringify(value));
return true;
},
writeString(key: string, value: string) {
values.set(key, value);
return true;
},
remove(key: string) {
values.delete(key);
},
};
};
const identity = async <T>(value: T): Promise<T> => value;
const prepareState = <T>(value: T): T => value;
const initialHost: Host = {
id: 'host-1', label: 'Host', hostname: 'host.test', username: 'root',
group: 'prod', tags: [], os: 'linux',
};
const initialSnippet: Snippet = {
id: 'script-1', label: 'Deploy', command: 'nct.log(1)', kind: 'script',
targetGroups: ['prod'],
};
function initialVault() {
return {
[STORAGE_KEY_HOSTS]: [initialHost],
[STORAGE_KEY_GROUPS]: ['prod'],
[STORAGE_KEY_MANAGED_SOURCES]: [],
[STORAGE_KEY_GROUP_CONFIGS]: [{ path: 'prod', username: 'root' }],
[STORAGE_KEY_SNIPPETS]: [initialSnippet],
};
}
test('group rename rolls back every Vault key when the snippet write fails', async () => {
const initial = initialVault();
const storage = createStorage(initial, STORAGE_KEY_SNIPPETS);
await assert.rejects(commitVaultGroupMutationPersistence({
storage,
mutate: (current) => ({
ok: true,
state: {
...current,
groups: ['production'],
configs: current.configs.map((config) => ({ ...config, path: 'production' })),
hosts: current.hosts.map((host) => ({ ...host, group: 'production' })),
snippets: remapSnippetTargetGroupPaths(current.snippets, 'prod', 'production'),
},
}),
prepareState,
decryptHosts: identity,
decryptConfigs: identity,
encryptHosts: identity,
encryptConfigs: identity,
isCurrent: () => true,
}), /rejected importer transaction/);
for (const [key, value] of Object.entries(initial)) {
assert.deepEqual(storage.read(key), value);
}
});
test('group delete rolls back every Vault key when the snippet write fails', async () => {
const initial = initialVault();
const storage = createStorage(initial, STORAGE_KEY_SNIPPETS);
await assert.rejects(commitVaultGroupMutationPersistence({
storage,
mutate: (current) => ({
ok: true,
state: {
...current,
groups: [],
configs: [],
hosts: current.hosts.map((host) => ({ ...host, group: undefined })),
snippets: removeSnippetTargetGroupPaths(current.snippets, ['prod']),
},
}),
prepareState,
decryptHosts: identity,
decryptConfigs: identity,
encryptHosts: identity,
encryptConfigs: identity,
isCurrent: () => true,
}), /rejected importer transaction/);
for (const [key, value] of Object.entries(initial)) {
assert.deepEqual(storage.read(key), value);
}
});
test('group rename preserves a concurrent snippet update from the locked snapshot', async () => {
const concurrentSnippet = { ...initialSnippet, command: 'nct.log(2)' };
const addedSnippet: Snippet = {
id: 'script-2', label: 'Check', command: 'nct.log(3)', kind: 'script',
targetGroups: ['prod/child'],
};
const storage = createStorage({
...initialVault(),
[STORAGE_KEY_SNIPPETS]: [concurrentSnippet, addedSnippet],
});
const result = await commitVaultGroupMutationPersistence({
storage,
mutate: (current) => ({
ok: true,
state: {
...current,
groups: ['production'],
snippets: remapSnippetTargetGroupPaths(current.snippets, 'prod', 'production'),
},
}),
prepareState,
decryptHosts: identity,
decryptConfigs: identity,
encryptHosts: identity,
encryptConfigs: identity,
isCurrent: () => true,
});
assert.equal(result.ok, true);
const saved = storage.read<Snippet[]>(STORAGE_KEY_SNIPPETS) ?? [];
assert.equal(saved[0]?.command, 'nct.log(2)');
assert.deepEqual(saved.map((snippet) => snippet.targetGroups), [
['production'],
['production/child'],
]);
});
test('group delete cleans targets from the latest persisted snippet snapshot', async () => {
const concurrentSnippet = { ...initialSnippet, command: 'nct.log(2)' };
const addedSnippet: Snippet = {
id: 'script-2', label: 'Check', command: 'nct.log(3)', kind: 'script',
targetGroups: ['prod/child', 'staging'],
};
const storage = createStorage({
...initialVault(),
[STORAGE_KEY_SNIPPETS]: [concurrentSnippet, addedSnippet],
});
const result = await commitVaultGroupMutationPersistence({
storage,
mutate: (current) => ({
ok: true,
state: {
...current,
groups: [],
snippets: removeSnippetTargetGroupPaths(current.snippets, ['prod']),
},
}),
prepareState,
decryptHosts: identity,
decryptConfigs: identity,
encryptHosts: identity,
encryptConfigs: identity,
isCurrent: () => true,
});
assert.equal(result.ok, true);
const saved = storage.read<Snippet[]>(STORAGE_KEY_SNIPPETS) ?? [];
assert.equal(saved[0]?.command, 'nct.log(2)');
assert.deepEqual(saved.map((snippet) => snippet.targetGroups), [
[],
['staging'],
]);
});
test('group mutation stops before writing when memory is ahead of persisted Vault state', async () => {
const storage = createStorage(initialVault());
let mutateCalled = false;
const result = await commitVaultGroupMutationPersistence({
storage,
mutate: (current) => {
mutateCalled = true;
return { ok: true, state: current };
},
prepareState,
decryptHosts: identity,
decryptConfigs: identity,
encryptHosts: identity,
encryptConfigs: identity,
isCurrent: () => true,
validateCurrent: (current) => current.snippets[0]?.command === 'nct.log(2)',
});
assert.deepEqual(result, { ok: false, superseded: true });
assert.equal(mutateCalled, false);
assert.deepEqual(storage.read<Snippet[]>(STORAGE_KEY_SNIPPETS), [initialSnippet]);
});
@@ -0,0 +1,97 @@
import type { GroupConfig, Host, ManagedSource, Snippet } from '../../domain/models';
import type {
VaultGroupMutationResult,
VaultGroupMutationState,
} from '../../domain/vaultGroupMutation';
import {
STORAGE_KEY_GROUP_CONFIGS,
STORAGE_KEY_GROUPS,
STORAGE_KEY_HOSTS,
STORAGE_KEY_MANAGED_SOURCES,
STORAGE_KEY_SNIPPETS,
} from '../../infrastructure/config/storageKeys';
import { commitPluginImporterTransaction } from './pluginImporterTransaction';
import { readStoredArray } from './vaultImportPersistence';
type TransactionStorage = {
read<T>(key: string): T | null;
readString(key: string): string | null;
write<T>(key: string, value: T): boolean;
writeString(key: string, value: string): boolean;
remove(key: string): void;
};
const GROUP_MUTATION_KEYS = [
STORAGE_KEY_HOSTS,
STORAGE_KEY_GROUPS,
STORAGE_KEY_MANAGED_SOURCES,
STORAGE_KEY_GROUP_CONFIGS,
STORAGE_KEY_SNIPPETS,
] as const;
export async function commitVaultGroupMutationPersistence({
storage,
mutate,
prepareState,
decryptHosts,
decryptConfigs,
encryptHosts,
encryptConfigs,
isCurrent,
validateCurrent,
}: {
storage: TransactionStorage;
mutate: (current: VaultGroupMutationState) => VaultGroupMutationResult;
prepareState: (state: VaultGroupMutationState) => VaultGroupMutationState;
decryptHosts: (hosts: Host[]) => Promise<Host[]>;
decryptConfigs: (configs: GroupConfig[]) => Promise<GroupConfig[]>;
encryptHosts: (hosts: Host[]) => Promise<unknown>;
encryptConfigs: (configs: GroupConfig[]) => Promise<unknown>;
isCurrent: () => boolean;
validateCurrent?: (current: VaultGroupMutationState) => boolean;
}): Promise<VaultGroupMutationResult | { ok: false; superseded: true }> {
const raw = new Map(GROUP_MUTATION_KEYS.map((key) => [key, storage.readString(key)]));
const [hosts, configs] = await Promise.all([
decryptHosts(readStoredArray<Host>(STORAGE_KEY_HOSTS, raw.get(STORAGE_KEY_HOSTS) ?? null)),
decryptConfigs(readStoredArray<GroupConfig>(
STORAGE_KEY_GROUP_CONFIGS,
raw.get(STORAGE_KEY_GROUP_CONFIGS) ?? null,
)),
]);
const current: VaultGroupMutationState = {
groups: readStoredArray<string>(STORAGE_KEY_GROUPS, raw.get(STORAGE_KEY_GROUPS) ?? null),
configs,
hosts,
managedSources: readStoredArray<ManagedSource>(
STORAGE_KEY_MANAGED_SOURCES,
raw.get(STORAGE_KEY_MANAGED_SOURCES) ?? null,
),
snippets: readStoredArray<Snippet>(STORAGE_KEY_SNIPPETS, raw.get(STORAGE_KEY_SNIPPETS) ?? null),
};
if (!isCurrent() || (validateCurrent && !validateCurrent(current))) {
return { ok: false, superseded: true };
}
const mutation = mutate(current);
if (!mutation.ok) return mutation;
const nextState = prepareState(mutation.state);
const [encryptedHosts, encryptedConfigs] = await Promise.all([
encryptHosts(nextState.hosts),
encryptConfigs(nextState.configs),
]);
if (
!isCurrent()
|| GROUP_MUTATION_KEYS.some((key) => storage.readString(key) !== raw.get(key))
) {
return { ok: false, superseded: true };
}
commitPluginImporterTransaction(storage, [
[STORAGE_KEY_HOSTS, encryptedHosts],
[STORAGE_KEY_GROUPS, nextState.groups],
[STORAGE_KEY_MANAGED_SOURCES, nextState.managedSources],
[STORAGE_KEY_GROUP_CONFIGS, encryptedConfigs],
[STORAGE_KEY_SNIPPETS, nextState.snippets],
]);
return { ok: true, state: nextState };
}
@@ -12,7 +12,7 @@ export interface VaultHostTreeActions {
onNewGroup: (parentPath?: string) => void;
onRenameGroup: (groupPath: string) => void;
onDeleteGroup: (groupPath: string) => void;
commitInlineGroupRename: (name: string) => void;
commitInlineGroupRename: (name: string) => boolean | void | Promise<boolean | void>;
cancelInlineGroupEdit: () => void;
commitInlineHostRename: (name: string) => void;
cancelInlineHostEdit: () => void;
@@ -81,6 +81,7 @@ test('vault actions register and unregister', () => {
readPersistedManagedSources: noop,
commitPluginImporterData: noop,
commitVaultImportTransaction: noop,
commitVaultGroupMutation: noop,
updateHostDistro: noop,
updateHostLastConnected: noop,
addShellHistoryEntry: noop,
+1
View File
@@ -58,6 +58,7 @@ export type VaultSnapshotActions = Pick<
| 'readPersistedManagedSources'
| 'commitPluginImporterData'
| 'commitVaultImportTransaction'
| 'commitVaultGroupMutation'
| 'updateHostDistro'
| 'updateHostLastConnected'
| 'addShellHistoryEntry'
+2 -2
View File
@@ -290,7 +290,7 @@ interface HostTreeViewProps {
onDeleteGroup: (groupPath: string) => void;
moveHostToGroup: (hostId: string, groupPath: string | null) => void;
moveGroup: (sourcePath: string, targetParent: string | null) => void;
commitInlineGroupRename?: (name: string) => void;
commitInlineGroupRename?: (name: string) => boolean | void | Promise<boolean | void>;
cancelInlineGroupEdit?: () => void;
managedGroupPaths?: Set<string>;
onUnmanageGroup?: (groupPath: string) => void;
@@ -330,7 +330,7 @@ interface TreeNodeProps {
onDeleteGroup: (groupPath: string) => void;
moveHostToGroup: (hostId: string, groupPath: string | null) => void;
moveGroup: (sourcePath: string, targetParent: string | null) => void;
commitInlineGroupRename?: (name: string) => void;
commitInlineGroupRename?: (name: string) => boolean | void | Promise<boolean | void>;
cancelInlineGroupEdit?: () => void;
managedGroupPaths?: Set<string>;
onUnmanageGroup?: (groupPath: string) => void;
+168
View File
@@ -0,0 +1,168 @@
import { CheckSquare, FolderTree, Search, Square } from 'lucide-react';
import React, { useMemo, useState } from 'react';
import { useI18n } from '@/application/i18n/I18nProvider';
import type { Host } from '@/domain/models';
import { Button } from '@/components/ui/button';
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Input } from '@/components/ui/input';
import { FixedSizeVirtualList } from '@/components/ui/FixedSizeVirtualList';
import { cn } from '@/lib/utils';
export interface SelectGroupDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
hosts: Host[];
customGroups?: string[];
selectedGroupPaths: string[];
onSelectionChange: (selectedGroupPaths: string[]) => void;
}
const GROUP_ROW_HEIGHT = 48;
function collectGroupPaths(
hosts: Host[],
customGroups: string[],
selectedGroupPaths: string[],
): string[] {
const paths = new Set<string>();
for (const groupPath of [...customGroups, ...selectedGroupPaths]) {
const parts = groupPath.split('/').filter(Boolean);
for (let index = 1; index <= parts.length; index += 1) {
paths.add(parts.slice(0, index).join('/'));
}
}
for (const host of hosts) {
const parts = host.group?.split('/').filter(Boolean) ?? [];
for (let index = 1; index <= parts.length; index += 1) {
paths.add(parts.slice(0, index).join('/'));
}
}
return [...paths].sort((left, right) => left.localeCompare(right));
}
function countHostsByGroupPath(hosts: Host[]): Map<string, number> {
const counts = new Map<string, number>();
for (const host of hosts) {
if (host.protocol === 'serial') continue;
const parts = host.group?.split('/').filter(Boolean) ?? [];
for (let index = 1; index <= parts.length; index += 1) {
const path = parts.slice(0, index).join('/');
counts.set(path, (counts.get(path) ?? 0) + 1);
}
}
return counts;
}
export const SelectGroupDialog: React.FC<SelectGroupDialogProps> = ({
open,
onOpenChange,
hosts,
customGroups = [],
selectedGroupPaths,
onSelectionChange,
}) => {
const { t } = useI18n();
const [searchQuery, setSearchQuery] = useState('');
const selected = useMemo(() => new Set(selectedGroupPaths), [selectedGroupPaths]);
const groupPaths = useMemo(
() => collectGroupPaths(hosts, customGroups, selectedGroupPaths),
[customGroups, hosts, selectedGroupPaths],
);
const displayedPaths = useMemo(() => {
const query = searchQuery.trim().toLocaleLowerCase();
if (!query) return groupPaths;
return groupPaths.filter((path) => path.toLocaleLowerCase().includes(query));
}, [groupPaths, searchQuery]);
const hostCountByPath = useMemo(() => countHostsByGroupPath(hosts), [hosts]);
const togglePath = (path: string) => {
if (selected.has(path)) {
onSelectionChange(selectedGroupPaths.filter((candidate) => candidate !== path));
return;
}
onSelectionChange([...selectedGroupPaths, path]);
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-md h-[min(78vh,600px)] flex flex-col p-0 gap-0 overflow-hidden">
<DialogHeader className="px-4 pt-4 pb-3 shrink-0 border-b border-border/60">
<DialogTitle className="text-base">{t('snippets.targets.selectGroups')}</DialogTitle>
</DialogHeader>
<div className="px-4 py-3 border-b border-border/60 shrink-0">
<div className="relative">
<Search
size={14}
className="absolute left-3 top-1/2 -translate-y-1/2 text-muted-foreground"
/>
<Input
value={searchQuery}
onChange={(event) => setSearchQuery(event.target.value)}
placeholder={t('common.searchPlaceholder')}
className="h-8 pl-8"
/>
</div>
</div>
<div className="flex-1 min-h-0 p-3">
{displayedPaths.length === 0 ? (
<p className="py-10 text-center text-sm text-muted-foreground">
{t('snippets.targets.noGroups')}
</p>
) : (
<FixedSizeVirtualList
className="h-full"
items={displayedPaths}
itemHeight={GROUP_ROW_HEIGHT}
getItemKey={(path) => path}
renderItem={(path) => {
const isSelected = selected.has(path);
const depth = Math.max(0, path.split('/').filter(Boolean).length - 1);
const label = path.split('/').filter(Boolean).pop() ?? path;
const hostCount = hostCountByPath.get(path) ?? 0;
return (
<button
type="button"
className={cn(
'w-full h-11 flex items-center gap-2.5 rounded-lg px-2.5 text-left transition-colors',
isSelected ? 'bg-primary/10 text-foreground' : 'hover:bg-muted/70',
)}
style={{ paddingLeft: `${10 + depth * 16}px` }}
onClick={() => togglePath(path)}
>
{isSelected ? (
<CheckSquare size={16} className="text-primary shrink-0" />
) : (
<Square size={16} className="text-muted-foreground shrink-0" />
)}
<FolderTree size={16} className="text-primary shrink-0" />
<span className="min-w-0 flex-1">
<span className="block truncate text-sm font-medium">{label}</span>
<span className="block truncate text-[11px] text-muted-foreground">{path}</span>
</span>
<span className="text-[11px] text-muted-foreground shrink-0">
{t('vault.groups.hostsCount', { count: hostCount })}
</span>
</button>
);
}}
/>
)}
</div>
<DialogFooter className="px-4 py-3 border-t border-border/60 shrink-0">
<Button className="w-full" onClick={() => onOpenChange(false)}>
{t('selectHost.continueWithCount', { count: selectedGroupPaths.length })}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
};
+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,
});
}}
/>
</>
);
}
+28 -9
View File
@@ -121,6 +121,7 @@ import { useVaultSnapshotField } from "@/application/state/vaultSnapshotStore.ts
import { netcattyBridge } from "@/infrastructure/services/netcattyBridge.ts";
import { ScriptExecutionOverlay } from "./terminal/ScriptExecutionOverlay";
import { isScriptSnippet } from "@/domain/snippetScript.ts";
import { snippetCanRunInTerminal } from "@/domain/snippetTargets.ts";
import { useOutputTriggers } from "@/application/state/useOutputTriggers.ts";
import { TerminalComposeBar } from "./terminal/TerminalComposeBar";
import { TerminalContextMenu } from "./terminal/TerminalContextMenu";
@@ -427,7 +428,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
const scriptSessionName = sessionDisplayName || host.label;
const outputTriggers = useOutputTriggers({
sessionId,
hostId: host.id,
host,
snippets,
onRunScript: (snippet, sid) => runAutomationScript({
snippet,
@@ -438,7 +439,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
hostname: host.hostname,
username: host.username,
},
}).catch((err) => {
}).then(() => undefined).catch((err) => {
const message = err instanceof Error ? err.message : String(err);
toast.error(message.includes('Observer mode') ? t('scripts.observer.blocked') : message);
throw err;
@@ -1512,6 +1513,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
return;
}
}
await xtermRuntimeRef.current?.keywordHighlighter.prepareForSerialization();
snapshot = serializeAddonRef.current.serialize() || "";
} else if (hibernatedRef.current || softHiddenRef.current) {
// Hibernate path: live xterm is torn down; use retained snapshot.
@@ -1617,7 +1619,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
terminalCwdTracker,
]);
const cleanupSession = async () => {
const cleanupSession = async (options?: { retainOwnership?: boolean }) => {
const closingSessionId = sessionRef.current;
xtermRuntimeRef.current?.flushKittyKeyboardReleases();
sessionRef.current = null;
@@ -1645,7 +1647,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);
}
@@ -1692,6 +1697,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
}
let serializedSnapshot: unknown;
try {
await xtermRuntimeRef.current?.keywordHighlighter.prepareForSerialization();
serializedSnapshot = serializeAddonRef.current?.serialize?.();
} catch (err) {
logger.warn("Failed to serialize terminal snapshot for attach popup", err);
@@ -1754,7 +1760,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);
}
@@ -2046,7 +2055,11 @@ const TerminalComponent: React.FC<TerminalProps> = ({
const snapshot = await serializeTerminalForHibernate(
term,
serializeAddon,
{ preferWasm: resolveHibernatePreferWasmSerialize(terminalSettingsRef.current) },
{
preferWasm: resolveHibernatePreferWasmSerialize(terminalSettingsRef.current),
prepare: () => xtermRuntimeRef.current?.keywordHighlighter.prepareForSerialization()
?? Promise.resolve(),
},
);
if (!canFinishHibernate()) return false;
@@ -2168,6 +2181,8 @@ const TerminalComponent: React.FC<TerminalProps> = ({
termRef,
fitAddonRef,
serializeAddonRef,
prepareKeywordHighlightSerialization: () => xtermRuntimeRef.current
?.keywordHighlighter.prepareForSerialization() ?? Promise.resolve(),
searchAddonRef,
hasRuntimeRef,
}), []);
@@ -3067,6 +3082,10 @@ const TerminalComponent: React.FC<TerminalProps> = ({
const executeSnippet = useCallback(async (snippet: Snippet) => {
if (isScriptSnippet(snippet)) {
if (!snippetCanRunInTerminal(snippet, host)) {
toast.error(t('scripts.targets.currentHostMismatch'));
return;
}
try {
await runAutomationScript({
snippet,
@@ -3089,7 +3108,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
executeSnippetCommand(command, snippet.noAutoRun, {
multiLineRunMode: snippet.multiLineRunMode,
});
}, [executeSnippetCommand, host.hostname, host.username, scriptSessionName, sessionId, t]);
}, [executeSnippetCommand, host, scriptSessionName, sessionId, t]);
const onSnippetShortkeyRef = useRef(executeSnippet);
onSnippetShortkeyRef.current = executeSnippet;
@@ -3303,7 +3322,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
setPendingHostKeyRequestId(null);
setError(null);
setProgressLogs((prev) => [...prev, "Disconnected by user."]);
void cleanupSession();
void cleanupSession({ retainOwnership: true });
updateStatus("disconnected");
setChainProgress(null);
setIsDisconnectedDialogDismissed(false);
@@ -3472,7 +3491,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
}
try {
await cleanupSession();
await cleanupSession({ retainOwnership: true });
} catch (error) {
finishReconnectPreparation();
throw error;
+60 -42
View File
@@ -60,48 +60,66 @@ const renderVault = (sortMode: string | null, hosts: Host[]) => {
return renderToStaticMarkup(
React.createElement(
I18nProvider,
{ locale: "en" },
React.createElement(
TooltipProvider,
null,
React.createElement(VaultView, {
hosts,
keys: [],
identities: [],
proxyProfiles: [],
snippets: [],
snippetPackages: [],
customGroups: [],
knownHosts: [],
managedSources: [],
sessionCount: 0,
hotkeyScheme: "mac",
keyBindings: [],
terminalThemeId: "default",
terminalFontSize: 14,
onOpenSettings: noop,
onOpenQuickSwitcher: noop,
onCreateLocalTerminal: noop,
onDeleteHost: noop,
onConnect: noop,
onUpdateHosts: noop,
onUpdateKeys: noop,
onImportOrReuseKey: () => fallbackKey,
onUpdateIdentities: noop,
onUpdateProxyProfiles: noop,
onUpdateSnippets: noop,
onUpdateSnippetPackages: noop,
onUpdateCustomGroups: noop,
onUpdateKnownHosts: noop,
onUpdateManagedSources: noop,
onConvertKnownHost: noop,
onOpenLogView: noop,
groupConfigs: [],
onUpdateGroupConfigs: noop,
showRecentHosts: false,
showOnlyUngroupedHostsInRoot: false,
}),
),
{
locale: "en",
children: React.createElement(
TooltipProvider,
null,
React.createElement(VaultView, {
hosts,
keys: [],
identities: [],
proxyProfiles: [],
snippets: [],
snippetPackages: [],
customGroups: [],
knownHosts: [],
managedSources: [],
sessionCount: 0,
hotkeyScheme: "mac",
keyBindings: [],
terminalThemeId: "default",
terminalFontSize: 14,
onOpenSettings: noop,
onOpenQuickSwitcher: noop,
onCreateLocalTerminal: noop,
onDeleteHost: noop,
onConnect: noop,
onUpdateHosts: noop,
onUpdateKeys: noop,
onImportOrReuseKey: () => fallbackKey,
onUpdateIdentities: noop,
onUpdateProxyProfiles: noop,
onUpdateSnippets: noop,
onUpdateSnippetPackages: noop,
onUpdateCustomGroups: noop,
onUpdateKnownHosts: noop,
onUpdateManagedSources: noop,
onReadPersistedHosts: async () => hosts,
onReadPersistedManagedSources: () => [],
onCommitPluginImporterData: async () => 0,
onCommitVaultImportTransaction: async () => ({
status: "persisted" as const,
groups: [],
sources: [],
groupConfigs: [],
}),
onCommitVaultGroupMutation: async (mutate) => mutate({
groups: [],
configs: [],
hosts,
managedSources: [],
snippets: [],
}),
onConvertKnownHost: noop,
onOpenLogView: noop,
groupConfigs: [],
onUpdateGroupConfigs: noop,
showRecentHosts: false,
showOnlyUngroupedHostsInRoot: false,
}),
),
},
),
);
};
+91 -113
View File
@@ -52,6 +52,10 @@ import type { VaultLockHandle } from "../application/state/vaultManagedImportLoc
import { useTreeExpandedState } from "../application/state/useTreeExpandedState";
import { useVaultGroupDeletion } from "../application/state/useVaultGroupDeletion";
import type { VaultHostPersistenceResult } from "../application/state/vaultImportProgress";
import type {
VaultGroupMutationResult,
VaultGroupMutationState,
} from "../domain/vaultGroupMutation";
import type { PluginImporterCommitRequest } from "../application/state/usePluginImporterCommit";
import { buildVaultCsvCredentialOptions } from "../application/vaultCsvExportCredentials";
import { sanitizeCredentialValue } from "../domain/credentials";
@@ -69,6 +73,9 @@ import {
upsertHostById,
} from "../domain/host";
import { exportHostsToCsvWithStats } from "../domain/vaultImport";
import {
remapSnippetTargetGroupPaths,
} from "../domain/hostGroupPathMutations";
import {
reorderVaultItems,
reorderVaultStrings,
@@ -261,6 +268,10 @@ interface VaultViewProps {
}
| { status: "superseded" }
>;
onCommitVaultGroupMutation: (
mutate: (current: VaultGroupMutationState) => VaultGroupMutationResult,
lock?: VaultLockHandle | null,
) => Promise<VaultGroupMutationResult | { ok: false; superseded: true }>;
onClearAndRemoveManagedSource?: (source: ManagedSource) => Promise<() => Promise<void>>;
onClearAndRemoveManagedSources?: (sources: ManagedSource[]) => Promise<() => Promise<void>>;
onUnmanageSource?: (sourceId: string) => void;
@@ -325,6 +336,7 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
onUpdateManagedSources,
onReadPersistedManagedSources,
onCommitVaultImportTransaction,
onCommitVaultGroupMutation,
onClearAndRemoveManagedSource,
onClearAndRemoveManagedSources,
onUnmanageSource,
@@ -951,7 +963,66 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
setIsNewFolderOpen(false);
};
const submitRenameGroup = () => {
const commitGroupPathChange = useCallback(async (
sourcePath: string,
nextPath: string,
replacementConfig?: GroupConfig,
) => {
try {
return await onCommitVaultGroupMutation((current) => {
if (!current.groups.includes(sourcePath)) {
return { ok: false, error: `Group "${sourcePath}" was not found.` };
}
const belongsToTree = (path: string) => (
path === sourcePath || path.startsWith(`${sourcePath}/`)
);
const rename = (path: string) => (
path === sourcePath
? nextPath
: path.startsWith(`${sourcePath}/`)
? nextPath + path.slice(sourcePath.length)
: path
);
const occupied = new Set([
...current.groups.filter((path) => !belongsToTree(path)),
...current.configs.map((config) => config.path).filter((path) => !belongsToTree(path)),
]);
const collision = [
...current.groups,
...current.configs.map((config) => config.path),
].filter(belongsToTree).map(rename).find((path) => occupied.has(path));
if (collision) return { ok: false, error: `Group "${collision}" already exists.` };
const configs = current.configs.map((config) => {
if (replacementConfig && config.path === sourcePath) {
return { ...replacementConfig, path: nextPath };
}
return belongsToTree(config.path) ? { ...config, path: rename(config.path) } : config;
});
if (replacementConfig && !current.configs.some((config) => config.path === sourcePath)) {
configs.push({ ...replacementConfig, path: nextPath });
}
return {
ok: true,
state: {
groups: Array.from(new Set(current.groups.map(rename))),
configs,
hosts: current.hosts.map((host) => (
host.group && belongsToTree(host.group) ? { ...host, group: rename(host.group) } : host
)),
managedSources: current.managedSources.map((source) => (
belongsToTree(source.groupName) ? { ...source, groupName: rename(source.groupName) } : source
)),
snippets: remapSnippetTargetGroupPaths(current.snippets, sourcePath, nextPath),
},
};
});
} catch (error) {
return { ok: false as const, error: error instanceof Error ? error.message : String(error) };
}
}, [onCommitVaultGroupMutation]);
const submitRenameGroup = async () => {
if (!renameTargetPath) return;
const nextName = renameGroupName.trim();
@@ -972,37 +1043,11 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
return;
}
const updatedGroups = customGroups.map((g) => {
if (g === renameTargetPath) return nextPath;
if (g.startsWith(renameTargetPath + "/"))
return nextPath + g.slice(renameTargetPath.length);
return g;
});
const updatedHosts = hosts.map((h) => {
const g = h.group || "";
if (g === renameTargetPath) return { ...h, group: nextPath };
if (g.startsWith(renameTargetPath + "/"))
return { ...h, group: nextPath + g.slice(renameTargetPath.length) };
return h;
});
// Update managed sources if any match the renamed group path
const updatedManagedSources = managedSources.map((s) => {
if (s.groupName === renameTargetPath)
return { ...s, groupName: nextPath };
if (s.groupName.startsWith(renameTargetPath + "/"))
return {
...s,
groupName: nextPath + s.groupName.slice(renameTargetPath.length),
};
return s;
});
if (updatedManagedSources.some((s, i) => s !== managedSources[i])) {
onUpdateManagedSources(updatedManagedSources);
const result = await commitGroupPathChange(renameTargetPath, nextPath);
if ("superseded" in result || "error" in result) {
setRenameGroupError("superseded" in result ? t("common.error") : result.error);
return;
}
onUpdateCustomGroups(Array.from(new Set(updatedGroups)));
onUpdateHosts(updatedHosts);
if (
selectedGroupPath &&
(selectedGroupPath === renameTargetPath ||
@@ -1026,7 +1071,7 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
}, []);
const handleSaveGroupConfig = useCallback(
(config: GroupConfig, _newName?: string, _newParent?: string | null) => {
async (config: GroupConfig, _newName?: string, _newParent?: string | null) => {
const oldPath = editingGroupPath!;
const newPath = config.path; // Panel already computed the correct path
@@ -1044,41 +1089,11 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
// Handle path change (rename or parent change)
if (newPath !== oldPath) {
// Update groups, hosts, managed sources, and configs for path change
const updatedGroups = customGroups.map((g) => {
if (g === oldPath) return newPath;
if (g.startsWith(oldPath + "/"))
return newPath + g.slice(oldPath.length);
return g;
});
const updatedHosts = hosts.map((h) => {
const g = h.group || "";
if (g === oldPath) return { ...h, group: newPath };
if (g.startsWith(oldPath + "/"))
return { ...h, group: newPath + g.slice(oldPath.length) };
return h;
});
const updatedManagedSources = managedSources.map((s) => {
if (s.groupName === oldPath) return { ...s, groupName: newPath };
if (s.groupName.startsWith(oldPath + "/"))
return {
...s,
groupName: newPath + s.groupName.slice(oldPath.length),
};
return s;
});
if (updatedManagedSources.some((s, i) => s !== managedSources[i])) {
onUpdateManagedSources(updatedManagedSources);
const result = await commitGroupPathChange(oldPath, newPath, config);
if ("superseded" in result || "error" in result) {
toast.error("superseded" in result ? t("common.error") : result.error);
return;
}
onUpdateCustomGroups(Array.from(new Set(updatedGroups)));
onUpdateHosts(updatedHosts);
// Update child config paths too
const finalConfigs = updatedConfigs.map((c) => {
if (c.path.startsWith(oldPath + "/"))
return { ...c, path: newPath + c.path.slice(oldPath.length) };
return c;
});
onUpdateGroupConfigs(finalConfigs);
if (selectedGroupPath === oldPath) setSelectedGroupPath(newPath);
if (selectedGroupPath?.startsWith(oldPath + "/")) {
setSelectedGroupPath(
@@ -1096,13 +1111,9 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
groupConfigs,
editingGroupPath,
customGroups,
hosts,
managedSources,
selectedGroupPath,
commitGroupPathChange,
onUpdateGroupConfigs,
onUpdateCustomGroups,
onUpdateHosts,
onUpdateManagedSources,
t,
],
);
@@ -1129,7 +1140,7 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
managedSources,
onReadPersistedHosts,
onReadPersistedManagedSources,
onCommitVaultImportTransaction,
onCommitVaultGroupMutation,
onClearAndRemoveManagedSource,
onClearAndRemoveManagedSources,
onDeletedPaths: handleDeletedGroupPaths,
@@ -1138,7 +1149,7 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
const deleteGroupPath = (path: string, deleteHosts: boolean = false) =>
deleteGroupPaths([path], deleteHosts);
const moveGroup = (sourcePath: string, targetParent: string | null) => {
const moveGroup = async (sourcePath: string, targetParent: string | null) => {
const name = sourcePath.split("/").filter(Boolean).pop() || "";
const newPath = targetParent ? `${targetParent}/${name}` : name;
if (newPath === sourcePath || newPath.startsWith(sourcePath + "/")) return;
@@ -1146,50 +1157,17 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
toast.error(t("vault.groups.errors.duplicatePath"));
return;
}
const updatedGroups = customGroups.map((g) => {
if (g === sourcePath) return newPath;
if (g.startsWith(sourcePath + "/"))
return newPath + g.slice(sourcePath.length);
return g;
});
const updatedHosts = hosts.map((h) => {
const g = h.group || "";
if (g === sourcePath) return { ...h, group: newPath };
if (g.startsWith(sourcePath + "/"))
return { ...h, group: newPath + g.slice(sourcePath.length) };
return h;
});
// Update managed sources if any match the moved group path
const updatedManagedSources = managedSources.map((s) => {
if (s.groupName === sourcePath) return { ...s, groupName: newPath };
if (s.groupName.startsWith(sourcePath + "/"))
return {
...s,
groupName: newPath + s.groupName.slice(sourcePath.length),
};
return s;
});
if (updatedManagedSources.some((s, i) => s !== managedSources[i])) {
onUpdateManagedSources(updatedManagedSources);
}
onUpdateCustomGroups(Array.from(new Set(updatedGroups)));
onUpdateHosts(updatedHosts);
// Update group configs for moved paths
const updatedGroupConfigs = groupConfigs.map((c) => {
if (c.path === sourcePath) return { ...c, path: newPath };
if (c.path.startsWith(sourcePath + "/"))
return { ...c, path: newPath + c.path.slice(sourcePath.length) };
return c;
});
if (updatedGroupConfigs.some((c, i) => c !== groupConfigs[i])) {
onUpdateGroupConfigs(updatedGroupConfigs);
const result = await commitGroupPathChange(sourcePath, newPath);
if ("superseded" in result || "error" in result) {
toast.error("superseded" in result ? t("common.error") : result.error);
return;
}
if (
selectedGroupPath &&
(selectedGroupPath === sourcePath ||
selectedGroupPath.startsWith(sourcePath + "/"))
) {
setSelectedGroupPath(newPath);
setSelectedGroupPath(newPath + selectedGroupPath.slice(sourcePath.length));
}
};
@@ -1321,8 +1299,7 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
hosts,
managedSources,
onUpdateCustomGroups,
onUpdateHosts,
onUpdateManagedSources,
onCommitGroupPathChange: commitGroupPathChange,
selectedGroupPath,
setSelectedGroupPath,
ensurePathExpanded: treeExpandedState.ensurePathExpanded,
@@ -1688,6 +1665,7 @@ export const vaultViewAreEqual = (
prev.onReadPersistedHosts === next.onReadPersistedHosts &&
prev.onReadPersistedManagedSources === next.onReadPersistedManagedSources &&
prev.onCommitVaultImportTransaction === next.onCommitVaultImportTransaction &&
prev.onCommitVaultGroupMutation === next.onCommitVaultGroupMutation &&
prev.onCommitPluginImporterData === next.onCommitPluginImporterData &&
prev.showRecentHosts === next.showRecentHosts &&
prev.hostClickBehavior === next.hostClickBehavior &&
+49 -2
View File
@@ -1,10 +1,11 @@
import { Globe, Link2, Play, Trash2 } from 'lucide-react';
import { FolderTree, Globe, Link2, Play, Trash2 } from 'lucide-react';
import React, { useCallback, useMemo, useState, type DragEvent } from 'react';
import type { Host, Snippet } from '@/domain/models';
import {
appendHostConnectScript,
getEditableHostConnectScriptIds,
getGlobalConnectScripts,
getGroupConnectScriptsForHost,
removeHostConnectScript,
reorderHostConnectScript,
} from '@/domain/hostConnectScripts.ts';
@@ -63,6 +64,16 @@ export const HostDetailsScriptsSection: React.FC<HostDetailsScriptsSectionProps>
() => getEditableHostConnectScriptIds(host, snippets),
[host, snippets],
);
const groupScripts = useMemo(
() => getGroupConnectScriptsForHost(host, snippets).filter(
(script) => !script.id || !queueIds.includes(script.id),
),
[host, queueIds, snippets],
);
const groupScriptIds = useMemo(
() => new Set(groupScripts.map((script) => script.id)),
[groupScripts],
);
const queuedScripts = useMemo(
() => queueIds
.map((id) => scriptById(snippets, id))
@@ -73,10 +84,11 @@ export const HostDetailsScriptsSection: React.FC<HostDetailsScriptsSectionProps>
() => scripts.filter((script) => {
if (!script.id) return false;
if (script.targetsAllHosts) return false;
if (groupScriptIds.has(script.id)) return false;
if (queueIds.includes(script.id)) return false;
return true;
}),
[queueIds, scripts],
[groupScriptIds, queueIds, scripts],
);
const linkOptions = useMemo(
() => linkableScripts.map((script) => ({
@@ -170,6 +182,41 @@ export const HostDetailsScriptsSection: React.FC<HostDetailsScriptsSectionProps>
</div>
) : null}
{groupScripts.length > 0 ? (
<div className="space-y-2">
<div className="flex items-center gap-1.5">
<FolderTree size={12} className="text-muted-foreground shrink-0" />
<label className="text-xs text-muted-foreground">
{t('hostDetails.automation.groupScripts')}
</label>
</div>
<p className="text-[10px] text-muted-foreground leading-relaxed">
{t('hostDetails.automation.groupScriptsHint')}
</p>
<div className="space-y-1.5">
{groupScripts.map((script) => (
<div
key={script.id}
className="flex items-center gap-2.5 px-2.5 py-2 rounded-md border border-border/50 bg-primary/5"
>
<VaultEntityIcon
className={cn(vaultEntityIconSmClass, vaultAutomationScriptIconClass)}
icon={<Play size={14} />}
/>
<div className="min-w-0 flex-1">
<div className="text-sm font-medium truncate">
{script.label || t('scripts.running.unnamed')}
</div>
<div className="text-[10px] text-muted-foreground truncate">
{script.targetGroups?.join(', ')}
</div>
</div>
</div>
))}
</div>
</div>
) : null}
<div className="space-y-2">
{queuedScripts.length === 0 ? (
<p className="text-[11px] text-muted-foreground">{t('hostDetails.automation.connectQueueEmpty')}</p>
@@ -0,0 +1,55 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import React from 'react';
import { act, create, type ReactTestRenderer } from 'react-test-renderer';
import { HostTreeGroupInlineRenameInput } from './HostTreeGroupInlineRenameInput';
test('inline group rename can retry after an asynchronous commit failure', async () => {
const actEnvironment = globalThis as typeof globalThis & {
IS_REACT_ACT_ENVIRONMENT?: boolean;
};
const previousActEnvironment = actEnvironment.IS_REACT_ACT_ENVIRONMENT;
actEnvironment.IS_REACT_ACT_ENVIRONMENT = true;
let renderer: ReactTestRenderer | null = null;
let attempts = 0;
try {
await act(async () => {
renderer = create(React.createElement(HostTreeGroupInlineRenameInput, {
initialName: 'prod',
onCommit: async () => {
attempts += 1;
return attempts > 1;
},
onCancel: () => undefined,
}));
});
const input = renderer!.root.findByType('input');
await act(async () => {
input.props.onKeyDown({
key: 'Enter',
preventDefault: () => undefined,
stopPropagation: () => undefined,
});
await Promise.resolve();
});
await act(async () => {
input.props.onKeyDown({
key: 'Enter',
preventDefault: () => undefined,
stopPropagation: () => undefined,
});
await Promise.resolve();
});
assert.equal(attempts, 2);
} finally {
await act(async () => {
renderer?.unmount();
});
actEnvironment.IS_REACT_ACT_ENVIRONMENT = previousActEnvironment;
}
});
@@ -4,7 +4,7 @@ import { cn } from '../../lib/utils';
type HostTreeGroupInlineRenameInputProps = {
initialName: string;
onCommit: (name: string) => void;
onCommit: (name: string) => boolean | void | Promise<boolean | void>;
onCancel: () => void;
className?: string;
style?: React.CSSProperties;
@@ -19,7 +19,7 @@ export const HostTreeGroupInlineRenameInput: React.FC<HostTreeGroupInlineRenameI
}) => {
const inputRef = useRef<HTMLInputElement>(null);
const [value, setValue] = useState(initialName);
const committedRef = useRef(false);
const submittingRef = useRef(false);
useEffect(() => {
const input = inputRef.current;
@@ -28,15 +28,20 @@ export const HostTreeGroupInlineRenameInput: React.FC<HostTreeGroupInlineRenameI
input.select();
}, []);
const commit = () => {
if (committedRef.current) return;
committedRef.current = true;
onCommit(value);
const commit = async () => {
if (submittingRef.current) return;
submittingRef.current = true;
try {
const committed = await onCommit(value);
if (committed === false) submittingRef.current = false;
} catch {
submittingRef.current = false;
}
};
const cancel = () => {
if (committedRef.current) return;
committedRef.current = true;
if (submittingRef.current) return;
submittingRef.current = true;
onCancel();
};
@@ -49,7 +54,7 @@ export const HostTreeGroupInlineRenameInput: React.FC<HostTreeGroupInlineRenameI
onChange={(event) => setValue(event.target.value)}
onBlur={() => {
queueMicrotask(() => {
commit();
void commit();
});
}}
onClick={(event) => event.stopPropagation()}
@@ -64,7 +69,7 @@ export const HostTreeGroupInlineRenameInput: React.FC<HostTreeGroupInlineRenameI
event.stopPropagation();
if (event.key === 'Enter') {
event.preventDefault();
commit();
void commit();
}
if (event.key === 'Escape') {
event.preventDefault();
+19 -2
View File
@@ -6,6 +6,7 @@ import {
} from '@/domain/snippetScript.ts';
import {
getRunnableHostsForSnippet,
resolveSnippetTargetGroupsForSave,
} from '@/domain/snippetTargets.ts';
import {
removeHostConnectScript,
@@ -54,11 +55,13 @@ export const QuickScriptEditorDialog: React.FC<QuickScriptEditorDialogProps> = (
const [open, setOpen] = useState(false);
const [editingSnippet, setEditingSnippet] = useState<Partial<Snippet>>(createBlankScript);
const [targetSelection, setTargetSelection] = useState<string[]>([]);
const [targetGroupSelection, setTargetGroupSelection] = useState<string[]>([]);
useEffect(() => {
const handler = () => {
setEditingSnippet(createBlankScript());
setTargetSelection([]);
setTargetGroupSelection([]);
setOpen(true);
};
window.addEventListener('netcatty:scripts:add', handler);
@@ -71,6 +74,7 @@ export const QuickScriptEditorDialog: React.FC<QuickScriptEditorDialogProps> = (
if (!snippet || !isScriptSnippet(snippet)) return;
setEditingSnippet(snippet);
setTargetSelection(snippet.targetsAllHosts ? [] : (snippet.targets ?? []));
setTargetGroupSelection(snippet.targetsAllHosts ? [] : (snippet.targetGroups ?? []));
setOpen(true);
};
window.addEventListener('netcatty:snippets:edit', handler);
@@ -112,6 +116,7 @@ export const QuickScriptEditorDialog: React.FC<QuickScriptEditorDialogProps> = (
if (detail.editAfterSave) {
setEditingSnippet(snippet);
setTargetSelection([]);
setTargetGroupSelection([]);
setOpen(true);
}
};
@@ -132,8 +137,12 @@ export const QuickScriptEditorDialog: React.FC<QuickScriptEditorDialogProps> = (
const runnableSnippet = useMemo(() => ({
...(editingSnippet as Snippet),
targets: editingSnippet.targetsAllHosts ? [] : targetSelection,
targetGroups: resolveSnippetTargetGroupsForSave(
editingSnippet,
targetGroupSelection,
),
targetsAllHosts: editingSnippet.targetsAllHosts || undefined,
}), [editingSnippet, targetSelection]);
}), [editingSnippet, targetGroupSelection, targetSelection]);
const runTargets = useMemo(
() => getRunnableHostsForSnippet(runnableSnippet, hosts),
@@ -174,6 +183,10 @@ export const QuickScriptEditorDialog: React.FC<QuickScriptEditorDialogProps> = (
tags: editingSnippet.tags ?? [],
package: packagePath,
targets: editingSnippet.targetsAllHosts ? [] : targetSelection,
targetGroups: resolveSnippetTargetGroupsForSave(
editingSnippet,
targetGroupSelection,
),
targetsAllHosts: editingSnippet.targetsAllHosts || undefined,
kind: 'script',
language: editingSnippet.language ?? 'javascript',
@@ -182,7 +195,7 @@ export const QuickScriptEditorDialog: React.FC<QuickScriptEditorDialogProps> = (
triggerPattern: editingSnippet.triggerPattern,
order: editingSnippet.order,
};
}, [editingSnippet, onCreatePackage, packages, targetSelection]);
}, [editingSnippet, onCreatePackage, packages, targetGroupSelection, targetSelection]);
const persistSnippet = useCallback((): Snippet | null => {
const savedSnippet = buildSavedSnippet();
@@ -238,10 +251,12 @@ export const QuickScriptEditorDialog: React.FC<QuickScriptEditorDialogProps> = (
const handleTargetsAllHostsChange = useCallback((checked: boolean) => {
if (checked) {
setTargetSelection([]);
setTargetGroupSelection([]);
setEditingSnippet((prev) => ({
...prev,
targetsAllHosts: true,
targets: [],
targetGroups: undefined,
}));
return;
}
@@ -266,6 +281,8 @@ export const QuickScriptEditorDialog: React.FC<QuickScriptEditorDialogProps> = (
selectedHostIds={targetSelection}
onSelectHost={handleSelectHost}
onSelectionChange={handleSelectionChange}
selectedGroupPaths={targetGroupSelection}
onGroupSelectionChange={setTargetGroupSelection}
targetsAllHosts={Boolean(editingSnippet.targetsAllHosts)}
onTargetsAllHostsChange={handleTargetsAllHostsChange}
/>
+28 -1
View File
@@ -5,9 +5,11 @@ import type { Host, Snippet } from '@/domain/models';
import { DEFAULT_SCRIPT_TEMPLATE } from '@/domain/snippetScript.ts';
import { scheduleWindowInputFocus } from '@/application/state/windowInputFocus';
import { SelectHostDialog } from '@/components/SelectHostDialog';
import { SelectGroupDialog } from '@/components/SelectGroupDialog';
import { ScriptCodeEditor } from './ScriptCodeEditor';
import { ScriptMetaFields } from './ScriptMetaFields';
import { SnippetTargetsSection } from '@/components/snippets/SnippetTargetsSection';
import { resolveSnippetTargetGroupsForSave } from '@/domain/snippetTargets.ts';
import { Button } from '@/components/ui/button';
import { Dialog, DialogContent, DialogTitle } from '@/components/ui/dialog';
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
@@ -26,6 +28,8 @@ export interface ScriptEditorModalProps {
selectedHostIds: string[];
onSelectHost: (host: Host) => void;
onSelectionChange?: (selectedHostIds: string[]) => void;
selectedGroupPaths?: string[];
onGroupSelectionChange?: (selectedGroupPaths: string[]) => void;
targetsAllHosts?: boolean;
onTargetsAllHostsChange?: (checked: boolean) => void;
}
@@ -53,11 +57,14 @@ export const ScriptEditorModal: React.FC<ScriptEditorModalProps> = ({
selectedHostIds,
onSelectHost,
onSelectionChange,
selectedGroupPaths = [],
onGroupSelectionChange,
targetsAllHosts = false,
onTargetsAllHostsChange,
}) => {
const { t } = useI18n();
const [targetPickerOpen, setTargetPickerOpen] = useState(false);
const [groupPickerOpen, setGroupPickerOpen] = useState(false);
const handleOpenChange = useCallback((isOpen: boolean) => {
if (!isOpen) {
@@ -81,9 +88,10 @@ export const ScriptEditorModal: React.FC<ScriptEditorModalProps> = ({
onChange({
...snippet,
targets: selectedHostIds,
targetGroups: resolveSnippetTargetGroupsForSave(snippet, selectedGroupPaths),
targetsAllHosts: undefined,
});
}, [onChange, selectedHostIds, snippet]);
}, [onChange, selectedGroupPaths, selectedHostIds, snippet]);
const language = 'javascript';
const editorValue = snippet.command || DEFAULT_SCRIPT_TEMPLATE;
@@ -146,9 +154,13 @@ export const ScriptEditorModal: React.FC<ScriptEditorModalProps> = ({
variant="embedded"
t={t}
targetHosts={targetHosts}
targetGroups={selectedGroupPaths}
onEditTargets={() => {
if (!targetsAllHosts) setTargetPickerOpen(true);
}}
onEditGroups={onGroupSelectionChange ? () => {
if (!targetsAllHosts) setGroupPickerOpen(true);
} : undefined}
hint={t('scripts.targets.hint')}
targetsAllHosts={targetsAllHosts}
onTargetsAllHostsChange={onTargetsAllHostsChange}
@@ -192,6 +204,21 @@ export const ScriptEditorModal: React.FC<ScriptEditorModalProps> = ({
onSelectionChange={onSelectionChange}
onConfirm={handleTargetsConfirm}
/>
<SelectGroupDialog
open={groupPickerOpen}
onOpenChange={setGroupPickerOpen}
hosts={hosts}
customGroups={customGroups}
selectedGroupPaths={selectedGroupPaths}
onSelectionChange={(nextGroups) => {
onGroupSelectionChange?.(nextGroups);
onChange({
...snippet,
targetGroups: nextGroups,
targetsAllHosts: undefined,
});
}}
/>
</>
);
};
@@ -3,6 +3,7 @@ import { Check, ChevronDown, RefreshCw } from "lucide-react";
import type { AIProviderId, ProviderStyle } from "../../../../infrastructure/ai/types";
import { resolveProviderStyle } from "../../../../infrastructure/ai/types";
import { buildModelDiscoveryHeaders, resolveModelsDiscoveryEndpoint } from "../../../../infrastructure/ai/modelDiscoveryHeaders";
import { buildProviderProbeUrl } from "../../../../infrastructure/ai/providerConnectionProbe";
import { useI18n } from "../../../../application/i18n/I18nProvider";
import { Button } from "../../../ui/button";
import { Tooltip, TooltipContent, TooltipTrigger } from "../../../ui/tooltip";
@@ -150,7 +151,7 @@ export const ModelSelector: React.FC<{
if (bridge.aiAllowlistAddHost && baseURL) {
await bridge.aiAllowlistAddHost(baseURL);
}
const url = `${baseURL.replace(/\/+$/, "")}${effectiveModelsEndpoint}`;
const url = buildProviderProbeUrl(baseURL, effectiveModelsEndpoint);
const headers = buildModelDiscoveryHeaders(resolvedStyle, apiKey);
const result = await bridge.aiFetch(url, "GET", headers, undefined, undefined, undefined, undefined, skipTLSVerify);
if (!result.ok) {
@@ -509,6 +509,9 @@ export const ProviderConfigForm: React.FC<{
placeholder={preset?.defaultBaseURL || "https://"}
className="w-full h-8 rounded-md border border-input bg-background px-3 text-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
/>
{resolvedStyle === "anthropic" ? (
<p className="text-[11px] text-muted-foreground/70">{t('ai.providers.baseUrl.anthropicHelp')}</p>
) : null}
</div>
{/* Default Model */}
+1 -6
View File
@@ -50,7 +50,7 @@ test("sftp host picker uses single-line quick switcher row layout", () => {
assert.doesNotMatch(source, /text-xs text-muted-foreground truncate/);
});
test("sftp host picker omits sourceSessionId for sudo connected hosts", () => {
test("sftp host picker passes sourceSessionId for sudo connected hosts", () => {
assert.match(source, /sftpSourceSessionIdForHost/);
assert.match(
source,
@@ -60,11 +60,6 @@ test("sftp host picker omits sourceSessionId for sudo connected hosts", () => {
source,
/onSelectHost\(\s*item\.entry\.host,\s*sourceSessionId \? \{ sourceSessionId \} : undefined,\s*\)/,
);
// Must not always pass sessionId as a reuse hint (sudo would hide the spinner).
assert.doesNotMatch(
source,
/onSelectHost\(item\.entry\.host, \{ sourceSessionId: item\.entry\.sessionId \}\)/,
);
});
test("sftp host picker virtualizes the host list", () => {
@@ -4,6 +4,7 @@ import type { SftpPane } from "../../application/state/sftp/types";
import {
connectionKeyMatchesHost,
findReusableSftpSidePanelTab,
isPendingSameEndpointSshSession,
isRemoteSftpTabHealthy,
shouldAcceptPendingSftpUpload,
shouldResetSftpSidePanelSourceSession,
@@ -77,6 +78,35 @@ test("shouldSkipSftpSidePanelAutoConnect rejects when the active tab has no endp
);
});
test("isPendingSameEndpointSshSession only waits for actively connecting SSH sessions", () => {
const host = {
id: "host-1",
hostname: "server.example",
port: 22,
username: "root",
};
const baseSession = {
hostId: "host-1",
hostname: "server.example",
port: 22,
username: "root",
protocol: "ssh",
};
assert.equal(
isPendingSameEndpointSshSession({ ...baseSession, status: "connecting" }, host),
true,
);
assert.equal(
isPendingSameEndpointSshSession({ ...baseSession, status: "disconnected" }, host),
false,
);
assert.equal(
isPendingSameEndpointSshSession({ ...baseSession, status: "connected" }, host),
false,
);
});
test("connectionKeyMatchesHost accepts host-id prefix keys", () => {
assert.equal(connectionKeyMatchesHost("host-1:server:22:ssh::root", "host-1"), true);
assert.equal(connectionKeyMatchesHost("host-2:server:22:ssh::root", "host-1"), false);
@@ -33,6 +33,36 @@ export function shouldSkipSftpSidePanelAutoConnect(
return isRemoteSftpTabHealthy(activeTab, hasBackendSession);
}
export function isPendingSameEndpointSshSession(
session: {
hostId?: string | null;
status?: string;
hostname?: string | null;
port?: number | null;
username?: string | null;
protocol?: string | null;
moshEnabled?: boolean;
etEnabled?: boolean;
},
host: {
id: string;
hostname: string;
port?: number | null;
username: string;
},
): boolean {
return Boolean(
session.hostId === host.id
&& session.status === "connecting"
&& session.hostname === host.hostname
&& (session.port ?? 22) === (host.port ?? 22)
&& session.username === host.username
&& (session.protocol === "ssh" || session.protocol === undefined)
&& !session.moshEnabled
&& !session.etEnabled,
);
}
/** Whether a stored endpoint key still belongs to the live connection's host. */
export function connectionKeyMatchesHost(
connectionKey: string | null | undefined,
@@ -0,0 +1,47 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import React from 'react';
import { renderToStaticMarkup } from 'react-dom/server';
import { SnippetTargetsSection } from './SnippetTargetsSection';
const t = (key: string) => ({
'snippets.targets.title': 'Targets',
'snippets.targets.selectHosts': 'Select hosts',
'snippets.targets.selectGroups': 'Select groups',
'snippets.targets.add': 'Add targets',
'snippets.targets.allHosts': 'All hosts',
'snippets.targets.allHostsShort': 'All hosts',
'snippets.targets.allHostsActive': 'Every host',
}[key] ?? key);
test('renders dynamic group targets separately from explicit hosts', () => {
const markup = renderToStaticMarkup(
<SnippetTargetsSection
t={t}
targetHosts={[]}
targetGroups={['Production/Web']}
onEditTargets={() => undefined}
onEditGroups={() => undefined}
/>,
);
assert.match(markup, /Production\/Web/);
assert.match(markup, /Select hosts/);
assert.match(markup, /Select groups/);
});
test('all-host mode hides host and group pickers', () => {
const markup = renderToStaticMarkup(
<SnippetTargetsSection
t={t}
targetHosts={[]}
targetGroups={['Production']}
onEditTargets={() => undefined}
onEditGroups={() => undefined}
targetsAllHosts
onTargetsAllHostsChange={() => undefined}
/>,
);
assert.doesNotMatch(markup, /Select hosts/);
assert.doesNotMatch(markup, /Select groups/);
assert.match(markup, /Every host/);
});
+46 -9
View File
@@ -1,3 +1,4 @@
import { FolderTree, Server } from 'lucide-react';
import React from 'react';
import { DistroAvatar } from '../DistroAvatar';
import { Button } from '@/components/ui/button';
@@ -10,6 +11,8 @@ export interface SnippetTargetsSectionProps {
t: (key: string, params?: Record<string, unknown>) => string;
targetHosts: Host[];
onEditTargets: () => void;
targetGroups?: string[];
onEditGroups?: () => void;
hint?: string;
variant?: 'card' | 'embedded';
targetsAllHosts?: boolean;
@@ -25,6 +28,8 @@ const TargetsBody: React.FC<{
t: SnippetTargetsSectionProps['t'];
targetHosts: Host[];
onEditTargets: () => void;
targetGroups?: string[];
onEditGroups?: () => void;
hint?: string;
embedded?: boolean;
targetsAllHosts?: boolean;
@@ -33,6 +38,8 @@ const TargetsBody: React.FC<{
t,
targetHosts,
onEditTargets,
targetGroups = [],
onEditGroups,
hint,
embedded = false,
targetsAllHosts = false,
@@ -50,14 +57,28 @@ const TargetsBody: React.FC<{
<div className="flex items-center gap-1 min-w-0">
{!targetsAllHosts ? (
<Button
variant="ghost"
size="sm"
className={cn(actionButtonClass(embedded), 'text-primary')}
onClick={onEditTargets}
>
{targetHosts.length > 0 ? t('action.edit') : t('snippets.targets.add')}
</Button>
<>
<Button
variant="ghost"
size="sm"
className={cn(actionButtonClass(embedded), 'text-primary gap-1')}
onClick={onEditTargets}
>
<Server size={12} />
{t('snippets.targets.selectHosts')}
</Button>
{onEditGroups ? (
<Button
variant="ghost"
size="sm"
className={cn(actionButtonClass(embedded), 'text-primary gap-1')}
onClick={onEditGroups}
>
<FolderTree size={12} />
{t('snippets.targets.selectGroups')}
</Button>
) : null}
</>
) : null}
{onTargetsAllHostsChange ? (
@@ -91,7 +112,7 @@ const TargetsBody: React.FC<{
>
{t('snippets.targets.allHostsActive')}
</p>
) : targetHosts.length === 0 ? (
) : targetHosts.length === 0 && targetGroups.length === 0 ? (
embedded ? null : (
<Button
variant="secondary"
@@ -103,6 +124,18 @@ const TargetsBody: React.FC<{
)
) : (
<div className={cn('gap-2', embedded ? 'flex flex-wrap' : 'space-y-2')}>
{targetGroups.map((groupPath) => (
<div
key={`group:${groupPath}`}
className={cn(
'flex items-center gap-2 rounded-lg border border-border/60 bg-primary/5 max-w-full',
embedded ? 'px-2 py-1.5 text-xs' : 'px-3 py-2 text-sm',
)}
>
<FolderTree size={14} className="text-primary shrink-0" />
<span className="truncate font-medium">{groupPath}</span>
</div>
))}
{targetHosts.map((host) => (
embedded ? (
<div
@@ -133,6 +166,8 @@ export const SnippetTargetsSection: React.FC<SnippetTargetsSectionProps> = ({
t,
targetHosts,
onEditTargets,
targetGroups,
onEditGroups,
hint,
variant = 'card',
targetsAllHosts,
@@ -143,6 +178,8 @@ export const SnippetTargetsSection: React.FC<SnippetTargetsSectionProps> = ({
t={t}
targetHosts={targetHosts}
onEditTargets={onEditTargets}
targetGroups={targetGroups}
onEditGroups={onEditGroups}
hint={hint}
embedded={variant === 'embedded'}
targetsAllHosts={targetsAllHosts}
+5 -1
View File
@@ -21,6 +21,7 @@ interface TerminalAutocompleteProps {
termRef: RefObject<XTerm | null>;
sessionId: string;
hostId: string;
hostGroup?: string;
hostOs: "linux" | "windows" | "macos";
settings?: Partial<AutocompleteSettings>;
protocol?: string;
@@ -63,6 +64,7 @@ export function TerminalAutocomplete({
termRef,
sessionId,
hostId,
hostGroup,
hostOs,
settings,
protocol,
@@ -119,6 +121,7 @@ export function TerminalAutocomplete({
...(options.cwd ? { cwd: options.cwd } : {}),
},
hostOs,
hostGroup,
cwdSource: options.cwdSource,
snippets: options.snippets,
maximum: options.maxResults ?? 15,
@@ -126,12 +129,13 @@ export function TerminalAutocomplete({
signal: options.signal,
onLatePathSuggestions: options.onLatePathSuggestions,
});
}, [allowHostStyleGreaterThanPrompt, hostId, hostOs, isPluginCompletionProviderAvailable, protocol, sensitiveInputActiveRef, sessionId, settings?.historyScope, status, workspaceId]);
}, [allowHostStyleGreaterThanPrompt, hostGroup, hostId, hostOs, isPluginCompletionProviderAvailable, protocol, sensitiveInputActiveRef, sessionId, settings?.historyScope, status, workspaceId]);
const autocomplete = useTerminalAutocomplete({
termRef,
containerRef,
sessionId,
hostId,
hostGroup,
hostOs,
settings,
onAcceptText,
+3 -3
View File
@@ -7,7 +7,7 @@ import { HoverCard, HoverCardContent, HoverCardTrigger } from '../ui/hover-card'
import { Tooltip, TooltipContent, TooltipTrigger } from '../ui/tooltip';
import { formatNetSpeed } from './terminalHelpers';
import { useServerStats } from '../../application/state/useServerStats';
import { formatDiskCapacityGb, resolveTerminalDiskSummary } from './serverStatsFormat';
import { formatDiskCapacityRange, resolveTerminalDiskSummary } from './serverStatsFormat';
interface TerminalServerStatsProps {
sessionId: string;
@@ -263,7 +263,7 @@ export const TerminalServerStats: React.FC<TerminalServerStatsProps> = ({
diskSummary.percent !== null && diskSummary.percent >= 80 && diskSummary.percent < 90 && "text-amber-400"
)}>
{diskSummary.used !== null && diskSummary.total !== null && diskSummary.percent !== null
? `${formatDiskCapacityGb(diskSummary.used)}/${formatDiskCapacityGb(diskSummary.total)}G (${diskSummary.percent}%)`
? `${formatDiskCapacityRange(diskSummary.used, diskSummary.total)} (${diskSummary.percent}%)`
: diskSummary.percent !== null
? `${diskSummary.percent}%`
: '--'}
@@ -295,7 +295,7 @@ export const TerminalServerStats: React.FC<TerminalServerStatsProps> = ({
"text-[11px] font-medium whitespace-nowrap",
disk.percent >= 90 ? "text-red-400" : disk.percent >= 80 ? "text-amber-400" : "text-emerald-400"
)}>
{formatDiskCapacityGb(disk.used)}/{formatDiskCapacityGb(disk.total)}G ({disk.percent}%)
{formatDiskCapacityRange(disk.used, disk.total)} ({disk.percent}%)
</span>
</div>
<div className="w-full h-1.5 bg-muted rounded-full overflow-hidden">
+1 -1
View File
@@ -394,7 +394,7 @@ test("manual disconnect keeps the session pane for reconnect", () => {
assert.match(body, /isBootActiveRef\.current = false/);
assert.match(body, /setIsCancelling\(true\)/);
assert.match(body, /updateStatus\("disconnected"\)/);
assert.match(body, /void cleanupSession\(\)/);
assert.match(body, /void cleanupSession\(\{ retainOwnership: true \}\)/);
assert.match(source, /trackSessionCleanup/);
assert.doesNotMatch(body, /onCloseSession/);
assert.match(source, /handleDisconnect: \(attachExistingSession \|\| compactToolbar\) \? undefined : handleDisconnect/);
+1
View File
@@ -993,6 +993,7 @@ function TerminalViewInner({ ctx }: { ctx: TerminalViewContext }) {
termRef={termRef}
sessionId={sessionId}
hostId={host.id}
hostGroup={host.group}
hostOs={autocompleteHostOs}
settings={autocompleteSettings}
protocol={effectiveTerminalProtocol ?? resolveEffectiveTerminalProtocol(host)}
@@ -242,6 +242,7 @@ export async function getCompletions(
input: string,
options: {
hostId?: string;
hostGroup?: string;
os?: "linux" | "windows" | "macos";
maxResults?: number;
/** Session ID for remote path completion */
@@ -397,7 +398,10 @@ export async function getCompletions(
// a snippet's label collides with an existing history entry's text, the
// score-sort + final dedup below keeps the snippet (the higher-scored one).
if (options.snippets && options.snippets.length > 0 && ctx.wordIndex === 0) {
for (const snippetSuggestion of getSnippetSuggestions(input, options.snippets, { hostId })) {
for (const snippetSuggestion of getSnippetSuggestions(input, options.snippets, {
hostId,
hostGroup: options.hostGroup,
})) {
suggestions.push(snippetSuggestion);
}
}
@@ -7,31 +7,37 @@
* Snippet so the accept path can run it through the canonical executeSnippetCommand.
*/
import type { Snippet } from "../../../domain/models";
import { snippetAppliesToHost } from "../../../domain/snippetTargets";
import { matchesSearchQuery } from "../../../lib/searchMatcher";
import type { CompletionSuggestion } from "./completionEngine";
const SNIPPET_BASE_SCORE = 2000; // Above history (1000+freq) per "snippet > history".
const SNIPPET_PREFIX_BONUS = 100;
function snippetAvailableForAutocomplete(snippet: Snippet, hostId?: string): boolean {
function snippetAvailableForAutocomplete(
snippet: Snippet,
host: { hostId?: string; hostGroup?: string },
): boolean {
if (snippet.targetsAllHosts) return true;
if (snippet.targets && snippet.targets.length > 0) {
return hostId !== undefined && snippet.targets.includes(hostId);
}
return true;
const hasScopedTargets = Boolean(
snippet.targets?.length || snippet.targetGroups !== undefined,
);
if (!hasScopedTargets) return true;
if (!host.hostId) return false;
return snippetAppliesToHost(snippet, { id: host.hostId, group: host.hostGroup });
}
export function getSnippetSuggestions(
input: string,
snippets: Snippet[],
options: { hostId?: string } = {},
options: { hostId?: string; hostGroup?: string } = {},
): CompletionSuggestion[] {
const needle = input.trim().toLowerCase();
if (!needle || !Array.isArray(snippets)) return [];
const out: CompletionSuggestion[] = [];
for (const snippet of snippets) {
if (!snippetAvailableForAutocomplete(snippet, options.hostId)) continue;
if (!snippetAvailableForAutocomplete(snippet, options)) continue;
const label = (snippet.label || "").toLowerCase();
const firstLine = (snippet.command || "").split("\n")[0].trim().toLowerCase();
@@ -13,6 +13,7 @@ import type { AutocompleteHistoryScope, Snippet } from '../../../domain/models';
export interface TerminalCompletionProviderRequest {
input: string;
session: NetcattyTerminalSessionSnapshot;
hostGroup?: string;
hostOs: 'linux' | 'windows' | 'macos';
cwdSource?: AutocompleteCwdSource;
snippets?: Snippet[];
@@ -63,6 +64,7 @@ export async function provideTerminalCompletions(
): Promise<CompletionSuggestion[]> {
const builtInPromise = getCompletions(request.input, {
hostId: request.session.hostId,
hostGroup: request.hostGroup,
os: request.hostOs,
maxResults: request.maximum,
sessionId: request.session.sessionId,
@@ -149,6 +149,7 @@ interface UseTerminalAutocompleteOptions {
containerRef: RefObject<HTMLElement | null>;
sessionId: string;
hostId: string;
hostGroup?: string;
hostOs: "linux" | "windows" | "macos";
settings?: Partial<AutocompleteSettings>;
/** Callback to write text to the terminal session — replaces CustomEvent */
@@ -200,6 +201,7 @@ export function useTerminalAutocomplete(
containerRef,
sessionId,
hostId,
hostGroup,
hostOs,
settings: userSettings,
onAcceptText,
@@ -237,6 +239,8 @@ export function useTerminalAutocomplete(
onAcceptSnippetRef.current = onAcceptSnippet;
const hostIdRef = useRef(hostId);
hostIdRef.current = hostId;
const hostGroupRef = useRef(hostGroup);
hostGroupRef.current = hostGroup;
const hostOsRef = useRef(hostOs);
hostOsRef.current = hostOs;
const sessionIdRef = useRef(sessionId);
@@ -1048,6 +1052,7 @@ export function useTerminalAutocomplete(
try {
completions = await provideCompletionsRef.current(input, {
hostId: hostIdRef.current,
hostGroup: hostGroupRef.current,
os: hostOsRef.current,
maxResults: settingsRef.current.maxSuggestions,
historyScope: settingsRef.current.historyScope,
@@ -19,6 +19,27 @@ test("getCompletions does not surface snippets past the command position", async
assert.equal(out.find((s) => s.source === "snippet"), undefined);
});
test("getCompletions applies dynamic group targets to the current host", async () => {
const groupedSnippet: Snippet = {
...deploySnippet,
targetGroups: ["Production"],
};
const matching = await getCompletions("dep", {
hostId: "host-prod",
hostGroup: "Production/Web",
snippets: [groupedSnippet],
});
const outside = await getCompletions("dep", {
hostId: "host-dev",
hostGroup: "Development",
snippets: [groupedSnippet],
});
assert.ok(matching.some((suggestion) => suggestion.source === "snippet"));
assert.equal(outside.some((suggestion) => suggestion.source === "snippet"), false);
});
test("getCompletions returns more than 8 snippet matches when default maxSuggestions allows it", async () => {
const snippets: Snippet[] = Array.from({ length: 20 }, (_, i) => ({
id: `s${i}`,
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
@@ -211,6 +211,68 @@ test('visual Provider host preserves semantics when command completion wins the
host.dispose();
});
test('visual Provider host restores annotations after terminal history is rebuilt', async () => {
const markers: Array<{ line: number; isDisposed: boolean; dispose(): void }> = [];
const renderedText: string[] = [];
const term = {
element: null,
buffer: { active: { type: 'normal', baseY: 4, cursorY: 2, cursorX: 0, getLine() { return undefined; } } },
onWriteParsed() { return { dispose() {} }; },
registerMarker(offset: number) {
const marker = {
line: 6 + offset,
isDisposed: false,
dispose() { this.isDisposed = true; this.line = -1; },
};
markers.push(marker);
return marker;
},
registerDecoration() {
return {
dispose() {},
onRender(listener: (element: Record<string, unknown>) => void) {
const element = {
className: '',
textContent: '',
title: '',
style: {},
setAttribute() {},
};
listener(element);
renderedText.push(String(element.textContent));
return { dispose() {} };
},
};
},
};
const host = new PluginTerminalVisualProviderHost({
term: term as never,
isProviderAvailable: (kind) => kind === 'terminal.semantic',
async request() {
return {
stale: false,
results: [{ providerId: 'semantic', status: 'ok', result: {
classification: 'success',
annotations: [{ text: 'retained annotation' }],
} }],
};
},
});
await host.commandSubmitted('true');
await host.commandCompleted();
assert.deepEqual(renderedText, ['success | retained annotation']);
markers[0].dispose();
host.terminalRebuilt();
assert.deepEqual(renderedText, [
'success | retained annotation',
'success | retained annotation',
]);
assert.equal(markers[1].line, 6);
host.dispose();
});
test('visual Provider host preserves semantic queue order at its bounded capacity', async () => {
let requests = 0;
const renderedText: string[] = [];
@@ -134,6 +134,9 @@ export class PluginTerminalVisualProviderHost implements IDisposable {
readonly #disposables: IDisposable[] = [];
readonly #matcherDecorations: Array<IDecoration | IMarker | undefined> = [];
readonly #annotationDecorations: Array<IDecoration | IMarker | undefined> = [];
#visibleAnnotations: readonly PluginTerminalAnnotation[] = Object.freeze([]);
#annotationAnchorLine: number | null = null;
#annotationMarker: IMarker | null = null;
readonly #requestControllers = new Set<AbortController>();
readonly #matcherRequestControllers = new Set<AbortController>();
#matcherTimer: ReturnType<typeof setTimeout> | undefined;
@@ -196,6 +199,9 @@ export class PluginTerminalVisualProviderHost implements IDisposable {
}
this.#isProviderAvailable = options.isProviderAvailable ?? (() => true);
this.#disposables.push(this.#term.onWriteParsed(() => {
if (this.#annotationMarker && !this.#annotationMarker.isDisposed) {
this.#annotationAnchorLine = this.#annotationMarker.line;
}
this.#matcherGeneration += 1;
for (const controller of this.#matcherRequestControllers) controller.abort();
this.#matcherRequestControllers.clear();
@@ -432,11 +438,23 @@ export class PluginTerminalVisualProviderHost implements IDisposable {
}
}
#renderAnnotations(annotations: readonly PluginTerminalAnnotation[]): void {
#renderAnnotations(
annotations: readonly PluginTerminalAnnotation[],
anchorLine?: number | null,
): void {
disposeAll(this.#annotationDecorations);
if (annotations.length === 0 || this.#disposed || !this.#active || !this.#visible) return;
const marker = this.#term.registerMarker(0);
this.#annotationMarker = null;
this.#visibleAnnotations = Object.freeze([...annotations]);
if (annotations.length === 0 || this.#disposed || !this.#active || !this.#visible) {
this.#annotationAnchorLine = null;
return;
}
const buffer = this.#term.buffer.active;
const cursorLine = buffer.baseY + buffer.cursorY;
const marker = this.#term.registerMarker((anchorLine ?? cursorLine) - cursorLine);
if (!marker) return;
this.#annotationMarker = marker;
this.#annotationAnchorLine = marker.line;
const decoration = this.#term.registerDecoration({ marker, anchor: 'right', x: 0, width: 1, layer: 'top' });
const text = annotations.map((annotation) => annotation.text).join(' | ').slice(0, 512);
const description = annotations.map((annotation) => annotation.description).filter(Boolean).join('\n');
@@ -454,6 +472,19 @@ export class PluginTerminalVisualProviderHost implements IDisposable {
this.#annotationDecorations.push(decoration, marker);
}
/** Recreate markers disposed by a terminal history replay without re-querying plugins. */
terminalRebuilt(): void {
if (this.#visibleAnnotations.length === 0) return;
this.#renderAnnotations(this.#visibleAnnotations, this.#annotationAnchorLine);
}
#clearAnnotations(): void {
disposeAll(this.#annotationDecorations);
this.#annotationMarker = null;
this.#visibleAnnotations = Object.freeze([]);
this.#annotationAnchorLine = null;
}
setActive(active: boolean, terminalBackground?: string): void {
if (this.#disposed || this.#active === active) {
if (active && terminalBackground) void this.refreshBackground('theme-changed', terminalBackground);
@@ -475,7 +506,7 @@ export class PluginTerminalVisualProviderHost implements IDisposable {
this.#backgroundGeneration += 1;
this.#pendingSemantics.splice(0);
disposeAll(this.#matcherDecorations);
disposeAll(this.#annotationDecorations);
this.#clearAnnotations();
this.#renderBackground([]);
}
@@ -493,7 +524,7 @@ export class PluginTerminalVisualProviderHost implements IDisposable {
this.#pendingSemantics.splice(0);
if (!visible) {
disposeAll(this.#matcherDecorations);
disposeAll(this.#annotationDecorations);
this.#clearAnnotations();
this.#renderBackground([]);
}
if (visible && this.#active) {
@@ -510,7 +541,7 @@ export class PluginTerminalVisualProviderHost implements IDisposable {
this.#backgroundGeneration += 1;
this.#abortProviderRequests();
this.#pendingSemantics.splice(0);
disposeAll(this.#annotationDecorations);
this.#clearAnnotations();
if (!this.#isProviderAvailable('terminal.background')) this.#renderBackground([]);
if (!this.#isProviderAvailable('terminal.matcher')) disposeAll(this.#matcherDecorations);
if (this.#active && this.#visible) {
@@ -557,7 +588,7 @@ export class PluginTerminalVisualProviderHost implements IDisposable {
this.#abortProviderRequests();
this.#pendingSemantics.splice(0);
disposeAll(this.#matcherDecorations);
disposeAll(this.#annotationDecorations);
this.#clearAnnotations();
this.#backgroundOverlay?.remove();
this.#backgroundOverlay = null;
disposeAll(this.#disposables);
@@ -1797,7 +1797,7 @@ export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContex
{ replayBacklog: true },
);
ctx.disposeExitRef.current = ctx.terminalBackend.onSessionExit(id, (evt) => {
ctx.disposeExitRef.current = ctx.terminalBackend.onSessionExit(id, async (evt) => {
ctx.updateStatus("disconnected");
const exitMessage = `\r\n[session closed${evt?.exitCode !== undefined ? ` (code ${evt.exitCode})` : ""}]`;
writeTerminalLine(ctx, term, exitMessage);
@@ -1810,6 +1810,7 @@ export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContex
if (ctx.onTerminalDataCapture && ctx.serializeAddonRef.current) {
try {
await ctx.prepareKeywordHighlightSerialization?.();
const terminalData = ctx.serializeAddonRef.current.serialize();
logger.info("[Terminal] Serialized terminal data", {
sessionId: ctx.sessionId,
@@ -209,6 +209,7 @@ export type TerminalSessionStartersContext = {
disposeTelnetEchoModeRef?: RefObject<(() => void) | null>;
fitAddonRef: RefObject<FitAddon | null>;
serializeAddonRef: RefObject<SerializeAddon | null>;
prepareKeywordHighlightSerialization?: () => Promise<void>;
pendingAuthRef: RefObject<PendingAuth>;
promptLineBreakStateRef?: RefObject<PromptLineBreakState>;
sudoAutofillRef?: RefObject<SudoPasswordAutofill | null>;
@@ -536,7 +536,7 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
cursorStyle,
cursorBlink,
scrollback,
// Decorations (keyword highlighting) use proposed APIs; enable globally so toggles work at runtime.
// Cursor-line rendering and Unicode width handling use proposed APIs.
allowProposedApi: true,
drawBoldTextInBrightColors,
minimumContrastRatio,
@@ -2218,7 +2218,9 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
resizeScheduler.schedule({ sessionId: id, cols, rows });
});
const keywordHighlighter = new KeywordHighlighter(term);
const keywordHighlighter = new KeywordHighlighter(term, {
serializeAddon,
});
keywordHighlighter.setRules(keywordHighlightRules, keywordHighlightEnabled);
const cursorLineHighlighter = new CursorLineHighlighter(term);
@@ -260,7 +260,7 @@ test('CursorLineHighlighter refreshes after hidden writes become visible', () =>
highlighter.dispose();
});
test('CursorLineHighlighter keeps a continuous background under keyword decorations', () => {
test('CursorLineHighlighter keeps a continuous background under colored keyword text', () => {
const term = createFakeTerm(10);
const keywordMarker = term.registerMarker(0);
term.registerDecoration({
@@ -352,7 +352,7 @@ test('CursorLineHighlighter fills a wrap-pending final cell', () => {
highlighter.dispose();
});
test('CursorLineHighlighter leaves colored and inverse cells untouched', () => {
test('CursorLineHighlighter keeps foreground colors and leaves inverse cells untouched', () => {
const term = createFakeTerm(10);
term.setColoredForegrounds([2, 3]);
term.setInverseCells([7]);
@@ -363,8 +363,7 @@ test('CursorLineHighlighter leaves colored and inverse cells untouched', () => {
assert.deepEqual(
term.decorations.map(({ options }) => ({ x: options.x, width: options.width })),
[
{ x: 0, width: 2 },
{ x: 4, width: 3 },
{ x: 0, width: 7 },
{ x: 8, width: 2 },
],
);
@@ -23,8 +23,8 @@ type HighlightRange = { x: number; width: number };
/**
* Highlights the buffer row under the cursor without tinting its glyphs.
* ANSI-colored cells keep their own background; only default-background cells
* receive the opaque theme color.
* Explicit ANSI backgrounds remain untouched. Foreground-only colors still
* receive the opaque theme background so colored text does not leave holes.
*/
export class CursorLineHighlighter implements IDisposable {
private enabled = false;
@@ -211,9 +211,7 @@ export class CursorLineHighlighter implements IDisposable {
}
const isHighlightable =
currentCell === undefined ||
(currentCell.isBgDefault() &&
currentCell.isFgDefault() &&
!currentCell.isInverse());
(currentCell.isBgDefault() && !currentCell.isInverse());
if (isHighlightable && rangeStart === null) rangeStart = x;
if ((!isHighlightable || x === cols - 1) && rangeStart !== null) {
const end = isHighlightable && x === cols - 1 ? x + 1 : x;
@@ -49,7 +49,7 @@ export function resolveHibernateSnapshotCapturePayload(
export async function serializeTerminalCloseFallback(
term: XTerm,
serializeAddon: SerializeAddon,
options: { preferWasm?: boolean } = {},
options: { preferWasm?: boolean; prepare?: () => Promise<void> } = {},
): Promise<TerminalCloseCapturePayload | null> {
const snapshot = await serializeTerminalForHibernate(term, serializeAddon, options);
return resolveHibernateSnapshotCapturePayload(snapshot);
@@ -792,6 +792,40 @@ const attachLedgerAnchors = (
}
};
/** Reattach sparse anchors after a display-only terminal buffer rebuild. */
export const restoreTerminalLineTimestampAnchors = (term: XTerm): void => {
const store = getTimestampStore(term);
for (const entry of store.ledger) {
if (!entry.marker?.isDisposed) continue;
entry.marker = undefined;
}
attachLedgerAnchors(term, store, store.ledger);
store.lastSeenBaseY = term.buffer.normal.baseY;
store.lastSeenBufferLength = term.buffer.normal.length;
store.lastSeenCols = _getTerminalColumnCount(term);
notifyTimestampStore(store);
};
export const snapshotTerminalLineTimestampLedger = (term: XTerm): Array<{
label: string;
secondKey: number;
line: number;
}> => getTimestampStore(term).ledger.map((entry) => ({
label: entry.label,
secondKey: entry.secondKey,
line: entry.marker && !entry.marker.isDisposed ? entry.marker.line : entry.line,
}));
export const restoreTerminalLineTimestampLedger = (
term: XTerm,
snapshot: readonly { label: string; secondKey: number; line: number }[],
): void => {
const store = getTimestampStore(term);
store.ledger = snapshot.map((entry) => ({ ...entry }));
store.lastStampSecondKey = store.ledger.at(-1)?.secondKey ?? null;
restoreTerminalLineTimestampAnchors(term);
};
/**
* Refresh ledger.line from live sparse anchors. Drops entries whose markers
* were disposed by scrollback trim. Bare (unanchored) lines keep their values
@@ -10,6 +10,7 @@ import {
resetTerminalOutputPressure,
setTerminalOutputPressureVisibility,
shouldDegradeTerminalSideWork,
shouldDegradeTerminalKeywordHighlight,
shouldSkipTerminalLineTimestamps,
} from "./terminalOutputPressure.ts";
import { TERMINAL_LONG_LINE_PRESSURE_BYTES } from "./terminalFlowConstants.ts";
@@ -172,6 +173,21 @@ test("arms large-output early on multi-line writes when scrollback is saturated"
}
});
test("a one-line prompt does not degrade keyword coloring on saturated scrollback", () => {
const term = createFakeTerm({
rows: 24,
options: { scrollback: 1000 },
buffer: { active: { length: 1020, baseY: 996 } },
});
const prompt = "\r\nplain prompt # ";
noteTerminalOutputPressureData(term, prompt);
assert.equal(shouldDegradeTerminalSideWork(term), true);
assert.equal(shouldDegradeTerminalKeywordHighlight(term, prompt), false);
resetTerminalOutputPressure(term);
});
test("does not treat an empty buffer as scrollback-saturated", () => {
const term = createFakeTerm({
rows: 24,
@@ -196,6 +212,10 @@ test("saturated multi-line degrades side work but keeps line timestamps", () =>
// but per-line gutter timestamps must still stamp.
noteTerminalOutputPressureData(term, "CONTAINER ID IMAGE\n".repeat(20));
assert.equal(shouldDegradeTerminalSideWork(term), true);
assert.equal(
shouldDegradeTerminalKeywordHighlight(term, "CONTAINER ID IMAGE\n".repeat(20)),
false,
);
assert.equal(shouldSkipTerminalLineTimestamps(term), false);
resetTerminalOutputPressure(term);
@@ -299,6 +299,19 @@ export const shouldDegradeTerminalSideWork = (term: XTerm): boolean => {
return pressure.largeOutput || pressure.longLine;
};
/**
* Keyword coloring only examines the current write, so a stale quiet-window
* bulk flag must not make a later one-line prompt schedule a history rebuild.
*/
export const shouldDegradeTerminalKeywordHighlight = (
term: XTerm,
data: string,
): boolean => {
const state = getOrCreateState(term);
if (state.longLine || data.length >= TERMINAL_LONG_LINE_PRESSURE_BYTES) return true;
return state.recentSampleBytes >= LARGE_OUTPUT_RATE_BYTES;
};
/**
* Whether line-timestamp registerMarker work should be skipped.
*
@@ -2089,7 +2089,7 @@ test("attachSessionToTerminal clears the backend id before reporting exit", () =
assert.equal(sessionIdSeenByConsumer, null);
});
test("attachSessionToTerminal drains hidden final output before exit capture", () => {
test("attachSessionToTerminal drains hidden final output before exit capture", async () => {
const { term, writes } = createFakeTerm();
let onData: ((data: string) => void) | null = null;
let onExit: ((evt: { reason?: string }) => void) | null = null;
@@ -2130,6 +2130,7 @@ test("attachSessionToTerminal drains hidden final output before exit capture", (
assert.deepEqual(writes, []);
onExit?.({ reason: "closed" });
await new Promise((resolve) => setTimeout(resolve, 0));
const expected = "final output\r\n\r\n[session closed]\r\n";
assert.equal(writes.join(""), expected);
@@ -956,7 +956,7 @@ export const attachSessionToTerminal = (
);
ctx.terminalBackend.notifyTerminalSessionDisplayReady?.(id);
ctx.disposeExitRef.current = ctx.terminalBackend.onSessionExit(id, (evt) => {
ctx.disposeExitRef.current = ctx.terminalBackend.onSessionExit(id, async (evt) => {
// The backend is already gone. In particular, an observe popup must not
// run its normal pause/snapshot/restore handoff while closing afterward.
if (ctx.sessionRef.current === id) {
@@ -971,6 +971,7 @@ export const attachSessionToTerminal = (
if (ctx.onTerminalDataCapture && ctx.serializeAddonRef.current) {
try {
await ctx.prepareKeywordHighlightSerialization?.();
const terminalData = ctx.serializeAddonRef.current.serialize();
ctx.onTerminalDataCapture(ctx.sessionId, terminalData);
} catch (err) {
@@ -899,8 +899,7 @@ test("leave-alt CSI clears latch even while buffer still reports alternate", ()
// Buffer flips to normal after leave is written; latch must be gone.
(term.buffer.active as { type: string }).type = "normal";
enqueueCoalescedTerminalWrite(term, "shell after tui", () => {});
assert.equal(microtasks.length, 1);
assert.equal(frames.length, 0);
assert.equal(frames.length, 0, "must not stay on rAF after leave-alt");
} finally {
resetTerminalWriteCoalescer(term);
globalThis.queueMicrotask = originalMicrotask;
@@ -956,8 +955,7 @@ test("enter-then-leave in one chunk does not latch rAF forever", () => {
microtasks.length = 0;
enqueueCoalescedTerminalWrite(term, "ordinary shell output", () => {});
assert.equal(microtasks.length, 1);
assert.equal(frames.length, 0);
assert.equal(frames.length, 0, "must not latch rAF after enter-then-leave");
} finally {
resetTerminalWriteCoalescer(term);
globalThis.queueMicrotask = originalMicrotask;
@@ -670,11 +670,11 @@ export const enqueueCoalescedTerminalWrite = (
}, {
getMaxPendingBytes: () => resolveCoalescerByteCap(term),
shouldFlushScheduledFrame: () => terminalWriteCoalescerFlushGates.get(term)?.() ?? true,
// Tabby streams normal-screen output without waiting for vsync. Keep rAF
// for alternate-screen TUIs (including the enter-alt burst before xterm
// has switched buffer.active.type) so multi-chunk repaints stay atomic.
// When rAF is unavailable (Node unit tests), prefer the "raf" mode so the
// coalescer falls back to an immediate flush (legacy test contract).
// Keep rAF for alternate-screen TUIs (including the enter-alt burst
// before xterm has switched buffer.active.type) so multi-chunk
// repaints stay atomic. Normal-screen output uses Electerm-style burst
// coalescing. When rAF is unavailable (Node unit tests), prefer "raf"
// so the coalescer falls back to an immediate flush (legacy contract).
resolveScheduleMode: ({ nextChunk }): WriteCoalesceScheduleMode => {
const preapplied = preappliedAltScreenNeedsRafByTerm.get(term);
if (preapplied !== undefined) {
@@ -693,7 +693,9 @@ export const enqueueCoalescedTerminalWrite = (
const canUseMicrotask = typeof queueMicrotask === "function"
&& typeof globalThis.requestAnimationFrame === "function";
if (canUseMicrotask) {
return "microtask";
// Electerm-style 16ms merge after a recent flush; idle echo still
// uses a microtask so typing does not wait a frame.
return "burst";
}
return "raf";
},
@@ -4,6 +4,7 @@ import { beforeEach, test } from "node:test";
import {
createWriteCoalescer,
MAX_PENDING_WRITE_COALESCE_BYTES,
WRITE_COALESCE_BURST_MS,
type WriteCoalescerOptions,
} from "./writeCoalescer.ts";
@@ -87,6 +88,53 @@ test("microtask schedule mode flushes after the current turn without rAF", async
coalescer.dispose();
});
test("burst mode flushes idle output on the next microtask", async () => {
const writes: string[] = [];
const coalescer = createWriteCoalescer((data) => writes.push(data), {
resolveScheduleMode: () => "burst",
});
coalescer.push("a");
coalescer.push("b");
assert.equal(writes.length, 0);
await Promise.resolve();
assert.deepEqual(writes, ["ab"]);
coalescer.dispose();
});
test("burst mode merges writes that arrive inside the 16ms window", () => {
const writes: string[] = [];
let now = 1000;
const timeouts: Array<{ callback: () => void; ms: number }> = [];
const coalescer = createWriteCoalescer((data) => writes.push(data), {
resolveScheduleMode: () => "burst",
now: () => now,
burstWindowMs: WRITE_COALESCE_BURST_MS,
scheduleTimeout(callback, ms) {
timeouts.push({ callback, ms: ms ?? 0 });
return () => {
const index = timeouts.findIndex((entry) => entry.callback === callback);
if (index >= 0) timeouts.splice(index, 1);
};
},
});
coalescer.push("first");
coalescer.flushSync();
assert.deepEqual(writes, ["first"]);
now += 4;
coalescer.push("second");
coalescer.push("third");
assert.deepEqual(writes, ["first"]);
assert.equal(timeouts.length, 1);
assert.ok(timeouts[0]!.ms <= WRITE_COALESCE_BURST_MS);
timeouts[0]!.callback();
assert.deepEqual(writes, ["first", "secondthird"]);
coalescer.dispose();
});
test("upgrades a pending microtask schedule to rAF when a later chunk requests it", () => {
const writes: string[] = [];
const microtasks: Array<() => void> = [];
+40 -5
View File
@@ -6,10 +6,12 @@
* per chunk, which can tear TUI frames (missing box borders, clipped bottom
* rows). Batching keeps rendering atomic per schedule turn.
*
* Schedule modes (Tabby-inspired):
* Schedule modes (Tabby + Electerm-inspired):
* - `raf`: wait for the next animation frame (best for alternate-screen TUIs)
* - `microtask`: flush after the current JS turn (normal-screen bulk / echo —
* closer to Tabby's direct write, still coalesces same-turn chunks)
* - `microtask`: flush after the current JS turn (same-turn merge only)
* - `burst`: idle output still flushes next microtask; writes that arrive
* inside a 16ms window after the last flush wait and merge (Electerm's
* attach-addon flood path). Never drops bytes.
*
* Ported from superset-sh/superset (issues #2241 / #2244):
* apps/desktop/src/renderer/lib/terminal/write-coalescer.ts
@@ -27,6 +29,11 @@ export {
/** Maximum time pending output may wait for its primary scheduler. */
export const WRITE_COALESCE_FALLBACK_DRAIN_MS = 50;
/**
* Electerm-style burst window. After a flush, further chunks wait up to this
* long so a log flood becomes ~60 writes/s instead of one write per IPC turn.
*/
export const WRITE_COALESCE_BURST_MS = 16;
export type WriteCoalescer = {
push(chunk: string): void;
@@ -38,7 +45,7 @@ export type WriteCoalescer = {
dispose(): void;
};
export type WriteCoalesceScheduleMode = "raf" | "microtask";
export type WriteCoalesceScheduleMode = "raf" | "microtask" | "burst";
export type WriteCoalesceScheduleContext = {
/** Chunk about to be (or just) enqueued. */
@@ -63,6 +70,8 @@ export type WriteCoalescerOptions = {
resolveScheduleMode?: (ctx: WriteCoalesceScheduleContext) => WriteCoalesceScheduleMode;
getMaxPendingBytes?: () => number;
shouldFlushScheduledFrame?: () => boolean;
now?: () => number;
burstWindowMs?: number;
};
const scheduleRafFrame = (callback: () => void): (() => void) | null => {
@@ -149,6 +158,7 @@ export const createWriteCoalescer = (
let cancelPendingSchedule: (() => void) | null = null;
let scheduledMode: WriteCoalesceScheduleMode | null = null;
let disposed = false;
let lastFlushTime = 0;
const customScheduleFrame = options.scheduleFrame;
const scheduleTimeout = options.scheduleTimeout ?? defaultScheduleTimeout;
const fallbackDrainMs = options.fallbackDrainMs ?? WRITE_COALESCE_FALLBACK_DRAIN_MS;
@@ -156,6 +166,12 @@ export const createWriteCoalescer = (
const getMaxPendingBytes = options.getMaxPendingBytes
?? (() => MAX_PENDING_WRITE_COALESCE_BYTES);
const shouldFlushScheduledFrame = options.shouldFlushScheduledFrame ?? (() => true);
const now = options.now ?? (() => (
typeof performance !== "undefined" && typeof performance.now === "function"
? performance.now()
: Date.now()
));
const burstWindowMs = options.burstWindowMs ?? WRITE_COALESCE_BURST_MS;
const cancelScheduledDrain = (): void => {
if (cancelPendingSchedule !== null) {
@@ -208,6 +224,23 @@ export const createWriteCoalescer = (
flushSync();
};
if (mode === "burst") {
const elapsed = lastFlushTime === 0 ? burstWindowMs : now() - lastFlushTime;
if (lastFlushTime > 0 && elapsed < burstWindowMs) {
const waitMs = Math.max(1, burstWindowMs - elapsed);
const cancel = scheduleTimeout(onScheduled, waitMs);
if (cancel === null) {
if (!shouldFlushScheduledFrame()) return;
flushSync();
return;
}
cancelPendingSchedule = cancel;
scheduledMode = "burst";
return;
}
mode = "microtask";
}
const primaryCancel = scheduleByMode(mode, onScheduled, customScheduleFrame);
const fallbackCancel = mode === "raf"
&& fallbackDrainMs > 0
@@ -234,6 +267,7 @@ export const createWriteCoalescer = (
const batch = pending.length === 1 ? pending[0]! : pending.join("");
pending = [];
pendingBytes = 0;
lastFlushTime = now();
(writeOverride ?? write)(batch);
};
@@ -245,6 +279,7 @@ export const createWriteCoalescer = (
const dropped = pendingBytes;
pending = [];
pendingBytes = 0;
lastFlushTime = 0;
onDropped?.(dropped);
};
@@ -275,7 +310,7 @@ export const createWriteCoalescer = (
// Upgrade microtask → rAF when a later chunk enters a TUI: the first shell
// chunk may have scheduled a same-turn flush before the alt-screen CSI
// arrived, which would tear the first repaint (Codex PR review).
if (scheduledMode === "microtask" && mode === "raf") {
if ((scheduledMode === "microtask" || scheduledMode === "burst") && mode === "raf") {
cancelScheduledDrain();
armSchedule("raf");
}
@@ -3,6 +3,7 @@ import assert from "node:assert/strict";
import {
formatDiskCapacityGb,
formatDiskCapacityRange,
resolveTerminalDiskSummary,
} from "./serverStatsFormat.ts";
@@ -14,6 +15,32 @@ test("disk capacity uses at most two decimal places", () => {
assert.equal(formatDiskCapacityGb(10.5), "10.5");
});
test("disk capacity stays in G below a terabyte", () => {
assert.equal(formatDiskCapacityRange(71.12, 878.15), "71.12/878.15G");
assert.equal(formatDiskCapacityRange(0.01, 0.5), "0.01/0.5G");
// 1023 GiB is still the largest value rendered in G.
assert.equal(formatDiskCapacityRange(1, 1023), "1/1023G");
});
test("disk capacity switches to T once a value reaches a terabyte", () => {
// Both sides in terabytes: one shared suffix.
assert.equal(formatDiskCapacityRange(8807.55, 118736.4), "8.6/115.95T");
assert.equal(formatDiskCapacityRange(2048, 4096), "2/4T");
});
test("disk capacity keeps the used figure when the sides differ in scale", () => {
// A total-driven unit would render these as 0.13/6.93T and 0/6.93T,
// discarding how much is actually in use.
assert.equal(formatDiskCapacityRange(136.37, 7096.47), "136.37G/6.93T");
assert.equal(formatDiskCapacityRange(3.25, 7096.47), "3.25G/6.93T");
assert.equal(formatDiskCapacityRange(1, 1024), "1G/1T");
});
test("disk capacity switches to P for petabyte-scale pools", () => {
assert.equal(formatDiskCapacityRange(1_048_576, 2_097_152), "1/2P");
assert.equal(formatDiskCapacityRange(512, 2_097_152), "512G/2P");
});
test("terminal disk summary totals every mounted filesystem", () => {
const summary = resolveTerminalDiskSummary({
diskUsed: 9.285,
+36
View File
@@ -20,6 +20,42 @@ export function formatDiskCapacityGb(value: number): string {
return Number(value.toFixed(2)).toString();
}
/** Capacity units, ascending from the GiB the collectors report in. */
const DISK_CAPACITY_UNITS = ["G", "T", "P"] as const;
/** Pick the largest unit that keeps a GiB value at or above 1. */
export function resolveDiskCapacityUnit(valueGb: number): { unit: string; divisor: number } {
let divisor = 1;
let index = 0;
while (
Math.abs(valueGb) / divisor >= 1024
&& index < DISK_CAPACITY_UNITS.length - 1
) {
divisor *= 1024;
index += 1;
}
return { unit: DISK_CAPACITY_UNITS[index], divisor };
}
/**
* Render a used/total pair, both reported in GiB.
*
* Each side is scaled independently so a mostly-empty multi-terabyte pool keeps
* its used figure — `136.37G/6.93T` rather than the `0.13/6.93T` a total-driven
* unit would produce, or the unreadable `136.37/7096.47G` of a fixed one. When
* both land in the same unit the suffix is written once, keeping the familiar
* `71.12/878.15G` shape for ordinary disks.
*/
export function formatDiskCapacityRange(usedGb: number, totalGb: number): string {
const used = resolveDiskCapacityUnit(usedGb);
const total = resolveDiskCapacityUnit(totalGb);
const usedText = formatDiskCapacityGb(usedGb / used.divisor);
const totalText = formatDiskCapacityGb(totalGb / total.divisor);
return used.unit === total.unit
? `${usedText}/${totalText}${total.unit}`
: `${usedText}${used.unit}/${totalText}${total.unit}`;
}
export function resolveTerminalDiskSummary(
stats: TerminalDiskStats,
): TerminalDiskSummary {
@@ -42,6 +42,57 @@ test("filters by host targets when set", () => {
assert.deepEqual(getSnippetSuggestions("restart", [scoped, global], { hostId: "host-2" }).map((o) => o.snippet?.id).sort(), ["g", "t"]);
});
test("filters dynamic group targets using nested host group membership", () => {
const scoped = snip({
id: "grouped",
label: "deploy-group",
targetGroups: ["Production"],
});
const mixed = snip({
id: "mixed",
label: "deploy-mixed",
targets: ["host-explicit"],
targetGroups: ["Staging"],
});
assert.deepEqual(
getSnippetSuggestions("deploy", [scoped, mixed], {
hostId: "host-prod",
hostGroup: "Production/Web",
}).map((item) => item.snippet?.id),
["grouped"],
);
assert.deepEqual(
getSnippetSuggestions("deploy", [scoped, mixed], {
hostId: "host-staging",
hostGroup: "Staging/API",
}).map((item) => item.snippet?.id),
["mixed"],
);
assert.deepEqual(
getSnippetSuggestions("deploy", [scoped, mixed], {
hostId: "host-dev",
hostGroup: "Development",
}),
[],
);
});
test("does not surface a snippet with an explicitly empty group scope", () => {
const disabled = snip({
id: "disabled-group-scope",
label: "deploy-disabled",
targetGroups: [],
});
assert.deepEqual(
getSnippetSuggestions("deploy", [disabled], {
hostId: "host-prod",
hostGroup: "Production",
}),
[],
);
});
test("no match returns empty; empty input returns empty", () => {
assert.deepEqual(getSnippetSuggestions("zzz", [snip({})], {}), []);
assert.deepEqual(getSnippetSuggestions("", [snip({})], {}), []);
@@ -164,7 +164,7 @@ test("reconnect preparation cancels an in-flight disconnected hibernate", () =>
const claimIndex = reconnectBody.indexOf(
"reconnectPreparationTokenRef.current = retryToken",
);
const cleanupIndex = reconnectBody.indexOf("await cleanupSession()");
const cleanupIndex = reconnectBody.indexOf("await cleanupSession({ retainOwnership: true })");
const startNewSessionIndex = reconnectBody.indexOf("const startNewSession = () =>");
assert.ok(claimIndex >= 0 && claimIndex < cleanupIndex);
@@ -321,3 +321,22 @@ test("serializeTerminalForHibernate preserves plain text context for normal scre
undefined,
]);
});
test("serializeTerminalForHibernate waits for deferred history preparation", async () => {
const term = createFakeTerm("normal", 3, 5);
const events: string[] = [];
const result = await serializeTerminalForHibernate(term as never, {
serialize: () => {
events.push("serialize");
return "serialized";
},
} as never, {
prepare: async () => {
await new Promise((resolve) => setTimeout(resolve, 1));
events.push("prepare");
},
});
assert.equal(result.snapshot, "serialized");
assert.deepEqual(events.slice(0, 2), ["prepare", "serialize"]);
});
@@ -189,8 +189,9 @@ async function serializeWithOptions(
export async function serializeTerminalForHibernate(
term: XTerm,
serializeAddon: SerializeAddon,
options: { preferWasm?: boolean } = {},
options: { preferWasm?: boolean; prepare?: () => Promise<void> } = {},
): Promise<TerminalHibernateSnapshot> {
await options.prepare?.();
const { excludeAltBuffer, excludeModes, alternateScreen } = resolveHibernateSerializeOptions(term);
const preferWasm = options.preferWasm === true;
const rows = Math.max(1, term.rows);
+5 -1
View File
@@ -909,7 +909,11 @@ export function useTerminalEffects(ctx: TerminalEffectsContext) {
if (!terminalDataCapturedRef.current && term && serializeAddon) {
const preferWasm = resolveHibernatePreferWasmSerialize(terminalSettingsRef.current);
try {
const payload = await serializeTerminalCloseFallback(term, serializeAddon, { preferWasm });
const payload = await serializeTerminalCloseFallback(term, serializeAddon, {
preferWasm,
prepare: () => xtermRuntimeRef.current?.keywordHighlighter.prepareForSerialization()
?? Promise.resolve(),
});
if (!isTerminalCloseGenerationCurrent(
closeGeneration,
terminalBootCloseGenerationRef.current,
+43
View File
@@ -2,6 +2,7 @@ import test from "node:test";
import assert from "node:assert/strict";
import React from "react";
import { renderToStaticMarkup } from "react-dom/server";
import { act, create, type ReactTestRenderer } from "react-test-renderer";
import {
VaultTreeGroupRow,
@@ -118,3 +119,45 @@ test("VaultTreeInlineRenameInput uses shared inline edit marker", () => {
assert.match(markup, /data-vault-tree-inline-edit="true"/);
assert.match(markup, /value="Ops"/);
});
test("VaultTreeInlineRenameInput can retry after an asynchronous commit failure", async () => {
const actEnvironment = globalThis as typeof globalThis & {
IS_REACT_ACT_ENVIRONMENT?: boolean;
};
const previousActEnvironment = actEnvironment.IS_REACT_ACT_ENVIRONMENT;
actEnvironment.IS_REACT_ACT_ENVIRONMENT = true;
let renderer: ReactTestRenderer | null = null;
let attempts = 0;
try {
await act(async () => {
renderer = create(
<VaultTreeInlineRenameInput
initialName="Ops"
onCommit={async () => {
attempts += 1;
return attempts > 1;
}}
onCancel={() => undefined}
/>,
);
});
const input = renderer!.root.findByType("input");
const pressEnter = async () => {
input.props.onKeyDown({
key: "Enter",
preventDefault: () => undefined,
stopPropagation: () => undefined,
});
await Promise.resolve();
};
await act(pressEnter);
await act(pressEnter);
assert.equal(attempts, 2);
} finally {
await act(async () => {
renderer?.unmount();
});
actEnvironment.IS_REACT_ACT_ENVIRONMENT = previousActEnvironment;
}
});
+17 -12
View File
@@ -5,7 +5,7 @@ import { cn } from "../../lib/utils";
type VaultTreeInlineRenameInputProps = {
initialName: string;
onCommit: (name: string) => void;
onCommit: (name: string) => boolean | void | Promise<boolean | void>;
onCancel: () => void;
className?: string;
style?: React.CSSProperties;
@@ -20,7 +20,7 @@ export const VaultTreeInlineRenameInput: React.FC<VaultTreeInlineRenameInputProp
}) => {
const inputRef = useRef<HTMLInputElement>(null);
const [value, setValue] = useState(initialName);
const committedRef = useRef(false);
const submittingRef = useRef(false);
useEffect(() => {
const input = inputRef.current;
@@ -29,15 +29,20 @@ export const VaultTreeInlineRenameInput: React.FC<VaultTreeInlineRenameInputProp
input.select();
}, []);
const commit = () => {
if (committedRef.current) return;
committedRef.current = true;
onCommit(value);
const commit = async () => {
if (submittingRef.current) return;
submittingRef.current = true;
try {
const committed = await onCommit(value);
if (committed === false) submittingRef.current = false;
} catch {
submittingRef.current = false;
}
};
const cancel = () => {
if (committedRef.current) return;
committedRef.current = true;
if (submittingRef.current) return;
submittingRef.current = true;
onCancel();
};
@@ -51,7 +56,7 @@ export const VaultTreeInlineRenameInput: React.FC<VaultTreeInlineRenameInputProp
onChange={(event) => setValue(event.target.value)}
onBlur={() => {
queueMicrotask(() => {
commit();
void commit();
});
}}
onClick={(event) => event.stopPropagation()}
@@ -66,7 +71,7 @@ export const VaultTreeInlineRenameInput: React.FC<VaultTreeInlineRenameInputProp
event.stopPropagation();
if (event.key === "Enter") {
event.preventDefault();
commit();
void commit();
}
if (event.key === "Escape") {
event.preventDefault();
@@ -91,7 +96,7 @@ type VaultTreeGroupRowProps = Omit<React.HTMLAttributes<HTMLDivElement>, "childr
hasChildren?: boolean;
editing?: boolean;
editingInitialName?: string;
onRenameCommit?: (name: string) => void;
onRenameCommit?: (name: string) => boolean | void | Promise<boolean | void>;
onRenameCancel?: () => void;
actions?: React.ReactNode;
labelActions?: React.ReactNode;
@@ -190,7 +195,7 @@ type VaultTreeItemRowProps = Omit<React.HTMLAttributes<HTMLDivElement>, "childre
actions?: React.ReactNode;
editing?: boolean;
editingInitialName?: string;
onRenameCommit?: (name: string) => void;
onRenameCommit?: (name: string) => boolean | void | Promise<boolean | void>;
onRenameCancel?: () => void;
content?: React.ReactNode;
};
+11 -6
View File
@@ -402,7 +402,7 @@ export function VaultViewLayout({ ctx }: { ctx: VaultViewLayoutContext }) {
[treeViewGroupTree],
);
const visibleSelectableGroupPaths = React.useMemo(
() => new globalThis.Set(
() => new globalThis.Set<string>(
viewMode === "tree"
? visibleTreeGroupPaths
: displayedGroups.map((group: { path: string }) => group.path),
@@ -1685,11 +1685,16 @@ export function VaultViewLayout({ ctx }: { ctx: VaultViewLayoutContext }) {
onClick={async () => {
if (pendingDeleteGroupPaths.length > 0) {
const isBulkDelete = bulkDeleteGroupPaths.length > 0;
await deleteGroupPaths(
pendingDeleteGroupPaths,
deleteGroupWithHosts,
isBulkDelete ? selectedHostIds : new Set(),
);
try {
await deleteGroupPaths(
pendingDeleteGroupPaths,
deleteGroupWithHosts,
isBulkDelete ? selectedHostIds : new Set<string>(),
);
} catch (error) {
toast.error(error instanceof Error ? error.message : t("common.error"));
return;
}
if (isBulkDelete) {
const deletedItemCount =
selectedHostIds.size + selectedGroupPaths.size;
@@ -0,0 +1,59 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import React from 'react';
import { act, create, type ReactTestRenderer } from 'react-test-renderer';
import { hostTreeInlineGroupEditStore } from '../../application/state/hostTreeInlineGroupEditStore';
import { useHostTreeInlineGroupActions } from './useHostTreeInlineGroupActions';
test('inline group rename stays open when the Vault commit is superseded', async () => {
const actEnvironment = globalThis as typeof globalThis & {
IS_REACT_ACT_ENVIRONMENT?: boolean;
};
const previousActEnvironment = actEnvironment.IS_REACT_ACT_ENVIRONMENT;
actEnvironment.IS_REACT_ACT_ENVIRONMENT = true;
let renderer: ReactTestRenderer | null = null;
let startRename: ((groupPath: string) => void) | undefined;
let commitRename: ((name: string) => Promise<boolean>) | undefined;
const selectedPaths: Array<string | null> = [];
const Probe = () => {
const actions = useHostTreeInlineGroupActions({
customGroups: ['prod'],
hosts: [],
managedSources: [],
onUpdateCustomGroups: () => undefined,
onCommitGroupPathChange: async () => ({ ok: false, superseded: true }),
selectedGroupPath: 'prod',
setSelectedGroupPath: (path) => selectedPaths.push(path),
ensurePathExpanded: () => undefined,
unnamedGroupLabel: 'New group',
t: (key) => key,
});
startRename = actions.startInlineRenameGroup;
commitRename = actions.commitInlineGroupRename;
return null;
};
try {
await act(async () => {
renderer = create(React.createElement(Probe));
});
act(() => {
startRename?.('prod');
});
await act(async () => {
await commitRename?.('production');
});
assert.deepEqual(selectedPaths, []);
assert.equal(hostTreeInlineGroupEditStore.getEdit()?.groupPath, 'prod');
} finally {
hostTreeInlineGroupEditStore.clear();
await act(async () => {
renderer?.unmount();
});
actEnvironment.IS_REACT_ACT_ENVIRONMENT = previousActEnvironment;
}
});
@@ -17,8 +17,13 @@ type UseHostTreeInlineGroupActionsParams = {
hosts: Host[];
managedSources: ManagedSource[];
onUpdateCustomGroups: (groups: string[]) => void;
onUpdateHosts: (hosts: Host[]) => void;
onUpdateManagedSources: (sources: ManagedSource[]) => void;
onCommitGroupPathChange: (
sourcePath: string,
nextPath: string,
) => Promise<
| { ok: true }
| { ok: false; error?: string; superseded?: true }
>;
selectedGroupPath: string | null;
setSelectedGroupPath: (path: string | null) => void;
ensurePathExpanded: (path: string) => void;
@@ -31,8 +36,7 @@ export function useHostTreeInlineGroupActions({
hosts,
managedSources,
onUpdateCustomGroups,
onUpdateHosts,
onUpdateManagedSources,
onCommitGroupPathChange,
selectedGroupPath,
setSelectedGroupPath,
ensurePathExpanded,
@@ -73,9 +77,9 @@ export function useHostTreeInlineGroupActions({
hostTreeInlineGroupEditStore.clear();
}, [customGroups, onUpdateCustomGroups]);
const commitInlineGroupRename = useCallback((rawName: string) => {
const commitInlineGroupRename = useCallback(async (rawName: string): Promise<boolean> => {
const edit = hostTreeInlineGroupEditStore.getEdit();
if (!edit) return;
if (!edit) return false;
const result = applyGroupPathRename({
renameTargetPath: edit.groupPath,
@@ -88,32 +92,32 @@ export function useHostTreeInlineGroupActions({
if (result.ok === false) {
if (result.error === 'unchanged') {
hostTreeInlineGroupEditStore.clear();
return;
return true;
}
if (result.error === 'required') {
if (edit.isNew) {
cancelInlineGroupEdit();
return;
return true;
}
toast.error(t('vault.groups.errors.required'));
return;
return false;
}
if (result.error === 'invalidChars') {
toast.error(t('vault.groups.errors.invalidChars'));
return;
return false;
}
if (result.error === 'duplicatePath') {
toast.error(t('vault.groups.errors.duplicatePath'));
return;
return false;
}
return;
return false;
}
if (result.updatedManagedSources.some((source, index) => source !== managedSources[index])) {
onUpdateManagedSources(result.updatedManagedSources);
const committed = await onCommitGroupPathChange(edit.groupPath, result.nextPath);
if (!committed.ok) {
toast.error(committed.error || t('common.error'));
return false;
}
onUpdateCustomGroups(result.updatedGroups);
onUpdateHosts(result.updatedHosts);
if (
selectedGroupPath
@@ -127,14 +131,13 @@ export function useHostTreeInlineGroupActions({
}
hostTreeInlineGroupEditStore.clear();
return true;
}, [
cancelInlineGroupEdit,
customGroups,
hosts,
managedSources,
onUpdateCustomGroups,
onUpdateHosts,
onUpdateManagedSources,
onCommitGroupPathChange,
selectedGroupPath,
setSelectedGroupPath,
t,
@@ -23,7 +23,7 @@ type RegisterVaultHostTreeActionsParams = {
startInlineNewGroup: (parentPath?: string) => void;
startInlineRenameGroup: (groupPath: string) => void;
startInlineDeleteGroup: (groupPath: string) => void;
commitInlineGroupRename: (name: string) => void;
commitInlineGroupRename: (name: string) => boolean | void | Promise<boolean | void>;
cancelInlineGroupEdit: () => void;
commitInlineHostRename: (name: string) => void;
cancelInlineHostEdit: () => void;
Loaded 100 of 169 files, more files were not shown because too many files have changed in this diff. Show more