Compare commits

...
Author SHA1 Message Date
bincxz a0d032ec3c fix(terminal): guard password prompts and bypass directory completion for Command arrows 2026-09-12 17:16:38 +08:00
bincxz 28f7193c4a fix(terminal): keep Command arrow edits local during broadcast 2026-09-12 17:03:51 +08:00
bincxz 51d4d97419 feat(terminal): support macOS Command arrows for line navigation 2026-09-12 16:48:31 +08:00
陈大猫andnetcatty-bot e7d7a12a91 fix: prevent AI command truncation on OpenWrt BusyBox (#3342)
* fix: bound AI command input for OpenWrt BusyBox

* fix: address Codex review on PR #3342

* fix: preserve custom prompts and split command history

* fix: preserve zsh history suppression for split AI input

---------

Co-authored-by: netcatty-bot <308658023+netcatty-bot@users.noreply.github.com>
2026-09-12 13:52:09 +08:00
86d8c5279d fix(port-forwarding): add per-rule auto-reconnect on disconnect (#3328)
* fix(#3326): automated AI fix

* fix: address Codex review on PR #3328

* fix: address Codex review on PR #3328

* fix(port-forwarding): honor explicit reconnect preference

* fix(port-forwarding): save auto-start alongside reconnect preference

* fix(port-forwarding): coordinate tray starts in main window

---------

Co-authored-by: netcatty-bot <308658023+netcatty-bot@users.noreply.github.com>
Co-authored-by: bincxz <16399091+binaricat@users.noreply.github.com>
2026-09-12 13:47:34 +08:00
e0cb6034fe fix(ai): surface Codex config.toml third-party models in chat picker (#3345)
* fix(#3344): automated AI fix

* fix: address Codex review on PR #3345

* fix: address Codex review on PR #3345

* fix(ai): honor Codex config models before sends in both runtimes

* fix(ai): use managed Codex environment for login actions

* fix(ai): reuse Codex auth checks within the effective environment

---------

Co-authored-by: netcatty-bot <308658023+netcatty-bot@users.noreply.github.com>
Co-authored-by: bincxz <16399091+binaricat@users.noreply.github.com>
2026-09-12 13:45:19 +08:00
b54c5e3a9b fix(sftp): report empty exec responses distinctly and log SCP fallback reason (#3355)
* fix(#3354): automated AI fix

* fix: address Codex review on PR #3355

* fix: address Codex review on PR #3355

* fix: address Codex review on PR #3355

* fix: address Codex review on PR #3355

* fix: address Codex review on PR #3355

* fix(sftp): preserve fallback diagnostics across worker boundary

---------

Co-authored-by: netcatty-bot <308658023+netcatty-bot@users.noreply.github.com>
Co-authored-by: bincxz <16399091+binaricat@users.noreply.github.com>
2026-09-12 13:36:58 +08:00
keroroyukiandbincxz 15b16ab7b2 fix: serial backspace leaves hidden chars for multi-byte CJK input (#3325)
* fix: serial backspace leaves hidden chars for multi-byte CJK input

Serial terminals process backspace at the byte level: one 0x7F removes
one byte from the device input buffer. For multi-byte CJK characters
(UTF-8: 3 bytes, GB18030: 2 bytes), a single backspace leaves orphan
bytes that render as hidden or garbled characters.

Fixes three layers of the problem:

1. Character mode (createXTermRuntime.ts): when backspace is received
   and the command-buffer's last character is wider than one byte,
   send as many backspace bytes as the character occupies on the wire
   so the device deletes the whole character.

2. Local echo (serialLocalEcho.ts): formatSerialLocalEcho now accepts
   a backspaceCells parameter so CJK ideographs (2 terminal cells)
   erase both cells instead of just one.

3. Line mode (serialLineInput.ts, Terminal.tsx): backspace local echo
   now erases the correct number of cells based on the deleted
   character's display width.

Adds serialCharMetrics.ts with getCharDisplayWidth (East Asian Width)
and getCharByteLength (UTF-8 / GB18030) utilities.

39 new and updated tests all pass.

* fix: address codex review — surrogate pairs, effective encoding, cursor guard, broadcast

Address all 5 P2 Codex review comments on PR #3325:

1. GB18030 4-byte supplementary chars: CJK Extension B+ (cp > 0xFFFF)
   now returns 4 bytes instead of 2, matching iconv-lite's GB18030
   encoder.

2. Effective session encoding: add currentEncodingRef to runtime
   context so toolbar encoding changes propagate to backspace byte
   length calculation (not just the saved host.charset).

3. Cursor-movement guard: track lastInputWasPrintable so backspace
   byte expansion only fires when the cursor is at the end of the
   typed buffer, not after arrow-key escape sequences.

4. Surrogate-pair-safe slicing: getLastChar/removeLastChar helpers
   handle UTF-16 surrogate pairs (emoji, CJK Extension B) so slice(0, -1)
   never leaves an orphan high surrogate.

5. Broadcast expansion: when backspace is expanded for the source
   serial session, the same expanded data is sent to broadcast peers
   instead of the original single-byte backspace.

Adds isPrintableInput, getLastChar, removeLastChar helpers.
56 tests pass.

* fix: address codex review — scope, bracketed paste, cursor confidence

Address 3 new Codex review comments on PR #3325:

1. P1: isExpanded/outData were declared inside the character-mode else
   block but referenced after it (ReferenceError in serial line mode).
   Moved declarations before the if/else so both branches share scope.

2. P2: Bracketed paste (\x1b[200~...\x1b[201~) starts with ESC so
   isPrintableInput returned false, losing tail confidence. Now detected
   and lastInputWasPrintable set to true.

3. P2: Printable input after a cursor movement reset lastInputWasPrintable
   to true even though the cursor was not at the buffer end. Now the flag
   is kept as-is when printable text is typed — only escape sequences
   reset it to false.

* fix: establish cursor-tail confidence for initial and post-submission typing

Address Codex review: lastInputWasPrintable started false, so the first
printable input after session start or line submission would keep it
false, defeating the backspace byte expansion guard.

- Initialize to true: cursor is at the end of an empty buffer at
  session start and after each line submission.
- Reset to true after Enter (handledSubmittedInput), Ctrl+C, and
  Ctrl+U — all three clear the command buffer, placing the cursor
  at the beginning of a fresh line.

This restores the original fix for the common case: type 你, press
Backspace, device receives 3 bytes and deletes the whole character.

* fix: address codex review — broadcast safety, paste cursor state, hibernation wake

Address 3 new Codex review comments on PR #3325:

1. Broadcast safety: stop forwarding expanded backspaces to broadcast
   peers. Each peer has its own protocol, encoding, and command buffer,
   so the source's repeated DELs can over-delete on non-serial peers
   (SSH character-level) or on serial peers with different encodings
   (GB18030 needs 2 DELs, not 3). The source session still receives
   the expanded data; broadcast peers get the original single DEL.

2. Bracketed paste cursor state: no longer unconditionally set
   lastInputWasPrintable=true on paste. After a cursor movement the
   paste inserts at the moved cursor, not the buffer tail, so the
   flag is now preserved as-is (false stays false).

3. Hibernation wake: initialize lastInputWasPrintable conservatively
   when commandBufferRef is nonempty. A hibernated session retains
   its buffer but the pre-hibernation cursor may have been moved,
   so we start with false instead of true.

* fix: grapheme-safe backspace + gate expansion behind byteOrientedBackspace

Address 2 new Codex review comments on PR #3325:

1. Remove the whole grapheme on backspace (P2):
   getLastChar/removeLastChar only handled UTF-16 surrogate pairs, so a
   decomposed grapheme like e + U+0301 returned only the combining mark
   and left the base. Rewrite both with Intl.Segmenter (grapheme
   granularity) so surrogate pairs, combining marks, variation
   selectors, and ZWJ sequences are removed as one unit. getCharByteLength
   and getCharDisplayWidth already compose correctly for graphemes.

2. Gate byte expansion on byte-oriented line editing (P2):
   Character-aware serial endpoints (e.g. a UTF-8 Linux console with
   readline) delete one whole character per DEL, so repeating the
   backspace 3x for a 3-byte char over-deletes. Add an explicit
   serialConfig.byteOrientedBackspace flag (default true, preserving the
   original fix) that users can disable for character-aware editors.
   Wired through the serial connect modal and host details panel, with
   i18n in en/zh-CN/zh-TW/ru/es, vault host validation, and session
   restore/snapshot preservation.

170 tests pass.

* fix: xterm grapheme width for local echo + always compute cell width

Address 2 new Codex review comments on PR #3325:

1. Use xterm's grapheme width for local-echo erasure (P2):
   getCharDisplayWidth returned 1 for emoji-presentation graphemes
   (e.g. ❤️, 1️⃣, ©️) whose base characters fall outside the standard
   emoji range. The 15-graphemes Unicode provider active in
   createXTermRuntime renders these as 2 cells. Now import
   stringCellWidth from terminalStringCellWidth.ts (which uses
   _core.unicodeService.getStringCellWidth when available) in both
   character mode (createXTermRuntime) and line mode (Terminal.tsx,
   serialLineInput). Add VS16 (U+FE0F) detection to both the xterm
   fallback and the domain getCharDisplayWidth so emoji-presentation
   graphemes are correctly 2 cells even without xterm.

2. Compute local-echo width when byte deletion is disabled (P2):
   When byteOrientedBackspace is false, the entire backspace block was
   skipped, leaving backspaceCells at 1 and erasing only half of a
   2-cell CJK/emoji glyph in local echo. Restructured the condition:
   display width is now always computed when backspacing a non-empty
   buffer, while wire-byte expansion is independently gated on
   byteOrientedBackspace.

182 tests pass.

* fix: read byteOrientedBackspace from session snapshot, not live host

Address Codex review (P2): a restored or hibernated session's
snapshotted serialConfig.byteOrientedBackspace can differ from the
current vault host (e.g. session saved false, host later changed to
true). The runtime read the live host instead of the snapshot, so
after recreation a character-aware session could start expanding DEL
and over-delete again.

Add serialByteOrientedBackspace to the runtime context (mirroring
serialLineMode and serialLocalEcho) and pass it from the session
snapshot in useTerminalEffects. Default true preserves the existing
fix for byte-oriented devices.

182 tests pass.

* fix: carry serialByteOrientedBackspace and currentEncodingRef into hibernation wake context

Address Codex review (P2): xTermRuntimeContextRef.current in Terminal.tsx
omitted serialByteOrientedBackspace and currentEncodingRef, so when
hibernateHiddenTabs woke a serial tab the runtime fell back to default
values (byte-oriented=true, host charset). A session snapshot with
byteOrientedBackspace=false would re-enable byte expansion after waking,
and a toolbar-selected encoding would be lost.

Added both fields to the wake context alongside serialLocalEcho and
serialLineMode, mirroring the useTerminalEffects mount path.

182 tests pass.

* fix: tail confidence after line submission, GB18030 4-byte BMP, free-form encodings

Address 3 Codex review comments (all P2):

1. Preserve tail confidence after line submission:
   After Enter submission sets lastInputWasPrintable = true, the same
   \r/\n went through the generic control-char branch and immediately
   reset it to false. This disabled byte expansion and correct local-echo
   width for every subsequent command. Now skip the reset when
   handledSubmittedInput is true.

2. Measure GB18030 bytes with the backend encoder:
   getCharByteLength assumed every BMP non-ASCII character is 2 bytes.
   But BMP characters outside the GBK character set (e.g. U+0100 'Ā')
   encode to 4 bytes in GB18030. Added isGbkCharacter() to check the
   GBK character set ranges; non-GBK BMP characters now return 4.

3. Use the backend charset for free-form serial encodings:
   currentEncodingRef is now always present (hibernation wake fix), so
   the ?? fallback to host.charset never triggered. For a Shift_JIS host
   with no toolbar override, currentEncodingRef.current is 'utf-8' (the
   default), causing UTF-8 byte lengths (3 for あ) instead of Shift_JIS
   (2). Now prefer host.charset when it's non-UTF-8 and the toolbar
   encoding is the default. Also added Shift_JIS support to
   resolveWireEncoding and getCharByteLength (1 byte ASCII, 2 bytes
   non-ASCII).

182 tests pass.

* fix: honor remembered UTF-8 when counting serial bytes

Address Codex review (P2): the previous fix treated every
toolbarEnc === 'utf-8' as an unselected default and overrode it with
the host charset. But a GB18030 host with a remembered UTF-8 preference
has its backend session synced to UTF-8 (via shouldSyncTerminalEncodingOnAttach),
so using the host's GB18030 charset for byte counting is wrong.

Now use resolveTerminalEncodingFromCharset to check if the host charset
is recognized by the toolbar system (UTF-8 or GB18030). For recognized
charsets, the toolbar encoding is authoritative (it may reflect a
remembered override). For unrecognized charsets (e.g. Shift_JIS), the
toolbar encoding falls back to the default (utf-8), so the host charset
is the correct wire encoding.

193 tests pass (including terminalEncodingPreference.test.ts).

* fix: honor toolbar changes for free-form charsets + GB18030 4-byte CJK Ext A

Address 2 Codex review comments (both P2):

1. Honor toolbar changes for free-form serial charsets:
   When a Shift_JIS session starts and the user later selects UTF-8 in
   the toolbar, setSessionEncoding switches the backend to UTF-8, but
   the runtime continued using host.charset because it is outside the
   two-option resolver. Now pass userPickedEncodingRef to the runtime
   context (mirroring existing use in shouldSyncTerminalEncodingOnAttach).
   The effective encoding logic:
   - Non-utf-8 toolbar → trust toolbar (explicit selection)
   - utf-8 toolbar + userPickedEncoding → trust toolbar (explicit utf-8)
   - utf-8 toolbar + recognized charset → trust toolbar (remembered override)
   - utf-8 toolbar + unrecognized charset → use host.charset (backend default)

2. GB18030 4-byte for CJK Extension A:
   isGbkCharacter classified U+3400–U+4DBF (CJK Extension A) as 2-byte
   GBK characters, but iconv-lite encodes this block as 4 bytes in
   GB18030. Removed the range; these characters now correctly return 4.

193 tests pass.

* fix: simplify effective encoding logic with backendSynced flag

Follow-up to previous fix: the 4-level decision tree had an edge case
where a Shift_JIS host with remembered UTF-8 would use host.charset
instead of the synced toolbar encoding. Simplified to a 3-level logic
using backendSynced = userPicked || hasRemembered:

- backendSynced OR recognized charset → trust toolbar encoding
- NOT synced AND unrecognized charset → use host.charset (backend default)

Added hasRememberedEncodingRef to runtime context (alongside existing
userPickedEncodingRef) so the logic mirrors shouldSyncTerminalEncodingOnAttach.

193 tests pass.

* fix: sum grapheme code points, remove Latin-1 from GBK, generic multi-byte fallback

Address 3 Codex review comments (all P2):

1. Sum every code point in non-UTF-8 graphemes:
   GB18030 and Shift_JIS returned after the first non-ASCII code point
   instead of counting the complete grapheme that getLastChar returns.
   ❤️ (U+2764 U+FE0F) is 8 GB18030 bytes, but the old code returned 4.
   Now accumulate byte lengths for all code points, like the UTF-8 path.

2. Stop classifying Latin-1 as two-byte GBK:
   U+00A1–U+00FE was in isGbkCharacter, but iconv-lite encodes these
   as 4 bytes in GB18030. Removed the range; these characters now
   correctly return 4 bytes.

3. Count bytes for every backend-supported serial charset:
   Unknown encodings fell back to UTF-8 byte length, but the backend
   uses iconv-lite for Big5, EUC-JP, Johab, etc. (2 bytes for CJK).
   Changed fallback to 1 byte ASCII + 2 bytes non-ASCII, matching
   iconv-lite's multi-byte encoding for common serial charsets.

Comment 4 (remembered override for free-form charsets) was already
fixed in commit fca4e7c0: backendSynced = userPicked || hasRemembered.

193 tests pass.

* fix: Shift_JIS half-width Katakana as 1 byte (iconv-lite 0xA1-0xDF)

Address Codex review (P2): half-width Katakana characters like ア
(U+FF71) are encoded as single bytes (0xB1) in Shift_JIS by iconv-lite,
but the previous code counted every non-ASCII character as 2 bytes.
Backspace sent 2 DELs, deleting the character and one preceding byte.

Now handle the three 1-byte non-ASCII cases in Shift_JIS:
- Half-width Katakana (U+FF61-U+FF9F, mapped to 0xA1-0xDF) → 1 byte
- ¥ (U+00A5, mapped to 0x5C) → 1 byte
- All other non-ASCII → 2 bytes

Self-review: traced all 4 encoding paths (UTF-8, GB18030, Shift_JIS,
unknown fallback) × all edge cases (single/multi-code-point graphemes,
ASCII/BMP/supplementary, GBK/non-GBK ranges). All paths verified correct.

193 tests pass.

* fix: deduplicate terminal width import after main merge

* test: retain input safety checks with serial erase metadata

* fix: restore serial editing after paste and document byte mode

* fix: keep pending serial input on its original encoding

* fix: restore serial backspace after urgent interruption

* fix: retain editable serial snippet input for backspace

* fix(serial): synchronize pending snippet tail state

* fix(serial): unify empty-line input state across text sources

* fix(serial): track editable startup snippets

* fix(serial): share pending-tail tracking for multiline input

---------

Co-authored-by: bincxz <16399091+binaricat@users.noreply.github.com>
2026-09-12 13:10:52 +08:00
f83b5f6693 fix(sftp): allow copy and paste within the same pane (#3318)
* fix(#3317): automated AI fix

* fix: address Codex review on PR #3318

* fix: address Codex review on PR #3318

* fix: address Codex review on PR #3318

* fix: address Codex review on PR #3318

* fix: address Codex review on PR #3318

* fix: address Codex review on PR #3318

* fix: address Codex review on PR #3318

* fix(sftp): preserve source links during same-pane replacement

* fix(sftp): leave self-directory merge and replace unchanged

* fix(sftp): scope local traversal limits to same-pane copies

* refactor(sftp): centralize same-pane paste filesystem checks

* fix(sftp): preserve source links through bind-mounted parents

---------

Co-authored-by: netcatty-bot <308658023+netcatty-bot@users.noreply.github.com>
Co-authored-by: bincxz <16399091+binaricat@users.noreply.github.com>
2026-09-12 12:25:18 +08:00
8017c144fe feat(cli): bind Skills+CLI chat session via host env (#3347)
* feat(workspace): search hosts by tag (#3001)

Co-authored-by: Cursor <cursoragent@cursor.com>

* feat(cli): bind Skills+CLI chat session via host env

Hide --chat-session from agents so they cannot spoof another chat tenant.
The host injects NETCATTY_CLI_CHAT_SESSION_ID, and Copilot/CodeBuddy
approval gates reject leftover flags and env-prefix overrides.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(ai): isolate Skills+CLI chat env across concurrent SDK turns

Cursor and OpenCode mutate process.env around spawn. Serialize those
writes so overlapping chats cannot inherit or restore the wrong
NETCATTY_CLI_CHAT_SESSION_ID.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Revert "feat(workspace): search hosts by tag (#3001)"

This reverts commit 4b651a3841.

* fix(ai): preserve chat isolation when cancelling Cursor startup

* fix(ai): isolate Cursor turns in cancellable workers

* fix(ai): contain native Cursor cleanup in a utility process

* fix(ai): report Cursor utility process fatal errors

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: bincxz <16399091+binaricat@users.noreply.github.com>
2026-09-12 12:16:57 +08:00
lyz1994andbincxz 462ffbb182 fix(ai): keep AI command execution connected through bastion hosts (#3146) (#3313)
* fix(ai): keep AI command execution connected through bastion hosts (#3146)

Approving an AI assistant command after jumping to a target through a
menu-driven bastion (BHostSSH, QiZhi TERM-SSHD) disconnected the session.
Two separate failures were involved:

1. The AI probe/wrapper was written to the interactive PTY as one
   multi-character channel write. Strict bastions treat one SSH channel
   write as a single keystroke and silently drop multi-character writes, so
   the wrapper never ran; the inactivity timeout then sent Ctrl+C, which the
   bastion read as "exit". Type the probe and wrapper one Unicode code point
   per write to match human keystrokes.

2. ensureSessionShellKindForExec opened a second SSH channel (exec) to probe
   the login shell. Bastions bind the transport to the first interactive
   session and tear it down when a second channel opens. Skip the
   exec-channel probe for bastion banners and let the PTY live shell probe
   determine the wrapper instead.

* fix(ai): preserve paced delivery and scope bastion keystrokes

* fix(ai): pace bastion input for Windows shells too

---------

Co-authored-by: bincxz <16399091+binaricat@users.noreply.github.com>
2026-09-12 12:13:36 +08:00
4288948d94 feat(terminal): add theme foregroundIntense color for bold default-foreground text (#3353)
* fix(#3352): automated AI fix

* fix: address Codex review on PR #3353

* fix: address Codex review on PR #3353

* fix(terminal): render intense foreground without rewriting output

* fix(terminal): refresh intense colors and preserve plugin theme contract

* fix(ci): use archived Bullseye security snapshot after LTS expiry

---------

Co-authored-by: netcatty-bot <308658023+netcatty-bot@users.noreply.github.com>
Co-authored-by: bincxz <16399091+binaricat@users.noreply.github.com>
2026-09-12 11:55:36 +08:00
66deeb8dd9 feat(vault): 新增 FinalShell 主机导入功能 (#3331)
* feat(vault): 新增 FinalShell 主机导入功能

本次提交完整实现了 FinalShell 连接的导入支持,包括:
1.  新增导入格式检测与解析逻辑,支持解密 FinalShell 加密密码
2.  适配导入流程 UI,新增导入选项与批量目录导入支持
3.  补充多语言国际化文案与测试用例
4.  更新所有相关文档与配置文件

* fix(vault): align FinalShell import guards and agent guidance

* fix(vault): detect FinalShell connections without usernames

* fix(vault): keep password validation lint compliant

* fix(ci): use archived Bullseye security packages for ARM builds

* fix(vault): retain FinalShell profiles and accept BOM JSON

* fix(ci): align archived security source with main

---------

Co-authored-by: YS-zdck <admin@zdck8.com>
Co-authored-by: bincxz <16399091+binaricat@users.noreply.github.com>
2026-09-12 11:53:24 +08:00
f0b3bca13f fix(ssh): measure stats latency with SSH ping instead of raw TCP probes (#3321)
* fix(#3320): automated AI fix

* fix: address Codex review on PR #3321

* fix: address Codex review on PR #3321

* fix: address Codex review on PR #3321

* fix(ssh): honor keepalive opt-out during stats latency checks

* fix(ci): pin retired Bullseye security packages to official snapshot

---------

Co-authored-by: netcatty-bot <308658023+netcatty-bot@users.noreply.github.com>
Co-authored-by: bincxz <16399091+binaricat@users.noreply.github.com>
2026-09-12 11:22:43 +08:00
6c05f0b450 fix(terminal): re-anchor reading position to content across fit reflow (#3319)
* fix(#3299): automated AI fix

* fix: address Codex review on PR #3319

* fix: address Codex review on PR #3319

* fix: address Codex review on PR #3319

* fix: address Codex review on PR #3319

* fix: address Codex review on PR #3319

* fix: address Codex review on PR #3319

* fix: address Codex review on PR #3319

* fix: address Codex review on PR #3319

* fix: address Codex review on PR #3319

* fix: address Codex review on PR #3319

* fix: address Codex review on PR #3319

* fix: address Codex review on PR #3319

* fix: address Codex review on PR #3319

* fix: address Codex review on PR #3319

* fix: address Codex review on PR #3319

* fix: address Codex review on PR #3319

* fix: address Codex review on PR #3319

* fix: address Codex review on PR #3319

* fix: address Codex review on PR #3319

* fix: address Codex review on PR #3319

* fix: address Codex review on PR #3319

* fix: address Codex review on PR #3319

* fix: address Codex review on PR #3319

* fix: address Codex review on PR #3319

* fix: address Codex review on PR #3319

* fix: address Codex review on PR #3319

* fix: address Codex review on PR #3319

* fix: address Codex review on PR #3319

* fix: address Codex review on PR #3319

* fix: address Codex review on PR #3319

* fix: address Codex review on PR #3319

* fix: address Codex review on PR #3319

* fix: address Codex review on PR #3319

* fix: address Codex review on PR #3319

* fix: address Codex review on PR #3319

* fix(terminal): retain reading offset throughout resize drags

* fix(terminal): retain reflow offset beside cursor 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-12 11:11:07 +08:00
aaced70fc6 fix(tabs): add Close Tabs to the Left to tab context menu (#3340)
* fix(#3339): automated AI fix

* fix: address Codex review on PR #3340

* fix: address Codex review on PR #3340

* fix: address Codex review on PR #3340

* fix: address Codex review on PR #3340

* fix: address Codex review on PR #3340

* fix: address Codex review on PR #3340

* fix: address Codex review on PR #3340

* fix(tabs): guard complete batch close and verify editor safety

---------

Co-authored-by: netcatty-bot <308658023+netcatty-bot@users.noreply.github.com>
Co-authored-by: bincxz <16399091+binaricat@users.noreply.github.com>
2026-09-12 10:55:36 +08:00
Eric ChanandCursor 891faff31c feat(workspace): search hosts by tag (#3001) (#3337)
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-12 10:46:42 +08:00
08557ff596 test(sftp): lock in OTP transport reuse for SFTP-page uploads (#3311)
* fix(#3310): automated AI fix

* fix: address Codex review on PR #3311

* fix: address Codex review on PR #3311

* test(sftp): settle pending OTP connections during teardown

* test(sftp): fail promptly on redundant upload OTP prompt

---------

Co-authored-by: netcatty-bot <308658023+netcatty-bot@users.noreply.github.com>
Co-authored-by: bincxz <16399091+binaricat@users.noreply.github.com>
2026-09-12 10:46:20 +08:00
Netcatty-Botandnetcatty-bot 7f75662c9f fix(terminal): detect bold in metric-aligned variable fonts via ink coverage (#3336)
* fix(#3335): automated AI fix

* fix: address Codex review on PR #3336

* fix: address Codex review on PR #3336

---------

Co-authored-by: netcatty-bot <308658023+netcatty-bot@users.noreply.github.com>
2026-09-12 10:45:05 +08:00
Netcatty-Botandnetcatty-bot 561f4b3c71 fix(#3327): automated AI fix (#3329)
Co-authored-by: netcatty-bot <308658023+netcatty-bot@users.noreply.github.com>
2026-09-12 10:44:49 +08:00
6ea6c23e88 fix(ai): send x-opencode-session for OpenCode providers and apply customHeaders (#3350)
* fix(#3349): automated AI fix

* test(ai): verify provider headers at SDK request boundary

---------

Co-authored-by: netcatty-bot <308658023+netcatty-bot@users.noreply.github.com>
Co-authored-by: bincxz <16399091+binaricat@users.noreply.github.com>
2026-09-12 10:40:39 +08:00
Netcatty-Botandnetcatty-bot 55c2b18738 fix(#3332): automated AI fix (#3333)
Co-authored-by: netcatty-bot <308658023+netcatty-bot@users.noreply.github.com>
2026-09-12 10:35:31 +08:00
Netcatty-Botandgithub-actions[bot] 8be56264fc Update Nix release metadata for v1.1.83 (#3316)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-09-07 18:04:01 +08:00
陈大猫 d7a0c8ed8d fix(build): use the keychain password for macOS signing (#3315) 2026-09-07 17:41:30 +08:00
陈大猫 65afea01e0 fix: unify host system detection and simplify host details (#3312)
* fix: unify host system detection and simplify host details

* fix: retain detected systems for temporary terminal hosts

* fix: detect remote systems after Mosh becomes ready

* fix: show the effective device mode in host system details
2026-09-07 16:31:11 +08:00
fe2f14f032 feat(ai): add Mention Note context picker for Vault Notes (#3290)
* fix(#3289): automated AI fix

* fix: address Codex review on PR #3290

* fix: address Codex review on PR #3290

* fix: address Codex review on PR #3290

* fix: address Codex review on PR #3290

* fix: address Codex review on PR #3290

* fix: address Codex review on PR #3290

* fix: address Codex review on PR #3290

* fix: address Codex review on PR #3290

* fix: address Codex review on PR #3290

* fix: address Codex review on PR #3290

* fix: address Codex review on PR #3290

* fix: address Codex review on PR #3290

* fix: address Codex review on PR #3290

* fix: address Codex review on PR #3290

* fix: address Codex review on PR #3290

* fix: address Codex review on PR #3290

* fix: address Codex review on PR #3290

* fix: address Codex review on PR #3290

* fix: address Codex review on PR #3290

* fix: address Codex review on PR #3290

* fix: address Codex review on PR #3290

* fix: address Codex review on PR #3290

* fix(ai): apply note attachment budgets synchronously

* fix(ai): reuse normalized note search fields while typing

* fix(ai): retain draft state binding in panel host

* fix(ai): reject carriage returns in note references

* fix(ai): protect continuation before pruning note payloads

* refactor(ai): keep note mentions as lightweight references

* fix(ai): gate note references on reader availability

* fix(ai): retain note references ahead of replay prose

* refactor(ai): share one instruction across note references

* fix(ai): keep accepted note references within replay space

* Bound agent note reads and add resumable literal search

* Preserve compact note requests and announce picker selection

* Retain mixed attachment context in compact note replay

* Keep imported long note identities readable

* Keep note picker selection and untitled labels consistent

---------

Co-authored-by: netcatty-bot <308658023+netcatty-bot@users.noreply.github.com>
Co-authored-by: bincxz <16399091+binaricat@users.noreply.github.com>
2026-09-07 14:32:08 +08:00
1a9c57b0a9 fix(terminal): keep reading position across fit resize and settled output (#3300)
* fix(#3299): automated AI fix

* fix: address Codex review on PR #3300

* fix: address Codex review on PR #3300

* fix: address Codex review on PR #3300

* fix: address Codex review on PR #3300

* fix: address Codex review on PR #3300

* fix: address Codex review on PR #3300

* fix: address Codex review on PR #3300

* fix: address Codex review on PR #3300

* fix: address Codex review on PR #3300

* fix: address Codex review on PR #3300

* fix: address Codex review on PR #3300

* fix: address Codex review on PR #3300

* fix: address Codex review on PR #3300

* fix: address Codex review on PR #3300

* fix: address Codex review on PR #3300

* fix: address Codex review on PR #3300

* fix: address Codex review on PR #3300

* fix: address Codex review on PR #3300

* fix: address Codex review on PR #3300

* fix: address Codex review on PR #3300

* fix: address Codex review on PR #3300

* fix: address Codex review on PR #3300

* fix: address Codex review on PR #3300

* fix(terminal): defer fit until synchronized output settles

---------

Co-authored-by: netcatty-bot <308658023+netcatty-bot@users.noreply.github.com>
Co-authored-by: bincxz <16399091+binaricat@users.noreply.github.com>
2026-09-07 11:46:23 +08:00
058727b0d9 fix(ssh): refresh login on new sessions and reconnects (#3294)
* fix(#3293): automated AI fix

* fix(ssh): refresh new sessions and discard reconnect source reuse

---------

Co-authored-by: netcatty-bot <308658023+netcatty-bot@users.noreply.github.com>
Co-authored-by: bincxz <16399091+binaricat@users.noreply.github.com>
2026-09-07 11:41:48 +08:00
陈大猫andnetcatty-bot 35ed0b6dbd fix(terminal): preserve row boundaries in alternate history preview (#3297)
* fix(terminal): preserve row boundaries in alternate history preview

* fix(terminal): retain columns across history row moves

* fix(terminal): keep cursor-only moves empty and overwrite whole glyphs

* fix: address Codex review on PR #3297

* fix: address Codex review on PR #3297

* fix: address Codex review on PR #3297

* fix: address Codex review on PR #3297

* fix: address Codex review on PR #3297

* fix(terminal): consume unprintable wide glyphs in history capture

* fix(terminal): preserve deferred wrap between display chunks

* fix: address Codex review on PR #3297

* fix: address Codex review on PR #3297

* fix: address Codex review on PR #3297

* fix: address Codex review on PR #3297

* fix: address Codex review on PR #3297

* fix: address Codex review on PR #3297

* fix: address Codex review on PR #3297

* fix: address Codex review on PR #3297

* fix: address Codex review on PR #3297

* fix: address Codex review on PR #3297

* fix: address Codex review on PR #3297

* fix: address Codex review on PR #3297

* fix(terminal): retain blank rows when reflowing history

* fix: address Codex review on PR #3297

* fix: address Codex review on PR #3297

* fix: address Codex review on PR #3297

* fix: address Codex review on PR #3297

* fix(terminal): refresh cached history after resize trims text

* fix(terminal): synchronize history with actual resized buffer

* fix(terminal): preserve final wide glyph during nonwrapping overflow

* fix(terminal): restore saved cursor modes in history

---------

Co-authored-by: netcatty-bot <308658023+netcatty-bot@users.noreply.github.com>
2026-09-07 11:11:10 +08:00
陈大猫 ab80d04039 fix(ci): allow exact research helper paths in Claude runs (#3306) 2026-09-07 10:30:46 +08:00
陈大猫 aaac962179 fix(sftp): share pending resume feedback across transfer views (#3305) 2026-09-07 10:30:42 +08:00
陈大猫 5d61462b20 fix(terminal): keep full-width history preview rows visible (#3304)
* fix(terminal): align history preview with rendered cell dimensions

* fix(terminal): fit wide history rows without changing copied text

* fix(terminal): select every history preview row
2026-09-07 10:30:38 +08:00
陈大猫 8cc8d938b8 fix(ai): preserve user text during request compression (#3303) 2026-09-07 10:30:35 +08:00
陈大猫andnetcatty-bot 1babda6fa9 fix(sync): resume auto-sync after first master-key setup (#3302)
* fix(sync): retry peer unlock when the shared password becomes available

* fix(sync): retain deliberate locks after availability notifications

* fix(sync): preserve deliberate locks after peer password events

* fix(sync): retain deliberate locks across consumer remounts

* fix: address Codex review on PR #3302

* fix: address Codex review on PR #3302

* fix: address Codex review on PR #3302

* fix: address Codex review on PR #3302

* test(sync): cover pending unlock owner handoff

* fix(sync): distinguish stale unlocks from invalid passwords

* fix(sync): retry completed auto unlock after consumers remount

* fix(sync): share auto unlock retry generation across consumers

---------

Co-authored-by: netcatty-bot <308658023+netcatty-bot@users.noreply.github.com>
2026-09-07 10:30:30 +08:00
陈大猫 0eebc77b1b fix(ssh): resolve watchdog PID without an intermediate shell (#3301) 2026-09-07 10:30:26 +08:00
陈大猫andnetcatty-bot 7069bdb1c8 fix(ai): keep live shell probes out of Bash history (#3298)
* fix(ai): keep live shell probes out of Bash history

* fix(ai): bypass custom eval when cleaning probe history

* fix(ai): clean Bash probes without external shell detection

* fix(ai): keep probe cleanup nonfatal in errexit shells

* fix(ai): bypass custom builtin definitions in history cleanup

* fix: address Codex review on PR #3298

* fix(ai): verify history cleanup across dispatcher fallbacks

* fix: address Codex review on PR #3298

* fix: address Codex review on PR #3298

* fix: address Codex review on PR #3298

* fix: address Codex review on PR #3298

* fix: address Codex review on PR #3298

* fix(ai): deliver shell probes without input overruns

* fix: address Codex review on PR #3298

* fix(ai): suspend wait timers during paced terminal input

* fix(ai): bound long command assignments for canonical terminals

* fix(ai): clear canonical pending input without a control command

---------

Co-authored-by: netcatty-bot <308658023+netcatty-bot@users.noreply.github.com>
2026-09-07 10:30:22 +08:00
陈大猫 10ad14223f fix(sftp): isolate side panel snapshots across tab switches (#3296) 2026-09-07 10:30:15 +08:00
陈大猫 f7f0d804af fix(settings): restore AI detection after effect replay (#3295) 2026-09-07 10:30:11 +08:00
陈大猫 4ba196510d fix(terminal): avoid duplicate broadcast keypress text (#3292) 2026-09-07 10:30:07 +08:00
陈大猫 fd54339367 fix(sftp): settle direct folder downloads after pause and resume (#3291) 2026-09-07 10:30:03 +08:00
github-actions[bot]andgithub-actions[bot] 28b9a8db89 chore: update star history charts [skip ci] (#3307)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-09-07 00:10:36 +00:00
cabdcdf31c fix(logs): adaptive light/dark theme for HTML session logs (#3264)
* fix(#3263): automated AI fix

* fix(logs): keep adaptive HTML colors readable

* fix(logs): adapt extended foreground colors to light pages

* perf(logs): calculate light foreground colors without iteration

---------

Co-authored-by: netcatty-bot <308658023+netcatty-bot@users.noreply.github.com>
Co-authored-by: bincxz <16399091+binaricat@users.noreply.github.com>
2026-09-06 15:11:13 +08:00
陈大猫andnetcatty-bot e2a9e54a90 fix(sftp): preserve concurrent files during local publication (#3286)
* fix(sftp): preserve concurrent files during local publication

* fix(sftp): preserve metadata during fallback restoration

* fix: address Codex review on PR #3286

* fix(sftp): prepare timestamps before publishing local downloads

* fix: address Codex review on PR #3286

* fix(sftp): preserve file access through restricted-mode publication

* fix(sftp): retry timestamps when preparation fails

* fix(sftp): verify unreadable destination recovery before replacement

---------

Co-authored-by: netcatty-bot <308658023+netcatty-bot@users.noreply.github.com>
2026-09-06 14:23:15 +08:00
296 changed files with 17020 additions and 1899 deletions

No files matched your search

+2
View File
@@ -877,6 +877,7 @@ jobs:
--bare -p --permission-mode dontAsk \
--settings "$HOME/.claude/settings.json" \
--allowedTools "Read" "Bash(web-search *)" "Bash(web-fetch *)" \
"Bash($research_dir/web-search *)" "Bash($research_dir/web-fetch *)" \
--disallowedTools "WebSearch" "WebFetch" "Edit" "Write" "NotebookEdit" \
--output-format stream-json --verbose --model "$AI_MODEL" \
"$prompt"
@@ -1695,6 +1696,7 @@ jobs:
--bare -p --permission-mode dontAsk \
--settings "$HOME/.claude/settings.json" \
--allowedTools "Read" "Bash(web-search *)" "Bash(web-fetch *)" \
"Bash($research_dir/web-search *)" "Bash($research_dir/web-fetch *)" \
--disallowedTools "WebSearch" "WebFetch" "Edit" "Write" "NotebookEdit" \
--output-format stream-json --verbose --model "$AI_MODEL" \
"$prompt"
+6 -5
View File
@@ -76,6 +76,7 @@ on:
- "scripts/mosh-extra-resources.cjs"
- "scripts/nodePtyConptyPatch.cjs"
- "scripts/patch-xterm-macos-column-selection.cjs"
- "scripts/patch-xterm-foreground-intense.cjs"
- "scripts/patch-xterm-webgl-atlas.cjs"
- "scripts/rebuildPatchedNodePty.cjs"
- "scripts/resolve-et-bin-release.cjs"
@@ -695,11 +696,11 @@ jobs:
shell: bash
run: |
set -euo pipefail
# Security CDN endpoints can lack files advertised in their indexes.
# Fall back per file, with the final Bullseye security snapshot for packages only.
# Live signed indexes still select versions and verify every download.
printf 'http://deb.debian.org/debian-security\tpriority:1\nhttp://security.debian.org/debian-security\tpriority:2\nhttp://snapshot.debian.org/archive/debian-security/20260901T000000Z/\tpriority:3 type:deb\n' > /etc/apt/security-mirrors.txt
sed -i 's|http://deb.debian.org/debian-security|mirror+file:/etc/apt/security-mirrors.txt|g' /etc/apt/sources.list
# Bullseye LTS ended on 2026-08-31; its security index is now frozen.
# Pin the final snapshot and waive expiry only for this archived source.
# APT still verifies its signature and every package's checksum.
sed -i '/ bullseye-security /d' /etc/apt/sources.list
printf 'deb [check-valid-until=no] http://snapshot.debian.org/archive/debian-security/20260901T000000Z/ bullseye-security main\n' >> /etc/apt/sources.list
apt-get -o Acquire::Retries=4 update
apt-get -o Acquire::Retries=4 install -y curl build-essential python3 git libfuse2 file rpm \
libarchive-tools \
+102 -48
View File
@@ -1,57 +1,111 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { closeTabsBatchImpl } from './app/AppHandlers.ts';
test('batch tab close removes standalone sessions in one state update', async () => {
const closedSessionBatches: string[][] = [];
const probedSessionBatches: string[][] = [];
const closeTabsInFlightRef = { current: false };
const sessions = [
{ id: 's1', protocol: 'ssh' },
{ id: 's2', protocol: 'ssh' },
{ id: 's3', protocol: 'ssh' },
];
function setup() {
const closed: string[] = [];
const probes: string[][] = [];
const editors = [{ id: 'e', sessionId: 'connection', sftpTabId: 'pane' }];
let active = 's1';
const ctx = {
closeLogView: (id: string) => { closed.push(id); },
closeSessions: (ids: string[]) => { closed.push(...ids); },
closeTabsInFlightRef: { current: false },
closeWorkspace: (id: string) => { closed.push(id); },
confirmIfBusyLocalTerminal: async (ids: string[]) => { probes.push(ids); return true; },
logViews: [{ id: 'log' }],
sessions: [{ id: 's1', workspaceId: null as string | null }, { id: 's2', workspaceId: null as string | null }],
workspaces: [{ id: 'ws' }],
activeTabStore: { getActiveTabId: () => active, setActiveTabId: (id: string) => { active = id; } },
editorTabStore: { getTabs: () => editors },
pluginViewTabStore: {
getTab: (id: string) => id === 'plugin',
close: (id: string) => { closed.push(id); },
},
handleRequestCloseEditorTabRef: { current: async (id: string) => {
closed.push(`editor:${id}`);
editors.splice(editors.findIndex((editor) => editor.id === id), 1);
return true;
} },
findEditorSftpOwnerTabId: (_connection?: string, _pane?: string): string | null => null,
orderedTabsWithEditors: ['s1', 's2', 'ws', 'editor:e', 'log', 'plugin'],
};
return { ctx, closed, probes, run: (ids: string[]) => closeTabsBatchImpl(() => ctx, ids) };
}
const result = await closeTabsBatchImpl(
() => ({
closeLogView: () => {},
closeSessions: (sessionIds: string[]) => closedSessionBatches.push(sessionIds),
closeTabsInFlightRef,
closeWorkspace: () => {},
confirmIfBusyLocalTerminal: async (sessionIds: string[]) => {
probedSessionBatches.push(sessionIds);
return true;
},
logViews: [],
sessions,
workspaces: [],
}),
['s1', 's2', 's3'],
);
assert.deepEqual(probedSessionBatches, [['s1', 's2', 's3']]);
assert.deepEqual(closedSessionBatches, [['s1', 's2', 's3']]);
assert.equal(closeTabsInFlightRef.current, false);
assert.equal(result, true);
test('batch closes sessions, workspaces, editors, logs and plugins and selects surviving tab', async () => {
const { ctx, closed, probes, run } = setup();
assert.equal(await run(['s1', 'ws', 'editor:e', 'log', 'plugin']), true);
assert.deepEqual(closed, ['editor:e', 's1', 'ws', 'log', 'plugin']);
assert.deepEqual(probes, [['s1']]);
assert.equal(ctx.activeTabStore.getActiveTabId(), 's2');
assert.equal(ctx.closeTabsInFlightRef.current, false);
});
test('batch tab close reports cancellation before mutating any tab', async () => {
let mutated = false;
const result = await closeTabsBatchImpl(
() => ({
closeLogView: () => { mutated = true; },
closeSessions: () => { mutated = true; },
closeTabsInFlightRef: { current: false },
closeWorkspace: () => { mutated = true; },
confirmIfBusyLocalTerminal: async () => false,
logViews: [],
sessions: [{ id: 's1', protocol: 'local' }],
workspaces: [],
}),
['s1'],
);
test('busy cancellation happens before any editor or other tab mutation', async () => {
const { ctx, closed, run } = setup();
ctx.confirmIfBusyLocalTerminal = async () => false;
assert.equal(await run(ctx.orderedTabsWithEditors), false);
assert.deepEqual(closed, []);
assert.equal(ctx.activeTabStore.getActiveTabId(), 's1');
assert.equal(ctx.closeTabsInFlightRef.current, false);
});
assert.equal(result, false);
assert.equal(mutated, false);
test('cancelled editor preserves its owner and active focus', async () => {
const { ctx, closed, run } = setup();
ctx.findEditorSftpOwnerTabId = () => 's1';
ctx.handleRequestCloseEditorTabRef.current = async () => false;
await run(ctx.orderedTabsWithEditors);
assert.deepEqual(closed, ['s2', 'ws', 'log', 'plugin']);
assert.equal(ctx.activeTabStore.getActiveTabId(), 's1');
});
test('editor outside close-left range preserves its owner', async () => {
const { ctx, closed, run } = setup();
ctx.findEditorSftpOwnerTabId = () => 's1';
await run(['s1']);
assert.deepEqual(closed, []);
});
test('preserved workspace containing an editor owner remains focused', async () => {
const { ctx, closed, probes, run } = setup();
ctx.sessions[0].workspaceId = 'ws';
ctx.findEditorSftpOwnerTabId = () => 's1';
ctx.activeTabStore.setActiveTabId('ws');
await run(['ws']);
assert.deepEqual(closed, []);
assert.deepEqual(probes, [['s1']]);
assert.equal(ctx.activeTabStore.getActiveTabId(), 'ws');
});
test('cancelled editor becomes focus destination when other tabs close', async () => {
const { ctx, run } = setup();
ctx.handleRequestCloseEditorTabRef.current = async () => false;
await run(ctx.orderedTabsWithEditors);
assert.equal(ctx.activeTabStore.getActiveTabId(), 'editor:e');
});
for (const phase of ['busy', 'editor'] as const) {
test(`overlapping batch is ignored during ${phase} prompt, including editor-only batches`, async () => {
const { ctx, closed, run } = setup();
let release!: (result: boolean) => void;
const pending = new Promise<boolean>((resolve) => { release = resolve; });
if (phase === 'busy') ctx.confirmIfBusyLocalTerminal = () => pending;
else ctx.handleRequestCloseEditorTabRef.current = () => pending;
const first = run(phase === 'busy' ? ['s1'] : ['editor:e']);
assert.equal(ctx.closeTabsInFlightRef.current, true);
assert.equal(await run(['s2', 'plugin']), false);
assert.deepEqual(closed, []);
release(false);
await first;
assert.equal(ctx.closeTabsInFlightRef.current, false);
});
}
test('failed prompt releases guard so next batch can run', async () => {
const { ctx, run } = setup();
ctx.handleRequestCloseEditorTabRef.current = async () => { throw new Error('save failed'); };
await assert.rejects(run(['editor:e']), /save failed/);
assert.equal(ctx.closeTabsInFlightRef.current, false);
assert.equal(await run(['s1']), true);
});
+82 -41
View File
@@ -9,8 +9,11 @@ import { sanitizeHostIconFields } from '../../domain/hostIcon';
import { resolveEffectiveTerminalProtocol } from '../../domain/terminalProtocol';
import { getTerminalPassthroughActions } from '../state/useGlobalHotkeys';
import { tabShortcutDigitFromEvent } from '../../domain/models/keyBindings';
import { isPortForwardingAutoReconnectEnabled } from '../../domain/portForwardingReconnect';
import { buildNumberShortcutTabTargets } from './tabShortcutTargets';
import { captureInheritedCwd } from '../state/inheritedCwd';
import { fromEditorTabId, isEditorTabId } from '../state/activeTabStore';
import { resolveBatchTabCloseFocus } from '../state/pluginViewTabStore';
type AppContextGetter = () => Record<string, any>;
const TERMINAL_PASSTHROUGH_ACTIONS = getTerminalPassthroughActions();
@@ -151,7 +154,7 @@ export function handleTrayTogglePortForwardImpl(getCtx: AppContextGetter, ruleId
const effectiveHost = resolveEffectiveHost(host);
void startTunnel(rule, effectiveHost, hosts.map(resolveEffectiveHost), keys, identities, (status, error) => {
if (status === "error" && error) toast.error(error);
}, rule.autoStart, terminalSettings, knownHosts);
}, isPortForwardingAutoReconnectEnabled(rule), terminalSettings, knownHosts);
}
return;
}
@@ -677,48 +680,86 @@ export async function confirmIfBusyLocalTerminalImpl(getCtx: AppContextGetter, s
}
}
export async function closeTabsBatchImpl(getCtx: AppContextGetter, targetIds: string[]) {
const { closeLogView, closeSessions, closeTabsInFlightRef, closeWorkspace, confirmIfBusyLocalTerminal, logViews, sessions, workspaces } = getCtx();
{
if (targetIds.length === 0) return true;
if (closeTabsInFlightRef.current) return false;
// Expand workspace ids into their constituent session ids so the busy
// probe sees every local shell that's about to be killed.
const sessionIdsToProbe: string[] = [];
for (const tabId of targetIds) {
const ws = workspaces.find((w) => w.id === tabId);
if (ws) {
for (const s of sessions) {
if (s.workspaceId === tabId) sessionIdsToProbe.push(s.id);
}
} else if (sessions.find((s) => s.id === tabId)) {
sessionIdsToProbe.push(tabId);
}
}
closeTabsInFlightRef.current = true;
try {
const ok = await confirmIfBusyLocalTerminal(sessionIdsToProbe);
if (!ok) return false;
const standaloneSessionIds = targetIds.filter((tabId) => (
sessions.some((session) => session.id === tabId)
));
if (standaloneSessionIds.length > 0) {
closeSessions(standaloneSessionIds);
}
for (const tabId of targetIds) {
if (workspaces.find((w) => w.id === tabId)) {
closeWorkspace(tabId);
} else if (logViews.find((lv) => lv.id === tabId)) {
closeLogView(tabId);
}
}
return true;
} finally {
closeTabsInFlightRef.current = false;
/**
* Expand batch-close target ids into the terminal session ids whose local
* shells the busy probe must inspect: workspace ids contribute every session
* they contain; standalone session ids pass through.
*/
export function collectBatchBusyProbeSessionIds(
sessions: readonly { id: string; workspaceId?: string | null }[],
workspaces: readonly { id: string }[],
targetIds: readonly string[],
): string[] {
const sessionIdsToProbe: string[] = [];
for (const tabId of targetIds) {
const ws = workspaces.find((w) => w.id === tabId);
if (ws) {
for (const s of sessions) {
if (s.workspaceId === tabId) sessionIdsToProbe.push(s.id);
}
} else if (sessions.find((s) => s.id === tabId)) {
sessionIdsToProbe.push(tabId);
}
}
return sessionIdsToProbe;
}
export async function closeTabsBatchImpl(getCtx: AppContextGetter, targetIds: string[]) {
const {
closeLogView, closeSessions, closeTabsInFlightRef, closeWorkspace,
confirmIfBusyLocalTerminal, logViews, sessions, workspaces,
activeTabStore, editorTabStore, pluginViewTabStore,
handleRequestCloseEditorTabRef, findEditorSftpOwnerTabId, orderedTabsWithEditors,
} = getCtx();
if (targetIds.length === 0) return true;
if (closeTabsInFlightRef.current) return false;
closeTabsInFlightRef.current = true;
try {
const activeBeforeClose = activeTabStore.getActiveTabId();
const pluginIds = targetIds.filter((id) => pluginViewTabStore.getTab(id));
const editorIds = targetIds.filter(isEditorTabId);
const regularIds = targetIds.filter((id) => !pluginViewTabStore.getTab(id) && !isEditorTabId(id));
// Cancel busy-terminal confirmation before making any changes, including editors.
if (regularIds.length && !await confirmIfBusyLocalTerminal(
collectBatchBusyProbeSessionIds(sessions, workspaces, regularIds),
)) return false;
const closedEditorIds: string[] = [];
for (const id of editorIds) {
if (await handleRequestCloseEditorTabRef.current(fromEditorTabId(id))) closedEditorIds.push(id);
}
// Owners must remain mounted while any editor survives (cancelled or outside
// the range), because unmounting their SFTP panel discards its editors.
const keepTabIds = new Set<string>();
for (const editor of editorTabStore.getTabs()) {
const owner = findEditorSftpOwnerTabId(editor.sessionId, editor.sftpTabId);
if (owner) keepTabIds.add(owner);
}
const effectiveRegularIds = regularIds.filter((id) => !keepTabIds.has(id) && !(
workspaces.some((workspace) => workspace.id === id) &&
sessions.some((session) => session.workspaceId === id && keepTabIds.has(session.id))
));
const standaloneSessionIds = effectiveRegularIds.filter((id) => sessions.some((session) => session.id === id));
if (standaloneSessionIds.length) closeSessions(standaloneSessionIds);
for (const id of effectiveRegularIds) {
if (workspaces.some((workspace) => workspace.id === id)) closeWorkspace(id);
else if (logViews.some((logView) => logView.id === id)) closeLogView(id);
}
for (const id of pluginIds) pluginViewTabStore.close(id);
const closingTabIds = new Set([...effectiveRegularIds, ...closedEditorIds, ...pluginIds]);
if (closingTabIds.has(activeBeforeClose)) {
activeTabStore.setActiveTabId(resolveBatchTabCloseFocus({
orderedTabIds: orderedTabsWithEditors,
closingTabIds,
activeTabId: activeBeforeClose,
}));
}
return true;
} finally {
closeTabsInFlightRef.current = false;
}
}
export function executeHotkeyActionImpl(getCtx: AppContextGetter, action: string, e: KeyboardEvent) {
@@ -225,3 +225,9 @@ test('AppShell uses default memo (store-driven), not always-rerender comparator'
assert.match(appShellSource, /memo\s*\(\s*AppShellView\s*\)/);
assert.doesNotMatch(appShellSource, /memo\s*\(\s*AppShellView\s*,\s*\(\s*\)\s*=>\s*false\s*\)/);
});
test('terminal system detection routes through the owner of temporary hosts', () => {
const sideEffects = readFileSync(join(here, 'AppSideEffects.tsx'), 'utf8');
assert.match(hostSources['TerminalHost.tsx'], /updateHostDistro: handlers\.updateTerminalHostDistro/);
assert.match(sideEffects, /if \(ephemeralHostIds\.has\(hostId\)\) \{\s*setEphemeralHosts\([\s\S]*?applyEphemeralHostDistroUpdate[\s\S]*?return;\s*\}\s*updateHostDistro\(hostId, distro\)/);
});
@@ -0,0 +1,28 @@
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import test from 'node:test';
const here = dirname(fileURLToPath(import.meta.url));
const appSideEffectsSource = readFileSync(join(here, 'AppSideEffects.tsx'), 'utf8');
test('terminal keyboard focus tracking is always published to the main process', () => {
assert.match(
appSideEffectsSource,
/useTerminalKeyboardFocus\(\s*\)/,
'the terminal keyboard focus signal must be unconditional',
);
});
test('terminal keyboard focus is not gated on hotkey scheme or font zoom settings', () => {
// When the signal is turned off, the main process falls back to window zoom
// for Ctrl+= / Ctrl+- / Ctrl+0 while the user is typing in a terminal, so
// "disable terminal zoom" and per-binding overrides stop having any effect
// on those chords (#3327).
assert.doesNotMatch(appSideEffectsSource, /useTerminalKeyboardFocus\(\s*hotkeyScheme/);
assert.doesNotMatch(
appSideEffectsSource,
/useTerminalKeyboardFocus\(\s*[^)\s][^)]*disableTerminalFontZoom/,
);
});
+30 -25
View File
@@ -21,11 +21,11 @@ import { getNotesSnapshot } from '../state/notesStore';
import { useVaultAgentBridge } from '../state/useVaultAgentBridge';
import { useWindowControls } from '../state/useWindowControls';
import { useTerminalKeyboardFocus } from '../state/useTerminalKeyboardFocus';
import { useEditorTabChromeList } from '../state/editorTabStore';
import { editorTabStore, useEditorTabChromeList } from '../state/editorTabStore';
import { findEditorSftpOwnerTabId } from '../state/editorSftpOwnerRegistry';
import {
isPluginViewTabId,
pluginViewTabStore,
resolveBatchTabCloseFocus,
usePluginViewTabs,
} from '../state/pluginViewTabStore';
import {
@@ -43,7 +43,7 @@ import { materializeHostProxyProfile } from '../../domain/proxyProfiles';
import { buildSshDeepLinkConnectionHost, buildSshDeepLinkEphemeralHost, buildSshDeepLinkEphemeralHostFromSaved, buildSshDeepLinkHostDraft, findSshDeepLinkHost, parseSshDeepLink } from '../../domain/sshDeepLink';
import { buildTelnetDeepLinkConnectionHost, buildTelnetDeepLinkEphemeralHostFromSaved, buildTelnetDeepLinkOpenHost, findTelnetDeepLinkHost, materializeTelnetDeepLinkMatchHost, parseTelnetDeepLink } from '../../domain/telnetDeepLink';
import { buildJmsDeepLinkEphemeralHost, isSupportedJmsProtocol, parseJmsDeepLink } from '../../domain/jmsDeepLink';
import { applyEphemeralHostsUpdate, splitHostsUpdateByEphemeral } from '../../domain/ephemeralHosts';
import { applyEphemeralHostDistroUpdate, applyEphemeralHostsUpdate, splitHostsUpdateByEphemeral } from '../../domain/ephemeralHosts';
import { resolveHostAuth } from '../../domain/sshAuth';
import { isEncryptedCredentialPlaceholder, stripSyncPayloadEncryptedCredentials } from '../../domain/credentials';
import {
@@ -139,14 +139,17 @@ export function AppSideEffects() {
terminalSettings,
hotkeyScheme,
keyBindings,
disableTerminalFontZoom,
isHotkeyRecording,
showSftpTab,
shellOnlyTabNumberShortcuts,
workspaceFocusStyle,
} = settings;
useTerminalKeyboardFocus(hotkeyScheme !== 'disabled' && !disableTerminalFontZoom);
// Always publish terminal keyboard focus to the main process. When font zoom
// is disabled (or hotkeys are off) the renderer ignores the font chords, so
// the main process must keep handing them to the page instead of falling
// back to window zoom while the user is typing in a terminal (#3327).
useTerminalKeyboardFocus();
const discoveredShells = useDiscoveredShells();
@@ -202,6 +205,7 @@ export function AppSideEffects() {
addConnectionLog,
updateConnectionLog,
updateHostLastConnected,
updateHostDistro,
importDataFromString,
readPersistedHosts,
groupConfigs,
@@ -1009,27 +1013,18 @@ export function AppSideEffects() {
}, [orderedTabsWithEditors]);
// Close many tabs at once with a single batched busy-shell confirmation.
// Used by the "Close all / Close others / Close to the right" context-menu
// actions on tabs (#748).
// Used by the "Close all / Close others / Close to the left / Close to the
// right" context-menu actions on tabs (#748).
const closeTabsBatch = useCallback(
async (targetIds: string[]) => {
const closingTabIds = new Set(targetIds);
const activeBeforeClose = activeTabStore.getActiveTabId();
const focusAfterClose = resolveBatchTabCloseFocus({
orderedTabIds: orderedTabsWithEditors,
closingTabIds,
activeTabId: activeBeforeClose,
});
const pluginIds = targetIds.filter((id) => pluginViewTabStore.getTab(id));
const regularIds = targetIds.filter((id) => !pluginViewTabStore.getTab(id));
const canClose = !regularIds.length || await closeTabsBatchImpl(
() => ({ closeLogView, closeSessions, closeTabsInFlightRef, closeWorkspace, confirmIfBusyLocalTerminal, logViews, sessions, targetIds: regularIds, workspaces }),
regularIds,
);
if (!canClose) return;
for (const id of pluginIds) pluginViewTabStore.close(id);
if (closingTabIds.has(activeBeforeClose)) activeTabStore.setActiveTabId(focusAfterClose);
},
(targetIds: string[]) => closeTabsBatchImpl(
() => ({
closeLogView, closeSessions, closeTabsInFlightRef, closeWorkspace,
confirmIfBusyLocalTerminal, logViews, sessions, workspaces,
activeTabStore, editorTabStore, pluginViewTabStore,
handleRequestCloseEditorTabRef, findEditorSftpOwnerTabId, orderedTabsWithEditors,
}),
targetIds,
),
[workspaces, sessions, logViews, confirmIfBusyLocalTerminal, closeWorkspace, closeSessions, closeLogView, orderedTabsWithEditors],
);
@@ -1751,6 +1746,14 @@ export function AppSideEffects() {
updateHosts(vaultHosts);
}, [ephemeralHostIds, updateHosts]);
const updateTerminalHostDistro = useCallback((hostId: string, distro: string) => {
if (ephemeralHostIds.has(hostId)) {
setEphemeralHosts((previous) => applyEphemeralHostDistroUpdate(previous, hostId, distro));
return;
}
updateHostDistro(hostId, distro);
}, [ephemeralHostIds, updateHostDistro]);
// Wrapper to create serial session with logging
const handleConnectSerial = useCallback((config: SerialConfig, options?: { charset?: string }) => {
const { username, hostname } = systemInfoRef.current;
@@ -1886,6 +1889,7 @@ export function AppSideEffects() {
handleTerminalDataCapture,
handleUpdateHostFromTerminal,
updateTerminalHosts,
updateTerminalHostDistro,
runSnippet: handleRunSnippet,
splitSessionWithCurrentShell,
toggleScriptsSidePanelRef,
@@ -1962,6 +1966,7 @@ export function AppSideEffects() {
handleTerminalDataCapture,
handleUpdateHostFromTerminal,
updateTerminalHosts,
updateTerminalHostDistro,
handleRunSnippet,
splitSessionWithCurrentShell,
handleEndSessionDrag,
+1 -4
View File
@@ -20,7 +20,6 @@ import {
import { useThemeRuntime, useTerminalAppearanceInjection } from '../../state/useThemeRuntime';
import {
useVaultSnapshot,
useVaultSnapshotActions,
} from '../../state/vaultSnapshotStore';
import { getAppHandlers, subscribeAppHandlers } from '../appHandlersBridge';
import { publishAppShellDomainSlice } from '../appShellPropsStore';
@@ -37,7 +36,6 @@ export function TerminalHost() {
const session = useSessionSnapshot();
const sessionActions = useSessionSnapshotActions();
const vault = useVaultSnapshot();
const vaultActions = useVaultSnapshotActions();
const terminalSettings = useTerminalSettingsStore();
const terminalSettingsActions = useTerminalSettingsActions();
const {
@@ -247,7 +245,7 @@ export function TerminalHost() {
terminalPaneMagnificationRef: handlers.terminalPaneMagnificationRef,
sftpPaneMagnificationRef: handlers.sftpPaneMagnificationRef,
toggleWorkspaceViewMode: sessionActions?.toggleWorkspaceViewMode,
updateHostDistro: vaultActions?.updateHostDistro,
updateHostDistro: handlers.updateTerminalHostDistro,
updateSplitSizes: sessionActions?.updateSplitSizes,
updateSessionFontSize: sessionActions?.updateSessionFontSize,
updateSessionRestoreCwd: sessionActions?.updateSessionRestoreCwd,
@@ -275,7 +273,6 @@ export function TerminalHost() {
terminalHosts,
terminalSettings,
terminalSettingsActions,
vaultActions,
]);
useLayoutEffect(() => {
+7
View File
@@ -497,6 +497,13 @@ export const enAiMessages: Messages = {
'ai.chat.menuFiles': 'Files',
'ai.chat.menuImage': 'Image',
'ai.chat.menuMentionHost': 'Mention Host',
'ai.chat.menuMentionNote': 'Mention Note',
'ai.chat.mentionNoteSearch': 'Search notes…',
'ai.chat.mentionNoteEmpty': 'No matching notes',
'ai.chat.mentionNoteUnavailable': 'This agent cannot read Vault notes in the current connection mode.',
'ai.chat.mentionNoteTooMany': 'These notes cannot all be referenced together. Please select fewer notes.',
'ai.chat.mentionNoteInvalid': '"{{title}}" could not be attached: the note has an invalid identifier.',
'ai.chat.untitledNote': 'Untitled note',
'ai.chat.menuUserSkills': 'User Skills',
'ai.chat.menuSlashCommands': 'Slash Commands',
'ai.chat.slashCommands': 'Slash commands',
+1 -1
View File
@@ -77,7 +77,7 @@ export const enCoreMessages: Messages = {
'field.selectHosts': 'Select Hosts',
'placeholder.workspaceName': 'Workspace name',
'placeholder.sessionName': 'Session name',
'placeholder.searchHosts': 'Search hosts...',
'placeholder.searchHosts': 'Search hosts or tags...',
'toast.settingsUnavailable': 'Settings window is unavailable on this platform.',
'credentials.protectionUnavailable.title': 'Credential Protection Unavailable',
'credentials.protectionUnavailable.message': 'Saved passwords and keys cannot be auto-decrypted on this device. Re-enter credentials before connecting.',
+5
View File
@@ -251,6 +251,7 @@ export const enTerminalMessages: Messages = {
'terminal.customTheme.group.bright': 'Bright Colors',
'terminal.customTheme.color.background': 'Background',
'terminal.customTheme.color.foreground': 'Foreground',
'terminal.customTheme.color.foregroundIntense': 'Intense foreground (bold)',
'terminal.customTheme.color.cursor': 'Cursor',
'terminal.customTheme.color.selection': 'Selection',
'terminal.customTheme.color.black': 'Black',
@@ -637,6 +638,7 @@ export const enTerminalMessages: Messages = {
'tabs.copyTabToNewWindow': 'Copy Tab to New Window',
'tabs.copyTabToNewWindowFailed': 'Failed to open tab in a new window',
'tabs.closeOthers': 'Close Others',
'tabs.closeToLeft': 'Close Tabs to the Left',
'tabs.closeToRight': 'Close Tabs to the Right',
'tabs.closeAll': 'Close All',
'keychain.edit.labelRequired': 'Label *',
@@ -807,6 +809,9 @@ export const enTerminalMessages: Messages = {
'serial.field.lineModeDesc': 'Buffer input and send on Enter (instead of character-by-character)',
'serial.field.backspaceBehavior': 'Backspace key',
'serial.field.backspaceBehaviorDesc': 'Use Ctrl+H for network devices that do not respond to the default Backspace code.',
'serial.encoding.pendingInput': 'Submit or clear the current input before changing the encoding.',
'serial.field.byteOrientedBackspace': 'Byte-Oriented Backspace',
'serial.field.byteOrientedBackspaceDesc': 'Send one DEL per byte so the device deletes a whole multi-byte character (for devices that delete bytes). Disable for character-aware line editors (e.g. Linux readline).',
'serial.backspace.default': 'Default (DEL, 0x7F)',
'serial.backspace.ctrlH': 'Ctrl+H (BS, 0x08)',
'serial.field.charset': 'Charset',
+14 -2
View File
@@ -116,6 +116,11 @@ export const enVaultMessages: Messages = {
'vault.import.securecrt.folderDesc': 'Import every session and keep the folder structure.',
'vault.import.securecrt.file': 'Single session file',
'vault.import.securecrt.fileDesc': 'Import one SecureCRT .ini session.',
'vault.import.finalshell.promptTitle': 'Import from FinalShell',
'vault.import.finalshell.folder': 'conn folder',
'vault.import.finalshell.folderDesc': 'Import every JSON connection and keep the folder structure.',
'vault.import.finalshell.file': 'Single connection file',
'vault.import.finalshell.fileDesc': 'Import one FinalShell JSON connection.',
'vault.import.csv.tip': 'Bulk import: use the CSV template.',
'vault.import.csv.downloadTemplate': 'Download CSV template',
'vault.import.toast.start': 'Importing from {format}...',
@@ -261,6 +266,8 @@ export const enVaultMessages: Messages = {
'pf.deleteActive.confirm': 'Stop and Delete',
'pf.form.autoStart': 'Auto Start',
'pf.form.autoStartDesc': 'Automatically start this rule when the app launches',
'pf.form.autoReconnect': 'Auto Reconnect',
'pf.form.autoReconnectDesc': 'Automatically reconnect when the connection drops',
// SFTP
'sftp.newFolder': 'New Folder',
@@ -710,8 +717,13 @@ export const enVaultMessages: Messages = {
'hostDetails.section.address': 'Address',
'hostDetails.hostname.placeholder': 'IP or Hostname',
'hostDetails.section.general': 'General',
'hostDetails.os.title': 'Operating System',
'hostDetails.os.desc': 'Used for environment reporting and shell-aware behavior.',
'hostDetails.os.title': 'System identification',
'hostDetails.os.desc': 'Prefer automatic detection. Override only when the detected system is incorrect. Network Device Mode is configured separately.',
'hostDetails.os.auto': 'Automatic',
'hostDetails.os.unknown': 'Other / unknown',
'hostDetails.os.selection': 'System selection',
'hostDetails.os.current': 'Current system',
'hostDetails.os.freebsd': 'FreeBSD',
'hostDetails.os.linux': 'Linux',
'hostDetails.os.windows': 'Windows',
'hostDetails.os.macos': 'macOS',
+7
View File
@@ -497,6 +497,13 @@ export const esAiMessages: Messages = {
'ai.chat.menuFiles': 'Archivos',
'ai.chat.menuImage': 'Imagen',
'ai.chat.menuMentionHost': 'Mencionar host',
'ai.chat.menuMentionNote': 'Mencionar nota',
'ai.chat.mentionNoteSearch': 'Buscar notas…',
'ai.chat.mentionNoteEmpty': 'Sin notas coincidentes',
'ai.chat.mentionNoteUnavailable': 'Este agente no puede leer notas de la bóveda en el modo de conexión actual.',
'ai.chat.mentionNoteTooMany': 'No se pueden referenciar todas estas notas juntas. Selecciona menos notas.',
'ai.chat.mentionNoteInvalid': 'No se pudo adjuntar «{{title}}»: la nota tiene un identificador no válido.',
'ai.chat.untitledNote': 'Nota sin título',
'ai.chat.menuUserSkills': 'Habilidades de usuario',
'ai.chat.menuSlashCommands': 'Comandos de barra',
'ai.chat.slashCommands': 'Comandos de barra',
+1 -1
View File
@@ -77,7 +77,7 @@ export const esCoreMessages: Messages = {
'field.selectHosts': 'Seleccionar hosts',
'placeholder.workspaceName': 'Nombre del espacio de trabajo',
'placeholder.sessionName': 'Nombre de la sesión',
'placeholder.searchHosts': 'Buscar hosts...',
'placeholder.searchHosts': 'Buscar hosts o etiquetas...',
'toast.settingsUnavailable': 'La ventana de configuración no está disponible en esta plataforma.',
'credentials.protectionUnavailable.title': 'Protección de credenciales no disponible',
'credentials.protectionUnavailable.message': 'Las contraseñas y claves guardadas no se pueden descifrar automáticamente en este dispositivo. Vuelve a ingresar las credenciales antes de conectarte.',
+5
View File
@@ -251,6 +251,7 @@ export const esTerminalMessages: Messages = {
'terminal.customTheme.group.bright': 'Colores brillantes',
'terminal.customTheme.color.background': 'Fondo',
'terminal.customTheme.color.foreground': 'Primer plano',
'terminal.customTheme.color.foregroundIntense': 'Primer plano intenso (negrita)',
'terminal.customTheme.color.cursor': 'Cursor',
'terminal.customTheme.color.selection': 'Selección',
'terminal.customTheme.color.black': 'Negro',
@@ -637,6 +638,7 @@ export const esTerminalMessages: Messages = {
'tabs.copyTabToNewWindow': 'Copiar pestaña a una nueva ventana',
'tabs.copyTabToNewWindowFailed': 'No se pudo abrir la pestaña en una nueva ventana',
'tabs.closeOthers': 'Cerrar las demás',
'tabs.closeToLeft': 'Cerrar pestañas a la izquierda',
'tabs.closeToRight': 'Cerrar pestañas a la derecha',
'tabs.closeAll': 'Cerrar todas',
'keychain.edit.labelRequired': 'Etiqueta *',
@@ -807,6 +809,9 @@ export const esTerminalMessages: Messages = {
'serial.field.lineModeDesc': 'Almacenar en búfer la entrada y enviarla al presionar Enter (en lugar de carácter por carácter)',
'serial.field.backspaceBehavior': 'Tecla de retroceso',
'serial.field.backspaceBehaviorDesc': 'Usa Ctrl+H para dispositivos de red que no responden al código de Retroceso predeterminado.',
'serial.encoding.pendingInput': 'Envía o borra la entrada actual antes de cambiar la codificación.',
'serial.field.byteOrientedBackspace': 'Retroceso orientado a bytes',
'serial.field.byteOrientedBackspaceDesc': 'Envía un DEL por byte para que el dispositivo elimine un carácter multibyte completo (para dispositivos que eliminan por bytes). Desactívelo para editores de línea conscientes de caracteres (p. ej. readline de Linux).',
'serial.backspace.default': 'Predeterminado (DEL, 0x7F)',
'serial.backspace.ctrlH': 'Ctrl+H (BS, 0x08)',
'serial.field.charset': 'Codificación de caracteres',
+14 -2
View File
@@ -116,6 +116,11 @@ export const esVaultMessages: Messages = {
'vault.import.securecrt.folderDesc': 'Importa todas las sesiones y conserva la estructura de carpetas.',
'vault.import.securecrt.file': 'Archivo de sesión único',
'vault.import.securecrt.fileDesc': 'Importa una sesión .ini de SecureCRT.',
'vault.import.finalshell.promptTitle': 'Importar desde FinalShell',
'vault.import.finalshell.folder': 'Carpeta conn',
'vault.import.finalshell.folderDesc': 'Importa todas las conexiones JSON y conserva la estructura de carpetas.',
'vault.import.finalshell.file': 'Archivo de conexión único',
'vault.import.finalshell.fileDesc': 'Importa una conexión JSON de FinalShell.',
'vault.import.csv.tip': 'Importación masiva: usa la plantilla CSV.',
'vault.import.csv.downloadTemplate': 'Descargar plantilla CSV',
'vault.import.toast.start': 'Importando desde {format}...',
@@ -261,6 +266,8 @@ export const esVaultMessages: Messages = {
'pf.deleteActive.confirm': 'Detener y eliminar',
'pf.form.autoStart': 'Inicio automático',
'pf.form.autoStartDesc': 'Inicia automáticamente esta regla cuando se abra la aplicación',
'pf.form.autoReconnect': 'Reconexión automática',
'pf.form.autoReconnectDesc': 'Vuelve a conectar automáticamente si se pierde la conexión',
// SFTP
'sftp.newFolder': 'Nueva carpeta',
@@ -710,8 +717,13 @@ export const esVaultMessages: Messages = {
'hostDetails.section.address': 'Dirección',
'hostDetails.hostname.placeholder': 'IP o nombre de host',
'hostDetails.section.general': 'General',
'hostDetails.os.title': 'Sistema operativo',
'hostDetails.os.desc': 'Se usa para informar del entorno y adaptar el comportamiento del shell.',
'hostDetails.os.title': 'Identificación del sistema',
'hostDetails.os.desc': 'Usa la detección automática. Selecciona un sistema solo si la detección es incorrecta. El modo de dispositivo de red se configura por separado.',
'hostDetails.os.auto': 'Automático',
'hostDetails.os.unknown': 'Otro / desconocido',
'hostDetails.os.selection': 'Selección del sistema',
'hostDetails.os.current': 'Sistema actual',
'hostDetails.os.freebsd': 'FreeBSD',
'hostDetails.os.linux': 'Linux',
'hostDetails.os.windows': 'Windows',
'hostDetails.os.macos': 'macOS',
+7
View File
@@ -449,6 +449,13 @@ export const ruAiMessages: Messages = {
'ai.chat.menuFiles': 'Файлы',
'ai.chat.menuImage': 'Изображение',
'ai.chat.menuMentionHost': 'Упомянуть хост',
'ai.chat.menuMentionNote': 'Упомянуть заметку',
'ai.chat.mentionNoteSearch': 'Поиск заметок…',
'ai.chat.mentionNoteEmpty': 'Нет подходящих заметок',
'ai.chat.mentionNoteUnavailable': 'Этот агент не может читать заметки хранилища в текущем режиме подключения.',
'ai.chat.mentionNoteTooMany': 'Невозможно сослаться на все эти заметки одновременно. Выберите меньше заметок.',
'ai.chat.mentionNoteInvalid': '«{{title}}» не удалось прикрепить: у заметки недопустимый идентификатор.',
'ai.chat.untitledNote': 'Заметка без названия',
'ai.chat.menuUserSkills': 'Пользовательские навыки',
'ai.chat.menuSlashCommands': 'Команды /',
'ai.chat.slashCommands': 'Команды /',
+1 -1
View File
@@ -76,7 +76,7 @@ export const ruCoreMessages: Messages = {
'field.selectHosts': 'Выбрать хосты',
'placeholder.workspaceName': 'Имя рабочего пространства',
'placeholder.sessionName': 'Имя сессии',
'placeholder.searchHosts': 'Поиск хостов...',
'placeholder.searchHosts': 'Поиск хостов или тегов...',
'toast.settingsUnavailable': 'Окно настроек недоступно на этой платформе.',
'credentials.protectionUnavailable.title': 'Защита учётных данных недоступна',
'credentials.protectionUnavailable.message': 'Сохранённые пароли и ключи не могут быть автоматически расшифрованы на этом устройстве. Перед подключением введите учётные данные заново.',
+5
View File
@@ -269,6 +269,7 @@ export const ruTerminalMessages: Messages = {
'terminal.customTheme.group.bright': 'Яркие цвета',
'terminal.customTheme.color.background': 'Фон',
'terminal.customTheme.color.foreground': 'Текст',
'terminal.customTheme.color.foregroundIntense': 'Яркий текст (жирный)',
'terminal.customTheme.color.cursor': 'Курсор',
'terminal.customTheme.color.selection': 'Выделение',
'terminal.customTheme.color.black': 'Чёрный',
@@ -652,6 +653,7 @@ export const ruTerminalMessages: Messages = {
'tabs.copyTabToNewWindow': 'Копировать вкладку в новое окно',
'tabs.copyTabToNewWindowFailed': 'Не удалось открыть вкладку в новом окне',
'tabs.closeOthers': 'Закрыть остальные',
'tabs.closeToLeft': 'Закрыть вкладки слева',
'tabs.closeToRight': 'Закрыть вкладки справа',
'tabs.closeAll': 'Закрыть все',
'keychain.edit.labelRequired': 'Метка *',
@@ -814,6 +816,9 @@ export const ruTerminalMessages: Messages = {
'serial.field.lineModeDesc': 'Буферизовать ввод и отправлять по Enter (вместо посимвольной отправки)',
'serial.field.backspaceBehavior': 'Клавиша Backspace',
'serial.field.backspaceBehaviorDesc': 'Используйте Ctrl+H для сетевых устройств, которые не реагируют на код Backspace по умолчанию.',
'serial.encoding.pendingInput': 'Отправьте или очистите текущий ввод перед сменой кодировки.',
'serial.field.byteOrientedBackspace': 'Побайтовый Backspace',
'serial.field.byteOrientedBackspaceDesc': 'Отправляет по одному DEL на байт, чтобы устройство удалило весь многобайтовый символ (для устройств, удаляющих побайтово). Отключите для редакторов, удаляющих посимвольно (например, readline в Linux).',
'serial.backspace.default': 'По умолчанию (DEL, 0x7F)',
'serial.backspace.ctrlH': 'Ctrl+H (BS, 0x08)',
'serial.field.charset': 'Кодировка',
+14 -2
View File
@@ -154,6 +154,11 @@ export const ruVaultMessages: Messages = {
'vault.import.securecrt.folderDesc': 'Импортировать все сеансы с сохранением структуры папок.',
'vault.import.securecrt.file': 'Один файл сеанса',
'vault.import.securecrt.fileDesc': 'Импортировать один файл сеанса SecureCRT .ini.',
'vault.import.finalshell.promptTitle': 'Импорт из FinalShell',
'vault.import.finalshell.folder': 'Папка conn',
'vault.import.finalshell.folderDesc': 'Импортировать все JSON-подключения с сохранением структуры папок.',
'vault.import.finalshell.file': 'Один файл подключения',
'vault.import.finalshell.fileDesc': 'Импортировать одно JSON-подключение FinalShell.',
'vault.import.csv.tip': 'Массовый импорт: используйте шаблон CSV.',
'vault.import.csv.downloadTemplate': 'Скачать шаблон CSV',
'vault.import.toast.start': 'Импорт из {format}...',
@@ -299,6 +304,8 @@ export const ruVaultMessages: Messages = {
'pf.deleteActive.confirm': 'Остановить и удалить',
'pf.form.autoStart': 'Автозапуск',
'pf.form.autoStartDesc': 'Автоматически запускать это правило при запуске приложения',
'pf.form.autoReconnect': 'Автопереподключение',
'pf.form.autoReconnectDesc': 'Автоматически восстанавливать соединение при обрыве',
// SFTP
'sftp.newFolder': 'Новая папка',
@@ -747,8 +754,13 @@ export const ruVaultMessages: Messages = {
'hostDetails.section.address': 'Адрес',
'hostDetails.hostname.placeholder': 'IP или имя хоста',
'hostDetails.section.general': 'Общие',
'hostDetails.os.title': 'Операционная система',
'hostDetails.os.desc': 'Используется для сведений о среде и адаптации командной оболочки.',
'hostDetails.os.title': 'Определение системы',
'hostDetails.os.desc': 'Используйте автоматическое определение. Укажите систему вручную только при ошибке. Режим сетевого устройства настраивается отдельно.',
'hostDetails.os.auto': 'Автоматически',
'hostDetails.os.unknown': 'Другая / неизвестно',
'hostDetails.os.selection': 'Выбор системы',
'hostDetails.os.current': 'Текущая система',
'hostDetails.os.freebsd': 'FreeBSD',
'hostDetails.os.linux': 'Linux',
'hostDetails.os.windows': 'Windows',
'hostDetails.os.macos': 'macOS',
+7
View File
@@ -497,6 +497,13 @@ export const zhCNAiMessages: Messages = {
'ai.chat.menuFiles': '文件',
'ai.chat.menuImage': '图片',
'ai.chat.menuMentionHost': '提及主机',
'ai.chat.menuMentionNote': '提及笔记',
'ai.chat.mentionNoteSearch': '搜索笔记…',
'ai.chat.mentionNoteEmpty': '没有匹配的笔记',
'ai.chat.mentionNoteUnavailable': '当前连接方式下,此 AI 无法读取保险箱笔记。',
'ai.chat.mentionNoteTooMany': '这些笔记无法一起引用,请减少选择。',
'ai.chat.mentionNoteInvalid': '无法附加「{{title}}」:笔记的标识符无效。',
'ai.chat.untitledNote': '未命名笔记',
'ai.chat.menuUserSkills': '用户 Skills',
'ai.chat.menuSlashCommands': '快捷命令',
'ai.chat.slashCommands': '快捷命令',
+6
View File
@@ -61,6 +61,7 @@ export const zhCNCoreMessages: Messages = {
'field.name': '名称',
'placeholder.workspaceName': '工作区名称',
'placeholder.sessionName': '会话名称',
'placeholder.searchHosts': '搜索主机或标签...',
'toast.settingsUnavailable': '当前平台无法打开设置窗口。',
'credentials.protectionUnavailable.title': '凭据保护不可用',
'credentials.protectionUnavailable.message': '当前设备无法自动解密已保存的密码和密钥。连接前请重新输入凭据。',
@@ -781,6 +782,11 @@ export const zhCNCoreMessages: Messages = {
'vault.import.securecrt.folderDesc': '一次导入全部会话,并保留目录结构。',
'vault.import.securecrt.file': '单个会话文件',
'vault.import.securecrt.fileDesc': '导入一个 SecureCRT .ini 会话。',
'vault.import.finalshell.promptTitle': '从 FinalShell 导入',
'vault.import.finalshell.folder': 'conn 文件夹',
'vault.import.finalshell.folderDesc': '导入全部 JSON 连接,并保留目录结构。',
'vault.import.finalshell.file': '单个连接文件',
'vault.import.finalshell.fileDesc': '导入一个 FinalShell JSON 连接。',
'vault.import.csv.tip': '批量导入:可使用 CSV 模板填写后导入。',
'vault.import.csv.downloadTemplate': '下载 CSV 模板',
'vault.import.toast.start': '正在从 {format} 导入...',
@@ -722,6 +722,7 @@ export const zhCNTerminalMessages: Messages = {
'tabs.copyTabToNewWindow': '复制标签页到新窗口',
'tabs.copyTabToNewWindowFailed': '无法在新窗口打开标签页',
'tabs.closeOthers': '关闭其他标签',
'tabs.closeToLeft': '关闭左侧标签',
'tabs.closeToRight': '关闭右侧标签',
'tabs.closeAll': '关闭所有标签',
'keychain.edit.labelRequired': 'Label *',
@@ -892,6 +893,9 @@ export const zhCNTerminalMessages: Messages = {
'serial.field.lineModeDesc': '缓冲输入,按回车后发送(而不是逐字符发送)',
'serial.field.backspaceBehavior': '回退键',
'serial.field.backspaceBehaviorDesc': '如果网络设备不响应默认回退键,请改用 Ctrl+H。',
'serial.encoding.pendingInput': '请先提交或清空当前输入,再切换编码。',
'serial.field.byteOrientedBackspace': '按字节退格',
'serial.field.byteOrientedBackspaceDesc': '发送与字符字节数相同的 DEL,使设备删除整个多字节字符(适用于按字节删除的设备)。若为逐字符删除的行编辑器(如 Linux readline),请关闭。',
'serial.backspace.default': '默认(DEL,0x7F)',
'serial.backspace.ctrlH': 'Ctrl+H(BS,0x08)',
'serial.field.charset': '字符编码',
+10 -2
View File
@@ -133,8 +133,13 @@ export const zhCNVaultMessages: Messages = {
'hostDetails.section.address': '地址',
'hostDetails.hostname.placeholder': 'IP 或 主机名',
'hostDetails.section.general': '通用',
'hostDetails.os.title': '操作系统',
'hostDetails.os.desc': '用于环境信息和适配对应的命令行行为。',
'hostDetails.os.title': '系统识别',
'hostDetails.os.desc': '默认自动识别,仅在识别错误时手动指定。网络设备模式单独设置。',
'hostDetails.os.auto': '自动识别',
'hostDetails.os.unknown': '其他 / 未知',
'hostDetails.os.selection': '识别方式',
'hostDetails.os.current': '当前系统',
'hostDetails.os.freebsd': 'FreeBSD',
'hostDetails.os.linux': 'Linux',
'hostDetails.os.windows': 'Windows',
'hostDetails.os.macos': 'macOS',
@@ -639,6 +644,7 @@ export const zhCNVaultMessages: Messages = {
'terminal.customTheme.group.bright': '高亮色',
'terminal.customTheme.color.background': '背景',
'terminal.customTheme.color.foreground': '前景',
'terminal.customTheme.color.foregroundIntense': '高亮前景(粗体)',
'terminal.customTheme.color.cursor': '光标',
'terminal.customTheme.color.selection': '选区',
'terminal.customTheme.color.black': '黑色',
@@ -974,6 +980,8 @@ export const zhCNVaultMessages: Messages = {
'pf.deleteActive.confirm': '关闭并删除',
'pf.form.autoStart': '自动启动',
'pf.form.autoStartDesc': '应用启动时自动开启此规则',
'pf.form.autoReconnect': '自动重连',
'pf.form.autoReconnectDesc': '连接断开后自动尝试重新连接',
// SFTP (pane + conflict)
'sftp.pane.local': '本地',
+7
View File
@@ -496,6 +496,13 @@ export const zhTWAiMessages: Messages = {
'ai.chat.menuFiles': '檔案',
'ai.chat.menuImage': '圖片',
'ai.chat.menuMentionHost': '提及主機',
'ai.chat.menuMentionNote': '提及筆記',
'ai.chat.mentionNoteSearch': '搜尋筆記…',
'ai.chat.mentionNoteEmpty': '沒有符合的筆記',
'ai.chat.mentionNoteUnavailable': '目前連線方式下,此 AI 無法讀取保險箱筆記。',
'ai.chat.mentionNoteTooMany': '這些筆記無法一起引用,請減少選擇。',
'ai.chat.mentionNoteInvalid': '無法附加「{{title}}」:筆記的識別碼無效。',
'ai.chat.untitledNote': '未命名筆記',
'ai.chat.menuUserSkills': '使用者 Skills',
'ai.chat.menuSlashCommands': '快捷指令',
'ai.chat.slashCommands': '快捷指令',
+6 -1
View File
@@ -63,7 +63,7 @@ export const zhTWCoreMessages: Messages = {
'field.selectHosts': '選擇主機',
'placeholder.workspaceName': '工作區名稱',
'placeholder.sessionName': '工作階段名稱',
'placeholder.searchHosts': '搜尋主機...',
'placeholder.searchHosts': '搜尋主機或標籤...',
'toast.settingsUnavailable': '目前平台無法開啟設定視窗。',
'credentials.protectionUnavailable.title': '憑證保護不可用',
'credentials.protectionUnavailable.message': '目前裝置無法自動解密已儲存的密碼和金鑰。連線前請重新輸入憑證。',
@@ -784,6 +784,11 @@ export const zhTWCoreMessages: Messages = {
'vault.import.securecrt.folderDesc': '一次匯入全部工作階段,並保留目錄結構。',
'vault.import.securecrt.file': '單一工作階段檔案',
'vault.import.securecrt.fileDesc': '匯入一個 SecureCRT .ini 工作階段。',
'vault.import.finalshell.promptTitle': '從 FinalShell 匯入',
'vault.import.finalshell.folder': 'conn 資料夾',
'vault.import.finalshell.folderDesc': '匯入全部 JSON 連線,並保留目錄結構。',
'vault.import.finalshell.file': '單一連線檔案',
'vault.import.finalshell.fileDesc': '匯入一個 FinalShell JSON 連線。',
'vault.import.csv.tip': '批次匯入:可使用 CSV 模板填寫後匯入。',
'vault.import.csv.downloadTemplate': '下載 CSV 模板',
'vault.import.toast.start': '正在從 {format} 匯入...',
@@ -722,6 +722,7 @@ export const zhTWTerminalMessages: Messages = {
'tabs.copyTabToNewWindow': '複製分頁到新視窗',
'tabs.copyTabToNewWindowFailed': '無法在新視窗開啟分頁',
'tabs.closeOthers': '關閉其他分頁',
'tabs.closeToLeft': '關閉左側分頁',
'tabs.closeToRight': '關閉右側分頁',
'tabs.closeAll': '關閉所有分頁',
'keychain.edit.labelRequired': 'Label *',
@@ -892,6 +893,9 @@ export const zhTWTerminalMessages: Messages = {
'serial.field.lineModeDesc': '緩衝輸入,按 Enter 鍵後傳送(而不是逐字元傳送)',
'serial.field.backspaceBehavior': '退格鍵',
'serial.field.backspaceBehaviorDesc': '如果網路裝置不回應預設退格鍵,請改用 Ctrl+H。',
'serial.encoding.pendingInput': '請先提交或清空目前輸入,再切換編碼。',
'serial.field.byteOrientedBackspace': '按位元組退格',
'serial.field.byteOrientedBackspaceDesc': '傳送與字元位元組數相同的 DEL,使裝置刪除整個多位元組字元(適用於按位元組刪除的裝置)。若為逐字元刪除的行編輯器(如 Linux readline),請關閉。',
'serial.backspace.default': '預設(DEL,0x7F)',
'serial.backspace.ctrlH': 'Ctrl+H(BS,0x08)',
'serial.field.charset': '字元編碼',
+10 -2
View File
@@ -132,8 +132,13 @@ export const zhTWVaultMessages: Messages = {
'hostDetails.section.address': '位址',
'hostDetails.hostname.placeholder': 'IP 或 主機名',
'hostDetails.section.general': '通用',
'hostDetails.os.title': '作業系統',
'hostDetails.os.desc': '用於環境資訊和配合對應的命令列行為。',
'hostDetails.os.title': '系統識別',
'hostDetails.os.desc': '預設自動辨識,僅在辨識錯誤時手動指定。網路裝置模式另行設定。',
'hostDetails.os.auto': '自動辨識',
'hostDetails.os.unknown': '其他 / 未知',
'hostDetails.os.selection': '辨識方式',
'hostDetails.os.current': '目前系統',
'hostDetails.os.freebsd': 'FreeBSD',
'hostDetails.os.linux': 'Linux',
'hostDetails.os.windows': 'Windows',
'hostDetails.os.macos': 'macOS',
@@ -639,6 +644,7 @@ export const zhTWVaultMessages: Messages = {
'terminal.customTheme.group.bright': '標示色',
'terminal.customTheme.color.background': '背景',
'terminal.customTheme.color.foreground': '前景',
'terminal.customTheme.color.foregroundIntense': '高亮前景(粗體)',
'terminal.customTheme.color.cursor': '游標',
'terminal.customTheme.color.selection': '選區',
'terminal.customTheme.color.black': '黑色',
@@ -974,6 +980,8 @@ export const zhTWVaultMessages: Messages = {
'pf.deleteActive.confirm': '關閉並刪除',
'pf.form.autoStart': '自動啟動',
'pf.form.autoStartDesc': '應用程式啟動時自動開啟此規則',
'pf.form.autoReconnect': '自動重連',
'pf.form.autoReconnectDesc': '連線中斷後自動嘗試重新連線',
// SFTP (pane + conflict)
'sftp.pane.local': '本機',
@@ -0,0 +1,58 @@
import assert from "node:assert/strict";
import test from "node:test";
import { findEditorSftpOwnerTabId, registerEditorSftpOwnerResolver } from "./editorSftpOwnerRegistry";
test("resolves the owning tab for a registered SFTP connection id", () => {
const unregister = registerEditorSftpOwnerResolver(() => ({
connectionIds: ["left-abc", "right-def"],
ownerTabId: "session-1",
}));
try {
assert.equal(findEditorSftpOwnerTabId("left-abc"), "session-1");
assert.equal(findEditorSftpOwnerTabId("right-def"), "session-1");
assert.equal(findEditorSftpOwnerTabId("left-unknown"), null);
assert.equal(findEditorSftpOwnerTabId(undefined), null);
} finally {
unregister();
}
});
test("falls back to the stable pane tab id when the connection id is stale", () => {
// Browse reconnects regenerate connection ids; an unsaved editor may still
// reference the pre-reconnect id, so owner resolution must also match on the
// stable pane tab id.
const unregister = registerEditorSftpOwnerResolver(() => ({
connectionIds: ["conn_new"],
paneTabIds: ["pane_1"],
ownerTabId: "session-1",
}));
try {
assert.equal(findEditorSftpOwnerTabId("conn_old", "pane_1"), "session-1");
assert.equal(findEditorSftpOwnerTabId(undefined, "pane_1"), "session-1");
assert.equal(findEditorSftpOwnerTabId("conn_old", "pane_unknown"), null);
assert.equal(findEditorSftpOwnerTabId(undefined, undefined), null);
} finally {
unregister();
}
});
test("unregistering a resolver stops it from resolving owners", () => {
const unregister = registerEditorSftpOwnerResolver(() => ({
connectionIds: ["conn-x"],
ownerTabId: "tab-x",
}));
unregister();
assert.equal(findEditorSftpOwnerTabId("conn-x"), null);
});
test("resolvers without an owner tab id never match", () => {
const unregister = registerEditorSftpOwnerResolver(() => ({
connectionIds: ["conn-y"],
ownerTabId: null,
}));
try {
assert.equal(findEditorSftpOwnerTabId("conn-y"), null);
} finally {
unregister();
}
});
@@ -0,0 +1,55 @@
/**
* Maps live SFTP connection ids to the top-level tab that owns the panel
* hosting them (a terminal/workspace tab for SftpSidePanel instances).
*
* Editor tabs record the SFTP `connection.id` as their `sessionId` — not the
* owning terminal session id — so batch-close logic that wants to keep an
* editor's owning tab open after a cancelled dirty-save prompt must resolve
* the owner through this registry instead of comparing `editorTab.sessionId`
* against terminal session ids.
*
* Each mounted SftpSidePanel registers a resolver that reports the connection
* ids it currently owns plus its host tab id; the top-level SftpView either
* registers nothing (its owner is not a closable terminal tab) or registers
* with a non-terminal owner id that simply never matches.
*/
export interface EditorSftpOwnerSnapshot {
connectionIds: readonly string[];
/**
* Stable SFTP pane tab ids (pane.id) hosted by this panel. Unlike connection
* ids these survive browse reconnects that regenerate connection ids, so
* owner resolution can fall back to them when an editor still references a
* pre-reconnect connection id.
*/
paneTabIds?: readonly string[];
ownerTabId: string | null;
}
type EditorSftpOwnerResolver = () => EditorSftpOwnerSnapshot;
const resolvers = new Set<EditorSftpOwnerResolver>();
export const registerEditorSftpOwnerResolver = (resolver: EditorSftpOwnerResolver): (() => void) => {
resolvers.add(resolver);
return () => {
resolvers.delete(resolver);
};
};
export const findEditorSftpOwnerTabId = (
connectionId: string | undefined,
paneTabId?: string,
): string | null => {
if (!connectionId && !paneTabId) return null;
for (const resolve of resolvers) {
const snapshot = resolve();
if (!snapshot.ownerTabId) continue;
if (connectionId && snapshot.connectionIds.includes(connectionId)) {
return snapshot.ownerTabId;
}
if (paneTabId && snapshot.paneTabIds?.includes(paneTabId)) {
return snapshot.ownerTabId;
}
}
return null;
};
@@ -101,6 +101,28 @@ test("workspace host factory creates a complete serial session", () => {
});
});
test("serial session factories preserve byteOrientedBackspace", () => {
const quickSession = createSerialTerminalSession("session-q", {
path: "COM4",
baudRate: 9600,
byteOrientedBackspace: false,
});
const savedHostSession = createHostTerminalSession("session-h", host({
protocol: "serial",
hostname: "COM3",
port: 115200,
username: "",
serialConfig: {
path: "COM3",
baudRate: 115200,
byteOrientedBackspace: false,
},
}));
assert.equal(quickSession.serialConfig?.byteOrientedBackspace, false);
assert.equal(savedHostSession.serialConfig?.byteOrientedBackspace, false);
});
test("workspace append snapshots serial Backspace behavior inherited from a group", () => {
const savedHost = host({
protocol: "serial",
@@ -0,0 +1,100 @@
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 { useSftpTransfers } from "./useSftpTransfers";
import { transferRuntime } from "./transferRuntime";
import { sftpTransferCenterStore } from "../sftpTransferCenterStore";
import { releaseTransferPauseTree } from "./transferPauseLatch";
for (const closePanel of [false, true]) {
test(`direct folder download resumes discovery with panel ${closePanel ? "closed" : "open"}`, async () => {
const previousWindow = globalThis.window;
const previousStorage = globalThis.localStorage;
const globals = globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean };
const previousAct = globals.IS_REACT_ACT_ENVIRONMENT;
globals.IS_REACT_ACT_ENVIRONMENT = true;
let finishListing!: () => void;
const listingGate = new Promise<void>((resolve) => { finishListing = resolve; });
let listingStarted!: () => void;
const started = new Promise<void>((resolve) => { listingStarted = resolve; });
let listCalls = 0;
let maxCount = 0;
const unsubscribe = sftpTransferCenterStore.subscribe(() => {
const root = sftpTransferCenterStore.getOwnerTasks("direct-runtime-owner").find((row) => !row.parentTaskId);
maxCount = Math.max(maxCount, root?.transferredBytes ?? 0);
});
(globalThis as { window?: unknown }).window = { netcatty: {
mkdirLocal: async () => undefined,
statLocal: async () => undefined,
startStreamTransfer: async (options: { transferId: string }) => {
sftpTransferCenterStore.ingestBackgroundEvent({ type: "completed", transferId: options.transferId, transferred: 1, totalBytes: 1, lifecycleEpoch: 0 });
return {};
},
resumeTransfer: async () => ({ success: false, reason: "Transfer is no longer active" }),
pauseTransfer: async () => ({ success: false, reason: "Transfer is no longer active" }),
} };
(globalThis as { localStorage?: unknown }).localStorage = {
getItem: () => null, setItem: () => undefined, removeItem: () => undefined,
};
let ops: ReturnType<typeof useSftpTransfers> | undefined;
let renderer: ReactTestRenderer | undefined;
let running: Promise<unknown> | undefined;
let rootId = "";
let reconnects = 0;
sftpTransferCenterStore.setDedicatedResumeHandler(async () => {
reconnects++;
return { success: false, error: "unexpected reconnect" };
});
function Probe() {
ops = useSftpTransfers({
ownerId: "direct-runtime-owner", getActivePane: () => null,
getPaneByConnectionId: () => null, getTabByConnectionId: () => null,
updateTab: () => undefined, refresh: async () => undefined,
clearCacheForConnection: () => undefined, handleSessionError: () => undefined,
sftpSessionsRef: { current: new Map() }, connectionCacheKeyMapRef: { current: new Map() },
listLocalFiles: async () => [], listRemoteFiles: async () => {
listCalls++; listingStarted(); await listingGate;
return ["one.txt", "two.txt"].map((name) => ({ name, type: "file" as const, size: 1, sizeFormatted: "1 B", lastModified: 0, lastModifiedFormatted: "" }));
},
});
return null;
}
try {
await act(async () => { renderer = create(React.createElement(Probe)); });
assert.ok(ops);
await act(async () => {
running = ops!.downloadToLocal({ fileName: "folder", sourcePath: "/folder", targetPath: "/download/folder-runtime", sftpId: "sftp", connectionId: "ssh", sourceHostId: "host", sourceHostLabel: "Test", isDirectory: true });
await started;
});
const root = sftpTransferCenterStore.getOwnerTasks("direct-runtime-owner")[0];
assert.ok(root); rootId = root.id;
assert.equal(root.status, "transferring", "live root must be visible in In Progress");
assert.equal(transferRuntime.isWalkInFlight(rootId), true, "direct download registers before discovery");
await act(async () => { await ops!.pauseTransfer(rootId); });
assert.equal(sftpTransferCenterStore.getTask(rootId)?.status, "paused");
if (closePanel) await act(async () => { renderer?.unmount(); renderer = undefined; });
await act(async () => { await ops!.resumeTransfer(rootId); });
assert.equal(sftpTransferCenterStore.getTask(rootId)?.status, "transferring", "resume must rejoin live directory discovery even without an active child stream");
assert.equal(transferRuntime.isWalkInFlight(rootId), true);
assert.equal(reconnects, 0);
finishListing();
await act(async () => { assert.equal(await running, "completed"); });
assert.equal(sftpTransferCenterStore.getTask(rootId)?.status, "completed");
assert.equal(sftpTransferCenterStore.getTask(rootId)?.transferredBytes, 2);
assert.ok(maxCount <= 2, `completed children must be counted once, observed ${maxCount}`);
assert.equal(listCalls, 1);
assert.equal(transferRuntime.isWalkInFlight(rootId), false);
} finally {
releaseTransferPauseTree(rootId, []);
finishListing();
await act(async () => { await running; renderer?.unmount(); });
if (rootId) sftpTransferCenterStore.dismiss(rootId);
unsubscribe();
sftpTransferCenterStore.setDedicatedResumeHandler(null);
(globalThis as { window?: unknown }).window = previousWindow;
(globalThis as { localStorage?: unknown }).localStorage = previousStorage;
globals.IS_REACT_ACT_ENVIRONMENT = previousAct;
}
});
}
@@ -500,3 +500,23 @@ for (const newerResume of [false, true]) {
assert.equal(isTransferPauseLatched(`${id}-child`), !newerResume);
});
}
test("folder resume releases a completed child compacted while pause was draining", async (t) => {
t.after(resetTransferPauseLatchesForTests);
t.after(resetTransferWalkRegistryForTests);
const root = { ...makeTask("compacted-pause-root"), isDirectory: true };
const child = { ...makeTask("compacted-pause-child"), parentTaskId: root.id };
registerTransferWalk(root.id);
const { host, getTasks } = createHost([root, child], () => ({
pauseTransfer: async () => ({ success: true }),
resumeTransfer: async () => ({ success: false, reason: "Transfer is no longer active" }),
}));
await softPauseTransfer(host, root.id);
await new Promise<void>((resolve) => setImmediate(resolve));
assert.equal(isTransferPauseLatched(child.id), true);
// Completion during soft-drain compacts this row before the user resumes.
host.setTasks(getTasks().filter((task) => task.id !== child.id));
const resumed = await softResumeTransfer(host, root.id);
assert.equal(resumed.handled, true);
assert.equal(isTransferPauseLatched(child.id), false, "the worker still waits on this child even though its history row is gone");
});
@@ -0,0 +1,82 @@
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 type { SftpFileEntry, TransferTask } from "../../../domain/models";
import { createSftpDirectoryTraversalBudget } from "../../../domain/sftpDirectoryCheckpoint";
import { netcattyBridge } from "../../../infrastructure/services/netcattyBridge";
import { useSftpDirectoryTransferOps } from "./transferDirectoryOps";
const modes = [
{ name: "ordinary local upload", sourceLocal: true, targetLocal: false, sameConnection: false, bounded: false },
{ name: "cross-pane local copy", sourceLocal: true, targetLocal: true, sameConnection: false, bounded: false },
{ name: "same-pane local copy", sourceLocal: true, targetLocal: true, sameConnection: true, bounded: true },
{ name: "remote download", sourceLocal: false, targetLocal: true, sameConnection: false, bounded: true },
] as const;
for (const mode of modes) {
for (const limit of ["entries", "directories"] as const) {
test(`${mode.name} ${mode.bounded ? "enforces" : "does not inherit"} the ${limit} budget`, async () => {
const previousGet = netcattyBridge.get;
const globals = globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean };
const previousAct = globals.IS_REACT_ACT_ENVIRONMENT;
globals.IS_REACT_ACT_ENVIRONMENT = true;
let renderer: ReactTestRenderer | undefined;
let ops!: ReturnType<typeof useSftpDirectoryTransferOps>;
const listed: string[] = [];
const root: TransferTask = {
id: crypto.randomUUID(), fileName: "source", sourcePath: "/source", targetPath: "/target",
sourceConnectionId: "source", targetConnectionId: mode.sameConnection ? "source" : "target",
direction: mode.sourceLocal ? "upload" : "download", status: "transferring",
totalBytes: 0, transferredBytes: 0, speed: 0, startTime: 0, isDirectory: true,
};
const transfersRef = { current: [root] };
const list = async (value: string): Promise<SftpFileEntry[]> => {
listed.push(value);
return value === "/source" ? ["one", "two"].map(name => ({
name, type: "directory", size: 0, sizeFormatted: "0 B", lastModified: 0, lastModifiedFormatted: "",
})) : [];
};
netcattyBridge.get = () => ({
mkdirLocal: async () => undefined,
mkdirSftp: async () => undefined,
realpathSftp: async (_id: string, value: string) => value,
} as unknown as NetcattyBridge);
function Probe() {
ops = useSftpDirectoryTransferOps({
ownerId: root.id, cancelledTasksRef: { current: new Set() }, pausedTasksRef: { current: new Set() },
waitUntilTransferResumed: async () => undefined, activeChildIdsRef: { current: new Map() },
transfersRef,
setTransfers: update => { transfersRef.current = typeof update === "function" ? update(transfersRef.current) : update; },
listLocalFiles: list, listRemoteFiles: (_id, value) => list(value),
});
return null;
}
try {
await act(async () => { renderer = create(React.createElement(Probe)); });
// Three empty directories exercise real recursion without large fixtures or file streams.
const budget = createSftpDirectoryTraversalBudget({
maxEntries: limit === "entries" ? 1 : 10,
maxDirectories: limit === "directories" ? 1 : 10,
});
const run = ops.transferDirectory(root, mode.sourceLocal ? null : "remote-source",
mode.targetLocal ? null : "remote-target", mode.sourceLocal, mode.targetLocal,
"auto", "auto", root.id, false, 0, false, undefined, budget);
if (mode.bounded) {
await assert.rejects(run, limit === "entries" ? /entry limit exceeded/ : /directory limit exceeded/);
assert.deepEqual(listed, ["/source"]);
} else {
assert.equal(await run, 0);
assert.deepEqual(listed, ["/source", "/source/one", "/source/two"]);
assert.equal(budget.visitedDirectories, 0);
assert.equal(budget.visitedEntries, 0);
}
assert.equal(budget.activeCanonicalDirectories.size, 0);
} finally {
await act(async () => { renderer?.unmount(); });
netcattyBridge.get = previousGet;
globals.IS_REACT_ACT_ENVIRONMENT = previousAct;
}
});
}
}
@@ -0,0 +1,105 @@
import assert from "node:assert/strict";
import fs from "node:fs/promises";
import path from "node:path";
import test from "node:test";
import React from "react";
import { act, create, type ReactTestRenderer } from "react-test-renderer";
import { useSftpTransfers } from "./useSftpTransfers";
import { createEmptyPane } from "./types";
import { sftpTransferCenterStore } from "../sftpTransferCenterStore";
for (const action of ["merge", "replace", "duplicate"] as const) {
test(`same-directory ${action} preserves source links through the real transfer hook`, async () => {
const dir = await fs.mkdtemp(path.join(process.cwd(), ".same-directory-copy-"));
const ownerId = `self-copy-${action}-${crypto.randomUUID()}`;
const previousWindow = globalThis.window;
const previousStorage = globalThis.localStorage;
const globals = globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean };
const previousAct = globals.IS_REACT_ACT_ENVIRONMENT;
globals.IS_REACT_ACT_ENVIRONMENT = true;
let writes = 0;
let listings = 0;
let renderer: ReactTestRenderer | undefined;
let ops!: ReturnType<typeof useSftpTransfers>;
const names = ["one", "two"];
for (const name of names) {
await fs.mkdir(path.join(dir, name));
await fs.writeFile(path.join(dir, name, "file"), "original data");
await fs.symlink("file", path.join(dir, name, "link"));
}
const pane = createEmptyPane("self-copy-pane");
pane.connection = { id: ownerId, isLocal: true, currentPath: dir, status: "connected" } as NonNullable<typeof pane.connection>;
const statPath = async (value: string) => {
const stat = await fs.lstat(value);
return { type: stat.isSymbolicLink() ? "symlink" : stat.isDirectory() ? "directory" : "file", size: stat.size, lastModified: stat.mtimeMs };
};
(globalThis as { window?: unknown }).window = { requestAnimationFrame: (callback: () => void) => setTimeout(callback, 0), cancelAnimationFrame: clearTimeout, netcatty: {
realpathLocal: fs.realpath, statLocal: statPath, lstatLocal: statPath,
mkdirLocal: async (value: string) => { writes++; await fs.mkdir(value, { recursive: true }); },
deleteLocalFile: async (value: string) => { writes++; await fs.rm(value, { recursive: true, force: true }); },
renameLocalFile: async (source: string, target: string) => { writes++; await fs.rename(source, target); },
startStreamTransfer: async (options: { sourcePath: string; targetPath: string; transferId: string }) => {
writes++;
await fs.copyFile(options.sourcePath, options.targetPath);
sftpTransferCenterStore.ingestBackgroundEvent({ type: "completed", transferId: options.transferId, transferred: 13, totalBytes: 13, lifecycleEpoch: 0 });
return {};
},
} };
(globalThis as { localStorage?: unknown }).localStorage = { getItem: () => null, setItem: () => undefined, removeItem: () => undefined };
function Probe() {
ops = useSftpTransfers({
ownerId, getActivePane: () => pane, getPaneByConnectionId: () => pane, getTabByConnectionId: () => ({ side: "left", tabId: pane.id, pane }),
updateTab: () => undefined, refresh: async () => undefined,
clearCacheForConnection: () => undefined, handleSessionError: () => undefined,
sftpSessionsRef: { current: new Map() }, connectionCacheKeyMapRef: { current: new Map() },
listRemoteFiles: async () => [],
listLocalFiles: async (value: string) => {
listings++;
return Promise.all((await fs.readdir(value)).map(async name => {
const stat = await fs.lstat(path.join(value, name));
return { name, type: stat.isSymbolicLink() ? "symlink" as const : "file" as const, size: stat.size, sizeFormatted: "", lastModified: stat.mtimeMs, lastModifiedFormatted: "" };
}));
},
});
return null;
}
try {
await act(async () => { renderer = create(React.createElement(Probe)); });
await act(async () => { await ops.startTransfer(names.map(name => ({ name, isDirectory: true })), "left", "left"); });
assert.equal(ops.conflicts.length, 2, JSON.stringify(sftpTransferCenterStore.getOwnerTasks(ownerId)));
const firstId = ops.conflicts[0].transferId;
// Apply to all exercises shared batch resolution rather than just one row.
await act(async () => { await ops.resolveConflict(firstId, action, true); });
await act(async () => {
const deadline = Date.now() + 3000;
while (Date.now() < deadline) {
const rows = sftpTransferCenterStore.getOwnerTasks(ownerId).filter(row => !row.parentTaskId);
if (rows.length === 2 && rows.every(row => ["completed", "failed"].includes(row.status))) break;
await new Promise(resolve => setTimeout(resolve, 20));
}
});
for (const name of names) {
assert.equal(await fs.readlink(path.join(dir, name, "link")), "file");
assert.equal(await fs.readFile(path.join(dir, name, "file"), "utf8"), "original data");
}
const roots = sftpTransferCenterStore.getOwnerTasks(ownerId).filter(row => !row.parentTaskId);
assert.equal(roots.length, 2);
assert.ok(roots.every(row => row.status === "completed"), JSON.stringify(roots.map(row => ({ status: row.status, error: row.error }))));
if (action === "duplicate") {
assert.ok(writes > 0);
assert.ok(listings > 0);
for (const name of names) assert.equal(await fs.readFile(path.join(dir, `${name} (copy)`, "file"), "utf8"), "original data");
} else {
assert.equal(writes, 0, "self merge/replace must never write or unlink anything");
assert.equal(listings, 0, "self merge/replace must not walk the source tree");
}
} finally {
await act(async () => { renderer?.unmount(); });
for (const row of sftpTransferCenterStore.getOwnerTasks(ownerId)) sftpTransferCenterStore.dismiss(row.id);
(globalThis as { window?: unknown }).window = previousWindow;
(globalThis as { localStorage?: unknown }).localStorage = previousStorage;
globals.IS_REACT_ACT_ENVIRONMENT = previousAct;
await fs.rm(dir, { recursive: true, force: true });
}
});
}
+232
View File
@@ -0,0 +1,232 @@
import { netcattyBridge } from "../../../infrastructure/services/netcattyBridge";
import {
getWindowsUncRoot,
isSameSftpPath,
isSftpDescendantPath,
isWindowsPath,
joinPath,
} from "./utils";
export type SamePanePasteAction = "allow" | "block-same-folder" | "block-into-source";
export interface SamePanePasteFile {
name: string;
isDirectory: boolean;
}
/** Filesystem identity of a directory (stat dev/ino). */
export interface SamePanePasteIdentity {
dev: number;
ino: number;
}
/**
* Lexically resolve "." / ".." segments and repeated separators so equivalent
* spellings of the same directory (e.g. /home/user/., /home//user) compare
* equal in the same-pane paste guards. Purely string-level: it never touches
* the server, and it is only used to make the guards stricter, never to build
* transfer paths.
*/
const canonicalizeSftpPath = (path: string): string => {
const isWindows = isWindowsPath(path);
const separator = isWindows ? "\\" : "/";
const unified = isWindows ? path.replace(/\//g, "\\") : path;
let root = "";
let rest = unified;
if (isWindows) {
const uncRoot = getWindowsUncRoot(unified, { acceptForwardSlashUnc: true });
const driveRoot = unified.match(/^[A-Za-z]:\\/)?.[0];
if (uncRoot) {
root = uncRoot;
rest = unified.slice(uncRoot.length);
} else if (driveRoot) {
root = driveRoot;
rest = unified.slice(driveRoot.length);
}
} else if (unified.startsWith("/")) {
// Collapse a leading "//" to "/" for comparisons: on ordinary POSIX
// filesystems "//home/user" names the same directory as "/home/user", and
// treating it as a distinct root would let a same-pane cut reach the
// replace/delete flow. This helper never builds transfer paths, so the
// guards stay strict either way.
root = "/";
rest = unified.replace(/^\/+/, "");
}
const parts: string[] = [];
for (const segment of rest.split(separator)) {
if (!segment || segment === ".") continue;
if (segment === "..") {
parts.pop();
continue;
}
parts.push(segment);
}
// UNC roots come back without a trailing separator, so re-insert one before
// joining the segments; without it \\server\share\docs would collapse to
// \\server\sharedocs and collide with an unrelated share's path.
if (root) {
return root.endsWith(separator)
? root + parts.join(separator)
: root + separator + parts.join(separator);
}
return parts.join(separator) || ".";
};
/**
* Decide whether an internal SFTP paste that targets the same connection as the
* clipboard source can proceed. Copying files into their own source folder is
* allowed (the conflict dialog handles same-name collisions), but pasting a
* clipboard directory into itself or one of its descendants is blocked for both
* copy and cut: the transfer creates the destination before listing the source,
* so the fresh copy would be rediscovered and nested until the traversal limit.
* For cuts, pasting into the clipboard's source folder is also blocked because
* the post-move source delete would remove the freshly moved items. Note that
* `sourcePath` is the folder containing every clipboard item, so the per-item
* checks must join it with each selected entry's name.
*
* Lexical canonicalization alone cannot see through symlinks: when the
* destination reaches the copied directory through an alias (e.g.
* `/a/link -> /a/docs/sub`), a purely string-level comparison would allow the
* paste and the recursive transfer would rediscover its own output. When
* `resolvePath` is provided (SFTP realpath for remote connections, the local
* realpath bridge for local panes), both sides are resolved through the
* filesystem before comparing. If that resolution fails, the guard fails
* closed and blocks the paste rather than risking a runaway transfer.
*
* realpath also cannot see through bind mounts: two mount paths for the same
* directory resolve to different strings. When `statIdentity` is provided
* (local panes only — remote SFTP stats carry no dev/ino), both sides' stat
* identities are compared as a final check; equal identities block the paste.
* The check only ever blocks on a positive identity match — an unavailable
* stat falls through to the symlink guards above rather than blocking
* unrelated pastes on transient stat failures.
*/
export const resolveSamePanePasteAction = async (params: {
operation: "copy" | "cut";
sourcePath: string;
targetPath: string;
files: readonly SamePanePasteFile[];
/** Optional filesystem resolver (e.g. realpath). Throws → unresolvable. */
resolvePath?: (path: string) => Promise<string>;
/** Optional stat identity provider (dev/ino). Returns null → unknown. */
statIdentity?: (path: string) => Promise<SamePanePasteIdentity | null>;
}): Promise<SamePanePasteAction> => {
const { resolvePath, statIdentity } = params;
// Canonicalize first so equivalent spellings of the same directory (dot
// segments, repeated separators) are still caught by the guards below, then
// resolve through the filesystem when a resolver is available so symlink
// aliases compare equal to their real targets. Returns null when resolution
// fails, which callers treat as fail-closed.
const canonicalizeForComparison = async (path: string): Promise<string | null> => {
const lexical = canonicalizeSftpPath(path);
if (!resolvePath) return lexical;
try {
return canonicalizeSftpPath(await resolvePath(path));
} catch {
return null;
}
};
const [targetPath, sourcePath] = await Promise.all([
canonicalizeForComparison(params.targetPath),
canonicalizeForComparison(params.sourcePath),
]);
// A files-only copy has no nesting hazard, so an unresolvable path only
// blocks pastes that could actually recurse (directory items, or a cut).
const failClosed = !targetPath || !sourcePath;
if (failClosed && (params.operation === "cut" || params.files.some((file) => file.isDirectory))) {
return "block-into-source";
}
if (failClosed) return "allow";
if (params.operation === "cut" && isSameSftpPath(targetPath, sourcePath)) {
return "block-same-folder";
}
// Bind-mount alias detection: realpath above maps each mount path to itself,
// so a cut (whose post-transfer source delete would destroy the only copy)
// must additionally compare filesystem identities. A positive match means
// both paths name the same directory. dev/ino are only trusted when at least
// one side is non-zero — Windows stats report meaningless 0 identities, and
// the local bridge omits them there.
const identityOf = async (path: string): Promise<SamePanePasteIdentity | null> => {
if (!statIdentity) return null;
try {
const identity = await statIdentity(path);
if (!identity || identity.dev === undefined || identity.ino === undefined) return null;
if (identity.dev === 0 && identity.ino === 0) return null;
return identity;
} catch {
return null;
}
};
const sameIdentity = (a: SamePanePasteIdentity, b: SamePanePasteIdentity): boolean =>
a.dev === b.dev && a.ino === b.ino;
if (params.operation === "cut") {
const [targetIdentity, sourceIdentity] = await Promise.all([
identityOf(params.targetPath),
identityOf(params.sourcePath),
]);
if (targetIdentity && sourceIdentity && sameIdentity(targetIdentity, sourceIdentity)) {
return "block-same-folder";
}
}
for (const file of params.files) {
if (!file.isDirectory) continue;
const itemPath = await canonicalizeForComparison(joinPath(params.sourcePath, file.name));
if (!itemPath) return "block-into-source";
if (
isSameSftpPath(targetPath, itemPath)
|| isSftpDescendantPath(targetPath, itemPath)
) {
return "block-into-source";
}
// A bind-mount alias of the directory item itself also names the paste
// destination, for both copy (recursive rediscovery) and cut (source
// delete would remove the freshly pasted entry).
if (statIdentity) {
const [targetIdentity, itemIdentity] = await Promise.all([
identityOf(params.targetPath),
identityOf(joinPath(params.sourcePath, file.name)),
]);
if (targetIdentity && itemIdentity && sameIdentity(targetIdentity, itemIdentity)) {
return "block-into-source";
}
}
}
return "allow";
};
/** Application boundary for filesystem checks used by every internal paste entry. */
export const resolveSameConnectionPasteAction = async (params: {
operation: "copy" | "cut";
sourcePath: string;
targetPath: string;
files: readonly SamePanePasteFile[];
isLocal: boolean;
sftpId: string | null;
}): Promise<SamePanePasteAction> => {
const bridge = netcattyBridge.get();
let resolvePath: ((path: string) => Promise<string>) | undefined;
let statIdentity: ((path: string) => Promise<SamePanePasteIdentity | null>) | undefined;
if (params.isLocal) {
if (bridge?.realpathLocal) resolvePath = (path) => bridge.realpathLocal!(path);
if (bridge?.statLocal) {
statIdentity = async (path) => {
const stat = await bridge.statLocal!(path);
if (stat.dev === undefined || stat.ino === undefined) return null;
return { dev: stat.dev, ino: stat.ino };
};
}
} else if (params.sftpId && bridge?.realpathSftp) {
const sftpId = params.sftpId;
resolvePath = (path) => bridge.realpathSftp!(sftpId, path);
}
return resolveSamePanePasteAction({ ...params, resolvePath, statIdentity });
};
@@ -0,0 +1,104 @@
import assert from "node:assert/strict";
import fs from "node:fs/promises";
import path from "node:path";
import test from "node:test";
import React from "react";
import { act, create, type ReactTestRenderer } from "react-test-renderer";
import type { TransferTask } from "../../../domain/models";
import { netcattyBridge } from "../../../infrastructure/services/netcattyBridge";
import type { SftpPane } from "./types";
import { useSftpTransferConflictOps } from "./transferConflictOps";
test("bind-mounted parents cannot cause replacement to delete the source link", async (t) => {
const originalGet = netcattyBridge.get;
const globals = globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean };
const previousAct = globals.IS_REACT_ACT_ENVIRONMENT;
globals.IS_REACT_ACT_ENVIRONMENT = true;
let renderer: ReactTestRenderer | undefined;
let ops!: ReturnType<typeof useSftpTransferConflictOps>;
let deletes = 0;
t.after(async () => {
await act(async () => renderer?.unmount());
netcattyBridge.get = originalGet;
globals.IS_REACT_ACT_ENVIRONMENT = previousAct;
});
netcattyBridge.get = () => ({
// Bind mounts retain different realpaths but expose the same parent identity.
realpathLocal: async (value: string) => value,
statLocal: async (value: string) => ({
type: "directory", size: 0, lastModified: 0, dev: 42,
ino: value === "/a" || value === "/mnt/alias" ? 7 : 8,
}),
deleteLocalFile: async () => { deletes++; },
} as unknown as NetcattyBridge);
function Probe() { ops = useSftpTransferConflictOps(); return null; }
await act(async () => { renderer = create(React.createElement(Probe)); });
const pane = { connection: { id: "local-pane", isLocal: true } } as SftpPane;
const task = {
sourceConnectionId: "local-pane", targetConnectionId: "local-pane",
sourcePath: "/a/link", targetPath: "/mnt/alias/link", fileName: "link", isDirectory: false,
} as TransferTask;
await assert.rejects(ops.deleteTargetPath(task, pane, null, "auto", "symlink"), /Choose Duplicate or Skip/);
assert.equal(deletes, 0);
assert.equal(await ops.isSameSourceEntry(task, pane, null, "auto"), true);
// Different entries and genuinely different parents must remain replaceable.
for (const targetPath of ["/mnt/alias/another-link", "/other/link"]) {
const other = { ...task, targetPath };
assert.equal(await ops.isSameSourceEntry(other, pane, null, "auto"), false);
await ops.deleteTargetPath(other, pane, null, "auto", "symlink");
}
assert.equal(deletes, 2);
});
test("same-pane link replacement preserves the source, including aliased parent paths", async (t) => {
const dir = await fs.mkdtemp(path.join(process.cwd(), ".pr3318-links-"));
const actGlobal = globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean };
const previousAct = actGlobal.IS_REACT_ACT_ENVIRONMENT;
actGlobal.IS_REACT_ACT_ENVIRONMENT = true;
const originalGet = netcattyBridge.get;
let renderer: ReactTestRenderer;
let ops!: ReturnType<typeof useSftpTransferConflictOps>;
let deletes = 0;
t.after(async () => {
await act(async () => renderer?.unmount());
netcattyBridge.get = originalGet;
actGlobal.IS_REACT_ACT_ENVIRONMENT = previousAct;
await fs.rm(dir, { recursive: true, force: true });
});
await fs.mkdir(path.join(dir, "source"));
await fs.mkdir(path.join(dir, "other"));
await fs.writeFile(path.join(dir, "source", "file"), "keep original contents");
await fs.symlink("file", path.join(dir, "source", "link"));
await fs.symlink("source", path.join(dir, "alias"));
netcattyBridge.get = () => ({
realpathLocal: fs.realpath,
lstatLocal: async (value: string) => {
const stat = await fs.lstat(value);
return { type: stat.isSymbolicLink() ? "symlink" : "file", size: stat.size, lastModified: stat.mtimeMs };
},
deleteLocalFile: async (value: string) => { deletes++; await fs.unlink(value); },
} as unknown as NetcattyBridge);
function Probe() { ops = useSftpTransferConflictOps(); return null; }
await act(async () => { renderer = create(React.createElement(Probe)); });
const pane = { connection: { id: "local-pane", isLocal: true } } as SftpPane;
const task = {
sourceConnectionId: "local-pane", targetConnectionId: "local-pane",
sourcePath: path.join(dir, "source", "link"),
targetPath: path.join(dir, "source", "link"),
fileName: "link", isDirectory: false,
} as TransferTask;
for (const parent of ["source", "alias", "source/."]) {
await assert.rejects(ops.deleteTargetPath(
{ ...task, targetPath: `${dir}/${parent}/link` }, pane, null, "auto", "symlink",
), /Choose Duplicate or Skip/);
assert.equal(await fs.readlink(task.sourcePath), "file");
assert.equal(await fs.readFile(task.sourcePath, "utf8"), "keep original contents");
}
assert.equal(deletes, 0);
const duplicate = await ops.getDuplicateTarget(task, pane, null, "auto");
assert.equal(duplicate.fileName, "link (copy)");
await fs.symlink("../source/file", path.join(dir, "other", "link"));
await ops.deleteTargetPath({ ...task, targetPath: path.join(dir, "other", "link") }, pane, null, "auto", "symlink");
assert.equal(deletes, 1);
assert.equal(await fs.readlink(task.sourcePath), "file");
});
+44 -3
View File
@@ -3,7 +3,7 @@ import type { SftpFilenameEncoding, TransferTask } from "../../../domain/models"
import { netcattyBridge } from "../../../infrastructure/services/netcattyBridge";
import { isMissingStatError } from "./errors";
import type { SftpPane } from "./types";
import { getParentPath, joinPath } from "./utils";
import { getFileName, getParentPath, isSameSftpPath, joinPath } from "./utils";
export function useSftpTransferConflictOps() {
const splitNameForDuplicate = useCallback((fileName: string, isDirectory: boolean) => {
@@ -84,6 +84,42 @@ export function useSftpTransferConflictOps() {
[splitNameForDuplicate, statTargetPath],
);
const isSameSourceEntry = useCallback(async (
task: TransferTask,
targetPane: SftpPane,
targetSftpId: string | null,
targetEncoding: SftpFilenameEncoding,
): Promise<boolean> => {
if (!targetPane.connection || task.sourceConnectionId !== task.targetConnectionId) return false;
if (isSameSftpPath(task.sourcePath, task.targetPath)) return true;
const bridge = netcattyBridge.get();
const resolve = targetPane.connection.isLocal
? bridge?.realpathLocal
: targetSftpId && bridge?.realpathSftp
? (value: string) => bridge.realpathSftp!(targetSftpId, value, targetEncoding)
: undefined;
const [sourceParent, targetParent] = await Promise.all(
[getParentPath(task.sourcePath), getParentPath(task.targetPath)].map(
(value) => resolve ? resolve(value) : Promise.resolve(value),
),
);
if (isSameSftpPath(
joinPath(sourceParent, getFileName(task.sourcePath)),
joinPath(targetParent, getFileName(task.targetPath)),
)) return true;
// Bind mounts keep distinct realpaths while naming the same directory.
// Compare parents so a symlink is identified as an entry, not its referent.
if (targetPane.connection.isLocal && bridge?.statLocal
&& getFileName(task.sourcePath) === getFileName(task.targetPath)) {
const [sourceStat, targetStat] = await Promise.all([
bridge.statLocal(sourceParent), bridge.statLocal(targetParent),
]);
return sourceStat.dev !== undefined && sourceStat.ino !== undefined
&& sourceStat.dev === targetStat.dev && sourceStat.ino === targetStat.ino;
}
return false;
}, []);
const deleteTargetPath = useCallback(
async (
task: TransferTask,
@@ -93,6 +129,11 @@ export function useSftpTransferConflictOps() {
expectedType?: "file" | "directory" | "symlink",
) => {
if (!targetPane.connection) return;
// Replace unlinks symlinks before copying. Never unlink the source entry
// when a clipboard copy is pasted back into its own directory.
if (expectedType === "symlink" && await isSameSourceEntry(task, targetPane, targetSftpId, targetEncoding)) {
throw new Error("Cannot replace the source link with itself. Choose Duplicate or Skip.");
}
if (targetPane.connection.isLocal) {
const deleteLocalFile = netcattyBridge.get()?.deleteLocalFile;
if (!deleteLocalFile) throw new Error("Local delete unavailable");
@@ -104,9 +145,9 @@ export function useSftpTransferConflictOps() {
if (!deleteSftp) throw new Error("SFTP delete unavailable");
await deleteSftp(targetSftpId, task.targetPath, targetEncoding, expectedType);
},
[],
[isSameSourceEntry],
);
return { statTargetPath, getDuplicateTarget, deleteTargetPath };
return { statTargetPath, getDuplicateTarget, deleteTargetPath, isSameSourceEntry };
}
+21 -4
View File
@@ -558,11 +558,20 @@ export function useSftpDirectoryTransferOps({
}
const listingGate = progress.listingGate;
const traversal = traversalBudget ?? createSftpDirectoryTraversalBudget();
// Only the newly supported same-pane local copy needs this fallback.
// Preserve existing large local uploads and cross-pane copies.
const boundLocalTraversal = sourceIsLocal && targetIsLocal
&& task.sourceConnectionId === task.targetConnectionId;
let claimedCanonicalPath: string | null = null;
let regularFiles: SftpFileEntry[] = [];
// Keep the current remote ancestor active through child discovery.
try {
if (!sourceIsLocal && sourceSftpId) {
// Bound same-pane local copies that rediscover their own output through
// filesystem aliases not detected by the paste guard.
if (boundLocalTraversal) {
claimedCanonicalPath = claimSftpDirectoryVisit(traversal, task.sourcePath);
if (!claimedCanonicalPath) return totalErrors;
} else if (!sourceIsLocal && sourceSftpId) {
const bridge = netcattyBridge.get();
const canonicalPath = await bridge?.realpathSftp?.(sourceSftpId, task.sourcePath, sourceEncoding)
.catch(() => task.sourcePath) ?? task.sourcePath;
@@ -611,7 +620,7 @@ export function useSftpDirectoryTransferOps({
// Filter both "." and ".." — some SFTP servers include "." in readdir
const filtered = files.filter((f) => f.name !== ".." && f.name !== ".");
if (!sourceIsLocal) accountSftpDirectoryEntries(traversal, filtered.length);
if (!sourceIsLocal || boundLocalTraversal) accountSftpDirectoryEntries(traversal, filtered.length);
// Separate directories from files.
// Symlink directories are only followed when followSymlinks is true
// (downloadToLocal). Uploads/copies treat symlinks as regular entries
@@ -840,7 +849,9 @@ export function useSftpDirectoryTransferOps({
if (row.status === "paused" || row.status === "pausing" || isPauseLatched(rootTaskId)) {
return { ...row, speed: 0 };
}
return { ...row, transferredBytes: row.transferredBytes + 1 };
const completed = (row.directoryResumeCheckpoint?.completedEntries ?? 0)
+ base.filter((child) => child.parentTaskId === rootTaskId && child.status === "completed").length;
return { ...row, transferredBytes: completed };
});
});
return;
@@ -924,6 +935,12 @@ export function useSftpDirectoryTransferOps({
|| parentRow.status === "pausing"
|| isPauseLatched(rootTaskId)
);
// Background completion may have already compacted this child and
// advanced the checkpoint. Count retained + compacted completions
// instead of incrementing the same file again when invoke settles.
const completed = (parentRow?.directoryResumeCheckpoint?.completedEntries ?? 0)
+ prev.filter((row) => row.parentTaskId === rootTaskId
&& (row.status === "completed" || row.id === fileId)).length;
const updated = prev.map((t) => {
if (t.id === fileId) {
return { ...t, status: "completed" as TransferStatus, endTime: Date.now(), transferredBytes: t.totalBytes };
@@ -934,7 +951,7 @@ export function useSftpDirectoryTransferOps({
}
return {
...t,
transferredBytes: t.transferredBytes + 1,
transferredBytes: completed,
speed: t.speed,
};
}
+12 -1
View File
@@ -8,6 +8,9 @@
const pausedIds = new Set<string>();
const barriers = new Map<string, { promise: Promise<void>; resolve: () => void }>();
// A completed child's history row can disappear while its worker still waits
// for the folder to resume. Keep the paused tree independent of UI history.
const pausedChildren = new Map<string, Set<string>>();
function ensureBarrier(taskId: string): { promise: Promise<void>; resolve: () => void } {
let barrier = barriers.get(taskId);
@@ -45,11 +48,18 @@ export function releaseTransferPause(taskId: string): void {
}
export function releaseTransferPauseTree(rootTaskId: string, childIds: readonly string[] = []): void {
const children = new Set([...childIds, ...(pausedChildren.get(rootTaskId) ?? [])]);
pausedChildren.delete(rootTaskId);
releaseTransferPause(rootTaskId);
for (const id of childIds) releaseTransferPause(id);
for (const id of children) releaseTransferPause(id);
}
export function latchTransferPauseTree(rootTaskId: string, childIds: readonly string[] = []): void {
if (childIds.length > 0) {
const children = pausedChildren.get(rootTaskId) ?? new Set<string>();
for (const id of childIds) children.add(id);
pausedChildren.set(rootTaskId, children);
}
latchTransferPause(rootTaskId);
for (const id of childIds) latchTransferPause(id);
}
@@ -77,6 +87,7 @@ export function resetTransferPauseLatchesForTests(): void {
for (const id of ids) releaseTransferPause(id);
pausedIds.clear();
barriers.clear();
pausedChildren.clear();
}
export function listTransferPauseLatchesForTests(): string[] {
+109 -126
View File
@@ -37,10 +37,7 @@ import {
releaseTransferPauseTree,
waitUntilTransferPauseReleased,
} from "./transferPauseLatch";
import {
bumpTransferControlEpoch,
settleTransferControlEpochTree,
} from "./transferControlEpoch";
import { bumpTransferControlEpoch } from "./transferControlEpoch";
import { transferRuntime } from "./transferRuntime";
import {
clearTransferCancelled,
@@ -48,7 +45,6 @@ import {
isTransferCancelledFlag,
markTransferCancelled,
markTransferCancelledTree,
settleTransferCancelTree,
} from "./transferCancelLatch";
import type { TransferResult, UseSftpTransfersParams, UseSftpTransfersResult } from "./useSftpTransfers.types";
import type { TransferConnectionLease } from "./transferConnectionPool";
@@ -446,7 +442,7 @@ export const useSftpTransfers = ({
cleanupTaskArtifacts,
});
const { statTargetPath, getDuplicateTarget, deleteTargetPath } = useSftpTransferConflictOps();
const { statTargetPath, getDuplicateTarget, deleteTargetPath, isSameSourceEntry } = useSftpTransferConflictOps();
const { transferFile, transferDirectory } = useSftpDirectoryTransferOps({
ownerId,
@@ -844,9 +840,14 @@ export const useSftpTransfers = ({
// Try same-host directory optimization first; falls back to recursive transfer
// if remote cp is unavailable (e.g. Windows SSH servers).
let dirHandledBySameHost = false;
// Merge/Replace onto the source directory is already satisfied. Walking
// it would rewrite its children (including replacing links with files).
// Duplicate has a different target and continues through normal copying.
let directoryHandled = task.isDirectory
&& await isSameSourceEntry(task, targetPane, targetSftpId, targetEncoding);
if (
task.isDirectory
&& !directoryHandled
&& !task.replaceExistingTarget
&& task.resumable === false
&& sameHost
@@ -866,10 +867,10 @@ export const useSftpTransfers = ({
if (cancelledTasksRef.current.has(task.id)) {
throw new Error("Transfer cancelled");
}
dirHandledBySameHost = result.success;
directoryHandled = result.success;
}
if (task.isDirectory && !dirHandledBySameHost) {
if (task.isDirectory && !directoryHandled) {
// For directory transfers, parent task uses:
// totalBytes = total file count (discovered async)
// transferredBytes = completed file count (incremented by child completions)
@@ -1809,127 +1810,109 @@ export const useSftpTransfers = ({
return "attention";
}
const sourceEncoding = params.sourceEncoding ?? "auto";
// Mutable counter to track child failures outside React state,
// so the final status check doesn't depend on render timing.
let childFailureCount = 0;
const patchDownload = (updates: Partial<TransferTask>) => {
transferRuntime.patchTask(task.id, updates);
const canonical = transferRuntime.getTask(task.id);
if (canonical) setTransfers((prev) => prev.map((row) => row.id === task.id ? canonical : row));
};
const executeDownload = async (): Promise<TransferStatus> => {
const sourceEncoding = params.sourceEncoding ?? "auto";
// Mutable counter to track child failures outside React state,
// so the final status check doesn't depend on render timing.
let childFailureCount = 0;
// Dedicated pool only when host id is known — never fall back to browse
// (tab close would kill the download and freeze the global center).
let sourceWorkLease: TransferConnectionLease | null = null;
let workingSourceSftpId = params.sftpId;
if (acquireTransferSession && params.sourceHostId) {
sourceWorkLease = await acquireTransferSession(
params.sourceHostId,
`${task.id}:work-source`,
);
workingSourceSftpId = sourceWorkLease.sftpId;
}
// Dedicated pool only when host id is known — never fall back to browse
// (tab close would kill the download and freeze the global center).
let sourceWorkLease: TransferConnectionLease | null = null;
let workingSourceSftpId = params.sftpId;
try {
if (acquireTransferSession && params.sourceHostId) {
sourceWorkLease = await acquireTransferSession(
params.sourceHostId,
`${task.id}:work-source`,
);
workingSourceSftpId = sourceWorkLease.sftpId;
}
await waitUntilTransferResumed(task.id);
if (cancelledTasksRef.current.has(task.id)) throw new Error("Transfer cancelled");
if (params.isDirectory) patchDownload({ status: "transferring", error: undefined });
try {
if (params.isDirectory) {
childFailureCount = await transferDirectory(
task,
workingSourceSftpId,
null, // targetSftpId = null (local)
false, // sourceIsLocal = false
true, // targetIsLocal = true
sourceEncoding,
"auto", // targetEncoding
task.id,
false, // sameHost
0, // symlinkDepth
true, // followSymlinks — download should expand symlink dirs
);
} else {
await transferFile(
task,
workingSourceSftpId,
null,
false,
true,
sourceEncoding,
"auto",
task.id,
);
}
if (params.isDirectory) {
childFailureCount = await transferDirectory(
task,
workingSourceSftpId,
null, // targetSftpId = null (local)
false, // sourceIsLocal = false
true, // targetIsLocal = true
sourceEncoding,
"auto", // targetEncoding
task.id,
false, // sameHost
0, // symlinkDepth
true, // followSymlinks — download should expand symlink dirs
);
} else {
await transferFile(
task,
workingSourceSftpId,
null,
false,
true,
sourceEncoding,
"auto",
task.id,
);
}
// Use childFailureCount (tracked outside React state) to determine
// final status reliably, regardless of render timing.
// Cancel must win: transferDirectory counts cancelled children as errors,
// but cancelTransfer already marked the parent cancelled — do not demote
// it to failed with "Some files failed to transfer".
// Re-read cancel inside the state update so a cancel that lands after
// transferDirectory returns cannot be overwritten by completed/failed.
let appliedStatus: TransferStatus = "completed";
setTransfers((prev) => {
const liveParent = prev.find((candidate) => candidate.id === task.id);
const completedCount = (liveParent?.directoryResumeCheckpoint?.completedEntries ?? 0) + prev.filter(
(t) => t.parentTaskId === task.id && t.status === "completed",
).length;
return prev.map((t) => {
if (t.id !== task.id) return t;
const parentCancelled = t.status === "cancelled"
|| cancelledTasksRef.current.has(task.id);
const resolved = resolveDirectDirectoryDownloadFinalStatus({
parentCancelled,
childFailureCount,
});
appliedStatus = resolved.status;
if (resolved.status === "cancelled") {
cancelledTasksRef.current.delete(task.id);
return {
...t,
status: "cancelled" as TransferStatus,
error: undefined,
endTime: Date.now(),
// Keep partial progress — do not look 100% complete when cancelled.
speed: 0,
};
}
const finalTotal = t.totalBytes > 0 ? t.totalBytes : completedCount;
const hasFailures = resolved.status === "failed";
return {
...t,
status: resolved.status,
error: resolved.error,
endTime: Date.now(),
totalBytes: finalTotal,
transferredBytes: hasFailures ? completedCount : finalTotal,
speed: 0,
};
// Runtime owns lifecycle while the walk is registered. Publish the
// final root there before mirroring it to the panel.
const rows = sftpTransferCenterStore.getSnapshot().tasks;
const liveParent = transferRuntime.getTask(task.id) ?? task;
const completedCount = (liveParent.directoryResumeCheckpoint?.completedEntries ?? 0)
+ rows.filter((row) => row.parentTaskId === task.id && row.status === "completed").length;
const resolved = resolveDirectDirectoryDownloadFinalStatus({
parentCancelled: liveParent.status === "cancelled" || cancelledTasksRef.current.has(task.id),
childFailureCount,
});
});
activeChildIdsRef.current.delete(task.id);
return appliedStatus;
} catch (err) {
activeChildIdsRef.current.delete(task.id);
const isCancelled = cancelledTasksRef.current.has(task.id);
// Clean up cancelled task tracking to prevent memory leak
if (isCancelled) cancelledTasksRef.current.delete(task.id);
const errMsg = err instanceof Error ? err.message : String(err);
setTransfers((prev) =>
prev.map((t) =>
t.id === task.id
? {
...t,
status: isCancelled ? ("cancelled" as TransferStatus) : ("failed" as TransferStatus),
error: isCancelled ? undefined : errMsg,
endTime: Date.now(),
}
: t,
),
);
return isCancelled ? "cancelled" : "failed";
} finally {
sourceWorkLease?.release();
sourceWorkLease = null;
const childIds = sftpTransferCenterStore.getSnapshot().tasks
.filter((candidate) => candidate.parentTaskId === task.id)
.map((candidate) => candidate.id);
const relatedChildIds = settleTransferCancelTree(task.id, childIds);
settleTransferControlEpochTree(task.id, relatedChildIds);
}
const finalTotal = liveParent.totalBytes > 0 ? liveParent.totalBytes : completedCount;
patchDownload({
status: resolved.status,
error: resolved.error,
endTime: Date.now(),
speed: 0,
...(resolved.status === "cancelled" ? {} : {
totalBytes: finalTotal,
transferredBytes: resolved.status === "failed" ? completedCount : finalTotal,
}),
});
const appliedStatus = transferRuntime.getTask(task.id)?.status ?? resolved.status;
activeChildIdsRef.current.delete(task.id);
return appliedStatus;
} catch (err) {
activeChildIdsRef.current.delete(task.id);
const isCancelled = cancelledTasksRef.current.has(task.id);
// Clean up cancelled task tracking to prevent memory leak
if (isCancelled) cancelledTasksRef.current.delete(task.id);
const errMsg = err instanceof Error ? err.message : String(err);
patchDownload({
status: isCancelled ? "cancelled" : "failed",
error: isCancelled ? undefined : errMsg,
endTime: Date.now(),
speed: 0,
});
return isCancelled ? "cancelled" : "failed";
} finally {
sourceWorkLease?.release();
sourceWorkLease = null;
}
};
let result: TransferStatus = "failed";
await runTrackedTransferAttempt(inFlightTransferIdsRef.current, task.id, () =>
transferRuntime.runWalk(task.id, async () => {
result = await executeDownload();
}),
);
return result;
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[sftpSessionsRef, acquireTransferSession],
+140 -92
View File
@@ -109,6 +109,8 @@ export interface SftpTransferCenterStore {
/** Insert or merge tasks by id (used by dedicated directory resume for children). */
upsertTasks(incoming: readonly TransferTask[]): void;
canControl(taskId: string): boolean;
subscribeResume(listener: Listener): () => void;
isResuming(taskId: string): boolean;
pause(taskId: string): Promise<void>;
resume(taskId: string): Promise<void>;
cancel(taskId: string): Promise<void>;
@@ -431,6 +433,13 @@ export function createSftpTransferCenterStore(persistence?: StorePersistence): S
let persistenceDirty = false;
let persistenceTimer: ReturnType<typeof setTimeout> | null = null;
const resumeInvocations = new Map<string, Promise<void>>();
// Ephemeral control intent: never serialize this or change bridge lifecycle epochs.
const resumeRequests = new Map<string, symbol>();
const resumeListeners = new Set<Listener>();
const notifyResume = () => resumeListeners.forEach((listener) => listener());
const clearResumeRequest = (taskId: string) => {
if (resumeRequests.delete(taskId)) notifyResume();
};
const resumePreparationFailures = new Map<string, string>();
let dedicatedResumeHandler: DedicatedTransferResumeHandler | null = null;
const dedicatedResumeWaiters = new Set<{
@@ -1725,114 +1734,140 @@ export function createSftpTransferCenterStore(persistence?: StorePersistence): S
controls.adopt && controls.canAdopt?.(task)
)));
},
pause: (taskId) => invoke(taskId, "pause"),
subscribeResume(listener) {
resumeListeners.add(listener);
return () => { resumeListeners.delete(listener); };
},
isResuming(taskId) {
if (!resumeRequests.has(taskId)) return false;
const task = tasks.find((candidate) => candidate.id === taskId);
return !!task && !task.error
&& ["paused", "interrupted", "attention", "pending", "queued"].includes(task.status);
},
pause(taskId) {
clearResumeRequest(taskId);
return invoke(taskId, "pause");
},
async resume(taskId) {
const startFresh = () => {
const running = invoke(taskId, "resume").finally(() => {
if (resumeInvocations.get(taskId) === running) resumeInvocations.delete(taskId);
});
resumeInvocations.set(taskId, running);
return running;
};
const request = Symbol("resume");
resumeRequests.set(taskId, request);
notifyResume();
const runResume = async () => {
const startFresh = () => {
const running = invoke(taskId, "resume").finally(() => {
if (resumeInvocations.get(taskId) === running) resumeInvocations.delete(taskId);
});
resumeInvocations.set(taskId, running);
return running;
};
const existing = resumeInvocations.get(taskId);
if (existing) {
// Dedicated resume holds the invocation for the full stream lifetime.
const task = tasks.find((candidate) => candidate.id === taskId);
if (task?.status === "cancelled") return existing;
const existing = resumeInvocations.get(taskId);
if (existing) {
// Dedicated resume holds the invocation for the full stream lifetime.
const task = tasks.find((candidate) => candidate.id === taskId);
if (task?.status === "cancelled") return existing;
// Soft-unpause live backend streams when still paused under a held run.
// Dedicated *directory* walks treat status===paused as shouldAbort between
// files, so soft-rejoin is unsafe (dying promise + false transferring).
// Wind down soft-paused children then startFresh from checkpoints.
// Single-file dedicated (and non-directory live streams) soft-unpause.
if (task && (task.status === "paused" || task.status === "pausing")) {
const childIds = tasks
.filter((candidate) => candidate.parentTaskId === taskId
&& (candidate.status === "paused"
|| candidate.status === "pausing"
|| candidate.status === "transferring"))
.map((candidate) => candidate.id);
// Soft-unpause live backend streams when still paused under a held run.
// Dedicated *directory* walks treat status===paused as shouldAbort between
// files, so soft-rejoin is unsafe (dying promise + false transferring).
// Wind down soft-paused children then startFresh from checkpoints.
// Single-file dedicated (and non-directory live streams) soft-unpause.
if (task && (task.status === "paused" || task.status === "pausing")) {
const childIds = tasks
.filter((candidate) => candidate.parentTaskId === taskId
&& (candidate.status === "paused"
|| candidate.status === "pausing"
|| candidate.status === "transferring"))
.map((candidate) => candidate.id);
// Always clear process-global latches + control epoch so walks wake and
// late soft-drain / pauseWatch cannot re-pause streams. Do not stamp
// control epoch as task.lifecycleEpoch (bridge-aligned only).
let resumeEpoch = bumpTransferControlEpoch(taskId);
releaseTransferPauseTree(taskId, childIds);
// Always clear process-global latches + control epoch so walks wake and
// late soft-drain / pauseWatch cannot re-pause streams. Do not stamp
// control epoch as task.lifecycleEpoch (bridge-aligned only).
let resumeEpoch = bumpTransferControlEpoch(taskId);
releaseTransferPauseTree(taskId, childIds);
if (task.ownerId === "dedicated-resume" && task.isDirectory) {
const bridge = netcattyBridge.get();
// Cancel soft-paused child streams so the held walk settles. When a
// child is not in activeTransfers, cancelTransfer leaves a sticky
// pendingCancel latch — clear it before startFresh reuses the same
// child transferIds (otherwise startStreamTransfer aborts immediately).
for (const id of childIds) {
try { await bridge?.cancelTransfer?.(id); } catch { /* best-effort wind-down */ }
try { await bridge?.clearPendingTransferCancel?.(id); } catch { /* best-effort */ }
if (task.ownerId === "dedicated-resume" && task.isDirectory) {
const bridge = netcattyBridge.get();
// Cancel soft-paused child streams so the held walk settles. When a
// child is not in activeTransfers, cancelTransfer leaves a sticky
// pendingCancel latch — clear it before startFresh reuses the same
// child transferIds (otherwise startStreamTransfer aborts immediately).
for (const id of childIds) {
try { await bridge?.cancelTransfer?.(id); } catch { /* best-effort wind-down */ }
try { await bridge?.clearPendingTransferCancel?.(id); } catch { /* best-effort */ }
}
try { await bridge?.clearPendingTransferCancel?.(taskId); } catch { /* best-effort */ }
try {
await existing;
} catch { /* previous aborted / cancelled */ }
// Clear again after wind-down in case cancel raced during await.
for (const id of childIds) {
try { await bridge?.clearPendingTransferCancel?.(id); } catch { /* best-effort */ }
}
try { await bridge?.clearPendingTransferCancel?.(taskId); } catch { /* best-effort */ }
if (!isTransferControlEpochCurrent(taskId, resumeEpoch)) return existing;
return resumeInvocations.get(taskId) ?? startFresh();
}
try { await bridge?.clearPendingTransferCancel?.(taskId); } catch { /* best-effort */ }
try {
// Reuse the live control path: held dedicated transfers must obey
// the same ordering and per-child lifecycle rules as panel jobs.
const softOperation = softResumeTransfer({
getTasks: () => tasks,
setTasks: (next) => { tasks = next; emit(); },
getBridge: defaultTransferControlBridge,
}, taskId);
resumeEpoch = getTransferControlEpoch(taskId);
const soft = await softOperation;
if (soft.handled) return existing;
const streamGone = /no longer active|not active|not found|session is no longer|Resume unavailable|Transfer not found/i
.test(soft.reason ?? "");
if (!streamGone) {
tasks = tasks.map((candidate) => candidate.id === taskId ? {
...candidate,
status: "paused" as const,
speed: 0,
phase: undefined,
error: soft.reason,
} : candidate);
emit();
return existing;
}
} catch {
// Fall through to await + restart.
}
if (!isTransferControlEpochCurrent(taskId, resumeEpoch)) return existing;
try {
await existing;
} catch { /* previous aborted / cancelled */ }
// Clear again after wind-down in case cancel raced during await.
for (const id of childIds) {
try { await bridge?.clearPendingTransferCancel?.(id); } catch { /* best-effort */ }
}
try { await bridge?.clearPendingTransferCancel?.(taskId); } catch { /* best-effort */ }
} catch { /* previous aborted */ }
if (!isTransferControlEpochCurrent(taskId, resumeEpoch)) return existing;
return resumeInvocations.get(taskId) ?? startFresh();
}
try {
// Reuse the live control path: held dedicated transfers must obey
// the same ordering and per-child lifecycle rules as panel jobs.
const softOperation = softResumeTransfer({
getTasks: () => tasks,
setTasks: (next) => { tasks = next; emit(); },
getBridge: defaultTransferControlBridge,
}, taskId);
resumeEpoch = getTransferControlEpoch(taskId);
const soft = await softOperation;
if (soft.handled) return existing;
const streamGone = /no longer active|not active|not found|session is no longer|Resume unavailable|Transfer not found/i
.test(soft.reason ?? "");
if (!streamGone) {
tasks = tasks.map((candidate) => candidate.id === taskId ? {
...candidate,
status: "paused" as const,
speed: 0,
phase: undefined,
error: soft.reason,
} : candidate);
emit();
return existing;
}
} catch {
// Fall through to await + restart.
// After demotion to interrupted/attention/failed while work unwinds:
// wait then re-invoke (do not rejoin a dying canceling promise).
if (task && (task.status === "interrupted" || task.status === "attention" || task.status === "failed")) {
const resumeEpoch = getTransferControlEpoch(taskId);
try {
await existing;
} catch { /* previous aborted */ }
if (getTransferControlEpoch(taskId) !== resumeEpoch) return existing;
return resumeInvocations.get(taskId) ?? startFresh();
}
if (!isTransferControlEpochCurrent(taskId, resumeEpoch)) return existing;
try {
await existing;
} catch { /* previous aborted */ }
if (!isTransferControlEpochCurrent(taskId, resumeEpoch)) return existing;
return resumeInvocations.get(taskId) ?? startFresh();
return existing;
}
// After demotion to interrupted/attention/failed while work unwinds:
// wait then re-invoke (do not rejoin a dying canceling promise).
if (task && (task.status === "interrupted" || task.status === "attention" || task.status === "failed")) {
const resumeEpoch = getTransferControlEpoch(taskId);
try {
await existing;
} catch { /* previous aborted */ }
if (getTransferControlEpoch(taskId) !== resumeEpoch) return existing;
return resumeInvocations.get(taskId) ?? startFresh();
}
return existing;
return startFresh();
};
try {
return await runResume();
} finally {
if (resumeRequests.get(taskId) === request) clearResumeRequest(taskId);
}
return startFresh();
},
cancel: (taskId) => invoke(taskId, "cancel"),
cancel(taskId) {
clearResumeRequest(taskId);
return invoke(taskId, "cancel");
},
async retry(taskId) {
const ownerId = findOwner(taskId);
const task = tasks.find((candidate) => candidate.id === taskId);
@@ -2430,3 +2465,16 @@ export function useSftpTransferTask(taskId: string, fallback: TransferTask): Tra
() => fallbackRef.current,
);
}
/** Shared control feedback for every transfer surface, including newly mounted rows. */
export function useSftpTransferResuming(taskId: string): boolean {
return useSyncExternalStore(
(listener) => {
const unsubscribeTask = sftpTransferCenterStore.subscribe(listener);
const unsubscribeResume = sftpTransferCenterStore.subscribeResume(listener);
return () => { unsubscribeTask(); unsubscribeResume(); };
},
() => sftpTransferCenterStore.isResuming(taskId),
() => false,
);
}
@@ -0,0 +1,46 @@
import assert from "node:assert/strict";
import test from "node:test";
import { createSftpTransferCenterStore } from "./sftpTransferCenterStore";
for (const outcome of ["success", "failure", "pause", "cancel"] as const) {
test(`resume feedback clears after ${outcome} without changing the paused checkpoint`, async (t) => {
const previous = Object.getOwnPropertyDescriptor(globalThis, "window");
t.after(() => {
if (previous) Object.defineProperty(globalThis, "window", previous);
else Reflect.deleteProperty(globalThis, "window");
});
let settle!: (value: { success: boolean; reason?: string }) => void;
let started!: () => void;
const entered = new Promise<void>((resolve) => { started = resolve; });
const gate = new Promise<{ success: boolean; reason?: string }>((resolve) => { settle = resolve; });
Object.defineProperty(globalThis, "window", { configurable: true, value: { netcatty: {
resumeTransfer: () => { started(); return gate; },
pauseTransfer: async () => ({ success: true, checkpointBytes: 100 }),
cancelTransfer: async () => ({ success: true }),
} } });
const store = createSftpTransferCenterStore();
store.publishOwner("feedback", [{
id: "feedback", fileName: "file.bin", sourcePath: "/source.bin", targetPath: "/target.bin",
sourceConnectionId: "remote", targetConnectionId: "local", direction: "download",
status: "paused", totalBytes: 1000, transferredBytes: 100, checkpointBytes: 100,
speed: 0, startTime: 1, isDirectory: false, resumable: true,
}]);
let notifications = 0;
const unsubscribe = store.subscribeResume(() => { notifications += 1; });
t.after(unsubscribe);
const running = store.resume("feedback");
assert.equal(store.isResuming("feedback"), true);
assert.equal(store.getTask("feedback")?.status, "paused");
assert.equal(store.getTask("feedback")?.checkpointBytes, 100);
await entered;
const followup = outcome === "pause" ? store.pause("feedback")
: outcome === "cancel" ? store.cancel("feedback") : Promise.resolve();
if (outcome === "pause" || outcome === "cancel") assert.equal(store.isResuming("feedback"), false);
settle(outcome === "failure" ? { success: false, reason: "Resume safety check failed" } : { success: true });
await Promise.all([running, followup]);
assert.equal(store.isResuming("feedback"), false);
assert.ok(notifications >= 2);
if (outcome === "pause") assert.equal(store.getTask("feedback")?.status, "paused");
if (outcome === "cancel") assert.equal(store.getTask("feedback")?.status, "cancelled");
});
}
@@ -0,0 +1,79 @@
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import test from 'node:test';
import vm from 'node:vm';
import React, { useCallback, useRef, useSyncExternalStore } from 'react';
import { act, create } from 'react-test-renderer';
import ts from 'typescript';
import { getSidePanelLiveSnapshot, subscribeSidePanelLiveSnapshot, sidePanelLiveStore, SIDE_PANEL_INACTIVE_LIVE_SNAPSHOT, type SidePanelLiveSnapshot } from './sidePanelLiveStore';
// Exercise the production hook without importing the unrelated panel UI modules.
const source = readFileSync(new URL('../../components/terminalLayer/terminalLayerSidePanelSlots.tsx', import.meta.url), 'utf8');
const ast = ts.createSourceFile('slots.tsx', source, ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX);
const hook = ast.statements.find((node) => ts.isFunctionDeclaration(node) && node.name?.text === 'useSidePanelLiveSnapshotForTab');
assert.ok(hook);
const code = ts.transpile(hook.getText(ast), { target: ts.ScriptTarget.ES2022 });
const useSnapshot = vm.runInNewContext(`${code}; useSidePanelLiveSnapshotForTab`, {
useCallback, useRef, useSyncExternalStore, getSidePanelLiveSnapshot,
subscribeSidePanelLiveSnapshot, SIDE_PANEL_INACTIVE_LIVE_SNAPSHOT,
}) as (tabId: string, enabled: boolean) => SidePanelLiveSnapshot;
const env = globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean };
test('returning to a tab never exposes the previous tab snapshot before its publisher catches up', async () => {
const previous = env.IS_REACT_ACT_ENVIRONMENT;
env.IS_REACT_ACT_ENVIRONMENT = true;
const own = { ...SIDE_PANEL_INACTIVE_LIVE_SNAPSHOT, focusedSessionId: 'ssh-a', activeTerminalSessionIdForSftp: 'ssh-a', activeTerminalCwd: '/root', activeTerminalCwdTrusted: true };
const other = { ...SIDE_PANEL_INACTIVE_LIVE_SNAPSHOT, focusedSessionId: 'local-b' };
let visible = true;
let observed: SidePanelLiveSnapshot = SIDE_PANEL_INACTIVE_LIVE_SNAPSHOT;
function Probe() { observed = useSnapshot('ssh-a', visible); return null; }
sidePanelLiveStore.update(own);
let renderer: ReturnType<typeof create> | undefined;
try {
await act(async () => { renderer = create(React.createElement(Probe)); });
assert.equal(observed.focusedSessionId, 'ssh-a');
visible = false;
await act(async () => { renderer?.update(React.createElement(Probe)); });
await act(async () => { sidePanelLiveStore.update(other); });
visible = true;
await act(async () => { renderer?.update(React.createElement(Probe)); });
assert.equal(observed.focusedSessionId, 'ssh-a', 'stale local session must not enter the SSH panel');
assert.equal(observed.activeTerminalCwd, '/root', 'unchanged cwd must survive the reveal gap');
await act(async () => { sidePanelLiveStore.update({ ...own, activeTerminalCwd: '/srv/changed' }); });
assert.equal(observed.activeTerminalCwd, '/srv/changed', 'real cwd changes must still arrive');
} finally {
await act(async () => renderer?.unmount());
sidePanelLiveStore.update(SIDE_PANEL_INACTIVE_LIVE_SNAPSHOT);
env.IS_REACT_ACT_ENVIRONMENT = previous;
}
});
test('new panels wait for their own snapshot and workspace panes accept focus changes within the workspace', async () => {
const previous = env.IS_REACT_ACT_ENVIRONMENT;
env.IS_REACT_ACT_ENVIRONMENT = true;
let observed = SIDE_PANEL_INACTIVE_LIVE_SNAPSHOT;
const workspace = { id: 'workspace-a' } as NonNullable<SidePanelLiveSnapshot['activeWorkspace']>;
const own = { ...SIDE_PANEL_INACTIVE_LIVE_SNAPSHOT, activeWorkspace: workspace, focusedSessionId: 'pane-a' };
let panelTabId = 'workspace-a';
function Probe() { observed = useSnapshot(panelTabId, true); return null; }
sidePanelLiveStore.update({ ...SIDE_PANEL_INACTIVE_LIVE_SNAPSHOT, focusedSessionId: 'unrelated' });
let renderer: ReturnType<typeof create> | undefined;
try {
await act(async () => { renderer = create(React.createElement(Probe)); });
assert.equal(observed, SIDE_PANEL_INACTIVE_LIVE_SNAPSHOT);
await act(async () => { sidePanelLiveStore.update(own); });
assert.equal(observed.focusedSessionId, 'pane-a');
await act(async () => { sidePanelLiveStore.update({ ...own, focusedSessionId: 'pane-b' }); });
assert.equal(observed.focusedSessionId, 'pane-b');
await act(async () => { sidePanelLiveStore.update({ ...SIDE_PANEL_INACTIVE_LIVE_SNAPSHOT, focusedSessionId: 'unrelated' }); });
assert.equal(observed.focusedSessionId, 'pane-b');
panelTabId = 'new-owner';
await act(async () => { renderer?.update(React.createElement(Probe)); });
assert.equal(observed, SIDE_PANEL_INACTIVE_LIVE_SNAPSHOT, 'a reused hook must not retain another owner');
} finally {
await act(async () => renderer?.unmount());
sidePanelLiveStore.update(SIDE_PANEL_INACTIVE_LIVE_SNAPSHOT);
env.IS_REACT_ACT_ENVIRONMENT = previous;
}
});
@@ -7,6 +7,7 @@ import {
createTerminalSelectionAttachment,
decodeTerminalSelectionAttachment,
} from "./terminalSelectionAttachment.ts";
import { createVaultNoteAttachment, formatVaultNoteReferences } from "./vaultNoteAttachment.ts";
test("createTerminalSelectionAttachment returns null for blank selections", () => {
assert.equal(createTerminalSelectionAttachment(" \n\t"), null);
@@ -50,3 +51,13 @@ test("buildPromptWithTerminalSelectionAttachments supports terminal-only prompts
`[Terminal selection: ${attachment.filename}]\nsystemctl status nginx`,
);
});
test("buildPromptWithTerminalSelectionAttachments keeps empty Vault note blocks", () => {
const attachment = createVaultNoteAttachment({ id: "note-1", title: "Runbook" });
assert.ok(attachment);
assert.equal(
buildPromptWithTerminalSelectionAttachments("add deployment steps to this note", [attachment]),
`add deployment steps to this note\n\n${formatVaultNoteReferences([attachment])}`,
);
});
@@ -1,4 +1,8 @@
import type { ChatMessageAttachment, UploadedFile } from "../../infrastructure/ai/types";
import {
formatVaultNoteReferences,
isVaultNoteAttachment,
} from "./vaultNoteAttachment";
export const TERMINAL_SELECTION_ATTACHMENT_MEDIA_TYPE = "text/plain";
@@ -81,6 +85,16 @@ export function isTerminalSelectionAttachment(
return attachment.terminalSelection === true;
}
/**
* Attachments whose text is inlined into the prompt instead of being sent as
* file parts: terminal selections and Vault note mentions.
*/
export function isInlineTextAttachment(
attachment: Pick<ChatMessageAttachment | UploadedFile, "terminalSelection" | "vaultNoteId">,
): boolean {
return isTerminalSelectionAttachment(attachment) || isVaultNoteAttachment(attachment);
}
export function buildPromptWithTerminalSelectionAttachments(
prompt: string,
attachments: Array<ChatMessageAttachment | UploadedFile>,
@@ -95,7 +109,11 @@ export function buildPromptWithTerminalSelectionAttachments(
})
.filter((block): block is string => block !== null);
if (terminalBlocks.length === 0) return prompt;
if (!prompt.trim()) return terminalBlocks.join("").trimStart();
return `${prompt}${terminalBlocks.join("")}`;
const notes = attachments.filter(isVaultNoteAttachment);
const noteBlocks = notes.length ? [`\n\n${formatVaultNoteReferences(notes)}`] : [];
const blocks = [...terminalBlocks, ...noteBlocks];
if (blocks.length === 0) return prompt;
if (!prompt.trim()) return blocks.join("").trimStart();
return `${prompt}${blocks.join("")}`;
}
@@ -0,0 +1,65 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import React from 'react';
import { createDomRenderer, flushEffects, installDomEnvironment } from '../../components/test-support/renderReactDom';
import { EncryptionService } from '../../infrastructure/services/EncryptionService';
import type { SyncManagerState } from '../../infrastructure/services/CloudSyncManager';
import type { SyncPayload } from '../../domain/sync';
test('manual sync does not clear a newly shared password when an older unlock is superseded', async (t) => {
const env = installDomEnvironment();
const previousStorage = Object.getOwnPropertyDescriptor(globalThis, "localStorage");
Object.defineProperty(globalThis, "localStorage", { configurable: true, value: env.window.localStorage });
t.after(() => {
if (previousStorage) Object.defineProperty(globalThis, "localStorage", previousStorage);
else Reflect.deleteProperty(globalThis, "localStorage");
});
let sharedPassword: string | undefined;
let clears = 0;
Object.defineProperty(env.window, 'netcatty', { configurable: true, value: {
cloudSyncGetSessionPassword: async () => sharedPassword,
cloudSyncClearSessionPassword: async () => { clears += 1; sharedPassword = undefined; },
} });
const { getCloudSyncManager } = await import('../../infrastructure/services/CloudSyncManager');
const manager = getCloudSyncManager();
await manager.setupMasterKey('old-fixture-password');
manager.lock();
const newConfig = await EncryptionService.createMasterKeyConfig('new-fixture-password');
const { useCloudSync } = await import('./useCloudSync');
let current!: ReturnType<typeof useCloudSync>;
const Probe = () => { current = useCloudSync(); return null; };
const renderer = await createDomRenderer(env.document);
const originalUnlock = EncryptionService.unlockMasterKey;
let release!: () => void;
let started!: () => void;
const gate = new Promise<void>((resolve) => { release = resolve; });
const entered = new Promise<void>((resolve) => { started = resolve; });
try {
await renderer.render(<Probe />);
await flushEffects();
EncryptionService.unlockMasterKey = async (...args) => {
started();
await gate;
return originalUnlock(...args);
};
sharedPassword = 'old-fixture-password';
const result = current.syncNow({} as SyncPayload).then(() => null, (error: unknown) => error);
await entered;
const state = Reflect.get(manager, 'state') as SyncManagerState;
state.masterKeyConfig = newConfig;
Reflect.get(manager, 'bumpSyncSecurityGeneration').call(manager);
sharedPassword = 'new-fixture-password';
release();
const error = await result;
assert.equal(clears, 0, 'stale work must not erase the new shared password');
assert.equal(sharedPassword, 'new-fixture-password');
assert.ok(error instanceof Error);
assert.match(error.message, /changed while unlocking/i);
} finally {
release();
EncryptionService.unlockMasterKey = originalUnlock;
await renderer.unmount();
manager.destroy();
env.cleanup();
}
});
+9 -36
View File
@@ -6,6 +6,7 @@
* Uses useSyncExternalStore for real-time state synchronization across all components.
*/
import { useCloudSyncAutoUnlock } from './useCloudSyncAutoUnlock';
import { useCallback, useEffect, useMemo, useRef, useSyncExternalStore } from 'react';
import {
type CloudProvider,
@@ -266,42 +267,14 @@ export const useCloudSync = (): CloudSyncHook => {
getConvergentSyncLocalConfigSnapshot,
);
// Auto-unlock: if a master key exists, retrieve the persisted password (Electron safeStorage)
// and unlock silently so users don't have to manage a LOCKED state in the UI.
// Track the master key config hash to detect when a new master key is set up in another window.
const lastMasterKeyHashRef = useRef<string | null>(null);
const attemptedAutoUnlockRef = useRef(false);
useEffect(() => {
// Compute a simple hash of the master key config to detect changes
const currentHash = state.masterKeyConfig
? JSON.stringify({ salt: state.masterKeyConfig.salt, kdf: state.masterKeyConfig.kdf })
: null;
// If master key config changed (e.g., set up in settings window), reset the attempt flag
if (currentHash !== lastMasterKeyHashRef.current) {
lastMasterKeyHashRef.current = currentHash;
attemptedAutoUnlockRef.current = false;
}
if (attemptedAutoUnlockRef.current) return;
if (state.securityState !== 'LOCKED') return;
attemptedAutoUnlockRef.current = true;
void (async () => {
try {
const bridge = netcattyBridge.get();
const password = await bridge?.cloudSyncGetSessionPassword?.();
if (!password) return;
const ok = await manager.unlock(password);
if (!ok) {
void bridge?.cloudSyncClearSessionPassword?.();
}
} catch {
// Ignore auto-unlock errors; manual actions will surface them.
}
})();
}, [state.securityState, state.masterKeyConfig]);
useCloudSyncAutoUnlock({
securityState: state.securityState,
masterKeyIdentity: state.masterKeyConfig
? JSON.stringify(state.masterKeyConfig)
: null,
manager,
bridge: netcattyBridge.get(),
});
useEffect(() => {
if (state.securityState !== 'UNLOCKED') return;
@@ -0,0 +1,253 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import React from 'react';
import { createDomRenderer, flushEffects, installDomEnvironment, runWithAct } from '../../components/test-support/renderReactDom.tsx';
import { useCloudSyncAutoUnlock } from './useCloudSyncAutoUnlock.ts';
for (const strict of [false, true]) {
test(`pending password read is handed off when its owner unmounts (StrictMode=${strict})`, async () => {
const dom = installDomEnvironment();
const renderer = await createDomRenderer(dom.document);
const pending: Array<(value: string | null) => void> = [];
let unlocks = 0;
const manager = { unlock: async () => { unlocks++; return true; } };
const bridge = { cloudSyncGetSessionPassword: () => new Promise<string | null>(resolve => { pending.push(resolve); }) };
function Consumer() {
useCloudSyncAutoUnlock({ securityState: 'LOCKED', masterKeyIdentity: 'handoff-key', manager, bridge });
return null;
}
function tree(owner: boolean) {
return React.createElement(strict ? React.StrictMode : React.Fragment, null,
owner ? React.createElement(Consumer, { key: 'owner' }) : null,
React.createElement(Consumer, { key: 'peer' }));
}
try {
await renderer.render(tree(true));
await flushEffects();
assert.ok(pending.length > 0);
await renderer.render(tree(false));
await flushEffects();
await runWithAct(async () => { for (const resolve of pending) resolve('fixture-only'); });
await flushEffects();
assert.equal(unlocks, 1, 'cancelled owners must not unlock when their old responses arrive');
} finally { await renderer.unmount(); dom.cleanup(); }
});
}
for (const strict of [false, true]) {
test(`peer auto-unlocks when password arrives after the config (StrictMode=${strict})`, async () => {
const dom = installDomEnvironment();
const renderer = await createDomRenderer(dom.document);
let password: string | null = null;
const listeners = new Set<() => void>();
const unlocked: string[] = [];
const manager = { unlock: async (value: string) => { unlocked.push(value); return true; } };
const bridge = {
cloudSyncGetSessionPassword: async () => password,
onCloudSyncSessionPasswordAvailable: (cb: () => void) => { listeners.add(cb); return () => { listeners.delete(cb); }; },
};
function Harness() {
useCloudSyncAutoUnlock({ securityState: 'LOCKED', masterKeyIdentity: 'first-key', manager, bridge });
return null;
}
try {
await renderer.render(strict ? React.createElement(React.StrictMode, null, React.createElement(Harness)) : React.createElement(Harness));
await flushEffects();
assert.deepEqual(unlocked, [], 'the initial read really happened before the password existed');
await runWithAct(async () => { password = 'fixture-only'; for (const cb of listeners) cb(); });
await flushEffects();
assert.deepEqual(unlocked, ['fixture-only']);
} finally { await renderer.unmount(); dom.cleanup(); }
assert.equal(listeners.size, 0);
});
}
for (const strict of [false, true]) {
test(`a failed attempt during key rotation keeps the password for the arriving config (StrictMode=${strict})`, async () => {
const dom = installDomEnvironment();
const renderer = await createDomRenderer(dom.document);
let password: string | null = null;
let cleared = 0;
let currentIdentity = 'old-key';
const listeners = new Set<() => void>();
const unlocked: Array<[string, string]> = [];
const manager = {
unlock: async (value: string) => {
unlocked.push([currentIdentity, value]);
return currentIdentity === 'new-key';
},
};
const bridge = {
cloudSyncGetSessionPassword: async () => password,
cloudSyncClearSessionPassword: async () => { cleared++; return true; },
onCloudSyncSessionPasswordAvailable: (cb: () => void) => { listeners.add(cb); return () => { listeners.delete(cb); }; },
};
function Harness() {
useCloudSyncAutoUnlock({ securityState: 'LOCKED', masterKeyIdentity: currentIdentity, manager, bridge });
return null;
}
try {
await renderer.render(strict ? React.createElement(React.StrictMode, null, React.createElement(Harness)) : React.createElement(Harness));
await flushEffects();
// The password notification overtakes the config event: the peer is
// still locked on the old config when the new password arrives.
await runWithAct(async () => { password = 'rotation-password'; for (const cb of listeners) cb(); });
await flushEffects();
assert.deepEqual(unlocked, [['old-key', 'rotation-password']], 'the new password was tried against the old config');
assert.equal(cleared, 0, 'the failed attempt must not clear the shared password');
// The config event arrives afterwards; the retained password must
// still be available to auto-unlock the rotated key.
currentIdentity = 'new-key';
await renderer.render(strict ? React.createElement(React.StrictMode, null, React.createElement(Harness)) : React.createElement(Harness));
await flushEffects();
assert.deepEqual(unlocked, [
['old-key', 'rotation-password'],
['new-key', 'rotation-password'],
]);
assert.equal(cleared, 0);
} finally { await renderer.unmount(); dom.cleanup(); }
assert.equal(listeners.size, 0);
});
}
test('completed auto-unlock does not undo a later deliberate lock', async () => {
const dom = installDomEnvironment();
const renderer = await createDomRenderer(dom.document);
let calls = 0;
const manager = { unlock: async () => { calls++; return true; } };
let onAvailable: (() => void) | undefined;
const bridge = {
cloudSyncGetSessionPassword: async () => 'fixture-only',
onCloudSyncSessionPasswordAvailable: (cb: () => void) => { onAvailable = cb; return () => { onAvailable = undefined; }; },
};
function Harness({ state, identity = 'key' }: { state: string; identity?: string }) {
useCloudSyncAutoUnlock({ securityState: state, masterKeyIdentity: identity, manager, bridge });
return null;
}
try {
await renderer.render(React.createElement(Harness, { state: 'LOCKED' }));
await flushEffects();
await renderer.render(React.createElement(Harness, { state: 'UNLOCKED' }));
await runWithAct(async () => { onAvailable?.(); });
await renderer.render(React.createElement(Harness, { state: 'LOCKED' }));
await flushEffects();
assert.equal(calls, 1);
await runWithAct(async () => { onAvailable?.(); });
await flushEffects();
assert.equal(calls, 1, 'a peer password event after deliberate lock must not reopen this vault');
await renderer.render(null);
await renderer.render(React.createElement(Harness, { state: 'LOCKED' }));
await runWithAct(async () => { onAvailable?.(); });
await flushEffects();
assert.equal(calls, 1, 'a remounted consumer must retain the manager deliberate lock');
await renderer.render(React.createElement(Harness, { state: 'LOCKED', identity: 'replacement-key' }));
await flushEffects();
assert.equal(calls, 2, 'a genuinely replaced key can still initialize automatically');
} finally { await renderer.unmount(); dom.cleanup(); }
});
for (const strict of [false, true]) {
test(`remount reads a password shared while no consumer was mounted (StrictMode=${strict})`, async () => {
const dom = installDomEnvironment();
const renderer = await createDomRenderer(dom.document);
let password: string | null = null;
let reads = 0;
const unlocked: string[] = [];
const manager = { unlock: async (value: string) => { unlocked.push(value); return true; } };
const bridge = { cloudSyncGetSessionPassword: async () => { reads++; return password; } };
function Consumer() {
useCloudSyncAutoUnlock({ securityState: 'LOCKED', masterKeyIdentity: 'remount-key', manager, bridge });
return null;
}
const tree = () => React.createElement(strict ? React.StrictMode : React.Fragment, null, React.createElement(Consumer));
try {
await renderer.render(tree());
await flushEffects();
assert.ok(reads > 0);
assert.deepEqual(unlocked, []);
await renderer.render(null);
await flushEffects();
password = 'fixture-shared-while-absent';
await renderer.render(tree());
await flushEffects();
assert.deepEqual(unlocked, ['fixture-shared-while-absent']);
} finally { await renderer.unmount(); dom.cleanup(); }
});
}
test('remount during an active derivation does not start a duplicate unlock', async () => {
const dom = installDomEnvironment();
const renderer = await createDomRenderer(dom.document);
let finish!: (value: boolean) => void;
let unlocks = 0;
const manager = { unlock: () => { unlocks++; return new Promise<boolean>(resolve => { finish = resolve; }); } };
const bridge = { cloudSyncGetSessionPassword: async () => 'fixture-only' };
function Consumer() {
useCloudSyncAutoUnlock({ securityState: 'LOCKED', masterKeyIdentity: 'active-key', manager, bridge });
return null;
}
try {
await renderer.render(React.createElement(Consumer));
await flushEffects();
assert.equal(unlocks, 1);
await renderer.render(null);
await renderer.render(React.createElement(Consumer));
await flushEffects();
assert.equal(unlocks, 1);
await runWithAct(async () => finish(true));
await flushEffects();
assert.equal(unlocks, 1);
} finally { await renderer.unmount(); dom.cleanup(); }
});
test('two StrictMode consumers share one derivation after initial effect replay', async () => {
const dom = installDomEnvironment();
const renderer = await createDomRenderer(dom.document);
const pending: Array<(value: string | null) => void> = [];
let unlocks = 0;
const manager = { unlock: async () => { unlocks++; return true; } };
const bridge = { cloudSyncGetSessionPassword: () => new Promise<string | null>(resolve => pending.push(resolve)) };
function Consumer() {
useCloudSyncAutoUnlock({ securityState: 'LOCKED', masterKeyIdentity: 'strict-shared', manager, bridge });
return null;
}
try {
await renderer.render(React.createElement(React.StrictMode, null,
React.createElement(Consumer, { key: 'one' }), React.createElement(Consumer, { key: 'two' })));
await flushEffects();
await runWithAct(async () => { for (const resolve of pending) resolve('fixture-only'); });
await flushEffects();
assert.equal(unlocks, 1);
} finally { await renderer.unmount(); dom.cleanup(); }
});
test('password notification and a later peer mount share the same attempt', async () => {
const dom = installDomEnvironment();
const renderer = await createDomRenderer(dom.document);
let password: string | null = null;
const listeners = new Set<() => void>();
let unlocks = 0;
const manager = { unlock: async () => { unlocks++; return false; } };
const bridge = {
cloudSyncGetSessionPassword: async () => password,
onCloudSyncSessionPasswordAvailable: (cb: () => void) => { listeners.add(cb); return () => { listeners.delete(cb); }; },
};
function Consumer() {
useCloudSyncAutoUnlock({ securityState: 'LOCKED', masterKeyIdentity: 'notification-key', manager, bridge });
return null;
}
const tree = (late: boolean) => React.createElement(React.Fragment, null,
React.createElement(Consumer, { key: 'one' }), React.createElement(Consumer, { key: 'two' }),
late ? React.createElement(Consumer, { key: 'late' }) : null);
try {
await renderer.render(tree(false));
await flushEffects();
assert.equal(unlocks, 0);
await runWithAct(async () => { password = 'fixture-only'; for (const cb of listeners) cb(); });
await flushEffects();
assert.equal(unlocks, 1);
await renderer.render(tree(true));
await flushEffects();
assert.equal(unlocks, 1, 'new consumers share the notification generation');
} finally { await renderer.unmount(); dom.cleanup(); }
});
+115
View File
@@ -0,0 +1,115 @@
import { useEffect, useState } from 'react';
interface AutoUnlockManager {
unlock(password: string): Promise<boolean>;
}
interface AutoUnlockBridge {
cloudSyncGetSessionPassword?(): Promise<string | null>;
onCloudSyncSessionPasswordAvailable?(callback: () => void): () => void;
}
// A renderer can mount several consumers of the same manager. Remember the
// unlocked key across consumer unmounts so reopening settings cannot undo a lock.
const unlockedIdentityByManager = new WeakMap<AutoUnlockManager, string>();
// Dedupe attempts per manager (not per hook instance): every consumer of the
// same manager observes the same events, and without sharing they would each
// run the expensive PBKDF2 derivation inside manager.unlock.
const attemptedAttemptByManager = new WeakMap<AutoUnlockManager, string | null>();
const passwordRevisionByManager = new WeakMap<AutoUnlockManager, number>();
const completedAttemptByManager = new WeakMap<AutoUnlockManager, string>();
// Mounted consumers register a retry trigger here so an attempt can be handed
// off when its owner unmounts (see the cleanup below).
const retryListenersByManager = new WeakMap<AutoUnlockManager, Set<() => void>>();
/** Retry a locked peer window when the setting window finishes sharing its key. */
export function useCloudSyncAutoUnlock(input: {
securityState: string;
masterKeyIdentity: string | null;
manager: AutoUnlockManager;
bridge: AutoUnlockBridge | null | undefined;
}) {
const { securityState, masterKeyIdentity, manager, bridge } = input;
const [retryRevision, setRetryRevision] = useState(0);
useEffect(() => bridge?.onCloudSyncSessionPasswordAvailable?.(() => {
passwordRevisionByManager.set(manager, (passwordRevisionByManager.get(manager) ?? 0) + 1);
setRetryRevision(value => value + 1);
}), [bridge, manager]);
useEffect(() => {
if (!masterKeyIdentity) return;
const attempt = JSON.stringify([masterKeyIdentity, passwordRevisionByManager.get(manager) ?? 0]);
if (securityState === 'UNLOCKED') {
unlockedIdentityByManager.set(manager, masterKeyIdentity);
attemptedAttemptByManager.set(manager, attempt);
return;
}
if (securityState !== 'LOCKED') return;
// Once this key has been unlocked, a later lock is intentional. A peer
// sharing its password must not undo it, regardless of notification order.
if (unlockedIdentityByManager.get(manager) === masterKeyIdentity) return;
// Register before the dedup check: a consumer that only observes an
// in-flight attempt must still be reachable for an ownership handoff.
let listeners = retryListenersByManager.get(manager);
if (!listeners) {
listeners = new Set();
retryListenersByManager.set(manager, listeners);
}
const notifyRetry = () => setRetryRevision(value => value + 1);
listeners.add(notifyRetry);
const removeListener = () => {
listeners.delete(notifyRetry);
if (listeners.size === 0 && completedAttemptByManager.get(manager) === attemptedAttemptByManager.get(manager)) {
attemptedAttemptByManager.delete(manager);
completedAttemptByManager.delete(manager);
}
};
if (attemptedAttemptByManager.get(manager) === attempt) {
return removeListener;
}
attemptedAttemptByManager.set(manager, attempt);
completedAttemptByManager.delete(manager);
let handedOff = false;
let cancelled = false;
let awaitingPassword = true;
void (async () => {
try {
const password = await bridge?.cloudSyncGetSessionPassword?.();
awaitingPassword = false;
if (cancelled || !password) return;
// A failed attempt must not clear the shared password: the config and
// the password travel over unordered channels (localStorage vs IPC),
// so this attempt may have raced a master-key rotation and the
// password may match the config that is still in flight. The attempt
// is deduped per [identity, revision], so keeping the password lets
// the arriving config trigger a retry instead of stranding the vault.
await manager.unlock(password);
} catch {
// Explicit sync actions surface errors; keep auto-unlock silent.
} finally {
if (!handedOff && attemptedAttemptByManager.get(manager) === attempt) {
completedAttemptByManager.set(manager, attempt);
// Availability events can arrive while no consumer is mounted.
// A future mount must read again after this attempt has settled.
if (listeners.size === 0) {
attemptedAttemptByManager.delete(manager);
completedAttemptByManager.delete(manager);
}
}
}
})();
return () => {
cancelled = true;
removeListener();
// React StrictMode can immediately remount the same effect.
if (awaitingPassword && attemptedAttemptByManager.get(manager) === attempt) {
handedOff = true;
attemptedAttemptByManager.set(manager, null);
// Hand the attempt off: this consumer's in-flight password request is
// discarded as cancelled, but other mounted consumers already returned
// at the dedup check above, so they must be nudged to retry.
for (const notify of listeners) notify();
}
};
}, [securityState, masterKeyIdentity, manager, bridge, retryRevision]);
}
@@ -0,0 +1,63 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import React from 'react';
import TestRenderer, { act } from 'react-test-renderer';
import { useCodexConfigModel } from './useCodexConfigModel';
import type { ExternalAgentConfig } from '../../infrastructure/ai/types';
(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
const agent: ExternalAgentConfig = {
id: 'discovered_codex', name: 'Codex', command: 'codex', sdkBackend: 'codex', enabled: true,
env: { CODEX_HOME: '/fixture/custom' },
};
for (const codexRuntime of ['sdk', 'app-server'] as const) {
test(`${codexRuntime}: quick send waits for the picker probe and receives its custom model`, async () => {
let resolve!: (result: unknown) => void;
const calls: unknown[] = [];
const originalWindow = Object.getOwnPropertyDescriptor(globalThis, 'window');
Object.defineProperty(globalThis, 'window', { configurable: true, value: { netcatty: {
aiCodexGetIntegration: (opts: unknown) => { calls.push(opts); return new Promise(done => { resolve = done; }); },
} } });
let result!: ReturnType<typeof useCodexConfigModel>;
function Harness({ config }: { config: ExternalAgentConfig }) {
result = useCodexConfigModel(config, true);
return null;
}
let root!: TestRenderer.ReactTestRenderer;
try {
const config = { ...agent, codexRuntime };
await act(async () => { root = TestRenderer.create(React.createElement(Harness, { config })); });
assert.equal(result.model, null);
let sent = false;
const sending = result.loadModel().then(model => { sent = true; return model; });
await Promise.resolve();
assert.equal(sent, false);
assert.equal(calls.length, 1);
assert.deepEqual((calls[0] as { agentEnv: unknown }).agentEnv, agent.env);
await act(async () => { resolve({ customConfig: { model: 'glm-5' } }); await sending; });
assert.equal(await sending, 'glm-5');
assert.equal(result.model, 'glm-5');
// A different agent must not inherit the previous home/model or promise.
await act(async () => { root.update(React.createElement(Harness, { config: { ...config, env: { CODEX_HOME: '/fixture/other' } } })); });
assert.equal(result.model, null);
assert.equal(calls.length, 2);
await act(async () => { resolve({ customConfig: null }); await result.loadModel(); });
assert.equal(result.model, null);
} finally {
await act(async () => root?.unmount());
if (originalWindow) Object.defineProperty(globalThis, 'window', originalWindow);
else Reflect.deleteProperty(globalThis, 'window');
}
});
}
test('non-Codex agents keep their model flow without probing Codex', async () => {
let result!: ReturnType<typeof useCodexConfigModel>;
function Harness() { result = useCodexConfigModel(undefined, true); return null; }
let root!: TestRenderer.ReactTestRenderer;
try {
await act(async () => { root = TestRenderer.create(React.createElement(Harness)); });
assert.equal(await result.loadModel(), null);
} finally { await act(async () => root.unmount()); }
});
+33
View File
@@ -0,0 +1,33 @@
import { useEffect, useMemo, useState } from 'react';
import { getNetcattyBridge } from '../../infrastructure/ai/aiChatStreamingSupport';
import { getManualAgentCommand, matchesManagedAgentConfig } from '../../infrastructure/ai/managedAgents';
import type { ExternalAgentConfig } from '../../infrastructure/ai/types';
/** Share the config probe between the picker and sends, scoped to this agent. */
export function useCodexConfigModel(agent: ExternalAgentConfig | undefined, isVisible: boolean) {
const loadModel = useMemo(() => {
let pending: Promise<string | null> | undefined;
return () => {
pending ??= Promise.resolve().then(async () => {
if (!isVisible || !agent || !matchesManagedAgentConfig(agent, 'codex')) return null;
const info = await getNetcattyBridge()?.aiCodexGetIntegration?.({
codexPath: getManualAgentCommand(agent),
agentEnv: agent.env,
});
return info?.customConfig?.model || null;
}).catch(() => null);
return pending;
};
// Reopening the panel refreshes config changed outside Netcatty.
}, [agent, isVisible]);
const [result, setResult] = useState<{ loadModel: typeof loadModel; model: string | null }>();
useEffect(() => {
if (!isVisible) return;
let cancelled = false;
void loadModel().then(model => {
if (!cancelled) setResult({ loadModel, model });
});
return () => { cancelled = true; };
}, [isVisible, loadModel]);
return { model: result?.loadModel === loadModel ? result.model : null, loadModel };
}
@@ -8,6 +8,7 @@ import {
isPluginTerminalProviderRefreshCurrent,
mergePluginTerminalThemeColors,
resolvePluginTerminalTheme,
toPluginTerminalTheme,
waitForProviderResponse,
} from './usePluginTerminalProviders.ts';
@@ -24,6 +25,15 @@ const baseTheme = {
},
} as const satisfies TerminalTheme;
test('theme provider requests keep the existing palette contract with intense color enabled or disabled', () => {
for (const foregroundIntense of ['#ff8800', undefined]) {
const theme = { ...baseTheme, colors: { ...baseTheme.colors, foregroundIntense } };
assert.deepEqual(toPluginTerminalTheme(theme), { type: baseTheme.type, colors: baseTheme.colors });
assert.equal(theme.colors.foregroundIntense, foregroundIntense);
assert.equal(resolvePluginTerminalTheme(theme, { background: '#112233' }).colors.foregroundIntense, foregroundIntense);
}
});
test('terminal theme Providers merge deterministically over the host theme', () => {
const merged = mergePluginTerminalThemeColors([
{ background: '#102030', cursor: '#abcdef' },
@@ -51,6 +51,13 @@ interface UsePluginTerminalProvidersOptions extends PluginTerminalProviderRefres
getSnapshotState(): Partial<NetcattyTerminalSessionSnapshot>;
}
export function toPluginTerminalTheme(theme: TerminalTheme) {
// The preview plugin contract exposes the existing palette only. Keep the
// host-only optional color out of its strictly validated request payload.
const { foregroundIntense: _foregroundIntense, ...colors } = theme.colors;
return { type: theme.type, colors };
}
export function mergePluginTerminalThemeColors(
results: readonly PluginTerminalThemeColors[],
): PluginTerminalThemeColors {
@@ -211,10 +218,7 @@ export function usePluginTerminalProviders(options: UsePluginTerminalProvidersOp
if (hasProvider('terminal.theme')) {
tasks.push(applyCurrentProviderResponse(waitForProviderResponse(request('terminal.theme', 'provideTheme', {
reason,
currentTheme: {
type: metadata.baseTheme.type,
colors: metadata.baseTheme.colors,
},
currentTheme: toPluginTerminalTheme(metadata.baseTheme),
}, PROVIDER_DEADLINE_MS, undefined, controller.signal), controller), isCurrent,
(response) => {
setThemeColors(mergePluginTerminalThemeColors(response.results.map((result) => result.status === 'ok'
@@ -7,6 +7,7 @@ import {
getAutoStartRuleBlockReason,
isAutoStartProxyReady,
isPortForwardingAutoStartEnabled,
isPortForwardingReconnectRequested,
} from "./usePortForwardingAutoStart.ts";
import type { GroupConfig, Host, PortForwardingRule, ProxyProfile } from "../../domain/models.ts";
import { STORAGE_KEY_PORT_FORWARDING } from "../../infrastructure/config/storageKeys.ts";
@@ -177,6 +178,15 @@ test("reconnect eligibility follows the current auto-start setting", () => {
assert.equal(isPortForwardingAutoStartEnabled([], "rule-1"), false);
});
test("reconnect is requested for auto-start and auto-reconnect rules only", () => {
assert.equal(isPortForwardingReconnectRequested([rule({ autoStart: true })], "rule-1"), true);
assert.equal(isPortForwardingReconnectRequested([rule({ autoStart: true, autoReconnect: false })], "rule-1"), false);
assert.equal(isPortForwardingReconnectRequested([rule({ autoStart: false, autoReconnect: true })], "rule-1"), true);
assert.equal(isPortForwardingReconnectRequested([rule({ autoStart: false, autoReconnect: false })], "rule-1"), false);
assert.equal(isPortForwardingReconnectRequested([rule({ autoStart: false })], "rule-1"), false);
assert.equal(isPortForwardingReconnectRequested([], "rule-1"), false);
});
test("network recovery starts an exhausted auto-start rule through the real service path", async (t) => {
const env = installStorage();
const previousWindow = Object.getOwnPropertyDescriptor(globalThis, "window");
@@ -8,6 +8,7 @@ import { GroupConfig, Host, Identity, KnownHost, PortForwardingRule, ProxyProfil
import { resolveGroupDefaults, applyGroupDefaults } from "../../domain/groupConfig";
import { materializeHostProxyProfile } from "../../domain/proxyProfiles";
import { STORAGE_KEY_PORT_FORWARDING } from "../../infrastructure/config/storageKeys";
import { isPortForwardingAutoReconnectEnabled } from "../../domain/portForwardingReconnect";
import { localStorageAdapter } from "../../infrastructure/persistence/localStorageAdapter";
import {
getActiveConnection,
@@ -103,6 +104,11 @@ export const isPortForwardingAutoStartEnabled = (
ruleId: string,
): boolean => rules.some((rule) => rule.id === ruleId && rule.autoStart === true);
export const isPortForwardingReconnectRequested = (
rules: PortForwardingRule[],
ruleId: string,
): boolean => rules.some((rule) => rule.id === ruleId && isPortForwardingAutoReconnectEnabled(rule));
export const shouldStartPortForwardingAutoStartRule = (
rule: PortForwardingRule,
connection?: { status: PortForwardingRule["status"] },
@@ -110,6 +116,18 @@ export const shouldStartPortForwardingAutoStartRule = (
!connection || connection.status === "inactive" || connection.status === "error"
);
/**
* Network-recovery counterpart of `shouldStartPortForwardingAutoStartRule`:
* unlike launch-time auto-start, rules that only opt in via `autoReconnect`
* are also restarted after network restoration.
*/
export const shouldRestartPortForwardingReconnectRule = (
rule: PortForwardingRule,
connection?: { status: PortForwardingRule["status"] },
): boolean => isPortForwardingAutoReconnectEnabled(rule) && (
!connection || connection.status === "inactive" || connection.status === "error"
);
export const recoverPortForwardingAutoStartAfterNetworkRestore = async (
restartAutoStartRules: (recoverableRuleIds: ReadonlySet<string>) => Promise<void>,
): Promise<void> => {
@@ -118,7 +136,7 @@ export const recoverPortForwardingAutoStartAfterNetworkRestore = async (
) ?? [];
const recoverableRuleIds = new Set<string>();
for (const rule of rules) {
if (rule.autoStart && resetReconnectAttempts(rule.id)) {
if (isPortForwardingAutoReconnectEnabled(rule) && resetReconnectAttempts(rule.id)) {
recoverableRuleIds.add(rule.id);
}
}
@@ -184,7 +202,7 @@ export const runPortForwardingAutoStart = async ({
recoveryRuleIds,
}: RunPortForwardingAutoStartOptions): Promise<void> => {
await syncWithBackend({
shouldReconnect: (ruleId) => isPortForwardingAutoStartEnabled(
shouldReconnect: (ruleId) => isPortForwardingReconnectRequested(
localStorageAdapter.read<PortForwardingRule[]>(STORAGE_KEY_PORT_FORWARDING) ?? [],
ruleId,
),
@@ -197,9 +215,11 @@ export const runPortForwardingAutoStart = async ({
STORAGE_KEY_PORT_FORWARDING,
) ?? [];
const autoStartRules = rules.filter((rule) =>
(!recoveryRuleIds || recoveryRuleIds.has(rule.id)) &&
(!recoveryRuleIds || isReconnectRecoveryEligible(rule.id)) &&
shouldStartPortForwardingAutoStartRule(rule, getActiveConnection(rule.id)),
recoveryRuleIds
? recoveryRuleIds.has(rule.id) &&
isReconnectRecoveryEligible(rule.id) &&
shouldRestartPortForwardingReconnectRule(rule, getActiveConnection(rule.id))
: shouldStartPortForwardingAutoStartRule(rule, getActiveConnection(rule.id)),
);
if (autoStartRules.length === 0) return;
@@ -385,7 +405,7 @@ export const usePortForwardingAutoStart = ({
return startPortForward(rule, host, resolveEffectiveHosts(hostsRef.current), keysRef.current, identitiesRef.current, onStatusChange, true, terminalSettingsRef.current, knownHostsRef.current);
};
setReconnectCallback(handleReconnect, (ruleId) => isPortForwardingAutoStartEnabled(
setReconnectCallback(handleReconnect, (ruleId) => isPortForwardingReconnectRequested(
localStorageAdapter.read<PortForwardingRule[]>(STORAGE_KEY_PORT_FORWARDING) ?? [],
ruleId,
));
+1 -1
View File
@@ -47,7 +47,7 @@ export interface ServerStats {
disks: DiskInfo[]; // All mounted disks
netRxSpeed: number; // Total network receive speed (bytes/sec)
netTxSpeed: number; // Total network transmit speed (bytes/sec)
latencyMs: number | null; // TCP connection establishment latency to the SSH endpoint
latencyMs: number | null; // SSH transport ping round-trip to the SSH endpoint
netInterfaces: NetInterfaceInfo[]; // Per-interface network stats
lastUpdated: number | null; // Timestamp of last successful update
}
+6
View File
@@ -32,6 +32,11 @@ export const useTrayPanelBackend = () => {
await bridge?.connectToHostFromTrayPanel?.(hostId);
}, []);
const startPortForwardFromTrayPanel = useCallback(async (ruleId: string) => {
const bridge = netcattyBridge.get();
return bridge?.startPortForwardFromTrayPanel?.(ruleId);
}, []);
const onTrayPanelCloseRequest = useCallback((callback: () => void) => {
const bridge = netcattyBridge.get();
return bridge?.onTrayPanelCloseRequest?.(callback);
@@ -71,6 +76,7 @@ export const useTrayPanelBackend = () => {
quitApp,
jumpToSession,
closeSessionFromTrayPanel,
startPortForwardFromTrayPanel,
connectToHostFromTrayPanel,
onTrayPanelCloseRequest,
onTrayPanelRefresh,
+8 -5
View File
@@ -111,6 +111,7 @@ export function useVaultImportHandlers({
const handleImportFileSelected = useCallback(
async (format: VaultImportFormat, files: File[], options?: VaultImportOptions) => {
const preserveProfiles = format === "securecrt" || format === "finalshell";
const file = files[0];
if (!file) return;
if (importInFlightRef.current) return;
@@ -141,7 +142,9 @@ export function useVaultImportHandlers({
? "CSV"
: format === "securecrt"
? "SecureCRT"
: "ssh_config";
: format === "finalshell"
? "FinalShell"
: "ssh_config";
const updateProgress = (next: Partial<VaultImportProgress>) => {
setImportProgress((current) => ({
status: "running",
@@ -183,9 +186,9 @@ export function useVaultImportHandlers({
result = applyVaultImportDestination(
result,
options?.destination ?? { mode: "preserve" },
// SecureCRT keeps distinct session files that share an endpoint;
// Folder imports keep distinct profiles that share an endpoint;
// only rewrite their group when the user picks an import location.
{ collapseDuplicateEndpoints: format !== "securecrt" },
{ collapseDuplicateEndpoints: !preserveProfiles },
);
}
updateProgress({ stage: "preparing", percent: 70 });
@@ -321,7 +324,7 @@ export function useVaultImportHandlers({
const existingKeys = new Set(currentHosts.map(makeKey));
// Filter out duplicates for both managed and non-managed imports
let newHosts = format === "securecrt"
let newHosts = preserveProfiles
? result.hosts
: result.hosts.filter((h) => !existingKeys.has(makeKey(h)));
@@ -486,7 +489,7 @@ export function useVaultImportHandlers({
importBaselineHosts,
importBaselineGroups,
result,
{ skipDuplicates: format !== "securecrt" },
{ skipDuplicates: !preserveProfiles },
);
newHosts = merged.addedHosts;
addedHostIds = new Set(merged.addedHosts.map((host) => host.id));
@@ -25,6 +25,23 @@ test("SecureCRT uses a directory picker and keeps every selected session file",
assert.deepEqual(selectVaultImportFiles("csv", files), [files[0]]);
});
test("FinalShell supports conn directory and single JSON file selection", () => {
const files = [
new File(["{}"], "one.json"),
new File(["{}"], "two.json"),
];
assert.deepEqual(getVaultImportPickerMode("finalshell"), {
directory: true,
multiple: true,
});
assert.deepEqual(getVaultImportPickerMode("finalshell", "file"), {
directory: false,
multiple: false,
});
assert.deepEqual(selectVaultImportFiles("finalshell", files), files);
});
test("vault import destination supports preserve, existing, and new groups", () => {
assert.deepEqual(buildVaultImportDestination({ mode: "preserve" }), {
mode: "preserve",
+8 -5
View File
@@ -7,15 +7,16 @@ export type VaultImportDestinationMode = "preserve" | "existing" | "new";
export function getVaultImportPickerMode(
format: VaultImportFormat,
secureCrtSource: "folder" | "file" = "folder",
source: "folder" | "file" = "folder",
): {
directory: boolean;
multiple: boolean;
} {
const isSecureCrtFolder = format === "securecrt" && secureCrtSource === "folder";
const isBatchFolder = (format === "securecrt" || format === "finalshell")
&& source === "folder";
return {
directory: isSecureCrtFolder,
multiple: isSecureCrtFolder,
directory: isBatchFolder,
multiple: isBatchFolder,
};
}
@@ -24,7 +25,9 @@ export function selectVaultImportFiles(
files: ArrayLike<File>,
): File[] {
const selected = Array.from(files);
return format === "securecrt" ? selected : selected.slice(0, 1);
return format === "securecrt" || format === "finalshell"
? selected
: selected.slice(0, 1);
}
const normalizeGroup = (raw: string | undefined): string | undefined => {
@@ -0,0 +1,54 @@
import assert from "node:assert/strict";
import test from "node:test";
import { createVaultNoteAttachment, formatVaultNoteReferences, vaultNoteReferencesFit } from "./vaultNoteAttachment.ts";
import { buildPromptWithTerminalSelectionAttachments, createTerminalSelectionAttachment, isInlineTextAttachment } from "./terminalSelectionAttachment.ts";
test("mentions carry identity without copying even large note bodies", () => {
const note = { id: "note-1", title: "Runbook", content: "secret body".repeat(200_000) };
const attachment = createVaultNoteAttachment(note)!;
assert.equal(attachment.vaultNoteId, note.id);
assert.equal(attachment.base64Data, "");
assert.equal(attachment.dataUrl, "");
assert.ok(JSON.stringify(attachment).length < 400);
assert.ok(isInlineTextAttachment(attachment));
const prompt = buildPromptWithTerminalSelectionAttachments("summarize", [attachment]);
assert.match(prompt, /vault_notes_get/);
assert.match(prompt, /latest content/);
assert.doesNotMatch(prompt, /secret body/);
});
test("same titled notes keep distinct exact IDs including imported special characters", () => {
for (const id of ["note-a", "note-b", ' quoted"\\id\r\n ']) {
const attachment = createVaultNoteAttachment({ id, title: "Same title" })!;
const reference = formatVaultNoteReferences([attachment]).split("\n")[0];
const [metadata] = JSON.parse(reference.slice("[Vault note references: ".length, -1));
assert.equal(metadata.noteId, id);
assert.equal(metadata.title, "Same title");
}
});
test("invalid IDs are rejected and displayed titles remain bounded without broken emoji", () => {
assert.equal(createVaultNoteAttachment({ id: " ", title: "x" }), null);
assert.equal(createVaultNoteAttachment({ id: "a".repeat(201), title: "x" }), null);
const attachment = createVaultNoteAttachment({ id: "ok", title: "😀".repeat(200) })!;
assert.equal(Array.from(attachment.vaultNoteTitle!).length, 120);
});
test("note-only sends and mixed terminal selections retain both kinds of context", () => {
const note = createVaultNoteAttachment({ id: "empty-note", title: "" })!;
const terminal = createTerminalSelectionAttachment("systemctl status nginx")!;
assert.match(buildPromptWithTerminalSelectionAttachments("", [note]), /empty-note/);
const prompt = buildPromptWithTerminalSelectionAttachments("compare", [note, terminal]);
assert.match(prompt, /systemctl status nginx/);
assert.match(prompt, /empty-note/);
assert.match(prompt, /^compare/);
});
test("reference limit reserves replay space and does not count ordinary files", () => {
const notes = Array.from({length: 10}, (_, i) => createVaultNoteAttachment({id: String(i).padEnd(200, "x"), title: "t".repeat(120)})!);
assert.equal(vaultNoteReferencesFit(notes), false);
const accepted = notes.filter((_, index) => vaultNoteReferencesFit(notes.slice(0, index + 1)));
assert.ok(accepted.length > 0);
assert.ok(formatVaultNoteReferences(accepted).length <= 1000);
assert.equal(vaultNoteReferencesFit([...accepted, {id: "file", filename: "file", mediaType: "text/plain", dataUrl: "", base64Data: "x".repeat(2_000_000)}]), true);
});
+38
View File
@@ -0,0 +1,38 @@
import type { VaultNote } from "../../domain/models";
import type { ChatMessageAttachment, UploadedFile } from "../../infrastructure/ai/types";
/** A mention stores only identity; the agent reads current content through the Vault tool. */
export function createVaultNoteAttachment(note: Pick<VaultNote, "id" | "title">): UploadedFile | null {
// Preserve imported IDs exactly. Reject oversized IDs instead of changing their identity.
if (!note.id.trim() || note.id.length > 200) return null;
const title = Array.from(note.title.trim() || "Untitled note").slice(0, 120).join("");
return {
id: crypto.randomUUID(),
filename: title,
mediaType: "text/markdown",
base64Data: "",
dataUrl: "",
vaultNoteId: note.id,
vaultNoteTitle: title,
};
}
export function isVaultNoteAttachment(
attachment: Pick<ChatMessageAttachment | UploadedFile, "vaultNoteId">,
): boolean {
return typeof attachment.vaultNoteId === "string" && attachment.vaultNoteId.length > 0;
}
/** JSON preserves special characters and whitespace in exact note IDs. */
export function formatVaultNoteReferences(
attachments: Array<Pick<ChatMessageAttachment | UploadedFile, "vaultNoteId" | "vaultNoteTitle">>,
): string {
const notes = attachments.map((attachment) => ({ noteId: attachment.vaultNoteId, title: attachment.vaultNoteTitle }));
return `[Vault note references: ${JSON.stringify(notes)}]\nUse vault_notes_get with each exact noteId to read the latest content before summarizing or editing. If unavailable, report that instead of choosing another note.`;
}
/** Reserve at least half of external replay's 2,000 characters for the user's request. */
export function vaultNoteReferencesFit(attachments: UploadedFile[]): boolean {
const notes = attachments.filter(isVaultNoteAttachment);
return notes.length === 0 || formatVaultNoteReferences(notes).length <= 1000;
}
+5
View File
@@ -88,6 +88,8 @@ interface AIChatPanelContentProps {
setGlobalPermissionMode?: (mode: AIPermissionMode) => void;
notes?: VaultNote[];
hosts?: Host[];
/** Mention Note: attach a Vault → Notes entry as inline context. */
onMentionNote?: (note: VaultNote) => void;
onOpenVaultNote?: (noteId: string) => void;
onOpenVaultHost?: (hostId: string) => void;
onOpenVaultSection?: (section: 'notes' | 'hosts') => void;
@@ -157,6 +159,7 @@ export const AIChatPanelContent: React.FC<AIChatPanelContentProps> = ({
setGlobalPermissionMode,
notes = [],
hosts = [],
onMentionNote,
onOpenVaultNote,
onOpenVaultHost,
onOpenVaultSection,
@@ -361,6 +364,8 @@ export const AIChatPanelContent: React.FC<AIChatPanelContentProps> = ({
onAddFiles={addFiles}
onRemoveFile={removeFile}
hosts={mentionHosts}
notes={notes}
onMentionNote={onMentionNote}
selectedUserSkills={selectedUserSkills}
userSkills={userSkillOptions}
quickMessages={quickMessages}
+78 -51
View File
@@ -9,9 +9,11 @@ import type {
AIPanelView,
AgentModelPreset,
AISessionScope,
UploadedFile,
DiscoveredAgent,
ExternalAgentConfig,
} from '../infrastructure/ai/types';
import type { VaultNote } from '../domain/models';
import type { ExecutorContext } from '../infrastructure/ai/cattyAgent/executor';
import {
filterAgentModelPresetsForCliVersion,
@@ -20,6 +22,7 @@ import {
resolveAgentModelSelection,
} from '../infrastructure/ai/types';
import { getExternalAgentSdkBackend, getManualAgentCommand, matchesManagedAgentConfig } from '../infrastructure/ai/managedAgents';
import { toast } from './ui/toast';
import { useAgentDiscovery } from '../application/state/useAgentDiscovery';
import {
getReadyUserSkillOptions,
@@ -42,9 +45,10 @@ import { draftsByScopeEqualIgnoringComposerText, selectDraftForAgentSwitch } fro
import { sanitizeContextWindow } from '../infrastructure/ai/contextCompaction';
import {
buildPromptWithTerminalSelectionAttachments,
isTerminalSelectionAttachment,
isInlineTextAttachment,
} from '../application/state/terminalSelectionAttachment';
import type { CodexIntegrationStatus } from './settings/tabs/ai/types';
import { createVaultNoteAttachment, isVaultNoteAttachment, vaultNoteReferencesFit } from '../application/state/vaultNoteAttachment';
import { useCodexConfigModel } from '../application/state/useCodexConfigModel';
import {
useAIChatStreaming,
getNetcattyBridge,
@@ -597,6 +601,42 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
removeDraftFile(scopeKey, currentAgentId, fileId);
}, [removeDraftFile, scopeKey, currentAgentId]);
// External turns keep the tools from launch; changed settings cannot enable note reads mid-turn.
const canMentionNotes = currentAgentId === 'catty' || (toolIntegrationMode === 'mcp' && !isStreaming);
const validateNoteMentions = useCallback((attachments: UploadedFile[]) => {
if (!canMentionNotes && attachments.some(isVaultNoteAttachment)) {
toast.warning(t('ai.chat.mentionNoteUnavailable'));
return false;
}
if (!vaultNoteReferencesFit(attachments)) {
toast.warning(t('ai.chat.mentionNoteTooMany'));
return false;
}
return true;
}, [canMentionNotes, t]);
/** Mention Note: attach a Vault → Notes entry as inline context for the next send. */
const mentionNote = useCallback((note: VaultNote) => {
if (!canMentionNotes) return;
const upload = createVaultNoteAttachment({ id: note.id, title: note.title.trim() || t('ai.chat.untitledNote') });
if (!upload) {
toast.error(t('ai.chat.mentionNoteInvalid', {
title: String(note.title || '').trim() || t('ai.chat.untitledNote'),
}));
return;
}
const existing = currentDraftRef.current?.attachments ?? [];
if (!validateNoteMentions([...existing.filter((file) => file.vaultNoteId !== upload.vaultNoteId), upload])) return;
enterScopeDraftMode(currentAgentId, panelViewRef.current.mode === 'session');
updateDraft(scopeKey, currentAgentId, (current) => ({
...current,
attachments: [
...current.attachments.filter((file) => file.vaultNoteId !== upload.vaultNoteId),
upload,
],
}));
}, [canMentionNotes, validateNoteMentions, updateDraft, currentAgentId, enterScopeDraftMode, scopeKey, t]);
useEffect(() => {
if (isVisible) return undefined;
flushDraftText();
@@ -803,35 +843,9 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
[currentAgentConfig],
);
const [codexConfigModel, setCodexConfigModel] = useState<string | null>(null);
const [codexCustomConfigResolved, setCodexCustomConfigResolved] = useState(false);
useEffect(() => {
if (!isVisible) return;
setCodexCustomConfigResolved(false);
if (!isCodexManagedAgent) {
setCodexConfigModel(null);
return;
}
const bridge = getNetcattyBridge();
if (!bridge?.aiCodexGetIntegration) return;
let cancelled = false;
void Promise.resolve(
bridge.aiCodexGetIntegration({ codexPath: getManualAgentCommand(currentAgentConfig) }) as Promise<CodexIntegrationStatus>,
).then((info) => {
if (cancelled) return;
const hasCustom = info?.state === 'connected_custom_config';
setCodexConfigModel(info?.customConfig?.model ?? null);
setCodexCustomConfigResolved(hasCustom);
}).catch(() => {
if (!cancelled) {
setCodexConfigModel(null);
setCodexCustomConfigResolved(false);
}
});
return () => {
cancelled = true;
};
}, [isVisible, isCodexManagedAgent, currentAgentId, currentAgentConfig]);
const { model: codexConfigModel, loadModel: loadCodexConfigModel } = useCodexConfigModel(
currentAgentConfig, isVisible,
);
const agentModelMapRef = useRef(agentModelMap);
agentModelMapRef.current = agentModelMap;
@@ -995,18 +1009,12 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
const isCodexAppServer = isCodexManagedAgent && currentAgentConfig?.codexRuntime === 'app-server';
const canSteerCurrentTurn = Boolean(activeSessionId && isStreaming && isCodexAppServer);
const hasCodexCustomConfig = codexCustomConfigResolved && isCodexManagedAgent && !isCodexAppServer;
const hasCodexCustomConfig = Boolean(codexConfigModel) && isCodexManagedAgent;
const agentModelPresets = useMemo(() => {
const runtimePresets = runtimeAgentModelPresets[currentAgentId];
if (hasCodexCustomConfig) {
if (runtimePresets) {
return runtimePresets;
}
if (codexConfigModel) {
return [{ id: codexConfigModel, name: codexConfigModel }];
}
return [];
if (hasCodexCustomConfig && codexConfigModel) {
return [{ id: codexConfigModel, name: codexConfigModel }];
}
if (runtimePresets) return runtimePresets;
const presets = getAgentModelPresets(
@@ -1141,6 +1149,7 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
const draft = currentDraftRef.current;
const currentPanelView = panelViewRef.current;
const currentSessionView = activeSessionRef.current;
if (!validateNoteMentions(draft?.attachments ?? [])) return;
const trimmed = draft?.text.trim() ?? '';
const sendScopeKey = scopeKey;
const attachments = (draft?.attachments ?? []).map((file) => ({
@@ -1149,18 +1158,27 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
filename: file.filename,
filePath: file.filePath,
terminalSelection: file.terminalSelection,
vaultNoteId: file.vaultNoteId,
vaultNoteTitle: file.vaultNoteTitle,
previewText: file.previewText,
lineCount: file.lineCount,
}));
const hasTerminalSelectionAttachments = attachments.some(isTerminalSelectionAttachment);
if ((!trimmed && !hasTerminalSelectionAttachments) || isStreaming) return;
const hasInlineTextAttachments = attachments.some(isInlineTextAttachment);
if ((!trimmed && !hasInlineTextAttachments) || isStreaming) return;
// Note-only sends (empty text + a mentioned note) still need a usable
// session title: fall back to the first mentioned note's title so these
// conversations don't all show up as "Untitled" in history.
const noteOnlyTitle = attachments.find(
(attachment) => isVaultNoteAttachment(attachment) && attachment.vaultNoteTitle,
)?.vaultNoteTitle ?? '';
const titleText = trimmed || noteOnlyTitle.trim();
const sendAgentId = currentSessionView?.agentId ?? draft?.agentId ?? currentAgentId;
const agentConfig = sendAgentId !== 'catty' ? findEnabledExternalAgent(externalAgents, sendAgentId) : undefined;
if (sendAgentId !== 'catty' && !agentConfig) return;
const selectedSkillSlugs = draft?.selectedUserSkillSlugs ?? [];
const modelPrompt = buildPromptWithTerminalSelectionAttachments(trimmed, attachments);
const modelAttachments = attachments.filter((attachment) => !isTerminalSelectionAttachment(attachment));
const modelAttachments = attachments.filter((attachment) => !isInlineTextAttachment(attachment));
const isDraftMode = currentPanelView.mode === 'draft';
flushDraftText();
@@ -1186,8 +1204,11 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
if (sendBridge?.aiSyncWebSearch) {
await sendBridge.aiSyncWebSearch(webSearchConfig?.apiHost || null, webSearchConfig?.apiKey || null);
}
let sendSelectedAgentModel = selectedAgentModel;
if (currentAgentConfig && shouldLoadSdkRuntimeModels(currentAgentConfig)) {
// A quick send must await the same config probe as the picker. Its model
// also takes precedence over App Server's built-in model catalog.
const configModel = await loadCodexConfigModel();
let sendSelectedAgentModel = configModel ?? selectedAgentModel;
if (!configModel && currentAgentConfig && shouldLoadSdkRuntimeModels(currentAgentConfig)) {
const runtimeTarget = buildExternalAgentRuntimeModelTarget(currentAgentConfig);
if (runtimeTarget) {
const catalog = await loadSdkRuntimeModelCatalog(runtimeTarget);
@@ -1337,7 +1358,7 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
updateLastMessage(sessionId, msg => msg.statusText ? { ...msg, statusText: '' } : msg);
setStreamingForScope(sessionId, false);
abortControllersRef.current.delete(sessionId);
autoTitleSession(sessionId, trimmed);
autoTitleSession(sessionId, titleText);
} else {
const toolScope = {
type: scopeType,
@@ -1360,7 +1381,7 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
getExecutorContext: () => buildExecutorContextForScope(toolScope),
autoTitleSession,
selectedUserSkillSlugs: selectedSkillSlugs,
titleText: trimmed,
titleText,
}, modelAttachments.length > 0 ? modelAttachments : undefined);
}
} finally {
@@ -1371,6 +1392,7 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
}
}
}, [
validateNoteMentions, loadCodexConfigModel,
isStreaming, activeProvider, effectiveActiveProvider, effectiveActiveModelId, selectedCattyThinking, scopeKey, currentAgentId,
activeModelId, externalAgents,
createSession, addMessageToSession, updateMessageById, updateLastMessage,
@@ -1467,6 +1489,7 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
const draft = currentDraftRef.current;
if (!sessionId || !draft || steeringSessionId || !canSteerCurrentTurn) return;
if (!validateNoteMentions(draft.attachments)) return;
const trimmed = draft.text.trim();
const attachments = draft.attachments.map((file) => ({
base64Data: file.base64Data,
@@ -1474,15 +1497,17 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
filename: file.filename,
filePath: file.filePath,
terminalSelection: file.terminalSelection,
vaultNoteId: file.vaultNoteId,
vaultNoteTitle: file.vaultNoteTitle,
previewText: file.previewText,
lineCount: file.lineCount,
}));
const hasTerminalSelectionAttachments = attachments.some(isTerminalSelectionAttachment);
if (!trimmed && !hasTerminalSelectionAttachments) return;
const hasInlineTextAttachments = attachments.some(isInlineTextAttachment);
if (!trimmed && !hasInlineTextAttachments) return;
const userMessageId = generateId();
const modelPrompt = buildPromptWithTerminalSelectionAttachments(trimmed, attachments);
const modelAttachments = attachments.filter((attachment) => !isTerminalSelectionAttachment(attachment));
const modelAttachments = attachments.filter((attachment) => !isInlineTextAttachment(attachment));
setSteerWarnings(current => {
const next = { ...current };
delete next[sessionId];
@@ -1511,7 +1536,8 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
} finally {
setSteeringSessionId(current => current === sessionId ? null : current);
}
}, [canSteerCurrentTurn, clearScopeDraft, steerExternalAgent, steeringSessionId]);
}, [
validateNoteMentions, canSteerCurrentTurn, clearScopeDraft, steerExternalAgent, steeringSessionId]);
const stopStreamingForSession = useCallback(async (sessionId: string) => {
const controller = abortControllersRef.current.get(sessionId);
@@ -1707,6 +1733,7 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
files={files}
addFiles={addFiles}
removeFile={removeFile}
onMentionNote={canMentionNotes ? mentionNote : undefined}
terminalSessions={terminalSessions}
selectedUserSkills={selectedUserSkills}
userSkillOptions={userSkillOptions}
@@ -28,6 +28,15 @@ test('checked targets keep a visible background after the cursor moves away', ()
assert.equal(getWorkspaceTargetRowStateClass(true, true), 'bg-primary/20');
});
test('workspace host picker search matches tags through the shared matcher', () => {
const source = readFileSync(
new URL('./workspace/AddToWorkspaceDialog.tsx', import.meta.url),
'utf8',
);
assert.match(source, /matchesWorkspaceHostPickerQuery/);
assert.doesNotMatch(source, /h\.label\?\.toLowerCase\(\)\.includes\(term\)/);
});
test('clicking a target aligns the keyboard cursor with that target', () => {
const source = readFileSync(
new URL('./workspace/AddToWorkspaceDialog.tsx', import.meta.url),
+5 -8
View File
@@ -1,6 +1,7 @@
import { Search } from 'lucide-react';
import React, { useMemo, useState, useEffect, useCallback } from 'react';
import { useI18n } from '../application/i18n/I18nProvider';
import { matchesWorkspaceHostPickerQuery } from '../lib/searchMatcher';
import { Host } from '../types';
import { DistroAvatar } from './DistroAvatar';
import { Button } from './ui/button';
@@ -30,13 +31,9 @@ export const CreateWorkspaceDialog: React.FC<CreateWorkspaceDialogProps> = ({
const [selectedHostIds, setSelectedHostIds] = useState<Set<string>>(new Set());
const filteredHosts = useMemo(() => {
if (!search.trim()) return hosts;
const term = search.toLowerCase();
return hosts.filter(h =>
h.label.toLowerCase().includes(term) ||
h.hostname.toLowerCase().includes(term) ||
(h.group || '').toLowerCase().includes(term)
);
const term = search.trim();
if (!term) return hosts;
return hosts.filter((host) => matchesWorkspaceHostPickerQuery(term, host));
}, [hosts, search]);
const toggleHost = useCallback((hostId: string) => {
@@ -109,7 +106,7 @@ export const CreateWorkspaceDialog: React.FC<CreateWorkspaceDialogProps> = ({
<div className="relative">
<Search className="absolute left-2 top-2.5 h-4 w-4 text-muted-foreground" />
<Input
placeholder={t('placeholder.searchHosts', { defaultValue: 'Search hosts...' })}
placeholder={t('placeholder.searchHosts', { defaultValue: 'Search hosts or tags...' })}
value={search}
onChange={(e) => setSearch(e.target.value)}
className="pl-8"
@@ -0,0 +1,69 @@
import assert from "node:assert/strict";
import test from "node:test";
import React, { act } from "react";
import { createDomRenderer, dispatchDomEvent, flushEffects, installDomEnvironment } from "./test-support/renderReactDom.tsx";
test("resume from another panel is visible while backend verification is pending", async (t) => {
const env = installDomEnvironment();
const previous = new Map<string, PropertyDescriptor | undefined>();
const globals = {
MutationObserver: env.window.MutationObserver,
NodeFilter: env.window.NodeFilter,
HTMLInputElement: env.window.HTMLInputElement,
Element: env.window.Element,
requestAnimationFrame: (callback: FrameRequestCallback) => setTimeout(() => callback(Date.now()), 0),
cancelAnimationFrame: clearTimeout,
};
for (const [key, value] of Object.entries(globals)) {
previous.set(key, Object.getOwnPropertyDescriptor(globalThis, key));
Object.defineProperty(globalThis, key, { configurable: true, writable: true, value });
}
Object.defineProperties(env.window.HTMLElement.prototype, {
offsetWidth: { configurable: true, get: () => 460 },
offsetHeight: { configurable: true, get() { return this.dataset.section === "global-sftp-transfer-list" ? 460 : 112; } },
});
const { I18nProvider } = await import("../application/i18n/I18nProvider.tsx");
const { sftpTransferCenterStore: store } = await import("../application/state/sftpTransferCenterStore.ts");
const { GlobalSftpTransferCenter } = await import("./GlobalSftpTransferCenter.tsx");
const { TooltipProvider } = await import("./ui/tooltip.tsx");
const renderer = await createDomRenderer(env.document);
t.after(async () => {
await renderer.unmount();
for (const [key, descriptor] of previous) {
if (descriptor) Object.defineProperty(globalThis, key, descriptor);
else Reflect.deleteProperty(globalThis, key);
}
env.cleanup();
});
let settleResume!: (result: { success: boolean }) => void;
let resumeCalls = 0;
const gate = new Promise<{ success: boolean }>((resolve) => { settleResume = resolve; });
Object.defineProperty(env.window, "netcatty", { configurable: true, value: {
resumeTransfer: async () => { resumeCalls += 1; return gate; },
} });
store.publishOwner("resume-display-test", [{
id: "resume-display", fileName: "resume-display.bin",
sourcePath: "/source.bin", targetPath: "/target.bin",
sourceConnectionId: "remote", targetConnectionId: "local", direction: "download",
status: "paused", totalBytes: 1000, transferredBytes: 100, speed: 0,
startTime: 1, isDirectory: false, resumable: true,
}]);
await renderer.render(<I18nProvider locale="en"><TooltipProvider><GlobalSftpTransferCenter /></TooltipProvider></I18nProvider>);
const toggle = env.document.querySelector("[data-section=global-sftp-transfer-toggle]");
assert.ok(toggle);
await dispatchDomEvent(toggle, new env.window.MouseEvent("click", { bubbles: true }));
let resumed!: Promise<void>;
await act(async () => { resumed = store.resume("resume-display"); });
try {
await flushEffects();
assert.equal(resumeCalls, 1);
const row = env.document.querySelector('[role=progressbar][aria-label="resume-display.bin"]')?.closest('[data-transfer-status]');
assert.ok(row);
assert.match(row.textContent ?? "", /Reconnecting and resuming/);
assert.equal(row.querySelector('button[aria-label="Resume"]'), null);
} finally {
await act(async () => { settleResume({ success: true }); await resumed; });
await dispatchDomEvent(toggle, new env.window.MouseEvent("click", { bubbles: true }));
await flushEffects();
}
});
+3 -18
View File
@@ -23,6 +23,7 @@ import { useI18n } from "../application/i18n/I18nProvider";
import {
sftpTransferCenterStore,
useSftpTransferCenterBadge,
useSftpTransferResuming,
type SftpTransferCenterSnapshot,
} from "../application/state/sftpTransferCenterStore";
import { transferRuntime } from "../application/state/sftp/transferRuntime";
@@ -355,7 +356,6 @@ function TransferRow({
const { t } = useI18n();
const folderReplaceWarningId = React.useId();
// Optimistic spinner from click until store status moves off paused/interrupted.
const [resumeClicked, setResumeClicked] = useState(false);
const isDirParent = isDirectoryParentTask(task);
const showCollapsedChildren = isDirParent && shouldShowCollapsedActiveChildren(task.status);
const activeChildren = useMemo(
@@ -397,22 +397,8 @@ function TransferRow({
const storeResuming = task.reconnectRequired === true
&& ["pending", "queued", "transferring"].includes(task.status)
&& !task.error;
const isResuming = storeResuming
|| (resumeClicked && ["paused", "interrupted", "attention", "pending", "queued"].includes(task.status) && !task.error);
// Clear optimistic click once the store has left the idle-resume surface or failed.
useEffect(() => {
if (!resumeClicked) return;
if (
storeResuming
|| task.status === "transferring"
|| task.status === "completed"
|| task.status === "failed"
|| task.status === "cancelled"
|| !!task.error
) {
setResumeClicked(false);
}
}, [resumeClicked, storeResuming, task.status, task.error]);
const sharedResuming = useSftpTransferResuming(task.id);
const isResuming = sharedResuming || storeResuming;
const canPause = task.resumable !== false && task.status === "transferring" && canControl && !isResuming;
// Orphaned tasks after app restart (interrupted / attention / paused without a
// live panel owner) must still expose resume/cancel from the global center.
@@ -467,7 +453,6 @@ function TransferRow({
}));
};
const resumeTask = () => {
setResumeClicked(true);
// Dedicated resume opens vault sessions for local↔remote and SFTP↔SFTP.
// Only force-open the panel when the row still needs a live owner/adoption
// (e.g. conflict) — not on every remote-to-remote resume click.
@@ -64,4 +64,6 @@ test("large transfer histories mount only visible rows and can scroll to the las
const lastRow = env.document.querySelector('[role=progressbar][aria-label="file-0.bin"]')?.closest('[data-transfer-status]');
assert.ok(lastRow?.classList.contains("border-b-0"), "only the actual final row omits its divider");
assert.equal(store.getSnapshot().tasks.length, 1_000, "offscreen files must remain queued");
await dispatchDomEvent(toggle, new env.window.MouseEvent("click", { bubbles: true }));
await flushEffects();
});
+49 -15
View File
@@ -1,3 +1,4 @@
import { getHostOsSelection, HOST_OS_SELECTIONS, resolveHostOs } from '../domain/host';
import React from "react";
import { AlertTriangle, ChevronDown, ChevronUp, Forward, Globe, HeartPulse, KeyRound, Link2, Palette, Plus, Router, ShieldAlert, TerminalSquare, Timer, Wifi, X, Variable } from "lucide-react";
import { customThemeStore } from "../application/state/customThemeStore";
@@ -350,26 +351,59 @@ export const HostDetailsAdvancedSections: React.FC<HostDetailsAdvancedSectionsPr
</HostDetailsSection>
)}
{/* Network Device Mode — only for SSH hosts without Mosh / ET (serial already uses raw mode) */}
{(!form.protocol || form.protocol === 'ssh') && !form.moshEnabled && !form.etEnabled && (
{(!form.protocol || ['ssh', 'telnet', 'mosh', 'et'].includes(form.protocol)) && (
<HostDetailsSection
icon={<Router size={14} className="text-muted-foreground" />}
title={t("hostDetails.section.deviceType")}
>
<ToggleRow
label={t("hostDetails.deviceType")}
hint={t("hostDetails.deviceType.desc")}
enabled={effectiveDeviceType === 'network'}
onToggle={() => update("deviceType", effectiveDeviceType === 'network' ? 'general' : 'network')}
/>
{effectiveDeviceType === 'network' && (
<div className="flex items-start gap-2 p-2 rounded-md bg-yellow-500/10 border border-yellow-500/20">
<AlertTriangle size={14} className="text-yellow-500 mt-0.5 flex-shrink-0" />
<p className="text-xs text-yellow-600 dark:text-yellow-400 break-words">
{t("hostDetails.deviceType.warning")}
{(!form.protocol || form.protocol === 'ssh') && !form.moshEnabled && !form.etEnabled && (<>
<ToggleRow
label={t("hostDetails.deviceType")}
hint={t("hostDetails.deviceType.desc")}
enabled={effectiveDeviceType === 'network'}
onToggle={() => update("deviceType", effectiveDeviceType === 'network' ? 'general' : 'network')}
/>
{effectiveDeviceType === 'network' && (
<div className="flex items-start gap-2 p-2 rounded-md bg-yellow-500/10 border border-yellow-500/20">
<AlertTriangle size={14} className="text-yellow-500 mt-0.5 flex-shrink-0" />
<p className="text-xs text-yellow-600 dark:text-yellow-400 break-words">
{t("hostDetails.deviceType.warning")}
</p>
</div>
)}
</>)}
<Collapsible>
<CollapsibleTrigger asChild>
<Button
variant="ghost"
className="group w-full justify-between h-8 px-2 hover:bg-accent/50"
aria-label={t("hostDetails.os.title")}
>
<span className="text-xs font-medium text-muted-foreground">{t("hostDetails.os.title")}</span>
<span className="flex items-center gap-1.5 text-xs text-muted-foreground">
{t(`hostDetails.os.${getHostOsSelection(form)}`)}
<ChevronDown size={14} className="group-data-[state=open]:rotate-180" />
</span>
</Button>
</CollapsibleTrigger>
<CollapsibleContent className="mt-2 space-y-2">
<HostDetailsSettingRow label={t("hostDetails.os.selection")} hint={t("hostDetails.os.desc")}>
<Select value={getHostOsSelection(form)} onValueChange={(value) => update("osOverride", value)}>
<SelectTrigger className="h-8 w-36 text-xs" aria-label={t("hostDetails.os.selection")}>
<SelectValue />
</SelectTrigger>
<SelectContent>
{HOST_OS_SELECTIONS.map((value) => (
<SelectItem key={value} value={value}>{t(`hostDetails.os.${value}`)}</SelectItem>
))}
</SelectContent>
</Select>
</HostDetailsSettingRow>
<p className="text-xs text-muted-foreground">
{t("hostDetails.os.current")}: {t(`hostDetails.os.${resolveHostOs({ ...form, deviceType: effectiveDeviceType })}`)}
</p>
</div>
)}
</CollapsibleContent>
</Collapsible>
</HostDetailsSection>
)}
@@ -11,7 +11,6 @@ import {
applyEffectiveHostAuthMethodSelection,
detachEffectiveHostIdentity,
HOST_AUTH_METHOD_CHOICES,
HOST_OS_CHOICES,
HostDetailsConnectionSections,
removeSelectedHostCredential,
shouldForceAuthMethodReselect,
@@ -112,26 +111,9 @@ test("color and icon settings render for non-Linux hosts", () => {
assert.match(markup, /hostDetails\.icon\.manualLabel/);
});
test("host operating system can be selected in the ordinary host editor", () => {
test("basic connection settings do not require operating system selection", () => {
const markup = renderConnectionSections({ os: "windows" });
assert.deepEqual(
HOST_OS_CHOICES.map(([value, labelKey]) => [value, labelKey]),
[
["linux", "hostDetails.os.linux"],
["windows", "hostDetails.os.windows"],
["macos", "hostDetails.os.macos"],
],
);
assert.match(markup, /hostDetails\.os\.title/);
assert.match(markup, /role="combobox"[^>]*aria-label="hostDetails\.os\.title"/);
assert.match(markup, />hostDetails\.os\.windows<\/span>/);
const source = fs.readFileSync(
path.join(path.dirname(fileURLToPath(import.meta.url)), "HostDetailsConnectionSections.tsx"),
"utf8",
);
assert.match(source, /onValueChange=\{\(value\) => update\("os", value as Host\["os"\]\)\}/);
assert.doesNotMatch(markup, /hostDetails\.os\./);
});
test("host credentials expose automatic and password-only choices", () => {
@@ -23,12 +23,6 @@ export const HOST_AUTH_METHOD_CHOICES = [
["certificate", "hostDetails.auth.certificate"],
] as const;
export const HOST_OS_CHOICES = [
["linux", "hostDetails.os.linux"],
["windows", "hostDetails.os.windows"],
["macos", "hostDetails.os.macos"],
] as const;
export function shouldForceAuthMethodReselect(
nextMethod: "auto" | "password" | "key" | "certificate",
currentMethod: "auto" | "password" | "key" | "certificate",
@@ -131,12 +125,6 @@ export const HostDetailsConnectionSections: React.FC<HostDetailsConnectionSectio
const selectedAuthMethodLabel = t(
HOST_AUTH_METHOD_CHOICES.find(([value]) => value === resolvedAuthMethod)?.[1] ?? "hostDetails.auth.auto",
);
const resolvedHostOs = HOST_OS_CHOICES.some(([value]) => value === form.os)
? form.os as Host["os"]
: "linux";
const selectedHostOsLabel = t(
HOST_OS_CHOICES.find(([value]) => value === resolvedHostOs)?.[1] ?? "hostDetails.os.linux",
);
const effectiveEtEnabled = form.etEnabled ?? groupDefaults?.etEnabled;
const effectiveProtocol = form.protocol ?? groupDefaults?.protocol;
@@ -152,31 +140,6 @@ export const HostDetailsConnectionSections: React.FC<HostDetailsConnectionSectio
onChange={(e) => update("hostname", e.target.value)}
className="h-10 w-full"
/>
<HostDetailsSettingRow
label={t("hostDetails.os.title")}
hint={t("hostDetails.os.desc")}
>
<Select
value={resolvedHostOs}
onValueChange={(value) => update("os", value as Host["os"])}
>
<SelectTrigger
className="h-8 w-32 gap-2"
aria-label={t("hostDetails.os.title")}
>
<span className="min-w-0 flex-1 truncate text-left">
{selectedHostOsLabel}
</span>
</SelectTrigger>
<SelectContent>
{HOST_OS_CHOICES.map(([value, labelKey]) => (
<SelectItem key={value} value={value}>
{t(labelKey)}
</SelectItem>
))}
</SelectContent>
</Select>
</HostDetailsSettingRow>
</HostDetailsSection>
<HostDetailsSection
+6 -1
View File
@@ -30,6 +30,7 @@ import {
isPortForwardingRuleStartable,
isPortForwardingRuleStoppable,
} from "../domain/portForwardingBulkActions";
import { isPortForwardingAutoReconnectEnabled } from "../domain/portForwardingReconnect";
import { materializeHostProxyProfile } from "../domain/proxyProfiles";
import { cn } from "../lib/utils";
import SelectHostPanel from "./SelectHostPanel";
@@ -207,7 +208,7 @@ const PortForwarding: React.FC<PortForwardingProps> = ({
);
}
},
rule.autoStart, // Enable reconnect for auto-start rules
isPortForwardingAutoReconnectEnabled(rule), // Enable reconnect for auto-reconnect rules (auto-start implies reconnect)
terminalSettings,
knownHosts,
);
@@ -516,6 +517,8 @@ const PortForwarding: React.FC<PortForwardingProps> = ({
remoteHost: newFormDraft.remoteHost,
remotePort: newFormDraft.remotePort,
hostId: newFormDraft.hostId,
autoStart: newFormDraft.autoStart ?? false,
autoReconnect: isPortForwardingAutoReconnectEnabled(newFormDraft),
});
setShowNewForm(false);
@@ -714,6 +717,8 @@ const PortForwarding: React.FC<PortForwardingProps> = ({
remoteHost: draftRule.remoteHost,
remotePort: draftRule.remotePort,
hostId: draftRule.hostId,
autoStart: draftRule.autoStart ?? false,
autoReconnect: isPortForwardingAutoReconnectEnabled(draftRule),
});
}
+20
View File
@@ -68,6 +68,7 @@ export const SerialConnectModal: React.FC<SerialConnectModalProps> = ({
const [localEcho, setLocalEcho] = useState(false);
const [lineMode, setLineMode] = useState(false);
const [backspaceBehavior, setBackspaceBehavior] = useState<SerialConfig['backspaceBehavior']>('default');
const [byteOrientedBackspace, setByteOrientedBackspace] = useState(false);
const [charset, setCharset] = useState('UTF-8');
// Save configuration state
@@ -119,6 +120,7 @@ export const SerialConnectModal: React.FC<SerialConnectModalProps> = ({
localEcho,
lineMode,
backspaceBehavior,
byteOrientedBackspace,
};
// Save as host if checkbox is checked and onSaveHost is provided
@@ -370,6 +372,24 @@ export const SerialConnectModal: React.FC<SerialConnectModalProps> = ({
</p>
</div>
<div className="flex items-center justify-between">
<div className="space-y-0.5">
<Label htmlFor="byte-oriented-backspace" className="text-sm font-medium cursor-pointer">
{t('serial.field.byteOrientedBackspace')}
</Label>
<p className="text-xs text-muted-foreground">
{t('serial.field.byteOrientedBackspaceDesc')}
</p>
</div>
<input
type="checkbox"
id="byte-oriented-backspace"
checked={byteOrientedBackspace}
onChange={(e) => setByteOrientedBackspace(e.target.checked)}
className="h-4 w-4 rounded border-input"
/>
</div>
<div className="flex items-center justify-between">
<div className="space-y-0.5">
<Label htmlFor="local-echo" className="text-sm font-medium cursor-pointer">
+22
View File
@@ -90,6 +90,9 @@ export const SerialHostDetailsPanel: React.FC<SerialHostDetailsPanelPropsWithRes
resolveSerialBackspaceFormValue(initialData, groupDefaults),
);
const [backspaceBehaviorChanged, setBackspaceBehaviorChanged] = useState(false);
const [byteOrientedBackspace, setByteOrientedBackspace] = useState(
initialData.serialConfig?.byteOrientedBackspace ?? false,
);
const [charset, setCharset] = useState(initialData.charset || 'UTF-8');
const [tags, setTags] = useState<string[]>(initialData.tags || []);
const [group, setGroup] = useState(initialData.group || '');
@@ -128,6 +131,7 @@ export const SerialHostDetailsPanel: React.FC<SerialHostDetailsPanelPropsWithRes
selectedBehavior: backspaceBehavior,
behaviorChanged: backspaceBehaviorChanged,
}),
byteOrientedBackspace,
};
const portName = selectedPort.split('/').pop() || selectedPort;
@@ -436,6 +440,24 @@ export const SerialHostDetailsPanel: React.FC<SerialHostDetailsPanelPropsWithRes
</p>
</div>
<div className="flex items-center justify-between">
<div className="space-y-0.5">
<Label htmlFor="byte-oriented-backspace" className="text-sm font-medium cursor-pointer">
{t('serial.field.byteOrientedBackspace')}
</Label>
<p className="text-xs text-muted-foreground">
{t('serial.field.byteOrientedBackspaceDesc')}
</p>
</div>
<input
type="checkbox"
id="byte-oriented-backspace"
checked={byteOrientedBackspace}
onChange={(e) => setByteOrientedBackspace(e.target.checked)}
className="h-4 w-4 rounded border-input"
/>
</div>
<div className="flex items-center justify-between">
<div className="space-y-0.5">
<Label htmlFor="local-echo" className="text-sm font-medium cursor-pointer">
+31
View File
@@ -31,6 +31,7 @@ import {
import { useSftpFollowTerminalCwd } from "../application/state/sftp/useSftpFollowTerminalCwd";
import { usePendingSftpUploadRebind } from "../application/state/sftp/usePendingSftpUploadRebind";
import { registerEditorSftpWriterScoped } from "../application/state/editorSftpBridge";
import { registerEditorSftpOwnerResolver } from "../application/state/editorSftpOwnerRegistry";
import {
editorTabStore,
useEditorTabPresenceRevision,
@@ -147,6 +148,9 @@ interface SftpSidePanelProps {
* focused). Keeps browse SFTP sessions warm across History/System switches.
*/
ownerPanelOpen?: boolean;
/** Top-level tab id hosting this panel (terminal/workspace tab), used to
* resolve which tab owns an editor's SFTP connection. */
ownerTabId?: string | null;
renderOverlays?: boolean;
pendingUpload?: {
requestId: string;
@@ -201,6 +205,7 @@ const SftpSidePanelInner: React.FC<SftpSidePanelProps> = ({
showWorkspaceHostHeader = false,
isVisible = true,
ownerPanelOpen = false,
ownerTabId = null,
renderOverlays = true,
pendingUpload = null,
onPendingUploadHandled,
@@ -478,6 +483,32 @@ const SftpSidePanelInner: React.FC<SftpSidePanelProps> = ({
);
}, []);
const ownerTabIdRef = useRef(ownerTabId);
ownerTabIdRef.current = ownerTabId;
// While mounted, report which SFTP connections this panel owns and which
// top-level tab hosts it, so batch-close logic can keep the owning tab open
// when an editor bound to one of those connections survives its close
// prompt. Intentionally no deps — go through sftpRef so connection churn
// doesn't register/unregister on every re-render.
useEffect(() => {
return registerEditorSftpOwnerResolver(() => {
const s = sftpRef.current;
if (!s) return { connectionIds: [], ownerTabId: ownerTabIdRef.current };
const connectionIds: string[] = [];
const paneTabIds: string[] = [];
for (const tab of [...(s.leftTabs?.tabs ?? []), ...(s.rightTabs?.tabs ?? [])]) {
const id = tab.connection?.id;
if (id) connectionIds.push(id);
// Pane tab ids are stable across browse reconnects (unlike connection
// ids), so owner resolution can match editors bound to a stale
// pre-reconnect connection id.
if (tab.id) paneTabIds.push(tab.id);
}
return { connectionIds, paneTabIds, ownerTabId: ownerTabIdRef.current };
});
}, []);
// When this side panel unmounts (its hosting terminal tab was closed) we
// force-close any editor tabs bound to connections this panel owned — the
// save channel is gone with the SFTP session and there's no way to recover
+196 -7
View File
@@ -1,4 +1,7 @@
import { createTerminalReflowReadingPosition } from "./terminal/terminalReflowReadingPosition";
import { resolveHostOs } from '../domain/host';
import { Terminal as XTerm } from "@xterm/xterm";
import type { IMarker } from "@xterm/xterm";
import { FitAddon } from "@xterm/addon-fit";
import { SerializeAddon } from "@xterm/addon-serialize";
import { SearchAddon } from "@xterm/addon-search";
@@ -231,6 +234,8 @@ import {
resolveTerminalHibernateReplayChunkBytes,
type TerminalHibernateWakePayload,
} from "../domain/terminalHibernate";
import { getLastChar, removeLastChar } from "../domain/serialCharMetrics";
import { stringCellWidth } from "./terminal/autocomplete/terminalStringCellWidth";
import { terminalHiddenRendererStore } from "../application/state/terminalHiddenRendererStore";
import {
wakeTerminalFromHibernate,
@@ -252,6 +257,9 @@ import {
shouldStartTerminalBackend,
} from "./terminal/restoredSessionGate";
import {
alignTerminalViewportScroll,
createSynchronizedOutputFitScheduler,
resolveTerminalReflowScrollAnchor,
AUTO_RUN_SNIPPET_LINE_DELAY_MS,
forceSyncRenderAfterResize,
MAX_CONNECTION_LOG_DATA_CHARS,
@@ -564,6 +572,11 @@ const TerminalComponent: React.FC<TerminalProps> = ({
const reconnectWakeInvalidateModeRef = useRef<"dispose" | "keep">("dispose");
const reconnectWakeTokenRef = useRef<symbol | null>(null);
const manualReconnectRequestRef = useRef<() => void>(() => {});
// Once a pane reconnects, every later SSH attempt in it must dial a fresh
// connection: reusing a live/idle pooled transport multiplexes onto the old
// login, so remote supplementary-group changes (e.g. `usermod -aG`) are not
// picked up until the app fully quits (#3293). See createTerminalSessionStarters.
const requireFreshConnectionOnReconnectRef = useRef(false);
const terminalDataCapturedRef = useRef(false);
const connectionLogBufferRef = useRef(createConnectionLogBuffer(MAX_CONNECTION_LOG_DATA_CHARS));
const terminalOutputHistoryRef = useRef(createTerminalOutputHistoryPreview());
@@ -634,10 +647,22 @@ const TerminalComponent: React.FC<TerminalProps> = ({
const isRendererActiveRef = useRef(isRendererActive);
isRendererActiveRef.current = isRendererActive;
const pendingOutputScrollRef = useRef(false);
const reflowReadingPositionRef = useRef(createTerminalReflowReadingPosition());
const lastFittedSizeRef = useRef<{ width: number; height: number } | null>(null);
const fontWeightFixupDoneRef = useRef(false);
const captureTerminalLogData = useCallback((data: string) => {
// Keep the history tracker's viewport size current so cursor-row and
// cursor-column moves are clamped exactly like the terminal clamps them.
const captureTerm = termRef.current;
if (captureTerm) {
terminalOutputHistoryRef.current.setViewportRows(captureTerm.rows);
terminalOutputHistoryRef.current.setViewportCols(captureTerm.cols);
// The tracker's wrap decisions must measure cell widths the way this
// terminal's Unicode provider does, or the preview diverges from what
// xterm actually rendered (e.g. `15-graphemes` emoji widths).
terminalOutputHistoryRef.current.setWidthTerminal(captureTerm);
}
const readableCommandData = commandLogRewriterRef.current.append(data);
// The alternate-screen preview reads the raw display stream (before the
// replay sanitizer, which drops alternate-screen output on purpose) but
@@ -1000,8 +1025,10 @@ const TerminalComponent: React.FC<TerminalProps> = ({
serialLineBufferRef.current = "";
} else if (ch === "\b" || ch === "\x7f") {
if (serialLineBufferRef.current.length > 0) {
serialLineBufferRef.current = serialLineBufferRef.current.slice(0, -1);
if (serialConfig?.localEcho) writeLocalTerminalData("\b \b");
const lastChar = getLastChar(serialLineBufferRef.current);
const cells = stringCellWidth(lastChar, termRef.current);
serialLineBufferRef.current = removeLastChar(serialLineBufferRef.current);
if (serialConfig?.localEcho) writeLocalTerminalData("\b \b".repeat(cells));
}
} else if (ch.charCodeAt(0) >= 32) {
serialLineBufferRef.current += ch;
@@ -1081,12 +1108,15 @@ const TerminalComponent: React.FC<TerminalProps> = ({
// hosts (TerminalLayer) and saved-host defaults (HostDetailsPanel) both
// stamp os: "linux", which mis-routes the autocomplete clear sequence to
// Ctrl-U on Windows where cmd/PowerShell render it literally (#1112).
const autocompleteHostOs: "linux" | "windows" | "macos" = host.protocol === "local"
const resolvedAutocompleteOs = host.protocol === "local"
? detectLocalOs(navigator.userAgent || navigator.platform)
: (host.os || "linux");
: resolveHostOs(host);
const autocompleteHostOs = resolvedAutocompleteOs === "windows" || resolvedAutocompleteOs === "macos"
? resolvedAutocompleteOs : "linux";
const autocompleteSettings = resolveTerminalAutocompleteSettings({
protocol: effectiveTerminalProtocol,
terminalSettings,
systemUnknown: resolvedAutocompleteOs === 'unknown',
isNetworkDevice: host.deviceType === 'network'
|| classifyDistroId(host.distro) === 'network-device',
});
@@ -2274,6 +2304,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
clearHibernateRetry();
clearAutoReconnect();
void cleanupSession();
synchronizedFitSchedulerRef.current?.dispose();
disposeRuntimeOnly();
};
@@ -2375,6 +2406,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
sessionId,
reuseConnectionFromSessionIdRef: reuseConnectionSourceRef,
requireFreshConnection,
requireFreshConnectionOnReconnectRef,
reuseConnectionSourceAttemptedRef,
setConnectionReuseAttemptSourceId,
shouldUseFreshSshConnection: () => {
@@ -2400,6 +2432,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
isNetworkDevice,
startupCommand,
noAutoRun,
recordSerialSnippetInput: (data) => xtermRuntimeRef.current?.recordSerialSnippetInput(data),
multiLineRunMode,
shellType,
suppressHostStartupCommandRef,
@@ -2867,6 +2900,12 @@ const TerminalComponent: React.FC<TerminalProps> = ({
});
}, [reuseConnectionFromSessionId, sessionId, terminalBackend]);
const synchronizedFitSchedulerRef = useRef<ReturnType<typeof createSynchronizedOutputFitScheduler> | null>(null);
if (!synchronizedFitSchedulerRef.current) {
synchronizedFitSchedulerRef.current = createSynchronizedOutputFitScheduler();
}
useEffect(() => () => synchronizedFitSchedulerRef.current?.dispose(), []);
type SafeFitOptions = { force?: boolean; requireVisible?: boolean; immediate?: boolean; allowHidden?: boolean };
const pendingWriteSafeFitRef = useRef<{
term: XTerm;
@@ -2964,6 +3003,14 @@ const TerminalComponent: React.FC<TerminalProps> = ({
return;
}
// Keep the frozen buffer intact until synchronized output finishes.
// Re-enter safeFit to use the latest size, buffer and reading position.
if (synchronizedFitSchedulerRef.current?.defer(term, () => {
if (termRef.current === term) {
safeFitRef.current({ ...options, force: true, immediate: true });
}
})) return;
const buffer = term.buffer.active;
const wasPinnedToBottom = buffer.viewportY >= buffer.baseY;
const savedViewportY = buffer.viewportY;
@@ -2971,6 +3018,48 @@ const TerminalComponent: React.FC<TerminalProps> = ({
const dimensions = fitAddon.proposeDimensions();
if (!dimensions || Number.isNaN(dimensions.cols) || Number.isNaN(dimensions.rows)) return;
// A column change rewraps the scrollback, moving rows above the
// reading position, so a saved row index no longer points at the same
// content after the resize. Capture the content instead of an index.
// Row-only and pixel-only fits never rewrap, so skip the O(scrollback)
// anchor scan on those frames.
const previousCols = term.cols;
const reflowAnchor = wasPinnedToBottom || term.cols === dimensions.cols
? null
: reflowReadingPositionRef.current.capture(buffer, {
// xterm keeps at most rows + scrollback buffer rows and trims
// from the top beyond that (Buffer._getCorrectBufferLength), so
// these bounds let the capture skip its whole-line measurement
// when no trim can reach the anchored line.
maxRows: dimensions.rows + (term.options.scrollback ?? 1000),
oldCols: previousCols,
newCols: dimensions.cols,
});
// Markers pinned to the viewed row and to the viewed logical line's
// start. xterm adjusts marker rows through the rewrap (and disposes
// them when their row is deleted or trimmed), so after the resize a
// surviving marker marks where the viewed content moved without
// scanning for it. Each rewrap direction deletes a different row:
// a column grow merges a wrapped continuation into its predecessor
// (disposing a marker on the viewed continuation row), while a column
// shrink on a full scrollback trims the line's first physical rows
// (disposing a marker on the start row; the viewed content survives).
// Pinning one marker to each of those rows keeps the resolver seeded
// in both directions.
let reflowMarker: IMarker | null = null;
let reflowStartMarker: IMarker | null = null;
if (reflowAnchor) {
reflowMarker = term.registerMarker(
savedViewportY - (buffer.baseY + buffer.cursorY),
);
if (reflowAnchor.startRow !== savedViewportY) {
reflowStartMarker = term.registerMarker(
reflowAnchor.startRow - (buffer.baseY + buffer.cursorY),
);
}
}
lastFittedSizeRef.current = { width, height };
// addon-fit 0.11 clears the renderer before resizing, which can show
// as a one-frame WebGL blink during layout changes. Resize directly
@@ -2988,15 +3077,99 @@ const TerminalComponent: React.FC<TerminalProps> = ({
forceSyncRenderAfterResize(term);
}
// Reflow changes the buffer row before xterm updates its scroll range.
// Align first so the relative restore below does not apply that delta twice.
alignTerminalViewportScroll(term);
let anchoredViewportY: number | null = null;
// Preserve scroll position across resize (superset/Tabby pattern).
if (wasPinnedToBottom) {
term.scrollToBottom();
} else {
const targetY = Math.min(savedViewportY, term.buffer.active.baseY);
// Re-locate the anchored content; fall back to the saved row index
// when the anchored content is gone (scrollback trim). Prefer the
// viewport-row marker (it marks the viewed row itself) over the
// line-start marker, which only seeds the scan.
const viewedMarkerRow = reflowMarker && !reflowMarker.isDisposed && reflowMarker.line >= 0
? reflowMarker.line
: null;
const startMarkerRow = reflowStartMarker && !reflowStartMarker.isDisposed
&& reflowStartMarker.line >= 0
? reflowStartMarker.line
: null;
const markerRow = viewedMarkerRow ?? startMarkerRow;
reflowMarker?.dispose();
reflowMarker = null;
reflowStartMarker?.dispose();
reflowStartMarker = null;
// A continuation anchor registers both markers, so both coming back
// disposed means the trim deleted the rows they pin — but marker
// disposal alone is not proof the anchored content is gone: a
// narrowing rewrap relocates the viewed characters into newly
// appended continuation rows before the trim cuts the same number
// of rows from the top, so the characters can survive at the buffer
// top while both pinned rows are gone. `trimReachedStart` switches
// the resolver to that remnant check (a trim removes from the top,
// so a line whose start row it reached leaves any surviving part at
// row 0) instead of letting it sweep the stale row for repetitive
// duplicates on every divider-drag frame. A single disposed
// viewport marker keeps the seeded resolve: a column-grow merge
// disposes it while the merged line still matches the anchor.
const trimReachedStart = reflowAnchor !== null
&& reflowAnchor.startRow !== savedViewportY
&& viewedMarkerRow === null
&& startMarkerRow === null;
anchoredViewportY = reflowAnchor === null
? null
: resolveTerminalReflowScrollAnchor(
term.buffer.active,
reflowAnchor,
markerRow,
trimReachedStart,
);
// Content matching can still fail when the viewed line is the
// cursor's own logical line: with the pinned `reflowCursorLine:
// false` default a narrowing resize skips rewrapping that line and
// truncates its rows, so the captured prefix no longer matches even
// though the viewport-row marker survived and tracks the exact
// viewed row. Restore the marker position there rather than the
// stale saved index, which would jump upward by the accumulated
// reflow delta of the rewrapped lines above. (The line-start marker
// must not stand in for a failed match: its row only seeds the
// scan, and the column-grow merge disposes the viewport marker
// while the merged line still matches the anchor.)
// Only those two cases may trust the viewport-row marker. When the
// viewport sits partway into a wrapped non-cursor line, a column
// shrink rewraps that line and xterm appends the group's newly
// created rows after the existing ones, so the marker keeps its old
// within-line row while the viewed characters move deeper — even
// though xterm still adjusts the marker through unrelated
// insertions and would have disposed it only if its row were
// trimmed. That mismatch is unobservable through marker disposal,
// so trust the marker as a restore position only when the anchored
// line is the cursor's own (row indices survive truncation) or the
// marker sits at the anchored line's start (the group's new rows
// are appended below it, and insertions above shift it onto the
// relocated start). Anything else falls back to the plain row
// restore.
const markerTrackedViewport = reflowAnchor !== null
&& (reflowAnchor.startRow === savedViewportY
|| reflowAnchor.containsCursor === true)
? viewedMarkerRow
: null;
const targetY = Math.min(
anchoredViewportY ?? markerTrackedViewport ?? savedViewportY,
term.buffer.active.baseY,
);
if (term.buffer.active.viewportY !== targetY) {
term.scrollToLine(targetY);
}
}
if (term.cols !== previousCols) {
reflowReadingPositionRef.current.remember(
term.buffer.active, anchoredViewportY === null ? null : reflowAnchor,
);
}
term.refresh(0, Math.max(0, term.rows - 1));
if (typeof requestAnimationFrame === "function") {
@@ -3153,12 +3326,16 @@ const TerminalComponent: React.FC<TerminalProps> = ({
sensitive,
...(lineDelayMs ? { lineDelayMs } : {}),
});
// Snippets left for editing share the serial typed-input buffer.
if (host.protocol === 'serial' && noAutoRun && !serialConfig?.lineMode) {
xtermRuntimeRef.current?.recordSerialSnippetInput(data);
}
scrollToBottomAfterProgrammaticInput(data);
if (options?.focus !== false) {
term.focus();
}
return true;
}, [prepareProgrammaticSudoInput, scrollToBottomAfterProgrammaticInput, terminalBackend, sessionId]);
}, [prepareProgrammaticSudoInput, scrollToBottomAfterProgrammaticInput, terminalBackend, sessionId, host.protocol, serialConfig?.lineMode]);
const executeSnippet = useCallback(async (snippet: Snippet) => {
if (isScriptSnippet(snippet)) {
@@ -3236,6 +3413,13 @@ const TerminalComponent: React.FC<TerminalProps> = ({
}, [onAddSelectionToAI, sessionId, terminalSettings?.normalizeTextOnCopy]);
const handleSetTerminalEncoding = useCallback((encoding: TerminalEncodingPreference) => {
// A byte-oriented device still holds bytes in the previous encoding.
// Require an empty input line before changing that encoding.
if (host.protocol === 'serial' && serialConfig?.byteOrientedBackspace === true
&& !serialConfig?.lineMode && commandBufferRef.current) {
toast.info(t('serial.encoding.pendingInput'));
return;
}
setTerminalEncoding(encoding);
setRememberedTerminalEncoding(encoding);
userPickedEncodingRef.current = true;
@@ -3248,7 +3432,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
if (sessionRef.current) {
setSessionEncoding(sessionRef.current, encoding);
}
}, [handleUpdateHostFromTerminal, host.id, host.protocol, setRememberedTerminalEncoding, setSessionEncoding]);
}, [handleUpdateHostFromTerminal, host.id, host.protocol, serialConfig?.byteOrientedBackspace, serialConfig?.lineMode, setRememberedTerminalEncoding, setSessionEncoding, t]);
const handleOpenSFTP = useCallback(async () => {
if (onOpenSftp) {
@@ -3627,6 +3811,10 @@ const TerminalComponent: React.FC<TerminalProps> = ({
const retryStillActive = () => retryTokenStillCurrent() && termRef.current === term;
bootEpochRef.current += 1;
// This start is a reconnect: force the bridge to dial a fresh connection
// instead of borrowing a parked/live transport, so the server performs a
// new login and picks up remote supplementary-group changes (#3293).
requireFreshConnectionOnReconnectRef.current = true;
publishBootEpoch();
isBootActiveRef.current = true;
auth.resetForRetry();
@@ -4040,6 +4228,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
requestSearchFocus,
serialLocalEcho: serialConfig?.localEcho,
serialLineMode: serialConfig?.lineMode,
serialByteOrientedBackspace: serialConfig?.byteOrientedBackspace ?? false,
serialLineBufferRef,
telnetLocalEchoRef,
onTerminalLogData: captureTerminalLogData,
+21
View File
@@ -634,3 +634,24 @@ test("App shell retains presentation-stable sessions for domain memos", () => {
assert.match(appSource, /sessions: sessionsForShell/);
assert.match(appSource, /orphanSessions: orphanSessionsForShell/);
});
test("bulk close context menu offers symmetric close-to-left option", () => {
const bulkCloseBlock = topTabsSource.slice(
topTabsSource.indexOf("Bulk-close menu items shared by session and workspace context menus"),
topTabsSource.indexOf("// Render the tabs"),
);
// Left of the anchor is symmetric with close-to-right: slice(0, anchorIdx).
assert.match(bulkCloseBlock, /orderedTabs\.slice\(0, anchorIdx\)/);
assert.match(bulkCloseBlock, /tabs\.closeToLeft/);
assert.match(bulkCloseBlock, /onCloseTabsBatch\(leftIds\)/);
// Locale parity: the new key is translated in every terminal locale.
const locales = ["en", "es", "ru", "zh-CN", "zh-TW"] as const;
for (const locale of locales) {
const source = readFileSync(
new URL(`../application/i18n/locales/${locale}/terminal.ts`, import.meta.url),
"utf8",
);
assert.match(source, /'tabs\.closeToLeft':/);
}
});
+7
View File
@@ -769,6 +769,7 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
const anchorIdx = orderedTabs.indexOf(anchorId);
const othersIds = orderedTabs.filter((id) => id !== anchorId);
const rightIds = anchorIdx >= 0 ? orderedTabs.slice(anchorIdx + 1) : [];
const leftIds = anchorIdx > 0 ? orderedTabs.slice(0, anchorIdx) : [];
return (
<>
<ContextMenuSeparator />
@@ -778,6 +779,12 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
>
{t('tabs.closeOthers')}
</ContextMenuItem>
<ContextMenuItem
disabled={leftIds.length === 0}
onClick={() => onCloseTabsBatch(leftIds)}
>
{t('tabs.closeToLeft')}
</ContextMenuItem>
<ContextMenuItem
disabled={rightIds.length === 0}
onClick={() => onCloseTabsBatch(rightIds)}
+8 -25
View File
@@ -10,10 +10,8 @@ import { useI18n } from "../application/i18n/I18nProvider";
import { I18nProvider } from "../application/i18n/I18nProvider";
import { useTrayPanelBackend } from "../application/state/useTrayPanelBackend";
import { useActiveTabId } from "../application/state/activeTabStore";
import { resolveGroupDefaults, applyGroupDefaults } from "../domain/groupConfig";
import { materializeHostProxyProfile } from "../domain/proxyProfiles";
import { upsertKnownHost } from "../domain/knownHosts";
import type { Host, KnownHost } from "../domain/models";
import type { KnownHost } from "../domain/models";
import { getEffectiveKnownHosts } from "../infrastructure/syncHelpers";
import { PortForwardHostKeyTrayPrompt } from "./port-forwarding";
import { X, Maximize2, ChevronRight, ChevronDown, Power } from "lucide-react";
@@ -136,11 +134,7 @@ const WorkspaceGroup: React.FC<{
);
};
interface TrayPanelContentProps {
terminalSettings?: { verifyHostKeys: boolean; keepaliveInterval: number; keepaliveCountMax: number };
}
const TrayPanelContent: React.FC<TrayPanelContentProps> = ({ terminalSettings }) => {
const TrayPanelContent: React.FC = () => {
const { t } = useI18n();
const {
hideTrayPanel,
@@ -148,27 +142,23 @@ const TrayPanelContent: React.FC<TrayPanelContentProps> = ({ terminalSettings })
quitApp,
jumpToSession,
closeSessionFromTrayPanel,
startPortForwardFromTrayPanel,
onTrayPanelCloseRequest,
onTrayPanelRefresh,
onTrayPanelMenuData,
} = useTrayPanelBackend();
const { hosts, keys, identities, proxyProfiles, groupConfigs, knownHosts, updateKnownHosts } = useVaultState();
const { hosts, knownHosts, updateKnownHosts } = useVaultState();
// TrayPanel runs in its own BrowserWindow, so this hook's session state is
// independent from (and typically empty compared to) the main App's — it's
// used here only for its storage-sync side effects, never for closeSession.
useSessionState({ persistSessionRestore: false });
const {
rules: portForwardingRules,
startTunnel,
stopTunnel,
hasRuntimeTunnel,
} = usePortForwardingState();
const activeTabId = useActiveTabId();
const proxyProfileIdSet = useMemo(
() => new Set(proxyProfiles.map((profile) => profile.id)),
[proxyProfiles],
);
const effectiveKnownHosts = useMemo(
() => getEffectiveKnownHosts(knownHosts) ?? [],
[knownHosts],
@@ -444,16 +434,9 @@ const TrayPanelContent: React.FC<TrayPanelContentProps> = ({ terminalSettings })
if (!result.success && result.error) toast.error(result.error);
});
} else {
const resolveEffectiveHost = (host: Host) => {
const withGroupDefaults = host.group
? applyGroupDefaults(host, resolveGroupDefaults(host.group, groupConfigs, { validProxyProfileIds: proxyProfileIdSet }), { validProxyProfileIds: proxyProfileIdSet })
: applyGroupDefaults(host, {}, { validProxyProfileIds: proxyProfileIdSet });
return materializeHostProxyProfile(withGroupDefaults, proxyProfiles);
};
const host = resolveEffectiveHost(rawHost);
void startTunnel(rule, host, hosts.map(resolveEffectiveHost), keys, identities, (status, error) => {
if (status === "error" && error) toast.error(error);
}, rule.autoStart, terminalSettings, effectiveKnownHosts);
void startPortForwardFromTrayPanel(rule.id).then((result) => {
if (result && !result.success && result.error) toast.error(result.error);
});
}
}}
className={cn(
@@ -519,7 +502,7 @@ type SettingsState = AppLockGateRenderContext["settings"];
const TrayPanel: React.FC<{ settings: SettingsState }> = ({ settings }) => {
return (
<I18nProvider locale={settings.uiLanguage}>
<TrayPanelContent terminalSettings={settings.terminalSettings} />
<TrayPanelContent />
</I18nProvider>
);
};
+18
View File
@@ -332,3 +332,21 @@ test('ChatInput wires /compact through getSystemSlashCommand and canCompact', ()
assert.match(source, /onCompact\?\.\(\)/);
assert.match(source, /command\.slug !== 'compact' \|\| canCompact/);
});
test('renders a Mention Note picker entry with a searchable note list', () => {
const source = readFileSync(new URL('./ChatInput.tsx', import.meta.url), 'utf8');
assert.match(source, /openInputPanelMenu\('noteMention'\)/);
assert.match(source, /ai\.chat\.menuMentionNote/);
assert.match(source, /aria-activedescendant=\{noteMentionItems\[activeMenuIndex\] \? `\$\{noteListId\}/);
assert.match(source, /createVaultNoteSearchIndex\(notes\)/);
assert.match(source, /onClick=\{\(\) => handleSelectNoteMention\(note\)\}/);
assert.match(source, /onKeyDown=\{handleNoteMentionKeyDown\}/);
});
test('renders vault note attachment chips with the note title', () => {
const source = readFileSync(new URL('./ChatInput.tsx', import.meta.url), 'utf8');
assert.match(source, /file\.vaultNoteId \? \(/);
assert.match(source, /\{file\.vaultNoteTitle \|\| file\.filename\}/);
});
+166 -4
View File
@@ -6,7 +6,7 @@
* and a bottom toolbar with muted controls + subtle send button.
*/
import { ArrowUp, AtSign, Check, ChevronDown, ChevronRight, Cpu, Eye, FileText, ImageIcon, Loader2, MessageSquare, Package, Plus, ShieldCheck, SquareTerminal, X, Zap } from 'lucide-react';
import { ArrowUp, AtSign, BookOpen, Check, ChevronDown, ChevronRight, Cpu, Eye, FileText, ImageIcon, Loader2, MessageSquare, Package, Plus, ShieldCheck, SquareTerminal, X, Zap } from 'lucide-react';
import {
resolveModelSelectionWithThinking,
resolveThinkingSelection,
@@ -54,6 +54,8 @@ import {
} from '../ai-elements/prompt-input';
import type { PromptInputStatus } from '../ai-elements/prompt-input';
import type { AgentModelPreset, AIPermissionMode, ProviderConfig, UploadedFile } from '../../infrastructure/ai/types';
import type { VaultNote } from '../../domain/models';
import { createVaultNoteSearchIndex } from '../../domain/notes';
import { ProviderIconBadge } from '../settings/tabs/ai/ProviderIconBadge';
import { VariableSizeVirtualList, type VariableSizeVirtualListHandle } from '../ui/VariableSizeVirtualList';
import { Tooltip, TooltipContent, TooltipTrigger } from '../ui/tooltip';
@@ -216,6 +218,10 @@ interface ChatInputProps {
onRemoveFile?: (id: string) => void;
/** Available hosts for @ mention */
hosts?: Array<{ sessionId: string; hostname: string; label: string; connected: boolean }>;
/** Available Vault → Notes entries for the Mention Note picker */
notes?: VaultNote[];
/** Callback when the user picks a note to attach as context */
onMentionNote?: (note: VaultNote) => void;
/** User skills currently selected for the next send */
selectedUserSkills?: Array<{ id: string; slug: string; name: string; description: string }>;
/** Available user skills for /skill-slug insertion */
@@ -271,6 +277,8 @@ const ChatInput: React.FC<ChatInputProps> = ({
onAddFiles,
onRemoveFile,
hosts = [],
notes = [],
onMentionNote,
selectedUserSkills = [],
userSkills = [],
quickMessages = [],
@@ -285,7 +293,7 @@ const ChatInput: React.FC<ChatInputProps> = ({
parked = false,
}) => {
const { t } = useI18n();
const hasTerminalSelectionAttachment = files.some((file) => file.terminalSelection);
const hasTerminalSelectionAttachment = files.some((file) => file.terminalSelection || file.vaultNoteId);
const composerDisabled = disabled || isSteering;
const composerTextRef = useRef(value);
const hasTextStoreRef = useRef<ComposerHasTextStore | null>(null);
@@ -298,13 +306,15 @@ const ChatInput: React.FC<ChatInputProps> = ({
const [composerMaxHeight, setComposerMaxHeight] = useState(CHAT_INPUT_MAX_HEIGHT);
const composerDesiredHeightRef = useRef<number | null>(null);
// Consolidate menu state into a single discriminated union to prevent multiple menus open simultaneously
type ActiveMenu = 'model' | 'thinking' | 'attach' | 'atMention' | 'slashCommand' | 'perm' | null;
type ActiveMenu = 'model' | 'thinking' | 'attach' | 'atMention' | 'noteMention' | 'slashCommand' | 'perm' | null;
const [activeMenu, setActiveMenu] = useState<ActiveMenu>(null);
const [menuPos, setMenuPos] = useState<{ left: number; bottom: number } | null>(null);
const [inputPanelPos, setInputPanelPos] = useState<{ left: number; bottom: number; width: number } | null>(null);
const [modelPrefs, setModelPrefs] = useState<ComposerModelPrefs>(() => readComposerModelPrefs(pickerScope));
const [slashQuery, setSlashQuery] = useState('');
const [slashRange, setSlashRange] = useState<{ start: number; end: number } | null>(null);
// Search query for the Mention Note picker
const [noteQuery, setNoteQuery] = useState('');
// Active highlight index for @ mention / slash skill keyboard navigation
const [activeMenuIndex, setActiveMenuIndex] = useState(0);
@@ -313,6 +323,7 @@ const ChatInput: React.FC<ChatInputProps> = ({
const showThinkingPicker = activeMenu === 'thinking';
const showAttachMenu = activeMenu === 'attach';
const showAtMention = activeMenu === 'atMention';
const showNoteMention = activeMenu === 'noteMention';
const showSlashCommandPicker = activeMenu === 'slashCommand';
const showPermPicker = activeMenu === 'perm';
@@ -322,6 +333,7 @@ const ChatInput: React.FC<ChatInputProps> = ({
setInputPanelPos(null);
setSlashQuery('');
setSlashRange(null);
setNoteQuery('');
}, []);
useEffect(() => {
@@ -582,7 +594,12 @@ const ChatInput: React.FC<ChatInputProps> = ({
closeAllMenus();
}, [readComposerText, commitComposerText, closeAllMenus]);
const openInputPanelMenu = useCallback((menu: 'atMention' | 'slashCommand') => {
const handleSelectNoteMention = useCallback((note: VaultNote) => {
onMentionNote?.(note);
closeAllMenus();
}, [onMentionNote, closeAllMenus]);
const openInputPanelMenu = useCallback((menu: 'atMention' | 'noteMention' | 'slashCommand') => {
const pos = getInputPanelMenuPos();
if (!pos) return;
setMenuPos(null);
@@ -746,6 +763,58 @@ const ChatInput: React.FC<ChatInputProps> = ({
if (showSlashCommandPicker) setActiveMenuIndex(0);
}, [showSlashCommandPicker, slashCommandKey]);
// Mention Note picker: filtered notes, highlight reset, search-input focus
const searchMentionNotes = useMemo(
() => showNoteMention ? createVaultNoteSearchIndex(notes) : null,
[notes, showNoteMention],
);
const noteMentionItems = useMemo(
() => searchMentionNotes?.(noteQuery) ?? [],
[searchMentionNotes, noteQuery],
);
const noteMentionKey = useMemo(
() => JSON.stringify(noteMentionItems.map((note) => note.id)),
[noteMentionItems],
);
const noteSearchInputRef = useRef<HTMLInputElement>(null);
const noteListId = React.useId();
useEffect(() => {
if (showNoteMention) setActiveMenuIndex(0);
}, [showNoteMention, noteMentionKey]);
useEffect(() => {
if (!showNoteMention || noteMentionItems.length === 0) return;
atMentionListRef.current?.scrollToIndex(activeMenuIndex);
}, [activeMenuIndex, noteMentionKey, noteMentionItems.length, showNoteMention]);
useEffect(() => {
if (!showNoteMention) return;
noteSearchInputRef.current?.focus();
}, [showNoteMention]);
const handleNoteMentionKeyDown = useCallback((e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.nativeEvent.isComposing) return;
if (e.key === 'Escape') {
e.preventDefault();
closeAllMenus();
return;
}
if (e.key === 'Enter') {
e.preventDefault();
const note = noteMentionItems[Math.min(activeMenuIndex, noteMentionItems.length - 1)];
if (note) handleSelectNoteMention(note);
return;
}
if (noteMentionItems.length === 0) return;
if (e.key === 'ArrowDown') {
e.preventDefault();
setActiveMenuIndex((i) => (i + 1) % noteMentionItems.length);
return;
}
if (e.key === 'ArrowUp') {
e.preventDefault();
setActiveMenuIndex((i) => (i - 1 + noteMentionItems.length) % noteMentionItems.length);
}
}, [activeMenuIndex, closeAllMenus, handleSelectNoteMention, noteMentionItems]);
useEffect(() => {
if (!showSlashCommandPicker || !menuPos || slashCommandItems.length === 0) return;
slashPickerListRef.current?.focus();
@@ -1041,6 +1110,8 @@ const ChatInput: React.FC<ChatInputProps> = ({
>
{file.terminalSelection ? (
<SquareTerminal size={12} className="text-muted-foreground/70 shrink-0" />
) : file.vaultNoteId ? (
<BookOpen size={11} className="text-muted-foreground/60 shrink-0" />
) : file.mediaType.startsWith('image/') ? (
<ImageIcon size={11} className="text-muted-foreground/60 shrink-0" />
) : (
@@ -1053,6 +1124,12 @@ const ChatInput: React.FC<ChatInputProps> = ({
{file.lineCount ? ` · ${t('ai.chat.terminalSelectionLines').replace('{count}', String(file.lineCount))}` : ''}
</span>
</span>
) : file.vaultNoteId ? (
<span className="min-w-0">
<span className="block truncate max-w-[210px] text-foreground/82">
{file.vaultNoteTitle || file.filename}
</span>
</span>
) : (
<span className="truncate max-w-[80px]">{file.filename}</span>
)}
@@ -1209,6 +1286,76 @@ const ChatInput: React.FC<ChatInputProps> = ({
document.body,
)}
{/* Mention Note popover */}
{showNoteMention && inputPanelPos && createPortal(
<>
<div className="fixed inset-0 z-[999]" onClick={closeAllMenus} />
<div
className="fixed z-[1000] overflow-hidden rounded-lg border border-border/50 bg-popover shadow-lg"
style={{ left: inputPanelPos.left, bottom: inputPanelPos.bottom, width: 'auto', minWidth: Math.min(240, inputPanelPos.width), maxWidth: inputPanelPos.width }}
>
<div className="p-1.5 border-b border-border/40">
<input
ref={noteSearchInputRef}
role="combobox"
aria-label={t('ai.chat.menuMentionNote')}
aria-expanded={true}
aria-controls={noteListId}
aria-autocomplete="list"
aria-activedescendant={noteMentionItems[activeMenuIndex] ? `${noteListId}-${activeMenuIndex}` : undefined}
type="text"
value={noteQuery}
onChange={(e) => setNoteQuery(e.target.value)}
onKeyDown={handleNoteMentionKeyDown}
placeholder={t('ai.chat.mentionNoteSearch')}
className="w-full h-6 rounded-md bg-muted/40 px-2 text-[12px] text-foreground outline-none placeholder:text-muted-foreground/50"
/>
</div>
<div id={noteListId} role="listbox" aria-label={t('ai.chat.menuMentionNote')}>
{noteMentionItems.length === 0 ? (
<div className="px-3 py-2 text-[11px] text-muted-foreground/60">{t('ai.chat.mentionNoteEmpty')}</div>
) : (
<div className="max-h-[280px]" style={{ height: Math.min(280, 8 + noteMentionItems.reduce((total, note) => total + (note.group ? 52 : 36), 0)) }}>
<VariableSizeVirtualList
ref={atMentionListRef}
items={noteMentionItems}
getItemHeight={(note) => note.group ? 52 : 36}
getItemKey={(note) => note.id}
className="h-full"
contentClassName="p-1"
renderItem={(note, idx) => {
const isActive = idx === activeMenuIndex;
return (
<button
id={`${noteListId}-${idx}`}
type="button"
role="option"
aria-selected={isActive}
onMouseEnter={() => setActiveMenuIndex(idx)}
onClick={() => handleSelectNoteMention(note)}
className={`h-full w-full rounded-md px-2 py-1 text-left transition-colors cursor-pointer ${isActive ? 'bg-muted/40' : 'hover:bg-muted/30'}`}
>
<div className="flex items-center gap-2 text-[12px] text-foreground/90">
<BookOpen size={11} className="text-muted-foreground/50 shrink-0" />
<span className="truncate">{note.title || t('ai.chat.untitledNote')}</span>
</div>
{note.group ? (
<div className="pl-5 text-[10px] text-muted-foreground/60 truncate">
{note.group}
</div>
) : null}
</button>
);
}}
/>
</div>
)}
</div>
</div>
</>,
document.body,
)}
{/* / command popover */}
{showSlashPickerUI && createPortal(
<>
@@ -1311,6 +1458,19 @@ const ChatInput: React.FC<ChatInputProps> = ({
<ImageIcon size={13} className="text-muted-foreground/60" />
<span className="text-foreground/85">{t('ai.chat.menuImage')}</span>
</button>
<button
type="button"
role="menuitem"
aria-label={t('ai.chat.menuMentionNote')}
disabled={!onMentionNote}
title={!onMentionNote ? t('ai.chat.mentionNoteUnavailable') : undefined}
onClick={() => openInputPanelMenu('noteMention')}
className="w-full flex items-center gap-2.5 px-3 py-1.5 text-left text-[12px] hover:bg-muted/30 transition-colors cursor-pointer whitespace-nowrap disabled:opacity-40 disabled:cursor-not-allowed"
>
<BookOpen size={13} className="text-muted-foreground/60" />
<span className="flex-1 text-foreground/85">{t('ai.chat.menuMentionNote')}</span>
{notes.length > 0 && <ChevronRight size={10} className="text-muted-foreground/50" />}
</button>
<button
type="button"
role="menuitem"
@@ -1640,6 +1800,8 @@ function chatInputPropsAreEqual(prev: ChatInputProps, next: ChatInputProps): boo
&& prev.onAddFiles === next.onAddFiles
&& prev.onRemoveFile === next.onRemoveFile
&& prev.hosts === next.hosts
&& prev.notes === next.notes
&& prev.onMentionNote === next.onMentionNote
&& prev.selectedUserSkills === next.selectedUserSkills
&& prev.userSkills === next.userSkills
&& prev.quickMessages === next.quickMessages
+9 -1
View File
@@ -6,7 +6,7 @@
* No avatars. Thinking blocks are collapsible.
*/
import { AlertCircle, FileText, RotateCcw, SquareTerminal, X, ZoomIn, ZoomOut } from 'lucide-react';
import { AlertCircle, BookOpen, FileText, RotateCcw, SquareTerminal, X, ZoomIn, ZoomOut } from 'lucide-react';
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useI18n } from '../../application/i18n/I18nProvider';
import type { ChatMessage, ToolCall as AgentToolCall } from '../../infrastructure/ai/types';
@@ -701,6 +701,14 @@ const ChatMessageList: React.FC<ChatMessageListProps> = ({
<SquareTerminal size={12} className="text-muted-foreground/60 shrink-0" />
<span className="truncate max-w-[150px]">{att.filename || 'terminal selection'}</span>
</div>
) : att.vaultNoteId ? (
<div
key={att.filename ? `${att.filename}-${i}` : `att-${message.id}-${i}`}
className="inline-flex items-center gap-1.5 h-7 px-2 rounded-md bg-muted/20 border border-border/20 text-[11px] text-foreground/70"
>
<BookOpen size={12} className="text-muted-foreground/60 shrink-0" />
<span className="truncate max-w-[150px]">{att.vaultNoteTitle || att.filename || 'note'}</span>
</div>
) : att.mediaType.startsWith('image/') ? (
<img
key={att.filename ? `${att.filename}-${i}` : `att-${message.id}-${i}`}
+66
View File
@@ -1,5 +1,6 @@
import assert from "node:assert/strict";
import test from "node:test";
import { buildExternalBridgeContextMessages } from "../../infrastructure/ai/harness/externalBridgeContext.ts";
import type { ChatMessageAttachment, ToolCall, ToolResult } from "../../infrastructure/ai/types.ts";
import {
@@ -210,3 +211,68 @@ test("buildHistoricalToolReplayMaps pairs reused tool ids with the nearest prece
assert.equal(maps.resolvedToolCallsByAssistant.get(messages[1]), undefined);
assert.equal(maps.resolvedToolCallsByAssistant.get(messages[2])?.has(messages[2].toolCalls![0]), true);
});
test("buildHistoricalUserReplayContent replaces historical vault note mentions with id metadata", () => {
const attachment: ChatMessageAttachment = {
base64Data: "A".repeat(5_000),
mediaType: "text/markdown",
filename: "runbook.md",
vaultNoteId: "note-123",
vaultNoteTitle: "Runbook",
previewText: "restart nginx",
};
const result = buildHistoricalUserReplayContent("check this note", [attachment]);
assert.match(result, /check this note/);
assert.match(result, /Vault note reference/);
assert.match(result, /"noteId":"note-123"/);
assert.match(result, /"title":"Runbook"/);
assert.doesNotMatch(result, /AAAAAA/);
});
test("external recovery retains note identity before truncating a long user request", () => {
const history = buildExternalBridgeContextMessages([{
id: "long-note-request", role: "user", timestamp: 1, content: "x".repeat(2500),
attachments: [{mediaType: "text/markdown", base64Data: "", vaultNoteId: "note-123", vaultNoteTitle: "Runbook"}],
}]);
const replay = history.find((message) => message.role === "user")!;
assert.match(replay.content, /"noteId":"note-123"/);
assert.match(replay.content, /vault_notes_get/);
assert.match(replay.content, /truncated/);
assert.ok(replay.content.length <= 2000);
});
test("external recovery keeps multiple note IDs and a short user constraint", () => {
const attachments = Array.from({length: 10}, (_, index) => ({
mediaType: "text/markdown", base64Data: "", vaultNoteId: `note-${index}`, vaultNoteTitle: `Runbook ${index}`,
}));
const history = buildExternalBridgeContextMessages([{
id: "many-notes", role: "user", timestamp: 1, content: "Only compare; do not edit.", attachments,
}]);
const replay = history.find((message) => message.role === "user")!;
for (const attachment of attachments) assert.ok(replay.content.includes(JSON.stringify(attachment.vaultNoteId)));
assert.match(replay.content, /Only compare; do not edit/);
assert.equal(replay.content.match(/Use vault_notes_get/g)?.length, 1);
});
for (const terminalSelection of [false, true]) {
test(`compact external recovery retains the request and attachment (terminal=${terminalSelection}) alongside note references`, () => {
const noteId = '550e8400-e29b-41d4-a716-446655440000';
const request = "Only summarize yesterday's deployments; do not edit.";
const messages: ChatMessage[] = [{
id: 'old-note', role: 'user', timestamp: 1, content: request,
attachments: [
{ mediaType: 'text/markdown', base64Data: '', vaultNoteId: noteId, vaultNoteTitle: 'Deployment Runbook' },
{ mediaType: 'text/plain', base64Data: 'YWJj', filename: 'other.txt', terminalSelection },
],
}, ...Array.from({ length: 8 }, (_, i): ChatMessage => ({
id: `later-${i}`, role: i % 2 ? 'assistant' : 'user', timestamp: i + 2, content: i % 2 ? 'Done.' : 'ok',
}))];
const compact = buildExternalBridgeContextMessages(messages)[0].content;
assert.ok(compact.includes(noteId));
assert.ok(compact.includes(request));
assert.ok(compact.includes('other.txt'));
assert.ok(compact.includes(terminalSelection ? 'terminal selection omitted' : 'attachment omitted'));
});
}
+12 -7
View File
@@ -1,5 +1,6 @@
import type { ChatMessage, ChatMessageAttachment, ToolCall, ToolResult } from "../../infrastructure/ai/types";
import { isTerminalSelectionAttachment } from "../../application/state/terminalSelectionAttachment";
import { formatVaultNoteReferences, isVaultNoteAttachment } from "../../application/state/vaultNoteAttachment";
import { redactSecretsForModel } from "../../infrastructure/ai/harness/modelSecretRedaction";
const MAX_ATTACHMENT_PLACEHOLDER_DETAIL_CHARS = 120;
@@ -50,15 +51,19 @@ export function buildHistoricalUserReplayContent(
content: string,
attachments: ChatMessageAttachment[] = [],
): string {
const placeholders = attachments.map((attachment, index) => (
isTerminalSelectionAttachment(attachment)
? formatTerminalSelectionPlaceholder(attachment, index)
: formatAttachmentPlaceholder(attachment, index)
));
if (!attachments.length) return content;
// Keep note identities ahead of prose so bounded external history retains them.
const notes = attachments.filter(isVaultNoteAttachment);
const placeholders = attachments.filter((attachment) => !isVaultNoteAttachment(attachment))
.map((attachment, index) => (
isTerminalSelectionAttachment(attachment)
? formatTerminalSelectionPlaceholder(attachment, index)
: formatAttachmentPlaceholder(attachment, index)
));
if (!placeholders.length) return content;
const attachmentBlock = placeholders.map((line) => `\n\n${line}`).join("");
return content.trim() ? `${content}${attachmentBlock}` : placeholders.join("\n\n");
const body = content.trim() ? `${content}${attachmentBlock}` : placeholders.join("\n\n");
return notes.length ? [formatVaultNoteReferences(notes), body].filter(Boolean).join("\n\n") : body;
}
function getToolCommand(toolCall?: ToolCall): string | undefined {
@@ -83,6 +83,7 @@ test('AddToWorkspaceDialog virtualizes and clamps selection on shrink', () => {
const source = read('workspace/AddToWorkspaceDialog.tsx');
assert.match(source, /VariableSizeVirtualList/);
assert.match(source, /data-host-picker-virtual="add-workspace"/);
assert.match(source, /matchesWorkspaceHostPickerQuery/);
assert.match(source, /clampListIndex\(prev, items\.length\)/);
assert.match(source, /onClick=\{\(\) => handleTargetClick\(idx, LOCAL_ITEM_ID\)\}/);
assert.match(source, /onClick=\{\(\) => handleTargetClick\(idx, host\.id\)\}/);
@@ -96,6 +97,7 @@ test('CreateWorkspaceDialog virtualizes selectable hosts', () => {
const source = read('CreateWorkspaceDialog.tsx');
assert.match(source, /FixedSizeVirtualList/);
assert.match(source, /data-host-picker-virtual="create-workspace"/);
assert.match(source, /matchesWorkspaceHostPickerQuery/);
assert.doesNotMatch(source, /filteredHosts\.map\(host =>/);
});
+13
View File
@@ -4,6 +4,7 @@
*/
import { ChevronDown,Copy,Trash2 } from 'lucide-react';
import React from 'react';
import { isPortForwardingAutoReconnectEnabled } from '../../domain/portForwardingReconnect';
import { useI18n } from '../../application/i18n/I18nProvider';
import { Host,PortForwardingRule } from '../../domain/models';
import { DistroAvatar } from '../DistroAvatar';
@@ -182,6 +183,18 @@ export const EditPanel: React.FC<EditPanelProps> = ({
onCheckedChange={checked => onDraftChange({ autoStart: checked })}
/>
</div>
{/* Auto Reconnect Toggle */}
<div className="flex items-center justify-between py-2">
<div className="space-y-0.5">
<Label className="text-sm font-medium">{t('pf.form.autoReconnect')}</Label>
<p className="text-[10px] text-muted-foreground">{t('pf.form.autoReconnectDesc')}</p>
</div>
<Switch
checked={isPortForwardingAutoReconnectEnabled(draft)}
onCheckedChange={checked => onDraftChange({ autoReconnect: checked })}
/>
</div>
</AsidePanelContent>
<AsidePanelFooter className="space-y-2">
<Button
@@ -4,6 +4,7 @@
*/
import { ChevronDown,Zap } from 'lucide-react';
import React from 'react';
import { isPortForwardingAutoReconnectEnabled } from '../../domain/portForwardingReconnect';
import { useI18n } from '../../application/i18n/I18nProvider';
import { Host,PortForwardingRule,PortForwardingType } from '../../domain/models';
import { cn } from '../../lib/utils';
@@ -182,6 +183,18 @@ export const NewFormPanel: React.FC<NewFormPanelProps> = ({
onCheckedChange={checked => onDraftChange({ autoStart: checked })}
/>
</div>
{/* Auto Reconnect Toggle */}
<div className="flex items-center justify-between py-2">
<div className="space-y-0.5">
<Label className="text-sm font-medium">{t('pf.form.autoReconnect')}</Label>
<p className="text-[10px] text-muted-foreground">{t('pf.form.autoReconnectDesc')}</p>
</div>
<Switch
checked={isPortForwardingAutoReconnectEnabled(draft)}
onCheckedChange={checked => onDraftChange({ autoReconnect: checked })}
/>
</div>
</AsidePanelContent>
<AsidePanelFooter className="space-y-2">
<Button
@@ -0,0 +1,96 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import test from "node:test";
import vm from "node:vm";
import React, { useCallback, useEffect, useRef, useState } from "react";
import TestRenderer, { act } from "react-test-renderer";
import ts from "typescript";
(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
// Exercise the production lifecycle and asynchronous resolver together, without
// loading the unrelated provider forms or making real CLI/authentication calls.
const source = readFileSync(new URL("./SettingsAITab.tsx", import.meta.url), "utf8");
const lifecycleStart = source.indexOf(" const mountedRef = useRef(true);");
const lifecycleEnd = source.indexOf("\n const applyResolvedAgentPath", lifecycleStart);
const resolverStart = source.indexOf(" const resolveAgentPath = useCallback");
const resolverEnd = source.indexOf("\n useEffect(() => {", resolverStart);
assert.ok(lifecycleStart >= 0 && lifecycleEnd > lifecycleStart);
assert.ok(resolverStart >= 0 && resolverEnd > resolverStart);
const declarations = source.slice(lifecycleStart, lifecycleEnd) + source.slice(resolverStart, resolverEnd);
const setters = ["Codex", "Claude", "Copilot", "Cursor", "Codebuddy", "Opencode", "Grok"];
const bindings = ["useRef", "useEffect", "useCallback", "getBridge", "applyResolvedAgentPath", "cursorApiKeyEncrypted",
...setters.map(name => `setIsResolving${name}`)];
const runHooks = vm.runInNewContext(ts.transpile(`(function(ctx) {
const { ${bindings.join(",")} } = ctx;
${declarations}
return resolveAgentPath;
})`, { target: ts.ScriptTarget.ES2022 }), { console }) as
(ctx: Record<string, unknown>) => (key: string) => Promise<unknown>;
function deferred() {
let resolve!: (value: unknown) => void;
const promise = new Promise<unknown>(done => { resolve = done; });
return { promise, resolve };
}
async function mountResolver() {
const calls: ReturnType<typeof deferred>[] = [];
const applied: unknown[] = [];
const state: { busy: boolean; resolve?: (key: string) => Promise<unknown> } = { busy: false };
const bridge = { aiResolveCli: () => { const pending = deferred(); calls.push(pending); return pending.promise; } };
function Harness() {
const [busy, setBusy] = useState(false);
state.busy = busy;
state.resolve = runHooks({
useRef, useEffect, useCallback,
getBridge: () => bridge,
applyResolvedAgentPath: (_key: string, result: unknown) => applied.push(result),
cursorApiKeyEncrypted: "",
...Object.fromEntries(setters.map(name => [`setIsResolving${name}`, setBusy])),
});
return null;
}
let root!: TestRenderer.ReactTestRenderer;
await act(async () => { root = TestRenderer.create(React.createElement(React.StrictMode, null, React.createElement(Harness))); });
return { calls, applied, state, root };
}
test("AI detection accepts completed requests after StrictMode effect replay", async () => {
const h = await mountResolver();
try {
let request!: Promise<unknown>;
await act(async () => { request = h.state.resolve!("cursor"); });
assert.equal(h.state.busy, true);
const result = { installed: true, available: true, cliBinPath: "/fixture/cursor-agent" };
await act(async () => { h.calls[0].resolve(result); await request; });
assert.deepEqual(h.applied, [result]);
assert.equal(h.state.busy, false);
} finally { await act(async () => h.root.unmount()); }
});
test("AI detection still ignores completion after real unmount", async () => {
const h = await mountResolver();
let request!: Promise<unknown>;
await act(async () => { request = h.state.resolve!("cursor"); });
await act(async () => h.root.unmount());
h.calls[0].resolve({ installed: true });
await request;
assert.deepEqual(h.applied, []);
});
test("an older AI detection result cannot replace or finish the newer request", async () => {
const h = await mountResolver();
try {
let old!: Promise<unknown>;
let current!: Promise<unknown>;
await act(async () => { old = h.state.resolve!("cursor"); current = h.state.resolve!("cursor"); });
await act(async () => { h.calls[0].resolve({ installed: false }); await old; });
assert.deepEqual(h.applied, []);
assert.equal(h.state.busy, true);
const result = { installed: true };
await act(async () => { h.calls[1].resolve(result); await current; });
assert.deepEqual(h.applied, [result]);
assert.equal(h.state.busy, false);
} finally { await act(async () => h.root.unmount()); }
});
+21 -16
View File
@@ -342,12 +342,16 @@ const SettingsAITab: React.FC<SettingsAITabProps> = ({
const mountedRef = useRef(true);
const agentPathRequestIdRef = useRef<Partial<Record<ManagedAgentKey, number>>>({});
const codexRequestIdRef = useRef(0);
useEffect(() => () => {
mountedRef.current = false;
codexRequestIdRef.current += 1;
for (const key of ["codex", "claude", "copilot", "cursor", "codebuddy", "opencode", "grok"] as ManagedAgentKey[]) {
agentPathRequestIdRef.current[key] = (agentPathRequestIdRef.current[key] ?? 0) + 1;
}
useEffect(() => {
mountedRef.current = true;
const agentPathRequestIds = agentPathRequestIdRef.current;
return () => {
mountedRef.current = false;
codexRequestIdRef.current += 1;
for (const key of ["codex", "claude", "copilot", "cursor", "codebuddy", "opencode", "grok"] as ManagedAgentKey[]) {
agentPathRequestIds[key] = (agentPathRequestIds[key] ?? 0) + 1;
}
};
}, []);
const applyResolvedAgentPath = useCallback((
@@ -641,6 +645,11 @@ const SettingsAITab: React.FC<SettingsAITabProps> = ({
}, 900);
}, []);
const codexManagedAgent = useMemo(
() => externalAgents.find((agent) => agent.id === "discovered_codex"),
[externalAgents],
);
const refreshCodexIntegration = useCallback(async (opts?: { refreshShellEnv?: boolean; validateChatGptAuth?: boolean; codexPath?: string }) => {
const bridge = getBridge();
if (!bridge?.aiCodexGetIntegration) return;
@@ -651,7 +660,7 @@ const SettingsAITab: React.FC<SettingsAITabProps> = ({
setIsCodexLoading(true);
setCodexError(null);
try {
const integration = await bridge.aiCodexGetIntegration(opts);
const integration = await bridge.aiCodexGetIntegration({ ...opts, agentEnv: codexManagedAgent?.env });
if (!isCurrentRequest()) return;
setCodexIntegration(integration);
} catch (err) {
@@ -663,7 +672,7 @@ const SettingsAITab: React.FC<SettingsAITabProps> = ({
setIsCodexLoading(false);
}
}
}, []);
}, [codexManagedAgent?.env]);
const codexCommittedPath = useMemo(
() => getManagedAgentCommandPath(externalAgents, "codex") || codexPathInfo?.path || undefined,
@@ -678,10 +687,6 @@ const SettingsAITab: React.FC<SettingsAITabProps> = ({
codexCommittedPath
), [codexCommittedPath]);
const codexManagedAgent = useMemo(
() => externalAgents.find((agent) => agent.id === "discovered_codex"),
[externalAgents],
);
const codexRuntime = codexManagedAgent?.codexRuntime ?? 'sdk';
const refreshCodexAppServerStatus = useCallback(async () => {
@@ -857,7 +862,7 @@ const SettingsAITab: React.FC<SettingsAITabProps> = ({
setCodexError(null);
setIsCodexLoading(true);
try {
const result = await bridge.aiCodexStartLogin({ codexPath: getCodexPathOverride() });
const result = await bridge.aiCodexStartLogin({ codexPath: getCodexPathOverride(), agentEnv: codexManagedAgent?.env });
if (!isCurrentRequest()) return;
if (!result.ok || !result.session) {
throw new Error(result.error || "Failed to start Codex login");
@@ -872,7 +877,7 @@ const SettingsAITab: React.FC<SettingsAITabProps> = ({
setIsCodexLoading(false);
}
}
}, [getCodexPathOverride]);
}, [getCodexPathOverride, codexManagedAgent?.env]);
const handleCancelCodexLogin = useCallback(async () => {
const bridge = getBridge();
@@ -908,7 +913,7 @@ const SettingsAITab: React.FC<SettingsAITabProps> = ({
setCodexError(null);
setIsCodexLoading(true);
try {
const result = await bridge.aiCodexLogout({ codexPath: getCodexPathOverride() });
const result = await bridge.aiCodexLogout({ codexPath: getCodexPathOverride(), agentEnv: codexManagedAgent?.env });
if (!isCurrentRequest()) return;
if (!result.ok) {
throw new Error(result.error || "Failed to log out from Codex");
@@ -924,7 +929,7 @@ const SettingsAITab: React.FC<SettingsAITabProps> = ({
setIsCodexLoading(false);
}
}
}, [getCodexPathOverride, refreshCodexIntegration]);
}, [getCodexPathOverride, refreshCodexIntegration, codexManagedAgent?.env]);
const refreshUserSkillsStatus = useCallback(async () => {
const bridge = getBridge();
+3 -3
View File
@@ -152,11 +152,11 @@ export interface FetchBridge {
export interface NetcattyAiBridge {
aiDiscoverAgents?: (options?: { refreshShellEnv?: boolean; apiKeyPresent?: boolean }) => Promise<Array<AgentPathInfo & { command: string }>>;
aiPrewarmShellEnv?: () => Promise<{ ok: boolean; error?: string }>;
aiCodexGetIntegration?: (options?: { refreshShellEnv?: boolean; validateChatGptAuth?: boolean; codexPath?: string }) => Promise<CodexIntegrationStatus>;
aiCodexStartLogin?: (options?: { codexPath?: string }) => Promise<{ ok: boolean; session?: CodexLoginSession; error?: string }>;
aiCodexGetIntegration?: (options?: { refreshShellEnv?: boolean; validateChatGptAuth?: boolean; codexPath?: string; agentEnv?: Record<string, string> }) => Promise<CodexIntegrationStatus>;
aiCodexStartLogin?: (options?: { codexPath?: string; agentEnv?: Record<string, string> }) => Promise<{ ok: boolean; session?: CodexLoginSession; error?: string }>;
aiCodexGetLoginSession?: (sessionId: string) => Promise<{ ok: boolean; session?: CodexLoginSession; error?: string }>;
aiCodexCancelLogin?: (sessionId: string) => Promise<{ ok: boolean; found?: boolean; session?: CodexLoginSession; error?: string }>;
aiCodexLogout?: (options?: { codexPath?: string }) => Promise<{ ok: boolean; state?: CodexIntegrationState; isConnected?: boolean; rawOutput?: string; logoutOutput?: string; error?: string }>;
aiCodexLogout?: (options?: { codexPath?: string; agentEnv?: Record<string, string> }) => Promise<{ ok: boolean; state?: CodexIntegrationState; isConnected?: boolean; rawOutput?: string; logoutOutput?: string; error?: string }>;
aiResolveCli?: (params: { command: string; customPath?: string; refreshShellEnv?: boolean; apiKeyPresent?: boolean }) => Promise<AgentPathInfo>;
aiSdkAgentListModels?: (sdkBackend: string, cwd?: string, providerId?: string, chatSessionId?: string, agentEnv?: Record<string, string>, agentCommand?: string, codexRuntime?: 'sdk' | 'app-server') => Promise<{ ok: boolean; models?: Array<{ id: string; name: string; description?: string; thinkingLevels?: string[]; defaultThinkingLevel?: string }>; currentModelId?: string | null; error?: string }>;
codexAppServerGetStatus?: (agentCommand?: string, agentEnv?: Record<string, string>) => Promise<{ ok: boolean; available: boolean; error?: string }>;
+4 -20
View File
@@ -20,10 +20,10 @@ import {
X,
XCircle,
} from 'lucide-react';
import React, { memo, useEffect, useState } from 'react';
import React, { memo } from 'react';
import { useI18n } from '../../application/i18n/I18nProvider';
import { getParentPath } from '../../application/state/sftp/utils';
import { useSftpTransferTask } from '../../application/state/sftpTransferCenterStore';
import { useSftpTransferTask, useSftpTransferResuming } from '../../application/state/sftpTransferCenterStore';
import { cn } from '../../lib/utils';
import { TransferTask } from '../../types';
import {
@@ -136,8 +136,6 @@ const SftpTransferItemInner: React.FC<SftpTransferItemProps> = ({
// panel setTransfersState for every tick — that re-rendered the whole SFTP
// tree and pegged the renderer during large copies.
const task = useSftpTransferTask(propsTask.id, propsTask);
// Optimistic spinner from click until store status moves off paused/interrupted.
const [resumeClicked, setResumeClicked] = useState(false);
// Same progress model as the global transfer center (done · found for folders).
const isDirParent = isDirectoryParentTask(task);
@@ -159,21 +157,8 @@ const SftpTransferItemInner: React.FC<SftpTransferItemProps> = ({
const storeResuming = task.reconnectRequired === true
&& ['pending', 'queued', 'transferring'].includes(task.status)
&& !task.error;
const isResuming = storeResuming
|| (resumeClicked && ['paused', 'interrupted', 'attention', 'pending', 'queued'].includes(task.status) && !task.error);
useEffect(() => {
if (!resumeClicked) return;
if (
storeResuming
|| task.status === 'transferring'
|| task.status === 'completed'
|| task.status === 'failed'
|| task.status === 'cancelled'
|| !!task.error
) {
setResumeClicked(false);
}
}, [resumeClicked, storeResuming, task.status, task.error]);
const sharedResuming = useSftpTransferResuming(task.id);
const isResuming = sharedResuming || storeResuming;
const effectiveSpeed = task.status === 'transferring'
? (Number.isFinite(task.speed) && task.speed > 0 ? task.speed : 0)
: 0;
@@ -417,7 +402,6 @@ const SftpTransferItemInner: React.FC<SftpTransferItemProps> = ({
className={actionButtonClass}
data-action="resume-transfer"
{...oncePerActivationHandlers(() => {
setResumeClicked(true);
onResume();
})}
aria-label={actionAriaLabel(resumeActionLabel)}
+412
View File
@@ -5,6 +5,7 @@ import {
requireCopyToOtherPaneTarget,
type SftpPaneSide,
} from "./copyToOtherPane";
import { resolveSamePanePasteAction } from "../../application/state/sftp/samePanePaste";
test("copy to other pane is unavailable when the destination pane is missing", () => {
assert.equal(canCopyToOtherPane({ getActivePane: () => null }, "right"), false);
@@ -62,3 +63,414 @@ test("copy to other pane reports why it cannot start instead of silently returni
);
assert.equal(unavailableCount, 1);
});
test("same-pane copy of files into their own source folder is allowed", async () => {
const files = [
{ name: "report.txt", isDirectory: false },
{ name: "notes.txt", isDirectory: false },
];
assert.equal(
await resolveSamePanePasteAction({ operation: "copy", sourcePath: "/home/user", targetPath: "/home/user", files }),
"allow",
);
assert.equal(
await resolveSamePanePasteAction({ operation: "copy", sourcePath: "/home/user", targetPath: "/home/user/docs", files }),
"allow",
);
});
test("same-pane copy of a directory into itself or a descendant is blocked", async () => {
const files = [{ name: "docs", isDirectory: true }];
assert.equal(
await resolveSamePanePasteAction({ operation: "copy", sourcePath: "/a", targetPath: "/a/docs", files }),
"block-into-source",
);
assert.equal(
await resolveSamePanePasteAction({ operation: "copy", sourcePath: "/a", targetPath: "/a/docs/sub", files }),
"block-into-source",
);
assert.equal(
await resolveSamePanePasteAction({ operation: "copy", sourcePath: "/a", targetPath: "/a/docs/sub/deep", files }),
"block-into-source",
);
});
test("same-pane copy of a directory into a sibling is allowed", async () => {
const files = [{ name: "docs", isDirectory: true }];
assert.equal(
await resolveSamePanePasteAction({ operation: "copy", sourcePath: "/a/docs", targetPath: "/a/sub", files }),
"allow",
);
assert.equal(
await resolveSamePanePasteAction({ operation: "copy", sourcePath: "/a/docs", targetPath: "/a/docsx", files }),
"allow",
);
});
test("same-pane cut into the source folder is blocked", async () => {
const files = [{ name: "report.txt", isDirectory: false }];
assert.equal(
await resolveSamePanePasteAction({ operation: "cut", sourcePath: "/home/user", targetPath: "/home/user", files }),
"block-same-folder",
);
assert.equal(
await resolveSamePanePasteAction({ operation: "cut", sourcePath: "/home/user", targetPath: "/home/user/", files }),
"block-same-folder",
);
});
test("same-pane cut of files into a child of the source folder is allowed", async () => {
const files = [
{ name: "report.txt", isDirectory: false },
{ name: "photos", isDirectory: false },
];
assert.equal(
await resolveSamePanePasteAction({ operation: "cut", sourcePath: "/home/user", targetPath: "/home/user/docs", files }),
"allow",
);
assert.equal(
await resolveSamePanePasteAction({ operation: "cut", sourcePath: "/home/user", targetPath: "/home/user/docs/sub", files }),
"allow",
);
});
test("same-pane cut of a directory into itself or a descendant is blocked", async () => {
const files = [{ name: "docs", isDirectory: true }];
assert.equal(
await resolveSamePanePasteAction({ operation: "cut", sourcePath: "/home/user", targetPath: "/home/user/docs", files }),
"block-into-source",
);
assert.equal(
await resolveSamePanePasteAction({ operation: "cut", sourcePath: "/home/user", targetPath: "/home/user/docs/sub", files }),
"block-into-source",
);
assert.equal(
await resolveSamePanePasteAction({ operation: "cut", sourcePath: "/home/user", targetPath: "/home/user/docs/sub/deep", files }),
"block-into-source",
);
});
test("same-pane cut into a sibling folder is allowed", async () => {
const files = [{ name: "docs", isDirectory: true }];
assert.equal(
await resolveSamePanePasteAction({ operation: "cut", sourcePath: "/home/user", targetPath: "/home/other", files }),
"allow",
);
assert.equal(
await resolveSamePanePasteAction({ operation: "cut", sourcePath: "/home/user", targetPath: "/home/user2", files }),
"allow",
);
});
test("same-pane paste guard understands Windows paths", async () => {
const files = [{ name: "docs", isDirectory: true }];
assert.equal(
await resolveSamePanePasteAction({
operation: "cut",
sourcePath: "C:\\Users\\me",
targetPath: "C:/Users/me",
files,
}),
"block-same-folder",
);
assert.equal(
await resolveSamePanePasteAction({
operation: "cut",
sourcePath: "C:\\Users\\me",
targetPath: "C:\\Users\\me\\docs",
files,
}),
"block-into-source",
);
assert.equal(
await resolveSamePanePasteAction({
operation: "copy",
sourcePath: "C:\\Users\\me",
targetPath: "C:\\Users\\me\\docs",
files: [{ name: "report.txt", isDirectory: false }],
}),
"allow",
);
assert.equal(
await resolveSamePanePasteAction({
operation: "cut",
sourcePath: "C:\\Users\\me",
targetPath: "C:\\Users\\other",
files,
}),
"allow",
);
});
test("same-pane guards canonicalize equivalent path spellings", async () => {
const files = [{ name: "docs", isDirectory: true }];
assert.equal(
await resolveSamePanePasteAction({ operation: "cut", sourcePath: "/home/user", targetPath: "/home/user/.", files }),
"block-same-folder",
);
assert.equal(
await resolveSamePanePasteAction({ operation: "cut", sourcePath: "/home/user", targetPath: "/home//user", files }),
"block-same-folder",
);
assert.equal(
await resolveSamePanePasteAction({ operation: "cut", sourcePath: "/home/user", targetPath: "/home/user/../user", files }),
"block-same-folder",
);
assert.equal(
await resolveSamePanePasteAction({
operation: "cut",
sourcePath: "/home/user",
targetPath: "/home/./user/docs",
files,
}),
"block-into-source",
);
assert.equal(
await resolveSamePanePasteAction({ operation: "cut", sourcePath: "/home/user", targetPath: "/home/userx", files }),
"allow",
);
});
test("same-pane guards collapse a leading double slash for POSIX comparisons", async () => {
const files = [{ name: "docs", isDirectory: true }];
assert.equal(
await resolveSamePanePasteAction({ operation: "cut", sourcePath: "/home/user", targetPath: "//home/user", files }),
"block-same-folder",
);
assert.equal(
await resolveSamePanePasteAction({
operation: "cut",
sourcePath: "/home/user",
targetPath: "//home/user/docs",
files,
}),
"block-into-source",
);
assert.equal(
await resolveSamePanePasteAction({ operation: "cut", sourcePath: "//home/user", targetPath: "/home/user", files }),
"block-same-folder",
);
assert.equal(
await resolveSamePanePasteAction({ operation: "cut", sourcePath: "/home/user", targetPath: "/home/other", files }),
"allow",
);
});
test("same-pane guards canonicalize equivalent Windows path spellings", async () => {
const files = [{ name: "docs", isDirectory: true }];
assert.equal(
await resolveSamePanePasteAction({
operation: "cut",
sourcePath: "C:\\Users\\me",
targetPath: "C:\\Users\\me\\.",
files,
}),
"block-same-folder",
);
assert.equal(
await resolveSamePanePasteAction({
operation: "cut",
sourcePath: "C:\\Users\\me",
targetPath: "C:\\Users\\\\me",
files,
}),
"block-same-folder",
);
assert.equal(
await resolveSamePanePasteAction({
operation: "cut",
sourcePath: "C:\\Users\\me",
targetPath: "C:\\Users\\me\\docs\\sub\\..\\docs",
files,
}),
"block-into-source",
);
});
test("same-pane guards keep the separator after UNC share roots", async () => {
const files = [{ name: "docs", isDirectory: true }];
// Copying docs from \\server\share into \\server\sharedocs (a different
// share) must not collide with the source item \\server\share\docs.
assert.equal(
await resolveSamePanePasteAction({
operation: "copy",
sourcePath: "\\\\server\\share",
targetPath: "\\\\server\\sharedocs",
files,
}),
"allow",
);
assert.equal(
await resolveSamePanePasteAction({
operation: "copy",
sourcePath: "\\\\server\\share",
targetPath: "\\\\server\\share\\docs",
files,
}),
"block-into-source",
);
});
test("same-pane guards resolve filesystem aliases before comparing", async () => {
const files = [{ name: "docs", isDirectory: true }];
// /a/link -> /a/docs/sub: pasting /a/docs from /a/link must be blocked even
// though the lexical paths look unrelated.
const aliasedResolver = (path: string) => {
const aliases: Record<string, string> = {
"/a/link": "/a/docs/sub",
"/a/docs": "/a/docs",
"/a": "/a",
};
return Promise.resolve(aliases[path] ?? path);
};
assert.equal(
await resolveSamePanePasteAction({
operation: "copy",
sourcePath: "/a",
targetPath: "/a/link",
files,
resolvePath: aliasedResolver,
}),
"block-into-source",
);
assert.equal(
await resolveSamePanePasteAction({
operation: "cut",
sourcePath: "/a",
targetPath: "/a/link",
files,
resolvePath: aliasedResolver,
}),
"block-into-source",
);
});
test("same-pane guards still allow unrelated real paths when resolving", async () => {
const files = [{ name: "docs", isDirectory: true }];
const resolver = (path: string) => {
const resolved: Record<string, string> = {
"/a/docs": "/data/docs",
"/a/other": "/data/other",
"/a/docs/docs": "/data/docs/docs",
};
return Promise.resolve(resolved[path] ?? path);
};
assert.equal(
await resolveSamePanePasteAction({
operation: "copy",
sourcePath: "/a/docs",
targetPath: "/a/other",
files,
resolvePath: resolver,
}),
"allow",
);
});
test("same-pane guards fail closed when filesystem resolution fails", async () => {
const files = [{ name: "docs", isDirectory: true }];
assert.equal(
await resolveSamePanePasteAction({
operation: "copy",
sourcePath: "/a/docs",
targetPath: "/a/other",
files,
resolvePath: () => Promise.reject(new Error("realpath unavailable")),
}),
"block-into-source",
);
});
test("files-only copy still passes when path resolution is unavailable", async () => {
const files = [{ name: "report.txt", isDirectory: false }];
assert.equal(
await resolveSamePanePasteAction({
operation: "copy",
sourcePath: "/a",
targetPath: "/b",
files,
resolvePath: () => Promise.reject(new Error("realpath unavailable")),
}),
"allow",
);
});
test("same-pane cut into a bind-mount alias of the source folder is blocked", async () => {
const files = [{ name: "report.txt", isDirectory: false }];
// realpath cannot see through bind mounts: /mnt/alias is bind-mounted to /a,
// so both sides resolve to themselves and only stat identities match.
const resolver = (path: string) => Promise.resolve(path);
const statIdentity = (path: string) => Promise.resolve(
path === "/a" || path === "/mnt/alias" ? { dev: 42, ino: 7 } : { dev: 42, ino: 9 },
);
assert.equal(
await resolveSamePanePasteAction({
operation: "cut",
sourcePath: "/a",
targetPath: "/mnt/alias",
files,
resolvePath: resolver,
statIdentity,
}),
"block-same-folder",
);
// Distinct filesystem identities must not block the cut.
assert.equal(
await resolveSamePanePasteAction({
operation: "cut",
sourcePath: "/a",
targetPath: "/mnt/other",
files,
resolvePath: resolver,
statIdentity: (path) => Promise.resolve(
path === "/a" ? { dev: 42, ino: 7 } : { dev: 42, ino: 8 },
),
}),
"allow",
);
// A provider that cannot stat falls through instead of blocking.
assert.equal(
await resolveSamePanePasteAction({
operation: "cut",
sourcePath: "/a",
targetPath: "/mnt/alias",
files,
resolvePath: resolver,
statIdentity: () => Promise.resolve(null),
}),
"allow",
);
});
test("same-pane paste into a bind-mount alias of a clipboard directory is blocked", async () => {
const files = [{ name: "docs", isDirectory: true }];
// /mnt/docs-alias is bind-mounted to /a/docs: pasting /a/docs there names
// the same directory, for copy (recursive rediscovery) and cut (the source
// delete would remove the freshly pasted entry).
const resolver = (path: string) => Promise.resolve(path);
const statIdentity = (path: string) => Promise.resolve(
path === "/a/docs" || path === "/mnt/docs-alias" ? { dev: 42, ino: 11 } : { dev: 42, ino: 3 },
);
assert.equal(
await resolveSamePanePasteAction({
operation: "copy",
sourcePath: "/a",
targetPath: "/mnt/docs-alias",
files,
resolvePath: resolver,
statIdentity,
}),
"block-into-source",
);
assert.equal(
await resolveSamePanePasteAction({
operation: "cut",
sourcePath: "/a",
targetPath: "/mnt/docs-alias",
files,
resolvePath: resolver,
statIdentity,
}),
"block-into-source",
);
});
@@ -21,6 +21,7 @@ import { keepOnlyPaneSelections } from "./selectionScope";
import type { SftpStateApi } from "../../../application/state/useSftpState";
import type { UploadEndpointPin } from "../../../application/state/sftp/uploadTargetPin";
import { filterHiddenFiles, isNavigableDirectory } from "../utils";
import { resolveSameConnectionPasteAction } from "../../../application/state/sftp/samePanePaste";
import type { SftpFileEntry } from "../../../types";
import { extractDropEntries, type DropEntry } from "../../../lib/sftpFileUtils";
import { toast } from "../../ui/toast";
@@ -351,14 +352,51 @@ export const useSftpKeyboardShortcuts = ({
const clipboard = sftpClipboardStore.get();
if (!clipboard || clipboard.files.length === 0) return;
// Pin the destination this paste was validated against. Native pastes may
// await clipboard reads before reaching here, and the user can navigate or
// switch tabs meanwhile — startTransfer would otherwise resolve the
// then-active pane and could land inside a directory this guard approved
// as safe.
const targetConnectionId = pane.connection!.id;
const targetPath = pane.connection!.currentPath;
const isSameConnection = clipboard.sourceSide === focusedSide
&& clipboard.sourceConnectionId === pane.connection!.id;
if (isSameConnection) {
toast.info("Paste within the same pane is not supported. Use copy to other pane instead.", "SFTP");
return;
const connection = pane.connection!;
const pasteAction = await resolveSameConnectionPasteAction({
operation: clipboard.operation,
sourcePath: clipboard.sourcePath,
targetPath,
files: clipboard.files,
isLocal: connection.isLocal,
sftpId: connection.isLocal ? null : (sftp.getSftpIdForConnection(connection.id) ?? null),
});
if (pasteAction === "block-same-folder") {
toast.info("The cut items are already in this folder.", "SFTP");
return;
}
if (pasteAction === "block-into-source") {
toast.info(
clipboard.operation === "cut"
? "A folder can't be moved into itself. Choose a different folder."
: "A folder can't be copied into itself or one of its subfolders. Choose a different folder.",
"SFTP",
);
return;
}
// Same-pane copy (and cut into a different folder) falls through to the
// shared transfer path below.
}
const sourceTabs = clipboard.sourceSide === "left" ? sftp.leftTabs.tabs : sftp.rightTabs.tabs;
// Re-read live state, not the pre-await snapshot: the same-connection
// guard above can await realpath while the user switches tabs, and
// startTransfer resolves the currently active pane. Comparing against the
// stale snapshot would let the pinned targetPath land on a different
// connection (and a cut would then delete the original after the wrong
// host received the data).
const liveSftp = sftpRef.current;
const sourceTabs = clipboard.sourceSide === "left" ? liveSftp.leftTabs.tabs : liveSftp.rightTabs.tabs;
const sourcePane = sourceTabs.find((tab) => tab.connection?.id === clipboard.sourceConnectionId);
if (!sourcePane?.connection) {
@@ -426,10 +464,24 @@ export const useSftpKeyboardShortcuts = ({
updateClipboardAfterCompletion(pendingNames.size === 0);
};
// Abandon the paste when the destination pane changed while the paste
// was pending: startTransfer resolves the currently active pane, and a
// different connection would receive a path pinned from another host.
// Read the live tabs (see the liveSftp note above), not the snapshot
// captured before the await.
const activeTargetPane = focusedSide === "left"
? liveSftp.leftTabs.tabs.find((tab) => tab.id === liveSftp.leftTabs.activeTabId)
: liveSftp.rightTabs.tabs.find((tab) => tab.id === liveSftp.rightTabs.activeTabId);
if (!activeTargetPane?.connection || activeTargetPane.connection.id !== targetConnectionId) {
toast.info("Paste cancelled: the destination connection changed.", "SFTP");
return;
}
await sftp.startTransfer(clipboard.files, clipboard.sourceSide, focusedSide, {
sourcePane,
sourcePath: clipboard.sourcePath,
sourceConnectionId: clipboard.sourceConnectionId,
targetPath,
onTransferComplete: handleTransferComplete,
});
} catch {
@@ -0,0 +1,53 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import React from 'react';
import { act, create } from 'react-test-renderer';
import { CustomThemeEditor } from './CustomThemeEditor';
import { TERMINAL_THEMES } from '../../infrastructure/config/terminalThemes';
import type { TerminalTheme } from '../../domain/models';
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
test('intense foreground can be enabled, edited, disabled and saved without changing the palette', async () => {
let theme: TerminalTheme = structuredClone(TERMINAL_THEMES[0]);
delete theme.colors.foregroundIntense;
const palette = { ...theme.colors };
let renderer: ReturnType<typeof create>;
function Editor() {
const [current, setCurrent] = React.useState(theme);
return <CustomThemeEditor theme={current} onChange={(next) => { theme = next; setCurrent(next); }} />;
}
await act(async () => { renderer = create(<Editor />); });
const toggle = () => renderer.root.findByProps({ role: 'switch' });
const color = () => renderer.root.findAllByType('input').find((input) =>
input.props.type === 'color' && input.props['aria-label'] === 'terminal.customTheme.color.foregroundIntense')!;
assert.equal(toggle().props['aria-checked'], false);
assert.equal(color().props.disabled, true);
await act(async () => toggle().props.onClick());
assert.equal(theme.colors.foregroundIntense, theme.colors.foreground);
await act(async () => color().props.onChange({ target: { value: '#ff8800' } }));
assert.equal(theme.colors.foregroundIntense, '#ff8800');
assert.equal(JSON.parse(JSON.stringify(theme)).colors.foregroundIntense, '#ff8800');
await act(async () => toggle().props.onClick());
assert.equal(theme.colors.foregroundIntense, undefined);
assert.deepEqual(JSON.parse(JSON.stringify(theme.colors)), palette);
await act(async () => renderer.unmount());
});
test('iTerm import preserves Bold Color and leaves older themes disabled', async () => {
const { JSDOM } = await import('jsdom');
const { parseItermcolors } = await import('../../infrastructure/parsers/itermcolorsParser');
const dom = new JSDOM();
const original = globalThis.DOMParser;
globalThis.DOMParser = dom.window.DOMParser;
const color = (key: string, r: number, g: number, b: number) => `<key>${key}</key><dict><key>Red Component</key><real>${r}</real><key>Green Component</key><real>${g}</real><key>Blue Component</key><real>${b}</real></dict>`;
const essential = color('Background Color', 0, 0, 0) + color('Foreground Color', 1, 1, 1);
try {
assert.equal(parseItermcolors(`<plist><dict>${essential}${color('Bold Color', 1, 0.5, 0)}</dict></plist>`, 'imported')?.colors.foregroundIntense, '#ff8000');
assert.equal(parseItermcolors(`<plist><dict>${essential}</dict></plist>`, 'older')?.colors.foregroundIntense, undefined);
} finally {
if (original === undefined) delete (globalThis as { DOMParser?: typeof DOMParser }).DOMParser;
else globalThis.DOMParser = original;
dom.window.close();
}
});
+26 -3
View File
@@ -7,16 +7,24 @@
import React, { useCallback, memo } from 'react';
import { TerminalTheme } from '../../domain/models';
import { useI18n } from '../../application/i18n/I18nProvider';
import { Switch } from '../ui/switch';
interface ColorFieldDef {
key: keyof TerminalTheme['colors'];
labelKey: string;
/** Fallback shown when the (optional) field has no value yet. */
fallbackKey?: keyof TerminalTheme['colors'];
}
const GENERAL_COLORS: ColorFieldDef[] = [
{ key: 'background', labelKey: 'terminal.customTheme.color.background' },
{ key: 'foreground', labelKey: 'terminal.customTheme.color.foreground' },
{
key: 'foregroundIntense',
labelKey: 'terminal.customTheme.color.foregroundIntense',
fallbackKey: 'foreground',
},
{ key: 'cursor', labelKey: 'terminal.customTheme.color.cursor' },
{ key: 'selection', labelKey: 'terminal.customTheme.color.selection' },
];
@@ -47,10 +55,14 @@ const ColorInput = memo(({
label,
value,
onChange,
enabled,
onEnabledChange,
}: {
label: string;
value: string;
onChange: (value: string) => void;
enabled?: boolean;
onEnabledChange?: (enabled: boolean) => void;
}) => {
// Local state for text input — allows partial hex while typing
const [textValue, setTextValue] = React.useState(value);
@@ -76,9 +88,14 @@ const ColorInput = memo(({
return (
<div className="flex items-center gap-2">
{onEnabledChange && (
<Switch checked={enabled} onCheckedChange={onEnabledChange} aria-label={label} />
)}
<div className="relative">
<input
type="color"
aria-label={label}
disabled={enabled === false}
value={value}
onChange={(e) => onChange(e.target.value)}
className="w-6 h-6 rounded cursor-pointer border border-border/50 p-0"
@@ -88,6 +105,8 @@ const ColorInput = memo(({
<span className="text-[10px] text-muted-foreground flex-1 truncate">{label}</span>
<input
type="text"
aria-label={label}
disabled={enabled === false}
value={textValue}
onChange={handleTextChange}
onBlur={handleBlur}
@@ -114,7 +133,7 @@ export const CustomThemeEditor: React.FC<CustomThemeEditorProps> = ({
}) => {
const { t } = useI18n();
const updateColor = useCallback((key: keyof TerminalTheme['colors'], value: string) => {
const updateColor = useCallback((key: keyof TerminalTheme['colors'], value: string | undefined) => {
onChange({
...theme,
colors: { ...theme.colors, [key]: value },
@@ -135,12 +154,16 @@ export const CustomThemeEditor: React.FC<CustomThemeEditorProps> = ({
{title}
</div>
<div className="space-y-1">
{fields.map(({ key, labelKey }) => (
{fields.map(({ key, labelKey, fallbackKey }) => (
<ColorInput
key={key}
label={t(labelKey)}
value={theme.colors[key]}
value={theme.colors[key] ?? (fallbackKey ? theme.colors[fallbackKey] : '') ?? ''}
onChange={(v) => updateColor(key, v)}
enabled={key === 'foregroundIntense' ? theme.colors.foregroundIntense !== undefined : undefined}
onEnabledChange={key === 'foregroundIntense'
? (enabled) => updateColor(key, enabled ? theme.colors.foreground : undefined)
: undefined}
/>
))}
</div>
@@ -0,0 +1,100 @@
import assert from "node:assert/strict";
import { test } from "node:test";
import type { Terminal as XTerm } from "@xterm/xterm";
import { alignTerminalViewportScroll } from "./terminalHelpers";
type ViewportSpy = {
scrollToLine: (line: number, disableSmoothScroll?: boolean) => void;
_sync?: () => void;
calls: Array<{ line: number; disableSmoothScroll?: boolean }>;
};
const createTerm = (
viewportY: number,
viewport?: ViewportSpy | null,
): XTerm => ({
buffer: { active: { viewportY } },
_core: viewport === null ? undefined : { _viewport: viewport },
}) as unknown as XTerm;
test("alignTerminalViewportScroll snaps the viewport back to the buffer row without smooth scrolling", () => {
const calls: Array<{ line: number; disableSmoothScroll?: boolean }> = [];
const viewport: ViewportSpy = {
scrollToLine: (line, disableSmoothScroll) => {
calls.push({ line, disableSmoothScroll });
},
calls,
};
const term = createTerm(247, viewport);
alignTerminalViewportScroll(term);
assert.deepEqual(calls, [{ line: 247, disableSmoothScroll: true }]);
});
test("alignTerminalViewportScroll syncs viewport dimensions before setting the position", () => {
const calls: Array<{ line: number; disableSmoothScroll?: boolean }> = [];
const order: string[] = [];
const viewport: ViewportSpy = {
_sync: () => {
order.push("sync");
},
scrollToLine: (line, disableSmoothScroll) => {
order.push("scrollToLine");
calls.push({ line, disableSmoothScroll });
},
calls,
};
const term = createTerm(247, viewport);
alignTerminalViewportScroll(term);
assert.deepEqual(order, ["sync", "scrollToLine"]);
assert.deepEqual(calls, [{ line: 247, disableSmoothScroll: true }]);
});
test("alignTerminalViewportScroll survives a failing dimension sync", () => {
const calls: Array<{ line: number; disableSmoothScroll?: boolean }> = [];
const viewport: ViewportSpy = {
_sync: () => {
throw new Error("boom");
},
scrollToLine: (line, disableSmoothScroll) => {
calls.push({ line, disableSmoothScroll });
},
calls,
};
assert.doesNotThrow(() => alignTerminalViewportScroll(createTerm(5, viewport)));
assert.deepEqual(calls, [{ line: 5, disableSmoothScroll: true }]);
});
test("alignTerminalViewportScroll is a no-op when the private viewport is unavailable", () => {
const term = createTerm(10, null);
assert.doesNotThrow(() => alignTerminalViewportScroll(term));
});
test("alignTerminalViewportScroll survives a viewport without scrollToLine", () => {
const term = {
buffer: { active: { viewportY: 5 } },
_core: { _viewport: {} },
} as unknown as XTerm;
assert.doesNotThrow(() => alignTerminalViewportScroll(term));
});
test("alignTerminalViewportScroll swallows viewport errors instead of breaking the fit", () => {
const term = {
buffer: { active: { viewportY: 5 } },
_core: {
_viewport: {
scrollToLine: () => {
throw new Error("boom");
},
},
},
} as unknown as XTerm;
assert.doesNotThrow(() => alignTerminalViewportScroll(term));
});
@@ -18,10 +18,11 @@ export function resolveTerminalAutocompleteSettings(input: {
terminalSettings?: TerminalAutocompleteSettingFields;
/** Vendor CLI / network-device session: skip live-preview PTY rewrites (#1193). */
isNetworkDevice?: boolean;
systemUnknown?: boolean;
}): Partial<AutocompleteSettings> | undefined {
const { protocol, terminalSettings, isNetworkDevice } = input;
const { protocol, terminalSettings, isNetworkDevice, systemUnknown } = input;
if (protocol === "serial") {
if (protocol === "serial" || systemUnknown) {
return {
enabled: terminalSettings?.autocompleteEnabled ?? true,
showGhostText: terminalSettings?.autocompleteGhostText ?? true,
@@ -59,6 +59,13 @@ function codePointCellWidth(cp: number): number {
}
function graphemeCellWidth(grapheme: string): number {
// Emoji-presentation graphemes (base + VS16 / U+FE0F) always render as
// 2 cells regardless of the base character's text width. xterm's
// 15-graphemes Unicode provider handles this via getStringCellWidth;
// the fallback must detect VS16 explicitly because codePointCellWidth
// returns 0 for the variation selector itself and 1 for bases like
// U+2764 (heavy black heart) that are outside the standard emoji range.
if (grapheme.includes("\uFE0F")) return 2;
let max = 0;
for (const ch of grapheme) {
const w = codePointCellWidth(ch.codePointAt(0) ?? 0);
Loaded 100 of 296 files, more files were not shown because too many files have changed in this diff. Show more