mirror of
https://github.com/binaricat/Netcatty.git
synced 2026-11-10 16:21:22 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9a77a090b0 | ||
|
|
850c5309b6 | ||
|
|
e5bfc12a2e | ||
|
|
6ab684bcd5 | ||
|
|
acecd46d57 | ||
|
|
aee852a9ac | ||
|
|
5bef721ac9 | ||
|
|
4c82424adf |
No files matched your search
@@ -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" />
|
||||
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -205,7 +205,11 @@ export function TerminalHost() {
|
||||
updateTerminalHosts: handlers.updateTerminalHosts,
|
||||
hotkeyScheme: terminalSettings.hotkeyScheme,
|
||||
isBroadcastEnabled: sessionActions?.isBroadcastEnabled,
|
||||
isComposeSendToAll: sessionActions?.isComposeSendToAll,
|
||||
onToggleComposeSendTarget: sessionActions?.toggleComposeSendTarget,
|
||||
isGlobalBroadcastEnabled: sessionActions?.isGlobalBroadcastEnabled,
|
||||
isGlobalComposeSendToAll: sessionActions?.isGlobalComposeSendToAll,
|
||||
onToggleGlobalComposeSendTarget: sessionActions?.toggleGlobalComposeSendTarget,
|
||||
canUseGlobalBroadcast: sessionActions?.canUseGlobalBroadcast,
|
||||
keyBindings: terminalSettings.keyBindings,
|
||||
openNoteRequest: local.openNoteRequest,
|
||||
|
||||
@@ -70,8 +70,12 @@ export function SessionPublisher({ persistSessionRestore, children }: SessionPub
|
||||
reorderTabs,
|
||||
toggleBroadcast,
|
||||
isBroadcastEnabled,
|
||||
toggleComposeSendTarget,
|
||||
isComposeSendToAll,
|
||||
toggleGlobalBroadcast,
|
||||
isGlobalBroadcastEnabled,
|
||||
toggleGlobalComposeSendTarget,
|
||||
isGlobalComposeSendToAll,
|
||||
canUseGlobalBroadcast,
|
||||
copySession,
|
||||
copyWorkspace,
|
||||
@@ -150,8 +154,12 @@ export function SessionPublisher({ persistSessionRestore, children }: SessionPub
|
||||
reorderTabs,
|
||||
toggleBroadcast,
|
||||
isBroadcastEnabled,
|
||||
toggleComposeSendTarget,
|
||||
isComposeSendToAll,
|
||||
toggleGlobalBroadcast,
|
||||
isGlobalBroadcastEnabled,
|
||||
toggleGlobalComposeSendTarget,
|
||||
isGlobalComposeSendToAll,
|
||||
canUseGlobalBroadcast,
|
||||
copySession,
|
||||
copyWorkspace,
|
||||
@@ -181,7 +189,9 @@ export function SessionPublisher({ persistSessionRestore, children }: SessionPub
|
||||
getOrderedWorkTabs,
|
||||
getSessionRestoreCwd,
|
||||
isBroadcastEnabled,
|
||||
isComposeSendToAll,
|
||||
isGlobalBroadcastEnabled,
|
||||
isGlobalComposeSendToAll,
|
||||
moveFocusInWorkspace,
|
||||
openLogView,
|
||||
canUseGlobalBroadcast,
|
||||
@@ -201,7 +211,9 @@ export function SessionPublisher({ persistSessionRestore, children }: SessionPub
|
||||
submitSessionRename,
|
||||
submitWorkspaceRename,
|
||||
toggleBroadcast,
|
||||
toggleComposeSendTarget,
|
||||
toggleGlobalBroadcast,
|
||||
toggleGlobalComposeSendTarget,
|
||||
toggleWorkspaceViewMode,
|
||||
updateSessionCodingCliProvider,
|
||||
updateSessionDynamicTitle,
|
||||
|
||||
@@ -165,8 +165,12 @@ test('vault init cancels the superseded StrictMode effect before publishing read
|
||||
);
|
||||
const initAt = vaultSource.indexOf('let cancelled = false;');
|
||||
assert.notEqual(initAt, -1, 'vault init must track cancellation');
|
||||
const initSlice = vaultSource.slice(initAt, initAt + 12000);
|
||||
assert.match(initSlice, /return \(\) => \{\s*\n\s*cancelled = true;\s*\n\s*\};/);
|
||||
const initSlice = vaultSource.slice(initAt, initAt + 20000);
|
||||
assert.match(
|
||||
initSlice,
|
||||
/return \(\) => \{\s*\n\s*cancelled = true;\s*\n\s*resolveTerminalDataReady\(\);\s*\n\s*\};/,
|
||||
'StrictMode cleanup cancels init and unblocks the terminal-data ready promise',
|
||||
);
|
||||
assert.match(
|
||||
initSlice,
|
||||
/if \(!cancelled\) \{\s*\n\s*setIsInitialized\(true\);\s*\n\s*setVaultInitialized\(true\);\s*\n\s*\}/,
|
||||
|
||||
@@ -1,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",
|
||||
|
||||
@@ -1,6 +1,15 @@
|
||||
import type { Messages } from '../types';
|
||||
|
||||
export const enTerminalMessages: Messages = {
|
||||
'terminal.toolbar.openSessionLog': 'Open session log',
|
||||
'terminal.toolbar.sessionLogActions': 'Session log actions',
|
||||
'terminal.toolbar.sessionLog.menu': 'Session log',
|
||||
'terminal.toolbar.sessionLog.open': 'Open',
|
||||
'terminal.toolbar.sessionLog.reveal': 'Show in folder',
|
||||
'terminal.toolbar.sessionLog.chooseApplication': 'Open with…',
|
||||
'terminal.toolbar.sessionLog.systemDefault': 'Open with system default',
|
||||
'terminal.toolbar.openSessionLogFailed': 'Failed to open session log.',
|
||||
|
||||
'terminal.menu.saveScreen': 'Save current screen',
|
||||
'terminal.saveScreen.failed': 'Failed to save terminal screen.',
|
||||
// Multi-line paste confirmation dialog (#3398)
|
||||
@@ -68,6 +77,8 @@ export const enTerminalMessages: Messages = {
|
||||
'terminal.composeBar.send': 'Send',
|
||||
'terminal.composeBar.close': 'Close compose bar',
|
||||
'terminal.composeBar.broadcasting': 'Broadcasting to all sessions',
|
||||
'terminal.composeBar.sendToAll': 'Send to all sessions',
|
||||
'terminal.composeBar.sendToActive': 'Send to the active session only',
|
||||
'terminal.composeBar.broadcastAllowPassword': 'Broadcast without password protection',
|
||||
'terminal.composeBar.resize': 'Resize compose bar height',
|
||||
'terminal.composeBar.manageSnippets': 'Manage quick snippets',
|
||||
|
||||
@@ -1,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",
|
||||
|
||||
@@ -1,6 +1,15 @@
|
||||
import type { Messages } from '../types';
|
||||
|
||||
export const esTerminalMessages: Messages = {
|
||||
'terminal.toolbar.openSessionLog': 'Abrir registro de sesión',
|
||||
'terminal.toolbar.sessionLogActions': 'Acciones del registro',
|
||||
'terminal.toolbar.sessionLog.menu': 'Registro de sesión',
|
||||
'terminal.toolbar.sessionLog.open': 'Abrir',
|
||||
'terminal.toolbar.sessionLog.reveal': 'Mostrar en carpeta',
|
||||
'terminal.toolbar.sessionLog.chooseApplication': 'Abrir con…',
|
||||
'terminal.toolbar.sessionLog.systemDefault': 'Abrir con la aplicación predeterminada',
|
||||
'terminal.toolbar.openSessionLogFailed': 'No se pudo abrir el registro.',
|
||||
|
||||
'terminal.menu.saveScreen': 'Guardar pantalla actual',
|
||||
'terminal.saveScreen.failed': 'No se pudo guardar la pantalla del terminal.',
|
||||
// Diálogo de confirmación de pegado multilínea (#3398)
|
||||
@@ -68,6 +77,8 @@ export const esTerminalMessages: Messages = {
|
||||
'terminal.composeBar.send': 'Enviar',
|
||||
'terminal.composeBar.close': 'Cerrar barra de redacción',
|
||||
'terminal.composeBar.broadcasting': 'Difundiendo a todas las sesiones',
|
||||
'terminal.composeBar.sendToAll': 'Enviar a todas las sesiones',
|
||||
'terminal.composeBar.sendToActive': 'Enviar solo a la sesión activa',
|
||||
'terminal.composeBar.broadcastAllowPassword': 'Difundir sin protección de contraseña',
|
||||
'terminal.composeBar.resize': 'Cambiar la altura de la barra de redacción',
|
||||
'terminal.composeBar.manageSnippets': 'Administrar snippets rápidos',
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
export const ruMonitorMessages = {
|
||||
"monitor.diskIo": "Дисковый ввод-вывод",
|
||||
"monitor.diskRead": "Чтение",
|
||||
"monitor.diskWrite": "Запись",
|
||||
"monitor.diskIoHint": "Суммарный ввод-вывод физических блочных устройств Linux, без разделов и виртуальных слоёв. Доступен после двух корректных замеров; macOS не поддерживается.",
|
||||
|
||||
"monitor.actions": "Действия панели",
|
||||
"monitor.unavailable": "Мониторинг недоступен. Запустите его снова после восстановления связи.",
|
||||
"monitor.username": "Пользователь",
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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/);
|
||||
});
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
});
|
||||
@@ -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
@@ -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
@@ -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); }}
|
||||
|
||||
@@ -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(); });
|
||||
});
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
}}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 &&
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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/);
|
||||
}
|
||||
});
|
||||
@@ -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>
|
||||
|
||||
@@ -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
@@ -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");
|
||||
});
|
||||
@@ -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. */
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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) => (
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
@@ -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))
|
||||
),
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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({
|
||||
|
||||
@@ -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),
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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/);
|
||||
});
|
||||
@@ -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 };
|
||||
};
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
Reference in new issue
Block a user