Compare commits

..
Author SHA1 Message Date
bincxz 07d1a6f2c1 fix(sftp): focus the safe conflict action on open 2026-09-25 15:24:09 +08:00
626e0ce5bd fix(quick-connect): accept multi-@ jump chains like JumpServer (#3525)
* fix(#3523): automated AI fix

* fix(#3523): parse JumpServer login on final SSH host

* fix(#3523): preserve login with SSH HostName override

* fix(#3523): guard JumpServer credential presets

* fix(#3523): retain SSH user with HostName alias

* fix(#3523): distinguish JumpServer login from SSH user option

---------

Co-authored-by: netcatty-bot <308658023+netcatty-bot@users.noreply.github.com>
Co-authored-by: bincxz <16399091+binaricat@users.noreply.github.com>
2026-09-24 17:49:46 +08:00
71d378c9b8 fix(sftp): verify current source before side-panel download (#3524)
* fix(#3521): automated AI fix

* fix: address Codex review on PR #3524

* fix: address Codex review on PR #3524

* fix(sftp): require fresh source metadata before downloads

* fix(sftp): refresh SCP symlink targets before download

* test(sftp): include download regression in default suite

---------

Co-authored-by: netcatty-bot <308658023+netcatty-bot@users.noreply.github.com>
Co-authored-by: bincxz <16399091+binaricat@users.noreply.github.com>
2026-09-24 17:48:22 +08:00
陈大猫 0261931f95 fix(terminal): keep broadcast going on vim ex command-line (#3467)
* fix(terminal): keep broadcast going on vim ex command-line

Broadcast pauses on fail-closed colon prompts so passwords are not
copied to peers. Vim command mode's last line is a bare `:`, which
matched that heuristic. Exempt lines that start with `:` so `:w` and
friends stay in sync; `Password:` is unchanged.

* fix(terminal): only treat a bare colon as vim command-mode

Codex: exempting every `:`-prefixed line would broadcast into custom
prompts such as `sudo -p ':'`. The hang is the first `:` key; later
`:w` no longer matches the trailing-colon heuristic.

* fix(terminal): exempt vim colon only on the alternate screen

A bare `:` on the primary screen can be `sudo -p ':'`. Vim command mode
uses DEC 1049, so require alternateScreen before treating `:` as ex.

* test(terminal): allow alternateScreen in prompt-security options
2026-09-21 16:09:50 +08:00
陈大猫 62e9c9ed12 fix(mcp): drop mustache braces from tool descriptions (#3465)
* fix(mcp): drop mustache braces from tool descriptions

Hosts that interpolate {{name}} in tools/list text treat the literal
{{variables}} snippet wording as a missing template variable and abort
the turn. Use plain language instead and regenerate the committed specs.

* fix(mcp): describe snippet placeholders without mustache tokens

Keep agents informed that snippets use two curly braces on each side,
without emitting a literal {{name}} token that template hosts interpolate.
2026-09-21 15:56:24 +08:00
1b4e826e15 feat(terminal): save and auto-apply a default side panel layout on connect (#3456)
* fix(#3455): automated AI fix

* fix: address Codex review on PR #3456

* fix: address Codex review on PR #3456

* fix: address Codex review on PR #3456

* fix: address Codex review on PR #3456

* fix: address Codex review on PR #3456

* fix: address Codex review on PR #3456

* fix: address Codex review on PR #3456

* fix(terminal): let explicit SFTP auto-open beat the saved layout

A JumpServer SFTP deep link or host.autoOpenSftpPanel must still open
file transfer when the saved default layout omitted SFTP.

* fix(terminal): skip saved layout only when SFTP auto-open is available

Telnet/plugin hosts can carry autoOpenSidePanel=sftp even though the
protocol cannot serve SFTP. Those sessions should still get the saved
default with the SFTP pane pruned.

---------

Co-authored-by: netcatty-bot <308658023+netcatty-bot@users.noreply.github.com>
Co-authored-by: bincxz <16399091+binaricat@users.noreply.github.com>
2026-09-21 15:45:59 +08:00
陈大猫 f469513579 fix(ai): parse live-shell probe sentinels after bash PS2 (#3466)
Interactive bash prints PS2 on the probe's backslash continuations, so
strict startsWith checks miss `_Q`/`_P:` and silent PTY execs hang until
the command timeout. Strip common prompt prefixes in the parser; keep
the existing short-line probe for OpenWrt.
2026-09-21 15:34:47 +08:00
Netcatty-Botandnetcatty-bot 707972d976 fix(sftp): read local *.asar upload sources outside Electron's asar fs wrapper (#3451)
* fix(#3450): automated AI fix

* fix: address Codex review on PR #3451

* fix: address Codex review on PR #3451

* fix: address Codex review on PR #3451

* fix: address Codex review on PR #3451

* fix: address Codex review on PR #3451

* fix: address Codex review on PR #3451

---------

Co-authored-by: netcatty-bot <308658023+netcatty-bot@users.noreply.github.com>
2026-09-21 14:57:08 +08:00
Netcatty-Botandnetcatty-bot 8573027e61 fix(ssh): hang up aux exec channels after completion to stop relay session leak (#3460)
Co-authored-by: netcatty-bot <308658023+netcatty-bot@users.noreply.github.com>
2026-09-21 14:56:56 +08:00
Netcatty-Botandnetcatty-bot ff65339e04 fix(terminal): keep IME preedit commits alive when Shift ends composition (#3444)
Co-authored-by: netcatty-bot <308658023+netcatty-bot@users.noreply.github.com>
2026-09-21 14:56:48 +08:00
Netcatty-Botandnetcatty-bot a70c43e302 fix(vault): keep host groups unchanged when dragging host cards (#3442)
Co-authored-by: netcatty-bot <308658023+netcatty-bot@users.noreply.github.com>
2026-09-21 14:56:28 +08:00
github-actions[bot]andgithub-actions[bot] 7b3b8214a9 chore: update star history charts [skip ci] (#3462)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-09-21 00:10:41 +00:00
cb06857480 feat(terminal): confirm before pasting multiple lines (#3398) (#3400)
* fix(#3398): automated AI fix

* fix: address Codex review on PR #3400

* fix: address Codex review on PR #3400

* fix: address Codex review on PR #3400

* fix: address Codex review on PR #3400

* fix: address Codex review on PR #3400

* fix: address Codex review on PR #3400

* fix: address Codex review on PR #3400

* fix: address Codex review on PR #3400

* fix: address Codex review on PR #3400

* fix: address Codex review on PR #3400

* fix(terminal): protect popup and sanitized multiline pastes

* fix(terminal): preserve input state across paste confirmation

* fix(terminal): retain serial character-mode paste echo

* test(terminal): cover paced input write options

* fix(terminal): retain telnet echo for paced pastes

* test(terminal): scope serial buffer assertion to serial sessions

* fix(terminal): align runtime write options with bridge contract

* fix(terminal): preserve bookkeeping for all paced pastes

* fix(terminal): record confirmed paste lines in order

* fix: record paced paste commands after transport writes

* fix: omit paste receipts for filtered input

* fix: cancel superseded paste batches across input paths

* fix: classify each acknowledged paste line before recording

* fix: stop paced paste when editing buffered serial input

* fix: preserve recording pause boundaries and serial edit cancellation

* fix: treat paced terminal pastes as user input

* fix: preserve paste drafts until write and snippet broadcast origin

* fix: retain filtered paste drafts and invalidate urgent input ownership

* fix: broadcast paced user paste only on guarded line receipts

---------

Co-authored-by: netcatty-bot <308658023+netcatty-bot@users.noreply.github.com>
Co-authored-by: bincxz <16399091+binaricat@users.noreply.github.com>
2026-09-18 15:19:34 +08:00
GOUKI9999 0982c213e7 fix: recover paused terminal output after AI interruption (#3431) 2026-09-18 14:11:13 +08:00
0d21e37a19 fix(terminal): map macOS Command+. to interrupt running command like Ctrl+C (#3409)
* fix(#3408): automated AI fix

* fix: address Codex review on PR #3409

* fix: address Codex review on PR #3409

* fix: address Codex review on PR #3409

* fix: address Codex review on PR #3409

* fix: address Codex review on PR #3409

* fix: address Codex review on PR #3409

* fix: address Codex review on PR #3409

* fix: preserve physical releases for Command-period interrupts

* fix: allow Command-period with selected terminal text

* style: keep Command-period additions ASCII-only

* test: validate Command-period fixture without renderer errors

* fix: support logical period across macOS keyboard layouts

---------

Co-authored-by: netcatty-bot <308658023+netcatty-bot@users.noreply.github.com>
Co-authored-by: bincxz <16399091+binaricat@users.noreply.github.com>
2026-09-18 14:11:05 +08:00
9082aa08a4 fix(serial): auto-login on serial consoles with saved host credentials (#3418)
* fix(#3417): automated AI fix

* fix: address Codex review on PR #3418

* fix: address Codex review on PR #3418

* fix: address Codex review on PR #3418

* fix: address Codex review on PR #3418

* fix: address Codex review on PR #3418

* fix: address Codex review on PR #3418

* fix: address Codex review on PR #3418

* fix: address Codex review on PR #3418

* fix: address Codex review on PR #3418

* fix: guard serial quick login and reconnect startup commands

* fix: preserve serial startup while tabs sleep

---------

Co-authored-by: netcatty-bot <308658023+netcatty-bot@users.noreply.github.com>
Co-authored-by: bincxz <16399091+binaricat@users.noreply.github.com>
2026-09-18 14:10:58 +08:00
dffcefc308 fix(ai): prime echo suppression before agent input to stop OpenWrt ash variable leak (#3385)
* fix(#3384): automated AI fix

* fix(ai): order display suppression with terminal output

---------

Co-authored-by: netcatty-bot <308658023+netcatty-bot@users.noreply.github.com>
Co-authored-by: bincxz <16399091+binaricat@users.noreply.github.com>
2026-09-18 14:10:52 +08:00
6bee56220c fix(terminal): stop stale zmodem echo suppression from eating typed command (#3424)
* fix(#3423): automated AI fix

* test: clean up drag-drop suppression expiry watchdog

---------

Co-authored-by: netcatty-bot <308658023+netcatty-bot@users.noreply.github.com>
Co-authored-by: bincxz <16399091+binaricat@users.noreply.github.com>
2026-09-18 14:10:46 +08:00
Netcatty-Botandnetcatty-bot 0fa0c6a0a4 fix(sftp): treat stat-less SCP remotes as unknown size instead of failing upload (#3402)
* fix(#3399): automated AI fix

* fix: address Codex review on PR #3402

* fix: address Codex review on PR #3402

* fix: address Codex review on PR #3402

---------

Co-authored-by: netcatty-bot <308658023+netcatty-bot@users.noreply.github.com>
2026-09-18 14:10:40 +08:00
12a3ae07ef feat: Enable SFTP sidebar and button for local shell sessions (#3379)
* Add local shell side panel auto-open settings with i18n support

- Add settings to control side panel behavior for local shell sessions
- Support auto-opening side panel when local shell is launched
- Allow users to choose which pane (Files, SFTP, Scripts, etc.) opens by default
- Add complete i18n translations for en, zh-CN, zh-TW, es, ru locales

* fix: wire local shell side panel settings

* fix: restore undefined check for local shell protocol detection

Local shell sessions may have protocol=undefined, not just 'local'.
Restore the original condition to auto-open sidebar for both cases.

* fix: enable SFTP sidebar for local shell sessions

Local shell now supports SFTP (for local file browsing).
Update sftpAvailable to include local shell so the auto-open
sidebar setting works correctly for PowerShell/bash sessions.

* chore: bump version to 1.1.83-fork.1

Distinguish fork from upstream (1.1.83) and previous 0.0.0 dev version.

* feat: show SFTP button in local shell toolbar

Local shell sessions now display the 'Open SFTP' button in the toolbar,
allowing users to browse local files via SFTP sidebar, consistent with
the auto-open behavior added earlier.

* feat: 本地Shell侧边栏支持追随终端目录功能

- 追随终端目录现在支持本地 POSIX shell(bash/zsh/fish)
- 排除 PowerShell 和 cmd(因为它们的 cd 命令和路径格式不同)
- 串口连接仍然不支持(它们根本没有 SFTP)
- 定位路径到终端功能已支持本地 Shell(无需修改)

* feat: support PowerShell and CMD in session restore

- Accept Windows absolute paths (C:\...) in isRestoreCwdPathEligible
- Add shell-specific cd commands:
  * PowerShell: Set-Location -LiteralPath with '' quote escaping
  * CMD: cd /d with "" quote escaping
  * bash/POSIX: cd -- with '\'' quote escaping
- Remove PowerShell/CMD restrictions in cwd injection eligibility
- Fix line terminator in SFTP locate path (use \r\n for Windows PTY)

* fix: bound local shell panel preferences and synchronize settings

---------

Co-authored-by: HHXXYY123 <HHXXYY123@outlook.com>
Co-authored-by: binaricat <binaricat@users.noreply.github.com>
Co-authored-by: bincxz <16399091+binaricat@users.noreply.github.com>
2026-09-18 14:10:33 +08:00
nervi0s ec2f0d0bce fix: support native file dragging from local filesystem panes (#3377)
* fix: support native file dragging from local filesystem panes

* fix: preserve symlink-aware paths during native file drag
2026-09-18 14:10:26 +08:00
Ryanisgoodandbincxz 3738565b1b feat(mcp): improve external agent discovery and setup (#3407)
* fix(ai): expose renamed terminal session labels

* feat(mcp): guide agents toward Netcatty tools

* feat(mcp): load Netcatty toolsets on demand

* feat(codex): install Netcatty skill with external MCP

* feat(mcp): install skills for Claude Code and Grok

* feat(mcp): add universal client setup prompt

* fix(mcp): keep Cursor in client setup tabs

* chore(ai): refresh generated tool specs

* fix(mcp): honor Claude skill config root and run MCP tests in CI

---------

Co-authored-by: bincxz <16399091+binaricat@users.noreply.github.com>
2026-09-18 14:10:20 +08:00
d98a22f49b fix(deep-link): accept SecureCRT-style CLI launches from bastion callers (#3391)
* fix(#3390): automated AI fix

* fix: address Codex review on PR #3391

* fix: address Codex review on PR #3391

* fix: address Codex review on PR #3391

* fix: address Codex review on PR #3391

* fix: preserve SecureCRT launch targets and operand boundaries

* fix: redact SecureCRT credentials before legacy switch scrubbing

* fix: reject ambiguous SecureCRT launch destinations

* fix: reject unknown slash switches consistently

* fix: reject mixed client switches in SecureCRT launches

---------

Co-authored-by: netcatty-bot <308658023+netcatty-bot@users.noreply.github.com>
Co-authored-by: bincxz <16399091+binaricat@users.noreply.github.com>
2026-09-18 14:10:13 +08:00
08009d4ce0 feat(terminal): show GPU utilization next to CPU in server stats strip (#3411)
* fix(#3410): automated AI fix

* fix(terminal): bound GPU stats queries and complete Chinese labels

* fix(terminal): declare optional GPU fields on stats bridge

* test(terminal): keep GPU fixtures in managed temporary storage

* fix(terminal): preserve unknown GPU memory totals

---------

Co-authored-by: netcatty-bot <308658023+netcatty-bot@users.noreply.github.com>
Co-authored-by: bincxz <16399091+binaricat@users.noreply.github.com>
2026-09-18 14:10:06 +08:00
OOKAMISAMAandbincxz f841ab4778 fix(terminal): start WSL local shells from Linux home (#3412)
* Default WSL launches to Linux $HOME

When launching WSL on Windows, ensure the distro starts in the Linux home if no explicit working directory was provided. Adds isWslExecutable and getWslLaunchArgs which append `--cd ~` for wsl.exe calls that lack an explicit cwd or --cd option. startLocalSession now uses getWslLaunchArgs when building shell args. Exports getWslLaunchArgs and adds a unit test verifying the behavior. This prevents WSL from failing early when the parent Windows cwd isn't mounted or accessible to the chosen distro.

* Insert --cd before WSL command/--exec

Ensure WSL launch uses Linux home by inserting --cd "~" before the first bare command or --exec/-e boundary so the option is consumed by WSL rather than passed to the invoked shell/command. Early-return when not a WSL executable, when an explicit cwd is supplied, or when --cd is already present. Also account for WSL options that take a separate value (--distribution/-d, --user/-u). Adds a unit test verifying --cd is placed before --exec.

* fix(terminal): preserve WSL command argument boundaries

* fix(terminal): retain legacy WSL distribution selectors

---------

Co-authored-by: bincxz <16399091+binaricat@users.noreply.github.com>
2026-09-18 14:09:59 +08:00
2fa13257e7 fix(session-logs): add millisecond precision to per-line timestamps (#3416)
* fix(#3414): automated AI fix

* fix(session-logs): preserve output arrival timestamps through flushes

---------

Co-authored-by: netcatty-bot <308658023+netcatty-bot@users.noreply.github.com>
Co-authored-by: bincxz <16399091+binaricat@users.noreply.github.com>
2026-09-18 14:09:53 +08:00
8ef37454ae fix(terminal): stabilize autocomplete popup geometry against hover jitter (#3427)
* fix(#3426): automated AI fix

* fix: address Codex review on PR #3427

* fix: keep autocomplete detail reserve within narrow panes

---------

Co-authored-by: netcatty-bot <308658023+netcatty-bot@users.noreply.github.com>
Co-authored-by: bincxz <16399091+binaricat@users.noreply.github.com>
2026-09-18 14:09:47 +08:00
7e4b3c293a fix(vault): warn about ssh_config data loss before deleting managed group (#3429)
* fix(#3428): automated AI fix

* fix: address Codex review on PR #3429

* fix: address Codex review on PR #3429

* fix: address Codex review on PR #3429

* fix: require confirmation for changed managed deletion files

* fix: bind managed deletion confirmation to group scope

---------

Co-authored-by: netcatty-bot <308658023+netcatty-bot@users.noreply.github.com>
Co-authored-by: bincxz <16399091+binaricat@users.noreply.github.com>
2026-09-18 14:09:40 +08:00
陈大猫 bf9fa571ee fix(sftp): preserve complete folder recovery after ownership changes (#3436)
* fix(sftp): preserve folder recovery completion and ownership

* fix(sftp): discard displaced updates before batch flush

* fix(sftp): bind recovery admission to reset and owner state

* fix(sftp): preserve replacement owners on rejected invocations

* fix(sftp): expire initial identity exemptions on activation

* fix(sftp): preserve recovery ownership through deferred writes
2026-09-18 14:09:34 +08:00
陈大猫 477bc0c4ec fix(ssh): reject lsof error text as terminal upload paths (#3432)
* fix(ssh): reject lsof diagnostics as terminal directories

* fix(ssh): retain vnode directory cwd fallback
2026-09-18 14:09:27 +08:00
陈大猫 741299f71d fix(terminal): pass through unavailable broadcast shortcuts (#3435) 2026-09-18 14:09:21 +08:00
陈大猫 349755aaf7 fix(shortcuts): leave Ctrl+L available for terminal clearing (#3433) 2026-09-18 14:09:15 +08:00
陈大猫 199afd56f6 fix(ai): handle sparse streamed tool call indexes (#3434)
* fix(ai): normalize sparse streamed tool call indexes

* test(ai): isolate mocked bridge window typing
2026-09-18 14:09:08 +08:00
246fc39536 fix(vault): round-trip proxy config in CSV import/export (#3438)
* fix(#3437): automated AI fix

* fix: preserve effective CSV proxy settings and reject invalid endpoints

* fix: warn when CSV proxy credentials cannot be exported

---------

Co-authored-by: netcatty-bot <308658023+netcatty-bot@users.noreply.github.com>
Co-authored-by: bincxz <16399091+binaricat@users.noreply.github.com>
2026-09-18 14:08:17 +08:00
陈大猫 c16f0c43d4 Merge pull request #3401 from OOKAMISAMA/main
feat(tab): Add configurable terminal tab double-click behavior
2026-09-15 22:52:12 +08:00
OOKAMI 4df94736d0 Update TopTabs.test.ts 2026-09-15 17:55:59 +08:00
OOKAMI 91c3defe66 Add configurable terminal tab double-click behavior
Introduce a new TerminalTabDoubleClickBehavior setting (duplicate | copy | disabled) with default 'duplicate'. Add model field, normalization, default, and sync key (tabDoubleClickBehavior). UI: TerminalBehaviorSettings adds a Select; AppView and TopTabs pass the setting through; TopTabItems implements createTopTabSessionDoubleClickHandler to perform duplicate/copy with a safe fallback. Add i18n strings, settings search entry, and update tests to cover behavior and serialization. Improves user control over double-click actions on session tabs.
2026-09-15 16:56:55 +08:00
陈大猫 faf74ae9be Merge pull request #3395 from binaricat/ai/issue-3394-34924104389
fix(ai): replay signed Anthropic thinking blocks in Catty follow-up turns
2026-09-15 13:47:20 +08:00
陈大猫 cbace2f2ac Merge pull request #3389 from lengyuqu/codebuddy
feat: upgrade CodeBuddy SDK integration
2026-09-15 13:46:41 +08:00
netcatty-bot a52726aef4 fix(#3394): automated AI fix 2026-09-15 03:36:18 +00:00
lengyuqu 605dc94b13 fix: clarify CodeBuddy session persistence scope 2026-09-14 17:02:06 +08:00
lengyuqu 9e74618f2e feat: upgrade CodeBuddy SDK integration 2026-09-14 16:38:33 +08:00
github-actions[bot]andgithub-actions[bot] b46910d46e chore: update star history charts [skip ci] (#3383)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-09-14 00:11:12 +00:00
陈大猫 d8ea77c28b Merge pull request #3373 from binaricat/codex/3135-custom-provider-headers
feat(ai): configure custom provider HTTP headers securely
2026-09-13 16:37:09 +08:00
bincxz 210879b848 fix: prepare sync credentials before protected apply begins 2026-09-13 16:22:11 +08:00
bincxz db0238ce6e fix: prepare provider headers before applying sync data 2026-09-13 16:07:26 +08:00
bincxz 6f30bbefb5 Merge main into custom provider headers PR 2026-09-13 15:46:12 +08:00
陈大猫 843c81ca44 Merge pull request #3376 from binaricat/ai/issue-3375-34697848012
fix(sftp): expand tree folders in place on double click
2026-09-13 15:44:39 +08:00
陈大猫 f1e59913c2 Merge pull request #3368 from binaricat/codex/issue-3153-right-long-press
feat(terminal): add optional right-click hold menu
2026-09-13 15:44:05 +08:00
bincxz a95eb62cb1 fix(sftp): isolate tree arrow double clicks and test real events 2026-09-13 15:32:32 +08:00
bincxz 7205c40a7c Merge latest main into right-click hold menu PR 2026-09-13 15:31:53 +08:00
陈大猫 7cf8ebac7e Merge pull request #3359 from binaricat/ai/issue-3358-34674962452
feat(ai): add reasoning effort option to provider advanced params
2026-09-13 15:29:24 +08:00
陈大猫 48c721bf43 Merge pull request #3372 from binaricat/codex/issue-3151-group-notes
feat: add Markdown notes to host groups
2026-09-13 15:29:20 +08:00
陈大猫 756f332db5 Merge pull request #3371 from binaricat/codex/issue-2383-sftp-command-refresh
feat(sftp): refresh visible sidebar after terminal commands
2026-09-13 15:29:16 +08:00
陈大猫 2ee55d5b4b Merge pull request #3370 from binaricat/codex/3154-terminal-read-context
feat: expose scoped terminal screen reads to MCP
2026-09-13 15:29:12 +08:00
陈大猫 2e588630a1 Merge pull request #3366 from binaricat/codex/issue-2407-tabs-position
feat(ui): allow placing the tab bar at the bottom
2026-09-13 15:29:07 +08:00
陈大猫 e9cc0d54d2 Merge pull request #3367 from binaricat/codex/issue-2450-ip-sort
feat: add optional IP sorting for vault hosts
2026-09-13 15:29:03 +08:00
陈大猫 57cd40a192 Merge pull request #3365 from binaricat/codex/issue-2645-compose-history
feat: recall compose history within the current terminal session
2026-09-13 15:28:59 +08:00
陈大猫 e5dc30ba2f Merge pull request #3364 from binaricat/codex/issue-3273-save-terminal-screen
feat(terminal): save the current rendered screen
2026-09-13 15:28:55 +08:00
陈大猫 e9d2963fe5 Merge pull request #3362 from binaricat/codex/issue-3139-command-arrows
feat(terminal): support Command+Left/Right line navigation on macOS
2026-09-13 15:28:51 +08:00
陈大猫 d0af56b4dd Merge pull request #3369 from binaricat/codex/issue-3351-unsaved-password-sftp
fix(sftp): reuse terminal login with an unsaved password
2026-09-13 15:28:47 +08:00
陈大猫 09f7fcf248 Merge pull request #3363 from binaricat/codex/issue-2326-tab-menu-order
fix(tabs): reorder session menu to reduce accidental reconnects
2026-09-13 15:28:42 +08:00
陈大猫 9e63ffd99e Merge pull request #3361 from binaricat/codex/issue-2773-scoop-docs
docs: add community Scoop installation instructions
2026-09-13 15:28:38 +08:00
netcatty-bot f24b5f4202 fix(#3375): automated AI fix 2026-09-12 14:14:36 +00:00
bincxz 26278a8694 fix(ai): reject transport-owned custom framing headers 2026-09-12 18:08:55 +08:00
bincxz e10e5ccbc2 Revert "fix: address Codex review on PR #3373"
This reverts commit dd5749460f.
2026-09-12 17:59:28 +08:00
bincxz d9ca951cc9 fix: restore compose draft when leaving session history 2026-09-12 17:47:27 +08:00
bincxz 00ec0d04c4 fix: keep compose history browsing stable during late sends 2026-09-12 17:45:00 +08:00
netcatty-bot dd5749460f fix: address Codex review on PR #3373 2026-09-12 09:43:28 +00:00
bincxz 00c9cf440f fix: record only deliverable workspace compose sends 2026-09-12 17:36:55 +08:00
bincxz 3b1a4adc82 fix: keep compose history in submission order 2026-09-12 17:27:36 +08:00
bincxz ac65d465cf fix(ai): preserve ChatGPT 4o reasoning guard 2026-09-12 17:22:03 +08:00
bincxz 2738adb2f8 fix: route terminal reads to the owning window 2026-09-12 17:20:59 +08:00
bincxz 688f544034 test(ai): complete custom provider fixture 2026-09-12 17:18:37 +08:00
bincxz f9ad1ddfe0 fix(sftp): retain selection reveal after explicit view changes 2026-09-12 17:17:20 +08:00
bincxz a0d032ec3c fix(terminal): guard password prompts and bypass directory completion for Command arrows 2026-09-12 17:16:38 +08:00
bincxz e9b0dadd2a fix(ai): limit reasoning alias opt-in to custom providers 2026-09-12 17:16:04 +08:00
bincxz be511f8546 test: include screen reads in builtin RPC coverage 2026-09-12 17:15:42 +08:00
bincxz 214bc8e7f1 test(ai): verify custom header removal persists 2026-09-12 17:13:42 +08:00
bincxz 758029bc29 feat(ai): configure and securely store custom provider headers 2026-09-12 17:12:34 +08:00
bincxz 8b5db49001 fix(terminal): sync hold preference and allow disconnected menu 2026-09-12 17:09:49 +08:00
bincxz 87f794d17d fix: preserve compose drafts and await send results 2026-09-12 17:09:05 +08:00
bincxz cb35353fde test(ai): type provider reasoning request checks 2026-09-12 17:08:48 +08:00
bincxz ce86e8e426 feat(sftp): refresh visible sidebar after terminal commands 2026-09-12 17:07:35 +08:00
bincxz 0714ca5ee0 fix: compare IPv6 host addresses numerically 2026-09-12 17:07:22 +08:00
bincxz b081673c2b feat: expose scoped terminal screen reads to MCP 2026-09-12 17:06:08 +08:00
bincxz 131ed03ffa fix(ui): align bottom controls and preserve chrome theme 2026-09-12 17:06:07 +08:00
bincxz 1fc98cd654 fix(ai): keep non-reasoning model defaults compatible 2026-09-12 17:05:27 +08:00
bincxz 28f7193c4a fix(terminal): keep Command arrow edits local during broadcast 2026-09-12 17:03:51 +08:00
bincxz dbdc8631dd fix(sftp): reuse terminal login with an unsaved password 2026-09-12 17:03:41 +08:00
bincxz a05e004259 feat(terminal): add optional right-click hold menu 2026-09-12 17:02:46 +08:00
bincxz cae4169948 fix: translate IP sorting in Spanish 2026-09-12 17:02:45 +08:00
bincxz 7ec9cd652b feat(ui): allow placing the tab bar at the bottom 2026-09-12 16:56:53 +08:00
bincxz 3987e465b6 feat: add optional IP sorting for vault hosts 2026-09-12 16:56:25 +08:00
bincxz d8761c156e feat(terminal): save the current rendered screen 2026-09-12 16:55:18 +08:00
bincxz 47bb5c2b55 feat: recall compose bar history within each terminal session 2026-09-12 16:51:52 +08:00
bincxz dc05697ede fix(ai): honor provider reasoning defaults for custom models 2026-09-12 16:50:40 +08:00
bincxz 51d4d97419 feat(terminal): support macOS Command arrows for line navigation 2026-09-12 16:48:31 +08:00
bincxz 530f57c95c fix(tabs): place copy actions before reconnect in session menu 2026-09-12 16:48:20 +08:00
bincxz da0938c209 docs: separate Scoop update and uninstall examples 2026-09-12 16:40:52 +08:00
bincxz 9e6846e0d5 docs: document community Scoop installation for Windows 2026-09-12 16:39:50 +08:00
netcatty-bot 0b96d70702 fix: address Codex review on PR #3359 2026-09-12 05:59:48 +00:00
netcatty-bot 818c6fd551 fix(#3358): automated AI fix 2026-09-12 05:35:51 +00:00
368 changed files with 18346 additions and 930 deletions

No files matched your search

+2 -2
View File
@@ -94,7 +94,7 @@ Placement rules (`resolveAgentKinds` in `toolSurfaces.cjs`):
|---------|-------------------|--------|
| Catty (sidebar) tools | `npm run generate:capability-tools` → `infrastructure/ai/harness/generated/cattyToolSpecs.json` | Sidebar agent tool set. CI verifies JSON drift. |
| Global agent tools | same script → `globalAgentToolSpecs.json` | Prepared for future global agent runtime; shared RPC tools only today. |
| MCP stdio | `electron/capabilities/codegen/mcpToolRegistry.cjs` → `electron/mcp/netcatty-mcp-server.cjs` | Registry-driven; external agents. Harness tools are **not** on MCP. |
| MCP stdio | `electron/capabilities/codegen/mcpToolRegistry.cjs` → `electron/mcp/netcatty-mcp-server.cjs` | Registry-driven; external agents. Only `harness.terminal.read_context` is also on MCP. |
| CLI | `electron/cli/netcatty-tool-cli.cjs` + `electron/capabilities/adapters/cliAdapter.cjs` | **30** catalog commands; exec/sftp/session remain special-case; vault/portforward/snippets use catalog fallback dispatch |
| RPC dispatch | `electron/bridges/mcpServerBridge.cjs` + `capabilityRpcDispatch.cjs` | `netcatty/*` builtin handlers via `buildBuiltinRpcHandlerRegistry` (catalog-aligned); `public/*`, `vault/*`, `portforward/*` → services |
| Vault bridge | `electron/bridges/aiBridge/vaultAgentBridge.cjs` + `infrastructure/ai/vaultAgentBridgeClient.ts` | Renderer vault state; **never** returns password/privateKey |
@@ -104,7 +104,7 @@ Placement rules (`resolveAgentKinds` in `toolSurfaces.cjs`):
**Handles:** `ToolOutputStore` persists across turns per chat session; cleared on chat session delete. Large `sftp.read` results spill to `tool_output_read`.
**Harness domain (`catalog/harness.cjs`):** Catty-only surface (`surfaces.catty.toolName`). Registered in the capability catalog but executed locally in `capabilityTools.executeLocalCattyCapability` (not MCP/CLI). `harness.web.search` is omitted when web search is not configured.
**Harness domain (`catalog/harness.cjs`):** Catty executes these locally in `capabilityTools.executeLocalCattyCapability`. The read-only `harness.terminal.read_context` also exposes `netcatty/readContext` to MCP, with scope validation before and after the existing renderer bridge call. It reuses the sidebar reader through the screen snapshot registry (80 rows by default, maximum 300); no script or command runs. Other harness tools remain Catty-only. `harness.web.search` is omitted when web search is not configured.
## Plugin host runtime (internal preview)
+2
View File
@@ -11,6 +11,7 @@ import { TooltipProvider } from './components/ui/tooltip';
import { ScriptAutomationRoot } from './components/scripts/ScriptAutomationRoot';
import { ExternalMcpApprovalsHost } from './components/ai/ExternalMcpApprovalsHost';
import { PluginAuthenticationHost } from './components/plugins/PluginAuthenticationHost';
import { MultilinePasteConfirmHost } from './components/terminal/MultilinePasteConfirmDialog';
import { useExternalMcpGrantPersister } from './components/ai/useExternalMcpGrantPersister';
import { setupMcpApprovalBridge } from './infrastructure/ai/shared/approvalGate';
import { setupCodexAppServerInteractionBridge } from './infrastructure/ai/shared/codexAppServerInteractions';
@@ -81,6 +82,7 @@ function AppWithProviders({ settings, appLock }: { settings: SettingsState; appL
<ScriptAutomationRoot />
<ExternalMcpApprovalsHost />
<PluginAuthenticationHost />
<MultilinePasteConfirmHost />
<VaultPublisher>
<SessionPublisher persistSessionRestore={!isPeerSessionWindow}>
<App />
+24
View File
@@ -220,6 +220,30 @@ Windows リリースは未署名の場合があります。
> **macOS ユーザーへ:** 現在のリリースはコード署名と notarization が行われている想定です。Gatekeeper の警告が出る場合は、GitHub Releases から最新版の公式ビルドを取得しているか確認してください。
### Scoop(Windows x64、コミュニティ管理)
[Scoop](https://scoop.sh/) をインストール済みの場合、コミュニティが管理する [lemon バケット](https://github.com/hoilc/scoop-lemon/blob/master/bucket/netcatty.json) から Netcatty をインストールできます。このバケットは Netcatty の公式 GitHub Releases からインストーラーをダウンロードします:
```powershell
scoop bucket add lemon https://github.com/hoilc/scoop-lemon
scoop install lemon/netcatty
```
更新またはアンインストールの前に Netcatty を終了してください:
```powershell
scoop update
scoop update netcatty
```
アンインストールする場合:
```powershell
scoop uninstall netcatty
```
これはサードパーティ製パッケージであり、Netcatty の公式バケットではありません。更新は GitHub Releases より遅れる場合があります。現在は x64 のみに対応しています。設定は `%APPDATA%\netcatty` に保存され、更新や通常のアンインストール後も残ります。`scoop uninstall netcatty --purge` はこれらの設定も削除します。この方法でインストールした場合は、Scoop で更新を管理してください。
### Nix / NixOS
Netcatty は Nix および NixOS ユーザー向けに、公式 Linux AppImage リリースをラップした flake を提供しています:
+25
View File
@@ -127,6 +127,7 @@ If you regularly work with a fleet of servers, Netcatty is built for speed and f
### 🖥️ Terminal Workspaces
- **Split panes** — horizontal and vertical splits for multi-tasking
- **Session management** — run multiple connections side-by-side
- **Save current screen** — right-click a terminal to save the visible final text, without replaying keystrokes or overwritten output; scroll first to save an earlier screen.
- **Inline images** — render Kitty graphics, SIXEL and iTerm inline images from remote programs
### 📁 SFTP + Built-in Editor
@@ -221,6 +222,30 @@ application and integration are complete.
> **macOS Users:** Current releases are expected to be code-signed and notarized. If Gatekeeper still warns, make sure you downloaded the latest official build from GitHub Releases.
### Scoop (Windows x64, community-maintained)
With [Scoop](https://scoop.sh/) installed, you can install Netcatty from the community-maintained [lemon bucket](https://github.com/hoilc/scoop-lemon/blob/master/bucket/netcatty.json), which downloads the installer from Netcatty's official GitHub Releases:
```powershell
scoop bucket add lemon https://github.com/hoilc/scoop-lemon
scoop install lemon/netcatty
```
Exit Netcatty before updating or uninstalling:
```powershell
scoop update
scoop update netcatty
```
To uninstall:
```powershell
scoop uninstall netcatty
```
This is a third-party package, not an official Netcatty bucket, and updates may lag behind GitHub Releases. It currently supports x64 only. Settings remain in `%APPDATA%\netcatty` across updates and normal uninstalls; `scoop uninstall netcatty --purge` also deletes those settings. Use Scoop to manage updates for this installation.
### Nix / NixOS
Netcatty provides a flake that wraps the official Linux AppImage release for Nix and NixOS users:
+24
View File
@@ -219,6 +219,30 @@ Windows 发布文件可能仍未签名。
> **macOS 用户注意:** 当前发布版本应已完成代码签名和公证。如果 Gatekeeper 仍然提示风险,请确认您下载的是 GitHub Releases 中的最新官方构建。
### Scoop(Windows x64,社区维护)
安装 [Scoop](https://scoop.sh/) 后,可以通过社区维护的 [lemon 软件源](https://github.com/hoilc/scoop-lemon/blob/master/bucket/netcatty.json) 安装 Netcatty。该软件源从 Netcatty 官方 GitHub Releases 下载安装文件:
```powershell
scoop bucket add lemon https://github.com/hoilc/scoop-lemon
scoop install lemon/netcatty
```
更新或卸载前,请先退出 Netcatty:
```powershell
scoop update
scoop update netcatty
```
卸载:
```powershell
scoop uninstall netcatty
```
这是第三方软件包,并非 Netcatty 官方软件源,更新可能晚于 GitHub Releases。目前仅支持 x64。设置保存在 `%APPDATA%\netcatty`,更新和普通卸载会保留这些设置;`scoop uninstall netcatty --purge` 还会删除这些设置。通过此方式安装后,请使用 Scoop 管理更新。
### Nix / NixOS
Netcatty 提供了一个 flake,为 Nix 和 NixOS 用户封装了官方 Linux AppImage 发行版:
+24
View File
@@ -219,6 +219,30 @@ Windows 發行檔可能仍未簽章。
> **macOS 使用者請注意:** 目前的發行版本應該都已完成程式碼簽章與公證。如果 Gatekeeper 仍然跳出警告,請確認你下載的是 GitHub Releases 上最新的官方建置版本。
### Scoop(Windows x64,社群維護)
安裝 [Scoop](https://scoop.sh/) 後,可以透過社群維護的 [lemon 軟體來源](https://github.com/hoilc/scoop-lemon/blob/master/bucket/netcatty.json) 安裝 Netcatty。此軟體來源從 Netcatty 官方 GitHub Releases 下載安裝檔:
```powershell
scoop bucket add lemon https://github.com/hoilc/scoop-lemon
scoop install lemon/netcatty
```
更新或解除安裝前,請先結束 Netcatty:
```powershell
scoop update
scoop update netcatty
```
解除安裝:
```powershell
scoop uninstall netcatty
```
這是第三方套件,並非 Netcatty 官方軟體來源,更新可能晚於 GitHub Releases。目前僅支援 x64。設定儲存在 `%APPDATA%\netcatty`,更新和一般解除安裝會保留這些設定;`scoop uninstall netcatty --purge` 也會刪除這些設定。透過此方式安裝後,請使用 Scoop 管理更新。
### Nix / NixOS
Netcatty 提供了一個 flake,替 Nix 與 NixOS 使用者包裝官方的 Linux AppImage 發行版:
@@ -698,3 +698,51 @@ test('connection log host snapshot includes custom host icon fields', () => {
},
);
});
for (const scenario of [
{ name: 'single orphan', active: 'one', sessions: [{ id: 'one' }], available: false, expected: null },
{ name: 'two orphans', active: 'one', sessions: [{ id: 'one' }, { id: 'two' }], available: true, expected: 'global' },
{ name: 'hidden active session', active: 'hidden', sessions: [{ id: 'one' }, { id: 'two' }, { id: 'hidden', hiddenFromTabs: true }], available: true, expected: null },
{ name: 'non-terminal tab', active: 'vault', sessions: [{ id: 'one' }, { id: 'two' }], available: true, expected: null },
{ name: 'workspace', active: 'workspace', sessions: [{ id: 'one', workspaceId: 'workspace' }, { id: 'two', workspaceId: 'workspace' }], available: false, expected: 'workspace' },
]) {
for (const scheme of ['pc', 'mac'] as const) {
test(`broadcast shortcut ${scheme}: ${scenario.name} only consumes an executed toggle`, () => {
const target = new FakeHTMLElement();
let prevented = 0;
let stopped = 0;
let globalEnabled = false;
let workspaceEnabled = false;
const toggles: string[] = [];
const event = {
key: 'b', code: 'KeyB', ctrlKey: scheme === 'pc', metaKey: scheme === 'mac',
altKey: false, shiftKey: false, target, composedPath: () => [target],
preventDefault: () => { prevented += 1; },
stopPropagation: () => { stopped += 1; },
} as unknown as KeyboardEvent;
const actionCtx = {
activeTabStore: { getActiveTabId: () => scenario.active },
editorTabs: [], orderedTabs: [], settings: {}, toEditorTabId: (id: string) => id,
sessions: scenario.sessions,
workspaces: scenario.active === 'workspace' ? [{ id: 'workspace' }] : [],
canUseGlobalBroadcast: scenario.available,
toggleGlobalBroadcast: () => { globalEnabled = !globalEnabled; toggles.push('global'); },
toggleBroadcast: (id: string) => { assert.equal(id, 'workspace'); workspaceEnabled = !workspaceEnabled; toggles.push('workspace'); },
};
const invoke = () => handleGlobalHotkeyKeyDownImpl(() => ({
HOTKEY_DEBUG: false, closeTabKeyStr: '', hotkeyScheme: scheme,
keyBindings: DEFAULT_KEY_BINDINGS, matchesKeyBinding,
executeHotkeyAction: (action: string, e: KeyboardEvent) => executeHotkeyActionImpl(() => actionCtx, action, e),
}), event);
invoke();
assert.equal(globalEnabled, scenario.expected === 'global');
assert.equal(workspaceEnabled, scenario.expected === 'workspace');
invoke();
assert.equal(globalEnabled, false);
assert.equal(workspaceEnabled, false);
assert.deepEqual(toggles, scenario.expected ? [scenario.expected, scenario.expected] : []);
assert.equal(prevented, scenario.expected ? 2 : 0);
assert.equal(stopped, scenario.expected ? 2 : 0);
});
}
}
+14 -2
View File
@@ -313,6 +313,16 @@ export function handleGlobalHotkeyKeyDownImpl(getCtx: AppContextGetter, e: Keybo
continue;
}
// An unavailable broadcast action must leave the chord to the terminal
// (notably tmux's Ctrl+B prefix). Dispatch once before consuming it.
if (binding.action === 'broadcast') {
if (executeHotkeyAction(binding.action, e) === true) {
e.preventDefault();
e.stopPropagation();
}
return;
}
e.preventDefault();
e.stopPropagation();
if (HOTKEY_DEBUG) {
@@ -963,12 +973,14 @@ export function executeHotkeyActionImpl(getCtx: AppContextGetter, action: string
const activeWs = workspaces.find(w => w.id === currentId);
if (activeWs) {
toggleBroadcast(activeWs.id);
return true;
} else if (sessions.some((session: TerminalSession) => (
session.id === currentId && !session.workspaceId
session.id === currentId && !session.workspaceId && !session.hiddenFromTabs
)) && canUseGlobalBroadcast) {
toggleGlobalBroadcast();
return true;
}
break;
return false;
}
case 'openSettings':
handleOpenSettingsRef.current();
+16 -6
View File
@@ -57,7 +57,7 @@ import { resolveCloseIntent } from '../state/resolveCloseIntent';
import { resolveSnippetsShortcutIntent } from '../state/resolveSnippetsShortcutIntent';
import { isPrimaryModifierWBinding, resolveWindowCommandCloseIntent } from '../state/windowCommandClose';
import type { SyncPayload } from '../../domain/sync';
import { applySyncPayload, buildLocalVaultPayloadAsync, hasMeaningfulSyncData } from '../syncPayload';
import { prepareSyncPayloadApply, buildLocalVaultPayloadAsync, hasMeaningfulSyncData } from '../syncPayload';
import {
applyProtectedSyncPayload,
ensureVersionChangeBackup,
@@ -252,6 +252,8 @@ export function AppSideEffects() {
runSnippet,
getOrderedWorkTabs,
toggleBroadcast,
toggleGlobalBroadcast,
canUseGlobalBroadcast,
logViews,
closeLogView,
copySession,
@@ -502,8 +504,8 @@ export function AppSideEffects() {
(payload: SyncPayload) =>
applyProtectedSyncPayload({
buildPreApplyPayload: () => buildCurrentSyncPayload(),
applyPayload: () =>
applySyncPayload(payload, {
prepareApply: () =>
prepareSyncPayloadApply(payload, {
importVaultData: importDataFromString,
importPortForwardingRules,
onSettingsApplied: settings.rehydrateAllFromStorage,
@@ -525,14 +527,17 @@ export function AppSideEffects() {
(payload: SyncPayload, commitReplica: () => Promise<void>) =>
applyProtectedSyncPayload({
buildPreApplyPayload: () => buildCurrentSyncPayload(),
applyPayload: async () => {
prepareApply: async () => {
const portable = stripSyncPayloadEncryptedCredentials(payload);
await applySyncPayload(portable, {
const applyPreparedPayload = await prepareSyncPayloadApply(portable, {
importVaultData: importDataFromString,
importPortForwardingRules,
onSettingsApplied: settings.rehydrateAllFromStorage,
}, { currentHosts: hosts });
await commitReplica();
return async () => {
await applyPreparedPayload();
await commitReplica();
};
},
translateProtectiveBackupFailure: (message) =>
t('cloudSync.localBackups.protectiveBackupFailed', { message }),
@@ -903,6 +908,8 @@ export function AppSideEffects() {
// array thrash does not rebuild appTerminalDomain (see domain isolation).
const sessionsRef = useRef(sessions);
sessionsRef.current = sessions;
const canUseGlobalBroadcastRef = useRef(canUseGlobalBroadcast);
canUseGlobalBroadcastRef.current = canUseGlobalBroadcast;
// Logs live in connectionLogsStore — keep a ref fresh without re-rendering App.
const connectionLogsRef = useRef(getConnectionLogsSnapshot().connectionLogs);
useEffect(() => {
@@ -1073,6 +1080,8 @@ export function AppSideEffects() {
terminalPaneMagnificationRef,
toEditorTabId,
toggleBroadcast,
toggleGlobalBroadcast,
canUseGlobalBroadcast: canUseGlobalBroadcastRef.current,
toggleScriptsSidePanelRef,
toggleSidePanelRef,
toggleWorkspaceViewMode,
@@ -1087,6 +1096,7 @@ export function AppSideEffects() {
splitSessionWithCurrentShell,
moveFocusInWorkspace,
toggleBroadcast,
toggleGlobalBroadcast,
toggleWorkspaceViewMode,
confirmIfBusyLocalTerminal,
]);
+5
View File
@@ -300,6 +300,8 @@ function AppViewInner({ domains }: AppViewProps) {
restoreTerminalCwd,
terminalSidePanelAutoOpen,
terminalSidePanelAutoOpenTab,
localShellSidePanelAutoOpen,
localShellSidePanelAutoOpenTab,
} = useSettingsChromeStore();
const { setTheme, setWindowOpacity } = useSettingsChromeActions();
@@ -504,6 +506,7 @@ function AppViewInner({ domains }: AppViewProps) {
onRenameSession={startSessionRename}
onCopySession={copySessionWithCurrentShell}
onDuplicateSession={duplicateSessionWithCurrentShell}
tabDoubleClickBehavior={terminalSettings.tabDoubleClickBehavior}
onCopySessionToNewWindow={copySessionToNewWindowWithCurrentShell}
onEditHost={handleEditHostFromOverlay}
onRenameWorkspace={startWorkspaceRename}
@@ -751,6 +754,8 @@ function AppViewInner({ domains }: AppViewProps) {
sftpAutoOpenSidebar={sftpAutoOpenSidebar}
terminalSidePanelAutoOpen={terminalSidePanelAutoOpen}
terminalSidePanelAutoOpenTab={terminalSidePanelAutoOpenTab}
localShellSidePanelAutoOpen={localShellSidePanelAutoOpen}
localShellSidePanelAutoOpenTab={localShellSidePanelAutoOpenTab}
sftpFollowTerminalCwd={sftpFollowTerminalCwd}
setSftpFollowTerminalCwd={setSftpFollowTerminalCwd}
editorWordWrap={editorWordWrap}
+22 -6
View File
@@ -1,10 +1,12 @@
import type { TransferOwnerObservation } from "../state/sftp/waitForTransferOwner";
import type { TransferStatus, TransferTask } from "../../domain/models";
export const DEDICATED_RESUME_LARGE_HISTORY_THRESHOLD = 4_096;
export const DEDICATED_RESUME_CHILD_UPDATE_BATCH_SIZE = 512;
export interface DedicatedResumeChildUpdateBatcher {
push(task: TransferTask): void;
push(task: TransferTask, observation?: TransferOwnerObservation): void;
discard(taskId: string): void;
flush(): void;
}
@@ -24,25 +26,39 @@ export function createDedicatedResumeChildUpdateBatcher(deps: {
hasTask: (taskId: string) => boolean;
upsertTasks: (tasks: readonly TransferTask[]) => void;
}): DedicatedResumeChildUpdateBatcher {
const pending = new Map<string, TransferTask>();
const pending = new Map<string, { task: TransferTask; observation?: TransferOwnerObservation }>();
const flush = () => {
if (pending.size === 0) return;
const batch = [...pending.values()];
pending.clear();
deps.upsertTasks(batch);
try {
const updates = batch.filter(({ observation }) => !observation?.hasIdentityConflict()).map(({ task }) => task);
if (updates.length) deps.upsertTasks(updates);
} finally {
for (const { observation } of batch) observation?.dispose();
}
};
return {
push(task) {
push(task, observation) {
if (observation?.hasIdentityConflict()) {
pending.get(task.id)?.observation?.dispose();
pending.delete(task.id);
observation.dispose();
return;
}
const shouldBatch = !!task.parentTaskId
&& deps.getTaskCount() >= DEDICATED_RESUME_LARGE_HISTORY_THRESHOLD
&& deps.hasTask(task.id);
if (!shouldBatch) {
deps.upsertTasks([task]);
try { deps.upsertTasks([task]); } finally { observation?.dispose(); }
return;
}
pending.set(task.id, task);
const previous = pending.get(task.id);
if (previous?.observation && previous.observation !== observation) previous.observation.dispose();
pending.set(task.id, { task, observation });
if (pending.size >= DEDICATED_RESUME_CHILD_UPDATE_BATCH_SIZE) flush();
},
discard: (taskId) => { pending.get(taskId)?.observation?.dispose(); pending.delete(taskId); },
flush,
};
}
@@ -28,3 +28,35 @@ test("top tabs chrome theme keeps accent foreground in sync", () => {
assert.match(supportSource, /removeStylePropertyIfSet\(tabsRoot, '--primary-foreground'\)/);
assert.match(supportSource, /removeStylePropertyIfSet\(tabsRoot, '--accent-foreground'\)/);
});
test("bottom title bar receives and clears the same active theme as the tabs", async () => {
const { applyTopTabsChromeThemeVars, clearTopTabsChromeThemeVars } = await import('./topTabsChromeTheme.ts');
const { TERMINAL_THEMES } = await import('../../infrastructure/config/terminalThemes.ts');
const makeRoot = () => {
const values = new Map<string, string>();
return { style: {
getPropertyValue: (key: string) => values.get(key) ?? '',
setProperty: (key: string, value: string) => values.set(key, value),
removeProperty: (key: string) => values.delete(key),
}};
};
const tabs = makeRoot();
const titlebar = makeRoot();
const original = Object.getOwnPropertyDescriptor(globalThis, 'document');
Object.defineProperty(globalThis, 'document', { configurable: true, value: {
querySelector: (selector: string) => selector === '[data-top-tabs-root]' ? tabs : titlebar,
}});
try {
applyTopTabsChromeThemeVars(TERMINAL_THEMES[0]);
for (const property of ['--secondary', '--foreground', '--top-tabs-bg']) {
assert.notEqual(tabs.style.getPropertyValue(property), '');
assert.equal(titlebar.style.getPropertyValue(property), tabs.style.getPropertyValue(property));
}
clearTopTabsChromeThemeVars();
assert.equal(tabs.style.getPropertyValue('--secondary'), '');
assert.equal(titlebar.style.getPropertyValue('--secondary'), '');
} finally {
if (original) Object.defineProperty(globalThis, 'document', original);
else Reflect.deleteProperty(globalThis, 'document');
}
});
+30 -20
View File
@@ -70,23 +70,31 @@ const TOP_TABS_THEME_PROPERTIES = [
'--muted-foreground',
] as const;
export function getTopTabsChromeRoots(): HTMLElement[] {
if (typeof document === 'undefined') return [];
return [
document.querySelector<HTMLElement>('[data-top-tabs-root]'),
document.querySelector<HTMLElement>('[data-tab-window-titlebar]'),
].filter((root): root is HTMLElement => root !== null);
}
let topTabsChromeThemeVarsApplied = false;
export function clearTopTabsChromeThemeVars(): void {
if (typeof document === 'undefined') return;
if (!topTabsChromeThemeVarsApplied) return;
const tabsRoot = document.querySelector<HTMLElement>('[data-top-tabs-root]');
if (!tabsRoot) return;
for (const property of TOP_TABS_THEME_PROPERTIES) {
removeStylePropertyIfSet(tabsRoot, property);
for (const root of getTopTabsChromeRoots()) {
for (const property of TOP_TABS_THEME_PROPERTIES) {
removeStylePropertyIfSet(root, property);
}
}
topTabsChromeThemeVarsApplied = false;
}
export function applyTopTabsChromeThemeVars(theme: TerminalTheme): void {
if (typeof document === 'undefined') return;
const tabsRoot = document.querySelector<HTMLElement>('[data-top-tabs-root]');
if (!tabsRoot) return;
const roots = getTopTabsChromeRoots();
if (roots.length === 0) return;
const bg = hexToHslToken(theme.colors.background);
const fg = hexToHslToken(theme.colors.foreground);
@@ -97,19 +105,21 @@ export function applyTopTabsChromeThemeVars(theme: TerminalTheme): void {
const border = adjustLightnessToken(bg, isDark ? 12 : -10);
const mutedFg = adjustSaturationToken(adjustLightnessToken(fg, isDark ? -20 : 20), 0.5);
setStylePropertyIfChanged(tabsRoot, '--background', bg);
setStylePropertyIfChanged(tabsRoot, '--foreground', fg);
setStylePropertyIfChanged(tabsRoot, '--accent', accent);
setStylePropertyIfChanged(tabsRoot, '--accent-foreground', accentForeground);
setStylePropertyIfChanged(tabsRoot, '--primary', accent);
setStylePropertyIfChanged(tabsRoot, '--primary-foreground', accentForeground);
setStylePropertyIfChanged(tabsRoot, '--secondary', secondary);
setStylePropertyIfChanged(tabsRoot, '--border', border);
setStylePropertyIfChanged(tabsRoot, '--muted-foreground', mutedFg);
setStylePropertyIfChanged(tabsRoot, '--top-tabs-bg', 'hsl(var(--secondary))');
setStylePropertyIfChanged(tabsRoot, '--top-tabs-fg', 'hsl(var(--foreground))');
setStylePropertyIfChanged(tabsRoot, '--top-tabs-muted', 'hsl(var(--muted-foreground))');
setStylePropertyIfChanged(tabsRoot, '--top-tabs-active-bg', 'hsl(var(--background))');
setStylePropertyIfChanged(tabsRoot, '--top-tabs-accent', 'hsl(var(--accent))');
for (const tabsRoot of roots) {
setStylePropertyIfChanged(tabsRoot, '--background', bg);
setStylePropertyIfChanged(tabsRoot, '--foreground', fg);
setStylePropertyIfChanged(tabsRoot, '--accent', accent);
setStylePropertyIfChanged(tabsRoot, '--accent-foreground', accentForeground);
setStylePropertyIfChanged(tabsRoot, '--primary', accent);
setStylePropertyIfChanged(tabsRoot, '--primary-foreground', accentForeground);
setStylePropertyIfChanged(tabsRoot, '--secondary', secondary);
setStylePropertyIfChanged(tabsRoot, '--border', border);
setStylePropertyIfChanged(tabsRoot, '--muted-foreground', mutedFg);
setStylePropertyIfChanged(tabsRoot, '--top-tabs-bg', 'hsl(var(--secondary))');
setStylePropertyIfChanged(tabsRoot, '--top-tabs-fg', 'hsl(var(--foreground))');
setStylePropertyIfChanged(tabsRoot, '--top-tabs-muted', 'hsl(var(--muted-foreground))');
setStylePropertyIfChanged(tabsRoot, '--top-tabs-active-bg', 'hsl(var(--background))');
setStylePropertyIfChanged(tabsRoot, '--top-tabs-accent', 'hsl(var(--accent))');
}
topTabsChromeThemeVarsApplied = true;
}
+5 -2
View File
@@ -181,11 +181,14 @@ export function useAppStartupEffects(ctx: StartupEffectsContext) {
},
{
children,
onChildUpdate: (child) => {
onChildUpdate: (child, observation) => {
if (acceptsResumeCallbacks) {
childUpdateBatcher.push({ ...child, ownerId: "dedicated-resume" });
childUpdateBatcher.push({ ...child, ownerId: "dedicated-resume" }, observation);
return true;
}
},
onChildSuperseded: (taskId) => childUpdateBatcher.discard(taskId),
flushChildUpdates: () => childUpdateBatcher.flush(),
onDirectoryCheckpointUpdate: (checkpoint) => {
if (acceptsResumeCallbacks) {
sftpTransferCenterStore.patchTask(task.id, {
+58 -5
View File
@@ -136,7 +136,7 @@ test('initialization applies the protected preview before persisting and enablin
return liveLocalPayload;
},
translateProtectiveBackupFailure: (message) => message,
applyPayload: async (incoming) => {
preparePayloadApply: async (incoming) => async () => {
calls.push('apply');
assert.equal(incoming.convergentSync?.schemaVersion, 2);
},
@@ -212,7 +212,7 @@ test('initialization applies a concurrent provider merge before releasing the mi
buildCurrentPayload: () => currentPayload,
buildPreApplyPayload: () => currentPayload,
translateProtectiveBackupFailure: (message) => message,
applyPayload: async (incoming) => {
preparePayloadApply: async (incoming) => async () => {
assert.equal(lockHeld, true);
applied.push(incoming);
currentPayload = stripConvergentSyncEnvelope(incoming);
@@ -265,7 +265,7 @@ test('initialization rejects a stale preview before backup or apply', async () =
buildCurrentPayload: () => changedPayload,
buildPreApplyPayload: () => changedPayload,
translateProtectiveBackupFailure: (message) => message,
applyPayload: () => {
preparePayloadApply: async () => async () => {
applied = true;
},
runProtectedApply: async (options) => {
@@ -300,7 +300,7 @@ test('blocked previews cannot enter the protected initialization transaction', a
buildCurrentPayload: () => localPayload,
buildPreApplyPayload: () => localPayload,
translateProtectiveBackupFailure: (message) => message,
applyPayload: () => {},
preparePayloadApply: async () => async () => {},
runProtectedApply: async () => {
entered = true;
},
@@ -337,7 +337,7 @@ test('a locked manager cannot enter the protected initialization transaction', a
return localPayload;
},
translateProtectiveBackupFailure: (message) => message,
applyPayload: () => {
preparePayloadApply: async () => async () => {
applied = true;
},
runProtectedApply: async () => {
@@ -352,3 +352,56 @@ test('a locked manager cannot enter the protected initialization transaction', a
assert.equal(applied, false);
assert.deepEqual(getConvergentSyncLocalConfig(), { enabled: false, initialized: false });
});
for (const failDuringPublish of [false, true]) {
test(`migration header failure precedes protected writes (${failDuringPublish ? 'publish' : 'initialize'})`, async () => {
const { prepareSyncPayloadApply } = await import('./syncPayload.ts');
const { readInterruptedVaultApply } = await import('./localVaultBackups.ts');
const headers = { ai: { providers: [{ id: 'custom', customHeaders: { 'X-Tenant': 'secret' } }] } };
const localPayload: SyncPayload = { ...payload(), ...(failDuringPublish ? {} : { settings: headers }) };
let currentPayload = localPayload;
let imports = 0;
let replicaCommits = 0;
let snapshots = 0;
Object.defineProperty(globalThis, 'window', { configurable: true, value: {
netcatty: { credentialsEncrypt: async () => {
assert.equal(readInterruptedVaultApply(), null);
throw new Error('keychain locked');
} },
dispatchEvent: () => true,
} });
const plan = planConvergentSyncMigration({
localPayload, localTrustedBaseline: null, providers: [], deviceId: 'device-a', now: NOW,
});
const manager = {
isUnlocked: () => true,
withConvergentSyncLock: async (task: () => Promise<void>) => task(),
saveConvergentReplica: async () => { replicaCommits++; },
saveConvergentProviderBaseline: async () => {},
syncConvergentProvidersUnderLock: async (
_incoming: SyncPayload,
apply: (incoming: SyncPayload, commit: () => Promise<void>) => Promise<void>,
) => {
await apply({ ...currentPayload, settings: headers }, async () => { replicaCommits++; });
return new Map();
},
} as unknown as CloudSyncManager;
await assert.rejects(initializePreparedConvergentMigration({
prepared: { plan, providerBaselines: [], localSnapshot: localPayload }, manager, now: NOW,
buildCurrentPayload: () => currentPayload,
buildPreApplyPayload: () => { snapshots++; return payload(); },
translateProtectiveBackupFailure: (message) => message,
preparePayloadApply: async (incoming) => {
const apply = await prepareSyncPayloadApply(incoming, { importVaultData: () => { imports++; } });
return async () => {
await apply();
currentPayload = stripConvergentSyncEnvelope(incoming);
};
},
}));
assert.equal(readInterruptedVaultApply(), null);
assert.equal(imports, failDuringPublish ? 1 : 0);
assert.equal(replicaCommits, failDuringPublish ? 1 : 0);
assert.equal(snapshots, failDuringPublish ? 1 : 0);
});
}
+5 -3
View File
@@ -137,7 +137,7 @@ export async function initializePreparedConvergentMigration(options: {
prepared: PreparedConvergentMigration;
buildCurrentPayload: () => SyncPayload | Promise<SyncPayload>;
buildPreApplyPayload: () => SyncPayload;
applyPayload: (payload: SyncPayload) => void | Promise<void>;
preparePayloadApply: (payload: SyncPayload) => Promise<() => Promise<void>>;
translateProtectiveBackupFailure: (message: string) => string;
manager?: CloudSyncManager;
now?: number;
@@ -164,8 +164,9 @@ export async function initializePreparedConvergentMigration(options: {
'Local sync data changed after the migration preview. Review the updated migration before enabling convergent sync.',
);
}
const applyPreparedPayload = await options.preparePayloadApply(prepared.plan.payload as SyncPayload);
return async () => {
await options.applyPayload(prepared.plan.payload as SyncPayload);
await applyPreparedPayload();
for (const baseline of prepared.providerBaselines) {
await manager.saveConvergentProviderBaseline(baseline);
}
@@ -195,8 +196,9 @@ export async function initializePreparedConvergentMigration(options: {
'Local sync data changed while publishing the convergent migration. Retry sync to preserve the newer local edits.',
);
}
const applyPreparedPayload = await options.preparePayloadApply(mergedPayload);
return async () => {
await options.applyPayload(mergedPayload);
await applyPreparedPayload();
await commitReplica();
};
},
+28 -7
View File
@@ -8,6 +8,7 @@ export const enAiMessages: Messages = {
'ai.chat.compactingStep': 'Trimming context for next step…',
'ai.chat.compactionRetry': 'Request was too large. Compacting context and retrying…',
'ai.chat.compactionBanner': 'Context compacted: {before}K → {after}K tokens',
'ai.chat.contextCompacted': 'Context compacted',
'ai.chat.contextUsage': 'Context usage: {used} / {max} tokens',
'ai.chat.activity.title': 'Agent activity',
'ai.chat.activity.plan': 'Plan',
@@ -53,6 +54,15 @@ export const enAiMessages: Messages = {
'ai.providers.icon.close': 'Close',
'ai.providers.icon.uploadedNote': 'Custom icon (64×64 WebP)',
'ai.providers.icon.errorType': 'Please choose an image file.',
'ai.providers.headers': 'Custom HTTP Headers',
'ai.providers.headers.help': 'Optional headers for this provider. Values are hidden and encrypted when saved.',
'ai.providers.headers.name': 'Header name',
'ai.providers.headers.value': 'Header value',
'ai.providers.headers.add': 'Add header',
'ai.providers.headers.remove': 'Remove header',
'ai.providers.headers.invalid': 'Use valid, unique HTTP headers. Content-Length and Transfer-Encoding are managed automatically.',
'ai.providers.headers.loadError': 'Unable to unlock saved headers. Cancel and try again after unlocking your system keychain.',
'ai.providers.headers.saveError': 'Unable to save securely. Check that your system keychain is available and try again.',
'ai.providers.apiKey': 'API Key',
'ai.providers.apiKey.placeholder': 'Enter API key',
'ai.providers.apiKey.decrypting': 'Decrypting...',
@@ -225,20 +235,24 @@ export const enAiMessages: Messages = {
'ai.codebuddy.envVars': 'Environment variables',
'ai.codebuddy.envVars.placeholder': 'CODEBUDDY_API_KEY=...\nCODEBUDDY_AUTH_TOKEN=...\nOTHER_VAR=...',
'ai.codebuddy.envVars.hint': 'One KEY=VALUE per line, passed to the CodeBuddy agent. Set CODEBUDDY_API_KEY or CODEBUDDY_AUTH_TOKEN here for authentication. Stored locally in plaintext.',
'ai.codebuddy.advancedSection': 'Advanced options (SDK 0.3.230)',
'ai.codebuddy.advancedSection': 'Advanced options (SDK 0.3.258)',
'ai.codebuddy.effort': 'Reasoning Effort',
'ai.codebuddy.effort.default': 'Default',
'ai.codebuddy.effort.minimal': 'Minimal',
'ai.codebuddy.effort.low': 'Low',
'ai.codebuddy.effort.medium': 'Medium',
'ai.codebuddy.effort.high': 'High',
'ai.codebuddy.effort.xhigh': 'XHigh',
'ai.codebuddy.effort.hint': 'Controls model reasoning depth. Use Low for simple commands to save tokens, High/XHigh for complex diagnostics.',
'ai.codebuddy.effort.max': 'Max',
'ai.codebuddy.effort.hint': 'Controls model reasoning depth. Use Minimal/Low for simple commands to save tokens, Max/High for complex diagnostics.',
'ai.codebuddy.maxTurns': 'Max Turns',
'ai.codebuddy.maxTurns.hint': 'Limits the maximum conversation turns per request to prevent runaway loops. Leave empty for default.',
'ai.codebuddy.maxBudget': 'Max Budget (USD)',
'ai.codebuddy.maxBudget.hint': 'Maximum spend (USD) per request. Stops automatically when exceeded. Leave empty for no limit.',
'ai.codebuddy.sandbox': 'Sandbox Mode',
'ai.codebuddy.sandbox.hint': 'Execute tool calls in a sandbox, restricting filesystem and network access.',
'ai.codebuddy.persistSession': 'Session Persistence',
'ai.codebuddy.persistSession.hint': "Keep CodeBuddy's own session transcript on disk so CodeBuddy can resume it later. Turn off to disable CodeBuddy session storage; Netcatty may still save chat history and external session metadata. File checkpointing is skipped. Requires CodeBuddy CLI 2.125.1 or newer.",
'ai.codebuddy.fileCheckpointing': 'File Checkpointing',
'ai.codebuddy.fileCheckpointing.hint': 'Enable file operation checkpoints so AI file modifications can be rolled back.',
'ai.codebuddy.elicitation.title': 'CodeBuddy needs your input',
@@ -350,21 +364,24 @@ export const enAiMessages: Messages = {
'ai.externalMcp.client.claude': 'Claude Code',
'ai.externalMcp.client.grok': 'Grok',
'ai.externalMcp.client.cursor': 'Cursor',
'ai.externalMcp.client.other': 'Other',
'ai.externalMcp.cliCommand': 'CLI command',
'ai.externalMcp.configSnippet': 'Config snippet',
'ai.externalMcp.universalSetupPrompt': 'Universal setup prompt',
'ai.externalMcp.addToCodex': 'Add to Codex',
'ai.externalMcp.addToClaude': 'Add to Claude Code',
'ai.externalMcp.addToGrok': 'Add to Grok',
'ai.externalMcp.codexAdded': 'Codex MCP entry added. Restart Codex or open a new Codex session.',
'ai.externalMcp.claudeAdded': 'Claude Code MCP entry added. Restart Claude Code or open a new Claude Code session.',
'ai.externalMcp.grokAdded': 'Grok MCP entry added. Restart Grok or open a new Grok session.',
'ai.externalMcp.codexAdded': 'Codex MCP entry and Netcatty skill added. Restart Codex or open a new Codex session.',
'ai.externalMcp.claudeAdded': 'Claude Code MCP entry and Netcatty skill added. Restart Claude Code or open a new Claude Code session.',
'ai.externalMcp.grokAdded': 'Grok MCP entry and Netcatty skill added. Restart Grok or open a new Grok session.',
'ai.externalMcp.installCodex': 'Install Codex separately, then click Refresh.',
'ai.externalMcp.installClaude': 'Install Claude Code separately, then click Refresh.',
'ai.externalMcp.installGrok': 'Install the Grok CLI separately, then click Refresh.',
'ai.externalMcp.conflict.description': 'A netcatty-external entry already exists and points elsewhere. Remove or edit it manually.',
'ai.externalMcp.enableForLauncher': 'Enable External MCP to get a usable launcher path.',
'ai.externalMcp.cursor.title': 'Cursor / other clients',
'ai.externalMcp.cursor.description': 'Merge this into your MCP config (for example ~/.cursor/mcp.json). Do not replace the whole file if you already have other servers.',
'ai.externalMcp.cursor.title': 'Cursor',
'ai.externalMcp.cursor.description': 'Merge this into your Cursor MCP configuration without replacing existing servers.',
'ai.externalMcp.other.description': 'Copy the setup prompt into another terminal agent, or merge the MCP snippet into its client configuration.',
'ai.externalMcp.status.unavailable': 'Unavailable',
'ai.externalMcp.status.disabled': 'Disabled',
'ai.externalMcp.status.running': 'Running',
@@ -471,6 +488,7 @@ export const enAiMessages: Messages = {
'ai.chat.useCustomModel': 'Use "{id}"',
'ai.chat.thinkingLevel': 'Thinking',
'ai.chat.thinkingOff': 'Off',
'ai.chat.thinkingDefault': 'Provider default',
'ai.chat.pinModel': 'Pin model',
'ai.chat.unpinModel': 'Unpin model',
'ai.chat.loadingModels': 'Loading models...',
@@ -584,6 +602,9 @@ export const enAiMessages: Messages = {
'terminal.layer.movePanelRight': 'Move panel to right',
'terminal.layer.closePanel': 'Close panel',
'terminal.layer.closePane': 'Close split',
'terminal.layer.saveLayoutAsDefault': 'Save layout as default',
'terminal.layer.layoutSavedAsDefault': 'Layout saved as default',
'terminal.layer.layoutSaveFailed': 'Could not save layout',
'terminal.layer.resizeSplit': 'Resize split',
'terminal.layer.splitHorizontal': 'Split top and bottom',
'terminal.layer.splitVertical': 'Split left and right',
+28
View File
@@ -56,6 +56,7 @@ export const enCoreMessages: Messages = {
'sort.newest': 'Newest to oldest',
'sort.oldest': 'Oldest to newest',
'sort.group': 'By group',
'sort.ip': 'By IP',
'field.label': 'Label',
'field.type': 'Type',
'auth.keyType': 'Type {type}',
@@ -358,6 +359,9 @@ export const enCoreMessages: Messages = {
'settings.vault.selectBeforeConnectDesc': 'Click once to select a host (accent highlight in grid view), click again to connect. Groups work the same way. Default remains single-click connect.',
'settings.vault.showOnlyUngroupedHostsInRoot': 'Only show ungrouped hosts at root',
'settings.vault.showOnlyUngroupedHostsInRootDesc': 'When enabled, the root host list only shows hosts without a group. Open a group from the sidebar to see grouped hosts.',
'settings.appearance.tabBarPosition': 'Tab bar position',
'settings.appearance.tabBarPosition.top': 'Top',
'settings.appearance.tabBarPosition.bottom': 'Bottom',
'settings.vault.showSftpTab': 'Show SFTP tab',
'settings.vault.showSftpTabDesc': 'Display the standalone SFTP view in the top tab bar. When hidden, use the in-session SFTP side panel instead.',
'settings.vault.showHostTreeSidebar': 'Show host list sidebar',
@@ -645,10 +649,18 @@ Highlight the focused split pane:
'settings.terminal.sidePanel.pane.system': 'System',
'settings.terminal.sidePanel.pane.notes': 'Notes',
'settings.terminal.sidePanel.pane.ai': 'AI',
'settings.terminal.section.localShellSidePanel': 'Local Shell Side Panel',
'settings.terminal.localShellSidePanel.autoOpen': 'Open side panel with local shell',
'settings.terminal.localShellSidePanel.autoOpen.desc': 'Automatically open a side panel when opening a local shell.',
'settings.terminal.localShellSidePanel.autoOpenPane': 'Default side panel',
'settings.terminal.localShellSidePanel.autoOpenPane.desc': 'Choose which panel opens first.',
'settings.terminal.localShellSidePanel.pane.files': 'Files',
'settings.terminal.accessibility.minimumContrastRatio.desc':
'Adjust colors to meet contrast requirements (1 = disabled, 21 = max)',
'settings.terminal.behavior.rightClick': 'Right-click behavior',
'settings.terminal.behavior.rightClick.desc': 'Action when right-clicking in terminal',
'settings.terminal.behavior.rightClick.longPressMenu': 'Right-click hold opens menu',
'settings.terminal.behavior.rightClick.longPressMenu.desc': 'When right-click pastes or selects a word, hold for half a second to open the menu. Does not override terminal apps that capture the mouse.',
'settings.terminal.behavior.rightClick.fullscreenMenu': 'Show menu over fullscreen apps',
'settings.terminal.behavior.rightClick.fullscreenMenu.desc':
'Show the context menu even when tmux, vim or other fullscreen apps take over the mouse. When off, right-click is passed to the app (Shift+Right-Click still opens the menu).',
@@ -685,6 +697,12 @@ Highlight the focused split pane:
'settings.terminal.behavior.autoUploadClipboardImage': 'Auto-upload clipboard images on paste',
'settings.terminal.behavior.autoUploadClipboardImage.desc':
'When the clipboard holds an image, pasting in a remote session uploads it via SFTP to .netcatty-paste-images/ under the remote working directory and inserts the remote path instead of pasting text.',
'settings.terminal.behavior.confirmMultilinePaste': 'Ask before pasting multiple lines',
'settings.terminal.behavior.confirmMultilinePaste.desc':
'Show a confirmation dialog before pasting multiple lines, with the line/character count, an editable preview, and Send / Send line by line / Cancel actions. Protects switch and firewall CLIs (Cisco IOS, Huawei VRP, H3C Comware) that do not support bracketed paste.',
'settings.terminal.behavior.confirmMultilinePasteMinLines': 'Confirmation threshold (lines)',
'settings.terminal.behavior.confirmMultilinePasteMinLines.desc':
'Ask for confirmation when a paste contains at least this many lines.',
'settings.terminal.behavior.shiftEnterNewline': 'Shift+Enter text',
'settings.terminal.behavior.shiftEnterNewline.desc':
'Send configured text instead of normal Enter when pressing Shift+Enter in the terminal.',
@@ -706,6 +724,11 @@ Highlight the focused split pane:
'settings.terminal.behavior.dynamicTabTitle.off': 'Disabled',
'settings.terminal.behavior.dynamicTabTitle.agent': 'Agents only',
'settings.terminal.behavior.dynamicTabTitle.all': 'All sessions',
'settings.terminal.behavior.tabDoubleClick': 'Tab double-click behavior',
'settings.terminal.behavior.tabDoubleClick.desc': 'Choose the action when double-clicking a terminal tab.',
'settings.terminal.behavior.tabDoubleClick.duplicate': 'Duplicate session (new connection)',
'settings.terminal.behavior.tabDoubleClick.copy': 'Copy tab (reuse connection)',
'settings.terminal.behavior.tabDoubleClick.disabled': 'Do nothing',
'settings.terminal.behavior.oscNotifications': 'OSC desktop notifications',
'settings.terminal.behavior.oscNotifications.desc':
'Show a system notification when a program emits OSC 9, OSC 777 notify, or kitty OSC 99. Used by Codex and other agents for turn-complete and approval alerts.',
@@ -1037,6 +1060,10 @@ Highlight the focused split pane:
'vault.groups.deleteDialogTitle': 'Delete Group',
'vault.groups.deleteDialog.desc': 'This will permanently delete the group and move all hosts to the root level.',
'vault.groups.deleteDialog.managedDesc': 'This is a managed SSH config group. Deleting it will also delete all hosts and unlink from the source file.',
'vault.groups.deleteDialog.mixedDesc': 'This group contains hosts imported from a linked SSH config file. Every host under its managed subgroup will be deleted, even hosts not managed by that file. All other hosts will move to the root level unless you also delete them below.',
'vault.groups.deleteDialog.managedWarning': 'Deleting this group will also clear its entries from the linked SSH config file. Entries that came from that file may be lost for other apps that use it. Back up the file before continuing.',
'vault.groups.deleteDialog.sourcesChanged': 'The managed files affected by this deletion have changed. Review the files and confirm again.',
'vault.groups.deleteDialog.managedFile': 'Linked file: {file}',
'vault.groups.deleteDialog.deleteHosts': 'Also delete all hosts in this group',
'vault.groups.deleteDialog.bulkTitle': 'Delete Selected Groups',
'vault.groups.deleteDialog.bulkDesc': 'This will permanently delete the selected groups and move their hosts to the root level.',
@@ -1095,6 +1122,7 @@ Highlight the focused split pane:
'vault.hosts.export.toast.success': 'Exported {count} hosts to CSV',
'vault.hosts.export.toast.successWithSkipped': 'Exported {count} hosts to CSV ({skipped} unsupported hosts skipped)',
'vault.hosts.export.toast.passphrasesSkipped': '{count} saved key passphrases could not be read and were left blank',
'vault.hosts.export.toast.proxyCredentialsSkipped': '{count} saved proxy passwords could not be read and were left blank',
'vault.hosts.export.toast.noHosts': 'No hosts to export',
'vault.hosts.allHosts': 'All hosts',
'vault.hosts.pinned': 'Pinned',
+14
View File
@@ -1,6 +1,15 @@
import type { Messages } from '../types';
export const enTerminalMessages: Messages = {
'terminal.menu.saveScreen': 'Save current screen',
'terminal.saveScreen.failed': 'Failed to save terminal screen.',
// Multi-line paste confirmation dialog (#3398)
'terminal.pasteConfirm.title': 'Paste multiple lines?',
'terminal.pasteConfirm.summary': '{lines} lines ({chars} characters) will be sent to the session.',
'terminal.pasteConfirm.preview': 'Preview (editable)',
'terminal.pasteConfirm.send': 'Send',
'terminal.pasteConfirm.sendLineByLine': 'Send line by line',
'terminal.pasteConfirm.cancel': 'Cancel',
'terminal.sudoHint.pressEnter': 'Press Enter to paste saved password',
'terminal.passwordPicker.title': 'Saved passwords',
'terminal.passwordPicker.empty': 'No saved passwords',
@@ -91,6 +100,7 @@ export const enTerminalMessages: Messages = {
'terminal.statusbar.reconnect.tooltip': 'Reconnect this session',
'terminal.serverStats.cpu': 'CPU Usage',
'terminal.serverStats.cpuCores': 'CPU Core Usage',
'terminal.serverStats.gpu': 'GPU Usage',
'terminal.serverStats.memory': 'Memory Usage',
'terminal.serverStats.memoryDetails': 'Memory Details',
'terminal.serverStats.memUsed': 'Used',
@@ -825,6 +835,10 @@ export const enTerminalMessages: Messages = {
'serial.field.configLabelPlaceholder': 'e.g. Arduino Uno',
'serial.connectAndSave': 'Connect & Save',
'serial.edit.title': 'Serial Port Settings',
'serial.field.username': 'Username',
'serial.field.password': 'Password',
'serial.field.autoLoginDesc': 'When set, Login/Password prompts on the serial console are answered automatically with these saved credentials.',
'serial.field.clearPassword': 'Clear saved password',
// Keyboard Interactive Authentication (2FA/MFA)
'keyboard.interactive.title': 'Authentication Required',
+1
View File
@@ -417,6 +417,7 @@ export const enVaultMessages: Messages = {
'sftp.goUp': 'Go up',
'sftp.goToTerminalCwd': 'Go to terminal directory',
'sftp.locatePathInTerminal': 'Open path in terminal',
'sftp.refreshAfterCommand': 'Refresh after terminal commands',
'sftp.followTerminalCwd': 'Follow terminal directory',
'sftp.followTerminalCwd.enable': 'Enable follow terminal directory',
'sftp.followTerminalCwd.disable': 'Disable follow terminal directory',
+16 -7
View File
@@ -8,6 +8,7 @@ export const esAiMessages: Messages = {
'ai.chat.compactingStep': 'Recortando el contexto para el siguiente paso…',
'ai.chat.compactionRetry': 'La petición era demasiado grande. Compactando el contexto y reintentando…',
'ai.chat.compactionBanner': 'Contexto compactado: {before}K → {after}K tokens',
'ai.chat.contextCompacted': 'Contexto compactado',
'ai.chat.contextUsage': 'Uso de contexto: {used} / {max} tokens',
'ai.chat.activity.title': 'Actividad del agente',
'ai.chat.activity.plan': 'Plan',
@@ -225,20 +226,24 @@ export const esAiMessages: Messages = {
'ai.codebuddy.envVars': 'Variables de entorno',
'ai.codebuddy.envVars.placeholder': 'CODEBUDDY_API_KEY=...\nCODEBUDDY_AUTH_TOKEN=...\nOTHER_VAR=...',
'ai.codebuddy.envVars.hint': 'Una KEY=VALUE por línea, pasada al agente de CodeBuddy. Define CODEBUDDY_API_KEY o CODEBUDDY_AUTH_TOKEN aquí para la autenticación. Se almacena localmente en texto plano.',
'ai.codebuddy.advancedSection': 'Opciones avanzadas (SDK 0.3.230)',
'ai.codebuddy.advancedSection': 'Opciones avanzadas (SDK 0.3.258)',
'ai.codebuddy.effort': 'Esfuerzo de razonamiento',
'ai.codebuddy.effort.default': 'Predeterminado',
'ai.codebuddy.effort.minimal': 'Mínimo',
'ai.codebuddy.effort.low': 'Bajo',
'ai.codebuddy.effort.medium': 'Medio',
'ai.codebuddy.effort.high': 'Alto',
'ai.codebuddy.effort.xhigh': 'XHigh',
'ai.codebuddy.effort.hint': 'Controla la profundidad de razonamiento del modelo. Usa Bajo para comandos simples y ahorrar tokens, y Alto/XHigh para diagnósticos complejos.',
'ai.codebuddy.effort.max': 'Máximo',
'ai.codebuddy.effort.hint': 'Controla la profundidad de razonamiento del modelo. Usa Mínimo/Bajo para comandos simples y ahorrar tokens, y Máximo/Alto para diagnósticos complejos.',
'ai.codebuddy.maxTurns': 'Máximo de turnos',
'ai.codebuddy.maxTurns.hint': 'Limita el máximo de turnos de conversación por petición para evitar bucles descontrolados. Déjalo vacío para usar el predeterminado.',
'ai.codebuddy.maxBudget': 'Presupuesto máximo (USD)',
'ai.codebuddy.maxBudget.hint': 'Gasto máximo (USD) por petición. Se detiene automáticamente al superarse. Déjalo vacío para no tener límite.',
'ai.codebuddy.sandbox': 'Modo sandbox',
'ai.codebuddy.sandbox.hint': 'Ejecuta llamadas a herramientas en un sandbox, restringiendo el acceso al sistema de archivos y a la red.',
'ai.codebuddy.persistSession': 'Persistencia de sesión',
'ai.codebuddy.persistSession.hint': 'Guarda en disco la transcripción de la sesión propia de CodeBuddy para que CodeBuddy pueda reanudarla más tarde. Desactívalo para deshabilitar el almacenamiento de sesiones de CodeBuddy; Netcatty aún puede guardar el historial del chat y los metadatos de la sesión externa. Se omiten los puntos de control de archivos. Requiere CodeBuddy CLI 2.125.1 o superior.',
'ai.codebuddy.fileCheckpointing': 'Puntos de control de archivos',
'ai.codebuddy.fileCheckpointing.hint': 'Habilita puntos de control de operaciones de archivos para poder revertir las modificaciones de IA a los archivos.',
'ai.codebuddy.elicitation.title': 'CodeBuddy necesita tu aporte',
@@ -350,21 +355,24 @@ export const esAiMessages: Messages = {
'ai.externalMcp.client.claude': 'Claude Code',
'ai.externalMcp.client.grok': 'Grok',
'ai.externalMcp.client.cursor': 'Cursor',
'ai.externalMcp.client.other': 'Otros',
'ai.externalMcp.cliCommand': 'Comando CLI',
'ai.externalMcp.configSnippet': 'Fragmento de configuración',
'ai.externalMcp.universalSetupPrompt': 'Prompt de instalación universal',
'ai.externalMcp.addToCodex': 'Agregar a Codex',
'ai.externalMcp.addToClaude': 'Agregar a Claude Code',
'ai.externalMcp.addToGrok': 'Agregar a Grok',
'ai.externalMcp.codexAdded': 'Entrada de MCP de Codex agregada. Reinicia Codex o abre una nueva sesión de Codex.',
'ai.externalMcp.claudeAdded': 'Entrada de MCP de Claude Code agregada. Reinicia Claude Code o abre una nueva sesión de Claude Code.',
'ai.externalMcp.grokAdded': 'Entrada de MCP de Grok agregada. Reinicia Grok o abre una nueva sesión de Grok.',
'ai.externalMcp.codexAdded': 'Entrada MCP de Codex y skill de Netcatty agregados. Reinicia Codex o abre una nueva sesión de Codex.',
'ai.externalMcp.claudeAdded': 'Entrada MCP de Claude Code y skill de Netcatty agregados. Reinicia Claude Code o abre una nueva sesión de Claude Code.',
'ai.externalMcp.grokAdded': 'Entrada MCP de Grok y skill de Netcatty agregados. Reinicia Grok o abre una nueva sesión de Grok.',
'ai.externalMcp.installCodex': 'Instala Codex por separado y luego haz clic en Actualizar.',
'ai.externalMcp.installClaude': 'Instala Claude Code por separado y luego haz clic en Actualizar.',
'ai.externalMcp.installGrok': 'Instala la CLI de Grok por separado y luego haz clic en Actualizar.',
'ai.externalMcp.conflict.description': 'Ya existe una entrada netcatty-external que apunta a otro lugar. Quítala o edítala manualmente.',
'ai.externalMcp.enableForLauncher': 'Habilita el MCP externo para obtener una ruta de launcher utilizable.',
'ai.externalMcp.cursor.title': 'Cursor / otros clientes',
'ai.externalMcp.cursor.description': 'Combina esto en tu configuración de MCP (por ejemplo, ~/.cursor/mcp.json). No reemplaces todo el archivo si ya tienes otros servidores.',
'ai.externalMcp.cursor.title': 'Cursor',
'ai.externalMcp.cursor.description': 'Combina esto con la configuración MCP de Cursor sin reemplazar los servidores existentes.',
'ai.externalMcp.other.description': 'Copia el prompt de instalación en otro agente de terminal o combina el fragmento MCP con la configuración de su cliente.',
'ai.externalMcp.status.unavailable': 'No disponible',
'ai.externalMcp.status.disabled': 'Desactivado',
'ai.externalMcp.status.running': 'En ejecución',
@@ -471,6 +479,7 @@ export const esAiMessages: Messages = {
'ai.chat.useCustomModel': 'Usar "{id}"',
'ai.chat.thinkingLevel': 'Razonamiento',
'ai.chat.thinkingOff': 'Off',
'ai.chat.thinkingDefault': 'Predeterminado del proveedor',
'ai.chat.pinModel': 'Fijar modelo',
'ai.chat.unpinModel': 'Quitar modelo fijado',
'ai.chat.loadingModels': 'Cargando modelos...',
+28
View File
@@ -55,6 +55,7 @@ export const esCoreMessages: Messages = {
'sort.za': 'Z-a',
'sort.newest': 'Más reciente a más antiguo',
'sort.oldest': 'Más antiguo a más reciente',
'sort.ip': 'Por IP',
'sort.group': 'Por grupo',
'field.label': 'Etiqueta',
'field.type': 'Tipo',
@@ -273,6 +274,9 @@ export const esCoreMessages: Messages = {
'settings.vault.selectBeforeConnectDesc': 'Haz clic una vez para seleccionar un host (resaltado con el color de acento en la vista de cuadrícula), haz clic de nuevo para conectarte. Los grupos funcionan igual. El valor predeterminado sigue siendo conectarse con un solo clic.',
'settings.vault.showOnlyUngroupedHostsInRoot': 'Mostrar solo hosts sin grupo en la raíz',
'settings.vault.showOnlyUngroupedHostsInRootDesc': 'Cuando está activado, la lista de hosts de la raíz solo muestra hosts sin grupo. Abre un grupo desde la barra lateral para ver los hosts agrupados.',
'settings.appearance.tabBarPosition': 'Posición de la barra de pestañas',
'settings.appearance.tabBarPosition.top': 'Arriba',
'settings.appearance.tabBarPosition.bottom': 'Abajo',
'settings.vault.showSftpTab': 'Mostrar pestaña SFTP',
'settings.vault.showSftpTabDesc': 'Muestra la vista SFTP independiente en la barra de pestañas superior. Cuando está oculta, usa el panel lateral SFTP dentro de la sesión.',
'settings.vault.showHostTreeSidebar': 'Mostrar barra lateral de lista de hosts',
@@ -560,10 +564,18 @@ Resalta el panel dividido enfocado:
'settings.terminal.sidePanel.pane.system': 'Sistema',
'settings.terminal.sidePanel.pane.notes': 'Notas',
'settings.terminal.sidePanel.pane.ai': 'IA',
'settings.terminal.section.localShellSidePanel': 'Panel lateral de Shell local',
'settings.terminal.localShellSidePanel.autoOpen': 'Expandir panel lateral al abrir Shell local',
'settings.terminal.localShellSidePanel.autoOpen.desc': 'Abre automáticamente un panel lateral después de abrir el Shell local.',
'settings.terminal.localShellSidePanel.autoOpenPane': 'Panel lateral predeterminado',
'settings.terminal.localShellSidePanel.autoOpenPane.desc': 'Elige qué panel se abre primero.',
'settings.terminal.localShellSidePanel.pane.files': 'Archivos',
'settings.terminal.accessibility.minimumContrastRatio.desc':
'Ajusta los colores para cumplir con los requisitos de contraste (1 = desactivado, 21 = máximo)',
'settings.terminal.behavior.rightClick': 'Comportamiento del clic derecho',
'settings.terminal.behavior.rightClick.desc': 'Acción al hacer clic derecho en la terminal',
'settings.terminal.behavior.rightClick.longPressMenu': 'Mantener clic derecho abre el menú',
'settings.terminal.behavior.rightClick.longPressMenu.desc': 'Si el clic derecho pega o selecciona una palabra, mantén pulsado medio segundo para abrir el menú. No afecta a las aplicaciones que capturan el ratón.',
'settings.terminal.behavior.rightClick.fullscreenMenu': 'Mostrar menú sobre aplicaciones en pantalla completa',
'settings.terminal.behavior.rightClick.fullscreenMenu.desc':
'Muestra el menú contextual incluso cuando tmux, vim u otras aplicaciones en pantalla completa toman el control del mouse. Cuando está desactivado, el clic derecho se pasa a la aplicación (Shift+Clic derecho sigue abriendo el menú).',
@@ -600,6 +612,12 @@ Resalta el panel dividido enfocado:
'settings.terminal.behavior.autoUploadClipboardImage': 'Subir automáticamente imágenes del portapapeles al pegar',
'settings.terminal.behavior.autoUploadClipboardImage.desc':
'Cuando el portapapeles contiene una imagen, pegarla en una sesión remota la sube por SFTP a .netcatty-paste-images/ dentro del directorio de trabajo remoto e inserta la ruta remota en lugar de pegar texto.',
'settings.terminal.behavior.confirmMultilinePaste': 'Preguntar antes de pegar varias líneas',
'settings.terminal.behavior.confirmMultilinePaste.desc':
'Muestra un diálogo de confirmación antes de pegar varias líneas, con el número de líneas y caracteres, una vista previa editable y las acciones Enviar / Enviar línea por línea / Cancelar. Protege las CLI de dispositivos de red (Cisco IOS, Huawei VRP, H3C Comware) que no admiten bracketed paste.',
'settings.terminal.behavior.confirmMultilinePasteMinLines': 'Umbral de confirmación (líneas)',
'settings.terminal.behavior.confirmMultilinePasteMinLines.desc':
'Pedir confirmación cuando el texto pegado contenga al menos esta cantidad de líneas.',
'settings.terminal.behavior.shiftEnterNewline': 'Texto de Shift+Enter',
'settings.terminal.behavior.shiftEnterNewline.desc':
'Envía el texto configurado en lugar del Enter normal al presionar Shift+Enter en la terminal.',
@@ -621,6 +639,11 @@ Resalta el panel dividido enfocado:
'settings.terminal.behavior.dynamicTabTitle.off': 'Desactivado',
'settings.terminal.behavior.dynamicTabTitle.agent': 'Solo agentes',
'settings.terminal.behavior.dynamicTabTitle.all': 'Todas las sesiones',
'settings.terminal.behavior.tabDoubleClick': 'Comportamiento de doble clic en pestaña',
'settings.terminal.behavior.tabDoubleClick.desc': 'Elige la acción al hacer doble clic en una pestaña de terminal.',
'settings.terminal.behavior.tabDoubleClick.duplicate': 'Duplicar sesión (nueva conexión)',
'settings.terminal.behavior.tabDoubleClick.copy': 'Copiar pestaña (reutilizar conexión)',
'settings.terminal.behavior.tabDoubleClick.disabled': 'No hacer nada',
'settings.terminal.behavior.oscNotifications': 'Notificaciones de escritorio OSC',
'settings.terminal.behavior.oscNotifications.desc':
'Muestra una notificación del sistema cuando un programa emite OSC 9, OSC 777 notify o kitty OSC 99. Lo usan Codex y otros agentes para avisos de turno completado y de aprobación.',
@@ -995,6 +1018,10 @@ Resalta el panel dividido enfocado:
'vault.groups.deleteDialogTitle': 'Eliminar grupo',
'vault.groups.deleteDialog.desc': 'Esto eliminará permanentemente el grupo y moverá todos los hosts al nivel raíz.',
'vault.groups.deleteDialog.managedDesc': 'Este es un grupo administrado de configuración SSH. Eliminarlo también eliminará todos los hosts y lo desvinculará del archivo de origen.',
'vault.groups.deleteDialog.mixedDesc': 'Este grupo contiene hosts importados de un archivo de configuración SSH vinculado. Todos los hosts bajo su subgrupo administrado se eliminarán, incluso los hosts no administrados por ese archivo. Los demás hosts se moverán al nivel raíz a menos que también los elimine abajo.',
'vault.groups.deleteDialog.managedWarning': 'Al eliminar este grupo también se borrarán sus entradas del archivo de configuración SSH vinculado. Las entradas provenientes de ese archivo pueden perderse y también afectará a otras aplicaciones que lo usen. Haz una copia de seguridad del archivo antes de continuar.',
'vault.groups.deleteDialog.sourcesChanged': 'Los archivos administrados afectados por esta eliminación han cambiado. Revisa los archivos y vuelve a confirmar.',
'vault.groups.deleteDialog.managedFile': 'Archivo vinculado: {file}',
'vault.groups.deleteDialog.deleteHosts': 'También eliminar todos los hosts de este grupo',
'vault.groups.deleteDialog.bulkTitle': 'Eliminar grupos seleccionados',
'vault.groups.deleteDialog.bulkDesc': 'Esto eliminará permanentemente los grupos seleccionados y moverá sus hosts al nivel raíz.',
@@ -1053,6 +1080,7 @@ Resalta el panel dividido enfocado:
'vault.hosts.export.toast.success': 'Se exportaron {count} hosts a CSV',
'vault.hosts.export.toast.successWithSkipped': 'Se exportaron {count} hosts a CSV (se omitieron {skipped} hosts no compatibles)',
'vault.hosts.export.toast.passphrasesSkipped': 'No se pudieron leer {count} frases de contraseña de claves guardadas y se dejaron en blanco',
'vault.hosts.export.toast.proxyCredentialsSkipped': 'No se pudieron leer {count} contraseñas de proxy guardadas y se dejaron en blanco',
'vault.hosts.export.toast.noHosts': 'No hay hosts para exportar',
'vault.hosts.allHosts': 'Todos los hosts',
'vault.hosts.pinned': 'Fijados',
+14
View File
@@ -1,6 +1,15 @@
import type { Messages } from '../types';
export const esTerminalMessages: Messages = {
'terminal.menu.saveScreen': 'Guardar pantalla actual',
'terminal.saveScreen.failed': 'No se pudo guardar la pantalla del terminal.',
// Diálogo de confirmación de pegado multilínea (#3398)
'terminal.pasteConfirm.title': '¿Pegar varias líneas?',
'terminal.pasteConfirm.summary': 'Se enviarán {lines} líneas ({chars} caracteres) a la sesión.',
'terminal.pasteConfirm.preview': 'Vista previa (editable)',
'terminal.pasteConfirm.send': 'Enviar',
'terminal.pasteConfirm.sendLineByLine': 'Enviar línea por línea',
'terminal.pasteConfirm.cancel': 'Cancelar',
'terminal.sudoHint.pressEnter': 'Presiona Enter para pegar la contraseña guardada',
'terminal.passwordPicker.title': 'Contraseñas guardadas',
'terminal.passwordPicker.empty': 'No hay contraseñas guardadas',
@@ -91,6 +100,7 @@ export const esTerminalMessages: Messages = {
'terminal.statusbar.reconnect.tooltip': 'Reconectar esta sesión',
'terminal.serverStats.cpu': 'Uso de CPU',
'terminal.serverStats.cpuCores': 'Uso de núcleos de CPU',
'terminal.serverStats.gpu': 'Uso de GPU',
'terminal.serverStats.memory': 'Uso de memoria',
'terminal.serverStats.memoryDetails': 'Detalles de memoria',
'terminal.serverStats.memUsed': 'Usada',
@@ -825,6 +835,10 @@ export const esTerminalMessages: Messages = {
'serial.field.configLabelPlaceholder': 'p. ej., Arduino Uno',
'serial.connectAndSave': 'Conectar y guardar',
'serial.edit.title': 'Configuración del puerto serial',
'serial.field.username': 'Usuario',
'serial.field.password': 'Contraseña',
'serial.field.autoLoginDesc': 'Si se establecen, los mensajes Login/Password de la consola serial se responden automáticamente con estas credenciales guardadas.',
'serial.field.clearPassword': 'Borrar la contraseña guardada',
// Keyboard Interactive Authentication (2FA/MFA)
'keyboard.interactive.title': 'Se requiere autenticación',
+30 -5
View File
@@ -5,6 +5,7 @@ export const ruAiMessages: Messages = {
// AI Settings
'ai.agentSettings': 'Настройки агента',
'ai.chat.preparing': 'Подготовка…',
'ai.chat.contextCompacted': 'Контекст сжат',
'ai.title': 'AI',
'ai.description': 'Настройка AI-провайдеров, агентов и параметров безопасности',
'ai.providers': 'Провайдеры',
@@ -206,6 +207,26 @@ export const ruAiMessages: Messages = {
'ai.codebuddy.envVars': 'Переменные окружения',
'ai.codebuddy.envVars.placeholder': 'CODEBUDDY_API_KEY=...\nCODEBUDDY_AUTH_TOKEN=...\nOTHER_VAR=...',
'ai.codebuddy.envVars.hint': 'По одной записи KEY=VALUE на строку, передаются агенту CodeBuddy. Укажите CODEBUDDY_API_KEY или CODEBUDDY_AUTH_TOKEN для аутентификации. Хранятся локально в открытом виде.',
'ai.codebuddy.advancedSection': 'Расширенные параметры (SDK 0.3.258)',
'ai.codebuddy.effort': 'Усилие рассуждения',
'ai.codebuddy.effort.default': 'По умолчанию',
'ai.codebuddy.effort.minimal': 'Minimal',
'ai.codebuddy.effort.low': 'Low',
'ai.codebuddy.effort.medium': 'Medium',
'ai.codebuddy.effort.high': 'High',
'ai.codebuddy.effort.xhigh': 'XHigh',
'ai.codebuddy.effort.max': 'Max',
'ai.codebuddy.effort.hint': 'Управляет глубиной рассуждения модели. Для простых команд используйте Minimal/Low, чтобы экономить токены, а для сложной диагностики — Max/High.',
'ai.codebuddy.maxTurns': 'Максимум шагов',
'ai.codebuddy.maxTurns.hint': 'Ограничивает максимальное число шагов диалога за запрос, предотвращая зацикливание. Оставьте пустым для значения по умолчанию.',
'ai.codebuddy.maxBudget': 'Максимальный бюджет (USD)',
'ai.codebuddy.maxBudget.hint': 'Максимальные расходы (USD) за запрос. При превышении выполнение останавливается автоматически. Оставьте пустым, чтобы не ограничивать.',
'ai.codebuddy.sandbox': 'Режим песочницы',
'ai.codebuddy.sandbox.hint': 'Выполняет вызовы инструментов в песочнице, ограничивая доступ к файловой системе и сети.',
'ai.codebuddy.persistSession': 'Сохранение сессии',
'ai.codebuddy.persistSession.hint': 'Сохраняет на диск собственную историю сессии CodeBuddy, чтобы CodeBuddy мог возобновить её позже. Отключите, чтобы запретить хранение сессии CodeBuddy; Netcatty всё ещё может сохранять историю чата и метаданные внешней сессии. Контрольные точки файлов пропускаются. Требуется CodeBuddy CLI 2.125.1 или новее.',
'ai.codebuddy.fileCheckpointing': 'Контрольные точки файлов',
'ai.codebuddy.fileCheckpointing.hint': 'Включает контрольные точки файловых операций, чтобы изменения файлов ИИ можно было откатить.',
'ai.codebuddy.elicitation.title': 'CodeBuddy требуется ваш ответ',
'ai.codebuddy.elicitation.description': 'Проверьте запрос, чтобы продолжить текущий ход.',
'ai.codebuddy.elicitation.select': 'Выберите вариант',
@@ -302,21 +323,24 @@ export const ruAiMessages: Messages = {
'ai.externalMcp.client.claude': 'Claude Code',
'ai.externalMcp.client.grok': 'Grok',
'ai.externalMcp.client.cursor': 'Cursor',
'ai.externalMcp.client.other': 'Другие',
'ai.externalMcp.cliCommand': 'CLI-команда',
'ai.externalMcp.configSnippet': 'Фрагмент конфига',
'ai.externalMcp.universalSetupPrompt': 'Универсальный запрос установки',
'ai.externalMcp.addToCodex': 'Добавить в Codex',
'ai.externalMcp.addToClaude': 'Добавить в Claude Code',
'ai.externalMcp.addToGrok': 'Добавить в Grok',
'ai.externalMcp.codexAdded': 'Запись MCP для Codex добавлена. Перезапустите Codex или откройте новую сессию.',
'ai.externalMcp.claudeAdded': 'Запись MCP для Claude Code добавлена. Перезапустите Claude Code или откройте новую сессию.',
'ai.externalMcp.grokAdded': 'Запись MCP для Grok добавлена. Перезапустите Grok или откройте новую сессию.',
'ai.externalMcp.codexAdded': 'Запись MCP для Codex и навык Netcatty добавлены. Перезапустите Codex или откройте новую сессию.',
'ai.externalMcp.claudeAdded': 'Запись MCP для Claude Code и навык Netcatty добавлены. Перезапустите Claude Code или откройте новую сессию.',
'ai.externalMcp.grokAdded': 'Запись MCP для Grok и навык Netcatty добавлены. Перезапустите Grok или откройте новую сессию.',
'ai.externalMcp.installCodex': 'Сначала установите Codex, затем нажмите Обновить.',
'ai.externalMcp.installClaude': 'Сначала установите Claude Code, затем нажмите Обновить.',
'ai.externalMcp.installGrok': 'Сначала установите Grok CLI, затем нажмите Обновить.',
'ai.externalMcp.conflict.description': 'Запись netcatty-external уже существует и указывает в другое место. Удалите или измените её вручную.',
'ai.externalMcp.enableForLauncher': 'Включите External MCP, чтобы получить путь launcher.',
'ai.externalMcp.cursor.title': 'Cursor / другие клиенты',
'ai.externalMcp.cursor.description': 'Объедините с MCP-конфигом (например ~/.cursor/mcp.json). Не заменяйте весь файл, если там уже есть другие серверы.',
'ai.externalMcp.cursor.title': 'Cursor',
'ai.externalMcp.cursor.description': 'Добавьте этот фрагмент в конфигурацию MCP Cursor, сохранив существующие серверы.',
'ai.externalMcp.other.description': 'Скопируйте запрос установки в другой терминальный агент или добавьте фрагмент MCP в конфигурацию его клиента.',
'ai.externalMcp.status.unavailable': 'Недоступно',
'ai.externalMcp.status.disabled': 'Выключено',
'ai.externalMcp.status.running': 'Работает',
@@ -423,6 +447,7 @@ export const ruAiMessages: Messages = {
'ai.chat.useCustomModel': 'Использовать «{id}»',
'ai.chat.thinkingLevel': 'Мышление',
'ai.chat.thinkingOff': 'Выкл.',
'ai.chat.thinkingDefault': 'По умолчанию провайдера',
'ai.chat.pinModel': 'Закрепить модель',
'ai.chat.unpinModel': 'Открепить модель',
'ai.chat.loadingModels': 'Загрузка моделей...',
+27
View File
@@ -55,6 +55,7 @@ export const ruCoreMessages: Messages = {
'sort.newest': 'Сначала новые',
'sort.oldest': 'Сначала старые',
'sort.group': 'По группе',
'sort.ip': 'По IP',
'field.label': 'Метка',
'field.type': 'Тип',
'auth.keyType': 'Тип {type}',
@@ -342,6 +343,9 @@ export const ruCoreMessages: Messages = {
'settings.vault.selectBeforeConnectDesc': 'Один клик выбирает хост (акцентная рамка в сетке), повторный клик подключает. Группы работают так же. По умолчанию — подключение одним кликом.',
'settings.vault.showOnlyUngroupedHostsInRoot': 'Показывать в корне только хосты без группы',
'settings.vault.showOnlyUngroupedHostsInRootDesc': 'Если включено, в корневом списке хостов будут показаны только хосты без группы. Откройте группу на боковой панели, чтобы увидеть сгруппированные хосты.',
'settings.appearance.tabBarPosition': 'Расположение панели вкладок',
'settings.appearance.tabBarPosition.top': 'Сверху',
'settings.appearance.tabBarPosition.bottom': 'Снизу',
'settings.vault.showSftpTab': 'Показывать вкладку SFTP',
'settings.vault.showSftpTabDesc': 'Показывать отдельный SFTP-вид в верхней панели вкладок. Если скрыто, используйте боковую панель SFTP внутри сессии.',
'settings.vault.showHostTreeSidebar': 'Показывать боковую панель хостов',
@@ -606,6 +610,8 @@ export const ruCoreMessages: Messages = {
'Подстраивать цвета под требования контрастности (1 = отключено, 21 = максимум)',
'settings.terminal.behavior.rightClick': 'Поведение правой кнопки мыши',
'settings.terminal.behavior.rightClick.desc': 'Действие при щелчке правой кнопкой в терминале',
'settings.terminal.behavior.rightClick.longPressMenu': 'Удержание правой кнопки открывает меню',
'settings.terminal.behavior.rightClick.longPressMenu.desc': 'Если правая кнопка вставляет текст или выделяет слово, удерживайте её полсекунды для открытия меню. Не влияет на приложения, захватывающие мышь.',
'settings.terminal.behavior.rightClick.fullscreenMenu': 'Показывать меню поверх полноэкранных приложений',
'settings.terminal.behavior.rightClick.fullscreenMenu.desc':
'Показывать контекстное меню, даже когда tmux, vim или другие полноэкранные приложения перехватывают мышь. Когда выключено, правый щелчок передаётся приложению (Shift+правый щелчок всё равно открывает меню).',
@@ -642,6 +648,12 @@ export const ruCoreMessages: Messages = {
'settings.terminal.behavior.autoUploadClipboardImage': 'Авто-загрузка изображений буфера обмена при вставке',
'settings.terminal.behavior.autoUploadClipboardImage.desc':
'Когда в буфере обмена есть изображение, вставка в удалённой сессии загружает его по SFTP в каталог .netcatty-paste-images/ в текущем удалённом каталоге и вводит удалённый путь вместо вставки текста.',
'settings.terminal.behavior.confirmMultilinePaste': 'Спрашивать перед вставкой нескольких строк',
'settings.terminal.behavior.confirmMultilinePaste.desc':
'Перед вставкой нескольких строк показывать диалог подтверждения с количеством строк и символов, редактируемым предпросмотром и действиями «Отправить» / «Отправить построчно» / «Отмена». Защищает CLI сетевых устройств (Cisco IOS, Huawei VRP, H3C Comware), не поддерживающих bracketed paste.',
'settings.terminal.behavior.confirmMultilinePasteMinLines': 'Порог подтверждения (строк)',
'settings.terminal.behavior.confirmMultilinePasteMinLines.desc':
'Запрашивать подтверждение, если вставка содержит не меньше этого числа строк.',
'settings.terminal.behavior.shiftEnterNewline': 'Текст для Shift+Enter',
'settings.terminal.behavior.shiftEnterNewline.desc':
'Отправлять настроенный текст вместо обычного Enter при нажатии Shift+Enter в терминале.',
@@ -663,6 +675,11 @@ export const ruCoreMessages: Messages = {
'settings.terminal.behavior.dynamicTabTitle.off': 'Отключено',
'settings.terminal.behavior.dynamicTabTitle.agent': 'Только агенты',
'settings.terminal.behavior.dynamicTabTitle.all': 'Все сеансы',
'settings.terminal.behavior.tabDoubleClick': 'Действие по двойному щелчку вкладки',
'settings.terminal.behavior.tabDoubleClick.desc': 'Выберите действие при двойном щелчке вкладки терминала.',
'settings.terminal.behavior.tabDoubleClick.duplicate': 'Дублировать сеанс (новое подключение)',
'settings.terminal.behavior.tabDoubleClick.copy': 'Копировать вкладку (использовать подключение)',
'settings.terminal.behavior.tabDoubleClick.disabled': 'Ничего не делать',
'settings.terminal.behavior.oscNotifications': 'Системные уведомления OSC',
'settings.terminal.behavior.oscNotifications.desc':
'Показывать системное уведомление, когда программа отправляет OSC 9, OSC 777 notify или kitty OSC 99. Так Codex и другие агенты сообщают о завершении хода и запросе подтверждения.',
@@ -739,6 +756,12 @@ export const ruCoreMessages: Messages = {
'settings.terminal.localShell.startDir.placeholder': 'Домашний каталог',
'settings.terminal.localShell.startDir.notFound': 'Каталог не найден',
'settings.terminal.localShell.startDir.isFile': 'Путь указывает на файл, а не на каталог',
'settings.terminal.section.localShellSidePanel': 'Боковая панель локальной оболочки',
'settings.terminal.localShellSidePanel.autoOpen': 'Развернуть боковую панель при открытии локальной оболочки',
'settings.terminal.localShellSidePanel.autoOpen.desc': 'Автоматически открывать боковую панель после открытия локальной оболочки.',
'settings.terminal.localShellSidePanel.autoOpenPane': 'Боковая панель по умолчанию',
'settings.terminal.localShellSidePanel.autoOpenPane.desc': 'Выберите, какую панель открывать в первую очередь.',
'settings.terminal.localShellSidePanel.pane.files': 'Файлы',
'settings.terminal.section.connection': 'Подключение',
'settings.terminal.connection.keepaliveInterval': 'Интервал keepalive',
'settings.terminal.connection.keepaliveInterval.desc': 'Как часто (в секундах) отправлять keepalive-пакеты на уровне SSH. Установите 0, чтобы отключить глобально. Учтите, что отдельные хосты могут переопределять это значение в своих настройках.',
@@ -1019,6 +1042,10 @@ export const ruCoreMessages: Messages = {
'vault.groups.deleteDialogTitle': 'Удалить группу',
'vault.groups.deleteDialog.desc': 'Группа будет безвозвратно удалена, а все хосты будут перемещены в корень.',
'vault.groups.deleteDialog.managedDesc': 'Это управляемая группа SSH-конфига. При её удалении также будут удалены все хосты и снята связь с исходным файлом.',
'vault.groups.deleteDialog.mixedDesc': 'Эта группа содержит хосты, импортированные из связанного SSH-конфига. Все хосты под управляемой подгруппой будут удалены, включая хосты, не управляемые этим файлом. Остальные хосты будут перемещены в корень, если вы также не удалите их ниже.',
'vault.groups.deleteDialog.managedWarning': 'При удалении этой группы её записи также будут удалены из связанного файла конфигурации SSH. Записи, импортированные из этого файла, могут быть утеряны, это затронет и другие приложения, использующие файл. Сделайте резервную копию файла перед продолжением.',
'vault.groups.deleteDialog.sourcesChanged': 'Управляемые файлы, затрагиваемые этим удалением, изменились. Проверьте файлы и подтвердите удаление снова.',
'vault.groups.deleteDialog.managedFile': 'Связанный файл: {file}',
'vault.groups.deleteDialog.deleteHosts': 'Также удалить все хосты в этой группе',
'vault.groups.deleteDialog.bulkTitle': 'Удалить выбранные группы',
'vault.groups.deleteDialog.bulkDesc': 'Выбранные группы будут безвозвратно удалены, а их хосты перемещены в корень.',
+14
View File
@@ -1,6 +1,15 @@
import type { Messages } from '../types';
export const ruTerminalMessages: Messages = {
'terminal.menu.saveScreen': 'Сохранить текущий экран',
'terminal.saveScreen.failed': 'Не удалось сохранить экран терминала.',
// Диалог подтверждения многострочной вставки (#3398)
'terminal.pasteConfirm.title': 'Вставить несколько строк?',
'terminal.pasteConfirm.summary': 'В сессию будет отправлено строк: {lines} (символов: {chars}).',
'terminal.pasteConfirm.preview': 'Предпросмотр (можно изменить)',
'terminal.pasteConfirm.send': 'Отправить',
'terminal.pasteConfirm.sendLineByLine': 'Отправить построчно',
'terminal.pasteConfirm.cancel': 'Отмена',
'terminal.sudoHint.pressEnter': 'Нажмите Enter, чтобы вставить сохранённый пароль',
'terminal.passwordPicker.title': 'Сохранённые пароли',
'terminal.passwordPicker.empty': 'Нет сохранённых паролей',
@@ -112,6 +121,7 @@ export const ruTerminalMessages: Messages = {
'terminal.statusbar.reconnect.tooltip': 'Переподключить эту сессию',
'terminal.serverStats.cpu': 'Использование CPU',
'terminal.serverStats.cpuCores': 'Использование ядер CPU',
'terminal.serverStats.gpu': 'Использование GPU',
'terminal.serverStats.memory': 'Использование памяти',
'terminal.serverStats.memoryDetails': 'Сведения о памяти',
'terminal.serverStats.memUsed': 'Использовано',
@@ -832,6 +842,10 @@ export const ruTerminalMessages: Messages = {
'serial.field.configLabelPlaceholder': 'например, Arduino Uno',
'serial.connectAndSave': 'Подключить и сохранить',
'serial.edit.title': 'Настройки последовательного порта',
'serial.field.username': 'Имя пользователя',
'serial.field.password': 'Пароль',
'serial.field.autoLoginDesc': 'Если задано, запросы Login/Password в последовательной консоли автоматически заполняются этими сохранёнными учётными данными.',
'serial.field.clearPassword': 'Очистить сохранённый пароль',
// Keyboard Interactive Authentication (2FA/MFA)
'keyboard.interactive.title': 'Требуется аутентификация',
+1
View File
@@ -108,6 +108,7 @@ export const ruVaultMessages: Messages = {
'vault.hosts.export.toast.success': 'Экспортировано {count} хостов в CSV',
'vault.hosts.export.toast.successWithSkipped': 'Экспортировано {count} хостов в CSV ({skipped} неподдерживаемых хостов пропущено)',
'vault.hosts.export.toast.passphrasesSkipped': 'Не удалось прочитать {count} сохранённых парольных фраз ключей; они оставлены пустыми',
'vault.hosts.export.toast.proxyCredentialsSkipped': 'Не удалось прочитать {count} сохранённых паролей прокси; они оставлены пустыми',
'vault.hosts.export.toast.noHosts': 'Нет хостов для экспорта',
'vault.hosts.allHosts': 'Все хосты',
'vault.hosts.pinned': 'Закреплённые',
@@ -0,0 +1,48 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import en from './en.ts';
import ru from './ru.ts';
import es from './es.ts';
import zhCN from './zh-CN.ts';
import zhTW from './zh-TW.ts';
const KEYS = [
'vault.groups.deleteDialog.managedDesc',
'vault.groups.deleteDialog.mixedDesc',
'vault.groups.deleteDialog.managedWarning',
'vault.groups.deleteDialog.managedFile',
] as const;
test('managed group delete warning copy exists in every locale', () => {
for (const [locale, messages] of Object.entries({ en, ru, es, zhCN, zhTW })) {
const missing = KEYS.filter((key) => !messages[key]);
assert.deepEqual(missing, [], `${locale} is missing managed group delete copy`);
}
});
test('managed group delete warning mentions data loss in every locale', () => {
const MARKERS = ['备份', '備份', 'copia de seguridad', 'резервную копию', 'Back up'];
for (const [locale, messages] of Object.entries({ en, ru, es, zhCN, zhTW })) {
const text = messages['vault.groups.deleteDialog.managedWarning'];
assert.match(
text ?? '',
/SSH/,
`${locale} warning should reference the SSH config file`,
);
assert.ok(
MARKERS.some((marker) => text?.includes(marker)),
`${locale} warning should tell the user to back up the file`,
);
}
});
test('linked file line interpolates the file path', () => {
for (const messages of [en, ru, es, zhCN, zhTW]) {
assert.match(
messages['vault.groups.deleteDialog.managedFile'] ?? '',
/\{file\}/,
'managedFile copy should include the {file} placeholder',
);
}
});
+28 -7
View File
@@ -8,6 +8,7 @@ export const zhCNAiMessages: Messages = {
'ai.chat.compactingStep': '正在为下一步整理上下文…',
'ai.chat.compactionRetry': '请求过大,正在压缩上下文并重试…',
'ai.chat.compactionBanner': '上下文已压缩:{before}K → {after}K tokens',
'ai.chat.contextCompacted': '上下文已压缩',
'ai.chat.contextUsage': '上下文使用:{used} / {max} tokens',
'ai.chat.activity.title': 'Agent 活动',
'ai.chat.activity.plan': '计划',
@@ -53,6 +54,15 @@ export const zhCNAiMessages: Messages = {
'ai.providers.icon.close': '收起',
'ai.providers.icon.uploadedNote': '自定义图标(64×64 WebP)',
'ai.providers.icon.errorType': '请选择图片文件。',
'ai.providers.headers': '自定义 HTTP 请求头',
'ai.providers.headers.help': '为此服务添加可选请求头。内容会隐藏显示并在保存时加密。',
'ai.providers.headers.name': '请求头名称',
'ai.providers.headers.value': '请求头内容',
'ai.providers.headers.add': '添加请求头',
'ai.providers.headers.remove': '删除请求头',
'ai.providers.headers.invalid': '请填写有效且不重复的请求头。Content-Length 和 Transfer-Encoding 由应用自动设置。',
'ai.providers.headers.loadError': '无法解锁已保存的请求头。请取消编辑,解锁系统钥匙串后重试。',
'ai.providers.headers.saveError': '无法安全保存。请确认系统钥匙串可用后重试。',
'ai.providers.apiKey': 'API Key',
'ai.providers.apiKey.placeholder': '输入 API Key',
'ai.providers.apiKey.decrypting': '解密中...',
@@ -225,20 +235,24 @@ export const zhCNAiMessages: Messages = {
'ai.codebuddy.envVars': '环境变量',
'ai.codebuddy.envVars.placeholder': 'CODEBUDDY_API_KEY=...\nCODEBUDDY_AUTH_TOKEN=...\nOTHER_VAR=...',
'ai.codebuddy.envVars.hint': '每行一个 KEY=VALUE,传给 CodeBuddy agent。可在此设置 CODEBUDDY_API_KEY 或 CODEBUDDY_AUTH_TOKEN 完成认证。明文存在本地。',
'ai.codebuddy.advancedSection': '高级选项(SDK 0.3.230)',
'ai.codebuddy.advancedSection': '高级选项(SDK 0.3.258)',
'ai.codebuddy.effort': '推理力度',
'ai.codebuddy.effort.default': '默认',
'ai.codebuddy.effort.minimal': 'Minimal',
'ai.codebuddy.effort.low': 'Low',
'ai.codebuddy.effort.medium': 'Medium',
'ai.codebuddy.effort.high': 'High',
'ai.codebuddy.effort.xhigh': 'XHigh',
'ai.codebuddy.effort.hint': '控制模型推理深度。简单命令用 Low 节省 token,复杂诊断用 High/XHigh。',
'ai.codebuddy.effort.max': 'Max',
'ai.codebuddy.effort.hint': '控制模型推理深度。简单命令用 Minimal/Low 节省 token,复杂诊断用 Max/High。',
'ai.codebuddy.maxTurns': '最大轮次',
'ai.codebuddy.maxTurns.hint': '限制每次请求的最大对话轮次,防止 AI 失控循环。留空使用默认值。',
'ai.codebuddy.maxBudget': '预算上限 (USD)',
'ai.codebuddy.maxBudget.hint': '每次请求的最大花费(美元)。超出后自动停止。留空不限制。',
'ai.codebuddy.sandbox': '沙箱模式',
'ai.codebuddy.sandbox.hint': '在沙箱中执行工具调用,限制文件系统和网络访问。',
'ai.codebuddy.persistSession': '会话持久化',
'ai.codebuddy.persistSession.hint': '将 CodeBuddy 自身的会话记录保存到磁盘,以便 CodeBuddy 后续恢复会话。关闭后将停用 CodeBuddy 的会话存储;Netcatty 仍可能保存聊天记录和外部会话元数据。文件检查点会跳过。需要 CodeBuddy CLI 2.125.1 或更高版本。',
'ai.codebuddy.fileCheckpointing': '文件检查点',
'ai.codebuddy.fileCheckpointing.hint': '启用文件操作检查点,AI 修改文件后可回滚。',
'ai.codebuddy.elicitation.title': 'CodeBuddy 需要你的输入',
@@ -350,21 +364,24 @@ export const zhCNAiMessages: Messages = {
'ai.externalMcp.client.claude': 'Claude Code',
'ai.externalMcp.client.grok': 'Grok',
'ai.externalMcp.client.cursor': 'Cursor',
'ai.externalMcp.client.other': '其他',
'ai.externalMcp.cliCommand': 'CLI 命令',
'ai.externalMcp.configSnippet': '配置片段',
'ai.externalMcp.universalSetupPrompt': '通用安装提示词',
'ai.externalMcp.addToCodex': '添加到 Codex',
'ai.externalMcp.addToClaude': '添加到 Claude Code',
'ai.externalMcp.addToGrok': '添加到 Grok',
'ai.externalMcp.codexAdded': '已添加 Codex MCP 条目。请重启 Codex 或打开新会话。',
'ai.externalMcp.claudeAdded': '已添加 Claude Code MCP 条目。请重启 Claude Code 或打开新会话。',
'ai.externalMcp.grokAdded': '已添加 Grok MCP 条目。请重启 Grok 或打开新会话。',
'ai.externalMcp.codexAdded': '已添加 Codex MCP 条目和 Netcatty Skill。请重启 Codex 或打开新会话。',
'ai.externalMcp.claudeAdded': '已添加 Claude Code MCP 条目和 Netcatty Skill。请重启 Claude Code 或打开新会话。',
'ai.externalMcp.grokAdded': '已添加 Grok MCP 条目和 Netcatty Skill。请重启 Grok 或打开新会话。',
'ai.externalMcp.installCodex': '请先单独安装 Codex,然后点击刷新。',
'ai.externalMcp.installClaude': '请先单独安装 Claude Code,然后点击刷新。',
'ai.externalMcp.installGrok': '请先单独安装 Grok CLI,然后点击刷新。',
'ai.externalMcp.conflict.description': '已存在指向其他位置的 netcatty-external 条目,请手动删除或修改。',
'ai.externalMcp.enableForLauncher': '请先启用对外 MCP,以获取可用的 launcher 路径。',
'ai.externalMcp.cursor.title': 'Cursor / 其他客户端',
'ai.externalMcp.cursor.description': '合并到 MCP 配置文件(例如 ~/.cursor/mcp.json)。若已有其他服务器,请勿整文件覆盖。',
'ai.externalMcp.cursor.title': 'Cursor',
'ai.externalMcp.cursor.description': '将片段合并到 Cursor MCP 配置中,并保留已有服务器。',
'ai.externalMcp.other.description': '将安装提示词复制到其他终端 Agent,或把 MCP 片段合并到对应客户端配置。',
'ai.externalMcp.status.unavailable': '不可用',
'ai.externalMcp.status.disabled': '已关闭',
'ai.externalMcp.status.running': '运行中',
@@ -471,6 +488,7 @@ export const zhCNAiMessages: Messages = {
'ai.chat.useCustomModel': '使用 “{id}”',
'ai.chat.thinkingLevel': '思考强度',
'ai.chat.thinkingOff': '关闭',
'ai.chat.thinkingDefault': '跟随提供商默认',
'ai.chat.pinModel': '固定模型',
'ai.chat.unpinModel': '取消固定',
'ai.chat.loadingModels': '正在加载模型...',
@@ -584,6 +602,9 @@ export const zhCNAiMessages: Messages = {
'terminal.layer.movePanelRight': '面板移至右侧',
'terminal.layer.closePanel': '关闭面板',
'terminal.layer.closePane': '关闭分屏',
'terminal.layer.saveLayoutAsDefault': '将布局保存为默认',
'terminal.layer.layoutSavedAsDefault': '布局已保存为默认',
'terminal.layer.layoutSaveFailed': '布局保存失败',
'terminal.layer.resizeSplit': '调整分屏大小',
'terminal.layer.splitHorizontal': '上下分屏',
'terminal.layer.splitVertical': '左右分屏',
+16
View File
@@ -42,6 +42,7 @@ export const zhCNCoreMessages: Messages = {
'sort.newest': '从新到旧',
'sort.oldest': '从旧到新',
'sort.group': '按分组',
'sort.ip': '按 IP',
'field.label': 'Label',
'field.type': '类型',
'auth.keyType': '类型 {type}',
@@ -119,6 +120,12 @@ export const zhCNCoreMessages: Messages = {
'settings.terminal.sidePanel.pane.system': '系统',
'settings.terminal.sidePanel.pane.notes': '笔记',
'settings.terminal.sidePanel.pane.ai': 'AI',
'settings.terminal.section.localShellSidePanel': '本地 Shell 侧边栏',
'settings.terminal.localShellSidePanel.autoOpen': '打开本地 Shell 时展开侧边栏',
'settings.terminal.localShellSidePanel.autoOpen.desc': '打开本地 Shell 后自动打开一个侧边栏。',
'settings.terminal.localShellSidePanel.autoOpenPane': '默认侧边栏',
'settings.terminal.localShellSidePanel.autoOpenPane.desc': '选择优先打开哪个面板。',
'settings.terminal.localShellSidePanel.pane.files': '文件',
// Settings > System
'settings.system.title': '系统',
@@ -356,6 +363,9 @@ export const zhCNCoreMessages: Messages = {
'settings.vault.selectBeforeConnectDesc': '单击选中主机(网格模式显示强调色边框),再次单击连接;分组同理。默认仍为单击直接连接。',
'settings.vault.showOnlyUngroupedHostsInRoot': '根目录只显示未分组主机',
'settings.vault.showOnlyUngroupedHostsInRootDesc': '开启后,主机库根目录的主机列表只显示没有分组的主机,已分组主机请从左侧分组进入查看。',
'settings.appearance.tabBarPosition': '标签栏位置',
'settings.appearance.tabBarPosition.top': '顶部',
'settings.appearance.tabBarPosition.bottom': '底部',
'settings.vault.showSftpTab': '显示 SFTP 标签页',
'settings.vault.showSftpTabDesc': '在顶部标签栏显示独立的 SFTP 视图。关闭后可改用会话内左侧的 SFTP 侧栏。',
'settings.vault.showHostTreeSidebar': '显示主机列表侧栏',
@@ -674,6 +684,10 @@ export const zhCNCoreMessages: Messages = {
'vault.groups.deleteDialogTitle': '删除分组',
'vault.groups.deleteDialog.desc': '这将永久删除该分组并将所有主机移动到根级别。',
'vault.groups.deleteDialog.managedDesc': '这是一个托管的 SSH config 分组。删除后将同时删除所有主机并断开与源文件的连接。',
'vault.groups.deleteDialog.mixedDesc': '该分组包含从关联 SSH config 文件导入的主机。其托管子分组下的所有主机都将被删除,包括不受该文件管理的主机;其余主机将移动到根级别,除非您在下方选择同时删除。',
'vault.groups.deleteDialog.managedWarning': '删除该分组会同时清空关联 SSH config 文件中的对应条目,从该文件导入的配置可能会丢失,其他使用该文件的应用也会受到影响。请先备份文件再继续。',
'vault.groups.deleteDialog.sourcesChanged': '此次删除涉及的托管文件已发生变化。请检查文件并重新确认。',
'vault.groups.deleteDialog.managedFile': '关联文件:{file}',
'vault.groups.deleteDialog.deleteHosts': '同时删除该分组下的所有主机',
'vault.groups.deleteDialog.bulkTitle': '删除所选分组',
'vault.groups.deleteDialog.bulkDesc': '这将永久删除所选分组,并把其中的主机移动到根级别。',
@@ -732,6 +746,7 @@ export const zhCNCoreMessages: Messages = {
'vault.hosts.export.toast.success': '已导出 {count} 个主机到 CSV',
'vault.hosts.export.toast.successWithSkipped': '已导出 {count} 个主机到 CSV(跳过 {skipped} 个不支持的主机)',
'vault.hosts.export.toast.passphrasesSkipped': '有 {count} 个已保存的密钥口令无法读取,导出时已留空',
'vault.hosts.export.toast.proxyCredentialsSkipped': '有 {count} 个已保存的代理密码无法读取,导出时已留空',
'vault.hosts.export.toast.noHosts': '没有主机可导出',
'vault.hosts.allHosts': '全部主机',
'vault.hosts.pinned': '已置顶',
@@ -1046,6 +1061,7 @@ export const zhCNCoreMessages: Messages = {
'sftp.goUp': '上一级',
'sftp.goToTerminalCwd': '定位到终端当前目录',
'sftp.locatePathInTerminal': '定位路径到终端',
'sftp.refreshAfterCommand': '终端命令完成后刷新',
'sftp.followTerminalCwd': '追随终端目录',
'sftp.followTerminalCwd.enable': '开启追随终端目录',
'sftp.followTerminalCwd.disable': '关闭追随终端目录',
@@ -1,6 +1,15 @@
import type { Messages } from '../types';
export const zhCNTerminalMessages: Messages = {
'terminal.menu.saveScreen': '保存当前屏幕',
'terminal.saveScreen.failed': '保存终端屏幕失败。',
// 多行粘贴确认对话框(#3398)
'terminal.pasteConfirm.title': '要粘贴多行文本吗?',
'terminal.pasteConfirm.summary': '将向会话发送 {lines} 行({chars} 个字符)。',
'terminal.pasteConfirm.preview': '预览(可编辑)',
'terminal.pasteConfirm.send': '发送',
'terminal.pasteConfirm.sendLineByLine': '逐行发送',
'terminal.pasteConfirm.cancel': '取消',
'terminal.sudoHint.pressEnter': '按 Enter 粘贴已保存的密码',
'terminal.passwordPicker.title': '已保存的密码',
'terminal.passwordPicker.empty': '没有已保存的密码',
@@ -289,6 +298,8 @@ export const zhCNTerminalMessages: Messages = {
'settings.terminal.accessibility.minimumContrastRatio.desc': '调整颜色以满足对比度要求 (1 = 禁用, 21 = 最大)',
'settings.terminal.behavior.rightClick': '右键行为',
'settings.terminal.behavior.rightClick.desc': '在终端中右键时执行的操作',
'settings.terminal.behavior.rightClick.longPressMenu': '右键长按显示菜单',
'settings.terminal.behavior.rightClick.longPressMenu.desc': '右键设为粘贴或选词时,按住半秒打开菜单。不影响接管鼠标的终端应用。',
'settings.terminal.behavior.rightClick.fullscreenMenu': '在全屏应用中也显示菜单',
'settings.terminal.behavior.rightClick.fullscreenMenu.desc':
'即使 tmux、vim 等全屏应用接管了鼠标,也显示右键菜单。关闭时右键会交给应用处理(Shift+右键仍可打开菜单)。',
@@ -322,6 +333,12 @@ export const zhCNTerminalMessages: Messages = {
'settings.terminal.behavior.autoUploadClipboardImage': '粘贴时自动上传剪贴板图片',
'settings.terminal.behavior.autoUploadClipboardImage.desc':
'当剪贴板包含图片时,在远程会话中粘贴会通过 SFTP 将图片上传到远端当前目录的 .netcatty-paste-images/ 文件夹,并在终端输入远端路径,而不是粘贴文本。',
'settings.terminal.behavior.confirmMultilinePaste': '粘贴多行前先询问',
'settings.terminal.behavior.confirmMultilinePaste.desc':
'粘贴多行文本时先弹出确认对话框,显示行数和字符数、可编辑的预览,并提供发送 / 逐行发送 / 取消操作。可保护不支持括号粘贴的网络设备 CLI(Cisco IOS、华为 VRP、H3C Comware)。',
'settings.terminal.behavior.confirmMultilinePasteMinLines': '确认阈值(行数)',
'settings.terminal.behavior.confirmMultilinePasteMinLines.desc':
'粘贴内容达到该行数时才弹出确认对话框。',
'settings.terminal.behavior.shiftEnterNewline': 'Shift+Enter 发送文本',
'settings.terminal.behavior.shiftEnterNewline.desc':
'在终端中按 Shift+Enter 时发送配置的文本,而不是普通回车。',
@@ -343,6 +360,11 @@ export const zhCNTerminalMessages: Messages = {
'settings.terminal.behavior.dynamicTabTitle.off': '关闭',
'settings.terminal.behavior.dynamicTabTitle.agent': '仅 Agent',
'settings.terminal.behavior.dynamicTabTitle.all': '全部会话',
'settings.terminal.behavior.tabDoubleClick': '标签页双击行为',
'settings.terminal.behavior.tabDoubleClick.desc': '选择双击顶部终端标签页时执行的操作。',
'settings.terminal.behavior.tabDoubleClick.duplicate': '复制会话(新建连接)',
'settings.terminal.behavior.tabDoubleClick.copy': '复制标签页(复用连接)',
'settings.terminal.behavior.tabDoubleClick.disabled': '无操作',
'settings.terminal.behavior.oscNotifications': 'OSC 桌面通知',
'settings.terminal.behavior.oscNotifications.desc':
'当程序发出 OSC 9、OSC 777 notify 或 kitty OSC 99 时弹出系统通知。Codex 等 Agent 用它提示回合完成或等待审批。',
@@ -909,6 +931,10 @@ export const zhCNTerminalMessages: Messages = {
'serial.field.configLabelPlaceholder': '例如 Arduino Uno',
'serial.connectAndSave': '连接并保存',
'serial.edit.title': '串口设置',
'serial.field.username': '用户名',
'serial.field.password': '密码',
'serial.field.autoLoginDesc': '设置后,串口终端出现 Login/Password 登录提示时,会自动填充此处保存的用户名和密码。',
'serial.field.clearPassword': '清除已保存的密码',
// Keyboard Interactive Authentication (2FA/MFA)
'keyboard.interactive.title': '需要验证',
+1
View File
@@ -503,6 +503,7 @@ export const zhCNVaultMessages: Messages = {
'terminal.statusbar.reconnect.tooltip': '重新连接当前会话',
'terminal.serverStats.cpu': 'CPU 使用率',
'terminal.serverStats.cpuCores': 'CPU 核心使用率',
'terminal.serverStats.gpu': 'GPU 使用率',
'terminal.serverStats.memory': '内存使用',
'terminal.serverStats.memoryDetails': '内存详情',
'terminal.serverStats.memUsed': '已用',
+19 -7
View File
@@ -8,6 +8,7 @@ export const zhTWAiMessages: Messages = {
'ai.chat.compactingStep': '正在為下一步整理上下文…',
'ai.chat.compactionRetry': '請求過大,正在壓縮上下文並重試…',
'ai.chat.compactionBanner': '上下文已壓縮:{before}K → {after}K tokens',
'ai.chat.contextCompacted': '上下文已壓縮',
'ai.chat.contextUsage': '上下文使用:{used} / {max} tokens',
'ai.chat.activity.title': 'Agent 活動',
'ai.chat.activity.plan': '計畫',
@@ -225,20 +226,24 @@ export const zhTWAiMessages: Messages = {
'ai.codebuddy.envVars': '環境變數',
'ai.codebuddy.envVars.placeholder': 'CODEBUDDY_API_KEY=...\nCODEBUDDY_AUTH_TOKEN=...\nOTHER_VAR=...',
'ai.codebuddy.envVars.hint': '每行一個 KEY=VALUE,傳給 CodeBuddy agent。可在此設定 CODEBUDDY_API_KEY 或 CODEBUDDY_AUTH_TOKEN 完成認證。明文存在本機。',
'ai.codebuddy.advancedSection': '進階選項(SDK 0.3.230)',
'ai.codebuddy.advancedSection': '進階選項(SDK 0.3.258)',
'ai.codebuddy.effort': '推理力度',
'ai.codebuddy.effort.default': '預設',
'ai.codebuddy.effort.minimal': 'Minimal',
'ai.codebuddy.effort.low': 'Low',
'ai.codebuddy.effort.medium': 'Medium',
'ai.codebuddy.effort.high': 'High',
'ai.codebuddy.effort.xhigh': 'XHigh',
'ai.codebuddy.effort.hint': '控制模型推理深度。簡單命令用 Low 節省 token,複雜診斷用 High/XHigh。',
'ai.codebuddy.effort.max': 'Max',
'ai.codebuddy.effort.hint': '控制模型推理深度。簡單命令用 Minimal/Low 節省 token,複雜診斷用 Max/High。',
'ai.codebuddy.maxTurns': '最大輪次',
'ai.codebuddy.maxTurns.hint': '限制每次請求的最大對話輪次,防止 AI 失控循環。留空使用預設值。',
'ai.codebuddy.maxBudget': '預算上限 (USD)',
'ai.codebuddy.maxBudget.hint': '每次請求的最大花費(美元)。超出後自動停止。留空不限制。',
'ai.codebuddy.sandbox': '沙箱模式',
'ai.codebuddy.sandbox.hint': '在沙箱中執行工具呼叫,限制檔案系統和網路存取。',
'ai.codebuddy.persistSession': '工作階段持久化',
'ai.codebuddy.persistSession.hint': '將 CodeBuddy 自身的工作階段記錄儲存到磁碟,以便 CodeBuddy 後續恢復工作階段。關閉後將停用 CodeBuddy 的工作階段儲存;Netcatty 仍可能儲存聊天記錄和外部工作階段中繼資料。檔案檢查點會略過。需要 CodeBuddy CLI 2.125.1 或更高版本。',
'ai.codebuddy.fileCheckpointing': '檔案檢查點',
'ai.codebuddy.fileCheckpointing.hint': '啟用檔案操作檢查點,AI 修改檔案後可回滾。',
'ai.codebuddy.elicitation.title': 'CodeBuddy 需要你的輸入',
@@ -349,21 +354,24 @@ export const zhTWAiMessages: Messages = {
'ai.externalMcp.client.claude': 'Claude Code',
'ai.externalMcp.client.grok': 'Grok',
'ai.externalMcp.client.cursor': 'Cursor',
'ai.externalMcp.client.other': '其他',
'ai.externalMcp.cliCommand': 'CLI 命令',
'ai.externalMcp.configSnippet': '設定片段',
'ai.externalMcp.universalSetupPrompt': '通用安裝提示詞',
'ai.externalMcp.addToCodex': '新增到 Codex',
'ai.externalMcp.addToClaude': '新增到 Claude Code',
'ai.externalMcp.addToGrok': '新增到 Grok',
'ai.externalMcp.codexAdded': '已新增 Codex MCP 項目。請重新啟動 Codex 或開啟新工作階段。',
'ai.externalMcp.claudeAdded': '已新增 Claude Code MCP 項目。請重新啟動 Claude Code 或開啟新工作階段。',
'ai.externalMcp.grokAdded': '已新增 Grok MCP 項目。請重新啟動 Grok 或開啟新工作階段。',
'ai.externalMcp.codexAdded': '已新增 Codex MCP 項目和 Netcatty Skill。請重新啟動 Codex 或開啟新工作階段。',
'ai.externalMcp.claudeAdded': '已新增 Claude Code MCP 項目和 Netcatty Skill。請重新啟動 Claude Code 或開啟新工作階段。',
'ai.externalMcp.grokAdded': '已新增 Grok MCP 項目和 Netcatty Skill。請重新啟動 Grok 或開啟新工作階段。',
'ai.externalMcp.installCodex': '請先另外安裝 Codex,然後按重新整理。',
'ai.externalMcp.installClaude': '請先另外安裝 Claude Code,然後按重新整理。',
'ai.externalMcp.installGrok': '請先另外安裝 Grok CLI,然後按重新整理。',
'ai.externalMcp.conflict.description': '已存在指向其他位置的 netcatty-external 項目,請手動刪除或修改。',
'ai.externalMcp.enableForLauncher': '請先啟用對外 MCP,以取得可用的 launcher 路徑。',
'ai.externalMcp.cursor.title': 'Cursor / 其他用戶端',
'ai.externalMcp.cursor.description': '合併到 MCP 設定檔(例如 ~/.cursor/mcp.json)。若已有其他伺服器,請勿整檔覆蓋。',
'ai.externalMcp.cursor.title': 'Cursor',
'ai.externalMcp.cursor.description': '將片段合併到 Cursor MCP 設定中,並保留已有伺服器。',
'ai.externalMcp.other.description': '將安裝提示詞複製到其他終端 Agent,或把 MCP 片段合併到對應用戶端設定。',
'ai.externalMcp.status.unavailable': '無法使用',
'ai.externalMcp.status.disabled': '已關閉',
'ai.externalMcp.status.running': '執行中',
@@ -470,6 +478,7 @@ export const zhTWAiMessages: Messages = {
'ai.chat.useCustomModel': '使用「{id}」',
'ai.chat.thinkingLevel': '思考強度',
'ai.chat.thinkingOff': '關閉',
'ai.chat.thinkingDefault': '跟隨提供商預設',
'ai.chat.pinModel': '釘選模型',
'ai.chat.unpinModel': '取消釘選',
'ai.chat.loadingModels': '正在載入模型...',
@@ -583,6 +592,9 @@ export const zhTWAiMessages: Messages = {
'terminal.layer.movePanelRight': '面板移至右側',
'terminal.layer.closePanel': '關閉面板',
'terminal.layer.closePane': '關閉分割',
'terminal.layer.saveLayoutAsDefault': '將佈局儲存為預設',
'terminal.layer.layoutSavedAsDefault': '佈局已儲存為預設',
'terminal.layer.layoutSaveFailed': '佈局儲存失敗',
'terminal.layer.resizeSplit': '調整分割大小',
'terminal.layer.splitHorizontal': '上下分割',
'terminal.layer.splitVertical': '左右分割',
+16
View File
@@ -42,6 +42,7 @@ export const zhTWCoreMessages: Messages = {
'sort.newest': '從新到舊',
'sort.oldest': '從舊到新',
'sort.group': '依群組',
'sort.ip': '依 IP',
'field.label': 'Label',
'field.type': '型別',
'auth.keyType': '型別 {type}',
@@ -121,6 +122,12 @@ export const zhTWCoreMessages: Messages = {
'settings.terminal.sidePanel.pane.system': '系統',
'settings.terminal.sidePanel.pane.notes': '筆記',
'settings.terminal.sidePanel.pane.ai': 'AI',
'settings.terminal.section.localShellSidePanel': '本機 Shell 側邊欄',
'settings.terminal.localShellSidePanel.autoOpen': '開啟本機 Shell 時展開側邊欄',
'settings.terminal.localShellSidePanel.autoOpen.desc': '開啟本機 Shell 後自動開啟一個側邊欄。',
'settings.terminal.localShellSidePanel.autoOpenPane': '預設側邊欄',
'settings.terminal.localShellSidePanel.autoOpenPane.desc': '選擇優先開啟哪個面板。',
'settings.terminal.localShellSidePanel.pane.files': '檔案',
// Settings > System
'settings.system.title': '系統',
@@ -358,6 +365,9 @@ export const zhTWCoreMessages: Messages = {
'settings.vault.selectBeforeConnectDesc': '點一下選取主機(網格模式會顯示強調色邊框),再點一下才連線;群組同理。預設仍是點一下直接連線。',
'settings.vault.showOnlyUngroupedHostsInRoot': '根目錄只顯示未歸類主機',
'settings.vault.showOnlyUngroupedHostsInRootDesc': '開啟後,主機庫根目錄的主機列表只顯示沒有群組的主機,已歸類的主機請從左側群組進入檢視。',
'settings.appearance.tabBarPosition': '分頁列位置',
'settings.appearance.tabBarPosition.top': '頂部',
'settings.appearance.tabBarPosition.bottom': '底部',
'settings.vault.showSftpTab': '顯示 SFTP 分頁',
'settings.vault.showSftpTabDesc': '在頂部分頁列顯示獨立的 SFTP 檢視。關閉後可改用工作階段內左側的 SFTP 側欄。',
'settings.vault.showHostTreeSidebar': '顯示主機列表側欄',
@@ -676,6 +686,10 @@ export const zhTWCoreMessages: Messages = {
'vault.groups.deleteDialogTitle': '刪除群組',
'vault.groups.deleteDialog.desc': '這將永久刪除該群組並將所有主機移動到根層級。',
'vault.groups.deleteDialog.managedDesc': '這是一個託管的 SSH config 群組。刪除後將同時刪除所有主機並斷開與原始檔的連線。',
'vault.groups.deleteDialog.mixedDesc': '該群組包含從關聯 SSH config 檔案匯入的主機。其受管理子群組下的所有主機都將被刪除,包括不受該檔案管理的主機;其餘主機將移動到根層級,除非您在下方選擇同時刪除。',
'vault.groups.deleteDialog.managedWarning': '刪除該群組會同時清空關聯 SSH config 檔案中的對應條目,從該檔案匯入的設定可能會遺失,其他使用該檔案的應用也會受到影響。請先備份檔案再繼續。',
'vault.groups.deleteDialog.sourcesChanged': '此次刪除涉及的受管理檔案已發生變化。請檢查檔案並重新確認。',
'vault.groups.deleteDialog.managedFile': '關聯檔案:{file}',
'vault.groups.deleteDialog.deleteHosts': '同時刪除該群組下的所有主機',
'vault.groups.deleteDialog.bulkTitle': '刪除所選群組',
'vault.groups.deleteDialog.bulkDesc': '這將永久刪除所選群組,並把其中的主機移動到根層級。',
@@ -734,6 +748,7 @@ export const zhTWCoreMessages: Messages = {
'vault.hosts.export.toast.success': '已匯出 {count} 個主機到 CSV',
'vault.hosts.export.toast.successWithSkipped': '已匯出 {count} 個主機到 CSV(跳過 {skipped} 個不支援的主機)',
'vault.hosts.export.toast.passphrasesSkipped': '有 {count} 個已儲存的金鑰密碼無法讀取,匯出時已留空',
'vault.hosts.export.toast.proxyCredentialsSkipped': '有 {count} 個已儲存的代理密碼無法讀取,匯出時已留空',
'vault.hosts.export.toast.noHosts': '沒有主機可匯出',
'vault.hosts.allHosts': '全部主機',
'vault.hosts.pinned': '已置頂',
@@ -1048,6 +1063,7 @@ export const zhTWCoreMessages: Messages = {
'sftp.goUp': '上一級',
'sftp.goToTerminalCwd': '定位到終端目前目錄',
'sftp.locatePathInTerminal': '定位路徑到終端',
'sftp.refreshAfterCommand': '終端命令完成後重新整理',
'sftp.followTerminalCwd': '追隨終端目錄',
'sftp.followTerminalCwd.enable': '開啟追隨終端目錄',
'sftp.followTerminalCwd.disable': '關閉追隨終端目錄',
@@ -1,6 +1,15 @@
import type { Messages } from '../types';
export const zhTWTerminalMessages: Messages = {
'terminal.menu.saveScreen': '儲存目前畫面',
'terminal.saveScreen.failed': '儲存終端畫面失敗。',
// 多行貼上確認對話框(#3398)
'terminal.pasteConfirm.title': '要貼上多行文字嗎?',
'terminal.pasteConfirm.summary': '將向工作階段傳送 {lines} 行({chars} 個字元)。',
'terminal.pasteConfirm.preview': '預覽(可編輯)',
'terminal.pasteConfirm.send': '傳送',
'terminal.pasteConfirm.sendLineByLine': '逐行傳送',
'terminal.pasteConfirm.cancel': '取消',
'terminal.sudoHint.pressEnter': '按 Enter 貼上已儲存的密碼',
'terminal.passwordPicker.title': '已儲存的密碼',
'terminal.passwordPicker.empty': '沒有已儲存的密碼',
@@ -289,6 +298,8 @@ export const zhTWTerminalMessages: Messages = {
'settings.terminal.accessibility.minimumContrastRatio.desc': '調整顏色以滿足對比度要求 (1 = 停用, 21 = 最大)',
'settings.terminal.behavior.rightClick': '右鍵行為',
'settings.terminal.behavior.rightClick.desc': '在終端中右鍵時執行的操作',
'settings.terminal.behavior.rightClick.longPressMenu': '右鍵長按顯示選單',
'settings.terminal.behavior.rightClick.longPressMenu.desc': '右鍵設為貼上或選詞時,按住半秒開啟選單。不影響接管滑鼠的終端應用。',
'settings.terminal.behavior.rightClick.fullscreenMenu': '在全螢幕應用中也顯示選單',
'settings.terminal.behavior.rightClick.fullscreenMenu.desc':
'即使 tmux、vim 等全螢幕應用接管了滑鼠,也顯示右鍵選單。關閉時右鍵會交給應用處理(Shift+右鍵仍可開啟選單)。',
@@ -322,6 +333,12 @@ export const zhTWTerminalMessages: Messages = {
'settings.terminal.behavior.autoUploadClipboardImage': '貼上時自動上傳剪貼簿圖片',
'settings.terminal.behavior.autoUploadClipboardImage.desc':
'當剪貼簿包含圖片時,在遠端工作階段中貼上會透過 SFTP 將圖片上傳到遠端目前目錄的 .netcatty-paste-images/ 資料夾,並在終端輸入遠端路徑,而不是貼上文字。',
'settings.terminal.behavior.confirmMultilinePaste': '貼上多行前先詢問',
'settings.terminal.behavior.confirmMultilinePaste.desc':
'貼上多行文字時先彈出確認對話框,顯示行數和字元數、可編輯的預覽,並提供傳送 / 逐行傳送 / 取消操作。可保護不支援括號貼上的網路設備 CLI(Cisco IOS、華為 VRP、H3C Comware)。',
'settings.terminal.behavior.confirmMultilinePasteMinLines': '確認門檻(行數)',
'settings.terminal.behavior.confirmMultilinePasteMinLines.desc':
'貼上內容達到該行數時才彈出確認對話框。',
'settings.terminal.behavior.shiftEnterNewline': 'Shift+Enter 傳送文字',
'settings.terminal.behavior.shiftEnterNewline.desc':
'在終端中按 Shift+Enter 時傳送設定的文字,而不是一般 Enter。',
@@ -343,6 +360,11 @@ export const zhTWTerminalMessages: Messages = {
'settings.terminal.behavior.dynamicTabTitle.off': '關閉',
'settings.terminal.behavior.dynamicTabTitle.agent': '僅 Agent',
'settings.terminal.behavior.dynamicTabTitle.all': '全部工作階段',
'settings.terminal.behavior.tabDoubleClick': '分頁雙擊行為',
'settings.terminal.behavior.tabDoubleClick.desc': '選擇雙擊頂部終端分頁時要執行的動作。',
'settings.terminal.behavior.tabDoubleClick.duplicate': '複製工作階段(新連線)',
'settings.terminal.behavior.tabDoubleClick.copy': '複製分頁(重用連線)',
'settings.terminal.behavior.tabDoubleClick.disabled': '無動作',
'settings.terminal.behavior.oscNotifications': 'OSC 桌面通知',
'settings.terminal.behavior.oscNotifications.desc':
'當程式發出 OSC 9、OSC 777 notify 或 kitty OSC 99 時顯示系統通知。Codex 等 Agent 會用它提示回合完成或等待核准。',
@@ -909,6 +931,10 @@ export const zhTWTerminalMessages: Messages = {
'serial.field.configLabelPlaceholder': '例如 Arduino Uno',
'serial.connectAndSave': '連線並儲存',
'serial.edit.title': '序列埠設定',
'serial.field.username': '使用者名稱',
'serial.field.password': '密碼',
'serial.field.autoLoginDesc': '設定後,序列埠終端出現 Login/Password 登入提示時,會自動填入此處儲存的使用者名稱和密碼。',
'serial.field.clearPassword': '清除已儲存的密碼',
// Keyboard Interactive Authentication (2FA/MFA)
'keyboard.interactive.title': '需要驗證',
+1
View File
@@ -503,6 +503,7 @@ export const zhTWVaultMessages: Messages = {
'terminal.statusbar.reconnect.tooltip': '重新連線目前工作階段',
'terminal.serverStats.cpu': 'CPU 使用率',
'terminal.serverStats.cpuCores': 'CPU 核心使用率',
'terminal.serverStats.gpu': 'GPU 使用率',
'terminal.serverStats.memory': '記憶體使用',
'terminal.serverStats.memoryDetails': '記憶體詳情',
'terminal.serverStats.memUsed': '已用',
+54
View File
@@ -78,3 +78,57 @@ test('prepared apply callback runs after the snapshot with sentinel protection',
assert.equal(observedSentinel, true);
assert.equal(readInterruptedVaultApply(), null);
});
for (const localRestore of [false, true]) {
for (const encryptionFails of [false, true]) {
test(`protected header preparation (${localRestore ? 'restore' : 'sync'}, ${encryptionFails ? 'failure' : 'success'})`, async () => {
const { prepareSyncPayloadApply, prepareLocalVaultPayloadApply } = await import('./syncPayload.ts');
const { STORAGE_KEY_THEME, STORAGE_KEY_AI_PROVIDERS } = await import('../infrastructure/config/storageKeys.ts');
const sealed = `enc:v1:${Buffer.concat([Buffer.from('v10'), Buffer.alloc(32, 4)]).toString('base64')}`;
const calls: string[] = [];
Object.defineProperty(globalThis, 'window', { configurable: true, value: {
netcatty: { credentialsEncrypt: async () => {
calls.push('encrypt');
assert.equal(readInterruptedVaultApply(), null);
if (encryptionFails) throw new Error('keychain locked');
return sealed;
} },
dispatchEvent: () => true,
} });
localStorageValues.set(STORAGE_KEY_THEME, 'light');
const payload: SyncPayload = { ...emptyPayload(), settings: {
theme: 'dark', ai: { providers: [{ id: 'custom', customHeaders: { 'X-Tenant': 'secret' } }] },
} };
const original = JSON.stringify(payload);
const importers = { importVaultData: () => {
calls.push('import');
assert.notEqual(readInterruptedVaultApply(), null);
} };
const applying = applyProtectedSyncPayload({
buildPreApplyPayload: () => { calls.push('snapshot'); return emptyPayload(); },
prepareApply: () => localRestore
? prepareLocalVaultPayloadApply(payload, importers, { prepareConvergentRestore: async () => {
calls.push('prepareReplica');
return async () => { calls.push('commitReplica'); };
} })
: prepareSyncPayloadApply(payload, importers),
translateProtectiveBackupFailure: (message) => message,
});
if (encryptionFails) {
await assert.rejects(applying);
assert.deepEqual(calls, ['encrypt']);
assert.equal(localStorageValues.get(STORAGE_KEY_THEME), 'light');
assert.equal(localStorageValues.has(STORAGE_KEY_AI_PROVIDERS), false);
} else {
await applying;
assert.deepEqual(calls, localRestore
? ['encrypt', 'prepareReplica', 'snapshot', 'import', 'commitReplica']
: ['encrypt', 'snapshot', 'import']);
assert.equal(localStorageValues.get(STORAGE_KEY_THEME), 'dark');
assert.equal(JSON.parse(localStorageValues.get(STORAGE_KEY_AI_PROVIDERS)!)[0].customHeaders['X-Tenant'], sealed);
}
assert.equal(readInterruptedVaultApply(), null);
assert.equal(JSON.stringify(payload), original);
});
}
}
@@ -0,0 +1,42 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { getComposeBarHistory, pruneComposeBarHistory, recordComposeBarHistory } from './composeBarHistoryStore';
test('compose history is isolated per terminal and removed when its session closes', () => {
pruneComposeBarHistory([]);
recordComposeBarHistory('a', 'echo a\necho again');
recordComposeBarHistory('b', 'echo b');
assert.deepEqual(getComposeBarHistory('a'), ['echo a\necho again']);
assert.deepEqual(getComposeBarHistory('b'), ['echo b']);
pruneComposeBarHistory(['b']);
assert.deepEqual(getComposeBarHistory('a'), []);
assert.deepEqual(getComposeBarHistory('b'), ['echo b']);
pruneComposeBarHistory([]);
});
test('late send completion cannot restore a closed session or overwrite another send', async () => {
const { createComposeBarHistoryRecorder } = await import('./composeBarHistoryStore');
pruneComposeBarHistory([]);
const first = createComposeBarHistoryRecorder('a');
const second = createComposeBarHistoryRecorder('a');
second('second');
first('first');
assert.deepEqual(getComposeBarHistory('a'), ['first', 'second']);
const late = createComposeBarHistoryRecorder('a');
pruneComposeBarHistory([]);
late('late');
assert.deepEqual(getComposeBarHistory('a'), []);
});
test('failed submissions unblock later successes in submission order', async () => {
const { createComposeBarHistoryRecorder } = await import('./composeBarHistoryStore');
pruneComposeBarHistory([]);
const failed = createComposeBarHistoryRecorder('a');
const accepted = createComposeBarHistoryRecorder('a');
accepted('accepted');
assert.deepEqual(getComposeBarHistory('a'), []);
failed();
assert.deepEqual(getComposeBarHistory('a'), ['accepted']);
pruneComposeBarHistory([]);
});
@@ -0,0 +1,46 @@
import { appendComposeBarHistory } from '../../domain/composeBarHistory';
// Session-only memory: survives closing the bar and moving a terminal into a
// workspace, but is discarded when that terminal session is removed.
type PendingSend = { settled: boolean; command?: string };
const histories = new Map<string, { entries: readonly string[]; pending: PendingSend[] }>();
export function getComposeBarHistory(sessionId: string): readonly string[] {
return histories.get(sessionId)?.entries ?? [];
}
export function createComposeBarHistoryRecorder(sessionId: string): (command?: string) => void {
let history = histories.get(sessionId);
if (!history) {
history = { entries: [], pending: [] };
histories.set(sessionId, history);
}
const target = history;
const submission: PendingSend = { settled: false };
target.pending.push(submission);
// Capture the session's object before an async send. A late completion after
// session removal cannot recreate that session's history in the map.
return (command) => {
if (submission.settled) return;
submission.settled = true;
submission.command = command;
// Settle failures too, so they cannot block later successful submissions.
while (target.pending[0]?.settled) {
const next = target.pending.shift()!;
if (next.command !== undefined) {
target.entries = appendComposeBarHistory(target.entries, next.command);
}
}
};
}
export function recordComposeBarHistory(sessionId: string, command: string): void {
createComposeBarHistoryRecorder(sessionId)(command);
}
export function pruneComposeBarHistory(sessionIds: readonly string[]): void {
const active = new Set(sessionIds);
for (const id of histories.keys()) {
if (!active.has(id)) histories.delete(id);
}
}
@@ -0,0 +1,62 @@
import test from "node:test";
import assert from "node:assert/strict";
import {
getPendingMultilinePasteConfirm,
requestMultilinePasteConfirm,
respondMultilinePasteConfirm,
subscribeMultilinePasteConfirm,
} from "./multilinePasteConfirmStore";
test("request stores the pending paste and respond resolves it", async () => {
const pending = requestMultilinePasteConfirm({
text: "line1\nline2",
lineCount: 2,
charCount: 12,
});
assert.equal(getPendingMultilinePasteConfirm()?.lineCount, 2);
respondMultilinePasteConfirm("line-by-line", "line1\nline2\nline3");
const response = await pending;
assert.deepEqual(response, { action: "line-by-line", text: "line1\nline2\nline3" });
assert.equal(getPendingMultilinePasteConfirm(), null);
});
test("respond without text falls back to the original request text", async () => {
const pending = requestMultilinePasteConfirm({
text: "line1\nline2",
lineCount: 2,
charCount: 12,
});
respondMultilinePasteConfirm("send");
const response = await pending;
assert.deepEqual(response, { action: "send", text: "line1\nline2" });
});
test("a newer paste request supersedes the pending one as cancelled", async () => {
const first = requestMultilinePasteConfirm({ text: "a\nb", lineCount: 2, charCount: 3 });
const second = requestMultilinePasteConfirm({ text: "c\nd", lineCount: 2, charCount: 3 });
assert.equal(getPendingMultilinePasteConfirm()?.text, "c\nd");
respondMultilinePasteConfirm("send", "c\nd");
assert.deepEqual(await first, { action: "cancel", text: "a\nb" });
assert.deepEqual(await second, { action: "send", text: "c\nd" });
});
test("respond without a pending request is a no-op and listeners are notified", async () => {
respondMultilinePasteConfirm("send");
assert.equal(getPendingMultilinePasteConfirm(), null);
const events: Array<string | null> = [];
const unsubscribe = subscribeMultilinePasteConfirm(() => {
events.push(getPendingMultilinePasteConfirm()?.id ?? "cleared");
});
const pending = requestMultilinePasteConfirm({ text: "x", lineCount: 1, charCount: 1 });
const pendingId = getPendingMultilinePasteConfirm()?.id ?? null;
respondMultilinePasteConfirm("cancel", "x");
await pending;
unsubscribe();
assert.ok(pendingId);
assert.deepEqual(events, [pendingId, "cleared"]);
});
@@ -0,0 +1,81 @@
/**
* App-level store for the "paste multiple lines" confirmation dialog (#3398).
*
* Terminal paste handlers call `requestMultilinePasteConfirm` from anywhere in
* the renderer and await the user's decision; `MultilinePasteConfirmHost`
* (mounted once in App) renders the dialog and resolves the pending request.
*/
export type MultilinePasteConfirmAction = "send" | "line-by-line" | "cancel";
export interface MultilinePasteConfirmRequest {
id: string;
/** Clipboard text the dialog previews (editable by the user). */
text: string;
lineCount: number;
charCount: number;
/** Restore the originating terminal's focus after the modal releases it. */
onClose?: () => void;
}
export interface MultilinePasteConfirmResponse {
action: MultilinePasteConfirmAction;
/** Text to send: the preview content, possibly edited by the user. */
text: string;
}
interface PendingRequest {
request: MultilinePasteConfirmRequest;
resolve: (response: MultilinePasteConfirmResponse) => void;
}
let pending: PendingRequest | null = null;
let nextRequestId = 0;
const listeners = new Set<() => void>();
const emit = (): void => {
for (const listener of listeners) listener();
};
export function requestMultilinePasteConfirm(
request: Omit<MultilinePasteConfirmRequest, "id">,
): Promise<MultilinePasteConfirmResponse> {
// Only one confirmation dialog can be pending; a newer paste supersedes the
// older one (the stale promise resolves as "cancel" so its paste is dropped).
if (pending) {
const stale = pending;
pending = null;
stale.resolve({ action: "cancel", text: stale.request.text });
}
nextRequestId += 1;
const requestWithId: MultilinePasteConfirmRequest = {
...request,
id: `multiline-paste-${nextRequestId}`,
};
return new Promise((resolve) => {
pending = { request: requestWithId, resolve };
emit();
});
}
export function respondMultilinePasteConfirm(
action: MultilinePasteConfirmAction,
text?: string,
): void {
const current = pending;
if (!current) return;
pending = null;
current.resolve({ action, text: text ?? current.request.text });
emit();
}
/** Snapshot for `useSyncExternalStore`; stable reference while pending. */
export const getPendingMultilinePasteConfirm =
(): MultilinePasteConfirmRequest | null => pending?.request ?? null;
export function subscribeMultilinePasteConfirm(listener: () => void): () => void {
listeners.add(listener);
return () => {
listeners.delete(listener);
};
}
+10 -1
View File
@@ -23,6 +23,7 @@ export type SettingsChromeSnapshot = {
darkUiThemeId: string;
uiLanguage: UILanguage;
windowOpacity: number;
tabBarPosition: 'top' | 'bottom';
showSftpTab: boolean;
showHostTreeSidebar: boolean;
showRecentHosts: boolean;
@@ -36,6 +37,8 @@ export type SettingsChromeSnapshot = {
restoreTerminalCwd: boolean;
terminalSidePanelAutoOpen: boolean;
terminalSidePanelAutoOpenTab: TerminalSidePanelAutoOpenTab;
localShellSidePanelAutoOpen: boolean;
localShellSidePanelAutoOpenTab: TerminalSidePanelAutoOpenTab;
};
/**
@@ -55,6 +58,7 @@ export const DEFAULT_SETTINGS_CHROME_SNAPSHOT: SettingsChromeSnapshot = Object.f
darkUiThemeId: 'default',
uiLanguage: 'en',
windowOpacity: 1,
tabBarPosition: 'top',
showSftpTab: true,
showHostTreeSidebar: true,
showRecentHosts: true,
@@ -68,6 +72,8 @@ export const DEFAULT_SETTINGS_CHROME_SNAPSHOT: SettingsChromeSnapshot = Object.f
restoreTerminalCwd: true,
terminalSidePanelAutoOpen: false,
terminalSidePanelAutoOpenTab: 'ai',
localShellSidePanelAutoOpen: false,
localShellSidePanelAutoOpenTab: 'scripts',
} satisfies SettingsChromeSnapshot);
export function settingsChromeSnapshotsEqual(
@@ -80,6 +86,7 @@ export function settingsChromeSnapshotsEqual(
&& a.darkUiThemeId === b.darkUiThemeId
&& a.uiLanguage === b.uiLanguage
&& a.windowOpacity === b.windowOpacity
&& a.tabBarPosition === b.tabBarPosition
&& a.showSftpTab === b.showSftpTab
&& a.showHostTreeSidebar === b.showHostTreeSidebar
&& a.showRecentHosts === b.showRecentHosts
@@ -92,7 +99,9 @@ export function settingsChromeSnapshotsEqual(
&& a.showTabNumberBadges === b.showTabNumberBadges
&& a.restoreTerminalCwd === b.restoreTerminalCwd
&& a.terminalSidePanelAutoOpen === b.terminalSidePanelAutoOpen
&& a.terminalSidePanelAutoOpenTab === b.terminalSidePanelAutoOpenTab;
&& a.terminalSidePanelAutoOpenTab === b.terminalSidePanelAutoOpenTab
&& a.localShellSidePanelAutoOpen === b.localShellSidePanelAutoOpen
&& a.localShellSidePanelAutoOpenTab === b.localShellSidePanelAutoOpenTab;
}
class SettingsChromeStore {
@@ -0,0 +1,79 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import React, { useState } from 'react';
import { act, create, type ReactTestRenderer } from 'react-test-renderer';
import { useSettingsIpcSync } from './settingsIpcSync.ts';
import { useSettingsStorageSync } from './settingsStorageSync.ts';
import type { TerminalSidePanelAutoOpenTab } from '../../domain/terminalSidePanelAutoOpen.ts';
import {
STORAGE_KEY_LOCAL_SHELL_SIDE_PANEL_AUTO_OPEN as enabledKey,
STORAGE_KEY_LOCAL_SHELL_SIDE_PANEL_AUTO_OPEN_TAB as tabKey,
} from '../../infrastructure/config/storageKeys.ts';
for (const pathway of ['ipc', 'storage'] as const) {
test(`${pathway} receives local shell settings, rejects invalid tabs and cleans up`, async (t) => {
let receive: ((payload: { key: string; value: unknown }) => void) | undefined;
let unsubscribed = false;
const fakeWindow = Object.assign(new EventTarget(), {
netcatty: {
onSettingsChanged(callback: typeof receive) {
receive = callback;
return () => { unsubscribed = true; };
},
},
});
for (const [key, value] of Object.entries({ window: fakeWindow, IS_REACT_ACT_ENVIRONMENT: true })) {
const previous = Object.getOwnPropertyDescriptor(globalThis, key);
Object.defineProperty(globalThis, key, { value, configurable: true });
t.after(() => {
if (previous) Object.defineProperty(globalThis, key, previous);
else Reflect.deleteProperty(globalThis, key);
});
}
let current = { enabled: false, tab: 'scripts' };
let renderer: ReactTestRenderer | undefined;
function Harness() {
const [enabled, setEnabled] = useState(false);
const [tab, setTab] = useState<TerminalSidePanelAutoOpenTab>('scripts');
current = { enabled, tab };
const params = {
localShellSidePanelAutoOpen: enabled,
localShellSidePanelAutoOpenTab: tab,
setLocalShellSidePanelAutoOpenState: setEnabled,
setLocalShellSidePanelAutoOpenTabState: setTab,
};
// Only local-shell events are dispatched; unrelated settings are not used.
useSettingsIpcSync({ ...params, enabled: pathway === 'ipc' } as unknown as Parameters<typeof useSettingsIpcSync>[0]);
useSettingsStorageSync({ ...params, enabled: pathway === 'storage' } as Parameters<typeof useSettingsStorageSync>[0]);
return null;
}
async function send(key: string, value: unknown) {
await act(async () => {
if (pathway === 'ipc') receive!({ key, value });
else fakeWindow.dispatchEvent(Object.assign(new Event('storage'), { key, newValue: value === null ? null : String(value) }));
});
}
try {
await act(async () => { renderer = create(React.createElement(Harness)); });
await send(enabledKey, true);
await send(tabKey, 'notes');
assert.deepEqual(current, { enabled: true, tab: 'notes' });
await send(tabKey, 'invalid');
await send(tabKey, null);
await send(enabledKey, null);
assert.deepEqual(current, { enabled: true, tab: 'notes' });
if (pathway === 'ipc') {
await send(enabledKey, 'false');
assert.equal(current.enabled, true);
}
await send(enabledKey, false);
await send(tabKey, 'history');
assert.deepEqual(current, { enabled: false, tab: 'history' });
await act(async () => { renderer!.unmount(); });
renderer = undefined;
if (pathway === 'ipc') assert.equal(unsubscribed, true);
} finally {
await act(async () => { renderer?.unmount(); });
}
});
}
+22
View File
@@ -1,3 +1,4 @@
import { normalizeTabBarPosition, type TabBarPosition } from '../../domain/tabBarPosition';
import { useEffect, type Dispatch, type SetStateAction } from 'react';
import type { CustomKeyBindings, HotkeyScheme, SessionLogFormat, TerminalSettings, UILanguage } from '../../domain/models';
import { parseCustomKeyBindingsStorageRecord } from '../../domain/customKeyBindings';
@@ -44,8 +45,11 @@ import {
STORAGE_KEY_UI_THEME_LIGHT,
STORAGE_KEY_WORKSPACE_FOCUS_STYLE,
STORAGE_KEY_SHOW_HOST_TREE_SIDEBAR,
STORAGE_KEY_TAB_BAR_POSITION,
STORAGE_KEY_TERMINAL_SIDE_PANEL_AUTO_OPEN,
STORAGE_KEY_TERMINAL_SIDE_PANEL_AUTO_OPEN_TAB,
STORAGE_KEY_LOCAL_SHELL_SIDE_PANEL_AUTO_OPEN,
STORAGE_KEY_LOCAL_SHELL_SIDE_PANEL_AUTO_OPEN_TAB,
STORAGE_KEY_WINDOW_OPACITY,
STORAGE_KEY_APP_ICON_VARIANT,
STORAGE_KEY_HTTP_NETWORK_PROXY,
@@ -100,9 +104,12 @@ interface UseSettingsIpcSyncParams {
setSftpFollowTerminalCwd: Dispatch<SetStateAction<boolean>>;
setSftpDefaultViewMode: Dispatch<SetStateAction<'list' | 'tree'>>;
setWorkspaceFocusStyleState: Dispatch<SetStateAction<'dim' | 'border'>>;
setTabBarPositionState: Dispatch<SetStateAction<TabBarPosition>>;
setShowHostTreeSidebarState: Dispatch<SetStateAction<boolean>>;
setTerminalSidePanelAutoOpenState: Dispatch<SetStateAction<boolean>>;
setTerminalSidePanelAutoOpenTabState: Dispatch<SetStateAction<TerminalSidePanelAutoOpenTab>>;
setLocalShellSidePanelAutoOpenState: Dispatch<SetStateAction<boolean>>;
setLocalShellSidePanelAutoOpenTabState: Dispatch<SetStateAction<TerminalSidePanelAutoOpenTab>>;
setDisableTerminalFontZoomState: Dispatch<SetStateAction<boolean>>;
setRestorePreviousSessionState: Dispatch<SetStateAction<boolean>>;
setRestoreTerminalCwdState: Dispatch<SetStateAction<boolean>>;
@@ -146,9 +153,12 @@ export function useSettingsIpcSync({
setSftpFollowTerminalCwd,
setSftpDefaultViewMode,
setWorkspaceFocusStyleState,
setTabBarPositionState,
setShowHostTreeSidebarState,
setTerminalSidePanelAutoOpenState,
setTerminalSidePanelAutoOpenTabState,
setLocalShellSidePanelAutoOpenState,
setLocalShellSidePanelAutoOpenTabState,
setDisableTerminalFontZoomState,
setRestorePreviousSessionState,
setRestoreTerminalCwdState,
@@ -300,6 +310,9 @@ export function useSettingsIpcSync({
if (key === STORAGE_KEY_WORKSPACE_FOCUS_STYLE && (value === 'dim' || value === 'border')) {
setWorkspaceFocusStyleState((prev) => (prev === value ? prev : value));
}
if (key === STORAGE_KEY_TAB_BAR_POSITION) {
setTabBarPositionState(normalizeTabBarPosition(value));
}
if (key === STORAGE_KEY_SHOW_HOST_TREE_SIDEBAR && typeof value === 'boolean') {
setShowHostTreeSidebarState((prev) => (prev === value ? prev : value));
}
@@ -309,6 +322,12 @@ export function useSettingsIpcSync({
if (key === STORAGE_KEY_TERMINAL_SIDE_PANEL_AUTO_OPEN_TAB && isTerminalSidePanelAutoOpenTab(value)) {
setTerminalSidePanelAutoOpenTabState((prev) => (prev === value ? prev : value));
}
if (key === STORAGE_KEY_LOCAL_SHELL_SIDE_PANEL_AUTO_OPEN && typeof value === 'boolean') {
setLocalShellSidePanelAutoOpenState((prev) => (prev === value ? prev : value));
}
if (key === STORAGE_KEY_LOCAL_SHELL_SIDE_PANEL_AUTO_OPEN_TAB && isTerminalSidePanelAutoOpenTab(value)) {
setLocalShellSidePanelAutoOpenTabState((prev) => (prev === value ? prev : value));
}
if (key === STORAGE_KEY_DISABLE_TERMINAL_FONT_ZOOM && typeof value === 'boolean') {
setDisableTerminalFontZoomState((prev) => (prev === value ? prev : value));
}
@@ -360,9 +379,12 @@ export function useSettingsIpcSync({
setSftpAutoOpenSidebar,
setSftpFollowTerminalCwd,
setSftpDefaultViewMode,
setTabBarPositionState,
setShowHostTreeSidebarState,
setTerminalSidePanelAutoOpenState,
setTerminalSidePanelAutoOpenTabState,
setLocalShellSidePanelAutoOpenState,
setLocalShellSidePanelAutoOpenTabState,
setDisableTerminalFontZoomState,
setRestorePreviousSessionState,
setRestoreTerminalCwdState,
+31 -5
View File
@@ -1,3 +1,4 @@
import { normalizeTabBarPosition, type TabBarPosition } from '../../domain/tabBarPosition';
import { useEffect, useRef, type Dispatch, type SetStateAction } from 'react';
import type { CustomKeyBindings, HotkeyScheme, SessionLogFormat, TerminalSettings, UILanguage } from '../../domain/models';
import { parseCustomKeyBindingsStorageRecord } from '../../domain/customKeyBindings';
@@ -34,9 +35,12 @@ import {
STORAGE_KEY_SHOW_RECENT_HOSTS,
STORAGE_KEY_HOST_CLICK_BEHAVIOR,
STORAGE_KEY_SHOW_SFTP_TAB,
STORAGE_KEY_TAB_BAR_POSITION,
STORAGE_KEY_SHOW_HOST_TREE_SIDEBAR,
STORAGE_KEY_TERMINAL_SIDE_PANEL_AUTO_OPEN,
STORAGE_KEY_TERMINAL_SIDE_PANEL_AUTO_OPEN_TAB,
STORAGE_KEY_LOCAL_SHELL_SIDE_PANEL_AUTO_OPEN,
STORAGE_KEY_LOCAL_SHELL_SIDE_PANEL_AUTO_OPEN_TAB,
STORAGE_KEY_SHELL_ONLY_TAB_NUMBER_SHORTCUTS,
STORAGE_KEY_SHOW_TAB_NUMBER_BADGES,
STORAGE_KEY_DISABLE_TERMINAL_FONT_ZOOM,
@@ -90,10 +94,13 @@ interface UseSettingsStorageSyncParams {
showRecentHosts: boolean;
hostClickBehavior: HostClickBehavior;
showOnlyUngroupedHostsInRoot: boolean;
tabBarPosition: TabBarPosition;
showSftpTab: boolean;
showHostTreeSidebar: boolean;
terminalSidePanelAutoOpen: boolean;
terminalSidePanelAutoOpenTab: TerminalSidePanelAutoOpenTab;
localShellSidePanelAutoOpen: boolean;
localShellSidePanelAutoOpenTab: TerminalSidePanelAutoOpenTab;
shellOnlyTabNumberShortcuts: boolean;
showTabNumberBadges: boolean;
disableTerminalFontZoom: boolean;
@@ -139,10 +146,13 @@ interface UseSettingsStorageSyncParams {
setShowRecentHostsState: Dispatch<SetStateAction<boolean>>;
setHostClickBehaviorState: Dispatch<SetStateAction<HostClickBehavior>>;
setShowOnlyUngroupedHostsInRootState: Dispatch<SetStateAction<boolean>>;
setTabBarPositionState: Dispatch<SetStateAction<TabBarPosition>>;
setShowSftpTabState: Dispatch<SetStateAction<boolean>>;
setShowHostTreeSidebarState: Dispatch<SetStateAction<boolean>>;
setTerminalSidePanelAutoOpenState: Dispatch<SetStateAction<boolean>>;
setTerminalSidePanelAutoOpenTabState: Dispatch<SetStateAction<TerminalSidePanelAutoOpenTab>>;
setLocalShellSidePanelAutoOpenState: Dispatch<SetStateAction<boolean>>;
setLocalShellSidePanelAutoOpenTabState: Dispatch<SetStateAction<TerminalSidePanelAutoOpenTab>>;
setShellOnlyTabNumberShortcutsState: Dispatch<SetStateAction<boolean>>;
setShowTabNumberBadgesState: Dispatch<SetStateAction<boolean>>;
setDisableTerminalFontZoomState: Dispatch<SetStateAction<boolean>>;
@@ -176,7 +186,7 @@ export function useSettingsStorageSync({
terminalThemeId, followAppTerminalTheme, terminalFontFamilyId, terminalFontSize,
sftpDoubleClickBehavior, sftpAutoSync, sftpShowHiddenFiles,
sftpUseCompressedUpload, sftpSkipUnchanged, sftpAutoOpenSidebar, sftpFollowTerminalCwd, sftpDefaultViewMode,
showRecentHosts, hostClickBehavior, showOnlyUngroupedHostsInRoot, showSftpTab, showHostTreeSidebar, terminalSidePanelAutoOpen, terminalSidePanelAutoOpenTab, shellOnlyTabNumberShortcuts, showTabNumberBadges, disableTerminalFontZoom, restorePreviousSession, restoreTerminalCwd, startupLanding,
showRecentHosts, hostClickBehavior, showOnlyUngroupedHostsInRoot, tabBarPosition, showSftpTab, showHostTreeSidebar, terminalSidePanelAutoOpen, terminalSidePanelAutoOpenTab, localShellSidePanelAutoOpen, localShellSidePanelAutoOpenTab, shellOnlyTabNumberShortcuts, showTabNumberBadges, disableTerminalFontZoom, restorePreviousSession, restoreTerminalCwd, startupLanding,
editorWordWrap, sessionLogsEnabled, sessionLogsDir, sessionLogsFormat, sessionLogsTimestampsEnabled, sshDebugLogsEnabled, sshDeepLinkEnabled, jmsDeepLinkEnabled, explorerContextMenuEnabled,
globalHotkeyEnabled, autoUpdateEnabled, windowOpacity, appIconVariant,
setTheme, setLightUiThemeId, setDarkUiThemeId, setAccentMode, applyIncomingCustomAccent,
@@ -185,7 +195,7 @@ export function useSettingsStorageSync({
setFollowAppTerminalThemeState, setTerminalFontFamilyId, setTerminalFontSize,
setSftpDoubleClickBehavior, setSftpAutoSync, setSftpShowHiddenFiles,
setSftpUseCompressedUpload, setSftpSkipUnchanged, setSftpAutoOpenSidebar, setSftpFollowTerminalCwd, setSftpDefaultViewMode,
setShowRecentHostsState, setHostClickBehaviorState, setShowOnlyUngroupedHostsInRootState, setShowSftpTabState, setShowHostTreeSidebarState, setTerminalSidePanelAutoOpenState, setTerminalSidePanelAutoOpenTabState, setShellOnlyTabNumberShortcutsState, setShowTabNumberBadgesState, setDisableTerminalFontZoomState, setRestorePreviousSessionState, setRestoreTerminalCwdState, setStartupLandingState,
setShowRecentHostsState, setHostClickBehaviorState, setShowOnlyUngroupedHostsInRootState, setTabBarPositionState, setShowSftpTabState, setShowHostTreeSidebarState, setTerminalSidePanelAutoOpenState, setTerminalSidePanelAutoOpenTabState, setLocalShellSidePanelAutoOpenState, setLocalShellSidePanelAutoOpenTabState, setShellOnlyTabNumberShortcutsState, setShowTabNumberBadgesState, setDisableTerminalFontZoomState, setRestorePreviousSessionState, setRestoreTerminalCwdState, setStartupLandingState,
setEditorWordWrapState, setSessionLogsEnabled, setSessionLogsDir, setSessionLogsFormat, setSessionLogsTimestampsEnabled, setSshDebugLogsEnabled, setSshDeepLinkEnabledState, setJmsDeepLinkEnabledState, setExplorerContextMenuEnabledState,
setGlobalHotkeyEnabled, setWindowOpacity, setAppIconVariant, setAutoUpdateEnabled, setWorkspaceFocusStyleState,
setSftpTransferConcurrencyState, setSshTransportIdleTtlMsState,
@@ -200,7 +210,7 @@ export function useSettingsStorageSync({
terminalThemeId, followAppTerminalTheme, terminalFontFamilyId, terminalFontSize,
sftpDoubleClickBehavior, sftpAutoSync, sftpShowHiddenFiles,
sftpUseCompressedUpload, sftpSkipUnchanged, sftpAutoOpenSidebar, sftpFollowTerminalCwd, sftpDefaultViewMode,
showRecentHosts, hostClickBehavior, showOnlyUngroupedHostsInRoot, showSftpTab, showHostTreeSidebar, terminalSidePanelAutoOpen, terminalSidePanelAutoOpenTab, shellOnlyTabNumberShortcuts, showTabNumberBadges, disableTerminalFontZoom, restorePreviousSession, restoreTerminalCwd, startupLanding,
showRecentHosts, hostClickBehavior, showOnlyUngroupedHostsInRoot, tabBarPosition, showSftpTab, showHostTreeSidebar, terminalSidePanelAutoOpen, terminalSidePanelAutoOpenTab, localShellSidePanelAutoOpen, localShellSidePanelAutoOpenTab, shellOnlyTabNumberShortcuts, showTabNumberBadges, disableTerminalFontZoom, restorePreviousSession, restoreTerminalCwd, startupLanding,
editorWordWrap, sessionLogsEnabled, sessionLogsDir, sessionLogsFormat, sessionLogsTimestampsEnabled, sshDebugLogsEnabled, sshDeepLinkEnabled, jmsDeepLinkEnabled, explorerContextMenuEnabled,
globalHotkeyEnabled, autoUpdateEnabled, windowOpacity, appIconVariant,
});
@@ -210,7 +220,7 @@ export function useSettingsStorageSync({
terminalThemeId, followAppTerminalTheme, terminalFontFamilyId, terminalFontSize,
sftpDoubleClickBehavior, sftpAutoSync, sftpShowHiddenFiles,
sftpUseCompressedUpload, sftpSkipUnchanged, sftpAutoOpenSidebar, sftpFollowTerminalCwd, sftpDefaultViewMode,
showRecentHosts, hostClickBehavior, showOnlyUngroupedHostsInRoot, showSftpTab, showHostTreeSidebar, terminalSidePanelAutoOpen, terminalSidePanelAutoOpenTab, shellOnlyTabNumberShortcuts, showTabNumberBadges, disableTerminalFontZoom, restorePreviousSession, restoreTerminalCwd, startupLanding,
showRecentHosts, hostClickBehavior, showOnlyUngroupedHostsInRoot, tabBarPosition, showSftpTab, showHostTreeSidebar, terminalSidePanelAutoOpen, terminalSidePanelAutoOpenTab, localShellSidePanelAutoOpen, localShellSidePanelAutoOpenTab, shellOnlyTabNumberShortcuts, showTabNumberBadges, disableTerminalFontZoom, restorePreviousSession, restoreTerminalCwd, startupLanding,
editorWordWrap, sessionLogsEnabled, sessionLogsDir, sessionLogsFormat, sessionLogsTimestampsEnabled, sshDebugLogsEnabled, sshDeepLinkEnabled, jmsDeepLinkEnabled, explorerContextMenuEnabled,
globalHotkeyEnabled, autoUpdateEnabled, windowOpacity, appIconVariant,
};
@@ -431,6 +441,9 @@ export function useSettingsStorageSync({
setShowOnlyUngroupedHostsInRootState(newValue);
}
}
if (e.key === STORAGE_KEY_TAB_BAR_POSITION) {
setTabBarPositionState(normalizeTabBarPosition(e.newValue));
}
if (e.key === STORAGE_KEY_SHOW_SFTP_TAB && e.newValue !== null) {
const newValue = e.newValue === 'true';
if (newValue !== s.showSftpTab) {
@@ -454,6 +467,17 @@ export function useSettingsStorageSync({
setTerminalSidePanelAutoOpenTabState(e.newValue);
}
}
if (e.key === STORAGE_KEY_LOCAL_SHELL_SIDE_PANEL_AUTO_OPEN && e.newValue !== null) {
const newValue = e.newValue === 'true';
if (newValue !== s.localShellSidePanelAutoOpen) {
setLocalShellSidePanelAutoOpenState(newValue);
}
}
if (e.key === STORAGE_KEY_LOCAL_SHELL_SIDE_PANEL_AUTO_OPEN_TAB && e.newValue !== null) {
if (isTerminalSidePanelAutoOpenTab(e.newValue) && e.newValue !== s.localShellSidePanelAutoOpenTab) {
setLocalShellSidePanelAutoOpenTabState(e.newValue);
}
}
if (e.key === STORAGE_KEY_SHELL_ONLY_TAB_NUMBER_SHORTCUTS && e.newValue !== null) {
const newValue = e.newValue === 'true';
if (newValue !== s.shellOnlyTabNumberShortcuts) {
@@ -574,8 +598,10 @@ export function useSettingsStorageSync({
setShowHostTreeSidebarState,
setTerminalSidePanelAutoOpenState,
setTerminalSidePanelAutoOpenTabState,
setLocalShellSidePanelAutoOpenState,
setLocalShellSidePanelAutoOpenTabState,
setShowRecentHostsState,
setShowSftpTabState,
setTabBarPositionState, setShowSftpTabState,
setShellOnlyTabNumberShortcutsState,
setShowTabNumberBadgesState,
setDisableTerminalFontZoomState,
@@ -41,8 +41,12 @@ const host = (id: string, label: string, hostname = label): Host => ({
for (const retainedStatus of [undefined, "interrupted", "failed", "paused"] as const) {
for (const newerPause of retainedStatus === "paused" ? [false, true] : [false]) {
for (const ownerChange of retainedStatus === "interrupted" || retainedStatus === undefined ? ["same", "active", "completed"] : ["same"]) {
for (const completedBeforeReplacement of ownerChange === "active" ? [false, true] : [false]) {
for (const flushBeforeReply of ownerChange === "same" || retainedStatus === undefined ? [false] : [false, true]) {
for (const replyRejects of ownerChange !== "same" && !completedBeforeReplacement && !flushBeforeReply ? [false, true] : [false]) {
const batchExistingIdentity = retainedStatus !== undefined;
test(`superseded folder child settles when its completion was compacted into the parent: retained=${retainedStatus ?? "none"}, newerPause=${newerPause}`, async (t) => {
test(`superseded folder child settles when its completion was compacted into the parent: retained=${retainedStatus ?? "none"}, newerPause=${newerPause}, owner=${ownerChange}, flush=${flushBeforeReply}, priorComplete=${completedBeforeReplacement}, reject=${replyRejects}`, async (t) => {
const { sftpTransferCenterStore } = await import("../sftpTransferCenterStore");
const previousLocalStorage = Object.getOwnPropertyDescriptor(globalThis, "localStorage");
Object.defineProperty(globalThis, "localStorage", {
@@ -75,14 +79,19 @@ test(`superseded folder child settles when its completion was compacted into the
...parent, id: "retained-child", parentTaskId: parent.id, isDirectory: false,
sourcePath: "/local/folder/a.bin", targetPath: "/remote/folder/a.bin",
totalBytes: 10, sourceLastModified: 1, status: retainedStatus ?? "interrupted",
directoryEntryIndex: 0, directoryEntryIdentity: "a".repeat(64),
directoryEntryIndex: 0,
directoryEntryIdentity: newerPause ? createDirectoryEntryIdentity({
sourcePath: "/local/folder/a.bin", targetPath: "/remote/folder/a.bin", size: 10, lastModified: 1,
}) : "a".repeat(64),
};
const { createDedicatedResumeChildUpdateBatcher } = await import("../../app/dedicatedResumeProgress");
// Exercise the real large-history batching branch with one relevant retained row.
const childBatcher = createDedicatedResumeChildUpdateBatcher({
getTaskCount: () => 4096,
hasTask: (id) => id === persisted.id,
upsertTasks: (updates) => sftpTransferCenterStore.upsertTasks(updates),
hasTask: (id) => id === persisted.id || id.startsWith("flush-peer-"),
// Peer rows only drive the real 512-entry flush; keep the relevant store
// fixture small so unrelated history maintenance cannot mask ownership.
upsertTasks: (updates) => sftpTransferCenterStore.upsertTasks(updates.filter((task) => !task.id.startsWith("flush-peer-"))),
});
sftpTransferCenterStore.publishOwner("compacted-owner", [parent, ...(batchExistingIdentity ? [persisted] : [])]);
(netcattyBridge as { get: () => unknown }).get = () => ({
@@ -106,20 +115,50 @@ test(`superseded folder child settles when its completion was compacted into the
directoryEntryIndex: options.directoryEntryIndex,
directoryEntryIdentity: options.directoryEntryIdentity,
});
// A newer same-id owner completes before this older invocation rejoins.
sftpTransferCenterStore.ingestBackgroundEvent({
type: "completed", transferId: childId, transferred: 10, totalBytes: 10, lifecycleEpoch: 0,
});
return { superseded: true };
if (ownerChange !== "same") {
const current = sftpTransferCenterStore.getTask(childId)!;
if (completedBeforeReplacement) {
sftpTransferCenterStore.ingestBackgroundEvent({
type: "completed", transferId: childId, transferred: 10, totalBytes: 10, lifecycleEpoch: 0,
});
}
sftpTransferCenterStore.upsertTasks([{
...current, status: "transferring", directoryEntryIdentity: "e".repeat(64), lifecycleEpoch: 1,
}]);
}
if (ownerChange !== "active") {
// A newer same-id owner completes before this older invocation rejoins.
sftpTransferCenterStore.ingestBackgroundEvent({
type: "completed", transferId: childId, transferred: 10, totalBytes: 10, lifecycleEpoch: ownerChange === "same" ? 0 : 1,
});
}
if (flushBeforeReply) {
for (let index = 0; index < 512; index += 1) {
childBatcher.push({ ...persisted, id: `flush-peer-${index}`, status: "transferring" });
}
if (ownerChange === "active") {
assert.equal(sftpTransferCenterStore.getTask(childId)?.directoryEntryIdentity, "e".repeat(64),
"automatic flush before the displaced reply must preserve the new owner");
} else assert.equal(sftpTransferCenterStore.getTask(childId), undefined,
"automatic flush must not recreate the compacted new owner");
}
if (replyRejects) throw new Error("old invocation transport failed");
return completedBeforeReplacement ? {} : { superseded: true };
},
});
running = resumeTransferWithDedicatedSession(parent, {
hosts: [host("h1", "box", "1.2.3.4")], keys: [], identities: [],
}, undefined, {
children: batchExistingIdentity ? [persisted] : [], shouldAbort: () => abort,
onChildUpdate: (child) => batchExistingIdentity
? childBatcher.push(child)
: sftpTransferCenterStore.publishOwner("compacted-owner", [parent, child]),
onChildSuperseded: (id) => childBatcher.discard(id),
flushChildUpdates: () => childBatcher.flush(),
onChildUpdate: (child, observation) => {
if (batchExistingIdentity) {
childBatcher.push(child, observation);
return true;
}
sftpTransferCenterStore.publishOwner("compacted-owner", [parent, child]);
},
});
const result = await Promise.race([
running,
@@ -133,6 +172,15 @@ test(`superseded folder child settles when its completion was compacted into the
}
assert.notEqual(result, "still-waiting", "completed compacted child must not leave its folder waiting forever");
assert.ok(childId);
if (ownerChange !== "same") {
childBatcher.flush();
assert.equal((result as { success: boolean }).success, false, "the displaced walk must not report success");
if (ownerChange === "active") {
assert.equal(sftpTransferCenterStore.getTask(childId)?.status, "transferring");
assert.equal(sftpTransferCenterStore.getTask(childId)?.directoryEntryIdentity, "e".repeat(64));
} else assert.equal(sftpTransferCenterStore.getTask(childId), undefined, "old waiter must not recreate a failed row");
return;
}
assert.equal(sftpTransferCenterStore.getTask(childId), undefined, "completed row was compacted");
assert.equal(sftpTransferCenterStore.getTask(parent.id)?.directoryResumeCheckpoint?.completedEntries, 1);
assert.notEqual(result, "still-waiting", "completed compacted child must not leave its folder waiting forever");
@@ -140,6 +188,10 @@ test(`superseded folder child settles when its completion was compacted into the
});
}
}
}
}
}
}
test("resolveDirectoryResumeTargetRoot prefers staged replace path", () => {
assert.equal(
@@ -696,6 +748,67 @@ test("single-file restart resume continues from checkpoint without page callback
}
});
test("stat-less SCP download resume keeps saved progress instead of restarting", async (t) => {
resetDedicatedSessionOpenGateForTests();
const previousLocalStorage = Object.getOwnPropertyDescriptor(globalThis, "localStorage");
Object.defineProperty(globalThis, "localStorage", {
configurable: true,
value: { getItem: () => "2", setItem: () => {}, removeItem: () => {} },
});
t.after(() => {
if (previousLocalStorage) Object.defineProperty(globalThis, "localStorage", previousLocalStorage);
else Reflect.deleteProperty(globalThis, "localStorage");
});
const originalGet = netcattyBridge.get;
let startOptions: Record<string, unknown> | undefined;
(netcattyBridge as { get: () => unknown }).get = () => ({
openSftp: async () => "dedicated-sftp",
closeSftp: async () => {},
// Stat-less SCP host: size cannot be measured, so the bridge reports the
// placeholder 0 with sizeKnown false.
statSftp: async () => ({ type: "file", size: 0, lastModified: 5, sizeKnown: false }),
startStreamTransfer: async (options: Record<string, unknown>) => {
startOptions = options;
return { transferId: "scp-download-resume" };
},
});
try {
const result = await resumeTransferWithDedicatedSession({
id: "scp-download-resume",
fileName: "file.bin",
sourcePath: "/remote/file.bin",
targetPath: "/local/file.bin",
sourceConnectionId: "old-sftp",
sourceHostId: "h1",
sourceHostLabel: "box",
targetConnectionId: "local",
direction: "download",
status: "interrupted",
totalBytes: 100,
transferredBytes: 20,
checkpointBytes: 20,
sourceLastModified: 5,
speed: 0,
startTime: 1,
isDirectory: false,
reconnectRequired: true,
}, {
hosts: [host("h1", "box", "1.2.3.4")],
keys: [],
identities: [],
});
assert.equal(result.success, true, result.error);
// Unknown size must not misread the saved checkpoint as a shrunk source;
// the download resumes from byte 20 instead of silently restarting.
assert.equal(startOptions?.checkpointBytes, 20);
assert.equal(startOptions?.totalBytes, 100);
} finally {
(netcattyBridge as { get: typeof originalGet }).get = originalGet;
resetDedicatedSessionOpenGateForTests();
}
});
test("folder restart resume reports file-count progress without child page callbacks", async (t) => {
resetDedicatedSessionOpenGateForTests();
const previousLocalStorage = Object.getOwnPropertyDescriptor(globalThis, "localStorage");
@@ -2131,3 +2244,202 @@ for (const scenario of ["deleted-id", "ambiguous-legacy-label"] as const) {
assert.equal(uploads, 0);
});
}
for (const completeDuringSetup of [false, true]) {
test(`large-history replace recovery retransfers retained completed children into its empty stage: late=${completeDuringSetup}`, async (t) => {
const { sftpTransferCenterStore: store } = await import("../sftpTransferCenterStore");
const { createDedicatedResumeChildUpdateBatcher } = await import("../../app/dedicatedResumeProgress");
const originalGet = netcattyBridge.get;
const previousLocalStorage = Object.getOwnPropertyDescriptor(globalThis, "localStorage");
Object.defineProperty(globalThis, "localStorage", {
configurable: true, value: { getItem: () => "2", setItem: () => {}, removeItem: () => {} },
});
const { createRequire } = await import("node:module");
const tempDirBridge = createRequire(import.meta.url)("../../../electron/bridges/tempDirBridge.cjs");
const root = await fs.promises.mkdtemp(`${tempDirBridge.getTempFilePath("folder-rebuild-test")}-`);
const finalPath = path.join(root, "final");
const stage = `${finalPath}.netcatty-retained-replace.part`;
await fs.promises.mkdir(stage);
await fs.promises.writeFile(path.join(stage, "b.txt"), "old contents");
const parent: TransferTask = {
id: "retained-replace", fileName: "folder", sourcePath: "/remote/folder", targetPath: finalPath,
stagedTargetPath: stage, replaceExistingTarget: true, sourceConnectionId: "old-sftp", targetConnectionId: "local",
sourceHostId: "h1", direction: "download", status: "transferring", totalBytes: 2,
transferredBytes: 1, speed: 0, startTime: 1, isDirectory: true, progressMode: "files", reconnectRequired: true,
};
const retained: TransferTask = {
...parent, id: "retained-replace-b", parentTaskId: parent.id, isDirectory: false,
sourcePath: "/remote/folder/b.txt", targetPath: `${stage}/b.txt`, fileName: "b.txt",
status: completeDuringSetup ? "interrupted" : "completed", totalBytes: 10, transferredBytes: 10, checkpointBytes: 10,
sourceLastModified: 2, directoryEntryIndex: 1,
directoryEntryIdentity: createDirectoryEntryIdentity({
sourcePath: "/remote/folder/b.txt", targetPath: `${stage}/b.txt`, size: 10, lastModified: 2,
}),
};
store.upsertTasks([parent, retained]);
assert.ok(store.getTask(retained.id), "missing earlier manifest index retains the completed row");
const batcher = createDedicatedResumeChildUpdateBatcher({
getTaskCount: () => 4096,
hasTask: (id) => !!store.getTask(id),
upsertTasks: (updates) => store.upsertTasks(updates),
});
let releaseFirst!: () => void;
const secondDispatched = new Promise<void>((resolve) => { releaseFirst = resolve; });
t.after(async () => {
await fs.promises.rm(root, { recursive: true, force: true });
netcattyBridge.get = originalGet;
store.patchTask(parent.id, { status: "completed" });
store.dismiss(parent.id);
if (previousLocalStorage) Object.defineProperty(globalThis, "localStorage", previousLocalStorage);
else Reflect.deleteProperty(globalThis, "localStorage");
resetDedicatedSessionOpenGateForTests();
});
(netcattyBridge as { get: () => unknown }).get = () => ({
openSftp: async () => "dedicated-sftp", closeSftp: async () => {},
listSftp: async (_id: string, remotePath: string) => {
if (completeDuringSetup) store.patchTask(retained.id, { status: "completed", ownerId: "dedicated-resume" });
return remotePath === parent.sourcePath
? ["a.txt", "b.txt"].map((name) => ({ name, type: "file", size: 10, lastModified: 2 })) : [];
},
mkdirLocal: async (target: string) => { await fs.promises.mkdir(target, { recursive: true }); },
statLocal: async (target: string) => {
try {
const stat = await fs.promises.stat(target);
return { type: stat.isDirectory() ? "directory" : "file", size: stat.size, lastModified: stat.mtimeMs };
} catch (error) {
if ((error as NodeJS.ErrnoException).code === "ENOENT") return null;
throw error;
}
},
statSftp: async (_id: string, source: string) => {
if (source.endsWith("a.txt")) await secondDispatched;
return { type: "file", size: 10, lastModified: 2 };
},
deleteLocalFile: async (target: string) => { await fs.promises.rm(target, { recursive: true }); },
renameLocalFile: async (source: string, target: string) => { await fs.promises.rename(source, target); },
startStreamTransfer: async (options: { targetPath: string; checkpointBytes: number }) => {
assert.equal(options.checkpointBytes, 0, "an empty replacement stage cannot reuse byte checkpoints");
await fs.promises.writeFile(options.targetPath, `new ${path.basename(options.targetPath)}`);
return {};
},
});
const result = await resumeTransferWithDedicatedSession(parent, {
hosts: [host("h1", "box")], keys: [], identities: [],
}, undefined, {
children: [retained],
flushChildUpdates: () => batcher.flush(),
onChildUpdate: (task, observation) => {
batcher.push(task, observation);
if (task.id === retained.id && task.status === "completed") releaseFirst();
return true;
},
onDirectoryCheckpointUpdate: (value) => store.patchTask(parent.id, { directoryResumeCheckpoint: value }),
});
batcher.flush();
assert.equal(result.success, true, result.error);
assert.deepEqual((await fs.promises.readdir(finalPath)).sort(), ["a.txt", "b.txt"],
"published replacement must include the previously completed file");
assert.equal(await fs.promises.readFile(path.join(finalPath, "b.txt"), "utf8"), "new b.txt");
});
}
for (const boundary of ["before-admission", "source-missing", "source-modified", "after-completion", "after-commit"] as const) {
test(`dedicated recovery preserves replacement ownership ${boundary}`, async (t) => {
const { sftpTransferCenterStore: store } = await import("../sftpTransferCenterStore");
const { createDedicatedResumeChildUpdateBatcher } = await import("../../app/dedicatedResumeProgress");
const originalGet = netcattyBridge.get;
const originalStorage = Object.getOwnPropertyDescriptor(globalThis, "localStorage");
Object.defineProperty(globalThis, "localStorage", { configurable: true, value: {
getItem: () => "2", setItem: () => {}, removeItem: () => {},
} });
const parent: TransferTask = {
id: `ownership-${boundary}`, fileName: "folder", sourcePath: "/local/folder", targetPath: "/remote/folder",
sourceConnectionId: "local", targetConnectionId: "old", targetHostId: "h1", direction: "upload",
status: "transferring", totalBytes: 2, transferredBytes: 0, speed: 0, startTime: 1,
isDirectory: true, progressMode: "files", reconnectRequired: true,
};
const child: TransferTask = {
...parent, id: `${parent.id}-a`, parentTaskId: parent.id, isDirectory: false,
sourcePath: "/local/folder/a", targetPath: "/remote/folder/a", fileName: "a", totalBytes: 1,
status: "interrupted", directoryEntryIndex: 0, directoryEntryIdentity: "a".repeat(64), sourceLastModified: 1,
};
store.upsertTasks([parent, boundary === "before-admission"
? { ...child, status: "transferring", directoryEntryIdentity: "b".repeat(64), lifecycleEpoch: 1 }
: child]);
const batch = createDedicatedResumeChildUpdateBatcher({
getTaskCount: () => 4096, hasTask: (id) => id === child.id || id.startsWith("peer-"),
upsertTasks: (rows) => store.upsertTasks(rows.filter((row) => !row.id.startsWith("peer-"))),
});
if (boundary === "before-admission") {
for (let n = 0; n < 511; n++) batch.push({ ...child, id: `peer-${n}` });
}
let releaseB!: () => void;
const bGate = new Promise<void>((resolve) => { releaseB = resolve; });
let timedOut = false;
const watchdog = setTimeout(() => { timedOut = true; releaseB(); }, 1000);
t.after(() => {
clearTimeout(watchdog);
releaseB();
batch.flush();
store.patchTask(parent.id, { status: "completed" });
store.dismiss(parent.id);
netcattyBridge.get = originalGet;
if (originalStorage) Object.defineProperty(globalThis, "localStorage", originalStorage);
else Reflect.deleteProperty(globalThis, "localStorage");
resetDedicatedSessionOpenGateForTests();
});
let starts = 0;
let displaced = 0;
let replaced = false;
(netcattyBridge as { get: () => unknown }).get = () => ({
openSftp: async () => "dedicated", closeSftp: async () => {
if (boundary === "after-commit") {
assert.equal(store.getTask(child.id), undefined, "completion must be committed before async cleanup");
store.upsertTasks([{ ...child, status: "transferring", directoryEntryIdentity: "b".repeat(64), lifecycleEpoch: 1 }]);
}
},
listLocalTree: async () => (boundary === "after-completion" ? ["a", "b"] : ["a"]).map((name) => ({
localPath: `/local/folder/${name}`, relativePath: name, type: "file", size: 1, lastModified: 1,
})),
mkdirSftp: async () => {}, statLocal: async () => {
if (boundary === "source-missing" || boundary === "source-modified") {
store.upsertTasks([{ ...child, status: "transferring", directoryEntryIdentity: "b".repeat(64), lifecycleEpoch: 1 }]);
if (boundary === "source-missing") return null;
return { type: "file", size: 1, lastModified: 2 };
}
return { type: "file", size: 1, lastModified: 1 };
},
startStreamTransfer: async (options: { sourcePath: string }) => {
starts++;
if (options.sourcePath.endsWith("/b")) await bGate;
return {};
},
});
const result = await resumeTransferWithDedicatedSession(parent, {
hosts: [host("h1", "box")], keys: [], identities: [],
}, undefined, {
children: [child],
onChildSuperseded: (id) => { displaced++; batch.discard(id); },
flushChildUpdates: () => batch.flush(),
onChildUpdate: (task, observation) => {
batch.push(task, observation);
if (boundary === "after-completion" && task.id === child.id && task.status === "completed" && !replaced) {
replaced = true;
queueMicrotask(() => {
try {
store.upsertTasks([{ ...task, status: "transferring", directoryEntryIdentity: "b".repeat(64), lifecycleEpoch: 1 }]);
} finally { releaseB(); }
});
}
return true;
},
});
batch.flush();
assert.equal(timedOut, false);
assert.equal(result.success, boundary === "after-commit", "only a committed success may precede a legitimate replacement");
assert.equal(store.getTask(child.id)?.directoryEntryIdentity, "b".repeat(64));
assert.equal(store.getTask(child.id)?.status, "transferring");
if (boundary !== "after-completion" && boundary !== "after-commit") assert.equal(starts, 0, "source validation and a full batch must not bypass ownership");
else assert.equal(displaced, boundary === "after-commit" ? 0 : 1);
});
}
@@ -1,4 +1,4 @@
import { runTransferAndWaitForOwner } from "./waitForTransferOwner";
import { runTransferAndWaitForOwner, TransferOwnerChangedError, type TransferOwnerObservation } from "./waitForTransferOwner";
import type { Host, Identity, KnownHost, SSHKey, TerminalSettings, TransferTask } from "../../../domain/models";
import { validateTransferResumeSource } from "../../../domain/sftpTransferCenter";
import { STORAGE_KEY_SFTP_TRANSFER_CONCURRENCY } from "../../../infrastructure/config/storageKeys";
@@ -62,7 +62,10 @@ export type DedicatedResumeResult = {
export type DedicatedResumeOptions = {
children?: readonly TransferTask[];
onChildUpdate?: (child: TransferTask) => void;
/** Return true only when the consumer takes responsibility for disposing the observation. */
onChildUpdate?: (child: TransferTask, observation?: TransferOwnerObservation) => unknown;
onChildSuperseded?: (taskId: string) => void;
flushChildUpdates?: () => void;
onDirectoryCheckpointUpdate?: (checkpoint: TransferTask["directoryResumeCheckpoint"]) => void;
shouldAbort?: () => boolean;
};
@@ -671,6 +674,7 @@ async function resumeSingleFileWithDedicatedSession(
const validationError = validateTransferResumeSource(task, {
size: sourceStat.size,
lastModified: sourceStat.lastModified,
sizeKnown: sourceStat.sizeKnown,
}, { allowSourceGrowth });
const classified = classifyResumeSourceValidationError(validationError);
if (classified.kind === "modified") {
@@ -1090,6 +1094,12 @@ async function resumeDirectoryWithDedicatedSession(
// the source since the interrupted attempt, so rebuild it from empty.
await resetDirectoryReplaceStage(parent, endpoints, targetSftpId);
}
// Session setup and traversal may finish more children. Capture only
// after resetting the stage/checkpoint: all these completions refer to
// the discarded destination, while later completions remain valid.
const completedAtRestart = new Map(sftpTransferCenterStore.getSnapshot().tasks
.filter((child) => child.parentTaskId === parent.id && child.status === "completed")
.map((child) => [child.id, child]));
const destRoot = resolveDirectoryResumeTargetRoot(parent);
if (endpoints.isDownload) await ensureLocalDir(destRoot);
if (targetSftpId) await ensureRemoteDir(targetSftpId, destRoot);
@@ -1188,8 +1198,25 @@ async function resumeDirectoryWithDedicatedSession(
}
if (targetSftpId) await ensureRemoteDir(targetSftpId, getParentPath(file.targetPath));
let countedCompleted = false;
const onOwnerChanged = () => {
if (countedCompleted) {
countedCompleted = false;
completedCount -= 1;
failedCount += 1;
}
options?.onChildSuperseded?.(childId);
};
let childObservation: TransferOwnerObservation | undefined = sftpTransferCenterStore.observeTaskSettlement(
childBase, resetPersistedCheckpoint ? completedAtRestart.get(childId) : undefined, onOwnerChanged,
);
const publishChild = (child: TransferTask) => {
if (childObservation?.hasIdentityConflict()) throw new TransferOwnerChangedError("Transfer identity changed before child update");
if (options?.onChildUpdate?.(child, childObservation) === true) childObservation = undefined;
};
try {
if (options?.shouldAbort?.()) throw new Error("Transfer cancelled");
if (childObservation.hasIdentityConflict()) throw new TransferOwnerChangedError("Transfer identity changed before source validation");
const sourceType = endpoints.isUpload ? "local" as const : "sftp" as const;
const targetType = endpoints.isDownload ? "local" as const : "sftp" as const;
@@ -1206,6 +1233,7 @@ async function resumeDirectoryWithDedicatedSession(
const validationError = validateTransferResumeSource(childBase, {
size: sourceStat.size,
lastModified: sourceStat.lastModified,
sizeKnown: sourceStat.sizeKnown,
}, { allowSourceGrowth });
const classified = classifyResumeSourceValidationError(validationError);
if (classified.kind === "restart") {
@@ -1217,8 +1245,7 @@ async function resumeDirectoryWithDedicatedSession(
sourceLastModified: sourceStat.lastModified,
};
} else if (classified.kind === "modified") {
attentionCount += 1;
options?.onChildUpdate?.({
publishChild({
...childBase,
status: "attention",
error: classified.message || validationError || "Source was modified",
@@ -1227,6 +1254,7 @@ async function resumeDirectoryWithDedicatedSession(
phase: undefined,
retryable: true,
});
attentionCount += 1;
return;
} else if (classified.kind === "fatal") {
throw new Error(classified.message || validationError || "Resume validation failed");
@@ -1236,56 +1264,71 @@ async function resumeDirectoryWithDedicatedSession(
// zero-byte plans (`||` would incorrectly promote to grown size).
const plannedBytes = Number(childBase.totalBytes);
const hasPlannedBytes = Number.isFinite(plannedBytes) && plannedBytes >= 0;
// Stat-less SCP sources report size as a placeholder 0; keep the
// planned size rather than re-planning to a fake zero.
const sourceSizeKnown = sourceStat.sizeKnown !== false && Number.isFinite(sourceStat.size);
childBase = {
...childBase,
totalBytes: allowSourceGrowth
? (hasPlannedBytes ? plannedBytes : sourceStat.size)
: (sourceStat.size || childBase.totalBytes),
sourceLastModified: allowSourceGrowth
&& hasPlannedBytes
&& sourceStat.size > plannedBytes
? (childBase.sourceLastModified ?? sourceStat.lastModified)
: (sourceStat.lastModified ?? childBase.sourceLastModified),
totalBytes: !sourceSizeKnown
? childBase.totalBytes
: allowSourceGrowth
? (hasPlannedBytes ? plannedBytes : sourceStat.size)
: (sourceStat.size || childBase.totalBytes),
sourceLastModified: !sourceSizeKnown
? (sourceStat.lastModified ?? childBase.sourceLastModified)
: allowSourceGrowth
&& hasPlannedBytes
&& sourceStat.size > plannedBytes
? (childBase.sourceLastModified ?? sourceStat.lastModified)
: (sourceStat.lastModified ?? childBase.sourceLastModified),
};
}
// Re-check abort after async stat before inserting a transferring child.
if (options?.shouldAbort?.()) throw new Error("Transfer cancelled");
options?.onChildUpdate?.(childBase);
const streamResult = await runTransferAndWaitForOwner(childBase, () => {
// Publish only after admission, while identity changes are observed.
options?.onChildUpdate?.(childBase);
return bridge.startStreamTransfer!({
transferId: childId,
// Lifecycle events must carry the current identity even while
// large-history renderer child updates remain batched.
parentTaskId: childBase.parentTaskId,
directoryEntryIndex: childBase.directoryEntryIndex,
directoryEntryIdentity: childBase.directoryEntryIdentity,
sourcePath: file.sourcePath,
targetPath: file.targetPath,
sourceType,
targetType,
sourceSftpId,
targetSftpId,
sourceHostId: endpoints.sourceHost?.id,
targetHostId: endpoints.targetHost?.id,
totalBytes: Number.isFinite(childBase.totalBytes)
? childBase.totalBytes
: (file.size || undefined),
resumable: parent.resumable !== false,
checkpointBytes: childBase.checkpointBytes ?? 0,
resumeStage: childBase.resumeStage,
downloadCheckpointBytes: childBase.downloadCheckpointBytes,
uploadCheckpointBytes: childBase.uploadCheckpointBytes,
sourceFingerprint: childBase.sourceFingerprint,
skipAdmission: true,
});
}, () => options?.shouldAbort?.() === true, pausedAtResume.get(childId),
resetPersistedCheckpoint ? completedAtRestart.get(childId) : undefined,
// Drop queued UI updates at the ownership change itself: waiting
// for this invocation's reply may allow an intervening batch flush.
onOwnerChanged,
(observation) => { childObservation = observation; return true; }, childObservation);
const streamResult = await runTransferAndWaitForOwner(childBase, () => bridge.startStreamTransfer!({
transferId: childId,
// Lifecycle events must carry the current identity even while
// large-history renderer child updates remain batched.
parentTaskId: childBase.parentTaskId,
directoryEntryIndex: childBase.directoryEntryIndex,
directoryEntryIdentity: childBase.directoryEntryIdentity,
sourcePath: file.sourcePath,
targetPath: file.targetPath,
sourceType,
targetType,
sourceSftpId,
targetSftpId,
sourceHostId: endpoints.sourceHost?.id,
targetHostId: endpoints.targetHost?.id,
totalBytes: Number.isFinite(childBase.totalBytes)
? childBase.totalBytes
: (file.size || undefined),
resumable: parent.resumable !== false,
checkpointBytes: childBase.checkpointBytes ?? 0,
resumeStage: childBase.resumeStage,
downloadCheckpointBytes: childBase.downloadCheckpointBytes,
uploadCheckpointBytes: childBase.uploadCheckpointBytes,
sourceFingerprint: childBase.sourceFingerprint,
skipAdmission: true,
}), () => options?.shouldAbort?.() === true, pausedAtResume.get(childId));
if (childObservation?.hasIdentityConflict()) throw new TransferOwnerChangedError("Transfer identity changed before child update");
if (streamResult?.error || streamResult?.cancelled) {
throw new Error(streamResult.error || "Transfer cancelled");
}
completedCount += 1;
options?.onChildUpdate?.({
publishChild({
...childBase,
status: "completed",
transferredBytes: Number.isFinite(childBase.totalBytes)
@@ -1297,13 +1340,21 @@ async function resumeDirectoryWithDedicatedSession(
reconnectRequired: false,
phase: undefined,
});
countedCompleted = true;
completedCount += 1;
bumpParentProgress(0);
} catch (error) {
if (options?.shouldAbort?.() || /cancelled|canceled/i.test(error instanceof Error ? error.message : String(error))) {
throw error instanceof Error ? error : new Error(String(error));
}
failedCount += 1;
options?.onChildUpdate?.({
// Another invocation owns this ID now. Report this walk's failure
// without replacing the winner or recreating its compacted row.
if (error instanceof TransferOwnerChangedError || childObservation?.hasIdentityConflict()) {
options?.onChildSuperseded?.(childId);
return;
}
publishChild({
...childBase,
status: "failed",
error: error instanceof Error ? error.message : String(error),
@@ -1312,6 +1363,8 @@ async function resumeDirectoryWithDedicatedSession(
reconnectRequired: false,
phase: undefined,
});
} finally {
childObservation?.dispose();
}
},
);
@@ -1322,6 +1375,10 @@ async function resumeDirectoryWithDedicatedSession(
if (result?.error) throw new Error(result.error);
// Commit deferred child outcomes before deciding success or promoting a stage.
// Async connection cleanup must not outlive provisional completion evidence.
options?.flushChildUpdates?.();
if (attentionCount > 0 && failedCount === 0 && completedCount + attentionCount >= totalFiles) {
return {
success: false,
@@ -0,0 +1,108 @@
import assert from "node:assert/strict";
import test from "node:test";
import { resolveDownloadSourceSnapshot } from "./downloadSourceSnapshot.ts";
const stat = (overrides: Partial<SftpStatResult> = {}): SftpStatResult => ({
name: "f",
size: 50_000,
type: "file",
lastModified: 0,
sizeKnown: true,
...overrides,
});
for (const listedSize of [25_000, 100_000]) {
test(`recreated file uses the current 50k size rather than listed ${listedSize}`, async () => {
const snapshot = await resolveDownloadSourceSnapshot(
async (_id, path, encoding) => {
assert.equal(path, "/tmp/f");
assert.equal(encoding, "auto");
return stat();
},
"sftp-1", "/tmp/f", "auto",
);
assert.deepEqual(snapshot, { size: 50_000, isDirectory: false });
assert.notEqual(snapshot.size, listedSize);
});
}
test("a stale file entry replaced by a directory uses directory routing", async () => {
const snapshot = await resolveDownloadSourceSnapshot(
async () => stat({ size: 0, type: "directory" }),
"sftp-1", "/tmp/x", "auto",
);
assert.deepEqual(snapshot, { size: 0, isDirectory: true });
});
test("a stale directory entry replaced by a file uses file routing", async () => {
const snapshot = await resolveDownloadSourceSnapshot(
async () => stat({ size: 12, type: "file" }),
"sftp-1", "/tmp/x", "auto",
);
assert.deepEqual(snapshot, { size: 12, isDirectory: false });
});
test("missing, failed and unsupported stats reject instead of using a stale listing", async () => {
const rejected = [
undefined,
async () => null as unknown as SftpStatResult,
async () => { throw new Error("File not found"); },
async () => stat({ type: "symlink" }),
async () => stat({ size: Number.NaN }),
async () => stat({ size: -1 }),
];
for (const candidate of rejected) {
await assert.rejects(
resolveDownloadSourceSnapshot(candidate, "sftp-1", "/tmp/f", "auto"),
/Cannot verify the current remote source before download/,
);
}
});
test("stat-less SCP file leaves sizing to the transfer instead of using listed bytes", async () => {
const snapshot = await resolveDownloadSourceSnapshot(
async () => stat({ size: 0, sizeKnown: false }),
"scp-1", "/tmp/f", "auto",
);
assert.deepEqual(snapshot, { size: undefined, isDirectory: false });
});
test("SCP links retain their resolved file or directory routing without using link size", async () => {
for (const target of ["file", "directory"] as const) {
const snapshot = await resolveDownloadSourceSnapshot(
async () => stat({ type: "symlink", size: 8 }),
"scp-1", "/tmp/link", "auto",
async (_id, parent, encoding) => {
assert.equal(parent, "/tmp");
assert.equal(encoding, "auto");
return [{ name: "link", type: "symlink", linkTarget: target, size: "8 bytes", lastModified: "" }];
},
);
assert.deepEqual(snapshot, { size: undefined, isDirectory: target === "directory" });
}
});
test("SCP link whose current target cannot be confirmed stops safely", async () => {
for (const entries of [
[],
[{ name: "link", type: "symlink" as const, linkTarget: null, size: "8 bytes", lastModified: "" }],
[{ name: "link", type: "file" as const, size: "8 bytes", lastModified: "" }],
]) {
await assert.rejects(
resolveDownloadSourceSnapshot(
async () => stat({ type: "symlink" }),
"scp-1", "/tmp/link", "auto", async () => entries,
),
/Cannot verify the current remote source before download/,
);
}
});
test("directory routing does not require a file byte count", async () => {
const snapshot = await resolveDownloadSourceSnapshot(
async () => stat({ size: Number.NaN, sizeKnown: false, type: "directory" }),
"sftp-1", "/tmp/d", "auto",
);
assert.deepEqual(snapshot, { size: 0, isDirectory: true });
});
@@ -0,0 +1,67 @@
import type { SftpFilenameEncoding } from "../../../domain/models/sftp";
import type { RemoteFile } from "../../../domain/models/workspace";
import { getFileName, getParentPath } from "./utils";
export type DownloadSourceSnapshot = {
/** Unknown for stat-less SCP files and links; the transfer reads its wire size. */
size: number | undefined;
isDirectory: boolean;
};
type StatSftp = (
sftpId: string,
path: string,
encoding?: SftpFilenameEncoding,
) => Promise<SftpStatResult>;
type ListSftp = (
sftpId: string,
path: string,
encoding?: SftpFilenameEncoding,
) => Promise<RemoteFile[]>;
const sourceUnavailable = () => new Error("Cannot verify the current remote source before download");
/** A stale listing must never supply the size or type of a new download. */
export const resolveDownloadSourceSnapshot = async (
statSftp: StatSftp | undefined,
sftpId: string,
sourcePath: string,
encoding: SftpFilenameEncoding | undefined,
listSftp?: ListSftp,
): Promise<DownloadSourceSnapshot> => {
if (!statSftp) throw sourceUnavailable();
let stat: SftpStatResult | null;
try {
stat = await statSftp(sftpId, sourcePath, encoding);
} catch (cause) {
throw new Error("Cannot verify the current remote source before download", { cause });
}
if (!stat) {
throw sourceUnavailable();
}
if (stat.type === "directory") return { size: 0, isDirectory: true };
if (stat.type === "symlink") {
// SFTP STAT follows links; legacy SCP describes the link node. Re-list its
// parent to resolve the current target kind rather than trusting the pane.
if (!listSftp) throw sourceUnavailable();
let entries: RemoteFile[];
try {
entries = await listSftp(sftpId, getParentPath(sourcePath), encoding);
} catch (cause) {
throw new Error("Cannot verify the current remote source before download", { cause });
}
const current = entries.find((entry) => entry.name === getFileName(sourcePath));
if (current?.type !== "symlink"
|| (current.linkTarget !== "file" && current.linkTarget !== "directory")) {
throw sourceUnavailable();
}
// The link's own size is never a valid file plan; SCP's wire header is.
return { size: undefined, isDirectory: current.linkTarget === "directory" };
}
if (stat.type !== "file") throw sourceUnavailable();
if (stat.sizeKnown === false) return { size: undefined, isDirectory: false };
if (!Number.isSafeInteger(stat.size) || stat.size < 0) throw sourceUnavailable();
return { size: stat.size, isDirectory: false };
};
@@ -0,0 +1,174 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { netcattyBridge } from '../../../infrastructure/services/netcattyBridge';
import { JSDOM } from 'jsdom';
import {
startSftpFileDrag, takeLocalFileDragSources, getLocalFileDragSources,
clearLocalFileDrag, clearLocalFileDragForPane, localDragPathKey, localFileDragMoveEffect,
} from './localFileDrag';
function setup(t: test.TestContext) {
const dom = new JSDOM('<!doctype html><html><body></body></html>');
const previous = Object.getOwnPropertyDescriptor(globalThis, 'window');
Object.defineProperty(globalThis, 'window', { value: dom.window, configurable: true });
const calls: { requestId: string; paths: string[] }[] = [];
const cancels: string[] = [];
const errors: string[] = [];
const remote: unknown[] = [];
const bridge = {
startLocalFileDrag: async (payload) => { calls.push(payload); return { started: true }; },
cancelLocalFileDrag: (id) => cancels.push(id),
getPathForFile: (file) => (file as unknown as { path: string }).path,
} as unknown as NetcattyBridge;
Object.defineProperty(window, "netcatty", { value: bridge, configurable: true });
t.after(() => {
clearLocalFileDrag();
dom.window.close();
if (previous) Object.defineProperty(globalThis, 'window', previous);
else Reflect.deleteProperty(globalThis, 'window');
});
const transfer = (paths: string[] = []) => ({
types: ['Files'], files: paths.map((path) => ({ path })),
effectAllowed: '', setData: (...args: string[]) => remote.push(args),
}) as unknown as DataTransfer;
const sources = [
{ name: 'certificate.txt', sourcePath: '/local', sourceConnectionId: 'local', isDirectory: false },
{ name: 'folder', sourcePath: '/another path', sourceConnectionId: 'local', isDirectory: true },
];
let prevented = 0;
const start = (isLocal: boolean | undefined = true, selection = sources) => startSftpFileDrag({
event: { dataTransfer: transfer(), preventDefault: () => { prevented++; } },
paneId: 'pane', connection: { id: 'local', isLocal }, sources: selection, side: 'left',
onRemoteDrag: (...args) => remote.push(args), onError: (error) => errors.push(error),
});
return { calls, cancels, errors, remote, transfer, sources, start, prevented: () => prevented, dom };
}
test('local multi-selection retains individual tree paths and never exports plain text', async (t) => {
const h = setup(t);
h.start();
assert.equal(h.prevented(), 1);
assert.equal(h.remote.length, 0);
assert.deepEqual(h.calls[0].paths, ['/local/certificate.txt', '/another path/folder']);
const payload = h.transfer([...h.calls[0].paths].reverse());
assert.equal(getLocalFileDragSources(payload)?.length, 2);
assert.deepEqual(takeLocalFileDragSources(payload), h.sources.map((source) => ({ ...source, side: 'left' })));
assert.equal(takeLocalFileDragSources(payload), null, 'a native gesture is consumed exactly once');
assert.equal(getLocalFileDragSources(payload), null);
});
test('native paths retain symlink-parent segments and match the returning internal drop', (t) => {
const h = setup(t);
const sources = [{ ...h.sources[0], sourcePath: '/local/link/.././folder' }];
h.start(true, sources);
assert.deepEqual(h.calls[0].paths, ['/local/link/.././folder/certificate.txt']);
const payload = h.transfer(h.calls[0].paths);
assert.deepEqual(takeLocalFileDragSources(payload), sources.map(source => ({ ...source, side: 'left' })));
assert.equal(takeLocalFileDragSources(payload), null);
});
test('remote origin preserves HTML payload and callbacks, including absent isLocal', (t) => {
const h = setup(t);
h.start(false);
assert.equal(h.prevented(), 0);
assert.equal(h.calls.length, 0);
assert.deepEqual(h.remote, [['text/plain', 'certificate.txt\nfolder'], [h.sources, 'left']]);
startSftpFileDrag({ event: { dataTransfer: h.transfer(), preventDefault: () => assert.fail('remote drag canceled') },
paneId: 'remote', connection: { id: 'remote' }, sources: h.sources, side: 'right',
onRemoteDrag: () => {}, onError: assert.fail });
assert.equal(h.calls.length, 0);
});
test('unrelated external files cannot reuse a previous local gesture', (t) => {
const h = setup(t);
h.start();
assert.equal(takeLocalFileDragSources(h.transfer(['/different/certificate.txt'])), null);
assert.equal(getLocalFileDragSources(h.transfer()), null);
h.start();
assert.equal(takeLocalFileDragSources(h.transfer(['/local/certificate.txt'])), null, 'partial selection is not an internal transfer');
});
test('local failure cancels without text fallback and stale async errors cannot clear a newer gesture', async (t) => {
const h = setup(t);
let reject!: (error: Error) => void;
netcattyBridge.require().startLocalFileDrag = () => new Promise((_resolve, rejectPromise) => { reject = rejectPromise; });
h.start();
const oldReject = reject;
h.start();
oldReject(new Error('obsolete'));
await new Promise((resolve) => setImmediate(resolve));
assert.equal(h.errors.length, 0);
assert.ok(getLocalFileDragSources(h.transfer()));
reject(new Error('file disappeared'));
await new Promise((resolve) => setImmediate(resolve));
assert.deepEqual(h.errors, ['file disappeared']);
assert.equal(getLocalFileDragSources(h.transfer()), null);
assert.equal(h.remote.length, 0);
});
test('native state is cleared on physical cancellation, external completion, connection disposal and new gestures', async (t) => {
const h = setup(t);
for (const event of [new h.dom.window.KeyboardEvent('keydown', { key: 'Escape' }),
new h.dom.window.MouseEvent('mousemove', { buttons: 0 }), new h.dom.window.MouseEvent('mousedown'),
new h.dom.window.Event('blur'), new h.dom.window.Event('pagehide')]) {
h.start();
await Promise.resolve();
window.dispatchEvent(event);
assert.equal(getLocalFileDragSources(h.transfer()), null, event.type);
}
h.start();
clearLocalFileDragForPane('another-pane');
assert.ok(getLocalFileDragSources(h.transfer()));
clearLocalFileDragForPane('pane');
assert.equal(getLocalFileDragSources(h.transfer()), null);
h.start();
h.start(false);
assert.equal(getLocalFileDragSources(h.transfer()), null);
});
test('invalid local selections and missing bridge are rejected; parent entry is excluded', (t) => {
const h = setup(t);
h.start(true, [{ ...h.sources[0], name: '..' }]);
assert.equal(h.calls.length, 0);
h.start(true, [{ ...h.sources[0], sourceConnectionId: 'remote' }]);
assert.equal(h.calls.length, 0);
h.start(true, [h.sources[0], { ...h.sources[0], name: '..' }]);
assert.deepEqual(h.calls[0].paths, ['/local/certificate.txt']);
netcattyBridge.require().startLocalFileDrag = undefined;
h.start();
assert.equal(h.errors.length, 3);
assert.equal(h.remote.length, 0);
});
test('path matching preserves POSIX whitespace, Unicode and backslashes, and accepts Windows separators', () => {
assert.equal(localDragPathKey('C:\\Users\\david\\certificate.pem'), 'C:/Users/david/certificate.pem');
assert.equal(localDragPathKey('\\\\server\\share\\folder'), '//server/share/folder');
assert.equal(localDragPathKey('/local/é \\name '), '/local/é \\name ');
assert.notEqual(localDragPathKey('/local/A'), localDragPathKey('/local/a'));
});
test('local move accepts the copy-only native transport on Windows/Linux without changing the operation', () => {
assert.equal(localFileDragMoveEffect({ effectAllowed: 'copyLink' }), 'copy');
assert.equal(localFileDragMoveEffect({ effectAllowed: 'copy' }), 'copy');
assert.equal(localFileDragMoveEffect({ effectAllowed: 'all' }), 'move');
assert.equal(localFileDragMoveEffect({ effectAllowed: 'copyMove' }), 'move');
});
test('native drop cleanup survives the browser microtask checkpoint before React receives the drop', async (t) => {
const h = setup(t);
h.start();
window.dispatchEvent(new h.dom.window.Event('drop'));
// Unlike dispatchEvent in jsdom, native browser events can drain microtasks
// after the window capture callback, before the React root bubble callback.
await Promise.resolve();
assert.ok(getLocalFileDragSources(h.transfer()), 'capture must not erase native identity before React handles drop');
assert.deepEqual(takeLocalFileDragSources(h.transfer(h.calls[0].paths)), h.sources.map(source => ({ ...source, side: 'left' })));
await new Promise(resolve => setTimeout(resolve, 5));
assert.equal(getLocalFileDragSources(h.transfer()), null);
h.start();
window.dispatchEvent(new h.dom.window.Event('drop'));
await new Promise(resolve => setTimeout(resolve, 5));
assert.equal(getLocalFileDragSources(h.transfer()), null, 'unhandled drops also release the gesture');
});
+177
View File
@@ -0,0 +1,177 @@
import { netcattyBridge } from "../../../infrastructure/services/netcattyBridge";
import { joinPath } from "./utils";
export interface LocalDragSource {
name: string;
isDirectory: boolean;
sourcePath?: string;
sourceConnectionId?: string;
targetPath?: string;
side: "left" | "right";
}
interface DragSession {
requestId: string;
paneId: string;
connectionId: string;
paths: string[];
sources: LocalDragSource[];
started?: boolean;
}
// Windows separators can differ between the SFTP UI and Electron webUtils.
// POSIX backslashes, whitespace, and case must remain significant.
export function localDragPathKey(value: string): string {
if (/^[A-Za-z]:[\\/]/.test(value) || /^\\\\/.test(value)) {
return value.replace(/\\/g, "/").replace(/\/$/, "");
}
return value.length > 1 ? value.replace(/\/$/, "") : value;
}
class LocalFileDragState {
session: DragSession | null = null;
clear(requestId?: string) {
if (!requestId || this.session?.requestId === requestId) this.session = null;
}
take(paths: string[]): LocalDragSource[] | null {
const session = this.session;
this.clear();
if (!session || paths.length === 0 || paths.some((path) => !path)) return null;
const expected = new Set(session.paths.map(localDragPathKey));
const actual = new Set(paths.map(localDragPathKey));
if (expected.size !== actual.size || [...expected].some((path) => !actual.has(path))) return null;
return session.sources;
}
}
const state = new LocalFileDragState();
let detach: (() => void) | undefined;
export function clearLocalFileDrag(requestId?: string) {
if (requestId && state.session?.requestId !== requestId) return;
const previous = state.session;
state.clear();
detach?.();
detach = undefined;
if (previous) netcattyBridge.get()?.cancelLocalFileDrag?.(previous.requestId);
}
export function clearLocalFileDragForPane(paneId: string) {
if (state.session?.paneId === paneId) clearLocalFileDrag();
}
function observeGestureEnd() {
let dropCleanupTimer: ReturnType<typeof setTimeout> | undefined;
// Electron has no cross-platform native-drag completion callback. A canceled
// HTML drag's dragend is not a native completion signal. Clear on drop and
// on the next physical input/focus transition, including Escape and returning
// after a drop outside the window. Never retain state into a new gesture.
const clear = () => clearLocalFileDrag();
const key = (event: KeyboardEvent) => { if (event.key === "Escape") clear(); };
const move = (event: MouseEvent) => { if (state.session?.started && event.buttons === 0) clear(); };
const drop = () => {
const requestId = state.session?.requestId;
// Native event dispatch can run microtasks BETWEEN capture and React's
// bubble listener. Use a macrotask so the row/pane can consume the gesture.
dropCleanupTimer = setTimeout(() => { if (requestId) clearLocalFileDrag(requestId); }, 0);
};
// preventDefault() on dragstart produces a synthetic mouseup in Chromium.
// It must not cancel the native handoff. Observe later input instead.
window.addEventListener("mousedown", clear, true);
window.addEventListener("mousemove", move, true);
window.addEventListener("keydown", key, true);
window.addEventListener("blur", clear);
window.addEventListener("pagehide", clear);
window.addEventListener("drop", drop, true);
detach = () => {
if (dropCleanupTimer !== undefined) clearTimeout(dropCleanupTimer);
window.removeEventListener("mousedown", clear, true);
window.removeEventListener("mousemove", move, true);
window.removeEventListener("keydown", key, true);
window.removeEventListener("blur", clear);
window.removeEventListener("pagehide", clear);
window.removeEventListener("drop", drop, true);
};
}
type DragEventLike = Pick<DragEvent, "dataTransfer" | "preventDefault">;
export function startSftpFileDrag({ event, paneId, connection, sources, side, onRemoteDrag, onError }: {
event: DragEventLike;
paneId: string;
connection?: { id: string; isLocal?: boolean } | null;
sources: Omit<LocalDragSource, "side">[];
side: "left" | "right";
onRemoteDrag: (sources: Omit<LocalDragSource, "side">[], side: "left" | "right") => void;
onError: (message: string) => void;
}) {
clearLocalFileDrag();
if (connection?.isLocal !== true) {
event.dataTransfer!.effectAllowed = "copyMove";
event.dataTransfer!.setData("text/plain", sources.map((file) => file.name).join("\n"));
onRemoteDrag(sources, side);
return;
}
event.preventDefault();
const bridge = netcattyBridge.get();
if (!bridge?.startLocalFileDrag) {
onError("Native file drag is unavailable. Restart Netcatty after updating.");
return;
}
const files = sources.filter((source) => source.name !== "..");
if (!files.length || files.some((file) => !file.sourcePath || file.sourceConnectionId !== connection.id)) {
onError("The local file selection is no longer available.");
return;
}
const requestId = crypto.randomUUID();
const paths = [...new Set(files.map((file) => joinPath(file.sourcePath!, file.name)))];
state.session = { requestId, paneId, connectionId: connection.id, paths, sources: files.map((file) => ({ ...file, side })) };
observeGestureEnd();
// Send during dragstart, without awaiting in the renderer. Do not interpret
// the reply as a completed transfer or use it to clear an active gesture.
try {
void bridge.startLocalFileDrag({ requestId, paths }).then((result) => {
if (result.started && state.session?.requestId === requestId) state.session.started = true;
if (!result.started && state.session?.requestId === requestId) {
clearLocalFileDrag(requestId);
if (result.error) onError(result.error);
}
}).catch((error: unknown) => {
if (state.session?.requestId !== requestId) return;
clearLocalFileDrag(requestId);
onError(error instanceof Error ? error.message : "Unable to drag local files");
});
} catch (error) {
clearLocalFileDrag(requestId);
onError(error instanceof Error ? error.message : "Unable to drag local files");
}
}
export function getLocalFileDragSources(dataTransfer: DataTransfer): LocalDragSource[] | null {
return dataTransfer.types.includes("Files") ? state.session?.sources ?? null : null;
}
export function localFileDragMoveEffect(dataTransfer: Pick<DataTransfer, "effectAllowed">): "copy" | "move" {
// Electron's Windows/Linux native source advertises COPY | LINK, not MOVE.
// Negotiate an allowed transport effect; our existing same-pane callback is
// responsible for the actual local move (the OS must not delete originals).
return ["all", "copyMove", "linkMove", "move"].includes(dataTransfer.effectAllowed) ? "move" : "copy";
}
export function takeLocalFileDragSources(dataTransfer: DataTransfer): LocalDragSource[] | null {
if (!state.session) return null;
const bridge = netcattyBridge.get();
const session = state.session;
try {
return state.take(Array.from(dataTransfer.files).map((file) => bridge?.getPathForFile?.(file) ?? ""));
} catch {
state.clear();
return null;
} finally {
detach?.();
detach = undefined;
bridge?.cancelLocalFileDrag?.(session.requestId);
}
}
@@ -334,7 +334,8 @@ test("live directory download rejects a Windows backslash traversal entry", asyn
}
});
test("live folder settles a superseded child whose completed row was compacted", async () => {
for (const ownerChange of ["same", "changed-active", "changed-completed"] as const) {
test(`live folder settles superseded ownership without clobbering its replacement: ${ownerChange}`, async () => {
const { sftpTransferCenterStore } = await import("../sftpTransferCenterStore");
const previousWindow = (globalThis as { window?: unknown }).window;
const previousLocalStorage = Object.getOwnPropertyDescriptor(globalThis, "localStorage");
@@ -355,10 +356,18 @@ test("live folder settles a superseded child whose completed row was compacted",
startStreamTransfer: async (options: { transferId: string }) => {
childId = options.transferId;
sftpTransferCenterStore.publishOwner("live-compacted-owner", tasks);
// The winning invocation has already completed; history compaction drops its row.
sftpTransferCenterStore.ingestBackgroundEvent({
type: "completed", transferId: childId, transferred: 1, totalBytes: 1, lifecycleEpoch: 0,
});
if (ownerChange !== "same") {
const current = sftpTransferCenterStore.getTask(childId)!;
sftpTransferCenterStore.admitTaskRun({ ...current, directoryEntryIdentity: "b".repeat(64) });
}
if (ownerChange !== "changed-active") {
// The winning invocation completed; history compaction drops its row.
sftpTransferCenterStore.ingestBackgroundEvent({
type: "completed", transferId: childId, transferred: 1, totalBytes: 1, lifecycleEpoch: 0,
});
}
tasks = [...sftpTransferCenterStore.getSnapshot().tasks];
transfersRef.current = tasks;
return { superseded: true };
},
} };
@@ -387,10 +396,15 @@ test("live folder settles a superseded child whose completed row was compacted",
new Promise<"still-waiting">((resolve) => setTimeout(() => resolve("still-waiting"), 450)),
]);
assert.ok(childId);
assert.equal(sftpTransferCenterStore.getTask(childId), undefined);
assert.equal(sftpTransferCenterStore.getTask(root.id)?.directoryResumeCheckpoint?.completedEntries, 1);
if (ownerChange === "changed-active") {
assert.equal(tasks.find((task) => task.id === childId)?.status, "transferring");
assert.equal(tasks.find((task) => task.id === childId)?.directoryEntryIdentity, "b".repeat(64));
} else {
assert.equal(tasks.find((task) => task.id === childId), undefined, "old waiter must not reinsert a compacted child");
assert.equal(sftpTransferCenterStore.getTask(root.id)?.directoryResumeCheckpoint?.completedEntries, 1);
}
assert.notEqual(result, "still-waiting", "compacted completion must settle the live folder");
assert.equal(result, 0);
assert.equal(result, ownerChange === "same" ? 0 : 1);
} finally {
cancelledTasksRef.current.add(root.id);
await running?.catch(() => {});
@@ -404,3 +418,4 @@ test("live folder settles a superseded child whose completed row was compacted",
}
});
}
@@ -1,5 +1,5 @@
import { reconcileSupersededControls } from "./globalSftpTransferControl";
import { runTransferAndWaitForOwner } from "./waitForTransferOwner";
import { runTransferAndWaitForOwner, TransferOwnerChangedError } from "./waitForTransferOwner";
import { useCallback, type Dispatch, type MutableRefObject, type SetStateAction } from "react";
import type { Host, SftpFileEntry, SftpFilenameEncoding, TransferStatus, TransferTask } from "../../../domain/models";
import {
@@ -118,6 +118,10 @@ async function tryStatTransferPath(
if (!sftpId) return null;
const stat = await netcattyBridge.get()?.statSftp?.(sftpId, filePath, encoding);
if (!stat || stat.type === "directory") return null;
// Stat-less SCP endpoints report size as a placeholder 0 (sizeKnown false).
// Treat that as missing metadata so skip-unchanged is disabled rather than
// comparing a fake zero against the other endpoint (Codex P1).
if (stat.sizeKnown === false) return null;
return { size: Number(stat.size) || 0, lastModified: Number(stat.lastModified) || 0, type: stat.type };
} catch {
return null;
@@ -978,6 +982,10 @@ export function useSftpDirectoryTransferOps({
errors.push(err instanceof Error ? err : new Error(message));
return;
}
if (err instanceof TransferOwnerChangedError) {
errors.push(err);
return;
}
// Mark child as failed
setTransfers((prev) =>
prev.map((t) =>
@@ -169,3 +169,284 @@ test("fresh directory recovery authorizes only an unchanged retained pause under
store.patchTask("root", { status: "paused", lifecycleEpoch: 6 });
assert.equal(store.admitTaskRun(task, currentPause), "paused", "parent pause still wins");
});
for (const compactNewOwner of [false, true]) {
test(`superseded waiter rejects a changed identity even after new owner compaction: ${compactNewOwner}`, async (t) => {
const { sftpTransferCenterStore: store } = await import("../sftpTransferCenterStore");
const { runTransferAndWaitForOwner } = await import("./waitForTransferOwner");
const task = { ...child(), id: `changed-owner-${compactNewOwner}`, parentTaskId: `changed-root-${compactNewOwner}` };
store.upsertTasks([{ ...task, id: task.parentTaskId, parentTaskId: undefined, isDirectory: true }, task]);
let abort = false;
const running = runTransferAndWaitForOwner(task, async () => {
const changed = { ...task, directoryEntryIdentity: "b".repeat(64) };
assert.equal(store.admitTaskRun(changed), "ready");
if (compactNewOwner) {
store.patchTask(changed.id, { status: "completed" });
assert.equal(store.getTask(changed.id), undefined);
}
return { superseded: true };
}, () => abort);
const settled = running.then(() => "completed", (error: Error) => error.message);
t.after(async () => {
abort = true;
await settled;
store.patchTask(task.parentTaskId, { status: "completed" });
store.dismiss(task.parentTaskId);
});
assert.match(String(await Promise.race([
settled, new Promise((resolve) => setTimeout(() => resolve("still-waiting"), 450)),
])), /identity changed/i);
});
}
test("destination rebuild restarts only the captured completion and preserves newer controls", async (t) => {
const { latchTransferPause, resetTransferPauseLatchesForTests } = await import("./transferPauseLatch");
const store = createSftpTransferCenterStore();
const task = { ...child(), parentTaskId: undefined, status: "completed" as const, checkpointBytes: 10 };
store.upsertTasks([task]);
const captured = store.getTask(task.id)!;
const restart = { ...task, status: "transferring" as const, checkpointBytes: 0, transferredBytes: 0 };
latchTransferPause(task.id);
t.after(resetTransferPauseLatchesForTests);
assert.equal(store.admitTaskRun(restart, undefined, captured), "paused");
resetTransferPauseLatchesForTests();
assert.equal(store.admitTaskRun(restart, undefined, captured), "ready");
assert.equal(store.getTask(task.id)?.checkpointBytes, 0);
store.patchTask(task.id, { status: "completed" });
assert.equal(store.admitTaskRun(restart, undefined, captured), "completed", "new completion must not be restarted");
store.patchTask(task.id, { status: "cancelled" });
assert.equal(store.admitTaskRun(restart, undefined, captured), "cancelled");
});
test("destination rebuild waiting on pause reuses a newer compacted completion", async (t) => {
const { sftpTransferCenterStore: store } = await import("../sftpTransferCenterStore");
const { runTransferAndWaitForOwner } = await import("./waitForTransferOwner");
const { latchTransferPause, resetTransferPauseLatchesForTests } = await import("./transferPauseLatch");
const task = { ...child(), id: "restart-wait-child", parentTaskId: "restart-wait-root", directoryEntryIndex: 1 };
const root = { ...task, id: task.parentTaskId, parentTaskId: undefined, isDirectory: true };
store.upsertTasks([root, { ...task, status: "completed" }]);
const captured = store.getTask(task.id)!;
latchTransferPause(task.id);
let starts = 0;
let abort = false;
const running = runTransferAndWaitForOwner(task, async () => { starts += 1; return {}; }, () => abort, undefined, captured);
const settled = running.then(() => "completed", (error: Error) => error.message);
t.after(async () => {
abort = true;
resetTransferPauseLatchesForTests();
await settled;
store.patchTask(root.id, { status: "completed" });
store.dismiss(root.id);
});
// An unrelated lifecycle emit must not pin the old completion in the observation.
store.patchTask(root.id, { reconnectRequired: true });
assert.equal(await Promise.race([
settled, new Promise((resolve) => setTimeout(() => resolve("still-waiting"), 230)),
]), "still-waiting", "old completion cannot finish recovery while a new transfer is still needed");
assert.equal(starts, 0);
resetTransferPauseLatchesForTests();
store.patchTask(task.id, { status: "transferring", lifecycleEpoch: 1 });
store.upsertTasks([{ ...task, id: "restart-wait-first", directoryEntryIndex: 0, status: "completed" }]);
store.patchTask(task.id, { status: "completed", lifecycleEpoch: 2 });
assert.equal(store.getTask(task.id), undefined);
assert.equal(await settled, "completed");
assert.equal(starts, 0, "new completion after stage rebuild must be reused even when its row was compacted");
});
test("paused admission cannot reclaim an identity replaced while it waited", async (t) => {
const { sftpTransferCenterStore: store } = await import("../sftpTransferCenterStore");
const { runTransferAndWaitForOwner } = await import("./waitForTransferOwner");
const task = { ...child(), id: "paused-displaced", parentTaskId: undefined };
store.upsertTasks([{ ...task, status: "paused" }]);
let starts = 0;
let abort = false;
const running = runTransferAndWaitForOwner(task, async () => { starts += 1; return {}; }, () => abort);
const settled = running.then(() => "completed", (error: Error) => error.message);
t.after(async () => {
abort = true;
await settled;
store.patchTask(task.id, { status: "completed" });
store.dismiss(task.id);
});
store.upsertTasks([{ ...task, status: "transferring", directoryEntryIdentity: "b".repeat(64), lifecycleEpoch: 1 }]);
assert.match(await settled, /identity changed/i);
assert.equal(starts, 0);
assert.equal(store.getTask(task.id)?.directoryEntryIdentity, "b".repeat(64));
});
for (const identityChangesDuringWait of [false, true]) {
test(`paused recovery distinguishes initial stale identity from later ownership: changed=${identityChangesDuringWait}`, async (t) => {
const { sftpTransferCenterStore: store } = await import("../sftpTransferCenterStore");
const { runTransferAndWaitForOwner } = await import("./waitForTransferOwner");
const { latchTransferPause, resetTransferPauseLatchesForTests } = await import("./transferPauseLatch");
const task = { ...child(), id: `paused-origin-${identityChangesDuringWait}`, parentTaskId: `paused-origin-root-${identityChangesDuringWait}` };
const root = { ...task, id: task.parentTaskId, parentTaskId: undefined, isDirectory: true };
store.upsertTasks([root, {
...task, status: identityChangesDuringWait ? "failed" : "paused",
directoryEntryIdentity: identityChangesDuringWait ? task.directoryEntryIdentity : "b".repeat(64),
}]);
const initialPause = store.getTask(task.id)!;
latchTransferPause(task.id);
let starts = 0;
let abort = false;
let conflicts = 0;
const running = runTransferAndWaitForOwner(task, async () => { starts += 1; return {}; }, () => abort,
identityChangesDuringWait ? undefined : initialPause, undefined, () => { conflicts += 1; });
const settled = running.then(() => "completed", (error: Error) => error.message);
t.after(async () => {
abort = true;
resetTransferPauseLatchesForTests();
await settled;
store.patchTask(root.id, { status: "completed" });
store.dismiss(root.id);
});
// This captures the prior failed result, or republishes the unchanged
// initial old identity. Neither is evidence of a replacement by itself.
store.patchTask(root.id, { reconnectRequired: true });
resetTransferPauseLatchesForTests();
if (identityChangesDuringWait) {
store.patchTask(task.id, { status: "transferring", directoryEntryIdentity: "b".repeat(64), lifecycleEpoch: 1 });
}
const outcome = await settled;
if (identityChangesDuringWait) {
assert.match(outcome, /identity changed/i);
assert.equal(starts, 0);
assert.equal(conflicts, 1);
assert.equal(store.getTask(task.id)?.directoryEntryIdentity, "b".repeat(64));
} else {
assert.equal(outcome, "completed");
assert.equal(starts, 1);
assert.equal(conflicts, 0);
}
});
}
test("paused retry observes newer same-identity completion after an old failure", async (t) => {
const { sftpTransferCenterStore: store } = await import("../sftpTransferCenterStore");
const { runTransferAndWaitForOwner } = await import("./waitForTransferOwner");
const { latchTransferPause, resetTransferPauseLatchesForTests } = await import("./transferPauseLatch");
const task = { ...child(), id: "failed-then-completed", parentTaskId: "failed-then-completed-root" };
const root = { ...task, id: task.parentTaskId, parentTaskId: undefined, isDirectory: true };
store.upsertTasks([root, { ...task, status: "failed" }]);
latchTransferPause(task.id);
let starts = 0;
let abort = false;
const running = runTransferAndWaitForOwner(task, async () => { starts += 1; return {}; }, () => abort);
const settled = running.then(() => "completed", (error: Error) => error.message);
t.after(async () => {
abort = true;
resetTransferPauseLatchesForTests();
await settled;
store.patchTask(root.id, { status: "completed" });
store.dismiss(root.id);
});
store.patchTask(root.id, { reconnectRequired: true });
resetTransferPauseLatchesForTests();
store.patchTask(task.id, { status: "transferring", lifecycleEpoch: 1 });
store.patchTask(task.id, { status: "completed", lifecycleEpoch: 2 });
assert.equal(store.getTask(task.id), undefined);
assert.equal(await settled, "completed");
assert.equal(starts, 0, "a newer completed attempt must replace the captured old failure");
});
test("an unchanged owner retains its original transport rejection", async (t) => {
const { sftpTransferCenterStore: store } = await import("../sftpTransferCenterStore");
const { runTransferAndWaitForOwner } = await import("./waitForTransferOwner");
const task = { ...child(), id: "unchanged-rejected-owner", parentTaskId: undefined };
store.upsertTasks([task]);
t.after(() => { store.patchTask(task.id, { status: "completed" }); store.dismiss(task.id); });
const error = new Error("original transport failure");
await assert.rejects(runTransferAndWaitForOwner(task, async () => { throw error; }, () => false),
(actual) => actual === error);
});
for (const activation of ["patch", "progress", "owner"] as const) {
test(`initial stale identity exemption ends when another owner activates that row: ${activation}`, async (t) => {
const { sftpTransferCenterStore: store } = await import("../sftpTransferCenterStore");
const { runTransferAndWaitForOwner } = await import("./waitForTransferOwner");
const { latchTransferPause, resetTransferPauseLatchesForTests } = await import("./transferPauseLatch");
const task = { ...child(), id: "activated-initial-identity", parentTaskId: undefined };
store.upsertTasks([{ ...task, status: "paused", directoryEntryIdentity: "b".repeat(64), lifecycleEpoch: 1 }]);
const initial = store.getTask(task.id)!;
latchTransferPause(task.id);
let starts = 0;
let abort = false;
const running = runTransferAndWaitForOwner(task, async () => { starts += 1; return {}; }, () => abort, initial);
const settled = running.then(() => "completed", (error: Error) => error.message);
t.after(async () => {
abort = true;
resetTransferPauseLatchesForTests();
await settled;
store.patchTask(task.id, { status: "completed" });
store.dismiss(task.id);
});
resetTransferPauseLatchesForTests();
if (activation === "progress") {
store.ingestBackgroundEvent({ type: "progress", transferId: task.id,
transferred: 1, lifecycleEpoch: 2, lifecycleState: "transferring" });
} else if (activation === "owner") store.patchTask(task.id, { ownerId: "replacement-owner" });
else store.patchTask(task.id, { status: "transferring", lifecycleEpoch: 2 });
assert.match(String(await Promise.race([
settled, new Promise((resolve) => setTimeout(() => resolve("still-waiting"), 450)),
])), /identity changed/i);
assert.equal(starts, 0);
assert.equal(store.getTask(task.id)?.directoryEntryIdentity, "b".repeat(64));
});
}
test("already active different identity is rejected before first admission", async (t) => {
const { sftpTransferCenterStore: store } = await import("../sftpTransferCenterStore");
const { runTransferAndWaitForOwner } = await import("./waitForTransferOwner");
const task = { ...child(), id: "already-active-identity", parentTaskId: undefined };
store.upsertTasks([{ ...task, directoryEntryIdentity: "b".repeat(64), lifecycleEpoch: 1 }]);
t.after(() => { store.patchTask(task.id, { status: "completed" }); store.dismiss(task.id); });
let starts = 0;
let conflicts = 0;
await assert.rejects(runTransferAndWaitForOwner(task, async () => { starts += 1; return {}; }, () => false,
undefined, undefined, () => { conflicts += 1; }), /identity changed/i);
assert.equal(starts, 0);
assert.equal(conflicts, 1);
assert.equal(store.getTask(task.id)?.directoryEntryIdentity, "b".repeat(64));
});
for (const replacementStatus of ["transferring", "completed"] as const) {
for (const oldStatus of ["completed", "failed"] as const) {
test(`deferred ${oldStatus} cannot overwrite a ${replacementStatus} replacement after waiter exit`, async () => {
const { sftpTransferCenterStore: store } = await import("../sftpTransferCenterStore");
const { runTransferAndWaitForOwner } = await import("./waitForTransferOwner");
const { createDedicatedResumeChildUpdateBatcher } = await import("../../app/dedicatedResumeProgress");
const task = child();
const root = { ...task, id: task.parentTaskId!, parentTaskId: undefined, isDirectory: true };
const batcher = createDedicatedResumeChildUpdateBatcher({
getTaskCount: () => 4096, hasTask: () => true,
upsertTasks: (updates) => store.upsertTasks(updates),
});
store.upsertTasks([root, task]);
let observation: ReturnType<typeof store.observeTaskSettlement> | undefined;
try {
await runTransferAndWaitForOwner(task, async () => {
store.patchTask(task.id, { status: oldStatus });
return {};
}, () => false, undefined, undefined, () => batcher.discard(task.id), (value) => {
observation = value;
return true;
});
assert.ok(observation);
batcher.push({ ...task, status: oldStatus }, observation);
store.upsertTasks([{ ...task, directoryEntryIdentity: "b".repeat(64), status: replacementStatus }]);
const replacement = store.getTask(task.id);
batcher.flush();
assert.equal(store.getTask(task.id), replacement);
if (replacementStatus === "transferring") assert.equal(replacement?.directoryEntryIdentity, "b".repeat(64));
else assert.equal(replacement, undefined, "the replacement remains compacted");
assert.equal(observation.read(), undefined, "the retained observation is disposed");
} finally {
batcher.flush();
observation?.dispose();
store.patchTask(root.id, { status: "completed" });
store.dismiss(root.id);
}
});
}
}
@@ -0,0 +1,60 @@
import assert from 'node:assert/strict';
import test, { after } from 'node:test';
import React from 'react';
import { act, create, type ReactTestRenderer } from 'react-test-renderer';
import { publishTerminalCommandCompletion } from '../terminalCommandCompletion';
import { useSftpCommandRefresh } from './useSftpCommandRefresh';
const environment = globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean };
const previous = environment.IS_REACT_ACT_ENVIRONMENT;
environment.IS_REACT_ACT_ENVIRONMENT = true;
after(() => { environment.IS_REACT_ACT_ENVIRONMENT = previous; });
test('completion refresh is coalesced, visible, idle and bound to its original pane', async (t) => {
t.mock.timers.enable({ apis: ['setTimeout'] });
let count = 0;
let options = {
sessionId: 'terminal-1', enabled: true, visible: true, busy: false,
matchesTerminal: true, paneId: 'pane-1', connectionId: 'connection-1',
path: '/browsed', connected: true, refresh: async () => { count += 1; },
};
function Probe() { useSftpCommandRefresh(options); return null; }
let renderer: ReactTestRenderer;
await act(async () => { renderer = create(React.createElement(Probe)); });
const update = async (patch: Partial<typeof options>) => {
options = { ...options, ...patch };
await act(async () => { renderer.update(React.createElement(Probe)); });
};
const finish = async () => { await act(async () => { t.mock.timers.tick(250); }); };
publishTerminalCommandCompletion('another-terminal');
await finish();
assert.equal(count, 0);
publishTerminalCommandCompletion('terminal-1');
publishTerminalCommandCompletion('terminal-1');
await finish();
assert.equal(count, 1);
for (const patch of [{ enabled: false }, { visible: false }, { connected: false }, { matchesTerminal: false }]) {
await update(patch);
publishTerminalCommandCompletion('terminal-1');
await finish();
assert.equal(count, 1);
await update({ enabled: true, visible: true, busy: false, connected: true, matchesTerminal: true });
}
await update({ busy: true });
publishTerminalCommandCompletion('terminal-1');
await finish();
assert.equal(count, 1);
await update({ busy: false });
await finish();
assert.equal(count, 2);
for (const patch of [{ path: '/new' }, { paneId: 'pane-2' }, { connectionId: 'connection-2' }, { sessionId: 'terminal-2' }]) {
publishTerminalCommandCompletion(options.sessionId);
await update(patch);
await finish();
assert.equal(count, 2);
}
publishTerminalCommandCompletion(options.sessionId);
await act(async () => { renderer.unmount(); });
await finish();
assert.equal(count, 2);
});
@@ -0,0 +1,56 @@
import { useEffect, useRef } from 'react';
import { subscribeTerminalCommandCompletion } from '../terminalCommandCompletion';
interface Options {
sessionId: string | null;
enabled: boolean;
visible: boolean;
busy: boolean;
matchesTerminal: boolean;
paneId: string;
connectionId: string | null;
path: string | null;
connected: boolean;
refresh: () => Promise<void>;
}
/** Coalesce completion bursts; never poll or carry refreshes across pane changes. */
export function useSftpCommandRefresh(options: Options): void {
const latest = useRef(options);
latest.current = options;
const drain = useRef(() => {});
useEffect(() => {
if (!options.enabled || !options.visible || !options.sessionId) return;
let timer: ReturnType<typeof setTimeout> | undefined;
let pending = false;
let refreshing = false;
let disposed = false;
const eligible = () => {
const live = latest.current;
return !disposed && live.enabled && live.visible && live.connected && live.matchesTerminal
&& live.sessionId === options.sessionId && live.paneId === options.paneId
&& live.connectionId === options.connectionId && live.path === options.path;
};
const schedule = () => {
if (!pending || refreshing || latest.current.busy || !eligible()) return;
clearTimeout(timer);
timer = setTimeout(() => {
if (!eligible() || latest.current.busy) return;
pending = false;
refreshing = true;
void latest.current.refresh().catch(() => {}).finally(() => {
refreshing = false;
schedule();
});
}, 250);
};
drain.current = schedule;
const unsubscribe = subscribeTerminalCommandCompletion((sessionId) => {
if (sessionId !== options.sessionId || !eligible()) return;
pending = true;
schedule();
});
return () => { disposed = true; clearTimeout(timer); unsubscribe(); drain.current = () => {}; };
}, [options.enabled, options.visible, options.sessionId, options.paneId, options.connectionId, options.path]);
useEffect(() => { drain.current(); }, [options.busy]);
}
@@ -0,0 +1,51 @@
import assert from 'node:assert/strict';
import test, { after } from 'node:test';
import React from 'react';
import { act, create, type ReactTestRenderer } from 'react-test-renderer';
import { useSftpPaneActions } from './useSftpPaneActions';
import { createEmptyPane, type SftpPane } from './types';
import type { SftpFileEntry } from '../../../types';
const environment = globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean };
const previous = environment.IS_REACT_ACT_ENVIRONMENT;
environment.IS_REACT_ACT_ENVIRONMENT = true;
after(() => { environment.IS_REACT_ACT_ENVIRONMENT = previous; });
test('command refresh keeps current directory/filter/selection and drops deleted selections', async () => {
const file = (name: string): SftpFileEntry => ({ name, type: 'file', size: 1, sizeFormatted: '1 B', lastModified: 0, lastModifiedFormatted: '1970-01-01' });
let result = [file('kept'), file('new')];
const selection = new Set(['kept']);
const pane: SftpPane = { ...createEmptyPane('pane'), connection: { id: 'conn', hostId: 'host', hostLabel: 'Host', status: 'connected', currentPath: '/browsed', homeDir: '/', isLocal: true }, files: [file('kept')], selectedFiles: selection, filter: 'ke' };
const left = { current: { tabs: [pane], activeTabId: pane.id } };
const calls: string[] = [];
let actions: ReturnType<typeof useSftpPaneActions>;
const update: Parameters<typeof useSftpPaneActions>[0]['updateTab'] = (_side, id, updater) => {
left.current.tabs = left.current.tabs.map((p) => p.id === id ? updater(p) : p);
};
function Probe() {
actions = useSftpPaneActions({
hosts: [], getActivePane: () => left.current.tabs[0], updateTab: update,
updateActiveTab: (side, updater) => update(side, pane.id, updater),
leftTabsRef: left, rightTabsRef: { current: { tabs: [], activeTabId: null } },
navSeqRef: { current: { left: 0, right: 0 } }, dirCacheRef: { current: new Map() },
sftpSessionsRef: { current: new Map() }, lastConnectedHostRef: { current: { left: null, right: null } },
connectionCacheKeyMapRef: { current: new Map() }, makeCacheKey: (id, path) => id + path,
clearCacheForConnection() {}, listLocalFiles: async (path) => { calls.push(path); return result; },
listRemoteFiles: async () => [], handleSessionError() {}, releaseConnection: async () => {},
isSessionError: () => false, clearSelectionsExcept() {}, dirCacheTtlMs: 1000,
});
return null;
}
let renderer: ReactTestRenderer;
await act(async () => { renderer = create(React.createElement(Probe)); });
await actions!.refresh('left', { tabId: pane.id, preserveSelection: true });
assert.deepEqual(calls, ['/browsed']);
assert.equal(left.current.tabs[0].selectedFiles, selection);
assert.equal(left.current.tabs[0].filter, 'ke');
assert.deepEqual(left.current.tabs[0].files.map((f) => f.name), ['kept', 'new']);
result = [file('new')];
await actions!.refresh('left', { preserveSelection: true });
assert.equal(left.current.tabs[0].selectedFiles.size, 0);
assert.equal(left.current.tabs[0].connection?.currentPath, '/browsed');
await act(async () => { renderer.unmount(); });
});
+15 -6
View File
@@ -27,6 +27,13 @@ import {
/** Shared empty set for navigation resets — never mutate this. */
const EMPTY_SET = new Set<string>();
/** Retain selection identity when no selected entry disappeared. */
export function retainSftpSelection(selected: Set<string>, files: SftpFileEntry[]): Set<string> {
const names = new Set(files.map((file) => file.name));
const retained = new Set([...selected].filter((name) => names.has(name)));
return retained.size === selected.size ? selected : retained;
}
interface UseSftpPaneActionsParams {
hosts: Host[];
getActivePane: (side: "left" | "right") => SftpPane | null;
@@ -53,13 +60,14 @@ interface UseSftpPaneActionsParams {
export type SftpNavigateResult = "reached" | "failed" | "aborted" | "superseded";
export type SftpNavigateOptions = {
force?: boolean;
preserveSelection?: boolean;
tabId?: string;
shouldApply?: () => boolean;
};
interface UseSftpPaneActionsResult {
navigateTo: (side: "left" | "right", path: string, options?: SftpNavigateOptions) => Promise<SftpNavigateResult>;
refresh: (side: "left" | "right", options?: { tabId?: string }) => Promise<void>;
refresh: (side: "left" | "right", options?: { tabId?: string; preserveSelection?: boolean }) => Promise<void>;
navigateUp: (side: "left" | "right") => Promise<void>;
openEntry: (side: "left" | "right", entry: SftpFileEntry) => Promise<void>;
toggleSelection: (side: "left" | "right", fileName: string, multiSelect: boolean) => void;
@@ -179,6 +187,7 @@ export const useSftpPaneActions = ({
pane.connection.homeDir,
);
const preserveSelection = options?.preserveSelection && normalizedPath === pane.connection.currentPath;
const connectionId = pane.connection.id;
const requestId = ++navSeqRef.current[side];
const cacheKey = makeCacheKey(connectionId, normalizedPath, pane.filenameEncoding);
@@ -281,7 +290,7 @@ export const useSftpPaneActions = ({
connection: prev.connection
? { ...prev.connection, currentPath: normalizedPath }
: null,
selectedFiles: EMPTY_SET,
selectedFiles: preserveSelection ? prev.selectedFiles : EMPTY_SET,
filter: clearFilterForPathChange ? "" : prev.filter,
loading: true,
error: null,
@@ -369,7 +378,7 @@ export const useSftpPaneActions = ({
connectionId,
path: normalizedPath,
files,
selectedFiles: EMPTY_SET,
selectedFiles: preserveSelection ? retainSftpSelection(getTargetPane()?.selectedFiles ?? EMPTY_SET, files) : EMPTY_SET,
filter: nextConfirmedFilter,
});
@@ -380,7 +389,7 @@ export const useSftpPaneActions = ({
: null,
files,
loading: false,
selectedFiles: EMPTY_SET,
selectedFiles: preserveSelection ? retainSftpSelection(prev.selectedFiles, files) : EMPTY_SET,
filter: clearFilterForPathChange ? "" : prev.filter,
}));
if (!pane.connection.isLocal) {
@@ -445,7 +454,7 @@ export const useSftpPaneActions = ({
);
const refresh = useCallback(
async (side: "left" | "right", options?: { tabId?: string }) => {
async (side: "left" | "right", options?: { tabId?: string; preserveSelection?: boolean }) => {
const sideTabs = side === "left" ? leftTabsRef.current : rightTabsRef.current;
const pane = options?.tabId
? sideTabs.tabs.find((t) => t.id === options.tabId) ?? null
@@ -476,7 +485,7 @@ export const useSftpPaneActions = ({
}
return;
}
await navigateTo(side, pane.connection.currentPath, { force: true, tabId: options?.tabId });
await navigateTo(side, pane.connection.currentPath, { force: true, tabId: options?.tabId, preserveSelection: options?.preserveSelection });
} else if (!pane?.connection && pane?.error) {
// For background tabs, don't trigger reconnection (it operates on
// the active tab). Just leave the error state for the user to see
+24 -5
View File
@@ -1,6 +1,11 @@
import type { TransferTask } from "../../../domain/models";
import { sftpTransferCenterStore } from "../sftpTransferCenterStore";
/** The displaced invocation may fail its walk, but no longer owns the child row. */
export class TransferOwnerChangedError extends Error {}
export type TransferOwnerObservation = ReturnType<typeof sftpTransferCenterStore.observeTaskSettlement>;
type StreamResult = { error?: string; cancelled?: boolean; superseded?: boolean } | undefined;
/** A live waiter owns bounded settlement evidence; persisted history stays compact. */
@@ -9,33 +14,47 @@ export async function runTransferAndWaitForOwner(
start: () => Promise<StreamResult>,
shouldAbort: () => boolean,
pausedAtResume?: TransferTask,
completedAtRestart?: TransferTask,
onOwnerChanged?: () => void,
retainObservation?: (observation: TransferOwnerObservation) => boolean,
existingObservation?: TransferOwnerObservation,
): Promise<StreamResult> {
// Register before admission/start: an owner may finish while dispatch waits for resume.
let observation = sftpTransferCenterStore.observeTaskSettlement(task);
let observation = existingObservation ?? sftpTransferCenterStore.observeTaskSettlement(task, completedAtRestart, onOwnerChanged);
try {
for (;;) {
if (shouldAbort()) throw new Error("Transfer cancelled");
const admission = sftpTransferCenterStore.admitTaskRun(task, pausedAtResume);
if (observation.hasIdentityConflict()) throw new TransferOwnerChangedError("Transfer identity changed before dispatch");
const admission = sftpTransferCenterStore.admitTaskRun(task, pausedAtResume, completedAtRestart);
if (admission === "cancelled") throw new Error("Transfer cancelled");
if (admission === "completed" || observation.read()?.status === "completed") return {};
if (admission === "conflict") throw new Error("Transfer identity changed before dispatch");
if (admission === "conflict") throw new TransferOwnerChangedError("Transfer identity changed before dispatch");
if (admission === "ready") break;
await new Promise((resolve) => setTimeout(resolve, 200));
}
// Admission begins a new attempt; discard any previous failed settlement.
observation.dispose();
observation = sftpTransferCenterStore.observeTaskSettlement(task);
observation = sftpTransferCenterStore.observeTaskSettlement(task, undefined, onOwnerChanged);
const result = await start();
if (observation.hasIdentityConflict()) throw new TransferOwnerChangedError("Transfer identity changed while waiting for its owner");
if (!result?.superseded) return result;
for (;;) {
if (shouldAbort()) throw new Error("Transfer cancelled");
if (observation.hasIdentityConflict()) throw new TransferOwnerChangedError("Transfer identity changed while waiting for its owner");
const latest = observation.read();
if (latest?.status === "completed") return { ...result, superseded: false };
if (latest?.status === "failed") throw new Error(latest.error || "Transfer failed");
if (latest?.status === "cancelled") throw new Error("Transfer cancelled");
await new Promise((resolve) => setTimeout(resolve, 200));
}
} catch (error) {
// A displaced invocation can reject instead of returning superseded. Its
// transport error must not regain permission to write the winning row.
if (observation.hasIdentityConflict()) throw new TransferOwnerChangedError("Transfer identity changed while waiting for its owner");
throw error;
} finally {
observation.dispose();
// A deferred child update may outlive the invocation. Its caller must either
// retain this evidence through the write or release it after handling exit.
if (!retainObservation?.(observation)) observation.dispose();
}
}
+71 -16
View File
@@ -42,6 +42,14 @@ import { restoreSftpTransferHistoryCooperatively } from "./sftp/transferHistoryR
import { cancelExternalUploadRuntime } from "./sftp/externalUploadRuntime";
type Listener = () => void;
type TaskSettlementObserver = {
expected: TransferTask;
initialInactiveRow?: TransferTask;
settled?: TransferTask;
conflicted?: boolean;
ignoredCompletion?: TransferTask;
onIdentityConflict?: () => void;
};
// Ordinary bounded history restores synchronously so existing callers receive
// it immediately. Legacy directory snapshots can contain tens of thousands of
@@ -98,9 +106,9 @@ export interface SftpTransferCenterStore {
/** Single task row — same object identity until that row is patched. */
getTask(taskId: string): TransferTask | undefined;
/** Observe exact task settlement before completed child rows are compacted. */
observeTaskSettlement(task: TransferTask): { read(): TransferTask | undefined; dispose(): void };
observeTaskSettlement(task: TransferTask, ignoredCompletion?: TransferTask, onIdentityConflict?: () => void): { read(): TransferTask | undefined; hasIdentityConflict(): boolean; dispose(): void };
/** Publish an explicit dispatch identity without flushing large child-history batches. */
admitTaskRun(task: TransferTask, pausedAtResume?: TransferTask): "ready" | "paused" | "completed" | "cancelled" | "conflict";
admitTaskRun(task: TransferTask, pausedAtResume?: TransferTask, completedAtRestart?: TransferTask): "ready" | "paused" | "completed" | "cancelled" | "conflict";
getOwnerTasks(ownerId: string): TransferTask[];
publishOwner(ownerId: string, tasks: readonly TransferTask[]): void;
registerOwner(ownerId: string, controls: SftpTransferOwnerControls): () => void;
@@ -405,7 +413,7 @@ export function createSftpTransferCenterStore(persistence?: StorePersistence): S
let snapshotDirty = false;
const listeners = new Set<Listener>();
const progressListeners = new Set<Listener>();
const settlementObservers = new Map<string, Set<{ expected: TransferTask; settled?: TransferTask }>>();
const settlementObservers = new Map<string, Set<TaskSettlementObserver>>();
const matchesObservedTask = (expected: TransferTask, candidate: TransferTask) => (
expected.id === candidate.id
&& expected.sourcePath === candidate.sourcePath
@@ -414,6 +422,29 @@ export function createSftpTransferCenterStore(persistence?: StorePersistence): S
&& expected.directoryEntryIndex === candidate.directoryEntryIndex
&& expected.directoryEntryIdentity === candidate.directoryEntryIdentity
);
const captureObservedTask = (task: TransferTask) => {
for (const observer of settlementObservers.get(task.id) ?? []) {
if (observer.conflicted || task === observer.ignoredCompletion) continue;
const matchesExpected = matchesObservedTask(observer.expected, task);
// A retry may plan a new source identity while its initial stale row is
// still paused. Only the unchanged initial attempt is exempt: activating
// it or replacing its owner/epoch must not let the stale plan reclaim it.
if (!matchesExpected && observer.initialInactiveRow
&& matchesObservedTask(observer.initialInactiveRow, task)
&& observer.initialInactiveRow.status === task.status
&& observer.initialInactiveRow.lifecycleEpoch === task.lifecycleEpoch
&& observer.initialInactiveRow.ownerId === task.ownerId) continue;
observer.initialInactiveRow = undefined;
if (!matchesExpected) {
observer.conflicted = true;
observer.onIdentityConflict?.();
} else if (observer.settled?.status !== "completed") {
// Failed/cancelled evidence belongs to the latest observed attempt. A
// resumed owner can supersede it; exact successful completion stays final.
observer.settled = TERMINAL_OWNER_STATUSES.has(task.status) ? task : undefined;
}
}
};
const refreshBadgeSnapshot = () => {
const next = buildBadgeSnapshot(tasks);
if (
@@ -633,12 +664,7 @@ export function createSftpTransferCenterStore(persistence?: StorePersistence): S
// settles; everything else can release its whole tree here.
settleFinishedTransferControlState(tasks, tasks);
if (settlementObservers.size > 0) {
for (const task of tasks) {
if (!TERMINAL_OWNER_STATUSES.has(task.status)) continue;
for (const observer of settlementObservers.get(task.id) ?? []) {
if (!observer.settled && matchesObservedTask(observer.expected, task)) observer.settled = task;
}
}
for (const task of tasks) captureObservedTask(task);
}
const beforePrune = tasks;
tasks = pruneSftpTransferHistory(tasks);
@@ -1338,7 +1364,7 @@ export function createSftpTransferCenterStore(persistence?: StorePersistence): S
},
getSnapshot: () => ensureSnapshot(),
getBadgeSnapshot: () => badgeSnapshot,
admitTaskRun(incoming, pausedAtResume) {
admitTaskRun(incoming, pausedAtResume, completedAtRestart) {
const rootId = incoming.parentTaskId ?? incoming.id;
if (isTransferOrRootCancelled(rootId, incoming.id)) return "cancelled";
const parent = incoming.parentTaskId ? tasks.find((task) => task.id === rootId) : undefined;
@@ -1348,7 +1374,10 @@ export function createSftpTransferCenterStore(persistence?: StorePersistence): S
if (existing && (existing.sourcePath !== incoming.sourcePath || existing.targetPath !== incoming.targetPath
|| existing.parentTaskId !== incoming.parentTaskId)) return "conflict";
if (existing?.status === "cancelled") return "cancelled";
if (existing?.status === "completed") {
// Only the exact completion captured at the destination reset is
// stale. A newer completion or user control must keep its authority.
const restartsCompletion = existing?.status === "completed" && existing === completedAtRestart;
if (existing?.status === "completed" && !restartsCompletion) {
return matchesObservedTask(incoming, existing) ? "completed" : "conflict";
}
const resumesUnchangedPause = existing === pausedAtResume
@@ -1369,6 +1398,14 @@ export function createSftpTransferCenterStore(persistence?: StorePersistence): S
...existing,
directoryEntryIndex: incoming.directoryEntryIndex,
directoryEntryIdentity: incoming.directoryEntryIdentity,
...(restartsCompletion ? {
checkpointBytes: incoming.checkpointBytes,
transferredBytes: incoming.transferredBytes,
resumeStage: incoming.resumeStage,
downloadCheckpointBytes: incoming.downloadCheckpointBytes,
uploadCheckpointBytes: incoming.uploadCheckpointBytes,
sourceFingerprint: incoming.sourceFingerprint,
} : {}),
...(existing.status === "transferring" ? {} : {
status: "transferring" as const,
lifecycleEpoch: undefined,
@@ -1378,25 +1415,36 @@ export function createSftpTransferCenterStore(persistence?: StorePersistence): S
}),
};
tasks = next;
captureObservedTask(next[index]);
emitProgress(false);
return "ready";
},
observeTaskSettlement(expected) {
const observer: { expected: TransferTask; settled?: TransferTask } = { expected: { ...expected } };
observeTaskSettlement(expected, ignoredCompletion, onIdentityConflict) {
const initial = tasks.find((task) => task.id === expected.id);
const observer: TaskSettlementObserver = {
expected: { ...expected }, ignoredCompletion, onIdentityConflict,
initialInactiveRow: initial && ["paused", "failed", "interrupted", "attention"].includes(initial.status)
&& !matchesObservedTask(expected, initial) ? { ...initial } : undefined,
};
const observers = settlementObservers.get(expected.id) ?? new Set();
observers.add(observer);
settlementObservers.set(expected.id, observers);
// A different active owner may predate registration. Detect it now,
// before admission can publish the stale caller's identity over it.
if (initial) captureObservedTask(initial);
let disposed = false;
return {
read() {
if (disposed) return undefined;
if (observer.settled) return observer.settled;
const current = tasks.find((task) => task.id === expected.id);
return current && matchesObservedTask(observer.expected, current) ? current : undefined;
return current && current !== ignoredCompletion && matchesObservedTask(observer.expected, current) ? current : undefined;
},
hasIdentityConflict: () => !disposed && observer.conflicted === true,
dispose() {
disposed = true;
observer.settled = undefined;
observer.conflicted = undefined;
observers.delete(observer);
if (observers.size === 0) settlementObservers.delete(expected.id);
},
@@ -1662,6 +1710,7 @@ export function createSftpTransferCenterStore(persistence?: StorePersistence): S
const next = tasks.slice();
next[index] = nextTask;
tasks = next;
captureObservedTask(nextTask);
if (changed) {
const lifecycleChanged = (
(updates.status !== undefined && updates.status !== task.status)
@@ -2023,7 +2072,8 @@ export function createSftpTransferCenterStore(persistence?: StorePersistence): S
backgroundEventsDuringRestore.set(event.transferId, pending);
return;
}
let existing = tasks.find((task) => task.id === event.transferId);
let existingIndex = tasks.findIndex((task) => task.id === event.transferId);
let existing = tasks[existingIndex];
const eventParent = event.parentTaskId
? tasks.find((task) => task.id === event.parentTaskId)
: undefined;
@@ -2039,7 +2089,8 @@ export function createSftpTransferCenterStore(persistence?: StorePersistence): S
directoryEntryIdentity: event.directoryEntryIdentity ?? task.directoryEntryIdentity,
background: false,
} : task);
existing = tasks.find((task) => task.id === event.transferId);
existing = tasks[existingIndex];
captureObservedTask(existing);
}
// A delayed child progress event can arrive after the renderer compacted
// that completed child into its parent checkpoint. Never resurrect it as
@@ -2119,6 +2170,7 @@ export function createSftpTransferCenterStore(persistence?: StorePersistence): S
if ((event.type === "queued" || event.type === "started" || event.type === "progress") && !existing) {
const sourcePath = event.sourcePath ?? "";
const targetPath = event.targetPath ?? "";
existingIndex = tasks.length;
tasks.push({
id: event.transferId,
ownerId: eventParent?.ownerId
@@ -2352,6 +2404,9 @@ export function createSftpTransferCenterStore(persistence?: StorePersistence): S
phase: undefined,
} : task);
}
// Ownership changes must be observed synchronously even when a progress
// event takes the lightweight UI path. Reuse the located row, not a scan.
if (existingIndex >= 0) captureObservedTask(tasks[existingIndex]);
// Progress-only ticks skip prune + coalesce listener paints; lifecycle
// events still take the full emit path so UI never lags on pause/cancel.
if (event.type === "progress" && !persistImmediately) {
@@ -0,0 +1,12 @@
type Listener = (sessionId: string) => void;
const listeners = new Set<Listener>();
/** Renderer-local fan-out of the terminal's existing completion detection. */
export function publishTerminalCommandCompletion(sessionId: string): void {
for (const listener of listeners) listener(sessionId);
}
export function subscribeTerminalCommandCompletion(listener: Listener): () => void {
listeners.add(listener);
return () => { listeners.delete(listener); };
}
+4 -1
View File
@@ -31,6 +31,7 @@ import {
type TerminalSessionInfo,
} from '../../infrastructure/ai/aiChatStreamingSupport';
import { useAgentCompactionUi } from './useAgentCompactionUi';
import { useI18n } from '../i18n/I18nProvider';
export { getNetcattyBridge } from '../../infrastructure/ai/aiChatStreamingSupport';
export type { ActiveCompactionUi } from './useAgentCompactionUi';
@@ -134,6 +135,7 @@ export function useAIChatStreaming({
updateMessageById,
persistContextCompaction,
}: UseAIChatStreamingParams): UseAIChatStreamingReturn {
const { t } = useI18n();
const [streamingSessionIds, setStreamingSessions] = useState<Set<string>>(
() => new Set(sharedStreamingSessionIds),
);
@@ -195,7 +197,8 @@ export function useAIChatStreaming({
setStreamingForScope,
getLatestSession: (sessionId: string) => latestAISessionsSnapshot?.find(s => s.id === sessionId),
persistContextCompaction,
}), [addMessageToSession, updateLastMessage, updateMessageById, reportStreamError, setStreamingForScope, persistContextCompaction]);
translate: t,
}), [addMessageToSession, updateLastMessage, updateMessageById, reportStreamError, setStreamingForScope, persistContextCompaction, t]);
const sendToExternalAgent = useCallback(async (
sessionId: string,
@@ -0,0 +1,49 @@
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 { useComposeBarHistory } from './useComposeBarHistory';
import { pruneComposeBarHistory, recordComposeBarHistory } from './composeBarHistoryStore';
test('changing workspace focus resets navigation without replacing the draft', async () => {
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
pruneComposeBarHistory([]);
recordComposeBarHistory('a', 'a1');
recordComposeBarHistory('a', 'a2');
recordComposeBarHistory('b', 'b1');
let history!: ReturnType<typeof useComposeBarHistory>;
function Harness({ sessionId }: { sessionId: string }) {
history = useComposeBarHistory(sessionId);
return null;
}
let root!: ReactTestRenderer;
await act(async () => { root = create(React.createElement(Harness, { sessionId: 'a' })); });
assert.equal(history.navigate('unsent draft', 'up'), 'a2');
await act(async () => { root.update(React.createElement(Harness, { sessionId: 'b' })); });
assert.equal(history.navigate('unsent draft', 'down'), undefined);
assert.equal(history.navigate('unsent draft', 'up'), 'b1');
assert.equal(history.navigate('b1', 'down'), 'unsent draft');
recordComposeBarHistory('b', 'later completion');
assert.equal(history.navigate('unsent draft', 'up'), 'later completion');
await act(async () => { root.unmount(); });
pruneComposeBarHistory([]);
});
test('an in-progress history walk stays stable when a late send evicts the oldest entry', async () => {
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
pruneComposeBarHistory([]);
for (let index = 0; index < 100; index += 1) recordComposeBarHistory('a', `h${index}`);
let history!: ReturnType<typeof useComposeBarHistory>;
function Harness() { history = useComposeBarHistory('a'); return null; }
let root!: ReactTestRenderer;
await act(async () => { root = create(React.createElement(Harness)); });
const finish = history.prepareRecord();
assert.equal(history.navigate('draft', 'up'), 'h99');
finish('h100');
assert.equal(history.navigate('h99', 'up'), 'h98');
assert.equal(history.navigate('h98', 'down'), 'h99');
assert.equal(history.navigate('h99', 'down'), 'draft');
assert.equal(history.navigate('draft', 'up'), 'h100');
await act(async () => { root.unmount(); });
pruneComposeBarHistory([]);
});
+41
View File
@@ -0,0 +1,41 @@
import { useCallback, useLayoutEffect, useRef } from 'react';
import { navigateComposeBarHistory, type ComposeBarHistoryDirection } from '../../domain/composeBarHistory';
import { getComposeBarHistory, createComposeBarHistoryRecorder } from './composeBarHistoryStore';
export function useComposeBarHistory(sessionId: string, restoreDraft?: (draft: string) => void) {
const cursor = useRef<{ index: number; draft: string; entries?: readonly string[] }>({ index: Infinity, draft: '' });
const reset = useCallback(() => {
cursor.current = { index: Infinity, draft: '' };
}, []);
// Keep the existing textarea draft when workspace focus changes, but begin
// a fresh history walk in the newly focused session.
useLayoutEffect(() => {
if (cursor.current.entries) restoreDraft?.(cursor.current.draft);
reset();
}, [reset, restoreDraft, sessionId]);
const prepareRecord = useCallback(() => createComposeBarHistoryRecorder(sessionId), [sessionId]);
const navigate = useCallback((currentValue: string, direction: ComposeBarHistoryDirection) => {
// A late send can evict old entries. Keep one walk stable until the user
// returns to the draft or edits, then pick up the latest history.
const entries = cursor.current.entries ?? getComposeBarHistory(sessionId);
const result = navigateComposeBarHistory({
...cursor.current,
entries,
currentValue,
}, direction);
if (result) {
cursor.current = {
index: result.index === entries.length ? Infinity : result.index,
draft: result.draft,
entries: result.index === entries.length ? undefined : entries,
};
}
return result?.value;
}, [sessionId]);
return { prepareRecord, navigate, reset };
}
+155
View File
@@ -352,3 +352,158 @@ test("manual stop still returns its recording and permits a clean restart", asyn
assert.equal(recorder!.isRecording, true);
await act(async () => renderer!.unmount());
});
test("confirmed line-by-line recording awaits each send and prompt step in order", async (t) => {
const previousWindow = Object.getOwnPropertyDescriptor(globalThis, "window");
let appendGate: Promise<void> | undefined;
let stopCalls = 0;
const steps: Array<{ type: string; value?: unknown; sensitive?: boolean }> = [];
const eventTarget = new EventTarget();
Object.assign(eventTarget, {
netcatty: {
scriptRecordingStart: async () => ({ ok: true }),
scriptRecordingStop: async () => { stopCalls++; return { steps: [...steps], code: "" }; },
scriptRecordingAppendStep: async (_id: string, step: typeof steps[number]) => {
await appendGate;
await new Promise(resolve => setImmediate(resolve));
steps.push(step);
return { stopped: false };
},
},
setInterval, clearInterval,
});
Object.defineProperty(globalThis, "window", { configurable: true, value: eventTarget });
t.after(() => {
if (previousWindow) Object.defineProperty(globalThis, "window", previousWindow);
else Reflect.deleteProperty(globalThis, "window");
});
let recorder!: Recorder;
let renderer!: ReactTestRenderer;
function Probe() { recorder = useScriptRecorder("session-1"); return null; }
await act(async () => { renderer = create(React.createElement(Probe)); });
try {
await act(async () => { await recorder.startRecording(); });
await act(async () => {
recorder.recordInput("echo ");
const recordLine = recorder.captureSubmittedLineRecorder()!;
recorder.recordInput("new input");
await Promise.all([recordLine("echo first"), recordLine("cd /tmp")]);
});
assert.deepEqual(steps.map(step => [step.type, step.value]), [
["send", "echo first"], ["waitForPrompt", undefined],
["send", "cd /tmp"], ["waitForPrompt", undefined],
]);
steps.length = 0;
await act(async () => {
await recorder.recordEnter();
assert.equal(steps[0].value, "new input");
steps.length = 0;
const recordLine = recorder.captureSubmittedLineRecorder()!;
await Promise.all([recordLine("secret", { sensitive: true }), recordLine("second secret", { sensitive: true })]);
});
assert.deepEqual(steps.filter(step => step.type === "send").map(step => step.sensitive), [true, true]);
const staleRecorder = recorder.captureSubmittedLineRecorder()!;
await act(async () => { await recorder.stopRecording(); await recorder.startRecording(); });
steps.length = 0;
await act(async () => { await staleRecorder("never sent in this recording"); });
assert.deepEqual(steps, []);
const pauseGate = deferred<void>();
appendGate = pauseGate.promise;
const beforePause = recorder.captureSubmittedLineRecorder()!;
const pausedFirst = beforePause("sent before pause 1");
const pausedSecond = beforePause("sent before pause 2");
await flushReact();
act(() => recorder.pauseRecording());
await beforePause("ignored while paused");
pauseGate.resolve();
await act(async () => { await Promise.all([pausedFirst, pausedSecond]); });
assert.deepEqual(steps.map(step => [step.type, step.value]), [
["send", "sent before pause 1"], ["waitForPrompt", undefined],
["send", "sent before pause 2"], ["waitForPrompt", undefined],
]);
act(() => recorder.resumeRecording());
steps.length = 0;
const gate = deferred<void>();
appendGate = gate.promise;
const recordLine = recorder.captureSubmittedLineRecorder()!;
const first = recordLine("sent first");
const second = recordLine("sent second");
await flushReact();
let stopped!: ReturnType<Recorder["stopRecording"]>;
act(() => { stopped = recorder.stopRecording(); });
assert.equal(stopCalls, 1);
await recordLine("arrived after stop");
gate.resolve();
await act(async () => { await Promise.all([first, second, stopped]); });
assert.equal(stopCalls, 2);
assert.deepEqual(steps.map(step => [step.type, step.value]), [
["send", "sent first"], ["waitForPrompt", undefined],
["send", "sent second"], ["waitForPrompt", undefined],
]);
} finally {
await act(async () => renderer.unmount());
}
});
test("paced recording uses its captured prefix and never imports text typed while paused", async (t) => {
const previousWindow = Object.getOwnPropertyDescriptor(globalThis, "window");
const steps: Array<{ type: string; value?: unknown }> = [];
const target = new EventTarget();
Object.assign(target, {
netcatty: {
scriptRecordingStart: async () => ({ ok: true }),
scriptRecordingAppendStep: async (_id: string, step: typeof steps[number]) => {
steps.push(step);
return { stopped: false };
},
}, setInterval, clearInterval,
});
Object.defineProperty(globalThis, "window", { configurable: true, value: target });
t.after(() => {
if (previousWindow) Object.defineProperty(globalThis, "window", previousWindow);
else Reflect.deleteProperty(globalThis, "window");
});
let recorder!: Recorder;
let renderer!: ReactTestRenderer;
function Probe() { recorder = useScriptRecorder("s"); return null; }
await act(async () => { renderer = create(React.createElement(Probe)); });
try {
await act(async () => {
await recorder.startRecording();
recorder.recordInput("echo ");
recorder.pauseRecording();
recorder.recordInput("PAUSED_");
recorder.resumeRecording();
const lineRecorder = recorder.captureSubmittedLineRecorder()!;
recorder.recordInput("next input");
await lineRecorder("first", { includePendingInput: true });
await lineRecorder("second");
await recorder.recordEnter();
});
assert.deepEqual(steps.filter(step => step.type === "send").map(step => step.value), ["echo first", "second", "next input"]);
steps.length = 0;
await act(async () => {
recorder.recordInput("preserved ");
recorder.captureSubmittedLineRecorder(); // No write receipt: rejected batch.
recorder.recordInput("draft");
await recorder.recordEnter();
assert.equal(steps.find(step => step.type === "send")?.value, "preserved draft");
steps.length = 0;
recorder.recordInput("old ");
const lateReceipt = recorder.captureSubmittedLineRecorder()!;
recorder.recordClearLine();
recorder.recordInput("old new draft");
await lateReceipt("sent", { includePendingInput: true });
await recorder.recordEnter();
assert.deepEqual(steps.filter(step => step.type === "send").map(step => step.value), ["old sent", "old new draft"]);
steps.length = 0;
recorder.recordInput("prefix for a dropped first line");
const lineRecorder = recorder.captureSubmittedLineRecorder()!;
await lineRecorder("second was sent", { consumePendingInput: false });
await recorder.recordEnter();
});
assert.deepEqual(steps.filter(step => step.type === "send").map(step => step.value), ["second was sent", "prefix for a dropped first line"]);
} finally {
await act(async () => renderer.unmount());
}
});
+67 -20
View File
@@ -27,12 +27,16 @@ export function useScriptRecorder(sessionId: string | undefined) {
const [elapsedMs, setElapsedMs] = useState(0);
const startedAtRef = useRef<number | null>(null);
const inputBufferRef = useRef('');
const inputRevisionRef = useRef(0);
const lastStepAtRef = useRef<number>(Date.now());
const isRecordingRef = useRef(false);
const isPausedRef = useRef(false);
const isStoppingRef = useRef(false);
const stopPromiseRef = useRef<Promise<ScriptRecordingResult> | null>(null);
const sessionIdRef = useRef(sessionId);
const recordingGenerationRef = useRef(0);
const pendingRecordStepsRef = useRef(Promise.resolve());
const isDrainingRecordingRef = useRef(false);
sessionIdRef.current = sessionId;
@@ -60,8 +64,10 @@ export function useScriptRecorder(sessionId: string | undefined) {
const bridge = netcattyBridge.get();
if (!sid || !bridge?.scriptRecordingStart) return;
await bridge.scriptRecordingStart(sid);
recordingGenerationRef.current += 1;
startedAtRef.current = Date.now();
lastStepAtRef.current = Date.now();
inputRevisionRef.current += 1;
inputBufferRef.current = '';
setElapsedMs(0);
setIsPaused(false);
@@ -76,27 +82,26 @@ export function useScriptRecorder(sessionId: string | undefined) {
const sid = sessionIdRef.current;
const bridge = netcattyBridge.get();
isStoppingRef.current = true;
isDrainingRecordingRef.current = true;
isRecordingRef.current = false;
isPausedRef.current = false;
setIsRecording(false);
setIsPaused(false);
inputRevisionRef.current += 1;
inputBufferRef.current = '';
startedAtRef.current = null;
if (!sid || !bridge?.scriptRecordingStop) {
isStoppingRef.current = false;
isDrainingRecordingRef.current = false;
return emptyScriptRecordingResult();
}
let stopRequest: Promise<ScriptRecordingResult>;
try {
stopRequest = Promise.resolve(bridge.scriptRecordingStop(sid));
} catch (error) {
stopRequest = Promise.reject(error);
}
const stopRequest = pendingRecordStepsRef.current.catch(() => {}).then(() => bridge.scriptRecordingStop!(sid));
let stopPromise: Promise<ScriptRecordingResult>;
stopPromise = stopRequest.finally(() => {
if (stopPromiseRef.current === stopPromise) {
stopPromiseRef.current = null;
isStoppingRef.current = false;
isDrainingRecordingRef.current = false;
}
});
stopPromiseRef.current = stopPromise;
@@ -104,8 +109,10 @@ export function useScriptRecorder(sessionId: string | undefined) {
}, []);
const finishAutomaticStop = useCallback((detail: ScriptRecordingLimitDetail) => {
isDrainingRecordingRef.current = false;
isRecordingRef.current = false;
isPausedRef.current = false;
inputRevisionRef.current += 1;
inputBufferRef.current = '';
startedAtRef.current = null;
setIsRecording(false);
@@ -126,10 +133,13 @@ export function useScriptRecorder(sessionId: string | undefined) {
setIsPaused(false);
}, []);
const appendStep = useCallback(async (step: ScriptRecordingStep) => {
const appendStep = useCallback(async (step: ScriptRecordingStep, accepted = false) => {
const sid = sessionIdRef.current;
if (!sid || !isRecordingRef.current || isPausedRef.current || isStoppingRef.current) return;
if (!sid || (!(accepted && isDrainingRecordingRef.current)
&& (!isRecordingRef.current || (!accepted && isPausedRef.current) || isStoppingRef.current))) return;
const generation = recordingGenerationRef.current;
const result = await netcattyBridge.get()?.scriptRecordingAppendStep?.(sid, step);
if (generation !== recordingGenerationRef.current || sid !== sessionIdRef.current) return;
if (result?.stopped) {
finishAutomaticStop({
sessionId: sid,
@@ -154,6 +164,7 @@ export function useScriptRecorder(sessionId: string | undefined) {
isStoppingRef.current = true;
isRecordingRef.current = false;
isPausedRef.current = false;
inputRevisionRef.current += 1;
inputBufferRef.current = '';
startedAtRef.current = null;
setIsRecording(false);
@@ -188,33 +199,68 @@ export function useScriptRecorder(sessionId: string | undefined) {
const recordBackspace = useCallback(() => {
if (!isRecordingRef.current || isPausedRef.current || isStoppingRef.current) return;
inputRevisionRef.current += 1;
inputBufferRef.current = inputBufferRef.current.slice(0, -1);
}, []);
const recordClearLine = useCallback(() => {
if (!isRecordingRef.current || isPausedRef.current || isStoppingRef.current) return;
inputRevisionRef.current += 1;
inputBufferRef.current = '';
}, []);
const recordEnter = useCallback(async (options?: { sensitive?: boolean }) => {
const recordEnter = useCallback(async (options?: { sensitive?: boolean; submittedLine?: string }) => {
const sid = sessionIdRef.current;
if (!isRecordingRef.current || isPausedRef.current || isStoppingRef.current || !sid) return;
const line = inputBufferRef.current;
inputBufferRef.current = '';
const line = options?.submittedLine ?? inputBufferRef.current;
if (options?.submittedLine === undefined) {
inputRevisionRef.current += 1;
inputBufferRef.current = '';
}
const now = Date.now();
const gap = now - lastStepAtRef.current;
if (gap > 1000) {
await appendStep({ type: 'sleep', value: gap });
}
await appendStep({
type: 'send',
value: line,
sensitive: options?.sensitive,
});
await appendStep({ type: 'waitForPrompt', timeoutMs: DEFAULT_RECORDING_PROMPT_TIMEOUT_MS });
lastStepAtRef.current = now;
const generation = recordingGenerationRef.current;
const pending = pendingRecordStepsRef.current.catch(() => {}).then(async () => {
const isCurrent = () => generation === recordingGenerationRef.current && sid === sessionIdRef.current
&& (isRecordingRef.current || isDrainingRecordingRef.current);
if (!isCurrent()) return;
if (gap > 1000) await appendStep({ type: 'sleep', value: gap }, true);
if (!isCurrent()) return;
await appendStep({ type: 'send', value: line, sensitive: options?.sensitive }, true);
if (!isCurrent()) return;
await appendStep({ type: 'waitForPrompt', timeoutMs: DEFAULT_RECORDING_PROMPT_TIMEOUT_MS }, true);
});
pendingRecordStepsRef.current = pending;
await pending;
}, [appendStep]);
// Delayed paste receipts must not consume newly typed input or leak into a
// recording started after the paste. Each receipt records one actual write.
const captureSubmittedLineRecorder = useCallback(() => {
if (!isRecordingRef.current || isPausedRef.current || isStoppingRef.current) return undefined;
const generation = recordingGenerationRef.current;
const sid = sessionIdRef.current;
const pendingInput = inputBufferRef.current;
const revision = inputRevisionRef.current;
let consumed = false;
const isCurrent = () => generation === recordingGenerationRef.current
&& sid === sessionIdRef.current && isRecordingRef.current
&& !isPausedRef.current && !isStoppingRef.current;
return (line: string, options?: { sensitive?: boolean; includePendingInput?: boolean; consumePendingInput?: boolean }): Promise<void> => {
// Capturing a batch is not a write. Retain its prefix if no receipt
// arrives; only its prefixed first line can consume the still-owned draft.
if (options?.consumePendingInput !== false && !consumed && generation === recordingGenerationRef.current && sid === sessionIdRef.current
&& revision === inputRevisionRef.current && inputBufferRef.current.startsWith(pendingInput)) {
inputBufferRef.current = inputBufferRef.current.slice(pendingInput.length);
inputRevisionRef.current += 1;
}
if (options?.consumePendingInput !== false) consumed = true;
if (!isCurrent()) return Promise.resolve();
return recordEnter({ sensitive: options?.sensitive, submittedLine: options?.includePendingInput ? `${pendingInput}${line}` : line });
};
}, [recordEnter]);
return {
isRecording,
isPaused,
@@ -227,5 +273,6 @@ export function useScriptRecorder(sessionId: string | undefined) {
recordBackspace,
recordClearLine,
recordEnter,
captureSubmittedLineRecorder,
};
}
+12
View File
@@ -33,6 +33,10 @@ export interface ServerStats {
cpu: number | null; // CPU usage percentage (0-100)
cpuCores: number | null; // Number of CPU cores
cpuPerCore: number[]; // Per-core CPU usage array
gpu: number | null; // NVIDIA GPU utilization percentage (0-100), null when no GPU data
gpuName: string | null; // First GPU name (tooltip)
gpuMemUsed: number | null; // Summed VRAM used in MB
gpuMemTotal: number | null; // Summed VRAM total in MB
memTotal: number | null; // Total memory in MB
memUsed: number | null; // Used memory in MB (excluding buffers/cache)
memFree: number | null; // Free memory in MB
@@ -107,6 +111,10 @@ function createEmptyServerStats(): ServerStats {
cpu: null,
cpuCores: null,
cpuPerCore: [],
gpu: null,
gpuName: null,
gpuMemUsed: null,
gpuMemTotal: null,
memTotal: null,
memUsed: null,
memFree: null,
@@ -200,6 +208,10 @@ function normalizeServerStats(stats: Partial<ServerStats>): ServerStats {
cpu: stats.cpu ?? null,
cpuCores: stats.cpuCores ?? null,
cpuPerCore: stats.cpuPerCore || [],
gpu: stats.gpu ?? null,
gpuName: stats.gpuName ?? null,
gpuMemUsed: stats.gpuMemUsed ?? null,
gpuMemTotal: stats.gpuMemTotal ?? null,
memTotal: stats.memTotal ?? null,
memUsed: stats.memUsed ?? null,
memFree: stats.memFree ?? null,
+65 -3
View File
@@ -1,3 +1,4 @@
import { normalizeTabBarPosition, type TabBarPosition } from '../../domain/tabBarPosition';
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, type SetStateAction } from 'react';
import { runThemeTransition, type ThemeTransitionMode } from './themeTransition';
@@ -78,9 +79,12 @@ import {
STORAGE_KEY_HOST_CLICK_BEHAVIOR,
STORAGE_KEY_SHOW_ONLY_UNGROUPED_HOSTS_IN_ROOT,
STORAGE_KEY_SHOW_SFTP_TAB,
STORAGE_KEY_TAB_BAR_POSITION,
STORAGE_KEY_SHOW_HOST_TREE_SIDEBAR,
STORAGE_KEY_TERMINAL_SIDE_PANEL_AUTO_OPEN,
STORAGE_KEY_TERMINAL_SIDE_PANEL_AUTO_OPEN_TAB,
STORAGE_KEY_LOCAL_SHELL_SIDE_PANEL_AUTO_OPEN,
STORAGE_KEY_LOCAL_SHELL_SIDE_PANEL_AUTO_OPEN_TAB,
STORAGE_KEY_SHELL_ONLY_TAB_NUMBER_SHORTCUTS,
STORAGE_KEY_SHOW_TAB_NUMBER_BADGES,
STORAGE_KEY_DISABLE_TERMINAL_FONT_ZOOM,
@@ -413,6 +417,9 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
const stored = localStorageAdapter.readBoolean(STORAGE_KEY_SHOW_ONLY_UNGROUPED_HOSTS_IN_ROOT);
return stored ?? DEFAULT_SHOW_ONLY_UNGROUPED_HOSTS_IN_ROOT;
});
const [tabBarPosition, setTabBarPositionState] = useState<TabBarPosition>(() =>
normalizeTabBarPosition(readStoredString(STORAGE_KEY_TAB_BAR_POSITION)),
);
const [showSftpTab, setShowSftpTabState] = useState<boolean>(() => {
const stored = localStorageAdapter.readBoolean(STORAGE_KEY_SHOW_SFTP_TAB);
return stored ?? DEFAULT_SHOW_SFTP_TAB;
@@ -429,6 +436,14 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
const stored = readStoredString(STORAGE_KEY_TERMINAL_SIDE_PANEL_AUTO_OPEN_TAB);
return isTerminalSidePanelAutoOpenTab(stored) ? stored : DEFAULT_TERMINAL_SIDE_PANEL_AUTO_OPEN_TAB;
});
const [localShellSidePanelAutoOpen, setLocalShellSidePanelAutoOpenState] = useState<boolean>(() => {
const stored = localStorageAdapter.readBoolean(STORAGE_KEY_LOCAL_SHELL_SIDE_PANEL_AUTO_OPEN);
return stored ?? false;
});
const [localShellSidePanelAutoOpenTab, setLocalShellSidePanelAutoOpenTabState] = useState<TerminalSidePanelAutoOpenTab>(() => {
const stored = readStoredString(STORAGE_KEY_LOCAL_SHELL_SIDE_PANEL_AUTO_OPEN_TAB);
return isTerminalSidePanelAutoOpenTab(stored) ? stored : 'scripts';
});
const [shellOnlyTabNumberShortcuts, setShellOnlyTabNumberShortcutsState] = useState<boolean>(() => {
const stored = localStorageAdapter.readBoolean(STORAGE_KEY_SHELL_ONLY_TAB_NUMBER_SHORTCUTS);
return stored ?? DEFAULT_SHELL_ONLY_TAB_NUMBER_SHORTCUTS;
@@ -1012,6 +1027,7 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
);
const storedShowOnlyUngroupedHostsInRoot = localStorageAdapter.readBoolean(STORAGE_KEY_SHOW_ONLY_UNGROUPED_HOSTS_IN_ROOT);
setShowOnlyUngroupedHostsInRootState(storedShowOnlyUngroupedHostsInRoot ?? DEFAULT_SHOW_ONLY_UNGROUPED_HOSTS_IN_ROOT);
setTabBarPositionState(normalizeTabBarPosition(readStoredString(STORAGE_KEY_TAB_BAR_POSITION)));
const storedShowSftpTab = localStorageAdapter.readBoolean(STORAGE_KEY_SHOW_SFTP_TAB);
setShowSftpTabState(storedShowSftpTab ?? DEFAULT_SHOW_SFTP_TAB);
const storedShowHostTreeSidebar = localStorageAdapter.readBoolean(STORAGE_KEY_SHOW_HOST_TREE_SIDEBAR);
@@ -1024,6 +1040,14 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
? storedTerminalSidePanelAutoOpenTab
: DEFAULT_TERMINAL_SIDE_PANEL_AUTO_OPEN_TAB,
);
const storedLocalShellSidePanelAutoOpen = localStorageAdapter.readBoolean(STORAGE_KEY_LOCAL_SHELL_SIDE_PANEL_AUTO_OPEN);
setLocalShellSidePanelAutoOpenState(storedLocalShellSidePanelAutoOpen ?? false);
const storedLocalShellSidePanelAutoOpenTab = readStoredString(STORAGE_KEY_LOCAL_SHELL_SIDE_PANEL_AUTO_OPEN_TAB);
setLocalShellSidePanelAutoOpenTabState(
isTerminalSidePanelAutoOpenTab(storedLocalShellSidePanelAutoOpenTab)
? storedLocalShellSidePanelAutoOpenTab
: 'scripts',
);
const storedShellOnlyTabNumberShortcuts = localStorageAdapter.readBoolean(STORAGE_KEY_SHELL_ONLY_TAB_NUMBER_SHORTCUTS);
setShellOnlyTabNumberShortcutsState(storedShellOnlyTabNumberShortcuts ?? DEFAULT_SHELL_ONLY_TAB_NUMBER_SHORTCUTS);
const storedShowTabNumberBadges = localStorageAdapter.readBoolean(STORAGE_KEY_SHOW_TAB_NUMBER_BADGES);
@@ -1198,9 +1222,12 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
setSftpFollowTerminalCwd,
setSftpDefaultViewMode,
setWorkspaceFocusStyleState,
setTabBarPositionState,
setShowHostTreeSidebarState,
setTerminalSidePanelAutoOpenState,
setTerminalSidePanelAutoOpenTabState,
setLocalShellSidePanelAutoOpenState,
setLocalShellSidePanelAutoOpenTabState,
setDisableTerminalFontZoomState,
setRestorePreviousSessionState,
setRestoreTerminalCwdState,
@@ -1254,7 +1281,7 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
terminalThemeId, followAppTerminalTheme, terminalFontFamilyId, terminalFontSize,
sftpDoubleClickBehavior, sftpAutoSync, sftpShowHiddenFiles,
sftpUseCompressedUpload, sftpSkipUnchanged, sftpAutoOpenSidebar, sftpFollowTerminalCwd, sftpDefaultViewMode,
showRecentHosts, hostClickBehavior, showOnlyUngroupedHostsInRoot, showSftpTab, showHostTreeSidebar, terminalSidePanelAutoOpen, terminalSidePanelAutoOpenTab, shellOnlyTabNumberShortcuts, showTabNumberBadges, disableTerminalFontZoom, restorePreviousSession, restoreTerminalCwd, startupLanding,
showRecentHosts, hostClickBehavior, showOnlyUngroupedHostsInRoot, tabBarPosition, showSftpTab, showHostTreeSidebar, terminalSidePanelAutoOpen, terminalSidePanelAutoOpenTab, localShellSidePanelAutoOpen, localShellSidePanelAutoOpenTab, shellOnlyTabNumberShortcuts, showTabNumberBadges, disableTerminalFontZoom, restorePreviousSession, restoreTerminalCwd, startupLanding,
editorWordWrap, sessionLogsEnabled, sessionLogsDir, sessionLogsFormat, sessionLogsTimestampsEnabled, sshDebugLogsEnabled, sshDeepLinkEnabled, jmsDeepLinkEnabled, explorerContextMenuEnabled,
globalHotkeyEnabled, autoUpdateEnabled, windowOpacity, appIconVariant,
setTheme, setLightUiThemeId, setDarkUiThemeId, setAccentMode,
@@ -1264,7 +1291,7 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
setFollowAppTerminalThemeState, setTerminalFontFamilyId, setTerminalFontSize: applyIncomingTerminalFontSize,
setSftpDoubleClickBehavior, setSftpAutoSync, setSftpShowHiddenFiles,
setSftpUseCompressedUpload, setSftpSkipUnchanged, setSftpAutoOpenSidebar, setSftpFollowTerminalCwd, setSftpDefaultViewMode,
setShowRecentHostsState, setHostClickBehaviorState, setShowOnlyUngroupedHostsInRootState, setShowSftpTabState, setShowHostTreeSidebarState, setTerminalSidePanelAutoOpenState, setTerminalSidePanelAutoOpenTabState, setShellOnlyTabNumberShortcutsState, setShowTabNumberBadgesState, setDisableTerminalFontZoomState, setRestorePreviousSessionState, setRestoreTerminalCwdState, setStartupLandingState,
setShowRecentHostsState, setHostClickBehaviorState, setShowOnlyUngroupedHostsInRootState, setTabBarPositionState, setShowSftpTabState, setShowHostTreeSidebarState, setTerminalSidePanelAutoOpenState, setTerminalSidePanelAutoOpenTabState, setLocalShellSidePanelAutoOpenState, setLocalShellSidePanelAutoOpenTabState, setShellOnlyTabNumberShortcutsState, setShowTabNumberBadgesState, setDisableTerminalFontZoomState, setRestorePreviousSessionState, setRestoreTerminalCwdState, setStartupLandingState,
setEditorWordWrapState, setSessionLogsEnabled, setSessionLogsDir, setSessionLogsFormat, setSessionLogsTimestampsEnabled, setSshDebugLogsEnabled, setSshDeepLinkEnabledState: applyIncomingSshDeepLinkEnabled, setJmsDeepLinkEnabledState: applyIncomingJmsDeepLinkEnabled, setExplorerContextMenuEnabledState: applyIncomingExplorerContextMenuEnabled,
setGlobalHotkeyEnabled, setWindowOpacity: applyIncomingWindowOpacity, setAppIconVariant, setAutoUpdateEnabled, setWorkspaceFocusStyleState,
setSftpTransferConcurrencyState, setSshTransportIdleTtlMsState,
@@ -1394,6 +1421,14 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
notifySettingsChanged(STORAGE_KEY_SHOW_ONLY_UNGROUPED_HOSTS_IN_ROOT, enabled);
}, [notifySettingsChanged]);
const setTabBarPosition = useCallback((value: TabBarPosition) => {
const next = normalizeTabBarPosition(value);
setTabBarPositionState(next);
localStorageAdapter.writeString(STORAGE_KEY_TAB_BAR_POSITION, next);
if (!persistMountedRef.current) return;
notifySettingsChanged(STORAGE_KEY_TAB_BAR_POSITION, next);
}, [notifySettingsChanged]);
const setShowSftpTab = useCallback((enabled: boolean) => {
setShowSftpTabState(enabled);
localStorageAdapter.writeBoolean(STORAGE_KEY_SHOW_SFTP_TAB, enabled);
@@ -1423,6 +1458,21 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
notifySettingsChanged(STORAGE_KEY_TERMINAL_SIDE_PANEL_AUTO_OPEN_TAB, next);
}, [notifySettingsChanged]);
const setLocalShellSidePanelAutoOpen = useCallback((enabled: boolean) => {
setLocalShellSidePanelAutoOpenState(enabled);
localStorageAdapter.writeBoolean(STORAGE_KEY_LOCAL_SHELL_SIDE_PANEL_AUTO_OPEN, enabled);
if (!persistMountedRef.current) return;
notifySettingsChanged(STORAGE_KEY_LOCAL_SHELL_SIDE_PANEL_AUTO_OPEN, enabled);
}, [notifySettingsChanged]);
const setLocalShellSidePanelAutoOpenTab = useCallback((tab: TerminalSidePanelAutoOpenTab) => {
const next = isTerminalSidePanelAutoOpenTab(tab) ? tab : 'scripts';
setLocalShellSidePanelAutoOpenTabState(next);
localStorageAdapter.writeString(STORAGE_KEY_LOCAL_SHELL_SIDE_PANEL_AUTO_OPEN_TAB, next);
if (!persistMountedRef.current) return;
notifySettingsChanged(STORAGE_KEY_LOCAL_SHELL_SIDE_PANEL_AUTO_OPEN_TAB, next);
}, [notifySettingsChanged]);
const setShellOnlyTabNumberShortcuts = useCallback((enabled: boolean) => {
setShellOnlyTabNumberShortcutsState(enabled);
localStorageAdapter.writeBoolean(STORAGE_KEY_SHELL_ONLY_TAB_NUMBER_SHORTCUTS, enabled);
@@ -1865,6 +1915,7 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
darkUiThemeId,
uiLanguage,
windowOpacity,
tabBarPosition,
showSftpTab,
showHostTreeSidebar,
showRecentHosts,
@@ -1878,6 +1929,8 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
restoreTerminalCwd,
terminalSidePanelAutoOpen,
terminalSidePanelAutoOpenTab,
localShellSidePanelAutoOpen,
localShellSidePanelAutoOpenTab,
});
}, [
darkUiThemeId,
@@ -1891,6 +1944,7 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
showHostTreeSidebar,
showOnlyUngroupedHostsInRoot,
showRecentHosts,
tabBarPosition,
showSftpTab,
showTabNumberBadges,
terminalSettings.dynamicTabTitleMode,
@@ -1899,6 +1953,8 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
theme,
uiLanguage,
windowOpacity,
localShellSidePanelAutoOpen,
localShellSidePanelAutoOpenTab,
]);
useLayoutEffect(() => {
@@ -2063,7 +2119,9 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
setHostClickBehavior,
showOnlyUngroupedHostsInRoot,
setShowOnlyUngroupedHostsInRoot,
tabBarPosition,
showSftpTab,
setTabBarPosition,
setShowSftpTab,
showHostTreeSidebar,
setShowHostTreeSidebar,
@@ -2071,6 +2129,10 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
setTerminalSidePanelAutoOpen,
terminalSidePanelAutoOpenTab,
setTerminalSidePanelAutoOpenTab,
localShellSidePanelAutoOpen,
setLocalShellSidePanelAutoOpen,
localShellSidePanelAutoOpenTab,
setLocalShellSidePanelAutoOpenTab,
shellOnlyTabNumberShortcuts,
setShellOnlyTabNumberShortcuts,
showTabNumberBadges,
@@ -2139,7 +2201,7 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
terminalThemeId, terminalFontFamilyId, terminalFontSize, terminalSettings,
customKeyBindings, editorWordWrap,
sftpDoubleClickBehavior, sftpAutoSync, sftpShowHiddenFiles, sftpUseCompressedUpload, sftpSkipUnchanged, sftpAutoOpenSidebar, sftpFollowTerminalCwd, sftpDefaultViewMode,
showRecentHosts, hostClickBehavior, showOnlyUngroupedHostsInRoot, showSftpTab, showHostTreeSidebar, terminalSidePanelAutoOpen, terminalSidePanelAutoOpenTab, shellOnlyTabNumberShortcuts, showTabNumberBadges, disableTerminalFontZoom,
showRecentHosts, hostClickBehavior, showOnlyUngroupedHostsInRoot, tabBarPosition, showSftpTab, showHostTreeSidebar, terminalSidePanelAutoOpen, terminalSidePanelAutoOpenTab, localShellSidePanelAutoOpen, localShellSidePanelAutoOpenTab, shellOnlyTabNumberShortcuts, showTabNumberBadges, disableTerminalFontZoom,
customThemes, workspaceFocusStyle, sessionLogsTimestampsEnabled, sshDebugLogsEnabled, sshDeepLinkEnabled, jmsDeepLinkEnabled, explorerContextMenuEnabled,
]),
};
+11 -3
View File
@@ -202,13 +202,19 @@ export const useTerminalBackend = () => {
bridge?.writeToSession?.(sessionId, data, options);
}, []);
const interruptSession = useCallback((sessionId: string, trace?: NetcattyTerminalInterruptTrace) => {
const notifyUserInput = useCallback((sessionId: string) => {
const bridge = netcattyBridge.get();
bridge?.notifySessionUserInput?.(sessionId);
}, []);
const interruptSession = useCallback((...args: Parameters<NonNullable<NetcattyBridge["interruptSession"]>>) => {
const [sessionId, , options] = args;
const bridge = netcattyBridge.get();
if (bridge?.interruptSession) {
bridge.interruptSession(sessionId, trace);
bridge.interruptSession(...args);
return;
}
bridge?.writeToSession?.(sessionId, "\x03");
if (!options?.cancelPendingWritesOnly) bridge?.writeToSession?.(sessionId, "\x03");
}, []);
const resizeSession = useCallback((sessionId: string, cols: number, rows: number) => {
@@ -593,6 +599,7 @@ export const useTerminalBackend = () => {
getSessionDistroInfo,
getServerStats,
writeToSession,
notifyUserInput,
interruptSession,
resizeSession,
clearSessionPtyBuffer,
@@ -675,6 +682,7 @@ export const useTerminalBackend = () => {
getSessionDistroInfo,
getServerStats,
writeToSession,
notifyUserInput,
interruptSession,
resizeSession,
clearSessionPtyBuffer,
@@ -4,7 +4,7 @@ import React from "react";
import { act, create, type ReactTestRenderer } from "react-test-renderer";
import type { GroupConfig, ManagedSource } from "../../domain/models.ts";
import { useVaultGroupDeletion } from "./useVaultGroupDeletion.ts";
import { useVaultGroupDeletion, VaultGroupDeletionConfirmationChangedError } from "./useVaultGroupDeletion.ts";
test("group deletion removes saved settings in the same Vault transaction", async () => {
const actEnvironment = globalThis as typeof globalThis & {
@@ -132,7 +132,7 @@ test("group deletion restores managed files when the persisted Vault cannot be r
assert.equal(commitCount, 0);
});
test("group deletion retries file clearing when another managed source enters the group", async () => {
test("group deletion rejects retry when another managed source enters the group", async () => {
const actEnvironment = globalThis as typeof globalThis & {
IS_REACT_ACT_ENVIRONMENT?: boolean;
};
@@ -198,7 +198,7 @@ test("group deletion retries file clearing when another managed source enters th
renderer = create(React.createElement(Probe));
});
await act(async () => {
await deleteGroups?.(["Production"]);
await assert.rejects(deleteGroups!(["Production"]), VaultGroupDeletionConfirmationChangedError);
});
} finally {
await act(async () => {
@@ -209,10 +209,9 @@ test("group deletion retries file clearing when another managed source enters th
assert.deepEqual(clearedSourceIds, [
["managed-first"],
["managed-concurrent", "managed-first"],
]);
assert.equal(restoreCount, 1);
assert.equal(commitCount, 1);
assert.equal(commitCount, 0);
});
test("group deletion restores managed files and does not report success when commit fails", async () => {
@@ -278,3 +277,209 @@ test("group deletion restores managed files and does not report success when com
assert.equal(restoreCount, 1);
assert.equal(reportedCount, 0);
});
const confirmedSource: ManagedSource = {
id: "managed-production",
type: "ssh_config",
filePath: "/tmp/managed-production.conf",
groupName: "Production",
lastSyncedAt: 1,
};
async function withDeletionProbe(
options: Partial<Parameters<typeof useVaultGroupDeletion>[0]>,
run: (getDelete: () => ReturnType<typeof useVaultGroupDeletion>, rerender: (sources: ManagedSource[]) => Promise<void>) => Promise<void>,
) {
const environment = globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean };
const previous = environment.IS_REACT_ACT_ENVIRONMENT;
environment.IS_REACT_ACT_ENVIRONMENT = true;
let renderer: ReactTestRenderer | undefined;
let deleteGroups!: ReturnType<typeof useVaultGroupDeletion>;
const defaults: Parameters<typeof useVaultGroupDeletion>[0] = {
customGroups: ["Production"], hosts: [], groupConfigs: [],
managedSources: [confirmedSource],
onReadPersistedHosts: async () => [],
onReadPersistedManagedSources: () => [confirmedSource],
onCommitVaultGroupMutation: async (mutate) => mutate({
groups: ["Production"], configs: [], hosts: [],
managedSources: [confirmedSource], snippets: [],
}),
...options,
};
const Probe = ({ sources }: { sources: ManagedSource[] }) => {
deleteGroups = useVaultGroupDeletion({ ...defaults, managedSources: sources });
return null;
};
try {
await act(async () => {
renderer = create(<Probe sources={defaults.managedSources} />);
});
await run(() => deleteGroups, async (sources) => {
await act(async () => { renderer!.update(<Probe sources={sources} />); });
});
} finally {
await act(async () => { renderer?.unmount(); });
environment.IS_REACT_ACT_ENVIRONMENT = previous;
}
}
for (const [name, rendered, persisted] of [
["changed file path", [confirmedSource], [{ ...confirmedSource, filePath: "/tmp/other.conf" }]],
["changed source id", [confirmedSource], [{ ...confirmedSource, id: "replacement" }]],
["added source", [confirmedSource], [confirmedSource, { ...confirmedSource, id: "added", filePath: "/tmp/added.conf" }]],
["first source", [], [confirmedSource]],
["source moved up within selection", [{ ...confirmedSource, groupName: "Production/Sub" }], [confirmedSource]],
["source moved into selection", [{ ...confirmedSource, groupName: "Staging" }], [confirmedSource]],
] as const) {
test(`group deletion rejects initially persisted ${name} before clearing files`, async () => {
let clearCount = 0;
let commitCount = 0;
let reportedCount = 0;
await withDeletionProbe({
managedSources: [...rendered],
onReadPersistedManagedSources: () => [...persisted],
onClearAndRemoveManagedSources: async () => {
clearCount += 1;
return async () => {};
},
onCommitVaultGroupMutation: async () => {
commitCount += 1;
throw new Error("unexpected commit");
},
onDeletedPaths: () => { reportedCount += 1; },
}, async (getDelete) => {
// A failed attempt refreshes latestRef without refreshing the warning.
for (let attempt = 0; attempt < 2; attempt += 1) {
await assert.rejects(getDelete()(["Production"]), VaultGroupDeletionConfirmationChangedError);
}
});
assert.equal(clearCount, 0);
assert.equal(commitCount, 0);
assert.equal(reportedCount, 0);
});
}
test("group deletion keeps old callbacks bound to their rendered confirmation", async () => {
const changed = { ...confirmedSource, filePath: "/tmp/changed.conf" };
const cleared: string[] = [];
await withDeletionProbe({
onReadPersistedManagedSources: () => [changed],
onClearAndRemoveManagedSources: async (sources) => {
cleared.push(...sources.map((source) => source.filePath));
return async () => {};
},
onCommitVaultGroupMutation: async (mutate) => mutate({
groups: ["Production"], configs: [], hosts: [], managedSources: [changed], snippets: [],
}),
}, async (getDelete, rerender) => {
const staleDelete = getDelete();
await rerender([changed]);
assert.notEqual(getDelete(), staleDelete);
await assert.rejects(staleDelete(["Production"]), VaultGroupDeletionConfirmationChangedError);
assert.deepEqual(cleared, []);
await getDelete()(["Production"]);
});
assert.deepEqual(cleared, [changed.filePath]);
});
for (const retry of ["snapshot", "commit", "superseded"] as const) {
test(`group deletion retains safe ${retry} retries for the same confirmed file`, async () => {
const updated = { ...confirmedSource, lastSyncedAt: 2 };
let reads = 0;
let commits = 0;
let restores = 0;
let reported = 0;
const cleared: string[] = [];
await withDeletionProbe({
onReadPersistedManagedSources: () => {
reads += 1;
return [retry === "snapshot" && reads > 1 ? updated : confirmedSource];
},
onClearAndRemoveManagedSources: async (sources) => {
cleared.push(...sources.map((source) => source.filePath));
return async () => { restores += 1; };
},
onCommitVaultGroupMutation: async (mutate) => {
commits += 1;
if (retry === "superseded" && commits === 1) return { ok: false, superseded: true };
return mutate({
groups: ["Production"], configs: [], hosts: [], snippets: [],
managedSources: [retry === "snapshot" || (retry === "commit" && commits === 1) ? updated : confirmedSource],
});
},
onDeletedPaths: () => { reported += 1; },
}, async (getDelete) => { await getDelete()(["Production"]); });
assert.deepEqual(cleared, [confirmedSource.filePath, confirmedSource.filePath]);
assert.equal(restores, 1);
assert.equal(commits, retry === "snapshot" ? 1 : 2);
assert.equal(reported, 1);
});
}
for (const retry of ["commit", "superseded"] as const) {
test(`group deletion rejects new files after a ${retry} retry`, async () => {
const added = { ...confirmedSource, id: "added", filePath: "/tmp/added.conf" };
let persisted = [confirmedSource];
let commits = 0;
let restores = 0;
let reported = 0;
const cleared: string[] = [];
await withDeletionProbe({
onReadPersistedManagedSources: () => persisted,
onClearAndRemoveManagedSources: async (sources) => {
cleared.push(...sources.map((source) => source.filePath));
return async () => { restores += 1; };
},
onCommitVaultGroupMutation: async (mutate) => {
commits += 1;
persisted = [confirmedSource, added];
if (retry === "superseded") return { ok: false, superseded: true };
return mutate({
groups: ["Production"], configs: [], hosts: [], snippets: [], managedSources: persisted,
});
},
onDeletedPaths: () => { reported += 1; },
}, async (getDelete) => {
await assert.rejects(getDelete()(["Production"]), VaultGroupDeletionConfirmationChangedError);
});
assert.deepEqual(cleared, [confirmedSource.filePath]);
assert.equal(restores, 1);
assert.equal(commits, 1);
assert.equal(reported, 0);
});
}
for (const phase of ["snapshot", "commit"] as const) {
test(`group deletion rejects a managed subgroup moved to its parent during ${phase}`, async () => {
const subgroup = { ...confirmedSource, groupName: "Production/Sub" };
let persisted = [subgroup];
let reads = 0;
let restores = 0;
let commits = 0;
const cleared: string[] = [];
await withDeletionProbe({
managedSources: [subgroup],
onReadPersistedManagedSources: () => {
if (phase === "snapshot" && ++reads > 1) persisted = [confirmedSource];
return persisted;
},
onClearAndRemoveManagedSources: async (sources) => {
cleared.push(...sources.map((source) => source.groupName));
return async () => { restores += 1; };
},
onCommitVaultGroupMutation: async (mutate) => {
commits += 1;
persisted = [confirmedSource];
return mutate({
groups: ["Production", "Production/Sub"], configs: [], hosts: [],
managedSources: persisted, snippets: [],
});
},
}, async (getDelete) => {
await assert.rejects(getDelete()(["Production"]), VaultGroupDeletionConfirmationChangedError);
});
assert.deepEqual(cleared, ["Production/Sub"]);
assert.equal(restores, 1);
assert.equal(commits, phase === "snapshot" ? 0 : 1);
});
}
@@ -14,6 +14,17 @@ import {
const RETRY_VAULT_GROUP_DELETION = Symbol("retry-vault-group-deletion");
export class VaultGroupDeletionConfirmationChangedError extends Error {
constructor() {
super("The managed files affected by this deletion have changed. Review the files and confirm again.");
this.name = "VaultGroupDeletionConfirmationChangedError";
}
}
const managedSourceDeletionIdentity = (source: ManagedSource): string => (
JSON.stringify([source.id, source.type, source.filePath, source.groupName])
);
const managedSourceSnapshotsMatch = (
left: ManagedSource[],
right: ManagedSource[],
@@ -59,6 +70,14 @@ export function useVaultGroupDeletion({
additionallyDeletedHostIds: ReadonlySet<string> = new Set(),
) => {
const selectedPaths = [...paths];
// Bind confirmation to the sources rendered with this callback, not the
// latest persisted snapshot (which reads and retries update independently).
const confirmedFiles = new Set(buildVaultGroupDeletion({
...latestRef.current,
selectedPaths,
deleteHosts,
managedSources,
}).sourcesToRemove.map(managedSourceDeletionIdentity));
let deletedRoots: string[] = [];
while (true) {
let restoreManagedFiles: (() => Promise<void>) | undefined;
@@ -82,6 +101,12 @@ export function useVaultGroupDeletion({
return { status: "empty" as const };
}
if (deletion.sourcesToRemove.some((source) => (
!confirmedFiles.has(managedSourceDeletionIdentity(source))
))) {
throw new VaultGroupDeletionConfirmationChangedError();
}
if (deletion.sourcesToRemove.length > 0) {
if (onClearAndRemoveManagedSources) {
restoreManagedFiles = await onClearAndRemoveManagedSources(deletion.sourcesToRemove);
@@ -194,6 +219,7 @@ export function useVaultGroupDeletion({
}
onDeletedPaths?.(deletedRoots);
}, [
managedSources,
onClearAndRemoveManagedSource,
onClearAndRemoveManagedSources,
onCommitVaultGroupMutation,
@@ -0,0 +1,145 @@
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 {
collectSidePanelPanes,
createSidePanelLayout,
splitSidePanelPane,
type SidePanelLayout,
} from '../../domain/sidePanelLayout.ts';
import { STORAGE_KEY_WORKSPACE_LAYOUT_PRESET } from '../../infrastructure/config/storageKeys.ts';
import { useWorkspaceLayoutPresetState } from './useWorkspaceLayoutPresetState.ts';
function buildSplitLayout(): SidePanelLayout {
let layout = createSidePanelLayout('scripts', 'pane-scripts');
layout = splitSidePanelPane(layout, 'pane-scripts', 'sftp', 'horizontal', {
paneId: 'pane-sftp',
splitId: 'split-root',
}, 400);
return layout;
}
test('saved default resolves per session with fresh ids and protocol-aware pruning', async () => {
const values = new Map<string, string>();
const fakeStorage = {
getItem: (key: string) => values.get(key) ?? null,
setItem: (key: string, value: string) => { values.set(key, value); },
removeItem: (key: string) => { values.delete(key); },
};
const globals = globalThis as typeof globalThis & {
IS_REACT_ACT_ENVIRONMENT?: boolean;
localStorage?: typeof fakeStorage;
};
const previousActEnvironment = globals.IS_REACT_ACT_ENVIRONMENT;
const previousLocalStorage = Object.getOwnPropertyDescriptor(globalThis, 'localStorage');
globals.IS_REACT_ACT_ENVIRONMENT = true;
Object.defineProperty(globalThis, 'localStorage', {
value: fakeStorage,
configurable: true,
});
let state: ReturnType<typeof useWorkspaceLayoutPresetState> | null = null;
const Probe = () => {
state = useWorkspaceLayoutPresetState();
return null;
};
try {
await act(async () => {
create(React.createElement(Probe));
});
assert.ok(state);
// Nothing saved yet: no preset to apply.
assert.equal(state.resolveDefaultLayoutForSession({ protocol: 'ssh' }), null);
let saved = false;
await act(async () => {
saved = state?.saveWorkspaceLayoutAsDefault(buildSplitLayout()) ?? false;
});
assert.equal(saved, true);
assert.ok(values.has(STORAGE_KEY_WORKSPACE_LAYOUT_PRESET));
// SSH sessions keep their SFTP pane; ids are rekeyed per application.
const sshResolved = state.resolveDefaultLayoutForSession({ protocol: 'ssh' });
assert.ok(sshResolved);
assert.equal(sshResolved.protocol, 'ssh');
assert.deepEqual(
collectSidePanelPanes(sshResolved.layout.root).map((pane) => pane.tool),
['scripts', 'sftp'],
);
assert.notEqual(sshResolved.layout.root.id, buildSplitLayout().root.id);
assert.equal(sshResolved.focusedTool, 'sftp');
// Protocols that cannot serve SFTP lose that pane.
const telnetResolved = state.resolveDefaultLayoutForSession({ protocol: 'telnet' });
assert.ok(telnetResolved);
assert.deepEqual(
collectSidePanelPanes(telnetResolved.layout.root).map((pane) => pane.tool),
['scripts'],
);
assert.equal(telnetResolved.focusedTool, 'scripts');
// A second resolve rekeys again so parallel workspaces share no node ids.
const again = state.resolveDefaultLayoutForSession({ protocol: 'ssh' });
assert.ok(again);
assert.notEqual(again.layout.root.id, sshResolved.layout.root.id);
} finally {
if (previousLocalStorage) {
Object.defineProperty(globalThis, 'localStorage', previousLocalStorage);
} else {
delete (globalThis as { localStorage?: unknown }).localStorage;
}
globals.IS_REACT_ACT_ENVIRONMENT = previousActEnvironment;
}
});
test('saving falls back to false when persistence rejects the write', async () => {
const fakeStorage = {
getItem: () => null,
setItem: () => {
const quotaError = new DOMException('full', 'QuotaExceededError');
throw quotaError;
},
removeItem: () => undefined,
};
const globals = globalThis as typeof globalThis & {
IS_REACT_ACT_ENVIRONMENT?: boolean;
localStorage?: typeof fakeStorage;
};
const previousActEnvironment = globals.IS_REACT_ACT_ENVIRONMENT;
const previousLocalStorage = Object.getOwnPropertyDescriptor(globalThis, 'localStorage');
globals.IS_REACT_ACT_ENVIRONMENT = true;
Object.defineProperty(globalThis, 'localStorage', {
value: fakeStorage,
configurable: true,
});
let state: ReturnType<typeof useWorkspaceLayoutPresetState> | null = null;
const Probe = () => {
state = useWorkspaceLayoutPresetState();
return null;
};
try {
await act(async () => {
create(React.createElement(Probe));
});
assert.ok(state);
let saved = true;
await act(async () => {
saved = state?.saveWorkspaceLayoutAsDefault(buildSplitLayout()) ?? true;
});
assert.equal(saved, false);
} finally {
if (previousLocalStorage) {
Object.defineProperty(globalThis, 'localStorage', previousLocalStorage);
} else {
delete (globalThis as { localStorage?: unknown }).localStorage;
}
globals.IS_REACT_ACT_ENVIRONMENT = previousActEnvironment;
}
});
@@ -0,0 +1,69 @@
import { useCallback } from 'react';
import {
getFocusedSidePanelPane,
type SidePanelLayout,
type SidePanelTool,
} from '../../domain/sidePanelLayout';
import {
createWorkspaceLayoutPreset,
rekeySidePanelLayout,
withoutUnavailableSidePanelTools,
} from '../../domain/workspaceLayoutPreset';
import {
readWorkspaceLayoutPreset,
writeWorkspaceLayoutPreset,
} from './workspaceLayoutPresetStore';
/** Protocols whose sessions can actually serve an SFTP pane. */
function canServeSftp(protocol: string): boolean {
return protocol === 'local' || protocol === 'ssh' || protocol === 'mosh';
}
export type ResolvedWorkspaceLayoutPreset = {
layout: SidePanelLayout;
focusedTool: SidePanelTool;
/** Connection protocol used for availability filtering (`session.protocol ?? 'ssh'`). */
protocol: string;
};
/**
* Application boundary for the saved default workspace side panel layout.
* Owns the preset persistence lifecycle (read, validate, write) so UI
* components consume handlers instead of touching the store directly.
*/
export function useWorkspaceLayoutPresetState() {
/**
* Resolve the saved default preset into a fresh layout for a newly
* connected session. Panes the session cannot serve (SFTP on non-file
* protocols) are pruned, and ids are rekeyed so several workspaces can
* run the same preset in parallel. Returns null when there is no preset
* or nothing survives availability filtering.
*/
const resolveDefaultLayoutForSession = useCallback(
(session: { protocol?: string | null }): ResolvedWorkspaceLayoutPreset | null => {
const preset = readWorkspaceLayoutPreset();
if (!preset) return null;
const proto = session.protocol ?? 'ssh';
const usable = withoutUnavailableSidePanelTools(
preset.layout,
(tool) => tool !== 'sftp' || canServeSftp(proto),
);
if (!usable) return null;
const layout = rekeySidePanelLayout(usable, () => crypto.randomUUID());
return { layout, focusedTool: getFocusedSidePanelPane(layout).tool, protocol: proto };
},
[],
);
/** Validate and persist a live layout as the default. False when invalid or the write failed. */
const saveWorkspaceLayoutAsDefault = useCallback((layout: SidePanelLayout): boolean => {
const preset = createWorkspaceLayoutPreset(layout);
if (!preset) return false;
return writeWorkspaceLayoutPreset(preset);
}, []);
return { resolveDefaultLayoutForSession, saveWorkspaceLayoutAsDefault };
}
@@ -0,0 +1,18 @@
import { STORAGE_KEY_WORKSPACE_LAYOUT_PRESET } from '../../infrastructure/config/storageKeys';
import { localStorageAdapter } from '../../infrastructure/persistence/localStorageAdapter';
import {
sanitizeWorkspaceLayoutPreset,
type WorkspaceLayoutPreset,
} from '../../domain/workspaceLayoutPreset';
/**
* Saved default workspace side panel layout. Device-local only: it is not
* part of the sync payload because pane arrangements are client-side chrome.
*/
export function readWorkspaceLayoutPreset(): WorkspaceLayoutPreset | null {
return sanitizeWorkspaceLayoutPreset(localStorageAdapter.read(STORAGE_KEY_WORKSPACE_LAYOUT_PRESET));
}
export function writeWorkspaceLayoutPreset(preset: WorkspaceLayoutPreset): boolean {
return localStorageAdapter.write(STORAGE_KEY_WORKSPACE_LAYOUT_PRESET, preset);
}
+141
View File
@@ -1963,3 +1963,144 @@ test("applySyncPayload applies pluginSidecars through the production applier hoo
assert.equal((applied as SyncPayload["pluginSidecars"])?.entries[0].value.clock, 3);
});
test("local shell side panel settings round-trip independently and trigger auto-sync", async () => {
for (const key of [storageKeys.STORAGE_KEY_LOCAL_SHELL_SIDE_PANEL_AUTO_OPEN, storageKeys.STORAGE_KEY_LOCAL_SHELL_SIDE_PANEL_AUTO_OPEN_TAB]) {
assert.ok((SYNCABLE_SETTING_STORAGE_KEYS as readonly string[]).includes(key));
}
for (const enabled of [true, false]) {
localStorage.clear();
localStorage.setItem(storageKeys.STORAGE_KEY_LOCAL_SHELL_SIDE_PANEL_AUTO_OPEN, String(enabled));
localStorage.setItem(storageKeys.STORAGE_KEY_LOCAL_SHELL_SIDE_PANEL_AUTO_OPEN_TAB, "notes");
localStorage.setItem(storageKeys.STORAGE_KEY_TERMINAL_SIDE_PANEL_AUTO_OPEN, String(!enabled));
localStorage.setItem(storageKeys.STORAGE_KEY_TERMINAL_SIDE_PANEL_AUTO_OPEN_TAB, "scripts");
const payload = await buildSyncPayload(vault());
assert.equal(payload.settings?.localShellSidePanelAutoOpen, enabled);
assert.equal(payload.settings?.localShellSidePanelAutoOpenTab, "notes");
localStorage.clear();
await applySyncPayload(payload, { importVaultData: () => {} });
assert.equal(localStorage.getItem(storageKeys.STORAGE_KEY_LOCAL_SHELL_SIDE_PANEL_AUTO_OPEN), String(enabled));
assert.equal(localStorage.getItem(storageKeys.STORAGE_KEY_LOCAL_SHELL_SIDE_PANEL_AUTO_OPEN_TAB), "notes");
assert.equal(localStorage.getItem(storageKeys.STORAGE_KEY_TERMINAL_SIDE_PANEL_AUTO_OPEN), String(!enabled));
assert.equal(localStorage.getItem(storageKeys.STORAGE_KEY_TERMINAL_SIDE_PANEL_AUTO_OPEN_TAB), "scripts");
}
});
test("missing local shell settings and invalid tabs preserve existing settings", async () => {
const payload = await buildSyncPayload(vault());
assert.equal(payload.settings?.localShellSidePanelAutoOpen, undefined);
assert.equal(payload.settings?.localShellSidePanelAutoOpenTab, undefined);
localStorage.setItem(storageKeys.STORAGE_KEY_LOCAL_SHELL_SIDE_PANEL_AUTO_OPEN, "true");
localStorage.setItem(storageKeys.STORAGE_KEY_LOCAL_SHELL_SIDE_PANEL_AUTO_OPEN_TAB, "notes");
await applySyncPayload(payload, { importVaultData: () => {} });
assert.equal(localStorage.getItem(storageKeys.STORAGE_KEY_LOCAL_SHELL_SIDE_PANEL_AUTO_OPEN), "true");
assert.equal(localStorage.getItem(storageKeys.STORAGE_KEY_LOCAL_SHELL_SIDE_PANEL_AUTO_OPEN_TAB), "notes");
await applySyncPayload({ ...payload, settings: { localShellSidePanelAutoOpenTab: "invalid" } } as unknown as SyncPayload, { importVaultData: () => {} });
assert.equal(localStorage.getItem(storageKeys.STORAGE_KEY_LOCAL_SHELL_SIDE_PANEL_AUTO_OPEN_TAB), "notes");
localStorage.setItem(storageKeys.STORAGE_KEY_LOCAL_SHELL_SIDE_PANEL_AUTO_OPEN_TAB, "invalid");
assert.equal((await buildSyncPayload(vault())).settings?.localShellSidePanelAutoOpenTab, undefined);
});
test("tab bar position survives settings export and import, with a safe fallback", async () => {
localStorage.clear();
localStorage.setItem(storageKeys.STORAGE_KEY_TAB_BAR_POSITION, "bottom");
const payload = buildSyncPayload(vault([]));
assert.equal(payload.settings?.tabBarPosition, "bottom");
localStorage.removeItem(storageKeys.STORAGE_KEY_TAB_BAR_POSITION);
await applySyncPayload(payload, { importVaultData: () => {} });
assert.equal(localStorage.getItem(storageKeys.STORAGE_KEY_TAB_BAR_POSITION), "bottom");
const invalid = { ...payload, settings: { tabBarPosition: "left" } } as unknown as SyncPayload;
await applySyncPayload(invalid, { importVaultData: () => {} });
assert.equal(localStorage.getItem(storageKeys.STORAGE_KEY_TAB_BAR_POSITION), "top");
localStorage.setItem(storageKeys.STORAGE_KEY_TAB_BAR_POSITION, "bottom");
await applySyncPayload({ ...payload, settings: {} }, { importVaultData: () => {} });
assert.equal(localStorage.getItem(storageKeys.STORAGE_KEY_TAB_BAR_POSITION), "bottom");
});
for (const enabled of [true, false]) {
test(`right-click long press preference survives sync (${enabled})`, async () => {
localStorage.setItem(storageKeys.STORAGE_KEY_TERM_SETTINGS,
JSON.stringify({ rightClickLongPressMenu: enabled }));
const payload = buildSyncPayload(vault());
assert.equal(payload.settings?.terminalSettings?.rightClickLongPressMenu, enabled);
localStorage.setItem(storageKeys.STORAGE_KEY_TERM_SETTINGS,
JSON.stringify({ rightClickLongPressMenu: !enabled }));
await applySyncPayload(payload, { importVaultData: () => {} });
const restored = JSON.parse(localStorage.getItem(storageKeys.STORAGE_KEY_TERM_SETTINGS)!);
assert.equal(restored.rightClickLongPressMenu, enabled);
});
}
test('custom provider headers use portable cloud secrets and local encryption with legacy preservation', async () => {
localStorage.clear();
const sealed = `enc:v1:${Buffer.concat([Buffer.from('v10'), Buffer.alloc(32, 2)]).toString('base64')}`;
Object.defineProperty(globalThis, 'window', { configurable: true, value: {
netcatty: {
credentialsEncrypt: async () => sealed,
credentialsDecrypt: async (value: string) => value === sealed ? 'tenant-secret' : value,
},
dispatchEvent: () => true,
} });
localStorage.setItem(storageKeys.STORAGE_KEY_AI_PROVIDERS, JSON.stringify([{
id: 'custom-headers', providerId: 'custom', enabled: true, customHeaders: { 'X-Tenant': sealed },
}]));
const plainSettings = buildSyncPayload(vault([]));
assert.equal(plainSettings.settings?.ai?.providers?.[0]?.customHeaders, undefined);
const cloud = await buildCloudSyncPayload(vault([]));
assert.deepEqual(cloud.settings?.ai?.providers?.[0]?.customHeaders, { 'X-Tenant': 'tenant-secret' });
await applySyncPayload(cloud, { importVaultData: () => {} });
const read = () => JSON.parse(localStorage.getItem(storageKeys.STORAGE_KEY_AI_PROVIDERS)!)[0];
assert.deepEqual(read().customHeaders, { 'X-Tenant': sealed });
await applySyncPayload(plainSettings, { importVaultData: () => {} });
assert.deepEqual(read().customHeaders, { 'X-Tenant': sealed });
cloud.settings!.ai!.providers![0].customHeaders = {};
await applySyncPayload(cloud, { importVaultData: () => {} });
assert.deepEqual(read().customHeaders, {});
});
for (const localRestore of [false, true]) {
test(`header encryption failure leaves vault and settings untouched (${localRestore ? 'restore' : 'sync'})`, async () => {
localStorage.clear();
localStorage.setItem(storageKeys.STORAGE_KEY_THEME, 'light');
localStorage.setItem(storageKeys.STORAGE_KEY_AI_PROVIDERS, '[]');
Object.defineProperty(globalThis, 'window', { configurable: true, value: {
netcatty: { credentialsEncrypt: async () => { throw new Error('keychain locked'); } },
dispatchEvent: () => true,
} });
const payload: SyncPayload = { ...vault([]), syncedAt: 1, settings: {
theme: 'dark', ai: { providers: [{ id: 'custom', customHeaders: { Authorization: 'secret' } }] },
} };
const original = JSON.stringify(payload);
let imports = 0;
let completed = 0;
let commits = 0;
const importers = { importVaultData: () => { imports++; }, onSettingsApplied: () => { completed++; } };
await assert.rejects(localRestore
? applyLocalVaultPayload(payload, importers, { prepareConvergentRestore: async () => async () => { commits++; } })
: applySyncPayload(payload, importers));
assert.equal(imports, 0);
assert.equal(completed, 0);
assert.equal(commits, 0);
assert.equal(localStorage.getItem(storageKeys.STORAGE_KEY_THEME), 'light');
assert.equal(localStorage.getItem(storageKeys.STORAGE_KEY_AI_PROVIDERS), '[]');
assert.equal(JSON.stringify(payload), original);
});
}
test('sync prepares header encryption once before importing and leaves input unchanged', async () => {
localStorage.clear();
const sealed = `enc:v1:${Buffer.concat([Buffer.from('v10'), Buffer.alloc(32, 4)]).toString('base64')}`;
const events: string[] = [];
Object.defineProperty(globalThis, 'window', { configurable: true, value: {
netcatty: { credentialsEncrypt: async () => { events.push('encrypt'); return sealed; } },
dispatchEvent: () => true,
} });
const payload: SyncPayload = { ...vault([]), syncedAt: 1, settings: {
theme: 'dark', ai: { providers: [{ id: 'custom', customHeaders: { 'X-Tenant': 'secret' } }] },
} };
const original = JSON.stringify(payload);
await applySyncPayload(payload, { importVaultData: () => { events.push('import'); } });
assert.deepEqual(events, ['encrypt', 'import']);
assert.equal(JSON.stringify(payload), original);
assert.deepEqual(JSON.parse(localStorage.getItem(storageKeys.STORAGE_KEY_AI_PROVIDERS)!)[0].customHeaders, { 'X-Tenant': sealed });
});
+75 -21
View File
@@ -1,3 +1,5 @@
import { normalizeTabBarPosition } from '../domain/tabBarPosition';
import { decryptProviderHeaders, encryptProviderHeaders } from '../infrastructure/ai/providerHeaderCredentials';
/**
* Sync Payload Builders - Single source of truth for constructing and applying
* the encrypted cloud-sync payload.
@@ -76,6 +78,8 @@ import {
STORAGE_KEY_TERM_SETTINGS,
STORAGE_KEY_TERMINAL_SIDE_PANEL_AUTO_OPEN,
STORAGE_KEY_TERMINAL_SIDE_PANEL_AUTO_OPEN_TAB,
STORAGE_KEY_LOCAL_SHELL_SIDE_PANEL_AUTO_OPEN,
STORAGE_KEY_LOCAL_SHELL_SIDE_PANEL_AUTO_OPEN_TAB,
STORAGE_KEY_CUSTOM_KEY_BINDINGS,
STORAGE_KEY_EDITOR_WORD_WRAP,
STORAGE_KEY_SFTP_DOUBLE_CLICK_BEHAVIOR,
@@ -92,6 +96,7 @@ import {
STORAGE_KEY_HOST_CLICK_BEHAVIOR,
STORAGE_KEY_SHOW_ONLY_UNGROUPED_HOSTS_IN_ROOT,
STORAGE_KEY_SHOW_SFTP_TAB,
STORAGE_KEY_TAB_BAR_POSITION,
STORAGE_KEY_SHOW_HOST_TREE_SIDEBAR,
STORAGE_KEY_SHELL_ONLY_TAB_NUMBER_SHORTCUTS,
STORAGE_KEY_SHOW_TAB_NUMBER_BADGES,
@@ -259,11 +264,12 @@ const SYNCABLE_TERMINAL_KEYS = [
'kittyKeyboardProtocolEnabled',
'scrollOnInput', 'scrollOnOutput', 'scrollOnKeyPress', 'scrollOnPaste',
'smoothScrolling',
'rightClickBehavior', 'showContextMenuOverFullscreenApps', 'middleClickBehavior', 'copyOnSelect', 'normalizeTextOnCopy', 'middleClickPaste', 'wordSeparators',
'rightClickBehavior', 'rightClickLongPressMenu', 'showContextMenuOverFullscreenApps', 'middleClickBehavior', 'copyOnSelect', 'normalizeTextOnCopy', 'middleClickPaste', 'wordSeparators',
'linkModifier', 'keywordHighlightEnabled', 'keywordHighlightRules',
'keepaliveInterval', 'keepaliveCountMax', 'disableBracketedPaste', 'clearWipesScrollback',
'autoUploadClipboardImageOnPaste',
'preserveSelectionOnInput', 'forcePromptNewLine', 'osc52Clipboard', 'oscNotifications', 'dynamicTabTitleMode',
'confirmBeforeMultilinePaste', 'multilinePasteConfirmMinLines',
'preserveSelectionOnInput', 'forcePromptNewLine', 'osc52Clipboard', 'oscNotifications', 'dynamicTabTitleMode', 'tabDoubleClickBehavior',
'autoCloseOnExit', 'disconnectedNoticeMode',
'showHostInfoBar', 'hostInfoBarTitleMode', 'showServerStats',
'serverStatsRefreshInterval',
@@ -299,6 +305,8 @@ export const SYNCABLE_SETTING_STORAGE_KEYS = [
STORAGE_KEY_TERM_SETTINGS,
STORAGE_KEY_TERMINAL_SIDE_PANEL_AUTO_OPEN,
STORAGE_KEY_TERMINAL_SIDE_PANEL_AUTO_OPEN_TAB,
STORAGE_KEY_LOCAL_SHELL_SIDE_PANEL_AUTO_OPEN,
STORAGE_KEY_LOCAL_SHELL_SIDE_PANEL_AUTO_OPEN_TAB,
STORAGE_KEY_CUSTOM_THEMES,
STORAGE_KEY_CUSTOM_KEY_BINDINGS,
STORAGE_KEY_EDITOR_WORD_WRAP,
@@ -315,6 +323,7 @@ export const SYNCABLE_SETTING_STORAGE_KEYS = [
STORAGE_KEY_HOST_CLICK_BEHAVIOR,
STORAGE_KEY_SHOW_ONLY_UNGROUPED_HOSTS_IN_ROOT,
STORAGE_KEY_SHOW_SFTP_TAB,
STORAGE_KEY_TAB_BAR_POSITION,
STORAGE_KEY_SHELL_ONLY_TAB_NUMBER_SHORTCUTS,
STORAGE_KEY_SHOW_TAB_NUMBER_BADGES,
STORAGE_KEY_WORKSPACE_FOCUS_STYLE,
@@ -399,13 +408,13 @@ const mergeAiProvidersPreservingLocalApiKeys = (
if (typeof provider?.id === 'string') localById.set(provider.id, provider);
}
return incoming.map((provider) => {
if (provider.apiKey != null) return provider;
const id = typeof provider.id === 'string' ? provider.id : undefined;
const localProvider = id != null ? localById.get(id) : undefined;
if (localProvider && typeof localProvider.apiKey === 'string') {
return { ...provider, apiKey: localProvider.apiKey };
}
return provider;
return {
...provider,
...(provider.apiKey == null && typeof localProvider?.apiKey === 'string' ? { apiKey: localProvider.apiKey } : {}),
...(provider.customHeaders == null && localProvider?.customHeaders != null ? { customHeaders: localProvider.customHeaders } : {}),
};
});
};
@@ -477,6 +486,12 @@ export function collectSyncableSettings(): SyncPayload['settings'] {
if (isTerminalSidePanelAutoOpenTab(terminalSidePanelAutoOpenTab)) {
settings.terminalSidePanelAutoOpenTab = terminalSidePanelAutoOpenTab;
}
const localShellSidePanelAutoOpen = localStorageAdapter.readBoolean(STORAGE_KEY_LOCAL_SHELL_SIDE_PANEL_AUTO_OPEN);
if (localShellSidePanelAutoOpen != null) settings.localShellSidePanelAutoOpen = localShellSidePanelAutoOpen;
const localShellSidePanelAutoOpenTab = localStorageAdapter.readString(STORAGE_KEY_LOCAL_SHELL_SIDE_PANEL_AUTO_OPEN_TAB);
if (isTerminalSidePanelAutoOpenTab(localShellSidePanelAutoOpenTab)) {
settings.localShellSidePanelAutoOpenTab = localShellSidePanelAutoOpenTab;
}
// Terminal settings (syncable subset only)
const termSettingsRaw = localStorageAdapter.readString(STORAGE_KEY_TERM_SETTINGS);
@@ -542,6 +557,8 @@ export function collectSyncableSettings(): SyncPayload['settings'] {
}
const showOnlyUngroupedHostsInRoot = localStorageAdapter.readBoolean(STORAGE_KEY_SHOW_ONLY_UNGROUPED_HOSTS_IN_ROOT);
if (showOnlyUngroupedHostsInRoot != null) settings.showOnlyUngroupedHostsInRoot = showOnlyUngroupedHostsInRoot;
const tabBarPosition = localStorageAdapter.readString(STORAGE_KEY_TAB_BAR_POSITION);
if (tabBarPosition != null) settings.tabBarPosition = normalizeTabBarPosition(tabBarPosition);
const showSftpTab = localStorageAdapter.readBoolean(STORAGE_KEY_SHOW_SFTP_TAB);
if (showSftpTab != null) settings.showSftpTab = showSftpTab;
const shellOnlyTabNumberShortcuts = localStorageAdapter.readBoolean(STORAGE_KEY_SHELL_ONLY_TAB_NUMBER_SHORTCUTS);
@@ -559,7 +576,11 @@ export function collectSyncableSettings(): SyncPayload['settings'] {
const ai: NonNullable<SyncPayload['settings']>['ai'] = {};
const providers = readArraySetting(STORAGE_KEY_AI_PROVIDERS);
if (providers) ai.providers = providers.map(stripDeviceBoundApiKey);
if (providers) ai.providers = providers.map((provider) => {
const next = { ...stripDeviceBoundApiKey(provider) };
delete next.customHeaders;
return next;
});
const activeProviderId = localStorageAdapter.readString(STORAGE_KEY_AI_ACTIVE_PROVIDER);
if (activeProviderId != null) ai.activeProviderId = activeProviderId;
const activeModelId = localStorageAdapter.readString(STORAGE_KEY_AI_ACTIVE_MODEL);
@@ -621,7 +642,12 @@ export async function collectCloudSyncableSettings(): Promise<SyncPayload['setti
};
if (providers) {
ai.providers = await Promise.all(providers.map(withPortableApiKey));
ai.providers = await Promise.all(providers.map(async (provider) => {
const next = await withPortableApiKey(provider);
return isRecord(provider.customHeaders)
? { ...next, customHeaders: await decryptProviderHeaders(provider.customHeaders as Record<string, string>) }
: next;
}));
}
if (webSearchConfig) {
ai.webSearchConfig = await withPortableApiKey(webSearchConfig);
@@ -658,7 +684,10 @@ function collectLocalBackupSettings(): SyncPayload['settings'] {
* Apply synced settings to localStorage. Merges terminal settings
* to preserve platform-specific fields.
*/
async function applySyncableSettings(settings: NonNullable<SyncPayload['settings']>): Promise<void> {
async function applySyncableSettings(
settings: NonNullable<SyncPayload['settings']>,
preparedProviders: Record<string, unknown>[] | undefined,
): Promise<void> {
// Theme & Appearance
if (settings.theme != null) localStorageAdapter.writeString(STORAGE_KEY_THEME, settings.theme);
if (settings.lightUiThemeId != null) localStorageAdapter.writeString(STORAGE_KEY_UI_THEME_LIGHT, settings.lightUiThemeId);
@@ -714,6 +743,12 @@ async function applySyncableSettings(settings: NonNullable<SyncPayload['settings
if (isTerminalSidePanelAutoOpenTab(settings.terminalSidePanelAutoOpenTab)) {
localStorageAdapter.writeString(STORAGE_KEY_TERMINAL_SIDE_PANEL_AUTO_OPEN_TAB, settings.terminalSidePanelAutoOpenTab);
}
if (settings.localShellSidePanelAutoOpen != null) {
localStorageAdapter.writeBoolean(STORAGE_KEY_LOCAL_SHELL_SIDE_PANEL_AUTO_OPEN, settings.localShellSidePanelAutoOpen);
}
if (isTerminalSidePanelAutoOpenTab(settings.localShellSidePanelAutoOpenTab)) {
localStorageAdapter.writeString(STORAGE_KEY_LOCAL_SHELL_SIDE_PANEL_AUTO_OPEN_TAB, settings.localShellSidePanelAutoOpenTab);
}
// Terminal settings - merge with existing to preserve platform-specific keys
if (settings.terminalSettings) {
@@ -795,6 +830,9 @@ async function applySyncableSettings(settings: NonNullable<SyncPayload['settings
settings.showOnlyUngroupedHostsInRoot,
);
}
if (settings.tabBarPosition != null) {
localStorageAdapter.writeString(STORAGE_KEY_TAB_BAR_POSITION, normalizeTabBarPosition(settings.tabBarPosition));
}
if (settings.showSftpTab != null) {
localStorageAdapter.writeBoolean(STORAGE_KEY_SHOW_SFTP_TAB, settings.showSftpTab);
}
@@ -816,11 +854,10 @@ async function applySyncableSettings(settings: NonNullable<SyncPayload['settings
const ai = settings.ai;
if (ai) {
if (ai.providers != null) {
const providers = await Promise.all(ai.providers.map(withLocalEncryptedApiKey));
if (preparedProviders != null) {
localStorageAdapter.write(
STORAGE_KEY_AI_PROVIDERS,
mergeAiProvidersPreservingLocalApiKeys(providers),
mergeAiProvidersPreservingLocalApiKeys(preparedProviders),
);
}
if (ai.activeProviderId != null) localStorageAdapter.writeString(STORAGE_KEY_AI_ACTIVE_PROVIDER, ai.activeProviderId);
@@ -1137,7 +1174,7 @@ export async function buildLocalVaultPayloadAsync(
* This ensures both vault data and port-forwarding rules are imported
* consistently across windows.
*/
function applyPayload(
async function preparePayloadApply(
payload: SyncPayload,
importers: SyncPayloadImporters,
options: {
@@ -1145,11 +1182,20 @@ function applyPayload(
applyPluginSidecars?: PluginSyncSidecarApplier;
currentHosts?: Host[];
},
): Promise<void> {
): Promise<() => Promise<void>> {
// Portable payloads must never keep device-bound enc:v1 blobs. Strip them
// so a previously poisoned cloud/backup snapshot can still restore host
// shells and let the user re-enter secrets (#2702).
const sanitizedPayload = stripSyncPayloadEncryptedCredentials(payload);
// Prepare credentials before any vault or settings mutation. Encryption can
// fail when local credential storage is unavailable; reuse the result below.
const providers = sanitizedPayload.settings?.ai?.providers;
const preparedProviders = providers == null ? undefined : await Promise.all(providers.map(async (provider) => {
const next = await withLocalEncryptedApiKey(provider);
return isRecord(provider.customHeaders)
? { ...next, customHeaders: await encryptProviderHeaders(await decryptProviderHeaders(provider.customHeaders as Record<string, string>)) }
: next;
}));
const legacyLineTimestampsEnabled = sanitizedPayload.settings?.terminalSettings?.showLineTimestamps === true;
let hosts = migrateHostsFromLegacyLineTimestamps(sanitizedPayload.hosts, legacyLineTimestampsEnabled);
if (!options.includeLocalOnlyData) {
@@ -1181,7 +1227,7 @@ function applyPayload(
vaultImport.groupConfigs = sanitizedPayload.groupConfigs;
}
return Promise.resolve(importers.importVaultData(JSON.stringify(vaultImport))).then(async () => {
return () => Promise.resolve(importers.importVaultData(JSON.stringify(vaultImport))).then(async () => {
// Only import port-forwarding rules when the payload explicitly carries
// them. Absent field = "payload was created before this feature existed",
// so local rules are preserved. Explicitly present [] = "remote has no
@@ -1192,7 +1238,7 @@ function applyPayload(
// Apply synced settings
if (sanitizedPayload.settings) {
await applySyncableSettings(sanitizedPayload.settings);
await applySyncableSettings(sanitizedPayload.settings, preparedProviders);
// Rehydrate in-memory bookmark snapshot after localStorage was updated
if (sanitizedPayload.settings.sftpGlobalBookmarks != null) rehydrateGlobalSftpBookmarks();
importers.onSettingsApplied?.();
@@ -1207,21 +1253,28 @@ function applyPayload(
});
}
export function applySyncPayload(
export function prepareSyncPayloadApply(
payload: SyncPayload,
importers: SyncPayloadImporters,
options?: {
applyPluginSidecars?: PluginSyncSidecarApplier;
currentHosts?: Host[];
},
): Promise<void> {
return applyPayload(payload, importers, {
): Promise<() => Promise<void>> {
return preparePayloadApply(payload, importers, {
includeLocalOnlyData: false,
applyPluginSidecars: options?.applyPluginSidecars,
currentHosts: options?.currentHosts,
});
}
export async function applySyncPayload(
...args: Parameters<typeof prepareSyncPayloadApply>
): Promise<void> {
const applyPreparedPayload = await prepareSyncPayloadApply(...args);
await applyPreparedPayload();
}
export async function prepareLocalVaultPayloadApply(
payload: SyncPayload,
importers: SyncPayloadImporters,
@@ -1236,9 +1289,10 @@ export async function prepareLocalVaultPayloadApply(
const sanitizedPayload = stripSyncPayloadEncryptedCredentials(payload);
const prepareConvergentRestore = dependencies.prepareConvergentRestore
?? prepareRestoredPayloadConvergentWrites;
const applyPreparedPayload = await preparePayloadApply(sanitizedPayload, importers, { includeLocalOnlyData: true });
const commitConvergentRestore = await prepareConvergentRestore(sanitizedPayload);
return async () => {
await applyPayload(sanitizedPayload, importers, { includeLocalOnlyData: true });
await applyPreparedPayload();
await commitConvergentRestore();
};
}
+1 -1
View File
@@ -189,7 +189,7 @@ test('normalizeStoredAgentModelSelection keeps bare CodeBuddy ids unsuffixed', (
const presets: AgentModelPreset[] = [{
id: 'glm-5.1',
name: 'GLM 5.1',
thinkingLevels: ['low', 'medium', 'high', 'xhigh'],
thinkingLevels: ['minimal', 'low', 'medium', 'high', 'xhigh', 'max'],
defaultThinkingLevel: 'medium',
encodeDefaultThinking: false,
}];
+5 -5
View File
@@ -50,7 +50,7 @@ import { CloudSyncDashboardTabs } from './cloud-sync/CloudSyncDashboardTabs';
interface SyncDashboardProps {
onBuildPayload: () => SyncPayload | Promise<SyncPayload>;
onBuildLocalPayload: () => SyncPayload;
onApplyMigrationPayload: (payload: SyncPayload) => void | Promise<void>;
onPrepareMigrationPayload: (payload: SyncPayload) => Promise<() => Promise<void>>;
onApplyPayload: (payload: SyncPayload) => void | Promise<void>;
onApplyConvergentPayload: (
payload: SyncPayload,
@@ -63,7 +63,7 @@ interface SyncDashboardProps {
const SyncDashboard: React.FC<SyncDashboardProps> = ({
onBuildPayload,
onBuildLocalPayload,
onApplyMigrationPayload,
onPrepareMigrationPayload,
onApplyPayload,
onApplyConvergentPayload,
onApplyLocalPayload,
@@ -336,7 +336,7 @@ const SyncDashboard: React.FC<SyncDashboardProps> = ({
prepared: preparedConvergentMigration,
buildCurrentPayload: onBuildPayload,
buildPreApplyPayload: onBuildLocalPayload,
applyPayload: onApplyMigrationPayload,
preparePayloadApply: onPrepareMigrationPayload,
translateProtectiveBackupFailure: (message) =>
t('cloudSync.localBackups.protectiveBackupFailed', { message }),
});
@@ -349,7 +349,7 @@ const SyncDashboard: React.FC<SyncDashboardProps> = ({
} finally {
setConvergentBusy(false);
}
}, [onApplyMigrationPayload, onBuildLocalPayload, onBuildPayload, preparedConvergentMigration, sync, t]);
}, [onPrepareMigrationPayload, onBuildLocalPayload, onBuildPayload, preparedConvergentMigration, sync, t]);
const handleResolveConvergentConflict = useCallback(async (
addressKey: string,
@@ -1053,7 +1053,7 @@ const SyncDashboard: React.FC<SyncDashboardProps> = ({
interface CloudSyncSettingsProps {
onBuildPayload: () => SyncPayload | Promise<SyncPayload>;
onBuildLocalPayload: () => SyncPayload;
onApplyMigrationPayload: (payload: SyncPayload) => void | Promise<void>;
onPrepareMigrationPayload: (payload: SyncPayload) => Promise<() => Promise<void>>;
onApplyPayload: (payload: SyncPayload) => void | Promise<void>;
onApplyConvergentPayload: (
payload: SyncPayload,
+17
View File
@@ -480,3 +480,20 @@ test("HostTreeView announces sibling position within each tree level", () => {
assert.equal(byKey.get("host:host-c")?.posInSet, 1);
assert.equal(byKey.get("host:host-c")?.setSize, 1);
});
test("HostTreeView IP sorting stays inside each group and leaves manual order intact", () => {
const pair = (prefix: string, group = ""): Host[] => [
{ ...baseHost, id: `${prefix}-ten`, label: "Alpha", hostname: "10.0.0.10", group, order: 1 },
{ ...baseHost, id: `${prefix}-two`, label: "Zulu", hostname: "10.0.0.2", group, order: 2 },
];
const groupTree: GroupNode[] = [{
name: "prod", path: "prod", hosts: pair("prod", "prod"),
children: { east: { name: "east", path: "prod/east", hosts: pair("east", "prod/east"), children: {} } },
}];
const options = {
groupTree, ungroupedHosts: pair("root"), expandedPaths: new Set(["prod", "prod/east"]), groupConfigs: [],
};
const keys = (sortMode: "ip" | "manual") => buildVisibleHostTreeItems({ ...options, sortMode }).map(item => item.key);
assert.deepEqual(keys("ip"), ["group:prod", "group:prod/east", "host:east-two", "host:east-ten", "host:prod-two", "host:prod-ten", "host:root-two", "host:root-ten"]);
assert.deepEqual(keys("manual"), ["group:prod", "group:prod/east", "host:east-ten", "host:east-two", "host:prod-ten", "host:prod-two", "host:root-ten", "host:root-two"]);
});
+3 -1
View File
@@ -1,3 +1,4 @@
import { compareHostAddresses } from "../domain/hostAddressSort";
import { CheckSquare, Edit2, FileSymlink, Server, Square, Expand, Minimize2 } from 'lucide-react';
import { useVirtualizer } from '@tanstack/react-virtual';
import React, { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react';
@@ -39,7 +40,7 @@ const getTreeGroupDropIntent = (
const hasDragType = (dataTransfer: DataTransfer, type: string) =>
Array.from(dataTransfer.types).includes(type);
type HostTreeSortMode = 'manual' | 'az' | 'za' | 'newest' | 'oldest' | 'group';
type HostTreeSortMode = 'manual' | 'az' | 'za' | 'newest' | 'oldest' | 'group' | 'ip';
export type VisibleHostTreeItem =
| {
@@ -90,6 +91,7 @@ const sortHostTreeHosts = (hosts: Host[], sortMode: HostTreeSortMode): Host[] =>
if (sortMode === 'newest') return (b.createdAt || 0) - (a.createdAt || 0);
if (sortMode === 'oldest') return (a.createdAt || 0) - (b.createdAt || 0);
if (sortMode === 'manual') return 0;
if (sortMode === 'ip') return compareHostAddresses(a.hostname, b.hostname) || a.label.localeCompare(b.label);
return a.label.localeCompare(b.label);
});
return sortMode === 'manual' ? sortByVaultOrder(sorted) : sorted;
+1 -1
View File
@@ -880,7 +880,7 @@ const PortForwarding: React.FC<PortForwardingProps> = ({
<SortDropdown
value={sortMode}
onChange={(mode) => {
if (mode !== "group") setSortMode(mode);
if (mode !== "group" && mode !== "ip") setSortMode(mode);
}}
modes={["manual", "az", "za", "newest", "oldest"]}
className={vaultHeaderIconButtonClass}
+5 -4
View File
@@ -16,6 +16,7 @@ import { formatHostPort } from "../domain/host";
import {
buildQuickConnectHost,
getQuickConnectDefaultPort,
isQuickConnectIdentityCompatible,
isQuickConnectIdentityUsable,
type QuickConnectAuthMethod,
type QuickConnectProtocol,
@@ -104,13 +105,13 @@ const QuickConnectWizard: React.FC<QuickConnectWizardProps> = ({
[keys, protocol, selectedIdentity],
);
const identityOptions = useMemo(
() => identities.map((identity) => ({
() => identities.filter((identity) => isQuickConnectIdentityCompatible(identity, target)).map((identity) => ({
value: identity.id,
label: identity.label,
sublabel: identity.username,
icon: <User size={14} />,
})),
[identities],
[identities, target],
);
const detachSelectedIdentity = () => {
@@ -131,7 +132,7 @@ const QuickConnectWizard: React.FC<QuickConnectWizardProps> = ({
return;
}
const identity = identities.find((candidate) => candidate.id === identityId);
if (!identity) return;
if (!identity || !isQuickConnectIdentityCompatible(identity, target)) return;
setSelectedIdentityId(identity.id);
setUsername(identity.username);
setAuthMethod(identity.authMethod);
@@ -431,7 +432,7 @@ const QuickConnectWizard: React.FC<QuickConnectWizardProps> = ({
</div>
</div>
{protocol !== "telnet" && identities.length > 0 && (
{protocol !== "telnet" && identityOptions.length > 0 && (
<div className="flex flex-col gap-4">
<div className="flex flex-col gap-3">
<Label className="text-sm font-medium">{t("quickConnect.identity.label")}</Label>
+3
View File
@@ -1,3 +1,4 @@
import { compareHostAddresses } from "../domain/hostAddressSort";
import {
Check,
CheckSquare,
@@ -200,6 +201,8 @@ export const SelectHostPanelContent: React.FC<SelectHostPanelContentProps> = ({
result = [...result].sort((a, b) => {
switch (sortMode) {
case 'ip':
return compareHostAddresses(a.hostname, b.hostname) || a.label.localeCompare(b.label);
case 'az':
return a.label.localeCompare(b.label);
case 'za':
+80 -1
View File
@@ -2,7 +2,7 @@
* Serial Host Details Panel
* A dedicated editor for serial port hosts (distinct from SSH HostDetailsPanel)
*/
import { ChevronDown, ChevronUp, Save, Tag, Usb } from 'lucide-react';
import { ChevronDown, ChevronUp, Eye, EyeOff, Save, Tag, Usb, X } from 'lucide-react';
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { useI18n } from '../application/i18n/I18nProvider';
import { useTerminalBackend } from '../application/state/useTerminalBackend';
@@ -75,9 +75,22 @@ export const SerialHostDetailsPanel: React.FC<SerialHostDetailsPanelPropsWithRes
const [ports, setPorts] = useState<SerialPort[]>([]);
const [isLoadingPorts, setIsLoadingPorts] = useState(false);
const [showAdvanced, setShowAdvanced] = useState(false);
const [showPassword, setShowPassword] = useState(false);
// Form state
const [label, setLabel] = useState(initialData.label);
const [username, setUsername] = useState(initialData.username || '');
const [password, setPassword] = useState(initialData.password || '');
// Tracks whether the user edited the password field this session. An empty
// password is a meaningful serial credential (the auto-login detector
// submits a blank line at a Password prompt), so an explicit edit to blank
// must be saved as '' while an untouched field preserves the stored value.
const [passwordChanged, setPasswordChanged] = useState(false);
// Explicit "remove the saved credential" flag. An empty password field is a
// meaningful serial credential (a present blank password), so blanking the
// input alone cannot restore `password` to undefined — the user needs a
// dedicated control to delete the credential entirely.
const [passwordCleared, setPasswordCleared] = useState(false);
const [selectedPort, setSelectedPort] = useState(initialData.hostname || initialData.serialConfig?.path || '');
const [baudRate, setBaudRate] = useState(initialData.serialConfig?.baudRate || initialData.port || 115200);
const [dataBits, setDataBits] = useState<5 | 6 | 7 | 8>(initialData.serialConfig?.dataBits || 8);
@@ -140,6 +153,16 @@ export const SerialHostDetailsPanel: React.FC<SerialHostDetailsPanelPropsWithRes
label: label.trim() || `Serial: ${portName}`,
hostname: selectedPort,
port: baudRate,
username: username.trim() || undefined,
// An empty password is meaningful for serial auto-login: the detector
// distinguishes an absent password from a present empty string and
// submits the required blank line at a Password prompt. An explicit edit
// saves exactly what the user typed (including a blank line); the
// dedicated clear control removes the credential entirely (undefined);
// an untouched field preserves the stored credential as-is.
password: passwordChanged
? (passwordCleared ? undefined : password)
: (initialData.password ?? undefined),
tags,
group,
charset,
@@ -276,6 +299,62 @@ export const SerialHostDetailsPanel: React.FC<SerialHostDetailsPanelPropsWithRes
)}
</div>
{/* Login credentials (auto-login) */}
<div className="space-y-2">
<Label htmlFor="serial-username">{t('serial.field.username')}</Label>
<Input
id="serial-username"
value={username}
onChange={(e) => setUsername(e.target.value)}
placeholder={t('serial.field.username')}
autoComplete="off"
/>
<div className="relative">
<Input
id="serial-password"
value={password}
type={showPassword ? 'text' : 'password'}
onChange={(e) => {
setPassword(e.target.value);
setPasswordChanged(true);
if (e.target.value !== '') {
setPasswordCleared(false);
}
}}
placeholder={t('serial.field.password')}
autoComplete="off"
className="pr-16"
/>
<div className="absolute right-2 top-1/2 flex -translate-y-1/2 items-center gap-0.5">
{(initialData.password !== undefined || password !== '') && (
<button
type="button"
onClick={() => {
setPassword('');
setPasswordChanged(true);
setPasswordCleared(true);
}}
title={t('serial.field.clearPassword')}
aria-label={t('serial.field.clearPassword')}
className="p-1 text-muted-foreground hover:text-foreground transition-colors"
>
<X size={14} />
</button>
)}
<button
type="button"
onClick={() => setShowPassword(!showPassword)}
className="p-1 text-muted-foreground hover:text-foreground transition-colors"
>
{showPassword ? <EyeOff size={16} /> : <Eye size={16} />}
</button>
</div>
</div>
<p className="text-xs text-muted-foreground">
{t('serial.field.autoLoginDesc')}
</p>
</div>
{/* Tags */}
<div className="space-y-2">
<Label className="flex items-center gap-2">
+18
View File
@@ -135,6 +135,10 @@ type TerminalTabSettingsProps = Pick<
| 'setTerminalSidePanelAutoOpen'
| 'terminalSidePanelAutoOpenTab'
| 'setTerminalSidePanelAutoOpenTab'
| 'localShellSidePanelAutoOpen'
| 'setLocalShellSidePanelAutoOpen'
| 'localShellSidePanelAutoOpenTab'
| 'setLocalShellSidePanelAutoOpenTab'
| 'workspaceFocusStyle'
| 'setWorkspaceFocusStyle'
>;
@@ -161,6 +165,10 @@ const SettingsTerminalTabContainer = React.memo<TerminalTabSettingsProps>(functi
setTerminalSidePanelAutoOpen,
terminalSidePanelAutoOpenTab,
setTerminalSidePanelAutoOpenTab,
localShellSidePanelAutoOpen,
setLocalShellSidePanelAutoOpen,
localShellSidePanelAutoOpenTab,
setLocalShellSidePanelAutoOpenTab,
workspaceFocusStyle,
setWorkspaceFocusStyle,
}) {
@@ -189,6 +197,10 @@ const SettingsTerminalTabContainer = React.memo<TerminalTabSettingsProps>(functi
setTerminalSidePanelAutoOpen={setTerminalSidePanelAutoOpen}
terminalSidePanelAutoOpenTab={terminalSidePanelAutoOpenTab}
setTerminalSidePanelAutoOpenTab={setTerminalSidePanelAutoOpenTab}
localShellSidePanelAutoOpen={localShellSidePanelAutoOpen}
setLocalShellSidePanelAutoOpen={setLocalShellSidePanelAutoOpen}
localShellSidePanelAutoOpenTab={localShellSidePanelAutoOpenTab}
setLocalShellSidePanelAutoOpenTab={setLocalShellSidePanelAutoOpenTab}
availableFonts={availableFonts}
workspaceFocusStyle={workspaceFocusStyle}
setWorkspaceFocusStyle={setWorkspaceFocusStyle}
@@ -545,6 +557,8 @@ const SettingsPageContent: React.FC<{ settings: SettingsState; appLock?: AppLock
setHostClickBehavior={settings.setHostClickBehavior}
showOnlyUngroupedHostsInRoot={settings.showOnlyUngroupedHostsInRoot}
setShowOnlyUngroupedHostsInRoot={settings.setShowOnlyUngroupedHostsInRoot}
tabBarPosition={settings.tabBarPosition}
setTabBarPosition={settings.setTabBarPosition}
showSftpTab={settings.showSftpTab}
setShowSftpTab={settings.setShowSftpTab}
showHostTreeSidebar={settings.showHostTreeSidebar}
@@ -581,6 +595,10 @@ const SettingsPageContent: React.FC<{ settings: SettingsState; appLock?: AppLock
setTerminalSidePanelAutoOpen={settings.setTerminalSidePanelAutoOpen}
terminalSidePanelAutoOpenTab={settings.terminalSidePanelAutoOpenTab}
setTerminalSidePanelAutoOpenTab={settings.setTerminalSidePanelAutoOpenTab}
localShellSidePanelAutoOpen={settings.localShellSidePanelAutoOpen}
setLocalShellSidePanelAutoOpen={settings.setLocalShellSidePanelAutoOpen}
localShellSidePanelAutoOpenTab={settings.localShellSidePanelAutoOpenTab}
setLocalShellSidePanelAutoOpenTab={settings.setLocalShellSidePanelAutoOpenTab}
workspaceFocusStyle={settings.workspaceFocusStyle}
setWorkspaceFocusStyle={settings.setWorkspaceFocusStyle}
/>
+26
View File
@@ -1,3 +1,4 @@
import { useSftpCommandRefresh } from "../application/state/sftp/useSftpCommandRefresh";
/**
* SftpSidePanel - SFTP file browser rendered as a resizable side panel
*
@@ -346,6 +347,7 @@ const SftpSidePanelInner: React.FC<SftpSidePanelProps> = ({
listLocalDir,
listDrives,
openPath,
statSftp,
} = useSftpBackend();
const sftpRef = useRef(sftp);
@@ -1336,6 +1338,7 @@ const SftpSidePanelInner: React.FC<SftpSidePanelProps> = ({
selectDirectory={selectDirectory}
listLocalDir={listLocalDir}
listDrives={listDrives}
statSftp={statSftp}
openPath={openPath}
t={t}
/>
@@ -1388,6 +1391,7 @@ type SftpSidePanelInteractiveBodyProps = {
selectDirectory: ReturnType<typeof useSftpBackend>["selectDirectory"];
listLocalDir: ReturnType<typeof useSftpBackend>["listLocalDir"];
listDrives: ReturnType<typeof useSftpBackend>["listDrives"];
statSftp: ReturnType<typeof useSftpBackend>["statSftp"];
openPath: ReturnType<typeof useSftpBackend>["openPath"];
t: ReturnType<typeof useI18n>["t"];
};
@@ -1430,6 +1434,7 @@ const SftpSidePanelInteractiveBody: React.FC<SftpSidePanelInteractiveBodyProps>
selectDirectory,
listLocalDir,
listDrives,
statSftp,
openPath,
t,
}) => {
@@ -1539,6 +1544,7 @@ const SftpSidePanelInteractiveBody: React.FC<SftpSidePanelInteractiveBodyProps>
showSaveDialog,
selectDirectory,
getSftpIdForConnection: sftp.getSftpIdForConnection,
statSftp,
listLocalFiles: listLocalDir,
listDrives,
});
@@ -1619,6 +1625,22 @@ const SftpSidePanelInteractiveBody: React.FC<SftpSidePanelInteractiveBodyProps>
|| (sftp.activeExternalEditCount ?? 0) > 0;
const connectionId = sftp.leftPane.connection?.id ?? null;
const connectionPath = sftp.leftPane.connection?.currentPath ?? null;
const [autoRefresh, setAutoRefresh] = useState(true);
useSftpCommandRefresh({
sessionId: focusedSessionId ?? activeSessionId,
enabled: autoRefresh,
visible: isVisible && ownerPanelOpen,
busy: hasActiveWork || sftp.leftPane.loading,
matchesTerminal: !!activeHost && !!connectedHostObjRef.current
&& sftpHostEndpointsEqual(activeHost, connectedHostObjRef.current)
&& sftp.leftPane.connection?.hostId === activeHost.id,
paneId: sftp.leftPane.id,
connectionId,
path: connectionPath,
connected: sftp.leftPane.connection?.status === "connected",
refresh: () => sftpRef.current.refresh("left", { tabId: sftp.leftPane.id, preserveSelection: true }),
});
const {
handleGoToTerminalCwd,
@@ -1860,6 +1882,10 @@ const SftpSidePanelInteractiveBody: React.FC<SftpSidePanelInteractiveBodyProps>
data-section="terminal-sftp-panel"
onClick={handlePaneFocus}
>
<label className="flex shrink-0 items-center gap-2 border-b border-border/50 px-3 py-1 text-xs text-muted-foreground">
<input type="checkbox" checked={autoRefresh} onChange={(event) => setAutoRefresh(event.target.checked)} />
{t("sftp.refreshAfterCommand")}
</label>
{showWorkspaceHostHeader && displayHost && (
<div
className={`${TERMINAL_SIDE_PANEL_INNER_HEADER_CLASS} border-b border-border/50 bg-muted/20 px-3 flex items-center`}
+2
View File
@@ -193,6 +193,7 @@ const SftpViewInner: React.FC<SftpViewProps> = ({
listLocalDir,
listDrives,
openPath,
statSftp,
} = useSftpBackend();
// Store sftp in a ref so callbacks can access the latest instance
@@ -424,6 +425,7 @@ const SftpViewInner: React.FC<SftpViewProps> = ({
showSaveDialog,
selectDirectory,
getSftpIdForConnection: sftp.getSftpIdForConnection,
statSftp,
listLocalFiles: listLocalDir,
listDrives,
});
+1
View File
@@ -1738,6 +1738,7 @@ const SnippetsManager: React.FC<SnippetsManagerProps> = ({
value={sortMode}
onChange={setSortMode}
className={vaultHeaderIconButtonClass}
modes={['manual', 'az', 'za', 'newest', 'oldest', 'group']}
/>
<Tooltip>
<TooltipTrigger asChild>
+36 -6
View File
@@ -1,3 +1,5 @@
import { clearTerminalBroadcastUserInput, markTerminalBroadcastUserInput } from "./terminal/runtime/terminalPacedBroadcast";
import { publishTerminalCommandCompletion } from "../application/state/terminalCommandCompletion";
import { createTerminalReflowReadingPosition } from "./terminal/terminalReflowReadingPosition";
import { resolveHostOs } from '../domain/host';
import { Terminal as XTerm } from "@xterm/xterm";
@@ -51,6 +53,7 @@ import { CONNECTION_PROGRESS_START } from "./terminal/connectionProgress";
import { supportsZmodemTerminalDragDrop } from "../lib/zmodemDragDrop";
import { resolveHostAuth, resolveHostAutofillPassword } from "../domain/sshAuth";
import { resolveEffectiveTerminalProtocol } from "../domain/terminalProtocol";
import { MULTILINE_PASTE_CONFIRM_MIN_LINES_DEFAULT } from "../domain/terminalPasteConfirm";
import { isPluginHostProtocol } from "../domain/pluginConnection";
import { clearTerminalBootEpoch, setTerminalBootEpoch } from "../domain/terminalBootEpoch";
import {
@@ -433,6 +436,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
sessionId,
() => passwordPromptActiveRef.current,
), [sessionId]);
useEffect(() => () => clearTerminalBroadcastUserInput(sessionId), [sessionId]);
const sensitivePromptOutputTailRef = useRef("");
const [activeScriptRun, setActiveScriptRun] = useState<import('@/types/global/netcatty-bridge-script.d.ts').ScriptRun | undefined>(undefined);
const dismissedScriptRunIdsRef = useRef(new Set<string>());
@@ -997,6 +1001,10 @@ const TerminalComponent: React.FC<TerminalProps> = ({
autocompleteAcceptTextRef.current = (text: string) => {
const id = sessionRef.current;
if (id && text) {
markTerminalBroadcastUserInput(sessionId);
if (["\r", "\n", "\b", "\x7f", "\x15"].some(control => text.includes(control))) {
xtermRuntimeRef.current?.invalidatePendingPasteDraft();
}
const sensitive = passwordPromptActiveRef.current;
let textToWrite = text;
let handledSubmittedInput = false;
@@ -1952,7 +1960,12 @@ const TerminalComponent: React.FC<TerminalProps> = ({
sensitivePromptOutputTailRef.current,
chunk,
);
const promptSecurityOptions = { allowHostStyleGreaterThan: isNetworkDevice };
const promptSecurityOptions = {
allowHostStyleGreaterThan: isNetworkDevice,
alternateScreen: termRef.current
? isTerminalAlternateScreenActive(termRef.current)
: false,
};
if (typeof meta?.pluginPipelineSensitiveInput === "boolean") {
passwordPromptActiveRef.current = meta.pluginPipelineSensitiveInput;
if (meta.pluginPipelineSensitiveInput) {
@@ -2367,9 +2380,10 @@ const TerminalComponent: React.FC<TerminalProps> = ({
onCommandSubmitted?.(...args);
}, [onCommandSubmitted, onTerminalCwdChange, sessionId, terminalCwdTracker]);
const pluginAwareOnCommandCompleted = useCallback(() => {
publishTerminalCommandCompletion(sessionId);
pluginTerminalLifecycle.onCommandCompleted();
void xtermRuntimeRef.current?.pluginProviderHost?.commandCompleted();
}, [pluginTerminalLifecycle]);
}, [pluginTerminalLifecycle, sessionId]);
const pluginAwareOnTerminalCwdChange = useCallback((
changedSessionId: string,
cwd: string | null,
@@ -2841,8 +2855,8 @@ const TerminalComponent: React.FC<TerminalProps> = ({
currentRow: buffer.baseY + buffer.cursorY,
lines,
};
});
}, [sessionId]);
}, readTerminalContext);
}, [readTerminalContext, sessionId]);
useEffect(() => {
const startHandler = (event: Event) => {
@@ -3214,6 +3228,14 @@ const TerminalComponent: React.FC<TerminalProps> = ({
normalizeTextOnCopyRef.current = terminalSettings?.normalizeTextOnCopy ?? true;
const autoUploadClipboardImageOnPasteRef = useRef(terminalSettings?.autoUploadClipboardImageOnPaste ?? false);
autoUploadClipboardImageOnPasteRef.current = terminalSettings?.autoUploadClipboardImageOnPaste ?? false;
const multilinePasteConfirmRef = useRef({
enabled: terminalSettings?.confirmBeforeMultilinePaste ?? false,
minLines: terminalSettings?.multilinePasteConfirmMinLines ?? MULTILINE_PASTE_CONFIRM_MIN_LINES_DEFAULT,
});
multilinePasteConfirmRef.current = {
enabled: terminalSettings?.confirmBeforeMultilinePaste ?? false,
minLines: terminalSettings?.multilinePasteConfirmMinLines ?? MULTILINE_PASTE_CONFIRM_MIN_LINES_DEFAULT,
};
const scrollToBottomAfterProgrammaticInput = useCallback((data: string) => {
if (!termRef.current) return;
@@ -3238,14 +3260,17 @@ const TerminalComponent: React.FC<TerminalProps> = ({
termRef.current?.scrollToBottom();
}, [activeScriptRun]);
const broadcastUserPasteData = useCallback((data: string) => {
const broadcastUserPasteData = useCallback((
data: string,
options?: { lineDelayMs?: number },
) => {
if (
!passwordPromptActiveRef.current
&& sessionRef.current
&& isBroadcastEnabledRef.current
&& onBroadcastInputRef.current
) {
onBroadcastInputRef.current(data, sessionId);
onBroadcastInputRef.current(data, sessionId, options);
return true;
}
return false;
@@ -3297,6 +3322,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
? AUTO_RUN_SNIPPET_LINE_DELAY_MS
: undefined;
const isMultiLine = data.includes('\n');
if (lineDelayMs) markTerminalBroadcastUserInput(sessionId);
// Wrap in bracketed paste BEFORE appending \r so the Enter is sent
// outside the paste markers — otherwise shells treat it as pasted text
// instead of a submit action.
@@ -3315,6 +3341,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
const sensitive = passwordPromptActiveRef.current;
if (!sensitive && options?.broadcast !== false && isBroadcastEnabledRef.current && onBroadcastInputRef.current) {
onBroadcastInputRef.current(data, sessionId, {
automated: true,
noAutoRun,
...(lineDelayMs ? { lineDelayMs } : {}),
});
@@ -3377,6 +3404,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
const terminalContextActions = useTerminalContextActions({
termRef,
sessionName: sessionDisplayName,
sourceSessionId: sessionId,
sessionRef,
scrollOnPasteRef,
@@ -3388,6 +3416,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
isLocalConnection,
supportsRemoteImagePaste,
autoUploadClipboardImageOnPasteRef,
multilinePasteConfirmRef,
terminalBackend,
getRemoteCwd: () => resolveSftpInitialPath({ preferFreshBackend: true }),
scrollToBottomAfterProgrammaticInput,
@@ -3969,6 +3998,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
containerRef,
autoUploadClipboardImage:
supportsRemoteImagePaste && terminalSettings?.autoUploadClipboardImageOnPaste === true,
multilinePasteConfirmRef,
getRemoteCwd: () => resolveSftpInitialPath({ preferFreshBackend: true }),
onClipboardImageUploadResult: handleClipboardImageUploadResult,
});
+5
View File
@@ -306,3 +306,8 @@ test("TerminalLayer re-renders when terminalFontFamilyId changes", () => {
false,
);
});
test("local shell auto-open preferences invalidate terminal layer memo", () => {
assert.equal(terminalLayerAreEqual(baseProps as never, { ...baseProps, localShellSidePanelAutoOpen: true } as never), false);
assert.equal(terminalLayerAreEqual(baseProps as never, { ...baseProps, localShellSidePanelAutoOpenTab: "sftp" } as never), false);
});
+180 -29
View File
@@ -1,3 +1,5 @@
import { captureTerminalBroadcastInput, isTerminalBroadcastInputCurrent, markTerminalBroadcastUserInput } from "./terminal/runtime/terminalPacedBroadcast";
import { pruneComposeBarHistory } from '../application/state/composeBarHistoryStore';
import { FolderTree, History, MessageSquare, PanelLeft, PanelRight, Palette, X, Zap } from 'lucide-react';
import React, { memo, startTransition, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { activeTabStore } from '../application/state/activeTabStore';
@@ -104,7 +106,7 @@ import {
} from './terminalLayer/terminalLayerSessionRouting';
import {
DEFAULT_TERMINAL_SIDE_PANEL_AUTO_OPEN_TAB,
resolveTerminalSidePanelAutoOpen,
resolveSessionSidePanelAutoOpen,
} from '../domain/terminalSidePanelAutoOpen';
import { shouldProbeCommandCwd } from './terminalLayer/commandCwdProbe';
import {
@@ -117,8 +119,10 @@ import { classifyDistroId, shouldProbeSessionCwd } from '../domain/host';
import {
collectSidePanelPanes,
sidePanelLayoutHasTool,
type SidePanelLayout,
type SidePanelSplitDirection,
} from '../domain/sidePanelLayout';
import { useWorkspaceLayoutPresetState } from '../application/state/useWorkspaceLayoutPresetState';
import {
isPaneMagnificationSelectionValid,
resolvePaneMagnificationCandidate,
@@ -261,6 +265,8 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
sftpAutoOpenSidebar,
terminalSidePanelAutoOpen = false,
terminalSidePanelAutoOpenTab = DEFAULT_TERMINAL_SIDE_PANEL_AUTO_OPEN_TAB,
localShellSidePanelAutoOpen = false,
localShellSidePanelAutoOpenTab = 'scripts',
sftpFollowTerminalCwd,
setSftpFollowTerminalCwd,
editorWordWrap,
@@ -301,6 +307,10 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
closePane: closeSidePanelPaneForTab,
resizeSplit: resizeSidePanelSplitForTab,
} = useTerminalSidePanelLayoutState();
useEffect(() => {
pruneComposeBarHistory(sessions.map((session) => session.id));
}, [sessions]);
useEffect(() => {
setMagnifiedPane((current) => {
if (!current) return current;
@@ -440,9 +450,101 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
terminalSidePanelAutoOpenRef.current = terminalSidePanelAutoOpen;
const terminalSidePanelAutoOpenTabRef = useRef(terminalSidePanelAutoOpenTab);
terminalSidePanelAutoOpenTabRef.current = terminalSidePanelAutoOpenTab;
const localShellSidePanelAutoOpenRef = useRef(localShellSidePanelAutoOpen);
localShellSidePanelAutoOpenRef.current = localShellSidePanelAutoOpen;
const localShellSidePanelAutoOpenTabRef = useRef(localShellSidePanelAutoOpenTab);
localShellSidePanelAutoOpenTabRef.current = localShellSidePanelAutoOpenTab;
const sftpFollowTerminalCwdRef = useRef(sftpFollowTerminalCwd);
sftpFollowTerminalCwdRef.current = sftpFollowTerminalCwd;
// Destructure the stable hook callbacks: the hook returns a fresh object
// each render, and depending on that object would recreate the preset
// callbacks (and in turn handleStatusChange) on unrelated state updates,
// defeating the terminal pane memoization.
const { resolveDefaultLayoutForSession, saveWorkspaceLayoutAsDefault: persistWorkspaceLayoutAsDefault } =
useWorkspaceLayoutPresetState();
const applyWorkspaceLayoutPresetForSession = useCallback((session: TerminalSession, tabId: string) => {
// Preset persistence and resolution live in the application layer; the
// component only mounts the resolved panes into its UI state.
const resolved = resolveDefaultLayoutForSession(session);
if (!resolved) return false;
const { layout, focusedTool, protocol: proto } = resolved;
for (const pane of collectSidePanelPanes(layout.root)) {
if (pane.tool === 'ai') {
setAiMountedTabIds((prev) => addMountedSidePanelTabId(prev, tabId));
} else if (pane.tool === 'scripts') {
setScriptsMountedTabIds((prev) => addMountedSidePanelTabId(prev, tabId));
} else if (pane.tool === 'theme') {
setThemeMountedTabIds((prev) => addMountedSidePanelTabId(prev, tabId));
} else if (pane.tool === 'system') {
setSystemMountedTabIds((prev) => addMountedSidePanelTabId(prev, tabId));
} else if (pane.tool === 'notes') {
setNotesMountedTabIds((prev) => addMountedSidePanelTabId(prev, tabId));
} else if (pane.tool === 'sftp') {
// Mirror the normal SFTP open path: a previously retained panel (kept
// mounted while transfers or external edits finished after close) must
// not keep suppressing command-triggered refreshes once the preset
// reopens SFTP for this tab. Mark the tab as opening first so an
// activity callback firing before React commits the open state still
// sees the panel as open instead of pruning the retained host state.
const cleanupTimer = sftpRetainedCleanupTimersRef.current.get(tabId);
if (cleanupTimer !== undefined) {
window.clearTimeout(cleanupTimer);
sftpRetainedCleanupTimersRef.current.delete(tabId);
}
sftpOpeningTabIdsRef.current.add(tabId);
sftpRetainedAfterCloseTabIdsRef.current.delete(tabId);
sftpPaneClosedTabIdsRef.current.delete(tabId);
const host = hostsRef.current.find(h => h.id === session.hostId);
const hostWithOverrides: Host = host
? {
...host,
protocol: session.protocol ?? host.protocol,
port: session.port ?? host.port,
moshEnabled: session.moshEnabled ?? host.moshEnabled,
etEnabled: session.etEnabled ?? host.etEnabled,
}
: {
id: session.hostId || session.id,
hostname: session.hostname,
username: session.username,
port: session.port ?? 22,
protocol: proto,
label: session.customName || session.hostLabel || session.hostname,
} as Host;
setSftpHostForTab(prev => {
const next = new Map(prev);
next.set(tabId, hostWithOverrides);
return next;
});
}
}
setSidePanelLayouts((prev) => {
const next = new Map(prev);
next.set(tabId, layout);
return next;
});
lastSidePanelTabRef.current.set(tabId, focusedTool);
setSidePanelOpenTabs(prev => {
const next = new Map(prev);
next.set(tabId, focusedTool);
return next;
});
return true;
}, [resolveDefaultLayoutForSession, setSidePanelLayouts, setSidePanelOpenTabs]);
const handleSaveWorkspaceLayoutAsDefault = useCallback((layout: SidePanelLayout): boolean => {
if (!persistWorkspaceLayoutAsDefault(layout)) {
toast.error(t('terminal.layer.layoutSaveFailed'));
return false;
}
toast.success(t('terminal.layer.layoutSavedAsDefault'));
return true;
}, [persistWorkspaceLayoutAsDefault, t]);
const handleStatusChange = useCallback((sessionId: string, status: TerminalSession['status']) => {
onUpdateSessionStatus(sessionId, status);
@@ -450,41 +552,62 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
const session = sessionsRef.current.find(s => s.id === sessionId);
if (!session) return;
const proto = session.protocol;
const sftpAvailable = proto === 'ssh' || proto === 'mosh';
const tabId = session.workspaceId || sessionId;
const workspaceId = session.workspaceId;
const tabId = workspaceId || sessionId;
// Within a workspace every member session maps to the same tab id, so the
// preset (and any auto-open host binding) must come from the workspace's
// focused session; otherwise whichever pane connects first would decide
// SFTP pruning and host selection regardless of focus.
let presetSession = session;
if (workspaceId) {
const focusedSessionId = workspacesRef.current.find(ws => ws.id === workspaceId)?.focusedSessionId;
const focusedSession = focusedSessionId
? sessionsRef.current.find(s => s.id === focusedSessionId)
: undefined;
if (focusedSession) presetSession = focusedSession;
}
const proto = presetSession.protocol ?? 'ssh';
if (sidePanelOpenTabsRef.current.has(tabId)) return;
const sessionAutoOpenTarget = session.autoOpenSidePanel === 'sftp' && sftpAvailable ? 'sftp' : null;
const autoOpenTarget = sessionAutoOpenTarget ?? resolveTerminalSidePanelAutoOpen({
enabled: terminalSidePanelAutoOpenRef.current,
selectedTab: terminalSidePanelAutoOpenTabRef.current,
sftpAvailable,
const targetPanel = resolveSessionSidePanelAutoOpen({
session: presetSession,
terminalEnabled: terminalSidePanelAutoOpenRef.current,
terminalTab: terminalSidePanelAutoOpenTabRef.current,
localEnabled: localShellSidePanelAutoOpenRef.current,
localTab: localShellSidePanelAutoOpenTabRef.current,
legacySftpEnabled: sftpAutoOpenSidebarRef.current,
});
const targetPanel = autoOpenTarget ?? (sftpAutoOpenSidebarRef.current && sftpAvailable ? 'sftp' : null);
if (!targetPanel) return;
// Explicit SFTP (JumpServer deep link / host.autoOpenSftpPanel) wins over a
// saved default that may omit file transfer — only when SFTP is actually
// available. Ordinary auto-open still yields to the saved default.
if (targetPanel !== "sftp") {
if (applyWorkspaceLayoutPresetForSession(presetSession, tabId)) return;
if (!targetPanel) return;
}
lastSidePanelTabRef.current.set(tabId, targetPanel);
if (targetPanel === 'sftp') {
sftpPaneClosedTabIdsRef.current.delete(tabId);
const host = hostsRef.current.find(h => h.id === session.hostId);
const host = hostsRef.current.find(h => h.id === presetSession.hostId);
const hostWithOverrides: Host = host
? {
...host,
protocol: session.protocol ?? host.protocol,
port: session.port ?? host.port,
moshEnabled: session.moshEnabled ?? host.moshEnabled,
etEnabled: session.etEnabled ?? host.etEnabled,
protocol: presetSession.protocol ?? host.protocol,
port: presetSession.port ?? host.port,
moshEnabled: presetSession.moshEnabled ?? host.moshEnabled,
etEnabled: presetSession.etEnabled ?? host.etEnabled,
}
: {
id: session.hostId || sessionId,
hostname: session.hostname,
username: session.username,
port: session.port ?? 22,
id: presetSession.hostId || presetSession.id,
hostname: presetSession.hostname,
username: presetSession.username,
port: presetSession.port ?? 22,
protocol: proto,
label: session.label || session.hostname,
label: presetSession.customName || presetSession.hostLabel || presetSession.hostname,
} as Host;
setSftpHostForTab(prev => {
@@ -509,7 +632,7 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
next.set(tabId, targetPanel);
return next;
});
}, [onUpdateSessionStatus, setSidePanelOpenTabs, sidePanelOpenTabsRef]);
}, [applyWorkspaceLayoutPresetForSession, onUpdateSessionStatus, setSidePanelOpenTabs, sidePanelOpenTabsRef]);
const handleSessionExit = useCallback((sessionId: string, evt: TerminalSessionExitEvent) => {
const intent = resolveTerminalSessionExitIntent(
@@ -1059,12 +1182,26 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
sourceSessionId: string,
options?: TerminalBroadcastInputOptions,
) => {
const paced = options?.pacedBroadcast;
if (paced && !options?.preparePacedBroadcast) {
if (!paced.targets || isTerminalSensitiveInputActive(sourceSessionId)) return [];
paced.targets = paced.targets.filter(target => isTerminalBroadcastInputCurrent(target)
&& !isTerminalSensitiveInputActive(target.sessionId));
}
const targetSessionIds = resolveTerminalBroadcastTargetIds({
sessions: sessionsRef.current,
sourceSessionId,
globalBroadcastEnabled: isGlobalBroadcastEnabled,
directTargetSessionIds: options?.kittyKeyboardTargetSessionIds,
});
}).filter(id => !paced?.targets || paced.targets.some(target => target.sessionId === id));
if (options?.preparePacedBroadcast && paced) {
paced.targets = targetSessionIds.filter(id => !isTerminalSensitiveInputActive(id)).map(id => {
markTerminalBroadcastUserInput(id);
terminalBackend.interruptSession(id, undefined, { cancelPendingWritesOnly: true });
return captureTerminalBroadcastInput(id);
});
return paced.targets.map(target => target.sessionId);
}
if (targetSessionIds.length === 0) return [];
const targetSessionIdSet = new Set(targetSessionIds);
@@ -1073,6 +1210,7 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
for (const session of sessionsRef.current) {
if (!targetSessionIdSet.has(session.id)) continue;
if (!canUseDirectSessionWriteFallback(session)) continue;
if (!paced && (options?.automated !== true || options.lineDelayMs)) markTerminalBroadcastUserInput(session.id);
if (options?.kittyKeyboardInput) {
dispatchKittyKeyboardBroadcastInput(session.id, options.kittyKeyboardInput, {
@@ -1089,6 +1227,7 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
&& isPluginHostProtocol(session.protocol)
&& terminalBackend.signalPluginConnection) {
broadcastInterruptPrioritizersRef.current.get(session.id)?.();
terminalBackend.interruptSession(session.id, undefined, { cancelPendingWritesOnly: true });
void terminalBackend.signalPluginConnection(session.id, "interrupt").catch(() => {
terminalBackend.interruptSession(session.id);
});
@@ -1102,7 +1241,7 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
}
if (isTerminalSensitiveInputActive(session.id)) continue;
terminalBackend.writeToSession(session.id, data, {
automated: true,
automated: options?.automated === true,
sensitive: false,
...(lineDelayMs ? { lineDelayMs } : {}),
});
@@ -2137,9 +2276,11 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
toast.info(t('scripts.recording.started'));
}, [getActiveTerminalSessionId, t]);
const handleComposeSend = useCallback((text: string) => {
const handleComposeSend = useCallback(async (text: string) => {
const activeWorkspace = activeWorkspaceRef.current;
if (!activeWorkspace) return;
if (!activeWorkspace) return false;
let recordHistory = false;
const pendingSends: Promise<boolean>[] = [];
const payload = text + '\r';
const broadcastEnabled = isBroadcastEnabled?.(activeWorkspace.id);
const focusedSessionId = activeWorkspace.focusedSessionId;
@@ -2155,11 +2296,14 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
if (isTerminalSensitiveInputActive(sid)) continue;
const executor = snippetExecutorsRef.current.get(sid);
if (executor) {
executor(text, false, { broadcast: false });
pendingSends.push(Promise.resolve(executor(text, false, { broadcast: false })).then(
(sent) => sent && !isTerminalSensitiveInputActive(sid),
));
} else {
const session = sessionsRef.current.find((candidate) => candidate.id === sid);
if (!session || !canUseDirectSessionWriteFallback(session)) continue;
terminalBackend.writeToSession(sid, payload, { sensitive: false });
recordHistory = recordHistory || session.status === 'connected';
}
}
} else {
@@ -2171,16 +2315,22 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
if (targetId) {
const executor = snippetExecutorsRef.current.get(targetId);
if (executor) {
executor(text, false);
const sensitive = isTerminalSensitiveInputActive(targetId);
pendingSends.push(Promise.resolve(executor(text, false)).then(
(sent) => sent && !sensitive && !isTerminalSensitiveInputActive(targetId),
));
} else {
const session = sessionsRef.current.find((candidate) => candidate.id === targetId);
if (!session || !canUseDirectSessionWriteFallback(session)) return;
if (!session || !canUseDirectSessionWriteFallback(session)) return false;
recordHistory = session.status === 'connected' && !isTerminalSensitiveInputActive(targetId);
terminalBackend.writeToSession(targetId, payload, {
sensitive: isTerminalSensitiveInputActive(targetId),
});
}
}
}
const results = await Promise.allSettled(pendingSends);
return recordHistory || results.some((result) => result.status === 'fulfilled' && result.value);
}, [isBroadcastEnabled, terminalBackend]);
const sessionLogConfig = useMemo(
@@ -2384,6 +2534,7 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
setSftpFollowTerminalCwd,
setSftpHostForTab,
setSftpInitialLocationForTab,
onSaveWorkspaceLayoutAsDefault: handleSaveWorkspaceLayoutAsDefault,
setSftpPendingUploadsForTab,
showHostTreeSidebar,
sidePanelOpenTabs,
+2 -2
View File
@@ -145,11 +145,11 @@ test('popup terminals use their window-local vault readiness', () => {
assert.match(source, /vaultInitializedOverride=\{vaultInitialized\}/);
});
test('popup provider tree mounts the plugin authentication host', () => {
test('popup provider tree mounts authentication and paste confirmation hosts', () => {
assert.match(source, /import \{ PluginAuthenticationHost \} from '\.\/plugins\/PluginAuthenticationHost';/);
assert.match(
source,
/<I18nProvider locale=\{settings\.uiLanguage\}>\s+<TerminalPopupPageInner\s+settings=\{settings\}\s+allowTerminalStart=\{allowTerminalStart\}\s+\/>\s+<PluginAuthenticationHost \/>\s+<\/I18nProvider>/,
/<I18nProvider locale=\{settings\.uiLanguage\}>\s+<TerminalPopupPageInner\s+settings=\{settings\}\s+allowTerminalStart=\{allowTerminalStart\}\s+\/>\s+<PluginAuthenticationHost \/>\s+<MultilinePasteConfirmHost \/>\s+<\/I18nProvider>/,
);
});
+5
View File
@@ -1,6 +1,8 @@
import { setupVaultAgentBridge } from "../infrastructure/ai/vaultAgentBridgeClient";
import { Copy, Minus, Square, Unplug, X } from 'lucide-react';
import React, { lazy, Suspense, useCallback, useEffect, useMemo, useState } from 'react';
import { I18nProvider, useI18n } from '../application/i18n/I18nProvider';
import { MultilinePasteConfirmHost } from './terminal/MultilinePasteConfirmDialog';
import { useTerminalPopupWindow } from '../application/state/useTerminalPopupWindow';
import { useVaultState } from '../application/state/useVaultState';
import { useWindowControls } from '../application/state/useWindowControls';
@@ -537,6 +539,8 @@ export default function TerminalPopupPage({
settings: SettingsState;
allowTerminalStart?: boolean;
}) {
useEffect(() => setupVaultAgentBridge(), []);
return (
<I18nProvider locale={settings.uiLanguage}>
<TerminalPopupPageInner
@@ -544,6 +548,7 @@ export default function TerminalPopupPage({
allowTerminalStart={allowTerminalStart}
/>
<PluginAuthenticationHost />
<MultilinePasteConfirmHost />
</I18nProvider>
);
}
+27 -7
View File
@@ -32,7 +32,7 @@ const {
} = await import("../application/state/terminalDragData.ts");
const {
activateLogViewTab,
createTopTabCopyDoubleClickHandler,
createTopTabSessionDoubleClickHandler,
formatSessionTopTabLabel,
formatSessionTopTabTooltip,
resolveSessionTabCodingCliIconState,
@@ -41,6 +41,7 @@ const {
const { activeTabStore } = await import("../application/state/activeTabStore.ts");
const indexCss = readFileSync(new URL("../index.css", import.meta.url), "utf8");
const topTabsSource = readFileSync(new URL("./TopTabs.tsx", import.meta.url), "utf8");
const topTabItemsSource = readFileSync(new URL("./top-tabs/TopTabItems.tsx", import.meta.url), "utf8");
const topTabsQuickControlsSource = readFileSync(new URL("./TopTabsQuickControls.tsx", import.meta.url), "utf8");
const syncStatusButtonSource = readFileSync(new URL("./SyncStatusButton.tsx", import.meta.url), "utf8");
const switchSource = readFileSync(new URL("./ui/switch.tsx", import.meta.url), "utf8");
@@ -53,7 +54,6 @@ const appSource = [
].join("\n");
const externalMcpToggleSource = readFileSync(new URL("../application/state/useExternalMcpToggleState.ts", import.meta.url), "utf8");
const zhTwAiSource = readFileSync(new URL("../application/i18n/locales/zh-TW/ai.ts", import.meta.url), "utf8");
const topTabItemsSource = readFileSync(new URL("./top-tabs/TopTabItems.tsx", import.meta.url), "utf8");
const terminalViewSource = readFileSync(new URL("./terminal/TerminalView.tsx", import.meta.url), "utf8");
test("host tree tab gutter fills the remaining sidebar width", () => {
@@ -248,17 +248,37 @@ test("disabling dynamic titles freezes a stored coding CLI icon and stops title
);
});
test("session top tabs copy the session on double click through the existing copy handler", () => {
test("session top tab double click dispatches the configured behavior", () => {
const copiedSessionIds: string[] = [];
const handleDoubleClick = createTopTabCopyDoubleClickHandler(
(sessionId) => copiedSessionIds.push(sessionId),
"session-1",
const duplicatedSessionIds: string[] = [];
const handlers = (["duplicate", "copy", "disabled"] as const).map((behavior) =>
createTopTabSessionDoubleClickHandler({
behavior,
onCopySession: (sessionId) => copiedSessionIds.push(sessionId),
onDuplicateSession: (sessionId) => duplicatedSessionIds.push(sessionId),
sessionId: `session-${behavior}`,
}),
);
handlers.forEach((handler) => handler({} as Parameters<typeof handler>[0]));
assert.deepEqual(duplicatedSessionIds, ["session-duplicate"]);
assert.deepEqual(copiedSessionIds, ["session-copy"]);
assert.match(topTabItemsSource, /behavior: tabDoubleClickBehavior/);
assert.match(topTabItemsSource, /onDoubleClick=\{handleDoubleClick\}/);
});
test("duplicate double-click behavior falls back to copying when unavailable", () => {
const copiedSessionIds: string[] = [];
const handleDoubleClick = createTopTabSessionDoubleClickHandler({
behavior: "duplicate",
onCopySession: (sessionId) => copiedSessionIds.push(sessionId),
sessionId: "session-1",
});
handleDoubleClick({} as Parameters<typeof handleDoubleClick>[0]);
assert.deepEqual(copiedSessionIds, ["session-1"]);
assert.match(topTabItemsSource, /onDoubleClick=\{handleDoubleClick\}/);
});
test("session close button double click stays on the close button", () => {
+28 -9
View File
@@ -11,7 +11,7 @@ import {
appendHostFromWorkspaceDrop,
resolveFocusSidebarDragKind,
} from '../domain/focusSidebarHostDrop';
import type { DynamicTabTitleMode, KeyBinding } from '../domain/models';
import type { DynamicTabTitleMode, KeyBinding, TerminalTabDoubleClickBehavior } from '../domain/models';
import { getTopTabInsertionTarget, getWorkspaceSessionDragId, hasWorkspaceSessionDrag } from '../application/state/terminalDragData';
import {
@@ -168,6 +168,7 @@ interface TopTabsProps {
showHostTreeSidebar: boolean;
switchTabKeyBinding: Pick<KeyBinding, 'mac' | 'pc'> | null;
dynamicTabTitleMode?: DynamicTabTitleMode;
tabDoubleClickBehavior: TerminalTabDoubleClickBehavior;
editorTabs: readonly EditorTabChrome[];
pluginViewTabs: readonly PluginViewTab[];
onClosePluginViewTab: (tabId: string) => void;
@@ -217,6 +218,7 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
showHostTreeSidebar,
switchTabKeyBinding,
dynamicTabTitleMode,
tabDoubleClickBehavior,
editorTabs,
pluginViewTabs,
onClosePluginViewTab,
@@ -226,6 +228,7 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
const { t } = useI18n();
const { maximize, isFullscreen, onFullscreenChanged } = useWindowControls();
const {
tabBarPosition,
hotkeyScheme,
showTabNumberBadges,
shellOnlyTabNumberShortcuts,
@@ -267,7 +270,8 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
const [hostTreeChromeReady, setHostTreeChromeReady] = useState(false);
const [hostTreeGutterExiting, setHostTreeGutterExiting] = useState(false);
const [rootTabsCompact, setRootTabsCompact] = useState(false);
const showWindowControls = !isMacClient;
const tabsAtBottom = tabBarPosition === 'bottom';
const showWindowControls = !isMacClient && !tabsAtBottom;
// Tab reorder drag state
const [dropIndicator, setDropIndicator] = useState<{ tabId: string; position: 'before' | 'after' } | null>(null);
@@ -899,6 +903,7 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
onEditHost={onEditHost}
renderBulkCloseItems={renderBulkCloseItems}
dynamicTabTitleMode={dynamicTabTitleMode}
tabDoubleClickBehavior={tabDoubleClickBehavior}
t={t}
tabAnimationClass={getTabAnimationClass(session.id)}
shortcutNumber={tabShortcutNumbers?.get(session.id)}
@@ -994,12 +999,24 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
}, [isMacClient, maximize]);
return (
<>
<div
data-tab-window-titlebar
data-section="top-tabs"
className={cn("h-9 shrink-0 items-end justify-end bg-secondary app-drag", tabsAtBottom ? "flex" : "hidden")}
style={dragRegionNoSelect}
onDoubleClick={handleTitleBarDoubleClick}
>
{!isMacClient && <WindowControls />}
</div>
<div
data-top-tabs-root
data-position={tabBarPosition}
data-section="top-tabs"
className="relative w-full bg-secondary app-drag"
className="relative w-full shrink-0 bg-secondary app-drag"
style={{
...dragRegionNoSelect,
order: tabsAtBottom ? 2 : undefined,
backgroundColor: 'var(--top-tabs-bg, hsl(var(--secondary)))',
color: 'var(--top-tabs-fg, hsl(var(--foreground)))',
}}
@@ -1010,12 +1027,12 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
updateScrollState={updateScrollState}
/>
{/* Always-on drag stripe so the window can be moved even when tabs fill the bar */}
<div className="absolute inset-x-0 top-0 h-1 app-drag pointer-events-auto z-10" style={dragRegionStyle} aria-hidden />
<div className={cn("absolute inset-x-0 h-1 app-drag pointer-events-auto z-10", tabsAtBottom ? "bottom-0" : "top-0")} style={dragRegionStyle} aria-hidden />
<div
className="h-9 flex items-end gap-0 app-drag overflow-visible"
className={cn("h-9 flex gap-0 app-drag overflow-visible", tabsAtBottom ? "items-start" : "items-end")}
style={{
...dragRegionStyle,
paddingLeft: isMacClient && !isWindowFullscreen ? 76 : 12,
paddingLeft: isMacClient && !isWindowFullscreen && !tabsAtBottom ? 76 : 12,
paddingRight: showWindowControls ? 0 : 12,
}}
>
@@ -1063,7 +1080,7 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
{hasHostTreeToggleSurface && (
<div
ref={hostTreeToggleSlotRef}
className="top-tab-host-tree-toggle-slot mb-0 flex-shrink-0 self-end app-no-drag"
className={cn("top-tab-host-tree-toggle-slot mb-0 flex-shrink-0 app-no-drag", tabsAtBottom ? "self-start" : "self-end")}
data-section="top-tabs-host-tree-toggle"
data-visible={effectiveShowHostTreeToggle ? 'true' : 'false'}
style={noDragRegionStyle}
@@ -1173,7 +1190,7 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
<Button
variant="ghost"
size="icon"
className="h-7 w-7 flex-shrink-0 app-no-drag self-end rounded-none"
className={cn("h-7 w-7 flex-shrink-0 app-no-drag rounded-none", tabsAtBottom ? "self-start" : "self-end")}
style={{ color: 'var(--top-tabs-muted, hsl(var(--muted-foreground)))' }}
onClick={onOpenQuickSwitcher}
>
@@ -1187,7 +1204,7 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
{/* Fixed right controls — utility icons + window controls share one h-7 row */}
<div
className="flex-shrink-0 flex items-center gap-0.5 app-drag self-end h-7 overflow-visible"
className={cn("flex-shrink-0 flex items-center gap-0.5 app-drag h-7 overflow-visible", tabsAtBottom ? "self-start" : "self-end")}
style={dragRegionStyle}
data-section="top-tabs-toolbar-actions"
>
@@ -1262,6 +1279,7 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
)}
</div>
</div>
</>
);
};
@@ -1305,6 +1323,7 @@ export const topTabsAreEqual = (prev: TopTabsProps, next: TopTabsProps): boolean
prev.showHostTreeSidebar === next.showHostTreeSidebar &&
prev.switchTabKeyBinding === next.switchTabKeyBinding &&
prev.dynamicTabTitleMode === next.dynamicTabTitleMode &&
prev.tabDoubleClickBehavior === next.tabDoubleClickBehavior &&
prev.hostById === next.hostById
);
};
@@ -162,3 +162,12 @@ test("Hosts manual sort mode uses saved order", () => {
assert.ok(markup.indexOf("Zulu Host") < markup.indexOf("Alpha Host"));
});
test("Hosts IP sort is restored and compares octets instead of labels", () => {
const markup = renderVault("ip", [
{ ...host("ten", "Alpha Ten", 1), hostname: "10.0.0.10" },
{ ...host("two", "Zulu Two", 2), hostname: "10.0.0.2" },
]);
assert.ok(markup.includes("Zulu Two"));
assert.ok(markup.indexOf("Zulu Two") < markup.indexOf("Alpha Ten"));
});
+38 -31
View File
@@ -77,6 +77,7 @@ import {
remapSnippetTargetGroupPaths,
} from "../domain/hostGroupPathMutations";
import {
canReorderVaultHosts,
reorderVaultItems,
reorderVaultStrings,
type VaultOrderPosition,
@@ -207,7 +208,8 @@ const isSortMode = (value: string): value is SortMode =>
value === "za" ||
value === "newest" ||
value === "oldest" ||
value === "group";
value === "group" ||
value === "ip";
// Props without isActive - it's now subscribed internally
interface VaultViewProps {
@@ -696,12 +698,15 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
keyPassphrases,
unreadablePassphraseCount,
} = await buildVaultCsvCredentialOptions(hosts, keys);
const { csv, exportedCount, skippedCount } = exportHostsToCsvWithStats(
const { csv, exportedCount, skippedCount, unreadableProxyCredentialCount } = exportHostsToCsvWithStats(
hosts,
{
keyPassphrases,
keyPassphrasesById,
keyPathsById,
proxyProfiles,
identities,
groupConfigs,
},
);
@@ -727,6 +732,13 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
}),
);
}
if (unreadableProxyCredentialCount > 0) {
toast.warning(
t("vault.hosts.export.toast.proxyCredentialsSkipped", {
count: unreadableProxyCredentialCount,
}),
);
}
if (skippedCount > 0) {
toast.warning(
t("vault.hosts.export.toast.successWithSkipped", {
@@ -739,7 +751,7 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
t("vault.hosts.export.toast.success", { count: exportedCount }),
);
}
}, [hosts, keys, t]);
}, [hosts, keys, proxyProfiles, identities, groupConfigs, t]);
// Copy hostname/IP for cross-host paste without opening the editor
const handleCopyHostname = useCallback(
@@ -1180,34 +1192,13 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
const source = hostsRef.current.find((host) => host.id === sourceHostId);
const target = hostsRef.current.find((host) => host.id === targetHostId);
if (!source || !target) return;
const targetGroup = target.group || "";
const targetManagedSource = managedSources
.filter(
(sourceInfo) =>
targetGroup === sourceInfo.groupName ||
targetGroup.startsWith(`${sourceInfo.groupName}/`),
)
.sort((a, b) => b.groupName.length - a.groupName.length)[0];
const updatedHosts = hostsRef.current.map((host) =>
host.id === sourceHostId
? {
...host,
label:
targetManagedSource &&
(!host.protocol || host.protocol === "ssh")
? host.label.replace(/\s/g, "")
: host.label,
group: targetGroup,
managedSourceId:
targetManagedSource &&
(!host.protocol || host.protocol === "ssh")
? targetManagedSource.id
: undefined,
}
: host,
);
// Card-to-card drags only reorder hosts within the same group; moving a
// host into a different group must go through the group folder drop
// (moveHostToGroup), otherwise dragging two grouped cards past each
// other silently swaps their group membership.
if (!canReorderVaultHosts(source.group, target.group)) return;
const reorderedHosts = reorderVaultItems<Host>(
updatedHosts,
hostsRef.current,
sourceHostId,
targetHostId,
position,
@@ -1216,7 +1207,7 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
onUpdateHosts(reorderedHosts);
setSortMode("manual");
},
[managedSources, onUpdateHosts, setSortMode],
[onUpdateHosts, setSortMode],
);
const reorderGroup = useCallback(
@@ -1288,6 +1279,21 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
t,
});
const managedFilesByGroupPath = useMemo(() => {
const map = new Map<string, string[]>();
for (const source of managedSources) {
if (source.filePath) {
const files = map.get(source.groupName);
if (files) {
files.push(source.filePath);
} else {
map.set(source.groupName, [source.filePath]);
}
}
}
return map;
}, [managedSources]);
const {
startInlineNewGroup,
startInlineRenameGroup,
@@ -1379,6 +1385,7 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
<>
<HostTreeGroupDeleteDialog
managedGroupPaths={managedGroupPaths}
managedFilesByGroupPath={managedFilesByGroupPath}
onConfirmDelete={deleteGroupPath}
/>
<VaultViewLayout
+1
View File
@@ -1612,6 +1612,7 @@ const ChatInput: React.FC<ChatInputProps> = ({
<ComposerThinkingChip
levels={thinkingLevels}
selectedLevel={visibleThinking}
allowDefault={hasProviderSwitcher && !!onThinkingLevelChange}
disabled={lockTurnConfiguration}
open={showThinkingPicker}
menuPos={showThinkingPicker ? menuPos : null}
+15 -7
View File
@@ -1,4 +1,4 @@
import { Brain, Check, ChevronDown } from 'lucide-react';
import { Brain, Check, ChevronDown, CircleSlash } from 'lucide-react';
import React, { useRef } from 'react';
import { createPortal } from 'react-dom';
import { useI18n } from '../../application/i18n/I18nProvider';
@@ -9,6 +9,8 @@ export interface ComposerThinkingChipProps {
levels: readonly string[];
selectedLevel?: string;
disabled?: boolean;
/** Offer a "provider default" entry that clears the override (empty selection). */
allowDefault?: boolean;
open: boolean;
menuPos: { left: number; bottom: number } | null;
onToggle: (rect: DOMRect | undefined) => void;
@@ -23,6 +25,7 @@ export const ComposerThinkingChip: React.FC<ComposerThinkingChipProps> = ({
levels,
selectedLevel,
disabled = false,
allowDefault = false,
open,
menuPos,
onToggle,
@@ -71,16 +74,17 @@ export const ComposerThinkingChip: React.FC<ComposerThinkingChipProps> = ({
className="fixed z-[1000] min-w-[148px] rounded-lg border border-border/50 bg-popover shadow-lg py-1"
style={{ left: menuPos.left, bottom: menuPos.bottom }}
>
{levels.map((level) => {
const isSelected = selectedLevel === level;
{(allowDefault ? [undefined, ...levels] : levels).map((level) => {
const isDefault = level === undefined;
const isSelected = isDefault ? !selectedLevel : selectedLevel === level;
return (
<button
key={level}
key={isDefault ? '__default__' : level}
type="button"
role="option"
aria-selected={isSelected}
onClick={() => {
if (!isSelected) onSelect(level);
if (!isSelected) onSelect(isDefault ? '' : level);
else onClose();
}}
className="w-full flex items-center gap-2 px-2.5 py-1.5 text-left text-[12px] hover:bg-muted/30 transition-colors cursor-pointer"
@@ -88,8 +92,12 @@ export const ComposerThinkingChip: React.FC<ComposerThinkingChipProps> = ({
{isSelected
? <Check size={11} className="text-primary shrink-0" />
: <span className="w-[11px] shrink-0" />}
<Brain size={12} className="text-violet-400/70 shrink-0" />
<span className="text-foreground/85">{formatLevel(level)}</span>
{isDefault
? <CircleSlash size={12} className="text-muted-foreground/60 shrink-0" />
: <Brain size={12} className="text-violet-400/70 shrink-0" />}
<span className="text-foreground/85">
{isDefault ? t('ai.chat.thinkingDefault') : formatLevel(level)}
</span>
</button>
);
})}
+54 -6
View File
@@ -5,6 +5,8 @@ import {
hostTreeInlineGroupDeleteStore,
useHostTreeInlineGroupDeleteTarget,
} from '../../application/state/hostTreeInlineGroupDeleteStore';
import { VaultGroupDeletionConfirmationChangedError } from '../../application/state/useVaultGroupDeletion';
import { toast } from '../ui/toast';
import { Button } from '../ui/button';
import {
Dialog,
@@ -17,11 +19,13 @@ import {
type HostTreeGroupDeleteDialogProps = {
managedGroupPaths?: Set<string>;
managedFilesByGroupPath?: Map<string, string[]>;
onConfirmDelete: (groupPath: string, deleteHosts: boolean) => void | Promise<void>;
};
export const HostTreeGroupDeleteDialog: React.FC<HostTreeGroupDeleteDialogProps> = ({
managedGroupPaths,
managedFilesByGroupPath,
onConfirmDelete,
}) => {
const { t } = useI18n();
@@ -29,6 +33,23 @@ export const HostTreeGroupDeleteDialog: React.FC<HostTreeGroupDeleteDialogProps>
const [deleteHosts, setDeleteHosts] = useState(false);
const isOpen = Boolean(targetPath);
const isManaged = Boolean(targetPath && managedGroupPaths?.has(targetPath));
const descendantManagedFiles: string[] = [];
if (targetPath && managedFilesByGroupPath) {
for (const [groupPath, files] of managedFilesByGroupPath) {
if (
(groupPath === targetPath || groupPath.startsWith(`${targetPath}/`))
) {
for (const filePath of files) {
if (
filePath
&& !descendantManagedFiles.includes(filePath)
) {
descendantManagedFiles.push(filePath);
}
}
}
}
}
useEffect(() => {
if (!isOpen) {
@@ -49,7 +70,9 @@ export const HostTreeGroupDeleteDialog: React.FC<HostTreeGroupDeleteDialogProps>
<DialogDescription className="break-words [overflow-wrap:anywhere]">
{isManaged
? t('vault.groups.deleteDialog.managedDesc')
: t('vault.groups.deleteDialog.desc')}
: descendantManagedFiles.length > 0
? t('vault.groups.deleteDialog.mixedDesc')
: t('vault.groups.deleteDialog.desc')}
</DialogDescription>
</DialogHeader>
<div className="min-w-0 space-y-4 py-4">
@@ -59,6 +82,25 @@ export const HostTreeGroupDeleteDialog: React.FC<HostTreeGroupDeleteDialogProps>
{t('vault.groups.pathLabel')}:{' '}
<span className="font-mono">{targetPath}</span>
</p>
{descendantManagedFiles.length > 0 && (
<div className="space-y-1 rounded-md border border-destructive/40 bg-destructive/10 p-3">
<p className="text-sm text-destructive">
{t('vault.groups.deleteDialog.managedWarning')}
</p>
{descendantManagedFiles.length > 0 && (
<div className="max-h-40 space-y-1 overflow-y-auto">
{descendantManagedFiles.map((filePath) => (
<p
key={filePath}
className="break-all font-mono text-xs text-muted-foreground"
>
{t('vault.groups.deleteDialog.managedFile', { file: filePath })}
</p>
))}
</div>
)}
</div>
)}
{!isManaged && (
<label className="flex items-center gap-2 text-sm cursor-pointer">
<input
@@ -79,12 +121,18 @@ export const HostTreeGroupDeleteDialog: React.FC<HostTreeGroupDeleteDialogProps>
</Button>
<Button
variant="destructive"
onClick={() => {
onClick={async () => {
if (!targetPath) return;
void Promise.resolve(onConfirmDelete(targetPath, isManaged || deleteHosts)).finally(() => {
hostTreeInlineGroupDeleteStore.close();
setDeleteHosts(false);
});
try {
await onConfirmDelete(targetPath, isManaged || deleteHosts);
} catch (error) {
toast.error(error instanceof VaultGroupDeletionConfirmationChangedError
? t('vault.groups.deleteDialog.sourcesChanged')
: error instanceof Error ? error.message : t('common.error'));
return;
}
hostTreeInlineGroupDeleteStore.close();
setDeleteHosts(false);
}}
>
{t('common.delete')}
Loaded 100 of 368 files, more files were not shown because too many files have changed in this diff. Show more