Compare commits

...
Author SHA1 Message Date
Cursor Agentand陈大猫 daa1d50d82 fix(system-manager): shrink port collector output and filter Windows UDP
Raise ports maxBuffer to 16MB, grep LISTEN on full netstat dumps, and
drop Windows ephemeral UDP binds on non-wildcard addresses.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-08-08 02:49:51 +00:00
Cursor Agentand陈大猫 159e82d677 fix(system-manager): parse full netstat pid/program tail for UDP
Capture the remainder after the peer address so spaced program names
survive when the State column is omitted.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-08-08 02:30:30 +00:00
Cursor Agentand陈大猫 ac220dbacc fix(system-manager): parse BusyBox UDP pid when State is omitted
On netstat -lnup rows without a State column, the pid/program token is
captured as state; promote it so Terminate keeps a process identity.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-08-08 02:28:50 +00:00
Cursor Agentand陈大猫 5f76503d46 fix(system-manager): map macOS tcp46/udp46 to tcp6/udp6 for merge
Dual-stack netstat rows must share keys with lsof IPv6 listeners so
anonymous placeholders collapse instead of duplicating the port.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-08-08 02:26:17 +00:00
Cursor Agentand陈大猫 cba9e02c63 fix(system-manager): collect Linux UDP and macOS dual-stack ports
Prefer netstat -lntup (or -lntp/-lnup), drop the Linux lsof -iUDP dump
fallback, accept tcp46/udp46, expand multi-pid ss users tuples, and keep
systemd units with empty descriptions.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-08-08 02:22:49 +00:00
Cursor Agentand陈大猫 b631ee0ab6 fix(system-manager): close remaining Bugbot findings on ports
Collect macOS/Windows UDP listeners, keep SO_REUSEPORT rows distinct,
ignore late action results after session switches, and parse capability
flags with line-anchored markers.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-08-08 02:18:46 +00:00
Cursor Agentand陈大猫 6d5d7f6b27 fix(system-manager): treat kill non-zero exits and pending actions as failures
SignalProcess now fails on non-zero kill/Stop-Process codes and propagates
pending channel state. Ports/Services UI surfaces pending instead of a silent
refresh success, with regression coverage.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-08-08 02:14:14 +00:00
Cursor Agentand陈大猫 1400d1abc3 fix(system-manager): close Bugbot findings on ports/services
Clear confirm state on session switch, harden IPv6/ss UDP parsing, map
Windows terminate to Stop-Process (TERM/KILL only), propagate sudo pending,
and extend confirm-dialog regression coverage.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-08-08 02:03:40 +00:00
Cursor Agentand陈大猫 1f8659d057 fix(system-manager): tighten port parsers and pending-state races
Reject non-LISTEN TCP / connected UDP in netstat+lsof parsers, stop dumping
all UDP via lsof, and reset Ports/Services pending flags on session switch.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-08-08 01:47:02 +00:00
Cursor Agentand陈大猫 34813639c9 fix(system-manager): harden ports/services collectors and network gating
Merge ss/netstat/lsof collectors so macOS is not stuck on empty netstat-only
output, accept BusyBox UDP and macOS dotted addresses, fall back when
systemctl --plain is unsupported, normalize new capability flags, and keep
port/service mutations read-only on network appliances.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-08-08 01:26:20 +00:00
Cursor Agentand陈大猫 9973309a15 feat(system-manager): add Ports and Services tabs with capability gating
Extend the System side panel for #2561 follow-ups: listening ports (ss/netstat)
and systemd services (systemctl), using the same detect-first visibility model
as GPU. Network appliances hide guessed tabs until tools/OS are confirmed.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-08-08 01:20:18 +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
282 changed files with 17957 additions and 7214 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);
@@ -8,6 +8,8 @@ export const enSystemManagerMessages: Messages = {
'systemManager.empty': 'No data available.',
'systemManager.tabs.overview': 'Overview',
'systemManager.tabs.processes': 'Processes',
'systemManager.tabs.ports': 'Ports',
'systemManager.tabs.services': 'Services',
'systemManager.tabs.tmux': 'tmux',
'systemManager.tabs.docker': 'Docker',
'systemManager.tabs.gpu': 'GPU',
@@ -25,6 +27,8 @@ export const enSystemManagerMessages: Messages = {
'systemManager.errors.loadDockerImages': 'Failed to load images',
'systemManager.errors.loadOverview': 'Failed to load system overview',
'systemManager.errors.loadGpu': 'Failed to load GPU / NPU stats',
'systemManager.errors.loadPorts': 'Failed to load listening ports',
'systemManager.errors.loadServices': 'Failed to load systemd services',
'systemManager.errors.sshChannelUnavailable': 'The server refused to open a new execution channel. Try again later, or reconnect this host.',
'systemManager.overview.empty': 'No system overview data yet.',
@@ -189,6 +193,34 @@ export const enSystemManagerMessages: Messages = {
'systemManager.docker.start': 'Start',
'systemManager.docker.stop': 'Stop',
'systemManager.ports.unavailable': 'No listening-port tools (ss / netstat) detected on this host.',
'systemManager.ports.loading': 'Loading listening ports…',
'systemManager.ports.empty': 'No listening ports reported.',
'systemManager.ports.search': 'Search ports…',
'systemManager.ports.meta': '{{count}} listener(s)',
'systemManager.ports.filter.all': 'All',
'systemManager.ports.unknownProcess': 'Unknown process',
'systemManager.ports.terminate': 'Terminate',
'systemManager.ports.confirmTerminate': 'Send SIGTERM to process {{pid}} that holds this port?',
'systemManager.services.unavailable': 'systemctl is not available on this host.',
'systemManager.services.loading': 'Loading systemd services…',
'systemManager.services.empty': 'No systemd services found.',
'systemManager.services.search': 'Search services…',
'systemManager.services.meta': '{{count}} service(s)',
'systemManager.services.filter.all': 'All',
'systemManager.services.filter.running': 'Running',
'systemManager.services.filter.failed': 'Failed',
'systemManager.services.filter.inactive': 'Inactive',
'systemManager.services.start': 'Start',
'systemManager.services.stop': 'Stop',
'systemManager.services.restart': 'Restart',
'systemManager.services.enable': 'Enable',
'systemManager.services.disable': 'Disable',
'systemManager.services.reload': 'Reload',
'systemManager.services.scope.user': 'user',
'systemManager.services.confirmAction': '{{action}} {{name}}?',
'systemManager.gpu.unavailable': 'No NVIDIA GPU or Ascend NPU tools detected on this host.',
'systemManager.gpu.loading': 'Loading accelerator stats…',
'systemManager.gpu.empty': 'Accelerator tools are present, but no devices were reported.',
@@ -8,6 +8,8 @@ export const ruSystemManagerMessages: Messages = {
'systemManager.empty': 'Нет данных.',
'systemManager.tabs.overview': 'Обзор',
'systemManager.tabs.processes': 'Процессы',
'systemManager.tabs.ports': 'Порты',
'systemManager.tabs.services': 'Службы',
'systemManager.tabs.tmux': 'tmux',
'systemManager.tabs.docker': 'Docker',
'systemManager.tabs.gpu': 'GPU',
@@ -25,6 +27,8 @@ export const ruSystemManagerMessages: Messages = {
'systemManager.errors.loadDockerImages': 'Не удалось загрузить образы',
'systemManager.errors.loadOverview': 'Не удалось загрузить обзор системы',
'systemManager.errors.loadGpu': 'Не удалось загрузить статистику GPU / NPU',
'systemManager.errors.loadPorts': 'Не удалось загрузить слушающие порты',
'systemManager.errors.loadServices': 'Не удалось загрузить службы systemd',
'systemManager.errors.sshChannelUnavailable': 'Сервер отказался открыть новый канал выполнения. Повторите попытку позже или переподключите этот хост.',
'systemManager.overview.empty': 'Данных обзора пока нет.',
@@ -189,6 +193,34 @@ export const ruSystemManagerMessages: Messages = {
'systemManager.docker.start': 'Запустить',
'systemManager.docker.stop': 'Остановить',
'systemManager.ports.unavailable': 'На этом хосте не обнаружены инструменты портов (ss / netstat).',
'systemManager.ports.loading': 'Загрузка слушающих портов…',
'systemManager.ports.empty': 'Слушающие порты не найдены.',
'systemManager.ports.search': 'Поиск портов…',
'systemManager.ports.meta': '{{count}} слушающих',
'systemManager.ports.filter.all': 'Все',
'systemManager.ports.unknownProcess': 'Неизвестный процесс',
'systemManager.ports.terminate': 'Завершить',
'systemManager.ports.confirmTerminate': 'Отправить SIGTERM процессу {{pid}}, занимающему этот порт?',
'systemManager.services.unavailable': 'systemctl недоступен на этом хосте.',
'systemManager.services.loading': 'Загрузка служб systemd…',
'systemManager.services.empty': 'Службы systemd не найдены.',
'systemManager.services.search': 'Поиск служб…',
'systemManager.services.meta': '{{count}} служб',
'systemManager.services.filter.all': 'Все',
'systemManager.services.filter.running': 'Запущены',
'systemManager.services.filter.failed': 'С ошибкой',
'systemManager.services.filter.inactive': 'Неактивны',
'systemManager.services.start': 'Запустить',
'systemManager.services.stop': 'Остановить',
'systemManager.services.restart': 'Перезапустить',
'systemManager.services.enable': 'Включить автозапуск',
'systemManager.services.disable': 'Отключить автозапуск',
'systemManager.services.reload': 'Перезагрузить',
'systemManager.services.scope.user': 'user',
'systemManager.services.confirmAction': '{{action}} {{name}}?',
'systemManager.gpu.unavailable': 'На этом хосте не обнаружены инструменты NVIDIA GPU или Ascend NPU.',
'systemManager.gpu.loading': 'Загрузка статистики ускорителей…',
'systemManager.gpu.empty': 'Инструменты ускорителей найдены, но устройства не сообщены.',
@@ -8,6 +8,8 @@ export const zhCnSystemManagerMessages: Messages = {
'systemManager.empty': '暂无数据。',
'systemManager.tabs.overview': '概览',
'systemManager.tabs.processes': '进程',
'systemManager.tabs.ports': '端口',
'systemManager.tabs.services': '服务',
'systemManager.tabs.tmux': 'tmux',
'systemManager.tabs.docker': 'Docker',
'systemManager.tabs.gpu': 'GPU',
@@ -25,6 +27,8 @@ export const zhCnSystemManagerMessages: Messages = {
'systemManager.errors.loadDockerImages': '加载镜像列表失败',
'systemManager.errors.loadOverview': '加载系统概览失败',
'systemManager.errors.loadGpu': '加载 GPU / NPU 状态失败',
'systemManager.errors.loadPorts': '加载监听端口失败',
'systemManager.errors.loadServices': '加载 systemd 服务失败',
'systemManager.errors.sshChannelUnavailable': '服务器拒绝打开新的执行通道。请稍后重试,或重新连接当前主机。',
'systemManager.overview.empty': '暂无系统概览数据。',
@@ -189,6 +193,34 @@ export const zhCnSystemManagerMessages: Messages = {
'systemManager.docker.start': '启动',
'systemManager.docker.stop': '停止',
'systemManager.ports.unavailable': '此主机未检测到端口工具(ss / netstat)。',
'systemManager.ports.loading': '正在加载监听端口…',
'systemManager.ports.empty': '暂无监听端口。',
'systemManager.ports.search': '搜索端口…',
'systemManager.ports.meta': '{{count}} 个监听',
'systemManager.ports.filter.all': '全部',
'systemManager.ports.unknownProcess': '未知进程',
'systemManager.ports.terminate': '结束进程',
'systemManager.ports.confirmTerminate': '向占用该端口的进程 {{pid}} 发送 SIGTERM?',
'systemManager.services.unavailable': '此主机未检测到 systemctl。',
'systemManager.services.loading': '正在加载 systemd 服务…',
'systemManager.services.empty': '暂无 systemd 服务。',
'systemManager.services.search': '搜索服务…',
'systemManager.services.meta': '{{count}} 个服务',
'systemManager.services.filter.all': '全部',
'systemManager.services.filter.running': '运行中',
'systemManager.services.filter.failed': '失败',
'systemManager.services.filter.inactive': '未运行',
'systemManager.services.start': '启动',
'systemManager.services.stop': '停止',
'systemManager.services.restart': '重启',
'systemManager.services.enable': '开机启用',
'systemManager.services.disable': '禁用开机',
'systemManager.services.reload': '重载',
'systemManager.services.scope.user': '用户',
'systemManager.services.confirmAction': '确认对 {{name}} 执行「{{action}}」?',
'systemManager.gpu.unavailable': '此主机未检测到 NVIDIA GPU 或昇腾 NPU 工具。',
'systemManager.gpu.loading': '正在加载加速卡状态…',
'systemManager.gpu.empty': '已检测到加速卡工具,但未返回设备信息。',
@@ -8,6 +8,8 @@ export const zhTwSystemManagerMessages: Messages = {
'systemManager.empty': '暫無資料。',
'systemManager.tabs.overview': '概覽',
'systemManager.tabs.processes': '程序',
'systemManager.tabs.ports': '連接埠',
'systemManager.tabs.services': '服務',
'systemManager.tabs.tmux': 'tmux',
'systemManager.tabs.docker': 'Docker',
'systemManager.tabs.gpu': 'GPU',
@@ -25,6 +27,8 @@ export const zhTwSystemManagerMessages: Messages = {
'systemManager.errors.loadDockerImages': '載入映像列表失敗',
'systemManager.errors.loadOverview': '載入系統概覽失敗',
'systemManager.errors.loadGpu': '載入 GPU / NPU 狀態失敗',
'systemManager.errors.loadPorts': '載入監聽連接埠失敗',
'systemManager.errors.loadServices': '載入 systemd 服務失敗',
'systemManager.errors.sshChannelUnavailable': '伺服器拒絕開啟新的執行通道。請稍後重試,或重新連線目前主機。',
'systemManager.overview.empty': '暫無系統概覽資料。',
@@ -189,6 +193,34 @@ export const zhTwSystemManagerMessages: Messages = {
'systemManager.docker.start': '啟動',
'systemManager.docker.stop': '停止',
'systemManager.ports.unavailable': '此主機未偵測到連接埠工具(ss / netstat)。',
'systemManager.ports.loading': '正在載入監聽連接埠…',
'systemManager.ports.empty': '暫無監聽連接埠。',
'systemManager.ports.search': '搜尋連接埠…',
'systemManager.ports.meta': '{{count}} 個監聽',
'systemManager.ports.filter.all': '全部',
'systemManager.ports.unknownProcess': '未知程序',
'systemManager.ports.terminate': '結束程序',
'systemManager.ports.confirmTerminate': '向佔用該連接埠的程序 {{pid}} 傳送 SIGTERM?',
'systemManager.services.unavailable': '此主機未偵測到 systemctl。',
'systemManager.services.loading': '正在載入 systemd 服務…',
'systemManager.services.empty': '暫無 systemd 服務。',
'systemManager.services.search': '搜尋服務…',
'systemManager.services.meta': '{{count}} 個服務',
'systemManager.services.filter.all': '全部',
'systemManager.services.filter.running': '執行中',
'systemManager.services.filter.failed': '失敗',
'systemManager.services.filter.inactive': '未執行',
'systemManager.services.start': '啟動',
'systemManager.services.stop': '停止',
'systemManager.services.restart': '重新啟動',
'systemManager.services.enable': '開機啟用',
'systemManager.services.disable': '停用開機',
'systemManager.services.reload': '重新載入',
'systemManager.services.scope.user': '使用者',
'systemManager.services.confirmAction': '確認對 {{name}} 執行「{{action}}」?',
'systemManager.gpu.unavailable': '此主機未偵測到 NVIDIA GPU 或昇騰 NPU 工具。',
'systemManager.gpu.loading': '正在載入加速卡狀態…',
'systemManager.gpu.empty': '已偵測到加速卡工具,但未回傳裝置資訊。',
@@ -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,
};
}
+12 -2
View File
@@ -15,6 +15,16 @@ function isExpired(entry: StoreEntry): boolean {
return Date.now() > entry.expiresAt;
}
function normalizeCapabilities(capabilities: SessionCapabilities): SessionCapabilities {
return {
...capabilities,
hasSs: capabilities.hasSs === true,
hasNetstat: capabilities.hasNetstat === true,
hasLsof: capabilities.hasLsof === true,
hasSystemctl: capabilities.hasSystemctl === true,
};
}
function notifySession(sessionId: string) {
listenersBySessionId.get(sessionId)?.forEach((listener) => listener());
}
@@ -28,13 +38,13 @@ export const sessionCapabilitiesStore = {
notifySession(sessionId);
return undefined;
}
return entry.capabilities;
return normalizeCapabilities(entry.capabilities);
},
set(sessionId: string, capabilities: SessionCapabilities, ttlMs: number) {
const entry: StoreEntry = {
capabilities: {
...capabilities,
...normalizeCapabilities(capabilities),
probedAt: Date.now(),
},
expiresAt: Date.now() + ttlMs,
@@ -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,
};
}
+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,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),
);
};
@@ -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);
});
+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,
@@ -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;
@@ -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;
}
+11 -3
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)
@@ -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 '';
});
}, []);
+157 -9
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,
@@ -747,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]);
@@ -967,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;
@@ -1631,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)
@@ -1706,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,
@@ -1795,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,
@@ -1841,7 +1989,7 @@ 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,
-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,
};
}
+42 -1
View File
@@ -1,5 +1,11 @@
import { useCallback, useMemo } from 'react';
import type { DockerContainerAction, DockerImageManageAction, TmuxManageAction } from '../../domain/systemManager/types';
import type {
DockerContainerAction,
DockerImageManageAction,
SystemdUnitAction,
SystemdUnitInfo,
TmuxManageAction,
} from '../../domain/systemManager/types';
import { netcattyBridge } from '../../infrastructure/services/netcattyBridge';
export function useSystemManagerBackend() {
@@ -120,6 +126,35 @@ export function useSystemManagerBackend() {
return bridge.listAccelerators(sessionId);
}, []);
const listListeningPorts = useCallback(async (sessionId: string) => {
const bridge = netcattyBridge.get();
if (!bridge?.listListeningPorts) {
return { success: false as const, error: 'listListeningPorts unavailable' };
}
return bridge.listListeningPorts(sessionId);
}, []);
const listSystemServices = useCallback(async (sessionId: string) => {
const bridge = netcattyBridge.get();
if (!bridge?.listSystemServices) {
return { success: false as const, error: 'listSystemServices unavailable' };
}
return bridge.listSystemServices(sessionId);
}, []);
const systemServiceAction = useCallback(async (options: {
sessionId: string;
unitName: string;
action: SystemdUnitAction;
scope?: SystemdUnitInfo['scope'];
}) => {
const bridge = netcattyBridge.get();
if (!bridge?.systemServiceAction) {
return { success: false as const, error: 'systemServiceAction unavailable' };
}
return bridge.systemServiceAction(options);
}, []);
const dockerInspect = useCallback(async (options: { sessionId: string; containerId: string }) => {
const bridge = netcattyBridge.get();
if (!bridge?.dockerInspect) {
@@ -181,6 +216,9 @@ export function useSystemManagerBackend() {
listDockerImages,
getDockerStats,
listAccelerators,
listListeningPorts,
listSystemServices,
systemServiceAction,
dockerInspect,
dockerImageInspect,
dockerAction,
@@ -200,6 +238,9 @@ export function useSystemManagerBackend() {
listDockerImages,
getDockerStats,
listAccelerators,
listListeningPorts,
listSystemServices,
systemServiceAction,
dockerInspect,
dockerImageInspect,
dockerAction,
@@ -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,
};
}
@@ -0,0 +1,42 @@
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import test from 'node:test';
const runtimeSource = readFileSync(new URL('./useThemeRuntime.ts', import.meta.url), 'utf8');
const terminalHostSource = readFileSync(
new URL('../app/hosts/TerminalHost.tsx', import.meta.url),
'utf8',
);
test('published currentTerminalTheme resolves without live custom accent', () => {
// Base global appearance must use accentMode: 'theme' so HSL drag does not
// mint a new theme object into TerminalHost / AppShell domain bags.
assert.match(
runtimeSource,
/accentMode: 'theme',\s*\n\s*customAccent: '',/,
'globalAppearance must ignore live custom accent',
);
assert.match(runtimeSource, /accentedGlobalAppearance/);
assert.match(
runtimeSource,
/currentTerminalTheme: globalAppearance\.theme/,
'published theme must come from the stable base appearance',
);
});
test('TerminalHost injects accented appearance without bridging it', () => {
assert.match(
terminalHostSource,
/useTerminalAppearanceInjection\(accentedGlobalAppearance/,
'CSS vars still track live accent during drag',
);
const bridgeStart = terminalHostSource.indexOf('const themeBridgeActions = useMemo');
assert.notEqual(bridgeStart, -1);
const bridge = terminalHostSource.slice(bridgeStart, bridgeStart + 900);
assert.match(bridge, /globalAppearance,/);
assert.doesNotMatch(
bridge,
/accentedGlobalAppearance/,
'theme bridge must not republish on accented appearance identity churn',
);
});
+40 -10
View File
@@ -1,4 +1,4 @@
import { useCallback, useLayoutEffect, useMemo, useState } from 'react';
import { useCallback, useLayoutEffect, useMemo, useRef, useState } from 'react';
import type { TerminalTheme } from '../../domain/models';
import {
@@ -42,7 +42,9 @@ export function useThemeRuntime(settings: ThemeRuntimeSettings) {
const [userIntent, setUserIntent] = useState<ThemeUserIntent>(idleThemeUserIntent());
const appearanceSettings = useMemo((): TerminalAppearanceSettings => ({
// Theme-id settings without accent — keeps resolveFocusedAppearance identity
// stable during color-picker drag (TerminalLayer memo depends on it).
const appearanceSettingsBase = useMemo((): Omit<TerminalAppearanceSettings, 'accentMode' | 'customAccent'> => ({
terminalThemeId,
terminalThemeDarkId,
terminalThemeLightId,
@@ -50,8 +52,6 @@ export function useThemeRuntime(settings: ThemeRuntimeSettings) {
resolvedTheme,
lightUiThemeId,
darkUiThemeId,
accentMode,
customAccent,
}), [
terminalThemeId,
terminalThemeDarkId,
@@ -60,24 +60,51 @@ export function useThemeRuntime(settings: ThemeRuntimeSettings) {
resolvedTheme,
lightUiThemeId,
darkUiThemeId,
]);
const accentRef = useRef({ accentMode, customAccent });
accentRef.current = { accentMode, customAccent };
// Live accent settings are only for CSS-var injection. The published bag must
// resolve against a stable base theme so accent drag does not rebuild
// TerminalHost / AppShell domains every HSL tick.
const appearanceSettingsForInject = useMemo((): TerminalAppearanceSettings => ({
...appearanceSettingsBase,
accentMode,
customAccent,
]);
}), [appearanceSettingsBase, accentMode, customAccent]);
const globalAppearance = useMemo(() => resolveGlobalTerminalAppearance({
userIntent,
settings: appearanceSettings,
settings: {
...appearanceSettingsBase,
accentMode: 'theme',
customAccent: '',
},
customThemes,
}), [userIntent, appearanceSettings, customThemes]);
}), [userIntent, appearanceSettingsBase, customThemes]);
const accentedGlobalAppearance = useMemo(() => resolveGlobalTerminalAppearance({
userIntent,
settings: appearanceSettingsForInject,
customThemes,
}), [userIntent, appearanceSettingsForInject, customThemes]);
// Read accent from a ref so this callback identity does not churn on drag.
// Callers that invoke it after an accent-only update still get the latest
// accent; Terminal panes also re-apply appearanceChromeStore at the leaf.
const resolveFocusedAppearance = useCallback((hostScope: TerminalAppearanceHostScope): ResolvedAppearance => (
resolveTerminalAppearance({
userIntent,
settings: appearanceSettings,
settings: {
...appearanceSettingsBase,
accentMode: accentRef.current.accentMode,
customAccent: accentRef.current.customAccent,
},
hostScope,
customThemes,
})
), [userIntent, appearanceSettings, customThemes]);
), [userIntent, appearanceSettingsBase, customThemes]);
const applyFollowAppSettingsForPick = useCallback((themeId: string) => {
const update = getFollowAppTerminalThemeSelectionUpdate(themeId);
@@ -110,10 +137,12 @@ export function useThemeRuntime(settings: ThemeRuntimeSettings) {
}, []);
// Stable bag identity so App domain memos can depend on members (or the
// bag) without thrashing on every parent render.
// bag) without thrashing on every parent render. Accented appearance is
// intentionally excluded — injection reads it separately.
return useMemo(() => ({
userIntent,
globalAppearance,
accentedGlobalAppearance,
resolveFocusedAppearance,
pickTheme,
clearIntent,
@@ -122,6 +151,7 @@ export function useThemeRuntime(settings: ThemeRuntimeSettings) {
}), [
userIntent,
globalAppearance,
accentedGlobalAppearance,
resolveFocusedAppearance,
pickTheme,
clearIntent,
+8
View File
@@ -667,7 +667,9 @@ export function useUpdateCheck(options?: { autoUpdateEnabled?: boolean; enabled?
hasCheckedOnStartupRef.current = true;
debugLog('Starting delayed update check for version:', updateState.currentVersion);
let checkArmed = true;
startupCheckTimeoutRef.current = setTimeout(async () => {
checkArmed = false;
// Re-check the toggle at fire time — the user may have toggled it
// after the timer was scheduled.
const stillEnabled = localStorageAdapter.readString(STORAGE_KEY_AUTO_UPDATE_ENABLED);
@@ -711,6 +713,12 @@ export function useUpdateCheck(options?: { autoUpdateEnabled?: boolean; enabled?
return () => {
if (startupCheckTimeoutRef.current) {
clearTimeout(startupCheckTimeoutRef.current);
startupCheckTimeoutRef.current = null;
}
// A remount clears the timer before it fires — allow the next effect to
// schedule again instead of permanently skipping the check.
if (checkArmed) {
hasCheckedOnStartupRef.current = false;
}
};
}, [enabled, updateState.currentVersion, autoUpdateEnabled, performCheck]);
+16 -1
View File
@@ -1,6 +1,11 @@
import assert from 'node:assert/strict';
import { describe, it } from 'node:test';
import { haveSameVaultAgentSnapshot, resolveVaultAgentEffectiveHost } from './useVaultAgentBridge';
import {
haveSameVaultAgentSnapshot,
resolveVaultAgentEffectiveHost,
resolveVaultAgentNotes,
} from './useVaultAgentBridge';
import { publishNotesSnapshot } from './notesStore';
type Snapshot = Parameters<typeof haveSameVaultAgentSnapshot>[0];
@@ -34,3 +39,13 @@ describe('resolveVaultAgentEffectiveHost', () => {
);
});
});
describe('resolveVaultAgentNotes', () => {
it('prefers the live notes store when App omitted the notes prop', () => {
const stale: Snapshot['notes'] = [];
const live = [{ id: 'n1', title: 'Live', content: 'x', updatedAt: 1 }] as Snapshot['notes'];
publishNotesSnapshot({ notes: live, noteGroups: [] });
assert.equal(resolveVaultAgentNotes(undefined, stale as never), live);
assert.equal(resolveVaultAgentNotes(stale as never, stale as never), stale);
});
});
+40 -5
View File
@@ -15,6 +15,7 @@ import {
rememberKeyPassphrase,
resolveDefaultKeyPassphraseAliases,
} from '../defaultKeyPassphrases';
import { getNotesActions, getNotesSnapshot, subscribeNotes } from './notesStore';
export interface UseVaultAgentBridgeInput {
hosts: Host[];
@@ -35,8 +36,10 @@ export interface UseVaultAgentBridgeInput {
updateGroupConfigs: (configs: GroupConfig[]) => void;
updateManagedSources: (sources: ManagedSource[]) => void;
updatePortForwardingRules: (rules: PortForwardingRule[]) => void;
notes: VaultNote[];
updateNotes: (notes: VaultNote[]) => void;
/** Optional override; defaults to notesStore so App need not subscribe to notes. */
notes?: VaultNote[];
/** Optional override; defaults to notesStore actions. */
updateNotes?: (notes: VaultNote[]) => void;
startTunnel: VaultAgentApiDeps['startTunnel'];
stopTunnel: VaultAgentApiDeps['stopTunnel'];
stopRuleTunnels: VaultAgentApiDeps['stopRuleTunnels'];
@@ -59,7 +62,7 @@ type VaultAgentSnapshot = {
const selectVaultAgentSnapshot = (input: UseVaultAgentBridgeInput): VaultAgentSnapshot => ({
hosts: input.hosts,
keys: input.keys,
notes: input.notes,
notes: input.notes ?? (getNotesSnapshot().notes as VaultNote[]),
snippets: input.snippets,
customGroups: input.customGroups,
groupConfigs: input.groupConfigs,
@@ -67,6 +70,16 @@ const selectVaultAgentSnapshot = (input: UseVaultAgentBridgeInput): VaultAgentSn
managedSources: input.managedSources,
});
/** Resolve notes for agent ops: live store wins when App omitted the prop. */
export function resolveVaultAgentNotes(
notesOverride: VaultNote[] | undefined,
snapshotNotes: VaultNote[],
): VaultNote[] {
return notesOverride !== undefined
? snapshotNotes
: (getNotesSnapshot().notes as VaultNote[]);
}
export const haveSameVaultAgentSnapshot = (
left: VaultAgentSnapshot,
right: VaultAgentSnapshot,
@@ -102,12 +115,34 @@ export function useVaultAgentBridge(input: UseVaultAgentBridgeInput): void {
lastSyncedVaultInputRef.current = selectedSnapshot;
}
// Keep notes fresh without forcing App to re-render on every note edit.
// Write in place: this object is also `lastSyncedVaultInputRef.current`, and
// the agent op handlers below rely on that aliasing so their writes survive
// the render-time comparison above. Replacing the object would break it.
useEffect(() => {
if (input.notes !== undefined) return;
const syncNotes = () => {
vaultSnapshotRef.current.notes = getNotesSnapshot().notes as VaultNote[];
};
syncNotes();
return subscribeNotes(syncNotes);
}, [input.notes]);
useEffect(() => {
registerVaultAgentHandler(async (op, params) => {
const current = inputRef.current;
const applyUpdateNotes = current.updateNotes
?? getNotesActions()?.updateNotes
?? ((notes: VaultNote[]) => {
console.warn('[useVaultAgentBridge] updateNotes unavailable');
void notes;
});
return handleVaultAgentOp(op, params, {
getHosts: () => vaultSnapshotRef.current.hosts,
getNotes: () => vaultSnapshotRef.current.notes,
getNotes: () => resolveVaultAgentNotes(
current.notes,
vaultSnapshotRef.current.notes,
),
getCustomGroups: () => vaultSnapshotRef.current.customGroups,
getGroupConfigs: () => vaultSnapshotRef.current.groupConfigs,
getPortForwardingRules: () => vaultSnapshotRef.current.portForwardingRules,
@@ -185,7 +220,7 @@ export function useVaultAgentBridge(input: UseVaultAgentBridgeInput): void {
}),
updateNotes: (notes) => {
vaultSnapshotRef.current.notes = notes;
current.updateNotes(notes);
applyUpdateNotes(notes);
},
updateSnippets: (nextSnippets) => {
vaultSnapshotRef.current.snippets = nextSnippets;
@@ -0,0 +1,12 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import test from "node:test";
const source = readFileSync(new URL("./useVaultImportHandlers.ts", import.meta.url), "utf8");
test("useVaultImportHandlers stays below the UI layer", () => {
assert.doesNotMatch(source, /from ["'].*components\//);
assert.doesNotMatch(source, /from ["'].*\/toast["']/);
assert.match(source, /notify: VaultImportNotifier/);
assert.match(source, /from ["']\.\/vaultImportOptions["']/);
});
+715
View File
@@ -0,0 +1,715 @@
import { startTransition, useCallback, useEffect, useRef, useState } from "react";
import {
readRememberedKeyPassphrases,
rememberImportedKeyPassphrase,
resolveDefaultKeyPassphraseAliases,
} from "../defaultKeyPassphrases";
import {
countVaultImportDuplicates,
ensureVaultImportPersisted,
mergeVaultImportedGroups,
rebaseVaultImportedHosts,
resolveUniqueManagedImportGroupName,
rollbackVaultImportedHosts,
waitForVaultImportProgressPaint,
type VaultHostPersistenceResult,
type VaultImportProgress,
} from "./vaultImportProgress";
import { importVaultHostsInWorker } from "./vaultImportWorker";
import {
type VaultLockHandle,
withVaultImportLock,
} from "./vaultManagedImportLock";
/** Exit the import lock so a concurrent host save can finish, then retry. */
const RETRY_VAULT_IMPORT_AFTER_CONCURRENT_EDIT = Symbol("retry-vault-import-after-concurrent-edit");
import { sanitizeHost } from "../../domain/host";
import {
applyVaultImportDestination,
applyVaultHostImport,
buildVaultHostEndpointKey,
buildVaultHostMergeKey,
filterVaultImportKeyPassphrasesAgainstExisting,
mergeVaultImportIssues,
resolveVaultImportKeyPassphraseConflicts,
type VaultImportFormat,
} from "../../domain/vaultImport";
import type { GroupConfig, Host, ManagedSource, SSHKey } from "../../types";
import type { VaultImportNotifier, VaultImportOptions } from "./vaultImportOptions";
interface UseVaultImportHandlersOptions {
customGroups: string[];
hosts: Host[];
keys: SSHKey[];
managedSources: ManagedSource[];
notify: VaultImportNotifier;
onReadPersistedHosts: () => Promise<Host[]>;
onUpdateHosts: (hosts: Host[]) => VaultHostPersistenceResult | Promise<VaultHostPersistenceResult>;
onUpdateKeys: (keys: SSHKey[]) => void;
onReadPersistedManagedSources: () => ManagedSource[];
onCommitVaultImportTransaction: (
hosts: Host[],
updateGroups: (current: string[]) => string[],
updateSources: (current: ManagedSource[]) => ManagedSource[],
updateGroupConfigs?: (current: GroupConfig[]) => GroupConfig[],
expectedHosts?: Host[],
lock?: VaultLockHandle | null,
) => Promise<
| {
status: "persisted";
groups: string[];
sources: ManagedSource[];
groupConfigs: GroupConfig[];
}
| { status: "superseded" }
>;
setIsImportOpen: (open: boolean) => void;
t: (key: string, values?: Record<string, unknown>) => string;
}
export function useVaultImportHandlers({
customGroups,
hosts,
keys,
managedSources,
notify,
onReadPersistedHosts,
onUpdateHosts,
onUpdateKeys,
onReadPersistedManagedSources,
onCommitVaultImportTransaction,
setIsImportOpen,
t,
}: UseVaultImportHandlersOptions) {
const [importProgress, setImportProgress] = useState<VaultImportProgress | null>(null);
const customGroupsRef = useRef(customGroups);
const hostsRef = useRef(hosts);
const keysRef = useRef(keys);
const managedSourcesRef = useRef(managedSources);
const activeImportAbortRef = useRef<AbortController | null>(null);
const importCommitStartedRef = useRef(false);
const importInFlightRef = useRef(false);
customGroupsRef.current = customGroups;
hostsRef.current = hosts;
keysRef.current = keys;
managedSourcesRef.current = managedSources;
const resetImportProgress = useCallback(() => setImportProgress(null), []);
useEffect(() => () => {
if (!importCommitStartedRef.current) activeImportAbortRef.current?.abort();
activeImportAbortRef.current = null;
}, []);
const cancelImport = useCallback(() => {
if (importCommitStartedRef.current) return;
activeImportAbortRef.current?.abort();
activeImportAbortRef.current = null;
setImportProgress(null);
setIsImportOpen(false);
}, [setIsImportOpen]);
const handleImportFileSelected = useCallback(
async (format: VaultImportFormat, files: File[], options?: VaultImportOptions) => {
const file = files[0];
if (!file) return;
if (importInFlightRef.current) return;
importInFlightRef.current = true;
activeImportAbortRef.current?.abort();
const abortController = new AbortController();
activeImportAbortRef.current = abortController;
importCommitStartedRef.current = false;
const { signal } = abortController;
const throwIfCancelled = () => {
if (signal.aborted) {
throw new DOMException("Vault import cancelled.", "AbortError");
}
};
let rollbackSnapshot: {
baselineHosts: Host[];
appliedHosts: Host[];
} | null = null;
let rollbackPendingImport: () => Promise<void> = async () => undefined;
const relativeRoot = file.webkitRelativePath?.split(/[\\/]+/).filter(Boolean)[0];
const selectionName = files.length > 1 ? (relativeRoot || file.name) : file.name;
const formatLabel =
format === "putty"
? "PuTTY"
: format === "mobaxterm"
? "MobaXterm"
: format === "csv"
? "CSV"
: format === "securecrt"
? "SecureCRT"
: "ssh_config";
const updateProgress = (next: Partial<VaultImportProgress>) => {
setImportProgress((current) => ({
status: "running",
stage: "reading",
percent: 5,
formatLabel,
fileName: selectionName,
totalFiles: files.length,
...current,
...next,
}));
};
setIsImportOpen(false);
setImportProgress({
status: "running",
stage: "reading",
percent: 5,
formatLabel,
fileName: selectionName,
completedFiles: 0,
totalFiles: files.length,
});
try {
let result = await importVaultHostsInWorker({
format,
files,
encoding: options?.encoding,
signal,
onProgress: (progress) => {
if (!signal.aborted) updateProgress(progress);
},
});
throwIfCancelled();
const isManaged = format === "ssh_config" && options?.managed === true;
if (!isManaged) {
result = applyVaultImportDestination(
result,
options?.destination ?? { mode: "preserve" },
// SecureCRT keeps distinct session files that share an endpoint;
// only rewrite their group when the user picks an import location.
{ collapseDuplicateEndpoints: format !== "securecrt" },
);
}
updateProgress({ stage: "preparing", percent: 70 });
await waitForVaultImportProgressPaint();
throwIfCancelled();
updateProgress({ stage: "saving", percent: 85 });
await waitForVaultImportProgressPaint();
throwIfCancelled();
const currentCustomGroups = customGroupsRef.current;
const currentHosts = hostsRef.current;
const currentManagedSources = managedSourcesRef.current;
const persistHosts = async (
nextHosts: Host[],
options?: {
baselineHosts?: Host[];
persistAttempt?: (
hosts: Host[],
baselineHosts: Host[],
) => Promise<VaultHostPersistenceResult>;
prepareAttempt?: (attempt: { baselineHosts: Host[]; appliedHosts: Host[] }) => Host[];
},
) => {
throwIfCancelled();
importCommitStartedRef.current = true;
let baselineHosts = options?.baselineHosts ?? currentHosts;
let appliedHosts = nextHosts;
while (true) {
appliedHosts = options?.prepareAttempt?.({ baselineHosts, appliedHosts }) ?? appliedHosts;
rollbackSnapshot = { baselineHosts, appliedHosts };
let persisted: VaultHostPersistenceResult;
if (options?.persistAttempt) {
persisted = await options.persistAttempt(appliedHosts, baselineHosts);
} else {
let hostUpdate: VaultHostPersistenceResult | Promise<VaultHostPersistenceResult>;
startTransition(() => {
hostUpdate = onUpdateHosts(appliedHosts);
});
persisted = await hostUpdate!;
}
if (persisted !== "superseded") {
if (persisted !== false) rollbackSnapshot = null;
return persisted;
}
// Locked import commits must release the shared lock before retrying
// so a concurrent host save queued behind that lock can finish first.
if (options?.persistAttempt) {
throw RETRY_VAULT_IMPORT_AFTER_CONCURRENT_EDIT;
}
const latestHosts = hostsRef.current;
hostsRef.current = latestHosts;
appliedHosts = rebaseVaultImportedHosts({
currentHosts: latestHosts,
baselineHosts,
appliedHosts,
});
baselineHosts = latestHosts;
}
};
rollbackPendingImport = async () => {
const snapshot = rollbackSnapshot;
if (!snapshot) return;
while (true) {
const rollbackHosts = rollbackVaultImportedHosts({
currentHosts: hostsRef.current,
...snapshot,
});
let rollbackUpdate: VaultHostPersistenceResult | Promise<VaultHostPersistenceResult>;
startTransition(() => {
rollbackUpdate = onUpdateHosts(rollbackHosts);
});
const persisted = await rollbackUpdate!;
if (persisted === "superseded") continue;
if (persisted === false) {
throw new Error(t("vault.import.progress.persistFailed"));
}
rollbackSnapshot = null;
return;
}
};
const fileBaseName = file.name.replace(/\.[^/.]+$/, "");
const requestedManagedGroup = options?.destination?.mode === "preserve"
? null
: options?.destination?.group ?? null;
let managedGroupName = requestedManagedGroup ?? `${fileBaseName} - Managed`;
// Check if this file is already managed
const bridge = (window as unknown as { netcatty?: { getPathForFile?: (file: File) => string | undefined } }).netcatty;
// Try bridge.getPathForFile first, then fall back to file.path (Electron legacy)
const filePath = bridge?.getPathForFile?.(file) || (file as File & { path?: string }).path;
if (isManaged && !filePath) {
// Cannot proceed with managed import without a valid file path
const message = t("vault.import.sshConfig.noFilePathDesc");
updateProgress({
status: "error",
stage: "failed",
error: message,
});
notify.error(
message,
t("vault.import.sshConfig.noFilePath"),
);
return;
}
if (isManaged) {
const existingSource = currentManagedSources.find(s => s.filePath === filePath);
if (existingSource) {
const message = t("vault.import.sshConfig.alreadyManagedDesc", {
group: existingSource.groupName,
});
updateProgress({
status: "error",
stage: "failed",
error: message,
});
notify.error(
message,
t("vault.import.sshConfig.alreadyManaged"),
);
return;
}
}
// Managed ssh_config rematch is endpoint-only; CSV/other imports treat
// group as part of session identity so direct vs proxy copies can coexist.
const makeKey = isManaged ? buildVaultHostEndpointKey : buildVaultHostMergeKey;
const existingKeys = new Set(currentHosts.map(makeKey));
// Filter out duplicates for both managed and non-managed imports
let newHosts = format === "securecrt"
? result.hosts
: result.hosts.filter((h) => !existingKeys.has(makeKey(h)));
// For managed imports, also update existing hosts to be managed
let updatedExistingHosts: Host[] = [];
if (isManaged) {
const importedKeys = new Set(result.hosts.map(makeKey));
updatedExistingHosts = currentHosts.filter((h) => importedKeys.has(makeKey(h)));
}
if (isManaged && (newHosts.length > 0 || updatedExistingHosts.length > 0)) {
while (true) {
try {
await withVaultImportLock("vault", async (lock) => {
const latestPersistedSources = onReadPersistedManagedSources();
managedSourcesRef.current = latestPersistedSources;
const sourceClaim = latestPersistedSources.find((source) => source.filePath === filePath);
if (sourceClaim) {
throw new Error(t("vault.import.sshConfig.alreadyManagedDesc", {
group: sourceClaim.groupName,
}));
}
const managedBaselineHosts = await onReadPersistedHosts();
hostsRef.current = managedBaselineHosts;
const managedExistingKeys = new Set(managedBaselineHosts.map(makeKey));
newHosts = result.hosts.filter((host) => !managedExistingKeys.has(makeKey(host)));
const managedImportedKeys = new Set(result.hosts.map(makeKey));
updatedExistingHosts = managedBaselineHosts.filter((host) => (
!host.managedSourceId && managedImportedKeys.has(makeKey(host))
));
if (newHosts.length === 0 && updatedExistingHosts.length === 0) return;
const sourceId = crypto.randomUUID();
let newSource: ManagedSource = {
id: sourceId,
type: "ssh_config",
filePath: filePath,
groupName: managedGroupName,
lastSyncedAt: Date.now(),
};
newHosts = newHosts.map((h) => ({
...h,
group: managedGroupName,
// Only SSH hosts can be managed (SSH config only supports SSH)
managedSourceId: (!h.protocol || h.protocol === "ssh") ? sourceId : undefined,
}));
// Update existing hosts to be managed (move to managed group)
const existingHostIds = new Set(updatedExistingHosts.map(h => h.id));
const updatedHosts = managedBaselineHosts.map((h) => {
if (!existingHostIds.has(h.id)) return h;
const canBeManaged = !h.protocol || h.protocol === "ssh";
return {
...h,
group: managedGroupName,
managedSourceId: canBeManaged ? sourceId : undefined,
// Sanitize label for managed hosts
label: canBeManaged && h.label ? h.label.replace(/\s/g, '') : h.label,
};
});
let nextGroups: string[] = [];
const ensureManagedSourceStillAvailable = () => {
const conflictingSource = managedSourcesRef.current.find((source) => (
source.id !== sourceId && source.filePath === filePath
));
if (conflictingSource) {
throw new Error(t("vault.import.sshConfig.alreadyManagedDesc", {
group: conflictingSource.groupName,
}));
}
};
const prepareManagedAttempt = ({
baselineHosts,
appliedHosts,
}: {
baselineHosts: Host[];
appliedHosts: Host[];
}) => {
ensureManagedSourceStillAvailable();
managedGroupName = requestedManagedGroup ?? resolveUniqueManagedImportGroupName({
baseName: fileBaseName,
customGroups: customGroupsRef.current,
hosts: baselineHosts,
managedSources: managedSourcesRef.current,
ownerSourceId: sourceId,
});
newSource = { ...newSource, groupName: managedGroupName };
nextGroups = Array.from(new Set([
...currentCustomGroups,
...(requestedManagedGroup ? [] : result.groups),
managedGroupName,
]));
return appliedHosts.map((host) => (
host.managedSourceId === sourceId
? { ...host, group: managedGroupName }
: host
));
};
const hostPersisted = await persistHosts(
[...updatedHosts, ...newHosts].map((host: Host) => sanitizeHost(host)),
{
baselineHosts: managedBaselineHosts,
prepareAttempt: prepareManagedAttempt,
persistAttempt: async (hostsToCommit, baselineHosts) => {
const transaction = await onCommitVaultImportTransaction(
hostsToCommit,
(latestPersistedGroups) => mergeVaultImportedGroups({
currentGroups: latestPersistedGroups,
baselineGroups: currentCustomGroups,
appliedGroups: nextGroups,
}),
(latestPersistedSources) => {
const conflictingSource = latestPersistedSources.find((source) => (
source.id !== sourceId && source.filePath === filePath
));
if (conflictingSource) {
throw new Error(t("vault.import.sshConfig.alreadyManagedDesc", {
group: conflictingSource.groupName,
}));
}
return [
...latestPersistedSources.filter((source) => source.id !== newSource.id),
newSource,
];
},
undefined,
baselineHosts,
lock,
);
if (transaction.status === "superseded") return "superseded";
customGroupsRef.current = transaction.groups;
managedSourcesRef.current = transaction.sources;
return true;
},
},
);
await ensureVaultImportPersisted(
hostPersisted,
t("vault.import.progress.persistFailed"),
undefined,
rollbackPendingImport,
);
});
break;
} catch (error) {
if (error !== RETRY_VAULT_IMPORT_AFTER_CONCURRENT_EDIT) throw error;
// Drain concurrent locked writes outside the critical section.
hostsRef.current = await onReadPersistedHosts();
}
}
} else if (newHosts.length > 0) {
let addedHostIds = new Set<string>();
let addedHostKeyPaths = new Map<string, string>();
while (true) {
try {
await withVaultImportLock("vault", async (lock) => {
const importBaselineHosts = await onReadPersistedHosts();
hostsRef.current = importBaselineHosts;
const importBaselineGroups = customGroupsRef.current;
const merged = applyVaultHostImport(
importBaselineHosts,
importBaselineGroups,
result,
{ skipDuplicates: format !== "securecrt" },
);
newHosts = merged.addedHosts;
addedHostIds = new Set(merged.addedHosts.map((host) => host.id));
addedHostKeyPaths = new Map(merged.addedHosts.flatMap((host) => {
const keyPath = host.identityFilePaths?.find((path) => path.trim())?.trim();
return keyPath ? [[host.id, keyPath] as const] : [];
}));
if (newHosts.length === 0) return;
const hostPersisted = await persistHosts(merged.hosts, {
baselineHosts: importBaselineHosts,
persistAttempt: async (hostsToCommit, baselineHosts) => {
const transaction = await onCommitVaultImportTransaction(
hostsToCommit,
(latestPersistedGroups) => mergeVaultImportedGroups({
currentGroups: latestPersistedGroups,
baselineGroups: importBaselineGroups,
appliedGroups: merged.customGroups,
}),
(latestPersistedSources) => latestPersistedSources,
undefined,
baselineHosts,
lock,
);
if (transaction.status === "superseded") return "superseded";
customGroupsRef.current = transaction.groups;
managedSourcesRef.current = transaction.sources;
return true;
},
});
await ensureVaultImportPersisted(
hostPersisted,
t("vault.import.progress.persistFailed"),
undefined,
rollbackPendingImport,
);
});
break;
} catch (error) {
if (error !== RETRY_VAULT_IMPORT_AFTER_CONCURRENT_EDIT) throw error;
hostsRef.current = await onReadPersistedHosts();
}
}
throwIfCancelled();
const resolved = await resolveVaultImportKeyPassphraseConflicts(
result.keyPassphraseCandidates ?? result.keyPassphrases ?? [],
resolveDefaultKeyPassphraseAliases,
addedHostIds,
addedHostKeyPaths,
);
throwIfCancelled();
const checked = await filterVaultImportKeyPassphrasesAgainstExisting(
resolved.keyPassphrases,
(keyPath) => readRememberedKeyPassphrases(keyPath, keysRef.current),
);
throwIfCancelled();
result.issues = mergeVaultImportIssues(
result.issues,
resolved.issues,
checked.issues,
);
for (const entry of checked.keyPassphrases) {
try {
const saved = await rememberImportedKeyPassphrase({
keyPath: entry.keyPath,
passphrase: entry.passphrase,
keys: keysRef.current,
getKeys: () => keysRef.current,
updateKeys: onUpdateKeys,
setCurrentKeys: (updatedKeys) => {
keysRef.current = updatedKeys;
},
});
throwIfCancelled();
if (saved === "conflict") {
result.issues.push({
level: "warning",
message: `CSV passphrase conflicts with an existing saved passphrase for KeyPath "${entry.keyPath}"; the existing passphrase was kept.`,
});
} else if (saved === "unreadable") {
result.issues.push({
level: "warning",
message: `Could not verify the existing saved passphrase for KeyPath "${entry.keyPath}"; the imported passphrase was not saved.`,
});
}
} catch (error) {
if (error instanceof DOMException && error.name === "AbortError") {
throw error;
}
result.issues.push({
level: "warning",
message: `Could not save the passphrase for KeyPath "${entry.keyPath}".`,
});
}
}
result.issues = mergeVaultImportIssues(result.issues);
}
// Count total hosts affected (new + converted to managed)
const totalAffected = newHosts.length + (isManaged ? updatedExistingHosts.length : 0);
const skipped = result.stats.skipped;
const duplicates = countVaultImportDuplicates({
importedHostCount: result.hosts.length,
newHostCount: newHosts.length,
fileDuplicateCount: result.stats.duplicates,
managed: isManaged,
});
const hasWarnings = skipped > 0 || duplicates > 0 || result.issues.length > 0;
if (result.stats.parsed === 0 && totalAffected === 0) {
const message = t("vault.import.toast.noEntries", { format: formatLabel });
updateProgress({
status: "error",
stage: "failed",
error: message,
});
notify.error(
message,
t("vault.import.toast.failedTitle"),
);
return;
}
if (totalAffected === 0) {
updateProgress({
status: "complete",
stage: "complete",
percent: 100,
imported: 0,
skipped,
duplicates,
});
notify.warning(
t("vault.import.toast.noNewHosts", { format: formatLabel }),
t("vault.import.toast.completedTitle"),
);
return;
}
throwIfCancelled();
rollbackSnapshot = null;
if (isManaged) {
notify.success(
t("vault.import.sshConfig.managedSuccess", { count: totalAffected }),
t("vault.import.toast.completedTitle"),
);
} else {
const details = t("vault.import.toast.summary", {
count: totalAffected,
skipped,
duplicates,
});
if (hasWarnings) {
const firstIssue = result.issues[0]?.message;
notify.warning(
firstIssue ? `${details} ${t("vault.import.toast.firstIssue", { issue: firstIssue })}` : details,
t("vault.import.toast.completedTitle"),
);
} else {
notify.success(details, t("vault.import.toast.completedTitle"));
}
}
updateProgress({
status: "complete",
stage: "complete",
percent: 100,
imported: totalAffected,
skipped,
duplicates,
});
} catch (err) {
let rollbackFailure: unknown;
if (rollbackSnapshot) {
try {
await rollbackPendingImport();
} catch (rollbackError) {
rollbackFailure = rollbackError;
console.error("[vault import] Failed to rollback imported hosts.", rollbackError);
}
}
if (
!rollbackFailure
&& (
signal.aborted
|| (err instanceof DOMException && err.name === "AbortError")
)
) return;
const originalMessage =
err instanceof Error ? err.message : t("common.unknownError");
const message = rollbackFailure
? `${originalMessage} ${t("vault.import.progress.rollbackFailed")}`
: originalMessage;
updateProgress({
status: "error",
stage: "failed",
error: message,
});
notify.error(message, t("vault.import.toast.failedTitle"));
} finally {
if (activeImportAbortRef.current === abortController) {
activeImportAbortRef.current = null;
}
importCommitStartedRef.current = false;
importInFlightRef.current = false;
}
},
[
notify,
onReadPersistedHosts,
onCommitVaultImportTransaction,
onReadPersistedManagedSources,
onUpdateHosts,
onUpdateKeys,
setIsImportOpen,
t,
],
);
return {
cancelImport,
handleImportFileSelected,
importProgress,
resetImportProgress,
};
}
+98 -8
View File
@@ -65,8 +65,18 @@ import {
} from "../../domain/connectionLogTerminalData";
import { getNextVaultOrder, normalizeVaultOrder } from "../../domain/vaultOrder";
import { loadSanitizedShellHistory } from "./shellHistoryPersistence";
import {
publishConnectionLogsSnapshot,
registerConnectionLogsActions,
} from "./connectionLogsStore";
import {
publishNotesSnapshot,
registerNotesActions,
} from "./notesStore";
import { commitVaultNotesWrite } from "./vaultNotesPersistence";
import { publishShellHistorySnapshot } from "./shellHistoryStore";
import { setVaultInitialized } from "./vaultInitStore";
import { notify } from "../notification";
import {
decryptGroupConfigs,
decryptHosts,
@@ -280,9 +290,14 @@ export const useVaultState = () => {
const customGroupsRef = useRef<string[]>([]);
const managedSourcesRef = useRef<ManagedSource[]>([]);
const hostsRef = useRef<Host[]>([]);
const notesRef = useRef<VaultNote[]>([]);
const noteGroupsRef = useRef<string[]>([]);
const notesPersistFailureNotifiedAtRef = useRef(0);
customGroupsRef.current = customGroups;
managedSourcesRef.current = managedSources;
hostsRef.current = hosts;
notesRef.current = notes;
noteGroupsRef.current = noteGroups;
// Write-version counters prevent out-of-order async writes from overwriting
// newer data. Each update bumps the counter; the .then() callback only
@@ -590,15 +605,37 @@ export const useVaultState = () => {
}, []);
const updateNotes = useCallback((data: Partial<VaultNote>[]) => {
const cleaned = normalizeVaultNotes(data);
const { notes: cleaned, persisted } = commitVaultNotesWrite({
data,
write: (key, value) => localStorageAdapter.write(key, value),
});
// Keep the in-session catalog updated so an autosave quota failure does not
// snap the editor back to stale props and discard the user's draft. Disk
// may still be behind — surface that explicitly.
notesRef.current = cleaned;
setNotes(cleaned);
localStorageAdapter.write(STORAGE_KEY_NOTES, cleaned);
publishNotesSnapshot({ notes: cleaned, noteGroups: noteGroupsRef.current });
if (!persisted) {
const now = Date.now();
// Debounced autosave can hit quota repeatedly; avoid toast spam.
if (now - notesPersistFailureNotifiedAtRef.current > 10_000) {
notesPersistFailureNotifiedAtRef.current = now;
notify.error(
"Notes could not be saved. Free some local storage space and try again.",
"Notes",
);
}
return false;
}
return true;
}, []);
const updateNoteGroups = useCallback((data: unknown) => {
const cleaned = normalizeNoteGroups(data);
noteGroupsRef.current = cleaned;
setNoteGroups(cleaned);
localStorageAdapter.write(STORAGE_KEY_NOTE_GROUPS, cleaned);
publishNotesSnapshot({ notes: notesRef.current, noteGroups: cleaned });
}, []);
const updateCustomGroups = useCallback((
@@ -828,6 +865,18 @@ export const useVaultState = () => {
publishShellHistorySnapshot(shellHistory);
}, [shellHistory]);
// Notes catalog for Notes / AI side panels — keep TerminalLayer off the hot path.
useLayoutEffect(() => {
publishNotesSnapshot({ notes, noteGroups });
}, [notes, noteGroups]);
useLayoutEffect(() => {
registerNotesActions({ updateNotes, updateNoteGroups });
return () => {
registerNotesActions(null);
};
}, [updateNotes, updateNoteGroups]);
const addShellHistoryEntry = useCallback(
(entry: Omit<ShellHistoryEntry, "id" | "timestamp">) => {
setShellHistory((prev) => {
@@ -923,6 +972,28 @@ export const useVaultState = () => {
});
}, [persistConnectionLogState]);
// Connection logs for Vault logs section — keep App domain bags off session churn.
useLayoutEffect(() => {
publishConnectionLogsSnapshot({ connectionLogs });
}, [connectionLogs]);
useLayoutEffect(() => {
registerConnectionLogsActions({
updateConnectionLog,
toggleConnectionLogSaved,
deleteConnectionLog,
clearUnsavedConnectionLogs,
});
return () => {
registerConnectionLogsActions(null);
};
}, [
updateConnectionLog,
toggleConnectionLogSaved,
deleteConnectionLog,
clearUnsavedConnectionLogs,
]);
// Convert a known host to a managed host
const convertKnownHostToHost = useCallback((knownHost: KnownHost): Host => {
const newHost: Host = {
@@ -968,11 +1039,13 @@ export const useVaultState = () => {
}, [hosts]);
useEffect(() => {
let cancelled = false;
const init = async () => {
try {
await withVaultImportLock("vault", async () => {
recoverPluginImporterTransaction(localStorageAdapter, PLUGIN_IMPORT_TRANSACTION_KEYS);
});
if (cancelled) return;
const savedHosts = localStorageAdapter.read<Host[]>(STORAGE_KEY_HOSTS);
if (savedHosts) {
@@ -981,6 +1054,7 @@ export const useVaultState = () => {
// and causes this stale result to be discarded.
const ver = ++hostsWriteVersion.current;
const decrypted = await decryptHosts(savedHosts);
if (cancelled) return;
if (ver === hostsWriteVersion.current) {
const sanitized = normalizeVaultOrder(
migrateHostsFromLegacyLineTimestamps(
@@ -1027,6 +1101,7 @@ export const useVaultState = () => {
// Decrypt sensitive fields (passphrase, privateKey)
const keyVer = ++keysWriteVersion.current;
const decryptedKeys = await decryptKeys(migratedKeys);
if (cancelled) return;
if (keyVer === keysWriteVersion.current) {
const orderedKeys = normalizeVaultOrder(decryptedKeys);
setKeys(orderedKeys);
@@ -1047,6 +1122,7 @@ export const useVaultState = () => {
if (savedIdentities) {
const idVer = ++identitiesWriteVersion.current;
const decryptedIds = await decryptIdentities(savedIdentities);
if (cancelled) return;
if (idVer === identitiesWriteVersion.current) {
const orderedIdentities = normalizeVaultOrder(decryptedIds);
setIdentities(orderedIdentities);
@@ -1062,6 +1138,7 @@ export const useVaultState = () => {
if (savedProxyProfiles) {
const proxyVer = ++proxyProfilesWriteVersion.current;
const decryptedProfiles = await decryptProxyProfiles(savedProxyProfiles);
if (cancelled) return;
if (proxyVer === proxyProfilesWriteVersion.current) {
const orderedProfiles = normalizeVaultOrder(decryptedProfiles);
setProxyProfiles(orderedProfiles);
@@ -1072,6 +1149,8 @@ export const useVaultState = () => {
}
}
if (cancelled) return;
// Read remaining non-encrypted data fresh after all async gaps above
const savedGroups = localStorageAdapter.read<string[]>(STORAGE_KEY_GROUPS);
const savedSnippets =
@@ -1148,6 +1227,7 @@ export const useVaultState = () => {
if (savedGroupConfigs) {
const gcVer = ++groupConfigsWriteVersion.current;
const decryptedGC = await decryptGroupConfigs(savedGroupConfigs);
if (cancelled) return;
if (gcVer === groupConfigsWriteVersion.current) {
const sanitizedGC = normalizeVaultOrder(decryptedGC.map(sanitizeGroupConfig));
setGroupConfigs(sanitizedGC);
@@ -1158,12 +1238,19 @@ export const useVaultState = () => {
}
}
} finally {
setIsInitialized(true);
setVaultInitialized(true);
// StrictMode remount cancels the first init; only the surviving effect
// may publish "vault ready" or terminals can boot against empty keys.
if (!cancelled) {
setIsInitialized(true);
setVaultInitialized(true);
}
}
};
init();
void init();
return () => {
cancelled = true;
};
}, [updateHosts, updateSnippets]);
useEffect(() => {
@@ -1375,9 +1462,12 @@ export const useVaultState = () => {
const updateHostLastConnected = useCallback((hostId: string) => {
setHosts((prev) => {
const next = prev.map((h) =>
h.id === hostId ? { ...h, lastConnectedAt: Date.now() } : h,
);
const idx = prev.findIndex((h) => h.id === hostId);
if (idx < 0) return prev;
const now = Date.now();
if (prev[idx].lastConnectedAt === now) return prev;
const next = prev.slice();
next[idx] = { ...prev[idx], lastConnectedAt: now };
const ver = ++hostsWriteVersion.current;
const encryptPromise = encryptHosts(next);
hostsEncryptPendingRef.current = encryptPromise.then(() => undefined);
+17
View File
@@ -0,0 +1,17 @@
import type { VaultImportDestination } from "../../domain/vaultImport";
import type { VaultImportFileEncoding } from "./vaultImportFile";
/** Options for a vault host-file import (kept out of UI modules). */
export type VaultImportOptions = {
managed?: boolean;
filePath?: string;
encoding?: VaultImportFileEncoding;
destination?: VaultImportDestination;
};
/** Notification sink so application/state hooks do not import UI toast. */
export type VaultImportNotifier = {
success: (message: string, title?: string) => void;
error: (message: string, title?: string) => void;
warning: (message: string, title?: string) => void;
};
@@ -0,0 +1,55 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import test from "node:test";
import { STORAGE_KEY_NOTES } from "../../infrastructure/config/storageKeys.ts";
import { commitVaultNotesWrite } from "./vaultNotesPersistence.ts";
test("commitVaultNotesWrite reports failure when the storage write returns false", () => {
const writes: Array<{ key: string; value: unknown }> = [];
const result = commitVaultNotesWrite({
data: [{
id: "note-1",
title: "Draft",
content: "body",
createdAt: 1,
updatedAt: 2,
}],
write: (key, value) => {
writes.push({ key, value });
return false;
},
});
assert.equal(result.persisted, false);
assert.equal(writes.length, 1);
assert.equal(writes[0]?.key, STORAGE_KEY_NOTES);
assert.equal(result.notes[0]?.title, "Draft");
});
test("commitVaultNotesWrite returns persisted notes when the write succeeds", () => {
const result = commitVaultNotesWrite({
data: [{
id: "note-1",
title: "Saved",
content: "ok",
createdAt: 1,
updatedAt: 2,
}],
write: () => true,
});
assert.equal(result.persisted, true);
assert.equal(result.notes[0]?.title, "Saved");
});
test("useVaultState updateNotes surfaces storage write failures", () => {
const source = readFileSync(new URL("./useVaultState.ts", import.meta.url), "utf8");
assert.match(source, /commitVaultNotesWrite/);
assert.match(source, /notify\.error/);
assert.match(source, /!persisted|persisted === false/);
assert.match(source, /return false/);
assert.match(source, /notesPersistFailureNotifiedAtRef/);
assert.match(source, /10_000/);
});
@@ -0,0 +1,21 @@
import { normalizeVaultNotes } from "../../domain/notes";
import type { VaultNote } from "../../domain/models";
import { STORAGE_KEY_NOTES } from "../../infrastructure/config/storageKeys";
export type VaultNotesWriteResult = {
notes: VaultNote[];
persisted: boolean;
};
/**
* Normalize and attempt to persist vault notes. Callers must check `persisted`
* before treating the update as durable (QuotaExceededError returns false).
*/
export function commitVaultNotesWrite(input: {
data: Partial<VaultNote>[];
write: (key: string, value: VaultNote[]) => boolean;
}): VaultNotesWriteResult {
const notes = normalizeVaultNotes(input.data);
const persisted = input.write(STORAGE_KEY_NOTES, notes);
return { notes, persisted };
}
Loaded 100 of 282 files, more files were not shown because too many files have changed in this diff. Show more