Compare commits

...
Author SHA1 Message Date
陈大猫 9a77a090b0 feat(monitor): share Linux disk I/O rates and expose one-second updates 2026-10-23 10:31:32 +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
94 changed files with 4502 additions and 451 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" />
+5 -2
View File
@@ -1184,7 +1184,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 +1200,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,
});
+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*\}/,
+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',
+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',
+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,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)
+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,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)
+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 () => {
+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\)\)/,
);
});
+319 -133
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,
@@ -65,9 +66,17 @@ import {
mergeTerminalDataIntoLogs,
mergeTerminalDataMapsForStorage,
pruneInMemoryUnsavedTerminalData,
writeTerminalDataMapWithQuotaRetry,
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,
@@ -229,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
@@ -257,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));
@@ -278,45 +291,6 @@ const buildPersistedLogIds = (
return ids;
};
/**
* Same result as comparing `JSON.stringify` of both maps (own keys in the
* same order, equal values) without serializing the multi-MB replay data.
*/
const isSameTerminalDataMap = (
a: ConnectionLogTerminalDataMap,
b: ConnectionLogTerminalDataMap,
): boolean => {
if (typeof a !== "object" || a === null || Array.isArray(a)) {
return JSON.stringify(a) === JSON.stringify(b);
}
const aKeys = Object.keys(a);
const bKeys = Object.keys(b);
if (aKeys.length !== bKeys.length) return false;
for (let i = 0; i < aKeys.length; i += 1) {
if (aKeys[i] !== bKeys[i] || a[aKeys[i]] !== b[bKeys[i]]) return false;
}
return true;
};
const writeConnectionLogTerminalDataMap = (
existing: ConnectionLogTerminalDataMap,
pruned: ConnectionLogTerminalDataMap,
logs: ConnectionLog[],
quotaFailedCharsRef: { current: number | null },
): boolean => {
if (isSameTerminalDataMap(existing, pruned)) return true;
// On quota failure keep the newest replay buffers instead of none.
// The hint stays on the hook so the domain helper remains a pure calculation.
const result = writeTerminalDataMapWithQuotaRetry(
logs,
pruned,
(next) => localStorageAdapter.write(STORAGE_KEY_CONNECTION_LOG_TERMINAL_DATA, next),
quotaFailedCharsRef.current,
);
quotaFailedCharsRef.current = result.quotaFailedChars;
return result.persisted;
};
export type UseVaultStateOptions = {
/**
* When false, skip loading and cross-window sync of the bulky
@@ -454,42 +428,92 @@ export const useVaultState = (options?: UseVaultStateOptions) => {
const identitiesReadSeq = useRef(0);
const proxyProfilesReadSeq = useRef(0);
const groupConfigsReadSeq = useRef(0);
const connectionLogsRef = useRef<ConnectionLog[]>([]);
const connectionLogTerminalDataRef = useRef<ConnectionLogTerminalDataMap>({});
const connectionLogQuotaFailedCharsRef = useRef<number | null>(null);
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(
existing,
merged,
logs,
connectionLogQuotaFailedCharsRef,
);
// On a failed write (e.g. QuotaExceeded once the side store nears ~50 MB)
// keep the same capped map instead of accumulating every log's replay
// buffer in memory; anything outside it would be pruned on the next write.
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;
return { map: connectionLogTerminalDataRef.current, persisted };
}, []);
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,
@@ -506,13 +530,43 @@ export const useVaultState = (options?: UseVaultStateOptions) => {
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) {
@@ -524,8 +578,40 @@ export const useVaultState = (options?: UseVaultStateOptions) => {
"[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[],
@@ -1326,67 +1412,56 @@ export const useVaultState = (options?: UseVaultStateOptions) => {
...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 = pruneInMemoryUnsavedTerminalData(
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 = pruneInMemoryUnsavedTerminalData(
return commitConnectionLogs((prev) => (
pruneInMemoryUnsavedTerminalData(
prev.map((log) => (log.id === id ? { ...log, ...updates } : log)),
);
persistConnectionLogState(updated, { pruneMainBlob: true });
return updated;
});
)
), { pruneMainBlob: true });
},
[persistConnectionLogState],
[commitConnectionLogs],
);
const toggleConnectionLogSaved = useCallback((id: string) => {
setConnectionLogs((prev) => {
const updated = pruneInMemoryUnsavedTerminalData(
void commitConnectionLogs((prev) => (
pruneInMemoryUnsavedTerminalData(
prev.map((log) =>
log.id === id ? { ...log, saved: !log.saved } : log
),
);
persistConnectionLogState(updated, { pruneMainBlob: false });
return updated;
});
}, [persistConnectionLogState]);
)
), { 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(() => {
@@ -1453,6 +1528,11 @@ export const useVaultState = (options?: UseVaultStateOptions) => {
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 () => {
@@ -1662,18 +1742,91 @@ export const useVaultState = (options?: UseVaultStateOptions) => {
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,
);
// Windows that never render connection-log replay skip the ~30–50 MB
// side-store parse on boot (Settings / tray / popup).
const terminalDataMap = syncConnectionLogTerminalData
? 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
@@ -1702,6 +1855,7 @@ export const useVaultState = (options?: UseVaultStateOptions) => {
}
}
} 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) {
@@ -1714,8 +1868,15 @@ export const useVaultState = (options?: UseVaultStateOptions) => {
void init();
return () => {
cancelled = true;
resolveTerminalDataReady();
};
}, [commitEncryptedHostsUnderVaultLock, syncConnectionLogTerminalData, updateHosts, updateSnippets]);
}, [
bumpConnectionLogTerminalDataRev,
commitEncryptedHostsUnderVaultLock,
syncConnectionLogTerminalData,
updateHosts,
updateSnippets,
]);
useEffect(() => {
if (!isInitialized) return;
@@ -1917,19 +2078,18 @@ export const useVaultState = (options?: UseVaultStateOptions) => {
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) {
// Other windows still receive the storage event; skip the multi-MB
// JSON.parse when this mount does not show connection-log replay.
if (!syncConnectionLogTerminalData) return;
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;
}
@@ -1957,20 +2117,46 @@ export const useVaultState = (options?: UseVaultStateOptions) => {
}
};
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) {
if (!syncConnectionLogTerminalData) return;
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,
));
}
};
@@ -1980,7 +2166,7 @@ export const useVaultState = (options?: UseVaultStateOptions) => {
window.removeEventListener("storage", handleStorage);
window.removeEventListener(LOCAL_STORAGE_ADAPTER_CHANGED_EVENT, handleLocalStorageAdapterChanged);
};
}, [applyConnectionLogsFromStorage, syncConnectionLogTerminalData]);
}, [applyConnectionLogsFromStorage, enqueueTerminalDataTask, syncConnectionLogTerminalData]);
const updateHostLastConnected = useCallback((hostId: string) => {
setHosts((prev) => {
+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, 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,
@@ -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>
)}
+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();
}
}}
+10
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,
+7 -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'
@@ -1328,6 +1328,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 +1356,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/);
});
@@ -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
+19 -6
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);
@@ -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 &&
+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.
+160 -122
View File
@@ -5,12 +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,
writeTerminalDataMapWithQuotaRetry,
rebaseQueuedConnectionLogs,
retainNewestUnsavedReplays,
} from "./connectionLogTerminalData.ts";
const baseLog: ConnectionLog = {
@@ -234,131 +236,167 @@ test("mergeTerminalDataMapsForStorage drops deleted log replay buffers", () => {
assert.equal(merged.deleted, undefined);
});
const quotaLogs = (count: number): ConnectionLog[] =>
Array.from({ length: count }, (_, index) => ({
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);
const quotaWriter = (limitChars: number) => {
const calls: number[] = [];
const write = (next: Record<string, string>) => {
const size = Object.values(next).reduce((sum, value) => sum + value.length, 0);
calls.push(Object.keys(next).length);
return size <= limitChars;
};
return { calls, write };
};
test("writeTerminalDataMapWithQuotaRetry writes once when it fits", () => {
const logs = quotaLogs(5);
const map = Object.fromEntries(logs.map((log) => [log.id, "x".repeat(10)]));
const { calls, write } = quotaWriter(1000);
const result = writeTerminalDataMapWithQuotaRetry(logs, map, write, null);
assert.equal(result.persisted, true);
assert.equal(result.written, map);
assert.deepEqual(calls, [5]);
});
test("writeTerminalDataMapWithQuotaRetry drops the oldest unsaved entries until it fits", () => {
const logs = quotaLogs(50);
// Newest first, matching pruneTerminalDataMapForStorage order.
const map = Object.fromEntries(
[...logs].reverse().map((log) => [log.id, "x".repeat(10)]),
);
const { calls, write } = quotaWriter(10 * 47);
const result = writeTerminalDataMapWithQuotaRetry(logs, map, write, null);
assert.equal(result.persisted, true);
// 50 fails, drop 1 → 49 fails, drop 2 → 48 fails, drop 4 → 46 fits.
assert.deepEqual(calls, [50, 49, 48, 46]);
assert.equal(result.written["log-49"], map["log-49"]);
assert.equal(result.written["log-4"], map["log-4"]);
for (const id of ["log-0", "log-1", "log-2", "log-3"]) {
assert.equal(result.written[id], undefined);
}
// Key order (newest first) is preserved.
assert.deepEqual(Object.keys(result.written), Object.keys(map).filter((id) => id in result.written));
});
test("writeTerminalDataMapWithQuotaRetry never drops saved or unknown entries and stops after all unsaved are gone", () => {
const logs: ConnectionLog[] = [
...quotaLogs(20),
{ ...baseLog, id: "saved", startTime: -1, saved: true },
];
const map: Record<string, string> = Object.fromEntries(
logs.map((log) => [log.id, "x".repeat(10)]),
);
map["other-window"] = "y".repeat(10);
const { calls, write } = quotaWriter(5); // nothing fits
const result = writeTerminalDataMapWithQuotaRetry(logs, map, write, null);
assert.equal(result.persisted, false);
assert.equal(result.written, map);
// 22, then drop 1, 2, 4, 8, 16, then all 20 unsaved: bounded attempts.
assert.deepEqual(calls, [22, 21, 20, 18, 14, 6, 2]);
assert.ok(result.attempts <= 2 + Math.ceil(Math.log2(20)));
});
const newestFirstMap = (logs: ConnectionLog[], chars: number) =>
Object.fromEntries([...logs].reverse().map((log) => [log.id, "x".repeat(chars)]));
test("writeTerminalDataMapWithQuotaRetry starts the next write from the newest set that last fit", () => {
const logs = quotaLogs(50);
const quota = quotaWriter(10 * 47);
const first = writeTerminalDataMapWithQuotaRetry(logs, newestFirstMap(logs, 10), quota.write, null);
// Next close: oldest drops out of the 50, a newer session arrives.
const nextLogs = [...logs.slice(1), { ...baseLog, id: "log-50", startTime: 50 }];
const second = quotaWriter(10 * 47);
const result = writeTerminalDataMapWithQuotaRetry(
nextLogs,
newestFirstMap(nextLogs, 10),
second.write,
first.quotaFailedChars,
);
assert.equal(result.persisted, true);
assert.deepEqual(second.calls, [47]);
assert.equal(result.written["log-50"] !== undefined, true);
assert.equal(result.written["log-3"], undefined);
});
test("writeTerminalDataMapWithQuotaRetry grows back when entries get smaller", () => {
const logs = quotaLogs(50);
const first = writeTerminalDataMapWithQuotaRetry(
logs,
newestFirstMap(logs, 10),
quotaWriter(10 * 47).write,
null,
);
const small = quotaWriter(10 * 47);
const result = writeTerminalDataMapWithQuotaRetry(
logs,
newestFirstMap(logs, 5),
small.write,
first.quotaFailedChars,
);
assert.deepEqual(small.calls, [50]);
assert.equal(Object.keys(result.written).length, 50);
});
test("writeTerminalDataMapWithQuotaRetry falls back to dropping when the hint is too optimistic", () => {
const logs = quotaLogs(50);
const first = writeTerminalDataMapWithQuotaRetry(
logs,
newestFirstMap(logs, 10),
quotaWriter(10 * 47).write,
null,
);
const tighter = quotaWriter(10 * 30); // quota shrank (other keys grew)
const result = writeTerminalDataMapWithQuotaRetry(
logs,
newestFirstMap(logs, 10),
tighter.write,
first.quotaFailedChars,
);
assert.equal(result.persisted, true);
assert.equal(tighter.calls[0], 47);
assert.ok(Object.keys(result.written).length <= 30);
assert.equal(result.written["log-49"] !== undefined, true);
assert.ok(result.attempts <= 2 + Math.ceil(Math.log2(50)));
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");
});
+93 -55
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 => {
@@ -114,62 +189,25 @@ export const pruneInMemoryUnsavedTerminalData = (
return changed ? next : logs;
};
/**
* Write the side store; if the write is rejected (localStorage quota), retry
* without the oldest unsaved replay buffers so the newest sessions are kept.
* Drops 1, 2, 4, … oldest entries per retry, so attempts stay O(log n).
* Saved logs and ids not in `logs` are never dropped.
*
* `quotaFailedChars` is owned by the caller. It is the summed entry length of
* the smallest payload that last hit the quota, so the next write can start
* from the newest set that fits. A successful write of the full map returns
* null. The helper does not keep that hint itself.
*/
export const writeTerminalDataMapWithQuotaRetry = (
logs: ConnectionLog[],
map: ConnectionLogTerminalDataMap,
write: (next: ConnectionLogTerminalDataMap) => boolean,
quotaFailedChars: number | null,
): { persisted: boolean; written: ConnectionLogTerminalDataMap; attempts: number; quotaFailedChars: number | null } => {
const droppable = logs
.filter((log) => !log.saved && map[log.id] !== undefined)
.sort((a, b) => a.startTime - b.startTime)
.map((log) => log.id);
let chars = 0;
for (const data of Object.values(map)) chars += data.length;
export type ConnectionLogTerminalDataDiff = {
puts: ConnectionLogTerminalDataMap;
deleteIds: string[];
};
let hint = quotaFailedChars;
let dropCount = 0;
if (hint !== null) {
while (dropCount < droppable.length && chars >= hint) {
chars -= map[droppable[dropCount]].length;
dropCount += 1;
}
/** 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];
}
let attempts = 0;
for (;;) {
let next = map;
if (dropCount > 0) {
const dropped = new Set(droppable.slice(0, dropCount));
next = {};
for (const [id, data] of Object.entries(map)) {
if (!dropped.has(id)) next[id] = data;
}
}
attempts += 1;
if (write(next)) {
if (dropCount === 0) hint = null;
return { persisted: true, written: next, attempts, quotaFailedChars: hint };
}
hint = hint === null ? chars : Math.min(hint, chars);
if (dropCount >= droppable.length) {
return { persisted: false, written: map, attempts, quotaFailedChars: hint };
}
const target = Math.min(Math.max(dropCount * 2, 1), droppable.length);
for (let i = dropCount; i < target; i += 1) chars -= map[droppable[i]].length;
dropCount = target;
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. */
+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) => (
+109 -12
View File
@@ -1,17 +1,30 @@
"use strict";
// An abandoned OPEN cannot be cancelled in SSH, but its physical connection
// may own unrelated terminals. Block only additional opens until its callback
// or transport closure settles; retain no unbounded retry queue.
// may own unrelated terminals. Hold further opens until the late callback,
// transport closure, or a short abandon wait. If the callback never arrives,
// force-clear the gate and admit a new open; the late channel is still closed
// when its callback finally fires. Do not queue unbounded SSH channel requests
// during the wait.
const abandonedOpens = new WeakMap();
function wakeAbandonedWaiters(state) {
const waiters = state?.waiters;
if (!waiters || waiters.size === 0) return;
state.waiters = new Set();
for (const wake of waiters) {
try { wake(); } catch { /* ignore */ }
}
}
function abandonOpen(client, token) {
let state = abandonedOpens.get(client);
if (!state) {
state = { tokens: new Set(), onClose: null };
state = { tokens: new Set(), onClose: null, waiters: new Set() };
state.onClose = () => {
if (abandonedOpens.get(client) === state) abandonedOpens.delete(client);
state.tokens.clear();
wakeAbandonedWaiters(state);
};
abandonedOpens.set(client, state);
client.once?.("close", state.onClose);
@@ -24,9 +37,29 @@ function settleAbandonedOpen(client, token) {
if (!state || !state.tokens.delete(token) || state.tokens.size > 0) return;
abandonedOpens.delete(client);
client.removeListener?.("close", state.onClose);
wakeAbandonedWaiters(state);
}
function forceClearAbandonedOpens(client) {
const state = abandonedOpens.get(client);
if (!state) return;
abandonedOpens.delete(client);
state.tokens.clear();
try { client.removeListener?.("close", state.onClose); } catch { /* ignore */ }
wakeAbandonedWaiters(state);
}
const DEFAULT_SFTP_CHANNEL_OPEN_TIMEOUT_MS = 10_000;
const DEFAULT_SFTP_CHANNEL_ABANDON_WAIT_MS = 2_000;
function resolveAbandonWaitMs(value) {
if (value === undefined || value === null || value === "") {
return DEFAULT_SFTP_CHANNEL_ABANDON_WAIT_MS;
}
const parsed = Number(value);
if (!Number.isFinite(parsed)) return DEFAULT_SFTP_CHANNEL_ABANDON_WAIT_MS;
return Math.max(0, parsed);
}
function closeSftpChannel(channel) {
if (!channel) return;
@@ -43,17 +76,66 @@ function createSftpOpenAbortError(signal) {
return error;
}
function openBoundedSftpChannel(sshClient, options = {}) {
if (!sshClient || typeof sshClient.sftp !== "function") {
return Promise.resolve(null);
}
function createPendingOpenError() {
const error = new Error("A previous SFTP channel open is still pending on this connection");
error.code = "SFTP_CHANNEL_OPEN_PENDING";
return error;
}
function waitForAbandonedOpenAdmission(sshClient, options) {
const state = abandonedOpens.get(sshClient);
if (!state) return Promise.resolve();
const abandonWaitMs = resolveAbandonWaitMs(options.abandonWaitMs);
// A zero wait keeps the historical immediate rejection for callers that must
// not block, and still lets upload fallback treat PENDING as a poisoned open.
if (abandonWaitMs === 0) return Promise.reject(createPendingOpenError());
const signal = options.signal || null;
if (signal?.aborted) return Promise.reject(createSftpOpenAbortError(signal));
if (abandonedOpens.has(sshClient)) {
const error = new Error("A previous SFTP channel open is still pending on this connection");
error.code = "SFTP_CHANNEL_OPEN_PENDING";
return Promise.reject(error);
}
const setTimeoutFn = options.setTimeoutFn || setTimeout;
const clearTimeoutFn = options.clearTimeoutFn || clearTimeout;
return new Promise((resolve, reject) => {
let settled = false;
let timer = null;
const cleanup = () => {
state.waiters?.delete(onGateCleared);
if (timer) clearTimeoutFn(timer);
timer = null;
signal?.removeEventListener?.("abort", onAbort);
};
const finish = (error) => {
if (settled) return;
settled = true;
cleanup();
if (error) reject(error);
else resolve();
};
const onGateCleared = () => finish();
const onAbort = () => finish(createSftpOpenAbortError(signal));
if (!state.waiters) state.waiters = new Set();
state.waiters.add(onGateCleared);
if (abandonedOpens.get(sshClient) !== state) {
finish();
return;
}
signal?.addEventListener?.("abort", onAbort, { once: true });
if (signal?.aborted) {
finish(createSftpOpenAbortError(signal));
return;
}
// Referenced on purpose: an unreferenced timer can let Node exit while
// this admission promise is still pending.
timer = setTimeoutFn(() => {
if (abandonedOpens.get(sshClient) === state) forceClearAbandonedOpens(sshClient);
finish();
}, abandonWaitMs);
});
}
function startBoundedSftpOpen(sshClient, options) {
const signal = options.signal || null;
const setTimeoutFn = options.setTimeoutFn || setTimeout;
const clearTimeoutFn = options.clearTimeoutFn || clearTimeout;
const timeoutMs = Math.max(
@@ -117,7 +199,22 @@ function openBoundedSftpChannel(sshClient, options = {}) {
});
}
function openBoundedSftpChannel(sshClient, options = {}) {
if (!sshClient || typeof sshClient.sftp !== "function") {
return Promise.resolve(null);
}
const signal = options.signal || null;
if (signal?.aborted) return Promise.reject(createSftpOpenAbortError(signal));
if (!abandonedOpens.has(sshClient)) {
return startBoundedSftpOpen(sshClient, options);
}
return waitForAbandonedOpenAdmission(sshClient, options).then(() => (
openBoundedSftpChannel(sshClient, options)
));
}
module.exports = {
DEFAULT_SFTP_CHANNEL_ABANDON_WAIT_MS,
DEFAULT_SFTP_CHANNEL_OPEN_TIMEOUT_MS,
closeSftpChannel,
openBoundedSftpChannel,
+170 -4
View File
@@ -3,7 +3,10 @@
const test = require("node:test");
const assert = require("node:assert/strict");
const { EventEmitter, getEventListeners } = require("node:events");
const { openBoundedSftpChannel } = require("./boundedSftpOpen.cjs");
const {
DEFAULT_SFTP_CHANNEL_ABANDON_WAIT_MS,
openBoundedSftpChannel,
} = require("./boundedSftpOpen.cjs");
function trackedTimerApi() {
const active = new Set();
@@ -94,9 +97,12 @@ for (const reason of ["timeout", "cancel"]) {
if (reason === "cancel") controller.abort(new Error("cancelled"));
await assert.rejects(opening);
for (let attempt = 0; attempt < 3; attempt++) {
await assert.rejects(openBoundedSftpChannel(sshClient, { timeoutMs: 5 }));
await assert.rejects(
openBoundedSftpChannel(sshClient, { timeoutMs: 5, abandonWaitMs: 0 }),
(error) => error.code === "SFTP_CHANNEL_OPEN_PENDING",
);
}
assert.equal(callbacks.length, 1, "retries must not allocate more abandoned SSH channel requests");
assert.equal(callbacks.length, 1, "a zero abandon wait must not allocate more SSH channel requests");
assert.equal(physicalCloses, 0, "existing terminal and SFTP channels remain connected");
const late = createChannel();
@@ -174,10 +180,170 @@ test("all abandoned parallel opens must settle before admitting a new one", asyn
await Promise.all([assert.rejects(first), assert.rejects(second)]);
assert.equal(sshClient.listenerCount("close"), 1);
callbacks[0](new Error("first failed"));
await assert.rejects(openBoundedSftpChannel(sshClient), error => error.code === "SFTP_CHANNEL_OPEN_PENDING");
await assert.rejects(
openBoundedSftpChannel(sshClient, { abandonWaitMs: 0 }),
(error) => error.code === "SFTP_CHANNEL_OPEN_PENDING",
);
callbacks[1](new Error("second failed"));
assert.equal(sshClient.listenerCount("close"), 0);
const next = openBoundedSftpChannel(sshClient);
callbacks[2](null, createChannel());
await next;
});
function delay(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
function settleOpen(promise) {
return promise.then((channel) => ({ channel }), (error) => ({ error }));
}
test("abandon wait defaults to 2s", () => {
assert.equal(DEFAULT_SFTP_CHANNEL_ABANDON_WAIT_MS, 2_000);
});
test("omitted abandon wait does not admit a new open immediately", async () => {
const callbacks = [];
const sshClient = new EventEmitter();
sshClient.sftp = (callback) => { callbacks.push(callback); };
await assert.rejects(
openBoundedSftpChannel(sshClient, { timeoutMs: 5 }),
(error) => error.code === "SFTP_CHANNEL_OPEN_TIMEOUT",
);
const outcome = settleOpen(openBoundedSftpChannel(sshClient, { timeoutMs: 50 }));
await delay(40);
assert.equal(callbacks.length, 1, "the default abandon wait must still be holding");
const late = createChannel();
callbacks[0](null, late);
assert.ok(late.endCalls > 0 || late.closeCalls > 0);
await delay(20);
assert.equal(callbacks.length, 2, "a late callback must release the next open");
const healthy = createChannel();
callbacks[1](null, healthy);
const settled = await outcome;
assert.equal(settled.channel, healthy);
assert.equal(settled.error, undefined);
});
test("abandon wait admits a new open when the late callback never arrives", async () => {
const callbacks = [];
const sshClient = new EventEmitter();
sshClient.sftp = (callback) => {
callbacks.push(callback);
if (callbacks.length > 1) callback(null, createChannel());
};
await assert.rejects(
openBoundedSftpChannel(sshClient, { timeoutMs: 10, abandonWaitMs: 20 }),
(error) => error.code === "SFTP_CHANNEL_OPEN_TIMEOUT",
);
assert.equal(sshClient.listenerCount("close"), 1);
let pending = 0;
let ok = 0;
for (let attempt = 0; attempt < 5; attempt += 1) {
try {
const channel = await openBoundedSftpChannel(sshClient, {
timeoutMs: 50,
abandonWaitMs: 30,
});
assert.ok(channel);
ok += 1;
} catch (error) {
if (error?.code === "SFTP_CHANNEL_OPEN_PENDING") pending += 1;
else throw error;
}
}
assert.equal(ok, 5);
assert.equal(pending, 0);
assert.equal(sshClient.listenerCount("close"), 0, "force-clear must drop the close listener");
const late = createChannel();
callbacks[0](null, late);
assert.ok(late.endCalls > 0 || late.closeCalls > 0, "a late channel is still closed");
assert.equal(sshClient.listenerCount("close"), 0);
});
test("partially settled abandoned opens keep the gate until the rest arrive or the wait expires", async () => {
const callbacks = [];
const sshClient = new EventEmitter();
sshClient.sftp = (callback) => { callbacks.push(callback); };
const first = openBoundedSftpChannel(sshClient, { timeoutMs: 5, abandonWaitMs: 20 });
const second = openBoundedSftpChannel(sshClient, { timeoutMs: 5, abandonWaitMs: 20 });
await Promise.all([assert.rejects(first), assert.rejects(second)]);
const outcome = settleOpen(openBoundedSftpChannel(sshClient, {
timeoutMs: 50,
abandonWaitMs: 250,
}));
await delay(30);
callbacks[0](new Error("first failed"));
await delay(30);
assert.equal(callbacks.length, 2, "one remaining abandoned open must keep the gate");
callbacks[1](new Error("second failed"));
await delay(20);
assert.equal(callbacks.length, 3);
const healthy = createChannel();
callbacks[2](null, healthy);
const settled = await outcome;
assert.equal(settled.channel, healthy);
});
test("abandon wait force-clears every outstanding token", async () => {
const callbacks = [];
let admitted = null;
const sshClient = new EventEmitter();
sshClient.sftp = (callback) => {
callbacks.push(callback);
if (callbacks.length > 2) {
admitted = createChannel();
callback(null, admitted);
}
};
await Promise.all([
assert.rejects(openBoundedSftpChannel(sshClient, { timeoutMs: 5 })),
assert.rejects(openBoundedSftpChannel(sshClient, { timeoutMs: 5 })),
]);
const outcome = settleOpen(openBoundedSftpChannel(sshClient, {
timeoutMs: 50,
abandonWaitMs: 40,
}));
await delay(15);
assert.equal(callbacks.length, 2);
const settled = await outcome;
assert.equal(settled.error, undefined, settled.error?.message);
assert.equal(settled.channel, admitted);
assert.equal(admitted.endCalls, 0);
assert.equal(admitted.closeCalls, 0);
assert.equal(callbacks.length, 3);
for (const callback of callbacks.slice(0, 2)) {
const late = createChannel();
callback(null, late);
assert.ok(late.endCalls > 0 || late.closeCalls > 0);
}
});
test("cancel during the abandon wait does not admit another open or clear the gate", async () => {
const callbacks = [];
const sshClient = new EventEmitter();
sshClient.sftp = (callback) => { callbacks.push(callback); };
const controller = new AbortController();
await assert.rejects(openBoundedSftpChannel(sshClient, { timeoutMs: 5 }));
const outcome = settleOpen(openBoundedSftpChannel(sshClient, {
timeoutMs: 50,
abandonWaitMs: 400,
signal: controller.signal,
}));
await delay(20);
assert.equal(callbacks.length, 1);
controller.abort(new Error("cancelled"));
const settled = await outcome;
assert.match(settled.error?.message || "", /cancelled/);
assert.equal(callbacks.length, 1);
assert.equal(getEventListeners(controller.signal, "abort").length, 0);
await assert.rejects(
openBoundedSftpChannel(sshClient, { abandonWaitMs: 0 }),
(error) => error.code === "SFTP_CHANNEL_OPEN_PENDING",
);
assert.equal(callbacks.length, 1);
});
@@ -41,6 +41,8 @@ function createBridgeRegistrarForTest({
tempDir,
sftpClients = new Map(),
transferBridge = createNoopBridge(),
sshBridge = null,
terminalBridge = null,
electronModuleOverrides = {},
}) {
const noopBridge = createNoopBridge();
@@ -81,12 +83,12 @@ function createBridgeRegistrarForTest({
sftpClients,
CLOUD_SYNC_PASSWORD_FILE: "cloud-sync-password",
getCliDiscoveryFilePath: () => path.join(tempDir, "cli-discovery.json"),
sshBridge: { ...noopBridge, ensureMoshStatsConnection() {} },
sshBridge: sshBridge || { ...noopBridge, ensureMoshStatsConnection() {} },
sftpBridge,
localFsBridge: noopBridge,
transferBridge,
portForwardingBridge: noopBridge,
terminalBridge: { ...noopBridge, execOnEtSession() {} },
terminalBridge: terminalBridge || { ...noopBridge, execOnEtSession() {} },
crashLogBridge: noopBridge,
ptyProcessTree: { getChildProcesses: () => [] },
getOauthBridge: () => ({ setupOAuthBridge() {} }),
@@ -250,3 +252,33 @@ test("downloadToTempWithProgress proxies transfer work to the terminal worker in
cancelled: false,
});
});
for (const mode of ["disabled", "unavailable", "worker"]) {
test(`registrar enables saved-log restart guards only for main fallback (${mode})`, async (t) => {
const tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "netcatty-log-fallback-test-"));
const previousWorkerSetting = process.env.NETCATTY_TERMINAL_WORKER;
process.env.NETCATTY_TERMINAL_WORKER = mode === "disabled" ? "0" : "1";
t.after(async () => {
if (previousWorkerSetting === undefined) delete process.env.NETCATTY_TERMINAL_WORKER;
else process.env.NETCATTY_TERMINAL_WORKER = previousWorkerSetting;
await fs.promises.rm(tempDir, { recursive: true, force: true });
});
const observed = {};
const bridgeSpy = (name) => ({
...createNoopBridge(),
registerHandlers(_ipcMain, options) { observed[name] = options; },
});
const registerBridges = createBridgeRegistrarForTest({
ipcMain: createIpcMainStub(), tempDir,
sshBridge: bridgeSpy("ssh"), terminalBridge: bridgeSpy("terminal"),
electronModuleOverrides: mode === "unavailable" ? {} : {
utilityProcess: { fork() { throw new Error("registration must not cold-start the worker"); } },
},
});
registerBridges({});
for (const name of ["ssh", "terminal"]) {
assert.equal(observed[name].manageSessionLogRestart, mode !== "worker");
assert.equal(Boolean(observed[name].terminalWorkerManager), mode === "worker");
}
});
}
+188 -18
View File
@@ -16,9 +16,16 @@ const { createProgrammaticCommandLogRewriter } = require("./programmaticCommandL
// Active log streams keyed by sessionId
const activeStreams = new Map();
// Keep only the latest server-owned target for each session, including while
// finalization is in flight. Finished records retain no terminal/parser data.
const sessionLogs = new Map();
// Buffer flush interval (ms)
const FLUSH_INTERVAL = 500;
// A failed snapshot retries without waiting for more output, but not in a
// tight loop. The delay grows while the same failure persists.
const SNAPSHOT_RETRY_MIN_MS = 250;
const SNAPSHOT_RETRY_MAX_MS = 5000;
// Max buffer size before immediate flush (bytes)
const MAX_BUFFER_SIZE = 64 * 1024;
const SUDO_AUTOFILL_MARKER_PATTERN = /__NETCATTY_SUDO_[a-z0-9_]+__/gi;
@@ -120,6 +127,7 @@ function startStream(sessionId, opts) {
console.warn(`[SessionLogStream] Stream already active for ${sessionId}, stopping old one`);
stopStream(sessionId, activeStreams.get(sessionId)?.startToken);
}
forgetSessionLog(sessionId);
const { hostLabel, hostname, directory, format, startTime } = opts;
if (!directory) {
@@ -166,16 +174,6 @@ function createStreamEntry(sessionId, opts) {
? fs.createWriteStream(filePath, { flags: "w", encoding: "utf8" })
: null;
if (writeStream) {
writeStream.on("error", (err) => {
console.error(`[SessionLogStream] Write error for ${sessionId}:`, err.message);
const entry = activeStreams.get(sessionId);
if (entry) {
entry.disabled = true;
}
});
}
const startToken = Symbol("session-log-stream");
const entry = {
writeStream,
@@ -202,6 +200,13 @@ function createStreamEntry(sessionId, opts) {
snapshotPromise: null,
snapshotRequested: false,
snapshotDirty: false,
snapshotError: null,
snapshotRetryTimer: null,
snapshotRetryDelay: 0,
// Set only while prepareSessionLog is waiting on a snapshot, so that
// caller can observe the failure before the delayed retry starts.
deferSnapshotRetry: false,
error: null,
closing: false,
disabled: false,
startToken,
@@ -210,11 +215,21 @@ function createStreamEntry(sessionId, opts) {
pendingInitialLineLeadingCarriageReturn: false,
};
if (writeStream) {
writeStream.on("error", (err) => {
console.error(`[SessionLogStream] Write error for ${sessionId}:`, err.message);
// A delayed error from an old connection must not disable a new stream.
entry.disabled = true;
entry.error = err;
});
}
entry.flushTimer = setInterval(() => {
flushBuffer(entry);
}, FLUSH_INTERVAL);
activeStreams.set(sessionId, entry);
sessionLogs.set(sessionId, { entry, filePath, startToken, stopPromise: null, error: null });
console.log(`[SessionLogStream] Started stream for ${sessionId} -> ${filePath}`);
return startToken;
}
@@ -283,6 +298,7 @@ function flushBuffer(entry) {
} catch (err) {
console.error("[SessionLogStream] Flush error:", err.message);
entry.disabled = true;
entry.error = err;
}
}
@@ -318,9 +334,27 @@ function renderSnapshotContent(entry, { finalize = false } = {}) {
: content;
}
function clearSnapshotRetry(entry) {
if (!entry?.snapshotRetryTimer) return;
clearTimeout(entry.snapshotRetryTimer);
entry.snapshotRetryTimer = null;
}
function scheduleSnapshotRetry(entry) {
if (!entry || entry.closing || entry.disabled || entry.isRaw || entry.snapshotRetryTimer) return;
const delay = entry.snapshotRetryDelay || SNAPSHOT_RETRY_MIN_MS;
entry.snapshotRetryTimer = setTimeout(() => {
entry.snapshotRetryTimer = null;
if (entry.closing || entry.disabled) return;
scheduleSnapshot(entry);
}, delay);
entry.snapshotRetryDelay = Math.min(delay * 2, SNAPSHOT_RETRY_MAX_MS);
}
function scheduleSnapshot(entry) {
if (!entry || entry.disabled || entry.isRaw || entry.closing) return;
if (!entry.snapshotDirty) return;
clearSnapshotRetry(entry);
if (entry.snapshotPromise) {
entry.snapshotRequested = true;
@@ -328,18 +362,26 @@ function scheduleSnapshot(entry) {
}
entry.snapshotDirty = false;
entry.snapshotError = null;
entry.snapshotPromise = fs.promises
.writeFile(entry.filePath, renderSnapshotContent(entry), "utf8")
.then(() => {
entry.snapshotRetryDelay = 0;
})
.catch((err) => {
console.error("[SessionLogStream] Snapshot write failed:", err.message);
entry.snapshotDirty = true;
entry.snapshotError = err;
})
.finally(() => {
entry.snapshotPromise = null;
if ((entry.snapshotRequested || entry.snapshotDirty) && !entry.closing) {
entry.snapshotRequested = false;
scheduleSnapshot(entry);
}
// prepareSessionLog must see this failure. Other callers retry on a
// delay, or sooner when new output is flushed.
if (entry.snapshotError && entry.deferSnapshotRetry) return;
if (!(entry.snapshotRequested || entry.snapshotDirty) || entry.closing) return;
entry.snapshotRequested = false;
if (entry.snapshotError) scheduleSnapshotRetry(entry);
else scheduleSnapshot(entry);
});
}
@@ -432,6 +474,21 @@ async function stopStream(sessionId, expectedToken) {
activeStreams.delete(sessionId);
entry.closing = true;
const record = sessionLogs.get(sessionId);
const stopPromise = finalizeStream(sessionId, entry);
if (record?.entry === entry) record.stopPromise = stopPromise;
const finalPath = await stopPromise;
if (record?.entry === entry) {
record.entry = null;
record.stopPromise = null;
record.error = finalPath ? null : (entry.error || new Error("Failed to finalize session log"));
}
return finalPath;
}
async function finalizeStream(sessionId, entry) {
clearSnapshotRetry(entry);
// Stop periodic flush
if (entry.flushTimer) {
clearInterval(entry.flushTimer);
@@ -451,11 +508,21 @@ async function stopStream(sessionId, expectedToken) {
flushBuffer(entry);
await waitForSnapshotIdle(entry);
// Close the raw write stream and wait for it to finish.
// Close the raw write stream and wait for it to finish. A flush that
// already disabled the entry must still end the stream, or the fd leaks.
if (entry.writeStream) {
await new Promise((resolve) => {
entry.writeStream.end(resolve);
});
let closeError = null;
try {
await new Promise((resolve, reject) => {
entry.writeStream.end((err) => err ? reject(err) : resolve());
});
} catch (err) {
closeError = err;
}
if (entry.disabled || closeError) {
entry.disabled = true;
entry.error = entry.error || closeError || new Error("Session log stream failed");
}
} else if (!entry.disabled) {
try {
await fs.promises.writeFile(entry.filePath, renderSnapshotContent(entry, { finalize: true }), "utf8");
@@ -463,6 +530,7 @@ async function stopStream(sessionId, expectedToken) {
} catch (err) {
console.error(`[SessionLogStream] Final snapshot write failed for ${sessionId}:`, err.message);
entry.disabled = true;
entry.error = err;
}
}
@@ -472,6 +540,101 @@ async function stopStream(sessionId, expectedToken) {
return entry.disabled ? null : finalPath;
}
/** Flush the session's current log without ending recording, or return its
* last finalized log. Paths are never inferred from labels or renderer input.
*/
async function prepareSessionLog(sessionId) {
const record = sessionLogs.get(sessionId);
if (!record) return null;
if (record.stopPromise) {
await record.stopPromise;
} else if (record.entry) {
const entry = record.entry;
if (entry.disabled) throw entry.error || new Error("Session log stream failed");
flushBuffer(entry);
if (entry.disabled) throw entry.error || new Error("Failed to flush session log");
if (entry.isRaw) {
// An empty write is a barrier for all earlier writes, not just the
// manager's buffer. Calling write() alone does not make the file ready.
await new Promise((resolve, reject) => {
entry.writeStream.write("", (err) => err ? reject(err) : resolve());
});
} else {
// Also materialize an empty txt/html log before its first output arrives.
// Hold the retry until this wait returns, then resume on the backoff timer.
entry.snapshotDirty = true;
entry.deferSnapshotRetry = true;
try {
scheduleSnapshot(entry);
await waitForSnapshotIdle(entry);
if (entry.snapshotError) throw entry.snapshotError;
} finally {
entry.deferSnapshotRetry = false;
if (!entry.snapshotPromise && !entry.closing
&& (entry.snapshotRequested || entry.snapshotDirty)) {
if (entry.snapshotError) scheduleSnapshotRetry(entry);
else scheduleSnapshot(entry);
}
}
}
// Stop may have begun while the flush was pending; wait for its final file.
if (record.stopPromise) await record.stopPromise;
}
if (sessionLogs.get(sessionId) !== record) {
throw new Error("Session log changed; try again");
}
if (record.error || record.entry?.disabled) {
throw record.error || record.entry.error || new Error("Session log stream failed");
}
const stat = await fs.promises.stat(record.filePath);
if (!stat.isFile()) throw new Error("Session log is not a file");
if (sessionLogs.get(sessionId) !== record) {
throw new Error("Session log changed; try again");
}
return record.filePath;
}
/** Invalidate a previous connection's target without disturbing its writer.
* Late finalization cannot restore it because records are identity-scoped.
*/
function forgetSessionLog(sessionId) {
sessionLogs.delete(sessionId);
}
function getSessionLogToken(sessionId) {
return sessionLogs.get(sessionId)?.startToken ?? null;
}
/** Drop only the inactive target observed before an asynchronous reconnect.
* A continuing manual stream or a newly-created/finalized log must survive.
*/
function forgetInactiveSessionLog(sessionId, expectedToken) {
if (!expectedToken || activeStreams.has(sessionId)
|| getSessionLogToken(sessionId) !== expectedToken) return false;
return sessionLogs.delete(sessionId);
}
/** Main-process fallback starts do not pass through the worker's lifecycle
* guard. Retire their prior inactive log only after a successful same-id start.
*/
function withSessionLogRestart(handler) {
return async (event, payload) => {
const sessionId = payload?.sessionId;
const previousToken = getSessionLogToken(sessionId);
const result = await handler(event, payload);
if (sessionId && result?.sessionId === sessionId && result.success !== false && !result.error) {
forgetInactiveSessionLog(sessionId, previousToken);
}
return result;
};
}
function hasSessionLog(sessionId) {
return sessionLogs.has(sessionId);
}
/**
* Check if a session has an active log stream.
* @param {string} sessionId
@@ -502,6 +665,7 @@ async function cleanupAll() {
console.log(`[SessionLogStream] Cleaning up ${activeStreams.size} active streams`);
const entries = [...activeStreams.entries()];
await Promise.allSettled(entries.map(([id, entry]) => stopStream(id, entry.startToken)));
sessionLogs.clear();
}
module.exports = {
@@ -511,6 +675,12 @@ module.exports = {
registerSudoAutofillInput,
registerProgrammaticCommandLogRewrite,
stopStream,
prepareSessionLog,
forgetSessionLog,
getSessionLogToken,
forgetInactiveSessionLog,
withSessionLogRestart,
hasSessionLog,
hasStream,
getActiveLogPaths,
cleanupAll,
@@ -5,6 +5,12 @@ const path = require("node:path");
const {
appendData,
hasStream,
hasSessionLog,
prepareSessionLog,
forgetSessionLog,
getSessionLogToken,
forgetInactiveSessionLog,
withSessionLogRestart,
registerProgrammaticCommandLogRewrite,
registerSudoAutofillInput,
startStream,
@@ -929,3 +935,293 @@ for (const format of ["txt", "html"]) {
}
});
}
for (const format of ["txt", "html", "raw"]) {
test(`prepare ${format} log flushes current output and leaves recording active`, async (t) => {
const directory = path.join(TEMP_ROOT, `prepare-${format}-${Date.now()}`);
const filePath = path.join(directory, `session.${format}`);
const sessionId = `prepare-${format}`;
t.after(async () => {
await stopStream(sessionId);
forgetSessionLog(sessionId);
fs.rmSync(directory, { recursive: true, force: true });
});
startStreamToFile(sessionId, { filePath, format });
assert.equal(await prepareSessionLog(sessionId), filePath, "empty logs must exist before opening");
assert.equal(fs.statSync(filePath).isFile(), true);
appendData(sessionId, "first\r\n");
assert.equal(await prepareSessionLog(sessionId), filePath);
assert.match(fs.readFileSync(filePath, "utf8"), /first/);
assert.equal(hasStream(sessionId), true);
appendData(sessionId, "second\r\n");
await prepareSessionLog(sessionId);
assert.match(fs.readFileSync(filePath, "utf8"), /first[\s\S]*second/);
const stopping = stopStream(sessionId);
assert.equal(await prepareSessionLog(sessionId), filePath, "prepare waits for finalization");
assert.equal(await stopping, filePath);
assert.equal(hasStream(sessionId), false);
assert.equal(await prepareSessionLog(sessionId), filePath, "stopped logs remain accessible");
assert.equal(await prepareSessionLog(`${sessionId}-wrong`), null);
fs.unlinkSync(filePath);
await assert.rejects(prepareSessionLog(sessionId), /ENOENT/);
});
}
test("prepare raw log waits for the write callback, including already-queued writes", async (t) => {
const { Writable } = require("node:stream");
const directory = path.join(TEMP_ROOT, `prepare-raw-barrier-${Date.now()}`);
const filePath = path.join(directory, "session.log");
const sessionId = "prepare-raw-barrier";
let completedWrites = 0;
t.mock.method(fs, "createWriteStream", () => new Writable({
write(chunk, _encoding, callback) {
setTimeout(() => {
fs.appendFileSync(filePath, chunk);
completedWrites++;
callback();
}, 15);
},
}));
t.after(async () => {
await stopStream(sessionId);
forgetSessionLog(sessionId);
fs.rmSync(directory, { recursive: true, force: true });
});
startStreamToFile(sessionId, { filePath, format: "raw" });
appendData(sessionId, "x".repeat(64 * 1024));
const preparing = prepareSessionLog(sessionId);
assert.equal(completedWrites, 0);
assert.equal(await preparing, filePath);
assert.equal(completedWrites, 2, "data write and barrier callbacks must both complete");
assert.equal(fs.readFileSync(filePath, "utf8").length, 64 * 1024);
});
test("prepare reports snapshot failures promptly and background writes keep retrying", async (t) => {
const directory = path.join(TEMP_ROOT, `prepare-error-${Date.now()}`);
const filePath = path.join(directory, "session.txt");
const sessionId = "prepare-snapshot-error";
const writeFile = fs.promises.writeFile.bind(fs.promises);
let fail = true;
let attempts = 0;
t.mock.method(fs.promises, "writeFile", async (...args) => {
if (args[0] === filePath) {
attempts++;
if (fail) throw new Error("disk full");
}
return writeFile(...args);
});
t.after(async () => {
fail = false;
await stopStream(sessionId);
forgetSessionLog(sessionId);
fs.rmSync(directory, { recursive: true, force: true });
});
startStreamToFile(sessionId, { filePath, format: "txt" });
appendData(sessionId, "not lost\n");
await assert.rejects(prepareSessionLog(sessionId), /disk full/);
const attemptsAtReport = attempts;
assert.ok(attemptsAtReport >= 1);
fail = false;
const started = Date.now();
while (!fs.existsSync(filePath) || !fs.readFileSync(filePath, "utf8").includes("not lost")) {
if (Date.now() - started > 2000) {
assert.fail(`snapshot retry did not persist the log (attempts=${attempts})`);
}
await new Promise((resolve) => setTimeout(resolve, 10));
}
assert.ok(attempts > attemptsAtReport, "a failed snapshot write retries without another prepare");
assert.equal(await prepareSessionLog(sessionId), filePath);
});
test("persistent snapshot failures back off instead of retrying immediately", async (t) => {
const directory = path.join(TEMP_ROOT, `snapshot-backoff-${Date.now()}`);
const filePath = path.join(directory, "session.txt");
const sessionId = "snapshot-backoff";
const writeFile = fs.promises.writeFile.bind(fs.promises);
let attempts = 0;
t.mock.method(fs.promises, "writeFile", async (...args) => {
if (args[0] === filePath) {
attempts++;
throw new Error("disk full");
}
return writeFile(...args);
});
t.after(async () => {
await stopStream(sessionId);
forgetSessionLog(sessionId);
fs.rmSync(directory, { recursive: true, force: true });
});
startStreamToFile(sessionId, { filePath, format: "txt" });
appendData(sessionId, "x".repeat(64 * 1024));
await new Promise((resolve) => setTimeout(resolve, 40));
assert.equal(attempts, 1, "a failed snapshot must not retry in a tight loop");
await new Promise((resolve) => setTimeout(resolve, 400));
assert.equal(attempts, 2, "the first retry waits for the backoff delay");
});
test("finalize ends a raw write stream that a synchronous flush already disabled", async (t) => {
const directory = path.join(TEMP_ROOT, `finalize-disabled-${Date.now()}`);
const filePath = path.join(directory, "session.log");
const sessionId = "finalize-disabled-raw";
const stream = {
ended: false,
on() { return this; },
write() { throw new Error("flush failed"); },
end(callback) {
this.ended = true;
if (typeof callback === "function") callback();
},
};
t.mock.method(fs, "createWriteStream", () => stream);
t.after(async () => {
await stopStream(sessionId);
forgetSessionLog(sessionId);
fs.rmSync(directory, { recursive: true, force: true });
});
const started = startStreamToFile(sessionId, { filePath, format: "raw" });
assert.equal(started.ok, true);
appendData(sessionId, "pending");
assert.equal(await stopStream(sessionId), null);
assert.equal(stream.ended, true);
});
test("prepare reports raw stream errors rather than opening an empty or stale file", async (t) => {
const directory = path.join(TEMP_ROOT, `prepare-raw-error-${Date.now()}`);
const filePath = path.join(directory, "directory.log");
const sessionId = "prepare-raw-error";
fs.mkdirSync(filePath, { recursive: true });
t.after(async () => {
await stopStream(sessionId);
forgetSessionLog(sessionId);
fs.rmSync(directory, { recursive: true, force: true });
});
startStreamToFile(sessionId, { filePath, format: "raw" });
appendData(sessionId, "data");
await assert.rejects(prepareSessionLog(sessionId), /EISDIR/);
assert.equal(await stopStream(sessionId), null);
await assert.rejects(prepareSessionLog(sessionId), /EISDIR/);
});
test("late finalization and stale tokens cannot replace a reconnected session's target", async (t) => {
const directory = path.join(TEMP_ROOT, `prepare-reconnect-${Date.now()}`);
const oldPath = path.join(directory, "old.txt");
const newPath = path.join(directory, "new.txt");
const sessionId = "prepare-reconnect";
const writeFile = fs.promises.writeFile.bind(fs.promises);
let releaseOldWrite;
const oldWriteGate = new Promise((resolve) => { releaseOldWrite = resolve; });
t.mock.method(fs.promises, "writeFile", async (...args) => {
if (args[0] === oldPath) await oldWriteGate;
return writeFile(...args);
});
t.after(async () => {
releaseOldWrite();
await stopStream(sessionId);
forgetSessionLog(sessionId);
fs.rmSync(directory, { recursive: true, force: true });
});
const first = startStreamToFile(sessionId, { filePath: oldPath, format: "txt" });
appendData(sessionId, "old\n");
const stopping = stopStream(sessionId, first.token);
const stalePrepare = prepareSessionLog(sessionId);
const second = startStreamToFile(sessionId, { filePath: newPath, format: "txt" });
assert.equal(second.ok, true);
appendData(sessionId, "new\n");
releaseOldWrite();
await assert.rejects(stalePrepare, /changed/);
assert.equal(await stopping, oldPath);
assert.equal(await stopStream(sessionId, first.token), null);
assert.equal(await prepareSessionLog(sessionId), newPath);
await stopStream(sessionId, second.token);
assert.equal(await prepareSessionLog(sessionId), newPath);
});
test("forgetting a session prevents late finalization from restoring its old path", async (t) => {
const directory = path.join(TEMP_ROOT, `prepare-forget-${Date.now()}`);
const filePath = path.join(directory, "old.txt");
const sessionId = "prepare-forget";
t.after(() => fs.rmSync(directory, { recursive: true, force: true }));
startStreamToFile(sessionId, { filePath, format: "txt" });
appendData(sessionId, "old\n");
const stopping = stopStream(sessionId);
forgetSessionLog(sessionId);
await stopping;
assert.equal(hasSessionLog(sessionId), false);
assert.equal(await prepareSessionLog(sessionId), null);
});
test("conditional log invalidation clears only the observed inactive stream token", async (t) => {
const directory = path.join(TEMP_ROOT, `conditional-forget-${Date.now()}`);
const sessionId = "conditional-forget";
const oldPath = path.join(directory, "old.txt");
const newPath = path.join(directory, "new.txt");
t.after(async () => {
await stopStream(sessionId);
forgetSessionLog(sessionId);
fs.rmSync(directory, { recursive: true, force: true });
});
assert.equal(getSessionLogToken(sessionId), null);
assert.equal(forgetInactiveSessionLog(sessionId, null), false);
const first = startStreamToFile(sessionId, { filePath: oldPath, format: "txt" });
assert.equal(getSessionLogToken(sessionId), first.token);
assert.equal(forgetInactiveSessionLog(sessionId, first.token), false, "active recording survives");
await stopStream(sessionId);
assert.equal(getSessionLogToken(sessionId), first.token, "finalization preserves record identity");
assert.equal(forgetInactiveSessionLog(sessionId, Symbol("wrong")), false);
assert.equal(forgetInactiveSessionLog(sessionId, first.token), true);
assert.equal(await prepareSessionLog(sessionId), null);
const second = startStreamToFile(sessionId, { filePath: newPath, format: "txt" });
assert.notEqual(second.token, first.token);
assert.equal(forgetInactiveSessionLog(sessionId, first.token), false);
await stopStream(sessionId);
assert.equal(forgetInactiveSessionLog(sessionId, first.token), false, "a new finalized log survives");
assert.equal(await prepareSessionLog(sessionId), newPath);
});
for (const outcome of ["success", "throw", "failure", "different-session", "new-active", "new-finalized", "manual"]) {
test(`fallback restart wrapper preserves the correct saved log (${outcome})`, async (t) => {
const directory = path.join(TEMP_ROOT, `fallback-${outcome}-${Date.now()}`);
const sessionId = `fallback-${outcome}`;
const oldPath = path.join(directory, "old.txt");
const newPath = path.join(directory, "new.txt");
const event = { sender: { id: 9 } };
const payload = { sessionId, sessionLog: { enabled: false } };
t.after(async () => {
await stopStream(sessionId, getSessionLogToken(sessionId));
forgetSessionLog(sessionId);
fs.rmSync(directory, { recursive: true, force: true });
});
startStreamToFile(sessionId, { filePath: oldPath, format: "txt", stopRequiresToken: outcome === "manual" });
appendData(sessionId, "previous output\n");
if (outcome !== "manual") await stopStream(sessionId);
const previousToken = getSessionLogToken(sessionId);
const start = withSessionLogRestart(async (receivedEvent, receivedPayload) => {
assert.equal(receivedEvent, event);
assert.equal(receivedPayload, payload);
assert.equal(await prepareSessionLog(sessionId), oldPath, "pending starts retain the old target");
if (outcome === "throw") throw new Error("authentication failed");
if (outcome === "failure") return { sessionId, success: false, error: "connection refused" };
if (outcome === "different-session") return { sessionId: "different" };
if (outcome.startsWith("new-")) {
startStreamToFile(sessionId, { filePath: newPath, format: "txt" });
appendData(sessionId, "new output\n");
if (outcome === "new-finalized") await stopStream(sessionId);
}
return { sessionId };
});
if (outcome === "throw") await assert.rejects(start(event, payload), /authentication failed/);
else {
const result = await start(event, payload);
assert.equal(result.sessionId, outcome === "different-session" ? "different" : sessionId);
}
if (outcome === "success") {
assert.equal(await prepareSessionLog(sessionId), null);
} else {
const newLog = outcome.startsWith("new-");
assert.equal(await prepareSessionLog(sessionId), newLog ? newPath : oldPath);
if (newLog) assert.notEqual(getSessionLogToken(sessionId), previousToken);
else assert.equal(getSessionLogToken(sessionId), previousToken);
assert.equal(hasStream(sessionId), outcome === "manual" || outcome === "new-active");
}
});
}
+112 -7
View File
@@ -6,7 +6,7 @@
const crypto = require("node:crypto");
const fs = require("node:fs");
const path = require("node:path");
const { dialog } = require("electron");
const { dialog, shell } = require("electron");
const {
terminalDataToHtmlContent,
terminalDataToPlainText,
@@ -782,13 +782,83 @@ async function stopManualSessionLog(event, payload = {}) {
}
}
async function getManualSessionLogStatus(event, payload = {}) {
const sessionLogStreamManager = require("./sessionLogStreamManager.cjs");
async function getManualSessionLogStatus(event, payload = {}, terminalWorkerManager = null) {
const manager = require("./sessionLogStreamManager.cjs");
const { sessionId } = payload;
if (!sessionId) {
return { success: false, isLogging: false, error: "Missing sessionId" };
if (!validSessionLogId(sessionId)) {
return { success: false, isLogging: false, hasSessionLog: false, error: "Missing sessionId" };
}
// Keep the recording toggle main-owned. Automatic logs may live in the
// worker, and stopped logs remain available independently of active streams.
const isLogging = manager.hasStream(sessionId);
let hasSessionLog = manager.hasSessionLog(sessionId);
if (!hasSessionLog && isTerminalWorkerRunning(terminalWorkerManager)) {
try {
const result = await terminalWorkerManager.request(
"netcatty:sessionLog:statusLocal", { sessionId }, {},
);
hasSessionLog = result?.success === true && result.hasSessionLog === true;
} catch {
// An unavailable worker must not manufacture an openable log target.
}
}
return { success: true, isLogging, hasSessionLog };
}
function validSessionLogId(sessionId) {
return typeof sessionId === "string" && sessionId.trim().length > 0;
}
async function prepareLocalSessionLog(_event, payload = {}) {
if (!validSessionLogId(payload?.sessionId)) {
return { success: false, error: "Missing sessionId" };
}
try {
const filePath = await require("./sessionLogStreamManager.cjs").prepareSessionLog(payload.sessionId);
return filePath
? { success: true, filePath }
: { success: false, error: "No saved log is available for this session" };
} catch (err) {
return { success: false, error: err?.message || String(err) };
}
}
/** Resolve only a main/worker-owned session target. Renderer paths, host labels
* and configured directories are deliberately not part of this operation.
*/
async function prepareSessionLog(event, payload = {}, terminalWorkerManager = null) {
const sessionId = payload?.sessionId;
if (!validSessionLogId(sessionId)) {
return { success: false, error: "Missing sessionId" };
}
const manager = require("./sessionLogStreamManager.cjs");
if (manager.hasSessionLog(sessionId)) {
return prepareLocalSessionLog(event, { sessionId });
}
if (!isTerminalWorkerRunning(terminalWorkerManager)) {
return { success: false, error: "No saved log is available for this session" };
}
try {
const result = await terminalWorkerManager.request(
"netcatty:sessionLog:prepareLocal", { sessionId }, {},
);
return result?.success && typeof result.filePath === "string" && result.filePath
? { success: true, filePath: result.filePath }
: { success: false, error: result?.error || "No saved log is available for this session" };
} catch (err) {
return { success: false, error: err?.message || String(err) };
}
}
async function revealSessionLog(event, payload = {}, terminalWorkerManager = null) {
const result = await prepareSessionLog(event, payload, terminalWorkerManager);
if (!result.success) return result;
try {
shell.showItemInFolder(result.filePath);
return result;
} catch (err) {
return { success: false, error: err?.message || String(err) };
}
return { success: true, isLogging: sessionLogStreamManager.hasStream(sessionId) };
}
/**
@@ -797,6 +867,11 @@ async function getManualSessionLogStatus(event, payload = {}) {
*/
function registerWorkerHandlers(ipcMain) {
ipcMain.handle("netcatty:sessionLogs:getActivePaths", async () => getLocalActiveLogPaths());
ipcMain.handle("netcatty:sessionLog:prepareLocal", prepareLocalSessionLog);
ipcMain.handle("netcatty:sessionLog:statusLocal", async (_event, payload = {}) => ({
success: true,
hasSessionLog: require("./sessionLogStreamManager.cjs").hasSessionLog(payload.sessionId),
}));
}
/**
@@ -818,7 +893,34 @@ function registerHandlers(ipcMain, options = {}) {
ipcMain.handle("netcatty:sessionLog:manualChoosePath", chooseManualSessionLogPath);
ipcMain.handle("netcatty:sessionLog:manualStart", startManualSessionLog);
ipcMain.handle("netcatty:sessionLog:manualStop", stopManualSessionLog);
ipcMain.handle("netcatty:sessionLog:manualStatus", getManualSessionLogStatus);
ipcMain.handle("netcatty:sessionLog:manualStatus", (event, payload) => (
getManualSessionLogStatus(event, payload, terminalWorkerManager)
));
ipcMain.handle("netcatty:sessionLog:prepare", (event, payload) => (
prepareSessionLog(event, payload, terminalWorkerManager)
));
ipcMain.handle("netcatty:sessionLog:reveal", (event, payload) => (
revealSessionLog(event, payload, terminalWorkerManager)
));
// Disconnected tabs may still open their last manual log. Once that session
// reconnects, it must not mask the new worker-owned automatic log instead.
const disconnectedSessions = new Set();
terminalWorkerManager?.onSessionClosed?.(({ sessionId, explicit }) => {
if (explicit) {
disconnectedSessions.delete(sessionId);
const manager = require("./sessionLogStreamManager.cjs");
if (!manager.hasStream(sessionId)) manager.forgetSessionLog(sessionId);
} else {
disconnectedSessions.add(sessionId);
}
});
terminalWorkerManager?.onSessionOwned?.(({ sessionId }) => {
if (!disconnectedSessions.delete(sessionId)) return;
const manager = require("./sessionLogStreamManager.cjs");
// Continuous manual recording intentionally survives terminal reconnects.
if (!manager.hasStream(sessionId)) manager.forgetSessionLog(sessionId);
});
// In the default terminal-worker runtime, sessions run in a utilityProcess
// and call appendData() on the worker's own sessionLogStreamManager module
@@ -850,6 +952,9 @@ module.exports = {
startManualSessionLog,
stopManualSessionLog,
getManualSessionLogStatus,
prepareLocalSessionLog,
prepareSessionLog,
revealSessionLog,
toLocalISOString,
terminalDataToHtml,
terminalPlainTextToHtml,
+262 -2
View File
@@ -15,11 +15,11 @@ async function waitForPath(targetPath, { timeoutMs = 2000 } = {}) {
throw new Error(`Timed out waiting for path: ${targetPath}`);
}
function loadBridgeWithDialog(dialogMock) {
function loadBridgeWithDialog(dialogMock, shellMock = {}) {
const originalLoad = Module._load;
Module._load = function patchedLoad(request, parent, isMain) {
if (request === "electron") {
return { dialog: dialogMock };
return { dialog: dialogMock, shell: shellMock };
}
return originalLoad.call(this, request, parent, isMain);
};
@@ -1001,3 +1001,263 @@ test("screen export escapes HTML when the user selects an HTML filename", async
fs.rmSync(directory, { recursive: true, force: true });
}
});
function registerSessionLogTestHandlers(bridge, terminalWorkerManager) {
const handlers = new Map();
bridge.registerHandlers({ handle: (channel, handler) => handlers.set(channel, handler) }, { terminalWorkerManager });
return handlers;
}
test("prepare and reveal use the current manual log before querying automatic worker logs", async (t) => {
const directory = path.join(TEMP_ROOT, `shortcut-manual-${Date.now()}`);
const filePath = path.join(directory, "manual.txt");
const sessionId = "shortcut-manual";
const revealed = [];
const bridge = loadBridgeWithDialog(
{ showSaveDialog: async () => ({ canceled: false, filePath }) },
{ showItemInFolder: (target) => revealed.push(target) },
);
const manager = require("./sessionLogStreamManager.cjs");
const workerRequests = [];
const handlers = registerSessionLogTestHandlers(bridge, {
isRunning: () => true,
request: async (...args) => { workerRequests.push(args); return { success: true, filePath: "/worker/log.txt" }; },
});
t.after(async () => {
await manager.cleanupAll();
fs.rmSync(directory, { recursive: true, force: true });
});
await bridge.startManualSessionLog(null, { sessionId, format: "txt" });
manager.appendData(sessionId, "fresh output\n");
const prepare = handlers.get("netcatty:sessionLog:prepare");
const reveal = handlers.get("netcatty:sessionLog:reveal");
assert.deepEqual(await prepare(null, { sessionId, filePath: "/untrusted/path" }), { success: true, filePath });
assert.match(fs.readFileSync(filePath, "utf8"), /fresh output/);
assert.equal(manager.hasStream(sessionId), true);
assert.deepEqual(await reveal(null, { sessionId, filePath: "/untrusted/path" }), { success: true, filePath });
assert.deepEqual(revealed, [filePath]);
await bridge.stopManualSessionLog(null, { sessionId });
assert.deepEqual(await prepare(null, { sessionId }), { success: true, filePath });
assert.deepEqual(workerRequests, []);
});
test("prepare routes automatic logs only to an already running worker with sessionId only", async () => {
const bridge = loadBridgeWithDialog({});
const requests = [];
const handlers = registerSessionLogTestHandlers(bridge, {
isRunning: () => true,
request: async (...args) => {
requests.push(args);
return { success: true, filePath: "/server-owned/automatic.html" };
},
});
const result = await handlers.get("netcatty:sessionLog:prepare")(null, {
sessionId: "shortcut-auto", filePath: "/arbitrary/renderer/path", directory: "/untrusted",
});
assert.deepEqual(result, { success: true, filePath: "/server-owned/automatic.html" });
assert.deepEqual(requests, [["netcatty:sessionLog:prepareLocal", { sessionId: "shortcut-auto" }, {}]]);
assert.equal(handlers.has("netcatty:sessionLog:prepareLocal"), false, "internal lookup is not renderer IPC");
});
test("prepare rejects malformed ids and never cold-starts an idle worker", async () => {
const bridge = loadBridgeWithDialog({});
let requests = 0;
const worker = { isRunning: () => false, request: async () => { requests++; } };
for (const payload of [undefined, null, {}, { sessionId: "" }, { sessionId: " " }, { sessionId: {} }]) {
assert.deepEqual(await bridge.prepareSessionLog(null, payload, worker), { success: false, error: "Missing sessionId" });
}
assert.deepEqual(await bridge.prepareSessionLog(null, { sessionId: "unknown", filePath: __filename }, worker), {
success: false, error: "No saved log is available for this session",
});
assert.equal(requests, 0);
});
test("prepare and reveal surface missing logs, worker errors, and shell failures", async () => {
const bridge = loadBridgeWithDialog({}, { showItemInFolder: () => { throw new Error("file manager unavailable"); } });
const payload = { sessionId: "shortcut-error" };
const worker = {
isRunning: () => true,
request: async () => { throw new Error("worker unavailable"); },
};
assert.deepEqual(await bridge.prepareSessionLog(null, payload, worker), { success: false, error: "worker unavailable" });
worker.request = async () => ({ success: false, error: "No saved log is available for this session" });
assert.deepEqual(await bridge.revealSessionLog(null, payload, worker), { success: false, error: "No saved log is available for this session" });
worker.request = async () => ({ success: true });
assert.equal((await bridge.prepareSessionLog(null, payload, worker)).success, false);
worker.request = async () => ({ success: true, filePath: "/server-owned/log.txt" });
assert.deepEqual(await bridge.revealSessionLog(null, payload, worker), { success: false, error: "file manager unavailable" });
});
test("worker prepare handler flushes and retains only the requested session's log", async (t) => {
const directory = path.join(TEMP_ROOT, `shortcut-worker-${Date.now()}`);
const filePath = path.join(directory, "worker.txt");
const sessionId = "shortcut-worker";
const bridge = loadBridgeWithDialog({});
const manager = require("./sessionLogStreamManager.cjs");
const handlers = new Map();
bridge.registerWorkerHandlers({ handle: (channel, handler) => handlers.set(channel, handler) });
const prepare = handlers.get("netcatty:sessionLog:prepareLocal");
t.after(async () => {
await manager.cleanupAll();
fs.rmSync(directory, { recursive: true, force: true });
});
manager.startStreamToFile(sessionId, { filePath, format: "txt" });
manager.appendData(sessionId, "worker output\n");
assert.deepEqual(await prepare(null, { sessionId }), { success: true, filePath });
assert.match(fs.readFileSync(filePath, "utf8"), /worker output/);
assert.equal(manager.hasStream(sessionId), true);
assert.equal((await prepare(null, { sessionId: "wrong-session", filePath })).success, false);
await manager.stopStream(sessionId);
assert.deepEqual(await prepare(null, { sessionId }), { success: true, filePath });
});
test("a missing manual log reports an error instead of silently opening another worker log", async (t) => {
const directory = path.join(TEMP_ROOT, `shortcut-missing-${Date.now()}`);
const filePath = path.join(directory, "missing.txt");
const sessionId = "shortcut-missing";
const bridge = loadBridgeWithDialog({});
const manager = require("./sessionLogStreamManager.cjs");
t.after(async () => {
await manager.cleanupAll();
fs.rmSync(directory, { recursive: true, force: true });
});
manager.startStreamToFile(sessionId, { filePath, format: "txt" });
await manager.stopStream(sessionId);
fs.unlinkSync(filePath);
const result = await bridge.prepareSessionLog(null, { sessionId }, {
isRunning: () => true,
request: async () => assert.fail("must not fall back to an unrelated automatic log"),
});
assert.equal(result.success, false);
assert.match(result.error, /ENOENT/);
});
test("disconnect retains a manual log but reconnect clears its precedence over a new automatic log", async (t) => {
const directory = path.join(TEMP_ROOT, `shortcut-reconnect-${Date.now()}`);
const filePath = path.join(directory, "old-manual.txt");
const sessionId = "shortcut-reconnect";
const bridge = loadBridgeWithDialog({});
const manager = require("./sessionLogStreamManager.cjs");
let onClosed;
let onOwned;
const handlers = registerSessionLogTestHandlers(bridge, {
isRunning: () => true,
onSessionClosed: (listener) => { onClosed = listener; },
onSessionOwned: (listener) => { onOwned = listener; },
request: async () => ({ success: true, filePath: "/worker/new-connection.txt" }),
});
t.after(async () => {
await manager.cleanupAll();
fs.rmSync(directory, { recursive: true, force: true });
});
manager.startStreamToFile(sessionId, { filePath, format: "txt" });
await manager.stopStream(sessionId);
const prepare = handlers.get("netcatty:sessionLog:prepare");
onClosed({ sessionId, reason: "exited" });
assert.deepEqual(await prepare(null, { sessionId }), { success: true, filePath });
onOwned({ sessionId, webContentsId: 2 });
assert.deepEqual(await prepare(null, { sessionId }), { success: true, filePath: "/worker/new-connection.txt" });
});
test("a live manual recording keeps its target across disconnect and reconnect", async (t) => {
const directory = path.join(TEMP_ROOT, `shortcut-live-reconnect-${Date.now()}`);
const filePath = path.join(directory, "manual.txt");
const sessionId = "shortcut-live-reconnect";
const bridge = loadBridgeWithDialog({});
const manager = require("./sessionLogStreamManager.cjs");
let onClosed;
let onOwned;
const handlers = registerSessionLogTestHandlers(bridge, {
isRunning: () => true,
onSessionClosed: (listener) => { onClosed = listener; },
onSessionOwned: (listener) => { onOwned = listener; },
request: async () => assert.fail("live manual recording must retain precedence"),
});
t.after(async () => {
await manager.cleanupAll();
fs.rmSync(directory, { recursive: true, force: true });
});
manager.startStreamToFile(sessionId, { filePath, format: "txt" });
manager.appendData(sessionId, "before\n");
onClosed({ sessionId, reason: "exited" });
onOwned({ sessionId, webContentsId: 2 });
manager.appendData(sessionId, "after\n");
const prepare = handlers.get("netcatty:sessionLog:prepare");
assert.deepEqual(await prepare(null, { sessionId }), { success: true, filePath });
assert.match(fs.readFileSync(filePath, "utf8"), /before[\s\S]*after/);
onClosed({ sessionId, explicit: true });
assert.deepEqual(await prepare(null, { sessionId }), { success: true, filePath });
});
test("log menu availability follows the owning stream, not automatic logging settings", async () => {
const bridge = loadBridgeWithDialog({});
const workerHandlers = new Map();
bridge.registerWorkerHandlers({ handle: (channel, handler) => workerHandlers.set(channel, handler) });
const manager = require("./sessionLogStreamManager.cjs");
const workerLogs = new Set();
const handlers = registerSessionLogTestHandlers(bridge, {
isRunning: () => true,
request: async (channel, payload) => {
assert.equal(channel, "netcatty:sessionLog:statusLocal");
return { success: true, hasSessionLog: workerLogs.has(payload.sessionId) };
},
});
const status = handlers.get("netcatty:sessionLog:manualStatus");
for (const sessionId of ["enabled-after-connect", "failed-before-shell"]) {
assert.equal((await status(null, { sessionId, enabled: true })).hasSessionLog, false);
}
workerLogs.add("recording-after-disabled");
assert.deepEqual(await status(null, { sessionId: "recording-after-disabled", enabled: false }), {
success: true, isLogging: false, hasSessionLog: true,
});
assert.equal((await status(null, { sessionId: "another-session" })).hasSessionLog, false);
// A successful replacement without logging retires the worker-owned target.
workerLogs.delete("recording-after-disabled");
assert.equal((await status(null, { sessionId: "recording-after-disabled" })).hasSessionLog, false);
assert.equal(handlers.has("netcatty:sessionLog:statusLocal"), false);
assert.deepEqual(await workerHandlers.get("netcatty:sessionLog:statusLocal")(null, { sessionId: "missing" }), {
success: true, hasSessionLog: false,
});
assert.equal(manager.hasSessionLog("missing"), false);
});
test("log availability retains stopped main logs and never starts a worker to check", async (t) => {
const directory = path.join(TEMP_ROOT, `availability-${Date.now()}`);
const filePath = path.join(directory, "manual.txt");
const sessionId = "availability-main";
const bridge = loadBridgeWithDialog({ showSaveDialog: async () => ({ canceled: false, filePath }) });
const manager = require("./sessionLogStreamManager.cjs");
const handlers = registerSessionLogTestHandlers(bridge, {
isRunning: () => false,
request: () => assert.fail("must not cold-start a worker"),
});
t.after(async () => {
await manager.cleanupAll();
manager.forgetSessionLog(sessionId);
fs.rmSync(directory, { recursive: true, force: true });
});
const status = handlers.get("netcatty:sessionLog:manualStatus");
assert.equal((await status(null, { sessionId })).hasSessionLog, false);
assert.equal((await bridge.startManualSessionLog(null, { sessionId, format: "txt" })).started, true);
assert.deepEqual(await status(null, { sessionId }), { success: true, isLogging: true, hasSessionLog: true });
await bridge.stopManualSessionLog(null, { sessionId });
assert.deepEqual(await status(null, { sessionId }), { success: true, isLogging: false, hasSessionLog: true });
const workerHandlers = new Map();
bridge.registerWorkerHandlers({ handle: (channel, handler) => workerHandlers.set(channel, handler) });
assert.deepEqual(await workerHandlers.get("netcatty:sessionLog:statusLocal")(null, { sessionId }), {
success: true, hasSessionLog: true,
}, "worker lookup also retains stopped logs without requiring an active stream");
});
test("an unavailable worker does not offer a nonexistent log or change manual recording status", async () => {
const bridge = loadBridgeWithDialog({});
for (const request of [
async () => { throw new Error("worker stopped"); },
async () => ({ success: false, error: "session missing" }),
]) {
const handlers = registerSessionLogTestHandlers(bridge, { isRunning: () => true, request });
assert.deepEqual(await handlers.get("netcatty:sessionLog:manualStatus")(null, { sessionId: "unavailable" }), {
success: true, isLogging: false, hasSessionLog: false,
});
}
});
+21 -15
View File
@@ -1656,21 +1656,27 @@ async function runRemoteUploadTransaction(client, localPath, remotePath, options
signal,
));
assertCanPromote();
// Apply the old mode to the stage before promotion. A failed chmod must not
// replace an executable final with a non-executable file and report success.
await restoreRemoteMode(client, encodedStagedPath, plan.existingMode, {
bestEffort: false,
});
assertCanPromote();
await runCancelablePreflight(() => assertStagedPromotionTargetSafe(
client,
encodedPath,
remotePath,
plan.destinationExisted,
plan.destinationSnapshot,
signal,
));
assertCanPromote();
// Mode restore is a remote chmod on the stage. Recheck the destination
// only after that round trip. A non-finite existingMode (new files, and
// targets whose mode could not be captured) makes restore a no-op, so a
// second lstat would be a serial RTT on every such upload.
if (Number.isFinite(plan.existingMode)) {
// Apply the old mode to the stage before promotion. A failed chmod must not
// replace an executable final with a non-executable file and report success.
await restoreRemoteMode(client, encodedStagedPath, plan.existingMode, {
bestEffort: false,
});
assertCanPromote();
await runCancelablePreflight(() => assertStagedPromotionTargetSafe(
client,
encodedPath,
remotePath,
plan.destinationExisted,
plan.destinationSnapshot,
signal,
));
assertCanPromote();
}
commitPromotion();
await renameRemotePath(client, encodedStagedPath, encodedPath, encodedBackupPath, {
stagePath: stagedLogical,
@@ -571,6 +571,104 @@ test("existing destinations stage then restore mode after rename", async (t) =>
assert.equal(remoteMeta.get("/usr/local/bin/tool")?.mode & 0o777, 0o755);
});
test("new-file staged upload lstats the destination once before rename", async (t) => {
const tempRoot = await fs.promises.mkdtemp(path.join(os.tmpdir(), "netcatty-new-file-lstat-"));
t.after(async () => {
await fs.promises.rm(tempRoot, { recursive: true, force: true });
});
tempDirBridge.init?.({ getPath: () => tempRoot });
const localPath = path.join(tempRoot, "payload.bin");
const payload = Buffer.from("new-file");
await fs.promises.writeFile(localPath, payload);
const remotePath = "/home/alice/new-file.bin";
const { channel, remoteFiles, chmodCalls } = createSessionChannel();
const originalLstat = channel.lstat.bind(channel);
let destinationLstats = 0;
channel.lstat = (targetPath, callback) => {
if (String(targetPath) === remotePath) destinationLstats += 1;
originalLstat(targetPath, callback);
};
const sftpClients = new Map();
sftpBridge.init({
electronModule: { webContents: { fromId: () => null } },
sessions: new Map([["session-new-file-lstat", { conn: { sftp: (cb) => cb(null, channel) } }]]),
sftpClients,
});
const opened = await sftpBridge.openSftpForSession(null, {
sessionId: "session-new-file-lstat",
fileProtocol: "sftp",
});
const result = await sftpBridge.uploadLocalToSftp(null, {
sftpId: opened.sftpId,
localPath,
remotePath,
encoding: "utf-8",
});
assert.equal(result.success, true);
assert.deepEqual(remoteFiles.get(remotePath), payload);
assert.deepEqual(chmodCalls, []);
// Plan lstat plus one pre-rename check. existingMode is null, so the
// post-restore lstat would be a no-op round trip.
assert.equal(destinationLstats, 2);
});
test("existing-file staged upload rechecks the destination after mode restore", async (t) => {
const tempRoot = await fs.promises.mkdtemp(path.join(os.tmpdir(), "netcatty-existing-file-lstat-"));
t.after(async () => {
await fs.promises.rm(tempRoot, { recursive: true, force: true });
});
tempDirBridge.init?.({ getPath: () => tempRoot });
const localPath = path.join(tempRoot, "tool.bin");
const payload = Buffer.from("replaced-tool");
await fs.promises.writeFile(localPath, payload);
const remotePath = "/usr/local/bin/tool";
const { channel, remoteFiles, remoteMeta, chmodCalls } = createSessionChannel();
remoteFiles.set(remotePath, Buffer.from("old-tool"));
remoteMeta.set(remotePath, { mode: 0o100755, mtime: 1, uid: 1000, gid: 1000 });
const originalLstat = channel.lstat.bind(channel);
let destinationLstats = 0;
channel.lstat = (targetPath, callback) => {
if (String(targetPath) === remotePath) destinationLstats += 1;
originalLstat(targetPath, callback);
};
const sftpClients = new Map();
sftpBridge.init({
electronModule: { webContents: { fromId: () => null } },
sessions: new Map([["session-existing-file-lstat", { conn: { sftp: (cb) => cb(null, channel) } }]]),
sftpClients,
});
const opened = await sftpBridge.openSftpForSession(null, {
sessionId: "session-existing-file-lstat",
fileProtocol: "sftp",
});
const result = await sftpBridge.uploadLocalToSftp(null, {
sftpId: opened.sftpId,
localPath,
remotePath,
encoding: "utf-8",
});
assert.equal(result.success, true);
assert.deepEqual(remoteFiles.get(remotePath), payload);
assert.equal(remoteMeta.get(remotePath)?.mode & 0o777, 0o755);
assert.ok(
chmodCalls.some((call) => String(call.targetPath).includes(".netcatty-upload-")),
`expected staged chmod, got ${JSON.stringify(chmodCalls)}`,
);
// Plan lstat, pre-restore check, and the post-chmod recheck that covers the
// mode-restore round trip.
assert.equal(destinationLstats, 3);
});
test("mode restore failure leaves the existing destination untouched", async (t) => {
const tempRoot = await fs.promises.mkdtemp(path.join(os.tmpdir(), "netcatty-stage-mode-fail-"));
t.after(async () => {
+3 -1
View File
@@ -1625,7 +1625,9 @@ function registerHandlers(ipcMain, options = {}) {
});
});
} else {
ipcMain.handle("netcatty:start", startSSHSessionWrapper);
ipcMain.handle("netcatty:start", options.manageSessionLogRestart === true
? sessionLogStreamManager.withSessionLogRestart(startSSHSessionWrapper)
: startSSHSessionWrapper);
ipcMain.handle("netcatty:ssh:exec", execCommand);
ipcMain.handle("netcatty:ssh:pwd", getSessionPwd);
ipcMain.handle("netcatty:ssh:remoteInfo", getSessionRemoteInfo);
@@ -0,0 +1,43 @@
"use strict";
// Read whole hardware-backed block devices only. Partitions live below these
// entries; dm/md/loop/zram layers have no device link and would double-count I/O.
// Linux block-stat sectors are always 512 bytes, regardless of device sector size.
// No iostat dependency, extra exec, sampling sleep, or privileged access is needed.
const DISK_IO_ASSIGNMENT = String.raw`diskio=$(if [ -r /proc/uptime ]; then awk '{printf "%s;",$1}' /proc/uptime; for d in /sys/block/*; do [ -e "$d/device" ] && [ -r "$d/stat" ] || continue; awk -v path="$d" 'NF>=7 {n=split(path,a,"/");printf "%s:%s:%s,",a[n],$3,$7}' "$d/stat"; done; fi)`;
function parseDiskIo(text) {
if (typeof text !== "string") return null;
const [time, counters, ...extra] = text.trim().split(";");
const timestamp = Number(time);
if (!time || extra.length || !Number.isFinite(timestamp) || timestamp < 0 || !counters) return null;
const devices = new Map();
for (const entry of counters.replace(/,$/, "").split(",")) {
const fields = entry.split(":");
const [name, read, write] = fields;
if (fields.length !== 3 || !/^[a-zA-Z0-9_.!-]+$/.test(name) || devices.has(name) || !/^\d+$/.test(read) || !/^\d+$/.test(write)) return null;
const readSectors = Number(read);
const writeSectors = Number(write);
if (!Number.isSafeInteger(readSectors) || !Number.isSafeInteger(writeSectors)) return null;
devices.set(name, { readSectors, writeSectors });
}
return { timestamp, devices };
}
function diskIoRates(current, previous) {
const unavailable = { diskReadSpeed: null, diskWriteSpeed: null };
if (!current || !previous || current.devices.size !== previous.devices.size) return unavailable;
const seconds = current.timestamp - previous.timestamp;
if (!(seconds > 0)) return unavailable;
let read = 0;
let write = 0;
for (const [name, counters] of current.devices) {
const before = previous.devices.get(name);
if (!before || counters.readSectors < before.readSectors || counters.writeSectors < before.writeSectors) return unavailable;
read += (counters.readSectors - before.readSectors) * 512;
write += (counters.writeSectors - before.writeSectors) * 512;
}
return { diskReadSpeed: read / seconds, diskWriteSpeed: write / seconds };
}
module.exports = { DISK_IO_ASSIGNMENT, parseDiskIo, diskIoRates };
@@ -0,0 +1,51 @@
const test = require("node:test");
const assert = require("node:assert/strict");
const { mkdtempSync, mkdirSync, writeFileSync, rmSync } = require("node:fs");
const { tmpdir } = require("node:os");
const { join } = require("node:path");
const { execFileSync } = require("node:child_process");
const { DISK_IO_ASSIGNMENT, parseDiskIo, diskIoRates } = require("./monitorDiskIo.cjs");
const unknown = { diskReadSpeed: null, diskWriteSpeed: null };
test("disk rates use remote fractional sample time and fixed 512-byte sectors", () => {
const before = parseDiskIo("100.25;sda:100:200,nvme0n1:50:100,");
const after = parseDiskIo("101.75;nvme0n1:80:140,sda:130:220,");
assert.deepEqual(diskIoRates(after, before), { diskReadSpeed: 20480, diskWriteSpeed: 20480 });
assert.deepEqual(diskIoRates(parseDiskIo("102.75;nvme0n1:80:140,sda:130:220,"), after), { diskReadSpeed: 0, diskWriteSpeed: 0 });
});
test("first sample, unavailable counters, reset, and topology changes never look idle", () => {
const before = parseDiskIo("100;sda:100:200,");
assert.deepEqual(diskIoRates(before, null), unknown);
assert.deepEqual(diskIoRates(null, before), unknown);
for (const text of ["100;sda:110:210", "99;sda:110:210", "101;sda:99:210", "101;sda:110:199", "101;sdb:110:210", "101;sda:110:210,sdb:0:0"]) {
assert.deepEqual(diskIoRates(parseDiskIo(text), before), unknown, text);
}
for (const text of [undefined, "", "100;", "NaN;sda:1:2", "100;sda:-1:2", "100;sda:1:NaN", "100;sda:1:2,sda:1:2", "100;sda:9007199254740992:1", "100;sda:1:2:3", "100;sda:1:2;extra"]) {
assert.equal(parseDiskIo(text), null, String(text));
}
});
test("Linux collection includes whole hardware devices, excludes partitions and stacked devices", () => {
const dir = mkdtempSync(join(tmpdir(), "disk-io-"));
try {
const block = join(dir, "block");
mkdirSync(block);
writeFileSync(join(dir, "uptime"), "123.45 100.00\n");
for (const name of ["sda", "nvme0n1", "dm-0", "md0", "loop0", "zram0"]) {
mkdirSync(join(block, name));
writeFileSync(join(block, name, "stat"), "1 2 100 4 5 6 200 8 9 10 11\n");
if (["sda", "nvme0n1"].includes(name)) mkdirSync(join(block, name, "device"));
}
mkdirSync(join(block, "sda", "sda1"));
writeFileSync(join(block, "sda", "sda1", "stat"), "1 2 100 4 5 6 200\n");
const command = DISK_IO_ASSIGNMENT.replaceAll("/proc/uptime", join(dir, "uptime")).replaceAll("/sys/block", block);
const output = execFileSync("sh", ["-c", `${command}; printf '%s' "$diskio"`], { encoding: "utf8" });
const parsed = parseDiskIo(output);
assert.equal(parsed.timestamp, 123.45);
assert.deepEqual([...parsed.devices.keys()].sort(), ["nvme0n1", "sda"]);
assert.deepEqual(parsed.devices.get("sda"), { readSectors: 100, writeSectors: 200 });
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
+10 -1
View File
@@ -1,4 +1,5 @@
/* eslint-disable no-undef */
const { DISK_IO_ASSIGNMENT, parseDiskIo, diskIoRates } = require("./monitorDiskIo.cjs");
const { sampleSharedStats } = require("./sharedStats.cjs");
const { GPU_ASSIGNMENT, parseGpuStats, withCachedMonitorGpu, cacheFullGpuStats, startMonitorGpuProbe } = require("./monitorGpuStats.cjs");
const { executeBoundedSshCommand } = require("../boundedSshExec.cjs");
@@ -1327,8 +1328,9 @@ function createSessionOpsApi(ctx) {
// polling. Hosts without timeout also omit this optional metric.
// macOS is skipped: powermetrics needs root, which stats must not.
GPU_ASSIGNMENT,
DISK_IO_ASSIGNMENT,
// Output all stats (using CPURAW and PERCORERAW instead of CPU and PERCORE)
`echo "CPURAW:$cpuraw|CORES:$cores|PERCORERAW:$percoreraw|MEMINFO:$meminfo|PROCS:$procs|NET:$net|GPU:$gpustat|HOST:$hostname_value|OS:$osname|KERNEL:$kernel|UPTIME:$uptime|LOAD:$loadavg"`
`echo "CPURAW:$cpuraw|CORES:$cores|PERCORERAW:$percoreraw|MEMINFO:$meminfo|PROCS:$procs|NET:$net|GPU:$gpustat|DISKIO:$diskio|HOST:$hostname_value|OS:$osname|KERNEL:$kernel|UPTIME:$uptime|LOAD:$loadavg"`
].filter(command => !session._monitorStats || (!command.startsWith('procs=') && !command.startsWith('gpustat='))).join('; ');
// Client-side run bound shared by the stats and disk probes; the remote
@@ -1457,6 +1459,7 @@ function createSessionOpsApi(ctx) {
let swapUsed = null;
let topProcesses = []; // Array of { pid, memPercent, command }
let disks = []; // Array of { mountPoint, used, total, percent }
let diskIo = null;
let networkInterfaces = []; // Array of { name, rxBytes, txBytes }
let hostname = "";
let osName = "";
@@ -1473,6 +1476,8 @@ function createSessionOpsApi(ctx) {
// macOS: command reports normalized CPU% directly (no delta needed)
const val = parseFloat(part.substring(4).trim());
if (!isNaN(val)) cpuDirect = Math.min(100, Math.max(0, Math.round(val)));
} else if (part.startsWith('DISKIO:')) {
diskIo = parseDiskIo(part.substring(7));
} else if (part.startsWith('CPURAW:')) {
const rawParts = part.substring(7).trim().split(/\s+/);
if (rawParts.length >= 2) {
@@ -1619,6 +1624,9 @@ function createSessionOpsApi(ctx) {
}
}
const diskRates = diskIoRates(diskIo, session.prevDiskIo);
session.prevDiskIo = diskIo;
// Calculate network speed based on previous reading
const now = Date.now();
const prevNet = session.prevNetStats || { interfaces: [], timestamp: 0 };
@@ -1761,6 +1769,7 @@ function createSessionOpsApi(ctx) {
diskUsed, // Disk used in GB for root partition (backward compat)
diskTotal, // Total disk in GB for root partition (backward compat)
disks, // Array of all mounted disks
...diskRates, // Aggregate physical-device I/O; null until two valid Linux samples
networkReady: prevNet.timestamp > 0 && prevNet.interfaces.length > 0,
netRxSpeed, // Total network receive speed (bytes/sec)
netTxSpeed, // Total network transmit speed (bytes/sec)
@@ -1530,3 +1530,33 @@ test("monitor CPU samples return while an optional GPU driver is still blocked",
await pending;
}
});
test("shared server stats parse Linux disk I/O across samples and leave macOS unavailable", async (t) => {
let now = 100000;
t.mock.method(Date, "now", () => now);
let payload = `${LINUX_STATS}|DISKIO:10.5;sda:100:200,`;
const conn = { exec(command, cb) { cb(null, fakeStream(selectServerStatsFixtureOutput(command, payload))); } };
const session = { conn, type: "ssh", _monitorStats: true, _monitorIntervalMs: 1000 };
const api = makeSessionOps(new Map([["diskio", session]]));
const get = () => api.getServerStats({ sender: {} }, { sessionId: "diskio" });
let result = await get();
assert.equal(result.success, true);
assert.equal(result.stats.diskReadSpeed, null);
assert.equal(result.stats.diskWriteSpeed, null);
now += 1000;
payload = `${LINUX_STATS}|DISKIO:11.5;sda:120:240,`;
result = await get();
assert.equal(result.stats.diskReadSpeed, 10240);
assert.equal(result.stats.diskWriteSpeed, 20480);
now += 1000;
payload = `${LINUX_STATS}|DISKIO:12.5;sda:120:240,`;
result = await get();
assert.equal(result.stats.diskReadSpeed, 0);
assert.equal(result.stats.diskWriteSpeed, 0);
now += 1000;
payload = MACOS_STATS;
result = await get();
assert.equal(result.success, true);
assert.equal(result.stats.diskReadSpeed, null);
assert.equal(result.stats.diskWriteSpeed, null);
});
@@ -83,6 +83,7 @@ async function sampleSharedStats(session, collect, now = Date.now()) {
const companion = session.moshStatsConn || session.etStatsConn;
if (companion && result.success) {
const state = stateFor(companion);
state.context.prevDiskIo = session.prevDiskIo;
state.context.prevNetStats = session.prevNetStats;
state.context.prevCpuStats = session.prevCpuStats;
state.history.latest = result;
@@ -93,3 +93,35 @@ test('a snapshot read before sampling still joins the existing endpoint history'
assert.equal(readSharedStats(monitor).points, readSharedStats(terminal).points);
assert.equal(readSharedStats(monitor).result.stats.cpu, 42);
});
test('one-second requests never overlap and keep disk baselines on the physical transport', async () => {
resetSharedStatsForTests();
const conn = {};
let release;
let calls = 0;
const contexts = [];
const collect = context => {
calls++;
contexts.push(context);
const previous = context.prevDiskIo;
context.prevDiskIo = { timestamp: calls };
return new Promise(resolve => { release = () => resolve({ success: true, stats: { diskReadSpeed: previous ? 512 : null, diskWriteSpeed: previous ? 1024 : null } }); });
};
const session = { conn, _monitorStats: true, _monitorIntervalMs: 1000 };
const first = sampleSharedStats(session, collect, 1000);
const overlapping = sampleSharedStats(session, collect, 2000);
assert.equal(calls, 1);
release();
const [a, b] = await Promise.all([first, overlapping]);
assert.equal(a, b);
assert.equal(a.stats.diskReadSpeed, null);
const next = sampleSharedStats(session, collect, Date.now() + 1001);
assert.equal(calls, 2);
release();
assert.equal((await next).stats.diskReadSpeed, 512);
const reconnect = sampleSharedStats({ ...session, conn: {} }, collect, Date.now() + 2002);
assert.equal(calls, 3);
assert.notEqual(contexts[0], contexts[2]);
release();
assert.equal((await reconnect).stats.diskReadSpeed, null);
});
+8 -5
View File
@@ -2639,11 +2639,14 @@ function registerHandlers(ipcMain, options = {}) {
});
return;
}
ipcMain.handle("netcatty:local:start", startLocalSession);
ipcMain.handle("netcatty:telnet:start", startTelnetSession);
ipcMain.handle("netcatty:mosh:start", startMoshSession);
ipcMain.handle("netcatty:et:start", startEtSession);
ipcMain.handle("netcatty:serial:start", startSerialSession);
const wrapSessionStart = options.manageSessionLogRestart === true
? sessionLogStreamManager.withSessionLogRestart
: (handler) => handler;
ipcMain.handle("netcatty:local:start", wrapSessionStart(startLocalSession));
ipcMain.handle("netcatty:telnet:start", wrapSessionStart(startTelnetSession));
ipcMain.handle("netcatty:mosh:start", wrapSessionStart(startMoshSession));
ipcMain.handle("netcatty:et:start", wrapSessionStart(startEtSession));
ipcMain.handle("netcatty:serial:start", wrapSessionStart(startSerialSession));
ipcMain.handle("netcatty:serial:list", listSerialPorts);
ipcMain.handle("netcatty:serial:ymodem-send", sendSerialYmodem);
ipcMain.handle("netcatty:serial:ymodem-receive", receiveSerialYmodem);
@@ -360,3 +360,37 @@ test("terminal worker mode proxies system management requests", async () => {
],
);
});
test("fallback log restart guard wraps all six starts only when main explicitly enables it", (t) => {
const manager = require("./sessionLogStreamManager.cjs");
const wrappers = new Map();
t.mock.method(manager, "withSessionLogRestart", (handler) => {
const wrapped = () => {};
wrappers.set(handler, wrapped);
return wrapped;
});
const workerHandlers = createFakeIpcMain();
sshBridge.registerHandlers(workerHandlers);
terminalBridge.registerHandlers(workerHandlers);
assert.equal(wrappers.size, 0, "utility-process registration keeps the runtime generation guard");
const fallbackHandlers = createFakeIpcMain();
sshBridge.registerHandlers(fallbackHandlers, { manageSessionLogRestart: true });
terminalBridge.registerHandlers(fallbackHandlers, { manageSessionLogRestart: true });
const startChannels = [
"netcatty:start", "netcatty:local:start", "netcatty:telnet:start",
"netcatty:mosh:start", "netcatty:et:start", "netcatty:serial:start",
];
assert.equal(wrappers.size, startChannels.length);
for (const channel of startChannels) {
assert.equal(fallbackHandlers.handlers.get(channel), wrappers.get(workerHandlers.handlers.get(channel)), channel);
}
assert.equal(fallbackHandlers.handlers.get("netcatty:serial:list"), workerHandlers.handlers.get("netcatty:serial:list"));
wrappers.clear();
const proxyHandlers = createFakeIpcMain();
const terminalWorkerManager = createFakeWorkerManager();
sshBridge.registerHandlers(proxyHandlers, { terminalWorkerManager, manageSessionLogRestart: true });
terminalBridge.registerHandlers(proxyHandlers, { terminalWorkerManager, manageSessionLogRestart: true });
assert.equal(wrappers.size, 0, "worker proxies must not also apply the main fallback guard");
});
+50 -7
View File
@@ -1596,7 +1596,10 @@ let sftpClients = null;
// Retry is same-process — a relaunch with no fingerprint already restarts at 0.
// Entries stay only while that task can still resume from the checkpoint.
const localUploadAcknowledgedPrefix = new Map();
const UPLOAD_PREFIX_SESSION_LOSS = /SFTP (channel closed|session lost)|ECONNRESET|EPIPE|Not connected|No response from server|Keepalive timeout|connection (lost|closed|reset)/i;
// Keep identical to SESSION_LOSS_ERROR in application/state/sftp/retryFromCheckpoint.ts.
// Both must accept every code or message isSftpChannelLossError treats as channel
// loss, or a same-id Retry drops this digest and restarts at byte 0.
const UPLOAD_PREFIX_SESSION_LOSS = /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;
function forgetLocalUploadAcknowledgedPrefix(transferId) {
if (transferId) localUploadAcknowledgedPrefix.delete(String(transferId));
@@ -1641,9 +1644,20 @@ function localUploadAcknowledgedOffset(transferId) {
return entry.digestOffset;
}
function localUploadPrefixFailureKeepsCheckpoint(transferId, message) {
return localUploadAcknowledgedOffset(transferId) > 0
&& UPLOAD_PREFIX_SESSION_LOSS.test(String(message || ""));
function isTransferChannelLossError(error) {
if (!error || typeof error !== "object") return false;
if (isSftpChannelLossError(error)) return true;
const cause = error.cause;
return Boolean(cause && typeof cause === "object" && isSftpChannelLossError(cause));
}
function localUploadPrefixFailureKeepsCheckpoint(transferId, error) {
if (!(localUploadAcknowledgedOffset(transferId) > 0)) return false;
// Prefer the bridge classifier (code, level, cause). The shared regex still
// covers string-only failures such as "Not connected" and "SFTP channel closed".
if (isTransferChannelLossError(error)) return true;
const message = typeof error === "string" ? error : (error?.message || String(error || ""));
return UPLOAD_PREFIX_SESSION_LOSS.test(message);
}
async function localUploadAcknowledgedPrefixMatches(transferId, sourcePath, checkpoint, sourceSize, options) {
@@ -2051,6 +2065,23 @@ function isStaleOrClosedSharedChannelError(error, sftp) {
return /channel closed/i.test(String(error.message || ""));
}
// Test seam: shorten channel-open timeout / abandon wait. Production leaves this null.
let isolatedSftpChannelOpenOptionsForTests = null;
function isolatedChannelOpenOptions(signal) {
const options = { signal };
const overrides = isolatedSftpChannelOpenOptionsForTests;
if (!overrides || typeof overrides !== "object") return options;
if (overrides.timeoutMs !== undefined) options.timeoutMs = overrides.timeoutMs;
if (overrides.abandonWaitMs !== undefined) options.abandonWaitMs = overrides.abandonWaitMs;
return options;
}
function isPoisonedSftpChannelOpenError(error) {
const code = error?.code;
return code === "SFTP_CHANNEL_OPEN_TIMEOUT" || code === "SFTP_CHANNEL_OPEN_PENDING";
}
async function openIsolatedSftpChannel(client, signal = null) {
// The dedicated one-channel login already holds its only session channel.
// Opening another SFTP subsystem drops that login, so stay on the browse channel.
@@ -2058,7 +2089,7 @@ async function openIsolatedSftpChannel(client, signal = null) {
return null;
}
const sshClient = client?.client;
return openBoundedSftpChannel(sshClient, { signal });
return openBoundedSftpChannel(sshClient, isolatedChannelOpenOptions(signal));
}
/**
@@ -2698,6 +2729,7 @@ async function uploadFile(
// Prefer an isolated SFTP channel so cancellation cannot kill the browse session.
let isolated = null;
let isolatedOpenError = null;
const preferSharedChannel = Number(fileSize) > 0
&& Number(fileSize) <= SMALL_UPLOAD_SHARED_MAX_BYTES
&& typeof sftp?.open === "function"
@@ -2709,6 +2741,7 @@ async function uploadFile(
);
} catch (err) {
rememberPipelineError(err);
isolatedOpenError = err;
console.warn(
"[transferBridge] Failed to open isolated SFTP channel for upload:",
err.message || String(err),
@@ -2774,7 +2807,12 @@ async function uploadFile(
}
}
if (!isolated && !client.__netcattySudoMode && !preferSharedChannel) {
// A timed-out or still-pending isolated OPEN has already spent the channel
// deadline (or is waiting on an abandoned one). Reopening for fastPut only
// pays another timeout or an immediate PENDING on a lossy link. Fall through
// to the shared browse channel instead (#3155).
const skipIsolatedFastPutReopen = isPoisonedSftpChannelOpenError(isolatedOpenError);
if (!isolated && !skipIsolatedFastPutReopen && !client.__netcattySudoMode && !preferSharedChannel) {
try {
isolated = markDisposableUploadChannel(
await openIsolatedSftpChannel(client, transfer?.signal),
@@ -6412,7 +6450,7 @@ async function startTransferNow(event, payload, onProgress) {
}
cleanupTransfer();
const cancelled = /cancel/i.test(message);
if (cancelled || !localUploadPrefixFailureKeepsCheckpoint(transferId, message)) {
if (cancelled || !localUploadPrefixFailureKeepsCheckpoint(transferId, error)) {
forgetLocalUploadAcknowledgedPrefix(transferId);
}
if (cancelled) {
@@ -8270,6 +8308,11 @@ module.exports = {
_setLocalPrefixHashStreamOverrideForTests: (override) => {
localPrefixHashStreamOverrideForTests = override;
},
_setIsolatedSftpChannelOpenOptionsForTests: (options) => {
isolatedSftpChannelOpenOptionsForTests = options && typeof options === "object"
? options
: null;
},
_hasLocalUploadAcknowledgedPrefixForTests: (transferId) => (
localUploadAcknowledgedPrefix.has(String(transferId))
),
+245
View File
@@ -10,6 +10,7 @@ const { EventEmitter } = require("node:events");
const { PassThrough, Readable, Writable } = require("node:stream");
const transferBridge = require("./transferBridge.cjs");
const { openBoundedSftpChannel } = require("./boundedSftpOpen.cjs");
// Most download tests here drive the session slot / isolated pool with tiny
// fixture files. Keep them on that path; the small-file exemption has its own
// test below that restores the production threshold.
@@ -14754,3 +14755,247 @@ test("small download does not open a fresh channel for ENOENT or EACCES", async
assert.match(denied.error || "", /Permission denied|EACCES/);
assert.equal(isolatedOpens, 0);
});
test("channel-open timeout skips the fastPut reopen and uses the shared channel", async (t) => {
// Lossy links abandon an isolated OPEN. Reopening for fastPut only waits out
// the abandon gate and pays another timeout (or PENDING). The body must move
// to the shared browse channel after the first poisoned open.
const tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "netcatty-sftp-open-poison-upload-"));
t.after(async () => {
transferBridge._setIsolatedSftpChannelOpenOptionsForTests(null);
await fs.promises.rm(tempDir, { recursive: true, force: true });
});
transferBridge._setIsolatedSftpChannelOpenOptionsForTests({
timeoutMs: 20,
abandonWaitMs: 30,
});
const localPath = path.join(tempDir, "fresh.bin");
const payload = Buffer.concat([
Buffer.from("open-poison"),
Buffer.alloc(SMALL_UPLOAD_SHARED_MAX_BYTES),
]);
await fs.promises.writeFile(localPath, payload);
const targetPath = "/tmp/open-poison.bin";
const remoteFiles = new Map();
const lateChannels = [];
let isolatedOpens = 0;
let fastPutCalls = 0;
let sharedWrites = 0;
const rememberFile = (sftp) => {
sftp.lstat = (remotePath, callback) => {
const key = String(remotePath);
if (!remoteFiles.has(key)) {
const error = new Error("ENOENT");
error.code = 2;
callback(error);
return;
}
callback(null, {
size: remoteFiles.get(key).length,
mode: 0o100644,
isDirectory: () => false,
isSymbolicLink: () => false,
});
};
sftp.open = (remotePath, _flags, callback) => {
const key = String(remotePath);
if (!remoteFiles.has(key)) remoteFiles.set(key, Buffer.alloc(0));
callback(null, Buffer.from(key));
};
sftp.close = (_handle, callback) => callback(null);
};
const shared = createFastSftp({});
rememberFile(shared);
shared.write = (handle, buffer, offset, length, position, callback) => {
sharedWrites += 1;
const key = handle.toString();
const next = Buffer.alloc(Math.max(remoteFiles.get(key)?.length || 0, position + length));
if (remoteFiles.has(key)) remoteFiles.get(key).copy(next);
buffer.copy(next, position, offset, offset + length);
remoteFiles.set(key, next);
setImmediate(() => callback(null));
};
shared.fastPut = () => {
fastPutCalls += 1;
throw new Error("shared fastPut must not run after a poisoned channel open");
};
const client = {
sftp: shared,
stat: async (remotePath) => {
const key = String(remotePath);
if (!remoteFiles.has(key)) {
const error = new Error("ENOENT");
error.code = 2;
throw error;
}
return { size: remoteFiles.get(key).length, isDirectory: false };
},
delete: async (remotePath) => { remoteFiles.delete(String(remotePath)); },
async rename(fromPath, toPath) {
remoteFiles.set(String(toPath), remoteFiles.get(String(fromPath)));
remoteFiles.delete(String(fromPath));
},
client: {
sftp(callback) {
isolatedOpens += 1;
const late = createFastSftp({
fastPut() {
fastPutCalls += 1;
throw new Error("fastPut reopen must not run after channel-open timeout");
},
end() { late.endCalls += 1; },
close() { late.closeCalls += 1; },
destroy() { late.destroyCalls += 1; },
});
late.endCalls = 0;
late.closeCalls = 0;
late.destroyCalls = 0;
lateChannels.push({ callback, late });
},
},
};
transferBridge.init({ sftpClients: new Map([["target", client]]) });
const result = await transferBridge.startTransfer({ sender: createSender() }, {
transferId: "upload-channel-open-timeout-shared",
sourcePath: localPath,
targetPath,
sourceType: "local",
targetType: "sftp",
targetSftpId: "target",
totalBytes: payload.length,
resumable: false,
skipAdmission: true,
});
assert.equal(result.error, undefined, result.error);
assert.equal(isolatedOpens, 1, "fastPut must not reopen an isolated channel after open timeout");
assert.equal(fastPutCalls, 0);
assert.ok(sharedWrites >= 1);
assert.deepEqual(remoteFiles.get(targetPath), payload);
const abandoned = lateChannels[0];
abandoned.callback(null, abandoned.late);
assert.ok(
abandoned.late.endCalls > 0 || abandoned.late.closeCalls > 0 || abandoned.late.destroyCalls > 0,
"the abandoned channel is closed when its callback finally arrives",
);
});
test("pending channel-open skips the fastPut reopen and uses the shared channel", async (t) => {
const tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "netcatty-sftp-open-pending-upload-"));
t.after(async () => {
transferBridge._setIsolatedSftpChannelOpenOptionsForTests(null);
await fs.promises.rm(tempDir, { recursive: true, force: true });
});
const localPath = path.join(tempDir, "fresh.bin");
const payload = Buffer.concat([
Buffer.from("open-pending"),
Buffer.alloc(SMALL_UPLOAD_SHARED_MAX_BYTES),
]);
await fs.promises.writeFile(localPath, payload);
const targetPath = "/tmp/open-pending.bin";
const remoteFiles = new Map();
let isolatedOpens = 0;
let sharedWrites = 0;
const warnings = [];
const originalWarn = console.warn;
console.warn = (...args) => {
warnings.push(args.map((part) => String(part)).join(" "));
originalWarn.apply(console, args);
};
t.after(() => { console.warn = originalWarn; });
const rememberFile = (sftp) => {
sftp.lstat = (remotePath, callback) => {
const key = String(remotePath);
if (!remoteFiles.has(key)) {
const error = new Error("ENOENT");
error.code = 2;
callback(error);
return;
}
callback(null, {
size: remoteFiles.get(key).length,
mode: 0o100644,
isDirectory: () => false,
isSymbolicLink: () => false,
});
};
sftp.open = (remotePath, _flags, callback) => {
const key = String(remotePath);
if (!remoteFiles.has(key)) remoteFiles.set(key, Buffer.alloc(0));
callback(null, Buffer.from(key));
};
sftp.close = (_handle, callback) => callback(null);
};
const shared = createFastSftp({});
rememberFile(shared);
shared.write = (handle, buffer, offset, length, position, callback) => {
sharedWrites += 1;
const key = handle.toString();
const next = Buffer.alloc(Math.max(remoteFiles.get(key)?.length || 0, position + length));
if (remoteFiles.has(key)) remoteFiles.get(key).copy(next);
buffer.copy(next, position, offset, offset + length);
remoteFiles.set(key, next);
setImmediate(() => callback(null));
};
const sshClient = new EventEmitter();
sshClient.sftp = (callback) => {
isolatedOpens += 1;
sshClient.pending = callback;
};
const client = {
sftp: shared,
stat: async (remotePath) => {
const key = String(remotePath);
if (!remoteFiles.has(key)) {
const error = new Error("ENOENT");
error.code = 2;
throw error;
}
return { size: remoteFiles.get(key).length, isDirectory: false };
},
delete: async (remotePath) => { remoteFiles.delete(String(remotePath)); },
async rename(fromPath, toPath) {
remoteFiles.set(String(toPath), remoteFiles.get(String(fromPath)));
remoteFiles.delete(String(fromPath));
},
client: sshClient,
};
await assert.rejects(
openBoundedSftpChannel(sshClient, { timeoutMs: 15 }),
(error) => error.code === "SFTP_CHANNEL_OPEN_TIMEOUT",
);
assert.equal(isolatedOpens, 1);
transferBridge._setIsolatedSftpChannelOpenOptionsForTests({ abandonWaitMs: 0 });
transferBridge.init({ sftpClients: new Map([["target", client]]) });
const result = await transferBridge.startTransfer({ sender: createSender() }, {
transferId: "upload-channel-open-pending-shared",
sourcePath: localPath,
targetPath,
sourceType: "local",
targetType: "sftp",
targetSftpId: "target",
totalBytes: payload.length,
resumable: false,
skipAdmission: true,
});
assert.equal(result.error, undefined, result.error);
assert.equal(isolatedOpens, 1, "PENDING must not allocate another isolated OPEN");
assert.equal(
warnings.some((line) => line.includes("Failed to reopen isolated SFTP channel for fastPut")),
false,
);
assert.ok(sharedWrites >= 1);
assert.deepEqual(remoteFiles.get(targetPath), payload);
});
+8 -2
View File
@@ -460,7 +460,10 @@ function createBridgeRegistrar(context) {
tempDirBridge.ensureTempDir();
// Register all IPC handlers
sshBridge.registerHandlers(ipcMain, { terminalWorkerManager });
sshBridge.registerHandlers(ipcMain, {
terminalWorkerManager,
manageSessionLogRestart: !terminalWorkerManager,
});
const stopMonitoring = () => terminalWorkerManager
? terminalWorkerManager.request("netcatty:monitor:stop-all", {}, {}).catch(() => {})
: sshBridge.stopAllMonitoring();
@@ -494,7 +497,10 @@ function createBridgeRegistrar(context) {
});
app.once?.("will-quit", () => hostBrowserManager.closeAll());
}
terminalBridge.registerHandlers(ipcMain, { terminalWorkerManager });
terminalBridge.registerHandlers(ipcMain, {
terminalWorkerManager,
manageSessionLogRestart: !terminalWorkerManager,
});
const scriptBridge = require("../bridges/scriptBridge.cjs");
scriptBridge.init({
+4
View File
@@ -1680,6 +1680,10 @@ function createPreloadApi(ctx) {
ipcRenderer.invoke("netcatty:sessionLog:manualStart", payload),
stopManualSessionLog: (payload) =>
ipcRenderer.invoke("netcatty:sessionLog:manualStop", payload),
prepareSessionLog: (payload) =>
ipcRenderer.invoke("netcatty:sessionLog:prepare", { sessionId: payload.sessionId }),
revealSessionLog: (payload) =>
ipcRenderer.invoke("netcatty:sessionLog:reveal", { sessionId: payload.sessionId }),
getManualSessionLogStatus: (payload) =>
ipcRenderer.invoke("netcatty:sessionLog:manualStatus", payload),
+11
View File
@@ -525,6 +525,9 @@ function createTerminalWorkerRuntime(options = {}) {
const requestedSessionGeneration = requestedSessionId
? (sessionOutputGenerations.get(requestedSessionId) ?? 0)
: null;
const previousSessionLogToken = requestedSessionId
? require("../bridges/sessionLogStreamManager.cjs").getSessionLogToken(requestedSessionId)
: null;
if (requestedSessionId) {
sessionRequestIds.set(requestedSessionId, message.requestId);
sessionLifecycleTombstoneTimes.delete(requestedSessionId);
@@ -556,6 +559,14 @@ function createTerminalWorkerRuntime(options = {}) {
const requestedGenerationStillCurrent = requestedSessionGeneration === null
|| currentGeneration === requestedSessionGeneration;
if (!exitedDuringRequest && requestedGenerationStillCurrent) {
// Failed reconnects retain the previous saved log. After a successful
// start without a replacement log, retire only the old inactive
// target, never a continuing manual or newly-finalized stream.
if (sessionId === requestedSessionId && result.success !== false && !result.error) {
require("../bridges/sessionLogStreamManager.cjs").forgetInactiveSessionLog(
sessionId, previousSessionLogToken,
);
}
sessionRequestIds.set(sessionId, message.requestId);
sessionStartMarkers.add(sessionId);
sessionLifecycleTombstoneTimes.delete(sessionId);
+117
View File
@@ -1532,3 +1532,120 @@ test("runtime contains throws from fire-and-forget send listeners", () => {
{ sessionId: "s1", data: "pwd\r" },
]);
});
test("a same-id restart with auto-save disabled forgets the prior saved-log target", async (t) => {
const fs = require("node:fs");
const path = require("node:path");
const manager = require("../bridges/sessionLogStreamManager.cjs");
const directory = path.join(__dirname, `.tmp-runtime-session-log-${Date.now()}`);
const filePath = path.join(directory, "old.txt");
const sessionId = "runtime-log-reconnect";
t.after(async () => {
await manager.cleanupAll();
fs.rmSync(directory, { recursive: true, force: true });
});
manager.startStreamToFile(sessionId, { filePath, format: "txt" });
manager.appendData(sessionId, "previous connection\n");
await manager.stopStream(sessionId);
assert.equal(await manager.prepareSessionLog(sessionId), filePath);
const parentPort = createParentPort();
const runtime = createTerminalWorkerRuntime({
parentPort,
registerBridges(ipcMain) {
ipcMain.handle("netcatty:start", async (_event, payload) => {
assert.equal(manager.hasSessionLog(payload.sessionId), true, "keep the old target until the replacement succeeds");
return { sessionId: payload.sessionId };
});
},
});
runtime.start();
parentPort.emitMessage({
kind: "request", requestId: "restart-no-log", channel: "netcatty:start",
payload: { sessionId, sessionLog: { enabled: false } }, webContentsId: 7,
});
await new Promise((resolve) => setImmediate(resolve));
assert.ok(parentPort.messages.some((message) => message.requestId === "restart-no-log" && message.result?.sessionId === sessionId));
assert.equal(await manager.prepareSessionLog(sessionId), null);
});
async function createSavedLogRestartFixture(t, name, handler, { active = false } = {}) {
const fs = require("node:fs");
const path = require("node:path");
const manager = require("../bridges/sessionLogStreamManager.cjs");
const directory = path.join(__dirname, `.tmp-runtime-session-log-${name}-${Date.now()}`);
const filePath = path.join(directory, "old.txt");
const sessionId = `runtime-log-${name}`;
t.after(async () => {
await manager.cleanupAll();
fs.rmSync(directory, { recursive: true, force: true });
});
manager.startStreamToFile(sessionId, { filePath, format: "txt", stopRequiresToken: active });
manager.appendData(sessionId, "previous connection\n");
if (!active) await manager.stopStream(sessionId);
const oldToken = manager.getSessionLogToken(sessionId);
const parentPort = createParentPort();
let resolveResponse;
const response = new Promise((resolve) => { resolveResponse = resolve; });
const postMessage = parentPort.postMessage;
parentPort.postMessage = (message) => {
postMessage(message);
if (message.kind === "response" && message.requestId === name) resolveResponse(message);
};
const fixture = { manager, directory, filePath, sessionId, oldToken, parentPort };
const runtime = createTerminalWorkerRuntime({
parentPort,
registerBridges(ipcMain) {
ipcMain.handle("netcatty:start", (event, payload) => handler(fixture, event, payload));
},
});
runtime.start();
parentPort.emitMessage({
kind: "request", requestId: name, channel: "netcatty:start",
payload: { sessionId, sessionLog: { enabled: false } }, webContentsId: 7,
});
return { ...fixture, response: await response };
}
for (const failure of ["throw", "result"]) {
test(`failed reconnect (${failure}) retains the previous saved log`, async (t) => {
const fixture = await createSavedLogRestartFixture(t, `failed-${failure}`, async ({ manager, sessionId, filePath }) => {
assert.equal(await manager.prepareSessionLog(sessionId), filePath);
if (failure === "throw") throw new Error("authentication failed");
return { sessionId, success: false, error: "connection refused" };
});
const { manager, sessionId, filePath, oldToken, response } = fixture;
if (failure === "throw") assert.equal(response.error, "authentication failed");
else assert.equal(response.result.success, false);
assert.equal(manager.getSessionLogToken(sessionId), oldToken);
assert.equal(await manager.prepareSessionLog(sessionId), filePath);
});
}
for (const finalized of [false, true]) {
test(`successful reconnect preserves its new ${finalized ? "finalized" : "active"} log`, async (t) => {
let newPath;
const { manager, sessionId, oldToken, response } = await createSavedLogRestartFixture(t, `new-${finalized}`, async (fixture) => {
newPath = require("node:path").join(fixture.directory, "new.txt");
fixture.manager.startStreamToFile(fixture.sessionId, { filePath: newPath, format: "txt" });
fixture.manager.appendData(fixture.sessionId, "new connection\n");
if (finalized) await fixture.manager.stopStream(fixture.sessionId);
return { sessionId: fixture.sessionId };
});
assert.equal(response.result.sessionId, sessionId);
assert.notEqual(manager.getSessionLogToken(sessionId), oldToken);
assert.equal(manager.hasStream(sessionId), !finalized);
assert.equal(await manager.prepareSessionLog(sessionId), newPath);
});
}
test("successful reconnect leaves an existing active manual recording untouched", async (t) => {
const { manager, sessionId, filePath, oldToken, response } = await createSavedLogRestartFixture(
t, "live-manual", async (fixture) => ({ sessionId: fixture.sessionId }), { active: true },
);
assert.equal(response.result.sessionId, sessionId);
assert.equal(manager.hasStream(sessionId), true);
assert.equal(manager.getSessionLogToken(sessionId), oldToken);
manager.appendData(sessionId, "after reconnect\n");
assert.equal(await manager.prepareSessionLog(sessionId), filePath);
assert.match(require("node:fs").readFileSync(filePath, "utf8"), /previous connection[\s\S]*after reconnect/);
});
+12 -1
View File
@@ -45,8 +45,16 @@ export const STORAGE_KEY_COMMAND_HISTORY = 'netcatty:commandHistory';
/** Set once the user clears all command history; histfile seeding honors it. */
export const STORAGE_KEY_COMMAND_HISTORY_CLEARED = 'netcatty:commandHistoryCleared';
export const STORAGE_KEY_CONNECTION_LOGS = 'netcatty_connection_logs_v1';
/** Side store for unsaved connection-log terminal replay buffers (main blob omits them for perf). */
/**
* Legacy side store for unsaved connection-log terminal replay buffers.
* Read once to migrate into IndexedDB, then removed. Do not write it.
*/
export const STORAGE_KEY_CONNECTION_LOG_TERMINAL_DATA = 'netcatty_connection_log_terminal_data_v1';
/**
* Tiny cross-window signal for the IndexedDB replay side store.
* Value shape: `{ rev: number, ids?: string[], nonce?: string }`.
*/
export const STORAGE_KEY_CONNECTION_LOG_TERMINAL_DATA_REV = 'netcatty_connection_log_terminal_data_rev_v1';
export const STORAGE_KEY_SESSION_RESTORE = 'netcatty_session_restore_v1';
export const STORAGE_KEY_RESTORE_PREVIOUS_SESSION = 'netcatty_restore_previous_session_v1';
export const STORAGE_KEY_RESTORE_TERMINAL_CWD = 'netcatty_restore_terminal_cwd_v1';
@@ -346,3 +354,6 @@ export const STORAGE_KEY_MONITOR_PANELS = 'netcatty_monitor_panels_v1';
export const STORAGE_KEY_MONITOR_TABLE_COLUMNS = 'netcatty_monitor_table_columns_v1';
export const STORAGE_KEY_MONITOR_HIDDEN_COLUMNS = "netcatty_monitor_hidden_columns_v1";
// Preferred application for saved terminal logs (independent of SFTP associations).
export const STORAGE_KEY_SESSION_LOG_APPLICATION = "netcatty.sessionLogApplication";
@@ -0,0 +1,365 @@
import assert from "node:assert/strict";
import { after, beforeEach, test } from "node:test";
import {
loadAll,
migrateLegacyConnectionLogTerminalData,
putMany,
replaceAll,
writeTerminalDataMapDiff,
type ConnectionLogTerminalDataMap,
} from "./connectionLogTerminalDataStore.ts";
type Row = { id: string; data: string };
type FakeState = {
failWrites: boolean;
onGet?: (rows: Map<string, Row>) => void;
openConnections: Set<{ close: () => void; onversionchange: (() => void) | null }>;
};
const fakeState: FakeState = {
failWrites: false,
openConnections: new Set(),
};
const databases = new Map<string, { version: number; stores: Map<string, Map<string, Row>> }>();
const previousIndexedDB = Object.getOwnPropertyDescriptor(globalThis, "indexedDB");
class FakeRequest<T = unknown> {
result: T | undefined;
error: Error | null = null;
onsuccess: ((event?: Event) => void) | null = null;
onerror: ((event?: Event) => void) | null = null;
onupgradeneeded: ((event?: Event) => void) | null = null;
}
function installFakeIndexedDB(): void {
const indexedDB = {
open(name: string, version = 1) {
const request = new FakeRequest<FakeDatabase>();
queueMicrotask(() => {
let record = databases.get(name);
if (!record) {
record = { version: 0, stores: new Map() };
databases.set(name, record);
}
const db = new FakeDatabase(record);
fakeState.openConnections.add(db);
request.result = db;
if (version > record.version) {
record.version = version;
request.onupgradeneeded?.(new Event("upgradeneeded"));
}
request.onsuccess?.(new Event("success"));
});
return request;
},
deleteDatabase(name: string) {
const request = new FakeRequest();
queueMicrotask(() => {
for (const db of [...fakeState.openConnections]) db.onversionchange?.();
databases.delete(name);
request.onsuccess?.(new Event("success"));
});
return request;
},
};
Object.defineProperty(globalThis, "indexedDB", {
value: indexedDB,
configurable: true,
writable: true,
});
}
class FakeDatabase {
onversionchange: (() => void) | null = null;
onclose: (() => void) | null = null;
closed = false;
constructor(private record: { version: number; stores: Map<string, Map<string, Row>> }) {}
objectStoreNames = {
contains: (name: string) => this.record.stores.has(name),
};
createObjectStore(name: string): void {
if (!this.record.stores.has(name)) this.record.stores.set(name, new Map());
}
transaction(storeName: string): FakeTransaction {
const rows = this.record.stores.get(storeName);
if (!rows) throw new Error(`missing store ${storeName}`);
return new FakeTransaction(rows);
}
close(): void {
this.closed = true;
fakeState.openConnections.delete(this);
this.onclose?.();
}
}
class FakeTransaction {
error: Error | null = null;
oncomplete: (() => void) | null = null;
onabort: (() => void) | null = null;
onerror: (() => void) | null = null;
private pending = 0;
private settled = false;
constructor(private rows: Map<string, Row>) {
queueMicrotask(() => this.finish());
}
objectStore(): FakeStore {
return new FakeStore(this.rows, this);
}
abort(): void {
this.error = this.error ?? new Error("aborted");
if (this.settled) return;
this.settled = true;
this.onabort?.();
}
track<T>(produce: () => T, writes = true): FakeRequest<T> {
this.pending += 1;
const request = new FakeRequest<T>();
queueMicrotask(() => {
try {
if (writes && fakeState.failWrites) {
const error = new Error("indexedDB write failed");
this.error = error;
request.error = error;
request.onerror?.(new Event("error"));
this.settled = true;
this.onerror?.();
this.onabort?.();
return;
}
request.result = produce();
request.onsuccess?.(new Event("success"));
} catch (error) {
request.error = error instanceof Error ? error : new Error(String(error));
this.error = request.error;
request.onerror?.(new Event("error"));
this.settled = true;
this.onerror?.();
this.onabort?.();
return;
} finally {
this.pending -= 1;
this.finish();
}
});
return request;
}
private finish(): void {
if (this.settled || this.pending > 0) return;
this.settled = true;
this.oncomplete?.();
}
}
class FakeStore {
constructor(
private rows: Map<string, Row>,
private tx: FakeTransaction,
) {}
getAll(): FakeRequest<Row[]> {
return this.tx.track(() => [...this.rows.values()].map((row) => ({ ...row })), false);
}
get(id: string): FakeRequest<Row | undefined> {
return this.tx.track(() => {
fakeState.onGet?.(this.rows);
const row = this.rows.get(id);
return row ? { ...row } : undefined;
}, false);
}
put(value: Row): FakeRequest<string> {
return this.tx.track(() => {
this.rows.set(value.id, { ...value });
return value.id;
});
}
delete(id: string): FakeRequest<undefined> {
return this.tx.track(() => {
this.rows.delete(id);
return undefined;
});
}
clear(): FakeRequest<undefined> {
return this.tx.track(() => {
this.rows.clear();
return undefined;
});
}
}
installFakeIndexedDB();
const resetStore = async () => {
fakeState.failWrites = false;
fakeState.onGet = undefined;
await new Promise<void>((resolve) => {
const request = indexedDB.deleteDatabase("netcatty_connection_log_terminal_data");
request.onsuccess = () => resolve();
request.onerror = () => resolve();
});
await new Promise((resolve) => setTimeout(resolve, 0));
};
beforeEach(async () => {
await resetStore();
});
after(() => {
if (previousIndexedDB) Object.defineProperty(globalThis, "indexedDB", previousIndexedDB);
else delete (globalThis as { indexedDB?: unknown }).indexedDB;
});
test("putMany, deleteMany, replaceAll, and clear round-trip one row per log id", async () => {
assert.equal(await replaceAll({ a: "one", b: "two" }), true);
assert.deepEqual(await loadAll(), { a: "one", b: "two" });
const diff = await writeTerminalDataMapDiff(
{ a: "one", b: "two" },
{ a: "one", b: "edited", c: "three" },
);
assert.deepEqual(diff, { persisted: true, changed: true });
assert.deepEqual(await loadAll(), { a: "one", b: "edited", c: "three" });
assert.equal(await replaceAll({ only: "left" }), true);
assert.deepEqual(await loadAll(), { only: "left" });
const { clear } = await import("./connectionLogTerminalDataStore.ts");
assert.equal(await clear(), true);
assert.deepEqual(await loadAll(), {});
});
test("writeTerminalDataMapDiff skips indexedDB when the map is unchanged", async () => {
assert.equal(await putMany({ b: "2", a: "1" }), true);
const result = await writeTerminalDataMapDiff(
{ b: "2", a: "1" },
{ a: "1", b: "2" },
);
assert.deepEqual(result, { persisted: true, changed: false });
assert.deepEqual(await loadAll(), { a: "1", b: "2" });
});
test("migration inserts legacy rows without clearing a replay committed after the empty read", async () => {
fakeState.onGet = (rows) => {
rows.set("concurrent", { id: "concurrent", data: "new-window" });
fakeState.onGet = undefined;
};
let removed = false;
const loaded = await migrateLegacyConnectionLogTerminalData({
read: () => ({ "log-old": "legacy" }),
remove: () => {
removed = true;
},
});
assert.equal(removed, true);
assert.equal(loaded.durable, true);
assert.equal(loaded.map.concurrent, "new-window");
assert.equal(loaded.map["log-old"], "legacy");
assert.deepEqual(await loadAll(), { concurrent: "new-window", "log-old": "legacy" });
});
test("migration copies a legacy map into indexedDB and removes it only after commit", async () => {
let removed = false;
const legacy: ConnectionLogTerminalDataMap = { "log-new": "replay", "log-old": "older" };
const loaded = await migrateLegacyConnectionLogTerminalData({
read: () => legacy,
remove: () => {
removed = true;
},
});
assert.equal(removed, true);
assert.equal(loaded.migrated, true);
assert.deepEqual(loaded.map, legacy);
assert.deepEqual(await loadAll(), legacy);
});
test("migration keeps indexedDB rows and copies legacy ids that are not stored yet", async () => {
assert.equal(await putMany({ kept: "from-idb" }), true);
let removed = false;
const loaded = await migrateLegacyConnectionLogTerminalData({
read: () => ({ kept: "stale-legacy", missing: "from-legacy" }),
remove: () => {
removed = true;
},
});
assert.equal(removed, true);
assert.equal(loaded.migrated, true);
assert.equal(loaded.durable, true);
assert.deepEqual(loaded.map, { kept: "from-idb", missing: "from-legacy" });
assert.deepEqual(await loadAll(), { kept: "from-idb", missing: "from-legacy" });
});
test("migration keeps the legacy key when merging missing rows fails", async () => {
assert.equal(await putMany({ kept: "from-idb" }), true);
fakeState.failWrites = true;
let removed = false;
const loaded = await migrateLegacyConnectionLogTerminalData({
read: () => ({ missing: "from-legacy" }),
remove: () => {
removed = true;
},
});
assert.equal(removed, false);
assert.equal(loaded.migrated, false);
assert.equal(loaded.durable, false);
assert.equal(loaded.map.missing, "from-legacy");
assert.equal(loaded.map.kept, "from-idb");
fakeState.failWrites = false;
assert.deepEqual(await loadAll(), { kept: "from-idb" });
});
test("migration keeps the legacy key when the indexedDB replace fails", async () => {
let removed = false;
fakeState.failWrites = true;
const loaded = await migrateLegacyConnectionLogTerminalData({
read: () => ({ keep: "legacy" }),
remove: () => {
removed = true;
},
});
assert.equal(removed, false);
assert.equal(loaded.migrated, false);
assert.deepEqual(loaded.map, { keep: "legacy" });
fakeState.failWrites = false;
assert.deepEqual(await loadAll(), {});
});
test("store methods soft-fail when indexedDB is unavailable", async () => {
const descriptor = Object.getOwnPropertyDescriptor(globalThis, "indexedDB");
Object.defineProperty(globalThis, "indexedDB", {
value: undefined,
configurable: true,
writable: true,
});
try {
assert.deepEqual(await loadAll(), {});
assert.equal(await putMany({ a: "x" }), false);
assert.equal(await replaceAll({ a: "x" }), false);
const { deleteMany, clear } = await import("./connectionLogTerminalDataStore.ts");
assert.equal(await deleteMany(["a"]), false);
assert.equal(await clear(), false);
} finally {
if (descriptor) Object.defineProperty(globalThis, "indexedDB", descriptor);
}
});
@@ -0,0 +1,371 @@
import {
diffTerminalDataMaps,
type ConnectionLogTerminalDataMap,
} from "../../domain/connectionLogTerminalData.ts";
export const CONNECTION_LOG_TERMINAL_DATA_DB_NAME = "netcatty_connection_log_terminal_data";
export const CONNECTION_LOG_TERMINAL_DATA_STORE_NAME = "entries";
const DB_VERSION = 1;
export type ConnectionLogTerminalDataRev = {
rev: number;
/** Log ids currently in the side store. */
ids?: string[];
/**
* Distinguishes two writes that would otherwise share `rev` and `ids`
* in the same millisecond, so the other window still observes a change.
*/
nonce?: string;
};
export type LegacyConnectionLogTerminalDataMigration = {
read: () => ConnectionLogTerminalDataMap | null;
remove: () => void;
};
export type MigratedConnectionLogTerminalData = {
map: ConnectionLogTerminalDataMap;
migrated: boolean;
/**
* False when `map` contains replay rows that are not committed in IndexedDB.
* Callers must not use that map as a diff baseline until a later write commits.
*/
durable: boolean;
};
type TerminalDataRow = {
id: string;
data: string;
};
let cachedDatabase: IDBDatabase | null = null;
let openingDatabase: Promise<IDBDatabase | null> | null = null;
const warnStoreFailure = (message: string, error: unknown): void => {
console.warn(`[connectionLogTerminalDataStore] ${message}`, error);
};
const forgetDatabase = (): void => {
const db = cachedDatabase;
cachedDatabase = null;
openingDatabase = null;
try {
db?.close();
} catch {
// The connection is already closing.
}
};
const transactionComplete = (tx: IDBTransaction): Promise<void> => new Promise((resolve, reject) => {
let settled = false;
const finish = (fn: () => void) => {
if (settled) return;
settled = true;
fn();
};
tx.oncomplete = () => finish(() => resolve());
tx.onabort = () => finish(() => reject(tx.error ?? new Error("IndexedDB transaction aborted")));
tx.onerror = () => finish(() => reject(tx.error ?? new Error("IndexedDB transaction failed")));
});
const openDatabase = (): Promise<IDBDatabase | null> => new Promise((resolve) => {
try {
if (typeof indexedDB === "undefined" || indexedDB === null) {
resolve(null);
return;
}
const request = indexedDB.open(CONNECTION_LOG_TERMINAL_DATA_DB_NAME, DB_VERSION);
request.onupgradeneeded = () => {
const db = request.result;
if (!db.objectStoreNames.contains(CONNECTION_LOG_TERMINAL_DATA_STORE_NAME)) {
db.createObjectStore(CONNECTION_LOG_TERMINAL_DATA_STORE_NAME, { keyPath: "id" });
}
};
request.onsuccess = () => resolve(request.result);
request.onerror = () => {
warnStoreFailure("IndexedDB open failed", request.error);
resolve(null);
};
} catch (error) {
warnStoreFailure("IndexedDB open failed", error);
resolve(null);
}
});
const getDatabase = async (): Promise<IDBDatabase | null> => {
if (cachedDatabase) return cachedDatabase;
if (!openingDatabase) {
openingDatabase = openDatabase().then((db) => {
openingDatabase = null;
if (!db) return null;
cachedDatabase = db;
db.onversionchange = () => {
if (cachedDatabase === db) cachedDatabase = null;
try {
db.close();
} catch {
// Already closed by the deleting context.
}
};
db.onclose = () => {
if (cachedDatabase === db) cachedDatabase = null;
};
return db;
}).catch((error: unknown) => {
openingDatabase = null;
warnStoreFailure("IndexedDB open failed", error);
return null;
});
}
return openingDatabase;
};
const rowsToMap = (rows: TerminalDataRow[]): ConnectionLogTerminalDataMap => {
const map: ConnectionLogTerminalDataMap = {};
for (const row of rows) {
if (!row || typeof row.id !== "string" || typeof row.data !== "string") continue;
map[row.id] = row.data;
}
return map;
};
const sanitizeMap = (map: ConnectionLogTerminalDataMap | null): ConnectionLogTerminalDataMap => {
if (!map || typeof map !== "object" || Array.isArray(map)) return {};
const next: ConnectionLogTerminalDataMap = {};
for (const [id, data] of Object.entries(map)) {
if (typeof data === "string" && data.length > 0) next[id] = data;
}
return next;
};
const readAll = async (): Promise<ConnectionLogTerminalDataMap | null> => {
try {
const db = await getDatabase();
if (!db) return null;
const tx = db.transaction(CONNECTION_LOG_TERMINAL_DATA_STORE_NAME, "readonly");
const store = tx.objectStore(CONNECTION_LOG_TERMINAL_DATA_STORE_NAME);
const request = store.getAll() as IDBRequest<TerminalDataRow[]>;
let rows: TerminalDataRow[] = [];
request.onsuccess = () => {
rows = request.result ?? [];
};
await transactionComplete(tx);
return rowsToMap(rows);
} catch (error) {
forgetDatabase();
warnStoreFailure("IndexedDB read failed", error);
return null;
}
};
export const loadAll = async (): Promise<ConnectionLogTerminalDataMap> => (await readAll()) ?? {};
/** `null` when IndexedDB could not be read. `{}` is a successful empty store. */
export const tryLoadAll = async (): Promise<ConnectionLogTerminalDataMap | null> => readAll();
export const replaceAll = async (map: ConnectionLogTerminalDataMap): Promise<boolean> => {
try {
const db = await getDatabase();
if (!db) return false;
const entries = sanitizeMap(map);
const tx = db.transaction(CONNECTION_LOG_TERMINAL_DATA_STORE_NAME, "readwrite");
const store = tx.objectStore(CONNECTION_LOG_TERMINAL_DATA_STORE_NAME);
const done = transactionComplete(tx);
store.clear();
for (const [id, data] of Object.entries(entries)) {
store.put({ id, data });
}
await done;
return true;
} catch (error) {
forgetDatabase();
warnStoreFailure("IndexedDB replace failed", error);
return false;
}
};
/** Insert ids that are still absent. Does not clear or overwrite existing rows. */
const putMissing = async (entries: ConnectionLogTerminalDataMap): Promise<boolean> => {
const rows = sanitizeMap(entries);
const ids = Object.keys(rows);
if (ids.length === 0) return true;
try {
const db = await getDatabase();
if (!db) return false;
const tx = db.transaction(CONNECTION_LOG_TERMINAL_DATA_STORE_NAME, "readwrite");
const store = tx.objectStore(CONNECTION_LOG_TERMINAL_DATA_STORE_NAME);
const done = transactionComplete(tx);
for (const id of ids) {
const request = store.get(id) as IDBRequest<TerminalDataRow | undefined>;
request.onsuccess = () => {
if (request.result === undefined) store.put({ id, data: rows[id] });
};
}
await done;
return true;
} catch (error) {
forgetDatabase();
warnStoreFailure("IndexedDB put failed", error);
return false;
}
};
export const putMany = async (entries: ConnectionLogTerminalDataMap): Promise<boolean> => {
const rows = sanitizeMap(entries);
const ids = Object.keys(rows);
if (ids.length === 0) return true;
try {
const db = await getDatabase();
if (!db) return false;
const tx = db.transaction(CONNECTION_LOG_TERMINAL_DATA_STORE_NAME, "readwrite");
const store = tx.objectStore(CONNECTION_LOG_TERMINAL_DATA_STORE_NAME);
const done = transactionComplete(tx);
for (const id of ids) store.put({ id, data: rows[id] });
await done;
return true;
} catch (error) {
forgetDatabase();
warnStoreFailure("IndexedDB put failed", error);
return false;
}
};
export const deleteMany = async (ids: readonly string[]): Promise<boolean> => {
if (ids.length === 0) return true;
try {
const db = await getDatabase();
if (!db) return false;
const tx = db.transaction(CONNECTION_LOG_TERMINAL_DATA_STORE_NAME, "readwrite");
const store = tx.objectStore(CONNECTION_LOG_TERMINAL_DATA_STORE_NAME);
const done = transactionComplete(tx);
for (const id of ids) store.delete(id);
await done;
return true;
} catch (error) {
forgetDatabase();
warnStoreFailure("IndexedDB delete failed", error);
return false;
}
};
export const clear = async (): Promise<boolean> => {
try {
const db = await getDatabase();
if (!db) return false;
const tx = db.transaction(CONNECTION_LOG_TERMINAL_DATA_STORE_NAME, "readwrite");
const store = tx.objectStore(CONNECTION_LOG_TERMINAL_DATA_STORE_NAME);
const done = transactionComplete(tx);
store.clear();
await done;
return true;
} catch (error) {
forgetDatabase();
warnStoreFailure("IndexedDB clear failed", error);
return false;
}
};
/**
* Apply a persisted-map diff in one transaction. Unchanged maps do not open
* a write. A failed commit leaves the previous rows in place for a retry.
*/
export const writeTerminalDataMapDiff = async (
previous: ConnectionLogTerminalDataMap,
next: ConnectionLogTerminalDataMap,
): Promise<{ persisted: boolean; changed: boolean }> => {
const diff = diffTerminalDataMaps(previous, next);
const puts = sanitizeMap(diff.puts);
const putIds = Object.keys(puts);
if (putIds.length === 0 && diff.deleteIds.length === 0) {
return { persisted: true, changed: false };
}
try {
const db = await getDatabase();
if (!db) return { persisted: false, changed: true };
const tx = db.transaction(CONNECTION_LOG_TERMINAL_DATA_STORE_NAME, "readwrite");
const store = tx.objectStore(CONNECTION_LOG_TERMINAL_DATA_STORE_NAME);
const done = transactionComplete(tx);
for (const id of putIds) store.put({ id, data: puts[id] });
for (const id of diff.deleteIds) store.delete(id);
await done;
return { persisted: true, changed: true };
} catch (error) {
forgetDatabase();
warnStoreFailure("IndexedDB diff write failed", error);
return { persisted: false, changed: true };
}
};
const safeRemove = (remove: () => void): void => {
try {
remove();
} catch (error) {
warnStoreFailure("Failed to remove legacy terminal replay blob", error);
}
};
const readLegacyMap = (
legacy: LegacyConnectionLogTerminalDataMigration,
): ConnectionLogTerminalDataMap | null => {
try {
return sanitizeMap(legacy.read());
} catch (error) {
warnStoreFailure("Failed to read legacy terminal replay blob", error);
return null;
}
};
/**
* One-shot move from the legacy localStorage blob into IndexedDB.
* An empty IndexedDB plus a legacy blob is copied, then the legacy key is
* removed only after the transaction commits. Rows already in IndexedDB are
* kept; legacy ids that are not there yet are inserted before the key is
* removed. A failed read does not treat the store as empty, and a failed
* write leaves the legacy key in place.
*/
export const migrateLegacyConnectionLogTerminalData = async (
legacy: LegacyConnectionLogTerminalDataMigration,
): Promise<MigratedConnectionLogTerminalData> => {
const loaded = await readAll();
if (loaded === null) {
const fallback = readLegacyMap(legacy);
return { map: fallback ?? {}, migrated: false, durable: false };
}
if (Object.keys(loaded).length > 0) {
const legacyMap = readLegacyMap(legacy);
if (legacyMap === null) return { map: loaded, migrated: false, durable: true };
const missing: ConnectionLogTerminalDataMap = {};
for (const [id, data] of Object.entries(legacyMap)) {
if (!Object.prototype.hasOwnProperty.call(loaded, id)) missing[id] = data;
}
if (Object.keys(missing).length === 0) {
safeRemove(legacy.remove);
return { map: loaded, migrated: false, durable: true };
}
const persisted = await putMissing(missing);
if (!persisted) {
return { map: { ...missing, ...loaded }, migrated: false, durable: false };
}
safeRemove(legacy.remove);
const committed = await readAll();
return {
map: committed ?? { ...loaded, ...missing },
migrated: true,
durable: committed !== null,
};
}
const legacyMap = readLegacyMap(legacy);
if (legacyMap === null) return { map: {}, migrated: false, durable: false };
if (Object.keys(legacyMap).length === 0) {
safeRemove(legacy.remove);
return { map: {}, migrated: false, durable: true };
}
// Insert without clearing. A row committed after the empty readAll must stay.
const persisted = await putMissing(legacyMap);
if (!persisted) return { map: legacyMap, migrated: false, durable: false };
safeRemove(legacy.remove);
const committed = await readAll();
return { map: committed ?? legacyMap, migrated: true, durable: committed !== null };
};
+2
View File
@@ -119,6 +119,8 @@ function seed(
diskPercent: 35,
diskUsed: 35,
diskTotal: 100,
diskReadSpeed: i === 1 ? null : i === 2 ? 0 : 2 * 1024 ** 2,
diskWriteSpeed: i === 1 ? null : i === 2 ? 0 : 512 * 1024,
disks: Array.from({ length: i === 2 ? 10 : 2 }, (_, j) => ({
mountPoint: j ? `/data-${j}` : "/",
used: 35,
+1
View File
@@ -59,6 +59,7 @@ async function main() {
() => run('document.querySelectorAll("[data-monitor-host]").length > 0'),
"monitoring renders",
);
assert.match(await run('document.querySelector("[data-disk-io]").textContent'), /2.0 MiB\/s/);
const screenshot = async (name) => {
owner.webContents.sendInputEvent({ type: "mouseMove", x: 1, y: 1 });
await wait(200);
+3 -1
View File
@@ -127,9 +127,11 @@ declare global {
stopManualSessionLog?(payload: {
sessionId: string;
}): Promise<{ success: boolean; stopped: boolean; error?: string; filePath?: string }>;
prepareSessionLog?(payload: { sessionId: string }): Promise<{ success: boolean; filePath?: string; error?: string }>;
revealSessionLog?(payload: { sessionId: string }): Promise<{ success: boolean; error?: string }>;
getManualSessionLogStatus?(payload: {
sessionId: string;
}): Promise<{ success: boolean; isLogging: boolean; error?: string }>;
}): Promise<{ success: boolean; isLogging: boolean; hasSessionLog: boolean; error?: string }>;
// Get file path from File object (for drag-and-drop, uses Electron's webUtils)
getPathForFile?(file: File): string | undefined;
+2
View File
@@ -321,6 +321,8 @@ declare global {
memPercent: number;
command: string;
}>;
diskReadSpeed?: number | null; // Linux hardware-backed block devices, bytes/sec; null while unavailable/warming up
diskWriteSpeed?: number | null; // Aggregate without partitions or stacked virtual devices
diskPercent: number | null; // Disk usage percentage for root partition
diskUsed: number | null; // Disk used in GB
diskTotal: number | null; // Total disk in GB