Compare commits

...
Author SHA1 Message Date
陈大猫 e997d52418 fix(ai): bypass warm model cache on manual refresh 2026-10-23 18:04:22 +08:00
GOUKI9999 02f8c798f7 feat(ai): hydrate model pickers from last-known-good and fetch only on demand
The list-models pipeline already served LKG on failure; this wires the
rest of the LKG-first, manual-refresh policy end to end.

Main: the list-models IPC accepts a force flag — manual refresh always
skips the in-memory cache and the persisted entry and goes live, and a
forced success overwrites the persisted catalog.

Renderer: the panel passes force only from the retry banner and the
picker's refresh button; panel-visibility and send-preflight loads stay
non-force and replay the renderer cache, so with a persisted catalog
main answers them without spending the live-fetch budget.

Picker: a footer refresh icon-button next to the status chip, visible
whenever any fetched catalog (live or last-known-good) or the degraded
warning is present; the spinner row remains the in-flight feedback.
Tooltip localized as ai.chat.refreshModelList in en/es/ru/zh-CN/zh-TW.

Fresh installs are unchanged (no persisted entry → live fetch as
before); existing users stop seeing jitter warnings on panel open and
pick up new CLI models via the refresh button.
2026-10-23 17:22:25 +08:00
GOUKI9999 45b0673ce8 fix(ai): clear the model catalog warning once a last-known-good catalog serves
A failed live catalog fetch serves the persisted last-known-good catalog
with a warning, and every TTL-expired automatic load re-attempted live —
so a backend whose discovery keeps failing re-armed the renderer's
「暂时无法更新实时模型列表」 banner forever, even though the persisted
models worked and selecting one never touched the banner state.

Serve the persisted last-known-good catalog without a live attempt on
every non-force load (the LKG-first policy, decided at the single
fetchSdkModelCatalog choke point): automatic loads now return a clean,
warning-less serve, so the next automatic load after the renderer's TTL
clears a stale banner. Warnings are reserved for an explicit force
refresh that fails, and for a first fetch with neither cache nor
persisted entry. The persisted current-id remap moves into a helper
shared by both serve paths.
2026-10-23 17:22:25 +08:00
651d474e01 fix(vault): treat empty vault_notes_get query as omitted (#3882)
Models pass query: "" on range reads. Accept that as no search so
vault_notes_get does not fail and retry.

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-10-23 17:14:21 +08:00
陈大猫 695fd27688 feat: add terminal context menu shortcut to open SFTP (#3878) 2026-10-23 14:41:06 +08:00
陈大猫 238fe45ee4 fix: avoid repeated atlas rebuilds when switching terminal tabs (#3873)
Deduplicate terminal reveal and focus recovery while preserving changed-layout and explicit resume/fullscreen recovery. Add behavioral regressions.

Related to #3825; this rendering optimization does not establish that the original Windows/macOS issue is resolved.
2026-10-23 13:53:28 +08:00
e780d44f97 fix(mcp): keep managed stdio fail-fast when the bridge drops (#3874)
A managed tool call that races bridge shutdown was still in flight, so
the socket error rejected it with "TCP connection lost". Managed
children do not reread discovery; report that race as not connected
and leave external in-flight wording unchanged.

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-10-23 12:21:20 +08:00
陈大猫 9766f21ecf feat(monitor): shared Linux disk I/O and one-second refresh (#3870) 2026-10-23 12:19:10 +08:00
陈大猫 e9cd311936 fix(mcp): reconnect external stdio bridge after Netcatty restart (#3872)
When NETCATTY_EXTERNAL_MCP_DISCOVERY_FILE is set, rpcCall recovers on demand via ensureConnected(): re-read discovery, reset TCP buffer, connect to current port, auth/verify with refreshed token. Concurrent callers coalesce. Close/error reject in-flight RPCs without replaying. Managed SDK children keep fail-fast.

Fixes #3871
2026-10-23 11:43:15 +08:00
d6871dbca9 feat: toggle the host list with Ctrl/Cmd+Shift+B (#3868)
* feat(shortcuts): toggle the host list with Ctrl/Cmd+Shift+B

Ctrl/Cmd+B already switches broadcast mode and stays available to a focused terminal. The new shortcut reuses the existing host-tree toggle.

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

* fix(shortcuts): let the host list chord through Monaco and idle terminals

Editor focus inside Monaco can toggle the host list. The chord is consumed only when that toggle runs, matching broadcast.

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

---------

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-10-23 10:18:20 +08:00
850c5309b6 feat(terminal): split compose-bar send target from toolbar broadcast (#3860)
* feat(terminal): split compose-bar send target from toolbar broadcast

Toolbar broadcast still fans out keystrokes, paste, and terminal-path
snippets. The compose-bar indicator is now a toggle for whether Enter
sends to broadcast peers or only the active session. Turning the toolbar
switch on or off resets that target to follow it; later flips stay
independent.

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

* fix(terminal): keep active-only compose sends off the toolbar fan-out

The focused workspace executor omitted broadcast, so executeSnippetCommand
fell back to the toolbar switch and still wrote every peer. Pass
broadcast: false on that path.

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

---------

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-10-23 09:48:54 +08:00
e5bfc12a2e perf(vault): store connection-log replay blobs in IndexedDB (#3861)
* perf(vault): store connection-log replay blobs in IndexedDB

Move unsaved connection-log terminal replay out of the multi-megabyte
localStorage blob into one IndexedDB row per log id. Persist only the
changed ids, keep the newest 50, and signal other windows with a tiny
revision key.

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

* fix(vault): keep connection-log replay across boot races and IndexedDB failure

Re-read connection-log metadata after the IndexedDB load before pruning,
return the capture promise through TerminalLayer, and write the unpruned
log blob when the side store rejects the commit.

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

* fix(vault): keep unmigrated connection-log replays until IndexedDB commits

Copy legacy replay ids that are missing from IndexedDB before deleting
the old blob, and do not treat an uncommitted legacy map as the diff
baseline.

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

* fix(vault): migrate legacy replays without clearing newer IndexedDB rows

Insert missing legacy ids in one transaction instead of replaceAll, so a
replay committed after the empty read is not wiped.

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

* fix(vault): rebase the IndexedDB fallback onto the latest connection logs

When the side store rejects a close, attach this window's replay to the
connection-log blob currently on disk instead of writing the stale snapshot.

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

* fix(vault): rebase queued connection-log writes onto the latest logs

A persist task can run after another window changes the log list. Write
the rebased list instead of the snapshot captured when the task was queued.

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

* fix(vault): keep queued connection logs after a remote snapshot

A storage event can replace memory with another window's logs before the
queued write runs. Keep ids from the queued snapshot unless this snapshot
deleted them.

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

* fix(vault): restore only locally changed queued logs

A queued persist was putting back every id from the pre-queue snapshot,
which undid another window's delete of a log this recipe never touched.
Track the ids the recipe added or replaced, and reload IndexedDB during
boot when the revision key moves across the migrate await so that peer
replay is hydrated before this window marks the revision seen.

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

* fix(vault): rebase connection logs after the side-store await

A non-pruned write stored the pre-await snapshot and could replace logs
another window saved during the IndexedDB round trip. Rebase immediately
before that write, shrink the quota fallback until the newest replay fits,
and leave the revision unconsumed when IndexedDB cannot be read.

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

* fix(vault): ignore same-window connection-log echoes

The deferred localStorage notification replays this window's own metadata
write. Applying it replaced connectionLogsRef with an older blob and dropped
logs that were added or finalized while that write was still queued.

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

* fix(vault): do not restore a queued log deleted from memory

A queued add or update was written back when the id was already gone from
memory, which let another window keep that row after a later local delete.
Rebase now applies a changed id only while memory still has it.

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

---------

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-10-23 07:55:24 +08:00
6ab684bcd5 fix(terminal): restore home input after an attached popup closes (#3866)
Attach close preparation clears the popup session ref, then unmount
cleanup called closeSession. That emits netcatty:exit to the home
renderer and can kill the shared PTY. Skip that second close.

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-10-23 03:57:36 +08:00
acecd46d57 fix(sftp): recover from abandoned SFTP channel-open poison on lossy links (#3865)
A timed-out SFTP OPEN used to pin the connection forever when its callback
never arrived. Wait up to 2s, then force-clear the gate and admit a new open.
Late channels are still closed. After TIMEOUT or PENDING, skip the fastPut
reopen and upload on the shared channel.

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-10-23 03:57:29 +08:00
aee852a9ac fix(sftp): resume Retry for every channel-loss error form the bridge already knows (#3864)
canRetryFromCheckpoint and the upload prefix digest missed ECONNABORTED,
socket hang up, ERR_SFTP_CHANNEL_CLOSED, and client-socket. Those Retries
restarted at byte 0 and stranded the staged .part.

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-10-23 03:57:25 +08:00
5bef721ac9 perf(sftp): skip the second promotion lstat when there is no mode to restore (#3862)
New-file uploads have a null existingMode, so restoreRemoteMode is a no-op.
Recheck the destination only after a real mode restore and keep one
pre-rename safety lstat otherwise.

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-10-23 03:57:18 +08:00
4c82424adf feat(terminal): open current session logs from the toolbar (#3834)
* feat: open saved session logs from terminal toolbar

Add one-click access to the current automatic or manual session log, flush before opening, and reuse application selection with a remembered log preference. Preserve recording controls and current-session ownership across worker routing and reconnects.

Refs #3807

* fix(logging): preserve saved logs on failed reconnect and localize failures

Address both Codex review findings with generation- and identity-checked replacement cleanup and localized user-facing error handling.

* fix(logging): guard successful main-process session replacement

Add shared inactive-log cleanup to fallback start handlers with regression coverage. Registration activation follows separately.

* fix(logging): enable log lifecycle cleanup for main-process fallback

* fix(terminal): keep log cleanup and tuck open-log into More actions

Always end a raw session-log write stream on finalize, including when a
flush already disabled it. Keep immediate snapshot retries, and pause
them only while prepare is waiting so a disk error can be reported.
Show open, reveal, and open-with in the toolbar overflow menu when
logging is active, instead of a default toolbar button.

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

* fix(logging): back off failed snapshot writes and drop retired log actions

Persistent snapshot failures retry on a growing delay instead of a tight
loop, and still flush immediately when new output arrives. Hide open-log
actions after a successful reconnect retires a stopped manual log.

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

* fix(terminal): keep open-log actions after automatic logging is turned off

The current automatic log keeps recording until reconnect, so the menu
stays available for that connection and clears on the next successful
connect.

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

* fix(terminal): derive log menu availability from saved session logs

* fix(terminal): nest session log actions in one More actions submenu

Collapse Open, Show in folder, and the two open-with actions into a Session log submenu that follows the Terminal Encoding menu, and drop the remembered-application hint.

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

---------

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-10-22 22:15:21 +08:00
439f981c67 fix(cli): show the final server on PuTTY bastion tab titles (#3859)
* fix(cli): show the final server on PuTTY bastion tab titles

PuTTY jump launches kept the bastion as the TCP host and labeled the tab
with the opaque login string. Reuse the SecureCRT tabName path so an
embedded or leftover target address is the session title, without changing
where the session connects.

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

* fix(cli): validate PuTTY jump tab addresses with net.isIP

The display-host check accepted malformed colon strings such as a:b:c and
rejected IPv4-mapped and zone-scoped IPv6 targets. Use Node's IP parser so
only real addresses become the tab title.

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

---------

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-10-22 21:50:10 +08:00
3d593b206b fix(terminal): close sessions when their owning window is destroyed (#3839)
* fix(terminal): close sessions when their owning renderer is destroyed

* test(terminal): keep a shared SSH transport when a copied window closes

Closing a child window must drop only the shells that window owns.
Cover several tabs on one connection split across the main window and
the copied window, for both the in-process lifetime tracker and the
worker close path.

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

---------

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-10-22 20:00:38 +08:00
f95e490707 perf(hosts): reuse manual order index while flattening the tree (#3842)
Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-10-22 19:50:01 +08:00
DeepFal bc36f6a085 fix(browser): keep the page live beside overlay side panels (#3851)
* fix(browser): keep the page live beside overlay side panels

The embedded host browser is a native WebContentsView that draws above
the renderer, and its occlusion check only yielded to dialogs, menus and
poppers. Opening New host / Edit host from the terminal host tree left
the page drawn over the overlay host editor panel.

Overlay-layout aside panels now carry data-native-view-occluder. Native
views cannot be clipped by a parent View on macOS, so the pane shrinks
the live page to the rectangle left beside a panel that covers a
full-height or full-width edge strip; the page stays interactive while
the editor is open. Poppers are checked against that live rectangle, so
selects inside the panel no longer freeze the page, and pictures for
poppers over the page are offset to where it sits. A panel over the
middle of the pane, or one leaving less than 160px, falls back to a
picture.

* test(browser): provide the pane geometry helper to the first-link harness

The first-link acceptance loads useHostBrowserPanel through a VM that
strips imports and injects dependencies by name, so it threw
"resolveHostBrowserPaneBesidePanels is not defined" once the hook
started using the domain helper. Load domain/hostBrowser.ts and pass
the helper in.
2026-10-22 18:14:06 +08:00
f7a166c3e9 fix(vault): keep the newest connection-log replays when the side store hits quota (#3855)
* fix(vault): keep the newest connection-log replays when the side store hits quota

Once the replay side store neared the localStorage quota (~48 x 1M-char
entries) every write failed with QuotaExceededError, so the stored map
froze with the oldest sessions and newer ones never survived a restart.
On a failed write, retry without the oldest unsaved replay buffers
(dropping 1, 2, 4, ... per attempt, so retries stay O(log n)) until it
fits or none are left. Storage format, per-entry cap, 50-entry cap and
log metadata are unchanged; saved logs and other windows' ids are never
dropped.

* perf(vault): start quota retries from the newest set that last fit

* fix(vault): keep the connection-log quota hint on the vault hook

The domain helper held a process-global hint, so the same logs and map
could keep a different replay set after an earlier caller. The hint now
lives in a useVaultState ref and is passed in and returned. Drop order,
saved and unknown ids, and the size estimate are unchanged.

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

---------

Co-authored-by: Grok Bot <grokbot@local>
Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-10-22 17:42:37 +08:00
7c82ec15d0 perf(vault): skip connection-log side-store parse in non-log windows (#3853)
Settings, tray, and the terminal popup mount useVaultState but never
show connection-log replay. Skip the bulky side-store load and storage
handlers there so a main-window persist does not JSON.parse it in those
renderers. Main-window behaviour and the write path stay unchanged.

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-10-22 16:41:28 +08:00
2849871111 perf(vault): align in-memory connection-log terminalData with the 50-entry persist cap (#3852)
Unsaved logs kept up to 500×1M chars of terminalData in React state while
only 50 survive a restart. After add/update/toggle, drop terminalData from
the oldest unsaved entries beyond MAX_PERSISTED_UNSAVED_TERMINAL_DATA_ENTRIES.
List metadata stays; replay beyond 50 matches restart behaviour.

When the side-store write fails (QuotaExceeded once it nears ~50 MB), keep
the same capped merged map in memory instead of accumulating every log's
replay buffer in connectionLogTerminalDataRef.

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-10-22 15:47:46 +08:00
36057a97d8 perf(vault): merge the connection-log replay side store once per persist (#3848)
Each connection-log write parsed the closed-session terminal-data JSON
twice, merged it twice, and JSON.stringify-compared it twice. Pass the
already-merged map through and compare keys without serializing the
multi-MB replay buffers. Storage format and in-memory state stay the same.

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-10-22 13:31:03 +08:00
41c66f76fa perf(terminal): skip redundant grapheme scans in the output-history tracker (#3847)
The alt-screen preview tracker segmented every print span and recomputed
the last grapheme on every non-ASCII append. Skip the boundary walk when
the remainder already fits the piece cap, and segment the open grapheme
only when a later span actually rejoins it.

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-10-22 13:30:54 +08:00
54cedb09b2 fix(ssh): don't re-prompt host key on rekey; answer it synchronously (#3845)
ssh2 re-runs hostVerifier on every key re-exchange. When the knownHosts
snapshot for the connection doesn't contain the key (first connect with
"Add and continue", "Continue" without saving), the verifier showed the
host-key dialog again and answered asynchronously. While that answer is
pending ssh2 keeps sending channel data ahead of its NEWKEYS, and OpenSSH
drops the connection ("Bad packet length"), killing the shell, rz/sz and
SFTP on that transport. Servers or gateways with a RekeyLimit hit this
after a few MB of transfer.

Like OpenSSH, accept a rekey only if it presents the key already accepted
for this connection, and answer synchronously without prompting.

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-10-22 12:19:14 +08:00
152 changed files with 7480 additions and 439 deletions

No files matched your search

+3 -1
View File
@@ -181,7 +181,9 @@ Organize and navigate your hosts using the view that best fits the moment: grid
## Monitoring View
Inspect CPU, memory, disk, network, and latency readings, with GPU details where available. Choose visible columns, resize them, adjust refresh controls, and open a terminal directly from a monitored host.
Inspect CPU, memory, disk, network, and latency readings, with GPU details where available. Choose visible columns, resize them, select refresh intervals down to 1 second, and open a terminal directly from a monitored host.
Disk I/O shows aggregate read/write throughput for Linux hardware-backed block devices, without counting partitions or stacked virtual devices twice. Rates use two consecutive samples; `--` means unavailable or warming up, not zero activity. Disk I/O is currently unavailable on macOS; existing macOS monitoring is unchanged. The terminal system overview uses the same shared measurements.
<img width="3364" height="1982" alt="image" src="docs/assets/readme/monitoring.webp" />
@@ -1,4 +1,5 @@
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import test from 'node:test';
import {
@@ -8,6 +9,11 @@ import {
handleGlobalHotkeyKeyDownImpl,
markForwardedNativeShortcutEvent,
} from './app/AppHandlers.ts';
import {
DEFAULT_SETTINGS_CHROME_SNAPSHOT,
getSettingsChromeSnapshot,
publishSettingsChromeSnapshot,
} from './state/settingsChromeStore.ts';
import { matchesKeyBinding } from '../domain/models.ts';
import { DEFAULT_KEY_BINDINGS } from '../domain/models/keyBindings.ts';
@@ -829,3 +835,201 @@ for (const scenario of [
});
}
}
for (const scheme of ['pc', 'mac'] as const) {
test(`focused terminal ${scheme} consumes Shift+B for the host list and leaves plain B to broadcast`, () => {
const target = new FakeHTMLElement();
const shiftedActions: string[] = [];
let shiftedPrevented = false;
let shiftedStopped = false;
handleGlobalHotkeyKeyDownImpl(() => ({
HOTKEY_DEBUG: false,
closeTabKeyStr: '',
executeHotkeyAction: (action: string) => {
shiftedActions.push(action);
return action === 'toggleHostTree';
},
hotkeyScheme: scheme,
keyBindings: DEFAULT_KEY_BINDINGS,
matchesKeyBinding,
}), {
key: 'b',
code: 'KeyB',
ctrlKey: scheme === 'pc',
metaKey: scheme === 'mac',
altKey: false,
shiftKey: true,
target,
composedPath: () => [target],
preventDefault: () => { shiftedPrevented = true; },
stopPropagation: () => { shiftedStopped = true; },
} as unknown as KeyboardEvent);
assert.deepEqual(shiftedActions, ['toggleHostTree']);
assert.equal(shiftedPrevented, true);
assert.equal(shiftedStopped, true);
const plainActions: string[] = [];
let plainPrevented = false;
handleGlobalHotkeyKeyDownImpl(() => ({
HOTKEY_DEBUG: false,
closeTabKeyStr: '',
executeHotkeyAction: (action: string) => {
plainActions.push(action);
return undefined;
},
hotkeyScheme: scheme,
keyBindings: DEFAULT_KEY_BINDINGS,
matchesKeyBinding,
}), {
key: 'b',
code: 'KeyB',
ctrlKey: scheme === 'pc',
metaKey: scheme === 'mac',
altKey: false,
shiftKey: false,
target,
composedPath: () => [target],
preventDefault: () => { plainPrevented = true; },
stopPropagation: () => {},
} as unknown as KeyboardEvent);
assert.deepEqual(plainActions, ['broadcast']);
assert.equal(plainPrevented, false);
});
}
test('focused terminal leaves the host-list chord alone when the toggle does not run', () => {
const target = new FakeHTMLElement();
const actions: string[] = [];
let prevented = false;
let stopped = false;
handleGlobalHotkeyKeyDownImpl(() => ({
HOTKEY_DEBUG: false,
closeTabKeyStr: '',
executeHotkeyAction: (action: string) => {
actions.push(action);
return false;
},
hotkeyScheme: 'pc',
keyBindings: DEFAULT_KEY_BINDINGS,
matchesKeyBinding,
}), {
key: 'b',
code: 'KeyB',
ctrlKey: true,
metaKey: false,
altKey: false,
shiftKey: true,
target,
composedPath: () => [target],
preventDefault: () => { prevented = true; },
stopPropagation: () => { stopped = true; },
} as unknown as KeyboardEvent);
assert.deepEqual(actions, ['toggleHostTree']);
assert.equal(prevented, false);
assert.equal(stopped, false);
});
test('host list shortcut reaches a focused Monaco editor and stays out of plain text fields', () => {
const monaco = new FakeMonacoHTMLElement();
const monacoActions: string[] = [];
let monacoPrevented = false;
handleGlobalHotkeyKeyDownImpl(() => ({
HOTKEY_DEBUG: false,
closeTabKeyStr: '',
executeHotkeyAction: (action: string) => {
monacoActions.push(action);
return true;
},
hotkeyScheme: 'pc',
keyBindings: DEFAULT_KEY_BINDINGS,
matchesKeyBinding,
}), {
key: 'b',
code: 'KeyB',
ctrlKey: true,
metaKey: false,
altKey: false,
shiftKey: true,
target: monaco,
composedPath: () => [monaco],
preventDefault: () => { monacoPrevented = true; },
stopPropagation: () => {},
} as unknown as KeyboardEvent);
assert.deepEqual(monacoActions, ['toggleHostTree']);
assert.equal(monacoPrevented, true);
const input = new FakeInputHTMLElement();
const inputActions: string[] = [];
let inputPrevented = false;
handleGlobalHotkeyKeyDownImpl(() => ({
HOTKEY_DEBUG: false,
closeTabKeyStr: '',
executeHotkeyAction: (action: string) => {
inputActions.push(action);
return true;
},
hotkeyScheme: 'pc',
keyBindings: DEFAULT_KEY_BINDINGS,
matchesKeyBinding,
}), {
key: 'b',
code: 'KeyB',
ctrlKey: true,
metaKey: false,
altKey: false,
shiftKey: true,
target: input,
composedPath: () => [input],
preventDefault: () => { inputPrevented = true; },
stopPropagation: () => {},
} as unknown as KeyboardEvent);
assert.deepEqual(inputActions, []);
assert.equal(inputPrevented, false);
});
test('toggleHostTree uses the host list toggle only while that list can show', () => {
const previous = getSettingsChromeSnapshot();
const calls: string[] = [];
const toggleHostTree = () => { calls.push('toggle'); };
const event = {} as KeyboardEvent;
const contextFor = (activeTabId: string, orderedTabs: string[] = [activeTabId]) => ({
activeTabStore: { getActiveTabId: () => activeTabId },
editorTabs: [],
orderedTabs,
sessions: [{ id: 'session-1' }],
settings: {},
toEditorTabId: (id: string) => id,
toggleHostTree,
workspaces: [{ id: 'workspace-1' }],
});
try {
publishSettingsChromeSnapshot({ ...DEFAULT_SETTINGS_CHROME_SNAPSHOT, showHostTreeSidebar: true });
assert.equal(executeHotkeyActionImpl(() => contextFor('session-1'), 'toggleHostTree', event), true);
assert.equal(executeHotkeyActionImpl(() => contextFor('editor:note', []), 'toggleHostTree', event), true);
assert.equal(executeHotkeyActionImpl(() => contextFor('workspace-1'), 'toggleHostTree', event), true);
assert.deepEqual(calls, ['toggle', 'toggle', 'toggle']);
calls.length = 0;
assert.equal(executeHotkeyActionImpl(() => contextFor('vault', []), 'toggleHostTree', event), false);
assert.equal(executeHotkeyActionImpl(() => contextFor('sftp', []), 'toggleHostTree', event), false);
assert.equal(executeHotkeyActionImpl(() => contextFor('plugin-view:demo:main', ['plugin-view:demo:main']), 'toggleHostTree', event), false);
assert.deepEqual(calls, []);
publishSettingsChromeSnapshot({ ...DEFAULT_SETTINGS_CHROME_SNAPSHOT, showHostTreeSidebar: false });
assert.equal(executeHotkeyActionImpl(() => contextFor('session-1'), 'toggleHostTree', event), false);
assert.deepEqual(calls, []);
} finally {
publishSettingsChromeSnapshot(previous);
}
});
test('host list shortcut calls the same store toggle as the toolbar button', () => {
const source = readFileSync(new URL('./app/AppSideEffects.tsx', import.meta.url), 'utf8');
assert.match(source, /toggleHostTree:\s*\(\)\s*=>\s*terminalHostTreeStore\.toggle\(\)/);
});
+28 -6
View File
@@ -9,6 +9,8 @@ import { getAvailablePaneMagnificationController } from '../../domain/paneMagnif
import { sanitizeHostIconFields } from '../../domain/hostIcon';
import { resolveEffectiveTerminalProtocol } from '../../domain/terminalProtocol';
import { getTerminalPassthroughActions } from '../state/useGlobalHotkeys';
import { getSettingsChromeSnapshot } from '../state/settingsChromeStore';
import { isHostTreeWorkTabSurface } from './workTabSurface';
import { tabShortcutDigitFromEvent } from '../../domain/models/keyBindings';
import { isPortForwardingAutoReconnectEnabled } from '../../domain/portForwardingReconnect';
import { buildNumberShortcutTabTargets } from './tabShortcutTargets';
@@ -276,6 +278,9 @@ export function handleGlobalHotkeyKeyDownImpl(getCtx: AppContextGetter, e: Keybo
const paneZoomBinding = keyBindings.find((binding) => binding.action === 'togglePaneZoom');
const paneZoomKeyStr = paneZoomBinding ? (isMac ? paneZoomBinding.mac : paneZoomBinding.pc) : null;
const isPaneZoomHotkey = paneZoomKeyStr ? matchesKeyBinding(e, paneZoomKeyStr, isMac) : false;
const hostTreeBinding = keyBindings.find((binding) => binding.action === 'toggleHostTree');
const hostTreeKeyStr = hostTreeBinding ? (isMac ? hostTreeBinding.mac : hostTreeBinding.pc) : null;
const isHostTreeHotkey = hostTreeKeyStr ? matchesKeyBinding(e, hostTreeKeyStr, isMac) : false;
if (
(isFormElement || isMonacoElement)
@@ -283,6 +288,7 @@ export function handleGlobalHotkeyKeyDownImpl(getCtx: AppContextGetter, e: Keybo
&& e.key !== 'Escape'
&& !isQuickSwitchHotkey
&& !isPaneZoomHotkey
&& !(isMonacoElement && isHostTreeHotkey)
&& !forwardedNativeShortcutEvents.has(e)
) {
return;
@@ -322,9 +328,10 @@ export function handleGlobalHotkeyKeyDownImpl(getCtx: AppContextGetter, e: Keybo
continue;
}
// An unavailable broadcast action must leave the chord to the terminal
// (notably tmux's Ctrl+B prefix). Dispatch once before consuming it.
if (binding.action === 'broadcast') {
// An unavailable action must leave the chord to the terminal.
// Broadcast keeps Ctrl/Cmd+B for tmux; the host list keeps Ctrl/Cmd+Shift+B
// when the sidebar cannot show. Dispatch once before consuming it.
if (binding.action === 'broadcast' || binding.action === 'toggleHostTree') {
if (executeHotkeyAction(binding.action, e) === true) {
e.preventDefault();
e.stopPropagation();
@@ -801,7 +808,7 @@ export async function closeTabsBatchImpl(getCtx: AppContextGetter, targetIds: st
}
export function executeHotkeyActionImpl(getCtx: AppContextGetter, action: string, e: KeyboardEvent) {
const { IS_DEV, MOVE_FOCUS_DEBOUNCE_MS, activeTabStore, addConnectionLogRef, canUseGlobalBroadcast, closePluginViewTab, closeSession, closeTabInFlightRef, closeWorkspace, collectSessionIds, confirmIfBusyLocalTerminal, createLocalTerminalWithCurrentShell, editorTabs, fromEditorTabId, handleOpenSettingsRef, handleRequestCloseEditorTabRef, isEditorTabId, isPluginViewTabId, isQuickSwitcherOpen, lastMoveFocusTimeRef, moveFocusInWorkspace, orderedTabs, resolveCloseIntent, resolveSnippetsShortcutIntent, sessions, setActiveTabId, setAddToWorkspaceDialog, setIsQuickSwitcherOpen, setNavigateToSection, settings, sftpPaneMagnificationRef, splitSessionWithCurrentShell, systemInfoRef, terminalPaneMagnificationRef, toEditorTabId, toggleBroadcast, toggleGlobalBroadcast, toggleScriptsSidePanelRef, toggleSidePanelRef, toggleWindowOpacity, workspaces } = getCtx();
const { IS_DEV, MOVE_FOCUS_DEBOUNCE_MS, activeTabStore, addConnectionLogRef, canUseGlobalBroadcast, closePluginViewTab, closeSession, closeTabInFlightRef, closeWorkspace, collectSessionIds, confirmIfBusyLocalTerminal, createLocalTerminalWithCurrentShell, editorTabs, fromEditorTabId, handleOpenSettingsRef, handleRequestCloseEditorTabRef, isEditorTabId, isPluginViewTabId, isQuickSwitcherOpen, lastMoveFocusTimeRef, moveFocusInWorkspace, orderedTabs, resolveCloseIntent, resolveSnippetsShortcutIntent, sessions, setActiveTabId, setAddToWorkspaceDialog, setIsQuickSwitcherOpen, setNavigateToSection, settings, sftpPaneMagnificationRef, splitSessionWithCurrentShell, systemInfoRef, terminalPaneMagnificationRef, toEditorTabId, toggleBroadcast, toggleGlobalBroadcast, toggleHostTree, toggleScriptsSidePanelRef, toggleSidePanelRef, toggleWindowOpacity, workspaces } = getCtx();
{
const shortcutTabs = buildNumberShortcutTabTargets({
showSftpTab: settings.showSftpTab ?? true,
@@ -997,6 +1004,18 @@ export function executeHotkeyActionImpl(getCtx: AppContextGetter, action: string
case 'toggleSidePanel':
toggleSidePanelRef.current?.();
break;
case 'toggleHostTree': {
const currentId = activeTabStore.getActiveTabId();
if (!isHostTreeWorkTabSurface({
enabled: getSettingsChromeSnapshot().showHostTreeSidebar,
activeTabId: currentId,
orderedTabs: orderedTabs ?? [],
sessionIds: new Set((sessions ?? []).map((session: TerminalSession) => session.id)),
workspaceIds: new Set((workspaces ?? []).map((workspace: { id: string }) => workspace.id)),
}) || typeof toggleHostTree !== 'function') return false;
toggleHostTree();
return true;
}
case 'broadcast': {
// Workspace tabs keep their local broadcast state; orphan tabs share
// the global state when at least two visible orphan tabs exist.
@@ -1184,7 +1203,7 @@ export function handleConnectToHostImpl(getCtx: AppContextGetter, host: Host, hi
}
}
export function handleTerminalDataCaptureImpl(getCtx: AppContextGetter, sessionId: string, data: string) {
export async function handleTerminalDataCaptureImpl(getCtx: AppContextGetter, sessionId: string, data: string) {
const { IS_DEV, connectionLogs, selectConnectionLogForTerminalDataCapture, sessions, updateConnectionLog } = getCtx();
{
if (IS_DEV) console.log('[handleTerminalDataCapture] Called', { sessionId, dataLength: data.length });
@@ -1200,7 +1219,10 @@ export function handleTerminalDataCaptureImpl(getCtx: AppContextGetter, sessionI
if (IS_DEV) console.log('[handleTerminalDataCapture] Matching log', matchingLog);
if (matchingLog) {
updateConnectionLog(matchingLog.id, {
// The IndexedDB replay write commits asynchronously. Waiting here keeps
// a crash during session teardown from dropping the newest capture any
// more often than the old synchronous localStorage write.
await updateConnectionLog(matchingLog.id, {
endTime: Date.now(),
terminalData: data,
});
+2
View File
@@ -70,6 +70,7 @@ import {
import { getCredentialProtectionAvailability } from '../../infrastructure/services/credentialProtection';
import { netcattyBridge } from '../../infrastructure/services/netcattyBridge';
import { localStorageAdapter } from '../../infrastructure/persistence/localStorageAdapter';
import { terminalHostTreeStore } from '../state/terminalHostTreeStore';
import {
markExternalMcpStartupReady,
readExternalMcpFocusOnHostOpen,
@@ -1126,6 +1127,7 @@ export function AppSideEffects() {
toggleBroadcast,
toggleGlobalBroadcast,
canUseGlobalBroadcast: canUseGlobalBroadcastRef.current,
toggleHostTree: () => terminalHostTreeStore.toggle(),
toggleScriptsSidePanelRef,
toggleSidePanelRef,
toggleWindowOpacity,
+5 -1
View File
@@ -272,7 +272,7 @@ function AppViewInner({ domains }: AppViewProps) {
handleEndSessionDrag, handleFollowAppTerminalThemeChange, handleHostConnectWithProtocolCheck, handleHotkeyAction, handleKeyboardInteractiveCancel, handleKeyboardInteractiveSubmit,
handleOpenHostFromVaultNote, handleOpenQuickSwitcher, handleOpenSettings, handleOpenVaultHostFromChat, handleOpenVaultNoteFromChat, handleOpenVaultSectionFromChat, handleOpenVaultSnippetFromChat, handleRootContextMenu, handlePassphraseCancel, handlePassphraseSkip, handlePassphraseSubmit, handleFidoPromptSubmit, handleFidoPromptCancel, handleProtocolSelect,
handleRequestCloseEditorTabRef, handleSessionStatusChange, handleSyncNowManual, handleTerminalDataCapture, handleUpdateHostFromTerminal,
hostById, hosts, terminalHosts, updateTerminalHosts, hotkeyScheme, identities, importOrReuseKey, isBroadcastEnabled, isGlobalBroadcastEnabled, onToggleGlobalBroadcast, canUseGlobalBroadcast, isCreateWorkspaceOpen, isMacClient, isQuickSwitcherOpen,
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,
resolveEmptyVaultConflict, resolveSessionAppearance, runSnippet, sessionLogsDir, sessionLogsEnabled, sessionLogsFormat, sessionLogsTimestampsEnabled, sessionRenameTarget, sshDebugLogsEnabled,
@@ -784,8 +784,12 @@ function AppViewInner({ domains }: AppViewProps) {
onCreateLocalTerminal={handleCreateLocalTerminal}
isBroadcastEnabled={isBroadcastEnabled}
onToggleBroadcast={toggleBroadcast}
isComposeSendToAll={isComposeSendToAll}
onToggleComposeSendTarget={onToggleComposeSendTarget}
isGlobalBroadcastEnabled={isGlobalBroadcastEnabled}
isGlobalComposeSendToAll={isGlobalComposeSendToAll}
onToggleGlobalBroadcast={onToggleGlobalBroadcast}
onToggleGlobalComposeSendTarget={onToggleGlobalComposeSendTarget}
canUseGlobalBroadcast={canUseGlobalBroadcast}
updateHosts={updateTerminalHosts}
updateSnippets={updateSnippets}
+4
View File
@@ -205,7 +205,11 @@ export function TerminalHost() {
updateTerminalHosts: handlers.updateTerminalHosts,
hotkeyScheme: terminalSettings.hotkeyScheme,
isBroadcastEnabled: sessionActions?.isBroadcastEnabled,
isComposeSendToAll: sessionActions?.isComposeSendToAll,
onToggleComposeSendTarget: sessionActions?.toggleComposeSendTarget,
isGlobalBroadcastEnabled: sessionActions?.isGlobalBroadcastEnabled,
isGlobalComposeSendToAll: sessionActions?.isGlobalComposeSendToAll,
onToggleGlobalComposeSendTarget: sessionActions?.toggleGlobalComposeSendTarget,
canUseGlobalBroadcast: sessionActions?.canUseGlobalBroadcast,
keyBindings: terminalSettings.keyBindings,
openNoteRequest: local.openNoteRequest,
@@ -70,8 +70,12 @@ export function SessionPublisher({ persistSessionRestore, children }: SessionPub
reorderTabs,
toggleBroadcast,
isBroadcastEnabled,
toggleComposeSendTarget,
isComposeSendToAll,
toggleGlobalBroadcast,
isGlobalBroadcastEnabled,
toggleGlobalComposeSendTarget,
isGlobalComposeSendToAll,
canUseGlobalBroadcast,
copySession,
copyWorkspace,
@@ -150,8 +154,12 @@ export function SessionPublisher({ persistSessionRestore, children }: SessionPub
reorderTabs,
toggleBroadcast,
isBroadcastEnabled,
toggleComposeSendTarget,
isComposeSendToAll,
toggleGlobalBroadcast,
isGlobalBroadcastEnabled,
toggleGlobalComposeSendTarget,
isGlobalComposeSendToAll,
canUseGlobalBroadcast,
copySession,
copyWorkspace,
@@ -181,7 +189,9 @@ export function SessionPublisher({ persistSessionRestore, children }: SessionPub
getOrderedWorkTabs,
getSessionRestoreCwd,
isBroadcastEnabled,
isComposeSendToAll,
isGlobalBroadcastEnabled,
isGlobalComposeSendToAll,
moveFocusInWorkspace,
openLogView,
canUseGlobalBroadcast,
@@ -201,7 +211,9 @@ export function SessionPublisher({ persistSessionRestore, children }: SessionPub
submitSessionRename,
submitWorkspaceRename,
toggleBroadcast,
toggleComposeSendTarget,
toggleGlobalBroadcast,
toggleGlobalComposeSendTarget,
toggleWorkspaceViewMode,
updateSessionCodingCliProvider,
updateSessionDynamicTitle,
@@ -165,8 +165,12 @@ test('vault init cancels the superseded StrictMode effect before publishing read
);
const initAt = vaultSource.indexOf('let cancelled = false;');
assert.notEqual(initAt, -1, 'vault init must track cancellation');
const initSlice = vaultSource.slice(initAt, initAt + 12000);
assert.match(initSlice, /return \(\) => \{\s*\n\s*cancelled = true;\s*\n\s*\};/);
const initSlice = vaultSource.slice(initAt, initAt + 20000);
assert.match(
initSlice,
/return \(\) => \{\s*\n\s*cancelled = true;\s*\n\s*resolveTerminalDataReady\(\);\s*\n\s*\};/,
'StrictMode cleanup cancels init and unblocks the terminal-data ready promise',
);
assert.match(
initSlice,
/if \(!cancelled\) \{\s*\n\s*setIsInitialized\(true\);\s*\n\s*setVaultInitialized\(true\);\s*\n\s*\}/,
+1
View File
@@ -582,6 +582,7 @@ export const enAiMessages: Messages = {
'ai.chat.modelCatalogSourceCached': 'Cached · last success {time}',
'ai.chat.modelCatalogSourceCachedNoTime': 'Cached',
'ai.chat.modelCatalogSourcePresets': 'Built-in default list',
'ai.chat.refreshModelList': 'Refresh model list',
'ai.chat.noMatchingModels': 'No matching models',
'ai.chat.recent': 'Recent',
'ai.chat.viewAll': 'View All',
+1
View File
@@ -964,6 +964,7 @@ Highlight the focused split pane:
'settings.shortcuts.category.terminal': 'Terminal',
'settings.shortcuts.category.navigation': 'Navigation',
'settings.shortcuts.category.app': 'App',
'settings.shortcuts.binding.toggle-host-tree': 'Toggle Host List',
'settings.shortcuts.category.sftp': 'SFTP',
// Context menus / common actions
+5
View File
@@ -1,4 +1,9 @@
export const enMonitorMessages = {
"monitor.diskIo": "Disk I/O",
"monitor.diskRead": "Read",
"monitor.diskWrite": "Write",
"monitor.diskIoHint": "Aggregate Linux hardware-backed block-device I/O, excluding partitions and stacked virtual devices. Unavailable until two valid samples; not supported on macOS.",
"monitor.actions": "Panel actions",
"monitor.unavailable": "Monitoring is unavailable. Try starting again when the connection is restored.",
"monitor.username": "Username",
+11
View File
@@ -1,6 +1,15 @@
import type { Messages } from '../types';
export const enTerminalMessages: Messages = {
'terminal.toolbar.openSessionLog': 'Open session log',
'terminal.toolbar.sessionLogActions': 'Session log actions',
'terminal.toolbar.sessionLog.menu': 'Session log',
'terminal.toolbar.sessionLog.open': 'Open',
'terminal.toolbar.sessionLog.reveal': 'Show in folder',
'terminal.toolbar.sessionLog.chooseApplication': 'Open with…',
'terminal.toolbar.sessionLog.systemDefault': 'Open with system default',
'terminal.toolbar.openSessionLogFailed': 'Failed to open session log.',
'terminal.menu.saveScreen': 'Save current screen',
'terminal.saveScreen.failed': 'Failed to save terminal screen.',
// Multi-line paste confirmation dialog (#3398)
@@ -68,6 +77,8 @@ export const enTerminalMessages: Messages = {
'terminal.composeBar.send': 'Send',
'terminal.composeBar.close': 'Close compose bar',
'terminal.composeBar.broadcasting': 'Broadcasting to all sessions',
'terminal.composeBar.sendToAll': 'Send to all sessions',
'terminal.composeBar.sendToActive': 'Send to the active session only',
'terminal.composeBar.broadcastAllowPassword': 'Broadcast without password protection',
'terminal.composeBar.resize': 'Resize compose bar height',
'terminal.composeBar.manageSnippets': 'Manage quick snippets',
+1
View File
@@ -565,6 +565,7 @@ export const esAiMessages: Messages = {
'ai.chat.modelCatalogSourceCached': 'En caché · último éxito {time}',
'ai.chat.modelCatalogSourceCachedNoTime': 'En caché',
'ai.chat.modelCatalogSourcePresets': 'Lista predeterminada integrada',
'ai.chat.refreshModelList': 'Actualizar la lista de modelos',
'ai.chat.noMatchingModels': 'No hay modelos coincidentes',
'ai.chat.recent': 'Recientes',
'ai.chat.viewAll': 'Ver todo',
+1
View File
@@ -913,6 +913,7 @@ Resalta el panel dividido enfocado:
'settings.shortcuts.binding.broadcast': 'Alternar modo de transmisión',
'settings.shortcuts.binding.toggle-opacity': 'Alternar la transparencia de la ventana',
'settings.shortcuts.binding.toggle-side-panel': 'Alternar panel lateral',
'settings.shortcuts.binding.toggle-host-tree': 'Alternar lista de hosts',
'settings.shortcuts.binding.sftp-copy': 'Copiar archivos',
'settings.shortcuts.binding.sftp-cut': 'Cortar archivos',
'settings.shortcuts.binding.sftp-paste': 'Pegar archivos',
+5
View File
@@ -1,4 +1,9 @@
export const esMonitorMessages = {
"monitor.diskIo": "E/S de disco",
"monitor.diskRead": "Lectura",
"monitor.diskWrite": "Escritura",
"monitor.diskIoHint": "E/S agregada de dispositivos de bloques físicos de Linux, sin contar particiones ni capas virtuales. No disponible hasta tener dos muestras válidas; no compatible con macOS.",
"monitor.actions": "Acciones del panel",
"monitor.unavailable": "La monitorización no está disponible. Iníciala de nuevo cuando se restablezca la conexión.",
"monitor.username": "Usuario",
+11
View File
@@ -1,6 +1,15 @@
import type { Messages } from '../types';
export const esTerminalMessages: Messages = {
'terminal.toolbar.openSessionLog': 'Abrir registro de sesión',
'terminal.toolbar.sessionLogActions': 'Acciones del registro',
'terminal.toolbar.sessionLog.menu': 'Registro de sesión',
'terminal.toolbar.sessionLog.open': 'Abrir',
'terminal.toolbar.sessionLog.reveal': 'Mostrar en carpeta',
'terminal.toolbar.sessionLog.chooseApplication': 'Abrir con…',
'terminal.toolbar.sessionLog.systemDefault': 'Abrir con la aplicación predeterminada',
'terminal.toolbar.openSessionLogFailed': 'No se pudo abrir el registro.',
'terminal.menu.saveScreen': 'Guardar pantalla actual',
'terminal.saveScreen.failed': 'No se pudo guardar la pantalla del terminal.',
// Diálogo de confirmación de pegado multilínea (#3398)
@@ -68,6 +77,8 @@ export const esTerminalMessages: Messages = {
'terminal.composeBar.send': 'Enviar',
'terminal.composeBar.close': 'Cerrar barra de redacción',
'terminal.composeBar.broadcasting': 'Difundiendo a todas las sesiones',
'terminal.composeBar.sendToAll': 'Enviar a todas las sesiones',
'terminal.composeBar.sendToActive': 'Enviar solo a la sesión activa',
'terminal.composeBar.broadcastAllowPassword': 'Difundir sin protección de contraseña',
'terminal.composeBar.resize': 'Cambiar la altura de la barra de redacción',
'terminal.composeBar.manageSnippets': 'Administrar snippets rápidos',
+1
View File
@@ -521,6 +521,7 @@ export const ruAiMessages: Messages = {
'ai.chat.modelCatalogSourceCached': 'Кэш · обновлён {time}',
'ai.chat.modelCatalogSourceCachedNoTime': 'Кэш',
'ai.chat.modelCatalogSourcePresets': 'Встроенный список моделей',
'ai.chat.refreshModelList': 'Обновить список моделей',
'ai.chat.noMatchingModels': 'Нет подходящих моделей',
'ai.chat.recent': 'Недавние',
'ai.chat.viewAll': 'Показать всё',
+1
View File
@@ -938,6 +938,7 @@ export const ruCoreMessages: Messages = {
'settings.shortcuts.binding.broadcast': 'Переключить режим трансляции',
'settings.shortcuts.binding.toggle-opacity': 'Переключить прозрачность окна',
'settings.shortcuts.binding.toggle-side-panel': 'Переключить боковую панель',
'settings.shortcuts.binding.toggle-host-tree': 'Переключить список хостов',
'settings.shortcuts.binding.sftp-copy': 'Копировать файл',
'settings.shortcuts.binding.sftp-cut': 'Вырезать файл',
'settings.shortcuts.binding.sftp-paste': 'Вставить файл',
+5
View File
@@ -1,4 +1,9 @@
export const ruMonitorMessages = {
"monitor.diskIo": "Дисковый ввод-вывод",
"monitor.diskRead": "Чтение",
"monitor.diskWrite": "Запись",
"monitor.diskIoHint": "Суммарный ввод-вывод физических блочных устройств Linux, без разделов и виртуальных слоёв. Доступен после двух корректных замеров; macOS не поддерживается.",
"monitor.actions": "Действия панели",
"monitor.unavailable": "Мониторинг недоступен. Запустите его снова после восстановления связи.",
"monitor.username": "Пользователь",
+11
View File
@@ -1,6 +1,15 @@
import type { Messages } from '../types';
export const ruTerminalMessages: Messages = {
'terminal.toolbar.openSessionLog': 'Открыть журнал сессии',
'terminal.toolbar.sessionLogActions': 'Действия с журналом',
'terminal.toolbar.sessionLog.menu': 'Журнал сессии',
'terminal.toolbar.sessionLog.open': 'Открыть',
'terminal.toolbar.sessionLog.reveal': 'Показать в папке',
'terminal.toolbar.sessionLog.chooseApplication': 'Открыть с помощью…',
'terminal.toolbar.sessionLog.systemDefault': 'Открыть по умолчанию',
'terminal.toolbar.openSessionLogFailed': 'Не удалось открыть журнал.',
'terminal.menu.saveScreen': 'Сохранить текущий экран',
'terminal.saveScreen.failed': 'Не удалось сохранить экран терминала.',
// Диалог подтверждения многострочной вставки (#3398)
@@ -89,6 +98,8 @@ export const ruTerminalMessages: Messages = {
'terminal.composeBar.send': 'Отправить',
'terminal.composeBar.close': 'Закрыть строку ввода',
'terminal.composeBar.broadcasting': 'Трансляция во все сессии',
'terminal.composeBar.sendToAll': 'Отправить во все сессии',
'terminal.composeBar.sendToActive': 'Отправить только в активную сессию',
'terminal.composeBar.broadcastAllowPassword': 'Транслировать без защиты пароля',
'terminal.composeBar.resize': 'Изменить высоту строки ввода',
'terminal.composeBar.manageSnippets': 'Управление быстрыми сниппетами',
@@ -0,0 +1,32 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import en from './en';
import es from './es';
import ru from './ru';
import zhCN from './zh-CN';
import zhTW from './zh-TW';
const keys = [
'terminal.toolbar.openSessionLog',
'terminal.toolbar.sessionLogActions',
'terminal.toolbar.sessionLog.menu',
'terminal.toolbar.sessionLog.open',
'terminal.toolbar.sessionLog.reveal',
'terminal.toolbar.sessionLog.chooseApplication',
'terminal.toolbar.sessionLog.systemDefault',
'terminal.toolbar.openSessionLogFailed',
] as const;
test('saved session log actions are translated in every shipped locale', () => {
for (const [locale, messages] of Object.entries({ en, es, ru, 'zh-CN': zhCN, 'zh-TW': zhTW })) {
for (const key of keys) assert.ok(messages[key], `${locale} is missing ${key}`);
assert.equal(messages['terminal.toolbar.sessionLog.rememberApplication'], undefined);
}
assert.equal(en['terminal.toolbar.sessionLog.menu'], 'Session log');
assert.equal(en['terminal.toolbar.sessionLog.open'], 'Open');
assert.equal(en['terminal.toolbar.sessionLog.reveal'], 'Show in folder');
assert.equal(en['terminal.toolbar.sessionLog.chooseApplication'], 'Open with…');
assert.equal(en['terminal.toolbar.sessionLog.systemDefault'], 'Open with system default');
assert.equal(zhCN['terminal.toolbar.sessionLog.menu'], '会话日志');
assert.equal(zhTW['terminal.toolbar.sessionLog.menu'], '工作階段日誌');
});
+1
View File
@@ -582,6 +582,7 @@ export const zhCNAiMessages: Messages = {
'ai.chat.modelCatalogSourceCached': '缓存 · 上次成功 {time}',
'ai.chat.modelCatalogSourceCachedNoTime': '缓存',
'ai.chat.modelCatalogSourcePresets': '内置默认列表',
'ai.chat.refreshModelList': '刷新模型列表',
'ai.chat.noMatchingModels': '没有匹配的模型',
'ai.chat.recent': '最近',
'ai.chat.viewAll': '查看全部',
@@ -1,4 +1,9 @@
export const zhCNMonitorMessages = {
"monitor.diskIo": "磁盘 I/O",
"monitor.diskRead": "读取",
"monitor.diskWrite": "写入",
"monitor.diskIoHint": "Linux 底层块设备的汇总 I/O,不重复统计分区和叠加的虚拟设备。取得两次有效采样前不可用;暂不支持 macOS。",
"monitor.actions": "面板操作",
"monitor.unavailable": "监控暂不可用,连接恢复后请重新开始。",
"monitor.username": "用户名",
@@ -1,6 +1,15 @@
import type { Messages } from '../types';
export const zhCNTerminalMessages: Messages = {
'terminal.toolbar.openSessionLog': '打开会话日志',
'terminal.toolbar.sessionLogActions': '会话日志操作',
'terminal.toolbar.sessionLog.menu': '会话日志',
'terminal.toolbar.sessionLog.open': '打开',
'terminal.toolbar.sessionLog.reveal': '在文件夹中显示',
'terminal.toolbar.sessionLog.chooseApplication': '打开方式…',
'terminal.toolbar.sessionLog.systemDefault': '用系统默认应用打开',
'terminal.toolbar.openSessionLogFailed': '无法打开会话日志。',
'terminal.menu.saveScreen': '保存当前屏幕',
'terminal.saveScreen.failed': '保存终端屏幕失败。',
// 多行粘贴确认对话框(#3398)
@@ -612,6 +621,7 @@ export const zhCNTerminalMessages: Messages = {
'settings.shortcuts.binding.broadcast': '切换广播模式',
'settings.shortcuts.binding.toggle-opacity': '切换窗口透明度',
'settings.shortcuts.binding.toggle-side-panel': '切换侧边栏',
'settings.shortcuts.binding.toggle-host-tree': '切换主机列表',
'settings.shortcuts.binding.sftp-copy': '复制文件',
'settings.shortcuts.binding.sftp-cut': '剪切文件',
'settings.shortcuts.binding.sftp-paste': '粘贴文件',
+2
View File
@@ -524,6 +524,8 @@ export const zhCNVaultMessages: Messages = {
'terminal.composeBar.send': '发送',
'terminal.composeBar.close': '关闭撰写栏',
'terminal.composeBar.broadcasting': '正在广播到所有会话',
'terminal.composeBar.sendToAll': '发送到所有会话',
'terminal.composeBar.sendToActive': '仅发送到当前会话',
'terminal.composeBar.broadcastAllowPassword': '广播时不对密码进行保护',
'terminal.composeBar.resize': '拖拽调整撰写栏高度',
'terminal.composeBar.manageSnippets': '管理快捷代码片段',
+1
View File
@@ -572,6 +572,7 @@ export const zhTWAiMessages: Messages = {
'ai.chat.modelCatalogSourceCached': '快取 · 上次成功 {time}',
'ai.chat.modelCatalogSourceCachedNoTime': '快取',
'ai.chat.modelCatalogSourcePresets': '內建預設清單',
'ai.chat.refreshModelList': '重新整理模型清單',
'ai.chat.noMatchingModels': '沒有符合的模型',
'ai.chat.recent': '最近',
'ai.chat.viewAll': '檢視全部',
@@ -1,4 +1,9 @@
export const zhTWMonitorMessages = {
"monitor.diskIo": "磁碟 I/O",
"monitor.diskRead": "讀取",
"monitor.diskWrite": "寫入",
"monitor.diskIoHint": "Linux 底層區塊裝置的彙總 I/O,不重複統計分割區與疊加的虛擬裝置。取得兩次有效取樣前無法使用;暫不支援 macOS。",
"monitor.actions": "面板操作",
"monitor.unavailable": "監控暫時無法使用,連線恢復後請重新開始。",
"monitor.username": "使用者名稱",
@@ -1,6 +1,15 @@
import type { Messages } from '../types';
export const zhTWTerminalMessages: Messages = {
'terminal.toolbar.openSessionLog': '開啟工作階段日誌',
'terminal.toolbar.sessionLogActions': '工作階段日誌操作',
'terminal.toolbar.sessionLog.menu': '工作階段日誌',
'terminal.toolbar.sessionLog.open': '開啟',
'terminal.toolbar.sessionLog.reveal': '在資料夾中顯示',
'terminal.toolbar.sessionLog.chooseApplication': '開啟方式…',
'terminal.toolbar.sessionLog.systemDefault': '用系統預設應用程式開啟',
'terminal.toolbar.openSessionLogFailed': '無法開啟工作階段日誌。',
'terminal.menu.saveScreen': '儲存目前畫面',
'terminal.saveScreen.failed': '儲存終端畫面失敗。',
// 多行貼上確認對話框(#3398)
@@ -612,6 +621,7 @@ export const zhTWTerminalMessages: Messages = {
'settings.shortcuts.binding.broadcast': '切換廣播模式',
'settings.shortcuts.binding.toggle-opacity': '切換視窗透明度',
'settings.shortcuts.binding.toggle-side-panel': '切換側邊欄',
'settings.shortcuts.binding.toggle-host-tree': '切換主機列表',
'settings.shortcuts.binding.sftp-copy': '複製檔案',
'settings.shortcuts.binding.sftp-cut': '剪下檔案',
'settings.shortcuts.binding.sftp-paste': '貼上檔案',
+2
View File
@@ -524,6 +524,8 @@ export const zhTWVaultMessages: Messages = {
'terminal.composeBar.send': '傳送',
'terminal.composeBar.close': '關閉撰寫欄',
'terminal.composeBar.broadcasting': '正在廣播到所有工作階段',
'terminal.composeBar.sendToAll': '傳送到所有工作階段',
'terminal.composeBar.sendToActive': '僅傳送到目前工作階段',
'terminal.composeBar.broadcastAllowPassword': '廣播時不對密碼進行保護',
'terminal.composeBar.resize': '拖曳調整撰寫欄高度',
'terminal.composeBar.manageSnippets': '管理快捷程式碼片段',
@@ -0,0 +1,82 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { readFileSync } from 'node:fs';
import { JSDOM } from 'jsdom';
import { performSessionLogAction } from './sessionLogActions';
import { STORAGE_KEY_SESSION_LOG_APPLICATION } from '../../infrastructure/config/storageKeys';
test('session log actions resolve and flush the selected session, remember apps and reset to default', async () => {
const dom = new JSDOM('', { url: 'http://localhost' });
const previousWindow = Object.getOwnPropertyDescriptor(globalThis, 'window');
const previousStorage = Object.getOwnPropertyDescriptor(globalThis, 'localStorage');
Object.defineProperty(globalThis, 'window', { configurable: true, value: dom.window });
Object.defineProperty(globalThis, 'localStorage', { configurable: true, value: dom.window.localStorage });
const calls: unknown[][] = [];
let picked: { path: string; name: string } | null = { path: '/Applications/My Editor.app', name: 'My Editor' };
let available = true;
let failOpen = false;
const bridge = {
prepareSessionLog: async (payload: { sessionId: string }) => {
calls.push(['prepare', payload]);
return available ? { success: true, filePath: `/logs/${payload.sessionId}.html` }
: { success: false, error: 'No saved log for this session' };
},
revealSessionLog: async (payload: { sessionId: string }) => {
calls.push(['reveal', payload]);
return { success: true };
},
selectApplication: async () => { calls.push(['choose']); return picked; },
openWithApplication: async (file: string, app: string) => {
calls.push(['application', file, app]);
if (failOpen) throw new Error('Launch failed');
return true;
},
openWithSystemDefault: async (file: string) => { calls.push(['default', file]); return { success: true }; },
};
Object.assign(dom.window, { netcatty: bridge });
try {
await performSessionLogAction('first', 'open');
assert.deepEqual(calls.splice(0), [['prepare', { sessionId: 'first' }], ['default', '/logs/first.html']]);
await performSessionLogAction('second', 'chooseApplication');
assert.deepEqual(calls.splice(0), [
['prepare', { sessionId: 'second' }], ['choose'], ['prepare', { sessionId: 'second' }],
['application', '/logs/second.html', picked!.path],
]);
assert.equal(JSON.parse(dom.window.localStorage.getItem(STORAGE_KEY_SESSION_LOG_APPLICATION)!).name, 'My Editor');
await performSessionLogAction('first', 'open');
assert.deepEqual(calls.splice(0), [['prepare', { sessionId: 'first' }], ['application', '/logs/first.html', picked!.path]]);
await performSessionLogAction('first', 'reveal');
assert.deepEqual(calls.splice(0), [['reveal', { sessionId: 'first' }]]);
await performSessionLogAction('first', 'systemDefault');
assert.equal(dom.window.localStorage.getItem(STORAGE_KEY_SESSION_LOG_APPLICATION), null);
calls.length = 0;
picked = null;
await performSessionLogAction('first', 'chooseApplication');
assert.deepEqual(calls.splice(0), [['prepare', { sessionId: 'first' }], ['choose']]);
available = false;
await assert.rejects(performSessionLogAction('missing', 'chooseApplication'), /No saved log/);
assert.deepEqual(calls.splice(0), [['prepare', { sessionId: 'missing' }]]);
available = true;
picked = { path: '/missing-editor', name: 'Missing' };
failOpen = true;
await assert.rejects(performSessionLogAction('first', 'chooseApplication'), /Launch failed/);
assert.equal(dom.window.localStorage.getItem(STORAGE_KEY_SESSION_LOG_APPLICATION), null);
} finally {
if (previousWindow) Object.defineProperty(globalThis, 'window', previousWindow);
else Reflect.deleteProperty(globalThis, 'window');
if (previousStorage) Object.defineProperty(globalThis, 'localStorage', previousStorage);
else Reflect.deleteProperty(globalThis, 'localStorage');
dom.window.close();
}
});
// The integration boundary must translate backend errors, including ordinary
// Error instances. Raw filesystem/app-launch details belong only in diagnostics.
test('terminal log action errors use the translated toast instead of raw backend text', () => {
const source = readFileSync(new URL('../../components/Terminal.tsx', import.meta.url), 'utf8');
const handler = source.slice(source.indexOf(' const handleSessionLogAction ='),
source.indexOf(' const handleToggleSessionLog ='));
assert.match(handler, /logger\.error\("\[Terminal\] Failed to open or reveal session log:", err\)/);
assert.match(handler, /toast\.error\(t\('terminal\.toolbar\.openSessionLogFailed'\)\)/);
assert.doesNotMatch(handler, /err\.message/);
});
+48
View File
@@ -0,0 +1,48 @@
import { STORAGE_KEY_SESSION_LOG_APPLICATION } from '../../infrastructure/config/storageKeys';
import { localStorageAdapter } from '../../infrastructure/persistence/localStorageAdapter';
import { netcattyBridge } from '../../infrastructure/services/netcattyBridge';
export type SessionLogAction = 'open' | 'reveal' | 'chooseApplication' | 'systemDefault';
type LogApplication = { path: string; name: string };
/** Resolve the session's actual log in its owning process before opening it. */
export async function performSessionLogAction(sessionId: string, action: SessionLogAction): Promise<void> {
const bridge = netcattyBridge.get();
if (!bridge?.prepareSessionLog) throw new Error('Session log bridge unavailable');
if (action === 'reveal') {
const result = await bridge.revealSessionLog?.({ sessionId });
if (!result?.success) throw new Error(result?.error || 'Failed to reveal session log');
return;
}
// Check availability before presenting an application picker. Resolve again
// afterwards so any output received while the picker was open is flushed.
let result = await bridge.prepareSessionLog({ sessionId });
if (!result.success || !result.filePath) throw new Error(result.error || 'No saved log for this session');
let application = localStorageAdapter.read<LogApplication>(STORAGE_KEY_SESSION_LOG_APPLICATION);
if (action === 'chooseApplication') {
if (!bridge.selectApplication) throw new Error('Application picker unavailable');
application = await bridge.selectApplication();
if (!application) return;
result = await bridge.prepareSessionLog({ sessionId });
if (!result.success || !result.filePath) throw new Error(result.error || 'No saved log for this session');
} else if (action === 'systemDefault') {
application = null;
}
if (application?.path) {
if (!bridge.openWithApplication) throw new Error('Application opening unavailable');
const opened = await bridge.openWithApplication(result.filePath, application.path);
if (!opened) throw new Error('Failed to open session log');
} else {
const opened = await bridge.openWithSystemDefault?.(result.filePath);
if (!opened?.success) throw new Error(opened?.error || 'Failed to open session log');
}
// Only remember a choice after a successful launch; failed launches leave
// the previous preference intact and can be retried or reset from the menu.
if (action === 'chooseApplication') {
localStorageAdapter.write(STORAGE_KEY_SESSION_LOG_APPLICATION, application);
} else if (action === 'systemDefault') {
localStorageAdapter.remove(STORAGE_KEY_SESSION_LOG_APPLICATION);
}
}
@@ -102,6 +102,10 @@ test('session actions register and unregister', () => {
reorderTabs: noop,
toggleBroadcast: noop,
isBroadcastEnabled: noop,
toggleComposeSendTarget: noop,
isComposeSendToAll: noop,
toggleGlobalComposeSendTarget: noop,
isGlobalComposeSendToAll: false,
copySession: noop,
copyWorkspace: noop,
createSessionFromCloneSource: noop,
@@ -68,8 +68,12 @@ export type SessionSnapshotActions = Pick<
| 'reorderTabs'
| 'toggleBroadcast'
| 'isBroadcastEnabled'
| 'toggleComposeSendTarget'
| 'isComposeSendToAll'
| 'toggleGlobalBroadcast'
| 'isGlobalBroadcastEnabled'
| 'toggleGlobalComposeSendTarget'
| 'isGlobalComposeSendToAll'
| 'canUseGlobalBroadcast'
| 'copySession'
| 'copyWorkspace'
@@ -10,6 +10,18 @@ const failed = (over: Partial<TransferTask> = {}): TransferTask => ({
speed: 0, startTime: 1, isDirectory: false, error: "SFTP channel closed", ...over,
});
test("canRetryFromCheckpoint resumes the channel-loss forms the bridge already classifies", () => {
for (const error of [
"read ECONNABORTED",
"socket hang up",
"ERR_SFTP_CHANNEL_CLOSED",
"SFTP pipelined upload failed: socket hang up",
"client-socket",
]) {
assert.equal(canRetryFromCheckpoint(failed({ error })), true, error);
}
});
test("canRetryFromCheckpoint only for a top-level file that lost its connection with progress", () => {
assert.equal(canRetryFromCheckpoint(failed()), true);
assert.equal(canRetryFromCheckpoint(failed({ error: "read ECONNRESET" })), true);
@@ -4,7 +4,11 @@ import type { TransferTask } from "../../../domain/models";
// .part is intact up to the checkpoint, so Retry can continue from it instead
// of restarting at byte 0 (which also strands the old stage on the server,
// because the new attempt stages under a fresh transfer id).
const SESSION_LOSS_ERROR = /SFTP (channel closed|session lost)|ECONNRESET|EPIPE|Not connected|No response from server|Keepalive timeout|connection (lost|closed|reset)/i;
// Keep this identical to UPLOAD_PREFIX_SESSION_LOSS in transferBridge.cjs, and
// cover every code or message isSftpChannelLossError already treats as channel
// loss (including ECONNABORTED, socket hang up, ERR_SFTP_CHANNEL_CLOSED, and
// client-socket). A miss here restarts at byte 0 and strands the .part.
const SESSION_LOSS_ERROR = /SFTP (channel closed|session lost)|ERR_SFTP_CHANNEL_CLOSED|ECONNRESET|ECONNABORTED|EPIPE|Not connected|No response from server|Keepalive timeout|socket hang up|client-socket|connection (lost|closed|reset)/i;
/**
* True when Retry should resume the same task from its checkpoint.
@@ -100,6 +100,10 @@ test("background-agent retry after restart uses fresh dedicated recovery instead
for (const [label, error, expectResume] of [
["dead channel", "SFTP channel closed", true],
["lost session", "SFTP session lost. Please reconnect.", true],
["aborted socket", "read ECONNABORTED", true],
["hung up socket", "socket hang up", true],
["closed channel code", "ERR_SFTP_CHANNEL_CLOSED", true],
["client socket", "client-socket", true],
["content failure", "Permission denied", false],
] as const) {
test(`panel Retry after ${label} ${expectResume ? "resumes the same task from its checkpoint" : "keeps the restart-from-0 retry"}`, async () => {
@@ -125,6 +125,55 @@ test('overlapping nonmodal menus yield with a picture; modal dialogs yield even
await f.tick(); assert.equal(f.layouts.at(-1)?.occluded, true);
});
function panel(f: Awaited<ReturnType<typeof fixture>>, x: number, y: number, width: number, height: number) {
const node = f.overlay('<div data-section="app-host-editor-layer"><div data-native-view-occluder></div></div>').firstElementChild!;
node.getBoundingClientRect = () => new f.env.window.DOMRect(x, y, width, height);
return node;
}
test('an overlay host editor panel narrows the live page beside it, then restores the pane', async (t) => {
const f = await fixture(t);
panel(f, 900, 0, 400, 900); await f.tick();
assert.deepEqual(f.layouts.at(-1)?.bounds, { x: 200, y: 100, width: 500, height: 600 }, 'a panel beside the pane changes nothing');
const editor = panel(f, 450, 0, 600, 900); await f.tick();
assert.equal(f.layouts.at(-1)?.visible, true);
assert.equal(f.layouts.at(-1)?.occluded, false, 'the page stays live instead of becoming a picture');
assert.deepEqual(f.layouts.at(-1)?.bounds, { x: 200, y: 100, width: 250, height: 600 });
assert.deepEqual(f.panel().snapshot, []);
editor.getBoundingClientRect = () => new f.env.window.DOMRect(400, 0, 650, 900);
f.resize(); await f.tick();
assert.deepEqual(f.layouts.at(-1)?.bounds, { x: 200, y: 100, width: 200, height: 600 }, 'resizing the panel follows it');
editor.parentElement!.remove(); await f.tick();
assert.deepEqual(f.layouts.at(-1)?.bounds, { x: 200, y: 100, width: 500, height: 600 });
assert.equal(f.layouts.at(-1)?.occluded, false);
});
test('a popper inside the panel leaves the narrowed page live; one over the page keeps aligned pictures', async (t) => {
const f = await fixture(t);
panel(f, 0, 0, 300, 900); await f.tick();
assert.deepEqual(f.layouts.at(-1)?.bounds, { x: 300, y: 100, width: 400, height: 600 });
const select = f.overlay(tooltip, 140, 110); await f.tick();
assert.equal(f.layouts.at(-1)?.occluded, false, 'a select inside the left panel does not reach the page');
select.remove();
f.overlay(tooltip, 320, 110); await f.tick();
assert.equal(f.layouts.at(-1)?.occluded, true);
assert.deepEqual(f.layouts.at(-1)?.bounds, { x: 300, y: 100, width: 400, height: 600 });
assert.deepEqual(f.panel().snapshot.map((layer) => layer.bounds), [{ x: 100, y: 0, width: 500, height: 600 }],
'pictures are placed where the narrowed page sits inside the pane');
});
test('a panel over the middle of the pane or leaving too little room falls back to a picture', async (t) => {
const f = await fixture(t);
const middle = panel(f, 300, 0, 100, 900); await f.tick();
assert.equal(f.layouts.at(-1)?.occluded, true);
assert.deepEqual(f.layouts.at(-1)?.bounds, { x: 200, y: 100, width: 500, height: 600 });
assert.deepEqual(f.panel().snapshot, layers);
middle.parentElement!.remove(); await f.tick();
assert.equal(f.layouts.at(-1)?.occluded, false);
panel(f, 250, 0, 600, 900); await f.tick();
assert.equal(f.layouts.at(-1)?.occluded, true, 'a 50px strip is not worth a live page');
});
test('closing a menu while capture is pending cannot restore stale pictures', async (t) => {
const f = await fixture(t);
let complete: (result: Result) => void = () => assert.fail('capture was not started');
+26 -9
View File
@@ -1,16 +1,17 @@
import { useEffect, useLayoutEffect, useRef, useState } from 'react';
import { currentBrowserThemeTokens, mountHostBrowser } from '../hostBrowser';
import { resolveHostBrowserPaneBesidePanels, type HostBrowserPaneRect } from '../../domain/hostBrowser';
import type { Host, Identity, KnownHost, SSHKey, TerminalSettings } from '../../domain/models';
import { netcattyBridge } from '../../infrastructure/services/netcattyBridge';
import type { HostBrowserPanelSnapshotLayer } from '../../types/global/netcatty-bridge-sync';
function overlapsBrowser(rect: DOMRect, element: Element): boolean {
function overlapsBrowser(rect: HostBrowserPaneRect, element: Element): boolean {
const style = getComputedStyle(element);
if (style.display === 'none' || style.visibility === 'hidden' || element.closest('[hidden], [inert], [data-state="closed"]')) return false;
const overlay = element.getBoundingClientRect();
return overlay.width > 0 && overlay.height > 0
&& overlay.x < rect.right && overlay.right > rect.x
&& overlay.y < rect.bottom && overlay.bottom > rect.y;
&& overlay.x < rect.x + rect.width && overlay.right > rect.x
&& overlay.y < rect.y + rect.height && overlay.bottom > rect.y;
}
interface BrowserPanelOptions {
@@ -70,12 +71,21 @@ export function useHostBrowserPanel(options: BrowserPanelOptions) {
const rect = element?.getBoundingClientRect();
// Native views sit above renderer portals. Only overlapping poppers need
// to yield; dialogs are modal even when their content is beside the pane.
// Overlay panels stay open while the user works, so the live page moves
// beside them when it can and only falls back to a picture otherwise.
const locked = !!document.querySelector('[data-app-lock-overlay]');
const modal = !!document.querySelector('[role="dialog"][aria-modal="true"], [role="alertdialog"]');
const overlays = Array.from(document.querySelectorAll(
'[role="dialog"], [role="alertdialog"], [role="menu"][data-state="open"], [data-radix-popper-content-wrapper] > *',
'[role="dialog"], [role="alertdialog"], [role="menu"][data-state="open"], [data-radix-popper-content-wrapper] > *, '
+ '[data-native-view-occluder]',
));
const covered = modal || (!!rect && overlays.some((overlay) => overlapsBrowser(rect, overlay)));
const isPanel = (overlay: Element) => overlay.matches('[data-native-view-occluder]');
const panels = rect ? overlays.filter((overlay) => isPanel(overlay) && overlapsBrowser(rect, overlay)) : [];
const live = rect && resolveHostBrowserPaneBesidePanels(rect, panels.map((panel) => panel.getBoundingClientRect()));
// Poppers over the part of the pane hidden behind a panel (e.g. a select
// inside the host editor) no longer touch the live page.
const covered = modal || (!!rect && !live)
|| (!!live && overlays.some((overlay) => !isPanel(overlay) && overlapsBrowser(live, overlay)));
const targets = new Set<Element>(overlays);
for (let target: Element | null = element; target; target = target.parentElement) targets.add(target);
for (const target of observed) {
@@ -90,7 +100,11 @@ export function useHostBrowserPanel(options: BrowserPanelOptions) {
const visible = isVisible && !!element?.isConnected && !!rect
&& rect.width > 0 && rect.height > 0 && !element?.closest('[inert]') && !locked;
if (!visible) setSnapshot(null);
const bounds = rect ? { x: rect.x, y: rect.y, width: rect.width, height: rect.height } : undefined;
const pane = live || rect;
const bounds = pane ? { x: pane.x, y: pane.y, width: pane.width, height: pane.height } : undefined;
// Pictures are relative to the native page, which may sit beside a panel.
const offsetX = rect && bounds ? bounds.x - rect.x : 0;
const offsetY = rect && bounds ? bounds.y - rect.y : 0;
const theme = element && getComputedStyle(element).colorScheme === 'light' ? 'light' : 'dark';
const themeTokens = currentBrowserThemeTokens(element ?? undefined);
const layout: Parameters<NonNullable<NetcattyBridge['setHostBrowserPanelLayout']>>[0] = {
@@ -102,7 +116,9 @@ export function useHostBrowserPanel(options: BrowserPanelOptions) {
const revision = ++layoutRevision;
void bridge?.setHostBrowserPanelLayout?.(layout).then((result) => {
if (disposed || layoutRevision !== revision) return;
setSnapshot(visible && covered && typeof result === 'object' ? { browserId, layers: result.layers } : null);
setSnapshot(visible && covered && typeof result === 'object' ? { browserId, layers: result.layers.map((layer) => ({
...layer, bounds: { ...layer.bounds, x: layer.bounds.x + offsetX, y: layer.bounds.y + offsetY },
})) } : null);
}).catch(() => {});
}
};
@@ -119,14 +135,15 @@ export function useHostBrowserPanel(options: BrowserPanelOptions) {
if (doc) mutationObserver?.observe(doc.documentElement, {
subtree: true, childList: true, attributes: true,
attributeFilter: ['style', 'class', 'hidden', 'inert', 'role', 'aria-modal', 'data-state',
'data-app-lock-overlay', 'data-radix-popper-content-wrapper', 'href', 'media', 'disabled'],
'data-app-lock-overlay', 'data-radix-popper-content-wrapper', 'data-native-view-occluder',
'href', 'media', 'disabled'],
});
const onMotion = (event: Event) => {
const target = event.target;
if (!element || !view || !(target instanceof view.Element)) return;
if (!target.contains(element) && !target.matches(
'[role="dialog"], [role="alertdialog"], [role="menu"], [data-app-lock-overlay], '
+ '[data-radix-popper-content-wrapper], [data-radix-popper-content-wrapper] > *',
+ '[data-radix-popper-content-wrapper], [data-radix-popper-content-wrapper] > *, [data-native-view-occluder]',
)) return;
trackAnimations(target);
schedule();
+4
View File
@@ -54,6 +54,8 @@ export interface ServerStats {
swapTotal: number | null; // Total swap in MB
swapUsed: number | null; // Used swap in MB
topProcesses: ProcessInfo[]; // Top 10 processes by memory
diskReadSpeed?: number | null; // Aggregate Linux hardware-backed block I/O, bytes/sec
diskWriteSpeed?: number | null;
diskPercent: number | null; // Disk usage percentage for root partition
diskUsed: number | null; // Disk used in GB
diskTotal: number | null; // Total disk in GB
@@ -240,6 +242,8 @@ function normalizeServerStats(stats: Partial<ServerStats>): ServerStats {
diskPercent: stats.diskPercent ?? null,
diskUsed: stats.diskUsed ?? null,
diskTotal: stats.diskTotal ?? null,
diskReadSpeed: stats.diskReadSpeed ?? null,
diskWriteSpeed: stats.diskWriteSpeed ?? null,
disks: stats.disks || [],
netRxSpeed: stats.netRxSpeed || 0,
netTxSpeed: stats.netTxSpeed || 0,
+1 -1
View File
@@ -50,7 +50,7 @@ export const useSessionLogBackend = () => {
const getManualSessionLogStatus = useCallback(async (payload: ManualStatusPayload) => {
const bridge = netcattyBridge.get();
return bridge?.getManualSessionLogStatus?.(payload) ?? { success: false, isLogging: false, error: "Session log bridge unavailable" };
return bridge?.getManualSessionLogStatus?.(payload) ?? { success: false, isLogging: false, hasSessionLog: false, error: "Session log bridge unavailable" };
}, []);
return {
@@ -0,0 +1,139 @@
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 { useSessionState } from "./useSessionState.ts";
type SessionState = ReturnType<typeof useSessionState>;
test("compose send target follows toolbar broadcast and then flips independently", async () => {
const previousWindow = Object.getOwnPropertyDescriptor(globalThis, "window");
const eventTarget = new EventTarget() as EventTarget & Record<string, unknown>;
Object.assign(eventTarget, { setTimeout, clearTimeout });
Object.defineProperty(globalThis, "window", {
configurable: true,
value: eventTarget,
});
const actEnvironment = globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean };
const previousActEnvironment = actEnvironment.IS_REACT_ACT_ENVIRONMENT;
actEnvironment.IS_REACT_ACT_ENVIRONMENT = true;
let state: SessionState | null = null;
let renderer: ReactTestRenderer | null = null;
const current = (): SessionState => {
assert.ok(state);
return state;
};
function Probe() {
state = useSessionState({ persistSessionRestore: false });
return null;
}
try {
await act(async () => {
renderer = create(React.createElement(Probe));
});
await act(async () => { current().createLocalTerminal({ shellName: "01" }); });
await act(async () => { current().createLocalTerminal({ shellName: "02" }); });
const [first, second] = current().orphanSessions;
await act(async () => {
current().createWorkspaceFromSessions(first.id, second.id, "right");
});
const workspaceId = current().workspaces[0]?.id;
assert.ok(workspaceId);
assert.equal(current().isBroadcastEnabled(workspaceId), false);
assert.equal(current().isComposeSendToAll(workspaceId), false);
await act(async () => { current().toggleBroadcast(workspaceId); });
assert.equal(current().isBroadcastEnabled(workspaceId), true);
assert.equal(current().isComposeSendToAll(workspaceId), true);
await act(async () => { current().toggleComposeSendTarget(workspaceId); });
assert.equal(current().isBroadcastEnabled(workspaceId), true);
assert.equal(current().isComposeSendToAll(workspaceId), false);
await act(async () => { current().toggleComposeSendTarget(workspaceId); });
assert.equal(current().isComposeSendToAll(workspaceId), true);
assert.equal(current().isBroadcastEnabled(workspaceId), true);
await act(async () => { current().toggleBroadcast(workspaceId); });
assert.equal(current().isBroadcastEnabled(workspaceId), false);
assert.equal(current().isComposeSendToAll(workspaceId), false);
await act(async () => { current().toggleComposeSendTarget(workspaceId); });
assert.equal(current().isBroadcastEnabled(workspaceId), false);
assert.equal(current().isComposeSendToAll(workspaceId), true);
await act(async () => { current().toggleGlobalBroadcast(); });
assert.equal(current().isGlobalBroadcastEnabled, false);
assert.equal(current().isGlobalComposeSendToAll, false);
assert.equal(current().canUseGlobalBroadcast, false);
} finally {
await act(async () => { renderer?.unmount(); });
actEnvironment.IS_REACT_ACT_ENVIRONMENT = previousActEnvironment;
if (previousWindow) Object.defineProperty(globalThis, "window", previousWindow);
else Reflect.deleteProperty(globalThis, "window");
}
});
test("global compose send target follows the toolbar switch for orphan sessions", async () => {
const previousWindow = Object.getOwnPropertyDescriptor(globalThis, "window");
const eventTarget = new EventTarget() as EventTarget & Record<string, unknown>;
Object.assign(eventTarget, { setTimeout, clearTimeout });
Object.defineProperty(globalThis, "window", {
configurable: true,
value: eventTarget,
});
const actEnvironment = globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean };
const previousActEnvironment = actEnvironment.IS_REACT_ACT_ENVIRONMENT;
actEnvironment.IS_REACT_ACT_ENVIRONMENT = true;
let state: SessionState | null = null;
let renderer: ReactTestRenderer | null = null;
const current = (): SessionState => {
assert.ok(state);
return state;
};
function Probe() {
state = useSessionState({ persistSessionRestore: false });
return null;
}
try {
await act(async () => {
renderer = create(React.createElement(Probe));
});
await act(async () => { current().createLocalTerminal({ shellName: "01" }); });
await act(async () => { current().createLocalTerminal({ shellName: "02" }); });
assert.equal(current().canUseGlobalBroadcast, true);
assert.equal(current().isGlobalComposeSendToAll, false);
await act(async () => { current().toggleGlobalBroadcast(); });
assert.equal(current().isGlobalBroadcastEnabled, true);
assert.equal(current().isGlobalComposeSendToAll, true);
await act(async () => { current().toggleGlobalComposeSendTarget(); });
assert.equal(current().isGlobalBroadcastEnabled, true);
assert.equal(current().isGlobalComposeSendToAll, false);
await act(async () => { current().toggleGlobalBroadcast(); });
assert.equal(current().isGlobalBroadcastEnabled, false);
assert.equal(current().isGlobalComposeSendToAll, false);
const closedId = current().orphanSessions[1].id;
await act(async () => { current().toggleGlobalComposeSendTarget(); });
assert.equal(current().isGlobalComposeSendToAll, true);
await act(async () => { current().closeSession(closedId); });
assert.equal(current().canUseGlobalBroadcast, false);
assert.equal(current().isGlobalBroadcastEnabled, false);
assert.equal(current().isGlobalComposeSendToAll, false);
} finally {
await act(async () => { renderer?.unmount(); });
actEnvironment.IS_REACT_ACT_ENVIRONMENT = previousActEnvironment;
if (previousWindow) Object.defineProperty(globalThis, "window", previousWindow);
else Reflect.deleteProperty(globalThis, "window");
}
});
+55 -11
View File
@@ -25,6 +25,10 @@ SplitHint,
updateWorkspaceSplitSizes,
} from '../../domain/workspace';
import { clearSessionFontSizeOverride as clearSessionFontSizeOverrideFields } from '../../domain/terminalAppearance';
import {
composeSendTargetAfterToolbarToggle,
flipComposeSendTarget,
} from '../../domain/terminalBroadcast';
import { buildOrderedWorkTabIds, reorderWorkTabIds } from '../app/workTabSurface';
import { activeTabStore } from './activeTabStore';
import {
@@ -235,8 +239,12 @@ export const useSessionState = ({
const [tabOrder, setTabOrder] = useState<string[]>(initialRestoreState.tabOrder);
// Broadcast mode: stores workspace IDs that have broadcast enabled
const [broadcastWorkspaceIds, setBroadcastWorkspaceIds] = useState<Set<string>>(new Set());
// Compose-bar send target. Toolbar toggles reset it; the compose bar can
// then flip it without changing keystroke fan-out.
const [composeSendAllWorkspaceIds, setComposeSendAllWorkspaceIds] = useState<Set<string>>(new Set());
// Global broadcast mode: enables broadcast for all orphan sessions (not in workspaces)
const [globalBroadcastEnabled, setGlobalBroadcastEnabled] = useState<boolean>(false);
const [globalComposeSendToAll, setGlobalComposeSendToAll] = useState<boolean>(false);
// Log views: stores open log replay tabs
const [logViews, setLogViews] = useState<LogView[]>([]);
const [restorePreviousSessionRevision, setRestorePreviousSessionRevision] = useState(0);
@@ -1137,8 +1145,10 @@ export const useSessionState = ({
const canUseGlobalBroadcast = orphanSessions.length >= 2;
// Losing the group must disarm broadcast before another tab can join it.
if (!canUseGlobalBroadcast && globalBroadcastEnabled) {
setGlobalBroadcastEnabled(false);
// Compose-bar send target follows the toolbar switch back to active-only.
if (!canUseGlobalBroadcast && (globalBroadcastEnabled || globalComposeSendToAll)) {
if (globalBroadcastEnabled) setGlobalBroadcastEnabled(false);
if (globalComposeSendToAll) setGlobalComposeSendToAll(false);
}
const openLogView = useCallback((log: ConnectionLog) => {
@@ -1273,31 +1283,61 @@ export const useSessionState = ({
return newSessionId;
}, [setActiveTabId]);
// Toggle broadcast mode for a workspace
const toggleBroadcast = useCallback((workspaceId: string) => {
setBroadcastWorkspaceIds(prev => {
const setWorkspaceComposeSendTarget = useCallback((workspaceId: string, target: 'all' | 'active') => {
setComposeSendAllWorkspaceIds((prev) => {
const hasAll = prev.has(workspaceId);
if ((target === 'all') === hasAll) return prev;
const next = new Set(prev);
if (next.has(workspaceId)) {
next.delete(workspaceId);
} else {
next.add(workspaceId);
}
if (target === 'all') next.add(workspaceId);
else next.delete(workspaceId);
return next;
});
}, []);
// Toggle broadcast mode for a workspace. Compose-bar send target follows.
const toggleBroadcast = useCallback((workspaceId: string) => {
const enabling = !broadcastWorkspaceIds.has(workspaceId);
setBroadcastWorkspaceIds(prev => {
const next = new Set(prev);
if (prev.has(workspaceId)) next.delete(workspaceId);
else next.add(workspaceId);
return next;
});
setWorkspaceComposeSendTarget(workspaceId, composeSendTargetAfterToolbarToggle(enabling));
}, [broadcastWorkspaceIds, setWorkspaceComposeSendTarget]);
// Check if a workspace has broadcast enabled
const isBroadcastEnabled = useCallback((workspaceId: string) => {
return broadcastWorkspaceIds.has(workspaceId);
}, [broadcastWorkspaceIds]);
const toggleComposeSendTarget = useCallback((workspaceId: string) => {
setWorkspaceComposeSendTarget(
workspaceId,
flipComposeSendTarget(composeSendAllWorkspaceIds.has(workspaceId) ? 'all' : 'active'),
);
}, [composeSendAllWorkspaceIds, setWorkspaceComposeSendTarget]);
const isComposeSendToAll = useCallback((workspaceId: string) => {
return composeSendAllWorkspaceIds.has(workspaceId);
}, [composeSendAllWorkspaceIds]);
// Toggle global broadcast mode for orphan sessions
const toggleGlobalBroadcast = useCallback(() => {
setGlobalBroadcastEnabled(prev => !prev);
setGlobalBroadcastEnabled(prev => {
const next = !prev;
setGlobalComposeSendToAll(composeSendTargetAfterToolbarToggle(next) === 'all');
return next;
});
}, []);
const toggleGlobalComposeSendTarget = useCallback(() => {
setGlobalComposeSendToAll((prev) => flipComposeSendTarget(prev ? 'all' : 'active') === 'all');
}, []);
// Global broadcast enabled state - direct boolean value
const isGlobalBroadcastEnabled = globalBroadcastEnabled;
const isGlobalComposeSendToAll = globalComposeSendToAll;
const baseWorkTabIds = useMemo(() => [
...orphanSessions.map(s => s.id),
@@ -1426,8 +1466,12 @@ export const useSessionState = ({
// Broadcast mode
toggleBroadcast,
isBroadcastEnabled,
toggleComposeSendTarget,
isComposeSendToAll,
toggleGlobalBroadcast,
isGlobalBroadcastEnabled,
toggleGlobalComposeSendTarget,
isGlobalComposeSendToAll,
orderedTabs,
getOrderedWorkTabs,
reorderTabs,
@@ -0,0 +1,60 @@
import assert from "node:assert/strict";
import test from "node:test";
import { readFileSync } from "node:fs";
const source = readFileSync(new URL("./useVaultState.ts", import.meta.url), "utf8");
const terminalLayerSource = readFileSync(
new URL("../../components/TerminalLayer.tsx", import.meta.url),
"utf8",
);
test("connection log replay blobs persist through IndexedDB instead of the legacy localStorage blob", () => {
assert.match(source, /await migrateLegacyConnectionLogTerminalData\(/);
assert.match(source, /await writeTerminalDataMapDiff\(/);
assert.match(source, /STORAGE_KEY_CONNECTION_LOG_TERMINAL_DATA_REV/);
assert.match(source, /syncConnectionLogTerminalData !== false|syncConnectionLogTerminalData\)/);
assert.doesNotMatch(source, /writeTerminalDataMapWithQuotaRetry/);
assert.doesNotMatch(
source,
/localStorageAdapter\.write\(\s*STORAGE_KEY_CONNECTION_LOG_TERMINAL_DATA\s*,/,
);
});
test("close-path connection log updates await the indexedDB persist task", () => {
assert.match(
source,
/const updateConnectionLog = useCallback\([\s\S]*?return commitConnectionLogs\(/,
);
assert.match(source, /rebaseQueuedConnectionLogs\(\s*next,\s*removedIds,\s*changedIds,/);
assert.match(
source,
/if \(!options\?\.pruneMainBlob\) \{[\s\S]*?rebaseQueuedConnectionLogs\(/,
);
assert.match(source, /retainNewestUnsavedReplays\(fallbackLogs, keep\)/);
assert.match(source, /if \(!map\) return;/);
assert.match(
source,
/if \(key === STORAGE_KEY_CONNECTION_LOGS\) \{\s*\/\/ This window already updated connectionLogsRef before setItem\.[\s\S]*?return;\s*\}/,
);
assert.match(
terminalLayerSource,
/const handleTerminalDataCapture = useCallback\(\(sessionId: string, data: string\) => \{\s*return onTerminalDataCapture\?\.\(sessionId, data\);/,
);
});
test("boot re-reads connection logs after indexedDB load and falls back to the main blob", () => {
const revAt = source.indexOf("let revSeen = readTerminalDataRev()");
const bootAt = source.indexOf("const loaded = await migrateLegacyConnectionLogTerminalData");
assert.notEqual(revAt, -1);
assert.ok(revAt < bootAt);
const bootSlice = source.slice(bootAt, bootAt + 2800);
assert.match(bootSlice, /revNow !== revSeen[\s\S]*?await loadConnectionLogTerminalData\(\)/);
assert.match(
bootSlice,
/localStorageAdapter\.read<ConnectionLog\[\]>\(\s*STORAGE_KEY_CONNECTION_LOGS,\s*\)[\s\S]*?pruneTerminalDataMapForStorage\(connectionLogsForBoot/,
);
assert.match(
source,
/!sidePersisted && unsavedTerminalDataPending && mainPersisted\) \{\s*\/\/[\s\S]*?localStorageAdapter\.read<ConnectionLog\[\]>\(\s*STORAGE_KEY_CONNECTION_LOGS\s*\)[\s\S]*?mergeTerminalDataIntoLogs\(freshLogs, buildTerminalDataMapFromLogs\(logs\)\)/,
);
});
+338 -106
View File
@@ -37,6 +37,7 @@ import {
import {
STORAGE_KEY_CONNECTION_LOGS,
STORAGE_KEY_CONNECTION_LOG_TERMINAL_DATA,
STORAGE_KEY_CONNECTION_LOG_TERMINAL_DATA_REV,
STORAGE_KEY_GROUP_CONFIGS,
STORAGE_KEY_GROUPS,
STORAGE_KEY_HOSTS,
@@ -64,8 +65,18 @@ import {
mergeConnectionLogsFromStorage,
mergeTerminalDataIntoLogs,
mergeTerminalDataMapsForStorage,
pruneInMemoryUnsavedTerminalData,
rebaseQueuedConnectionLogs,
retainNewestUnsavedReplays,
pruneTerminalDataMapForStorage,
type ConnectionLogTerminalDataMap,
} from "../../domain/connectionLogTerminalData";
import {
tryLoadAll as tryLoadConnectionLogTerminalData,
migrateLegacyConnectionLogTerminalData,
writeTerminalDataMapDiff,
type ConnectionLogTerminalDataRev,
} from "../../infrastructure/persistence/connectionLogTerminalDataStore";
import { getNextVaultOrder, normalizeVaultOrder } from "../../domain/vaultOrder";
import {
deleteSelectedSnippetsFromVault,
@@ -227,6 +238,13 @@ const safeParse = <T,>(value: string | null): T | null => {
}
};
/** `null` means the read failed. `{}` is a successful empty store. Retries once. */
const loadConnectionLogTerminalData = async (): Promise<ConnectionLogTerminalDataMap | null> => {
const first = await tryLoadConnectionLogTerminalData();
if (first) return first;
return tryLoadConnectionLogTerminalData();
};
/**
* Strip the bulky `terminalData` replay buffer from transient (unsaved)
* connection logs before persisting. `terminalData` is the full terminal
@@ -255,9 +273,6 @@ const readLegacyLineTimestampsEnabled = (): boolean => {
return stored?.showLineTimestamps === true;
};
const readConnectionLogTerminalDataMap = (): ConnectionLogTerminalDataMap =>
localStorageAdapter.read<ConnectionLogTerminalDataMap>(STORAGE_KEY_CONNECTION_LOG_TERMINAL_DATA) ?? {};
const readPersistedConnectionLogIds = (): Set<string> => {
const stored = localStorageAdapter.read<ConnectionLog[]>(STORAGE_KEY_CONNECTION_LOGS) ?? [];
return new Set(stored.map((log) => log.id));
@@ -276,23 +291,18 @@ const buildPersistedLogIds = (
return ids;
};
const writeConnectionLogTerminalDataMap = (
logs: ConnectionLog[],
localMaps: ConnectionLogTerminalDataMap[],
usePersistedLogIdsFromDisk: boolean,
): boolean => {
const existing = readConnectionLogTerminalDataMap();
const pruned = mergeTerminalDataMapsForStorage(
logs,
existing,
localMaps,
buildPersistedLogIds(logs, usePersistedLogIdsFromDisk),
);
if (JSON.stringify(existing) === JSON.stringify(pruned)) return true;
return localStorageAdapter.write(STORAGE_KEY_CONNECTION_LOG_TERMINAL_DATA, pruned);
export type UseVaultStateOptions = {
/**
* When false, skip loading and cross-window sync of the bulky
* connection-log terminal-data side store (~30–50 MB in localStorage).
* Pass false from windows that never show connection-log replay
* (Settings, tray panel, terminal popup). Default true.
*/
syncConnectionLogTerminalData?: boolean;
};
export const useVaultState = () => {
export const useVaultState = (options?: UseVaultStateOptions) => {
const syncConnectionLogTerminalData = options?.syncConnectionLogTerminalData !== false;
const [isInitialized, setIsInitialized] = useState(false);
const [hosts, setHosts] = useState<Host[]>([]);
const [keys, setKeys] = useState<SSHKey[]>([]);
@@ -418,44 +428,92 @@ export const useVaultState = () => {
const identitiesReadSeq = useRef(0);
const proxyProfilesReadSeq = useRef(0);
const groupConfigsReadSeq = useRef(0);
const connectionLogsRef = useRef<ConnectionLog[]>([]);
const connectionLogTerminalDataRef = useRef<ConnectionLogTerminalDataMap>({});
const connectionLogTerminalDataPersistedRef = useRef<ConnectionLogTerminalDataMap>({});
const connectionLogTerminalDataRevRawRef = useRef<string | null>(null);
const connectionLogTerminalDataLoadSeq = useRef(0);
const terminalDataQueueRef = useRef<Promise<void>>(Promise.resolve());
const terminalDataReadyRef = useRef<Promise<void>>(Promise.resolve());
const pluginCredentialCatalogVersion = useRef(0);
const syncConnectionLogTerminalDataMap = useCallback((
const enqueueTerminalDataTask = useCallback(<T,>(task: () => Promise<T>): Promise<T> => {
const run = terminalDataQueueRef.current.then(task, task);
terminalDataQueueRef.current = run.then(() => undefined, () => undefined);
return run;
}, []);
const bumpConnectionLogTerminalDataRev = useCallback((ids: readonly string[]) => {
const current = localStorageAdapter.read<ConnectionLogTerminalDataRev>(
STORAGE_KEY_CONNECTION_LOG_TERMINAL_DATA_REV,
);
const prev = typeof current?.rev === "number" && Number.isFinite(current.rev) ? current.rev : 0;
const payload: ConnectionLogTerminalDataRev = {
rev: Math.max(prev + 1, Date.now()),
ids: [...ids].sort(),
nonce: crypto.randomUUID(),
};
// Record the exact JSON before setItem so this window ignores its own echo.
connectionLogTerminalDataRevRawRef.current = JSON.stringify(payload);
const written = localStorageAdapter.write(STORAGE_KEY_CONNECTION_LOG_TERMINAL_DATA_REV, payload);
if (!written) {
connectionLogTerminalDataRevRawRef.current = localStorageAdapter.readString(
STORAGE_KEY_CONNECTION_LOG_TERMINAL_DATA_REV,
);
}
}, []);
const absorbRemoteTerminalDataIfNeeded = useCallback(async () => {
const raw = localStorageAdapter.readString(STORAGE_KEY_CONNECTION_LOG_TERMINAL_DATA_REV);
if (raw === connectionLogTerminalDataRevRawRef.current) return;
const map = await loadConnectionLogTerminalData();
if (!map) return;
connectionLogTerminalDataRevRawRef.current = raw;
connectionLogTerminalDataPersistedRef.current = map;
connectionLogTerminalDataRef.current = {
...map,
...connectionLogTerminalDataRef.current,
};
}, []);
const syncConnectionLogTerminalDataMap = useCallback(async (
logs: ConnectionLog[],
options?: { usePersistedLogIdsFromDisk?: boolean },
) => {
const usePersistedLogIdsFromDisk = options?.usePersistedLogIdsFromDisk ?? false;
const localMaps = [
connectionLogTerminalDataRef.current,
buildTerminalDataMapFromLogs(logs),
];
const existing = readConnectionLogTerminalDataMap();
if (!syncConnectionLogTerminalData) return true;
await absorbRemoteTerminalDataIfNeeded();
const baseline = connectionLogTerminalDataPersistedRef.current;
const merged = mergeTerminalDataMapsForStorage(
logs,
existing,
localMaps,
buildPersistedLogIds(logs, usePersistedLogIdsFromDisk),
baseline,
[
connectionLogTerminalDataRef.current,
buildTerminalDataMapFromLogs(logs),
],
buildPersistedLogIds(logs, options?.usePersistedLogIdsFromDisk ?? false),
);
const persisted = writeConnectionLogTerminalDataMap(
logs,
localMaps,
usePersistedLogIdsFromDisk,
);
if (persisted) {
connectionLogTerminalDataRef.current = merged;
} else {
connectionLogTerminalDataRef.current = {
...connectionLogTerminalDataRef.current,
...buildTerminalDataMapFromLogs(logs),
};
}
return { map: connectionLogTerminalDataRef.current, persisted };
}, []);
const written = await writeTerminalDataMapDiff(baseline, merged);
// Keep the capped map in memory even when IndexedDB rejects the write, so
// the next close retries from the last committed baseline.
connectionLogTerminalDataRef.current = merged;
if (!written.changed) return true;
if (!written.persisted) return false;
connectionLogTerminalDataPersistedRef.current = merged;
bumpConnectionLogTerminalDataRev(Object.keys(merged));
return true;
}, [
absorbRemoteTerminalDataIfNeeded,
bumpConnectionLogTerminalDataRev,
syncConnectionLogTerminalData,
]);
const persistConnectionLogState = useCallback((
const persistConnectionLogState = useCallback(async (
logs: ConnectionLog[],
options?: { pruneMainBlob?: boolean },
options?: {
pruneMainBlob?: boolean;
removedIds?: ReadonlySet<string>;
changedIds?: ReadonlySet<string>;
},
) => {
const unsavedTerminalDataPending = logs.some(
(log) => !log.saved && log.terminalData !== undefined,
@@ -472,13 +530,43 @@ export const useVaultState = () => {
let sidePersisted = true;
if (mainPersisted || !options?.pruneMainBlob) {
({ persisted: sidePersisted } = syncConnectionLogTerminalDataMap(logs, {
sidePersisted = await syncConnectionLogTerminalDataMap(logs, {
usePersistedLogIdsFromDisk: Boolean(options?.pruneMainBlob && mainPersisted),
}));
});
}
if (!options?.pruneMainBlob) {
mainPersisted = localStorageAdapter.write(STORAGE_KEY_CONNECTION_LOGS, logs);
// The side-store await can overlap another window's metadata write.
// Rebase this snapshot onto the blob that exists now, immediately before
// replacing it. A failed read keeps the snapshot already rebased at enqueue.
const freshLogs = localStorageAdapter.read<ConnectionLog[]>(STORAGE_KEY_CONNECTION_LOGS);
const toWrite = freshLogs
? rebaseQueuedConnectionLogs(
logs,
options?.removedIds ?? new Set<string>(),
options?.changedIds ?? new Set<string>(),
connectionLogsRef.current,
freshLogs,
)
: logs;
mainPersisted = localStorageAdapter.write(STORAGE_KEY_CONNECTION_LOGS, toWrite);
} else if (!sidePersisted && unsavedTerminalDataPending && mainPersisted) {
// IndexedDB rejected the replay after the pruned metadata write. Put the
// buffers back on the current main blob so a restart still has them,
// without replacing logs another window wrote during the await.
// If that blob exceeds quota, drop older unsaved replays and retry so
// the newest capture still has a durable copy.
const freshLogs = localStorageAdapter.read<ConnectionLog[]>(STORAGE_KEY_CONNECTION_LOGS);
let fallbackLogs = freshLogs
? mergeTerminalDataIntoLogs(freshLogs, buildTerminalDataMapFromLogs(logs))
: logs;
sidePersisted = localStorageAdapter.write(STORAGE_KEY_CONNECTION_LOGS, fallbackLogs);
let keep = fallbackLogs.filter((log) => !log.saved && log.terminalData).length;
while (!sidePersisted && keep > 1) {
keep = Math.max(1, Math.floor(keep / 2));
fallbackLogs = retainNewestUnsavedReplays(fallbackLogs, keep);
sidePersisted = localStorageAdapter.write(STORAGE_KEY_CONNECTION_LOGS, fallbackLogs);
}
}
if (!mainPersisted && unsavedTerminalDataPending) {
@@ -490,8 +578,40 @@ export const useVaultState = () => {
"[useVaultState] Failed to persist connection log terminal replay data side store.",
);
}
return mainPersisted && sidePersisted;
}, [syncConnectionLogTerminalDataMap]);
const commitConnectionLogs = useCallback((
recipe: (prev: ConnectionLog[]) => ConnectionLog[],
options?: { pruneMainBlob?: boolean },
) => {
const prev = connectionLogsRef.current;
const next = recipe(prev);
const prevById = new Map(prev.map((log) => [log.id, log]));
const nextIds = new Set<string>();
const changedIds = new Set<string>();
for (const log of next) {
nextIds.add(log.id);
const before = prevById.get(log.id);
if (!before || before !== log) changedIds.add(log.id);
}
const removedIds = new Set(prev.filter((log) => !nextIds.has(log.id)).map((log) => log.id));
connectionLogsRef.current = next;
setConnectionLogs(next);
// The task may run after another window writes. Rebase onto the logs that
// exist then. Restore only ids this recipe added or replaced.
return enqueueTerminalDataTask(() => persistConnectionLogState(
rebaseQueuedConnectionLogs(
next,
removedIds,
changedIds,
connectionLogsRef.current,
localStorageAdapter.read<ConnectionLog[]>(STORAGE_KEY_CONNECTION_LOGS),
),
{ ...options, removedIds, changedIds },
));
}, [enqueueTerminalDataTask, persistConnectionLogState]);
const applyConnectionLogsFromStorage = useCallback((
prev: ConnectionLog[],
storedLogs: ConnectionLog[],
@@ -1292,63 +1412,56 @@ export const useVaultState = () => {
...log,
id: crypto.randomUUID(),
};
setConnectionLogs((prev) => {
void commitConnectionLogs((prev) => {
// Keep only the last 500 non-saved entries plus all saved entries
const savedLogs = prev.filter((l) => l.saved);
const unsavedLogs = prev.filter((l) => !l.saved);
const updated = [newLog, ...unsavedLogs].slice(0, 500);
const final = [...updated, ...savedLogs].sort(
(a, b) => b.startTime - a.startTime
return pruneInMemoryUnsavedTerminalData(
[...updated, ...savedLogs].sort(
(a, b) => b.startTime - a.startTime
),
);
persistConnectionLogState(final, { pruneMainBlob: true });
return final;
});
}, { pruneMainBlob: true });
return newLog.id;
},
[persistConnectionLogState],
[commitConnectionLogs],
);
const updateConnectionLog = useCallback(
(id: string, updates: Partial<ConnectionLog>) => {
setConnectionLogs((prev) => {
const updated = prev.map((log) =>
log.id === id ? { ...log, ...updates } : log
);
persistConnectionLogState(updated, { pruneMainBlob: true });
return updated;
});
return commitConnectionLogs((prev) => (
pruneInMemoryUnsavedTerminalData(
prev.map((log) => (log.id === id ? { ...log, ...updates } : log)),
)
), { pruneMainBlob: true });
},
[persistConnectionLogState],
[commitConnectionLogs],
);
const toggleConnectionLogSaved = useCallback((id: string) => {
setConnectionLogs((prev) => {
const updated = prev.map((log) =>
log.id === id ? { ...log, saved: !log.saved } : log
);
persistConnectionLogState(updated, { pruneMainBlob: false });
return updated;
});
}, [persistConnectionLogState]);
void commitConnectionLogs((prev) => (
pruneInMemoryUnsavedTerminalData(
prev.map((log) =>
log.id === id ? { ...log, saved: !log.saved } : log
),
)
), { pruneMainBlob: false });
}, [commitConnectionLogs]);
const deleteConnectionLog = useCallback((id: string) => {
setConnectionLogs((prev) => {
const updated = prev.filter((log) => log.id !== id);
const map = { ...connectionLogTerminalDataRef.current };
delete map[id];
connectionLogTerminalDataRef.current = map;
persistConnectionLogState(updated, { pruneMainBlob: true });
return updated;
});
}, [persistConnectionLogState]);
void commitConnectionLogs(
(prev) => prev.filter((log) => log.id !== id),
{ pruneMainBlob: true },
);
}, [commitConnectionLogs]);
const clearUnsavedConnectionLogs = useCallback(() => {
setConnectionLogs((prev) => {
const saved = prev.filter((log) => log.saved);
persistConnectionLogState(saved, { pruneMainBlob: true });
return saved;
});
}, [persistConnectionLogState]);
void commitConnectionLogs(
(prev) => prev.filter((log) => log.saved),
{ pruneMainBlob: true },
);
}, [commitConnectionLogs]);
// Connection logs for Vault logs section — keep App domain bags off session churn.
useLayoutEffect(() => {
@@ -1415,6 +1528,11 @@ export const useVaultState = () => {
useEffect(() => {
let cancelled = false;
let resolveTerminalDataReady = () => {};
const terminalDataReady = new Promise<void>((resolve) => {
resolveTerminalDataReady = resolve;
});
terminalDataReadyRef.current = terminalDataReady;
const init = async () => {
try {
await withVaultImportLock("vault", async () => {
@@ -1624,14 +1742,91 @@ export const useVaultState = () => {
publishShellHistorySnapshot(savedShellHistory);
}
// Load connection logs
// Load connection logs. Replay blobs live in IndexedDB, one row per
// log id. Windows that never render replay skip that read
// (Settings / tray / popup).
const savedConnectionLogs = localStorageAdapter.read<ConnectionLog[]>(
STORAGE_KEY_CONNECTION_LOGS,
);
const terminalDataMap = readConnectionLogTerminalDataMap();
let connectionLogsForBoot = savedConnectionLogs;
let terminalDataMap: ConnectionLogTerminalDataMap = {};
let durableTerminalData = true;
if (syncConnectionLogTerminalData) {
const readTerminalDataRev = () => localStorageAdapter.readString(
STORAGE_KEY_CONNECTION_LOG_TERMINAL_DATA_REV,
);
// A peer can commit replay and bump the rev while this load is in
// flight. Capture the rev first and reload when it moves, so stamping
// the key later does not mark an unread snapshot as consumed.
let revSeen = readTerminalDataRev();
const loaded = await migrateLegacyConnectionLogTerminalData({
read: () => localStorageAdapter.read<ConnectionLogTerminalDataMap>(
STORAGE_KEY_CONNECTION_LOG_TERMINAL_DATA,
),
remove: () => {
localStorageAdapter.remove(STORAGE_KEY_CONNECTION_LOG_TERMINAL_DATA);
},
});
if (cancelled) return;
terminalDataMap = loaded.map;
let revNow = readTerminalDataRev();
if (revNow !== revSeen) {
const reloaded = await loadConnectionLogTerminalData();
if (cancelled) return;
if (reloaded) {
if (Object.keys(reloaded).length > 0) {
terminalDataMap = { ...terminalDataMap, ...reloaded };
}
revSeen = revNow;
}
}
// Another window can append a log while IndexedDB is opening. Prune
// against that newer metadata so this boot does not delete its replay.
connectionLogsForBoot = localStorageAdapter.read<ConnectionLog[]>(
STORAGE_KEY_CONNECTION_LOGS,
) ?? savedConnectionLogs;
const pruned = connectionLogsForBoot
? pruneTerminalDataMapForStorage(connectionLogsForBoot, terminalDataMap)
: terminalDataMap;
// A legacy map that never reached IndexedDB is not a diff baseline.
// Comparing against it would persist only the changed ids and then
// drop the rest of the legacy blob on the next boot.
const baseline = loaded.durable ? terminalDataMap : {};
const written = await writeTerminalDataMapDiff(baseline, pruned);
if (cancelled) return;
if (written.persisted) {
terminalDataMap = pruned;
if (!loaded.durable) {
localStorageAdapter.remove(STORAGE_KEY_CONNECTION_LOG_TERMINAL_DATA);
}
}
durableTerminalData = written.persisted || loaded.durable;
if (loaded.migrated || (written.changed && written.persisted)) {
bumpConnectionLogTerminalDataRev(Object.keys(terminalDataMap));
} else {
revNow = readTerminalDataRev();
if (revNow !== revSeen) {
const reloaded = await loadConnectionLogTerminalData();
if (cancelled) return;
if (reloaded) {
if (Object.keys(reloaded).length > 0) {
terminalDataMap = { ...terminalDataMap, ...reloaded };
}
revSeen = revNow;
connectionLogsForBoot = localStorageAdapter.read<ConnectionLog[]>(
STORAGE_KEY_CONNECTION_LOGS,
) ?? connectionLogsForBoot;
}
}
connectionLogTerminalDataRevRawRef.current = revSeen;
}
}
connectionLogTerminalDataRef.current = terminalDataMap;
if (savedConnectionLogs) {
setConnectionLogs(mergeTerminalDataIntoLogs(savedConnectionLogs, terminalDataMap));
connectionLogTerminalDataPersistedRef.current = durableTerminalData ? terminalDataMap : {};
if (connectionLogsForBoot) {
const hydrated = mergeTerminalDataIntoLogs(connectionLogsForBoot, terminalDataMap);
connectionLogsRef.current = hydrated;
setConnectionLogs(hydrated);
}
// Load managed sources
@@ -1660,6 +1855,7 @@ export const useVaultState = () => {
}
}
} finally {
resolveTerminalDataReady();
// StrictMode remount cancels the first init; only the surviving effect
// may publish "vault ready" or terminals can boot against empty keys.
if (!cancelled) {
@@ -1672,8 +1868,15 @@ export const useVaultState = () => {
void init();
return () => {
cancelled = true;
resolveTerminalDataReady();
};
}, [commitEncryptedHostsUnderVaultLock, updateHosts, updateSnippets]);
}, [
bumpConnectionLogTerminalDataRev,
commitEncryptedHostsUnderVaultLock,
syncConnectionLogTerminalData,
updateHosts,
updateSnippets,
]);
useEffect(() => {
if (!isInitialized) return;
@@ -1875,16 +2078,18 @@ export const useVaultState = () => {
if (key === STORAGE_KEY_CONNECTION_LOGS) {
const next = safeParse<ConnectionLog[]>(event.newValue) ?? [];
setConnectionLogs((prev) =>
applyConnectionLogsFromStorage(prev, next, connectionLogTerminalDataRef.current),
const merged = applyConnectionLogsFromStorage(
connectionLogsRef.current,
next,
connectionLogTerminalDataRef.current,
);
connectionLogsRef.current = merged;
setConnectionLogs(merged);
return;
}
if (key === STORAGE_KEY_CONNECTION_LOG_TERMINAL_DATA) {
const next = safeParse<ConnectionLogTerminalDataMap>(event.newValue) ?? {};
connectionLogTerminalDataRef.current = next;
setConnectionLogs((prev) => mergeConnectionLogsFromStorage(prev, prev, next));
if (key === STORAGE_KEY_CONNECTION_LOG_TERMINAL_DATA_REV) {
applyTerminalDataRev(event.newValue);
return;
}
@@ -1912,19 +2117,46 @@ export const useVaultState = () => {
}
};
const applyTerminalDataRev = (raw: string | null) => {
// Settings, tray, and popup mounts never replay logs and must not
// pull the side store across windows.
if (!syncConnectionLogTerminalData) return;
if (raw === connectionLogTerminalDataRevRawRef.current) return;
const seq = ++connectionLogTerminalDataLoadSeq.current;
void terminalDataReadyRef.current.then(() => enqueueTerminalDataTask(async () => {
if (seq !== connectionLogTerminalDataLoadSeq.current) return;
const map = await loadConnectionLogTerminalData();
if (seq !== connectionLogTerminalDataLoadSeq.current) return;
if (!map) return;
connectionLogTerminalDataRevRawRef.current = localStorageAdapter.readString(
STORAGE_KEY_CONNECTION_LOG_TERMINAL_DATA_REV,
);
connectionLogTerminalDataPersistedRef.current = map;
connectionLogTerminalDataRef.current = map;
const merged = mergeConnectionLogsFromStorage(
connectionLogsRef.current,
connectionLogsRef.current,
map,
);
connectionLogsRef.current = merged;
setConnectionLogs(merged);
}));
};
const handleLocalStorageAdapterChanged = (event: Event) => {
const key = (event as CustomEvent<{ key?: string }>).detail?.key;
if (key === STORAGE_KEY_CONNECTION_LOGS) {
const next = localStorageAdapter.read<ConnectionLog[]>(STORAGE_KEY_CONNECTION_LOGS) ?? [];
setConnectionLogs((prev) =>
applyConnectionLogsFromStorage(prev, next, connectionLogTerminalDataRef.current),
);
// This window already updated connectionLogsRef before setItem. The
// adapter notifies on a timer, so this blob can be older than a log
// added or finalized while that write was still queued. Applying it
// would drop that log from the ref, and the follow-up recipe would
// record no changed id. Other windows still arrive through "storage".
return;
}
if (key === STORAGE_KEY_CONNECTION_LOG_TERMINAL_DATA) {
const next = readConnectionLogTerminalDataMap();
connectionLogTerminalDataRef.current = next;
setConnectionLogs((prev) => mergeConnectionLogsFromStorage(prev, prev, next));
if (key === STORAGE_KEY_CONNECTION_LOG_TERMINAL_DATA_REV) {
applyTerminalDataRev(localStorageAdapter.readString(
STORAGE_KEY_CONNECTION_LOG_TERMINAL_DATA_REV,
));
}
};
@@ -1934,7 +2166,7 @@ export const useVaultState = () => {
window.removeEventListener("storage", handleStorage);
window.removeEventListener(LOCAL_STORAGE_ADAPTER_CHANGED_EVENT, handleLocalStorageAdapterChanged);
};
}, [applyConnectionLogsFromStorage]);
}, [applyConnectionLogsFromStorage, enqueueTerminalDataTask, syncConnectionLogTerminalData]);
const updateHostLastConnected = useCallback((hostId: string) => {
setHosts((prev) => {
+2
View File
@@ -411,6 +411,8 @@ export const AIChatPanelContent: React.FC<AIChatPanelContentProps> = ({
modelCatalogLoading={modelCatalogLoading}
modelCatalogSource={modelCatalogSource}
modelCatalogFetchedAt={modelCatalogFetchedAt}
modelCatalogWarning={modelCatalogWarning}
onRefreshModelCatalog={handleModelCatalogRetry}
allowCustomModelEntry={allowCustomModelEntry}
selectedModelId={selectedAgentModel}
onModelSelect={handleAgentModelSelect}
+3
View File
@@ -1090,6 +1090,9 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
target.agentEnv,
target.agentCommand,
target.codexRuntime,
// Manual refresh (retry banner / picker button) must bypass the
// main-side LKG-first policy and go live.
options.force === true,
);
if (!result?.ok || !Array.isArray(result.models)) {
throw new Error(result?.error || 'Failed to load SDK agent models');
@@ -620,6 +620,34 @@ test('SDK runtime model cache preserves degraded warnings until a successful ret
assert.equal(cache.read('copilot')?.warning, undefined);
});
test('SDK runtime model cache re-queries main past the TTL so a clean LKG serve clears a stale banner', async () => {
let now = 1_000;
const cache = createSdkRuntimeModelCache({ ttlMs: 100, now: () => now });
// A degraded serve (warning) is cached and replayed within the TTL.
await cache.refresh('claude:/opt/bin/claude', async () => ({
currentModelId: 'claude-opus-5-5',
models: [{ id: 'claude-opus-5-5', name: 'Opus 5.5' }],
warning: 'live fetch failed',
}));
assert.equal(cache.read('claude:/opt/bin/claude')?.warning, 'live fetch failed');
// Past the TTL the entry is evicted and the next automatic load re-queries
// main; with the LKG-first policy that round-trip returns a clean
// last-known-good serve, so the stale banner clears.
now = 1_150;
const cleared = await cache.refresh('claude:/opt/bin/claude', async () => ({
currentModelId: 'claude-opus-5-5',
models: [{ id: 'claude-opus-5-5', name: 'Opus 5.5' }],
source: 'last-known-good',
fetchedAt: '2026-10-23T08:00:00.000Z',
}));
assert.equal(cleared.source, 'last-known-good');
assert.equal(cleared.warning, undefined);
assert.equal(cache.read('claude:/opt/bin/claude')?.warning, undefined);
assert.equal(cache.read('claude:/opt/bin/claude')?.fetchedAt, '2026-10-23T08:00:00.000Z');
});
// #3584 alignment: OpenCode must render build-time presets while its live
// catalog load is pending, exactly like every other backend. The old host
// code suppressed presets for opencode while `runtimeModelLoading` was set,
+4
View File
@@ -247,6 +247,10 @@ export function createSdkRuntimeModelCache(options: SdkRuntimeModelCacheOptions
pruneEntries();
const cached = entries.get(key);
if (!refreshOptions.force && cached && now() - cached.updatedAt < ttlMs) {
// The LKG-first policy lives in main: once a persisted catalog exists
// there, a TTL-expired non-force refresh is answered from disk with a
// clean last-known-good serve (no live attempt, no warning), so the
// next automatic load here clears a stale banner.
touchEntry(key, cached);
return Promise.resolve(cloneCatalog(cached));
}
@@ -0,0 +1,69 @@
import assert from "node:assert/strict";
import test from "node:test";
import { JSDOM } from "jsdom";
import type { GroupNode } from "../types";
test("HostTreeView React updates invalidate order when configs or sort mode change", async () => {
const dom = new JSDOM('<!doctype html><html><body><div id="scroll"><div id="root"></div></div></body></html>', {
url: "http://localhost", pretendToBeVisual: true,
});
const previous = new Map<string, PropertyDescriptor | undefined>();
const install = (key: string, value: unknown) => {
previous.set(key, Object.getOwnPropertyDescriptor(globalThis, key));
Object.defineProperty(globalThis, key, { configurable: true, writable: true, value });
};
class ResizeObserverStub {
observe() {}
unobserve() {}
disconnect() {}
}
for (const key of ["window", "document", "navigator", "HTMLElement", "Element", "Node", "MutationObserver", "Event", "CustomEvent", "MouseEvent", "KeyboardEvent", "FocusEvent", "localStorage"] as const) {
install(key, key === "window" ? dom.window : dom.window[key]);
}
install("getComputedStyle", dom.window.getComputedStyle.bind(dom.window));
install("requestAnimationFrame", dom.window.requestAnimationFrame.bind(dom.window));
install("cancelAnimationFrame", dom.window.cancelAnimationFrame.bind(dom.window));
install("ResizeObserver", ResizeObserverStub);
install("IS_REACT_ACT_ENVIRONMENT", true);
const scroller = dom.window.document.getElementById("scroll")!;
Object.defineProperties(scroller, { offsetHeight: { value: 800 }, offsetWidth: { value: 1280 } });
const { act, createElement } = await import("react");
const { createRoot } = await import("react-dom/client");
const { HostTreeView } = await import("./HostTreeView.tsx");
const noop = () => undefined;
const groupTree: GroupNode[] = ["alpha", "beta"].map((path) => ({ name: path, path, hosts: [], children: {} }));
const common = {
groupTree, hosts: [], expandedPaths: new Set<string>(), scrollRef: { current: scroller },
onConnect: noop, onEditHost: noop, onDuplicateHost: noop, onDeleteHost: noop,
onCopyCredentials: noop, onNewGroup: noop, onRenameGroup: noop, onEditGroup: noop,
onDeleteGroup: noop, moveHostToGroup: noop, moveGroup: noop,
};
const root = createRoot(dom.window.document.getElementById("root")!);
const paths = () => [...dom.window.document.querySelectorAll("[data-group-path]")].map((node) => node.getAttribute("data-group-path"));
try {
await act(async () => {
root.render(createElement(HostTreeView, { ...common, sortMode: "manual", groupConfigs: [{ path: "beta", order: -1 }] }));
});
assert.deepEqual(paths(), ["beta", "alpha"]);
await act(async () => {
root.render(createElement(HostTreeView, { ...common, sortMode: "manual", groupConfigs: [{ path: "alpha", order: -1 }] }));
});
assert.deepEqual(paths(), ["alpha", "beta"]);
const groupConfigs = [{ path: "beta", order: -1 }];
await act(async () => {
root.render(createElement(HostTreeView, { ...common, sortMode: "az", groupConfigs }));
});
assert.deepEqual(paths(), ["alpha", "beta"]);
await act(async () => {
root.render(createElement(HostTreeView, { ...common, sortMode: "manual", groupConfigs }));
});
assert.deepEqual(paths(), ["beta", "alpha"]);
} finally {
await act(async () => { root.unmount(); });
dom.window.close();
for (const [key, descriptor] of previous) {
if (descriptor) Object.defineProperty(globalThis, key, descriptor);
else delete (globalThis as Record<string, unknown>)[key];
}
}
});
+72
View File
@@ -498,3 +498,75 @@ test("HostTreeView IP sorting stays inside each group and leaves manual order in
assert.deepEqual(keys("ip"), ["group:prod", "group:prod/east", "host:east-two", "host:east-ten", "host:prod-two", "host:prod-ten", "host:root-two", "host:root-ten"]);
assert.deepEqual(keys("manual"), ["group:prod", "group:prod/east", "host:east-ten", "host:east-two", "host:prod-ten", "host:prod-two", "host:root-ten", "host:root-two"]);
});
// Deterministic work-count assertions, not a wall-clock benchmark.
test("HostTreeView scans manual group order only once across expanded leaf groups", () => {
const groupTree: GroupNode[] = ["c", "b", "a"].map((path) => ({
name: path, path, children: {}, hosts: [],
}));
const groupConfigs: GroupConfig[] = groupTree.map((node, order) => ({ path: node.path, order }));
let configVisits = 0;
Object.defineProperty(groupConfigs, "filter", {
value: (predicate: (value: GroupConfig, index: number, array: GroupConfig[]) => unknown) =>
Array.prototype.filter.call(groupConfigs, (value: GroupConfig, index: number, array: GroupConfig[]) => {
configVisits += 1;
return predicate(value, index, array);
}),
});
for (const expandedPaths of [new Set<string>(), new Set(["a", "b", "c"])]) {
configVisits = 0;
const manual = buildVisibleHostTreeItems({ groupTree, groupConfigs, expandedPaths, ungroupedHosts: [], sortMode: "manual" });
assert.equal(configVisits, 3);
assert.deepEqual(manual.map((item) => item.key), ["group:c", "group:b", "group:a"]);
for (const sortMode of ["az", "za", "newest", "oldest", "group", "ip"] as const) {
configVisits = 0;
const items = buildVisibleHostTreeItems({ groupTree, groupConfigs, expandedPaths, ungroupedHosts: [], sortMode });
assert.equal(configVisits, 0, `${sortMode} must not construct a manual order index`);
assert.deepEqual(items.map((item) => item.key), sortMode === "za"
? ["group:c", "group:b", "group:a"]
: ["group:a", "group:b", "group:c"]);
}
}
});
test("HostTreeView shared order index preserves valid duplicates, ties and nested sibling metadata", () => {
const leaf = (path: string): GroupNode => ({ name: path.split("/").at(-1)!, path, children: {}, hosts: [] });
const parent: GroupNode = {
...leaf("parent"),
children: { z: leaf("parent/z"), a: leaf("parent/a"), b: leaf("parent/b") },
hosts: [{ ...baseHost, id: "child-host", group: "parent" }],
};
const groupTree = [leaf("unordered-z"), leaf("tie-b"), parent, leaf("tie-a"), leaf("unordered-a")];
const groupConfigs: GroupConfig[] = [
{ path: "parent", order: 10 }, { path: "parent", order: -1 },
{ path: "parent", order: Number.NaN }, { path: "parent", order: Infinity },
{ path: "tie-b", order: 0 }, { path: "tie-a", order: 0 },
{ path: "unordered-a", order: Number.NaN },
{ path: "parent/a", order: 0 }, { path: "parent/b", order: 0 },
{ path: "parent/z", order: -2 },
];
const ungroupedHosts = [{ ...baseHost, id: "root-host", group: "" }];
const expandedPaths = new Set(["parent", "parent/z", "parent/a", "parent/b"]);
const inputBefore = structuredClone({ groupTree, groupConfigs, ungroupedHosts, expandedPaths });
const items = buildVisibleHostTreeItems({ groupTree, groupConfigs, ungroupedHosts, expandedPaths, sortMode: "manual" });
assert.deepEqual(items.map(({ key, depth, posInSet, setSize }) => ({ key, depth, posInSet, setSize })), [
{ key: "group:parent", depth: 0, posInSet: 1, setSize: 6 },
{ key: "group:parent/z", depth: 1, posInSet: 1, setSize: 4 },
{ key: "group:parent/b", depth: 1, posInSet: 2, setSize: 4 },
{ key: "group:parent/a", depth: 1, posInSet: 3, setSize: 4 },
{ key: "host:child-host", depth: 1, posInSet: 4, setSize: 4 },
{ key: "group:tie-a", depth: 0, posInSet: 2, setSize: 6 },
{ key: "group:tie-b", depth: 0, posInSet: 3, setSize: 6 },
{ key: "group:unordered-z", depth: 0, posInSet: 4, setSize: 6 },
{ key: "group:unordered-a", depth: 0, posInSet: 5, setSize: 6 },
{ key: "host:root-host", depth: 0, posInSet: 6, setSize: 6 },
]);
assert.equal(items[0].kind === "group" && items[0].node, parent);
assert.equal(items[4].kind === "host" && items[4].host, parent.hosts[0]);
assert.deepEqual({ groupTree, groupConfigs, ungroupedHosts, expandedPaths }, inputBefore);
const changed = buildVisibleHostTreeItems({
groupTree, groupConfigs: [...groupConfigs, { path: "tie-a", order: -10 }],
ungroupedHosts, expandedPaths, sortMode: "manual",
});
assert.equal(changed[0].key, "group:tie-a");
});
+18 -14
View File
@@ -64,22 +64,25 @@ export type VisibleHostTreeItem =
setSize: number;
};
const sortHostTreeGroups = (
nodes: GroupNode[],
sortMode: HostTreeSortMode,
groupConfigs: GroupConfig[],
): GroupNode[] => {
const originalIndex = new Map(nodes.map((node, index) => [node.path, index]));
const orderByPath = new Map(
const buildHostTreeGroupOrder = (groupConfigs: GroupConfig[]): Map<string, number> => (
new Map(
groupConfigs
.filter((config) => typeof config.order === 'number' && Number.isFinite(config.order))
.map((config) => [config.path, config.order as number]),
);
)
);
const sortHostTreeGroups = (
nodes: GroupNode[],
sortMode: HostTreeSortMode,
orderByPath?: ReadonlyMap<string, number>,
): GroupNode[] => {
const originalIndex = new Map(nodes.map((node, index) => [node.path, index]));
return [...nodes].sort((a, b) => {
if (sortMode === 'za') return b.name.localeCompare(a.name);
if (sortMode === 'manual') {
const orderA = orderByPath.get(a.path);
const orderB = orderByPath.get(b.path);
const orderA = orderByPath?.get(a.path);
const orderB = orderByPath?.get(b.path);
if (orderA !== undefined && orderB !== undefined && orderA !== orderB) return orderA - orderB;
if (orderA !== undefined) return -1;
if (orderB !== undefined) return 1;
@@ -115,8 +118,9 @@ export function buildVisibleHostTreeItems({
groupConfigs: GroupConfig[];
}): VisibleHostTreeItem[] {
const items: VisibleHostTreeItem[] = [];
const orderByPath = sortMode === 'manual' ? buildHostTreeGroupOrder(groupConfigs) : undefined;
const visitGroups = (nodes: GroupNode[], depth: number) => {
const sortedGroups = sortHostTreeGroups(nodes, sortMode, groupConfigs);
const sortedGroups = sortHostTreeGroups(nodes, sortMode, orderByPath);
// Root-level ungrouped hosts are siblings of top-level groups.
const rootUngrouped = depth === 0 ? sortHostTreeHosts(ungroupedHosts, sortMode) : [];
const siblingSetSize = sortedGroups.length + rootUngrouped.length;
@@ -133,7 +137,7 @@ export function buildVisibleHostTreeItems({
const childGroups = sortHostTreeGroups(
Object.values(node.children ?? {}) as GroupNode[],
sortMode,
groupConfigs,
orderByPath,
);
const childHosts = sortHostTreeHosts(node.hosts, sortMode);
const childSetSize = childGroups.length + childHosts.length;
@@ -182,7 +186,7 @@ export function buildVisibleHostTreeItems({
const childGroups = sortHostTreeGroups(
Object.values(node.children ?? {}) as GroupNode[],
sortMode,
groupConfigs,
orderByPath,
);
const childHosts = sortHostTreeHosts(node.hosts, sortMode);
const childSetSize = childGroups.length + childHosts.length;
@@ -439,7 +443,7 @@ const TreeNode: React.FC<TreeNodeProps> = ({
const childNodes = useMemo(() => sortHostTreeGroups(
Object.values(node.children ?? {}) as GroupNode[],
sortMode,
groupConfigs,
sortMode === 'manual' ? buildHostTreeGroupOrder(groupConfigs) : undefined,
), [groupConfigs, node.children, sortMode]);
const sortedHosts = useMemo(
() => sortHostTreeHosts(node.hosts, sortMode),
+1 -1
View File
@@ -270,7 +270,7 @@ const SettingsSyncTabWithVault: React.FC<{ onSettingsApplied?: () => void }> = (
groupConfigs,
importDataFromString,
clearVaultData,
} = useVaultState();
} = useVaultState({ syncConnectionLogTerminalData: false });
const { rules: portForwardingRules, importRules: importPortForwardingRules } = usePortForwardingState();
+49 -7
View File
@@ -73,6 +73,7 @@ import {
} from "../application/state/terminalAutoReconnect";
import { useStoredBoolean } from "../application/state/useStoredBoolean";
import { readOptionalStoredStringValue, useStoredString } from "../application/state/useStoredString";
import { performSessionLogAction, type SessionLogAction } from "../application/state/sessionLogActions";
import { useSessionLogBackend } from "../application/state/useSessionLogBackend";
import { useTerminalLayoutSuppressActive } from "../application/state/terminalLayoutSuppressStore";
import { useAppearanceChromeStore } from "../application/state/appearanceChromeStore";
@@ -363,6 +364,8 @@ const TerminalComponent: React.FC<TerminalProps> = ({
onOpenTheme,
onOpenSystem,
isBroadcastEnabled,
composeSendToAll,
onToggleComposeSendTarget,
onToggleBroadcast,
onToggleComposeBar,
isWorkspaceComposeBarOpen,
@@ -906,6 +909,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
const [isCancelling, setIsCancelling] = useState(false);
const [showSFTP, setShowSFTP] = useState(false);
const [isSessionLogging, setIsSessionLogging] = useState(false);
const [canOpenSessionLog, setCanOpenSessionLog] = useState(false);
const [progressValue, setProgressValue] = useState(CONNECTION_PROGRESS_START);
const [isDisconnectedDialogDismissed, setIsDisconnectedDialogDismissed] = useState(false);
const [connectionReuseFellBack, setConnectionReuseFellBack] = useState(false);
@@ -1614,7 +1618,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
const capturedData = options?.finalized
? data
: (finalizeTerminalLogData() || data);
captureHandler(capturedSessionId, capturedData);
return captureHandler(capturedSessionId, capturedData);
}, [finalizeTerminalLogData]);
const attachHomeWebContentsIdRef = useRef<number | null | undefined>(undefined);
@@ -1792,6 +1796,14 @@ const TerminalComponent: React.FC<TerminalProps> = ({
if (!closingSessionId) {
disposeSessionListeners();
// Close preparation already cleared sessionRef after the display handoff.
// Unmount teardown calls cleanup again. An attach popup must not
// closeSession on that second pass: worker mode emits netcatty:exit to
// the home renderer before the boot-epoch check, which drops home input
// (sessionRef = null) while a mismatched epoch leaves the PTY alive.
if (attachExistingSession) {
return;
}
// Still notify main so in-flight SSH passphrase prompts for this UI
// sessionId are aborted even before a backend session was attached.
try {
@@ -1961,10 +1973,11 @@ const TerminalComponent: React.FC<TerminalProps> = ({
}, [sessionId]);
const forceCloseHibernatedSession = useCallback(() => {
let pendingCapture: void | Promise<void>;
if (!terminalDataCapturedRef.current) {
const hibernatedData = hibernateSnapshotRef.current + hibernatePendingBufferRef.current;
if (hibernatedData) {
handleTerminalDataCaptureOnce(sessionId, hibernatedData);
pendingCapture = handleTerminalDataCaptureOnce(sessionId, hibernatedData);
}
}
disposeDataRef.current?.();
@@ -2007,6 +2020,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
}
sessionRef.current = null;
clearHibernateRuntimeState();
return pendingCapture;
}, [attachAuthorization, attachExistingSession, clearHibernateRuntimeState, handleTerminalDataCaptureOnce, sessionId, terminalBackend]);
const observeTerminalInputPrompt = useCallback((
@@ -3433,13 +3447,15 @@ const TerminalComponent: React.FC<TerminalProps> = ({
// A bypassed password fan-out (#3488) forces the sensitive marker even
// when this target has not itself classified its prompt as sensitive.
const sensitive = passwordPromptActiveRef.current || options?.sensitive === true;
const broadcastThisSend = options?.broadcast !== undefined
? options.broadcast === true
: isBroadcastEnabledRef.current;
if (
shouldBroadcastDuringSensitivePrompt({
sensitivePromptActive: sensitive,
broadcastPasswordBypass: broadcastPasswordBypassRef.current,
})
&& options?.broadcast !== false
&& isBroadcastEnabledRef.current
&& broadcastThisSend
&& onBroadcastInputRef.current
) {
const broadcastDeliveredSessionIds = onBroadcastInputRef.current(data, sessionId, {
@@ -3449,6 +3465,11 @@ const TerminalComponent: React.FC<TerminalProps> = ({
// peer writes keep skipping input interceptors.
...(sensitive ? { sourceSensitive: true } : {}),
...(lineDelayMs ? { lineDelayMs } : {}),
// Compose-bar send-to-all while the toolbar switch is off. Keystrokes
// and snippets leave this unset and stay on the toolbar gate.
...(options?.broadcast === true && !isBroadcastEnabledRef.current
? { composePeerFanOut: true }
: {}),
});
// #3491: report the sessions that received the fan-out so callers (the
// solo compose bar) can detect a payload delivered into a sensitive
@@ -4127,6 +4148,15 @@ const TerminalComponent: React.FC<TerminalProps> = ({
onClipboardImageUploadResult: handleClipboardImageUploadResult,
});
const handleSessionLogAction = useCallback(async (action: SessionLogAction) => {
try {
await performSessionLogAction(sessionRef.current ?? sessionId, action);
} catch (err) {
logger.error("[Terminal] Failed to open or reveal session log:", err);
toast.error(t('terminal.toolbar.openSessionLogFailed'));
}
}, [sessionId, t]);
const handleToggleSessionLog = useCallback(async () => {
const currentSessionId = sessionRef.current ?? sessionId;
if (!currentSessionId) {
@@ -4176,6 +4206,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
if (startResult?.success) {
if (!startResult?.started && startResult?.canceled) return;
setIsSessionLogging(!!startResult?.started);
if (startResult?.started) setCanOpenSessionLog(true);
} else {
toast.error(startResult?.error || "Failed to start session log");
}
@@ -4200,14 +4231,21 @@ const TerminalComponent: React.FC<TerminalProps> = ({
const currentSessionId = sessionRef.current ?? sessionId;
if (!currentSessionId) {
setIsSessionLogging(false);
setCanOpenSessionLog(false);
return;
}
let cancelled = false;
void getManualSessionLogStatus({ sessionId: currentSessionId }).then((result) => {
if (!cancelled) setIsSessionLogging(!!result?.isLogging);
if (!cancelled) {
setIsSessionLogging(!!result?.isLogging);
setCanOpenSessionLog(!!result?.hasSessionLog);
}
}).catch(() => {
if (!cancelled) setIsSessionLogging(false);
if (!cancelled) {
setIsSessionLogging(false);
setCanOpenSessionLog(false);
}
});
return () => {
@@ -4283,6 +4321,8 @@ const TerminalComponent: React.FC<TerminalProps> = ({
isSearchOpen={isSearchOpen}
onToggleSearch={handleToggleSearch}
showLogButton
onSessionLogAction={handleSessionLogAction}
canOpenSessionLog={canOpenSessionLog}
onToggleSessionLog={handleToggleSessionLog}
isSessionLogging={isSessionLogging}
isSessionLogDisabled={status !== "connected" && !isSessionLogging}
@@ -4315,6 +4355,8 @@ const TerminalComponent: React.FC<TerminalProps> = ({
handleSendYmodem,
handleSetTerminalEncoding,
handleToggleSessionLog,
handleSessionLogAction,
canOpenSessionLog,
handleToggleSearch,
handleToolbarRecordingToggle,
host,
@@ -4689,7 +4731,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
return (
<>
<TerminalView ctx={{ Activity, ArrowDownToLine, ArrowUpFromLine, Button, Clock3, Copy, Cpu, HardDrive, HoverCard, HoverCardContent, HoverCardTrigger, Maximize2, MemoryStick, Radio, RefreshCcw, Sparkles, SquareArrowOutUpRight, Unplug, TerminalAutocomplete, TerminalComposeBar, TerminalConnectionDialog, TerminalContextMenu, TerminalSearchBar, Tooltip, TooltipContent, TooltipTrigger, ZmodemOverwriteDialog, ZmodemProgressIndicator, auth, autocompleteAcceptTextRef, autocompleteCloseRef, autocompleteHostOs, autocompleteInputRef, autocompleteKeyEventRef, autocompleteRepositionRef, autocompleteSettings, canUpdateHost: !!onUpdateHost, chainProgress, cn, compactToolbar, lineTimestampsAvailable, containerRef, effectiveFontSize, effectiveFontWeight, effectiveTheme, error, executeSnippet, executeSnippetCommand, handleAddSelectionToAI: onAddSelectionToAI ? handleAddSelectionToAI : undefined, handleCancelConnect, handleCloseDisconnectedSession, handleCloseSearch, handleDisconnect: (attachExistingSession || compactToolbar) ? undefined : handleDisconnect, handleDismissDisconnectedDialog, handleDragEnter, handleDragLeave, handleDragOver, handleDrop, handleFindNext, handleFindPrevious, handleHostKeyAddAndContinue, handleHostKeyClose, handleHostKeyContinue, handleOpenHostBrowser, handleOsc52ReadResponse, handleOsc7SetupConfirm, handleOsc7SetupOpenChange, handleReceiveYmodem, handleRetry, handleSearch, handleSendYmodem, handleTopOverlayMouseDownCapture, hasMouseTracking, host, hotkeyScheme, inWorkspace, isBroadcastEnabled, isCancelling, isComposeBarOpen: effectiveComposeBarOpen, isConnectionAwaitingUserInput, isDraggingOver, isFocusMode, isFocusedPane, isLocalConnection, remoteDragDropUsesZmodem, isPluginTerminalProviderAvailable, isReconnectActive, isSerialConnection, isSearchOpen, isSupportedOs, isSystemSidebarEligible, isVisible, keyBindings, keys, identities, knownCwdRef, needsHostKeyVerification, onAddSelectionToAI, onBroadcastInput, onCloseSession, onDetach, onDetachDragEnd, onDetachDragStart, onDetachPointerDown, onEndSessionDrag, onExpandToFocus, onTogglePaneMagnification, onOpenSystem, onRename, onSplitHorizontal, onSplitVertical, onStartSessionDrag, onToggleBroadcast, onUpdateHost: handleUpdateHostFromTerminal, osc52ReadPromptVisible, osc7SetupOpen, osc7SetupRunning, passwordPromptActiveRef, pendingHostKeyInfo, progressLogs, progressValue, renderControls, resolvedFontFamily, restoreState, scrollToBottomAfterProgrammaticInput, searchMatchCount, searchFocusToken, scriptExecutionOverlay: activeScriptRun ? (
<TerminalView ctx={{ Activity, ArrowDownToLine, ArrowUpFromLine, Button, Clock3, Copy, Cpu, HardDrive, HoverCard, HoverCardContent, HoverCardTrigger, Maximize2, MemoryStick, Radio, RefreshCcw, Sparkles, SquareArrowOutUpRight, Unplug, TerminalAutocomplete, TerminalComposeBar, TerminalConnectionDialog, TerminalContextMenu, TerminalSearchBar, Tooltip, TooltipContent, TooltipTrigger, ZmodemOverwriteDialog, ZmodemProgressIndicator, auth, autocompleteAcceptTextRef, autocompleteCloseRef, autocompleteHostOs, autocompleteInputRef, autocompleteKeyEventRef, autocompleteRepositionRef, autocompleteSettings, canUpdateHost: !!onUpdateHost, chainProgress, cn, compactToolbar, lineTimestampsAvailable, containerRef, effectiveFontSize, effectiveFontWeight, effectiveTheme, error, executeSnippet, executeSnippetCommand, handleAddSelectionToAI: onAddSelectionToAI ? handleAddSelectionToAI : undefined, handleCancelConnect, handleCloseDisconnectedSession, handleCloseSearch, handleDisconnect: (attachExistingSession || compactToolbar) ? undefined : handleDisconnect, handleDismissDisconnectedDialog, handleDragEnter, handleDragLeave, handleDragOver, handleDrop, handleFindNext, handleFindPrevious, handleHostKeyAddAndContinue, handleHostKeyClose, handleHostKeyContinue, handleOpenHostBrowser, handleOpenSFTP, handleOsc52ReadResponse, handleOsc7SetupConfirm, handleOsc7SetupOpenChange, handleReceiveYmodem, handleRetry, handleSearch, handleSendYmodem, handleTopOverlayMouseDownCapture, hasMouseTracking, host, hotkeyScheme, inWorkspace, isBroadcastEnabled, composeSendToAll, onToggleComposeSendTarget, isCancelling, isComposeBarOpen: effectiveComposeBarOpen, isConnectionAwaitingUserInput, isDraggingOver, isFocusMode, isFocusedPane, isLocalConnection, remoteDragDropUsesZmodem, isPluginTerminalProviderAvailable, isReconnectActive, isSerialConnection, isSearchOpen, isSupportedOs, isSystemSidebarEligible, isVisible, keyBindings, keys, identities, knownCwdRef, needsHostKeyVerification, onAddSelectionToAI, onBroadcastInput, onCloseSession, onDetach, onDetachDragEnd, onDetachDragStart, onDetachPointerDown, onEndSessionDrag, onExpandToFocus, onTogglePaneMagnification, onOpenSystem, onRename, onSplitHorizontal, onSplitVertical, onStartSessionDrag, onToggleBroadcast, onUpdateHost: handleUpdateHostFromTerminal, osc52ReadPromptVisible, osc7SetupOpen, osc7SetupRunning, passwordPromptActiveRef, pendingHostKeyInfo, progressLogs, progressValue, renderControls, resolvedFontFamily, restoreState, scrollToBottomAfterProgrammaticInput, searchMatchCount, searchFocusToken, scriptExecutionOverlay: activeScriptRun ? (
<ScriptExecutionOverlay
run={activeScriptRun}
onPause={() => { void pauseScriptRun(activeScriptRun.runId); }}
+21 -5
View File
@@ -260,8 +260,12 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
onCreateLocalTerminal,
isBroadcastEnabled,
onToggleBroadcast,
isComposeSendToAll,
onToggleComposeSendTarget,
isGlobalBroadcastEnabled,
isGlobalComposeSendToAll,
onToggleGlobalBroadcast,
onToggleGlobalComposeSendTarget,
canUseGlobalBroadcast,
updateHosts,
updateSnippets,
@@ -700,7 +704,7 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
}, [onAddKnownHost]);
const handleTerminalDataCapture = useCallback((sessionId: string, data: string) => {
onTerminalDataCapture?.(sessionId, data);
return onTerminalDataCapture?.(sessionId, data);
}, [onTerminalDataCapture]);
// Terminal backend for broadcast writes
@@ -1277,6 +1281,7 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
sourceSessionId,
globalBroadcastEnabled: isGlobalBroadcastEnabled,
directTargetSessionIds: options?.kittyKeyboardTargetSessionIds,
includeGlobalPeersWithoutToolbar: options?.composePeerFanOut === true,
}).filter(id => !paced?.targets || paced.targets.some(target => target.sessionId === id));
if (options?.preparePacedBroadcast && paced) {
paced.targets = targetSessionIds.filter(id => passwordBypass || !isTerminalSensitiveInputActive(id)).map(id => {
@@ -2463,14 +2468,19 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
let anyRecipientSensitive = false;
const pendingSends: Promise<boolean>[] = [];
const payload = text + '\r';
const broadcastEnabled = isBroadcastEnabled?.(activeWorkspace.id);
// Compose Enter follows the compose-bar target. Toolbar broadcast stays
// the keystroke switch and is only the fallback when no compose target
// has been published (older callers).
const composeSendToAll = isComposeSendToAll
? isComposeSendToAll(activeWorkspace.id) === true
: Boolean(isBroadcastEnabled?.(activeWorkspace.id));
const focusedSessionId = activeWorkspace.focusedSessionId;
const broadcastPasswordBypass = broadcastPasswordBypassRef.current;
const focusedSensitive = focusedSessionId
? isTerminalSensitiveInputActive(focusedSessionId)
: false;
if (broadcastEnabled && (broadcastPasswordBypass || !focusedSensitive)) {
if (composeSendToAll && (broadcastPasswordBypass || !focusedSensitive)) {
const allSessionIds = sessionsRef.current
.filter((session) => session.workspaceId === activeWorkspace.id)
.map((session) => session.id);
@@ -2518,7 +2528,9 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
const executor = snippetExecutorsRef.current.get(targetId);
if (executor) {
const sensitive = isTerminalSensitiveInputActive(targetId);
pendingSends.push(Promise.resolve(executor(text, false)).then(
// Explicitly local: an omitted broadcast option falls back to the
// toolbar switch, which can still be on while compose is active-only.
pendingSends.push(Promise.resolve(executor(text, false, { broadcast: false })).then(
(sent) => sent && !sensitive && !isTerminalSensitiveInputActive(targetId),
));
} else {
@@ -2539,7 +2551,7 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
// eligibility for it.
if (focusedSensitive || anyRecipientSensitive) return false;
return delivered;
}, [isBroadcastEnabled, terminalBackend]);
}, [isBroadcastEnabled, isComposeSendToAll, terminalBackend]);
const sessionLogConfig = useMemo(
() => ({
@@ -2664,7 +2676,11 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
restoreTerminalCwd,
identities,
isBroadcastEnabled,
isComposeSendToAll,
onToggleComposeSendTarget,
isGlobalBroadcastEnabled,
isGlobalComposeSendToAll,
onToggleGlobalComposeSendTarget,
canUseGlobalBroadcast,
isComposeBarOpen,
keyBindings,
+1 -1
View File
@@ -286,7 +286,7 @@ function TerminalPopupPageInner({
groupConfigs,
updateKnownHosts,
deleteSelectedSnippets,
} = useVaultState();
} = useVaultState({ syncConnectionLogTerminalData: false });
const [config, setConfig] = useState<TerminalPopupPayload | null>(null);
const [terminalReady, setTerminalReady] = useState(false);
const [startupError, setStartupError] = useState<string | null>(null);
+1 -1
View File
@@ -148,7 +148,7 @@ const TrayPanelContent: React.FC = () => {
onTrayPanelMenuData,
} = useTrayPanelBackend();
const { hosts, knownHosts, updateKnownHosts } = useVaultState();
const { hosts, knownHosts, updateKnownHosts } = useVaultState({ syncConnectionLogTerminalData: false });
// TrayPanel runs in its own BrowserWindow, so this hook's session state is
// independent from (and typically empty compared to) the main App's — it's
// used here only for its storage-sync side effects, never for closeSession.
+10
View File
@@ -212,6 +212,10 @@ interface ChatInputProps {
modelCatalogSource?: 'live' | 'last-known-good' | 'presets';
/** ISO timestamp of the successful fetch backing `modelCatalogSource`. */
modelCatalogFetchedAt?: string;
/** The catalog warning text behind the host's retry banner, when present. */
modelCatalogWarning?: string;
/** Manual refresh action for the picker's refresh button (force path). */
onRefreshModelCatalog?: () => void;
/**
* Offer the picker's "use custom model" action. Disable when the current
* agent locks the model (e.g. managed Codex `model` config overrides the
@@ -286,6 +290,8 @@ const ChatInput: React.FC<ChatInputProps> = ({
modelCatalogLoading = false,
modelCatalogSource,
modelCatalogFetchedAt,
modelCatalogWarning,
onRefreshModelCatalog,
allowCustomModelEntry = true,
selectedModelId,
onModelSelect,
@@ -1608,6 +1614,8 @@ const ChatInput: React.FC<ChatInputProps> = ({
loading={!hasProviderSwitcher ? modelCatalogLoading : undefined}
catalogSource={!hasProviderSwitcher ? modelCatalogSource : undefined}
catalogFetchedAt={!hasProviderSwitcher ? modelCatalogFetchedAt : undefined}
catalogWarning={!hasProviderSwitcher ? modelCatalogWarning : undefined}
onRefreshCatalog={!hasProviderSwitcher ? onRefreshModelCatalog : undefined}
allowCustomEntry={allowCustomModelEntry}
prefs={modelPrefs}
onSelectProviderModel={(providerId, modelId, contextWindow) => {
@@ -1824,6 +1832,8 @@ function chatInputPropsAreEqual(prev: ChatInputProps, next: ChatInputProps): boo
&& prev.modelCatalogLoading === next.modelCatalogLoading
&& prev.modelCatalogSource === next.modelCatalogSource
&& prev.modelCatalogFetchedAt === next.modelCatalogFetchedAt
&& prev.modelCatalogWarning === next.modelCatalogWarning
&& prev.onRefreshModelCatalog === next.onRefreshModelCatalog
&& prev.allowCustomModelEntry === next.allowCustomModelEntry
&& prev.selectedModelId === next.selectedModelId
&& prev.onModelSelect === next.onModelSelect
@@ -156,3 +156,36 @@ test('external agent picker shows the loading row while the live catalog is pend
// keeps built-in presets visible during loading and passes them through.
assert.doesNotMatch(html, /gpt-5\.1/);
});
test('manual refresh button renders for any fetched catalog or warning and stays localized', () => {
const base = {
modelPresets: [{ id: 'm-1', name: 'M1' }],
selectedModelId: 'm-1',
prefs: { recent: [], pinned: [] },
onSelectModel: () => {},
onTogglePinned: () => {},
};
for (const [locale, refreshLabel] of [
['en', 'Refresh model list'],
['zh-CN', '刷新模型列表'],
['zh-TW', '重新整理模型清單'],
['es', 'Actualizar la lista de modelos'],
['ru', 'Обновить список моделей'],
]) {
const html = renderToStaticMarkup(
<I18nProvider locale={locale}>
<ComposerModelPicker {...base} catalogSource="last-known-good" onRefreshCatalog={() => {}} />
</I18nProvider>,
);
assert.ok(html.includes(`aria-label="${refreshLabel}"`));
}
// A plain preset list without a host refresh action stays buttonless.
assert.doesNotMatch(renderToStaticMarkup(<ComposerModelPicker {...base} />), /refreshModelList/);
// A degraded preset serve (warning, no source) still offers the refresh.
const warned = renderToStaticMarkup(
<ComposerModelPicker {...base} catalogWarning="live fetch failed" onRefreshCatalog={() => {}} />,
);
assert.match(warned, /aria-label="ai\.chat\.refreshModelList"/);
});
+43 -4
View File
@@ -1,4 +1,4 @@
import { Check, ChevronLeft, ChevronRight, Loader2, Pin, Search, Star } from 'lucide-react';
import { Check, ChevronLeft, ChevronRight, Loader2, Pin, RotateCw, Search, Star } from 'lucide-react';
import React, { useEffect, useMemo, useState } from 'react';
import { useI18n } from '../../application/i18n/I18nProvider';
import {
@@ -37,6 +37,18 @@ export interface ComposerModelPickerProps {
catalogSource?: 'live' | 'last-known-good' | 'presets';
/** ISO timestamp of the successful fetch backing `catalogSource`. */
catalogFetchedAt?: string;
/**
* The catalog warning text behind the host's retry banner, when present.
* Together with `catalogSource` it decides whether the manual refresh
* button renders: any fetched catalog (live or last-known-good) or a
* degraded preset fallback offers a refresh; a plain preset list does not.
*/
catalogWarning?: string | null;
/**
* Manual refresh action (LKG-first policy: the picker hydrates from disk
* and the user refreshes explicitly). Triggers the host's force refresh.
*/
onRefreshCatalog?: () => void;
prefs: ComposerModelPrefs;
/**
* Offer the "use custom model" action. Disable when the host locks the
@@ -107,6 +119,8 @@ export const ComposerModelPicker: React.FC<ComposerModelPickerProps> = ({
loading = false,
catalogSource,
catalogFetchedAt,
catalogWarning,
onRefreshCatalog,
prefs,
allowCustomEntry = true,
onSelectProviderModel,
@@ -174,6 +188,18 @@ export const ComposerModelPicker: React.FC<ComposerModelPickerProps> = ({
? t('ai.chat.modelCatalogSourceCached').replace('{time}', time)
: t('ai.chat.modelCatalogSourceCachedNoTime');
}, [loading, catalogSource, catalogFetchedAt, t]);
// Manual refresh (LKG-first): offered whenever a fetched catalog (any
// source) or the degraded-preset warning is present. Hidden while loading —
// the spinner row is the in-flight feedback — and in provider mode, where
// neither a catalog chip nor a host refresh action exists.
const showRefreshButton = useMemo(() => (
!loading
&& Boolean(onRefreshCatalog)
&& (catalogSource === 'live'
|| catalogSource === 'last-known-good'
|| Boolean(catalogWarning))
), [loading, onRefreshCatalog, catalogSource, catalogWarning]);
const refreshLabel = t('ai.chat.refreshModelList');
// Manual model ids are accepted in both modes: provider-bound catalogs and
// preset lists from external CLI agents whose catalogs lag new models.
const showCustom = Boolean(
@@ -384,9 +410,22 @@ export const ComposerModelPicker: React.FC<ComposerModelPickerProps> = ({
)}
</div>
{catalogChipText && (
<div className="border-t border-border/40 px-2.5 py-1 text-[10px] text-muted-foreground/45" title={catalogChipText}>
{catalogChipText}
{(catalogChipText || showRefreshButton) && (
<div className="flex items-center gap-1 border-t border-border/40 px-2.5 py-1 text-[10px] text-muted-foreground/45">
{catalogChipText && (
<span className="min-w-0 flex-1 truncate" title={catalogChipText}>{catalogChipText}</span>
)}
{showRefreshButton && (
<button
type="button"
aria-label={refreshLabel}
title={refreshLabel}
onClick={onRefreshCatalog}
className="shrink-0 rounded p-0.5 text-muted-foreground/55 transition-colors hover:bg-muted/40 hover:text-foreground/75 cursor-pointer"
>
<RotateCw size={11} />
</button>
)}
</div>
)}
</div>
+12 -1
View File
@@ -1,7 +1,9 @@
import test from "node:test";
import assert from "node:assert/strict";
import React from "react";
import { renderToStaticMarkup } from "react-dom/server";
import { invokeAsideActionMenuItemClick } from "./ui/aside-panel.tsx";
import { AsidePanel, invokeAsideActionMenuItemClick } from "./ui/aside-panel.tsx";
test("AsideActionMenuItem closes its menu before running the action", () => {
const events: string[] = [];
@@ -21,3 +23,12 @@ test("AsideActionMenuItem still closes when no action is provided", () => {
assert.deepEqual(events, ["close"]);
});
test("overlay aside panels ask native views to yield; inline panels do not", () => {
const render = (layout: "overlay" | "inline") => renderToStaticMarkup(
React.createElement(AsidePanel, { open: true, onClose: () => {}, layout, children: "content" }),
);
assert.match(render("overlay"), /data-native-view-occluder=""/);
assert.doesNotMatch(render("inline"), /data-native-view-occluder/);
});
+1 -1
View File
@@ -158,7 +158,7 @@ export interface NetcattyAiBridge {
aiCodexCancelLogin?: (sessionId: string) => Promise<{ ok: boolean; found?: boolean; session?: CodexLoginSession; error?: string }>;
aiCodexLogout?: (options?: { codexPath?: string; agentEnv?: Record<string, string> }) => Promise<{ ok: boolean; state?: CodexIntegrationState; isConnected?: boolean; rawOutput?: string; logoutOutput?: string; error?: string }>;
aiResolveCli?: (params: { command: string; customPath?: string; refreshShellEnv?: boolean; apiKeyPresent?: boolean }) => Promise<AgentPathInfo>;
aiSdkAgentListModels?: (sdkBackend: string, cwd?: string, providerId?: string, chatSessionId?: string, agentEnv?: Record<string, string>, agentCommand?: string, codexRuntime?: 'sdk' | 'app-server') => Promise<{ ok: boolean; models?: Array<{ id: string; name: string; description?: string; thinkingLevels?: string[]; defaultThinkingLevel?: string }>; currentModelId?: string | null; warning?: string; source?: 'live' | 'last-known-good'; fetchedAt?: string; error?: string }>;
aiSdkAgentListModels?: (sdkBackend: string, cwd?: string, providerId?: string, chatSessionId?: string, agentEnv?: Record<string, string>, agentCommand?: string, codexRuntime?: 'sdk' | 'app-server', force?: boolean) => Promise<{ ok: boolean; models?: Array<{ id: string; name: string; description?: string; thinkingLevels?: string[]; defaultThinkingLevel?: string }>; currentModelId?: string | null; warning?: string; source?: 'live' | 'last-known-good'; fetchedAt?: string; error?: string }>;
codexAppServerGetStatus?: (agentCommand?: string, agentEnv?: Record<string, string>) => Promise<{ ok: boolean; available: boolean; error?: string }>;
aiUserSkillsGetStatus?: () => Promise<UserSkillsStatusResult>;
aiUserSkillsOpenFolder?: () => Promise<UserSkillsStatusResult>;
@@ -0,0 +1,23 @@
import React from "react";
import { useI18n } from "../../application/i18n/I18nProvider";
function rate(value: number | null | undefined) {
if (value == null || !Number.isFinite(value) || value < 0) return "--";
if (value >= 1024 ** 2) return `${(value / 1024 ** 2).toFixed(1)} MiB/s`;
if (value >= 1024) return `${(value / 1024).toFixed(1)} KiB/s`;
return `${Math.round(value)} B/s`;
}
/** Same shared-sampler rates in monitor cards, tables, and terminal overview. */
export function DiskIoSummary({ stats }: {
stats?: { diskReadSpeed?: number | null; diskWriteSpeed?: number | null } | null;
}) {
const { t } = useI18n();
return (
<div data-disk-io className="flex flex-wrap gap-x-3 gap-y-0.5 text-[10px] tabular-nums text-muted-foreground" title={t("monitor.diskIoHint")}>
<span>{t("monitor.diskIo")}</span>
<span>{t("monitor.diskRead")} {rate(stats?.diskReadSpeed)}</span>
<span>{t("monitor.diskWrite")} {rate(stats?.diskWriteSpeed)}</span>
</div>
);
}
@@ -12,6 +12,7 @@ import { aggregateMountedDiskUsage } from '../../domain/systemDiskUsage';
import { cn } from '../../lib/utils';
import { useServerStats } from '../../application/state/useServerStats';
import { ResourceBar } from './ResourceBar';
import { DiskIoSummary } from './DiskIoSummary';
import {
SystemPanelEmpty,
SystemPanelError,
@@ -335,6 +336,8 @@ export const SystemOverviewTab = memo(function SystemOverviewTab({
/>
</div>
<div className="mt-3"><DiskIoSummary stats={stats} /></div>
<div className="mt-3 grid grid-cols-2 gap-2">
<InfoPill label={t('systemManager.overview.load')} value={formatLoad(stats.loadAverage)} />
<InfoPill label={t('systemManager.overview.uptime')} value={formatDuration(stats.uptimeSeconds, t)} />
@@ -3,6 +3,7 @@ import test from 'node:test';
import React from 'react';
import { JSDOM } from 'jsdom';
import { act, create, type ReactTestRenderer } from 'react-test-renderer';
import { I18nProvider } from '../../application/i18n/I18nProvider';
import { TerminalComposeBar } from './TerminalComposeBar';
import { TooltipProvider } from '../ui/tooltip';
import { getComposeBarHistory, pruneComposeBarHistory, recordComposeBarHistory } from '../../application/state/composeBarHistoryStore';
@@ -195,3 +196,65 @@ test('empty compose draft Enter reconnects; a typed draft still sends', async (t
await act(async () => { root.unmount(); });
});
test('compose send target toggle shows all vs active without looking like broadcast when active', async (t) => {
const dom = new JSDOM('<!doctype html><html><body></body></html>', { url: 'http://localhost' });
const originals = new Map<string, PropertyDescriptor | undefined>();
for (const name of ['window', 'document', 'localStorage']) {
originals.set(name, Object.getOwnPropertyDescriptor(globalThis, name));
Object.defineProperty(globalThis, name, { configurable: true, value: dom.window[name as keyof Window] });
}
t.after(() => {
dom.window.close();
for (const [name, descriptor] of originals) {
if (descriptor) Object.defineProperty(globalThis, name, descriptor);
else Reflect.deleteProperty(globalThis, name);
}
});
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
const toggles: string[] = [];
const render = (composeSendToAll: boolean) => (
<I18nProvider locale="en">
<TooltipProvider>
<TerminalComposeBar
sessionId="compose-target"
onSend={() => true}
onClose={() => {}}
isBroadcastEnabled
composeSendToAll={composeSendToAll}
onToggleComposeSendTarget={() => { toggles.push('toggle'); }}
/>
</TooltipProvider>
</I18nProvider>
);
let root!: ReactTestRenderer;
await act(async () => {
root = create(render(true), { createNodeMock: (el) => (el.type === 'textarea' ? { focus() {} } : null) });
});
const button = root.root.findAll(
(node) => node.props?.['data-compose-send-target'] === 'all' || node.props?.['data-compose-send-target'] === 'active',
)[0];
assert.ok(button);
assert.equal(button.props['data-compose-send-target'], 'all');
assert.equal(button.props['aria-pressed'], true);
assert.match(String(button.props['aria-label']), /all sessions/i);
const pulsing = root.root.findAll((node) => typeof node.props?.className === 'string' && node.props.className.includes('animate-pulse'));
assert.ok(pulsing.length > 0);
await act(async () => {
button.props.onClick({
preventDefault() {},
stopPropagation() {},
defaultPrevented: false,
nativeEvent: { defaultPrevented: false },
});
});
assert.deepEqual(toggles, ['toggle']);
await act(async () => { root.update(render(false)); });
const active = root.root.findAll((node) => node.props?.['data-compose-send-target'] === 'active')[0];
assert.ok(active);
assert.equal(active.props['aria-pressed'], false);
assert.match(String(active.props['aria-label']), /active session/i);
const stillPulsing = root.root.findAll((node) => typeof node.props?.className === 'string' && node.props.className.includes('animate-pulse'));
assert.equal(stillPulsing.length, 0);
await act(async () => { root.unmount(); });
});
+32 -7
View File
@@ -280,6 +280,9 @@ export interface TerminalComposeBarProps {
onSnippetClick?: (snippet: Snippet) => void;
snippets?: Snippet[];
isBroadcastEnabled?: boolean;
/** Compose-bar send target. Undefined follows toolbar broadcast. */
composeSendToAll?: boolean;
onToggleComposeSendTarget?: () => void;
themeColors?: {
background: string;
foreground: string;
@@ -293,6 +296,8 @@ export const TerminalComposeBar: React.FC<TerminalComposeBarProps> = ({
onSnippetClick,
snippets = [],
isBroadcastEnabled,
composeSendToAll,
onToggleComposeSendTarget,
themeColors,
}) => {
const { t } = useI18n();
@@ -350,8 +355,9 @@ export const TerminalComposeBar: React.FC<TerminalComposeBarProps> = ({
};
}, [broadcastMenuAnchor]);
const sendToAll = composeSendToAll ?? Boolean(isBroadcastEnabled);
const handleBroadcastContextMenu = useCallback((event: React.MouseEvent) => {
if (!isBroadcastEnabled) return;
if (!isBroadcastEnabled && !sendToAll) return;
event.preventDefault();
event.stopPropagation();
setBroadcastMenuAnchor({
@@ -359,7 +365,7 @@ export const TerminalComposeBar: React.FC<TerminalComposeBarProps> = ({
y: event.clientY,
inTextarea: event.target instanceof Element && Boolean(event.target.closest('textarea')),
});
}, [isBroadcastEnabled]);
}, [isBroadcastEnabled, sendToAll]);
const handleComposeMenuEdit = useCallback(async (action: 'cut' | 'copy' | 'paste' | 'selectAll') => {
setBroadcastMenuAnchor(null);
@@ -607,14 +613,33 @@ export const TerminalComposeBar: React.FC<TerminalComposeBarProps> = ({
<div className="flex-1 min-h-0 px-3 pt-1.5 pb-2 flex flex-col">
<div className="flex flex-1 min-h-0 items-start gap-1.5">
{isBroadcastEnabled && (
{(onToggleComposeSendTarget || sendToAll) && (
<Tooltip>
<TooltipTrigger asChild>
<div className="flex items-center cursor-default pt-0.5 flex-shrink-0">
<Radio size={14} className="text-amber-400 animate-pulse" />
</div>
<button
type="button"
data-compose-send-target={sendToAll ? 'all' : 'active'}
aria-pressed={sendToAll}
aria-label={sendToAll
? t('terminal.composeBar.sendToAll')
: t('terminal.composeBar.sendToActive')}
className="flex items-center pt-0.5 flex-shrink-0 rounded-sm"
onMouseDown={(event) => event.preventDefault()}
onClick={() => onToggleComposeSendTarget?.()}
onContextMenu={handleBroadcastContextMenu}
>
<Radio
size={14}
className={sendToAll ? 'text-amber-400 animate-pulse' : undefined}
style={sendToAll ? undefined : { color: theme.mutedFg }}
/>
</button>
</TooltipTrigger>
<TooltipContent>{t('terminal.composeBar.broadcasting')}</TooltipContent>
<TooltipContent>
{sendToAll
? t('terminal.composeBar.broadcasting')
: t('terminal.composeBar.sendToActive')}
</TooltipContent>
</Tooltip>
)}
@@ -0,0 +1,123 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { JSDOM } from 'jsdom';
test('SFTP menu opens the clicked pane without a selection and is absent without a handler', async () => {
const dom = new JSDOM('<!doctype html><html><body><div id="root"></div></body></html>', {
pretendToBeVisual: true,
url: 'http://localhost',
});
const window = dom.window;
const previousGlobals = new Map<string, PropertyDescriptor | undefined>();
const installGlobal = (key: string, value: unknown) => {
previousGlobals.set(key, Object.getOwnPropertyDescriptor(globalThis, key));
Object.defineProperty(globalThis, key, {
configurable: true,
writable: true,
value,
});
};
class ResizeObserverStub {
observe() {}
unobserve() {}
disconnect() {}
}
installGlobal('window', window);
installGlobal('document', window.document);
installGlobal('navigator', window.navigator);
installGlobal('HTMLElement', window.HTMLElement);
installGlobal('HTMLInputElement', window.HTMLInputElement);
installGlobal('HTMLTextAreaElement', window.HTMLTextAreaElement);
installGlobal('Element', window.Element);
installGlobal('SVGElement', window.SVGElement);
installGlobal('Node', window.Node);
installGlobal('NodeFilter', window.NodeFilter);
installGlobal('MutationObserver', window.MutationObserver);
installGlobal('CustomEvent', window.CustomEvent);
installGlobal('DOMRect', window.DOMRect);
installGlobal('Event', window.Event);
installGlobal('KeyboardEvent', window.KeyboardEvent);
installGlobal('MouseEvent', window.MouseEvent);
installGlobal('getComputedStyle', window.getComputedStyle.bind(window));
installGlobal('requestAnimationFrame', window.requestAnimationFrame.bind(window));
installGlobal('cancelAnimationFrame', window.cancelAnimationFrame.bind(window));
installGlobal('ResizeObserver', ResizeObserverStub);
installGlobal('IS_REACT_ACT_ENVIRONMENT', true);
const { default: React, act } = await import('react');
const { createRoot } = await import('react-dom/client');
const { I18nProvider } = await import('../../application/i18n/I18nProvider.tsx');
const { TerminalContextMenu } = await import('./TerminalContextMenu.tsx');
const rootNode = window.document.getElementById('root');
assert.ok(rootNode);
const root = createRoot(rootNode);
const actions: string[] = [];
const openMenu = async (hostId: string) => {
const surface = window.document.querySelector<HTMLElement>(`[data-testid="${hostId}"]`);
assert.ok(surface);
await act(async () => {
surface.dispatchEvent(new window.MouseEvent('contextmenu', {
bubbles: true,
button: 2,
clientX: 20,
clientY: 20,
}));
});
};
const findMenuItem = (label: string): HTMLElement => {
const item = Array.from(window.document.querySelectorAll<HTMLElement>('[role="menuitem"]'))
.find((candidate) => candidate.textContent?.includes(label));
assert.ok(item, `${label} menu item should be visible`);
return item;
};
const renderPanes = async (enabled = true) => {
await act(async () => {
root.render(
<I18nProvider locale="zh-CN">
{['host-a', 'host-b'].map((hostId) => (
<TerminalContextMenu
key={hostId}
sessionId={hostId}
hostId={hostId}
status="connected"
hasSelection={false}
onOpenSftp={enabled ? () => actions.push(hostId) : undefined}
>
<div data-testid={hostId}>Terminal</div>
</TerminalContextMenu>
))}
</I18nProvider>,
);
});
};
try {
await renderPanes();
for (const hostId of ['host-b', 'host-a', 'host-b']) {
await openMenu(hostId);
const item = findMenuItem('打开 SFTP');
assert.notEqual(item.getAttribute('aria-disabled'), 'true');
assert.equal(findMenuItem('复制').getAttribute('aria-disabled'), 'true');
await act(async () => item.click());
assert.equal(window.document.querySelector('[role="menu"]'), null);
}
assert.deepEqual(actions, ['host-b', 'host-a', 'host-b']);
await renderPanes(false);
await openMenu('host-a');
assert.equal(window.document.body.textContent?.includes('打开 SFTP'), false);
} finally {
await act(async () => root.unmount());
await new Promise((resolve) => setTimeout(resolve, 0));
dom.window.close();
for (const [key, descriptor] of previousGlobals) {
if (descriptor) Object.defineProperty(globalThis, key, descriptor);
else delete (globalThis as Record<string, unknown>)[key];
}
}
});
@@ -7,6 +7,7 @@ import {
Copy,
Download,
Globe2,
FolderInput,
Pencil,
RefreshCcw,
Sparkles,
@@ -67,6 +68,7 @@ export interface TerminalContextMenuProps {
onReconnect?: () => void;
onClose?: () => void;
onOpenBrowser?: () => void;
onOpenSftp?: () => void;
onSelectWord?: () => void;
onAddSelectionToAI?: () => void;
onRename?: () => void;
@@ -227,6 +229,7 @@ export const TerminalContextMenu: React.FC<TerminalContextMenuProps> = ({
onReconnect,
onClose,
onOpenBrowser,
onOpenSftp,
onSelectWord,
onAddSelectionToAI,
onRename,
@@ -413,6 +416,14 @@ export const TerminalContextMenu: React.FC<TerminalContextMenuProps> = ({
{t('terminal.menu.selectAll')}
<ContextMenuShortcut>{selectAllShortcut}</ContextMenuShortcut>
</ContextMenuItem>
{onOpenSftp && (
<>
<ContextMenuSeparator />
<ContextMenuItem onClick={onOpenSftp}>
<FolderInput size={14} className="mr-2" /> {t('terminal.toolbar.openSftp')}
</ContextMenuItem>
</>
)}
{onOpenBrowser && (
<>
<ContextMenuSeparator />
+24 -1
View File
@@ -6,7 +6,7 @@ import { renderToStaticMarkup } from "react-dom/server";
import { I18nProvider } from "../../application/i18n/I18nProvider.tsx";
import type { Host } from "../../types.ts";
import { TerminalToolbar } from "./TerminalToolbar.tsx";
import { TERMINAL_TOOLBAR_LAYOUT_DEFAULTS, TerminalToolbar } from "./TerminalToolbar.tsx";
const toolbarSource = readFileSync(new URL("./TerminalToolbar.tsx", import.meta.url), "utf8");
@@ -213,3 +213,26 @@ for (const host of [serialHost, pluginHost]) {
assert.equal(renderToolbar(host).includes('aria-label="Open SFTP"'), false);
});
}
test('open session log stays in the overflow menu until logging is active', () => {
assert.equal(TERMINAL_TOOLBAR_LAYOUT_DEFAULTS.placement.openSessionLog, 'collapse');
const hidden = renderToolbar(sshHost, 'disconnected', { onSessionLogAction: () => {} });
assert.doesNotMatch(hidden, /aria-label="Open session log"/);
assert.doesNotMatch(hidden, /Open session log/);
for (const status of ['connected', 'disconnected'] as const) {
const markup = renderToolbar(sshHost, status, {
onSessionLogAction: () => {},
canOpenSessionLog: true,
});
assert.doesNotMatch(markup, /aria-label="Open session log"/);
assert.doesNotMatch(markup, /aria-label="Session log actions"/);
assert.match(markup, /aria-label="More actions"/);
}
assert.match(toolbarSource, /case 'openSessionLog':/);
assert.match(toolbarSource, /data-session-log-submenu="true"/);
assert.match(toolbarSource, /terminal\.toolbar\.sessionLog\.menu/);
assert.match(toolbarSource, /terminal\.toolbar\.sessionLog\.\$\{action\}/);
assert.equal(toolbarSource.includes('rememberApplication'), false);
assert.equal(toolbarSource.includes('Open session log'), false);
});
+105 -6
View File
@@ -6,6 +6,8 @@
import {
Check,
ChevronRight,
ChevronDown,
FileSearch,
Circle,
Download,
FileText,
@@ -25,6 +27,7 @@ import {
} from 'lucide-react';
import React, { useMemo, useState } from 'react';
import { useI18n } from '../../application/i18n/I18nProvider';
import type { SessionLogAction } from '../../application/state/sessionLogActions';
import { useToolbarItemLayout } from '../../application/state/useToolbarItemLayout';
import type { ToolbarItemLayoutDefaults } from '../../domain/toolbarItemLayout';
import { STORAGE_KEY_TERMINAL_TOOLBAR_LAYOUT } from '../../infrastructure/config/storageKeys';
@@ -51,6 +54,7 @@ export const TERMINAL_TOOLBAR_ITEM_IDS = [
'compose',
'search',
'sessionLog',
'openSessionLog',
'scripts',
'history',
'configureOsc7',
@@ -73,6 +77,7 @@ export const TERMINAL_TOOLBAR_LAYOUT_DEFAULTS: ToolbarItemLayoutDefaults = {
compose: 'show',
search: 'show',
sessionLog: 'show',
openSessionLog: 'collapse',
scripts: 'show',
history: 'collapse',
configureOsc7: 'collapse',
@@ -114,6 +119,9 @@ export interface TerminalToolbarProps {
// Manual session log
showLogButton?: boolean;
onToggleSessionLog?: () => void;
onSessionLogAction?: (action: SessionLogAction) => void;
/** Automatic logging is enabled, or this session has started a manual log. */
canOpenSessionLog?: boolean;
isSessionLogging?: boolean;
isSessionLogDisabled?: boolean;
// Compose bar
@@ -152,6 +160,8 @@ export const TerminalToolbar: React.FC<TerminalToolbarProps> = ({
onToggleSearch,
showLogButton = false,
onToggleSessionLog,
onSessionLogAction,
canOpenSessionLog = false,
isSessionLogging = false,
isSessionLogDisabled = false,
isComposeBarOpen,
@@ -196,12 +206,13 @@ export const TerminalToolbar: React.FC<TerminalToolbarProps> = ({
const pendingScriptDeleteCount = (pendingScriptDeleteIds ?? []).filter((id) =>
snippets.some((snippet) => snippet.id === id),
).length;
// Overflow popover + encoding submenu are both controlled so that
// picking an encoding closes the whole chain, and so the parent popover
// can ignore clicks that land in the submenu portal (otherwise the
// submenu click would read as "outside" and dismiss the parent).
// Overflow popover and its submenus are controlled so that picking an
// item closes the whole chain, and so the parent popover can ignore
// clicks that land in a submenu portal (otherwise the submenu click
// would read as "outside" and dismiss the parent).
const [overflowOpen, setOverflowOpen] = useState(false);
const [encodingSubmenuOpen, setEncodingSubmenuOpen] = useState(false);
const [sessionLogSubmenuOpen, setSessionLogSubmenuOpen] = useState(false);
const buttonBase =
'h-6 w-6 p-0 shadow-none border-none text-[color:var(--terminal-toolbar-fg)] bg-transparent hover:bg-transparent';
@@ -248,6 +259,7 @@ export const TerminalToolbar: React.FC<TerminalToolbarProps> = ({
ids.push('ymodemSend', 'ymodemReceive');
}
if (showLogButton) ids.push('sessionLog');
if (onSessionLogAction && canOpenSessionLog) ids.push('openSessionLog');
if (historySupported) ids.push('history');
if (onConfigureOsc7 && !hidesSftp) ids.push('configureOsc7');
if (onStartRecording) ids.push('recording');
@@ -264,6 +276,8 @@ export const TerminalToolbar: React.FC<TerminalToolbarProps> = ({
onSetTerminalEncoding,
onStartRecording,
showLogButton,
onSessionLogAction,
canOpenSessionLog,
]);
const itemLabels = useMemo(
@@ -278,6 +292,7 @@ export const TerminalToolbar: React.FC<TerminalToolbarProps> = ({
sessionLog: isSessionLogging
? t('terminal.toolbar.stopSessionLog')
: t('terminal.toolbar.startSessionLog'),
openSessionLog: t('terminal.toolbar.sessionLog.menu'),
scripts: t('terminal.toolbar.scripts'),
history: t('terminal.toolbar.history'),
configureOsc7: t('terminal.toolbar.configureOsc7'),
@@ -298,6 +313,7 @@ export const TerminalToolbar: React.FC<TerminalToolbarProps> = ({
compose: <TextCursorInput size={14} />,
search: <Search size={14} />,
sessionLog: <FileText size={14} />,
openSessionLog: <FileSearch size={14} />,
scripts: <Zap size={14} />,
history: <History size={14} />,
configureOsc7: <FolderSync size={14} />,
@@ -437,6 +453,52 @@ export const TerminalToolbar: React.FC<TerminalToolbarProps> = ({
);
}
const renderSessionLogSubmenu = () => {
if (!onSessionLogAction) return null;
return (
<Popover open={sessionLogSubmenuOpen} onOpenChange={setSessionLogSubmenuOpen}>
<PopoverTrigger asChild>
<button
type="button"
className={menuItemClass}
aria-haspopup="menu"
aria-expanded={sessionLogSubmenuOpen}
>
<FileSearch size={12} className="shrink-0" />
<span className="flex-1 text-left truncate">{t('terminal.toolbar.sessionLog.menu')}</span>
<ChevronRight size={12} className="shrink-0 text-muted-foreground" />
</button>
</PopoverTrigger>
<PopoverContent
data-session-log-submenu="true"
data-section="terminal-toolbar-menu"
className="w-56 p-1"
side="right"
align="start"
sideOffset={6}
>
{(['open', 'reveal', 'chooseApplication', 'systemDefault'] as const).map((action) => (
<button
key={action}
type="button"
className={menuItemClass}
onClick={() => {
onSessionLogAction(action);
setSessionLogSubmenuOpen(false);
setOverflowOpen(false);
}}
>
<FileSearch size={12} className="shrink-0" />
<span className="flex-1 text-left truncate">
{t(`terminal.toolbar.sessionLog.${action}`)}
</span>
</button>
))}
</PopoverContent>
</Popover>
);
};
const renderEncodingSubmenu = () => {
if (!encodingSwitchSupported || !onSetTerminalEncoding) return null;
return (
@@ -631,6 +693,38 @@ export const TerminalToolbar: React.FC<TerminalToolbarProps> = ({
<TooltipContent side="bottom">{t('terminal.toolbar.searchTerminal')}</TooltipContent>
</Tooltip>
);
case 'openSessionLog':
return (
<div key={id} className="flex items-center gap-0.5">
<Tooltip>
<TooltipTrigger asChild>
<Button type="button" variant="secondary" size="icon" className={buttonBase}
aria-label={t('terminal.toolbar.openSessionLog')}
onClick={() => onSessionLogAction?.('open')}>
<FileSearch size={12} />
</Button>
</TooltipTrigger>
<TooltipContent side="bottom">{t('terminal.toolbar.openSessionLog')}</TooltipContent>
</Tooltip>
<Popover>
<PopoverTrigger asChild>
<Button type="button" variant="secondary" size="icon" className={cn(buttonBase, 'w-4')}
aria-label={t('terminal.toolbar.sessionLogActions')}>
<ChevronDown size={10} />
</Button>
</PopoverTrigger>
<PopoverContent align="end" className="w-56 p-1">
{(['reveal', 'chooseApplication', 'systemDefault'] as const).map(action => (
<PopoverClose asChild key={action}>
<button type="button" className={menuItemClass} onClick={() => onSessionLogAction?.(action)}>
{t(`terminal.toolbar.sessionLog.${action}`)}
</button>
</PopoverClose>
))}
</PopoverContent>
</Popover>
</div>
);
case 'sessionLog':
return (
<Tooltip key={id}>
@@ -903,6 +997,8 @@ export const TerminalToolbar: React.FC<TerminalToolbarProps> = ({
</button>
</PopoverClose>
);
case 'openSessionLog':
return <React.Fragment key={id}>{renderSessionLogSubmenu()}</React.Fragment>;
case 'sessionLog':
return (
<PopoverClose asChild key={id}>
@@ -1020,7 +1116,10 @@ export const TerminalToolbar: React.FC<TerminalToolbarProps> = ({
open={overflowOpen}
onOpenChange={(open) => {
setOverflowOpen(open);
if (!open) setEncodingSubmenuOpen(false);
if (!open) {
setEncodingSubmenuOpen(false);
setSessionLogSubmenuOpen(false);
}
}}
>
<Tooltip>
@@ -1048,7 +1147,7 @@ export const TerminalToolbar: React.FC<TerminalToolbarProps> = ({
// "outside" this popover; without this guard a click
// in the submenu would dismiss the parent.
const target = e.target as Element | null;
if (target?.closest('[data-encoding-submenu="true"]')) {
if (target?.closest('[data-encoding-submenu="true"], [data-session-log-submenu="true"]')) {
e.preventDefault();
}
}}
+18
View File
@@ -652,6 +652,16 @@ test("terminal boot is cancelable and closes eagerly on cleanup", () => {
);
// Owner panes close the pending backend; attach popups only dispose xterm.
assert.match(neverConnectedBranch, /if \(!attachExistingSession\)/);
const emptySessionBranch = readFileSync(new URL("../Terminal.tsx", import.meta.url), "utf8");
const emptyStart = emptySessionBranch.indexOf("if (!closingSessionId)");
const emptyEnd = emptySessionBranch.indexOf("Observe/attach popups must not kill", emptyStart);
const emptyBranch = emptySessionBranch.slice(emptyStart, emptyEnd);
const attachGuardAt = emptyBranch.indexOf("if (attachExistingSession)");
const emptyCloseAt = emptyBranch.indexOf("terminalBackend.closeSession(");
assert.ok(
attachGuardAt !== -1 && emptyCloseAt !== -1 && attachGuardAt < emptyCloseAt,
"a second attach cleanup after sessionRef is cleared must not closeSession",
);
assert.match(effectsSource, /let ownedRuntime:/);
assert.match(
cleanup,
@@ -731,3 +741,11 @@ test("terminal theme updates force xterm renderer to repaint immediately", () =>
assert.match(schedulerSource, /term\.options\.theme = \{/);
assert.match(schedulerSource, /forceSyncRenderAfterResize\(term\)/);
});
test("SFTP context action reuses the pane toolbar handler and its availability", () => {
const source = readFileSync(new URL("./TerminalView.tsx", import.meta.url), "utf8");
const terminalSource = readFileSync(new URL("../Terminal.tsx", import.meta.url), "utf8");
assert.match(source, /onOpenSftp=\{status === "connected" && !isSerialConnection && !host\?\.id\?\.startsWith\("serial-"\) && !isPluginHostProtocol\(host\?\.protocol\) \? ctx\.handleOpenSFTP : undefined\}/);
assert.match(terminalSource, /onOpenSFTP=\{handleOpenSFTP\}/);
assert.match(terminalSource, /<TerminalView ctx=\{\{[^\n]*handleOpenSFTP,/);
});
+8 -1
View File
@@ -390,7 +390,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, 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, timeLeft, toast, zmodem } = ctx;
// Context menu only needs a snapshot at open; avoid selection state lifting into Terminal.
const [contextMenuHasSelection, setContextMenuHasSelection] = useState(false);
const isNetworkDevice = host.deviceType === 'network'
@@ -591,6 +591,7 @@ function TerminalViewInner({ ctx, isPaneMagnified = false }: { ctx: TerminalView
onSaveScreen={terminalContextActions.onSaveScreen}
onCopy={terminalContextActions.onCopy}
onOpenBrowser={(!host?.protocol || ['ssh', 'mosh', 'local'].includes(host.protocol)) ? () => { void ctx.handleOpenHostBrowser(); } : undefined}
onOpenSftp={status === "connected" && !isSerialConnection && !host?.id?.startsWith("serial-") && !isPluginHostProtocol(host?.protocol) ? ctx.handleOpenSFTP : undefined}
onPaste={terminalContextActions.onPaste}
onUploadClipboardImage={status === "connected" ? terminalContextActions.onUploadClipboardImage : undefined}
onPasteSelection={terminalContextActions.onPasteSelection}
@@ -1328,6 +1329,10 @@ function TerminalViewInner({ ctx, isPaneMagnified = false }: { ctx: TerminalView
let anyRecipientSensitive = false;
let broadcastRecipientIds: readonly string[] = [];
const sent = await executeSnippetCommand(text, false, {
// Explicit target: active-only stays local while toolbar
// broadcast (keystrokes) can remain on, and send-to-all
// still fans out when the toolbar switch is off.
broadcast: composeSendToAll ?? Boolean(isBroadcastEnabled),
onBroadcastDelivered: (sessionIds) => {
broadcastRecipientIds = sessionIds;
if (sessionIds.some((id) => isTerminalSensitiveInputActive(id))) {
@@ -1352,6 +1357,8 @@ function TerminalViewInner({ ctx, isPaneMagnified = false }: { ctx: TerminalView
termRef.current?.focus();
}}
isBroadcastEnabled={isBroadcastEnabled}
composeSendToAll={composeSendToAll}
onToggleComposeSendTarget={onToggleComposeSendTarget}
themeColors={effectiveTheme.colors}
/>
)}
@@ -1965,7 +1965,14 @@ export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContex
sessionId: ctx.sessionId,
dataLength: terminalData.length,
});
ctx.onTerminalDataCapture(ctx.sessionId, terminalData);
const pendingCapture: unknown = ctx.onTerminalDataCapture(ctx.sessionId, terminalData);
if (
pendingCapture !== undefined
&& pendingCapture !== null
&& typeof (pendingCapture as Promise<void>).then === "function"
) {
await (pendingCapture as Promise<void>);
}
} catch (err) {
logger.warn("Failed to serialize terminal data:", err);
}
@@ -245,7 +245,7 @@ export type TerminalSessionStartersContext = {
onSessionAttached?: (sessionId: string) => void;
onRestoreCwdIntentConsumed?: (cwd: string) => void;
onSessionExit?: (sessionId: string, evt: TerminalSessionExitEvent) => void;
onTerminalDataCapture?: (sessionId: string, data: string) => void;
onTerminalDataCapture?: (sessionId: string, data: string) => void | Promise<void>;
onTerminalLogData?: (data: string) => void;
onProgrammaticCommandLogRewrite?: (rewrite: ProgrammaticCommandLogRewrite) => void;
onTerminalOutput?: (chunk: string, meta?: TerminalSessionDataMeta) => void;
@@ -0,0 +1,41 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import test from "node:test";
import { shouldBroadcastTerminalUserInput } from "./terminalUserPaste.ts";
test("toolbar broadcast still fans terminal keystrokes out to peers", () => {
const term = {};
assert.equal(
shouldBroadcastTerminalUserInput(term, "ls", {
isBroadcastEnabled: true,
hasBroadcastInputHandler: true,
}),
true,
);
assert.equal(
shouldBroadcastTerminalUserInput(term, "ls", {
isBroadcastEnabled: false,
hasBroadcastInputHandler: true,
}),
false,
);
});
test("keystroke fan-out stays on the toolbar gate and ignores the compose-bar target", () => {
const runtime = readFileSync(new URL("./createXTermRuntime.ts", import.meta.url), "utf8");
assert.match(runtime, /isBroadcastEnabled: ctx\.isBroadcastEnabledRef\.current/);
assert.match(
runtime,
/onBroadcastInput\?\.\(broadcastData, ctx\.sessionId, sensitive \? \{ sourceSensitive: true \} : undefined\)/,
);
assert.doesNotMatch(runtime, /composeSendToAll|composePeerFanOut/);
const layer = readFileSync(new URL("../../TerminalLayer.tsx", import.meta.url), "utf8");
assert.match(layer, /includeGlobalPeersWithoutToolbar: options\?\.composePeerFanOut === true/);
const keystrokeCall = layer.slice(
layer.indexOf("const handleBroadcastInput"),
layer.indexOf("const handleCommandSubmitted"),
);
assert.doesNotMatch(keystrokeCall, /isComposeSendToAll/);
});
@@ -475,6 +475,10 @@ export const createTerminalOutputHistoryPreview = (options?: {
// mark / trailing surrogate in the next) while xterm's grapheme provider
// joins them across writes, so the next span must be able to rejoin it.
let openGrapheme: string | null = null;
// When set, `openGrapheme` still holds the whole non-ASCII piece the last
// append wrote; its last grapheme is only segmented out when a following
// span actually tries to rejoin it (rare), not on every append.
let openGraphemeUnresolved = false;
let openGraphemeCell = 0;
let cacheDirty = true;
let cacheCols = -1;
@@ -551,6 +555,10 @@ export const createTerminalOutputHistoryPreview = (options?: {
&& cursor === current.length
&& cursorCell === openGraphemeCell
) {
if (openGraphemeUnresolved) {
openGrapheme = lastGraphemeUnits(openGrapheme);
openGraphemeUnresolved = false;
}
const probeEnd = Math.min(span.length, WRAP_PROBE_UTF16_UNITS);
const head = openGrapheme + span.slice(0, probeEnd);
let first = firstGraphemeUnits(head);
@@ -703,7 +711,9 @@ export const createTerminalOutputHistoryPreview = (options?: {
// clusters and corrupt the transcript, while a boundary-aligned cut
// keeps every piece's segmentation identical to the span's.
let pieceEnd = offset + (maxChars - current.length);
if (pieceEnd - offset > pieceCap) {
// A remainder no longer than the cap needs no boundary search: the
// segment walk below would consume all of it and cut at its end.
if (pieceEnd - offset > pieceCap && span.length - offset > pieceCap) {
const window = span.slice(offset, offset + pieceCap + WRAP_PROBE_UTF16_UNITS);
let cutLength = 0;
let end = window.length;
@@ -794,7 +804,8 @@ export const createTerminalOutputHistoryPreview = (options?: {
// continuing it (a split cluster) can be rejoined (see writeSpan).
// An empty open grapheme would corrupt the join, so track null then.
openGrapheme = !fitting ? null
: isAsciiOnly(fitting) ? fitting.slice(-1) : lastGraphemeUnits(fitting);
: pieceAscii ? fitting.slice(-1) : fitting;
openGraphemeUnresolved = !!fitting && !pieceAscii;
openGraphemeCell = cursorCell;
}
cursor = appending ? current.length
@@ -1000,7 +1000,14 @@ export const attachSessionToTerminal = (
try {
await ctx.prepareKeywordHighlightSerialization?.();
const terminalData = ctx.serializeAddonRef.current.serialize();
ctx.onTerminalDataCapture(ctx.sessionId, terminalData);
const pendingCapture: unknown = ctx.onTerminalDataCapture(ctx.sessionId, terminalData);
if (
pendingCapture !== undefined
&& pendingCapture !== null
&& typeof (pendingCapture as Promise<void>).then === "function"
) {
await (pendingCapture as Promise<void>);
}
} catch (err) {
logger.warn("Failed to serialize terminal data:", err);
}
+9 -1
View File
@@ -48,6 +48,11 @@ export interface TerminalBroadcastInputOptions {
* the dispatcher tags the payload sourceSensitive.
*/
sensitive?: boolean;
/**
* Compose-bar send-to-all for orphan sessions while the toolbar broadcast
* switch stays off. Keystroke fan-out never sets this.
*/
composePeerFanOut?: boolean;
/**
* Observer for the sessions that actually received the payload (#3491):
* the solo compose bar uses it to detect a fan-out delivered into a peer's
@@ -198,7 +203,7 @@ export interface TerminalProps {
onTerminalFontSizeChange?: (fontSize: number) => void;
onStatusChange?: (sessionId: string, status: TerminalSession["status"], sftpHost?: Host) => void;
onSessionExit?: (sessionId: string, evt: TerminalSessionExitEvent) => void;
onTerminalDataCapture?: (sessionId: string, data: string) => void;
onTerminalDataCapture?: (sessionId: string, data: string) => void | Promise<void>;
onOsDetected?: (hostId: string, distro: string) => void;
onCloseSession?: (sessionId: string) => void;
onUpdateHost?: (host: Host) => void;
@@ -236,6 +241,9 @@ export interface TerminalProps {
onOpenTheme?: () => void;
onOpenSystem?: () => void;
isBroadcastEnabled?: boolean;
/** Compose-bar send target for solo sessions. Workspace compose lives on the layer. */
composeSendToAll?: boolean;
onToggleComposeSendTarget?: () => void;
onToggleBroadcast?: () => void;
onToggleComposeBar?: () => void;
isWorkspaceComposeBarOpen?: boolean;
+2
View File
@@ -54,6 +54,8 @@ export const terminalPropsAreEqual = (
&& prev.disableTerminalFontZoom === next.disableTerminalFontZoom
&& prev.keyBindings === next.keyBindings
&& prev.isBroadcastEnabled === next.isBroadcastEnabled
&& prev.composeSendToAll === next.composeSendToAll
&& prev.onToggleComposeSendTarget === next.onToggleComposeSendTarget
&& prev.isWorkspaceComposeBarOpen === next.isWorkspaceComposeBarOpen
&& prev.sessionLog === next.sessionLog
&& prev.sshDebugLogEnabled === next.sshDebugLogEnabled
@@ -0,0 +1,182 @@
/* eslint-disable @typescript-eslint/no-explicit-any, react-hooks/exhaustive-deps */
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 { useTerminalEffects } from './useTerminalEffects';
import { TERMINAL_SESSION_RESTORE_FOCUS_EVENT } from './focusTerminalSession';
// Exercise the real hook's reveal and settling effects. The injected effect
// hooks omit unrelated backend/auth/font setup; no recovery logic is copied.
function visibilityEffect(effect: React.EffectCallback) {
const body = effect.toString();
return body.includes('const becameVisible')
|| (/hiddenAtRef\.current\s*=\s*Date\.now\(\)/.test(body))
|| ((/hiddenAtRef\.current\s*=\s*null/.test(body)) && body.includes('const timer'))
|| (body.includes('const timer1') && body.includes('const timer2'))
|| body.includes('const handleRestoreFocus')
|| body.includes('const recoverTerminalOnAppResume');
}
function harness(t: any, initiallyHasRuntime = true) {
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
let now = 10_000;
let nextId = 0;
const timers = new Map<number, { at: number; run: () => void }>();
const schedule = (run: () => void, delay = 0) => {
const id = ++nextId;
timers.set(id, { at: now + delay, run });
return id;
};
t.mock.method(Date, 'now', () => now);
t.mock.method(globalThis, 'setTimeout', schedule);
t.mock.method(globalThis, 'clearTimeout', (id: number) => timers.delete(id));
const win = new EventTarget();
const doc = Object.assign(new EventTarget(), { visibilityState: 'visible', querySelector: () => null });
let fullscreen: ((value: boolean) => void) | undefined;
let renderer: ReactTestRenderer;
const documentBefore = Object.getOwnPropertyDescriptor(globalThis, 'document');
Object.defineProperty(globalThis, 'document', { configurable: true, value: doc });
const windowBefore = Object.getOwnPropertyDescriptor(globalThis, 'window');
Object.defineProperty(globalThis, 'window', { configurable: true, value: Object.assign(win, { setTimeout: schedule }) });
t.after(() => {
if (renderer) act(() => renderer.unmount());
if (documentBefore) Object.defineProperty(globalThis, 'document', documentBefore);
else delete (globalThis as any).document;
if (windowBefore) Object.defineProperty(globalThis, 'window', windowBefore);
else delete (globalThis as any).window;
});
const fitTimes: number[] = [];
const calls = { ensure: 0, clear: 0, fit: 0, forcedFit: 0, render: 0, focus: 0 };
const term = { options: {}, focus() { calls.focus++; }, rows: 24, cols: 80, buffer: { active: { type: 'normal' } }, scrollToBottom() {}, _core: { _renderService: { _renderRows() { calls.render++; } } } };
const runtime = { ensureWebglRenderer() { calls.ensure++; }, clearTextureAtlas() { calls.clear++; } };
const values: Record<string, any> = {
host: { id: 'host', protocol: 'ssh' }, auth: {}, zmodem: {}, effectiveTheme: { id: 'theme', colors: {} },
terminalSettings: {}, terminalSettingsRef: { current: {} },
terminalBackend: { onWindowFullScreenChanged(callback: (value: boolean) => void) { fullscreen = callback; return () => { fullscreen = undefined; }; } },
pluginDecorationRules: [], sessionId: 'session', paneLayoutKey: 'layout',
isVisible: false, isVisibleRef: { current: false }, splitResizeActive: false, inWorkspace: false,
termRef: { current: initiallyHasRuntime ? term : null }, xtermRuntimeRef: { current: initiallyHasRuntime ? runtime : null },
fitAddonRef: { current: {} }, sessionRef: { current: 'pty' },
containerRef: { current: { clientWidth: 800, clientHeight: 480 } },
lastFittedSizeRef: { current: { width: 800, height: 480 } }, pendingOutputScrollRef: { current: false },
safeFit(options: any) {
calls.fit++;
fitTimes.push(now);
if (options?.force) calls.forcedFit++;
const container = values.containerRef.current;
values.lastFittedSizeRef.current = { width: container.clientWidth, height: container.clientHeight };
},
forceSyncRenderAfterResize() { calls.render++; }, resizeSession() {},
useEffect(effect: React.EffectCallback, deps: React.DependencyList) {
React.useEffect(visibilityEffect(effect) ? effect : () => {}, deps);
},
useLayoutEffect(effect: React.EffectCallback, deps: React.DependencyList) {
React.useLayoutEffect(visibilityEffect(effect) ? effect : () => {}, deps);
},
};
const ctx = new Proxy(values, { get(target, key: string) {
if (!(key in target)) target[key] = key.endsWith('Ref') ? { current: null } : (() => {});
return target[key];
} });
function Component() { useTerminalEffects(ctx); return null; }
act(() => { renderer = create(React.createElement(Component)); });
function advance(ms: number) {
const end = now + ms;
while (true) {
const next = [...timers.entries()].filter(([, item]) => item.at <= end).sort((a, b) => a[1].at - b[1].at)[0];
if (!next) break;
now = next[1].at;
timers.delete(next[0]);
act(() => next[1].run());
}
now = end;
}
return { calls, fitTimes, advance, values,
focus() { const event = Object.assign(new Event(TERMINAL_SESSION_RESTORE_FOCUS_EVENT), { detail: { sessionId: 'session' } }); act(() => { win.dispatchEvent(event); }); },
resume() { act(() => { doc.dispatchEvent(new Event('visibilitychange')); }); },
fullscreen() { act(() => fullscreen?.(true)); },
reveal() { values.isVisible = true; values.isVisibleRef.current = true; act(() => renderer.update(React.createElement(Component))); },
publishRuntime() { values.termRef.current = term; values.xtermRuntimeRef.current = runtime; },
};
}
for (const hiddenMs of [2000, 4000]) {
test(`unchanged reveal after ${hiddenMs}ms does not repeat recovery in settling timers`, t => {
const h = harness(t);
h.advance(hiddenMs);
h.reveal();
assert.equal(h.calls.clear, hiddenMs >= 3000 ? 1 : 0);
h.advance(600);
assert.equal(h.calls.clear, hiddenMs >= 3000 ? 1 : 0);
assert.equal(h.calls.fit, 0);
});
}
test('late changed bounds still receive a settling fit', t => {
const h = harness(t);
h.advance(4000); h.reveal(); h.advance(200);
h.values.containerRef.current.clientHeight = 460;
h.advance(400);
assert.equal(h.calls.fit, 1);
assert.equal(h.calls.forcedFit, 0);
assert.deepEqual(h.values.lastFittedSizeRef.current, { width: 800, height: 460 });
});
test('runtime published after reveal still receives delayed recovery', t => {
const h = harness(t, false);
h.advance(4000); h.reveal();
assert.equal(h.calls.clear, 0);
h.advance(25); h.publishRuntime(); h.advance(575);
assert.equal(h.calls.ensure, 1);
assert.equal(h.calls.clear, 1);
assert.equal(h.calls.forcedFit, 1);
});
test('focus restoration and its 50ms retry do not refit unchanged bounds', t => {
const h = harness(t);
h.advance(4000); h.reveal(); h.advance(600);
h.focus(); h.advance(50); h.focus(); h.advance(500);
assert.equal(h.calls.focus, 2);
assert.equal(h.calls.fit, 0);
assert.equal(h.calls.clear, 1);
});
test('focus restoration still recovers changed bounds', t => {
const h = harness(t);
h.advance(4000); h.reveal(); h.advance(600);
h.values.containerRef.current.clientWidth = 780;
h.focus(); h.advance(50); h.focus(); h.advance(100);
assert.equal(h.calls.focus, 2);
assert.equal(h.calls.forcedFit, 2);
assert.deepEqual(h.values.lastFittedSizeRef.current, { width: 780, height: 480 });
});
for (const source of ['resume', 'fullscreen'] as const) {
test(`fitted focus retries preserve pending ${source} recovery timers`, t => {
const h = harness(t);
h.advance(4000); h.reveal(); h.advance(600);
h[source](); h.advance(0);
assert.equal(h.calls.forcedFit, 1);
h.focus(); h.advance(50); h.focus(); h.advance(500);
assert.equal(h.calls.focus, 2);
assert.equal(h.calls.forcedFit, 3, 'all three explicit recovery passes must survive focus retries');
assert.deepEqual(h.fitTimes.map(at => at - h.fitTimes[0]), source === 'resume' ? [0, 100, 300] : [0, 150, 400]);
});
}
for (const source of ['resume', 'fullscreen'] as const) {
test(`focus during changed bounds preserves pending ${source} settling passes`, t => {
const h = harness(t);
h.advance(4000); h.reveal(); h.advance(600);
h[source](); h.advance(0);
h.advance(50);
h.values.containerRef.current.clientWidth = 780;
h.focus(); h.advance(500);
assert.equal(h.calls.focus, 1);
assert.equal(h.calls.forcedFit, 3);
assert.deepEqual(h.fitTimes.map(at => at - h.fitTimes[0]), source === 'resume' ? [0, 100, 300] : [0, 150, 400]);
assert.equal(h.values.lastFittedSizeRef.current.width, 780);
});
}
@@ -115,6 +115,27 @@ test('immediate tab recovery marks webgl recovery to skip the delayed duplicate
);
assert.match(
source,
/lastWebglRecoveryLayoutKeyRef\.current === paneLayoutKey\s*&& hiddenMs < CSS_ONLY_TAB_REVEAL_MAX_HIDDEN_MS/,
/if \(lastWebglRecoveryLayoutKeyRef\.current === paneLayoutKey\) \{/,
);
});
test('reveal does not claim recovery before the terminal runtime exists', () => {
const start = source.indexOf('const recoverTerminalAfterBecomeVisible = () => {');
const end = source.indexOf('// Refit synchronously', start);
const recovery = source.slice(start, end);
const runtimeGuard = recovery.indexOf('if (!termRef.current || !xtermRuntimeRef.current) return;');
const commit = recovery.indexOf('lastWebglRecoveryLayoutKeyRef.current = paneLayoutKey');
assert.ok(runtimeGuard >= 0 && runtimeGuard < commit);
});
test('settling tab-layout probes do not force a fit when bounds are unchanged', () => {
const start = source.indexOf('const refitSettledLayout = () => {');
const end = source.indexOf('}, [inWorkspace, isVisible]);', start);
const settling = source.slice(start, end);
assert.ok(start >= 0 && end > start);
assert.match(settling, /if \(!isVisibleRef\.current \|\| currentContainerSizeAlreadyFit\(\)\) return;/);
assert.match(settling, /safeFit\(\{ requireVisible: true \}\);/);
assert.doesNotMatch(settling, /force: true/);
assert.match(settling, /setTimeout\(refitSettledLayout, 100\)/);
assert.match(settling, /setTimeout\(refitSettledLayout, 350\)/);
});
+40 -20
View File
@@ -917,8 +917,14 @@ export function useTerminalEffects(ctx: TerminalEffectsContext) {
bootAbort.abort();
isBootActiveRef.current = false;
if (hibernatedRef?.current) {
forceCloseHibernatedSession?.();
disposeOwnedRuntime();
const pendingClose = forceCloseHibernatedSession?.() as void | Promise<void>;
if (pendingClose && typeof pendingClose.then === "function") {
void pendingClose.finally(() => {
disposeOwnedRuntime();
});
} else {
disposeOwnedRuntime();
}
return;
}
@@ -946,13 +952,20 @@ export function useTerminalEffects(ctx: TerminalEffectsContext) {
return;
}
const persistCloseCapture = (data: string, source: string, dataLength: number) => {
const persistCloseCapture = async (data: string, source: string, dataLength: number) => {
logger.info("[Terminal] Capturing data on unmount", {
sessionId,
source,
dataLength,
});
handleTerminalDataCaptureOnce(sessionId, data, { finalized: true });
const pendingCapture: unknown = handleTerminalDataCaptureOnce(sessionId, data, { finalized: true });
if (
pendingCapture !== undefined
&& pendingCapture !== null
&& typeof (pendingCapture as Promise<void>).then === "function"
) {
await (pendingCapture as Promise<void>);
}
};
const term = termRef.current;
@@ -991,7 +1004,7 @@ export function useTerminalEffects(ctx: TerminalEffectsContext) {
? resolveConnectionLogCapturePayload(finalizeTerminalLogData)
: null;
if (connectionLogPayload) {
persistCloseCapture(
await persistCloseCapture(
connectionLogPayload.data,
connectionLogPayload.source,
connectionLogPayload.data.length,
@@ -1018,7 +1031,7 @@ export function useTerminalEffects(ctx: TerminalEffectsContext) {
return;
}
if (payload) {
persistCloseCapture(payload.data, payload.source, payload.data.length);
await persistCloseCapture(payload.data, payload.source, payload.data.length);
}
ownedRuntime = null;
scheduleTerminalCloseTeardown(teardown);
@@ -1317,7 +1330,12 @@ export function useTerminalEffects(ctx: TerminalEffectsContext) {
applyUserCursorBlinkPreference(term, terminalSettingsRef.current);
if (hasOpenAppDialog()) return;
term.focus();
scheduleLayoutRecoveryRefit([0, 100]);
// Tab focus retries do not imply a lost renderer or a changed layout.
// Leave explicit app-resume/fullscreen recovery timers untouched when
// these bounds are already fitted, or recovery is still settling.
if (!currentContainerSizeAlreadyFit() && layoutRecoveryTimersRef.current.length === 0) {
scheduleLayoutRecoveryRefit([0, 100]);
}
};
window.addEventListener(TERMINAL_SESSION_RESTORE_FOCUS_EVENT, handleRestoreFocus);
@@ -1387,6 +1405,9 @@ export function useTerminalEffects(ctx: TerminalEffectsContext) {
const recoverTerminalAfterBecomeVisible = () => {
flushTerminalWritesAfterBecomeVisible();
// A hidden pane can become visible before its asynchronous boot completes.
// Do not claim recovery until there is a runtime for the delayed pass to use.
if (!termRef.current || !xtermRuntimeRef.current) return;
if (
getHiddenDurationMs() < CSS_ONLY_TAB_REVEAL_MAX_HIDDEN_MS
@@ -1463,20 +1484,17 @@ export function useTerminalEffects(ctx: TerminalEffectsContext) {
useEffect(() => {
if (!isVisible) return;
const hiddenMs = hiddenAtRef.current
? Date.now() - hiddenAtRef.current
: Number.POSITIVE_INFINITY;
hiddenAtRef.current = null;
if (
lastWebglRecoveryLayoutKeyRef.current === paneLayoutKey
&& hiddenMs < CSS_ONLY_TAB_REVEAL_MAX_HIDDEN_MS
) {
// The layout effect already recovers long hides as well as short ones.
// Repeating it here discards the freshly populated, potentially shared atlas.
if (lastWebglRecoveryLayoutKeyRef.current === paneLayoutKey) {
flushPendingOutputScroll();
return;
}
const timer = setTimeout(() => {
if (!isVisibleRef.current || !termRef.current || !xtermRuntimeRef.current) return;
lastWebglRecoveryLayoutKeyRef.current = paneLayoutKey;
// A pane that mounted hidden deferred its WebGL renderer; create it now
// that it's visible (no-op if already active or WebGL is disabled).
@@ -1687,14 +1705,16 @@ export function useTerminalEffects(ctx: TerminalEffectsContext) {
// Fit twice: once after initial layout (100ms) and again after layout settles
// (350ms) to handle race conditions during split operations where the container
// dimensions may not be final on the first pass.
const timer1 = setTimeout(() => {
const refitSettledLayout = () => {
// Keep checking for late split/layout changes, but an unchanged tab does
// not need another forced fit (which clears its glyph atlas even when
// the row/column count is unchanged).
if (!isVisibleRef.current || currentContainerSizeAlreadyFit()) return;
safeFit({ requireVisible: true });
finishLayoutRecoveryAfterFit();
}, 100);
const timer2 = setTimeout(() => {
safeFit({ force: true, requireVisible: true });
finishLayoutRecoveryAfterFit();
}, 350);
};
const timer1 = setTimeout(refitSettledLayout, 100);
const timer2 = setTimeout(refitSettledLayout, 350);
return () => { clearTimeout(timer1); clearTimeout(timer2); };
}, [inWorkspace, isVisible]);
@@ -696,7 +696,7 @@ export interface TerminalLayerProps {
onCommandExecuted?: (command: string, hostId: string, hostLabel: string, sessionId: string) => void;
onDeleteShellHistoryEntry?: (entryId: string) => void;
onClearCommandHistory?: () => void;
onTerminalDataCapture?: (sessionId: string, data: string) => void;
onTerminalDataCapture?: (sessionId: string, data: string) => void | Promise<void>;
onCreateWorkspaceFromSessions: (baseSessionId: string, joiningSessionId: string, hint: Exclude<SplitHint, null>) => void;
onAddSessionToWorkspace: (workspaceId: string, sessionId: string, hint: Exclude<SplitHint, null>) => void;
onRequestAddToWorkspace?: (workspaceId: string) => void;
@@ -719,10 +719,14 @@ export interface TerminalLayerProps {
onCreateLocalTerminal?: () => void;
// Broadcast mode
isBroadcastEnabled?: (workspaceId: string) => boolean;
isComposeSendToAll?: (workspaceId: string) => boolean;
onToggleComposeSendTarget?: (workspaceId: string) => void;
isGlobalBroadcastEnabled?: boolean;
isGlobalComposeSendToAll?: boolean;
canUseGlobalBroadcast?: boolean;
onToggleBroadcast?: (workspaceId: string) => void;
onToggleGlobalBroadcast?: () => void;
onToggleGlobalComposeSendTarget?: () => void;
// SFTP side panel
updateHosts: (hosts: Host[]) => void;
updateSnippets?: (snippets: Snippet[]) => void;
@@ -815,7 +819,7 @@ interface TerminalPaneProps {
onCloseSession: (sessionId: string) => void;
onStatusChange: (sessionId: string, status: TerminalSession['status'], sftpHost?: Host) => void;
onSessionExit: (sessionId: string, evt: TerminalSessionExitEvent) => void;
onTerminalDataCapture?: (sessionId: string, data: string) => void;
onTerminalDataCapture?: (sessionId: string, data: string) => void | Promise<void>;
onOsDetected: (hostId: string, distro: string) => void;
onUpdateHost: (host: Host) => void;
onAddKnownHost?: (knownHost: KnownHost) => void;
@@ -824,9 +828,12 @@ interface TerminalPaneProps {
onSetWorkspaceFocusedSession?: (workspaceId: string, sessionId: string) => void;
onSplitSession?: (sessionId: string, direction: SplitDirection) => void;
isBroadcastEnabled?: (workspaceId: string) => boolean;
isComposeSendToAll?: (workspaceId: string) => boolean;
isGlobalBroadcastEnabled?: boolean;
isGlobalComposeSendToAll?: boolean;
canUseGlobalBroadcast?: boolean;
onToggleGlobalBroadcast?: () => void;
onToggleGlobalComposeSendTarget?: () => void;
onBroadcastInput: (
data: string,
sourceSessionId: string,
@@ -952,9 +959,12 @@ const terminalPanePropsAreEqual = (
prev.onSetWorkspaceFocusedSession === next.onSetWorkspaceFocusedSession &&
prev.onSplitSession === next.onSplitSession &&
prev.isBroadcastEnabled === next.isBroadcastEnabled &&
prev.isComposeSendToAll === next.isComposeSendToAll &&
prev.isGlobalBroadcastEnabled === next.isGlobalBroadcastEnabled &&
prev.isGlobalComposeSendToAll === next.isGlobalComposeSendToAll &&
prev.canUseGlobalBroadcast === next.canUseGlobalBroadcast &&
prev.onToggleGlobalBroadcast === next.onToggleGlobalBroadcast &&
prev.onToggleGlobalComposeSendTarget === next.onToggleGlobalComposeSendTarget &&
prev.onBroadcastInput === next.onBroadcastInput &&
prev.onBroadcastInterruptPriorityChange === next.onBroadcastInterruptPriorityChange &&
prev.onToggleWorkspaceComposeBar === next.onToggleWorkspaceComposeBar &&
@@ -1253,9 +1263,12 @@ const TerminalPane: React.FC<TerminalPaneProps> = memo(({
onSetWorkspaceFocusedSession,
onSplitSession,
isBroadcastEnabled,
isComposeSendToAll,
isGlobalBroadcastEnabled,
isGlobalComposeSendToAll,
canUseGlobalBroadcast,
onToggleGlobalBroadcast,
onToggleGlobalComposeSendTarget,
onBroadcastInput,
onBroadcastInterruptPriorityChange,
onToggleWorkspaceComposeBar,
@@ -1485,6 +1498,12 @@ const TerminalPane: React.FC<TerminalPaneProps> = memo(({
const broadcastEnabled = activeWorkspaceId
? !!isBroadcastEnabled?.(activeWorkspaceId)
: (isGlobalBroadcastEnabled ?? false);
const composeSendToAll = activeWorkspaceId
? !!isComposeSendToAll?.(activeWorkspaceId)
: !!isGlobalComposeSendToAll;
const toggleComposeSendTarget = !activeWorkspaceId && canUseGlobalBroadcast
? onToggleGlobalComposeSendTarget
: undefined;
const isHostEphemeral = !isSavedVaultHost(hostMap.get(host.id));
const sessionAppearance = useMemo(
() => resolveSessionAppearance({ host, isEphemeral: isHostEphemeral }),
@@ -1630,6 +1649,8 @@ const TerminalPane: React.FC<TerminalPaneProps> = memo(({
onSplitHorizontal={onSplitSession ? splitHorizontalHandler : undefined}
onSplitVertical={onSplitSession ? splitVerticalHandler : undefined}
isBroadcastEnabled={broadcastEnabled}
composeSendToAll={inActiveWorkspace ? undefined : composeSendToAll}
onToggleComposeSendTarget={toggleComposeSendTarget}
onToggleBroadcast={
inActiveWorkspace
? workspaceBroadcastHandler
@@ -1637,7 +1658,7 @@ const TerminalPane: React.FC<TerminalPaneProps> = memo(({
}
onToggleComposeBar={inActiveWorkspace ? onToggleWorkspaceComposeBar : undefined}
isWorkspaceComposeBarOpen={inActiveWorkspace ? isComposeBarOpen : undefined}
onBroadcastInput={broadcastEnabled ? onBroadcastInput : undefined}
onBroadcastInput={onBroadcastInput}
onBroadcastInterruptPriorityChange={onBroadcastInterruptPriorityChange}
onSnippetExecutorChange={onSnippetExecutorChange}
onProgrammaticCommandLogRewriteChange={onProgrammaticCommandLogRewriteChange}
@@ -1717,7 +1738,7 @@ interface TerminalPanesHostProps {
onCloseSession: (sessionId: string) => void;
onStatusChange: (sessionId: string, status: TerminalSession['status'], sftpHost?: Host) => void;
onSessionExit: (sessionId: string, evt: TerminalSessionExitEvent) => void;
onTerminalDataCapture?: (sessionId: string, data: string) => void;
onTerminalDataCapture?: (sessionId: string, data: string) => void | Promise<void>;
onOsDetected: (hostId: string, distro: string) => void;
onUpdateHost: (host: Host) => void;
onAddKnownHost?: (knownHost: KnownHost) => void;
@@ -1726,9 +1747,12 @@ interface TerminalPanesHostProps {
onSetWorkspaceFocusedSession?: (workspaceId: string, sessionId: string) => void;
onSplitSession?: (sessionId: string, direction: SplitDirection) => void;
isBroadcastEnabled?: (workspaceId: string) => boolean;
isComposeSendToAll?: (workspaceId: string) => boolean;
isGlobalBroadcastEnabled?: boolean;
isGlobalComposeSendToAll?: boolean;
canUseGlobalBroadcast?: boolean;
onToggleGlobalBroadcast?: () => void;
onToggleGlobalComposeSendTarget?: () => void;
onBroadcastInput: (
data: string,
sourceSessionId: string,
@@ -1816,9 +1840,12 @@ const terminalPanesHostPropsAreEqual = (
if (prev.onSetWorkspaceFocusedSession !== next.onSetWorkspaceFocusedSession) return false;
if (prev.onSplitSession !== next.onSplitSession) return false;
if (prev.isBroadcastEnabled !== next.isBroadcastEnabled) return false;
if (prev.isComposeSendToAll !== next.isComposeSendToAll) return false;
if (prev.isGlobalBroadcastEnabled !== next.isGlobalBroadcastEnabled) return false;
if (prev.isGlobalComposeSendToAll !== next.isGlobalComposeSendToAll) return false;
if (prev.canUseGlobalBroadcast !== next.canUseGlobalBroadcast) return false;
if (prev.onToggleGlobalBroadcast !== next.onToggleGlobalBroadcast) return false;
if (prev.onToggleGlobalComposeSendTarget !== next.onToggleGlobalComposeSendTarget) return false;
if (prev.onBroadcastInput !== next.onBroadcastInput) return false;
if (prev.onBroadcastInterruptPriorityChange !== next.onBroadcastInterruptPriorityChange) return false;
if (prev.onToggleWorkspaceComposeBar !== next.onToggleWorkspaceComposeBar) return false;
@@ -547,6 +547,8 @@ export function TerminalLayerTabBridge({ stableRef }: { stableRef: StableRef })
restoreTerminalCwd: s.restoreTerminalCwd,
identities: s.identities,
isBroadcastEnabled: s.isBroadcastEnabled,
isComposeSendToAll: s.isComposeSendToAll,
onToggleComposeSendTarget: s.onToggleComposeSendTarget,
isComposeBarOpen: s.isComposeBarOpen,
isFocusMode,
isSidePanelOpenForCurrentTab,
@@ -659,6 +661,8 @@ export function TerminalLayerTabBridge({ stableRef }: { stableRef: StableRef })
workspaceBroadcastHandlersRef: s.workspaceBroadcastHandlersRef,
workspaceById,
isGlobalBroadcastEnabled: s.isGlobalBroadcastEnabled,
isGlobalComposeSendToAll: s.isGlobalComposeSendToAll,
onToggleGlobalComposeSendTarget: s.onToggleGlobalComposeSendTarget,
canUseGlobalBroadcast: s.canUseGlobalBroadcast,
onToggleGlobalBroadcast: s.onToggleGlobalBroadcastRef.current,
// AI scope maintenance (merge/dissolve handoff) needs the full list; do not
@@ -101,6 +101,8 @@ function TerminalLayerWorkspaceSectionInner({ ctx }: { ctx: WorkspaceContext })
onSetWorkspaceFocusedSession,
onSplitSession,
isBroadcastEnabled,
isComposeSendToAll,
onToggleComposeSendTarget,
handleBroadcastInput,
handleBroadcastInterruptPriorityChange,
handleToggleWorkspaceComposeBar,
@@ -124,8 +126,10 @@ function TerminalLayerWorkspaceSectionInner({ ctx }: { ctx: WorkspaceContext })
onStartSessionDrag,
onEndSessionDrag,
isGlobalBroadcastEnabled,
isGlobalComposeSendToAll,
canUseGlobalBroadcast,
onToggleGlobalBroadcast,
onToggleGlobalComposeSendTarget,
t,
} = ctx;
@@ -247,6 +251,9 @@ function TerminalLayerWorkspaceSectionInner({ ctx }: { ctx: WorkspaceContext })
onSetWorkspaceFocusedSession={onSetWorkspaceFocusedSession}
onSplitSession={onSplitSession}
isBroadcastEnabled={isBroadcastEnabled}
isComposeSendToAll={isComposeSendToAll}
isGlobalComposeSendToAll={isGlobalComposeSendToAll}
onToggleGlobalComposeSendTarget={onToggleGlobalComposeSendTarget}
onBroadcastInput={handleBroadcastInput}
onBroadcastInterruptPriorityChange={handleBroadcastInterruptPriorityChange}
onToggleWorkspaceComposeBar={handleToggleWorkspaceComposeBar}
@@ -349,6 +356,10 @@ function TerminalLayerWorkspaceSectionInner({ ctx }: { ctx: WorkspaceContext })
refocusTerminalSession(focusedSessionId);
}}
isBroadcastEnabled={isBroadcastEnabled?.(activeWorkspace.id)}
composeSendToAll={isComposeSendToAll?.(activeWorkspace.id) ?? false}
onToggleComposeSendTarget={onToggleComposeSendTarget
? () => onToggleComposeSendTarget(activeWorkspace.id)
: undefined}
themeColors={composeBarThemeColors}
/>
)}
@@ -23,6 +23,7 @@ function setup(
sensitive = new Set<string>(),
broadcastPasswordBypass = false,
restoredDisconnected = new Set<string>(),
composeSendToAll?: boolean,
) {
const writes: string[] = [];
const context = {
@@ -34,6 +35,7 @@ function setup(
restoreState: restoredDisconnected.has(String(index)) ? 'restored-disconnected' : undefined,
})) },
isBroadcastEnabled: () => broadcast,
isComposeSendToAll: composeSendToAll === undefined ? undefined : () => composeSendToAll,
isTerminalSensitiveInputActive: (id: string) => sensitive.has(id),
broadcastPasswordBypassRef: { current: broadcastPasswordBypass },
snippetExecutorsRef: { current: executors },
@@ -60,6 +62,53 @@ for (const broadcast of [false, true]) {
}
}
test('compose active-only sends to the focused session while toolbar broadcast stays on', async () => {
const { send, writes } = setup(
['connected', 'connected'], true, new Map(), new Set(), false, new Set(), false,
);
assert.equal(await send('command'), true);
assert.deepEqual(writes, ['0']);
});
test('compose active-only does not run peer executors while toolbar broadcast stays on', async () => {
const calls: Array<{ id: string; broadcast: boolean | undefined }> = [];
const executors = new Map<string, Executor>([
['0', async (_text, _immediate, options) => {
calls.push({ id: '0', broadcast: options?.broadcast });
return true;
}],
['1', async () => { calls.push({ id: '1', broadcast: undefined }); return true; }],
]);
assert.equal(
await setup(
['connected', 'connected'], true, executors, new Set(), false, new Set(), false,
).send('command'),
true,
);
assert.deepEqual(calls, [{ id: '0', broadcast: false }]);
});
test('compose send-to-all fans out while toolbar broadcast stays off', async () => {
const { send, writes } = setup(
['connected', 'connected'], false, new Map(), new Set(), false, new Set(), true,
);
assert.equal(await send('command'), true);
assert.deepEqual(writes, ['0', '1']);
});
test('compose send-to-all keeps a bypassed sensitive peer out of history', async () => {
const executors = new Map<string, Executor>([
['0', async () => true],
['1', async () => true],
]);
assert.equal(
await setup(
['connected', 'connected'], false, executors, new Set(['1']), true, new Set(), true,
).send('secret'),
false,
);
});
test('a disconnected broadcast peer cannot undo another successful fallback', async () => {
assert.equal(await setup(['connected', 'disconnected'], true).send('command'), true);
});
@@ -169,18 +218,43 @@ for (const result of [true, false, Promise.resolve(true), Promise.resolve(false)
sessionId: 'a', sessionRef: { current: 'a' },
executeSnippetCommand: () => result,
isTerminalSensitiveInputActive: () => false,
composeSendToAll: undefined as boolean | undefined,
isBroadcastEnabled: false,
};
runInNewContext(soloCode, context);
assert.equal(await context.send('command'), await result);
});
}
test('solo compose send uses the compose-bar target while toolbar broadcast stays on', async () => {
const calls: Array<boolean | undefined> = [];
const context = {
send: undefined as unknown as (text: string) => Promise<boolean>,
sessionId: 'a', sessionRef: { current: 'a' },
executeSnippetCommand: (_text: string, _noAutoRun?: boolean, options?: { broadcast?: boolean }) => {
calls.push(options?.broadcast);
return true;
},
isTerminalSensitiveInputActive: () => false,
composeSendToAll: false,
isBroadcastEnabled: true,
};
runInNewContext(soloCode, context);
assert.equal(await context.send('command'), true);
context.composeSendToAll = true;
context.isBroadcastEnabled = false;
assert.equal(await context.send('command'), true);
assert.deepEqual(calls, [false, true]);
});
test('solo missing sessions and sensitive input are excluded; rejection propagates to the compose handler', async () => {
const context = {
send: undefined as unknown as (text: string) => Promise<boolean>,
sessionId: 'a', sessionRef: { current: null as string | null },
executeSnippetCommand: async () => true,
isTerminalSensitiveInputActive: () => false,
composeSendToAll: undefined as boolean | undefined,
isBroadcastEnabled: false,
};
runInNewContext(soloCode, context);
assert.equal(await context.send('command'), false);
@@ -414,6 +414,8 @@ const WORKSPACE_CTX_KEYS = [
'onSetWorkspaceFocusedSession',
'onSplitSession',
'isBroadcastEnabled',
'isComposeSendToAll',
'onToggleComposeSendTarget',
'handleBroadcastInput',
'handleBroadcastInterruptPriorityChange',
'handleToggleWorkspaceComposeBar',
@@ -440,8 +442,10 @@ const WORKSPACE_CTX_KEYS = [
'onStartSessionDrag',
'onEndSessionDrag',
'isGlobalBroadcastEnabled',
'isGlobalComposeSendToAll',
'canUseGlobalBroadcast',
'onToggleGlobalBroadcast',
'onToggleGlobalComposeSendTarget',
] as const;
export function terminalLayerSidePanelStableCtxEqual(prev: Ctx, next: Ctx): boolean {
@@ -477,6 +481,10 @@ export function terminalLayerViewCtxEqual(prev: Ctx, next: Ctx): boolean {
if (prev.refocusTerminalSession !== next.refocusTerminalSession) return false;
if (prev.setIsComposeBarOpen !== next.setIsComposeBarOpen) return false;
if (prev.isBroadcastEnabled !== next.isBroadcastEnabled) return false;
if (prev.isComposeSendToAll !== next.isComposeSendToAll) return false;
if (prev.onToggleComposeSendTarget !== next.onToggleComposeSendTarget) return false;
if (prev.isGlobalComposeSendToAll !== next.isGlobalComposeSendToAll) return false;
if (prev.onToggleGlobalComposeSendTarget !== next.onToggleGlobalComposeSendTarget) return false;
if (prev.composeBarThemeColors !== next.composeBarThemeColors) return false;
if (prev.workspaceOuterRef !== next.workspaceOuterRef) return false;
// Use stable side-panel equal only: LIVE fields (cwd, theme focus, etc.) flow
+4
View File
@@ -76,9 +76,13 @@ export const terminalLayerAreEqual = (
prev.onCreateLocalTerminal === next.onCreateLocalTerminal &&
prev.isBroadcastEnabled === next.isBroadcastEnabled &&
prev.onToggleBroadcast === next.onToggleBroadcast &&
prev.isComposeSendToAll === next.isComposeSendToAll &&
prev.onToggleComposeSendTarget === next.onToggleComposeSendTarget &&
prev.isGlobalBroadcastEnabled === next.isGlobalBroadcastEnabled &&
prev.isGlobalComposeSendToAll === next.isGlobalComposeSendToAll &&
prev.canUseGlobalBroadcast === next.canUseGlobalBroadcast &&
prev.onToggleGlobalBroadcast === next.onToggleGlobalBroadcast &&
prev.onToggleGlobalComposeSendTarget === next.onToggleGlobalComposeSendTarget &&
prev.updateSnippets === next.updateSnippets &&
prev.updateSnippetPackages === next.updateSnippetPackages &&
prev.toggleScriptsSidePanelRef === next.toggleScriptsSidePanelRef &&
+6 -2
View File
@@ -330,7 +330,8 @@ export const AsidePanelStack: React.FC<AsidePanelStackProps> = ({
className
)}
style={inlineStyle}
data-section={dataSection}>
data-section={dataSection}
data-native-view-occluder={layout === 'overlay' ? '' : undefined}>
<AsidePanelHeader
title={currentItem.title}
subtitle={currentItem.subtitle}
@@ -429,7 +430,10 @@ export const AsidePanel: React.FC<AsidePanelProps> = ({
className,
)}
style={panelStyle}
data-section={dataSection}>
data-section={dataSection}
// Overlay panels float above content; native views (host browser)
// must yield where they overlap instead of drawing over the panel.
data-native-view-occluder={layout === 'overlay' ? '' : undefined}>
{resizable ? (
<div
role="separator"
+3 -1
View File
@@ -1,4 +1,5 @@
import React from "react";
import { DiskIoSummary } from "../systemManager/DiskIoSummary";
import type { MonitorStats } from "../../domain/monitorRuntime";
import { ResourceBar } from "../systemManager/ResourceBar";
import { useI18n } from "../../application/i18n/I18nProvider";
@@ -15,7 +16,7 @@ export function MonitorDiskUsage({
stats,
limit,
}: {
stats?: (Pick<MonitorStats, "disks" | "diskPercent"> & Partial<Pick<MonitorStats, "diskUsed" | "diskTotal">>) | null;
stats?: (Pick<MonitorStats, "disks" | "diskPercent"> & Partial<Pick<MonitorStats, "diskUsed" | "diskTotal" | "diskReadSpeed" | "diskWriteSpeed">>) | null;
limit?: number;
}) {
const { t } = useI18n();
@@ -30,6 +31,7 @@ export function MonitorDiskUsage({
return (
<TooltipProvider delayDuration={150}>
<div className={limit === 2 ? "min-h-[38px] space-y-1.5" : "space-y-1.5"}>
<DiskIoSummary stats={stats} />
{disks.map((disk, index) => (
<Tooltip key={`${disk.mountPoint}-${index}`}>
<TooltipTrigger asChild>
+16
View File
@@ -186,3 +186,19 @@ for (const view of ["list", "charts"] as const) {
}
});
}
test("disk I/O distinguishes warming up or unsupported from idle and reports both rates", () => {
for (const [rates, expected] of [
[{}, ["Read --", "Write --"]],
[{ diskReadSpeed: null, diskWriteSpeed: null }, ["Read --", "Write --"]],
[{ diskReadSpeed: 0, diskWriteSpeed: 0 }, ["Read 0 B/s", "Write 0 B/s"]],
[{ diskReadSpeed: 1024, diskWriteSpeed: 2097152 }, ["Read 1.0 KiB/s", "Write 2.0 MiB/s"]],
] as const) {
const document = new JSDOM(renderToStaticMarkup(
<I18nProvider locale="en"><MonitorDiskUsage stats={{ disks: [], diskPercent: null, ...rates }} /></I18nProvider>,
)).window.document;
const io = document.querySelector("[data-disk-io]")!;
for (const text of expected) assert.ok(io.textContent?.includes(text));
assert.match(io.getAttribute("title")!, /Linux.*not supported on macOS/);
}
});
+1 -1
View File
@@ -624,7 +624,7 @@ export function MonitorPanels({
<SelectValue>{t("monitor.seconds", { count: panel.interval })}</SelectValue>
</SelectTrigger>
<SelectContent>
{MONITOR_INTERVALS.filter((v) => v >= 3).map((v) => (
{MONITOR_INTERVALS.map((v) => (
<SelectItem key={v} value={String(v)}>
{t("monitor.seconds", { count: v })}
</SelectItem>
+27
View File
@@ -0,0 +1,27 @@
# Saved session logs
Recording and opening a session log stay separate.
- **Start / Stop session log** controls manual recording.
- **Session log** lives in the terminal toolbar's existing **More actions**
menu, as one submenu with **Open**, **Show in folder**, **Open with…**,
and **Open with system default**. It is not a default toolbar button.
Toolbar customization can show the shortcut on the bar or hide it. When it
is shown, the arrow beside it offers the same reveal and open-with actions.
The submenu appears when this session has a saved log, and it stays available
after recording stops. A successful reconnect drops a stopped log from the
old connection, and the submenu goes with it. A failed reconnect keeps it.
Pending terminal output is flushed before opening; recording continues
normally. Automatic logs and manually recorded logs both open this way.
A successfully selected application is remembered locally for subsequent log
opens, independently of SFTP file associations. Choosing the system default
clears this preference. Text, raw, and HTML logs retain their original format.
The last log remains accessible after recording stops or a session disconnects.
After a successful reconnect, a stopped log from the old connection is not reused.
If reconnecting fails, the previous saved log remains available.
Manual recording that is still active remains the current log. If the current session has no saved log,
or the file was moved or deleted, the action reports an error rather than
opening an unrelated file. The action does not enable logging automatically.
+207
View File
@@ -5,10 +5,14 @@ import type { ConnectionLog } from "./models.ts";
import { selectConnectionLogForTerminalDataCapture } from "./connectionLog.ts";
import {
MAX_PERSISTED_UNSAVED_TERMINAL_DATA_ENTRIES,
diffTerminalDataMaps,
mergeConnectionLogsFromStorage,
mergeTerminalDataIntoLogs,
mergeTerminalDataMapsForStorage,
pruneInMemoryUnsavedTerminalData,
pruneTerminalDataMapForStorage,
rebaseQueuedConnectionLogs,
retainNewestUnsavedReplays,
} from "./connectionLogTerminalData.ts";
const baseLog: ConnectionLog = {
@@ -149,6 +153,44 @@ test("pruneTerminalDataMapForStorage caps unsaved replay buffers", () => {
assert.equal(pruned["log-0"], undefined);
});
test("pruneInMemoryUnsavedTerminalData drops oldest unsaved replay buffers beyond the persist cap", () => {
const logs: ConnectionLog[] = Array.from({ length: 60 }, (_, index) => ({
...baseLog,
id: `log-${index}`,
startTime: index,
saved: false,
terminalData: `data-${index}`,
}));
// One bookmarked entry must keep its replay buffer even when unsaved count is high.
logs.push({
...baseLog,
id: "saved-keep",
startTime: 0,
saved: true,
terminalData: "saved-data",
});
const pruned = pruneInMemoryUnsavedTerminalData(logs);
const unsavedWithData = pruned.filter((log) => !log.saved && log.terminalData !== undefined);
assert.equal(unsavedWithData.length, MAX_PERSISTED_UNSAVED_TERMINAL_DATA_ENTRIES);
assert.equal(pruned.find((log) => log.id === "log-59")?.terminalData, "data-59");
assert.equal(pruned.find((log) => log.id === "log-0")?.terminalData, undefined);
assert.equal(pruned.find((log) => log.id === "log-0")?.id, "log-0"); // metadata kept
assert.equal(pruned.find((log) => log.id === "saved-keep")?.terminalData, "saved-data");
assert.equal(pruned.length, logs.length);
});
test("pruneInMemoryUnsavedTerminalData is a no-op at or under the cap", () => {
const logs: ConnectionLog[] = Array.from({ length: 10 }, (_, index) => ({
...baseLog,
id: `log-${index}`,
startTime: index,
terminalData: `data-${index}`,
}));
const pruned = pruneInMemoryUnsavedTerminalData(logs);
assert.equal(pruned, logs);
});
test("mergeTerminalDataMapsForStorage keeps replay data from other windows", () => {
const logs: ConnectionLog[] = [
{ ...baseLog, id: "local", startTime: 2000 },
@@ -193,3 +235,168 @@ test("mergeTerminalDataMapsForStorage drops deleted log replay buffers", () => {
assert.equal(merged.local, "keep");
assert.equal(merged.deleted, undefined);
});
test("rebaseQueuedConnectionLogs keeps logs another window wrote while this snapshot waited", () => {
const queued = [{ ...baseLog, id: "local", startTime: 2000, terminalData: "local-replay" }];
const memory = queued;
const disk = [
{ ...baseLog, id: "remote", startTime: 3000 },
{ ...baseLog, id: "local", startTime: 2000 },
];
const rebased = rebaseQueuedConnectionLogs(queued, new Set(), new Set(["local"]), memory, disk);
assert.deepEqual(rebased.map((log) => log.id), ["remote", "local"]);
assert.equal(rebased.find((log) => log.id === "local")?.terminalData, "local-replay");
});
test("rebaseQueuedConnectionLogs does not restore an id deleted from memory after it was queued", () => {
const queued = [{ ...baseLog, id: "local", startTime: 2000, terminalData: "local-replay" }];
const disk = [
{ ...baseLog, id: "remote", startTime: 3000 },
{ ...baseLog, id: "local", startTime: 2000, terminalData: "stale-intermediate" },
];
const rebased = rebaseQueuedConnectionLogs(
queued,
new Set(),
new Set(["local"]),
[{ ...baseLog, id: "remote", startTime: 3000 }],
disk,
);
assert.deepEqual(rebased.map((log) => log.id), ["remote"]);
assert.equal(rebased.find((log) => log.id === "local"), undefined);
const notYetOnDisk = rebaseQueuedConnectionLogs(
queued,
new Set(),
new Set(["local"]),
[{ ...baseLog, id: "remote", startTime: 3000 }],
[{ ...baseLog, id: "remote", startTime: 3000 }],
);
assert.deepEqual(notYetOnDisk.map((log) => log.id), ["remote"]);
});
test("rebaseQueuedConnectionLogs does not restore an unchanged log another window deleted", () => {
const unchanged = { ...baseLog, id: "unchanged", startTime: 1000, terminalData: "old" };
const added = { ...baseLog, id: "local", startTime: 4000, terminalData: "local-replay" };
const queued = [added, unchanged];
const remote = [{ ...baseLog, id: "remote", startTime: 3000 }];
const rebased = rebaseQueuedConnectionLogs(
queued,
new Set(),
new Set(["local"]),
[added, ...remote],
remote,
);
assert.deepEqual(rebased.map((log) => log.id), ["local", "remote"]);
assert.equal(rebased.find((log) => log.id === "unchanged"), undefined);
assert.equal(rebased.find((log) => log.id === "local")?.terminalData, "local-replay");
});
test("rebaseQueuedConnectionLogs applies a local metadata edit onto the latest disk row", () => {
const queued = [{ ...baseLog, id: "local", startTime: 2000, saved: true, terminalData: "replay" }];
const disk = [
{ ...baseLog, id: "remote", startTime: 3000 },
{ ...baseLog, id: "local", startTime: 2000, saved: false },
];
const rebased = rebaseQueuedConnectionLogs(
queued,
new Set(),
new Set(["local"]),
queued,
disk,
);
assert.equal(rebased.find((log) => log.id === "local")?.saved, true);
assert.equal(rebased.find((log) => log.id === "local")?.terminalData, "replay");
assert.ok(rebased.some((log) => log.id === "remote"));
});
test("retainNewestUnsavedReplays drops older unsaved buffers and keeps the newest", () => {
const logs: ConnectionLog[] = [
{ ...baseLog, id: "old", startTime: 1000, terminalData: "old" },
{ ...baseLog, id: "mid", startTime: 2000, terminalData: "mid" },
{ ...baseLog, id: "new", startTime: 3000, terminalData: "new" },
{ ...baseLog, id: "saved", startTime: 500, saved: true, terminalData: "saved" },
];
const kept = retainNewestUnsavedReplays(logs, 1);
assert.equal(kept.find((log) => log.id === "new")?.terminalData, "new");
assert.equal(kept.find((log) => log.id === "saved")?.terminalData, "saved");
assert.equal(kept.find((log) => log.id === "old")?.terminalData, undefined);
assert.equal(kept.find((log) => log.id === "mid")?.terminalData, undefined);
});
test("rebaseQueuedConnectionLogs drops a queued delete and keeps a queued add still in memory", () => {
const queued = [{ ...baseLog, id: "added", startTime: 4000, terminalData: "new" }];
const memory = queued;
const disk = [
{ ...baseLog, id: "deleted", startTime: 1000 },
{ ...baseLog, id: "kept", startTime: 2000 },
];
const rebased = rebaseQueuedConnectionLogs(
queued,
new Set(["deleted"]),
new Set(["added"]),
memory,
disk,
);
assert.deepEqual(rebased.map((log) => log.id), ["added", "kept"]);
assert.equal(rebased.find((log) => log.id === "added")?.terminalData, "new");
});
test("diffTerminalDataMaps puts only changed ids and deletes removed ids", () => {
const diff = diffTerminalDataMaps(
{ same: "keep", edited: "old", removed: "gone" },
{ same: "keep", edited: "new", added: "fresh" },
);
assert.deepEqual(diff.puts, { edited: "new", added: "fresh" });
assert.deepEqual(diff.deleteIds, ["removed"]);
});
test("diffTerminalDataMaps treats key order as unchanged", () => {
const diff = diffTerminalDataMaps(
{ b: "2", a: "1" },
{ a: "1", b: "2" },
);
assert.deepEqual(diff.puts, {});
assert.deepEqual(diff.deleteIds, []);
});
test("prune cap diffs to deleting only replay buffers outside the newest 50", () => {
const logs: ConnectionLog[] = Array.from({ length: 60 }, (_, index) => ({
...baseLog,
id: `log-${index}`,
startTime: index,
saved: false,
}));
logs.push({
...baseLog,
id: "saved-keep",
startTime: 1,
saved: true,
});
const map = Object.fromEntries(logs.map((log) => [log.id, `data-${log.id}`]));
const pruned = pruneTerminalDataMapForStorage(logs, map);
const diff = diffTerminalDataMaps(map, pruned);
assert.equal(Object.keys(pruned).length, MAX_PERSISTED_UNSAVED_TERMINAL_DATA_ENTRIES + 1);
assert.equal(Object.keys(diff.puts).length, 0);
assert.equal(diff.deleteIds.length, 10);
assert.equal(diff.deleteIds.includes("log-0"), true);
assert.equal(diff.deleteIds.includes("log-9"), true);
assert.equal(diff.deleteIds.includes("log-10"), false);
assert.equal(diff.deleteIds.includes("log-59"), false);
assert.equal(diff.deleteIds.includes("saved-keep"), false);
assert.equal(pruned["saved-keep"], "data-saved-keep");
});
+124 -2
View File
@@ -26,8 +26,8 @@ export const mergeTerminalDataIntoLogs = (
};
/**
* When another window or a pruned localStorage write reloads connection logs,
* keep in-memory / side-store terminal replay data instead of wiping it.
* When another window reloads connection logs, keep in-memory / side-store
* terminal replay data instead of wiping it.
*/
export const mergeConnectionLogsFromStorage = (
prev: ConnectionLog[],
@@ -49,6 +49,81 @@ export const mergeConnectionLogsFromStorage = (
return changed ? merged : next;
};
/**
* Apply a queued connection-log snapshot when it runs, not when it was captured.
* Disk may have gained another window's logs during the wait. Keep those logs.
* Metadata from this snapshot replaces a disk row only for ids the recipe added
* or updated (`changedIds`) that are still in memory. An id deleted locally
* after this snapshot was queued is not written back. Deletes from this
* snapshot apply only while memory still agrees.
*/
export const rebaseQueuedConnectionLogs = (
queued: readonly ConnectionLog[],
removedIds: ReadonlySet<string>,
changedIds: ReadonlySet<string>,
memory: readonly ConnectionLog[],
disk: readonly ConnectionLog[] | null,
): ConnectionLog[] => {
const memoryById = new Map(memory.map((log) => [log.id, log]));
const queuedById = new Map(queued.map((log) => [log.id, log]));
const diskKept = (disk ?? []).filter(
(log) => !(removedIds.has(log.id) && !memoryById.has(log.id)),
);
const present = new Set<string>();
const withLocalEdits: ConnectionLog[] = [];
for (const log of diskKept) {
const queuedLog = queuedById.get(log.id);
const memoryLog = memoryById.get(log.id);
if (changedIds.has(log.id)) {
if (!memoryLog) continue;
present.add(log.id);
withLocalEdits.push(memoryLog);
continue;
}
present.add(log.id);
const replay = queuedLog?.terminalData ?? memoryLog?.terminalData ?? log.terminalData;
if (!replay || log.terminalData === replay) {
withLocalEdits.push(log);
continue;
}
withLocalEdits.push({ ...log, terminalData: replay });
}
const extras: ConnectionLog[] = [];
for (const log of queued) {
if (present.has(log.id) || !changedIds.has(log.id)) continue;
const memoryLog = memoryById.get(log.id);
if (!memoryLog) continue;
extras.push(memoryLog);
}
if (extras.length === 0) return withLocalEdits;
return pruneInMemoryUnsavedTerminalData(
[...extras, ...withLocalEdits].sort((a, b) => b.startTime - a.startTime),
);
};
/**
* Drop `terminalData` from unsaved logs outside the newest `keep` entries.
* Saved logs and the newest unsaved replays stay intact.
*/
export const retainNewestUnsavedReplays = (
logs: readonly ConnectionLog[],
keep: number,
): ConnectionLog[] => {
const ranked = logs
.filter((log) => !log.saved && log.terminalData)
.sort((a, b) => b.startTime - a.startTime);
if (ranked.length <= keep) return [...logs];
const keepIds = new Set(ranked.slice(0, Math.max(0, keep)).map((log) => log.id));
let changed = false;
const next = logs.map((log) => {
if (log.saved || log.terminalData === undefined || keepIds.has(log.id)) return log;
changed = true;
const { terminalData: _omitted, ...rest } = log;
return rest;
});
return changed ? next : [...logs];
};
export const buildTerminalDataMapFromLogs = (
logs: ConnectionLog[],
): ConnectionLogTerminalDataMap => {
@@ -88,6 +163,53 @@ export const pruneTerminalDataMapForStorage = (
return next;
};
/**
* Drop in-memory `terminalData` from the oldest unsaved logs beyond the
* persisted-entry cap. List metadata stays; only replay buffers are removed.
* Matches what survives a restart (`pruneTerminalDataMapForStorage`).
*/
export const pruneInMemoryUnsavedTerminalData = (
logs: ConnectionLog[],
maxEntries: number = MAX_PERSISTED_UNSAVED_TERMINAL_DATA_ENTRIES,
): ConnectionLog[] => {
const unsavedWithData = logs
.filter((log) => !log.saved && log.terminalData !== undefined)
.sort((a, b) => b.startTime - a.startTime);
if (unsavedWithData.length <= maxEntries) return logs;
const keep = new Set(unsavedWithData.slice(0, maxEntries).map((log) => log.id));
let changed = false;
const next = logs.map((log) => {
if (log.saved || log.terminalData === undefined || keep.has(log.id)) return log;
changed = true;
const { terminalData: _omitted, ...rest } = log;
return rest;
});
return changed ? next : logs;
};
export type ConnectionLogTerminalDataDiff = {
puts: ConnectionLogTerminalDataMap;
deleteIds: string[];
};
/** Changed replay blobs to write, and ids no longer in the persisted map. */
export const diffTerminalDataMaps = (
previous: ConnectionLogTerminalDataMap,
next: ConnectionLogTerminalDataMap,
): ConnectionLogTerminalDataDiff => {
const puts: ConnectionLogTerminalDataMap = {};
const deleteIds: string[] = [];
for (const id of Object.keys(next)) {
if (previous[id] !== next[id]) puts[id] = next[id];
}
for (const id of Object.keys(previous)) {
if (!Object.prototype.hasOwnProperty.call(next, id)) deleteIds.push(id);
}
return { puts, deleteIds };
};
/** Fold side-store maps together, then cap to the allowed unsaved/saved set. */
export const mergeTerminalDataMapsForStorage = (
logs: ConnectionLog[],
+31
View File
@@ -0,0 +1,31 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { HOST_BROWSER_MIN_LIVE_EXTENT, resolveHostBrowserPaneBesidePanels } from './hostBrowser';
const pane = { x: 200, y: 100, width: 500, height: 600 };
test('panels beside the pane leave it unchanged, even when the pane itself is small', () => {
assert.equal(resolveHostBrowserPaneBesidePanels(pane, [{ x: 700, y: 0, width: 300, height: 900 }]), pane);
const small = { x: 0, y: 0, width: HOST_BROWSER_MIN_LIVE_EXTENT - 1, height: 80 };
assert.equal(resolveHostBrowserPaneBesidePanels(small, []), small);
});
test('full-height or full-width edge panels shrink the pane to the remaining rectangle', () => {
assert.deepEqual(resolveHostBrowserPaneBesidePanels(pane, [{ x: 450, y: 0, width: 600, height: 900 }]),
{ x: 200, y: 100, width: 250, height: 600 });
assert.deepEqual(resolveHostBrowserPaneBesidePanels(pane, [{ x: 0, y: 0, width: 300, height: 900 }]),
{ x: 300, y: 100, width: 400, height: 600 });
assert.deepEqual(resolveHostBrowserPaneBesidePanels(pane, [{ x: 0, y: 500, width: 900, height: 400 }]),
{ x: 200, y: 100, width: 500, height: 400 });
assert.deepEqual(resolveHostBrowserPaneBesidePanels(pane, [
{ x: 0, y: 0, width: 300, height: 900 },
{ x: 550, y: 0, width: 400, height: 900 },
]), { x: 300, y: 100, width: 250, height: 600 });
});
test('panels over the middle, a corner, or leaving too little room cannot keep a live page', () => {
assert.equal(resolveHostBrowserPaneBesidePanels(pane, [{ x: 300, y: 0, width: 100, height: 900 }]), null);
assert.equal(resolveHostBrowserPaneBesidePanels(pane, [{ x: 600, y: 0, width: 300, height: 300 }]), null);
assert.equal(resolveHostBrowserPaneBesidePanels(pane, [{ x: 0, y: 0, width: 900, height: 900 }]), null);
assert.equal(resolveHostBrowserPaneBesidePanels(pane, [{ x: 250, y: 0, width: 600, height: 900 }]), null);
});
+36
View File
@@ -63,3 +63,39 @@ export type HostBrowserPanelEvent = { browserId: string } & (
| { type: 'bookmarks' }
| { type: 'shortcut'; shortcut: HostBrowserShortcut }
);
export interface HostBrowserPaneRect { x: number; y: number; width: number; height: number }
/** Below this extent a page beside an overlay panel is too cramped to keep live. */
export const HOST_BROWSER_MIN_LIVE_EXTENT = 160;
/**
* Native browser views draw above the renderer and cannot be clipped, so a
* panel covering a full-height or full-width edge strip of the pane is avoided
* by shrinking the page to the remaining rectangle. Returns null when a panel
* covers the middle or leaves too little room; the pane then shows a picture.
*/
export function resolveHostBrowserPaneBesidePanels(
pane: HostBrowserPaneRect,
panels: readonly HostBrowserPaneRect[],
): HostBrowserPaneRect | null {
let left = pane.x;
let top = pane.y;
let right = pane.x + pane.width;
let bottom = pane.y + pane.height;
for (const panel of panels) {
const panelRight = panel.x + panel.width;
const panelBottom = panel.y + panel.height;
if (panel.x >= right || panelRight <= left || panel.y >= bottom || panelBottom <= top) continue;
const spansHeight = panel.y <= top && panelBottom >= bottom;
const spansWidth = panel.x <= left && panelRight >= right;
if (spansHeight && panel.x > left && panelRight >= right) right = panel.x;
else if (spansHeight && panel.x <= left && panelRight < right) left = panelRight;
else if (spansWidth && panel.y > top && panelBottom >= bottom) bottom = panel.y;
else if (spansWidth && panel.y <= top && panelBottom < bottom) top = panelBottom;
else return null;
}
if (left === pane.x && top === pane.y && right === pane.x + pane.width && bottom === pane.y + pane.height) return pane;
if (right - left < HOST_BROWSER_MIN_LIVE_EXTENT || bottom - top < HOST_BROWSER_MIN_LIVE_EXTENT) return null;
return { x: left, y: top, width: right - left, height: bottom - top };
}
+69
View File
@@ -0,0 +1,69 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { getTerminalPassthroughActions } from '../application/state/useGlobalHotkeys.ts';
import en from '../application/i18n/locales/en.ts';
import zhCN from '../application/i18n/locales/zh-CN.ts';
import zhTW from '../application/i18n/locales/zh-TW.ts';
import { findActiveSystemShortcutConflict } from './activeKeyBindings.ts';
import { DEFAULT_KEY_BINDINGS, matchesKeyBinding } from './models/keyBindings.ts';
test('host list toggle defaults to Ctrl/Cmd+Shift+B and leaves Ctrl/Cmd+B on broadcast', () => {
const binding = DEFAULT_KEY_BINDINGS.find((entry) => entry.id === 'toggle-host-tree');
assert.equal(binding?.action, 'toggleHostTree');
assert.equal(binding?.label, 'Toggle Host List');
assert.equal(binding?.category, 'app');
assert.equal(binding?.pc, 'Ctrl + Shift + B');
assert.equal(binding?.mac, '⌘ + Shift + B');
assert.equal(
findActiveSystemShortcutConflict('Ctrl + B', 'pc', DEFAULT_KEY_BINDINGS)?.id,
'broadcast',
);
assert.equal(
findActiveSystemShortcutConflict('⌘ + B', 'mac', DEFAULT_KEY_BINDINGS)?.id,
'broadcast',
);
assert.equal(
findActiveSystemShortcutConflict('Ctrl + Shift + B', 'pc', DEFAULT_KEY_BINDINGS)?.id,
'toggle-host-tree',
);
assert.equal(
findActiveSystemShortcutConflict('⌘ + Shift + B', 'mac', DEFAULT_KEY_BINDINGS)?.id,
'toggle-host-tree',
);
assert.deepEqual(
DEFAULT_KEY_BINDINGS.filter((entry) => entry.pc === 'Ctrl + Shift + B').map((entry) => entry.id),
['toggle-host-tree'],
);
assert.deepEqual(
DEFAULT_KEY_BINDINGS.filter((entry) => entry.mac === '⌘ + Shift + B').map((entry) => entry.id),
['toggle-host-tree'],
);
assert.equal(getTerminalPassthroughActions().has('toggleHostTree'), false);
assert.equal(matchesKeyBinding({
key: 'B',
code: 'KeyB',
ctrlKey: true,
metaKey: false,
altKey: false,
shiftKey: true,
} as KeyboardEvent, binding?.pc ?? '', false), true);
assert.equal(matchesKeyBinding({
key: 'b',
code: 'KeyB',
ctrlKey: true,
metaKey: false,
altKey: false,
shiftKey: false,
} as KeyboardEvent, binding?.pc ?? '', false), false);
});
test('host list shortcut label is localized in English and Chinese', () => {
assert.equal(en['settings.shortcuts.binding.toggle-host-tree'], 'Toggle Host List');
assert.equal(zhCN['settings.shortcuts.binding.toggle-host-tree'], '切换主机列表');
assert.equal(zhTW['settings.shortcuts.binding.toggle-host-tree'], '切換主機列表');
});
+1
View File
@@ -273,6 +273,7 @@ export const DEFAULT_KEY_BINDINGS: KeyBinding[] = [
{ id: 'snippets', action: 'snippets', label: 'Open Snippets', mac: '⌘ + Shift + S', pc: 'Ctrl + Shift + S', category: 'app' },
{ id: 'broadcast', action: 'broadcast', label: 'Switch the Broadcast Mode', mac: '⌘ + B', pc: 'Ctrl + B', category: 'app' },
{ id: 'toggle-side-panel', action: 'toggleSidePanel', label: 'Toggle Side Panel', mac: '⌘ + \\', pc: 'Ctrl + \\', category: 'app' },
{ id: 'toggle-host-tree', action: 'toggleHostTree', label: 'Toggle Host List', mac: '⌘ + Shift + B', pc: 'Ctrl + Shift + B', category: 'app' },
{ id: 'open-settings', action: 'openSettings', label: 'Open Settings', mac: '⌘ + ,', pc: 'Ctrl + ,', category: 'app' },
{ id: 'toggle-opacity', action: 'toggleOpacity', label: 'Toggle Window Transparency', mac: '⌘ + U', pc: 'Ctrl + Shift + U', category: 'app' },
+45
View File
@@ -2,6 +2,8 @@ import assert from 'node:assert/strict';
import test from 'node:test';
import {
composeSendTargetAfterToolbarToggle,
flipComposeSendTarget,
resolveTerminalBroadcastTargetIds,
shouldBroadcastDuringSensitivePrompt,
} from './terminalBroadcast.ts';
@@ -93,6 +95,49 @@ test('missing or hidden global sources do not fan out', () => {
);
});
test('compose send target follows toolbar broadcast, then flips on its own', () => {
assert.equal(composeSendTargetAfterToolbarToggle(true), 'all');
assert.equal(composeSendTargetAfterToolbarToggle(false), 'active');
assert.equal(flipComposeSendTarget('all'), 'active');
assert.equal(flipComposeSendTarget('active'), 'all');
// An independent flip does not rewrite the toolbar-derived default.
assert.equal(composeSendTargetAfterToolbarToggle(true), 'all');
});
test('compose peer fan-out can reach orphan peers while toolbar broadcast is off', () => {
const sessions = [
session('source'),
session('peer'),
session('hidden-orphan', { hiddenFromTabs: true }),
];
assert.deepEqual(
resolveTerminalBroadcastTargetIds({
sessions,
sourceSessionId: 'source',
globalBroadcastEnabled: false,
includeGlobalPeersWithoutToolbar: true,
}),
['peer'],
);
assert.deepEqual(
resolveTerminalBroadcastTargetIds({
sessions,
sourceSessionId: 'source',
globalBroadcastEnabled: false,
}),
[],
);
assert.deepEqual(
resolveTerminalBroadcastTargetIds({
sessions: [session('hidden-source', { hiddenFromTabs: true }), session('peer')],
sourceSessionId: 'hidden-source',
globalBroadcastEnabled: false,
includeGlobalPeersWithoutToolbar: true,
}),
[],
);
});
test('sensitive prompts pause broadcasting unless the password bypass is on', () => {
assert.equal(
shouldBroadcastDuringSensitivePrompt({
+23 -1
View File
@@ -15,16 +15,37 @@ export function shouldBroadcastDuringSensitivePrompt(input: {
return !input.sensitivePromptActive || input.broadcastPasswordBypass;
}
export type ComposeSendTarget = 'all' | 'active';
/**
* Toolbar broadcast is the keystroke fan-out switch. Turning it on or off
* resets the compose-bar send target to match. A later compose-bar flip does
* not move the toolbar switch.
*/
export function composeSendTargetAfterToolbarToggle(toolbarEnabled: boolean): ComposeSendTarget {
return toolbarEnabled ? 'all' : 'active';
}
export function flipComposeSendTarget(target: ComposeSendTarget): ComposeSendTarget {
return target === 'all' ? 'active' : 'all';
}
export function resolveTerminalBroadcastTargetIds({
sessions,
sourceSessionId,
globalBroadcastEnabled,
directTargetSessionIds,
includeGlobalPeersWithoutToolbar = false,
}: {
sessions: readonly BroadcastSession[];
sourceSessionId: string;
globalBroadcastEnabled: boolean;
directTargetSessionIds?: readonly string[];
/**
* Compose-bar "send to all" can target orphan peers while the toolbar
* (global) broadcast switch stays off. Keystroke fan-out leaves this false.
*/
includeGlobalPeersWithoutToolbar?: boolean;
}): string[] {
if (directTargetSessionIds) {
const directTargetIds = new Set(directTargetSessionIds);
@@ -45,7 +66,8 @@ export function resolveTerminalBroadcastTargetIds({
.map((session) => session.id);
}
if (!globalBroadcastEnabled || sourceSession.hiddenFromTabs) return [];
if (sourceSession.hiddenFromTabs) return [];
if (!globalBroadcastEnabled && !includeGlobalPeersWithoutToolbar) return [];
return sessions
.filter((session) => (
Loaded 100 of 152 files, more files were not shown because too many files have changed in this diff. Show more