Compare commits

..
Author SHA1 Message Date
Cursor Agentand陈大猫 0d6ef5f701 fix(known-hosts): make virtualized grid cards keyboard-focusable
Grid HostItem roots now set data-vault-focus-target + tabIndex like list
mode so VirtualizedHostCollection arrow-key focus works at 30+ hosts.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-08-07 06:19:03 +00:00
Cursor Agentand陈大猫 6e858a7e4e merge(base): notesStore sync publish from #2790
Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-08-07 06:18:52 +00:00
Cursor Agentand陈大猫 1acd80338e fix(notes): publish notesStore sync on vault note updates
updateNotes/updateNoteGroups now publishNotesSnapshot immediately so
AI/notes consumers do not briefly read a stale catalog after a hide
flush. Flush inactive drafts in useLayoutEffect before paint.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-08-07 06:18:43 +00:00
Cursor Agentand陈大猫 a10c043705 merge(base): lint + notes hide flush from #2790
Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-08-07 06:06:00 +00:00
Cursor Agentand陈大猫 abe0846a65 fix: clear lint blockers and flush notes when panels hide
Move AI panel diagnostics persistence into application/state so
components stop importing localStorageAdapter. Flush note drafts when
retained NotesManager mounts become inactive, and silence related lint
warnings in theme/host-key helpers.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-08-07 06:05:59 +00:00
Cursor Agentand陈大猫 4cedaf482d test: restore known-hosts column policy assertions
Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-08-07 05:58:26 +00:00
Cursor Agentand陈大猫 fd545e6577 fix: make virtualized known hosts keyboard-focusable
HostItem cards now set data-vault-focus-target and tabIndex so
VirtualizedHostCollection arrow/Home/End navigation can focus them.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-08-07 05:58:12 +00:00
Cursor Agentand陈大猫 12202a1757 merge: clear remaining application→UI imports from base
Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-08-07 05:51:59 +00:00
Cursor Agentand陈大猫 4cdcf5dc4c fix: move remaining migrated-hook deps out of components
Host-key verification types/helpers now live in domain/, and SFTP column
layout types live in application/state/sftp/columnLayout. Application
hooks no longer import those definitions from the UI layer.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-08-07 05:51:58 +00:00
Cursor Agentand陈大猫 780f967177 merge: remove UI imports from migrated AI/system state hooks
Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-08-07 05:45:12 +00:00
Cursor Agentand陈大猫 69766dcda1 fix: keep migrated state hooks free of UI-layer imports
Move aiChatStreamingSupport into infrastructure/ai and poll list helpers
into domain/systemManager so useAIChatStreaming / useSystemManager no
longer import from components. Leave thin re-exports at the old paths.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-08-07 05:45:11 +00:00
Cursor Agentand陈大猫 f98ce74a54 fix: measure vault grid columns from content width
Non-virtual known-hosts/snippets ResizeObservers now subtract scroll
container padding via getElementContentWidth, matching
VirtualizedHostCollection's child-root measure so the 30-item threshold
no longer flips columns near breakpoint edges.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-08-07 05:35:14 +00:00
Cursor Agentand陈大猫 d9553d8e71 fix: reset vault reorder drag state on every drop
useVaultItemReorder.handleDropCapture now clears dragging ids in finally
so virtualized known-host cards that unmount mid-drag cannot leave a
stale source id after drops on padding or empty cells.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-08-07 05:17:04 +00:00
Cursor Agentand陈大猫 9b234f38b3 fix: finish container-based snippet package grid styling
Apply the shared CSS-var column style to the package grid as well so no
viewport Tailwind breakpoints remain in SnippetsManager.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-08-07 05:09:57 +00:00
Cursor Agentand陈大猫 711683a90b fix: drive vault grids from container width, not viewport
Known hosts and snippets non-virtual grids now use the same
container-width column helpers as the virtualizer, so crossing the
virtualization threshold no longer reflows when Vault sidebars shrink
content relative to the viewport.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-08-07 05:09:39 +00:00
Cursor Agentand陈大猫 f7222761a2 merge: AI context domain move from base
Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-08-07 05:08:32 +00:00
Cursor Agentand陈大猫 2e2988cacd fix: drop duplicate AIPanelContext type from TerminalLayerSupport
The type is re-exported from domain; the local definition conflicted.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-08-07 05:08:31 +00:00
Cursor Agentand陈大猫 2b9d000a89 fix: move AI terminal session context builders into domain
buildAITerminalSessionInfo and AIPanelContext now live under domain/ so
application hooks no longer import TerminalLayerSupport and create a
UI → application → UI cycle.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-08-07 05:07:59 +00:00
Cursor Agentand陈大猫 5187e7ea66 fix: import known-hosts grid column helper
The virtualizer getColumnCount call referenced
getKnownHostsGridColumnCount without importing it.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-08-07 05:00:02 +00:00
Cursor Agentand陈大猫 778010a271 fix: stabilize known-host columns and clear snippet drag on drop
Virtualized known hosts now use the same 2/sm3/xl4 column breakpoints as
the CSS grid. Snippet reorder drops reset drag ids in finally so
virtualized unmount mid-drag cannot leave a stale draggingSnippetId.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-08-07 04:59:23 +00:00
Cursor Agentand陈大猫 09d2a18ed3 merge: vault import UI boundary + notes pagehide flush
Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-08-07 04:58:48 +00:00
Cursor Agentand陈大猫 904c59c395 fix: keep vault import hooks UI-free and flush notes on teardown
Move ImportOptions into application/state and inject a notifier so
useVaultImportHandlers no longer imports components. Notes drafts also
flush on pagehide/beforeunload when Electron tears down without React
unmount.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-08-07 04:58:42 +00:00
Cursor Agentand陈大猫 d5f4b54ae7 merge: unsaved prompt singleton render registration
Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-08-07 04:49:01 +00:00
Cursor Agentand陈大猫 25ca616622 fix: register unsaved prompt singleton during render
AppView close handlers call promptUnsavedChanges outside the old
render-prop. Assign the singleton during render so Cmd/Ctrl+W and tab
close never hit the pre-effect null window that auto-cancels.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-08-07 04:49:00 +00:00
Cursor Agentand陈大猫 396fb97191 merge: clear stale terminal accent from base PR
Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-08-07 04:44:22 +00:00
Cursor Agentand陈大猫 fba5059083 fix: clear terminal accent when switching back to theme mode
Re-resolve appearanceTheme by catalog id before applyCustomAccent so a
stale baked custom cursor/selection cannot stick after accentMode returns
to theme while TerminalLayer memo ignores accent props.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-08-07 04:44:21 +00:00
Cursor Agentand陈大猫 8e7791a0fc fix: keep snippet virtualizer on the same column policy
VirtualizedHostCollection now accepts getColumnCount so snippets can
mirror the non-virtual CSS grid (max 3 full-width columns; card-width
sizing in split view) instead of the host 4-column layout.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-08-07 04:38:44 +00:00
Cursor Agentand陈大猫 dee922385a merge: accent chrome + note rename fixes from base PR
Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-08-07 04:37:29 +00:00
Cursor Agentand陈大猫 0c8ac89172 fix: keep compose chrome accent live and merge note renames
Subscribe theme-panel focusedAppearance to appearanceChromeStore so
compose-bar chrome updates during color-picker drag even when
resolveFocusedAppearance identity stays stable. Tree renames now merge
title into the post-flush note instead of replacing from a stale row.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-08-07 04:37:23 +00:00
Cursor Agentand陈大猫 7763b1ed20 merge: preserve chrome accent from base PR
Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-08-07 04:29:49 +00:00
Cursor Agentand陈大猫 c03e46fced fix: keep accent in chrome themes without TerminalLayer thrash
Restore accent in globalAppearance for chrome/injection consumers.
Keep resolveFocusedAppearance identity stable across accent drag via
accent ref, while Terminal leaf still re-applies appearanceChromeStore.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-08-07 04:29:48 +00:00
Cursor Agentand陈大猫 4e5f2825c6 fix: address Codex feedback on output triggers and AI history memo
- Keep output-trigger scan on setTimeout so hidden/minimized renderers still flush
- Compare sibling workspace updatedAt in scoped AI panel equality for history sort,
  while still ignoring unrelated terminal stream ticks

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-08-07 04:22:22 +00:00
Cursor Agentand陈大猫 abf32a23cc fix: keep editor-close hotkey ref fresh during render
Assign handleRequestCloseEditorTabRef in render instead of useEffect so
Cmd/Ctrl+W never hits the App stub before the effect runs.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-08-07 04:15:05 +00:00
Cursor Agentand陈大猫 4a85ae32ff fix: keep editor-close hotkey ref fresh during render
Assign handleRequestCloseEditorTabRef in render instead of useEffect so
Cmd/Ctrl+W never hits the App stub before the effect runs.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-08-07 04:15:04 +00:00
Cursor Agentand陈大猫 177c574ffc merge: Bugbot/Codex fixes from base PR #2790
Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-08-07 04:11:28 +00:00
Cursor Agentand陈大猫 66ad27b4bf fix(notes): flush draft before markdown export paths
Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-08-07 04:11:27 +00:00
Cursor Agentand陈大猫 2afd36ab33 fix: address Bugbot/Codex regressions from perf cleanup
- Keep blank Enter from closing RenameDraftDialog; only reset rename state after a non-empty submit
- Flush note drafts before vault mutations so pending title/body edits are not overwritten
- Re-apply appearanceChromeStore accent on appearanceTheme in Terminal, and keep layer themes unaccented

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-08-07 04:11:09 +00:00
Cursor Agentand陈大猫 4717a6ec8e chore(i18n): drop unused knownHosts showingLimited strings
Virtualized Known Hosts no longer caps the visible list, so the
showingLimited copy was dead across locales.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-08-07 03:58:47 +00:00
Cursor Agentand陈大猫 b941556b1d perf: virtualize known hosts/snippets lists and consolidate formatBytes
Replace KnownHosts RENDER_LIMIT=100 and large Snippets grids with
VirtualizedHostCollection (>=30), using drop indicators instead of live
grid reorder so drag sources can unmount safely. Centralize byte
formatting in domain/formatBytes with options that preserve each call
site's zero/precision/TB behavior.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-08-07 03:56:57 +00:00
Cursor Agentand陈大猫 8c7bc0c706 chore: safe dead-code cleanup without behavior changes
Disable plugin WebContentsView spellcheck, drop unused SFTP/i18n/shim
dead code, centralize side-panel position and auto-update storage keys,
and stop plumbing unused accent props into Terminal panes.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-08-07 03:54:14 +00:00
Cursor Agentand陈大猫 7a91b31290 perf(ai): ignore sibling session updatedAt in visible panel memo
Scoped aiSessionIdSetEqual so history chrome only compares updatedAt for
the panel's exact scope (and selected resume). Sibling stream ticks no
longer invalidate unrelated visible AI panels while the full session list
remains available for fuzzy history ranking.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-08-07 03:52:01 +00:00
Cursor Agentand陈大猫 885b0c0177 perf: cut UI jank from stringify equality, scan coalesce, and debug storage
Replace connection-log terminalData map JSON.stringify equality with cheap
structural compare, coalesce output-trigger scans via rAF (setTimeout fallback),
route terminal diagnostics and SFTP transfer center reads through
localStorageAdapter, and extract shared FNV-1a helpers for harness hashing.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-08-07 03:51:49 +00:00
Cursor Agentand陈大猫 a39af9d7b0 fix: correct import paths after application/state hook migrations
Migrated vault/AI/system hooks still used components/-relative imports;
point them at repo-root domain/components/types/lib instead.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-08-07 03:42:40 +00:00
Cursor Agentand陈大猫 a9252d5902 merge: migrate architecture-debt hooks into application/state
Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-08-07 03:41:31 +00:00
Cursor Agentand陈大猫 e44afc3916 merge: package size, dead code, and consolidation cleanup
Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-08-07 03:41:28 +00:00
Cursor Agentand陈大猫 2b1bfadb45 merge: electron memory and re-render fixes
Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-08-07 03:41:26 +00:00
Cursor Agentand陈大猫 5329a47d23 fix: stabilize rAF text batching and diagnostics tests
Polyfill requestAnimationFrame for Node, flush pending text before
steer buffering, bypass rAF while steer is in flight, and stub
global localStorage for AI panel diagnostics adapter tests.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-08-07 03:40:50 +00:00
Cursor Agentand陈大猫 162b5a599f chore: package size, dead code, and consolidation cleanup
Remove unused direct deps, exclude Vite-bundled renderer packages from
electron-builder, delete high-confidence dead code/no-op transfer warm
wiring, consolidate cloud provider IDs and KnownHost/SFTP helpers, and
land UI/AGENTS quick wins (rAF text batching, notes draft debounce,
streaming plain text, tempDirBridge, diagnostics adapter, External MCP
setters).

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-08-07 03:38:16 +00:00
Cursor Agentand陈大猫 c5308a573d fix(ui): cut unnecessary AppView/VaultView/settings re-renders
Stabilize TopTabs close handler, memo AppHostTreeLayer, exclude accent from
settingsVersion, ignore connectionLogs off the logs section, share lastConnected
host refs, and keep rename dialog drafts local so chrome domain stays stable.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-08-07 03:35:24 +00:00
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
Cursor Agentand陈大猫 d43885bdbc fix(electron): reduce baseline memory via spellcheck/GPU/prewarm guards
Disable spellcheck on BrowserWindows and the default session, drop spare
renderers, gate aggressive GPU switches behind NETCATTY_COMPAT_GPU, and make
settings prewarm opt-in via NETCATTY_PREWARM_SETTINGS.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-08-07 03:33:24 +00:00
Cursor Agentand陈大猫 c98e688ad6 refactor(sftp): align local bookmarks store API with global bookmarks
Export subscribe/get/set/rehydrate helpers from localSftpBookmarks to match
the globalSftpBookmarks external-store pattern.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-08-07 03:29:36 +00:00
Cursor Agentand陈大猫 5c2d60f694 refactor: migrate architecture-debt hooks into application/state
Move SFTP Wave A stores/hooks and related vault/AI/terminal/systemManager
hooks out of components into application/state, update importers, and keep
thin deprecated re-exports at the old paths.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-08-07 03:28:56 +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
ea6fe18539 fix(vault): allow CSV import of same-endpoint hosts in different groups (#2739)
* fix(#2737): automated Cursor CLI fix

* fix: address Codex review on PR #2739

* fix(vault): harden destination re-dedupe passphrase and counts

Keep collapsed destination imports aligned with CSV merge policy for key
passphrases, and correct plugin importer added/duplicate counters when
hosts collapse into one group.

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

* fix(vault): preserve SecureCRT and plugin hosts under destination group

Skip endpoint collapse for SecureCRT imports, and keep plugin hosts that
differ by configuration when an import destination rewrites their group.

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

* fix(vault): skip plugin imports that collide after destination rewrite

After rewriting imported hosts into the destination group, drop ones that
now share a fingerprint with existing vault hosts.

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

* fix(vault): keep group case in host merge keys

Vault group paths are compared exactly, so Prod and prod must remain
distinct session identities during import/create dedupe.

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

* fix(vault): prune orphan credentials after destination collisions

When a destination rewrite drops colliding imported hosts, also remove
identities/keys that were only referenced by those skipped hosts.

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

* fix(vault): only prune newly imported credentials on collision

Pass existing identity/key counts into destination handling so remapped
duplicates of vault credentials are never deleted when a host is skipped.

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

* fix(vault): merge credential refs when collapsing destination hosts

Carry identityId/telnetIdentityId/identityFileId onto the retained host
so destination-group collapse does not drop referenced auth.

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

* fix(vault): only graft credential refs onto bare collapsed hosts

Avoid copying identityId onto a retained host that already has key or
password auth, which would silently change resolveHostAuth preference.

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 18:55:02 +08:00
13fa68a720 feat(plugins): add sync providers and encrypted sidecar sync (#2713)
* feat(plugins): add sync providers and encrypted sidecar sync

Implement PR 8 of the plugin platform (#2269): namespaced sync Providers
with provider.sync, encrypted-object storage operations, WebDAV through the
shared storage surface, dynamic cloud provider IDs, and non-cascade sidecars
for sync:true plugin settings plus account/CRDT baselines.

Related to #2269

* fix(plugins): wire sync providers and sidecars into production paths

Close PR 8 integration gaps: dynamic plugin provider registry and
getConnectedAdapter createPluginStorage, WebDAV through EncryptedObjectStorage,
cloud payload collect/apply for pluginSidecars, and IPC/preload bridges.

Related to #2269

* fix(plugins): preserve WebDAV auth reuse and resourceId through EOS wrap

encryptedObjectStorageAsCloudAdapter now reports initiallyAuthenticated when
config exists (matching raw WebDAVAdapter) so getConnectedAdapter reuses the
cached instance, and preserves/refreshes resourceId from the backing adapter
instead of always forcing the default object key.

Related to #2269

* fix(plugins): address Codex PR 8 review findings

Carry pluginSidecars through merge/convergent uploads, lazy-connect before
object I/O with revision-aware writes, fail closed on sidecar collect errors,
expose connectPluginProvider, preserve remote timestamps, and drop deleted
installed-plugin settings from collection.

Related to #2269

* fix(plugins): close remaining Codex P1/P2 sync issues

Lazy stream cancel for inline sync reads, must-not-exist conditional writes,
three-way sidecar merge for local resets, validated apply path for settings,
and merge remote sidecars into convergent uploads.

Related to #2269

* fix(plugins): harden sidecar collect/apply and write verification

Preserve last-known sidecars when the host is offline, apply remote
deletions for installed plugins, force convergent upload on sidecar
divergence, verify plugin object writes, and pick plugin providers in
auto-sync startup checks.

Related to #2269

* fix(plugins): close remaining Codex sidecar/sync path gaps

Include sidecars in auto-sync payload and hash, keep explicit empty bundles,
fail apply when host is offline, require getCapabilities, and carry merged
sidecars into convergent local apply/upload decisions.

Related to #2269

* fix(plugins): close Codex P1 sidecar collect/apply/merge gaps

Treat null host responses as unavailable, persist collected settings into
the non-cascade table, retain decoded convergent payloads, use three-way
sidecar merge for deletions, and fail apply only on operational errors.
Also treat non-empty plugin sidecars as meaningful sync data and re-run
auto-sync when plugin contributions change.

Related to #2269

* fix(plugins): close remaining Codex P1 sidecar and provider gaps

Replay offline-cached sidecars before collect, keep explicit empty
sidecar fields after three-way merge, thread sidecars through convergent
conflict/downgrade materialization, keep gated-off plugin providers from
joining sync as connected, and rebind plugin EOS sessions after runtime
replacement.

Related to #2269

* fix(plugins): separate pending remote sidecars from last-known cache

Replay only host-offline remote applies into the DB, keep last-known as
upload fallback, use contribution reset for deleted settings, probe real
plugin host readiness, rebind storage events for plugin providers, clear
plugin bases on reset, and swallow disconnect rejections.

Related to #2269

* fix(plugins): hydrate retained sidecars and gate missing providers

Materialize installed-plugin settings from retained sidecars before
collect pruning, clear sidecars on setting reset, fail closed when
pending remote replay fails, and only restore plugin providers that are
contribution-available. Also initialize dynamic provider counters on
connect.

Related to #2269

* fix(plugins): surface plugin sync providers and preserve conflict sidecars

Collect live sidecars before convergent conflict apply/upload, include
dynamic providers in status and disconnect paths, and list plugin sync
providers in Cloud Sync settings for connect/disconnect.

Related to #2269

* fix(plugins): per-provider sidecar baselines and safer collect/UI

Merge convergent sidecars against each provider's own baseline, keep
retained settings in sidecars without unvalidated plugin_settings writes,
guard isConnectDisabled for never-connected plugins, and fall back plugin
provider labels/icons in the status popup.

Related to #2269

* fix(plugins): correct plugin provider discovery and sidecar reverify

Read nested contribution metadata for sync provider cards, gate empty-config
connects, enforce single-provider disconnect for plugins, and re-verify after
sidecar-only convergent uploads so baselines track the post-write payload.

Related to #2269

* fix(plugins): refresh sync provider availability from live contributions

Replace the contribution-available provider ID set when plugins change so
disabled or uninstalled sync providers leave the auto-sync ready set and
drop to disconnected with config retained.

Related to #2269

* fix(plugins): preserve sidecars in backups, pending queue, and migration

Attach last-known plugin sidecars to protective local vault backups, fail
operationally when pending/last-known storage writes are rejected, carry
sidecars through convergent migration publish, and reject trailing garbage
on encrypted object parse.

Related to #2269

* fix(plugins): abort conflict collect failures and merge migration sidecars

Rethrow operational sidecar collection errors during convergent conflict
resolution, LWW-union all local/provider sidecars during convergent
migration, re-collect last-known after apply, and prefer live host collect
for protective local vault backups.

Related to #2269

* fix(plugins): restore pluginBridge tests, fix import path, union downgrade sidecars

Correct SettingsSyncTab dynamic import path so production builds resolve,
add ipcMain.on to the plugin bridge test double, union all provider
sidecars on convergent downgrade, keep empty last-known resets, and skip
force-delete when contribution reset validation rejects.

Related to #2269

* fix(plugins): abort backup collect failures and honor empty remote wipes

Only fall back to last-known sidecars when the host is unavailable during
protective backups, treat explicit empty remote sidecar bundles as
authoritative wipes, remove lint-blocking anys from harness tests, and
use a stable equal-timestamp sidecar tie-break.

Related to #2269

* fix(plugins): include sidecars in equality, preserve merge base on reconnect

Compare pluginSidecars in cloudSyncPayloadsEqual for migration freshness,
clear plugin merge state only when account/resource changes on reconnect,
cancel accepted sync read streams on failure, and stop re-connecting the
plugin EOS adapter on every I/O.

Related to #2269

* fix(plugins): drop cached adapters on contribution refresh

Invalidate plugin sync adapters when live contributions change so a
restarted runtime gets a fresh connect, take offline any non-disconnected
missing providers, and latch version-change backups only after cancellation
checks pass.

Related to #2269

* fix(plugins): preserve omitted sidecars and hydrate on plugin enable

Treat missing pluginSidecars as legacy/unsupported in three-way merge
(only explicit empty is a wipe), hydrate retained settings when a plugin
enables, and re-enable retained provider configs when contributions return.

Related to #2269

* fix(plugins): keep newer retained sidecars over older stored settings

When collection merges existing sidecars with plugin_settings rows, prefer
the newer updatedAt so a schema-rejected remote value retained only in
the sidecar table is not overwritten by a stale local setting.

Related to #2269

* fix(plugins): seal plugin provider config and clear base on config change

Encrypt opaque plugin provider configuration before persistence, clear
merge state when reconnect configuration changes, and stream sync objects
below a base64-safe inline threshold so control-plane JSON stays in budget.

Related to #2269

* fix(plugins): seal plugin configs by provider id and hydrate before start

Encrypt plugin provider configuration only for non-builtin provider IDs
so field-name collisions cannot skip sealing, hydrate retained sidecars
before plugin enable starts the runtime, and stop tearing down live
adapters on no-op availability refreshes.

Related to #2269

* fix(plugins): seal scalar configs, safe inline reads, stable config fingerprint

Encrypt any non-builtin plugin configuration JSON shape, enforce the
base64-safe inline read cutoff, and compare reconnect configurations with
order-independent fingerprints so merge bases are not cleared spuriously.

Related to #2269

* fix(plugins): thread sync credentials and harden availability checks

Pass SyncConnectPayload.credential through the object-storage and IPC host
path, treat only null/undefined config as missing, and avoid wiping the
plugin provider availability catalog on transient discovery failures.

Related to #2269

* fix(plugins): unblock schema-required sync connect and scalar configs

Add a JSON config dialog for plugin sync providers that declare a
required configurationSchema, reuse retained configs on reconnect, and
treat falsy scalar configs as present in readiness and adapter lookup.

Related to #2269

* fix(plugins): rebind sync adapters on contribution refresh

Drop cached plugin sync adapters when live contributions still include
the provider so replaced runtimes get a fresh connect, and seal falsy
scalar plugin configs with nullish presence checks end-to-end.

Related to #2269

* fix(plugins): apply sidecars under current declared setting scopes

When a plugin update changes a syncable setting scope, write under the
live declaration and drop the obsolete scope row so collection does not
republish duplicates.

Related to #2269

* fix(plugins): seal config envelopes and stabilize provider counters

Use an unambiguous host-owned sealed-config envelope, initialize
dynamic-provider sequence counters before cross-window decrypt, keep
three-way sidecar deletions during convergent migration, and create the
sidecar table for existing schema-1 plugin databases.

Related to #2269

* fix(plugins): avoid mid-sync adapter drops and sidecar resurrection

Keep plugin adapters across setting-only contribution refreshes and
rebind sessions on ensureConnected; stop preserving local-only missing
plugin sidecars when remote already mentions the plugin; compare configs
with nullish semantics across windows; swallow quick-connect failures
after toasting.

Related to #2269

* fix(plugins): restore remoteKeys for installed sidecar resets

Reintroduce the remote key set used when dropping installed-plugin
settings the remote no longer carries so applyFromSync cannot throw
after replacing the sidecar table.

Related to #2269

* fix(plugins): honor conflict policy and re-key scoped sidecars

Pass preferCloud/preferLocal into three-way sidecar merge, use
per-provider baselines on convergent downgrade so local resets are not
resurrected, and re-key applied settings under the current declared
scope coordinates.

Related to #2269

* fix(plugins): keep strategy on final sidecar merge and block dual legacy

Pass preferCloud/preferLocal through the final verified sidecar merge,
avoid auto-reactivating a retained plugin while another legacy provider
is already connected, cache plugin adapters only after initializeSync,
and preserve explicit null configuration values.

Related to #2269

* fix(plugins): strip plugin avatars and protect last-known sidecars

Do not load plugin-supplied avatar URLs in the main renderer, and avoid
overwriting last-known sidecars with an unmerged remote bundle when the
post-apply collect fails or is non-authoritative.

Related to #2269

* fix(plugins): strip top-bar plugin avatars and stop always-rebind

Hide plugin avatar URLs in the sync status popover, drop always-on
rebindSession for plugin adapters, and read convergent mode from the
local config module when deciding whether to auto-reactivate providers.

Related to #2269

* fix(plugins): use window.netcatty for sync IPC and allow null configs

Read plugin sync and sidecar APIs from the production preload global,
and treat an explicit config property (including JSON null) as present
for readiness and adapter lookup.

Related to #2269

* fix(plugins): close residual Codex P2 sync edge cases

Three-way merge sidecars during v2 convergent migration so local
resets are not resurrected, preserve explicit null plugin configs on
adapter rebuild, rebind EOS sessions after I/O failure for replaced
runtimes, and accept the public inlineObjectBytes limit on readObject.

Related to #2269

* fix(plugins): keep inline read cutoff under control-plane budget

Restore the base64-safe inline cutoff so advertised-size reads cannot
overflow the 128 KiB provider JSON envelope; providers near the public
inlineObjectBytes limit must stream.

Related to #2269

* fix(plugins): harden sidecar empty-vault, hydrate LWW, and credential seals

Close remaining Codex/review gaps for PR8: keep empty sidecar shells from
bypassing upload guards, skip startup hydrate when local settings are newer,
seal/validate durable plugin credential refs, and cover schema 1→2 migration.

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

* fix(plugins): rebind plugin sync sessions and keep null configs

Address Codex P2 on 8ae52c54: re-issue connect after runtime replacement,
and treat stored config:null as present in the dashboard reconnect path.

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

* fix(plugins): defer empty sidecar last-known until sync succeeds

Keep prior non-empty last-known across an authoritative empty collect so the
empty-vault guard can still recognize a plugin-only reset, then commit the
empty cache after a successful upload.

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

* fix(plugins): avoid restricted localStorage global in sidecar test

Bind the mock to a local binding so eslint no-restricted-globals stays clean.

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

* fix(plugins): commit applied sidecar cache and reset orphan settings

Prefer merged payload sidecars when updating last-known after sync, and clear
syncable plugin_settings that lack sidecar rows on authoritative remote deletes.

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

* fix(plugins): reseal marker-shaped plugin configs instead of skipping

Exact __netcatty_plugin_config_v1 collisions no longer bypass encryption; trusted
host envelopes are unwrapped then resealed, and false collisions are sealed whole.

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

* fix(plugins): keep merged sidecars in last-known after apply collect fails

Return applyFromSync entries from IPC and use them when follow-up collect fails,
so preserved missing-plugin rows are not dropped from the offline upload cache.

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

* fix(plugins): delete stale-scope settings at stored coordinates on reset

After resetSetting against the current declared scope, also remove the row at
the sidecar's stored scope so scope migrations cannot republish remote deletes.

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

* fix(plugins): close sync-provider credential, stream, and sidecar gaps

Make SecretRef usable end-to-end via lease-bound network.request, keep
renderer/main transfers abortable and bounded, and stop sidecar
last-known/pending from corrupting backups and conflict apply.

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

* fix(plugins): harden single-provider, secret schema, and conditional conflicts

Keep legacy restart from dual-connecting providers, extract writeOnly schema
secrets into OS storage, and surface conditional-write precondition failures
as conflict UI instead of a generic sync error.

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

* fix(plugins): dedupe sync type imports for lint

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

* fix: address Codex review on PR #2713

* fix(plugins): wait for sidecar host before version backup latch

Defer the one-shot upgrade snapshot until the plugin sidecar host is ready (or a short grace), and catch getDataHash failures so pending/baseline paths do not reject unhandled.

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

* fix(plugins): require sidecar grace before version backup

Always wait for contributions/grace tick before the upgrade snapshot, and only latch after a non-cancelled attempt so cleanup cannot suppress retries.

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

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: netcatty-bot <308658023+netcatty-bot@users.noreply.github.com>
2026-08-05 18:43:10 +08:00
Netcatty-Botandnetcatty-bot ea3642cb56 fix(#2743): automated Cursor CLI fix (#2744)
Co-authored-by: netcatty-bot <308658023+netcatty-bot@users.noreply.github.com>
2026-08-05 17:39:13 +08:00
857429f6dc fix(terminal): show server name in host info bar (#2732)
* fix(#2708): automated Cursor CLI fix

* fix(terminal): let host info bar show label or address

Avoid concatenating both into the toolbar; add a setting so users pick vault label or connection address, with the other kept in the tooltip.

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 13:51:18 +08:00
9ac4c46df9 fix(sftp): keep external-edit temps across terminal tab switches (#2730)
* fix(#2727): automated Cursor CLI fix

* fix(sftp): drop external-edit retainers when browse sessions close

closeSftp already deletes registered temps on disconnect/reconnect; clear
the matching renderer retainers so hasActiveWork cannot stick on a gone file.

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

* fix(sftp): clear external-edit retainers when watches force-stop

When the temp file disappears, fileWatcherBridge force-stops the watch
with localPath; drop the matching retainer so hasActiveWork cannot stick.

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

* fix: address Codex review on PR #2730

---------

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 13:32:51 +08:00
4adbfb8817 feat(tabs): add edit host to session tab and quick switcher menus (#2729)
* fix(#2725): automated Cursor CLI fix

* fix(ui): keep Quick Switcher open for host context menu clicks

Portaled ContextMenuContent lives outside containerRef, so outside mousedown was closing the switcher before Connect/Edit host could run.

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 13:26:36 +08:00
Netcatty-Bot bd12cac427 fix(#2731): automated Cursor CLI fix (#2735) 2026-08-05 12:49:29 +08:00
陈大猫 68ca65dce8 fix(ai): stop OpenCode turns dying on terminal tool failures (#2724)
* fix(ai): stop OpenCode turns dying on terminal tool failures

OpenCode MCP terminal_execute treated non-zero exits as bare
"Error: Operation failed" (dropping stdout), and the OpenCode driver
aborted the whole turn on tool status=error. Preserve command output
and surface tool errors as tool results so multi-step agent runs can
continue.

Closes #2718

* fix(ai): use neutral MCP text for silent terminal_execute success

Codex P2 on #2724: ok:true empty serial/raw PTY results no longer fall
back to "Error: Operation failed". Prefer Catty's "Command completed
(no output)" and keep operational failures / non-zero exits unchanged.
Also prefer non-empty OpenCode tool error/output when formatting tool
results.
2026-08-05 11:16:26 +08:00
陈大猫 f395b7c9e9 fix: allow deleting global command history entries (#2706)
Add single-entry delete on the Global history side panel, remove the
matching host autocomplete record when no duplicate row remains, and
keep the change set intentionally slim (no multi-window journal/sequence
hardening).

Fixes #2701
2026-08-05 10:58:22 +08:00
Netcatty-Botandnetcatty-bot 32f0e5abeb fix(terminal): deliver right-click mouseup to mouse-tracking apps (#2722)
* fix(#2721): automated Cursor CLI fix

* fix: address Codex review on PR #2722

---------

Co-authored-by: netcatty-bot <308658023+netcatty-bot@users.noreply.github.com>
2026-08-05 10:40:26 +08:00
Yohannandyangheng 83e0e98795 feat(terminal): auto-upload clipboard images on paste (remote sessions, opt-in setting) (#2676)
* feat(terminal): 粘贴时自动上传剪贴板图片(远程会话,设置项控制)

- 新增设置项 autoUploadClipboardImageOnPaste(默认关闭):剪贴板含图片时,远程 SSH 会话中粘贴自动通过 SFTP 上传图片到远端 .netcatty-paste-images/ 并输入远端路径,无图则回退普通文本粘贴
- terminalClipboardPaste.ts:图片检查优先于本地文件/文本粘贴;上传失败上报错误提示,不上传时静默回退
- 统一四路粘贴入口:右键/中键(onPaste)、DOM paste 事件(useTerminalFilePaste)、绑定快捷键(createXTermRuntime case "paste")
- TerminalBehaviorSettings.tsx 新增设置开关,i18n 补齐 en/ru/zh-CN/zh-TW
- syncPayload.ts 设置同步白名单加入新字段
- 测试:terminalClipboardPaste.test.ts 新增 4 个用例(上传/回退/失败上报/本地不受影响)

Author: yangheng
Model: 42865985-ead4-417f-88a9-12012a3495fe/deepseek-v4-flash

* fix(terminal): 剪贴板图片上传异常时上报错误而非静默回退

- clipboardImagePaste.ts:readClipboardImage 抛错视为 no-image(读图失败与空剪贴板无法区分,可安全回退普通粘贴)
- terminalClipboardPaste.ts:确认剪贴板有图后上传流程抛异常(如 SFTP 无法打开)→ 上报 upload-failed 错误并停止,与右键菜单路径一致,不再静默粘贴无关剪贴板内容
- 测试:新增「上传抛异常 → 上报 upload-failed 且不粘贴」「读图失败 → no-image」2 个用例,18/18 通过

Author: yangheng
Model: 42865985-ead4-417f-88a9-12012a3495fe/deepseek-v4-flash

---------

Co-authored-by: yangheng <492238647@qq.com>
2026-08-05 10:29:37 +08:00
陈大猫 f5edd32366 fix(terminal): keep broadcast through command colons (#2715)
* fix(terminal): keep broadcast through command colons

Treat trailing colons after a confirmed shell prompt as typed command
input so workspace broadcast is not paused mid-command (#2709).

Require terminator + whitespace so mid-label shapes like Challenge #1:
stay fail-closed; standalone auth prompts remain untrusted.

* fix(terminal): require real PS1 body for colon broadcast bypass

Title-case challenge labels that embed shell markers (Challenge # 1:,
Account $ code:) must stay fail-closed. Only bare markers, user@host,
paths, shell-version PS1s, and lowercase identities count as a real
prompt prefix for the #2709 mid-command colon exemption.
2026-08-05 10:08:38 +08:00
陈大猫andCursor 735c047a09 [Bug] Fix host connect script queue selection (#2705) (#2716)
* fix(hosts): keep draft connect scripts before onConnect promote

Host details queue editing pruned non-onConnect scripts immediately, so
default manual automation scripts could be selected but never appeared
in the queue. Keep an editable draft queue and only prune missing IDs
until save promotes triggers.

Closes #2705

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

* fix(hosts): promote persisted manual connect-queue scripts on save

Editable queue retention left already-saved manual script IDs unpromoted
when reopen/save compared only newly added IDs. Promote every queued
script that still needs onConnect + host targeting.

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

* chore(hosts): drop unused HostDetailsPanel snippet imports

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

* fix(hosts): do not demote global scripts during queue save sync

Promote-on-save must leave targetsAllHosts onConnect scripts unchanged
when they appear in a host connectScriptIds list.

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

* test(hosts): cover global connect scripts during queue save sync

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

* fix(hosts): promote connect-queue scripts on new host save

New hosts have no initialData, so queue promote-on-save was skipped and
manual scripts stayed non-onConnect after the first save.

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

* fix(hosts): harden connect-queue promote against Codex review

Preserve global scope when promoting manuals, skip re-promoting scripts
demoted while the host editor is open, sync peer host queues for retained
targets, and keep test fixtures ASCII-only.

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

* fix(hosts): persist peer connect queues with edited host save

Peer queue sync via onHostsChange was overwritten by onSave rebuilding
from a stale hosts snapshot. Write peers+edited host together and make
save paths apply functional host updates.

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

* fix(hosts): keep connect queues across hydration and concurrent edits

Skip queue rewrite while snippets are still empty, and do not promote
entries whose non-onConnect trigger changed while the host editor stayed
open.

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

* fix(hosts): skip connect-queue rewrite before snippets hydrate

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

* fix(hosts): preserve concurrent target removals and merge peer deltas

Drop stale queue entries when targets were unlinked while editing, and
apply peer connectScriptIds updates via functional host state merges.

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

* fix(hosts): hydrate connect-queue baseline and append peer deltas

Capture the first hydrated snippet catalog as the editor baseline, and
merge peer queue updates by appending only newly queued script IDs.

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

* fix(hosts): snapshot opening queue and keep peer updates host-safe

Use the editor-opening connect queue as sync previous IDs, and never
overwrite the edited host inside peer delta merges.

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

* fix(hosts): clear deleted queues after hydrate and honor manual unlinks

Treat a hydrated empty snippet catalog as ready for queue reconciliation,
and drop concurrently untargeted non-onConnect queue entries on save.

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

* fix(hosts): clear loginScriptId when connect queue is emptied

sanitizeHost reconstitutes connectScriptIds from loginScriptId when the
explicit queue is absent, so clearing the last queued script must also
drop the legacy field.

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

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-05 09:53:37 +08:00
陈大猫 cd8371e3fc Merge pull request #2717 from binaricat/cursor/fix-copy-tab-channel-rate-limit-b2b2
fix(ssh): Copy Tab reuse survives bastion channelOpen rate limits (#2704)
2026-08-04 21:54:45 +08:00
Cursor Agentand陈大猫 eee9be01f1 fix(ssh): retry rate-limited PID discovery for direct bastions
Shell-open retries already applied to every Copy Tab reuse, but resilient
PID scans were still gated on jumpHosts. Direct bastion targets that
rate-limit session channels could reuse the shell while leaving the
copied tab without shellPid. Always retry rate-limited discovery on the
reuse path; keep the optional pre-scan pause jump-host-only.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-08-04 13:41:39 +00:00
Cursor Agentand陈大猫 f59518a2ff fix(ssh): retry rate-limited pre-open shell PID discovery
If the jump-host Copy Tab pre-open PID scan is rejected with channelOpen
too offen, a single delayed attempt left discovery unavailable and skipped
post-open assignment. Retry rate-limited pre-open scans so shellPid
tracking still succeeds before opening the reused shell.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-08-04 13:25:29 +00:00
Cursor Agentand陈大猫 6acc2883fd fix(ssh): retry rate-limited post-open shell PID discovery
After a bastion-rate-limited shell open succeeds on Copy Tab, the follow-up
PID discovery exec can hit the same "channelOpen too offen" limit. Delay and
retry that discovery on jump-host reuse so the copied session still gets a
shellPid for unambiguous cwd lookup.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-08-04 13:20:49 +00:00
Cursor Agentand陈大猫 b7e8f9a04a fix(ssh): keep jump-host shell PID discovery with rate-limit backoff
Address review feedback: permanently skipping PID discovery on jump-host
Copy Tab left shared transports without shellPid and broke cwd lookup.
Delay discovery briefly instead, and keep shell-open retries for the
bastion "channelOpen too offen" rate limit.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-08-04 13:14:54 +00:00
Cursor Agentand陈大猫 93abe8446a fix(ssh): retry Copy Tab shell opens on bastion channel rate limits
Bastion/jump hosts often reject rapid session channel opens with
"channelOpen too offen". Copy Tab was opening a discovery exec then a
shell on the same authenticated transport, failing reuse and falling
back to a fresh login that prompts for OTP again.

Skip shell PID discovery when jump hosts are present, and retry shell
opens with short backoff on the bastion rate-limit error so duplicated
tabs keep reusing the live connection.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-08-04 13:09:15 +00:00
陈大猫 4ed86c4ddd Merge pull request #2711 from binaricat/codex/fix-cursor-automation-handoffs
fix(ci): harden Cursor automation handoffs
2026-08-04 17:39:57 +08:00
陈大猫 f2b8a72324 Merge pull request #2703 from binaricat/worktree/quiet-cloud-b74d
fix(ssh): keep connect automation on fresh connections
2026-08-04 17:25:51 +08:00
bincxz 85d9710a8b fix(ci): enforce issue reference boundaries 2026-08-04 17:24:19 +08:00
bincxz 887d228a1b fix(ci): parse multi-issue pull references 2026-08-04 17:21:56 +08:00
bincxz 9af475c16a fix(ci): reclassify related PR follow-ups 2026-08-04 17:11:46 +08:00
bincxz 68269ce2bd fix(ci): preserve trusted maintainer routing 2026-08-04 17:02:21 +08:00
bincxz 0d6df02bf6 fix(ci): close automation review gaps 2026-08-04 16:57:01 +08:00
bincxz 6b1c5632e3 fix(ci): address automation review findings 2026-08-04 16:40:31 +08:00
bincxz a603a0e1f0 fix(ci): harden cursor automation handoffs 2026-08-04 16:27:14 +08:00
bincxz e35e2b23ea fix(ssh): preserve source fallback across auth retry 2026-08-04 13:44:24 +08:00
bincxz 005739230c fix(ssh): harden automation reconnect fallbacks 2026-08-04 13:38:39 +08:00
bincxz a4ef55f174 fix(ssh): read live automation policy on retry 2026-08-04 13:20:13 +08:00
bincxz 2f8f7daa56 fix(ssh): freeze automation policy per connection 2026-08-04 13:10:34 +08:00
bincxz ad8b5aa2a7 fix(ssh): close late automation sync window 2026-08-04 13:03:04 +08:00
bincxz 3e54c585af fix(ssh): finalize one-shot automation reuse policy 2026-08-04 12:55:44 +08:00
bincxz 55b2dacbcc fix(ssh): show progress after reused session reconnect 2026-08-04 12:37:44 +08:00
bincxz f200aafc8f fix(ssh): harden fresh automation reconnects 2026-08-04 12:26:28 +08:00
bincxz 9d876cf6d5 fix(ssh): use fresh connections for connect automation 2026-08-04 11:58:07 +08:00
陈大猫 29df6216ed Merge pull request #2700 from binaricat/fix/remove-unused-eslint-disable-monaco-paste
fix(lint): remove unused eslint-disable in monaco paste test
2026-08-04 10:56:34 +08:00
bincxz c8c24a5c5c fix(lint): remove unused eslint-disable in monaco paste test
The no-dynamic-delete rule no longer flags this delete, so the disable directive is unused.
2026-08-04 10:56:17 +08:00
陈大猫 f35f578cae Merge pull request #2693 from binaricat/fix/2684-keyword-highlight-flicker
fix: prevent keyword highlights flashing on Enter
2026-08-04 10:46:24 +08:00
陈大猫 d44af5f4a5 Merge pull request #2695 from binaricat/fix/2677-macos-bitwarden-agent-forwarding
Fix macOS Bitwarden SSH agent forwarding
2026-08-04 10:09:32 +08:00
bincxz b261869acc fix(terminal): resume Enter work after full refresh 2026-08-04 03:40:41 +08:00
bincxz d7ec779a7d fix(terminal): pause Enter scans under pressure 2026-08-04 03:32:52 +08:00
bincxz af1e0ce5ad fix(terminal): preserve Enter refresh progress 2026-08-04 03:25:07 +08:00
bincxz 8b40c187f7 fix(terminal): reschedule queued work after Enter 2026-08-04 02:55:27 +08:00
bincxz 1961e87ff9 fix(terminal): detect saturated output scrolls 2026-08-04 02:31:07 +08:00
bincxz 04479d1c4f fix: retain automatic login agent warning 2026-08-04 02:22:15 +08:00
bincxz db24fd115e fix(terminal): bound refresh latency during output 2026-08-04 02:21:13 +08:00
bincxz 39458a84f0 fix: separate agent availability warnings 2026-08-04 02:11:55 +08:00
bincxz b60fad2080 fix(terminal): recognize delayed output scrolls 2026-08-04 02:06:41 +08:00
bincxz 072bbf24ad fix: preserve standard agent authentication 2026-08-04 02:01:12 +08:00
bincxz 5d8409580a fix(terminal): cancel Enter scroll work before pressure 2026-08-04 01:59:18 +08:00
bincxz 11850a0dac fix(terminal): separate highlight stability from pruning 2026-08-04 01:52:49 +08:00
bincxz 8b7d1d7647 fix: preserve channel connection reuse 2026-08-04 01:51:22 +08:00
bincxz a3d071f398 fix(terminal): defer pruning through Enter output 2026-08-04 01:47:47 +08:00
bincxz 83cd1890ac fix(terminal): retain Enter protection until output idle 2026-08-04 01:42:24 +08:00
bincxz 38bce21a94 fix(terminal): wait for Enter output movement 2026-08-04 01:34:47 +08:00
bincxz 6e73cec109 fix: refresh forwarding agent selection 2026-08-04 01:32:03 +08:00
bincxz aef1678af8 fix(terminal): retain submitted Enter until output 2026-08-04 01:25:50 +08:00
bincxz bed3e03fe2 fix(terminal): prefer Enter writes over output scrolls 2026-08-04 01:21:49 +08:00
bincxz 74a23c335b fix: isolate login and forwarding agents 2026-08-04 01:18:38 +08:00
bincxz ac7288d65f fix(terminal): preserve Enter pruning across scan frames 2026-08-04 01:15:12 +08:00
bincxz 9a5ef5c84c fix(terminal): defer highlight pruning after Enter 2026-08-04 01:06:50 +08:00
bincxz 9400e3731b fix(terminal): bound keyword highlight retention 2026-08-04 01:05:40 +08:00
bincxz cb6c65fd37 fix: separate agent login from forwarding 2026-08-04 01:05:26 +08:00
bincxz 28738881ff fix(terminal): prevent keyword highlight flicker on Enter 2026-08-04 01:05:26 +08:00
陈大猫 13aa4a8c67 Merge pull request #2694 from binaricat/fix/2682-cursor-keyword-highlight
fix(terminal): keep cursor-row background behind keyword highlights
2026-08-04 00:59:53 +08:00
bincxz 3b6ca6f803 fix: select usable macOS agent for forwarding 2026-08-04 00:55:00 +08:00
bincxz 121a1ed8be fix(terminal): keep keyword highlights on cursor line
Cursor-line highlighting skipped keyword foreground ranges, exposing the terminal default background. Paint the row continuously while leaving keyword foreground decorations intact.
2026-08-04 00:52:11 +08:00
陈大猫 8ac48c3e51 Merge pull request #2691 from binaricat/cursor/issue-2689-30798978737
fix(settings): stop terminal font size oscillating across windows
2026-08-04 00:41:06 +08:00
bincxz 76c2463b3d fix(settings): repair stale font size persistence 2026-08-04 00:20:54 +08:00
bincxz 56bef275f5 fix(settings): converge terminal font size updates 2026-08-04 00:01:34 +08:00
陈大猫 3dbd2213fa Merge pull request #2690 from yintian710/main
feat(SnippetsManager): 添加批量删除代码片段功能
2026-08-03 23:57:46 +08:00
bincxz 8ef87f2f16 fix(snippets): report bulk deletion accurately 2026-08-03 23:50:32 +08:00
bincxz 84125bcdcb fix(snippets): clean host bindings on bulk delete 2026-08-03 23:46:07 +08:00
netcatty-bot c6313b8b02 fix(#2689): automated Cursor CLI fix 2026-08-03 09:16:55 +00:00
陈大猫 bbef1d2d99 Merge pull request #2686 from binaricat/worktree/brave-harbor-2120
feat: add nested split terminal side panels
2026-08-03 17:07:58 +08:00
yintian 0cff7a3e52 test(snippet): 添加片段批量删除功能的测试用例
- 创建国际化多语言验证测试确保所有语言支持批量删除文案
- 实现片段选择状态管理单元测试验证删除逻辑
- 添加边界情况测试处理不存在的选择ID
- 验证删除操作不会修改原始片段数组
- 确保空选择集返回完整副本避免副作用
2026-08-03 17:00:12 +08:00
bincxz 21022d6b92 fix: include split divider in pane minimum 2026-08-03 16:46:42 +08:00
bincxz a50222dc07 fix: preserve nested pane minimums 2026-08-03 16:44:07 +08:00
yintian ce0b8f85c3 feat(SnippetsManager): 添加批量删除代码片段功能
- 引入 removeSelectedSnippets 函数处理批量删除逻辑
- 扩展 deleteTarget 状态类型以支持批量删除操作
- 实现 requestDeleteSelectedSnippets 函数请求删除选中项
- 在确认删除函数中添加对批量删除类型的处理
- 添加删除按钮到多选模式的操作栏中
- 更新删除确认对话框以支持批量删除的标题和描述
- 添加英文、俄文、简体中文和繁体中文的批量删除相关翻译
2026-08-03 16:38:02 +08:00
bincxz 764c7ea4c7 fix: clamp side panel to terminal surface 2026-08-03 16:37:30 +08:00
bincxz 1a61ec86ef fix: close side panel review gaps 2026-08-03 16:32:21 +08:00
bincxz 228d992671 fix: address split panel review findings 2026-08-03 16:25:22 +08:00
bincxz 523dcd80c8 fix: harden split side panel interactions 2026-08-03 16:14:29 +08:00
bincxz 093728c31f fix: refine AI composer streaming 2026-08-03 15:34:42 +08:00
陈大猫 d11fcd80ca Merge pull request #2671 from binaricat/fix/windows-explorer-context-menu-2651-2655
fix(windows): Explorer Open-in-Netcatty toggle and launch fix
2026-08-03 15:33:42 +08:00
bincxz 0950efb41b feat: resize AI chat composer 2026-08-03 15:18:39 +08:00
bincxz 7cfe82aacc fix: compact agent selector menu 2026-08-03 14:09:19 +08:00
bincxz 97890d6f84 fix: unify side panel toolbars 2026-08-03 14:01:24 +08:00
bincxz 33a1e5852c fix: polish split side panel layout 2026-08-03 13:50:25 +08:00
bincxz 9639a26568 fix: harden split side panel lifecycle 2026-08-03 12:46:06 +08:00
bincxz fb3cb2a263 fix(windows): fall back to HKCU when machine Explorer verbs cannot be repaired
If residual/stale HKLM keys cannot be rewritten without elevation, register
per-user HKCU verbs instead so Settings can still enable Open in Netcatty
for portable and unelevated installs.
2026-08-03 12:35:39 +08:00
bincxz add0f33cd5 feat: add split terminal side panels 2026-08-03 12:34:49 +08:00
bincxz 1289383169 fix(windows): track dev appArgs in Explorer menu warm-start identity
Persist and compare development app entry args so moving a checkout
re-registers Explorer shell commands instead of keeping a stale
electron.exe + old app path launch line.
2026-08-03 12:28:35 +08:00
bincxz 4974e22879 fix(windows): restore hidden portable Explorer verbs on failed enable
Clear only ProgrammaticAccessOnly when unsuppressing keys that still have
commands, and unsuppress after a failed HKLM refresh so preference-write
rollback cannot leave the only working portable integration hidden.
2026-08-03 12:23:39 +08:00
bincxz 61bc1f1102 fix(windows): roll back Explorer suppression without deleting install verbs
When only one ProgrammaticAccessOnly write succeeds, remove just that
value (or a newly created empty key) instead of deleting the whole HKCU
shell subtree that may still hold a working portable command.
2026-08-03 12:15:13 +08:00
bincxz 0d7b9839bc fix(windows): revalidate Explorer verbs before enabled warm-start
When the saved preference says enabled, still confirm both shell verbs
remain registered; otherwise re-apply so interrupted installer rewrites
or partial cleanup cannot leave a permanently missing menu entry.
2026-08-03 12:10:25 +08:00
bincxz 7657de7c01 fix(windows): suppress machine Explorer verbs without deleting HKCU first
When HKLM registration exists, disable only writes ProgrammaticAccessOnly.
Deleting portable HKCU install verbs before a failed suppression left users
with no working Explorer integration and an unrepairable machine path.
2026-08-03 12:04:08 +08:00
bincxz 925be73b1e fix(windows): recheck HKCU residuals after Explorer menu probe
Probe markers must not skip residual scans: a later per-user NSIS install
writes HKCU verbs, not HKLM, and must still be repaired/adopted on launch.
2026-08-03 11:58:39 +08:00
bincxz 3dfb100c50 fix(windows): probe default-off without preference and fix dev shell cmds
Record a separate probe marker for ZIP/portable no-verb defaults so a
shared AppData profile cannot suppress a later NSIS install. Register
development Explorer verbs as electron.exe + absolute app entry.
2026-08-03 11:52:28 +08:00
bincxz 259a5af6f6 fix(windows): report residual Explorer verbs and cache default-off
After a failed machine repair, report any surviving shell verb as enabled
so Settings can disable it. Persist enabled:false for ZIP/portable defaults
so warm starts skip repeated registry probes.
2026-08-03 11:43:53 +08:00
bincxz 953ff61328 fix(windows): refresh HKLM before clearing portable Explorer verbs
When a partial machine registration exists, keep working portable HKCU
verbs until both HKLM shell entries are current, so unelevated enable
attempts cannot destroy the user's only working context-menu path.
2026-08-03 11:33:47 +08:00
bincxz e014098695 fix(windows): require HKCU cleanup and roll back failed Explorer toggles
Treat residual portable HKCU verbs as a hard enable failure when HKLM
exists, require suppression cleanup on user-scope enable, and restore
the previous preference when apply partially mutates the registry.
2026-08-03 11:28:05 +08:00
bincxz be9f53bf3f fix(windows): clear HKCU Explorer verbs when machine install exists
Codex P2 on #2671: portable/ZIP builds leave real HKCU shell verbs that
shadow later all-users HKLM registrations. When machine keys exist,
delete the per-user Netcatty verbs so Explorer uses the current install.
2026-08-03 11:24:50 +08:00
陈大猫 e9de0b1548 Merge pull request #2674 from binaricat/fix/sftp-picker-sudo-connected-2656
fix(sftp): show sudo hosts under Connected in host picker
2026-08-03 11:24:04 +08:00
bincxz 604cbbdf20 fix(windows): roll back partial Explorer menu suppression writes
Codex P2 on #2671: if only one ProgrammaticAccessOnly value is written
during disable, delete the partial key so disable never leaves a single
hidden verb while the other remains visible.
2026-08-03 11:14:33 +08:00
bincxz a109c3409b fix(sftp): do not mark sudo Connected opens as reused
Omit sourceSessionId for sftpSudo hosts in the picker and normalize the
browse connect path so reusedConnection (spinner/overlay/logs) matches the
dedicated sudo open. Addresses Codex review on #2674.
2026-08-03 11:12:10 +08:00
bincxz a010454e10 fix(windows): restore suppression on failed enable and detect residual verbs
Codex P2s on #2671: remove treats any leftover runnable verb as still
enabled, and install restores ProgrammaticAccessOnly when enabling fails
after clearing a prior per-user suppression.
2026-08-03 11:09:58 +08:00
陈大猫 f8939decd7 Merge pull request #2675 from binaricat/fix/sftp-stale-conflict-dialog-2636
fix(sftp): stop skipped conflicts from reopening after restart
2026-08-03 11:09:51 +08:00
bincxz ed2fa70f5c fix(windows): repair partial Explorer menu residuals on first launch
Codex P2 on #2671: when no preference exists but a single leftover shell
verb remains, still run install/repair so the Settings toggle cannot stay
off while Explorer still shows one Open-in-Netcatty entry.
2026-08-03 11:04:52 +08:00
bincxz efd92fdbe4 fix(sftp): stop skipped conflicts from reopening after restart
Skip used to mark transfers cancelled without clearing conflict metadata, so reopening SFTP rehydrated the dialog while the transfer center looked empty. Clear conflict on skip/stop, only restore attention conflicts in the dialog, and strip stale payloads when loading persisted history.

Fixes #2636
2026-08-03 11:01:46 +08:00
bincxz b893ed2b06 fix(windows): fail Explorer menu install when HKLM verbs stay stale
Codex P2 on #2671: unelevated refreshes must not report success merely
because non-empty HKLM commands exist. Require writes to succeed or both
verbs to already match the expected executable/command contract.
2026-08-03 11:00:23 +08:00
bincxz e7d41c13c8 fix(windows): require both Explorer verbs to be suppressed
Codex P2 on #2671: treat the integration as user-suppressed only when
both folder and background keys have ProgrammaticAccessOnly, so a partial
suppress write cannot hide one menu while claiming the feature is off.
2026-08-03 10:55:14 +08:00
bincxz 41344d83f7 fix(sftp): show sudo hosts under Connected in host picker
List open SSH terminals in the SFTP Connected section even when sftpSudo
is enabled. Connection still opens a dedicated sudo SFTP channel and does
not reuse the terminal shell. Closes #2656.
2026-08-03 10:52:44 +08:00
bincxz eb79e97626 fix(windows): reassert disabled Explorer menu and require both verbs
Address Codex P2s on #2671: disabled preferences re-suppress on every
startup so NSIS updates cannot resurrect the menu, and registration is
only considered complete when both folder and background commands exist.
2026-08-03 10:50:03 +08:00
bincxz e3241f8b92 fix(windows): treat Explorer shell verbs without command as inactive
Codex P2 on #2671: a partially written registry verb (key exists, no
command value) must not count as an active menu registration, so install
and Settings toggle state stay honest and repair can still run.
2026-08-03 10:44:42 +08:00
陈大猫 71e7c60aaa Merge pull request #2670 from binaricat/fix/sftp-download-growing-files-2653
fix(sftp): allow downloading append-only growing files
2026-08-03 10:44:24 +08:00
bincxz 2f6b395cf3 fix(sftp): version prefix fingerprints and empty-prefix digests
Persist download fingerprints as sha256:pN:hex so coverage is explicit,
treat planned zero bytes as the empty digest, and fall back to a full-file
check for legacy bare sha256:hex identities saved by older builds.
2026-08-03 10:34:43 +08:00
bincxz 49065c1b5e fix(sftp): preserve zero-byte totalBytes on standalone resume
Standalone dedicated reconnect was still coercing totalBytes=0 to
undefined via ||, so the bridge re-statted a grown remote and abandoned
the empty snapshot plan. Keep finite zeros in the stream payload.
2026-08-03 10:25:39 +08:00
bincxz b7359fefa6 fix(windows): re-register Explorer menu when portable path moves
Persist the last-applied executablePath in the preference record and
re-apply shell verbs when it differs from the current launcher path so
renamed/moved portable builds keep Open-in-Netcatty working.
2026-08-03 10:22:00 +08:00
bincxz 4d612668d5 fix(sftp): fail closed on compact growth; keep explicit zero plans
Drop growth-aware compact identity experiments that either lost rewrite
proof or needed renderer node:fs. Compact entries keep full size+mtime
identities (retransfer on remote growth). Preserve explicit totalBytes=0
through bridge preflight so empty download snapshots do not re-stat into
a grown remote size.
2026-08-03 10:19:31 +08:00
bincxz b4d40ab67a fix(windows): register portable launcher path for Explorer menu
Codex P2 on #2671: electron-builder portable apps expose the stable
launcher via PORTABLE_EXECUTABLE_FILE while process.execPath points at a
temp unpack that is deleted on exit. Resolve that path for shell verbs
and bump the preference schema so existing installs re-register once.
2026-08-03 10:17:41 +08:00
bincxz 2d20fe9834 fix(sftp): keep planned size proof for download resume identities
Path-only compact identities were too weak for rewritten/truncated files.
Download trees now key entry identity to path+planned size (omit mtime),
revalidate covered compact entries against local destination sizes, and
preserve explicit zero-byte snapshot plans through resume validation.
2026-08-03 10:12:22 +08:00
bincxz 072af9caa3 fix(windows): keep HKLM Explorer verbs for all users on toggle off
Address Codex P2 on #2671: the Settings toggle is per-user, so disable
only clears HKCU and suppresses machine verbs via ProgrammaticAccessOnly
instead of deleting HKLM (even when elevated). Also persist a schemaVersion
so healthy warm starts skip registry work after the first migration.
2026-08-03 10:11:26 +08:00
bincxz b965a031f2 fix(windows): skip Explorer registry rewrites when verbs are current
Codex P2 on #2671: warm starts no longer spawn a full reg.exe write pass
when installer/user shell verbs already match the expected command, label,
and icon. Still rewrites stale --open-terminal-path entries on upgrade.
2026-08-03 10:03:59 +08:00
bincxz df540cca4f fix(sftp): address Codex P2s on growing download resume
Preserve the planned snapshot size for interrupted directory download
children, use path-stable compact identities for download trees so
append-only growth does not invalidate covered prefixes, and treat
zero-byte planned snapshots as complete in the sequential fallback.
2026-08-03 10:03:46 +08:00
bincxz 8533b3f4c0 fix(sftp): harden growing-download finish and resume paths
Codex review on #2670: accept remote growth only after the full planned
prefix still matches the staged file, and skip the sequential stream when
the checkpoint already covers the snapshot so grown logs do not resume
from the old EOF.
2026-08-03 09:54:58 +08:00
bincxz d6d268e5c7 fix(windows): suppress HKLM Explorer menu per-user without elevation
Address Codex review on #2671: when all-users (HKLM) shell verbs cannot be
deleted unelevated, hide them for the current user via ProgrammaticAccessOnly
instead of failing the Settings toggle. Also stop mirroring machine verbs into
HKCU so uninstall does not leave a broken Open-in-Netcatty entry.
2026-08-03 09:51:36 +08:00
github-actions[bot]andgithub-actions[bot] 3dbc4bb9bb chore: update star history charts [skip ci] (#2672)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-08-03 00:24:14 +00:00
bincxz 61281d79d8 fix(windows): toggle Explorer Open-in-Netcatty menu and harden launch
Add a Settings > System switch to enable/disable the Explorer folder
context menu, and fix shell launch so Chromium does not swallow
--open-terminal-path (args after --, =path form, trailing-dot roots).

Closes #2651
Closes #2655
2026-08-03 01:22:03 +08:00
陈大猫 3e347ccb41 Merge pull request #2668 from binaricat/fix/2661-snippet-find-paste
fix(snippets): paste into Monaco find widget, not script body
2026-08-03 01:18:31 +08:00
bincxz 506769c421 fix(snippets): abort find-widget paste if focus left mid-read
Address Codex review: after the async clipboard read, only insert when
the find input is still document.activeElement and still connected, so
a delayed paste cannot steal focus or hit the editor body.
2026-08-03 01:04:51 +08:00
bincxz fab439a646 fix(sftp): allow downloading append-only growing files
SFTP downloads take a fixed size snapshot at start, then rejected the
transfer if the remote file grew before completion. Live logs and other
append-only sources commonly hit that post-check and failed with a size
mismatch. Accept remote growth when the planned prefix was transferred,
and keep resume identity keyed to that snapshot so pause/resume still works.

Closes #2653
2026-08-03 01:02:13 +08:00
陈大猫 1d879443ec Merge pull request #2669 from binaricat/fix/sftp-conflict-dialog-button-layout
fix(sftp): keep conflict dialog actions on one row
2026-08-03 01:01:02 +08:00
bincxz 8174971c3c fix(sftp): keep conflict dialog actions on one row
Widen the SFTP conflict dialog and prevent footer buttons from wrapping so Stop/Skip/Duplicate/Merge/Replace stay on a single line.
2026-08-03 01:00:48 +08:00
bincxz 421dcb8230 fix(snippets): paste into Monaco find widget, not script body
Custom Ctrl/Cmd+V handling for Electron overwrote paste while the
snippet script editor find bar had focus, so search text landed in the
script. Route find-widget paste through shared helpers and reuse them
in the text editor.

Fixes #2661
2026-08-03 00:57:24 +08:00
陈大猫 b4fbedf827 Merge pull request #2664 from binaricat/cursor/issue-2663-30735043928
fix(ssh): trigger macOS Local Network prompt from main process
2026-08-03 00:44:14 +08:00
bincxz 406fe2135a fix(ssh): harden macOS Local Network probe for LAN SSH
Bound the main-process TCC probe to 3s, only treat real private IPv4/IPv6
literals as LAN, prefer proxy as first hop when set, and annotate
unreachability using either the target or the first hop. Closes the
remaining merge blockers for #2663.
2026-08-03 00:36:03 +08:00
陈大猫 a2b922eb92 Merge pull request #2666 from hang666/feat/grok-build-agent
feat(ai): Grok Build managed agent
2026-08-03 00:24:58 +08:00
陈大猫 fbb09d0100 Merge pull request #2667 from binaricat/codex/catty-context-controls
feat(ai): add Catty context controls
2026-08-03 00:18:47 +08:00
hang666 f17d96ba1b fix(ai): normalize Grok plan updates to shared activity shape
Emit plan items as { text, completed } with top-level running|completed status so sdkAgentAdapter and the activity UI can render labels and completion instead of empty rows stuck on running.
2026-08-02 22:08:53 +08:00
hang666 97b773ef89 fix(ai): fail incomplete Grok turns and stop swallowing ACP prompt errors
Remove the async rpc.request hop that let promptDone win Promise.race on session/prompt JSON-RPC errors. Gate emitDone on turnCompleted, treat any incomplete process close (including exit 0) as failure, and align streaming-json toolResult emission with ACP when rawOutput is present.
2026-08-02 21:39:36 +08:00
hang666 0436dc9556 fix(ai): use ACP optionIds and fail signal-killed unfinished Grok turns
Permission replies must select an optionId offered in params.options instead of inventing allow-once. Process close(null, signal) before turnCompleted is a failure for both ACP and streaming-json drivers.
2026-08-02 21:18:54 +08:00
hang666 5ee42cf34b fix(ai): handle Grok ACP stdin EPIPE like other bridges
Attach a stdin error listener so async EPIPE after peer exit is not an
unhandled stream error. Treat EPIPE/ERR_STREAM_DESTROYED as benign
(processErrorGuards/terminalBridge); fail the turn only for other mid-turn
write errors.
2026-08-02 20:52:15 +08:00
hang666 72b4f2319b fix(ai): treat partial Grok streams with nonzero exit as failures
Only turnCompleted (headless end / ACP prompt success) may ignore teardown
exit codes. streamedAssistantText must not suppress errors, or mid-response
crashes look successful.
2026-08-02 20:40:34 +08:00
hang666 47b2094d8b fix(ai): emit Grok ACP usage synchronously on prompt result
Root cause of Token ~1: onPromptComplete resolved promptDone in the same
tick as session/prompt, so Promise.race often discarded the result and the
UI fell back to estimated usage (prompt length / 4). Emit usage when the
prompt response is handled, and map live Grok _meta.usage fields including
cachedReadTokens.
2026-08-02 20:27:24 +08:00
hang666 73780dddab fix(ai): expire Grok runtime siblings and emit ACP prompt usage
Drop the inactive acp/streaming-json in-memory session when switching
runtimes so switch-back cannot resume a pre-switch thread without
intervening turns. Forward optional session/prompt usage to emitter.usage.
2026-08-02 19:52:02 +08:00
hang666 2453b9f1a8 fix(ai): ignore Grok process exit after successful prompt
Tool-only turns leave no assistant text; Windows teardown often exits with
code 1. Mark turnCompleted when session/prompt succeeds so close does not
emitError or skip emitDone.
2026-08-02 19:36:56 +08:00
hang666 ea8ccce690 fix(ai): spawn Grok CLI through prepareCommandForSpawn
Windows .cmd/.bat installer shims fail with spawn EINVAL when launched
without shell. Route ACP, headless, and models spawns through the same
helper other managed agents use.
2026-08-02 18:42:06 +08:00
hang666 6888a79b03 fix(ai): seed Grok history when ACP resume falls back to session/new
When a stored session id cannot resume/load, still inject renderer history
into the new session prompt so multi-turn context is not dropped. Successful
resume/load paths remain unseeded to avoid stacked prior replies.
2026-08-02 18:09:01 +08:00
hang666 ae5b648fdc fix(ai): remove obsolete model 2026-08-02 17:47:15 +08:00
hang666 8aace6c1ce fix(ai): place Grok ACP global flags before agent subcommand
--disallowed-tools and --no-auto-update are top-level grok options; after
agent the live CLI rejects them and MCP-mode ACP turns cannot start.
2026-08-02 16:42:58 +08:00
hang666 77fed032f5 fix(ai): close Grok dual-runtime resume, auth, and tool-policy gaps
Prefer ACP session/resume (then load/new) without replaying history into the
current turn, authenticate when Grok advertises methods, isolate acp vs
streaming-json session identities, and apply MCP local-tool lockdown plus
--no-auto-update on both transports.
2026-08-02 16:35:37 +08:00
hang666 b2a379a368 fix(ai): stop Grok ACP turns from replaying prior replies
Suppress session/update emissions until session/prompt starts so
session/load history replay does not fill the current assistant bubble.
Also treat Grok like CodeBuddy: when a resume session id exists, do not
also inject renderer conversation history into the prompt.
2026-08-02 16:14:28 +08:00
hang666 129aaddb40 feat(ai): add Grok ACP vs streaming-json runtime switch
Expose a Settings toggle on the Grok Build card (like Codex App Server)
to choose ACP agent stdio or the original headless streaming-json path.
Persist grokRuntime on ExternalAgentConfig and pass it into the driver
via NETCATTY_GROK_RUNTIME on the agent env.
2026-08-02 16:05:20 +08:00
hang666 97b0dd4ebc fix(ai): pass Grok ACP MCP env as name/value pairs
Grok agent stdio session/new rejects plain object env (Invalid params /
McpServer enum). Emit type:stdio servers with env as [{name,value},...]
matching the live protocol, and assert the shipped shape in unit tests.
2026-08-02 15:52:27 +08:00
hang666 3ea4286ed5 feat(ai): add Grok Build ACP agent-stdio turn path
Default Grok turns to grok agent stdio (JSON-RPC ACP): initialize,
session/new with Netcatty mcpServers, session/prompt, and session/update
mapped onto the shared emitter protocol. Keep headless streaming-json as
an explicit NETCATTY_GROK_RUNTIME=streaming-json fallback.
2026-08-02 15:44:28 +08:00
hang666 76ce594fbd feat(ai): lock down Grok local tools in MCP mode
In MCP tool-integration mode, pass --disallowed-tools for Grok built-in
shell/edit/write so remote session work goes through injected Netcatty MCP,
matching Claude-style policy. Skills mode leaves local shell available for
the Netcatty CLI skill path.
2026-08-02 15:41:41 +08:00
hang666 6404c6b209 feat(ai): add Grok model preset fallback
Curate GROK_MODEL_PRESETS and wire getAgentModelPresets so the chat model
picker still has a non-empty list when live grok models discovery fails,
matching the Claude/Codex fallback pattern.
2026-08-02 15:40:34 +08:00
netcatty-bot e8769cd590 fix(#2663): automated Cursor CLI fix 2026-08-02 06:14:24 +00:00
hang666 cd32635f44 test(ai): cover Grok Build coding CLI provider matching
Assert the grok executable basename and Windows path resolve to the
Grok Build coding-CLI provider for terminal tab icons and discovery UX.
2026-08-02 10:32:34 +08:00
hang666 c46247e996 feat(ai): wire Grok Build settings UI and i18n
Expose Grok as a managed Settings agent card, icon, coding-CLI provider,
and runtime model discovery target. Add en/zh/ru strings for in-app Grok
Build (distinct from External MCP client install).
2026-08-02 10:31:29 +08:00
hang666 a6a54e705f feat(ai): discover and match Grok Build as managed agent
Add grok to ManagedAgentKey/meta, discovery known-agents list, XAI_API_KEY
and ~/.grok/auth.json auth probe, and closed sdkBackend unions so the CLI
is discoverable and routable like other managed agents.
2026-08-02 10:28:51 +08:00
hang666 c456a97224 feat(ai): add Grok Build managed agent CLI driver
Register a headless streaming-json driver for backend key grok that maps
ACP-derived NDJSON events into the shared emitter protocol and injects
Netcatty MCP via a project .grok/config.toml merge with restore.
2026-08-02 10:25:23 +08:00
592 changed files with 60056 additions and 5806 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 }}
+436 -72
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,
});
}
@@ -254,6 +261,25 @@ jobs:
context,
issueNumber: issue.number,
});
const relatedPull = pull || await auto.findOpenPullForIssue({
github,
context,
issueNumber: issue.number,
includeRelated: true,
});
const refined = auto.refineIssueCommentRoute(decision, {
hasOpenBotPull: Boolean(pull),
hasOpenRelatedPull: Boolean(relatedPull),
body: comment.body,
labels,
});
if (refined.kind === 'issue_classify') {
return set('issue_classify', {
issue_number: String(issue.number),
trigger_comment_id: String(comment.id),
reason: refined.reason,
});
}
return set('issue_followup', {
issue_number: String(issue.number),
pull_number: pull ? String(pull.number) : '',
@@ -274,9 +300,8 @@ jobs:
repository: `${context.repo.owner}/${context.repo.repo}`,
});
if (
sameRepo &&
context.payload.action === 'closed' &&
auto.shouldGatePullOnSourceIssueFollowups(pr, {
auto.shouldCleanupSourceIssueAfterPull(pr, {
ownActors,
repository: `${context.repo.owner}/${context.repo.repo}`,
})
@@ -284,7 +309,7 @@ jobs:
return set('source_issue_cleanup', {
issue_number: String(auto.extractSourceIssueNumber(pr)),
pull_number: String(pr.number),
reason: pr.merged ? 'automation PR merged' : 'automation PR closed',
reason: pr.merged ? 'merged PR closed source issue' : 'automation PR closed',
});
}
if (sameRepo) {
@@ -332,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
@@ -370,16 +430,16 @@ jobs:
core.warning(`PR #${pullNumber} is no longer closed; skipping source cleanup.`);
return;
}
if (!auto.shouldGatePullOnSourceIssueFollowups(pull, {
if (!auto.shouldCleanupSourceIssueAfterPull(pull, {
ownActors: process.env.OWN_ACTORS,
repository: `${context.repo.owner}/${context.repo.repo}`,
})) {
core.warning(`PR #${pullNumber} is not a trusted automation pull request; skipping source cleanup.`);
core.warning(`PR #${pullNumber} is not eligible for source cleanup; skipping.`);
return;
}
const issueNumber = auto.extractSourceIssueNumber(pull);
const issueNumbers = auto.extractSourceIssueNumbers(pull);
const expectedIssueNumber = Number(process.env.SOURCE_ISSUE_NUMBER);
if (!issueNumber || issueNumber !== expectedIssueNumber) {
if (!issueNumbers.includes(expectedIssueNumber)) {
core.warning(`PR #${pullNumber} source issue changed while queued; skipping cleanup.`);
return;
}
@@ -394,20 +454,161 @@ jobs:
if (error.status !== 404) throw error;
}
};
for (const name of ['ready-for-agent', 'needs-info', 'ready-for-human']) {
await removeLabel(issueNumber, name);
}
if (!pull.merged) {
await github.rest.issues.addLabels({
for (const issueNumber of issueNumbers) {
const { data: sourceIssue } = await github.rest.issues.get({
...context.repo,
issue_number: issueNumber,
labels: ['ready-for-human'],
});
if (sourceIssue.pull_request) {
core.warning(`#${issueNumber} is a pull request, not a source issue; skipping cleanup.`);
continue;
}
if (pull.merged && sourceIssue.state !== 'closed') {
core.warning(`Merged PR #${pullNumber} does not currently close issue #${issueNumber}; skipping cleanup.`);
continue;
}
for (const name of ['ready-for-agent', 'needs-info', 'ready-for-human']) {
await removeLabel(issueNumber, name);
}
if (!pull.merged) {
await github.rest.issues.addLabels({
...context.repo,
issue_number: issueNumber,
labels: ['ready-for-human'],
});
}
}
for (const name of ['automation:codex-loop', 'ready-for-human']) {
await removeLabel(pullNumber, name);
}
reconcile_closed_handoffs:
name: Reconcile handoffs
if: github.event_name == 'schedule' && github.event.schedule == '17 3 * * *'
runs-on: ubuntu-latest
permissions:
actions: write
contents: read
issues: write
pull-requests: write
steps:
- name: Checkout automation helpers
uses: actions/checkout@v7
with:
ref: ${{ github.event.repository.default_branch }}
- name: Remove stale ready-for-human labels
uses: actions/github-script@v9
with:
github-token: ${{ secrets.TRIAGE_GITHUB_TOKEN || secrets.GITHUB_TOKEN }}
script: |
const auto = require(`${process.env.GITHUB_WORKSPACE}/scripts/cursor-automation.cjs`);
const queries = [
`repo:${context.repo.owner}/${context.repo.repo} is:issue is:closed label:"ready-for-human" label:triage`,
`repo:${context.repo.owner}/${context.repo.repo} is:pr is:merged label:"ready-for-human" label:"automation:bot-pr"`,
];
let cleaned = 0;
for (const q of queries) {
const items = await github.paginate(
github.rest.search.issuesAndPullRequests,
{ q, per_page: 100 },
(response) => auto.extractPaginatedItems(response),
);
for (const item of items) {
try {
await github.rest.issues.removeLabel({
...context.repo,
issue_number: item.number,
name: 'ready-for-human',
});
cleaned += 1;
} catch (error) {
if (error.status !== 404) throw error;
}
}
}
core.summary.addHeading('Closed handoff reconciliation');
core.summary.addRaw(`Removed stale ready-for-human from ${cleaned} closed items.`);
await core.summary.write();
- name: Retry handoffs fixed by this hardening pass
uses: actions/github-script@v9
env:
ISSUE_BOT_LOGINS: ${{ vars.AUTOMATION_ISSUE_BOT_LOGINS || '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`);
const recoveryVersion = 'protected-tests-and-media-v1';
const auditedIssueNumbers = new Set([2679, 2697, 2704, 2705, 2708, 2709]);
const repository = `${context.repo.owner}/${context.repo.repo}`;
const issues = await github.paginate(
github.rest.search.issuesAndPullRequests,
{
q: `repo:${repository} is:issue is:open label:"ready-for-human" label:triage`,
per_page: 100,
},
(response) => auto.extractPaginatedItems(response),
);
const pulls = await github.paginate(github.rest.pulls.list, {
...context.repo,
state: 'open',
per_page: 100,
sort: 'updated',
direction: 'desc',
});
let dispatched = 0;
for (const issue of issues) {
if (!auditedIssueNumbers.has(Number(issue.number))) continue;
const comments = await github.paginate(
github.rest.issues.listComments,
{ ...context.repo, issue_number: issue.number, per_page: 100 },
);
if (!auto.shouldRetryIssueHandoff(comments, {
trustedActors: process.env.ISSUE_BOT_LOGINS,
recoveryVersion,
notBefore: '2026-07-31T12:54:37Z',
notAfter: '2026-08-04T08:27:14Z',
})) continue;
if (pulls.some((pull) => auto.isTrustedOpenPullForIssue(pull, issue.number, {
repository,
includeRelated: true,
}))) continue;
const { data: marker } = await github.rest.issues.createComment({
...context.repo,
issue_number: issue.number,
body: [
'<!-- cursor-automation -->',
`<!-- cursor-handoff-recovery:version=${recoveryVersion} -->`,
'',
'此前导致自动处理停止的问题已经修复,现重新进入自动检查。',
].join('\n'),
});
try {
await github.rest.actions.createWorkflowDispatch({
...context.repo,
workflow_id: 'cursor-automation.yml',
ref: context.payload.repository.default_branch,
inputs: { issue_number: String(issue.number) },
});
dispatched += 1;
} catch (error) {
try {
await github.rest.issues.deleteComment({
...context.repo,
comment_id: marker.id,
});
} catch (cleanupError) {
core.warning(`Issue #${issue.number}: could not remove retry marker (${cleanupError.message}).`);
}
throw error;
}
}
core.summary.addHeading('Retry recovered handoffs');
core.summary.addRaw(`Re-dispatched ${dispatched} retryable issues without open related PRs.`);
await core.summary.write();
classify:
name: Classify issue
needs: route
@@ -693,6 +894,7 @@ jobs:
fi
- name: Research external context for classification
id: research
if: steps.prepare.outputs.should_run == 'true'
env:
GITHUB_TOKEN: ''
@@ -802,6 +1004,7 @@ jobs:
run: *stage_cursor_api_key
- name: Classify with Cursor CLI
id: classify_agent
if: steps.prepare.outputs.should_run == 'true'
env:
GITHUB_TOKEN: ''
@@ -857,6 +1060,15 @@ jobs:
);
'
- name: Validate classification result
id: validate_classification
if: steps.prepare.outputs.should_run == 'true'
run: |
node -e '
const auto = require(process.env.RUNNER_TEMP + "/cursor-automation.cjs");
auto.parseClassificationFile(".cursor-runtime/classification.json");
'
- name: Apply classification
id: apply
if: steps.prepare.outputs.should_run == 'true'
@@ -890,6 +1102,20 @@ jobs:
with:
github-token: ${{ secrets.TRIAGE_GITHUB_TOKEN || secrets.GITHUB_TOKEN }}
script: |
const fs = require('node:fs');
let auto = null;
for (const helper of [
`${process.env.RUNNER_TEMP}/cursor-automation.cjs`,
`${process.env.GITHUB_WORKSPACE}/scripts/cursor-automation.cjs`,
]) {
if (!fs.existsSync(helper)) continue;
try {
auto = require(helper);
break;
} catch (error) {
core.warning(`Could not load automation helper ${helper}: ${error.message}`);
}
}
const issueNumber = Number(process.env.ISSUE_NUMBER);
const commentId = String(process.env.TRIGGER_COMMENT_ID || '');
const { data: issue } = await github.rest.issues.get({
@@ -933,22 +1159,42 @@ jobs:
if (issue.state === 'closed') update.state = 'open';
await github.rest.issues.update(update);
if (!processed) {
const isChinese = /[\u3400-\u9fff]/u.test(
`${issue.title || ''}\n${issue.body || ''}`,
);
const marker = /^[A-Za-z0-9_-]+$/.test(commentId)
? `<!-- cursor-followup:comment-id=${commentId};result=blocked -->`
: '';
const kind = '${{ steps.research.outcome }}' === 'failure'
? 'research_failed'
: '${{ steps.classify_agent.outcome }}' === 'failure'
? 'classification_failed'
: '${{ steps.validate_classification.outcome }}' === 'failure'
? 'classification_failed'
: '${{ steps.apply.outcome }}' === 'failure'
? 'apply_failed'
: 'processing_failed';
const chinese = /[\u3400-\u9fff]/u.test(
`${issue.title || ''}\n${issue.body || ''}`,
);
const failureMessage = auto
? auto.buildClassificationFailureMessage(issue, {
kind,
isFollowup: Boolean(commentId),
workflowUrl: `${process.env.GITHUB_SERVER_URL}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`,
})
: [
chinese
? '自动分类流程没有正常完成,Issue 已保留并转给维护者继续处理。'
: 'The automatic classification process did not finish normally. The issue was preserved for maintainer review.',
`${process.env.GITHUB_SERVER_URL}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`,
].join('\n\n');
await github.rest.issues.createComment({
...context.repo,
issue_number: issueNumber,
body: [
'<!-- cursor-automation -->',
`<!-- cursor-classification-failure:kind=${kind};run=${context.runId} -->`,
marker,
'',
isChinese
? '收到这条补充了,但自动复核没有安全完成,已经转给维护者继续处理。'
: 'We received the additional information, but the automatic review could not finish safely. A maintainer has been notified.',
failureMessage,
].filter(Boolean).join('\n'),
});
}
@@ -1760,6 +2006,7 @@ jobs:
changedFiles: process.argv[2].split("\n").filter(Boolean),
nameStatusText: process.argv[3],
});
auto.writeProtectedPathReport(process.env.RUNNER_TEMP + "/protected-paths.json", hits);
if (hits.length) {
console.error("Protected paths modified:", hits.join(", "));
process.exit(1);
@@ -1855,6 +2102,7 @@ jobs:
const hits = auto.hasProtectedChangesInSources({
nameStatusText: process.argv[1],
});
auto.writeProtectedPathReport(process.env.RUNNER_TEMP + "/protected-paths.json", hits);
if (hits.length) {
console.error("Protected paths modified after verification:", hits.join(", "));
process.exit(1);
@@ -1888,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",
@@ -1909,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
@@ -2084,10 +2334,19 @@ jobs:
...context.repo,
issue_number: issueNumber,
});
const fallback = auto.buildIssueFollowupFallbackReply(
issue,
'processing_failed',
const protectedPaths = auto.readProtectedPathReport(
process.env.RUNNER_TEMP + '/protected-paths.json',
);
const fallback = protectedPaths.length
? auto.buildImplementationFailureMessage(issue, {
kind: 'protected_path',
protectedPaths,
workflowUrl: `${process.env.GITHUB_SERVER_URL}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`,
})
: auto.buildIssueFollowupFallbackReply(
issue,
'processing_failed',
);
const comments = await github.paginate(
github.rest.issues.listComments,
{ ...context.repo, issue_number: issueNumber, per_page: 100 },
@@ -2813,29 +3072,32 @@ jobs:
cp scripts/cursor-automation.cjs "$RUNNER_TEMP/cursor-automation.cjs"
cp scripts/compare-ci-test-baseline.cjs "$RUNNER_TEMP/compare-ci-test-baseline.cjs"
- name: Skip if bot PR already open
- name: Skip if trusted related PR already open
id: existing
uses: actions/github-script@v9
with:
script: |
const auto = require(`${process.env.RUNNER_TEMP}/cursor-automation.cjs`);
const existing = await auto.findOpenBotPrForIssue({
const botPull = await auto.findOpenBotPrForIssue({
github,
context,
issueNumber: process.env.ISSUE_NUMBER,
});
const existing = botPull || await auto.findOpenPullForIssue({
github,
context,
issueNumber: process.env.ISSUE_NUMBER,
includeRelated: true,
});
core.setOutput('exists', existing ? 'true' : 'false');
core.setOutput('url', existing ? existing.html_url : '');
if (existing) {
await github.rest.issues.createComment({
...context.repo,
issue_number: Number(process.env.ISSUE_NUMBER),
body: [
auto.TRIAGE_MARKER,
auto.DISCLAIMER,
'',
`An automation pull request is already open or was already filed: ${existing.html_url}`,
].join('\n'),
await auto.markNeedsHuman({
github,
context,
issueNumber: Number(process.env.ISSUE_NUMBER),
message: `A trusted related pull request is already open: ${existing.html_url}`,
dedupeMarker: `<!-- cursor-existing-related-pr:${existing.number} -->`,
});
}
@@ -2992,6 +3254,7 @@ jobs:
changedFiles: process.argv[2].split("\n").filter(Boolean),
nameStatusText: process.argv[3],
});
auto.writeProtectedPathReport(process.env.RUNNER_TEMP + "/protected-paths.json", hits);
if (hits.length) {
console.error("Protected paths modified:", hits.join(", "));
process.exit(1);
@@ -3114,6 +3377,7 @@ jobs:
node -e '
const auto = require(process.env.RUNNER_TEMP + "/cursor-automation.cjs");
const hits = auto.hasProtectedChangesInSources({ nameStatusText: process.argv[1] });
auto.writeProtectedPathReport(process.env.RUNNER_TEMP + "/protected-paths.json", hits);
if (hits.length) {
console.error("Protected paths modified:", hits.join(", "));
process.exit(1);
@@ -3160,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",
@@ -3170,6 +3435,7 @@ jobs:
'
- name: Upload implement patch
id: upload_patch
if: steps.patch.outputs.artifact_ready == 'true'
uses: actions/upload-artifact@v7
with:
@@ -3180,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
@@ -3189,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
@@ -3203,14 +3478,17 @@ jobs:
: `${process.env.GITHUB_WORKSPACE}/scripts/cursor-automation.cjs`;
const auto = require(helper);
const issueNumber = Number(process.env.ISSUE_NUMBER);
const protectedPaths = auto.readProtectedPathReport(
process.env.RUNNER_TEMP + '/protected-paths.json',
);
const { data: issue } = await github.rest.issues.get({
...context.repo,
issue_number: issueNumber,
});
const protectedFailure = '${{ steps.check_protected.outcome }}' === 'failure';
const artifactReady = '${{ steps.patch.outputs.artifact_ready }}' === 'true';
const artifactUploaded = '${{ steps.upload_patch.outcome }}' === 'success';
const verifyFailed = '${{ steps.verify.outputs.passed }}' === 'false';
const kind = protectedFailure
const kind = protectedPaths.length
? 'protected_path'
: artifactReady && verifyFailed
? 'verification_failed'
@@ -3222,7 +3500,10 @@ jobs:
message: auto.buildImplementationFailureMessage(issue, {
kind,
workflowUrl: `${process.env.GITHUB_SERVER_URL}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`,
artifactName: artifactReady ? `implement-patch-${context.runId}` : '',
artifactName: artifactReady && artifactUploaded
? `implement-patch-${context.runId}`
: '',
protectedPaths,
}),
dedupeMarker: `<!-- cursor-implement-failure:base=${{ steps.branch.outputs.base_sha }};kind=${kind} -->`,
});
@@ -3258,7 +3539,60 @@ jobs:
name: implement-patch-${{ github.run_id }}
path: patch-in
- name: Checkout trusted helper for publish guard
uses: actions/checkout@v7
with:
ref: ${{ github.event.repository.default_branch }}
persist-credentials: false
path: helpers
- name: Ensure automation helper present
working-directory: helpers
env:
BOOTSTRAP_SHA: ${{ github.sha }}
run: |
set -euo pipefail
if [[ -f scripts/cursor-automation.cjs ]]; then
cp scripts/cursor-automation.cjs "$RUNNER_TEMP/cursor-automation.cjs"
exit 0
fi
git fetch --depth=1 origin "${BOOTSTRAP_SHA}"
git show "FETCH_HEAD:scripts/cursor-automation.cjs" > "$RUNNER_TEMP/cursor-automation.cjs"
test -s "$RUNNER_TEMP/cursor-automation.cjs"
- name: Skip publish if trusted related PR opened during implementation
id: existing
uses: actions/github-script@v9
with:
github-token: ${{ secrets.TRIAGE_GITHUB_TOKEN || secrets.GITHUB_TOKEN }}
script: |
const auto = require(`${process.env.RUNNER_TEMP}/cursor-automation.cjs`);
const issueNumber = Number(process.env.ISSUE_NUMBER);
const botPull = await auto.findOpenBotPrForIssue({
github,
context,
issueNumber,
});
const existing = botPull || await auto.findOpenPullForIssue({
github,
context,
issueNumber,
includeRelated: true,
});
core.setOutput('exists', existing ? 'true' : 'false');
if (existing) {
await auto.markNeedsHuman({
github,
context,
issueNumber,
message: `A trusted related pull request is already open: ${existing.html_url}`,
dedupeMarker: `<!-- cursor-existing-related-pr:${existing.number} -->`,
});
core.notice(`Trusted related pull request ${existing.html_url} opened during implementation; skip publishing a duplicate branch.`);
}
- name: Publish branch from fresh runner
if: steps.existing.outputs.exists != 'true'
id: publish
run: |
set -euo pipefail
@@ -3301,7 +3635,7 @@ jobs:
exit 1
- name: Mark needs human on publish failure
if: failure() && steps.publish.outputs.handoff == 'true'
if: failure() && steps.existing.outputs.exists != 'true' && steps.publish.outputs.handoff == 'true'
uses: actions/github-script@v9
env:
ISSUE_NUMBER: ${{ needs.implement.outputs.issue_number }}
@@ -3329,32 +3663,6 @@ jobs:
});
}
- name: Checkout helper for PR body helpers
if: steps.publish.outputs.published == 'true'
uses: actions/checkout@v7
with:
ref: ${{ github.event.repository.default_branch }}
persist-credentials: false
path: helpers
- name: Ensure automation helper present
if: steps.publish.outputs.published == 'true'
working-directory: helpers
env:
BOOTSTRAP_SHA: ${{ github.sha }}
EVENT_NAME: ${{ github.event_name }}
HEAD_REPO: ''
BASE_REPO: ${{ github.repository }}
run: |
set -euo pipefail
if [[ -f scripts/cursor-automation.cjs ]]; then
cp scripts/cursor-automation.cjs "$RUNNER_TEMP/cursor-automation.cjs"
exit 0
fi
git fetch --depth=1 origin "${BOOTSTRAP_SHA}"
git show "FETCH_HEAD:scripts/cursor-automation.cjs" > "$RUNNER_TEMP/cursor-automation.cjs"
test -s "$RUNNER_TEMP/cursor-automation.cjs"
- name: Open draft PR
if: steps.publish.outputs.published == 'true'
id: openpr
@@ -3373,6 +3681,29 @@ jobs:
script: |
const auto = require(`${process.env.RUNNER_TEMP}/cursor-automation.cjs`);
const fs = require('node:fs');
const issueNumber = Number(process.env.ISSUE_NUMBER);
const botPull = await auto.findOpenBotPrForIssue({
github,
context,
issueNumber,
});
const existing = botPull || await auto.findOpenPullForIssue({
github,
context,
issueNumber,
includeRelated: true,
});
if (existing) {
await auto.markNeedsHuman({
github,
context,
issueNumber,
message: `A trusted related pull request is already open: ${existing.html_url}`,
dedupeMarker: `<!-- cursor-existing-related-pr:${existing.number} -->`,
});
core.notice(`Trusted related pull request ${existing.html_url} opened before PR creation; skip the duplicate.`);
return;
}
let statusText = '';
let agentBody = '';
try {
@@ -4400,6 +4731,7 @@ jobs:
changedFiles: process.argv[2].split("\n").filter(Boolean),
nameStatusText: process.argv[3],
});
auto.writeProtectedPathReport(process.env.RUNNER_TEMP + "/protected-paths.json", hits);
if (hits.length) {
console.error("Protected paths modified:", hits.join(", "));
process.exit(1);
@@ -4498,6 +4830,7 @@ jobs:
node -e '
const auto = require(process.env.RUNNER_TEMP + "/cursor-automation.cjs");
const hits = auto.hasProtectedChangesInSources({ nameStatusText: process.argv[1] });
auto.writeProtectedPathReport(process.env.RUNNER_TEMP + "/protected-paths.json", hits);
if (hits.length) {
console.error("Protected paths modified:", hits.join(", "));
process.exit(1);
@@ -4534,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",
@@ -4544,6 +4878,7 @@ jobs:
'
- name: Upload fix patch
id: upload_fixpatch
if: steps.codex.outputs.action == 'fix' && steps.fixpatch.outputs.artifact_ready == 'true'
uses: actions/upload-artifact@v7
with:
@@ -4552,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
@@ -4561,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
@@ -4578,9 +4922,12 @@ jobs:
issue_number: pull_number,
body: [
auto.TRIAGE_MARKER,
auto.DISCLAIMER,
`<!-- cursor-codex-fix-failure:kind=no_changes;run=${context.runId} -->`,
'',
'Automatic fix did not produce additional code changes for the latest Codex findings. Marking for human review.',
auto.buildCodexFixFailureMessage({
kind: 'no_changes',
workflowUrl: `${process.env.GITHUB_SERVER_URL}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`,
}),
].join('\n'),
});
await auto.applyCodexTerminalLabels({
@@ -4604,14 +4951,32 @@ jobs:
: `${process.env.GITHUB_WORKSPACE}/scripts/cursor-automation.cjs`;
const auto = require(helper);
const pull_number = Number(process.env.PULL_NUMBER);
const protectedPaths = auto.readProtectedPathReport(
process.env.RUNNER_TEMP + '/protected-paths.json',
);
const artifactReady = '${{ steps.fixpatch.outputs.artifact_ready }}' === 'true';
const artifactUploaded = '${{ steps.upload_fixpatch.outcome }}' === 'success';
const verifyFailed = '${{ steps.fixverify.outputs.passed }}' === 'false';
const kind = protectedPaths.length
? 'protected_path'
: artifactReady && verifyFailed
? 'verification_failed'
: 'processing_failed';
await github.rest.issues.createComment({
...context.repo,
issue_number: pull_number,
body: [
auto.TRIAGE_MARKER,
auto.DISCLAIMER,
`<!-- cursor-codex-fix-failure:kind=${kind};run=${context.runId} -->`,
'',
'Automatic Codex fix did not finish cleanly (agent, protected paths, or verification failed). Marking for human review.',
auto.buildCodexFixFailureMessage({
kind,
protectedPaths,
artifactName: artifactReady && artifactUploaded
? `codex-fix-patch-${context.runId}`
: '',
workflowUrl: `${process.env.GITHUB_SERVER_URL}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`,
}),
].join('\n'),
});
await auto.applyCodexTerminalLabels({
@@ -4996,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,
+8
View File
@@ -142,6 +142,14 @@ smoke coverage. Packaged-resource changes must also pass `npm run pack:dir`.
- Seed data: `config/defaultData.ts`; terminal themes: `config/terminalThemes.ts`.
- **Temporary files**: All temporary files (e.g., SFTP downloaded files for external editing) must be written to Netcatty's dedicated temp directory via `tempDirBridge.getTempFilePath(fileName)`. Do not write directly to `os.tmpdir()`. This ensures proper cleanup and user visibility in Settings > System.
## Terminal Side Panel Splits
- `domain/sidePanelLayout.ts` owns the pure pane/split tree operations, including focus, unique tools, close collapse, resize, and the pane limit.
- `application/state/useTerminalSidePanelLayoutState.ts` owns per-terminal layouts and keeps the legacy focused-tool map compatible with external open paths.
- `TerminalLayerSidePanelSection.tsx` renders one shared toolbar plus the nested pane chrome. Each pane content host must remain `overflow-hidden` and layout-contained.
- Mounted tool panels use a stable portal node that moves between their pane host and the hidden parking host. Never fall back to an `absolute inset-0` side-panel root when a pane host is not ready.
- Closing the whole side panel clears that terminal's split tree. Switching terminal tabs must not reuse another tab's layout.
## Testing & Safety
- Favor unit tests for domain helpers (e.g., `workspace.ts`, `host.ts`) and hook-level tests for application state.
- When changing storage keys or schema, provide migration or backward-compat handling.
+116 -24
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,
@@ -50,7 +51,7 @@ import { TERMINAL_THEMES } from './infrastructure/config/terminalThemes';
import { useThemeRuntime, useTerminalAppearanceInjection } from './application/state/useThemeRuntime';
import { useCustomThemes } from './application/state/customThemeStore';
import type { SyncPayload } from './domain/sync';
import { applySyncPayload, buildLocalVaultPayload, hasMeaningfulSyncData } from './application/syncPayload';
import { applySyncPayload, buildLocalVaultPayloadAsync, hasMeaningfulSyncData } from './application/syncPayload';
import {
applyProtectedSyncPayload,
ensureVersionChangeBackup,
@@ -248,6 +249,7 @@ function App({ settings }: { settings: SettingsState }) {
updateKnownHosts,
updateManagedSources,
addShellHistoryEntry,
removeShellHistoryEntry,
addConnectionLog,
updateConnectionLog,
toggleConnectionLogSaved,
@@ -289,15 +291,11 @@ function App({ settings }: { settings: SettingsState }) {
draggingSessionId,
setDraggingSessionId,
sessionRenameTarget,
sessionRenameValue,
setSessionRenameValue,
startSessionRename,
renameSessionInline,
submitSessionRename,
resetSessionRename,
workspaceRenameTarget,
workspaceRenameValue,
setWorkspaceRenameValue,
startWorkspaceRename,
submitWorkspaceRename,
resetWorkspaceRename,
@@ -489,7 +487,7 @@ function App({ settings }: { settings: SettingsState }) {
[portForwardingRules],
);
const buildCurrentSyncPayload = useCallback(() => {
const buildCurrentSyncPayload = useCallback(async () => {
let effectivePortForwardingRules = portForwardingRulesForSync;
if (effectivePortForwardingRules.length === 0) {
const stored = localStorageAdapter.read<typeof portForwardingRulesForSync>(
@@ -505,7 +503,7 @@ function App({ settings }: { settings: SettingsState }) {
}
}
return buildLocalVaultPayload(
return buildLocalVaultPayloadAsync(
{
hosts,
keys,
@@ -548,6 +546,10 @@ function App({ settings }: { settings: SettingsState }) {
}, [buildCurrentSyncPayload]);
const versionBackupAttemptedRef = useRef(false);
const versionBackupInFlightRef = useRef(false);
// Bumps when plugin contributions settle or the sidecar-host grace timer
// fires so version-change backups can wait for live sidecar collect.
const [pluginSidecarReadyTick, setPluginSidecarReadyTick] = useState(0);
// Two-stage gate: once the vault has initialized we open the auto-sync
// gate immediately — the hook's own hasMeaningfulSyncData guard and
// the cross-window restore barrier prevent an empty-but-not-yet-
@@ -559,6 +561,25 @@ function App({ settings }: { settings: SettingsState }) {
}
}, [isVaultInitialized, startupSyncSafetyReady]);
useEffect(() => {
if (isPeerSessionWindow) return;
const bridge = netcattyBridge.get() as {
onPluginContributionsChanged?: (callback: () => void) => () => void;
} | null | undefined;
const unsubscribe = bridge?.onPluginContributionsChanged?.(() => {
setPluginSidecarReadyTick((v) => v + 1);
});
// Plugins stay gated off for many installs; still allow a version
// backup after a short grace so we do not wait forever for host ready.
const graceTimer = window.setTimeout(() => {
setPluginSidecarReadyTick((v) => v + 1);
}, 5_000);
return () => {
unsubscribe?.();
window.clearTimeout(graceTimer);
};
}, [isPeerSessionWindow]);
// Retry the version-change backup as hosts/keys/snippets become
// available. ensureVersionChangeBackup refuses to advance the stored
// version stamp when the observed payload is empty, so running this
@@ -567,30 +588,47 @@ function App({ settings }: { settings: SettingsState }) {
// in which case the effect continues to no-op).
useEffect(() => {
if (isPeerSessionWindow || !isVaultInitialized || versionBackupAttemptedRef.current) return;
const payload = buildCurrentSyncPayloadRef.current();
if (!hasMeaningfulSyncData(payload)) return;
versionBackupAttemptedRef.current = true;
// Always wait for contributions or the grace tick — host-ready alone is
// not enough because collect can still return an empty authoritative
// bundle before plugin settings are declared.
if (pluginSidecarReadyTick === 0) return;
if (versionBackupInFlightRef.current) return;
let cancelled = false;
versionBackupInFlightRef.current = true;
void (async () => {
try {
const payload = await buildCurrentSyncPayloadRef.current();
if (cancelled) return;
if (!hasMeaningfulSyncData(payload)) return;
const info = await netcattyBridge.get()?.getAppInfo?.();
if (cancelled) return;
await ensureVersionChangeBackup(payload, info?.version ?? null);
if (cancelled) return;
// Latch only after a completed (non-cancelled) attempt so effect
// cleanup cannot permanently suppress retries when the in-flight
// call later throws.
versionBackupAttemptedRef.current = true;
} catch (error) {
if (!cancelled) {
// Reset the latch so a later data change (or the next mount)
// can retry. ensureVersionChangeBackup already leaves the
// version stamp untouched on failure, so retrying is safe.
versionBackupAttemptedRef.current = false;
console.error('[App] Failed to create version-change backup:', error);
}
} finally {
versionBackupInFlightRef.current = false;
// If hydration re-ran the effect mid-flight, nudge another attempt
// now that the in-flight guard is clear.
if (cancelled && !versionBackupAttemptedRef.current) {
queueMicrotask(() => {
setPluginSidecarReadyTick((v) => v + 1);
});
}
console.error('[App] Failed to create version-change backup:', error);
}
})();
return () => {
cancelled = true;
};
}, [isPeerSessionWindow, isVaultInitialized, hosts, keys, identities, proxyProfiles, snippets, customGroups, snippetPackages, notes, noteGroups, knownHosts]);
}, [isPeerSessionWindow, isVaultInitialized, hosts, keys, identities, proxyProfiles, snippets, customGroups, snippetPackages, notes, noteGroups, knownHosts, pluginSidecarReadyTick]);
// Memoized "apply a remote payload safely" callback. Stable identity
// across renders so useAutoSync's `syncNow` useCallback doesn't rebuild
@@ -623,7 +661,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,
@@ -785,12 +824,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;
@@ -798,6 +853,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
@@ -815,18 +874,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,
}]);
});
@@ -835,6 +905,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]);
@@ -1697,6 +1792,7 @@ function App({ settings }: { settings: SettingsState }) {
const appVaultDomain = useMemo(() => ({
addShellHistoryEntry,
removeShellHistoryEntry,
commitPluginImporterData,
commitVaultImportTransaction,
connectionLogs,
@@ -1742,7 +1838,7 @@ function App({ settings }: { settings: SettingsState }) {
updateSnippetPackages,
updateSnippets,
vaultFocusRequest,
}), [addShellHistoryEntry, commitPluginImporterData, commitVaultImportTransaction, connectionLogs, convertKnownHostToHost, customGroups, deepLinkHostDraft, effectiveKnownHosts, groupConfigs, handleAddKnownHost, handleDeleteHost, handleOpenHostFromVaultNote, handleOpenVaultHostFromChat, handleOpenVaultNoteFromChat, handleOpenVaultSectionFromChat, handleOpenVaultSnippetFromChat, hosts, identities, importOrReuseKey, keys, managedSources, navigateToSection, noteGroups, notes, proxyProfiles, readPersistedHosts, readPersistedManagedSources, setDeepLinkHostDraft, setNavigateToSection, setVaultFocusRequest, snippetPackages, snippets, unmanageSource, updateCustomGroups, updateGroupConfigs, updateHosts, updateIdentities, updateKeys, updateKnownHosts, updateManagedSources, updateNoteGroups, updateNotes, updateProxyProfiles, updateSnippetPackages, updateSnippets, vaultFocusRequest]);
}), [addShellHistoryEntry, commitPluginImporterData, commitVaultImportTransaction, connectionLogs, convertKnownHostToHost, customGroups, deepLinkHostDraft, effectiveKnownHosts, groupConfigs, handleAddKnownHost, handleDeleteHost, handleOpenHostFromVaultNote, handleOpenVaultHostFromChat, handleOpenVaultNoteFromChat, handleOpenVaultSectionFromChat, handleOpenVaultSnippetFromChat, hosts, identities, importOrReuseKey, keys, managedSources, navigateToSection, noteGroups, notes, proxyProfiles, readPersistedHosts, readPersistedManagedSources, removeShellHistoryEntry, setDeepLinkHostDraft, setNavigateToSection, setVaultFocusRequest, snippetPackages, snippets, unmanageSource, updateCustomGroups, updateGroupConfigs, updateHosts, updateIdentities, updateKeys, updateKnownHosts, updateManagedSources, updateNoteGroups, updateNotes, updateProxyProfiles, updateSnippetPackages, updateSnippets, vaultFocusRequest]);
const appTerminalDomain = useMemo(() => ({
addSessionToWorkspace,
@@ -1845,10 +1941,7 @@ function App({ settings }: { settings: SettingsState }) {
resetWorkspaceRename,
resolvedTheme,
sessionRenameTarget,
sessionRenameValue,
setActiveTabId,
setSessionRenameValue,
setWorkspaceRenameValue,
settings,
startSessionRename,
renameSessionInline,
@@ -1860,8 +1953,7 @@ function App({ settings }: { settings: SettingsState }) {
toggleConnectionLogSaved,
updateConnectionLog,
workspaceRenameTarget,
workspaceRenameValue,
}), [accentMode, clearUnsavedConnectionLogs, closeLogView, customAccent, deleteConnectionLog, handleEndSessionDrag, handleOpenQuickSwitcher, handleOpenSettings, handleRootContextMenu, handleSyncNowManual, isMacClient, logViews, openLogView, orderedTabsWithEditors, orphanSessionsForShell, reorderWorkTabs, resetSessionRename, resetWorkspaceRename, resolvedTheme, sessionRenameTarget, sessionRenameValue, setActiveTabId, setSessionRenameValue, setWorkspaceRenameValue, settings, startSessionRename, renameSessionInline, startWorkspaceRename, submitSessionRename, submitWorkspaceRename, t, themeById, toggleConnectionLogSaved, updateConnectionLog, workspaceRenameTarget, workspaceRenameValue]);
}), [accentMode, clearUnsavedConnectionLogs, closeLogView, customAccent, deleteConnectionLog, handleEndSessionDrag, handleOpenQuickSwitcher, handleOpenSettings, handleRootContextMenu, handleSyncNowManual, isMacClient, logViews, openLogView, orderedTabsWithEditors, orphanSessionsForShell, reorderWorkTabs, resetSessionRename, resetWorkspaceRename, resolvedTheme, sessionRenameTarget, setActiveTabId, settings, startSessionRename, renameSessionInline, startWorkspaceRename, submitSessionRename, submitWorkspaceRename, t, themeById, toggleConnectionLogSaved, updateConnectionLog, workspaceRenameTarget]);
const appDialogsDomain = useMemo(() => ({
addToWorkspaceDialog,
+1
View File
@@ -3,6 +3,7 @@
## [Unreleased] - 2026-03-11
### 功能
- 终端新增"粘贴时自动上传剪贴板图片"选项:剪贴板含图片时,在远程会话中粘贴(快捷键/右键/中键)自动通过 SFTP 上传图片到远端 `.netcatty-paste-images/` 目录并输入远端路径,否则保持原有文本粘贴行为(设置 > 终端 > 行为,默认关闭)
- 修复自动更新 IPC 事件仅发送到单个窗口的问题,改为广播所有窗口(主窗口 + 设置窗口均可收到)
- 统一手动检查更新与自动更新的状态机,消除三套并行状态
- 手动"检查更新"通过 GitHub API 检测版本,发现更新后异步触发 electron-updater 下载
+1 -1
View File
@@ -216,7 +216,7 @@ Windows リリースは未署名の場合があります。
> **Windows のポータブルデータ:** Netcatty を終了し、`Netcatty.exe`(zip 版)またはポータブル版ランチャーと同じ場所に `data` フォルダーを作成してください。次回起動時から、Netcatty はデータをこのフォルダーに保存します。保存済みのパスワードと秘密鍵は、作成した Windows ユーザーによって引き続き保護されます。別のコンピューターまたは Windows ユーザーへ移動した場合は、これらの機密情報を再入力する必要があります。
> **Windows でフォルダーを Netcatty で開く:** インストール版では、エクスプローラーのフォルダーおよびフォルダー背景のコンテキストメニューに **Open in Netcatty** が追加されます。選択すると、そのフォルダーでローカルターミナルが開きます。Windows 11 では、先に **その他のオプションを表示** を選択してください。ZIP 版とポータブル版では、このメニューは追加されません。
> **Windows でフォルダーを Netcatty で開く:** インストール版では、エクスプローラーのフォルダーおよびフォルダー背景のコンテキストメニューに **Open in Netcatty** が追加されます。選択すると、そのフォルダーでローカルターミナルが開きます。Windows 11 では、先に **その他のオプションを表示** を選択してください。**設定 → システム → Windows Explorer** からメニューの表示/非表示を切り替えられます。ZIP 版とポータブル版では、既定ではこのメニューは追加されません。
> **macOS ユーザーへ:** 現在のリリースはコード署名と notarization が行われている想定です。Gatekeeper の警告が出る場合は、GitHub Releases から最新版の公式ビルドを取得しているか確認してください。
+1 -1
View File
@@ -217,7 +217,7 @@ application and integration are complete.
> **Windows portable data:** Exit Netcatty, then create a folder named `data` beside `Netcatty.exe` (zip build) or beside the portable launcher. Netcatty will store its profile there on the next launch. Saved passwords and private keys remain protected by the Windows user account that created them, so they must be re-entered after moving the folder to another computer or Windows account.
> **Open folders in Netcatty on Windows:** The installed version adds **Open in Netcatty** to Explorer's folder context menu and folder-background context menu. It opens a local terminal in that folder. On Windows 11, choose **Show more options** first. ZIP and portable versions do not add this menu.
> **Open folders in Netcatty on Windows:** The installed version adds **Open in Netcatty** to Explorer's folder context menu and folder-background context menu. It opens a local terminal in that folder. On Windows 11, choose **Show more options** first. Turn the menu off or on in **Settings → System → Windows Explorer**. ZIP and portable versions do not add this menu by default.
> **macOS Users:** Current releases are expected to be code-signed and notarized. If Gatekeeper still warns, make sure you downloaded the latest official build from GitHub Releases.
+1 -1
View File
@@ -215,7 +215,7 @@ Windows 发布文件可能仍未签名。
> **Windows 便携数据:** 退出 Netcatty,在 `Netcatty.exe`(zip 版)或便携版启动文件旁创建名为 `data` 的文件夹。下次启动后,Netcatty 会把数据保存在这里。已保存的密码和私钥仍受创建它们的 Windows 用户保护;将该文件夹移到其他电脑或 Windows 用户后,需要重新输入这些敏感信息。
> **在 Windows 中用 Netcatty 打开文件夹:** 安装版会在资源管理器的文件夹右键菜单和文件夹空白处右键菜单中加入 **Open in Netcatty**。点击后会直接在该目录打开本地终端;Windows 11 需要先选择 **显示更多选项**。ZIP 版和便携版不会添加此菜单。
> **在 Windows 中用 Netcatty 打开文件夹:** 安装版会在资源管理器的文件夹右键菜单和文件夹空白处右键菜单中加入 **Open in Netcatty**。点击后会直接在该目录打开本地终端;Windows 11 需要先选择 **显示更多选项**。可在 **设置 → 系统 → Windows 资源管理器** 中随时关闭或重新开启。ZIP 版和便携版默认不会添加此菜单。
> **macOS 用户注意:** 当前发布版本应已完成代码签名和公证。如果 Gatekeeper 仍然提示风险,请确认您下载的是 GitHub Releases 中的最新官方构建。
+3
View File
@@ -92,6 +92,7 @@ interface AppHostEditorLayerProps {
onCreateGroup: (groupPath: string) => void;
onImportOrReuseKey: (draft: Partial<SSHKey>) => SSHKey;
onUpdateSnippets: (snippets: Snippet[]) => void;
onUpdateHosts?: (hosts: Host[] | ((prev: Host[]) => Host[])) => void;
}
export const AppHostEditorLayer: React.FC<AppHostEditorLayerProps> = ({
@@ -117,6 +118,7 @@ export const AppHostEditorLayer: React.FC<AppHostEditorLayerProps> = ({
onCreateGroup,
onImportOrReuseKey,
onUpdateSnippets,
onUpdateHosts,
}) => {
const { t } = useI18n();
const derivedSurfaceVisible = useWorkSurfaceVisible({
@@ -189,6 +191,7 @@ export const AppHostEditorLayer: React.FC<AppHostEditorLayerProps> = ({
groupConfigs={groupConfigs}
snippets={snippets}
onSnippetsChange={onUpdateSnippets}
onHostsChange={onUpdateHosts}
onImportKey={onImportOrReuseKey}
onSave={onSave}
onCancel={onCancel}
+5
View File
@@ -53,3 +53,8 @@ test('shared host tree forwards work-surface host management callbacks', () => {
assert.match(hostTreeLayerSource, /onNewHost=\{onNewHost\}/);
assert.match(hostTreeLayerSource, /onEditHost=\{onEditHost\}/);
});
test('shared host tree layer is memoized with a custom areEqual', () => {
assert.match(hostTreeLayerSource, /memo\(AppHostTreeLayerInner,\s*appHostTreeLayerAreEqual\)/);
});
+31 -2
View File
@@ -1,4 +1,4 @@
import React, { useMemo } from 'react';
import React, { memo, useMemo } from 'react';
import { useActiveTabId } from '../state/activeTabStore';
import type { EditorTabChrome } from '../state/editorTabStore';
@@ -47,7 +47,33 @@ export function getAppHostTreeLayerStyle(surfaceVisible: boolean): React.CSSProp
};
}
export const AppHostTreeLayer: React.FC<AppHostTreeLayerProps> = ({
function appHostTreeLayerAreEqual(
prev: AppHostTreeLayerProps,
next: AppHostTreeLayerProps,
): boolean {
return prev.enabled === next.enabled
&& prev.hosts === next.hosts
&& prev.customGroups === next.customGroups
&& prev.groupConfigs === next.groupConfigs
&& prev.sessions === next.sessions
&& prev.workspaces === next.workspaces
&& prev.editorTabs === next.editorTabs
&& prev.logViews === next.logViews
&& prev.orderedTabs === next.orderedTabs
&& prev.accentMode === next.accentMode
&& prev.currentTerminalTheme === next.currentTerminalTheme
&& prev.customAccent === next.customAccent
&& prev.followAppTerminalTheme === next.followAppTerminalTheme
&& prev.hostById === next.hostById
&& prev.themeById === next.themeById
&& prev.resolveSessionAppearance === next.resolveSessionAppearance
&& prev.onConnect === next.onConnect
&& prev.onNewHost === next.onNewHost
&& prev.onEditHost === next.onEditHost
&& prev.onCreateLocalTerminal === next.onCreateLocalTerminal;
}
const AppHostTreeLayerInner: React.FC<AppHostTreeLayerProps> = ({
enabled,
hosts,
customGroups,
@@ -154,3 +180,6 @@ export const AppHostTreeLayer: React.FC<AppHostTreeLayerProps> = ({
</div>
);
};
export const AppHostTreeLayer = memo(AppHostTreeLayerInner, appHostTreeLayerAreEqual);
AppHostTreeLayer.displayName = 'AppHostTreeLayer';
+184 -113
View File
@@ -1,5 +1,5 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import React, { Suspense, lazy, memo, useCallback, useEffect, useMemo } from 'react';
import React, { Suspense, lazy, memo, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { AlertTriangle, Download, Trash2 } from 'lucide-react';
import { activeTabStore, toEditorTabId, useIsEditorTabActive } from '../state/activeTabStore';
import { editorTabStore } from '../state/editorTabStore';
@@ -12,7 +12,7 @@ import { QuickScriptEditorDialog } from '../../components/scripts/QuickScriptEdi
import { AddToWorkspaceDialog } from '../../components/workspace/AddToWorkspaceDialog';
import { KeyboardInteractiveModal } from '../../components/KeyboardInteractiveModal';
import { PassphraseModal } from '../../components/PassphraseModal';
import { UnsavedChangesProvider } from '../../components/editor/UnsavedChangesDialog';
import { UnsavedChangesProvider, promptUnsavedChanges } from '../../components/editor/UnsavedChangesDialog';
import { SnippetExecutionProvider } from '../../components/SnippetExecutionProvider';
import { Button } from '../../components/ui/button';
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '../../components/ui/dialog';
@@ -23,6 +23,7 @@ import { toast } from '../../components/ui/toast';
import { AppHostTreeLayer } from './AppHostTreeLayer';
import { AppHostEditorLayer } from './AppHostEditorLayer';
import { AppPluginKeybindingHost } from './AppPluginKeybindingHost';
import { shouldOpenHostEditOnWorkSurface } from './workTabSurface';
import { getUiThemeById } from '../../infrastructure/config/uiThemes';
import { buildAppThemeCssVars } from '../state/settingsStateDefaults';
import { useMainWindowInputFocusRecovery } from '../state/useMainWindowInputFocusRecovery';
@@ -65,6 +66,68 @@ const TextEditorTabFallback = ({ tabId }: { tabId: string }) => {
);
};
/** Local draft so keystrokes do not rebuild App chrome domain every character. */
function RenameDraftDialog({
open,
inputId,
initialName,
title,
nameLabel,
placeholder,
cancelLabel,
saveLabel,
onCancel,
onSave,
}: {
open: boolean;
inputId: string;
initialName: string;
title: string;
nameLabel: string;
placeholder: string;
cancelLabel: string;
saveLabel: string;
onCancel: () => void;
onSave: (name: string) => void;
}) {
const [draft, setDraft] = useState(initialName);
useEffect(() => {
if (open) setDraft(initialName);
}, [open, initialName]);
return (
<Dialog open={open} onOpenChange={(nextOpen) => {
if (!nextOpen) onCancel();
}}>
<DialogContent className="max-w-sm">
<DialogHeader>
<DialogTitle>{title}</DialogTitle>
</DialogHeader>
<div className="space-y-2 py-2">
<Label htmlFor={inputId}>{nameLabel}</Label>
<Input
id={inputId}
value={draft}
onChange={(e) => setDraft(e.target.value)}
onKeyDown={(e) => {
if (e.key !== 'Enter') return;
if (!draft.trim()) return;
onSave(draft);
}}
autoFocus
placeholder={placeholder}
/>
</div>
<DialogFooter>
<Button variant="ghost" onClick={onCancel}>{cancelLabel}</Button>
<Button onClick={() => onSave(draft)} disabled={!draft.trim()}>{saveLabel}</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
export type AppViewProps = {
domains: AppViewDomains;
};
@@ -113,7 +176,7 @@ function AppViewInner({ domains }: AppViewProps) {
useMainWindowInputFocusRecovery({ onPageHidden: dismissTransientOverlays });
const {
accentMode, addShellHistoryEntry, addSessionToWorkspace, addToWorkspaceDialog, appendHostToWorkspace, appendLocalTerminalToWorkspace,
accentMode, addShellHistoryEntry, removeShellHistoryEntry, addSessionToWorkspace, addToWorkspaceDialog, appendHostToWorkspace, appendLocalTerminalToWorkspace,
clearAndRemoveSource, clearAndRemoveSources, clearUnsavedConnectionLogs, closeLogView, closeSession, closeTabsBatch, closeWorkspace, commitPluginImporterData, commitVaultImportTransaction, copySessionToNewWindowWithCurrentShell, copySessionWithCurrentShell, copyWorkspaceWithCurrentShell,
connectionLogs, convertKnownHostToHost, createWorkspaceFromSessions, createWorkspaceFromTargets, createWorkspaceWithHosts, customAccent,
customGroups, currentTerminalTheme, deepLinkHostDraft, deleteConnectionLog, draggingSessionId, effectiveKnownHosts, editorTabs, editorWordWrap, emptyVaultConflict,
@@ -126,14 +189,14 @@ function AppViewInner({ domains }: AppViewProps) {
keyBindings, keyboardInteractiveQueue, keys, logViews, managedSources, navigateToSection, noteGroups, notes, openLogView, openNoteRequest, orderedTabsWithEditors, orphanSessions,
passphraseQueue, protocolSelectHost, proxyProfiles, portForwardingRules, quickResults, quickSearch, removeSessionFromWorkspace, reorderWorkTabs, reorderWorkspaceSessions,
resolveEmptyVaultConflict, resolvedTheme, resolveSessionAppearance, runSnippet, sessionLogsDir, sessionLogsEnabled, sessionLogsFormat, sessionLogsTimestampsEnabled, sessionRenameTarget, sshDebugLogsEnabled,
sessionRenameValue, sessions, setActiveTabId, setDeepLinkHostDraft, setDraggingSessionId, setEditorWordWrap,
setNavigateToSection, setSessionRenameValue, setTerminalFontFamilyId, setTerminalFontSize, setVaultFocusRequest, updateSessionFontSize, updateSessionRestoreCwd, updateSessionDynamicTitle, updateSessionCodingCliProvider, clearSessionFontSizeOverride,
setWorkspaceFocusedSession, setWorkspaceRenameValue, settings, sftpAutoOpenSidebar, sftpFollowTerminalCwd, setSftpFollowTerminalCwd, sftpAutoSync, sftpDefaultViewMode, sftpDoubleClickBehavior,
sessions, setActiveTabId, setDeepLinkHostDraft, setDraggingSessionId, setEditorWordWrap,
setNavigateToSection, setTerminalFontFamilyId, setTerminalFontSize, setVaultFocusRequest, updateSessionFontSize, updateSessionRestoreCwd, updateSessionDynamicTitle, updateSessionCodingCliProvider, clearSessionFontSizeOverride,
setWorkspaceFocusedSession, settings, sftpAutoOpenSidebar, sftpFollowTerminalCwd, setSftpFollowTerminalCwd, sftpAutoSync, sftpDefaultViewMode, sftpDoubleClickBehavior,
sftpShowHiddenFiles, sftpUseCompressedUpload, snippetPackages, snippets, splitSessionWithCurrentShell, startSessionRename,
startWorkspaceRename, submitSessionRename, submitWorkspaceRename, t, terminalFontFamilyId, terminalFontSize, terminalSettings, terminalThemeId, themeById,
toggleBroadcast, toggleConnectionLogSaved, toggleScriptsSidePanelRef, toggleSidePanelRef, toggleWorkspaceViewMode, unmanageSource, updateConnectionLog,
readPersistedHosts, readPersistedManagedSources, updateCustomGroups, updateGroupConfigs, updateHostDistro, updateHosts, updateIdentities, updateKeys, updateKnownHosts, updateManagedSources,
updateNoteGroups, updateNotes, updateProxyProfiles, updateSnippetPackages, updateSnippets, updateSplitSizes, updateTerminalSetting, vaultFocusRequest, workspaceRenameTarget, workspaceRenameValue, workspaces,
updateNoteGroups, updateNotes, updateProxyProfiles, updateSnippetPackages, updateSnippets, updateSplitSizes, updateTerminalSetting, vaultFocusRequest, workspaceRenameTarget, workspaces,
VaultViewContainer, SftpViewMount, TerminalLayerMount, LogViewWrapper,
} = ctx;
@@ -182,6 +245,21 @@ function AppViewInner({ domains }: AppViewProps) {
onUpdateHosts: updateHosts,
onSaved: handleWorkSurfaceHostSaved,
});
const openWorkSurfaceHostEdit = workSurfaceHostEditor.openEdit;
const handleEditHostFromOverlay = useCallback((host: (typeof hosts)[number]) => {
if (shouldOpenHostEditOnWorkSurface(activeTabStore.getActiveTabId())) {
openWorkSurfaceHostEdit(host);
return;
}
setDeepLinkHostDraft(host);
setNavigateToSection('hosts');
setActiveTabId('vault');
}, [
openWorkSurfaceHostEdit,
setActiveTabId,
setDeepLinkHostDraft,
setNavigateToSection,
]);
const handleCreateWorkSurfaceHostGroup = useCallback((groupPath: string) => {
updateCustomGroups(Array.from(new Set([...customGroups, groupPath])));
}, [customGroups, updateCustomGroups]);
@@ -195,6 +273,70 @@ function AppViewInner({ domains }: AppViewProps) {
pluginViewTabStore.close(tabId);
}, [orderedTabsWithEditors]);
const orderedTabsWithEditorsRef = useRef(orderedTabsWithEditors);
orderedTabsWithEditorsRef.current = orderedTabsWithEditors;
// Stable for TopTabs memo: read ordered tabs via ref; prompt via module singleton.
const handleRequestCloseEditorTab = useCallback(async (id: string): Promise<boolean> => {
const tab = editorTabStore.getTab(id);
if (!tab) return false;
const closeEditorAndActivateNeighbor = () => {
const closingTabId = toEditorTabId(id);
const list = orderedTabsWithEditorsRef.current;
const idx = list.indexOf(closingTabId);
releaseEditorTabSaveCoordinator(id);
editorTabStore.close(id);
if (activeTabStore.getActiveTabId() !== closingTabId) return;
const next = list[idx - 1] ?? list[idx + 1] ?? 'vault';
activeTabStore.setActiveTabId(next === closingTabId ? 'vault' : next);
};
const dirty = tab.content !== tab.baselineContent;
if (!dirty) {
closeEditorAndActivateNeighbor();
return true;
}
const choice = await promptUnsavedChanges(tab.fileName);
if (choice === 'cancel') return false;
if (choice === 'discard') {
closeEditorAndActivateNeighbor();
return true;
}
if (choice === 'save') {
const ok = await saveEditorTab(id);
if (!ok) {
const msg = editorTabStore.getTab(id)?.saveError ?? 'Save failed';
toast.error(msg, 'SFTP');
return false;
}
const latest = editorTabStore.getTab(id);
if (!latest || latest.content !== latest.baselineContent) return false;
closeEditorAndActivateNeighbor();
return true;
}
return false;
}, []);
// Keep the hotkey ref current during render so Cmd/Ctrl+W never sees the
// App.tsx stub `() => false` between commit and useEffect.
handleRequestCloseEditorTabRef.current = handleRequestCloseEditorTab;
const handleSaveSessionRename = useCallback((name: string) => {
if (!sessionRenameTarget) return;
if (!name.trim()) return;
submitSessionRename(sessionRenameTarget.id, name);
resetSessionRename();
}, [resetSessionRename, sessionRenameTarget, submitSessionRename]);
const handleSaveWorkspaceRename = useCallback((name: string) => {
if (!workspaceRenameTarget) return;
if (!name.trim()) return;
submitWorkspaceRename(workspaceRenameTarget.id, name);
resetWorkspaceRename();
}, [resetWorkspaceRename, submitWorkspaceRename, workspaceRenameTarget]);
useEffect(() => {
const catalog = buildPluginSettingScopeCatalog({
hosts,
@@ -208,54 +350,7 @@ function AppViewInner({ domains }: AppViewProps) {
return (
<SnippetExecutionProvider>
<UnsavedChangesProvider>
{({ prompt }) => {
// Helper: close an editor tab and activate the neighbor (left-preference), or vault.
const closeEditorAndActivateNeighbor = (id: string) => {
const closingTabId = toEditorTabId(id);
const list = orderedTabsWithEditors;
const idx = list.indexOf(closingTabId);
releaseEditorTabSaveCoordinator(id);
editorTabStore.close(id);
if (activeTabStore.getActiveTabId() !== closingTabId) return;
const next = list[idx - 1] ?? list[idx + 1] ?? 'vault';
activeTabStore.setActiveTabId(next === closingTabId ? 'vault' : next);
};
// Real dirty-confirm close handler.
const handleRequestCloseEditorTab = async (id: string): Promise<boolean> => {
const tab = editorTabStore.getTab(id);
if (!tab) return false;
const dirty = tab.content !== tab.baselineContent;
if (!dirty) {
closeEditorAndActivateNeighbor(id);
return true;
}
const choice = await prompt(tab.fileName);
if (choice === 'cancel') return false;
if (choice === 'discard') {
closeEditorAndActivateNeighbor(id);
return true;
}
if (choice === 'save') {
const ok = await saveEditorTab(id);
if (!ok) {
const msg = editorTabStore.getTab(id)?.saveError ?? 'Save failed';
toast.error(msg, 'SFTP');
return false;
}
const latest = editorTabStore.getTab(id);
if (!latest || latest.content !== latest.baselineContent) return false;
closeEditorAndActivateNeighbor(id);
return true;
}
return false;
};
// Expose to the hotkey dispatcher (Cmd/Ctrl+W).
handleRequestCloseEditorTabRef.current = handleRequestCloseEditorTab;
return (
{() => (
<div className="flex flex-col h-screen text-foreground font-sans netcatty-shell" data-terminal-appearance-root onContextMenu={handleRootContextMenu}>
<TopTabs
theme={resolvedTheme}
@@ -272,6 +367,7 @@ function AppViewInner({ domains }: AppViewProps) {
onRenameSession={startSessionRename}
onCopySession={copySessionWithCurrentShell}
onCopySessionToNewWindow={copySessionToNewWindowWithCurrentShell}
onEditHost={handleEditHostFromOverlay}
onRenameWorkspace={startWorkspaceRename}
onCopyWorkspace={copyWorkspaceWithCurrentShell}
onCloseWorkspace={closeWorkspace}
@@ -345,6 +441,7 @@ function AppViewInner({ domains }: AppViewProps) {
onCreateGroup={handleCreateWorkSurfaceHostGroup}
onImportOrReuseKey={importOrReuseKey}
onUpdateSnippets={updateSnippets}
onUpdateHosts={updateHosts}
/>
<VaultViewContainer appThemeStyle={appThemeStyle}>
@@ -446,8 +543,6 @@ function AppViewInner({ domains }: AppViewProps) {
identities={identities}
snippets={snippets}
snippetPackages={snippetPackages}
notes={notes}
noteGroups={noteGroups}
sessions={sessions}
workspaces={workspaces}
knownHosts={effectiveKnownHosts}
@@ -485,6 +580,7 @@ function AppViewInner({ domains }: AppViewProps) {
onUpdateHost={handleUpdateHostFromTerminal}
onAddKnownHost={handleAddKnownHost}
onCommandExecuted={handleTerminalCommandExecuted}
onDeleteShellHistoryEntry={removeShellHistoryEntry}
onTerminalDataCapture={handleTerminalDataCapture}
onCreateWorkspaceFromSessions={createWorkspaceFromSessions}
onAddSessionToWorkspace={addSessionToWorkspace}
@@ -510,8 +606,6 @@ function AppViewInner({ domains }: AppViewProps) {
updateHosts={updateTerminalHosts}
updateSnippets={updateSnippets}
updateSnippetPackages={updateSnippetPackages}
updateNotes={updateNotes}
updateNoteGroups={updateNoteGroups}
sftpDefaultViewMode={sftpDefaultViewMode}
sftpDoubleClickBehavior={sftpDoubleClickBehavior}
sftpAutoSync={sftpAutoSync}
@@ -665,6 +759,11 @@ function AppViewInner({ domains }: AppViewProps) {
showSftpTab={settings.showSftpTab}
onQueryChange={setQuickSearch}
onSelect={handleHostConnectWithProtocolCheck}
onEditHost={(host) => {
setIsQuickSwitcherOpen(false);
setQuickSearch('');
handleEditHostFromOverlay(host);
}}
onSelectTab={(tabId) => {
setActiveTabId(tabId);
setIsQuickSwitcherOpen(false);
@@ -691,59 +790,32 @@ function AppViewInner({ domains }: AppViewProps) {
</LazyLoadBoundary>
)}
<Dialog open={!!sessionRenameTarget} onOpenChange={(open) => {
if (!open) {
resetSessionRename();
}
}}>
<DialogContent className="max-w-sm">
<DialogHeader>
<DialogTitle>{t('dialog.renameSession.title')}</DialogTitle>
</DialogHeader>
<div className="space-y-2 py-2">
<Label htmlFor="session-name">{t('field.name')}</Label>
<Input
id="session-name"
value={sessionRenameValue}
onChange={(e) => setSessionRenameValue(e.target.value)}
onKeyDown={(e) => { if (e.key === 'Enter') submitSessionRename(); }}
autoFocus
placeholder={t('placeholder.sessionName')}
/>
</div>
<DialogFooter>
<Button variant="ghost" onClick={resetSessionRename}>{t('common.cancel')}</Button>
<Button onClick={submitSessionRename} disabled={!sessionRenameValue.trim()}>{t('common.save')}</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<RenameDraftDialog
open={!!sessionRenameTarget}
inputId="session-name"
initialName={sessionRenameTarget ? (sessionRenameTarget.customName || sessionRenameTarget.hostLabel) : ''}
title={t('dialog.renameSession.title')}
nameLabel={t('field.name')}
placeholder={t('placeholder.sessionName')}
cancelLabel={t('common.cancel')}
saveLabel={t('common.save')}
onCancel={resetSessionRename}
onSave={handleSaveSessionRename}
/>
<RenameDraftDialog
open={!!workspaceRenameTarget}
inputId="workspace-name"
initialName={workspaceRenameTarget ? workspaceRenameTarget.title : ''}
title={t('dialog.renameWorkspace.title')}
nameLabel={t('field.name')}
placeholder={t('placeholder.workspaceName')}
cancelLabel={t('common.cancel')}
saveLabel={t('common.save')}
onCancel={resetWorkspaceRename}
onSave={handleSaveWorkspaceRename}
/>
<Dialog open={!!workspaceRenameTarget} onOpenChange={(open) => {
if (!open) {
resetWorkspaceRename();
}
}}>
<DialogContent className="max-w-sm">
<DialogHeader>
<DialogTitle>{t('dialog.renameWorkspace.title')}</DialogTitle>
</DialogHeader>
<div className="space-y-2 py-2">
<Label htmlFor="workspace-name">{t('field.name')}</Label>
<Input
id="workspace-name"
value={workspaceRenameValue}
onChange={(e) => setWorkspaceRenameValue(e.target.value)}
onKeyDown={(e) => { if (e.key === 'Enter') submitWorkspaceRename(); }}
autoFocus
placeholder={t('placeholder.workspaceName')}
/>
</div>
<DialogFooter>
<Button variant="ghost" onClick={resetWorkspaceRename}>{t('common.cancel')}</Button>
<Button onClick={submitWorkspaceRename} disabled={!workspaceRenameValue.trim()}>{t('common.save')}</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{isCreateWorkspaceOpen && (
<LazyLoadBoundary name="Create workspace" resetKey="create-workspace">
@@ -847,8 +919,7 @@ function AppViewInner({ domains }: AppViewProps) {
</DialogContent>
</Dialog>
</div>
);
}}
)}
</UnsavedChangesProvider>
</SnippetExecutionProvider>
);
@@ -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),
+63 -6
View File
@@ -9,8 +9,12 @@ import { sftpTransferCenterStore } from '../state/sftpTransferCenterStore';
import { resumeTransferWithDedicatedSession } from '../state/sftp/dedicatedTransferResume';
import { getSftpTransferResourceKeys, globalSftpTransferScheduler } from '../state/sftp/globalTransferScheduler';
import { hasNewSourceFingerprint } from '../state/sftp/transferProgressMetadata';
import { STORAGE_KEY_SFTP_TRANSFER_CONCURRENCY } from '../../infrastructure/config/storageKeys';
import {
STORAGE_KEY_AUTO_UPDATE_ENABLED,
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 +28,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 +40,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>(
@@ -208,7 +221,7 @@ export function useAppStartupEffects(ctx: StartupEffectsContext) {
// Skip "update available" toast if auto-download has already started or completed
if (updateState.autoDownloadStatus !== 'idle') return;
// Don't show automatic notification when auto-update is disabled
if (localStorageAdapter.readString('netcatty_auto_update_enabled_v1') === 'false') return;
if (localStorageAdapter.readString(STORAGE_KEY_AUTO_UPDATE_ENABLED) === 'false') return;
if (updateState.hasUpdate && updateState.latestRelease) {
const version = updateState.latestRelease.version;
toast.info(
@@ -409,6 +422,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 +436,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 +532,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 +555,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
@@ -9,6 +9,7 @@ import {
reorderWorkTabIds,
resolveWorkTabActiveHostId,
resolveWorkTabHostTreeTheme,
shouldOpenHostEditOnWorkSurface,
} from './workTabSurface';
import type { EditorTab } from '../state/editorTabStore';
import type { Host, TerminalSession, TerminalTheme, Workspace } from '../../types';
@@ -77,6 +78,15 @@ test('root pages are not work tab surfaces', () => {
assert.equal(isRootPageTabId('session-1'), false);
});
test('host edit overlay prefers work-surface editor except on vault/sftp/plugin tabs', () => {
assert.equal(shouldOpenHostEditOnWorkSurface('session-1'), true);
assert.equal(shouldOpenHostEditOnWorkSurface('workspace-1'), true);
assert.equal(shouldOpenHostEditOnWorkSurface('editor:file-1'), true);
assert.equal(shouldOpenHostEditOnWorkSurface('vault'), false);
assert.equal(shouldOpenHostEditOnWorkSurface('sftp'), false);
assert.equal(shouldOpenHostEditOnWorkSurface('plugin-view:demo'), false);
});
test('shared host tree is visible for editor, log, session, and workspace tabs', () => {
const sessionIds = new Set(['session-1']);
const workspaceIds = new Set(['workspace-1']);
+8
View File
@@ -23,6 +23,14 @@ export function isRootPageTabId(activeTabId: string): boolean {
return activeTabId === 'vault' || activeTabId === 'sftp';
}
/**
* Host edit from overlays (Quick Switcher): use the terminal work-surface
* HostDetailsPanel when a work tab is active; otherwise deep-link into Vault.
*/
export function shouldOpenHostEditOnWorkSurface(activeTabId: string): boolean {
return !isRootPageTabId(activeTabId) && !isPluginViewTabId(activeTabId);
}
export function buildOrderedWorkTabIds(
tabOrder: readonly string[],
allTabIds: readonly string[],
+34
View File
@@ -258,6 +258,24 @@ export const enAiMessages: Messages = {
'ai.opencode.check': 'Check',
'ai.opencode.resetPath': 'Reset',
// AI Grok Build (in-app managed agent — distinct from External MCP client install)
'ai.grok.title': 'Grok Build',
'ai.grok.description': "xAI's Grok Build coding agent CLI. Install the Grok CLI, sign in with `grok login` or set XAI_API_KEY, then select it as an external agent.",
'ai.grok.detecting': 'Detecting...',
'ai.grok.detected': 'Detected',
'ai.grok.notFound': 'Not found',
'ai.grok.path': 'Path:',
'ai.grok.notFoundHint': 'Could not find grok in PATH. Install Grok Build CLI or specify the executable path below.',
'ai.grok.customPathPlaceholder': 'e.g. /usr/local/bin/grok',
'ai.grok.check': 'Check',
'ai.grok.resetPath': 'Reset',
'ai.grok.runtime.acp.title': 'Use Grok ACP (agent stdio)',
'ai.grok.runtime.acp.default': 'Default',
'ai.grok.runtime.acp.description':
'Talk to Grok over Agent Client Protocol (grok agent stdio). Injects Netcatty MCP on session/new. Turn off to use the original headless streaming-json CLI path.',
'ai.grok.runtime.streamingJson.hint':
'Using headless streaming-json (grok -p --output-format streaming-json). Project .grok/config.toml is used for MCP injection.',
// AI Default Agent
'ai.defaultAgent': 'Default Agent',
'ai.defaultAgent.description': 'Agent to use when starting a new AI session',
@@ -386,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.',
@@ -425,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',
@@ -511,6 +539,11 @@ export const enAiMessages: Messages = {
'terminal.layer.movePanelLeft': 'Move panel to left',
'terminal.layer.movePanelRight': 'Move panel to right',
'terminal.layer.closePanel': 'Close panel',
'terminal.layer.closePane': 'Close split',
'terminal.layer.resizeSplit': 'Resize split',
'terminal.layer.splitHorizontal': 'Split top and bottom',
'terminal.layer.splitVertical': 'Split left and right',
'terminal.layer.openInNewSplit': 'Open in new split',
'terminal.layer.hostTree.search': 'Search hosts...',
'terminal.layer.hostTree.searchButton': 'Search',
'terminal.layer.hostTree.tagsButton': 'Filter by tags',
@@ -547,6 +580,7 @@ export const enAiMessages: Messages = {
'topTabs.windowOpacity': 'Window opacity',
'topTabs.openSettings': 'Open Settings',
'ai.chat.sessionHistory': 'Session history',
'ai.chat.resizeInput': 'Drag to resize the message input',
'ai.chat.attach': 'Attach',
'ai.chat.terminalSelectionAttachment': 'Terminal selection',
'ai.chat.terminalSelectionLines': 'lines: {count}',
+10
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',
@@ -574,6 +577,9 @@ Highlight the focused split pane:
'settings.terminal.behavior.bracketedPaste': 'Bracketed paste mode',
'settings.terminal.behavior.bracketedPaste.desc':
'Wrap pasted text with escape sequences so the shell can distinguish paste from typed input. Disable if you see ^[[200~ artifacts.',
'settings.terminal.behavior.autoUploadClipboardImage': 'Auto-upload clipboard images on paste',
'settings.terminal.behavior.autoUploadClipboardImage.desc':
'When the clipboard holds an image, pasting in a remote session uploads it via SFTP to .netcatty-paste-images/ under the remote working directory and inserts the remote path instead of pasting text.',
'settings.terminal.behavior.shiftEnterNewline': 'Shift+Enter text',
'settings.terminal.behavior.shiftEnterNewline.desc':
'Send configured text instead of normal Enter when pressing Shift+Enter in the terminal.',
@@ -680,6 +686,10 @@ Highlight the focused split pane:
'settings.terminal.section.serverStats': 'Server Stats (Linux)',
'settings.terminal.hostInfoBar.show': 'Show host information bar',
'settings.terminal.hostInfoBar.show.desc': 'Show the host address and server information above the terminal. Action buttons remain available when hidden.',
'settings.terminal.hostInfoBar.titleMode': 'Host information title',
'settings.terminal.hostInfoBar.titleMode.desc': 'Choose whether the bar shows the connection address (user@host) or the vault host label.',
'settings.terminal.hostInfoBar.titleMode.address': 'Connection address',
'settings.terminal.hostInfoBar.titleMode.label': 'Host label',
'settings.terminal.section.systemManager': 'System Manager',
'settings.terminal.systemManager.processRefreshInterval': 'Process list refresh',
'settings.terminal.systemManager.processRefreshInterval.desc': 'How often to refresh the process list in the system manager side panel.',
@@ -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',
+16
View File
@@ -34,6 +34,7 @@ export const enTerminalMessages: Messages = {
'history.action.paste': 'Paste to terminal',
'history.action.run': 'Run in terminal',
'history.action.saveAsSnippet': 'Save as snippet',
'history.action.delete': 'Delete from history',
'terminal.toolbar.library': 'Library',
'terminal.toolbar.noSnippets': 'No snippets available',
'terminal.toolbar.terminalSettings': 'Terminal settings',
@@ -81,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',
@@ -332,6 +337,13 @@ export const enTerminalMessages: Messages = {
'cloudSync.connect.webdav.failedTitle': 'WebDAV connection failed',
'cloudSync.connect.s3.success': 'S3 connected successfully',
'cloudSync.connect.s3.failedTitle': 'S3 connection failed',
'cloudSync.connect.plugin.success': 'Plugin sync provider connected successfully',
'cloudSync.connect.plugin.failedTitle': 'Plugin sync connection failed',
'cloudSync.pluginConfig.title': 'Configure {name}',
'cloudSync.pluginConfig.desc': 'Enter the JSON configuration required by this plugin sync provider.',
'cloudSync.pluginConfig.label': 'Provider configuration (JSON)',
'cloudSync.pluginConfig.invalidJson': 'Configuration must be valid JSON.',
'cloudSync.pluginConfig.schemaInvalid': 'Configuration does not match the provider schema.',
'cloudSync.lastSync.never': 'Never',
'cloudSync.lastSync.justNow': 'Just now',
'cloudSync.lastSync.minutesAgo': '{minutes} min ago',
@@ -680,6 +692,10 @@ export const enTerminalMessages: Messages = {
'snippets.selection.selectVisible': 'Select visible',
'snippets.selection.deselectAll': 'Deselect all',
'snippets.selection.exportSelected': 'Export selected ({count})',
'snippets.selection.deleteSelected': 'Delete ({count})',
'snippets.selection.deleteConfirmTitle': 'Delete selected items ({count})?',
'snippets.selection.deleteConfirmDesc': 'The selected items will be permanently deleted. This action cannot be undone.',
'snippets.selection.deleteSuccess': 'Deleted selected items: {count}.',
'snippets.export.snippet': 'Export snippet',
'snippets.export.package': 'Export script package',
'snippets.export.toast.empty': 'No snippets to export.',
+9 -32
View File
@@ -113,8 +113,6 @@ export const enVaultMessages: Messages = {
'knownHosts.empty.title': 'No Known Hosts',
'knownHosts.empty.desc':
"Known hosts are SSH servers you've connected to before. Import from your system's known_hosts file to get started.",
'knownHosts.results.showingLimited':
'Showing {shown} of {total} hosts. Use search to find specific hosts.',
'knownHosts.toast.scanUnavailable': 'System scan is unavailable on this platform.',
'knownHosts.toast.scanNoFile': 'No system known_hosts file found.',
'knownHosts.toast.scanNoEntries': 'No usable entries found in known_hosts.',
@@ -268,6 +266,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',
@@ -448,13 +448,6 @@ export const enVaultMessages: Messages = {
'settings.tab.sftpFileAssociations': 'SFTP',
'settings.sftp.transferConcurrency': 'Transfer Concurrency',
'settings.sftp.transferConcurrency.desc': 'Maximum number of files transferred in parallel for the same endpoint, including folder contents and separately selected files. Higher values may improve speed but can overwhelm some servers.',
'settings.sftp.transferPoolIdleTtl': 'Transfer connection keep-alive',
'settings.sftp.transferPoolIdleTtl.desc': 'How long dedicated transfer SFTP connections stay warm after idle. Longer values reduce cold-start delay before the next upload or download. Busy transfers are never closed.',
'settings.sftp.transferPoolIdleTtl.1m': '1 minute',
'settings.sftp.transferPoolIdleTtl.5m': '5 minutes',
'settings.sftp.transferPoolIdleTtl.15m': '15 minutes',
'settings.sftp.transferPoolIdleTtl.30m': '30 minutes',
'settings.sftp.transferPoolIdleTtl.never': 'Until app quit',
'settings.ssh.transportIdleTtl': 'SSH connection keep-alive',
'settings.ssh.transportIdleTtl.desc': 'How long a shared SSH connection stays warm after the last terminal, SFTP browse, bulk transfer, or port-forward lease is released. While warm, reconnecting to the same host can skip re-authentication (including MFA). Active sessions and transfers are never closed.',
'settings.ssh.transportIdleTtl.1m': '1 minute',
@@ -487,6 +480,9 @@ export const enVaultMessages: Messages = {
'settings.sshDeepLink.title': 'SSH and Telnet links',
'settings.sshDeepLink.enable': 'Open ssh:// and telnet:// links with Netcatty',
'settings.sshDeepLink.enableDesc': 'Allow Netcatty to handle ssh:// and telnet:// links from browsers and other apps.',
'settings.explorerContextMenu.title': 'Windows Explorer',
'settings.explorerContextMenu.enable': 'Show “Open in Netcatty” in folder context menus',
'settings.explorerContextMenu.enableDesc': 'Adds a right-click item for folders and folder backgrounds so you can open a local terminal there. Applies immediately; no reinstall needed.',
'settings.jmsDeepLink.title': 'JumpServer Links',
'settings.jmsDeepLink.enable': 'Handle jms:// links',
'settings.jmsDeepLink.enableDesc': 'Register Netcatty as the handler for JumpServer jms:// client links. May conflict with the official JumpServer client.',
@@ -557,6 +553,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',
@@ -905,30 +904,8 @@ export const enVaultMessages: Messages = {
'hostDetails.plugin.unavailable': 'The configured provider is unavailable. Configuration is preserved until the plugin is restored.',
'hostDetails.plugin.useSsh': 'Use SSH instead',
// Host form (legacy modal)
'hostForm.title.edit': 'Edit Host',
'hostForm.title.new': 'New Host',
'hostForm.desc.edit': 'Update connection details for this host',
'hostForm.desc.new': 'Create a new SSH host entry',
'hostForm.field.label': 'Label',
'hostForm.placeholder.label': 'My Production Server',
'hostForm.field.hostname': 'Hostname / IP',
'hostForm.placeholder.hostname': '192.168.1.1',
'hostForm.field.port': 'Port',
'hostForm.field.username': 'Username',
'hostForm.field.osType': 'OS Type',
'hostForm.placeholder.selectOs': 'Select OS',
'hostForm.field.group': 'Group',
'hostForm.placeholder.group': 'e.g. AWS, DigitalOcean',
'hostForm.field.tags': 'Tags',
'hostForm.placeholder.addTag': 'Add a tag...',
'hostForm.auth.method': 'Authentication Method',
'hostForm.auth.password': 'Password',
'hostForm.auth.sshKey': 'SSH Key',
// Host form (legacy keys still referenced by TerminalAuthDialog)
'hostForm.auth.selectKey': 'Select an SSH Key',
'hostForm.auth.noKeys': 'No keys available',
'hostForm.auth.noKeysHint': 'No SSH keys found in Keychain. Please create one first.',
'hostForm.saveHost': 'Save Host',
// Connection logs
'logs.table.date': 'Date',
+34
View File
@@ -211,6 +211,24 @@ export const ruAiMessages: Messages = {
'ai.codebuddy.elicitation.validation.format': 'Поле «{field}» должно соответствовать формату {format}.',
'ai.codebuddy.elicitation.validation.option': 'Выберите допустимый вариант для «{field}».',
// AI Grok Build (in-app managed agent — distinct from External MCP client install)
'ai.grok.title': 'Grok Build',
'ai.grok.description': 'Агент программирования Grok Build от xAI (CLI). Установите Grok CLI, выполните `grok login` или задайте XAI_API_KEY, затем выберите его как внешнего агента.',
'ai.grok.detecting': 'Обнаружение...',
'ai.grok.detected': 'Обнаружен',
'ai.grok.notFound': 'Не найден',
'ai.grok.path': 'Путь:',
'ai.grok.notFoundHint': 'Не удалось найти grok в PATH. Установите Grok Build CLI или укажите путь к исполняемому файлу ниже.',
'ai.grok.customPathPlaceholder': 'например, /usr/local/bin/grok',
'ai.grok.check': 'Проверить',
'ai.grok.resetPath': 'Сбросить',
'ai.grok.runtime.acp.title': 'Использовать Grok ACP (agent stdio)',
'ai.grok.runtime.acp.default': 'По умолчанию',
'ai.grok.runtime.acp.description':
'Подключение к Grok через Agent Client Protocol (grok agent stdio). Netcatty MCP внедряется в session/new. Выключите, чтобы использовать исходный headless streaming-json CLI.',
'ai.grok.runtime.streamingJson.hint':
'Режим headless streaming-json (grok -p --output-format streaming-json). MCP внедряется через .grok/config.toml проекта.',
// AI Default Agent
'ai.defaultAgent': 'Агент по умолчанию',
'ai.defaultAgent.description': 'Агент, который будет использоваться при запуске новой AI-сессии',
@@ -338,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': 'Эта команда заблокирована вашей политикой безопасности и не может быть выполнена.',
@@ -377,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': 'Предыдущих сессий нет',
@@ -463,6 +491,11 @@ export const ruAiMessages: Messages = {
'terminal.layer.movePanelLeft': 'Переместить панель влево',
'terminal.layer.movePanelRight': 'Переместить панель вправо',
'terminal.layer.closePanel': 'Закрыть панель',
'terminal.layer.closePane': 'Закрыть область',
'terminal.layer.resizeSplit': 'Изменить размер области',
'terminal.layer.splitHorizontal': 'Разделить сверху и снизу',
'terminal.layer.splitVertical': 'Разделить слева и справа',
'terminal.layer.openInNewSplit': 'Открыть в новой области',
'terminal.layer.hostTree.search': 'Поиск хостов...',
'terminal.layer.hostTree.searchButton': 'Поиск',
'terminal.layer.hostTree.tagsButton': 'Фильтр по тегам',
@@ -492,6 +525,7 @@ export const ruAiMessages: Messages = {
'topTabs.windowOpacity': 'Прозрачность окна',
'topTabs.openSettings': 'Открыть настройки',
'ai.chat.sessionHistory': 'История сессий',
'ai.chat.resizeInput': 'Перетащите, чтобы изменить высоту поля ввода',
'ai.chat.attach': 'Прикрепить',
'ai.chat.terminalSelectionAttachment': 'Выделение терминала',
'ai.chat.terminalSelectionLines': 'строк: {count}',
+10
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',
@@ -535,6 +538,9 @@ export const ruCoreMessages: Messages = {
'settings.terminal.behavior.bracketedPaste': 'Режим bracketed paste',
'settings.terminal.behavior.bracketedPaste.desc':
'Оборачивать вставляемый текст escape-последовательностями, чтобы оболочка отличала вставку от обычного ввода. Отключите, если видите артефакты вида ^[[200~.',
'settings.terminal.behavior.autoUploadClipboardImage': 'Авто-загрузка изображений буфера обмена при вставке',
'settings.terminal.behavior.autoUploadClipboardImage.desc':
'Когда в буфере обмена есть изображение, вставка в удалённой сессии загружает его по SFTP в каталог .netcatty-paste-images/ в текущем удалённом каталоге и вводит удалённый путь вместо вставки текста.',
'settings.terminal.behavior.shiftEnterNewline': 'Текст для Shift+Enter',
'settings.terminal.behavior.shiftEnterNewline.desc':
'Отправлять настроенный текст вместо обычного Enter при нажатии Shift+Enter в терминале.',
@@ -637,6 +643,10 @@ export const ruCoreMessages: Messages = {
'settings.terminal.section.serverStats': 'Статистика сервера (Linux)',
'settings.terminal.hostInfoBar.show': 'Показывать сведения о хосте',
'settings.terminal.hostInfoBar.show.desc': 'Показывать адрес хоста и сведения о сервере над терминалом. Кнопки действий остаются доступными после скрытия.',
'settings.terminal.hostInfoBar.titleMode': 'Заголовок сведений о хосте',
'settings.terminal.hostInfoBar.titleMode.desc': 'Показывать в панели адрес подключения (user@host) или метку хоста из хранилища.',
'settings.terminal.hostInfoBar.titleMode.address': 'Адрес подключения',
'settings.terminal.hostInfoBar.titleMode.label': 'Метка хоста',
'settings.terminal.section.systemManager': 'Системный менеджер',
'settings.terminal.systemManager.processRefreshInterval': 'Обновление списка процессов',
'settings.terminal.systemManager.processRefreshInterval.desc': 'Как часто обновлять список процессов в боковой панели системного менеджера.',
@@ -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': 'Создан',
+16
View File
@@ -55,6 +55,7 @@ export const ruTerminalMessages: Messages = {
'history.action.paste': 'Вставить в терминал',
'history.action.run': 'Выполнить в терминале',
'history.action.saveAsSnippet': 'Сохранить как сниппет',
'history.action.delete': 'Удалить из истории',
'terminal.toolbar.library': 'Библиотека',
'terminal.toolbar.noSnippets': 'Нет доступных сниппетов',
'terminal.toolbar.terminalSettings': 'Настройки терминала',
@@ -102,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': 'Использование памяти',
@@ -348,6 +353,13 @@ export const ruTerminalMessages: Messages = {
'cloudSync.connect.webdav.failedTitle': 'Ошибка подключения WebDAV',
'cloudSync.connect.s3.success': 'S3 успешно подключён',
'cloudSync.connect.s3.failedTitle': 'Ошибка подключения S3',
'cloudSync.connect.plugin.success': 'Провайдер синхронизации плагина подключен',
'cloudSync.connect.plugin.failedTitle': 'Ошибка подключения синхронизации плагина',
'cloudSync.pluginConfig.title': 'Настройка {name}',
'cloudSync.pluginConfig.desc': 'Введите JSON-конфигурацию, требуемую этим провайдером синхронизации.',
'cloudSync.pluginConfig.label': 'Конфигурация провайдера (JSON)',
'cloudSync.pluginConfig.invalidJson': 'Конфигурация должна быть допустимым JSON.',
'cloudSync.pluginConfig.schemaInvalid': 'Конфигурация не соответствует схеме провайдера.',
'cloudSync.lastSync.never': 'Никогда',
'cloudSync.lastSync.justNow': 'Только что',
'cloudSync.lastSync.minutesAgo': '{minutes} мин назад',
@@ -697,6 +709,10 @@ export const ruTerminalMessages: Messages = {
'snippets.selection.selectVisible': 'Выбрать видимые',
'snippets.selection.deselectAll': 'Снять выбор',
'snippets.selection.exportSelected': 'Экспорт выбранных ({count})',
'snippets.selection.deleteSelected': 'Удалить ({count})',
'snippets.selection.deleteConfirmTitle': 'Удалить выбранные элементы ({count})?',
'snippets.selection.deleteConfirmDesc': 'Выбранные элементы будут удалены без возможности восстановления.',
'snippets.selection.deleteSuccess': 'Удалено выбранных элементов: {count}.',
'snippets.export.snippet': 'Экспорт сниппета',
'snippets.export.package': 'Экспорт пакета',
'snippets.export.toast.empty': 'Нет сниппетов для экспорта.',
+8 -32
View File
@@ -150,8 +150,6 @@ export const ruVaultMessages: Messages = {
'knownHosts.empty.title': 'Нет известных хостов',
'knownHosts.empty.desc':
'Известные хосты — это SSH-серверы, к которым вы подключались раньше. Импортируйте системный файл known_hosts, чтобы начать.',
'knownHosts.results.showingLimited':
'Показано {shown} из {total} хостов. Используйте поиск, чтобы найти нужные хосты.',
'knownHosts.toast.scanUnavailable': 'Сканирование системы недоступно на этой платформе.',
'knownHosts.toast.scanNoFile': 'Системный файл known_hosts не найден.',
'knownHosts.toast.scanNoEntries': 'В known_hosts не найдено пригодных записей.',
@@ -305,6 +303,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': 'Показать детали',
@@ -485,13 +484,6 @@ export const ruVaultMessages: Messages = {
'settings.tab.sftpFileAssociations': 'SFTP',
'settings.sftp.transferConcurrency': 'Параллелизм передачи',
'settings.sftp.transferConcurrency.desc': 'Максимальное число файлов, одновременно передаваемых для одной цели, включая содержимое папок и отдельно выбранные файлы. Большие значения могут ускорить передачу, но перегрузить некоторые серверы.',
'settings.sftp.transferPoolIdleTtl': 'Удержание transfer-соединений',
'settings.sftp.transferPoolIdleTtl.desc': 'Как долго простаивающие SFTP-соединения пула передачи остаются открытыми. Большее время снижает задержку перед следующей передачей. Активные передачи не закрываются.',
'settings.sftp.transferPoolIdleTtl.1m': '1 минута',
'settings.sftp.transferPoolIdleTtl.5m': '5 минут',
'settings.sftp.transferPoolIdleTtl.15m': '15 минут',
'settings.sftp.transferPoolIdleTtl.30m': '30 минут',
'settings.sftp.transferPoolIdleTtl.never': 'До выхода из приложения',
'settings.ssh.transportIdleTtl': 'Удержание SSH-соединений',
'settings.ssh.transportIdleTtl.desc': 'Как долго общее SSH-соединение остаётся открытым после освобождения последнего lease терминала, SFTP-браузера, пакетной передачи или проброса портов. Пока оно тёплое, повторное подключение к тому же хосту может обойти повторную аутентификацию (включая MFA). Активные сессии и передачи не закрываются.',
'settings.ssh.transportIdleTtl.1m': '1 минута',
@@ -524,6 +516,9 @@ export const ruVaultMessages: Messages = {
'settings.sshDeepLink.title': 'SSH- и Telnet-ссылки',
'settings.sshDeepLink.enable': 'Открывать ssh:// и telnet:// ссылки в Netcatty',
'settings.sshDeepLink.enableDesc': 'Разрешить Netcatty обрабатывать ssh:// и telnet:// ссылки из браузеров и других приложений.',
'settings.explorerContextMenu.title': 'Проводник Windows',
'settings.explorerContextMenu.enable': 'Показывать «Open in Netcatty» в контекстном меню папок',
'settings.explorerContextMenu.enableDesc': 'Добавляет пункт в меню папок и фона папки, чтобы открыть локальный терминал в этом каталоге. Применяется сразу, переустановка не нужна.',
'settings.jmsDeepLink.title': 'Ссылки JumpServer',
'settings.jmsDeepLink.enable': 'Обрабатывать ссылки jms://',
'settings.jmsDeepLink.enableDesc': 'Зарегистрировать Netcatty как обработчик клиентских ссылок JumpServer jms://. Может конфликтовать с официальным клиентом JumpServer.',
@@ -594,6 +589,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': 'Перейти к',
@@ -938,30 +936,8 @@ export const ruVaultMessages: Messages = {
'hostDetails.plugin.unavailable': 'Настроенный провайдер недоступен. Конфигурация сохранится до восстановления плагина.',
'hostDetails.plugin.useSsh': 'Использовать SSH',
// Host form (legacy modal)
'hostForm.title.edit': 'Редактировать хост',
'hostForm.title.new': 'Новый хост',
'hostForm.desc.edit': 'Обновите параметры подключения для этого хоста',
'hostForm.desc.new': 'Создайте новую запись SSH-хоста',
'hostForm.field.label': 'Метка',
'hostForm.placeholder.label': 'Мой production-сервер',
'hostForm.field.hostname': 'Имя хоста / IP',
'hostForm.placeholder.hostname': '192.168.1.1',
'hostForm.field.port': 'Порт',
'hostForm.field.username': 'Имя пользователя',
'hostForm.field.osType': 'Тип ОС',
'hostForm.placeholder.selectOs': 'Выберите ОС',
'hostForm.field.group': 'Группа',
'hostForm.placeholder.group': 'например, AWS, DigitalOcean',
'hostForm.field.tags': 'Теги',
'hostForm.placeholder.addTag': 'Добавить тег...',
'hostForm.auth.method': 'Метод аутентификации',
'hostForm.auth.password': 'Пароль',
'hostForm.auth.sshKey': 'SSH-ключ',
// Host form (legacy keys still referenced by TerminalAuthDialog)
'hostForm.auth.selectKey': 'Выберите SSH-ключ',
'hostForm.auth.noKeys': 'Нет доступных ключей',
'hostForm.auth.noKeysHint': 'В связке ключей не найдено SSH-ключей. Сначала создайте один.',
'hostForm.saveHost': 'Сохранить хост',
'vault.import.plugins.title': 'Импортёры плагинов',
'vault.import.plugins.loading': 'Создание безопасного предварительного просмотра…',
'vault.import.plugins.progress': 'Обработано {completed} из {total} {message}',
@@ -0,0 +1,32 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import en from './en.ts';
import ru from './ru.ts';
import zhCN from './zh-CN.ts';
import zhTW from './zh-TW.ts';
const KEYS = [
'snippets.selection.deleteSelected',
'snippets.selection.deleteConfirmTitle',
'snippets.selection.deleteConfirmDesc',
'snippets.selection.deleteSuccess',
] as const;
test('snippet bulk-delete copy exists in every locale', () => {
for (const [locale, messages] of Object.entries({ en, ru, zhCN, zhTW })) {
const missing = KEYS.filter((key) => !messages[key]);
assert.deepEqual(missing, [], `${locale} is missing snippet bulk-delete copy`);
}
});
test('English bulk-delete copy is entity-neutral and grammatical for one item', () => {
assert.equal(
en['snippets.selection.deleteConfirmTitle'].replace('{count}', '1'),
'Delete selected items (1)?',
);
assert.equal(
en['snippets.selection.deleteSuccess'].replace('{count}', '1'),
'Deleted selected items: 1.',
);
});
+34
View File
@@ -258,6 +258,24 @@ export const zhCNAiMessages: Messages = {
'ai.opencode.check': '检查',
'ai.opencode.resetPath': '重置',
// AI Grok Build(应用内托管 Agent,与 External MCP「添加到 Grok」不同)
'ai.grok.title': 'Grok Build',
'ai.grok.description': 'xAI 的 Grok Build 编程 Agent CLI。安装 Grok CLI,使用 `grok login` 登录或设置 XAI_API_KEY 后,即可作为外部 Agent 选择。',
'ai.grok.detecting': '检测中...',
'ai.grok.detected': '已检测到',
'ai.grok.notFound': '未找到',
'ai.grok.path': '路径:',
'ai.grok.notFoundHint': '在 PATH 中未找到 grok。请安装 Grok Build CLI 或在下方指定可执行文件路径。',
'ai.grok.customPathPlaceholder': '例如 /usr/local/bin/grok',
'ai.grok.check': '检查',
'ai.grok.resetPath': '重置',
'ai.grok.runtime.acp.title': '使用 Grok ACP(agent stdio)',
'ai.grok.runtime.acp.default': '默认',
'ai.grok.runtime.acp.description':
'通过 Agent Client Protocol(grok agent stdio)接入 Grok。在 session/new 注入 Netcatty MCP。关闭后使用原始 headless streaming-json CLI 路径。',
'ai.grok.runtime.streamingJson.hint':
'当前为 headless streaming-json(grok -p --output-format streaming-json)。MCP 通过项目 .grok/config.toml 注入。',
// AI Default Agent
'ai.defaultAgent': '默认 Agent',
'ai.defaultAgent.description': '创建新 AI 会话时使用的 Agent',
@@ -386,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': '此命令已被安全策略拦截,无法执行。',
@@ -425,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': '没有历史会话',
@@ -511,6 +539,11 @@ export const zhCNAiMessages: Messages = {
'terminal.layer.movePanelLeft': '面板移至左侧',
'terminal.layer.movePanelRight': '面板移至右侧',
'terminal.layer.closePanel': '关闭面板',
'terminal.layer.closePane': '关闭分屏',
'terminal.layer.resizeSplit': '调整分屏大小',
'terminal.layer.splitHorizontal': '上下分屏',
'terminal.layer.splitVertical': '左右分屏',
'terminal.layer.openInNewSplit': '在新分屏中打开',
'terminal.layer.hostTree.search': '搜索主机...',
'terminal.layer.hostTree.searchButton': '搜索',
'terminal.layer.hostTree.tagsButton': '按标签筛选',
@@ -547,6 +580,7 @@ export const zhCNAiMessages: Messages = {
'topTabs.windowOpacity': '窗口透明度',
'topTabs.openSettings': '打开设置',
'ai.chat.sessionHistory': '会话历史',
'ai.chat.resizeInput': '拖动调整消息输入框高度',
'ai.chat.attach': '附件',
'ai.chat.terminalSelectionAttachment': '终端选区',
'ai.chat.terminalSelectionLines': '{count} 行',
+4 -1
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 调试日志',
@@ -729,7 +732,6 @@ export const zhCNCoreMessages: Messages = {
'knownHosts.empty.title': '暂无已知主机',
'knownHosts.empty.desc':
'Known Hosts 是你之前连接过的 SSH server。导入系统的 known_hosts 文件以开始。',
'knownHosts.results.showingLimited': '显示 {shown}/{total} 个主机。使用搜索查找特定主机。',
'knownHosts.toast.scanUnavailable': '当前平台无法扫描系统 known_hosts。',
'knownHosts.toast.scanNoFile': '未找到系统 known_hosts 文件。',
'knownHosts.toast.scanNoEntries': 'known_hosts 中没有可用条目。',
@@ -860,6 +862,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': '创建时间',
+18 -7
View File
@@ -52,6 +52,7 @@ export const zhCNTerminalMessages: Messages = {
'history.action.paste': '粘贴到终端',
'history.action.run': '在终端执行',
'history.action.saveAsSnippet': '保存为代码片段',
'history.action.delete': '从历史记录中删除',
// SFTP File Opener
'sftp.context.copyPath': '复制文件路径',
'sftp.context.openWith': '打开方式...',
@@ -92,13 +93,6 @@ export const zhCNTerminalMessages: Messages = {
'settings.tab.sftpFileAssociations': 'SFTP',
'settings.sftp.transferConcurrency': '传输并发数',
'settings.sftp.transferConcurrency.desc': '同一目标同时传输的文件数量上限,包括文件夹内文件和单独多选的文件。较高的值可能提高速度,但可能导致某些服务器过载。',
'settings.sftp.transferPoolIdleTtl': '传输连接保活时间',
'settings.sftp.transferPoolIdleTtl.desc': '专用传输 SFTP 连接空闲多久后关闭。时间更长可减少下次上传/下载的冷启动等待;进行中的传输不会被关闭。',
'settings.sftp.transferPoolIdleTtl.1m': '1 分钟',
'settings.sftp.transferPoolIdleTtl.5m': '5 分钟',
'settings.sftp.transferPoolIdleTtl.15m': '15 分钟',
'settings.sftp.transferPoolIdleTtl.30m': '30 分钟',
'settings.sftp.transferPoolIdleTtl.never': '直到退出应用',
'settings.ssh.transportIdleTtl': 'SSH 连接保活时间',
'settings.ssh.transportIdleTtl.desc': '最后一个终端、SFTP 浏览、批量传输或端口转发释放后,共享 SSH 连接再保留多久。保留期间再次打开同主机可跳过重新认证(含 MFA);进行中的会话与传输不会被关闭。',
'settings.ssh.transportIdleTtl.1m': '1 分钟',
@@ -131,6 +125,9 @@ export const zhCNTerminalMessages: Messages = {
'settings.sshDeepLink.title': 'SSH 和 Telnet 链接',
'settings.sshDeepLink.enable': '用 Netcatty 打开 ssh:// 和 telnet:// 链接',
'settings.sshDeepLink.enableDesc': '允许 Netcatty 接管来自浏览器和其他应用的 ssh://、telnet:// 链接。',
'settings.explorerContextMenu.title': 'Windows 资源管理器',
'settings.explorerContextMenu.enable': '在文件夹右键菜单中显示 “Open in Netcatty”',
'settings.explorerContextMenu.enableDesc': '为文件夹和文件夹空白处添加右键项,可直接在该目录打开本地终端。开关立即生效,无需重装。',
'settings.jmsDeepLink.title': 'JumpServer 链接',
'settings.jmsDeepLink.enable': '处理 jms:// 链接',
'settings.jmsDeepLink.enableDesc': '将 Netcatty 注册为 JumpServer jms:// 客户端链接的处理程序,可能与官方 JumpServer 客户端冲突。',
@@ -202,6 +199,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': '选择主题',
@@ -307,6 +307,9 @@ export const zhCNTerminalMessages: Messages = {
'settings.terminal.behavior.bracketedPaste': '括号粘贴模式',
'settings.terminal.behavior.bracketedPaste.desc':
'粘贴文本时使用转义序列包裹,以便终端区分粘贴和键入。如果出现 ^[[200~ 字样请关闭此选项。',
'settings.terminal.behavior.autoUploadClipboardImage': '粘贴时自动上传剪贴板图片',
'settings.terminal.behavior.autoUploadClipboardImage.desc':
'当剪贴板包含图片时,在远程会话中粘贴会通过 SFTP 将图片上传到远端当前目录的 .netcatty-paste-images/ 文件夹,并在终端输入远端路径,而不是粘贴文本。',
'settings.terminal.behavior.shiftEnterNewline': 'Shift+Enter 发送文本',
'settings.terminal.behavior.shiftEnterNewline.desc':
'在终端中按 Shift+Enter 时发送配置的文本,而不是普通回车。',
@@ -407,6 +410,10 @@ export const zhCNTerminalMessages: Messages = {
'settings.terminal.section.serverStats': '服务器状态(Linux)',
'settings.terminal.hostInfoBar.show': '显示主机信息栏',
'settings.terminal.hostInfoBar.show.desc': '在终端上方显示主机地址和服务器信息。隐藏后仍可使用操作按钮。',
'settings.terminal.hostInfoBar.titleMode': '主机信息标题',
'settings.terminal.hostInfoBar.titleMode.desc': '选择信息栏显示连接地址(user@host)还是主机名称(Vault 标签)。',
'settings.terminal.hostInfoBar.titleMode.address': '连接地址',
'settings.terminal.hostInfoBar.titleMode.label': '主机名称',
'settings.terminal.serverStats.show': '显示服务器状态',
'settings.terminal.serverStats.show.desc': '在终端状态栏显示 CPU、内存和磁盘使用情况(仅限 Linux 服务器)。',
'settings.terminal.serverStats.refreshInterval': '刷新间隔',
@@ -761,6 +768,10 @@ export const zhCNTerminalMessages: Messages = {
'snippets.selection.selectVisible': '选择当前显示',
'snippets.selection.deselectAll': '取消选择',
'snippets.selection.exportSelected': '导出选中({count})',
'snippets.selection.deleteSelected': '删除({count})',
'snippets.selection.deleteConfirmTitle': '删除选中的 {count} 项?',
'snippets.selection.deleteConfirmDesc': '所选项目将被永久删除,此操作无法撤销。',
'snippets.selection.deleteSuccess': '已删除 {count} 个所选项目。',
'snippets.export.snippet': '导出代码片段',
'snippets.export.package': '导出脚本包',
'snippets.export.toast.empty': '没有可导出的代码片段。',
+12 -22
View File
@@ -331,29 +331,8 @@ export const zhCNVaultMessages: Messages = {
'hostDetails.plugin.useSsh': '改用 SSH',
// Host form (legacy modal)
'hostForm.title.edit': '编辑主机',
'hostForm.title.new': '新建主机',
'hostForm.desc.edit': '更新该主机的连接信息',
'hostForm.desc.new': '创建一个新的 SSH 主机条目',
'hostForm.field.label': '名称',
'hostForm.placeholder.label': 'My Production Server',
'hostForm.field.hostname': 'Hostname / IP',
'hostForm.placeholder.hostname': '192.168.1.1',
'hostForm.field.port': '端口',
'hostForm.field.username': '用户名',
'hostForm.field.osType': '操作系统类型',
'hostForm.placeholder.selectOs': '选择操作系统',
'hostForm.field.group': '分组',
'hostForm.placeholder.group': '例如:AWS、DigitalOcean',
'hostForm.field.tags': '标签',
'hostForm.placeholder.addTag': '添加标签…',
'hostForm.auth.method': '认证方式',
'hostForm.auth.password': '密码',
'hostForm.auth.sshKey': 'SSH密钥',
// Host form (legacy keys still referenced by TerminalAuthDialog)
'hostForm.auth.selectKey': '选择 SSH密钥',
'hostForm.auth.noKeys': '暂无密钥',
'hostForm.auth.noKeysHint': '钥匙串中未找到 SSH密钥,请先创建一个。',
'hostForm.saveHost': '保存主机',
// Connection logs
'logs.table.date': '日期',
@@ -427,6 +406,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': '内存使用',
@@ -659,6 +642,13 @@ export const zhCNVaultMessages: Messages = {
'cloudSync.connect.webdav.failedTitle': 'WebDAV 连接失败',
'cloudSync.connect.s3.success': 'S3 已连接',
'cloudSync.connect.s3.failedTitle': 'S3 连接失败',
'cloudSync.connect.plugin.success': '插件同步提供方已连接',
'cloudSync.connect.plugin.failedTitle': '插件同步连接失败',
'cloudSync.pluginConfig.title': '配置 {name}',
'cloudSync.pluginConfig.desc': '请输入该插件同步提供方所需的 JSON 配置。',
'cloudSync.pluginConfig.label': '提供方配置(JSON)',
'cloudSync.pluginConfig.invalidJson': '配置必须是有效的 JSON。',
'cloudSync.pluginConfig.schemaInvalid': '配置不符合提供方声明的结构。',
'cloudSync.lastSync.never': '从未',
'cloudSync.lastSync.justNow': '刚刚',
'cloudSync.lastSync.minutesAgo': '{minutes} 分钟前',
+34
View File
@@ -258,6 +258,24 @@ export const zhTWAiMessages: Messages = {
'ai.opencode.check': '檢查',
'ai.opencode.resetPath': '重置',
// AI Grok Build(應用內託管 Agent,與 External MCP「新增到 Grok」不同)
'ai.grok.title': 'Grok Build',
'ai.grok.description': 'xAI 的 Grok Build 程式設計 Agent CLI。安裝 Grok CLI,使用 `grok login` 登入或設定 XAI_API_KEY 後,即可作為外部 Agent 選擇。',
'ai.grok.detecting': '偵測中...',
'ai.grok.detected': '已偵測到',
'ai.grok.notFound': '未找到',
'ai.grok.path': '路徑:',
'ai.grok.notFoundHint': '在 PATH 中未找到 grok。請安裝 Grok Build CLI 或在下方指定執行檔路徑。',
'ai.grok.customPathPlaceholder': '例如 /usr/local/bin/grok',
'ai.grok.check': '檢查',
'ai.grok.resetPath': '重置',
'ai.grok.runtime.acp.title': '使用 Grok ACP(agent stdio)',
'ai.grok.runtime.acp.default': '預設',
'ai.grok.runtime.acp.description':
'透過 Agent Client Protocol(grok agent stdio)接入 Grok。在 session/new 注入 Netcatty MCP。關閉後使用原始 headless streaming-json CLI 路徑。',
'ai.grok.runtime.streamingJson.hint':
'目前為 headless streaming-json(grok -p --output-format streaming-json)。MCP 透過專案 .grok/config.toml 注入。',
// AI Default Agent
'ai.defaultAgent': '預設 Agent',
'ai.defaultAgent.description': '建立新 AI 工作階段時使用的 Agent',
@@ -385,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': '此指令已被安全策略攔截,無法執行。',
@@ -424,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': '沒有歷史工作階段',
@@ -510,6 +538,11 @@ export const zhTWAiMessages: Messages = {
'terminal.layer.movePanelLeft': '面板移至左側',
'terminal.layer.movePanelRight': '面板移至右側',
'terminal.layer.closePanel': '關閉面板',
'terminal.layer.closePane': '關閉分割',
'terminal.layer.resizeSplit': '調整分割大小',
'terminal.layer.splitHorizontal': '上下分割',
'terminal.layer.splitVertical': '左右分割',
'terminal.layer.openInNewSplit': '在新分割中開啟',
'terminal.layer.hostTree.search': '搜尋主機...',
'terminal.layer.hostTree.searchButton': '搜尋',
'terminal.layer.hostTree.tagsButton': '按標籤篩選',
@@ -546,6 +579,7 @@ export const zhTWAiMessages: Messages = {
'topTabs.windowOpacity': '視窗透明度',
'topTabs.openSettings': '開啟設定',
'ai.chat.sessionHistory': '工作階段歷史',
'ai.chat.resizeInput': '拖曳調整訊息輸入框高度',
'ai.chat.attach': '附件',
'ai.chat.terminalSelectionAttachment': '終端選區',
'ai.chat.terminalSelectionLines': '{count} 行',
+4 -1
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 除錯日誌',
@@ -729,7 +732,6 @@ export const zhTWCoreMessages: Messages = {
'knownHosts.empty.title': '暫無已知主機',
'knownHosts.empty.desc':
'Known Hosts 是你之前連線過的 SSH server。匯入系統的 known_hosts 檔案以開始。',
'knownHosts.results.showingLimited': '顯示 {shown}/{total} 個主機。使用搜索查詢特定主機。',
'knownHosts.toast.scanUnavailable': '目前平台無法掃描系統 known_hosts。',
'knownHosts.toast.scanNoFile': '未找到系統 known_hosts 檔案。',
'knownHosts.toast.scanNoEntries': 'known_hosts 中沒有可用條目。',
@@ -860,6 +862,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': '建立時間',
+18 -7
View File
@@ -52,6 +52,7 @@ export const zhTWTerminalMessages: Messages = {
'history.action.paste': '貼上到終端',
'history.action.run': '在終端執行',
'history.action.saveAsSnippet': '儲存為程式碼片段',
'history.action.delete': '從歷史記錄中刪除',
// SFTP File Opener
'sftp.context.copyPath': '複製檔案路徑',
'sftp.context.openWith': '開啟方式...',
@@ -92,13 +93,6 @@ export const zhTWTerminalMessages: Messages = {
'settings.tab.sftpFileAssociations': 'SFTP',
'settings.sftp.transferConcurrency': '傳輸併發數',
'settings.sftp.transferConcurrency.desc': '同一目標同時傳輸的檔案數量上限,包括資料夾內檔案和單獨多選的檔案。較高的值可能提高速度,但可能導致某些伺服器過載。',
'settings.sftp.transferPoolIdleTtl': '傳輸連線保活時間',
'settings.sftp.transferPoolIdleTtl.desc': '專用傳輸 SFTP 連線閒置多久後關閉。時間更長可減少下次上傳/下載的冷啟動等待;進行中的傳輸不會被關閉。',
'settings.sftp.transferPoolIdleTtl.1m': '1 分鐘',
'settings.sftp.transferPoolIdleTtl.5m': '5 分鐘',
'settings.sftp.transferPoolIdleTtl.15m': '15 分鐘',
'settings.sftp.transferPoolIdleTtl.30m': '30 分鐘',
'settings.sftp.transferPoolIdleTtl.never': '直到結束應用',
'settings.ssh.transportIdleTtl': 'SSH 連線保活時間',
'settings.ssh.transportIdleTtl.desc': '最後一個終端、SFTP 瀏覽、批次傳輸或連接埠轉發釋放後,共享 SSH 連線再保留多久。保留期間再次開啟同主機可略過重新驗證(含 MFA);進行中的工作階段與傳輸不會被關閉。',
'settings.ssh.transportIdleTtl.1m': '1 分鐘',
@@ -131,6 +125,9 @@ export const zhTWTerminalMessages: Messages = {
'settings.sshDeepLink.title': 'SSH 和 Telnet 連結',
'settings.sshDeepLink.enable': '用 Netcatty 開啟 ssh:// 和 telnet:// 連結',
'settings.sshDeepLink.enableDesc': '允許 Netcatty 接管來自瀏覽器和其他應用程式的 ssh://、telnet:// 連結。',
'settings.explorerContextMenu.title': 'Windows 檔案總管',
'settings.explorerContextMenu.enable': '在資料夾右鍵選單中顯示 “Open in Netcatty”',
'settings.explorerContextMenu.enableDesc': '為資料夾與資料夾空白處加入右鍵項目,可直接在該目錄開啟本機終端機。開關立即生效,無需重裝。',
'settings.jmsDeepLink.title': 'JumpServer 連結',
'settings.jmsDeepLink.enable': '處理 jms:// 連結',
'settings.jmsDeepLink.enableDesc': '將 Netcatty 註冊為 JumpServer jms:// 用戶端連結的處理程式,可能與官方 JumpServer 用戶端衝突。',
@@ -202,6 +199,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': '選擇主題',
@@ -307,6 +307,9 @@ export const zhTWTerminalMessages: Messages = {
'settings.terminal.behavior.bracketedPaste': '括號貼上模式',
'settings.terminal.behavior.bracketedPaste.desc':
'貼上文字時使用轉義序列包裹,以便終端區分貼上和鍵入。如果出現 ^[[200~ 字樣請關閉此選項。',
'settings.terminal.behavior.autoUploadClipboardImage': '貼上時自動上傳剪貼簿圖片',
'settings.terminal.behavior.autoUploadClipboardImage.desc':
'當剪貼簿包含圖片時,在遠端工作階段中貼上會透過 SFTP 將圖片上傳到遠端目前目錄的 .netcatty-paste-images/ 資料夾,並在終端輸入遠端路徑,而不是貼上文字。',
'settings.terminal.behavior.shiftEnterNewline': 'Shift+Enter 傳送文字',
'settings.terminal.behavior.shiftEnterNewline.desc':
'在終端中按 Shift+Enter 時傳送設定的文字,而不是普通 Enter。',
@@ -407,6 +410,10 @@ export const zhTWTerminalMessages: Messages = {
'settings.terminal.section.serverStats': '伺服器狀態(Linux)',
'settings.terminal.hostInfoBar.show': '顯示主機資訊列',
'settings.terminal.hostInfoBar.show.desc': '在終端上方顯示主機地址和伺服器資訊。隱藏後仍可使用操作按鈕。',
'settings.terminal.hostInfoBar.titleMode': '主機資訊標題',
'settings.terminal.hostInfoBar.titleMode.desc': '選擇資訊列顯示連線地址(user@host)還是主機名稱(Vault 標籤)。',
'settings.terminal.hostInfoBar.titleMode.address': '連線地址',
'settings.terminal.hostInfoBar.titleMode.label': '主機名稱',
'settings.terminal.serverStats.show': '顯示伺服器狀態',
'settings.terminal.serverStats.show.desc': '在終端狀態列顯示 CPU、記憶體和磁碟使用情況(僅限 Linux 伺服器)。',
'settings.terminal.serverStats.refreshInterval': '重新整理間隔',
@@ -761,6 +768,10 @@ export const zhTWTerminalMessages: Messages = {
'snippets.selection.selectVisible': '選擇目前顯示',
'snippets.selection.deselectAll': '取消選擇',
'snippets.selection.exportSelected': '匯出選取({count})',
'snippets.selection.deleteSelected': '刪除({count})',
'snippets.selection.deleteConfirmTitle': '刪除選取的 {count} 項?',
'snippets.selection.deleteConfirmDesc': '所選項目將被永久刪除,此操作無法復原。',
'snippets.selection.deleteSuccess': '已刪除 {count} 個所選項目。',
'snippets.export.snippet': '匯出程式碼片段',
'snippets.export.package': '匯出程式碼包',
'snippets.export.toast.empty': '沒有可匯出的程式碼片段。',
+12 -22
View File
@@ -331,29 +331,8 @@ export const zhTWVaultMessages: Messages = {
'hostDetails.plugin.useSsh': '改用 SSH',
// Host form (legacy modal)
'hostForm.title.edit': '編輯主機',
'hostForm.title.new': '新建主機',
'hostForm.desc.edit': '更新該主機的連線資訊',
'hostForm.desc.new': '建立一個新的 SSH 主機條目',
'hostForm.field.label': '名稱',
'hostForm.placeholder.label': 'My Production Server',
'hostForm.field.hostname': 'Hostname / IP',
'hostForm.placeholder.hostname': '192.168.1.1',
'hostForm.field.port': '埠',
'hostForm.field.username': '使用者名稱',
'hostForm.field.osType': '作業系統型別',
'hostForm.placeholder.selectOs': '選擇作業系統',
'hostForm.field.group': '分組',
'hostForm.placeholder.group': '例如:AWS、DigitalOcean',
'hostForm.field.tags': '標籤',
'hostForm.placeholder.addTag': '新增標籤…',
'hostForm.auth.method': '認證方式',
'hostForm.auth.password': '密碼',
'hostForm.auth.sshKey': 'SSH金鑰',
// Host form (legacy keys still referenced by TerminalAuthDialog)
'hostForm.auth.selectKey': '選擇 SSH金鑰',
'hostForm.auth.noKeys': '暫無金鑰',
'hostForm.auth.noKeysHint': '鑰匙串中未找到 SSH金鑰,請先建立一個。',
'hostForm.saveHost': '儲存主機',
// Connection logs
'logs.table.date': '日期',
@@ -427,6 +406,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': '記憶體使用',
@@ -659,6 +642,13 @@ export const zhTWVaultMessages: Messages = {
'cloudSync.connect.webdav.failedTitle': 'WebDAV 連線失敗',
'cloudSync.connect.s3.success': 'S3 已連線',
'cloudSync.connect.s3.failedTitle': 'S3 連線失敗',
'cloudSync.connect.plugin.success': '外掛同步提供者已連線',
'cloudSync.connect.plugin.failedTitle': '外掛同步連線失敗',
'cloudSync.pluginConfig.title': '設定 {name}',
'cloudSync.pluginConfig.desc': '請輸入此外掛同步提供者所需的 JSON 設定。',
'cloudSync.pluginConfig.label': '提供者設定(JSON)',
'cloudSync.pluginConfig.invalidJson': '設定必須是有效的 JSON。',
'cloudSync.pluginConfig.schemaInvalid': '設定不符合提供者宣告的結構。',
'cloudSync.lastSync.never': '從未',
'cloudSync.lastSync.justNow': '剛剛',
'cloudSync.lastSync.minutesAgo': '{minutes} 分鐘前',
+2 -2
View File
@@ -400,7 +400,7 @@ function writeApplyInProgressSentinel(record: VaultApplyInProgressRecord): void
type ProtectedApplyCallback = () => void | Promise<void>;
export type ApplyProtectedSyncPayloadOptions = {
buildPreApplyPayload: () => SyncPayload;
buildPreApplyPayload: () => SyncPayload | Promise<SyncPayload>;
translateProtectiveBackupFailure: (message: string) => string;
} & (
| { applyPayload: ProtectedApplyCallback; prepareApply?: never }
@@ -415,7 +415,7 @@ export function applyProtectedSyncPayload(
const applyPayload = options.prepareApply
? await options.prepareApply()
: options.applyPayload;
const pre = buildPreApplyPayload();
const pre = await buildPreApplyPayload();
let protectiveBackupId: string | null = null;
try {
const backup = await createRequiredProtectiveLocalVaultBackup(pre);
@@ -0,0 +1,137 @@
import assert from 'node:assert/strict';
import { describe, it } from 'node:test';
import { storePluginSyncSecretsThenConnect } from './pluginSyncConnectWithSecrets.ts';
describe('storePluginSyncSecretsThenConnect', () => {
it('deletes just-created secret keys when connect rejects', async () => {
const putCalls: Array<{ key: string; value: string }> = [];
const deletedKeys: string[][] = [];
let connectCalls = 0;
await assert.rejects(
() => storePluginSyncSecretsThenConnect({
providerId: 'plugin:sync.example',
secrets: [
{ secretKey: 'sync-credential', value: 'bad-password' },
{ secretKey: 'sync-credential:token', value: 'bad-token' },
],
putSecret: async ({ key, value }) => {
putCalls.push({ key, value });
return { kind: 'secret', id: `ref-${key}`, key };
},
deleteSecrets: async ({ keys }) => {
deletedKeys.push([...keys]);
return { deleted: keys.length };
},
connect: async () => {
connectCalls += 1;
throw new Error('auth failed');
},
}),
/auth failed/,
);
assert.equal(connectCalls, 1);
assert.deepEqual(putCalls, [
{ key: 'sync-credential', value: 'bad-password' },
{ key: 'sync-credential:token', value: 'bad-token' },
]);
assert.deepEqual(deletedKeys, [['sync-credential', 'sync-credential:token']]);
});
it('does not delete secrets when connect succeeds', async () => {
const deletedKeys: string[][] = [];
await storePluginSyncSecretsThenConnect({
providerId: 'plugin:sync.example',
secrets: [{ secretKey: 'sync-credential', value: 'ok' }],
putSecret: async ({ key }) => ({ kind: 'secret', id: 'ref-1', key }),
deleteSecrets: async ({ keys }) => {
deletedKeys.push([...keys]);
return { deleted: keys.length };
},
connect: async () => {},
});
assert.deepEqual(deletedKeys, []);
});
it('does not delete when reusing an existing credential without new secrets', async () => {
const deletedKeys: string[][] = [];
const existing = { kind: 'secret' as const, id: 'existing-ref', key: 'sync-credential' };
await assert.rejects(
() => storePluginSyncSecretsThenConnect({
providerId: 'plugin:sync.example',
secrets: [],
existingCredential: existing,
putSecret: async () => {
throw new Error('put should not run');
},
deleteSecrets: async ({ keys }) => {
deletedKeys.push([...keys]);
return { deleted: keys.length };
},
connect: async () => {
throw new Error('network failed');
},
}),
/network failed/,
);
assert.deepEqual(deletedKeys, []);
});
it('deletes secrets that were put before a later put fails', async () => {
const deletedKeys: string[][] = [];
await assert.rejects(
() => storePluginSyncSecretsThenConnect({
providerId: 'plugin:sync.example',
secrets: [
{ secretKey: 'sync-credential', value: 'first' },
{ secretKey: 'sync-credential:token', value: 'second' },
],
putSecret: async ({ key }) => {
if (key === 'sync-credential:token') throw new Error('put failed');
return { kind: 'secret', id: 'ref-1', key };
},
deleteSecrets: async ({ keys }) => {
deletedKeys.push([...keys]);
return { deleted: keys.length };
},
connect: async () => {
throw new Error('connect should not run');
},
}),
/put failed/,
);
assert.deepEqual(deletedKeys, [['sync-credential']]);
});
it('keeps overwritten secrets when reconnect connect rejects', async () => {
const deletedKeys: string[][] = [];
const restoredKeys: string[][] = [];
await assert.rejects(
() => storePluginSyncSecretsThenConnect({
providerId: 'plugin:sync.example',
secrets: [{ secretKey: 'sync-credential', value: 'bad-password' }],
putSecret: async ({ key }) => ({
kind: 'secret',
id: 'ref-overwritten',
key,
created: false,
}),
deleteSecrets: async ({ keys }) => {
deletedKeys.push([...keys]);
return { deleted: keys.length };
},
restoreSecrets: async ({ keys }) => {
restoredKeys.push([...keys]);
return { restored: keys.length };
},
connect: async () => {
throw new Error('auth failed');
},
}),
/auth failed/,
);
assert.deepEqual(deletedKeys, []);
assert.deepEqual(restoredKeys, [['sync-credential']]);
});
});
+107
View File
@@ -0,0 +1,107 @@
/**
* Persist plugin sync secrets then connect; roll back just-created secrets if
* connect (or a later put) fails so bad passwords are not left in OS storage.
* Overwritten keys are restored from the host-side overwrite stash so a
* rejected reconnect does not leave a broken SecretRef / rejected credential.
*/
export type PluginSyncSecretRef = { kind: 'secret'; id: string; key: string };
export interface PluginSyncConnectSecretInput {
secretKey: string;
value: string;
}
export interface PluginSyncPutSecretResult extends PluginSyncSecretRef {
/** False when the durable (plugin,key) row already existed and was overwritten. */
created?: boolean;
}
export interface StorePluginSyncSecretsThenConnectParams {
providerId: string;
secrets: readonly PluginSyncConnectSecretInput[];
/** Reused when `secrets` is empty (edit non-secret fields / reconnect). */
existingCredential?: PluginSyncSecretRef;
putSecret: (params: {
providerId: string;
key: string;
value: string;
}) => Promise<PluginSyncPutSecretResult>;
deleteSecrets: (params: {
providerId: string;
keys: string[];
}) => Promise<unknown>;
/**
* Restore host-stashed plaintext for overwritten keys (`discard: false`),
* or drop the stash after a successful connect (`discard: true`).
*/
restoreSecrets?: (params: {
providerId: string;
keys: string[];
discard?: boolean;
}) => Promise<unknown>;
connect: (credential: PluginSyncSecretRef | undefined) => Promise<void>;
}
export async function storePluginSyncSecretsThenConnect(
params: StorePluginSyncSecretsThenConnectParams,
): Promise<void> {
const createdKeys: string[] = [];
const overwrittenKeys: string[] = [];
let credential: PluginSyncSecretRef | undefined = params.existingCredential;
try {
if (params.secrets.length > 0) {
credential = undefined;
for (const secret of params.secrets) {
const ref = await params.putSecret({
providerId: params.providerId,
key: secret.secretKey,
value: secret.value,
});
if (ref.created === false) {
overwrittenKeys.push(secret.secretKey);
} else {
createdKeys.push(secret.secretKey);
}
// SyncConnectPayload.credential carries the primary (first) secret;
// additional secrets remain addressable via secrets.get(key).
if (!credential) credential = ref;
}
}
await params.connect(credential);
if (overwrittenKeys.length > 0 && params.restoreSecrets) {
try {
await params.restoreSecrets({
providerId: params.providerId,
keys: [...overwrittenKeys],
discard: true,
});
} catch {
/* best-effort stash cleanup */
}
}
} catch (error) {
if (createdKeys.length > 0) {
try {
await params.deleteSecrets({
providerId: params.providerId,
keys: [...createdKeys],
});
} catch {
/* best-effort; surface the original connect/put error */
}
}
if (overwrittenKeys.length > 0 && params.restoreSecrets) {
try {
await params.restoreSecrets({
providerId: params.providerId,
keys: [...overwrittenKeys],
});
} catch {
/* best-effort; surface the original connect/put error */
}
}
throw error;
}
}
+209
View File
@@ -0,0 +1,209 @@
import test from 'node:test';
import assert from 'node:assert/strict';
type LocalStorageMock = {
clear(): void;
getItem(key: string): string | null;
setItem(key: string, value: string): void;
removeItem(key: string): void;
};
function installLocalStorage(): LocalStorageMock {
const store = new Map<string, string>();
const localStorage: LocalStorageMock = {
clear() { store.clear(); },
getItem(key) { return store.has(key) ? store.get(key)! : null; },
setItem(key, value) { store.set(key, String(value)); },
removeItem(key) { store.delete(key); },
};
Object.defineProperty(globalThis, 'localStorage', {
value: localStorage,
configurable: true,
});
return localStorage;
}
const localStorage = installLocalStorage();
const { SYNC_STORAGE_KEYS } = await import('../domain/sync.ts');
const { localStorageAdapter } = await import('../infrastructure/persistence/localStorageAdapter.ts');
const {
collectPluginSyncSidecarsFromHost,
commitPluginSidecarsLastKnown,
isPluginSidecarHostReady,
} = await import('./pluginSyncSidecarBridge.ts');
const { hasMeaningfulCloudSyncData } = await import('./syncPayload.ts');
test.beforeEach(() => {
localStorage.clear();
delete (globalThis as { window?: unknown }).window;
});
test('isPluginSidecarHostReady follows pluginHostReady probe when present', () => {
(globalThis as { window: unknown }).window = {
netcatty: {
pluginHostReady: () => true,
async collectPluginSyncSidecars() {
return { version: 1, entries: [] };
},
},
};
assert.equal(isPluginSidecarHostReady(), true);
(globalThis as { window: unknown }).window = {
netcatty: {
pluginHostReady: () => false,
async collectPluginSyncSidecars() {
return null;
},
},
};
assert.equal(isPluginSidecarHostReady(), false);
});
test('collect defers empty last-known until commit (empty-vault guard ignores last-known alone)', async () => {
localStorageAdapter.write(SYNC_STORAGE_KEYS.PLUGIN_SIDECARS_LAST_KNOWN, {
version: 1,
entries: [{
pluginId: 'com.example.p',
kind: 'settings',
key: 'k',
value: 1,
updatedAt: 1,
}],
});
(globalThis as { window: unknown }).window = {
netcatty: {
async collectPluginSyncSidecars() {
return { version: 1, entries: [] };
},
},
};
const collected = await collectPluginSyncSidecarsFromHost();
assert.deepEqual(collected, { version: 1, entries: [] });
// Last-known still holds prior entries until commit, but that alone must not
// bypass the empty-vault upload guard.
const lastKnown = localStorageAdapter.read<{ entries: unknown[] }>(
SYNC_STORAGE_KEYS.PLUGIN_SIDECARS_LAST_KNOWN,
);
assert.equal(lastKnown?.entries?.length, 1);
assert.equal(
hasMeaningfulCloudSyncData({
hosts: [],
keys: [],
identities: [],
snippets: [],
customGroups: [],
syncedAt: 1,
pluginSidecars: { version: 1, entries: [] },
}),
false,
);
commitPluginSidecarsLastKnown({ version: 1, entries: [] });
const committed = localStorageAdapter.read<{ entries: unknown[] }>(
SYNC_STORAGE_KEYS.PLUGIN_SIDECARS_LAST_KNOWN,
);
assert.deepEqual(committed?.entries, []);
});
test('liveOnly collect returns null instead of last-known when host is gated off', async () => {
localStorageAdapter.write(SYNC_STORAGE_KEYS.PLUGIN_SIDECARS_LAST_KNOWN, {
version: 1,
entries: [{
pluginId: 'com.example.p',
kind: 'settings',
key: 'k',
value: 1,
updatedAt: 1,
}],
});
(globalThis as { window: unknown }).window = {
netcatty: {
async collectPluginSyncSidecars() {
return null;
},
},
};
assert.equal(await collectPluginSyncSidecarsFromHost({ liveOnly: true }), null);
const fallback = await collectPluginSyncSidecarsFromHost();
assert.equal(fallback?.entries?.length, 1);
});
test('liveOnly collect omits pending remote when replay cannot apply', async () => {
localStorageAdapter.write(SYNC_STORAGE_KEYS.PLUGIN_SIDECARS_PENDING_REMOTE, {
version: 1,
entries: [{
pluginId: 'com.example.remote',
kind: 'settings',
key: 'k',
value: 'remote',
updatedAt: 2,
}],
});
(globalThis as { window: unknown }).window = {
netcatty: {
async collectPluginSyncSidecars() {
return { version: 1, entries: [] };
},
// no applyPluginSyncSidecars → pending cannot replay
},
};
assert.equal(await collectPluginSyncSidecarsFromHost({ liveOnly: true }), null);
const uploadPath = await collectPluginSyncSidecarsFromHost();
assert.equal(uploadPath?.entries?.[0]?.value, 'remote');
});
test('commitPluginSidecarsAfterSuccessfulSync prefers merged payload sidecars', async () => {
const { commitPluginSidecarsAfterSuccessfulSync } = await import('./pluginSyncSidecarBridge.ts');
localStorageAdapter.write(SYNC_STORAGE_KEYS.PLUGIN_SIDECARS_LAST_KNOWN, {
version: 1,
entries: [{
pluginId: 'com.example.old',
kind: 'settings',
key: 'k',
value: 'old',
updatedAt: 1,
}],
});
commitPluginSidecarsAfterSuccessfulSync(
{
pluginSidecars: {
version: 1,
entries: [{
pluginId: 'com.example.local',
kind: 'settings',
key: 'k',
value: 'local',
updatedAt: 2,
}],
},
},
[{
success: true,
mergedPayload: {
pluginSidecars: {
version: 1,
entries: [{
pluginId: 'com.example.merged',
kind: 'settings',
key: 'k',
value: 'merged',
updatedAt: 3,
}],
},
},
}],
);
const committed = localStorageAdapter.read<{ entries: Array<{ value: string }> }>(
SYNC_STORAGE_KEYS.PLUGIN_SIDECARS_LAST_KNOWN,
);
assert.equal(committed?.entries?.[0]?.value, 'merged');
});
+343
View File
@@ -0,0 +1,343 @@
/**
* Renderer bridge for plugin encrypted-sync sidecars.
* Collects/applies sidecars through the main-process non-cascade store when
* the plugin host is available. When the host is offline, last-known sidecars
* are retained so cloud uploads cannot wipe previously synced plugin data.
*/
import type { PluginSyncSidecarBundle } from '../domain/pluginSyncSidecar';
import { SYNC_STORAGE_KEYS } from '../domain/sync';
import { localStorageAdapter } from '../infrastructure/persistence/localStorageAdapter';
/** Ordinary upload fallback when collect cannot reach the host. */
const LAST_KNOWN_SIDECARS_KEY = SYNC_STORAGE_KEYS.PLUGIN_SIDECARS_LAST_KNOWN;
/**
* Remote apply that could not reach the host DB. Distinct from last-known so
* a later collect does not re-apply a stale post-collect snapshot over newer
* local plugin settings.
*/
const PENDING_REMOTE_SIDECARS_KEY = SYNC_STORAGE_KEYS.PLUGIN_SIDECARS_PENDING_REMOTE;
const HOST_UNAVAILABLE_MARKER = 'PLUGIN_SIDECAR_HOST_UNAVAILABLE';
export class PluginSidecarHostUnavailableError extends Error {
readonly code = HOST_UNAVAILABLE_MARKER;
constructor(message = 'Plugin sidecar host is unavailable') {
super(message);
this.name = 'PluginSidecarHostUnavailableError';
}
}
export function isPluginSidecarHostUnavailableError(error: unknown): boolean {
if (error instanceof PluginSidecarHostUnavailableError) return true;
if (!error || typeof error !== 'object') return false;
const maybe = error as { code?: unknown; message?: unknown };
if (maybe.code === HOST_UNAVAILABLE_MARKER) return true;
return typeof maybe.message === 'string'
&& maybe.message.includes('Plugin sidecar host is unavailable');
}
type ElectronSidecarApi = {
collectPluginSyncSidecars?: () => Promise<PluginSyncSidecarBundle | null | undefined>;
applyPluginSyncSidecars?: (
bundle: PluginSyncSidecarBundle | null | undefined,
) => Promise<unknown>;
/** True only when main process wired PluginSyncSidecarService. */
pluginHostReady?: () => boolean;
};
function getSidecarApi(): ElectronSidecarApi | null {
if (typeof window === 'undefined') return null;
// Preload exposes the production bridge as window.netcatty only.
const bridge = (window as Window & {
netcatty?: ElectronSidecarApi;
electron?: ElectronSidecarApi;
}).netcatty
?? (window as Window & { electron?: ElectronSidecarApi }).electron;
return bridge ?? null;
}
/**
* Whether the main-process sidecar service is wired. Version-change backups
* should wait for this (or a grace timeout) before latching, otherwise an
* early empty collect permanently skips plugin settings in the upgrade snapshot.
*/
export function isPluginSidecarHostReady(): boolean {
const api = getSidecarApi();
if (!api) return false;
if (typeof api.pluginHostReady === 'function') {
try {
return api.pluginHostReady() === true;
} catch {
return false;
}
}
// Older / test bridges without the probe: collect presence implies ready.
return typeof api.collectPluginSyncSidecars === 'function';
}
function readBundle(key: string): PluginSyncSidecarBundle | null {
const raw = localStorageAdapter.read<PluginSyncSidecarBundle>(key);
if (!raw || !Array.isArray(raw.entries)) return null;
return { version: 1, entries: raw.entries };
}
function writeBundle(key: string, bundle: PluginSyncSidecarBundle | null | undefined): void {
if (!bundle || !Array.isArray(bundle.entries)) {
localStorageAdapter.remove(key);
return;
}
const ok = localStorageAdapter.write(key, {
version: 1,
entries: bundle.entries,
});
if (ok === false) {
throw new Error(
`Failed to persist plugin sidecars (${key}); local storage write was rejected`,
);
}
}
function readLastKnownSidecars(): PluginSyncSidecarBundle | null {
return readBundle(LAST_KNOWN_SIDECARS_KEY);
}
function writeLastKnownSidecars(bundle: PluginSyncSidecarBundle | null | undefined): void {
writeBundle(LAST_KNOWN_SIDECARS_KEY, bundle);
}
/** null means no pending remote apply. Empty entries is a valid pending reset. */
function readPendingRemoteSidecars(): PluginSyncSidecarBundle | null {
const raw = localStorageAdapter.read<PluginSyncSidecarBundle | null>(PENDING_REMOTE_SIDECARS_KEY);
if (raw == null) return null;
if (!Array.isArray(raw.entries)) return null;
return { version: 1, entries: raw.entries };
}
function writePendingRemoteSidecars(bundle: PluginSyncSidecarBundle): void {
const ok = localStorageAdapter.write(PENDING_REMOTE_SIDECARS_KEY, {
version: 1,
entries: bundle.entries,
});
if (ok === false) {
// Operational failure — must not look like host-unavailable (which apply
// swallows). Sync must abort so the remote bundle is not lost.
throw new Error(
'Failed to queue pending remote plugin sidecars; local storage write was rejected',
);
}
}
function clearPendingRemoteSidecars(): void {
localStorageAdapter.remove(PENDING_REMOTE_SIDECARS_KEY);
}
function isAuthoritativeBundle(
value: unknown,
): value is PluginSyncSidecarBundle {
return Boolean(
value
&& typeof value === 'object'
&& Array.isArray((value as PluginSyncSidecarBundle).entries),
);
}
function isSuccessfulApplyResult(result: unknown): boolean {
if (result == null) return false;
if (
typeof result === 'object'
&& result !== null
&& 'applied' in result
&& (result as { applied?: unknown }).applied === false
) {
return false;
}
return true;
}
/**
* Returns:
* - a real bundle (possibly empty entries) when the host collected successfully
* - last-known bundle when the host is unavailable (protect remote from wipe)
* Throws on operational host failure (DB/runtime error) so sync aborts.
*
* Pending remote applies (host was offline during download) are replayed into
* the DB before collection. Ordinary last-known collect snapshots are never
* re-applied, so local settings edits made after the last collect are kept.
*/
export async function collectPluginSyncSidecarsFromHost(options?: {
/**
* When true, never fall back to last-known cache. Returns null if the host is
* gated off / non-authoritative so callers can omit `pluginSidecars` instead
* of applying a stale cache snapshot (e.g. convergent conflict materialize).
*/
liveOnly?: boolean;
} = {}): Promise<PluginSyncSidecarBundle | null> {
const liveOnly = options.liveOnly === true;
const api = getSidecarApi();
if (typeof api?.collectPluginSyncSidecars !== 'function') {
return liveOnly ? null : readLastKnownSidecars();
}
const pendingRemote = readPendingRemoteSidecars();
if (pendingRemote && typeof api.applyPluginSyncSidecars === 'function') {
try {
const replayResult = await api.applyPluginSyncSidecars(pendingRemote);
if (!isSuccessfulApplyResult(replayResult)) {
// Upload path: keep returning pending so we do not push stale local
// over an authoritative remote still waiting to apply.
// liveOnly (conflict materialize): omit instead of attaching the queue.
return liveOnly ? null : pendingRemote;
}
clearPendingRemoteSidecars();
} catch (error) {
// Operational failure: keep pending and surface so upload aborts.
// Host-unavailable during replay: upload keeps pending; liveOnly omits.
if (isPluginSidecarHostUnavailableError(error)) {
return liveOnly ? null : pendingRemote;
}
throw error;
}
} else if (pendingRemote && typeof api.applyPluginSyncSidecars !== 'function') {
return liveOnly ? null : pendingRemote;
}
const bundle = await api.collectPluginSyncSidecars();
// Passive/null means the plugin host is gated off or manager resolution failed.
// Do not treat that as an authoritative empty bundle (would wipe last-known).
if (bundle == null || !isAuthoritativeBundle(bundle)) {
return liveOnly ? null : readLastKnownSidecars();
}
const normalized: PluginSyncSidecarBundle = {
version: 1,
entries: bundle.entries,
};
// Defer overwriting a non-empty last-known with an authoritative empty collect.
// useAutoSync runs hasMeaningfulCloudSyncData() after buildPayload/collect; if
// we erased last-known here, a deliberate plugin-only reset (entries → [])
// would look like an empty vault and be blocked. Commit the empty cache after
// a successful upload via commitPluginSidecarsLastKnown().
const previous = readLastKnownSidecars();
const previousHadEntries = Array.isArray(previous?.entries) && previous.entries.length > 0;
if (!(previousHadEntries && normalized.entries.length === 0)) {
writeLastKnownSidecars(normalized);
}
return normalized;
}
/**
* Persist the sidecar bundle that was just successfully synced (or applied).
* Clears a deferred non-empty last-known after an empty reset upload.
*/
export function commitPluginSidecarsLastKnown(
bundle: PluginSyncSidecarBundle | null | undefined,
): void {
if (!bundle || !Array.isArray(bundle.entries)) {
writeLastKnownSidecars({ version: 1, entries: [] });
return;
}
writeLastKnownSidecars({ version: 1, entries: bundle.entries });
}
/**
* After a successful cloud upload/download round-trip, finalize the deferred
* last-known sidecar cache. Prefer merged/downloaded payload sidecars when
* present so we do not overwrite a remote apply with the pre-sync local collect.
*/
export function commitPluginSidecarsAfterSuccessfulSync(
payload: { pluginSidecars?: PluginSyncSidecarBundle | null },
results: Iterable<{
success?: boolean;
mergedPayload?: { pluginSidecars?: PluginSyncSidecarBundle | null } | null;
}>,
): void {
const resultList = Array.from(results);
if (!resultList.some((result) => result.success === true)) return;
let commitSidecars: PluginSyncSidecarBundle | undefined;
if (Object.prototype.hasOwnProperty.call(payload, 'pluginSidecars')) {
commitSidecars = {
version: 1,
entries: Array.isArray(payload.pluginSidecars?.entries) ? payload.pluginSidecars.entries : [],
};
}
for (const result of resultList) {
if (
result.mergedPayload
&& Object.prototype.hasOwnProperty.call(result.mergedPayload, 'pluginSidecars')
) {
commitSidecars = {
version: 1,
entries: Array.isArray(result.mergedPayload.pluginSidecars?.entries)
? result.mergedPayload.pluginSidecars.entries
: [],
};
break;
}
}
if (commitSidecars) commitPluginSidecarsLastKnown(commitSidecars);
}
export async function applyPluginSyncSidecarsFromHost(
bundle: PluginSyncSidecarBundle | null | undefined,
): Promise<void> {
// Empty bundle is authoritative (cloud deleted all sidecars) — still apply.
const normalized: PluginSyncSidecarBundle = {
version: 1,
entries: Array.isArray(bundle?.entries) ? bundle.entries : [],
};
const api = getSidecarApi();
if (typeof api?.applyPluginSyncSidecars !== 'function') {
// Host offline: queue remote apply for later DB replay, and keep last-known
// for upload protection so cloud is not wiped with empty collect.
writePendingRemoteSidecars(normalized);
writeLastKnownSidecars(normalized);
throw new PluginSidecarHostUnavailableError(
'Plugin sidecar host is unavailable; cannot apply downloaded sidecars',
);
}
const result = await api.applyPluginSyncSidecars(normalized);
// Passive IPC returns null when the manager is unavailable, or
// { applied: false } when the sidecar service was not wired.
if (!isSuccessfulApplyResult(result)) {
writePendingRemoteSidecars(normalized);
writeLastKnownSidecars(normalized);
throw new PluginSidecarHostUnavailableError(
'Plugin sidecar host is unavailable; cannot apply downloaded sidecars',
);
}
clearPendingRemoteSidecars();
// Prefer merged entries returned by apply (includes missing-plugin rows the
// remote omitted) when refreshing last-known without a successful collect.
const appliedMerged: PluginSyncSidecarBundle | null = Array.isArray(
(result as { entries?: unknown }).entries,
)
? { version: 1, entries: (result as { entries: PluginSyncSidecarBundle['entries'] }).entries }
: null;
const fallbackLastKnown = appliedMerged ?? normalized;
// Prefer a fresh host collect so last-known reflects post-apply host state.
if (typeof api.collectPluginSyncSidecars === 'function') {
try {
const collected = await api.collectPluginSyncSidecars();
if (isAuthoritativeBundle(collected)) {
writeLastKnownSidecars({ version: 1, entries: collected.entries });
return;
}
// Apply already committed; collect shape was non-authoritative. Prefer the
// merged apply result over the raw remote so preserved local rows survive.
writeLastKnownSidecars(fallbackLastKnown);
return;
} catch {
// Apply already committed. Do not fall back to the raw remote bundle —
// that would drop missing-plugin rows applyFromSync preserved.
try {
writeLastKnownSidecars(fallbackLastKnown);
} catch {
// last-known write failure is secondary; apply already succeeded.
}
return;
}
}
// No collect API: use merged apply result when available, else remote.
writeLastKnownSidecars(fallbackLastKnown);
}
+85
View File
@@ -0,0 +1,85 @@
import type React from 'react';
import {
STORAGE_KEY_AI_PANEL_DIAGNOSTIC_HIDE,
STORAGE_KEY_AI_PANEL_DIAGNOSTIC_PROFILE,
} from '../../infrastructure/config/storageKeys';
import { localStorageAdapter } from '../../infrastructure/persistence/localStorageAdapter';
export const AI_PANEL_DIAGNOSTIC_HIDE_KEY = STORAGE_KEY_AI_PANEL_DIAGNOSTIC_HIDE;
export const AI_PANEL_DIAGNOSTIC_PROFILE_KEY = STORAGE_KEY_AI_PANEL_DIAGNOSTIC_PROFILE;
export const AI_PANEL_FORCE_HIDE_ALL_CONTENT = false;
export const AI_PANEL_FORCE_HIDE_SHELL = false;
export type AIPanelDiagnosticPart =
| 'all'
| 'attachments'
| 'header'
| 'history'
| 'input'
| 'markdown'
| 'messages'
| 'recent'
| 'toolcalls';
function readLocalStorageValue(key: string): string {
try {
return localStorageAdapter.readString(key) ?? '';
} catch {
return '';
}
}
export function getAIPanelDiagnosticHiddenParts(): ReadonlySet<string> {
if (AI_PANEL_FORCE_HIDE_ALL_CONTENT) {
return new Set(['all']);
}
const raw = readLocalStorageValue(AI_PANEL_DIAGNOSTIC_HIDE_KEY);
return new Set(
raw
.split(',')
.map((part) => part.trim().toLowerCase())
.filter(Boolean),
);
}
export function isAIPanelDiagnosticPartHidden(
part: AIPanelDiagnosticPart,
hiddenParts = getAIPanelDiagnosticHiddenParts(),
): boolean {
return hiddenParts.has('all') || hiddenParts.has(part);
}
export function isAIPanelDiagnosticsProfilingEnabled(): boolean {
const raw = readLocalStorageValue(AI_PANEL_DIAGNOSTIC_PROFILE_KEY).trim().toLowerCase();
return raw === '1' || raw === 'true' || raw === 'yes' || raw === 'on';
}
export function logAIPanelProfiler(
id: string,
phase: 'mount' | 'update' | 'nested-update',
actualDuration: number,
baseDuration: number,
): void {
if (!isAIPanelDiagnosticsProfilingEnabled()) return;
console.info(
`[AI panel profile] ${id} ${phase}: actual=${actualDuration.toFixed(1)}ms base=${baseDuration.toFixed(1)}ms`,
);
}
export function profileAIPanelCalculation<T>(label: string, calculate: () => T): T {
if (!isAIPanelDiagnosticsProfilingEnabled()) return calculate();
const startedAt = performance.now();
try {
return calculate();
} finally {
const elapsed = performance.now() - startedAt;
console.info(`[AI panel profile] ${label}: ${elapsed.toFixed(1)}ms`);
}
}
export function getAIPanelProfilerProps(id: string): Pick<React.ProfilerProps, 'id' | 'onRender'> {
return {
id,
onRender: logAIPanelProfiler,
};
}
@@ -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,
);
}
+46 -1
View File
@@ -27,6 +27,7 @@ const systemLight: AppearanceRenderSnapshot = {
darkUiThemeId: "midnight",
accentMode: "theme",
customAccent: "208 100% 50%",
customAccentVersion: 0,
};
test("an OS color event cannot persist a stale System choice over a newer Dark choice", () => {
@@ -97,6 +98,7 @@ test("a non-theme appearance IPC value wins over stale local storage for that ke
darkUiThemeId: "midnight",
accentMode: "theme",
customAccent: "208 100% 50%",
customAccentVersion: 0,
};
// Storage still lags for every non-theme field (the race the review covers).
const staleStored: StoredAppearanceValues = {
@@ -151,6 +153,22 @@ test("a non-theme appearance IPC value wins over stale local storage for that ke
}, customAccentSelection);
assert.equal(nextCustomAccent.customAccent, "221.2 83.2% 53.3%");
// Stale lower-version accent echoes must not clobber a newer local drag sample.
const afterDrag = {
...current,
customAccent: "199 89% 48%",
customAccentVersion: 2,
};
const staleAccentEcho = resolveAppearanceSyncState(afterDrag, {
...staleStored,
customAccent: "0 84% 60%",
}, {
key: STORAGE_KEY_COLOR,
value: { color: "0 84% 60%", version: 1 },
});
assert.equal(staleAccentEcho.customAccent, "199 89% 48%");
assert.equal(staleAccentEcho.customAccentVersion, 2);
// Without an announced key, full rehydrate still reads storage.
const noIncoming = resolveAppearanceSyncState(current, {
...staleStored,
@@ -168,6 +186,7 @@ test("sequential keyed appearance IPC updates compose without stale-storage clob
darkUiThemeId: "midnight",
accentMode: "theme",
customAccent: "208 100% 50%",
customAccentVersion: 0,
};
const staleStored: StoredAppearanceValues = { ...initial };
@@ -196,6 +215,7 @@ test("System on a light OS changes to Dark in every open follow-app terminal", (
darkUiThemeId: systemLight.darkUiThemeId,
accentMode: systemLight.accentMode,
customAccent: systemLight.customAccent,
customAccentVersion: systemLight.customAccentVersion,
};
const terminalAppearance = (appearance: AppearanceState, resolvedTheme: "light" | "dark") => (
resolveGlobalTerminalAppearance({
@@ -292,7 +312,7 @@ test("the race guards are wired into the real settings paths", () => {
const lightNotifyIndex = stateSource.indexOf("notifySettingsChanged(STORAGE_KEY_UI_THEME_LIGHT, lightUiThemeId)", writeIndex);
const darkNotifyIndex = stateSource.indexOf("notifySettingsChanged(STORAGE_KEY_UI_THEME_DARK, darkUiThemeId)", writeIndex);
const accentModeNotifyIndex = stateSource.indexOf("notifySettingsChanged(STORAGE_KEY_ACCENT_MODE, accentMode)", writeIndex);
const colorNotifyIndex = stateSource.indexOf("notifySettingsChanged(STORAGE_KEY_COLOR, customAccent)", writeIndex);
const colorNotifyIndex = stateSource.indexOf("notifySettingsChanged(STORAGE_KEY_COLOR, customAccentRecord)", writeIndex);
assert.ok(guardIndex >= 0, "the settings effect must compare persisted appearance fields");
assert.ok(returnIndex > guardIndex && returnIndex < writeIndex, "the stale render must stop before storage is written");
@@ -301,6 +321,8 @@ test("the race guards are wired into the real settings paths", () => {
assert.ok(darkNotifyIndex > writeIndex, "dark UI theme changes must be announced over IPC with the new value");
assert.ok(accentModeNotifyIndex > writeIndex, "accent mode changes must be announced over IPC with the new value");
assert.ok(colorNotifyIndex > writeIndex, "custom accent changes must be announced over IPC with the new value");
assert.match(stateSource, /shouldBroadcastCustomAccentChange/);
assert.match(stateSource, /serializeCustomAccentRecord\(customAccentRecord\)/);
// Source still only notifies fields that actually changed (keyed, not a single theme-only broadcast).
assert.match(
stateSource,
@@ -321,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",
);
});
+41 -15
View File
@@ -6,10 +6,14 @@ import {
STORAGE_KEY_UI_THEME_LIGHT,
} from '../../infrastructure/config/storageKeys';
import {
isValidHslToken,
isValidTheme,
isValidUiThemeId,
} from './settingsStateDefaults';
import {
parseCustomAccentRecord,
shouldApplyCustomAccentRecord,
type CustomAccentRecord,
} from './customAccentSync';
export type AppearanceState = {
theme: "light" | "dark" | "system";
@@ -17,6 +21,8 @@ export type AppearanceState = {
darkUiThemeId: string;
accentMode: "theme" | "custom";
customAccent: string;
/** Sync revision for custom accent; gates stale IPC/storage echoes during color-picker drag. */
customAccentVersion: number;
};
export type AppearanceRenderSnapshot = {
@@ -50,7 +56,8 @@ export function hasPersistedAppearanceChanged(
|| previous.lightUiThemeId !== current.lightUiThemeId
|| previous.darkUiThemeId !== current.darkUiThemeId
|| previous.accentMode !== current.accentMode
|| previous.customAccent !== current.customAccent;
|| previous.customAccent !== current.customAccent
|| previous.customAccentVersion !== current.customAccentVersion;
}
export function resolveIncomingAppearanceValue<T>(
@@ -106,13 +113,20 @@ export function resolveAppearanceSyncState(
current.accentMode,
(value): value is AppearanceState['accentMode'] => value === 'theme' || value === 'custom',
);
const customAccent = resolveIncomingAppearanceValue(
incoming,
STORAGE_KEY_COLOR,
stored.customAccent,
current.customAccent,
(value): value is string => typeof value === 'string' && isValidHslToken(value),
);
const currentAccentRecord: CustomAccentRecord = {
color: current.customAccent,
version: current.customAccentVersion,
};
const incomingAccentRaw = incoming?.key === STORAGE_KEY_COLOR ? incoming.value : undefined;
const resolvedAccentRaw = incomingAccentRaw !== undefined
? incomingAccentRaw
: (incoming ? currentAccentRecord : stored.customAccent);
const resolvedAccentRecord = incomingAccentRaw !== undefined || !incoming
? parseCustomAccentRecord(resolvedAccentRaw)
: currentAccentRecord;
const nextAccentRecord = shouldApplyCustomAccentRecord(currentAccentRecord, resolvedAccentRecord)
? resolvedAccentRecord
: currentAccentRecord;
return {
theme: isValidTheme(theme) ? theme : current.theme,
@@ -123,9 +137,8 @@ export function resolveAppearanceSyncState(
? darkUiThemeId
: current.darkUiThemeId,
accentMode: accentMode === 'theme' || accentMode === 'custom' ? accentMode : current.accentMode,
customAccent: typeof customAccent === 'string' && isValidHslToken(customAccent)
? customAccent.trim()
: current.customAccent,
customAccent: nextAccentRecord.color,
customAccentVersion: nextAccentRecord.version,
};
}
@@ -165,11 +178,24 @@ export function resolveAppearanceStorageEvent(
};
}
if (key === STORAGE_KEY_COLOR) {
if (newValue == null) {
return { handled: true, next: current };
}
const incoming = parseCustomAccentRecord(newValue);
const currentRecord: CustomAccentRecord = {
color: current.customAccent,
version: current.customAccentVersion,
};
if (!shouldApplyCustomAccentRecord(currentRecord, incoming)) {
return { handled: true, next: current };
}
return {
handled: true,
next: newValue && isValidHslToken(newValue)
? { ...current, customAccent: newValue.trim() }
: current,
next: {
...current,
customAccent: incoming.color,
customAccentVersion: incoming.version,
},
};
}
return { handled: false, next: current };
+204
View File
@@ -0,0 +1,204 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {
parseCustomAccentRecord,
serializeCustomAccentRecord,
shouldApplyCustomAccentRecord,
shouldBroadcastCustomAccentChange,
type CustomAccentMutationSource,
type CustomAccentRecord,
} from './customAccentSync.ts';
/**
* Minimal model of the settings ↔ main custom-accent sync loop that caused
* #2743. Models both IPC rebroadcast and stale localStorage overwrites while
* the native color picker fires rapid onChange events.
*/
function simulateAccentDrag(options: {
shouldBroadcast: (
source: CustomAccentMutationSource,
persistMounted: boolean,
) => { shouldBroadcast: boolean; nextSource: CustomAccentMutationSource };
shouldApply: (current: CustomAccentRecord, incoming: CustomAccentRecord) => boolean;
versioned: boolean;
}): { settingsValues: string[]; mainValues: string[]; storageWrites: string[] } {
let settings: CustomAccentRecord = { color: '221.2 83.2% 53.3%', version: 0 };
let main: CustomAccentRecord = { color: '221.2 83.2% 53.3%', version: 0 };
let settingsSource: CustomAccentMutationSource = 'local';
let mainSource: CustomAccentMutationSource = 'local';
let storage = options.versioned
? serializeCustomAccentRecord(settings)
: settings.color;
const settingsValues: string[] = [];
const mainValues: string[] = [];
const storageWrites: string[] = [];
const pendingIpc: Array<{ to: 'settings' | 'main'; record: CustomAccentRecord }> = [];
const writeStorage = (record: CustomAccentRecord) => {
const next = options.versioned
? serializeCustomAccentRecord(record)
: record.color;
if (next === storage) return;
storage = next;
storageWrites.push(next);
};
const applyLocal = (window: 'settings' | 'main', color: string) => {
const bump = (prev: CustomAccentRecord): CustomAccentRecord => (
options.versioned
? { color, version: prev.version + 1 }
: { color, version: 0 }
);
if (window === 'settings') {
settingsSource = 'local';
settings = bump(settings);
settingsValues.push(settings.color);
writeStorage(settings);
const decision = options.shouldBroadcast(settingsSource, true);
settingsSource = decision.nextSource;
if (decision.shouldBroadcast) pendingIpc.push({ to: 'main', record: { ...settings } });
return;
}
mainSource = 'local';
main = bump(main);
mainValues.push(main.color);
writeStorage(main);
const decision = options.shouldBroadcast(mainSource, true);
mainSource = decision.nextSource;
if (decision.shouldBroadcast) pendingIpc.push({ to: 'settings', record: { ...main } });
};
const applyIncoming = (window: 'settings' | 'main', record: CustomAccentRecord) => {
if (window === 'settings') {
if (!options.shouldApply(settings, record)) return;
settingsSource = 'incoming';
settings = { ...record };
settingsValues.push(settings.color);
writeStorage(settings);
const decision = options.shouldBroadcast(settingsSource, true);
settingsSource = decision.nextSource;
if (decision.shouldBroadcast) pendingIpc.push({ to: 'main', record: { ...settings } });
return;
}
if (!options.shouldApply(main, record)) return;
mainSource = 'incoming';
main = { ...record };
mainValues.push(main.color);
writeStorage(main);
const decision = options.shouldBroadcast(mainSource, true);
mainSource = decision.nextSource;
if (decision.shouldBroadcast) pendingIpc.push({ to: 'settings', record: { ...main } });
};
const flushIpc = () => {
while (pendingIpc.length > 0) {
const next = pendingIpc.shift()!;
applyIncoming(next.to, next.record);
if (settingsValues.length > 40) break;
}
};
const deliverStorageTo = (window: 'settings' | 'main') => {
const record = options.versioned
? parseCustomAccentRecord(JSON.parse(storage))
: parseCustomAccentRecord(storage);
applyIncoming(window, record);
};
applyLocal('settings', '0 84% 60%');
deliverStorageTo('main');
applyLocal('settings', '199 89% 48%');
// Delayed IPC for the older color arrives after the newer local drag sample.
const delayed = pendingIpc.shift();
flushIpc();
if (delayed) applyIncoming(delayed.to, delayed.record);
deliverStorageTo('settings');
flushIpc();
return { settingsValues, mainValues, storageWrites };
}
test('parseCustomAccentRecord accepts legacy HSL tokens and versioned JSON', () => {
assert.deepEqual(parseCustomAccentRecord('199 89% 48%'), { color: '199 89% 48%', version: 0 });
assert.deepEqual(
parseCustomAccentRecord({ color: '0 84% 60%', version: 3 }),
{ color: '0 84% 60%', version: 3 },
);
assert.deepEqual(
parseCustomAccentRecord('{"color":"262.1 83.3% 57.8%","version":9}'),
{ color: '262.1 83.3% 57.8%', version: 9 },
);
assert.equal(parseCustomAccentRecord('bad').color, '221.2 83.2% 53.3%');
});
test('shouldApplyCustomAccentRecord ignores stale revisions', () => {
const current = { color: '199 89% 48%', version: 2 };
assert.equal(shouldApplyCustomAccentRecord(current, { color: '0 84% 60%', version: 1 }), false);
assert.equal(shouldApplyCustomAccentRecord(current, { color: '330 81% 60%', version: 3 }), true);
assert.equal(shouldApplyCustomAccentRecord(current, { color: '199 89% 48%', version: 2 }), false);
});
test('shouldBroadcastCustomAccentChange suppresses incoming rebroadcasts', () => {
assert.deepEqual(
shouldBroadcastCustomAccentChange('incoming', true),
{ shouldBroadcast: false, nextSource: 'local' },
);
assert.deepEqual(
shouldBroadcastCustomAccentChange('local', true),
{ shouldBroadcast: true, nextSource: 'local' },
);
assert.deepEqual(
shouldBroadcastCustomAccentChange('local', false),
{ shouldBroadcast: false, nextSource: 'local' },
);
});
test('legacy unversioned always-broadcast accent sync oscillates during a fast drag', () => {
const alwaysBroadcast = (
_source: CustomAccentMutationSource,
persistMounted: boolean,
) => ({
shouldBroadcast: persistMounted,
nextSource: 'local' as const,
});
const alwaysApply = () => true;
const { settingsValues } = simulateAccentDrag({
shouldBroadcast: alwaysBroadcast,
shouldApply: alwaysApply,
versioned: false,
});
const unique = new Set(settingsValues);
assert.ok(
unique.has('0 84% 60%') && unique.has('199 89% 48%') && settingsValues.length > 2,
`expected oscillation between drag samples, got ${settingsValues.join(' | ')}`,
);
});
test('versioned accent sync ignores stale peer echoes during a fast drag', () => {
const { settingsValues, mainValues } = simulateAccentDrag({
shouldBroadcast: shouldBroadcastCustomAccentChange,
shouldApply: shouldApplyCustomAccentRecord,
versioned: true,
});
assert.deepEqual(settingsValues, ['0 84% 60%', '199 89% 48%']);
assert.ok(mainValues.includes('199 89% 48%'));
assert.equal(
mainValues.includes('0 84% 60%') && mainValues[mainValues.length - 1] === '0 84% 60%',
false,
);
});
test('serializeCustomAccentRecord round-trips through parse', () => {
const raw = serializeCustomAccentRecord({ color: '262.1 83.3% 57.8%', version: 9 });
assert.deepEqual(parseCustomAccentRecord(JSON.parse(raw)), {
color: '262.1 83.3% 57.8%',
version: 9,
});
});
+93
View File
@@ -0,0 +1,93 @@
import { DEFAULT_CUSTOM_ACCENT, isValidHslToken } from './settingsStateDefaults';
export type CustomAccentMutationSource = 'local' | 'incoming';
export type CustomAccentRecord = {
color: string;
version: number;
};
const FALLBACK_RECORD: CustomAccentRecord = {
color: DEFAULT_CUSTOM_ACCENT,
version: 0,
};
function normalizeAccentColor(raw: unknown): string | null {
if (typeof raw !== 'string') return null;
const trimmed = raw.trim();
return isValidHslToken(trimmed) ? trimmed : null;
}
/**
* Parse persisted / IPC custom-accent payloads.
* Accepts legacy plain HSL tokens ("221.2 83.2% 53.3%") and versioned records.
*/
export function parseCustomAccentRecord(raw: unknown): CustomAccentRecord {
if (typeof raw === 'string') {
const trimmed = raw.trim();
if (trimmed.startsWith('{')) {
try {
return parseCustomAccentRecord(JSON.parse(trimmed));
} catch {
// fall through to plain HSL
}
}
const color = normalizeAccentColor(trimmed);
if (color) return { color, version: 0 };
}
if (raw && typeof raw === 'object') {
const record = raw as { color?: unknown; version?: unknown };
const color = normalizeAccentColor(record.color) ?? FALLBACK_RECORD.color;
const version = Number(record.version);
return {
color,
version: Number.isFinite(version) && version > 0 ? Math.floor(version) : 0,
};
}
return { ...FALLBACK_RECORD };
}
export function serializeCustomAccentRecord(record: CustomAccentRecord): string {
const color = normalizeAccentColor(record.color) ?? FALLBACK_RECORD.color;
return JSON.stringify({
color,
version: Math.max(0, Math.floor(record.version) || 0),
});
}
/**
* Incoming peer updates must not clobber a newer local/drag revision.
* Equal versions are treated as already-applied (no state thrash).
*/
export function shouldApplyCustomAccentRecord(
current: CustomAccentRecord,
incoming: CustomAccentRecord,
): boolean {
if (incoming.version > current.version) return true;
if (incoming.version < current.version) return false;
// Same version: only apply when the color itself differs and both are
// legacy/unversioned (version 0), so first-load plain strings still sync.
if (incoming.version === 0 && current.version === 0) {
return incoming.color !== current.color;
}
return false;
}
/**
* Decide whether a custom-accent state change should be rebroadcast to peer
* 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).
*/
export function shouldBroadcastCustomAccentChange(
mutationSource: CustomAccentMutationSource,
persistMounted: boolean,
): { shouldBroadcast: boolean; nextSource: CustomAccentMutationSource } {
if (mutationSource === 'incoming') {
return { shouldBroadcast: false, nextSource: 'local' };
}
return { shouldBroadcast: persistMounted, nextSource: 'local' };
}
@@ -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,
};
}
+1 -1
View File
@@ -19,7 +19,7 @@ const publishCredentialCatalogIds = (ids: ReadonlyArray<string>) => {
};
export const pluginExtensionBridge = Object.freeze({
async listProviders(kind: "connection" | "authentication" | "importer") {
async listProviders(kind: "connection" | "authentication" | "importer" | "sync") {
return requireBridge().listPluginExtensionProviders?.({ kind }) ?? [];
},
async updateCredentialCatalog(entries: ReadonlyArray<{ id: string; ciphertext: string }>) {
+9 -2
View File
@@ -22,6 +22,7 @@ import {
STORAGE_KEY_SSH_DEBUG_LOGS_ENABLED,
STORAGE_KEY_SSH_DEEP_LINK_ENABLED,
STORAGE_KEY_JMS_DEEP_LINK_ENABLED,
STORAGE_KEY_EXPLORER_CONTEXT_MENU_ENABLED,
STORAGE_KEY_SFTP_AUTO_OPEN_SIDEBAR,
STORAGE_KEY_SFTP_FOLLOW_TERMINAL_CWD,
STORAGE_KEY_SFTP_DEFAULT_VIEW_MODE,
@@ -72,7 +73,7 @@ interface UseSettingsIpcSyncParams {
setTerminalThemeLightId: Dispatch<SetStateAction<string>>;
setFollowAppTerminalThemeState: Dispatch<SetStateAction<boolean>>;
setTerminalFontFamilyId: Dispatch<SetStateAction<string>>;
setTerminalFontSize: Dispatch<SetStateAction<number>>;
setTerminalFontSize: (raw: unknown) => void;
mergeIncomingTerminalSettings: (incoming: Partial<TerminalSettings>) => void;
setEditorWordWrapState: Dispatch<SetStateAction<boolean>>;
setSessionLogsEnabled: Dispatch<SetStateAction<boolean>>;
@@ -82,6 +83,7 @@ interface UseSettingsIpcSyncParams {
setSshDebugLogsEnabled: Dispatch<SetStateAction<boolean>>;
setSshDeepLinkEnabledState: (enabled: boolean) => void;
setJmsDeepLinkEnabledState: (enabled: boolean) => void;
setExplorerContextMenuEnabledState: (enabled: boolean) => void;
setHotkeyScheme: Dispatch<SetStateAction<HotkeyScheme>>;
applyIncomingCustomKeyBindings: (incoming: { bindings: CustomKeyBindings; version: number; origin: string }) => void;
setIsHotkeyRecordingState: Dispatch<SetStateAction<boolean>>;
@@ -125,6 +127,7 @@ export function useSettingsIpcSync({
setSshDebugLogsEnabled,
setSshDeepLinkEnabledState,
setJmsDeepLinkEnabledState,
setExplorerContextMenuEnabledState,
setHotkeyScheme,
applyIncomingCustomKeyBindings,
setIsHotkeyRecordingState,
@@ -193,7 +196,7 @@ export function useSettingsIpcSync({
const migrated = migrateIncomingTerminalFontId(value);
if (migrated) setTerminalFontFamilyId(migrated);
}
if (key === STORAGE_KEY_TERM_FONT_SIZE && typeof value === 'number') {
if (key === STORAGE_KEY_TERM_FONT_SIZE) {
setTerminalFontSize(value);
}
if (key === STORAGE_KEY_TERM_SETTINGS) {
@@ -235,6 +238,9 @@ export function useSettingsIpcSync({
if (key === STORAGE_KEY_JMS_DEEP_LINK_ENABLED && typeof value === 'boolean') {
setJmsDeepLinkEnabledState(value);
}
if (key === STORAGE_KEY_EXPLORER_CONTEXT_MENU_ENABLED && typeof value === 'boolean') {
setExplorerContextMenuEnabledState(value);
}
if (key === STORAGE_KEY_HOTKEY_SCHEME && (value === 'disabled' || value === 'mac' || value === 'pc')) {
setHotkeyScheme(value);
}
@@ -331,6 +337,7 @@ export function useSettingsIpcSync({
setSessionLogsTimestampsEnabled,
setSshDeepLinkEnabledState,
setJmsDeepLinkEnabledState,
setExplorerContextMenuEnabledState,
setSshDebugLogsEnabled,
setSftpAutoOpenSidebar,
setSftpFollowTerminalCwd,
@@ -95,6 +95,8 @@ export const DEFAULT_SESSION_LOGS_TIMESTAMPS_ENABLED = false;
export const DEFAULT_SSH_DEBUG_LOGS_ENABLED = false;
export const DEFAULT_SSH_DEEP_LINK_ENABLED = true;
export const DEFAULT_JMS_DEEP_LINK_ENABLED = false;
/** Windows Explorer context menu; true matches NSIS installer default. */
export const DEFAULT_EXPLORER_CONTEXT_MENU_ENABLED = true;
export const readStoredString = (key: string): string | null => {
const raw = localStorageAdapter.readString(key);
+46 -20
View File
@@ -16,6 +16,7 @@ import {
STORAGE_KEY_SSH_DEBUG_LOGS_ENABLED,
STORAGE_KEY_SSH_DEEP_LINK_ENABLED,
STORAGE_KEY_JMS_DEEP_LINK_ENABLED,
STORAGE_KEY_EXPLORER_CONTEXT_MENU_ENABLED,
STORAGE_KEY_RESTORE_PREVIOUS_SESSION,
STORAGE_KEY_RESTORE_TERMINAL_CWD,
STORAGE_KEY_SFTP_AUTO_OPEN_SIDEBAR,
@@ -27,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,
@@ -65,6 +67,7 @@ interface UseSettingsStorageSyncParams {
darkUiThemeId: string;
accentMode: 'theme' | 'custom';
customAccent: string;
customAccentVersion: number;
customCSS: string;
uiFontFamilyId: string;
hotkeyScheme: HotkeyScheme;
@@ -77,6 +80,7 @@ interface UseSettingsStorageSyncParams {
sftpAutoSync: boolean;
sftpShowHiddenFiles: boolean;
sftpUseCompressedUpload: boolean;
sftpSkipUnchanged: boolean;
sftpAutoOpenSidebar: boolean;
sftpFollowTerminalCwd: boolean;
sftpDefaultViewMode: 'list' | 'tree';
@@ -99,6 +103,7 @@ interface UseSettingsStorageSyncParams {
sshDebugLogsEnabled: boolean;
sshDeepLinkEnabled: boolean;
jmsDeepLinkEnabled: boolean;
explorerContextMenuEnabled: boolean;
globalHotkeyEnabled: boolean;
autoUpdateEnabled: boolean;
windowOpacity: number;
@@ -107,7 +112,7 @@ interface UseSettingsStorageSyncParams {
setLightUiThemeId: Dispatch<SetStateAction<string>>;
setDarkUiThemeId: Dispatch<SetStateAction<string>>;
setAccentMode: Dispatch<SetStateAction<'theme' | 'custom'>>;
setCustomAccent: Dispatch<SetStateAction<string>>;
applyIncomingCustomAccent: (raw: unknown) => void;
setCustomCSS: Dispatch<SetStateAction<string>>;
setUiFontFamilyId: Dispatch<SetStateAction<string>>;
setHotkeyScheme: Dispatch<SetStateAction<HotkeyScheme>>;
@@ -117,11 +122,12 @@ interface UseSettingsStorageSyncParams {
setTerminalThemeLightId: Dispatch<SetStateAction<string>>;
setFollowAppTerminalThemeState: Dispatch<SetStateAction<boolean>>;
setTerminalFontFamilyId: Dispatch<SetStateAction<string>>;
setTerminalFontSize: Dispatch<SetStateAction<number>>;
setTerminalFontSize: (raw: unknown) => void;
setSftpDoubleClickBehavior: Dispatch<SetStateAction<'open' | 'transfer'>>;
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'>>;
@@ -144,6 +150,7 @@ interface UseSettingsStorageSyncParams {
setSshDebugLogsEnabled: Dispatch<SetStateAction<boolean>>;
setSshDeepLinkEnabledState: (enabled: boolean) => void;
setJmsDeepLinkEnabledState: (enabled: boolean) => void;
setExplorerContextMenuEnabledState: (enabled: boolean) => void;
setGlobalHotkeyEnabled: Dispatch<SetStateAction<boolean>>;
setWindowOpacity: (raw: unknown) => void;
setAppIconVariant: Dispatch<SetStateAction<AppIconVariant>>;
@@ -157,22 +164,22 @@ interface UseSettingsStorageSyncParams {
export function useSettingsStorageSync({
enabled = true,
theme, lightUiThemeId, darkUiThemeId, accentMode, customAccent,
theme, lightUiThemeId, darkUiThemeId, accentMode, customAccent, customAccentVersion,
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,
editorWordWrap, sessionLogsEnabled, sessionLogsDir, sessionLogsFormat, sessionLogsTimestampsEnabled, sshDebugLogsEnabled, sshDeepLinkEnabled, jmsDeepLinkEnabled, explorerContextMenuEnabled,
globalHotkeyEnabled, autoUpdateEnabled, windowOpacity, appIconVariant,
setTheme, setLightUiThemeId, setDarkUiThemeId, setAccentMode, setCustomAccent,
setTheme, setLightUiThemeId, setDarkUiThemeId, setAccentMode, applyIncomingCustomAccent,
setCustomCSS, setUiFontFamilyId, setHotkeyScheme, setUiLanguage,
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,
setEditorWordWrapState, setSessionLogsEnabled, setSessionLogsDir, setSessionLogsFormat, setSessionLogsTimestampsEnabled, setSshDebugLogsEnabled, setSshDeepLinkEnabledState, setJmsDeepLinkEnabledState, setExplorerContextMenuEnabledState,
setGlobalHotkeyEnabled, setWindowOpacity, setAppIconVariant, setAutoUpdateEnabled, setWorkspaceFocusStyleState,
setSftpTransferConcurrencyState, setSshTransportIdleTtlMsState,
applyIncomingCustomKeyBindings, mergeIncomingTerminalSettings,
@@ -181,23 +188,23 @@ export function useSettingsStorageSync({
// can compare without capturing 25+ state variables in its closure / dep array.
// This avoids constant listener detach/reattach on every state change.
const settingsSnapshotRef = useRef({
theme, lightUiThemeId, darkUiThemeId, accentMode, customAccent,
theme, lightUiThemeId, darkUiThemeId, accentMode, customAccent, customAccentVersion,
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,
editorWordWrap, sessionLogsEnabled, sessionLogsDir, sessionLogsFormat, sessionLogsTimestampsEnabled, sshDebugLogsEnabled, sshDeepLinkEnabled, jmsDeepLinkEnabled, explorerContextMenuEnabled,
globalHotkeyEnabled, autoUpdateEnabled, windowOpacity, appIconVariant,
});
settingsSnapshotRef.current = {
theme, lightUiThemeId, darkUiThemeId, accentMode, customAccent,
theme, lightUiThemeId, darkUiThemeId, accentMode, customAccent, customAccentVersion,
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,
editorWordWrap, sessionLogsEnabled, sessionLogsDir, sessionLogsFormat, sessionLogsTimestampsEnabled, sshDebugLogsEnabled, sshDeepLinkEnabled, jmsDeepLinkEnabled, explorerContextMenuEnabled,
globalHotkeyEnabled, autoUpdateEnabled, windowOpacity, appIconVariant,
};
@@ -212,7 +219,15 @@ export function useSettingsStorageSync({
if (appearance.next.lightUiThemeId !== s.lightUiThemeId) setLightUiThemeId(appearance.next.lightUiThemeId);
if (appearance.next.darkUiThemeId !== s.darkUiThemeId) setDarkUiThemeId(appearance.next.darkUiThemeId);
if (appearance.next.accentMode !== s.accentMode) setAccentMode(appearance.next.accentMode);
if (appearance.next.customAccent !== s.customAccent) setCustomAccent(appearance.next.customAccent);
if (
appearance.next.customAccent !== s.customAccent
|| appearance.next.customAccentVersion !== s.customAccentVersion
) {
applyIncomingCustomAccent({
color: appearance.next.customAccent,
version: appearance.next.customAccentVersion,
});
}
return;
}
if (e.key === STORAGE_KEY_CUSTOM_CSS && e.newValue !== null) {
@@ -283,10 +298,7 @@ export function useSettingsStorageSync({
}
// Sync terminal font size from other windows
if (e.key === STORAGE_KEY_TERM_FONT_SIZE && e.newValue) {
const newSize = parseInt(e.newValue, 10);
if (!isNaN(newSize) && newSize !== s.terminalFontSize) {
setTerminalFontSize(newSize);
}
setTerminalFontSize(e.newValue);
}
// Sync SFTP double-click behavior from other windows
if (e.key === STORAGE_KEY_SFTP_DOUBLE_CLICK_BEHAVIOR && e.newValue) {
@@ -357,6 +369,12 @@ export function useSettingsStorageSync({
setJmsDeepLinkEnabledState(newValue);
}
}
if (e.key === STORAGE_KEY_EXPLORER_CONTEXT_MENU_ENABLED && e.newValue !== null) {
const newValue = e.newValue === 'true';
if (newValue !== s.explorerContextMenuEnabled) {
setExplorerContextMenuEnabledState(newValue);
}
}
// Sync SFTP compressed upload setting from other windows
if (e.key === STORAGE_KEY_SFTP_USE_COMPRESSED_UPLOAD && e.newValue !== null) {
const newValue = e.newValue === 'true' || e.newValue === 'enabled';
@@ -364,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';
@@ -499,7 +523,7 @@ export function useSettingsStorageSync({
mergeIncomingTerminalSettings,
setAccentMode,
setAutoUpdateEnabled,
setCustomAccent,
applyIncomingCustomAccent,
setCustomCSS,
setDarkUiThemeId,
setEditorWordWrapState,
@@ -516,6 +540,7 @@ export function useSettingsStorageSync({
setSessionLogsTimestampsEnabled,
setSshDeepLinkEnabledState,
setJmsDeepLinkEnabledState,
setExplorerContextMenuEnabledState,
setSshDebugLogsEnabled,
setSftpAutoOpenSidebar,
setSftpFollowTerminalCwd,
@@ -526,6 +551,7 @@ export function useSettingsStorageSync({
setSftpTransferConcurrencyState,
setSshTransportIdleTtlMsState,
setSftpUseCompressedUpload,
setSftpSkipUnchanged,
setShowOnlyUngroupedHostsInRootState,
setShowHostTreeSidebarState,
setTerminalSidePanelAutoOpenState,
@@ -50,6 +50,26 @@ test("parks browse only when the interactive surface hides and not already parke
}), false);
});
test("keeps a hidden SFTP owner live while an external editor temp file is open", () => {
const interactive = isBrowseSessionInteractive({
surfaceVisible: false,
hasOwnedEditorTab: false,
hasActiveExternalEdit: true,
});
assert.equal(interactive, true);
assert.equal(shouldParkBrowseSessions({
interactive: false,
browseParked: false,
activeExternalEditCount: 1,
}), false);
assert.equal(shouldParkBrowseSessions({
interactive: false,
browseParked: false,
activeExternalEditCount: 0,
}), true);
});
test("restores browse when the surface becomes interactive again after park", () => {
assert.equal(shouldRestoreBrowseSessions({ interactive: true, browseParked: true }), true);
assert.equal(shouldRestoreBrowseSessions({ interactive: true, browseParked: false }), false);
@@ -9,8 +9,12 @@
export function isBrowseSessionInteractive(params: {
surfaceVisible: boolean;
hasOwnedEditorTab: boolean;
/** External editor temp files (Notepad++ etc.) still need the browse session. */
hasActiveExternalEdit?: boolean;
}): boolean {
return params.surfaceVisible || params.hasOwnedEditorTab;
return params.surfaceVisible
|| params.hasOwnedEditorTab
|| !!params.hasActiveExternalEdit;
}
export function listRemoteBrowseConnectionIds(
@@ -49,8 +53,14 @@ export function shouldParkBrowseSessions(params: {
browseParked: boolean;
/** Defer park while unfinished transfers may still use browse sessions pre-lease. */
activeTransfersCount?: number;
/**
* Defer park while an external editor still holds a downloaded temp file.
* Parking calls closeSftp, which deletes those temps and breaks save/sync.
*/
activeExternalEditCount?: number;
}): boolean {
if (params.activeTransfersCount && params.activeTransfersCount > 0) return false;
if (params.activeExternalEditCount && params.activeExternalEditCount > 0) return false;
return !params.interactive && !params.browseParked;
}
+32
View File
@@ -0,0 +1,32 @@
export type SortField = "name" | "size" | "modified" | "type";
export type SortOrder = "asc" | "desc";
export interface ColumnWidths {
name: number;
modified: number;
size: number;
type: number;
}
export type SftpColumnVisibility = Record<keyof ColumnWidths, boolean>;
export const DEFAULT_SFTP_COLUMN_VISIBILITY: SftpColumnVisibility = {
name: true,
modified: true,
size: true,
type: true,
};
export const normalizeSftpColumnVisibility = (value: unknown): SftpColumnVisibility => {
if (!value || typeof value !== "object" || Array.isArray(value)) {
return DEFAULT_SFTP_COLUMN_VISIBILITY;
}
const stored = value as Partial<Record<keyof ColumnWidths, unknown>>;
return {
name: true,
modified: stored.modified !== false,
size: stored.size !== false,
type: stored.type !== false,
};
};
@@ -1,31 +0,0 @@
import assert from "node:assert/strict";
import test from "node:test";
import { resumeCompressedUploadSafely } from "./compressedUploadControl.ts";
test("live compressed resume failure stays paused instead of starting a second transfer", async () => {
const outcome = await resumeCompressedUploadSafely({
transferId: "compressed-live",
reconnectRequired: false,
resume: async () => ({ success: false, reason: "Upload resume is unavailable" }),
});
assert.deepEqual(outcome, { kind: "failed", reason: "Upload resume is unavailable" });
});
test("restored compressed task may restart when its old worker no longer exists", async () => {
const outcome = await resumeCompressedUploadSafely({
transferId: "compressed-restored",
reconnectRequired: true,
resume: async () => ({ success: false, reason: "Compression is not active" }),
});
assert.deepEqual(outcome, { kind: "restart", reason: "Compression is not active" });
});
test("successful compressed resume rejoins the existing job", async () => {
const outcome = await resumeCompressedUploadSafely({
transferId: "compressed-live",
reconnectRequired: false,
resume: async () => ({ success: true }),
});
assert.deepEqual(outcome, { kind: "resumed" });
});
@@ -1,16 +0,0 @@
export type CompressedUploadResumeOutcome =
| { kind: "resumed" }
| { kind: "restart"; reason?: string }
| { kind: "failed"; reason: string };
export async function resumeCompressedUploadSafely(params: {
transferId: string;
reconnectRequired: boolean;
resume?: (transferId: string) => Promise<{ success: boolean; reason?: string }>;
}): Promise<CompressedUploadResumeOutcome> {
const result = await (params.resume?.(params.transferId)
?? { success: false, reason: "Resume unavailable" });
if (result.success) return { kind: "resumed" };
if (params.reconnectRequired) return { kind: "restart", reason: result.reason };
return { kind: "failed", reason: result.reason ?? "Could not resume the compressed upload." };
}
@@ -29,6 +29,7 @@ import {
isMissingDirectoryReplacePathError,
promoteDirectoryReplaceStage as promoteDirectoryReplacePaths,
} from "./directoryReplacePromotion";
import { sftpTransferCenterStore } from "../sftpTransferCenterStore";
export interface DedicatedResumeDeps {
hosts: readonly Host[];
@@ -664,10 +665,13 @@ async function resumeSingleFileWithDedicatedSession(
: null;
if (!sourceStat) throw new Error("Source is unavailable");
{
// Remote download sources may grow append-only (live logs); keep the
// planned snapshot size and existing checkpoint instead of restarting.
const allowSourceGrowth = sourceType === "sftp";
const validationError = validateTransferResumeSource(task, {
size: sourceStat.size,
lastModified: sourceStat.lastModified,
});
}, { allowSourceGrowth });
const classified = classifyResumeSourceValidationError(validationError);
if (classified.kind === "modified") {
const err = new Error(classified.message || validationError || "Source modified");
@@ -693,7 +697,9 @@ async function resumeSingleFileWithDedicatedSession(
targetSftpId,
sourceHostId: endpoints.sourceHost?.id,
targetHostId: endpoints.targetHost?.id,
totalBytes: task.totalBytes || undefined,
totalBytes: Number.isFinite(task.totalBytes)
? task.totalBytes
: undefined,
resumable: task.resumable !== false,
checkpointBytes: task.checkpointBytes ?? task.transferredBytes ?? 0,
resumeStage: task.resumeStage,
@@ -703,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 };
@@ -1032,6 +1049,9 @@ async function resumeDirectoryWithDedicatedSession(
const planned = traversal.files;
totalFiles = planned.length;
const hasCompactCheckpoint = isValidDirectoryResumeCheckpoint(parent.directoryResumeCheckpoint);
// Compact identities keep size+mtime so a rewrite/truncate fails closed.
// Append-only growth of an already-covered remote file invalidates the
// prefix and retransfers those entries — safer than path-only proofs.
const compactCheckpointValid = hasCompactCheckpoint
&& await validateDirectoryResumeCheckpoint(parent, planned);
if (
@@ -1110,6 +1130,15 @@ async function resumeDirectoryWithDedicatedSession(
const childId = persisted?.id ?? crypto.randomUUID();
const resetPersistedCheckpoint = resetReplaceStage
|| (hasCompactCheckpoint && !compactCheckpointValid);
// Prefer the original planned snapshot size for interrupted download
// children. Fresh traversal `file.size` may already include appends
// (live logs); re-planning to the grown size breaks growth-aware
// resume validation and restarts the file incorrectly.
const plannedTotalBytes = !resetPersistedCheckpoint
&& endpoints.isDownload
&& Number.isFinite(persisted?.totalBytes)
? Math.max(0, Number(persisted?.totalBytes) || 0)
: (file.size || persisted?.totalBytes || 0);
let childBase: TransferTask = {
...parent,
...persisted,
@@ -1123,7 +1152,7 @@ async function resumeDirectoryWithDedicatedSession(
progressMode: "bytes",
ownerId: "dedicated-resume",
status: "transferring",
totalBytes: file.size || persisted?.totalBytes || 0,
totalBytes: plannedTotalBytes,
transferredBytes: resetPersistedCheckpoint
? 0
: (persisted?.checkpointBytes ?? persisted?.transferredBytes ?? 0),
@@ -1168,10 +1197,11 @@ async function resumeDirectoryWithDedicatedSession(
if (!sourceStat) {
throw new Error("Source is unavailable");
}
const allowSourceGrowth = sourceType === "sftp";
const validationError = validateTransferResumeSource(childBase, {
size: sourceStat.size,
lastModified: sourceStat.lastModified,
});
}, { allowSourceGrowth });
const classified = classifyResumeSourceValidationError(validationError);
if (classified.kind === "restart") {
childBase = {
@@ -1196,10 +1226,21 @@ async function resumeDirectoryWithDedicatedSession(
} else if (classified.kind === "fatal") {
throw new Error(classified.message || validationError || "Resume validation failed");
} else {
// Keep the planned snapshot size for growing remote downloads so
// we do not re-plan the whole file mid-resume. Preserve explicit
// zero-byte plans (`||` would incorrectly promote to grown size).
const plannedBytes = Number(childBase.totalBytes);
const hasPlannedBytes = Number.isFinite(plannedBytes) && plannedBytes >= 0;
childBase = {
...childBase,
totalBytes: sourceStat.size || childBase.totalBytes,
sourceLastModified: sourceStat.lastModified ?? childBase.sourceLastModified,
totalBytes: allowSourceGrowth
? (hasPlannedBytes ? plannedBytes : sourceStat.size)
: (sourceStat.size || childBase.totalBytes),
sourceLastModified: allowSourceGrowth
&& hasPlannedBytes
&& sourceStat.size > plannedBytes
? (childBase.sourceLastModified ?? sourceStat.lastModified)
: (sourceStat.lastModified ?? childBase.sourceLastModified),
};
}
@@ -1217,7 +1258,9 @@ async function resumeDirectoryWithDedicatedSession(
targetSftpId,
sourceHostId: endpoints.sourceHost?.id,
targetHostId: endpoints.targetHost?.id,
totalBytes: childBase.totalBytes || file.size || undefined,
totalBytes: Number.isFinite(childBase.totalBytes)
? childBase.totalBytes
: (file.size || undefined),
resumable: parent.resumable !== false,
checkpointBytes: childBase.checkpointBytes ?? 0,
resumeStage: childBase.resumeStage,
@@ -1227,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");
}
@@ -1235,7 +1288,9 @@ async function resumeDirectoryWithDedicatedSession(
options?.onChildUpdate?.({
...childBase,
status: "completed",
transferredBytes: childBase.totalBytes || file.size || childBase.transferredBytes,
transferredBytes: Number.isFinite(childBase.totalBytes)
? childBase.totalBytes
: (file.size || childBase.transferredBytes),
speed: 0,
endTime: Date.now(),
error: undefined,
@@ -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,
});
}
@@ -0,0 +1,28 @@
import assert from "node:assert/strict";
import test from "node:test";
import { createExternalEditTempRetention } from "./externalEditTempRetention";
test("remembers distinct temps per sftp session and forgets one session without touching others", () => {
const retention = createExternalEditTempRetention();
assert.equal(retention.remember("sftp-a", "/tmp/a.txt"), true);
assert.equal(retention.remember("sftp-a", "/tmp/a.txt"), false);
assert.equal(retention.remember("sftp-b", "/tmp/b.txt"), true);
assert.equal(retention.size, 2);
assert.equal(retention.forgetSftp("sftp-a"), true);
assert.equal(retention.size, 1);
assert.equal(retention.forgetSftp("sftp-a"), false);
assert.equal(retention.forgetPath("/tmp/b.txt"), true);
assert.equal(retention.size, 0);
});
test("clear drops every retainer after session-wide cleanup", () => {
const retention = createExternalEditTempRetention();
retention.remember("sftp-1", "/tmp/one.txt");
retention.remember("sftp-2", "/tmp/two.txt");
assert.equal(retention.clear(), true);
assert.equal(retention.size, 0);
assert.equal(retention.clear(), false);
});
@@ -0,0 +1,59 @@
/**
* Renderer-side retainers for remote temps opened in external editors.
* closeSftp deletes those files; forget matching retainers so park/auto-connect
* checks do not keep treating a deleted temp as active work.
*/
export type ExternalEditTempRetention = {
remember(sftpId: string, localPath: string): boolean;
forgetPath(localPath: string): boolean;
forgetSftp(sftpId: string): boolean;
clear(): boolean;
readonly size: number;
};
export function createExternalEditTempRetention(): ExternalEditTempRetention {
const bySftp = new Map<string, Set<string>>();
const recount = (): number => {
let total = 0;
for (const paths of bySftp.values()) total += paths.size;
return total;
};
return {
remember(sftpId, localPath) {
if (!sftpId || !localPath) return false;
let paths = bySftp.get(sftpId);
if (!paths) {
paths = new Set();
bySftp.set(sftpId, paths);
}
if (paths.has(localPath)) return false;
paths.add(localPath);
return true;
},
forgetPath(localPath) {
if (!localPath) return false;
let changed = false;
for (const [sftpId, paths] of bySftp) {
if (!paths.delete(localPath)) continue;
changed = true;
if (paths.size === 0) bySftp.delete(sftpId);
}
return changed;
},
forgetSftp(sftpId) {
if (!sftpId || !bySftp.has(sftpId)) return false;
bySftp.delete(sftpId);
return true;
},
clear() {
if (bySftp.size === 0) return false;
bySftp.clear();
return true;
},
get size() {
return recount();
},
};
}
@@ -138,9 +138,13 @@ test("release invalidates pending watch starts while a new generation remains us
test("backend session cleanup removes a stopped watch from renderer ownership", async () => {
const stopped: Array<{ watchId: string; cleanupTempFile: boolean }> = [];
let onBackendStopped: ((payload: { watchId: string }) => void) | null = null;
let onBackendStopped: ((payload: {
watchId: string;
localPath?: string;
}) => void) | null = null;
let lifecycle: ExternalFileWatchLifecycle | null = null;
let renderer: ReactTestRenderer | null = null;
const forgottenPaths: string[] = [];
function Probe() {
lifecycle = useExternalFileWatchLifecycle(
@@ -151,6 +155,7 @@ test("backend session cleanup removes a stopped watch from renderer ownership",
onBackendStopped = callback;
return () => { onBackendStopped = null; };
},
(localPath) => { forgottenPaths.push(localPath); },
);
return null;
}
@@ -162,9 +167,13 @@ test("backend session cleanup removes a stopped watch from renderer ownership",
assert.equal(lifecycle!.activeCountRef.current, 1);
await act(async () => {
onBackendStopped?.({ watchId: "watch-closed-with-session" });
onBackendStopped?.({
watchId: "watch-closed-with-session",
localPath: "/tmp/externally-deleted.txt",
});
});
assert.equal(lifecycle!.activeCountRef.current, 0);
assert.deepEqual(forgottenPaths, ["/tmp/externally-deleted.txt"]);
await act(async () => renderer!.unmount());
await new Promise((resolve) => setImmediate(resolve));
@@ -19,7 +19,12 @@ export type StopExternalFileWatch = (
) => void | Promise<unknown>;
export type SubscribeExternalFileWatchStopped = (
callback: (payload: { watchId: string }) => void,
callback: (payload: {
watchId: string;
localPath?: string;
remotePath?: string;
sftpId?: string;
}) => void,
) => (() => void) | void;
export interface ExternalFileWatchLifecycle {
@@ -32,16 +37,19 @@ export interface ExternalFileWatchLifecycle {
export function useExternalFileWatchLifecycle(
stopWatch: StopExternalFileWatch,
subscribeStopped?: SubscribeExternalFileWatchStopped,
onStoppedLocalPath?: (localPath: string) => void,
): ExternalFileWatchLifecycle {
const watchIdsRef = useRef<Set<string>>(new Set());
const activeCountRef = useRef(0);
const stopWatchRef = useRef(stopWatch);
const subscribeStoppedRef = useRef(subscribeStopped);
const onStoppedLocalPathRef = useRef(onStoppedLocalPath);
const disposedRef = useRef(false);
const generationRef = useRef(0);
const invalidatedCleanupTempFilesRef = useRef(false);
stopWatchRef.current = stopWatch;
subscribeStoppedRef.current = subscribeStopped;
onStoppedLocalPathRef.current = onStoppedLocalPath;
const captureGeneration = useCallback(() => generationRef.current, []);
@@ -86,9 +94,10 @@ export function useExternalFileWatchLifecycle(
};
}, [releaseAll]);
useEffect(() => subscribeStoppedRef.current?.(({ watchId }) => {
useEffect(() => subscribeStoppedRef.current?.(({ watchId, localPath }) => {
if (!watchId || !watchIdsRef.current.delete(watchId)) return;
activeCountRef.current = watchIdsRef.current.size;
if (localPath) onStoppedLocalPathRef.current?.(localPath);
}), []);
return { activeCountRef, captureGeneration, remember, releaseAll };
@@ -0,0 +1,83 @@
import { useCallback, useMemo, useSyncExternalStore } from "react";
import type { SftpBookmark } from "../../../domain/models";
import { localStorageAdapter } from "../../../infrastructure/persistence/localStorageAdapter";
import { STORAGE_KEY_SFTP_LOCAL_BOOKMARKS } from "../../../infrastructure/config/storageKeys";
import { createSftpBookmark } from "./bookmarkHelpers";
type Listener = () => void;
const listeners = new Set<Listener>();
let snapshot: SftpBookmark[] =
localStorageAdapter.read<SftpBookmark[]>(STORAGE_KEY_SFTP_LOCAL_BOOKMARKS) ?? [];
export function subscribeLocalSftpBookmarks(listener: Listener) {
listeners.add(listener);
return () => {
listeners.delete(listener);
};
}
export function getLocalSftpBookmarksSnapshot() {
return snapshot;
}
export function rehydrateLocalSftpBookmarks() {
snapshot = localStorageAdapter.read<SftpBookmark[]>(STORAGE_KEY_SFTP_LOCAL_BOOKMARKS) ?? [];
for (const listener of listeners) listener();
}
export function setLocalSftpBookmarks(
next: SftpBookmark[] | ((prev: SftpBookmark[]) => SftpBookmark[]),
) {
snapshot = typeof next === "function" ? next(snapshot) : next;
localStorageAdapter.write(STORAGE_KEY_SFTP_LOCAL_BOOKMARKS, snapshot);
for (const listener of listeners) listener();
}
if (typeof window !== "undefined") {
window.addEventListener("storage", (event) => {
if (event.key === STORAGE_KEY_SFTP_LOCAL_BOOKMARKS) {
rehydrateLocalSftpBookmarks();
}
});
}
interface UseLocalSftpBookmarksParams {
currentPath: string | undefined;
}
export const useLocalSftpBookmarks = ({
currentPath,
}: UseLocalSftpBookmarksParams) => {
const bookmarks = useSyncExternalStore(
subscribeLocalSftpBookmarks,
getLocalSftpBookmarksSnapshot,
getLocalSftpBookmarksSnapshot,
);
const isCurrentPathBookmarked = useMemo(
() => !!currentPath && bookmarks.some((b) => b.path === currentPath),
[currentPath, bookmarks],
);
const toggleBookmark = useCallback(() => {
if (!currentPath) return;
if (isCurrentPathBookmarked) {
setLocalSftpBookmarks((prev) => prev.filter((b) => b.path !== currentPath));
} else {
setLocalSftpBookmarks((prev) => [...prev, createSftpBookmark(currentPath)]);
}
}, [currentPath, isCurrentPathBookmarked]);
const deleteBookmark = useCallback((id: string) => {
setLocalSftpBookmarks((prev) => prev.filter((b) => b.id !== id));
}, []);
return {
bookmarks,
isCurrentPathBookmarked,
toggleBookmark,
deleteBookmark,
};
};
@@ -6,7 +6,7 @@
*/
import { useSyncExternalStore, useEffect } from "react";
import { sftpFocusStore, SftpFocusedSide } from "./useSftpFocusedPane";
import { sftpFocusStore, SftpFocusedSide } from "./sftpFocusStore";
type SftpDialogActionType = "rename" | "delete" | "newFolder" | "newFile" | null;
@@ -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(),
}
@@ -1,25 +0,0 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
DEFAULT_SFTP_TRANSFER_POOL_IDLE_TTL_MS,
isTransferPoolIdleReclaimDisabled,
resolveSftpTransferPoolIdleTtlMs,
} from "../../../infrastructure/config/sftpTransferPool.ts";
test("default pool idle TTL is five minutes", () => {
assert.equal(DEFAULT_SFTP_TRANSFER_POOL_IDLE_TTL_MS, 5 * 60_000);
assert.equal(resolveSftpTransferPoolIdleTtlMs(() => null), DEFAULT_SFTP_TRANSFER_POOL_IDLE_TTL_MS);
});
test("accepts preset values including never-reclaim zero", () => {
assert.equal(resolveSftpTransferPoolIdleTtlMs(() => 60_000), 60_000);
assert.equal(resolveSftpTransferPoolIdleTtlMs(() => 0), 0);
assert.equal(resolveSftpTransferPoolIdleTtlMs(() => 123), DEFAULT_SFTP_TRANSFER_POOL_IDLE_TTL_MS);
});
test("transfer pool idle reclaim helper is deprecated and always reclaim-enabled", () => {
// Channels close when idle; SSH park is separate. The helper always returns false.
assert.equal(isTransferPoolIdleReclaimDisabled(0), false);
assert.equal(isTransferPoolIdleReclaimDisabled(300_000), false);
});
-5
View File
@@ -111,9 +111,4 @@ export interface SftpStateOptions {
* This keeps side-panel tab switches on the already-authenticated SSH transport.
*/
resolveBrowseSourceSessionId?: (hostId: string, host?: Host) => string | undefined;
/**
* @deprecated Transfer channels no longer park independently. SSH keep-alive
* is controlled by sshTransportIdleTtlMs in settings.
*/
transferPoolIdleTtlMs?: number;
}
@@ -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,
});
},
});
@@ -157,6 +157,23 @@ test("release metadata closes the backend before a failed connection is retained
assert.equal(cacheKeys.size, 0);
});
test("releaseSftpConnectionMetadata notifies after a remote session closes", async () => {
const sessions = new Map([["connection-1", "sftp-1"]]);
const cacheKeys = new Map([["connection-1", "cache-1"]]);
const notified: string[] = [];
await releaseSftpConnectionMetadata({
connectionId: "connection-1",
sftpSessions: sessions,
connectionCacheKeys: cacheKeys,
clearCacheForConnection: () => {},
closeSftp: async () => {},
onRemoteSessionClosed: (sftpId) => { notified.push(sftpId); },
});
assert.deepEqual(notified, ["sftp-1"]);
});
test("resolvePinnedReconnectSide follows a tab moved to the other side", () => {
assert.equal(
resolvePinnedReconnectSide("left", "tab-1", [], [{ id: "tab-1" }]),
+21 -11
View File
@@ -2,6 +2,7 @@ import React, { useCallback, useEffect, useRef, useState } from "react";
import type { MutableRefObject } from "react";
import { netcattyBridge } from "../../../infrastructure/services/netcattyBridge";
import type { Host, Identity, KnownHost, SftpConnection, SftpFileEntry, SftpFilenameEncoding, SSHKey } from "../../../domain/models";
import { createKnownHostFromHostKeyInfo } from "../../../domain/knownHosts";
import type { SftpHostKeyInfo, SftpHostKeyVerificationState, SftpPane } from "./types";
import { useSftpDirectoryListing } from "./useSftpDirectoryListing";
import { useSftpHostCredentials } from "./useSftpHostCredentials";
@@ -41,6 +42,8 @@ interface UseSftpConnectionsParams {
clearCacheForConnection: (connectionId: string) => void;
createEmptyPane: (id?: string, showHiddenFiles?: boolean) => SftpPane;
autoConnectLocalOnMount?: boolean;
/** Fired after a browse SFTP id is closed so renderer retainers can drop. */
onRemoteSessionClosed?: (sftpId: string) => void;
}
export interface SftpConnectOptions {
@@ -82,6 +85,7 @@ export async function releaseSftpConnectionMetadata(params: {
connectionCacheKeys: Map<string, string>;
clearCacheForConnection: (connectionId: string) => void;
closeSftp: (sftpId: string) => Promise<unknown>;
onRemoteSessionClosed?: (sftpId: string) => void;
}): Promise<void> {
const sftpId = takeSftpConnectionMetadataForClose(params);
if (params.isLocal || !sftpId) return;
@@ -90,6 +94,7 @@ export async function releaseSftpConnectionMetadata(params: {
} catch {
// Best-effort: backend owner cleanup remains the final safety net.
}
params.onRemoteSessionClosed?.(sftpId);
}
export function createSftpConnectionId(
@@ -202,15 +207,7 @@ const createKnownHostFromSftpHostKeyInfo = (
hostKeyInfo: SftpHostKeyInfo,
now = Date.now(),
idSuffix = Math.random().toString(36).slice(2, 11),
): KnownHost => ({
id: hostKeyInfo.knownHostId || `kh-${now}-${idSuffix}`,
hostname: hostKeyInfo.hostname,
port: hostKeyInfo.port || 22,
keyType: hostKeyInfo.keyType,
publicKey: hostKeyInfo.publicKey || `SHA256:${hostKeyInfo.fingerprint}`,
fingerprint: hostKeyInfo.fingerprint,
discoveredAt: now,
});
): KnownHost => createKnownHostFromHostKeyInfo(hostKeyInfo, { now, idSuffix });
export const useSftpConnections = ({
hosts,
@@ -240,7 +237,13 @@ export const useSftpConnections = ({
clearCacheForConnection,
createEmptyPane,
autoConnectLocalOnMount = true,
onRemoteSessionClosed,
}: UseSftpConnectionsParams): UseSftpConnectionsResult => {
const onRemoteSessionClosedRef = useRef(onRemoteSessionClosed);
onRemoteSessionClosedRef.current = onRemoteSessionClosed;
const notifyRemoteSessionClosed = useCallback((sftpId: string | undefined) => {
if (sftpId) onRemoteSessionClosedRef.current?.(sftpId);
}, []);
const getHostCredentials = useSftpHostCredentials({ hosts, keys, identities, knownHosts, terminalSettings });
const { listLocalFiles, listRemoteFiles } = useSftpDirectoryListing();
const [hostKeyVerification, setHostKeyVerification] = useState<SftpHostKeyVerificationState | null>(null);
@@ -450,6 +453,7 @@ export const useSftpConnections = ({
connectionCacheKeys: connectionCacheKeyMapRef.current,
clearCacheForConnection,
closeSftp: async (sftpId) => netcattyBridge.get()?.closeSftp(sftpId),
onRemoteSessionClosed: (sftpId) => notifyRemoteSessionClosed(sftpId),
});
};
@@ -491,6 +495,7 @@ export const useSftpConnections = ({
} catch {
// Ignore errors when closing stale SFTP sessions
}
notifyRemoteSessionClosed(oldSftpId);
}
}
}
@@ -568,6 +573,10 @@ export const useSftpConnections = ({
sharedHostCache?.path,
);
// Sudo never reuses a terminal shell; normalize before UI state so
// reusedConnection (spinner/overlay) matches the actual open path.
const sourceSessionId = host.sftpSudo ? undefined : options?.sourceSessionId;
const connection: SftpConnection = {
id: connectionId,
hostId: host.id,
@@ -579,7 +588,7 @@ export const useSftpConnections = ({
// If the backend falls back to a fresh connection, the pane stays
// non-interactive (loading=true) with stale cached files visible —
// no worse than the previous UX of always showing a spinner.
reusedConnection: !!options?.sourceSessionId,
reusedConnection: !!sourceSessionId,
fileProtocol: host.sftpFileProtocol ?? 'auto',
};
@@ -652,7 +661,7 @@ export const useSftpConnections = ({
// immediately repeated after a shared-channel attempt fails.
const sftpId = await openSftpConnectionOnce({
bridge,
sourceSessionId: !host.sftpSudo ? options?.sourceSessionId : undefined,
sourceSessionId,
openOptions: {
sessionId: sftpSessionId,
...credentials,
@@ -962,6 +971,7 @@ export const useSftpConnections = ({
} catch {
// Ignore errors when closing SFTP session during disconnect
}
notifyRemoteSessionClosed(sftpId);
}
}
@@ -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";
@@ -41,12 +93,17 @@ import {
cleanupFailedExternalOpenTemp,
useExternalFileWatchLifecycle,
} from "./externalFileWatchLifecycle";
import { createExternalEditTempRetention } from "./externalEditTempRetention";
import {
cancelExternalUploadRuntime,
getExternalUploadController,
registerExternalUploadController,
unregisterExternalUploadController,
} from "./externalUploadRuntime";
import {
isTransferPauseLatched,
waitWhileTransferOrRootPaused,
} from "./transferPauseLatch";
type UploadConflictResolver = {
resolve: (action: FileConflictAction) => void;
@@ -150,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);
@@ -163,23 +223,49 @@ export const useSftpExternalOperations = (
},
}), [registerUploadController]);
// Temp files downloaded for external editors (even without auto-sync watches).
// Parking / disconnect / reconnect call closeSftp, which deletes these paths —
// forget matching retainers so hasActiveWork does not stick after cleanup.
const externalEditTempsRef = useRef(createExternalEditTempRetention());
const [activeExternalEditCount, setActiveExternalEditCount] = useState(0);
const rememberExternalEditTemp = useCallback((sftpId: string, localPath: string) => {
if (!externalEditTempsRef.current.remember(sftpId, localPath)) return;
setActiveExternalEditCount(externalEditTempsRef.current.size);
}, []);
const forgetExternalEditTemp = useCallback((localPath: string) => {
if (!externalEditTempsRef.current.forgetPath(localPath)) return;
setActiveExternalEditCount(externalEditTempsRef.current.size);
}, []);
const forgetExternalEditTempsForSftp = useCallback((sftpId: string) => {
if (!externalEditTempsRef.current.forgetSftp(sftpId)) return;
setActiveExternalEditCount(externalEditTempsRef.current.size);
}, []);
// Track every renderer-owned watch id so duplicate opens stay deduplicated
// and panel/window teardown releases the worker-side polling resources.
const stopExternalFileWatch = useCallback(async (watchId: string, cleanupTempFile: boolean) => {
await netcattyBridge.get()?.stopFileWatch?.(watchId, cleanupTempFile);
}, []);
const subscribeExternalFileWatchStopped = useCallback((
callback: (payload: { watchId: string }) => void,
callback: (payload: { watchId: string; localPath?: string }) => void,
) => netcattyBridge.get()?.onFileWatchStopped?.(callback), []);
const {
activeCountRef: activeFileWatchCountRef,
captureGeneration: captureExternalFileWatchGeneration,
remember: rememberExternalFileWatch,
releaseAll: releaseExternalFileWatches,
releaseAll: releaseExternalFileWatchesBase,
} = useExternalFileWatchLifecycle(
stopExternalFileWatch,
subscribeExternalFileWatchStopped,
// Force-stop (temp deleted / session cleanup) includes localPath — drop retainer.
forgetExternalEditTemp,
);
const releaseExternalFileWatches = useCallback(async (cleanupTempFiles = false) => {
await releaseExternalFileWatchesBase(cleanupTempFiles);
if (externalEditTempsRef.current.clear()) {
setActiveExternalEditCount(0);
}
}, [releaseExternalFileWatchesBase]);
const [uploadConflicts, setUploadConflicts] = useState<FileConflict[]>([]);
const uploadConflictResolversRef = useRef<Map<string, UploadConflictResolver>>(new Map());
/** Maps conflict id → owning UploadController so cancel A never stops B's prompts. */
@@ -483,6 +569,7 @@ export const useSftpExternalOperations = (
if (bridge.registerTempFile) {
try {
await bridge.registerTempFile(sftpId, localTempPath);
rememberExternalEditTemp(sftpId, localTempPath);
} catch (err) {
console.warn("[SFTP] Failed to register temp file for cleanup:", err);
}
@@ -490,7 +577,7 @@ export const useSftpExternalOperations = (
return { localTempPath, sftpId, externalTransferId };
},
[getActivePane, isTransferCancelled, ownerId, sftpSessionsRef],
[getActivePane, isTransferCancelled, ownerId, rememberExternalEditTemp, sftpSessionsRef],
);
const downloadToTempAndOpen = useCallback(
@@ -525,6 +612,7 @@ export const useSftpExternalOperations = (
await bridge.openWithApplication(localTempPath, appPath);
} catch (err) {
await cleanupFailedExternalOpenTemp(bridge, sftpId, localTempPath).catch(() => {});
forgetExternalEditTemp(localTempPath);
if (externalTransferId) {
sftpTransferCenterStore.patchTask(externalTransferId, {
status: "failed" as TransferStatus,
@@ -555,7 +643,13 @@ export const useSftpExternalOperations = (
return { localTempPath, watchId };
},
[captureExternalFileWatchGeneration, downloadToTemp, getActivePane, rememberExternalFileWatch],
[
captureExternalFileWatchGeneration,
downloadToTemp,
forgetExternalEditTemp,
getActivePane,
rememberExternalFileWatch,
],
);
const openWithSystemDefault = useCallback(
@@ -590,12 +684,14 @@ export const useSftpExternalOperations = (
} catch (error) {
if (!pane.connection.isLocal) {
await cleanupFailedExternalOpenTemp(bridgeMethods, sftpId, localTempPath).catch(() => {});
forgetExternalEditTemp(localTempPath);
}
throw error;
}
if (!result.success) {
if (!pane.connection.isLocal) {
await cleanupFailedExternalOpenTemp(bridgeMethods, sftpId, localTempPath).catch(() => {});
forgetExternalEditTemp(localTempPath);
}
if (externalTransferId) {
sftpTransferCenterStore.patchTask(externalTransferId, {
@@ -627,7 +723,13 @@ export const useSftpExternalOperations = (
notify.error(err instanceof Error ? err.message : String(err), "SFTP");
}
},
[captureExternalFileWatchGeneration, downloadToTemp, getActivePane, rememberExternalFileWatch],
[
captureExternalFileWatchGeneration,
downloadToTemp,
forgetExternalEditTemp,
getActivePane,
rememberExternalFileWatch,
],
);
// Create upload callbacks that translate to TransferTask updates
@@ -721,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
@@ -845,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("/")
));
@@ -908,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),
},
@@ -923,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);
}
};
@@ -940,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);
}
},
[
@@ -1103,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,
@@ -1215,7 +1579,7 @@ export const useSftpExternalOperations = (
: undefined,
shouldDiscard: isSessionError,
run: async (uploadSftpId) => uploadEntriesDirect(
entries,
capturedEntries,
{
targetPath: uploadTargetPath,
sftpId: uploadSftpId,
@@ -1223,7 +1587,7 @@ export const useSftpExternalOperations = (
isLocal: livePane.connection!.isLocal,
bridge: uploadBridge,
joinPath,
callbacks,
callbacks: transferCallbacks,
useCompressedUpload,
resolveConflict: createUploadConflictResolver(controller),
},
@@ -1239,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);
}
};
@@ -1259,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,
@@ -1491,6 +1867,8 @@ export const useSftpExternalOperations = (
cancelExternalUpload,
selectApplication,
activeFileWatchCountRef,
activeExternalEditCount,
forgetExternalEditTempsForSftp,
releaseExternalFileWatches,
uploadConflicts,
resolveUploadConflict,
@@ -68,6 +68,10 @@ export interface SftpExternalOperationsResult {
) => Promise<{ localTempPath: string; watchId?: string }>;
openWithSystemDefault: (side: "left" | "right", remotePath: string, fileName: string, options?: { enableWatch?: boolean }) => Promise<void>;
activeFileWatchCountRef: React.MutableRefObject<number>;
/** Remote temps opened in an external editor; keeps browse sessions from parking. */
activeExternalEditCount: number;
/** Drop retainers after closeSftp deletes that session's registered temps. */
forgetExternalEditTempsForSftp: (sftpId: string) => void;
releaseExternalFileWatches: (cleanupTempFiles?: boolean) => Promise<void>;
uploadExternalFiles: (
side: "left" | "right",
@@ -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,
@@ -0,0 +1,10 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import test from "node:test";
const source = readFileSync(new URL("./useSftpPaneSorting.ts", import.meta.url), "utf8");
test("useSftpPaneSorting imports column layout from application state", () => {
assert.doesNotMatch(source, /from ["'].*components\//);
assert.match(source, /from ["']\.\/columnLayout["']/);
});
@@ -6,13 +6,13 @@ import {
type SftpColumnVisibility,
type SortField,
type SortOrder,
} from "../utils";
} from "./columnLayout";
import { STORAGE_KEY_SFTP_VISIBLE_COLUMNS } from "../../../infrastructure/config/storageKeys";
import {
LOCAL_STORAGE_ADAPTER_CHANGED_EVENT,
localStorageAdapter,
} from "../../../infrastructure/persistence/localStorageAdapter";
import { useSftpDirectoriesFirst } from "../../../application/state/sftp/useSftpDirectoriesFirst";
import { useSftpDirectoriesFirst } from "./useSftpDirectoriesFirst";
export interface UseSftpPaneSortingResult {
sortField: SortField;
@@ -2,25 +2,6 @@ import { useEffect, useLayoutEffect, useRef } from "react";
import { sftpTransferCenterStore } from "../sftpTransferCenterStore";
export function useWarmSftpTransferPool(params: {
hostIds: readonly string[];
activeHostId?: string;
warmTransferPoolForHost?: (hostId: string) => void | Promise<void>;
}) {
const warmRef = useRef(params.warmTransferPoolForHost);
warmRef.current = params.warmTransferPoolForHost;
const hostIdsKey = [...new Set([
...params.hostIds,
...(params.activeHostId ? [params.activeHostId] : []),
])].sort().join("\u0000");
useEffect(() => {
for (const hostId of hostIdsKey.split("\u0000").filter(Boolean)) {
void warmRef.current?.(hostId);
}
}, [hostIdsKey]);
}
export function useReportSftpTransferOwnerActivity(params: {
ownerId: string;
activeTransfersCount: number;
+93 -6
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) {
@@ -178,10 +182,12 @@ export const useSftpTransfers = ({
const surfaceVisibleRef = useRef(surfaceVisible);
surfaceVisibleRef.current = surfaceVisible;
const [transfers, setTransfersState] = useState<TransferTask[]>(() => sftpTransferCenterStore.getOwnerTasks(ownerId));
// Only re-surface live attention conflicts. Terminal rows may still carry a
// stale conflict payload after older Skip paths — those must not reopen the dialog.
const [conflicts, setConflicts] = useState<FileConflict[]>(() => sftpTransferCenterStore
.getOwnerTasks(ownerId)
.map((task) => task.conflict)
.filter((conflict): conflict is FileConflict => !!conflict));
.filter((task) => task.status === "attention" && !!task.conflict)
.map((task) => task.conflict as FileConflict));
// Track cancelled task IDs. has() also sees process-global cancel flags set by
// the transfer center after the React owner unmounts.
@@ -1292,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 = {
@@ -1314,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);
@@ -1333,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(() => {
@@ -1393,8 +1459,20 @@ export const useSftpTransfers = ({
const task = transfersRef.current.find((t) => t.id === conflictId);
if (!task) {
// Dialog can show a store-owned conflict before the panel adopts the row.
// Clearing only React state leaves attention+conflict in localStorage and
// the prompt returns on the next SFTP open (issue #2636).
conflictsRef.current = conflictsRef.current.filter((c) => c.transferId !== conflictId);
setConflicts(conflictsRef.current);
if (action === "stop" || action === "skip") {
sftpTransferCenterStore.patchTask(conflictId, {
status: "cancelled",
conflict: undefined,
endTime: Date.now(),
speed: 0,
error: undefined,
});
}
return;
}
@@ -1436,9 +1514,18 @@ export const useSftpTransfers = ({
for (const affectedTask of affectedTasks) {
cancelledTasksRef.current.add(affectedTask.id);
}
// Drop conflict so remount / restart cannot rehydrate the dialog from a
// cancelled row that still carries conflict metadata.
setTransfers((prev) =>
prev.map((t) => affectedConflictIds.has(t.id)
? { ...t, status: "cancelled" as TransferStatus, endTime: Date.now() }
? {
...t,
status: "cancelled" as TransferStatus,
endTime: Date.now(),
conflict: undefined,
error: undefined,
speed: 0,
}
: t,
),
);
+7 -5
View File
@@ -1,11 +1,13 @@
import { SftpFileEntry, TransferTask } from "../../../domain/models";
import { formatBytes } from "../../../domain/formatBytes";
export const formatFileSize = (bytes: number): string => {
if (bytes === 0) return "--";
const units = ["Bytes", "KB", "MB", "GB", "TB"];
const i = Math.floor(Math.log(bytes) / Math.log(1024));
const size = bytes / Math.pow(1024, i);
return `${size.toFixed(i === 0 ? 0 : 2)} ${units[i]}`;
return formatBytes(bytes, {
zeroDisplay: "--",
includeTB: true,
fractionDigits: 2,
byteUnit: "Bytes",
});
};
/**
+1 -1
View File
@@ -2318,7 +2318,7 @@ export function createSftpTransferCenterStore(persistence?: StorePersistence): S
const browserPersistence: StorePersistence | undefined = typeof globalThis.localStorage === "undefined"
? undefined
: {
read: () => globalThis.localStorage.getItem(STORAGE_KEY_SFTP_TRANSFER_CENTER),
read: () => localStorageAdapter.readString(STORAGE_KEY_SFTP_TRANSFER_CENTER),
write: (value) => { localStorageAdapter.writeString(STORAGE_KEY_SFTP_TRANSFER_CENTER, value); },
};
@@ -0,0 +1,365 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {
createLocalTerminalFontSizeRecord,
parseTerminalFontSizeRecord,
resolveAuthoritativeTerminalFontSizeStorage,
resolveIncomingTerminalFontSize,
resolveTerminalFontSizeStorage,
serializeTerminalFontSizeRecord,
shouldApplyTerminalFontSizeRecord,
shouldBroadcastTerminalFontSizeChange,
type TerminalFontSizeMutationSource,
type TerminalFontSizeRecord,
} from './terminalFontSizeSync.ts';
/**
* Minimal model of the settings ↔ main font-size sync loop that caused #2689.
* Models both IPC rebroadcast and stale localStorage overwrites.
*/
function simulateFontSizeClicks(options: {
shouldBroadcast: (
source: TerminalFontSizeMutationSource,
persistMounted: boolean,
) => { shouldBroadcast: boolean; nextSource: TerminalFontSizeMutationSource };
shouldApply: (current: TerminalFontSizeRecord, incoming: TerminalFontSizeRecord) => boolean;
versioned: boolean;
}): { settingsValues: number[]; mainValues: number[]; storageWrites: string[] } {
let settings: TerminalFontSizeRecord = { fontSize: 16, version: 0, origin: 'legacy' };
let main: TerminalFontSizeRecord = { fontSize: 16, version: 0, origin: 'legacy' };
let settingsSource: TerminalFontSizeMutationSource = 'local';
let mainSource: TerminalFontSizeMutationSource = 'local';
let storage = options.versioned
? serializeTerminalFontSizeRecord(settings)
: String(settings.fontSize);
const settingsValues: number[] = [];
const mainValues: number[] = [];
const storageWrites: string[] = [];
const pendingIpc: Array<{ to: 'settings' | 'main'; record: TerminalFontSizeRecord }> = [];
const writeStorage = (record: TerminalFontSizeRecord) => {
const next = options.versioned
? serializeTerminalFontSizeRecord(record)
: String(record.fontSize);
if (next === storage) return;
storage = next;
storageWrites.push(next);
};
const applyLocal = (window: 'settings' | 'main', fontSize: number) => {
const bump = (
prev: TerminalFontSizeRecord,
origin: string,
): TerminalFontSizeRecord => (
options.versioned
? { fontSize, version: prev.version + 1, origin }
: { fontSize, version: 0, origin: 'legacy' }
);
if (window === 'settings') {
settingsSource = 'local';
settings = bump(settings, 'settings-window');
settingsValues.push(settings.fontSize);
writeStorage(settings);
const decision = options.shouldBroadcast(settingsSource, true);
settingsSource = decision.nextSource;
if (decision.shouldBroadcast) pendingIpc.push({ to: 'main', record: { ...settings } });
return;
}
mainSource = 'local';
main = bump(main, 'main-window');
mainValues.push(main.fontSize);
writeStorage(main);
const decision = options.shouldBroadcast(mainSource, true);
mainSource = decision.nextSource;
if (decision.shouldBroadcast) pendingIpc.push({ to: 'settings', record: { ...main } });
};
const applyIncoming = (window: 'settings' | 'main', record: TerminalFontSizeRecord) => {
if (window === 'settings') {
if (!options.shouldApply(settings, record)) return;
settingsSource = 'incoming';
settings = { ...record };
settingsValues.push(settings.fontSize);
writeStorage(settings);
const decision = options.shouldBroadcast(settingsSource, true);
settingsSource = decision.nextSource;
if (decision.shouldBroadcast) pendingIpc.push({ to: 'main', record: { ...settings } });
return;
}
if (!options.shouldApply(main, record)) return;
mainSource = 'incoming';
main = { ...record };
mainValues.push(main.fontSize);
writeStorage(main);
const decision = options.shouldBroadcast(mainSource, true);
mainSource = decision.nextSource;
if (decision.shouldBroadcast) pendingIpc.push({ to: 'settings', record: { ...main } });
};
const flushIpc = () => {
while (pendingIpc.length > 0) {
const next = pendingIpc.shift()!;
applyIncoming(next.to, next.record);
if (settingsValues.length > 40) break;
}
};
const deliverStorageTo = (window: 'settings' | 'main') => {
const record = parseTerminalFontSizeRecord(storage);
applyIncoming(window, record);
};
// Match reporter steps: + (16→17) then - while a delayed peer echo of 17
// can still race with a later local 15.
applyLocal('settings', 17);
deliverStorageTo('main');
applyLocal('settings', 15);
const delayed = pendingIpc.shift();
flushIpc();
if (delayed) applyIncoming(delayed.to, delayed.record);
deliverStorageTo('settings');
flushIpc();
return { settingsValues, mainValues, storageWrites };
}
test('parseTerminalFontSizeRecord accepts legacy plain numbers and versioned records', () => {
assert.deepEqual(
parseTerminalFontSizeRecord('16'),
{ fontSize: 16, version: 0, origin: 'legacy' },
);
assert.deepEqual(
parseTerminalFontSizeRecord(14),
{ fontSize: 14, version: 0, origin: 'legacy' },
);
assert.deepEqual(
parseTerminalFontSizeRecord({ fontSize: 18, version: 3, origin: 'window-a' }),
{ fontSize: 18, version: 3, origin: 'window-a' },
);
assert.deepEqual(
parseTerminalFontSizeRecord('{"fontSize":15,"version":9,"origin":"window-b"}'),
{ fontSize: 15, version: 9, origin: 'window-b' },
);
assert.deepEqual(
parseTerminalFontSizeRecord('17|12|settings-window'),
{ fontSize: 17, version: 12, origin: 'settings-window' },
);
assert.equal(parseTerminalFontSizeRecord('bad').fontSize, 14);
});
test('shouldApplyTerminalFontSizeRecord ignores stale revisions', () => {
const current = { fontSize: 15, version: 2, origin: 'window-a' };
assert.equal(
shouldApplyTerminalFontSizeRecord(
current,
{ fontSize: 17, version: 1, origin: 'window-b' },
),
false,
);
assert.equal(
shouldApplyTerminalFontSizeRecord(
current,
{ fontSize: 18, version: 3, origin: 'window-b' },
),
true,
);
assert.equal(shouldApplyTerminalFontSizeRecord(current, { ...current }), false);
});
test('simultaneous writers converge through storage in either persistence order', () => {
const run = (order: Array<'settings' | 'main'>) => {
const initial: TerminalFontSizeRecord = {
fontSize: 16,
version: 1,
origin: 'initial-window',
};
let storage = serializeTerminalFontSizeRecord(initial);
let settings = createLocalTerminalFontSizeRecord(
initial,
storage,
17,
'settings-window',
10,
);
let main = createLocalTerminalFontSizeRecord(
initial,
storage,
15,
'main-window',
10,
);
for (const writer of order) {
const current = writer === 'settings' ? settings : main;
const resolution = resolveTerminalFontSizeStorage(current, storage);
if (resolution.shouldAdopt) {
if (writer === 'settings') settings = resolution.record;
else main = resolution.record;
} else if (resolution.shouldPersist) {
storage = resolution.serializedRecord;
}
}
const settingsResolution = resolveTerminalFontSizeStorage(settings, storage);
const mainResolution = resolveTerminalFontSizeStorage(main, storage);
settings = settingsResolution.record;
main = mainResolution.record;
assert.equal(settingsResolution.shouldPersist, false);
assert.equal(mainResolution.shouldPersist, false);
assert.deepEqual(settings, main);
assert.equal(resolveTerminalFontSizeStorage(settings, storage).shouldAdopt, false);
assert.equal(resolveTerminalFontSizeStorage(settings, storage).shouldPersist, false);
return settings;
};
assert.deepEqual(run(['settings', 'main']), run(['main', 'settings']));
});
test('a stale window creates and persists a revision newer than shared storage', () => {
const stored = serializeTerminalFontSizeRecord({
fontSize: 18,
version: 7,
origin: 'main-window',
});
const local = createLocalTerminalFontSizeRecord(
{ fontSize: 15, version: 2, origin: 'settings-window' },
stored,
16,
'settings-window',
5,
);
assert.equal(local.version, 8);
const resolution = resolveTerminalFontSizeStorage(local, stored);
assert.equal(resolution.shouldAdopt, false);
assert.equal(resolution.shouldPersist, true);
assert.deepEqual(parseTerminalFontSizeRecord(resolution.serializedRecord), local);
});
test('a rejected losing record repairs shared storage back to the current winner', () => {
const winner: TerminalFontSizeRecord = {
fontSize: 17,
version: 10,
origin: 'settings-window',
};
const loser: TerminalFontSizeRecord = {
fontSize: 15,
version: 10,
origin: 'main-window',
};
const loserRaw = serializeTerminalFontSizeRecord(loser);
const rejected = resolveIncomingTerminalFontSize(winner, loser, loserRaw);
assert.equal(rejected.shouldUpdate, false);
assert.deepEqual(rejected.record, winner);
assert.equal(
rejected.repairSerializedRecord,
serializeTerminalFontSizeRecord(winner),
);
const stable = resolveIncomingTerminalFontSize(
winner,
loser,
rejected.repairSerializedRecord,
);
assert.equal(stable.shouldUpdate, false);
assert.equal(stable.repairSerializedRecord, null);
});
test('a delayed persistence effect arbitrates from the latest authoritative record', () => {
const renderedWhenEffectWasScheduled: TerminalFontSizeRecord = {
fontSize: 14,
version: 9,
origin: 'old-window',
};
const winner: TerminalFontSizeRecord = {
fontSize: 17,
version: 10,
origin: 'settings-window',
};
const loser: TerminalFontSizeRecord = {
fontSize: 15,
version: 10,
origin: 'main-window',
};
const currentRef = { current: renderedWhenEffectWasScheduled };
currentRef.current = winner;
const resolution = resolveAuthoritativeTerminalFontSizeStorage(
currentRef,
serializeTerminalFontSizeRecord(loser),
);
assert.equal(resolution.shouldAdopt, false);
assert.equal(resolution.shouldPersist, true);
assert.deepEqual(resolution.record, winner);
assert.deepEqual(parseTerminalFontSizeRecord(resolution.serializedRecord), winner);
});
test('shouldBroadcastTerminalFontSizeChange suppresses incoming rebroadcasts', () => {
assert.deepEqual(
shouldBroadcastTerminalFontSizeChange('incoming', true),
{ shouldBroadcast: false, nextSource: 'local' },
);
assert.deepEqual(
shouldBroadcastTerminalFontSizeChange('local', true),
{ shouldBroadcast: true, nextSource: 'local' },
);
assert.deepEqual(
shouldBroadcastTerminalFontSizeChange('local', false),
{ shouldBroadcast: false, nextSource: 'local' },
);
});
test('legacy unversioned always-broadcast font size sync oscillates during rapid +/- clicks', () => {
const alwaysBroadcast = (
_source: TerminalFontSizeMutationSource,
persistMounted: boolean,
) => ({
shouldBroadcast: persistMounted,
nextSource: 'local' as const,
});
const alwaysApply = () => true;
const { settingsValues } = simulateFontSizeClicks({
shouldBroadcast: alwaysBroadcast,
shouldApply: alwaysApply,
versioned: false,
});
const unique = new Set(settingsValues);
assert.ok(
unique.has(17) && unique.has(15) && settingsValues.length > 2,
`expected oscillation between 17 and 15, got ${settingsValues.join(',')}`,
);
});
test('versioned font size sync ignores stale peer echoes during rapid +/- clicks', () => {
const { settingsValues, mainValues } = simulateFontSizeClicks({
shouldBroadcast: shouldBroadcastTerminalFontSizeChange,
shouldApply: shouldApplyTerminalFontSizeRecord,
versioned: true,
});
assert.deepEqual(settingsValues, [17, 15]);
assert.ok(mainValues.includes(15));
assert.equal(mainValues.includes(17) && mainValues[mainValues.length - 1] === 17, false);
});
test('serializeTerminalFontSizeRecord round-trips through parse', () => {
const record = { fontSize: 18, version: 9, origin: 'window|with delimiter' };
const raw = serializeTerminalFontSizeRecord(record);
assert.deepEqual(parseTerminalFontSizeRecord(raw), record);
});
test('serialized records remain readable by the old parseInt storage reader', () => {
const raw = serializeTerminalFontSizeRecord({
fontSize: 18,
version: 9,
origin: 'settings-window',
});
assert.equal(parseInt(raw, 10), 18);
});
+257
View File
@@ -0,0 +1,257 @@
import {
DEFAULT_FONT_SIZE,
MAX_FONT_SIZE,
MIN_FONT_SIZE,
} from '../../infrastructure/config/fonts';
export type TerminalFontSizeMutationSource = 'local' | 'incoming';
export type TerminalFontSizeRecord = {
fontSize: number;
version: number;
origin: string;
};
const LEGACY_TERMINAL_FONT_SIZE_ORIGIN = 'legacy';
function normalizeTerminalFontSizeOrigin(origin: unknown): string {
return typeof origin === 'string' && origin.length > 0
? origin
: LEGACY_TERMINAL_FONT_SIZE_ORIGIN;
}
export function clampTerminalFontSizeValue(fontSize: unknown): number {
const value = typeof fontSize === 'number' ? fontSize : Number(fontSize);
if (!Number.isFinite(value)) return DEFAULT_FONT_SIZE;
return Math.max(MIN_FONT_SIZE, Math.min(MAX_FONT_SIZE, Math.round(value)));
}
/**
* Parse persisted / IPC terminal-font-size payloads.
* Accepts legacy plain numbers ("16") and versioned records.
*/
export function parseTerminalFontSizeRecord(raw: unknown): TerminalFontSizeRecord {
if (typeof raw === 'number' || typeof raw === 'string') {
const trimmed = typeof raw === 'string' ? raw.trim() : raw;
if (typeof trimmed === 'string' && trimmed.startsWith('{')) {
try {
return parseTerminalFontSizeRecord(JSON.parse(trimmed));
} catch {
// fall through to Number()
}
}
if (typeof trimmed === 'string') {
const [fontSizePart, versionPart, encodedOrigin, ...rest] = trimmed.split('|');
if (encodedOrigin && rest.length === 0) {
const fontSize = Number(fontSizePart);
const version = Number(versionPart);
if (Number.isFinite(fontSize) && Number.isFinite(version)) {
let origin = encodedOrigin;
try {
origin = decodeURIComponent(encodedOrigin);
} catch {
// Keep the encoded value as a stable tie-breaker.
}
return {
fontSize: clampTerminalFontSizeValue(fontSize),
version: version > 0 ? Math.floor(version) : 0,
origin: normalizeTerminalFontSizeOrigin(origin),
};
}
}
}
const asNumber = Number(trimmed);
if (Number.isFinite(asNumber)) {
return {
fontSize: clampTerminalFontSizeValue(asNumber),
version: 0,
origin: LEGACY_TERMINAL_FONT_SIZE_ORIGIN,
};
}
}
if (raw && typeof raw === 'object') {
const record = raw as { fontSize?: unknown; version?: unknown; origin?: unknown };
const fontSize = clampTerminalFontSizeValue(record.fontSize);
const version = Number(record.version);
return {
fontSize,
version: Number.isFinite(version) && version > 0 ? Math.floor(version) : 0,
origin: normalizeTerminalFontSizeOrigin(record.origin),
};
}
return {
fontSize: DEFAULT_FONT_SIZE,
version: 0,
origin: LEGACY_TERMINAL_FONT_SIZE_ORIGIN,
};
}
export function serializeTerminalFontSizeRecord(record: TerminalFontSizeRecord): string {
const fontSize = clampTerminalFontSizeValue(record.fontSize);
const version = Math.max(0, Math.floor(record.version) || 0);
const origin = encodeURIComponent(normalizeTerminalFontSizeOrigin(record.origin));
// Keep the font size first so older Netcatty versions using parseInt() can
// still read the preference after a downgrade.
return `${fontSize}|${version}|${origin}`;
}
export function createTerminalFontSizeSyncOrigin(): string {
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
return crypto.randomUUID();
}
return `${Date.now()}-${Math.random().toString(36).slice(2)}`;
}
export function nextTerminalFontSizeSyncVersion(
currentVersion: number,
persistedVersion: number,
now: number = Date.now(),
): number {
return Math.max(
Math.floor(now),
Math.floor(currentVersion) + 1,
Math.floor(persistedVersion) + 1,
);
}
export function createLocalTerminalFontSizeRecord(
current: TerminalFontSizeRecord,
persistedRaw: unknown,
nextFontSizeValue: unknown,
origin: string,
now: number = Date.now(),
): TerminalFontSizeRecord {
const nextFontSize = clampTerminalFontSizeValue(nextFontSizeValue);
if (nextFontSize === current.fontSize) return current;
const persisted = parseTerminalFontSizeRecord(persistedRaw);
return {
fontSize: nextFontSize,
version: nextTerminalFontSizeSyncVersion(current.version, persisted.version, now),
origin: normalizeTerminalFontSizeOrigin(origin),
};
}
export function areTerminalFontSizeRecordsEqual(
left: TerminalFontSizeRecord,
right: TerminalFontSizeRecord,
): boolean {
return left.fontSize === right.fontSize
&& left.version === right.version
&& left.origin === right.origin;
}
/**
* Incoming peer updates must not clobber a newer local revision. A stable
* origin tie-break makes simultaneous writers converge on the same record.
*/
export function shouldApplyTerminalFontSizeRecord(
current: TerminalFontSizeRecord,
incoming: TerminalFontSizeRecord,
): boolean {
if (incoming.version > current.version) return true;
if (incoming.version < current.version) return false;
if (
incoming.version === 0
&& incoming.origin === LEGACY_TERMINAL_FONT_SIZE_ORIGIN
&& current.origin === LEGACY_TERMINAL_FONT_SIZE_ORIGIN
) {
return incoming.fontSize !== current.fontSize;
}
if (incoming.origin !== current.origin) return incoming.origin > current.origin;
return incoming.fontSize > current.fontSize;
}
export type TerminalFontSizeStorageResolution = {
record: TerminalFontSizeRecord;
serializedRecord: string;
shouldAdopt: boolean;
shouldPersist: boolean;
};
export type TerminalFontSizeIncomingResolution = {
record: TerminalFontSizeRecord;
shouldUpdate: boolean;
repairSerializedRecord: string | null;
};
export function resolveTerminalFontSizeStorage(
current: TerminalFontSizeRecord,
storedRaw: unknown,
): TerminalFontSizeStorageResolution {
const stored = parseTerminalFontSizeRecord(storedRaw);
if (shouldApplyTerminalFontSizeRecord(current, stored)) {
return {
record: stored,
serializedRecord: serializeTerminalFontSizeRecord(stored),
shouldAdopt: true,
shouldPersist: false,
};
}
const serializedRecord = serializeTerminalFontSizeRecord(current);
return {
record: current,
serializedRecord,
shouldAdopt: false,
shouldPersist: storedRaw !== serializedRecord,
};
}
export function resolveAuthoritativeTerminalFontSizeStorage(
currentRef: Readonly<{ current: TerminalFontSizeRecord }>,
storedRaw: unknown,
): TerminalFontSizeStorageResolution {
return resolveTerminalFontSizeStorage(currentRef.current, storedRaw);
}
export function resolveIncomingTerminalFontSize(
current: TerminalFontSizeRecord,
incomingRaw: unknown,
storedRaw: unknown,
): TerminalFontSizeIncomingResolution {
const incoming = parseTerminalFontSizeRecord(incomingRaw);
if (shouldApplyTerminalFontSizeRecord(current, incoming)) {
return {
record: incoming,
shouldUpdate: true,
repairSerializedRecord: null,
};
}
const storageResolution = resolveTerminalFontSizeStorage(current, storedRaw);
if (storageResolution.shouldAdopt) {
return {
record: storageResolution.record,
shouldUpdate: true,
repairSerializedRecord: null,
};
}
return {
record: current,
shouldUpdate: false,
repairSerializedRecord: storageResolution.shouldPersist
? storageResolution.serializedRecord
: null,
};
}
/**
* Decide whether a terminal-font-size state change should be rebroadcast to
* peer windows. Incoming IPC/storage updates must not notify again —
* otherwise +/- clicks in the Settings window ping-pong with the main window
* and terminals oscillate between the last two sizes (see #2689, same class
* as window-opacity #2018).
*/
export function shouldBroadcastTerminalFontSizeChange(
mutationSource: TerminalFontSizeMutationSource,
persistMounted: boolean,
): { shouldBroadcast: boolean; nextSource: TerminalFontSizeMutationSource } {
if (mutationSource === 'incoming') {
return { shouldBroadcast: false, nextSource: 'local' };
}
return { shouldBroadcast: persistMounted, nextSource: 'local' };
}
+26 -8
View File
@@ -13,15 +13,9 @@ import {
} from '../../infrastructure/config/storageKeys';
import { localStorageAdapter } from '../../infrastructure/persistence/localStorageAdapter';
import { useToolbarItemLayout } from './useToolbarItemLayout';
import type { SidePanelTool } from '../../domain/sidePanelLayout';
export type TerminalSidePanelTabId =
| 'sftp'
| 'scripts'
| 'history'
| 'theme'
| 'system'
| 'notes'
| 'ai';
export type TerminalSidePanelTabId = SidePanelTool;
export const TERMINAL_SIDE_PANEL_TAB_DEFAULT_ORDER: TerminalSidePanelTabId[] = [
'sftp',
@@ -64,6 +58,30 @@ export function reorderTerminalSidePanelTab(
.order as TerminalSidePanelTabId[];
}
export function fitTerminalSidePanelTabs(params: {
shown: TerminalSidePanelTabId[];
collapsed: TerminalSidePanelTabId[];
active: TerminalSidePanelTabId | null;
maxShown: number;
}): { shown: TerminalSidePanelTabId[]; collapsed: TerminalSidePanelTabId[] } {
if (params.shown.length <= params.maxShown) {
return { shown: params.shown, collapsed: params.collapsed };
}
const kept = new Set(params.shown.slice(0, Math.max(1, params.maxShown)));
if (params.active && params.shown.includes(params.active) && !kept.has(params.active)) {
const lastKept = [...kept].at(-1);
if (lastKept) kept.delete(lastKept);
kept.add(params.active);
}
const shown = params.shown.filter((id) => kept.has(id));
const autoCollapsed = params.shown.filter((id) => !kept.has(id));
return {
shown,
collapsed: [...autoCollapsed, ...params.collapsed.filter((id) => !autoCollapsed.includes(id))],
};
}
/**
* Seed the modern layout storage from the legacy order-only key when needed.
*/
@@ -0,0 +1,36 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
clampTerminalSidePanelWidth,
getTerminalSidePanelAvailableWidth,
getTerminalSidePanelMaxShownTools,
getTerminalSidePanelMaxWidth,
TERMINAL_SIDE_PANEL_MAX_WIDTH,
TERMINAL_SIDE_PANEL_MIN_WIDTH,
} from './terminalSidePanelWidth.ts';
test('terminal side panel can expand to the wider maximum', () => {
assert.equal(getTerminalSidePanelMaxWidth(2000), TERMINAL_SIDE_PANEL_MAX_WIDTH);
assert.equal(clampTerminalSidePanelWidth(1400, 2000), TERMINAL_SIDE_PANEL_MAX_WIDTH);
});
test('terminal side panel collapses tool buttons before shared actions are clipped', () => {
assert.equal(getTerminalSidePanelMaxShownTools(280), 2);
assert.equal(getTerminalSidePanelMaxShownTools(320), 4);
assert.ok(getTerminalSidePanelMaxShownTools(420) >= 7);
});
test('terminal side panel uses the actual surface left after fixed sibling panels', () => {
assert.equal(getTerminalSidePanelAvailableWidth(780, 220), 560);
assert.equal(getTerminalSidePanelMaxWidth(560), TERMINAL_SIDE_PANEL_MIN_WIDTH);
assert.equal(clampTerminalSidePanelWidth(680, 560), TERMINAL_SIDE_PANEL_MIN_WIDTH);
});
test('terminal side panel keeps usable terminal space in smaller windows', () => {
assert.equal(getTerminalSidePanelMaxWidth(1000), 680);
assert.equal(clampTerminalSidePanelWidth(900, 1000), 680);
assert.equal(clampTerminalSidePanelWidth(100, 1000), TERMINAL_SIDE_PANEL_MIN_WIDTH);
assert.equal(clampTerminalSidePanelWidth(280, 1000, 401), 401);
assert.equal(clampTerminalSidePanelWidth(280, 600, 401), TERMINAL_SIDE_PANEL_MIN_WIDTH);
});
@@ -0,0 +1,43 @@
export const TERMINAL_SIDE_PANEL_MIN_WIDTH = 280;
export const TERMINAL_SIDE_PANEL_MAX_WIDTH = 1200;
export const TERMINAL_SIDE_PANEL_MIN_TERMINAL_WIDTH = 320;
export const TERMINAL_SIDE_PANEL_TOOL_BUTTON_WIDTH = 28;
export const TERMINAL_SIDE_PANEL_TOOLBAR_RESERVED_WIDTH = 200;
export function getTerminalSidePanelAvailableWidth(
terminalLayerWidth: number,
occupiedSiblingWidth: number,
): number {
return Math.max(0, terminalLayerWidth - occupiedSiblingWidth);
}
export function getTerminalSidePanelMaxWidth(availableSurfaceWidth: number): number {
const availableWidth = availableSurfaceWidth - TERMINAL_SIDE_PANEL_MIN_TERMINAL_WIDTH;
return Math.max(
TERMINAL_SIDE_PANEL_MIN_WIDTH,
Math.min(TERMINAL_SIDE_PANEL_MAX_WIDTH, availableWidth),
);
}
export function clampTerminalSidePanelWidth(
width: number,
availableSurfaceWidth: number,
contentMinimumWidth = TERMINAL_SIDE_PANEL_MIN_WIDTH,
): number {
const maximumWidth = getTerminalSidePanelMaxWidth(availableSurfaceWidth);
const minimumWidth = Math.min(
maximumWidth,
Math.max(TERMINAL_SIDE_PANEL_MIN_WIDTH, contentMinimumWidth),
);
return Math.max(minimumWidth, Math.min(maximumWidth, width));
}
export function getTerminalSidePanelMaxShownTools(panelWidth: number): number {
return Math.max(
1,
Math.floor(
(panelWidth - TERMINAL_SIDE_PANEL_TOOLBAR_RESERVED_WIDTH)
/ TERMINAL_SIDE_PANEL_TOOL_BUTTON_WIDTH,
),
);
}
-9
View File
@@ -172,15 +172,6 @@ export const useUIFontsLoaded = (): boolean => {
);
};
/**
* Check if a font ID is valid
*/
export const isValidUiFontId = (fontId: string): boolean => {
// Local fonts are always considered valid (they start with 'local-')
if (fontId.startsWith('local-')) return true;
return uiFontStore.getAvailableFonts().some(f => f.id === fontId);
};
/**
* Initialize UI fonts eagerly
*/
Loaded 100 of 592 files, more files were not shown because too many files have changed in this diff. Show more