Compare commits

..
Author SHA1 Message Date
Cursor Agentand陈大猫 f8c220dbd1 Isolate notes and customAccent from TerminalLayer hot props
Route vault notes through notesStore (shellHistoryStore pattern) and
app accent through appearanceChromeStore so note edits and accent-drag
ticks no longer rebuild TerminalLayer. Notes/AI slots and Terminal leaves
subscribe locally; VaultView and open-note callbacks stay unchanged.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-08-07 03:33:25 +00:00
陈大猫 443202ba4e fix(sftp): instant folder-drop scan UI and drop full-tree pre-scan (#2782)
* fix(sftp): instant folder-drop scan UI and drop full-tree pre-scan

Restore interleaved folder transfers as the only non-compressed path, remove
the folder pre-scan setting, and make local folder drops show a scanning task
with live discovered counts via native listLocalTree (with cancel + progress).

UI shows "done · found" instead of a fake grand-total percentage while
discovery is still growing.

* fix(sftp): make folder scan cancellation bridge-safe

* fix(sftp): preserve path-only terminal drops

* fix(terminal): accept native folder drop paths

* fix(sftp): keep scan task cancellable during setup

* fix(sftp): preserve scan failure state during setup errors

* fix(sftp): keep folder scan task across retries

* fix(sftp): cancel scan task immediately

* fix(sftp): keep cancellation active through session setup

* fix(sftp): settle failed drop upload retry

* fix(sftp): preserve cancellation in final scan cleanup

* fix(sftp): harden folder scan cancellation and conflicts

* fix(sftp): finish Codex follow-ups on folder scan cancel UX

- Abort sibling listLocalTree walks when one multi-root scan fails
- Prefer ERR_*_CANCELLED over matching "cancel" in error text
- Clear scanning phase on settle; only label live rows as Scanning
- Omit directory metadata sizes from drop entries used for totals

* fix(sftp): stream folder discovery into concurrent uploads

Local path-backed folder drops now edge-scan and edge-transfer: listLocalTree
streams entry batches while upload workers consume them, so the first files
start before a 100k-file walk finishes. Cancel aborts the walk, clears the
queue, and stops creating child tasks. Compressed uploads still materialize
fully then pack.

* fix(sftp): harden progressive folder UX, retry, and size-based uploads

Align the SFTP panel with transfer-center progress, keep nested tree batches
until tree-end, honor pause during progressive enqueue, add low-cost retry for
external drag-drop files, and drop whole-file upload digests for industry
size-based resume (download live-log prefix checks unchanged).

* fix(sftp): address multi-agent review on progressive upload and retry

Gate progressive drops to path-backed folders only, prefer pool sessions on
drag-drop retry with in-flight and cancel guards, soft-check upload mtime
without fail-open contentVerifiedSeparately, skip multi-root pause HOL,
and fail closed when the local tree stream misses tree-end.

* fix(sftp): finish multi-root pause HOL and parent rollup guards

Race-wait on all latched parents so resuming a non-head folder can proceed,
and only roll progressive parents to completed after terminal failed status.

* fix(sftp): address Codex progressive conflict and hang findings

Add root-level conflict preflight to progressive folder uploads, wake all
backpressure waiters, preserve cancel listeners across uploadEntriesDirect,
and surface empty-directory mkdir failures.
2026-08-07 02:01:18 +08:00
6c246bb8f4 fix: optimize growing-download verification (#2780)
* fix(sftp): optimize growing-download verification

* fix(sftp): harden growing-download prefix verification

Skip unprivileged SSH digest commands for sudo SFTP sessions and reject
empty-input digests so root-only sources fall through to the elevated
SFTP channel. Hash remote prefixes in concurrency-sized windows so
verification keeps a bounded memory footprint instead of retaining the
full downloaded prefix.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>

* fix(sftp): fall back when remote digest command times out

Treat SSH digest open/run timeouts as a miss for the optional
command-path optimization so growing-download verification can still
prove the prefix via SFTP ranges instead of failing a completed transfer.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>

* fix(sftp): propagate digest open timeouts after transport invalidation

SSH_EXEC_OPEN_TIMEOUT invalidates the physical transport in
boundedSshExec, so falling through to SFTP on the same session is unsafe.
Keep run-timeout fallback to SFTP, but fail closed on open timeout.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>

* fix(sftp): mark digest open timeouts as noTransferFallback

SSH_EXEC_OPEN_TIMEOUT already invalidates the transport. Tag it with
noTransferFallback so downloadFile's isolated→shared catch fails closed
instead of retrying verification on the poisoned session.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>

* fix(sftp): fail closed on verify timeouts; range-hash checkpoint resumes

Mark SFTP verification request/stream timeouts with noTransferFallback so
downloadFile does not retry body transfer on a channel that still owns a
timed-out request. Allow checkpoint-complete growth verification to use
bounded prefix ranges under the session fast-download slot, including
sudo paths that skip unprivileged SSH digests.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>

* fix(sftp): drop wedged SFTP channel after verification timeouts

Clear and end the cached SFTP channel when prefix verification hits an
OPEN/READ/stream watchdog or digest exec-open timeout, so later
browse/transfer work cannot reuse a channel that still owns cancelled
requests.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>

* fix(sftp): use window inactivity watchdog for prefix range hashing

Reset a single per-window READ inactivity timer whenever any range
completes, instead of arming independent deadlines that all expire
together on slow or serialized SFTP servers.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>

* fix(sftp): abandon channel on stalled CLOSE; share abort gate

Drop the cached SFTP channel when verification CLOSE times out or fails,
and fan out concurrent prefix READs through one shared AbortSignal gate
so the bounded window does not attach dozens of listeners.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>

---------

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-08-06 20:53:46 +08:00
陈大猫 90df595e43 fix: remove lint warnings (#2779) 2026-08-06 18:56:20 +08:00
ab3580982d fix(sftp): parallel directory discovery, folder pre-scan, and skip-unchanged (#2712 / #2556) (#2772)
* fix(sftp): parallel folder discovery and unblock transfer stalls

Address #2712 / #2556 leftovers after the pipelined fail-closed work
(#2449 / #2720 / #2761): keep bulk body I/O on concurrent READ/WRITE only,
surface upload digest prep as verifying instead of a silent hang, make the
fastPut path-gate wait cancelable (#2755), parallelize multi-select enqueue,
and bound-parallelize directory listings so folder totals appear faster.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>

* feat(sftp): folder pre-scan and skip unchanged files

Absorb rsync strengths without an rsync backend: optionally walk the
full tree before transfer for stable file totals, and skip files whose
size and mtime already match the destination.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>

* fix(sftp): drop unused DiscoveredTransferFile import

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>

* fix: address Codex review on PR #2772

* fix(sftp): address Codex review on path gate, aliases, and worker drain

- Invalidate same-transfer fallback when a force-released path gate gets a late truncating OPEN
- Use per-branch ancestors so sibling symlink aliases copy under pre-scan BFS
- Keep interleaved subdirectory walks sequential for deterministic manifest indices
- Drain transfer/bounded workers before propagating errors; ASCII punctuation

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>

* fix(sftp): canonical pre-scan file order and pre-2001 mtime skip

Sort discovered files with compareDirectoryTraversalPaths before index
assignment, and treat timestamps >= 1e11 as milliseconds so year-2000
local mtimes still match SFTP second precision.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>

* fix(sftp): bound multi-select downloads and preserve dest mtime

Address Codex review on PR #2772: serialize local blob reads, bound
multi-select root downloads before pre-scan, replace remaining Unicode
em dashes, and stamp destination mtimes after successful transfers so
skip-unchanged can match on a later pass.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>

* fix(sftp): keep in-place OPEN poison until callback settles

Address Codex review on e2cc8241: do not force-release path gates for
in-place truncating OPENs, fail closed on pending-OPEN wait timeouts
without clearing poison, and replace remaining Unicode em dashes in
ASCII-only transfer modules.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>

* fix(sftp): fail closed when in-place OPEN poison is held

When an isolated in-place truncating OPEN leaves the path gate poisoned
after channel error/cancel, mark the transfer terminal (noTransferFallback)
so fastPut/concurrent-shared cannot wait forever on the unreleased prior
gate.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>

* fix(sftp): omit cyclic dirs from pre-scan plan and lower mtime cutoff

Only record discovered directories after a visit is accepted so symlink
cycles are not mkdir'd as empty targets, and treat magnitudes >= 1e10 as
milliseconds so early-1970s local Date values still match SFTP seconds.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>

* fix(sftp): count max-depth symlink gaps and skip symlink quick-check

Pre-scan now surfaces max-depth symlink directory omissions as transfer
errors (matching interleaved walk), and discovered file symlinks carry
isSymlink so skip-unchanged does not trust link-node size/mtime.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>

* fix(sftp): unit-aware skip mtimes and interleaved symlink bypass

Declare mtimeUnit on skip identities so early-1970 millisecond values
normalize without magnitude cutoffs, and bypass skip-unchanged for
symlinks on the interleaved (non-prescan) directory walk as well.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>

* fix(sftp): bound local pre-scan and preserve SCP destination mtimes

Apply the same directory/entry traversal budget to local folder pre-scan,
and best-effort touch SCP upload targets after commit so skip-unchanged
can match on a later pass.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>

* fix(sftp): address Codex review on pause, skip restat, mtime bound

- Honor transfer pause during folder pre-scan BFS waves/listings
- Re-stat source immediately before skip-unchanged on pre-scanned files
- Bound best-effort destination setStat/utimes so sendComplete cannot hang

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>

* fix(sftp): harden skip restat and pre-transfer mtime capture

- Require a fresh source stat before skip-unchanged (prescan + interleaved)
- Capture source soft identity before bytes move for non-resumable/SCP
- Drop post-commit mtime re-stat fallback; keep ASCII in touched comments

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>

* fix(sftp): propagate fresh re-stat size into interleaved transfers

Use the re-statted source size/mtime for child tasks on the non-prescan
path so grown files are not truncated to the stale listing size.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>

* fix(sftp): omit stale listing size after re-stat failure

When source re-stat fails, clear child totalBytes/mtime so
startStreamTransfer omits an explicit snapshot and the bridge
re-stats on the dedicated session instead of truncating to the
stale listing size.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>

* fix(sftp): make soft-identity capture cancelable

Race pre-transfer source soft-identity capture against cancel and
pass the transfer abort signal into requireSftpChannel so a dead
SFTP reopen cannot block cancel for the channel timeout.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>

---------

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
Co-authored-by: netcatty-bot <308658023+netcatty-bot@users.noreply.github.com>
2026-08-06 18:46:03 +08:00
bd81b1fbcf fix(sftp): make fastPut pending OPEN gate wait cancelable (#2759)
* fix(sftp): make fastPut pending OPEN gate wait cancelable

An isolated concurrent OPEN that dies without a callback leaves
pendingWriteOpenPathGate unresolved by design. Unconditionally awaiting
it before fastPut pinned the transfer after abort was cleared; race the
wait with cancel and bound it fail-closed so Cancel/lease cleanup can
settle without racing a still-pending truncate (#2755).

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

* fix(sftp): end fastPut channel on gate wait abort

Clear the gate-wait timeout on cancel so abandoned rejects cannot fire,
and end the reopened isolated channel when the wait fails so Cancel /
fail-closed timeout cannot leak an SSH subsystem.

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

* fix(sftp): poison path gate after fastPut OPEN wait timeout

When a dead write OPEN leaves truncatingSharedWriteOpenGates unresolved,
fail-closed fastPut timeout now marks that entry failed so later same-path
uploads reject promptly instead of hanging on waitForPrior. Keep the
barrier until a late OPEN release clears it; waitForPrior rejection no
longer starts OPEN.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>

* fix(sftp): keep path-gate poison across successor waiters

Reinstall the poisoned truncatingSharedWriteOpenGates entry on fail() so a
same-path waiter that replaced the map slot cannot release the barrier for a
third OPEN. Propagate fail (not resolve) when abandonGateWait sees a poisoned
prior, and poison on cancel during the fastPut gate wait as well as timeout.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>

* fix(sftp): let OPEN owner clear poison after successor fail

Successor waiters that propagate a poisoned prior must not reinstall
themselves into truncatingSharedWriteOpenGates. Otherwise the OPEN owner's
late release cannot delete the barrier and same-path uploads stay
fail-closed forever after the dangerous OPEN has settled.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>

* fix(sftp): clear path-gate poison after transport end

Arm a 2s post-close clear when the owning isolated SFTP channel ends so a
host-keyed poison from a never-callback OPEN does not fail-closed every
later same-path upload until process restart. Keep the grace so a late
OPEN racing end() still has a barrier.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>

* fix(sftp): arm path-gate poison clear without per-OPEN listeners

Install the post-poison transport-gone clear from fail() instead of
attaching end/close listeners on every write OPEN. Shared browse channels
never emit those events per transfer, so the old approach leaked listeners
and could hit MaxListenersExceededWarning.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>

* fix(sftp): only OPEN owners arm path-gate poison cleanup

Track openIssued / priorEntry on truncatingSharedWriteOpenGates so a
waiter that reaches fastPut timeout poisons the OPEN-owning ancestor
instead of reinstalling itself and clearing the barrier while the prior
transport may still deliver a late truncating OPEN.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>

* fix(sftp): do not arm ancestor poison cleanup from waiter fail

When a waiting upload times out and fail() walks to the OPEN-owning
ancestor, keep that ancestor's barrier until its own OPEN settles. Only
the OPEN owner entry arms releaseAfterTransportGone after its transport
is already gone.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-08-06 16:57:46 +08:00
205d1591e8 feat(terminal): add disconnect/reconnect on workspace split status bar (#2769) (#2771)
* feat(terminal): add disconnect/reconnect controls on session status bar

Workspace panes could not reach tab-level reconnect. Expose disconnect
(without closing the pane) and reconnect beside the host info actions so
split sessions stay operable. Closes #2769.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>

* fix(terminal): abort in-flight boot on status-bar disconnect

Disconnect keeps the pane mounted, so cancel's unmount cleanup never runs.
Flip isBootActiveRef (and isCancelling) so a late startSSH/startMosh attach
cannot resurrect the session after the user disconnects mid-connect.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>

* fix(terminal): ignore stale start UI after status-bar disconnect

When disconnect aborts an in-flight connect, starter catch paths must not
reopen auth or repopulate errors. Gate SSH/telnet/mosh/ET/local/serial
failure UI on isTerminalBootActive and harden the boot effect awaits.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>

* fix(terminal): hide disconnect/reconnect on compact popup terminals

Owned command popups use compactToolbar with a one-shot startup command.
Exposing reconnect there can re-run that command; keep the controls on
normal tabs/workspace panes only.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>

* fix(terminal): use boot epoch so reconnect cannot revive aborted starts

Disconnect alone flipping isBootActiveRef is not enough: an immediate
reconnect re-arms boot, and a late startSSH from the aborted attempt can
still attach. Capture a boot epoch per attempt and ignore stale attaches/UI.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>

* fix(terminal): do not let stale attach abort a newer reconnect

tryAttach failure for an expired boot epoch must close the orphan session
only. Calling abortSessionStartAfterUnmount() would force disconnected on
a reconnect that already re-armed boot.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>

* fix(terminal): reject late host-key prompts after disconnect

Disconnect can land before pendingHostKeyRequestId is set. Auto-deny
onHostKeyVerification when boot is inactive or status is disconnected
so approval UI cannot reopen on an aborted pane.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>

* fix(terminal): cancel wake and MFA when status-bar disconnects

Invalidate hibernation reconnect-wake tokens on disconnect so a late wake
cannot re-arm boot. Reject/cancel keyboard-interactive prompts for
disconnected panes and clear any already-queued MFA for that session.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>

* fix(terminal): ignore stale SSH catch UI and password fallback

Check boot attempt currency before resetting reconnect UI state on
SSH start failure, and before launching key→password fallback after
disconnect so a replacement reconnect is not interrupted.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>

* fix(terminal): guard stale SSH progress and host-key prompts

Ignore chain-progress callbacks from superseded boot attempts, and
thread bootEpoch through SSH host-key verification so a late prompt
from an aborted start cannot overwrite a replacement reconnect.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>

* fix(terminal): correlate MFA and jump host-keys with boot epoch

Forward bootEpoch through jump-host verification and keyboard-interactive
prompts, and reject superseded MFA when the retained tab has already
started a replacement reconnect.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>

* fix(terminal): keep replacement SSH session alive across stale starts

Skip closeSession for superseded starts while a newer boot still owns
the shared sessionId, and avoid clearing MFA wait state before the
attempt-freshness guard on SSH success.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>

* fix(terminal): own shared sessionIds by boot epoch

Claim SSH/local registry slots with bootEpoch so a stale start cannot
overwrite a replacement, and make closeSession no-op when the payload
epoch does not match the live session owner.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>

* fix(terminal): claim bootEpoch for all reconnectable backends

Pass bootEpoch through Telnet/Mosh/ET/serial starts, claim registry slots
the same way as SSH/local, and dispose displaced owners when a newer
boot replaces the shared sessionId.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>

* fix(terminal): protect preload listeners and ET artifacts on displace

Defer epoch-scoped closeSession listener teardown until main confirms
ownership, and remove ET external-auth temp artifacts when a newer boot
displaces the previous registry owner.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>

* fix(terminal): clean rejected Mosh starts and keep replacement listeners

Kill superseded Mosh/ET PTYs and auth temp files when claimSessionSlot
rejects, and never clear shared preload listeners on epoch-scoped closes.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>

* fix(terminal): settle superseded boots and stop displaced log streams

Reject Telnet/SSH claim failures through Promise reject/failReuse instead of
uncaught EventEmitter/.then throws, and stop a displaced session's log stream
so a logging-disabled reconnect cannot append into the old file.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>

* fix(terminal): keep woken runtime when disconnect cancels hibernate wake

Disconnect invalidates in-flight hibernated reconnects with keep mode so a
successful wake that already cleared hibernatedRef is not disposed into a
dead pane that can never reconnect. Unmount still disposes orphan runtimes.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>

* fix(terminal): bind reused SSH shellPid only to the claimed session

Late PID discovery after Copy Tab reuse must not write onto a newer
bootEpoch that already replaced the shared sessionId registry slot.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>

* fix(terminal): await plugin cancel cleanup and clear boot epochs on teardown

Track plugin start/cancel promises so Disconnect→Reconnect waits until the
external session finishes unregistering, and stop unmount teardown from
republishing boot epochs into the process-wide map.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>

* fix(terminal): cancel SSH passphrase prompts on disconnect

Register a pending-boot AbortSignal for SSH starts, abort it (and session-
scoped passphrase requests) from closeSession even before a registry slot
exists, and filter/drop late passphrase modals by boot epoch / disconnect.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>

* fix(terminal): close owned boot epoch and scope passphrase cancels

Capture the pre-bump epoch for disconnect/teardown closeSession so live
transports are not skipped by epoch mismatch, cancel only passphrase prompts
at or below that epoch, and revalidate boot currency after async passphrase
lookups before queuing a modal.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>

* fix(terminal): tag Telnet auto-login and Mosh ready with bootEpoch

Emit bootEpoch on telnet auto-login and mosh:ready events so overlapping
Disconnect→Reconnect boots ignore cross-delivered callbacks meant for an
older shared sessionId.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>

* fix(terminal): update disconnect assertion and cancel pending serial opens

Align the disconnect source regression with invalidateBootEpochForClose, and
abort in-flight serialPort.open attempts via pending boot AbortSignal so
Disconnect→Reconnect cannot leave a stale open racing the replacement.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>

* fix(terminal): keep flow-pause leases on skipped epoch closes

Worker close:await previously cleared pause ownership in finally even when
the worker returned skipped for an epoch mismatch, dropping the live
replacement's lease. Only clear on non-skipped closes; epoch-scoped
fire-and-forget close similarly defers to onSessionClosed.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>

---------

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-08-06 15:47:20 +08:00
30004e7716 fix(ssh): forward resolved Local Network first hop on direct starts (#2775)
When the terminal worker is disabled, startSSHSessionWrapper probed LAN
access but dropped the resolved address before startSSHSessionWithRetries.
Share attachMacLocalNetworkProbeResult with the worker path so split-DNS
FQDN first hops still get Local Network error guidance.

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-08-06 15:14:57 +08:00
75452055ae fix(ssh): harden macOS Local Network TCC trigger (#2673) (#2766)
* fix(ssh): harden macOS Local Network TCC trigger for LAN SSH

Replace the main-process TCP probe with Apple's TN3179 UDP discard-port
connect, resolve vault hostnames/.local before probing, hold the socket
briefly so TCC can present the alert, and annotate EHOSTUNREACH errors
that embed LAN IPs even when the host was stored as a DNS name.

Addresses recurring #2673 / #2663 / #1040 Local Network silent failures.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>

* fix(ssh): address Codex Local Network review findings

Clear the UDP connect safety timer once connected so a slow .local
resolve cannot truncate the TCC hold. Mark worker-forwarded start
payloads after the main-process probe so utilityProcess skips a second
hold. Keep the new test comments ASCII-only.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>

* fix(ssh): bound Local Network DNS lookup by probe timeout

Codex noted that hostname resolution before the UDP probe had no
deadline, so a hung DNS lookup could stall every non-literal SSH start.
Race the lookup against the existing probe timeout and skip probing when
it expires so the normal SSH dial can proceed.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>

* fix(ssh): tighten Local Network error address parsing

Ignore LAN bind addresses in Node's "- Local (ip:port)" suffix so public
SSH failures are not mislabeled as Local Network denials. Also extract
embedded IPv6 ULA/link-local remotes from unreachable errors.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>

* fix(ssh): only hint Local Network when the first hop is LAN

A .local final host reached through a public proxy or jump must not force
the macOS Local Network hint when the failing dial never touched LAN.
Annotate from the first hop and remote error address instead.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>

* fix(ssh): probe jump-level proxy as the first Local Network hop

Match sshBridge first-hop wiring: a usable jump.proxy overrides the
session proxy and the jump hostname. Otherwise a .local jump behind a
public proxy probes the wrong host, and a LAN proxy in front of a public
jump is missed.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>

* fix(ssh): retry .local Local Network probes over udp6

IPv6-only mDNS hosts never resolve on a udp4 socket, so the TCC trigger
could miss them. For unresolved hostnames try udp4 then udp6.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>

* fix(ssh): share Local Network probe timeout across UDP families

udp4 then udp6 fallbacks for .local hosts now inherit one overall
deadline instead of restarting DEFAULT_PROBE_TIMEOUT_MS per family.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>

* chore: re-request Codex review after unanswered pass

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>

* fix(ssh): skip Local Network probe for ProxyCommand hops

Treat an active jump/session ProxyCommand as a terminal first hop so we
do not fall through to a LAN hostname and spuriously prompt. Also share
one wall-clock deadline across DNS resolution and UDP family attempts.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>

* test(ssh): tolerate residual Local Network probe budgets in hold test

Shared-deadline math can arm the safety timer at 499ms instead of exactly
500ms. Identify that timer by being larger than the hold, not by equality.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>

* fix(macos): preserve ProxyCommand skip during Local Network error annotation

Keep ProxyCommand's skipProbe sentinel through annotateMacLocalNetworkErrorMessage
so .local hop names do not get a false Local Network hint when TCP probing was
intentionally skipped. Split residual UDP budget evenly across remaining address
families so dual-stack and .local IPv6 attempts still get time.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>

* test(ssh): fire residual Local Network safety timers in hang probe test

Shared-deadline math arms the UDP safety timer with a residual budget
(e.g. 2999ms) rather than the exact DEFAULT_PROBE_TIMEOUT_MS constant,
so matching only the constant left the hang-socket test unable to settle.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>

* fix(macos): never hint Local Network for ProxyCommand errors

skipProbe means an external ProxyCommand owns the dial. Do not append the
Netcatty Local Network TCC hint from child-process LAN addresses in the
error text — that guidance would send users to enable Netcatty for a
connection Electron never made.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>

* fix(macos): ignore downstream LAN addresses behind public first hops

When a public proxy/jump owns the first dial, unreachable addresses embedded
in the error often name a private host the remote side tried to reach. Only
use those addresses as Local Network evidence when they identify the local
first-hop dial.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>

* fix(macos): keep LAN hints for private DNS first hops and ASCII comments

Treat common private DNS suffixes (e.g. bastion.lan) as Local Network
evidence so hostname-based first hops still get guidance when Node reports
the resolved IP. Replace em dashes and arrows in this module with ASCII.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>

* fix(macos): keep Local Network hints for unqualified first hops

Single-label jump/proxy names such as "dev-viet" resolve to LAN IPs that
appear in Node unreachable errors. Treat those hostnames as local-side
evidence so annotation still guides users to macOS Local Network settings.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>

* fix(macos): carry resolved first-hop LAN addresses into error annotation

ensureAccess remembers the LAN IP it probed for a hostname first hop and
forwards it on the worker start payload. Annotation uses that resolved
address so private split-DNS FQDNs like bastion.example.com still get
Local Network guidance without treating public proxies as LAN.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>

---------

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-08-06 15:02:54 +08:00
08d1bb5f81 fix(terminal): keep newest scrollback after hibernate wake (#2765)
* fix(terminal): restore hibernate scrollback before viewport

Hibernate wake was writing the viewport first and appending older
scrollback on idle. Under a finite xterm scrollback cap that trims the
just-restored newest rows, so switching back to a hibernated tab could
permanently drop the end of large output (#2762). Replay scrollback →
viewport → pending on the wake path while keeping chunked writes.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>

* fix(terminal): prefer full hibernate snapshot on wake

SerializeAddon range slices omit the seam newline, so concatenating
scrollback+viewport can merge boundary lines. Prefer the coherent full
snapshot for wake history, with a scrollback-then-viewport fallback, and
cover the seam with a real SerializeAddon regression.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>

* style(terminal): keep hibernate wake comments ASCII-only

Address Codex P3: replace em dash / arrow punctuation in touched
comments per AGENTS.md ASCII convention.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>

* fix(terminal): drain hibernate pending via take-and-clear on wake

Full-history replay widens the window where capped pending output can
grow without increasing length. Length-based slice drains then skip new
bytes. Capture and clear pending before replay, then drain pure deltas.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>

* fix(terminal): final pending drain before hibernate listener teardown

After the bounded take-and-clear loop, take any remaining pending once
more before stopHibernateListeners so wake-time output is not dropped.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>

* fix(terminal): stop hibernate listeners before final pending drain

Awaiting the final pending write while listeners are still live can
lose already-acked bytes that arrive during that write. Stop listeners
first, then take-and-replay any remainder.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>

* fix(terminal): keep hibernate exit listener through final pending replay

Stop only the data listener before the final pending drain so pending
stops growing, but leave the exit listener registered until after the
reattach decision so an exit during that await is not missed.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>

* fix(terminal): pause backend flow across hibernate history replay

Full-history wake no longer races the 512 KiB pending cap or the 64 KiB
preload backlog. Pause-and-wait drains into pending, stop the data
listener, take pending once, replay, reattach, then resume. Removes the
multi-pass drain that still dropped ACKed bytes under sustained output.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>

* fix(terminal): harden hibernate wake against drain failure and exit tails

Check setSessionFlowPausedAndWait success. On drain failure keep the
hibernate data listener with the pending cap disabled for the wake window.
After history replay, take pending once more so exit "[session closed]"
tails and uncapped live arrivals are not discarded.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>

* fix(terminal): pause reconnect hibernate wakes and recheck exit after late drain

Always pause when a backend session exists (including sessionConnected=false
reconnect wakes). Drain late pending plus an exit-tail take before deciding
reattach. Resume flow only after reattach, or on wake failure; successful
reconnect wakes stay paused until cleanupSession.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>

* fix(terminal): restore hibernate listeners on failed wake

On wake failure, dispose the partial xterm runtime and reinstall hibernate
listeners (preserving pending) instead of unpausing into a listener-less
backlog gap. Successful reconnect wakes still keep the pause until
cleanupSession. Add a final pending take before exit-listener teardown.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>

* fix(terminal): restore take-and-cleared pending on failed hibernate wake

Track every pending take during wake and pass it to restoreAfterFailedWake
so a thrown replay/reattach cannot permanently drop already-buffered output
with the disposed partial xterm. Also take once more before exit-listener
teardown.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>

* fix(terminal): drain hibernate pending until empty before teardown

After history replay, loop take-and-append until an empty take so an exit
or residual chunk that arrives during an awaited replay cannot be dropped
by stopHibernateListeners. Data listener is already stopped, so the loop
stays short in practice.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>

---------

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-08-06 14:33:59 +08:00
Netcatty-Bot 6bc65a43de fix(terminal): forward Ctrl+V for local clipboard image paste (#2760)
Local terminal paste with an image-only clipboard now forwards raw Ctrl+V into the PTY so nested TUIs (e.g. Claude Code) can read the OS clipboard. Text and file-path paste, and remote sessions, are unchanged.

Fixes #2758
2026-08-06 14:13:04 +08:00
904af93a1d fix(credentials): 防止解密失败后二次加密污染本地与云端 (#2702) (#2770)
* fix(credentials): stop double-encrypting undecryptable safeStorage blobs

When local decrypt failed (e.g. OSCrypt key churn after reboot), encrypt
used to wrap the leftover enc:v1 ciphertext again, permanently poisoning
the vault. Startup sync could also push those placeholders to cloud and
make download restore the same poison.

- Keep real enc:v1 blobs unchanged on encrypt (header check, no wrap)
- Strip device-bound placeholders when applying portable sync payloads
- Guard startup local-wins / merge round-trips before upload
- Skip vault init re-encrypt writes when secrets are still undecrypted

Fixes #2702

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>

* fix(sync): strip enc:v1 secrets from smart-merge uploads

Bugbot found that after local apply sanitized placeholders, legacy v1
smart-merge could still decrypt an unstripped remote, merge it, and
re-upload the poison. Strip device-bound credentials on remote decrypt
and again on the merged payload before upload.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>

* fix(credentials): address Codex P2 review on #2702

- Always re-encrypt vault init batches so plaintext siblings are not
  left unprotected when one record is a stale enc:v1 placeholder
- Sanitize device-bound secrets before convergent restore prepare so
  CRDT replica commit matches vault import
- Require a complete safeStorage blob (header + min 31 bytes) before
  treating enc:v1 as ciphertext; encrypt header-only coincidences

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>

* fix(credentials): address Codex P1 review on #2702

- Decode enc:v1 payloads with atob in the renderer-safe domain helper
- Heal poisoned remote secrets from local/base before smart-merge so
  good credentials are not discarded as remote-only deletions
- Keep post-merge strip so leftover enc:v1 never uploads

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>

* fix(credentials): address Codex P1 CBC min and startup heal

- Accept 19-byte v10/v11 CBC OSCrypt blobs (not only 31-byte GCM)
- Heal poisoned remote secrets before startup smart-merge
- Strip unresolved placeholders on merge round-trip upload

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>

* fix(sync): sanitize payloads at every apply/commit boundary

Codex P1: vault apply stripped enc:v1 while commitRemoteInspection and
convergent materialization could keep the poison. Sanitize merged/remote
payloads before apply+base commit, strip on CRDT materialization, and
sanitize convergent apply inputs.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>

* fix(sync): heal local and remote secrets before smart-merge

Smart-merge could select a locally-changed entity whose only secret
delta was enc:v1 poison, then strip and upload empty secrets. Heal
both sides from the opposite payload/base before merge on all sync
paths.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>

* fix(sync): keep enc:v1 intact during convergent materialize

Stripping device-bound secrets inside materializeSyncPayloadFromConvergentState
broke envelope validation for poisoned-but-consistent v2 snapshots, so decrypt
could not hydrate the clouds #2702 needs to recover. Portable stripping stays
at apply/upload boundaries.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>

* fix(credentials): detect Windows DPAPI by decoded header bytes

Real DPAPI blobs start with 01 00 00 00 d0 8c... and base64-encode as
AQAAANCM..., so the previous AQAAAA string prefix rejected them and
allowed double-wrapping after key rotation. Match decoded headers in
both the main-process bridge and renderer predicate.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>

* test(credentials): use complete enc:v1 fixtures for stricter detector

Short placeholders like enc:v1:djEwAAAA no longer pass the platform
header + minimum-size checks. Update auth/SFTP/proxy/sync fixtures to
full v10-shaped blobs so npm test matches production validation.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>

* fix(sync): treat preferred credential deletions as authoritative

When healing enc:v1 before merge, an empty/missing secret on a present
preferred entity is an intentional clear and must not be revived from
base. Only fall back to base when preferred is absent or also poisoned.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>

* fix(credentials): reject impossible v10/v11 ciphertext lengths

Accept only CBC-aligned sizes (19+16n) or GCM-sized blobs (>=31) so
coincidental enc:v1 plaintext of intermediate length is encrypted instead
of treated as an undecryptable placeholder.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>

* fix(credentials): require full DPAPI provider GUID signature

Accept Windows safeStorage blobs only when they start with version
01 00 00 00 plus provider GUID df9d8cd0-1501-11d1-8c7a-00c04fc297eb,
so coincidental 01 00 00 00 prefixes are not treated as ciphertext.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>

---------

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-08-06 13:17:24 +08:00
a8bd5e0f16 fix(windows): 恢复无边框窗口圆角并修复托盘四角透明 (#2505) (#2767)
* fix(windows): restore frameless rounded corners and tray transparency (#2505)

Windows frameless windows used a solid host backgroundColor under DWM
rounding, which showed as a dark rim, while the tray popup kept the
default opaque backdrop under CSS border-radius (square tips / 冒尖).

Use a clear transparent host with native roundedCorners for app windows,
and disable OS rounding for the CSS-shaped tray overlay. Keep html/body
transparent on the tray route so corner pixels stay see-through.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>

* fix(windows): require chrome helpers outside with(ctx)

createMainWindowApi injects require from windowManager.cjs, so a relative
require inside with(ctx) resolved to electron/bridges/windowsWindowChrome.cjs
and crashed every main-window create (MODULE_NOT_FOUND). Load the helpers at
module scope in each factory so paths stay file-local.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>

* fix(windows): keep resizable app windows opaque

Codex P2: Electron documents transparent BrowserWindows as not resizable,
and enabling resizable can break them. App content windows now only set
roundedCorners: true with a solid theme backdrop; the tray overlay stays
transparent because it is already non-resizable.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>

* fix(tray): hide opaque splash on tray overlay route

Codex P2: the shared splash screen still painted an opaque full-window
fill when the tray panel showed before React mounted, recreating the
square-corner flash. Mark tray-window early, CSS-hide the splash, and
remove the splash node on DOM ready / tray render.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>

* fix(tray): defer first show until ready; keep chrome comments ASCII

Wait to show the tray BrowserWindow until did-finish-load so the shared
opaque splash cannot flash as a square underlay. Also rewrite the new
helper file header in ASCII-only English per AGENTS.md.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>

* fix(tray): cancel pending first-load show on second toggle

If the user toggles the tray again while the first renderer load is still
pending, clear trayPanelShowWhenReady so did-finish-load cannot open the
panel after an intentional dismiss.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>

* fix(tray): keep shared splash transparent by default

Move the opaque splash fill behind html:not(.tray-window) so the tray
overlay route never inherits a solid full-window background from the
shared splash markup.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>

---------

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-08-06 11:50:32 +08:00
61839acaca [Feature] System 面板增加 GPU / NPU 占用监控 (#2561) (#2768)
* feat(system): add GPU/NPU monitor tab when accelerators are detected

Probe nvidia-smi and npu-smi during System capabilities checks and show a
GPU sub-tab with utilization, memory/HBM, temperature, power, and compute
processes. Reuses the existing SSH exec + parse pattern (same approach as
open-source remote GPU monitors) without new dependencies.

Related to #2561

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>

* fix(system): address GPU monitor Bugbot findings

- Use PowerShell nvidia-smi collector for local Windows sessions
- Keep GPU tab in loading state while listAccelerators is pending
- Render compute processes even when device list is empty
- Stop treating Ascend HBM usage rate (%) as megabytes

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>

* fix(system): address Codex GPU collector review findings

- Wrap POSIX accelerator script with JSON.stringify so nested sed
  quotes cannot break outer sh -c
- Parse Ascend proc-mem whitespace-delimited table rows

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>

* fix(system): show unknown GPU util/memory as -- not 0%

ResourceBar now treats null/undefined as unavailable, and the GPU
device cards pass through null metrics instead of coalescing to 0.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>

---------

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-08-06 11:44:08 +08:00
d7b749d23f fix(sync): rehydrate follow-app theme and guard groupConfig wipes (#2764)
* fix(sync): rehydrate follow-app theme and guard partial groupConfig wipes

Second devices were keeping the local follow-app terminal theme flag after
cloud sync wrote peer appearance settings, which flickered black vs yellow.
Also block uploading an empty groupConfigs list while other vault data
remains so hydration races cannot clear startup-command group defaults.

Fixes #2757

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>

* fix(sync): narrow complete-wipe shrink guard to groupConfigs

Only treat empty groupConfigs with other vault data present as suspicious.
Wiping the last snippet/note/etc. while hosts remain stays a normal delete.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>

---------

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-08-06 11:33:18 +08:00
陈大猫 a1b338414f fix(sftp): remove serial createReadStream bulk download path (#2761)
* fix(sftp): remove serial createReadStream bulk download path

Align downloads with upload fail-closed (#2449/#2719): use concurrent
isolated/shared READ fanout only, wait for the per-session fast slot,
and never fall back to RTT-bound serial createReadStream. Keep hash
helpers on createReadStream for verification only.

* fix(sftp): harden download slot cancel and fail-closed tests

Address multi-agent review: make session fast-slot wait cancel idempotent
with abort restore, recheck cancel after acquire, and lock fail-closed /
slot-wait cancel with condition-based waiter-depth assertions.
2026-08-06 11:18:50 +08:00
陈大猫andCursor c4015b9b68 fix(windows): stop shipping broken arm64 installers from x64 CI (#2763)
Hard-coded win.target arches made pack:win-x64 also emit win-arm64 and
a universal NSIS package that can leave Netcatty.exe missing on ARM
Windows (#2570). Let CLI --x64/--arm64 control packaging instead.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-06 10:57:37 +08:00
陈大猫andCursor 8bbead1261 fix(sftp): serialize write OPENs and harden late/supersede cleanup (#2750)
* fix(sftp): keep cancel shared write OPEN drain gated on callback

Cancel settle must not force-complete sharedWriteOpenDrain — only channel
error does — so stage cleanup cannot race a late truncating OPEN after #2720.

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

* fix(sftp): bound cancel OPEN drain and unlink late truncates

Restore cancel drain force-complete so stalled shared OPENs cannot hang
the lease, and best-effort unlink late truncating "w" opens after close so
force-complete cannot leave a recreated stage orphan.

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

* fix(sftp): only unlink generated stages on cancel OPEN

Gate cancel-time truncating OPEN unlink on generatedStagePath so in-place
and symlink destinations keep their final path; cover with a late-OPEN test.

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

* fix(sftp): unlink late staged OPEN after channel-error settle

Late truncating generated-stage OPENs after settle must unlink even when
transfer.cancelled is false, so channel-error force-complete cannot leave
a recreated .netcatty-upload orphan.

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

* fix(sftp): skip late OPEN unlink when same-id retry owns stage

After shared write OPEN drain force-complete, a late truncating "w"
callback still closed and unlinked generated stages. Resumable retries
reuse `.netcatty-<transferId>.part`, so a stale callback could delete
the new attempt's stage. Gate unlink on activeTransfers ownership
(generated stages + truncating only), matching the in-place final
target guard.

* fix(sftp): only skip late OPEN unlink for reusable resume stages

Same-id retry ownership must not suppress unlink for every generated
stage path. Non-resumable uploads use unique .netcatty-upload-* paths;
a stale late OPEN on those must still clean orphans. Only deterministic
.netcatty-<transferId>.part resume stages are protected.

* fix(sftp): gate late OPEN unlink skip on actual staged path

Only suppress late generated-stage unlink when the same-id retry's
active.stagedRemote.path matches. Do not treat resumable+targetPath or
path shape alone as ownership for in-place retries.

* fix(sftp): compare late OPEN stage paths in the same representation

OPEN filePath may be a session-encoded Buffer while stagedRemote.path is the
logical string, so strict equality failed and could unlink a same-id retry
stage. Normalize via encodePathForSession / Buffer equals before ownership skip.

* fix(sftp): gate truncating shared OPEN against same-path retries

Serialize truncating shared-write OPENs per remote path so a same-id
retry cannot issue OPEN "w" while a prior attempt is still unsettled.
When a drain force-complete already released the gate and a late stale
OPEN re-truncates a retry-owned resume stage, invalidate that retry
instead of allowing a sparse promote. Keep late-OPEN unlink skip for
retry-owned stages.

* fix(sftp): bound best-effort unlink so cancel cannot hang

Shared/sudo cancel after OPEN "w" waited on unlink before finishCancel.
A dead channel that never invokes the unlink callback left the transfer
and its session lease active indefinitely. Cap unlink at the same 2s
grace used for shared-write drain force-complete.

* fix(sftp): make truncating shared OPEN path-gate waits cancelable

A same-path upload queued behind a prior truncating OPEN could keep its
lease/admission slot forever if cancelled before runOpen. Race the gate
wait with transfer.abort and release the gate + drain on cancel.

* fix(sftp): scope truncating OPEN path gates per SFTP session

A module-global path-only key serialized unrelated hosts that shared the
same remote pathname. Include transfer/sftp session identity in the gate
key so only same-endpoint opens wait on each other.

* fix(sftp): refuse stage promote after stale OPEN truncation

A late force-completed OPEN w from a prior same-id attempt can zero the
deterministic stage after concurrent write finishes. Latch
staleOpenTruncatedStage through assertCanPromote and uploadFileConcurrent
so promotion cannot rename a corrupt stage.

* fix(sftp): keep path gate chained when canceling a queued OPEN wait

Releasing a cancelled waiter's gate immediately cleared the barrier while
the prior truncating OPEN was still in flight. Chain the waiter's release
to waitForPrior so later same-path attempts stay serialized.

* fix(sftp): hold reusable-stage path gate past OPEN force-complete

Force-completing drain no longer frees the path gate for generated stages
immediately. Same-id retries stay serialized until the late OPEN settles
(or a 10s hard cap) so promotion cannot race a still-processing OPEN w.

* test(sftp): wait for generated-stage path gate hold on retry OPEN

After force-complete, generated stages keep the path gate for 10s more.
Align the late-OPEN retry test wait with that hold.

* fix(sftp): hold truncating OPEN path gates for in-place targets too

In-place shared OPEN force-complete no longer frees the path gate
immediately, and late OPEN invalidation applies to final-path retries so
a stale truncating OPEN cannot wipe an in-flight same-id rewrite.

* fix(sftp): keep path gate held until OPEN settles (no hard cap)

Remove the post-force-complete gate timeout so a different transferId
cannot reopen the same target while a stale truncating OPEN is pending.

* test(sftp): accept normal {transferId,totalBytes} retry success

startTransferNow returns transferId/totalBytes on success, not success:true.

* fix(sftp): reject path-gate waits when the SFTP channel errors

Queued same-path OPEN waiters now observe channel error while waiting on
the prior gate, so a dead session cannot pin lease/admission forever.

* fix(sftp): treat superseded OPEN as cancelled, not failed

Late OPEN rejections after a same-id retry must not broadcast a terminal
failed event that marks the live retry task failed in transfer center.

* fix(sftp): emit cancelled channel for superseded OPEN failures

Worker fan-out only maps transfer:error as cancelled for cancel messages.
Superseded attempts now use transfer:cancelled so retries are not failed.

* fix(sftp): silence superseded OPEN terminal events for live retries

When a same-id retry already owns activeTransfers, do not emit cancelled
or failed for the stale superseded attempt or the transfer center sticks.

* fix(sftp): release path gate on OPEN force-complete for dead channels

When the OPEN callback never arrives, free same-path waiters after drain
force-complete so later uploads do not hang forever on the gate.

* fix(sftp): hold path gate until OPEN settles; 30s dead-channel grace

Force-complete no longer frees the gate immediately. Late OPEN release
keeps retries serialized; a 30s grace frees dead channels.

* fix(sftp): never force-release path gate on OPEN drain force-complete

A dead-channel force-complete must free the transfer/lease, but keep the
path gate held until the OPEN callback actually settles. Force-releasing
lets a same-path retry write then get wiped by a late truncating OPEN.

* fix(sftp): skip upload fallback when OPEN is superseded by a retry

A same-id retry can already own the transfer while the old OPEN callback
still rejects. Mark that rejection noTransferFallback so we never truncate
the shared stage via prepareUploadFallbackCheckpoint under the retry.

* fix(sftp): bound late OPEN close before releasing path gate

After drain force-complete, a late truncating OPEN must still close the
handle before releasing the path gate — but a dead channel may never
invoke CLOSE. Race close with a 2s timeout so same-path uploads cannot
wait forever on pathGate.waitForPrior.

* chore: re-trigger Codex review automation

* fix(sftp): always suppress terminal events for superseded OPEN attempts

Superseded means this attempt lost ownership. Do not require the retry to
still own activeTransfers — if the retry already completed, emitting
cancelled for the same transferId rewrites a completed transfer.

* chore: re-trigger Codex for path-gate head

* chore: nudge Codex automation after cancelled run

* fix(sftp): keep SFTP leases when suppressing superseded attempts

Superseded OPEN cleanup must not release transferId leases — a same-id
retry shares that lease set entry and would lose its hold if we release.

* fix(sftp): do not return result.error for superseded transfer attempts

Renderer directory ops treat any startStreamTransfer error as failure.
Superseded same-id OPEN attempts should return a non-error marker so the
live retry is not marked failed.

* fix(sftp): bound CLOSE on pre-settle cancel OPEN path

Reuse the 2s close timeout for cancel-before-settle so a dead channel
cannot leave the path gate held after finishCancel never runs.

* fix(sftp): hold path gate until unlink callback settles

Remove the 2s force-complete on late shared OPEN unlink so a delayed
server unlink cannot race a same-id retry that already reopened the stage.

* fix(sftp): path-gate r+ resume OPENs; release only unused superseded leases

Serialize all shared write opens including resume r+. On superseded, release
only SFTP leases not also held by the live same-id retry.

* fix(sftp): serialize shared and isolated OPENs by host+path

Path gates keyed only by SFTP object let an isolated-channel retry race a
stale shared truncating OPEN on the same host path. Prefer targetHostId so
both channels wait on the same gate.

* fix(sftp): path-gate isolated writes; revalidate checkpoint after gate

Gate all write OPENs including isolated channels so a shared stale OPEN "w"
cannot race. After waiting on the path gate, re-stat the stage and shrink
the resume checkpoint (and flags) before issuing r+/w.

* fix(sftp): dynamic truncating flags; cross-id stale OPEN; bound unlink

Resolve r+/w at OPEN/unlink time so afterPathGate flag getters still unlink
generated stages on cancel. Invalidate any same-path active transfer (not only
same-id) when a late truncating OPEN lands, and force checkpoint restart when
the stage was wiped before OPEN accepted. Bound best-effort unlink so a dead
channel cannot hang cancel settle.

* fix(sftp): clear staleOpenTruncatedStage after gated full restart

When a late truncating OPEN zeros the stage while a retry waits on the path
gate, afterPathGate restarts from offset zero. Consume the stale flag there so
a successful full rewrite is not failed by the post-upload guard.

* fix(sftp): keep cancel active during post-gate checkpoint revalidation

After the path gate opens, afterPathGate may stat a remote stage. Keep the
transfer abort hook and channel-error listener live for that await so cancel
or a dead channel can still release the path gate and lease.

* fix(sftp): hold path gate for isolated write OPENs until settle

Isolated truncating OPENs previously released the host/path gate on cancel
as soon as sftp.end() was called, allowing a same-path retry to race a still
in-flight OPEN that can truncate the stage without invalidation. Keep the gate
until the OPEN callback (or a bounded force-release after end), and run late
OPEN invalidation for isolated channels too.

* fix(sftp): close late shared read OPEN handles after settle

Restores close of late shared/sudo OPEN "r" handles after cancel or channel
error settle. Write-only gate invalidation remains for isolated late OPENs.

* fix(sftp): copy host IDs onto transfer for path-gate keys

dedicatedTransferResume passes targetHostId with a new targetSftpId. Keep
those host IDs on the transfer object so sharedWriteOpenSessionKey stays
host-scoped across session replacement instead of falling back to sftpId.

* fix(sftp): do not complete directory children on superseded invoke

When a same-id retry steals ownership mid-OPEN, the old startStreamTransfer
returns { superseded: true }. Wait for the live owner's terminal task status
instead of treating that result as a successful child completion.

* fix(sftp): pass host IDs on background restart; scope stale-OPEN invalidation

Background agent resume now includes source/target host IDs so path gates stay
host-scoped after openSftpForSession. Stale OPEN invalidation requires a
matching host or sftp session instead of treating missing host as a wildcard.

* fix(sftp): do not unlink stage after same-transfer fastPut fallback

When concurrent isolated OPEN dies without a callback and upload falls back
to fastPut on the same generated stage, a late OPEN must not unlink that
stage. Skip unlink when the same transfer already accepted an OPEN, owns
stagedRemote, or moved to a fastPut/stream strategy.

* fix(sftp): narrow fastPut unlink skip; handle superseded in all callers

Only suppress late-stage unlink when the same transfer moved to
fastPut/stream — ordinary cancel still unlinks. Background restart and
dedicated resume wait for the live same-id owner instead of treating
superseded invokes as completed.

* fix(sftp): never force-release isolated write path gates

Hold host/path gates for isolated truncating OPENs until the OPEN callback
settles, matching shared-channel behavior. Force-releasing after sftp.end()
allowed a late OPEN to wipe a completed same-path destination with no live
transfer left to invalidate.

* fix(sftp): hold path gate until late unlink settles

Transfer cancel can settle after a 2s unlink grace so dead channels do not
hang invokes, but the host/path gate stays held until the unlink callback
actually fires so a delayed unlink cannot delete a same-path retry stage.

* fix(sftp): wait for live owner without fixed superseded deadline

Superseded invokes now wait only on the live same-id transfer's terminal
status (or cancel). A 30-minute timeout could mark a still-running long
transfer failed and stick that status after the owner later completed.

* fix(sftp): classify superseded before cancelled cleanup

When a cancelled attempt loses ownership to a same-id retry mid-OPEN, the
error is Transfer superseded while cancelled is still true. Handle supersede
first so cancel cleanup cannot delete the retry stage, free its leases, or
emit a terminal cancelled event for the live owner.

* fix(sftp): wait for pending write OPEN before fastPut fallback

Expose pendingWriteOpenPathGate on the transfer so non-resumable concurrent
isolated failures wait for their own truncating OPEN to settle before
fastPut rewrites the same stage.

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-06 09:16:59 +08:00
c3a7a4758b feat(settings): 支持一键清空所有会话日志 (#2738)
* feat(settings): 支持一键清空所有会话日志

在系统设置的会话日志卡片中新增清空按钮,删除保存目录下的
所有日志文件及主机子目录;操作前弹窗二次确认,完成后展示
删除/失败数量。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* fix(session-logs): restrict clear-all to known log artifacts

Clearing the session logs directory no longer recursively deletes every
top-level entry. Only files matching Netcatty auto-save / export naming
patterns and host folders that exclusively contain those artifacts are
removed, so a shared Documents/Downloads save path cannot wipe unrelated
user files (Codex P1 on #2738).

* fix(session-logs): skip active auto-save streams during clear-all

Clear-all must not rm host dirs or log files still open by
sessionLogStreamManager write streams (orphan inode / silent stop).
Expose getActiveLogPaths and preserve those targets while still
deleting inactive siblings and pure host folders.

* fix(session-logs): preserve active snapshot-only host dirs on clear-all

txt/html streams may not create a file until the first snapshot, so treat
any active log path whose parent is the host directory as live work before
removing pure session-log folders.

* fix(session-logs): preserve worker-owned active streams on clear-all

Clear-all only queried the main-process sessionLogStreamManager, so
worker-owned auto-save files were invisible and could be deleted while
live. Union active paths from a running terminal worker via
netcatty:sessionLogs:getActivePaths, and skip cold-starting a stopped
worker.

---------

Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
Co-authored-by: bincxz <16399091+binaricat@users.noreply.github.com>
2026-08-06 08:54:03 +08:00
9a3d96be76 fix(ai): improve approval command readability and cancel idle timeout on review (#2734)
* fix(#2697): automated Cursor CLI fix

* fix: address Codex review on PR #2734

* fix(ai): keep MCP absolute approval deadline and Escape reject

Canceling idle review no longer leaves MCP approvals unbounded past the
external agent tool-call window, and Enter-only key stopPropagation lets
Escape still reject from Copy/Expand controls.

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

* fix(ai): keep wrapped CLI target context on approval cards

Parse --session from netcatty-tool-cli wrappers into execution context and
still show invocation args when the pending card displays an unwrapped command.

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

* fix(ai): surface Codex reason on pending approval cards

Keep agent justification visible in the execution-context strip and still
show extra invocation args such as commandActions beside the command block.

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

* fix(ai): bound Catty approval cancel with absolute deadline

Keep Catty confirm-mode approvals from going unbounded after review:
cancelApprovalTimeout re-arms the creation absolute deadline (MCP-style)
so a late approve cannot outlive the original timeout/stream tool budget.

Also harden pending-card Enter: extract isNestedInteractiveApprovalTarget
so Copy/Expand (and other nested controls) never Approve Once via bubble.

* fix(ai): bound Codex approval cancel and unwrap CLI wrappers

cancelInteractionTimeout now re-arms the absolute creation deadline
(Catty/MCP style) instead of leaving review unbounded. Also treat
netcatty-tool-cli.cjs/.cmd as CLI wrappers so approval Command/Copy
shows the remote command after --.

* fix: address Codex review on PR #2734

* fix(ai): separate Catty approval idle timer from hard deadline

Review activity re-arms a full idle window from now instead of the original
absolute remainder, capped by a hard deadline (3x idle, max 30m). Stream
tool budgets use the hard deadline so mid-review approvals are not cut off.

* fix(ai): re-arm Catty approval idle and sync-deny hard deadline

Review interactions kept a one-shot timeoutCancelledRef, so later
cancelApprovalTimeout calls no longer re-armed idle. Remove the one-shot
and deny synchronously when the hard deadline has already elapsed so a
late approve cannot race setTimeout(0).

* fix(ai): re-arm approval idle on args-only scroll review

Pending approvals without a display command (Codex file-change/permissions,
write tools with JSON args) only show the overflow args block. Wire the same
onScroll/onWheel markReviewing path used by the command pre so continuous
wheel/trackpad review re-arms Catty and MCP/Codex idle timers.

---------

Co-authored-by: netcatty-bot <308658023+netcatty-bot@users.noreply.github.com>
Co-authored-by: bincxz <16399091+binaricat@users.noreply.github.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-06 08:53:36 +08:00
69df81613b fix(sftp): keep filter input IME-safe during CJK composition (#2746)
* fix(#2745): automated Cursor CLI fix

* fix(sftp): reset IME composing guard when filter bar closes

If the filter input unmounts mid-composition (filter bar toggled closed),
compositionend never fires, leaving filterComposingRef stuck at true and
blocking every later onSetFilter commit. Clear the guard when the bar hides.

Also drop the now-unused startTransition prop from the toolbar (its only
use was the removed deferred filter path).

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>

* fix(sftp): resync filter draft to committed filter when bar closes

Resetting only the composing guard left filterDraft diverged from pane.filter
when the bar closed mid-composition, so reopening showed stale uncommitted text.
Resync the draft to pane.filter on close.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>

* fix(sftp): clear IME composing guard in commitFilterValue

Centralize the guard reset so every commit/clear path (composition end,
Escape, inline clear button, close button) finalizes the IME session. The
inline clear previously left filterComposingRef stuck true, blocking later
onChange commits and letting a delayed compositionend undo the clear.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>

* fix(sftp): honor external filter change over stale draft on composition end

If navigation (e.g. follow-terminal-CWD) clears pane.filter mid-composition,
onCompositionEnd previously re-committed the stale composed draft, overwriting
the navigation-clear invariant. Capture pane.filter at composition start and
adopt any external change on end instead of committing the stale draft.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>

* fix(sftp): supersede IME composition when navigation clears filter

Navigation can clear pane.filter mid-CJK composition. Adopt that external
clear into the draft while composing, arm a supersede latch so the common
post-compositionend onChange cannot re-commit the stale draft, and reset
composing/supersede state when the filter bar unmounts or a commit path runs.

* fix(sftp): supersede IME filter draft when path navigates

When the committed filter was already empty before composition,
directory navigation sets filter to "" again so pane.filter does not
change. Track path at composition start and supersede the draft when
the path changes so compositionend cannot re-commit stale IME text.

* fix: address Codex review on PR #2746

* fix(sftp): clear IME supersede latch if no post-change arrives

After compositionend arms the supersede latch, schedule a macrotask
fallback clear so ordinary keystrokes work when the browser never
fires the post-composition onChange that would clear it.

* fix: use ASCII punctuation in PR comments and tests

Replace em dashes with ASCII hyphens in comments/tests. Keep the guarded
setTimeout supersede-latch clear from the prior IME fix.

---------

Co-authored-by: netcatty-bot <308658023+netcatty-bot@users.noreply.github.com>
Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
Co-authored-by: bincxz <16399091+binaricat@users.noreply.github.com>
2026-08-06 08:53:19 +08:00
583c75b107 fix(plugins): backfill sync provider bindings after schema upgrade (#2752)
* fix(plugins): backfill sync provider bindings after schema upgrade

Promote legacy sync-provider-map secrets into the host binding table on
open, and infer plugin ownership from sync-credential rows so disconnect
still cleans credentials when bindings were never written.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>

* fix(plugins): infer sync provider owners only via pluginId. namespace

Exact pluginId===providerId is not ownership for contribution provider
ids; prefer the longest pluginId. prefix when backfilling disconnect cleanup.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>

* fix(plugins): stop inferring sync owner from credential prefixes

Parent plugin ids can namespace-prefix another plugin's provider id.
Guessing ownership from sync-credential rows would let disconnect wipe
the wrong secrets. Keep host bindings and legacy map backfill as the
only ownership sources when no live contribution is present.

* fix(plugins): do not overwrite existing sync provider bindings on backfill

Legacy sync-provider-map rows from a parent plugin must not steal a
provider that already has a host binding. Skip upsert when a binding
exists; still delete the stale map secret.

* fix(plugins): pick longest owner when legacy map rows conflict

Group sync-provider-map rows by provider before binding so a shorter
parent plugin cannot win just because SQLite returned it first. Still
preserve existing host bindings and delete all leftover map secrets.

* fix(plugins): never delete secrets during sync-provider-map backfill

The map key prefix is not reserved for host internals; plugins may store
ordinary secrets under the same shape. Only seed missing host bindings
from candidate rows and leave plugin_secrets untouched.

* fix(plugins): seed sync bindings from live provider metadata

Schema-2 installs only stored sync-credential* rows. After upgrade, seed
missing provider bindings from the host contribution catalog when those
credentials exist, instead of guessing from secret key prefixes.

* fix(plugins): seed sync bindings from installed manifests, not only enabled

listProviders skips disabled/quarantined plugins. Walk installed plugin
manifests at host init so schema-2 credential rows under disabled plugins
still get provider bindings for disconnect cleanup.

* fix(plugins): seed sync bindings from all installed version manifests

Active-only listPlugins() misses providers removed/renamed on upgrade
while credentials remain under the plugin. Walk every plugin_versions
manifest so disconnect can still resolve ownership after schema upgrade.

* fix(plugins): only auto-bind single-provider plugins from credentials

Plugin-scoped sync-credential* rows are shared across providers. Binding
every historical provider would let disconnect of a stale one wipe the
active provider's secrets. Seed bindings only when exactly one sync
provider is known for that plugin.

* fix: address Codex review on PR #2752

* fix(plugins): stop resurrecting sync bindings after unbind

Consume promoted sync-provider-map markers and leave an unbound
tombstone on disconnect so constructor backfill cannot re-bind a
provider the user already disconnected.

* fix(plugins): prefer credential-backed owners for legacy map backfill

Longest sync-provider-map namespace alone could bind a nested plugin over a
parent that still owns sync-credential* secrets. Only promote candidates with
credentials; pick the longest among those, and skip when none or ambiguous.

* fix(plugins): skip ambiguous multi-credential legacy map backfill

Only auto-bind when exactly one credential-backed map candidate exists.
When parent and nested plugins both hold credentials for the same provider,
longest pluginId is not a reliable owner signal — leave unbound.

* fix(plugins): skip cross-plugin provider conflicts in live backfill

When multiple credential-backed plugins declare the same providerId via
nested namespaces, do not bind the first writer. A later missing-provider
disconnect would otherwise delete the wrong sync-credential rows.

* fix(plugins): use full installed catalog for enable-time sync backfill

Enable-time binding must see the same active+inactive manifest catalog as
startup so cross-plugin nested provider ambiguity is not missed when only
the newly enabled plugin's live providers would be passed.

* fix(plugins): count non-credential manifest claims in backfill conflicts

Live nested plugins without credentials yet must still block binding a
credential-backed legacy parent to the same providerId, or disconnect can
prefer the live owner while the stale binding remains.

* fix(plugins): skip legacy map promote on installed-manifest conflicts

Constructor-time map backfill must not bind a sole credential-backed
parent when another installed package also declares the same sync
provider, or later disconnect cannot clear the stale binding.

* fix(plugins): require single-provider maps for legacy-map promotion

A plugin with one sync-credential row and maps for multiple historical
providers must not bind all of them — disconnecting a stale provider
would delete the live credentials via the shared prefix.

* fix(plugins): count installed manifest providers in legacy-map guard

A sole stale map row must not promote when the credential owner's live
manifest also declares another sync provider under the same credentials.

* fix(plugins): backfill sync bindings before startup activation

Seed installed-manifest bindings before contributionService.initialize so
an early syncDeleteSecrets cannot miss ownership and leave orphan credentials.

* fix(plugins): seed sync bindings at host construction

Run installed-manifest backfill when the plugin host is created, before
async manager.initialize, so early syncDeleteSecrets can resolve ownership.

* fix(plugins): re-seed sync bindings after package store recovery

PackageStore.recover can add installed versions after the constructor
seed; re-run the catalog backfill when package initialize finishes.

* fix(plugins): await manager init before syncDeleteSecrets ownership

Block secret cleanup until package recovery and binding seed finish so
early disconnect cannot miss retained sync-credential rows.

* fix(plugins): re-run legacy-map backfill after package recovery

After PackageStore.recover repopulates versions, re-run legacy-map promote
so recovered manifests participate in ownership conflict checks.

* fix(plugins): defer legacy-map backfill until after package recovery

Constructor no longer promotes sole map candidates before recovered
manifests are visible. hostService re-runs backfill after package init.

* fix(plugins): defer live binding seed until after package recovery

Construction-time live-provider backfill can bind a stale owner against an
incomplete catalog before PackageStore.recover() restores plugin_versions.
Seed only after recovery; syncDeleteSecrets already awaits manager init.

---------

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
Co-authored-by: netcatty-bot <308658023+netcatty-bot@users.noreply.github.com>
2026-08-06 08:53:03 +08:00
陈大猫 ae74f25588 fix(ci): dedupe same-head @codex review requests (#2754)
* fix(ci): dedupe same-head @codex review requests

Plain maintainer/Cursor-bot @codex review comments did not plant the
cursor-external-codex marker, so own_rerequest on synchronize posted a
second request for the same SHA. That concurrent pair produced a clean
issue comment from one job and a findings review from the other.

Skip re-request when any trusted or Cursor-bot author already requested
this head via external marker, cursor-codex-head pin, or a recent plain
@codex review.

* fix(ci): only dedupe plain @codex against the current head push

Plain unpinned requests no longer skip solely by age — that blocked
re-request after a new synchronize when an older plain @codex still
looked "recent". Callers pass the head commit time as notBefore so
only post-push plain requests suppress a duplicate.

* fix(ci): use PR head-change time for plain @codex dedupe

Commit author/committer dates predate cherry-picks and rebases, so a
plain @codex for the previous head could suppress re-request after a
new synchronize. Pass pull_request.updated_at (moves with the push)
as notBefore instead.

* fix(ci): dedupe @codex only via head-pinned markers

Drop plain-unpinned timestamp heuristics — they cannot safely know which
SHA a bare @codex review targeted. Skip re-request when a trusted author
already pinned this head with cursor-codex-head or cursor-external-codex.
2026-08-05 23:40:51 +08:00
陈大猫 07c914a73d fix(ci): let maintainer @bot reclassify auto-closed issues (#2753)
* fix(ci): let maintainer @bot reclassify auto-closed issues

Author reopen disputes still stay on issue_followup when
triage:already-available/unclear remain, but an explicit maintainer
@netcatty-bot mention must re-enter issue_classify so re-triage can
run without an open bot PR.

* fix(ci): prefer maintainer @bot over author follow-up routing

When a trusted maintainer is also the issue author, route explicit
@netcatty-bot mentions as maintainer re-triage so auto-closed
already-available/unclear issues can re-enter classify.
2026-08-05 22:12:22 +08:00
a97002c0a7 fix(ci): stop reopen from replaying issue triage (#2751)
* fix(ci): stop reopen from replaying issue triage

Already-admitted issue reopen events were routed back into full classify,
so follow-up handoffs and maintainer reopens could close again or overwrite
ready-for-human with needs-info. Skip bot reopen, hand auto-closed human
reopens to maintainers, and keep already-available disputes on follow-up.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>

* fix(ci): keep ready-for-human follow-ups off classify

After reopen handoff drops triage:already-available, actionable author
replies could still be refined into classify and auto-close again. Treat
ready-for-human like the dispute signal and keep those comments on
follow-up.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>

* fix(ci): preserve already-available dispute label on handoff

Keep triage:already-available when handing reopened auto-closed issues
to humans, and only use that label (not ready-for-human) to block
reclassify. This stops the close loop without blocking feature_defer or
other ready-for-human follow-ups from re-entering classify.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>

* fix(ci): keep unclear reopen disputes off classify

Match the already-available dispute guard for triage:unclear / unclear so
human reopen handoffs of auto-closed unclear issues cannot re-enter
classify and close again on the next author follow-up.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>

* fix(ci): revalidate auto-close labels before handoff

Queued ready_for_human_handoff must not overwrite a maintainer who already
cleared triage:already-available/unclear and moved the issue (for example
to ready-for-agent). Skip the handoff when those outcome labels are gone.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>

* test(ci): cover skipped reopen handoff without auto-close labels

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>

* fix(ci): do not reopen issues already closed before handoff

Queued ready_for_human_handoff must respect a maintainer who re-closed the
issue while the job was waiting. Skip when live state is closed instead of
forcing ensureOpen.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>

* fix(ci): reclassify non-auto-close issue reopens

After a merged automation fix, cleanup leaves triage:bug-ready/admitted on
the source issue. Human reopen of those labels must re-enter classify so a
failed fix can spawn another implementation; only bot reopen stays skipped.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>

---------

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-08-05 20:47:57 +08:00
2ea5efacba fix(plugins): address residual Codex sync secret and backup gaps (#2748)
* fix(plugins): address residual Codex sync secret and backup gaps

Retain provider→plugin secret bindings so disconnect can wipe credentials
after a plugin is disabled, keep overwritten secrets on failed reconnect,
and snapshot live-empty sidecars in protective backups.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>

* fix(plugins): restore overwritten sync secrets on rejected reconnect

Stash prior plaintext and SecretRef id on overwrite, restore them when
connect fails, and discard the stash after a successful reconnect so
saved provider credentials keep resolving.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>

* fix(plugins): make sync secret overwrite stash opt-in

Only the syncPutSecret path stashes prior plaintext/SecretRef so ordinary
secrets.set overwrites do not leave credentials in process memory.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>

* fix(plugins): host-own sync provider bindings and clear failed stashes

Move provider→plugin bindings into a dedicated DB table outside
plugin-writable secrets, validate provider namespace ownership, and drop
overwrite stashes when replacement writes fail.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>

* fix(plugins): keep overwrite stash until restore write succeeds

Only drop the prior plaintext after encrypt+upsert succeeds so a failed
restore can still retry and recover the saved SecretRef.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>

* fix(plugins): preserve overwrite stash across retries and prefix deletes

Do not replace an existing restore stash on later puts, and clear matching
stash entries when deleteByKeyPrefix wipes sync credentials on disconnect.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>

---------

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-08-05 19:44:35 +08:00
9c5da74486 fix(terminal): forward Cmd+C to Kitty when there is no xterm selection (#2749)
macOS copy defaults to Cmd+C. With no terminal selection, the shortcut
handler used to consume the chord as an empty clipboard write, so nested
TUIs that expect Kitty Super+C (e.g. Herdr) never received the key.

Pass plain Meta+C through the same no-selection path as Ctrl+C, while
keeping other unbound copy chords as a safe no-op (#1461).

Closes #2728

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-08-05 19:18:57 +08:00
7bdbbe2e16 fix(sftp): pipelined sudo download READ fanout on shared channel (#2720)
* fix(#2719): automated Cursor CLI fix

* fix: address Codex review on PR #2720

* fix(sftp): settle shared OPEN cancel without ending sudo session

Codex P2 on PR #2720: disposeChannel:false made pre-OPEN abort a no-op
while openSftpHandle had no cancel reject, so cancel during a stalled
OPEN hung forever and held the SFTP lease. openSftpHandleForTransfer
rejects the pending OPEN on cancel without sftp.end() on shared channels,
closes late handles best-effort, and keeps isolated-channel end behavior.

* fix(sftp): force-settle cancelled shared download READs

Codex P2 on d855e69c: shared/sudo downloads passed forceSettleOnError
false, so cancel while an sftp.read callback never arrived hung forever
(abortChannel cannot end the shared session). Downloads have no remote
WRITEs to drain, so always force-settle on cancel/error without calling
sftp.end(). Shared uploads still wait for in-flight WRITEs.

* fix(sftp): harden sudo shared download settle and fanout gates

Address Codex P2 (channel error during stalled OPEN never settled) and
multi-agent review: keep concurrent-shared READ sudo-only with a
per-session fast slot, settle OPEN sync throws without leaking listeners,
force-settle cancel without awaiting shared CLOSE, and report stream
strategy after fallback.

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

* fix(sftp): dispose isolated channel on CLOSE timeout

Codex P2: success-path CLOSE timeout with disposeChannel:true was
non-fatal and returned the channel to the pool. Shared/sudo stays
non-fatal; isolated downloads now surface completedWithUnhealthyChannel
so the channel is ended instead of reused.

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

* fix(sftp): drain shared write OPEN before cancel cleanup

Codex P2: cancelling a pending shared upload OPEN settled immediately,
so stage cleanup could run before a late truncating OPEN recreated the
remote path. Shared write opens now wait for the OPEN callback (or a
drain timeout), close the handle, then settle.

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

* fix(sftp): keep shared write OPEN drain past cancel timeout

Codex P2: the 2s cancel settle unblocked UX but cleanup could still race
a later truncating OPEN. Publish sharedWriteOpenDrain until the OPEN
callback finishes (including late opens), and await it in upload finally
before returning to stage cleanup.

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

* fix(sftp): await shared write OPEN drain without timeout

Codex P2: the 10s drain cap still let cleanup race a slower late OPEN.
Await sharedWriteOpenDrain unbounded so stage cleanup cannot run until
the write OPEN callback has closed its handle.

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

* fix(sftp): keep shared write OPEN drain pending on channel error

Codex P2: channel error completed sharedWriteOpenDrain before the OPEN
callback, allowing cleanup/fallback to race a late truncating OPEN.
Match the cancel path — settle the wait, but release the drain only after
the OPEN callback closes any late handle.

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

* fix(sftp): force-settle stalled shared verification READs on cancel

After concurrent download ranges finish, sample verification still awaited
bare sftp.read outside forceSettleOnError. Shared/sudo cancel cannot end
the session, so a hung verification callback held the transfer and lease.

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

* fix(sftp): recheck cancel after verification sample race

A responsive last-sample READ can finish after cancel before the 2s
force-settle fires; without a post-race cancelled check the download
was reported complete.

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

* fix(sftp): retain verification channel errors across sample gaps

A shared-channel error between verification sample races was dropped
when rejectPending was null, so the next read could hang forever.

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

* fix(sftp): bound shared write OPEN drain after channel error

Channel error kept sharedWriteOpenDrain pending for a late OPEN, but a
dead channel that never invokes the callback hung the transfer and lease.

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

---------

Co-authored-by: netcatty-bot <308658023+netcatty-bot@users.noreply.github.com>
Co-authored-by: bincxz <16399091+binaricat@users.noreply.github.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-05 19:05:32 +08:00
4747c45a99 feat(ai): add in-chat jump list for long sessions (#2733)
* fix(#2679): automated Cursor CLI fix

* fix: address Codex review on PR #2733

* fix: address Codex review on PR #2733

* fix(ai): keep chat jump pin from collapsing loaded history

Preserve the rendered message window across jump release, ignore streaming isAtBottom flips, and make load-earlier advance from the effective pinned tail.

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

---------

Co-authored-by: netcatty-bot <308658023+netcatty-bot@users.noreply.github.com>
Co-authored-by: bincxz <16399091+binaricat@users.noreply.github.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-05 19:02:09 +08:00
e40aace189 fix(ci): stop green Codex fixes failing baseline name matching (#2747)
* fix(ci): stop green Codex fixes failing baseline name matching

Accept exit-0 candidates when quantitative coverage does not shrink,
even if individual TAP success titles were renamed. Keep rejecting
true coverage loss, upload baseline test logs, and surface comparison
details on rejected verification runs.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>

* fix(ci): require baseline failures to be fixed by name

Address Codex P1: a red→green candidate must keep each failing test
title as a success, so deleting the failing test and adding an unrelated
passer cannot pass the quantitative clean gate.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>

* fix(ci): keep comparator comments ASCII-only

Replace the non-ASCII arrow in the red-to-green comment so the file
stays consistent with the repo ASCII convention.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>

* fix(ci): fail closed on incomplete baseline TAP summaries

Require a complete exact-base summary before accepting a quantitative
clean candidate, so a truncated red baseline cannot be bypassed by
deleting crashing coverage.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>

* test(ci): clarify incomplete-baseline fail-closed coverage

Keep the comparator fail-closed on truncated exact-base TAP summaries
and re-request Codex review on the current head.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>

---------

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-08-05 19:00:32 +08:00
231 changed files with 25253 additions and 2725 deletions

No files matched your search

+4 -3
View File
@@ -176,9 +176,10 @@ jobs:
pack_script: pack:mac
- name: windows
os: windows-latest
# The mosh binary workflow currently produces win32-x64 only.
# Keep official packages aligned with bundled-mosh coverage
# until Cygwin arm64 is stable enough to build win32-arm64.
# Official Windows releases are x64-only for now. Bundled mosh/et
# are win32-x64, and win.target must not hard-code arm64 or the
# x64 job still emits broken win-arm64 / universal NSIS packages
# (#2570). Use pack:win-x64 until a dedicated arm64 job exists.
pack_script: pack:win-x64
env:
MOSH_BIN_RELEASE: ${{ needs.resolve-mosh.outputs.mosh_bin_release }}
+69 -5
View File
@@ -196,9 +196,16 @@ jobs:
}
if (event === 'issues') {
return set('issue_classify', {
issue_number: String(context.payload.issue.number),
reason: `issues:${context.payload.action}`,
const issue = context.payload.issue;
const decision = auto.decideIssuesEventRoute({
action: context.payload.action,
labels: issue?.labels || [],
actorLogin: context.payload.sender?.login,
botLogins: process.env.ISSUE_BOT_LOGINS,
});
return set(decision.kind, {
issue_number: String(issue.number),
reason: decision.reason,
});
}
@@ -264,6 +271,7 @@ jobs:
hasOpenBotPull: Boolean(pull),
hasOpenRelatedPull: Boolean(relatedPull),
body: comment.body,
labels,
});
if (refined.kind === 'issue_classify') {
return set('issue_classify', {
@@ -349,6 +357,41 @@ jobs:
return set('skip', { reason: `unhandled ${event}` });
ready_for_human_handoff:
name: Hand reopened issue to maintainers
needs: route
if: needs.route.outputs.kind == 'ready_for_human_handoff'
runs-on: ubuntu-latest
concurrency:
group: cursor-source-issue-${{ needs.route.outputs.issue_number || github.run_id }}
cancel-in-progress: false
permissions:
contents: read
issues: write
steps:
- name: Checkout helpers
uses: actions/checkout@v7
with:
ref: ${{ github.event.repository.default_branch }}
persist-credentials: false
- name: Apply ready-for-human handoff
uses: actions/github-script@v9
env:
ISSUE_NUMBER: ${{ needs.route.outputs.issue_number }}
TRUSTED_COMMENT_AUTHORS: ${{ vars.AUTOMATION_OWN_ACTORS || 'binaricat,netcatty-bot,github-actions[bot]' }}
with:
github-token: ${{ secrets.TRIAGE_GITHUB_TOKEN || secrets.GITHUB_TOKEN }}
script: |
const auto = require(`${process.env.GITHUB_WORKSPACE}/scripts/cursor-automation.cjs`);
await auto.applyReadyForHumanHandoff({
github,
context,
issueNumber: process.env.ISSUE_NUMBER,
dedupeMarker: auto.REOPEN_HANDOFF_MARKER,
trustedCommentAuthors: process.env.TRUSTED_COMMENT_AUTHORS,
});
cleanup_source_issue:
name: Clean source issue state
needs: route
@@ -2093,6 +2136,7 @@ jobs:
".cursor-runtime/followup-pending-snapshots.json",
".cursor-runtime/followup-verify-result.json",
".cursor-runtime/followup-test-comparison.json",
".cursor-runtime/followup-base-tests.log",
".cursor-runtime/followup-candidate-lint.log",
".cursor-runtime/followup-candidate-tests.log",
".cursor-runtime/followup-candidate-build.log",
@@ -2114,6 +2158,7 @@ jobs:
.cursor-runtime/followup-pending-snapshots.json
.cursor-runtime/followup-verify-result.json
.cursor-runtime/followup-test-comparison.json
.cursor-runtime/followup-base-tests.log
.cursor-runtime/followup-candidate-lint.log
.cursor-runtime/followup-candidate-tests.log
.cursor-runtime/followup-candidate-build.log
@@ -3379,6 +3424,7 @@ jobs:
".cursor-runtime/implement-pr-body.md",
".cursor-runtime/verify-result.json",
".cursor-runtime/test-comparison.json",
".cursor-runtime/base-tests.log",
".cursor-runtime/candidate-lint.log",
".cursor-runtime/candidate-tests.log",
".cursor-runtime/candidate-build.log",
@@ -3400,6 +3446,7 @@ jobs:
.cursor-runtime/implement-pr-body.out.md
.cursor-runtime/verify-result.json
.cursor-runtime/test-comparison.json
.cursor-runtime/base-tests.log
.cursor-runtime/candidate-lint.log
.cursor-runtime/candidate-tests.log
.cursor-runtime/candidate-build.log
@@ -3409,6 +3456,14 @@ jobs:
if: steps.patch.outputs.artifact_ready == 'true' && steps.verify.outputs.passed != 'true'
run: |
echo "Candidate patch was preserved, but candidate-specific verification failed." >&2
if [[ -f .cursor-runtime/test-comparison.json ]]; then
echo "Test comparison:" >&2
cat .cursor-runtime/test-comparison.json >&2 || true
fi
if [[ -f .cursor-runtime/verify-result.json ]]; then
echo "Verify result:" >&2
cat .cursor-runtime/verify-result.json >&2 || true
fi
exit 1
- name: Mark needs human on implement failure
@@ -4812,6 +4867,7 @@ jobs:
".cursor-runtime/fix.patch",
".cursor-runtime/fix-verify-result.json",
".cursor-runtime/fix-test-comparison.json",
".cursor-runtime/fix-base-tests.log",
".cursor-runtime/fix-candidate-lint.log",
".cursor-runtime/fix-candidate-tests.log",
".cursor-runtime/fix-candidate-build.log",
@@ -4831,6 +4887,7 @@ jobs:
.cursor-runtime/fix.patch
.cursor-runtime/fix-verify-result.json
.cursor-runtime/fix-test-comparison.json
.cursor-runtime/fix-base-tests.log
.cursor-runtime/fix-candidate-lint.log
.cursor-runtime/fix-candidate-tests.log
.cursor-runtime/fix-candidate-build.log
@@ -4840,6 +4897,14 @@ jobs:
if: steps.codex.outputs.action == 'fix' && steps.fixpatch.outputs.artifact_ready == 'true' && steps.fixverify.outputs.passed != 'true'
run: |
echo "Codex fix patch was preserved, but candidate-specific verification failed." >&2
if [[ -f .cursor-runtime/fix-test-comparison.json ]]; then
echo "Test comparison:" >&2
cat .cursor-runtime/fix-test-comparison.json >&2 || true
fi
if [[ -f .cursor-runtime/fix-verify-result.json ]]; then
echo "Verify result:" >&2
cat .cursor-runtime/fix-verify-result.json >&2 || true
fi
exit 1
- name: Mark needs human when no fix produced
@@ -5296,8 +5361,7 @@ jobs:
},
);
const forceRetry =
process.env.GITHUB_EVENT_NAME === 'workflow_dispatch';
if (
process.env.GITHUB_EVENT_NAME === 'workflow_dispatch'; if (
!forceRetry &&
auto.shouldSkipExternalCodexRerequest({
existingComments: comments,
+60 -2
View File
@@ -22,6 +22,7 @@ import {
rememberKeyPassphrase,
shouldUpdateReferenceKeyPassphrase,
} from './application/defaultKeyPassphrases';
import { isTerminalBootEpochCurrent } from './domain/terminalBootEpoch';
import { initializeFonts } from './application/state/fontStore';
import { initializeUIFonts } from './application/state/uiFontStore';
import { I18nProvider, useI18n } from './application/i18n/I18nProvider';
@@ -34,7 +35,7 @@ import { buildTelnetDeepLinkConnectionHost, buildTelnetDeepLinkEphemeralHostFrom
import { buildJmsDeepLinkEphemeralHost, isSupportedJmsProtocol, parseJmsDeepLink } from './domain/jmsDeepLink';
import { applyEphemeralHostsUpdate, splitHostsUpdateByEphemeral } from './domain/ephemeralHosts';
import { resolveHostAuth } from './domain/sshAuth';
import { isEncryptedCredentialPlaceholder } from './domain/credentials';
import { isEncryptedCredentialPlaceholder, stripSyncPayloadEncryptedCredentials } from './domain/credentials';
import {
mergeTerminalHostUpdate,
TERMINAL_THEME_AUTO,
@@ -664,7 +665,8 @@ function App({ settings }: { settings: SettingsState }) {
applyProtectedSyncPayload({
buildPreApplyPayload: () => buildCurrentSyncPayload(),
applyPayload: async () => {
await applySyncPayload(payload, {
const portable = stripSyncPayloadEncryptedCredentials(payload);
await applySyncPayload(portable, {
importVaultData: importDataFromString,
importPortForwardingRules,
onSettingsApplied: settings.rehydrateAllFromStorage,
@@ -826,12 +828,28 @@ function App({ settings }: { settings: SettingsState }) {
const unsubscribe = bridge.onPassphraseRequest(async (request) => {
console.log('[App] Passphrase request received:', request);
const isRequestCurrent = () => (
typeof request.sessionId !== "string"
|| isTerminalBootEpochCurrent(request.sessionId, request.bootEpoch)
);
// Disconnect/reconnect may leave a late passphrase prompt for a
// superseded boot; reject epoch-tagged requests that are no longer current.
if (!isRequestCurrent()) {
void bridge.respondPassphrase?.(request.requestId, "", true);
return;
}
// If the bridge already tried a passphrase and it was wrong, skip auto-respond
if (!request.passphraseInvalid) {
// Check if a reference key exists for this path — use its passphrase
const currentKeys = keysRef.current;
const refKey = currentKeys.find((k: SSHKey) => k.source === 'reference' && k.filePath === request.keyPath);
if (refKey?.passphrase && refKey.savePassphrase !== false && !isEncryptedCredentialPlaceholder(refKey.passphrase)) {
if (!isRequestCurrent()) {
void bridge.respondPassphrase?.(request.requestId, "", true);
return;
}
console.log('[App] Auto-responding with reference key passphrase for:', request.keyPath);
void bridge.respondPassphrase?.(request.requestId, refKey.passphrase, false);
return;
@@ -839,6 +857,10 @@ function App({ settings }: { settings: SettingsState }) {
// Fallback: try old storage for passphrase
const saved = await loadDefaultKeyPassphrase(request.keyPath);
if (!isRequestCurrent()) {
void bridge.respondPassphrase?.(request.requestId, "", true);
return;
}
if (saved) {
console.log('[App] Auto-responding with saved passphrase for:', request.keyPath);
// Migrate to reference key if one exists
@@ -856,18 +878,29 @@ function App({ settings }: { settings: SettingsState }) {
} catch (err) {
console.warn('[App] Failed to migrate passphrase to reference key:', err);
}
if (!isRequestCurrent()) {
void bridge.respondPassphrase?.(request.requestId, "", true);
return;
}
}
void bridge.respondPassphrase?.(request.requestId, saved, false);
return;
}
}
if (!isRequestCurrent()) {
void bridge.respondPassphrase?.(request.requestId, "", true);
return;
}
// No saved passphrase or it was invalid, show modal
setPassphraseQueue(prev => [...prev, {
requestId: request.requestId,
keyPath: request.keyPath,
keyName: request.keyName,
hostname: request.hostname,
sessionId: request.sessionId,
bootEpoch: request.bootEpoch,
}]);
});
@@ -876,6 +909,31 @@ function App({ settings }: { settings: SettingsState }) {
};
}, [updateKeys]);
// Drop queued passphrase prompts when a terminal disconnects mid-boot.
useEffect(() => {
const onDisconnected = (event: Event) => {
const detail = (event as CustomEvent<{ sessionId?: string }>).detail;
const disconnectedSessionId = detail?.sessionId;
if (!disconnectedSessionId) return;
const bridge = netcattyBridge.get();
setPassphraseQueue((prev) => {
const keep: typeof prev = [];
for (const item of prev) {
if (item.sessionId === disconnectedSessionId) {
void bridge?.respondPassphrase?.(item.requestId, "", true);
continue;
}
keep.push(item);
}
return keep;
});
};
window.addEventListener("netcatty:terminal-session-disconnected", onDisconnected);
return () => {
window.removeEventListener("netcatty:terminal-session-disconnected", onDisconnected);
};
}, []);
// Handle passphrase submit
const handlePassphraseSubmit = useCallback(async (requestId: string, passphrase: string, remember: boolean) => { return handlePassphraseSubmitImpl(() => ({ keysRef, netcattyBridge, passphrase, passphraseQueue, remember, rememberKeyPassphrase, requestId, setPassphraseQueue, updateKeys }), requestId, passphrase, remember); }, [passphraseQueue, updateKeys]);
-4
View File
@@ -464,8 +464,6 @@ function AppViewInner({ domains }: AppViewProps) {
identities={identities}
snippets={snippets}
snippetPackages={snippetPackages}
notes={notes}
noteGroups={noteGroups}
sessions={sessions}
workspaces={workspaces}
knownHosts={effectiveKnownHosts}
@@ -529,8 +527,6 @@ function AppViewInner({ domains }: AppViewProps) {
updateHosts={updateTerminalHosts}
updateSnippets={updateSnippets}
updateSnippetPackages={updateSnippetPackages}
updateNotes={updateNotes}
updateNoteGroups={updateNoteGroups}
sftpDefaultViewMode={sftpDefaultViewMode}
sftpDoubleClickBehavior={sftpDoubleClickBehavior}
sftpAutoSync={sftpAutoSync}
@@ -5,6 +5,10 @@ import {
removeKeyboardInteractiveRequest,
shouldQueueKeyboardInteractiveRequest,
} from "./useAppStartupEffects.ts";
import {
clearTerminalBootEpoch,
setTerminalBootEpoch,
} from "../../domain/terminalBootEpoch.ts";
const sessions = [{ id: "terminal-1" }, { id: "terminal-2" }];
@@ -19,6 +23,42 @@ test("terminal-scoped keyboard-interactive requests are limited to owned session
);
});
test("disconnected terminal sessions do not queue keyboard-interactive prompts", () => {
assert.equal(
shouldQueueKeyboardInteractiveRequest(
{ scope: "terminal", sessionId: "terminal-1" },
[{ id: "terminal-1", status: "disconnected" }],
),
false,
);
assert.equal(
shouldQueueKeyboardInteractiveRequest(
{ scope: "terminal", sessionId: "terminal-1" },
[{ id: "terminal-1", status: "connecting" }],
),
true,
);
});
test("superseded terminal boot epochs do not queue keyboard-interactive prompts", () => {
setTerminalBootEpoch("terminal-1", 3);
assert.equal(
shouldQueueKeyboardInteractiveRequest(
{ scope: "terminal", sessionId: "terminal-1", bootEpoch: 1 },
[{ id: "terminal-1", status: "connecting" }],
),
false,
);
assert.equal(
shouldQueueKeyboardInteractiveRequest(
{ scope: "terminal", sessionId: "terminal-1", bootEpoch: 3 },
[{ id: "terminal-1", status: "connecting" }],
),
true,
);
clearTerminalBootEpoch("terminal-1");
});
test("external keyboard-interactive requests are not filtered by terminal session ids", () => {
assert.equal(
shouldQueueKeyboardInteractiveRequest({ scope: "external", sessionId: "sftp-conn-1" }, sessions),
+58 -4
View File
@@ -11,6 +11,7 @@ import { getSftpTransferResourceKeys, globalSftpTransferScheduler } from '../sta
import { hasNewSourceFingerprint } from '../state/sftp/transferProgressMetadata';
import { STORAGE_KEY_SFTP_TRANSFER_CONCURRENCY } from '../../infrastructure/config/storageKeys';
import type { TransferTask } from '../../domain/models';
import { isTerminalBootEpochCurrent } from '../../domain/terminalBootEpoch';
import {
canApplyDedicatedResumeProgress,
createDedicatedResumeChildUpdateBatcher,
@@ -24,8 +25,10 @@ type KeyboardInteractiveRequestLike = {
scope?: KeyboardInteractiveScope;
sessionId?: string;
hostId?: string;
requestId?: string;
bootEpoch?: number;
};
type SessionIdLike = { id: string; hostId?: string; hostname?: string };
type SessionIdLike = { id: string; hostId?: string; hostname?: string; status?: string };
type KeyboardInteractiveQueueItem = { requestId: string };
export function shouldQueueKeyboardInteractiveRequest(
@@ -34,7 +37,14 @@ export function shouldQueueKeyboardInteractiveRequest(
): boolean {
if (request.scope !== "terminal") return true;
if (!request.sessionId) return false;
return sessions.some((session) => session.id === request.sessionId);
const session = sessions.find((entry) => entry.id === request.sessionId);
if (!session) return false;
// Status-bar disconnect keeps the tab; do not queue MFA for aborted panes.
if (session.status === "disconnected") return false;
// After disconnect → reconnect the tab is connecting again; reject MFA from
// a superseded SSH start that still shares this sessionId.
if (!isTerminalBootEpochCurrent(request.sessionId, request.bootEpoch)) return false;
return true;
}
export function removeKeyboardInteractiveRequest<T extends KeyboardInteractiveQueueItem>(
@@ -409,6 +419,9 @@ export function useAppStartupEffects(ctx: StartupEffectsContext) {
targetType: task.direction === "download" ? "local" : "sftp",
sourceSftpId: task.direction === "download" ? sftpId : undefined,
targetSftpId: task.direction === "upload" ? sftpId : undefined,
// Keep host-scoped path gates across session reopen (Codex P1).
sourceHostId: task.sourceHostId,
targetHostId: task.targetHostId,
totalBytes: task.totalBytes,
resumable: task.resumable !== false,
checkpointBytes,
@@ -420,7 +433,30 @@ export function useAppStartupEffects(ctx: StartupEffectsContext) {
});
},
);
if (result?.cancelled || result?.error === "Transfer cancelled") {
// Same-id retry stole ownership; wait for the live owner's terminal
// status instead of treating this invoke as completed (Codex P2).
if (result?.superseded === true) {
// Wait for live owner terminal status only (no fixed deadline).
for (;;) {
const latest = sftpTransferCenterStore.getSnapshot().tasks.find((candidate) => candidate.id === task.id);
const status = latest?.status;
if (status === "completed" || status === "cancelled" || status === "failed") {
if (status === "failed") {
throw new Error(latest?.error || "Transfer failed");
}
if (status === "cancelled") {
sftpTransferCenterStore.ingestBackgroundEvent({
type: "cancelled",
transferId: task.id,
endedAt: Date.now(),
});
}
// completed: events already applied; cancelled handled above.
break;
}
await new Promise((resolve) => setTimeout(resolve, 200));
}
} else if (result?.cancelled || result?.error === "Transfer cancelled") {
sftpTransferCenterStore.ingestBackgroundEvent({ type: "cancelled", transferId: task.id, endedAt: Date.now() });
} else if (result?.error) {
throw new Error(result.error);
@@ -493,7 +529,12 @@ export function useAppStartupEffects(ctx: StartupEffectsContext) {
if (!bridge?.onKeyboardInteractive) return;
const unsubscribe = bridge.onKeyboardInteractive((request) => {
if (!shouldQueueKeyboardInteractiveRequest(request, sessionsRef.current)) return;
if (!shouldQueueKeyboardInteractiveRequest(request, sessionsRef.current)) {
if (request.scope === "terminal" && request.requestId) {
void bridge.respondKeyboardInteractive?.(request.requestId, [], true);
}
return;
}
console.log('[App] Keyboard-interactive request received:', request);
// Add to queue instead of replacing - supports multiple concurrent sessions
setKeyboardInteractiveQueue(prev => [...prev, {
@@ -511,10 +552,23 @@ export function useAppStartupEffects(ctx: StartupEffectsContext) {
const unsubscribeCancelled = bridge.onKeyboardInteractiveCancelled?.((event) => {
setKeyboardInteractiveQueue(prev => removeKeyboardInteractiveRequest(prev, event.requestId));
});
const onTerminalDisconnected = (event: Event) => {
const sessionId = (event as CustomEvent<{ sessionId?: string }>).detail?.sessionId;
if (!sessionId) return;
setKeyboardInteractiveQueue((prev) => {
const doomed = prev.filter((request) => request.sessionId === sessionId);
for (const request of doomed) {
void bridge.respondKeyboardInteractive?.(request.requestId, [], true);
}
return prev.filter((request) => request.sessionId !== sessionId);
});
};
window.addEventListener("netcatty:terminal-session-disconnected", onTerminalDisconnected);
return () => {
unsubscribe?.();
unsubscribeCancelled?.();
window.removeEventListener("netcatty:terminal-session-disconnected", onTerminalDisconnected);
};
}, [enabled, setKeyboardInteractiveQueue]);
+10
View File
@@ -404,6 +404,14 @@ export const enAiMessages: Messages = {
'ai.chat.reject': 'Reject',
'ai.chat.toolLabel': 'Tool',
'ai.chat.targetLabel': 'Target',
'ai.chat.rawCommand': 'Command',
'ai.chat.copyCommand': 'Copy',
'ai.chat.commandCopied': 'Copied',
'ai.chat.approvalSession': 'Session',
'ai.chat.approvalShell': 'Shell',
'ai.chat.approvalCwd': 'Cwd',
'ai.chat.approvalReason': 'Reason',
'ai.chat.approvalInvocation': 'Invocation',
'ai.chat.permissionRequired': 'Permission Required',
'ai.chat.permissionDescription': 'The AI agent wants to execute a tool call that requires your approval.',
'ai.chat.commandBlocked': 'This command is blocked by your security policy and cannot be executed.',
@@ -443,6 +451,8 @@ export const enAiMessages: Messages = {
'ai.chat.newChat': 'New Chat',
'ai.chat.allSessions': 'All Sessions',
'ai.chat.loadEarlierMessages': 'Load earlier messages ({n} more)',
'ai.chat.jumpNav': 'Jump to message',
'ai.chat.jumpUntitled': '(empty message)',
'ai.chat.usedTools': 'Tools used: {n}',
'ai.chat.loadMoreSessions': 'Load more sessions ({n} more)',
'ai.chat.noSessions': 'No previous sessions',
+3
View File
@@ -197,6 +197,9 @@ export const enCoreMessages: Messages = {
'settings.sessionLogs.timestamps': 'Add timestamps',
'settings.sessionLogs.timestampsDesc': 'Prefix each line in plain text and HTML logs with the local time.',
'settings.sessionLogs.hint': 'Session logs capture all terminal output for troubleshooting and auditing purposes.',
'settings.sessionLogs.clearAll': 'Clear all logs',
'settings.sessionLogs.clearAllDesc': 'Delete all saved session log files and host subdirectories from the save directory.',
'settings.sessionLogs.clearConfirm': 'This will permanently delete all session log files in the save directory. This cannot be undone. Continue?',
// Settings > SSH Debug Logs
'settings.sshDebugLogs.title': 'SSH Debug Logs',
@@ -10,6 +10,7 @@ export const enSystemManagerMessages: Messages = {
'systemManager.tabs.processes': 'Processes',
'systemManager.tabs.tmux': 'tmux',
'systemManager.tabs.docker': 'Docker',
'systemManager.tabs.gpu': 'GPU',
'systemManager.popup.loading': 'Opening terminal…',
'systemManager.popup.startupFailed': 'The startup command did not complete successfully. Check that the target is still available and try again.',
@@ -23,6 +24,7 @@ export const enSystemManagerMessages: Messages = {
'systemManager.errors.loadDockerStats': 'Failed to load container stats',
'systemManager.errors.loadDockerImages': 'Failed to load images',
'systemManager.errors.loadOverview': 'Failed to load system overview',
'systemManager.errors.loadGpu': 'Failed to load GPU / NPU stats',
'systemManager.errors.sshChannelUnavailable': 'The server refused to open a new execution channel. Try again later, or reconnect this host.',
'systemManager.overview.empty': 'No system overview data yet.',
@@ -187,6 +189,23 @@ export const enSystemManagerMessages: Messages = {
'systemManager.docker.start': 'Start',
'systemManager.docker.stop': 'Stop',
'systemManager.gpu.unavailable': 'No NVIDIA GPU or Ascend NPU tools detected on this host.',
'systemManager.gpu.loading': 'Loading accelerator stats…',
'systemManager.gpu.empty': 'Accelerator tools are present, but no devices were reported.',
'systemManager.gpu.meta': '{{devices}} device(s) · {{processes}} process(es)',
'systemManager.gpu.devices': 'Devices',
'systemManager.gpu.processes': 'Compute processes',
'systemManager.gpu.noProcesses': 'No compute processes reported.',
'systemManager.gpu.vendor.nvidia': 'NVIDIA',
'systemManager.gpu.vendor.ascend': 'Ascend',
'systemManager.gpu.util': 'Util',
'systemManager.gpu.memory': 'VRAM',
'systemManager.gpu.hbm': 'HBM',
'systemManager.gpu.temperature': 'Temperature',
'systemManager.gpu.power': 'Power',
'systemManager.gpu.fan': 'Fan {{value}}%',
'systemManager.gpu.driver': 'Driver {{version}}',
'systemManager.inspect.status': 'Status',
'systemManager.inspect.image': 'Image',
'systemManager.inspect.created': 'Created',
+4
View File
@@ -82,6 +82,10 @@ export const enTerminalMessages: Messages = {
'terminal.statusbar.copyHostname.tooltip': 'Copy host address ({hostname})',
'terminal.statusbar.copyHostname.toast': 'Copied host address: {hostname}',
'terminal.statusbar.copyHostname.error': 'Failed to copy host address to clipboard',
'terminal.statusbar.disconnect.label': 'Disconnect',
'terminal.statusbar.disconnect.tooltip': 'Disconnect this session without closing the tab',
'terminal.statusbar.reconnect.label': 'Reconnect',
'terminal.statusbar.reconnect.tooltip': 'Reconnect this session',
'terminal.serverStats.cpu': 'CPU Usage',
'terminal.serverStats.cpuCores': 'CPU Core Usage',
'terminal.serverStats.memory': 'Memory Usage',
+5
View File
@@ -268,6 +268,8 @@ export const enVaultMessages: Messages = {
'sftp.transfers.calculatingTotal': 'Calculating total size...',
'sftp.transfers.filesCount': '{count} files',
'sftp.transfers.filesProgress': '{current}/{total} files',
// Progressive folder walk: total is "found so far", not a fixed grand total.
'sftp.transfers.filesDiscoveredProgress': '{completed} done · {discovered} found',
'sftp.transfers.expandChildren': 'Show files',
'sftp.transfers.collapseChildren': 'Hide files',
'sftp.transfers.expandChildList': 'Show detail',
@@ -560,6 +562,9 @@ export const enVaultMessages: Messages = {
'settings.sftp.compressedUpload.enable': 'Enable folder compression',
'settings.sftp.compressedUpload.enableDesc': 'Automatically compress folders using tar before transfer. Requires tar support on the server. Falls back to regular transfer if not available.',
'settings.sftp.skipUnchanged.enable': 'Skip unchanged files',
'settings.sftp.skipUnchanged.enableDesc': 'Skip files whose size and modification time already match the destination (rsync-style). Disabled when replacing a folder into a staging path.',
// Quick Switcher
'qs.search.placeholder': 'Search hosts or tabs',
'qs.jumpTo': 'Jump To',
+10
View File
@@ -356,6 +356,14 @@ export const ruAiMessages: Messages = {
'ai.chat.reject': 'Отклонить',
'ai.chat.toolLabel': 'Инструмент',
'ai.chat.targetLabel': 'Цель',
'ai.chat.rawCommand': 'Команда',
'ai.chat.copyCommand': 'Копировать',
'ai.chat.commandCopied': 'Скопировано',
'ai.chat.approvalSession': 'Сессия',
'ai.chat.approvalShell': 'Shell',
'ai.chat.approvalCwd': 'Каталог',
'ai.chat.approvalReason': 'Причина',
'ai.chat.approvalInvocation': 'Вызов',
'ai.chat.permissionRequired': 'Требуется разрешение',
'ai.chat.permissionDescription': 'AI-агент хочет выполнить вызов инструмента, для которого требуется ваше одобрение.',
'ai.chat.commandBlocked': 'Эта команда заблокирована вашей политикой безопасности и не может быть выполнена.',
@@ -395,6 +403,8 @@ export const ruAiMessages: Messages = {
'ai.chat.newChat': 'Новый чат',
'ai.chat.allSessions': 'Все сессии',
'ai.chat.loadEarlierMessages': 'Загрузить более ранние сообщения (ещё {n})',
'ai.chat.jumpNav': 'Перейти к сообщению',
'ai.chat.jumpUntitled': '(пустое сообщение)',
'ai.chat.usedTools': 'Использовано инструментов: {n}',
'ai.chat.loadMoreSessions': 'Загрузить больше сессий (ещё {n})',
'ai.chat.noSessions': 'Предыдущих сессий нет',
+3
View File
@@ -185,6 +185,9 @@ export const ruCoreMessages: Messages = {
'settings.sessionLogs.timestamps': 'Добавлять метки времени',
'settings.sessionLogs.timestampsDesc': 'Добавлять локальное время в начало каждой строки в текстовых и HTML-журналах.',
'settings.sessionLogs.hint': 'Журналы сессий сохраняют весь вывод терминала для диагностики и аудита.',
'settings.sessionLogs.clearAll': 'Очистить все журналы',
'settings.sessionLogs.clearAllDesc': 'Удалить все сохранённые файлы журналов сессий и вложенные папки хостов из папки сохранения.',
'settings.sessionLogs.clearConfirm': 'Это приведёт к безвозвратному удалению всех файлов журналов сессий в папке сохранения. Это действие нельзя отменить. Продолжить?',
// Settings > SSH Debug Logs
'settings.sshDebugLogs.title': 'Отладочные журналы SSH',
@@ -10,6 +10,7 @@ export const ruSystemManagerMessages: Messages = {
'systemManager.tabs.processes': 'Процессы',
'systemManager.tabs.tmux': 'tmux',
'systemManager.tabs.docker': 'Docker',
'systemManager.tabs.gpu': 'GPU',
'systemManager.popup.loading': 'Открытие терминала…',
'systemManager.popup.startupFailed': 'Команда запуска не была выполнена успешно. Проверьте, что цель доступна, и повторите попытку.',
@@ -23,6 +24,7 @@ export const ruSystemManagerMessages: Messages = {
'systemManager.errors.loadDockerStats': 'Не удалось загрузить статистику контейнеров',
'systemManager.errors.loadDockerImages': 'Не удалось загрузить образы',
'systemManager.errors.loadOverview': 'Не удалось загрузить обзор системы',
'systemManager.errors.loadGpu': 'Не удалось загрузить статистику GPU / NPU',
'systemManager.errors.sshChannelUnavailable': 'Сервер отказался открыть новый канал выполнения. Повторите попытку позже или переподключите этот хост.',
'systemManager.overview.empty': 'Данных обзора пока нет.',
@@ -187,6 +189,23 @@ export const ruSystemManagerMessages: Messages = {
'systemManager.docker.start': 'Запустить',
'systemManager.docker.stop': 'Остановить',
'systemManager.gpu.unavailable': 'На этом хосте не обнаружены инструменты NVIDIA GPU или Ascend NPU.',
'systemManager.gpu.loading': 'Загрузка статистики ускорителей…',
'systemManager.gpu.empty': 'Инструменты ускорителей найдены, но устройства не сообщены.',
'systemManager.gpu.meta': '{{devices}} устройств · {{processes}} процессов',
'systemManager.gpu.devices': 'Устройства',
'systemManager.gpu.processes': 'Вычислительные процессы',
'systemManager.gpu.noProcesses': 'Вычислительные процессы не сообщены.',
'systemManager.gpu.vendor.nvidia': 'NVIDIA',
'systemManager.gpu.vendor.ascend': 'Ascend',
'systemManager.gpu.util': 'Загрузка',
'systemManager.gpu.memory': 'VRAM',
'systemManager.gpu.hbm': 'HBM',
'systemManager.gpu.temperature': 'Температура',
'systemManager.gpu.power': 'Питание',
'systemManager.gpu.fan': 'Вентилятор {{value}}%',
'systemManager.gpu.driver': 'Драйвер {{version}}',
'systemManager.inspect.status': 'Статус',
'systemManager.inspect.image': 'Образ',
'systemManager.inspect.created': 'Создан',
+4
View File
@@ -103,6 +103,10 @@ export const ruTerminalMessages: Messages = {
'terminal.statusbar.copyHostname.tooltip': 'Копировать адрес хоста ({hostname})',
'terminal.statusbar.copyHostname.toast': 'Адрес хоста скопирован: {hostname}',
'terminal.statusbar.copyHostname.error': 'Не удалось скопировать адрес хоста в буфер обмена',
'terminal.statusbar.disconnect.label': 'Отключить',
'terminal.statusbar.disconnect.tooltip': 'Отключить сессию, не закрывая вкладку',
'terminal.statusbar.reconnect.label': 'Переподключиться',
'terminal.statusbar.reconnect.tooltip': 'Переподключить эту сессию',
'terminal.serverStats.cpu': 'Использование CPU',
'terminal.serverStats.cpuCores': 'Использование ядер CPU',
'terminal.serverStats.memory': 'Использование памяти',
+4
View File
@@ -305,6 +305,7 @@ export const ruVaultMessages: Messages = {
'sftp.transfers.calculatingTotal': 'Вычисление общего размера...',
'sftp.transfers.filesCount': '{count} файл(ов)',
'sftp.transfers.filesProgress': '{current}/{total} файл(ов)',
'sftp.transfers.filesDiscoveredProgress': '{completed} готово · {discovered} найдено',
'sftp.transfers.expandChildren': 'Показать файлы',
'sftp.transfers.collapseChildren': 'Скрыть файлы',
'sftp.transfers.expandChildList': 'Показать детали',
@@ -597,6 +598,9 @@ export const ruVaultMessages: Messages = {
'settings.sftp.compressedUpload.enable': 'Включить сжатие папок',
'settings.sftp.compressedUpload.enableDesc': 'Автоматически сжимать папки с помощью tar перед передачей. Требует поддержки tar на сервере. Если она недоступна, будет использована обычная передача.',
'settings.sftp.skipUnchanged.enable': 'Пропускать неизменённые файлы',
'settings.sftp.skipUnchanged.enableDesc': 'Пропускать файлы, у которых размер и время изменения уже совпадают с назначением (в стиле rsync). Отключается при замене папки во временный путь.',
// Quick Switcher
'qs.search.placeholder': 'Поиск хостов или вкладок',
'qs.jumpTo': 'Перейти к',
+10
View File
@@ -404,6 +404,14 @@ export const zhCNAiMessages: Messages = {
'ai.chat.reject': '拒绝',
'ai.chat.toolLabel': '工具',
'ai.chat.targetLabel': '目标',
'ai.chat.rawCommand': '命令',
'ai.chat.copyCommand': '复制',
'ai.chat.commandCopied': '已复制',
'ai.chat.approvalSession': '会话',
'ai.chat.approvalShell': 'Shell',
'ai.chat.approvalCwd': '目录',
'ai.chat.approvalReason': '原因',
'ai.chat.approvalInvocation': '调用详情',
'ai.chat.permissionRequired': '需要权限',
'ai.chat.permissionDescription': 'AI Agent 希望执行一个需要你批准的工具调用。',
'ai.chat.commandBlocked': '此命令已被安全策略拦截,无法执行。',
@@ -443,6 +451,8 @@ export const zhCNAiMessages: Messages = {
'ai.chat.newChat': '新对话',
'ai.chat.allSessions': '所有会话',
'ai.chat.loadEarlierMessages': '加载更早的消息(还有 {n} 条)',
'ai.chat.jumpNav': '跳转到消息',
'ai.chat.jumpUntitled': '(空消息)',
'ai.chat.usedTools': '已使用 {n} 个工具',
'ai.chat.loadMoreSessions': '加载更多会话(还有 {n} 条)',
'ai.chat.noSessions': '没有历史会话',
+4
View File
@@ -194,6 +194,9 @@ export const zhCNCoreMessages: Messages = {
'settings.sessionLogs.timestamps': '添加时间戳',
'settings.sessionLogs.timestampsDesc': '为纯文本和 HTML 日志的每一行添加本地时间。',
'settings.sessionLogs.hint': '会话日志用于记录终端输出,便于故障排查和审计。',
'settings.sessionLogs.clearAll': '清空所有日志',
'settings.sessionLogs.clearAllDesc': '删除保存目录中所有会话日志文件及主机子目录。',
'settings.sessionLogs.clearConfirm': '此操作将永久删除保存目录中的所有会话日志文件,且无法撤销,是否继续?',
// Settings > SSH Debug Logs
'settings.sshDebugLogs.title': 'SSH 调试日志',
@@ -860,6 +863,7 @@ export const zhCNCoreMessages: Messages = {
'sftp.transfers.calculatingTotal': '正在统计总大小...',
'sftp.transfers.filesCount': '{count} 个文件',
'sftp.transfers.filesProgress': '{current}/{total} 个文件',
'sftp.transfers.filesDiscoveredProgress': '已完成 {completed} · 已发现 {discovered}',
'sftp.transfers.expandChildren': '展开文件',
'sftp.transfers.collapseChildren': '收起文件',
'sftp.transfers.expandChildList': '展开详情',
@@ -10,6 +10,7 @@ export const zhCnSystemManagerMessages: Messages = {
'systemManager.tabs.processes': '进程',
'systemManager.tabs.tmux': 'tmux',
'systemManager.tabs.docker': 'Docker',
'systemManager.tabs.gpu': 'GPU',
'systemManager.popup.loading': '正在打开终端…',
'systemManager.popup.startupFailed': '启动命令未成功。请确认目标仍然可用后重试。',
@@ -23,6 +24,7 @@ export const zhCnSystemManagerMessages: Messages = {
'systemManager.errors.loadDockerStats': '加载容器性能数据失败',
'systemManager.errors.loadDockerImages': '加载镜像列表失败',
'systemManager.errors.loadOverview': '加载系统概览失败',
'systemManager.errors.loadGpu': '加载 GPU / NPU 状态失败',
'systemManager.errors.sshChannelUnavailable': '服务器拒绝打开新的执行通道。请稍后重试,或重新连接当前主机。',
'systemManager.overview.empty': '暂无系统概览数据。',
@@ -187,6 +189,23 @@ export const zhCnSystemManagerMessages: Messages = {
'systemManager.docker.start': '启动',
'systemManager.docker.stop': '停止',
'systemManager.gpu.unavailable': '此主机未检测到 NVIDIA GPU 或昇腾 NPU 工具。',
'systemManager.gpu.loading': '正在加载加速卡状态…',
'systemManager.gpu.empty': '已检测到加速卡工具,但未返回设备信息。',
'systemManager.gpu.meta': '{{devices}} 张卡 · {{processes}} 个进程',
'systemManager.gpu.devices': '设备',
'systemManager.gpu.processes': '计算进程',
'systemManager.gpu.noProcesses': '当前没有计算进程。',
'systemManager.gpu.vendor.nvidia': 'NVIDIA',
'systemManager.gpu.vendor.ascend': '昇腾',
'systemManager.gpu.util': '利用率',
'systemManager.gpu.memory': '显存',
'systemManager.gpu.hbm': 'HBM',
'systemManager.gpu.temperature': '温度',
'systemManager.gpu.power': '功耗',
'systemManager.gpu.fan': '风扇 {{value}}%',
'systemManager.gpu.driver': '驱动 {{version}}',
'systemManager.inspect.status': '状态',
'systemManager.inspect.image': '镜像',
'systemManager.inspect.created': '创建时间',
@@ -206,6 +206,9 @@ export const zhCNTerminalMessages: Messages = {
'settings.sftp.compressedUpload.enable': '启用文件夹压缩',
'settings.sftp.compressedUpload.enableDesc': '自动使用 tar 压缩文件夹后再传输。需要服务器支持 tar 命令,不支持时自动回退到普通传输。',
'settings.sftp.skipUnchanged.enable': '跳过未改文件',
'settings.sftp.skipUnchanged.enableDesc': '当目标文件的大小与修改时间已与源一致时跳过传输(类似 rsync)。替换到暂存路径时自动禁用。',
// Settings > Terminal
'settings.terminal.section.theme': '终端主题',
'settings.terminal.themeModal.title': '选择主题',
+4
View File
@@ -427,6 +427,10 @@ export const zhCNVaultMessages: Messages = {
'terminal.statusbar.copyHostname.tooltip': '复制主机地址({hostname})',
'terminal.statusbar.copyHostname.toast': '已复制主机地址:{hostname}',
'terminal.statusbar.copyHostname.error': '复制主机地址失败',
'terminal.statusbar.disconnect.label': '断开连接',
'terminal.statusbar.disconnect.tooltip': '断开当前会话,但保留标签页',
'terminal.statusbar.reconnect.label': '重新连接',
'terminal.statusbar.reconnect.tooltip': '重新连接当前会话',
'terminal.serverStats.cpu': 'CPU 使用率',
'terminal.serverStats.cpuCores': 'CPU 核心使用率',
'terminal.serverStats.memory': '内存使用',
+10
View File
@@ -403,6 +403,14 @@ export const zhTWAiMessages: Messages = {
'ai.chat.reject': '拒絕',
'ai.chat.toolLabel': '工具',
'ai.chat.targetLabel': '目標',
'ai.chat.rawCommand': '指令',
'ai.chat.copyCommand': '複製',
'ai.chat.commandCopied': '已複製',
'ai.chat.approvalSession': '工作階段',
'ai.chat.approvalShell': 'Shell',
'ai.chat.approvalCwd': '目錄',
'ai.chat.approvalReason': '原因',
'ai.chat.approvalInvocation': '呼叫詳情',
'ai.chat.permissionRequired': '需要權限',
'ai.chat.permissionDescription': 'AI Agent 希望執行一個需要你批准的工具呼叫。',
'ai.chat.commandBlocked': '此指令已被安全策略攔截,無法執行。',
@@ -442,6 +450,8 @@ export const zhTWAiMessages: Messages = {
'ai.chat.newChat': '新對話',
'ai.chat.allSessions': '所有工作階段',
'ai.chat.loadEarlierMessages': '載入更早的訊息(還有 {n} 條)',
'ai.chat.jumpNav': '跳轉到訊息',
'ai.chat.jumpUntitled': '(空訊息)',
'ai.chat.usedTools': '已使用 {n} 個工具',
'ai.chat.loadMoreSessions': '載入更多工作階段(還有 {n} 條)',
'ai.chat.noSessions': '沒有歷史工作階段',
+4
View File
@@ -194,6 +194,9 @@ export const zhTWCoreMessages: Messages = {
'settings.sessionLogs.timestamps': '新增時間戳',
'settings.sessionLogs.timestampsDesc': '為純文字和 HTML 日誌的每一行新增本機時間。',
'settings.sessionLogs.hint': '工作階段日誌用於記錄終端輸出,便於故障排查和審計。',
'settings.sessionLogs.clearAll': '清空所有日誌',
'settings.sessionLogs.clearAllDesc': '刪除儲存目錄中所有工作階段日誌檔案及主機子目錄。',
'settings.sessionLogs.clearConfirm': '此操作將永久刪除儲存目錄中的所有工作階段日誌檔案,且無法復原,是否繼續?',
// Settings > SSH Debug Logs
'settings.sshDebugLogs.title': 'SSH 除錯日誌',
@@ -860,6 +863,7 @@ export const zhTWCoreMessages: Messages = {
'sftp.transfers.calculatingTotal': '正在統計總大小...',
'sftp.transfers.filesCount': '{count} 個檔案',
'sftp.transfers.filesProgress': '{current}/{total} 個檔案',
'sftp.transfers.filesDiscoveredProgress': '已完成 {completed} · 已發現 {discovered}',
'sftp.transfers.expandChildren': '展開檔案',
'sftp.transfers.collapseChildren': '收起檔案',
'sftp.transfers.expandChildList': '展開詳情',
@@ -10,6 +10,7 @@ export const zhTwSystemManagerMessages: Messages = {
'systemManager.tabs.processes': '程序',
'systemManager.tabs.tmux': 'tmux',
'systemManager.tabs.docker': 'Docker',
'systemManager.tabs.gpu': 'GPU',
'systemManager.popup.loading': '正在開啟終端…',
'systemManager.popup.startupFailed': '啟動指令未順利完成。請確認目標仍可使用後再試一次。',
@@ -23,6 +24,7 @@ export const zhTwSystemManagerMessages: Messages = {
'systemManager.errors.loadDockerStats': '載入容器效能資料失敗',
'systemManager.errors.loadDockerImages': '載入映像列表失敗',
'systemManager.errors.loadOverview': '載入系統概覽失敗',
'systemManager.errors.loadGpu': '載入 GPU / NPU 狀態失敗',
'systemManager.errors.sshChannelUnavailable': '伺服器拒絕開啟新的執行通道。請稍後重試,或重新連線目前主機。',
'systemManager.overview.empty': '暫無系統概覽資料。',
@@ -187,6 +189,23 @@ export const zhTwSystemManagerMessages: Messages = {
'systemManager.docker.start': '啟動',
'systemManager.docker.stop': '停止',
'systemManager.gpu.unavailable': '此主機未偵測到 NVIDIA GPU 或昇騰 NPU 工具。',
'systemManager.gpu.loading': '正在載入加速卡狀態…',
'systemManager.gpu.empty': '已偵測到加速卡工具,但未回傳裝置資訊。',
'systemManager.gpu.meta': '{{devices}} 張卡 · {{processes}} 個程序',
'systemManager.gpu.devices': '裝置',
'systemManager.gpu.processes': '計算程序',
'systemManager.gpu.noProcesses': '目前沒有計算程序。',
'systemManager.gpu.vendor.nvidia': 'NVIDIA',
'systemManager.gpu.vendor.ascend': '昇騰',
'systemManager.gpu.util': '使用率',
'systemManager.gpu.memory': '顯示記憶體',
'systemManager.gpu.hbm': 'HBM',
'systemManager.gpu.temperature': '溫度',
'systemManager.gpu.power': '功耗',
'systemManager.gpu.fan': '風扇 {{value}}%',
'systemManager.gpu.driver': '驅動 {{version}}',
'systemManager.inspect.status': '狀態',
'systemManager.inspect.image': '映像',
'systemManager.inspect.created': '建立時間',
@@ -206,6 +206,9 @@ export const zhTWTerminalMessages: Messages = {
'settings.sftp.compressedUpload.enable': '啟用資料夾壓縮',
'settings.sftp.compressedUpload.enableDesc': '自動使用 tar 壓縮資料夾後再傳輸。需要伺服器支援 tar 指令,不支援時自動回退到普通傳輸。',
'settings.sftp.skipUnchanged.enable': '跳過未改檔案',
'settings.sftp.skipUnchanged.enableDesc': '當目標檔案的大小與修改時間已與來源一致時跳過傳輸(類似 rsync)。替換到暫存路徑時自動停用。',
// Settings > Terminal
'settings.terminal.section.theme': '終端主題',
'settings.terminal.themeModal.title': '選擇主題',
+4
View File
@@ -427,6 +427,10 @@ export const zhTWVaultMessages: Messages = {
'terminal.statusbar.copyHostname.tooltip': '複製主機地址({hostname})',
'terminal.statusbar.copyHostname.toast': '已複製主機地址:{hostname}',
'terminal.statusbar.copyHostname.error': '複製主機地址失敗',
'terminal.statusbar.disconnect.label': '中斷連線',
'terminal.statusbar.disconnect.tooltip': '中斷目前工作階段,但保留標籤頁',
'terminal.statusbar.reconnect.label': '重新連線',
'terminal.statusbar.reconnect.tooltip': '重新連線目前工作階段',
'terminal.serverStats.cpu': 'CPU 使用率',
'terminal.serverStats.cpuCores': 'CPU 核心使用率',
'terminal.serverStats.memory': '記憶體使用',
@@ -0,0 +1,31 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
getAppearanceChromeSnapshot,
publishAppearanceChromeSnapshot,
subscribeAppearanceChrome,
} from './appearanceChromeStore.ts';
test('appearanceChromeStore notifies subscribers only when accent fields change', () => {
const events: string[] = [];
const unsubscribe = subscribeAppearanceChrome(() => {
const snap = getAppearanceChromeSnapshot();
events.push(`${snap.accentMode}:${snap.customAccent}`);
});
publishAppearanceChromeSnapshot({ accentMode: 'custom', customAccent: '#ff0000' });
assert.equal(events.at(-1), 'custom:#ff0000');
assert.equal(getAppearanceChromeSnapshot().customAccent, '#ff0000');
publishAppearanceChromeSnapshot({ accentMode: 'custom', customAccent: '#ff0000' });
assert.equal(events.length, 1);
publishAppearanceChromeSnapshot({ accentMode: 'custom', customAccent: '#00ff00' });
assert.equal(events.at(-1), 'custom:#00ff00');
publishAppearanceChromeSnapshot({ accentMode: 'theme', customAccent: '#00ff00' });
assert.equal(events.at(-1), 'theme:#00ff00');
unsubscribe();
});
@@ -0,0 +1,68 @@
import { useSyncExternalStore } from 'react';
type Listener = () => void;
export type AppearanceChromeSnapshot = {
accentMode: 'theme' | 'custom';
customAccent: string;
};
const DEFAULT_SNAPSHOT: AppearanceChromeSnapshot = Object.freeze({
accentMode: 'theme',
customAccent: '',
});
/**
* External store for app accent chrome so Terminal leaves can apply custom
* accent without TerminalLayer rebuilding on every color-picker drag tick.
*/
class AppearanceChromeStore {
private snapshot: AppearanceChromeSnapshot = DEFAULT_SNAPSHOT;
private listeners = new Set<Listener>();
getSnapshot = (): AppearanceChromeSnapshot => this.snapshot;
subscribe = (listener: Listener): (() => void) => {
this.listeners.add(listener);
return () => {
this.listeners.delete(listener);
};
};
setSnapshot(next: AppearanceChromeSnapshot): void {
if (
this.snapshot.accentMode === next.accentMode
&& this.snapshot.customAccent === next.customAccent
) {
return;
}
this.snapshot = next;
for (const listener of this.listeners) {
listener();
}
}
}
export const appearanceChromeStore = new AppearanceChromeStore();
export function publishAppearanceChromeSnapshot(
snapshot: AppearanceChromeSnapshot,
): void {
appearanceChromeStore.setSnapshot(snapshot);
}
export function getAppearanceChromeSnapshot(): AppearanceChromeSnapshot {
return appearanceChromeStore.getSnapshot();
}
export function subscribeAppearanceChrome(listener: Listener): () => void {
return appearanceChromeStore.subscribe(listener);
}
export function useAppearanceChromeStore(): AppearanceChromeSnapshot {
return useSyncExternalStore(
subscribeAppearanceChrome,
getAppearanceChromeSnapshot,
getAppearanceChromeSnapshot,
);
}
+23
View File
@@ -343,3 +343,26 @@ test("the race guards are wired into the real settings paths", () => {
assert.match(popupSource, /terminalTheme=\{settings\.currentTerminalTheme\}/);
assert.match(popupSource, /followAppTerminalTheme=\{settings\.followAppTerminalTheme\}/);
});
test("cloud sync rehydrate picks up follow-app terminal theme from storage", () => {
// applySyncPayload writes followAppTerminalTheme to localStorage, then calls
// rehydrateAllFromStorage. Without reading TERM_FOLLOW_APP_THEME there, the
// open window keeps the pre-sync follow-app flag while terminalThemeId updates
// — black default vs synced yellow theme flicker (#2757).
const stateSource = readFileSync(new URL("./useSettingsState.ts", import.meta.url), "utf8");
const rehydrateIndex = stateSource.indexOf("const rehydrateAllFromStorage = useCallback(() => {");
assert.ok(rehydrateIndex >= 0, "rehydrateAllFromStorage must exist");
const nextCallbackIndex = stateSource.indexOf("}, [applyIncomingCustomKeyBindings", rehydrateIndex);
assert.ok(nextCallbackIndex > rehydrateIndex, "rehydrate callback body must be bounded");
const rehydrateBody = stateSource.slice(rehydrateIndex, nextCallbackIndex);
assert.match(
rehydrateBody,
/STORAGE_KEY_TERM_FOLLOW_APP_THEME/,
"rehydrate must read the follow-app terminal theme key written by applySyncableSettings",
);
assert.match(
rehydrateBody,
/setFollowAppTerminalThemeState/,
"rehydrate must update React follow-app state from storage after cloud sync",
);
});
+1 -1
View File
@@ -77,7 +77,7 @@ export function shouldApplyCustomAccentRecord(
/**
* Decide whether a custom-accent state change should be rebroadcast to peer
* windows. Incoming IPC/storage updates must not notify again — otherwise a
* windows. Incoming IPC/storage updates must not notify again - otherwise a
* fast native color-picker drag in the settings window ping-pongs with the
* main window and the accent CSS variables oscillate (see #2743, same class
* as window-opacity #2018).
@@ -189,7 +189,7 @@ test("loadDefaultKeyPassphrase removes undecryptable credential placeholders", a
globalThis.localStorage.setItem(
STORAGE_KEY_DEFAULT_KEY_PASSPHRASES,
JSON.stringify({
[keyPath]: "enc:v1:djEwYWJj",
[keyPath]: "enc:v1:djEwYWJjAAAAAAAAAAAAAAAAAA==",
"/Users/alice/.ssh/id_rsa": "still-valid",
}),
);
@@ -206,7 +206,7 @@ test("loadDefaultKeyPassphrase removes undecryptable credential placeholders", a
test("export read reports unavailable encrypted passphrases without deleting them", async (t) => {
installLocalStorage(t);
const keyPath = "/Users/alice/.ssh/id_ed25519";
const encrypted = "enc:v1:djEwYWJj";
const encrypted = "enc:v1:djEwYWJjAAAAAAAAAAAAAAAAAA==";
globalThis.localStorage.setItem(
STORAGE_KEY_DEFAULT_KEY_PASSPHRASES,
JSON.stringify({ [keyPath]: encrypted }),
@@ -225,7 +225,7 @@ test("export read reports unavailable encrypted passphrases without deleting the
test("export read retries when the passphrase changes during decryption", async (t) => {
installLocalStorage(t);
const keyPath = "/Users/alice/.ssh/id_ed25519";
const encrypted = "enc:v1:djEwYWJj";
const encrypted = "enc:v1:djEwYWJjAAAAAAAAAAAAAAAAAA==";
let releaseDecrypt: (() => void) | undefined;
const decryptGate = new Promise<void>((resolve) => {
releaseDecrypt = resolve;
@@ -286,7 +286,7 @@ test("remembered passphrase read marks encrypted Keychain placeholders unreadabl
installLocalStorage(t);
const key = {
...referenceKey(),
passphrase: "enc:v1:djEwYWJj",
passphrase: "enc:v1:djEwYWJjAAAAAAAAAAAAAAAAAA==",
savePassphrase: true,
};
@@ -311,7 +311,7 @@ test("passphrase verification reads every alias and preserves unreadable state",
STORAGE_KEY_DEFAULT_KEY_PASSPHRASES,
JSON.stringify({
"~/.ssh/id_ed25519": "readable-secret",
"/Users/alice/.ssh/id_ed25519": "enc:v1:djEwYWJj",
"/Users/alice/.ssh/id_ed25519": "enc:v1:djEwYWJjAAAAAAAAAAAAAAAAAA==",
}),
);
@@ -455,7 +455,7 @@ test("loadDefaultKeyPassphrase cleanup preserves a passphrase saved concurrently
});
globalThis.localStorage.setItem(
STORAGE_KEY_DEFAULT_KEY_PASSPHRASES,
JSON.stringify({ "/Users/alice/.ssh/id_old": "enc:v1:djEwYWJj" }),
JSON.stringify({ "/Users/alice/.ssh/id_old": "enc:v1:djEwYWJjAAAAAAAAAAAAAAAAAA==" }),
);
const pendingLoad = loadDefaultKeyPassphrase("/Users/alice/.ssh/id_old");
@@ -549,7 +549,7 @@ test("loadDefaultKeyPassphrase prefers an exact saved path over a stale alias",
globalThis.localStorage.setItem(
STORAGE_KEY_DEFAULT_KEY_PASSPHRASES,
JSON.stringify({
"~/.ssh/id_ed25519": "enc:v1:djEwYWJj",
"~/.ssh/id_ed25519": "enc:v1:djEwYWJjAAAAAAAAAAAAAAAAAA==",
"/Users/alice/.ssh/id_ed25519": "valid-exact-passphrase",
}),
);
@@ -574,7 +574,7 @@ test("loadDefaultKeyPassphrase falls back to a valid alias and removes an invali
STORAGE_KEY_DEFAULT_KEY_PASSPHRASES,
JSON.stringify({
"~/.ssh/id_ed25519": "valid-alias-passphrase",
"/Users/alice/.ssh/id_ed25519": "enc:v1:djEwYWJj",
"/Users/alice/.ssh/id_ed25519": "enc:v1:djEwYWJjAAAAAAAAAAAAAAAAAA==",
}),
);
@@ -913,7 +913,7 @@ test("shouldUpdateReferenceKeyPassphrase replaces missing or undecryptable passp
assert.equal(
shouldUpdateReferenceKeyPassphrase({
...referenceKey(),
passphrase: "enc:v1:djEwAAAA",
passphrase: "enc:v1:djEwdGVzdAAAAAAAAAAAAAAAAA==",
}),
true,
);
+101
View File
@@ -0,0 +1,101 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
EMPTY_NOTES_SNAPSHOT,
getEmptyNotesSnapshot,
getNotesActions,
getNotesSnapshot,
publishNotesSnapshot,
registerNotesActions,
subscribeNotes,
subscribeNotesNoop,
} from './notesStore.ts';
test('notesStore notifies subscribers only when snapshot identity changes', () => {
const events: number[] = [];
const unsubscribe = subscribeNotes(() => {
events.push(getNotesSnapshot().notes.length);
});
const firstNotes = [{
id: 'n1',
title: 'One',
content: 'body',
tags: [],
createdAt: 1,
updatedAt: 1,
order: 1000,
}];
const firstGroups = ['Ops'];
publishNotesSnapshot({ notes: firstNotes, noteGroups: firstGroups });
assert.equal(events.at(-1), 1);
assert.equal(getNotesSnapshot().notes, firstNotes);
assert.equal(getNotesSnapshot().noteGroups, firstGroups);
publishNotesSnapshot({ notes: firstNotes, noteGroups: firstGroups });
assert.equal(events.length, 1);
const secondNotes = [
...firstNotes,
{
id: 'n2',
title: 'Two',
content: 'more',
tags: [],
createdAt: 2,
updatedAt: 2,
order: 2000,
},
];
publishNotesSnapshot({ notes: secondNotes, noteGroups: firstGroups });
assert.equal(events.at(-1), 2);
publishNotesSnapshot({ notes: secondNotes, noteGroups: ['Ops', 'DB'] });
assert.equal(events.length, 3);
assert.deepEqual(getNotesSnapshot().noteGroups, ['Ops', 'DB']);
unsubscribe();
});
test('notesStore gated helpers stay empty and never notify', () => {
let called = 0;
const unsub = subscribeNotesNoop(() => {
called += 1;
});
publishNotesSnapshot({
notes: [{
id: 'n1',
title: 'One',
content: 'body',
tags: [],
createdAt: 1,
updatedAt: 1,
order: 1000,
}],
noteGroups: ['Ops'],
});
assert.equal(called, 0);
assert.equal(getEmptyNotesSnapshot(), EMPTY_NOTES_SNAPSHOT);
assert.equal(getEmptyNotesSnapshot().notes.length, 0);
unsub();
});
test('registerNotesActions exposes update handlers', () => {
const calls: string[] = [];
registerNotesActions({
updateNotes: () => {
calls.push('notes');
},
updateNoteGroups: () => {
calls.push('groups');
},
});
const actions = getNotesActions();
assert.ok(actions);
actions!.updateNotes([]);
actions!.updateNoteGroups([]);
assert.deepEqual(calls, ['notes', 'groups']);
registerNotesActions(null);
assert.equal(getNotesActions(), null);
});
+138
View File
@@ -0,0 +1,138 @@
import { useSyncExternalStore } from 'react';
import type { VaultNote } from '../../domain/models';
type Listener = () => void;
export type NotesSnapshot = {
notes: readonly VaultNote[];
noteGroups: readonly string[];
};
export type NotesActions = {
updateNotes: (notes: VaultNote[]) => void;
updateNoteGroups: (groups: string[]) => void;
};
const EMPTY_NOTES: readonly VaultNote[] = Object.freeze([]);
const EMPTY_NOTE_GROUPS: readonly string[] = Object.freeze([]);
export const EMPTY_NOTES_SNAPSHOT: NotesSnapshot = Object.freeze({
notes: EMPTY_NOTES,
noteGroups: EMPTY_NOTE_GROUPS,
});
/**
* External store for vault notes so Notes / AI side-panel consumers can
* subscribe without forcing TerminalLayer re-renders on every note edit.
*/
class NotesStore {
private snapshot: NotesSnapshot = EMPTY_NOTES_SNAPSHOT;
private actions: NotesActions | null = null;
private listeners = new Set<Listener>();
private actionListeners = new Set<Listener>();
getSnapshot = (): NotesSnapshot => this.snapshot;
subscribe = (listener: Listener): (() => void) => {
this.listeners.add(listener);
return () => {
this.listeners.delete(listener);
};
};
setSnapshot(next: NotesSnapshot): void {
if (
this.snapshot.notes === next.notes
&& this.snapshot.noteGroups === next.noteGroups
) {
return;
}
this.snapshot = next;
for (const listener of this.listeners) {
listener();
}
}
getActions = (): NotesActions | null => this.actions;
subscribeActions = (listener: Listener): (() => void) => {
this.actionListeners.add(listener);
return () => {
this.actionListeners.delete(listener);
};
};
setActions(next: NotesActions | null): void {
if (this.actions === next) return;
this.actions = next;
for (const listener of this.actionListeners) {
listener();
}
}
}
export const notesStore = new NotesStore();
export function publishNotesSnapshot(snapshot: NotesSnapshot): void {
notesStore.setSnapshot(snapshot);
}
export function getNotesSnapshot(): NotesSnapshot {
return notesStore.getSnapshot();
}
export function subscribeNotes(listener: Listener): () => void {
return notesStore.subscribe(listener);
}
/** No-op subscribe for gated (hidden) panel mounts. */
export function subscribeNotesNoop(_listener: Listener): () => void {
return () => {};
}
export function getEmptyNotesSnapshot(): NotesSnapshot {
return EMPTY_NOTES_SNAPSHOT;
}
export function registerNotesActions(actions: NotesActions | null): void {
notesStore.setActions(actions);
}
export function getNotesActions(): NotesActions | null {
return notesStore.getActions();
}
export function subscribeNotesActions(listener: Listener): () => void {
return notesStore.subscribeActions(listener);
}
const noopUpdateNotes: NotesActions['updateNotes'] = () => {};
const noopUpdateNoteGroups: NotesActions['updateNoteGroups'] = () => {};
/**
* Subscribe to notes catalog + vault mutation actions for Notes / AI panels.
*/
export function useNotesStore(): {
notes: VaultNote[];
noteGroups: string[];
updateNotes: NotesActions['updateNotes'];
updateNoteGroups: NotesActions['updateNoteGroups'];
} {
const snapshot = useSyncExternalStore(
subscribeNotes,
getNotesSnapshot,
getNotesSnapshot,
);
const actions = useSyncExternalStore(
subscribeNotesActions,
getNotesActions,
getNotesActions,
);
return {
notes: snapshot.notes as VaultNote[],
noteGroups: snapshot.noteGroups as string[],
updateNotes: actions?.updateNotes ?? noopUpdateNotes,
updateNoteGroups: actions?.updateNoteGroups ?? noopUpdateNoteGroups,
};
}
+14 -4
View File
@@ -28,6 +28,7 @@ import {
STORAGE_KEY_SFTP_TRANSFER_CONCURRENCY,
STORAGE_KEY_SSH_TRANSPORT_IDLE_TTL_MS,
STORAGE_KEY_SFTP_USE_COMPRESSED_UPLOAD,
STORAGE_KEY_SFTP_SKIP_UNCHANGED,
STORAGE_KEY_SHOW_ONLY_UNGROUPED_HOSTS_IN_ROOT,
STORAGE_KEY_SHOW_RECENT_HOSTS,
STORAGE_KEY_HOST_CLICK_BEHAVIOR,
@@ -79,6 +80,7 @@ interface UseSettingsStorageSyncParams {
sftpAutoSync: boolean;
sftpShowHiddenFiles: boolean;
sftpUseCompressedUpload: boolean;
sftpSkipUnchanged: boolean;
sftpAutoOpenSidebar: boolean;
sftpFollowTerminalCwd: boolean;
sftpDefaultViewMode: 'list' | 'tree';
@@ -125,6 +127,7 @@ interface UseSettingsStorageSyncParams {
setSftpAutoSync: Dispatch<SetStateAction<boolean>>;
setSftpShowHiddenFiles: Dispatch<SetStateAction<boolean>>;
setSftpUseCompressedUpload: Dispatch<SetStateAction<boolean>>;
setSftpSkipUnchanged: Dispatch<SetStateAction<boolean>>;
setSftpAutoOpenSidebar: Dispatch<SetStateAction<boolean>>;
setSftpFollowTerminalCwd: Dispatch<SetStateAction<boolean>>;
setSftpDefaultViewMode: Dispatch<SetStateAction<'list' | 'tree'>>;
@@ -165,7 +168,7 @@ export function useSettingsStorageSync({
customCSS, uiFontFamilyId, hotkeyScheme, uiLanguage,
terminalThemeId, followAppTerminalTheme, terminalFontFamilyId, terminalFontSize,
sftpDoubleClickBehavior, sftpAutoSync, sftpShowHiddenFiles,
sftpUseCompressedUpload, sftpAutoOpenSidebar, sftpFollowTerminalCwd, sftpDefaultViewMode,
sftpUseCompressedUpload, sftpSkipUnchanged, sftpAutoOpenSidebar, sftpFollowTerminalCwd, sftpDefaultViewMode,
showRecentHosts, hostClickBehavior, showOnlyUngroupedHostsInRoot, showSftpTab, showHostTreeSidebar, terminalSidePanelAutoOpen, terminalSidePanelAutoOpenTab, shellOnlyTabNumberShortcuts, disableTerminalFontZoom, restorePreviousSession, restoreTerminalCwd,
editorWordWrap, sessionLogsEnabled, sessionLogsDir, sessionLogsFormat, sessionLogsTimestampsEnabled, sshDebugLogsEnabled, sshDeepLinkEnabled, jmsDeepLinkEnabled, explorerContextMenuEnabled,
globalHotkeyEnabled, autoUpdateEnabled, windowOpacity, appIconVariant,
@@ -174,7 +177,7 @@ export function useSettingsStorageSync({
setTerminalThemeId, setTerminalThemeDarkId, setTerminalThemeLightId,
setFollowAppTerminalThemeState, setTerminalFontFamilyId, setTerminalFontSize,
setSftpDoubleClickBehavior, setSftpAutoSync, setSftpShowHiddenFiles,
setSftpUseCompressedUpload, setSftpAutoOpenSidebar, setSftpFollowTerminalCwd, setSftpDefaultViewMode,
setSftpUseCompressedUpload, setSftpSkipUnchanged, setSftpAutoOpenSidebar, setSftpFollowTerminalCwd, setSftpDefaultViewMode,
setShowRecentHostsState, setHostClickBehaviorState, setShowOnlyUngroupedHostsInRootState, setShowSftpTabState, setShowHostTreeSidebarState, setTerminalSidePanelAutoOpenState, setTerminalSidePanelAutoOpenTabState, setShellOnlyTabNumberShortcutsState, setDisableTerminalFontZoomState, setRestorePreviousSessionState, setRestoreTerminalCwdState,
setEditorWordWrapState, setSessionLogsEnabled, setSessionLogsDir, setSessionLogsFormat, setSessionLogsTimestampsEnabled, setSshDebugLogsEnabled, setSshDeepLinkEnabledState, setJmsDeepLinkEnabledState, setExplorerContextMenuEnabledState,
setGlobalHotkeyEnabled, setWindowOpacity, setAppIconVariant, setAutoUpdateEnabled, setWorkspaceFocusStyleState,
@@ -189,7 +192,7 @@ export function useSettingsStorageSync({
customCSS, uiFontFamilyId, hotkeyScheme, uiLanguage,
terminalThemeId, followAppTerminalTheme, terminalFontFamilyId, terminalFontSize,
sftpDoubleClickBehavior, sftpAutoSync, sftpShowHiddenFiles,
sftpUseCompressedUpload, sftpAutoOpenSidebar, sftpFollowTerminalCwd, sftpDefaultViewMode,
sftpUseCompressedUpload, sftpSkipUnchanged, sftpAutoOpenSidebar, sftpFollowTerminalCwd, sftpDefaultViewMode,
showRecentHosts, hostClickBehavior, showOnlyUngroupedHostsInRoot, showSftpTab, showHostTreeSidebar, terminalSidePanelAutoOpen, terminalSidePanelAutoOpenTab, shellOnlyTabNumberShortcuts, disableTerminalFontZoom, restorePreviousSession, restoreTerminalCwd,
editorWordWrap, sessionLogsEnabled, sessionLogsDir, sessionLogsFormat, sessionLogsTimestampsEnabled, sshDebugLogsEnabled, sshDeepLinkEnabled, jmsDeepLinkEnabled, explorerContextMenuEnabled,
globalHotkeyEnabled, autoUpdateEnabled, windowOpacity, appIconVariant,
@@ -199,7 +202,7 @@ export function useSettingsStorageSync({
customCSS, uiFontFamilyId, hotkeyScheme, uiLanguage,
terminalThemeId, followAppTerminalTheme, terminalFontFamilyId, terminalFontSize,
sftpDoubleClickBehavior, sftpAutoSync, sftpShowHiddenFiles,
sftpUseCompressedUpload, sftpAutoOpenSidebar, sftpFollowTerminalCwd, sftpDefaultViewMode,
sftpUseCompressedUpload, sftpSkipUnchanged, sftpAutoOpenSidebar, sftpFollowTerminalCwd, sftpDefaultViewMode,
showRecentHosts, hostClickBehavior, showOnlyUngroupedHostsInRoot, showSftpTab, showHostTreeSidebar, terminalSidePanelAutoOpen, terminalSidePanelAutoOpenTab, shellOnlyTabNumberShortcuts, disableTerminalFontZoom, restorePreviousSession, restoreTerminalCwd,
editorWordWrap, sessionLogsEnabled, sessionLogsDir, sessionLogsFormat, sessionLogsTimestampsEnabled, sshDebugLogsEnabled, sshDeepLinkEnabled, jmsDeepLinkEnabled, explorerContextMenuEnabled,
globalHotkeyEnabled, autoUpdateEnabled, windowOpacity, appIconVariant,
@@ -379,6 +382,12 @@ export function useSettingsStorageSync({
setSftpUseCompressedUpload(newValue);
}
}
if (e.key === STORAGE_KEY_SFTP_SKIP_UNCHANGED && e.newValue !== null) {
const newValue = e.newValue === 'true';
if (newValue !== s.sftpSkipUnchanged) {
setSftpSkipUnchanged(newValue);
}
}
// Sync SFTP auto-open sidebar setting from other windows
if (e.key === STORAGE_KEY_SFTP_AUTO_OPEN_SIDEBAR && e.newValue !== null) {
const newValue = e.newValue === 'true';
@@ -542,6 +551,7 @@ export function useSettingsStorageSync({
setSftpTransferConcurrencyState,
setSshTransportIdleTtlMsState,
setSftpUseCompressedUpload,
setSftpSkipUnchanged,
setShowOnlyUngroupedHostsInRootState,
setShowHostTreeSidebarState,
setTerminalSidePanelAutoOpenState,
@@ -29,6 +29,7 @@ import {
isMissingDirectoryReplacePathError,
promoteDirectoryReplaceStage as promoteDirectoryReplacePaths,
} from "./directoryReplacePromotion";
import { sftpTransferCenterStore } from "../sftpTransferCenterStore";
export interface DedicatedResumeDeps {
hosts: readonly Host[];
@@ -708,8 +709,19 @@ async function resumeSingleFileWithDedicatedSession(
skipAdmission: true,
});
if (streamResult?.error) {
throw new Error(streamResult.error);
if (streamResult?.superseded === true) {
// Live same-id owner still running; wait for terminal events only.
for (;;) {
if (shouldAbort?.()) throw new Error("Transfer cancelled");
const latest = sftpTransferCenterStore.getTask(task.id);
const status = latest?.status;
if (status === "completed") break;
if (status === "failed") throw new Error(latest?.error || "Transfer failed");
if (status === "cancelled") throw new Error("Transfer cancelled");
await new Promise((resolve) => setTimeout(resolve, 200));
}
} else if (streamResult?.error || streamResult?.cancelled) {
throw new Error(streamResult.error || "Transfer cancelled");
}
}, { retries: 1, delayMs: 600 });
return { transferId: task.id };
@@ -1258,7 +1270,17 @@ async function resumeDirectoryWithDedicatedSession(
skipAdmission: true,
});
if (streamResult?.error || streamResult?.cancelled) {
if (streamResult?.superseded === true) {
for (;;) {
if (options?.shouldAbort?.()) throw new Error("Transfer cancelled");
const latest = sftpTransferCenterStore.getTask(childBase.id);
const status = latest?.status;
if (status === "completed") break;
if (status === "failed") throw new Error(latest?.error || "Transfer failed");
if (status === "cancelled") throw new Error("Transfer cancelled");
await new Promise((resolve) => setTimeout(resolve, 200));
}
} else if (streamResult?.error || streamResult?.cancelled) {
throw new Error(streamResult.error || "Transfer cancelled");
}
@@ -0,0 +1,186 @@
import assert from "node:assert/strict";
import test from "node:test";
import type { TransferTask } from "../../../domain/models";
import {
isExternalDragDropFileUpload,
retryExternalDragDropFileUpload,
} from "./externalDragDropRetry";
const baseTask = (overrides: Partial<TransferTask> = {}): TransferTask => ({
id: "child-1",
fileName: "a.txt",
sourcePath: "/tmp/docs/a.txt",
targetPath: "/remote/docs/a.txt",
sourceConnectionId: "external",
targetConnectionId: "conn-1",
targetHostId: "host-1",
direction: "upload",
status: "failed",
totalBytes: 12,
transferredBytes: 0,
speed: 0,
startTime: 1,
isDirectory: false,
origin: "drag-drop",
parentTaskId: "folder-1",
error: "Network error",
...overrides,
});
test("detects progressive drag-drop file children as retryable", () => {
assert.equal(isExternalDragDropFileUpload(baseTask()), true);
assert.equal(isExternalDragDropFileUpload(baseTask({ isDirectory: true })), false);
assert.equal(isExternalDragDropFileUpload(baseTask({ origin: "manual" })), false);
assert.equal(isExternalDragDropFileUpload(baseTask({ sourceConnectionId: "local" })), false);
assert.equal(isExternalDragDropFileUpload(baseTask({ status: "completed" })), false);
assert.equal(isExternalDragDropFileUpload(baseTask({ retryable: false })), false);
});
test("retry reuses the same transfer id and starts a stream upload", async () => {
const patches: Array<{ id: string; updates: Partial<TransferTask> }> = [];
const streams: Array<Record<string, unknown>> = [];
// No pool acquire → falls back to browse sftp id.
const result = await retryExternalDragDropFileUpload(baseTask(), {
getBrowseSftpId: () => "sftp-live",
startStreamTransfer: async (options) => {
streams.push(options as unknown as Record<string, unknown>);
return { transferId: options.transferId };
},
onPatch: (taskId, updates) => patches.push({ id: taskId, updates }),
});
assert.equal(result.success, true);
assert.equal(streams.length, 1);
assert.equal(streams[0].transferId, "child-1");
assert.equal(streams[0].sourcePath, "/tmp/docs/a.txt");
assert.equal(streams[0].targetPath, "/remote/docs/a.txt");
assert.equal(streams[0].targetSftpId, "sftp-live");
assert.equal(patches[0].updates.status, "transferring");
assert.equal(patches.at(-1)?.updates.status, "completed");
});
test("retry prefers a dedicated pool session over the browse sftp id", async () => {
let released = false;
let acquired = false;
const result = await retryExternalDragDropFileUpload(baseTask(), {
getBrowseSftpId: () => "sftp-live",
acquireTransferSession: async () => {
acquired = true;
return {
sftpId: "sftp-pool",
poolKey: "host-1",
release: () => { released = true; },
discard: () => {},
};
},
startStreamTransfer: async (options) => {
assert.equal(options.targetSftpId, "sftp-pool");
return {};
},
onPatch: () => {},
});
assert.equal(result.success, true);
assert.equal(acquired, true);
assert.equal(released, true);
});
test("retry falls back to browse when pool acquire is unavailable", async () => {
const result = await retryExternalDragDropFileUpload(baseTask(), {
getBrowseSftpId: () => "sftp-live",
startStreamTransfer: async (options) => {
assert.equal(options.targetSftpId, "sftp-live");
return {};
},
onPatch: () => {},
});
assert.equal(result.success, true);
});
test("retry rolls parent to completed when all siblings succeed", async () => {
const patches: Array<{ id: string; updates: Partial<TransferTask> }> = [];
const result = await retryExternalDragDropFileUpload(baseTask(), {
getBrowseSftpId: () => "sftp-live",
startStreamTransfer: async () => ({}),
getTask: (id) => (
id === "folder-1"
? baseTask({
id: "folder-1",
isDirectory: true,
status: "failed",
sourcePath: "/tmp/docs",
targetPath: "/remote/docs",
error: "1 of 2 files failed",
totalBytes: 2,
transferredBytes: 1,
parentTaskId: undefined,
})
: undefined
),
getChildTasks: () => [
baseTask({ id: "child-1", status: "transferring" }),
baseTask({ id: "child-2", status: "completed", sourcePath: "/tmp/docs/b.txt" }),
],
onPatch: (taskId, updates) => patches.push({ id: taskId, updates }),
});
assert.equal(result.success, true);
const parentPatch = patches.find((p) => p.id === "folder-1");
assert.ok(parentPatch);
assert.equal(parentPatch?.updates.status, "completed");
});
test("retry does not rollup a still-transferring progressive parent", async () => {
const patches: Array<{ id: string; updates: Partial<TransferTask> }> = [];
await retryExternalDragDropFileUpload(baseTask(), {
getBrowseSftpId: () => "sftp-live",
startStreamTransfer: async () => ({}),
getTask: (id) => (
id === "folder-1"
? baseTask({
id: "folder-1",
isDirectory: true,
status: "transferring",
sourcePath: "/tmp/docs",
parentTaskId: undefined,
})
: undefined
),
getChildTasks: () => [
baseTask({ id: "child-1", status: "transferring" }),
baseTask({ id: "child-2", status: "completed", sourcePath: "/tmp/docs/b.txt" }),
],
onPatch: (taskId, updates) => patches.push({ id: taskId, updates }),
});
assert.equal(patches.some((p) => p.id === "folder-1"), false);
});
test("retry fails clearly when no sftp session can be opened", async () => {
const patches: Array<Partial<TransferTask>> = [];
const result = await retryExternalDragDropFileUpload(baseTask(), {
getBrowseSftpId: () => undefined,
startStreamTransfer: async () => {
throw new Error("should not start");
},
onPatch: (_id, updates) => patches.push(updates),
});
assert.equal(result.success, false);
assert.match(result.error || "", /No SFTP session/);
assert.equal(patches.at(-1)?.status, "failed");
});
test("stream error keeps the row failed with the backend message", async () => {
const patches: Array<Partial<TransferTask>> = [];
const result = await retryExternalDragDropFileUpload(baseTask(), {
getBrowseSftpId: () => "sftp-live",
startStreamTransfer: async () => ({ error: "Permission denied" }),
onPatch: (_id, updates) => patches.push(updates),
});
assert.equal(result.success, false);
assert.equal(result.error, "Permission denied");
assert.equal(patches.at(-1)?.status, "failed");
assert.equal(patches.at(-1)?.error, "Permission denied");
});
@@ -0,0 +1,267 @@
/**
* Low-cost retry for progressive / external drag-drop file uploads.
*
* These rows use sourceConnectionId "external" and never have dual-pane
* endpoints, so the generic processTransfer retry path silently no-ops.
* Retry re-opens a single startStreamTransfer with the stored local path.
*/
import type { TransferTask } from "../../../domain/models";
import type { TransferConnectionLease } from "./transferConnectionPool";
import { isTransferCancelledFlag } from "./transferCancelLatch";
export function isExternalDragDropFileUpload(
task: Pick<
TransferTask,
| "origin"
| "direction"
| "sourceConnectionId"
| "isDirectory"
| "sourcePath"
| "targetPath"
| "retryable"
| "status"
>,
): boolean {
if (task.retryable === false) return false;
if (task.origin !== "drag-drop") return false;
if (task.direction !== "upload") return false;
if (task.sourceConnectionId !== "external") return false;
if (task.isDirectory) return false;
if (!task.sourcePath || task.sourcePath === "local") return false;
if (!task.targetPath) return false;
return task.status === "failed" || task.status === "cancelled" || task.status === "attention";
}
export type ExternalDragDropRetryDeps = {
getBrowseSftpId: (connectionId: string) => string | undefined;
acquireTransferSession?: (
hostId: string,
transferId: string,
) => Promise<TransferConnectionLease>;
startStreamTransfer: (options: {
transferId: string;
sourcePath: string;
targetPath: string;
sourceType: "local";
targetType: "local" | "sftp";
targetSftpId?: string;
targetHostId?: string;
totalBytes?: number;
resumable?: boolean;
checkpointBytes?: number;
}) => Promise<{ error?: string; cancelled?: boolean } | undefined>;
clearPendingCancel?: (transferId: string) => Promise<unknown>;
cleanupArtifacts?: (task: TransferTask) => Promise<void>;
onPatch: (taskId: string, updates: Partial<TransferTask>) => void;
/** Live store lookup for terminal cancel races and completion bytes. */
getTask?: (taskId: string) => TransferTask | undefined;
/** Children of a progressive parent (for rollup after child success). */
getChildTasks?: (parentTaskId: string) => TransferTask[];
};
/**
* Restart a failed/cancelled external drag-drop file upload in place (same id).
* Returns true when the stream completed successfully.
*/
export async function retryExternalDragDropFileUpload(
task: TransferTask,
deps: ExternalDragDropRetryDeps,
): Promise<{ success: boolean; error?: string }> {
if (!isExternalDragDropFileUpload(task)) {
return { success: false, error: "Not an external drag-drop file upload" };
}
try {
await deps.clearPendingCancel?.(task.id);
} catch {
// best-effort
}
try {
await deps.cleanupArtifacts?.(task);
} catch {
// best-effort
}
const targetIsLocal = task.targetConnectionId === "local" || !task.targetHostId;
let lease: TransferConnectionLease | null = null;
let targetSftpId: string | undefined;
try {
if (!targetIsLocal) {
// Prefer a dedicated pool session when hostId is known — same policy as
// progressive upload (do not pin the browse/tab session).
if (task.targetHostId && deps.acquireTransferSession) {
lease = await deps.acquireTransferSession(task.targetHostId, `${task.id}:retry`);
targetSftpId = lease.sftpId;
}
if (!targetSftpId) {
targetSftpId = deps.getBrowseSftpId(task.targetConnectionId);
}
if (!targetSftpId) {
const error = "No SFTP session available to retry this upload. Reconnect and try again.";
deps.onPatch(task.id, {
status: "failed",
error,
endTime: Date.now(),
speed: 0,
});
return { success: false, error };
}
}
deps.onPatch(task.id, {
status: "transferring",
error: undefined,
transferredBytes: 0,
checkpointBytes: 0,
speed: 0,
endTime: undefined,
phase: "transferring",
reconnectRequired: false,
pauseUnavailableReason: undefined,
startTime: Date.now(),
});
const result = await deps.startStreamTransfer({
transferId: task.id,
sourcePath: task.sourcePath,
targetPath: task.targetPath,
sourceType: "local",
targetType: targetIsLocal ? "local" : "sftp",
targetSftpId: targetIsLocal ? undefined : targetSftpId,
targetHostId: targetIsLocal ? undefined : task.targetHostId,
totalBytes: task.totalBytes > 0 ? task.totalBytes : undefined,
resumable: true,
checkpointBytes: 0,
});
// Late cancel can settle the row before this return; never resurrect it.
if (isTransferCancelledFlag(task.id) || result?.cancelled) {
deps.onPatch(task.id, {
status: "cancelled",
error: undefined,
endTime: Date.now(),
speed: 0,
phase: undefined,
});
return { success: false, error: "Transfer cancelled" };
}
if (result?.error) {
deps.onPatch(task.id, {
status: "failed",
error: result.error,
endTime: Date.now(),
speed: 0,
phase: undefined,
});
return { success: false, error: result.error };
}
const live = deps.getTask?.(task.id);
const completedBytes = Math.max(
live?.totalBytes ?? 0,
live?.transferredBytes ?? 0,
task.totalBytes,
0,
);
deps.onPatch(task.id, {
status: "completed",
error: undefined,
transferredBytes: completedBytes,
totalBytes: Math.max(live?.totalBytes ?? 0, task.totalBytes, completedBytes),
endTime: Date.now(),
speed: 0,
phase: undefined,
});
// Progressive parents finalize once with "N of M failed". After a child
// retry succeeds, re-roll the parent when no failed children remain.
if (task.parentTaskId && deps.getChildTasks) {
rollupParentAfterChildSuccess(task.parentTaskId, task.id, deps);
}
return { success: true };
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
if (isTransferCancelledFlag(task.id) || /cancel/i.test(message)) {
deps.onPatch(task.id, {
status: "cancelled",
error: undefined,
endTime: Date.now(),
speed: 0,
phase: undefined,
});
return { success: false, error: "Transfer cancelled" };
}
deps.onPatch(task.id, {
status: "failed",
error: message,
endTime: Date.now(),
speed: 0,
phase: undefined,
});
if (lease && /session|sftp|disconnect|not found/i.test(message)) {
try { lease.discard(); } catch { /* best-effort */ }
lease = null;
}
return { success: false, error: message };
} finally {
try { lease?.release(); } catch { /* best-effort */ }
}
}
function rollupParentAfterChildSuccess(
parentTaskId: string,
completedChildId: string,
deps: ExternalDragDropRetryDeps,
): void {
// Only re-roll parents that already finished the progressive walk as failed.
// Never promote a still-scanning/transferring parent mid-walk.
const parent = deps.getTask?.(parentTaskId);
if (
parent
&& parent.status !== "failed"
&& parent.status !== "attention"
&& parent.status !== "cancelled"
) {
return;
}
const children = deps.getChildTasks?.(parentTaskId) ?? [];
if (children.length === 0) return;
const stillFailed = children.some(
(child) =>
child.id !== completedChildId
&& (child.status === "failed" || child.status === "attention"),
);
if (stillFailed) return;
const active = children.some(
(child) =>
child.id !== completedChildId
&& (
child.status === "transferring"
|| child.status === "pending"
|| child.status === "queued"
|| child.status === "pausing"
|| child.status === "paused"
),
);
if (active) return;
const completedCount = children.filter(
(child) => child.id === completedChildId || child.status === "completed",
).length;
const total = Math.max(
Number(parent?.totalBytes) || 0,
children.length,
completedCount,
);
deps.onPatch(parentTaskId, {
status: "completed",
error: undefined,
transferredBytes: completedCount,
totalBytes: total,
speed: 0,
endTime: Date.now(),
phase: undefined,
});
}
@@ -2,8 +2,13 @@ import test from "node:test";
import assert from "node:assert/strict";
import {
DEFAULT_SFTP_DIRECTORY_LISTING_CONCURRENCY,
DEFAULT_SFTP_FILE_TRANSFER_CONCURRENCY,
DEFAULT_SFTP_SKIP_UNCHANGED,
resolveSftpDirectoryListingConcurrency,
resolveSftpSkipUnchangedEnabled,
resolveSftpTransferConcurrency,
runBoundedConcurrency,
runSftpTransferWorkers,
} from "./transferConcurrency";
@@ -12,6 +17,20 @@ test("defaults folder file transfers to two concurrent files", () => {
assert.equal(DEFAULT_SFTP_FILE_TRANSFER_CONCURRENCY, 2);
});
test("defaults directory listing fanout to four concurrent readdirs", () => {
assert.equal(
resolveSftpDirectoryListingConcurrency(() => null),
DEFAULT_SFTP_DIRECTORY_LISTING_CONCURRENCY,
);
assert.equal(DEFAULT_SFTP_DIRECTORY_LISTING_CONCURRENCY, 4);
});
test("defaults skip-unchanged to enabled", () => {
assert.equal(resolveSftpSkipUnchangedEnabled(() => null), DEFAULT_SFTP_SKIP_UNCHANGED);
assert.equal(DEFAULT_SFTP_SKIP_UNCHANGED, true);
assert.equal(resolveSftpSkipUnchangedEnabled(() => false), false);
});
test("keeps explicit folder transfer concurrency within the supported range", () => {
assert.equal(resolveSftpTransferConcurrency(() => 1), 1);
assert.equal(resolveSftpTransferConcurrency(() => 16), 16);
@@ -19,6 +38,19 @@ test("keeps explicit folder transfer concurrency within the supported range", ()
assert.equal(resolveSftpTransferConcurrency(() => 17), DEFAULT_SFTP_FILE_TRANSFER_CONCURRENCY);
});
test("keeps directory listing concurrency within the supported range", () => {
assert.equal(resolveSftpDirectoryListingConcurrency(() => 1), 1);
assert.equal(resolveSftpDirectoryListingConcurrency(() => 8), 8);
assert.equal(
resolveSftpDirectoryListingConcurrency(() => 0),
DEFAULT_SFTP_DIRECTORY_LISTING_CONCURRENCY,
);
assert.equal(
resolveSftpDirectoryListingConcurrency(() => 9),
DEFAULT_SFTP_DIRECTORY_LISTING_CONCURRENCY,
);
});
test("limits default multi-file transfer scheduling to two concurrent workers", async () => {
let active = 0;
let maxActive = 0;
@@ -33,6 +65,106 @@ test("limits default multi-file transfer scheduling to two concurrent workers",
assert.equal(maxActive, 2);
});
test("runBoundedConcurrency respects an explicit limit", async () => {
let active = 0;
let maxActive = 0;
await runBoundedConcurrency([1, 2, 3, 4, 5, 6], 3, async () => {
active += 1;
maxActive = Math.max(maxActive, active);
await new Promise((resolve) => setTimeout(resolve, 5));
active -= 1;
});
assert.equal(maxActive, 3);
});
test("runBoundedConcurrency drains siblings and stops new claims after an error", async () => {
const started: number[] = [];
const finished: number[] = [];
let releaseSlow!: () => void;
const slowGate = new Promise<void>((resolve) => {
releaseSlow = resolve;
});
const run = runBoundedConcurrency([0, 1, 2, 3, 4], 2, async (item) => {
started.push(item);
if (item === 0) {
await new Promise((resolve) => setTimeout(resolve, 10));
throw new Error("boom");
}
if (item === 1) {
await slowGate;
}
finished.push(item);
});
await new Promise((resolve) => setTimeout(resolve, 30));
let settledWhileSiblingRunning = false;
await Promise.race([
run.then(
() => {
settledWhileSiblingRunning = true;
},
() => {
settledWhileSiblingRunning = true;
},
),
new Promise((resolve) => setTimeout(resolve, 5)),
]);
assert.equal(
settledWhileSiblingRunning,
false,
"must wait for in-flight siblings before propagating the error",
);
assert.ok(started.includes(1));
assert.equal(finished.includes(1), false);
releaseSlow();
await assert.rejects(run, /boom/);
assert.ok(finished.includes(1), "in-flight sibling must finish before reject");
assert.deepEqual(
[...started].sort((a, b) => a - b),
[0, 1],
"must not claim additional queue items after the first error",
);
});
test("runSftpTransferWorkers drains siblings and stops new claims after an error", async () => {
const started: number[] = [];
const finished: number[] = [];
let releaseSlow!: () => void;
const slowGate = new Promise<void>((resolve) => {
releaseSlow = resolve;
});
const run = runSftpTransferWorkers([0, 1, 2, 3, 4], () => 2, async (item) => {
started.push(item);
if (item === 0) {
await new Promise((resolve) => setTimeout(resolve, 10));
throw new Error("session lost");
}
if (item === 1) {
await slowGate;
}
finished.push(item);
});
await new Promise((resolve) => setTimeout(resolve, 30));
let settledEarly = false;
await Promise.race([
run.then(
() => { settledEarly = true; },
() => { settledEarly = true; },
),
new Promise((resolve) => setTimeout(resolve, 5)),
]);
assert.equal(settledEarly, false);
releaseSlow();
await assert.rejects(run, /session lost/);
assert.ok(finished.includes(1));
assert.deepEqual([...started].sort((a, b) => a - b), [0, 1]);
});
test("beforeClaim runs before claiming the next queue index", async () => {
const events: string[] = [];
let paused = true;
+92 -8
View File
@@ -2,6 +2,20 @@ export const DEFAULT_SFTP_FILE_TRANSFER_CONCURRENCY = 2;
export const MIN_SFTP_FILE_TRANSFER_CONCURRENCY = 1;
export const MAX_SFTP_FILE_TRANSFER_CONCURRENCY = 16;
/**
* Bounded parallel directory listings while walking a folder tree.
* SFTP has no recursive LIST; FileZilla/WinSCP still walk one dir at a time on
* a single control channel. We pipeline several OPENDIR/READDIR requests so
* wide trees discover total file counts much faster without a second full scan.
* Keep this modest - listing shares the transfer SFTP session with file I/O.
*/
export const DEFAULT_SFTP_DIRECTORY_LISTING_CONCURRENCY = 4;
export const MIN_SFTP_DIRECTORY_LISTING_CONCURRENCY = 1;
export const MAX_SFTP_DIRECTORY_LISTING_CONCURRENCY = 8;
/** Default on: skip size+mtime matches like rsync's generator. */
export const DEFAULT_SFTP_SKIP_UNCHANGED = true;
export function resolveSftpTransferConcurrency(readStoredValue: () => number | null | undefined): number {
const stored = readStoredValue();
return stored != null &&
@@ -11,6 +25,24 @@ export function resolveSftpTransferConcurrency(readStoredValue: () => number | n
: DEFAULT_SFTP_FILE_TRANSFER_CONCURRENCY;
}
export function resolveSftpDirectoryListingConcurrency(
readStoredValue?: () => number | null | undefined,
): number {
const stored = readStoredValue?.();
return stored != null &&
stored >= MIN_SFTP_DIRECTORY_LISTING_CONCURRENCY &&
stored <= MAX_SFTP_DIRECTORY_LISTING_CONCURRENCY
? stored
: DEFAULT_SFTP_DIRECTORY_LISTING_CONCURRENCY;
}
export function resolveSftpSkipUnchangedEnabled(
readStoredValue: () => boolean | null | undefined,
): boolean {
const stored = readStoredValue();
return stored == null ? DEFAULT_SFTP_SKIP_UNCHANGED : stored;
}
export async function runSftpTransferWorkers<T>(
items: T[],
readStoredConcurrency: () => number | null | undefined,
@@ -26,17 +58,27 @@ export async function runSftpTransferWorkers<T>(
): Promise<void> {
const concurrency = resolveSftpTransferConcurrency(readStoredConcurrency);
let nextIndex = 0;
let failed = false;
let firstError: unknown;
const runNext = async () => {
while (nextIndex < items.length) {
// Wait BEFORE claiming so pause does not leave a claimed-but-not-started
// index that arms as soon as soft-drain finishes the previous file.
if (options?.beforeClaim) {
await options.beforeClaim();
while (!failed && nextIndex < items.length) {
try {
// Wait BEFORE claiming so pause does not leave a claimed-but-not-started
// index that arms as soon as soft-drain finishes the previous file.
if (options?.beforeClaim) {
await options.beforeClaim();
}
if (failed || nextIndex >= items.length) return;
const index = nextIndex++;
await worker(items[index], index);
} catch (err) {
if (!failed) {
failed = true;
firstError = err;
}
return;
}
if (nextIndex >= items.length) return;
const index = nextIndex++;
await worker(items[index], index);
}
};
@@ -44,5 +86,47 @@ export async function runSftpTransferWorkers<T>(
{ length: Math.min(concurrency, items.length) },
() => runNext(),
);
// Settle every started worker before propagating - Promise.all would reject
// while siblings keep claiming files after the caller releases leases.
await Promise.all(workers);
if (failed) throw firstError;
}
/** Run workers over a queue with an explicit concurrency (not settings-backed). */
export async function runBoundedConcurrency<T>(
items: T[],
concurrency: number,
worker: (item: T, index: number) => Promise<void>,
options?: {
beforeClaim?: () => Promise<void>;
},
): Promise<void> {
const limit = Math.max(1, Math.min(Math.floor(concurrency) || 1, items.length || 1));
if (items.length === 0) return;
let nextIndex = 0;
let failed = false;
let firstError: unknown;
const runNext = async () => {
while (!failed && nextIndex < items.length) {
try {
if (options?.beforeClaim) {
await options.beforeClaim();
}
// Re-check after beforeClaim: a sibling may have failed while we waited.
if (failed || nextIndex >= items.length) return;
const index = nextIndex++;
await worker(items[index], index);
} catch (err) {
if (!failed) {
failed = true;
firstError = err;
}
return;
}
}
};
// Settle every started worker before propagating - Promise.all would reject
// while siblings keep claiming directories / transferring after the caller cleans up.
await Promise.all(Array.from({ length: Math.min(limit, items.length) }, () => runNext()));
if (failed) throw firstError;
}
@@ -146,7 +146,9 @@ test("directory transfer discovers each directory once with bounded listing conc
assert.equal(listCalls.size, directoryCount + 1);
assert.ok(Array.from(listCalls.values()).every((count) => count === 1));
assert.equal(listCalls.has("/source/loop"), false, "canonical symlink cycles must not be listed");
assert.equal(maxActiveListings, 1);
// Interleaved walk processes sibling subdirectories sequentially so resume
// manifests stay deterministic (no full-tree pre-scan fan-out).
assert.equal(maxActiveListings, 1, `expected sequential listings, got ${maxActiveListings}`);
assert.equal(tasks.find((task) => task.id === root.id)?.totalBytes, directoryCount);
} finally {
await act(async () => {
+258 -26
View File
@@ -9,11 +9,20 @@ import {
type SftpDirectoryTraversalBudget,
shouldFollowSftpSymlinkDirectory,
} from "../../../domain/sftpDirectoryCheckpoint";
import { STORAGE_KEY_SFTP_TRANSFER_CONCURRENCY } from "../../../infrastructure/config/storageKeys";
import { isUnchangedTransferCandidate } from "../../../domain/sftpTransferSkip";
import {
STORAGE_KEY_SFTP_SKIP_UNCHANGED,
STORAGE_KEY_SFTP_TRANSFER_CONCURRENCY,
} from "../../../infrastructure/config/storageKeys";
import { localStorageAdapter } from "../../../infrastructure/persistence/localStorageAdapter";
import { netcattyBridge } from "../../../infrastructure/services/netcattyBridge";
import { logger } from "../../../lib/logger";
import { runSftpTransferWorkers } from "./transferConcurrency";
import {
DEFAULT_SFTP_DIRECTORY_LISTING_CONCURRENCY,
resolveSftpDirectoryListingConcurrency,
resolveSftpSkipUnchangedEnabled,
runSftpTransferWorkers,
} from "./transferConcurrency";
import { getSftpTransferResourceKeys, globalSftpTransferScheduler } from "./globalTransferScheduler";
import { resolveDedicatedStreamEndpointIds } from "../../../domain/sftpDedicatedStreamPolicy";
import { isSessionError } from "./errors";
@@ -31,6 +40,49 @@ import {
import { isTransferOrRootCancelled } from "./transferCancelLatch";
import { joinPath, joinTransferTargetPath } from "./utils";
function createDirectoryListingGate(concurrency = DEFAULT_SFTP_DIRECTORY_LISTING_CONCURRENCY) {
const limit = Math.max(1, Math.floor(concurrency) || 1);
let active = 0;
const waiters: Array<() => void> = [];
const acquire = async () => {
if (active < limit) {
active += 1;
return;
}
await new Promise<void>((resolve) => {
waiters.push(() => {
active += 1;
resolve();
});
});
};
const release = () => {
active = Math.max(0, active - 1);
const next = waiters.shift();
if (next) next();
};
return {
get active() {
return active;
},
async run<T>(fn: () => Promise<T>): Promise<T> {
await acquire();
try {
return await fn();
} finally {
release();
}
},
};
}
type DirectoryDiscoveryProgress = {
discoveredFiles: number;
nextEntryIndex: number;
/** Tree-wide semaphore so nested parallel walks cannot multiply listings. */
listingGate?: ReturnType<typeof createDirectoryListingGate>;
};
function isCancelledLocalOrGlobal(
cancelledTasksRef: { current: Set<string> },
rootTaskId: string,
@@ -45,6 +97,40 @@ function isCancelledLocalOrGlobal(
return false;
}
function readSkipUnchangedEnabled(): boolean {
return resolveSftpSkipUnchangedEnabled(() => localStorageAdapter.readBoolean(STORAGE_KEY_SFTP_SKIP_UNCHANGED));
}
async function tryStatTransferPath(
filePath: string,
isLocal: boolean,
sftpId: string | null,
encoding: SftpFilenameEncoding,
): Promise<{ size: number; lastModified: number; type: string } | null> {
try {
if (isLocal) {
const stat = await netcattyBridge.get()?.statLocal?.(filePath);
if (!stat || stat.type === "directory") return null;
return { size: Number(stat.size) || 0, lastModified: Number(stat.lastModified) || 0, type: stat.type };
}
if (!sftpId) return null;
const stat = await netcattyBridge.get()?.statSftp?.(sftpId, filePath, encoding);
if (!stat || stat.type === "directory") return null;
return { size: Number(stat.size) || 0, lastModified: Number(stat.lastModified) || 0, type: stat.type };
} catch {
return null;
}
}
async function tryStatTransferTarget(
targetPath: string,
targetIsLocal: boolean,
targetSftpId: string | null,
targetEncoding: SftpFilenameEncoding,
): Promise<{ size: number; lastModified: number; type: string } | null> {
return tryStatTransferPath(targetPath, targetIsLocal, targetSftpId, targetEncoding);
}
export type AcquireTransferSessionFn = (
hostId: string,
transferId: string,
@@ -357,13 +443,40 @@ export function useSftpDirectoryTransferOps({
await new Promise((resolve) => setTimeout(resolve, 80));
}
})();
let result: { error?: string; cancelled?: boolean } | undefined;
let result: { error?: string; cancelled?: boolean; superseded?: boolean } | undefined;
try {
result = await transferPromise;
} finally {
watchPaused = false;
await pauseWatch.catch(() => {});
}
// Same-id retry stole ownership while OPEN was pending. The live
// owner still drives progress/completion events; this invoke must
// not mark the child completed or failed (Codex P2 on b17f64e9).
if (result?.superseded === true) {
// Wait for the live same-id owner only — no fixed deadline so
// long transfers are not failed while still running (Codex P2).
for (;;) {
if (
cancelledTasksRef.current.has(task.id)
|| cancelledTasksRef.current.has(rootTaskId)
) {
throw new Error("Transfer cancelled");
}
const latest = sftpTransferCenterStore.getTask(task.id)
?? transfersRef.current.find((candidate) => candidate.id === task.id);
const status = latest?.status;
if (status === "completed") break;
if (status === "failed") {
throw new Error(latest?.error || "Transfer failed");
}
if (status === "cancelled") {
throw new Error("Transfer cancelled");
}
await new Promise((resolve) => setTimeout(resolve, 200));
}
break;
}
if (result?.error || result?.cancelled) {
throw new Error(result.error || "Transfer cancelled");
}
@@ -422,7 +535,7 @@ export function useSftpDirectoryTransferOps({
sameHost?: boolean,
symlinkDepth = 0,
followSymlinks = false, // Only true for downloadToLocal — uploads/copies treat symlinks as files
discoveryProgress?: { discoveredFiles: number; nextEntryIndex: number },
discoveryProgress?: DirectoryDiscoveryProgress,
traversalBudget?: SftpDirectoryTraversalBudget,
) => {
// Check if task or root task was cancelled before starting
@@ -430,8 +543,20 @@ export function useSftpDirectoryTransferOps({
throw new Error("Transfer cancelled");
}
// Always interleave discovery with transfer (rsync --inc-recursive style).
// Full-tree pre-scan was removed: non-compressed folder uploads start bytes
// as soon as each directory is listed; compressed upload is a separate path.
// UI should treat totalBytes as "discovered so far", not a fixed grand total.
let totalErrors = 0;
const progress = discoveryProgress ?? { discoveredFiles: 0, nextEntryIndex: 0 };
const progress = discoveryProgress ?? {
discoveredFiles: 0,
nextEntryIndex: 0,
listingGate: createDirectoryListingGate(resolveSftpDirectoryListingConcurrency()),
};
if (!progress.listingGate) {
progress.listingGate = createDirectoryListingGate(resolveSftpDirectoryListingConcurrency());
}
const listingGate = progress.listingGate;
const traversal = traversalBudget ?? createSftpDirectoryTraversalBudget();
let claimedCanonicalPath: string | null = null;
let regularFiles: SftpFileEntry[] = [];
@@ -473,13 +598,16 @@ export function useSftpDirectoryTransferOps({
}
let files: SftpFileEntry[];
if (sourceIsLocal) {
files = await listLocalFiles(task.sourcePath);
} else if (sourceSftpId) {
files = await listRemoteFiles(sourceSftpId, task.sourcePath, sourceEncoding);
} else {
// Tree-wide listing gate: parallel siblings share one budget.
files = await listingGate.run(async () => {
if (sourceIsLocal) {
return listLocalFiles(task.sourcePath);
}
if (sourceSftpId) {
return listRemoteFiles(sourceSftpId, task.sourcePath, sourceEncoding);
}
throw new Error("No source connection");
}
});
// Filter both "." and ".." — some SFTP servers include "." in readdir
const filtered = files.filter((f) => f.name !== ".." && f.name !== ".");
@@ -509,26 +637,25 @@ export function useSftpDirectoryTransferOps({
regularFiles.sort((left, right) => left.name.localeCompare(right.name));
// Directory progress is discovered by the same traversal that performs
// the transfer. This avoids a second full-tree list pass and lets the UI
// grow the total incrementally without flooding the server at startup.
// the transfer (single pass - no separate countDirectoryFiles walk).
// Process subdirectories sequentially so directoryEntryIndex / manifest
// order stay deterministic for resume, and sibling symlink aliases can
// claim the same canonical path after the prior branch releases.
progress.discoveredFiles += regularFiles.length;
setTransfers((prev) => prev.map((candidate) => candidate.id === rootTaskId
? {
...candidate,
totalBytes: Math.max(progress.discoveredFiles, candidate.transferredBytes),
// Keep scanning only until the first file completes; later lists must
// not flip an in-progress folder bar back to indeterminate thrash.
...(candidate.transferredBytes <= 0 ? { phase: "scanning" as const } : null),
}
: candidate));
// Process subdirectories sequentially to avoid unbounded concurrent SFTP
// requests from nested Promise.all + worker pools across the tree.
// File-level concurrency within each directory is still governed by the
// shared SFTP transfer worker scheduler below.
for (const dir of dirs) {
if (cancelledTasksRef.current.has(task.id) || cancelledTasksRef.current.has(rootTaskId)) {
throw new Error("Transfer cancelled");
}
// Pause between subfolders — otherwise the walk enters the next tree while
// the user thinks the whole folder transfer is paused.
await waitWhileTransferPaused(rootTaskId);
const childTask: TransferTask = {
@@ -544,7 +671,7 @@ export function useSftpDirectoryTransferOps({
};
const isSymlink = dir.type === "symlink";
const subdirErrors = await transferDirectory(
totalErrors += await transferDirectory(
childTask,
sourceSftpId,
targetSftpId,
@@ -559,7 +686,6 @@ export function useSftpDirectoryTransferOps({
progress,
traversal,
);
totalErrors += subdirErrors;
}
} finally {
// Release on success, cancellation, and traversal errors.
@@ -570,6 +696,12 @@ export function useSftpDirectoryTransferOps({
// Transfer files in parallel with concurrency limit
if (regularFiles.length > 0) {
setTransfers((prev) => prev.map((candidate) => candidate.id === rootTaskId
? {
...candidate,
phase: "transferring",
}
: candidate));
const errors: Error[] = [];
// If the SFTP session dies mid-directory, stop queueing more files
// (remaining workers will still finish their current item).
@@ -589,8 +721,10 @@ export function useSftpDirectoryTransferOps({
const fileSize = getEntrySize(file);
const sourcePath = joinPath(task.sourcePath, file.name);
const targetPath = joinTransferTargetPath(task.targetPath, file.name);
let transferSize = fileSize;
let transferLastModified = file.lastModified;
const directoryEntryIndex = directoryEntryBase + fileIndex;
const directoryEntryIdentity = createDirectoryEntryIdentity({
let directoryEntryIdentity = createDirectoryEntryIdentity({
sourcePath,
targetPath,
size: fileSize,
@@ -615,6 +749,104 @@ export function useSftpDirectoryTransferOps({
if (isPauseLatched(rootTaskId)) {
await waitWhileTransferPaused(rootTaskId);
}
// Symlink listing attrs are the link node; transfer follows target bytes.
// For regular files, re-stat so skip and transfer use current size/mtime
// (Codex P1: listing attrs can go stale during a long interleaved walk).
let freshSourceOk = false;
if (file.type !== "symlink") {
const freshSource = await tryStatTransferPath(
sourcePath,
sourceIsLocal,
sourceSftpId,
sourceEncoding,
);
if (freshSource && freshSource.type !== "directory") {
transferSize = freshSource.size;
transferLastModified = freshSource.lastModified;
directoryEntryIdentity = createDirectoryEntryIdentity({
sourcePath,
targetPath,
size: transferSize,
lastModified: transferLastModified,
});
freshSourceOk = true;
} else {
// Do not pass stale listing size as an explicit snapshot: the
// dedicated transfer session may still read a grown file (Codex P2).
// Child totalBytes 0 is omitted at startStreamTransfer via
// `|| undefined`, so the bridge re-stats instead of truncating.
transferSize = 0;
transferLastModified = 0;
}
}
const skipUnchanged = readSkipUnchangedEnabled()
&& !task.replaceExistingTarget
&& !String(task.targetPath).includes(".netcatty-");
// Missing fresh metadata must not fall back to listing attrs for skip.
if (skipUnchanged && file.type !== "symlink" && freshSourceOk) {
const existing = await tryStatTransferTarget(
targetPath,
targetIsLocal,
targetSftpId,
targetEncoding,
);
if (
existing
&& existing.type !== "directory"
&& isUnchangedTransferCandidate(
{ size: transferSize, lastModified: transferLastModified, mtimeUnit: "ms" },
{ size: existing.size, lastModified: existing.lastModified, mtimeUnit: "ms" },
)
) {
const skippedId = persistedChild?.id ?? crypto.randomUUID();
setTransfers((prev) => {
const hasChild = prev.some((row) => row.id === skippedId);
const base = hasChild
? prev.map((row) => row.id === skippedId
? {
...row,
status: "completed" as TransferStatus,
transferredBytes: transferSize,
totalBytes: transferSize,
endTime: Date.now(),
speed: 0,
error: undefined,
}
: row)
: [...prev, {
...task,
id: skippedId,
fileName: file.name,
originalFileName: file.name,
sourcePath,
targetPath,
isDirectory: false,
progressMode: "bytes" as const,
parentTaskId: rootTaskId,
totalBytes: transferSize,
transferredBytes: transferSize,
sourceLastModified: transferLastModified,
directoryEntryIndex,
directoryEntryIdentity,
status: "completed" as TransferStatus,
speed: 0,
startTime: Date.now(),
endTime: Date.now(),
}];
return base.map((row) => {
if (row.id !== rootTaskId) return row;
if (row.status === "paused" || row.status === "pausing" || isPauseLatched(rootTaskId)) {
return { ...row, speed: 0 };
}
return { ...row, transferredBytes: row.transferredBytes + 1 };
});
});
return;
}
}
const fileId = persistedChild?.id ?? crypto.randomUUID();
// Track child ID outside React state for immediate cancellation visibility
@@ -634,11 +866,11 @@ export function useSftpDirectoryTransferOps({
isDirectory: false,
progressMode: "bytes",
parentTaskId: rootTaskId,
totalBytes: fileSize,
sourceLastModified: file.lastModified,
totalBytes: transferSize,
sourceLastModified: transferLastModified || undefined,
directoryEntryIndex,
directoryEntryIdentity,
// Inherit retryable from parent — downloadToLocal sets retryable: false
// Inherit retryable from parent - downloadToLocal sets retryable: false
// because "local" targetConnectionId can't be resolved by retryTransfer
retryable: task.retryable,
// New/restarted child streams arm at bridge lifecycleEpoch 0. Never
@@ -746,7 +978,7 @@ export function useSftpDirectoryTransferOps({
? {
...t,
status: "failed" as TransferStatus,
error: "SFTP session lost — reconnect and resume remaining files",
error: "SFTP session lost - reconnect and resume remaining files",
speed: 0,
endTime: Date.now(),
}
@@ -59,3 +59,96 @@ test("upload task callbacks write through the transfer store without page callba
assert.equal(patches[1].updates.transferredBytes, 100);
assert.deepEqual(dismissed, []);
});
test("progress promotes pending scanning folder rows into transferring", () => {
const patches: Array<{ taskId: string; updates: Partial<TransferTask> }> = [];
const liveTask: TransferTask = {
id: "folder-1",
fileName: "docs",
sourcePath: "local",
targetPath: "/remote/docs",
sourceConnectionId: "external",
targetConnectionId: "connection-1",
direction: "upload",
status: "pending",
totalBytes: 0,
transferredBytes: 0,
speed: 0,
startTime: 1,
isDirectory: true,
progressMode: "files",
phase: "scanning",
};
const store = {
upsertTasks: () => {},
patchTask: (taskId: string, updates: Partial<TransferTask>) => patches.push({ taskId, updates }),
dismiss: () => {},
getTask: (taskId: string) => (taskId === "folder-1" ? liveTask : undefined),
};
const callbacks = createUploadTaskCallbacks({
ownerId: "owner-1",
connectionId: "connection-1",
targetPath: "/remote",
store,
});
callbacks.onTaskProgress?.("folder-1", {
transferred: 12,
total: 400,
speed: 0,
percent: 3,
phase: "transferring",
});
assert.equal(patches[0].taskId, "folder-1");
assert.equal(patches[0].updates.status, "transferring");
assert.equal(patches[0].updates.transferredBytes, 12);
assert.equal(patches[0].updates.totalBytes, 400);
assert.equal(patches[0].updates.phase, "transferring");
});
test("scanning callbacks expose live file counts in files progress mode", () => {
const upserts: TransferTask[][] = [];
const patches: Array<{ taskId: string; updates: Partial<TransferTask> }> = [];
const store = {
upsertTasks: (tasks: readonly TransferTask[]) => upserts.push([...tasks]),
patchTask: (taskId: string, updates: Partial<TransferTask>) => patches.push({ taskId, updates }),
dismiss: () => {},
};
const callbacks = createUploadTaskCallbacks({
ownerId: "owner-1",
connectionId: "connection-1",
targetPath: "/remote",
store,
});
callbacks.onScanningStart?.("scan-1", { label: "docs" });
callbacks.onScanningProgress?.("scan-1", {
fileCount: 1284,
directoryCount: 40,
entryCount: 1324,
label: "docs",
});
assert.equal(upserts[0][0].fileName, "docs");
assert.equal(upserts[0][0].phase, "scanning");
assert.equal(upserts[0][0].progressMode, "files");
assert.equal(upserts[0][0].status, "pending");
assert.deepEqual(patches[0], {
taskId: "scan-1",
updates: {
// Found files live in totalBytes; completed stays 0 during scan.
transferredBytes: 0,
totalBytes: 1284,
progressMode: "files",
phase: "scanning",
fileName: "docs",
},
});
// Display contract: 0 done · N found (not N done · N found).
const discovered = Math.max(patches[0].updates.totalBytes ?? 0, patches[0].updates.transferredBytes ?? 0);
const completed = patches[0].updates.transferredBytes ?? 0;
assert.equal(completed, 0);
assert.equal(discovered, 1284);
});
+48 -4
View File
@@ -6,7 +6,7 @@ import { joinPath } from "./utils";
type UploadTransferStore = Pick<
typeof sftpTransferCenterStore,
"upsertTasks" | "patchTask" | "dismiss"
>;
> & Partial<Pick<typeof sftpTransferCenterStore, "getTask">>;
interface UploadTaskCallbacksParams {
ownerId: string;
@@ -27,11 +27,11 @@ export const createUploadTaskCallbacks = ({
targetConnectionKey,
store = sftpTransferCenterStore,
}: UploadTaskCallbacksParams): UploadCallbacks => ({
onScanningStart: (taskId: string) => {
onScanningStart: (taskId: string, info?: { label?: string }) => {
store.upsertTasks([{
id: taskId,
ownerId,
fileName: "Scanning files...",
fileName: info?.label?.trim() || "Scanning files...",
sourcePath: "local",
targetPath,
sourceConnectionId: "external",
@@ -42,18 +42,30 @@ export const createUploadTaskCallbacks = ({
targetConnectionKey,
direction: "upload",
status: "pending" as TransferStatus,
// Progressive discovery: totalBytes = found so far, completed stays 0
// until real transfers start (UI: "0 done · N found").
totalBytes: 0,
transferredBytes: 0,
speed: 0,
startTime: Date.now(),
isDirectory: true,
progressMode: "bytes",
progressMode: "files",
origin: "drag-drop",
background: false,
resumable: true,
phase: "scanning",
}]);
},
onScanningProgress: (taskId: string, progress) => {
store.patchTask(taskId, {
// Found count is the growing total; nothing is completed during scan.
totalBytes: Math.max(0, progress.fileCount),
transferredBytes: 0,
progressMode: "files",
phase: "scanning",
...(progress.label?.trim() ? { fileName: progress.label.trim() } : null),
});
},
onScanningEnd: (taskId: string) => {
store.dismiss(taskId);
},
@@ -90,8 +102,36 @@ export const createUploadTaskCallbacks = ({
const durableCheckpoint = Number.isFinite(Number(progress.checkpointBytes))
? Math.max(0, Math.trunc(Number(progress.checkpointBytes)))
: progress.transferred;
// Progressive folder walks keep the scanning row as pending until real
// progress arrives. Promote pending/queued → transferring so the panel
// leaves "Waiting..." and matches the transfer-center live state.
const current = "getTask" in store && typeof store.getTask === "function"
? store.getTask(taskId)
: undefined;
const shouldPromote =
!!current
&& (current.status === "pending" || current.status === "queued")
&& current.reconnectRequired !== true
&& (
progress.phase === "scanning"
|| progress.phase === "transferring"
|| progress.transferred > 0
|| progress.total > 0
);
// Only patch fingerprint/checkpoint while paused — do not keep animating
// high-water transferred after the user hit Pause.
const isPausedLike = current?.status === "paused" || current?.status === "pausing";
if (isPausedLike) {
store.patchTask(taskId, {
checkpointBytes: durableCheckpoint,
resumable: progress.resumable,
pauseUnavailableReason: progress.pauseUnavailableReason,
...("sourceFingerprint" in progress && progress.sourceFingerprint
? { sourceFingerprint: progress.sourceFingerprint as string }
: null),
});
return;
}
store.patchTask(taskId, {
transferredBytes: progress.transferred,
totalBytes: progress.total,
@@ -101,6 +141,7 @@ export const createUploadTaskCallbacks = ({
phase: progress.phase,
resumable: progress.resumable,
pauseUnavailableReason: progress.pauseUnavailableReason,
...(shouldPromote ? { status: "transferring" as TransferStatus } : null),
// Durable pause identity may arrive on a forced progress event while
// status is already paused — keep it for restart/resume safety.
...("sourceFingerprint" in progress && progress.sourceFingerprint
@@ -121,6 +162,7 @@ export const createUploadTaskCallbacks = ({
endTime: Date.now(),
transferredBytes: totalBytes,
speed: 0,
phase: undefined,
});
},
onTaskFailed: (taskId: string, error: string) => {
@@ -129,6 +171,7 @@ export const createUploadTaskCallbacks = ({
endTime: Date.now(),
error,
speed: 0,
phase: undefined,
});
},
onTaskCancelled: (taskId: string) => {
@@ -136,6 +179,7 @@ export const createUploadTaskCallbacks = ({
status: "cancelled" as TransferStatus,
endTime: Date.now(),
speed: 0,
phase: undefined,
});
},
});
@@ -15,11 +15,63 @@ import {
UploadResult,
startUploadScanningTask,
} from "../../../lib/uploadService";
import { extractDropEntries, type DropEntry } from "../../../lib/sftpFileUtils";
import { uploadLocalFoldersProgressively } from "../../../lib/progressiveFolderUpload";
import {
captureDropPayload,
formatDropScanLabel,
isDropScanCancelledError,
localTreeToDropEntries,
materializeDropEntries,
type DropEntry,
type LocalTreeListEntry,
} from "../../../lib/sftpFileUtils";
function isUploadScanCancelled(controller: UploadController, error?: unknown): boolean {
return controller.isCancelled() || isDropScanCancelledError(error);
}
// Re-export UploadResult for external usage
export type { UploadResult };
type LocalTreeScanOptions = {
onProgress?: (progress: { fileCount: number; directoryCount: number; entryCount: number }) => void;
onEntries?: (entries: LocalTreeListEntry[]) => void;
abortSignal?: AbortSignal;
};
function createDropScanCancelledError(): Error {
const error = new Error("Drop scan cancelled");
(error as Error & { code?: string }).code = "ERR_DROP_SCAN_CANCELLED";
return error;
}
async function listLocalTreeWithAbort(
bridge: NetcattyBridge,
localPath: string,
options: LocalTreeScanOptions = {},
): Promise<LocalTreeListEntry[]> {
const scanId = `drop-scan-${crypto.randomUUID()}`;
const abortSignal = options.abortSignal;
const requestCancel = () => {
void bridge.cancelLocalTreeScan?.(scanId);
};
if (abortSignal?.aborted) {
throw createDropScanCancelledError();
}
abortSignal?.addEventListener("abort", requestCancel, { once: true });
const { abortSignal: _abortSignal, ...bridgeOptions } = options;
try {
return await bridge.listLocalTree!(localPath, { ...bridgeOptions, scanId });
} catch (error) {
if (abortSignal?.aborted || isDropScanCancelledError(error)) {
throw createDropScanCancelledError();
}
throw error;
} finally {
abortSignal?.removeEventListener("abort", requestCancel);
}
}
import type { UseSftpExternalOperationsParams, SftpExternalOperationsResult } from "./useSftpExternalOperations.types";
import { getSftpTransferResourceKeys, globalSftpTransferScheduler } from "./globalTransferScheduler";
import { localStorageAdapter } from "../../../infrastructure/persistence/localStorageAdapter";
@@ -48,6 +100,10 @@ import {
registerExternalUploadController,
unregisterExternalUploadController,
} from "./externalUploadRuntime";
import {
isTransferPauseLatched,
waitWhileTransferOrRootPaused,
} from "./transferPauseLatch";
type UploadConflictResolver = {
resolve: (action: FileConflictAction) => void;
@@ -151,9 +207,12 @@ export const useSftpExternalOperations = (
callbacks: UploadCallbacks,
): UploadCallbacks => ({
...callbacks,
onScanningStart: (taskId) => {
onScanningStart: (taskId, info) => {
registerUploadController(taskId, controller);
callbacks.onScanningStart?.(taskId);
callbacks.onScanningStart?.(taskId, info);
},
onScanningProgress: (taskId, progress) => {
callbacks.onScanningProgress?.(taskId, progress);
},
onTaskCreated: (task) => {
registerUploadController(task.id, controller);
@@ -764,9 +823,12 @@ export const useSftpExternalOperations = (
conflictDefaults.set(conflictType, action);
},
});
controller.addCancelListener(() => {
cancelPendingUploadConflicts(controller);
});
});
};
}, []);
}, [cancelPendingUploadConflicts]);
// Create upload bridge that wraps netcattyBridge.
// Pass connect-time Host so pooled stream uploads open the pinned endpoint
@@ -888,52 +950,271 @@ export const useSftpExternalOperations = (
const uploadExternalFiles = useCallback(
async (side: "left" | "right", dataTransfer: DataTransfer, targetPath?: string): Promise<UploadResult[]> => {
// DataTransfer is only valid during the drop event. Capture entries BEFORE
// DataTransfer is only valid during the drop event. Capture roots BEFORE
// any await (session reconnect can take seconds while a transfer is busy).
// Otherwise extractDropEntries returns [] and the UI toasts "Uploaded files: 0".
let capturedEntries: DropEntry[];
try {
capturedEntries = await extractDropEntries(dataTransfer);
} catch (error) {
logger.error("[SFTP] Failed to read dropped files:", error);
throw error;
}
if (capturedEntries.length === 0) {
// Native tree expansion (listLocalTree) happens after the scanning UI is up.
const dropPayload = captureDropPayload(dataTransfer);
if (dropPayload.roots.length === 0 && dropPayload.filesFallback.length === 0) {
return [];
}
const pane = getActivePane(side);
if (!pane?.connection) {
throw new Error("No active connection");
}
const bridge = netcattyBridge.get();
if (!bridge) {
throw new Error("Bridge not available");
}
const uploadTargetPath = targetPath || pane.connection.currentPath;
const controller = new UploadController();
const callbacks = bindUploadControllerCallbacks(
controller,
createUploadCallbacks(
pane.connection.id,
uploadTargetPath,
pane.connection.isLocal ? undefined : pane.connection.hostId,
pane.connection.isLocal ? undefined : connectionCacheKeyMapRef.current.get(pane.connection.id),
pane.connection.isLocal ? undefined : pane.connection.hostLabel,
),
);
const pathBackedFolderRoots = dropPayload.roots
.filter((root) => root.isDirectory && !!root.localPath)
.map((root) => ({ name: root.name, localPath: root.localPath! }));
// Progressive only when *every* root is a path-backed folder. Mixed drops
// (folder + files, path-less dirs) must fall through to materialize so
// sibling items are not silently dropped.
const canProgressiveUpload = pathBackedFolderRoots.length > 0
&& pathBackedFolderRoots.length === dropPayload.roots.length
&& !!bridge.listLocalTree
&& !useCompressedUpload;
const needsDeepScan = dropPayload.roots.some((root) => root.isDirectory);
const scanLabel = formatDropScanLabel(dropPayload.roots);
// Instant feedback: scanning / parent row appears before work begins.
const scanningTask = needsDeepScan
? startUploadScanningTask(callbacks, crypto.randomUUID(), { label: scanLabel })
: null;
if (scanningTask && typeof window !== "undefined") {
window.dispatchEvent(new CustomEvent("netcatty:open-sftp-transfer-center"));
}
const scanAbort = new AbortController();
const detachScanCancel = controller.addCancelListener(() => {
scanAbort.abort();
if (scanningTask?.isOpen()) scanningTask.cancel();
});
// Path-backed folders (no compressed upload): edge-scan + edge-transfer.
// Open the SFTP session first, then stream discovery batches into upload
// workers so the first files start while the rest of the tree is still walking.
if (canProgressiveUpload) {
const runProgressive = async (forceReconnect = false): Promise<UploadResult[]> => {
const { sftpId, release } = await resolveRemoteSftpId(side, { forceReconnect });
const livePane = getActivePane(side) ?? pane;
if (!livePane.connection) {
release();
throw new Error("No active connection");
}
const uploadPaneId = livePane.id;
const liveTargetPath = targetPath || livePane.connection.currentPath;
const connectHost = resolveUploadConnectHost(uploadPaneId, livePane.connection.isLocal);
const uploadBridge = createUploadBridge(connectHost);
const liveCallbacks = livePane.connection.id === pane.connection.id
&& liveTargetPath === uploadTargetPath
? callbacks
: bindUploadControllerCallbacks(
controller,
createUploadCallbacks(
livePane.connection.id,
liveTargetPath,
livePane.connection.isLocal ? undefined : livePane.connection.hostId,
livePane.connection.isLocal ? undefined : connectionCacheKeyMapRef.current.get(livePane.connection.id),
livePane.connection.isLocal ? undefined : livePane.connection.hostLabel,
),
);
const parentTaskIds = new Map<string, string>();
if (scanningTask && pathBackedFolderRoots.length === 1) {
parentTaskIds.set(pathBackedFolderRoots[0].name, scanningTask.taskId);
// Keep the scanning row as the folder parent (do not dismiss it).
scanningTask.complete = () => {
/* no-op: progressive path settles via onTaskCompleted/Cancelled */
};
} else if (scanningTask?.isOpen()) {
scanningTask.complete();
}
try {
if (controller.isCancelled()) {
scanningTask?.cancel();
return [{ fileName: "", success: false, cancelled: true }];
}
const results = await uploadLocalFoldersProgressively(
pathBackedFolderRoots,
{
targetPath: liveTargetPath,
sftpId,
targetHostId: livePane.connection.isLocal ? undefined : livePane.connection.hostId,
isLocal: livePane.connection.isLocal,
bridge: uploadBridge,
joinPath,
callbacks: liveCallbacks,
parentTaskIds,
abortSignal: scanAbort.signal,
// Soft-pause must freeze discovery enqueue + child UI rows, not
// only the open streams (otherwise Pause still floods the queue).
waitWhilePaused: (parentTaskId) => waitWhileTransferOrRootPaused(parentTaskId),
isPaused: (parentTaskId) => isTransferPauseLatched(parentTaskId),
resolveConflict: createUploadConflictResolver(controller),
listLocalTree: (localPath, treeOptions) => listLocalTreeWithAbort(bridge, localPath, {
...treeOptions,
abortSignal: scanAbort.signal,
}),
},
controller,
);
if (clearDirCacheEntry && targetPath) {
clearDirCacheEntry(livePane.connection.id, liveTargetPath);
}
if (liveTargetPath === livePane.connection.currentPath) {
await refresh(side, { tabId: uploadPaneId });
}
return results;
} finally {
release();
}
};
try {
return await runProgressive(false);
} catch (error) {
if (isSessionError(error) && ensureRemoteSftpId) {
logger.warn("[SFTP] Progressive upload session lost; reconnecting once", error);
try {
return await runProgressive(true);
} catch (retryError) {
if (isUploadScanCancelled(controller, retryError)) {
scanningTask?.cancel();
return [{ fileName: "", success: false, cancelled: true }];
}
if (scanningTask?.isOpen()) scanningTask.fail(retryError);
throw retryError;
}
}
if (isUploadScanCancelled(controller, error)) {
scanningTask?.cancel();
return [{ fileName: "", success: false, cancelled: true }];
}
if (scanningTask?.isOpen()) scanningTask.fail(error);
logger.error("[SFTP] Progressive folder upload failed:", error);
throw error;
} finally {
detachScanCancel();
unregisterUploadController(controller);
}
}
let capturedEntries: DropEntry[] = [];
try {
const scanT0 = performance.now();
capturedEntries = await materializeDropEntries(dropPayload, {
abortSignal: scanAbort.signal,
isCancelled: () => controller.isCancelled(),
listLocalTree: bridge.listLocalTree
? (localPath, treeOptions) => listLocalTreeWithAbort(bridge, localPath, {
...treeOptions,
abortSignal: scanAbort.signal,
})
: undefined,
onProgress: scanningTask
? (progress) => {
callbacks.onScanningProgress?.(scanningTask.taskId, {
...progress,
label: progress.label || scanLabel,
});
}
: undefined,
});
logger.debug(
`[SFTP:perf] materializeDropEntries — ${capturedEntries.length} entries — ${(performance.now() - scanT0).toFixed(0)}ms`,
);
} catch (error) {
if (scanningTask?.isOpen()) {
if (isUploadScanCancelled(controller, error)) {
scanningTask.cancel();
} else {
scanningTask.fail(error);
}
}
detachScanCancel();
unregisterUploadController(controller);
if (isUploadScanCancelled(controller, error)) {
return [{ fileName: "", success: false, cancelled: true }];
}
logger.error("[SFTP] Failed to read dropped files:", error);
throw error;
}
if (controller.isCancelled()) {
scanningTask?.cancel();
detachScanCancel();
unregisterUploadController(controller);
return [{ fileName: "", success: false, cancelled: true }];
}
if (capturedEntries.length === 0) {
scanningTask?.complete();
detachScanCancel();
unregisterUploadController(controller);
return [];
}
// Keep scanning visible through session open so the UI never goes blank
// between "N files found" and the real transfer rows.
const run = async (forceReconnect = false): Promise<UploadResult[]> => {
const pane = getActivePane(side);
if (!pane?.connection) {
const { sftpId, release } = await resolveRemoteSftpId(side, { forceReconnect });
const livePane = getActivePane(side) ?? pane;
if (!livePane.connection) {
release();
throw new Error("No active connection");
}
const bridge = netcattyBridge.get();
if (!bridge) {
throw new Error("Bridge not available");
}
const { sftpId, release } = await resolveRemoteSftpId(side, { forceReconnect });
const livePane = getActivePane(side) ?? pane;
if (!livePane.connection) throw new Error("No active connection");
const uploadPaneId = livePane.id;
const uploadTargetPath = targetPath || livePane.connection.currentPath;
const controller = new UploadController();
const callbacks = bindUploadControllerCallbacks(
controller,
createUploadCallbacks(
livePane.connection.id,
uploadTargetPath,
livePane.connection.isLocal ? undefined : livePane.connection.hostId,
livePane.connection.isLocal ? undefined : connectionCacheKeyMapRef.current.get(livePane.connection.id),
livePane.connection.isLocal ? undefined : livePane.connection.hostLabel,
),
);
const liveTargetPath = targetPath || livePane.connection.currentPath;
const connectHost = resolveUploadConnectHost(uploadPaneId, livePane.connection.isLocal);
const uploadBridge = createUploadBridge(connectHost);
// Retarget callbacks if the pane path/id changed after reconnect.
const liveCallbacks = livePane.connection.id === pane.connection.id
&& liveTargetPath === uploadTargetPath
? callbacks
: bindUploadControllerCallbacks(
controller,
createUploadCallbacks(
livePane.connection.id,
liveTargetPath,
livePane.connection.isLocal ? undefined : livePane.connection.hostId,
livePane.connection.isLocal ? undefined : connectionCacheKeyMapRef.current.get(livePane.connection.id),
livePane.connection.isLocal ? undefined : livePane.connection.hostLabel,
),
);
const transferCallbacks = scanningTask
? {
...liveCallbacks,
onTaskCreated: (task: Parameters<NonNullable<UploadCallbacks["onTaskCreated"]>>[0]) => {
scanningTask.complete();
liveCallbacks.onTaskCreated?.(task);
},
}
: liveCallbacks;
let transferSucceeded = false;
try {
if (controller.isCancelled()) {
scanningTask?.cancel();
return [{ fileName: "", success: false, cancelled: true }];
}
const hasDirectory = capturedEntries.some((entry) => (
entry.isDirectory || entry.relativePath.replace(/\\/g, "/").includes("/")
));
@@ -951,13 +1232,13 @@ export const useSftpExternalOperations = (
run: async (uploadSftpId) => uploadEntriesDirect(
capturedEntries,
{
targetPath: uploadTargetPath,
targetPath: liveTargetPath,
sftpId: uploadSftpId,
targetHostId: livePane.connection!.isLocal ? undefined : livePane.connection!.hostId,
isLocal: livePane.connection!.isLocal,
bridge: uploadBridge,
joinPath,
callbacks,
callbacks: transferCallbacks,
useCompressedUpload,
resolveConflict: createUploadConflictResolver(controller),
},
@@ -966,15 +1247,19 @@ export const useSftpExternalOperations = (
});
if (clearDirCacheEntry && targetPath) {
clearDirCacheEntry(livePane.connection.id, uploadTargetPath);
clearDirCacheEntry(livePane.connection.id, liveTargetPath);
}
if (uploadTargetPath === livePane.connection.currentPath) {
if (liveTargetPath === livePane.connection.currentPath) {
await refresh(side, { tabId: uploadPaneId });
}
transferSucceeded = true;
return results;
} finally {
if (scanningTask?.isOpen()) {
if (controller.isCancelled()) scanningTask.cancel();
else if (transferSucceeded) scanningTask.complete();
}
release();
unregisterUploadController(controller);
}
};
@@ -983,10 +1268,26 @@ export const useSftpExternalOperations = (
} catch (error) {
if (isSessionError(error) && ensureRemoteSftpId) {
logger.warn("[SFTP] Upload session lost; reconnecting and retrying once", error);
return await run(true);
try {
return await run(true);
} catch (retryError) {
if (isUploadScanCancelled(controller, retryError)) {
scanningTask?.cancel();
} else if (scanningTask?.isOpen()) {
scanningTask.fail(retryError);
}
throw retryError;
}
}
if (scanningTask?.isOpen()) {
if (controller.isCancelled()) scanningTask.cancel();
else scanningTask.fail(error);
}
logger.error("[SFTP] Upload failed:", error);
throw error;
} finally {
detachScanCancel();
unregisterUploadController(controller);
}
},
[
@@ -1146,105 +1447,125 @@ export const useSftpExternalOperations = (
connectionCacheKeyMapRef.current,
);
const initialUploadTargetPath = targetPath || originatingPane.connection.currentPath;
const controller = new UploadController();
const callbacks = bindUploadControllerCallbacks(
controller,
createUploadCallbacks(
originatingPane.connection.id,
initialUploadTargetPath,
originatingPane.connection.isLocal ? undefined : originatingPane.connection.hostId,
originatingPane.connection.isLocal
? undefined
: connectionCacheKeyMapRef.current.get(originatingPane.connection.id),
originatingPane.connection.isLocal ? undefined : originatingPane.connection.hostLabel,
),
);
const folderName = folderPath.replace(/\\/g, "/").split("/").filter(Boolean).pop()
|| folderPath;
const scanningTask = startUploadScanningTask(callbacks, crypto.randomUUID(), {
label: folderName,
});
if (typeof window !== "undefined") {
window.dispatchEvent(new CustomEvent("netcatty:open-sftp-transfer-center"));
}
const scanAbort = new AbortController();
const detachScanCancel = controller.addCancelListener(() => {
scanAbort.abort();
if (scanningTask.isOpen()) scanningTask.cancel();
});
let capturedEntries: DropEntry[] | null = null;
const run = async (forceReconnect = false): Promise<UploadResult[]> => {
const pane = resolveUploadTargetPane({
side,
tabId: originatingTabId,
getActivePane,
getPaneByTabId,
getPaneByConnectionId,
});
assertUploadEndpointUnchanged(
pane.connection,
originatingEndpoint,
connectionCacheKeyMapRef.current,
);
const bridge = netcattyBridge.get();
if (!bridge) throw new Error("Bridge not available");
if (!bridge.listLocalTree) throw new Error("Folder upload not supported");
const { sftpId, release } = await resolveRemoteSftpId(side, {
forceReconnect,
connectionId: pane.connection.id,
tabId: originatingTabId,
});
// Never re-resolve via getActivePane after awaits — focus may have moved.
const livePane = resolveUploadTargetPane({
side,
tabId: originatingTabId,
getActivePane,
getPaneByTabId,
getPaneByConnectionId,
});
assertUploadEndpointUnchanged(
livePane.connection,
originatingEndpoint,
connectionCacheKeyMapRef.current,
);
const uploadPaneId = livePane.id;
const uploadTargetPath = targetPath || livePane.connection.currentPath;
const controller = new UploadController();
const callbacks = bindUploadControllerCallbacks(
controller,
createUploadCallbacks(
livePane.connection.id,
uploadTargetPath,
livePane.connection.isLocal ? undefined : livePane.connection.hostId,
livePane.connection.isLocal ? undefined : connectionCacheKeyMapRef.current.get(livePane.connection.id),
livePane.connection.isLocal ? undefined : livePane.connection.hostLabel,
),
);
// Pin connect-time Host before listLocalTree: a slow folder scan can
// outlive a same-hostId tab rebind, and resolveUploadConnectHost would
// otherwise open the pooled stream bridge on the newly selected endpoint.
const uploadConnectHost = resolveUploadConnectHost(
uploadPaneId,
livePane.connection.isLocal,
);
const uploadBridge = createUploadBridge(uploadConnectHost);
const scanningTask = startUploadScanningTask(callbacks);
if (controller.isCancelled()) {
scanningTask.cancel();
return [{ fileName: "", success: false, cancelled: true }];
}
let release = () => {};
try {
const localEntries = await bridge.listLocalTree(folderPath);
const pane = resolveUploadTargetPane({
side,
tabId: originatingTabId,
getActivePane,
getPaneByTabId,
getPaneByConnectionId,
});
assertUploadEndpointUnchanged(
pane.connection,
originatingEndpoint,
connectionCacheKeyMapRef.current,
);
const bridge = netcattyBridge.get();
if (!bridge) throw new Error("Bridge not available");
if (!bridge.listLocalTree) throw new Error("Folder upload not supported");
const resolved = await resolveRemoteSftpId(side, {
forceReconnect,
connectionId: pane.connection.id,
tabId: originatingTabId,
});
const sftpId = resolved.sftpId;
release = resolved.release;
// Never re-resolve via getActivePane after awaits — focus may have moved.
const livePane = resolveUploadTargetPane({
side,
tabId: originatingTabId,
getActivePane,
getPaneByTabId,
getPaneByConnectionId,
});
assertUploadEndpointUnchanged(
livePane.connection,
originatingEndpoint,
connectionCacheKeyMapRef.current,
);
const uploadPaneId = livePane.id;
const uploadTargetPath = targetPath || livePane.connection.currentPath;
// Pin connect-time Host before listLocalTree: a slow folder scan can
// outlive a same-hostId tab rebind, and resolveUploadConnectHost would
// otherwise open the pooled stream bridge on the newly selected endpoint.
const uploadConnectHost = resolveUploadConnectHost(
uploadPaneId,
livePane.connection.isLocal,
);
const uploadBridge = createUploadBridge(uploadConnectHost);
const liveCallbacks = bindUploadControllerCallbacks(
controller,
createUploadCallbacks(
livePane.connection.id,
uploadTargetPath,
livePane.connection.isLocal ? undefined : livePane.connection.hostId,
livePane.connection.isLocal
? undefined
: connectionCacheKeyMapRef.current.get(livePane.connection.id),
livePane.connection.isLocal ? undefined : livePane.connection.hostLabel,
),
);
const transferCallbacks = {
...liveCallbacks,
onTaskCreated: (task: Parameters<NonNullable<UploadCallbacks["onTaskCreated"]>>[0]) => {
scanningTask.complete();
liveCallbacks.onTaskCreated?.(task);
},
};
if (capturedEntries === null) {
const localEntries = await listLocalTreeWithAbort(bridge, folderPath, {
abortSignal: scanAbort.signal,
onProgress: (progress) => {
callbacks.onScanningProgress?.(scanningTask.taskId, {
...progress,
label: folderName,
});
},
});
capturedEntries = localTreeToDropEntries(localEntries);
}
if (controller.isCancelled()) {
scanningTask.cancel();
return [{ fileName: "", success: false, cancelled: true }];
}
scanningTask.complete();
const entries: DropEntry[] = localEntries.map((entry) => {
if (entry.type === "directory") {
return {
file: null,
relativePath: entry.relativePath,
isDirectory: true,
};
}
const file = {
name: entry.relativePath.split("/").pop() || entry.relativePath,
size: entry.size,
lastModified: entry.lastModified,
type: "",
path: entry.localPath,
arrayBuffer: async () => {
const currentBridge = netcattyBridge.get();
if (!currentBridge?.readLocalFile) {
throw new Error("Local file reading not supported");
}
return currentBridge.readLocalFile(entry.localPath);
},
} as File & { path?: string };
return {
file,
relativePath: entry.relativePath,
isDirectory: false,
};
});
const results = await runWithCompressedUploadSession({
enabled: useCompressedUpload,
@@ -1258,7 +1579,7 @@ export const useSftpExternalOperations = (
: undefined,
shouldDiscard: isSessionError,
run: async (uploadSftpId) => uploadEntriesDirect(
entries,
capturedEntries,
{
targetPath: uploadTargetPath,
sftpId: uploadSftpId,
@@ -1266,7 +1587,7 @@ export const useSftpExternalOperations = (
isLocal: livePane.connection!.isLocal,
bridge: uploadBridge,
joinPath,
callbacks,
callbacks: transferCallbacks,
useCompressedUpload,
resolveConflict: createUploadConflictResolver(controller),
},
@@ -1282,18 +1603,8 @@ export const useSftpExternalOperations = (
await refresh(refreshSide, { tabId: uploadPaneId });
}
return results;
} catch (error) {
if (controller.isCancelled()) {
scanningTask.cancel();
return [{ fileName: "", success: false, cancelled: true }];
}
if (scanningTask.isOpen()) {
scanningTask.fail(error);
}
throw error;
} finally {
release();
unregisterUploadController(controller);
}
};
@@ -1302,11 +1613,33 @@ export const useSftpExternalOperations = (
} catch (error) {
if (isSessionError(error) && ensureRemoteSftpId) {
logger.warn("[SFTP] Folder upload session lost; reconnecting and retrying once", error);
return await run(true);
try {
return await run(true);
} catch (retryError) {
if (isUploadScanCancelled(controller, retryError)) {
scanningTask.cancel();
return [{ fileName: "", success: false, cancelled: true }];
}
if (scanningTask.isOpen()) scanningTask.fail(retryError);
throw retryError;
}
}
if (isUploadScanCancelled(controller, error)) {
scanningTask.cancel();
return [{ fileName: "", success: false, cancelled: true }];
}
if (scanningTask.isOpen()) scanningTask.fail(error);
logger.error("[SFTP] Folder picker upload failed:", error);
throw error;
}
finally {
detachScanCancel();
unregisterUploadController(controller);
if (scanningTask.isOpen()) {
if (controller.isCancelled()) scanningTask.cancel();
else scanningTask.complete();
}
}
},
[
acquireTransferSession,
@@ -232,7 +232,7 @@ test("buildSftpHostCredentials rejects proxy identities with blank usernames eve
label: "Proxy login",
username: " ",
authMethod: "password",
password: "enc:v1:djEwAAAA",
password: "enc:v1:djEwdGVzdAAAAAAAAAAAAAAAAA==",
created: 1,
}],
}),
@@ -285,7 +285,7 @@ test("buildSftpHostCredentials rejects incomplete proxy identities on jump hosts
label: "Proxy login",
username: "",
authMethod: "password",
password: "enc:v1:djEwAAAA",
password: "enc:v1:djEwdGVzdAAAAAAAAAAAAAAAAA==",
created: 1,
}],
}),
@@ -368,7 +368,7 @@ test("buildSftpHostCredentials rejects undecryptable proxy identity passwords",
label: "Proxy login",
username: "proxy-user",
authMethod: "password",
password: "enc:v1:djEwAAAA",
password: "enc:v1:djEwdGVzdAAAAAAAAAAAAAAAAA==",
created: 1,
}],
}),
@@ -434,7 +434,7 @@ test("buildSftpHostCredentials rejects undecryptable jump host proxy identity pa
label: "Proxy login",
username: "proxy-user",
authMethod: "password",
password: "enc:v1:djEwAAAA",
password: "enc:v1:djEwdGVzdAAAAAAAAAAAAAAAAA==",
created: 1,
}],
}),
@@ -539,7 +539,7 @@ test("buildSftpHostCredentials rejects undecryptable saved password credentials"
() => buildSftpHostCredentials({
host: host({
authMethod: "password",
password: "enc:v1:djEwAAAA",
password: "enc:v1:djEwdGVzdAAAAAAAAAAAAAAAAA==",
}),
hosts: [],
keys: [],
@@ -553,7 +553,7 @@ test("buildSftpHostCredentials keeps automatic target discovery available with a
const credentials = buildSftpHostCredentials({
host: host({
authMethod: "auto",
password: "enc:v1:djEwAAAA",
password: "enc:v1:djEwdGVzdAAAAAAAAAAAAAAAAA==",
}),
hosts: [],
keys: [],
@@ -569,7 +569,7 @@ test("buildSftpHostCredentials keeps automatic jump discovery available with an
id: "jump-1",
label: "Jump",
authMethod: "auto",
password: "enc:v1:djEwAAAA",
password: "enc:v1:djEwdGVzdAAAAAAAAAAAAAAAAA==",
});
const credentials = buildSftpHostCredentials({
host: host({ hostChain: { hostIds: ["jump-1"] } }),
@@ -604,7 +604,7 @@ test("buildSftpHostCredentials rejects undecryptable saved key material without
id: "key-1",
label: "Imported key",
type: "ED25519",
privateKey: "enc:v1:djEwAAAA",
privateKey: "enc:v1:djEwdGVzdAAAAAAAAAAAAAAAAA==",
source: "imported",
category: "key",
created: 1,
@@ -626,7 +626,7 @@ test("buildSftpHostCredentials does not use stale local key paths when a selecte
id: "key-1",
label: "Imported key",
type: "ED25519",
privateKey: "enc:v1:djEwAAAA",
privateKey: "enc:v1:djEwdGVzdAAAAAAAAAAAAAAAAA==",
source: "imported",
category: "key",
created: 1,
@@ -653,7 +653,7 @@ test("buildSftpHostCredentials uses the system agent when a synced key cannot be
label: "Synced key",
type: "ED25519",
publicKey: "ssh-ed25519 AAAASELECTED",
privateKey: "enc:v1:djEwAAAA",
privateKey: "enc:v1:djEwdGVzdAAAAAAAAAAAAAAAAA==",
source: "imported",
category: "key",
created: 1,
+67 -3
View File
@@ -60,6 +60,10 @@ import {
} from "./transferConflictLifecycle";
import { getParentPath, joinPath } from "./utils";
import { promoteDirectoryReplaceStage as promoteDirectoryReplacePaths } from "./directoryReplacePromotion";
import {
isExternalDragDropFileUpload,
retryExternalDragDropFileUpload,
} from "./externalDragDropRetry";
/** Keep the MutableRefObject mirror in sync with the process-global latch set. */
function syncPausedTasksRef(ref: { current: Set<string> }, taskId: string, latched: boolean) {
@@ -1294,8 +1298,62 @@ export const useSftpTransfers = ({
const retryTransfer = useCallback(
async (transferId: string) => {
const task = transfersRef.current.find((t) => t.id === transferId);
// Prefer the live owner list; fall back to the center store (drag-drop
// children are upserted there and mirrored via subscribe).
const task = transfersRef.current.find((t) => t.id === transferId)
?? sftpTransferCenterStore.getTask(transferId);
if (!task || task.retryable === false) return;
// Progressive / external drag-drop children never have dual-pane endpoints
// (sourceConnectionId is "external"). Re-run startStreamTransfer in place.
if (isExternalDragDropFileUpload(task)) {
if (inFlightTransferIdsRef.current.has(transferId)) return;
inFlightTransferIdsRef.current.add(transferId);
clearTransferCancelled(transferId);
cancelledTasksRef.current.delete(transferId);
try {
const result = await retryExternalDragDropFileUpload(task, {
getBrowseSftpId: (connectionId) => sftpSessionsRef.current.get(connectionId),
acquireTransferSession: acquireTransferSession
? (hostId, id) => acquireTransferSession(hostId, id)
: undefined,
startStreamTransfer: async (options) => {
const bridge = netcattyBridge.get();
if (!bridge?.startStreamTransfer) {
return { error: "Stream transfer is unavailable" };
}
return bridge.startStreamTransfer(options);
},
clearPendingCancel: (id) => netcattyBridge.get()?.clearPendingTransferCancel?.(id),
cleanupArtifacts: cleanupTaskArtifacts,
getTask: (id) => sftpTransferCenterStore.getTask(id)
?? transfersRef.current.find((row) => row.id === id),
getChildTasks: (parentId) => {
const fromStore = sftpTransferCenterStore.getOwnerTasks(ownerId)
.filter((row) => row.parentTaskId === parentId);
if (fromStore.length > 0) return fromStore;
return transfersRef.current.filter((row) => row.parentTaskId === parentId);
},
onPatch: (taskId, updates) => {
transferRuntime.patchTask(taskId, updates);
setTransfers((prev) => {
if (!prev.some((row) => row.id === taskId)) {
// Store-only child: mirror the patch by re-pulling owner rows.
return sftpTransferCenterStore.getOwnerTasks(ownerId);
}
return prev.map((row) => (row.id === taskId ? { ...row, ...updates } : row));
});
},
});
if (!result.success && result.error && !/cancelled/i.test(result.error)) {
notify.warning(result.error, "SFTP");
}
} finally {
inFlightTransferIdsRef.current.delete(transferId);
}
return;
}
await cleanupTaskArtifacts(task);
const retriedTask: TransferTask = {
@@ -1316,7 +1374,13 @@ export const useSftpTransfers = ({
};
const endpoints = resolveTaskEndpoints(task);
if (!endpoints) return;
if (!endpoints) {
notify.warning(
"Could not resolve transfer endpoints for retry. Reconnect the target and try again.",
"SFTP",
);
return;
}
const { targetSide, sourcePane, targetPane } = endpoints;
const completionHandler = completionHandlersRef.current.get(transferId);
@@ -1335,7 +1399,7 @@ export const useSftpTransfers = ({
await processTransfer(retriedTask, sourcePane, targetPane, targetSide);
},
// eslint-disable-next-line react-hooks/exhaustive-deps -- processTransfer is defined inline
[cleanupTaskArtifacts, resolveTaskEndpoints, setTransfers],
[acquireTransferSession, cleanupTaskArtifacts, ownerId, resolveTaskEndpoints, setTransfers, sftpSessionsRef],
);
const clearCompletedTransfers = useCallback(() => {
+45
View File
@@ -191,3 +191,48 @@ test("auto-sync skips only the exact remote-applied data hash", () => {
"remote-apply skip hash must survive temporary sync blockers and clear only after a successful sync",
);
});
test("startup local-wins and merge round-trips refuse device-bound credential placeholders", () => {
const source = readFileSync(new URL("./useAutoSync.ts", import.meta.url), "utf8");
const uploadLocalIndex = source.indexOf("if (conflictAction === 'upload-local')");
const uploadGuardIndex = source.indexOf(
"findSyncPayloadEncryptedCredentialPaths(localPayload)",
uploadLocalIndex,
);
const uploadPushIndex = source.indexOf("manager.syncAllProviders(localPayload)", uploadGuardIndex);
const mergeRoundTripBlockIndex = source.indexOf(
"stripSyncPayloadEncryptedCredentials(mergeResult.payload)",
);
const mergeLocalHealIndex = source.indexOf(
"healPoisonedSecretsForMerge(localPayload, remoteRaw, base)",
);
const mergeRemoteHealIndex = source.indexOf(
"healPoisonedSecretsForMerge(remoteRaw, localPayload, base)",
);
const mergePushIndex = source.indexOf(
"manager.syncAllProviders(portableMerge)",
mergeRoundTripBlockIndex,
);
const stripRemoteIndex = source.indexOf(
"stripSyncPayloadEncryptedCredentials(remoteRaw)",
);
assert.notEqual(uploadLocalIndex, -1);
assert.notEqual(uploadGuardIndex, -1);
assert.notEqual(uploadPushIndex, -1);
assert.notEqual(mergeLocalHealIndex, -1);
assert.notEqual(mergeRemoteHealIndex, -1);
assert.notEqual(mergeRoundTripBlockIndex, -1);
assert.notEqual(mergePushIndex, -1);
assert.notEqual(stripRemoteIndex, -1);
assert.ok(
uploadGuardIndex < uploadPushIndex,
"startup local-wins must guard placeholders before syncAllProviders",
);
assert.ok(
mergeLocalHealIndex < mergeRoundTripBlockIndex
&& mergeRemoteHealIndex < mergeRoundTripBlockIndex
&& mergeRoundTripBlockIndex < mergePushIndex,
"startup merge must heal local+remote secrets, then strip, then upload",
);
});
+33 -13
View File
@@ -14,6 +14,8 @@ import { getCloudSyncManager } from '../../infrastructure/services/CloudSyncMana
import { netcattyBridge } from '../../infrastructure/services/netcattyBridge';
import {
findSyncPayloadEncryptedCredentialPaths,
healPoisonedSecretsForMerge,
stripSyncPayloadEncryptedCredentials,
} from '../../domain/credentials';
import { isProviderReadyForSync, type CloudProvider, type SyncPayload } from '../../domain/sync';
import { mergeSyncPayloads } from '../../domain/syncMerge';
@@ -435,13 +437,14 @@ export const useAutoSync = (config: AutoSyncConfig) => {
for (const result of resultList) {
if (result.mergedPayload && !result.mergedPayloadApplied) {
await Promise.resolve(onApplyPayload(result.mergedPayload));
const portableMerged = stripSyncPayloadEncryptedCredentials(result.mergedPayload);
await Promise.resolve(onApplyPayload(portableMerged));
if (result.remoteFile) {
await sync.commitRemoteInspection(result.provider, result.remoteFile, result.mergedPayload, {
await sync.commitRemoteInspection(result.provider, result.remoteFile, portableMerged, {
recordDownload: true,
});
}
skipNextSyncHashRef.current = allProvidersSynced ? getSyncPayloadDataHash(result.mergedPayload) : null;
skipNextSyncHashRef.current = allProvidersSynced ? getSyncPayloadDataHash(portableMerged) : null;
if (!allProvidersSynced) {
console.warn('[AutoSync] Remote payload applied locally, but not every provider synced; leaving next auto-sync enabled for retry.');
}
@@ -492,7 +495,7 @@ export const useAutoSync = (config: AutoSyncConfig) => {
} finally {
isSyncRunningRef.current = false;
}
}, [enabled, sync, buildPayload, getDataHash, onApplyConvergentPayload, onApplyPayload, t]);
}, [enabled, sync, buildPayload, onApplyConvergentPayload, onApplyPayload, t]);
const syncNowRef = useRef(syncNow);
useEffect(() => {
@@ -707,7 +710,11 @@ export const useAutoSync = (config: AutoSyncConfig) => {
inspectedRemoteChange = true;
const remoteFile = inspection.remoteFile;
const remotePayload = inspection.payload;
const remoteRaw = inspection.payload;
// Strip device-bound enc:v1 for download/apply paths so poisoned cloud
// snapshots restore usable host shells. Smart-merge heals from local/base
// separately below so good secrets are not treated as remote deletions.
const remotePayload = stripSyncPayloadEncryptedCredentials(remoteRaw);
const localPayload = await buildPayloadRef.current();
// If local vault is empty but cloud has data, this almost certainly
@@ -778,6 +785,14 @@ export const useAutoSync = (config: AutoSyncConfig) => {
}
if (conflictAction === 'upload-local') {
const encryptedCredentialPaths = findSyncPayloadEncryptedCredentialPaths(localPayload);
if (encryptedCredentialPaths.length > 0) {
console.warn(
'[AutoSync] Startup local-wins blocked: encrypted credential placeholders found at:',
encryptedCredentialPaths.join(', '),
);
throw new Error(tRef.current('sync.credentialsUnavailable'));
}
const pushResults = await manager.syncAllProviders(localPayload);
const results = Array.from(pushResults.values());
commitPluginSidecarsAfterSuccessfulSync(localPayload, results);
@@ -797,15 +812,20 @@ export const useAutoSync = (config: AutoSyncConfig) => {
throw new Error('Startup local-wins sync failed for one or more providers');
}
const mergeResult = mergeSyncPayloads(base, localPayload, remotePayload);
// Prefer usable secrets from the opposite side / base over enc:v1
// placeholders before merge. Otherwise smart-merge can pick a "changed"
// local entity whose only secret change is ciphertext, then strip +
// upload and wipe usable cloud passwords. Same for remote-side poison.
const localHealed = healPoisonedSecretsForMerge(localPayload, remoteRaw, base);
const remoteHealed = healPoisonedSecretsForMerge(remoteRaw, localPayload, base);
const mergeResult = mergeSyncPayloads(base, localHealed, remoteHealed);
// Apply merged payload to local state BEFORE committing. If the apply
// throws, the next startup will re-run the merge with fresh data.
await Promise.resolve(onApplyPayloadRef.current(mergeResult.payload));
// Base is the last-agreed remote snapshot; `commitRemoteInspection`
// stores remotePayload as the base so the next diff is computed
// against what the cloud actually has, not against the merged
// local-only state.
const portableMerge = stripSyncPayloadEncryptedCredentials(mergeResult.payload);
await Promise.resolve(onApplyPayloadRef.current(portableMerge));
// Base is the last-agreed remote snapshot; store the portable remote
// view so future diffs never treat device-bound enc:v1 as cloud truth.
await manager.commitRemoteInspection(connectedProvider, remoteFile, remotePayload);
startupConsistent = true;
markCurrentDataSynced = false;
@@ -826,9 +846,9 @@ export const useAutoSync = (config: AutoSyncConfig) => {
// that only approximated the correct ordering.
if (mergeResult.payload) {
try {
const roundTripResults = await manager.syncAllProviders(mergeResult.payload);
const roundTripResults = await manager.syncAllProviders(portableMerge);
const roundTripResultList = Array.from(roundTripResults.values());
commitPluginSidecarsAfterSuccessfulSync(mergeResult.payload, roundTripResultList);
commitPluginSidecarsAfterSuccessfulSync(portableMerge, roundTripResultList);
const wasShrinkBlocked = roundTripResultList.some((r) => r.shrinkBlocked === true);
const roundTripFullySynced = roundTripResultList.length > 0
&& roundTripResultList.every((result) => result.success);
+35 -8
View File
@@ -1,6 +1,7 @@
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, type SetStateAction } from 'react';
import { runThemeTransition, type ThemeTransitionMode } from './themeTransition';
import { publishAppearanceChromeSnapshot } from './appearanceChromeStore';
import { SyncConfig, TerminalSettings, HotkeyScheme, CustomKeyBindings, DEFAULT_KEY_BINDINGS, KeyBinding, UILanguage, SessionLogFormat, normalizeTerminalSettings } from '../../domain/models';
import {
DEFAULT_HTTP_NETWORK_PROXY,
@@ -35,6 +36,7 @@ import {
STORAGE_KEY_SFTP_AUTO_OPEN_SIDEBAR,
STORAGE_KEY_SFTP_FOLLOW_TERMINAL_CWD,
STORAGE_KEY_SFTP_TRANSFER_CONCURRENCY,
STORAGE_KEY_SFTP_SKIP_UNCHANGED,
STORAGE_KEY_SSH_TRANSPORT_IDLE_TTL_MS,
STORAGE_KEY_SFTP_TRANSFER_POOL_IDLE_TTL_MS,
STORAGE_KEY_SFTP_DEFAULT_VIEW_MODE,
@@ -84,7 +86,10 @@ import { DEFAULT_UI_FONT_ID, withWindowsEmojiFallback } from '../../infrastructu
import { uiFontStore, useUIFontsLoaded } from './uiFontStore';
import { localStorageAdapter } from '../../infrastructure/persistence/localStorageAdapter';
import { netcattyBridge } from '../../infrastructure/services/netcattyBridge';
import { resolveSftpTransferConcurrency } from './sftp/transferConcurrency';
import {
resolveSftpTransferConcurrency,
resolveSftpSkipUnchangedEnabled,
} from './sftp/transferConcurrency';
import { resolveSshTransportIdleTtlMs } from '../../infrastructure/config/sshTransportIdleTtl';
import {
DEFAULT_ACCENT_MODE,
@@ -190,7 +195,7 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
});
// Track the OS color scheme preference (updated by matchMedia listener)
const [systemPreference, setSystemPreference] = useState<'light' | 'dark'>(getSystemPreference);
// resolvedTheme is always 'light' or 'dark' — derived synchronously from theme + OS preference
// resolvedTheme is always 'light' or 'dark' - derived synchronously from theme + OS preference
const resolvedTheme: 'light' | 'dark' = theme === 'system' ? systemPreference : theme;
const [lightUiThemeId, setLightUiThemeId] = useState<string>(() => {
const stored = readStoredString(STORAGE_KEY_UI_THEME_LIGHT);
@@ -418,8 +423,11 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
);
});
// Folder transfer concurrency is renderer-only (runSftpTransferWorkers).
// Do not push it into main-process host admission — that made multi-select
// Do not push it into main-process host admission - that made multi-select
// top-level files queue against each other and against folder children.
const [sftpSkipUnchanged, setSftpSkipUnchanged] = useState<boolean>(() =>
resolveSftpSkipUnchangedEnabled(() => localStorageAdapter.readBoolean(STORAGE_KEY_SFTP_SKIP_UNCHANGED)),
);
const [sshTransportIdleTtlMs, setSshTransportIdleTtlMsState] = useState<number>(() => {
// Prefer the new SSH transport key; if absent (upgrade), migrate the legacy
@@ -582,7 +590,7 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
const explorerContextMenuEnabledRef = useRef(explorerContextMenuEnabled);
const explorerContextMenuSetRequestIdRef = useRef(0);
// Fix 1: Mount guard — skip redundant IPC broadcasts & localStorage writes on initial mount.
// Fix 1: Mount guard - skip redundant IPC broadcasts & localStorage writes on initial mount.
// Set to true by the LAST useEffect declaration; all persist effects see false on first render.
const persistMountedRef = useRef(false);
const appearanceTransitionModeRef = useRef<ThemeTransitionMode>('view');
@@ -726,7 +734,7 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
const clamped = Math.max(1, Math.min(16, Math.round(value)));
setSftpTransferConcurrencyState(clamped);
localStorageAdapter.writeString(STORAGE_KEY_SFTP_TRANSFER_CONCURRENCY, String(clamped));
// Intentionally not calling setGlobalTransferConcurrency — this setting
// Intentionally not calling setGlobalTransferConcurrency - this setting
// only caps files inside a single folder transfer job.
notifySettingsChanged(STORAGE_KEY_SFTP_TRANSFER_CONCURRENCY, clamped);
}, [notifySettingsChanged]);
@@ -833,6 +841,13 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
// Terminal
const storedTermTheme = readStoredString(STORAGE_KEY_TERM_THEME);
if (storedTermTheme) setTerminalThemeId(storedTermTheme);
// Cloud sync writes follow-app via applySyncableSettings; without this the
// open window keeps the pre-sync flag while terminalThemeId updates, which
// flickers between the local default theme and the synced one (#2757).
const storedFollowAppTermTheme = readStoredString(STORAGE_KEY_TERM_FOLLOW_APP_THEME);
if (storedFollowAppTermTheme === 'true' || storedFollowAppTermTheme === 'false') {
setFollowAppTerminalThemeState(storedFollowAppTermTheme === 'true');
}
const storedTermThemeDark = readStoredString(STORAGE_KEY_TERM_THEME_DARK);
if (storedTermThemeDark) setTerminalThemeDarkId(storedTermThemeDark);
const storedTermThemeLight = readStoredString(STORAGE_KEY_TERM_THEME_LIGHT);
@@ -883,6 +898,8 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
if (storedHidden === 'true' || storedHidden === 'false') setSftpShowHiddenFiles(storedHidden === 'true');
const storedCompress = readStoredString(STORAGE_KEY_SFTP_USE_COMPRESSED_UPLOAD);
if (storedCompress === 'true' || storedCompress === 'false') setSftpUseCompressedUpload(storedCompress === 'true');
const storedSkipUnchanged = localStorageAdapter.readBoolean(STORAGE_KEY_SFTP_SKIP_UNCHANGED);
if (storedSkipUnchanged != null) setSftpSkipUnchanged(storedSkipUnchanged);
const storedAutoOpenSidebar = readStoredString(STORAGE_KEY_SFTP_AUTO_OPEN_SIDEBAR);
if (storedAutoOpenSidebar === 'true' || storedAutoOpenSidebar === 'false') setSftpAutoOpenSidebar(storedAutoOpenSidebar === 'true');
const storedFollowTerminalCwd = readStoredString(STORAGE_KEY_SFTP_FOLLOW_TERMINAL_CWD);
@@ -951,6 +968,8 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
const persistedAppearanceChanged = previousAppearance === null
|| hasPersistedAppearanceChanged(previousAppearance, appearanceRender);
previousAppearanceRenderRef.current = appearanceRender;
// Terminal leaves subscribe here so accent drag does not rebuild TerminalLayer.
publishAppearanceChromeSnapshot({ accentMode, customAccent });
const tokens = getUiThemeById(resolvedTheme, resolvedTheme === 'dark' ? darkUiThemeId : lightUiThemeId).tokens;
const apply = () => applyThemeTokens(theme, resolvedTheme, tokens, accentMode, customAccent);
const transitionMode = appearanceTransitionModeRef.current;
@@ -1114,7 +1133,7 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
customCSS, uiFontFamilyId, hotkeyScheme, uiLanguage,
terminalThemeId, followAppTerminalTheme, terminalFontFamilyId, terminalFontSize,
sftpDoubleClickBehavior, sftpAutoSync, sftpShowHiddenFiles,
sftpUseCompressedUpload, sftpAutoOpenSidebar, sftpFollowTerminalCwd, sftpDefaultViewMode,
sftpUseCompressedUpload, sftpSkipUnchanged, sftpAutoOpenSidebar, sftpFollowTerminalCwd, sftpDefaultViewMode,
showRecentHosts, hostClickBehavior, showOnlyUngroupedHostsInRoot, showSftpTab, showHostTreeSidebar, terminalSidePanelAutoOpen, terminalSidePanelAutoOpenTab, shellOnlyTabNumberShortcuts, disableTerminalFontZoom, restorePreviousSession, restoreTerminalCwd,
editorWordWrap, sessionLogsEnabled, sessionLogsDir, sessionLogsFormat, sessionLogsTimestampsEnabled, sshDebugLogsEnabled, sshDeepLinkEnabled, jmsDeepLinkEnabled, explorerContextMenuEnabled,
globalHotkeyEnabled, autoUpdateEnabled, windowOpacity, appIconVariant,
@@ -1124,7 +1143,7 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
setTerminalThemeId, setTerminalThemeDarkId, setTerminalThemeLightId,
setFollowAppTerminalThemeState, setTerminalFontFamilyId, setTerminalFontSize: applyIncomingTerminalFontSize,
setSftpDoubleClickBehavior, setSftpAutoSync, setSftpShowHiddenFiles,
setSftpUseCompressedUpload, setSftpAutoOpenSidebar, setSftpFollowTerminalCwd, setSftpDefaultViewMode,
setSftpUseCompressedUpload, setSftpSkipUnchanged, setSftpAutoOpenSidebar, setSftpFollowTerminalCwd, setSftpDefaultViewMode,
setShowRecentHostsState, setHostClickBehaviorState, setShowOnlyUngroupedHostsInRootState, setShowSftpTabState, setShowHostTreeSidebarState, setTerminalSidePanelAutoOpenState, setTerminalSidePanelAutoOpenTabState, setShellOnlyTabNumberShortcutsState, setDisableTerminalFontZoomState, setRestorePreviousSessionState, setRestoreTerminalCwdState,
setEditorWordWrapState, setSessionLogsEnabled, setSessionLogsDir, setSessionLogsFormat, setSessionLogsTimestampsEnabled, setSshDebugLogsEnabled, setSshDeepLinkEnabledState: applyIncomingSshDeepLinkEnabled, setJmsDeepLinkEnabledState: applyIncomingJmsDeepLinkEnabled, setExplorerContextMenuEnabledState: applyIncomingExplorerContextMenuEnabled,
setGlobalHotkeyEnabled, setWindowOpacity: applyIncomingWindowOpacity, setAppIconVariant, setAutoUpdateEnabled, setWorkspaceFocusStyleState,
@@ -1352,6 +1371,12 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
notifySettingsChanged(STORAGE_KEY_SFTP_USE_COMPRESSED_UPLOAD, sftpUseCompressedUpload);
}, [sftpUseCompressedUpload, notifySettingsChanged]);
useEffect(() => {
localStorageAdapter.writeBoolean(STORAGE_KEY_SFTP_SKIP_UNCHANGED, sftpSkipUnchanged);
if (!persistMountedRef.current) return;
notifySettingsChanged(STORAGE_KEY_SFTP_SKIP_UNCHANGED, sftpSkipUnchanged);
}, [sftpSkipUnchanged, notifySettingsChanged]);
// Persist SFTP auto-open sidebar setting
useEffect(() => {
localStorageAdapter.writeString(STORAGE_KEY_SFTP_AUTO_OPEN_SIDEBAR, sftpAutoOpenSidebar ? 'true' : 'false');
@@ -1737,6 +1762,8 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
setSftpShowHiddenFiles,
sftpUseCompressedUpload,
setSftpUseCompressedUpload,
sftpSkipUnchanged,
setSftpSkipUnchanged,
sftpAutoOpenSidebar,
setSftpAutoOpenSidebar,
sftpFollowTerminalCwd,
@@ -1821,7 +1848,7 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
uiFontFamilyId, uiLanguage, customCSS,
terminalThemeId, terminalFontFamilyId, terminalFontSize, terminalSettings,
customKeyBindings, editorWordWrap,
sftpDoubleClickBehavior, sftpAutoSync, sftpShowHiddenFiles, sftpUseCompressedUpload, sftpAutoOpenSidebar, sftpFollowTerminalCwd, sftpDefaultViewMode,
sftpDoubleClickBehavior, sftpAutoSync, sftpShowHiddenFiles, sftpUseCompressedUpload, sftpSkipUnchanged, sftpAutoOpenSidebar, sftpFollowTerminalCwd, sftpDefaultViewMode,
showRecentHosts, hostClickBehavior, showOnlyUngroupedHostsInRoot, showSftpTab, showHostTreeSidebar, terminalSidePanelAutoOpen, terminalSidePanelAutoOpenTab, shellOnlyTabNumberShortcuts, disableTerminalFontZoom,
customThemes, workspaceFocusStyle, sessionLogsTimestampsEnabled, sshDebugLogsEnabled, sshDeepLinkEnabled, jmsDeepLinkEnabled, explorerContextMenuEnabled,
]),
@@ -112,6 +112,14 @@ export function useSystemManagerBackend() {
return bridge.getDockerStats(options);
}, []);
const listAccelerators = useCallback(async (sessionId: string) => {
const bridge = netcattyBridge.get();
if (!bridge?.listAccelerators) {
return { success: false as const, error: 'listAccelerators unavailable' };
}
return bridge.listAccelerators(sessionId);
}, []);
const dockerInspect = useCallback(async (options: { sessionId: string; containerId: string }) => {
const bridge = netcattyBridge.get();
if (!bridge?.dockerInspect) {
@@ -172,6 +180,7 @@ export function useSystemManagerBackend() {
listDockerContainers,
listDockerImages,
getDockerStats,
listAccelerators,
dockerInspect,
dockerImageInspect,
dockerAction,
@@ -190,6 +199,7 @@ export function useSystemManagerBackend() {
listDockerContainers,
listDockerImages,
getDockerStats,
listAccelerators,
dockerInspect,
dockerImageInspect,
dockerAction,
+5 -5
View File
@@ -276,9 +276,9 @@ export const useTerminalBackend = () => {
netcattyBridge.get()?.notifyTerminalSessionDisplayReady?.(sessionId);
}, []);
const closeSession = useCallback(async (sessionId: string) => {
const closeSession = useCallback(async (sessionId: string, options?: { bootEpoch?: number }) => {
const bridge = netcattyBridge.get();
await bridge?.closeSession?.(sessionId);
await bridge?.closeSession?.(sessionId, options);
}, []);
const rebindSessionOutput = useCallback(async (sessionId: string, authorization: string) => {
@@ -351,17 +351,17 @@ export const useTerminalBackend = () => {
return bridge.onSessionExit(sessionId, cb);
}, []);
const onTelnetAutoLoginComplete = useCallback((sessionId: string, cb: (evt: { sessionId: string }) => void) => {
const onTelnetAutoLoginComplete = useCallback((sessionId: string, cb: (evt: { sessionId: string; bootEpoch?: number }) => void) => {
const bridge = netcattyBridge.get();
return bridge?.onTelnetAutoLoginComplete?.(sessionId, cb);
}, []);
const onTelnetAutoLoginCancelled = useCallback((sessionId: string, cb: (evt: { sessionId: string }) => void) => {
const onTelnetAutoLoginCancelled = useCallback((sessionId: string, cb: (evt: { sessionId: string; bootEpoch?: number }) => void) => {
const bridge = netcattyBridge.get();
return bridge?.onTelnetAutoLoginCancelled?.(sessionId, cb);
}, []);
const onMoshSessionReady = useCallback((sessionId: string, cb: (evt: { sessionId: string }) => void) => {
const onMoshSessionReady = useCallback((sessionId: string, cb: (evt: { sessionId: string; bootEpoch?: number }) => void) => {
const bridge = netcattyBridge.get();
return bridge?.onMoshSessionReady?.(sessionId, cb);
}, []);
+20
View File
@@ -65,6 +65,10 @@ import {
} from "../../domain/connectionLogTerminalData";
import { getNextVaultOrder, normalizeVaultOrder } from "../../domain/vaultOrder";
import { loadSanitizedShellHistory } from "./shellHistoryPersistence";
import {
publishNotesSnapshot,
registerNotesActions,
} from "./notesStore";
import { publishShellHistorySnapshot } from "./shellHistoryStore";
import { setVaultInitialized } from "./vaultInitStore";
import {
@@ -828,6 +832,18 @@ export const useVaultState = () => {
publishShellHistorySnapshot(shellHistory);
}, [shellHistory]);
// Notes catalog for Notes / AI side panels — keep TerminalLayer off the hot path.
useLayoutEffect(() => {
publishNotesSnapshot({ notes, noteGroups });
}, [notes, noteGroups]);
useLayoutEffect(() => {
registerNotesActions({ updateNotes, updateNoteGroups });
return () => {
registerNotesActions(null);
};
}, [updateNotes, updateNoteGroups]);
const addShellHistoryEntry = useCallback(
(entry: Omit<ShellHistoryEntry, "id" | "timestamp">) => {
setShellHistory((prev) => {
@@ -989,6 +1005,10 @@ export const useVaultState = () => {
),
);
setHosts(sanitized);
// Always re-encrypt the batch. Stale enc:v1 placeholders are left
// unchanged by encryptCredentialValue (no double-wrap); plaintext
// siblings still need encryption when safeStorage was previously
// unavailable for some records.
encryptHosts(sanitized).then((enc) => {
if (ver === hostsWriteVersion.current)
localStorageAdapter.write(STORAGE_KEY_HOSTS, enc);
+143 -28
View File
@@ -139,6 +139,56 @@ test("sync payloads preserve opaque plugin hosts without requiring the plugin to
assert.deepEqual(imported?.hosts, [pluginHost]);
});
test("applySyncPayload preserves host startup command and appearance overrides (#2757)", async () => {
const host = {
id: "host-1",
label: "Prod",
hostname: "prod.example",
username: "root",
port: 22,
protocol: "ssh" as const,
tags: [],
os: "linux" as const,
startupCommand: "tmux attach || tmux",
startupCommandRunMode: "lineDelay" as const,
theme: "solarized-dark",
themeOverride: true,
};
const groupConfigs = [
{
path: "prod",
startupCommand: "cd /srv && exec bash -l",
theme: "solarized-dark",
themeOverride: true,
},
];
const payload = buildSyncPayload({
...vault(),
hosts: [host],
groupConfigs,
});
assert.equal(payload.hosts[0]?.startupCommand, "tmux attach || tmux");
assert.equal(payload.hosts[0]?.theme, "solarized-dark");
assert.equal(payload.hosts[0]?.themeOverride, true);
assert.equal(payload.groupConfigs?.[0]?.startupCommand, "cd /srv && exec bash -l");
let imported: Record<string, unknown> | null = null;
await applySyncPayload(payload, {
importVaultData: (json) => { imported = JSON.parse(json); },
});
const importedHosts = imported?.hosts as typeof payload.hosts;
assert.equal(importedHosts?.[0]?.startupCommand, "tmux attach || tmux");
assert.equal(importedHosts?.[0]?.startupCommandRunMode, "lineDelay");
assert.equal(importedHosts?.[0]?.theme, "solarized-dark");
assert.equal(importedHosts?.[0]?.themeOverride, true);
assert.equal(
(imported?.groupConfigs as typeof groupConfigs)?.[0]?.startupCommand,
"cd /srv && exec bash -l",
);
});
test("buildCloudSyncPayload includes notes and note groups", async () => {
const payload = await buildCloudSyncPayload({
...vault([]),
@@ -162,13 +212,13 @@ test("buildSyncPayload includes AI configuration settings", () => {
id: "openai-main",
providerId: "openai",
name: "OpenAI",
apiKey: "enc:v1:test",
apiKey: "enc:v1:djEwdGVzdAAAAAAAAAAAAAAAAA==",
defaultModel: "gpt-test",
enabled: true,
}];
const webSearch = {
providerId: "tavily",
apiKey: "enc:v1:web",
apiKey: "enc:v1:djEwd2ViAAAAAAAAAAAAAAAAAA==",
enabled: true,
maxResults: 7,
};
@@ -189,8 +239,11 @@ test("buildSyncPayload includes AI configuration settings", () => {
const payload = buildSyncPayload(vault([]));
// Device-bound enc:v1 apiKeys are stripped from portable sync settings.
const { apiKey: _providerKey, ...providerWithoutKey } = providers[0]!;
const { apiKey: _webKey, ...webSearchWithoutKey } = webSearch;
assert.deepEqual(payload.settings?.ai, {
providers,
providers: [providerWithoutKey],
activeProviderId: "openai-main",
activeModelId: "gpt-test",
globalPermissionMode: "auto",
@@ -201,7 +254,7 @@ test("buildSyncPayload includes AI configuration settings", () => {
maxIterations: 10,
agentModelMap: { codex: "gpt-test" },
agentProviderMap: { catty: "openai-main" },
webSearchConfig: webSearch,
webSearchConfig: webSearchWithoutKey,
showTerminalSelectionAction: false,
});
});
@@ -290,12 +343,12 @@ test("buildSyncPayload omits device-bound encrypted AI API keys", () => {
id: "openai-main",
providerId: "openai",
name: "OpenAI",
apiKey: "enc:v1:djEwAAAA",
apiKey: "enc:v1:djEwQUFBQQAAAAAAAAAAAAAAAA==",
enabled: true,
}]));
localStorage.setItem(storageKeys.STORAGE_KEY_AI_WEB_SEARCH, JSON.stringify({
providerId: "tavily",
apiKey: "enc:v1:djEwAAAA",
apiKey: "enc:v1:djEwQUFBQQAAAAAAAAAAAAAAAA==",
enabled: true,
}));
@@ -310,8 +363,8 @@ test("buildCloudSyncPayload includes decrypted AI API keys for portable cloud sy
value: {
netcatty: {
credentialsDecrypt: async (value: string) => {
if (value === "enc:v1:djEwPROVIDER") return "sk-provider";
if (value === "enc:v1:djEwWEB") return "sk-web";
if (value === "enc:v1:djEwUFJPVklERVIAAAAAAAAAAA==") return "sk-provider";
if (value === "enc:v1:djEwV0VCAAAAAAAAAAAAAAAAAA==") return "sk-web";
return value;
},
},
@@ -323,12 +376,12 @@ test("buildCloudSyncPayload includes decrypted AI API keys for portable cloud sy
id: "openai-main",
providerId: "openai",
name: "OpenAI",
apiKey: "enc:v1:djEwPROVIDER",
apiKey: "enc:v1:djEwUFJPVklERVIAAAAAAAAAAA==",
enabled: true,
}]));
localStorage.setItem(storageKeys.STORAGE_KEY_AI_WEB_SEARCH, JSON.stringify({
providerId: "tavily",
apiKey: "enc:v1:djEwWEB",
apiKey: "enc:v1:djEwV0VCAAAAAAAAAAAAAAAAAA==",
enabled: true,
}));
@@ -352,7 +405,7 @@ test("buildCloudSyncPayload fails instead of deleting API keys when decrypt fail
id: "openai-main",
providerId: "openai",
name: "OpenAI",
apiKey: "enc:v1:djEwPROVIDER",
apiKey: "enc:v1:djEwUFJPVklERVIAAAAAAAAAAA==",
enabled: true,
}]));
@@ -367,12 +420,12 @@ test("applySyncPayload restores AI configuration settings", async () => {
id: "anthropic-main",
providerId: "anthropic",
name: "Anthropic",
apiKey: "enc:v1:test",
apiKey: "enc:v1:djEwdGVzdAAAAAAAAAAAAAAAAA==",
enabled: true,
}];
const webSearch = {
providerId: "exa",
apiKey: "enc:v1:web",
apiKey: "enc:v1:djEwd2ViAAAAAAAAAAAAAAAAAA==",
enabled: true,
};
@@ -423,7 +476,12 @@ test("applySyncPayload encrypts synced plaintext AI API keys before saving local
Object.defineProperty(globalThis, "window", {
value: {
netcatty: {
credentialsEncrypt: async (value: string) => `enc:v1:djEwLOCAL_${value}`,
credentialsEncrypt: async (value: string) => {
const body = Buffer.alloc(19, 0);
Buffer.from("v10", "utf8").copy(body, 0);
Buffer.from(`LOCAL_${value}`.slice(0, 16), "utf8").copy(body, 3);
return `enc:v1:${body.toString("base64")}`;
},
},
dispatchEvent: () => true,
},
@@ -451,8 +509,20 @@ test("applySyncPayload encrypts synced plaintext AI API keys before saving local
const provider = JSON.parse(localStorage.getItem(storageKeys.STORAGE_KEY_AI_PROVIDERS)!)[0];
const webSearch = JSON.parse(localStorage.getItem(storageKeys.STORAGE_KEY_AI_WEB_SEARCH)!);
assert.equal(provider.apiKey, "enc:v1:djEwLOCAL_sk-provider");
assert.equal(webSearch.apiKey, "enc:v1:djEwLOCAL_sk-web");
const expectedProviderKey = (() => {
const body = Buffer.alloc(19, 0);
Buffer.from("v10", "utf8").copy(body, 0);
Buffer.from("LOCAL_sk-provider".slice(0, 16), "utf8").copy(body, 3);
return `enc:v1:${body.toString("base64")}`;
})();
const expectedWebKey = (() => {
const body = Buffer.alloc(19, 0);
Buffer.from("v10", "utf8").copy(body, 0);
Buffer.from("LOCAL_sk-web".slice(0, 16), "utf8").copy(body, 3);
return `enc:v1:${body.toString("base64")}`;
})();
assert.equal(provider.apiKey, expectedProviderKey);
assert.equal(webSearch.apiKey, expectedWebKey);
});
test("applySyncPayload restores host tree sidebar visibility setting", async () => {
@@ -608,14 +678,14 @@ test("applySyncPayload preserves local AI provider apiKeys when synced payload o
id: "openai-main",
providerId: "openai",
name: "OpenAI",
apiKey: "enc:v1:djEwLOCAL",
apiKey: "enc:v1:djEwTE9DQUwAAAAAAAAAAAAAAA==",
enabled: true,
},
{
id: "anthropic-main",
providerId: "anthropic",
name: "Anthropic",
apiKey: "enc:v1:djEwANTHROPIC",
apiKey: "enc:v1:djEwQU5USFJPUElDAAAAAAAAAA==",
enabled: true,
},
];
@@ -646,14 +716,14 @@ test("applySyncPayload preserves local AI provider apiKeys when synced payload o
id: "openai-main",
providerId: "openai",
name: "OpenAI (renamed)",
apiKey: "enc:v1:djEwLOCAL",
apiKey: "enc:v1:djEwTE9DQUwAAAAAAAAAAAAAAA==",
enabled: true,
},
{
id: "anthropic-main",
providerId: "anthropic",
name: "Anthropic",
apiKey: "enc:v1:djEwANTHROPIC",
apiKey: "enc:v1:djEwQU5USFJPUElDAAAAAAAAAA==",
enabled: false,
},
]);
@@ -661,7 +731,7 @@ test("applySyncPayload preserves local AI provider apiKeys when synced payload o
test("applySyncPayload prefers explicit synced apiKey over local apiKey", async () => {
localStorage.setItem(storageKeys.STORAGE_KEY_AI_PROVIDERS, JSON.stringify([
{ id: "openai-main", providerId: "openai", name: "OpenAI", apiKey: "enc:v1:djEwLOCAL", enabled: true },
{ id: "openai-main", providerId: "openai", name: "OpenAI", apiKey: "enc:v1:djEwTE9DQUwAAAAAAAAAAAAAAA==", enabled: true },
]));
const payload: SyncPayload = {
@@ -689,7 +759,7 @@ test("applySyncPayload prefers explicit synced apiKey over local apiKey", async
test("applySyncPayload preserves local web-search apiKey when synced config omits it", async () => {
localStorage.setItem(storageKeys.STORAGE_KEY_AI_WEB_SEARCH, JSON.stringify({
providerId: "tavily",
apiKey: "enc:v1:djEwWEB",
apiKey: "enc:v1:djEwV0VCAAAAAAAAAAAAAAAAAA==",
enabled: true,
maxResults: 7,
}));
@@ -713,7 +783,7 @@ test("applySyncPayload preserves local web-search apiKey when synced config omit
const stored = JSON.parse(localStorage.getItem(storageKeys.STORAGE_KEY_AI_WEB_SEARCH)!);
assert.deepEqual(stored, {
providerId: "tavily",
apiKey: "enc:v1:djEwWEB",
apiKey: "enc:v1:djEwV0VCAAAAAAAAAAAAAAAAAA==",
enabled: false,
maxResults: 12,
});
@@ -722,7 +792,7 @@ test("applySyncPayload preserves local web-search apiKey when synced config omit
test("applySyncPayload drops local web-search apiKey when synced config switches provider", async () => {
localStorage.setItem(storageKeys.STORAGE_KEY_AI_WEB_SEARCH, JSON.stringify({
providerId: "tavily",
apiKey: "enc:v1:djEwWEB",
apiKey: "enc:v1:djEwV0VCAAAAAAAAAAAAAAAAAA==",
enabled: true,
}));
@@ -996,19 +1066,19 @@ test("buildLocalVaultPayload preserves local AI API keys for protective backups"
id: "openai-main",
providerId: "openai",
name: "OpenAI",
apiKey: "enc:v1:djEwPROVIDER",
apiKey: "enc:v1:djEwUFJPVklERVIAAAAAAAAAAA==",
enabled: true,
}]));
localStorage.setItem(storageKeys.STORAGE_KEY_AI_WEB_SEARCH, JSON.stringify({
providerId: "tavily",
apiKey: "enc:v1:djEwWEB",
apiKey: "enc:v1:djEwV0VCAAAAAAAAAAAAAAAAAA==",
enabled: true,
}));
const payload = buildLocalVaultPayload(vault([]));
assert.equal(payload.settings?.ai?.providers?.[0]?.apiKey, "enc:v1:djEwPROVIDER");
assert.equal(payload.settings?.ai?.webSearchConfig?.apiKey, "enc:v1:djEwWEB");
assert.equal(payload.settings?.ai?.providers?.[0]?.apiKey, "enc:v1:djEwUFJPVklERVIAAAAAAAAAAA==");
assert.equal(payload.settings?.ai?.webSearchConfig?.apiKey, "enc:v1:djEwV0VCAAAAAAAAAAAAAAAAAA==");
});
test("applySyncPayload ignores legacy cloud known hosts", async () => {
@@ -1447,6 +1517,51 @@ test("prepareLocalVaultPayloadApply does not import until the prepared callback
assert.deepEqual(calls, ["prepare", "import", "commit"]);
});
test("prepareLocalVaultPayloadApply sanitizes enc:v1 before convergent prepare and apply", async () => {
const completeBlob = Buffer.alloc(31, 0);
Buffer.from("v10", "utf8").copy(completeBlob, 0);
const ENC = `enc:v1:${completeBlob.toString("base64")}`;
const preparedPayloads: SyncPayload[] = [];
const imported: Array<Record<string, unknown>> = [];
const applyPreparedPayload = await prepareLocalVaultPayloadApply({
hosts: [{
id: "h1",
label: "prod",
hostname: "prod.example",
username: "root",
password: ENC,
port: 22,
os: "linux",
group: "",
tags: [],
protocol: "ssh",
}],
keys: [],
identities: [],
snippets: [],
customGroups: [],
syncedAt: 1,
}, {
importVaultData: (json) => {
imported.push(JSON.parse(json) as Record<string, unknown>);
},
}, {
prepareConvergentRestore: async (payload) => {
preparedPayloads.push(payload);
return async () => undefined;
},
});
await applyPreparedPayload();
assert.equal(preparedPayloads.length, 1);
assert.equal(preparedPayloads[0]?.hosts[0]?.password, undefined);
assert.equal(imported.length, 1);
const hosts = imported[0]?.hosts as Array<{ password?: string }> | undefined;
assert.equal(hosts?.[0]?.password, undefined);
});
test("applyLocalVaultPayload leaves local data untouched when convergent preparation fails", async () => {
let imported = false;
const payload: SyncPayload = {
+48 -33
View File
@@ -1,5 +1,5 @@
/**
* Sync Payload Builders — Single source of truth for constructing and applying
* Sync Payload Builders - Single source of truth for constructing and applying
* the encrypted cloud-sync payload.
*
* Both the main window (App.tsx) and the settings window (SettingsSyncTab.tsx)
@@ -32,7 +32,10 @@ import {
parseCustomKeyBindingsStorageRecord,
serializeCustomKeyBindingsStorageRecord,
} from '../domain/customKeyBindings';
import { isEncryptedCredentialPlaceholder } from '../domain/credentials';
import {
isEncryptedCredentialPlaceholder,
stripSyncPayloadEncryptedCredentials,
} from '../domain/credentials';
import { localStorageAdapter } from '../infrastructure/persistence/localStorageAdapter';
import { decryptField, encryptField } from '../infrastructure/persistence/secureFieldAdapter';
import { sanitizeQuickMessages } from '../infrastructure/ai/quickMessages';
@@ -71,6 +74,7 @@ import {
STORAGE_KEY_SFTP_AUTO_SYNC,
STORAGE_KEY_SFTP_SHOW_HIDDEN_FILES,
STORAGE_KEY_SFTP_USE_COMPRESSED_UPLOAD,
STORAGE_KEY_SFTP_SKIP_UNCHANGED,
STORAGE_KEY_SFTP_AUTO_OPEN_SIDEBAR,
STORAGE_KEY_SFTP_FOLLOW_TERMINAL_CWD,
STORAGE_KEY_SFTP_DEFAULT_VIEW_MODE,
@@ -283,6 +287,7 @@ export const SYNCABLE_SETTING_STORAGE_KEYS = [
STORAGE_KEY_SFTP_AUTO_SYNC,
STORAGE_KEY_SFTP_SHOW_HIDDEN_FILES,
STORAGE_KEY_SFTP_USE_COMPRESSED_UPLOAD,
STORAGE_KEY_SFTP_SKIP_UNCHANGED,
STORAGE_KEY_SFTP_AUTO_OPEN_SIDEBAR,
STORAGE_KEY_SFTP_FOLLOW_TERMINAL_CWD,
STORAGE_KEY_SFTP_DEFAULT_VIEW_MODE,
@@ -384,7 +389,7 @@ const mergeAiProvidersPreservingLocalApiKeys = (
/**
* Same rationale as `mergeAiProvidersPreservingLocalApiKeys`. Only restores the
* local apiKey when the incoming config still points at the same providerId —
* local apiKey when the incoming config still points at the same providerId -
* switching providers must not silently leak a key meant for a different one.
*/
const mergeWebSearchConfigPreservingLocalApiKey = (
@@ -487,6 +492,8 @@ export function collectSyncableSettings(): SyncPayload['settings'] {
if (hidden === 'true' || hidden === 'false') settings.sftpShowHiddenFiles = hidden === 'true';
const compress = localStorageAdapter.readString(STORAGE_KEY_SFTP_USE_COMPRESSED_UPLOAD);
if (compress === 'true' || compress === 'false') settings.sftpUseCompressedUpload = compress === 'true';
const skipUnchanged = localStorageAdapter.readBoolean(STORAGE_KEY_SFTP_SKIP_UNCHANGED);
if (skipUnchanged != null) settings.sftpSkipUnchanged = skipUnchanged;
const autoOpenSidebar = localStorageAdapter.readString(STORAGE_KEY_SFTP_AUTO_OPEN_SIDEBAR);
if (autoOpenSidebar === 'true' || autoOpenSidebar === 'false') settings.sftpAutoOpenSidebar = autoOpenSidebar === 'true';
const followTerminalCwd = localStorageAdapter.readString(STORAGE_KEY_SFTP_FOLLOW_TERMINAL_CWD);
@@ -494,7 +501,7 @@ export function collectSyncableSettings(): SyncPayload['settings'] {
const defaultViewMode = localStorageAdapter.readString(STORAGE_KEY_SFTP_DEFAULT_VIEW_MODE);
if (defaultViewMode === 'list' || defaultViewMode === 'tree') settings.sftpDefaultViewMode = defaultViewMode;
// SFTP Bookmarks (global only — local bookmarks are device-specific)
// SFTP Bookmarks (global only - local bookmarks are device-specific)
const globalBookmarks = localStorageAdapter.read<SftpBookmark[]>(STORAGE_KEY_SFTP_GLOBAL_BOOKMARKS);
if (globalBookmarks && Array.isArray(globalBookmarks)) settings.sftpGlobalBookmarks = globalBookmarks;
@@ -663,7 +670,7 @@ async function applySyncableSettings(settings: NonNullable<SyncPayload['settings
localStorageAdapter.writeString(STORAGE_KEY_TERMINAL_SIDE_PANEL_AUTO_OPEN_TAB, settings.terminalSidePanelAutoOpenTab);
}
// Terminal settings — merge with existing to preserve platform-specific keys
// Terminal settings - merge with existing to preserve platform-specific keys
if (settings.terminalSettings) {
let existing: Record<string, unknown> = {};
const raw = localStorageAdapter.readString(STORAGE_KEY_TERM_SETTINGS);
@@ -723,6 +730,7 @@ async function applySyncableSettings(settings: NonNullable<SyncPayload['settings
if (settings.sftpAutoSync != null) localStorageAdapter.writeString(STORAGE_KEY_SFTP_AUTO_SYNC, String(settings.sftpAutoSync));
if (settings.sftpShowHiddenFiles != null) localStorageAdapter.writeString(STORAGE_KEY_SFTP_SHOW_HIDDEN_FILES, String(settings.sftpShowHiddenFiles));
if (settings.sftpUseCompressedUpload != null) localStorageAdapter.writeString(STORAGE_KEY_SFTP_USE_COMPRESSED_UPLOAD, String(settings.sftpUseCompressedUpload));
if (settings.sftpSkipUnchanged != null) localStorageAdapter.writeBoolean(STORAGE_KEY_SFTP_SKIP_UNCHANGED, settings.sftpSkipUnchanged);
if (settings.sftpAutoOpenSidebar != null) localStorageAdapter.writeString(STORAGE_KEY_SFTP_AUTO_OPEN_SIDEBAR, String(settings.sftpAutoOpenSidebar));
if (settings.sftpFollowTerminalCwd != null) localStorageAdapter.writeString(STORAGE_KEY_SFTP_FOLLOW_TERMINAL_CWD, String(settings.sftpFollowTerminalCwd));
if (settings.sftpDefaultViewMode != null) {
@@ -803,7 +811,7 @@ async function applySyncableSettings(settings: NonNullable<SyncPayload['settings
// After all AI writes, reconcile per-agent bindings against the final
// provider list. Sync payloads can land with a new `providers` set but
// no `agentProviderMap`, or with a stale `agentProviderMap` that
// points at ids the synced provider set doesn't include — either way
// points at ids the synced provider set doesn't include - either way
// we'd leak overrides bound to ghost providers. Mirrors the same
// cleanup `removeProvider` does for explicit user deletes.
pruneOrphanPerAgentBindings();
@@ -868,7 +876,7 @@ function pruneOrphanPerAgentBindings(): void {
nextProviderMap[agentId] = providerId;
} else {
providerChanged = true;
// Drop the saved model too — that id belonged to the now-missing
// Drop the saved model too - that id belonged to the now-missing
// provider and isn't trustworthy against any other binding.
if (agentId in nextModelMap) {
delete nextModelMap[agentId];
@@ -1079,31 +1087,35 @@ function applyPayload(
applyPluginSidecars?: PluginSyncSidecarApplier;
},
): Promise<void> {
const legacyLineTimestampsEnabled = payload.settings?.terminalSettings?.showLineTimestamps === true;
// Portable payloads must never keep device-bound enc:v1 blobs. Strip them
// so a previously poisoned cloud/backup snapshot can still restore host
// shells and let the user re-enter secrets (#2702).
const sanitizedPayload = stripSyncPayloadEncryptedCredentials(payload);
const legacyLineTimestampsEnabled = sanitizedPayload.settings?.terminalSettings?.showLineTimestamps === true;
// Build the vault import object. Cloud sync intentionally ignores
// local-only trust records even if legacy cloud snapshots still carry them.
const vaultImport: Record<string, unknown> = {
hosts: migrateHostsFromLegacyLineTimestamps(payload.hosts, legacyLineTimestampsEnabled),
keys: payload.keys,
identities: payload.identities,
proxyProfiles: payload.proxyProfiles,
snippets: payload.snippets,
customGroups: payload.customGroups,
hosts: migrateHostsFromLegacyLineTimestamps(sanitizedPayload.hosts, legacyLineTimestampsEnabled),
keys: sanitizedPayload.keys,
identities: sanitizedPayload.identities,
proxyProfiles: sanitizedPayload.proxyProfiles,
snippets: sanitizedPayload.snippets,
customGroups: sanitizedPayload.customGroups,
};
if (payload.snippetPackages !== undefined) {
vaultImport.snippetPackages = payload.snippetPackages;
if (sanitizedPayload.snippetPackages !== undefined) {
vaultImport.snippetPackages = sanitizedPayload.snippetPackages;
}
if (payload.notes !== undefined) {
vaultImport.notes = payload.notes;
if (sanitizedPayload.notes !== undefined) {
vaultImport.notes = sanitizedPayload.notes;
}
if (payload.noteGroups !== undefined) {
vaultImport.noteGroups = payload.noteGroups;
if (sanitizedPayload.noteGroups !== undefined) {
vaultImport.noteGroups = sanitizedPayload.noteGroups;
}
if (options.includeLocalOnlyData && payload.knownHosts !== undefined) {
vaultImport.knownHosts = payload.knownHosts;
if (options.includeLocalOnlyData && sanitizedPayload.knownHosts !== undefined) {
vaultImport.knownHosts = sanitizedPayload.knownHosts;
}
if (Array.isArray(payload.groupConfigs)) {
vaultImport.groupConfigs = payload.groupConfigs;
if (Array.isArray(sanitizedPayload.groupConfigs)) {
vaultImport.groupConfigs = sanitizedPayload.groupConfigs;
}
return Promise.resolve(importers.importVaultData(JSON.stringify(vaultImport))).then(async () => {
@@ -1111,23 +1123,23 @@ function applyPayload(
// them. Absent field = "payload was created before this feature existed",
// so local rules are preserved. Explicitly present [] = "remote has no
// rules, clear local state".
if (payload.portForwardingRules !== undefined && importers.importPortForwardingRules) {
importers.importPortForwardingRules(payload.portForwardingRules);
if (sanitizedPayload.portForwardingRules !== undefined && importers.importPortForwardingRules) {
importers.importPortForwardingRules(sanitizedPayload.portForwardingRules);
}
// Apply synced settings
if (payload.settings) {
await applySyncableSettings(payload.settings);
if (sanitizedPayload.settings) {
await applySyncableSettings(sanitizedPayload.settings);
// Rehydrate in-memory bookmark snapshot after localStorage was updated
if (payload.settings.sftpGlobalBookmarks != null) rehydrateGlobalSftpBookmarks();
if (sanitizedPayload.settings.sftpGlobalBookmarks != null) rehydrateGlobalSftpBookmarks();
importers.onSettingsApplied?.();
}
// Plugin encrypted sidecars: only apply when the field is present so
// legacy payloads without pluginSidecars do not wipe installed settings.
if (Object.prototype.hasOwnProperty.call(payload, 'pluginSidecars')) {
if (Object.prototype.hasOwnProperty.call(sanitizedPayload, 'pluginSidecars')) {
const applySidecars = options.applyPluginSidecars ?? defaultApplyPluginSyncSidecars;
await applySidecars(payload.pluginSidecars ?? { version: 1, entries: [] });
await applySidecars(sanitizedPayload.pluginSidecars ?? { version: 1, entries: [] });
}
});
}
@@ -1154,11 +1166,14 @@ export async function prepareLocalVaultPayloadApply(
) => Promise<() => Promise<void>>;
} = {},
): Promise<() => Promise<void>> {
// Sanitize once so vault import and convergent replica commit share the
// same portable secrets view (no device-bound enc:v1 leftovers).
const sanitizedPayload = stripSyncPayloadEncryptedCredentials(payload);
const prepareConvergentRestore = dependencies.prepareConvergentRestore
?? prepareRestoredPayloadConvergentWrites;
const commitConvergentRestore = await prepareConvergentRestore(payload);
const commitConvergentRestore = await prepareConvergentRestore(sanitizedPayload);
return async () => {
await applyPayload(payload, importers, { includeLocalOnlyData: true });
await applyPayload(sanitizedPayload, importers, { includeLocalOnlyData: true });
await commitConvergentRestore();
};
}
@@ -111,7 +111,7 @@ test("CSV credentials exclude an explicitly unsaved Keychain passphrase for a di
test("CSV credentials warn when a saved reference-key passphrase cannot be read", async () => {
const result = await buildVaultCsvCredentialOptions(
[host("key-1")],
[referenceKey({ savePassphrase: true, passphrase: "enc:v1:djEwYWJj" })],
[referenceKey({ savePassphrase: true, passphrase: "enc:v1:djEwYWJjAAAAAAAAAAAAAAAAAA==" })],
async () => ({ values: [], unreadable: false, present: false }),
);
+10 -6
View File
@@ -19,6 +19,7 @@ import {
import { useI18n } from '../application/i18n/I18nProvider';
import {
findSyncPayloadEncryptedCredentialPaths,
stripSyncPayloadEncryptedCredentials,
} from '../domain/credentials';
import {
isProviderReadyForSync,
@@ -675,9 +676,10 @@ const SyncDashboard: React.FC<SyncDashboardProps> = ({
// requested provider fails, another provider can verify a newer
// joined replica that must be applied before reporting the error.
if (result.mergedPayload && !result.mergedPayloadApplied && onApplyPayload) {
await Promise.resolve(onApplyPayload(result.mergedPayload));
const portableMerged = stripSyncPayloadEncryptedCredentials(result.mergedPayload);
await Promise.resolve(onApplyPayload(portableMerged));
if (result.remoteFile) {
await sync.commitRemoteInspection(result.provider, result.remoteFile, result.mergedPayload, {
await sync.commitRemoteInspection(result.provider, result.remoteFile, portableMerged, {
recordDownload: true,
});
}
@@ -703,13 +705,14 @@ const SyncDashboard: React.FC<SyncDashboardProps> = ({
// USE_REMOTE applies cloud data over local — same data-loss
// shape as a local backup restore, so gate auto-sync in
// every other window the same way.
const portableRemote = stripSyncPayloadEncryptedCredentials(remoteResult.payload);
await withRestoreBarrier(async () => {
await Promise.resolve(onApplyPayload(remoteResult.payload));
await Promise.resolve(onApplyPayload(portableRemote));
});
await sync.commitRemoteInspection(
remoteResult.provider,
remoteResult.remoteFile,
remoteResult.payload,
portableRemote,
{ recordDownload: true },
);
toast.success(t('cloudSync.resolve.downloaded'));
@@ -746,9 +749,10 @@ const SyncDashboard: React.FC<SyncDashboardProps> = ({
// reflects what's now on cloud (in case remote changed during the merge).
for (const result of (results as Map<CloudProvider, SyncResult>).values()) {
if (result.mergedPayload && !result.mergedPayloadApplied) {
await Promise.resolve(onApplyPayload(result.mergedPayload));
const portableMerged = stripSyncPayloadEncryptedCredentials(result.mergedPayload);
await Promise.resolve(onApplyPayload(portableMerged));
if (result.remoteFile) {
await sync.commitRemoteInspection(result.provider, result.remoteFile, result.mergedPayload, {
await sync.commitRemoteInspection(result.provider, result.remoteFile, portableMerged, {
recordDownload: true,
});
}
+26 -2
View File
@@ -129,8 +129,11 @@ test("directory parent detection matches side-queue file-count semantics", () =>
}), false);
});
test("folder parent progress uses file counts not byte formatting", () => {
test("folder parent progress uses progressive discovery labels not byte formatting", () => {
const t = (key: string, params?: Record<string, string | number>) => {
if (key === "sftp.transfers.filesDiscoveredProgress") {
return `${params?.completed} done · ${params?.discovered} found`;
}
if (key === "sftp.transfers.filesProgress") {
return `${params?.current}/${params?.total} files`;
}
@@ -146,14 +149,35 @@ test("folder parent progress uses file counts not byte formatting", () => {
totalBytes: 12,
transferredBytes: 1,
speed: 0,
phase: "transferring",
}, t);
assert.equal(display.percent, (1 / 12) * 100);
assert.equal(display.detail, "1/12 files");
assert.equal(display.detail, "1 done · 12 found");
assert.equal(display.indeterminate, false);
// Must not look like "1 Bytes / 12 Bytes"
assert.doesNotMatch(display.detail, /Bytes/i);
});
test("folder parent scanning stays indeterminate while nothing has completed", () => {
const t = (key: string, params?: Record<string, string | number>) => {
if (key === "sftp.transfers.filesDiscoveredProgress") {
return `${params?.completed} done · ${params?.discovered} found`;
}
return key;
};
const display = buildGlobalTransferProgressDisplay({
status: "transferring",
isDirectory: true,
progressMode: "files",
totalBytes: 40,
transferredBytes: 0,
speed: 0,
phase: "scanning",
}, t);
assert.equal(display.detail, "0 done · 40 found");
assert.equal(display.indeterminate, true);
});
test("single-file progress still shows byte totals", () => {
const t = (key: string) => key;
const display = buildGlobalTransferProgressDisplay({
+52 -9
View File
@@ -181,28 +181,56 @@ export type GlobalTransferProgressDisplay = {
/**
* Build progress labels for a top-level row. Directory parents use file counts
* so we never show "1 Bytes / 12 Bytes" for n/m files.
*
* Folder walks interleave discovery with transfer: totalBytes is "found so far",
* not a fixed grand total. Active rows use progressive copy so the UI does not
* look like a lying overall percentage.
*/
export function buildGlobalTransferProgressDisplay(
task: Pick<TransferTask, "status" | "isDirectory" | "parentTaskId" | "progressMode" | "totalBytes" | "transferredBytes" | "speed">,
task: Pick<TransferTask, "status" | "isDirectory" | "parentTaskId" | "progressMode" | "totalBytes" | "transferredBytes" | "speed" | "phase">,
t: (key: string, params?: Record<string, string | number>) => string,
): GlobalTransferProgressDisplay {
const isDirParent = isDirectoryParentTask(task);
const percent = getGlobalTransferProgressPercent(task);
const hasTotal = task.totalBytes > 0;
const discovered = Math.max(task.totalBytes, task.transferredBytes);
if (isDirParent) {
const indeterminate = task.status === "transferring" && !hasTotal;
const isActive = task.status === "transferring"
|| task.status === "pausing"
|| task.status === "queued"
|| task.status === "pending";
// Still discovering, or no completed files yet — pulse rather than freeze a fake %.
const indeterminate = isActive && (
task.phase === "scanning"
|| !hasTotal
|| task.transferredBytes <= 0
);
let detail = "";
if (task.status === "transferring" || task.status === "pausing" || task.status === "queued" || task.status === "pending") {
detail = hasTotal
? t("sftp.transfers.filesProgress", { current: task.transferredBytes, total: task.totalBytes })
: t("sftp.transfers.filesCount", { count: task.transferredBytes });
if (isActive) {
if (discovered > 0) {
detail = t("sftp.transfers.filesDiscoveredProgress", {
completed: task.transferredBytes,
discovered,
});
}
} else if (task.status === "completed" && hasTotal) {
detail = t("sftp.transfers.filesCount", { count: task.totalBytes });
} else if (hasTotal) {
detail = t("sftp.transfers.filesProgress", { current: task.transferredBytes, total: task.totalBytes });
} else if (discovered > 0) {
detail = t("sftp.transfers.filesDiscoveredProgress", {
completed: task.transferredBytes,
discovered,
});
}
return { percent, detail, indeterminate };
// Soft percent of files found so far (discovered may still grow).
const softPercent = discovered > 0
? Math.min(100, (task.transferredBytes / discovered) * 100)
: 0;
return {
percent: task.status === "completed" ? 100 : softPercent,
detail,
indeterminate,
};
}
const detailParts: string[] = [];
@@ -394,6 +422,14 @@ function TransferRow({
if (task.status === "pausing" || task.status === "paused" || task.status === "interrupted") {
return t(statusLabelKey(task.status));
}
// Scanning runs as pending/transferring; never override terminal statuses
// (cancelled/failed/completed) that still carry a stale phase value.
if (
task.phase === "scanning"
&& (task.status === "pending" || task.status === "queued" || task.status === "transferring")
) {
return t("sftp.transferCenter.phase.scanning");
}
if (task.phase && task.status === "transferring") {
return t(`sftp.transferCenter.phase.${task.phase}`);
}
@@ -687,6 +723,13 @@ export function GlobalSftpTransferCenter() {
// progress ticks used to re-render this TopTabs child on every byte.
const badge = useSftpTransferCenterBadge();
const [open, setOpen] = useState(false);
// Folder drops fire this so the scanning row is visible immediately without
// requiring the user to notice the badge first.
useEffect(() => {
const openCenter = () => setOpen(true);
window.addEventListener("netcatty:open-sftp-transfer-center", openCenter);
return () => window.removeEventListener("netcatty:open-sftp-transfer-center", openCenter);
}, []);
const snapshot = useSftpTransferCenterWhenOpen(open);
const [bucket, setBucket] = useState<GlobalTransferBucket>("all");
const [showBackground, setShowBackground] = useState(false);
@@ -406,7 +406,7 @@ test("validateProxyConfigForSave rejects unreadable proxy identity passwords", (
label: "Proxy login",
username: "proxy-user",
authMethod: "password",
password: "enc:v1:djEwAAAA",
password: "enc:v1:djEwdGVzdAAAAAAAAAAAAAAAAA==",
created: 1,
}],
}),
@@ -459,7 +459,7 @@ test("prepareProxyConfigForSave returns identity errors before save handlers bui
label: "Proxy login",
username: "",
authMethod: "password",
password: "enc:v1:djEwAAAA",
password: "enc:v1:djEwdGVzdAAAAAAAAAAAAAAAAA==",
created: 1,
}],
}),
+2
View File
@@ -21,6 +21,8 @@ export interface PassphraseRequest {
keyPath: string;
keyName: string;
hostname?: string;
sessionId?: string;
bootEpoch?: number;
}
/** Opt-in only: do not default to remembering key passphrases (#2024). */
+1 -1
View File
@@ -178,7 +178,7 @@ test("ProxyPanel warns when a saved proxy profile has an unreadable identity pas
},
}],
selectedProxyProfileId: proxyProfile.id,
identities: [{ ...proxyIdentity, password: "enc:v1:djEwAAAA" }],
identities: [{ ...proxyIdentity, password: "enc:v1:djEwdGVzdAAAAAAAAAAAAAAAAA==" }],
});
assert.match(markup, /Proxy identity password cannot be read/);
+1 -1
View File
@@ -193,7 +193,7 @@ test("prepareProxyProfileForSave rejects unreadable proxy identity passwords", (
identityId: proxyIdentity.id,
},
},
[{ ...proxyIdentity, password: "enc:v1:djEwAAAA" }],
[{ ...proxyIdentity, password: "enc:v1:djEwdGVzdAAAAAAAAAAAAAAAAA==" }],
).error,
"unreadableIdentity",
);
+64
View File
@@ -64,6 +64,70 @@ test("renders active transfer cancel action with an item-specific label", () =>
assert.match(markup, /aria-label="Pause: archive\.tar\.gz"/);
});
test("directory parents show transfer-center done/found progress instead of Waiting", () => {
const markup = renderTransferItem({
...baseTask,
id: "folder-1",
fileName: "big-tree",
isDirectory: true,
progressMode: "files",
status: "pending",
phase: "transferring",
transferredBytes: 65,
totalBytes: 108202,
error: undefined,
speed: 0,
});
assert.match(markup, /65 done · 108202 found/);
assert.doesNotMatch(markup, />Waiting\.\.\.</);
});
test("scanning directory parents keep scanning label with live discovery counts", () => {
const markup = renderTransferItem({
...baseTask,
id: "folder-scan",
fileName: "docs",
isDirectory: true,
progressMode: "files",
status: "pending",
phase: "scanning",
transferredBytes: 0,
totalBytes: 1284,
error: undefined,
speed: 0,
});
assert.match(markup, /Scanning/);
assert.match(markup, /0 done · 1284 found/);
});
test("child rows reserve an action column wide enough for pause and cancel", () => {
const markup = renderTransferItem(
{
...baseTask,
id: "child-transfer-actions",
parentTaskId: "transfer-1",
status: "transferring",
error: undefined,
transferredBytes: 256,
totalBytes: 1024,
speed: 128,
resumable: true,
},
{
isChild: true,
childNameColumnWidth: 260,
onResizeNameColumn: () => {},
onSetNameColumnWidth: () => {},
},
);
assert.match(markup, /grid-template-columns:24px 260px 10px minmax\(0, 1fr\) 56px/);
assert.match(markup, /aria-label="Pause: archive\.tar\.gz"/);
assert.match(markup, /aria-label="Cancel: archive\.tar\.gz"/);
});
test("renders paused transfer resume action", () => {
const markup = renderTransferItem({
...baseTask,
+218 -23
View File
@@ -3,7 +3,7 @@ import { FitAddon } from "@xterm/addon-fit";
import { SerializeAddon } from "@xterm/addon-serialize";
import { SearchAddon } from "@xterm/addon-search";
import "@xterm/xterm/css/xterm.css";
import { Activity, Cpu, Clock3, Copy, HardDrive, Maximize2, MemoryStick, Radio, ArrowDownToLine, ArrowUpFromLine, Sparkles, SquareArrowOutUpRight } from "lucide-react";
import { Activity, Cpu, Clock3, Copy, HardDrive, Maximize2, MemoryStick, Radio, ArrowDownToLine, ArrowUpFromLine, RefreshCcw, Sparkles, SquareArrowOutUpRight, Unplug } from "lucide-react";
import React, { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
import { useI18n } from "../application/i18n/I18nProvider";
import { detectLocalOs } from "../lib/localShell";
@@ -48,6 +48,7 @@ import { supportsZmodemTerminalDragDrop } from "../lib/zmodemDragDrop";
import { resolveHostAuth, resolveHostAutofillPassword } from "../domain/sshAuth";
import { resolveEffectiveTerminalProtocol } from "../domain/terminalProtocol";
import { isPluginHostProtocol } from "../domain/pluginConnection";
import { clearTerminalBootEpoch, setTerminalBootEpoch } from "../domain/terminalBootEpoch";
import {
appendTerminalPromptSecurityTail,
isConfirmedTerminalShellPrompt,
@@ -65,6 +66,7 @@ import { useStoredBoolean } from "../application/state/useStoredBoolean";
import { readOptionalStoredStringValue, useStoredString } from "../application/state/useStoredString";
import { useSessionLogBackend } from "../application/state/useSessionLogBackend";
import { useTerminalLayoutSuppressActive } from "../application/state/terminalLayoutSuppressStore";
import { useAppearanceChromeStore } from "../application/state/appearanceChromeStore";
import {
shouldPublishPluginTerminalSessionMountLifecycle,
usePluginTerminalSessionLifecycle,
@@ -259,8 +261,6 @@ const TerminalComponent: React.FC<TerminalProps> = ({
fontSize,
terminalTheme,
followAppTerminalTheme = false,
accentMode = "theme",
customAccent = "",
terminalSettings,
sessionId,
workspaceId,
@@ -448,6 +448,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
const hibernateContextViewportSnapshotRef = useRef("");
const hibernateContextScrollbackSnapshotRef = useRef("");
const hibernatePendingBufferRef = useRef("");
const hibernatePendingCapDisabledRef = useRef(false);
const hibernateAlternateScreenRef = useRef(false);
const hibernateRetryTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const fullHibernateRuntimeRef = useRef<(() => Promise<boolean>) | null>(null);
@@ -457,7 +458,48 @@ const TerminalComponent: React.FC<TerminalProps> = ({
const wakePromiseRef = useRef<Promise<boolean> | null>(null);
const sessionRef = useRef<string | null>(null);
const sessionCleanupPromiseRef = useRef<Promise<void> | null>(null);
/** Epoch owned by an in-flight disconnect/teardown close (before bump). */
const pendingCloseBootEpochRef = useRef<number | undefined>(undefined);
const trackSessionCleanup = (promise: Promise<unknown>) => {
const settled = Promise.resolve(promise).then(
() => undefined,
() => undefined,
);
const previous = sessionCleanupPromiseRef.current;
const next = previous
? Promise.all([previous, settled]).then(() => undefined)
: settled;
sessionCleanupPromiseRef.current = next;
void next.finally(() => {
if (sessionCleanupPromiseRef.current === next) {
sessionCleanupPromiseRef.current = null;
}
});
};
const isBootActiveRef = useRef(false);
const bootEpochRef = useRef(0);
const publishBootEpoch = () => {
setTerminalBootEpoch(sessionId, bootEpochRef.current);
};
const bumpBootEpoch = () => {
bootEpochRef.current += 1;
publishBootEpoch();
};
/** Invalidate the current boot and remember its epoch for the matching closeSession. */
const invalidateBootEpochForClose = () => {
const closingEpoch = bootEpochRef.current;
bumpBootEpoch();
pendingCloseBootEpochRef.current = closingEpoch;
return closingEpoch;
};
const resolveCloseBootEpoch = () => {
if (pendingCloseBootEpochRef.current !== undefined) {
const epoch = pendingCloseBootEpochRef.current;
pendingCloseBootEpochRef.current = undefined;
return epoch;
}
return bootEpochRef.current;
};
const hasConnectedRef = useRef(false);
const hasRunStartupCommandRef = useRef(false);
const restoreCwdIntentRef = useRef<{ cwd: string; command: string } | null>(null);
@@ -476,6 +518,10 @@ const TerminalComponent: React.FC<TerminalProps> = ({
/** Connected wake for multi-tab snippet fan-out (reattaches session listeners). */
const wakeHibernatedRuntimeForConnectedRef = useRef<(() => Promise<boolean>) | null>(null);
const reconnectWakeInFlightRef = useRef(false);
// When a hibernated reconnect wake is invalidated: 'dispose' for unmount/
// session teardown (orphan runtime must go), 'keep' for Disconnect so the
// pane retains an xterm instance and can reconnect without being remounted.
const reconnectWakeInvalidateModeRef = useRef<"dispose" | "keep">("dispose");
const reconnectWakeTokenRef = useRef<symbol | null>(null);
const manualReconnectRequestRef = useRef<() => void>(() => {});
const terminalDataCapturedRef = useRef(false);
@@ -497,6 +543,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
}, [onSessionExit]);
useEffect(() => () => {
reconnectWakeInvalidateModeRef.current = "dispose";
reconnectWakeTokenRef.current = null;
}, [sessionId]);
@@ -1216,6 +1263,8 @@ const TerminalComponent: React.FC<TerminalProps> = ({
});
}, [availableFonts, fontFamilyId, hasFontFamilyOverride, host.fontFamily, terminalSettings?.fallbackFont]);
const { accentMode, customAccent } = useAppearanceChromeStore();
const baseEffectiveTheme = useMemo(() => {
if (appearanceTheme) return appearanceTheme;
if (followAppTerminalTheme) {
@@ -1546,6 +1595,17 @@ const TerminalComponent: React.FC<TerminalProps> = ({
if (!closingSessionId) {
disposeSessionListeners();
// Still notify main so in-flight SSH passphrase prompts for this UI
// sessionId are aborted even before a backend session was attached.
try {
await terminalBackend.closeSession(sessionId, { bootEpoch: resolveCloseBootEpoch() });
} catch (err) {
logger.warn("Failed to cancel pending session boot on disconnect", err);
}
const postDisposeCleanup = sessionCleanupPromiseRef.current;
if (postDisposeCleanup) {
await postDisposeCleanup;
}
return;
}
@@ -1647,7 +1707,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
clearTerminalSessionFlowAck(closingSessionId);
}
try {
await terminalBackend.closeSession(closingSessionId);
await terminalBackend.closeSession(closingSessionId, { bootEpoch: resolveCloseBootEpoch() });
} catch (err) {
logger.warn("Failed to close SSH session", err);
}
@@ -1785,10 +1845,12 @@ const TerminalComponent: React.FC<TerminalProps> = ({
backendId,
(chunk, meta) => {
observeTerminalInputPrompt(chunk, meta);
hibernatePendingBufferRef.current = appendHibernatePendingBuffer(
hibernatePendingBufferRef.current,
chunk,
);
hibernatePendingBufferRef.current = hibernatePendingCapDisabledRef.current
? hibernatePendingBufferRef.current + chunk
: appendHibernatePendingBuffer(
hibernatePendingBufferRef.current,
chunk,
);
const pluginPipelineIngressBytes = Number.isFinite(meta?.pluginPipelineIngressBytes)
? Math.max(0, Number(meta.pluginPipelineIngressBytes))
: chunk.length;
@@ -1808,10 +1870,12 @@ const TerminalComponent: React.FC<TerminalProps> = ({
setError(evt.error);
}
const exitMessage = `\r\n[session closed${evt?.exitCode !== undefined ? ` (code ${evt.exitCode})` : ""}]`;
hibernatePendingBufferRef.current = appendHibernatePendingBuffer(
hibernatePendingBufferRef.current,
exitMessage,
);
hibernatePendingBufferRef.current = hibernatePendingCapDisabledRef.current
? hibernatePendingBufferRef.current + exitMessage
: appendHibernatePendingBuffer(
hibernatePendingBufferRef.current,
exitMessage,
);
onSessionExitRef.current?.(sessionId, evt);
scheduleAutoReconnect({ evt });
});
@@ -2067,9 +2131,14 @@ const TerminalComponent: React.FC<TerminalProps> = ({
useEffect(() => () => {
clearKittyKeyboardBroadcastSession(sessionId);
clearTerminalBootEpoch(sessionId);
}, [sessionId]);
const teardown = () => {
// Capture the live epoch before invalidating so closeSession still matches
// the registered backend session / pending passphrase boot.
invalidateBootEpochForClose();
clearTerminalBootEpoch(sessionId);
isBootActiveRef.current = false;
retryTokenRef.current = null;
reconnectPreparationTokenRef.current = null;
@@ -2200,6 +2269,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
isPaneVisibleRef: isVisibleRef,
isVisibleRef: isRendererActiveRef,
isBootActiveRef,
bootEpochRef,
pendingOutputScrollRef,
sessionRef,
hasConnectedRef,
@@ -2207,6 +2277,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
restoreCwdIntentRef,
disposeDataRef,
disposeExitRef,
trackSessionCleanup,
disposeTelnetEchoModeRef,
fitAddonRef,
serializeAddonRef,
@@ -2329,7 +2400,15 @@ const TerminalComponent: React.FC<TerminalProps> = ({
return;
}
setMoshShellReady(false);
disposeMoshReadyRef.current = terminalBackend.onMoshSessionReady(sessionId, () => {
disposeMoshReadyRef.current = terminalBackend.onMoshSessionReady(sessionId, (evt) => {
const currentEpoch = bootEpochRef.current;
if (
Number.isFinite(currentEpoch)
&& Number.isFinite(evt?.bootEpoch)
&& evt.bootEpoch !== currentEpoch
) {
return;
}
setMoshShellReady(true);
}) ?? null;
}, [effectiveTerminalProtocol, sessionId, terminalBackend]);
@@ -3096,6 +3175,50 @@ const TerminalComponent: React.FC<TerminalProps> = ({
onCloseSession?.(sessionId);
};
/** Tear down the live transport but keep the tab/pane for a later reconnect. */
const handleDisconnect = () => {
if (attachExistingSession) return;
if (statusRef.current === "disconnected") return;
if (pendingHostKeyRequestId) {
void terminalBackend.respondHostKeyVerification(pendingHostKeyRequestId, false);
}
clearAutoReconnect({ stopLoop: true });
retryTokenRef.current = null;
reconnectPreparationTokenRef.current = null;
restoreCwdIntentRef.current = null;
// A hibernated reconnect may still be waking the runtime; invalidate that
// continuation so it cannot re-arm boot after this disconnect. Keep any
// runtime already created — wakeHibernatedRuntime clears hibernatedRef
// before returning, and disposing here would leave no term and no
// hibernation marker, permanently blocking later Reconnect.
reconnectWakeInvalidateModeRef.current = "keep";
reconnectWakeTokenRef.current = null;
reconnectWakeInFlightRef.current = false;
// Cancel closes the tab (effect cleanup flips boot-active). Disconnect keeps
// the pane mounted, so mark boot inactive here or a late startSSH/startMosh
// attach can still bring the session back after the user asked to stop.
// Bump the epoch so a later reconnect cannot revive this aborted attempt,
// but closeSession must still target the pre-bump epoch.
invalidateBootEpochForClose();
isBootActiveRef.current = false;
setIsCancelling(true);
auth.setNeedsAuth(false);
auth.setAuthRetryMessage(null);
setNeedsHostKeyVerification(false);
setPendingHostKeyInfo(null);
setPendingHostKeyRequestId(null);
setError(null);
setProgressLogs((prev) => [...prev, "Disconnected by user."]);
void cleanupSession();
updateStatus("disconnected");
setChainProgress(null);
setIsDisconnectedDialogDismissed(false);
window.dispatchEvent(new CustomEvent("netcatty:terminal-session-disconnected", {
detail: { sessionId },
}));
setTimeout(() => setIsCancelling(false), 600);
};
const handleDismissDisconnectedDialog = () => {
setIsDisconnectedDialogDismissed(true);
queueMicrotask(() => termRef.current?.focus());
@@ -3156,11 +3279,15 @@ const TerminalComponent: React.FC<TerminalProps> = ({
}
reconnectWakeInFlightRef.current = true;
const wakeToken = Symbol();
reconnectWakeInvalidateModeRef.current = "dispose";
reconnectWakeTokenRef.current = wakeToken;
updateStatus("connecting");
void wakeForReconnect().then((woke) => {
if (reconnectWakeTokenRef.current !== wakeToken) {
disposeRuntimeOnly();
reconnectWakeInFlightRef.current = false;
if (reconnectWakeInvalidateModeRef.current === "dispose") {
disposeRuntimeOnly();
}
return;
}
reconnectWakeTokenRef.current = null;
@@ -3172,7 +3299,10 @@ const TerminalComponent: React.FC<TerminalProps> = ({
updateStatus("disconnected");
}).catch(() => {
if (reconnectWakeTokenRef.current !== wakeToken) {
disposeRuntimeOnly();
reconnectWakeInFlightRef.current = false;
if (reconnectWakeInvalidateModeRef.current === "dispose") {
disposeRuntimeOnly();
}
return;
}
reconnectWakeTokenRef.current = null;
@@ -3230,6 +3360,8 @@ const TerminalComponent: React.FC<TerminalProps> = ({
// ghost backend session with no owning UI.
const retryStillActive = () => retryTokenStillCurrent() && termRef.current === term;
bootEpochRef.current += 1;
publishBootEpoch();
isBootActiveRef.current = true;
auth.resetForRetry();
terminalDataCapturedRef.current = false;
@@ -3686,16 +3818,22 @@ const TerminalComponent: React.FC<TerminalProps> = ({
wakeInProgressRef.current = true;
setPasswordPickerState(null);
const stopHibernateListeners = () => {
const backendId = sessionRef.current;
const stopHibernateDataListener = () => {
disposeDataRef.current?.();
disposeDataRef.current = null;
};
const stopHibernateListeners = (opts?: { keepPaused?: boolean }) => {
const backendId = sessionRef.current;
stopHibernateDataListener();
disposeExitRef.current?.();
disposeExitRef.current = null;
if (backendId) {
flushTerminalSessionFlowAck(backendId);
clearTerminalSessionFlowAck(backendId);
terminalBackend.setSessionFlowPaused?.(backendId, false);
if (!opts?.keepPaused) {
terminalBackend.setSessionFlowPaused?.(backendId, false);
}
}
};
@@ -3704,7 +3842,64 @@ const TerminalComponent: React.FC<TerminalProps> = ({
runtimeContext,
container,
getPayload,
stopHibernateListeners,
prepareWakeFlow: async () => {
const backendId = sessionRef.current;
if (!backendId) return true;
// Always pause when a backend exists, including reconnect wakes
// (sessionConnected=false) that will not reattach. Stopping the
// hibernate listener without a pause drops live output into the
// preload backlog without flow ACKs until cleanupSession.
if (terminalBackend.setSessionFlowPausedAndWait) {
const result = await terminalBackend.setSessionFlowPausedAndWait(backendId, true);
return result?.success === true;
}
terminalBackend.setSessionFlowPaused?.(backendId, true);
return false;
},
takePendingBuffer: () => {
const pending = hibernatePendingBufferRef.current;
hibernatePendingBufferRef.current = "";
return pending;
},
stopHibernateDataListener,
setHibernatePendingCapDisabled: (disabled) => {
hibernatePendingCapDisabledRef.current = disabled;
},
stopHibernateListeners: () => stopHibernateListeners({ keepPaused: true }),
restoreAfterFailedWake: (takenPending) => {
hibernatePendingCapDisabledRef.current = false;
const pendingStillInRef = hibernatePendingBufferRef.current;
disposeRuntimeOnly();
const backendId = sessionRef.current;
if (!backendId) {
// No backend to reattach listeners to; still keep taken bytes for a
// later wake of a disconnected hibernated tab.
hibernatePendingBufferRef.current = appendHibernatePendingBuffer(
takenPending || "",
pendingStillInRef,
);
return;
}
beginHibernatedSessionListeners(backendId);
// beginHibernatedSessionListeners clears pending; restore take-and-cleared
// bytes from this wake plus anything that arrived after the last take.
const restored = appendHibernatePendingBuffer(
takenPending || "",
pendingStillInRef,
);
if (restored) {
hibernatePendingBufferRef.current = appendHibernatePendingBuffer(
restored,
hibernatePendingBufferRef.current,
);
}
},
resumeAfterReattach: () => {
hibernatePendingCapDisabledRef.current = false;
const backendId = sessionRef.current;
if (!backendId) return;
terminalBackend.setSessionFlowPaused?.(backendId, false);
},
sessionConnected: options.sessionConnected,
getSessionConnected: () => getSessionConnectedRef.current(),
reattachSession: (term) => {
@@ -3730,7 +3925,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
});
wakePromiseRef.current = wakePromise;
return wakePromise;
}, [sessionId, terminalBackend, terminalRuntimeRefs, resizeSession, terminalSettings]);
}, [sessionId, terminalBackend, terminalRuntimeRefs, resizeSession, terminalSettings, beginHibernatedSessionListeners]);
const wakeHibernatedRuntime = useCallback(async (sessionConnected: boolean): Promise<boolean> => {
if (!hibernatedRef.current) {
@@ -3805,11 +4000,11 @@ const TerminalComponent: React.FC<TerminalProps> = ({
onWake: wakeFromHibernateRuntime,
});
useTerminalEffects({ CONNECTION_TIMEOUT, Error, XTERM_PERFORMANCE_CONFIG, applyUserCursorPreference, auth, autocompleteCloseRef, autocompleteInputRef, autocompleteKeyEventRef, captureTerminalLogData, chainHosts: resolvedChainHosts, chainProgress, clearTerminalCwd, commandBufferRef, connectionLogBufferRef, containerRef, createPromptLineBreakState, createReplaySafeTerminalLogSanitizer, createXTermRuntime, deferTerminalResizeRef, disableTerminalFontZoomRef, effectiveFontSize, effectiveFontWeight, effectiveTheme, error, executeSnippetCommand, finalizeTerminalLogData, fitAddonRef, fontFamilyId, fontSize, fontWeightFixupDoneRef, forceCloseHibernatedSession, forceSyncRenderAfterResize, handleOsc52ReadRequest, handleTerminalDataCaptureOnce, hasConnectedRef, hasRuntimeRef, host, hotkeySchemeRef, hibernatedRef, identities, inWorkspace, isBootActiveRef, isBroadcastEnabledRef, isComposeBarOpen: effectiveComposeBarOpen, isConnectionAwaitingUserInput, isConnectionPastTcpDial, isFocusMode, isFocused, isLocalConnection, isNetworkDevice, isResizing: deferTerminalResize, isRestoringSelectionRef, isSearchOpen, isSerialConnection, isVisible, isVisibleRef, keyBindingsRef, keys, kittyKeyboardProtocolEnabledForSession, knownCwdRef, lastFittedSizeRef, lastToastedErrorRef, logger, mouseTrackingRef, needsHostKeyVerification, onBroadcastInputRef, onBroadcastInterruptPriorityChange, onCommandExecuted, onCommandSubmitted, onHotkeyActionRef, onOpenExternalError, onOutputTriggerUserInputRef: noteOutputTriggerUserInputRef, onPluginRuntimeCwdChange: pluginAwareOnRuntimeCwdChange, onSnippetShortkeyRef, onSnippetExecutorChange, onTerminalCwdChange, onTerminalTitleChange, onTerminalBell, onTerminalFontSizeChange, paneLayoutKey, passwordPromptActiveRef, pendingAuthRef, pendingOutputScrollRef, pluginDecorationRefreshRef, pluginDecorationRules, pluginDecorationRulesRef, pluginTerminalLifecycle, pluginTerminalProviderRevision, isPluginTerminalProviderAvailable, requestPluginTerminalProviders, prepareRestoredReconnect, prepareInitialCwdIntent, prevIsResizingRef, promptLineBreakStateRef, resizeSession, resolveHostAuth, resolvedFontFamily, safeFit, scriptRecorderRef: recorderRef, searchAddonRef, serialConfig, serialLineBufferRef, serializeAddonRef, sessionId, sessionRef, sessionStarters, setError, setHasMouseTracking, setIsCancelling, setIsDisconnectedDialogDismissed, requestSearchFocus, setNeedsHostKeyVerification, setPendingHostKeyInfo, setPendingHostKeyRequestId, setProgressLogs, setProgressValue, setShowLogs, setStatus, setTimeLeft, shellType, shouldEnableNativeUserInputAutoScroll, shouldProbeSessionCwd, shouldStartTerminalBackend, attachExistingSession, attachAuthorization, attachHomeWebContentsIdRef, snippetsRef, splitResizeActive: isResizing, status, statusRef, sudoAutofillRef, t, teardown, telnetLocalEchoRef, termRef, terminalAltKeyOptions, terminalBackend, terminalContextActionsRef, terminalCwdTracker, terminalDataCapturedRef, terminalLogSanitizerRef, terminalSettings, terminalSettingsRef, terminalTitleRef, toHostKeyInfo, toast, updateStatus, useEffect, useLayoutEffect, workspaceId, xtermRuntimeRef, zmodem, zmodemToastedRef, restoreState });
useTerminalEffects({ CONNECTION_TIMEOUT, Error, XTERM_PERFORMANCE_CONFIG, applyUserCursorPreference, auth, autocompleteCloseRef, autocompleteInputRef, autocompleteKeyEventRef, captureTerminalLogData, chainHosts: resolvedChainHosts, chainProgress, clearTerminalCwd, commandBufferRef, connectionLogBufferRef, containerRef, createPromptLineBreakState, createReplaySafeTerminalLogSanitizer, createXTermRuntime, deferTerminalResizeRef, disableTerminalFontZoomRef, effectiveFontSize, effectiveFontWeight, effectiveTheme, error, executeSnippetCommand, finalizeTerminalLogData, fitAddonRef, fontFamilyId, fontSize, fontWeightFixupDoneRef, forceCloseHibernatedSession, forceSyncRenderAfterResize, handleOsc52ReadRequest, handleTerminalDataCaptureOnce, hasConnectedRef, hasRuntimeRef, host, hotkeySchemeRef, hibernatedRef, identities, inWorkspace, isBootActiveRef, bootEpochRef, isBroadcastEnabledRef, isComposeBarOpen: effectiveComposeBarOpen, isConnectionAwaitingUserInput, isConnectionPastTcpDial, isFocusMode, isFocused, isLocalConnection, isNetworkDevice, isResizing: deferTerminalResize, isRestoringSelectionRef, isSearchOpen, isSerialConnection, isVisible, isVisibleRef, keyBindingsRef, keys, kittyKeyboardProtocolEnabledForSession, knownCwdRef, lastFittedSizeRef, lastToastedErrorRef, logger, mouseTrackingRef, needsHostKeyVerification, onBroadcastInputRef, onBroadcastInterruptPriorityChange, onCommandExecuted, onCommandSubmitted, onHotkeyActionRef, onOpenExternalError, onOutputTriggerUserInputRef: noteOutputTriggerUserInputRef, onPluginRuntimeCwdChange: pluginAwareOnRuntimeCwdChange, onSnippetShortkeyRef, onSnippetExecutorChange, onTerminalCwdChange, onTerminalTitleChange, onTerminalBell, onTerminalFontSizeChange, paneLayoutKey, passwordPromptActiveRef, pendingAuthRef, pendingOutputScrollRef, pluginDecorationRefreshRef, pluginDecorationRules, pluginDecorationRulesRef, pluginTerminalLifecycle, pluginTerminalProviderRevision, isPluginTerminalProviderAvailable, requestPluginTerminalProviders, prepareRestoredReconnect, prepareInitialCwdIntent, prevIsResizingRef, promptLineBreakStateRef, resizeSession, resolveHostAuth, resolvedFontFamily, safeFit, scriptRecorderRef: recorderRef, searchAddonRef, serialConfig, serialLineBufferRef, serializeAddonRef, sessionId, sessionRef, sessionStarters, setError, setHasMouseTracking, setIsCancelling, setIsDisconnectedDialogDismissed, requestSearchFocus, setNeedsHostKeyVerification, setPendingHostKeyInfo, setPendingHostKeyRequestId, setProgressLogs, setProgressValue, setShowLogs, setStatus, setTimeLeft, shellType, shouldEnableNativeUserInputAutoScroll, shouldProbeSessionCwd, shouldStartTerminalBackend, attachExistingSession, attachAuthorization, attachHomeWebContentsIdRef, snippetsRef, splitResizeActive: isResizing, status, statusRef, sudoAutofillRef, t, teardown, telnetLocalEchoRef, termRef, terminalAltKeyOptions, terminalBackend, terminalContextActionsRef, terminalCwdTracker, terminalDataCapturedRef, terminalLogSanitizerRef, terminalSettings, terminalSettingsRef, terminalTitleRef, toHostKeyInfo, toast, updateStatus, useEffect, useLayoutEffect, workspaceId, xtermRuntimeRef, zmodem, zmodemToastedRef, restoreState });
return (
<>
<TerminalView ctx={{ Activity, ArrowDownToLine, ArrowUpFromLine, Button, Clock3, Copy, Cpu, HardDrive, HoverCard, HoverCardContent, HoverCardTrigger, Maximize2, MemoryStick, Radio, Sparkles, SquareArrowOutUpRight, TerminalAutocomplete, TerminalComposeBar, TerminalConnectionDialog, TerminalContextMenu, TerminalSearchBar, Tooltip, TooltipContent, TooltipTrigger, ZmodemOverwriteDialog, ZmodemProgressIndicator, auth, autocompleteAcceptTextRef, autocompleteCloseRef, autocompleteHostOs, autocompleteInputRef, autocompleteKeyEventRef, autocompleteRepositionRef, autocompleteSettings, canUpdateHost: !!onUpdateHost, chainProgress, cn, compactToolbar, lineTimestampsAvailable, containerRef, effectiveFontSize, effectiveFontWeight, effectiveTheme, error, executeSnippet, executeSnippetCommand, handleAddSelectionToAI: onAddSelectionToAI ? handleAddSelectionToAI : undefined, handleCancelConnect, handleCloseDisconnectedSession, handleCloseSearch, handleDismissDisconnectedDialog, handleDragEnter, handleDragLeave, handleDragOver, handleDrop, handleFindNext, handleFindPrevious, handleHostKeyAddAndContinue, handleHostKeyClose, handleHostKeyContinue, handleOsc52ReadResponse, handleOsc7SetupConfirm, handleOsc7SetupOpenChange, handleReceiveYmodem, handleRetry, handleSearch, handleSendYmodem, handleTopOverlayMouseDownCapture, hasMouseTracking, host, hotkeyScheme, inWorkspace, isBroadcastEnabled, isCancelling, isComposeBarOpen: effectiveComposeBarOpen, isConnectionAwaitingUserInput, isDraggingOver, isFocusMode, isFocusedPane, isLocalConnection, remoteDragDropUsesZmodem, isPluginTerminalProviderAvailable, isSerialConnection, isSearchOpen, isSupportedOs, isSystemSidebarEligible, isVisible, keyBindings, keys, knownCwdRef, needsHostKeyVerification, onAddSelectionToAI, onBroadcastInput, onCloseSession, onDetach, onDetachDragEnd, onDetachDragStart, onDetachPointerDown, onEndSessionDrag, onExpandToFocus, onOpenSystem, onRename, onSplitHorizontal, onSplitVertical, onStartSessionDrag, onToggleBroadcast, onUpdateHost: handleUpdateHostFromTerminal, osc52ReadPromptVisible, osc7SetupOpen, osc7SetupRunning, passwordPromptActiveRef, pendingHostKeyInfo, progressLogs, progressValue, renderControls, resolvedFontFamily, restoreState, scrollToBottomAfterProgrammaticInput, searchMatchCount, searchFocusToken, scriptExecutionOverlay: activeScriptRun ? (
<TerminalView ctx={{ Activity, ArrowDownToLine, ArrowUpFromLine, Button, Clock3, Copy, Cpu, HardDrive, HoverCard, HoverCardContent, HoverCardTrigger, Maximize2, MemoryStick, Radio, RefreshCcw, Sparkles, SquareArrowOutUpRight, Unplug, TerminalAutocomplete, TerminalComposeBar, TerminalConnectionDialog, TerminalContextMenu, TerminalSearchBar, Tooltip, TooltipContent, TooltipTrigger, ZmodemOverwriteDialog, ZmodemProgressIndicator, auth, autocompleteAcceptTextRef, autocompleteCloseRef, autocompleteHostOs, autocompleteInputRef, autocompleteKeyEventRef, autocompleteRepositionRef, autocompleteSettings, canUpdateHost: !!onUpdateHost, chainProgress, cn, compactToolbar, lineTimestampsAvailable, containerRef, effectiveFontSize, effectiveFontWeight, effectiveTheme, error, executeSnippet, executeSnippetCommand, handleAddSelectionToAI: onAddSelectionToAI ? handleAddSelectionToAI : undefined, handleCancelConnect, handleCloseDisconnectedSession, handleCloseSearch, handleDisconnect: (attachExistingSession || compactToolbar) ? undefined : handleDisconnect, handleDismissDisconnectedDialog, handleDragEnter, handleDragLeave, handleDragOver, handleDrop, handleFindNext, handleFindPrevious, handleHostKeyAddAndContinue, handleHostKeyClose, handleHostKeyContinue, handleOsc52ReadResponse, handleOsc7SetupConfirm, handleOsc7SetupOpenChange, handleReceiveYmodem, handleRetry, handleSearch, handleSendYmodem, handleTopOverlayMouseDownCapture, hasMouseTracking, host, hotkeyScheme, inWorkspace, isBroadcastEnabled, isCancelling, isComposeBarOpen: effectiveComposeBarOpen, isConnectionAwaitingUserInput, isDraggingOver, isFocusMode, isFocusedPane, isLocalConnection, remoteDragDropUsesZmodem, isPluginTerminalProviderAvailable, isSerialConnection, isSearchOpen, isSupportedOs, isSystemSidebarEligible, isVisible, keyBindings, keys, knownCwdRef, needsHostKeyVerification, onAddSelectionToAI, onBroadcastInput, onCloseSession, onDetach, onDetachDragEnd, onDetachDragStart, onDetachPointerDown, onEndSessionDrag, onExpandToFocus, onOpenSystem, onRename, onSplitHorizontal, onSplitVertical, onStartSessionDrag, onToggleBroadcast, onUpdateHost: handleUpdateHostFromTerminal, osc52ReadPromptVisible, osc7SetupOpen, osc7SetupRunning, passwordPromptActiveRef, pendingHostKeyInfo, progressLogs, progressValue, renderControls, resolvedFontFamily, restoreState, scrollToBottomAfterProgrammaticInput, searchMatchCount, searchFocusToken, scriptExecutionOverlay: activeScriptRun ? (
<ScriptExecutionOverlay
run={activeScriptRun}
onPause={() => { void pauseScriptRun(activeScriptRun.runId); }}
@@ -3818,7 +4013,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
onDismiss={dismissScriptOverlay}
compactTopChrome={terminalSettings?.showHostInfoBar === false}
/>
) : null, sessionDisplayName, sessionId, workspaceId, sessionRef, setIsComposeBarOpen, setShowLogs, shouldShowConnectionDialog, showLogs, showSelectionAIAction: Boolean(showSelectionAIAction && onAddSelectionToAI), isRestoringSelectionRef, snippets, status, sudoHintRef, sudoHintText, passwordPickerState, onPasswordPickerSelect: handlePasswordPickerSelect, passwordPickerTitle, passwordPickerEmptyText, t, termRef, terminalBackend, terminalContextActions, terminalCwdTracker, terminalPreviewVars, terminalSettings, timeLeft, toast, zmodem }} />
) : null, sessionDisplayName, sessionId, workspaceId, sessionRef, setIsComposeBarOpen, setShowLogs, shouldShowConnectionDialog, showConnectionControls: !attachExistingSession && !compactToolbar, showLogs, showSelectionAIAction: Boolean(showSelectionAIAction && onAddSelectionToAI), isRestoringSelectionRef, snippets, status, sudoHintRef, sudoHintText, passwordPickerState, onPasswordPickerSelect: handlePasswordPickerSelect, passwordPickerTitle, passwordPickerEmptyText, t, termRef, terminalBackend, terminalContextActions, terminalCwdTracker, terminalPreviewVars, terminalSettings, timeLeft, toast, zmodem }} />
<ScriptSaveRecordingDialog
open={saveRecordingOpen}
code={recordedCode}
+26
View File
@@ -217,6 +217,32 @@ test("TerminalLayer ignores shellHistory prop churn (reads shellHistoryStore ins
);
});
test("TerminalLayer ignores notes prop churn (reads notesStore instead)", () => {
assert.equal(
terminalLayerAreEqual(
baseProps as never,
{
...baseProps,
notes: [{ id: "n1", title: "One", content: "body", tags: [], createdAt: 1, updatedAt: 1, order: 1000 }],
noteGroups: ["Ops"],
updateNotes: () => {},
updateNoteGroups: () => {},
} as never,
),
true,
);
});
test("TerminalLayer ignores customAccent prop churn (reads appearanceChromeStore instead)", () => {
assert.equal(
terminalLayerAreEqual(
baseProps as never,
{ ...baseProps, accentMode: "custom", customAccent: "#ff00aa" } as never,
),
true,
);
});
test("TerminalLayer ignores sessions dynamicTitle-only updates", () => {
const sessionsA = [{
id: "s1",
-8
View File
@@ -173,8 +173,6 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
identities,
snippets,
snippetPackages,
notes,
noteGroups,
openNoteRequest,
onOpenVaultNoteFromChat,
onOpenVaultHostFromChat,
@@ -237,8 +235,6 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
updateHosts,
updateSnippets,
updateSnippetPackages,
updateNotes,
updateNoteGroups,
sftpDefaultViewMode,
sftpDoubleClickBehavior,
sftpAutoSync,
@@ -2165,8 +2161,6 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
programmaticCommandLogRewriteHandlersRef,
snippetPackages,
snippets,
noteGroups,
notes,
onOpenVaultHostFromChat,
onOpenVaultNoteFromChat: handleOpenVaultNoteFromAiPanel,
onOpenVaultSectionFromChat,
@@ -2189,8 +2183,6 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
TooltipContent,
TooltipTrigger,
updateHosts,
updateNoteGroups,
updateNotes,
updateSnippetPackages,
updateSnippets,
X,
+8 -3
View File
@@ -25,12 +25,17 @@ export const ConversationContent = ({ className, ...props }: ConversationContent
/>
);
export const ConversationScrollButton = ({ className, ...props }: React.ButtonHTMLAttributes<HTMLButtonElement>) => {
export const ConversationScrollButton = ({
className,
onClick,
...props
}: React.ButtonHTMLAttributes<HTMLButtonElement>) => {
const { isAtBottom, scrollToBottom } = useStickToBottomContext();
const handleClick = useCallback(() => {
const handleClick = useCallback((event: React.MouseEvent<HTMLButtonElement>) => {
onClick?.(event);
scrollToBottom();
}, [scrollToBottom]);
}, [onClick, scrollToBottom]);
if (isAtBottom) return null;
+149
View File
@@ -1,12 +1,24 @@
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { dirname, join } from 'node:path';
import test from 'node:test';
import { fileURLToPath } from 'node:url';
import {
approvalArgsHaveExtraContext,
approvalCommandWasUnwrapped,
extractApprovalExecutionContext,
extractDisplayCommand,
isNestedInteractiveApprovalTarget,
MAX_TOOL_COMMAND_TOOLTIP_CHARS,
truncateToolCommandTooltip,
} from './tool-call';
const toolCallSource = readFileSync(
join(dirname(fileURLToPath(import.meta.url)), 'tool-call.tsx'),
'utf8',
);
// Codex (SDK) emits command_execution.command as a STRING that wraps the real
// command in `<shell> -lc '<full>'`. Under Skills + CLI the real command is a
// netcatty-tool-cli call. The title must unwrap the shell layer first, else the
@@ -44,6 +56,30 @@ test('raw (unwrapped) netcatty-tool-cli exec still works', () => {
);
});
test('netcatty-tool-cli.cjs wrapper still unwraps to remote command', () => {
assert.equal(
extractDisplayCommand({
command: `/bin/zsh -lc '"/abs/netcatty-tool-cli.cjs" exec --session X -- "uptime"'`,
}),
'uptime',
);
assert.equal(
extractDisplayCommand({
command: `"/Resources/netcatty-tool-cli.cjs" exec --session X -- "df -h"`,
}),
'df -h',
);
});
test('netcatty-tool-cli.cmd wrapper still unwraps to remote command', () => {
assert.equal(
extractDisplayCommand({
command: `"C:\\\\App\\\\netcatty-tool-cli.cmd" exec --session X -- "whoami"`,
}),
'whoami',
);
});
test('netcatty-tool-cli env -> list sessions', () => {
assert.equal(extractDisplayCommand({ command: 'netcatty-tool-cli env' }), 'netcatty: list sessions');
});
@@ -70,3 +106,116 @@ test('empty / missing args -> null', () => {
assert.equal(extractDisplayCommand(undefined), null);
assert.equal(extractDisplayCommand({ command: '' }), null);
});
test('extractApprovalExecutionContext surfaces session/cwd/shell without rewriting command', () => {
assert.deepEqual(
extractApprovalExecutionContext({
sessionId: 'term-1',
cwd: '/var/log',
command: ['zsh', '-lc', 'df -h | sort'],
}),
{ sessionId: 'term-1', cwd: '/var/log', shell: 'zsh', reason: undefined },
);
assert.deepEqual(
extractApprovalExecutionContext({
command: `/bin/bash -lc 'uptime'`,
}),
{ sessionId: undefined, cwd: undefined, shell: 'bash', reason: undefined },
);
assert.equal(extractApprovalExecutionContext({ path: '/tmp' }), null);
});
test('extractApprovalExecutionContext reads --session from netcatty-tool-cli wrappers', () => {
assert.deepEqual(
extractApprovalExecutionContext({
command: `/bin/zsh -lc '"/abs/netcatty-tool-cli" exec --session term-9 --chat-session chat-1 -- "uptime"'`,
}),
{ sessionId: 'term-9', cwd: undefined, shell: 'zsh', reason: undefined },
);
});
test('extractApprovalExecutionContext surfaces Codex reason for approval review', () => {
assert.deepEqual(
extractApprovalExecutionContext({
command: 'rm -rf /tmp/x',
cwd: '/tmp',
reason: 'Clean stale build artifacts',
commandActions: [{ type: 'delete' }],
}),
{
sessionId: undefined,
cwd: '/tmp',
shell: undefined,
reason: 'Clean stale build artifacts',
},
);
});
test('approvalCommandWasUnwrapped detects Skills+CLI display unwrap', () => {
const args = {
command: `/bin/zsh -lc '"/abs/netcatty-tool-cli" exec --session X -- "uptime"'`,
};
const display = extractDisplayCommand(args);
assert.equal(display, 'uptime');
assert.equal(approvalCommandWasUnwrapped(args, display), true);
assert.equal(approvalCommandWasUnwrapped({ command: 'uptime' }, 'uptime'), false);
});
test('approvalArgsHaveExtraContext keeps commandActions visible beside the command block', () => {
assert.equal(
approvalArgsHaveExtraContext({
command: 'echo hi',
reason: 'demo',
commandActions: [{ type: 'read' }],
}),
true,
);
assert.equal(
approvalArgsHaveExtraContext({ command: 'echo hi', cwd: '/tmp', reason: 'demo' }),
false,
);
});
test('isNestedInteractiveApprovalTarget ignores Enter on Copy/Expand review controls', () => {
const card = { id: 'card' };
const copyBtn = {
closest: (selector: string) => (selector.includes('button') ? copyBtn : null),
};
const plainSpan = {
closest: () => null,
};
assert.equal(isNestedInteractiveApprovalTarget(copyBtn, card), true);
assert.equal(isNestedInteractiveApprovalTarget(plainSpan, card), false);
assert.equal(isNestedInteractiveApprovalTarget(card, card), false);
assert.equal(isNestedInteractiveApprovalTarget(null, card), false);
});
// Pending approvals with no display command (Codex file-change/permissions,
// write tools with JSON-only args) only render the overflow args <pre>. Wheel /
// trackpad scroll must re-arm idle the same way the command block does.
test('args overflow block re-arms review timers on scroll/wheel while pending', () => {
assert.match(
toolCallSource,
/JSON\.stringify\(args,\s*null,\s*2\)[\s\S]{0,200}?<\/pre>/,
);
// Both review surfaces (command + args) wire markReviewing for continuous scroll.
const markReviewingScrollBindings = toolCallSource.match(
/onScroll=\{(?:isPendingApproval \? markReviewing : undefined|markReviewing)\}/g,
);
const markReviewingWheelBindings = toolCallSource.match(
/onWheel=\{(?:isPendingApproval \? markReviewing : undefined|markReviewing)\}/g,
);
assert.ok(
markReviewingScrollBindings && markReviewingScrollBindings.length >= 2,
'command and args overflow blocks must both call markReviewing onScroll',
);
assert.ok(
markReviewingWheelBindings && markReviewingWheelBindings.length >= 2,
'command and args overflow blocks must both call markReviewing onWheel',
);
assert.match(
toolCallSource,
/onScroll=\{isPendingApproval \? markReviewing : undefined\}[\s\S]{0,80}onWheel=\{isPendingApproval \? markReviewing : undefined\}[\s\S]{0,80}JSON\.stringify\(args/,
);
});
+361 -39
View File
@@ -1,13 +1,36 @@
import React, { useCallback, useEffect, useRef, useState } from 'react';
import type { HTMLAttributes } from 'react';
import { cn } from '../../lib/utils';
import { Check, ChevronDown, ChevronRight, CheckCircle2, Loader2, ShieldAlert, X, XCircle, Slash } from 'lucide-react';
import { Check, ChevronDown, ChevronRight, CheckCircle2, Copy, Loader2, ShieldAlert, X, XCircle, Slash } from 'lucide-react';
import { Button } from '../ui/button';
import { Badge } from '../ui/badge';
import { Tooltip, TooltipContent, TooltipTrigger } from '../ui/tooltip';
import { useI18n } from '../../application/i18n/I18nProvider';
import { cancelApprovalTimeout } from '../../infrastructure/ai/shared/approvalGate';
export const MAX_TOOL_COMMAND_TOOLTIP_CHARS = 240;
/** Collapsed approval command block max height (px). Full text remains scrollable. */
export const APPROVAL_COMMAND_COLLAPSED_MAX_HEIGHT_PX = 144;
/** Expanded approval command block max height (px). */
export const APPROVAL_COMMAND_EXPANDED_MAX_HEIGHT_PX = 384;
/** Prefer expand control when the raw command exceeds this many characters. */
export const APPROVAL_COMMAND_EXPAND_CHAR_THRESHOLD = 180;
const NESTED_INTERACTIVE_SELECTOR = 'button, a, input, textarea, select, [role="button"]';
/**
* Enter on the pending-card root means Approve Once. Enter on nested review
* controls (Copy / Expand / action buttons) must not approve — those controls
* also stopPropagation on Enter so the card handler is a second line of defense.
*/
export function isNestedInteractiveApprovalTarget(
target: { closest?: (selector: string) => unknown } | null,
currentTarget: unknown,
): boolean {
if (!target || target === currentTarget) return false;
if (typeof target.closest !== 'function') return false;
return Boolean(target.closest(NESTED_INTERACTIVE_SELECTOR));
}
export function truncateToolCommandTooltip(
command: string,
@@ -72,10 +95,14 @@ export function extractDisplayCommand(args: Record<string, unknown> | undefined)
if (strWrap) cmdString = strWrap[2];
// Netcatty CLI wrapper extraction.
const cliIdx = cmdString.indexOf('netcatty-tool-cli');
// Packaged / Windows paths may be `netcatty-tool-cli.cjs` or `.cmd`; strip the
// optional extension so the subcommand after the binary is still found.
const cliIdx = cmdString.search(/netcatty-tool-cli(?:\.(?:cjs|cmd|exe|js))?/i);
if (cliIdx >= 0) {
const cliMatch = cmdString.slice(cliIdx).match(/^netcatty-tool-cli(?:\.(?:cjs|cmd|exe|js))?/i);
const cliTokenLen = cliMatch?.[0]?.length ?? 'netcatty-tool-cli'.length;
const afterCli = cmdString
.slice(cliIdx + 'netcatty-tool-cli'.length)
.slice(cliIdx + cliTokenLen)
.replace(/^["']?\s*/, '');
const subMatch = afterCli.match(/^(\S+)/);
const sub = subMatch ? subMatch[1] : '';
@@ -106,6 +133,110 @@ export function extractDisplayCommand(args: Record<string, unknown> | undefined)
return cmdString;
}
export interface ApprovalExecutionContext {
sessionId?: string;
cwd?: string;
shell?: string;
reason?: string;
}
function rawCommandString(args: Record<string, unknown> | undefined): string | null {
if (!args) return null;
const raw = (args as { command?: unknown }).command;
if (typeof raw === 'string') return raw || null;
if (Array.isArray(raw) && raw.length > 0) return raw.map((p) => String(p)).join(' ');
return null;
}
const APPROVAL_CONTEXT_ARG_KEYS = new Set([
'command',
'cwd',
'working_directory',
'workdir',
'workingDirectory',
'sessionId',
'shell',
'reason',
]);
/**
* True when pending args still carry review-relevant fields beyond the
* command block / execution-context strip (e.g. commandActions).
*/
export function approvalArgsHaveExtraContext(
args: Record<string, unknown> | undefined,
): boolean {
if (!args) return false;
return Object.keys(args).some((key) => !APPROVAL_CONTEXT_ARG_KEYS.has(key));
}
/**
* True when the reviewable display command was unwrapped from a Skills+CLI /
* shell wrapper — the pending card should still surface target flags.
*/
export function approvalCommandWasUnwrapped(
args: Record<string, unknown> | undefined,
displayCommand: string | null,
): boolean {
if (!displayCommand) return false;
const raw = rawCommandString(args);
if (!raw || raw === displayCommand) return false;
return raw.includes('netcatty-tool-cli') || /(?:^|\/)(sh|bash|zsh|fish|ash|dash)\s+-l?c\s+/.test(raw)
|| (Array.isArray(args?.command) && args.command.length >= 3);
}
/**
* Best-effort execution context for approval review (session / cwd / shell).
* Never invents host names; only surfaces fields already present on tool args
* or explicit netcatty-tool-cli flags in the command string.
*/
export function extractApprovalExecutionContext(
args: Record<string, unknown> | undefined,
): ApprovalExecutionContext | null {
if (!args) return null;
let sessionId = typeof args.sessionId === 'string' && args.sessionId.trim()
? args.sessionId.trim()
: undefined;
const cwdCandidate = [args.cwd, args.working_directory, args.workdir, args.workingDirectory]
.find((value) => typeof value === 'string' && value.trim());
const cwd = typeof cwdCandidate === 'string' ? cwdCandidate.trim() : undefined;
let shell = typeof args.shell === 'string' && args.shell.trim()
? args.shell.trim()
: undefined;
const reason = typeof args.reason === 'string' && args.reason.trim()
? args.reason.trim()
: undefined;
const raw = (args as { command?: unknown }).command;
if (!shell) {
if (Array.isArray(raw) && raw.length >= 2) {
const first = String(raw[0] ?? '');
const shellMatch = first.match(/(?:^|\/)(sh|bash|zsh|fish|ash|dash)$/);
if (shellMatch) shell = shellMatch[1];
} else if (typeof raw === 'string') {
const shellMatch = raw.match(/^(?:\S*\/)?(sh|bash|zsh|fish|ash|dash)\s+-l?c\s+/);
if (shellMatch) shell = shellMatch[1];
}
}
// Skills+CLI wrappers keep the Netcatty target only on CLI flags after unwrap.
if (!sessionId) {
const cmd = rawCommandString(args);
if (cmd && cmd.includes('netcatty-tool-cli')) {
const sessionMatch = cmd.match(/--session(?:\s+|=)(?:"([^"]+)"|'([^']+)'|(\S+))/);
const fromFlag = sessionMatch?.[1] ?? sessionMatch?.[2] ?? sessionMatch?.[3];
if (fromFlag) sessionId = fromFlag;
}
}
if (!sessionId && !cwd && !shell && !reason) return null;
return { sessionId, cwd, shell, reason };
}
/**
* Format tool result for display. Extracts stdout/stderr from structured
* command results for terminal-like output.
@@ -149,6 +280,8 @@ export interface ToolCallProps extends HTMLAttributes<HTMLDivElement> {
isInterrupted?: boolean;
/** Approval state for this tool call (from the approval gate). */
approvalStatus?: 'pending' | 'approved' | 'denied';
/** Pending approval id used to cancel the auto-deny timer on review. */
approvalId?: string;
/** Called when user approves this tool call. */
onApprove?: () => void;
/** Called when user rejects this tool call. */
@@ -161,18 +294,72 @@ export interface ToolCallProps extends HTMLAttributes<HTMLDivElement> {
alwaysAllowLabel?: string;
}
async function copyTextToClipboard(text: string): Promise<boolean> {
try {
if (typeof navigator !== 'undefined' && navigator.clipboard?.writeText) {
await navigator.clipboard.writeText(text);
return true;
}
} catch {
// fall through
}
try {
if (typeof document === 'undefined') return false;
const el = document.createElement('textarea');
el.value = text;
el.setAttribute('readonly', '');
el.style.position = 'fixed';
el.style.left = '-9999px';
document.body.appendChild(el);
el.select();
const ok = document.execCommand('copy');
document.body.removeChild(el);
return ok;
} catch {
return false;
}
}
export const ToolCall = ({
name, args, result, isError, isLoading, isInterrupted,
approvalStatus, onApprove, onReject, onApproveOnce, onAlwaysAllow, alwaysAllowLabel,
approvalStatus, approvalId, onApprove, onReject, onApproveOnce, onAlwaysAllow, alwaysAllowLabel,
className, ...props
}: ToolCallProps) => {
const { t } = useI18n();
const [expanded, setExpanded] = useState(false);
const [commandExpanded, setCommandExpanded] = useState(false);
const [copied, setCopied] = useState(false);
const [frozenCommand, setFrozenCommand] = useState<string | null>(null);
const cardRef = useRef<HTMLDivElement>(null);
const approveBtnRef = useRef<HTMLButtonElement>(null);
const [responded, setResponded] = useState(false);
const isPendingApproval = approvalStatus === 'pending' && !responded;
const liveDisplayCommand = extractDisplayCommand(args);
const reviewCommand = isPendingApproval
? (frozenCommand ?? liveDisplayCommand)
: liveDisplayCommand;
const executionContext = extractApprovalExecutionContext(args);
const showApprovalCommand = Boolean(isPendingApproval && reviewCommand);
const showArgsAlongsideCommand = Boolean(
showApprovalCommand
&& args
&& Object.keys(args).length > 0
&& (approvalCommandWasUnwrapped(args, reviewCommand) || approvalArgsHaveExtraContext(args)),
);
const commandNeedsExpand = Boolean(
reviewCommand
&& (reviewCommand.length > APPROVAL_COMMAND_EXPAND_CHAR_THRESHOLD
|| reviewCommand.includes('\n')),
);
// Each review interaction re-arms the Catty idle window (capped by hard
// deadline). Do not one-shot cancel — subsequent focus/scroll/key events
// must keep extending idle while the user is still deciding.
const markReviewing = useCallback(() => {
if (!isPendingApproval || !approvalId) return;
cancelApprovalTimeout(approvalId);
}, [approvalId, isPendingApproval]);
const handleApproveOnce = useCallback(() => {
if (!isPendingApproval) return;
@@ -192,28 +379,68 @@ export const ToolCall = ({
onReject?.();
}, [isPendingApproval, onReject]);
// Keyboard: Enter = approve, Escape = reject (when pending)
const handleCopyCommand = useCallback(async () => {
if (!reviewCommand) return;
markReviewing();
const ok = await copyTextToClipboard(reviewCommand);
if (!ok) return;
setCopied(true);
window.setTimeout(() => setCopied(false), 1500);
}, [markReviewing, reviewCommand]);
// Keyboard: Enter = approve, Escape = reject (when pending).
// Ignore Enter from nested controls (Copy / Expand / action buttons) so it
// activates that control instead of approving the pending command.
const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
if (!isPendingApproval) return;
if (e.key === 'Enter') { e.preventDefault(); handleApproveOnce(); }
else if (e.key === 'Escape') { e.preventDefault(); handleReject(); }
}, [isPendingApproval, handleApproveOnce, handleReject]);
if (e.key === 'Enter') {
if (isNestedInteractiveApprovalTarget(e.target as HTMLElement | null, e.currentTarget)) {
return;
}
e.preventDefault();
handleApproveOnce();
} else if (e.key === 'Escape') {
e.preventDefault();
handleReject();
} else {
// Typing / navigation while reviewing cancels the idle auto-deny timer.
markReviewing();
}
}, [isPendingApproval, handleApproveOnce, handleReject, markReviewing]);
// Auto-focus and auto-scroll when approval is pending
// Auto-focus and auto-scroll when approval is pending.
// Do not treat this programmatic expand/focus as user review (timeout stays armed).
useEffect(() => {
if (!isPendingApproval || !cardRef.current) return;
cardRef.current.scrollIntoView({ behavior: 'smooth', block: 'end' });
// Small delay to let the UI render, then expand and focus
setExpanded(true);
const focusTimer = setTimeout(() => approveBtnRef.current?.focus(), 100);
return () => clearTimeout(focusTimer);
}, [isPendingApproval]);
// Reset responded state when approvalStatus changes (e.g. new approval)
// Freeze the reviewable command for the life of this pending approval.
// Do not reset review/timeout state when args identity churns while still pending.
useEffect(() => {
if (approvalStatus === 'pending') setResponded(false);
if (approvalStatus === 'pending') {
setResponded(false);
setCommandExpanded(false);
setFrozenCommand(extractDisplayCommand(args));
return;
}
setFrozenCommand(null);
setCommandExpanded(false);
// Intentionally depend only on approvalStatus so late arg patches cannot
// replace the command the user is already reviewing.
// eslint-disable-next-line react-hooks/exhaustive-deps -- freeze on pending enter
}, [approvalStatus]);
// If the first pending paint had no command yet, accept the first non-empty one.
useEffect(() => {
if (!isPendingApproval || frozenCommand) return;
const next = extractDisplayCommand(args);
if (next) setFrozenCommand(next);
}, [args, frozenCommand, isPendingApproval]);
// Border/bg color based on approval status
const borderClass = approvalStatus === 'pending'
? 'border-yellow-500/30 bg-yellow-500/[0.04]'
@@ -236,46 +463,48 @@ export const ToolCall = ({
<CheckCircle2 size={12} className={cn('text-green-400/70', statusIconClass)} />
) : null;
const headerCommand = reviewCommand ?? liveDisplayCommand;
return (
<div
ref={cardRef}
tabIndex={isPendingApproval ? 0 : undefined}
onKeyDown={isPendingApproval ? handleKeyDown : undefined}
onPointerDownCapture={isPendingApproval ? markReviewing : undefined}
className={cn('min-w-0 rounded-md border overflow-hidden text-[12px] outline-none', borderClass, className)}
{...props}
>
<button
type="button"
onClick={() => setExpanded(e => !e)}
onClick={() => {
if (isPendingApproval) markReviewing();
setExpanded((e) => !e);
}}
className="w-full flex items-center gap-2 px-3 py-1.5 hover:bg-muted/20 transition-colors cursor-pointer"
>
{expanded
? <ChevronDown size={12} className="text-muted-foreground/40 shrink-0" />
: <ChevronRight size={12} className="text-muted-foreground/40 shrink-0" />
}
{(() => {
const displayCmd = extractDisplayCommand(args);
if (displayCmd) {
return (
<Tooltip>
<TooltipTrigger asChild>
<span className="font-mono text-muted-foreground/70 truncate cursor-default">
<span className="text-muted-foreground/40">$ </span>{displayCmd}
</span>
</TooltipTrigger>
<TooltipContent
side="top"
align="start"
collisionPadding={12}
className="w-[calc(100vw-24px)] max-w-[420px] whitespace-pre-wrap break-words font-mono text-[11px] leading-relaxed [overflow-wrap:anywhere]"
>
{truncateToolCommandTooltip(displayCmd)}
</TooltipContent>
</Tooltip>
);
}
return <span className="font-mono text-muted-foreground/70 truncate">{name}</span>;
})()}
{headerCommand ? (
<Tooltip>
<TooltipTrigger asChild>
<span className="font-mono text-muted-foreground/70 truncate cursor-default">
<span className="text-muted-foreground/40">$ </span>{headerCommand}
</span>
</TooltipTrigger>
<TooltipContent
side="top"
align="start"
collisionPadding={12}
className="w-[calc(100vw-24px)] max-w-[420px] whitespace-pre-wrap break-words font-mono text-[11px] leading-relaxed [overflow-wrap:anywhere]"
>
{truncateToolCommandTooltip(headerCommand)}
</TooltipContent>
</Tooltip>
) : (
<span className="font-mono text-muted-foreground/70 truncate">{name}</span>
)}
<span className="flex-1" />
{/* Approval badge for resolved approvals */}
{approvalStatus === 'approved' && (
@@ -293,10 +522,103 @@ export const ToolCall = ({
{expanded && (
<div className="border-t border-border/20">
{args && Object.keys(args).length > 0 && (
{showApprovalCommand && reviewCommand && (
<div className="px-3 py-2 space-y-1.5">
{executionContext && (
<div className="flex flex-wrap gap-x-3 gap-y-0.5 text-[10px] text-muted-foreground/45">
<span className="font-medium uppercase tracking-wider text-muted-foreground/30">
{t('ai.chat.targetLabel')}
</span>
{executionContext.sessionId && (
<span className="font-mono truncate" title={executionContext.sessionId}>
{t('ai.chat.approvalSession')}: {executionContext.sessionId}
</span>
)}
{executionContext.shell && (
<span className="font-mono">
{t('ai.chat.approvalShell')}: {executionContext.shell}
</span>
)}
{executionContext.cwd && (
<span className="font-mono truncate" title={executionContext.cwd}>
{t('ai.chat.approvalCwd')}: {executionContext.cwd}
</span>
)}
{executionContext.reason && (
<span className="truncate" title={executionContext.reason}>
{t('ai.chat.approvalReason')}: {executionContext.reason}
</span>
)}
</div>
)}
<div className="flex items-center justify-between gap-2">
<div className="text-[10px] font-medium uppercase tracking-wider text-muted-foreground/30">
{t('ai.chat.rawCommand')}
</div>
<div className="flex items-center gap-1">
{commandNeedsExpand && (
<button
type="button"
className="text-[10px] text-muted-foreground/50 hover:text-muted-foreground px-1.5 py-0.5 rounded hover:bg-muted/30"
onClick={() => {
markReviewing();
setCommandExpanded((v) => !v);
}}
onKeyDown={(e) => {
// Keep Enter on this control; let Escape bubble to card reject.
if (e.key === 'Enter') e.stopPropagation();
}}
>
{commandExpanded ? t('ai.chat.collapse') : t('ai.chat.expand')}
</button>
)}
<button
type="button"
className="inline-flex items-center gap-1 text-[10px] text-muted-foreground/50 hover:text-muted-foreground px-1.5 py-0.5 rounded hover:bg-muted/30"
onClick={() => { void handleCopyCommand(); }}
onKeyDown={(e) => {
if (e.key === 'Enter') e.stopPropagation();
}}
>
<Copy size={10} className="shrink-0" />
{copied ? t('ai.chat.commandCopied') : t('ai.chat.copyCommand')}
</button>
</div>
</div>
<pre
className={cn(
'overflow-auto rounded-md border border-border/25 bg-muted/20 px-2.5 py-2',
'text-[11px] font-mono leading-relaxed text-foreground/80',
'whitespace-pre-wrap break-words [overflow-wrap:anywhere]',
)}
style={{
maxHeight: commandExpanded
? APPROVAL_COMMAND_EXPANDED_MAX_HEIGHT_PX
: APPROVAL_COMMAND_COLLAPSED_MAX_HEIGHT_PX,
}}
onScroll={markReviewing}
onWheel={markReviewing}
>
{reviewCommand}
</pre>
</div>
)}
{args && Object.keys(args).length > 0 && (!showApprovalCommand || showArgsAlongsideCommand) && (
<div className="px-3 py-2">
<div className="text-[10px] font-medium uppercase tracking-wider text-muted-foreground/30 mb-1">Arguments</div>
<pre className="max-h-64 overflow-auto text-[11px] font-mono text-muted-foreground/50 whitespace-pre [overflow-wrap:normal]">
<div className="text-[10px] font-medium uppercase tracking-wider text-muted-foreground/30 mb-1">
{showArgsAlongsideCommand ? t('ai.chat.approvalInvocation') : 'Arguments'}
</div>
{/*
Args-only approvals (Codex file-change/permissions, write tools with
JSON args) have no command pre — wheel/trackpad scroll must re-arm
idle the same way the command overflow block does.
*/}
<pre
className="max-h-64 overflow-auto text-[11px] font-mono text-muted-foreground/50 whitespace-pre [overflow-wrap:normal]"
onScroll={isPendingApproval ? markReviewing : undefined}
onWheel={isPendingApproval ? markReviewing : undefined}
>
{JSON.stringify(args, null, 2)}
</pre>
</div>
+142
View File
@@ -0,0 +1,142 @@
/**
* Floating jump list for long AI chat sessions (user-turn TOC).
*/
import { ListTree, X } from 'lucide-react';
import React, { useCallback, useEffect, useRef, useState } from 'react';
import { useStickToBottomContext } from 'use-stick-to-bottom';
import { useI18n } from '../../application/i18n/I18nProvider';
import type { ChatJumpEntry } from '../../domain/chatJumpNav';
import { cn } from '../../lib/utils';
export interface ChatJumpNavProps {
entries: ChatJumpEntry[];
activeMessageId: string | null;
/** When true, ignore transient isAtBottom flips from streaming resize. */
isStreaming?: boolean;
onSelect: (messageId: string) => void;
/** Fired after the user leaves the jump target and returns to the bottom. */
onReleasePin?: () => void;
}
const ChatJumpNav: React.FC<ChatJumpNavProps> = ({
entries,
activeMessageId,
isStreaming = false,
onSelect,
onReleasePin,
}) => {
const { t } = useI18n();
const { stopScroll, isAtBottom } = useStickToBottomContext();
const [open, setOpen] = useState(false);
const rootRef = useRef<HTMLDivElement>(null);
// Only release after the viewport has left the bottom; avoids clearing a pin
// on the same tick as select while isAtBottom is still true.
const leftBottomWhilePinnedRef = useRef(false);
useEffect(() => {
if (!open) return;
const onPointerDown = (event: PointerEvent) => {
const target = event.target as Node | null;
if (target && rootRef.current && !rootRef.current.contains(target)) {
setOpen(false);
}
};
document.addEventListener('pointerdown', onPointerDown);
return () => document.removeEventListener('pointerdown', onPointerDown);
}, [open]);
useEffect(() => {
if (!activeMessageId) {
leftBottomWhilePinnedRef.current = false;
return;
}
if (!isAtBottom) {
leftBottomWhilePinnedRef.current = true;
return;
}
if (leftBottomWhilePinnedRef.current) {
// Streaming content growth / smooth resize can flip isAtBottom without the
// user intending to leave the jump target; keep the pin until streaming ends
// or they explicitly scroll to bottom via the scroll button.
if (isStreaming) return;
leftBottomWhilePinnedRef.current = false;
onReleasePin?.();
return;
}
// Jump target was already in the bottom window, so the viewport never left
// isAtBottom. Clear the pin after scrollIntoView has had a chance to run.
if (isStreaming) return;
const timer = window.setTimeout(() => {
onReleasePin?.();
}, 100);
return () => window.clearTimeout(timer);
}, [activeMessageId, isAtBottom, isStreaming, onReleasePin]);
const handleSelect = useCallback((messageId: string) => {
stopScroll();
onSelect(messageId);
setOpen(false);
}, [onSelect, stopScroll]);
if (entries.length === 0) return null;
return (
<div ref={rootRef} className="absolute top-3 right-3 z-20 flex flex-col items-end gap-1.5">
<button
type="button"
className={cn(
'h-7 w-7 rounded-full border border-border/40 bg-background/90 backdrop-blur-sm',
'flex items-center justify-center shadow-sm',
'text-muted-foreground hover:text-foreground hover:bg-muted transition-colors cursor-pointer',
open && 'text-foreground bg-muted',
)}
aria-label={t('ai.chat.jumpNav')}
aria-expanded={open}
title={t('ai.chat.jumpNav')}
onClick={() => setOpen((value) => !value)}
>
{open ? <X size={14} /> : <ListTree size={14} />}
</button>
{open && (
<div
className={cn(
'w-[min(220px,calc(100vw-2rem))] max-h-[min(320px,50vh)] overflow-y-auto',
'rounded-md border border-border/50 bg-background/95 backdrop-blur-sm shadow-md',
'py-1',
)}
role="listbox"
aria-label={t('ai.chat.jumpNav')}
>
{entries.map((entry) => {
const selected = entry.messageId === activeMessageId;
return (
<button
key={entry.messageId}
type="button"
role="option"
aria-selected={selected}
className={cn(
'flex w-full items-start gap-2 px-2.5 py-1.5 text-left text-[12px] leading-snug',
'hover:bg-muted/70 transition-colors cursor-pointer',
selected
? 'bg-muted text-foreground'
: 'text-foreground/80',
)}
onClick={() => handleSelect(entry.messageId)}
>
<span className="shrink-0 tabular-nums text-muted-foreground/70 w-4 text-right">
{entry.index}
</span>
<span className="min-w-0 flex-1 truncate">{entry.label}</span>
</button>
);
})}
</div>
)}
</div>
);
};
export default ChatJumpNav;
+57
View File
@@ -132,6 +132,63 @@ test("ChatMessageList only renders the recent message batch by default", () => {
assert.match(markup, /message-59/);
});
test("ChatMessageList exposes jump navigation once there are enough user turns", () => {
const messages: ChatMessage[] = [
{ id: "u1", role: "user", content: "first turn", timestamp: 1 },
{ id: "a1", role: "assistant", content: "ok", timestamp: 2 },
{ id: "u2", role: "user", content: "second turn", timestamp: 3 },
{ id: "a2", role: "assistant", content: "ok", timestamp: 4 },
{ id: "u3", role: "user", content: "third turn", timestamp: 5 },
{ id: "a3", role: "assistant", content: "ok", timestamp: 6 },
];
const markup = renderToStaticMarkup(
React.createElement(
I18nProvider,
{ locale: "en" },
React.createElement(
TooltipProvider,
null,
React.createElement(ChatMessageList, { messages }),
),
),
);
assert.match(markup, /aria-label="Jump to message"/);
assert.match(markup, /id="ai-chat-msg-u1"/);
assert.match(markup, /id="ai-chat-msg-u3"/);
});
test("jump pin release does not reset the loaded message tail", () => {
const source = readFileSync(new URL("./ChatMessageList.tsx", import.meta.url), "utf8");
const releaseHandler = source.match(
/const handleReleaseJumpPin = useCallback\(\(\) => \{[\s\S]*?\}, \[\]\);/,
)?.[0] ?? "";
assert.match(releaseHandler, /setActiveJumpMessageId\(null\)/);
assert.match(releaseHandler, /setPendingJumpMessageId\(null\)/);
assert.doesNotMatch(releaseHandler, /setRenderedTailCount/);
});
test("load earlier advances from the effective pinned tail", () => {
const source = readFileSync(new URL("./ChatMessageList.tsx", import.meta.url), "utf8");
assert.match(
source,
/setRenderedTailCount\(\(count\) =>\s*Math\.max\(count, effectiveTailCount\) \+ MESSAGE_RENDER_STEP\)/,
);
});
test("jump pin ignores streaming isAtBottom flips and releases on scroll button", () => {
const jumpSource = readFileSync(new URL("./ChatJumpNav.tsx", import.meta.url), "utf8");
const listSource = readFileSync(new URL("./ChatMessageList.tsx", import.meta.url), "utf8");
assert.match(jumpSource, /isStreaming\?: boolean/);
assert.match(jumpSource, /if \(isStreaming\) return;/);
assert.match(jumpSource, /window\.setTimeout/);
assert.match(listSource, /isStreaming=\{!!isStreaming\}/);
assert.match(listSource, /<ConversationScrollButton onClick=\{handleReleaseJumpPin\} \/>/);
});
test("ChatMessageList renders Codex activities and actual usage", () => {
const messages: ChatMessage[] = [{
id: "assistant-activity",
+67 -5
View File
@@ -73,6 +73,12 @@ import {
getAIPanelProfilerProps,
isAIPanelDiagnosticPartHidden,
} from './aiPanelDiagnostics';
import {
buildChatJumpEntries,
chatMessageDomId,
resolveTailCountForJumpTarget,
} from '../../domain/chatJumpNav';
import ChatJumpNav from './ChatJumpNav';
interface ChatMessageListProps {
messages: ChatMessage[];
@@ -354,9 +360,13 @@ const ChatMessageList: React.FC<ChatMessageListProps> = ({
const hideMarkdown = isAIPanelDiagnosticPartHidden('markdown', hiddenParts);
const hideToolCalls = isAIPanelDiagnosticPartHidden('toolcalls', hiddenParts);
const [renderedTailCount, setRenderedTailCount] = useState(MESSAGE_RENDER_BATCH);
const [activeJumpMessageId, setActiveJumpMessageId] = useState<string | null>(null);
const [pendingJumpMessageId, setPendingJumpMessageId] = useState<string | null>(null);
useEffect(() => {
setRenderedTailCount(MESSAGE_RENDER_BATCH);
setActiveJumpMessageId(null);
setPendingJumpMessageId(null);
}, [activeSessionId]);
const visibleMessages = useMemo(
@@ -364,11 +374,47 @@ const ChatMessageList: React.FC<ChatMessageListProps> = ({
[messages],
);
const hiddenMessageCount = Math.max(0, visibleMessages.length - renderedTailCount);
// While a jump target is active, re-resolve the tail against the current list
// so streaming appends cannot slide the window past the selected message.
const effectiveTailCount = activeJumpMessageId
? resolveTailCountForJumpTarget(visibleMessages, activeJumpMessageId, renderedTailCount)
: renderedTailCount;
const hiddenMessageCount = Math.max(0, visibleMessages.length - effectiveTailCount);
const displayedMessages = hiddenMessageCount > 0
? visibleMessages.slice(-renderedTailCount)
? visibleMessages.slice(-effectiveTailCount)
: visibleMessages;
const jumpEntries = useMemo(
() => buildChatJumpEntries(visibleMessages, {
emptyLabel: t('ai.chat.jumpUntitled'),
}),
[t, visibleMessages],
);
const handleJumpToMessage = useCallback((messageId: string) => {
setActiveJumpMessageId(messageId);
// Persist the expanded window so releasing the pin (or a spurious isAtBottom
// flip) cannot unmount the jump target. Load-earlier progress is preserved
// because we never reset renderedTailCount on pin release.
setRenderedTailCount((count) =>
resolveTailCountForJumpTarget(visibleMessages, messageId, count));
setPendingJumpMessageId(messageId);
}, [visibleMessages]);
const handleReleaseJumpPin = useCallback(() => {
setActiveJumpMessageId(null);
setPendingJumpMessageId(null);
}, []);
useEffect(() => {
if (!pendingJumpMessageId) return;
const target = document.getElementById(chatMessageDomId(pendingJumpMessageId));
if (!target) return;
target.scrollIntoView({ behavior: 'smooth', block: 'start' });
setPendingJumpMessageId(null);
}, [displayedMessages, pendingJumpMessageId]);
const resolvedToolCallIds = new Set(
displayedMessages
.filter((m) => m.role === 'tool')
@@ -432,6 +478,7 @@ const ChatMessageList: React.FC<ChatMessageListProps> = ({
args={request.args}
isInterrupted={false}
approvalStatus="pending"
approvalId={approvalId}
onApproveOnce={() => handleApproveOnce(approvalId)}
onAlwaysAllow={request.allowSession === false
? undefined
@@ -463,6 +510,7 @@ const ChatMessageList: React.FC<ChatMessageListProps> = ({
isInterrupted={options.historical ? !isPending : undefined}
isLoading={options.historical ? undefined : Boolean(options.isToolRunning && !isPending)}
approvalStatus={approvalStatus}
approvalId={isPending ? toolCall.id : undefined}
onApproveOnce={() => handleApproveOnce(toolCall.id)}
onAlwaysAllow={() => handleAlwaysAllow(toolCall.id, pendingRequest ?? {
toolCallId: toolCall.id,
@@ -483,7 +531,8 @@ const ChatMessageList: React.FC<ChatMessageListProps> = ({
{hiddenMessageCount > 0 && (
<button
type="button"
onClick={() => setRenderedTailCount((count) => count + MESSAGE_RENDER_STEP)}
onClick={() => setRenderedTailCount((count) =>
Math.max(count, effectiveTailCount) + MESSAGE_RENDER_STEP)}
className="w-full py-2 text-center text-[12px] text-muted-foreground/50 hover:text-muted-foreground transition-colors cursor-pointer"
>
{t('ai.chat.loadEarlierMessages').replace('{n}', String(hiddenMessageCount))}
@@ -591,7 +640,11 @@ const ChatMessageList: React.FC<ChatMessageListProps> = ({
const isThisStreaming = isStreaming && isLastAssistant;
return (
<Message key={message.id} from={message.role}>
<Message
key={message.id}
id={chatMessageDomId(message.id)}
from={message.role}
>
<MessageContent from={message.role}>
{/* Thinking block */}
{!isUser && message.thinking && (
@@ -762,6 +815,7 @@ const ChatMessageList: React.FC<ChatMessageListProps> = ({
isLoading={false}
isInterrupted={false}
approvalStatus={'pending'}
approvalId={id}
onApproveOnce={() => handleApproveOnce(id)}
onAlwaysAllow={() => handleAlwaysAllow(id, req)}
onReject={() => handleReject(id)}
@@ -781,6 +835,7 @@ const ChatMessageList: React.FC<ChatMessageListProps> = ({
isLoading={false}
isInterrupted={false}
approvalStatus="pending"
approvalId={approvalId}
onApproveOnce={() => handleApproveOnce(approvalId)}
onAlwaysAllow={request.allowSession === false
? undefined
@@ -833,7 +888,14 @@ const ChatMessageList: React.FC<ChatMessageListProps> = ({
</div>
)}
</ConversationContent>
<ConversationScrollButton />
<ChatJumpNav
entries={jumpEntries}
activeMessageId={activeJumpMessageId}
isStreaming={!!isStreaming}
onSelect={handleJumpToMessage}
onReleasePin={handleReleaseJumpPin}
/>
<ConversationScrollButton onClick={handleReleaseJumpPin} />
</Conversation>
{/* Image preview lightbox */}
@@ -100,6 +100,7 @@ export const ExternalMcpApprovalsHost: React.FC = () => {
isLoading={false}
isInterrupted={false}
approvalStatus="pending"
approvalId={id}
onApproveOnce={() => handleApproveOnce(id)}
onAlwaysAllow={() => handleAlwaysAllow(id, req)}
onReject={() => handleReject(id)}
+4 -2
View File
@@ -2,6 +2,7 @@ import React, { type Dispatch, type MutableRefObject, type SetStateAction } from
import { AlertTriangle, Cloud, Database, Download, History, Key, Loader2, ShieldCheck, Trash2 } from 'lucide-react';
import type { CloudProvider, ConflictResolution, SyncPayload, SyncResult, WebDAVAuthType } from '../../domain/sync';
import type { ShrinkFinding } from '../../domain/syncGuards';
import { stripSyncPayloadEncryptedCredentials } from '../../domain/credentials';
import type { useCloudSync } from '../../application/state/useCloudSync';
import { cn } from '../../lib/utils';
import { Button } from '../ui/button';
@@ -901,9 +902,10 @@ export const CloudSyncDialogs: React.FC<CloudSyncDialogsProps> = ({
// re-deleting the remote additions we just merged in.
for (const result of results.values()) {
if (result.mergedPayload && !result.mergedPayloadApplied) {
await Promise.resolve(onApplyPayload(result.mergedPayload));
const portableMerged = stripSyncPayloadEncryptedCredentials(result.mergedPayload);
await Promise.resolve(onApplyPayload(portableMerged));
if (result.remoteFile) {
await sync.commitRemoteInspection(result.provider, result.remoteFile, result.mergedPayload, {
await sync.commitRemoteInspection(result.provider, result.remoteFile, portableMerged, {
recordDownload: true,
});
}
@@ -40,6 +40,8 @@ export default function SettingsFileAssociationsTab() {
sftpAutoSync, setSftpAutoSync,
sftpShowHiddenFiles, setSftpShowHiddenFiles,
sftpUseCompressedUpload, setSftpUseCompressedUpload,
sftpSkipUnchanged, setSftpSkipUnchanged,
sftpAutoOpenSidebar, setSftpAutoOpenSidebar,
sftpFollowTerminalCwd, setSftpFollowTerminalCwd,
sftpDefaultViewMode, setSftpDefaultViewMode,
@@ -209,6 +211,12 @@ export default function SettingsFileAssociationsTab() {
</span>
</div>
</SettingRow>
<SettingRow
label={t('settings.sftp.skipUnchanged.enable')}
description={t('settings.sftp.skipUnchanged.enableDesc')}
>
<Toggle checked={sftpSkipUnchanged} onChange={setSftpSkipUnchanged} />
</SettingRow>
<SettingRow
label={t('settings.ssh.transportIdleTtl')}
description={t('settings.ssh.transportIdleTtl.desc')}
@@ -175,6 +175,8 @@ const SettingsSystemTab: React.FC<SettingsSystemTabProps> = ({
const [crashLogClearResult, setCrashLogClearResult] = useState<{ deletedCount: number } | null>(null);
const [sshDebugLogInfo, setSshDebugLogInfo] = useState<SshDebugLogInfo | null>(null);
const [isLoadingSshDebugLogInfo, setIsLoadingSshDebugLogInfo] = useState(false);
const [isClearingSessionLogs, setIsClearingSessionLogs] = useState(false);
const [sessionLogsClearResult, setSessionLogsClearResult] = useState<{ deletedCount: number; failedCount: number } | null>(null);
const [appVersion, setAppVersion] = useState('');
@@ -355,6 +357,25 @@ const SettingsSystemTab: React.FC<SettingsSystemTabProps> = ({
}
}, [sessionLogsDir]);
const handleClearSessionLogs = useCallback(async () => {
const bridge = netcattyBridge.get();
if (!sessionLogsDir || !bridge?.clearSessionLogsDir) return;
if (!window.confirm(t("settings.sessionLogs.clearConfirm"))) return;
setIsClearingSessionLogs(true);
setSessionLogsClearResult(null);
try {
const result = await bridge.clearSessionLogsDir(sessionLogsDir);
if (result.success) {
setSessionLogsClearResult({ deletedCount: result.deletedCount, failedCount: result.failedCount });
}
} catch (err) {
console.error("[SettingsSystemTab] Failed to clear session logs:", err);
} finally {
setIsClearingSessionLogs(false);
}
}, [sessionLogsDir, t]);
const handleOpenSshDebugLogDir = useCallback(async () => {
const bridge = netcattyBridge.get();
if (!bridge?.openSshDebugLogDir) return;
@@ -984,6 +1005,34 @@ const SettingsSystemTab: React.FC<SettingsSystemTabProps> = ({
onChange={setSessionLogsTimestampsEnabled}
/>
</SettingRow>
{/* Clear All Logs */}
<div className="space-y-2 pt-2 border-t border-border/60">
<div className="flex items-center justify-between gap-4">
<div>
<p className="text-sm font-medium">{t("settings.sessionLogs.clearAll")}</p>
<p className="text-xs text-muted-foreground">{t("settings.sessionLogs.clearAllDesc")}</p>
</div>
<Button
variant="outline"
size="sm"
onClick={handleClearSessionLogs}
disabled={isClearingSessionLogs || !sessionLogsDir}
className="gap-1.5 shrink-0 text-destructive hover:text-destructive hover:bg-destructive/10"
>
<Trash2 size={14} />
{isClearingSessionLogs ? t("settings.system.clearing") : t("settings.sessionLogs.clearAll")}
</Button>
</div>
{sessionLogsClearResult && (
<p className="text-sm text-muted-foreground">
{t("settings.system.clearResult", {
deleted: sessionLogsClearResult.deletedCount,
failed: sessionLogsClearResult.failedCount,
})}
</p>
)}
</div>
</SettingCard>
<p className="text-xs text-muted-foreground">
+108 -3
View File
@@ -1,5 +1,8 @@
import test from "node:test";
import assert from "node:assert/strict";
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import React from "react";
import { renderToStaticMarkup } from "react-dom/server";
@@ -18,6 +21,11 @@ import {
import type { SftpPane } from "../../application/state/sftp/types.ts";
import { TooltipProvider } from "../ui/tooltip.tsx";
const toolbarSource = fs.readFileSync(
path.join(path.dirname(fileURLToPath(import.meta.url)), "SftpPaneToolbar.tsx"),
"utf8",
);
test("single SFTP view-mode button toggles to the other mode", () => {
assert.equal(getNextSftpViewMode("list"), "tree");
assert.equal(getNextSftpViewMode("tree"), "list");
@@ -36,7 +44,7 @@ test("narrow SFTP toolbar spills non-pinned show items into overflow without cha
assert.ok(!narrow.inlineIds.includes("newFolder"));
assert.ok(narrow.overflowIds.includes("newFolder"));
assert.ok(narrow.overflowIds.includes("encoding"));
// hide is already excluded from shown/collapsed by partition — not reintroduced here
// hide is already excluded from shown/collapsed by partition - not reintroduced here
assert.ok(!narrow.inlineIds.includes("encoding"));
});
@@ -132,7 +140,6 @@ test("toolbar renders one view-mode toggle instead of separate list and tree but
handlePathKeyDown: () => {},
handlePathDoubleClick: () => {},
handlePathSubmit: () => {},
startTransition: (callback: () => void) => callback(),
getNextUntitledName: () => "untitled",
setNewFileName: () => {},
setFileNameError: () => {},
@@ -211,7 +218,6 @@ test("toolbar exposes copy-current-path action for the active directory", () =>
handlePathKeyDown: () => {},
handlePathDoubleClick: () => {},
handlePathSubmit: () => {},
startTransition: (callback: () => void) => callback(),
getNextUntitledName: () => "untitled",
setNewFileName: () => {},
setFileNameError: () => {},
@@ -277,6 +283,105 @@ test("copy-current-path action reports clipboard failures", async () => {
assert.equal(errorMessage, "Could not copy current path");
});
test("SFTP filter input guards CJK IME composition instead of deferring controlled value writes", () => {
assert.match(toolbarSource, /onCompositionStart=\{/);
assert.match(toolbarSource, /onCompositionEnd=\{/);
assert.match(toolbarSource, /shouldCommitImeControlledChange/);
assert.match(toolbarSource, /value=\{filterDraft\}/);
assert.doesNotMatch(
toolbarSource,
/onChange=\{\(e\) => startTransition\(\(\) => onSetFilter\(e\.target\.value\)\)\}/,
);
});
test("SFTP filter honors an external filter change over a stale draft when composition ends", () => {
// If navigation clears pane.filter mid-composition, compositionEnd must adopt the
// external value instead of committing the stale draft, preserving the invariant
// that different-directory navigation clears the filter.
assert.match(toolbarSource, /filterAtComposeStartRef\.current = pane\.filter;/);
assert.match(toolbarSource, /filterPathAtComposeStartRef\.current = pane\.connection\?\.currentPath/);
assert.match(
toolbarSource,
/pane\.filter !== filterAtComposeStartRef\.current\s*\|\|\s*pathChangedDuringCompose\s*\|\|\s*filterCompositionSupersededRef\.current/,
);
assert.match(toolbarSource, /filterCompositionSupersededRef\.current = true;/);
assert.match(toolbarSource, /setFilterDraft\(pane\.filter\);/);
});
test("SFTP filter adopts external navigation-cleared filters during an open IME composition", () => {
// Sync path must pass valueAtComposeStart while composing so pane.filter="" from
// different-directory navigation supersedes the draft mid-composition.
assert.match(toolbarSource, /valueAtComposeStart:\s*composing/);
assert.match(toolbarSource, /filterAtComposeStartRef\.current/);
assert.match(toolbarSource, /pathChangedDuringCompose/);
assert.match(
toolbarSource,
/filterCompositionSupersededRef\.current = true;/,
);
});
test("SFTP filter supersedes IME draft on path navigation even when committed filter was already empty", () => {
// When the filter was already "" at compose start, navigation sets filter to ""
// again - pane.filter does not change - so path-at-compose-start must drive
// supersede; otherwise compositionend commits the stale draft.
assert.match(toolbarSource, /filterPathAtComposeStartRef/);
assert.match(
toolbarSource,
/currentPath !== filterPathAtComposeStartRef\.current/,
);
assert.match(
toolbarSource,
/\[pane\.filter, pane\.connection\?\.currentPath\]/,
);
// Hide-filter reset must still clear composing + supersede and resync draft.
assert.match(
toolbarSource,
/if \(!showFilterBar\) \{\s*filterComposingRef\.current = false;\s*filterCompositionSupersededRef\.current = false;\s*setFilterDraft\(pane\.filter\);/,
);
});
test("SFTP filter suppresses the post-composition onChange after external supersede", () => {
// Browsers may fire onChange with composing=false after compositionend; that event
// must not re-commit the stale composed draft over a navigation-cleared filter.
assert.match(toolbarSource, /resolveSupersededImeInputEvent/);
assert.match(toolbarSource, /superseded\.ignoreEventValue/);
assert.match(toolbarSource, /compositionExternallySuperseded:\s*filterCompositionSupersededRef\.current/);
});
test("SFTP filter clears the supersede latch if no post-composition change arrives", () => {
// Some IME/browser paths never fire the post-compositionend onChange that would
// clear filterCompositionSupersededRef; without a deferred clear, the next
// ordinary keystroke is treated as the stale supersede follow-up and dropped.
// Guard the clear so an intervening onChange clear or new compositionstart wins.
assert.match(
toolbarSource,
/filterCompositionSupersededRef\.current = true;\s*setFilterDraft\(pane\.filter\);\s*window\.setTimeout\(\(\) => \{/,
);
assert.match(
toolbarSource,
/if \(\s*filterCompositionSupersededRef\.current\s*&&\s*!filterComposingRef\.current\s*\) \{\s*filterCompositionSupersededRef\.current = false;/,
);
});
test("SFTP filter commit path clears the IME composing guard so clears/commits can't leave it stuck", () => {
// commitFilterValue backs composition end, Escape, inline clear and close; it must
// drop the guard so a programmatic clear mid-composition isn't blocked or undone.
assert.match(
toolbarSource,
/const commitFilterValue = useCallback\(\(value: string\) => \{[\s\S]*?filterComposingRef\.current = false;[\s\S]*?filterCompositionSupersededRef\.current = false;/,
);
});
test("SFTP filter clears the IME composing guard and resyncs the draft when the filter bar closes", () => {
// If the input unmounts mid-composition, compositionend never fires; the guard
// must be reset (or every later onSetFilter is blocked) and the draft resynced to
// the committed filter so a reopened bar never shows stale, uncommitted text.
assert.match(
toolbarSource,
/if \(!showFilterBar\) \{\s*filterComposingRef\.current = false;\s*filterCompositionSupersededRef\.current = false;\s*setFilterDraft\(pane\.filter\);/,
);
});
test("toolbar display path keeps the previous confirmed path while loading the same connection", () => {
assert.equal(
getNextSftpToolbarDisplayPath({
+145 -10
View File
@@ -18,6 +18,11 @@ import type { SftpFilenameEncoding } from "../../types";
import type { SftpPane } from "../../application/state/sftp/types";
import type { SftpBookmark } from "../../domain/models";
import { isWindowsPath } from "../../application/state/sftp/utils";
import {
resolveSupersededImeInputEvent,
shouldAdoptExternalImeControlledValue,
shouldCommitImeControlledChange,
} from "../../domain/imeControlledInput";
import { toast } from "../ui/toast";
export const SFTP_TOOLBAR_ITEM_IDS = [
@@ -193,7 +198,6 @@ interface SftpPaneToolbarProps {
handlePathKeyDown: (e: React.KeyboardEvent) => void;
handlePathDoubleClick: () => void;
handlePathSubmit: (pathOverride?: string) => void;
startTransition: React.TransitionStartFunction;
getNextUntitledName: (existingNames: string[]) => string;
setNewFileName: (value: string) => void;
setFileNameError: (value: string | null) => void;
@@ -303,7 +307,6 @@ export const SftpPaneToolbar: React.FC<SftpPaneToolbarProps> = React.memo(({
handlePathKeyDown,
handlePathDoubleClick,
handlePathSubmit,
startTransition,
getNextUntitledName,
setNewFileName,
setFileNameError,
@@ -327,6 +330,16 @@ export const SftpPaneToolbar: React.FC<SftpPaneToolbarProps> = React.memo(({
onListDrives,
}) => {
const [displayPath, setDisplayPath] = useState(pane.connection?.currentPath ?? "");
const [filterDraft, setFilterDraft] = useState(pane.filter);
const filterComposingRef = useRef(false);
const filterAtComposeStartRef = useRef(pane.filter);
// Directory at compositionstart. Navigation that leaves the filter already ""
// does not change pane.filter, so path is the signal that must supersede the
// draft (compositionend would otherwise commit stale IME text).
const filterPathAtComposeStartRef = useRef(pane.connection?.currentPath ?? "");
// Set when pane.filter / path changes externally during (or just after)
// composition so the post-compositionend onChange cannot re-commit a stale draft.
const filterCompositionSupersededRef = useRef(false);
const prevDisplayConnectionIdRef = useRef(pane.connection?.id);
const toolbarLayout = useToolbarItemLayout(
STORAGE_KEY_SFTP_TOOLBAR_LAYOUT,
@@ -347,6 +360,60 @@ export const SftpPaneToolbar: React.FC<SftpPaneToolbarProps> = React.memo(({
);
}, [pane.connection?.currentPath, pane.connection?.id, pane.loading]);
useEffect(() => {
setFilterDraft((draftValue) => {
const composing = filterComposingRef.current;
const currentPath = pane.connection?.currentPath ?? "";
const pathChangedDuringCompose = composing
&& currentPath !== filterPathAtComposeStartRef.current;
const shouldAdopt = shouldAdoptExternalImeControlledValue({
isComposingSession: composing,
draftValue,
externalValue: pane.filter,
// Allow navigation-cleared filters to supersede an open IME composition so
// compositionend / post-composition onChange cannot resurrect the draft.
valueAtComposeStart: composing
? filterAtComposeStartRef.current
: undefined,
}) || pathChangedDuringCompose;
if (
shouldAdopt
&& composing
&& (
pane.filter !== filterAtComposeStartRef.current
|| pathChangedDuringCompose
)
) {
filterCompositionSupersededRef.current = true;
}
return shouldAdopt ? pane.filter : draftValue;
});
}, [pane.filter, pane.connection?.currentPath]);
// The filter input only mounts while the bar is open, so a composition that is
// still active when the bar closes never fires `compositionend`. Clear the guard
// (otherwise a stuck `true` blocks every later commit) and resync the draft to the
// committed filter so a reopened bar never shows stale, uncommitted text.
useEffect(() => {
if (!showFilterBar) {
filterComposingRef.current = false;
filterCompositionSupersededRef.current = false;
setFilterDraft(pane.filter);
}
}, [showFilterBar, pane.filter]);
const commitFilterValue = useCallback((value: string) => {
// Committing/clearing finalizes any IME session, so drop the guard here. This
// covers every commit path (composition end, Escape, inline clear, close) so a
// stale composing flag can't block later commits or resurrect a pre-clear value.
filterComposingRef.current = false;
filterCompositionSupersededRef.current = false;
setFilterDraft(value);
// Keep parent filter in lockstep with the input. Deferred updates against a
// controlled value fight CJK IME composition and can echo/drop candidates.
onSetFilter(value);
}, [onSetFilter]);
const handleNewFolder = useCallback(() => {
setNewFolderName("");
setShowNewFolderDialog(true);
@@ -984,23 +1051,91 @@ export const SftpPaneToolbar: React.FC<SftpPaneToolbarProps> = React.memo(({
/>
<Input
ref={filterInputRef}
value={pane.filter}
onChange={(e) => startTransition(() => onSetFilter(e.target.value))}
value={filterDraft}
onChange={(e) => {
const superseded = resolveSupersededImeInputEvent({
compositionExternallySuperseded: filterCompositionSupersededRef.current,
isComposingSession: filterComposingRef.current,
nativeEventIsComposing: e.nativeEvent.isComposing,
});
if (superseded.ignoreEventValue) {
// Keep draft on the external filter (e.g. navigation clear). Do not
// apply the stale composed text from a post-compositionend change.
if (superseded.clearSupersedeLatch) {
filterCompositionSupersededRef.current = false;
}
setFilterDraft(pane.filter);
return;
}
const next = e.target.value;
setFilterDraft(next);
if (
shouldCommitImeControlledChange({
isComposingSession: filterComposingRef.current,
nativeEventIsComposing: e.nativeEvent.isComposing,
compositionExternallySuperseded: filterCompositionSupersededRef.current,
})
) {
onSetFilter(next);
}
}}
onCompositionStart={() => {
filterComposingRef.current = true;
filterCompositionSupersededRef.current = false;
filterAtComposeStartRef.current = pane.filter;
filterPathAtComposeStartRef.current = pane.connection?.currentPath ?? "";
}}
onCompositionEnd={(e) => {
filterComposingRef.current = false;
// We never self-commit while composing, so any change to pane.filter
// or directory during the session is external (e.g. follow-CWD
// navigation). Honor it and drop the stale composed draft instead of
// letting the commit overwrite the navigation-cleared filter. Path
// matters when the committed filter was already "" before compose -
// navigation sets filter to "" again, so filter-only checks miss it.
// Keep the supersede latch armed so a browser post-compositionend
// onChange cannot reassert the composed text with composing=false.
// Some IME paths never fire that follow-up change; disarm on the
// next macrotask so ordinary keystrokes commit. Guard the clear so
// an intervening post-composition onChange or a new compositionstart
// is not clobbered.
const pathChangedDuringCompose =
(pane.connection?.currentPath ?? "") !== filterPathAtComposeStartRef.current;
if (
pane.filter !== filterAtComposeStartRef.current
|| pathChangedDuringCompose
|| filterCompositionSupersededRef.current
) {
filterCompositionSupersededRef.current = true;
setFilterDraft(pane.filter);
window.setTimeout(() => {
if (
filterCompositionSupersededRef.current
&& !filterComposingRef.current
) {
filterCompositionSupersededRef.current = false;
}
}, 0);
return;
}
commitFilterValue(e.currentTarget.value);
}}
placeholder={t("sftp.filter.placeholder")}
className="h-6 w-full pl-7 pr-7 text-xs bg-background"
onKeyDown={(e) => {
if (e.key === "Escape") {
if (pane.filter) {
startTransition(() => onSetFilter(""));
if (filterComposingRef.current || e.nativeEvent.isComposing) return;
if (filterDraft || pane.filter) {
commitFilterValue("");
} else {
setShowFilterBar(false);
}
}
}}
/>
{pane.filter && (
{filterDraft && (
<button
onClick={() => startTransition(() => onSetFilter(""))}
onClick={() => commitFilterValue("")}
className="absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
>
<X size={12} />
@@ -1014,7 +1149,7 @@ export const SftpPaneToolbar: React.FC<SftpPaneToolbarProps> = React.memo(({
size="icon"
className="h-6 w-6 shrink-0"
onClick={() => {
startTransition(() => onSetFilter(""));
commitFilterValue("");
setShowFilterBar(false);
}}
>
@@ -1102,7 +1237,7 @@ function SftpBookmarkPopoverBody({
);
}
/** Nested bookmark opener inside ⋮ — keeps overflow open until a leaf action. */
/** Nested bookmark opener inside ⋮ - keeps overflow open until a leaf action. */
function SftpOverflowNestedBookmark({
menuItemClass,
bookmarkButtonLabel,
-1
View File
@@ -551,7 +551,6 @@ const SftpPaneViewInner: React.FC<SftpPaneViewProps> = ({
handlePathKeyDown={handlePathKeyDown}
handlePathDoubleClick={handlePathDoubleClick}
handlePathSubmit={handlePathSubmit}
startTransition={startTransition}
getNextUntitledName={getNextUntitledName}
setNewFileName={setNewFileName}
setFileNameError={setFileNameError}
+76 -52
View File
@@ -26,10 +26,17 @@ import { getParentPath } from '../../application/state/sftp/utils';
import { useSftpTransferTask } from '../../application/state/sftpTransferCenterStore';
import { cn } from '../../lib/utils';
import { TransferTask } from '../../types';
import {
buildGlobalTransferProgressDisplay,
isDirectoryParentTask,
} from '../GlobalSftpTransferCenter';
import { Button } from '../ui/button';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '../ui/tooltip';
import { formatSpeed, formatTransferBytes } from './utils';
/** Child rows need room for Pause + Cancel (2×24px icons + gap). */
const CHILD_ACTIONS_COLUMN_PX = 56;
interface SftpTransferItemProps {
task: TransferTask;
isChild?: boolean;
@@ -132,16 +139,20 @@ const SftpTransferItemInner: React.FC<SftpTransferItemProps> = ({
// Optimistic spinner from click until store status moves off paused/interrupted.
const [resumeClicked, setResumeClicked] = useState(false);
// Align with global transfer center: directory parents default to file-count
// progress unless explicitly compressed/byte mode.
const progressMode = task.progressMode
?? (task.isDirectory && !task.parentTaskId ? 'files' : 'bytes');
const isDirParent = task.isDirectory && !task.parentTaskId && progressMode === 'files';
const hasKnownTotal = task.totalBytes > 0 || (!isDirParent && !!task.sourceLastModified);
const progress = hasKnownTotal
? Math.min((task.transferredBytes / task.totalBytes) * 100, 100)
: 0;
const isIndeterminate = task.status === 'transferring' && !hasKnownTotal;
// Same progress model as the global transfer center (done · found for folders).
const isDirParent = isDirectoryParentTask(task);
const centerProgress = buildGlobalTransferProgressDisplay(task, t);
const hasKnownTotal = isDirParent
? task.totalBytes > 0 && task.transferredBytes > 0 && task.phase !== 'scanning'
: task.totalBytes > 0 || !!task.sourceLastModified;
const progress = isDirParent
? centerProgress.percent
: hasKnownTotal
? Math.min((task.transferredBytes / task.totalBytes) * 100, 100)
: 0;
const isIndeterminate = isDirParent
? centerProgress.indeterminate && (task.status === 'transferring' || task.status === 'pending' || task.status === 'queued' || task.status === 'pausing')
: task.status === 'transferring' && !hasKnownTotal;
const isActiveTransfer = task.status === 'transferring' || task.status === 'pausing';
// Reconnect / dedicated resume window — keep the action slot as a spinner
// until the first real progress clears reconnectRequired.
@@ -178,13 +189,9 @@ const SftpTransferItemInner: React.FC<SftpTransferItemProps> = ({
? formatTransferBytes(task.totalBytes)
: '';
const fileCountDisplay = isDirParent && (isActiveTransfer || isPausedLike)
? (task.totalBytes > 0
? t('sftp.transfers.filesProgress', { current: task.transferredBytes, total: task.totalBytes })
: t('sftp.transfers.filesCount', { count: task.transferredBytes }))
: isDirParent && task.status === 'completed' && task.totalBytes > 0
? t('sftp.transfers.filesCount', { count: task.totalBytes })
: '';
// Prefer the transfer-center detail string so the panel never lags behind
// "N done · M found" while status is still pending during progressive walks.
const fileCountDisplay = isDirParent ? centerProgress.detail : '';
const speedFormatted = effectiveSpeed > 0 ? formatSpeed(effectiveSpeed) : '';
const targetDirectoryPath = task.isDirectory ? task.targetPath : getParentPath(task.targetPath);
@@ -193,29 +200,42 @@ const SftpTransferItemInner: React.FC<SftpTransferItemProps> = ({
// the backend drained in-flight chunks ("finish current step").
const pausingLabel = t('sftp.transferCenter.status.pausing');
const resumingLabel = t('sftp.transferCenter.status.resuming');
const isLiveScanning = task.phase === 'scanning'
&& (task.status === 'pending' || task.status === 'queued' || task.status === 'transferring');
const progressOverlayText = isResuming
? resumingLabel
: task.status === 'pending'
? t('sftp.task.waiting')
: isLiveScanning
? (fileCountDisplay
? `${t('sftp.transferCenter.phase.scanning')} · ${fileCountDisplay}`
: t('sftp.transferCenter.phase.scanning'))
: task.status === 'pausing'
? pausingLabel
: isIndeterminate
? t('sftp.transfer.preparing')
: isDirParent
? (fileCountDisplay
? `${fileCountDisplay}${hasKnownTotal ? ` • ${Math.round(progress)}%` : ''}`
: hasKnownTotal
? `${Math.round(progress)}%`
: '...')
: bytesDisplay
? `${bytesDisplay}${hasKnownTotal ? ` • ${Math.round(progress)}%` : ''}`
: hasKnownTotal
? `${Math.round(progress)}%`
: '...';
: isDirParent
? (fileCountDisplay
|| (task.status === 'pending' || task.status === 'queued'
? t('sftp.task.waiting')
: isIndeterminate
? '...'
: `${Math.round(progress)}%`))
: task.status === 'pending'
? t('sftp.task.waiting')
: isIndeterminate
? t('sftp.transfer.preparing')
: bytesDisplay
? `${bytesDisplay}${hasKnownTotal ? ` • ${Math.round(progress)}%` : ''}`
: hasKnownTotal
? `${Math.round(progress)}%`
: '...';
const progressBarWidth = task.status === 'pending' || (task.status === 'transferring' && !hasKnownTotal) || isIndeterminate
? (task.status === 'pending' || !hasKnownTotal ? '100%' : `${progress}%`)
: `${progress}%`;
const progressBarWidth = isDirParent
? (centerProgress.indeterminate || isLiveScanning
? '100%'
: `${progress}%`)
: task.status === 'pending'
|| (task.status === 'transferring' && !hasKnownTotal)
|| isIndeterminate
? (task.status === 'pending' || !hasKnownTotal ? '100%' : `${progress}%`)
: `${progress}%`;
const statusIcon = isResuming
? <Loader2 size={12} className="animate-spin text-primary" />
@@ -269,7 +289,12 @@ const SftpTransferItemInner: React.FC<SftpTransferItemProps> = ({
</div>
);
const progressSummaryText = isResuming || isActiveTransfer || isPausedLike || task.status === 'pending'
const progressSummaryText = isResuming
|| isActiveTransfer
|| isPausedLike
|| task.status === 'pending'
|| task.status === 'queued'
|| (isDirParent && !!fileCountDisplay)
? [speedFormatted, progressOverlayText].filter(Boolean).join(' • ')
: '';
const showTransferSizeCalculation = task.status === 'transferring' && !hasKnownTotal && !isDirParent;
@@ -437,7 +462,9 @@ const SftpTransferItemInner: React.FC<SftpTransferItemProps> = ({
data-transfer-status={task.status}
data-transfer-direction={task.direction}
style={{
gridTemplateColumns: `24px ${childNameColumnWidth}px 10px minmax(0, 1fr) 24px`,
// Last column reserves space for Pause + Cancel so the
// progress bar never paints under the action buttons.
gridTemplateColumns: `24px ${childNameColumnWidth}px 10px minmax(0, 1fr) ${CHILD_ACTIONS_COLUMN_PX}px`,
}}
>
<div className="flex h-full items-center justify-center text-muted-foreground">
@@ -468,10 +495,10 @@ const SftpTransferItemInner: React.FC<SftpTransferItemProps> = ({
</TooltipTrigger>
<TooltipContent side="top">{resizeNameColumnLabel}</TooltipContent>
</Tooltip>
<div className="min-w-0">
<div className="min-w-0 overflow-hidden">
{childProgressBar}
</div>
<div className="flex h-full items-center justify-center">
<div className="flex h-full min-w-0 items-center justify-end gap-0.5 pl-1">
{actionButtons}
</div>
</div>
@@ -551,12 +578,16 @@ const SftpTransferItemInner: React.FC<SftpTransferItemProps> = ({
{toggleChildrenButton}
{progressSummaryText && (
<span className="ml-auto shrink-0 whitespace-nowrap text-[10px] text-muted-foreground font-mono">
<span className="ml-auto min-w-0 max-w-[50%] truncate whitespace-nowrap text-right text-[10px] text-muted-foreground font-mono">
{progressSummaryText}
</span>
)}
{actionButtons}
{/* Keep pause/cancel outside the progress summary so long
"N done · M found" labels never crowd the action buttons. */}
<div className="ml-1 shrink-0">
{actionButtons}
</div>
</div>
{showBelowParentProgress && (
@@ -622,6 +653,9 @@ const arePropsEqual = (
if (prev.fileName !== next.fileName) return false;
if (prev.targetPath !== next.targetPath) return false;
if (prev.totalBytes !== next.totalBytes) return false;
if (prev.transferredBytes !== next.transferredBytes) return false;
if (prev.phase !== next.phase) return false;
if (prev.progressMode !== next.progressMode) return false;
if ((prevProps.canRevealTarget ?? false) !== (nextProps.canRevealTarget ?? false)) return false;
if ((prevProps.canCopyTargetPath ?? false) !== (nextProps.canCopyTargetPath ?? false)) return false;
if ((prevProps.isChild ?? false) !== (nextProps.isChild ?? false)) return false;
@@ -634,20 +668,10 @@ const arePropsEqual = (
if ((prevProps.childListId ?? '') !== (nextProps.childListId ?? '')) return false;
if ((prevProps.resizeHandleTabIndex ?? 0) !== (nextProps.resizeHandleTabIndex ?? 0)) return false;
if (next.status === 'transferring' || next.status === 'pausing') {
if (next.totalBytes <= 0 && prev.transferredBytes !== next.transferredBytes) return false;
const prevProgress = prev.totalBytes > 0 ? (prev.transferredBytes / prev.totalBytes) * 100 : 0;
const nextProgress = next.totalBytes > 0 ? (next.transferredBytes / next.totalBytes) * 100 : 0;
if (Math.abs(nextProgress - prevProgress) >= 0.1) return false;
if (next.status === 'transferring' || next.status === 'pausing' || next.status === 'pending' || next.status === 'queued') {
if (next.speed !== prev.speed) return false;
}
if (next.status === 'pending') {
return true;
}
return true;
};
@@ -463,7 +463,7 @@ export const useSftpKeyboardShortcuts = ({
const hasClipboardItems = (dataTransfer?.items?.length ?? 0) > 0;
// webkitGetAsEntry must be invoked synchronously during the paste event.
const dropEntriesPromise = dataTransfer?.items?.length
? extractDropEntries(dataTransfer)
? extractDropEntries(dataTransfer).catch(() => [])
: null;
const pastedFileSnapshot = dataTransfer?.files?.length
? Array.from(dataTransfer.files).filter((file) => file.name)
+59 -26
View File
@@ -1,6 +1,11 @@
import { useCallback, useRef, useState } from "react";
import type { SftpFileEntry } from "../../../types";
import type { TransferStatus } from "../../../domain/models";
import { getParentPath, joinPath as joinFsPath } from "../../../application/state/sftp/utils";
import {
DEFAULT_SFTP_FILE_TRANSFER_CONCURRENCY,
runBoundedConcurrency,
} from "../../../application/state/sftp/transferConcurrency";
import { logger } from "../../../lib/logger";
import { toast } from "../../ui/toast";
import { getFileExtension, getLanguageId, FileOpenerType, SystemAppInfo } from "../../../lib/sftpFileUtils";
@@ -12,6 +17,14 @@ import type { TextEditorModalSnapshot } from "../../TextEditorModal";
import type { UseSftpViewFileOpsParams, UseSftpViewFileOpsResult } from "./useSftpViewFileOps.types";
import { assertSftpFileFitsBuiltinEditor } from "../sftpEditorFileLimits";
/** Local multi-select blob downloads read whole files into ArrayBuffers. */
const LOCAL_BLOB_DOWNLOAD_CONCURRENCY = 1;
/**
* Multi-select roots each start their own interleaved folder walk / session
* work. Bound them so many selected directories cannot stampede the scheduler.
*/
const MULTI_SELECT_ROOT_DOWNLOAD_CONCURRENCY = DEFAULT_SFTP_FILE_TRANSFER_CONCURRENCY;
export const useSftpViewFileOps = ({
sftpRef,
behaviorRef,
@@ -538,9 +551,14 @@ export const useSftpViewFileOps = ({
if (!pane.connection) return;
if (pane.connection.isLocal) {
for (const file of files) {
await handleDownloadFileForSide(side, file);
}
// Sequential: each local download materializes a full ArrayBuffer.
await runBoundedConcurrency(
files,
LOCAL_BLOB_DOWNLOAD_CONCURRENCY,
async (file) => {
await handleDownloadFileForSide(side, file);
},
);
return;
}
@@ -558,27 +576,42 @@ export const useSftpViewFileOps = ({
const selectedDirectory = await selectDirectory(t("sftp.context.download"));
if (!selectedDirectory) return;
// Sequential enqueue; each file uses dedicated transfer-pool sessions
// via downloadToLocal so the browse connection stays responsive.
for (const file of files) {
const sourcePath = sftpRef.current.joinPath(pane.connection.currentPath, file.name);
const targetPath = joinFsPath(selectedDirectory, file.name);
const isDirectory = isNavigableDirectory(file);
const fileSize = typeof file.size === "string" ? parseInt(file.size, 10) || 0 : (file.size || 0);
// Bound root jobs: each directory root walks and transfers independently,
// so unbounded Promise.allSettled would multiply session / LIST pressure.
const results: Array<PromiseSettledResult<{ file: SftpFileEntry; status: TransferStatus }>> = [];
await runBoundedConcurrency(
files,
MULTI_SELECT_ROOT_DOWNLOAD_CONCURRENCY,
async (file, index) => {
try {
const sourcePath = sftpRef.current.joinPath(pane.connection.currentPath, file.name);
const targetPath = joinFsPath(selectedDirectory, file.name);
const isDirectory = isNavigableDirectory(file);
const fileSize = typeof file.size === "string" ? parseInt(file.size, 10) || 0 : (file.size || 0);
try {
const status = await sftpRef.current.downloadToLocal({
fileName: file.name,
sourcePath,
targetPath,
sftpId,
connectionId: pane.connection.id,
sourceHostId: pane.connection.hostId,
sourceHostLabel: pane.connection.hostLabel,
sourceEncoding: pane.filenameEncoding,
isDirectory,
totalBytes: isDirectory ? undefined : fileSize,
});
const status = await sftpRef.current.downloadToLocal({
fileName: file.name,
sourcePath,
targetPath,
sftpId,
connectionId: pane.connection.id,
sourceHostId: pane.connection.hostId,
sourceHostLabel: pane.connection.hostLabel,
sourceEncoding: pane.filenameEncoding,
isDirectory,
totalBytes: isDirectory ? undefined : fileSize,
});
results[index] = { status: "fulfilled", value: { file, status } };
} catch (reason) {
results[index] = { status: "rejected", reason };
}
},
);
for (const result of results) {
if (!result) continue;
if (result.status === "fulfilled") {
const { file, status } = result.value;
if (status === "completed") {
toast.success(`${t("sftp.context.download")}: ${file.name}`, "SFTP");
} else if (status === "failed") {
@@ -586,9 +619,9 @@ export const useSftpViewFileOps = ({
} else if (status === "attention") {
toast.error(`${file.name}: another transfer for this path is already in progress`, "SFTP");
}
} catch (e) {
logger.error("[SftpView] Failed to download file:", e);
const errorMessage = e instanceof Error ? e.message : String(e);
} else {
logger.error("[SftpView] Failed to download file:", result.reason);
const errorMessage = result.reason instanceof Error ? result.reason.message : String(result.reason);
const isCancelError = errorMessage.includes("cancelled") || errorMessage.includes("canceled");
if (!isCancelError) toast.error(errorMessage || t("sftp.error.downloadFailed"), "SFTP");
}
@@ -0,0 +1,29 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import test from "node:test";
test("gpu tab keeps loading while accelerator query is pending", () => {
const source = readFileSync(new URL("./GpuManagerTab.tsx", import.meta.url), "utf8");
assert.match(source, /setGpuListPending\(true\)/);
assert.match(source, /if \(result\.pending\)/);
assert.match(source, /isRefreshActive = loading \|\| gpuListPending/);
assert.match(source, /if \(!data\)/);
assert.doesNotMatch(
source,
/if \(result\.pending\) return null;\s*\n\s*if \(!result\.success\)/,
);
});
test("gpu tab still renders compute processes when device list is empty", () => {
const source = readFileSync(new URL("./GpuManagerTab.tsx", import.meta.url), "utf8");
assert.match(source, /if \(!devices\.length && !processes\.length\)/);
assert.match(source, /processes\.map\(\(process\) =>/);
});
test("gpu tab passes null utilization to ResourceBar instead of zero", () => {
const source = readFileSync(new URL("./GpuManagerTab.tsx", import.meta.url), "utf8");
assert.match(source, /value=\{util\}/);
assert.match(source, /value=\{memPct\}/);
assert.doesNotMatch(source, /value=\{util \?\? 0\}/);
assert.doesNotMatch(source, /value=\{memPct \?\? 0\}/);
});
+294
View File
@@ -0,0 +1,294 @@
import { CircuitBoard, Cpu, Thermometer, Zap } from 'lucide-react';
import React, { memo, useCallback, useEffect, useMemo, useState } from 'react';
import { useI18n } from '../../application/i18n/I18nProvider';
import type { useSystemManagerBackend } from '../../application/state/useSystemManagerBackend';
import type {
AcceleratorDeviceInfo,
AcceleratorProcessInfo,
AcceleratorSnapshot,
} from '../../domain/systemManager/types';
import { ResourceBar } from './ResourceBar';
import {
SystemPanelEmpty,
SystemPanelError,
SystemPanelInlineError,
SystemPanelList,
SystemPanelLoading,
SystemPanelRefreshButton,
SystemPanelRow,
SystemPanelShell,
SystemPanelStatusBadge,
SystemPanelToolbar,
} from './SystemPanelUi';
import { usePolling, useStableTranslate } from './hooks/useSystemManager';
type Backend = ReturnType<typeof useSystemManagerBackend>;
interface GpuManagerTabProps {
sessionId: string;
isVisible: boolean;
backend: Backend;
refreshIntervalSec: number;
}
function formatMb(mb: number | null | undefined): string {
if (!Number.isFinite(mb)) return '--';
const value = Number(mb);
if (value >= 1024) return `${(value / 1024).toFixed(1)} GB`;
return `${Math.round(value)} MB`;
}
function memoryPercent(device: AcceleratorDeviceInfo): number | null {
if (!Number.isFinite(device.memoryUsedMb) || !Number.isFinite(device.memoryTotalMb)) return null;
if (!device.memoryTotalMb || device.memoryTotalMb <= 0) return null;
return Math.max(0, Math.min(100, (Number(device.memoryUsedMb) / Number(device.memoryTotalMb)) * 100));
}
function vendorLabel(
vendor: AcceleratorDeviceInfo['vendor'],
t: ReturnType<typeof useI18n>['t'],
): string {
return vendor === 'nvidia'
? t('systemManager.gpu.vendor.nvidia')
: t('systemManager.gpu.vendor.ascend');
}
const DeviceCard = memo(function DeviceCard({
device,
}: {
device: AcceleratorDeviceInfo;
}) {
const { t } = useI18n();
const memPct = memoryPercent(device);
const util = Number.isFinite(device.utilizationPercent) ? Number(device.utilizationPercent) : null;
return (
<div className="border-b border-border/30 px-3 py-2.5 space-y-2">
<div className="flex items-start justify-between gap-2 min-w-0">
<div className="min-w-0">
<div className="flex items-center gap-1.5 min-w-0">
<span className="text-xs font-medium truncate">
[{device.index}] {device.name}
</span>
<SystemPanelStatusBadge tone="muted">
{vendorLabel(device.vendor, t)}
</SystemPanelStatusBadge>
{device.health ? (
<SystemPanelStatusBadge tone={/ok|healthy|good/i.test(device.health) ? 'success' : 'warning'}>
{device.health}
</SystemPanelStatusBadge>
) : null}
</div>
{device.driverVersion ? (
<div className="text-[10px] text-muted-foreground mt-0.5">
{t('systemManager.gpu.driver', { version: device.driverVersion })}
</div>
) : null}
</div>
<div className="flex items-center gap-2 shrink-0 text-[10px] text-muted-foreground tabular-nums">
{Number.isFinite(device.temperatureC) ? (
<span className="inline-flex items-center gap-0.5" title={t('systemManager.gpu.temperature')}>
<Thermometer size={10} />
{Math.round(Number(device.temperatureC))}°C
</span>
) : null}
{Number.isFinite(device.powerDrawW) ? (
<span className="inline-flex items-center gap-0.5" title={t('systemManager.gpu.power')}>
<Zap size={10} />
{Number(device.powerDrawW).toFixed(0)}
{Number.isFinite(device.powerLimitW) ? `/${Math.round(Number(device.powerLimitW))}` : ''}
W
</span>
) : null}
</div>
</div>
<ResourceBar
label={t('systemManager.gpu.util')}
value={util}
/>
<div className="flex items-center gap-2 min-w-0">
<ResourceBar
label={device.vendor === 'ascend' ? t('systemManager.gpu.hbm') : t('systemManager.gpu.memory')}
value={memPct}
className="flex-1"
/>
<span className="text-[10px] tabular-nums text-muted-foreground shrink-0">
{formatMb(device.memoryUsedMb)} / {formatMb(device.memoryTotalMb)}
</span>
</div>
{Number.isFinite(device.fanPercent) ? (
<div className="text-[10px] text-muted-foreground">
{t('systemManager.gpu.fan', { value: Math.round(Number(device.fanPercent)) })}
</div>
) : null}
</div>
);
});
const ProcessRow = memo(function ProcessRow({
process,
}: {
process: AcceleratorProcessInfo;
}) {
const { t } = useI18n();
return (
<SystemPanelRow
title={process.processName || '—'}
subtitle={`${vendorLabel(process.vendor, t)} #${process.gpuIndex}`}
trailing={(
<div className="flex items-center gap-2 text-[10px] text-muted-foreground tabular-nums">
<span>PID {process.pid}</span>
<span>{formatMb(process.memoryUsedMb)}</span>
</div>
)}
/>
);
});
export const GpuManagerTab = memo(function GpuManagerTab({
sessionId,
isVisible,
backend,
refreshIntervalSec,
}: GpuManagerTabProps) {
const { t } = useI18n();
const stableT = useStableTranslate();
const intervalMs = Math.max(2, refreshIntervalSec) * 1000;
const [gpuListPending, setGpuListPending] = useState(false);
useEffect(() => {
setGpuListPending(false);
}, [sessionId]);
const fetcher = useCallback(async (): Promise<AcceleratorSnapshot | null> => {
const result = await backend.listAccelerators(sessionId);
if (result.pending) {
setGpuListPending(true);
return null;
}
setGpuListPending(false);
if (!result.success) {
throw new Error(result.error || stableT('systemManager.errors.loadGpu'));
}
return {
devices: result.devices || [],
processes: result.processes || [],
nvidiaDriverVersion: result.nvidiaDriverVersion ?? null,
probedAt: result.probedAt || Date.now(),
};
}, [backend, sessionId, stableT]);
const { data, error, loading, refresh } = usePolling(
fetcher,
intervalMs,
isVisible,
undefined,
{ resetKey: sessionId },
);
const devices = useMemo(() => data?.devices ?? [], [data?.devices]);
const processes = useMemo(() => data?.processes ?? [], [data?.processes]);
const isRefreshActive = loading || gpuListPending;
const meta = useMemo(() => {
const nvidiaCount = devices.filter((d) => d.vendor === 'nvidia').length;
const ascendCount = devices.filter((d) => d.vendor === 'ascend').length;
return t('systemManager.gpu.meta', {
devices: devices.length,
processes: processes.length,
nvidia: nvidiaCount,
ascend: ascendCount,
});
}, [devices, processes, t]);
if (!isVisible && !data) {
return null;
}
if (!data) {
if (error && !isRefreshActive) {
return (
<SystemPanelShell section="system-manager-gpu">
<SystemPanelError
message={error}
retryLabel={t('history.action.refresh')}
onRetry={() => void refresh()}
/>
</SystemPanelShell>
);
}
return (
<SystemPanelShell section="system-manager-gpu">
<SystemPanelLoading message={t('systemManager.gpu.loading')} />
</SystemPanelShell>
);
}
if (!devices.length && !processes.length) {
return (
<SystemPanelShell section="system-manager-gpu">
<SystemPanelToolbar trailing={(
<SystemPanelRefreshButton
title={t('history.action.refresh')}
loading={isRefreshActive}
onClick={() => void refresh()}
/>
)}>
<span className="text-[11px] text-muted-foreground truncate">{meta}</span>
</SystemPanelToolbar>
<SystemPanelEmpty icon={CircuitBoard} message={t('systemManager.gpu.empty')} />
</SystemPanelShell>
);
}
return (
<SystemPanelShell section="system-manager-gpu">
<SystemPanelToolbar trailing={(
<SystemPanelRefreshButton
title={t('history.action.refresh')}
loading={isRefreshActive}
onClick={() => void refresh()}
/>
)}>
<span className="text-[11px] text-muted-foreground truncate">{meta}</span>
</SystemPanelToolbar>
{error && !isRefreshActive ? (
<SystemPanelInlineError
message={error}
retryLabel={t('history.action.refresh')}
onRetry={() => void refresh()}
/>
) : null}
<SystemPanelList>
<div className="px-3 py-1.5 text-[10px] uppercase tracking-wide text-muted-foreground flex items-center gap-1">
<Cpu size={10} />
{t('systemManager.gpu.devices')}
</div>
{devices.length === 0 ? (
<div className="px-3 py-2 text-[11px] text-muted-foreground">
{t('systemManager.gpu.empty')}
</div>
) : (
devices.map((device) => (
<DeviceCard key={`${device.vendor}-${device.index}-${device.uuid || device.name}`} device={device} />
))
)}
<div className="px-3 pt-3 pb-1.5 text-[10px] uppercase tracking-wide text-muted-foreground">
{t('systemManager.gpu.processes')}
</div>
{processes.length === 0 ? (
<div className="px-3 py-2 text-[11px] text-muted-foreground">
{t('systemManager.gpu.noProcesses')}
</div>
) : (
processes.map((process) => (
<ProcessRow
key={`${process.vendor}-${process.gpuIndex}-${process.pid}-${process.processName}`}
process={process}
/>
))
)}
</SystemPanelList>
</SystemPanelShell>
);
});
+6 -4
View File
@@ -3,12 +3,13 @@ import { cn } from '../../lib/utils';
interface ResourceBarProps {
label: string;
value: number;
value: number | null | undefined;
className?: string;
}
export const ResourceBar = memo(function ResourceBar({ label, value, className }: ResourceBarProps) {
const clamped = Math.max(0, Math.min(100, value));
const finite = typeof value === 'number' && Number.isFinite(value);
const clamped = finite ? Math.max(0, Math.min(100, value)) : 0;
return (
<div className={cn('flex items-center gap-2 min-w-0', className)}>
<span className="text-[10px] text-muted-foreground w-7 shrink-0">{label}</span>
@@ -16,13 +17,14 @@ export const ResourceBar = memo(function ResourceBar({ label, value, className }
<div
className={cn(
'h-full rounded-full',
clamped > 85 ? 'bg-destructive/70' : 'bg-primary/70',
finite && clamped > 85 ? 'bg-destructive/70' : 'bg-primary/70',
!finite && 'opacity-0',
)}
style={{ width: `${clamped}%` }}
/>
</div>
<span className="text-[10px] tabular-nums text-muted-foreground w-10 text-right shrink-0">
{Number.isFinite(value) ? `${value.toFixed(1)}%` : '--'}
{finite ? `${value.toFixed(1)}%` : '--'}
</span>
</div>
);
@@ -1,4 +1,4 @@
import { Activity, Box, Gauge, LayoutList, Loader2, TerminalSquare } from 'lucide-react';
import { Activity, Box, CircuitBoard, Gauge, LayoutList, Loader2, TerminalSquare } from 'lucide-react';
import type { LucideIcon } from 'lucide-react';
import React, { memo, useMemo, useState } from 'react';
import { useI18n } from '../../application/i18n/I18nProvider';
@@ -11,6 +11,7 @@ import { buildSystemManagerTabs, shouldCollectServerStats } from '../../domain/s
import type { Snippet, TerminalSession } from '../../types';
import { cn } from '../../lib/utils';
import { DockerManagerTab } from './DockerManagerTab';
import { GpuManagerTab } from './GpuManagerTab';
import { ProcessManagerTab } from './ProcessManagerTab';
import { SystemOverviewTab } from './SystemOverviewTab';
import { TmuxManagerTab } from './TmuxManagerTab';
@@ -165,10 +166,12 @@ export const SystemManagerSidePanel = memo(function SystemManagerSidePanel({
{ id: 'processes', icon: LayoutList, label: t('systemManager.tabs.processes') },
{ id: 'tmux', icon: TerminalSquare, label: t('systemManager.tabs.tmux') },
{ id: 'docker', icon: Box, label: t('systemManager.tabs.docker') },
{ id: 'gpu', icon: CircuitBoard, label: t('systemManager.tabs.gpu') },
];
const tmuxReady = capabilities?.hasTmux === true;
const dockerReady = capabilities?.hasDocker === true;
const gpuReady = capabilities?.hasNvidiaSmi === true || capabilities?.hasNpuSmi === true;
const tmuxPanelState = resolveCapabilityPanelState({
isActive: resolvedTab === 'tmux',
ready: tmuxReady,
@@ -179,6 +182,11 @@ export const SystemManagerSidePanel = memo(function SystemManagerSidePanel({
ready: dockerReady,
capabilitiesKnown: capabilities !== undefined,
});
const gpuPanelState = resolveCapabilityPanelState({
isActive: resolvedTab === 'gpu',
ready: gpuReady,
capabilitiesKnown: capabilities !== undefined,
});
return (
<SystemPanelShell section="system-manager-panel">
@@ -267,6 +275,24 @@ export const SystemManagerSidePanel = memo(function SystemManagerSidePanel({
/>
</div>
) : null}
{gpuPanelState === 'unavailable' ? (
<div className="flex-1 min-h-0">
<SystemPanelEmpty icon={CircuitBoard} message={t('systemManager.gpu.unavailable')} />
</div>
) : gpuPanelState === 'checking' ? (
<div className="flex-1 min-h-0">
<SystemPanelChecking message={t('systemManager.common.checkingAvailability')} />
</div>
) : gpuPanelState === 'ready' ? (
<div className={cn('flex-1 min-h-0 flex flex-col', resolvedTab !== 'gpu' && 'hidden')}>
<GpuManagerTab
sessionId={sessionId}
isVisible={isVisible && resolvedTab === 'gpu'}
backend={backend}
refreshIntervalSec={terminalSettings.systemManagerProcessRefreshInterval}
/>
</div>
) : null}
</div>
</SystemPanelShell>
);
+97 -1
View File
@@ -11,9 +11,12 @@ import {
resolveTerminalRightInset,
resolveTerminalTopOffsets,
shouldBlockTerminalReconnectForTarget,
shouldEnableStatusBarDisconnect,
shouldEnableStatusBarReconnect,
shouldReconnectTerminalOnEnterKey,
shouldShowSelectionAIOverlay,
shouldShowLineTimestampToolbarToggle,
shouldShowStatusBarConnectionControls,
} from "./TerminalView.tsx";
test("line timestamp toggle creates a persistent host update", () => {
@@ -305,6 +308,8 @@ test("hidden host information keeps terminal actions rendered", () => {
const copyAction = source.indexOf('aria-label={t("terminal.statusbar.copyHostname.label")}');
const timestampAction = source.indexOf("shouldShowLineTimestampToolbarToggle", copyAction);
const systemAction = source.indexOf('aria-label={t("terminal.layer.system")}', timestampAction);
const disconnectAction = source.indexOf('aria-label={t("terminal.statusbar.disconnect.label")}', systemAction);
const reconnectAction = source.indexOf('aria-label={t("terminal.statusbar.reconnect.label")}', disconnectAction);
const actionsStart = source.indexOf('className="flex items-center gap-0.5 flex-shrink-0"');
const controls = source.indexOf("{renderControls({ showClose: inWorkspace })}");
const compactDragHandle = source.indexOf('data-terminal-detach-drag-handle="true"');
@@ -314,6 +319,8 @@ test("hidden host information keeps terminal actions rendered", () => {
assert.notEqual(copyAction, -1);
assert.notEqual(timestampAction, -1);
assert.notEqual(systemAction, -1);
assert.notEqual(disconnectAction, -1);
assert.notEqual(reconnectAction, -1);
assert.notEqual(actionsStart, -1);
assert.notEqual(controls, -1);
assert.notEqual(compactDragHandle, -1);
@@ -324,11 +331,100 @@ test("hidden host information keeps terminal actions rendered", () => {
assert.ok(hostInfoEnd < copyAction);
assert.ok(copyAction < timestampAction);
assert.ok(timestampAction < systemAction);
assert.ok(systemAction < actionsStart);
assert.ok(systemAction < disconnectAction);
assert.ok(disconnectAction < reconnectAction);
assert.ok(reconnectAction < actionsStart);
assert.ok(actionsStart < controls);
assert.ok(compactDragHandle < hostInfoStart);
});
test("status bar disconnect stays enabled while connected or connecting", () => {
assert.equal(shouldEnableStatusBarDisconnect("connected"), true);
assert.equal(shouldEnableStatusBarDisconnect("connecting"), true);
assert.equal(shouldEnableStatusBarDisconnect("disconnected"), false);
assert.equal(shouldEnableStatusBarDisconnect(undefined), false);
});
test("status bar reconnect matches tab-menu reconnect gating", () => {
assert.equal(shouldEnableStatusBarReconnect("connected"), true);
assert.equal(shouldEnableStatusBarReconnect("disconnected"), true);
assert.equal(shouldEnableStatusBarReconnect("connecting"), false);
assert.equal(shouldEnableStatusBarReconnect(undefined), false);
});
test("status bar connection controls require an owned session surface", () => {
assert.equal(
shouldShowStatusBarConnectionControls({
showConnectionControls: true,
hasDisconnectHandler: true,
hasReconnectHandler: true,
}),
true,
);
assert.equal(
shouldShowStatusBarConnectionControls({
showConnectionControls: false,
hasDisconnectHandler: true,
hasReconnectHandler: true,
}),
false,
);
assert.equal(
shouldShowStatusBarConnectionControls({
showConnectionControls: true,
hasDisconnectHandler: false,
hasReconnectHandler: false,
}),
false,
);
});
test("manual disconnect keeps the session pane for reconnect", () => {
const source = readFileSync(new URL("../Terminal.tsx", import.meta.url), "utf8");
const disconnectStart = source.indexOf("const handleDisconnect = () => {");
const disconnectEnd = source.indexOf("const handleDismissDisconnectedDialog", disconnectStart);
assert.notEqual(disconnectStart, -1);
assert.notEqual(disconnectEnd, -1);
const body = source.slice(disconnectStart, disconnectEnd);
assert.match(body, /clearAutoReconnect\(\{ stopLoop: true \}\)/);
assert.match(body, /reconnectWakeTokenRef\.current = null/);
assert.match(body, /reconnectWakeInFlightRef\.current = false/);
assert.match(body, /netcatty:terminal-session-disconnected/);
assert.match(body, /invalidateBootEpochForClose\(\)/);
assert.match(body, /isBootActiveRef\.current = false/);
assert.match(body, /setIsCancelling\(true\)/);
assert.match(body, /updateStatus\("disconnected"\)/);
assert.match(body, /void cleanupSession\(\)/);
assert.match(source, /trackSessionCleanup/);
assert.doesNotMatch(body, /onCloseSession/);
assert.match(source, /handleDisconnect: \(attachExistingSession \|\| compactToolbar\) \? undefined : handleDisconnect/);
assert.match(source, /showConnectionControls: !attachExistingSession && !compactToolbar/);
assert.match(source, /setTerminalBootEpoch/);
const startersSource = readFileSync(
new URL("./runtime/createTerminalSessionStarters.ts", import.meta.url),
"utf8",
);
assert.match(startersSource, /createBootAttemptGuard\(ctx\)/);
assert.match(startersSource, /setTerminalBootEpoch\(ctx\.sessionId, bootEpoch\)/);
const effectsSource = readFileSync(new URL("./useTerminalEffects.ts", import.meta.url), "utf8");
assert.match(
effectsSource,
/!isBootActiveRef\.current[\s\S]*statusRef\.current === "disconnected"[\s\S]*bootEpochMismatch/,
);
assert.match(
effectsSource,
/respondHostKeyVerification\?\.\(request\.requestId, false\)/,
);
assert.match(effectsSource, /request\.bootEpoch/);
assert.match(
startersSource,
/if \(!isCurrentAttempt\(\)\) return;/,
);
assert.match(startersSource, /bootEpoch,/);
});
test("hidden host information reveals actions without permanently covering terminal content", () => {
const source = readFileSync(new URL("./TerminalView.tsx", import.meta.url), "utf8");
+82 -1
View File
@@ -1,6 +1,7 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import React, { memo, useCallback, useEffect, useRef, useState } from 'react';
import { ChevronsLeft, GripVertical, Network, X as XIcon } from 'lucide-react';
import { isSessionReconnectDisabled } from '../top-tabs/SessionTabContextMenuContent';
import { resolveEffectiveTerminalProtocol } from '../../domain/terminalProtocol';
import { classifyDistroId } from '../../domain/host';
@@ -47,6 +48,32 @@ export function shouldShowLineTimestampToolbarToggle(
return lineTimestampsAvailable !== false && Boolean(onUpdateHost);
}
/** Keep the tab/pane; only tear down the live transport. */
export function shouldEnableStatusBarDisconnect(
status: 'connecting' | 'connected' | 'disconnected' | undefined,
): boolean {
return status === 'connected' || status === 'connecting';
}
export function shouldEnableStatusBarReconnect(
status: 'connecting' | 'connected' | 'disconnected' | undefined,
): boolean {
if (!status) return false;
return !isSessionReconnectDisabled(status);
}
export function shouldShowStatusBarConnectionControls({
showConnectionControls,
hasDisconnectHandler,
hasReconnectHandler,
}: {
showConnectionControls?: boolean;
hasDisconnectHandler?: boolean;
hasReconnectHandler?: boolean;
}): boolean {
return Boolean(showConnectionControls && (hasDisconnectHandler || hasReconnectHandler));
}
export function shouldEnableYmodemAction({
isSerialConnection,
status,
@@ -287,7 +314,7 @@ function terminalViewCtxEqual(
}
function TerminalViewInner({ ctx }: { ctx: TerminalViewContext }) {
const { Activity, Button, Clock3, Copy, Maximize2, Radio, SquareArrowOutUpRight, TerminalAutocomplete, TerminalComposeBar, TerminalConnectionDialog, TerminalContextMenu, TerminalSearchBar, Tooltip, TooltipContent, TooltipTrigger, ZmodemOverwriteDialog, ZmodemProgressIndicator, auth, autocompleteAcceptTextRef, autocompleteCloseRef, autocompleteHostOs, autocompleteInputRef, autocompleteKeyEventRef, autocompleteRepositionRef, autocompleteSettings, canUpdateHost, chainProgress, cn, compactToolbar, lineTimestampsAvailable, containerRef, effectiveFontSize, effectiveFontWeight, effectiveTerminalProtocol, effectiveTheme, error, executeSnippet, executeSnippetCommand, handleAddSelectionToAI, handleCancelConnect, handleCloseDisconnectedSession, handleCloseSearch, handleDismissDisconnectedDialog, handleDragEnter, handleDragLeave, handleDragOver, handleDrop, handleFindNext, handleFindPrevious, handleHostKeyAddAndContinue, handleHostKeyClose, handleHostKeyContinue, handleOsc52ReadResponse, handleOsc7SetupConfirm, handleOsc7SetupOpenChange, handleReceiveYmodem, handleRetry, handleSearch, handleSendYmodem, handleTopOverlayMouseDownCapture, hasMouseTracking, host, hotkeyScheme, inWorkspace, isBroadcastEnabled, isCancelling, isComposeBarOpen, isConnectionAwaitingUserInput, isDraggingOver, isFocusMode, isFocusedPane, isLocalConnection, remoteDragDropUsesZmodem, isPluginTerminalProviderAvailable, isSerialConnection, isSearchOpen, isSupportedOs, isSystemSidebarEligible, isVisible, keyBindings, keys, knownCwdRef, needsHostKeyVerification, onCloseSession, onDetach, onDetachPointerDown, onExpandToFocus, onOpenSystem, onRename, onSplitHorizontal, onSplitVertical, onToggleBroadcast, onUpdateHost, osc52ReadPromptVisible, osc7SetupOpen, osc7SetupRunning, passwordPromptActiveRef, pendingHostKeyInfo, progressLogs, progressValue, renderControls, resolvedFontFamily, restoreState, scriptExecutionOverlay, searchMatchCount, searchFocusToken, sessionDisplayName, sessionId, workspaceId, sessionRef, setIsComposeBarOpen, setShowLogs, shouldShowConnectionDialog, showLogs, showSelectionAIAction, isRestoringSelectionRef, snippets, status, sudoHintRef, sudoHintText, passwordPickerState, onPasswordPickerSelect, passwordPickerTitle, passwordPickerEmptyText, t, termRef, terminalContextActions, terminalCwdTracker, terminalPreviewVars, terminalSettings, timeLeft, toast, zmodem } = ctx;
const { Activity, Button, Clock3, Copy, Maximize2, Radio, RefreshCcw, SquareArrowOutUpRight, TerminalAutocomplete, TerminalComposeBar, TerminalConnectionDialog, TerminalContextMenu, TerminalSearchBar, Tooltip, TooltipContent, TooltipTrigger, Unplug, ZmodemOverwriteDialog, ZmodemProgressIndicator, auth, autocompleteAcceptTextRef, autocompleteCloseRef, autocompleteHostOs, autocompleteInputRef, autocompleteKeyEventRef, autocompleteRepositionRef, autocompleteSettings, canUpdateHost, chainProgress, cn, compactToolbar, lineTimestampsAvailable, containerRef, effectiveFontSize, effectiveFontWeight, effectiveTerminalProtocol, effectiveTheme, error, executeSnippet, executeSnippetCommand, handleAddSelectionToAI, handleCancelConnect, handleCloseDisconnectedSession, handleCloseSearch, handleDisconnect, handleDismissDisconnectedDialog, handleDragEnter, handleDragLeave, handleDragOver, handleDrop, handleFindNext, handleFindPrevious, handleHostKeyAddAndContinue, handleHostKeyClose, handleHostKeyContinue, handleOsc52ReadResponse, handleOsc7SetupConfirm, handleOsc7SetupOpenChange, handleReceiveYmodem, handleRetry, handleSearch, handleSendYmodem, handleTopOverlayMouseDownCapture, hasMouseTracking, host, hotkeyScheme, inWorkspace, isBroadcastEnabled, isCancelling, isComposeBarOpen, isConnectionAwaitingUserInput, isDraggingOver, isFocusMode, isFocusedPane, isLocalConnection, remoteDragDropUsesZmodem, isPluginTerminalProviderAvailable, isSerialConnection, isSearchOpen, isSupportedOs, isSystemSidebarEligible, isVisible, keyBindings, keys, knownCwdRef, needsHostKeyVerification, onCloseSession, onDetach, onDetachPointerDown, onExpandToFocus, onOpenSystem, onRename, onSplitHorizontal, onSplitVertical, onToggleBroadcast, onUpdateHost, osc52ReadPromptVisible, osc7SetupOpen, osc7SetupRunning, passwordPromptActiveRef, pendingHostKeyInfo, progressLogs, progressValue, renderControls, resolvedFontFamily, restoreState, scriptExecutionOverlay, searchMatchCount, searchFocusToken, sessionDisplayName, sessionId, workspaceId, sessionRef, setIsComposeBarOpen, setShowLogs, shouldShowConnectionDialog, showConnectionControls, showLogs, showSelectionAIAction, isRestoringSelectionRef, snippets, status, sudoHintRef, sudoHintText, passwordPickerState, onPasswordPickerSelect, passwordPickerTitle, passwordPickerEmptyText, t, termRef, terminalContextActions, terminalCwdTracker, terminalPreviewVars, terminalSettings, timeLeft, toast, zmodem } = ctx;
// Context menu only needs a snapshot at open; avoid selection state lifting into Terminal.
const [contextMenuHasSelection, setContextMenuHasSelection] = useState(false);
const isNetworkDevice = host.deviceType === 'network'
@@ -646,6 +673,60 @@ function TerminalViewInner({ ctx }: { ctx: TerminalViewContext }) {
<TooltipContent side="bottom">{t("terminal.layer.system")}</TooltipContent>
</Tooltip>
)}
{shouldShowStatusBarConnectionControls({
showConnectionControls,
hasDisconnectHandler: Boolean(handleDisconnect),
hasReconnectHandler: Boolean(handleRetry),
}) && (
<>
{handleDisconnect && (
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
className={cn(
"ml-0.5 p-0.5 rounded transition-colors flex-shrink-0",
"hover:bg-[color:var(--terminal-toolbar-btn-hover)]",
shouldEnableStatusBarDisconnect(status)
? "opacity-60 hover:opacity-100"
: "opacity-30 cursor-not-allowed",
)}
onPointerDown={(event) => event.stopPropagation()}
onClick={handleDisconnect}
disabled={!shouldEnableStatusBarDisconnect(status)}
aria-label={t("terminal.statusbar.disconnect.label")}
>
<Unplug size={10} />
</button>
</TooltipTrigger>
<TooltipContent side="bottom">{t("terminal.statusbar.disconnect.tooltip")}</TooltipContent>
</Tooltip>
)}
{handleRetry && (
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
className={cn(
"ml-0.5 p-0.5 rounded transition-colors flex-shrink-0",
"hover:bg-[color:var(--terminal-toolbar-btn-hover)]",
shouldEnableStatusBarReconnect(status)
? "opacity-60 hover:opacity-100"
: "opacity-30 cursor-not-allowed",
)}
onPointerDown={(event) => event.stopPropagation()}
onClick={handleRetry}
disabled={!shouldEnableStatusBarReconnect(status)}
aria-label={t("terminal.statusbar.reconnect.label")}
>
<RefreshCcw size={10} />
</button>
</TooltipTrigger>
<TooltipContent side="bottom">{t("terminal.statusbar.reconnect.tooltip")}</TooltipContent>
</Tooltip>
)}
</>
)}
</div>
{showHostInfoBar && !compactToolbar && (
<TerminalServerStats
@@ -111,4 +111,16 @@ describe('extractRootPathsFromDropEntries', () => {
['/home/user/folder'],
);
});
it('uses native path-only entries from a folder scan', () => {
assert.deepEqual(
extractRootPathsFromDropEntries([{
file: null,
localPath: '/home/user/folder/src/main.ts',
relativePath: 'folder/src/main.ts',
isDirectory: false,
}]),
['/home/user/folder'],
);
});
});
+299 -23
View File
@@ -2,11 +2,151 @@ import test from "node:test";
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import xterm from "@xterm/xterm";
import serializeMod from "@xterm/addon-serialize";
import type { Terminal as XTerm } from "@xterm/xterm";
import { applyHibernateWakeToTerminal } from "./terminalHibernateRuntime.ts";
import {
applyHibernateWakeToTerminal,
resolveHibernateWakeHistory,
} from "./terminalHibernateRuntime.ts";
import { writeTerminalPayloadChunked } from "./terminalReplay.ts";
const { Terminal } = xterm;
const { SerializeAddon } = serializeMod;
const readActiveBufferText = (term: XTerm): string => {
const buffer = term.buffer.active;
const lines: string[] = [];
for (let index = 0; index < buffer.length; index += 1) {
lines.push(buffer.getLine(index)?.translateToString(true) ?? "");
}
return lines.join("\n");
};
const writeAndWait = (term: XTerm, data: string): Promise<void> =>
new Promise((resolve) => {
term.write(data, () => resolve());
});
test("hibernate wake pauses flow before replay and resumes only after reattach", () => {
// #2762 / Codex: full-history wake must not race the capped pending buffer or
// the 64 KiB preload backlog. Pause+wait drains into pending, then stop the
// data listener, take pending once, replay, reattach, and only then resume.
const mountSource = readFileSync(new URL("./terminalRuntimeMount.ts", import.meta.url), "utf8");
const terminalSource = readFileSync(new URL("../Terminal.tsx", import.meta.url), "utf8");
assert.match(mountSource, /prepareWakeFlow\?:\s*\(\) => Promise<boolean>/);
assert.match(mountSource, /restoreAfterFailedWake\?:\s*\(takenPending: string\) => void/);
assert.match(mountSource, /resumeAfterReattach\?:\s*\(\) => void/);
assert.match(mountSource, /takePendingBuffer: \(\) => string/);
assert.match(mountSource, /stopHibernateDataListener: \(\) => void/);
assert.match(mountSource, /const drainOk = \(await prepareWakeFlow\?\.\(\)\) \?\? true;/);
assert.match(mountSource, /const takeAndTrackPending = \(\): string =>/);
assert.match(mountSource, /const pendingAtApplyStart = takeAndTrackPending\(\);/);
assert.match(mountSource, /const pendingTail = takeAndTrackPending\(\);/);
assert.match(mountSource, /if \(!pendingTail\) break;/);
assert.match(mountSource, /restoreAfterFailedWake\?\.\(takenPendingForRestore\)/);
assert.doesNotMatch(mountSource, /pending\.slice\(replayedPendingLength\)/);
assert.doesNotMatch(mountSource, /for \(let drainPass = 0;/);
assert.doesNotMatch(mountSource, /finalPendingDelta/);
assert.doesNotMatch(mountSource, /preTeardownTail/);
const prepareIndex = mountSource.indexOf("const drainOk = (await prepareWakeFlow?.()) ?? true;");
const disableCapIndex = mountSource.indexOf("setHibernatePendingCapDisabled?.(true);");
const stopDataBeforeReplay = mountSource.indexOf("if (drainOk) {\n stopHibernateDataListener();");
const pendingIndex = mountSource.indexOf("const pendingAtApplyStart = takeAndTrackPending();");
const applyIndex = mountSource.indexOf("await applyHibernateWakeToTerminal(");
const stopDataBeforeTail = mountSource.indexOf(
"stopHibernateDataListener();",
applyIndex,
);
const pendingTailIndex = mountSource.indexOf("const pendingTail = takeAndTrackPending();");
const emptyBreakIndex = mountSource.indexOf("if (!pendingTail) break;");
const shouldReattachIndex = mountSource.indexOf(
"const shouldReattach = sessionConnected && (getSessionConnected?.() ?? true);",
);
const stopAllIndex = mountSource.indexOf("stopHibernateListeners();", shouldReattachIndex);
const reattachIndex = mountSource.indexOf("reattachSession(term);", shouldReattachIndex);
const failedRestoreIndex = mountSource.indexOf("restoreAfterFailedWake?.(takenPendingForRestore);");
const resumeIndex = mountSource.indexOf("resumeAfterReattach?.();");
assert.ok(prepareIndex >= 0, "wake must pause backend flow before history replay");
assert.ok(
disableCapIndex > prepareIndex,
"drain failure must disable the pending cap before keeping the live listener",
);
assert.ok(stopDataBeforeReplay > prepareIndex, "successful drain stops the data listener before replay");
assert.ok(pendingIndex > stopDataBeforeReplay, "pending take must run after drain handling");
assert.ok(applyIndex > pendingIndex, "history replay follows the pending capture");
assert.ok(
stopDataBeforeTail > applyIndex,
"data listener must stop again before residual pending drain",
);
assert.ok(
pendingTailIndex > stopDataBeforeTail,
"residual pending drain must run after history replay",
);
assert.ok(
emptyBreakIndex > pendingTailIndex,
"residual drain must end with an empty take before teardown",
);
assert.ok(
shouldReattachIndex > emptyBreakIndex,
"reattach decision must run after until-empty pending drain",
);
assert.ok(
stopAllIndex > shouldReattachIndex,
"full hibernate listener teardown must wait until after the reattach decision",
);
assert.ok(reattachIndex > stopAllIndex, "reattach runs after hibernate listeners are cleared");
assert.ok(failedRestoreIndex >= 0, "failed wakes must restore take-and-cleared pending");
assert.ok(resumeIndex > reattachIndex, "flow resume must wait until after reattach");
assert.match(
mountSource,
/if \(!wakeSucceeded\) \{[\s\S]*?restoreAfterFailedWake\?\.\(takenPendingForRestore\);[\s\S]*?\} else if \(didReattach\) \{[\s\S]*?resumeAfterReattach\?\.\(\);/,
);
assert.match(
terminalSource,
/takePendingBuffer:\s*\(\)\s*=>\s*\{\s*const pending = hibernatePendingBufferRef\.current;\s*hibernatePendingBufferRef\.current = "";\s*return pending;\s*\}/,
);
assert.match(
terminalSource,
/setSessionFlowPausedAndWait\(backendId,\s*true\)/,
);
// Reconnect wakes (sessionConnected=false) must still pause when a backend
// session exists; otherwise stopping the hibernate listener drops live output.
assert.doesNotMatch(
terminalSource,
/prepareWakeFlow: async \(\) => \{\s*if \(!options\.sessionConnected\) return true;/,
);
assert.match(
terminalSource,
/stopHibernateListeners\(\{\s*keepPaused:\s*true\s*\}\)/,
);
assert.match(
terminalSource,
/restoreAfterFailedWake:\s*\(takenPending\)\s*=>\s*\{[\s\S]*?disposeRuntimeOnly\(\);[\s\S]*?beginHibernatedSessionListeners\(backendId\)/,
);
assert.match(
terminalSource,
/appendHibernatePendingBuffer\(\s*takenPending \|\| "",\s*pendingStillInRef,\s*\)/,
);
assert.match(
terminalSource,
/resumeAfterReattach:\s*\(\)\s*=>\s*\{[\s\S]*?setSessionFlowPaused\?\.\(backendId,\s*false\)/,
);
assert.match(
terminalSource,
/hibernatePendingCapDisabledRef\.current\s*\?\s*hibernatePendingBufferRef\.current \+ chunk/,
);
assert.match(
terminalSource,
/result\?\.success === true/,
);
});
test("writeTerminalPayloadChunked splits large buffers (shipped wake helper)", async () => {
const writes: string[] = [];
const term = {
@@ -22,7 +162,43 @@ test("writeTerminalPayloadChunked splits large buffers (shipped wake helper)", a
assert.equal(writes.join(""), payload);
});
test("applyHibernateWakeToTerminal defers scrollback to idle and chunks viewport first", async () => {
test("resolveHibernateWakeHistory prefers the coherent full snapshot", () => {
assert.equal(
resolveHibernateWakeHistory({
snapshot: "FULL",
viewportSnapshot: "VIEW",
scrollbackSnapshot: "SCROLL",
pendingBuffer: "",
alternateScreen: false,
}),
"FULL",
);
});
test("resolveHibernateWakeHistory falls back to scrollback before viewport with a seam newline", () => {
assert.equal(
resolveHibernateWakeHistory({
snapshot: "",
viewportSnapshot: "VIEWPORT_END\r\n",
scrollbackSnapshot: "SCROLLBACK_START",
pendingBuffer: "",
alternateScreen: false,
}),
"SCROLLBACK_START\r\nVIEWPORT_END\r\n",
);
assert.equal(
resolveHibernateWakeHistory({
snapshot: "",
viewportSnapshot: "VIEWPORT_END\r\n",
scrollbackSnapshot: "SCROLLBACK_START\r\n",
pendingBuffer: "",
alternateScreen: false,
}),
"SCROLLBACK_START\r\nVIEWPORT_END\r\n",
);
});
test("applyHibernateWakeToTerminal replays snapshot then pending without idle append", async () => {
const writes: string[] = [];
const term = {
rows: 24,
@@ -43,38 +219,32 @@ test("applyHibernateWakeToTerminal defers scrollback to idle and chunks viewport
// @ts-expect-error test override
globalThis.requestIdleCallback = (cb: () => void) => {
idleScheduled = true;
// Do not run immediately — proves scrollback is deferred off the wake path.
setTimeout(cb, 0);
return 1;
};
try {
const viewport = "VIEWPORT";
const scrollback = "S".repeat(40_000);
const snapshot = "SCROLLBACK_START\r\nVIEWPORT_END\r\n";
const pending = "PENDING_TAIL\r\n";
await applyHibernateWakeToTerminal(
term,
runtime as never,
{
snapshot: viewport,
viewportSnapshot: viewport,
scrollbackSnapshot: scrollback,
pendingBuffer: "",
snapshot,
viewportSnapshot: "VIEWPORT_END\r\n",
scrollbackSnapshot: "SCROLLBACK_START\r\n",
pendingBuffer: pending,
alternateScreen: false,
},
{ replayOptions: { chunkBytes: 8_192 } },
);
// Viewport written on the wake path; scrollback scheduled idle.
assert.ok(writes.join("").includes(viewport));
assert.equal(idleScheduled, true);
assert.ok(
!writes.join("").includes(scrollback.slice(0, 1000)),
"scrollback must not be written synchronously on wake",
assert.equal(writes.join(""), `${snapshot}${pending}`);
assert.equal(
idleScheduled,
false,
"scrollback must not be deferred to idle after viewport (append would evict the end)",
);
// Flush idle callback.
await new Promise((resolve) => setTimeout(resolve, 20));
assert.ok(writes.join("").includes(scrollback.slice(0, 100)), "scrollback eventually applied");
} finally {
if (originalRic) {
globalThis.requestIdleCallback = originalRic;
@@ -85,9 +255,115 @@ test("applyHibernateWakeToTerminal defers scrollback to idle and chunks viewport
}
});
test("hibernate runtime source schedules scrollback via requestIdleCallback", () => {
test("hibernate wake keeps newest rows from a SerializeAddon snapshot under a finite scrollback cap", async () => {
// #2762: idle-appending older scrollback after viewport under scrollback=N
// evicts the newest rows. Prefer the full SerializeAddon snapshot.
const rows = 5;
const scrollbackCap = 8;
const source = new Terminal({
cols: 40,
rows,
scrollback: 50,
allowProposedApi: true,
});
const serializeAddon = new SerializeAddon();
source.loadAddon(serializeAddon);
const olderLines = Array.from({ length: 20 }, (_, index) => `old-${index}`);
const newestLines = Array.from({ length: rows }, (_, index) => `new-${index}`);
await writeAndWait(source, `${olderLines.join("\r\n")}\r\n${newestLines.join("\r\n")}\r\n`);
const snapshot = serializeAddon.serialize();
const bufferLength = source.buffer.active.length;
const viewportStart = Math.max(0, bufferLength - rows);
const scrollbackSnapshot = serializeAddon.serialize({
range: { start: Math.max(0, viewportStart - 20), end: viewportStart - 1 },
});
const viewportSnapshot = serializeAddon.serialize({
range: { start: viewportStart, end: bufferLength - 1 },
});
source.dispose();
// Range concat is not byte-identical to the full snapshot (missing seam newline).
assert.notEqual(scrollbackSnapshot + viewportSnapshot, snapshot);
const term = new Terminal({
cols: 40,
rows,
scrollback: scrollbackCap,
allowProposedApi: true,
});
const runtime = {
ensureWebglRenderer: () => {},
clearTextureAtlas: () => {},
};
try {
await applyHibernateWakeToTerminal(
term,
runtime as never,
{
snapshot,
viewportSnapshot,
scrollbackSnapshot,
pendingBuffer: "",
alternateScreen: false,
},
{ replayOptions: { chunkBytes: 1024 } },
);
await new Promise((resolve) => setTimeout(resolve, 30));
const text = readActiveBufferText(term);
for (const line of newestLines) {
assert.match(text, new RegExp(line), `newest viewport line missing after wake: ${line}`);
}
assert.doesNotMatch(text, /new-0new-1/, "seam must not merge adjacent snapshot lines");
} finally {
term.dispose();
}
});
test("wrong wake order (viewport then scrollback append) drops newest rows under scrollback cap", async () => {
// Guardrail: documents why idle-append-after-viewport is unsafe.
const rows = 5;
const scrollbackCap = 10;
const term = new Terminal({
cols: 40,
rows,
scrollback: scrollbackCap,
allowProposedApi: true,
});
try {
const olderLines = Array.from({ length: scrollbackCap + rows }, (_, index) => `old-${index}`);
const newestLines = Array.from({ length: rows }, (_, index) => `new-${index}`);
await writeAndWait(term, `${newestLines.join("\r\n")}\r\n`);
await writeAndWait(term, `${olderLines.join("\r\n")}\r\n`);
const text = readActiveBufferText(term);
for (const line of newestLines) {
assert.equal(
text.includes(line),
false,
`viewport-first append must evict newest line under the cap: ${line}`,
);
}
assert.match(text, /old-14/);
} finally {
term.dispose();
}
});
test("hibernate runtime source prefers resolveHibernateWakeHistory on the wake path", () => {
const source = readFileSync(new URL("./terminalHibernateRuntime.ts", import.meta.url), "utf8");
assert.match(source, /requestIdleCallback|scheduleIdle/);
assert.match(source, /writeTerminalPayloadChunked\(term, scrollback/);
assert.match(source, /writeTerminalReplaySequence\(term, \[viewport, payload\.pendingBuffer\]/);
assert.match(source, /export function resolveHibernateWakeHistory/);
assert.match(
source,
/writeTerminalReplaySequence\(\s*term,\s*\[\s*history,\s*payload\.pendingBuffer\s*\]/,
);
assert.doesNotMatch(
source,
/scheduleIdle\(\(\)\s*=>\s*\{\s*void writeTerminalPayloadChunked\(term, scrollback/,
);
});
@@ -53,7 +53,9 @@ export function useTerminalFilePaste({
const wantsImageUpload =
autoUploadClipboardImage && !isLocalConnection && !!bridge?.readClipboardImage;
if (!wantsImageUpload && (!isLocalConnection || !bridge?.readClipboardFiles)) return;
const canHandleLocalPaste =
isLocalConnection && !!(bridge?.readClipboardFiles || bridge?.hasClipboardImage);
if (!wantsImageUpload && !canHandleLocalPaste) return;
// ⚡ Must call preventDefault SYNCHRONOUSLY — the event lifecycle
// is synchronous; calling it after an await is too late and the
@@ -129,6 +129,7 @@ test("reconnect wakes a hibernated terminal before requiring a terminal instance
const wakePromiseRefIndex = source.indexOf("const wakePromiseRef = useRef<Promise<boolean> | null>(null)");
const wakeGuardRefIndex = source.indexOf("const reconnectWakeInFlightRef = useRef(false)");
const wakeTokenRefIndex = source.indexOf("const reconnectWakeTokenRef = useRef<symbol | null>(null)");
const wakeInvalidateModeIndex = source.indexOf('const reconnectWakeInvalidateModeRef = useRef<"dispose" | "keep">("dispose")');
const wakeTokenCleanupIndex = source.indexOf("reconnectWakeTokenRef.current = null", wakeTokenRefIndex);
const reconnectIndex = source.indexOf("const startReconnect = ");
const hibernatedBranchIndex = source.indexOf('!termRef.current && hibernatedRef.current', reconnectIndex);
@@ -140,12 +141,15 @@ test("reconnect wakes a hibernated terminal before requiring a terminal instance
const wakeJoinIndex = source.indexOf("return wakePromiseRef.current ?? false", source.indexOf("const wakeFromHibernateRuntime"));
const wakeTokenIndex = source.indexOf("const wakeToken = Symbol()", hibernatedBranchIndex);
const staleWakeGuardIndex = source.indexOf("reconnectWakeTokenRef.current !== wakeToken", wakeInvocationIndex);
const staleWakeDisposeIndex = source.indexOf("disposeRuntimeOnly();", staleWakeGuardIndex);
const staleWakeDisposeGuardIndex = source.indexOf('reconnectWakeInvalidateModeRef.current === "dispose"', staleWakeGuardIndex);
const staleWakeDisposeIndex = source.indexOf("disposeRuntimeOnly();", staleWakeDisposeGuardIndex);
const disconnectKeepModeIndex = source.indexOf('reconnectWakeInvalidateModeRef.current = "keep"', source.indexOf("const handleDisconnect"));
const missingTermReturnIndex = source.indexOf("if (!termRef.current) return;", reconnectIndex);
assert.notEqual(wakePromiseRefIndex, -1);
assert.notEqual(wakeGuardRefIndex, -1);
assert.notEqual(wakeTokenRefIndex, -1);
assert.notEqual(wakeInvalidateModeIndex, -1);
assert.notEqual(wakeTokenCleanupIndex, -1);
assert.ok(wakeTokenCleanupIndex < reconnectIndex);
assert.notEqual(reconnectIndex, -1);
@@ -158,7 +162,9 @@ test("reconnect wakes a hibernated terminal before requiring a terminal instance
assert.notEqual(wakeJoinIndex, -1);
assert.notEqual(wakeTokenIndex, -1);
assert.notEqual(staleWakeGuardIndex, -1);
assert.notEqual(staleWakeDisposeGuardIndex, -1);
assert.notEqual(staleWakeDisposeIndex, -1);
assert.notEqual(disconnectKeepModeIndex, -1);
assert.notEqual(missingTermReturnIndex, -1);
assert.ok(
hibernatedBranchIndex < missingTermReturnIndex && wakeCallIndex < missingTermReturnIndex,
@@ -173,9 +179,13 @@ test("reconnect wakes a hibernated terminal before requiring a terminal instance
"closing a terminal must be able to invalidate a pending hibernated reconnect",
);
assert.ok(
staleWakeGuardIndex < staleWakeDisposeIndex,
staleWakeGuardIndex < staleWakeDisposeGuardIndex && staleWakeDisposeGuardIndex < staleWakeDisposeIndex,
"an invalidated hibernated wake must dispose any runtime created after unmount cleanup",
);
assert.ok(
disconnectKeepModeIndex !== -1,
"disconnect must keep a woken runtime so later reconnect still has a terminal instance",
);
});
test("dismissing the disconnected dialog returns focus to the terminal for enter reconnect", () => {
@@ -6,7 +6,7 @@ import {
} from "./createTerminalSessionStarters";
const noop = () => undefined;
const ENCRYPTED_CREDENTIAL_PLACEHOLDER = "enc:v1:djEwAAAA";
const ENCRYPTED_CREDENTIAL_PLACEHOLDER = "enc:v1:djEwdGVzdAAAAAAAAAAAAAAAAA==";
const armSudoPrompt = (
autofill: { armForCommand: (command: string) => void } | null,
@@ -618,7 +618,7 @@ test("startMosh asks for credential re-entry when saved key material cannot be d
keys: [{
id: "key-1",
label: "Deploy key",
privateKey: "enc:v1:djEwAAAA",
privateKey: "enc:v1:djEwdGVzdAAAAAAAAAAAAAAAAA==",
}],
resolvedChainHosts: [],
sessionId: "session-1",
@@ -699,7 +699,7 @@ test("startMosh does not use stale local key paths when selected key material is
keys: [{
id: "key-1",
label: "Deploy key",
privateKey: "enc:v1:djEwAAAA",
privateKey: "enc:v1:djEwdGVzdAAAAAAAAAAAAAAAAA==",
}],
resolvedChainHosts: [],
sessionId: "session-1",
@@ -8,7 +8,7 @@ import {
} from "./createTerminalSessionStarters";
const noop = () => undefined;
const ENCRYPTED_CREDENTIAL_PLACEHOLDER = "enc:v1:djEwAAAA";
const ENCRYPTED_CREDENTIAL_PLACEHOLDER = "enc:v1:djEwdGVzdAAAAAAAAAAAAAAAAA==";
test("startTelnet rejects missing saved proxy profiles", async () => {
let started = false;
@@ -11,7 +11,7 @@ import { pasteTextIntoTerminal } from "./terminalUserPaste";
import { shouldSuppressHostStartupCommandOnReconnect } from "../restoredSessionGate";
const noop = () => undefined;
const ENCRYPTED_CREDENTIAL_PLACEHOLDER = "enc:v1:djEwAAAA";
const ENCRYPTED_CREDENTIAL_PLACEHOLDER = "enc:v1:djEwdGVzdAAAAAAAAAAAAAAAAA==";
const armSudoPrompt = (
autofill: { armForCommand: (command: string) => void } | null,
@@ -569,6 +569,465 @@ test("startSSH rechecks the live automation policy before password fallback", as
assert.equal(commitCount, 0);
});
test("startSSH auth failure after disconnect does not reopen credential UI", async () => {
let resolveStart: ((error: Error) => void) | null = null;
let releaseStartEntered: (() => void) | null = null;
const started = new Promise<void>((resolve) => { releaseStartEntered = resolve; });
const needsAuthCalls: boolean[] = [];
const statuses: string[] = [];
const errors: Array<string | null> = [];
const isBootActiveRef = { current: true };
const bootEpochRef = { current: 1 };
const terminalBackend = {
backendAvailable: () => true,
startSSHSession: async () => {
releaseStartEntered?.();
return await new Promise<string>((_resolve, reject) => {
resolveStart = reject;
});
},
onSessionData: () => noop,
onSessionExit: () => noop,
onChainProgress: () => noop,
writeToSession: noop,
resizeSession: noop,
};
const ctx = createStarterContext({
host: {
id: "host-1",
label: "Target",
hostname: "target.example.test",
username: "alice",
password: "secret",
authMethod: "password",
},
isBootActiveRef,
bootEpochRef,
setNeedsAuth: (value: boolean) => { needsAuthCalls.push(value); },
setStatus: (value: string) => { statuses.push(value); },
setError: (value: string | null) => { errors.push(value); },
updateStatus: (value: string) => { statuses.push(value); },
terminalBackend,
});
const startPromise = createTerminalSessionStarters(ctx as never).startSSH(createTermStub() as never);
await started;
isBootActiveRef.current = false;
bootEpochRef.current += 1;
resolveStart?.(new Error("Authentication failed"));
await startPromise;
assert.deepEqual(needsAuthCalls, []);
assert.deepEqual(statuses, []);
assert.deepEqual(errors, []);
});
test("startSSH stale attempt cannot attach after disconnect then reconnect", async () => {
let resolveStart: ((id: string) => void) | null = null;
let releaseStartEntered: (() => void) | null = null;
const started = new Promise<void>((resolve) => { releaseStartEntered = resolve; });
const attached: string[] = [];
const closed: string[] = [];
const isBootActiveRef = { current: true };
const bootEpochRef = { current: 1 };
const terminalBackend = {
backendAvailable: () => true,
startSSHSession: async () => {
releaseStartEntered?.();
return await new Promise<string>((resolve) => {
resolveStart = resolve;
});
},
closeSession: (id: string, opts?: { bootEpoch?: number }) => {
if (opts?.bootEpoch !== undefined && opts.bootEpoch !== bootEpochRef.current) return;
closed.push(id);
},
onSessionData: () => noop,
onSessionExit: () => noop,
onChainProgress: () => noop,
writeToSession: noop,
resizeSession: noop,
};
const ctx = createStarterContext({
host: {
id: "host-1",
label: "Target",
hostname: "target.example.test",
username: "alice",
password: "secret",
authMethod: "password",
},
isBootActiveRef,
bootEpochRef,
onSessionAttached: (id: string) => { attached.push(id); },
terminalBackend,
});
const startPromise = createTerminalSessionStarters(ctx as never).startSSH(createTermStub() as never);
await started;
// Disconnect then immediately reconnect: boot becomes active again on a new epoch.
bootEpochRef.current += 1;
isBootActiveRef.current = false;
bootEpochRef.current += 1;
isBootActiveRef.current = true;
resolveStart?.("stale-ssh-session");
await startPromise;
assert.deepEqual(attached, []);
// Shared sessionId must not be closed while the replacement reconnect owns it.
assert.deepEqual(closed, []);
});
test("stale startSSH closes orphan only when boot is fully inactive", async () => {
let resolveStart: ((id: string) => void) | null = null;
let releaseStartEntered: (() => void) | null = null;
const started = new Promise<void>((resolve) => { releaseStartEntered = resolve; });
const closed: string[] = [];
const isBootActiveRef = { current: true };
const bootEpochRef = { current: 1 };
const terminalBackend = {
backendAvailable: () => true,
startSSHSession: async () => {
releaseStartEntered?.();
return await new Promise<string>((resolve) => {
resolveStart = resolve;
});
},
// No newer registry owner exists; closing by the attempt's bootEpoch succeeds.
closeSession: (id: string) => { closed.push(id); },
onSessionData: () => noop,
onSessionExit: () => noop,
onChainProgress: () => noop,
writeToSession: noop,
resizeSession: noop,
};
const ctx = createStarterContext({
host: {
id: "host-1",
label: "Target",
hostname: "target.example.test",
username: "alice",
password: "secret",
authMethod: "password",
},
isBootActiveRef,
bootEpochRef,
terminalBackend,
});
const startPromise = createTerminalSessionStarters(ctx as never).startSSH(createTermStub() as never);
await started;
bootEpochRef.current += 1;
isBootActiveRef.current = false;
resolveStart?.("orphan-ssh-session");
await startPromise;
assert.deepEqual(closed, ["orphan-ssh-session"]);
});
test("stale startSSH success does not clear replacement MFA wait state", async () => {
let resolveStart: ((id: string) => void) | null = null;
let releaseStartEntered: (() => void) | null = null;
const started = new Promise<void>((resolve) => { releaseStartEntered = resolve; });
const awaitingUserInput: boolean[] = [];
const closed: string[] = [];
const isBootActiveRef = { current: true };
const bootEpochRef = { current: 1 };
const terminalBackend = {
backendAvailable: () => true,
startSSHSession: async () => {
releaseStartEntered?.();
return await new Promise<string>((resolve) => {
resolveStart = resolve;
});
},
closeSession: (id: string, opts?: { bootEpoch?: number }) => {
if (opts?.bootEpoch !== undefined && opts.bootEpoch !== bootEpochRef.current) return;
closed.push(id);
},
onSessionData: () => noop,
onSessionExit: () => noop,
onChainProgress: () => noop,
writeToSession: noop,
resizeSession: noop,
};
const ctx = createStarterContext({
host: {
id: "host-1",
label: "Target",
hostname: "target.example.test",
username: "alice",
password: "secret",
authMethod: "password",
},
isBootActiveRef,
bootEpochRef,
setIsConnectionAwaitingUserInput: (value: boolean) => { awaitingUserInput.push(value); },
terminalBackend,
});
const startPromise = createTerminalSessionStarters(ctx as never).startSSH(createTermStub() as never);
await started;
awaitingUserInput.length = 0;
bootEpochRef.current += 1;
isBootActiveRef.current = false;
bootEpochRef.current += 1;
isBootActiveRef.current = true;
resolveStart?.("stale-ssh-session");
await startPromise;
assert.deepEqual(awaitingUserInput, []);
assert.deepEqual(closed, []);
});
test("stale startSSH attach failure does not disconnect a newer reconnect", async () => {
let resolveStart: ((id: string) => void) | null = null;
let releaseStartEntered: (() => void) | null = null;
const started = new Promise<void>((resolve) => { releaseStartEntered = resolve; });
const statuses: string[] = [];
const isBootActiveRef = { current: true };
const bootEpochRef = { current: 1 };
const terminalBackend = {
backendAvailable: () => true,
startSSHSession: async () => {
releaseStartEntered?.();
return await new Promise<string>((resolve) => {
resolveStart = resolve;
});
},
closeSession: noop,
onSessionData: () => noop,
onSessionExit: () => noop,
onChainProgress: () => noop,
writeToSession: noop,
resizeSession: noop,
};
const ctx = createStarterContext({
host: {
id: "host-1",
label: "Target",
hostname: "target.example.test",
username: "alice",
password: "secret",
authMethod: "password",
},
isBootActiveRef,
bootEpochRef,
updateStatus: (value: string) => { statuses.push(value); },
setStatus: (value: string) => { statuses.push(value); },
terminalBackend,
});
const startPromise = createTerminalSessionStarters(ctx as never).startSSH(createTermStub() as never);
await started;
bootEpochRef.current += 1;
isBootActiveRef.current = false;
bootEpochRef.current += 1;
isBootActiveRef.current = true;
statuses.length = 0;
resolveStart?.("stale-ssh-session");
await startPromise;
assert.deepEqual(statuses, []);
});
test("stale startSSH failure does not reset replacement reconnect UI state", async () => {
let rejectStart: ((error: Error) => void) | null = null;
let releaseStartEntered: (() => void) | null = null;
const started = new Promise<void>((resolve) => { releaseStartEntered = resolve; });
const awaitingUserInput: boolean[] = [];
const pastTcpDial: boolean[] = [];
const chainProgress: Array<unknown> = [];
let unsubscribed = false;
const isBootActiveRef = { current: true };
const bootEpochRef = { current: 1 };
const terminalBackend = {
backendAvailable: () => true,
startSSHSession: async () => {
releaseStartEntered?.();
return await new Promise<string>((_resolve, reject) => {
rejectStart = reject;
});
},
onSessionData: () => noop,
onSessionExit: () => noop,
onChainProgress: () => () => { unsubscribed = true; },
writeToSession: noop,
resizeSession: noop,
};
const ctx = createStarterContext({
host: {
id: "host-1",
label: "Target",
hostname: "target.example.test",
username: "alice",
password: "secret",
authMethod: "password",
},
isBootActiveRef,
bootEpochRef,
setIsConnectionAwaitingUserInput: (value: boolean) => { awaitingUserInput.push(value); },
setIsConnectionPastTcpDial: (value: boolean) => { pastTcpDial.push(value); },
setChainProgress: (value: unknown) => { chainProgress.push(value); },
terminalBackend,
});
const startPromise = createTerminalSessionStarters(ctx as never).startSSH(createTermStub() as never);
await started;
// Replacement reconnect is waiting for keyboard-interactive input.
awaitingUserInput.length = 0;
pastTcpDial.length = 0;
chainProgress.length = 0;
bootEpochRef.current += 1;
isBootActiveRef.current = false;
bootEpochRef.current += 1;
isBootActiveRef.current = true;
rejectStart?.(new Error("Authentication failed"));
await startPromise;
assert.equal(unsubscribed, true);
assert.deepEqual(awaitingUserInput, []);
assert.deepEqual(pastTcpDial, []);
assert.deepEqual(chainProgress, []);
});
test("stale startSSH key auth failure does not launch password fallback", async () => {
let rejectStart: ((error: Error) => void) | null = null;
let releaseStartEntered: (() => void) | null = null;
const started = new Promise<void>((resolve) => { releaseStartEntered = resolve; });
const startCalls: Array<Record<string, unknown>> = [];
const progressLogs: string[] = [];
const isBootActiveRef = { current: true };
const bootEpochRef = { current: 1 };
const terminalBackend = {
backendAvailable: () => true,
startSSHSession: async (options: Record<string, unknown>) => {
startCalls.push(options);
releaseStartEntered?.();
return await new Promise<string>((_resolve, reject) => {
rejectStart = reject;
});
},
onSessionData: () => noop,
onSessionExit: () => noop,
onChainProgress: () => noop,
writeToSession: noop,
resizeSession: noop,
};
const ctx = createStarterContext({
host: {
id: "host-1",
label: "Target",
hostname: "target.example.test",
username: "alice",
authMethod: "key",
identityFileId: "key-1",
password: "login-secret",
},
keys: [{
id: "key-1",
name: "Key",
privateKey: "plain-private-key",
publicKey: "",
source: "embedded",
}],
isBootActiveRef,
bootEpochRef,
setProgressLogs: (updater: string[] | ((prev: string[]) => string[])) => {
const next = typeof updater === "function" ? updater(progressLogs) : updater;
progressLogs.splice(0, progressLogs.length, ...next);
},
terminalBackend,
});
const startPromise = createTerminalSessionStarters(ctx as never).startSSH(createTermStub() as never);
await started;
bootEpochRef.current += 1;
isBootActiveRef.current = false;
rejectStart?.(new Error("Authentication failed"));
await startPromise;
assert.equal(startCalls.length, 1);
assert.equal(startCalls[0].password, "login-secret");
assert.equal(startCalls[0].bootEpoch, 1);
assert.equal(
progressLogs.includes("Key auth failed. Trying password..."),
false,
);
});
test("stale startSSH chain progress does not overwrite replacement reconnect UI", async () => {
let chainProgressListener:
| ((sessionId: string, hop: number, total: number, label: string, status: string, error?: string) => void)
| null = null;
let releaseStartEntered: (() => void) | null = null;
const started = new Promise<void>((resolve) => { releaseStartEntered = resolve; });
const awaitingUserInput: boolean[] = [];
const chainProgress: unknown[] = [];
const isBootActiveRef = { current: true };
const bootEpochRef = { current: 1 };
const terminalBackend = {
backendAvailable: () => true,
startSSHSession: async () => {
releaseStartEntered?.();
return await new Promise<string>(() => {});
},
onSessionData: () => noop,
onSessionExit: () => noop,
onChainProgress: (
listener: (sessionId: string, hop: number, total: number, label: string, status: string, error?: string) => void,
) => {
chainProgressListener = listener;
return noop;
},
writeToSession: noop,
resizeSession: noop,
};
const ctx = createStarterContext({
host: {
id: "host-1",
label: "Target",
hostname: "target.example.test",
username: "alice",
password: "secret",
authMethod: "password",
hostChain: { hostIds: ["jump-1"] },
},
resolvedChainHosts: [{
id: "jump-1",
label: "Jump",
hostname: "jump.example.test",
username: "jump",
password: "jump-secret",
}],
isBootActiveRef,
bootEpochRef,
setIsConnectionAwaitingUserInput: (value: boolean) => { awaitingUserInput.push(value); },
setChainProgress: (value: unknown) => { chainProgress.push(value); },
terminalBackend,
});
void createTerminalSessionStarters(ctx as never).startSSH(createTermStub() as never);
await started;
awaitingUserInput.length = 0;
chainProgress.length = 0;
bootEpochRef.current += 1;
isBootActiveRef.current = false;
bootEpochRef.current += 1;
isBootActiveRef.current = true;
chainProgressListener?.(
"session-1",
1,
2,
"jump.example.test",
"auth-attempt",
"waiting for user input...",
);
assert.deepEqual(awaitingUserInput, []);
assert.deepEqual(chainProgress, []);
});
test("startSSH keeps interactive source auth retries off unrelated pooled transports", async () => {
const captured: Record<string, unknown>[] = [];
const terminalBackend = {
@@ -665,7 +1124,7 @@ test("startSSH uses the system agent when a synced vault key cannot be decrypted
label: "Synced key",
type: "ED25519",
publicKey: "ssh-ed25519 AAAASELECTED",
privateKey: "enc:v1:djEwAAAA",
privateKey: "enc:v1:djEwdGVzdAAAAAAAAAAAAAAAAA==",
source: "imported",
category: "key",
created: 1,
@@ -3,6 +3,7 @@ import type { ProviderValidationIssue } from "@netcatty/plugin-contract";
import { logger } from "../../../lib/logger";
import type { Host, SSHKey } from "../../../types";
import type { TerminalSessionExitEvent } from "../../../application/state/resolveTerminalSessionExitIntent";
import { setTerminalBootEpoch } from "../../../domain/terminalBootEpoch";
import type { TerminalSessionStartersContext } from "./createTerminalSessionStarters.types";
export type {
PendingAuth,
@@ -16,8 +17,8 @@ import {
attachSessionToTerminal,
buildTermEnv,
closeOrphanBackendSession,
createBootAttemptGuard,
getFlowController,
isTerminalBootActive,
notePendingOutputScrollIfEnabled,
resetTerminalLineTimestampState,
tryAttachSessionToTerminal,
@@ -124,6 +125,16 @@ export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContex
ctx.setChainProgress(null);
};
const createAttemptGuards = () => {
const bootEpoch = ctx.bootEpochRef?.current ?? 0;
const isCurrentAttempt = createBootAttemptGuard(ctx);
return {
bootEpoch,
isCurrentAttempt,
ignoreStaleAttemptUi: () => !isCurrentAttempt(),
};
};
const consumeRestoreCwdIntent = (term: XTerm, id: string): void => {
const intent = ctx.restoreCwdIntentRef?.current;
if (!intent) return;
@@ -186,6 +197,10 @@ export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContex
};
const startSSH = async (term: XTerm) => {
const { isCurrentAttempt, ignoreStaleAttemptUi, bootEpoch } = createAttemptGuards();
// Correlate host-key prompts with this boot so a superseded start cannot
// reopen approval UI after disconnect → reconnect.
setTerminalBootEpoch(ctx.sessionId, bootEpoch);
if (!ctx.terminalBackend.backendAvailable()) {
ctx.setError("Native SSH bridge unavailable. Launch via Electron app.");
writeTerminalLine(
@@ -439,6 +454,9 @@ export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContex
const unsub = ctx.terminalBackend.onChainProgress((sid, hop, total, label, status, error) => {
// P1: Only process events for this session
if (sid !== ctx.sessionId) return;
// Disconnect/reconnect can leave two SSH starts sharing this UI
// sessionId; ignore progress from the superseded boot attempt.
if (!isCurrentAttempt()) return;
// P3: Only show chain progress UI for multi-hop connections
if (total > 1) {
@@ -599,6 +617,7 @@ export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContex
sshTcpConnectTimeoutMs: connectionTimeouts.tcpConnectTimeoutSeconds * 1000,
sshAuthReadyTimeoutMs: connectionTimeouts.authReadyTimeoutSeconds * 1000,
verifyHostKeys: globalTerminalSettings.verifyHostKeys,
bootEpoch,
sessionLog: ctx.sessionLog?.enabled ? ctx.sessionLog : undefined,
sshDebugLogEnabled: ctx.sshDebugLogEnabled,
identityFilePaths: attempt.useIdentityFiles ? targetIdentityFilePaths : undefined,
@@ -686,6 +705,9 @@ export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContex
id = await startAttempt({ key, password: hasPassword ? effectivePassword : undefined, useIdentityFiles: true });
} catch (err) {
if (isAuthError(err) && hasPassword) {
// Disconnect/reconnect may have invalidated this boot; do not
// launch a password fallback that cannot be cleaned up yet.
if (!isCurrentAttempt()) throw err;
ctx.setProgressLogs((prev) => [
...prev,
"Key auth failed. Trying password...",
@@ -700,16 +722,27 @@ export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContex
}
if (unsubscribeChainProgress) unsubscribeChainProgress();
// A superseded start may still resolve after reconnect; do not clear the
// replacement's MFA wait / connection-timeout state, and do not close the
// shared sessionId while the newer boot is active.
if (!isCurrentAttempt()) {
closeOrphanBackendSession(ctx, id, { bootEpoch });
return;
}
ctx.setIsConnectionAwaitingUserInput?.(false);
if (!tryAttachSessionToTerminal(ctx, term, id, {
isCurrentAttempt,
bootEpoch,
onConnected: () => ctx.setChainProgress(null),
onExitMessage: (evt) =>
`\r\n[session closed${evt?.exitCode !== undefined ? ` (code ${evt.exitCode})` : ""}]`,
sudoAutofillPassword: resolveSavedSudoAutofillPassword(),
sudoAutofillCandidates: resolveSudoAutofillCandidates(),
})) {
abortSessionStartAfterUnmount();
// Only the current attempt may clear UI; a stale attach must not
// disconnect a newer reconnect that already re-armed boot.
if (isCurrentAttempt()) abortSessionStartAfterUnmount();
return;
}
@@ -730,6 +763,14 @@ export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContex
}, 600);
}
} catch (err) {
// Always drop this attempt's progress listener; only the current
// boot may reset shared reconnect/UI state that a replacement owns.
if (unsubscribeChainProgress) unsubscribeChainProgress();
if (ignoreStaleAttemptUi()) return;
ctx.setChainProgress(null);
ctx.setIsConnectionAwaitingUserInput?.(false);
ctx.setIsConnectionPastTcpDial?.(false);
const message = err instanceof Error ? err.message : String(err);
const authError = isAuthError(err);
@@ -763,15 +804,11 @@ export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContex
writeTerminalLine(ctx, term, `\r\n[Failed to start SSH: ${message}]`);
ctx.updateStatus("disconnected");
}
ctx.setChainProgress(null);
ctx.setIsConnectionAwaitingUserInput?.(false);
ctx.setIsConnectionPastTcpDial?.(false);
if (unsubscribeChainProgress) unsubscribeChainProgress();
}
};
const startTelnet = async (term: XTerm) => {
const { isCurrentAttempt, ignoreStaleAttemptUi, bootEpoch } = createAttemptGuards();
if (!ctx.terminalBackend.telnetAvailable()) {
ctx.setError("Telnet bridge unavailable. Please run the desktop build.");
writeTerminalLine(ctx, term, "\r\n[Telnet bridge unavailable. Please run the desktop build.]");
@@ -879,7 +916,14 @@ export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContex
if (waitsForAutoLogin) {
disposeAutoLoginComplete = ctx.terminalBackend.onTelnetAutoLoginComplete?.(
ctx.sessionId,
() => {
(evt) => {
if (
Number.isFinite(bootEpoch)
&& Number.isFinite(evt?.bootEpoch)
&& evt.bootEpoch !== bootEpoch
) {
return;
}
disposeAutoLoginListener();
cancelPendingStartupCommand = scheduleStartupCommand(ctx, term, telnetSessionId, () => {
cancelPendingStartupCommand = undefined;
@@ -889,7 +933,16 @@ export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContex
);
disposeAutoLoginCancelled = ctx.terminalBackend.onTelnetAutoLoginCancelled?.(
ctx.sessionId,
cleanupTelnetStartupWait,
(evt) => {
if (
Number.isFinite(bootEpoch)
&& Number.isFinite(evt?.bootEpoch)
&& evt.bootEpoch !== bootEpoch
) {
return;
}
cleanupTelnetStartupWait();
},
);
}
attachTelnetEchoMode(ctx.sessionId);
@@ -905,6 +958,7 @@ export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContex
charset: ctx.host.charset,
env: telnetEnv,
sessionLog: ctx.sessionLog?.enabled ? ctx.sessionLog : undefined,
bootEpoch,
});
telnetSessionId = id;
if (id !== ctx.sessionId) {
@@ -912,12 +966,14 @@ export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContex
}
if (!tryAttachSessionToTerminal(ctx, term, id, {
isCurrentAttempt,
bootEpoch,
onExitMessage: (evt) =>
`\r\n[Telnet session closed${evt?.exitCode !== undefined ? ` (code ${evt.exitCode})` : ""}]`,
onExit: cleanupTelnetSession,
})) {
cleanupTelnetSession();
abortSessionStartAfterUnmount();
if (isCurrentAttempt()) abortSessionStartAfterUnmount();
return;
}
@@ -938,6 +994,7 @@ export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContex
return;
}
cleanupTelnetSession();
if (ignoreStaleAttemptUi()) return;
ctx.setError(message);
writeTerminalLine(ctx, term, `\r\n[Failed to start Telnet: ${message}]`);
ctx.updateStatus("disconnected");
@@ -945,6 +1002,7 @@ export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContex
};
const startMosh = async (term: XTerm) => {
const { isCurrentAttempt, ignoreStaleAttemptUi, bootEpoch } = createAttemptGuards();
if (!ctx.terminalBackend.moshAvailable()) {
ctx.setError("Mosh bridge unavailable. Please run the desktop build.");
writeTerminalLine(ctx, term, "\r\n[Mosh bridge unavailable. Please run the desktop build.]");
@@ -1083,7 +1141,14 @@ export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContex
cancelPendingStartupCommand = undefined;
});
};
const onMoshReady = () => {
const onMoshReady = (evt?: { sessionId: string; bootEpoch?: number }) => {
if (
Number.isFinite(bootEpoch)
&& Number.isFinite(evt?.bootEpoch)
&& evt.bootEpoch !== bootEpoch
) {
return;
}
moshReadyFired = true;
if (sessionAttached) {
runMoshStartup();
@@ -1129,10 +1194,13 @@ export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContex
charset: ctx.host.charset,
env: moshEnv,
sessionLog: ctx.sessionLog?.enabled ? ctx.sessionLog : undefined,
bootEpoch,
});
attachedSessionId = id;
if (!tryAttachSessionToTerminal(ctx, term, id, {
isCurrentAttempt,
bootEpoch,
onExitMessage: (evt) =>
`\r\n[Mosh session closed${evt?.exitCode !== undefined ? ` (code ${evt.exitCode})` : ""}]`,
// Real backend exit only — do not chain onto disposeExitRef, because
@@ -1143,7 +1211,7 @@ export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContex
sudoAutofillCandidates: resolveSudoAutofillCandidates(),
})) {
cleanupMoshStartupWait();
abortSessionStartAfterUnmount();
if (isCurrentAttempt()) abortSessionStartAfterUnmount();
return;
}
sessionAttached = true;
@@ -1162,6 +1230,7 @@ export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContex
disposeMoshReady = undefined;
cancelPendingStartupCommand?.();
cancelPendingStartupCommand = undefined;
if (ignoreStaleAttemptUi()) return;
const message = err instanceof Error ? err.message : String(err);
ctx.setError(message);
writeTerminalLine(ctx, term, `\r\n[Failed to start Mosh: ${message}]`);
@@ -1170,6 +1239,7 @@ export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContex
};
const startEt = async (term: XTerm) => {
const { isCurrentAttempt, ignoreStaleAttemptUi, bootEpoch } = createAttemptGuards();
if (!ctx.terminalBackend.etAvailable()) {
ctx.setError("EternalTerminal bridge unavailable. Please run the desktop build.");
writeTerminalLine(ctx, term, "\r\n[EternalTerminal bridge unavailable. Please run the desktop build.]");
@@ -1439,15 +1509,20 @@ export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContex
charset: ctx.host.charset,
env: etEnv,
sessionLog: ctx.sessionLog?.enabled ? ctx.sessionLog : undefined,
bootEpoch,
});
if (!tryAttachSessionToTerminal(ctx, term, id, {
isCurrentAttempt,
bootEpoch,
onExitMessage: (evt) =>
`\r\n[EternalTerminal session closed${evt?.exitCode !== undefined ? ` (code ${evt.exitCode})` : ""}]`,
sudoAutofillPassword: resolveSavedSudoAutofillPassword(),
sudoAutofillCandidates: resolveSudoAutofillCandidates(),
})) {
abortSessionStartAfterUnmount();
// Only the current attempt may clear UI; a stale attach must not
// disconnect a newer reconnect that already re-armed boot.
if (isCurrentAttempt()) abortSessionStartAfterUnmount();
return;
}
@@ -1463,6 +1538,7 @@ export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContex
}, 600);
}
} catch (err) {
if (ignoreStaleAttemptUi()) return;
const message = err instanceof Error ? err.message : String(err);
ctx.setError(message);
writeTerminalLine(ctx, term, `\r\n[Failed to start EternalTerminal: ${message}]`);
@@ -1471,6 +1547,7 @@ export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContex
};
const startPluginConnection = async (term: XTerm) => {
const { isCurrentAttempt, ignoreStaleAttemptUi, bootEpoch } = createAttemptGuards();
if (!ctx.terminalBackend.pluginConnectionAvailable()) {
ctx.setError("Plugin connection bridge unavailable. Please run the desktop build with Plugin Development enabled.");
writeTerminalLine(ctx, term, "\r\n[Plugin connection bridge unavailable.]");
@@ -1508,9 +1585,12 @@ export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContex
startController.abort(new DOMException("Plugin connection request was cancelled", "AbortError"));
if (ctx.terminalBackend.cancelPluginExtensionRequest) {
try {
void Promise.resolve(ctx.terminalBackend.cancelPluginExtensionRequest(requestId)).catch((err) => {
const cancelPromise = Promise.resolve(
ctx.terminalBackend.cancelPluginExtensionRequest(requestId),
).catch((err) => {
logger.warn("Failed to cancel pending plugin connection request", err);
});
ctx.trackSessionCleanup?.(cancelPromise);
} catch (err) {
logger.warn("Failed to cancel pending plugin connection request", err);
}
@@ -1521,7 +1601,7 @@ export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContex
bootMonitorTimer = setTimeout(() => {
bootMonitorTimer = null;
if (pendingCancelReleased) return;
if (!isTerminalBootActive(ctx)) {
if (!isCurrentAttempt()) {
cancelPendingStart();
return;
}
@@ -1538,7 +1618,7 @@ export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContex
ctx.disposeExitRef.current = cancelPendingStart;
scheduleBootMonitor();
try {
const opened = await ctx.terminalBackend.startPluginConnection({
const startPromise = ctx.terminalBackend.startPluginConnection({
requestId,
sessionId: ctx.sessionId,
protocol: ctx.host.protocol,
@@ -1557,9 +1637,17 @@ export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContex
: {}),
signal: startController.signal,
});
// Disconnect may cancel before this settles; reconnect must wait until
// finishExternalSession cleared the shared sessionId registration.
ctx.trackSessionCleanup?.(startPromise);
const opened = await startPromise;
releasePendingStartCancellation();
if (startController.signal.aborted || !isTerminalBootActive(ctx)) {
closeOrphanBackendSession(ctx, opened.sessionId);
if (!isCurrentAttempt()) {
closeOrphanBackendSession(ctx, opened.sessionId, { bootEpoch });
return;
}
if (startController.signal.aborted) {
closeOrphanBackendSession(ctx, opened.sessionId, { bootEpoch });
abortSessionStartAfterUnmount();
return;
}
@@ -1577,13 +1665,17 @@ export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContex
scheduleStartupCommand(ctx, term, id);
};
if (!tryAttachSessionToTerminal(ctx, term, id, {
isCurrentAttempt,
bootEpoch,
onExitMessage: formatPluginConnectionExitMessage,
requireExplicitConnectionReady: true,
onConnected: (meta) => {
if (meta?.pluginConnectionReady === true) schedulePluginStartup();
},
})) {
abortSessionStartAfterUnmount();
// Only the current attempt may clear UI; a stale attach must not
// disconnect a newer reconnect that already re-armed boot.
if (isCurrentAttempt()) abortSessionStartAfterUnmount();
return;
}
if (opened.status === "connected") {
@@ -1592,10 +1684,7 @@ export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContex
}
} catch (error) {
releasePendingStartCancellation();
if (!isTerminalBootActive(ctx)) {
abortSessionStartAfterUnmount();
return;
}
if (ignoreStaleAttemptUi()) return;
const message = error instanceof Error ? error.message : String(error);
ctx.setError(message);
writeTerminalLine(ctx, term, "\r\n[Failed to start plugin connection. See connection details.]");
@@ -1604,6 +1693,7 @@ export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContex
};
const startLocal = async (term: XTerm) => {
const { isCurrentAttempt, ignoreStaleAttemptUi, bootEpoch } = createAttemptGuards();
if (!ctx.terminalBackend.localAvailable()) {
ctx.setError("Local shell bridge unavailable. Please run the desktop build.");
writeTerminalLine(
@@ -1636,10 +1726,11 @@ export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContex
TERM: ctx.terminalSettings?.terminalEmulationType ?? "xterm-256color",
},
sessionLog: ctx.sessionLog?.enabled ? ctx.sessionLog : undefined,
bootEpoch,
});
if (!isTerminalBootActive(ctx)) {
closeOrphanBackendSession(ctx, id);
if (!isCurrentAttempt()) {
closeOrphanBackendSession(ctx, id, { bootEpoch });
return;
}
@@ -1714,6 +1805,7 @@ export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContex
consumeRestoreCwdIntent(term, id);
scheduleStartupCommand(ctx, term, id);
} catch (err) {
if (ignoreStaleAttemptUi()) return;
const message = err instanceof Error ? err.message : String(err);
ctx.setError(message);
writeTerminalLine(ctx, term, `\r\n[Failed to start local shell: ${message}]`);
@@ -1723,6 +1815,7 @@ export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContex
// Start Serial session
const startSerial = async (term: XTerm) => {
const { isCurrentAttempt, ignoreStaleAttemptUi, bootEpoch } = createAttemptGuards();
if (!ctx.serialConfig) {
ctx.setError("No serial configuration provided");
writeTerminalLine(ctx, term, "\r\n[Error: No serial configuration provided]");
@@ -1746,15 +1839,20 @@ export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContex
flowControl: ctx.serialConfig.flowControl,
charset: ctx.host.charset,
sessionLog: ctx.sessionLog?.enabled ? ctx.sessionLog : undefined,
bootEpoch,
});
if (!tryAttachSessionToTerminal(ctx, term, id, {
isCurrentAttempt,
bootEpoch,
onExitMessage: (evt) =>
`\r\n[serial port closed${evt?.exitCode !== undefined ? ` (code ${evt.exitCode})` : ""}]`,
// Convert lone LF to CRLF to prevent "staircase effect" in serial terminals
convertLfToCrlf: true,
})) {
abortSessionStartAfterUnmount();
// Only the current attempt may clear UI; a stale attach must not
// disconnect a newer reconnect that already re-armed boot.
if (isCurrentAttempt()) abortSessionStartAfterUnmount();
return;
}
@@ -1763,6 +1861,7 @@ export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContex
ctx.setProgressValue(100);
writeTerminalLine(ctx, term, `[Connected to ${ctx.serialConfig.path} at ${ctx.serialConfig.baudRate} baud]`);
} catch (err) {
if (ignoreStaleAttemptUi()) return;
const message = err instanceof Error ? err.message : String(err);
ctx.setError(message);
writeTerminalLine(ctx, term, `\r\n[Failed to connect to serial port: ${message}]`);
@@ -73,15 +73,15 @@ export type TerminalBackendApi = {
) => () => void;
onTelnetAutoLoginComplete?: (
sessionId: string,
cb: (evt: { sessionId: string }) => void,
cb: (evt: { sessionId: string; bootEpoch?: number }) => void,
) => (() => void) | undefined;
onTelnetAutoLoginCancelled?: (
sessionId: string,
cb: (evt: { sessionId: string }) => void,
cb: (evt: { sessionId: string; bootEpoch?: number }) => void,
) => (() => void) | undefined;
onMoshSessionReady?: (
sessionId: string,
cb: (evt: { sessionId: string }) => void,
cb: (evt: { sessionId: string; bootEpoch?: number }) => void,
) => (() => void) | undefined;
onTelnetEchoMode?: (
sessionId: string,
@@ -103,7 +103,7 @@ export type TerminalBackendApi = {
writeToSession: (sessionId: string, data: string, options?: { automated?: boolean; sensitive?: boolean; lineDelayMs?: number; logRewrite?: ProgrammaticCommandLogRewrite }) => void;
interruptSession?: (sessionId: string, trace?: NetcattyTerminalInterruptTrace) => void;
resizeSession: (sessionId: string, cols: number, rows: number) => void;
closeSession: (sessionId: string) => void | Promise<void>;
closeSession: (sessionId: string, options?: { bootEpoch?: number }) => void | Promise<void>;
/** Pause/resume the source stream for output back-pressure (optional). */
setSessionFlowPaused?: (sessionId: string, paused: boolean) => void;
/** Acknowledge rendered terminal output bytes for main-process IPC back-pressure. */
@@ -189,6 +189,11 @@ export type TerminalSessionStartersContext = {
isVisibleRef?: RefObject<boolean>;
/** False after unmount/teardown so in-flight session starts skip attach. */
isBootActiveRef?: RefObject<boolean>;
/**
* Monotonic boot epoch. Disconnect / a newer reconnect bumps this so an
* older in-flight start cannot become current again when boot is re-armed.
*/
bootEpochRef?: RefObject<number>;
pendingOutputScrollRef?: RefObject<boolean>;
sessionRef: RefObject<string | null>;
@@ -196,6 +201,11 @@ export type TerminalSessionStartersContext = {
hasRunStartupCommandRef: RefObject<boolean>;
disposeDataRef: RefObject<(() => void) | null>;
disposeExitRef: RefObject<(() => void) | null>;
/**
* Track an async cleanup (e.g. cancelled plugin start → finishExternalSession)
* so Disconnect/Reconnect can await it before starting a replacement boot.
*/
trackSessionCleanup?: (promise: Promise<unknown>) => void;
disposeTelnetEchoModeRef?: RefObject<(() => void) | null>;
fitAddonRef: RefObject<FitAddon | null>;
serializeAddonRef: RefObject<SerializeAddon | null>;
@@ -1476,8 +1476,8 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
) {
return true;
}
// When copy is bound specifically to Ctrl+C and there is no text
// selected, pass the event through so xterm can send SIGINT.
// No xterm selection: pass Ctrl+C through for SIGINT, and Cmd+C for
// Kitty Super+C (nested TUIs). Other copy chords stay a safe no-op.
const shouldForwardCopyToTerminal =
shouldPassThroughCopyShortcut(action, term.hasSelection(), e);
if (shouldForwardCopyToTerminal && !kittySequenceForKeyDown) return true;
@@ -1494,24 +1494,10 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
break;
}
case "paste": {
const autoImagePaste =
ctx.terminalSettingsRef.current?.autoUploadClipboardImageOnPaste === true;
if (autoImagePaste) {
// onPaste (shared with the context menu) auto-uploads a
// clipboard image via SFTP for remote sessions and falls
// back to a regular text paste when there is no image.
void ctx.terminalContextActionsRef?.current?.onPaste?.();
break;
}
navigator.clipboard.readText().then((text) => {
const id = ctx.sessionRef.current;
if (id) {
pasteTextIntoTerminal(term, text, {
scrollOnPaste: shouldScrollOnTerminalPaste(ctx.terminalSettingsRef.current),
onPasteData: broadcastUserPasteData,
});
}
});
// Always share the context-menu paste path so local image-only
// clipboard can forward Ctrl+V, and remote auto-upload stays
// gated inside handleTerminalClipboardPaste.
void ctx.terminalContextActionsRef?.current?.onPaste?.();
break;
}
case "pasteSelection": {
@@ -3,6 +3,7 @@ import test from "node:test";
import {
isPlainCtrlCInterruptChord,
isPlainMetaCCopyChord,
shouldPassThroughCopyShortcut,
} from "./terminalCopyShortcut.ts";
@@ -27,10 +28,20 @@ test("plain Ctrl+C copy with no selection passes through for SIGINT", () => {
assert.equal(shouldPassThroughCopyShortcut("copy", false, event), true);
});
test("copy shortcut does not pass through when text is selected", () => {
const event = keyboardEvent("c", "KeyC", { ctrlKey: true });
test("plain Cmd+C copy with no selection passes through for Kitty Super+C", () => {
const event = keyboardEvent("c", "KeyC", { metaKey: true });
assert.equal(shouldPassThroughCopyShortcut("copy", true, event), false);
assert.equal(isPlainMetaCCopyChord(event), true);
assert.equal(isPlainCtrlCInterruptChord(event), false);
assert.equal(shouldPassThroughCopyShortcut("copy", false, event), true);
});
test("copy shortcut does not pass through when text is selected", () => {
const ctrlC = keyboardEvent("c", "KeyC", { ctrlKey: true });
const cmdC = keyboardEvent("c", "KeyC", { metaKey: true });
assert.equal(shouldPassThroughCopyShortcut("copy", true, ctrlC), false);
assert.equal(shouldPassThroughCopyShortcut("copy", true, cmdC), false);
});
test("copy shortcut does not pass through for shifted or alternate chords", () => {
@@ -38,14 +49,26 @@ test("copy shortcut does not pass through for shifted or alternate chords", () =
shouldPassThroughCopyShortcut("copy", false, keyboardEvent("C", "KeyC", { ctrlKey: true, shiftKey: true })),
false,
);
assert.equal(
shouldPassThroughCopyShortcut("copy", false, keyboardEvent("C", "KeyC", { metaKey: true, shiftKey: true })),
false,
);
assert.equal(
shouldPassThroughCopyShortcut("copy", false, keyboardEvent("l", "KeyL", { ctrlKey: true })),
false,
);
assert.equal(
shouldPassThroughCopyShortcut("copy", false, keyboardEvent("c", "KeyC", { metaKey: true, ctrlKey: true })),
false,
);
assert.equal(
shouldPassThroughCopyShortcut("paste", false, keyboardEvent("c", "KeyC", { ctrlKey: true })),
false,
);
assert.equal(
shouldPassThroughCopyShortcut("paste", false, keyboardEvent("c", "KeyC", { metaKey: true })),
false,
);
});
test("plain Ctrl+C copy passthrough follows the physical C key on non-Latin layouts", () => {
@@ -54,3 +77,10 @@ test("plain Ctrl+C copy passthrough follows the physical C key on non-Latin layo
assert.equal(isPlainCtrlCInterruptChord(event), true);
assert.equal(shouldPassThroughCopyShortcut("copy", false, event), true);
});
test("plain Cmd+C copy passthrough follows the physical C key on non-Latin layouts", () => {
const event = keyboardEvent("\u0441", "KeyC", { metaKey: true });
assert.equal(isPlainMetaCCopyChord(event), true);
assert.equal(shouldPassThroughCopyShortcut("copy", false, event), true);
});
@@ -3,18 +3,43 @@ type CopyShortcutKeyEvent = Pick<
"key" | "code" | "ctrlKey" | "shiftKey" | "altKey" | "metaKey"
>;
function isPhysicalCKey(e: CopyShortcutKeyEvent): boolean {
return e.key.toLowerCase() === "c" || e.code === "KeyC";
}
export function isPlainCtrlCInterruptChord(e: CopyShortcutKeyEvent): boolean {
return e.ctrlKey
&& !e.shiftKey
&& !e.altKey
&& !e.metaKey
&& (e.key.toLowerCase() === "c" || e.code === "KeyC");
&& isPhysicalCKey(e);
}
/** macOS Cmd+C / Super+C — forward when there is no xterm selection. */
export function isPlainMetaCCopyChord(e: CopyShortcutKeyEvent): boolean {
return e.metaKey
&& !e.ctrlKey
&& !e.shiftKey
&& !e.altKey
&& isPhysicalCKey(e);
}
/**
* When copy matches with no xterm selection, pass the chord through instead of
* consuming an empty clipboard write.
*
* - Ctrl+C → SIGINT (or Kitty Ctrl+C)
* - Cmd+C → Kitty Super+C for nested TUIs (e.g. Herdr)
*
* Other no-selection copy bindings stay consumed as a safe no-op so keys like
* F5 / Ctrl+L are not forwarded to the remote (#1461).
*/
export function shouldPassThroughCopyShortcut(
action: string,
hasSelection: boolean,
e: CopyShortcutKeyEvent,
): boolean {
return action === "copy" && !hasSelection && isPlainCtrlCInterruptChord(e);
return action === "copy"
&& !hasSelection
&& (isPlainCtrlCInterruptChord(e) || isPlainMetaCCopyChord(e));
}
Loaded 100 of 231 files, more files were not shown because too many files have changed in this diff. Show more