Compare commits

..
Author SHA1 Message Date
Cursor Agentand陈大猫 4bcb98b9d6 fix: invalidate Escape-dismissed autocomplete cascade fetches
Escape at level 0 kept the main-row selection, so late directory listings
could reinstall panels after dismissal. Dismiss via a versioned cascade
helper and bind level-0 prefetch installs to suggestion identity.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-08-09 02:19:40 +00:00
netcatty-bot de17e1baf2 fix: address Codex review on PR #2824 2026-08-08 22:55:37 +00:00
netcatty-bot d8011adfbd fix: address Codex review on PR #2824 2026-08-08 22:22:24 +00:00
netcatty-bot 53df32e686 fix: address Codex review on PR #2824 2026-08-08 21:51:36 +00:00
netcatty-bot 9c120ed2fa fix: address Codex review on PR #2824 2026-08-08 21:16:34 +00:00
Cursor Agentand陈大猫 a287d303aa fix(terminal): prefetch autocomplete sub-dirs after selection commits
Calling fetchSubDirForIndex from the startTransition refresh path raced
when directory listings resolved before selectedIndex flushed, so panels
were dropped. Prefetch from a post-commit effect instead, and skip when
cascade panels are already open.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-08-08 07:28:36 +00:00
Cursor Agentand陈大猫 7185b38a46 fix(terminal): prefetch sub-dir panels for default autocomplete selection
Default-selecting the first suggestion skipped fetchSubDirForIndex, so
directory cascade panels and ArrowRight stayed inert until ArrowUp/Down.
Also keep open cascade panels across unchanged suggestion refreshes.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-08-08 07:25:58 +00:00
netcatty-bot f06c9aace0 fix(#2821): automated Cursor CLI fix 2026-08-08 01:54:59 +00:00
3490b0673d fix: StrictMode 双发清理 + 笔记页卡顿优化 (#2810)
* Cursor: Apply local changes for cloud agent

* fix(terminal): stop first SSH fail under StrictMode remount

Defer backend boot past the double-invoke, cancel superseded vault init,
gate start on vault hydration, and harden hotkey/attach cleanup so the
first connect no longer races a stale close or empty keychain.

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

* perf: finish StrictMode mega-subscriber plan (accent isolation + docs)

Stabilize published terminal themes across custom-accent drag, apply accent
at chrome/log leaves and CSS injection only, land Phase 0 design/plan docs,
and lock Host-bag / Bugbot-clean contracts for the isolation checklist.

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

* docs: add StrictMode mega-subscriber design and plan artifacts

Force-add Phase 0 spec/plan under docs/superpowers (gitignored locally)
so the delivery branch carries the approved design and plan copy.

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

* fix: clear remaining StrictMode doubles and notes editor jank

Gate every AI send and reject concurrent runTurn, latch tray connect flush
and settings persistMounted remounts, and stop notes content drafts from
rebuilding MDXEditor/shell on each keystroke.

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

* perf: gate hidden notes panel subscribe and skip host-link annotate on keystrokes

Hidden terminal notes mounts keep a live snapshot read but stop listening to
publishes. Host-link DOM annotation follows hosts/mode (preview MutationObserver)
instead of every markdown value change.

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

* fix: latch SSH transport idle TTL notify under StrictMode

Avoid duplicate main-process notifySettingsChanged on effect double-invoke
when the idle TTL value has not changed.

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

* perf: freeze inactive vault notes against hosts churn and prefetch MDXEditor

Gate the retained VaultNotesSection notesStore subscription while hidden,
ignore hosts identity in its memo compare when inactive, and warm the lazy
InlineMarkdownEditor chunk when notes become active.

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

* fix: clear remaining AI StrictMode double-fire hazards

Latch Ask-AI terminal selection on requestId before draft mutation, hoist
Codex App Server IPC bridge to App (ref-counted), and make Catty
requestApproval join an existing waiter for the same toolCallId.

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

* fix: remount-safe AI send latch and skip stale MCP approval IPC

Replace the component sendInFlightRef with a module key latch so StrictMode
panel remount cannot reopen a mid-flight send, and no-op resolveApproval when
the pending entry is already drained.

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

* test: allow gated useNotesStore in VaultNotesSection memo contract

The retained notes section now passes enabled:isActive so hidden mounts do
not subscribe; update the VaultView memo source assertion accordingly.

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

* fix: keep note drafts across mode toggle and external note publishes

Flush ref-only content before edit/preview remount, and skip prop→editor
setMarkdown while a local draft diverges from the last synced value.

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

* chore: strip StrictMode sermon comments after Bugbot/Comment Sicko pass

Keep effect-order and public-export contracts; drop remount/perf narration
that the latches and tests already encode.

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

* fix: remount-safe Ask-AI terminal selection latch

Move consumed requestIds to a module Set so StrictMode remount cannot
append the same selection chip twice before parent state clears.

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

* chore: drop remount latch comment

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-07 14:38:24 +00:00
陈大猫andCursor 70607cb26d perf: StrictMode + Host-island mega-subscriber elimination (#2809)
Split vault/session/settings into publishers and store-backed Hosts so hot
fields no longer rebuild the whole shell, and harden Terminal boot / startup
effects for StrictMode double-mounts.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-07 14:37:37 +00:00
a56b868d10 fix(system-manager): show Ascend 910B NPU info from CANN 24.x npu-smi tables (#2812)
* fix(system-manager): parse CANN 24.x Ascend npu-smi info tables

ModelArts / CANN 24.1 tables include Hugepages-Usage and Bus-Id chip rows
that the old regex rejected, so the GPU tab showed zero Ascend devices
even when npu-smi was present. Parse summary+chip pairs like nputop,
always collect the info dump, and fall back to info -m for NPU IDs.

Closes #2811

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

* feat(system-manager): harden Ascend parsing from nputop fixtures and polish GPU UI

Borrow real npu-smi samples from youyve/nputop (Apache-2.0) so CI covers
910B2C, 310B4, Atlas A3 multi-chip, and 310P3 process tables without
hardware. Aggregate multi-chip NPUs, parse info-dump processes, and surface
npu-smi driver versions.

GPU tab now keeps a short util/HBM sparkline history and ResourceBar uses
smooth width transitions with load-aware tones, closer to an nvtop feel
inside the existing system-manager chrome.

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-07 13:44:18 +00:00
c4e2391dd8 fix(sftp): omit totalBytes on temp download for external editor (#2799)
downloadToTempWithProgress passed totalBytes:0, which transferBridge
treats as an explicit empty snapshot and skips the remote body, so
external editors opened empty files (fixes #2787).

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-08-07 10:26:20 +00:00
cfa47270be fix(terminal): raise autocomplete suggestion cap for snippet matches (#2797)
* fix(terminal): raise autocomplete suggestion default beyond 8

Snippet keyword matches were truncated at autocompleteMaxSuggestions=8
before the floating list, so users with many matching scripts could not
scroll to the rest (fixes #2793).

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

* fix(terminal): migrate persisted autocompleteMaxSuggestions 8 to 50

Existing installs saved the old default into localStorage with no settings
UI, so raising DEFAULT alone left the popup capped at 8. Treat 8 as the
legacy sentinel while preserving intentional custom caps.

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-07 10:26:18 +00:00
b7979e02be fix(terminal): stop keyword highlight flicker on Enter (#2805)
* fix(terminal): stop keyword highlight flicker on Enter

Idle Enter was misclassified as user scroll so overscan decorations were
pruned before writeParsed cancel (Ubuntu RTT). Also swap cursor-line
decorations atomically to avoid clear-before-create flash (fixes #2784).

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

* fix(terminal): catch up highlights after Enter protection window

While enterInputPending blocked scroll-prune, viewport scrolls were also
ignored with no dirty mark. Mark dirty during the window and trigger a
write refresh when Enter protection clears.

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

* fix(terminal): persist keyword highlights across scroll (#2784)

Replace Enter scroll-skip band-aid with mam15mon/#2348 persistence:
keep decorations across scroll, sync only newly exposed rows, bound
retention (~20 viewports / max 1200). Keep Enter write-path protections
and cursor-line atomic swap. Continuous Enter may scroll; prune-on-scroll
is gone.

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

* fix(terminal): bound persistent keyword prune to scroll path

Keep Enter/write deferred pruning intact by pruning the retention cap
only after scroll viewport indexing, not on every decoration apply.

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

* fix(terminal): rescan dirty overlap on persistent scroll refresh

Overlapping scroll only scanned newly exposed rows then cleared the whole
viewport dirty set, which could drop in-place redraw work when scroll
outranked a pending write refresh. Rescan write-dirtied overlap lines and
only clear dirty for ranges that were actually processed.

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: mam15mon <mam15mon@users.noreply.github.com>
2026-08-07 10:25:59 +00:00
2c6390bf6f fix(tabs): restore previous tab after closing temporary session (#2804)
Closing the active tab fell back to last workspace/solo in array order,
so opening a temp tab then closing it jumped away from the prior work
tab (fixes #2788).

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-08-07 10:25:57 +00:00
0f535d0b80 fix(notes): protect title IME, surface persist failures, harden paste (#2803)
* fix(notes): protect title IME, surface persist failures, harden paste

Note titles rewrote controlled value during CJK composition (Sogou Wubi).
Notes localStorage writes ignored QuotaExceededError so restarts lost
content. Markdown paste intercept could preventDefault with a no-op insert
(fixes #2783).

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

* fix(notes): commit title draft on blur for IME composition

IME-only title entry never called onCommit until compositionend; blur
could flush an empty draftTitle and then adopt the stale external value.
Commit the local draft on blur before parent flushNoteDraft.

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

* fix(notes): stash IME title drafts in refs without controlled rewrite

Composition updates now live-stash into draftTitleRef for teardown and
note-switch flushes, while onCommit still waits for idle IME. Blur syncs
superseded external titles before flush so stale drafts cannot overwrite.

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

* fix(notes): clear stashed title when IME composition is superseded

compositionEnd now live-stashes the adopted external title so pagehide
and note-switch flushes cannot persist rejected composed text.

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

* fix(notes): cancel draft flush during IME title stash

Prior idle commits could still debounce-flush mid-composition and rewrite
the controlled title. Stash now clears the timer, and external supersede
in the adopt effect live-stashes the authoritative title.

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

* fix(notes): dedupe quota failure toasts during autosave

Debounced draft flushes can hit QuotaExceededError repeatedly; rate-limit
the error toast so users are not spammed every 300ms.

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-07 10:25:54 +00:00
cursor[bot] 68ad8a6118 Merge pull request #2800 from binaricat/cursor/fix-automation-verify-gate-dcb2
fix(ci): stop Cursor automation verify-gate false positives
2026-08-07 09:04:22 +00:00
Cursor Agentand陈大猫 0144c9c6d3 fix(ci): narrow volatile TAP diag skip to known logger tags
Address Bugbot: do not skip every `# [` comment. Only ignore known app
debug tags (transferDiag, FileWatcher, SessionLogStream, ...), while
still failing on unknown bracket diagnostics like `# [fatal]`.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-08-07 08:57:57 +00:00
Cursor Agentand陈大猫 4bcc619de4 fix(ci): stop automation verify gate false positives
Ignore success-case TAP YAML diagnostics and normalize volatile console
noise in baseline comparison so red-but-unchanged candidate runs pass as
baseline_only. Align the session-backed fastPut expectation with live-path
uploads, harden SCP abort teardown races, drop mixed hallucinated research
citations instead of failing classify, and soften verification failure
copy so it no longer claims the patch introduced the failure.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-08-07 08:49:13 +00:00
cc5ad3be16 perf: isolate Terminal hot props, cut memory/jank, and repay architecture debt (#2790)
* 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>

* 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>

* 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>

* 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>

* 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>

* 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>

* 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>

* 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>

* 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>

* fix(notes): flush draft before markdown export paths

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

* 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>

* 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>

* 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>

* 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>

* 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>

* 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>

* 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>

* 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>

* 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>

* 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>

* 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>

* 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>

---------

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-08-07 14:40:38 +08: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
313 changed files with 24761 additions and 8520 deletions

No files matched your search

+61 -2049
View File
File diff suppressed because it is too large. Load diff
+6 -5
View File
@@ -7,6 +7,7 @@ import {
} from '../state/activeTabStore';
import { updateActiveChromeThemeDeps } from '../state/activeChromeThemeSync';
import { useActiveChromeTheme } from '../state/useActiveChromeTheme';
import { useAppearanceChromeStore } from '../state/appearanceChromeStore';
import { netcattyBridge } from '../../infrastructure/services/netcattyBridge';
import { resolveActiveChromeTheme } from './activeChromeTheme';
import type { TerminalAppearanceHostScope, ResolvedAppearance } from '../../domain/terminalAppearanceRuntime';
@@ -19,7 +20,7 @@ import type {
import type { LogView } from '../state/logViewState';
import type { EditorTabChrome } from '../state/editorTabStore';
interface AppActiveTabChromeProps {
export interface AppActiveTabChromeProps {
showSftpTab: boolean;
setActiveTabId: (id: string) => void;
applyAppTheme: () => void;
@@ -29,8 +30,6 @@ interface AppActiveTabChromeProps {
workspaceById: Map<string, Workspace>;
currentTerminalTheme: TerminalTheme;
followAppTerminalTheme: boolean;
accentMode: 'theme' | 'custom';
customAccent: string;
editorTabs: readonly EditorTabChrome[];
logViews: readonly LogView[];
resolveSessionAppearance?: (hostScope: TerminalAppearanceHostScope) => ResolvedAppearance;
@@ -44,6 +43,9 @@ interface AppActiveTabChromeProps {
* re-renders this null-rendering component (and the self-subscribing leaves)
* instead of forcing the entire App tree (which holds all vault/session/
* settings state and rebuilds the giant AppView ctx) to re-render.
*
* Accent comes from appearanceChromeStore so color-picker drag does not
* rebuild AppShell chrome props.
*/
export function AppActiveTabChrome({
showSftpTab,
@@ -55,14 +57,13 @@ export function AppActiveTabChrome({
workspaceById,
currentTerminalTheme,
followAppTerminalTheme,
accentMode,
customAccent,
editorTabs,
logViews,
resolveSessionAppearance,
t,
}: AppActiveTabChromeProps) {
const activeTabId = useActiveTabId();
const { accentMode, customAccent } = useAppearanceChromeStore();
useEffect(() => {
if (!showSftpTab && activeTabId === 'sftp') {
+25 -20
View File
@@ -2,7 +2,8 @@ import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import test from "node:test";
const appSource = readFileSync(new URL("../../App.tsx", import.meta.url), "utf8");
const appSideEffectsSource = readFileSync(new URL("./AppSideEffects.tsx", import.meta.url), "utf8");
const terminalHostSource = readFileSync(new URL("./hosts/TerminalHost.tsx", import.meta.url), "utf8");
const appViewSource = readFileSync(new URL("./AppView.tsx", import.meta.url), "utf8");
const runtimeSource = readFileSync(new URL("../state/useThemeRuntime.ts", import.meta.url), "utf8");
const settingsSource = readFileSync(new URL("../state/useSettingsState.ts", import.meta.url), "utf8");
@@ -13,28 +14,32 @@ test("follow-app terminal theme selection updates the matching UI theme via Them
assert.match(runtimeSource, /setLightUiThemeId\(update\.uiThemeId\)/);
assert.match(runtimeSource, /setTheme\(update\.appTheme\)/);
assert.doesNotMatch(runtimeSource, /isFollowAppIntentSettled\(userIntent\.themeId/);
assert.match(appSource, /useThemeRuntime\(/);
assert.match(appSource, /pickTerminalTheme\(themeId\)/);
assert.match(appSource, /pickTheme: pickTerminalTheme/);
// Domain deps must use stable members, not the whole themeRuntime bag.
assert.match(appSource, /clearThemeIntent, settleManualThemeIntent, pickTerminalTheme, resolveFocusedAppearance/);
assert.match(terminalHostSource, /useThemeRuntime\(/);
assert.match(terminalHostSource, /pickTerminalTheme\(themeId\)/);
assert.match(terminalHostSource, /pickTheme: pickTerminalTheme/);
// Theme members are listed field-by-field on the TerminalHost bag (not themeRuntime bag).
assert.match(terminalHostSource, /clearThemeIntent,/);
assert.match(terminalHostSource, /settleManualThemeIntent,/);
assert.match(terminalHostSource, /pickTerminalTheme,/);
assert.match(terminalHostSource, /resolveSessionAppearance: resolveFocusedAppearance/);
assert.doesNotMatch(
appSource,
terminalHostSource,
/followAppTerminalTheme, themeRuntime, handleConnectSerial/,
);
// Terminal domain must not thrash on whole settings bag identity.
assert.match(appSource, /sshDebugLogsEnabled,/);
assert.match(terminalHostSource, /sshDebugLogsEnabled:/);
assert.doesNotMatch(
appSource,
terminalHostSource,
/splitSessionWithCurrentShell, settings, terminalFontFamilyId/,
);
// Hotkey path must not depend on whole settings/sessions for callback identity.
assert.match(appSource, /showSftpTab: showSftpTabRef\.current/);
assert.match(appSource, /sessions: sessionsRef\.current/);
assert.match(appSource, /connectionLogs: connectionLogsRef\.current/);
assert.match(appSource, /useTerminalAppearanceInjection/);
assert.match(appSource, /includeChromeSurfaces: followAppTerminalTheme/);
assert.match(appSource, /clearThemeIntent\(\)/);
assert.match(appSideEffectsSource, /showSftpTab: showSftpTabRef\.current/);
assert.match(appSideEffectsSource, /sessions: sessionsRef\.current/);
assert.match(appSideEffectsSource, /connectionLogs: connectionLogsRef\.current/);
assert.match(terminalHostSource, /useTerminalAppearanceInjection/);
assert.match(terminalHostSource, /includeChromeSurfaces: followAppTerminalTheme/);
assert.match(terminalHostSource, /useTerminalAppearanceInjection\(accentedGlobalAppearance/);
assert.match(terminalHostSource, /clearThemeIntent\(\)/);
assert.match(runtimeSource, /injectTerminalAppearanceVars\(appearance\.theme, \{ includeChromeSurfaces \}\)/);
assert.doesNotMatch(settingsSource, /pendingFollowAppTerminalThemeId/);
assert.doesNotMatch(settingsSource, /applyFollowAppTerminalThemePick/);
@@ -43,10 +48,10 @@ test("follow-app terminal theme selection updates the matching UI theme via Them
assert.match(appViewSource, /pickTerminalTheme=\{ctx\.pickTerminalTheme\}/);
});
test("default terminal theme selection clears the current mode override", () => {
assert.match(appSource, /const handleDefaultTerminalThemeChange = useCallback\(\(themeId: string\) => \{/);
assert.match(appSource, /setTerminalThemeId\(themeId\)/);
assert.match(appSource, /resolvedTheme === 'dark'[\s\S]*setTerminalThemeDarkId\(TERMINAL_THEME_AUTO\)/);
assert.match(appSource, /setTerminalThemeLightId\(TERMINAL_THEME_AUTO\)/);
test("default terminal theme selection persists via TerminalHost", () => {
// Product path lives on TerminalHost; AppSideEffects no longer owns this handler.
assert.match(terminalHostSource, /const handleDefaultTerminalThemeChange = useCallback\(\(themeId: string\) => \{/);
assert.match(terminalHostSource, /setTerminalThemeId\(themeId\)/);
assert.match(terminalHostSource, /TERMINAL_THEME_AUTO/);
assert.match(appViewSource, /onUpdateTerminalThemeId=\{handleDefaultTerminalThemeChange\}/);
});
+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\)/);
});
+33 -6
View File
@@ -1,6 +1,7 @@
import React, { useMemo } from 'react';
import React, { memo, useMemo } from 'react';
import { useActiveTabId } from '../state/activeTabStore';
import { useAppearanceChromeStore } from '../state/appearanceChromeStore';
import type { EditorTabChrome } from '../state/editorTabStore';
import type { LogView } from '../state/logViewState';
import { useManualTerminalChromeSurfaceInjection } from '../state/useManualTerminalChromeSurfaceInjection';
@@ -26,9 +27,7 @@ interface AppHostTreeLayerProps {
editorTabs: readonly EditorTabChrome[];
logViews: readonly LogView[];
orderedTabs: readonly string[];
accentMode: 'theme' | 'custom';
currentTerminalTheme: TerminalTheme;
customAccent: string;
followAppTerminalTheme: boolean;
hostById: ReadonlyMap<string, Host>;
themeById: ReadonlyMap<string, TerminalTheme>;
@@ -47,7 +46,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
// accentMode / customAccent intentionally omitted — read from
// appearanceChromeStore so accent drag does not rebuild the App shell.
&& prev.currentTerminalTheme === next.currentTerminalTheme
&& 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,
@@ -57,9 +82,7 @@ export const AppHostTreeLayer: React.FC<AppHostTreeLayerProps> = ({
editorTabs,
logViews,
orderedTabs,
accentMode,
currentTerminalTheme,
customAccent,
followAppTerminalTheme,
hostById,
themeById,
@@ -70,6 +93,7 @@ export const AppHostTreeLayer: React.FC<AppHostTreeLayerProps> = ({
onCreateLocalTerminal,
}) => {
const activeTabId = useActiveTabId();
const { accentMode, customAccent } = useAppearanceChromeStore();
const sessionIds = useMemo(() => new Set(sessions.map((session) => session.id)), [sessions]);
const workspaceIds = useMemo(() => new Set(workspaces.map((workspace) => workspace.id)), [workspaces]);
const logViewIds = useMemo(() => new Set(logViews.map((logView) => logView.id)), [logViews]);
@@ -154,3 +178,6 @@ export const AppHostTreeLayer: React.FC<AppHostTreeLayerProps> = ({
</div>
);
};
export const AppHostTreeLayer = memo(AppHostTreeLayerInner, appHostTreeLayerAreEqual);
AppHostTreeLayer.displayName = 'AppHostTreeLayer';
+13
View File
@@ -0,0 +1,13 @@
import type { ReactNode } from 'react';
/**
* Owns dialog / queue / ephemeral React state for the main window.
*
* The concrete state currently lives in `AppSideEffects` (same React tree)
* and is published into `appLocalUiStore` for Host islands. This provider is
* the composition slot the architecture requires so App itself never owns
* domain bags or mega-hook subscriptions.
*/
export function AppLocalStateProvider({ children }: { children: ReactNode }) {
return <>{children}</>;
}
+4 -2
View File
@@ -53,10 +53,12 @@ test('vault activation suppresses inherited text color transitions', () => {
});
test('vault surface carries app theme vars while terminal chrome is active', () => {
const appThemeStyleSource = readFileSync(new URL('./useAppThemeStyle.ts', import.meta.url), 'utf8');
assert.match(appMountsSource, /appThemeStyle\?: React\.CSSProperties/);
assert.match(appMountsSource, /style=\{\{ \.\.\.appThemeStyle, \.\.\.containerStyle \}\}/);
assert.match(appViewSource, /buildAppThemeCssVars\(tokens, accentMode, customAccent\)/);
assert.match(appViewSource, /<VaultViewContainer appThemeStyle=\{appThemeStyle\}>/);
assert.match(appThemeStyleSource, /buildAppThemeCssVars\(tokens, accentMode, customAccent\)/);
assert.match(appThemeStyleSource, /useAppearanceChromeStore/);
assert.match(appViewSource, /VaultThemedSurface|useAppThemeStyle|appThemeStyle/);
});
test('active tab chrome keeps removed theme side effects unmounted', () => {
@@ -0,0 +1,224 @@
import assert from 'node:assert/strict';
import { existsSync, readFileSync, readdirSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import test from 'node:test';
const here = dirname(fileURLToPath(import.meta.url));
const appShellSource = readFileSync(join(here, 'AppShell.tsx'), 'utf8');
const appSource = readFileSync(join(here, '../../App.tsx'), 'utf8');
const vaultPublisherSource = readFileSync(join(here, 'publishers/VaultPublisher.tsx'), 'utf8');
const sessionPublisherSource = readFileSync(join(here, 'publishers/SessionPublisher.tsx'), 'utf8');
const settingsPublisherSource = readFileSync(join(here, 'publishers/SettingsPublisher.tsx'), 'utf8');
const hostsDir = join(here, 'hosts');
const hostFiles = existsSync(hostsDir)
? readdirSync(hostsDir).filter((name) => name.endsWith('.tsx') || name.endsWith('.ts'))
: [];
const hostSources = Object.fromEntries(
hostFiles.map((name) => [name, readFileSync(join(hostsDir, name), 'utf8')]),
);
const MEGA_HOOKS = ['useVaultState', 'useSessionState', 'useSettingsState'] as const;
const APP_RUNTIME_HOOKS = [
'useAppVaultRuntime',
'useAppSessionRuntime',
'useAppSettingsRuntime',
] as const;
test('AppShell does not co-host the vault/session/settings mega hooks', () => {
for (const hook of MEGA_HOOKS) {
assert.doesNotMatch(
appShellSource,
new RegExp(`${hook}\\s*\\(`),
`AppShell must not call ${hook}(); Hosts subscribe to stores instead`,
);
}
});
test('AppShell composes the four Host islands', () => {
assert.match(appShellSource, /<VaultHost\b/);
assert.match(appShellSource, /<TerminalHost\b/);
assert.match(appShellSource, /<ChromeHost\b/);
assert.match(appShellSource, /<DialogsHost\b/);
});
test('AppShell renders the shell from store-backed bags only', () => {
assert.match(appShellSource, /useSyncExternalStore|useAppShellProps/);
assert.match(appShellSource, /appViewDomainsEqual/);
assert.match(appShellSource, /<AppView domains=\{domains\} \/>/);
assert.match(appShellSource, /<AppActiveTabChrome \{\.\.\.chrome\} \/>/);
});
test('App renders through AppShell and owns no domain bags', () => {
assert.match(appSource, /<AppShell\b/);
assert.doesNotMatch(appSource, /<AppView\b/);
assert.doesNotMatch(appSource, /<AppActiveTabChrome\b/);
assert.doesNotMatch(appSource, /<ConfirmDialog\b/);
assert.doesNotMatch(appSource, /<PortForwardHostKeyDialog\b/);
assert.doesNotMatch(appSource, /appVaultDomain\s*=/);
assert.doesNotMatch(appSource, /appTerminalDomain\s*=/);
assert.doesNotMatch(appSource, /appChromeDomain\s*=/);
assert.doesNotMatch(appSource, /appDialogsDomain\s*=/);
});
test('App.tsx does not subscribe to vault/session/settings runtimes or mega hooks', () => {
for (const hook of MEGA_HOOKS) {
assert.doesNotMatch(appSource, new RegExp(`\\b${hook}\\s*\\(`));
}
for (const hook of APP_RUNTIME_HOOKS) {
assert.doesNotMatch(
appSource,
new RegExp(`\\b${hook}\\s*\\(`),
`App.tsx must not call ${hook}(); move subscriptions into Hosts / AppSideEffects`,
);
}
assert.match(appSource, /<VaultPublisher>/);
assert.match(appSource, /<SessionPublisher\b/);
assert.match(appSource, /<SettingsPublisher\b/);
assert.match(appSource, /<AppSideEffects\b/);
});
test('publishers own the mega hooks and store fan-out', () => {
assert.match(vaultPublisherSource, /\buseVaultState\s*\(/);
assert.match(sessionPublisherSource, /\buseSessionState\s*\(/);
assert.match(settingsPublisherSource, /\buseSettingsState\s*\(/);
assert.match(vaultPublisherSource, /publishVaultSnapshot\(/);
assert.match(vaultPublisherSource, /registerVaultSnapshotActions\(/);
assert.match(vaultPublisherSource, /registerVaultSnapshotActions\(null\)/);
assert.match(sessionPublisherSource, /publishSessionSnapshot\(/);
assert.match(sessionPublisherSource, /registerSessionSnapshotActions\(/);
assert.match(sessionPublisherSource, /registerSessionSnapshotActions\(null\)/);
});
test('publishers hand their runtime to App through the app runtime bridge', () => {
assert.match(vaultPublisherSource, /registerAppVaultRuntime\(vault\)/);
assert.match(vaultPublisherSource, /registerAppVaultRuntime\(null\)/);
assert.match(vaultPublisherSource, /<AppVaultRuntimeContext\.Provider value=\{vaultForApp\}>/);
assert.match(vaultPublisherSource, /notes: _notes/);
assert.match(vaultPublisherSource, /connectionLogs: _connectionLogs/);
assert.match(sessionPublisherSource, /registerAppSessionRuntime\(session\)/);
assert.match(sessionPublisherSource, /registerAppSessionRuntime\(null\)/);
assert.match(sessionPublisherSource, /<AppSessionRuntimeContext\.Provider value=\{session\}>/);
assert.match(settingsPublisherSource, /registerAppSettingsRuntime\(settings\)/);
assert.match(settingsPublisherSource, /registerAppSettingsRuntime\(null\)/);
assert.match(settingsPublisherSource, /<AppSettingsRuntimeContext\.Provider value=\{settings\}>/);
});
test('VaultHost builds from vault snapshot stores', () => {
const source = hostSources['VaultHost.tsx'];
assert.ok(source, 'application/app/hosts/VaultHost.tsx must exist');
assert.match(source, /useVaultSnapshot/);
assert.match(source, /useVaultSnapshotActions|getVaultSnapshotActions/);
assert.doesNotMatch(source, /\buseVaultState\s*\(/);
assert.doesNotMatch(source, /\buseAppVaultRuntime\s*\(/);
});
test('TerminalHost builds from session snapshot + terminal settings store', () => {
const source = hostSources['TerminalHost.tsx'];
assert.ok(source, 'application/app/hosts/TerminalHost.tsx must exist');
assert.match(source, /useSessionSnapshot/);
assert.match(source, /useSessionSnapshotActions|getSessionSnapshotActions/);
assert.match(source, /useTerminalSettingsStore|getTerminalSettingsSnapshot/);
assert.doesNotMatch(source, /\buseSessionState\s*\(/);
assert.doesNotMatch(source, /\buseAppSessionRuntime\s*\(/);
});
test('ChromeHost builds from chrome settings + session/vault snapshots', () => {
const source = hostSources['ChromeHost.tsx'];
assert.ok(source, 'application/app/hosts/ChromeHost.tsx must exist');
assert.match(source, /useSettingsChromeStore|getSettingsChromeSnapshot/);
assert.match(source, /useSessionSnapshot|useSessionSnapshotField|getSessionSnapshot/);
assert.match(source, /useVaultSnapshot|useVaultSnapshotField|getVaultSnapshot/);
assert.doesNotMatch(source, /\buseSettingsState\s*\(/);
assert.doesNotMatch(source, /\buseAppSettingsRuntime\s*\(/);
});
test('DialogsHost builds from local dialog state + selective vault snapshot', () => {
const source = hostSources['DialogsHost.tsx'];
assert.ok(source, 'application/app/hosts/DialogsHost.tsx must exist');
assert.match(source, /useVaultSnapshot|useVaultSnapshotField|getVaultSnapshot/);
assert.doesNotMatch(source, /\buseVaultState\s*\(/);
assert.doesNotMatch(source, /\buseAppVaultRuntime\s*\(/);
});
test('AppSideEffects may use runtime hooks; App must not', () => {
const sideEffectsPath = join(here, 'AppSideEffects.tsx');
assert.ok(existsSync(sideEffectsPath), 'application/app/AppSideEffects.tsx must exist');
const sideEffectsSource = readFileSync(sideEffectsPath, 'utf8');
assert.doesNotMatch(sideEffectsSource, /\bfunction App\b|\bconst App\b/);
assert.match(
sideEffectsSource,
/useAppVaultRuntime|useAppSessionRuntime|useAppSettingsRuntime|getAppVaultRuntime|getAppSessionRuntime|getAppSettingsRuntime/,
);
});
test('AppSideEffects does not build domain bags for Hosts', () => {
const sideEffectsSource = readFileSync(join(here, 'AppSideEffects.tsx'), 'utf8');
assert.doesNotMatch(sideEffectsSource, /appVaultDomain\s*=/);
assert.doesNotMatch(sideEffectsSource, /appTerminalDomain\s*=/);
assert.doesNotMatch(sideEffectsSource, /appChromeDomain\s*=/);
assert.doesNotMatch(sideEffectsSource, /appDialogsDomain\s*=/);
assert.doesNotMatch(sideEffectsSource, /appMountsDomain\s*=/);
assert.doesNotMatch(sideEffectsSource, /appViewDomains\s*=/);
assert.doesNotMatch(sideEffectsSource, /appShellChrome\s*=/);
assert.doesNotMatch(sideEffectsSource, /appShellOverlays\s*=/);
// Flat glue only — no prepared domain bags on the handlers bridge.
assert.doesNotMatch(sideEffectsSource, /vaultDomain\s*:/);
assert.doesNotMatch(sideEffectsSource, /terminalDomain\s*:/);
assert.doesNotMatch(sideEffectsSource, /chromeDomain\s*:/);
assert.doesNotMatch(sideEffectsSource, /dialogsDomain\s*:/);
assert.doesNotMatch(sideEffectsSource, /mountsDomain\s*:/);
assert.match(sideEffectsSource, /registerAppHandlers\s*\(/);
assert.match(sideEffectsSource, /publishAppLocalUi\s*\(/);
});
test('Hosts assemble bags field-by-field without spreading prepared domains', () => {
for (const name of ['VaultHost.tsx', 'TerminalHost.tsx', 'ChromeHost.tsx', 'DialogsHost.tsx']) {
const source = hostSources[name];
assert.ok(source, `application/app/hosts/${name} must exist`);
assert.doesNotMatch(
source,
/handlers\?\.(vault|terminal|chrome|dialogs)Domain|handlers\?\.appShellChrome|handlers\?\.appShellOverlays/,
`${name} must not read prepared *Domain / appShell* bags from handlers`,
);
assert.doesNotMatch(
source,
/\.\.\.\s*prepared/,
`${name} must not spread a prepared domain bag`,
);
assert.match(source, /getAppHandlers|subscribeAppHandlers/);
}
});
test('published Host bags omit notes, accent, and connectionLogs churn fields', () => {
const vaultHost = hostSources['VaultHost.tsx'];
const terminalHost = hostSources['TerminalHost.tsx'];
assert.ok(vaultHost && terminalHost);
// Notes / connection logs live in dedicated stores — VaultHost must not
// publish them into the vault domain bag that AppView consumes.
assert.doesNotMatch(vaultHost, /\bnotes\s*,/);
assert.doesNotMatch(vaultHost, /\bconnectionLogs\s*,/);
assert.doesNotMatch(vaultHost, /notesStore|connectionLogsStore|useNotesStore|useConnectionLogs/);
// Accent feeds useThemeRuntime for local injection only; the published
// terminal domain bag must not list accentMode/customAccent fields.
const domainStart = terminalHost.indexOf('const terminalDomain = useMemo');
assert.notEqual(domainStart, -1);
const domain = terminalHost.slice(domainStart, terminalHost.indexOf('useLayoutEffect(() => {\n if (terminalDomain)', domainStart));
assert.doesNotMatch(domain, /accentMode/);
assert.doesNotMatch(domain, /customAccent/);
assert.match(domain, /currentTerminalTheme,/);
assert.match(
terminalHost,
/useTerminalAppearanceInjection\(accentedGlobalAppearance/,
);
});
test('AppShell uses default memo (store-driven), not always-rerender comparator', () => {
assert.match(appShellSource, /memo\s*\(\s*AppShellView\s*\)/);
assert.doesNotMatch(appShellSource, /memo\s*\(\s*AppShellView\s*,\s*\(\s*\)\s*=>\s*false\s*\)/);
});
+68
View File
@@ -0,0 +1,68 @@
import { memo } from 'react';
import { useI18n } from '../i18n/I18nProvider';
import { ConfirmDialog } from '../../components/ui/confirm-dialog';
import { PortForwardHostKeyDialog } from '../../components/port-forwarding';
import { AppActiveTabChrome } from './AppActiveTabChrome';
import { AppView } from './AppView';
import {
useAppShellProps,
type AppShellOverlays,
} from './appShellPropsStore';
import { ChromeHost } from './hosts/ChromeHost';
import { DialogsHost } from './hosts/DialogsHost';
import { TerminalHost } from './hosts/TerminalHost';
import { VaultHost } from './hosts/VaultHost';
export type { AppShellOverlays };
/**
* The rendered main window. Host islands subscribe to stores and publish
* domain / chrome / overlay bags into `appShellPropsStore`; this shell only
* re-renders when those bag identities change (see `appViewDomainsEqual` /
* chrome / overlays identity checks in the store).
*
* Default `memo` is enough: AppShell takes no props, so parent App updates
* do not force a re-render; store subscriptions via `useAppShellProps` still
* drive updates when Hosts publish new bag identities.
*
* `AppShell.architecture.test.ts` enforces Host composition and that the
* three mega hooks never reappear here.
*/
function AppShellView() {
const { t } = useI18n();
const { domains, chrome, overlays } = useAppShellProps();
return (
<>
<VaultHost />
<TerminalHost />
<ChromeHost />
<DialogsHost />
{domains && chrome && overlays ? (
<>
<PortForwardHostKeyDialog onAddKnownHost={overlays.onAddKnownHost} />
<ConfirmDialog
open={overlays.deleteHostConfirm !== null}
title={
overlays.deleteHostConfirm
? t('confirm.deleteHost', { name: overlays.deleteHostConfirm.name })
: ''
}
confirmLabel={t('action.delete')}
destructive
onOpenChange={(open) => {
if (!open) overlays.onCancelDeleteHost();
}}
onConfirm={overlays.onConfirmDeleteHost}
/>
<AppActiveTabChrome {...chrome} />
<AppView domains={domains} />
</>
) : null}
</>
);
}
export const AppShell = memo(AppShellView);
AppShell.displayName = 'AppShell';
+1768
View File
File diff suppressed because it is too large. Load diff
+300 -186
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';
@@ -24,8 +24,12 @@ 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 { useConnectionLogsStore } from '../state/connectionLogsStore';
import {
useSettingsChromeActions,
useSettingsChromeStore,
} from '../state/settingsChromeStore';
import { useAppThemeStyle } from './useAppThemeStyle';
import { useMainWindowInputFocusRecovery } from '../state/useMainWindowInputFocusRecovery';
import { useExternalMcpToggleState } from '../state/useExternalMcpToggleState';
import { selectPluginThemeTokens } from '../state/pluginContributionEnvironment';
@@ -66,6 +70,144 @@ 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>
);
}
/**
* Applies app theme CSS vars to the vault surface. Subscribing here (instead of
* in AppView) keeps accent-picker drags from rebuilding the whole shell; the
* `children` element identity is unchanged so VaultView bails out.
*/
function AppVaultThemeSurface({
VaultViewContainer,
children,
}: {
VaultViewContainer: React.ComponentType<any>;
children: React.ReactNode;
}) {
const appThemeStyle = useAppThemeStyle();
return (
<VaultViewContainer appThemeStyle={appThemeStyle}>
{children}
</VaultViewContainer>
);
}
/** Plugin keybinding host with locally derived theme tokens and locale. */
function AppPluginKeybindingThemeHost({
sessions,
workspaces,
}: {
sessions: any[];
workspaces: any[];
}) {
const appThemeStyle = useAppThemeStyle();
const { resolvedTheme, uiLanguage } = useSettingsChromeStore();
const pluginThemeTokens = useMemo(
() => selectPluginThemeTokens(appThemeStyle as Record<string, unknown>),
[appThemeStyle],
);
return (
<AppPluginKeybindingHost
locale={uiLanguage}
theme={resolvedTheme}
themeTokens={pluginThemeTokens}
sessions={sessions}
workspaces={workspaces}
/>
);
}
/**
* Log replay surface reads the latest log body from connectionLogsStore, so
* terminal-data appends never touch the App domain bags.
*/
function AppLogViewSurface({
LogViewWrapper,
logView,
defaultTerminalTheme,
defaultFontSize,
onClose,
}: {
LogViewWrapper: React.ComponentType<any>;
logView: any;
defaultTerminalTheme: any;
defaultFontSize: number;
onClose: () => void;
}) {
const { connectionLogs, updateConnectionLog } = useConnectionLogsStore();
const latestLog =
connectionLogs.find((log) => log.id === logView.connectionLogId) ?? logView.log;
return (
<LogViewWrapper
logView={{ ...logView, log: latestLog }}
defaultTerminalTheme={defaultTerminalTheme}
defaultFontSize={defaultFontSize}
onClose={onClose}
onUpdateLog={updateConnectionLog}
/>
);
}
export type AppViewProps = {
domains: AppViewDomains;
};
@@ -114,30 +256,50 @@ function AppViewInner({ domains }: AppViewProps) {
useMainWindowInputFocusRecovery({ onPageHidden: dismissTransientOverlays });
const {
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,
addShellHistoryEntry, removeShellHistoryEntry, addSessionToWorkspace, addToWorkspaceDialog, appendHostToWorkspace, appendLocalTerminalToWorkspace,
clearAndRemoveSource, clearAndRemoveSources, closeLogView, closeSession, closeTabsBatch, closeWorkspace, commitPluginImporterData, commitVaultImportTransaction, copySessionToNewWindowWithCurrentShell, copySessionWithCurrentShell, copyWorkspaceWithCurrentShell,
convertKnownHostToHost, createWorkspaceFromSessions, createWorkspaceFromTargets, createWorkspaceWithHosts,
customGroups, currentTerminalTheme, deepLinkHostDraft, draggingSessionId, effectiveKnownHosts, editorTabs, editorWordWrap, emptyVaultConflict,
followAppTerminalTheme,
groupConfigs, handleAddKnownHost, handleConnectSerial, handleConnectToHost, handleCreateLocalTerminal, handleDefaultTerminalThemeChange, handleDeleteHost,
handleEndSessionDrag, handleFollowAppTerminalThemeChange, handleHostConnectWithProtocolCheck, handleHotkeyAction, handleKeyboardInteractiveCancel, handleKeyboardInteractiveSubmit,
handleOpenHostFromVaultNote, handleOpenQuickSwitcher, handleOpenSettings, handleOpenVaultHostFromChat, handleOpenVaultNoteFromChat, handleOpenVaultSectionFromChat, handleOpenVaultSnippetFromChat, handleRootContextMenu, handlePassphraseCancel, handlePassphraseSkip, handlePassphraseSubmit, handleProtocolSelect,
handleRequestCloseEditorTabRef, handleSessionStatusChange, handleSyncNowManual, handleTerminalDataCapture, handleUpdateHostFromTerminal,
hostById, hosts, terminalHosts, updateTerminalHosts, hotkeyScheme, identities, importOrReuseKey, isBroadcastEnabled, isCreateWorkspaceOpen, isMacClient, isQuickSwitcherOpen,
keyBindings, keyboardInteractiveQueue, keys, logViews, managedSources, navigateToSection, noteGroups, notes, openLogView, openNoteRequest, orderedTabsWithEditors, orphanSessions,
keyBindings, keyboardInteractiveQueue, keys, logViews, managedSources, navigateToSection, 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,
resolveEmptyVaultConflict, resolveSessionAppearance, runSnippet, sessionLogsDir, sessionLogsEnabled, sessionLogsFormat, sessionLogsTimestampsEnabled, sessionRenameTarget, sshDebugLogsEnabled,
sessions, setActiveTabId, setDeepLinkHostDraft, setDraggingSessionId, setEditorWordWrap,
setNavigateToSection, setTerminalFontFamilyId, setTerminalFontSize, setVaultFocusRequest, updateSessionFontSize, updateSessionRestoreCwd, updateSessionDynamicTitle, updateSessionCodingCliProvider, clearSessionFontSizeOverride,
setWorkspaceFocusedSession, 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,
toggleBroadcast, toggleScriptsSidePanelRef, toggleSidePanelRef, toggleWorkspaceViewMode, unmanageSource,
readPersistedHosts, readPersistedManagedSources, updateCustomGroups, updateGroupConfigs, updateHostDistro, updateHosts, updateIdentities, updateKeys, updateKnownHosts, updateManagedSources,
updateNoteGroups, updateNotes, updateProxyProfiles, updateSnippetPackages, updateSnippets, updateSplitSizes, updateTerminalSetting, vaultFocusRequest, workspaceRenameTarget, workspaceRenameValue, workspaces,
updateProxyProfiles, updateSnippetPackages, updateSnippets, updateSplitSizes, updateTerminalSetting, vaultFocusRequest, workspaceRenameTarget, workspaces,
VaultViewContainer, SftpViewMount, TerminalLayerMount, LogViewWrapper,
} = ctx;
// Chrome-visible settings slice comes from settingsChromeStore, not from the
// App chrome domain bag — the whole `settings` object changes identity on
// every settings render and would rebuild the shell.
const {
theme: themePreference,
resolvedTheme,
windowOpacity,
showSftpTab,
showHostTreeSidebar,
showRecentHosts,
hostClickBehavior,
showOnlyUngroupedHostsInRoot,
dynamicTabTitleMode,
disableTerminalFontZoom,
restoreTerminalCwd,
terminalSidePanelAutoOpen,
terminalSidePanelAutoOpenTab,
} = useSettingsChromeStore();
const { setTheme, setWindowOpacity } = useSettingsChromeActions();
const handleTerminalCommandExecuted = useCallback((
command: string,
hostId: string,
@@ -155,21 +317,6 @@ function AppViewInner({ domains }: AppViewProps) {
setAddToWorkspaceDialog({ mode: 'append', workspaceId });
}, [setAddToWorkspaceDialog]);
const appThemeStyle = useMemo(() => {
const tokens = getUiThemeById(
resolvedTheme,
resolvedTheme === 'dark' ? settings.darkUiThemeId : settings.lightUiThemeId,
).tokens;
return {
...buildAppThemeCssVars(tokens, accentMode, customAccent),
colorScheme: resolvedTheme,
} as React.CSSProperties;
}, [accentMode, customAccent, resolvedTheme, settings.darkUiThemeId, settings.lightUiThemeId]);
const pluginThemeTokens = useMemo(
() => selectPluginThemeTokens(appThemeStyle as Record<string, unknown>),
[appThemeStyle],
);
const isPeerSessionWindow = typeof window !== 'undefined'
&& window.location.hash.startsWith('#/session-window');
const externalMcpToggle = useExternalMcpToggleState();
@@ -211,6 +358,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,
@@ -224,58 +435,11 @@ 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}
themePreference={settings.theme}
themePreference={themePreference}
hosts={hosts}
sessions={sessions}
orphanSessions={orphanSessions}
@@ -295,21 +459,21 @@ function AppViewInner({ domains }: AppViewProps) {
onCloseLogView={closeLogView}
onCloseTabsBatch={closeTabsBatch}
onOpenQuickSwitcher={handleOpenQuickSwitcher}
onThemeChange={settings.setTheme}
onThemeChange={setTheme}
onOpenSettings={handleOpenSettings}
externalMcpEnabled={externalMcpToggle.enabled}
onToggleExternalMcp={externalMcpToggle.setEnabled}
showExternalMcpToggle={!isPeerSessionWindow}
windowOpacity={settings.windowOpacity}
setWindowOpacity={settings.setWindowOpacity}
windowOpacity={windowOpacity}
setWindowOpacity={setWindowOpacity}
onSyncNow={handleSyncNowManual}
onStartSessionDrag={setDraggingSessionId}
onEndSessionDrag={handleEndSessionDrag}
onReorderTabs={reorderWorkTabs}
onRemoveSessionFromWorkspace={removeSessionFromWorkspace}
showSftpTab={settings.showSftpTab}
showHostTreeSidebar={settings.showHostTreeSidebar}
dynamicTabTitleMode={settings.terminalSettings.dynamicTabTitleMode}
showSftpTab={showSftpTab}
showHostTreeSidebar={showHostTreeSidebar}
dynamicTabTitleMode={dynamicTabTitleMode}
editorTabs={editorTabs}
pluginViewTabs={pluginViewTabs}
onClosePluginViewTab={closePluginViewTab}
@@ -319,7 +483,7 @@ function AppViewInner({ domains }: AppViewProps) {
<div className="flex-1 relative min-h-0">
<AppHostTreeLayer
enabled={settings.showHostTreeSidebar}
enabled={showHostTreeSidebar}
hosts={hosts}
customGroups={customGroups}
groupConfigs={groupConfigs}
@@ -328,9 +492,7 @@ function AppViewInner({ domains }: AppViewProps) {
editorTabs={editorTabs}
logViews={logViews}
orderedTabs={orderedTabsWithEditors}
accentMode={accentMode}
currentTerminalTheme={currentTerminalTheme}
customAccent={customAccent}
followAppTerminalTheme={followAppTerminalTheme}
hostById={hostById}
themeById={themeById}
@@ -365,7 +527,7 @@ function AppViewInner({ domains }: AppViewProps) {
onUpdateHosts={updateHosts}
/>
<VaultViewContainer appThemeStyle={appThemeStyle}>
<AppVaultThemeSurface VaultViewContainer={VaultViewContainer}>
<VaultView
hosts={hosts}
keys={keys}
@@ -373,11 +535,8 @@ function AppViewInner({ domains }: AppViewProps) {
proxyProfiles={proxyProfiles}
snippets={snippets}
snippetPackages={snippetPackages}
notes={notes}
noteGroups={noteGroups}
customGroups={customGroups}
knownHosts={effectiveKnownHosts}
connectionLogs={connectionLogs}
managedSources={managedSources}
sessionCount={sessions.filter((s) => !s.hiddenFromTabs).length}
hotkeyScheme={hotkeyScheme}
@@ -401,8 +560,6 @@ function AppViewInner({ domains }: AppViewProps) {
onUpdateProxyProfiles={updateProxyProfiles}
onUpdateSnippets={updateSnippets}
onUpdateSnippetPackages={updateSnippetPackages}
onUpdateNotes={updateNotes}
onUpdateNoteGroups={updateNoteGroups}
onUpdateCustomGroups={updateCustomGroups}
onCommitPluginImporterData={commitPluginImporterData}
onUpdateKnownHosts={updateKnownHosts}
@@ -413,14 +570,11 @@ function AppViewInner({ domains }: AppViewProps) {
onClearAndRemoveManagedSources={clearAndRemoveSources}
onUnmanageSource={unmanageSource}
onConvertKnownHost={convertKnownHostToHost}
onToggleConnectionLogSaved={toggleConnectionLogSaved}
onDeleteConnectionLog={deleteConnectionLog}
onClearUnsavedConnectionLogs={clearUnsavedConnectionLogs}
onRunSnippet={runSnippet}
onOpenLogView={openLogView}
showRecentHosts={settings.showRecentHosts}
hostClickBehavior={settings.hostClickBehavior}
showOnlyUngroupedHostsInRoot={settings.showOnlyUngroupedHostsInRoot}
showRecentHosts={showRecentHosts}
hostClickBehavior={hostClickBehavior}
showOnlyUngroupedHostsInRoot={showOnlyUngroupedHostsInRoot}
navigateToSection={navigateToSection}
onNavigateToSectionHandled={() => setNavigateToSection(null)}
deepLinkHostDraft={deepLinkHostDraft}
@@ -429,7 +583,7 @@ function AppViewInner({ domains }: AppViewProps) {
onVaultFocusRequestHandled={() => setVaultFocusRequest(null)}
terminalSettings={terminalSettings}
/>
</VaultViewContainer>
</AppVaultThemeSurface>
<SftpViewMount
hosts={terminalHosts}
@@ -464,8 +618,6 @@ function AppViewInner({ domains }: AppViewProps) {
identities={identities}
snippets={snippets}
snippetPackages={snippetPackages}
notes={notes}
noteGroups={noteGroups}
sessions={sessions}
workspaces={workspaces}
knownHosts={effectiveKnownHosts}
@@ -477,14 +629,12 @@ function AppViewInner({ domains }: AppViewProps) {
clearThemeIntent={ctx.clearThemeIntent}
settleManualThemeIntent={ctx.settleManualThemeIntent}
resolveSessionAppearance={ctx.resolveSessionAppearance}
accentMode={accentMode}
customAccent={customAccent}
terminalSettings={terminalSettings}
terminalFontFamilyId={terminalFontFamilyId}
fontSize={terminalFontSize}
hotkeyScheme={hotkeyScheme}
disableTerminalFontZoom={settings.disableTerminalFontZoom}
restoreTerminalCwd={settings.restoreTerminalCwd}
disableTerminalFontZoom={disableTerminalFontZoom}
restoreTerminalCwd={restoreTerminalCwd}
keyBindings={keyBindings}
onHotkeyAction={handleHotkeyAction}
onUpdateTerminalThemeId={handleDefaultTerminalThemeChange}
@@ -529,16 +679,14 @@ function AppViewInner({ domains }: AppViewProps) {
updateHosts={updateTerminalHosts}
updateSnippets={updateSnippets}
updateSnippetPackages={updateSnippetPackages}
updateNotes={updateNotes}
updateNoteGroups={updateNoteGroups}
sftpDefaultViewMode={sftpDefaultViewMode}
sftpDoubleClickBehavior={sftpDoubleClickBehavior}
sftpAutoSync={sftpAutoSync}
sftpShowHiddenFiles={sftpShowHiddenFiles}
sftpUseCompressedUpload={sftpUseCompressedUpload}
sftpAutoOpenSidebar={sftpAutoOpenSidebar}
terminalSidePanelAutoOpen={settings.terminalSidePanelAutoOpen}
terminalSidePanelAutoOpenTab={settings.terminalSidePanelAutoOpenTab}
terminalSidePanelAutoOpen={terminalSidePanelAutoOpen}
terminalSidePanelAutoOpenTab={terminalSidePanelAutoOpenTab}
sftpFollowTerminalCwd={sftpFollowTerminalCwd}
setSftpFollowTerminalCwd={setSftpFollowTerminalCwd}
editorWordWrap={editorWordWrap}
@@ -548,7 +696,7 @@ function AppViewInner({ domains }: AppViewProps) {
sessionLogsFormat={sessionLogsFormat}
sessionLogsTimestampsEnabled={sessionLogsTimestampsEnabled}
sshDebugLogsEnabled={sshDebugLogsEnabled}
showHostTreeSidebar={settings.showHostTreeSidebar}
showHostTreeSidebar={showHostTreeSidebar}
toggleScriptsSidePanelRef={toggleScriptsSidePanelRef}
toggleSidePanelRef={toggleSidePanelRef}
onStartSessionRename={startSessionRename}
@@ -556,21 +704,18 @@ function AppViewInner({ domains }: AppViewProps) {
onRemoveSessionFromWorkspace={removeSessionFromWorkspace}
/>
{/* Log Views - readonly terminal replays */}
{logViews.map(logView => {
// Get the latest log data from connectionLogs to reflect updates
const latestLog = connectionLogs.find(l => l.id === logView.connectionLogId) || logView.log;
return (
<LogViewWrapper
key={logView.id}
logView={{ ...logView, log: latestLog }}
defaultTerminalTheme={currentTerminalTheme}
defaultFontSize={terminalFontSize}
onClose={() => closeLogView(logView.id)}
onUpdateLog={updateConnectionLog}
/>
);
})}
{/* Log Views - readonly terminal replays. The latest log body comes
from connectionLogsStore inside AppLogViewSurface. */}
{logViews.map((logView: any) => (
<AppLogViewSurface
key={logView.id}
LogViewWrapper={LogViewWrapper}
logView={logView}
defaultTerminalTheme={currentTerminalTheme}
defaultFontSize={terminalFontSize}
onClose={() => closeLogView(logView.id)}
/>
))}
{/* Editor Tabs — kept mounted for Monaco instance persistence; visibility toggled via CSS */}
{editorTabs.map((tab) => (
@@ -587,10 +732,7 @@ function AppViewInner({ domains }: AppViewProps) {
</LazyLoadBoundary>
))}
<AppPluginKeybindingHost
locale={settings.uiLanguage}
theme={resolvedTheme}
themeTokens={pluginThemeTokens}
<AppPluginKeybindingThemeHost
sessions={sessions}
workspaces={workspaces}
/>
@@ -681,7 +823,7 @@ function AppViewInner({ domains }: AppViewProps) {
results={quickResults}
sessions={sessions}
workspaces={workspaces}
showSftpTab={settings.showSftpTab}
showSftpTab={showSftpTab}
onQueryChange={setQuickSearch}
onSelect={handleHostConnectWithProtocolCheck}
onEditHost={(host) => {
@@ -715,59 +857,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">
@@ -871,8 +986,7 @@ function AppViewInner({ domains }: AppViewProps) {
</DialogContent>
</Dialog>
</div>
);
}}
)}
</UnsavedChangesProvider>
</SnippetExecutionProvider>
);
+24
View File
@@ -78,6 +78,30 @@ test("editor tabs use the followed terminal theme when follow-app terminal theme
assert.equal(resolved?.id, currentTheme.id);
});
test("follow-app chrome applies custom accent onto the published base theme", () => {
const editorTab = {
id: "editor-1",
hostId: "host-1",
sessionId: "sftp-1",
};
const resolved = resolveActiveChromeTheme({
...baseInput,
accentMode: "custom",
customAccent: "0 100% 50%",
activeTabId: toEditorTabId(editorTab.id),
editorTabs: [editorTab as unknown as EditorTab],
followAppTerminalTheme: true,
hostById: new Map([
["host-1", { id: "host-1", theme: hostTheme.id } as unknown as Host],
]),
});
assert.equal(resolved?.id, currentTheme.id);
assert.notEqual(resolved?.colors.cursor, currentTheme.colors.cursor);
assert.notEqual(resolved, currentTheme);
});
test("log tabs use the saved log theme when available", () => {
const resolved = resolveActiveChromeTheme({
...baseInput,
+12 -7
View File
@@ -18,12 +18,12 @@ export type ResolveActiveChromeThemeInput = {
customAccent: string;
editorTabs: readonly EditorTabChrome[];
followAppTerminalTheme: boolean;
hostById: Map<string, Host>;
hostById: ReadonlyMap<string, Host>;
logViews: readonly LogView[];
resolveSessionAppearance?: (hostScope: TerminalAppearanceHostScope) => ResolvedAppearance;
sessionById: Map<string, TerminalSession>;
themeById: Map<string, TerminalTheme>;
workspaceById: Map<string, Workspace>;
sessionById: ReadonlyMap<string, TerminalSession>;
themeById: ReadonlyMap<string, TerminalTheme>;
workspaceById: ReadonlyMap<string, Workspace>;
};
export function isActiveChromeThemeResolvable({
@@ -68,7 +68,9 @@ export function resolveActiveChromeTheme({
};
const resolveHostTheme = (hostId: string): TerminalTheme => {
if (followAppTerminalTheme) return currentTerminalTheme;
if (followAppTerminalTheme) {
return applyCustomAccentToTerminalTheme(currentTerminalTheme, accentMode, customAccent);
}
if (resolveSessionAppearance) {
return resolveSessionAppearance(resolveHostScope(hostId)).theme;
}
@@ -90,12 +92,15 @@ export function resolveActiveChromeTheme({
const logView = logViews.find((item) => item.id === activeTabId);
if (logView) {
const explicitThemeId = logView.log.themeId;
return explicitThemeId ? themeById.get(explicitThemeId) ?? currentTerminalTheme : currentTerminalTheme;
const base = explicitThemeId ? themeById.get(explicitThemeId) ?? currentTerminalTheme : currentTerminalTheme;
return applyCustomAccentToTerminalTheme(base, accentMode, customAccent);
}
const workspace = workspaceById.get(activeTabId);
if (workspace) {
if (followAppTerminalTheme) return currentTerminalTheme;
if (followAppTerminalTheme) {
return applyCustomAccentToTerminalTheme(currentTerminalTheme, accentMode, customAccent);
}
if (workspace.viewMode === "focus") {
const focusedSession = resolveWorkspaceTargetSessionFromMap(workspace, sessionById);
+64
View File
@@ -0,0 +1,64 @@
/**
* Stable accessor bridge for App-local handlers that Host islands need when
* assembling domain bags. `AppSideEffects` registers the live glue via
* `useLayoutEffect`; Hosts call `getAppHandlers()` instead of receiving mega
* props from App.
*
* Keep this intentionally loose (`Record<string, unknown>`): the handler set
* tracks App glue, not a frozen public API.
*/
type Listener = () => void;
export type AppHandlers = Record<string, unknown>;
function handlersShallowEqual(prev: AppHandlers, next: AppHandlers): boolean {
const prevKeys = Object.keys(prev);
const nextKeys = Object.keys(next);
if (prevKeys.length !== nextKeys.length) return false;
for (const key of nextKeys) {
if (prev[key] !== next[key]) return false;
}
return true;
}
class AppHandlersBridge {
private handlers: AppHandlers | null = null;
private listeners = new Set<Listener>();
get = (): AppHandlers | null => this.handlers;
subscribe = (listener: Listener): (() => void) => {
this.listeners.add(listener);
return () => {
this.listeners.delete(listener);
};
};
set(next: AppHandlers | null): void {
if (this.handlers === next) return;
if (
this.handlers
&& next
&& handlersShallowEqual(this.handlers, next)
) {
return;
}
this.handlers = next;
for (const listener of this.listeners) listener();
}
}
const bridge = new AppHandlersBridge();
export function registerAppHandlers(handlers: AppHandlers | null): void {
bridge.set(handlers);
}
export function getAppHandlers(): AppHandlers | null {
return bridge.get();
}
export function subscribeAppHandlers(listener: Listener): () => void {
return bridge.subscribe(listener);
}
+116
View File
@@ -0,0 +1,116 @@
import { useSyncExternalStore } from 'react';
import type { Host, PortForwardingRule } from '../../domain/models';
import type { VaultSection } from '../../components/VaultView';
import type { KeyboardInteractiveRequest } from '../../components/KeyboardInteractiveModal';
import type { PassphraseRequest } from '../../components/PassphraseModal';
type Listener = () => void;
/**
* Dialog / queue / ephemeral UI owned outside the vault/session/settings
* mega hooks. DialogsHost / VaultHost / TerminalHost subscribe here so
* AppSideEffects never has to rebuild domain bags when a modal opens.
*
* `portForwardingRules` is published here as a thin derived slice: the PF
* hook still lives in AppSideEffects (tray / sync / auto-start), but Hosts
* must not receive a prepared terminal domain bag.
*/
export type AppLocalUiSnapshot = {
isQuickSwitcherOpen: boolean;
isCreateWorkspaceOpen: boolean;
addToWorkspaceDialog:
| { mode: 'append'; workspaceId: string }
| { mode: 'create' }
| null;
quickSearch: string;
protocolSelectHost: Host | null;
navigateToSection: VaultSection | null;
deepLinkHostDraft: Host | null;
ephemeralHosts: readonly Host[];
portForwardingRules: readonly PortForwardingRule[];
keyboardInteractiveQueue: readonly KeyboardInteractiveRequest[];
passphraseQueue: readonly PassphraseRequest[];
deleteHostConfirm: { hostId: string; name: string } | null;
vaultFocusRequest: unknown;
openNoteRequest: unknown;
emptyVaultConflict: unknown;
};
export const EMPTY_APP_LOCAL_UI: AppLocalUiSnapshot = Object.freeze({
isQuickSwitcherOpen: false,
isCreateWorkspaceOpen: false,
addToWorkspaceDialog: null,
quickSearch: '',
protocolSelectHost: null,
navigateToSection: null,
deepLinkHostDraft: null,
ephemeralHosts: Object.freeze([]) as readonly Host[],
portForwardingRules: Object.freeze([]) as readonly PortForwardingRule[],
keyboardInteractiveQueue: Object.freeze([]) as readonly KeyboardInteractiveRequest[],
passphraseQueue: Object.freeze([]) as readonly PassphraseRequest[],
deleteHostConfirm: null,
vaultFocusRequest: null,
openNoteRequest: null,
emptyVaultConflict: null,
});
class AppLocalUiStore {
private snapshot: AppLocalUiSnapshot = EMPTY_APP_LOCAL_UI;
private listeners = new Set<Listener>();
getSnapshot = (): AppLocalUiSnapshot => this.snapshot;
subscribe = (listener: Listener): (() => void) => {
this.listeners.add(listener);
return () => {
this.listeners.delete(listener);
};
};
setSnapshot(next: AppLocalUiSnapshot): void {
const prev = this.snapshot;
if (
prev.isQuickSwitcherOpen === next.isQuickSwitcherOpen
&& prev.isCreateWorkspaceOpen === next.isCreateWorkspaceOpen
&& prev.addToWorkspaceDialog === next.addToWorkspaceDialog
&& prev.quickSearch === next.quickSearch
&& prev.protocolSelectHost === next.protocolSelectHost
&& prev.navigateToSection === next.navigateToSection
&& prev.deepLinkHostDraft === next.deepLinkHostDraft
&& prev.ephemeralHosts === next.ephemeralHosts
&& prev.portForwardingRules === next.portForwardingRules
&& prev.keyboardInteractiveQueue === next.keyboardInteractiveQueue
&& prev.passphraseQueue === next.passphraseQueue
&& prev.deleteHostConfirm === next.deleteHostConfirm
&& prev.vaultFocusRequest === next.vaultFocusRequest
&& prev.openNoteRequest === next.openNoteRequest
&& prev.emptyVaultConflict === next.emptyVaultConflict
) {
return;
}
this.snapshot = next;
for (const listener of this.listeners) listener();
}
}
export const appLocalUiStore = new AppLocalUiStore();
export function publishAppLocalUi(snapshot: AppLocalUiSnapshot): void {
appLocalUiStore.setSnapshot(snapshot);
}
export function getAppLocalUiSnapshot(): AppLocalUiSnapshot {
return appLocalUiStore.getSnapshot();
}
export function subscribeAppLocalUi(listener: Listener): () => void {
return appLocalUiStore.subscribe(listener);
}
export function useAppLocalUiStore(): AppLocalUiSnapshot {
return useSyncExternalStore(
subscribeAppLocalUi,
getAppLocalUiSnapshot,
getAppLocalUiSnapshot,
);
}
+120
View File
@@ -0,0 +1,120 @@
import { useSyncExternalStore } from 'react';
import type { KnownHost } from '../../types';
import type { AppActiveTabChromeProps } from './AppActiveTabChrome';
import { appViewDomainsEqual, type AppViewDomains } from './appViewDomains';
type Listener = () => void;
export type AppShellOverlays = {
onAddKnownHost: (knownHost: KnownHost) => void;
deleteHostConfirm: { hostId: string; name: string } | null;
onCancelDeleteHost: () => void;
onConfirmDeleteHost: () => void;
};
export type AppShellPropsSnapshot = {
domains: AppViewDomains | null;
chrome: AppActiveTabChromeProps | null;
overlays: AppShellOverlays | null;
};
const EMPTY: AppShellPropsSnapshot = Object.freeze({
domains: null,
chrome: null,
overlays: null,
});
/**
* Host islands publish domain / chrome / overlay bags here. `AppShell`
* subscribes via `useSyncExternalStore` and only re-renders when bag
* identities change (`appViewDomainsEqual` + chrome/overlays identity).
*/
class AppShellPropsStore {
private snapshot: AppShellPropsSnapshot = EMPTY;
private listeners = new Set<Listener>();
getSnapshot = (): AppShellPropsSnapshot => this.snapshot;
subscribe = (listener: Listener): (() => void) => {
this.listeners.add(listener);
return () => {
this.listeners.delete(listener);
};
};
setDomains(domains: AppViewDomains): void {
const prev = this.snapshot;
if (prev.domains && appViewDomainsEqual(prev.domains, domains)) {
return;
}
this.snapshot = { ...prev, domains };
for (const listener of this.listeners) listener();
}
setChrome(chrome: AppActiveTabChromeProps): void {
if (this.snapshot.chrome === chrome) return;
this.snapshot = { ...this.snapshot, chrome };
for (const listener of this.listeners) listener();
}
setOverlays(overlays: AppShellOverlays): void {
if (this.snapshot.overlays === overlays) return;
this.snapshot = { ...this.snapshot, overlays };
for (const listener of this.listeners) listener();
}
setDomainSlice<K extends keyof AppViewDomains>(
key: K,
slice: AppViewDomains[K],
): void {
const prev = this.snapshot.domains;
if (prev && prev[key] === slice) return;
const domains = {
vault: prev?.vault ?? {},
terminal: prev?.terminal ?? {},
chrome: prev?.chrome ?? {},
dialogs: prev?.dialogs ?? {},
mounts: prev?.mounts ?? {},
[key]: slice,
} as AppViewDomains;
this.setDomains(domains);
}
}
export const appShellPropsStore = new AppShellPropsStore();
export function publishAppShellDomains(domains: AppViewDomains): void {
appShellPropsStore.setDomains(domains);
}
export function publishAppShellDomainSlice<K extends keyof AppViewDomains>(
key: K,
slice: AppViewDomains[K],
): void {
appShellPropsStore.setDomainSlice(key, slice);
}
export function publishAppShellChrome(chrome: AppActiveTabChromeProps): void {
appShellPropsStore.setChrome(chrome);
}
export function publishAppShellOverlays(overlays: AppShellOverlays): void {
appShellPropsStore.setOverlays(overlays);
}
export function getAppShellPropsSnapshot(): AppShellPropsSnapshot {
return appShellPropsStore.getSnapshot();
}
export function subscribeAppShellProps(listener: Listener): () => void {
return appShellPropsStore.subscribe(listener);
}
export function useAppShellProps(): AppShellPropsSnapshot {
return useSyncExternalStore(
subscribeAppShellProps,
getAppShellPropsSnapshot,
getAppShellPropsSnapshot,
);
}
+210
View File
@@ -0,0 +1,210 @@
import { useCallback, useLayoutEffect, useMemo, useRef, useSyncExternalStore } from 'react';
import { TERMINAL_THEMES } from '../../../infrastructure/config/terminalThemes';
import { retainStableSessionsIgnoringPresentation } from '../../../domain/terminalPaneSessionsEqual';
import { useI18n } from '../../i18n/I18nProvider';
import { useCustomThemes } from '../../state/customThemeStore';
import { useEditorTabChromeList } from '../../state/editorTabStore';
import { toEditorTabId } from '../../state/activeTabStore';
import {
getSessionSnapshotActions,
useSessionSnapshot,
useSessionSnapshotActions,
} from '../../state/sessionSnapshotStore';
import { useSettingsChromeStore } from '../../state/settingsChromeStore';
import { useVaultSnapshot } from '../../state/vaultSnapshotStore';
import {
getTerminalSettingsActions,
useTerminalSettingsStore,
} from '../../state/terminalSettingsStore';
import { usePluginViewTabs } from '../../state/pluginViewTabStore';
import { getAppHandlers, subscribeAppHandlers } from '../appHandlersBridge';
import {
publishAppShellChrome,
publishAppShellDomainSlice,
} from '../appShellPropsStore';
import {
getThemeRuntimeActions,
subscribeThemeRuntimeActions,
} from '../themeRuntimeBridge';
const IS_MAC_CLIENT =
typeof navigator !== 'undefined' && /Mac|Macintosh/.test(navigator.userAgent);
/**
* Chrome island: TopTabs / active-tab chrome from settings chrome store,
* appearance chrome, and selective session/vault snapshot fields. Assembles
* chrome + shell chrome bags field-by-field — never spreads a prepared bag.
*/
export function ChromeHost() {
const { t } = useI18n();
const settingsChrome = useSettingsChromeStore();
const session = useSessionSnapshot();
const sessionActions = useSessionSnapshotActions();
const vault = useVaultSnapshot();
const terminalSettings = useTerminalSettingsStore();
const editorTabs = useEditorTabChromeList();
const pluginViewTabs = usePluginViewTabs();
void pluginViewTabs;
const customThemes = useCustomThemes();
const handlers = useSyncExternalStore(
subscribeAppHandlers,
getAppHandlers,
getAppHandlers,
);
const themeRuntime = useSyncExternalStore(
subscribeThemeRuntimeActions,
getThemeRuntimeActions,
getThemeRuntimeActions,
);
const orphanSessionsForShellRef = useRef(session.orphanSessions);
const orphanSessionsForShell = retainStableSessionsIgnoringPresentation(
orphanSessionsForShellRef.current,
session.orphanSessions as never,
);
orphanSessionsForShellRef.current = orphanSessionsForShell as typeof session.orphanSessions;
const themeById = useMemo(
() => new Map([...customThemes, ...TERMINAL_THEMES].map((theme) => [theme.id, theme])),
[customThemes],
);
const hostById = useMemo(
() => new Map(vault.hosts.map((host) => [host.id, host])),
[vault.hosts],
);
const sessionById = useMemo(
() => new Map(session.sessions.map((s) => [s.id, s])),
[session.sessions],
);
const workspaceById = useMemo(
() => new Map(session.workspaces.map((workspace) => [workspace.id, workspace])),
[session.workspaces],
);
const editorTabTopIds = useMemo(
() => editorTabs.map((tab) => toEditorTabId(tab.id)),
[editorTabs],
);
const pluginViewTabIds = useMemo(
() => pluginViewTabs.map((tab) => tab.id),
[pluginViewTabs],
);
const additionalWorkTabIds = useMemo(
() => [...editorTabTopIds, ...pluginViewTabIds],
[editorTabTopIds, pluginViewTabIds],
);
const orderedTabsWithEditors = useMemo(
() => sessionActions?.getOrderedWorkTabs(additionalWorkTabIds) ?? ['vault'],
[additionalWorkTabIds, sessionActions],
);
const reorderWorkTabs = useCallback((
draggedId: string,
targetId: string,
position: 'before' | 'after' = 'before',
) => {
sessionActions?.reorderTabs(draggedId, targetId, position, additionalWorkTabIds);
}, [additionalWorkTabIds, sessionActions]);
const chromeDomain = useMemo(() => {
if (!handlers) return null;
return {
closeLogView: sessionActions?.closeLogView,
handleEndSessionDrag: handlers.handleEndSessionDrag,
handleOpenQuickSwitcher: handlers.handleOpenQuickSwitcher,
handleOpenSettings: handlers.handleOpenSettings,
handleRootContextMenu: handlers.handleRootContextMenu,
handleSyncNowManual: handlers.handleSyncNowManual,
isMacClient: IS_MAC_CLIENT,
logViews: session.logViews,
openLogView: sessionActions?.openLogView,
orderedTabsWithEditors,
orphanSessions: orphanSessionsForShell,
reorderWorkTabs,
resetSessionRename: sessionActions?.resetSessionRename,
resetWorkspaceRename: sessionActions?.resetWorkspaceRename,
sessionRenameTarget: session.sessionRenameTarget,
setActiveTabId: sessionActions?.setActiveTabId,
startSessionRename: sessionActions?.startSessionRename,
renameSessionInline: sessionActions?.renameSessionInline,
startWorkspaceRename: sessionActions?.startWorkspaceRename,
submitSessionRename: sessionActions?.submitSessionRename,
submitWorkspaceRename: sessionActions?.submitWorkspaceRename,
t,
themeById,
workspaceRenameTarget: session.workspaceRenameTarget,
};
}, [
handlers,
orderedTabsWithEditors,
orphanSessionsForShell,
reorderWorkTabs,
session.logViews,
session.sessionRenameTarget,
session.workspaceRenameTarget,
sessionActions,
t,
themeById,
]);
// Call-time getters so chrome can publish before Publisher layout effects
// register action slots — avoids undefined applyAppTheme/setActiveTabId on
// the first Host publish (startup TypeError under StrictMode).
const setActiveTabId = useCallback((id: string) => {
getSessionSnapshotActions()?.setActiveTabId?.(id);
}, []);
const applyAppTheme = useCallback(() => {
getTerminalSettingsActions()?.applyAppTheme?.();
}, []);
const appShellChrome = useMemo(() => {
if (!handlers) return null;
// Theme runtime actions may still be null on the first paint before
// TerminalHost registers them; wait so AppActiveTabChrome never mounts
// with a missing resolveSessionAppearance / currentTerminalTheme.
if (!themeRuntime?.currentTerminalTheme || !themeRuntime?.resolveFocusedAppearance) {
return null;
}
return {
showSftpTab: settingsChrome.showSftpTab,
setActiveTabId,
applyAppTheme,
hostById,
sessionById,
themeById,
workspaceById,
currentTerminalTheme: themeRuntime.currentTerminalTheme,
followAppTerminalTheme: terminalSettings.followAppTerminalTheme,
editorTabs,
logViews: session.logViews,
resolveSessionAppearance: themeRuntime.resolveFocusedAppearance,
t,
};
}, [
applyAppTheme,
editorTabs,
handlers,
hostById,
session.logViews,
sessionById,
setActiveTabId,
settingsChrome.showSftpTab,
t,
terminalSettings.followAppTerminalTheme,
themeById,
themeRuntime,
workspaceById,
]);
useLayoutEffect(() => {
if (chromeDomain) publishAppShellDomainSlice('chrome', chromeDomain);
if (appShellChrome) publishAppShellChrome(appShellChrome as never);
}, [appShellChrome, chromeDomain]);
return null;
}
+107
View File
@@ -0,0 +1,107 @@
import { useLayoutEffect, useMemo, useSyncExternalStore } from 'react';
import { getHostSearchMatch } from '../../../lib/searchMatcher';
import type { Host } from '../../../types';
import { useEditorTabChromeList } from '../../state/editorTabStore';
import { useVaultSnapshot } from '../../state/vaultSnapshotStore';
import { getAppHandlers, subscribeAppHandlers } from '../appHandlersBridge';
import {
publishAppShellDomainSlice,
publishAppShellOverlays,
} from '../appShellPropsStore';
import { useAppLocalUiStore } from '../appLocalUiStore';
const EMPTY_HOST_RESULTS: Host[] = [];
/**
* Dialogs island: local dialog/queue state from `appLocalUiStore`, plus a
* selective vault hosts subscription for quick-search results when open.
* Assembles dialogs + overlays field-by-field — never spreads a prepared bag.
*/
export function DialogsHost() {
const local = useAppLocalUiStore();
const vault = useVaultSnapshot();
const editorTabs = useEditorTabChromeList();
const handlers = useSyncExternalStore(
subscribeAppHandlers,
getAppHandlers,
getAppHandlers,
);
const quickResults = useMemo(() => {
if (!local.isQuickSwitcherOpen) return EMPTY_HOST_RESULTS;
const term = local.quickSearch.trim();
if (!term) return vault.hosts as Host[];
return (vault.hosts as Host[])
.map((host) => ({ host, match: getHostSearchMatch(term, host) }))
.filter((entry) => entry.match.matched)
.sort((left, right) => {
if (left.match.score !== right.match.score) {
return right.match.score - left.match.score;
}
return left.host.label.localeCompare(right.host.label);
})
.map((entry) => entry.host);
}, [local.isQuickSwitcherOpen, local.quickSearch, vault.hosts]);
const dialogsDomain = useMemo(() => {
if (!handlers) return null;
return {
addToWorkspaceDialog: local.addToWorkspaceDialog,
clearAndRemoveSource: handlers.clearAndRemoveSource,
clearAndRemoveSources: handlers.clearAndRemoveSources,
editorTabs,
emptyVaultConflict: local.emptyVaultConflict,
handleHostConnectWithProtocolCheck: handlers.handleHostConnectWithProtocolCheck,
handleKeyboardInteractiveCancel: handlers.handleKeyboardInteractiveCancel,
handleKeyboardInteractiveSubmit: handlers.handleKeyboardInteractiveSubmit,
handlePassphraseCancel: handlers.handlePassphraseCancel,
handlePassphraseSkip: handlers.handlePassphraseSkip,
handlePassphraseSubmit: handlers.handlePassphraseSubmit,
handleProtocolSelect: handlers.handleProtocolSelect,
handleRequestCloseEditorTabRef: handlers.handleRequestCloseEditorTabRef,
isCreateWorkspaceOpen: local.isCreateWorkspaceOpen,
isQuickSwitcherOpen: local.isQuickSwitcherOpen,
keyboardInteractiveQueue: local.keyboardInteractiveQueue,
passphraseQueue: local.passphraseQueue,
protocolSelectHost: local.protocolSelectHost,
quickResults,
quickSearch: local.quickSearch,
resolveEmptyVaultConflict: handlers.resolveEmptyVaultConflict,
setAddToWorkspaceDialog: handlers.setAddToWorkspaceDialog,
setIsCreateWorkspaceOpen: handlers.setIsCreateWorkspaceOpen,
setIsQuickSwitcherOpen: handlers.setIsQuickSwitcherOpen,
setProtocolSelectHost: handlers.setProtocolSelectHost,
setQuickSearch: handlers.setQuickSearch,
};
}, [
editorTabs,
handlers,
local.addToWorkspaceDialog,
local.emptyVaultConflict,
local.isCreateWorkspaceOpen,
local.isQuickSwitcherOpen,
local.keyboardInteractiveQueue,
local.passphraseQueue,
local.protocolSelectHost,
local.quickSearch,
quickResults,
]);
const overlays = useMemo(() => {
if (!handlers) return null;
return {
onAddKnownHost: handlers.handleAddKnownHost as (knownHost: never) => void,
deleteHostConfirm: local.deleteHostConfirm,
onCancelDeleteHost: handlers.handleCancelDeleteHost as () => void,
onConfirmDeleteHost: handlers.handleConfirmDeleteHost as () => void,
};
}, [handlers, local.deleteHostConfirm]);
useLayoutEffect(() => {
if (dialogsDomain) publishAppShellDomainSlice('dialogs', dialogsDomain);
if (overlays) publishAppShellOverlays(overlays as never);
}, [dialogsDomain, overlays]);
return null;
}
+279
View File
@@ -0,0 +1,279 @@
import { useCallback, useLayoutEffect, useMemo, useRef, useSyncExternalStore } from 'react';
import { TERMINAL_THEME_AUTO } from '../../../domain/terminalAppearance';
import { retainStableSessionsIgnoringPresentation } from '../../../domain/terminalPaneSessionsEqual';
import { getAppSettingsRuntime } from '../../state/appRuntimeBridge';
import { useAppearanceChromeStore } from '../../state/appearanceChromeStore';
import { useCustomThemes } from '../../state/customThemeStore';
import {
useSessionSnapshot,
useSessionSnapshotActions,
} from '../../state/sessionSnapshotStore';
import {
useSettingsChromeActions,
useSettingsChromeStore,
} from '../../state/settingsChromeStore';
import {
useTerminalSettingsActions,
useTerminalSettingsStore,
} from '../../state/terminalSettingsStore';
import { useThemeRuntime, useTerminalAppearanceInjection } from '../../state/useThemeRuntime';
import {
useVaultSnapshot,
useVaultSnapshotActions,
} from '../../state/vaultSnapshotStore';
import { getAppHandlers, subscribeAppHandlers } from '../appHandlersBridge';
import { publishAppShellDomainSlice } from '../appShellPropsStore';
import { useAppLocalUiStore } from '../appLocalUiStore';
import { registerThemeRuntimeActions } from '../themeRuntimeBridge';
/**
* Terminal island: sessions from `sessionSnapshotStore`, terminal settings
* from `terminalSettingsStore`, mutators via snapshot actions, theme runtime
* owned here, glue handlers from the app handlers bridge. Assembles the full
* terminal domain bag field-by-field — never spreads a prepared bag.
*/
export function TerminalHost() {
const session = useSessionSnapshot();
const sessionActions = useSessionSnapshotActions();
const vault = useVaultSnapshot();
const vaultActions = useVaultSnapshotActions();
const terminalSettings = useTerminalSettingsStore();
const terminalSettingsActions = useTerminalSettingsActions();
const {
followAppTerminalTheme,
terminalThemeId,
terminalThemeDarkId,
terminalThemeLightId,
} = terminalSettings;
const settingsChrome = useSettingsChromeStore();
const settingsChromeActions = useSettingsChromeActions();
const appearance = useAppearanceChromeStore();
const customThemes = useCustomThemes();
const local = useAppLocalUiStore();
const handlers = useSyncExternalStore(
subscribeAppHandlers,
getAppHandlers,
getAppHandlers,
);
// Call-time getters: SettingsPublisher registers the runtime in a layout
// effect after this Host's first render. Capturing noop setters into
// useThemeRuntime would permanently drop follow-app UI theme persistence.
const setLightUiThemeId = useCallback((id: string) => {
getAppSettingsRuntime()?.setLightUiThemeId?.(id);
}, []);
const setDarkUiThemeId = useCallback((id: string) => {
getAppSettingsRuntime()?.setDarkUiThemeId?.(id);
}, []);
const themeRuntime = useThemeRuntime({
terminalThemeId,
terminalThemeDarkId,
terminalThemeLightId,
followAppTerminalTheme,
resolvedTheme: settingsChrome.resolvedTheme,
lightUiThemeId: settingsChrome.lightUiThemeId,
darkUiThemeId: settingsChrome.darkUiThemeId,
accentMode: appearance.accentMode,
customAccent: appearance.customAccent,
customThemes,
setTheme: settingsChromeActions.setTheme,
setLightUiThemeId,
setDarkUiThemeId,
});
const {
globalAppearance,
accentedGlobalAppearance,
clearIntent: clearThemeIntent,
settleManualIntent: settleManualThemeIntent,
pickTheme: pickTerminalTheme,
resolveFocusedAppearance,
currentTerminalTheme,
} = themeRuntime;
// Inject live accent into CSS vars without publishing accented theme identity
// into the terminal domain bag (accent drag must not rebuild AppShell).
useTerminalAppearanceInjection(accentedGlobalAppearance, {
includeChromeSurfaces: followAppTerminalTheme,
});
const prevFollowAppTerminalThemeRef = useRef(followAppTerminalTheme);
useLayoutEffect(() => {
if (prevFollowAppTerminalThemeRef.current === followAppTerminalTheme) return;
prevFollowAppTerminalThemeRef.current = followAppTerminalTheme;
clearThemeIntent();
}, [followAppTerminalTheme, clearThemeIntent]);
// Bridge exposes the stable base theme only — ChromeHost must not republish
// when accentedGlobalAppearance identity churns during color-picker drag.
const themeBridgeActions = useMemo(() => ({
clearThemeIntent,
settleManualThemeIntent,
pickTerminalTheme,
resolveFocusedAppearance: resolveFocusedAppearance as (...args: never[]) => unknown,
currentTerminalTheme,
globalAppearance,
}), [
clearThemeIntent,
currentTerminalTheme,
globalAppearance,
pickTerminalTheme,
resolveFocusedAppearance,
settleManualThemeIntent,
]);
useLayoutEffect(() => {
registerThemeRuntimeActions(themeBridgeActions);
return () => {
registerThemeRuntimeActions(null);
};
}, [themeBridgeActions]);
const sessionsForShellRef = useRef(session.sessions);
const sessionsForShell = retainStableSessionsIgnoringPresentation(
sessionsForShellRef.current,
session.sessions as never,
);
sessionsForShellRef.current = sessionsForShell as typeof session.sessions;
const hostById = useMemo(
() => new Map(vault.hosts.map((host) => [host.id, host])),
[vault.hosts],
);
const terminalHosts = useMemo(
() => (
local.ephemeralHosts.length > 0
? [...vault.hosts, ...local.ephemeralHosts]
: vault.hosts
),
[local.ephemeralHosts, vault.hosts],
);
const handleDefaultTerminalThemeChange = useCallback((themeId: string) => {
// Persist the default theme for ephemeral/manual hosts. Mode overrides
// reset to auto so the chosen theme becomes the new baseline for the
// current resolved UI mode (same behavior as pre-Host App).
terminalSettingsActions?.setTerminalThemeId(themeId);
if (settingsChrome.resolvedTheme === 'dark') {
terminalSettingsActions?.setTerminalThemeDarkId(TERMINAL_THEME_AUTO);
} else {
terminalSettingsActions?.setTerminalThemeLightId(TERMINAL_THEME_AUTO);
}
}, [settingsChrome.resolvedTheme, terminalSettingsActions]);
const handleFollowAppTerminalThemeChange = useCallback((themeId: string) => {
pickTerminalTheme(themeId);
}, [pickTerminalTheme]);
const terminalDomain = useMemo(() => {
if (!handlers) return null;
return {
addSessionToWorkspace: sessionActions?.addSessionToWorkspace,
appendHostToWorkspace: sessionActions?.appendHostToWorkspace,
appendLocalTerminalToWorkspace: sessionActions?.appendLocalTerminalToWorkspace,
clearSessionFontSizeOverride: sessionActions?.clearSessionFontSizeOverride,
closeSession: sessionActions?.closeSession,
closeTabsBatch: handlers.closeTabsBatch,
copySessionWithCurrentShell: handlers.copySessionWithCurrentShell,
copyWorkspaceWithCurrentShell: handlers.copyWorkspaceWithCurrentShell,
copySessionToNewWindowWithCurrentShell: handlers.copySessionToNewWindowWithCurrentShell,
closeWorkspace: sessionActions?.closeWorkspace,
createWorkspaceFromSessions: sessionActions?.createWorkspaceFromSessions,
createWorkspaceFromTargets: handlers.createWorkspaceFromTargets,
createWorkspaceWithHosts: handlers.createWorkspaceWithHosts,
currentTerminalTheme,
draggingSessionId: session.draggingSessionId,
editorWordWrap: terminalSettings.editorWordWrap,
followAppTerminalTheme: terminalSettings.followAppTerminalTheme,
clearThemeIntent,
settleManualThemeIntent,
pickTerminalTheme,
resolveSessionAppearance: resolveFocusedAppearance,
handleConnectSerial: handlers.handleConnectSerial,
handleConnectToHost: handlers.handleConnectToHost,
handleCreateLocalTerminal: handlers.handleCreateLocalTerminal,
handleDefaultTerminalThemeChange,
handleFollowAppTerminalThemeChange,
handleHotkeyAction: handlers.handleHotkeyAction,
handleSessionStatusChange: handlers.handleSessionStatusChange,
handleTerminalDataCapture: handlers.handleTerminalDataCapture,
handleUpdateHostFromTerminal: handlers.handleUpdateHostFromTerminal,
hostById,
terminalHosts,
updateTerminalHosts: handlers.updateTerminalHosts,
hotkeyScheme: terminalSettings.hotkeyScheme,
isBroadcastEnabled: sessionActions?.isBroadcastEnabled,
keyBindings: terminalSettings.keyBindings,
openNoteRequest: local.openNoteRequest,
portForwardingRules: local.portForwardingRules,
removeSessionFromWorkspace: sessionActions?.removeSessionFromWorkspace,
reorderWorkspaceSessions: sessionActions?.reorderWorkspaceSessions,
runSnippet: handlers.runSnippet,
sessionLogsDir: terminalSettings.sessionLogsDir,
sessionLogsEnabled: terminalSettings.sessionLogsEnabled,
sessionLogsFormat: terminalSettings.sessionLogsFormat,
sessionLogsTimestampsEnabled: terminalSettings.sessionLogsTimestampsEnabled,
sessions: sessionsForShell,
setDraggingSessionId: sessionActions?.setDraggingSessionId,
setEditorWordWrap: terminalSettingsActions?.setEditorWordWrap,
setTerminalFontFamilyId: terminalSettingsActions?.setTerminalFontFamilyId,
setTerminalFontSize: terminalSettingsActions?.setTerminalFontSize,
setWorkspaceFocusedSession: sessionActions?.setWorkspaceFocusedSession,
sftpAutoOpenSidebar: terminalSettings.sftpAutoOpenSidebar,
sftpFollowTerminalCwd: terminalSettings.sftpFollowTerminalCwd,
setSftpFollowTerminalCwd: terminalSettingsActions?.setSftpFollowTerminalCwd,
sftpAutoSync: terminalSettings.sftpAutoSync,
sftpDefaultViewMode: terminalSettings.sftpDefaultViewMode,
sftpDoubleClickBehavior: terminalSettings.sftpDoubleClickBehavior,
sftpShowHiddenFiles: terminalSettings.sftpShowHiddenFiles,
sftpUseCompressedUpload: terminalSettings.sftpUseCompressedUpload,
splitSessionWithCurrentShell: handlers.splitSessionWithCurrentShell,
sshDebugLogsEnabled: terminalSettings.sshDebugLogsEnabled,
terminalFontFamilyId: terminalSettings.terminalFontFamilyId,
terminalFontSize: terminalSettings.terminalFontSize,
terminalSettings: terminalSettings.terminalSettings,
terminalThemeId: terminalSettings.terminalThemeId,
toggleBroadcast: sessionActions?.toggleBroadcast,
toggleScriptsSidePanelRef: handlers.toggleScriptsSidePanelRef,
toggleSidePanelRef: handlers.toggleSidePanelRef,
toggleWorkspaceViewMode: sessionActions?.toggleWorkspaceViewMode,
updateHostDistro: vaultActions?.updateHostDistro,
updateSplitSizes: sessionActions?.updateSplitSizes,
updateSessionFontSize: sessionActions?.updateSessionFontSize,
updateSessionRestoreCwd: sessionActions?.updateSessionRestoreCwd,
updateSessionDynamicTitle: sessionActions?.updateSessionDynamicTitle,
updateSessionCodingCliProvider: sessionActions?.updateSessionCodingCliProvider,
updateTerminalSetting: terminalSettingsActions?.updateTerminalSetting,
workspaces: session.workspaces,
};
}, [
clearThemeIntent,
currentTerminalTheme,
handleDefaultTerminalThemeChange,
handleFollowAppTerminalThemeChange,
handlers,
hostById,
local.openNoteRequest,
local.portForwardingRules,
pickTerminalTheme,
resolveFocusedAppearance,
session.draggingSessionId,
session.workspaces,
sessionActions,
sessionsForShell,
settleManualThemeIntent,
terminalHosts,
terminalSettings,
terminalSettingsActions,
vaultActions,
]);
useLayoutEffect(() => {
if (terminalDomain) publishAppShellDomainSlice('terminal', terminalDomain);
}, [terminalDomain]);
return null;
}
+104
View File
@@ -0,0 +1,104 @@
import { useLayoutEffect, useMemo, useSyncExternalStore } from 'react';
import { getEffectiveKnownHosts } from '../../../infrastructure/syncHelpers';
import {
useVaultSnapshot,
useVaultSnapshotActions,
} from '../../state/vaultSnapshotStore';
import { getAppHandlers, subscribeAppHandlers } from '../appHandlersBridge';
import { publishAppShellDomainSlice } from '../appShellPropsStore';
import { useAppLocalUiStore } from '../appLocalUiStore';
import { APP_MOUNTS_DOMAIN } from './mountsDomain';
/**
* Vault island: catalog from `vaultSnapshotStore`, glue handlers from the
* app handlers bridge, local vault UI from `appLocalUiStore`. Assembles the
* full vault domain bag field-by-field — never spreads a prepared bag from
* AppSideEffects.
*/
export function VaultHost() {
const vault = useVaultSnapshot();
const actions = useVaultSnapshotActions();
const local = useAppLocalUiStore();
const handlers = useSyncExternalStore(
subscribeAppHandlers,
getAppHandlers,
getAppHandlers,
);
const effectiveKnownHosts = useMemo(
() => getEffectiveKnownHosts(vault.knownHosts as never) ?? [],
[vault.knownHosts],
);
const vaultDomain = useMemo(() => {
if (!handlers) return null;
return {
addShellHistoryEntry: actions?.addShellHistoryEntry,
removeShellHistoryEntry: actions?.removeShellHistoryEntry,
commitPluginImporterData: actions?.commitPluginImporterData,
commitVaultImportTransaction: actions?.commitVaultImportTransaction,
convertKnownHostToHost: actions?.convertKnownHostToHost,
customGroups: vault.customGroups,
deepLinkHostDraft: local.deepLinkHostDraft,
effectiveKnownHosts,
groupConfigs: vault.groupConfigs,
handleAddKnownHost: handlers.handleAddKnownHost,
handleDeleteHost: handlers.handleDeleteHost,
handleOpenHostFromVaultNote: handlers.handleOpenHostFromVaultNote,
handleOpenVaultHostFromChat: handlers.handleOpenVaultHostFromChat,
handleOpenVaultNoteFromChat: handlers.handleOpenVaultNoteFromChat,
handleOpenVaultSectionFromChat: handlers.handleOpenVaultSectionFromChat,
handleOpenVaultSnippetFromChat: handlers.handleOpenVaultSnippetFromChat,
hosts: vault.hosts,
identities: vault.identities,
importOrReuseKey: actions?.importOrReuseKey,
keys: vault.keys,
managedSources: vault.managedSources,
navigateToSection: local.navigateToSection,
proxyProfiles: vault.proxyProfiles,
readPersistedHosts: actions?.readPersistedHosts,
readPersistedManagedSources: actions?.readPersistedManagedSources,
setDeepLinkHostDraft: handlers.setDeepLinkHostDraft,
setNavigateToSection: handlers.setNavigateToSection,
setVaultFocusRequest: handlers.setVaultFocusRequest,
snippetPackages: vault.snippetPackages,
snippets: vault.snippets,
unmanageSource: handlers.unmanageSource,
updateCustomGroups: actions?.updateCustomGroups,
updateGroupConfigs: actions?.updateGroupConfigs,
updateHosts: actions?.updateHosts,
updateIdentities: actions?.updateIdentities,
updateKeys: actions?.updateKeys,
updateKnownHosts: actions?.updateKnownHosts,
updateManagedSources: actions?.updateManagedSources,
updateProxyProfiles: actions?.updateProxyProfiles,
updateSnippetPackages: actions?.updateSnippetPackages,
updateSnippets: actions?.updateSnippets,
vaultFocusRequest: local.vaultFocusRequest,
};
}, [
actions,
effectiveKnownHosts,
handlers,
local.deepLinkHostDraft,
local.navigateToSection,
local.vaultFocusRequest,
vault.customGroups,
vault.groupConfigs,
vault.hosts,
vault.identities,
vault.keys,
vault.managedSources,
vault.proxyProfiles,
vault.snippetPackages,
vault.snippets,
]);
useLayoutEffect(() => {
if (vaultDomain) publishAppShellDomainSlice('vault', vaultDomain);
publishAppShellDomainSlice('mounts', APP_MOUNTS_DOMAIN);
}, [vaultDomain]);
return null;
}
+9
View File
@@ -0,0 +1,9 @@
import { LogViewWrapper, SftpViewMount, TerminalLayerMount, VaultViewContainer } from '../AppMounts';
/** Lazy mount wrappers — stable module identity for the app lifetime. */
export const APP_MOUNTS_DOMAIN = Object.freeze({
VaultViewContainer,
SftpViewMount,
TerminalLayerMount,
LogViewWrapper,
});
@@ -0,0 +1,217 @@
import { useLayoutEffect, useMemo } from 'react';
import {
AppSessionRuntimeContext,
registerAppSessionRuntime,
} from '../../state/appRuntimeBridge';
import {
publishSessionSnapshot,
registerSessionSnapshotActions,
} from '../../state/sessionSnapshotStore';
import { useSessionState } from '../../state/useSessionState';
import type { ReactNode } from 'react';
export type SessionPublisherProps = {
/** Peer session windows must not write the main window's restore record. */
persistSessionRestore: boolean;
children?: ReactNode;
};
/**
* Owns `useSessionState` and publishes it the same three ways `VaultPublisher`
* publishes the vault: catalog into `sessionSnapshotStore`, mutators into its
* action slot, and the whole runtime onto `appRuntimeBridge` for App.
*/
export function SessionPublisher({ persistSessionRestore, children }: SessionPublisherProps) {
const session = useSessionState({ persistSessionRestore });
const {
sessions,
orphanSessions,
workspaces,
logViews,
draggingSessionId,
sessionRenameTarget,
workspaceRenameTarget,
setActiveTabId,
closeSession,
closeSessions,
closeWorkspace,
openLogView,
closeLogView,
setDraggingSessionId,
startSessionRename,
renameSessionInline,
submitSessionRename,
resetSessionRename,
startWorkspaceRename,
submitWorkspaceRename,
resetWorkspaceRename,
removeSessionFromWorkspace,
setWorkspaceFocusedSession,
toggleWorkspaceViewMode,
createLocalTerminal,
createSerialSession,
connectToHost,
updateSessionStatus,
updateSessionFontSize,
clearSessionFontSizeOverride,
createWorkspaceWithHosts,
createWorkspaceFromSessions,
addSessionToWorkspace,
appendHostToWorkspace,
appendLocalTerminalToWorkspace,
createWorkspaceFromTargets,
updateSplitSizes,
splitSession,
reorderWorkspaceSessions,
moveFocusInWorkspace,
runSnippet,
getOrderedWorkTabs,
reorderTabs,
toggleBroadcast,
isBroadcastEnabled,
copySession,
copyWorkspace,
createSessionFromCloneSource,
updateSessionRestoreCwd,
getSessionRestoreCwd,
updateSessionDynamicTitle,
updateSessionCodingCliProvider,
} = session;
useLayoutEffect(() => {
registerAppSessionRuntime(session);
}, [session]);
// Only a real unmount clears the slot; see VaultPublisher for why.
useLayoutEffect(() => () => {
registerAppSessionRuntime(null);
}, []);
useLayoutEffect(() => {
publishSessionSnapshot({
sessions,
orphanSessions,
workspaces,
logViews,
draggingSessionId,
sessionRenameTarget,
workspaceRenameTarget,
});
}, [
draggingSessionId,
logViews,
orphanSessions,
sessionRenameTarget,
sessions,
workspaceRenameTarget,
workspaces,
]);
const sessionActions = useMemo(() => ({
setActiveTabId,
closeSession,
closeSessions,
closeWorkspace,
openLogView,
closeLogView,
setDraggingSessionId,
startSessionRename,
renameSessionInline,
submitSessionRename,
resetSessionRename,
startWorkspaceRename,
submitWorkspaceRename,
resetWorkspaceRename,
removeSessionFromWorkspace,
setWorkspaceFocusedSession,
toggleWorkspaceViewMode,
createLocalTerminal,
createSerialSession,
connectToHost,
updateSessionStatus,
updateSessionFontSize,
clearSessionFontSizeOverride,
createWorkspaceWithHosts,
createWorkspaceFromSessions,
addSessionToWorkspace,
appendHostToWorkspace,
appendLocalTerminalToWorkspace,
createWorkspaceFromTargets,
updateSplitSizes,
splitSession,
reorderWorkspaceSessions,
moveFocusInWorkspace,
runSnippet,
getOrderedWorkTabs,
reorderTabs,
toggleBroadcast,
isBroadcastEnabled,
copySession,
copyWorkspace,
createSessionFromCloneSource,
updateSessionRestoreCwd,
getSessionRestoreCwd,
updateSessionDynamicTitle,
updateSessionCodingCliProvider,
}), [
addSessionToWorkspace,
appendHostToWorkspace,
appendLocalTerminalToWorkspace,
clearSessionFontSizeOverride,
closeLogView,
closeSession,
closeSessions,
closeWorkspace,
connectToHost,
copySession,
copyWorkspace,
createLocalTerminal,
createSerialSession,
createSessionFromCloneSource,
createWorkspaceFromSessions,
createWorkspaceFromTargets,
createWorkspaceWithHosts,
getOrderedWorkTabs,
getSessionRestoreCwd,
isBroadcastEnabled,
moveFocusInWorkspace,
openLogView,
removeSessionFromWorkspace,
renameSessionInline,
reorderTabs,
reorderWorkspaceSessions,
resetSessionRename,
resetWorkspaceRename,
runSnippet,
setActiveTabId,
setDraggingSessionId,
setWorkspaceFocusedSession,
splitSession,
startSessionRename,
startWorkspaceRename,
submitSessionRename,
submitWorkspaceRename,
toggleBroadcast,
toggleWorkspaceViewMode,
updateSessionCodingCliProvider,
updateSessionDynamicTitle,
updateSessionFontSize,
updateSessionRestoreCwd,
updateSessionStatus,
updateSplitSizes,
]);
useLayoutEffect(() => {
registerSessionSnapshotActions(sessionActions);
return () => {
registerSessionSnapshotActions(null);
};
}, [sessionActions]);
return (
<AppSessionRuntimeContext.Provider value={session}>
{children}
</AppSessionRuntimeContext.Provider>
);
}
@@ -0,0 +1,47 @@
import { useLayoutEffect, type ReactNode } from 'react';
import {
AppSettingsRuntimeContext,
registerAppSettingsRuntime,
} from '../../state/appRuntimeBridge';
import { useSettingsState } from '../../state/useSettingsState';
export type SettingsPublisherProps = {
/** Peer session windows must not drive the main window's settings IPC sync. */
enableSettingsSync: boolean;
/** Peer session windows must not re-apply OS-level system settings effects. */
enableSystemEffects: boolean;
children?: ReactNode;
};
/**
* Owns `useSettingsState` and hands it to consumers the same way
* `VaultPublisher` / `SessionPublisher` hand over their runtimes: a context for
* render-time reads and the `appRuntimeBridge` slot for imperative callers.
*
* The store fan-out (`settingsChromeStore` / `appearanceChromeStore`) already
* happens inside `useSettingsState`, so this publisher only relocates the hook
* out of the component that also builds the shell's domain bags.
*/
export function SettingsPublisher({
enableSettingsSync,
enableSystemEffects,
children,
}: SettingsPublisherProps) {
const settings = useSettingsState({ enableSettingsSync, enableSystemEffects });
useLayoutEffect(() => {
registerAppSettingsRuntime(settings);
}, [settings]);
// Only a real unmount clears the slot; see VaultPublisher for why.
useLayoutEffect(() => () => {
registerAppSettingsRuntime(null);
}, []);
return (
<AppSettingsRuntimeContext.Provider value={settings}>
{children}
</AppSettingsRuntimeContext.Provider>
);
}
@@ -0,0 +1,197 @@
import { useLayoutEffect, useMemo, useRef, type ReactNode } from 'react';
import {
AppVaultRuntimeContext,
registerAppVaultRuntime,
type AppVaultContextValue,
} from '../../state/appRuntimeBridge';
import {
publishVaultSnapshot,
registerVaultSnapshotActions,
} from '../../state/vaultSnapshotStore';
import { useVaultState } from '../../state/useVaultState';
import { getEffectiveKnownHosts } from '../../../infrastructure/syncHelpers';
export type VaultPublisherProps = {
children?: ReactNode;
};
function vaultContextValuesEqual(
prev: AppVaultContextValue,
next: AppVaultContextValue,
): boolean {
const keys = Object.keys(next) as Array<keyof AppVaultContextValue>;
return keys.every((key) => prev[key] === next[key]);
}
/**
* Owns `useVaultState` and publishes it three ways: the catalog into
* `vaultSnapshotStore` for shell surfaces that subscribe to a slice, the
* mutators into the same store's action slot, and a **catalog-only** runtime
* onto `AppVaultRuntimeContext` for App.
*
* `notes` / `noteGroups` / `connectionLogs` / `shellHistory` are intentionally
* absent from the context value: they churn on a different cadence and already
* fan out through dedicated stores. Including them would force App (the
* domain-bag builder) to re-render on every note edit or session log append.
* Imperative callers that still need the full hook return use
* `getAppVaultRuntime()`.
*/
export function VaultPublisher({ children }: VaultPublisherProps) {
const vault = useVaultState();
const {
isInitialized,
hosts,
keys,
identities,
proxyProfiles,
snippets,
snippetPackages,
customGroups,
knownHosts,
managedSources,
groupConfigs,
updateHosts,
updateKeys,
importOrReuseKey,
updateIdentities,
updateProxyProfiles,
updateSnippets,
updateSnippetPackages,
updateCustomGroups,
updateKnownHosts,
updateManagedSources,
updateGroupConfigs,
convertKnownHostToHost,
readPersistedHosts,
readPersistedManagedSources,
commitPluginImporterData,
commitVaultImportTransaction,
updateHostDistro,
updateHostLastConnected,
addShellHistoryEntry,
removeShellHistoryEntry,
} = vault;
useLayoutEffect(() => {
registerAppVaultRuntime(vault);
}, [vault]);
// Only a real unmount clears the slot. A re-render re-registers through the
// effect above, and StrictMode's simulated remount re-runs both.
useLayoutEffect(() => () => {
registerAppVaultRuntime(null);
}, []);
// useVaultState decrypts hosts/keys before it reads known hosts, so the state
// is briefly empty at boot even when storage has entries. Publish the same
// storage fallback App uses so a subscriber connecting during that window
// does not re-prompt for a fingerprint it already trusts.
const effectiveKnownHosts = useMemo(
() => getEffectiveKnownHosts(knownHosts) ?? [],
[knownHosts],
);
useLayoutEffect(() => {
publishVaultSnapshot({
isVaultInitialized: isInitialized,
hosts,
keys,
identities,
proxyProfiles,
snippets,
snippetPackages,
customGroups,
knownHosts: effectiveKnownHosts,
managedSources,
groupConfigs,
});
}, [
customGroups,
effectiveKnownHosts,
groupConfigs,
hosts,
identities,
isInitialized,
keys,
managedSources,
proxyProfiles,
snippetPackages,
snippets,
]);
useLayoutEffect(() => {
registerVaultSnapshotActions({
updateHosts,
updateKeys,
importOrReuseKey,
updateIdentities,
updateProxyProfiles,
updateSnippets,
updateSnippetPackages,
updateCustomGroups,
updateKnownHosts,
updateManagedSources,
updateGroupConfigs,
convertKnownHostToHost,
readPersistedHosts,
readPersistedManagedSources,
commitPluginImporterData,
commitVaultImportTransaction,
updateHostDistro,
updateHostLastConnected,
addShellHistoryEntry,
removeShellHistoryEntry,
});
return () => {
registerVaultSnapshotActions(null);
};
}, [
addShellHistoryEntry,
commitPluginImporterData,
commitVaultImportTransaction,
convertKnownHostToHost,
importOrReuseKey,
readPersistedHosts,
readPersistedManagedSources,
removeShellHistoryEntry,
updateCustomGroups,
updateGroupConfigs,
updateHostDistro,
updateHostLastConnected,
updateHosts,
updateIdentities,
updateKeys,
updateKnownHosts,
updateManagedSources,
updateProxyProfiles,
updateSnippetPackages,
updateSnippets,
]);
// Strip high-churn fields, then retain the previous object identity when only
// those fields (or exportData) changed so React context consumers stay quiet.
const vaultForAppRef = useRef<AppVaultContextValue | null>(null);
const vaultForApp = useMemo((): AppVaultContextValue => {
const {
notes: _notes,
noteGroups: _noteGroups,
connectionLogs: _connectionLogs,
shellHistory: _shellHistory,
exportData: _exportData,
...catalog
} = vault;
const prev = vaultForAppRef.current;
if (prev && vaultContextValuesEqual(prev, catalog)) {
return prev;
}
vaultForAppRef.current = catalog;
return catalog;
}, [vault]);
return (
<AppVaultRuntimeContext.Provider value={vaultForApp}>
{children}
</AppVaultRuntimeContext.Provider>
);
}
@@ -0,0 +1,271 @@
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import test from 'node:test';
const appSource = readFileSync(new URL('../../App.tsx', import.meta.url), 'utf8');
const appSideEffectsSource = readFileSync(
new URL('./AppSideEffects.tsx', import.meta.url),
'utf8',
);
const indexSource = readFileSync(new URL('../../index.tsx', import.meta.url), 'utf8');
const startupEffectsSource = readFileSync(
new URL('./useAppStartupEffects.ts', import.meta.url),
'utf8',
);
const updateCheckSource = readFileSync(
new URL('../state/useUpdateCheck.ts', import.meta.url),
'utf8',
);
const portForwardingAutoStartSource = readFileSync(
new URL('../state/usePortForwardingAutoStart.ts', import.meta.url),
'utf8',
);
test('every renderer root mounts under StrictMode', () => {
assert.match(indexSource, /import \{ StrictMode, Suspense, lazy \} from 'react'/);
const renderCalls = indexSource.match(/root\.render\(/g) ?? [];
assert.equal(renderCalls.length, 4, 'main, settings, tray and terminal-popup roots');
let cursor = 0;
for (let index = 0; index < renderCalls.length; index += 1) {
const renderAt = indexSource.indexOf('root.render(', cursor);
assert.notEqual(renderAt, -1);
const opener = indexSource.slice(renderAt, renderAt + 60);
assert.match(opener, /root\.render\(\s*<StrictMode>/, `root.render #${index + 1} lacks StrictMode`);
cursor = renderAt + 'root.render('.length;
}
});
test('clone-session payload is consumed once even if the effect re-runs', () => {
const effectStart = appSideEffectsSource.indexOf('consumedNewWindowSessionRef');
assert.notEqual(effectStart, -1, 'clone-session effect must latch on payload identity');
const effectEnd = appSideEffectsSource.indexOf(
'}, [createSessionFromCloneSource, isVaultInitialized, pendingNewWindowSession]);',
effectStart,
);
assert.notEqual(effectEnd, -1);
const body = appSideEffectsSource.slice(effectStart, effectEnd);
// A ref comparison is required: clearing the state only lands on the next
// render, so a re-invoked effect still closes over the same payload.
assert.match(body, /if \(consumedNewWindowSessionRef\.current === pendingNewWindowSession\) return;/);
assert.ok(
body.indexOf('consumedNewWindowSessionRef.current = pending')
< body.indexOf('createSessionFromCloneSource(pending.sourceSession'),
'payload must be marked consumed before the clone is created',
);
});
test('rendererReady is notified once per renderer process', () => {
assert.match(appSource, /^let rendererReadySent = false;$/m);
const guardAt = appSource.indexOf('if (!rendererReadySent) {');
assert.notEqual(guardAt, -1);
const guarded = appSource.slice(guardAt, guardAt + 200);
assert.match(guarded, /rendererReadySent = true;/);
assert.match(guarded, /netcattyBridge\.get\(\)\?\.rendererReady\?\.\(\)/);
assert.ok(
guarded.indexOf('rendererReadySent = true;')
< guarded.indexOf('netcattyBridge.get()?.rendererReady?.()'),
'the latch must be set before the IPC call so a re-entrant effect is blocked',
);
});
test('update-available toast latches on the release version', () => {
const latchAt = startupEffectsSource.indexOf('toastedUpdateVersionRef');
assert.notEqual(latchAt, -1);
assert.match(startupEffectsSource, /const toastedUpdateVersionRef = useRef<string \| null>\(null\)/);
assert.match(startupEffectsSource, /if \(toastedUpdateVersionRef\.current === version\) return;/);
const guardAt = startupEffectsSource.indexOf('if (toastedUpdateVersionRef.current === version) return;');
const toastAt = startupEffectsSource.indexOf('toast.info(', guardAt);
assert.notEqual(toastAt, -1);
assert.ok(guardAt < toastAt, 'the version latch must gate the toast call');
});
test('port-forward auto-start runs once across a StrictMode double effect', () => {
assert.match(
portForwardingAutoStartSource,
/const autoStartExecutedRef = useRef\(false\);/,
'the launch auto-start needs a module-render latch, not just an effect dep list',
);
const effectStart = portForwardingAutoStartSource.indexOf('if (autoStartExecutedRef.current) return;');
assert.notEqual(effectStart, -1, 'the effect must bail out when the latch is already set');
const effectEnd = portForwardingAutoStartSource.indexOf(
'}, [\n enabled,\n isVaultInitialized,\n runAutoStart,\n ]);',
effectStart,
);
assert.notEqual(effectEnd, -1, 'auto-start effect dep list moved; update this contract');
const body = portForwardingAutoStartSource.slice(effectStart, effectEnd);
// StrictMode invokes the effect twice with the same render's closure, so the
// latch has to be written before the async run is kicked off — awaiting or
// deferring the write would let the second invoke start a duplicate tunnel.
const latchAt = body.indexOf('autoStartExecutedRef.current = true;');
const runAt = body.indexOf('void runAutoStart();');
assert.notEqual(latchAt, -1);
assert.notEqual(runAt, -1);
assert.ok(latchAt < runAt, 'the latch must be set before runAutoStart() is called');
// The vault gate must also sit before the latch: latching on a pre-hydration
// invoke would permanently suppress the real auto-start.
assert.ok(
body.indexOf('if (!isVaultInitialized) return;') < latchAt,
'the vault gate must precede the latch write',
);
});
test('cancelled startup update check resets its latch instead of skipping forever', () => {
const scheduleAt = updateCheckSource.indexOf('let checkArmed = true;');
assert.notEqual(scheduleAt, -1);
const tail = updateCheckSource.slice(scheduleAt);
// The latch is only meaningful once the timer actually fires; a cleanup that
// cancels it beforehand must let the next effect schedule again.
assert.match(tail, /startupCheckTimeoutRef\.current = setTimeout\(async \(\) => \{\s*\n\s*checkArmed = false;/);
assert.match(tail, /if \(checkArmed\) \{\s*\n\s*hasCheckedOnStartupRef\.current = false;\s*\n\s*\}/);
});
test('terminal popup config survives StrictMode unsubscribe/resubscribe', () => {
const preloadSource = readFileSync(new URL('../../electron/preload.cjs', import.meta.url), 'utf8');
const apiSource = readFileSync(new URL('../../electron/preload/api.cjs', import.meta.url), 'utf8');
assert.match(preloadSource, /lastPayload:\s*null/);
assert.match(
preloadSource,
/terminalPopupConfigState\.lastPayload = payload/,
'incoming popup config must be retained beyond the one-shot pending slot',
);
const subscribeAt = apiSource.indexOf('onTerminalPopupConfig:');
assert.notEqual(subscribeAt, -1);
const subscribe = apiSource.slice(subscribeAt, subscribeAt + 700);
assert.match(
subscribe,
/terminalPopupConfigState\.pending \?\? terminalPopupConfigState\.lastPayload/,
'resubscribe must replay lastPayload after pending was drained',
);
assert.match(subscribe, /terminalPopupConfigState\.pending = null/);
assert.doesNotMatch(
subscribe,
/terminalPopupConfigState\.lastPayload = null/,
'StrictMode remount must not clear lastPayload on subscribe',
);
});
test('vault init cancels the superseded StrictMode effect before publishing ready', () => {
const vaultSource = readFileSync(
new URL('../state/useVaultState.ts', import.meta.url),
'utf8',
);
const initAt = vaultSource.indexOf('let cancelled = false;');
assert.notEqual(initAt, -1, 'vault init must track cancellation');
const initSlice = vaultSource.slice(initAt, initAt + 12000);
assert.match(initSlice, /return \(\) => \{\s*\n\s*cancelled = true;\s*\n\s*\};/);
assert.match(
initSlice,
/if \(!cancelled\) \{\s*\n\s*setIsInitialized\(true\);\s*\n\s*setVaultInitialized\(true\);\s*\n\s*\}/,
'only the surviving init may mark the vault ready',
);
assert.match(initSlice, /if \(cancelled\) return;/);
});
test('global hotkey registration cleans up across StrictMode remount', () => {
const systemEffectsSource = readFileSync(
new URL('../state/systemSettingsEffects.ts', import.meta.url),
'utf8',
);
const hotkeyAt = systemEffectsSource.indexOf('Persist and sync toggle window hotkey setting');
assert.notEqual(hotkeyAt, -1);
const hotkeyEffect = systemEffectsSource.slice(hotkeyAt, hotkeyAt + 2200);
assert.match(hotkeyEffect, /let cancelled = false;/);
assert.match(hotkeyEffect, /if \(cancelled\) return;/);
assert.match(
hotkeyEffect,
/if \(didRegister\) \{\s*\n\s*bridge\?\.unregisterGlobalHotkey/,
'cleanup must unregister a registration started by this effect',
);
// Early return before notify must not skip returning the cleanup function.
assert.doesNotMatch(
hotkeyEffect,
/if \(!persistMountedRef\.current\) return;\s*\n\s*notifySettingsChanged/,
);
});
test('settings persistMountedRef resets on StrictMode cleanup', () => {
const settingsSource = readFileSync(
new URL('../state/useSettingsState.ts', import.meta.url),
'utf8',
);
const markAt = settingsSource.indexOf('Mark persist effects mounted AFTER all persist useEffects');
assert.notEqual(markAt, -1);
const markEffect = settingsSource.slice(markAt, markAt + 500);
assert.match(markEffect, /persistMountedRef\.current = true;/);
assert.match(
markEffect,
/return \(\) => \{\s*\n\s*persistMountedRef\.current = false;\s*\n\s*\};/,
'remount must treat boot as a fresh mount, not a settings change',
);
});
test('tray panel connect flush latches against StrictMode double invoke', () => {
const sideEffectsSource = readFileSync(
new URL('./AppSideEffects.tsx', import.meta.url),
'utf8',
);
assert.match(sideEffectsSource, /pendingTrayConnectFlushKeyRef/);
assert.match(
sideEffectsSource,
/if \(pendingTrayConnectFlushKeyRef\.current === flushKey\) return;/,
);
});
test('ssh transport idle TTL notify latches against StrictMode double invoke', () => {
const settingsSource = readFileSync(
new URL('../state/useSettingsState.ts', import.meta.url),
'utf8',
);
assert.match(settingsSource, /lastPushedSshTransportIdleTtlRef/);
assert.match(
settingsSource,
/if \(lastPushedSshTransportIdleTtlRef\.current === sshTransportIdleTtlMs\) return;/,
);
assert.match(
settingsSource,
/lastPushedSshTransportIdleTtlRef\.current = sshTransportIdleTtlMs;/,
);
});
test('terminal selection Ask-AI payload is consumed once under StrictMode', () => {
const hostSource = readFileSync(
new URL('../../components/terminalLayer/TerminalLayerSupport.tsx', import.meta.url),
'utf8',
);
assert.match(hostSource, /consumedTerminalSelectionRequestIds/);
assert.doesNotMatch(
hostSource,
/consumedTerminalSelectionRequestIdRef/,
'component refs reset on StrictMode remount; use a module Set',
);
assert.match(
hostSource,
/if \(consumedTerminalSelectionRequestIds\.has\(pendingTerminalSelection\.requestId\)\)/,
);
const latchAt = hostSource.indexOf('markTerminalSelectionRequestConsumed(pendingTerminalSelection.requestId)');
const draftAt = hostSource.indexOf('updateDraft(scopeKey, defaultAgentId');
assert.ok(latchAt > 0 && draftAt > latchAt, 'must latch before mutating the draft');
});
test('Codex App Server interaction bridge is app-singleton like MCP approvals', () => {
assert.match(appSource, /setupCodexAppServerInteractionBridge/);
const panelSource = readFileSync(
new URL('../../components/AIChatSidePanel.tsx', import.meta.url),
'utf8',
);
assert.doesNotMatch(
panelSource,
/setupCodexAppServerInteractionBridge/,
'per-panel Codex IPC listeners fan out approvals under retained multi-tab mounts',
);
});
+52
View File
@@ -0,0 +1,52 @@
/**
* Theme runtime actions produced by TerminalHost (`useThemeRuntime`).
* AppSideEffects handlers (default/follow theme changes) call through this
* bridge instead of co-hosting the hook.
*/
type Listener = () => void;
export type ThemeRuntimeBridgeActions = {
clearThemeIntent: () => void;
settleManualThemeIntent: () => void;
pickTerminalTheme: (themeId: string) => void;
resolveFocusedAppearance: (...args: never[]) => unknown;
currentTerminalTheme: unknown;
globalAppearance: unknown;
};
class ThemeRuntimeBridge {
private actions: ThemeRuntimeBridgeActions | null = null;
private listeners = new Set<Listener>();
get = (): ThemeRuntimeBridgeActions | null => this.actions;
subscribe = (listener: Listener): (() => void) => {
this.listeners.add(listener);
return () => {
this.listeners.delete(listener);
};
};
set(next: ThemeRuntimeBridgeActions | null): void {
if (this.actions === next) return;
this.actions = next;
for (const listener of this.listeners) listener();
}
}
const bridge = new ThemeRuntimeBridge();
export function registerThemeRuntimeActions(
actions: ThemeRuntimeBridgeActions | null,
): void {
bridge.set(actions);
}
export function getThemeRuntimeActions(): ThemeRuntimeBridgeActions | null {
return bridge.get();
}
export function subscribeThemeRuntimeActions(listener: Listener): () => void {
return bridge.subscribe(listener);
}
+3
View File
@@ -213,6 +213,7 @@ export function useAppStartupEffects(ctx: StartupEffectsContext) {
}, [dedicatedResumeHosts, enabled, identities, isVaultInitialized, keys, knownHosts, terminalSettings]);
// Show toast notification when update is available (only when auto-download is idle)
const toastedUpdateVersionRef = useRef<string | null>(null);
useEffect(() => {
if (!enabled) return;
// Skip "update available" toast if auto-download has already started or completed
@@ -221,6 +222,8 @@ export function useAppStartupEffects(ctx: StartupEffectsContext) {
if (localStorageAdapter.readString('netcatty_auto_update_enabled_v1') === 'false') return;
if (updateState.hasUpdate && updateState.latestRelease) {
const version = updateState.latestRelease.version;
if (toastedUpdateVersionRef.current === version) return;
toastedUpdateVersionRef.current = version;
toast.info(
t('update.available.message', { version }),
{
+28
View File
@@ -0,0 +1,28 @@
import type React from 'react';
import { useMemo } from 'react';
import { useAppearanceChromeStore } from '../state/appearanceChromeStore';
import { useSettingsChromeStore } from '../state/settingsChromeStore';
import { buildAppThemeCssVars } from '../state/settingsStateDefaults';
import { getUiThemeById } from '../../infrastructure/config/uiThemes';
/**
* App theme CSS variables for surfaces that need them (vault surface, plugin
* theme tokens). Reads accent from appearanceChromeStore and UI theme ids from
* settingsChromeStore so accent drags only re-render the leaf that applies the
* vars, never the App shell.
*/
export function useAppThemeStyle(): React.CSSProperties {
const { accentMode, customAccent } = useAppearanceChromeStore();
const { resolvedTheme, darkUiThemeId, lightUiThemeId } = useSettingsChromeStore();
return useMemo(() => {
const tokens = getUiThemeById(
resolvedTheme,
resolvedTheme === 'dark' ? darkUiThemeId : lightUiThemeId,
).tokens;
return {
...buildAppThemeCssVars(tokens, accentMode, customAccent),
colorScheme: resolvedTheme,
} as React.CSSProperties;
}, [accentMode, customAccent, darkUiThemeId, lightUiThemeId, resolvedTheme]);
}
+26
View File
@@ -267,6 +267,32 @@ test('shared host tree uses the followed terminal theme when follow-app terminal
assert.equal(resolved.id, currentTheme.id);
});
test('follow-app host tree applies custom accent onto the published base theme', () => {
const currentTheme = makeTheme('app-light', 'light', '#ffffff');
const host = {
id: 'host-1',
label: 'Host',
hostname: 'host.local',
username: 'root',
tags: [],
os: 'linux',
} as Host;
const resolved = resolveWorkTabHostTreeTheme({
activeHostId: host.id,
accentMode: 'custom',
currentTerminalTheme: currentTheme,
customAccent: '0 100% 50%',
followAppTerminalTheme: true,
hostById: new Map([[host.id, host]]),
themeById: new Map([[currentTheme.id, currentTheme]]),
});
assert.equal(resolved.id, currentTheme.id);
assert.notEqual(resolved.colors.cursor, currentTheme.colors.cursor);
assert.notEqual(resolved, currentTheme);
});
test('shared host tree falls back to the current terminal theme without an active host', () => {
const currentTheme = makeTheme('app-dark', 'dark', '#111111');
+3 -1
View File
@@ -179,7 +179,9 @@ export function resolveWorkTabHostTreeTheme({
hostById: ReadonlyMap<string, Host>;
themeById: ReadonlyMap<string, TerminalTheme>;
}): TerminalTheme {
if (!activeHostId || followAppTerminalTheme) return currentTerminalTheme;
if (!activeHostId || followAppTerminalTheme) {
return applyCustomAccentToTerminalTheme(currentTerminalTheme, accentMode, customAccent);
}
const host = hostById.get(activeHostId) ?? null;
const themeId = resolveHostTerminalThemeId(host, currentTerminalTheme.id);
+5
View File
@@ -268,6 +268,8 @@ export const enVaultMessages: Messages = {
'sftp.transfers.calculatingTotal': 'Calculating total size...',
'sftp.transfers.filesCount': '{count} files',
'sftp.transfers.filesProgress': '{current}/{total} files',
// Progressive folder walk: total is "found so far", not a fixed grand total.
'sftp.transfers.filesDiscoveredProgress': '{completed} done · {discovered} found',
'sftp.transfers.expandChildren': 'Show files',
'sftp.transfers.collapseChildren': 'Hide files',
'sftp.transfers.expandChildList': 'Show detail',
@@ -560,6 +562,9 @@ export const enVaultMessages: Messages = {
'settings.sftp.compressedUpload.enable': 'Enable folder compression',
'settings.sftp.compressedUpload.enableDesc': 'Automatically compress folders using tar before transfer. Requires tar support on the server. Falls back to regular transfer if not available.',
'settings.sftp.skipUnchanged.enable': 'Skip unchanged files',
'settings.sftp.skipUnchanged.enableDesc': 'Skip files whose size and modification time already match the destination (rsync-style). Disabled when replacing a folder into a staging path.',
// Quick Switcher
'qs.search.placeholder': 'Search hosts or tabs',
'qs.jumpTo': 'Jump To',
+4
View File
@@ -305,6 +305,7 @@ export const ruVaultMessages: Messages = {
'sftp.transfers.calculatingTotal': 'Вычисление общего размера...',
'sftp.transfers.filesCount': '{count} файл(ов)',
'sftp.transfers.filesProgress': '{current}/{total} файл(ов)',
'sftp.transfers.filesDiscoveredProgress': '{completed} готово · {discovered} найдено',
'sftp.transfers.expandChildren': 'Показать файлы',
'sftp.transfers.collapseChildren': 'Скрыть файлы',
'sftp.transfers.expandChildList': 'Показать детали',
@@ -597,6 +598,9 @@ export const ruVaultMessages: Messages = {
'settings.sftp.compressedUpload.enable': 'Включить сжатие папок',
'settings.sftp.compressedUpload.enableDesc': 'Автоматически сжимать папки с помощью tar перед передачей. Требует поддержки tar на сервере. Если она недоступна, будет использована обычная передача.',
'settings.sftp.skipUnchanged.enable': 'Пропускать неизменённые файлы',
'settings.sftp.skipUnchanged.enableDesc': 'Пропускать файлы, у которых размер и время изменения уже совпадают с назначением (в стиле rsync). Отключается при замене папки во временный путь.',
// Quick Switcher
'qs.search.placeholder': 'Поиск хостов или вкладок',
'qs.jumpTo': 'Перейти к',
+1
View File
@@ -863,6 +863,7 @@ export const zhCNCoreMessages: Messages = {
'sftp.transfers.calculatingTotal': '正在统计总大小...',
'sftp.transfers.filesCount': '{count} 个文件',
'sftp.transfers.filesProgress': '{current}/{total} 个文件',
'sftp.transfers.filesDiscoveredProgress': '已完成 {completed} · 已发现 {discovered}',
'sftp.transfers.expandChildren': '展开文件',
'sftp.transfers.collapseChildren': '收起文件',
'sftp.transfers.expandChildList': '展开详情',
@@ -206,6 +206,9 @@ export const zhCNTerminalMessages: Messages = {
'settings.sftp.compressedUpload.enable': '启用文件夹压缩',
'settings.sftp.compressedUpload.enableDesc': '自动使用 tar 压缩文件夹后再传输。需要服务器支持 tar 命令,不支持时自动回退到普通传输。',
'settings.sftp.skipUnchanged.enable': '跳过未改文件',
'settings.sftp.skipUnchanged.enableDesc': '当目标文件的大小与修改时间已与源一致时跳过传输(类似 rsync)。替换到暂存路径时自动禁用。',
// Settings > Terminal
'settings.terminal.section.theme': '终端主题',
'settings.terminal.themeModal.title': '选择主题',
+1
View File
@@ -863,6 +863,7 @@ export const zhTWCoreMessages: Messages = {
'sftp.transfers.calculatingTotal': '正在統計總大小...',
'sftp.transfers.filesCount': '{count} 個檔案',
'sftp.transfers.filesProgress': '{current}/{total} 個檔案',
'sftp.transfers.filesDiscoveredProgress': '已完成 {completed} · 已發現 {discovered}',
'sftp.transfers.expandChildren': '展開檔案',
'sftp.transfers.collapseChildren': '收起檔案',
'sftp.transfers.expandChildList': '展開詳情',
@@ -206,6 +206,9 @@ export const zhTWTerminalMessages: Messages = {
'settings.sftp.compressedUpload.enable': '啟用資料夾壓縮',
'settings.sftp.compressedUpload.enableDesc': '自動使用 tar 壓縮資料夾後再傳輸。需要伺服器支援 tar 指令,不支援時自動回退到普通傳輸。',
'settings.sftp.skipUnchanged.enable': '跳過未改檔案',
'settings.sftp.skipUnchanged.enableDesc': '當目標檔案的大小與修改時間已與來源一致時跳過傳輸(類似 rsync)。替換到暫存路徑時自動停用。',
// Settings > Terminal
'settings.terminal.section.theme': '終端主題',
'settings.terminal.themeModal.title': '選擇主題',
@@ -7,7 +7,7 @@ const chromeThemeSource = readFileSync(new URL("./useActiveChromeTheme.ts", impo
const storeSource = readFileSync(new URL("./activeTabStore.ts", import.meta.url), "utf8");
test("active tab changes notify chrome theme before react subscribers", () => {
const setActiveTabIdBody = storeSource.match(/setActiveTabId = \(id: string\) => \{[\s\S]*?\n {2}\};/)?.[0] ?? "";
const setActiveTabIdBody = storeSource.match(/setActiveTabId = \(id: string(?:, options\?: \w+)?\) => \{[\s\S]*?\n {2}\};/)?.[0] ?? "";
assert.match(setActiveTabIdBody, /this\.syncListeners\.forEach\(\(listener\) => listener\(id\)\)/);
assert.match(setActiveTabIdBody, /this\.scheduleNotify\(\)/);
assert.ok(
+31
View File
@@ -4,6 +4,37 @@ import test from 'node:test';
import { activeTabStore, fromEditorTabId, isEditorTabId, toEditorTabId } from './activeTabStore';
import { terminalLayoutSuppressStore } from './terminalLayoutSuppressStore';
test('active tab store remembers the previous tab for restore-on-close', () => {
const previousWindow = (globalThis as { window?: unknown }).window;
(globalThis as { window?: unknown }).window ??= globalThis;
const marker = `prev-tab-${Date.now()}`;
const first = `${marker}-a`;
const second = `${marker}-b`;
const third = `${marker}-c`;
const before = activeTabStore.getActiveTabId();
try {
activeTabStore.setActiveTabId(first);
activeTabStore.setActiveTabId(second);
assert.equal(activeTabStore.getPreviousActiveTabId(), first);
activeTabStore.setActiveTabId(third);
assert.equal(activeTabStore.getPreviousActiveTabId(), second);
activeTabStore.setActiveTabId(first, { recordPrevious: false });
assert.equal(activeTabStore.getActiveTabId(), first);
assert.equal(activeTabStore.getPreviousActiveTabId(), second);
} finally {
activeTabStore.setActiveTabId(before, { recordPrevious: false });
if (previousWindow === undefined) {
delete (globalThis as { window?: unknown }).window;
} else {
(globalThis as { window?: unknown }).window = previousWindow;
}
}
});
test('editor tab helpers round trip ids', () => {
assert.equal(toEditorTabId('file-1'), 'editor:file-1');
assert.equal(fromEditorTabId('editor:file-1'), 'file-1');
+12 -1
View File
@@ -16,14 +16,22 @@ export const toEditorTabId = (editorId: string): string => `${EDITOR_PREFIX}${ed
/** Strip the "editor:" prefix to recover the internal editorTab id. */
export const fromEditorTabId = (tabId: string): string => tabId.slice(EDITOR_PREFIX.length);
type SetActiveTabOptions = {
/** When false, keep the previous-tab pointer unchanged (used for close restore). */
recordPrevious?: boolean;
};
class ActiveTabStore {
private activeTabId: string = 'vault';
private previousActiveTabId: string | null = null;
private listeners = new Set<Listener>();
private syncListeners = new Set<SyncListener>();
private notifyRafId: number | null = null;
getActiveTabId = () => this.activeTabId;
getPreviousActiveTabId = () => this.previousActiveTabId;
private scheduleNotify = () => {
if (this.notifyRafId !== null) return;
const schedule = typeof requestAnimationFrame === 'function'
@@ -35,8 +43,11 @@ class ActiveTabStore {
});
};
setActiveTabId = (id: string) => {
setActiveTabId = (id: string, options?: SetActiveTabOptions) => {
if (this.activeTabId !== id) {
if (options?.recordPrevious !== false) {
this.previousActiveTabId = this.activeTabId;
}
this.activeTabId = id;
this.syncListeners.forEach((listener) => listener(id));
// Coalesce rapid tab switches into one notification per frame and avoid
+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,
};
}
+80
View File
@@ -0,0 +1,80 @@
import { useSyncExternalStore } from 'react';
import type { AISession } from '../../infrastructure/ai/types';
import type { DraftsByScope, PanelViewByScope } from './aiStateSnapshots';
type Listener = () => void;
export type AISessionsSnapshot = {
sessions: readonly AISession[];
activeSessionIdMap: Readonly<Record<string, string | null>>;
draftsByScope: DraftsByScope;
panelViewByScope: PanelViewByScope;
};
const EMPTY_SESSIONS: readonly AISession[] = Object.freeze([]);
const EMPTY_MAP: Readonly<Record<string, string | null>> = Object.freeze({});
const EMPTY_DRAFTS: DraftsByScope = Object.freeze({});
const EMPTY_PANEL: PanelViewByScope = Object.freeze({});
export const EMPTY_AI_SESSIONS_SNAPSHOT: AISessionsSnapshot = Object.freeze({
sessions: EMPTY_SESSIONS,
activeSessionIdMap: EMPTY_MAP,
draftsByScope: EMPTY_DRAFTS,
panelViewByScope: EMPTY_PANEL,
});
/**
* Hot AI chat state — streaming message updates must not invalidate
* slow AI config Context consumers (providers, permissions, etc.).
*/
class AISessionsStore {
private snapshot: AISessionsSnapshot = EMPTY_AI_SESSIONS_SNAPSHOT;
private listeners = new Set<Listener>();
getSnapshot = (): AISessionsSnapshot => this.snapshot;
subscribe = (listener: Listener): (() => void) => {
this.listeners.add(listener);
return () => {
this.listeners.delete(listener);
};
};
setSnapshot(next: AISessionsSnapshot): void {
if (
this.snapshot.sessions === next.sessions
&& this.snapshot.activeSessionIdMap === next.activeSessionIdMap
&& this.snapshot.draftsByScope === next.draftsByScope
&& this.snapshot.panelViewByScope === next.panelViewByScope
) {
return;
}
this.snapshot = next;
for (const listener of this.listeners) {
listener();
}
}
}
export const aiSessionsStore = new AISessionsStore();
export function publishAISessionsSnapshot(snapshot: AISessionsSnapshot): void {
aiSessionsStore.setSnapshot(snapshot);
}
export function getAISessionsSnapshot(): AISessionsSnapshot {
return aiSessionsStore.getSnapshot();
}
export function subscribeAISessions(listener: Listener): () => void {
return aiSessionsStore.subscribe(listener);
}
export function useAISessionsStore(): AISessionsSnapshot {
return useSyncExternalStore(
subscribeAISessions,
getAISessionsSnapshot,
getAISessionsSnapshot,
);
}
@@ -0,0 +1,99 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
getAppSessionRuntime,
getAppSettingsRuntime,
getAppVaultRuntime,
registerAppSessionRuntime,
registerAppSettingsRuntime,
registerAppVaultRuntime,
subscribeAppSessionRuntime,
subscribeAppSettingsRuntime,
subscribeAppVaultRuntime,
type AppSessionRuntime,
type AppSettingsRuntime,
type AppVaultRuntime,
} from './appRuntimeBridge';
const vaultRuntime = (label: string) => ({ label }) as unknown as AppVaultRuntime;
const sessionRuntime = (label: string) => ({ label }) as unknown as AppSessionRuntime;
const settingsRuntime = (label: string) => ({ label }) as unknown as AppSettingsRuntime;
test('vault slot exposes the last registered runtime and notifies on change', () => {
let notifications = 0;
const unsubscribe = subscribeAppVaultRuntime(() => {
notifications += 1;
});
const first = vaultRuntime('first');
registerAppVaultRuntime(first);
assert.equal(getAppVaultRuntime(), first);
assert.equal(notifications, 1);
// Re-registering the same identity is what a render with unchanged state
// does; it must not wake subscribers.
registerAppVaultRuntime(first);
assert.equal(notifications, 1);
const second = vaultRuntime('second');
registerAppVaultRuntime(second);
assert.equal(getAppVaultRuntime(), second);
assert.equal(notifications, 2);
unsubscribe();
registerAppVaultRuntime(null);
assert.equal(getAppVaultRuntime(), null);
assert.equal(notifications, 2, 'unsubscribed listeners stay quiet');
});
test('session slot is independent from the vault slot', () => {
let vaultNotifications = 0;
let sessionNotifications = 0;
const unsubscribeVault = subscribeAppVaultRuntime(() => {
vaultNotifications += 1;
});
const unsubscribeSession = subscribeAppSessionRuntime(() => {
sessionNotifications += 1;
});
const runtime = sessionRuntime('session');
registerAppSessionRuntime(runtime);
assert.equal(getAppSessionRuntime(), runtime);
assert.equal(sessionNotifications, 1);
assert.equal(vaultNotifications, 0);
unsubscribeVault();
unsubscribeSession();
registerAppSessionRuntime(null);
});
test('settings slot is independent from the vault and session slots', () => {
let settingsNotifications = 0;
let otherNotifications = 0;
const unsubscribeSettings = subscribeAppSettingsRuntime(() => {
settingsNotifications += 1;
});
const unsubscribeVault = subscribeAppVaultRuntime(() => {
otherNotifications += 1;
});
const unsubscribeSession = subscribeAppSessionRuntime(() => {
otherNotifications += 1;
});
const runtime = settingsRuntime('settings');
registerAppSettingsRuntime(runtime);
assert.equal(getAppSettingsRuntime(), runtime);
assert.equal(settingsNotifications, 1);
assert.equal(otherNotifications, 0);
// A re-render with unchanged state re-registers the same identity.
registerAppSettingsRuntime(runtime);
assert.equal(settingsNotifications, 1);
unsubscribeSettings();
unsubscribeVault();
unsubscribeSession();
registerAppSettingsRuntime(null);
assert.equal(getAppSettingsRuntime(), null);
});
+138
View File
@@ -0,0 +1,138 @@
import { createContext, useContext } from 'react';
import type { useSessionState } from './useSessionState';
import type { useSettingsState } from './useSettingsState';
import type { useVaultState } from './useVaultState';
export type AppVaultRuntime = ReturnType<typeof useVaultState>;
export type AppSessionRuntime = ReturnType<typeof useSessionState>;
export type AppSettingsRuntime = ReturnType<typeof useSettingsState>;
/**
* The slice of the vault runtime that `VaultPublisher` puts on the React
* context.
*
* `notes` / `noteGroups` / `connectionLogs` / `shellHistory` are deliberately
* absent. They churn on every note keystroke, session start/exit, and history
* append, and publishing them here would re-render every context consumer for
* data those consumers only ever read at call time. Consumers subscribe to
* `notesStore` / `connectionLogsStore` / `shellHistoryStore` instead, or read
* the full runtime imperatively through `getAppVaultRuntime()`.
*
* `exportData` is omitted for the same reason: its callback identity tracks the
* notes arrays, so keeping it would reintroduce the churn it is meant to avoid.
*/
export type AppVaultContextValue = Omit<
AppVaultRuntime,
'notes' | 'noteGroups' | 'connectionLogs' | 'shellHistory' | 'exportData'
>;
type Listener = () => void;
/**
* Holds the live return of one mega hook so the component that owns the hook
* and the components that consume it no longer have to be the same component.
*
* `VaultPublisher` / `SessionPublisher` / `SettingsPublisher` own
* `useVaultState` / `useSessionState` / `useSettingsState` and expose the value
* two ways:
*
* - through a React context, for render-time consumers (App). Context keeps the
* read synchronous and non-null from the first render, which a store written
* in a layout effect cannot do for a descendant.
* - through this module slot, for imperative callers that need the current
* runtime outside of a render (`getAppVaultRuntime()` in an event handler or
* a bridge callback) and for non-descendant subscribers.
*/
class RuntimeSlot<T> {
private value: T | null = null;
private listeners = new Set<Listener>();
get = (): T | null => this.value;
subscribe = (listener: Listener): (() => void) => {
this.listeners.add(listener);
return () => {
this.listeners.delete(listener);
};
};
set(next: T | null): void {
if (this.value === next) return;
this.value = next;
for (const listener of this.listeners) {
listener();
}
}
}
const vaultSlot = new RuntimeSlot<AppVaultRuntime>();
const sessionSlot = new RuntimeSlot<AppSessionRuntime>();
const settingsSlot = new RuntimeSlot<AppSettingsRuntime>();
export function registerAppVaultRuntime(runtime: AppVaultRuntime | null): void {
vaultSlot.set(runtime);
}
export function getAppVaultRuntime(): AppVaultRuntime | null {
return vaultSlot.get();
}
export function subscribeAppVaultRuntime(listener: Listener): () => void {
return vaultSlot.subscribe(listener);
}
export function registerAppSessionRuntime(runtime: AppSessionRuntime | null): void {
sessionSlot.set(runtime);
}
export function getAppSessionRuntime(): AppSessionRuntime | null {
return sessionSlot.get();
}
export function subscribeAppSessionRuntime(listener: Listener): () => void {
return sessionSlot.subscribe(listener);
}
export function registerAppSettingsRuntime(runtime: AppSettingsRuntime | null): void {
settingsSlot.set(runtime);
}
export function getAppSettingsRuntime(): AppSettingsRuntime | null {
return settingsSlot.get();
}
export function subscribeAppSettingsRuntime(listener: Listener): () => void {
return settingsSlot.subscribe(listener);
}
export const AppVaultRuntimeContext = createContext<AppVaultContextValue | null>(null);
export const AppSessionRuntimeContext = createContext<AppSessionRuntime | null>(null);
export const AppSettingsRuntimeContext = createContext<AppSettingsRuntime | null>(null);
/** Read the vault runtime owned by `VaultPublisher`. */
export function useAppVaultRuntime(): AppVaultContextValue {
const runtime = useContext(AppVaultRuntimeContext);
if (!runtime) {
throw new Error('useAppVaultRuntime must be rendered inside <VaultPublisher>');
}
return runtime;
}
/** Read the session runtime owned by `SessionPublisher`. */
export function useAppSessionRuntime(): AppSessionRuntime {
const runtime = useContext(AppSessionRuntimeContext);
if (!runtime) {
throw new Error('useAppSessionRuntime must be rendered inside <SessionPublisher>');
}
return runtime;
}
/** Read the settings runtime owned by `SettingsPublisher`. */
export function useAppSettingsRuntime(): AppSettingsRuntime {
const runtime = useContext(AppSettingsRuntimeContext);
if (!runtime) {
throw new Error('useAppSettingsRuntime must be rendered inside <SettingsPublisher>');
}
return runtime;
}
@@ -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,
);
}
@@ -0,0 +1,126 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import type { ConnectionLog } from '../../domain/models.ts';
import {
EMPTY_CONNECTION_LOGS_SNAPSHOT,
getConnectionLogsActions,
getConnectionLogsSnapshot,
getEmptyConnectionLogsSnapshot,
publishConnectionLogsSnapshot,
registerConnectionLogsActions,
subscribeConnectionLogs,
subscribeConnectionLogsActions,
} from './connectionLogsStore.ts';
const baseLog: ConnectionLog = {
id: 'log-1',
sessionId: 'session-1',
hostId: 'host-1',
hostLabel: 'Example',
hostname: 'example.com',
username: 'user',
protocol: 'ssh',
startTime: 1000,
localUsername: 'local',
localHostname: 'machine',
saved: false,
};
test('connectionLogsStore notifies subscribers only when the log array identity changes', () => {
const events: number[] = [];
const unsubscribe = subscribeConnectionLogs(() => {
events.push(getConnectionLogsSnapshot().connectionLogs.length);
});
const firstLogs = [baseLog];
publishConnectionLogsSnapshot({ connectionLogs: firstLogs });
assert.equal(events.at(-1), 1);
assert.equal(getConnectionLogsSnapshot().connectionLogs, firstLogs);
// Same identity must not wake the Vault logs section on unrelated renders.
publishConnectionLogsSnapshot({ connectionLogs: firstLogs });
assert.equal(events.length, 1);
const secondLogs = [baseLog, { ...baseLog, id: 'log-2', startTime: 2000 }];
publishConnectionLogsSnapshot({ connectionLogs: secondLogs });
assert.equal(events.at(-1), 2);
assert.equal(events.length, 2);
unsubscribe();
publishConnectionLogsSnapshot({ connectionLogs: [] });
assert.equal(events.length, 2);
});
test('connectionLogsStore exposes a frozen empty snapshot for gated mounts', () => {
assert.equal(getEmptyConnectionLogsSnapshot(), EMPTY_CONNECTION_LOGS_SNAPSHOT);
assert.equal(getEmptyConnectionLogsSnapshot().connectionLogs.length, 0);
assert.equal(Object.isFrozen(EMPTY_CONNECTION_LOGS_SNAPSHOT), true);
});
test('registerConnectionLogsActions exposes the vault log mutators', () => {
const calls: string[] = [];
const actionEvents: number[] = [];
const unsubscribe = subscribeConnectionLogsActions(() => {
actionEvents.push(actionEvents.length);
});
registerConnectionLogsActions({
updateConnectionLog: () => {
calls.push('update');
},
toggleConnectionLogSaved: () => {
calls.push('toggle');
},
deleteConnectionLog: () => {
calls.push('delete');
},
clearUnsavedConnectionLogs: () => {
calls.push('clear');
},
});
const actions = getConnectionLogsActions();
assert.ok(actions);
actions.updateConnectionLog('log-1', { saved: true });
actions.toggleConnectionLogSaved('log-1');
actions.deleteConnectionLog('log-1');
actions.clearUnsavedConnectionLogs();
assert.deepEqual(calls, ['update', 'toggle', 'delete', 'clear']);
assert.equal(actionEvents.length, 1);
registerConnectionLogsActions(null);
assert.equal(getConnectionLogsActions(), null);
assert.equal(actionEvents.length, 2);
unsubscribe();
});
test('connectionLogsStore keeps values and actions on independent slots', () => {
const valueEvents: number[] = [];
const actionEvents: number[] = [];
const unsubValues = subscribeConnectionLogs(() => {
valueEvents.push(valueEvents.length);
});
const unsubActions = subscribeConnectionLogsActions(() => {
actionEvents.push(actionEvents.length);
});
// Re-registering unstable mutator identities must not invalidate the logs.
registerConnectionLogsActions({
updateConnectionLog: () => {},
toggleConnectionLogSaved: () => {},
deleteConnectionLog: () => {},
clearUnsavedConnectionLogs: () => {},
});
assert.equal(valueEvents.length, 0);
assert.equal(actionEvents.length, 1);
publishConnectionLogsSnapshot({ connectionLogs: [baseLog] });
assert.equal(valueEvents.length, 1);
assert.equal(actionEvents.length, 1);
registerConnectionLogsActions(null);
unsubValues();
unsubActions();
});
+135
View File
@@ -0,0 +1,135 @@
import { useSyncExternalStore } from 'react';
import type { ConnectionLog } from '../../domain/models';
type Listener = () => void;
export type ConnectionLogsSnapshot = {
connectionLogs: readonly ConnectionLog[];
};
export type ConnectionLogsActions = {
updateConnectionLog: (id: string, updates: Partial<ConnectionLog>) => void;
toggleConnectionLogSaved: (id: string) => void;
deleteConnectionLog: (id: string) => void;
clearUnsavedConnectionLogs: () => void;
};
const EMPTY_CONNECTION_LOGS: readonly ConnectionLog[] = Object.freeze([]);
export const EMPTY_CONNECTION_LOGS_SNAPSHOT: ConnectionLogsSnapshot = Object.freeze({
connectionLogs: EMPTY_CONNECTION_LOGS,
});
/**
* External store for connection logs so the Vault logs section and log-view
* replays can subscribe without keeping logs in the App domain bags. Every
* session start/exit appends a log, which would otherwise rebuild the whole
* shell.
*/
class ConnectionLogsStore {
private snapshot: ConnectionLogsSnapshot = EMPTY_CONNECTION_LOGS_SNAPSHOT;
private actions: ConnectionLogsActions | null = null;
private listeners = new Set<Listener>();
private actionListeners = new Set<Listener>();
getSnapshot = (): ConnectionLogsSnapshot => this.snapshot;
subscribe = (listener: Listener): (() => void) => {
this.listeners.add(listener);
return () => {
this.listeners.delete(listener);
};
};
setSnapshot(next: ConnectionLogsSnapshot): void {
if (this.snapshot.connectionLogs === next.connectionLogs) return;
this.snapshot = next;
for (const listener of this.listeners) {
listener();
}
}
getActions = (): ConnectionLogsActions | null => this.actions;
subscribeActions = (listener: Listener): (() => void) => {
this.actionListeners.add(listener);
return () => {
this.actionListeners.delete(listener);
};
};
setActions(next: ConnectionLogsActions | null): void {
if (this.actions === next) return;
this.actions = next;
for (const listener of this.actionListeners) {
listener();
}
}
}
export const connectionLogsStore = new ConnectionLogsStore();
export function publishConnectionLogsSnapshot(
snapshot: ConnectionLogsSnapshot,
): void {
connectionLogsStore.setSnapshot(snapshot);
}
export function getConnectionLogsSnapshot(): ConnectionLogsSnapshot {
return connectionLogsStore.getSnapshot();
}
export function subscribeConnectionLogs(listener: Listener): () => void {
return connectionLogsStore.subscribe(listener);
}
export function getEmptyConnectionLogsSnapshot(): ConnectionLogsSnapshot {
return EMPTY_CONNECTION_LOGS_SNAPSHOT;
}
export function registerConnectionLogsActions(
actions: ConnectionLogsActions | null,
): void {
connectionLogsStore.setActions(actions);
}
export function getConnectionLogsActions(): ConnectionLogsActions | null {
return connectionLogsStore.getActions();
}
export function subscribeConnectionLogsActions(listener: Listener): () => void {
return connectionLogsStore.subscribeActions(listener);
}
const noopUpdateConnectionLog: ConnectionLogsActions['updateConnectionLog'] = () => {};
const noopToggleConnectionLogSaved: ConnectionLogsActions['toggleConnectionLogSaved'] = () => {};
const noopDeleteConnectionLog: ConnectionLogsActions['deleteConnectionLog'] = () => {};
const noopClearUnsavedConnectionLogs: ConnectionLogsActions['clearUnsavedConnectionLogs'] = () => {};
/** Subscribe to the connection log catalog plus its vault mutation actions. */
export function useConnectionLogsStore(): {
connectionLogs: ConnectionLog[];
updateConnectionLog: ConnectionLogsActions['updateConnectionLog'];
toggleConnectionLogSaved: ConnectionLogsActions['toggleConnectionLogSaved'];
deleteConnectionLog: ConnectionLogsActions['deleteConnectionLog'];
clearUnsavedConnectionLogs: ConnectionLogsActions['clearUnsavedConnectionLogs'];
} {
const snapshot = useSyncExternalStore(
subscribeConnectionLogs,
getConnectionLogsSnapshot,
getConnectionLogsSnapshot,
);
const actions = useSyncExternalStore(
subscribeConnectionLogsActions,
getConnectionLogsActions,
getConnectionLogsActions,
);
return {
connectionLogs: snapshot.connectionLogs as ConnectionLog[],
updateConnectionLog: actions?.updateConnectionLog ?? noopUpdateConnectionLog,
toggleConnectionLogSaved: actions?.toggleConnectionLogSaved ?? noopToggleConnectionLogSaved,
deleteConnectionLog: actions?.deleteConnectionLog ?? noopDeleteConnectionLog,
clearUnsavedConnectionLogs: actions?.clearUnsavedConnectionLogs ?? noopClearUnsavedConnectionLogs,
};
}
+114
View File
@@ -0,0 +1,114 @@
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('useNotesStore source gates subscribe when enabled is false', async () => {
const { readFileSync } = await import('node:fs');
const source = readFileSync(new URL('./notesStore.ts', import.meta.url), 'utf8');
assert.match(source, /enabled \? subscribeNotes : subscribeNotesNoop/);
assert.match(source, /enabled \? subscribeNotesActions : subscribeNotesNoop/);
assert.match(source, /getNotesSnapshot/);
assert.doesNotMatch(
source,
/enabled \? getNotesSnapshot : getEmptyNotesSnapshot/,
'hidden notes mounts should keep the last live snapshot, not flash empty',
);
});
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);
});
+143
View File
@@ -0,0 +1,143 @@
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[]) => boolean | 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.
*
* Pass `{ enabled: false }` for retained-but-hidden mounts (e.g. terminal
* notes side panel) so vault note publishes do not re-render them. Snapshot
* reads still use the live store so reopen does not flash empty data.
*/
export function useNotesStore(options?: { enabled?: boolean }): {
notes: VaultNote[];
noteGroups: string[];
updateNotes: NotesActions['updateNotes'];
updateNoteGroups: NotesActions['updateNoteGroups'];
} {
const enabled = options?.enabled !== false;
const snapshot = useSyncExternalStore(
enabled ? subscribeNotes : subscribeNotesNoop,
getNotesSnapshot,
getNotesSnapshot,
);
const actions = useSyncExternalStore(
enabled ? subscribeNotesActions : subscribeNotesNoop,
getNotesActions,
getNotesActions,
);
return {
notes: snapshot.notes as VaultNote[],
noteGroups: snapshot.noteGroups as string[],
updateNotes: actions?.updateNotes ?? noopUpdateNotes,
updateNoteGroups: actions?.updateNoteGroups ?? noopUpdateNoteGroups,
};
}
@@ -88,7 +88,13 @@ test("session restore persistence can be disabled for non-main windows", () => {
assert.match(hookSource, /if \(!persistSessionRestore\) return;/);
assert.match(traySource, /useSessionState\(\{ persistSessionRestore: false \}\)/);
assert.match(appSource, /window\.location\.hash\.startsWith\('#\/session-window'\)/);
assert.match(appSource, /persistSessionRestore: !isPeerSessionWindow/);
// App no longer calls useSessionState; SessionPublisher owns the hook and
// App passes the peer-window flag down to it.
assert.match(appSource, /<SessionPublisher persistSessionRestore=\{!isPeerSessionWindow\}>/);
assert.match(
readFileSync(new URL("../app/publishers/SessionPublisher.tsx", import.meta.url), "utf8"),
/useSessionState\(\{ persistSessionRestore \}\)/,
);
assert.match(indexSource, /hash === '#\/session-window'/);
assert.match(registerBridgesSource, /route: "session-window"/);
assert.match(registerBridgesSource, /registerAsMainWindow: false/);
@@ -100,7 +106,8 @@ test("session restore persistence can be disabled for non-main windows", () => {
});
test("session peer windows do not run main-window startup effects", () => {
const appSource = readFileSync(new URL("../../App.tsx", import.meta.url), "utf8");
const appSource = readFileSync(new URL("../app/AppSideEffects.tsx", import.meta.url), "utf8");
const appRootSource = readFileSync(new URL("../../App.tsx", import.meta.url), "utf8");
const autoSyncSource = readFileSync(new URL("./useAutoSync.ts", import.meta.url), "utf8");
const startupEffectsSource = readFileSync(new URL("../app/useAppStartupEffects.ts", import.meta.url), "utf8");
const updateCheckSource = readFileSync(new URL("./useUpdateCheck.ts", import.meta.url), "utf8");
@@ -112,7 +119,13 @@ test("session peer windows do not run main-window startup effects", () => {
const trayPanelJumpIndex = appSource.indexOf("onTrayPanelJumpToSession");
assert.match(appSource, /const isPeerSessionWindow = typeof window !== 'undefined' && window\.location\.hash\.startsWith\('#\/session-window'\)/);
assert.match(appSource, /useSettingsState\(\{[^}]*enableSettingsSync: !isPeerSessionWindow[^}]*enableSystemEffects: !isPeerSessionWindow/s);
// SettingsPublisher owns useSettingsState; App only forwards the peer-window
// flags to it.
assert.match(appRootSource, /<SettingsPublisher\s+enableSettingsSync=\{!isPeerSessionWindow\}\s+enableSystemEffects=\{!isPeerSessionWindow\}/s);
assert.match(
readFileSync(new URL("../app/publishers/SettingsPublisher.tsx", import.meta.url), "utf8"),
/useSettingsState\(\{ enableSettingsSync, enableSystemEffects \}\)/,
);
assert.match(appSource, /useAppStartupEffects\(\{[^}]*enabled: !isPeerSessionWindow/s);
assert.match(appSource, /useUpdateCheck\(\{[^}]*enabled: !isPeerSessionWindow/s);
assert.match(appSource, /if \(isPeerSessionWindow \|\| !isVaultInitialized \|\| versionBackupAttemptedRef\.current\) return;/);
@@ -0,0 +1,118 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import type { TerminalSession } from '../../domain/models.ts';
import {
EMPTY_SESSION_SNAPSHOT,
getSessionSnapshot,
getSessionSnapshotActions,
publishSessionSnapshot,
registerSessionSnapshotActions,
sessionSnapshotsEqual,
subscribeSessionSnapshot,
} from './sessionSnapshotStore.ts';
function makeSession(id: string): TerminalSession {
return { id, hostLabel: id, status: 'connected' } as TerminalSession;
}
test('sessionSnapshotStore notifies only when a field identity changes', () => {
const seen: number[] = [];
const unsubscribe = subscribeSessionSnapshot(() => {
seen.push(getSessionSnapshot().sessions.length);
});
const sessions = [makeSession('s1')];
const snapshot = { ...EMPTY_SESSION_SNAPSHOT, sessions, orphanSessions: sessions };
publishSessionSnapshot(snapshot);
assert.deepEqual(seen, [1]);
assert.equal(getSessionSnapshot().sessions, sessions);
publishSessionSnapshot({ ...snapshot });
assert.deepEqual(seen, [1]);
// Presentation-only fields still count: rename targets drive dialog state.
publishSessionSnapshot({ ...snapshot, sessionRenameTarget: sessions[0] });
assert.deepEqual(seen, [1, 1]);
assert.equal(getSessionSnapshot().sessionRenameTarget, sessions[0]);
unsubscribe();
publishSessionSnapshot(EMPTY_SESSION_SNAPSHOT);
assert.deepEqual(seen, [1, 1]);
});
test('sessionSnapshotsEqual compares every published field', () => {
assert.equal(
sessionSnapshotsEqual(EMPTY_SESSION_SNAPSHOT, { ...EMPTY_SESSION_SNAPSHOT }),
true,
);
assert.equal(
sessionSnapshotsEqual(EMPTY_SESSION_SNAPSHOT, {
...EMPTY_SESSION_SNAPSHOT,
draggingSessionId: 's1',
}),
false,
);
assert.equal(
sessionSnapshotsEqual(EMPTY_SESSION_SNAPSHOT, { ...EMPTY_SESSION_SNAPSHOT, logViews: [] }),
false,
);
});
test('session actions register and unregister', () => {
assert.equal(getSessionSnapshotActions(), null);
const calls: string[] = [];
const noop = (() => {}) as never;
registerSessionSnapshotActions({
setActiveTabId: ((id: string) => calls.push(id)) as never,
closeSession: noop,
closeSessions: noop,
closeWorkspace: noop,
openLogView: noop,
closeLogView: noop,
setDraggingSessionId: noop,
startSessionRename: noop,
renameSessionInline: noop,
submitSessionRename: noop,
resetSessionRename: noop,
startWorkspaceRename: noop,
submitWorkspaceRename: noop,
resetWorkspaceRename: noop,
removeSessionFromWorkspace: noop,
setWorkspaceFocusedSession: noop,
toggleWorkspaceViewMode: noop,
createLocalTerminal: noop,
createSerialSession: noop,
connectToHost: noop,
updateSessionStatus: noop,
updateSessionFontSize: noop,
clearSessionFontSizeOverride: noop,
createWorkspaceWithHosts: noop,
createWorkspaceFromSessions: noop,
addSessionToWorkspace: noop,
appendHostToWorkspace: noop,
appendLocalTerminalToWorkspace: noop,
createWorkspaceFromTargets: noop,
updateSplitSizes: noop,
splitSession: noop,
reorderWorkspaceSessions: noop,
moveFocusInWorkspace: noop,
runSnippet: noop,
getOrderedWorkTabs: noop,
reorderTabs: noop,
toggleBroadcast: noop,
isBroadcastEnabled: noop,
copySession: noop,
copyWorkspace: noop,
createSessionFromCloneSource: noop,
updateSessionRestoreCwd: noop,
getSessionRestoreCwd: noop,
updateSessionDynamicTitle: noop,
updateSessionCodingCliProvider: noop,
});
getSessionSnapshotActions()?.setActiveTabId('vault');
assert.deepEqual(calls, ['vault']);
registerSessionSnapshotActions(null);
assert.equal(getSessionSnapshotActions(), null);
});
+200
View File
@@ -0,0 +1,200 @@
import { useSyncExternalStore } from 'react';
import type { TerminalSession, Workspace } from '../../domain/models';
import type { LogView } from './logViewState';
import type { useSessionState } from './useSessionState';
type SessionState = ReturnType<typeof useSessionState>;
type Listener = () => void;
/**
* Session/workspace catalog for shell surfaces. `activeTabId` is intentionally
* absent — it lives in `activeTabStore` so tab switches stay off this path.
*/
export type SessionSnapshot = {
sessions: readonly TerminalSession[];
/** Sessions with no workspace and not hidden from tabs; TopTabs renders these. */
orphanSessions: readonly TerminalSession[];
workspaces: readonly Workspace[];
logViews: readonly LogView[];
draggingSessionId: string | null;
sessionRenameTarget: TerminalSession | null;
workspaceRenameTarget: Workspace | null;
};
/**
* Session mutators the shell needs. Derived from the hook return so the store
* contract cannot drift from `useSessionState`.
*/
export type SessionSnapshotActions = Pick<
SessionState,
| 'setActiveTabId'
| 'closeSession'
| 'closeSessions'
| 'closeWorkspace'
| 'openLogView'
| 'closeLogView'
| 'setDraggingSessionId'
| 'startSessionRename'
| 'renameSessionInline'
| 'submitSessionRename'
| 'resetSessionRename'
| 'startWorkspaceRename'
| 'submitWorkspaceRename'
| 'resetWorkspaceRename'
| 'removeSessionFromWorkspace'
| 'setWorkspaceFocusedSession'
| 'toggleWorkspaceViewMode'
// Terminal / chrome mutators previously read only via App session runtime.
| 'createLocalTerminal'
| 'createSerialSession'
| 'connectToHost'
| 'updateSessionStatus'
| 'updateSessionFontSize'
| 'clearSessionFontSizeOverride'
| 'createWorkspaceWithHosts'
| 'createWorkspaceFromSessions'
| 'addSessionToWorkspace'
| 'appendHostToWorkspace'
| 'appendLocalTerminalToWorkspace'
| 'createWorkspaceFromTargets'
| 'updateSplitSizes'
| 'splitSession'
| 'reorderWorkspaceSessions'
| 'moveFocusInWorkspace'
| 'runSnippet'
| 'getOrderedWorkTabs'
| 'reorderTabs'
| 'toggleBroadcast'
| 'isBroadcastEnabled'
| 'copySession'
| 'copyWorkspace'
| 'createSessionFromCloneSource'
| 'updateSessionRestoreCwd'
| 'getSessionRestoreCwd'
| 'updateSessionDynamicTitle'
| 'updateSessionCodingCliProvider'
>;
const EMPTY_SESSIONS: readonly TerminalSession[] = Object.freeze([]);
const EMPTY_WORKSPACES: readonly Workspace[] = Object.freeze([]);
const EMPTY_LOG_VIEWS: readonly LogView[] = Object.freeze([]);
export const EMPTY_SESSION_SNAPSHOT: SessionSnapshot = Object.freeze({
sessions: EMPTY_SESSIONS,
orphanSessions: EMPTY_SESSIONS,
workspaces: EMPTY_WORKSPACES,
logViews: EMPTY_LOG_VIEWS,
draggingSessionId: null,
sessionRenameTarget: null,
workspaceRenameTarget: null,
});
const SNAPSHOT_KEYS = Object.keys(EMPTY_SESSION_SNAPSHOT) as (keyof SessionSnapshot)[];
export function sessionSnapshotsEqual(
prev: SessionSnapshot,
next: SessionSnapshot,
): boolean {
return SNAPSHOT_KEYS.every((key) => prev[key] === next[key]);
}
/**
* External store for the session catalog so TopTabs, the terminal layer and
* the host tree can subscribe directly instead of receiving sessions through
* the App domain bags.
*/
class SessionSnapshotStore {
private snapshot: SessionSnapshot = EMPTY_SESSION_SNAPSHOT;
private actions: SessionSnapshotActions | null = null;
private listeners = new Set<Listener>();
private actionListeners = new Set<Listener>();
getSnapshot = (): SessionSnapshot => this.snapshot;
subscribe = (listener: Listener): (() => void) => {
this.listeners.add(listener);
return () => {
this.listeners.delete(listener);
};
};
setSnapshot(next: SessionSnapshot): void {
if (sessionSnapshotsEqual(this.snapshot, next)) return;
this.snapshot = next;
for (const listener of this.listeners) {
listener();
}
}
getActions = (): SessionSnapshotActions | null => this.actions;
subscribeActions = (listener: Listener): (() => void) => {
this.actionListeners.add(listener);
return () => {
this.actionListeners.delete(listener);
};
};
setActions(next: SessionSnapshotActions | null): void {
if (this.actions === next) return;
this.actions = next;
for (const listener of this.actionListeners) {
listener();
}
}
}
export const sessionSnapshotStore = new SessionSnapshotStore();
export function publishSessionSnapshot(snapshot: SessionSnapshot): void {
sessionSnapshotStore.setSnapshot(snapshot);
}
export function getSessionSnapshot(): SessionSnapshot {
return sessionSnapshotStore.getSnapshot();
}
export function subscribeSessionSnapshot(listener: Listener): () => void {
return sessionSnapshotStore.subscribe(listener);
}
export function registerSessionSnapshotActions(
actions: SessionSnapshotActions | null,
): void {
sessionSnapshotStore.setActions(actions);
}
export function getSessionSnapshotActions(): SessionSnapshotActions | null {
return sessionSnapshotStore.getActions();
}
export function subscribeSessionSnapshotActions(listener: Listener): () => void {
return sessionSnapshotStore.subscribeActions(listener);
}
/** Subscribe to the whole session catalog. Prefer a narrower selector hook. */
export function useSessionSnapshot(): SessionSnapshot {
return useSyncExternalStore(
subscribeSessionSnapshot,
getSessionSnapshot,
getSessionSnapshot,
);
}
/** Subscribe to one session field so unrelated session churn stays out. */
export function useSessionSnapshotField<K extends keyof SessionSnapshot>(
field: K,
): SessionSnapshot[K] {
const read = () => getSessionSnapshot()[field];
return useSyncExternalStore(subscribeSessionSnapshot, read, read);
}
export function useSessionSnapshotActions(): SessionSnapshotActions | null {
return useSyncExternalStore(
subscribeSessionSnapshotActions,
getSessionSnapshotActions,
getSessionSnapshotActions,
);
}
@@ -147,6 +147,7 @@ test("closing one split pane keeps the other terminal as an orphan tab", () => {
workspaceId: "ws-1",
layoutResult,
remainingSessions: nextSessions,
preferredTabId: "s-outside",
}),
"s2",
);
@@ -197,6 +198,81 @@ test("closing the last visible solo tab does not fall back to a hidden MCP sessi
);
});
test("closing a temporary solo tab restores the previously focused tab", () => {
const standalone = (id: string, status: TerminalSession["status"] = "connected"): TerminalSession => ({
...session(id),
workspaceId: undefined,
status,
});
const remainingSessions = [
standalone("s1", "disconnected"),
standalone("s2"),
standalone("s3"),
standalone("s4"),
standalone("s5", "disconnected"),
standalone("s6"),
];
const layoutResult = closeSessionWorkspaceLayoutState([], undefined, "s7");
assert.equal(
resolveActiveTabAfterCloseSession({
currentActiveTabId: "s7",
closedSessionId: "s7",
workspaceId: undefined,
layoutResult,
remainingSessions,
preferredTabId: "s5",
}),
"s5",
);
});
test("closing a temporary solo tab ignores a preferred tab that no longer exists", () => {
const standalone = (id: string): TerminalSession => ({
...session(id),
workspaceId: undefined,
});
const remainingSessions = [standalone("s1"), standalone("s2"), standalone("s6")];
const layoutResult = closeSessionWorkspaceLayoutState([], undefined, "s7");
assert.equal(
resolveActiveTabAfterCloseSession({
currentActiveTabId: "s7",
closedSessionId: "s7",
workspaceId: undefined,
layoutResult,
remainingSessions,
preferredTabId: "s5",
}),
"s6",
);
});
test("closeSessionsState restores the previous tab instead of the newest remaining tab", () => {
const standaloneSession = (id: string): TerminalSession => ({
...session(id),
workspaceId: undefined,
});
const result = closeSessionsState({
sessions: [
standaloneSession("s1"),
standaloneSession("s2"),
standaloneSession("s5"),
standaloneSession("s6"),
standaloneSession("s7"),
],
workspaces: [],
sessionIds: ["s7"],
currentActiveTabId: "s7",
preferredTabId: "s5",
tabOrder: ["s1", "s2", "s5", "s6", "s7"],
});
assert.equal(result.activeTabId, "s5");
assert.deepEqual(result.sessions.map((candidate) => candidate.id), ["s1", "s2", "s5", "s6"]);
});
test("closing several standalone sessions removes the whole batch", () => {
const standaloneSession = (id: string): TerminalSession => ({
...session(id),
@@ -129,22 +129,64 @@ export function applyCloseSessionToSessions(
));
}
export function isPreferredTabAvailableAfterClose(
input: {
preferredTabId: string | null | undefined;
closedSessionId: string;
layoutResult: CloseSessionWorkspaceLayoutResult;
remainingSessions: readonly TerminalSession[];
},
): input is {
preferredTabId: string;
closedSessionId: string;
layoutResult: CloseSessionWorkspaceLayoutResult;
remainingSessions: readonly TerminalSession[];
} {
const { preferredTabId, closedSessionId, layoutResult, remainingSessions } = input;
if (!preferredTabId || preferredTabId === closedSessionId) return false;
if (
preferredTabId === layoutResult.dissolvedWorkspaceId
|| preferredTabId === layoutResult.removedWorkspaceId
) {
return false;
}
if (preferredTabId === "vault" || preferredTabId === "sftp") return true;
if (layoutResult.workspaces.some((workspace) => workspace.id === preferredTabId)) return true;
return remainingSessions.some((session) => (
session.id === preferredTabId
&& !session.workspaceId
&& !session.hiddenFromTabs
));
}
export function resolveActiveTabAfterCloseSession({
currentActiveTabId,
closedSessionId,
workspaceId,
layoutResult,
remainingSessions,
preferredTabId,
}: {
currentActiveTabId: string | null;
closedSessionId: string;
workspaceId: string | undefined;
layoutResult: CloseSessionWorkspaceLayoutResult;
remainingSessions: readonly TerminalSession[];
preferredTabId?: string | null;
}): string | null {
const fallbackWorkspace = layoutResult.workspaces[layoutResult.workspaces.length - 1];
const fallbackSolo = remainingSessions.filter((session) => !session.workspaceId && !session.hiddenFromTabs).slice(-1)[0];
const preferredInput = {
preferredTabId,
closedSessionId,
layoutResult,
remainingSessions,
};
const preferredFallback = isPreferredTabAvailableAfterClose(preferredInput)
? preferredInput.preferredTabId
: undefined;
const fallback = layoutResult.lastRemainingSessionId
?? preferredFallback
?? fallbackWorkspace?.id
?? fallbackSolo?.id
?? "vault";
@@ -166,12 +208,14 @@ export function closeSessionsState({
workspaces,
sessionIds,
currentActiveTabId,
preferredTabId,
tabOrder,
}: {
sessions: readonly TerminalSession[];
workspaces: readonly Workspace[];
sessionIds: readonly string[];
currentActiveTabId: string | null;
preferredTabId?: string | null;
tabOrder: readonly string[];
}): CloseSessionsStateResult {
let nextSessions = [...sessions];
@@ -207,6 +251,7 @@ export function closeSessionsState({
workspaceId,
layoutResult,
remainingSessions: nextSessions,
preferredTabId,
});
if (activeTabAfterClose) {
nextActiveTabId = activeTabAfterClose;
@@ -0,0 +1,170 @@
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import test from 'node:test';
import {
DEFAULT_SETTINGS_CHROME_SNAPSHOT,
getSettingsChromeActions,
getSettingsChromeSnapshot,
publishSettingsChromeSnapshot,
registerSettingsChromeActions,
settingsChromeSnapshotsEqual,
subscribeSettingsChrome,
subscribeSettingsChromeActions,
type SettingsChromeSnapshot,
} from './settingsChromeStore.ts';
type SnapshotKey = keyof SettingsChromeSnapshot;
/**
* Derive the probe value instead of hard-coding a field list: the chrome slice
* grows as surfaces move off the App settings bag, and this suite is about the
* dedupe contract, not about which fields happen to be in it today.
*/
function mutate(value: unknown): unknown {
if (typeof value === 'boolean') return !value;
if (typeof value === 'number') return value + 1;
if (typeof value === 'string') return `${value}-changed`;
throw new Error(`Unsupported snapshot field type: ${typeof value}`);
}
test('settingsChromeStore ignores structurally identical republishes', () => {
const base = getSettingsChromeSnapshot();
let events = 0;
const unsubscribe = subscribeSettingsChrome(() => {
events += 1;
});
// useSettingsState rebuilds this object literal on every render, so a fresh
// identity with unchanged fields must not wake chrome consumers.
publishSettingsChromeSnapshot({ ...base });
publishSettingsChromeSnapshot({ ...base });
assert.equal(events, 0);
assert.equal(getSettingsChromeSnapshot(), base);
unsubscribe();
});
test('settingsChromeStore notifies on any single chrome field change', () => {
const base = getSettingsChromeSnapshot();
const keys = Object.keys(base) as SnapshotKey[];
assert.ok(keys.length > 0, 'chrome snapshot must publish at least one field');
for (const key of keys) {
let events = 0;
const unsubscribe = subscribeSettingsChrome(() => {
events += 1;
});
const changed = { ...base, [key]: mutate(base[key]) } as SettingsChromeSnapshot;
publishSettingsChromeSnapshot(changed);
assert.equal(events, 1, `expected a notification for ${key}`);
assert.equal(getSettingsChromeSnapshot()[key], changed[key]);
publishSettingsChromeSnapshot({ ...changed });
assert.equal(events, 1, `expected no repeat notification for ${key}`);
publishSettingsChromeSnapshot(base);
assert.equal(events, 2, `expected a restore notification for ${key}`);
unsubscribe();
}
assert.equal(getSettingsChromeSnapshot(), base);
});
test('settingsChromeStore stops notifying after unsubscribe', () => {
const base = getSettingsChromeSnapshot();
const [firstKey] = Object.keys(base) as SnapshotKey[];
let events = 0;
const unsubscribe = subscribeSettingsChrome(() => {
events += 1;
});
unsubscribe();
publishSettingsChromeSnapshot({
...base,
[firstKey]: mutate(base[firstKey]),
} as SettingsChromeSnapshot);
assert.equal(events, 0);
publishSettingsChromeSnapshot(base);
});
test('chrome snapshot fields stay in sync with what useSettingsState publishes', () => {
// The whole point of the slice is that chrome leaves can stop reading the
// App settings bag. A field the publisher forgets would silently freeze at
// its default, so pin the two shapes together.
const source = readFileSync(new URL('./useSettingsState.ts', import.meta.url), 'utf8');
const callStart = source.indexOf('publishSettingsChromeSnapshot({');
assert.notEqual(callStart, -1, 'useSettingsState must publish the chrome slice');
const callEnd = source.indexOf('});', callStart);
assert.notEqual(callEnd, -1);
const published = source
.slice(callStart, callEnd)
.split('\n')
.slice(1)
.map((line) => line.trim().match(/^([A-Za-z0-9_]+)\s*[:,]/)?.[1])
.filter((name): name is string => Boolean(name));
assert.deepEqual(
published.slice().sort(),
Object.keys(DEFAULT_SETTINGS_CHROME_SNAPSHOT).sort(),
);
});
test('settingsChromeSnapshotsEqual compares by field, not object identity', () => {
const base = DEFAULT_SETTINGS_CHROME_SNAPSHOT;
assert.equal(settingsChromeSnapshotsEqual({ ...base }, { ...base }), true);
assert.equal(
settingsChromeSnapshotsEqual(base, { ...base, windowOpacity: 0.8 }),
false,
);
assert.equal(
settingsChromeSnapshotsEqual(base, { ...base, showSftpTab: !base.showSftpTab }),
false,
);
});
test('registerSettingsChromeActions exposes the theme and opacity setters', () => {
const calls: string[] = [];
let actionEvents = 0;
const unsubscribe = subscribeSettingsChromeActions(() => {
actionEvents += 1;
});
registerSettingsChromeActions({
setTheme: () => calls.push('theme'),
setWindowOpacity: () => calls.push('opacity'),
});
const actions = getSettingsChromeActions();
assert.ok(actions);
actions.setTheme('light');
actions.setWindowOpacity(0.5);
assert.deepEqual(calls, ['theme', 'opacity']);
assert.equal(actionEvents, 1);
registerSettingsChromeActions(null);
assert.equal(getSettingsChromeActions(), null);
assert.equal(actionEvents, 2);
unsubscribe();
});
test('setter churn never invalidates the chrome value snapshot', () => {
// useSettingsState re-registers on every setter identity change; chrome
// consumers of the value snapshot must not re-render for that.
let valueEvents = 0;
const unsubscribe = subscribeSettingsChrome(() => {
valueEvents += 1;
});
registerSettingsChromeActions({ setTheme: () => {}, setWindowOpacity: () => {} });
registerSettingsChromeActions({ setTheme: () => {}, setWindowOpacity: () => {} });
assert.equal(valueEvents, 0);
registerSettingsChromeActions(null);
unsubscribe();
});
+181
View File
@@ -0,0 +1,181 @@
import { useSyncExternalStore } from 'react';
import type { UILanguage } from '../../domain/models/connection';
import type { DynamicTabTitleMode } from '../../domain/models/terminal';
import type { HostClickBehavior } from '../../domain/hostClickBehavior';
import type { TerminalSidePanelAutoOpenTab } from '../../domain/terminalSidePanelAutoOpen';
type Listener = () => void;
export type SettingsChromeTheme = 'dark' | 'light' | 'system';
/**
* Chrome-facing settings slice. Keep this aligned with what AppView / TopTabs /
* HostTree actually read. Unrelated settings edits must not rebuild App chrome
* domain bags — leaves subscribe here instead of receiving the whole settings
* object through domain props.
*/
export type SettingsChromeSnapshot = {
theme: SettingsChromeTheme;
resolvedTheme: 'light' | 'dark';
lightUiThemeId: string;
darkUiThemeId: string;
uiLanguage: UILanguage;
windowOpacity: number;
showSftpTab: boolean;
showHostTreeSidebar: boolean;
showRecentHosts: boolean;
hostClickBehavior: HostClickBehavior;
showOnlyUngroupedHostsInRoot: boolean;
dynamicTabTitleMode: DynamicTabTitleMode;
disableTerminalFontZoom: boolean;
restoreTerminalCwd: boolean;
terminalSidePanelAutoOpen: boolean;
terminalSidePanelAutoOpenTab: TerminalSidePanelAutoOpenTab;
};
/**
* Setters chrome leaves need. Kept on a separate slot from the value snapshot:
* `useSettingsState` rebuilds these callbacks on its own cadence, and that must
* never invalidate the value snapshot.
*/
export type SettingsChromeActions = {
setTheme: (theme: SettingsChromeTheme) => void;
setWindowOpacity: (opacity: number) => void;
};
export const DEFAULT_SETTINGS_CHROME_SNAPSHOT: SettingsChromeSnapshot = Object.freeze({
theme: 'system',
resolvedTheme: 'dark',
lightUiThemeId: 'default',
darkUiThemeId: 'default',
uiLanguage: 'en',
windowOpacity: 1,
showSftpTab: true,
showHostTreeSidebar: true,
showRecentHosts: true,
hostClickBehavior: 'connect',
showOnlyUngroupedHostsInRoot: false,
dynamicTabTitleMode: 'off',
disableTerminalFontZoom: false,
restoreTerminalCwd: true,
terminalSidePanelAutoOpen: false,
terminalSidePanelAutoOpenTab: 'ai',
} satisfies SettingsChromeSnapshot);
export function settingsChromeSnapshotsEqual(
a: SettingsChromeSnapshot,
b: SettingsChromeSnapshot,
): boolean {
return a.theme === b.theme
&& a.resolvedTheme === b.resolvedTheme
&& a.lightUiThemeId === b.lightUiThemeId
&& a.darkUiThemeId === b.darkUiThemeId
&& a.uiLanguage === b.uiLanguage
&& a.windowOpacity === b.windowOpacity
&& a.showSftpTab === b.showSftpTab
&& a.showHostTreeSidebar === b.showHostTreeSidebar
&& a.showRecentHosts === b.showRecentHosts
&& a.hostClickBehavior === b.hostClickBehavior
&& a.showOnlyUngroupedHostsInRoot === b.showOnlyUngroupedHostsInRoot
&& a.dynamicTabTitleMode === b.dynamicTabTitleMode
&& a.disableTerminalFontZoom === b.disableTerminalFontZoom
&& a.restoreTerminalCwd === b.restoreTerminalCwd
&& a.terminalSidePanelAutoOpen === b.terminalSidePanelAutoOpen
&& a.terminalSidePanelAutoOpenTab === b.terminalSidePanelAutoOpenTab;
}
class SettingsChromeStore {
private snapshot: SettingsChromeSnapshot = DEFAULT_SETTINGS_CHROME_SNAPSHOT;
private actions: SettingsChromeActions | null = null;
private listeners = new Set<Listener>();
private actionListeners = new Set<Listener>();
getSnapshot = (): SettingsChromeSnapshot => this.snapshot;
subscribe = (listener: Listener): (() => void) => {
this.listeners.add(listener);
return () => {
this.listeners.delete(listener);
};
};
setSnapshot(next: SettingsChromeSnapshot): void {
if (settingsChromeSnapshotsEqual(this.snapshot, next)) return;
this.snapshot = next;
for (const listener of this.listeners) {
listener();
}
}
getActions = (): SettingsChromeActions | null => this.actions;
subscribeActions = (listener: Listener): (() => void) => {
this.actionListeners.add(listener);
return () => {
this.actionListeners.delete(listener);
};
};
setActions(next: SettingsChromeActions | null): void {
if (this.actions === next) return;
this.actions = next;
for (const listener of this.actionListeners) {
listener();
}
}
}
export const settingsChromeStore = new SettingsChromeStore();
export function publishSettingsChromeSnapshot(
snapshot: SettingsChromeSnapshot,
): void {
settingsChromeStore.setSnapshot(snapshot);
}
export function getSettingsChromeSnapshot(): SettingsChromeSnapshot {
return settingsChromeStore.getSnapshot();
}
export function subscribeSettingsChrome(listener: Listener): () => void {
return settingsChromeStore.subscribe(listener);
}
export function registerSettingsChromeActions(
actions: SettingsChromeActions | null,
): void {
settingsChromeStore.setActions(actions);
}
export function getSettingsChromeActions(): SettingsChromeActions | null {
return settingsChromeStore.getActions();
}
export function subscribeSettingsChromeActions(listener: Listener): () => void {
return settingsChromeStore.subscribeActions(listener);
}
export function useSettingsChromeStore(): SettingsChromeSnapshot {
return useSyncExternalStore(
subscribeSettingsChrome,
getSettingsChromeSnapshot,
getSettingsChromeSnapshot,
);
}
const noopSetTheme: SettingsChromeActions['setTheme'] = () => {};
const noopSetWindowOpacity: SettingsChromeActions['setWindowOpacity'] = () => {};
/** Chrome setters, safe to call before `useSettingsState` has registered. */
export function useSettingsChromeActions(): SettingsChromeActions {
const actions = useSyncExternalStore(
subscribeSettingsChromeActions,
getSettingsChromeActions,
getSettingsChromeActions,
);
return {
setTheme: actions?.setTheme ?? noopSetTheme,
setWindowOpacity: actions?.setWindowOpacity ?? noopSetWindowOpacity,
};
}
+14 -4
View File
@@ -28,6 +28,7 @@ import {
STORAGE_KEY_SFTP_TRANSFER_CONCURRENCY,
STORAGE_KEY_SSH_TRANSPORT_IDLE_TTL_MS,
STORAGE_KEY_SFTP_USE_COMPRESSED_UPLOAD,
STORAGE_KEY_SFTP_SKIP_UNCHANGED,
STORAGE_KEY_SHOW_ONLY_UNGROUPED_HOSTS_IN_ROOT,
STORAGE_KEY_SHOW_RECENT_HOSTS,
STORAGE_KEY_HOST_CLICK_BEHAVIOR,
@@ -79,6 +80,7 @@ interface UseSettingsStorageSyncParams {
sftpAutoSync: boolean;
sftpShowHiddenFiles: boolean;
sftpUseCompressedUpload: boolean;
sftpSkipUnchanged: boolean;
sftpAutoOpenSidebar: boolean;
sftpFollowTerminalCwd: boolean;
sftpDefaultViewMode: 'list' | 'tree';
@@ -125,6 +127,7 @@ interface UseSettingsStorageSyncParams {
setSftpAutoSync: Dispatch<SetStateAction<boolean>>;
setSftpShowHiddenFiles: Dispatch<SetStateAction<boolean>>;
setSftpUseCompressedUpload: Dispatch<SetStateAction<boolean>>;
setSftpSkipUnchanged: Dispatch<SetStateAction<boolean>>;
setSftpAutoOpenSidebar: Dispatch<SetStateAction<boolean>>;
setSftpFollowTerminalCwd: Dispatch<SetStateAction<boolean>>;
setSftpDefaultViewMode: Dispatch<SetStateAction<'list' | 'tree'>>;
@@ -165,7 +168,7 @@ export function useSettingsStorageSync({
customCSS, uiFontFamilyId, hotkeyScheme, uiLanguage,
terminalThemeId, followAppTerminalTheme, terminalFontFamilyId, terminalFontSize,
sftpDoubleClickBehavior, sftpAutoSync, sftpShowHiddenFiles,
sftpUseCompressedUpload, sftpAutoOpenSidebar, sftpFollowTerminalCwd, sftpDefaultViewMode,
sftpUseCompressedUpload, sftpSkipUnchanged, sftpAutoOpenSidebar, sftpFollowTerminalCwd, sftpDefaultViewMode,
showRecentHosts, hostClickBehavior, showOnlyUngroupedHostsInRoot, showSftpTab, showHostTreeSidebar, terminalSidePanelAutoOpen, terminalSidePanelAutoOpenTab, shellOnlyTabNumberShortcuts, disableTerminalFontZoom, restorePreviousSession, restoreTerminalCwd,
editorWordWrap, sessionLogsEnabled, sessionLogsDir, sessionLogsFormat, sessionLogsTimestampsEnabled, sshDebugLogsEnabled, sshDeepLinkEnabled, jmsDeepLinkEnabled, explorerContextMenuEnabled,
globalHotkeyEnabled, autoUpdateEnabled, windowOpacity, appIconVariant,
@@ -174,7 +177,7 @@ export function useSettingsStorageSync({
setTerminalThemeId, setTerminalThemeDarkId, setTerminalThemeLightId,
setFollowAppTerminalThemeState, setTerminalFontFamilyId, setTerminalFontSize,
setSftpDoubleClickBehavior, setSftpAutoSync, setSftpShowHiddenFiles,
setSftpUseCompressedUpload, setSftpAutoOpenSidebar, setSftpFollowTerminalCwd, setSftpDefaultViewMode,
setSftpUseCompressedUpload, setSftpSkipUnchanged, setSftpAutoOpenSidebar, setSftpFollowTerminalCwd, setSftpDefaultViewMode,
setShowRecentHostsState, setHostClickBehaviorState, setShowOnlyUngroupedHostsInRootState, setShowSftpTabState, setShowHostTreeSidebarState, setTerminalSidePanelAutoOpenState, setTerminalSidePanelAutoOpenTabState, setShellOnlyTabNumberShortcutsState, setDisableTerminalFontZoomState, setRestorePreviousSessionState, setRestoreTerminalCwdState,
setEditorWordWrapState, setSessionLogsEnabled, setSessionLogsDir, setSessionLogsFormat, setSessionLogsTimestampsEnabled, setSshDebugLogsEnabled, setSshDeepLinkEnabledState, setJmsDeepLinkEnabledState, setExplorerContextMenuEnabledState,
setGlobalHotkeyEnabled, setWindowOpacity, setAppIconVariant, setAutoUpdateEnabled, setWorkspaceFocusStyleState,
@@ -189,7 +192,7 @@ export function useSettingsStorageSync({
customCSS, uiFontFamilyId, hotkeyScheme, uiLanguage,
terminalThemeId, followAppTerminalTheme, terminalFontFamilyId, terminalFontSize,
sftpDoubleClickBehavior, sftpAutoSync, sftpShowHiddenFiles,
sftpUseCompressedUpload, sftpAutoOpenSidebar, sftpFollowTerminalCwd, sftpDefaultViewMode,
sftpUseCompressedUpload, sftpSkipUnchanged, sftpAutoOpenSidebar, sftpFollowTerminalCwd, sftpDefaultViewMode,
showRecentHosts, hostClickBehavior, showOnlyUngroupedHostsInRoot, showSftpTab, showHostTreeSidebar, terminalSidePanelAutoOpen, terminalSidePanelAutoOpenTab, shellOnlyTabNumberShortcuts, disableTerminalFontZoom, restorePreviousSession, restoreTerminalCwd,
editorWordWrap, sessionLogsEnabled, sessionLogsDir, sessionLogsFormat, sessionLogsTimestampsEnabled, sshDebugLogsEnabled, sshDeepLinkEnabled, jmsDeepLinkEnabled, explorerContextMenuEnabled,
globalHotkeyEnabled, autoUpdateEnabled, windowOpacity, appIconVariant,
@@ -199,7 +202,7 @@ export function useSettingsStorageSync({
customCSS, uiFontFamilyId, hotkeyScheme, uiLanguage,
terminalThemeId, followAppTerminalTheme, terminalFontFamilyId, terminalFontSize,
sftpDoubleClickBehavior, sftpAutoSync, sftpShowHiddenFiles,
sftpUseCompressedUpload, sftpAutoOpenSidebar, sftpFollowTerminalCwd, sftpDefaultViewMode,
sftpUseCompressedUpload, sftpSkipUnchanged, sftpAutoOpenSidebar, sftpFollowTerminalCwd, sftpDefaultViewMode,
showRecentHosts, hostClickBehavior, showOnlyUngroupedHostsInRoot, showSftpTab, showHostTreeSidebar, terminalSidePanelAutoOpen, terminalSidePanelAutoOpenTab, shellOnlyTabNumberShortcuts, disableTerminalFontZoom, restorePreviousSession, restoreTerminalCwd,
editorWordWrap, sessionLogsEnabled, sessionLogsDir, sessionLogsFormat, sessionLogsTimestampsEnabled, sshDebugLogsEnabled, sshDeepLinkEnabled, jmsDeepLinkEnabled, explorerContextMenuEnabled,
globalHotkeyEnabled, autoUpdateEnabled, windowOpacity, appIconVariant,
@@ -379,6 +382,12 @@ export function useSettingsStorageSync({
setSftpUseCompressedUpload(newValue);
}
}
if (e.key === STORAGE_KEY_SFTP_SKIP_UNCHANGED && e.newValue !== null) {
const newValue = e.newValue === 'true';
if (newValue !== s.sftpSkipUnchanged) {
setSftpSkipUnchanged(newValue);
}
}
// Sync SFTP auto-open sidebar setting from other windows
if (e.key === STORAGE_KEY_SFTP_AUTO_OPEN_SIDEBAR && e.newValue !== null) {
const newValue = e.newValue === 'true';
@@ -542,6 +551,7 @@ export function useSettingsStorageSync({
setSftpTransferConcurrencyState,
setSshTransportIdleTtlMsState,
setSftpUseCompressedUpload,
setSftpSkipUnchanged,
setShowOnlyUngroupedHostsInRootState,
setShowHostTreeSidebarState,
setTerminalSidePanelAutoOpenState,
+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,
};
};
@@ -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,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,
};
};
@@ -0,0 +1,124 @@
/**
* SFTP Clipboard Store
*
* Manages clipboard state for SFTP file operations (copy/cut/paste)
* This is a simple store that holds the clipboard state and operation type.
*/
import { useSyncExternalStore } from "react";
type SftpClipboardOperation = "copy" | "cut";
export interface SftpClipboardFile {
name: string;
isDirectory: boolean;
}
interface SftpClipboardState {
files: SftpClipboardFile[];
sourcePath: string;
sourceConnectionId: string;
sourceSide: "left" | "right";
operation: SftpClipboardOperation;
}
type ClipboardListener = () => void;
let clipboardState: SftpClipboardState | null = null;
const clipboardListeners = new Set<ClipboardListener>();
const notifyListeners = () => {
clipboardListeners.forEach((listener) => listener());
};
export const sftpClipboardStore = {
getSnapshot: (): SftpClipboardState | null => clipboardState,
subscribe: (listener: ClipboardListener) => {
clipboardListeners.add(listener);
return () => clipboardListeners.delete(listener);
},
/**
* Copy files to clipboard
*/
copy: (
files: SftpClipboardFile[],
sourcePath: string,
sourceConnectionId: string,
sourceSide: "left" | "right"
) => {
clipboardState = {
files,
sourcePath,
sourceConnectionId,
sourceSide,
operation: "copy",
};
notifyListeners();
},
/**
* Cut files to clipboard
*/
cut: (
files: SftpClipboardFile[],
sourcePath: string,
sourceConnectionId: string,
sourceSide: "left" | "right"
) => {
clipboardState = {
files,
sourcePath,
sourceConnectionId,
sourceSide,
operation: "cut",
};
notifyListeners();
},
/**
* Clear clipboard (called after paste for cut operation)
*/
clear: () => {
clipboardState = null;
notifyListeners();
},
/**
* Update clipboard file list (used for partial cut transfers)
*/
updateFiles: (files: SftpClipboardFile[]) => {
if (!clipboardState) return;
if (files.length === 0) {
clipboardState = null;
} else {
clipboardState = {
...clipboardState,
files,
};
}
notifyListeners();
},
/**
* Check if there are files in the clipboard
*/
hasFiles: (): boolean => clipboardState !== null && clipboardState.files.length > 0,
/**
* Get the clipboard state
*/
get: (): SftpClipboardState | null => clipboardState,
};
/**
* React hook to subscribe to clipboard state changes
*/
export const useSftpClipboard = (): SftpClipboardState | null => {
return useSyncExternalStore(
sftpClipboardStore.subscribe,
sftpClipboardStore.getSnapshot,
sftpClipboardStore.getSnapshot
);
};
@@ -0,0 +1,124 @@
/**
* SFTP Dialog Action Store
*
* Manages dialog action triggers for SFTP operations.
* This store allows keyboard shortcuts to trigger dialogs in the appropriate pane.
*/
import { useSyncExternalStore, useEffect } from "react";
import { sftpFocusStore, SftpFocusedSide } from "./sftpFocusStore";
type SftpDialogActionType = "rename" | "delete" | "newFolder" | "newFile" | null;
interface SftpDialogAction {
type: SftpDialogActionType;
targetSide: SftpFocusedSide;
targetScopeId: string;
targetFiles?: string[]; // For rename (single file) or delete (multiple files)
timestamp: number; // To distinguish different triggers of the same action
}
type ActionListener = () => void;
let dialogAction: SftpDialogAction | null = null;
const actionListeners = new Set<ActionListener>();
const notifyListeners = () => {
actionListeners.forEach((listener) => listener());
};
export const sftpDialogActionStore = {
getSnapshot: (): SftpDialogAction | null => dialogAction,
subscribe: (listener: ActionListener) => {
actionListeners.add(listener);
return () => actionListeners.delete(listener);
},
/**
* Trigger a dialog action
*/
trigger: (type: SftpDialogActionType, targetScopeId: string, targetFiles?: string[]) => {
if (!type) {
dialogAction = null;
} else {
dialogAction = {
type,
targetSide: sftpFocusStore.getFocusedSide(),
targetScopeId,
targetFiles,
timestamp: Date.now(),
};
}
notifyListeners();
},
/**
* Clear the current action (called after a pane handles it)
*/
clear: () => {
dialogAction = null;
notifyListeners();
},
/**
* Get the current action
*/
get: (): SftpDialogAction | null => dialogAction,
};
/**
* React hook to subscribe to dialog action changes
*/
export const useSftpDialogAction = (): SftpDialogAction | null => {
return useSyncExternalStore(
sftpDialogActionStore.subscribe,
sftpDialogActionStore.getSnapshot,
sftpDialogActionStore.getSnapshot
);
};
/**
* React hook for a pane to respond to dialog actions
* Only the pane matching the targetSide will execute the callback
*/
export const useSftpDialogActionHandler = (
side: SftpFocusedSide,
scopeId: string,
handlers: {
onRename?: (fileName: string) => void;
onDelete?: (fileNames: string[]) => void;
onNewFolder?: () => void;
onNewFile?: () => void;
},
isActive = true
) => {
const action = useSftpDialogAction();
useEffect(() => {
if (!action || action.targetSide !== side || action.targetScopeId !== scopeId || !isActive) return;
// Handle the action and clear it
switch (action.type) {
case "rename":
if (handlers.onRename && action.targetFiles?.[0]) {
handlers.onRename(action.targetFiles[0]);
}
break;
case "delete":
if (handlers.onDelete && action.targetFiles) {
handlers.onDelete(action.targetFiles);
}
break;
case "newFolder":
handlers.onNewFolder?.();
break;
case "newFile":
handlers.onNewFile?.();
break;
}
// Clear the action after handling
sftpDialogActionStore.clear();
}, [action, side, scopeId, handlers, isActive]);
};
+54
View File
@@ -0,0 +1,54 @@
/**
* SFTP Focused Pane Store
*
* Tracks which SFTP pane (left or right) is currently focused.
* This is used to determine which pane should receive keyboard shortcut actions.
*/
import { useSyncExternalStore } from "react";
export type SftpFocusedSide = "left" | "right";
type FocusListener = () => void;
let focusedSide: SftpFocusedSide = "left";
const focusListeners = new Set<FocusListener>();
const notifyListeners = () => {
focusListeners.forEach((listener) => listener());
};
export const sftpFocusStore = {
getSnapshot: (): SftpFocusedSide => focusedSide,
subscribe: (listener: FocusListener) => {
focusListeners.add(listener);
return () => focusListeners.delete(listener);
},
/**
* Set the focused side
*/
setFocusedSide: (side: SftpFocusedSide) => {
if (focusedSide !== side) {
focusedSide = side;
notifyListeners();
}
},
/**
* Get the current focused side
*/
getFocusedSide: (): SftpFocusedSide => focusedSide,
};
/**
* React hook to subscribe to focused side changes
*/
export const useSftpFocusedSide = (): SftpFocusedSide => {
return useSyncExternalStore(
sftpFocusStore.subscribe,
sftpFocusStore.getSnapshot,
sftpFocusStore.getSnapshot
);
};
@@ -0,0 +1,70 @@
import { useCallback, useSyncExternalStore } from "react";
import { localStorageAdapter } from "../../../infrastructure/persistence/localStorageAdapter";
import { STORAGE_KEY_SFTP_HOST_VIEW_MODES } from "../../../infrastructure/config/storageKeys";
// ── Shared external store for per-host SFTP view mode preferences ──
type ViewMode = 'list' | 'tree';
type Listener = () => void;
const listeners = new Set<Listener>();
let snapshot: Record<string, ViewMode> =
localStorageAdapter.read<Record<string, ViewMode>>(STORAGE_KEY_SFTP_HOST_VIEW_MODES) ?? {};
function subscribe(listener: Listener) {
listeners.add(listener);
return () => { listeners.delete(listener); };
}
function getSnapshot() {
return snapshot;
}
function persist(next: Record<string, ViewMode>) {
snapshot = next;
localStorageAdapter.write(STORAGE_KEY_SFTP_HOST_VIEW_MODES, snapshot);
for (const l of listeners) l();
}
// Sync across windows/tabs via storage events
if (typeof window !== 'undefined') {
window.addEventListener('storage', (e) => {
if (e.key !== STORAGE_KEY_SFTP_HOST_VIEW_MODES) return;
try {
snapshot = e.newValue
? (JSON.parse(e.newValue) as Record<string, ViewMode>)
: {};
} catch {
snapshot = {};
}
for (const l of listeners) l();
});
}
/** Get the saved view mode for a specific host, or null if none saved. */
export function getHostViewMode(hostId: string): ViewMode | null {
return snapshot[hostId] ?? null;
}
/** Save the view mode preference for a specific host. */
export function setHostViewMode(hostId: string, mode: ViewMode): void {
if (snapshot[hostId] === mode) return;
persist({ ...snapshot, [hostId]: mode });
}
// ── Hook ──
export function useSftpHostViewMode(hostId: string | undefined) {
const store = useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
const mode: ViewMode | null = hostId ? (store[hostId] ?? null) : null;
const setMode = useCallback((newMode: ViewMode) => {
if (hostId) {
setHostViewMode(hostId, newMode);
}
}, [hostId]);
return { hostViewMode: mode, setHostViewMode: setMode };
}
@@ -0,0 +1,153 @@
import { useCallback, useSyncExternalStore } from "react";
export interface SftpTreeSelectionItem {
path: string;
name: string;
isDirectory: boolean;
sourcePath: string;
}
interface SftpTreeSelectionState {
visibleItems: SftpTreeSelectionItem[];
visibleItemsByPath: Map<string, SftpTreeSelectionItem>;
visibleIndexByPath: Map<string, number>;
visiblePathsSet: Set<string>;
selectedPaths: Set<string>;
}
const EMPTY_PATHS = new Set<string>();
const EMPTY_STATE: SftpTreeSelectionState = {
visibleItems: [],
visibleItemsByPath: new Map(),
visibleIndexByPath: new Map(),
visiblePathsSet: new Set(),
selectedPaths: EMPTY_PATHS,
};
type Listener = () => void;
const paneStates = new Map<string, SftpTreeSelectionState>();
const paneListeners = new Map<string, Set<Listener>>();
const notifyPaneListeners = (paneId: string) => {
paneListeners.get(paneId)?.forEach((listener) => listener());
};
const getPaneState = (paneId: string): SftpTreeSelectionState =>
paneStates.get(paneId) ?? EMPTY_STATE;
const setPaneState = (
paneId: string,
updater: (state: SftpTreeSelectionState) => SftpTreeSelectionState,
) => {
const prev = getPaneState(paneId);
const next = updater(prev);
if (next === prev) return;
if (next.visibleItems.length === 0 && next.selectedPaths.size === 0) {
paneStates.delete(paneId);
} else {
paneStates.set(paneId, next);
}
notifyPaneListeners(paneId);
};
export const sftpTreeSelectionStore = {
getPaneState,
getSelectedItems: (paneId: string): SftpTreeSelectionItem[] => {
const state = getPaneState(paneId);
const result: SftpTreeSelectionItem[] = [];
for (const path of state.selectedPaths) {
const item = state.visibleItemsByPath.get(path);
if (item) result.push(item);
}
return result;
},
setVisibleItems: (paneId: string, visibleItems: SftpTreeSelectionItem[]) => {
const visibleItemsByPath = new Map<string, SftpTreeSelectionItem>();
const visibleIndexByPath = new Map<string, number>();
const visiblePathsSet = new Set(visibleItems.map((item) => item.path));
visibleItems.forEach((item, index) => {
visibleItemsByPath.set(item.path, item);
visibleIndexByPath.set(item.path, index);
});
setPaneState(paneId, (state) => {
const newSelected = new Set([...state.selectedPaths].filter((p) => visiblePathsSet.has(p)));
const changed =
newSelected.size !== state.selectedPaths.size ||
[...newSelected].some((p) => !state.selectedPaths.has(p));
return {
visibleItems,
visibleItemsByPath,
visibleIndexByPath,
visiblePathsSet,
selectedPaths: changed ? newSelected : state.selectedPaths,
};
});
},
setSelection: (paneId: string, selectedPaths: Iterable<string>) => {
setPaneState(paneId, (state) => ({
...state,
selectedPaths: new Set(Array.from(selectedPaths).filter((path) => state.visiblePathsSet.has(path))),
}));
},
clearSelection: (paneId: string) => {
setPaneState(paneId, (state) => ({ ...state, selectedPaths: EMPTY_PATHS }));
},
clearAllExcept: (paneIdsToKeep?: Iterable<string>) => {
const keep = new Set(paneIdsToKeep ?? []);
Array.from(paneStates.keys()).forEach((paneId) => {
if (keep.has(paneId)) return;
setPaneState(paneId, (state) => {
if (state.selectedPaths.size === 0) return state;
return { ...state, selectedPaths: EMPTY_PATHS };
});
});
},
selectAllVisible: (paneId: string) => {
setPaneState(paneId, (state) => ({
...state,
selectedPaths: new Set(
state.visibleItems.map((item) => item.path),
),
}));
},
clearPane: (paneId: string) => {
if (!paneStates.has(paneId)) return;
paneStates.delete(paneId);
notifyPaneListeners(paneId);
},
subscribe: (paneId: string, listener: Listener) => {
const listeners = paneListeners.get(paneId) ?? new Set<Listener>();
listeners.add(listener);
paneListeners.set(paneId, listeners);
return () => {
const current = paneListeners.get(paneId);
if (!current) return;
current.delete(listener);
if (current.size === 0) {
paneListeners.delete(paneId);
}
};
},
};
export const useSftpTreeSelectionState = (paneId: string): SftpTreeSelectionState => {
const subscribe = useCallback(
(listener: () => void) => sftpTreeSelectionStore.subscribe(paneId, listener),
[paneId],
);
return useSyncExternalStore(
subscribe,
() => sftpTreeSelectionStore.getPaneState(paneId),
() => sftpTreeSelectionStore.getPaneState(paneId),
);
};
@@ -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 () => {
+230 -25
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,
@@ -449,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
@@ -457,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[] = [];
@@ -500,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 !== ".");
@@ -536,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 = {
@@ -571,7 +671,7 @@ export function useSftpDirectoryTransferOps({
};
const isSymlink = dir.type === "symlink";
const subdirErrors = await transferDirectory(
totalErrors += await transferDirectory(
childTask,
sourceSftpId,
targetSftpId,
@@ -586,7 +686,6 @@ export function useSftpDirectoryTransferOps({
progress,
traversal,
);
totalErrors += subdirErrors;
}
} finally {
// Release on success, cancellation, and traversal errors.
@@ -597,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).
@@ -616,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,
@@ -642,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
@@ -661,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
@@ -773,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);
});
@@ -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,
});
},
});
+2 -9
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";
@@ -206,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,
@@ -15,11 +15,63 @@ import {
UploadResult,
startUploadScanningTask,
} from "../../../lib/uploadService";
import { extractDropEntries, type DropEntry } from "../../../lib/sftpFileUtils";
import { uploadLocalFoldersProgressively } from "../../../lib/progressiveFolderUpload";
import {
captureDropPayload,
formatDropScanLabel,
isDropScanCancelledError,
localTreeToDropEntries,
materializeDropEntries,
type DropEntry,
type LocalTreeListEntry,
} from "../../../lib/sftpFileUtils";
function isUploadScanCancelled(controller: UploadController, error?: unknown): boolean {
return controller.isCancelled() || isDropScanCancelledError(error);
}
// Re-export UploadResult for external usage
export type { UploadResult };
type LocalTreeScanOptions = {
onProgress?: (progress: { fileCount: number; directoryCount: number; entryCount: number }) => void;
onEntries?: (entries: LocalTreeListEntry[]) => void;
abortSignal?: AbortSignal;
};
function createDropScanCancelledError(): Error {
const error = new Error("Drop scan cancelled");
(error as Error & { code?: string }).code = "ERR_DROP_SCAN_CANCELLED";
return error;
}
async function listLocalTreeWithAbort(
bridge: NetcattyBridge,
localPath: string,
options: LocalTreeScanOptions = {},
): Promise<LocalTreeListEntry[]> {
const scanId = `drop-scan-${crypto.randomUUID()}`;
const abortSignal = options.abortSignal;
const requestCancel = () => {
void bridge.cancelLocalTreeScan?.(scanId);
};
if (abortSignal?.aborted) {
throw createDropScanCancelledError();
}
abortSignal?.addEventListener("abort", requestCancel, { once: true });
const { abortSignal: _abortSignal, ...bridgeOptions } = options;
try {
return await bridge.listLocalTree!(localPath, { ...bridgeOptions, scanId });
} catch (error) {
if (abortSignal?.aborted || isDropScanCancelledError(error)) {
throw createDropScanCancelledError();
}
throw error;
} finally {
abortSignal?.removeEventListener("abort", requestCancel);
}
}
import type { UseSftpExternalOperationsParams, SftpExternalOperationsResult } from "./useSftpExternalOperations.types";
import { getSftpTransferResourceKeys, globalSftpTransferScheduler } from "./globalTransferScheduler";
import { localStorageAdapter } from "../../../infrastructure/persistence/localStorageAdapter";
@@ -48,6 +100,10 @@ import {
registerExternalUploadController,
unregisterExternalUploadController,
} from "./externalUploadRuntime";
import {
isTransferPauseLatched,
waitWhileTransferOrRootPaused,
} from "./transferPauseLatch";
type UploadConflictResolver = {
resolve: (action: FileConflictAction) => void;
@@ -151,9 +207,12 @@ export const useSftpExternalOperations = (
callbacks: UploadCallbacks,
): UploadCallbacks => ({
...callbacks,
onScanningStart: (taskId) => {
onScanningStart: (taskId, info) => {
registerUploadController(taskId, controller);
callbacks.onScanningStart?.(taskId);
callbacks.onScanningStart?.(taskId, info);
},
onScanningProgress: (taskId, progress) => {
callbacks.onScanningProgress?.(taskId, progress);
},
onTaskCreated: (task) => {
registerUploadController(task.id, controller);
@@ -764,9 +823,12 @@ export const useSftpExternalOperations = (
conflictDefaults.set(conflictType, action);
},
});
controller.addCancelListener(() => {
cancelPendingUploadConflicts(controller);
});
});
};
}, []);
}, [cancelPendingUploadConflicts]);
// Create upload bridge that wraps netcattyBridge.
// Pass connect-time Host so pooled stream uploads open the pinned endpoint
@@ -888,52 +950,271 @@ export const useSftpExternalOperations = (
const uploadExternalFiles = useCallback(
async (side: "left" | "right", dataTransfer: DataTransfer, targetPath?: string): Promise<UploadResult[]> => {
// DataTransfer is only valid during the drop event. Capture entries BEFORE
// DataTransfer is only valid during the drop event. Capture roots BEFORE
// any await (session reconnect can take seconds while a transfer is busy).
// Otherwise extractDropEntries returns [] and the UI toasts "Uploaded files: 0".
let capturedEntries: DropEntry[];
try {
capturedEntries = await extractDropEntries(dataTransfer);
} catch (error) {
logger.error("[SFTP] Failed to read dropped files:", error);
throw error;
}
if (capturedEntries.length === 0) {
// Native tree expansion (listLocalTree) happens after the scanning UI is up.
const dropPayload = captureDropPayload(dataTransfer);
if (dropPayload.roots.length === 0 && dropPayload.filesFallback.length === 0) {
return [];
}
const pane = getActivePane(side);
if (!pane?.connection) {
throw new Error("No active connection");
}
const bridge = netcattyBridge.get();
if (!bridge) {
throw new Error("Bridge not available");
}
const uploadTargetPath = targetPath || pane.connection.currentPath;
const controller = new UploadController();
const callbacks = bindUploadControllerCallbacks(
controller,
createUploadCallbacks(
pane.connection.id,
uploadTargetPath,
pane.connection.isLocal ? undefined : pane.connection.hostId,
pane.connection.isLocal ? undefined : connectionCacheKeyMapRef.current.get(pane.connection.id),
pane.connection.isLocal ? undefined : pane.connection.hostLabel,
),
);
const pathBackedFolderRoots = dropPayload.roots
.filter((root) => root.isDirectory && !!root.localPath)
.map((root) => ({ name: root.name, localPath: root.localPath! }));
// Progressive only when *every* root is a path-backed folder. Mixed drops
// (folder + files, path-less dirs) must fall through to materialize so
// sibling items are not silently dropped.
const canProgressiveUpload = pathBackedFolderRoots.length > 0
&& pathBackedFolderRoots.length === dropPayload.roots.length
&& !!bridge.listLocalTree
&& !useCompressedUpload;
const needsDeepScan = dropPayload.roots.some((root) => root.isDirectory);
const scanLabel = formatDropScanLabel(dropPayload.roots);
// Instant feedback: scanning / parent row appears before work begins.
const scanningTask = needsDeepScan
? startUploadScanningTask(callbacks, crypto.randomUUID(), { label: scanLabel })
: null;
if (scanningTask && typeof window !== "undefined") {
window.dispatchEvent(new CustomEvent("netcatty:open-sftp-transfer-center"));
}
const scanAbort = new AbortController();
const detachScanCancel = controller.addCancelListener(() => {
scanAbort.abort();
if (scanningTask?.isOpen()) scanningTask.cancel();
});
// Path-backed folders (no compressed upload): edge-scan + edge-transfer.
// Open the SFTP session first, then stream discovery batches into upload
// workers so the first files start while the rest of the tree is still walking.
if (canProgressiveUpload) {
const runProgressive = async (forceReconnect = false): Promise<UploadResult[]> => {
const { sftpId, release } = await resolveRemoteSftpId(side, { forceReconnect });
const livePane = getActivePane(side) ?? pane;
if (!livePane.connection) {
release();
throw new Error("No active connection");
}
const uploadPaneId = livePane.id;
const liveTargetPath = targetPath || livePane.connection.currentPath;
const connectHost = resolveUploadConnectHost(uploadPaneId, livePane.connection.isLocal);
const uploadBridge = createUploadBridge(connectHost);
const liveCallbacks = livePane.connection.id === pane.connection.id
&& liveTargetPath === uploadTargetPath
? callbacks
: bindUploadControllerCallbacks(
controller,
createUploadCallbacks(
livePane.connection.id,
liveTargetPath,
livePane.connection.isLocal ? undefined : livePane.connection.hostId,
livePane.connection.isLocal ? undefined : connectionCacheKeyMapRef.current.get(livePane.connection.id),
livePane.connection.isLocal ? undefined : livePane.connection.hostLabel,
),
);
const parentTaskIds = new Map<string, string>();
if (scanningTask && pathBackedFolderRoots.length === 1) {
parentTaskIds.set(pathBackedFolderRoots[0].name, scanningTask.taskId);
// Keep the scanning row as the folder parent (do not dismiss it).
scanningTask.complete = () => {
/* no-op: progressive path settles via onTaskCompleted/Cancelled */
};
} else if (scanningTask?.isOpen()) {
scanningTask.complete();
}
try {
if (controller.isCancelled()) {
scanningTask?.cancel();
return [{ fileName: "", success: false, cancelled: true }];
}
const results = await uploadLocalFoldersProgressively(
pathBackedFolderRoots,
{
targetPath: liveTargetPath,
sftpId,
targetHostId: livePane.connection.isLocal ? undefined : livePane.connection.hostId,
isLocal: livePane.connection.isLocal,
bridge: uploadBridge,
joinPath,
callbacks: liveCallbacks,
parentTaskIds,
abortSignal: scanAbort.signal,
// Soft-pause must freeze discovery enqueue + child UI rows, not
// only the open streams (otherwise Pause still floods the queue).
waitWhilePaused: (parentTaskId) => waitWhileTransferOrRootPaused(parentTaskId),
isPaused: (parentTaskId) => isTransferPauseLatched(parentTaskId),
resolveConflict: createUploadConflictResolver(controller),
listLocalTree: (localPath, treeOptions) => listLocalTreeWithAbort(bridge, localPath, {
...treeOptions,
abortSignal: scanAbort.signal,
}),
},
controller,
);
if (clearDirCacheEntry && targetPath) {
clearDirCacheEntry(livePane.connection.id, liveTargetPath);
}
if (liveTargetPath === livePane.connection.currentPath) {
await refresh(side, { tabId: uploadPaneId });
}
return results;
} finally {
release();
}
};
try {
return await runProgressive(false);
} catch (error) {
if (isSessionError(error) && ensureRemoteSftpId) {
logger.warn("[SFTP] Progressive upload session lost; reconnecting once", error);
try {
return await runProgressive(true);
} catch (retryError) {
if (isUploadScanCancelled(controller, retryError)) {
scanningTask?.cancel();
return [{ fileName: "", success: false, cancelled: true }];
}
if (scanningTask?.isOpen()) scanningTask.fail(retryError);
throw retryError;
}
}
if (isUploadScanCancelled(controller, error)) {
scanningTask?.cancel();
return [{ fileName: "", success: false, cancelled: true }];
}
if (scanningTask?.isOpen()) scanningTask.fail(error);
logger.error("[SFTP] Progressive folder upload failed:", error);
throw error;
} finally {
detachScanCancel();
unregisterUploadController(controller);
}
}
let capturedEntries: DropEntry[] = [];
try {
const scanT0 = performance.now();
capturedEntries = await materializeDropEntries(dropPayload, {
abortSignal: scanAbort.signal,
isCancelled: () => controller.isCancelled(),
listLocalTree: bridge.listLocalTree
? (localPath, treeOptions) => listLocalTreeWithAbort(bridge, localPath, {
...treeOptions,
abortSignal: scanAbort.signal,
})
: undefined,
onProgress: scanningTask
? (progress) => {
callbacks.onScanningProgress?.(scanningTask.taskId, {
...progress,
label: progress.label || scanLabel,
});
}
: undefined,
});
logger.debug(
`[SFTP:perf] materializeDropEntries — ${capturedEntries.length} entries — ${(performance.now() - scanT0).toFixed(0)}ms`,
);
} catch (error) {
if (scanningTask?.isOpen()) {
if (isUploadScanCancelled(controller, error)) {
scanningTask.cancel();
} else {
scanningTask.fail(error);
}
}
detachScanCancel();
unregisterUploadController(controller);
if (isUploadScanCancelled(controller, error)) {
return [{ fileName: "", success: false, cancelled: true }];
}
logger.error("[SFTP] Failed to read dropped files:", error);
throw error;
}
if (controller.isCancelled()) {
scanningTask?.cancel();
detachScanCancel();
unregisterUploadController(controller);
return [{ fileName: "", success: false, cancelled: true }];
}
if (capturedEntries.length === 0) {
scanningTask?.complete();
detachScanCancel();
unregisterUploadController(controller);
return [];
}
// Keep scanning visible through session open so the UI never goes blank
// between "N files found" and the real transfer rows.
const run = async (forceReconnect = false): Promise<UploadResult[]> => {
const pane = getActivePane(side);
if (!pane?.connection) {
const { sftpId, release } = await resolveRemoteSftpId(side, { forceReconnect });
const livePane = getActivePane(side) ?? pane;
if (!livePane.connection) {
release();
throw new Error("No active connection");
}
const bridge = netcattyBridge.get();
if (!bridge) {
throw new Error("Bridge not available");
}
const { sftpId, release } = await resolveRemoteSftpId(side, { forceReconnect });
const livePane = getActivePane(side) ?? pane;
if (!livePane.connection) throw new Error("No active connection");
const uploadPaneId = livePane.id;
const uploadTargetPath = targetPath || livePane.connection.currentPath;
const controller = new UploadController();
const callbacks = bindUploadControllerCallbacks(
controller,
createUploadCallbacks(
livePane.connection.id,
uploadTargetPath,
livePane.connection.isLocal ? undefined : livePane.connection.hostId,
livePane.connection.isLocal ? undefined : connectionCacheKeyMapRef.current.get(livePane.connection.id),
livePane.connection.isLocal ? undefined : livePane.connection.hostLabel,
),
);
const liveTargetPath = targetPath || livePane.connection.currentPath;
const connectHost = resolveUploadConnectHost(uploadPaneId, livePane.connection.isLocal);
const uploadBridge = createUploadBridge(connectHost);
// Retarget callbacks if the pane path/id changed after reconnect.
const liveCallbacks = livePane.connection.id === pane.connection.id
&& liveTargetPath === uploadTargetPath
? callbacks
: bindUploadControllerCallbacks(
controller,
createUploadCallbacks(
livePane.connection.id,
liveTargetPath,
livePane.connection.isLocal ? undefined : livePane.connection.hostId,
livePane.connection.isLocal ? undefined : connectionCacheKeyMapRef.current.get(livePane.connection.id),
livePane.connection.isLocal ? undefined : livePane.connection.hostLabel,
),
);
const transferCallbacks = scanningTask
? {
...liveCallbacks,
onTaskCreated: (task: Parameters<NonNullable<UploadCallbacks["onTaskCreated"]>>[0]) => {
scanningTask.complete();
liveCallbacks.onTaskCreated?.(task);
},
}
: liveCallbacks;
let transferSucceeded = false;
try {
if (controller.isCancelled()) {
scanningTask?.cancel();
return [{ fileName: "", success: false, cancelled: true }];
}
const hasDirectory = capturedEntries.some((entry) => (
entry.isDirectory || entry.relativePath.replace(/\\/g, "/").includes("/")
));
@@ -951,13 +1232,13 @@ export const useSftpExternalOperations = (
run: async (uploadSftpId) => uploadEntriesDirect(
capturedEntries,
{
targetPath: uploadTargetPath,
targetPath: liveTargetPath,
sftpId: uploadSftpId,
targetHostId: livePane.connection!.isLocal ? undefined : livePane.connection!.hostId,
isLocal: livePane.connection!.isLocal,
bridge: uploadBridge,
joinPath,
callbacks,
callbacks: transferCallbacks,
useCompressedUpload,
resolveConflict: createUploadConflictResolver(controller),
},
@@ -966,15 +1247,19 @@ export const useSftpExternalOperations = (
});
if (clearDirCacheEntry && targetPath) {
clearDirCacheEntry(livePane.connection.id, uploadTargetPath);
clearDirCacheEntry(livePane.connection.id, liveTargetPath);
}
if (uploadTargetPath === livePane.connection.currentPath) {
if (liveTargetPath === livePane.connection.currentPath) {
await refresh(side, { tabId: uploadPaneId });
}
transferSucceeded = true;
return results;
} finally {
if (scanningTask?.isOpen()) {
if (controller.isCancelled()) scanningTask.cancel();
else if (transferSucceeded) scanningTask.complete();
}
release();
unregisterUploadController(controller);
}
};
@@ -983,10 +1268,26 @@ export const useSftpExternalOperations = (
} catch (error) {
if (isSessionError(error) && ensureRemoteSftpId) {
logger.warn("[SFTP] Upload session lost; reconnecting and retrying once", error);
return await run(true);
try {
return await run(true);
} catch (retryError) {
if (isUploadScanCancelled(controller, retryError)) {
scanningTask?.cancel();
} else if (scanningTask?.isOpen()) {
scanningTask.fail(retryError);
}
throw retryError;
}
}
if (scanningTask?.isOpen()) {
if (controller.isCancelled()) scanningTask.cancel();
else scanningTask.fail(error);
}
logger.error("[SFTP] Upload failed:", error);
throw error;
} finally {
detachScanCancel();
unregisterUploadController(controller);
}
},
[
@@ -1146,105 +1447,125 @@ export const useSftpExternalOperations = (
connectionCacheKeyMapRef.current,
);
const initialUploadTargetPath = targetPath || originatingPane.connection.currentPath;
const controller = new UploadController();
const callbacks = bindUploadControllerCallbacks(
controller,
createUploadCallbacks(
originatingPane.connection.id,
initialUploadTargetPath,
originatingPane.connection.isLocal ? undefined : originatingPane.connection.hostId,
originatingPane.connection.isLocal
? undefined
: connectionCacheKeyMapRef.current.get(originatingPane.connection.id),
originatingPane.connection.isLocal ? undefined : originatingPane.connection.hostLabel,
),
);
const folderName = folderPath.replace(/\\/g, "/").split("/").filter(Boolean).pop()
|| folderPath;
const scanningTask = startUploadScanningTask(callbacks, crypto.randomUUID(), {
label: folderName,
});
if (typeof window !== "undefined") {
window.dispatchEvent(new CustomEvent("netcatty:open-sftp-transfer-center"));
}
const scanAbort = new AbortController();
const detachScanCancel = controller.addCancelListener(() => {
scanAbort.abort();
if (scanningTask.isOpen()) scanningTask.cancel();
});
let capturedEntries: DropEntry[] | null = null;
const run = async (forceReconnect = false): Promise<UploadResult[]> => {
const pane = resolveUploadTargetPane({
side,
tabId: originatingTabId,
getActivePane,
getPaneByTabId,
getPaneByConnectionId,
});
assertUploadEndpointUnchanged(
pane.connection,
originatingEndpoint,
connectionCacheKeyMapRef.current,
);
const bridge = netcattyBridge.get();
if (!bridge) throw new Error("Bridge not available");
if (!bridge.listLocalTree) throw new Error("Folder upload not supported");
const { sftpId, release } = await resolveRemoteSftpId(side, {
forceReconnect,
connectionId: pane.connection.id,
tabId: originatingTabId,
});
// Never re-resolve via getActivePane after awaits — focus may have moved.
const livePane = resolveUploadTargetPane({
side,
tabId: originatingTabId,
getActivePane,
getPaneByTabId,
getPaneByConnectionId,
});
assertUploadEndpointUnchanged(
livePane.connection,
originatingEndpoint,
connectionCacheKeyMapRef.current,
);
const uploadPaneId = livePane.id;
const uploadTargetPath = targetPath || livePane.connection.currentPath;
const controller = new UploadController();
const callbacks = bindUploadControllerCallbacks(
controller,
createUploadCallbacks(
livePane.connection.id,
uploadTargetPath,
livePane.connection.isLocal ? undefined : livePane.connection.hostId,
livePane.connection.isLocal ? undefined : connectionCacheKeyMapRef.current.get(livePane.connection.id),
livePane.connection.isLocal ? undefined : livePane.connection.hostLabel,
),
);
// Pin connect-time Host before listLocalTree: a slow folder scan can
// outlive a same-hostId tab rebind, and resolveUploadConnectHost would
// otherwise open the pooled stream bridge on the newly selected endpoint.
const uploadConnectHost = resolveUploadConnectHost(
uploadPaneId,
livePane.connection.isLocal,
);
const uploadBridge = createUploadBridge(uploadConnectHost);
const scanningTask = startUploadScanningTask(callbacks);
if (controller.isCancelled()) {
scanningTask.cancel();
return [{ fileName: "", success: false, cancelled: true }];
}
let release = () => {};
try {
const localEntries = await bridge.listLocalTree(folderPath);
const pane = resolveUploadTargetPane({
side,
tabId: originatingTabId,
getActivePane,
getPaneByTabId,
getPaneByConnectionId,
});
assertUploadEndpointUnchanged(
pane.connection,
originatingEndpoint,
connectionCacheKeyMapRef.current,
);
const bridge = netcattyBridge.get();
if (!bridge) throw new Error("Bridge not available");
if (!bridge.listLocalTree) throw new Error("Folder upload not supported");
const resolved = await resolveRemoteSftpId(side, {
forceReconnect,
connectionId: pane.connection.id,
tabId: originatingTabId,
});
const sftpId = resolved.sftpId;
release = resolved.release;
// Never re-resolve via getActivePane after awaits — focus may have moved.
const livePane = resolveUploadTargetPane({
side,
tabId: originatingTabId,
getActivePane,
getPaneByTabId,
getPaneByConnectionId,
});
assertUploadEndpointUnchanged(
livePane.connection,
originatingEndpoint,
connectionCacheKeyMapRef.current,
);
const uploadPaneId = livePane.id;
const uploadTargetPath = targetPath || livePane.connection.currentPath;
// Pin connect-time Host before listLocalTree: a slow folder scan can
// outlive a same-hostId tab rebind, and resolveUploadConnectHost would
// otherwise open the pooled stream bridge on the newly selected endpoint.
const uploadConnectHost = resolveUploadConnectHost(
uploadPaneId,
livePane.connection.isLocal,
);
const uploadBridge = createUploadBridge(uploadConnectHost);
const liveCallbacks = bindUploadControllerCallbacks(
controller,
createUploadCallbacks(
livePane.connection.id,
uploadTargetPath,
livePane.connection.isLocal ? undefined : livePane.connection.hostId,
livePane.connection.isLocal
? undefined
: connectionCacheKeyMapRef.current.get(livePane.connection.id),
livePane.connection.isLocal ? undefined : livePane.connection.hostLabel,
),
);
const transferCallbacks = {
...liveCallbacks,
onTaskCreated: (task: Parameters<NonNullable<UploadCallbacks["onTaskCreated"]>>[0]) => {
scanningTask.complete();
liveCallbacks.onTaskCreated?.(task);
},
};
if (capturedEntries === null) {
const localEntries = await listLocalTreeWithAbort(bridge, folderPath, {
abortSignal: scanAbort.signal,
onProgress: (progress) => {
callbacks.onScanningProgress?.(scanningTask.taskId, {
...progress,
label: folderName,
});
},
});
capturedEntries = localTreeToDropEntries(localEntries);
}
if (controller.isCancelled()) {
scanningTask.cancel();
return [{ fileName: "", success: false, cancelled: true }];
}
scanningTask.complete();
const entries: DropEntry[] = localEntries.map((entry) => {
if (entry.type === "directory") {
return {
file: null,
relativePath: entry.relativePath,
isDirectory: true,
};
}
const file = {
name: entry.relativePath.split("/").pop() || entry.relativePath,
size: entry.size,
lastModified: entry.lastModified,
type: "",
path: entry.localPath,
arrayBuffer: async () => {
const currentBridge = netcattyBridge.get();
if (!currentBridge?.readLocalFile) {
throw new Error("Local file reading not supported");
}
return currentBridge.readLocalFile(entry.localPath);
},
} as File & { path?: string };
return {
file,
relativePath: entry.relativePath,
isDirectory: false,
};
});
const results = await runWithCompressedUploadSession({
enabled: useCompressedUpload,
@@ -1258,7 +1579,7 @@ export const useSftpExternalOperations = (
: undefined,
shouldDiscard: isSessionError,
run: async (uploadSftpId) => uploadEntriesDirect(
entries,
capturedEntries,
{
targetPath: uploadTargetPath,
sftpId: uploadSftpId,
@@ -1266,7 +1587,7 @@ export const useSftpExternalOperations = (
isLocal: livePane.connection!.isLocal,
bridge: uploadBridge,
joinPath,
callbacks,
callbacks: transferCallbacks,
useCompressedUpload,
resolveConflict: createUploadConflictResolver(controller),
},
@@ -1282,18 +1603,8 @@ export const useSftpExternalOperations = (
await refresh(refreshSide, { tabId: uploadPaneId });
}
return results;
} catch (error) {
if (controller.isCancelled()) {
scanningTask.cancel();
return [{ fileName: "", success: false, cancelled: true }];
}
if (scanningTask.isOpen()) {
scanningTask.fail(error);
}
throw error;
} finally {
release();
unregisterUploadController(controller);
}
};
@@ -1302,11 +1613,33 @@ export const useSftpExternalOperations = (
} catch (error) {
if (isSessionError(error) && ensureRemoteSftpId) {
logger.warn("[SFTP] Folder upload session lost; reconnecting and retrying once", error);
return await run(true);
try {
return await run(true);
} catch (retryError) {
if (isUploadScanCancelled(controller, retryError)) {
scanningTask.cancel();
return [{ fileName: "", success: false, cancelled: true }];
}
if (scanningTask.isOpen()) scanningTask.fail(retryError);
throw retryError;
}
}
if (isUploadScanCancelled(controller, error)) {
scanningTask.cancel();
return [{ fileName: "", success: false, cancelled: true }];
}
if (scanningTask.isOpen()) scanningTask.fail(error);
logger.error("[SFTP] Folder picker upload failed:", error);
throw error;
}
finally {
detachScanCancel();
unregisterUploadController(controller);
if (scanningTask.isOpen()) {
if (controller.isCancelled()) scanningTask.cancel();
else scanningTask.complete();
}
}
},
[
acquireTransferSession,
@@ -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["']/);
});
@@ -0,0 +1,168 @@
import React, { useCallback, useEffect, useRef, useState } from "react";
import {
DEFAULT_SFTP_COLUMN_VISIBILITY,
normalizeSftpColumnVisibility,
type ColumnWidths,
type SftpColumnVisibility,
type SortField,
type SortOrder,
} 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 "./useSftpDirectoriesFirst";
export interface UseSftpPaneSortingResult {
sortField: SortField;
sortOrder: SortOrder;
directoriesFirst: boolean;
columnWidths: ColumnWidths;
visibleColumns: SftpColumnVisibility;
handleSort: (field: SortField) => void;
handleResizeStart: (field: keyof ColumnWidths, e: React.MouseEvent) => void;
toggleColumnVisibility: (field: keyof ColumnWidths) => void;
toggleDirectoriesFirst: () => void;
}
export const useSftpPaneSorting = (): UseSftpPaneSortingResult => {
const [sortField, setSortField] = useState<SortField>("name");
const [sortOrder, setSortOrder] = useState<SortOrder>("asc");
const { directoriesFirst, toggleDirectoriesFirst } = useSftpDirectoriesFirst();
const [columnWidths, setColumnWidths] = useState<ColumnWidths>({
name: 56,
modified: 28,
size: 7,
type: 9,
});
const [visibleColumns, setVisibleColumns] = useState<SftpColumnVisibility>(() =>
normalizeSftpColumnVisibility(
localStorageAdapter.read<Partial<SftpColumnVisibility>>(STORAGE_KEY_SFTP_VISIBLE_COLUMNS),
),
);
useEffect(() => {
const syncVisibleColumns = (event: Event) => {
if (event instanceof StorageEvent && event.key !== STORAGE_KEY_SFTP_VISIBLE_COLUMNS) return;
if (event instanceof CustomEvent && event.detail?.key !== STORAGE_KEY_SFTP_VISIBLE_COLUMNS) return;
const next = normalizeSftpColumnVisibility(
localStorageAdapter.read<Partial<SftpColumnVisibility>>(STORAGE_KEY_SFTP_VISIBLE_COLUMNS),
);
setVisibleColumns(next);
};
window.addEventListener("storage", syncVisibleColumns);
window.addEventListener(LOCAL_STORAGE_ADAPTER_CHANGED_EVENT, syncVisibleColumns);
return () => {
window.removeEventListener("storage", syncVisibleColumns);
window.removeEventListener(LOCAL_STORAGE_ADAPTER_CHANGED_EVENT, syncVisibleColumns);
};
}, []);
useEffect(() => {
if (visibleColumns[sortField]) return;
setSortField("name");
setSortOrder("asc");
}, [sortField, visibleColumns]);
const toggleColumnVisibility = useCallback((field: keyof ColumnWidths) => {
if (field === "name") return;
setVisibleColumns((current) => {
const next = {
...DEFAULT_SFTP_COLUMN_VISIBILITY,
...current,
name: true,
[field]: !current[field],
};
localStorageAdapter.write(STORAGE_KEY_SFTP_VISIBLE_COLUMNS, next);
return next;
});
}, []);
const resizingRef = useRef<{
field: keyof ColumnWidths;
startX: number;
startWidth: number;
} | null>(null);
const handleSort = useCallback((field: SortField) => {
if (sortField === field) {
setSortOrder((prev) => (prev === "asc" ? "desc" : "asc"));
} else {
setSortField(field);
setSortOrder("asc");
}
}, [sortField]);
const rafIdRef = useRef<number | null>(null);
const lastClientXRef = useRef(0);
const applyColumnWidth = useCallback(() => {
if (!resizingRef.current) return;
const { field, startX, startWidth } = resizingRef.current;
const diff = lastClientXRef.current - startX;
const limits: Record<keyof ColumnWidths, { min: number; max: number }> = {
name: { min: 36, max: 78 },
modified: { min: 18, max: 42 },
size: { min: 5, max: 16 },
type: { min: 6, max: 18 },
};
const { min, max } = limits[field];
const newWidth = Math.max(
min,
Math.min(max, startWidth + diff / 8),
);
setColumnWidths((prev) => ({
...prev,
[field]: newWidth,
}));
}, []);
const handleResizeMove = useCallback((e: MouseEvent) => {
if (!resizingRef.current) return;
lastClientXRef.current = e.clientX;
if (rafIdRef.current !== null) return;
rafIdRef.current = requestAnimationFrame(() => {
rafIdRef.current = null;
applyColumnWidth();
});
}, [applyColumnWidth]);
const handleResizeEnd = useCallback(() => {
if (rafIdRef.current !== null) cancelAnimationFrame(rafIdRef.current);
applyColumnWidth();
rafIdRef.current = null;
resizingRef.current = null;
document.removeEventListener("mousemove", handleResizeMove);
document.removeEventListener("mouseup", handleResizeEnd);
}, [applyColumnWidth, handleResizeMove]);
const handleResizeStart = (
field: keyof ColumnWidths,
e: React.MouseEvent,
) => {
e.preventDefault();
e.stopPropagation();
lastClientXRef.current = e.clientX;
resizingRef.current = {
field,
startX: e.clientX,
startWidth: columnWidths[field],
};
document.addEventListener("mousemove", handleResizeMove);
document.addEventListener("mouseup", handleResizeEnd);
};
return {
sortField,
sortOrder,
directoriesFirst,
columnWidths,
visibleColumns,
handleSort,
handleResizeStart,
toggleColumnVisibility,
toggleDirectoriesFirst,
};
};
@@ -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;
+67 -3
View File
@@ -60,6 +60,10 @@ import {
} from "./transferConflictLifecycle";
import { getParentPath, joinPath } from "./utils";
import { promoteDirectoryReplaceStage as promoteDirectoryReplacePaths } from "./directoryReplacePromotion";
import {
isExternalDragDropFileUpload,
retryExternalDragDropFileUpload,
} from "./externalDragDropRetry";
/** Keep the MutableRefObject mirror in sync with the process-global latch set. */
function syncPausedTasksRef(ref: { current: Set<string> }, taskId: string, latched: boolean) {
@@ -1294,8 +1298,62 @@ export const useSftpTransfers = ({
const retryTransfer = useCallback(
async (transferId: string) => {
const task = transfersRef.current.find((t) => t.id === transferId);
// Prefer the live owner list; fall back to the center store (drag-drop
// children are upserted there and mirrored via subscribe).
const task = transfersRef.current.find((t) => t.id === transferId)
?? sftpTransferCenterStore.getTask(transferId);
if (!task || task.retryable === false) return;
// Progressive / external drag-drop children never have dual-pane endpoints
// (sourceConnectionId is "external"). Re-run startStreamTransfer in place.
if (isExternalDragDropFileUpload(task)) {
if (inFlightTransferIdsRef.current.has(transferId)) return;
inFlightTransferIdsRef.current.add(transferId);
clearTransferCancelled(transferId);
cancelledTasksRef.current.delete(transferId);
try {
const result = await retryExternalDragDropFileUpload(task, {
getBrowseSftpId: (connectionId) => sftpSessionsRef.current.get(connectionId),
acquireTransferSession: acquireTransferSession
? (hostId, id) => acquireTransferSession(hostId, id)
: undefined,
startStreamTransfer: async (options) => {
const bridge = netcattyBridge.get();
if (!bridge?.startStreamTransfer) {
return { error: "Stream transfer is unavailable" };
}
return bridge.startStreamTransfer(options);
},
clearPendingCancel: (id) => netcattyBridge.get()?.clearPendingTransferCancel?.(id),
cleanupArtifacts: cleanupTaskArtifacts,
getTask: (id) => sftpTransferCenterStore.getTask(id)
?? transfersRef.current.find((row) => row.id === id),
getChildTasks: (parentId) => {
const fromStore = sftpTransferCenterStore.getOwnerTasks(ownerId)
.filter((row) => row.parentTaskId === parentId);
if (fromStore.length > 0) return fromStore;
return transfersRef.current.filter((row) => row.parentTaskId === parentId);
},
onPatch: (taskId, updates) => {
transferRuntime.patchTask(taskId, updates);
setTransfers((prev) => {
if (!prev.some((row) => row.id === taskId)) {
// Store-only child: mirror the patch by re-pulling owner rows.
return sftpTransferCenterStore.getOwnerTasks(ownerId);
}
return prev.map((row) => (row.id === taskId ? { ...row, ...updates } : row));
});
},
});
if (!result.success && result.error && !/cancelled/i.test(result.error)) {
notify.warning(result.error, "SFTP");
}
} finally {
inFlightTransferIdsRef.current.delete(transferId);
}
return;
}
await cleanupTaskArtifacts(task);
const retriedTask: TransferTask = {
@@ -1316,7 +1374,13 @@ export const useSftpTransfers = ({
};
const endpoints = resolveTaskEndpoints(task);
if (!endpoints) return;
if (!endpoints) {
notify.warning(
"Could not resolve transfer endpoints for retry. Reconnect the target and try again.",
"SFTP",
);
return;
}
const { targetSide, sourcePane, targetPane } = endpoints;
const completionHandler = completionHandlersRef.current.get(transferId);
@@ -1335,7 +1399,7 @@ export const useSftpTransfers = ({
await processTransfer(retriedTask, sourcePane, targetPane, targetSide);
},
// eslint-disable-next-line react-hooks/exhaustive-deps -- processTransfer is defined inline
[cleanupTaskArtifacts, resolveTaskEndpoints, setTransfers],
[acquireTransferSession, cleanupTaskArtifacts, ownerId, resolveTaskEndpoints, setTransfers, sftpSessionsRef],
);
const clearCompletedTransfers = useCallback(() => {
@@ -0,0 +1,269 @@
import { startTransition, useCallback, useEffect, useRef, useState } from 'react';
export interface AsyncRecordState<T> {
data: T | null;
loading: boolean;
error: string | null;
updatedAt: number | null;
}
type RecordMap<T> = Record<string, AsyncRecordState<T>>;
interface UseAsyncRecordCacheOptions<TItem, TValue> {
items: TItem[];
enabled: boolean;
getKey: (item: TItem) => string;
fetchRecord: (item: TItem) => Promise<TValue | null>;
prefetchLimit?: number;
prefetchDelayMs?: number;
staleTimeMs?: number;
}
function delay(ms: number): Promise<void> {
return new Promise((resolve) => window.setTimeout(resolve, ms));
}
function scheduleIdleTask(callback: () => void): () => void {
if (typeof window.requestIdleCallback === 'function') {
const id = window.requestIdleCallback(callback, { timeout: 1200 });
return () => window.cancelIdleCallback(id);
}
const id = window.setTimeout(callback, 80);
return () => window.clearTimeout(id);
}
function normalizeRecordError(error: unknown): string {
return error instanceof Error ? error.message : String(error || 'Unknown error');
}
function isRecordFresh<TValue>(record: AsyncRecordState<TValue> | undefined, staleTimeMs: number): boolean {
if (!record || record.error || record.updatedAt === null) return false;
if (!Number.isFinite(staleTimeMs)) return true;
return Date.now() - record.updatedAt < staleTimeMs;
}
const EMPTY_RECORDS = {};
export function useAsyncRecordCache<TItem, TValue>({
items,
enabled,
getKey,
fetchRecord,
prefetchLimit = 64,
prefetchDelayMs = 16,
staleTimeMs = 30_000,
}: UseAsyncRecordCacheOptions<TItem, TValue>) {
const [records, setRecords] = useState<RecordMap<TValue>>(() => EMPTY_RECORDS);
const recordsRef = useRef<RecordMap<TValue>>(records);
const enabledRef = useRef(enabled);
const inflightRef = useRef(new Set<string>());
const requestVersionRef = useRef(new Map<string, number>());
const queuedForceRef = useRef(new Set<string>());
const loadRecordRef = useRef<(
item: TItem,
options?: { force?: boolean; urgent?: boolean },
) => Promise<void>>(async () => {});
recordsRef.current = records;
enabledRef.current = enabled;
const commitRecords = useCallback((
updater: (prev: RecordMap<TValue>) => RecordMap<TValue>,
urgent = false,
) => {
const apply = () => {
setRecords((prev) => {
const next = updater(prev);
recordsRef.current = next;
return next;
});
};
if (urgent) {
apply();
return;
}
startTransition(apply);
}, []);
const loadRecord = useCallback(async (
item: TItem,
options?: { force?: boolean; urgent?: boolean },
) => {
if (!enabledRef.current) return;
const key = getKey(item);
if (!key) return;
if (inflightRef.current.has(key)) {
if (options?.force) {
queuedForceRef.current.add(key);
requestVersionRef.current.set(key, (requestVersionRef.current.get(key) ?? 0) + 1);
}
return;
}
const existing = recordsRef.current[key];
if (!options?.force && isRecordFresh(existing, staleTimeMs)) {
return;
}
const requestVersion = (requestVersionRef.current.get(key) ?? 0) + 1;
requestVersionRef.current.set(key, requestVersion);
inflightRef.current.add(key);
commitRecords((prev) => ({
...prev,
[key]: {
data: prev[key]?.data ?? null,
loading: true,
error: null,
updatedAt: prev[key]?.updatedAt ?? null,
},
}), options?.urgent);
try {
const data = await fetchRecord(item);
if (requestVersionRef.current.get(key) !== requestVersion) return;
commitRecords((prev) => ({
...prev,
[key]: {
data,
loading: false,
error: null,
updatedAt: Date.now(),
},
}));
} catch (error) {
if (requestVersionRef.current.get(key) !== requestVersion) return;
commitRecords((prev) => ({
...prev,
[key]: {
data: prev[key]?.data ?? null,
loading: false,
error: normalizeRecordError(error),
updatedAt: prev[key]?.updatedAt ?? null,
},
}));
} finally {
inflightRef.current.delete(key);
if (queuedForceRef.current.has(key)) {
if (enabledRef.current) {
queuedForceRef.current.delete(key);
void loadRecordRef.current(item, { force: true, urgent: options?.urgent });
} else {
commitRecords((prev) => {
const current = prev[key];
if (!current?.loading) return prev;
return {
...prev,
[key]: {
...current,
loading: false,
},
};
}, true);
}
}
}
}, [commitRecords, fetchRecord, getKey, staleTimeMs]);
loadRecordRef.current = loadRecord;
useEffect(() => {
const itemKeys = new Set(items.map(getKey).filter(Boolean));
for (const key of queuedForceRef.current) {
if (!itemKeys.has(key)) {
queuedForceRef.current.delete(key);
}
}
commitRecords((prev) => {
let changed = false;
const next: RecordMap<TValue> = {};
for (const [key, value] of Object.entries(prev) as Array<[string, AsyncRecordState<TValue>]>) {
if (!itemKeys.has(key)) {
changed = true;
continue;
}
next[key] = value;
}
return changed ? next : prev;
});
}, [commitRecords, getKey, items]);
useEffect(() => {
if (!enabled || queuedForceRef.current.size === 0) return;
for (const item of items) {
const key = getKey(item);
if (!key || !queuedForceRef.current.has(key)) continue;
queuedForceRef.current.delete(key);
void loadRecord(item, { force: true, urgent: true });
}
}, [enabled, getKey, items, loadRecord]);
useEffect(() => {
if (!enabled || items.length === 0 || prefetchLimit <= 0) return undefined;
let cancelled = false;
const candidates = items.slice(0, prefetchLimit);
const cancelIdleTask = scheduleIdleTask(() => {
void (async () => {
for (const item of candidates) {
if (cancelled) return;
await loadRecord(item);
if (prefetchDelayMs > 0) {
await delay(prefetchDelayMs);
}
}
})();
});
return () => {
cancelled = true;
cancelIdleTask();
};
}, [enabled, items, loadRecord, prefetchDelayMs, prefetchLimit]);
const invalidateRecord = useCallback((key: string) => {
requestVersionRef.current.set(key, (requestVersionRef.current.get(key) ?? 0) + 1);
queuedForceRef.current.delete(key);
commitRecords((prev) => {
if (!(key in prev)) return prev;
const { [key]: _removed, ...next } = prev;
return next;
}, true);
}, [commitRecords]);
const invalidateMatching = useCallback((matches: (key: string) => boolean) => {
for (const key of requestVersionRef.current.keys()) {
if (matches(key)) {
requestVersionRef.current.set(key, (requestVersionRef.current.get(key) ?? 0) + 1);
queuedForceRef.current.delete(key);
}
}
commitRecords((prev) => {
let changed = false;
const next: RecordMap<TValue> = {};
for (const [key, value] of Object.entries(prev) as Array<[string, AsyncRecordState<TValue>]>) {
if (matches(key)) {
changed = true;
continue;
}
next[key] = value;
}
return changed ? next : prev;
}, true);
}, [commitRecords]);
const refreshRecord = useCallback(
(item: TItem) => loadRecord(item, { force: true, urgent: true }),
[loadRecord],
);
return {
records,
loadRecord,
refreshRecord,
invalidateRecord,
invalidateMatching,
};
}
+19 -3
View File
@@ -62,20 +62,25 @@ export function useSystemSettingsEffects({
// Persist and sync toggle window hotkey setting
useEffect(() => {
if (!enabled) return;
let cancelled = false;
let didRegister = false;
// Register/unregister the global hotkey in main process (needed on mount)
const bridge = netcattyBridge.get();
if (bridge?.registerGlobalHotkey) {
if (toggleWindowHotkey && globalHotkeyEnabled) {
setHotkeyRegistrationError(null);
didRegister = true;
bridge
.registerGlobalHotkey(toggleWindowHotkey)
.then((result) => {
if (cancelled) return;
if (result?.success === false) {
console.warn('[GlobalHotkey] Hotkey registration failed:', result.error);
setHotkeyRegistrationError(result.error || 'Failed to register hotkey');
}
})
.catch((err) => {
if (cancelled) return;
console.warn('[GlobalHotkey] Failed to register hotkey:', err);
setHotkeyRegistrationError(err?.message || 'Failed to register hotkey');
});
@@ -87,9 +92,20 @@ export function useSystemSettingsEffects({
}
}
localStorageAdapter.writeString(STORAGE_KEY_TOGGLE_WINDOW_HOTKEY, toggleWindowHotkey);
// Skip IPC on initial mount
if (!persistMountedRef.current) return;
notifySettingsChanged(STORAGE_KEY_TOGGLE_WINDOW_HOTKEY, toggleWindowHotkey);
// Skip settings-sync IPC on initial mount; still return cleanup below.
if (persistMountedRef.current) {
notifySettingsChanged(STORAGE_KEY_TOGGLE_WINDOW_HOTKEY, toggleWindowHotkey);
}
return () => {
cancelled = true;
// Drop Mount1's registration before Mount2 re-registers (StrictMode), and
// release the accelerator on real unmount / disable transitions.
if (didRegister) {
bridge?.unregisterGlobalHotkey?.().catch((err) => {
console.warn('[GlobalHotkey] Failed to unregister hotkey on cleanup', err);
});
}
};
}, [
toggleWindowHotkey,
enabled,
+201
View File
@@ -0,0 +1,201 @@
import { useSyncExternalStore } from 'react';
import type {
HotkeyScheme,
KeyBinding,
SessionLogFormat,
TerminalSettings,
} from '../../domain/models';
import { normalizeTerminalSettings } from '../../domain/models';
import type { useSettingsState } from './useSettingsState';
type SettingsState = ReturnType<typeof useSettingsState>;
type Listener = () => void;
/**
* Terminal-facing settings slice. Mirrors `settingsChromeStore`: values and
* actions live on separate slots so setter identity churn never invalidates
* the value snapshot TerminalHost / terminal domain bags subscribe to.
*/
export type TerminalSettingsSnapshot = {
terminalThemeId: string;
terminalThemeDarkId: string;
terminalThemeLightId: string;
followAppTerminalTheme: boolean;
terminalFontFamilyId: string;
terminalFontSize: number;
terminalSettings: TerminalSettings;
hotkeyScheme: HotkeyScheme;
keyBindings: readonly KeyBinding[];
isHotkeyRecording: boolean;
sftpDoubleClickBehavior: 'open' | 'transfer';
sftpAutoSync: boolean;
sftpShowHiddenFiles: boolean;
sftpUseCompressedUpload: boolean;
sftpAutoOpenSidebar: boolean;
sftpFollowTerminalCwd: boolean;
sftpDefaultViewMode: 'list' | 'tree';
editorWordWrap: boolean;
sessionLogsEnabled: boolean;
sessionLogsDir: string;
sessionLogsFormat: SessionLogFormat;
sessionLogsTimestampsEnabled: boolean;
sshDebugLogsEnabled: boolean;
};
export type TerminalSettingsActions = Pick<
SettingsState,
| 'setTerminalThemeId'
| 'setTerminalThemeDarkId'
| 'setTerminalThemeLightId'
| 'setFollowAppTerminalTheme'
| 'setTerminalFontFamilyId'
| 'setTerminalFontSize'
| 'updateTerminalSetting'
| 'setSftpFollowTerminalCwd'
| 'setEditorWordWrap'
| 'applyAppTheme'
>;
export const DEFAULT_TERMINAL_SETTINGS_SNAPSHOT: TerminalSettingsSnapshot = Object.freeze({
terminalThemeId: 'default',
terminalThemeDarkId: 'default',
terminalThemeLightId: 'default',
followAppTerminalTheme: true,
terminalFontFamilyId: 'default',
terminalFontSize: 14,
terminalSettings: normalizeTerminalSettings(),
hotkeyScheme: 'mac',
keyBindings: Object.freeze([]) as readonly KeyBinding[],
isHotkeyRecording: false,
sftpDoubleClickBehavior: 'open',
sftpAutoSync: false,
sftpShowHiddenFiles: false,
sftpUseCompressedUpload: false,
sftpAutoOpenSidebar: false,
sftpFollowTerminalCwd: false,
sftpDefaultViewMode: 'list',
editorWordWrap: true,
sessionLogsEnabled: false,
sessionLogsDir: '',
sessionLogsFormat: 'txt',
sessionLogsTimestampsEnabled: false,
sshDebugLogsEnabled: false,
});
export function terminalSettingsSnapshotsEqual(
a: TerminalSettingsSnapshot,
b: TerminalSettingsSnapshot,
): boolean {
return a.terminalThemeId === b.terminalThemeId
&& a.terminalThemeDarkId === b.terminalThemeDarkId
&& a.terminalThemeLightId === b.terminalThemeLightId
&& a.followAppTerminalTheme === b.followAppTerminalTheme
&& a.terminalFontFamilyId === b.terminalFontFamilyId
&& a.terminalFontSize === b.terminalFontSize
&& a.terminalSettings === b.terminalSettings
&& a.hotkeyScheme === b.hotkeyScheme
&& a.keyBindings === b.keyBindings
&& a.isHotkeyRecording === b.isHotkeyRecording
&& a.sftpDoubleClickBehavior === b.sftpDoubleClickBehavior
&& a.sftpAutoSync === b.sftpAutoSync
&& a.sftpShowHiddenFiles === b.sftpShowHiddenFiles
&& a.sftpUseCompressedUpload === b.sftpUseCompressedUpload
&& a.sftpAutoOpenSidebar === b.sftpAutoOpenSidebar
&& a.sftpFollowTerminalCwd === b.sftpFollowTerminalCwd
&& a.sftpDefaultViewMode === b.sftpDefaultViewMode
&& a.editorWordWrap === b.editorWordWrap
&& a.sessionLogsEnabled === b.sessionLogsEnabled
&& a.sessionLogsDir === b.sessionLogsDir
&& a.sessionLogsFormat === b.sessionLogsFormat
&& a.sessionLogsTimestampsEnabled === b.sessionLogsTimestampsEnabled
&& a.sshDebugLogsEnabled === b.sshDebugLogsEnabled;
}
class TerminalSettingsStore {
private snapshot: TerminalSettingsSnapshot = DEFAULT_TERMINAL_SETTINGS_SNAPSHOT;
private actions: TerminalSettingsActions | null = null;
private listeners = new Set<Listener>();
private actionListeners = new Set<Listener>();
getSnapshot = (): TerminalSettingsSnapshot => this.snapshot;
subscribe = (listener: Listener): (() => void) => {
this.listeners.add(listener);
return () => {
this.listeners.delete(listener);
};
};
setSnapshot(next: TerminalSettingsSnapshot): void {
if (terminalSettingsSnapshotsEqual(this.snapshot, next)) return;
this.snapshot = next;
for (const listener of this.listeners) {
listener();
}
}
getActions = (): TerminalSettingsActions | null => this.actions;
subscribeActions = (listener: Listener): (() => void) => {
this.actionListeners.add(listener);
return () => {
this.actionListeners.delete(listener);
};
};
setActions(next: TerminalSettingsActions | null): void {
if (this.actions === next) return;
this.actions = next;
for (const listener of this.actionListeners) {
listener();
}
}
}
export const terminalSettingsStore = new TerminalSettingsStore();
export function publishTerminalSettingsSnapshot(
snapshot: TerminalSettingsSnapshot,
): void {
terminalSettingsStore.setSnapshot(snapshot);
}
export function getTerminalSettingsSnapshot(): TerminalSettingsSnapshot {
return terminalSettingsStore.getSnapshot();
}
export function subscribeTerminalSettings(listener: Listener): () => void {
return terminalSettingsStore.subscribe(listener);
}
export function registerTerminalSettingsActions(
actions: TerminalSettingsActions | null,
): void {
terminalSettingsStore.setActions(actions);
}
export function getTerminalSettingsActions(): TerminalSettingsActions | null {
return terminalSettingsStore.getActions();
}
export function subscribeTerminalSettingsActions(listener: Listener): () => void {
return terminalSettingsStore.subscribeActions(listener);
}
export function useTerminalSettingsStore(): TerminalSettingsSnapshot {
return useSyncExternalStore(
subscribeTerminalSettings,
getTerminalSettingsSnapshot,
getTerminalSettingsSnapshot,
);
}
export function useTerminalSettingsActions(): TerminalSettingsActions | null {
return useSyncExternalStore(
subscribeTerminalSettingsActions,
getTerminalSettingsActions,
getTerminalSettingsActions,
);
}
@@ -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("./useAIChatStreaming.ts", import.meta.url), "utf8");
test("useAIChatStreaming imports streaming support from infrastructure", () => {
assert.match(source, /from ['"]\.\.\/\.\.\/infrastructure\/ai\/aiChatStreamingSupport['"]/);
assert.doesNotMatch(source, /from ['"].*components\//);
});
+268
View File
@@ -0,0 +1,268 @@
/**
* useAIChatStreaming — React UI layer for AI chat streaming.
*
* Turn orchestration lives in AgentRuntime + TurnDrivers; this hook only
* manages streaming state, abort controllers, and UI callbacks.
*/
import React, { useCallback, useEffect, useRef, useState } from 'react';
import type {
AIPermissionMode,
AIToolIntegrationMode,
AISession,
ChatMessage,
ChatMessageAttachment,
ExternalAgentConfig,
ProviderConfig,
WebSearchConfig,
} from '../../infrastructure/ai/types';
import type { ExecutorContext } from '../../infrastructure/ai/cattyAgent/executor';
import { getAgentRuntime } from '../../infrastructure/ai/harness/globalAgentRuntime';
import type {
TurnSteerInput,
TurnSteerResult,
} from '../../infrastructure/ai/harness/turnDrivers/types';
import { classifyError } from '../../infrastructure/ai/errorClassifier';
import { latestAISessionsSnapshot } from './aiStateSnapshots';
import {
generateId,
getNetcattyBridge,
type DefaultTargetSessionHint,
type TerminalSessionInfo,
} from '../../infrastructure/ai/aiChatStreamingSupport';
import { useAgentCompactionUi } from './useAgentCompactionUi';
export { getNetcattyBridge } from '../../infrastructure/ai/aiChatStreamingSupport';
export type { ActiveCompactionUi } from './useAgentCompactionUi';
export type { DefaultTargetSessionHint } from '../../infrastructure/ai/aiChatStreamingSupport';
const sharedStreamingSessionIds = new Set<string>();
const sharedAbortControllers = new Map<string, AbortController>();
const streamingSubscribers = new Set<() => void>();
/** Whether a chat session still has an active stream (used to keep panel mounted while hidden). */
export function isAIChatSessionStreaming(sessionId: string | null | undefined): boolean {
return !!sessionId && sharedStreamingSessionIds.has(sessionId);
}
function emitStreamingStoreChange(): void {
streamingSubscribers.forEach(listener => {
try {
listener();
} catch (err) {
console.error('[AIChatStreaming] Failed to notify streaming subscriber:', err);
}
});
}
export interface UseAIChatStreamingParams {
maxIterations: number;
addMessageToSession: (sessionId: string, message: ChatMessage) => void;
updateLastMessage: (sessionId: string, updater: (msg: ChatMessage) => ChatMessage) => void;
updateMessageById: (sessionId: string, messageId: string, updater: (msg: ChatMessage) => ChatMessage) => void;
persistContextCompaction?: (
sessionId: string,
compaction: import('../../infrastructure/ai/types').AISessionContextCompaction,
) => void;
}
export interface UseAIChatStreamingReturn {
streamingSessionIds: Set<string>;
setStreamingForScope: (key: string, val: boolean) => void;
abortControllersRef: React.MutableRefObject<Map<string, AbortController>>;
sendToCattyAgent: (
sessionId: string,
sendScopeKey: string,
trimmed: string,
abortController: AbortController,
currentSession: AISession | undefined,
assistantMsgId: string,
context: SendToCattyContext,
attachments?: ChatMessageAttachment[],
) => Promise<void>;
sendToExternalAgent: (
sessionId: string,
assistantMsgId: string,
trimmed: string,
agentConfig: ExternalAgentConfig,
abortController: AbortController,
attachedImages: Array<{ base64Data: string; mediaType: string; filename?: string; filePath?: string }>,
context: SendToExternalContext,
) => Promise<void>;
steerExternalAgent: (input: TurnSteerInput) => Promise<TurnSteerResult>;
reportStreamError: (sessionId: string, abortSignal: AbortSignal, err: unknown) => void;
activeCompaction: import('./useAgentCompactionUi').ActiveCompactionUi | null;
}
export interface SendToCattyContext {
activeProvider: ProviderConfig | undefined;
activeModelId: string;
scopeType: 'terminal' | 'workspace';
scopeTargetId?: string;
scopeLabel?: string;
globalPermissionMode: AIPermissionMode;
commandBlocklist?: string[];
commandTimeout?: number;
terminalSessions: TerminalSessionInfo[];
webSearchConfig?: WebSearchConfig | null;
getExecutorContext?: () => ExecutorContext;
autoTitleSession: (sessionId: string, text: string) => void;
titleText?: string;
selectedUserSkillSlugs?: string[];
permissionMode?: AIPermissionMode;
forceCompaction?: boolean;
}
export interface SendToExternalContext {
existingSessionId?: string;
updateExternalSessionId?: (sessionId: string, externalSessionId: string | undefined) => void;
historyMessages?: Array<{ role: 'user' | 'assistant'; content: string }>;
terminalSessions: TerminalSessionInfo[];
defaultTargetSession?: DefaultTargetSessionHint;
providers: ProviderConfig[];
selectedAgentModel?: string;
toolIntegrationMode: AIToolIntegrationMode;
selectedUserSkillSlugs?: string[];
permissionMode: AIPermissionMode;
}
export function useAIChatStreaming({
maxIterations,
addMessageToSession,
updateLastMessage,
updateMessageById,
persistContextCompaction,
}: UseAIChatStreamingParams): UseAIChatStreamingReturn {
const [streamingSessionIds, setStreamingSessions] = useState<Set<string>>(
() => new Set(sharedStreamingSessionIds),
);
useEffect(() => {
const syncFromStore = () => {
setStreamingSessions(new Set(sharedStreamingSessionIds));
};
streamingSubscribers.add(syncFromStore);
syncFromStore();
return () => {
streamingSubscribers.delete(syncFromStore);
};
}, []);
const setStreamingForScope = useCallback((key: string, val: boolean) => {
const hadKey = sharedStreamingSessionIds.has(key);
if (val) {
sharedStreamingSessionIds.add(key);
} else {
sharedStreamingSessionIds.delete(key);
}
if (hadKey !== val) {
emitStreamingStoreChange();
}
}, []);
const abortControllersRef = useRef<Map<string, AbortController>>(sharedAbortControllers);
const activeCompaction = useAgentCompactionUi();
const reportStreamError = useCallback((
sessionId: string,
abortSignal: AbortSignal,
err: unknown,
) => {
if (abortSignal.aborted) return;
console.error('[AIChatSidePanel] Stream error (full):', err);
const errorInfo = classifyError(err);
updateLastMessage(sessionId, msg => ({
...msg,
statusText: '',
executionStatus: msg.executionStatus === 'running' ? 'failed' : msg.executionStatus,
}));
addMessageToSession(sessionId, {
id: generateId(),
role: 'assistant',
content: '',
errorInfo,
timestamp: Date.now(),
});
}, [updateLastMessage, addMessageToSession]);
const uiCallbacks = useCallback(() => ({
addMessageToSession,
updateLastMessage,
updateMessageById,
reportStreamError,
setStreamingForScope,
getLatestSession: (sessionId: string) => latestAISessionsSnapshot?.find(s => s.id === sessionId),
persistContextCompaction,
}), [addMessageToSession, updateLastMessage, updateMessageById, reportStreamError, setStreamingForScope, persistContextCompaction]);
const sendToExternalAgent = useCallback(async (
sessionId: string,
assistantMsgId: string,
trimmed: string,
agentConfig: ExternalAgentConfig,
abortController: AbortController,
attachedImages: Array<{ base64Data: string; mediaType: string; filename?: string; filePath?: string }>,
context: SendToExternalContext,
) => {
const bridge = getNetcattyBridge();
await getAgentRuntime().runTurn({
backend: 'external-sdk',
chatSessionId: sessionId,
assistantMsgId,
userText: trimmed,
signal: abortController.signal,
agentConfig,
attachedImages,
context,
bridge,
ui: uiCallbacks(),
});
}, [uiCallbacks]);
const steerExternalAgent = useCallback(async (input: TurnSteerInput) => {
return getAgentRuntime().steerTurn(input);
}, []);
const sendToCattyAgent = useCallback(async (
sessionId: string,
sendScopeKey: string,
trimmed: string,
abortController: AbortController,
currentSession: AISession | undefined,
assistantMsgId: string,
context: SendToCattyContext,
attachments?: ChatMessageAttachment[],
) => {
const bridge = getNetcattyBridge();
try {
await getAgentRuntime().runTurn({
backend: 'catty',
chatSessionId: sessionId,
sendScopeKey,
userText: trimmed,
signal: abortController.signal,
currentSession,
assistantMsgId,
context,
attachments,
maxIterations,
bridge,
ui: uiCallbacks(),
});
} finally {
abortControllersRef.current.delete(sessionId);
}
}, [maxIterations, uiCallbacks]);
return {
streamingSessionIds,
setStreamingForScope,
abortControllersRef,
sendToCattyAgent,
sendToExternalAgent,
steerExternalAgent,
reportStreamError,
activeCompaction,
};
}
+15 -9
View File
@@ -1,4 +1,4 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
import { localStorageAdapter } from '../../infrastructure/persistence/localStorageAdapter';
import {
STORAGE_KEY_AI_PROVIDERS,
@@ -50,6 +50,7 @@ import {
} from './aiDraftState';
import { convertFilesToUploads } from './useFileUpload';
import { removeProviderReferences } from './aiProviderCleanup';
import { publishAISessionsSnapshot } from './aiSessionsStore';
import {
AI_STATE_CHANGED_DRAFTS_BY_SCOPE,
AI_STATE_CHANGED_PANEL_VIEW_BY_SCOPE,
@@ -1004,6 +1005,17 @@ export function useAIState() {
// ── Computed ──
const activeProvider = providers.find(p => p.id === activeProviderId) ?? null;
// Stream/message updates publish here so AIConfig Context consumers do not
// re-render on every token. AIChatPanelsHost reads aiSessionsStore instead.
useLayoutEffect(() => {
publishAISessionsSnapshot({
sessions,
activeSessionIdMap,
draftsByScope,
panelViewByScope,
});
}, [sessions, activeSessionIdMap, draftsByScope, panelViewByScope]);
return useMemo(() => ({
providers,
setProviders,
@@ -1039,10 +1051,8 @@ export function useAIState() {
setWebSearchConfig,
quickMessages,
setQuickMessages,
sessions,
activeSessionIdMap,
draftsByScope,
panelViewByScope,
// sessions / activeSessionIdMap / draftsByScope / panelViewByScope live in
// aiSessionsStore so streaming updates do not rebuild this config object.
setActiveSessionId,
ensureDraftForScope,
updateDraft,
@@ -1096,10 +1106,6 @@ export function useAIState() {
setWebSearchConfig,
quickMessages,
setQuickMessages,
sessions,
activeSessionIdMap,
draftsByScope,
panelViewByScope,
setActiveSessionId,
ensureDraftForScope,
updateDraft,
+95
View File
@@ -0,0 +1,95 @@
import { useEffect, useState } from 'react';
import type { ContextPrepareTrigger } from '../../infrastructure/ai/harness/types';
import { getAgentRuntime } from '../../infrastructure/ai/harness/globalAgentRuntime';
import { CATTY_COMPACTION_STATUS_KEYS } from '../../infrastructure/ai/harness/compactionStatusKeys';
export interface ActiveCompactionUi {
sessionId: string;
trigger: ContextPrepareTrigger;
}
export interface AgentContextUsage {
sessionId: string;
inputTokens: number;
contextWindow: number;
estimated: boolean;
}
function statusKeyForTrigger(trigger: ContextPrepareTrigger): string {
switch (trigger) {
case 'step':
return CATTY_COMPACTION_STATUS_KEYS.step;
case '413-retry':
return CATTY_COMPACTION_STATUS_KEYS.retry;
default:
return CATTY_COMPACTION_STATUS_KEYS.preTurn;
}
}
export function useAgentCompactionUi(): ActiveCompactionUi | null {
const [active, setActive] = useState<ActiveCompactionUi | null>(null);
useEffect(() => {
const unsubscribe = getAgentRuntime().subscribe((event) => {
const sessionId = event.chatSessionId ?? event.sessionId;
if (event.type === 'compaction_start') {
setActive({ sessionId, trigger: event.trigger });
return;
}
if (event.type === 'compaction' || event.type === 'turn_end') {
setActive((prev) => (prev?.sessionId === sessionId ? null : prev));
}
});
return unsubscribe;
}, []);
return active;
}
export function compactionStatusText(
trigger: ContextPrepareTrigger,
translate: (key: string, params?: Record<string, string | number>) => string,
): string {
return translate(statusKeyForTrigger(trigger));
}
export function resolveCompactionStatusText(
statusText: string | undefined,
translate: (key: string) => string,
): string | undefined {
if (!statusText) return undefined;
if (statusText.startsWith('ai.')) return translate(statusText);
return statusText;
}
export function useAgentContextUsage(sessionId: string | null | undefined): AgentContextUsage | null {
const [usage, setUsage] = useState<AgentContextUsage | null>(null);
useEffect(() => {
if (!sessionId) {
setUsage(null);
return undefined;
}
const unsubscribe = getAgentRuntime().subscribe((event) => {
const eventSessionId = event.chatSessionId ?? event.sessionId;
if (eventSessionId !== sessionId || event.type !== 'context_snapshot') return;
const snapshot = event.snapshot;
if (
typeof snapshot.contextWindow !== 'number'
|| typeof snapshot.estimatedInputTokens !== 'number'
) {
return;
}
setUsage({
sessionId,
inputTokens: Math.max(0, snapshot.estimatedInputTokens),
contextWindow: Math.max(1, snapshot.contextWindow),
estimated: true,
});
});
return unsubscribe;
}, [sessionId]);
return usage?.sessionId === sessionId ? usage : null;
}
+12 -4
View File
@@ -7,7 +7,7 @@
* - Debounced sync to avoid too frequent API calls
*/
import { useCallback, useEffect, useRef, useState } from 'react';
import { useCallback, useEffect, useRef, useState, useSyncExternalStore } from 'react';
import { useCloudSync } from './useCloudSync';
import { useI18n } from '../i18n/I18nProvider';
import { getCloudSyncManager } from '../../infrastructure/services/CloudSyncManager';
@@ -49,6 +49,7 @@ import {
shouldRunRuntimeRemoteCheck,
} from './autoSyncRemoteSchedule';
import { resolveAutoSyncHashDecision } from './autoSyncHashDecision';
import { getNotesSnapshot, subscribeNotes } from './notesStore';
interface AutoSyncConfig {
enabled?: boolean;
@@ -163,6 +164,9 @@ interface RemoteVersionCheckOptions {
export const useAutoSync = (config: AutoSyncConfig) => {
const enabled = config.enabled !== false;
// Subscribe so note edits still trigger getSyncSnapshot rebuilds even when
// App omits notes props (notes live in notesStore, not App render).
const notesSnapshot = useSyncExternalStore(subscribeNotes, getNotesSnapshot, getNotesSnapshot);
const { t } = useI18n();
const tRef = useRef(t);
useEffect(() => {
@@ -254,8 +258,10 @@ export const useAutoSync = (config: AutoSyncConfig) => {
snippets: config.snippets,
customGroups: config.customGroups,
snippetPackages: config.snippetPackages,
notes: config.notes,
noteGroups: config.noteGroups,
// Prefer explicit overrides (tests); otherwise read the live notes store so
// App does not have to re-render on every note edit to keep sync current.
notes: config.notes ?? (notesSnapshot.notes as SyncPayload['notes']),
noteGroups: config.noteGroups ?? (notesSnapshot.noteGroups as SyncPayload['noteGroups']),
portForwardingRules: getEffectivePortForwardingRulesForSync(config.portForwardingRules),
groupConfigs: config.groupConfigs,
};
@@ -271,6 +277,8 @@ export const useAutoSync = (config: AutoSyncConfig) => {
config.noteGroups,
config.portForwardingRules,
config.groupConfigs,
notesSnapshot.notes,
notesSnapshot.noteGroups,
]);
// Build sync payload (includes plugin sidecars when host is available)
@@ -495,7 +503,7 @@ export const useAutoSync = (config: AutoSyncConfig) => {
} finally {
isSyncRunningRef.current = false;
}
}, [enabled, sync, buildPayload, getDataHash, onApplyConvergentPayload, onApplyPayload, t]);
}, [enabled, sync, buildPayload, onApplyConvergentPayload, onApplyPayload, t]);
const syncNowRef = useRef(syncNow);
useEffect(() => {
@@ -1,6 +1,6 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import { buildAITerminalSessionInfo } from '../../components/terminalLayer/TerminalLayerSupport';
import { buildAITerminalSessionInfo } from '../../domain/buildAITerminalSessionInfo';
import { detectLocalOs } from '../../lib/localShell';
import type { Host, PortForwardingRule, TerminalSession } from '../../types';
import { STORAGE_KEY_AI_EXTERNAL_MCP_ENABLED } from '../../infrastructure/config/storageKeys';
@@ -15,9 +15,11 @@ import {
readExternalMcpSilentSessions,
readExternalMcpStoredEnabled,
resetExternalMcpStartupReadyForTests,
resetExternalMcpStartupSyncOnceForTests,
shouldStartExternalMcpOnStartup,
shouldWaitForExternalMcpStartupReady,
syncExternalMcpStartupState,
syncExternalMcpStartupStateOnce,
waitForExternalMcpStartupReady,
writeExternalMcpFocusOnHostOpen,
writeExternalMcpSilentSessions,
@@ -178,17 +180,17 @@ describe('useExternalMcpToggleState startup ready gate', () => {
it('wires App startup reconcile to release the gate after enable settles', async () => {
const appSource = await import('node:fs').then((fs) =>
fs.readFileSync(new URL('../../App.tsx', import.meta.url), 'utf8'),
fs.readFileSync(new URL('../app/AppSideEffects.tsx', import.meta.url), 'utf8'),
);
const hookSource = await import('node:fs').then((fs) =>
fs.readFileSync(new URL('./useExternalMcpToggleState.ts', import.meta.url), 'utf8'),
);
assert.match(appSource, /await syncExternalMcpStartupState\(netcattyBridge\.get\(\)\)/);
assert.match(appSource, /await syncExternalMcpStartupStateOnce\(netcattyBridge\.get\(\)\)/);
assert.match(appSource, /markExternalMcpStartupReady\(\)/);
assert.ok(
appSource.indexOf('await syncExternalMcpStartupState(netcattyBridge.get())')
appSource.indexOf('await syncExternalMcpStartupStateOnce(netcattyBridge.get())')
< appSource.indexOf('markExternalMcpStartupReady()'),
'startup ready must be marked only after await syncExternalMcpStartupState',
'startup ready must be marked only after await syncExternalMcpStartupStateOnce',
);
assert.match(hookSource, /export async function syncExternalMcpStartupState/);
assert.match(hookSource, /await Promise\.resolve\(bridge\?\.externalMcpSetEnabled\?\.\(plan\.runtimeEnabled\)\)/);
@@ -247,6 +249,51 @@ describe('useExternalMcpToggleState startup ready gate', () => {
});
});
describe('syncExternalMcpStartupStateOnce single-flight', () => {
it('reaches the bridge once when the startup effect is invoked twice', async () => {
const restore = installMemoryLocalStorage();
try {
resetExternalMcpStartupReadyForTests();
resetExternalMcpStartupSyncOnceForTests();
const storageKeys = await import('../../infrastructure/config/storageKeys.ts');
globalThis.localStorage.setItem(storageKeys.STORAGE_KEY_AI_EXTERNAL_MCP_ENABLED, 'true');
globalThis.localStorage.setItem(storageKeys.STORAGE_KEY_AI_EXTERNAL_MCP_MODE, 'persistent');
const configCalls: unknown[] = [];
const enabledCalls: boolean[] = [];
const bridge = {
externalMcpSetConfig: async (config: unknown) => {
configCalls.push(config);
return { ok: true };
},
externalMcpSetEnabled: async (enabled: boolean) => {
enabledCalls.push(enabled);
return { ok: true, enabled };
},
};
// Mount, cleanup, mount again: both effect runs share one reconcile.
const first = syncExternalMcpStartupStateOnce(bridge);
const second = syncExternalMcpStartupStateOnce(bridge);
assert.equal(first, second);
const [firstPlan, secondPlan] = await Promise.all([first, second]);
assert.equal(firstPlan, secondPlan);
assert.equal(firstPlan.runtimeEnabled, true);
assert.equal(configCalls.length, 1);
assert.deepEqual(enabledCalls, [true]);
// A later remount awaits the settled promise instead of re-sending IPC.
await syncExternalMcpStartupStateOnce(bridge);
assert.equal(configCalls.length, 1);
assert.deepEqual(enabledCalls, [true]);
} finally {
resetExternalMcpStartupSyncOnceForTests();
restore();
}
});
});
describe('syncExternalMcpStartupState generation guard', () => {
it('skips stale startup enable after concurrent top-bar toggle generation bump', async () => {
const restore = installMemoryLocalStorage();
@@ -64,6 +64,7 @@ export function resetExternalMcpStartupReadyForTests(): void {
externalMcpStartupReadyWaitPromise = null;
externalMcpStartupReadyWaitResolve = null;
externalMcpEnableGeneration = 0;
externalMcpStartupSyncPromise = null;
}
export function getExternalMcpEnableGenerationForTests(): number {
@@ -144,12 +145,26 @@ export function readExternalMcpMode(): ExternalMcpMode {
return normalizeExternalMcpMode(localStorageAdapter.readString(STORAGE_KEY_AI_EXTERNAL_MCP_MODE));
}
export function writeExternalMcpMode(mode: ExternalMcpMode): ExternalMcpMode {
const normalized = normalizeExternalMcpMode(mode);
localStorageAdapter.writeString(STORAGE_KEY_AI_EXTERNAL_MCP_MODE, normalized);
emitAIStateChanged(STORAGE_KEY_AI_EXTERNAL_MCP_MODE);
return normalized;
}
export function readExternalMcpIdleTimeoutMinutes(): number {
return normalizeExternalMcpIdleTimeoutMinutes(
localStorageAdapter.readNumber(STORAGE_KEY_AI_EXTERNAL_MCP_IDLE_TIMEOUT_MINUTES),
);
}
export function writeExternalMcpIdleTimeoutMinutes(minutes: number): number {
const normalized = normalizeExternalMcpIdleTimeoutMinutes(minutes);
localStorageAdapter.writeNumber(STORAGE_KEY_AI_EXTERNAL_MCP_IDLE_TIMEOUT_MINUTES, normalized);
emitAIStateChanged(STORAGE_KEY_AI_EXTERNAL_MCP_IDLE_TIMEOUT_MINUTES);
return normalized;
}
/** Whether host_open should surface/focus the main window. Defaults to true (existing behavior). */
export function readExternalMcpFocusOnHostOpen(): boolean {
return localStorageAdapter.readBoolean(STORAGE_KEY_AI_EXTERNAL_MCP_FOCUS_ON_HOST_OPEN) ?? true;
@@ -187,6 +202,13 @@ export function readSessionIdleTimeoutMinutes(): number {
);
}
export function writeSessionIdleTimeoutMinutes(minutes: number): number {
const normalized = normalizeSessionIdleTimeoutMinutes(minutes);
localStorageAdapter.writeNumber(STORAGE_KEY_AI_SESSION_IDLE_TIMEOUT_MINUTES, normalized);
emitAIStateChanged(STORAGE_KEY_AI_SESSION_IDLE_TIMEOUT_MINUTES);
return normalized;
}
export function shouldStartExternalMcpOnStartup({
enabled,
mode,
@@ -289,6 +311,26 @@ export async function syncExternalMcpStartupState(
return plan;
}
let externalMcpStartupSyncPromise: Promise<ExternalMcpStartupSyncPlan> | null = null;
/**
* Startup reconcile runs exactly once per renderer load. A remounted App must
* await the same settled promise instead of pushing a second enable/disable to
* the main process, which would fight the user's live toggle.
*/
export function syncExternalMcpStartupStateOnce(
bridge: ExternalMcpBridge | undefined = netcattyBridge.get(),
): Promise<ExternalMcpStartupSyncPlan> {
if (!externalMcpStartupSyncPromise) {
externalMcpStartupSyncPromise = syncExternalMcpStartupState(bridge);
}
return externalMcpStartupSyncPromise;
}
export function resetExternalMcpStartupSyncOnceForTests(): void {
externalMcpStartupSyncPromise = null;
}
export function useExternalMcpToggleState() {
// UI mirrors the stored switch. Startup reconcile (App mount, main window only)
// decides whether temporary mode should clear/persist and start the runtime.
@@ -2,7 +2,11 @@ import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import test from 'node:test';
const appSource = readFileSync(new URL('../../App.tsx', import.meta.url), 'utf8');
const appSource = [
readFileSync(new URL('../app/AppSideEffects.tsx', import.meta.url), 'utf8'),
readFileSync(new URL('../app/hosts/TerminalHost.tsx', import.meta.url), 'utf8'),
readFileSync(new URL('../app/hosts/ChromeHost.tsx', import.meta.url), 'utf8'),
].join('\n');
const hookSource = readFileSync(new URL('./useManualTerminalChromeSurfaceInjection.ts', import.meta.url), 'utf8');
const bridgeSource = readFileSync(new URL('../../components/terminalLayer/TerminalLayerTabBridge.tsx', import.meta.url), 'utf8');
const varsSource = readFileSync(new URL('../../infrastructure/theme/terminalAppearanceVars.ts', import.meta.url), 'utf8');
@@ -12,7 +16,9 @@ test('manual mode injects chrome surfaces from focused session theme', () => {
assert.match(bridgeSource, /useManualTerminalChromeSurfaceInjection/);
assert.match(bridgeSource, /!s\.followAppTerminalTheme && isTerminalLayerVisible/);
assert.match(readFileSync(new URL('../app/AppHostTreeLayer.tsx', import.meta.url), 'utf8'), /useManualTerminalChromeSurfaceInjection/);
assert.match(appSource, /resolveSessionAppearance=\{resolveFocusedAppearance\}/);
// App feeds the focused-session resolver to AppActiveTabChrome through the
// memoized shell chrome bag (AppShell spreads it onto the chrome component).
assert.match(appSource, /resolveSessionAppearance: resolveFocusedAppearance/);
assert.match(hookSource, /applyTopTabsChromeThemeVars\(theme\)/);
assert.match(hookSource, /injectTerminalLayerChromeSurfaceVars\(theme\)/);
assert.match(hookSource, /if \(wasEnabled\) \{[\s\S]*clearTerminalLayerChromeSurfaceVars\(\)/);
@@ -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("./usePortForwardHostKeyVerification.ts", import.meta.url), "utf8");
test("usePortForwardHostKeyVerification stays below the UI layer", () => {
assert.doesNotMatch(source, /from ["'].*components\//);
assert.match(source, /from ["']\.\.\/\.\.\/domain\/portForwardHostKey["']/);
});
@@ -1,7 +1,7 @@
import { useCallback, useEffect, useState } from "react";
import type { KnownHost } from "../../domain/models";
import { netcattyBridge } from "../../infrastructure/services/netcattyBridge";
import type { HostKeyInfo } from "../../components/terminal/TerminalHostKeyVerification";
import type { HostKeyInfo } from "../../domain/hostKey";
import {
createKnownHostFromPortForwardHostKeyInfo,
enqueuePortForwardHostKeyVerification,
@@ -9,7 +9,7 @@ import {
toPendingPortForwardHostKeyVerification,
type PendingPortForwardHostKeyVerification,
type PortForwardHostKeyRequest,
} from "../../components/port-forwarding/hostKeyVerification";
} from "../../domain/portForwardHostKey";
export interface PortForwardHostKeyVerificationState {
hostKeyInfo: HostKeyInfo;
+415
View File
@@ -0,0 +1,415 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { netcattyBridge } from '../../infrastructure/services/netcattyBridge';
export interface DiskInfo {
capacityKey?: string; // Filesystem or shared-pool identity for deduplication
mountPoint: string;
used: number; // Used in GB
total: number; // Total in GB
percent: number; // Usage percentage
}
export interface NetInterfaceInfo {
name: string; // Interface name (e.g., eth0, ens33)
rxBytes: number; // Total received bytes
txBytes: number; // Total transmitted bytes
rxSpeed: number; // Receive speed (bytes/sec)
txSpeed: number; // Transmit speed (bytes/sec)
}
export interface ProcessInfo {
pid: string;
memPercent: number;
command: string;
}
export interface ServerStats {
hostname?: string; // Remote hostname
osName?: string; // Remote operating system name
kernelRelease?: string; // Remote kernel release
uptimeSeconds?: number | null;// Remote uptime in seconds
loadAverage?: number[]; // 1/5/15 minute load average
cpu: number | null; // CPU usage percentage (0-100)
cpuCores: number | null; // Number of CPU cores
cpuPerCore: number[]; // Per-core CPU usage array
memTotal: number | null; // Total memory in MB
memUsed: number | null; // Used memory in MB (excluding buffers/cache)
memFree: number | null; // Free memory in MB
memBuffers: number | null; // Buffers in MB
memCached: number | null; // Cached in MB
swapTotal: number | null; // Total swap in MB
swapUsed: number | null; // Used swap in MB
topProcesses: ProcessInfo[]; // Top 10 processes by memory
diskPercent: number | null; // Disk usage percentage for root partition
diskUsed: number | null; // Disk used in GB
diskTotal: number | null; // Total disk in GB
disks: DiskInfo[]; // All mounted disks
netRxSpeed: number; // Total network receive speed (bytes/sec)
netTxSpeed: number; // Total network transmit speed (bytes/sec)
latencyMs: number | null; // TCP connection establishment latency to the SSH endpoint
netInterfaces: NetInterfaceInfo[]; // Per-interface network stats
lastUpdated: number | null; // Timestamp of last successful update
}
interface UseServerStatsOptions {
sessionId: string;
enabled: boolean; // Whether stats collection is enabled (from settings)
refreshInterval: number; // Refresh interval in seconds
isSupportedOs: boolean; // Only collect stats for Linux/macOS servers
isConnected: boolean; // Only collect when connected
}
interface ServerStatsState {
stats: ServerStats;
isLoading: boolean;
error: string | null;
}
interface ServerStatsClient {
enabled: boolean;
refreshInterval: number;
isSupportedOs: boolean;
isConnected: boolean;
}
type ServerStatsListener = (state: ServerStatsState) => void;
interface SharedServerStatsSession {
sessionId: string;
state: ServerStatsState;
listeners: Set<ServerStatsListener>;
clients: Map<number, ServerStatsClient>;
intervalRef: ReturnType<typeof setInterval> | null;
initialTimerRef: ReturnType<typeof setTimeout> | null;
connectedAt: number;
hasFetched: boolean;
fetchGeneration: number;
consecutiveFailures: number;
givenUp: boolean;
pollIntervalMs: number | null;
pollingActive: boolean;
inflight: Promise<void> | null;
inflightGeneration: number | null;
}
const CONSECUTIVE_FAILURE_LIMIT = 3;
const serverStatsSessions = new Map<string, SharedServerStatsSession>();
let nextClientId = 1;
function createEmptyServerStats(): ServerStats {
return {
hostname: undefined,
osName: undefined,
kernelRelease: undefined,
uptimeSeconds: null,
loadAverage: [],
cpu: null,
cpuCores: null,
cpuPerCore: [],
memTotal: null,
memUsed: null,
memFree: null,
memBuffers: null,
memCached: null,
swapTotal: null,
swapUsed: null,
topProcesses: [],
diskPercent: null,
diskUsed: null,
diskTotal: null,
disks: [],
netRxSpeed: 0,
netTxSpeed: 0,
latencyMs: null,
netInterfaces: [],
lastUpdated: null,
};
}
function createInitialState(): ServerStatsState {
return {
stats: createEmptyServerStats(),
isLoading: false,
error: null,
};
}
function getSharedServerStatsSession(sessionId: string): SharedServerStatsSession {
const existing = serverStatsSessions.get(sessionId);
if (existing) return existing;
const session: SharedServerStatsSession = {
sessionId,
state: createInitialState(),
listeners: new Set(),
clients: new Map(),
intervalRef: null,
initialTimerRef: null,
connectedAt: 0,
hasFetched: false,
fetchGeneration: 0,
consecutiveFailures: 0,
givenUp: false,
pollIntervalMs: null,
pollingActive: false,
inflight: null,
inflightGeneration: null,
};
serverStatsSessions.set(sessionId, session);
return session;
}
function emitServerStatsState(session: SharedServerStatsSession): void {
for (const listener of session.listeners) {
listener(session.state);
}
}
function updateServerStatsState(session: SharedServerStatsSession, patch: Partial<ServerStatsState>): void {
session.state = { ...session.state, ...patch };
emitServerStatsState(session);
}
function clearServerStatsTimers(session: SharedServerStatsSession): void {
if (session.initialTimerRef) {
clearTimeout(session.initialTimerRef);
session.initialTimerRef = null;
}
if (session.intervalRef) {
clearInterval(session.intervalRef);
session.intervalRef = null;
}
session.pollingActive = false;
session.pollIntervalMs = null;
}
function resetServerStatsSession(session: SharedServerStatsSession): void {
clearServerStatsTimers(session);
session.connectedAt = 0;
session.hasFetched = false;
session.fetchGeneration += 1;
session.consecutiveFailures = 0;
session.givenUp = false;
updateServerStatsState(session, createInitialState());
}
function getActiveServerStatsClients(session: SharedServerStatsSession): ServerStatsClient[] {
return Array.from(session.clients.values()).filter((client) => (
client.enabled && client.isSupportedOs && client.isConnected
));
}
function normalizeServerStats(stats: Partial<ServerStats>): ServerStats {
return {
hostname: stats.hostname,
osName: stats.osName,
kernelRelease: stats.kernelRelease,
uptimeSeconds: Number.isFinite(stats.uptimeSeconds) ? Number(stats.uptimeSeconds) : null,
loadAverage: Array.isArray(stats.loadAverage) ? stats.loadAverage : [],
cpu: stats.cpu ?? null,
cpuCores: stats.cpuCores ?? null,
cpuPerCore: stats.cpuPerCore || [],
memTotal: stats.memTotal ?? null,
memUsed: stats.memUsed ?? null,
memFree: stats.memFree ?? null,
memBuffers: stats.memBuffers ?? null,
memCached: stats.memCached ?? null,
swapTotal: stats.swapTotal ?? null,
swapUsed: stats.swapUsed ?? null,
topProcesses: stats.topProcesses || [],
diskPercent: stats.diskPercent ?? null,
diskUsed: stats.diskUsed ?? null,
diskTotal: stats.diskTotal ?? null,
disks: stats.disks || [],
netRxSpeed: stats.netRxSpeed || 0,
netTxSpeed: stats.netTxSpeed || 0,
latencyMs: Number.isFinite(stats.latencyMs) ? Number(stats.latencyMs) : null,
netInterfaces: stats.netInterfaces || [],
lastUpdated: Date.now(),
};
}
function markServerStatsFailure(session: SharedServerStatsSession, message: string): void {
session.consecutiveFailures += 1;
updateServerStatsState(session, { error: message });
if (session.consecutiveFailures >= CONSECUTIVE_FAILURE_LIMIT) {
// Stop polling this session. This covers network devices that advertise
// OpenSSH but do not support the POSIX stats shell command.
session.givenUp = true;
clearServerStatsTimers(session);
}
}
async function fetchSharedServerStats(session: SharedServerStatsSession, force = false): Promise<void> {
if (!session.sessionId) return;
if (session.inflight && session.inflightGeneration === session.fetchGeneration) {
await session.inflight;
return;
}
if (session.givenUp && !force) return;
if (getActiveServerStatsClients(session).length === 0) return;
const bridge = netcattyBridge.get();
if (!bridge?.getServerStats) return;
if (force) {
session.givenUp = false;
session.consecutiveFailures = 0;
}
const generation = session.fetchGeneration;
session.inflightGeneration = generation;
updateServerStatsState(session, { isLoading: true, error: null });
const request = (async () => {
try {
const result = await bridge.getServerStats(session.sessionId);
// Discard stale responses from before a polling restart or reconnect.
if (generation !== session.fetchGeneration) return;
if (result.pending) {
// Transient "not ready yet", e.g. a Mosh session whose SSH handshake
// has not finished. Do not count this toward the hard-failure cutoff.
return;
}
if (result.success && result.stats) {
session.hasFetched = true;
session.consecutiveFailures = 0;
updateServerStatsState(session, {
stats: normalizeServerStats(result.stats),
error: null,
});
} else if (result.error) {
markServerStatsFailure(session, result.error);
} else {
markServerStatsFailure(session, 'No stats returned');
}
} catch (err) {
if (generation === session.fetchGeneration) {
markServerStatsFailure(session, err instanceof Error ? err.message : 'Unknown error');
}
} finally {
if (generation === session.fetchGeneration) {
updateServerStatsState(session, { isLoading: false });
}
}
})();
session.inflight = request;
try {
await request;
} finally {
if (session.inflight === request) {
session.inflight = null;
session.inflightGeneration = null;
}
}
}
function reconcileSharedServerStatsSession(session: SharedServerStatsSession): void {
const activeClients = getActiveServerStatsClients(session);
if (activeClients.length === 0) {
resetServerStatsSession(session);
return;
}
if (session.connectedAt === 0) {
session.connectedAt = Date.now();
}
const intervalMs = Math.max(5, Math.min(...activeClients.map((client) => client.refreshInterval))) * 1000;
const shouldRestartPolling = !session.pollingActive || session.pollIntervalMs !== intervalMs;
if (!shouldRestartPolling) return;
if (session.givenUp) return;
clearServerStatsTimers(session);
session.fetchGeneration += 1;
session.pollingActive = true;
session.pollIntervalMs = intervalMs;
const connectionAge = Date.now() - session.connectedAt;
const needsWarmup = !session.hasFetched && connectionAge < 2000;
session.initialTimerRef = setTimeout(() => {
session.initialTimerRef = null;
void fetchSharedServerStats(session);
}, needsWarmup ? 2000 : 0);
session.intervalRef = setInterval(() => {
void fetchSharedServerStats(session);
}, intervalMs);
}
function maybeDisposeSharedServerStatsSession(session: SharedServerStatsSession): void {
if (session.clients.size > 0 || session.listeners.size > 0) return;
clearServerStatsTimers(session);
session.fetchGeneration += 1;
serverStatsSessions.delete(session.sessionId);
}
export function useServerStats({
sessionId,
enabled,
refreshInterval,
isSupportedOs,
isConnected,
}: UseServerStatsOptions) {
const clientIdRef = useRef<number | null>(null);
if (clientIdRef.current === null) {
clientIdRef.current = nextClientId;
nextClientId += 1;
}
const [state, setState] = useState<ServerStatsState>(() => getSharedServerStatsSession(sessionId).state);
useEffect(() => {
const session = getSharedServerStatsSession(sessionId);
const clientId = clientIdRef.current;
if (clientId === null) return;
const listener: ServerStatsListener = (nextState) => {
setState(nextState);
};
session.listeners.add(listener);
setState(session.state);
return () => {
session.listeners.delete(listener);
session.clients.delete(clientId);
reconcileSharedServerStatsSession(session);
maybeDisposeSharedServerStatsSession(session);
};
}, [sessionId]);
useEffect(() => {
const session = getSharedServerStatsSession(sessionId);
const clientId = clientIdRef.current;
if (clientId === null) return;
session.clients.set(clientId, {
enabled,
refreshInterval,
isSupportedOs,
isConnected,
});
setState(session.state);
reconcileSharedServerStatsSession(session);
}, [sessionId, enabled, refreshInterval, isSupportedOs, isConnected]);
const refresh = useCallback(() => {
const session = getSharedServerStatsSession(sessionId);
void fetchSharedServerStats(session, true).finally(() => {
reconcileSharedServerStatsSession(session);
});
}, [sessionId]);
return {
stats: state.stats,
isLoading: state.isLoading,
error: state.error,
refresh,
};
}
+19 -8
View File
@@ -542,6 +542,7 @@ export const useSessionState = ({
workspaces: workspacesRef.current,
sessionIds,
currentActiveTabId: activeTabStore.getActiveTabId(),
preferredTabId: activeTabStore.getPreviousActiveTabId(),
tabOrder: tabOrderRef.current,
});
@@ -549,9 +550,10 @@ export const useSessionState = ({
setSessions(result.sessions);
setTabOrder(result.tabOrder);
if (result.activeTabId) {
setActiveTabId(result.activeTabId);
// Closing must not rewrite previous-tab history to the closed tab.
activeTabStore.setActiveTabId(result.activeTabId, { recordPrevious: false });
}
}, [setActiveTabId]);
}, []);
const closeSession = useCallback((sessionId: string, e?: MouseEvent) => {
e?.stopPropagation();
@@ -619,17 +621,26 @@ export const useSessionState = ({
});
}, []);
const submitWorkspaceRename = useCallback(() => {
const submitWorkspaceRename = useCallback((workspaceId?: string, name?: string) => {
if (workspaceId !== undefined && name !== undefined) {
const trimmed = name.trim();
if (!trimmed) return;
setWorkspaces(prev => prev.map(w => (
w.id === workspaceId ? { ...w, title: trimmed, autoTitle: false } : w
)));
return;
}
setWorkspaceRenameValue(prevValue => {
const name = prevValue.trim();
if (!name) return prevValue;
const trimmed = prevValue.trim();
if (!trimmed) return prevValue;
setWorkspaceRenameTarget(prevTarget => {
if (!prevTarget) return prevTarget;
setWorkspaces(prev => prev.map(w => w.id === prevTarget.id ? { ...w, title: name, autoTitle: false } : w));
setWorkspaces(prev => prev.map(w => w.id === prevTarget.id ? { ...w, title: trimmed, autoTitle: false } : w));
return null;
});
return '';
});
}, []);
+178 -13
View File
@@ -1,6 +1,15 @@
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, type SetStateAction } from 'react';
import { runThemeTransition, type ThemeTransitionMode } from './themeTransition';
import { publishAppearanceChromeSnapshot } from './appearanceChromeStore';
import {
publishSettingsChromeSnapshot,
registerSettingsChromeActions,
} from './settingsChromeStore';
import {
publishTerminalSettingsSnapshot,
registerTerminalSettingsActions,
} from './terminalSettingsStore';
import { SyncConfig, TerminalSettings, HotkeyScheme, CustomKeyBindings, DEFAULT_KEY_BINDINGS, KeyBinding, UILanguage, SessionLogFormat, normalizeTerminalSettings } from '../../domain/models';
import {
DEFAULT_HTTP_NETWORK_PROXY,
@@ -35,6 +44,7 @@ import {
STORAGE_KEY_SFTP_AUTO_OPEN_SIDEBAR,
STORAGE_KEY_SFTP_FOLLOW_TERMINAL_CWD,
STORAGE_KEY_SFTP_TRANSFER_CONCURRENCY,
STORAGE_KEY_SFTP_SKIP_UNCHANGED,
STORAGE_KEY_SSH_TRANSPORT_IDLE_TTL_MS,
STORAGE_KEY_SFTP_TRANSFER_POOL_IDLE_TTL_MS,
STORAGE_KEY_SFTP_DEFAULT_VIEW_MODE,
@@ -84,7 +94,10 @@ import { DEFAULT_UI_FONT_ID, withWindowsEmojiFallback } from '../../infrastructu
import { uiFontStore, useUIFontsLoaded } from './uiFontStore';
import { localStorageAdapter } from '../../infrastructure/persistence/localStorageAdapter';
import { netcattyBridge } from '../../infrastructure/services/netcattyBridge';
import { resolveSftpTransferConcurrency } from './sftp/transferConcurrency';
import {
resolveSftpTransferConcurrency,
resolveSftpSkipUnchangedEnabled,
} from './sftp/transferConcurrency';
import { resolveSshTransportIdleTtlMs } from '../../infrastructure/config/sshTransportIdleTtl';
import {
DEFAULT_ACCENT_MODE,
@@ -420,6 +433,9 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
// Folder transfer concurrency is renderer-only (runSftpTransferWorkers).
// Do not push it into main-process host admission - that made multi-select
// top-level files queue against each other and against folder children.
const [sftpSkipUnchanged, setSftpSkipUnchanged] = useState<boolean>(() =>
resolveSftpSkipUnchangedEnabled(() => localStorageAdapter.readBoolean(STORAGE_KEY_SFTP_SKIP_UNCHANGED)),
);
const [sshTransportIdleTtlMs, setSshTransportIdleTtlMsState] = useState<number>(() => {
// Prefer the new SSH transport key; if absent (upgrade), migrate the legacy
@@ -740,7 +756,10 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
// Push idle TTL to the main-process transport registry on load and whenever
// it changes (including cross-window IPC/storage updates to local state).
const lastPushedSshTransportIdleTtlRef = useRef<number | null>(null);
useEffect(() => {
if (lastPushedSshTransportIdleTtlRef.current === sshTransportIdleTtlMs) return;
lastPushedSshTransportIdleTtlRef.current = sshTransportIdleTtlMs;
notifySettingsChanged(STORAGE_KEY_SSH_TRANSPORT_IDLE_TTL_MS, sshTransportIdleTtlMs);
}, [sshTransportIdleTtlMs, notifySettingsChanged]);
@@ -890,6 +909,8 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
if (storedHidden === 'true' || storedHidden === 'false') setSftpShowHiddenFiles(storedHidden === 'true');
const storedCompress = readStoredString(STORAGE_KEY_SFTP_USE_COMPRESSED_UPLOAD);
if (storedCompress === 'true' || storedCompress === 'false') setSftpUseCompressedUpload(storedCompress === 'true');
const storedSkipUnchanged = localStorageAdapter.readBoolean(STORAGE_KEY_SFTP_SKIP_UNCHANGED);
if (storedSkipUnchanged != null) setSftpSkipUnchanged(storedSkipUnchanged);
const storedAutoOpenSidebar = readStoredString(STORAGE_KEY_SFTP_AUTO_OPEN_SIDEBAR);
if (storedAutoOpenSidebar === 'true' || storedAutoOpenSidebar === 'false') setSftpAutoOpenSidebar(storedAutoOpenSidebar === 'true');
const storedFollowTerminalCwd = readStoredString(STORAGE_KEY_SFTP_FOLLOW_TERMINAL_CWD);
@@ -958,6 +979,8 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
const persistedAppearanceChanged = previousAppearance === null
|| hasPersistedAppearanceChanged(previousAppearance, appearanceRender);
previousAppearanceRenderRef.current = appearanceRender;
// Terminal leaves subscribe here so accent drag does not rebuild TerminalLayer.
publishAppearanceChromeSnapshot({ accentMode, customAccent });
const tokens = getUiThemeById(resolvedTheme, resolvedTheme === 'dark' ? darkUiThemeId : lightUiThemeId).tokens;
const apply = () => applyThemeTokens(theme, resolvedTheme, tokens, accentMode, customAccent);
const transitionMode = appearanceTransitionModeRef.current;
@@ -1121,7 +1144,7 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
customCSS, uiFontFamilyId, hotkeyScheme, uiLanguage,
terminalThemeId, followAppTerminalTheme, terminalFontFamilyId, terminalFontSize,
sftpDoubleClickBehavior, sftpAutoSync, sftpShowHiddenFiles,
sftpUseCompressedUpload, sftpAutoOpenSidebar, sftpFollowTerminalCwd, sftpDefaultViewMode,
sftpUseCompressedUpload, sftpSkipUnchanged, sftpAutoOpenSidebar, sftpFollowTerminalCwd, sftpDefaultViewMode,
showRecentHosts, hostClickBehavior, showOnlyUngroupedHostsInRoot, showSftpTab, showHostTreeSidebar, terminalSidePanelAutoOpen, terminalSidePanelAutoOpenTab, shellOnlyTabNumberShortcuts, disableTerminalFontZoom, restorePreviousSession, restoreTerminalCwd,
editorWordWrap, sessionLogsEnabled, sessionLogsDir, sessionLogsFormat, sessionLogsTimestampsEnabled, sshDebugLogsEnabled, sshDeepLinkEnabled, jmsDeepLinkEnabled, explorerContextMenuEnabled,
globalHotkeyEnabled, autoUpdateEnabled, windowOpacity, appIconVariant,
@@ -1131,7 +1154,7 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
setTerminalThemeId, setTerminalThemeDarkId, setTerminalThemeLightId,
setFollowAppTerminalThemeState, setTerminalFontFamilyId, setTerminalFontSize: applyIncomingTerminalFontSize,
setSftpDoubleClickBehavior, setSftpAutoSync, setSftpShowHiddenFiles,
setSftpUseCompressedUpload, setSftpAutoOpenSidebar, setSftpFollowTerminalCwd, setSftpDefaultViewMode,
setSftpUseCompressedUpload, setSftpSkipUnchanged, setSftpAutoOpenSidebar, setSftpFollowTerminalCwd, setSftpDefaultViewMode,
setShowRecentHostsState, setHostClickBehaviorState, setShowOnlyUngroupedHostsInRootState, setShowSftpTabState, setShowHostTreeSidebarState, setTerminalSidePanelAutoOpenState, setTerminalSidePanelAutoOpenTabState, setShellOnlyTabNumberShortcutsState, setDisableTerminalFontZoomState, setRestorePreviousSessionState, setRestoreTerminalCwdState,
setEditorWordWrapState, setSessionLogsEnabled, setSessionLogsDir, setSessionLogsFormat, setSessionLogsTimestampsEnabled, setSshDebugLogsEnabled, setSshDeepLinkEnabledState: applyIncomingSshDeepLinkEnabled, setJmsDeepLinkEnabledState: applyIncomingJmsDeepLinkEnabled, setExplorerContextMenuEnabledState: applyIncomingExplorerContextMenuEnabled,
setGlobalHotkeyEnabled, setWindowOpacity: applyIncomingWindowOpacity, setAppIconVariant, setAutoUpdateEnabled, setWorkspaceFocusStyleState,
@@ -1359,6 +1382,12 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
notifySettingsChanged(STORAGE_KEY_SFTP_USE_COMPRESSED_UPLOAD, sftpUseCompressedUpload);
}, [sftpUseCompressedUpload, notifySettingsChanged]);
useEffect(() => {
localStorageAdapter.writeBoolean(STORAGE_KEY_SFTP_SKIP_UNCHANGED, sftpSkipUnchanged);
if (!persistMountedRef.current) return;
notifySettingsChanged(STORAGE_KEY_SFTP_SKIP_UNCHANGED, sftpSkipUnchanged);
}, [sftpSkipUnchanged, notifySettingsChanged]);
// Persist SFTP auto-open sidebar setting
useEffect(() => {
localStorageAdapter.writeString(STORAGE_KEY_SFTP_AUTO_OPEN_SIDEBAR, sftpAutoOpenSidebar ? 'true' : 'false');
@@ -1616,11 +1645,13 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
notifySettingsChanged,
});
// Fix 1: Mark all persist effects as mounted.
// This MUST be declared AFTER all persist useEffects so that React runs it last
// during the initial mount cycle (effects fire in declaration order).
// Mark persist effects mounted AFTER all persist useEffects so React runs
// this last (declaration order). Cleanup clears the latch for remount.
useEffect(() => {
persistMountedRef.current = true;
return () => {
persistMountedRef.current = false;
};
}, []);
// Get merged key bindings (defaults + custom overrides)
@@ -1691,6 +1722,142 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
applyThemeTokens(theme, resolvedTheme, tokens, accentMode, customAccent);
}, [theme, resolvedTheme, lightUiThemeId, darkUiThemeId, accentMode, customAccent]);
const setEditorWordWrap = useCallback((enabled: boolean) => {
setEditorWordWrapState(enabled);
localStorageAdapter.writeString(STORAGE_KEY_EDITOR_WORD_WRAP, String(enabled));
notifySettingsChanged(STORAGE_KEY_EDITOR_WORD_WRAP, enabled);
}, [notifySettingsChanged]);
// Main-window chrome subscribes to this slice instead of receiving the whole
// settings object through the App domain bags. The store dedupes field by
// field, so unrelated settings churn never notifies chrome consumers.
useLayoutEffect(() => {
publishSettingsChromeSnapshot({
theme,
resolvedTheme,
lightUiThemeId,
darkUiThemeId,
uiLanguage,
windowOpacity,
showSftpTab,
showHostTreeSidebar,
showRecentHosts,
hostClickBehavior,
showOnlyUngroupedHostsInRoot,
dynamicTabTitleMode: terminalSettings.dynamicTabTitleMode,
disableTerminalFontZoom,
restoreTerminalCwd,
terminalSidePanelAutoOpen,
terminalSidePanelAutoOpenTab,
});
}, [
darkUiThemeId,
disableTerminalFontZoom,
hostClickBehavior,
lightUiThemeId,
resolvedTheme,
restoreTerminalCwd,
showHostTreeSidebar,
showOnlyUngroupedHostsInRoot,
showRecentHosts,
showSftpTab,
terminalSettings.dynamicTabTitleMode,
terminalSidePanelAutoOpen,
terminalSidePanelAutoOpenTab,
theme,
uiLanguage,
windowOpacity,
]);
useLayoutEffect(() => {
registerSettingsChromeActions({ setTheme, setWindowOpacity });
return () => {
registerSettingsChromeActions(null);
};
}, [setTheme, setWindowOpacity]);
// TerminalHost / terminal domain bags subscribe here instead of receiving
// settings through the App mega-subscriber.
useLayoutEffect(() => {
publishTerminalSettingsSnapshot({
terminalThemeId,
terminalThemeDarkId,
terminalThemeLightId,
followAppTerminalTheme,
terminalFontFamilyId,
terminalFontSize,
terminalSettings,
hotkeyScheme,
keyBindings,
isHotkeyRecording,
sftpDoubleClickBehavior,
sftpAutoSync,
sftpShowHiddenFiles,
sftpUseCompressedUpload,
sftpAutoOpenSidebar,
sftpFollowTerminalCwd,
sftpDefaultViewMode,
editorWordWrap,
sessionLogsEnabled,
sessionLogsDir,
sessionLogsFormat,
sessionLogsTimestampsEnabled,
sshDebugLogsEnabled,
});
}, [
editorWordWrap,
followAppTerminalTheme,
hotkeyScheme,
isHotkeyRecording,
keyBindings,
sessionLogsDir,
sessionLogsEnabled,
sessionLogsFormat,
sessionLogsTimestampsEnabled,
sftpAutoOpenSidebar,
sftpAutoSync,
sftpDefaultViewMode,
sftpDoubleClickBehavior,
sftpFollowTerminalCwd,
sftpShowHiddenFiles,
sftpUseCompressedUpload,
sshDebugLogsEnabled,
terminalFontFamilyId,
terminalFontSize,
terminalSettings,
terminalThemeDarkId,
terminalThemeId,
terminalThemeLightId,
]);
useLayoutEffect(() => {
registerTerminalSettingsActions({
setTerminalThemeId,
setTerminalThemeDarkId,
setTerminalThemeLightId,
setFollowAppTerminalTheme: setFollowAppTerminalThemeState,
setTerminalFontFamilyId,
setTerminalFontSize,
updateTerminalSetting,
setSftpFollowTerminalCwd,
setEditorWordWrap,
applyAppTheme,
});
return () => {
registerTerminalSettingsActions(null);
};
}, [
applyAppTheme,
setEditorWordWrap,
setSftpFollowTerminalCwd,
setTerminalFontFamilyId,
setTerminalFontSize,
setTerminalThemeDarkId,
setTerminalThemeId,
setTerminalThemeLightId,
updateTerminalSetting,
]);
return {
theme,
setTheme,
@@ -1744,6 +1911,8 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
setSftpShowHiddenFiles,
sftpUseCompressedUpload,
setSftpUseCompressedUpload,
sftpSkipUnchanged,
setSftpSkipUnchanged,
sftpAutoOpenSidebar,
setSftpAutoOpenSidebar,
sftpFollowTerminalCwd,
@@ -1778,11 +1947,7 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
setSshTransportIdleTtlMs,
// Editor Settings
editorWordWrap,
setEditorWordWrap: useCallback((enabled: boolean) => {
setEditorWordWrapState(enabled);
localStorageAdapter.writeString(STORAGE_KEY_EDITOR_WORD_WRAP, String(enabled));
notifySettingsChanged(STORAGE_KEY_EDITOR_WORD_WRAP, enabled);
}, [notifySettingsChanged]),
setEditorWordWrap,
// Session Logs
sessionLogsEnabled,
setSessionLogsEnabled,
@@ -1824,11 +1989,11 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
// Opaque version that changes when any synced setting changes, used by useAutoSync.
// eslint-disable-next-line react-hooks/exhaustive-deps
settingsVersion: useMemo(() => Math.random(), [
theme, lightUiThemeId, darkUiThemeId, accentMode, customAccent,
theme, lightUiThemeId, darkUiThemeId, accentMode,
uiFontFamilyId, uiLanguage, customCSS,
terminalThemeId, terminalFontFamilyId, terminalFontSize, terminalSettings,
customKeyBindings, editorWordWrap,
sftpDoubleClickBehavior, sftpAutoSync, sftpShowHiddenFiles, sftpUseCompressedUpload, sftpAutoOpenSidebar, sftpFollowTerminalCwd, sftpDefaultViewMode,
sftpDoubleClickBehavior, sftpAutoSync, sftpShowHiddenFiles, sftpUseCompressedUpload, sftpSkipUnchanged, sftpAutoOpenSidebar, sftpFollowTerminalCwd, sftpDefaultViewMode,
showRecentHosts, hostClickBehavior, showOnlyUngroupedHostsInRoot, showSftpTab, showHostTreeSidebar, terminalSidePanelAutoOpen, terminalSidePanelAutoOpenTab, shellOnlyTabNumberShortcuts, disableTerminalFontZoom,
customThemes, workspaceFocusStyle, sessionLogsTimestampsEnabled, sshDebugLogsEnabled, sshDeepLinkEnabled, jmsDeepLinkEnabled, explorerContextMenuEnabled,
]),
-11
View File
@@ -399,14 +399,6 @@ export const useSftpState = (
[hosts, identities, keys, openPoolSftpSession, transferKnownHosts, transferPoolKeyCache, transferTerminalSettings],
);
/**
* @deprecated No-op. SSH transport idle park keeps connections warm; opening
* a background transfer channel is unnecessary and could re-trigger MFA.
*/
const warmTransferPoolForHost = useCallback(async (_hostId: string) => {
// Intentionally empty — unified transport registry owns keep-alive.
}, []);
/** True after browse channels were soft-closed while this owner stayed mounted. */
const browseParkedRef = useRef(false);
const browseLifecycleGenRef = useRef(0);
@@ -824,7 +816,6 @@ export const useSftpState = (
rejectHostKeyVerification,
acceptHostKeyVerification,
acceptAndSaveHostKeyVerification,
warmTransferPoolForHost,
};
const methodsRef = useRef(currentMethods);
methodsRef.current = currentMethods;
@@ -907,8 +898,6 @@ export const useSftpState = (
rejectHostKeyVerification: () => methodsRef.current.rejectHostKeyVerification(),
acceptHostKeyVerification: () => methodsRef.current.acceptHostKeyVerification(),
acceptAndSaveHostKeyVerification: () => methodsRef.current.acceptAndSaveHostKeyVerification(),
warmTransferPoolForHost: (...args: Parameters<typeof warmTransferPoolForHost>) =>
methodsRef.current.warmTransferPoolForHost(...args),
activeFileWatchCountRef,
}), [activeFileWatchCountRef]); // activeFileWatchCountRef is a stable ref
+326
View File
@@ -0,0 +1,326 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { useI18n } from '../i18n/I18nProvider';
import type { I18nContextValue } from '../i18n/I18nProvider';
import { sessionCapabilitiesStore } from './sessionCapabilitiesStore';
import type { SessionCapabilities } from '../../domain/systemManager/types';
import type { useSystemManagerBackend } from './useSystemManagerBackend';
import { nextPollData } from '../../domain/systemManager/pollListStable';
type Backend = ReturnType<typeof useSystemManagerBackend>;
function delay(ms: number): Promise<void> {
return new Promise((resolve) => window.setTimeout(resolve, ms));
}
function normalizePollingErrorMessage(error: unknown, t: I18nContextValue['t']): string {
const message = error instanceof Error ? error.message : String(error || 'Unknown error');
const lower = message.toLowerCase();
if (lower.includes('channel open failure') || lower.includes('unable to exec')) {
return t('systemManager.errors.sshChannelUnavailable');
}
return message;
}
/** Stable i18n ref so polling fetchers do not reset when locale re-renders. */
export function useStableTranslate(): I18nContextValue['t'] {
const { t } = useI18n();
const tRef = useRef(t);
tRef.current = t;
return useCallback(
(key, values) => tRef.current(key, values),
[],
);
}
export function useSessionCapabilities(
sessionId: string | null,
isConnected: boolean,
backend: Backend,
enabled: boolean,
capabilitiesTtlMs: number,
) {
const ttlMsRef = useRef(capabilitiesTtlMs);
ttlMsRef.current = capabilitiesTtlMs;
const [capabilities, setCapabilities] = useState<SessionCapabilities | undefined>(
() => (sessionId ? sessionCapabilitiesStore.get(sessionId) : undefined),
);
const [probing, setProbing] = useState(false);
useEffect(() => {
if (!sessionId) return undefined;
return sessionCapabilitiesStore.subscribe(sessionId, () => {
setCapabilities(sessionCapabilitiesStore.get(sessionId));
});
}, [sessionId]);
useEffect(() => {
if (!sessionId || isConnected) return undefined;
sessionCapabilitiesStore.delete(sessionId);
return undefined;
}, [sessionId, isConnected]);
const probe = useCallback(async (force = false) => {
if (!sessionId || !isConnected) return;
if (!force && sessionCapabilitiesStore.get(sessionId)) return;
setProbing(true);
try {
const result = await backend.probeSystemCapabilities(sessionId);
if (result.success && result.capabilities) {
sessionCapabilitiesStore.set(sessionId, result.capabilities, ttlMsRef.current);
}
} finally {
setProbing(false);
}
}, [backend, isConnected, sessionId]);
useEffect(() => {
if (!sessionId || !isConnected || !enabled) return undefined;
void probe();
return undefined;
}, [enabled, sessionId, isConnected, probe]);
return { capabilities, probing, refreshCapabilities: () => probe(true) };
}
/** Prefetch capabilities only for the given session ids (e.g. when System panel opens). */
export function useSystemCapabilitiesWarmup(
sessionIds: string[],
backend: Backend,
enabled: boolean,
capabilitiesTtlMs: number,
) {
const backendRef = useRef(backend);
backendRef.current = backend;
const inflightRef = useRef(new Set<string>());
const ttlMsRef = useRef(capabilitiesTtlMs);
ttlMsRef.current = capabilitiesTtlMs;
const sessionKey = enabled ? sessionIds.slice().sort().join(',') : '';
useEffect(() => {
if (!sessionKey) return undefined;
for (const sessionId of sessionKey.split(',')) {
if (!sessionId || sessionCapabilitiesStore.get(sessionId)) continue;
if (inflightRef.current.has(sessionId)) continue;
inflightRef.current.add(sessionId);
void backendRef.current.probeSystemCapabilities(sessionId).then((result) => {
inflightRef.current.delete(sessionId);
if (result.success && result.capabilities) {
sessionCapabilitiesStore.set(sessionId, result.capabilities, ttlMsRef.current);
}
});
}
return undefined;
}, [sessionKey]);
}
export function usePolling<T>(
fetcher: () => Promise<T | null>,
intervalMs: number,
enabled: boolean,
merge?: (prev: T | null, next: T) => T,
options?: { poll?: boolean; resetKey?: string },
) {
const stableT = useStableTranslate();
const resetKey = options?.resetKey ?? '';
const [data, setData] = useState<T | null>(null);
const [dataKey, setDataKey] = useState(resetKey);
const [error, setError] = useState<string | null>(null);
const [errorKey, setErrorKey] = useState(resetKey);
const [loading, setLoading] = useState(false);
const [loadingKey, setLoadingKey] = useState(resetKey);
const failuresRef = useRef(0);
const hasDataRef = useRef(false);
const enabledRef = useRef(enabled);
const generationRef = useRef(0);
const runIdRef = useRef(0);
const loadingRunIdRef = useRef(0);
const inflightRef = useRef<{ generation: number; runId: number } | null>(null);
const queuedRunRef = useRef<{
options?: { withLoading?: boolean; minLoadingMs?: number };
resolve: () => void;
} | null>(null);
const fetcherRef = useRef(fetcher);
const mergeRef = useRef(merge);
const pollRef = useRef(options?.poll ?? true);
const resetKeyRef = useRef(resetKey);
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
enabledRef.current = enabled;
fetcherRef.current = fetcher;
mergeRef.current = merge;
pollRef.current = options?.poll ?? true;
const clearPollTimer = useCallback(() => {
if (timerRef.current !== null) {
clearTimeout(timerRef.current);
timerRef.current = null;
}
}, []);
const pollDelayMs = useCallback(() => {
if (failuresRef.current >= 3) return intervalMs * 4;
return intervalMs;
}, [intervalMs]);
const resolveQueuedRun = useCallback(() => {
queuedRunRef.current?.resolve();
queuedRunRef.current = null;
}, []);
const run = useCallback(async (options?: { withLoading?: boolean; minLoadingMs?: number }) => {
const generation = generationRef.current;
const runResetKey = resetKeyRef.current;
if (!enabledRef.current) return;
if (inflightRef.current?.generation === generation) {
if (options?.withLoading) {
queuedRunRef.current?.resolve();
loadingRunIdRef.current = 0;
setLoadingKey(runResetKey);
setLoading(true);
return new Promise<void>((resolve) => {
queuedRunRef.current = { options, resolve };
});
}
return;
}
const runId = ++runIdRef.current;
inflightRef.current = { generation, runId };
const showLoading = options?.withLoading ?? !hasDataRef.current;
const startedAt = Date.now();
const isCurrent = () => (
generationRef.current === generation
&& enabledRef.current
&& inflightRef.current?.runId === runId
&& resetKeyRef.current === runResetKey
);
if (showLoading) {
loadingRunIdRef.current = runId;
setLoadingKey(runResetKey);
setLoading(true);
}
try {
const result = await fetcherRef.current();
if (!isCurrent()) return;
if (result !== null) {
setDataKey(runResetKey);
setData((prev) => {
const mergeFn = mergeRef.current;
const next = mergeFn ? mergeFn(prev, result) : nextPollData(prev, result);
if (next !== prev) hasDataRef.current = true;
return next;
});
setErrorKey(runResetKey);
setError(null);
failuresRef.current = 0;
}
} catch (err) {
if (!isCurrent()) return;
failuresRef.current += 1;
setDataKey(runResetKey);
setData(null);
hasDataRef.current = false;
setErrorKey(runResetKey);
setError(normalizePollingErrorMessage(err, stableT));
} finally {
if (inflightRef.current?.runId === runId) {
inflightRef.current = null;
}
if (showLoading) {
const remaining = Math.max(0, (options?.minLoadingMs ?? 0) - (Date.now() - startedAt));
if (remaining > 0) await delay(remaining);
if (
generationRef.current === generation
&& enabledRef.current
&& resetKeyRef.current === runResetKey
&& loadingRunIdRef.current === runId
) {
loadingRunIdRef.current = 0;
setLoadingKey(runResetKey);
setLoading(false);
}
}
const queued = queuedRunRef.current;
if (
queued
&& generationRef.current === generation
&& enabledRef.current
&& resetKeyRef.current === runResetKey
) {
queuedRunRef.current = null;
await run(queued.options);
queued.resolve();
}
}
}, [stableT]);
const scheduleNextPoll = useCallback(() => {
clearPollTimer();
if (!enabledRef.current || !pollRef.current) return;
const generation = generationRef.current;
timerRef.current = setTimeout(() => {
void run({ withLoading: false }).finally(() => {
if (generationRef.current === generation) {
scheduleNextPoll();
}
});
}, pollDelayMs());
}, [clearPollTimer, pollDelayMs, run]);
useEffect(() => {
const resetChanged = resetKeyRef.current !== resetKey;
resetKeyRef.current = resetKey;
generationRef.current += 1;
inflightRef.current = null;
clearPollTimer();
if (!enabled) {
resolveQueuedRun();
loadingRunIdRef.current = 0;
setLoading(false);
setLoadingKey(resetKey);
setDataKey(resetKey);
setData(null);
setErrorKey(resetKey);
setError(null);
failuresRef.current = 0;
hasDataRef.current = false;
return undefined;
}
if (resetChanged) {
resolveQueuedRun();
loadingRunIdRef.current = 0;
setLoading(false);
setLoadingKey(resetKey);
setDataKey(resetKey);
setData(null);
setErrorKey(resetKey);
setError(null);
failuresRef.current = 0;
hasDataRef.current = false;
}
const generation = generationRef.current;
void run({ withLoading: true }).finally(() => {
if (generationRef.current === generation && pollRef.current) scheduleNextPoll();
});
return () => {
generationRef.current += 1;
resolveQueuedRun();
loadingRunIdRef.current = 0;
inflightRef.current = null;
clearPollTimer();
};
}, [clearPollTimer, enabled, intervalMs, options?.poll, resetKey, resolveQueuedRun, run, scheduleNextPoll]);
const refresh = useCallback(async () => {
failuresRef.current = 0;
await run({ withLoading: true, minLoadingMs: 450 });
}, [run]);
return {
data: dataKey === resetKey ? data : null,
error: errorKey === resetKey ? error : null,
loading: loadingKey === resetKey ? loading : enabled,
refresh,
};
}
@@ -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("./useTerminalAiContexts.ts", import.meta.url), "utf8");
test("useTerminalAiContexts imports AI context primitives from domain", () => {
assert.match(source, /from ['"]\.\.\/\.\.\/domain\/buildAITerminalSessionInfo['"]/);
assert.doesNotMatch(source, /from ['"].*components\//);
});
+146
View File
@@ -0,0 +1,146 @@
import type React from 'react';
import { useCallback, useMemo, useRef } from 'react';
import { retainStableAiPanelContexts } from '../../domain/aiPanelContextsEqual';
import {
buildAITerminalSessionInfo,
type AIPanelContext,
} from '../../domain/buildAITerminalSessionInfo';
import { collectSessionIds } from '../../domain/workspace';
import type { TerminalContextReader } from '../../domain/terminalContextRead';
import { detectLocalOs } from '../../lib/localShell';
import type { Host, PortForwardingRule, TerminalSession, Workspace } from '../../types';
interface UseTerminalAiContextsOptions {
hosts: Host[];
hostsRef: React.MutableRefObject<Host[]>;
portForwardingRules: PortForwardingRule[];
portForwardingRulesRef: React.MutableRefObject<PortForwardingRule[]>;
mountedAiTabIds: string[];
sessionHostsMap: Map<string, Host>;
sessions: TerminalSession[];
sessionsRef: React.MutableRefObject<TerminalSession[]>;
terminalContextReadersRef: React.MutableRefObject<Map<string, TerminalContextReader>>;
workspaces: Workspace[];
workspacesRef: React.MutableRefObject<Workspace[]>;
}
export function useTerminalAiContexts({
hosts,
hostsRef,
portForwardingRules,
portForwardingRulesRef,
mountedAiTabIds,
sessionHostsMap,
sessions,
sessionsRef,
terminalContextReadersRef,
workspaces,
workspacesRef,
}: UseTerminalAiContextsOptions) {
const previousAiContextsRef = useRef<Map<string, AIPanelContext> | null>(null);
const aiContextsByTabId = useMemo(() => {
const localOs = detectLocalOs(navigator.userAgent || navigator.platform);
const sessionById = new Map<string, TerminalSession>(sessions.map((session) => [session.id, session]));
const workspaceById = new Map<string, Workspace>(workspaces.map((workspace) => [workspace.id, workspace]));
const allHosts = hosts;
const tabIds = new Set<string>(mountedAiTabIds);
const contexts = new Map<string, AIPanelContext>();
for (const tabId of tabIds) {
const workspace = workspaceById.get(tabId);
if (workspace) {
const sessionIds = collectSessionIds(workspace.root);
contexts.set(tabId, {
scopeType: 'workspace',
scopeTargetId: workspace.id,
scopeHostIds: sessionIds
.map((sessionId) => sessionById.get(sessionId)?.hostId)
.filter((hostId): hostId is string => !!hostId),
scopeLabel: workspace.title,
terminalSessions: sessionIds.map((sessionId) =>
buildAITerminalSessionInfo(
sessionById.get(sessionId),
sessionHostsMap.get(sessionId),
localOs,
{ allHosts, portForwardingRules },
),
),
});
continue;
}
const session = sessionById.get(tabId);
if (!session) continue;
contexts.set(tabId, {
scopeType: 'terminal',
scopeTargetId: session.id,
scopeHostIds: session.hostId ? [session.hostId] : [],
scopeLabel: session.hostLabel ?? '',
terminalSessions: [
buildAITerminalSessionInfo(
session,
sessionHostsMap.get(session.id),
localOs,
{ allHosts, portForwardingRules },
),
],
});
}
const retained = retainStableAiPanelContexts(previousAiContextsRef.current, contexts);
previousAiContextsRef.current = retained;
return retained;
}, [sessions, workspaces, mountedAiTabIds, sessionHostsMap, hosts, portForwardingRules]);
const resolveAIExecutorContext = useCallback((scope: {
type: 'terminal' | 'workspace';
targetId?: string;
label?: string;
}) => {
const latestWorkspaces = workspacesRef.current;
const latestSessions = sessionsRef.current;
const latestHosts = hostsRef.current;
const localOs = detectLocalOs(navigator.userAgent || navigator.platform);
const sessionIds = scope.type === 'workspace'
? (() => {
const workspace = scope.targetId ? latestWorkspaces.find((w) => w.id === scope.targetId) : undefined;
return workspace?.root ? collectSessionIds(workspace.root) : [];
})()
: scope.targetId ? [scope.targetId] : [];
const workspaceName = scope.type === 'workspace'
? latestWorkspaces.find((w) => w.id === scope.targetId)?.title ?? scope.label
: undefined;
return {
sessions: sessionIds.map((sid) => {
const session = latestSessions.find((s) => s.id === sid);
const host = session ? sessionHostsMap.get(session.id) : undefined;
return buildAITerminalSessionInfo(session, host, localOs, {
allHosts: latestHosts,
portForwardingRules: portForwardingRulesRef.current,
});
}),
workspaceId: scope.type === 'workspace' ? scope.targetId : undefined,
workspaceName,
readTerminalContext: (request: Parameters<TerminalContextReader>[0]) => {
const reader = terminalContextReadersRef.current.get(request.sessionId);
if (!reader) {
return Promise.resolve({
ok: false as const,
error: `Terminal session "${request.sessionId}" has no readable terminal buffer.`,
});
}
return reader(request);
},
};
}, [hostsRef, portForwardingRulesRef, sessionHostsMap, sessionsRef, terminalContextReadersRef, workspacesRef]);
return {
aiContextsByTabId,
resolveAIExecutorContext,
};
}
Loaded 100 of 313 files, more files were not shown because too many files have changed in this diff. Show more