Compare commits

..
Author SHA1 Message Date
陈大猫 f42e870e74 ci: retrigger test after cancelled lint-and-test again (#4142) 2026-11-05 16:36:04 +08:00
陈大猫 24c2e6beff ci: retrigger lint-and-test after cancelled zombie (#4142) 2026-11-05 16:30:57 +08:00
陈大猫 c59ea270e2 ci: retrigger test after cancelled lint-and-test (#4142) 2026-11-05 16:12:43 +08:00
陈大猫 9b29c115df fix(terminal): stop Shift+Insert CSI leaking to broadcast peers (#4142) 2026-11-05 15:49:51 +08:00
陈大猫 4eceeda4b7 fix(terminal-worker): stop EPIPE ignore path from log-feedback looping (#4137)
Fixes #4136.

When the terminal utilityProcess console pipe is already dead, classifyProcessError marks EPIPE / ERR_STREAM_DESTROYED as ignore, but installTerminalWorkerErrorGuards still called logError → console.error. That write failed with EPIPE, re-entered uncaughtException, and busy-looped.

- On action === ignore: only report, never logError.
- Latch consoleBroken on stdout/stderr error so the default console.error path no-ops after the transport dies.
- Harden compressed extract cancel rollback under SIGTERM (ignore HUP/INT/TERM during cleanup, unlink archive first) and stabilize the FIFO cancel test that was flaking CI.

Out of scope: #4128 main-process EIO twin.
2026-11-05 11:58:00 +08:00
陈大猫andbinaricat c357d14181 fix(transfer): keep SSH alive when canceling mid-transfer (#4134) (#4135)
* fix(transfer): keep SSH alive when canceling mid-transfer (#4134)

Cancel of a large isolated SFTP upload/download used to call sftp.end()
mid-WRITE on a channel that shares the terminal SSH transport. That
CHANNEL_CLOSE with in-flight traffic can make the remote drop the whole
connection, so the shell shows [session closed by remote (no exit status)].

Abort now drains like the shared browse path (no mid-flight end); dispose
only after settle. Cancel returns { cancelled: true } so the pool does not
treat channel-closed as session loss.

* fix(transfer): deferred-dispose soft-cancelled isolated SFTP (#4134)

Codex P2 on 88b4890: softDispose dropped cancelled isolated download
channels from the pool without ending them, leaking remote subsystems.
Schedule sftp.end() after a ~2s quiet settle instead of abandoning forever
or ending mid-flight on the shared SSH transport.

Aligned cheap fix for isolated fastPut cancel: after force-settle, defer
end of the disposable channel the same way. Stopping ssh2 fastXfer
internal scheduling still needs invasive hooks — left as documented
residual; deferred end after quiet is the minimal leak fix.

Tests cover mid-flight isolated upload/download cancel (no end while
held) and eventual deferred end after settle.

* Revert "fix(transfer): deferred-dispose soft-cancelled isolated SFTP (#4134)"

This reverts commit 5b85ee70c0.

* fix(transfer): dispose soft-cancelled SFTP only after request drain (#4134)

Codex 4475686359 on 5b85ee70: fixed 2s deferred sftp.end() still fired while
READs were pending and could recreate the shared-SSH drop. Poll sftp._requests
for quiescence before ending; if still pending past the hard max, skip end
(abandon until session close) rather than ending mid-flight.

Tests: held isolated download READs past 2s must not end until released;
fastPut gate cancel still deferred-ends after drain.

* Revert "fix(transfer): dispose soft-cancelled SFTP only after request drain (#4134)"

This reverts commit 7e9934cd7e.

* chore: retrigger CI after lint-and-test runner cancel (#4135)

---------

Co-authored-by: binaricat <binaricat@users.noreply.github.com>
2026-11-05 10:44:57 +08:00
陈大猫 e7ade527ee fix(ui): keep follow-system themeSource from chrome override (#4131)
When Interface Theme is Follow System, chrome refresh called setTheme(light|dark) and locked nativeTheme.themeSource, so OS appearance flips no longer updated the UI. Keep setTheme('system') on system chrome preference.

Fixes #4130

Gate: GH Codex CLEAN on 2c0c1c77fd (6635017901); CI 41860537287 success; Electron smoke PASS NC_OUT=/workspace/netcatty-smoke-shots/pr-4131-37c0f491a (same tree as prior tip).
2026-11-05 09:10:11 +08:00
陈大猫 54c4db5030 fix(pty): forward childProcesses queries to terminal worker (#4127)
Worker-owned PTY sessions register their PIDs in the worker, leaving the main process map empty during close-busy checks.

Forward child-process queries to the terminal worker and register a worker handler for string and object payloads. Fall back to the main process tree if the worker is unavailable or returns a non-array result.
2026-11-04 04:25:48 -08:00
陈大猫 e17d7f01b6 perf(terminal): cache ligature font parses across terminals (#4126) 2026-11-04 04:01:36 -08:00
陈大猫andClaude Opus 5.5 5098f53944 perf(renderer): cut the startup bundle by a third (#4120)
* perf(renderer): cut the startup bundle by a third

Every window parsed ~6.1 MB of JavaScript before the first render. The entry
chunk carried all five UI locales, and the whole AI SDK chunk was preloaded
even though the AI panel is lazy.

- i18n: only English stays in the entry; other dictionaries load on demand
  (plus their same-base fallback, e.g. zh-TW -> zh-CN). index.tsx awaits the
  saved language before the first render, and I18nProvider re-renders when a
  runtime switch finishes loading.
- AI runtime: aiStateSnapshots cleaned up chats through getAgentRuntime(),
  and the plugin AI/agent hosts created their runtimes on mount, which pulled
  the Catty harness and @ai-sdk providers into startup. Cleanup now goes
  through a light handle that the runtime registers (the reversible privacy
  flag and main-process privacy mapping are still cleared without a runtime),
  and both plugin hosts create their runtime on the first request, re-checking
  liveness after the async load.
- Chunking: manualChunks' object form also moved Vite's dynamic-import
  preload helper into vendor-ai (the AI SDK itself uses import()), so the
  entry imported it from there and vendor-ai (649 KB) was preloaded on every
  launch. Vendor chunks now use the function form and the helper gets its own
  1 KB chunk.
- jsqr is loaded when a QR image is imported instead of with the host editor.
- startupBundle.architecture.test.ts guards these invariants.

Startup JS (index.html): 5.29 MB entry + 649 KB vendor-ai + 138 KB radix ->
3.80 MB entry + 1 KB helper + 174 KB radix (-35%). Startup harness, 2000 hosts,
10 runs, warm p50: loadURL -> renderer entry evaluated 321 -> 207 ms,
loadURL -> renderer ready 481 -> 323 ms.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test(i18n): preload locale messages before static rendering

* test(i18n): preload locales in remaining zh-CN UI tests

Non-English dictionaries now load lazily, so I18nProvider can initially render English before its effect finishes loading zh-CN. Explicitly await loadLocaleMessages before rendering these localized UI tests to fix the remaining CI failures without timing-based waits or production changes.

* chore: retrigger CI after locale preload fix

Previous tip 38108ce66 was on the branch but the PR head stayed
stuck on d49ad024e after a GitHub 500 during push. Empty commit
to refresh the PR head and re-queue checks.

* chore: retrigger CI after plugin-contract runner cancel

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 03:19:21 -08:00
陈大猫 c1df8a6011 fix(ai): keep sessions past 2MB localStorage budget via overflow (#4123)
Urgent fix for #4123: retain AI chat history past the 2MiB primary localStorage budget via overflow chunks + pre-split disk backup, with disk-only recovery markers, aggregate-quota degradation, import live-state retention, and count-cap marker resolution.

Gate: local Codex CLEAN (no P1) on 084ed813; CI green on acabe0bbc; Electron smoke PASS (pr-4124-acabe0bbc). Residual P2s noted (cross-window reconcile; vault-restore marker clear; retention-cap during pending recovery).

No release.
2026-11-04 02:41:44 -08:00
陈大猫andClaude Opus 5.5 a8bb5a43fc perf(main): stop blocking the first window on validator compilation (#4115)
The main process ran ~1.4 s of synchronous work in registerBridges before the
main window could start loading (app:// assets are served from the main
thread too), even for users without plugins:

- contractValidator: every createDefinitionValidator() built a fresh Ajv
  instance and re-registered the 200 KB plugin contract (~17 ms each, ~90 of
  them across the plugin host at startup, plus one per plugin view resource
  request). Share one Ajv per process, compile each definition on first use
  and memoize it; each validator still exposes its own `.errors`.
- distributionSnapshot: compile the three distribution schemas lazily on the
  shared contract Ajv instead of a second Ajv at module load (273 ms -> 37 ms).
- pluginBridge: hoist the PluginViewResourceRequest validator to module scope.
- cloudSyncBridge: load webdav / @aws-sdk/client-s3 / @smithy/node-http-handler
  on first sync instead of at startup (108 ms -> 4 ms).
- app:// scheme: enable codeCache so V8 reuses compiled renderer bundles
  across launches and windows. A same-length in-place edit of the cached entry
  chunk runs the new code, so stale cache is not reused after upgrades.

Startup harness (2000 hosts, 10 runs, warm p50): registerBridges 1421 ->
116 ms, plugin host 871 -> 30 ms, renderer ready 2147 -> 723 ms, total memory
923 -> 823 MB.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 02:33:16 -08:00
b87360783a perf: add opt-in startup timeline and real-app startup harness (#4114)
* perf: add opt-in startup timeline and real-app startup harness

- electron/perfTrace.cjs records first-occurrence startup marks in the main
  process when NETCATTY_PERF_TRACE=1 (a no-op otherwise), then writes the
  main-window renderer timeline (navigation, paint, netcatty:* marks, JS heap)
  and app.getAppMetrics() to JSON after the first renderer-ready signal.
- Marks: main.cjs entry / modules loaded / whenReady, main window creation,
  registerBridges and plugin-host start/end, loadURL, did-finish-load,
  renderer ready, window shown. The renderer adds three performance.mark()
  calls (entry evaluated, App mounted, renderer ready), also visible in
  DevTools.
- scripts/perf-startup.live.cjs (npm run perf:startup) launches the built app
  N times against a throwaway --user-data-dir profile with ssh:// deep-link
  registration disabled, optionally seeds synthetic hosts over CDP, and
  reports cold plus warm p50/p90.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* perf(harness): add --profile option to reuse a startup profile

Lets the harness keep one profile across invocations, e.g. to compare warm
V8 code caches across builds.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* chore: retrigger CI after flaky monitorRuntimeIsolation failure

Previous lint-and-test failed on known-preexisting
`useEffect is not defined` in monitorRuntimeIsolation.test.ts.

* chore: retrigger CI after monitorRuntimeIsolation useEffect flake

Known pre-existing flake: monitor controls test fails with
`useEffect is not defined`. Empty commit to re-run once.

* test(app): bind React hooks in monitorRuntimeIsolation VM sandbox

Extracting component declarations drops their React imports, leaving bare hooks such as useEffect undefined in the VM. Inject common React hooks and APIs while preserving explicit binding overrides to prevent intermittent monitor isolation failures in CI.

Stub the startup performance mark through App test bindings so the mount effect can execute without browser globals. Production components remain unchanged.

Validation: monitorRuntimeIsolation passed five consecutive runs. Plain npm run lint found 17 existing errors in git-ignored scratch files; excluding only _t.mjs and sim-4107.mjs passed with zero errors and eight existing warnings.

* chore: retrigger CI after plugin-contract runner cancel

Lint/schema passed; plugin-contract step cancelled by the runner
(not a test failure). Empty commit to re-queue.

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: binaricat <binaricat@users.noreply.github.com>
2026-11-04 02:15:34 -08:00
dc5fcfbc19 chore: fix outstanding ESLint warnings (#4112)
* chore: fix outstanding ESLint warnings

- useSftpFollowTerminalCwd: drop activeSessionId/focusedSessionId from the
  syncFollowToTerminalCwd deps; the callback reads them through refs and the
  only consumer effect already re-runs on those ids.
- useSftpPaneDialogs: list the stable setShowDeleteConfirm callback in the
  handleDelete/openDeleteConfirm deps.
- csvProxy: remove the unnecessary `\[` escapes inside character classes
  (same matches under the `u` flag).
- Remove unused test imports/variables.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* chore: retrigger CI after runner SIGTERM cancel

Previous lint-and-test was cancelled by runner shutdown, not a real failure.

* chore: retrigger CI after second runner cancel

Lint had already passed; plugin-contract step was cancelled by the runner.

* chore: retrigger CI after third runner cancel

Lint and schema steps passed; plugin-contract step was cancelled by
the runner again. Empty commit to re-queue lint-and-test.

* chore: retrigger CI after repeated runner cancel

plugin-contract step keeps being cancelled by the Actions runner
(not a test failure). Empty commit to re-queue; Actions rerun API
denied for this PAT.

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: binaricat <binaricat@users.noreply.github.com>
2026-11-04 02:11:09 -08:00
fcfba34fc1 perf(state): stop handler churn on session updates and repeated JSON work (#4121)
* perf(state): stop handler churn on session updates and repeated JSON work

- Shell handlers: AppSideEffects registers ~58 glue callbacks for the Host
  islands. Several close over sessions/workspaces/hosts (split/copy/duplicate,
  close tabs, delete host, keyboard-interactive, connect...), so every session
  status change and host update (connect bursts, last-connected times)
  re-notified the bridge and rebuilt all four shell bags, re-rendering AppView
  and TopTabs. They are now registered behind per-key stable wrappers that
  call the latest implementation (stabilizeAppHandlers); local callbacks and
  their effects are unchanged. All registered entries are actions, setters or
  refs, never read during render.
- Session actions: moveFocusInWorkspace, copySession and copyWorkspace read
  workspaces / orphan sessions / log views through refs at call time (the same
  snapshot the closure captured), so they keep a stable identity. Getters read
  during render (isBroadcastEnabled, getOrderedWorkTabs, ...) are untouched.
- AI history: the live store was parsed five times when the AI state root
  mounted; parseSessionsStore parses once per call (two parses total).
- AI history writes: the synced branch serializes once and reuses the string
  for the disk mirror, and rewriting the same immutable sessions array (save
  path, then cloud-sync collection) is skipped while storage still holds that
  exact write in the same mode.
- Command history: saving after each Enter parsed the whole store (up to 10k
  entries) only to read its clear generation, and every other window parsed it
  again on the storage event. The generation is now serialized first and read
  from the raw string (full parse only to adopt a peer clear), and the
  debounced write runs when the renderer is idle.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* chore: retrigger CI after cancelled lint-and-test run

Previous lint-and-test was cancelled mid-run.

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: binaricat <binaricat@users.noreply.github.com>
2026-11-04 01:11:31 -08:00
陈大猫andClaude Opus 5.5 fa554888a6 perf(terminal): stop per-second churn while connecting and while idle (#4118)
* perf(terminal): stop per-second churn while connecting and while idle

- Connecting sessions: the 200 ms progress animation and 1 s countdown were
  Terminal state threaded into the TerminalView ctx, so every connecting
  session (hidden tabs and startup restores included) re-rendered Terminal +
  TerminalView up to six times a second. Terminal now only publishes the
  timeout deadline and real progress events; the connection dialog animates
  and counts down locally (connectionTicker.ts). HostConnectionTestDialog
  keeps passing progress/timeLeft as before.
- Monitor runtime: polled every second unconditionally and woke every
  subscriber each time because takenAt always changes. It now polls at 1 s
  only while something is monitored (here or in another window), 10 s
  otherwise, and skips notifying for unchanged idle replies.
- Auto-reconnect progress log: capped at the newest 300 lines (it grew by a
  few lines every 5 s retry for as long as an outage lasted).
- App Lock: report activity at most once a second instead of an IPC per
  keystroke / wheel tick / pointer event.
- Tray/Dock: the renderer resends hosts only when the host list changed, and
  the main process rebuilds the macOS Dock menu (sort + one native item per
  host) only then, not on every session status change.

Real app, profiling build, main -> this change:
  3 SSH sessions connecting (3 s)   14 commits / 35.3 ms -> 8 / 1.7 ms
  idle (10 s)                       13 commits / 3.1 ms  -> 2 / 0.1 ms

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(terminal): update observe-popup reconnect deps assertion for capped progress logs

CI failed because #4118 wrapped setProgressLogs in useCallback to apply
capConnectionProgressLogs and added it to scheduleAutoReconnect deps.
The observe-popup source assertion still expected the old deps list.

The attachExistingSession early-return behavior is unchanged; only the
stale dependency regex needed updating.

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 01:11:25 -08:00
陈大猫andClaude Opus 5.5 d56150953d perf(terminal): cap live WebGL contexts, release them eagerly, bound note undo history (#4122)
* perf(terminal): cap live WebGL contexts and stop the context-loss cascade

Chromium keeps at most 16 WebGL contexts per renderer and force-loses the
one that drew least recently past that. Hidden terminal panes keep their
WebGL renderer unless hibernation is on, so with more than 16 terminal
tabs every forced loss was rebuilt 50 ms later, evicting another context.
With 20 local tabs, half of all tab switches landed on a lost context
(xterm waits 3 s before falling back), and terminals that lost three
contexts within a minute were parked on the DOM renderer for good.

- Add a shared context budget (12). Past it, renderers of panes hidden for
  at least 3 s are released, least recently seen first. Visible panes are
  never released or refused; a released pane rebuilds when shown.
- A context lost while hidden waits for the reveal instead of rebuilding
  at once, and no longer counts toward the breaker.
- The short-hide CSS reveal path now also asks for the renderer (a no-op
  while it is live).
- Disposing the addon left its context alive until GC. Lose it explicitly
  so GPU memory and the Chromium slot come back right away.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* perf(notes): bound the source editor's undo history

Every undo step in the markdown source editor holds a full copy of the
note, and the stack never shrank. A long session on a large note kept
every intermediate version alive (a 100 KB note with 1,000 steps holds
~200 MB of UTF-16 text).

Keep the 200 most recent steps and at most 8M characters across them,
dropping the oldest first. The newest step always survives.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 00:22:42 -08:00
陈大猫andClaude Opus 5.5 1b9c744fda perf(ui): stop whole-shell re-renders on clicks, focus, typing and modifiers (#4117)
Several everyday interactions re-rendered the entire shell (AppView, TopTabs,
TerminalLayer and every pane) even though nothing they show had changed:

- Clicking or focusing a split pane always replaced the workspaces array
  (twice per click: onClick + onFocusCapture), cascading through the whole
  app and re-saving the session restore snapshot. setWorkspaceFocusedSession
  and moveFocusInWorkspace now keep the same array when focus is unchanged
  (domain/workspace.ts setWorkspaceFocusedSessionId).
- Every window focus/visibility change resynced app lock and produced a new
  runtime state object even when nothing changed, re-rendering the provider
  tree. Equal states keep the current object and useAppLockState's result is
  memoized.
- Quick switcher keystrokes went through AppSideEffects state and the
  app-local UI store, re-rendering AppSideEffects, every host island, AppShell,
  AppView and TopTabs per character. QuickSwitcherOverlay now owns the query
  and host results; the overlay unmounts on close, which resets the query
  exactly where setQuickSearch('') used to.
- Pressing/releasing Ctrl/Cmd (every ⌘C/⌘V/Ctrl+C) re-rendered the whole tab
  bar to toggle shortcut number badges. The held flag now lives in a small
  store read only by each tab's TabIconSlot.
- AppView passed fresh inline callbacks that defeated TopTabs' memo comparator
  and every open editor tab; TextEditorTabView is now memoized. TopTabs
  memoizes per-workspace session lists and hoists the root tab icons.
- Workspace drag previews only update state when the drop hint actually moves.

React commits / render time for 10 repetitions in the real app (profiling
build, 2 split panes, 300 hosts), main -> this change:
  window focus            42 / 17.1 ms -> 2 / 0.4 ms
  press/release Cmd       50 / 41.9 ms -> 22 / 6.6 ms
  click focused pane      42 / 69.6 ms -> 3 / 0.4 ms
  quick switcher typing   41 / 61.3 ms -> 16 / 34.8 ms

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 00:22:10 -08:00
陈大猫andClaude Opus 5.5 2318434ac9 perf(ai): stop freezing the app during agent discovery and chat backups (#4116)
Both paths ran synchronous I/O on the Electron main process, which freezes
every window and the terminal keystroke relay while it runs:

- Agent discovery (opening the AI panel or Settings > AI) probed auth with
  execFileSync: `security find-generic-password` (4 s timeout), `gh auth
  status` (network, 5 s), `which cursor-agent` (4 s) and `cursor-agent status`
  (8 s), one after another. The probes now use a non-rejecting async execFile
  helper with the same result shape; discovery awaits them.
- Cursor SDK detection evaluated `import("@cursor/sdk")` (~4 MB of ESM plus
  its transport stack) only to check that it is installed, and kept it
  resident afterwards. It now checks for the ESM entry that the dynamic import
  loads; the SDK itself still loads on first use. The package does not export
  package.json and its CJS build is stripped from releases, so the check walks
  require.resolve.paths() instead of using require.resolve.
- The AI chat backup wrote the whole history (often several MB) with
  writeFileSync on every debounced change. Writes now use fs.promises and are
  serialized so generations cannot interleave.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 00:21:50 -08:00
陈大猫 dcf558e685 fix(terminal): clear sticky broadcast latch on vim ex (#4107) (#4109)
Fixes #4107.

Merge gate: GitHub Codex clean on tip b7efbe347; local CLEAN residual (unknown colon-prefixed alt-screen challenges) left as documented thrash; CI green (lint-and-test / windows-fido / windows-broadcast-ssh); Electron smoke PASS NC_OUT=/workspace/netcatty-smoke-shots/pr-4109-b7efbe347-20261104-122316 (both panes exited vim via toolbar broadcast :q!).
2026-11-03 20:38:28 -08:00
csr588 7683874a4e feat(autocomplete): add zh-CN descriptions for touch (#4084) 2026-11-03 19:09:25 -08:00
陈大猫 3ac24f7aa2 fix(ai): never pass empty messages to AI SDK standardizePrompt (#4102)
Fixes #4102

Gate: GitHub Codex CLEAN (6598986796) on 322485f; CI 41604002300 SUCCESS; Electron smoke PASS NC_OUT=/workspace/netcatty-smoke-shots/pr-4103-322485f-20261103-163619 (local Codex CLI geo-403).
2026-11-03 00:49:24 -08:00
陈大猫andbinaricat 79a6e14f7d fix(vault-backups): clarify folder is not portable alone (#4097)
* fix(vault-backups): clarify folder is not portable alone (#4096)

* ci: retrigger lint-and-test after runner cancel (#4096)

* ci: retrigger lint-and-test after runner cancel (attempt 3, #4096)

---------

Co-authored-by: binaricat <binaricat@users.noreply.github.com>
2026-11-02 17:55:42 -08:00
github-actions[bot]andgithub-actions[bot] 8043178d54 chore: update star history charts [skip ci] (#4087)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-11-02 00:07:46 +00:00
陈大猫 9fb2a5c8c9 fix(terminal): honor Settings custom shell when Terminal button clicked (#4085) (#4086)
* fix(terminal): honor Settings custom shell when Terminal button clicked (#4085)

Vault/host-tree Terminal buttons wired onClick={handler}, so React passed the
MouseEvent as the shell arg and skipped resolveShellSetting(terminalSettings.localShell).
Only treat objects with a string command as explicit shells; wrap onClick sites.

* ci: retrigger after lint-and-test runner cancel (#4085)

* test(terminal): accept wrapped local-shell onClick in host-tree toolbar (#4085)

Codex GH review: source assertion still expected onClick={onCreateLocalTerminal}
after wrappers; update to () => onCreateLocalTerminal() so npm test passes.
2026-11-01 06:04:01 -08:00
陈大猫andbinaricat 70ce1258f9 fix(tray): do not force-show main window when starting port forward (#4081) (#4082)
Only call openMainWindowReady() when the runtime is locked (UI needed to
unlock). Unlocked tray starts deliver via sendToMainWindow with focus:false
so a hidden/minimized main window stays in the background.

Fixes #4081

Co-authored-by: binaricat <binaricat@users.noreply.github.com>
2026-10-31 21:14:33 -07:00
陈大猫andbinaricat c01d057a7d fix(mcp): recognize Oh My Zsh ➜ idle prompts (#4079)
* fix(mcp): recognize Oh My Zsh ➜ idle prompts

Oh My Zsh robbyrussell paints idle prompts like `➜  ~` / `➜  ~ git:(main) ✗`.
The POSIX idle whitelist never matched leading Unicode ➜, so MCP
terminal_execute stayed SHELL_NOT_READY after a session switched into zsh.
Accept common robbyrussell ➜ lines (two-space theme form and `➜ ~`) and
still reject pending input and bare ➜.

Fixes #4078

* chore: retrigger CI after unrelated PluginAIContextHost flake

* chore: retrigger CI (lint-and-test hung after cancelled plugin-verify step)

* chore: empty commit to retrigger CI

---------

Co-authored-by: binaricat <binaricat@users.noreply.github.com>
2026-10-31 05:22:08 -07:00
陈大猫 cd6e1c6990 fix(mcp): recognize Powerlevel10k ❯ idle prompts (#4076)
Powerlevel10k paints idle prompts like `~ ❯` / `alice@host ~/src ❯`.
The POSIX idle whitelist never matched Unicode ❯, so MCP terminal_execute
stayed SHELL_NOT_READY while bash `$` worked. Accept common p10k ❯ lines
and still reject pending input and bare ❯.

Fixes #4075
2026-10-31 01:38:41 -07:00
陈大猫 42c69902e8 fix(ai): strip Anthropic signed thinking when context prefix mutates (#4073) (#4074)
* fix(ai): strip Anthropic signed thinking when context prefix mutates (#4073)

Anthropic preserved-thinking signatures bind to the exact conversation
prefix. Step compression/prune and durable summary keep-tail were
replaying later signed blocks after mutating earlier tool_results,
causing stable 400 Invalid signature errors on long tool chats.

Strip signed reasoning when prepareStep didAdjust, skip signatures on
durable-summary tails, and one-shot retry after matching 400.

* fix(ai): propagate Anthropic signature stream errors with progress-aware retry (#4074)

P1-1: rethrow signature failures from cattyStreamProcessor error chunks
(and reader catch) via createCattyAnthropicSignatureRetryError so the
turn-driver strip-and-retry path can see them.

P1-2: mirror the 413 hadToolProgress path on signature retry — rebuild
from latest session, retain completed tool results, new assistant
message, and enable write replay before stripping/retrying.

* fix(ai): preserve plain-object Anthropic signature messages in retry wrapper (#4074)

* fix(ai): only strip Anthropic signed thinking when provider allows removal (#4074)

DeepSeek Anthropic-compat requires thinking blocks with replayed tool calls;
gate durable-summary and prepareStep strips behind providerAllowsAnthropicSignedThinkingRemoval. Keep 400 invalid-signature strip-and-retry unchanged.

* chore: retrigger CI after runner shutdown cancel on lint-and-test (#4074)
2026-10-30 21:36:19 -07:00
陈大猫andbinaricat 780d297591 fix(terminal): forward xterm onBinary for legacy mouse (#3811) (#4066)
* fix(terminal): forward xterm onBinary for legacy mouse (#3811)

Legacy DEC mouse (ttymouse=xterm2) is emitted via onBinary; only onData
was wired. Preserve 0-255 through the write path with a binary/latin1
Buffer so high coordinate bytes are not UTF-8-corrupted.

* chore: retrigger CI after lint-and-test runner shutdown (exit 143)

* chore: retrigger CI after lint-and-test runner shutdown (2)

---------

Co-authored-by: binaricat <binaricat@users.noreply.github.com>
2026-10-30 07:11:27 -07:00
GOUKI9999 b496b00d2f feat: bounded recovery for scoped disconnected sessions (#3935)
Contributor GOUKI9999 session_reconnect/session_recover. Maintainer gate fix: do not report ready while still offline/connecting (pending instead); connected keeps advisory shellReady; regenerate capability tool specs. Unit tests 12/12. Electron smoke PASS (SSH shell path). Residual P2 hung-worker readiness left timeout-bounded. No release.
2026-10-29 19:32:02 -07:00
Joseph Hau a8c69b8e11 docs: propose Context Chips for the terminal compose bar (#4059)
* docs: propose terminal compose bar Context Chips

* docs: fix Context Chips baseline and demo anchoring
2026-10-30 10:09:24 +08:00
6d687d570f fix(sftp): keep non-ASCII local paths intact in Windows compressed upload (#4057)
* fix(sftp): keep non-ASCII local paths intact in Windows compressed upload

Windows tar.exe converts argv to the ANSI code page, turning non-ASCII
characters into '?'. Run tar with cwd instead of -C and stream the
archive through stdout instead of -f, so local paths never pass through
argv. Folders whose own name is non-ASCII fall back to per-file upload
on Windows.

* fix(sftp): exclude in-tree tar archive under GNU tar stdout

* fix(sftp): resolve trusted local tar before cwd switch

Use the Windows System32 tar.exe for local availability checks and compression so an upload sibling tar.exe cannot hijack execution. Keep Unix tar lookup unchanged and update command assertions.

Residual P2: exclude patterns still interpret glob metacharacters in archive paths; glob escaping is intentionally deferred.

* ci: retrigger lint-and-test after stuck runner

* ci: retrigger after unrelated plugin manager UI flake

---------

Co-authored-by: noon.li <214648221+lyon-le@users.noreply.github.com>
Co-authored-by: 陈大猫 <16399091+binaricat@users.noreply.github.com>
2026-10-29 05:38:59 -07:00
陈大猫 a2f23054df fix(sftp): keep non-ASCII local paths intact in Windows compressed upload (#4058)
Gate for contributor #4057 (same tip). cwd+stdout FD instead of -C/-f for Windows tar.exe ANSI argv; GNU tar --exclude for in-tree archive; resolve System32 tar.exe before cwd switch.

Local Codex CLEAN on 73ff25a31 (residual P2 glob-escape deferred). Electron smoke PASS NC_OUT pr-4057-73ff25a31. CI green on tip after plugin-manager focus flake retrigger.
2026-10-29 05:38:44 -07:00
Joseph Hau 0fe069e575 fix(terminal): preserve output timestamps after clear (#4056)
* fix(terminal): preserve output timestamps after clear

* fix(terminal): limit timestamp checkpoints to erases
2026-10-29 02:08:05 -07:00
GOUKI9999and陈大猫 1809b7dc02 fix(sync): keep full AI chat history when cloud sync is enabled (#4047)
* fix(sync): keep full history when AI chat sync is enabled

Retain all messages when cloud AI-session sync is enabled; keep the 500-message limit for local-only chats. Cover both settings with a history-retention regression test.

Rebase onto origin/main and retain #4045 preserveActive behavior and its no-cancel regression coverage unchanged. Drop the duplicate wait-before-merge loop, Waiting-for-Agent UI and i18n, sync status and streaming wait helpers, hasActiveTurn, wait-only tests, and docs plan.

Validation: all 23 tests pass across useAIState.historyRetention.test.ts, syncPayload.aiSessions.test.ts, and agentStop.test.ts. Targeted ESLint and git diff --check pass.

* chore: retrigger CI after cancelled lint-and-test

Previous Actions run 40824800880 cancelled mid plugin-contract step; windows jobs were already green. No code change.

* chore: retrigger CI after cancelled lint-and-test (Codex local PASS)

---------

Co-authored-by: 陈大猫 <16399091+binaricat@users.noreply.github.com>
2026-10-28 23:58:42 -07:00
陈大猫 d5a03606b7 feat: rebuild plugin UI with host rendering and standard settings (#4053)
* feat: rebuild plugin pages with host rendering and native settings

* fix(plugins): contain host-view errors, isolate editing, and migrate fixtures

* fix(plugins): migrate remaining permission fixture hosts
2026-10-28 23:08:31 -07:00
陈大猫 7cd57adcea fix(terminal): PTY OSC7 install when exec cannot verify shell (#4052) (#4054)
Fixes #4052. Electron smoke PASS; Codex CLEAN; CI green.
2026-10-29 12:48:32 +08:00
208 changed files with 9960 additions and 969 deletions

No files matched your search

+3
View File
@@ -50,6 +50,9 @@ function SettingsI18nProvider({ children }: { children: React.ReactNode }) {
* shell bags; AppSideEffects owns reactive effects + local UI publishing.
*/
function App() {
useEffect(() => {
performance.mark('netcatty:renderer:app-mounted');
}, []);
return (
<AppLocalStateProvider>
<MonitorRuntimeHost />
@@ -0,0 +1,84 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { handleCreateLocalTerminalImpl } from './AppHandlers';
type CreatedLocal = {
shell?: string;
shellArgs?: string[];
shellName?: string;
shellIcon?: string;
localStartDir?: string;
shellType?: string;
};
function createCtx(overrides?: {
localShell?: string;
localShellArgs?: string[];
discoveredShells?: Array<{ id: string; name: string; command: string; args?: string[]; icon?: string }>;
}) {
const created: CreatedLocal[] = [];
const logs: Array<{ hostLabel: string }> = [];
const localShell = overrides?.localShell ?? '';
const localShellArgs = overrides?.localShellArgs ?? [];
const discoveredShells = overrides?.discoveredShells ?? [];
const getCtx = () => ({
addConnectionLog: (entry: { hostLabel: string }) => {
logs.push(entry);
},
classifyLocalShellType: () => 'unknown',
createLocalTerminal: (options: CreatedLocal) => {
created.push(options);
return 'session-1';
},
discoveredShells,
resolveShellSetting: (
value: string,
shells: Array<{ id: string; command: string; args?: string[] }>,
customArgs?: string[],
) => {
if (!value) return null;
const match = shells.find((shell) => shell.id === value);
if (match) {
return { command: match.command, args: customArgs?.length ? customArgs : match.args };
}
return { command: value, args: customArgs?.length ? customArgs : undefined };
},
systemInfoRef: { current: { username: 'qi', hostname: 'box' } },
terminalSettings: { localShell, localShellArgs },
});
return { getCtx, created, logs };
}
test('create local terminal resolves Settings custom absolute path when no shell arg is passed', () => {
const custom = String.raw`C:\Users\lybing\AppData\Local\Programs\nu\bin\nu.exe`;
const { getCtx, created } = createCtx({ localShell: custom });
handleCreateLocalTerminalImpl(getCtx as never);
assert.equal(created.length, 1);
assert.equal(created[0].shell, custom);
});
test('create local terminal ignores MouseEvent-like first args and still uses Settings shell (#4085)', () => {
const custom = String.raw`C:\Users\lybing\AppData\Local\Programs\nu\bin\nu.exe`;
const { getCtx, created } = createCtx({ localShell: custom });
// Vault header historically used onClick={handleCreateLocalTerminal}, so React
// passes the click event as the first argument.
const fakeClickEvent = { type: 'click', target: {}, preventDefault() {} };
handleCreateLocalTerminalImpl(getCtx as never, fakeClickEvent as never);
assert.equal(created.length, 1);
assert.equal(created[0].shell, custom);
});
test('create local terminal still honors an explicit shell object', () => {
const { getCtx, created } = createCtx({ localShell: 'pwsh' });
handleCreateLocalTerminalImpl(getCtx as never, {
command: String.raw`D:\portable\zsh\zsh.exe`,
args: ['-l'],
name: 'Portable Zsh',
icon: 'zsh',
});
assert.equal(created.length, 1);
assert.equal(created[0].shell, String.raw`D:\portable\zsh\zsh.exe`);
assert.deepEqual(created[0].shellArgs, ['-l']);
assert.equal(created[0].shellName, 'Portable Zsh');
assert.equal(created[0].shellIcon, 'zsh');
});
+12 -7
View File
@@ -1138,11 +1138,18 @@ export function handleCreateLocalTerminalImpl(
const { addConnectionLog, classifyLocalShellType, createLocalTerminal, discoveredShells, resolveShellSetting, systemInfoRef, terminalSettings } = getCtx();
{
const { username, hostname } = systemInfoRef.current;
const resolved = shell ?? resolveShellSetting(terminalSettings.localShell, discoveredShells, terminalSettings.localShellArgs);
// Vault/header buttons historically wired `onClick={handleCreateLocalTerminal}`.
// React then passes the MouseEvent as the first arg; treat only real shell
// objects (string command) as explicit so Settings localShell still applies (#4085).
const explicitShell = shell && typeof shell.command === 'string' ? shell : undefined;
const resolved = explicitShell
?? resolveShellSetting(terminalSettings.localShell, discoveredShells, terminalSettings.localShellArgs);
// Match by ID (not command) to avoid WSL distros all sharing wsl.exe
const matchedShell = !shell ? discoveredShells.find(s => s.id === terminalSettings.localShell) : undefined;
const shellName = shell?.name ?? matchedShell?.name;
const shellIcon = shell?.icon ?? matchedShell?.icon;
const matchedShell = !explicitShell
? discoveredShells.find(s => s.id === terminalSettings.localShell)
: undefined;
const shellName = explicitShell?.name ?? matchedShell?.name;
const shellIcon = explicitShell?.icon ?? matchedShell?.icon;
const sessionId = createLocalTerminal({
shellType: classifyLocalShellType(resolved?.command || terminalSettings.localShell, navigator.userAgent),
shell: resolved?.command,
@@ -1272,16 +1279,14 @@ export function hasMultipleProtocolsImpl(getCtx: AppContextGetter, host: Host) {
}
export function handleHostConnectWithProtocolCheckImpl(getCtx: AppContextGetter, host: Host) {
const { handleConnectToHost, hasMultipleProtocols, resolveEffectiveHost, setIsQuickSwitcherOpen, setProtocolSelectHost, setQuickSearch } = getCtx();
const { handleConnectToHost, hasMultipleProtocols, resolveEffectiveHost, setIsQuickSwitcherOpen, setProtocolSelectHost } = getCtx();
{
if (hasMultipleProtocols(host)) {
setProtocolSelectHost(resolveEffectiveHost(host));
setIsQuickSwitcherOpen(false);
setQuickSearch('');
} else {
handleConnectToHost(host);
setIsQuickSwitcherOpen(false);
setQuickSearch('');
}
}
}
+11 -2
View File
@@ -139,12 +139,21 @@ test('ChromeHost builds from chrome settings + session/vault snapshots', () => {
assert.doesNotMatch(source, /\buseAppSettingsRuntime\s*\(/);
});
test('DialogsHost builds from local dialog state + selective vault snapshot', () => {
test('DialogsHost builds from local dialog state without vault runtime hooks', () => {
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*\(/);
// Quick switcher search is owned by QuickSwitcherOverlay; republishing the
// dialogs bag per keystroke re-rendered the whole shell.
assert.doesNotMatch(source, /quickSearch|getHostSearchMatch/);
});
test('QuickSwitcherOverlay owns the quick switcher query', () => {
const overlay = readFileSync(join(here, 'QuickSwitcherOverlay.tsx'), 'utf8');
assert.match(overlay, /useState\(''\)/);
const store = readFileSync(join(here, 'appLocalUiStore.ts'), 'utf8');
assert.doesNotMatch(store, /quickSearch/);
});
test('AppSideEffects may use runtime hooks; App must not', () => {
+24 -8
View File
@@ -1,5 +1,5 @@
import React, { useCallback, useEffect, useEffectEvent, useLayoutEffect, useMemo, useRef, useState } from 'react';
import { registerAppHandlers } from './appHandlersBridge';
import { registerAppHandlers, stabilizeAppHandlers, type StableAppHandlerCache } from './appHandlersBridge';
import { publishAppLocalUi } from './appLocalUiStore';
import { activeTabStore, toEditorTabId, fromEditorTabId, isEditorTabId } from '../state/activeTabStore';
import { useAutoSync } from '../state/useAutoSync';
@@ -19,6 +19,8 @@ import {
} from '../state/connectionLogsStore';
import { getNotesSnapshot } from '../state/notesStore';
import { useVaultAgentBridge } from '../state/useVaultAgentBridge';
import { terminalReconnectRegistry } from '../state/terminalReconnectRegistry';
import { requestAgentSessionReconnect } from '../state/agentSessionReconnect';
import { useWindowControls } from '../state/useWindowControls';
import { useTerminalKeyboardFocus } from '../state/useTerminalKeyboardFocus';
import { editorTabStore, useEditorTabChromeList } from '../state/editorTabStore';
@@ -134,7 +136,6 @@ export function AppSideEffects() {
| { mode: 'create' }
| null
>(null);
const [quickSearch, setQuickSearch] = useState('');
// Protocol selection dialog state for QuickSwitcher
const [protocolSelectHost, setProtocolSelectHost] = useState<Host | null>(null);
// Navigation state for VaultView sections
@@ -1527,6 +1528,19 @@ export function AppSideEffects() {
sessions, hosts, keys, identities, resolveEffectiveHost,
});
const reconnectSessionForVaultAgent = useCallback((sessionId: string) => {
const session = sessions.find((entry) => entry.id === sessionId);
return requestAgentSessionReconnect(sessionId, session?.status, terminalReconnectRegistry);
}, [sessions]);
const inspectSessionRecoveryForVaultAgent = useCallback((sessionId: string) => {
const session = sessions.find((entry) => entry.id === sessionId);
if (!session) return { ok: false, error: `Session "${sessionId}" was not found.` };
const live = terminalReconnectRegistry.inspect(sessionId);
return { ok: true, sessionId, status: session.status, waitingForInput: false,
reconnecting: false, reconnectAllowed: false, ...live };
}, [sessions]);
const closeSessionForVaultAgent = useCallback((sessionId: string) => {
if (!sessions.some((session) => session.id === sessionId)) {
return { ok: false as const, error: `Session "${sessionId}" was not found.` };
@@ -1571,6 +1585,8 @@ export function AppSideEffects() {
openHost: openHostForVaultAgent,
findExistingSessionForHost: findExistingSessionForHostForVaultAgent,
closeSession: closeSessionForVaultAgent,
reconnectSession: reconnectSessionForVaultAgent,
inspectSessionRecovery: inspectSessionRecoveryForVaultAgent,
getScriptSessionMeta: (sessionId) => sessions.find((session) => session.id === sessionId),
});
@@ -1947,7 +1963,7 @@ export function AppSideEffects() {
const hasMultipleProtocols = useCallback((host: Host) => { return hasMultipleProtocolsImpl(() => ({ host, resolveEffectiveHost }), host); }, [resolveEffectiveHost]);
// Handle host connect with protocol selection (used by QuickSwitcher)
const handleHostConnectWithProtocolCheck = useCallback((host: Host) => { return handleHostConnectWithProtocolCheckImpl(() => ({ handleConnectToHost, hasMultipleProtocols, host, resolveEffectiveHost, setIsQuickSwitcherOpen, setProtocolSelectHost, setQuickSearch }), host); }, [hasMultipleProtocols, handleConnectToHost, resolveEffectiveHost]);
const handleHostConnectWithProtocolCheck = useCallback((host: Host) => { return handleHostConnectWithProtocolCheckImpl(() => ({ handleConnectToHost, hasMultipleProtocols, host, resolveEffectiveHost, setIsQuickSwitcherOpen, setProtocolSelectHost }), host); }, [hasMultipleProtocols, handleConnectToHost, resolveEffectiveHost]);
// Handle protocol selection from dialog
const handleProtocolSelect = useCallback((protocol: HostProtocol, port: number) => { return handleProtocolSelectImpl(() => ({ handleConnectToHost, port, protocol, protocolSelectHost, setProtocolSelectHost }), protocol, port); }, [protocolSelectHost, handleConnectToHost]);
@@ -2015,8 +2031,11 @@ export function AppSideEffects() {
// Hosts assemble domain bags from stores + this flat glue. Do NOT register
// catalog arrays or prepared vault/terminal/chrome/dialogs domain bags here.
// Handlers are registered behind stable per-key wrappers so session/host
// churn does not re-notify every Host (see stabilizeAppHandlers).
const appHandlerCacheRef = useRef<StableAppHandlerCache>(new Map());
useLayoutEffect(() => {
registerAppHandlers({
registerAppHandlers(stabilizeAppHandlers(appHandlerCacheRef.current, {
// Vault glue
handleAddKnownHost,
handleDeleteHost,
@@ -2076,15 +2095,13 @@ export function AppSideEffects() {
setIsCreateWorkspaceOpen,
setIsQuickSwitcherOpen,
setProtocolSelectHost,
setQuickSearch,
handleCancelDeleteHost,
handleConfirmDeleteHost,
});
}));
publishAppLocalUi({
isQuickSwitcherOpen,
isCreateWorkspaceOpen,
addToWorkspaceDialog,
quickSearch,
protocolSelectHost,
navigateToSection,
deepLinkHostDraft,
@@ -2150,7 +2167,6 @@ export function AppSideEffects() {
isQuickSwitcherOpen,
isCreateWorkspaceOpen,
addToWorkspaceDialog,
quickSearch,
protocolSelectHost,
navigateToSection,
deepLinkHostDraft,
+51 -54
View File
@@ -46,6 +46,7 @@ import { getAvailablePaneMagnificationController } from '../../domain/paneMagnif
import { pluginViewTabStore, usePluginViewTabs } from '../state/pluginViewTabStore';
import { buildPluginSettingScopeCatalog } from '../state/usePluginSettingScopeCatalog';
import { useWorkSurfaceHostEditor } from '../state/useWorkSurfaceHostEditor';
import { QuickSwitcherOverlay } from './QuickSwitcherOverlay';
import {
appViewDomainsEqual,
mergeAppViewDomains,
@@ -53,9 +54,6 @@ import {
} from './appViewDomains';
const LazyProtocolSelectDialog = lazy(() => import('../../components/ProtocolSelectDialog'));
const LazyQuickSwitcher = lazy(() =>
import('../../components/QuickSwitcher').then((m) => ({ default: m.QuickSwitcher })),
);
const LazyCreateWorkspaceDialog = lazy(() =>
import('../../components/CreateWorkspaceDialog').then((m) => ({ default: m.CreateWorkspaceDialog })),
);
@@ -63,6 +61,9 @@ const LazyTextEditorTabView = lazy(() =>
import('../../components/editor/TextEditorTabView').then((m) => ({ default: m.TextEditorTabView })),
);
// Module-level so TopTabs' memo comparator sees a stable callback.
const focusEditorWindowTab = (id: string) => { void focusEditorInWindow(id); };
const TextEditorTabFallback = ({ tabId }: { tabId: string }) => {
const isVisible = useIsEditorTabActive(tabId);
const hostTreeLayoutWidth = useTerminalHostTreeLayoutWidth();
@@ -242,12 +243,10 @@ function AppViewInner({ domains }: AppViewProps) {
setIsCreateWorkspaceOpen,
setIsQuickSwitcherOpen,
setProtocolSelectHost,
setQuickSearch,
} = ctx;
const dismissTransientOverlays = useCallback(() => {
setIsQuickSwitcherOpen(false);
setQuickSearch('');
setIsCreateWorkspaceOpen(false);
setProtocolSelectHost(null);
setAddToWorkspaceDialog(null);
@@ -260,7 +259,6 @@ function AppViewInner({ domains }: AppViewProps) {
setIsCreateWorkspaceOpen,
setIsQuickSwitcherOpen,
setProtocolSelectHost,
setQuickSearch,
]);
useMainWindowInputFocusRecovery({ onPageHidden: dismissTransientOverlays });
@@ -277,7 +275,7 @@ function AppViewInner({ domains }: AppViewProps) {
handleRequestCloseEditorTabRef, handleSessionStatusChange, handleSyncNowManual, handleTerminalDataCapture, handleUpdateHostFromTerminal,
hostById, hosts, terminalHosts, updateTerminalHosts, hotkeyScheme, identities, importOrReuseKey, isBroadcastEnabled, isComposeSendToAll, onToggleComposeSendTarget, isGlobalBroadcastEnabled, isGlobalComposeSendToAll, onToggleGlobalBroadcast, onToggleGlobalComposeSendTarget, canUseGlobalBroadcast, isCreateWorkspaceOpen, isMacClient, isQuickSwitcherOpen,
keyBindings, keyboardInteractiveQueue, keys, logViews, managedSources, navigateToSection, openLogView, openNoteRequest, orderedTabsWithEditors, orphanSessions,
passphraseQueue, fidoPromptQueue, protocolSelectHost, proxyProfiles, portForwardingRules, quickResults, quickSearch, removeSessionFromWorkspace, reorderWorkTabs, reorderWorkspaceSessions,
passphraseQueue, fidoPromptQueue, protocolSelectHost, proxyProfiles, portForwardingRules, removeSessionFromWorkspace, reorderWorkTabs, reorderWorkspaceSessions,
resolveEmptyVaultConflict, resolveSessionAppearance, runSnippet, sessionLogsDir, sessionLogsEnabled, sessionLogsFormat, sessionLogsTimestampsEnabled, sessionRenameTarget, sshDebugLogsEnabled,
sessions, setActiveTabId, setDeepLinkHostDraft, setDraggingSessionId, setEditorWordWrap,
setNavigateToSection, setTerminalFontFamilyId, setTerminalFontSize, setVaultFocusRequest, updateSessionFontSize, updateSessionRestoreCwd, updateSessionDynamicTitle, updateSessionCodingCliProvider, clearSessionFontSizeOverride,
@@ -506,6 +504,16 @@ function AppViewInner({ domains }: AppViewProps) {
await popOutEditorTab(id, host?.label);
}, [hostById]);
// Stable editor-tab callbacks so memoized TextEditorTabView/TextEditorPane
// skip re-rendering when unrelated AppView state changes.
const requestCloseEditorTabFromView = useCallback(
(id: string) => handleRequestCloseEditorTabRef.current(id),
[handleRequestCloseEditorTabRef],
);
const popOutEditorTabFromView = useCallback((id: string) => {
void handlePopOutEditorTab(id);
}, [handlePopOutEditorTab]);
const handleSaveSessionRename = useCallback((name: string) => {
if (!sessionRenameTarget) return;
if (!name.trim()) return;
@@ -583,7 +591,7 @@ function AppViewInner({ domains }: AppViewProps) {
onClosePluginViewTab={closePluginViewTab}
onRequestCloseEditorTab={handleRequestCloseEditorTab}
onPopOutEditorTab={handlePopOutEditorTab}
onFocusEditorWindowTab={(id) => { void focusEditorInWindow(id); }}
onFocusEditorWindowTab={focusEditorWindowTab}
hostById={hostById}
/>
@@ -847,8 +855,8 @@ function AppViewInner({ domains }: AppViewProps) {
hotkeyScheme={hotkeyScheme}
keyBindings={keyBindings}
hostById={hostById}
onRequestClose={(id) => handleRequestCloseEditorTabRef.current(id)}
onPopOut={(id) => { void handlePopOutEditorTab(id); }}
onRequestClose={requestCloseEditorTabFromView}
onPopOut={popOutEditorTabFromView}
/>
</Suspense>
</LazyLoadBoundary>
@@ -937,50 +945,39 @@ function AppViewInner({ domains }: AppViewProps) {
)}
{isQuickSwitcherOpen && (
<LazyLoadBoundary name="Quick switcher" resetKey={quickSearch}>
<Suspense fallback={null}>
<LazyQuickSwitcher
isOpen={isQuickSwitcherOpen}
query={quickSearch}
results={quickResults}
sessions={sessions}
workspaces={workspaces}
showSftpTab={showSftpTab}
onQueryChange={setQuickSearch}
onSelect={handleHostConnectWithProtocolCheck}
onQuickConnect={openQuickConnectFromInput}
onEditHost={(host) => {
setIsQuickSwitcherOpen(false);
setQuickSearch('');
handleEditHostFromOverlay(host);
}}
onSelectTab={(tabId) => {
setActiveTabId(tabId);
setIsQuickSwitcherOpen(false);
setQuickSearch('');
}}
onCreateLocalTerminal={(shell) => {
handleCreateLocalTerminal(shell);
setIsQuickSwitcherOpen(false);
setQuickSearch('');
}}
onCreateWorkspace={() => {
setIsQuickSwitcherOpen(false);
setQuickSearch('');
setAddToWorkspaceDialog({ mode: 'create' });
}}
onClose={() => {
setIsQuickSwitcherOpen(false);
setQuickSearch('');
}}
keyBindings={keyBindings}
terminalSettings={terminalSettings}
paneMagnificationState={paneMagnificationState}
onMagnifyCurrentPane={handleMagnifyCurrentPane}
onRestoreMagnifiedPane={handleRestoreMagnifiedPane}
/>
</Suspense>
</LazyLoadBoundary>
<QuickSwitcherOverlay
hosts={hosts}
isOpen={isQuickSwitcherOpen}
sessions={sessions}
workspaces={workspaces}
showSftpTab={showSftpTab}
onSelect={handleHostConnectWithProtocolCheck}
onQuickConnect={openQuickConnectFromInput}
onEditHost={(host) => {
setIsQuickSwitcherOpen(false);
handleEditHostFromOverlay(host);
}}
onSelectTab={(tabId) => {
setActiveTabId(tabId);
setIsQuickSwitcherOpen(false);
}}
onCreateLocalTerminal={(shell) => {
handleCreateLocalTerminal(shell);
setIsQuickSwitcherOpen(false);
}}
onCreateWorkspace={() => {
setIsQuickSwitcherOpen(false);
setAddToWorkspaceDialog({ mode: 'create' });
}}
onClose={() => {
setIsQuickSwitcherOpen(false);
}}
keyBindings={keyBindings}
terminalSettings={terminalSettings}
paneMagnificationState={paneMagnificationState}
onMagnifyCurrentPane={handleMagnifyCurrentPane}
onRestoreMagnifiedPane={handleRestoreMagnifiedPane}
/>
)}
<RenameDraftDialog
+48
View File
@@ -0,0 +1,48 @@
import React, { Suspense, lazy, useMemo, useState } from 'react';
import type { QuickSwitcherProps } from '../../components/QuickSwitcher';
import { LazyLoadBoundary } from '../../components/ui/lazy-load-boundary';
import { getHostSearchMatch } from '../../lib/searchMatcher';
import type { Host } from '../../types';
const LazyQuickSwitcher = lazy(() =>
import('../../components/QuickSwitcher').then((m) => ({ default: m.QuickSwitcher })),
);
/** Hosts matching the quick switcher query, best match first. */
export function filterQuickSwitcherHosts(hosts: Host[], query: string): Host[] {
const term = query.trim();
if (!term) return hosts;
return hosts
.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);
}
export type QuickSwitcherOverlayProps =
Omit<QuickSwitcherProps, 'query' | 'results' | 'onQueryChange'> & { hosts: Host[] };
/**
* Owns the quick switcher query. It used to live in AppSideEffects and travel
* through the app-local UI store, so every keystroke re-rendered the whole
* shell (AppSideEffects, every host island, AppShell, AppView and TopTabs).
* The overlay is only mounted while the switcher is open, so closing it resets
* the query just like the old `setQuickSearch('')` calls did.
*/
export function QuickSwitcherOverlay({ hosts, ...props }: QuickSwitcherOverlayProps) {
const [query, setQuery] = useState('');
const results = useMemo(() => filterQuickSwitcherHosts(hosts, query), [hosts, query]);
return (
<LazyLoadBoundary name="Quick switcher" resetKey={query}>
<Suspense fallback={null}>
<LazyQuickSwitcher {...props} query={query} results={results} onQueryChange={setQuery} />
</Suspense>
</LazyLoadBoundary>
);
}
+45
View File
@@ -0,0 +1,45 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
getAppHandlers,
registerAppHandlers,
stabilizeAppHandlers,
subscribeAppHandlers,
type StableAppHandlerCache,
} from './appHandlersBridge';
test('stabilized handlers keep their identity and call the latest implementation', () => {
const cache: StableAppHandlerCache = new Map();
const ref = { current: 1 };
const calls: string[] = [];
const first = stabilizeAppHandlers(cache, {
closeTabsBatch: (id: string) => calls.push(`v1:${id}`),
toggleSidePanelRef: ref,
});
const second = stabilizeAppHandlers(cache, {
closeTabsBatch: (id: string) => calls.push(`v2:${id}`),
toggleSidePanelRef: ref,
});
assert.equal(second.closeTabsBatch, first.closeTabsBatch);
assert.equal(second.toggleSidePanelRef, ref, 'non-function values pass through');
(first.closeTabsBatch as (id: string) => void)('a');
assert.deepEqual(calls, ['v2:a']);
});
test('the bridge does not notify Hosts when only handler implementations change', () => {
const cache: StableAppHandlerCache = new Map();
let notified = 0;
const unsubscribe = subscribeAppHandlers(() => { notified += 1; });
try {
registerAppHandlers(stabilizeAppHandlers(cache, { handleDeleteHost: () => 'v1' }));
const afterFirst = notified;
// A session status change recreated the callback (it closes over hosts).
registerAppHandlers(stabilizeAppHandlers(cache, { handleDeleteHost: () => 'v2' }));
assert.equal(notified, afterFirst);
assert.equal((getAppHandlers()!.handleDeleteHost as () => string)(), 'v2');
} finally {
unsubscribe();
registerAppHandlers(null);
}
});
+31
View File
@@ -51,6 +51,37 @@ class AppHandlersBridge {
const bridge = new AppHandlersBridge();
export type StableAppHandlerCache = Map<string, { latest: unknown; stable: unknown }>;
/**
* Give every function-valued handler a per-key wrapper with a stable identity
* that always calls the latest implementation; non-functions (refs) pass
* through. Many glue callbacks close over sessions/workspaces/hosts, so their
* identities changed on every session status change and host update (connect
* bursts, last-connected times), and the bridge re-notified every Host island,
* rebuilding all shell bags and re-rendering AppView/TopTabs each time. The
* handlers are actions invoked from events, never read during render.
*/
export function stabilizeAppHandlers(cache: StableAppHandlerCache, handlers: AppHandlers): AppHandlers {
const stableHandlers: AppHandlers = {};
for (const [key, value] of Object.entries(handlers)) {
if (typeof value !== 'function') {
stableHandlers[key] = value;
continue;
}
let entry = cache.get(key);
if (!entry) {
const created: { latest: unknown; stable: unknown } = { latest: value, stable: null };
created.stable = (...args: unknown[]) => (created.latest as (...callArgs: unknown[]) => unknown)(...args);
cache.set(key, created);
entry = created;
}
entry.latest = value;
stableHandlers[key] = entry.stable;
}
return stableHandlers;
}
export function registerAppHandlers(handlers: AppHandlers | null): void {
bridge.set(handlers);
}
-3
View File
@@ -24,7 +24,6 @@ export type AppLocalUiSnapshot = {
| { mode: 'append'; workspaceId: string }
| { mode: 'create' }
| null;
quickSearch: string;
protocolSelectHost: Host | null;
navigateToSection: VaultSection | null;
deepLinkHostDraft: Host | null;
@@ -43,7 +42,6 @@ export const EMPTY_APP_LOCAL_UI: AppLocalUiSnapshot = Object.freeze({
isQuickSwitcherOpen: false,
isCreateWorkspaceOpen: false,
addToWorkspaceDialog: null,
quickSearch: '',
protocolSelectHost: null,
navigateToSection: null,
deepLinkHostDraft: null,
@@ -76,7 +74,6 @@ class AppLocalUiStore {
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
+3 -29
View File
@@ -1,9 +1,6 @@
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,
@@ -11,16 +8,14 @@ import {
} 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.
* Dialogs island: local dialog/queue state from `appLocalUiStore`. The quick
* switcher owns its query and host results (QuickSwitcherOverlay), so typing
* there no longer republishes this bag.
* 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,
@@ -28,22 +23,6 @@ export function DialogsHost() {
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 {
@@ -68,14 +47,11 @@ export function DialogsHost() {
passphraseQueue: local.passphraseQueue,
fidoPromptQueue: local.fidoPromptQueue,
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,
@@ -88,8 +64,6 @@ export function DialogsHost() {
local.passphraseQueue,
local.fidoPromptQueue,
local.protocolSelectHost,
local.quickSearch,
quickResults,
]);
const overlays = useMemo(() => {
@@ -14,7 +14,22 @@ function loadComponent(path: string, name: string, bindings: Record<string, unkn
const code = ts.transpile(declaration.getText(ast).replace(/^export /, ''), {
target: ts.ScriptTarget.ES2022, jsx: ts.JsxEmit.React,
});
return vm.runInNewContext(`${code}; ${name}`, { React, ...bindings }) as React.ComponentType;
// Extracting the function drops imports, so inject bare React hooks/APIs into the sandbox.
return vm.runInNewContext(`${code}; ${name}`, {
React,
useState: React.useState,
useEffect: React.useEffect,
useLayoutEffect: React.useLayoutEffect,
useMemo: React.useMemo,
useCallback: React.useCallback,
useRef: React.useRef,
useReducer: React.useReducer,
useContext: React.useContext,
memo: React.memo,
createElement: React.createElement,
Fragment: React.Fragment,
...bindings,
}) as React.ComponentType;
}
test('monitor controls update their runtime without rendering unrelated app siblings', async () => {
@@ -32,6 +47,7 @@ test('monitor controls update their runtime without rendering unrelated app sibl
};
const MonitorRuntimeHost = loadComponent('./MonitorRuntimeHost.tsx', 'MonitorRuntimeHost', { useMonitorRuntime });
const App = loadComponent('../../App.tsx', 'App', {
performance: { mark: () => {} },
useMonitorRuntime,
MonitorRuntimeHost,
AppLocalStateProvider: ({ children }: { children: React.ReactNode }) => children,
@@ -0,0 +1,39 @@
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { dirname, join } from 'node:path';
import test from 'node:test';
import { fileURLToPath } from 'node:url';
// Guards for what the startup bundle (index.html entry) may pull in. Each of
// these used to land in the entry chunk that every window parses at launch.
const root = join(dirname(fileURLToPath(import.meta.url)), '..', '..');
const read = (path: string) => readFileSync(join(root, path), 'utf8');
const staticImports = (source: string) =>
[...source.matchAll(/^\s*import\s+(?!type\b)[^;]*?from\s+['"]([^'"]+)['"]/gm)].map((match) => match[1]);
test('only the English dictionary is bundled with the startup entry', () => {
const imports = staticImports(read('application/i18n/messages.ts'));
assert.deepEqual(imports.filter((path) => path.startsWith('./locales/')), ['./locales/en']);
});
test('startup modules do not statically import the AI runtime or SDK providers', () => {
const runtimeModules = /harness\/globalAgentRuntime|plugin\/trustedHostRuntime|plugin\/configuredModel|pluginAgent\/trustedAgentRuntime|sdk\/providers/;
for (const path of [
'application/state/aiStateSnapshots.ts',
'application/state/usePluginAIContextHost.ts',
'application/state/usePluginAgentHost.ts',
]) {
const offending = staticImports(read(path)).filter((specifier) => runtimeModules.test(specifier));
assert.deepEqual(offending, [], `${path} must load the AI runtime lazily`);
}
});
test("Vite's preload helper stays out of the vendor-ai chunk", () => {
// The entry imports the helper for its dynamic imports; if it is grouped
// with the AI SDK, the whole vendor-ai chunk is preloaded at startup.
assert.match(read('vite.config.ts'), /vite\/preload-helper'\)\) return 'preload-helper'/);
});
test('the QR decoder is loaded on demand', () => {
assert.ok(!staticImports(read('components/HostTotpFields.tsx')).includes('jsqr'));
});
+20 -13
View File
@@ -293,7 +293,10 @@ export function useAppStartupEffects(ctx: StartupEffectsContext) {
terminalSettings,
});
// Sync tray menu data + handle tray actions
// Sync tray menu data + handle tray actions. Hosts are only re-sent when the
// host list changed: session status updates (connect bursts) used to resend
// and re-serialize every host and rebuild the macOS Dock menu each time.
const lastTrayHostsRef = useRef<unknown>(null);
useEffect(() => {
if (!enabled) return;
const bridge = netcattyBridge.get();
@@ -316,17 +319,21 @@ export function useAppStartupEffects(ctx: StartupEffectsContext) {
};
});
const hostsForSystemMenu = hosts
.filter((host: any) => typeof host?.id === "string" && host.id.length > 0)
.map((host: any) => ({
id: host.id,
label: host.label,
hostname: host.hostname,
group: host.group,
pinned: host.pinned,
lastConnectedAt: host.lastConnectedAt,
protocol: host.protocol,
}));
const hostsChanged = lastTrayHostsRef.current !== hosts;
lastTrayHostsRef.current = hosts;
const hostsForSystemMenu = hostsChanged
? hosts
.filter((host: any) => typeof host?.id === "string" && host.id.length > 0)
.map((host: any) => ({
id: host.id,
label: host.label,
hostname: host.hostname,
group: host.group,
pinned: host.pinned,
lastConnectedAt: host.lastConnectedAt,
protocol: host.protocol,
}))
: undefined;
void bridge.updateTrayMenuData({
sessions: sessionsForTray,
@@ -334,7 +341,7 @@ export function useAppStartupEffects(ctx: StartupEffectsContext) {
...rule,
canStop: hasRuntimeTunnel(rule.id),
})),
hosts: hostsForSystemMenu,
...(hostsForSystemMenu ? { hosts: hostsForSystemMenu } : {}),
});
}, 250);
+46 -12
View File
@@ -1,6 +1,13 @@
import React, { createContext, useContext, useMemo } from 'react';
import React, { createContext, useContext, useEffect, useMemo, useSyncExternalStore } from 'react';
import { DEFAULT_UI_LOCALE, resolveSupportedLocale } from '../../infrastructure/config/i18n';
import { MESSAGES_BY_LOCALE } from './messages';
import {
getBaseFallbackLocale,
getLoadedLocaleMessages,
getLocaleMessagesVersion,
loadLocaleMessages,
subscribeLocaleMessages,
type Messages,
} from './messages';
type InterpolationValues = Record<string, string | number | boolean | null | undefined>;
@@ -29,32 +36,59 @@ const interpolate = (template: string, values?: InterpolationValues): string =>
.replace(/\{(\w+)\}/g, replaceSingleBraceToken);
};
const resolveMessage = (resolvedLocale: string, key: string): string | undefined => {
const direct = MESSAGES_BY_LOCALE[resolvedLocale]?.[key];
if (direct) return direct;
const base = resolvedLocale.split('-')[0];
const baseKey = Object.keys(MESSAGES_BY_LOCALE).find((k) => k === base || k.startsWith(`${base}-`));
const baseHit = baseKey ? MESSAGES_BY_LOCALE[baseKey]?.[key] : undefined;
if (baseHit) return baseHit;
return MESSAGES_BY_LOCALE[DEFAULT_UI_LOCALE]?.[key];
type LocaleDictionaries = {
direct?: Messages;
base?: Messages;
fallback?: Messages;
};
/**
* Dictionaries consulted for `resolvedLocale`: exact locale, same-base sibling
* (zh-TW -> zh-CN), then English. `_loadedVersion` is only a cache key so a
* dictionary that finishes loading yields a fresh snapshot.
*/
const snapshotLocaleDictionaries = (resolvedLocale: string, _loadedVersion: number): LocaleDictionaries => {
const baseKey = getBaseFallbackLocale(resolvedLocale);
return {
direct: getLoadedLocaleMessages(resolvedLocale),
base: baseKey ? getLoadedLocaleMessages(baseKey) : undefined,
fallback: getLoadedLocaleMessages(DEFAULT_UI_LOCALE),
};
};
const resolveMessage = (dictionaries: LocaleDictionaries, key: string): string | undefined =>
dictionaries.direct?.[key] || dictionaries.base?.[key] || dictionaries.fallback?.[key];
export const I18nProvider: React.FC<{ locale: string; children: React.ReactNode }> = ({
locale,
children,
}) => {
const resolvedLocale = resolveSupportedLocale(locale || DEFAULT_UI_LOCALE);
// Non-English dictionaries load on demand (index.tsx preloads the saved one
// before the first render); re-render consumers once a switch finishes.
const messagesVersion = useSyncExternalStore(
subscribeLocaleMessages,
getLocaleMessagesVersion,
getLocaleMessagesVersion,
);
useEffect(() => {
void loadLocaleMessages(resolvedLocale).catch(() => {});
}, [resolvedLocale]);
const dictionaries = useMemo(
() => snapshotLocaleDictionaries(resolvedLocale, messagesVersion),
[resolvedLocale, messagesVersion],
);
const value = useMemo<I18nContextValue>(() => {
return {
locale,
resolvedLocale,
t: (key, values) => {
const msg = resolveMessage(resolvedLocale, key) ?? key;
const msg = resolveMessage(dictionaries, key) ?? key;
return interpolate(msg, values);
},
};
}, [locale, resolvedLocale]);
}, [dictionaries, locale, resolvedLocale]);
return <I18nContext.Provider value={value}>{children}</I18nContext.Provider>;
};
+2
View File
@@ -460,6 +460,7 @@ export const enTerminalMessages: Messages = {
'cloudSync.localBackups.clearFailed': 'Could not clear all local backups. Please retry.',
'cloudSync.localBackups.title': 'Local Backup History',
'cloudSync.localBackups.desc': 'Netcatty keeps local restore points before app version changes and before vault restores.',
'cloudSync.localBackups.folderWarning': 'This folder alone is not a complete, portable backup. Deleting or wiping the app profile (userData) may make these restore points unreadable.',
'cloudSync.localBackups.retentionTitle': 'Backup Retention',
'cloudSync.localBackups.retentionDesc': 'Choose how many local backups Netcatty should keep.',
'cloudSync.localBackups.maxCount': 'Max backups',
@@ -474,6 +475,7 @@ export const enTerminalMessages: Messages = {
'cloudSync.localBackups.restoreSuccess': 'Local backup restored.',
'cloudSync.localBackups.restoreFailedTitle': 'Restore failed',
'cloudSync.localBackups.restoreMissing': 'Backup not found.',
'cloudSync.localBackups.decryptFailed': 'The current app profile cannot decrypt this backup. The encryption key may be missing, the profile may have changed, or the backup may be damaged.',
'cloudSync.localBackups.protectiveBackupFailed': 'Safety backup could not be created, so the restore was aborted to protect your current data. Resolve the underlying issue (e.g. keychain access) and try again. Details: {message}',
'cloudSync.localBackups.restoreConfirmTitle': 'Restore this backup?',
'cloudSync.localBackups.restoreConfirmDesc': 'Your current hosts, keys, snippets and settings will be replaced with the contents of this backup. A protective snapshot of your current data is taken automatically first.',
+2
View File
@@ -460,6 +460,7 @@ export const esTerminalMessages: Messages = {
'cloudSync.localBackups.clearFailed': 'No se pudieron eliminar todas las copias locales. Inténtalo de nuevo.',
'cloudSync.localBackups.title': 'Historial de copias de seguridad locales',
'cloudSync.localBackups.desc': 'Netcatty conserva puntos de restauración locales antes de los cambios de versión de la app y antes de las restauraciones de la bóveda.',
'cloudSync.localBackups.folderWarning': 'Esta carpeta por sí sola no es una copia de seguridad completa y portátil. Eliminar o vaciar el perfil de la aplicación (userData) puede impedir la lectura de estos puntos de restauración.',
'cloudSync.localBackups.retentionTitle': 'Retención de copias de seguridad',
'cloudSync.localBackups.retentionDesc': 'Elige cuántas copias de seguridad locales debe conservar Netcatty.',
'cloudSync.localBackups.maxCount': 'Copias máximas',
@@ -474,6 +475,7 @@ export const esTerminalMessages: Messages = {
'cloudSync.localBackups.restoreSuccess': 'Copia de seguridad local restaurada.',
'cloudSync.localBackups.restoreFailedTitle': 'Falló la restauración',
'cloudSync.localBackups.restoreMissing': 'No se encontró la copia de seguridad.',
'cloudSync.localBackups.decryptFailed': 'El perfil actual de la aplicación no puede descifrar esta copia. Puede faltar la clave de cifrado, haber cambiado el perfil o estar dañada la copia.',
'cloudSync.localBackups.protectiveBackupFailed': 'No se pudo crear la copia de seguridad de protección, por lo que la restauración se canceló para proteger tus datos actuales. Resuelve el problema subyacente (p. ej., acceso al keychain) e inténtalo de nuevo. Detalles: {message}',
'cloudSync.localBackups.restoreConfirmTitle': '¿Restaurar esta copia de seguridad?',
'cloudSync.localBackups.restoreConfirmDesc': 'Tus hosts, claves, snippets y configuraciones actuales se reemplazarán con el contenido de esta copia de seguridad. Se crea automáticamente una instantánea de protección de tus datos actuales primero.',
@@ -0,0 +1,36 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
getBaseFallbackLocale,
getLoadedLocaleMessages,
getLocaleMessagesVersion,
loadLocaleMessages,
subscribeLocaleMessages,
} from '../messages';
test('only English is loaded until another locale is requested', () => {
assert.ok(getLoadedLocaleMessages('en'));
assert.equal(getLoadedLocaleMessages('ru'), undefined);
});
test('zh-TW resolves through zh-CN, matching the old static lookup order', () => {
assert.equal(getBaseFallbackLocale('zh-TW'), 'zh-CN');
assert.equal(getBaseFallbackLocale('zh-CN'), 'zh-CN');
assert.equal(getBaseFallbackLocale('es'), 'es');
assert.equal(getBaseFallbackLocale('fr'), undefined);
});
test('loading a locale also loads its fallback and notifies subscribers once each', async () => {
const before = getLocaleMessagesVersion();
let notified = 0;
const unsubscribe = subscribeLocaleMessages(() => { notified += 1; });
await Promise.all([loadLocaleMessages('zh-TW'), loadLocaleMessages('zh-TW')]);
unsubscribe();
assert.ok(getLoadedLocaleMessages('zh-TW'));
assert.ok(getLoadedLocaleMessages('zh-CN'), 'base fallback is loaded with zh-TW');
assert.equal(notified, 2, 'one notification per newly loaded dictionary');
assert.equal(getLocaleMessagesVersion(), before + 2);
await loadLocaleMessages('zh-TW');
assert.equal(getLocaleMessagesVersion(), before + 2, 'already loaded locales are not reloaded');
});
+2
View File
@@ -478,6 +478,7 @@ export const ruTerminalMessages: Messages = {
'cloudSync.localBackups.clearFailed': 'Не удалось удалить все локальные копии. Повторите попытку.',
'cloudSync.localBackups.title': 'История локальных резервных копий',
'cloudSync.localBackups.desc': 'Netcatty сохраняет локальные точки восстановления перед сменой версии приложения и перед восстановлением хранилища.',
'cloudSync.localBackups.folderWarning': 'Эта папка сама по себе не является полной переносимой резервной копией. Удаление или очистка профиля приложения (userData) может сделать эти точки восстановления нечитаемыми.',
'cloudSync.localBackups.retentionTitle': 'Хранение резервных копий',
'cloudSync.localBackups.retentionDesc': 'Выберите, сколько локальных резервных копий должен хранить Netcatty.',
'cloudSync.localBackups.maxCount': 'Макс. число копий',
@@ -492,6 +493,7 @@ export const ruTerminalMessages: Messages = {
'cloudSync.localBackups.restoreSuccess': 'Локальная резервная копия восстановлена.',
'cloudSync.localBackups.restoreFailedTitle': 'Ошибка восстановления',
'cloudSync.localBackups.restoreMissing': 'Резервная копия не найдена.',
'cloudSync.localBackups.decryptFailed': 'Текущий профиль приложения не может расшифровать эту копию. Возможно, отсутствует ключ шифрования, профиль изменился или копия повреждена.',
'cloudSync.localBackups.protectiveBackupFailed': 'Не удалось создать защитную резервную копию, поэтому восстановление было прервано для защиты ваших текущих данных. Устраните основную проблему (например, доступ к keychain) и попробуйте снова. Подробности: {message}',
'cloudSync.localBackups.restoreConfirmTitle': 'Восстановить эту резервную копию?',
'cloudSync.localBackups.restoreConfirmDesc': 'Ваши текущие хосты, ключи, сниппеты и настройки будут заменены содержимым этой резервной копии. Перед этим автоматически создаётся защитный снимок текущих данных.',
+2
View File
@@ -888,6 +888,7 @@ export const zhCNVaultMessages: Messages = {
'cloudSync.localBackups.clearFailed': '未能清空全部本地备份,请重试。',
'cloudSync.localBackups.title': '本地备份历史',
'cloudSync.localBackups.desc': 'Netcatty 会在版本变化前,以及恢复主机库前,自动留下一份本地恢复点。',
'cloudSync.localBackups.folderWarning': '仅复制此文件夹并不构成完整、可迁移的备份。删除或清空应用配置目录(userData)可能导致这些恢复点无法读取。',
'cloudSync.localBackups.retentionTitle': '备份保留数量',
'cloudSync.localBackups.retentionDesc': '设置 Netcatty 最多保留多少份本地备份。',
'cloudSync.localBackups.maxCount': '最多保留',
@@ -902,6 +903,7 @@ export const zhCNVaultMessages: Messages = {
'cloudSync.localBackups.restoreSuccess': '已恢复本地备份。',
'cloudSync.localBackups.restoreFailedTitle': '恢复失败',
'cloudSync.localBackups.restoreMissing': '找不到这份备份。',
'cloudSync.localBackups.decryptFailed': '当前应用配置无法解密此备份。可能是加密密钥丢失、应用配置已更改,或备份已损坏。',
'cloudSync.localBackups.protectiveBackupFailed': '无法创建保护性备份,已中止恢复以避免覆盖当前数据。请先解决底层问题(例如钥匙串访问)后重试。详情:{message}',
'cloudSync.localBackups.restoreConfirmTitle': '确认恢复此备份?',
'cloudSync.localBackups.restoreConfirmDesc': '当前的主机、密钥、代码片段与设置将被替换为此备份中的内容。系统会先自动创建一个保护性快照,便于撤销。',
+2
View File
@@ -888,6 +888,7 @@ export const zhTWVaultMessages: Messages = {
'cloudSync.localBackups.clearFailed': '未能清空全部本機備份,請重試。',
'cloudSync.localBackups.title': '本機備份歷史',
'cloudSync.localBackups.desc': 'Netcatty 會在版本變化前,以及恢復主機庫前,自動留下一份本機恢復點。',
'cloudSync.localBackups.folderWarning': '僅複製此資料夾並不構成完整、可攜的備份。刪除或清空應用程式設定目錄(userData)可能導致這些還原點無法讀取。',
'cloudSync.localBackups.retentionTitle': '備份保留數量',
'cloudSync.localBackups.retentionDesc': '設定 Netcatty 最多保留多少份本機備份。',
'cloudSync.localBackups.maxCount': '最多保留',
@@ -902,6 +903,7 @@ export const zhTWVaultMessages: Messages = {
'cloudSync.localBackups.restoreSuccess': '已恢復本機備份。',
'cloudSync.localBackups.restoreFailedTitle': '恢復失敗',
'cloudSync.localBackups.restoreMissing': '找不到這份備份。',
'cloudSync.localBackups.decryptFailed': '目前的應用程式設定無法解密此備份。可能是加密金鑰遺失、應用程式設定已變更,或備份已損毀。',
'cloudSync.localBackups.protectiveBackupFailed': '無法建立保護性備份,已中止恢復以避免覆蓋目前資料。請先解決底層問題(例如鑰匙串存取)後重試。詳情:{message}',
'cloudSync.localBackups.restoreConfirmTitle': '確認恢復此備份?',
'cloudSync.localBackups.restoreConfirmDesc': '目前的主機、金鑰、程式碼片段與設定將被替換為此備份中的內容。系統會先自動建立一個保護性快照,便於復原。',
+72 -11
View File
@@ -1,17 +1,78 @@
import en, { type Messages } from './locales/en';
import zhCN from './locales/zh-CN';
import zhTW from './locales/zh-TW';
import ru from './locales/ru';
import es from './locales/es';
// Keep keys stable; add new locales by adding another import and map entry.
// Keep keys stable; add new locales by adding a loader below and an entry in
// SUPPORTED_UI_LOCALES (infrastructure/config/i18n.ts).
export { type Messages };
export const MESSAGES_BY_LOCALE: Record<string, Messages> = {
en,
ru,
es,
'zh-CN': zhCN,
'zh-TW': zhTW,
/**
* Lookup order for the "same base language" fallback in I18nProvider (for
* example zh-TW falls back to zh-CN, the first `zh*` entry). Matches the key
* order of the old static MESSAGES_BY_LOCALE map.
*/
export const LOCALE_MESSAGE_KEYS = ['en', 'ru', 'es', 'zh-CN', 'zh-TW'] as const;
/**
* Only English is bundled with the startup chunk; other locales are loaded on
* demand. All five dictionaries used to be parsed by every window at startup
* (~1.3 MB of the entry chunk) even though one locale is in use.
*/
const LOCALE_LOADERS: Record<string, () => Promise<{ default: Messages }>> = {
ru: () => import('./locales/ru'),
es: () => import('./locales/es'),
'zh-CN': () => import('./locales/zh-CN'),
'zh-TW': () => import('./locales/zh-TW'),
};
const loadedMessages: Record<string, Messages> = { en };
const pendingLoads = new Map<string, Promise<void>>();
const listeners = new Set<() => void>();
let messagesVersion = 0;
export function getLoadedLocaleMessages(locale: string): Messages | undefined {
return loadedMessages[locale];
}
export function getLocaleMessagesVersion(): number {
return messagesVersion;
}
export function subscribeLocaleMessages(listener: () => void): () => void {
listeners.add(listener);
return () => {
listeners.delete(listener);
};
}
/** The same-base fallback locale that I18nProvider consults for `locale`. */
export function getBaseFallbackLocale(locale: string): string | undefined {
const base = locale.split('-')[0];
return LOCALE_MESSAGE_KEYS.find((key) => key === base || key.startsWith(`${base}-`));
}
function loadOne(locale: string): Promise<void> {
if (loadedMessages[locale]) return Promise.resolve();
const loader = LOCALE_LOADERS[locale];
if (!loader) return Promise.resolve();
let pending = pendingLoads.get(locale);
if (!pending) {
pending = loader()
.then((module) => {
loadedMessages[locale] = module.default;
messagesVersion += 1;
for (const listener of listeners) listener();
})
.finally(() => {
pendingLoads.delete(locale);
});
pendingLoads.set(locale, pending);
}
return pending;
}
/** Load the dictionaries `locale` resolves through (itself and its base fallback). */
export function loadLocaleMessages(locale: string): Promise<void> {
const needed = new Set([locale]);
const fallback = getBaseFallbackLocale(locale);
if (fallback) needed.add(fallback);
return Promise.all([...needed].map(loadOne)).then(() => undefined);
}
@@ -0,0 +1,20 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { requestAgentSessionReconnect } from './agentSessionReconnect';
test('reconnect preserves identity and only dispatches disconnected eligible sessions', () => {
let calls = 0;
const registry = { isActive: () => false, requestIfAvailable: () => { calls++; return true; } };
assert.equal(requestAgentSessionReconnect('s', undefined, registry).ok, false);
assert.equal(requestAgentSessionReconnect('s', 'connected', registry).status, 'connected');
assert.equal(requestAgentSessionReconnect('s', 'connecting', registry).started, false);
const result = requestAgentSessionReconnect('s', 'disconnected', registry);
assert.deepEqual(result, { ok: true, started: true, sessionId: 's', status: 'reconnecting', shellReady: false });
assert.equal(calls, 1);
});
test('auto/manual reconnect and unavailable terminal cannot queue another recovery', () => {
const registry = { isActive: () => true, requestIfAvailable: () => { throw Error('must not dispatch'); } };
assert.equal(requestAgentSessionReconnect('s', 'disconnected', registry).started, false);
assert.equal(requestAgentSessionReconnect('s', 'disconnected', { isActive: () => false, requestIfAvailable: () => false }).code, 'RECONNECT_UNAVAILABLE');
});
@@ -0,0 +1,21 @@
type ReconnectRegistry = {
isActive: (sessionId: string) => boolean;
requestIfAvailable: (sessionId: string) => boolean;
};
/** Synchronous dispatch only: no queued recovery after scope/terminal disposal. */
export function requestAgentSessionReconnect(
sessionId: string,
status: string | undefined,
registry: ReconnectRegistry,
): Record<string, unknown> {
if (!status) return { ok: false, started: false, error: `Session "${sessionId}" was not found.` };
if (status === 'connected') return { ok: true, started: false, sessionId, status: 'connected', shellReady: false };
if (status === 'connecting' || registry.isActive(sessionId)) {
return { ok: true, started: false, sessionId, status: 'reconnecting' };
}
if (status !== 'disconnected' || !registry.requestIfAvailable(sessionId)) {
return { ok: false, started: false, sessionId, code: 'RECONNECT_UNAVAILABLE', error: 'Reconnect is unavailable: terminal may be unmounted, waiting for authentication, or recovering. Inspect session state and the Netcatty UI.' };
}
return { ok: true, started: true, sessionId, status: 'reconnecting', shellReady: false };
}
+19 -13
View File
@@ -18,28 +18,34 @@ export interface AiSessionsBackupBridge {
aiSessionsBackupImport?: () => Promise<{ success: boolean; json?: string }>;
}
/**
* Parse the live sessions store once, returning both whether it is usable and
* its sessions. The history is often several MB; the helpers below used to
* parse it twice per call, and mount ran them back to back (five parses).
*/
export function parseSessionsStore(raw: string | null | undefined): { valid: boolean; sessions: AISession[] } {
if (raw == null || raw === '') return { valid: false, sessions: [] };
try {
const parsed = JSON.parse(raw);
return Array.isArray(parsed)
? { valid: true, sessions: parsed as AISession[] }
: { valid: false, sessions: [] };
} catch {
return { valid: false, sessions: [] };
}
}
/** Restore only when the live store is missing or malformed. A valid empty
* array represents an intentional deletion and must remain authoritative. */
export function isMissingOrCorruptSessionsStore(raw: string | null | undefined): boolean {
if (raw == null || raw === '') return true;
try {
return !Array.isArray(JSON.parse(raw));
} catch {
return true;
}
return !parseSessionsStore(raw).valid;
}
/** Read the live sessions array from a raw localStorage string. Returns [] when
* missing/corrupt (including parseable non-arrays such as `{}`), so callers never
* treat a non-array as AISession[]. */
export function readLiveSessionsArray(raw: string | null | undefined): AISession[] {
if (isMissingOrCorruptSessionsStore(raw)) return [];
try {
const parsed = JSON.parse(raw as string);
return Array.isArray(parsed) ? parsed as AISession[] : [];
} catch {
return [];
}
return parseSessionsStore(raw).sessions;
}
/**
+501 -92
View File
@@ -1,10 +1,16 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import test, { type TestContext } from 'node:test';
import { createAiPrivacyService } from '../../electron/shared/aiPrivacyService.cjs';
import {
hasReversibleModelPrivacy,
setReversibleModelPrivacy,
} from '../../infrastructure/ai/harness/modelSecretRedaction';
import {
STORAGE_KEY_AI_SESSIONS,
STORAGE_KEY_AI_SESSIONS_OVERFLOW,
STORAGE_KEY_AI_SESSIONS_DISK_OVERFLOW,
STORAGE_KEY_CLOUD_SYNC_AI_SESSIONS,
} from '../../infrastructure/config/storageKeys';
import type { AISession } from '../../infrastructure/ai/types';
import { localStorageAdapter } from '../../infrastructure/persistence/localStorageAdapter';
import {
@@ -17,6 +23,11 @@ import {
setLatestAIActiveSessionMapSnapshot,
setLatestAISessionsSnapshot,
rebindActiveSessionMap,
readSessionsFromStorage,
prewarmAIStateStorageSnapshots,
writeSessionsForStorage,
flushBackupMirror,
__resetBackupMirrorForTests,
} from './aiStateSnapshots';
test('orphan cleanup keeps durable Catty output while explicit deletion removes it', async () => {
@@ -73,6 +84,424 @@ function makeSession(id: string, updatedAt: number, messages: unknown[]): AISess
} as never;
}
function installSessionStorage(t: TestContext, aggregateQuota = Number.POSITIVE_INFINITY) {
const storage = new Map<string, string>();
const previousStorage = globalThis.localStorage;
Object.defineProperty(globalThis, 'localStorage', {
configurable: true,
value: {
getItem: (key: string) => storage.get(key) ?? null,
setItem: (key: string, value: string) => {
const storedBytes = [...storage].reduce((total, [storedKey, storedValue]) => (
total + (storedKey === key ? 0 : Buffer.byteLength(storedKey + storedValue))
), 0);
if (storedBytes + Buffer.byteLength(key + value) > aggregateQuota) {
throw new DOMException('aggregate quota exceeded', 'QuotaExceededError');
}
storage.set(key, value);
},
removeItem: (key: string) => { storage.delete(key); },
},
});
__resetBackupMirrorForTests();
t.after(() => {
__resetBackupMirrorForTests();
Object.defineProperty(globalThis, 'localStorage', { configurable: true, value: previousStorage });
});
storage.set(STORAGE_KEY_CLOUD_SYNC_AI_SESSIONS, JSON.stringify({ enabled: false }));
return storage;
}
function makeLargeSessions(count = 4) {
return Array.from({ length: count }, (_, index) => makeSession(
`large-${index}`, count - index,
Array.from({ length: 200 }, (_, messageIndex) => ({
id: `message-${messageIndex}`,
role: messageIndex % 2 ? 'assistant' : 'user',
content: 'x'.repeat(5 * 1024 + 128),
timestamp: messageIndex,
})),
));
}
test('local-only writes retain four full large chats across overflow chunks and prewarming', (t) => {
const storage = installSessionStorage(t);
const sessions = freezeHistory(makeLargeSessions());
assert.ok(JSON.stringify(sessions).length > 2 * 1024 * 1024);
assert.equal(writeSessionsForStorage(sessions), true);
const primary = JSON.parse(storage.get(STORAGE_KEY_AI_SESSIONS)!) as AISession[];
assert.ok(primary.length < sessions.length);
assert.ok(storage.has(`${STORAGE_KEY_AI_SESSIONS_OVERFLOW}_2`));
for (const [key, value] of storage) {
if (key.startsWith(STORAGE_KEY_AI_SESSIONS_OVERFLOW) || key === STORAGE_KEY_AI_SESSIONS) {
assert.ok(value.length <= 2 * 1024 * 1024);
}
}
assert.deepEqual(readSessionsFromStorage(), sessions);
// A later write of the reloaded history must retain the overflow again.
assert.equal(writeSessionsForStorage(readSessionsFromStorage()), true);
assert.deepEqual(readSessionsFromStorage(), sessions);
const previousSessions = latestAISessionsSnapshot;
try {
setLatestAISessionsSnapshot(null as unknown as AISession[]);
prewarmAIStateStorageSnapshots();
assert.deepEqual(latestAISessionsSnapshot, sessions);
} finally {
setLatestAISessionsSnapshot(previousSessions as AISession[]);
}
});
test('backup includes overflow chats before the primary blob is replaced', (t) => {
installSessionStorage(t);
const backupWrites: string[] = [];
const previousWindow = globalThis.window;
Object.defineProperty(globalThis, 'window', {
configurable: true,
value: { netcatty: { aiSessionsBackupWrite: async ({ json }: { json: string }) => {
backupWrites.push(json);
return { ok: true };
} } },
});
t.after(() => Object.defineProperty(globalThis, 'window', { configurable: true, value: previousWindow }));
const sessions = makeLargeSessions();
const setItem = globalThis.localStorage.setItem;
t.mock.method(globalThis.localStorage, 'setItem', (key: string, value: string) => {
if (key === STORAGE_KEY_AI_SESSIONS) {
flushBackupMirror();
assert.deepEqual(JSON.parse(backupWrites[0]), sessions);
}
setItem(key, value);
});
assert.equal(writeSessionsForStorage(sessions), true);
flushBackupMirror();
assert.equal(backupWrites.length, 1);
const primary = JSON.parse(localStorageAdapter.readString(STORAGE_KEY_AI_SESSIONS)!) as AISession[];
const primaryIds = new Set(primary.map(session => session.id));
assert.ok(sessions.some(session => !primaryIds.has(session.id)));
});
test('shrinking local history removes stale overflow chunks and deleted chats', (t) => {
const storage = installSessionStorage(t);
const sessions = makeLargeSessions();
assert.equal(writeSessionsForStorage(sessions), true);
assert.equal(writeSessionsForStorage(sessions.slice(0, 2)), true);
assert.ok(storage.has(STORAGE_KEY_AI_SESSIONS_OVERFLOW));
assert.equal(storage.has(`${STORAGE_KEY_AI_SESSIONS_OVERFLOW}_2`), false);
assert.deepEqual(readSessionsFromStorage(), sessions.slice(0, 2));
assert.equal(writeSessionsForStorage([sessions[0]]), true);
assert.equal(storage.has(STORAGE_KEY_AI_SESSIONS_OVERFLOW), false);
assert.deepEqual(readSessionsFromStorage(), [sessions[0]]);
assert.equal(writeSessionsForStorage([]), true);
assert.deepEqual(readSessionsFromStorage(), []);
});
test('merged storage deduplicates primary and overflow by the newer updatedAt', (t) => {
const storage = installSessionStorage(t);
const primary = [makeSession('primary-newer', 3, []), makeSession('overflow-newer', 1, [])];
const overflow = [makeSession('primary-newer', 2, []), makeSession('overflow-newer', 4, [])];
storage.set(STORAGE_KEY_AI_SESSIONS, JSON.stringify(primary));
storage.set(STORAGE_KEY_AI_SESSIONS_OVERFLOW, JSON.stringify(overflow));
assert.deepEqual(readSessionsFromStorage(), [overflow[1], primary[0]]);
});
test('the 50-session count cap still applies to primary plus overflow', (t) => {
installSessionStorage(t);
const sessions = Array.from({ length: 51 }, (_, index) => makeSession(`session-${index}`, index, [{
id: 'm', role: 'user', content: 'x'.repeat(50 * 1024), timestamp: 0,
}]));
assert.equal(writeSessionsForStorage(sessions), true);
const loaded = readSessionsFromStorage();
assert.equal(loaded.length, 50);
assert.deepEqual(loaded.map(session => session.id), sessions.slice(1).reverse().map(session => session.id));
});
test('cloud sync restores overflow if its full authoritative write fails', (t) => {
const storage = installSessionStorage(t);
assert.equal(writeSessionsForStorage(makeLargeSessions()), true);
const originalOverflow = storage.get(STORAGE_KEY_AI_SESSIONS_OVERFLOW);
storage.set(STORAGE_KEY_CLOUD_SYNC_AI_SESSIONS, JSON.stringify({ enabled: true }));
const before = new Map(storage);
const sessions = [makeSession('synced', 5, [])];
const writeString = localStorageAdapter.writeString;
const write = t.mock.method(localStorageAdapter, 'writeString', (key: string, value: string) => (
key === STORAGE_KEY_AI_SESSIONS ? false : writeString(key, value)
));
assert.equal(writeSessionsForStorage(sessions), false);
assert.equal(storage.get(STORAGE_KEY_AI_SESSIONS_OVERFLOW), originalOverflow);
assert.deepEqual(storage, before);
const error = new Error('unexpected primary write failure');
write.mock.mockImplementation((key: string, value: string) => {
if (key === STORAGE_KEY_AI_SESSIONS) throw error;
return writeString(key, value);
});
assert.throws(() => writeSessionsForStorage(sessions), candidate => candidate === error);
assert.deepEqual(storage, before);
write.mock.restore();
assert.equal(writeSessionsForStorage(sessions), true);
assert.equal([...storage.keys()].some(key => key.startsWith(STORAGE_KEY_AI_SESSIONS_OVERFLOW)), false);
assert.deepEqual(readSessionsFromStorage(), sessions);
});
test('enabling cloud sync frees overflow quota for the authoritative primary', (t) => {
const storage = installSessionStorage(t, 5 * 1024 * 1024);
const sessions = freezeHistory(makeLargeSessions());
assert.equal(writeSessionsForStorage(sessions), true);
assert.ok(storage.has(STORAGE_KEY_AI_SESSIONS_OVERFLOW));
storage.set(STORAGE_KEY_CLOUD_SYNC_AI_SESSIONS, JSON.stringify({ enabled: true }));
assert.equal(writeSessionsForStorage(sessions), true);
assert.equal([...storage.keys()].some(key => key.startsWith(STORAGE_KEY_AI_SESSIONS_OVERFLOW)), false);
assert.deepEqual(JSON.parse(storage.get(STORAGE_KEY_AI_SESSIONS)!), sessions);
assert.deepEqual(readSessionsFromStorage(), sessions);
});
test('aggregate quota retries shed replay bulk from overflow and retain every chat', (t) => {
const storage = installSessionStorage(t, 5 * 1024 * 1024);
const sessions = freezeHistory(Array.from({ length: 6 }, (_, index) => makeSession(
`bulk-${index}`, 6 - index, [{
id: `message-${index}`,
role: 'assistant',
content: 'x'.repeat(700 * 1024),
timestamp: index,
toolResults: [{ toolCallId: `call-${index}`, content: 'z'.repeat(300 * 1024) }],
providerContinuation: {
reasoningParts: [{ text: '', providerOptions: { openai: {
reasoningEncryptedContent: 'c'.repeat(100 * 1024),
} } }],
},
}],
)));
assert.ok(JSON.stringify(sessions).length > 5 * 1024 * 1024);
const writes = t.mock.method(localStorageAdapter, 'writeString');
assert.equal(writeSessionsForStorage(sessions), true);
assert.ok(writes.mock.calls.some(call => call.result === false));
const primary = JSON.parse(storage.get(STORAGE_KEY_AI_SESSIONS)!) as AISession[];
assert.equal(primary[0].id, sessions[0].id);
const overflow = JSON.parse([...storage]
.filter(([key]) => key.startsWith(STORAGE_KEY_AI_SESSIONS_OVERFLOW))
.map(([, value]) => value).join('')) as AISession[];
assert.ok(overflow.length > 0);
assert.ok(overflow.every(session => {
const message = session.messages[0];
return (message.toolResults?.[0].content.length ?? 0) <= 8 * 1024
&& !JSON.stringify(message.providerContinuation).includes('reasoningEncryptedContent');
}));
const loaded = readSessionsFromStorage();
assert.deepEqual(loaded.map(session => session.id), sessions.map(session => session.id));
assert.deepEqual(loaded.map(session => session.messages[0].content), sessions.map(session => session.messages[0].content));
});
test('a smaller primary reclaims quota before overflow when the final history fits', (t) => {
const storage = installSessionStorage(t, 5 * 1024 * 1024);
const chat = (id: string, updatedAt: number, mib: number) => makeSession(id, updatedAt, [{
id: 'm', role: 'user', content: 'x'.repeat(Math.floor(mib * 1024 * 1024)), timestamp: 0,
}]);
const previous = [chat('old-primary', 2, 1.9), chat('old-overflow', 1, 1.5)];
assert.equal(writeSessionsForStorage(previous), true);
const next = [chat('new-primary', 3, 1.1), ...previous];
assert.equal(writeSessionsForStorage(next), true);
assert.deepEqual(JSON.parse(storage.get(STORAGE_KEY_AI_SESSIONS)!), [next[0]]);
assert.deepEqual(readSessionsFromStorage(), next);
assert.equal(storage.has(STORAGE_KEY_AI_SESSIONS_DISK_OVERFLOW), false);
});
test('overflow failure rolls back a replaced primary and the previous overflow', (t) => {
const storage = installSessionStorage(t, 5 * 1024 * 1024);
const previous = makeLargeSessions();
assert.equal(writeSessionsForStorage(previous), true);
const before = new Map(storage);
const writeString = localStorageAdapter.writeString;
const error = new Error('overflow write failed');
let failOnce = true;
t.mock.method(localStorageAdapter, 'writeString', (key: string, value: string) => {
if (failOnce && key === STORAGE_KEY_AI_SESSIONS_OVERFLOW) {
failOnce = false;
assert.notEqual(storage.get(STORAGE_KEY_AI_SESSIONS), before.get(STORAGE_KEY_AI_SESSIONS));
throw error;
}
return writeString(key, value);
});
assert.throws(() => writeSessionsForStorage([
{ ...previous[0], title: 'New primary' }, ...previous.slice(1),
]), candidate => candidate === error);
assert.deepEqual(storage, before);
});
test('aggregate quota falls back to a current primary with full backup and persists later turns', (t) => {
const storage = installSessionStorage(t, 5 * 1024 * 1024);
assert.equal(writeSessionsForStorage([makeSession('previous-primary', 0, [])]), true);
__resetBackupMirrorForTests();
const backupWrites: string[] = [];
const previousWindow = globalThis.window;
Object.defineProperty(globalThis, 'window', {
configurable: true,
value: { netcatty: { aiSessionsBackupWrite: async ({ json }: { json: string }) => {
backupWrites.push(json);
return { ok: true };
} } },
});
t.after(() => Object.defineProperty(globalThis, 'window', { configurable: true, value: previousWindow }));
const sessions = freezeHistory(makeLargeSessions(6));
assert.ok(JSON.stringify(sessions).length > 5 * 1024 * 1024);
const setItem = globalThis.localStorage.setItem;
t.mock.method(globalThis.localStorage, 'setItem', (key: string, value: string) => {
if (key === STORAGE_KEY_AI_SESSIONS) {
// The full intended history must be scheduled before replacing the old
// valid primary, including on the subsequent mutation of the same chat.
flushBackupMirror();
assert.equal((JSON.parse(backupWrites.at(-1)!) as AISession[]).length, 6);
}
setItem(key, value);
});
assert.equal(writeSessionsForStorage(sessions), true);
flushBackupMirror();
assert.deepEqual(JSON.parse(backupWrites.at(-1)!), sessions);
assert.deepEqual(readSessionsFromStorage(), [sessions[0]]);
assert.deepEqual(JSON.parse(storage.get(STORAGE_KEY_AI_SESSIONS_DISK_OVERFLOW)!), sessions.slice(1).map(session => session.id));
assert.equal([...storage.keys()].some(key => key.startsWith(STORAGE_KEY_AI_SESSIONS_OVERFLOW)), false);
const updated = sessions.map((session, index) => index === 0 ? {
...session,
updatedAt: session.updatedAt + 1,
messages: [...session.messages.slice(1), {
id: 'new-turn', role: 'user' as const, content: 'Latest turn survives reload', timestamp: 1000,
}],
} : session);
assert.equal(writeSessionsForStorage(updated), true);
flushBackupMirror();
assert.deepEqual(JSON.parse(backupWrites.at(-1)!), updated);
assert.deepEqual(JSON.parse(storage.get(STORAGE_KEY_AI_SESSIONS)!), [updated[0]]);
assert.deepEqual(readSessionsFromStorage(), [updated[0]]);
});
test('live writes resolve only the disk-only IDs included in primary or overflow', (t) => {
const storage = installSessionStorage(t);
const sessions = makeLargeSessions();
storage.set(STORAGE_KEY_AI_SESSIONS, JSON.stringify([sessions[0]]));
storage.set(STORAGE_KEY_AI_SESSIONS_DISK_OVERFLOW, JSON.stringify([
sessions[0].id, sessions[1].id, 'unrecovered', 'deleted',
]));
assert.equal(writeSessionsForStorage(sessions, { deletedSessionIds: ['deleted'] }), true);
assert.deepEqual(readSessionsFromStorage(), sessions);
assert.deepEqual(JSON.parse(storage.get(STORAGE_KEY_AI_SESSIONS_DISK_OVERFLOW)!), ['unrecovered']);
// Reusing the same immutable array must still process explicit deletion,
// including when the primary itself has not changed.
assert.equal(writeSessionsForStorage(sessions, { deletedSessionIds: ['unrecovered'] }), true);
assert.equal(storage.has(STORAGE_KEY_AI_SESSIONS_DISK_OVERFLOW), false);
});
test('same-array primary writes preserve pending recovery until explicit deletion or sync', (t) => {
const storage = installSessionStorage(t);
const sessions = [makeSession('primary', 1, [])];
assert.equal(writeSessionsForStorage(sessions), true);
storage.set(STORAGE_KEY_AI_SESSIONS_DISK_OVERFLOW, JSON.stringify(['unrecovered']));
assert.equal(writeSessionsForStorage(sessions), true);
assert.deepEqual(JSON.parse(storage.get(STORAGE_KEY_AI_SESSIONS_DISK_OVERFLOW)!), ['unrecovered']);
assert.equal(writeSessionsForStorage(sessions, { deletedSessionIds: ['unrecovered'] }), true);
assert.equal(storage.has(STORAGE_KEY_AI_SESSIONS_DISK_OVERFLOW), false);
storage.set(STORAGE_KEY_AI_SESSIONS_DISK_OVERFLOW, JSON.stringify(['unrecovered']));
storage.set(STORAGE_KEY_CLOUD_SYNC_AI_SESSIONS, JSON.stringify({ enabled: true }));
assert.equal(writeSessionsForStorage(sessions), true);
assert.equal(storage.has(STORAGE_KEY_AI_SESSIONS_DISK_OVERFLOW), false);
});
test('tiny newest chat bounds overflow writes under aggregate quota and preserves unresolved IDs', (t) => {
const storage = installSessionStorage(t, 512 * 1024);
const sessions = [makeSession('empty-newest', 10, []), ...Array.from({ length: 3 }, (_, index) => (
makeSession(`huge-older-${index}`, 3 - index, [{
id: 'm', role: 'user', content: 'x'.repeat(768 * 1024), timestamp: 0,
}])
))];
storage.set(STORAGE_KEY_AI_SESSIONS, JSON.stringify([sessions[0]]));
storage.set(STORAGE_KEY_AI_SESSIONS_DISK_OVERFLOW, JSON.stringify(['unrecovered']));
const writes = t.mock.method(globalThis.localStorage, 'setItem');
assert.equal(writeSessionsForStorage(sessions), true);
assert.ok(writes.mock.callCount() < 150, `bounded writes: ${writes.mock.callCount()}`);
assert.deepEqual(readSessionsFromStorage(), [sessions[0]]);
assert.deepEqual(JSON.parse(storage.get(STORAGE_KEY_AI_SESSIONS_DISK_OVERFLOW)!), [
'unrecovered', ...sessions.slice(1).map(session => session.id),
]);
});
test('primary-only fallback frees primary quota before writing recovery metadata', (t) => {
const storage = installSessionStorage(t);
const previous = [makeSession('old-primary', 1, [{
id: 'm', role: 'user', content: 'x'.repeat(64 * 1024), timestamp: 0,
}])];
const previousJson = JSON.stringify(previous);
const sessions = [makeSession('empty-newest', 10, []), ...makeLargeSessions(2)];
storage.set(STORAGE_KEY_AI_SESSIONS, previousJson);
// The old primary fills the quota. Metadata fits only after it is replaced.
const aggregateQuota = [...storage].reduce((total, [key, value]) => total + key.length + value.length, 0);
const setItem = globalThis.localStorage.setItem;
t.mock.method(globalThis.localStorage, 'setItem', (key: string, value: string) => {
const total = [...storage].reduce((sum, [storedKey, storedValue]) => (
sum + (storedKey === key ? 0 : storedKey.length + storedValue.length)
), key.length + value.length);
if (total > aggregateQuota) throw new DOMException('aggregate quota exceeded', 'QuotaExceededError');
setItem(key, value);
});
assert.equal(writeSessionsForStorage(sessions), true);
assert.deepEqual(readSessionsFromStorage(), [sessions[0]]);
assert.deepEqual(JSON.parse(storage.get(STORAGE_KEY_AI_SESSIONS_DISK_OVERFLOW)!), sessions.slice(1).map(session => session.id));
});
test('primary-only fallback rolls back when recovery metadata cannot be written', (t) => {
const storage = installSessionStorage(t);
const previous = [makeSession('previous', 0, [])];
assert.equal(writeSessionsForStorage(previous), true);
storage.set(STORAGE_KEY_AI_SESSIONS_DISK_OVERFLOW, JSON.stringify(['unrecovered']));
const before = new Map(storage);
const setItem = globalThis.localStorage.setItem;
t.mock.method(globalThis.localStorage, 'setItem', (key: string, value: string) => {
if (key.startsWith(STORAGE_KEY_AI_SESSIONS_OVERFLOW) || key === STORAGE_KEY_AI_SESSIONS_DISK_OVERFLOW) {
throw new DOMException('quota exceeded', 'QuotaExceededError');
}
setItem(key, value);
});
assert.equal(writeSessionsForStorage([makeSession('new', 1, []), ...makeLargeSessions(2)]), false);
assert.deepEqual(storage, before);
});
test('unchanged primary does not hide modified overflow from the write cache', (t) => {
const storage = installSessionStorage(t);
const sessions = freezeHistory(makeLargeSessions());
assert.equal(writeSessionsForStorage(sessions), true);
storage.set(STORAGE_KEY_AI_SESSIONS_OVERFLOW, 'corrupt overflow');
assert.equal(writeSessionsForStorage(sessions), true);
assert.deepEqual(readSessionsFromStorage(), sessions);
});
test('quota failures retain the previous overflow and back up the intended full set', (t) => {
const storage = installSessionStorage(t);
const original = makeLargeSessions();
assert.equal(writeSessionsForStorage(original), true);
__resetBackupMirrorForTests();
const before = new Map(storage);
const next = original.map(session => ({ ...session, title: 'Changed', updatedAt: session.updatedAt + 10 }));
t.mock.method(localStorageAdapter, 'writeString', (key: string, value: string) => {
if (key === STORAGE_KEY_AI_SESSIONS) return false;
storage.set(key, value);
return true;
});
const backupWrites: string[] = [];
const previousWindow = globalThis.window;
Object.defineProperty(globalThis, 'window', {
configurable: true,
value: { netcatty: { aiSessionsBackupWrite: async ({ json }: { json: string }) => {
backupWrites.push(json);
return { ok: true };
} } },
});
t.after(() => Object.defineProperty(globalThis, 'window', { configurable: true, value: previousWindow }));
assert.equal(writeSessionsForStorage(next), false);
assert.deepEqual(storage, before);
assert.deepEqual(readSessionsFromStorage(), original);
flushBackupMirror();
assert.deepEqual(JSON.parse(backupWrites[0]), next);
});
test('orphan cleanup preserves resumable privacy maps until explicit chat deletion', (t) => {
const service = createAiPrivacyService({ maxSessions: 3 });
service.configure({ enabled: true });
@@ -92,6 +521,7 @@ test('orphan cleanup preserves resumable privacy maps until explicit chat deleti
const previousSessions = latestAISessionsSnapshot;
const previousActiveSessionMap = latestAIActiveSessionMapSnapshot;
t.mock.method(localStorageAdapter, 'read', () => null);
t.mock.method(localStorageAdapter, 'readString', () => null);
t.mock.method(localStorageAdapter, 'write', () => true);
t.mock.method(localStorageAdapter, 'writeString', () => true);
Object.defineProperty(globalThis, 'window', {
@@ -181,6 +611,7 @@ test('orphan cleanup marks dangling chats instead of stripping them, and clears
const previousSessions = latestAISessionsSnapshot;
const previousActiveSessionMap = latestAIActiveSessionMapSnapshot;
const read = t.mock.method(localStorageAdapter, 'read', () => null);
t.mock.method(localStorageAdapter, 'readString', () => null);
t.mock.method(localStorageAdapter, 'write', () => true);
const writeString = t.mock.method(localStorageAdapter, 'writeString', () => true);
const previousWindow = globalThis.window;
@@ -236,6 +667,7 @@ test('orphan cleanup with an empty live-target set marks all scoped chats dangli
const previousSessions = latestAISessionsSnapshot;
const previousActiveSessionMap = latestAIActiveSessionMapSnapshot;
t.mock.method(localStorageAdapter, 'read', () => null);
t.mock.method(localStorageAdapter, 'readString', () => null);
t.mock.method(localStorageAdapter, 'write', () => true);
t.mock.method(localStorageAdapter, 'writeString', () => true);
const previousWindow = globalThis.window;
@@ -626,49 +1058,26 @@ test('writeSessionsForStorage retries below nominal budgets after a quota failur
}
});
test('writeSessionsForStorage reduces several small sessions to fit a sub-512 KB quota', async () => {
const { writeSessionsForStorage } = await import('./aiStateSnapshots');
test('writeSessionsForStorage splits overflow to fit a sub-512 KB per-key quota', (t) => {
const storage = installSessionStorage(t);
const writes: string[] = [];
let stored: string | undefined;
const previousLocalStorage = globalThis.localStorage;
Object.defineProperty(globalThis, 'localStorage', {
configurable: true,
value: {
getItem: () => null,
setItem: (_key: string, value: string) => {
writes.push(value);
if (value.length > 300 * 1024) {
throw new DOMException('quota exceeded', 'QuotaExceededError');
}
stored = value;
},
removeItem: () => {},
},
t.mock.method(globalThis.localStorage, 'setItem', (key: string, value: string) => {
writes.push(value);
if (value.length > 300 * 1024) {
throw new DOMException('quota exceeded', 'QuotaExceededError');
}
storage.set(key, value);
});
try {
const sessions = Array.from({ length: 10 }, (_, index) => makeSession(
`session-${index}`,
10 - index,
[{
id: `message-${index}`,
role: 'user' as const,
content: 'x'.repeat(40 * 1024),
timestamp: index,
}],
));
assert.equal(writeSessionsForStorage(sessions), true);
assert.ok(writes[0].length > 300 * 1024);
assert.ok(stored);
assert.ok(stored.length <= 300 * 1024);
assert.ok((JSON.parse(stored) as AISession[]).length < sessions.length);
} finally {
Object.defineProperty(globalThis, 'localStorage', {
configurable: true,
value: previousLocalStorage,
});
}
const sessions = Array.from({ length: 10 }, (_, index) => makeSession(
`session-${index}`, 10 - index,
[{ id: `message-${index}`, role: 'user', content: 'x'.repeat(40 * 1024), timestamp: index }],
));
assert.equal(writeSessionsForStorage(sessions), true);
assert.ok(writes[0].length > 300 * 1024);
const primary = storage.get(STORAGE_KEY_AI_SESSIONS)!;
assert.ok(primary.length <= 300 * 1024);
assert.ok((JSON.parse(primary) as AISession[]).length < sessions.length);
assert.deepEqual(readSessionsFromStorage(), sessions);
});
test('serializeSessionsForStorage truncates slightly-over-cap tool results to strictly smaller sizes', async () => {
@@ -780,50 +1189,26 @@ test('writeSessionsForStorage keeps every chat by shedding tool output under quo
}
});
test('writeSessionsForStorage makes a final attempt with only the newest session', async () => {
const { writeSessionsForStorage } = await import('./aiStateSnapshots');
test('writeSessionsForStorage makes a final attempt with the newest primary and keeps overflow', (t) => {
const storage = installSessionStorage(t);
const writes: string[] = [];
let stored: string | undefined;
const previousLocalStorage = globalThis.localStorage;
Object.defineProperty(globalThis, 'localStorage', {
configurable: true,
value: {
getItem: () => null,
setItem: (_key: string, value: string) => {
writes.push(value);
if (value.length > 50 * 1024) {
throw new DOMException('quota exceeded', 'QuotaExceededError');
}
stored = value;
},
removeItem: () => {},
},
t.mock.method(globalThis.localStorage, 'setItem', (key: string, value: string) => {
writes.push(value);
if (value.length > 50 * 1024) {
throw new DOMException('quota exceeded', 'QuotaExceededError');
}
storage.set(key, value);
});
try {
const sessions = Array.from({ length: 10 }, (_, index) => makeSession(
`session-${index}`,
10 - index,
[{
id: `message-${index}`,
role: 'user' as const,
content: 'x'.repeat(40 * 1024),
timestamp: index,
}],
));
assert.equal(writeSessionsForStorage(sessions), true);
assert.equal(writes.length, 6);
assert.ok(stored);
const persisted = JSON.parse(stored) as AISession[];
assert.deepEqual(persisted.map(session => session.id), ['session-0']);
assert.ok(stored.length <= 50 * 1024);
} finally {
Object.defineProperty(globalThis, 'localStorage', {
configurable: true,
value: previousLocalStorage,
});
}
const sessions = Array.from({ length: 10 }, (_, index) => makeSession(
`session-${index}`, 10 - index,
[{ id: `message-${index}`, role: 'user', content: 'x'.repeat(40 * 1024), timestamp: index }],
));
assert.equal(writeSessionsForStorage(sessions), true);
assert.ok(writes.some(value => value.length > 50 * 1024));
const primary = storage.get(STORAGE_KEY_AI_SESSIONS)!;
assert.deepEqual((JSON.parse(primary) as AISession[]).map(session => session.id), ['session-0']);
assert.ok(primary.length <= 50 * 1024);
assert.deepEqual(readSessionsFromStorage(), sessions);
});
function freezeHistory<T>(value: T): T {
@@ -899,18 +1284,18 @@ test('synced storage bypasses fit-first pruning and non-quota failures still pro
const { writeSessionsForStorage } = await import('./aiStateSnapshots');
const sessions = freezeHistory(makePersistenceHistory(51, 201));
const read = t.mock.method(localStorageAdapter, 'read', () => ({ enabled: true }));
const write = t.mock.method(localStorageAdapter, 'write', () => false);
const writeString = t.mock.method(localStorageAdapter, 'writeString', () => true);
t.mock.method(localStorageAdapter, 'readString', () => null);
const writeString = t.mock.method(localStorageAdapter, 'writeString', () => false);
assert.equal(writeSessionsForStorage(sessions), false);
assert.equal(write.mock.callCount(), 1);
assert.equal(write.mock.calls[0].arguments[1], sessions);
assert.equal(writeString.mock.callCount(), 0);
// Synced: one write of the complete, unpruned history (serialized once).
assert.equal(writeString.mock.callCount(), 1);
assert.equal(writeString.mock.calls[0].arguments[1], JSON.stringify(sessions));
read.mock.mockImplementation(() => ({ enabled: false }));
const error = new Error('non-quota storage failure');
writeString.mock.mockImplementation(() => { throw error; });
assert.throws(() => writeSessionsForStorage(sessions), candidate => candidate === error);
assert.equal(writeString.mock.callCount(), 1);
assert.equal(writeString.mock.callCount(), 2);
});
for (const [budget, expectedCalls] of [[128 * 1024, 160], [0, 161]]) {
@@ -945,6 +1330,7 @@ test('successful store writes schedule a throttled disk backup mirror', async (t
const sessions = freezeHistory(makePersistenceHistory(3, 10));
const writeString = t.mock.method(localStorageAdapter, 'writeString', () => true);
t.mock.method(localStorageAdapter, 'read', () => null);
t.mock.method(localStorageAdapter, 'readString', () => null);
try {
assert.equal(writeSessionsForStorage(sessions), true);
// Throttled: nothing has been written to disk yet.
@@ -953,11 +1339,12 @@ test('successful store writes schedule a throttled disk backup mirror', async (t
assert.equal(backupWrites.length, 1);
// The mirror carries exactly the JSON that landed in localStorage.
assert.equal(backupWrites[0], writeString.mock.calls[0].arguments[1]);
// Failed store writes never schedule a mirror.
// A failed shrinking write still mirrors the full count-capped history.
writeString.mock.mockImplementation(() => false);
assert.equal(writeSessionsForStorage(sessions), false);
flushBackupMirror();
assert.equal(backupWrites.length, 1);
assert.equal(backupWrites.length, 2);
assert.deepEqual(JSON.parse(backupWrites[1]), sessions);
} finally {
__resetBackupMirrorForTests();
Object.defineProperty(globalThis, 'window', {
@@ -987,3 +1374,25 @@ test('rebindActiveSessionMap drops stale pointers and sets the new scope', () =>
{ 'terminal:new-ws': 'chat-1' },
);
});
test('rewriting the same sessions array skips re-serialization while storage still holds it', async (t) => {
const { writeSessionsForStorage } = await import('./aiStateSnapshots');
const sessions = freezeHistory(makePersistenceHistory(3, 10));
let stored: string | null = null;
t.mock.method(localStorageAdapter, 'read', () => ({ enabled: true }));
const writeString = t.mock.method(localStorageAdapter, 'writeString', (_key: string, value: string) => {
stored = value;
return true;
});
t.mock.method(localStorageAdapter, 'readString', (key: string) => key === STORAGE_KEY_AI_SESSIONS ? stored : null);
assert.equal(writeSessionsForStorage(sessions), true);
const stringify = t.mock.method(JSON, 'stringify');
// Cloud-sync collection writes the same immutable array again.
assert.equal(writeSessionsForStorage(sessions), true);
assert.equal(writeString.mock.callCount(), 1);
assert.equal(stringify.mock.callCount(), 0);
// Another window replaced the store: write again.
stored = '[]';
assert.equal(writeSessionsForStorage(sessions), true);
assert.equal(writeString.mock.callCount(), 2);
});
+269 -38
View File
@@ -2,6 +2,8 @@ import { localStorageAdapter } from '../../infrastructure/persistence/localStora
import {
STORAGE_KEY_AI_ACTIVE_SESSION_MAP,
STORAGE_KEY_AI_SESSIONS,
STORAGE_KEY_AI_SESSIONS_OVERFLOW,
STORAGE_KEY_AI_SESSIONS_DISK_OVERFLOW,
STORAGE_KEY_CLOUD_SYNC_AI_SESSIONS,
} from '../../infrastructure/config/storageKeys';
import type {
@@ -23,12 +25,14 @@ import {
pruneInactiveScopedState,
} from './aiScopeCleanup';
import { emitAIStateChanged } from './aiStateEvents';
import { getAgentRuntime } from '../../infrastructure/ai/harness/globalAgentRuntime';
import { DebouncedBackupMirror, readLiveSessionsArray } from './aiSessionsBackup';
import { peekAgentRuntimeCleanup } from '../../infrastructure/ai/harness/agentRuntimeHandle';
import { setReversibleModelPrivacy } from '../../infrastructure/ai/harness/modelSecretRedaction';
import { DebouncedBackupMirror, mergeImportedSessions, readLiveSessionsArray } from './aiSessionsBackup';
/** Typed accessor for the Electron IPC bridge exposed on `window.netcatty`. */
export interface AIBridge {
aiSdkAgentCleanup?: (chatSessionId: string) => Promise<{ ok: boolean }>;
aiPrivacyClearSession?: (chatSessionId: string) => Promise<{ ok: boolean; error?: string }>;
deleteChatToolOutputsTemp?: (chatSessionId: string) => Promise<{ deletedCount: number }>;
deleteTerminalToolOutputsEverywhereTemp?: (terminalSessionId: string) => Promise<{ deletedCount: number }>;
aiMcpSetPermissionMode?: (mode: AIPermissionMode) => Promise<unknown> | unknown;
@@ -63,8 +67,18 @@ export async function cleanupSdkAgentSessions(sessionIds: string[]): Promise<voi
export function cleanupDeletedAIChatSessions(sessionIds: string[]) {
const bridge = getAIBridge();
if (sessionIds.length === 0) return;
// Only the startup-light handle is imported here; the runtime registers it
// when created. Without a runtime there is no in-memory runtime state, but
// the reversible-privacy flag (set outside the runtime) and the
// main-process privacy mapping may still exist, so always clear those.
const runtime = peekAgentRuntimeCleanup();
for (const sessionId of sessionIds) {
getAgentRuntime().clearChatSession(sessionId);
if (runtime) {
runtime.clearChatSession(sessionId);
} else {
setReversibleModelPrivacy(sessionId, false);
void bridge?.aiPrivacyClearSession?.(sessionId).catch(() => {});
}
void bridge?.aiSdkAgentCleanup?.(sessionId).catch(() => {});
void bridge?.deleteChatToolOutputsTemp?.(sessionId).catch(() => {});
}
@@ -72,8 +86,9 @@ export function cleanupDeletedAIChatSessions(sessionIds: string[]) {
export function cleanupClosedTerminalSessions(terminalSessionIds: string[]) {
const bridge = getAIBridge();
const runtime = peekAgentRuntimeCleanup();
for (const terminalSessionId of new Set(terminalSessionIds)) {
getAgentRuntime().clearTerminalSession(terminalSessionId);
runtime?.clearTerminalSession(terminalSessionId);
void bridge?.deleteTerminalToolOutputsEverywhereTemp?.(terminalSessionId).catch(() => {});
}
}
@@ -94,7 +109,7 @@ export let latestAIActiveTargetIdsSnapshot: Set<string> = new Set();
export function cleanupOrphanedAISessions(activeTargetIds: Set<string>) {
latestAIActiveTargetIdsSnapshot = activeTargetIds;
const currentSessions = latestAISessionsSnapshot
?? readLiveSessionsArray(localStorageAdapter.readString(STORAGE_KEY_AI_SESSIONS));
?? readSessionsFromStorage();
// Sessions shown by a still-live scope must be protected from cleanup
// even when their own `scope.targetId` points at a closed terminal —
@@ -145,7 +160,14 @@ export function cleanupOrphanedAISessions(activeTargetIds: Set<string>) {
if (sessionsMarked) {
setLatestAISessionsSnapshot(markedSessions);
writeSessionsForStorage(markedSessions);
// Startup cleanup can run before the disk backup read resolves. Defer
// persistence until those chats are merged, preserving their recovery
// marker and avoiding a partial backup of only the loaded sessions.
const diskOnlyIds = localStorageAdapter.read<string[]>(STORAGE_KEY_AI_SESSIONS_DISK_OVERFLOW) ?? [];
const loadedIds = new Set(markedSessions.map(session => session.id));
if (!diskOnlyIds.some(id => !loadedIds.has(id))) {
writeSessionsForStorage(markedSessions);
}
emitAIStateChanged(STORAGE_KEY_AI_SESSIONS);
}
@@ -186,10 +208,9 @@ const MAX_STORED_SESSIONS = 50;
/** Maximum number of messages per session when persisting to localStorage. */
const MAX_SESSION_MESSAGES = 200;
/**
* Byte budget for the serialized sessions JSON. The localStorage quota is
* ~5-10 MB across all keys, and Responses reasoning ciphertext can add tens
* of KB per turn, so keep the sessions blob well under the quota with
* headroom for the rest of the app's storage keys.
* String-length budget for the primary sessions JSON and each overflow chunk.
* Replay bulk is shed from the primary blob first; whole sessions that still
* do not fit are retained in overflow rather than silently evicted.
*/
const MAX_SESSIONS_JSON_BYTES = 2 * 1024 * 1024;
/** Retry budgets used when the primary budget still fails to persist. */
@@ -492,16 +513,17 @@ export function serializeSessionsForStorage(
}
/**
* Persist sessions to localStorage with byte-budgeted pruning and retries.
* Returns true when the write succeeded; a false result means the payload
* could not be persisted even after escalation (it stays memory-only).
* Persist sessions to localStorage with byte-budgeted primary/overflow slices.
* Returns true when local history was saved, possibly as a newest-primary
* fallback with the full history scheduled for disk backup under quota pressure.
*/
// Durability mirror (issue #4015 follow-up): every successful store write
// also schedules a debounced disk backup so a wiped/recreated profile does
// not take the chat history with it. The exact JSON that landed in
// localStorage is mirrored (already byte-budgeted), throttled to at most one
// write per mutation batch.
// not take the chat history with it. Persisted history is mirrored, including
// overflow sessions, throttled to at most one write per mutation batch.
// Before a byte-budget drop, mirror the full
// count-capped set even if the subsequent localStorage writes fail.
const backupMirror = new DebouncedBackupMirror((json) => {
try {
void getAIBridge()?.aiSessionsBackupWrite?.({ json })?.catch(() => {});
@@ -530,14 +552,187 @@ export function scheduleBackupMirror(json: string) {
}
}
export function writeSessionsForStorage(sessions: AISession[]): boolean {
/**
* Last successful write: the same immutable sessions array is written again by
* the debounced save path and by cloud-sync collection, and each write used to
* re-serialize the whole (often multi-MB) history.
*/
let lastSessionsWrite: { sessions: AISession[]; synced: boolean; json: string } | null = null;
function rememberSessionsWrite(sessions: AISession[], synced: boolean, json: string): void {
lastSessionsWrite = { sessions, synced, json };
}
function storedSessionsJsonEquals(json: string): boolean {
try {
return localStorageAdapter.readString(STORAGE_KEY_AI_SESSIONS) === json;
} catch {
return false;
}
}
function overflowChunkKey(index: number): string {
return index === 0 ? STORAGE_KEY_AI_SESSIONS_OVERFLOW : `${STORAGE_KEY_AI_SESSIONS_OVERFLOW}_${index + 1}`;
}
function readOverflowChunks(): string[] {
const chunks: string[] = [];
for (let index = 0; ; index += 1) {
const chunk = localStorageAdapter.readString(overflowChunkKey(index));
if (chunk == null) return chunks;
chunks.push(chunk);
}
}
function clearOverflowChunks(startIndex = 0) {
for (let index = startIndex; localStorageAdapter.readString(overflowChunkKey(index)) != null; index += 1) {
localStorageAdapter.remove(overflowChunkKey(index));
}
}
function writeDiskOverflowIds(ids: string[]): boolean {
if (ids.length > 0) {
return localStorageAdapter.write(STORAGE_KEY_AI_SESSIONS_DISK_OVERFLOW, ids);
}
if (localStorageAdapter.readString(STORAGE_KEY_AI_SESSIONS_DISK_OVERFLOW) != null) {
localStorageAdapter.remove(STORAGE_KEY_AI_SESSIONS_DISK_OVERFLOW);
}
return true;
}
/** Replace the entire overflow blob; never leave a partially written JSON array. */
function replaceOverflowChunks(chunks: string[]): boolean {
clearOverflowChunks();
let complete = false;
try {
complete = chunks.every((chunk, index) => (
localStorageAdapter.writeString(overflowChunkKey(index), chunk)
));
return complete;
} finally {
if (!complete) clearOverflowChunks();
}
}
/** Read both local retention slices; newer copies win if a session is duplicated. */
export function readSessionsFromStorage(): AISession[] {
return mergeImportedSessions(
readLiveSessionsArray(readOverflowChunks().join('')),
readLiveSessionsArray(localStorageAdapter.readString(STORAGE_KEY_AI_SESSIONS)),
);
}
export function writeSessionsForStorage(
sessions: AISession[],
options: { deletedSessionIds?: readonly string[] } = {},
): boolean {
const synced = Boolean(
localStorageAdapter.read<{ enabled?: boolean }>(STORAGE_KEY_CLOUD_SYNC_AI_SESSIONS)?.enabled,
);
// Nothing to do when this exact array was already written in this mode and
// storage still holds that write (another window may have replaced it).
if (
lastSessionsWrite?.sessions === sessions
&& lastSessionsWrite.synced === synced
&& storedSessionsJsonEquals(lastSessionsWrite.json)
// A matching primary alone cannot establish that overflow is intact.
&& localStorageAdapter.readString(STORAGE_KEY_AI_SESSIONS_OVERFLOW) == null
&& localStorageAdapter.readString(STORAGE_KEY_AI_SESSIONS_DISK_OVERFLOW) == null
) {
return true;
}
// Synced records are authoritative after restart: silently evicting a
// session or turn here would turn local retention into cloud deletion.
if (localStorageAdapter.read<{ enabled?: boolean }>(STORAGE_KEY_CLOUD_SYNC_AI_SESSIONS)?.enabled) {
const syncedOk = localStorageAdapter.write(STORAGE_KEY_AI_SESSIONS, sessions);
if (syncedOk) scheduleBackupMirror(JSON.stringify(sessions));
return syncedOk;
if (synced) {
const json = JSON.stringify(sessions);
const previousChunks = readOverflowChunks();
// The authoritative primary replaces overflow, so free its quota first.
clearOverflowChunks();
let syncedOk = false;
try {
syncedOk = localStorageAdapter.writeString(STORAGE_KEY_AI_SESSIONS, json);
if (syncedOk) {
clearOverflowChunks();
if (localStorageAdapter.readString(STORAGE_KEY_AI_SESSIONS_DISK_OVERFLOW) != null) {
localStorageAdapter.remove(STORAGE_KEY_AI_SESSIONS_DISK_OVERFLOW);
}
scheduleBackupMirror(json);
rememberSessionsWrite(sessions, true, json);
}
return syncedOk;
} finally {
if (!syncedOk) replaceOverflowChunks(previousChunks);
}
}
const inputIds = new Set(sessions.map(session => session.id));
const pruned = pruneSessionsForStorage(sessions);
const retainedIds = new Set(pruned.map(session => session.id));
const deletedIds = new Set(options.deletedSessionIds);
// A live write can race startup's backup read. Preserve marker IDs absent
// from the live input unless explicitly deleted. IDs present in the input
// are resolved even if the count cap evicts them; pending IDs block mirroring.
const diskOverflowIds = localStorageAdapter.read<unknown>(STORAGE_KEY_AI_SESSIONS_DISK_OVERFLOW);
const unresolvedDiskIds: string[] = Array.isArray(diskOverflowIds)
? diskOverflowIds.filter((id): id is string => (
typeof id === 'string' && !retainedIds.has(id) && !deletedIds.has(id) && !inputIds.has(id)
))
: [];
let fullBackupScheduled = false;
const ensureFullBackup = () => {
if (!fullBackupScheduled && unresolvedDiskIds.length === 0) {
scheduleBackupMirror(JSON.stringify(pruned));
fullBackupScheduled = true;
}
};
const persistCandidate = (
candidate: { json: string; sessions: AISession[] },
budget: number,
chunkBudget = budget,
): boolean => {
const primaryIds = new Set(candidate.sessions.map(session => session.id));
const omitted = pruned.filter(session => !primaryIds.has(session.id));
if (omitted.length > 0) ensureFullBackup();
// Apply the same replay-bulk ladder to every overflow chat as budgets
// tighten. Chunking alone cannot reduce an origin's aggregate quota use.
const overflow = omitted.map(session => serializeSessionsForStorage([session], budget).sessions[0]);
const overflowJson = overflow.length > 0 ? JSON.stringify(overflow) : '';
// Split the array's JSON string, so even one oversized session can span
// chunks without losing messages. Concatenate all chunks before parsing.
const chunks: string[] = [];
// Primary budgets can shrink to a tiny empty chat. Keep overflow writes
// bounded independently while still supporting small per-key quotas.
const chunkSize = Math.max(32 * 1024, chunkBudget);
for (let offset = 0; offset < overflowJson.length; offset += chunkSize) {
chunks.push(overflowJson.slice(offset, offset + chunkSize));
}
const previousChunks = readOverflowChunks();
const previousPrimary = localStorageAdapter.readString(STORAGE_KEY_AI_SESSIONS);
let primaryWritten = false;
let persisted = false;
try {
// Reclaim both old slices before writing overflow: the final history may
// fit even when overflow plus the old, larger primary would exceed quota.
clearOverflowChunks();
if (!localStorageAdapter.writeString(STORAGE_KEY_AI_SESSIONS, candidate.json)) return false;
primaryWritten = true;
if (!replaceOverflowChunks(chunks)) return false;
if (!writeDiskOverflowIds(unresolvedDiskIds)) return false;
persisted = true;
if (!fullBackupScheduled && unresolvedDiskIds.length === 0) scheduleBackupMirror(candidate.json);
rememberSessionsWrite(sessions, false, candidate.json);
return true;
} finally {
if (!persisted) {
clearOverflowChunks();
if (primaryWritten) {
if (previousPrimary == null) localStorageAdapter.remove(STORAGE_KEY_AI_SESSIONS);
else localStorageAdapter.writeString(STORAGE_KEY_AI_SESSIONS, previousPrimary);
}
replaceOverflowChunks(previousChunks);
}
}
};
const primaryCandidates: { json: string; sessions: AISession[] }[] = [];
let previousLength = Number.POSITIVE_INFINITY;
for (const configuredBudget of [MAX_SESSIONS_JSON_BYTES, ...RETRY_SESSIONS_JSON_BYTES]) {
// A real quota failure means the next attempt must be smaller even when
@@ -549,24 +744,60 @@ export function writeSessionsForStorage(sessions: AISession[]): boolean {
: configuredBudget;
const budget = Math.min(configuredBudget, reducedBudget);
if (budget < 2) break;
const candidate = serializeSessionsForStorage(sessions, budget);
if (candidate.json.length >= previousLength) continue;
if (localStorageAdapter.writeString(STORAGE_KEY_AI_SESSIONS, candidate.json)) {
scheduleBackupMirror(candidate.json);
return true;
}
const candidate = serializeSessionsForStorage(pruned, budget);
primaryCandidates.push(candidate);
// Even an unchanged primary can succeed with smaller overflow chunks or
// more degraded overflow replay data at this retry budget.
if (persistCandidate(candidate, budget)) return true;
previousLength = candidate.json.length;
}
// Last resort: attempt the smallest representation this serializer can
// produce (the newest session with replay-only ciphertext removed). A very
// small amount of shared quota may still be enough to preserve that chat.
const minimalCandidate = serializeSessionsForStorage(sessions, 0);
if (
minimalCandidate.json.length < previousLength
&& localStorageAdapter.writeString(STORAGE_KEY_AI_SESSIONS, minimalCandidate.json)
) {
scheduleBackupMirror(minimalCandidate.json);
return true;
// Last resort: use the smallest primary representation (the newest chat
// with replay-only bulk removed), retaining other chats in overflow.
const minimalCandidate = serializeSessionsForStorage(pruned, 0);
const minimalChunkBudget = Math.min(minimalCandidate.json.length, RETRY_SESSIONS_JSON_BYTES[RETRY_SESSIONS_JSON_BYTES.length - 1]);
primaryCandidates.push(minimalCandidate);
if (persistCandidate(minimalCandidate, 0, minimalChunkBudget)) return true;
// If the complete retained set cannot fit, save the newest primary anyway.
// Schedule the full disk mirror before discarding overflow; do not let an
// old valid primary block persistence of every subsequent conversation turn.
ensureFullBackup();
const previousChunks = readOverflowChunks();
const previousPrimary = localStorageAdapter.readString(STORAGE_KEY_AI_SESSIONS);
const previousDiskOverflow = localStorageAdapter.readString(STORAGE_KEY_AI_SESSIONS_DISK_OVERFLOW);
clearOverflowChunks();
let primaryWritten = false;
let primaryPersisted = false;
try {
for (const candidate of primaryCandidates) {
const primaryIds = new Set(candidate.sessions.map(session => session.id));
const diskOnlyIds = [...new Set([
...unresolvedDiskIds,
...pruned.filter(session => !primaryIds.has(session.id)).map(session => session.id),
])];
// Replacing a large old primary can free the space needed by metadata.
// Roll it back below if the recovery marker still cannot be persisted.
if (!localStorageAdapter.writeString(STORAGE_KEY_AI_SESSIONS, candidate.json)) continue;
primaryWritten = true;
if (writeDiskOverflowIds(diskOnlyIds)) {
primaryPersisted = true;
rememberSessionsWrite(sessions, false, candidate.json);
return true;
}
}
} finally {
if (!primaryPersisted) {
if (primaryWritten) {
if (previousPrimary == null) localStorageAdapter.remove(STORAGE_KEY_AI_SESSIONS);
else localStorageAdapter.writeString(STORAGE_KEY_AI_SESSIONS, previousPrimary);
}
if (previousDiskOverflow != null) {
localStorageAdapter.writeString(STORAGE_KEY_AI_SESSIONS_DISK_OVERFLOW, previousDiskOverflow);
} else if (localStorageAdapter.readString(STORAGE_KEY_AI_SESSIONS_DISK_OVERFLOW) != null) {
localStorageAdapter.remove(STORAGE_KEY_AI_SESSIONS_DISK_OVERFLOW);
}
replaceOverflowChunks(previousChunks);
}
}
console.warn(
'[AIState] Failed to persist AI sessions within the storage quota; recent chat history may not survive a restart.',
@@ -615,7 +846,7 @@ export function prewarmAIStateStorageSnapshots() {
try {
if (latestAISessionsSnapshot === null) {
latestAISessionsSnapshot =
localStorageAdapter.read<AISession[]>(STORAGE_KEY_AI_SESSIONS) ?? [];
readSessionsFromStorage();
}
if (latestAIActiveSessionMapSnapshot === null) {
latestAIActiveSessionMapSnapshot =
@@ -326,3 +326,20 @@ test("an external clear also drops in-memory compose-bar recall", async () => {
unsubscribe();
});
test("saving after a command and peer writes read the clear generation without parsing the history", async (t) => {
clearHistory();
recordCommand("echo fast-path", "host-fast", "linux");
assert.equal(flushCommandHistoryStore(), true);
const raw = storage.get(COMMAND_HISTORY_STORAGE_KEY)!;
assert.match(raw, /^\{"clearGeneration":\d+,/, "generation is serialized first");
const parse = t.mock.method(JSON, "parse");
recordCommand("echo fast-path-2", "host-fast", "linux");
assert.equal(flushCommandHistoryStore(), true);
// A peer window's save only needs the generation as well.
dispatchStorageEvent(COMMAND_HISTORY_STORAGE_KEY, storage.get(COMMAND_HISTORY_STORAGE_KEY)!, raw);
assert.equal(parse.mock.callCount(), 0);
parse.mock.restore();
assert.ok(queryHistory("echo fast", { hostId: "host-fast" }).some((entry) => entry.command === "echo fast-path-2"));
});
+61 -27
View File
@@ -68,33 +68,75 @@ function loadStore(): HistoryStore {
}
let saveTimer: ReturnType<typeof setTimeout> | null = null;
let saveIdleHandle: number | null = null;
const CLEAR_GENERATION_PREFIX = /^\{"clearGeneration":(\d+)[,}]/;
/**
* The store's clear generation, read from its raw JSON without parsing the
* whole history (up to 10k entries, ~1.5 MB). Stores are serialized with
* clearGeneration first (serializeStore); other layouts fall back to a parse.
*/
function readRawClearGeneration(raw: string | null): number | null {
if (!raw) return null;
const match = CLEAR_GENERATION_PREFIX.exec(raw.slice(0, 48));
if (match) return Number(match[1]);
try {
return readClearGeneration(JSON.parse(raw) as HistoryStore);
} catch {
return null;
}
}
function serializeStore(store: HistoryStore): HistoryStore {
// Keep clearGeneration first so readRawClearGeneration can find it.
return { clearGeneration: readClearGeneration(store), version: store.version, entries: store.entries };
}
function persistStoreNow(store: HistoryStore): boolean {
// A peer clear is visible in storage before its queued event is handled.
// Never flush a cached pre-clear snapshot over that newer empty store.
const persisted = localStorageAdapter.read<HistoryStore>(STORAGE_KEY);
if (persisted && readClearGeneration(persisted) > readClearGeneration(store)) {
cachedStore = persisted;
knownClearGeneration = readClearGeneration(persisted);
notifyCommandHistoryCleared();
return true;
// Only the generation is needed on this per-command path; parse the stored
// history just in the rare case where it must be adopted.
const persistedGeneration = readRawClearGeneration(localStorageAdapter.readString(STORAGE_KEY));
if (persistedGeneration !== null && persistedGeneration > readClearGeneration(store)) {
const persisted = localStorageAdapter.read<HistoryStore>(STORAGE_KEY);
if (persisted && readClearGeneration(persisted) > readClearGeneration(store)) {
cachedStore = persisted;
knownClearGeneration = readClearGeneration(persisted);
notifyCommandHistoryCleared();
return true;
}
}
const ok = localStorageAdapter.write(STORAGE_KEY, store);
const ok = localStorageAdapter.write(STORAGE_KEY, serializeStore(store));
if (ok) return true;
// Storage full — evict lowest scored entries (not just oldest by insertion)
const now = Date.now();
store.entries.sort((a, b) => scoreEntryAt(b, now) - scoreEntryAt(a, now));
store.entries = store.entries.slice(0, Math.floor(MAX_ENTRIES / 2));
return localStorageAdapter.write(STORAGE_KEY, store);
return localStorageAdapter.write(STORAGE_KEY, serializeStore(store));
}
function hasPendingSave(): boolean {
return saveTimer !== null || saveIdleHandle !== null;
}
function saveStore(store: HistoryStore): void {
cachedStore = store;
// Debounce saves to avoid excessive writes
if (saveTimer) clearTimeout(saveTimer);
// Debounce saves, then write when the renderer is idle: serializing the
// history right after Enter used to land in the middle of command output.
cancelPendingSave();
saveTimer = setTimeout(() => {
persistStoreNow(store);
saveTimer = null;
const persist = () => {
saveIdleHandle = null;
persistStoreNow(store);
};
if (typeof requestIdleCallback === "function") {
saveIdleHandle = requestIdleCallback(persist, { timeout: 2000 });
} else {
persist();
}
}, 500);
}
@@ -103,6 +145,10 @@ function cancelPendingSave(): void {
clearTimeout(saveTimer);
saveTimer = null;
}
if (saveIdleHandle !== null) {
if (typeof cancelIdleCallback === "function") cancelIdleCallback(saveIdleHandle);
saveIdleHandle = null;
}
}
/**
@@ -129,26 +175,14 @@ function applyExternalCommandHistoryWrite(
rawNewValue: string | null,
rawOldValue: string | null,
): void {
let incomingGeneration: number | null = null;
if (rawNewValue) {
try {
incomingGeneration = readClearGeneration(JSON.parse(rawNewValue) as HistoryStore);
} catch {
incomingGeneration = null;
}
}
// Fires in every other window for each save; read just the generation.
const incomingGeneration = readRawClearGeneration(rawNewValue);
let previousGeneration = knownClearGeneration;
if (previousGeneration === null) {
// A fresh popup can have compose recall before its first autocomplete query.
// The event's old value is its baseline, even after an earlier global clear.
try {
previousGeneration = rawOldValue
? readClearGeneration(JSON.parse(rawOldValue) as HistoryStore)
: 0;
} catch {
previousGeneration = 0;
}
previousGeneration = readRawClearGeneration(rawOldValue) ?? 0;
}
if (incomingGeneration !== null && incomingGeneration > previousGeneration) {
cancelPendingSave();
@@ -156,7 +190,7 @@ function applyExternalCommandHistoryWrite(
knownClearGeneration = incomingGeneration;
notifyCommandHistoryCleared();
} else {
if (!saveTimer) cachedStore = null;
if (!hasPendingSave()) cachedStore = null;
if (incomingGeneration !== null && knownClearGeneration === null) {
knownClearGeneration = incomingGeneration;
}
@@ -47,3 +47,45 @@ test("renderer cursor acknowledges GPU publication separately from CPU sample an
historyId: "history",
});
});
test("idle runtime polls do not notify subscribers until something changes", async () => {
const React = await import("react");
const { act, create } = await import("react-test-renderer");
const { useMonitorSnapshot } = await import("./monitorRuntimeStore");
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
const idle = (takenAt: number): MonitorSnapshot => ({
historyMode: "replace",
runtimeId: "idle-poll-test",
epoch: 7,
takenAt,
running: [],
count: 0,
hostCount: 0,
activeSessions: [],
startedAt: {},
hosts: {},
});
let renders = 0;
function Probe() {
useMonitorSnapshot();
renders += 1;
return null;
}
// Later than anything earlier tests published, so this baseline wins.
const base = 1_000_000_000;
publishMonitorSnapshot(idle(base));
let renderer!: ReturnType<typeof create>;
act(() => {
renderer = create(React.createElement(Probe));
});
const baseline = renders;
act(() => {
for (let at = base + 1000; at <= base + 5000; at += 1000) publishMonitorSnapshot(idle(at));
});
assert.equal(renders, baseline, "unchanged idle replies must not re-render subscribers");
act(() => {
publishMonitorSnapshot({ ...idle(base + 6000), running: ["panel"], count: 1 });
});
assert.equal(renders, baseline + 1, "a real change still notifies");
act(() => renderer.unmount());
});
+23 -1
View File
@@ -14,8 +14,30 @@ const empty: MonitorSnapshot = {
};
let snapshot = empty;
const listeners = new Set<() => void>();
export function isIdleMonitorSnapshot(value: MonitorSnapshot): boolean {
return value.running.length === 0
&& value.count === 0
&& value.hostCount === 0
&& value.activeSessions.length === 0
&& Object.keys(value.hosts).length === 0
&& Object.keys(value.startedAt).length === 0;
}
/** An idle runtime polled again: only `takenAt` moved, nothing to render. */
function isUnchangedIdleSnapshot(previous: MonitorSnapshot, next: MonitorSnapshot): boolean {
return isIdleMonitorSnapshot(previous)
&& isIdleMonitorSnapshot(next)
&& previous.epoch === next.epoch
&& previous.runtimeId === next.runtimeId
&& Boolean(previous.unavailable) === Boolean(next.unavailable);
}
export function publishMonitorSnapshot(next: MonitorSnapshot) {
snapshot = mergeMonitorSnapshotHistory(snapshot, next);
const merged = mergeMonitorSnapshotHistory(snapshot, next);
// The runtime is polled even when nothing is monitored; do not wake every
// subscriber (host-key dialog, monitor panels) for an unchanged idle reply.
if (merged === snapshot || isUnchangedIdleSnapshot(snapshot, merged)) return;
snapshot = merged;
for (const l of listeners) l();
}
export function getMonitorHistoryCursor(): MonitorHistoryCursor {
@@ -393,12 +393,10 @@ export function useSftpFollowTerminalCwd({
handledFollowRef.current = { connectionId: currentConnection.id, terminalCwd: targetPath };
}
}, [
activeSessionId,
activeTerminalCwd,
activeTerminalCwdTrusted,
canFollowTerminalCwd,
effectiveFollowTerminalCwd,
focusedSessionId,
hasActiveWork,
isVisible,
onGetTerminalCwd,
@@ -3,6 +3,37 @@ import test from 'node:test';
import { createTerminalReconnectRegistry } from './terminalReconnectRegistry';
test('recovery snapshots use the current handler and disappear on unmount', () => {
const registry = createTerminalReconnectRegistry();
assert.equal(registry.inspect('s'), null);
const unregister = registry.register('s', () => {}, () => false,
() => ({ status: 'disconnected', waitingForInput: true, reconnecting: false }));
assert.equal(registry.inspect('s')?.waitingForInput, true);
assert.equal(registry.inspect('s')?.reconnectAllowed, false);
unregister();
assert.equal(registry.inspect('s'), null);
});
test('agent reconnect never queues an unmounted terminal', () => {
const registry = createTerminalReconnectRegistry();
assert.equal(registry.requestIfAvailable('missing'), false);
let calls = 0;
registry.register('missing', () => { calls++; });
assert.equal(calls, 0);
});
test('agent reconnect respects live eligibility and active reconnects', () => {
const registry = createTerminalReconnectRegistry();
let eligible = false;
let calls = 0;
registry.register('s', () => { calls++; registry.setActive('s', true); }, () => eligible);
assert.equal(registry.requestIfAvailable('s'), false);
eligible = true;
assert.equal(registry.requestIfAvailable('s'), true);
assert.equal(registry.requestIfAvailable('s'), false);
assert.equal(calls, 1);
});
test('a registered terminal session can be reconnected on request', () => {
const registry = createTerminalReconnectRegistry();
const requestedSessionIds: string[] = [];
+27 -1
View File
@@ -1,7 +1,10 @@
type TerminalReconnectHandler = () => void;
export type TerminalRecoverySnapshot = { status: string; waitingForInput: boolean; reconnecting: boolean; stopped?: boolean };
export const createTerminalReconnectRegistry = () => {
const handlers = new Map<string, TerminalReconnectHandler>();
const eligibility = new Map<string, () => boolean>();
const recoveryReaders = new Map<string, () => TerminalRecoverySnapshot>();
const pendingRequests = new Set<string>();
const activeSessionIds = new Set<string>();
const enterReconnectEnabledIds = new Set<string>();
@@ -28,19 +31,40 @@ export const createTerminalReconnectRegistry = () => {
return () => listeners.delete(listener);
};
const register = (sessionId: string, handler: TerminalReconnectHandler): (() => void) => {
const register = (sessionId: string, handler: TerminalReconnectHandler,
canReconnect: () => boolean = () => false, readRecovery?: () => TerminalRecoverySnapshot): (() => void) => {
handlers.set(sessionId, handler);
eligibility.set(sessionId, canReconnect);
if (readRecovery) recoveryReaders.set(sessionId, readRecovery);
else recoveryReaders.delete(sessionId);
if (pendingRequests.delete(sessionId)) {
handler();
}
return () => {
if (handlers.get(sessionId) === handler) {
handlers.delete(sessionId);
eligibility.delete(sessionId);
recoveryReaders.delete(sessionId);
setActive(sessionId, false);
}
};
};
const inspect = (sessionId: string) => {
const state = recoveryReaders.get(sessionId)?.();
if (!state) return null;
return { ...state, reconnecting: state.reconnecting || isActive(sessionId),
reconnectAllowed: !isActive(sessionId) && Boolean(eligibility.get(sessionId)?.()) };
};
// Agent requests must never survive unmount or bypass authentication/auto-retry.
const requestIfAvailable = (sessionId: string): boolean => {
const handler = handlers.get(sessionId);
if (!handler || isActive(sessionId) || !eligibility.get(sessionId)?.()) return false;
handler();
return true;
};
const request = (sessionId: string): boolean => {
if (isActive(sessionId)) return false;
const handler = handlers.get(sessionId);
@@ -66,7 +90,9 @@ export const createTerminalReconnectRegistry = () => {
return {
register,
inspect,
request,
requestIfAvailable,
requestEnterReconnect,
setEnterReconnectEnabled,
setActive,
@@ -0,0 +1,456 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import React, { act } from 'react';
import { createRoot } from 'react-dom/client';
import { JSDOM } from 'jsdom';
import type { AISession } from '../../infrastructure/ai/types';
import { STORAGE_KEY_AI_ACTIVE_SESSION_MAP, STORAGE_KEY_AI_SESSIONS, STORAGE_KEY_AI_SESSIONS_OVERFLOW, STORAGE_KEY_AI_SESSIONS_DISK_OVERFLOW, STORAGE_KEY_CLOUD_SYNC_AI_SESSIONS } from '../../infrastructure/config/storageKeys';
import { useAIState } from './useAIState';
import { useAISessionsStore } from './aiSessionsStore';
import { __resetBackupMirrorForTests, flushBackupMirror, readSessionsFromStorage, setLatestAIActiveSessionMapSnapshot, setLatestAISessionsSnapshot, writeSessionsForStorage } from './aiStateSnapshots';
for (const enabled of [true, false]) {
test(`appending to long history preserves the synced prefix (chat sync ${enabled})`, async () => {
const dom = new JSDOM('<!doctype html><html><body></body></html>', { url: 'http://localhost' });
for (const name of ['window', 'document', 'localStorage', 'CustomEvent', 'Event'] as const) {
Object.defineProperty(globalThis, name, { configurable: true, value: name === 'window' ? dom.window : dom.window[name] });
}
Object.defineProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT', { configurable: true, value: true });
const source: AISession = {
id: 'long-chat', title: 'Long chat', agentId: 'codex', externalSessionId: 'native-thread',
scope: { type: 'global' }, createdAt: 1, updatedAt: 501,
messages: [{ id: 'system', role: 'system', content: 'Instruction', timestamp: 0 },
...Array.from({ length: 500 }, (_, index) => ({ id: `message-${index}`, role: 'user' as const, content: `Prompt ${index}`, timestamp: index + 1 }))],
};
setLatestAISessionsSnapshot([source]);
dom.window.localStorage.setItem(STORAGE_KEY_AI_SESSIONS, JSON.stringify([source]));
dom.window.localStorage.setItem(STORAGE_KEY_CLOUD_SYNC_AI_SESSIONS, JSON.stringify({ enabled }));
let ai: ReturnType<typeof useAIState>;
let sessions: readonly AISession[] = [];
function Harness() {
ai = useAIState();
sessions = useAISessionsStore().sessions;
return null;
}
const container = document.createElement('div');
document.body.appendChild(container);
const root = createRoot(container);
try {
await act(async () => { root.render(React.createElement(Harness)); });
await act(async () => {
ai!.addMessageToSession(source.id, { id: 'new-reply', role: 'assistant', content: 'Continued', timestamp: 502 });
});
const updated = sessions.find(value => value.id === source.id)!;
assert.equal(updated.messages.length, enabled ? 502 : 500);
assert.equal(updated.messages[0].id, 'system');
assert.equal(updated.messages[1].id, enabled ? 'message-0' : 'message-2');
assert.equal(updated.messages.at(-1)?.id, 'new-reply');
assert.equal(updated.externalSessionId, 'native-thread');
} finally {
await act(async () => { root.unmount(); });
container.remove();
dom.window.close();
}
});
}
test('startup and cross-window updates load overflow chats and mirror the merged history', async () => {
const dom = new JSDOM('<!doctype html><html><body></body></html>', { url: 'http://localhost' });
for (const name of ['window', 'document', 'localStorage', 'CustomEvent', 'Event'] as const) {
Object.defineProperty(globalThis, name, { configurable: true, value: name === 'window' ? dom.window : dom.window[name] });
}
Object.defineProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT', { configurable: true, value: true });
const primary: AISession = {
id: 'primary', title: 'Primary', agentId: 'catty', scope: { type: 'global' },
createdAt: 1, updatedAt: 2, messages: [],
};
const overflow = { ...primary, id: 'overflow', title: 'Overflow', updatedAt: 1 };
dom.window.localStorage.setItem(STORAGE_KEY_AI_SESSIONS, JSON.stringify([primary]));
dom.window.localStorage.setItem(STORAGE_KEY_AI_SESSIONS_OVERFLOW, JSON.stringify([overflow]));
setLatestAISessionsSnapshot(null as unknown as AISession[]);
__resetBackupMirrorForTests();
const backups: string[] = [];
Object.assign(dom.window, { netcatty: { aiSessionsBackupWrite: async ({ json }: { json: string }) => {
backups.push(json);
return { ok: true };
} } });
let sessions: readonly AISession[] = [];
function Harness() {
useAIState();
sessions = useAISessionsStore().sessions;
return null;
}
const container = document.createElement('div');
document.body.appendChild(container);
const root = createRoot(container);
try {
await act(async () => { root.render(React.createElement(Harness)); });
assert.deepEqual(sessions, [primary, overflow]);
flushBackupMirror();
assert.deepEqual(JSON.parse(backups[0]), [primary, overflow]);
const updatedOverflow = { ...overflow, title: 'Updated elsewhere', updatedAt: 3 };
await act(async () => {
dom.window.localStorage.setItem(STORAGE_KEY_AI_SESSIONS_OVERFLOW, JSON.stringify([updatedOverflow]));
dom.window.dispatchEvent(new dom.window.StorageEvent('storage', { key: STORAGE_KEY_AI_SESSIONS_OVERFLOW }));
});
assert.deepEqual(sessions, [updatedOverflow, primary]);
} finally {
await act(async () => { root.unmount(); });
__resetBackupMirrorForTests();
setLatestAISessionsSnapshot([]);
container.remove();
dom.window.close();
}
});
for (const scopeType of ['global', 'terminal'] as const) {
test(`import retains disk-only chats in live history across rename and deletion (${scopeType})`, async () => {
const dom = new JSDOM('<!doctype html><html><body></body></html>', {
url: 'http://localhost', storageQuota: 5 * 1024 * 1024,
});
const previousDOMException = globalThis.DOMException;
for (const name of ['window', 'document', 'localStorage', 'CustomEvent', 'Event', 'DOMException'] as const) {
Object.defineProperty(globalThis, name, { configurable: true, value: name === 'window' ? dom.window : dom.window[name] });
}
Object.defineProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT', { configurable: true, value: true });
const source: AISession[] = Array.from({ length: 6 }, (_, index) => ({
id: `imported-${index}`, title: `Imported ${index}`, agentId: 'catty',
scope: scopeType === 'global' ? { type: 'global' } : { type: 'terminal', targetId: 'closed-terminal' },
createdAt: 1, updatedAt: 6 - index,
messages: [{ id: 'm', role: 'user', content: 'x'.repeat(Math.floor(1.1 * 1024 * 1024)), timestamp: 1 }],
}));
const ids = source.map(session => session.id);
const backups: string[] = [];
Object.assign(dom.window, { netcatty: {
aiSessionsBackupImport: async () => ({ success: true, json: JSON.stringify(source) }),
aiSessionsBackupWrite: async ({ json }: { json: string }) => { backups.push(json); return { ok: true }; },
} });
dom.window.localStorage.setItem(STORAGE_KEY_AI_SESSIONS, '[]');
setLatestAISessionsSnapshot([]);
setLatestAIActiveSessionMapSnapshot({});
__resetBackupMirrorForTests();
let ai!: ReturnType<typeof useAIState>;
let sessions: readonly AISession[] = [];
function Harness() {
ai = useAIState();
sessions = useAISessionsStore().sessions;
return null;
}
const container = document.createElement('div');
document.body.appendChild(container);
const root = createRoot(container);
try {
await act(async () => { root.render(React.createElement(Harness)); });
await act(async () => { ai.cleanupOrphanedSessions(new Set()); });
let importedCount = 0;
await act(async () => { importedCount = await ai.importSessionsBackup(); });
assert.equal(importedCount, source.length);
assert.deepEqual(sessions.map(session => session.id), ids);
assert.deepEqual(readSessionsFromStorage().map(session => session.id), [ids[0]]);
assert.deepEqual(JSON.parse(dom.window.localStorage.getItem(STORAGE_KEY_AI_SESSIONS_DISK_OVERFLOW)!), ids.slice(1));
assert.equal(sessions.every(session => !!session.scopeDangling === (scopeType === 'terminal')), true);
flushBackupMirror();
assert.deepEqual(JSON.parse(backups.at(-1)!), sessions);
await act(async () => { ai.updateSessionTitle(ids[0], 'Renamed after import'); });
assert.deepEqual(sessions.map(session => session.id), ids);
assert.equal(sessions[0].title, 'Renamed after import');
assert.deepEqual(JSON.parse(dom.window.localStorage.getItem(STORAGE_KEY_AI_SESSIONS_DISK_OVERFLOW)!), ids.slice(1));
flushBackupMirror();
assert.deepEqual(JSON.parse(backups.at(-1)!), sessions);
await act(async () => { ai.deleteSession(ids[5]); });
assert.deepEqual(sessions.map(session => session.id), ids.slice(0, 5));
assert.deepEqual(JSON.parse(dom.window.localStorage.getItem(STORAGE_KEY_AI_SESSIONS_DISK_OVERFLOW)!), ids.slice(1, 5));
flushBackupMirror();
assert.deepEqual(JSON.parse(backups.at(-1)!), sessions);
} finally {
await act(async () => { root.unmount(); });
__resetBackupMirrorForTests();
setLatestAISessionsSnapshot([]);
setLatestAIActiveSessionMapSnapshot({});
container.remove();
dom.window.close();
Object.defineProperty(globalThis, 'DOMException', { configurable: true, value: previousDOMException });
}
});
}
test('startup recovers disk-only chats before mirroring a valid partial primary', async () => {
const dom = new JSDOM('<!doctype html><html><body></body></html>', {
url: 'http://localhost', storageQuota: 5 * 1024 * 1024,
});
const previousDOMException = globalThis.DOMException;
for (const name of ['window', 'document', 'localStorage', 'CustomEvent', 'Event', 'DOMException'] as const) {
Object.defineProperty(globalThis, name, { configurable: true, value: name === 'window' ? dom.window : dom.window[name] });
}
Object.defineProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT', { configurable: true, value: true });
const source: AISession[] = Array.from({ length: 6 }, (_, index) => ({
id: `chat-${index}`, title: `Chat ${index}`, agentId: 'catty', scope: { type: 'global' },
createdAt: 1, updatedAt: 6 - index,
messages: [{ id: 'm', role: 'user', content: 'x'.repeat(Math.floor(1.1 * 1024 * 1024)), timestamp: 1 }],
}));
const backups: string[] = [];
let reads = 0;
let resolveRead!: (backup: { ok: boolean; json: string }) => void;
Object.assign(dom.window, { netcatty: {
aiSessionsBackupWrite: async ({ json }: { json: string }) => { backups.push(json); return { ok: true }; },
aiSessionsBackupReadLatest: () => {
reads += 1;
return new Promise(resolve => { resolveRead = resolve; });
},
} });
__resetBackupMirrorForTests();
assert.equal(writeSessionsForStorage(source), true);
flushBackupMirror();
assert.deepEqual(JSON.parse(backups[0]), source);
assert.deepEqual(readSessionsFromStorage(), [source[0]]);
assert.deepEqual(JSON.parse(dom.window.localStorage.getItem(STORAGE_KEY_AI_SESSIONS_DISK_OVERFLOW)!), source.slice(1).map(session => session.id));
// A newer local copy must win, and an old deleted chat in the backup must
// stay deleted: only the explicitly recorded disk-only IDs are recovered.
const primary = { ...source[0], title: 'Newer primary', updatedAt: 10 };
dom.window.localStorage.setItem(STORAGE_KEY_AI_SESSIONS, JSON.stringify([primary]));
const expected = [primary, ...source.slice(1)];
const staleBackup = JSON.stringify([...source, { ...source[5], id: 'deleted-chat' }]);
backups.length = 0;
__resetBackupMirrorForTests();
setLatestAISessionsSnapshot(null as unknown as AISession[]);
let sessions: readonly AISession[] = [];
function Harness() {
useAIState();
sessions = useAISessionsStore().sessions;
return null;
}
const container = document.createElement('div');
document.body.appendChild(container);
const root = createRoot(container);
try {
await act(async () => { root.render(React.createElement(Harness)); });
assert.equal(reads, 1);
flushBackupMirror();
assert.deepEqual(backups, []);
await act(async () => { resolveRead({ ok: true, json: staleBackup }); });
assert.deepEqual(sessions, expected);
flushBackupMirror();
assert.deepEqual(JSON.parse(backups.at(-1)!), expected);
assert.deepEqual(readSessionsFromStorage(), [primary]);
} finally {
await act(async () => { root.unmount(); });
__resetBackupMirrorForTests();
setLatestAISessionsSnapshot([]);
container.remove();
dom.window.close();
Object.defineProperty(globalThis, 'DOMException', { configurable: true, value: previousDOMException });
}
});
for (const scopeType of ['terminal', 'workspace'] as const) {
test(`startup orphan cleanup preserves pending disk-only recovery (${scopeType})`, async () => {
const dom = new JSDOM('<!doctype html><html><body></body></html>', { url: 'http://localhost' });
for (const name of ['window', 'document', 'localStorage', 'CustomEvent', 'Event'] as const) {
Object.defineProperty(globalThis, name, { configurable: true, value: name === 'window' ? dom.window : dom.window[name] });
}
Object.defineProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT', { configurable: true, value: true });
const primary: AISession = {
id: 'closed-primary', title: 'Closed primary', agentId: 'catty',
scope: { type: scopeType, targetId: 'closed-target' },
createdAt: 1, updatedAt: 2, messages: [],
};
const diskOnly: AISession = {
...primary, id: 'disk-only', title: 'Disk only', updatedAt: 1,
scope: { type: scopeType, targetId: 'another-closed-target' },
};
dom.window.localStorage.setItem(STORAGE_KEY_AI_SESSIONS, JSON.stringify([primary]));
dom.window.localStorage.setItem(STORAGE_KEY_AI_SESSIONS_DISK_OVERFLOW, JSON.stringify([diskOnly.id]));
setLatestAISessionsSnapshot(null as unknown as AISession[]);
__resetBackupMirrorForTests();
const backups: string[] = [];
let reads = 0;
let resolveRead!: (backup: { ok: boolean; json: string }) => void;
Object.assign(dom.window, { netcatty: {
aiSessionsBackupWrite: async ({ json }: { json: string }) => { backups.push(json); return { ok: true }; },
aiSessionsBackupReadLatest: () => {
reads += 1;
return new Promise(resolve => { resolveRead = resolve; });
},
} });
let sessions: readonly AISession[] = [];
function Harness() {
const ai = useAIState();
sessions = useAISessionsStore().sessions;
// TerminalLayerSupport performs this cleanup on mount, while the disk
// backup IPC read can still be pending.
React.useEffect(() => { ai.cleanupOrphanedSessions(new Set()); }, [ai.cleanupOrphanedSessions]);
return null;
}
const container = document.createElement('div');
document.body.appendChild(container);
const root = createRoot(container);
try {
await act(async () => { root.render(React.createElement(Harness)); });
assert.equal(reads, 1);
assert.deepEqual(sessions, [{ ...primary, scopeDangling: true }]);
assert.deepEqual(JSON.parse(dom.window.localStorage.getItem(STORAGE_KEY_AI_SESSIONS_DISK_OVERFLOW)!), [diskOnly.id]);
flushBackupMirror();
assert.deepEqual(backups, []);
await act(async () => { resolveRead({ ok: true, json: JSON.stringify([primary, diskOnly]) }); });
const expected = [primary, diskOnly].map(session => ({ ...session, scopeDangling: true }));
assert.deepEqual(sessions, expected);
assert.deepEqual(readSessionsFromStorage(), expected);
assert.equal(dom.window.localStorage.getItem(STORAGE_KEY_AI_SESSIONS_DISK_OVERFLOW), null);
flushBackupMirror();
assert.deepEqual(JSON.parse(backups.at(-1)!), expected);
} finally {
await act(async () => { root.unmount(); });
__resetBackupMirrorForTests();
setLatestAISessionsSnapshot([]);
container.remove();
dom.window.close();
}
});
}
for (const mutation of ['rename', 'create', 'delete', 'delete-pending'] as const) {
test(`live ${mutation} preserves intended history across pending disk recovery`, async () => {
const dom = new JSDOM('<!doctype html><html><body></body></html>', { url: 'http://localhost' });
for (const name of ['window', 'document', 'localStorage', 'CustomEvent', 'Event'] as const) {
Object.defineProperty(globalThis, name, { configurable: true, value: name === 'window' ? dom.window : dom.window[name] });
}
Object.defineProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT', { configurable: true, value: true });
const primary: AISession = {
id: 'primary', title: 'Primary', agentId: 'catty', scope: { type: 'global' },
createdAt: 1, updatedAt: 2, messages: [],
};
const diskOnly = { ...primary, id: 'disk-only', updatedAt: 1 };
dom.window.localStorage.setItem(STORAGE_KEY_AI_SESSIONS, JSON.stringify([primary]));
dom.window.localStorage.setItem(STORAGE_KEY_AI_SESSIONS_DISK_OVERFLOW, JSON.stringify([diskOnly.id]));
dom.window.localStorage.setItem(STORAGE_KEY_AI_ACTIVE_SESSION_MAP, JSON.stringify({ 'global:': diskOnly.id }));
setLatestAIActiveSessionMapSnapshot({ 'global:': diskOnly.id });
setLatestAISessionsSnapshot(null as unknown as AISession[]);
__resetBackupMirrorForTests();
const backups: string[] = [];
let resolveRead!: (backup: { ok: boolean; json: string }) => void;
Object.assign(dom.window, { netcatty: {
aiSessionsBackupWrite: async ({ json }: { json: string }) => { backups.push(json); return { ok: true }; },
aiSessionsBackupReadLatest: () => new Promise(resolve => { resolveRead = resolve; }),
} });
let ai!: ReturnType<typeof useAIState>;
let snapshot!: ReturnType<typeof useAISessionsStore>;
function Harness() {
ai = useAIState();
snapshot = useAISessionsStore();
return null;
}
const container = document.createElement('div');
document.body.appendChild(container);
const root = createRoot(container);
try {
await act(async () => { root.render(React.createElement(Harness)); });
assert.equal(snapshot.activeSessionIdMap['global:'], diskOnly.id);
await act(async () => {
if (mutation === 'rename') ai.updateSessionTitle(primary.id, 'Renamed before recovery');
else if (mutation === 'create') ai.createSession({ type: 'global' }, 'catty');
else ai.deleteSession(mutation === 'delete-pending' ? diskOnly.id : primary.id);
});
const liveBeforeRecovery = [...snapshot.sessions];
const expectedActiveId = mutation === 'create' ? liveBeforeRecovery[0].id
: mutation === 'delete-pending' ? null : diskOnly.id;
assert.equal(snapshot.activeSessionIdMap['global:'], expectedActiveId);
assert.deepEqual(readSessionsFromStorage(), liveBeforeRecovery);
const recoveryMarker = dom.window.localStorage.getItem(STORAGE_KEY_AI_SESSIONS_DISK_OVERFLOW);
assert.deepEqual(recoveryMarker == null ? null : JSON.parse(recoveryMarker), mutation === 'delete-pending' ? null : [diskOnly.id]);
flushBackupMirror();
assert.deepEqual(backups.map(json => JSON.parse(json)), mutation === 'delete-pending' ? [liveBeforeRecovery] : []);
await act(async () => { resolveRead({ ok: true, json: JSON.stringify([primary, diskOnly]) }); });
const expected = mutation === 'delete-pending' ? liveBeforeRecovery : [...liveBeforeRecovery, diskOnly];
assert.deepEqual(snapshot.sessions, expected);
assert.deepEqual(readSessionsFromStorage(), expected);
assert.equal(dom.window.localStorage.getItem(STORAGE_KEY_AI_SESSIONS_DISK_OVERFLOW), null);
flushBackupMirror();
assert.deepEqual(JSON.parse(backups.at(-1)!), expected);
} finally {
await act(async () => { root.unmount(); });
__resetBackupMirrorForTests();
setLatestAISessionsSnapshot([]);
setLatestAIActiveSessionMapSnapshot({});
container.remove();
dom.window.close();
}
});
}
test('count-cap eviction clears a loaded disk-only marker and resumes backup mirroring', () => {
const dom = new JSDOM('<!doctype html><html><body></body></html>', { url: 'http://localhost' });
for (const name of ['window', 'document', 'localStorage', 'CustomEvent', 'Event'] as const) {
Object.defineProperty(globalThis, name, { configurable: true, value: name === 'window' ? dom.window : dom.window[name] });
}
const source: AISession[] = Array.from({ length: 51 }, (_, index) => ({
id: `chat-${index}`, title: `Chat ${index}`, agentId: 'catty', scope: { type: 'global' },
createdAt: 1, updatedAt: 51 - index, messages: [],
}));
dom.window.localStorage.setItem(STORAGE_KEY_AI_SESSIONS_DISK_OVERFLOW, JSON.stringify([source[50].id]));
const backups: string[] = [];
Object.assign(dom.window, { netcatty: { aiSessionsBackupWrite: async ({ json }: { json: string }) => {
backups.push(json);
return { ok: true };
} } });
__resetBackupMirrorForTests();
try {
assert.equal(writeSessionsForStorage(source), true);
assert.deepEqual(readSessionsFromStorage(), source.slice(0, 50));
assert.equal(dom.window.localStorage.getItem(STORAGE_KEY_AI_SESSIONS_DISK_OVERFLOW), null);
flushBackupMirror();
assert.deepEqual(backups.map(json => JSON.parse(json)), [source.slice(0, 50)]);
} finally {
__resetBackupMirrorForTests();
dom.window.close();
}
});
for (const keepPrimary of [true, false]) {
test(`deletion clears disk-only recovery and remains authoritative (keep primary ${keepPrimary})`, async () => {
const dom = new JSDOM('<!doctype html><html><body></body></html>', { url: 'http://localhost' });
for (const name of ['window', 'document', 'localStorage', 'CustomEvent', 'Event'] as const) {
Object.defineProperty(globalThis, name, { configurable: true, value: name === 'window' ? dom.window : dom.window[name] });
}
Object.defineProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT', { configurable: true, value: true });
const primary: AISession = {
id: 'primary', title: 'Primary', agentId: 'catty', scope: { type: 'global' },
createdAt: 1, updatedAt: 2, messages: [],
};
dom.window.localStorage.setItem(STORAGE_KEY_AI_SESSIONS, JSON.stringify([primary]));
dom.window.localStorage.setItem(STORAGE_KEY_AI_SESSIONS_DISK_OVERFLOW, JSON.stringify(['deleted-chat']));
const remaining = keepPrimary ? [primary] : [];
assert.equal(writeSessionsForStorage(remaining, { deletedSessionIds: ['deleted-chat'] }), true);
assert.equal(dom.window.localStorage.getItem(STORAGE_KEY_AI_SESSIONS_DISK_OVERFLOW), null);
__resetBackupMirrorForTests();
setLatestAISessionsSnapshot(null as unknown as AISession[]);
let reads = 0;
Object.assign(dom.window, { netcatty: { aiSessionsBackupReadLatest: async () => {
reads += 1;
return { ok: true, json: JSON.stringify([primary, { ...primary, id: 'deleted-chat' }]) };
} } });
let sessions: readonly AISession[] = [];
function Harness() {
useAIState();
sessions = useAISessionsStore().sessions;
return null;
}
const container = document.createElement('div');
document.body.appendChild(container);
const root = createRoot(container);
try {
await act(async () => { root.render(React.createElement(Harness)); });
assert.equal(reads, 0);
assert.deepEqual(sessions, remaining);
} finally {
await act(async () => { root.unmount(); });
__resetBackupMirrorForTests();
setLatestAISessionsSnapshot([]);
container.remove();
dom.window.close();
}
});
}
+59 -45
View File
@@ -14,6 +14,8 @@ import {
STORAGE_KEY_AI_RESPONSE_IDLE_TIMEOUT,
STORAGE_KEY_AI_MAX_ITERATIONS,
STORAGE_KEY_AI_SESSIONS,
STORAGE_KEY_AI_SESSIONS_OVERFLOW,
STORAGE_KEY_AI_SESSIONS_DISK_OVERFLOW,
STORAGE_KEY_AI_ACTIVE_SESSION_MAP,
STORAGE_KEY_AI_AGENT_MODEL_MAP,
STORAGE_KEY_AI_AGENT_PROVIDER_MAP,
@@ -73,6 +75,7 @@ import {
latestAIPanelViewByScopeSnapshot,
latestAISessionsSnapshot,
prepareRestoredSessions,
readSessionsFromStorage,
scheduleBackupMirror,
rebindActiveSessionMap,
writeSessionsForStorage,
@@ -85,9 +88,9 @@ import {
} from './aiStateSnapshots';
import {
isMissingOrCorruptSessionsStore,
parseSessionsStore,
mergeImportedSessions,
parseBackupSessions,
readLiveSessionsArray,
} from './aiSessionsBackup';
import { AI_STATE_CHANGED_EVENT, emitAIStateChanged } from './aiStateEvents';
import {
@@ -180,49 +183,55 @@ export function useAIState() {
// ── Sessions ──
const [sessions, setSessionsRaw] = useState<AISession[]>(() =>
latestAISessionsSnapshot
?? readLiveSessionsArray(localStorageAdapter.readString(STORAGE_KEY_AI_SESSIONS))
?? readSessionsFromStorage()
);
// An explicit empty array is authoritative, even if the backup is stale.
// Only a missing or corrupt store can be restored from disk.
// Disk-only overflow IDs also permit recovery, without reviving deleted chats.
useEffect(() => {
window.addEventListener('beforeunload', flushBackupMirror);
return () => window.removeEventListener('beforeunload', flushBackupMirror);
}, []);
useEffect(() => {
const liveRaw = localStorageAdapter.readString(STORAGE_KEY_AI_SESSIONS);
if (!isMissingOrCorruptSessionsStore(liveRaw)) {
if (readLiveSessionsArray(liveRaw).length > 0 && liveRaw != null) scheduleBackupMirror(liveRaw);
const liveStore = parseSessionsStore(liveRaw);
const liveSessions = readSessionsFromStorage();
const diskOnlyRecovery = (localStorageAdapter.read<string[]>(STORAGE_KEY_AI_SESSIONS_DISK_OVERFLOW)?.length ?? 0) > 0;
if (!diskOnlyRecovery && (liveStore.valid || liveSessions.length > 0)) {
if (liveSessions.length > 0) scheduleBackupMirror(JSON.stringify(liveSessions));
return;
}
if (latestAISessionsSnapshot && latestAISessionsSnapshot.length > 0) return;
if (!diskOnlyRecovery && latestAISessionsSnapshot && latestAISessionsSnapshot.length > 0) return;
const bridge = getAIBridge();
if (!bridge?.aiSessionsBackupReadLatest) return;
let cancelled = false;
const canRestore = () => diskOnlyRecovery
? (localStorageAdapter.read<string[]>(STORAGE_KEY_AI_SESSIONS_DISK_OVERFLOW)?.length ?? 0) > 0
: isMissingOrCorruptSessionsStore(localStorageAdapter.readString(STORAGE_KEY_AI_SESSIONS))
&& readSessionsFromStorage().length === 0
&& (latestAISessionsSnapshot?.length ?? 0) === 0
&& sessionsRef.current.length === 0;
void (async () => {
try {
const liveRaw = localStorageAdapter.readString(STORAGE_KEY_AI_SESSIONS);
if (!isMissingOrCorruptSessionsStore(liveRaw)) return;
if (!canRestore()) return;
const backup = await bridge.aiSessionsBackupReadLatest?.();
if (cancelled || !backup?.ok) return;
// Re-check after the async gap: a session may have been created
// while the backup was being read; never restore over live state.
// Cover both persisted storage and in-memory snapshot/React state —
// createSession updates the snapshot before the debounced disk write.
if (!isMissingOrCorruptSessionsStore(localStorageAdapter.readString(STORAGE_KEY_AI_SESSIONS))) {
return;
}
if ((latestAISessionsSnapshot?.length ?? 0) > 0) return;
// Re-check after the async gap so live mutations and deletions win.
if (!canRestore()) return;
const backupSessions = parseBackupSessions(backup.json);
if (!backupSessions) return;
const diskOnlyIds = new Set(localStorageAdapter.read<string[]>(STORAGE_KEY_AI_SESSIONS_DISK_OVERFLOW) ?? []);
const recovered = diskOnlyRecovery
? mergeImportedSessions(
backupSessions.filter(session => diskOnlyIds.has(session.id)),
latestAISessionsSnapshot ?? readSessionsFromStorage(),
)
: backupSessions;
const syncEnabled = !!localStorageAdapter.read<{ enabled?: boolean }>(STORAGE_KEY_CLOUD_SYNC_AI_SESSIONS)?.enabled;
const restored = prepareRestoredSessions(parseBackupSessions(backup.json) ?? [], syncEnabled);
const restored = prepareRestoredSessions(recovered, syncEnabled);
if (restored.length === 0) return;
// Apply restore synchronously (not inside a setState updater). A
// deferred updater would leave didRestore false and skip dangling
// marking even when the records later land in React state.
if ((latestAISessionsSnapshot?.length ?? 0) > 0) return;
if (sessionsRef.current.length > 0) return;
if (!isMissingOrCorruptSessionsStore(localStorageAdapter.readString(STORAGE_KEY_AI_SESSIONS))) {
return;
}
setLatestAISessionsSnapshot(restored);
writeSessionsForStorage(restored);
emitAIStateChanged(STORAGE_KEY_AI_SESSIONS);
@@ -324,6 +333,11 @@ export function useAIState() {
useEffect(() => {
const validSessionIds = new Set<string>(sessions.map((session) => session.id));
// Disk-only chats may not have arrived from the startup backup read yet.
// Keep their active pointers and panel views until recovery or deletion.
for (const id of localStorageAdapter.read<string[]>(STORAGE_KEY_AI_SESSIONS_DISK_OVERFLOW) ?? []) {
validSessionIds.add(id);
}
let changed = false;
const nextActiveSessionIdMap: Record<string, string | null> = {};
@@ -543,7 +557,9 @@ export function useAIState() {
useEffect(() => {
const handleStorage = (e: StorageEvent) => {
try {
switch (e.key) {
const key = e.key === STORAGE_KEY_AI_SESSIONS_OVERFLOW || e.key?.startsWith(`${STORAGE_KEY_AI_SESSIONS_OVERFLOW}_`)
? STORAGE_KEY_AI_SESSIONS : e.key;
switch (key) {
case STORAGE_KEY_AI_PROVIDERS: {
const parsed = localStorageAdapter.read<ProviderConfig[]>(STORAGE_KEY_AI_PROVIDERS);
if (parsed != null && !Array.isArray(parsed)) {
@@ -635,7 +651,7 @@ export function useAIState() {
break;
}
case STORAGE_KEY_AI_SESSIONS: {
const nextSessions = localStorageAdapter.read<AISession[]>(STORAGE_KEY_AI_SESSIONS) ?? [];
const nextSessions = readSessionsFromStorage();
setLatestAISessionsSnapshot(nextSessions);
setSessionsRaw(nextSessions);
break;
@@ -677,8 +693,7 @@ export function useAIState() {
case STORAGE_KEY_AI_SESSIONS:
setSessionsRaw(
latestAISessionsSnapshot
?? localStorageAdapter.read<AISession[]>(STORAGE_KEY_AI_SESSIONS)
?? [],
?? readSessionsFromStorage(),
);
return;
case STORAGE_KEY_AI_ACTIVE_SESSION_MAP:
@@ -730,8 +745,8 @@ export function useAIState() {
}, []);
// ── Session CRUD ──
const persistSessions = useCallback((next: AISession[]) => {
writeSessionsForStorage(next);
const persistSessions = useCallback((next: AISession[], deletedSessionIds?: readonly string[]) => {
writeSessionsForStorage(next, { deletedSessionIds });
}, []);
// Debounced version of persistSessions for high-frequency updates (e.g. streaming)
@@ -876,7 +891,7 @@ export function useAIState() {
setSessionsRaw(prev => {
const next = prev.filter(s => s.id !== sessionId);
setLatestAISessionsSnapshot(next);
persistSessions(next);
persistSessions(next, [sessionId]);
return next;
});
if (scopeKey) {
@@ -920,7 +935,7 @@ export function useAIState() {
return !(s.scope.type === scopeType && s.scope.targetId === targetId);
});
setLatestAISessionsSnapshot(next);
persistSessions(next);
persistSessions(next, removedSessionIds);
return next;
});
const scopeKey = `${scopeType}:${targetId}`;
@@ -1016,11 +1031,12 @@ export function useAIState() {
const syncEnabled = !!localStorageAdapter.read<{ enabled?: boolean }>(STORAGE_KEY_CLOUD_SYNC_AI_SESSIONS)?.enabled;
const next = prepareRestoredSessions(mergeImportedSessions(sessionsRef.current, imported), syncEnabled);
if (!writeSessionsForStorage(next)) return 0;
const persisted = readLiveSessionsArray(localStorageAdapter.readString(STORAGE_KEY_AI_SESSIONS));
// Local readback can omit disk-only overflow chats. Keep the complete
// prepared history live so cleanup and later writes retain those chats.
const importedIds = new Set(imported.map(session => session.id));
sessionsRef.current = persisted;
setLatestAISessionsSnapshot(persisted);
setSessionsRaw(persisted);
sessionsRef.current = next;
setLatestAISessionsSnapshot(next);
setSessionsRaw(next);
emitAIStateChanged(STORAGE_KEY_AI_SESSIONS);
// Import does not change live terminals/workspaces, so the orphan-cleanup
// effect will not run. Reuse the last known live target set (empty is
@@ -1029,7 +1045,7 @@ export function useAIState() {
cleanupOrphanedAISessions(latestAIActiveTargetIdsSnapshot);
const nextSessions =
latestAISessionsSnapshot
?? persisted;
?? next;
sessionsRef.current = nextSessions;
setSessionsRaw(nextSessions);
setActiveSessionIdMapRaw(
@@ -1037,7 +1053,7 @@ export function useAIState() {
?? localStorageAdapter.read<Record<string, string | null>>(STORAGE_KEY_AI_ACTIVE_SESSION_MAP)
?? {},
);
return persisted.filter(session => importedIds.has(session.id)).length;
return nextSessions.filter(session => importedIds.has(session.id)).length;
}, []);
const updateSessionExternalSessionId = useCallback((sessionId: string, externalSessionId: string | undefined) => {
@@ -1053,16 +1069,17 @@ export function useAIState() {
});
}, [debouncedPersistSessions]);
// Maximum messages per session to prevent unbounded memory growth
// Local-only retention. Synced history must match the full-history storage policy.
const MAX_MESSAGES_PER_SESSION = 500;
const addMessageToSession = useCallback((sessionId: string, message: ChatMessage) => {
const syncEnabled = localStorageAdapter.read<{ enabled?: boolean }>(STORAGE_KEY_CLOUD_SYNC_AI_SESSIONS)?.enabled === true;
setSessionsRaw(prev => {
const next = prev.map(s => {
if (s.id !== sessionId) return s;
let msgs = [...s.messages, message];
// Trim oldest messages if exceeding limit (keep system messages)
if (msgs.length > MAX_MESSAGES_PER_SESSION) {
// Dropping synced prefixes makes the next cloud merge add them back.
if (!syncEnabled && msgs.length > MAX_MESSAGES_PER_SESSION) {
const systemMsgs = msgs.filter(m => m.role === 'system');
const nonSystemMsgs = msgs.filter(m => m.role !== 'system');
const dropped = nonSystemMsgs.length - (MAX_MESSAGES_PER_SESSION - systemMsgs.length);
@@ -1308,8 +1325,7 @@ export function useAIState() {
const nextSessions =
latestAISessionsSnapshot
?? localStorageAdapter.read<AISession[]>(STORAGE_KEY_AI_SESSIONS)
?? [];
?? readSessionsFromStorage();
sessionsRef.current = nextSessions;
setSessionsRaw(nextSessions);
setActiveSessionIdMapRaw(
@@ -1361,8 +1377,7 @@ export function useAIState() {
?? {};
const currentSessions =
latestAISessionsSnapshot
?? localStorageAdapter.read<AISession[]>(STORAGE_KEY_AI_SESSIONS)
?? [];
?? readSessionsFromStorage();
const currentPanelViewByScope = latestAIPanelViewByScopeSnapshot ?? {};
const result = handoffDissolvedWorkspaceAIScope({
activeSessionIdMap: currentMap,
@@ -1407,8 +1422,7 @@ export function useAIState() {
?? {};
const currentSessions =
latestAISessionsSnapshot
?? localStorageAdapter.read<AISession[]>(STORAGE_KEY_AI_SESSIONS)
?? [];
?? readSessionsFromStorage();
const result = retargetWorkspaceActiveChatAfterMemberLoss({
activeSessionIdMap: currentMap,
sessions: currentSessions,
@@ -11,6 +11,10 @@ import {
} from "./useActiveChromeTheme.ts";
import { TERMINAL_THEMES } from "../../infrastructure/config/terminalThemes.ts";
import { readFileSync } from "node:fs";
import {
getSettingsChromeSnapshot,
publishSettingsChromeSnapshot,
} from "./settingsChromeStore.ts";
function createInlineStyle() {
const values = new Map<string, string>();
@@ -98,6 +102,60 @@ test("syncActiveChromeTheme skips surface refresh when the active theme fingerpr
}
});
for (const preference of ["system", "light", "dark"] as const) {
for (const chromeType of ["light", "dark"] as const) {
test(`syncActiveChromeTheme uses ${preference === "system" ? "system" : chromeType} native theme for ${preference} preference and ${chromeType} chrome`, () => {
const globals = globalThis as typeof globalThis & { document?: Document; window?: Window };
const originalDocument = globals.document;
const originalWindow = globals.window;
const originalSnapshot = getSettingsChromeSnapshot();
const theme = TERMINAL_THEMES.find((candidate) => candidate.type === chromeType);
assert.ok(theme);
const themeCalls: string[] = [];
const backgroundCalls: string[] = [];
globals.document = {
documentElement: {
dataset: {},
classList: { remove: () => {}, add: () => {} },
removeAttribute: () => {},
},
getElementById: () => null,
createElement: () => ({}),
head: { appendChild: () => {} },
querySelector: () => null,
} as unknown as Document;
globals.window = {
netcatty: {
setTheme: (value: string) => themeCalls.push(value),
setBackgroundColor: (value: string) => backgroundCalls.push(value),
},
} as unknown as Window & typeof globalThis;
try {
publishSettingsChromeSnapshot({ ...originalSnapshot, theme: preference });
syncActiveChromeTheme(theme, () => {
throw new Error("app theme should not be restored for an active chrome theme");
});
assert.deepEqual(themeCalls, [preference === "system" ? "system" : chromeType]);
assert.deepEqual(backgroundCalls, [theme.colors.background]);
} finally {
publishSettingsChromeSnapshot(originalSnapshot);
if (originalDocument) {
globals.document = originalDocument;
} else {
delete globals.document;
}
if (originalWindow) {
globals.window = originalWindow;
} else {
delete globals.window;
}
}
});
}
}
test("active chrome theme foreground uses contrast calculation", () => {
const source = readFileSync(new URL("./useActiveChromeTheme.ts", import.meta.url), "utf8");
+4 -2
View File
@@ -1,6 +1,6 @@
import { pluginThemeRegistry } from '../../infrastructure/config/pluginThemeRegistry';
import { usePluginThemeRevision } from './usePluginThemeRuntime';
import { useSettingsChromeStore } from './settingsChromeStore';
import { getSettingsChromeSnapshot, useSettingsChromeStore } from './settingsChromeStore';
import { useLayoutEffect, useRef } from "react";
import type { TerminalTheme } from "../../domain/models";
import {
@@ -239,7 +239,9 @@ function applyActiveChromeTheme(theme: TerminalTheme) {
function refreshActiveChromeThemeSurfaces(theme: TerminalTheme) {
const targetClass = theme.type === "dark" ? "dark" : "light";
if (typeof window !== "undefined") {
netcattyBridge.get()?.setTheme?.(targetClass);
// Preserve OS preference events instead of locking nativeTheme to the chrome type.
const themeSource = getSettingsChromeSnapshot().theme === "system" ? "system" : targetClass;
netcattyBridge.get()?.setTheme?.(themeSource);
netcattyBridge.get()?.setBackgroundColor?.(theme.colors.background);
}
applyTopTabsChromeThemeVars(theme);
@@ -92,3 +92,21 @@ test("selectPreferredRuntimeAppLockState prefers initialized state when versions
current,
);
});
test("selectPreferredRuntimeAppLockState keeps the current object when a resync changes nothing", () => {
const current = normalizeRuntimeAppLockState({
initialized: true,
locked: false,
reason: null,
version: 4,
lastUnlockedAt: 1000,
lastActivityAt: 2000,
});
const resynced = normalizeRuntimeAppLockState({ ...current });
assert.notEqual(resynced, current);
// Window focus resyncs must not hand React a new state object.
assert.equal(selectPreferredRuntimeAppLockState(current, resynced), current);
const changed = normalizeRuntimeAppLockState({ ...current, locked: true, reason: "idle" });
assert.equal(selectPreferredRuntimeAppLockState(current, changed), changed);
});
+14 -1
View File
@@ -43,7 +43,20 @@ export function selectPreferredRuntimeAppLockState(
if (incoming.version < current.version) return current;
if (incoming.initialized && !current.initialized) return incoming;
if (!incoming.initialized && current.initialized) return current;
return incoming;
// Every window focus/visibility change resyncs; keep the current object when
// nothing changed so the gate (and the provider tree under it) does not
// re-render.
return isSameRuntimeAppLockState(current, incoming) ? current : incoming;
}
function isSameRuntimeAppLockState(left: RuntimeAppLockState, right: RuntimeAppLockState): boolean {
return left.initialized === right.initialized
&& left.locked === right.locked
&& left.reason === right.reason
&& left.version === right.version
&& left.lastLockedAt === right.lastLockedAt
&& left.lastUnlockedAt === right.lastUnlockedAt
&& left.lastActivityAt === right.lastActivityAt;
}
export function useAppLockRuntime(
+25 -3
View File
@@ -1,4 +1,4 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import {
normalizeAppLockSettings,
@@ -131,6 +131,8 @@ export function createOptimisticUnlockedRuntimeState(
};
}
export const APP_LOCK_ACTIVITY_REPORT_INTERVAL_MS = 1000;
export function useAppLockState(settings: AppLockSettings) {
const normalizedSettings = useMemo(() => normalizeAppLockSettings(settings), [settings]);
const systemUnlockRefreshKey = `${normalizedSettings.enabled}:${normalizedSettings.systemUnlockEnabled}:${Boolean(normalizedSettings.passwordVerifier)}`;
@@ -160,8 +162,14 @@ export function useAppLockState(settings: AppLockSettings) {
void bridge?.setAppLockRuntimeLocked?.(reason);
}, [bridge, normalizedSettings]);
// Activity only resets a minutes-long idle timer; one report per second is
// enough instead of an IPC per keystroke, wheel tick and pointer event.
const lastActivityReportAtRef = useRef(0);
const recordActivity = useCallback(() => {
if (effectiveRuntimeState.locked) return;
const now = Date.now();
if (now - lastActivityReportAtRef.current < APP_LOCK_ACTIVITY_REPORT_INTERVAL_MS) return;
lastActivityReportAtRef.current = now;
void bridge?.reportAppLockActivity?.();
}, [bridge, effectiveRuntimeState.locked]);
@@ -249,7 +257,9 @@ export function useAppLockState(settings: AppLockSettings) {
void refreshSystemUnlockStatus();
}, [refreshSystemUnlockStatus, systemUnlockRefreshKey]);
return {
// Stable identity: AppLockGate passes this to <App>, whose provider tree
// re-renders whenever it changes.
return useMemo(() => ({
initialized: effectiveRuntimeState.initialized,
locked: effectiveRuntimeState.locked,
lockReason: effectiveRuntimeState.reason,
@@ -261,5 +271,17 @@ export function useAppLockState(settings: AppLockSettings) {
reset,
recordActivity,
resync: refreshRuntimeState,
};
}), [
effectiveRuntimeState.initialized,
effectiveRuntimeState.locked,
effectiveRuntimeState.reason,
lockNow,
unlock,
unlockWithSystemAuth,
systemUnlockStatus,
refreshSystemUnlockStatus,
reset,
recordActivity,
refreshRuntimeState,
]);
}
+18 -6
View File
@@ -13,6 +13,7 @@ import { useMonitorPanels } from "./monitorPanelsStorage";
import {
monitorActions,
getMonitorHistoryCursor,
isIdleMonitorSnapshot,
publishMonitorSnapshot,
publishMonitorUnavailable,
} from "./monitorRuntimeStore";
@@ -157,16 +158,24 @@ export function useMonitorRuntime() {
isInitialized,
retryVersion,
]);
// Poll every second while anything is monitored (here or in another window);
// otherwise only every 10 s to notice monitors started elsewhere. Starting a
// panel here publishes demand and reads a snapshot immediately, and flipping
// `hasRunningPanels` restarts this loop at the fast cadence.
const hasRunningPanels = running.size > 0;
useEffect(() => {
const bridge = netcattyBridge.get();
let disposed = false;
let pending = false;
let remoteRunning = false;
let timer: ReturnType<typeof setTimeout> | null = null;
const poll = async () => {
if (pending || !bridge?.monitorSnapshot) return;
pending = true;
try {
const snapshot = await bridge.monitorSnapshot(getMonitorHistoryCursor());
if (disposed) return;
remoteRunning = !isIdleMonitorSnapshot(snapshot);
if (
(epoch.current !== null && epoch.current !== snapshot.epoch) ||
(runtimeId.current !== null &&
@@ -186,13 +195,16 @@ export function useMonitorRuntime() {
pending = false;
}
};
void poll();
const timer = setInterval(() => {
void poll();
}, 1000);
const schedule = () => {
if (disposed) return;
timer = setTimeout(() => {
void poll().finally(schedule);
}, hasRunningPanels || remoteRunning ? 1000 : 10_000);
};
void poll().finally(schedule);
return () => {
disposed = true;
clearInterval(timer);
if (timer) clearTimeout(timer);
};
}, []);
}, [hasRunningPanels]);
}
+50 -30
View File
@@ -3,8 +3,7 @@ import type { TerminalSession, Workspace } from '../../domain/models';
import { getNotesSnapshot } from './notesStore';
import { useActiveTabId } from './activeTabStore';
import { getAppAppLockRuntime } from './appRuntimeBridge';
import { createTrustedPluginAIHostRuntime } from '../../infrastructure/ai/plugin/trustedHostRuntime';
import { getConfiguredPluginModel, prepareConfiguredPluginModel } from '../../infrastructure/ai/plugin/configuredModel';
import type { createTrustedPluginAIHostRuntime } from '../../infrastructure/ai/plugin/trustedHostRuntime';
import { buildSelectedPluginSources } from '../../infrastructure/ai/plugin/selectedSources';
import { netcattyBridge } from '../../infrastructure/services/netcattyBridge';
@@ -27,30 +26,47 @@ export function usePluginAIContextHost({ sessions, workspaces }: { sessions: Ter
let mounted = true;
let chooser: { runtimeId: string; cancel: () => void } | null = null;
const requests = new Map<string, { runtimeId: string; controller: AbortController }>();
const runtime = createTrustedPluginAIHostRuntime({
getModel: getConfiguredPluginModel,
prepareModel: prepareConfiguredPluginModel,
isActive: () => mounted && document.visibilityState === 'visible' && getAppAppLockRuntime()?.locked === false,
chooseSelected: (signal, runtimeId) => new Promise((resolve, reject) => {
if (chooser) { reject(Error('A host selection is already pending')); return; }
const finish = (id: string | null) => {
if (chooser?.cancel !== cancel) return;
chooser = null; signal.removeEventListener('abort', cancel); setChoice(null);
if (id === null || signal.aborted) { reject(Error('Selection cancelled')); return; }
selectedNoteId = id || null;
resolve(buildSelectedPluginSources(() => ({ ...latest.current,
note: getNotesSnapshot().notes.find((note) => note.id === selectedNoteId) ?? null })));
};
const cancel = () => finish(null);
chooser = { runtimeId, cancel };
signal.addEventListener('abort', cancel, { once: true });
setNoteId('');
// This inventory stays inside trusted host UI. The plugin receives
// summaries only for the Note chosen here and current selected scopes.
setNoteChoices(getNotesSnapshot().notes.map((note) => ({ id: note.id, title: note.title })));
setChoice({ confirm: (id) => finish(id), cancel });
if (signal.aborted) cancel();
}),
// The runtime pulls in the AI SDK providers (~1 MB with the Catty
// harness). This host is mounted for every main window, so create it on
// the first plugin AI request instead of at startup.
type HostRuntime = ReturnType<typeof createTrustedPluginAIHostRuntime>;
let runtime: HostRuntime | null = null;
let runtimeLoading: Promise<HostRuntime> | null = null;
const withLoadedRuntime = (apply: (loaded: HostRuntime) => void) => {
if (runtime) apply(runtime);
else void runtimeLoading?.then(apply, () => {});
};
const ensureRuntime = () => runtimeLoading ??= Promise.all([
import('../../infrastructure/ai/plugin/trustedHostRuntime'),
import('../../infrastructure/ai/plugin/configuredModel'),
]).then(([{ createTrustedPluginAIHostRuntime: createRuntime }, { getConfiguredPluginModel, prepareConfiguredPluginModel }]) => {
runtime = createRuntime({
getModel: getConfiguredPluginModel,
prepareModel: prepareConfiguredPluginModel,
isActive: () => mounted && document.visibilityState === 'visible' && getAppAppLockRuntime()?.locked === false,
chooseSelected: (signal, runtimeId) => new Promise((resolve, reject) => {
if (chooser) { reject(Error('A host selection is already pending')); return; }
const finish = (id: string | null) => {
if (chooser?.cancel !== cancel) return;
chooser = null; signal.removeEventListener('abort', cancel); setChoice(null);
if (id === null || signal.aborted) { reject(Error('Selection cancelled')); return; }
selectedNoteId = id || null;
resolve(buildSelectedPluginSources(() => ({ ...latest.current,
note: getNotesSnapshot().notes.find((note) => note.id === selectedNoteId) ?? null })));
};
const cancel = () => finish(null);
chooser = { runtimeId, cancel };
signal.addEventListener('abort', cancel, { once: true });
setNoteId('');
// This inventory stays inside trusted host UI. The plugin receives
// summaries only for the Note chosen here and current selected scopes.
setNoteChoices(getNotesSnapshot().notes.map((note) => ({ id: note.id, title: note.title })));
setChoice({ confirm: (id) => finish(id), cancel });
if (signal.aborted) cancel();
}),
});
if (!mounted) runtime.shutdown();
return runtime;
});
const unsubscribe = bridge.onPluginAIHostRequest((request) => {
if (request.kind === 'request.cancel') {
@@ -60,24 +76,28 @@ export function usePluginAIContextHost({ sessions, workspaces }: { sessions: Ter
if (request.kind === 'runtime.release') {
for (const pending of requests.values()) if (pending.runtimeId === request.runtimeId) pending.controller.abort();
if (chooser?.runtimeId === request.runtimeId) chooser.cancel();
runtime.releaseRuntime(request.runtimeId);
withLoadedRuntime((loaded) => loaded.releaseRuntime(request.runtimeId));
return;
}
if (requests.has(request.requestId) || requests.size >= 64) return;
const controller = new AbortController();
requests.set(request.requestId, { runtimeId: request.runtimeId, controller });
void runtime.handle(request.kind, request.payload, request.runtimeId, controller.signal).then(
void ensureRuntime().then((loaded) => {
// Loading is async: re-check that the host and request are still live.
if (!mounted || controller.signal.aborted) throw new Error('Plugin AI request is no longer active');
return loaded.handle(request.kind, request.payload, request.runtimeId, controller.signal);
}).then(
(result) => bridge.respondPluginAIHost({ requestId: request.requestId, actionToken: request.actionToken, result }),
() => bridge.respondPluginAIHost({ requestId: request.requestId, actionToken: request.actionToken, error: true }),
).catch(() => {}).finally(() => requests.delete(request.requestId));
});
const pageHide = () => { runtime.shutdown(); for (const pending of requests.values()) pending.controller.abort(); };
const pageHide = () => { withLoadedRuntime((loaded) => loaded.shutdown()); for (const pending of requests.values()) pending.controller.abort(); };
window.addEventListener('pagehide', pageHide);
void bridge.setPluginAIHostReady(true).catch(() => {});
return () => {
mounted = false; chooser?.cancel(); unsubscribe(); window.removeEventListener('pagehide', pageHide);
for (const pending of requests.values()) pending.controller.abort();
requests.clear(); runtime.shutdown();
requests.clear(); withLoadedRuntime((loaded) => loaded.shutdown());
void bridge.setPluginAIHostReady(false).catch(() => {});
};
}, []);
+53 -33
View File
@@ -1,7 +1,7 @@
import { useEffect, useRef, useState } from 'react';
import type { TerminalSession } from '../../domain/models';
import type { PluginAgentHostTerminal } from '@netcatty/plugin-contract';
import { createTrustedPluginAgentRuntime } from '../../infrastructure/ai/pluginAgent/trustedAgentRuntime';
import type { createTrustedPluginAgentRuntime } from '../../infrastructure/ai/pluginAgent/trustedAgentRuntime';
import { netcattyBridge } from '../../infrastructure/services/netcattyBridge';
import { getAppAppLockRuntime } from './appRuntimeBridge';
import { LOCAL_STORAGE_ADAPTER_CHANGED_EVENT } from '../../infrastructure/persistence/localStorageAdapter';
@@ -22,58 +22,78 @@ export function usePluginAgentHost(sessions: TerminalSession[]) {
let mounted = true;
let chooser: { runtimeId: string; cancel: () => void } | null = null;
const requests = new Map<string, { runtimeId: string; controller: AbortController }>();
const runtime = createTrustedPluginAgentRuntime({
isActive: () => mounted && document.visibilityState === 'visible' && getAppAppLockRuntime()?.locked === false,
chooseTargets: (signal, runtimeId) => new Promise((resolve, reject) => {
if (chooser) { reject(Error('A target selection is already pending')); return; }
const items = latest.current.filter((item) => item.status === 'connected').map((item) => ({
sessionId: item.id, hostId: item.hostId, hostname: (item.hostname || '').slice(0, 253),
label: (item.customName || item.hostLabel || item.id).slice(0, 160),
...(item.protocol ? { protocol: item.protocol } : {}), ...(item.username ? { username: item.username.slice(0, 160) } : {}), connected: true,
}));
const finish = (ids: string[] | null) => {
if (chooser?.cancel !== cancel) return;
chooser = null; signal.removeEventListener('abort', cancel); setChoice(null);
if (ids === null || signal.aborted || ids.length > 32 || new Set(ids).size !== ids.length) { reject(Error('Target selection cancelled')); return; }
const selected = ids.map((id) => items.find((item) => item.sessionId === id));
if (selected.some((item) => !item || !latest.current.some((session) => session.id === item.sessionId && session.status === 'connected'))) {
reject(Error('Selected terminal is no longer connected')); return;
}
resolve(selected as PluginAgentHostTerminal[]);
};
const cancel = () => finish(null);
chooser = { runtimeId, cancel }; signal.addEventListener('abort', cancel, { once: true });
setChoice({ items, confirm: finish, cancel });
if (signal.aborted) cancel();
}),
});
// The agent runtime pulls in the whole Catty harness and AI SDK. This host
// is mounted for every main window, so create it on the first plugin agent
// request instead of at startup; until then there is nothing to cancel,
// release or invalidate.
type AgentRuntime = ReturnType<typeof createTrustedPluginAgentRuntime>;
let runtime: AgentRuntime | null = null;
let runtimeLoading: Promise<AgentRuntime> | null = null;
const withLoadedRuntime = (apply: (loaded: AgentRuntime) => void) => {
if (runtime) apply(runtime);
else void runtimeLoading?.then(apply, () => {});
};
const ensureRuntime = () => runtimeLoading ??= import('../../infrastructure/ai/pluginAgent/trustedAgentRuntime')
.then(({ createTrustedPluginAgentRuntime: createRuntime }) => {
runtime = createRuntime({
isActive: () => mounted && document.visibilityState === 'visible' && getAppAppLockRuntime()?.locked === false,
chooseTargets: (signal, runtimeId) => new Promise((resolve, reject) => {
if (chooser) { reject(Error('A target selection is already pending')); return; }
const items = latest.current.filter((item) => item.status === 'connected').map((item) => ({
sessionId: item.id, hostId: item.hostId, hostname: (item.hostname || '').slice(0, 253),
label: (item.customName || item.hostLabel || item.id).slice(0, 160),
...(item.protocol ? { protocol: item.protocol } : {}), ...(item.username ? { username: item.username.slice(0, 160) } : {}), connected: true,
}));
const finish = (ids: string[] | null) => {
if (chooser?.cancel !== cancel) return;
chooser = null; signal.removeEventListener('abort', cancel); setChoice(null);
if (ids === null || signal.aborted || ids.length > 32 || new Set(ids).size !== ids.length) { reject(Error('Target selection cancelled')); return; }
const selected = ids.map((id) => items.find((item) => item.sessionId === id));
if (selected.some((item) => !item || !latest.current.some((session) => session.id === item.sessionId && session.status === 'connected'))) {
reject(Error('Selected terminal is no longer connected')); return;
}
resolve(selected as PluginAgentHostTerminal[]);
};
const cancel = () => finish(null);
chooser = { runtimeId, cancel }; signal.addEventListener('abort', cancel, { once: true });
setChoice({ items, confirm: finish, cancel });
if (signal.aborted) cancel();
}),
});
if (!mounted) runtime.shutdown();
return runtime;
});
const unsubscribe = bridge.subscribe((request) => {
if (request.kind === 'request.cancel') {
const pending = requests.get(request.payload.requestId);
if (pending?.runtimeId === request.runtimeId) pending.controller.abort();
runtime.cancelRequest(request.payload.requestId, request.runtimeId); return;
withLoadedRuntime((loaded) => loaded.cancelRequest(request.payload.requestId, request.runtimeId)); return;
}
if (request.kind === 'runtime.release') {
for (const item of requests.values()) if (item.runtimeId === request.runtimeId) item.controller.abort();
if (chooser?.runtimeId === request.runtimeId) chooser.cancel();
runtime.releaseRuntime(request.runtimeId); return;
withLoadedRuntime((loaded) => loaded.releaseRuntime(request.runtimeId)); return;
}
if (request.kind === 'agent.close') { runtime.releaseChat(request.payload.chatSessionId, request.runtimeId); return; }
if (request.kind === 'agent.close') { withLoadedRuntime((loaded) => loaded.releaseChat(request.payload.chatSessionId, request.runtimeId)); return; }
if (requests.has(request.requestId) || requests.size >= 64) return;
const controller = new AbortController(); requests.set(request.requestId, { runtimeId: request.runtimeId, controller });
void runtime.handle(request, controller.signal).then(
void ensureRuntime().then((loaded) => {
// Loading is async: re-check that the host and request are still live.
if (!mounted || controller.signal.aborted) throw new Error('Plugin agent request is no longer active');
return loaded.handle(request, controller.signal);
}).then(
(result) => bridge.respond({ requestId: request.requestId, actionToken: request.actionToken, result }).catch(() =>
bridge.respond({ requestId: request.requestId, actionToken: request.actionToken, error: true })),
() => bridge.respond({ requestId: request.requestId, actionToken: request.actionToken, error: true }),
).catch(() => {}).finally(() => requests.delete(request.requestId));
});
const pageHide = () => { runtime.shutdown(); for (const item of requests.values()) item.controller.abort(); };
const pageHide = () => { withLoadedRuntime((loaded) => loaded.shutdown()); for (const item of requests.values()) item.controller.abort(); };
const configurationKeys = new Set([STORAGE_KEY_AI_PROVIDERS, STORAGE_KEY_AI_ACTIVE_PROVIDER, STORAGE_KEY_AI_ACTIVE_MODEL,
STORAGE_KEY_AI_AGENT_MODEL_MAP, STORAGE_KEY_AI_AGENT_PROVIDER_MAP, STORAGE_KEY_AI_EXTERNAL_AGENTS]);
const configurationChanged = (event: Event) => {
const key = event.type === 'storage' ? (event as StorageEvent).key : (event as CustomEvent<{ key: string }>).detail?.key;
if (key === null || (key && configurationKeys.has(key))) {
runtime.invalidateConfiguration();
withLoadedRuntime((loaded) => loaded.invalidateConfiguration());
// Invalidate main-owned backend leases too, even with no event read in
// flight. Re-advertising availability does not renew existing handles.
void bridge.ready(false).then(() => { if (mounted) return bridge.ready(true); }).catch(() => {});
@@ -85,7 +105,7 @@ export function usePluginAgentHost(sessions: TerminalSession[]) {
return () => {
mounted = false; chooser?.cancel(); unsubscribe(); window.removeEventListener('pagehide', pageHide);
window.removeEventListener('storage', configurationChanged); window.removeEventListener(LOCAL_STORAGE_ADAPTER_CHANGED_EVENT, configurationChanged);
for (const item of requests.values()) item.controller.abort(); requests.clear(); runtime.shutdown();
for (const item of requests.values()) item.controller.abort(); requests.clear(); withLoadedRuntime((loaded) => loaded.shutdown());
void bridge.ready(false).catch(() => {});
};
}, []);
+33 -23
View File
@@ -21,6 +21,7 @@ getNextFocusSessionId,
insertPaneIntoWorkspace,
pruneWorkspaceNode,
reorderWorkspaceFocusSessionOrder,
setWorkspaceFocusedSessionId,
SplitDirection,
SplitHint,
updateWorkspaceSplitSizes,
@@ -249,6 +250,8 @@ export const useSessionState = ({
const [globalComposeSendToAll, setGlobalComposeSendToAll] = useState<boolean>(false);
// Log views: stores open log replay tabs
const [logViews, setLogViews] = useState<LogView[]>([]);
const logViewsRef = useRef(logViews);
logViewsRef.current = logViews;
const [restorePreviousSessionRevision, setRestorePreviousSessionRevision] = useState(0);
const sessionsRef = useRef(sessions);
const tabOrderRef = useRef(tabOrder);
@@ -1097,10 +1100,10 @@ export const useSessionState = ({
// Set the focused session in a workspace (for focus mode)
const setWorkspaceFocusedSession = useCallback((workspaceId: string, sessionId: string) => {
setWorkspaces(prev => prev.map(ws => {
if (ws.id !== workspaceId) return ws;
return { ...ws, focusedSessionId: sessionId };
}));
// Pane click and focus both land here; an unchanged focus must keep the
// same array or every click re-renders the whole shell (and re-saves the
// session restore snapshot).
setWorkspaces(prev => setWorkspaceFocusedSessionId(prev, workspaceId, sessionId));
}, []);
const reorderWorkspaceSessions = useCallback((
@@ -1126,7 +1129,7 @@ export const useSessionState = ({
// Move focus between panes in a workspace
const moveFocusInWorkspace = useCallback((workspaceId: string, direction: FocusDirection): boolean => {
const workspace = workspaces.find(w => w.id === workspaceId);
const workspace = workspacesRef.current.find(w => w.id === workspaceId);
if (!workspace) {
return false;
}
@@ -1147,13 +1150,10 @@ export const useSessionState = ({
}
// Update focused session
setWorkspaces(prev => prev.map(ws => {
if (ws.id !== workspaceId) return ws;
return { ...ws, focusedSessionId: nextSessionId };
}));
setWorkspaces(prev => setWorkspaceFocusedSessionId(prev, workspaceId, nextSessionId));
return true;
}, [workspaces]);
}, []);
// Run a snippet on multiple target hosts - creates a focus mode workspace
const runSnippet = useCallback((snippet: Snippet, targetHosts: Host[], commandOverride?: string) => {
@@ -1196,6 +1196,10 @@ export const useSessionState = ({
}, [setActiveTabId]);
const orphanSessions = useMemo(() => sessions.filter(s => !s.workspaceId && !s.hiddenFromTabs), [sessions]);
// Read by action callbacks at call time so they keep a stable identity:
// orphanSessions changes with every session status update.
const orphanSessionsRef = useRef(orphanSessions);
orphanSessionsRef.current = orphanSessions;
const canUseGlobalBroadcast = orphanSessions.length >= 2;
// Losing the group must disarm broadcast before another tab can join it.
@@ -1230,6 +1234,13 @@ export const useSessionState = ({
}) => {
// Pre-allocate the new id outside the updater so StrictMode's
// double-invocation of the functional updater doesn't mint two ids.
// Same snapshot the old closure captured (values at click time), read from
// refs so this action keeps a stable identity across session updates.
const tabIdsAtCall = [
...orphanSessionsRef.current.map(s => s.id),
...workspacesRef.current.map(w => w.id),
...logViewsRef.current.map(lv => lv.id),
];
const newSessionId = crypto.randomUUID();
setSessions(prevSessions => {
@@ -1250,17 +1261,13 @@ export const useSessionState = ({
// StrictMode's double-invocation is harmless.
setActiveTabId(newSessionId);
setTabOrder(prevTabOrder => {
const allTabIds = [
...orphanSessions.map(s => s.id),
...workspaces.map(w => w.id),
...logViews.map(lv => lv.id),
];
const allTabIds = tabIdsAtCall;
return insertCopiedTabOrderIdOnce(prevTabOrder, sessionId, newSessionId, allTabIds);
});
return [...prevSessions, newSession];
});
}, [orphanSessions, workspaces, logViews, setActiveTabId]);
}, [setActiveTabId]);
// Copy a whole workspace (split/focus tab): clone every pane's session and
// reproduce the layout tree in a new tab.
@@ -1268,7 +1275,14 @@ export const useSessionState = ({
localShellType?: TerminalSession['shellType'];
perPaneCwd?: Record<string, string | undefined>;
}) => {
const sourceWorkspace = workspaces.find(w => w.id === workspaceId);
// Same snapshot the old closure captured (values at click time), read from
// refs so this action keeps a stable identity across session updates.
const tabIdsAtCall = [
...orphanSessionsRef.current.map(s => s.id),
...workspacesRef.current.map(w => w.id),
...logViewsRef.current.map(lv => lv.id),
];
const sourceWorkspace = workspacesRef.current.find(w => w.id === workspaceId);
if (!sourceWorkspace) return;
// Pre-mint the new workspace id and the old->new session-id map OUTSIDE the
@@ -1306,17 +1320,13 @@ export const useSessionState = ({
setWorkspaces(prev => addWorkspaceIfMissing(prev, built.newWorkspace));
setActiveTabId(newWorkspaceId);
setTabOrder(prevTabOrder => {
const allTabIds = [
...orphanSessions.map(s => s.id),
...workspaces.map(w => w.id),
...logViews.map(lv => lv.id),
];
const allTabIds = tabIdsAtCall;
return insertCopiedTabOrderIdOnce(prevTabOrder, workspaceId, newWorkspaceId, allTabIds);
});
return [...prevSessions, ...built.newSessions];
});
}, [workspaces, orphanSessions, logViews, setActiveTabId]);
}, [setActiveTabId]);
const createSessionFromCloneSource = useCallback((sourceSession: TerminalSession, options?: {
localShellType?: TerminalSession['shellType'];
@@ -95,7 +95,6 @@ test("merging a workspace tab with a standalone tab reuses the workspace id (iss
// standalone on the opposite side of the drop hint (hint describes where
// the dragged member should appear relative to the standalone).
let orientationStandaloneId = "";
let orientationMemberId = "";
await act(async () => {
orientationStandaloneId = current().createLocalTerminal({ shellName: "orient-standalone" });
});
+37 -9
View File
@@ -1,4 +1,4 @@
import { useEffect, useMemo, useState } from 'react';
import { useEffect, useMemo, useSyncExternalStore } from 'react';
import { parseKeyCombo, type HotkeyScheme } from '../../domain/models/keyBindings';
@@ -70,26 +70,55 @@ export function shouldReleaseShortcutModifier(
return !isShortcutModifierHeld(event, requirements);
}
export function useShortcutModifierHeld(
// The held flag lives in a tiny store so only the tab icon slots re-render when
// it flips. It used to be TopTabs state, which re-rendered the whole tab bar
// twice for every Ctrl/⌘ shortcut typed in a terminal (Ctrl+C, ⌘V, ...).
let modifierHeld = false;
const modifierHeldListeners = new Set<() => void>();
function publishModifierHeld(next: boolean): void {
if (modifierHeld === next) return;
modifierHeld = next;
for (const listener of modifierHeldListeners) listener();
}
function subscribeModifierHeld(listener: () => void): () => void {
modifierHeldListeners.add(listener);
return () => {
modifierHeldListeners.delete(listener);
};
}
const getModifierHeld = () => modifierHeld;
/** Whether the tab-switch modifier combination is currently held. */
export function useShortcutModifierHeldValue(): boolean {
return useSyncExternalStore(subscribeModifierHeld, getModifierHeld, getModifierHeld);
}
/**
* Track the modifiers of the effective [1...9] tab-switch binding and publish
* whether they are held. Pass a null binding to stop tracking.
*/
export function useTrackShortcutModifierHeld(
keyBinding: string | null,
scheme: HotkeyScheme,
): boolean {
const [isHeld, setIsHeld] = useState(false);
): void {
const requirements = useMemo(
() => getShortcutModifierRequirements(keyBinding, scheme),
[keyBinding, scheme],
);
useEffect(() => {
setIsHeld(false);
publishModifierHeld(false);
if (!requirements) return;
// Sync on every modifier transition so extras clear the preview and
// releasing an extra modifier can re-arm when the exact combo remains.
const syncHeldState = (event: KeyboardEvent) => {
setIsHeld(isShortcutModifierHeld(event, requirements));
publishModifierHeld(isShortcutModifierHeld(event, requirements));
};
const clearHeldState = () => setIsHeld(false);
const clearHeldState = () => publishModifierHeld(false);
const handleVisibilityChange = () => {
if (document.visibilityState === 'hidden') {
clearHeldState();
@@ -106,8 +135,7 @@ export function useShortcutModifierHeld(
window.removeEventListener('keyup', syncHeldState, true);
window.removeEventListener('blur', clearHeldState);
document.removeEventListener('visibilitychange', handleVisibilityChange);
publishModifierHeld(false);
};
}, [requirements]);
return isHeld;
}
+4
View File
@@ -55,6 +55,8 @@ export interface UseVaultAgentBridgeInput {
openHost?: VaultAgentApiDeps['openHost'];
findExistingSessionForHost?: VaultAgentApiDeps['findExistingSessionForHost'];
closeSession?: VaultAgentApiDeps['closeSession'];
reconnectSession?: VaultAgentApiDeps['reconnectSession'];
inspectSessionRecovery?: VaultAgentApiDeps['inspectSessionRecovery'];
getScriptSessionMeta?: (sessionId: string) => Pick<TerminalSession, 'status' | 'customName' | 'hostLabel' | 'hostname' | 'username'> | undefined;
}
@@ -273,6 +275,8 @@ export function useVaultAgentBridge(input: UseVaultAgentBridgeInput): void {
? (host, isExternalMcpCall, options) => current.openHost!(host, isExternalMcpCall, options)
: undefined,
findExistingSessionForHost: current.findExistingSessionForHost,
reconnectSession: current.reconnectSession,
inspectSessionRecovery: current.inspectSessionRecovery,
closeSession: current.closeSession
? (sessionId) => current.closeSession!(sessionId)
: undefined,
@@ -1,7 +1,7 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import React from 'react';
import { act, create, type ReactTestRenderer } from 'react-test-renderer';
import { act, create } from 'react-test-renderer';
import {
collectSidePanelPanes,
@@ -79,6 +79,17 @@ test('sync persistence survives restart beyond the local-only history limits', (
assert.equal(localOnly[0].messages.length, 200);
});
test('sync collection after restart includes overflow before clearing it', () => {
const primary = session();
const overflow = { ...session(), id: 'overflow-chat', updatedAt: 1 };
save([primary], true);
storage.set(keys.STORAGE_KEY_AI_SESSIONS_OVERFLOW, JSON.stringify([overflow]));
snapshots.setLatestAISessionsSnapshot(null as unknown as AISession[]);
assert.deepEqual(collectAiSessionsForSync()?.map(value => value.id), [primary.id, overflow.id]);
assert.equal(storage.has(keys.STORAGE_KEY_AI_SESSIONS_OVERFLOW), false);
assert.deepEqual(JSON.parse(storage.get(keys.STORAGE_KEY_AI_SESSIONS)!).map((value: AISession) => value.id), [primary.id, overflow.id]);
});
test('failed history persistence refuses upload without pruning the cloud representation', async () => {
save([session()]);
const original = globalThis.localStorage.setItem;
+2 -2
View File
@@ -51,6 +51,7 @@ import { sanitizeQuickMessages } from '../infrastructure/ai/quickMessages';
import { emitAIStateChanged } from './state/aiStateEvents';
import {
latestAISessionsSnapshot,
readSessionsFromStorage,
setLatestAISessionsSnapshot,
writeSessionsForStorage,
cleanupSdkAgentSessions,
@@ -1073,8 +1074,7 @@ export function collectAiSessionsForSync(): SyncPayload['aiSessions'] {
function collectLocalAiSessions(): NonNullable<SyncPayload['aiSessions']> {
return latestAISessionsSnapshot
?? localStorageAdapter.read<SyncPayload['aiSessions']>(STORAGE_KEY_AI_SESSIONS)
?? [];
?? readSessionsFromStorage();
}
/** Build the portable payload; device-local data is added by the backup builder. */
@@ -3,7 +3,7 @@ import test from "node:test";
import React from "react";
import type { AISession } from "../infrastructure/ai/types.ts";
import { MESSAGES_BY_LOCALE } from "../application/i18n/messages.ts";
import { getLoadedLocaleMessages, loadLocaleMessages } from "../application/i18n/messages.ts";
import { createDomRenderer, dispatchDomEvent, installDomEnvironment, runWithAct } from "./test-support/renderReactDom.tsx";
test("history drawer filters, pages, clears and explains the bounded search in every locale", async () => {
@@ -107,9 +107,11 @@ test("history drawer filters, pages, clears and explains the bounded search in e
assert.equal(closed, 1);
for (const locale of ["en", "zh-CN", "zh-TW", "es", "ru"]) {
// Non-English dictionaries load on demand.
await loadLocaleMessages(locale);
await render(sessions, locale);
await enterQuery("no-such-content");
const messages = MESSAGES_BY_LOCALE[locale];
const messages = getLoadedLocaleMessages(locale)!;
const input = renderer.container.querySelector("input")!;
const hint = dom.document.getElementById(input.getAttribute("aria-describedby")!);
for (const key of ["ai.chat.searchSessions", "ai.chat.searchSessionsHint", "ai.chat.noMatchingSessions"]) {
+1
View File
@@ -112,6 +112,7 @@ export function createAppLockGate(deps: AppLockGateDeps): React.FC<AppLockGatePr
setTimeout(() => splash.remove(), 200);
}
if (shouldNotifyRendererReady) {
performance.mark('netcatty:renderer:ready');
notifyAppLockRendererReady();
}
} catch {
@@ -3,6 +3,7 @@ import test from "node:test";
import React from "react";
import { I18nProvider } from "../application/i18n/I18nProvider.tsx";
import { loadLocaleMessages } from "../application/i18n/messages.ts";
import { APP_LOCK_AUTO_PROMPT_DELAY_MS, AppLockOverlay } from "./AppLockOverlay.tsx";
import {
createDomRenderer,
@@ -342,6 +343,7 @@ test("AppLockOverlay renders platform-specific system unlock button", async () =
});
test("AppLockOverlay localizes the system unlock button label", async () => {
await loadLocaleMessages("zh-CN");
const dom = installDomEnvironment();
const renderer = await createDomRenderer(dom.document);
@@ -2,9 +2,11 @@ import assert from 'node:assert/strict';
import test from 'node:test';
import React from 'react';
import { I18nProvider } from '../application/i18n/I18nProvider';
import { loadLocaleMessages } from '../application/i18n/messages';
import { createDomRenderer, dispatchDomEvent, flushEffects, installDomEnvironment, runWithAct } from './test-support/renderReactDom';
test('backup cleanup confirms, preserves data on cancel, reports errors and refreshes after retry', async () => {
test('backup cleanup confirms, preserves data on cancel, refreshes after retry and explains decrypt failures', async () => {
await loadLocaleMessages('zh-CN');
const dom = installDomEnvironment();
const globals = {
localStorage: dom.window.localStorage,
@@ -37,6 +39,9 @@ test('backup cleanup confirms, preserves data on cancel, reports errors and refr
id: 'fixture', createdAt: 1, reason: 'before_restore', fingerprint: 'fixture',
preview: { hostCount: 1, keyCount: 0, snippetCount: 0, noteCount: 0, identityCount: 0, portForwardingRuleCount: 0 },
}] : [],
readVaultBackup: async () => {
throw new Error("Error invoking remote method 'netcatty:vaultBackups:read': Error: VAULT_BACKUP_DECRYPT_FAILED");
},
clearVaultBackups: async () => {
calls += 1;
if (fail) throw new Error('access denied');
@@ -57,6 +62,8 @@ test('backup cleanup confirms, preserves data on cancel, reports errors and refr
try {
await renderer.render(<I18nProvider locale="en"><LocalBackupsPanel onApplyPayload={() => { throw new Error('must not restore'); }} /></I18nProvider>);
await flushEffects();
assert.match(dom.document.body.textContent ?? '', /This folder alone is not a complete, portable backup/);
assert.match(dom.document.body.textContent ?? '', /Deleting or wiping the app profile \(userData\)/);
assert.doesNotMatch(dom.document.body.textContent ?? '', /Master key required/);
assert.equal([...dom.document.querySelectorAll('button')].some((el) => el.textContent === 'Restore'), false);
await runWithAct(async () => { finishProbe?.({ encryptionAvailable: false }); });
@@ -90,6 +97,19 @@ test('backup cleanup confirms, preserves data on cancel, reports errors and refr
assert.equal(dom.document.querySelector('[role="dialog"]'), null);
assert.match(dom.document.body.textContent ?? '', /No local backups yet/);
assert.match(notices[1], /^success:/);
hasBackup = true;
await renderer.render(<I18nProvider locale="zh-CN"><LocalBackupsPanel key="restore" onApplyPayload={() => { throw new Error('must not apply unreadable backup'); }} /></I18nProvider>);
await runWithAct(async () => { finishProbe?.({ encryptionAvailable: true }); });
await flushEffects();
assert.match(dom.document.body.textContent ?? '', /仅复制此文件夹并不构成完整、可迁移的备份/);
await click(button('恢复'));
dialog = dom.document.querySelector('[role="dialog"]');
assert.ok(dialog);
await click(button('恢复', dialog));
assert.match(notices[2], /当前应用配置无法解密此备份/);
assert.match(notices[2], /可能是加密密钥丢失、应用配置已更改,或备份已损坏/);
assert.doesNotMatch(notices[2], /VAULT_BACKUP_DECRYPT_FAILED|decryptString/);
} finally {
toast.success = success;
toast.error = error;
+3 -1
View File
@@ -1,5 +1,4 @@
import React, { useEffect, useRef, useState } from "react";
import jsQR from "jsqr";
import { RE2JS } from "re2js";
import type { Host, HostTotpConfig } from "../domain/models";
import { netcattyBridge } from "@/infrastructure/services/netcattyBridge.ts";
@@ -60,6 +59,9 @@ function errorKey(error: string | undefined): string {
}
async function decodeQrFile(file: File): Promise<string> {
// Loaded on demand: the decoder is only needed when importing a QR image,
// but the host editor (and with it this module) is part of the startup bundle.
const { default: jsQR } = await import("jsqr");
const bitmap = await createImageBitmap(file);
try {
const canvas = document.createElement("canvas");
@@ -1,9 +1,10 @@
import assert from "node:assert/strict";
import test from "node:test";
import test, { before } from "node:test";
import React from "react";
import { renderToStaticMarkup } from "react-dom/server";
import { I18nProvider } from "../application/i18n/I18nProvider";
import { loadLocaleMessages } from "../application/i18n/messages";
import type { Host, TerminalSession } from "../types";
import { QuickSwitcher } from "./QuickSwitcher";
@@ -40,6 +41,12 @@ const renderSwitcher = (
</I18nProvider>,
);
// Non-English dictionaries load on demand; static rendering runs no effects.
before(async () => {
await loadLocaleMessages("zh-TW");
await loadLocaleMessages("zh-CN");
});
test("quick switcher keeps a matching host ahead of an address row", () => {
const markup = renderSwitcher("10.1.1.1", [host()]);
const quickConnectAt = markup.indexOf("Quick connect to 10.1.1.1");
+1 -1
View File
@@ -150,7 +150,7 @@ export function getQuickSwitcherVisualRowHeight(row: QuickSwitcherVisualRow): nu
return QS_ROW_HEIGHT;
}
interface QuickSwitcherProps {
export interface QuickSwitcherProps {
isOpen: boolean;
query: string;
results: Host[];
+92 -17
View File
@@ -2,6 +2,7 @@ import { publishTerminalSelectionTarget } from "../infrastructure/plugins/termin
import { usePluginTerminalSelection } from "../application/state/usePluginTerminalSelection";
import { clearTerminalBroadcastUserInput, markTerminalBroadcastUserInput } from "./terminal/runtime/terminalPacedBroadcast";
import { shouldBroadcastDuringSensitivePrompt } from "../domain/terminalBroadcast";
import { probeAltScreenScheduling } from "./terminal/runtime/terminalWriteCoalescer";
import { STORAGE_KEY_TERMINAL_BROADCAST_PASSWORD_BYPASS } from "../infrastructure/config/storageKeys";
import { createTerminalReflowReadingPosition } from "./terminal/terminalReflowReadingPosition";
import { resolveHostOs } from '../domain/host';
@@ -53,7 +54,7 @@ import {
import { classifyDistroId, hostRestrictsExtraSshChannels, shouldProbeSessionCwd } from "../domain/host";
import { shouldCollectServerStats } from "../domain/systemManager/systemTarget";
import { resolveHostSshConnectionTimeouts } from "../domain/sshConnectionTimeouts";
import { CONNECTION_PROGRESS_START } from "./terminal/connectionProgress";
import { CONNECTION_PROGRESS_START, capConnectionProgressLogs } from "./terminal/connectionProgress";
import { supportsZmodemTerminalDragDrop } from "../lib/zmodemDragDrop";
import { resolveHostAuth, resolveHostAutofillPassword } from "../domain/sshAuth";
import { resolveEffectiveTerminalProtocol } from "../domain/terminalProtocol";
@@ -63,6 +64,7 @@ import { clearTerminalBootEpoch, setTerminalBootEpoch } from "../domain/terminal
import {
foldTerminalPromptSecurityOutput,
isConfirmedTerminalShellPrompt,
isSensitiveTerminalChallenge,
isUntrustedTerminalInputPrompt,
} from "../domain/terminalPromptSecurity";
import { listPasswordPromptFillCandidates } from "../domain/passwordPromptAssist";
@@ -155,6 +157,7 @@ import { createTerminalOutputHistoryPreview } from "./terminal/runtime/terminalO
import { useZmodemTransfer } from "./terminal/hooks/useZmodemTransfer";
import {
createTerminalSessionStarters,
isAuthFailureMessage,
type PendingAuth,
type TerminalSessionDataMeta,
} from "./terminal/runtime/createTerminalSessionStarters";
@@ -167,7 +170,7 @@ import {
} from "./terminal/runtime/createXTermRuntime";
import { clearKittyKeyboardBroadcastSession } from "./terminal/runtime/kittyKeyboardBroadcast";
import { registerTerminalSensitiveInputReader } from "./terminal/runtime/terminalSensitiveInputRegistry";
import { registerTerminalCommandInjectionReadyReader } from "./terminal/runtime/terminalCommandInjectionReadyRegistry";
import { isTerminalReadyForCommandInjection, registerTerminalCommandInjectionReadyReader } from "./terminal/runtime/terminalCommandInjectionReadyRegistry";
import {
isIdleShellReadyForCommandInjection,
isShellPromptReadyForInsertion,
@@ -206,7 +209,7 @@ import { useTerminalFilePaste } from "./terminal/hooks/useTerminalFilePaste";
import { getRememberedYmodemSendDefaultPath, rememberYmodemSendFilePath } from "../application/state/ymodemFileMemory";
import { TerminalAutocomplete } from "./terminal/TerminalAutocomplete";
import { resolveTerminalAutocompleteSettings } from "./terminal/autocomplete/terminalAutocompleteSettings";
import { buildOsc7SetupExecCommand, runOsc7SetupAction, shouldOfferOsc7SetupAction } from "./terminal/osc7Setup";
import { buildOsc7SetupExecCommand, runOsc7SetupAction, shouldOfferOsc7SetupAction, waitForOsc7SessionOutput } from "./terminal/osc7Setup";
import {
getRemoteClipboardImageUploadErrorMessageKey,
type RemoteClipboardImageUploadResult,
@@ -463,6 +466,11 @@ const TerminalComponent: React.FC<TerminalProps> = ({
const sensitivePromptOutputTailRef = useRef("");
const heredocPromptOriginRef = useRef("");
const promptSecurityPendingCrRef = useRef(false);
const promptSecurityScreenRef = useRef<{
// Null until a raw transition or snapshot establishes authoritative screen state.
active: boolean | null;
incomplete: Parameters<typeof probeAltScreenScheduling>[1];
}>({ active: null, incomplete: null });
const [activeScriptRun, setActiveScriptRun] = useState<import('@/types/global/netcatty-bridge-script.d.ts').ScriptRun | undefined>(undefined);
const dismissedScriptRunIdsRef = useRef(new Set<string>());
@@ -505,6 +513,10 @@ const TerminalComponent: React.FC<TerminalProps> = ({
const availableFonts = useAvailableFonts();
const containerRef = useRef<HTMLDivElement>(null);
const termRef = useRef<XTerm | null>(null);
const isPromptSecurityAlternateScreenActive = useCallback(() => (
promptSecurityScreenRef.current.active
?? (termRef.current ? isTerminalAlternateScreenActive(termRef.current) : false)
), []);
const fitAddonRef = useRef<FitAddon | null>(null);
const serializeAddonRef = useRef<SerializeAddon | null>(null);
const searchAddonRef = useRef<SearchAddon | null>(null);
@@ -602,6 +614,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
const reconnectWakeInvalidateModeRef = useRef<"dispose" | "keep">("dispose");
const reconnectWakeTokenRef = useRef<symbol | null>(null);
const manualReconnectRequestRef = useRef<() => void>(() => {});
const agentRecoveryStoppedRef = useRef(false);
// Once a pane reconnects, every later SSH attempt in it must dial a fresh
// connection: reusing a live/idle pooled transport multiplexes onto the old
// login, so remote supplementary-group changes (e.g. `usermod -aG`) are not
@@ -911,8 +924,18 @@ const TerminalComponent: React.FC<TerminalProps> = ({
const [error, setError] = useState<string | null>(null);
const lastToastedErrorRef = useRef<string | null>(null);
const [showLogs, setShowLogs] = useState(false);
const [progressLogs, setProgressLogs] = useState<string[]>([]);
const [timeLeft, setTimeLeft] = useState(CONNECTION_TIMEOUT / 1000);
const [progressLogs, setProgressLogsState] = useState<string[]>([]);
// Every writer (starters, auto-reconnect, cancel) appends; an outage with
// auto-reconnect on (retry every 5 s, no attempt cap) used to grow this list
// and its unvirtualized dialog render for as long as the tab stayed open.
const setProgressLogs = useCallback((update: React.SetStateAction<string[]>) => {
setProgressLogsState((prev) => capConnectionProgressLogs(
typeof update === "function" ? update(prev) : update,
));
}, []);
// Deadline of the running connection timeout; the dialog counts down from
// it locally so per-second ticks do not re-render Terminal/TerminalView.
const [connectionDeadlineAt, setConnectionDeadlineAt] = useState<number | null>(null);
const [isCancelling, setIsCancelling] = useState(false);
const [showSFTP, setShowSFTP] = useState(false);
const [isSessionLogging, setIsSessionLogging] = useState(false);
@@ -1115,6 +1138,8 @@ const TerminalComponent: React.FC<TerminalProps> = ({
shouldBroadcastDuringSensitivePrompt({
sensitivePromptActive: sensitive,
broadcastPasswordBypass: broadcastPasswordBypassRef.current,
sensitivePromptSource: passwordPromptActiveRef.source,
alternateScreen: isPromptSecurityAlternateScreenActive(),
})
&& isBroadcastEnabledRef.current
&& onBroadcastInputRef.current
@@ -1390,6 +1415,9 @@ const TerminalComponent: React.FC<TerminalProps> = ({
setupOsc7Tracking: terminalBackend.setupOsc7Tracking,
writeToSession: terminalBackend.writeToSession,
writeLocalTerminalData,
isTerminalReadyForCommandInjection: () => isTerminalReadyForCommandInjection(sessionId),
waitForSessionOutput: (id, predicate, timeoutMs) =>
waitForOsc7SessionOutput(terminalBackend.onSessionData, id, predicate, timeoutMs),
}).then((result) => {
handleOsc7SetupOpenChange(false);
if (result.success) {
@@ -1413,6 +1441,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
terminalCwdTracker,
terminalBackend.setupOsc7Tracking,
terminalBackend.writeToSession,
terminalBackend.onSessionData,
writeLocalTerminalData,
]);
@@ -1501,6 +1530,9 @@ const TerminalComponent: React.FC<TerminalProps> = ({
}, []);
const updateStatus = useCallback((next: TerminalSession["status"]) => {
if (next === "disconnected") {
promptSecurityScreenRef.current = { active: null, incomplete: null };
}
statusRef.current = next;
setStatus(next);
hasConnectedRef.current = next === "connected";
@@ -1567,7 +1599,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
}, TERMINAL_AUTO_RECONNECT_DELAY_MS);
return true;
}, [attachExistingSession, host, t, terminalSettings, updateStatus]);
}, [attachExistingSession, host, setProgressLogs, t, terminalSettings, updateStatus]);
const prepareRestoredReconnect = useCallback(() => {
suppressHostStartupCommandRef.current = shouldSuppressHostStartupCommandOnReconnect(
@@ -1722,6 +1754,10 @@ const TerminalComponent: React.FC<TerminalProps> = ({
throw new Error("Terminal output did not settle before applying the snapshot");
}
}
promptSecurityScreenRef.current = {
active: payload.alternateScreen,
incomplete: null,
};
if (typeof payload.perCharacterInput === "boolean") {
restoreTerminalPerCharacterInput(terminalSettingsRef, payload.perCharacterInput);
xtermRuntimeRef.current?.restorePerCharacterInput(payload.perCharacterInput);
@@ -2055,6 +2091,14 @@ const TerminalComponent: React.FC<TerminalProps> = ({
chunk: string,
meta?: TerminalSessionDataMeta,
) => {
// PTY output reaches this observer before coalesced writes finish parsing.
const screenProbe = probeAltScreenScheduling(chunk, promptSecurityScreenRef.current.incomplete);
promptSecurityScreenRef.current = {
active: screenProbe.lastTransition === null
? promptSecurityScreenRef.current.active
: screenProbe.lastTransition === "enter",
incomplete: screenProbe.incomplete,
};
const folded = foldTerminalPromptSecurityOutput({
origin: heredocPromptOriginRef.current,
current: sensitivePromptOutputTailRef.current,
@@ -2065,9 +2109,9 @@ const TerminalComponent: React.FC<TerminalProps> = ({
promptSecurityPendingCrRef.current = folded.pendingCr;
const promptSecurityOptions = {
allowHostStyleGreaterThan: isNetworkDevice,
alternateScreen: termRef.current
alternateScreen: promptSecurityScreenRef.current.active ?? (termRef.current
? isTerminalAlternateScreenActive(termRef.current)
: false,
: false),
};
if (typeof meta?.pluginPipelineSensitiveInput === "boolean") {
passwordPromptActiveRef.current = meta.pluginPipelineSensitiveInput;
@@ -2082,17 +2126,24 @@ const TerminalComponent: React.FC<TerminalProps> = ({
promptSecurityPendingCrRef.current = false;
}
return;
} else if (isUntrustedTerminalInputPrompt(
folded.promptText,
promptSecurityOptions,
)) {
} else if (passwordPromptActiveRef.current && passwordPromptActiveRef.source === "explicit") {
return;
} else if (isSensitiveTerminalChallenge(folded.promptText)
|| (!(promptSecurityOptions.alternateScreen && /^:/.test(folded.promptText.trimStart()))
&& isUntrustedTerminalInputPrompt(
folded.promptText,
promptSecurityOptions,
))) {
passwordPromptActiveRef.current = true;
passwordPromptActiveRef.source = "heuristic";
autocompleteCloseRef.current?.();
} else if (isConfirmedTerminalShellPrompt(
} else if ((promptSecurityOptions.alternateScreen && /^:/.test(folded.promptText.trimStart())) || isConfirmedTerminalShellPrompt(
folded.promptText,
promptSecurityOptions,
)) {
// Vim ex can end a stale heuristic latch on the alternate screen (#4107).
// Other output must confirm a shell prompt: background job output can
// arrive while a password is still expected, including inside tmux.
passwordPromptActiveRef.current = false;
passwordPromptActiveRef.source = "heuristic";
}
@@ -3443,6 +3494,8 @@ const TerminalComponent: React.FC<TerminalProps> = ({
shouldBroadcastDuringSensitivePrompt({
sensitivePromptActive: passwordPromptActiveRef.current,
broadcastPasswordBypass: broadcastPasswordBypassRef.current,
sensitivePromptSource: options?.sensitive === true ? "explicit" : passwordPromptActiveRef.source,
alternateScreen: isPromptSecurityAlternateScreenActive(),
})
&& sessionRef.current
&& isBroadcastEnabledRef.current
@@ -3460,7 +3513,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
return true;
}
return false;
}, [sessionId]);
}, [isPromptSecurityAlternateScreenActive, sessionId]);
const executeSnippetCommand = useCallback(async (
command: string,
@@ -3538,6 +3591,8 @@ const TerminalComponent: React.FC<TerminalProps> = ({
shouldBroadcastDuringSensitivePrompt({
sensitivePromptActive: sensitive,
broadcastPasswordBypass: broadcastPasswordBypassRef.current,
sensitivePromptSource: options?.sensitive === true ? "explicit" : passwordPromptActiveRef.source,
alternateScreen: isPromptSecurityAlternateScreenActive(),
})
&& broadcastThisSend
&& onBroadcastInputRef.current
@@ -3578,7 +3633,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
term.focus();
}
return true;
}, [prepareProgrammaticSudoInput, scrollToBottomAfterProgrammaticInput, terminalBackend, sessionId, host.protocol, serialConfig?.lineMode]);
}, [isPromptSecurityAlternateScreenActive, prepareProgrammaticSudoInput, scrollToBottomAfterProgrammaticInput, terminalBackend, sessionId, host.protocol, serialConfig?.lineMode]);
const executeSnippet = useCallback(async (snippet: Snippet) => {
if (isScriptSnippet(snippet)) {
@@ -3780,6 +3835,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
]);
const handleCancelConnect = () => {
agentRecoveryStoppedRef.current = true;
if (pendingHostKeyRequestId) {
void terminalBackend.respondHostKeyVerification(pendingHostKeyRequestId, false);
}
@@ -3813,6 +3869,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
const handleDisconnect = () => {
if (attachExistingSession) return;
if (statusRef.current === "disconnected") return;
agentRecoveryStoppedRef.current = true;
if (pendingHostKeyRequestId) {
void terminalBackend.respondHostKeyVerification(pendingHostKeyRequestId, false);
}
@@ -4145,14 +4202,31 @@ const TerminalComponent: React.FC<TerminalProps> = ({
startReconnectRef.current = startReconnect;
const handleRetry = () => {
agentRecoveryStoppedRef.current = false;
startReconnect("manual");
};
manualReconnectRequestRef.current = handleRetry;
const canAgentReconnectRef = useRef<() => boolean>(() => false);
canAgentReconnectRef.current = () => statusRef.current === 'disconnected'
&& !agentRecoveryStoppedRef.current && !isAuthFailureMessage(error || '')
&& !auth.needsAuth && !needsHostKeyVerification && !isConnectionAwaitingUserInput
&& !autoReconnectLoopActiveRef.current && !reconnectWakeInFlightRef.current
&& reconnectPreparationTokenRef.current === null;
const readAgentRecoveryRef = useRef(() => ({ status: String(statusRef.current), waitingForInput: false, reconnecting: false, stopped: false }));
readAgentRecoveryRef.current = () => ({
status: statusRef.current,
waitingForInput: Boolean(auth.needsAuth || needsHostKeyVerification || isConnectionAwaitingUserInput || isAuthFailureMessage(error || '')),
stopped: agentRecoveryStoppedRef.current,
reconnecting: autoReconnectLoopActiveRef.current || reconnectWakeInFlightRef.current
|| reconnectPreparationTokenRef.current !== null,
});
useEffect(() => {
if (attachExistingSession) return undefined;
return terminalReconnectRegistry.register(
sessionId,
() => manualReconnectRequestRef.current(),
() => canAgentReconnectRef.current(),
() => readAgentRecoveryRef.current(),
);
}, [attachExistingSession, sessionId]);
@@ -4558,6 +4632,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
onAutocompleteReposition: () => autocompleteRepositionRef.current?.(),
terminalContextActionsRef,
isRestoringSelectionRef,
isPaneVisibleRef: isVisibleRef,
};
const safeFitRef = useRef(safeFit);
@@ -4815,7 +4890,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
onWake: wakeFromHibernateRuntime,
});
useTerminalEffects({ CONNECTION_TIMEOUT, Error, XTERM_PERFORMANCE_CONFIG, applyUserCursorPreference, applyUserCursorWidthPreference, shouldApplyUserCursorPreference, snapshotUserCursorPreference, auth, autocompleteCloseRef, autocompleteInputRef, autocompleteKeyEventRef, autocompleteRepositionRef, captureTerminalLogData, chainHosts: resolvedChainHosts, chainProgress, clearTerminalCwd, commandBufferRef, connectionLogBufferRef, containerRef, createPromptLineBreakState, createReplaySafeTerminalLogSanitizer, createXTermRuntime, deferTerminalResizeRef, disableTerminalFontZoomRef, effectiveFontSize, effectiveFontWeight, effectiveTheme, error, executeSnippetCommand, finalizeTerminalLogData, fitAddonRef, fontFamilyId, fontSize, fontWeightFixupDoneRef, forceCloseHibernatedSession, forceSyncRenderAfterResize, handleOsc52ReadRequest, handleTerminalDataCaptureOnce, hasConnectedRef, hasRuntimeRef, host, hotkeySchemeRef, hibernatedRef, identities, inWorkspace, isBootActiveRef, bootEpochRef, isBroadcastEnabledRef, broadcastPasswordBypassRef, isComposeBarOpen: effectiveComposeBarOpen, isConnectionAwaitingUserInput, isConnectionPastTcpDial, isFocusMode, isFocused, isLocalConnection, isNetworkDevice, isResizing: deferTerminalResize, isRestoringSelectionRef, isSearchOpen, isSerialConnection, isVisible, isVisibleRef, keyBindingsRef, keys, kittyKeyboardProtocolEnabledForSession, knownCwdRef, lastFittedSizeRef, lastToastedErrorRef, logger, mouseTrackingRef, needsHostKeyVerification, onBroadcastInputRef, onBroadcastInterruptPriorityChange, onCommandExecuted, onCommandSubmitted: cwdAwareOnCommandSubmitted, onCommandSettled: onCommandSubmitted, onHotkeyActionRef, onOpenExternalError, onOutputTriggerUserInputRef: noteOutputTriggerUserInputRef, onPluginRuntimeCwdChange: pluginAwareOnRuntimeCwdChange, onSnippetShortkeyRef, onSnippetExecutorChange, onTerminalCwdChange, onTerminalTitleChange, onTerminalBell, onTerminalFontSizeChange, paneLayoutKey, passwordPromptActiveRef, pendingAuthRef, pendingOutputScrollRef, pluginDecorationRefreshRef, pluginDecorationRules, pluginDecorationRulesRef, pluginTerminalLifecycle, pluginTerminalProviderRevision, isPluginTerminalProviderAvailable, requestPluginTerminalProviders, prepareRestoredReconnect, prepareInitialCwdIntent, prevIsResizingRef, promptLineBreakStateRef, resizeSession, resolveHostAuth, resolvedFontFamily, safeFit, scriptRecorderRef: recorderRef, searchAddonRef, serialConfig, serialLineBufferRef, serializeAddonRef, sessionId, sessionRef, sessionStarters, setError, setHasMouseTracking, setIsCancelling, setIsDisconnectedDialogDismissed, requestSearchFocus, setNeedsHostKeyVerification, setPendingHostKeyInfo, setPendingHostKeyRequestId, setProgressLogs, setProgressValue, setShowLogs, setStatus, setTimeLeft, shellType, shouldEnableNativeUserInputAutoScroll, shouldProbeSessionCwd, shouldStartTerminalBackend, vaultInitialized, attachExistingSession, attachAuthorization, attachHomeWebContentsIdRef, snippetsRef, splitResizeActive: isResizing, status, statusRef, sudoAutofillRef, t, teardown, telnetLocalEchoRef, termRef, terminalAltKeyOptions, terminalBackend, terminalContextActionsRef, terminalCwdTracker, terminalDataCapturedRef, terminalLogSanitizerRef, terminalOutputHistory: terminalOutputHistoryRef.current, terminalSettings, terminalSettingsRef, terminalTitleRef, toHostKeyInfo, toast, updateStatus, useEffect, useLayoutEffect, workspaceId, xtermRuntimeRef, zmodem, zmodemToastedRef, restoreState });
useTerminalEffects({ isPromptSecurityAlternateScreenActive, CONNECTION_TIMEOUT, Error, XTERM_PERFORMANCE_CONFIG, applyUserCursorPreference, applyUserCursorWidthPreference, shouldApplyUserCursorPreference, snapshotUserCursorPreference, auth, autocompleteCloseRef, autocompleteInputRef, autocompleteKeyEventRef, autocompleteRepositionRef, captureTerminalLogData, chainHosts: resolvedChainHosts, chainProgress, clearTerminalCwd, commandBufferRef, connectionLogBufferRef, containerRef, createPromptLineBreakState, createReplaySafeTerminalLogSanitizer, createXTermRuntime, deferTerminalResizeRef, disableTerminalFontZoomRef, effectiveFontSize, effectiveFontWeight, effectiveTheme, error, executeSnippetCommand, finalizeTerminalLogData, fitAddonRef, fontFamilyId, fontSize, fontWeightFixupDoneRef, forceCloseHibernatedSession, forceSyncRenderAfterResize, handleOsc52ReadRequest, handleTerminalDataCaptureOnce, hasConnectedRef, hasRuntimeRef, host, hotkeySchemeRef, hibernatedRef, identities, inWorkspace, isBootActiveRef, bootEpochRef, isBroadcastEnabledRef, broadcastPasswordBypassRef, isComposeBarOpen: effectiveComposeBarOpen, isConnectionAwaitingUserInput, isConnectionPastTcpDial, isFocusMode, isFocused, isLocalConnection, isNetworkDevice, isResizing: deferTerminalResize, isRestoringSelectionRef, isSearchOpen, isSerialConnection, isVisible, isVisibleRef, keyBindingsRef, keys, kittyKeyboardProtocolEnabledForSession, knownCwdRef, lastFittedSizeRef, lastToastedErrorRef, logger, mouseTrackingRef, needsHostKeyVerification, onBroadcastInputRef, onBroadcastInterruptPriorityChange, onCommandExecuted, onCommandSubmitted: cwdAwareOnCommandSubmitted, onCommandSettled: onCommandSubmitted, onHotkeyActionRef, onOpenExternalError, onOutputTriggerUserInputRef: noteOutputTriggerUserInputRef, onPluginRuntimeCwdChange: pluginAwareOnRuntimeCwdChange, onSnippetShortkeyRef, onSnippetExecutorChange, onTerminalCwdChange, onTerminalTitleChange, onTerminalBell, onTerminalFontSizeChange, paneLayoutKey, passwordPromptActiveRef, pendingAuthRef, pendingOutputScrollRef, pluginDecorationRefreshRef, pluginDecorationRules, pluginDecorationRulesRef, pluginTerminalLifecycle, pluginTerminalProviderRevision, isPluginTerminalProviderAvailable, requestPluginTerminalProviders, prepareRestoredReconnect, prepareInitialCwdIntent, prevIsResizingRef, promptLineBreakStateRef, resizeSession, resolveHostAuth, resolvedFontFamily, safeFit, scriptRecorderRef: recorderRef, searchAddonRef, serialConfig, serialLineBufferRef, serializeAddonRef, sessionId, sessionRef, sessionStarters, setError, setHasMouseTracking, setIsCancelling, setIsDisconnectedDialogDismissed, requestSearchFocus, setNeedsHostKeyVerification, setPendingHostKeyInfo, setPendingHostKeyRequestId, setProgressLogs, setProgressValue, setShowLogs, setStatus, setConnectionDeadlineAt, shellType, shouldEnableNativeUserInputAutoScroll, shouldProbeSessionCwd, shouldStartTerminalBackend, vaultInitialized, attachExistingSession, attachAuthorization, attachHomeWebContentsIdRef, snippetsRef, splitResizeActive: isResizing, status, statusRef, sudoAutofillRef, t, teardown, telnetLocalEchoRef, termRef, terminalAltKeyOptions, terminalBackend, terminalContextActionsRef, terminalCwdTracker, terminalDataCapturedRef, terminalLogSanitizerRef, terminalOutputHistory: terminalOutputHistoryRef.current, terminalSettings, terminalSettingsRef, terminalTitleRef, toHostKeyInfo, toast, updateStatus, useEffect, useLayoutEffect, workspaceId, xtermRuntimeRef, zmodem, zmodemToastedRef, restoreState });
return (
<>
@@ -4828,7 +4903,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
onDismiss={dismissScriptOverlay}
compactTopChrome={terminalSettings?.showHostInfoBar === false}
/>
) : null, sessionDisplayName, sessionId, workspaceId, sessionRef, setIsComposeBarOpen, setShowLogs, shouldShowConnectionDialog, showDisconnectedTerminalNotice, showConnectionControls: !attachExistingSession && !compactToolbar, showLogs, showSelectionAIAction: Boolean(showSelectionAIAction && onAddSelectionToAI), isRestoringSelectionRef, snippets, status, sudoHintRef, sudoHintText, passwordPickerState, onPasswordPickerSelect: handlePasswordPickerSelect, passwordPickerTitle, passwordPickerEmptyText, t, termRef, terminalBackend, terminalContextActions, terminalCwdTracker, terminalPreviewVars, terminalSettings, terminalReconnectAvailable: !attachExistingSession, reconnectNoticeMessage, timeLeft, toast, zmodem }} isPaneMagnified={isPaneMagnified} />
) : null, sessionDisplayName, sessionId, workspaceId, sessionRef, setIsComposeBarOpen, setShowLogs, shouldShowConnectionDialog, showDisconnectedTerminalNotice, showConnectionControls: !attachExistingSession && !compactToolbar, showLogs, showSelectionAIAction: Boolean(showSelectionAIAction && onAddSelectionToAI), isRestoringSelectionRef, snippets, status, sudoHintRef, sudoHintText, passwordPickerState, onPasswordPickerSelect: handlePasswordPickerSelect, passwordPickerTitle, passwordPickerEmptyText, t, termRef, terminalBackend, terminalContextActions, terminalCwdTracker, terminalPreviewVars, terminalSettings, terminalReconnectAvailable: !attachExistingSession, reconnectNoticeMessage, connectionDeadlineAt, connectionTimeoutSeconds: CONNECTION_TIMEOUT / 1000, toast, zmodem }} isPaneMagnified={isPaneMagnified} />
<ScriptSaveRecordingDialog
open={saveRecordingOpen}
code={recordedCode}
+1 -1
View File
@@ -218,7 +218,7 @@ test('an attached observe popup never owns automatic reconnect', () => {
const reconnectEnd = terminalSource.indexOf('const prepareRestoredReconnect', reconnectStart);
const reconnectSource = terminalSource.slice(reconnectStart, reconnectEnd);
assert.match(reconnectSource, /if \(attachExistingSession\) return false;/);
assert.match(reconnectSource, /\[attachExistingSession, host, t, terminalSettings, updateStatus\]/);
assert.match(reconnectSource, /\[attachExistingSession, host, setProgressLogs, t, terminalSettings, updateStatus\]/);
assert.match(terminalSource, /const startReconnect = async[\s\S]*?if \(attachExistingSession\) return;/);
assert.match(
terminalSource,
+38 -17
View File
@@ -4,7 +4,7 @@ import { fromEditorTabId, isEditorTabId, toEditorTabId, useActiveTabId } from '.
import { topTabsSessionsEqual } from '../domain/topTabsSessionsEqual';
import { isHostTreeWorkTabSurface } from '../application/app/workTabSurface';
import { buildTabShortcutNumberById } from '../application/app/tabShortcutTargets';
import { useShortcutModifierHeld } from '../application/state/useShortcutModifierHeld';
import { useTrackShortcutModifierHeld } from '../application/state/useShortcutModifierHeld';
import type { EditorTabChrome } from '../application/state/editorTabStore';
import { collectSessionIds } from '../domain/workspace';
import {
@@ -56,6 +56,11 @@ const dragRegionStyle = { WebkitAppRegion: 'drag' } as React.CSSProperties;
const dragRegionNoSelect = { WebkitAppRegion: 'drag', userSelect: 'none' } as React.CSSProperties;
const noDragRegionStyle = { WebkitAppRegion: 'no-drag' } as React.CSSProperties;
const emptyTabStyle: React.CSSProperties = {};
// Hoisted so the memoized root tabs see a stable icon element every render.
const VAULT_TAB_ICON = <FolderLock size={14} />;
const SFTP_TAB_ICON = <Folder size={14} />;
type WorkspaceTabSessions = { ids: string[]; sessions: TerminalSession[] };
const EMPTY_WORKSPACE_TAB_SESSIONS: WorkspaceTabSessions = { ids: [], sessions: [] };
export function computeHostTreeTabGutter(hostTreeLayoutWidth: number, toggleRight: number): number {
return Math.max(0, hostTreeLayoutWidth - toggleRight);
@@ -242,18 +247,19 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
: hotkeyScheme === 'pc'
? switchTabKeyBinding?.pc ?? null
: null;
const shortcutModifierHeld = useShortcutModifierHeld(switchTabKey, hotkeyScheme);
// Badges only appear while the switch modifier is held. The held flag is a
// small store read by each tab's TabIconSlot, so pressing Ctrl/⌘ does not
// re-render the tab bar; the numbers themselves only change with the tabs.
useTrackShortcutModifierHeld(showTabNumberBadges ? switchTabKey : null, hotkeyScheme);
const tabShortcutNumbers = useMemo(() => {
// Keep the tab bar quiet until the modifier for the active shortcut scheme
// is held, while retaining the existing setting as the master toggle.
if (!showTabNumberBadges || hotkeyScheme === 'disabled' || !shortcutModifierHeld) return null;
if (!showTabNumberBadges || hotkeyScheme === 'disabled') return null;
return buildTabShortcutNumberById({
showSftpTab,
shellOnlyTabNumberShortcuts,
orderedTabs,
editorTabIds: editorTabs.map((tab) => toEditorTabId(tab.id)),
});
}, [hotkeyScheme, showTabNumberBadges, showSftpTab, shellOnlyTabNumberShortcuts, shortcutModifierHeld, orderedTabs, editorTabs]);
}, [hotkeyScheme, showTabNumberBadges, showSftpTab, shellOnlyTabNumberShortcuts, orderedTabs, editorTabs]);
const isHostTreeOpen = useTerminalHostTreeOpen();
const hostTreeLayoutWidth = useTerminalHostTreeLayoutWidth();
const toggleHostTree = useToggleTerminalHostTree();
@@ -520,6 +526,26 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
isHostTreeOpen,
]);
// Per-workspace session lists, rebuilt only when workspaces/sessions change
// (not on every tab switch, drag frame or modifier change) so the memoized
// WorkspaceTopTab keeps its props.
const workspaceTabSessions = useMemo(() => {
const sessionById = new Map(sessions.map((session) => [session.id, session]));
return new Map(workspaces.map((workspace): [string, WorkspaceTabSessions] => {
const ids = collectSessionIds(workspace.root);
// Detach-menu labels resolve presentation inside WorkspaceTopTab so
// dynamic title updates stay live without remapping the whole bar.
const workspaceSessions = ids
.map((sessionId) => sessionById.get(sessionId))
.filter((session): session is TerminalSession => Boolean(session));
return [workspace.id, { ids, sessions: workspaceSessions }];
}));
}, [sessions, workspaces]);
const handleDetachSessionFromWorkspace = useCallback(
(_workspaceId: string, sessionId: string) => onRemoveSessionFromWorkspace(sessionId),
[onRemoveSessionFromWorkspace],
);
const handleTabDragStart = useCallback((e: React.DragEvent, tabId: string) => {
// Payload must be written on the original dragstart. A slow reorder calls
// this again from `drag`, when DataTransfer is already read-only.
@@ -929,20 +955,15 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
const shiftStyle = tabShiftStyles[workspace.id] || emptyTabStyle;
const showDropIndicatorBefore = dropIndicator?.tabId === workspace.id && dropIndicator.position === 'before';
const showDropIndicatorAfter = dropIndicator?.tabId === workspace.id && dropIndicator.position === 'after';
const workspaceSessionIds = collectSessionIds(workspace.root);
// Detach-menu labels resolve presentation inside WorkspaceTopTab so
// dynamic title updates stay live without remapping the whole bar.
const workspaceSessions = workspaceSessionIds
.map((sessionId) => sessions.find((s) => s.id === sessionId))
.filter((s): s is TerminalSession => Boolean(s));
const workspaceSessionLists = workspaceTabSessions.get(workspace.id) ?? EMPTY_WORKSPACE_TAB_SESSIONS;
return (
<WorkspaceTopTab
key={workspace.id}
workspace={workspace}
paneCount={paneCount}
workspaceSessionIds={workspaceSessionIds}
workspaceSessions={workspaceSessions}
workspaceSessionIds={workspaceSessionLists.ids}
workspaceSessions={workspaceSessionLists.sessions}
dynamicTabTitleMode={dynamicTabTitleMode}
isBeingDragged={isBeingDragged}
isDraggingForReorder={isDraggingForReorder}
@@ -958,7 +979,7 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
onRenameWorkspace={onRenameWorkspace}
onCopyWorkspace={onCopyWorkspace}
onCloseWorkspace={onCloseWorkspace}
onDetachSessionFromWorkspace={(_workspaceId, sessionId) => onRemoveSessionFromWorkspace(sessionId)}
onDetachSessionFromWorkspace={handleDetachSessionFromWorkspace}
renderBulkCloseItems={renderBulkCloseItems}
t={t}
tabAnimationClass={getTabAnimationClass(workspace.id)}
@@ -1040,7 +1061,7 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
<RootTopTab
tabId="vault"
label="Vaults"
icon={<FolderLock size={14} />}
icon={VAULT_TAB_ICON}
className="rounded"
compact={rootTabsCompact}
shortcutNumber={tabShortcutNumbers?.get('vault')}
@@ -1049,7 +1070,7 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
<RootTopTab
tabId="sftp"
label="SFTP"
icon={<Folder size={14} />}
icon={SFTP_TAB_ICON}
className="rounded-t-md"
compact={rootTabsCompact}
shortcutNumber={tabShortcutNumbers?.get('sftp')}
+7 -1
View File
@@ -1,4 +1,4 @@
import test from 'node:test';
import test, { before } from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import React from 'react';
@@ -6,6 +6,12 @@ import { renderToStaticMarkup } from 'react-dom/server';
import { ComposerModelPicker } from './ComposerModelPicker';
import { I18nProvider } from '../../application/i18n/I18nProvider';
import { loadLocaleMessages } from '../../application/i18n/messages';
// Non-English dictionaries load on demand; static rendering runs no effects.
before(async () => {
for (const locale of ['zh-CN', 'zh-TW', 'es', 'ru']) await loadLocaleMessages(locale);
});
for (const [locale, cachedLabel] of [
['en', 'Cached'],
@@ -173,7 +173,9 @@ export const LocalBackupsPanel: React.FC<LocalBackupsPanelProps> = ({
toast.success(t('cloudSync.localBackups.restoreSuccess'));
} catch (error) {
toast.error(
error instanceof Error ? error.message : t('common.unknownError'),
error instanceof Error && error.message.includes('VAULT_BACKUP_DECRYPT_FAILED')
? t('cloudSync.localBackups.decryptFailed')
: error instanceof Error ? error.message : t('common.unknownError'),
t('cloudSync.localBackups.restoreFailedTitle'),
);
} finally {
@@ -294,6 +296,10 @@ export const LocalBackupsPanel: React.FC<LocalBackupsPanelProps> = ({
</div>
</div>
<p className="text-xs text-muted-foreground">
{t('cloudSync.localBackups.folderWarning')}
</p>
{backups.length === 0 ? (
<div className="rounded-lg border border-dashed border-border/60 p-4 text-sm text-muted-foreground">
{t('cloudSync.localBackups.empty')}
+12 -3
View File
@@ -5,7 +5,7 @@
* on tab-switch — we just toggle CSS visibility via the `isVisible` prop.
*/
import type * as Monaco from 'monaco-editor';
import React, { useCallback } from 'react';
import React, { useCallback, useMemo } from 'react';
import { useI18n } from '../../application/i18n/I18nProvider';
import { saveEditorTab } from '../../application/state/editorTabSave';
@@ -37,7 +37,7 @@ export function getTextEditorTabShellStyle(isVisible: boolean, hostTreeLayoutWid
};
}
export const TextEditorTabView: React.FC<TextEditorTabViewProps> = ({
const TextEditorTabViewImpl: React.FC<TextEditorTabViewProps> = ({
tabId,
hotkeyScheme,
keyBindings,
@@ -66,6 +66,11 @@ export const TextEditorTabView: React.FC<TextEditorTabViewProps> = ({
[tabId],
);
const handlePopOut = useMemo(
() => (onPopOut ? () => onPopOut(tabId) : undefined),
[onPopOut, tabId],
);
const handleToggleWordWrap = useCallback(() => {
const current = editorTabStore.getTab(tabId);
if (!current) return;
@@ -126,10 +131,14 @@ export const TextEditorTabView: React.FC<TextEditorTabViewProps> = ({
onToggleWordWrap={handleToggleWordWrap}
onSave={handleSave}
initialViewState={tab.viewState}
onPopOut={onPopOut ? () => onPopOut(tabId) : undefined}
onPopOut={handlePopOut}
/>
</div>
);
};
// Memoized: AppView re-renders for unrelated shell state; an editor tab only
// needs to render when its own props or subscribed tab state change.
export const TextEditorTabView = React.memo(TextEditorTabViewImpl);
export default TextEditorTabView;
+4 -1
View File
@@ -4,9 +4,12 @@ import React from "react";
import { renderToStaticMarkup } from "react-dom/server";
import { I18nProvider } from "../../application/i18n/I18nProvider.tsx";
import { loadLocaleMessages } from "../../application/i18n/messages.ts";
import { NoteOutline } from "./NoteOutline.tsx";
test("note outline renders a borderless hierarchy without heading-level labels", () => {
test("note outline renders a borderless hierarchy without heading-level labels", async () => {
// Static rendering runs no effects, so load the dictionary explicitly.
await loadLocaleMessages("zh-CN");
const markup = renderToStaticMarkup(
<I18nProvider locale="zh-CN">
<NoteOutline
@@ -4,10 +4,36 @@ import React from "react";
import { act, create, type ReactTestRenderer } from "react-test-renderer";
import {
MAX_SOURCE_HISTORY_CHARS,
MAX_SOURCE_HISTORY_STEPS,
NoteSourceEditor,
type NoteSourceEditorHandle,
pushSourceHistory,
} from "./NoteSourceEditor.tsx";
const snapshot = (value: string) => ({ value, selectionStart: 0, selectionEnd: 0 });
test("source undo history keeps only the most recent steps", () => {
const stack: Array<ReturnType<typeof snapshot>> = [];
for (let step = 0; step < MAX_SOURCE_HISTORY_STEPS + 50; step += 1) {
pushSourceHistory(stack, snapshot(`v${step}`));
}
assert.equal(stack.length, MAX_SOURCE_HISTORY_STEPS);
assert.equal(stack[0].value, "v50");
assert.equal(stack.at(-1)?.value, `v${MAX_SOURCE_HISTORY_STEPS + 49}`);
});
test("source undo history drops old full-note copies past the text budget", () => {
const stack: Array<ReturnType<typeof snapshot>> = [];
const third = Math.floor(MAX_SOURCE_HISTORY_CHARS / 3) + 1;
for (const label of ["a", "b", "c", "d"]) pushSourceHistory(stack, snapshot(label.repeat(third)));
assert.deepEqual(stack.map((entry) => entry.value[0]), ["c", "d"]);
// The newest step always survives, even when it alone is over budget.
pushSourceHistory(stack, snapshot("e".repeat(MAX_SOURCE_HISTORY_CHARS + 1)));
assert.deepEqual(stack.map((entry) => entry.value[0]), ["e"]);
});
test("NoteSourceEditor toolbar undo restores coalesced ordinary typing", async () => {
const actEnvironment = globalThis as typeof globalThis & {
IS_REACT_ACT_ENVIRONMENT?: boolean;
+30 -7
View File
@@ -9,6 +9,29 @@ interface SourceHistorySnapshot {
selectionEnd: number;
}
/** Undo steps kept per note; the oldest steps are dropped first. */
export const MAX_SOURCE_HISTORY_STEPS = 200;
/**
* Every step holds a full copy of the note, so long sessions on a large note
* grew without bound. Also cap the text the steps hold (~16 MB as UTF-16).
*/
export const MAX_SOURCE_HISTORY_CHARS = 8_000_000;
export const pushSourceHistory = <T extends { value: string }>(stack: T[], snapshot: T): void => {
stack.push(snapshot);
let chars = 0;
for (const entry of stack) chars += entry.value.length;
let drop = 0;
while (
stack.length - drop > 1
&& (stack.length - drop > MAX_SOURCE_HISTORY_STEPS || chars > MAX_SOURCE_HISTORY_CHARS)
) {
chars -= stack[drop].value.length;
drop += 1;
}
if (drop > 0) stack.splice(0, drop);
};
export const shouldCoalesceSourceUndoStep = (
previous: { inputType: string; at: number; caret: number } | null,
inputType: string,
@@ -173,7 +196,7 @@ export const NoteSourceEditor = React.forwardRef<NoteSourceEditorHandle, NoteSou
edit.start + edit.removedLength,
);
compositionBaselineRef.current = baseline;
undoStackRef.current.push(baseline);
pushSourceHistory(undoStackRef.current, baseline);
redoStackRef.current = [];
}
if (inputType === "insertFromComposition") compositionBaselineRef.current = null;
@@ -189,7 +212,7 @@ export const NoteSourceEditor = React.forwardRef<NoteSourceEditorHandle, NoteSou
const previousSelectionStart = inputType === "deleteContentBackward"
? edit.start + edit.removedLength
: edit.start;
undoStackRef.current.push(createSnapshot(
pushSourceHistory(undoStackRef.current, createSnapshot(
localValue,
previousSelectionStart,
edit.start + edit.removedLength,
@@ -214,9 +237,9 @@ export const NoteSourceEditor = React.forwardRef<NoteSourceEditorHandle, NoteSou
compositionBaselineRef.current = null;
const current = createSnapshot();
if (action === "undo") {
redoStackRef.current.push(current);
pushSourceHistory(redoStackRef.current, current);
} else {
undoStackRef.current.push(current);
pushSourceHistory(undoStackRef.current, current);
}
setLocalValue(target.value);
onChange(target.value);
@@ -260,7 +283,7 @@ export const NoteSourceEditor = React.forwardRef<NoteSourceEditorHandle, NoteSou
const start = textarea.selectionStart;
const end = textarea.selectionEnd;
const nextValue = `${localValue.substring(0, start)} ${localValue.substring(end)}`;
undoStackRef.current.push(createSnapshot(localValue, start, end));
pushSourceHistory(undoStackRef.current, createSnapshot(localValue, start, end));
redoStackRef.current = [];
compositionBaselineRef.current = null;
resetUserEditCoalescing();
@@ -287,7 +310,7 @@ export const NoteSourceEditor = React.forwardRef<NoteSourceEditorHandle, NoteSou
const end = textarea.selectionEnd;
const result = wrapMarkdownSyntax(localValue, start, end, action);
if (result.text !== localValue) {
undoStackRef.current.push(createSnapshot(localValue, start, end));
pushSourceHistory(undoStackRef.current, createSnapshot(localValue, start, end));
redoStackRef.current = [];
compositionBaselineRef.current = null;
resetUserEditCoalescing();
@@ -350,7 +373,7 @@ export const NoteSourceEditor = React.forwardRef<NoteSourceEditorHandle, NoteSou
if (compositionBaselineRef.current === null) {
const baseline = createSnapshot();
compositionBaselineRef.current = baseline;
undoStackRef.current.push(baseline);
pushSourceHistory(undoStackRef.current, baseline);
redoStackRef.current = [];
}
resetUserEditCoalescing();
@@ -5,6 +5,7 @@ import { renderToStaticMarkup } from 'react-dom/server';
import { JSDOM } from 'jsdom';
import { PluginAuthorRow } from '../../plugins/PluginAuthorRow';
import { I18nProvider } from '../../../application/i18n/I18nProvider';
import { loadLocaleMessages } from '../../../application/i18n/messages';
const entry = { id: 'com.netcatty.starter', name: 'Starter', author: 'Netcatty', version: '0.1.2',
repository: 'binaricat/Netcatty', commit: 'a'.repeat(40), selectionToken: 'b'.repeat(64),
@@ -20,11 +21,13 @@ test('untrusted entry strings and absent imagery show only the author without de
assert.match(html, /&lt;img/);
});
test('installed authors share a compact row with localized sources and full accessible provenance', () => {
test('installed authors share a compact row with localized sources and full accessible provenance', async () => {
const plugin = { id: entry.id, manifest: { publisher: 'An extremely long publisher name' },
source: { id: 'third-party:example', kind: 'third-party', name: 'Community packages', origin: 'https://packages.example.org/catalog' },
} as NetcattyInstalledPlugin;
for (const [locale, label] of [['en', 'Third-party'], ['zh-CN', '第三方']]) {
// Static rendering runs no effects, so load the dictionary explicitly.
await loadLocaleMessages(locale);
const dom = new JSDOM(renderToStaticMarkup(<I18nProvider locale={locale}><PluginAuthorRow plugin={plugin} showSource /></I18nProvider>));
const row = dom.window.document.querySelector('[data-plugin-author-row]')!;
const source = row.querySelector<HTMLElement>('[data-plugin-source]')!;
+2 -2
View File
@@ -302,7 +302,7 @@ export const useSftpPaneDialogs = ({
} finally {
setIsDeleting(false);
}
}, [isDeleting, onClearSelection, onDeleteFilesAtPath, onMutateSuccess, t]);
}, [isDeleting, onClearSelection, onDeleteFilesAtPath, onMutateSuccess, setShowDeleteConfirm, t]);
// entryPath is the full path; renameName is initialized to the basename
const openRenameDialog = useCallback((entryPath: string) => {
@@ -342,7 +342,7 @@ export const useSftpPaneDialogs = ({
setDeleteError(null);
setDeleteTargets(names);
setShowDeleteConfirm(true);
}, []);
}, [setShowDeleteConfirm]);
return {
showHostPicker,
@@ -15,6 +15,7 @@ import { Tooltip, TooltipContent, TooltipTrigger } from '../ui/tooltip';
import { TerminalAuthDialog, TerminalAuthDialogProps } from './TerminalAuthDialog';
import { TerminalConnectionProgress, TerminalConnectionProgressProps } from './TerminalConnectionProgress';
import { HostKeyInfo, TerminalHostKeyVerification } from './TerminalHostKeyVerification';
import { useAnimatedConnectionProgress } from './connectionTicker';
import {
resolveDisconnectedDialogTerminalRoot,
restoreTerminalFocusFromDisconnectedDialog,
@@ -35,6 +36,8 @@ export interface TerminalConnectionDialogProps {
restoreState?: 'restored-disconnected';
error: string | null;
progressValue: number;
/** Animate indeterminate progress locally from `progressValue` (terminal connects). */
progressAnimating?: boolean;
chainProgress: ChainProgress | null;
needsAuth: boolean;
showLogs: boolean;
@@ -95,7 +98,8 @@ export const TerminalConnectionDialog: React.FC<TerminalConnectionDialogProps> =
status,
restoreState,
error,
progressValue,
progressValue: progressBaseValue,
progressAnimating = false,
chainProgress,
needsAuth,
showLogs,
@@ -109,6 +113,7 @@ export const TerminalConnectionDialog: React.FC<TerminalConnectionDialogProps> =
progressProps,
}) => {
const { t } = useI18n();
const progressValue = useAnimatedConnectionProgress(progressBaseValue, progressAnimating);
const hasError = Boolean(error);
const isRestoredDisconnected = status === 'disconnected' && restoreState === 'restored-disconnected';
const isConnecting = status === 'connecting';
@@ -5,13 +5,17 @@
import { Loader2, Play } from 'lucide-react';
import React from 'react';
import { useI18n } from '../../application/i18n/I18nProvider';
import { useConnectionCountdownSeconds } from './connectionTicker';
import { Button } from '../ui/button';
import { ScrollArea } from '../ui/scroll-area';
export interface TerminalConnectionProgressProps {
status: 'connecting' | 'connected' | 'disconnected';
error: string | null;
/** Seconds shown before a deadline is known; also used as-is when `deadlineAt` is omitted. */
timeLeft: number;
/** Connection timeout deadline (epoch ms). When provided, counts down locally. */
deadlineAt?: number | null;
isAwaitingUserInput?: boolean;
showEnterReconnectHint?: boolean;
isCancelling: boolean;
@@ -56,7 +60,8 @@ export const TerminalConnectionLogList: React.FC<TerminalConnectionLogListProps>
export const TerminalConnectionProgress: React.FC<TerminalConnectionProgressProps> = ({
status,
error,
timeLeft,
timeLeft: timeLeftProp,
deadlineAt,
isAwaitingUserInput = false,
showEnterReconnectHint = false,
isCancelling: _isCancelling,
@@ -69,6 +74,8 @@ export const TerminalConnectionProgress: React.FC<TerminalConnectionProgressProp
closeLabel,
}) => {
const { t } = useI18n();
const countdownSeconds = useConnectionCountdownSeconds(deadlineAt ?? null, timeLeftProp);
const timeLeft = deadlineAt === undefined ? timeLeftProp : countdownSeconds;
return (
<>
@@ -1,8 +1,10 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { JSDOM } from 'jsdom';
import { loadLocaleMessages } from '../../application/i18n/messages.ts';
test('SFTP menu opens the clicked pane without a selection and is absent without a handler', async () => {
await loadLocaleMessages('zh-CN');
const dom = new JSDOM('<!doctype html><html><body><div id="root"></div></body></html>', {
pretendToBeVisual: true,
url: 'http://localhost',
@@ -1,8 +1,10 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { JSDOM } from 'jsdom';
import { loadLocaleMessages } from '../../application/i18n/messages.ts';
test('split menu keeps custom shortcuts aligned with the matching split actions', async () => {
await loadLocaleMessages('zh-CN');
const dom = new JSDOM('<!doctype html><html><body><div id="root"></div></body></html>', {
pretendToBeVisual: true,
url: 'http://localhost',
+4 -2
View File
@@ -494,7 +494,7 @@ function terminalViewCtxEqual(
}
function TerminalViewInner({ ctx, isPaneMagnified = false }: { ctx: TerminalViewContext; isPaneMagnified?: boolean }) {
const { Activity, Button, Clock3, Copy, Maximize2, Radio, RefreshCcw, SquareArrowOutUpRight, TerminalAutocomplete, TerminalComposeBar, TerminalConnectionDialog, TerminalContextMenu, TerminalSearchBar, Tooltip, TooltipContent, TooltipTrigger, Unplug, ZmodemOverwriteDialog, ZmodemProgressIndicator, auth, autocompleteAcceptTextRef, autocompleteCloseRef, autocompleteHostOs, autocompleteInputRef, autocompleteKeyEventRef, autocompleteRepositionRef, autocompleteSettings, canUpdateHost, chainProgress, cn, compactToolbar, lineTimestampsAvailable, containerRef, effectiveFontSize, effectiveFontWeight, effectiveTerminalProtocol, effectiveTheme, error, executeSnippet, executeSnippetCommand, handleAddSelectionToAI, handleCancelConnect, handleCloseDisconnectedSession, handleCloseSearch, handleDisconnect, handleDismissDisconnectedDialog, handleDragEnter, handleDragLeave, handleDragOver, handleDrop, handleFindNext, handleFindPrevious, handleHostKeyAddAndContinue, handleHostKeyClose, handleHostKeyContinue, handleOsc52ReadResponse, handleOsc7SetupConfirm, handleOsc7SetupOpenChange, handleReceiveYmodem, handleRetry, handleSearch, handleSendYmodem, handleTopOverlayMouseDownCapture, hasMouseTracking, host, hotkeyScheme, inWorkspace, isBroadcastEnabled, composeSendToAll, onToggleComposeSendTarget, isCancelling, isComposeBarOpen, isConnectionAwaitingUserInput, isDraggingOver, isFocusMode, isFocusedPane, isLocalConnection, remoteDragDropUsesZmodem, isPluginTerminalProviderAvailable, isReconnectActive, isSerialConnection, isSearchOpen, isSupportedOs, isSystemSidebarEligible, isVisible, keyBindings, keys, identities, knownCwdRef, needsHostKeyVerification, onCloseSession, onDetach, onDetachPointerDown, onExpandToFocus, onTogglePaneMagnification, onOpenSystem, onRename, onSplitHorizontal, onSplitVertical, onToggleBroadcast, onUpdateHost, osc52ReadPromptVisible, osc7SetupOpen, osc7SetupRunning, passwordPromptActiveRef, pendingHostKeyInfo, progressLogs, progressValue, renderControls, resolvedFontFamily, restoreState, scriptExecutionOverlay, searchMatchCount, searchFocusToken, sessionDisplayName, sessionId, workspaceId, sessionRef, setIsComposeBarOpen, setShowLogs, shouldShowConnectionDialog, showDisconnectedTerminalNotice, showConnectionControls, showLogs, showSelectionAIAction, isRestoringSelectionRef, snippets, status, sudoHintRef, sudoHintText, passwordPickerState, onPasswordPickerSelect, passwordPickerTitle, passwordPickerEmptyText, t, termRef, terminalContextActions, terminalCwdTracker, terminalPreviewVars, terminalSettings, terminalReconnectAvailable, reconnectNoticeMessage, timeLeft, toast, zmodem } = ctx;
const { Activity, Button, Clock3, Copy, Maximize2, Radio, RefreshCcw, SquareArrowOutUpRight, TerminalAutocomplete, TerminalComposeBar, TerminalConnectionDialog, TerminalContextMenu, TerminalSearchBar, Tooltip, TooltipContent, TooltipTrigger, Unplug, ZmodemOverwriteDialog, ZmodemProgressIndicator, auth, autocompleteAcceptTextRef, autocompleteCloseRef, autocompleteHostOs, autocompleteInputRef, autocompleteKeyEventRef, autocompleteRepositionRef, autocompleteSettings, canUpdateHost, chainProgress, cn, compactToolbar, lineTimestampsAvailable, containerRef, effectiveFontSize, effectiveFontWeight, effectiveTerminalProtocol, effectiveTheme, error, executeSnippet, executeSnippetCommand, handleAddSelectionToAI, handleCancelConnect, handleCloseDisconnectedSession, handleCloseSearch, handleDisconnect, handleDismissDisconnectedDialog, handleDragEnter, handleDragLeave, handleDragOver, handleDrop, handleFindNext, handleFindPrevious, handleHostKeyAddAndContinue, handleHostKeyClose, handleHostKeyContinue, handleOsc52ReadResponse, handleOsc7SetupConfirm, handleOsc7SetupOpenChange, handleReceiveYmodem, handleRetry, handleSearch, handleSendYmodem, handleTopOverlayMouseDownCapture, hasMouseTracking, host, hotkeyScheme, inWorkspace, isBroadcastEnabled, composeSendToAll, onToggleComposeSendTarget, isCancelling, isComposeBarOpen, isConnectionAwaitingUserInput, isDraggingOver, isFocusMode, isFocusedPane, isLocalConnection, remoteDragDropUsesZmodem, isPluginTerminalProviderAvailable, isReconnectActive, isSerialConnection, isSearchOpen, isSupportedOs, isSystemSidebarEligible, isVisible, keyBindings, keys, identities, knownCwdRef, needsHostKeyVerification, onCloseSession, onDetach, onDetachPointerDown, onExpandToFocus, onTogglePaneMagnification, onOpenSystem, onRename, onSplitHorizontal, onSplitVertical, onToggleBroadcast, onUpdateHost, osc52ReadPromptVisible, osc7SetupOpen, osc7SetupRunning, passwordPromptActiveRef, pendingHostKeyInfo, progressLogs, progressValue, renderControls, resolvedFontFamily, restoreState, scriptExecutionOverlay, searchMatchCount, searchFocusToken, sessionDisplayName, sessionId, workspaceId, sessionRef, setIsComposeBarOpen, setShowLogs, shouldShowConnectionDialog, showDisconnectedTerminalNotice, showConnectionControls, showLogs, showSelectionAIAction, isRestoringSelectionRef, snippets, status, sudoHintRef, sudoHintText, passwordPickerState, onPasswordPickerSelect, passwordPickerTitle, passwordPickerEmptyText, t, termRef, terminalContextActions, terminalCwdTracker, terminalPreviewVars, terminalSettings, terminalReconnectAvailable, reconnectNoticeMessage, connectionDeadlineAt, connectionTimeoutSeconds, toast, zmodem } = ctx;
// Context menu only needs a snapshot at open; avoid selection state lifting into Terminal.
const [contextMenuHasSelection, setContextMenuHasSelection] = useState(false);
const isNetworkDevice = host.deviceType === 'network'
@@ -1352,6 +1352,7 @@ function TerminalViewInner({ ctx, isPaneMagnified = false }: { ctx: TerminalView
restoreState={restoreState}
error={error}
progressValue={progressValue}
progressAnimating={connectionDeadlineAt != null}
chainProgress={chainProgress}
needsAuth={auth.needsAuth}
showLogs={showLogs}
@@ -1391,7 +1392,8 @@ function TerminalViewInner({ ctx, isPaneMagnified = false }: { ctx: TerminalView
isValid: auth.isValid,
}}
progressProps={{
timeLeft,
timeLeft: connectionTimeoutSeconds,
deadlineAt: connectionDeadlineAt,
isAwaitingUserInput: Boolean(isConnectionAwaitingUserInput),
isCancelling,
progressLogs,
@@ -176,6 +176,7 @@
"Adds a new line to the option without altering any existing values. This is t": "向选项添加新行,而不更改任何现有值",
"Adds read and execute permissions for all classes": "为所有类别添加读和执行权限",
"Adds the user as an owner of the package": "将该用户添加为软件包的所有者",
"Adjust the access and modification time stamps for the file by the specified value": "按指定值调整文件的访问时间和修改时间",
"Advertised address (format: <ip|interface>[:port])": "通告地址(格式:<ip|interface>[:port])",
"Advice on how to set your identity configuration when your information is guessed from the system username and domain name": "当你的身份信息是根据系统用户名和域名猜测而来时,关于如何设置身份配置的建议",
"Advice on what to do when you've accidentally added one git repo inside of another": "当你不慎把一个 git 仓库添加到另一个仓库内部时该如何处理的建议",
@@ -339,6 +340,7 @@
"Attempt three-way merge if a patch does not apply": "若补丁无法应用,则尝试三方合并",
"Attempt to compile using the specified compiler, which should be the name of the compiler's executable": "尝试使用指定的编译器编译,该编译器应为编译器可执行文件的名称",
"Attempt to fix broken packages": "尝试修复损坏的软件包",
"Attempt to force the update, even if the file permissions do not currently permit it": "即使当前文件权限不允许,也尝试强制更新时间",
"Attempt to remove directories as well as other types of files": "尝试同时删除目录以及其他类型的文件",
"Attempt to use AUTH SSL/TLS and encrypted data transfers when connecting via regular FTP protocol. This might be needed if the FTP server requires it for security reasons or you wish to connect securely whenever remote FTP server supports it. Default is false since it might trigger certificate verification errors on misconfigured servers": "通过常规 FTP 协议连接时,尝试使用 AUTH SSL/TLS 和加密数据传输。若 FTP 服务器出于安全原因要求如此,或你希望在远程 FTP 服务器支持时始终安全连接,则可能需要此项。默认为 false,因为它可能在配置错误的服务器上触发证书验证错误",
"Audible. Include a bell (ASCII 0x07) character in the output when any packet is received. This option is ignored if other format options are present": "可听。收到任何数据包时在输出中包含响铃字符(ASCII 0x07)。若存在其他格式选项,则忽略此选项",
@@ -508,6 +510,7 @@
"Change Git configuration": "更改 Git 配置",
"Change daemon’s gid before entering the service loop. The value of this option is given to getgrnam(3) and numeric IDs are not supported": "进入服务循环前更改守护进程的 gid。此选项的值会传给 getgrnam(3),不支持数字 ID",
"Change daemon’s uid and gid before entering the service loop. When only --user is given without --group, the primary group ID for the user is used. The values of the option are given to getpwnam(3) and getgrnam(3) and numeric IDs are not supported": "进入服务循环前更改守护进程的 uid 和 gid。仅给出 --user 而未给出 --group 时,使用该用户的主组 ID。选项的值会传给 getpwnam(3) 和 getgrnam(3),不支持数字 ID",
"Change file access and modification times": "更改文件的访问时间和修改时间",
"Change file modes or Access Control Lists": "更改文件模式或访问控制列表",
"Change format of printed timestamps": "更改所打印时间戳的格式",
"Change journal output mode": "更改 journal 输出模式",
@@ -515,7 +518,10 @@
"Change settings for a plugin": "更改插件的设置",
"Change settings on your registry profile": "更改注册表个人资料上的设置",
"Change tape after writing Nx1024 bytes": "写入 Nx1024 字节后更换磁带",
"Change the access and modification times to the specified time instead of the current time of day": "将访问时间和修改时间设为指定时间,而不是当前时间",
"Change the access time of the file": "仅修改文件的访问时间",
"Change the modes of the file hierarchies rooted in the files, instead of just the files themselves. Beware of unintentionally matching the ``..'' hard link to the parent directory when using wildcards like ``.*''": "更改以这些文件为根的文件层次的模式,而不仅仅是这些文件本身。使用 ``.*'' 之类的通配符时,请注意不要无意中匹配到指向父目录的 ``..'' 硬链接",
"Change the modification time of the file": "仅修改文件的修改时间",
"Change the shell working directory": "更改 shell 工作目录",
"Change the user ID and/or the group ID for the file hierarchies rooted in the files instead of just the files themselves": "更改以这些文件为根的文件层次的用户 ID 和/或组 ID,而不仅仅是这些文件本身",
"Change the user and/or group ownership of a given file, directory, or symbolic link": "更改给定文件、目录或符号链接的用户和/或组所有权",
@@ -1020,6 +1026,7 @@
"Do not clone only the history leading to the tip of a single branch, either specified by the --branch option or the primary branch remote’s HEAD points at. Further fetches into the resulting repository will only update the remote-tracking branch for the branch this option was used for the initial cloning. If the HEAD at the remote did not point at any branch when --single-branch clone was made, no remote-tracking branch is created": "不只克隆通向单个分支顶端的历史,该分支由 --branch 选项指定,或是远程 HEAD 所指向的主分支。之后对该仓库的获取,只会更新最初克隆时使用了此选项的那个分支的远程跟踪分支。若做 --single-branch 克隆时远程 HEAD 未指向任何分支,则不会创建远程跟踪分支",
"Do not copy Extended Attributes (EAs) or resource forks": "不复制扩展属性(EA)或资源叉",
"Do not create a commit, but show a list of paths that are to be committed, pa": "不创建提交,只显示将要提交的路径列表",
"Do not create the file if it does not exist": "文件不存在时不创建文件",
"Do not delete untagged parents": "不删除未加标签的父项",
"Do not detect renames": "不检测重命名",
"Do not display a diagnostic message if chmod could not modify the mode for file, nor modify the exit status to reflect such failures": "若 chmod 无法修改文件的模式,则不显示诊断消息,也不因此修改退出状态",
@@ -1661,6 +1668,7 @@
"If the destination file cannot be opened, remove it and create a new file, without prompting for confirmation": "若无法打开目标文件,则删除它并创建新文件,且不提示确认",
"If the file gc.log exists, then `git gc --auto` will print its content and exit with status zero instead of running unless that file is more than 'gc.logExpiry' old. Default is \"1.day\". See `gc.pruneExpire` for more ways to specify its value": "若存在文件 gc.log,则 `git gc --auto` 会打印其内容并以状态零退出,而不会运行,除非该文件的存在时间超过 'gc.logExpiry'。默认值为 \"1.day\"。指定该值的更多方式见 `gc.pruneExpire`",
"If the file is a symbolic link, change the mode of the link itself rather than the file that the link points to": "若文件是符号链接,则更改链接本身的模式,而不是链接所指向的文件",
"If the file is a symbolic link, change the times of the link itself rather than the file that the link points to": "如果文件是符号链接,则修改链接本身的时间,而不是链接所指向文件的时间",
"If the file is a symbolic link, change the user ID and/or the group ID of the link itself": "若文件是符号链接,则更改链接本身的用户 ID 和/或组 ID",
"If the fix argument is provided, then remediations will be applied to the package tree": "若提供了 fix 参数,则会对软件包树应用修复",
"If the number of objects fetched over the Git native transfer is below this limit, then the objects will be unpacked into loose object files. However if the number of received objects equals or exceeds this limit then the received pack will be stored as a pack, after adding any missing delta bases. Storing the pack from a push can make the push operation complete faster, especially on slow filesystems. If not set, the value of `transfer.unpackLimit` is used instead": "若通过 Git 原生传输获取的对象数低于此限制,则这些对象会解包为松散对象文件。若接收的对象数达到或超过此限制,则在补上缺失的 delta 基对象后,将接收到的包存储为包。存储来自推送的包可使推送更快完成,尤其是在较慢的文件系统上。若未设置,则改用 `transfer.unpackLimit` 的值",
@@ -4382,6 +4390,7 @@
"Use the POST method; send STRING as the data": "使用 POST 方法;将 STRING 作为数据发送",
"Use the POST method; send contents of FILE": "使用 POST 方法;发送 FILE 的内容",
"Use the TCP_NODELAY option": "使用 TCP_NODELAY 选项",
"Use the access and modifications times from the specified file instead of the current time of day": "使用指定文件的访问时间和修改时间,而不是当前时间",
"Use the cached list of resources if available": "若可用,则使用缓存的资源列表",
"Use the current environment configuration file only": "仅使用当前环境配置文件",
"Use the following IP address as the source address in outgoing packets. On hosts with more than one IP address, this option can be used to force the source address to be something other than the IP address of the interface the probe packet is sent on. If the IP address is not one of this machine's interface addresses, an error is returned and nothing is sent": "使用以下 IP 地址作为出站数据包的源地址。在拥有多个 IP 地址的主机上,此选项可将源地址强制为探测包所发送接口的 IP 地址以外的地址。若该 IP 地址不是本机任一接口地址,则返回错误且不发送任何内容",
@@ -4632,7 +4641,9 @@
"You can set this to 'link', in which case a hardlink followed by a delete of the source are used to make sure that object creation will not overwrite existing objects": "可将其设为 'link',此时会先创建硬链接再删除源,以确保创建对象时不会覆盖现有对象",
"You can use the --if-present flag to avoid exiting with a non-zero exit code when the script is undefined. This lets you run potentially undefined scripts without breaking the execution chain": "可使用 --if-present 标志,以避免在脚本未定义时以非零退出码退出。这样你可以运行可能未定义的脚本,而不中断执行链",
"You may specify up to 16 'pad' bytes to fill out the packet you send. This is useful for diagnosing data-dependent problems in a network. For example, '-p ff' will cause the sent packet to be filled with all ones": "最多可以指定 16 个 'pad' 字节来填充所发送的数据包。这有助于诊断网络中与数据相关的问题。例如,'-p ff' 会使发送的数据包全部填充为 1",
"[-][[hh]mm]SS": "[-][[hh]mm]SS(时间格式)",
"[EXPERIMENTAL] A boolean that enables branching support when using `--recurse-submodules` or `submodule.recurse=true`. Enabling this will allow certain commands to accept `--recurse-submodules` and certain commands that already accept `--recurse-submodules` will now consider branches. Defaults to false": "[实验性] 一个布尔值,在使用 `--recurse-submodules` 或 `submodule.recurse=true` 时启用分支支持。启用后,某些命令将接受 `--recurse-submodules`,而已接受 `--recurse-submodules` 的某些命令将会考虑分支。默认为 false",
"[[CC]YY]MMDDhhmm[.SS]": "[[CC]YY]MMDDhhmm[.SS](时间格式)",
"[experimental] selection of layers to catalog": "[实验性] 要编目的层选择",
"[protocol://]host[:port] Use this proxy": "[protocol://]host[:port] 使用此代理",
"[protocol://]host[:port] Use this proxy first": "[protocol://]host[:port] 优先使用此代理",
@@ -176,6 +176,7 @@
"Adds a new line to the option without altering any existing values. This is t": "向選項新增新行,而不更改任何現有值",
"Adds read and execute permissions for all classes": "為所有類別新增讀和執行許可權",
"Adds the user as an owner of the package": "將該使用者新增為軟體包的所有者",
"Adjust the access and modification time stamps for the file by the specified value": "按指定值調整檔案的訪問時間和修改時間",
"Advertised address (format: <ip|interface>[:port])": "通告地址(格式:<ip|interface>[:port])",
"Advice on how to set your identity configuration when your information is guessed from the system username and domain name": "當你的身份資訊是根據系統使用者名稱和域名猜測而來時,關於如何設定身份配置的建議",
"Advice on what to do when you've accidentally added one git repo inside of another": "當你不慎把一個 git 倉庫新增到另一個倉庫內部時該如何處理的建議",
@@ -339,6 +340,7 @@
"Attempt three-way merge if a patch does not apply": "若補丁無法應用,則嘗試三方合併",
"Attempt to compile using the specified compiler, which should be the name of the compiler's executable": "嘗試使用指定的編譯器編譯,該編譯器應為編譯器執行檔的名稱",
"Attempt to fix broken packages": "嘗試修復損壞的軟體包",
"Attempt to force the update, even if the file permissions do not currently permit it": "即使當前檔案許可權不允許,也嘗試強制更新時間",
"Attempt to remove directories as well as other types of files": "嘗試同時刪除目錄以及其他型別的檔案",
"Attempt to use AUTH SSL/TLS and encrypted data transfers when connecting via regular FTP protocol. This might be needed if the FTP server requires it for security reasons or you wish to connect securely whenever remote FTP server supports it. Default is false since it might trigger certificate verification errors on misconfigured servers": "通過常規 FTP 協議連線時,嘗試使用 AUTH SSL/TLS 和加密資料傳輸。若 FTP 伺服器出於安全原因要求如此,或你希望在遠端 FTP 伺服器支援時始終安全連線,則可能需要此項。預設為 false,因為它可能在配置錯誤的伺服器上觸發證書驗證錯誤",
"Audible. Include a bell (ASCII 0x07) character in the output when any packet is received. This option is ignored if other format options are present": "可聽。收到任何資料包時在輸出中包含響鈴字元(ASCII 0x07)。若存在其他格式選項,則忽略此選項",
@@ -508,6 +510,7 @@
"Change Git configuration": "更改 Git 配置",
"Change daemon’s gid before entering the service loop. The value of this option is given to getgrnam(3) and numeric IDs are not supported": "進入服務迴圈前更改守護行程的 gid。此選項的值會傳給 getgrnam(3),不支援數字 ID",
"Change daemon’s uid and gid before entering the service loop. When only --user is given without --group, the primary group ID for the user is used. The values of the option are given to getpwnam(3) and getgrnam(3) and numeric IDs are not supported": "進入服務迴圈前更改守護行程的 uid 和 gid。僅給出 --user 而未給出 --group 時,使用該使用者的主組 ID。選項的值會傳給 getpwnam(3) 和 getgrnam(3),不支援數字 ID",
"Change file access and modification times": "更改檔案的訪問時間和修改時間",
"Change file modes or Access Control Lists": "更改檔案模式或訪問控制列表",
"Change format of printed timestamps": "更改所列印時間戳的格式",
"Change journal output mode": "更改 journal 輸出模式",
@@ -515,7 +518,10 @@
"Change settings for a plugin": "更改外掛的設定",
"Change settings on your registry profile": "更改登錄檔個人資料上的設定",
"Change tape after writing Nx1024 bytes": "寫入 Nx1024 位元組後更換磁帶",
"Change the access and modification times to the specified time instead of the current time of day": "將訪問時間和修改時間設為指定時間,而不是當前時間",
"Change the access time of the file": "僅修改檔案的訪問時間",
"Change the modes of the file hierarchies rooted in the files, instead of just the files themselves. Beware of unintentionally matching the ``..'' hard link to the parent directory when using wildcards like ``.*''": "更改以這些檔案為根的檔案層次的模式,而不僅僅是這些檔案本身。使用 ``.*'' 之類的萬用字元時,請注意不要無意中匹配到指向父目錄的 ``..'' 硬連結",
"Change the modification time of the file": "僅修改檔案的修改時間",
"Change the shell working directory": "更改 shell 工作目錄",
"Change the user ID and/or the group ID for the file hierarchies rooted in the files instead of just the files themselves": "更改以這些檔案為根的檔案層次的使用者 ID 和/或組 ID,而不僅僅是這些檔案本身",
"Change the user and/or group ownership of a given file, directory, or symbolic link": "更改給定檔案、目錄或符號連結的使用者和/或組所有權",
@@ -1020,6 +1026,7 @@
"Do not clone only the history leading to the tip of a single branch, either specified by the --branch option or the primary branch remote’s HEAD points at. Further fetches into the resulting repository will only update the remote-tracking branch for the branch this option was used for the initial cloning. If the HEAD at the remote did not point at any branch when --single-branch clone was made, no remote-tracking branch is created": "不只克隆通向單個分支頂端的歷史,該分支由 --branch 選項指定,或是遠端 HEAD 所指向的主分支。之後對該倉庫的獲取,只會更新最初克隆時使用了此選項的那個分支的遠端跟蹤分支。若做 --single-branch 克隆時遠端 HEAD 未指向任何分支,則不會建立遠端跟蹤分支",
"Do not copy Extended Attributes (EAs) or resource forks": "不復制擴充套件屬性(EA)或資源叉",
"Do not create a commit, but show a list of paths that are to be committed, pa": "不建立提交,只顯示將要提交的路徑列表",
"Do not create the file if it does not exist": "檔案不存在時不建立檔案",
"Do not delete untagged parents": "不刪除未加標籤的父項",
"Do not detect renames": "不檢測重新命名",
"Do not display a diagnostic message if chmod could not modify the mode for file, nor modify the exit status to reflect such failures": "若 chmod 無法修改檔案的模式,則不顯示診斷訊息,也不因此修改退出狀態",
@@ -1661,6 +1668,7 @@
"If the destination file cannot be opened, remove it and create a new file, without prompting for confirmation": "若無法開啟目標檔案,則刪除它並建立新檔案,且不提示確認",
"If the file gc.log exists, then `git gc --auto` will print its content and exit with status zero instead of running unless that file is more than 'gc.logExpiry' old. Default is \"1.day\". See `gc.pruneExpire` for more ways to specify its value": "若存在檔案 gc.log,則 `git gc --auto` 會列印其內容並以狀態零退出,而不會執行,除非該檔案的存在時間超過 'gc.logExpiry'。預設值為 \"1.day\"。指定該值的更多方式見 `gc.pruneExpire`",
"If the file is a symbolic link, change the mode of the link itself rather than the file that the link points to": "若檔案是符號連結,則更改連結本身的模式,而不是連結所指向的檔案",
"If the file is a symbolic link, change the times of the link itself rather than the file that the link points to": "如果檔案是符號連結,則修改連結本身的時間,而不是連結所指向檔案的時間",
"If the file is a symbolic link, change the user ID and/or the group ID of the link itself": "若檔案是符號連結,則更改連結本身的使用者 ID 和/或組 ID",
"If the fix argument is provided, then remediations will be applied to the package tree": "若提供了 fix 引數,則會對軟體包樹應用修復",
"If the number of objects fetched over the Git native transfer is below this limit, then the objects will be unpacked into loose object files. However if the number of received objects equals or exceeds this limit then the received pack will be stored as a pack, after adding any missing delta bases. Storing the pack from a push can make the push operation complete faster, especially on slow filesystems. If not set, the value of `transfer.unpackLimit` is used instead": "若通過 Git 原生傳輸獲取的物件數低於此限制,則這些物件會解包為鬆散物件檔案。若接收的物件數達到或超過此限制,則在補上缺失的 delta 基物件後,將接收到的包儲存為包。儲存來自推送的包可使推送更快完成,尤其是在較慢的檔案系統上。若未設定,則改用 `transfer.unpackLimit` 的值",
@@ -4382,6 +4390,7 @@
"Use the POST method; send STRING as the data": "使用 POST 方法;將 STRING 作為資料傳送",
"Use the POST method; send contents of FILE": "使用 POST 方法;傳送 FILE 的內容",
"Use the TCP_NODELAY option": "使用 TCP_NODELAY 選項",
"Use the access and modifications times from the specified file instead of the current time of day": "使用指定檔案的訪問時間和修改時間,而不是當前時間",
"Use the cached list of resources if available": "若可用,則使用快取的資源列表",
"Use the current environment configuration file only": "僅使用當前環境配置檔案",
"Use the following IP address as the source address in outgoing packets. On hosts with more than one IP address, this option can be used to force the source address to be something other than the IP address of the interface the probe packet is sent on. If the IP address is not one of this machine's interface addresses, an error is returned and nothing is sent": "使用以下 IP 地址作為出站資料包的源地址。在擁有多個 IP 地址的主機上,此選項可將源地址強制為探測包所傳送介面的 IP 地址以外的地址。若該 IP 地址不是本機任一介面地址,則返回錯誤且不傳送任何內容",
@@ -4632,7 +4641,9 @@
"You can set this to 'link', in which case a hardlink followed by a delete of the source are used to make sure that object creation will not overwrite existing objects": "可將其設為 'link',此時會先建立硬連結再刪除源,以確保建立物件時不會覆蓋現有物件",
"You can use the --if-present flag to avoid exiting with a non-zero exit code when the script is undefined. This lets you run potentially undefined scripts without breaking the execution chain": "可使用 --if-present 標誌,以避免在指令碼未定義時以非零退出碼退出。這樣你可以執行可能未定義的指令碼,而不中斷執行鏈",
"You may specify up to 16 'pad' bytes to fill out the packet you send. This is useful for diagnosing data-dependent problems in a network. For example, '-p ff' will cause the sent packet to be filled with all ones": "最多可以指定 16 個 'pad' 位元組來填充所傳送的資料包。這有助於診斷網路中與資料相關的問題。例如,'-p ff' 會使傳送的資料包全部填充為 1",
"[-][[hh]mm]SS": "[-][[hh]mm]SS(時間格式)",
"[EXPERIMENTAL] A boolean that enables branching support when using `--recurse-submodules` or `submodule.recurse=true`. Enabling this will allow certain commands to accept `--recurse-submodules` and certain commands that already accept `--recurse-submodules` will now consider branches. Defaults to false": "[實驗性] 一個布林值,在使用 `--recurse-submodules` 或 `submodule.recurse=true` 時啟用分支支援。啟用後,某些命令將接受 `--recurse-submodules`,而已接受 `--recurse-submodules` 的某些命令將會考慮分支。預設為 false",
"[[CC]YY]MMDDhhmm[.SS]": "[[CC]YY]MMDDhhmm[.SS](時間格式)",
"[experimental] selection of layers to catalog": "[實驗性] 要編目的層選擇",
"[protocol://]host[:port] Use this proxy": "[protocol://]host[:port] 使用此代理",
"[protocol://]host[:port] Use this proxy first": "[protocol://]host[:port] 優先使用此代理",
@@ -34,6 +34,33 @@ const dockerSpec: FigSpec = {
],
};
const TOUCH = {
command: "Change file access and modification times",
adjust: "Adjust the access and modification time stamps for the file by the specified value",
access: "Change the access time of the file",
noCreate: "Do not create the file if it does not exist",
force: "Attempt to force the update, even if the file permissions do not currently permit it",
link: "If the file is a symbolic link, change the times of the link itself rather than the file that the link points to",
modify: "Change the modification time of the file",
reference: "Use the access and modifications times from the specified file instead of the current time of day",
timestamp: "Change the access and modification times to the specified time instead of the current time of day",
} as const;
const touchSpec: FigSpec = {
name: "touch",
description: TOUCH.command,
options: [
{ name: "-A", description: TOUCH.adjust },
{ name: "-a", description: TOUCH.access },
{ name: "-c", description: TOUCH.noCreate },
{ name: "-f", description: TOUCH.force },
{ name: "-h", description: TOUCH.link },
{ name: "-m", description: TOUCH.modify },
{ name: "-r", description: TOUCH.reference },
{ name: "-t", description: TOUCH.timestamp },
],
};
Object.defineProperty(globalThis, "localStorage", {
value: {
getItem: () => null,
@@ -47,8 +74,11 @@ Object.defineProperty(globalThis, "localStorage", {
Object.defineProperty(globalThis, "window", {
value: {
netcatty: {
listFigSpecs: async () => ["docker"],
loadFigSpec: async (commandName: string) => commandName === "docker" ? dockerSpec : null,
listFigSpecs: async () => ["docker", "touch"],
loadFigSpec: async (commandName: string) => ({
docker: dockerSpec,
touch: touchSpec,
} as Record<string, FigSpec>)[commandName] ?? null,
},
},
configurable: true,
@@ -124,6 +154,24 @@ test("locales other than zh-CN and zh-TW keep English descriptions", async () =>
assert.equal(dockerSpec.subcommands?.[0].options?.[0].description, SINCE);
});
test("zh-CN translates touch options without changing the command", async () => {
const suggestions = await getCompletions("touch -", {
locale: "zh-CN",
skipPathCompletion: true,
maxResults: 20,
});
assert.equal(suggestionNamed(suggestions, "-A")?.description, "按指定值调整文件的访问时间和修改时间");
assert.equal(suggestionNamed(suggestions, "-a")?.description, "仅修改文件的访问时间");
assert.equal(suggestionNamed(suggestions, "-c")?.description, "文件不存在时不创建文件");
assert.equal(suggestionNamed(suggestions, "-f")?.description, "即使当前文件权限不允许,也尝试强制更新时间");
assert.equal(suggestionNamed(suggestions, "-h")?.description, "如果文件是符号链接,则修改链接本身的时间,而不是链接所指向文件的时间");
assert.equal(suggestionNamed(suggestions, "-m")?.description, "仅修改文件的修改时间");
assert.equal(suggestionNamed(suggestions, "-r")?.description, "使用指定文件的访问时间和修改时间,而不是当前时间");
assert.equal(suggestionNamed(suggestions, "-t")?.description, "将访问时间和修改时间设为指定时间,而不是当前时间");
assert.equal(touchSpec.options?.[0].description, TOUCH.adjust);
});
test("zh-CN and zh-TW overlays do not leak across later lookups", async () => {
const traditional = await getCompletions("docker logs --", {
locale: "zh-TW",
+18 -1
View File
@@ -5,7 +5,9 @@ import test from "node:test";
import {
CONNECTION_PROGRESS_CAP,
CONNECTION_PROGRESS_START,
MAX_CONNECTION_PROGRESS_LOGS,
advanceIndeterminateConnectionProgress,
capConnectionProgressLogs,
advanceMonotonicConnectionProgress,
resolveHopConnectionProgress,
} from "./connectionProgress";
@@ -39,7 +41,12 @@ test("connecting seeds progress once and hop/phase changes do not snap it back",
effectsSource,
/if \(status === "connecting"\) \{\s*setIsDisconnectedDialogDismissed\(false\);\s*setProgressValue\(CONNECTION_PROGRESS_START\);/,
);
assert.match(effectsSource, /setProgressValue\(advanceIndeterminateConnectionProgress\)/);
// The indeterminate animation and countdown tick inside the connection
// dialog (connectionTicker.ts); Terminal only publishes the deadline.
assert.doesNotMatch(effectsSource, /setProgressValue\(advanceIndeterminateConnectionProgress\)/);
assert.match(effectsSource, /setConnectionDeadlineAt\(Date\.now\(\) \+ connectionTimeout\)/);
const tickerSource = readFileSync(new URL("./connectionTicker.ts", import.meta.url), "utf8");
assert.match(tickerSource, /setValue\(advanceIndeterminateConnectionProgress\)/);
assert.doesNotMatch(effectsSource, /setProgressValue\(5\)/);
const starterSource = readFileSync(
@@ -49,3 +56,13 @@ test("connecting seeds progress once and hop/phase changes do not snap it back",
assert.match(starterSource, /advanceMonotonicConnectionProgress/);
assert.match(starterSource, /resolveHopConnectionProgress/);
});
test("connection progress logs keep only the newest lines", () => {
const short = ["a", "b"];
assert.equal(capConnectionProgressLogs(short), short);
const long = Array.from({ length: MAX_CONNECTION_PROGRESS_LOGS + 5 }, (_, i) => `line ${i}`);
const capped = capConnectionProgressLogs(long);
assert.equal(capped.length, MAX_CONNECTION_PROGRESS_LOGS);
assert.equal(capped[0], "line 5");
assert.equal(capped.at(-1), `line ${MAX_CONNECTION_PROGRESS_LOGS + 4}`);
});
@@ -16,3 +16,9 @@ export const resolveHopConnectionProgress = (hop: number, total: number): number
export const advanceMonotonicConnectionProgress = (prev: number, next: number): number =>
Math.max(prev, next);
/** Connection progress lines kept per terminal (newest win). */
export const MAX_CONNECTION_PROGRESS_LOGS = 300;
export const capConnectionProgressLogs = (logs: string[]): string[] =>
logs.length > MAX_CONNECTION_PROGRESS_LOGS ? logs.slice(-MAX_CONNECTION_PROGRESS_LOGS) : logs;
@@ -0,0 +1,86 @@
import assert from 'node:assert/strict';
import { mock, test } from 'node:test';
import React from 'react';
import { act, create } from 'react-test-renderer';
import { CONNECTION_PROGRESS_CAP } from './connectionProgress';
import { useAnimatedConnectionProgress, useConnectionCountdownSeconds } from './connectionTicker';
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
function renderHookValue<P>(useValue: (props: P) => number, initialProps: P) {
const result = { current: Number.NaN, renders: 0 };
function Probe(props: P) {
result.current = useValue(props);
result.renders += 1;
return null;
}
let renderer!: ReturnType<typeof create>;
act(() => {
renderer = create(React.createElement(Probe as React.FC<P>, initialProps as P & React.Attributes));
});
return {
result,
rerender(props: P) {
act(() => {
renderer.update(React.createElement(Probe as React.FC<P>, props as P & React.Attributes));
});
},
unmount() {
act(() => renderer.unmount());
},
};
}
test('animated connection progress advances only while animating and follows the base', () => {
mock.timers.enable({ apis: ['setInterval'] });
try {
const hook = renderHookValue(
({ base, animating }: { base: number; animating: boolean }) => useAnimatedConnectionProgress(base, animating),
{ base: 5, animating: false },
);
act(() => mock.timers.tick(1000));
assert.equal(hook.result.current, 5, 'no animation while the timeout is not running');
hook.rerender({ base: 5, animating: true });
act(() => mock.timers.tick(200));
const afterOneTick = hook.result.current;
assert.ok(afterOneTick > 5);
act(() => mock.timers.tick(10_000));
assert.ok(hook.result.current <= CONNECTION_PROGRESS_CAP);
// A hop event raises the base above the animation: adopt it.
hook.rerender({ base: 100, animating: false });
assert.equal(hook.result.current, 100);
// A new attempt restarts from a lower base.
hook.rerender({ base: 5, animating: true });
assert.equal(hook.result.current, 5);
hook.unmount();
} finally {
mock.timers.reset();
}
});
test('connection countdown ticks from the deadline and freezes when the timer stops', () => {
mock.timers.enable({ apis: ['setInterval', 'Date'], now: 1_000_000 });
try {
const hook = renderHookValue(
({ deadlineAt }: { deadlineAt: number | null }) => useConnectionCountdownSeconds(deadlineAt, 30),
{ deadlineAt: null },
);
assert.equal(hook.result.current, 30, 'initial seconds before a deadline exists');
hook.rerender({ deadlineAt: 1_000_000 + 20_000 });
assert.equal(hook.result.current, 20);
act(() => mock.timers.tick(3000));
assert.equal(hook.result.current, 17);
hook.rerender({ deadlineAt: null });
act(() => mock.timers.tick(5000));
assert.equal(hook.result.current, 17, 'stopped timer keeps the last value');
hook.unmount();
} finally {
mock.timers.reset();
}
});
+55
View File
@@ -0,0 +1,55 @@
import { useEffect, useRef, useState } from 'react';
import { advanceIndeterminateConnectionProgress } from './connectionProgress';
/**
* Indeterminate connection progress, animated locally by the connection dialog.
*
* The 200 ms animation used to live in Terminal state, which re-rendered the
* whole Terminal + TerminalView (~45 ms) five times a second for every
* connecting session, hidden tabs and startup restores included. Terminal now
* only publishes real progress events (`baseValue`); this hook animates from
* there while `animating` and never moves backwards, except when the base
* itself drops (a new connection attempt restarts the bar).
*/
export function useAnimatedConnectionProgress(baseValue: number, animating: boolean): number {
const [value, setValue] = useState(baseValue);
const lastBaseRef = useRef(baseValue);
useEffect(() => {
const previousBase = lastBaseRef.current;
lastBaseRef.current = baseValue;
setValue((current) => (baseValue < previousBase ? baseValue : Math.max(current, baseValue)));
}, [baseValue]);
useEffect(() => {
if (!animating) return undefined;
const timer = setInterval(() => {
setValue(advanceIndeterminateConnectionProgress);
}, 200);
return () => clearInterval(timer);
}, [animating]);
return value;
}
const secondsUntil = (deadlineAt: number): number =>
Math.max(0, Math.ceil((deadlineAt - Date.now()) / 1000));
/**
* Seconds left until `deadlineAt`, ticking locally once a second. With no
* deadline (timer stopped, e.g. while waiting for user input) the last value
* is kept, matching the countdown that used to stop with its interval.
*/
export function useConnectionCountdownSeconds(deadlineAt: number | null, initialSeconds: number): number {
const [seconds, setSeconds] = useState(() => (deadlineAt == null ? initialSeconds : secondsUntil(deadlineAt)));
useEffect(() => {
if (deadlineAt == null) return undefined;
setSeconds(secondsUntil(deadlineAt));
const timer = setInterval(() => setSeconds(secondsUntil(deadlineAt)), 1000);
return () => clearInterval(timer);
}, [deadlineAt]);
return seconds;
}
+396
View File
@@ -1,4 +1,5 @@
import test from "node:test";
import * as pty from "node-pty";
import assert from "node:assert/strict";
import { execFileSync, spawnSync } from "node:child_process";
import {
@@ -17,10 +18,22 @@ import {
import { tmpdir } from "node:os";
import { basename, join } from "node:path";
import { isIdleShellReadyForCommandInjection } from "../../domain/terminalCommandInjectionReady";
import {
isTerminalReadyForCommandInjection,
registerTerminalCommandInjectionReadyReader,
} from "./runtime/terminalCommandInjectionReadyRegistry";
import {
OSC7_MARKER,
OSC7_SETUP_OTHER_USER_MARKER,
OSC7_SETUP_STAGED_MARKER,
OSC7_PTY_SHELL_MARKER,
OSC7_PTY_ACK_MARKER,
OSC7_PTY_DONE_MARKER,
OSC7_PTY_FAILED_MARKER,
buildOsc7PtyShellProbeCommand,
buildOsc7PtySetupCommands,
buildOsc7ReloadCommand,
buildOsc7SetupCommand,
buildOsc7SetupExecCommand,
@@ -30,6 +43,8 @@ import {
parseOsc7SetupStagedPath,
runOsc7SetupAction,
shouldOfferOsc7SetupAction,
waitForOsc7SessionOutput,
type RunOsc7SetupActionOptions,
} from "./osc7Setup";
const runSetup = (env: NodeJS.ProcessEnv) => {
@@ -193,6 +208,229 @@ test("runOsc7SetupAction stages the script and types a short runner for user-swi
assert.deepEqual(localData, []);
});
// Model separate exec/PTY identities (e.g. Koko) with live output observers.
// Echo input first, then deliver acknowledgements in separate chunks.
const createExit4PtyHarness = (shell: string | null = "bash", completion: "done" | "failed" | null = "done") => {
const writes: string[] = [];
const setupCommands: string[] = [];
const timeouts: number[] = [];
let onData: ((chunk: string) => void) | undefined;
let disposed = 0;
let ready = true;
const options: RunOsc7SetupActionOptions = {
status: "connected",
sessionId: "pty-session",
setupCommand: "exec-only-setup",
setupOsc7Tracking: async (_id, command) => {
setupCommands.push(command);
return { success: false, code: 4, error: "exec identity mismatch" };
},
isTerminalReadyForCommandInjection: () => ready,
waitForSessionOutput: (id, predicate, timeout) => {
timeouts.push(timeout);
return waitForOsc7SessionOutput((observedId, callback) => {
assert.equal(observedId, options.sessionId);
onData = callback;
return () => { onData = undefined; disposed += 1; };
}, id, predicate, 10);
},
writeToSession: (id, command, writeOptions) => {
assert.equal(id, options.sessionId);
assert.equal(writeOptions?.automated, true);
assert.deepEqual(writeOptions?.logRewrite, { sentCommand: command, displayCommand: "" });
assert.ok(onData, "subscribe before writing so fast replies cannot be lost");
writes.push(command);
onData(`prompt> ${command.replace(/\r$/, "\r\n")}`);
assert.ok(onData, "input echo must not satisfy the output predicate");
const final = command.includes("base64 -d");
const marker = writes.length === 1 ? OSC7_PTY_SHELL_MARKER
: !final ? OSC7_PTY_ACK_MARKER
: completion === "failed" ? OSC7_PTY_FAILED_MARKER : OSC7_PTY_DONE_MARKER;
const value = writes.length === 1 ? shell : !final ? String(writes.length - 2) : completion && shell;
if (value) {
onData(`\r\n${marker.slice(0, 12)}`);
onData?.(`${marker.slice(12)}${value}\r`);
assert.ok(onData, "wait for the complete line, not a partial shell name");
onData?.("\n");
}
},
};
return {
options, writes, setupCommands, timeouts,
setReady: (value: boolean) => { ready = value; },
disposed: () => disposed,
};
};
test("exit 4 probes the foreground shell and waits for chunked installation DONE, without exec staging", async () => {
for (const shell of ["bash", "zsh"]) {
const h = createExit4PtyHarness(shell);
const result = await runOsc7SetupAction(h.options);
assert.equal(result.success, true);
assert.equal(result.sentToTerminal, true);
assert.equal(result.error, undefined);
assert.deepEqual(h.setupCommands, ["exec-only-setup"]);
assert.deepEqual(h.writes, [buildOsc7PtyShellProbeCommand(), ...buildOsc7PtySetupCommands(shell as "bash" | "zsh")]);
assert.match(h.writes[0], /ZSH_VERSION.*BASH_VERSION/);
assert.match(h.writes[1], /mktemp/);
assert.match(h.writes.at(-1)!, new RegExp(`NETCATTY_OSC7_FORCE_SHELL=${shell}`));
for (const command of h.writes) assert.ok(Buffer.byteLength(command) < 3000);
assert.deepEqual(h.timeouts, h.writes.map((_, i) => i === h.writes.length - 1 ? 20000 : 5000));
assert.equal(h.disposed(), h.writes.length);
}
});
test("exit-4 stderr messages also select the dedicated PTY path", async () => {
for (const stderr of [
"Netcatty OSC 7 setup: could not identify the active terminal shell",
"Netcatty OSC 7 setup: active terminal shell did not match the current tab",
]) {
const h = createExit4PtyHarness();
h.options.setupOsc7Tracking = async () => ({ success: false, stderr: `${stderr}\n` });
assert.equal((await runOsc7SetupAction(h.options)).success, true);
assert.equal(h.writes.length, 1 + buildOsc7PtySetupCommands("bash").length);
}
});
test("exit 4 fails closed with an unsafe prompt or missing safety callbacks", async () => {
for (const missing of ["unsafe", "isTerminalReadyForCommandInjection", "waitForSessionOutput"] as const) {
const h = createExit4PtyHarness();
if (missing === "unsafe") h.setReady(false);
else delete h.options[missing];
const result = await runOsc7SetupAction(h.options);
assert.equal(result.success, false);
assert.match(result.error || "", /idle bash\/zsh prompt.*empty input/);
assert.deepEqual(h.writes, []);
assert.deepEqual(h.timeouts, []);
}
});
test("exit 4 blocks recalled input, editors, alt-screen and sensitive prompts with an empty command buffer", async () => {
for (const unsafe of [
{ userInputLength: 12 }, // ESC-prefixed history recall need not enter commandBufferRef.
{ isAtPrompt: false },
{ alternateScreenActive: true },
{ sensitiveInputActive: true },
{ hasLiveTerminal: false },
]) {
const h = createExit4PtyHarness();
const unregister = registerTerminalCommandInjectionReadyReader(h.options.sessionId, () =>
isIdleShellReadyForCommandInjection({
hasLiveTerminal: true, isAtPrompt: true, userInputLength: 0,
pendingTypedInputLength: 0, alternateScreenActive: false,
sensitiveInputActive: false, ...unsafe,
}));
h.options.isTerminalReadyForCommandInjection = () => isTerminalReadyForCommandInjection(h.options.sessionId);
try {
assert.equal((await runOsc7SetupAction(h.options)).success, false);
assert.deepEqual(h.writes, []);
} finally {
unregister();
}
assert.equal(isTerminalReadyForCommandInjection(h.options.sessionId), false);
}
});
test("Terminal wires the live command-injection readiness registry into OSC7 setup", () => {
const source = readFileSync(new URL("../Terminal.tsx", import.meta.url), "utf8");
assert.match(source, /runOsc7SetupAction\(\{[^]*?isTerminalReadyForCommandInjection: \(\) => isTerminalReadyForCommandInjection\(sessionId\)/);
});
test("exit 4 refuses installation after probe timeout, unknown shell, or unsupported fish", async () => {
for (const shell of [null, "unknown", "fish"]) {
const h = createExit4PtyHarness(shell);
const result = await runOsc7SetupAction(h.options);
assert.equal(result.success, false);
assert.match(result.error || "", /idle bash\/zsh prompt.*OSC 7 manually/);
assert.equal(h.writes.length, 1);
assert.equal(h.disposed(), 1);
}
});
test("exit 4 rechecks prompt readiness after the probe and between payload chunks", async () => {
for (const stopAfter of [1, 3]) {
const h = createExit4PtyHarness();
const write = h.options.writeToSession;
h.options.writeToSession = (...args) => {
write(...args);
if (h.writes.length === stopAfter) h.setReady(false);
};
const result = await runOsc7SetupAction(h.options);
assert.equal(result.success, false);
assert.equal(h.writes.length, stopAfter);
}
});
test("exit 4 does not claim success when installation times out or reports failure", async () => {
for (const completion of [null, "failed"] as const) {
const h = createExit4PtyHarness("bash", completion);
const result = await runOsc7SetupAction(h.options);
assert.equal(result.success, false);
assert.notEqual(result.sentToTerminal, true);
assert.match(result.error || "", /failed or did not finish/);
assert.equal(h.writes.length, 1 + buildOsc7PtySetupCommands("bash").length);
assert.equal(h.disposed(), h.writes.length);
}
});
test("exit 4 stops on a missing, stale or failed chunk acknowledgement", async () => {
for (const reply of [null, `${OSC7_PTY_ACK_MARKER}0`, `${OSC7_PTY_FAILED_MARKER}bash`]) {
const h = createExit4PtyHarness();
let count = 0;
let observer: ((chunk: string) => void) | undefined;
h.options.waitForSessionOutput = (id, predicate) => waitForOsc7SessionOutput((_id, callback) => {
observer = callback;
return () => { observer = undefined; };
}, id, predicate, 10);
h.options.writeToSession = (id, command, options) => {
count += 1;
if (count === 3) {
observer?.(`prompt> ${command}\r\n`);
if (reply) observer?.(`\r\n${reply}\r\n`);
} else {
observer?.(`prompt> ${command}\r\n`);
observer?.(`\r\n${count === 1 ? OSC7_PTY_SHELL_MARKER + "bash" : OSC7_PTY_ACK_MARKER + "0"}\r\n`);
}
assert.equal(id, h.options.sessionId);
assert.equal(options?.automated, true);
};
const result = await runOsc7SetupAction(h.options);
assert.equal(result.success, false);
assert.equal(count, 3, "no later chunk or final installer after a failed ack");
assert.match(result.error || "", /failed or did not finish/);
}
});
test("unrelated exec failures never select the exit-4 PTY transport", async () => {
for (const code of [1, 2, 3, 5]) {
const h = createExit4PtyHarness();
h.options.setupOsc7Tracking = async () => ({ success: false, code, stderr: "unrelated failure" });
const result = await runOsc7SetupAction(h.options);
assert.equal(result.success, false);
assert.deepEqual(h.writes, []);
assert.deepEqual(h.timeouts, []);
}
});
test("waitForOsc7SessionOutput disposes even if subscription immediately delivers output", async () => {
let disposed = false;
const result = await waitForOsc7SessionOutput((_id, callback) => {
callback("done");
return () => { disposed = true; };
}, "session", (output) => output === "done", 100);
assert.equal(result, "done");
assert.equal(disposed, true);
});
test("exit 4 sends no probe when the output subscription is unavailable", async () => {
const h = createExit4PtyHarness();
h.options.waitForSessionOutput = (id, predicate, timeout) => waitForOsc7SessionOutput(() => {
throw new Error("Session output unavailable");
}, id, predicate, timeout);
await assert.rejects(runOsc7SetupAction(h.options), /Session output unavailable/);
assert.deepEqual(h.writes, []);
});
test("runOsc7SetupAction fails when the setup script cannot be staged", async () => {
const writes: string[] = [];
let calls = 0;
@@ -776,6 +1014,164 @@ const withStagedSetupScript = (fn: (scriptPath: string) => void) => {
}
};
test("PTY builders chunk the exact shared script into lines below the canonical limit", () => {
for (const shell of ["bash", "zsh"] as const) {
const commands = buildOsc7PtySetupCommands(shell);
assert.ok(commands.length > 3);
for (const command of commands) {
assert.ok(command.startsWith(" "));
assert.ok(command.endsWith("\r"));
assert.doesNotMatch(command.slice(0, -1), /[\r\n]/);
assert.ok(Buffer.byteLength(command) < 3000);
}
assert.match(commands[0], /mktemp/);
assert.match(commands.at(-1)!, new RegExp(`NETCATTY_OSC7_FORCE_SHELL=${shell}`));
assert.doesNotMatch(commands.join(""), /NETCATTY_OSC7_EXPECTED_CWD/);
const payload = commands.flatMap((command) => {
const match = command.match(/printf '%s' '([A-Za-z0-9+/=]+)'/);
return match ? [match[1]] : [];
}).join("");
const script = Buffer.from(payload, "base64").toString("utf8");
assert.ok(buildOsc7SetupExecCommand().includes(quoteShellArg(script)), "reuse exact POSIX setup script");
assert.match(script, /netcatty-osc7-version: 2/);
}
});
test("exit-4 transport installs through a real canonical Bash PTY without exceeding its line limit", { timeout: 30000 }, async () => {
const home = mkdtempSync(join(tmpdir(), "netcatty-osc7-canonical-"));
const prompt = "__NETCATTY_TEST_PROMPT__> ";
const terminal = pty.spawn("/bin/bash", ["--noprofile", "--norc", "--noediting", "-i"], {
name: "xterm", cols: 120, rows: 24,
env: { ...process.env, HOME: home, SHELL: "/bin/unknown", PS1: prompt, PS2: "CONTINUATION> ", PROMPT_COMMAND: "", ZDOTDIR: "", XDG_CONFIG_HOME: "" },
});
let screen = "";
let transcript = "";
const capture = terminal.onData((chunk) => { screen += chunk; transcript += chunk; });
const wait = (predicate: (output: string) => boolean, timeout = 5000) =>
waitForOsc7SessionOutput((_id, callback) => {
const listener = terminal.onData(callback);
return () => listener.dispose();
}, "real-pty", predicate, timeout);
try {
assert.ok(await wait((output) => output.endsWith(prompt)), "initial Bash prompt");
// --noediting keeps Bash on the kernel's canonical input path, unlike Readline.
const canonical = wait((output) => output.endsWith(prompt));
screen = "";
terminal.write("stty icanon; stty -a\r");
assert.match(await canonical || "", /(?:^|[;\s])icanon(?:[;\s]|$)/);
const writes: string[] = [];
const result = await runOsc7SetupAction({
status: "connected", sessionId: "real-pty", setupCommand: "exec-only-setup",
setupOsc7Tracking: async () => ({ success: false, code: 4 }),
isTerminalReadyForCommandInjection: () => screen.endsWith(prompt),
waitForSessionOutput: (_id, predicate, timeout) => wait(predicate, timeout),
writeToSession: (_id, command) => {
assert.ok(Buffer.byteLength(command) < 3000, "each typed line fits canonical input");
writes.push(command);
screen = "";
// Preserve the path for a shell-side cleanup assertion after the final command.
terminal.write(command.includes("base64 -d")
? ` __netcatty_test_file="$__netcatty_osc7_pty_file"; ${command}` : command);
},
});
assert.equal(result.success, true, `${result.error}\n${transcript.slice(-4000)}`);
assert.ok(writes.length > 3);
assert.ok(writes.reduce((total, command) => total + command.length, 0) > 4096);
assert.equal(markerCount(readFileSync(join(home, ".bashrc"), "utf8")), 2);
assert.ok(transcript.includes("\u001b]7;file://"));
assert.match(transcript, new RegExp(`\r?\n${OSC7_PTY_DONE_MARKER}bash\r?\n`));
assert.doesNotMatch(transcript, /CONTINUATION> /);
// A final shell assertion covers cleanup without exec sharing any staging file.
if (!screen.endsWith(prompt)) assert.ok(await wait((output) => output.endsWith(prompt)));
const cleaned = wait((output) => output.includes("\r\nCLEANED\r\n"));
terminal.write("test -n \"$__netcatty_test_file\" && test ! -e \"$__netcatty_test_file\" && test -z \"${__netcatty_osc7_pty_file-}\" && printf '\\n%s\\n' CLEANED\r");
assert.ok(await cleaned, "transport removes the PTY-local file and unsets its path");
} finally {
capture.dispose();
terminal.kill();
rmSync(home, { recursive: true, force: true });
}
});
test("PTY probe recognizes interactive bash and rejects a noninteractive shell", () => {
for (const interactive of [true, false]) {
const args = ["--noprofile", "--norc", ...(interactive ? ["-i"] : []), "-c",
buildOsc7PtyShellProbeCommand().replace(/\r$/, "\n")];
const output = execFileSync("/bin/bash", args, { stdio: "pipe" }).toString("utf8");
assert.match(output, new RegExp(`\n${OSC7_PTY_SHELL_MARKER}${interactive ? "bash" : "unknown"}\n`));
}
});
test("chunked PTY installer runs locally in bash, preserves hooks and stays idempotent with DONE ack", () => {
withTempHome("netcatty-osc7-inline-bash-", (home) => {
const rcPath = join(home, ".bashrc");
writeFileSync(rcPath, 'PROMPT_COMMAND="printf existing-title"\n');
const command = buildOsc7PtySetupCommands("bash").join("").replace(/\r/g, "\n");
for (let run = 0; run < 2; run += 1) {
const output = execFileSync("/bin/bash", ["--noprofile", "--norc", "-i", "-c", command], {
env: { ...process.env, HOME: home, SHELL: "/bin/unknown", ZDOTDIR: "", XDG_CONFIG_HOME: "" },
stdio: "pipe",
}).toString("utf8");
assert.match(output, new RegExp(`\n${OSC7_PTY_DONE_MARKER}bash\n`));
assert.doesNotMatch(output, /__NETCATTY_OSC7_PTY_FAILED__/);
assert.ok(output.includes("\u001b]7;file://"));
const rc = readFileSync(rcPath, "utf8");
assert.equal(markerCount(rc), 2);
assert.match(rc, /netcatty-osc7-version: 2/);
assert.match(rc, /printf existing-title/);
}
});
});
test("chunked PTY installer emits failure instead of DONE when install or reload fails", () => {
withTempHome("netcatty-osc7-inline-fail-", (home) => {
const blockedHome = join(home, "not-a-directory");
writeFileSync(blockedHome, "file");
writeFileSync(join(home, ".bashrc"), "return 1\n");
for (const targetHome of [blockedHome, home]) {
const output = execFileSync("/bin/bash", ["--noprofile", "--norc", "-c",
buildOsc7PtySetupCommands("bash").join("").replace(/\r/g, "\n")], {
env: { ...process.env, HOME: targetHome, ZDOTDIR: "", XDG_CONFIG_HOME: "" },
stdio: "pipe",
}).toString("utf8");
assert.match(output, new RegExp(`\n${OSC7_PTY_FAILED_MARKER}bash\n`));
assert.doesNotMatch(output, /__NETCATTY_OSC7_PTY_DONE__/);
}
});
});
test("PTY probe and chunked installer remove their bash history entries", () => {
withTempHome("netcatty-osc7-inline-history-", (home) => {
const output = runInteractiveHistoryProbe({
shellPath: "/bin/bash",
shellArgs: ["--noprofile", "--norc", "-i"],
dumpHistoryCommand: `history > ${quoteShellArg(join(home, "history"))}`,
dumpPath: join(home, "history"),
env: { HOME: home, HISTFILE: join(home, ".bash_history"), SHELL: "/bin/bash" },
input: `echo keepme\n${buildOsc7PtyShellProbeCommand()}${buildOsc7PtySetupCommands("bash").join("")}`,
});
assert.match(output, /echo keepme/);
assert.doesNotMatch(output, /NETCATTY_OSC7|base64|history -d/);
});
});
test("PTY probe and chunked installer support zsh with shell-local ZDOTDIR", (t) => {
const shellPath = existingShells(["/bin/zsh", "/usr/bin/zsh"])[0];
if (!shellPath) return t.skip("zsh is not installed on this runner");
withTempHome("netcatty-osc7-inline-zsh-", (home) => {
const zdotdir = join(home, "custom-zsh");
const command = `ZDOTDIR=${quoteShellArg(zdotdir)}; ${buildOsc7PtyShellProbeCommand()}${buildOsc7PtySetupCommands("zsh").join("")}`;
const output = execFileSync(shellPath, ["-f", "-i", "-c", command.replace(/\r/g, "\n")], {
env: { ...process.env, HOME: home, SHELL: "/bin/unknown", ZDOTDIR: undefined },
stdio: "pipe",
}).toString("utf8");
assert.match(output, new RegExp(`\n${OSC7_PTY_SHELL_MARKER}zsh\n`));
assert.match(output, new RegExp(`\n${OSC7_PTY_DONE_MARKER}zsh\n`));
assert.equal(markerCount(readFileSync(join(zdotdir, ".zshrc"), "utf8")), 2);
assert.equal(existsSync(join(home, ".zshrc")), false);
});
});
test("buildOsc7TypedSetupCommand stays a single line for reliable history cleanup", async () => {
const sha256 = await getOsc7StagedScriptSha256();
for (const shell of ["bash", "zsh", "fish"] as const) {
+159 -1
View File
@@ -36,7 +36,7 @@ export type Osc7SetupRunResult = {
code?: number | null;
error?: string;
reloadCommand?: string;
/** Setup was retyped into the terminal for a user-switched (su/sudo) shell. */
/** Setup was sent through the foreground terminal. */
sentToTerminal?: boolean;
};
@@ -57,6 +57,14 @@ export type RunOsc7SetupActionOptions = {
},
) => void;
writeLocalTerminalData?: (data: string) => void;
/** Live prompt safety gate (drafts, sensitive prompts and alt-screen block). */
isTerminalReadyForCommandInjection?: () => boolean;
/** Subscribe synchronously (throw if unavailable); resolve null on timeout. */
waitForSessionOutput?: (
sessionId: string,
predicate: (output: string) => boolean,
timeoutMs: number,
) => Promise<string | null>;
};
export const shouldOfferOsc7SetupAction = ({
@@ -675,6 +683,89 @@ export const buildOsc7TypedSetupCommand = (
return ` ${run}; source (test -n "${DOLLAR}XDG_CONFIG_HOME"; and echo "${DOLLAR}XDG_CONFIG_HOME"; or echo "${DOLLAR}HOME/.config")/fish/config.fish >/dev/null 2>&1; __netcatty_osc7_cwd 2>/dev/null; true\r`;
};
export const OSC7_PTY_SHELL_MARKER = "__NETCATTY_OSC7_PTY_SHELL__=";
export const OSC7_PTY_ACK_MARKER = "__NETCATTY_OSC7_PTY_ACK__=";
export const OSC7_PTY_DONE_MARKER = "__NETCATTY_OSC7_PTY_DONE__=";
export const OSC7_PTY_FAILED_MARKER = "__NETCATTY_OSC7_PTY_FAILED__=";
type Osc7PtyShell = "bash" | "zsh";
// Split marker and value in printf so terminal input echo cannot acknowledge
// the probe/install. A leading newline separates markers from prompt/OSC data.
const ptyMarkerCommand = (marker: string, value: string): string =>
`printf '\\n%s%s\\n' '${marker}' ${value}`;
export const buildOsc7PtyShellProbeCommand = (): string =>
` case "$-" in *i*) if test -n "${DOLLAR}{ZSH_VERSION-}"; then ${ptyMarkerCommand(OSC7_PTY_SHELL_MARKER, "zsh")}; elif test -n "${DOLLAR}{BASH_VERSION-}"; then ${ptyMarkerCommand(OSC7_PTY_SHELL_MARKER, "bash")}; else ${ptyMarkerCommand(OSC7_PTY_SHELL_MARKER, "unknown")}; fi;; *) ${ptyMarkerCommand(OSC7_PTY_SHELL_MARKER, "unknown")};; esac; ${BASH_DELETE_MARKED_HISTORY_COMMAND}\r`;
/** PTY-local transport. Every line stays below Linux's canonical input limit. */
export const buildOsc7PtySetupCommands = (shell: Osc7PtyShell): string[] => {
const cleanup = shell === "bash" ? `; ${BASH_DELETE_MARKED_HISTORY_COMMAND}` : "";
const finish = (command: string): string => ` ${command}${cleanup}\r`;
const failed = ptyMarkerCommand(OSC7_PTY_FAILED_MARKER, shell);
const file = '"$__netcatty_osc7_pty_file"';
const commands = [finish(
`if __netcatty_osc7_pty_file=$(mktemp /tmp/.netcatty-osc7-pty.XXXXXX); then ${ptyMarkerCommand(OSC7_PTY_ACK_MARKER, "0")}; else ${failed}; fi`,
)];
// Leave room for the append, ack, and Bash history cleanup on each line.
const payload = btoa(POSIX_SETUP_SCRIPT);
const chunkSize = 1800;
for (let offset = 0; offset < payload.length; offset += chunkSize) {
const ack = String(commands.length);
commands.push(finish(
`if test -n ${file} && printf '%s' '${payload.slice(offset, offset + chunkSize)}' >> ${file}; then ${ptyMarkerCommand(OSC7_PTY_ACK_MARKER, ack)}; else rm -f -- ${file}; unset __netcatty_osc7_pty_file; ${failed}; fi`,
));
}
const runner = 'c=$(base64 -d "$1") && printf "%s\\n" "$c" | sh';
const shellEnv = shell === "zsh" ? ' NETCATTY_ZDOTDIR="${ZDOTDIR:-}"' : "";
const run = `env NETCATTY_OSC7_FORCE_SHELL=${shell}${shellEnv} sh -c ${quoteForSingleQuotedShellString(runner)} sh ${file}`;
const rc = shell === "bash" ? '$HOME/.bashrc' : '${ZDOTDIR:-$HOME}/.zshrc';
commands.push(finish(
`if ${run} && . "${rc}" >/dev/null 2>&1 && osc7_cwd 2>/dev/null; then __netcatty_osc7_pty_result='${OSC7_PTY_DONE_MARKER}'; else __netcatty_osc7_pty_result='${OSC7_PTY_FAILED_MARKER}'; fi; rm -f -- ${file}; unset __netcatty_osc7_pty_file; printf '\\n%s%s\\n' "$__netcatty_osc7_pty_result" ${shell}; unset __netcatty_osc7_pty_result`,
));
return commands;
};
// Only accept complete output lines, including when a marker spans chunks.
const readPtyMarker = (output: string, marker: string): string | null =>
readMarkerLine(output.slice(0, output.lastIndexOf("\n") + 1), marker);
/** Short-lived observer; never consume/replay terminal backlog or flow acks. */
export const waitForOsc7SessionOutput = (
subscribe: (sessionId: string, onData: (chunk: string) => void) => () => void,
sessionId: string,
predicate: (output: string) => boolean,
timeoutMs: number,
): Promise<string | null> => {
let output = "";
let dispose: (() => void) | undefined;
let settled = false;
let result: string | null = null;
let resolveOutput: ((value: string | null) => void) | undefined;
let timer: ReturnType<typeof setTimeout> | undefined;
const finish = (value: string | null) => {
settled = true;
result = value;
clearTimeout(timer);
dispose?.();
resolveOutput?.(value);
};
// Let subscription errors throw before runOsc7SetupAction sends any input.
dispose = subscribe(sessionId, (chunk) => {
if (settled) return;
output = (output + chunk).slice(-65536);
if (predicate(output)) finish(output);
});
if (settled) {
dispose();
return Promise.resolve(result);
}
return new Promise((resolve) => {
resolveOutput = resolve;
timer = setTimeout(() => finish(null), timeoutMs);
});
};
const isOsc7SetupShell = (value: string): value is Osc7SetupShell =>
value === "bash" || value === "zsh" || value === "fish";
@@ -744,6 +835,8 @@ export const runOsc7SetupAction = async ({
setupOsc7Tracking,
writeToSession,
writeLocalTerminalData,
isTerminalReadyForCommandInjection,
waitForSessionOutput,
}: RunOsc7SetupActionOptions): Promise<Osc7SetupRunResult> => {
if (status !== "connected") {
return { success: false, error: "Terminal is not connected" };
@@ -788,6 +881,71 @@ export const runOsc7SetupAction = async ({
return { ...result, success: true, sentToTerminal: true };
}
// Only this confirmed action may use foreground setup on exec identity
// failures (#4052). Keep the OTHER_USER staged transport above unchanged.
const identityErrors = [
"Netcatty OSC 7 setup: could not identify the active terminal shell",
"Netcatty OSC 7 setup: active terminal shell did not match the current tab",
];
if (result.code === 4 || identityErrors.includes(result.stderr?.trim() || "")) {
const idleError = "Configure directory tracking needs an idle bash/zsh prompt with empty input. Clear pending input and try again, or configure OSC 7 manually.";
if (!waitForSessionOutput || !isTerminalReadyForCommandInjection || !isTerminalReadyForCommandInjection()) {
return { ...result, success: false, error: idleError };
}
// Output observers run before xterm has necessarily rendered the next prompt.
// Wait briefly for the live safety reader; never infer readiness from an ack.
const waitUntilPromptReady = async (): Promise<boolean> => {
const deadline = Date.now() + 5000;
while (!isTerminalReadyForCommandInjection()) {
if (Date.now() >= deadline) return false;
await new Promise((resolve) => setTimeout(resolve, 25));
}
return true;
};
const probeOutput = waitForSessionOutput(
sessionId,
(output) => readPtyMarker(output, OSC7_PTY_SHELL_MARKER) !== null,
5000,
);
const probe = buildOsc7PtyShellProbeCommand();
writeToSession(sessionId, probe, {
automated: true,
logRewrite: { sentCommand: probe, displayCommand: "" },
});
const shell = readPtyMarker(await probeOutput || "", OSC7_PTY_SHELL_MARKER);
if ((shell !== "bash" && shell !== "zsh") || !await waitUntilPromptReady()) {
return { ...result, success: false, error: idleError };
}
const completed = (output: string) => readPtyMarker(output, OSC7_PTY_DONE_MARKER) === shell;
const failed = (output: string) => readPtyMarker(output, OSC7_PTY_FAILED_MARKER) === shell;
const commands = buildOsc7PtySetupCommands(shell);
for (let index = 0; index < commands.length; index += 1) {
if (!await waitUntilPromptReady()) {
return { ...result, success: false, error: idleError };
}
const final = index === commands.length - 1;
const acknowledged = (output: string) => final
? completed(output) : readPtyMarker(output, OSC7_PTY_ACK_MARKER) === String(index);
// Subscribe before every write, and wait for this command's acknowledgement
// before typing another line (including when the shell uses canonical input).
const installOutput = waitForSessionOutput(sessionId, (output) => acknowledged(output) || failed(output), final ? 20000 : 5000);
const command = commands[index];
writeToSession(sessionId, command, {
automated: true,
logRewrite: { sentCommand: command, displayCommand: "" },
});
const output = await installOutput;
if (!output || failed(output) || !acknowledged(output)) {
return {
...result,
success: false,
error: "Foreground directory tracking setup failed or did not finish within 20 seconds. Check the terminal; manual OSC 7 configuration is also available.",
};
}
}
return { ...result, success: true, sentToTerminal: true, error: undefined };
}
if (!result.success || (typeof result.code === "number" && result.code !== 0)) {
return {
...result,
@@ -155,7 +155,7 @@ const formatPluginConnectionExitMessage = (event: TerminalSessionExitEvent): str
return `\r\n${lines.join("\r\n")}`;
};
const isAuthFailureMessage = (message: string): boolean => {
export const isAuthFailureMessage = (message: string): boolean => {
const normalized = message.toLowerCase();
return normalized.includes("all configured authentication methods failed") ||
normalized.includes("authentication failed") ||
@@ -1,3 +1,5 @@
import { shouldBroadcastDuringSensitivePrompt } from "../../../domain/terminalBroadcast";
import type { SensitiveInputActiveRef } from "../autocomplete/terminalAutocompletePrompt";
import { clearTerminalBroadcastUserInput, captureTerminalBroadcastInput, isTerminalBroadcastInputCurrent, markTerminalBroadcastUserInput, type TerminalPacedBroadcast } from "./terminalPacedBroadcast";
import { stringCellWidth } from "../autocomplete/terminalStringCellWidth";
import type { TerminalBroadcastInputOptions } from "../terminalHelpers";
@@ -109,6 +111,7 @@ import {
terminalFontLigaturesEnabled,
} from "./terminalFontLigatures";
import { createWebglRendererController } from "./webglRendererController";
import { getSharedWebglContextBudget } from "./webglContextBudget";
import {
captureMiddleClickTerminalMouseEvent,
markMiddleClickContextMenuEvent,
@@ -305,6 +308,12 @@ export type XTermRuntime = {
ensureWebglRenderer: () => void;
/** Drop the WebGL addon while keeping the terminal alive (soft-hide). */
suspendWebglRenderer: () => void;
/**
* Re-read isPaneVisibleRef after the pane is shown or hidden. Hidden panes
* wait for the reveal path to rebuild a lost WebGL renderer, and past the
* shared context budget they give theirs up (see webglContextBudget).
*/
syncPaneVisibility: () => void;
/**
* Turn programming ligatures on or off. An active WebGL renderer is rebuilt
* because its glyph atlas captures font-feature-settings only at creation.
@@ -408,7 +417,9 @@ export type CreateXTermRuntimeContext = {
recordEnter: (options?: { sensitive?: boolean }) => Promise<void>;
captureSubmittedLineRecorder?: () => ((line: string, options?: { sensitive?: boolean; includePendingInput?: boolean; consumePendingInput?: boolean }) => Promise<void>) | undefined;
} | undefined>;
passwordPromptActiveRef?: RefObject<boolean>;
passwordPromptActiveRef?: SensitiveInputActiveRef;
/** Includes raw PTY screen transitions not yet parsed by xterm. */
isPromptSecurityAlternateScreenActive?: () => boolean;
/**
* Opt-in "broadcast without password protection" (#3488). When true, an
* active password prompt no longer pauses raw-string broadcast fan-out.
@@ -472,10 +483,19 @@ export type CreateXTermRuntimeContext = {
// background tabs) to avoid spinning up many WebGL contexts at once. Defaults
// to visible (immediate WebGL) when omitted.
initiallyVisible?: boolean;
/**
* Whether the pane is on screen right now. Unlike initiallyVisible, which
* stays true for hidden panes unless hibernation is on, this feeds the WebGL
* context budget. Treated as always visible when omitted.
*/
isPaneVisibleRef?: RefObject<boolean>;
/** When true, keep the DOM renderer until replay completes (hibernate wake). */
deferWebglUntilReplayComplete?: boolean;
};
/** Private xterm fields used to release a disposed renderer's WebGL context. */
type WebglAddonInternals = { _renderer?: { _gl?: WebGL2RenderingContext } };
const detectPlatform = (): XTermPlatform => {
if (
typeof process !== "undefined" &&
@@ -899,10 +919,27 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
}
};
// Disposing the addon leaves its WebGL context alive until the canvas is
// garbage collected, still holding GPU memory and one of Chromium's 16
// context slots. Lose it explicitly so both come back right away.
const disposeWebglAddon = (addon: WebglAddon) => {
const gl = (addon as unknown as WebglAddonInternals)._renderer?._gl;
addon.dispose();
try {
if (gl && !gl.isContextLost()) gl.getExtension("WEBGL_lose_context")?.loseContext();
} catch (err) {
logger.warn("[XTerm] Failed to release WebGL context", err);
}
};
const isPaneVisibleRef = ctx.isPaneVisibleRef;
const webglController = createWebglRendererController({
enabled: performanceConfig.useWebGLAddon,
budget: getSharedWebglContextBudget(),
isVisible: isPaneVisibleRef ? () => isPaneVisibleRef.current !== false : undefined,
createAddon: () => new WebglAddon(),
loadAddon: (addon) => term.loadAddon(addon),
disposeAddon: disposeWebglAddon,
repaint: repaintTerminal,
setLoaded: (loaded) => {
webglLoaded = loaded;
@@ -1054,8 +1091,12 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
// classification carried in the paste options must still tag the fan-out.
const sourceSensitive = dispatchingFromPasswordPrompt || options?.sensitive === true;
if (
(!dispatchingFromPasswordPrompt
|| ctx.broadcastPasswordBypassRef?.current === true)
shouldBroadcastDuringSensitivePrompt({
sensitivePromptActive: dispatchingFromPasswordPrompt,
broadcastPasswordBypass: ctx.broadcastPasswordBypassRef?.current === true,
sensitivePromptSource: ctx.passwordPromptActiveRef?.source,
alternateScreen: ctx.isPromptSecurityAlternateScreenActive?.() || term.buffer?.active.type === "alternate",
})
&& ctx.isBroadcastEnabledRef.current
&& ctx.onBroadcastInputRef.current
) {
@@ -1380,9 +1421,9 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
// reconnect may have cleared passwordPromptActiveRef while the dialog was
// open, so the live ref alone would downgrade the secret to nonsensitive.
// The override is consumed only for the flagged paste data.
const sensitive = ctx.passwordPromptActiveRef?.current === true
|| options?.sensitive === true
const carriedSensitive = options?.sensitive === true
|| shouldOverrideTerminalUserPasteSensitivity(term, logicalData ?? data);
const sensitive = ctx.passwordPromptActiveRef?.current === true || carriedSensitive;
if (!options?.lineDelayMs && logicalData !== null
&& !isPrintableInput(logicalData) && !isTerminalReportSequence(logicalData)) {
commandBufferRevision += 1;
@@ -1411,8 +1452,12 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
// The password bypass (#3488) only lifts the live password-prompt pause.
// Explicitly classified sensitive payloads (confirmed pastes, preloaded
// sudo credentials) stay non-broadcastable regardless of the opt-in.
const canBroadcastInput = (!sensitive
|| (ctx.broadcastPasswordBypassRef?.current === true && options?.sensitive !== true)) &&
const canBroadcastInput = shouldBroadcastDuringSensitivePrompt({
sensitivePromptActive: sensitive,
broadcastPasswordBypass: ctx.broadcastPasswordBypassRef?.current === true && options?.sensitive !== true,
sensitivePromptSource: carriedSensitive ? "explicit" : ctx.passwordPromptActiveRef?.source,
alternateScreen: ctx.isPromptSecurityAlternateScreenActive?.() || term.buffer?.active.type === "alternate",
}) &&
inputSource !== "kitty" &&
!handlingKittyBroadcast &&
!suppressTerminalBroadcast &&
@@ -1746,8 +1791,12 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
isHandlingBroadcast: () => handlingKittyBroadcast,
isBroadcastEnabled: () => ctx.isBroadcastEnabledRef.current,
// The #3488 bypass lifts the password-prompt pause for Kitty key fan-out too.
isSensitiveInput: () => ctx.passwordPromptActiveRef?.current === true
&& ctx.broadcastPasswordBypassRef?.current !== true,
isSensitiveInput: () => !shouldBroadcastDuringSensitivePrompt({
sensitivePromptActive: ctx.passwordPromptActiveRef?.current === true,
broadcastPasswordBypass: ctx.broadcastPasswordBypassRef?.current === true,
sensitivePromptSource: ctx.passwordPromptActiveRef?.source,
alternateScreen: ctx.isPromptSecurityAlternateScreenActive?.() || term.buffer?.active.type === "alternate",
}),
// Dispatching from an active prompt under the bypass must tag the payload
// so peer writes keep input interceptors skipped.
isSensitivePromptSource: () => ctx.passwordPromptActiveRef?.current === true,
@@ -2727,6 +2776,15 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
};
return true;
}
// xterm leaves Shift+Insert to the browser's native paste handler. Fan out
// the clipboard data from onData instead of encoding an Insert key for
// peers or treating the paste as that key's pending legacy data (#4142).
if (
e.key === "Insert" && e.shiftKey
&& !e.ctrlKey && !e.altKey && !e.metaKey
) {
return true;
}
if (!shouldDeferKittyKeyEvent(normalizedKittyEvent)) {
const identity = kittyKeyIdentity(e);
if (
@@ -3168,6 +3226,12 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
});
});
term.onBinary((data) => {
// Legacy mouse reports are wire bytes, not text input or broadcast keys.
const id = ctx.sessionRef.current;
if (id) ctx.terminalBackend.writeToSession(id, data, { binary: true });
});
const handleKittyKeyboardBroadcast = createKittyKeyboardBroadcastHandler({
resolveOptions: () => ({
kittyProtocolEnabled: kittyKeyboardProtocolEnabled,
@@ -3494,6 +3558,7 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
clearTextureAtlas: clearWebglTextureAtlas,
ensureWebglRenderer: loadWebglRenderer,
suspendWebglRenderer,
syncPaneVisibility: webglController.syncVisibility,
syncFontLigatures: (enabled: boolean) => {
ligatures.apply(enabled);
},
@@ -10,7 +10,7 @@ const ts=require('typescript');
const bridge=require(root+'/electron/bridges/terminalBridge.cjs');
test('serial input combinations preserve the preceding device bytes after CJK deletion', async () => {
const modules = await Promise.all([
'components/terminal/runtime/terminalPacedBroadcast.ts', 'components/terminal/runtime/terminalReportSequence.ts', 'domain/serialCharMetrics.ts', 'components/terminal/runtime/terminalInputSanitize.ts',
'components/terminal/runtime/terminalPacedBroadcast.ts', 'components/terminal/runtime/terminalReportSequence.ts', 'domain/terminalBroadcast.ts', 'domain/serialCharMetrics.ts', 'components/terminal/runtime/terminalInputSanitize.ts',
'components/terminal/runtime/terminalBackspaceInput.ts', 'components/terminal/runtime/terminalPerCharacterInput.ts',
'components/terminal/runtime/terminalSudoAutofill.ts', 'components/terminal/runtime/terminalCommandExecution.ts',
'components/terminal/runtime/serialLocalEcho.ts', 'components/terminal/autocomplete/terminalStringCellWidth.ts',
@@ -269,7 +269,7 @@ test("runtime routes Shift+Enter text through the shared input handler", () => {
);
assert.match(
source,
/const canBroadcastInput = \(!sensitive\s*\|\|\s*\(ctx\.broadcastPasswordBypassRef\?\.current === true && options\?\.sensitive !== true\)\) &&[\s\S]*?const willBroadcastInput = canBroadcastInput && options\?\.skipBroadcast !== true;[\s\S]*?if \(!canBroadcastInput && !handlingKittyBroadcast\) \{\s*prepareSudoAutofillInput/s,
/const canBroadcastInput = shouldBroadcastDuringSensitivePrompt\(\{[\s\S]*?broadcastPasswordBypass: ctx\.broadcastPasswordBypassRef\?\.current === true && options\?\.sensitive !== true,[\s\S]*?\}\) &&[\s\S]*?const willBroadcastInput = canBroadcastInput && options\?\.skipBroadcast !== true;[\s\S]*?if \(!canBroadcastInput && !handlingKittyBroadcast\) \{\s*prepareSudoAutofillInput/s,
);
assert.match(
source,
@@ -107,6 +107,7 @@ test("attached popups retain the active mode across snapshots and return, includ
hibernateSnapshotRef: ref(""), hibernatePendingBufferRef: ref(""), hibernateViewportSnapshotRef: ref(""), hibernateScrollbackSnapshotRef: ref(""),
hibernateContextSnapshotRef: ref(""), hibernateContextViewportSnapshotRef: ref(""), hibernateContextScrollbackSnapshotRef: ref(""), hibernateAlternateScreenRef: ref(false),
kittyKeyboardProtocolEnabledForSession: false, passwordPromptActiveRef: ref(false), knownCwdRef: ref(null), terminalTitleRef: ref(null), pluginTerminalLifecycleRef: ref(null),
promptSecurityScreenRef: ref({ active: null, incomplete: null }),
onTerminalCwdChange: undefined, onTerminalTitleChange: undefined, terminalCwdTracker: { setRendererCwd: noop },
flushPendingTerminalWritesBeforeHibernate: async () => true, resetTerminalLineTimestamps: noop,
applyAuthoritativeHibernateSnapshot: noop, logger: { warn: noop },
@@ -147,6 +148,7 @@ test("attached popups retain the active mode across snapshots and return, includ
safeFit: noop, logger: { warn: noop, error: (...args: unknown[]) => errors.push(args) },
onPluginRuntimeCwdChange: noop, onOpenExternalError: noop, requestSearchFocus: noop,
requestPluginTerminalProviders: undefined, isPluginTerminalProviderAvailable: () => false,
isPromptSecurityAlternateScreenActive: undefined,
useEffect: (effect: React.EffectCallback, dependencies: React.DependencyList) => {
useEffect(() => {
if (effect.toString().includes("bootAbort")) return effect();
@@ -0,0 +1,40 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import test from "node:test";
import { runInNewContext } from "node:vm";
const source = readFileSync(new URL("./createXTermRuntime.ts", import.meta.url), "utf8");
const start = source.indexOf(" term.onBinary((data) => {");
const end = source.indexOf(" const handleKittyKeyboardBroadcast =", start);
assert.ok(start >= 0 && end > start, "runtime must subscribe to xterm onBinary");
test("onBinary forwards unchanged bytes only to the current session without text handling", () => {
const writes: Array<{ id: string; data: string; binary: boolean }> = [];
const sessionRef: { current: string | null } = { current: "first" };
let receive!: (data: string) => void;
runInNewContext(source.slice(start, end), {
term: { onBinary(callback: typeof receive) { receive = callback; } },
ctx: {
sessionRef,
terminalBackend: {
writeToSession(id: string, data: string, options: { binary: boolean }) {
writes.push({ id, data, binary: options.binary });
},
},
},
handleTerminalInputData: () => assert.fail("binary reports must bypass text handling"),
});
const mouse = String.fromCharCode(0x1b, 0x5b, 0x4d, 0x20, 0x21, 0xc0);
receive(mouse);
sessionRef.current = "replacement";
const allBytes = String.fromCharCode(...Array.from({ length: 256 }, (_, i) => i));
receive(allBytes);
sessionRef.current = null;
receive(mouse);
assert.deepEqual(writes, [
{ id: "first", data: mouse, binary: true },
{ id: "replacement", data: allBytes, binary: true },
]);
});
@@ -20,7 +20,7 @@ function section(start: string, end: string): string {
}
const declarations = section(" let suppressNextTerminalDataBroadcast =", " const broadcastKittyInput =");
const keyboardCallback = section(" term.attachCustomKeyEventHandler((e: KeyboardEvent) => {", " const handleMiddleClick =");
const dataCallback = section(" term.onData((data) => {", " const handleKittyKeyboardBroadcast =");
const dataCallback = section(" term.onData((data) => {", " term.onBinary((data) => {");
const compositionMarker = section(" const markKittyCompositionPending =", " const finishKittyComposition =");
const textInputMarker = section(" const markKittyTextInput =", ' textarea?.addEventListener("compositionstart"');
const suppression = section(" const suppressTerminalBroadcast =", " // skipBroadcast");
@@ -34,11 +34,11 @@ test("password-prompt input is classified before prompt state reset and cannot b
);
assert.match(
terminalSource,
/typeof meta\?\.pluginPipelineSensitiveInput === "boolean"[\s\S]*?passwordPromptActiveRef\.current = meta\.pluginPipelineSensitiveInput[\s\S]*?sensitivePromptOutputTailRef\.current = "";[\s\S]*?return;[\s\S]*?else if \(isUntrustedTerminalInputPrompt/u,
/typeof meta\?\.pluginPipelineSensitiveInput === "boolean"[\s\S]*?passwordPromptActiveRef\.current = meta\.pluginPipelineSensitiveInput[\s\S]*?sensitivePromptOutputTailRef\.current = "";[\s\S]*?return;[\s\S]*?isUntrustedTerminalInputPrompt/u,
);
assert.match(
runtimeSource,
/const sensitive = ctx\.passwordPromptActiveRef\?\.current === true[\s\S]*?const canBroadcastInput = \(!sensitive\s*\|\|\s*\(ctx\.broadcastPasswordBypassRef\?\.current === true && options\?\.sensitive !== true\)\) &&[\s\S]*?const willBroadcastInput = canBroadcastInput && options\?\.skipBroadcast !== true;/u,
/const sensitive = ctx\.passwordPromptActiveRef\?\.current === true[\s\S]*?const canBroadcastInput = shouldBroadcastDuringSensitivePrompt\(\{\s*sensitivePromptActive: sensitive,[\s\S]*?broadcastPasswordBypass: ctx\.broadcastPasswordBypassRef\?\.current === true && options\?\.sensitive !== true,[\s\S]*?\}\) &&[\s\S]*?const willBroadcastInput = canBroadcastInput && options\?\.skipBroadcast !== true;/u,
);
assert.match(
runtimeSource,
@@ -50,7 +50,7 @@ test("password-prompt input is classified before prompt state reset and cannot b
);
assert.match(
runtimeSource,
/const broadcastUserPasteData = \(\s*data: string,\s*options\?: TerminalBroadcastInputOptions,\s*\) => \{[\s\S]*?const dispatchingFromPasswordPrompt = ctx\.passwordPromptActiveRef\?\.current === true;[\s\S]*?const sourceSensitive = dispatchingFromPasswordPrompt \|\| options\?\.sensitive === true;[\s\S]*?\|\| ctx\.broadcastPasswordBypassRef\?\.current === true\)[\s\S]*?onBroadcastInputRef\.current\(\s*data,\s*ctx\.sessionId,\s*sourceSensitive[\s\S]*?sourceSensitive: true/u,
/const broadcastUserPasteData = \(\s*data: string,\s*options\?: TerminalBroadcastInputOptions,\s*\) => \{[\s\S]*?const dispatchingFromPasswordPrompt = ctx\.passwordPromptActiveRef\?\.current === true;[\s\S]*?const sourceSensitive = dispatchingFromPasswordPrompt \|\| options\?\.sensitive === true;[\s\S]*?broadcastPasswordBypass: ctx\.broadcastPasswordBypassRef\?\.current === true,[\s\S]*?onBroadcastInputRef\.current\(\s*data,\s*ctx\.sessionId,\s*sourceSensitive[\s\S]*?sourceSensitive: true/u,
);
assert.match(
terminalSource,
@@ -141,7 +141,7 @@ test("explicit plugin sensitivity is not treated as the heuristic prompt latch",
test("active and hibernated output share host-owned sensitive prompt classification", () => {
assert.match(
terminalSource,
/const observeTerminalInputPrompt = useCallback[\s\S]*?typeof meta\?\.pluginPipelineSensitiveInput === "boolean"[\s\S]*?passwordPromptActiveRef\.current = meta\.pluginPipelineSensitiveInput[\s\S]*?sensitivePromptOutputTailRef\.current = "";[\s\S]*?return;[\s\S]*?isConfirmedTerminalShellPrompt[\s\S]*?passwordPromptActiveRef\.current = false/u,
/const observeTerminalInputPrompt = useCallback[\s\S]*?typeof meta\?\.pluginPipelineSensitiveInput === "boolean"[\s\S]*?passwordPromptActiveRef\.current = meta\.pluginPipelineSensitiveInput[\s\S]*?sensitivePromptOutputTailRef\.current = "";[\s\S]*?return;[\s\S]*?isUntrustedTerminalInputPrompt[\s\S]*?passwordPromptActiveRef\.current = false/u,
);
assert.match(
terminalSource,
@@ -1,6 +1,8 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import test from "node:test";
import { runInNewContext } from "node:vm";
import { shouldBroadcastDuringSensitivePrompt } from "../../../domain/terminalBroadcast.ts";
import { shouldBroadcastTerminalUserInput } from "./terminalUserPaste.ts";
@@ -39,3 +41,51 @@ test("keystroke fan-out stays on the toolbar gate and ignores the compose-bar ta
);
assert.doesNotMatch(keystrokeCall, /isComposeSendToAll/);
});
const runtimeSource = readFileSync(new URL("./createXTermRuntime.ts", import.meta.url), "utf8");
const gateStart = runtimeSource.indexOf(" const canBroadcastInput =");
const gateEnd = runtimeSource.indexOf(" const willBroadcastInput =", gateStart);
assert.ok(gateStart > 0 && gateEnd > gateStart);
const gateCode = runtimeSource.slice(gateStart, gateEnd) + "\ncanBroadcastInput;";
const kittyStart = runtimeSource.indexOf(" isSensitiveInput: () => !shouldBroadcastDuringSensitivePrompt(");
const kittyEnd = runtimeSource.indexOf(" // Dispatching from an active prompt", kittyStart);
assert.ok(kittyStart > 0 && kittyEnd > kittyStart);
const kittyCode = "({" + runtimeSource.slice(kittyStart, kittyEnd) + "}).isSensitiveInput()";
for (const alternateScreen of [false, true]) {
for (const source of ['heuristic', 'explicit'] as const) {
test(`raw and Kitty broadcast respect ${source} sensitivity on ${alternateScreen ? 'alternate' : 'primary'} screen`, () => {
const context = {
ctx: {
passwordPromptActiveRef: { current: true, source },
broadcastPasswordBypassRef: { current: false },
isBroadcastEnabledRef: { current: true },
},
term: { buffer: { active: { type: alternateScreen ? 'alternate' : 'normal' } } },
sensitive: true, carriedSensitive: false,
options: undefined, inputSource: 'terminal', handlingKittyBroadcast: false,
suppressTerminalBroadcast: false, id: 'session', broadcastDataBeforeSudo: 'password',
onBroadcastInput: () => {},
shouldBroadcastDuringSensitivePrompt, shouldBroadcastTerminalUserInput,
};
assert.equal(runInNewContext(gateCode, context), false);
assert.equal(runInNewContext(kittyCode, context), true);
const pendingParseContext = {
...context,
term: { buffer: { active: { type: 'normal' } } },
ctx: { ...context.ctx, isPromptSecurityAlternateScreenActive: () => true },
};
assert.equal(runInNewContext(gateCode, pendingParseContext), false);
assert.equal(runInNewContext(kittyCode, pendingParseContext), true);
const bypassContext = {
...context,
ctx: { ...context.ctx, broadcastPasswordBypassRef: { current: true } },
};
assert.equal(runInNewContext(gateCode, bypassContext), true);
assert.equal(runInNewContext(kittyCode, bypassContext), false);
assert.equal(runInNewContext(gateCode, { ...context, carriedSensitive: true }), false,
'a carried sensitive paste stays closed even on the alternate screen');
});
}
}
@@ -26,7 +26,7 @@ const code = ts.transpileModule(
{ compilerOptions: { target: ts.ScriptTarget.ES2022 } },
).outputText;
const helpers = await Promise.all([
"../../../domain/serialCharMetrics.ts", "./terminalReportSequence.ts", "./terminalInputSanitize.ts",
"../../../domain/terminalBroadcast.ts", "../../../domain/serialCharMetrics.ts", "./terminalReportSequence.ts", "./terminalInputSanitize.ts",
"./terminalBackspaceInput.ts", "./terminalPerCharacterInput.ts",
"./terminalSudoAutofill.ts", "./terminalCommandExecution.ts",
"./terminalPacedBroadcast.ts", "./terminalInterruptInputState.ts", "./serialLocalEcho.ts", "../autocomplete/terminalStringCellWidth.ts",
@@ -1,5 +1,6 @@
import test from "node:test";
import assert from "node:assert/strict";
import xterm from "@xterm/xterm";
import {
MAX_TERMINAL_LINE_TIMESTAMP_ENTRIES,
@@ -18,6 +19,159 @@ import {
type StampCursorEstimate,
} from "./terminalLineTimestamps.ts";
import { MAX_INCOMPLETE_TERMINAL_CONTROL_SEQUENCE_CHARS } from "./terminalControlSequenceLimits.ts";
import { installEraseInDisplayHandlers } from "../clearTerminalViewport.ts";
const createRealTimestampTerm = (wipeScrollback = true) => {
const term = new xterm.Terminal({
cols: 40, rows: 6, scrollback: 20, allowProposedApi: true,
});
const handler = installEraseInDisplayHandlers(term, {
getClearWipesScrollback: () => wipeScrollback,
});
const writes: string[] = [];
const originalWrite = term.write.bind(term);
term.write = (data, callback) => {
assert.equal(typeof data, "string");
writes.push(data as string);
originalWrite(data, callback);
};
return {
term,
writes,
write: (data: string, second = 0) => new Promise<void>((resolve) => {
writeTerminalDataWithLineTimestamps(term, data, resolve, {
timestampDate: new Date(2026, 9, 29, 12, 0, second),
});
}),
dispose: () => { handler.dispose(); term.dispose(); },
};
};
test("real xterm keeps cleared prompts timestamped across chunk and second boundaries", async () => {
for (const wipe of [true, false]) {
for (const second of [0, 1]) {
for (const clear of ["\x1b[H\x1b[2J\x1b[3J", "\x1b[H\x1b[J", "\x1b[1;1f\x1b[2J"]) {
for (const chunking of ["merged", "separate", "bytewise"]) {
const fixture = createRealTimestampTerm(wipe);
const { term, write } = fixture;
try {
const initial = "history\r\n$ clear\r\n";
await write(initial);
const redraw = clear + "$ ";
const chunks = chunking === "merged" ? [redraw]
: chunking === "separate" ? [clear, "$ "] : [...redraw];
for (const chunk of chunks) await write(chunk, second);
assert.equal(term.buffer.active.cursorY, 0, "prompt is at the top");
assert.equal(term.buffer.active.getLine(term.buffer.active.baseY)?.translateToString(true), "$ ");
assert.deepEqual(getVisibleTerminalLineTimestampRows(term), [
{ row: 0, label: `12:00:0${second}` },
], JSON.stringify({ wipe, second, clear, chunking }));
assert.equal(fixture.writes.join(""), initial + redraw, "PTY bytes are unchanged");
} finally {
fixture.dispose();
}
}
}
}
}
});
test("real xterm timestamps repeated same-second clears and redraws without a newline", async () => {
const fixture = createRealTimestampTerm();
try {
await fixture.write("$ ");
for (let index = 0; index < 5; index += 1) {
// Ctrl+L-style redraw: no CR/LF to reset the timestamp segmenter.
await fixture.write("\x1b[H\x1b[2J$ ");
assert.deepEqual(getVisibleTerminalLineTimestampRows(fixture.term), [
{ row: 0, label: "12:00:00" },
]);
assert.equal(getTerminalLineTimestampLedgerCount(fixture.term), 1);
}
} finally {
fixture.dispose();
}
});
test("real xterm preserves timestamped history when output and clear share a batch", async () => {
const fixture = createRealTimestampTerm(false);
try {
await fixture.write("old\r\n", 0);
await fixture.write("new\r\n\x1b[H\x1b[2J\x1b[3J$ ", 1);
fixture.term.scrollToTop();
assert.deepEqual(getVisibleTerminalLineTimestampRows(fixture.term), [
{ row: 0, label: "12:00:00" },
{ row: 1, label: "12:00:01" },
{ row: 2, label: "12:00:01" },
]);
} finally {
fixture.dispose();
}
});
test("real xterm retains clear anchors after scrollback trim and terminal reflow", async () => {
const fixture = createRealTimestampTerm();
try {
await fixture.write("line\r\n".repeat(40));
await fixture.write("\x1b[H\x1b[2J\x1b[3J$ ", 1);
fixture.term.resize(20, 6);
await fixture.write("command\r\nnext", 2);
assert.deepEqual(getVisibleTerminalLineTimestampRows(fixture.term), [
{ row: 0, label: "12:00:01" },
{ row: 1, label: "12:00:02" },
]);
} finally {
fixture.dispose();
}
});
test("real xterm does not treat OSC payloads or alternate-screen redraws as clear boundaries", async () => {
const fixture = createRealTimestampTerm();
try {
const data = "\x1b]0;title\x1b[H\x1b[2J\x07$ ";
await fixture.write(data);
assert.equal(fixture.writes.length, 1, "string controls retain the bulk path");
await fixture.write("\x1b[?1049h\x1b[H\x1b[2JTUI", 1);
assert.equal(fixture.writes.length, 2, "alternate-screen output retains the bulk path");
await fixture.write("\x1b[?1049l\r\n$ ", 2);
assert.deepEqual(getVisibleTerminalLineTimestampRows(fixture.term), [
{ row: 0, label: "12:00:00" },
{ row: 1, label: "12:00:02" },
]);
} finally {
fixture.dispose();
}
});
test("standalone cursor moves keep untouched suffix timestamps", async () => {
for (const position of ["\x1b[H", "\x1b[1;1f"]) {
const fixture = createRealTimestampTerm();
try {
await fixture.write("first\r\nsecond\r\n", 0);
await fixture.write(position + "new", 1);
assert.equal(fixture.term.buffer.active.getLine(1)?.translateToString(true), "second");
assert.equal(getVisibleTerminalLineTimestampRows(fixture.term).find(({ row }) => row === 1)?.label, "12:00:00");
assert.equal(fixture.writes.length, 2, "a standalone move does not split the write");
} finally {
fixture.dispose();
}
}
});
test("normal-screen cursor-heavy frames remain a single write", async () => {
const fixture = createRealTimestampTerm();
let notifications = 0;
const unsubscribe = onTerminalLineTimestampsChange(fixture.term, () => { notifications += 1; });
try {
const frame = Array.from({ length: 1000 }, (_, row) => `\x1b[${row % 6 + 1};1Hrow`).join("");
await fixture.write(frame);
assert.deepEqual(fixture.writes, [frame]);
assert.equal(notifications, 1, "one ledger notification per frame");
} finally {
unsubscribe();
fixture.dispose();
}
});
const createFakeTerm = (options: {
cols?: number;
@@ -323,17 +323,6 @@ const isBulkMeasurableEscapeSequence = (sequence: string): boolean =>
getAlternateScreenAction(sequence) === null
&& (getWraparoundAction(sequence) !== null || isSgrSequence(sequence));
const isPotentialAlternateScreenSequence = (sequence: string): boolean => {
if (!sequence.startsWith("\x1b[?")) return false;
const params = sequence.slice(3).split(";");
const alternateScreenModes = ["47", "1047", "1049"];
return params.some((part) => (
part === ""
|| alternateScreenModes.some((mode) => mode.startsWith(part) || part.startsWith(mode))
));
};
const isPrintableOutput = (char: string): boolean => {
if (char === "\t") return true;
const code = char.codePointAt(0);
@@ -1711,7 +1700,7 @@ const writeTerminalDataWithSecondLedger = (
const segments = store.segmenter.append(dataForTimestamps, stampDate);
const pendingEscapeSequence = store.segmenter.flushPendingEscapeSequence();
if (isPotentialAlternateScreenSequence(pendingEscapeSequence)) {
if (canRetainIncompleteTerminalControlSequence(pendingEscapeSequence)) {
store.timestampOnlyPrefix = pendingEscapeSequence;
}
@@ -1779,6 +1768,40 @@ const writeTerminalDataWithSecondLedger = (
});
};
/**
* Normal-screen erasure needs real parser coordinates. Keep erase writes
* separate from text so anchors are attached before an erase,
* and subsequent stamps are seeded after xterm has moved/trimmed the buffer.
* Standalone cursor moves stay on the bulk path: a backwards sparse stamp
* would overwrite the fill-forward time of untouched rows below it.
* Skip alternate-screen programs and string controls (OSC/DCS payloads).
*/
const findTimestampCursorBoundary = (term: XTerm, data: string): {
start: number;
end: number;
} | null => {
const prefix = getTimestampStore(term).timestampOnlyPrefix;
const input = prefix + data;
let altActive = term.buffer?.active?.type === "alternate";
for (let index = input.indexOf("\x1b"); index !== -1;) {
const parsed = readEscapeSequence(input, index);
if (!parsed?.complete) break;
const sequence = parsed.sequence;
const action = getAlternateScreenAction(sequence);
if (action) altActive = action === "enter";
const final = getCsiFinal(sequence);
const params = sequence.slice(2, -1);
if (!altActive && final === "J" && /^[\d;]*$/.test(params)) {
return {
start: Math.max(0, index - prefix.length),
end: parsed.endIndex + 1 - prefix.length,
};
}
index = input.indexOf("\x1b", parsed.endIndex + 1);
}
return null;
};
export const writeTerminalDataWithLineTimestamps = (
term: XTerm,
data: string,
@@ -1814,13 +1837,41 @@ export const writeTerminalDataWithLineTimestamps = (
return;
}
// Record/render separation: per-second ledger + sparse reflow anchors.
writeTerminalDataWithSecondLedger(
term,
data,
done,
options,
diagnostics,
shouldMeasureDiagnostics,
);
// Preserve arrival time across parser checkpoints, including a second rollover.
const writeOptions = { ...options, timestampDate: options?.timestampDate ?? new Date() };
const writeStamped = (part: string, onComplete: () => void) => {
writeTerminalDataWithSecondLedger(
term, part, onComplete, writeOptions, diagnostics, shouldMeasureDiagnostics,
);
};
const writeNext = (remaining: string): void => {
const boundary = findTimestampCursorBoundary(term, remaining);
if (!boundary) {
writeStamped(remaining, done);
return;
}
const writeControl = () => {
writeFallbackOnly(remaining.slice(boundary.start, boundary.end), () => {
const store = getTimestampStore(term);
store.segmenter.reset();
store.timestampOnlyPrefix = "";
// Erasure can dispose the current second's anchor. A redraw must be
// allowed to stamp again even when its arrival time is the same second.
store.lastStampSecondKey = null;
rebaseLedgerForScrollback(term, store);
notifyTimestampStore(store);
if (boundary.end < remaining.length) {
writeNext(remaining.slice(boundary.end));
} else {
done();
}
});
};
if (boundary.start > 0) {
writeStamped(remaining.slice(0, boundary.start), writeControl);
} else {
writeControl();
}
};
writeNext(data);
};
@@ -0,0 +1,135 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import path from "node:path";
import test from "node:test";
import { fileURLToPath } from "node:url";
import { buildSync } from "esbuild";
import { JSDOM } from "jsdom";
import ts from "typescript";
test("native Shift+Insert broadcasts clipboard bytes once without an Insert key", async () => {
const root = fileURLToPath(new URL("../../..", import.meta.url));
const layer = readFileSync(new URL("../../TerminalLayer.tsx", import.meta.url), "utf8");
const start = layer.indexOf(" const handleBroadcastInput = useCallback(");
const end = layer.indexOf(" const handleCommandSubmitted =", start);
assert.ok(start >= 0 && end > start);
const dispatcher = ts.transpileModule(layer.slice(start, end), {
compilerOptions: { target: ts.ScriptTarget.ES2022 },
}).outputText;
const bundle = buildSync({
stdin: {
contents: `
export { createXTermRuntime } from "./components/terminal/runtime/createXTermRuntime";
export { DEFAULT_TERMINAL_SETTINGS } from "./domain/models/terminal";
export { resolveTerminalBroadcastTargetIds } from "./domain/terminalBroadcast";
export { dispatchKittyKeyboardBroadcastInput } from "./components/terminal/runtime/kittyKeyboardBroadcast";
export * from "./components/terminal/runtime/terminalPacedBroadcast";
`,
loader: "ts", resolveDir: root,
},
bundle: true, format: "iife", globalName: "runtimeTest", platform: "browser", write: false,
alias: {
"node:diagnostics_channel": path.join(root, "infrastructure/shims/nodeDiagnosticsChannel.ts"),
"diagnostics_channel": path.join(root, "infrastructure/shims/nodeDiagnosticsChannel.ts"),
},
define: { "import.meta.env.DEV": "false", "import.meta.env.PROD": "true", "import.meta": "{}" },
}).outputFiles[0].text;
const dom = new JSDOM("<body/>", { url: "http://localhost", runScripts: "outside-only", pretendToBeVisual: true });
Object.assign(dom.window, { structuredClone });
try {
dom.window.eval(`
Object.defineProperty(navigator, 'platform', {value:'Win32'});
window.matchMedia = () => ({matches:false,addListener(){},removeListener(){},addEventListener(){},removeEventListener(){}});
window.ResizeObserver = window.IntersectionObserver = class {observe(){} unobserve(){} disconnect(){}};
HTMLCanvasElement.prototype.getContext = function(type) {
if (type !== '2d') return null;
return new Proxy({canvas:this,measureText:()=>({width:8,fontBoundingBoxAscent:12,fontBoundingBoxDescent:3}),
getImageData:()=>({data:new Uint8ClampedArray(4)}),createLinearGradient:()=>({addColorStop(){}})},
{get:(target,key)=>key in target ? target[key] : ()=>{}});
};
`);
dom.window.eval(bundle);
const results = await dom.window.eval(`(async () => {
const {createXTermRuntime, DEFAULT_TERMINAL_SETTINGS, resolveTerminalBroadcastTargetIds,
dispatchKittyKeyboardBroadcastInput, markTerminalBroadcastUserInput,
captureTerminalBroadcastInput, isTerminalBroadcastInputCurrent} = runtimeTest;
const ref = current => ({current});
const useCallback = fn => fn;
const sessionsRef = ref([]), isGlobalBroadcastEnabled = false;
const broadcastPasswordBypassRef = ref(false);
const canUseDirectSessionWriteFallback = () => true;
const isTerminalSensitiveInputActive = () => false;
const wires = new Map();
const terminalBackend = {
writeToSession:(id,data)=>wires.get(id).push(data), resizeSession(){}, openExternalAvailable:false,
};
${dispatcher}
const make = (id, kittyKeyboardProtocolEnabled) => {
const container = document.createElement('div'); document.body.appendChild(container);
wires.set(id, []);
const host = {id,label:id,hostname:'localhost',protocol:'ssh',sshInputCompatibility:false};
return createXTermRuntime({
container, host, getHost:()=>host, sessionId:id, sessionRef:ref(id), statusRef:ref('connected'),
fontFamilyId:'jetbrains-mono',resolvedFontFamily:'monospace',fontSize:14,
terminalTheme:{colors:{background:'#111111',foreground:'#eeeeee',cursor:'#ffffff',selection:'#444444'}},
terminalSettingsRef:ref({...DEFAULT_TERMINAL_SETTINGS,rendererType:'dom',fontLigatures:false,cursorBlink:false}),
terminalBackend,hotkeySchemeRef:ref('disabled'),keyBindingsRef:ref([]),onHotkeyActionRef:ref(undefined),
isBroadcastEnabledRef:ref(true),onBroadcastInputRef:ref(handleBroadcastInput),
commandBufferRef:ref(''),requestSearchFocus(){},kittyKeyboardProtocolEnabled,
});
};
const results = [];
for (const bracketed of [false,true]) {
for (const delayed of [true,false]) {
for (const peerKitty of [false,true]) {
const source = make('source', false), peer = make('peer', peerKitty);
try {
sessionsRef.current = ['source','peer'].map(id=>({id,protocol:'ssh',workspaceId:'workspace'}));
if (peerKitty) await new Promise(resolve=>peer.term.write('\\x1b[>8u',resolve));
if (bracketed) await new Promise(resolve=>source.term.write('\\x1b[?2004h',resolve));
const key = new KeyboardEvent('keydown', {
bubbles:true,cancelable:true,key:'Insert',code:'Insert',keyCode:45,shiftKey:true,
});
source.term.textarea.dispatchEvent(key);
const beforePaste = [...wires.get('peer')];
if (delayed) await new Promise(resolve=>setTimeout(resolve,10));
// Supply the native clipboard event which Chromium emits for this chord.
const paste = new Event('paste', {bubbles:true,cancelable:true});
Object.defineProperty(paste,'clipboardData',{value:{getData:()=> 'clipboard'}});
source.term.textarea.dispatchEvent(paste);
source.term.textarea.dispatchEvent(new KeyboardEvent('keyup', {
bubbles:true,key:'Insert',code:'Insert',keyCode:45,shiftKey:true,
}));
results.push({bracketed,delayed,peerKitty,beforePaste,
prevented:key.defaultPrevented,source:[...wires.get('source')],peer:[...wires.get('peer')]});
// Ordinary Insert still uses the physical-key broadcast path.
if (!peerKitty) {
wires.get('source').length=0; wires.get('peer').length=0;
source.term.textarea.dispatchEvent(new KeyboardEvent('keydown', {
bubbles:true,cancelable:true,key:'Insert',code:'Insert',keyCode:45,
}));
results.push({plainInsert:true,source:[...wires.get('source')],peer:[...wires.get('peer')]});
}
} finally { source.dispose(); peer.dispose(); }
}
}
}
return JSON.stringify(results);
})()`);
for (const result of JSON.parse(results)) {
if (result.plainInsert) {
assert.deepEqual(result.source, ["\x1b[2~"]);
assert.deepEqual(result.peer, ["\x1b[2~"]);
continue;
}
const label = JSON.stringify(result);
assert.deepEqual(result.beforePaste, [], label);
assert.equal(result.prevented, false, "native paste must remain enabled: " + label);
const expected = result.bracketed ? "\x1b[200~clipboard\x1b[201~" : "clipboard";
assert.deepEqual(result.source, [expected], label);
assert.deepEqual(result.peer, [expected], label);
}
} finally {
dom.window.close();
}
});
@@ -67,7 +67,7 @@ type AltScreenProbeResult = {
* DECSET modes that enter/leave the alternate screen. Resumes from incomplete
* parser state so long param lists can span PTY/schedule flushes.
*/
const probeAltScreenScheduling = (
export const probeAltScreenScheduling = (
data: string,
resume: IncompletePrivateCsi | null,
): AltScreenProbeResult => {
@@ -0,0 +1,157 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
createWebglContextBudget,
type WebglContextBudgetMember,
} from "./webglContextBudget";
function createClock() {
let time = 0;
let nextTimerId = 1;
const timers = new Map<number, { at: number; run: () => void }>();
return {
now: () => time,
setTimer: (run: () => void, delayMs: number) => {
const id = nextTimerId++;
timers.set(id, { at: time + delayMs, run });
return id as unknown as ReturnType<typeof setTimeout>;
},
clearTimer: (id: ReturnType<typeof setTimeout>) => {
timers.delete(id as unknown as number);
},
get pendingTimers() { return timers.size; },
advance(ms: number) {
const end = time + ms;
for (;;) {
const next = [...timers.entries()]
.filter(([, timer]) => timer.at <= end)
.sort((a, b) => a[1].at - b[1].at)[0];
if (!next) break;
timers.delete(next[0]);
time = next[1].at;
next[1].run();
}
time = end;
},
};
}
class FakeMember implements WebglContextBudgetMember {
hiddenSince: number | null = null;
releases = 0;
constructor(readonly name: string) {}
getHiddenSince() { return this.hiddenSince; }
release() { this.releases += 1; }
}
function setup(maxActiveContexts = 3) {
const clock = createClock();
const budget = createWebglContextBudget({
maxActiveContexts,
minHiddenMs: 3_000,
now: clock.now,
setTimer: clock.setTimer,
clearTimer: clock.clearTimer,
});
return { clock, budget };
}
test("below the cap every pane keeps its renderer, hidden or not", () => {
const { clock, budget } = setup(3);
const members = ["a", "b", "c"].map((name) => new FakeMember(name));
for (const member of members) assert.equal(budget.tryAcquire(member), true);
for (const member of members) member.hiddenSince = clock.now();
clock.advance(60_000);
for (const member of members) budget.noteHidden(member);
clock.advance(60_000);
assert.deepEqual(members.map((member) => member.releases), [0, 0, 0]);
assert.equal(budget.getActiveCount(), 3);
assert.equal(clock.pendingTimers, 0);
});
test("a visible pane past the cap releases the least recently seen hidden pane", () => {
const { clock, budget } = setup(3);
const [a, b, c] = ["a", "b", "c"].map((name) => new FakeMember(name));
budget.tryAcquire(a);
budget.tryAcquire(b);
budget.tryAcquire(c);
b.hiddenSince = 1_000;
a.hiddenSince = 2_000;
clock.advance(10_000);
const shown = new FakeMember("shown");
assert.equal(budget.tryAcquire(shown), true);
assert.equal(b.releases, 1, "hidden longest goes first");
assert.equal(a.releases, 0);
assert.equal(c.releases, 0, "visible panes are never released");
assert.equal(budget.getActiveCount(), 3);
});
test("recently hidden panes are released only once they stay hidden long enough", () => {
const { clock, budget } = setup(2);
const [a, b] = ["a", "b"].map((name) => new FakeMember(name));
budget.tryAcquire(a);
budget.tryAcquire(b);
clock.advance(5_000);
a.hiddenSince = clock.now();
budget.noteHidden(a);
budget.tryAcquire(new FakeMember("shown"));
assert.equal(a.releases, 0, "a quick tab flip must not lose the renderer");
assert.equal(budget.getActiveCount(), 3);
clock.advance(2_999);
assert.equal(a.releases, 0);
clock.advance(1);
assert.equal(a.releases, 1);
assert.equal(budget.getActiveCount(), 2);
assert.equal(clock.pendingTimers, 0);
});
test("a pane hidden after the budget went over is released once eligible", () => {
const { clock, budget } = setup(2);
const [a, b, c] = ["a", "b", "c"].map((name) => new FakeMember(name));
budget.tryAcquire(a);
budget.tryAcquire(b);
budget.tryAcquire(c);
assert.equal(budget.getActiveCount(), 3, "three visible panes all keep WebGL");
assert.equal(clock.pendingTimers, 0);
b.hiddenSince = clock.now();
budget.noteHidden(b);
clock.advance(3_000);
assert.equal(b.releases, 1);
assert.equal(budget.getActiveCount(), 2);
});
test("a hidden pane cannot take a context once the budget is full", () => {
const { budget } = setup(2);
budget.tryAcquire(new FakeMember("a"));
budget.tryAcquire(new FakeMember("b"));
const hidden = new FakeMember("hidden");
hidden.hiddenSince = 0;
assert.equal(budget.tryAcquire(hidden), false);
assert.equal(budget.getActiveCount(), 2);
hidden.hiddenSince = null;
assert.equal(budget.tryAcquire(hidden), true, "it gets one as soon as it is shown");
});
test("released and forgotten panes free their slot", () => {
const { budget } = setup(1);
const a = new FakeMember("a");
budget.tryAcquire(a);
budget.forget(a);
budget.forget(a);
assert.equal(budget.getActiveCount(), 0);
const hidden = new FakeMember("hidden");
hidden.hiddenSince = 0;
assert.equal(budget.tryAcquire(hidden), true);
assert.equal(budget.tryAcquire(hidden), true, "acquiring twice keeps one slot");
assert.equal(budget.getActiveCount(), 1);
});
@@ -0,0 +1,128 @@
/**
* Process-wide cap on live xterm WebGL contexts.
*
* Chromium keeps at most 16 WebGL contexts alive per renderer process and
* force-loses the one that drew least recently once a new context pushes past
* that. Unless hibernation is on, hidden terminal panes keep their WebGL
* renderer, so a window with many terminal tabs used to cross the limit. Each
* forced loss rebuilt a context, which pushed out another one, until the
* recovery breaker parked those terminals on the DOM renderer for good.
*
* The budget stays below the browser limit by releasing the renderers of panes
* that have been hidden for a while, least recently seen first. Visible panes
* are never released and always get a context. A released pane builds a new
* renderer when it is shown again. Below the cap nothing changes.
*/
export const MAX_ACTIVE_WEBGL_CONTEXTS = 12;
/**
* Same window as the CSS-only tab reveal in useTerminalEffects, so quickly
* flipping between tabs never tears down and rebuilds a renderer.
*/
export const MIN_HIDDEN_MS_BEFORE_WEBGL_RELEASE = 3_000;
export interface WebglContextBudgetMember {
/** When the pane was hidden, or null while it is on screen. */
getHiddenSince(): number | null;
/** Drop the member's WebGL renderer. Must not acquire a context again. */
release(): void;
}
export interface WebglContextBudget {
/**
* Reserve a context for a member about to create its renderer. Visible
* members always get one; hidden members only while the budget has room.
*/
tryAcquire(member: WebglContextBudgetMember): boolean;
/** Give the slot back once the member's renderer is gone. Idempotent. */
forget(member: WebglContextBudgetMember): void;
/** A member holding a context was just hidden; release it later if over. */
noteHidden(member: WebglContextBudgetMember): void;
getActiveCount(): number;
}
interface WebglContextBudgetOptions {
maxActiveContexts?: number;
minHiddenMs?: number;
now?: () => number;
setTimer?: (callback: () => void, delayMs: number) => ReturnType<typeof setTimeout>;
clearTimer?: (timer: ReturnType<typeof setTimeout>) => void;
}
export function createWebglContextBudget(
options: WebglContextBudgetOptions = {},
): WebglContextBudget {
const maxActive = options.maxActiveContexts ?? MAX_ACTIVE_WEBGL_CONTEXTS;
const minHiddenMs = options.minHiddenMs ?? MIN_HIDDEN_MS_BEFORE_WEBGL_RELEASE;
const now = options.now ?? Date.now;
const setTimer = options.setTimer
?? ((callback: () => void, delayMs: number) => setTimeout(callback, delayMs));
const clearTimer = options.clearTimer ?? clearTimeout;
const active = new Set<WebglContextBudgetMember>();
let checkTimer: ReturnType<typeof setTimeout> | null = null;
let checkAt = Number.POSITIVE_INFINITY;
const scheduleCheck = (at: number) => {
if (checkTimer !== null) {
if (checkAt <= at) return;
clearTimer(checkTimer);
}
checkAt = at;
checkTimer = setTimer(() => {
checkTimer = null;
checkAt = Number.POSITIVE_INFINITY;
releaseOverflow();
}, Math.max(0, at - now()));
};
const releaseOverflow = () => {
if (active.size <= maxActive) return;
const time = now();
const releasable: Array<{ member: WebglContextBudgetMember; hiddenSince: number }> = [];
let nextReleasableAt = Number.POSITIVE_INFINITY;
for (const member of active) {
const hiddenSince = member.getHiddenSince();
if (hiddenSince === null) continue;
const releasableAt = hiddenSince + minHiddenMs;
if (releasableAt <= time) releasable.push({ member, hiddenSince });
else nextReleasableAt = Math.min(nextReleasableAt, releasableAt);
}
releasable.sort((a, b) => a.hiddenSince - b.hiddenSince);
for (const { member } of releasable) {
if (active.size <= maxActive) break;
active.delete(member);
member.release();
}
if (active.size > maxActive && Number.isFinite(nextReleasableAt)) {
scheduleCheck(nextReleasableAt);
}
};
return {
tryAcquire(member) {
if (active.has(member)) return true;
if (member.getHiddenSince() !== null && active.size >= maxActive) return false;
active.add(member);
releaseOverflow();
return true;
},
forget(member) {
active.delete(member);
},
noteHidden(member) {
if (!active.has(member) || active.size <= maxActive) return;
const hiddenSince = member.getHiddenSince();
if (hiddenSince !== null) scheduleCheck(hiddenSince + minHiddenMs);
},
getActiveCount: () => active.size,
};
}
let sharedBudget: WebglContextBudget | null = null;
/** The budget shared by every terminal in this renderer process. */
export function getSharedWebglContextBudget(): WebglContextBudget {
sharedBudget ??= createWebglContextBudget();
return sharedBudget;
}
@@ -0,0 +1,275 @@
import assert from "node:assert/strict";
import test from "node:test";
import { createWebglContextBudget } from "./webglContextBudget";
import { createWebglRendererController } from "./webglRendererController";
class FakeAddon {
contextLoss: (() => void) | null = null;
disposeCalls = 0;
onContextLoss(callback: () => void) {
this.contextLoss = callback;
}
dispose() {
this.disposeCalls += 1;
}
loseContext() {
assert.ok(this.contextLoss, "addon must install a context-loss handler");
this.contextLoss();
}
}
function createClock() {
let time = 0;
let nextTimerId = 1;
const timers = new Map<number, { at: number; run: () => void }>();
return {
now: () => time,
setTimer: (run: () => void, delayMs: number) => {
const id = nextTimerId++;
timers.set(id, { at: time + delayMs, run });
return id as unknown as ReturnType<typeof setTimeout>;
},
clearTimer: (id: ReturnType<typeof setTimeout>) => {
timers.delete(id as unknown as number);
},
get pendingTimers() { return timers.size; },
advance(ms: number) {
const end = time + ms;
for (;;) {
const next = [...timers.entries()]
.filter(([, timer]) => timer.at <= end)
.sort((a, b) => a[1].at - b[1].at)[0];
if (!next) break;
timers.delete(next[0]);
time = next[1].at;
next[1].run();
}
time = end;
},
};
}
function setup(maxActiveContexts = 12) {
const clock = createClock();
const budget = createWebglContextBudget({
maxActiveContexts,
now: clock.now,
setTimer: clock.setTimer,
clearTimer: clock.clearTimer,
});
const createPane = (initiallyVisible = true) => {
let visible = initiallyVisible;
const addons: FakeAddon[] = [];
const warnings: string[] = [];
let loaded = false;
const controller = createWebglRendererController({
enabled: true,
createAddon: () => {
const addon = new FakeAddon();
addons.push(addon);
return addon;
},
loadAddon: () => {},
repaint: () => {},
setLoaded: (value) => { loaded = value; },
warn: (message) => warnings.push(message),
now: clock.now,
setTimer: clock.setTimer,
clearTimer: clock.clearTimer,
budget,
isVisible: () => visible,
});
return {
controller,
addons,
warnings,
get loaded() { return loaded; },
/** Change visibility the way useTerminalEffects does: ref, then sync. */
setVisible(value: boolean) {
visible = value;
controller.syncVisibility();
},
/** Change only the live ref, as if a sync never reached this runtime. */
setVisibleWithoutSync(value: boolean) {
visible = value;
},
reveal() {
this.setVisible(true);
controller.ensure();
},
};
};
return { clock, budget, createPane };
}
test("a context lost while hidden waits for the pane to be shown", () => {
const { clock, createPane } = setup();
const pane = createPane();
pane.controller.ensure();
pane.setVisible(false);
pane.addons[0].loseContext();
assert.equal(pane.loaded, false);
assert.equal(clock.pendingTimers, 0, "no rebuild while nothing is drawn");
assert.ok(pane.warnings.some((warning) => warning.includes("rebuilding when shown")));
clock.advance(60_000);
assert.equal(pane.addons.length, 1);
pane.reveal();
assert.equal(pane.addons.length, 2);
assert.equal(pane.loaded, true);
});
test("a recovery scheduled while visible is dropped if the pane is hidden first", () => {
const { clock, createPane } = setup();
const pane = createPane();
pane.controller.ensure();
pane.addons[0].loseContext();
assert.equal(clock.pendingTimers, 1);
pane.setVisible(false);
clock.advance(50);
assert.equal(pane.addons.length, 1);
assert.equal(pane.loaded, false);
pane.reveal();
assert.equal(pane.addons.length, 2);
});
test("evictions while hidden do not trip the context-loss breaker", () => {
const { clock, createPane } = setup();
const pane = createPane();
pane.controller.ensure();
for (let round = 0; round < 5; round += 1) {
pane.setVisible(false);
pane.addons.at(-1)!.loseContext();
clock.advance(1_000);
pane.reveal();
assert.equal(pane.loaded, true, `round ${round} rebuilt on reveal`);
}
assert.ok(!pane.warnings.some((warning) => warning.includes("staying on DOM renderer")));
});
test("panes past the budget give up WebGL after staying hidden, and get it back when shown", () => {
const { clock, budget, createPane } = setup(2);
const background = createPane();
const other = createPane();
background.controller.ensure();
other.controller.ensure();
background.setVisible(false);
clock.advance(3_000);
const front = createPane();
front.controller.ensure();
assert.equal(background.loaded, false);
assert.equal(background.addons[0].disposeCalls, 1);
assert.equal(other.loaded, true);
assert.equal(front.loaded, true);
assert.equal(budget.getActiveCount(), 2);
front.setVisible(false);
background.reveal();
assert.equal(background.loaded, true);
assert.equal(budget.getActiveCount(), 3, "the pane just hidden keeps WebGL for a while");
clock.advance(3_000);
assert.equal(front.loaded, false);
assert.equal(budget.getActiveCount(), 2);
});
test("a hidden pane mounted past the budget keeps the DOM renderer until shown", () => {
const { budget, createPane } = setup(1);
createPane().controller.ensure();
const hidden = createPane(false);
hidden.controller.ensure();
assert.equal(hidden.addons.length, 0);
assert.equal(budget.getActiveCount(), 1);
hidden.reveal();
assert.equal(hidden.loaded, true);
});
test("a pane on screen is never refused or released, even if a sync was missed", () => {
const { clock, budget, createPane } = setup(1);
const visible = createPane();
visible.controller.ensure();
visible.setVisible(false);
clock.advance(10_000);
visible.setVisibleWithoutSync(true);
createPane().controller.ensure();
clock.advance(10_000);
assert.equal(visible.loaded, true);
assert.equal(budget.getActiveCount(), 2);
const late = createPane(false);
late.setVisibleWithoutSync(true);
late.controller.ensure();
assert.equal(late.loaded, true);
});
test("suspend, dispose and failed loads return the budget slot", () => {
const { budget, createPane } = setup();
const suspended = createPane();
suspended.controller.ensure();
suspended.controller.suspend();
const disposed = createPane();
disposed.controller.ensure();
disposed.controller.dispose();
assert.equal(budget.getActiveCount(), 0);
const failing = createWebglRendererController({
enabled: true,
createAddon: () => new FakeAddon(),
loadAddon: () => { throw new Error("no WebGL"); },
repaint: () => {},
setLoaded: () => {},
warn: () => {},
budget,
});
failing.ensure();
assert.equal(budget.getActiveCount(), 0);
});
test("every teardown goes through disposeAddon so the context can be released", () => {
const clock = createClock();
const released: FakeAddon[] = [];
const make = (failLoad = false) => createWebglRendererController<FakeAddon>({
enabled: true,
createAddon: () => new FakeAddon(),
loadAddon: () => {
if (failLoad) throw new Error("no WebGL");
},
repaint: () => {},
setLoaded: () => {},
warn: () => {},
now: clock.now,
setTimer: clock.setTimer,
clearTimer: clock.clearTimer,
disposeAddon: (addon) => { released.push(addon); },
});
const suspended = make();
suspended.ensure();
suspended.suspend();
const disposed = make();
disposed.ensure();
disposed.dispose();
make(true).ensure();
const lost = make();
lost.ensure();
lost.getAddon()!.loseContext();
assert.equal(released.length, 4);
assert.ok(released.every((addon) => addon.disposeCalls === 0), "the hook replaces addon.dispose()");
});
Loaded 100 of 208 files, more files were not shown because too many files have changed in this diff. Show more