diff --git a/.github/workflows/ai-automation.yml b/.github/workflows/ai-automation.yml index 2a1ce2fe2..34fb1ccaf 100644 --- a/.github/workflows/ai-automation.yml +++ b/.github/workflows/ai-automation.yml @@ -1202,8 +1202,7 @@ jobs: persist-credentials: false - name: Freeze trusted helper - run: cp scripts/ai-automation.cjs "$RUNNER_TEMP/ai-automation.cjs" - chmod 0444 "$RUNNER_TEMP/ai-automation.cjs" + run: *freeze_ai_helper - name: Install Claude Code CLI run: | diff --git a/application/app/AppActiveTabChrome.tsx b/application/app/AppActiveTabChrome.tsx index 030dcf71a..d2c5786b6 100644 --- a/application/app/AppActiveTabChrome.tsx +++ b/application/app/AppActiveTabChrome.tsx @@ -8,6 +8,7 @@ import { import { updateActiveChromeThemeDeps } from '../state/activeChromeThemeSync'; import { useActiveChromeTheme } from '../state/useActiveChromeTheme'; import { useAppearanceChromeStore } from '../state/appearanceChromeStore'; +import { focusEditorInWindow } from '../state/editorWindowClient'; import { netcattyBridge } from '../../infrastructure/services/netcattyBridge'; import { resolveActiveChromeTheme } from './activeChromeTheme'; import type { TerminalAppearanceHostScope, ResolvedAppearance } from '../../domain/terminalAppearanceRuntime'; @@ -71,6 +72,16 @@ export function AppActiveTabChrome({ } }, [showSftpTab, activeTabId, setActiveTabId]); + useEffect(() => { + if (!isEditorTabId(activeTabId)) return; + const editorId = fromEditorTabId(activeTabId); + if (!editorId) return; + const tab = editorTabs.find((entry) => entry.id === editorId); + if (tab?.placement !== 'window') return; + void focusEditorInWindow(editorId); + setActiveTabId('vault'); + }, [activeTabId, editorTabs, setActiveTabId]); + const chromeThemeDeps = useMemo(() => ({ accentMode, applyAppTheme, diff --git a/application/app/AppSideEffects.tsx b/application/app/AppSideEffects.tsx index ed1ddda3e..450d90398 100755 --- a/application/app/AppSideEffects.tsx +++ b/application/app/AppSideEffects.tsx @@ -22,6 +22,7 @@ import { useVaultAgentBridge } from '../state/useVaultAgentBridge'; import { useWindowControls } from '../state/useWindowControls'; import { useTerminalKeyboardFocus } from '../state/useTerminalKeyboardFocus'; import { editorTabStore, useEditorTabChromeList } from '../state/editorTabStore'; +import { installEditorWindowSourceListeners } from '../state/editorWindowClient'; import { findEditorSftpOwnerTabId } from '../state/editorSftpOwnerRegistry'; import { isPluginViewTabId, @@ -52,6 +53,7 @@ import { } from '../../domain/terminalAppearance'; import { selectConnectionLogForTerminalDataCapture } from '../../domain/connectionLog'; import { collectSessionIds } from '../../domain/workspace'; +import { useHostOpenSessionReuse } from '../state/useHostOpenSessionReuse'; import type { PaneMagnificationController } from '../../domain/paneMagnification'; import { resolveCloseIntent } from '../state/resolveCloseIntent'; import { resolveSnippetsShortcutIntent } from '../state/resolveSnippetsShortcutIntent'; @@ -641,6 +643,8 @@ export function AppSideEffects() { workspaces, }); + useEffect(() => installEditorWindowSourceListeners(), []); + const pendingTrayPortForwardsWhileLockedRef = useRef>([]); const _handleTrayTogglePortForwardMaybeDeferred = useEffectEvent((ruleId: string, start: boolean) => { @@ -1429,6 +1433,10 @@ export function AppSideEffects() { return { ok: true as const, sessionId, host }; }, [handleConnectToHost]); + const findExistingSessionForHostForVaultAgent = useHostOpenSessionReuse({ + sessions, hosts, keys, identities, resolveEffectiveHost, + }); + const closeSessionForVaultAgent = useCallback((sessionId: string) => { if (!sessions.some((session) => session.id === sessionId)) { return { ok: false as const, error: `Session "${sessionId}" was not found.` }; @@ -1471,6 +1479,7 @@ export function AppSideEffects() { stopTunnel, stopRuleTunnels, openHost: openHostForVaultAgent, + findExistingSessionForHost: findExistingSessionForHostForVaultAgent, closeSession: closeSessionForVaultAgent, getScriptSessionMeta: (sessionId) => sessions.find((session) => session.id === sessionId), }); diff --git a/application/app/AppView.tsx b/application/app/AppView.tsx index 09c02243c..886c3f542 100644 --- a/application/app/AppView.tsx +++ b/application/app/AppView.tsx @@ -2,8 +2,9 @@ import React, { Suspense, lazy, memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { AlertTriangle, Download, Trash2 } from 'lucide-react'; import { activeTabStore, toEditorTabId, useIsEditorTabActive } from '../state/activeTabStore'; -import { editorTabStore } from '../state/editorTabStore'; +import { editorTabStore, tabIsDirty } from '../state/editorTabStore'; import { releaseEditorTabSaveCoordinator, saveEditorTab } from '../state/editorTabSave'; +import { focusEditorInWindow, popOutEditorTab } from '../state/editorWindowClient'; import { useTerminalHostTreeLayoutWidth } from '../state/terminalHostTreeStore'; import { TopTabs } from '../../components/TopTabs'; import { VaultView } from '../../components/VaultView'; @@ -420,6 +421,23 @@ function AppViewInner({ domains }: AppViewProps) { const tab = editorTabStore.getTab(id); if (!tab) return false; + if (tab.placement === "window") { + const result = await netcattyBridge.get()?.closeEditorWindowTabs?.({ editorIds: [id] }); + const ok = Boolean(result?.success) && result?.cancelled !== true; + if (ok) { + const closingTabId = toEditorTabId(id); + const list = orderedTabsWithEditorsRef.current; + const idx = list.indexOf(closingTabId); + releaseEditorTabSaveCoordinator(id); + editorTabStore.close(id); + if (activeTabStore.getActiveTabId() === closingTabId) { + const next = list[idx - 1] ?? list[idx + 1] ?? 'vault'; + activeTabStore.setActiveTabId(next === closingTabId ? 'vault' : next); + } + } + return ok; + } + const closeEditorAndActivateNeighbor = () => { const closingTabId = toEditorTabId(id); const list = orderedTabsWithEditorsRef.current; @@ -431,7 +449,7 @@ function AppViewInner({ domains }: AppViewProps) { activeTabStore.setActiveTabId(next === closingTabId ? 'vault' : next); }; - const dirty = tab.content !== tab.baselineContent; + const dirty = tabIsDirty(tab); if (!dirty) { closeEditorAndActivateNeighbor(); return true; @@ -462,6 +480,16 @@ function AppViewInner({ domains }: AppViewProps) { // App.tsx stub `() => false` between commit and useEffect. handleRequestCloseEditorTabRef.current = handleRequestCloseEditorTab; + const handlePopOutEditorTab = useCallback(async (id: string) => { + const tab = editorTabStore.getTab(id); + if (!tab || tab.placement === "window") { + if (tab) void focusEditorInWindow(id); + return; + } + const host = hostById.get(tab.hostId); + await popOutEditorTab(id, host?.label); + }, [hostById]); + const handleSaveSessionRename = useCallback((name: string) => { if (!sessionRenameTarget) return; if (!name.trim()) return; @@ -538,6 +566,8 @@ function AppViewInner({ domains }: AppViewProps) { pluginViewTabs={pluginViewTabs} onClosePluginViewTab={closePluginViewTab} onRequestCloseEditorTab={handleRequestCloseEditorTab} + onPopOutEditorTab={handlePopOutEditorTab} + onFocusEditorWindowTab={(id) => { void focusEditorInWindow(id); }} hostById={hostById} /> @@ -788,7 +818,7 @@ function AppViewInner({ domains }: AppViewProps) { ))} {/* Editor Tabs — kept mounted for Monaco instance persistence; visibility toggled via CSS */} - {editorTabs.map((tab) => ( + {editorTabs.filter((tab) => tab.placement !== "window").map((tab) => ( }> handleRequestCloseEditorTabRef.current(id)} + onPopOut={(id) => { void handlePopOutEditorTab(id); }} /> diff --git a/application/app/strictModeIdempotency.test.ts b/application/app/strictModeIdempotency.test.ts index 8c0b2106d..6f4efa0a3 100644 --- a/application/app/strictModeIdempotency.test.ts +++ b/application/app/strictModeIdempotency.test.ts @@ -29,7 +29,7 @@ test('every renderer root mounts under StrictMode', () => { assert.match(indexSource, /import \{ StrictMode, Suspense, lazy \} from 'react'/); const renderCalls = indexSource.match(/root\.render\(/g) ?? []; - assert.equal(renderCalls.length, 4, 'main, settings, tray and terminal-popup roots'); + assert.equal(renderCalls.length, 5, 'main, settings, tray, terminal-popup and editor-window roots'); let cursor = 0; for (let index = 0; index < renderCalls.length; index += 1) { diff --git a/application/app/useAppStartupEffects.editorQuit.test.ts b/application/app/useAppStartupEffects.editorQuit.test.ts new file mode 100644 index 000000000..c71024390 --- /dev/null +++ b/application/app/useAppStartupEffects.editorQuit.test.ts @@ -0,0 +1,47 @@ +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 { useAppStartupEffects } from "./useAppStartupEffects.ts"; +import { editorTabStore } from "../state/editorTabStore.ts"; +import { popOutEditorTab } from "../state/editorWindowClient.ts"; +import { netcattyBridge } from "../../infrastructure/services/netcattyBridge.ts"; +import { toast } from "../../components/ui/toast.tsx"; + +test("app Quit sees the source's unsaved contents until the detached receiver accepts them", async (t) => { + const previous = Object.getOwnPropertyDescriptor(globalThis, "IS_REACT_ACT_ENVIRONMENT"); + Object.defineProperty(globalThis, "IS_REACT_ACT_ENVIRONMENT", { configurable: true, value: true }); + let renderer: ReactTestRenderer | undefined; + const editorId = "quit-during-editor-handoff"; + t.after(async () => { + await act(async () => renderer?.unmount()); + editorTabStore.close(editorId); + if (previous) Object.defineProperty(globalThis, "IS_REACT_ACT_ENVIRONMENT", previous); + else Reflect.deleteProperty(globalThis, "IS_REACT_ACT_ENVIRONMENT"); + }); + let query!: () => Promise; + let accept!: (result: { success: boolean }) => void; + const receipt = new Promise<{ success: boolean }>((resolve) => { accept = resolve; }); + const reports: boolean[] = []; + t.mock.method(toast, "warning", () => {}); + t.mock.method(netcattyBridge, "get", () => ({ + openEditorWindow: () => receipt, + onCheckDirtyEditors: (callback: typeof query) => { query = callback; return () => {}; }, + reportDirtyEditorsResult: (dirty: boolean) => reports.push(dirty), + })); + function SourceWindow() { + useAppStartupEffects({ enabled: false, updateState: {}, sessions: [], hosts: [], keys: [], identities: [], knownHosts: [], proxyProfiles: [], groupConfigs: [], workspaces: [], portForwardingRules: [], t: (key: string) => key }); + return null; + } + await act(async () => { renderer = create(React.createElement(SourceWindow)); }); + editorTabStore.upsertFromSnapshot({ editorId, sessionId: "source", sftpTabId: "sftp", hostId: "fixture", remotePath: "/alpha.txt", fileName: "alpha.txt", languageId: "plaintext", content: "unsaved", baselineContent: "original", wordWrap: false, viewState: null }); + const opening = popOutEditorTab(editorId); + assert.equal(editorTabStore.getTab(editorId)?.placement, "window"); + await query(); + assert.deepEqual(reports, [true], "a cold receiver must not make the only owned unsaved copy invisible to Quit"); + accept({ success: true }); + assert.equal(await opening, true); + assert.equal(editorTabStore.getTab(editorId)?.content, ""); + await query(); + assert.deepEqual(reports, [true, false], "after receipt the destination owns dirty reporting; the source copy is released"); +}); diff --git a/application/app/useAppStartupEffects.ts b/application/app/useAppStartupEffects.ts index 100befb60..ececc09b5 100644 --- a/application/app/useAppStartupEffects.ts +++ b/application/app/useAppStartupEffects.ts @@ -357,6 +357,7 @@ export function useAppStartupEffects(ctx: StartupEffectsContext) { // exactly the same outcome. let hasDirty = false; try { + // Pending handoffs retain source contents; accepted handoffs clear them. hasDirty = editorTabStore.getTabs().some((tab) => tab.content !== tab.baselineContent); if (hasDirty) toast.warning(t('sftp.editor.quitBlockedByDirty'), 'SFTP'); if (!hasDirty) { diff --git a/application/i18n/locales/en/ai.ts b/application/i18n/locales/en/ai.ts index 103eb19b5..f63ffbe05 100644 --- a/application/i18n/locales/en/ai.ts +++ b/application/i18n/locales/en/ai.ts @@ -5,6 +5,7 @@ export const enAiMessages: Messages = { 'ai.agentSettings': 'Agent Settings', 'ai.chat.preparing': 'Preparing…', 'ai.chat.modelCatalogWarning': 'Could not refresh the live model list. Showing the last available or built-in models.', + 'ai.chat.modelCatalogRetry': 'Retry', 'ai.chat.compactingContext': 'Compacting earlier context…', 'ai.chat.compactingStep': 'Trimming context for next step…', 'ai.chat.compactionRetry': 'Request was too large. Compacting context and retrying…', @@ -521,12 +522,18 @@ export const enAiMessages: Messages = { 'ai.chat.hoursAgo': '{n}h ago', 'ai.chat.daysAgo': '{n}d ago', 'ai.chat.newChat': 'New Chat', + 'ai.chat.undoLastTurn': 'Undo last turn', + 'ai.chat.undoLastTurnNotice': "Original chat kept. Commands and file changes are not undone. Return to the original chat for saved full outputs and existing background tasks.", 'ai.chat.allSessions': 'All Sessions', 'ai.chat.loadEarlierMessages': 'Load earlier messages ({n} more)', 'ai.chat.jumpNav': 'Jump to message', 'ai.chat.jumpUntitled': '(empty message)', 'ai.chat.usedTools': 'Tools used: {n}', 'ai.chat.loadMoreSessions': 'Load more sessions ({n} more)', + 'ai.chat.searchSessions': 'Search titles and recent content…', + 'ai.chat.clearSessionSearch': 'Clear session search', + 'ai.chat.searchSessionsHint': 'Searches titles, prioritizing newer messages and entries. For long messages and tool outputs, only a limited portion at the beginning is searched. This is not full-text search.', + 'ai.chat.noMatchingSessions': 'No matches in titles or recent retained content', 'ai.chat.noSessions': 'No previous sessions', 'ai.chat.forkFromHere': 'Fork from here', 'ai.chat.forkCreated': 'Branched into a new chat — the original stays in history.', diff --git a/application/i18n/locales/en/scripts.ts b/application/i18n/locales/en/scripts.ts index 453e75ec7..05604dd5f 100644 --- a/application/i18n/locales/en/scripts.ts +++ b/application/i18n/locales/en/scripts.ts @@ -36,6 +36,8 @@ export const enScriptsMessages = { 'scripts.sidePanel.library': 'Library', 'scripts.sidePanel.running': 'Running', 'scripts.sidePanel.newScript': 'New script', + 'scripts.sidePanel.viewList': 'List view', + 'scripts.sidePanel.viewStacked': 'Stacked view', 'scripts.running.empty': 'No scripts are running in this session.', 'scripts.running.unnamed': 'Untitled script', 'scripts.running.status.running': 'Running', diff --git a/application/i18n/locales/en/terminal.ts b/application/i18n/locales/en/terminal.ts index 776d06c33..ccafa9637 100644 --- a/application/i18n/locales/en/terminal.ts +++ b/application/i18n/locales/en/terminal.ts @@ -199,6 +199,7 @@ export const enTerminalMessages: Messages = { 'terminal.progress.timeoutIn': 'Timeout in {seconds}s', 'terminal.progress.waitingForUserInput': 'Waiting for user input', 'terminal.progress.disconnected': 'Disconnected', + 'terminal.progress.connected': 'Connected', 'terminal.progress.cancelling': 'Cancelling...', 'terminal.progress.startOver': 'Start over', 'terminal.progress.enterReconnectHint': 'Press Enter to reconnect', @@ -868,6 +869,13 @@ export const enTerminalMessages: Messages = { // Text Editor 'sftp.editor.wordWrap': 'Word Wrap', 'sftp.editor.maximize': 'Maximize', + 'sftp.editor.popOut': 'Open in editor window', + 'sftp.editor.dock': 'Dock to main window', + 'sftp.editor.focusWindow': 'Focus editor window', + 'sftp.editor.close': 'Close', + 'sftp.editor.windowTitle': 'Editor', + 'sftp.editor.windowEmpty': 'No files open', + 'sftp.editor.dockFailed': 'Could not dock this file back to the main window', 'sftp.editor.unsavedTitle': 'Unsaved changes', 'sftp.editor.unsavedMessage': '{fileName} has unsaved changes. Save before closing?', 'sftp.editor.discardChanges': 'Discard', diff --git a/application/i18n/locales/en/vault.ts b/application/i18n/locales/en/vault.ts index 1a6c6eca0..0a479cdf9 100644 --- a/application/i18n/locales/en/vault.ts +++ b/application/i18n/locales/en/vault.ts @@ -407,6 +407,7 @@ export const enVaultMessages: Messages = { 'sftp.transferCenter.prioritize': 'Transfer next', 'sftp.transferCenter.pauseAll': 'Pause all', 'sftp.transferCenter.resumeAll': 'Resume all', + 'sftp.transferCenter.cancelAll': 'Cancel all', 'sftp.transferCenter.empty': 'No transfers in this category', 'sftp.transferCenter.showBackground': 'Show {count} background transfers', 'sftp.transferCenter.hideBackground': 'Hide background transfers', @@ -714,6 +715,16 @@ export const enVaultMessages: Messages = { 'hostDetails.title.new': 'New Host', 'vault.panel.resizeWidth': 'Resize panel width', 'hostDetails.saveAria': 'Save', + // Host connection test + 'hostConnectionTest.title': 'Test Connection', + 'hostConnectionTest.test': 'Test', + 'hostConnectionTest.chainOf': 'Chain {current} of {total}', + 'hostConnectionTest.success': 'Connection successful', + 'hostConnectionTest.successHint': 'The host accepted your credentials. You can close this window.', + 'hostConnectionTest.connecting': 'Connecting… timeout in {seconds}s', + 'hostConnectionTest.failed': 'Connection failed.', + 'hostConnectionTest.retry': 'Retry', + 'hostConnectionTest.credentialsUnavailable': 'Saved credentials cannot be decrypted on this device. Re-enter them below.', 'hostDetails.section.address': 'Address', 'hostDetails.hostname.placeholder': 'IP or Hostname', 'hostDetails.section.general': 'General', diff --git a/application/i18n/locales/es/ai.ts b/application/i18n/locales/es/ai.ts index bf82e2e6f..324e60b4c 100644 --- a/application/i18n/locales/es/ai.ts +++ b/application/i18n/locales/es/ai.ts @@ -5,6 +5,7 @@ export const esAiMessages: Messages = { 'ai.agentSettings': 'Configuración del agente', 'ai.chat.preparing': 'Preparando…', 'ai.chat.modelCatalogWarning': 'No se pudo actualizar la lista de modelos. Se muestran los últimos modelos disponibles o los integrados.', + 'ai.chat.modelCatalogRetry': 'Reintentar', 'ai.chat.compactingContext': 'Compactando el contexto anterior…', 'ai.chat.compactingStep': 'Recortando el contexto para el siguiente paso…', 'ai.chat.compactionRetry': 'La petición era demasiado grande. Compactando el contexto y reintentando…', @@ -512,12 +513,18 @@ export const esAiMessages: Messages = { 'ai.chat.hoursAgo': 'hace {n} h', 'ai.chat.daysAgo': 'hace {n} d', 'ai.chat.newChat': 'Nuevo chat', + 'ai.chat.undoLastTurn': 'Deshacer el último turno', + 'ai.chat.undoLastTurnNotice': "Se conserva el chat original. Los comandos y cambios de archivos no se deshacen. Consulta allí las salidas completas guardadas y las tareas en segundo plano existentes.", 'ai.chat.allSessions': 'Todas las sesiones', 'ai.chat.loadEarlierMessages': 'Cargar mensajes anteriores ({n} más)', 'ai.chat.jumpNav': 'Ir al mensaje', 'ai.chat.jumpUntitled': '(mensaje vacío)', 'ai.chat.usedTools': 'Herramientas usadas: {n}', 'ai.chat.loadMoreSessions': 'Cargar más sesiones ({n} más)', + 'ai.chat.searchSessions': 'Buscar títulos y contenido reciente…', + 'ai.chat.clearSessionSearch': 'Borrar la búsqueda de sesiones', + 'ai.chat.searchSessionsHint': 'Busca en los títulos y da prioridad a los mensajes y entradas más recientes. De los mensajes largos y las salidas de herramientas, solo se busca una parte limitada del comienzo. No es una búsqueda de texto completo.', + 'ai.chat.noMatchingSessions': 'Sin coincidencias en títulos ni contenido reciente conservado', 'ai.chat.noSessions': 'No hay sesiones anteriores', 'ai.chat.forkFromHere': 'Bifurcar desde aquí', 'ai.chat.forkCreated': 'Conversación bifurcada en un chat nuevo; el original permanece en el historial.', diff --git a/application/i18n/locales/es/scripts.ts b/application/i18n/locales/es/scripts.ts index d452a6b2d..64f456bde 100644 --- a/application/i18n/locales/es/scripts.ts +++ b/application/i18n/locales/es/scripts.ts @@ -36,6 +36,8 @@ export const esScriptsMessages = { 'scripts.sidePanel.library': 'Biblioteca', 'scripts.sidePanel.running': 'En ejecución', 'scripts.sidePanel.newScript': 'Nuevo script', + 'scripts.sidePanel.viewList': 'Vista de lista', + 'scripts.sidePanel.viewStacked': 'Vista apilada', 'scripts.running.empty': 'No hay scripts en ejecución en esta sesión.', 'scripts.running.unnamed': 'Script sin título', 'scripts.running.status.running': 'En ejecución', diff --git a/application/i18n/locales/es/terminal.ts b/application/i18n/locales/es/terminal.ts index 1ec49da87..1c3ea9586 100644 --- a/application/i18n/locales/es/terminal.ts +++ b/application/i18n/locales/es/terminal.ts @@ -868,6 +868,13 @@ export const esTerminalMessages: Messages = { // Text Editor 'sftp.editor.wordWrap': 'Ajuste de línea', 'sftp.editor.maximize': 'Maximizar', + 'sftp.editor.popOut': 'Abrir en ventana de editor', + 'sftp.editor.dock': 'Anclar a la ventana principal', + 'sftp.editor.focusWindow': 'Enfocar ventana de editor', + 'sftp.editor.close': 'Cerrar', + 'sftp.editor.windowTitle': 'Editor', + 'sftp.editor.windowEmpty': 'No hay archivos abiertos', + 'sftp.editor.dockFailed': 'No se pudo anclar este archivo a la ventana principal', 'sftp.editor.unsavedTitle': 'Cambios sin guardar', 'sftp.editor.unsavedMessage': '{fileName} tiene cambios sin guardar. ¿Guardar antes de cerrar?', 'sftp.editor.discardChanges': 'Descartar', diff --git a/application/i18n/locales/es/vault.ts b/application/i18n/locales/es/vault.ts index 2a8536a69..b6b712c07 100644 --- a/application/i18n/locales/es/vault.ts +++ b/application/i18n/locales/es/vault.ts @@ -407,6 +407,7 @@ export const esVaultMessages: Messages = { 'sftp.transferCenter.prioritize': 'Transferir a continuación', 'sftp.transferCenter.pauseAll': 'Pausar todas', 'sftp.transferCenter.resumeAll': 'Reanudar todas', + 'sftp.transferCenter.cancelAll': 'Cancelar todas', 'sftp.transferCenter.empty': 'No hay transferencias en esta categoría', 'sftp.transferCenter.showBackground': 'Mostrar {count} transferencias en segundo plano', 'sftp.transferCenter.hideBackground': 'Ocultar transferencias en segundo plano', diff --git a/application/i18n/locales/ru/ai.ts b/application/i18n/locales/ru/ai.ts index 2400e4a2d..216327ec8 100644 --- a/application/i18n/locales/ru/ai.ts +++ b/application/i18n/locales/ru/ai.ts @@ -6,6 +6,7 @@ export const ruAiMessages: Messages = { 'ai.agentSettings': 'Настройки агента', 'ai.chat.preparing': 'Подготовка…', 'ai.chat.modelCatalogWarning': 'Не удалось обновить список моделей. Показаны последние доступные или встроенные модели.', + 'ai.chat.modelCatalogRetry': 'Повторить', 'ai.chat.contextCompacted': 'Контекст сжат', 'ai.title': 'AI', 'ai.description': 'Настройка AI-провайдеров, агентов и параметров безопасности', @@ -468,12 +469,18 @@ export const ruAiMessages: Messages = { 'ai.chat.hoursAgo': '{n}ч назад', 'ai.chat.daysAgo': '{n}д назад', 'ai.chat.newChat': 'Новый чат', + 'ai.chat.undoLastTurn': 'Отменить последний ход', + 'ai.chat.undoLastTurnNotice': "Исходный чат сохранён. Команды и изменения файлов не отменяются. Полные сохранённые результаты и фоновые задачи доступны в исходном чате.", 'ai.chat.allSessions': 'Все сессии', 'ai.chat.loadEarlierMessages': 'Загрузить более ранние сообщения (ещё {n})', 'ai.chat.jumpNav': 'Перейти к сообщению', 'ai.chat.jumpUntitled': '(пустое сообщение)', 'ai.chat.usedTools': 'Использовано инструментов: {n}', 'ai.chat.loadMoreSessions': 'Загрузить больше сессий (ещё {n})', + 'ai.chat.searchSessions': 'Поиск по заголовкам и недавнему содержимому…', + 'ai.chat.clearSessionSearch': 'Очистить поиск сессий', + 'ai.chat.searchSessionsHint': 'Поиск по заголовкам с приоритетом более новых сообщений и записей. В длинных сообщениях и результатах инструментов поиск охватывает только ограниченную начальную часть. Это не полнотекстовый поиск.', + 'ai.chat.noMatchingSessions': 'Нет совпадений в заголовках и недавнем сохранённом содержимом', 'ai.chat.noSessions': 'Предыдущих сессий нет', 'ai.chat.forkFromHere': 'Ветвление отсюда', 'ai.chat.forkCreated': 'Разговор перенесён в новый чат; оригинал остаётся в истории.', diff --git a/application/i18n/locales/ru/scripts.ts b/application/i18n/locales/ru/scripts.ts index cfd2e3935..7067ccc62 100644 --- a/application/i18n/locales/ru/scripts.ts +++ b/application/i18n/locales/ru/scripts.ts @@ -36,6 +36,8 @@ export const ruScriptsMessages = { 'scripts.sidePanel.library': 'Библиотека', 'scripts.sidePanel.running': 'Выполняются', 'scripts.sidePanel.newScript': 'Новый скрипт', + 'scripts.sidePanel.viewList': 'Список', + 'scripts.sidePanel.viewStacked': 'Плитка', 'scripts.running.empty': 'В этой сессии не выполняются скрипты.', 'scripts.running.unnamed': 'Скрипт без названия', 'scripts.running.status.running': 'Выполняется', diff --git a/application/i18n/locales/ru/terminal.ts b/application/i18n/locales/ru/terminal.ts index bcf39ea61..5923e9d43 100644 --- a/application/i18n/locales/ru/terminal.ts +++ b/application/i18n/locales/ru/terminal.ts @@ -220,6 +220,7 @@ export const ruTerminalMessages: Messages = { 'terminal.progress.timeoutIn': 'Тайм-аут через {seconds}с', 'terminal.progress.waitingForUserInput': 'Ожидание ввода пользователя', 'terminal.progress.disconnected': 'Отключено', + 'terminal.progress.connected': 'Подключено', 'terminal.progress.cancelling': 'Отмена...', 'terminal.progress.startOver': 'Начать заново', 'terminal.progress.enterReconnectHint': 'Нажмите Enter, чтобы подключиться снова', @@ -875,6 +876,13 @@ export const ruTerminalMessages: Messages = { // Text Editor 'sftp.editor.wordWrap': 'Перенос строк', 'sftp.editor.maximize': 'Развернуть', + 'sftp.editor.popOut': 'Открыть в окне редактора', + 'sftp.editor.dock': 'Вернуть в главное окно', + 'sftp.editor.focusWindow': 'Перейти к окну редактора', + 'sftp.editor.close': 'Закрыть', + 'sftp.editor.windowTitle': 'Редактор', + 'sftp.editor.windowEmpty': 'Нет открытых файлов', + 'sftp.editor.dockFailed': 'Не удалось вернуть файл в главное окно', 'sftp.editor.unsavedTitle': 'Несохранённые изменения', 'sftp.editor.unsavedMessage': 'В файле {fileName} есть несохранённые изменения. Сохранить перед закрытием?', 'sftp.editor.discardChanges': 'Отбросить', diff --git a/application/i18n/locales/ru/vault.ts b/application/i18n/locales/ru/vault.ts index 9edaa32cd..5ce0c6b81 100644 --- a/application/i18n/locales/ru/vault.ts +++ b/application/i18n/locales/ru/vault.ts @@ -445,6 +445,7 @@ export const ruVaultMessages: Messages = { 'sftp.transferCenter.prioritize': 'Передать следующей', 'sftp.transferCenter.pauseAll': 'Приостановить все', 'sftp.transferCenter.resumeAll': 'Продолжить все', + 'sftp.transferCenter.cancelAll': 'Отменить все', 'sftp.transferCenter.empty': 'В этой категории нет передач', 'sftp.transferCenter.showBackground': 'Показать фоновые: {count}', 'sftp.transferCenter.hideBackground': 'Скрыть фоновые', @@ -752,6 +753,15 @@ export const ruVaultMessages: Messages = { 'hostDetails.title.new': 'Новый хост', 'vault.panel.resizeWidth': 'Изменить ширину панели', 'hostDetails.saveAria': 'Сохранить', + 'hostConnectionTest.title': 'Проверка подключения', + 'hostConnectionTest.test': 'Проверить', + 'hostConnectionTest.chainOf': 'Цепочка {current} из {total}', + 'hostConnectionTest.success': 'Подключение успешно', + 'hostConnectionTest.successHint': 'Хост принял ваши учётные данные. Можно закрыть окно.', + 'hostConnectionTest.connecting': 'Подключение… таймаут через {seconds} с', + 'hostConnectionTest.failed': 'Не удалось подключиться.', + 'hostConnectionTest.retry': 'Повторить', + 'hostConnectionTest.credentialsUnavailable': 'Сохранённые учётные данные не удаётся расшифровать на этом устройстве. Введите их заново ниже.', 'hostDetails.section.address': 'Адрес', 'hostDetails.hostname.placeholder': 'IP или имя хоста', 'hostDetails.section.general': 'Общие', diff --git a/application/i18n/locales/scriptsViewModeLocales.test.ts b/application/i18n/locales/scriptsViewModeLocales.test.ts new file mode 100644 index 000000000..34bca9ddb --- /dev/null +++ b/application/i18n/locales/scriptsViewModeLocales.test.ts @@ -0,0 +1,16 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import en from './en.ts'; +import ru from './ru.ts'; +import es from './es.ts'; +import zhCN from './zh-CN.ts'; +import zhTW from './zh-TW.ts'; + +test('scripts side panel view-toggle copy exists in every locale', () => { + const KEYS = ['scripts.sidePanel.viewList', 'scripts.sidePanel.viewStacked']; + for (const [locale, messages] of Object.entries({ en, ru, es, zhCN, zhTW })) { + const missing = KEYS.filter((key) => !(messages as Record)[key]); + assert.deepEqual(missing, [], `${locale} is missing scripts view-toggle copy`); + } +}); \ No newline at end of file diff --git a/application/i18n/locales/undoLastTurnLocales.test.ts b/application/i18n/locales/undoLastTurnLocales.test.ts new file mode 100644 index 000000000..32ace3e62 --- /dev/null +++ b/application/i18n/locales/undoLastTurnLocales.test.ts @@ -0,0 +1,20 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import en from './en.ts'; +import ru from './ru.ts'; +import es from './es.ts'; +import zhCN from './zh-CN.ts'; +import zhTW from './zh-TW.ts'; + +const UNDO_KEYS = [ + 'ai.chat.undoLastTurn', + 'ai.chat.undoLastTurnNotice', +] as const; + +test('Undo-last-turn UI is localized in every supported locale', () => { + for (const [name, messages] of Object.entries({ en, es, 'zh-CN': zhCN, 'zh-TW': zhTW, ru })) { + const missing = UNDO_KEYS.filter(key => !messages[key]); + assert.deepEqual(missing, [], `${name} is missing undo-last-turn labels`); + } +}); diff --git a/application/i18n/locales/zh-CN/ai.ts b/application/i18n/locales/zh-CN/ai.ts index d7c4c9eb3..d331eed61 100644 --- a/application/i18n/locales/zh-CN/ai.ts +++ b/application/i18n/locales/zh-CN/ai.ts @@ -5,6 +5,7 @@ export const zhCNAiMessages: Messages = { 'ai.agentSettings': 'Agent 设置', 'ai.chat.preparing': '准备中…', 'ai.chat.modelCatalogWarning': '暂时无法更新实时模型列表,当前显示上次可用或内置的模型。', + 'ai.chat.modelCatalogRetry': '重试', 'ai.chat.compactingContext': '正在压缩较早的上下文…', 'ai.chat.compactingStep': '正在为下一步整理上下文…', 'ai.chat.compactionRetry': '请求过大,正在压缩上下文并重试…', @@ -521,12 +522,18 @@ export const zhCNAiMessages: Messages = { 'ai.chat.hoursAgo': '{n}小时前', 'ai.chat.daysAgo': '{n}天前', 'ai.chat.newChat': '新对话', + 'ai.chat.undoLastTurn': '撤销上一轮', + 'ai.chat.undoLastTurnNotice': "原对话已保留。已执行的命令和文件修改不会撤销;之前保存的完整输出和后台任务,请回原对话查看。", 'ai.chat.allSessions': '所有会话', 'ai.chat.loadEarlierMessages': '加载更早的消息(还有 {n} 条)', 'ai.chat.jumpNav': '跳转到消息', 'ai.chat.jumpUntitled': '(空消息)', 'ai.chat.usedTools': '已使用 {n} 个工具', 'ai.chat.loadMoreSessions': '加载更多会话(还有 {n} 条)', + 'ai.chat.searchSessions': '搜索标题和近期内容…', + 'ai.chat.clearSessionSearch': '清空会话搜索', + 'ai.chat.searchSessionsHint': '搜索标题,并优先搜索较新的消息和条目;超长消息及工具输出仅搜索开头的有限部分。这不是全文搜索。', + 'ai.chat.noMatchingSessions': '标题和近期保留的内容中没有匹配结果', 'ai.chat.noSessions': '没有历史会话', 'ai.chat.forkFromHere': '从此处分叉', 'ai.chat.forkCreated': '已分叉到新对话,原对话保留在历史中。', diff --git a/application/i18n/locales/zh-CN/core.ts b/application/i18n/locales/zh-CN/core.ts index 402220da7..49ff8ca47 100644 --- a/application/i18n/locales/zh-CN/core.ts +++ b/application/i18n/locales/zh-CN/core.ts @@ -1051,6 +1051,7 @@ export const zhCNCoreMessages: Messages = { 'sftp.transferCenter.prioritize': '优先传输', 'sftp.transferCenter.pauseAll': '全部暂停', 'sftp.transferCenter.resumeAll': '全部继续', + 'sftp.transferCenter.cancelAll': '全部取消', 'sftp.transferCenter.empty': '当前分类没有传输任务', 'sftp.transferCenter.showBackground': '显示 {count} 个后台任务', 'sftp.transferCenter.hideBackground': '隐藏后台任务', diff --git a/application/i18n/locales/zh-CN/scripts.ts b/application/i18n/locales/zh-CN/scripts.ts index 615c52073..d9a2c8e4b 100644 --- a/application/i18n/locales/zh-CN/scripts.ts +++ b/application/i18n/locales/zh-CN/scripts.ts @@ -36,6 +36,8 @@ export const zhCNScriptsMessages = { 'scripts.sidePanel.library': '脚本库', 'scripts.sidePanel.running': '运行中', 'scripts.sidePanel.newScript': '新建脚本', + 'scripts.sidePanel.viewList': '列表视图', + 'scripts.sidePanel.viewStacked': '堆叠视图', 'scripts.running.empty': '当前会话没有正在运行的脚本。', 'scripts.running.unnamed': '未命名脚本', 'scripts.running.status.running': '运行中', diff --git a/application/i18n/locales/zh-CN/terminal.ts b/application/i18n/locales/zh-CN/terminal.ts index 55c731556..88fdbf91e 100644 --- a/application/i18n/locales/zh-CN/terminal.ts +++ b/application/i18n/locales/zh-CN/terminal.ts @@ -968,6 +968,13 @@ export const zhCNTerminalMessages: Messages = { // Text Editor 'sftp.editor.wordWrap': '自动换行', 'sftp.editor.maximize': '最大化', + 'sftp.editor.popOut': '在编辑器窗口打开', + 'sftp.editor.dock': '停靠回主窗口', + 'sftp.editor.focusWindow': '聚焦编辑器窗口', + 'sftp.editor.close': '关闭', + 'sftp.editor.windowTitle': '编辑器', + 'sftp.editor.windowEmpty': '没有打开的文件', + 'sftp.editor.dockFailed': '无法把该文件停靠回主窗口', 'sftp.editor.unsavedTitle': '未保存的修改', 'sftp.editor.unsavedMessage': '{fileName} 有未保存的修改,是否保存后关闭?', 'sftp.editor.discardChanges': '不保存', diff --git a/application/i18n/locales/zh-CN/vault.ts b/application/i18n/locales/zh-CN/vault.ts index cc3f1a3af..7db51ffd5 100644 --- a/application/i18n/locales/zh-CN/vault.ts +++ b/application/i18n/locales/zh-CN/vault.ts @@ -130,6 +130,15 @@ export const zhCNVaultMessages: Messages = { 'hostDetails.title.new': '新建主机', 'vault.panel.resizeWidth': '调整面板宽度', 'hostDetails.saveAria': '保存', + 'hostConnectionTest.title': '测试连接', + 'hostConnectionTest.test': '测试', + 'hostConnectionTest.chainOf': '第 {current}/{total} 跳', + 'hostConnectionTest.success': '连接成功', + 'hostConnectionTest.successHint': '主机已接受您的凭据。您可以关闭此窗口。', + 'hostConnectionTest.connecting': '正在连接… {seconds} 秒后超时', + 'hostConnectionTest.failed': '连接失败。', + 'hostConnectionTest.retry': '重试', + 'hostConnectionTest.credentialsUnavailable': '保存的凭据无法在此设备上解密。请在下方重新输入。', 'hostDetails.section.address': '地址', 'hostDetails.hostname.placeholder': 'IP 或 主机名', 'hostDetails.section.general': '通用', @@ -591,6 +600,7 @@ export const zhCNVaultMessages: Messages = { 'terminal.progress.timeoutIn': '将在 {seconds}s 后超时', 'terminal.progress.waitingForUserInput': '等待用户输入', 'terminal.progress.disconnected': '已断开', + 'terminal.progress.connected': '已连接', 'terminal.progress.cancelling': '正在取消...', 'terminal.progress.startOver': '重新开始', 'terminal.connection.dismissDisconnectedDialog': '关闭断连提示', diff --git a/application/i18n/locales/zh-TW/ai.ts b/application/i18n/locales/zh-TW/ai.ts index a25acdc45..3f14acdb3 100644 --- a/application/i18n/locales/zh-TW/ai.ts +++ b/application/i18n/locales/zh-TW/ai.ts @@ -5,6 +5,7 @@ export const zhTWAiMessages: Messages = { 'ai.agentSettings': 'Agent 設定', 'ai.chat.preparing': '準備中…', 'ai.chat.modelCatalogWarning': '暫時無法更新即時模型清單,目前顯示上次可用或內建的模型。', + 'ai.chat.modelCatalogRetry': '重試', 'ai.chat.compactingContext': '正在壓縮較早的上下文…', 'ai.chat.compactingStep': '正在為下一步整理上下文…', 'ai.chat.compactionRetry': '請求過大,正在壓縮上下文並重試…', @@ -511,12 +512,18 @@ export const zhTWAiMessages: Messages = { 'ai.chat.hoursAgo': '{n}小時前', 'ai.chat.daysAgo': '{n}天前', 'ai.chat.newChat': '新對話', + 'ai.chat.undoLastTurn': '復原上一輪', + 'ai.chat.undoLastTurnNotice': "原對話已保留。已執行的命令和檔案修改不會復原;之前儲存的完整輸出和背景工作,請回原對話查看。", 'ai.chat.allSessions': '所有工作階段', 'ai.chat.loadEarlierMessages': '載入更早的訊息(還有 {n} 條)', 'ai.chat.jumpNav': '跳轉到訊息', 'ai.chat.jumpUntitled': '(空訊息)', 'ai.chat.usedTools': '已使用 {n} 個工具', 'ai.chat.loadMoreSessions': '載入更多工作階段(還有 {n} 條)', + 'ai.chat.searchSessions': '搜尋標題和近期內容…', + 'ai.chat.clearSessionSearch': '清空會話搜尋', + 'ai.chat.searchSessionsHint': '搜尋標題,並優先搜尋較新的訊息與項目;超長訊息與工具輸出僅搜尋開頭的有限部分。這不是全文搜尋。', + 'ai.chat.noMatchingSessions': '標題和近期保留的內容中沒有符合的結果', 'ai.chat.noSessions': '沒有歷史工作階段', 'ai.chat.forkFromHere': '從此處分叉', 'ai.chat.forkCreated': '已分叉到新對話,原對話保留在歷史中。', diff --git a/application/i18n/locales/zh-TW/core.ts b/application/i18n/locales/zh-TW/core.ts index 738cb4c00..95431928f 100644 --- a/application/i18n/locales/zh-TW/core.ts +++ b/application/i18n/locales/zh-TW/core.ts @@ -1053,6 +1053,7 @@ export const zhTWCoreMessages: Messages = { 'sftp.transferCenter.prioritize': '優先傳輸', 'sftp.transferCenter.pauseAll': '全部暫停', 'sftp.transferCenter.resumeAll': '全部繼續', + 'sftp.transferCenter.cancelAll': '全部取消', 'sftp.transferCenter.empty': '目前分類沒有傳輸工作', 'sftp.transferCenter.showBackground': '顯示 {count} 個背景工作', 'sftp.transferCenter.hideBackground': '隱藏背景工作', diff --git a/application/i18n/locales/zh-TW/scripts.ts b/application/i18n/locales/zh-TW/scripts.ts index 1fde32bb8..f558b5b37 100644 --- a/application/i18n/locales/zh-TW/scripts.ts +++ b/application/i18n/locales/zh-TW/scripts.ts @@ -36,6 +36,8 @@ export const zhTWScriptsMessages = { 'scripts.sidePanel.library': '指令碼庫', 'scripts.sidePanel.running': '執行中', 'scripts.sidePanel.newScript': '新增指令碼', + 'scripts.sidePanel.viewList': '列表檢視', + 'scripts.sidePanel.viewStacked': '堆疊檢視', 'scripts.running.empty': '目前工作階段沒有正在執行的指令碼。', 'scripts.running.unnamed': '未命名指令碼', 'scripts.running.status.running': '執行中', diff --git a/application/i18n/locales/zh-TW/terminal.ts b/application/i18n/locales/zh-TW/terminal.ts index afa925f40..bc3df3feb 100644 --- a/application/i18n/locales/zh-TW/terminal.ts +++ b/application/i18n/locales/zh-TW/terminal.ts @@ -968,6 +968,13 @@ export const zhTWTerminalMessages: Messages = { // Text Editor 'sftp.editor.wordWrap': '自動換行', 'sftp.editor.maximize': '最大化', + 'sftp.editor.popOut': '在編輯器視窗開啟', + 'sftp.editor.dock': '停靠回主視窗', + 'sftp.editor.focusWindow': '聚焦編輯器視窗', + 'sftp.editor.close': '關閉', + 'sftp.editor.windowTitle': '編輯器', + 'sftp.editor.windowEmpty': '沒有開啟的檔案', + 'sftp.editor.dockFailed': '無法把此檔案停靠回主視窗', 'sftp.editor.unsavedTitle': '未儲存的修改', 'sftp.editor.unsavedMessage': '{fileName} 有未儲存的修改,是否儲存後關閉?', 'sftp.editor.discardChanges': '不儲存', diff --git a/application/i18n/locales/zh-TW/vault.ts b/application/i18n/locales/zh-TW/vault.ts index e06197860..8e248241a 100644 --- a/application/i18n/locales/zh-TW/vault.ts +++ b/application/i18n/locales/zh-TW/vault.ts @@ -129,6 +129,15 @@ export const zhTWVaultMessages: Messages = { 'hostDetails.title.details': '主機詳情', 'hostDetails.title.new': '新增主機', 'hostDetails.saveAria': '儲存', + 'hostConnectionTest.title': '測試連線', + 'hostConnectionTest.test': '測試', + 'hostConnectionTest.chainOf': '第 {current}/{total} 跳', + 'hostConnectionTest.success': '連線成功', + 'hostConnectionTest.successHint': '主機已接受您的憑證。您可以關閉此視窗。', + 'hostConnectionTest.connecting': '正在連線… {seconds} 秒後逾時', + 'hostConnectionTest.failed': '連線失敗。', + 'hostConnectionTest.retry': '重試', + 'hostConnectionTest.credentialsUnavailable': '儲存的憑證無法在此裝置上解密。請在下方重新輸入。', 'hostDetails.section.address': '位址', 'hostDetails.hostname.placeholder': 'IP 或 主機名', 'hostDetails.section.general': '通用', @@ -591,6 +600,7 @@ export const zhTWVaultMessages: Messages = { 'terminal.progress.timeoutIn': '將在 {seconds}s 後超時', 'terminal.progress.waitingForUserInput': '等待使用者輸入', 'terminal.progress.disconnected': '已斷開', + 'terminal.progress.connected': '已連線', 'terminal.progress.cancelling': '正在取消...', 'terminal.progress.startOver': '重新開始', 'terminal.connection.dismissDisconnectedDialog': '關閉斷連提示', diff --git a/application/state/aiStateSnapshots.test.ts b/application/state/aiStateSnapshots.test.ts index 0d3d178dc..fd5f62af0 100644 --- a/application/state/aiStateSnapshots.test.ts +++ b/application/state/aiStateSnapshots.test.ts @@ -466,6 +466,115 @@ test('writeSessionsForStorage reduces several small sessions to fit a sub-512 KB } }); +test('serializeSessionsForStorage truncates slightly-over-cap tool results to strictly smaller sizes', async () => { + const { serializeSessionsForStorage } = await import('./aiStateSnapshots'); + // Just over the 8 KiB cap: the truncation marker must not make the degraded + // copy larger than the original, or the ladder could evict a session whose + // unmodified JSON would have fit the budget. + for (const oversize of [8193, 8266, 20000]) { + const session = makeSession('near-cap', 1, [{ + id: 'tool-message', + role: 'tool', + content: '', + timestamp: 1, + toolResults: [{ toolCallId: 'call-1', content: 'z'.repeat(oversize) }], + }]); + const fullJson = JSON.stringify([session]); + const result = serializeSessionsForStorage([session], fullJson.length - 10); + const content = result.sessions[0].messages[0].toolResults?.[0].content ?? ''; + assert.ok(content.length <= 8 * 1024, `cap respected for ${oversize}`); + assert.ok( + JSON.stringify(result.sessions).length < fullJson.length, + `degraded copy smaller than original for ${oversize}`, + ); + assert.ok(content.includes('truncated')); + } +}); + +test('serializeSessionsForStorage sheds oversized tool output before dropping visible sessions', async () => { + const { serializeSessionsForStorage } = await import('./aiStateSnapshots'); + const newest = makeSession('newest', 2, [{ + id: 'new-message', + role: 'assistant', + content: 'still-open chat', + timestamp: 2, + }]); + const toolBulk = makeSession('tool-bulk', 1, [{ + id: 'tool-message', + role: 'tool', + content: '', + timestamp: 1, + toolResults: [{ toolCallId: 'call-1', content: 'z'.repeat(400 * 1024) }], + }]); + + const result = serializeSessionsForStorage([toolBulk, newest], 100 * 1024); + + // The bloated tool result must not evict the other visible chat. + assert.deepEqual(result.sessions.map(session => session.id), ['newest', 'tool-bulk']); + assert.ok(result.json.length <= 100 * 1024); + const persistedToolContent = result.sessions[1].messages[0].toolResults?.[0].content; + assert.ok(persistedToolContent); + assert.ok(persistedToolContent.length <= 9 * 1024); + assert.ok(persistedToolContent.includes('truncated')); + assert.ok(persistedToolContent.startsWith('zzz')); + assert.equal(result.sessions[0].messages[0].content, 'still-open chat'); +}); + +test('writeSessionsForStorage keeps every chat by shedding tool output under quota pressure', async () => { + const { writeSessionsForStorage } = await import('./aiStateSnapshots'); + let stored: string | undefined; + const previousLocalStorage = globalThis.localStorage; + Object.defineProperty(globalThis, 'localStorage', { + configurable: true, + value: { + getItem: () => null, + setItem: (_key: string, value: string) => { + if (value.length > 300 * 1024) { + throw new DOMException('quota exceeded', 'QuotaExceededError'); + } + stored = value; + }, + removeItem: () => {}, + }, + }); + + try { + const sessions = Array.from({ length: 10 }, (_, index) => makeSession( + `session-${index}`, + 10 - index, + [{ + id: `message-${index}`, + role: 'tool' as const, + content: '', + timestamp: index, + toolResults: [{ toolCallId: `call-${index}`, content: 'z'.repeat(40 * 1024) }], + }], + )); + + assert.equal(writeSessionsForStorage(sessions), true); + assert.ok(stored); + assert.ok(stored.length <= 300 * 1024); + const persisted = JSON.parse(stored) as AISession[]; + assert.deepEqual( + persisted.map(session => session.id), + sessions.map(session => session.id), + ); + // Degradation sheds only as many messages as the budget demands, so at + // least one chat keeps its full tool output while the persisted set stays + // complete. Assert completion first; truncation second. + const shedContents = persisted.map( + session => session.messages[0].toolResults?.[0].content ?? '', + ); + assert.ok(shedContents.some(content => content.includes('truncated'))); + assert.ok(shedContents.some(content => content.startsWith('zzz') && !content.includes('truncated'))); + } finally { + Object.defineProperty(globalThis, 'localStorage', { + configurable: true, + value: previousLocalStorage, + }); + } +}); + test('writeSessionsForStorage makes a final attempt with only the newest session', async () => { const { writeSessionsForStorage } = await import('./aiStateSnapshots'); const writes: string[] = []; diff --git a/application/state/aiStateSnapshots.ts b/application/state/aiStateSnapshots.ts index 5ff7506cd..3ddbdc58f 100644 --- a/application/state/aiStateSnapshots.ts +++ b/application/state/aiStateSnapshots.ts @@ -195,12 +195,40 @@ const RETRY_SESSIONS_JSON_BYTES = [ 128 * 1024, ] as const; +/** + * Maximum persisted length of a single tool result payload. Terminal tool + * output can reach hundreds of KB per command, and AI-driven terminal work + * accumulates many of them. When storage pressure forces degradation, shedding + * this replay bulk keeps every visible conversation persisted instead of + * evicting whole chats that happen to carry it. + */ +const MAX_PERSISTED_TOOL_RESULT_CHARS = 8 * 1024; + +function truncatePersistedToolResultContent(content: string): string { + if (content.length <= MAX_PERSISTED_TOOL_RESULT_CHARS) return content; + // The marker consumes budget and must stay single-line: JSON-escaped + // newlines would cost 2 characters each, letting a slightly-over-cap + // result serialize at the same (or larger) size than the original — the + // ladder assumes the degraded form is the smallest. Shrink the retained + // head+tail until the whole persisted copy fits the cap. + let half = Math.floor(MAX_PERSISTED_TOOL_RESULT_CHARS / 2); + let omitted = content.length - 2 * half; + let marker = `[Netcatty] persisted copy truncated; ${omitted} characters of tool output omitted`; + while (2 * half + marker.length > MAX_PERSISTED_TOOL_RESULT_CHARS) { + half -= Math.ceil((2 * half + marker.length - MAX_PERSISTED_TOOL_RESULT_CHARS) / 2); + omitted = content.length - 2 * half; + marker = `[Netcatty] persisted copy truncated; ${omitted} characters of tool output omitted`; + } + return `${content.slice(0, half)}${marker}${content.slice(content.length - half)}`; +} + /** * Remove `reasoningEncryptedContent` ciphertext from a message's persisted - * continuation. The ciphertext exists so stateless Responses turns can replay - * prior reasoning items; it is also by far the largest per-message payload. - * When storage pressure forces it, dropping the ciphertext keeps the visible - * conversation intact at the cost of reasoning replay for affected messages. + * continuation and shed bulk tool output. The ciphertext exists so stateless + * Responses turns can replay prior reasoning items; tool output dominates + * persisted size in AI-driven terminal chats. When storage pressure forces + * it, shedding this bulk keeps the visible conversation intact at the cost of + * reasoning replay and full tool output for affected messages. */ function stripReasoningEncryptedContent( options: ProviderContinuationOptions, @@ -218,7 +246,7 @@ function stripReasoningEncryptedContent( return Object.keys(stripped).length ? stripped : undefined; } -function stripEncryptedReasoningFromMessage(message: AISession['messages'][number]) { +function stripReasoningCiphertextFromMessage(message: AISession['messages'][number]) { const continuation = message.providerContinuation; if (!continuation?.reasoningParts) return message; let changed = false; @@ -239,6 +267,22 @@ function stripEncryptedReasoningFromMessage(message: AISession['messages'][numbe }; } +function stripEncryptedReasoningFromMessage(message: AISession['messages'][number]) { + const strippedContinuationMessage = stripReasoningCiphertextFromMessage(message); + const toolResults = strippedContinuationMessage.toolResults; + if (!toolResults?.length) return strippedContinuationMessage; + let toolResultsChanged = false; + const nextToolResults = toolResults.map((result) => { + const content = truncatePersistedToolResultContent(result.content); + if (content === result.content) return result; + toolResultsChanged = true; + return { ...result, content }; + }); + return toolResultsChanged + ? { ...strippedContinuationMessage, toolResults: nextToolResults } + : strippedContinuationMessage; +} + function stripEncryptedReasoningFromSession(session: AISession): AISession { let changed = false; const messages = session.messages.map(message => { @@ -259,7 +303,7 @@ function stripCompactedEncryptedReasoningFromSession(session: AISession): AISess let changed = false; const messages = session.messages.map((message, index) => { if (index >= compactedMessageCount) return message; - const stripped = stripEncryptedReasoningFromMessage(message); + const stripped = stripReasoningCiphertextFromMessage(message); if (stripped !== message) changed = true; return stripped; }); @@ -310,6 +354,11 @@ export function pruneSessionsForStorage(sessions: AISession[]): AISession[] { * Serialize sessions for localStorage under a byte budget, escalating pruning * as needed. Returns the JSON to persist plus the (possibly) further-pruned * sessions that JSON represents. + * + * Degradation ladder, cheapest loss first: strip replay ciphertext, shed bulk + * tool output (kept at a bounded head+tail per result), and only then drop + * oldest whole sessions. Evicting visible chats to preserve tool bulk is what + * emptied history for users under storage pressure. */ export function serializeSessionsForStorage( sessions: AISession[], @@ -321,13 +370,13 @@ export function serializeSessionsForStorage( // before deciding whether the newest session deserves full protection; // otherwise dead metadata can evict other visible conversations. const session = stripCompactedEncryptedReasoningFromSession(rawSession); - const ciphertextMessages = session.messages.flatMap((message, index) => { - const strippedMessage = stripEncryptedReasoningFromMessage(message); - if (strippedMessage === message) return []; + const degradableMessages = session.messages.flatMap((message, index) => { + const degradedMessage = stripEncryptedReasoningFromMessage(message); + if (degradedMessage === message) return []; return [{ index, - strippedMessage, - jsonLengthDelta: JSON.stringify(strippedMessage).length - JSON.stringify(message).length, + degradedMessage, + jsonLengthDelta: JSON.stringify(degradedMessage).length - JSON.stringify(message).length, }]; }); const strippedSession = stripEncryptedReasoningFromSession(session); @@ -339,7 +388,7 @@ export function serializeSessionsForStorage( strippedJson: strippedSession === session ? json : JSON.stringify(strippedSession), - ciphertextMessages, + degradableMessages, }; }); @@ -364,11 +413,12 @@ export function serializeSessionsForStorage( minimalLength -= removed.strippedJson.length + 1; } - // Then keep full continuation data for the retained sessions and remove - // replay-only ciphertext from the oldest retained sessions only as needed, - // never touching the protected newest session. Within one session, remove - // it one message at a time from oldest to newest so a long active chat can - // retain its most recent replayable tool exchange when that still fits. + // Then keep full continuation data for the retained sessions and shed + // replay-only bulk (ciphertext, oversized tool output) from the oldest + // retained sessions only as needed, never touching the protected newest + // session. Within one session, shed it one message at a time from oldest to + // newest so a long active chat can retain its most recent replayable tool + // exchange when that still fits. let jsonLength = 2 + serialized.reduce((total, entry) => total + entry.json.length, 0) + Math.max(0, serialized.length - 1); const firstStrippableIndex = protectNewestContinuation ? 1 : 0; @@ -378,27 +428,27 @@ export function serializeSessionsForStorage( index -= 1 ) { const current = serialized[index]; - let strippedMessageCount = 0; - while (jsonLength > budgetBytes && strippedMessageCount < current.ciphertextMessages.length) { - jsonLength += current.ciphertextMessages[strippedMessageCount].jsonLengthDelta; - strippedMessageCount += 1; + let degradedMessageCount = 0; + while (jsonLength > budgetBytes && degradedMessageCount < current.degradableMessages.length) { + jsonLength += current.degradableMessages[degradedMessageCount].jsonLengthDelta; + degradedMessageCount += 1; } - if (strippedMessageCount > 0) { - const strippedMessagesByIndex = new Map( - current.ciphertextMessages - .slice(0, strippedMessageCount) - .map(entry => [entry.index, entry.strippedMessage] as const), + if (degradedMessageCount > 0) { + const degradedMessagesByIndex = new Map( + current.degradableMessages + .slice(0, degradedMessageCount) + .map(entry => [entry.index, entry.degradedMessage] as const), ); const nextSession = { ...current.session, messages: current.session.messages.map((message, messageIndex) => ( - strippedMessagesByIndex.get(messageIndex) ?? message + degradedMessagesByIndex.get(messageIndex) ?? message )), }; const nextJson = JSON.stringify(nextSession); const projectedJsonLength = current.json.length - + current.ciphertextMessages - .slice(0, strippedMessageCount) + + current.degradableMessages + .slice(0, degradedMessageCount) .reduce((total, entry) => total + entry.jsonLengthDelta, 0); jsonLength += nextJson.length - projectedJsonLength; serialized[index] = { diff --git a/application/state/editorModalDetach.test.ts b/application/state/editorModalDetach.test.ts new file mode 100644 index 000000000..ca06b17df --- /dev/null +++ b/application/state/editorModalDetach.test.ts @@ -0,0 +1,109 @@ +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 { editorTabStore } from "../../application/state/editorTabStore.ts"; +import { netcattyBridge } from "../../infrastructure/services/netcattyBridge.ts"; +import { toast } from "../../components/ui/toast.tsx"; +import { useSftpViewFileOps } from "../../components/sftp/hooks/useSftpViewFileOps.ts"; +import type { UseSftpViewFileOpsParams, UseSftpViewFileOpsResult } from "../../components/sftp/hooks/useSftpViewFileOps.types.ts"; + +for (const action of ["onPopOut", "onPromoteToTab"] as const) { + test(`${action} preserves a modified modal when the file is already detached`, async (t) => { + const environment = globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }; + const previous = environment.IS_REACT_ACT_ENVIRONMENT; + environment.IS_REACT_ACT_ENVIRONMENT = true; + let ops!: UseSftpViewFileOpsResult; + const params = { + sftpRef: { current: { + leftPane: { id: "pane", connection: { id: "conn", hostId: "host" } }, + readTextFile: async () => "original", + } }, + behaviorRef: { current: "" }, autoSyncRef: { current: false }, + getOpenerForFileRef: { current: () => null }, setOpenerForExtension: () => {}, + t: (key: string) => key, + } as unknown as UseSftpViewFileOpsParams; + const original = { + editorId: "modal-test", sessionId: "conn", sftpTabId: "pane", hostId: "host", + remotePath: "/file.txt", fileName: "file.txt", languageId: "plaintext", + content: "window edits", baselineContent: "original", wordWrap: false, viewState: null, + }; + editorTabStore.upsertFromSnapshot(original, "window"); + const before = editorTabStore.getTab(original.editorId); + t.after(() => editorTabStore.close(original.editorId)); + let focusCount = 0; + t.mock.method(netcattyBridge, "get", () => ({ + focusEditorWindow: async () => { focusCount++; return { success: true }; }, + })); + const errors = t.mock.method(toast, "error", () => {}); + function Harness() { ops = useSftpViewFileOps(params); return null; } + let renderer!: ReactTestRenderer; + await act(async () => { renderer = create(React.createElement(Harness)); }); + t.after(async () => { + await act(async () => renderer.unmount()); + environment.IS_REACT_ACT_ENVIRONMENT = previous; + }); + await act(async () => { + await ops.onEditFileLeft({ name: "file.txt", size: 8 } as Parameters[0], "/./file.txt"); + }); + const modal = { ...original, content: "modal edits" }; + await act(async () => { ops[action](modal); }); + assert.equal(ops.showTextEditor, true); + assert.equal(ops.textEditorTarget?.fullPath, "/./file.txt"); + assert.equal(ops.textEditorContent, "original"); + assert.equal(modal.content, "modal edits"); + assert.equal(editorTabStore.getTab(original.editorId), before); + assert.equal(focusCount, 0); + assert.equal(errors.mock.callCount(), 1); + }); +} + +for (const focusSucceeds of [true, false]) { + test(`Maximize dismisses a clean duplicate modal only after detached focus succeeds: ${focusSucceeds}`, async (t) => { + const environment = globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }; + const previous = environment.IS_REACT_ACT_ENVIRONMENT; + environment.IS_REACT_ACT_ENVIRONMENT = true; + let ops!: UseSftpViewFileOpsResult; + const params = { + sftpRef: { current: { + leftPane: { id: "pane", connection: { id: "conn", hostId: "host" } }, + readTextFile: async () => "original", + } }, + behaviorRef: { current: "" }, autoSyncRef: { current: false }, + getOpenerForFileRef: { current: () => null }, setOpenerForExtension: () => {}, + t: (key: string) => key, + } as unknown as UseSftpViewFileOpsParams; + const detached = { + editorId: "clean-duplicate", sessionId: "conn", sftpTabId: "pane", hostId: "host", + remotePath: "/file.txt", fileName: "file.txt", languageId: "plaintext", + content: "detached edits", baselineContent: "original", wordWrap: false, viewState: null, + }; + editorTabStore.upsertFromSnapshot(detached, "window"); + const before = editorTabStore.getTab(detached.editorId); + t.after(() => editorTabStore.close(detached.editorId)); + let finishFocus!: (result: { success: boolean }) => void; + const focusing = new Promise<{ success: boolean }>((resolve) => { finishFocus = resolve; }); + let focusCount = 0; + t.mock.method(netcattyBridge, "get", () => ({ + focusEditorWindow: () => { focusCount++; return focusing; }, + })); + function Harness() { ops = useSftpViewFileOps(params); return null; } + let renderer!: ReactTestRenderer; + await act(async () => { renderer = create(React.createElement(Harness)); }); + t.after(async () => { + await act(async () => renderer.unmount()); + environment.IS_REACT_ACT_ENVIRONMENT = previous; + }); + await act(async () => { + await ops.onEditFileLeft({ name: "file.txt", size: 8 } as Parameters[0], "/file.txt"); + }); + await act(async () => { ops.onPromoteToTab({ ...detached, content: "original" }); }); + assert.equal(focusCount, 1); + assert.equal(ops.showTextEditor, true, "keep the modal until focus is acknowledged"); + await act(async () => { finishFocus({ success: focusSucceeds }); }); + assert.equal(ops.showTextEditor, !focusSucceeds); + assert.equal(ops.textEditorTarget?.fullPath ?? null, focusSucceeds ? null : "/file.txt"); + assert.equal(ops.textEditorContent, focusSucceeds ? "" : "original"); + assert.equal(editorTabStore.getTab(detached.editorId), before, "focusing must preserve existing detached edits"); + }); +} diff --git a/application/state/editorTabStore.identity.test.ts b/application/state/editorTabStore.identity.test.ts new file mode 100644 index 000000000..78a919e86 --- /dev/null +++ b/application/state/editorTabStore.identity.test.ts @@ -0,0 +1,16 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +test("independent renderers creating editors in the same millisecond get distinct identities", async (t) => { + t.mock.method(Date, "now", () => 1234567890); + const [firstRenderer, secondRenderer] = await Promise.all([ + import(new URL("./editorTabStore.ts?renderer=first", import.meta.url).href), + import(new URL("./editorTabStore.ts?renderer=second", import.meta.url).href), + ]); + assert.notEqual(firstRenderer.EditorTabStore, secondRenderer.EditorTabStore, "use independent renderer module state"); + const snapshot = { sessionId: "session", sftpTabId: "pane", hostId: "host", remotePath: "/file.txt", fileName: "file.txt", languageId: "plaintext", content: "edits", baselineContent: "original", wordWrap: false, viewState: null }; + const first = new firstRenderer.EditorTabStore().promoteFromModal(snapshot); + const second = new secondRenderer.EditorTabStore().promoteFromModal(snapshot); + assert.notEqual(first, second, "process-wide ownership must not collide across renderer-local counters"); + assert.match(first, /^edt_[\da-f]{8}-[\da-f]{4}-[\da-f]{4}-[\da-f]{4}-[\da-f]{12}$/i); +}); diff --git a/application/state/editorTabStore.test.ts b/application/state/editorTabStore.test.ts index 9fe03f6ff..2f8339140 100644 --- a/application/state/editorTabStore.test.ts +++ b/application/state/editorTabStore.test.ts @@ -28,6 +28,20 @@ const makeTab = (overrides: Partial = {}): EditorTab => ({ ...overrides, }); +test("clean modal reuses a detached tab without changing its dirty state or content", () => { + const store = new EditorTabStore(); + const tab = makeTab({ placement: "window", windowDirty: true, content: "", baselineContent: "" }); + store._debugInsert(tab); + const id = store.promoteFromModal({ + ...makeTab(), + remotePath: "/etc//nginx/./nginx.conf", + }); + assert.equal(id, tab.id); + assert.equal(store.getTab(id), tab); + assert.equal(store.isDirty(id), true); + assert.equal(store.getTabs().length, 1); +}); + test("updateContent stores content and viewState; dirty flag derives from baseline", () => { const store = new EditorTabStore(); store._debugInsert(makeTab()); diff --git a/application/state/editorTabStore.ts b/application/state/editorTabStore.ts index 61d0f41d7..2f50204ee 100644 --- a/application/state/editorTabStore.ts +++ b/application/state/editorTabStore.ts @@ -2,6 +2,7 @@ import { useCallback, useMemo, useSyncExternalStore } from "react"; import type * as Monaco from "monaco-editor"; import { activeTabStore, fromEditorTabId, isEditorTabId } from "./activeTabStore"; +import type { EditorTabPlacement, EditorWindowTabSnapshot } from "./editorWindowTypes"; // POSIX-style normalization: collapse "/./" and duplicate slashes, not ".." (remote paths // may contain semantic ".." segments we don't want to resolve client-side). @@ -23,6 +24,7 @@ export interface EditorTab { sftpTabId: string; /** Stable endpoint id; used to verify the session is still the one we opened against. */ hostId: string; + hostLabel?: string; remotePath: string; fileName: string; languageId: string; @@ -32,12 +34,16 @@ export interface EditorTab { viewState: Monaco.editor.ICodeEditorViewState | null; savingState: EditorSavingState; saveError: string | null; + placement?: EditorTabPlacement; + windowDirty?: boolean; } type Listener = () => void; -let idCounter = 0; -const genId = (): EditorTabId => `edt_${Date.now().toString(36)}_${(++idCounter).toString(36)}`; +const genId = (): EditorTabId => `edt_${crypto.randomUUID()}`; + +export const tabIsDirty = (tab: EditorTab): boolean => + tab.placement === "window" ? tab.windowDirty === true : tab.content !== tab.baselineContent; export class EditorTabStore { private tabs: EditorTab[] = []; @@ -79,7 +85,82 @@ export class EditorTabStore { }; isDirty = (id: EditorTabId): boolean => { const t = this.getTab(id); - return !!t && t.content !== t.baselineContent; + return !!t && tabIsDirty(t); + }; + + listByOwner = (owner: { sessionId?: string; sftpTabId?: string }): EditorTab[] => + this.tabs.filter((t) => this.tabMatchesOwner(t, owner)); + + markDetached = (id: EditorTabId, dirty: boolean, releaseContent = true): void => { + this.patch(id, { + placement: "window", + windowDirty: dirty, + ...(releaseContent ? { content: "", baselineContent: "", viewState: null } : {}), + }); + this.notifyStructural(); + }; + + setWindowDirty = (id: EditorTabId, dirty: boolean): void => { + const tab = this.getTab(id); + if (!tab || tab.placement !== "window" || tab.windowDirty === dirty) return; + this.patch(id, { windowDirty: dirty }); + }; + + upsertFromSnapshot = ( + snapshot: EditorWindowTabSnapshot, + placement: EditorTabPlacement = "tab", + ): EditorTabId => { + const existingById = snapshot.editorId ? this.getTab(snapshot.editorId) : undefined; + const normalized = normalizePath(snapshot.remotePath); + const existing = existingById ?? this.tabs.find( + (t) => t.sessionId === snapshot.sessionId && normalizePath(t.remotePath) === normalized, + ); + const next: Partial = { + sessionId: snapshot.sessionId, + sftpTabId: snapshot.sftpTabId, + hostId: snapshot.hostId, + hostLabel: snapshot.hostLabel, + remotePath: snapshot.remotePath, + fileName: snapshot.fileName, + languageId: snapshot.languageId, + content: placement === "window" ? "" : snapshot.content, + baselineContent: placement === "window" ? "" : snapshot.baselineContent, + wordWrap: snapshot.wordWrap, + viewState: placement === "window" ? null : snapshot.viewState as EditorTab["viewState"], + placement, + windowDirty: placement === "window" + ? snapshot.content !== snapshot.baselineContent + : false, + savingState: "idle", + saveError: null, + }; + if (existing) { + this.patch(existing.id, next); + if (existing.placement !== placement) this.notifyStructural(); + return existing.id; + } + const tab: EditorTab = { + id: snapshot.editorId || this.makeId(), + kind: "editor", + sessionId: snapshot.sessionId, + sftpTabId: snapshot.sftpTabId, + hostId: snapshot.hostId, + hostLabel: snapshot.hostLabel, + remotePath: snapshot.remotePath, + fileName: snapshot.fileName, + languageId: snapshot.languageId, + content: next.content ?? "", + baselineContent: next.baselineContent ?? "", + wordWrap: snapshot.wordWrap, + viewState: (next.viewState ?? null) as EditorTab["viewState"], + savingState: "idle", + saveError: null, + placement, + windowDirty: next.windowDirty === true, + }; + this.tabs = [...this.tabs, tab]; + this.notifyStructural(); + return tab.id; }; updateContent = ( @@ -91,6 +172,12 @@ export class EditorTabStore { }; markSaved = (id: EditorTabId, newBaseline: string) => { + const tab = this.getTab(id); + if (tab?.placement === "window") { + // Only the detached renderer has the current content. Wait for its dirty report. + this.patch(id, { savingState: "idle", saveError: null }); + return; + } this.patch(id, { baselineContent: newBaseline, savingState: "idle", saveError: null }); }; @@ -174,17 +261,26 @@ export class EditorTabStore { baselineContent: string; wordWrap: boolean; viewState: Monaco.editor.ICodeEditorViewState | null; + placement?: EditorTabPlacement; }): EditorTabId => { const normalized = normalizePath(snapshot.remotePath); const existing = this.tabs.find( (t) => t.sessionId === snapshot.sessionId && normalizePath(t.remotePath) === normalized, ); if (existing) { + if (existing.placement === "window") { + if (snapshot.content !== snapshot.baselineContent) { + throw new Error("This file is already open in the editor window. Your changes are still in this dialog. Dock or close the existing editor before transferring these changes."); + } + return existing.id; + } this.patch(existing.id, { content: snapshot.content, baselineContent: snapshot.baselineContent, wordWrap: snapshot.wordWrap, viewState: snapshot.viewState, + placement: snapshot.placement ?? existing.placement ?? "tab", + windowDirty: false, // keep languageId/hostId/fileName stable; they shouldn't change for the same path }); return existing.id; @@ -204,6 +300,8 @@ export class EditorTabStore { viewState: snapshot.viewState, savingState: "idle", saveError: null, + placement: snapshot.placement ?? "tab", + windowDirty: false, }; this.tabs = [...this.tabs, tab]; this.notifyStructural(); @@ -221,9 +319,9 @@ export class EditorTabStore { saveTab?: (tabId: EditorTabId) => Promise, onCloseTab?: (tabId: EditorTabId) => void, ): Promise => { - const matching = this.tabs.filter((t) => this.tabMatchesOwner(t, owner)); + const matching = this.tabs.filter((t) => this.tabMatchesOwner(t, owner) && t.placement !== "window"); for (const tab of matching) { - const dirty = tab.content !== tab.baselineContent; + const dirty = tabIsDirty(tab); if (!dirty) { onCloseTab?.(tab.id); this.close(tab.id); @@ -332,9 +430,11 @@ export type EditorTabChrome = Pick< | 'sessionId' | 'sftpTabId' | 'hostId' + | 'hostLabel' | 'remotePath' | 'fileName' | 'languageId' + | 'placement' >; const projectEditorTabChrome = (tab: EditorTab): EditorTabChrome => ({ @@ -343,9 +443,11 @@ const projectEditorTabChrome = (tab: EditorTab): EditorTabChrome => ({ sessionId: tab.sessionId, sftpTabId: tab.sftpTabId, hostId: tab.hostId, + hostLabel: tab.hostLabel, remotePath: tab.remotePath, fileName: tab.fileName, languageId: tab.languageId, + placement: tab.placement ?? "tab", }); export const useHasEditorTabForSessions = ( diff --git a/application/state/editorWindowClient.test.ts b/application/state/editorWindowClient.test.ts new file mode 100644 index 000000000..a61e96917 --- /dev/null +++ b/application/state/editorWindowClient.test.ts @@ -0,0 +1,147 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { netcattyBridge } from "../../infrastructure/services/netcattyBridge.ts"; +import { editorTabStore } from "./editorTabStore.ts"; +import { dockDetachedEditorTab, installEditorWindowSourceListeners, popOutEditorTab, saveDetachedEditorTab } from "./editorWindowClient.ts"; +import { registerEditorSftpWriterScoped } from "./editorSftpBridge.ts"; +import { releaseEditorTabSaveCoordinator } from "./editorTabSave.ts"; +import type { EditorWindowSaveRequest, EditorWindowSaveResult } from "./editorWindowTypes.ts"; + +const snapshot = { + editorId: "save-test", sessionId: "conn", sftpTabId: "pane", hostId: "host", + remotePath: "/file", fileName: "file", languageId: "plaintext", + content: "v2", baselineContent: "v1", wordWrap: false, viewState: null, +}; + +for (const latestContent of ["v3", "v2", "v1"]) { + test(`detached save reports current dirty state after editing to ${latestContent}`, async (t) => { + editorTabStore.upsertFromSnapshot(snapshot); + t.after(() => editorTabStore.close(snapshot.editorId)); + let finish!: (result: { ok: boolean }) => void; + const pending = new Promise<{ ok: boolean }>((resolve) => { finish = resolve; }); + const reports: boolean[] = []; + t.mock.method(netcattyBridge, "get", () => ({ + saveEditorWindowTab: (request: { content: string }) => { + assert.equal(request.content, "v2"); + return pending; + }, + reportEditorWindowDirty: ({ dirty }: { dirty: boolean }) => reports.push(dirty), + })); + const saving = saveDetachedEditorTab(snapshot.editorId); + editorTabStore.updateContent(snapshot.editorId, latestContent, null); + finish({ ok: true }); + assert.deepEqual(await saving, { ok: true }); + assert.equal(editorTabStore.getTab(snapshot.editorId)?.baselineContent, "v2"); + assert.equal(editorTabStore.getTab(snapshot.editorId)?.content, latestContent); + assert.deepEqual(reports, [latestContent !== "v2"]); + }); +} + +test("source save receipt preserves dirty until the detached renderer reports it", async (t) => { + editorTabStore.upsertFromSnapshot(snapshot, "window"); + t.after(() => { + editorTabStore.close(snapshot.editorId); + releaseEditorTabSaveCoordinator(snapshot.editorId); + }); + let finish!: () => void; + const pending = new Promise((resolve) => { finish = resolve; }); + t.after(registerEditorSftpWriterScoped(async () => { await pending; return "conn"; })); + let onSave!: (request: EditorWindowSaveRequest) => Promise; + let onDirty!: (payload: { editorId: string; dirty: boolean }) => void; + const receipts: EditorWindowSaveResult[] = []; + t.mock.method(netcattyBridge, "get", () => ({ + onEditorWindowSaveRequest: (cb: typeof onSave) => { onSave = cb; return () => {}; }, + onEditorWindowDirtyChanged: (cb: typeof onDirty) => { onDirty = cb; return () => {}; }, + reportEditorWindowSaveResult: (result: EditorWindowSaveResult) => receipts.push(result), + })); + t.after(installEditorWindowSourceListeners()); + const saving = onSave({ ...snapshot, requestId: "request" }); + onDirty({ editorId: snapshot.editorId, dirty: true }); + finish(); + await saving; + assert.equal(receipts[0]?.ok, true); + assert.equal(editorTabStore.isDirty(snapshot.editorId), true); + onDirty({ editorId: snapshot.editorId, dirty: false }); + assert.equal(editorTabStore.isDirty(snapshot.editorId), false); +}); + +for (const outcome of ["accepted", "rejected", "failed"] as const) { + test(`popout retains source contents until the receiver ${outcome}`, async (t) => { + editorTabStore.upsertFromSnapshot(snapshot); + t.after(() => editorTabStore.close(snapshot.editorId)); + let resolve!: (result: { success: boolean }) => void; + let reject!: (error: Error) => void; + const receipt = new Promise<{ success: boolean }>((yes, no) => { resolve = yes; reject = no; }); + t.mock.method(netcattyBridge, "get", () => ({ openEditorWindow: () => receipt })); + const opening = popOutEditorTab(snapshot.editorId); + assert.equal(editorTabStore.getTab(snapshot.editorId)?.placement, "window"); + assert.equal(editorTabStore.getTab(snapshot.editorId)?.content, "v2"); + assert.equal(editorTabStore.getTab(snapshot.editorId)?.baselineContent, "v1"); + if (outcome === "failed") reject(new Error("IPC disconnected")); + else resolve({ success: outcome === "accepted" }); + assert.equal(await opening, outcome === "accepted"); + const tab = editorTabStore.getTab(snapshot.editorId)!; + assert.equal(tab.placement, outcome === "accepted" ? "window" : "tab"); + assert.equal(tab.content, outcome === "accepted" ? "" : "v2"); + assert.equal(tab.baselineContent, outcome === "accepted" ? "" : "v1"); + assert.equal(editorTabStore.isDirty(tab.id), true); + }); +} + +for (const outcome of ["accepted", "rejected", "failed"] as const) { + test(`dock freezes the current copy until ${outcome} and refuses a second transfer`, async (t) => { + editorTabStore.upsertFromSnapshot(snapshot); + editorTabStore.setSavingState(snapshot.editorId, "error", "previous save failed"); + t.after(() => editorTabStore.close(snapshot.editorId)); + let resolve!: (result: { success: boolean; error?: string }) => void; + let reject!: (error: Error) => void; + const receipt = new Promise<{ success: boolean; error?: string }>((yes, no) => { resolve = yes; reject = no; }); + let calls = 0; + t.mock.method(netcattyBridge, "get", () => ({ + dockEditorWindowTab: (sent: typeof snapshot) => { + calls++; + assert.equal(sent.content, "v2"); + return receipt; + }, + })); + const docking = dockDetachedEditorTab(snapshot.editorId); + assert.equal(editorTabStore.getTab(snapshot.editorId)?.savingState, "saving", "existing pane busy state makes Monaco read-only during transfer"); + assert.equal((await dockDetachedEditorTab(snapshot.editorId)).success, false); + assert.equal(calls, 1); + if (outcome === "failed") reject(new Error("IPC disconnected")); + else resolve({ success: outcome === "accepted", error: "Source unavailable" }); + assert.equal((await docking).success, outcome === "accepted"); + const tab = editorTabStore.getTab(snapshot.editorId)!; + assert.equal(tab.content, "v2"); + assert.equal(tab.baselineContent, "v1"); + assert.equal(tab.savingState, outcome === "accepted" ? "saving" : "error", "success stays frozen until the caller closes; failure restores interaction"); + if (outcome !== "accepted") { + assert.equal(tab.saveError, "previous save failed"); + editorTabStore.updateContent(snapshot.editorId, "v3", null); + assert.equal(editorTabStore.getTab(snapshot.editorId)?.content, "v3"); + } + }); +} + +test("dock does not take over an in-progress save", async (t) => { + editorTabStore.upsertFromSnapshot(snapshot); + editorTabStore.setSavingState(snapshot.editorId, "saving"); + t.after(() => editorTabStore.close(snapshot.editorId)); + let calls = 0; + t.mock.method(netcattyBridge, "get", () => ({ dockEditorWindowTab: async () => { calls++; return { success: true }; } })); + assert.equal((await dockDetachedEditorTab(snapshot.editorId)).success, false); + assert.equal(calls, 0); + assert.equal(editorTabStore.getTab(snapshot.editorId)?.savingState, "saving"); +}); + +test("a closing destination rejects a popout without releasing source content", async (t) => { + editorTabStore.upsertFromSnapshot(snapshot); + t.after(() => editorTabStore.close(snapshot.editorId)); + t.mock.method(netcattyBridge, "get", () => ({ openEditorWindow: async () => ({ success: false, error: "Editor window is closing" }) })); + assert.equal(await popOutEditorTab(snapshot.editorId), false); + const tab = editorTabStore.getTab(snapshot.editorId)!; + assert.equal(tab.placement, "tab"); + assert.equal(tab.content, "v2"); + assert.equal(tab.baselineContent, "v1"); + assert.equal(editorTabStore.isDirty(tab.id), true); +}); diff --git a/application/state/editorWindowClient.ts b/application/state/editorWindowClient.ts new file mode 100644 index 000000000..f7f6ca18d --- /dev/null +++ b/application/state/editorWindowClient.ts @@ -0,0 +1,283 @@ +import { netcattyBridge } from "../../infrastructure/services/netcattyBridge"; +import { saveEditorTab } from "./editorTabSave"; +import { + editorTabStore, + tabIsDirty, + type EditorTab, + type EditorTabId, +} from "./editorTabStore"; +import type { EditorWindowTabSnapshot } from "./editorWindowTypes"; +import { activeTabStore, toEditorTabId } from "./activeTabStore"; + +export function toEditorWindowSnapshot( + tab: EditorTab, + hostLabel?: string, +): EditorWindowTabSnapshot { + return { + editorId: tab.id, + sessionId: tab.sessionId, + sftpTabId: tab.sftpTabId, + hostId: tab.hostId, + hostLabel: hostLabel || tab.hostLabel, + remotePath: tab.remotePath, + fileName: tab.fileName, + languageId: tab.languageId, + content: tab.content, + baselineContent: tab.baselineContent, + wordWrap: tab.wordWrap, + viewState: tab.viewState, + }; +} + +export async function openEditorInWindow(snapshot: EditorWindowTabSnapshot): Promise { + const bridge = netcattyBridge.get(); + if (!bridge?.openEditorWindow) return false; + const result = await bridge.openEditorWindow(snapshot); + return result?.success === true; +} + +export async function focusEditorInWindow(editorId: EditorTabId): Promise { + const result = await netcattyBridge.get()?.focusEditorWindow?.(editorId); + return result?.success === true; +} + +export async function popOutEditorTab( + tabId: EditorTabId, + hostLabel?: string, +): Promise { + const tab = editorTabStore.getTab(tabId); + if (!tab) return false; + if (tab.placement === "window") { + return focusEditorInWindow(tabId); + } + const dirty = tabIsDirty(tab); + const snapshot = toEditorWindowSnapshot(tab, hostLabel || tab.hostLabel); + // Hide the source editor during transfer, but retain its only owned copy + // until the destination has acknowledged installing the tab. + editorTabStore.markDetached(tabId, dirty, false); + const ok = await openEditorInWindow(snapshot).catch(() => false); + if (!ok) { + if (editorTabStore.getTab(tabId)) editorTabStore.upsertFromSnapshot(snapshot, "tab"); + return false; + } + editorTabStore.markDetached(tabId, dirty); + const activeId = activeTabStore.getActiveTabId(); + if (activeId === toEditorTabId(tabId)) { + activeTabStore.setActiveTabId("vault"); + } + return true; +} + +export async function closeOwnedEditorWindowTabs( + owner: { sessionId?: string; sftpTabId?: string }, + options?: { force?: boolean }, +): Promise<{ cancelled: boolean; closedIds: string[] }> { + const matching = editorTabStore.listByOwner(owner).filter((tab) => tab.placement === "window"); + if (matching.length === 0) return { cancelled: false, closedIds: [] }; + const result = await netcattyBridge.get()?.closeEditorWindowTabs?.({ + editorIds: matching.map((tab) => tab.id), + force: options?.force === true, + }); + const closedIds = result?.closedIds ?? []; + for (const id of closedIds) editorTabStore.close(id); + if (!result || result.success === false) { + return { cancelled: options?.force !== true, closedIds }; + } + if (result.cancelled !== true) { + for (const tab of matching) editorTabStore.close(tab.id); + } + return { + cancelled: result.cancelled === true, + closedIds, + }; +} + +export async function confirmCloseOwnedEditors( + owner: { sessionId?: string; sftpTabId?: string }, + promptChoice: (tab: EditorTab) => Promise<"save" | "discard" | "cancel">, + saveTab?: (tabId: EditorTabId) => Promise, + onCloseTab?: (tabId: EditorTabId) => void, +): Promise { + const windowClose = await closeOwnedEditorWindowTabs(owner, { force: false }); + if (windowClose.cancelled) return false; + return editorTabStore.confirmCloseByOwner(owner, promptChoice, saveTab, onCloseTab); +} + +export function forceCloseOwnedEditors(owners: { + sessionIds?: readonly string[]; + sftpTabIds?: readonly string[]; +}): EditorTabId[] { + const matching = editorTabStore.getTabs().filter((tab) => { + if (tab.placement !== "window") return false; + if (owners.sessionIds?.includes(tab.sessionId)) return true; + if (owners.sftpTabIds?.includes(tab.sftpTabId)) return true; + return false; + }); + if (matching.length > 0) { + void netcattyBridge.get()?.closeEditorWindowTabs?.({ + editorIds: matching.map((tab) => tab.id), + force: true, + }); + } + return editorTabStore.forceCloseByOwners(owners); +} + +export function notifyEditorWindowSessionRemap(fromSessionId: string, toSessionId: string): void { + if (!fromSessionId || !toSessionId || fromSessionId === toSessionId) return; + netcattyBridge.get()?.remapEditorWindowSession?.({ fromSessionId, toSessionId }); +} + +export async function saveDetachedEditorTab(tabId: EditorTabId): Promise<{ ok: boolean; error?: string }> { + const current = editorTabStore.getTab(tabId); + if (!current) return { ok: false, error: "Editor tab closed before save completed" }; + editorTabStore.setSavingState(tabId, "saving"); + const result = await netcattyBridge.get()?.saveEditorWindowTab?.({ + editorId: current.id, + sessionId: current.sessionId, + sftpTabId: current.sftpTabId, + hostId: current.hostId, + remotePath: current.remotePath, + content: current.content, + }); + if (result?.ok) { + editorTabStore.markSaved(tabId, current.content); + netcattyBridge.get()?.reportEditorWindowDirty?.({ + editorId: tabId, + dirty: editorTabStore.isDirty(tabId), + }); + return { ok: true }; + } + const error = result?.error || "Save failed"; + editorTabStore.setSavingState(tabId, "error", error); + return { ok: false, error }; +} + +export async function dockDetachedEditorTab(tabId: EditorTabId): Promise<{ success: boolean; error?: string }> { + const tab = editorTabStore.getTab(tabId); + if (!tab) return { success: false, error: "Editor tab closed" }; + if (tab.savingState === "saving") return { success: false, error: "Editor tab is busy" }; + // Keep the snapshot's owner read-only until receipt. Success stays busy + // until the caller removes this copy; failure restores its previous state. + editorTabStore.setSavingState(tabId, "saving"); + let accepted = false; + try { + const result = await netcattyBridge.get()?.dockEditorWindowTab?.( + toEditorWindowSnapshot(tab, tab.hostLabel), + ); + if (result?.success) { + accepted = true; + return { success: true }; + } + return { success: false, error: result?.error || "Failed to dock editor tab" }; + } catch (error) { + return { success: false, error: error instanceof Error ? error.message : "Failed to dock editor tab" }; + } finally { + if (!accepted) editorTabStore.setSavingState(tabId, tab.savingState, tab.saveError); + } +} + +export function reportDetachedEditorDirty(tabId: EditorTabId, dirty: boolean): void { + netcattyBridge.get()?.reportEditorWindowDirty?.({ editorId: tabId, dirty }); +} + +export function reportDetachedEditorTabsClosed(editorIds: string[]): void { + if (editorIds.length === 0) return; + netcattyBridge.get()?.reportEditorWindowTabsClosed?.({ editorIds }); +} + +export function subscribeEditorWindowCloseTabs( + cb: (payload: import("./editorWindowTypes").EditorWindowCloseTabsRequest) => void | Promise, +): () => void { + return netcattyBridge.get()?.onEditorWindowCloseTabs?.(cb) ?? (() => {}); +} + +export function reportEditorWindowCloseTabsResult( + payload: import("./editorWindowTypes").EditorWindowCloseTabsResult, +): void { + netcattyBridge.get()?.reportEditorWindowCloseTabsResult?.(payload); +} + +export function subscribeEditorWindowDirtyCheck(cb: () => boolean): () => void { + const bridge = netcattyBridge.get(); + if (!bridge?.onCheckDirtyEditors) return () => {}; + return bridge.onCheckDirtyEditors(() => { + let hasDirty = false; + try { + hasDirty = cb() === true; + } catch { + hasDirty = false; + } + try { + bridge.reportDirtyEditorsResult?.(hasDirty); + } catch { + // ignore + } + }); +} + +export function installEditorWindowSourceListeners(): () => void { + const bridge = netcattyBridge.get(); + if (!bridge) return () => {}; + + const unsubSave = bridge.onEditorWindowSaveRequest?.(async (payload) => { + try { + const tab = editorTabStore.getTab(payload.editorId); + if (!tab) { + bridge.reportEditorWindowSaveResult?.({ + requestId: payload.requestId, + ok: false, + error: "Editor tab closed before save completed", + }); + return; + } + if (tab.sessionId !== payload.sessionId) { + editorTabStore.remapSessionId(tab.sessionId, payload.sessionId); + } + const ok = await saveEditorTab(payload.editorId, payload.content); + const latest = editorTabStore.getTab(payload.editorId); + bridge.reportEditorWindowSaveResult?.({ + requestId: payload.requestId, + ok, + liveConnectionId: latest?.sessionId, + error: ok ? undefined : (latest?.saveError || "Save failed"), + }); + } catch (err) { + bridge.reportEditorWindowSaveResult?.({ + requestId: payload.requestId, + ok: false, + error: err instanceof Error ? err.message : "Save failed", + }); + } + }) ?? (() => {}); + + const unsubDock = bridge.onEditorWindowDockRequest?.((payload) => { + try { + const id = editorTabStore.upsertFromSnapshot(payload, "tab"); + activeTabStore.setActiveTabId(toEditorTabId(id)); + bridge.reportEditorWindowDockResult?.({ requestId: payload.requestId, ok: true }); + } catch (err) { + bridge.reportEditorWindowDockResult?.({ + requestId: payload.requestId, + ok: false, + error: err instanceof Error ? err.message : "Failed to dock", + }); + } + }) ?? (() => {}); + + const unsubDirty = bridge.onEditorWindowDirtyChanged?.((payload) => { + editorTabStore.setWindowDirty(payload.editorId, payload.dirty === true); + }) ?? (() => {}); + + const unsubClosed = bridge.onEditorWindowTabsClosed?.((payload) => { + for (const editorId of payload.editorIds ?? []) { + editorTabStore.close(editorId); + } + }) ?? (() => {}); + + return () => { + unsubSave(); + unsubDock(); + unsubDirty(); + unsubClosed(); + }; +} diff --git a/application/state/editorWindowTypes.ts b/application/state/editorWindowTypes.ts new file mode 100644 index 000000000..7018939f3 --- /dev/null +++ b/application/state/editorWindowTypes.ts @@ -0,0 +1,45 @@ +export type EditorTabPlacement = "tab" | "window"; + +export interface EditorWindowTabSnapshot { + editorId: string; + sessionId: string; + sftpTabId: string; + hostId: string; + hostLabel?: string; + remotePath: string; + fileName: string; + languageId: string; + content: string; + baselineContent: string; + wordWrap: boolean; + viewState: unknown; +} + +export interface EditorWindowSaveRequest { + requestId: string; + editorId: string; + sessionId: string; + sftpTabId: string; + hostId: string; + remotePath: string; + content: string; +} + +export interface EditorWindowSaveResult { + requestId: string; + ok: boolean; + liveConnectionId?: string; + error?: string; +} + +export interface EditorWindowCloseTabsRequest { + requestId: string; + editorIds: string[]; + force?: boolean; +} + +export interface EditorWindowCloseTabsResult { + requestId: string; + cancelled: boolean; + closedIds: string[]; +} diff --git a/application/state/resolveTerminalSessionExitIntent.test.ts b/application/state/resolveTerminalSessionExitIntent.test.ts index 4ab512e22..06892be2c 100644 --- a/application/state/resolveTerminalSessionExitIntent.test.ts +++ b/application/state/resolveTerminalSessionExitIntent.test.ts @@ -2,15 +2,26 @@ import test from "node:test"; import assert from "node:assert/strict"; import { + formatSessionClosedExitMessage, resolveTerminalSessionExitIntent, shouldCloseTerminalPopupOnExit, shouldRevealTerminalPopupOnExit, } from "./resolveTerminalSessionExitIntent.ts"; -test("normal backend exited events close the session tab", () => { +test("backend exited events close the session tab only when auto-close is enabled", () => { + assert.deepEqual( + resolveTerminalSessionExitIntent({ reason: "exited", exitCode: 0 }, true), + { kind: "closeSession" }, + ); +}); + +test("omitted auto-close setting keeps the session tab on a clean exit", () => { + // The fallback for callers that have not loaded the terminal setting yet: + // keep the tab so a jump host / bastion reaping a session (a clean exit) + // never silently deletes the user's scrollback. assert.deepEqual( resolveTerminalSessionExitIntent({ reason: "exited", exitCode: 0 }), - { kind: "closeSession" }, + { kind: "markDisconnected" }, ); }); @@ -56,11 +67,19 @@ test("backend closed events keep the tab and mark it disconnected", () => { ); }); +test("administrative exit-signal disconnects keep the tab", () => { + assert.deepEqual( + resolveTerminalSessionExitIntent({ reason: "signaled", exitSignal: "SIGHUP" }, true), + { kind: "markDisconnected" }, + ); +}); + test("terminal popup only auto-closes after clean command exit", () => { assert.equal(shouldCloseTerminalPopupOnExit({ reason: "exited", exitCode: 0 }), true); assert.equal(shouldCloseTerminalPopupOnExit({ reason: "exited", exitCode: 1 }), false); assert.equal(shouldCloseTerminalPopupOnExit({ reason: "error", error: "connection reset" }), false); assert.equal(shouldCloseTerminalPopupOnExit({ reason: "closed", exitCode: 0 }), false); + assert.equal(shouldCloseTerminalPopupOnExit({ reason: "signaled", exitSignal: "SIGHUP" }), false); }); test("disabled auto-close keeps command and attached terminal popups open", () => { @@ -120,3 +139,65 @@ test("attached terminal popups keep their existing auto-close behavior by defaul true, ); }); + +test("exit messages show the reported code for a real remote exit", () => { + assert.equal( + formatSessionClosedExitMessage({ reason: "exited", exitCode: 0 }), + "\r\n[session closed (code 0)]", + ); + assert.equal( + formatSessionClosedExitMessage({ reason: "exited", exitCode: 127 }), + "\r\n[session closed (code 127)]", + ); +}); + +test("an administrative exit-signal renders as a remote signal, not a clean code", () => { + assert.equal( + formatSessionClosedExitMessage({ reason: "signaled", exitSignal: "SIGHUP" }), + "\r\n[session closed by remote signal]", + ); + assert.equal( + formatSessionClosedExitMessage({ reason: "closed", exitSignal: "SIGTERM" }), + "\r\n[session closed by remote signal]", + ); +}); + +test("a channel closed without exit-status or exit-signal must not render as code 0", () => { + assert.equal( + formatSessionClosedExitMessage({ + reason: "closed", + remoteClosedWithoutExitStatus: true, + }), + "\r\n[session closed by remote (no exit status)]", + ); +}); + +test("exit messages without a reported code stay plain instead of showing a sentinel", () => { + assert.equal( + formatSessionClosedExitMessage({ reason: "closed" }), + "\r\n[session closed]", + ); + assert.equal(formatSessionClosedExitMessage({}), "\r\n[session closed]"); +}); + +test("infrastructure teardowns must not render a numeric code as a clean exit (#3631)", () => { + // A terminal-worker exit broadcast carries the worker's own exit status + // (graceful drain = 0). It belongs to the infrastructure failure, not to + // any remote shell, so it must never read as "session closed (code 0)". + assert.equal( + formatSessionClosedExitMessage({ + reason: "error", + exitCode: 0, + error: "Terminal worker exited with code 0", + }), + "\r\n[session closed unexpectedly]", + ); + assert.equal( + formatSessionClosedExitMessage({ reason: "error", exitCode: 1 }), + "\r\n[session closed unexpectedly]", + ); + assert.equal( + formatSessionClosedExitMessage({ reason: "error" }), + "\r\n[session closed unexpectedly]", + ); +}); diff --git a/application/state/resolveTerminalSessionExitIntent.ts b/application/state/resolveTerminalSessionExitIntent.ts index 23de87dce..c45b6c452 100644 --- a/application/state/resolveTerminalSessionExitIntent.ts +++ b/application/state/resolveTerminalSessionExitIntent.ts @@ -4,10 +4,31 @@ export type TerminalSessionExitEvent = { exitCode?: number; signal?: number; error?: string; - reason?: "exited" | "error" | "timeout" | "closed"; + reason?: "exited" | "error" | "timeout" | "closed" | "signaled"; + // ssh2 exit-signal name ("SIGHUP", "SIGTERM", ...). Set when the server + // terminated the session process. Distinct from a transport "closed". + exitSignal?: string; + // True when the remote closed the channel without sending exit-status or + // exit-signal (#3591): no numeric exit code exists, and the renderer shows + // a distinct message instead of a misleading "code 0". + remoteClosedWithoutExitStatus?: boolean; diagnostics?: ReadonlyArray; }; +export function formatSessionClosedExitMessage(evt: TerminalSessionExitEvent): string { + if (evt?.remoteClosedWithoutExitStatus) { + return "\r\n[session closed by remote (no exit status)]"; + } + if (evt?.reason === "signaled" || (typeof evt?.exitSignal === "string" && evt.exitSignal.trim().length > 0)) { + return "\r\n[session closed by remote signal]"; + } + // Infrastructure exit codes do not describe the remote shell's status. + if (evt?.reason === "error") { + return "\r\n[session closed unexpectedly]"; + } + return `\r\n[session closed${evt?.exitCode !== undefined ? ` (code ${evt.exitCode})` : ""}]`; +} + export type TerminalSessionExitIntent = | { kind: "closeSession" } | { kind: "markDisconnected" }; @@ -23,7 +44,11 @@ function isConfirmedCleanExit(evt: TerminalSessionExitEvent): boolean { export function resolveTerminalSessionExitIntent( evt: TerminalSessionExitEvent, - autoCloseOnExit = true, + // Keep-tab is the safe default: a caller that has not loaded the terminal + // setting yet must not silently delete a session tab. Remote graceful + // channel closes (e.g. a jump host reaping an idle session) are reported + // as a clean exit, so an unavailable setting must not re-enable closing. + autoCloseOnExit = false, ): TerminalSessionExitIntent { if (autoCloseOnExit && isConfirmedCleanExit(evt)) { return { kind: "closeSession" }; diff --git a/application/state/sessionHistorySearch.test.ts b/application/state/sessionHistorySearch.test.ts new file mode 100644 index 000000000..3739699bd --- /dev/null +++ b/application/state/sessionHistorySearch.test.ts @@ -0,0 +1,790 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + collectSessionSearchFields, + type SessionHistorySearchMessage, +} from "../../domain/sessionHistorySearch.ts"; +import { + PINYIN_CACHE_MAX_CHARS, + getPinyinCacheStatsForTests, + resetPinyinCacheForTests, +} from "../../lib/searchMatcher.ts"; +import { createSessionHistorySearch } from "./sessionHistorySearch.ts"; + +// One instance for the whole file: it keeps the pre-factory semantics of every +// test sharing (and clearing) a single cache. +const { clearSessionHistorySearchCache, filterSessionHistory } = createSessionHistorySearch(); + +// Fixture messages stay loosely typed: persisted sessions carry far richer +// shapes (role, ids, payload fields, …) than the minimal searchable shapes, +// and extra properties must remain structurally harmless. +function createSession( + id: string, + title: string, + messages: Array>, +) { + return { + id, + title, + messages: messages.map((message, index) => ({ + id: `${id}-m${index}`, + role: "user", + content: "", + timestamp: index, + ...message, + })) as SessionHistorySearchMessage[], + }; +} + +test("filterSessionHistory matches titles and keeps order when query is blank", () => { + const sessions = [ + createSession("a", "nginx restart", []), + createSession("b", "disk cleanup", []), + ]; + + for (const query of ["", " \t\n"]) { + assert.deepEqual(filterSessionHistory(sessions, query), sessions); + } +}); + +test("filterSessionHistory handles special characters through the shared matcher", () => { + const sessions = [ + createSession("a", "Ops", [{ content: "check [prod].json at /var/log/nginx" }]), + createSession("b", "Deploy", [{ content: "ship it" }]), + ]; + + assert.deepEqual(filterSessionHistory(sessions, "[prod].json"), [sessions[0]]); + assert.deepEqual(filterSessionHistory(sessions, "/var/log/nginx"), [sessions[0]]); + assert.deepEqual(filterSessionHistory(sessions, "[](){}"), []); +}); + +test("filterSessionHistory matches message content of user and assistant turns", () => { + const sessions = [ + createSession("a", "Debugging", [ + { role: "user", content: "why is sshd down on prod-3?" }, + { role: "assistant", content: "let me check the journal" }, + ]), + createSession("b", "Deploy notes", [ + { role: "user", content: "ship release 1.2" }, + ]), + ]; + + assert.deepEqual(filterSessionHistory(sessions, "sshd down"), [sessions[0]]); + assert.deepEqual(filterSessionHistory(sessions, "1.2"), [sessions[1]]); + assert.deepEqual(filterSessionHistory(sessions, "nothing here"), []); +}); + +test("filterSessionHistory ignores hidden system messages", () => { + const sessions = [ + createSession("a", "Ops", [ + { role: "system", content: "shared hidden prompt with marker text" }, + { role: "user", content: "all clear" }, + ]), + createSession("b", "Deploy", [ + { role: "system", content: "shared hidden prompt with marker text" }, + { role: "user", content: "ship it" }, + ]), + ]; + + // A query found only inside system messages must not surface the sessions: + // the reopened conversation hides those messages, so a match would display + // no matching text. + assert.deepEqual(filterSessionHistory(sessions, "marker text"), []); +}); + +test("filterSessionHistory searches thinking and tool call/result content", () => { + const sessions = [ + createSession("a", "Untitled", [ + { role: "assistant", thinking: "the certificate expired" }, + ]), + createSession("b", "Ops", [ + { + role: "assistant", + toolCalls: [{ name: "systemctl", id: "t1", arguments: {} }], + toolResults: [{ toolCallId: "t1", content: "nginx failed to bind port" }], + }, + ]), + ]; + + assert.deepEqual(filterSessionHistory(sessions, "certificate"), [sessions[0]]); + assert.deepEqual(filterSessionHistory(sessions, "systemctl"), [sessions[1]]); + assert.deepEqual(filterSessionHistory(sessions, "bind port"), [sessions[1]]); +}); + +test("filterSessionHistory matches text stored only in tool call arguments", () => { + const sessions = [ + createSession("a", "Ops", [ + { + role: "assistant", + toolCalls: [{ + name: "shell", + id: "t1", + arguments: { command: "systemctl restart nginx" }, + }], + }, + ]), + createSession("b", "Deploy", [ + { + role: "assistant", + toolCalls: [{ name: "file", id: "t2", arguments: {} }], + }, + ]), + ]; + + assert.deepEqual(filterSessionHistory(sessions, "nginx"), [sessions[0]]); + assert.deepEqual(filterSessionHistory(sessions, "restart nginx"), [sessions[0]]); + assert.deepEqual(filterSessionHistory(sessions, "nothing here"), []); + + const fields = collectSessionSearchFields(sessions[0]); + assert.ok(fields.some((field) => field.includes("systemctl restart nginx"))); +}); + +test("filterSessionHistory matches persisted agent activities", () => { + const sessions = [ + createSession("a", "Research", [ + { + role: "assistant", + agentActivities: [ + { id: "1", type: "web_search", status: "completed", query: "rust async runtime benchmarks" }, + ], + }, + ]), + createSession("b", "Refactor", [ + { + role: "assistant", + agentActivities: [ + { + id: "2", + type: "file_change", + status: "completed", + changes: [ + { path: "src/sessionStore.ts", kind: "update" }, + { path: "src/sessionStore.test.ts", kind: "add" }, + ], + }, + ], + }, + ]), + createSession("c", "Plan", [ + { + role: "assistant", + agentActivities: [ + { + id: "3", + type: "plan_update", + status: "completed", + items: [ + { text: "extract search collector", completed: true }, + { text: "wire into drawer", completed: false }, + ], + }, + { id: "4", type: "warning", status: "completed", message: "rate limit hit" }, + ], + }, + ]), + ]; + + assert.deepEqual(filterSessionHistory(sessions, "async runtime"), [sessions[0]]); + assert.deepEqual(filterSessionHistory(sessions, "sessionStore.test"), [sessions[1]]); + assert.deepEqual(filterSessionHistory(sessions, "extract search collector"), [sessions[2]]); + assert.deepEqual(filterSessionHistory(sessions, "rate limit"), [sessions[2]]); + assert.deepEqual(filterSessionHistory(sessions, "nothing here"), []); + + const fields = collectSessionSearchFields(sessions[2]); + assert.ok(fields.some((field) => field.includes("extract search collector"))); + assert.ok(fields.some((field) => field.includes("rate limit hit"))); +}); + +test("filterSessionHistory matches persisted error messages and attachment labels", () => { + const sessions = [ + createSession("a", "Untitled", [ + { + role: "assistant", + content: "", + errorInfo: { type: "provider", message: "upstream rate limit exceeded", retryable: true }, + }, + ]), + createSession("b", "Deploy", [ + { + role: "user", + content: "please summarize", + attachments: [ + { base64Data: "", mediaType: "text/plain", filename: "k8s-manifest.yaml" }, + { base64Data: "", mediaType: "text/markdown", vaultNoteTitle: "Postgres runbook" }, + ], + }, + ]), + createSession("c", "Legacy", [ + { + role: "user", + content: "see attached", + images: [ + { base64Data: "", mediaType: "image/png", filename: "screenshot.png" }, + ], + }, + ]), + createSession("d", "Payloads", [ + { + role: "user", + content: "", + attachments: [ + { base64Data: "AAAA", mediaType: "image/png", terminalSelection: true }, + ], + }, + ]), + ]; + + assert.deepEqual(filterSessionHistory(sessions, "rate limit exceeded"), [sessions[0]]); + assert.deepEqual(filterSessionHistory(sessions, "k8s-manifest"), [sessions[1]]); + assert.deepEqual(filterSessionHistory(sessions, "postgres runbook"), [sessions[1]]); + assert.deepEqual(filterSessionHistory(sessions, "screenshot.png"), [sessions[2]]); + // Attachment base64 payloads and unlabeled attachments are not indexed. + assert.deepEqual(filterSessionHistory(sessions, "AAAA"), []); + assert.deepEqual(filterSessionHistory(sessions, "nothing here"), []); + + const fields = collectSessionSearchFields(sessions[1]); + assert.ok(fields.some((field) => field.includes("k8s-manifest.yaml"))); + assert.ok(fields.some((field) => field.includes("Postgres runbook"))); + assert.ok(!fields.some((field) => field === "AAAA")); +}); + +test("budget exhaustion inside a message keeps the newest tool calls searchable", () => { + // One assistant message bearing enough tool calls (each argument capped at + // 2,000 chars) to exhaust the remaining session budget: iteration must be + // newest-first so the latest calls (appended at the array end) stay + // searchable instead of the oldest ones winning the budget. + const session = createSession("a", "Ops", [ + { + role: "assistant", + content: "y".repeat(20_000), + toolCalls: Array.from({ length: 25 }, (_, i) => ({ + name: `call-${i}`, + id: `t${i}`, + arguments: { command: `marker-args-${i} ${"z".repeat(20_000)}` }, + })), + }, + ]); + + const fields = collectSessionSearchFields(session); + // The newest call (index 24, appended last) stays searchable along with the + // rest of the recent burst; the oldest call is the one dropped. + assert.ok(fields.some((field) => field.includes("marker-args-24"))); + assert.ok(fields.some((field) => field.includes("marker-args-19"))); + assert.ok(!fields.some((field) => field.includes("marker-args-0"))); + assert.deepEqual(filterSessionHistory([session], "marker-args-24"), [session]); + // Retained call fields are output in chronological order after the + // newest-first collection is reversed back (call-10 before call-19). + const call10 = fields.findIndex((field) => field.includes("call-10")); + const call19 = fields.findIndex((field) => field.includes("call-19")); + assert.ok(call10 !== -1 && call19 !== -1 && call10 < call19); +}); + +test("later attachment labels stay searchable when the label list overflows the cap", () => { + // The joined labels of these attachments exceed the 2,000-character field + // cap; feeding labels individually keeps the later (newest) ones findable. + const session = createSession("a", "Deploy", [ + { + role: "user", + content: "please summarize", + attachments: [ + { base64Data: "", mediaType: "text/plain", filename: `pad-${"a".repeat(2_100)}` }, + { base64Data: "", mediaType: "text/markdown", filename: "latest-run.log" }, + ], + }, + ]); + + const fields = collectSessionSearchFields(session); + assert.ok(fields.some((field) => field.includes("latest-run.log"))); + assert.deepEqual(filterSessionHistory([session], "latest-run"), [session]); +}); + +test("collectSessionSearchFields skips empty content and caps very long fields", () => { + const session = createSession("a", " ", [ + { role: "user", content: " " }, + { role: "assistant", content: "x".repeat(25_000) }, + ]); + + const fields = collectSessionSearchFields(session); + assert.equal(fields.length, 1); + assert.equal(fields[0].length, 20_000); +}); + +test("filterSessionHistory matches CJK titles via the shared pinyin matcher", () => { + const sessions = [ + createSession("a", "重启生产服务器", []), + createSession("b", "backup", []), + ]; + + assert.deepEqual(filterSessionHistory(sessions, "chongqi"), [sessions[0]]); + assert.deepEqual(filterSessionHistory(sessions, "重启"), [sessions[0]]); +}); + +test("filterSessionHistory indexes the displayed fallback for untitled sessions", () => { + // An empty persisted title is displayed as the localized "Untitled" label; + // searching for that label must find the session instead of hiding it. + const sessions = [ + createSession("a", "", [{ content: "nginx notes" }]), + createSession("b", "nginx restart", []), + ]; + + // Without the fallback the blank title contributes nothing. + assert.deepEqual(filterSessionHistory(sessions, "untitled"), []); + assert.deepEqual(filterSessionHistory(sessions, "untitled", { untitledLabel: "Untitled" }), [sessions[0]]); + // Titled sessions are unaffected by the fallback label. + assert.deepEqual(filterSessionHistory(sessions, "restart"), [sessions[1]]); + // Localized (CJK) fallback labels match through title pinyin too. + assert.deepEqual(filterSessionHistory(sessions, "无标题", { untitledLabel: "无标题" }), [sessions[0]]); + assert.deepEqual(filterSessionHistory(sessions, "wbt", { untitledLabel: "无标题" }), [sessions[0]]); + + const fields = collectSessionSearchFields(sessions[0], "Untitled"); + assert.deepEqual(fields, ["Untitled", "nginx notes"]); +}); + +test("filterSessionHistory lets tokens span title pinyin and message text", () => { + const sessions = [ + createSession("a", "重启服务器", [ + { role: "user", content: "restarted nginx without errors" }, + ]), + ]; + + const combined = filterSessionHistory(sessions, "chongqi nginx"); + assert.deepEqual(combined, [sessions[0]]); + // Message-only pinyin still stays out of the expensive fallback. + assert.deepEqual(filterSessionHistory(sessions, "chongqi"), [sessions[0]]); + assert.deepEqual( + filterSessionHistory(sessions, "failing nginx"), + [], + ); +}); + +test("search field trimming stays inside the field and remaining session limits", () => { + const fieldLimited = createSession("a", "Ops", [ + { content: " ".repeat(20_000) + "outside-field" }, + { content: " recent-match " + " ".repeat(1_000_000) }, + ]); + assert.deepEqual(collectSessionSearchFields(fieldLimited), ["Ops", "recent-match"]); + assert.deepEqual(filterSessionHistory([fieldLimited], "recent-match"), [fieldLimited]); + assert.deepEqual(filterSessionHistory([fieldLimited], "outside-field"), []); + + // Title + three full fields leave 3,997 characters for the oldest message. + // Trimming must not reach past that window to discover more content. + const sessionLimited = createSession("b", "Ops", [ + { content: " ".repeat(3_997) + "outside-session" }, + ...Array.from({ length: 3 }, () => ({ content: "x".repeat(20_000) })), + ]); + assert.equal(collectSessionSearchFields(sessionLimited).join("").length, 60_003); + assert.deepEqual(filterSessionHistory([sessionLimited], "outside-session"), []); +}); + +test("filterSessionHistory keeps the pinyin fallback on titles only", () => { + const sessions = [ + createSession("a", "Ops", [ + { role: "user", content: "重启生产服务器" }, + ]), + ]; + + // Pinyin transliteration of long message content is skipped; literal CJK + // queries still match message text. + assert.deepEqual(filterSessionHistory(sessions, "重启"), [sessions[0]]); + assert.deepEqual(filterSessionHistory(sessions, "chongqi"), []); +}); + +test("collectSessionSearchFields bounds the total searchable text per session", () => { + const session = createSession("a", "big session", Array.from({ length: 40 }, (_, i) => ({ + role: "user" as const, + content: `chunk-${i}-${"y".repeat(19_000)}`, + }))); + + const fields = collectSessionSearchFields(session); + const total = fields.reduce((sum, field) => sum + field.length, 0); + assert.ok(total <= 64_000, `total ${total} exceeds session cap`); + // Title is indexed first so it always lands in the bounded window. + assert.equal(fields[0], "big session"); +}); + +test("collectSessionSearchFields keeps recent messages searchable under the cap", () => { + // Four 20,000-char messages exceed the 64 KB cap; the budget must be + // allocated newest-first so the most recent message remains searchable. + const session = createSession("a", "big session", [0, 1, 2, 3].map((i) => ({ + role: "user" as const, + content: `${"x".repeat(19_900)} marker-${i}`, + }))); + + const fields = collectSessionSearchFields(session); + assert.equal(fields[0], "big session"); + // Oldest messages are dropped, the newest is retained. + assert.ok(!fields.some((field) => field.includes("marker-0"))); + assert.ok(fields.some((field) => field.includes("marker-3"))); + // Output order stays chronological (marker-1 before marker-2). + const marker1 = fields.findIndex((field) => field.includes("marker-1")); + const marker2 = fields.findIndex((field) => field.includes("marker-2")); + assert.ok(marker1 !== -1 && marker2 !== -1 && marker1 < marker2); +}); + +test("collectSessionSearchFields truncates a field to the remaining session budget", () => { + // Four 19,000-char messages plus the title overflow the 64 KB cap; the last + // collected (oldest) message must be truncated to exactly the remaining + // budget instead of being discarded wholesale. + const session = createSession("a", "big session", [0, 1, 2, 3].map(() => ({ + role: "user" as const, + content: "a".repeat(19_000), + }))); + + const fields = collectSessionSearchFields(session); + const total = fields.reduce((sum, field) => sum + field.length, 0); + assert.ok(total <= 64_000, `total ${total} exceeds session cap`); + assert.equal(total, 64_000); + // The oldest message fills the leftover budget with its freshest-available + // head instead of being dropped entirely. + assert.ok(fields.some((field) => field.length === 64_000 - (11 + 3 * 19_000))); + assert.ok(fields.some((field) => field.startsWith("aaa"))); +}); + +test("huge tool call arguments are serialized within the field cap without full materialization", () => { + const blob = "x".repeat(5_000_000); + const sessions = [ + createSession("a", "Ops", [ + { + role: "assistant", + toolCalls: [{ + // Retained calls can carry uncapped payloads (e.g. `sftp_write_file` + // content); serialization must stop at the field cap instead of + // stringifying the whole argument object on every keystroke. + name: "sftp_write_file", + id: "t1", + arguments: { path: "/tmp/report.md", content: blob }, + }], + }, + ]), + ]; + + const fields = collectSessionSearchFields(sessions[0]); + const serialized = fields.find((field) => field.includes("/tmp/report.md")); + assert.ok(serialized); + // The serialized field (plus the tool name) must land under the total + // per-session budget cap, i.e. no multi-megabyte field is ever emitted. + assert.ok(serialized!.length <= 2_000); + assert.ok(serialized!.includes("/tmp/report.md")); + + // Short payloads still serialize fully and stay searchable. + const searchable = createSession("b", "Ops", [ + { + role: "assistant", + toolCalls: [{ + name: "shell", + id: "t2", + arguments: { command: "systemctl restart nginx" }, + }], + }, + ]); + assert.ok( + collectSessionSearchFields(searchable).some((field) => field.includes('"command":"systemctl restart nginx"')), + ); +}); + +test("wide tool call argument objects and long property names stay within the cap", () => { + // A wide object: thousands of own properties must be iterated lazily + // instead of materializing the full entries array on every keystroke. + const wide: Record = {}; + for (let i = 0; i < 5_000; i++) wide[`key-${i}`] = `value-${i}`; + // An unusually large property name must be capped before it is escaped. + const longKey = "k".repeat(50_000); + const sessions = [ + createSession("a", "Ops", [ + { + role: "assistant", + toolCalls: [{ + name: "shell", + id: "t1", + arguments: { command: "systemctl restart nginx", [longKey]: "ignored", ...wide }, + }], + }, + ]), + ]; + + const fields = collectSessionSearchFields(sessions[0]); + const serialized = fields.find((field) => field.includes("systemctl restart nginx")); + assert.ok(serialized); + assert.ok(serialized!.length <= 2_000); + // The oversized key (and the wide object past the cap) never materialize: + // the long key is truncated before escaping. + assert.ok(!fields.some((field) => field.length > 2_000)); +}); + +test("filterSessionHistory matches persisted status text", () => { + const sessions = [ + createSession("a", "Untitled", [ + { role: "assistant", content: "", statusText: "generating diff for src/main.ts" }, + ]), + createSession("b", "Deploy", [ + { role: "user", content: "ship it" }, + ]), + ]; + + assert.deepEqual(filterSessionHistory(sessions, "generating diff"), [sessions[0]]); + assert.deepEqual(filterSessionHistory(sessions, "src/main.ts"), [sessions[0]]); + assert.deepEqual(filterSessionHistory(sessions, "nothing here"), []); + + const fields = collectSessionSearchFields(sessions[0]); + assert.ok(fields.some((field) => field.includes("generating diff for src/main.ts"))); +}); + +// Regression: bounds enforcement must run once per scan, not on every cache +// miss. Evicting per miss makes each rebuild drop the index of a later +// session in the same scan, so any history longer than the entry cap +// cycle-evicts the whole cache and rebuilds every corpus on every keystroke. +test("filterSessionHistory keeps index-cache hits for histories larger than the cache cap", () => { + const sessionCount = 80; + const sessions = Array.from({ length: sessionCount }, (_, i) => + createSession(`s${i}`, `session ${i}`, [{ content: `body ${i} deploy` }])); + // Observe corpus (re)builds: a cache hit serves the prepared haystack + // without touching `messages`, while a rebuild collects the fields again. + const readSessions = new Set(); + for (const session of sessions) { + const messages = session.messages; + Object.defineProperty(session, "messages", { + get() { + readSessions.add(session.id); + return messages; + }, + configurable: true, + }); + } + + filterSessionHistory(sessions, "deploy"); + readSessions.clear(); + const second = filterSessionHistory(sessions, "deploy"); + + // End-of-scan eviction only prunes the per-scan overshoot: the indexes of + // every session retained by the last prune are cache hits. Per-miss + // eviction would have rebuilt all 80 sessions here. + const rebuilt = readSessions.size; + assert.ok(rebuilt <= sessionCount - 64, `expected no cache thrash, rebuilt ${rebuilt}`); + assert.deepEqual(second, sessions); + // Eviction must retain the FRONT of the ranked list (the sessions the next + // scan visits first) and rebuild only the oldest tail above the entry cap — + // evicting the Map's first key would drop the head and thrash it instead. + const rebuiltIds = [...readSessions].sort(); + assert.deepEqual( + rebuiltIds, + sessions.slice(-(sessionCount - 64)).map((s) => s.id).sort(), + ); +}); + +// Regression: hard-rejecting NEW entries once the entry cap is saturated by +// live sessions froze the cache's membership. New sessions are prepended to +// the ranked list, so every subsequently created conversation (and its +// immutable streaming updates) would rebuild its index on every keystroke +// while stale lower-ranked entries stayed cached — and the end-of-scan prune +// could not correct it because the cache was already within both bounds. +test("filterSessionHistory admits a newly created session after the entry cap is saturated", () => { + const initial = Array.from({ length: 64 }, (_, i) => + createSession(`s${i}`, `session ${i}`, [{ content: `body ${i} deploy` }])); + filterSessionHistory(initial, "deploy"); + + // A newly created session is prepended: it ranks above every cached entry. + const created = createSession("fresh", "fresh session", [{ content: "body fresh deploy" }]); + const sessions = [created, ...initial]; + + // Observe corpus (re)builds as in the thrash regression test above. + const readSessions = new Set(); + for (const session of sessions) { + const messages = session.messages; + Object.defineProperty(session, "messages", { + get() { + readSessions.add(session.id); + return messages; + }, + configurable: true, + }); + } + + // First scan over the new list: "fresh" misses (its index was never cached) + // and must be admitted; the prune evicts the lowest-ranked tail instead. + filterSessionHistory(sessions, "deploy"); + readSessions.clear(); + const third = filterSessionHistory(sessions, "deploy"); + + assert.deepEqual(third, sessions); + // The fresh, highest-ranked session stays cached; only the evicted tail + // above the entry cap is rebuilt. + assert.ok(!readSessions.has("fresh"), `expected fresh session to be cached, rebuilt: ${[...readSessions]}`); + assert.deepEqual([...readSessions], ["s63"]); +}); + +// Regression: the entry cap must be enforced at cache-admission time, not only +// by the end-of-scan prune. When misses are small (empty/short searchable +// content), the char budget consumes almost no headroom per insert, so +// overshooting the entry cap mid-scan and letting the prune trim it made a +// single keystroke over a very long history build and transiently retain one +// index + Map entry per live session. Admission must instead evict the stale +// lowest-ranked untouched tail to make room (the same entry the prune would +// otherwise drop moments later), keeping the cache within the entry cap +// throughout the scan. +test("filterSessionHistory evicts the stale tail at admission instead of overshooting the entry cap", () => { + const initial = Array.from({ length: 64 }, (_, i) => + createSession(`s${i}`, `session ${i}`, [{ content: `body ${i} deploy` }])); + filterSessionHistory(initial, "deploy"); + + // A newly created session is prepended: it ranks above every cached entry. + const created = createSession("fresh", "fresh session", [{ content: "body fresh deploy" }]); + const sessions = [created, ...initial]; + + // Observe corpus (re)builds as in the regression tests above. + const readSessions = new Set(); + for (const session of sessions) { + const messages = session.messages; + Object.defineProperty(session, "messages", { + get() { + readSessions.add(session.id); + return messages; + }, + configurable: true, + }); + } + + // The single scan over the prepended list must admit "fresh" by evicting + // the stalest cached tail entry ("s63") during the scan: under the old + // overshoot-then-prune behavior "s63" would stay cached until the prune, + // so it would not be rebuilt in this same scan at all. Rebuilding it here + // is the (bounded, one-entry) cost of never exceeding the entry cap + // mid-scan. + filterSessionHistory(sessions, "deploy"); + assert.deepEqual([...readSessions].sort(), ["fresh", "s63"]); + + // Recovery is unchanged: the fresh session stays cached, and only the + // tail displaced below the entry cap rebuilds on the next keystroke. + readSessions.clear(); + const next = filterSessionHistory(sessions, "deploy"); + assert.deepEqual(next, sessions); + assert.ok(!readSessions.has("fresh"), `expected fresh session to be cached, rebuilt: ${[...readSessions]}`); + assert.deepEqual([...readSessions], ["s63"]); +}); + +// Regression: a hard-reject at the CHAR-budget gate froze cache membership +// just like the earlier entry-cap bug. With ~20 large sessions (each near the +// 64,000-character corpus cap) already consuming nearly the 4M-character +// cache budget, a newly prepended high-ranked session was rejected without +// evicting anything, and the end-of-scan prune — seeing a cache within both +// bounds — kept the stale lower-ranked entries: the new large conversation +// then rebuilt its index on every search keystroke. +test("filterSessionHistory admits a newly created session after the char budget is saturated", () => { + // Corpus exactly 64,000 chars (title + 4 x 15,999-char messages), each + // index retaining ~192K chars (normalized fields + haystack + compact + // haystack): 20 fill ~3.84M of the 4M budget; a 21st would overflow it. + const createLargeSession = (id: string) => + createSession(id, "sess", [0, 1, 2, 3].map(() => ({ content: "x".repeat(15_999) }))); + + const initial = Array.from({ length: 20 }, (_, i) => createLargeSession(`s${i}`)); + filterSessionHistory(initial, "x"); + + // A newly created large session is prepended: it ranks above every cached + // entry, and its index can only fit if the stale tail is evicted. + const created = createLargeSession("fresh"); + const sessions = [created, ...initial]; + + // Observe corpus (re)builds as in the regression tests above. + const readSessions = new Set(); + for (const session of sessions) { + const messages = session.messages; + Object.defineProperty(session, "messages", { + get() { + readSessions.add(session.id); + return messages; + }, + configurable: true, + }); + } + + // First scan over the new list: "fresh" misses (its index was never cached) + // and must be admitted by evicting the stalest cached tail entry. + filterSessionHistory(sessions, "x"); + readSessions.clear(); + const second = filterSessionHistory(sessions, "x"); + + assert.deepEqual(second, sessions); + // The fresh, highest-ranked session stays cached; only the evicted stalest + // tail session (which no longer fits the char budget) rebuilds. + assert.ok(!readSessions.has("fresh"), `expected fresh session to be cached, rebuilt: ${[...readSessions]}`); + assert.deepEqual([...readSessions], ["s19"]); + // And the steady state holds on subsequent scans too. + readSessions.clear(); + filterSessionHistory(sessions, "x"); + assert.deepEqual([...readSessions], ["s19"]); +}); + +test("filterSessionHistory blank-query path prunes cache entries for sessions that left the list", () => { + const kept = createSession("keep", "nginx restart", [{ content: "reload nginx" }]); + const removed = createSession("remove", "disk cleanup", [{ content: "rm old logs" }]); + + filterSessionHistory([kept, removed], "nginx"); + // Deleting a session then clearing the query goes through the blank path, + // which must prune the cache against the remaining list. + filterSessionHistory([kept], ""); + // Subsequent searches are unaffected: the surviving session stays cached + // (its cached index is still there) and matching still works. + assert.deepEqual(filterSessionHistory([kept], "nginx"), [kept]); + assert.deepEqual(filterSessionHistory([kept], "nothing here"), []); + // The removed session's index is gone entirely (no match via a stale cache). + assert.deepEqual(filterSessionHistory([kept, removed], "rm old logs"), [removed]); +}); + +test("clearSessionHistorySearchCache releases cached indexes without breaking later searches", () => { + const session = createSession("a", "nginx restart", [{ content: "reload nginx" }]); + + filterSessionHistory([session], "nginx"); + clearSessionHistorySearchCache(); + // After the clear, the next search rebuilds from scratch and still matches. + assert.deepEqual(filterSessionHistory([session], "nginx"), [session]); + assert.deepEqual(filterSessionHistory([session], ""), [session]); +}); + +test("independent search instances own isolated caches", () => { + const first = createSessionHistorySearch(); + const second = createSessionHistorySearch(); + const session = createSession("iso-1", "isolated cache", [{ content: "lighthouse" }]); + assert.deepEqual(first.filterSessionHistory([session], "lighthouse"), [session]); + // Clearing one instance must not disturb the other's state: both stay fully + // functional afterwards (each store is private to its instance). + second.clearSessionHistorySearchCache(); + assert.deepEqual(second.filterSessionHistory([session], "lighthouse"), [session]); + assert.deepEqual(first.filterSessionHistory([session], "lighthouse"), [session]); +}); + +test("filterSessionHistory caps the query and matches on its capped prefix", () => { + const session = createSession("a", "nginx restart", [{ content: "reload nginx" }]); + + // A pasted multi-megabyte query must not be re-normalized per session: it is + // capped via truncateQueryForMatch (MAX_SEARCH_QUERY_LENGTH) at this scan's + // input and normalized once per scan, so matching falls back to the capped + // prefix (which still spans tokens here). + const longQuery = `${"nginx ".repeat(200_000)}zzz-impossible-tail`; + assert.deepEqual(filterSessionHistory([session], longQuery), [session]); + // The uncapped-looking but normal query still matches exactly. + assert.deepEqual(filterSessionHistory([session], "zzz-impossible-tail"), []); +}); + +// Keep the author's long-title rescan protection local to history. Shared +// search must still match complete fields, including later joined fields. +test("history pinyin cache preserves long titles across same-order rescans", () => { + resetPinyinCacheForTests(); + const sessions = Array.from({ length: 17 }, (_, index) => + createSession(`rescan-${index}`, `标题${index}${"重启".repeat(10_000)}`, [])); + for (let pass = 0; pass < 2; pass += 1) { + assert.deepEqual(filterSessionHistory(sessions, "unmatched-latin"), []); + } + const stats = getPinyinCacheStatsForTests(); + assert.equal(stats.size, sessions.length); + assert.ok(stats.chars <= PINYIN_CACHE_MAX_CHARS); + resetPinyinCacheForTests(); +}); + +test("history alone bounds title pinyin while retaining literal title and body matches", () => { + const sessions = [ + createSession("pinyin-head", `服务器${"a".repeat(256)}`, [{ content: "nginx" }]), + createSession("pinyin-tail", `${"a".repeat(256)}服务器`, [{ content: "nginx" }]), + ]; + assert.deepEqual(filterSessionHistory(sessions, "fuwuqi nginx"), [sessions[0]]); + assert.deepEqual(filterSessionHistory(sessions, "服务器 nginx"), sessions); +}); diff --git a/application/state/sessionHistorySearch.ts b/application/state/sessionHistorySearch.ts new file mode 100644 index 000000000..ca8566c12 --- /dev/null +++ b/application/state/sessionHistorySearch.ts @@ -0,0 +1,382 @@ +import { + getSessionSearchTitle, + matchesSessionSearchIndex, + prepareSessionSearchIndex, + type SessionHistorySearchTarget, + type SessionSearchIndex, +} from '../../domain/sessionHistorySearch'; +import { prepareSearchQuery, truncateQueryForMatch } from '../../lib/searchMatcher'; + +/** + * Memory bounds for the per-session search index cache. `filterSessionHistory` + * runs on every search keystroke, but `pruneSessionsForStorage` only bounds the + * persisted copy — `useAIState` retains hundreds of live sessions in memory, + * and rebuilding plus re-normalizing each session's (up to 64,000-character) + * haystack per keystroke scans tens of megabytes and freezes the renderer. + * + * The raw collected fields are NOT retained (only the prepared search fields), + * and + * the cache holds at most `MAX_SESSION_SEARCH_INDEX_CACHE_ENTRIES` entries + * within a `MAX_SESSION_SEARCH_INDEX_CACHE_CHARS` character budget: an + * unbounded cache would let one entry per live session (several normalized + * copies of each session's searchable corpus) permanently pin hundreds of + * megabytes in a long-running process. Both bounds hold after every scan's + * prune and are enforced eagerly on every insert (see + * `canCacheSessionSearchIndex`): when either bound lacks headroom, the gate + * first evicts the cache's stale, lowest-ranked untouched tail (entries the + * in-flight scan has not yet visited — they rank below every visited session, + * so this can never cycle-evict an entry the same scan still needs); if no + * such entry remains, the insert is skipped — the miss is then rebuilt on the + * next search, exactly what the end-of-scan prune would have done to it. + * Skipping is only reachable for misses ranked below every cached entry, so + * cache membership never freezes for freshly prepended high-ranked sessions: + * at their (earliest) visit nothing is touched yet and the stale tail yields. + */ +const MAX_SESSION_SEARCH_INDEX_CACHE_ENTRIES = 64; +const MAX_SESSION_SEARCH_INDEX_CACHE_CHARS = 4_000_000; + +/** + * Mutable cache state owned by exactly one search client (one mounted history + * drawer). Keeping this out of module scope means several concurrently mounted + * drawers (one per terminal tab) never share a cache: each drawer's prune only + * drops entries no longer in that drawer's scoped session list, and one + * drawer's unmount releases only its own store (previously a shared + * module-level cache was discarded for every drawer on any unmount and pruned + * against whichever drawer scanned last). + */ +export type SessionHistorySearchStore = { + /** + * Sessions in state are updated immutably (`{ ...s, messages: next }`), so + * object identity is a reliable cache key: each session's normalized/compact + * haystacks are built once and reused until that session's object changes. + * A changed display title (e.g. a localized fallback switch) forces a rebuild. + */ + cache: Map; + /** + * Session keys the in-flight `filterSessionHistory` scan has already visited + * (re-appended cache hits or inserted misses). `canCacheSessionSearchIndex` + * never evicts a touched key: it either ranks above the current insert (it was + * visited earlier in this newest-first scan) or it is the current session + * itself (whose stale entry a rebuild is about to replace), and evicting either + * would cycle-evict entries the same scan still needs. + */ + scanTouched: Set; + /** + * Running total of the cache's retained characters. Maintained at every + * mutation site so the eager insert gate in `canCacheSessionSearchIndex` stays + * O(1) per miss instead of re-summing the whole cache on every keystroke + * (mid-scan overshoot of the entry cap would otherwise make that sum O(n²) + * across a scan). + */ + cacheChars: number; +}; + +/** Retained characters of an index (the prepared haystacks; raw fields are dropped). */ +function indexRetainedChars(index: SessionSearchIndex): number { + // `normalizedFields`, `haystack` and `haystackCompact` are all retained + // (`haystack` is `normalizedFields` joined, `haystackCompact` is derived + // from `haystack`), so their combined length approximates the cache's + // per-entry memory cost. Counting only the joined haystacks would + // undercount by roughly a full copy of the corpus and let the char budget + // retain ~50% more than advertised. + let total = index.prepared.haystack.length + index.prepared.haystackCompact.length; + for (const field of index.prepared.normalizedFields) total += field.length; + return total; +} + +/** + * Whether a freshly built index may be inserted into the cache without + * pushing either bound past its cap. Both bounds must fit eagerly — the prune + * that re-enforces them runs once per scan, so admitting every prepared index + * would let a single search over hundreds/thousands of uncached sessions + * transiently pin the whole corpus (several normalized haystacks per session) + * or hold one index and Map entry per live session — for empty/short sessions + * the char budget consumes little room, so the entry cap would be enforced + * only after the complete scan — before pruning, briefly allocating memory + * for the whole scanned list. + * + * Rather than rejecting inserts outright (which froze cache membership the + * same way a hard entry-count gate would), lack of headroom in either bound + * first evicts the cache's stale tail — the lowest-ranked entries this scan + * has not yet visited. Because the scan visits the ranked list newest-first, + * every untouched entry ranks below the current insert, so evicting any of + * them never drops an index a later visit of the same scan still needs (no + * cycle thrash) and always preserves the highest-ranked cached prefix. Only + * if no untouched entry remains does the insert get skipped — that is + * reachable only for misses ranked below every cached entry, which the + * end-of-scan prune would have evicted from the Map's back anyway, so the + * skip changes no steady-state membership: the index is simply rebuilt on + * the next search. + * + * The bounds check is applied to NEW entries only: cache hits merely + * re-append an existing entry (no growth), and a rebuild replaces the same + * session's entry (no entry-count growth; the char delta against the + * replaced index is checked). + */ +function canCacheSessionSearchIndex( + store: SessionHistorySearchStore, + previous: SessionSearchIndex | undefined, + index: SessionSearchIndex, +): boolean { + const deltaChars = indexRetainedChars(index) - (previous ? indexRetainedChars(previous) : 0); + // A rebuild replaces its own entry (no entry-count growth); a new entry + // grows the Map by one. + const growsEntries = !previous; + if ( + deltaChars <= 0 + && (!growsEntries || store.cache.size < MAX_SESSION_SEARCH_INDEX_CACHE_ENTRIES) + ) { + return true; + } + if ( + store.cacheChars + deltaChars <= MAX_SESSION_SEARCH_INDEX_CACHE_CHARS + && (!growsEntries || store.cache.size < MAX_SESSION_SEARCH_INDEX_CACHE_ENTRIES) + ) { + return true; + } + // Not enough headroom: evict the lowest-ranked untouched entries. Mid-scan + // the Map's order is old-insertion-order front to back with this scan's + // touched keys re-appended at the end, so scanning the keys back-to-front + // and skipping touched ones reaches the untouched (stalest, lowest-ranked) + // entries first. + let entriesToFree = growsEntries + ? store.cache.size + 1 - MAX_SESSION_SEARCH_INDEX_CACHE_ENTRIES + : 0; + const keysInInsertionOrder = [...store.cache.keys()]; + for (let i = keysInInsertionOrder.length - 1; i >= 0; i--) { + if (entriesToFree <= 0 + && store.cacheChars + deltaChars <= MAX_SESSION_SEARCH_INDEX_CACHE_CHARS) { + break; + } + const key = keysInInsertionOrder[i]; + if (store.scanTouched.has(key)) continue; + const evicted = store.cache.get(key); + if (!evicted) continue; + store.cacheChars -= indexRetainedChars(evicted); + store.cache.delete(key); + if (growsEntries) entriesToFree--; + } + return store.cacheChars + deltaChars <= MAX_SESSION_SEARCH_INDEX_CACHE_CHARS + && (!growsEntries || store.cache.size < MAX_SESSION_SEARCH_INDEX_CACHE_ENTRIES); +} + +function getSessionSearchIndex( + store: SessionHistorySearchStore, + session: SessionHistorySearchTarget, + untitledLabel: string, +): SessionSearchIndex { + const indexedTitle = getSessionSearchTitle(session, untitledLabel); + const cached = store.cache.get(session); + if (cached && cached.displayTitle === indexedTitle) { + // Re-append on every visit so the cache's insertion order mirrors the + // latest scan's visitation order (the ranked, newest-first session list); + // `pruneSessionSearchIndexCache` relies on that order when evicting. + store.cache.delete(session); + store.cache.set(session, cached); + store.scanTouched.add(session); + return cached; + } + const index = prepareSessionSearchIndex(session, untitledLabel); + // Mark the session touched before the gate so the gate's admission eviction + // can never pick this session's own (stale) entry — the rebuild below is + // about to replace it, and evicting it would corrupt the running char total. + store.scanTouched.add(session); + if (canCacheSessionSearchIndex(store, cached, index)) { + store.cache.set(session, index); + // Keep the eager gate's running total in sync (also covers the + // display-title-change rebuild, which replaces an existing entry). + store.cacheChars += indexRetainedChars(index) - (cached ? indexRetainedChars(cached) : 0); + } + return index; +} + +/** + * Eviction runs once per full scan (at the end of `filterSessionHistory`), not + * on every cache miss: evicting during the scan would make each miss drop the + * index of a later session in the same scan, so any history longer than the + * entry cap would cycle-evict the whole cache and rebuild every session's + * haystack on every keystroke. Deferring eviction keeps every retained entry + * usable within a scan while still bounding the cache — first by dropping + * indexes for sessions no longer in the scanned list, then by evicting the + * lowest-ranked entries (the oldest tail of the newest-first ranked list) + * until both bounds hold, preserving the front of the ranked list. + * + * In practice the eviction loop is cheap: both bounds are enforced eagerly + * on every insert (see `canCacheSessionSearchIndex`), so the cache never + * overshoots the entry cap or char budget mid-scan — the prune only drops + * indexes for sessions that left the scanned list and re-syncs the running + * char total. + */ +function pruneSessionSearchIndexCache( + store: SessionHistorySearchStore, + sessions: readonly SessionHistorySearchTarget[], +): void { + const live = new Set(sessions); + for (const key of [...store.cache.keys()]) { + if (!live.has(key)) store.cache.delete(key); + } + let retained = 0; + for (const cachedIndex of store.cache.values()) { + retained += indexRetainedChars(cachedIndex); + } + // Re-sync the eager gate's running total: the sum above is authoritative + // here (once per scan), so any drift accumulated mid-scan is corrected. + store.cacheChars = retained; + // Evict from the END of the Map until both bounds hold. Every scan visits + // the full ranked session list newest-first and `getSessionSearchIndex` + // re-appends each visited entry, so after a scan the Map's insertion order + // mirrors visitation order: the highest-ranked (newest) sessions sit at the + // FRONT and the oldest tail at the BACK. Evicting the front (classic LRU on + // Map order) would therefore drop the newest, highest-ranked sessions and + // retain the stalest tail — every subsequent keystroke would rebuild every + // session above the cache cap. Evicting the back instead preserves the + // front of the ranked list. The single remaining entry is always kept, even + // if it alone exceeds the char budget (a single entry is bounded by ~192K + // chars by the collector caps). + // Snapshot the keys once: prune only deletes (never inserts), so popping + // from this array visits keys exactly in Map order, back to front. + const keysInInsertionOrder = [...store.cache.keys()]; + while ( + store.cache.size > MAX_SESSION_SEARCH_INDEX_CACHE_ENTRIES + || retained > MAX_SESSION_SEARCH_INDEX_CACHE_CHARS + ) { + // A single entry can never push the cache past the char budget (it is + // bounded by ~192K chars by the collector caps), so keep the last entry. + if (store.cache.size <= 1) break; + const evictKey = keysInInsertionOrder.pop(); + if (evictKey === undefined) break; + const evicted = store.cache.get(evictKey); + if (evicted) retained -= indexRetainedChars(evicted); + store.cache.delete(evictKey); + } + store.cacheChars = retained; +} + +/** + * Release every cached index and touched-session reference for one store + * (invoked for the drawer's unmount lifecycle via the per-drawer search + * instance). Cache keys are the session objects themselves, so clearing fully + * drops all strong session references — the retained haystack characters + * become garbage together with their sessions. + */ +function clearSessionHistorySearchStore(store: SessionHistorySearchStore): void { + store.cache.clear(); + store.scanTouched.clear(); + store.cacheChars = 0; +} + +export type SessionHistorySearchOptions = { + /** + * Localized fallback label rendered by the drawer for sessions with an + * empty persisted title (e.g. `t('ai.chat.untitled')`); indexed as the + * displayed title so searching for the visible label finds the session. + */ + untitledLabel?: string; +}; + +function filterSessionHistoryStore( + store: SessionHistorySearchStore, + sessions: readonly T[], + query: string, + options: SessionHistorySearchOptions = {}, +): T[] { + const trimmed = query.trim(); + if (!trimmed) { + // The blank-query path skips the scan entirely; release any stale + // scan-touched state so it cannot pin visited sessions (and their + // message/attachment memory) while the drawer is open without a query. + // Normally this set is already empty — the nonblank scan clears it in its + // `finally` — but clearing here too keeps every entry path leak-free. + store.scanTouched.clear(); + // Cache keys are the session objects themselves, so an entry for a + // deleted/replaced session strongly retains that session (and its full + // message/attachment payloads). Prune against the current list here too: + // the blank-query path may run while the drawer stays open after + // deletions, and a later nonblank search that would prune otherwise may + // never happen (e.g. the user just closes the drawer). + pruneSessionSearchIndexCache(store, sessions); + return [...sessions]; + } + const untitledLabel = options.untitledLabel ?? ''; + // Bound and prepare the query once per scan: a pasted-in multi-megabyte + // query would otherwise be re-normalized (NFKC), tokenized and compacted + // inside `matchesSessionSearchIndex` for every session, freezing the + // renderer. The cap (`truncateQueryForMatch`, `MAX_SEARCH_QUERY_LENGTH`) is + // applied here at this scan's input — not inside the shared `prepareSearchQuery` + // — so the meaning of over-limit queries stays local to session history and + // the normalized forms are computed a single time, letting the per-session + // work below only do `includes` scans over the cached haystacks. + const preparedQuery = prepareSearchQuery(truncateQueryForMatch(trimmed)); + // A new scan starts with no visited sessions: the touched set records only + // this scan's visits so `canCacheSessionSearchIndex` can never mistake a + // previous scan's keys for keys the current scan still needs. + store.scanTouched.clear(); + try { + // Drop entries still held by sessions that left the scan list (e.g. the + // history shrank or session objects changed since the previous scan). Those + // entries are never visited by this scan — the end-of-scan prune would + // remove them too — but dropping them up front keeps their characters + // available to this scan's inserts (via the eager char gate). Cheap: one + // Set plus one pass over the cached keys. + const currentLive = new Set(sessions); + for (const key of [...store.cache.keys()]) { + if (!currentLive.has(key)) { + const removed = store.cache.get(key); + if (removed) store.cacheChars -= indexRetainedChars(removed); + store.cache.delete(key); + } + } + // Scan all sessions first (a miss admits its index into the cache — + // evicting this scan's untouched stale tail whenever either bound lacks + // headroom, and skipping the insert only when no untouched entry remains), + // then prune entries that fell out of the scan list at the end of the + // scan. + const result = sessions.filter((session) => { + const index = getSessionSearchIndex(store, session, untitledLabel); + return matchesSessionSearchIndex(preparedQuery, index); + }); + // Prune against the full scanned list (not the filtered result): sessions + // that merely did not match this query must keep their cached indexes. + pruneSessionSearchIndexCache(store, sessions); + return result; + } finally { + // The touched set only guards against cycle-eviction within the current + // scan; once the scan ends (even if it throws), release its strong + // references to session objects so deleted/replaced sessions and their + // large message/attachment payloads aren't retained until the next + // nonblank search. + store.scanTouched.clear(); + } +} + +/** + * One stateful search instance per consumer (one mounted session history + * drawer). The cache lives in the returned instance's store instead of module + * scope, so concurrently mounted drawers (one per terminal tab) never share + * state: an unmount of one drawer clears only that drawer's indexes, and each + * drawer's prune is scoped to its own session list. + */ +export type SessionHistorySearch = { + filterSessionHistory: ( + sessions: readonly T[], + query: string, + options?: SessionHistorySearchOptions, + ) => T[]; + clearSessionHistorySearchCache: () => void; +}; + +export function createSessionHistorySearch(): SessionHistorySearch { + const store: SessionHistorySearchStore = { + cache: new Map(), + scanTouched: new Set(), + cacheChars: 0, + }; + return { + filterSessionHistory(sessions, query, options = {}) { + return filterSessionHistoryStore(store, sessions, query, options); + }, + clearSessionHistorySearchCache() { + clearSessionHistorySearchStore(store); + }, + }; +} diff --git a/application/state/sessionRestoreSettings.test.ts b/application/state/sessionRestoreSettings.test.ts index bc63f3a2a..9314d87ea 100644 --- a/application/state/sessionRestoreSettings.test.ts +++ b/application/state/sessionRestoreSettings.test.ts @@ -209,3 +209,13 @@ test("restore terminal cwd setting participates in cross-window settings sync", assert.match(ipcSyncSource, /setRestoreTerminalCwdState/); assert.match(ipcSyncSource, /key === STORAGE_KEY_RESTORE_TERMINAL_CWD/); }); + +test("editor route keeps IPC/storage synchronization while disabling system effects", () => { + const source = readFileSync(new URL("../../index.tsx", import.meta.url), "utf8"); + const expression = source.match(/const settingsOptions =\s*([^;]+);/s)?.[1]; + assert.ok(expression, "renderer settings options must be explicit"); + const resolveOptions = new Function("isPeerSessionWindow", "isEditorWindow", `return (${expression});`); + assert.deepEqual(resolveOptions(false, true), { enableSystemEffects: false }); + assert.deepEqual(resolveOptions(true, false), { enableSettingsSync: false, enableSystemEffects: false }); + assert.equal(resolveOptions(false, false), undefined); +}); diff --git a/application/state/settingsStateDefaults.test.ts b/application/state/settingsStateDefaults.test.ts index 22507bc9e..edab951e4 100644 --- a/application/state/settingsStateDefaults.test.ts +++ b/application/state/settingsStateDefaults.test.ts @@ -9,9 +9,14 @@ import { resolveReadableForegroundForHsl, resolveThemeAccentForeground, migrateIncomingTerminalFontId, + migrateMaterializedAutoCloseOnExitDefault, } from "./settingsStateDefaults.ts"; import { TERMINAL_FONT_AUTO } from "../../infrastructure/config/fonts.ts"; -import { STORAGE_KEY_TERM_FONT_FAMILY } from "../../infrastructure/config/storageKeys.ts"; +import { + STORAGE_KEY_TERM_AUTO_CLOSE_EXIT_MIGRATED, + STORAGE_KEY_TERM_FONT_FAMILY, + STORAGE_KEY_TERM_SETTINGS, +} from "../../infrastructure/config/storageKeys.ts"; import type { UiThemeTokens } from "../../infrastructure/config/uiThemes.ts"; function installMemoryLocalStorage(): Map { @@ -140,3 +145,72 @@ test("app surface theme vars isolate non-terminal pages from active terminal chr "--ring": "270 70% 45%", }); }); + +test("migrateMaterializedAutoCloseOnExitDefault resets the materialized default-on once", () => { + const store = installMemoryLocalStorage(); + try { + store.set( + STORAGE_KEY_TERM_SETTINGS, + JSON.stringify({ scrollback: 3000, autoCloseOnExit: true }), + ); + migrateMaterializedAutoCloseOnExitDefault(); + const migrated = JSON.parse(store.get(STORAGE_KEY_TERM_SETTINGS)!); + assert.equal(migrated.autoCloseOnExit, false); + // Other saved settings pass through untouched. + assert.equal(migrated.scrollback, 3000); + assert.equal(store.get(STORAGE_KEY_TERM_AUTO_CLOSE_EXIT_MIGRATED), "true"); + } finally { + delete (globalThis as { localStorage?: unknown }).localStorage; + } +}); + +test("migrateMaterializedAutoCloseOnExitDefault runs once and respects later explicit edits", () => { + const store = installMemoryLocalStorage(); + try { + store.set( + STORAGE_KEY_TERM_SETTINGS, + JSON.stringify({ autoCloseOnExit: true }), + ); + migrateMaterializedAutoCloseOnExitDefault(); + assert.equal( + JSON.parse(store.get(STORAGE_KEY_TERM_SETTINGS)!).autoCloseOnExit, + false, + ); + // The user re-enables after upgrading: the migration must not undo it again. + store.set( + STORAGE_KEY_TERM_SETTINGS, + JSON.stringify({ autoCloseOnExit: true }), + ); + migrateMaterializedAutoCloseOnExitDefault(); + assert.equal( + JSON.parse(store.get(STORAGE_KEY_TERM_SETTINGS)!).autoCloseOnExit, + true, + ); + } finally { + delete (globalThis as { localStorage?: unknown }).localStorage; + } +}); + +test("migrateMaterializedAutoCloseOnExitDefault leaves fresh installs and explicit-off alone", () => { + const store = installMemoryLocalStorage(); + try { + // Fresh install: no saved settings at all. + migrateMaterializedAutoCloseOnExitDefault(); + assert.equal(store.has(STORAGE_KEY_TERM_SETTINGS), false); + assert.equal(store.get(STORAGE_KEY_TERM_AUTO_CLOSE_EXIT_MIGRATED), "true"); + + // Explicit-off (or any settings blob without the key) is never rewritten. + delete (globalThis as { localStorage?: unknown }).localStorage; + const store2 = installMemoryLocalStorage(); + store2.set( + STORAGE_KEY_TERM_SETTINGS, + JSON.stringify({ scrollback: 500 }), + ); + migrateMaterializedAutoCloseOnExitDefault(); + assert.deepEqual(JSON.parse(store2.get(STORAGE_KEY_TERM_SETTINGS)!), { + scrollback: 500, + }); + } finally { + delete (globalThis as { localStorage?: unknown }).localStorage; + } +}); diff --git a/application/state/settingsStateDefaults.ts b/application/state/settingsStateDefaults.ts index baa82f435..be5141a11 100644 --- a/application/state/settingsStateDefaults.ts +++ b/application/state/settingsStateDefaults.ts @@ -1,5 +1,9 @@ import type { HotkeyScheme, SessionLogFormat, TerminalSettings } from '../../domain/models'; -import { STORAGE_KEY_TERM_FONT_FAMILY } from '../../infrastructure/config/storageKeys'; +import { + STORAGE_KEY_TERM_AUTO_CLOSE_EXIT_MIGRATED, + STORAGE_KEY_TERM_FONT_FAMILY, + STORAGE_KEY_TERM_SETTINGS, +} from '../../infrastructure/config/storageKeys'; import { isDeprecatedPrimaryFontId, TERMINAL_FONT_AUTO, @@ -63,6 +67,30 @@ export function migrateIncomingTerminalFontId(raw: string | null | undefined): s } return raw; } +/** + * One-time migration for the autoCloseOnExit default flip (tabs now survive + * session exits by default; the old default-on silently deleted tabs whenever + * a bastion or idle-harvesting server reported a clean exit). Builds before + * the flip shipped autoCloseOnExit: true as the default and materialized it + * into STORAGE_KEY_TERM_SETTINGS on any settings write, so most upgraders + * carry a value they never chose. Leaving it in place would make the new + * default unreachable for them. Reset that materialized `true` exactly once + * and persist the marker; afterwards every stored value is treated as the + * user's own choice and is respected. An inbound cloud or backup payload that + * still carries the pre-migration materialized true (no + * autoCloseOnExitDefaultMigrated flag) is rewritten to false so sync cannot + * resurrect auto-close. The cost: a user who deliberately enabled auto-close + * before this release must re-enable it once. + */ +export function migrateMaterializedAutoCloseOnExitDefault(): void { + if (localStorageAdapter.readBoolean(STORAGE_KEY_TERM_AUTO_CLOSE_EXIT_MIGRATED)) return; + const stored = localStorageAdapter.read>(STORAGE_KEY_TERM_SETTINGS); + if (stored?.autoCloseOnExit === true) { + localStorageAdapter.write(STORAGE_KEY_TERM_SETTINGS, { ...stored, autoCloseOnExit: false }); + } + localStorageAdapter.writeBoolean(STORAGE_KEY_TERM_AUTO_CLOSE_EXIT_MIGRATED, true); +} + // Auto-detect default hotkey scheme based on platform export const DEFAULT_HOTKEY_SCHEME: HotkeyScheme = typeof navigator !== 'undefined' && /Mac|iPhone|iPad|iPod/i.test(navigator.platform) diff --git a/application/state/sftp/externalUploadRuntime.test.ts b/application/state/sftp/externalUploadRuntime.test.ts index af69828d8..491b4f7ce 100644 --- a/application/state/sftp/externalUploadRuntime.test.ts +++ b/application/state/sftp/externalUploadRuntime.test.ts @@ -1,7 +1,7 @@ import test from "node:test"; import assert from "node:assert/strict"; -import { UploadController } from "../../../lib/uploadService"; +import { UploadController, uploadEntriesDirect } from "../../../lib/uploadService"; import { cancelExternalUploadRuntime, getExternalUploadController, @@ -37,3 +37,64 @@ test("external upload controls survive their originating panel unmount", async ( assert.equal(getExternalUploadController("folder-1"), undefined); assert.equal(getExternalUploadController("child-1"), undefined); }); + +for (const selectedFile of ["a.bin", "c.bin"]) { + test(`cancelling independent ${selectedFile} preserves its active and queued upload siblings`, async (t) => { + resetExternalUploadRuntimeForTests(); + t.after(resetExternalUploadRuntimeForTests); + const controller = new UploadController(); + const cancelled: string[] = []; + const stagedCancelled: string[] = []; + const ids = new Map(); + const pending = new Map void>(); + const started: string[] = []; + const upload = uploadEntriesDirect( + ["a.bin", "b.bin", "c.bin"].map((name) => ({ + file: null, relativePath: name, isDirectory: false, localPath: `/local/${name}`, size: 8, + })), + { + targetPath: "/remote", sftpId: "fixture", isLocal: false, + fileTransferConcurrency: 2, joinPath: (base, name) => `${base}/${name}`, + callbacks: { + onTaskCreated: (task) => { + ids.set(task.fileName, task.id); + registerExternalUploadController(task.id, controller, "file"); + }, + }, + bridge: { + mkdirSftp: async () => {}, + cancelStagedUploadFile: async (id) => { stagedCancelled.push(id); }, + cancelTransfer: async (id) => { cancelled.push(id); pending.get(id)?.(true); }, + startStreamTransfer: ({ transferId, sourcePath }) => new Promise((resolve) => { + started.push(sourcePath); + pending.set(transferId, (cancelled = false) => resolve({ transferId, cancelled })); + }), + }, + }, + controller, + ); + t.after(async () => { + await controller.cancel(); + for (const finish of pending.values()) finish(true); + await upload; + }); + await new Promise(setImmediate); + assert.deepEqual(started, ["/local/a.bin", "/local/b.bin"]); + const selectedId = ids.get(selectedFile)!; + await cancelExternalUploadRuntime(selectedId); + assert.deepEqual(cancelled, [selectedId]); + assert.deepEqual(stagedCancelled, [selectedId]); + assert.equal(controller.isCancelled(), false); + await new Promise(setImmediate); + assert.deepEqual(started, selectedFile === "a.bin" + ? ["/local/a.bin", "/local/b.bin", "/local/c.bin"] + : ["/local/a.bin", "/local/b.bin"]); + for (const [id, finish] of pending) if (id !== selectedId) finish(); + const results = (await upload).sort((a, b) => a.fileName.localeCompare(b.fileName)); + assert.deepEqual(results, ["a.bin", "b.bin", "c.bin"].map((fileName) => ( + fileName === selectedFile + ? { fileName, success: false, cancelled: true } + : { fileName, success: true } + ))); + }); +} diff --git a/application/state/sftp/externalUploadRuntime.ts b/application/state/sftp/externalUploadRuntime.ts index 85f820f1c..4f2a28f52 100644 --- a/application/state/sftp/externalUploadRuntime.ts +++ b/application/state/sftp/externalUploadRuntime.ts @@ -5,11 +5,13 @@ import type { UploadController } from "../../../lib/uploadService"; // terminal only removes the view; the global transfer runtime can still cancel // the upload, and the upload's own finally block releases these entries. const controllersByTaskId = new Map(); +const scopesByTaskId = new Map(); const taskIdsByController = new Map>(); export function registerExternalUploadController( taskId: string, controller: UploadController, + scope: "file" | "batch" = "batch", ): void { if (!taskId) return; const previous = controllersByTaskId.get(taskId); @@ -19,6 +21,7 @@ export function registerExternalUploadController( if (previousIds?.size === 0) taskIdsByController.delete(previous); } controllersByTaskId.set(taskId, controller); + scopesByTaskId.set(taskId, scope); const taskIds = taskIdsByController.get(controller) ?? new Set(); taskIds.add(taskId); taskIdsByController.set(controller, taskIds); @@ -30,6 +33,7 @@ export function unregisterExternalUploadController(controller: UploadController) for (const taskId of taskIds) { if (controllersByTaskId.get(taskId) === controller) { controllersByTaskId.delete(taskId); + scopesByTaskId.delete(taskId); } } taskIdsByController.delete(controller); @@ -40,6 +44,10 @@ export function getExternalUploadController(taskId: string): UploadController | } export async function cancelExternalUploadRuntime(taskId?: string): Promise { + if (taskId && scopesByTaskId.get(taskId) === "file") { + await controllersByTaskId.get(taskId)?.cancelFile(taskId); + return true; + } const controllers = taskId ? [controllersByTaskId.get(taskId)].filter((value): value is UploadController => !!value) : [...taskIdsByController.keys()]; @@ -50,5 +58,6 @@ export async function cancelExternalUploadRuntime(taskId?: string): Promise { + const { markTransferCancelledTree, resetTransferCancelLatchesForTests } = await import("./transferCancelLatch"); + const { bumpTransferControlEpoch } = await import("./transferControlEpoch"); + const { releaseTransferPauseTree } = await import("./transferPauseLatch"); + t.after(() => { resetTransferCancelLatchesForTests(); resetTransferPauseLatchesForTests(); }); + let finishPause!: (value: { success: boolean }) => void; + let resumes = 0; + const { host } = createHost([makeTask("cancel-during-pause")], () => ({ + pauseTransfer: () => new Promise((resolve) => { finishPause = resolve; }), + resumeTransfer: async () => { resumes += 1; return { success: true }; }, + })); + const pause = softPauseTransfer(host, "cancel-during-pause"); + markTransferCancelledTree("cancel-during-pause"); + bumpTransferControlEpoch("cancel-during-pause"); + releaseTransferPauseTree("cancel-during-pause"); + finishPause({ success: true }); + await pause; + assert.equal(resumes, 0); +}); diff --git a/application/state/sftp/globalSftpTransferControl.ts b/application/state/sftp/globalSftpTransferControl.ts index bdae2f9cf..80342e89e 100644 --- a/application/state/sftp/globalSftpTransferControl.ts +++ b/application/state/sftp/globalSftpTransferControl.ts @@ -24,6 +24,7 @@ import { latchTransferPauseTree, releaseTransferPauseTree, } from "./transferPauseLatch"; +import { isTransferOrRootCancelled } from "./transferCancelLatch"; import { isTransferWalkInFlight } from "./transferWalkRegistry"; export type TransferControlBridge = { @@ -72,7 +73,7 @@ export function reconcileSupersededControls( requestedAction: "pause" | "resume", controlTaskId = taskId, ): void { - if (!isTransferControlEpochCurrent(controlTaskId, epoch)) return; + if (!isTransferControlEpochCurrent(controlTaskId, epoch) || isTransferOrRootCancelled(taskId)) return; const task = host.getTasks().find((candidate) => candidate.id === taskId); if (!task || ["completed", "cancelled", "failed"].includes(task.status)) return; const apply = (id: string, descendants: string[], action: "pause" | "resume" | "cancel") => { @@ -234,7 +235,7 @@ export async function softPauseTransfer( const undoObsoletePause = async (id: string) => { const live = host.getTasks().find((candidate) => candidate.id === taskId); if (!live || ["completed", "cancelled", "failed", "interrupted"].includes(live.status)) return; - if (isTransferOrRootPauseLatched(taskId, id)) return; + if (isTransferOrRootPauseLatched(taskId, id) || isTransferOrRootCancelled(taskId, id)) return; try { await bridge!.resumeTransfer?.(id); } catch { /* best-effort */ } }; const pauseOne = async (id: string) => { diff --git a/application/state/sftp/transferCancelOutcome.ts b/application/state/sftp/transferCancelOutcome.ts new file mode 100644 index 000000000..b289c8bc8 --- /dev/null +++ b/application/state/sftp/transferCancelOutcome.ts @@ -0,0 +1,46 @@ +import type { TransferTask } from "../../../domain/models"; +import { isValidDirectoryResumeCheckpoint } from "../../../domain/sftpDirectoryCheckpoint"; + +/** Apply only after backend cancellation AND the old walk have settled. */ +export function settleCancelledTransferTree( + tasks: readonly TransferTask[], + rootId: string, + cancelledIds: ReadonlySet, + failedIds: ReadonlySet, + retainedTasks: readonly TransferTask[] = [], +): TransferTask[] { + const existingTasks = new Map(tasks.map((task) => [task.id, task])); + // Use observed terminal outcomes rather than stale pre-cancel snapshots. + // Missing completed children already covered by the parent stay compacted; + // cancelled/failed exceptions still need their rows for the recovery walk. + const settledTasks = failedIds.size > 0 + ? [...tasks, ...retainedTasks.filter((task) => { + if (existingTasks.has(task.id)) return false; + const checkpoint = task.parentTaskId + ? existingTasks.get(task.parentTaskId)?.directoryResumeCheckpoint + : undefined; + return !(task.status === "completed" + && isValidDirectoryResumeCheckpoint(checkpoint) + && Number.isSafeInteger(task.directoryEntryIndex) + && (task.directoryEntryIndex ?? -1) >= 0 + && task.directoryEntryIndex! < checkpoint.coveredEntries); + })] + : tasks; + return settledTasks.map((task) => { + // Natural completion/failure wins a race with Cancel. Rows cancelled by + // this attempt can be re-admitted only when the whole tree needs recovery. + if (!cancelledIds.has(task.id) || task.status === "completed" || task.status === "failed") return task; + const failed = failedIds.has(task.id) || (task.id === rootId && failedIds.size > 0); + const recoverable = failedIds.size > 0; + return { + ...task, + status: failed ? "attention" : recoverable ? "interrupted" : "cancelled", + error: failed ? "Could not cancel transfer. Please try again." : undefined, + reconnectRequired: recoverable, + endTime: recoverable ? undefined : Date.now(), + speed: 0, + phase: undefined, + conflict: undefined, + }; + }); +} diff --git a/application/state/sftp/transferCancellation.test.ts b/application/state/sftp/transferCancellation.test.ts new file mode 100644 index 000000000..d5ec9160f --- /dev/null +++ b/application/state/sftp/transferCancellation.test.ts @@ -0,0 +1,375 @@ +import assert from "node:assert/strict"; +import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import test from "node:test"; +import type { TransferTask } from "../../../domain/models"; +import { netcattyBridge } from "../../../infrastructure/services/netcattyBridge"; +import { createSftpTransferCenterStore } from "../sftpTransferCenterStore"; +import { globalSftpTransferScheduler } from "./globalTransferScheduler"; +import { isTransferOrRootCancelled, resetTransferCancelLatchesForTests } from "./transferCancelLatch"; +import { createTransferRuntime, resetTransferRuntimeRunsForTests } from "./transferRuntime"; +import { resetTransferWalkRegistryForTests } from "./transferWalkRegistry"; + +const task = (id: string, extra: Partial = {}): TransferTask => ({ + id, fileName: id, sourcePath: `/source/${id}`, targetPath: `/target/${id}`, + sourceConnectionId: "local", targetConnectionId: "fixture", direction: "upload", + status: "transferring", totalBytes: 8, transferredBytes: 0, speed: 0, + startTime: 1, isDirectory: false, resumable: true, ...extra, +}); +const tick = () => new Promise((resolve) => setImmediate(resolve)); + +test("failed cancellation waits for the old walk, then Resume restores scheduler-rejected file bytes", async (t) => { + const root = await mkdtemp(join(tmpdir(), "netcatty-cancel-")); + const originalGet = netcattyBridge.get; + t.after(async () => { + netcattyBridge.get = originalGet; + resetTransferCancelLatchesForTests(); + resetTransferWalkRegistryForTests(); + resetTransferRuntimeRunsForTests(); + await rm(root, { recursive: true, force: true }); + }); + const store = createSftpTransferCenterStore(); + const runtime = createTransferRuntime(store); + const ids = ["active", "queued-1", "queued-2"]; + const expected = ids.map((id) => Buffer.from(`bytes-of-${id}`)); + const children = ids.map((id) => task(id, { parentTaskId: "folder", targetPath: join(root, id) })); + store.publishOwner("closed-panel", [task("folder", { isDirectory: true, targetHostId: "fixture-host" }), ...children]); + let release!: () => void; + const held = new Promise((resolve) => { release = resolve; }); + t.after(() => release()); + let softResumeCalls = 0; + let pauseCalls = 0; + netcattyBridge.get = () => ({ + cancelTransfer: async (id: string) => ({ success: id !== "active" }), + pauseTransfer: async () => { pauseCalls += 1; return { success: true }; }, + clearPendingTransferCancel: async () => undefined, + resumeTransfer: async () => { softResumeCalls += 1; return { success: true }; }, + } as unknown as ReturnType); + let starts = 0; + const walking = runtime.runWalk("folder", async () => { + await Promise.all(children.map((child, index) => globalSftpTransferScheduler.run( + "closed-panel", child.id, ["cancel-fixture"], () => 1, async () => { + starts += 1; + await held; + await writeFile(child.targetPath, expected[index]); + store.patchTask(child.id, { status: "completed" }); + }, + ).catch(() => store.patchTask(child.id, { status: "cancelled" })))); + store.patchTask("folder", { status: "cancelled" }); + }); + await tick(); + assert.equal(starts, 1); + let cancelSettled = false; + const cancelling = store.cancel("folder").then(() => { cancelSettled = true; }); + const resuming = store.resume("folder"); + await tick(); + assert.equal(cancelSettled, true, "failed cancel must return promptly so the user can retry"); + assert.equal(store.getTask("folder")?.status, "attention"); + assert.equal(isTransferOrRootCancelled("folder"), true); + assert.equal(softResumeCalls, 0, "must not unlatch the old walk"); + await store.pause("folder"); + assert.equal(pauseCalls, 0, "a late row-level Pause must not park the cancelling walk"); + let freshWalks = 0; + store.setDedicatedResumeHandler(async () => { + freshWalks += 1; + assert.equal(runtime.isWalkInFlight("folder"), false); + for (let index = 0; index < children.length; index += 1) { + const child = children[index]; + if (store.getTask(child.id)?.status === "completed") continue; + assert.equal(store.admitTaskRun(child), "ready"); + await writeFile(child.targetPath, expected[index]); + store.patchTask(child.id, { status: "completed" }); + } + return { success: true }; + }); + release(); + await Promise.all([walking, cancelling, resuming]); + assert.equal(freshWalks, 1); + assert.equal(starts, 1, "cancelled queue callbacks cannot silently count as transferred files"); + assert.equal(store.getTask("folder")?.status, "completed"); + for (let index = 0; index < children.length; index += 1) { + assert.deepEqual(await readFile(children[index].targetPath), expected[index]); + } +}); + +test("failed ownerless compression cancellation retries the same backend", async (t) => { + const originalGet = netcattyBridge.get; + t.after(() => { netcattyBridge.get = originalGet; resetTransferCancelLatchesForTests(); }); + const store = createSftpTransferCenterStore(); + const calls: string[] = []; + netcattyBridge.get = () => ({ + cancelCompressedUpload: async (id: string) => { + calls.push(`compressed:${id}`); + return { success: calls.length > 1 }; + }, + cancelTransfer: async (id: string) => { calls.push(`stream:${id}`); return { success: true }; }, + } as unknown as ReturnType); + store.publishOwner("closed-panel", [task("archive", { controlKind: "compressed-upload" })]); + await store.cancel("archive"); + assert.equal(store.getTask("archive")?.status, "attention"); + await store.cancel("archive"); + assert.deepEqual(calls, ["compressed:archive", "compressed:archive"]); + assert.equal(store.getTask("archive")?.status, "cancelled"); +}); + +test("late completion survives cancellation, while an unrelated paused batch peer stays paused", async (t) => { + const originalGet = netcattyBridge.get; + t.after(() => { netcattyBridge.get = originalGet; resetTransferCancelLatchesForTests(); }); + const store = createSftpTransferCenterStore(); + const calls: string[] = []; + store.publishOwner("gone", [task("selected", { batchId: "batch" }), task("peer", { batchId: "batch", status: "paused" })]); + netcattyBridge.get = () => ({ + cancelTransfer: async (id: string) => { + calls.push(id); + store.patchTask(id, { status: "completed" }); + return { success: true }; + }, + } as unknown as ReturnType); + await store.cancel("selected"); + assert.deepEqual(calls, ["selected"]); + assert.equal(store.getTask("selected")?.status, "completed"); + assert.equal(store.getTask("peer")?.status, "paused"); +}); + + +test("Resume cannot revive a tree pre-latched by an upcoming Cancel all batch", async (t) => { + const { markTransferCancelledTree } = await import("./transferCancelLatch"); + t.after(resetTransferCancelLatchesForTests); + const store = createSftpTransferCenterStore(); + store.publishOwner("gone", [task("waiting-cancel-batch", { status: "paused", isDirectory: true })]); + markTransferCancelledTree("waiting-cancel-batch"); + await store.resume("waiting-cancel-batch"); + assert.equal(store.getTask("waiting-cancel-batch")?.status, "paused"); + assert.equal(isTransferOrRootCancelled("waiting-cancel-batch"), true); +}); + + +test("failed cancel can be retried while its old walk is still running", async (t) => { + const originalGet = netcattyBridge.get; + t.after(() => { netcattyBridge.get = originalGet; resetTransferCancelLatchesForTests(); }); + const store = createSftpTransferCenterStore(); + const runtime = createTransferRuntime(store); + store.publishOwner("gone", [task("retry-live-cancel", { isDirectory: true })]); + let release!: () => void; + const held = new Promise((resolve) => { release = resolve; }); + t.after(() => release()); + const walking = runtime.runWalk("retry-live-cancel", async () => { await held; }); + let attempts = 0; + netcattyBridge.get = () => ({ + cancelTransfer: async () => { + attempts += 1; + if (attempts === 1) return { success: false }; + release(); + return { success: true }; + }, + cleanupTransferArtifacts: async () => undefined, + } as unknown as ReturnType); + await store.cancel("retry-live-cancel"); + assert.equal(runtime.isWalkInFlight("retry-live-cancel"), true); + assert.equal(store.getTask("retry-live-cancel")?.status, "attention"); + const resume = store.resume("retry-live-cancel"); + await store.cancel("retry-live-cancel"); + await Promise.all([walking, resume]); + assert.equal(attempts, 2, "second cancel must reach the backend before natural completion"); + assert.equal(store.getTask("retry-live-cancel")?.status, "cancelled", "new successful cancel owns final outcome"); +}); + + +test("large orphan recovery clears pending cancellations in bounded concurrent batches", async (t) => { + const originalGet = netcattyBridge.get; + t.after(() => { netcattyBridge.get = originalGet; resetTransferCancelLatchesForTests(); }); + const store = createSftpTransferCenterStore(); + store.publishOwner("gone", [task("cleanup-root", { isDirectory: true }), + ...Array.from({ length: 2200 }, (_, index) => task(`cleanup-${index}`, { parentTaskId: "cleanup-root" }))]); + let active = 0; + let peak = 0; + let cleared = 0; + netcattyBridge.get = () => ({ + cancelTransfer: async (id: string) => ({ success: id !== "cleanup-0" }), + clearPendingTransferCancel: async () => { + active += 1; + peak = Math.max(peak, active); + await new Promise((resolve) => setImmediate(resolve)); + active -= 1; + cleared += 1; + }, + } as unknown as ReturnType); + await store.cancel("cleanup-root"); + assert.equal(cleared, 2201); + assert.ok(peak > 1, "recovery must not serialize one IPC per row"); + assert.ok(peak <= 32, "recovery uses the cancellation batch bound"); +}); + +for (const completeDuring of ["backend-cancel", "walk-settlement"] as const) { + test(`failed cancellation preserves compacted completion during ${completeDuring} on dedicated resume`, async (t) => { + const { sftpTransferCenterStore: store } = await import("../sftpTransferCenterStore"); + const { createDirectoryEntryIdentity } = await import("../../../domain/sftpDirectoryCheckpoint"); + const { resumeTransferWithDedicatedSession, resetDedicatedSessionOpenGateForTests } = + await import("./dedicatedTransferResume"); + const originalGet = netcattyBridge.get; + const previousStorage = Object.getOwnPropertyDescriptor(globalThis, "localStorage"); + Object.defineProperty(globalThis, "localStorage", { + configurable: true, + value: { getItem: () => null, setItem: () => {}, removeItem: () => {} }, + }); + const parent = task(`compacted-cancel-${completeDuring}`, { + sourcePath: "/source/folder", targetPath: "/target/folder", targetHostId: "fixture-host", + isDirectory: true, progressMode: "files", totalBytes: 3, + }); + const children = ["completed", "cancelled", "cancel-failed"].map((name, index) => { + const child = task(`${parent.id}-${name}`, { + parentTaskId: parent.id, fileName: name, + sourcePath: `${parent.sourcePath}/${name}`, targetPath: `${parent.targetPath}/${name}`, + sourceLastModified: 1, directoryEntryIndex: index, + }); + return { ...child, directoryEntryIdentity: createDirectoryEntryIdentity({ + sourcePath: child.sourcePath, targetPath: child.targetPath, size: child.totalBytes, lastModified: 1, + }) }; + }); + // Match the sorted traversal order so dedicated resume validates the actual checkpoint. + children.sort((a, b) => a.sourcePath.localeCompare(b.sourcePath)); + children.forEach((child, index) => { child.directoryEntryIndex = index; }); + const completed = children.find((child) => child.fileName === "completed")!; + const cancelled = children.find((child) => child.fileName === "cancelled")!; + const failed = children.find((child) => child.fileName === "cancel-failed")!; + const runtime = createTransferRuntime(store); + let release!: () => void; + const held = new Promise((resolve) => { release = resolve; }); + let walking: Promise | undefined; + t.after(async () => { + release(); + await walking; + store.setDedicatedResumeHandler(null); + store.patchTask(parent.id, { status: "completed" }); + store.dismiss(parent.id); + netcattyBridge.get = originalGet; + resetTransferCancelLatchesForTests(); + resetTransferWalkRegistryForTests(); + resetTransferRuntimeRunsForTests(); + resetDedicatedSessionOpenGateForTests(); + if (previousStorage) Object.defineProperty(globalThis, "localStorage", previousStorage); + else Reflect.deleteProperty(globalThis, "localStorage"); + }); + const uploads: string[] = []; + const progress: number[] = []; + netcattyBridge.get = () => ({ + cancelTransfer: async (id: string) => { + if (completeDuring === "backend-cancel" && id === completed.id) { + store.ingestBackgroundEvent({ type: "completed", transferId: id, transferred: 8, totalBytes: 8 }); + } + return { success: id !== failed.id }; + }, + clearPendingTransferCancel: async () => undefined, + openSftp: async () => "reconnected-sftp", closeSftp: async () => {}, + listLocalTree: async () => children.map((child) => ({ + localPath: child.sourcePath, relativePath: child.fileName, + type: "file", size: 8, lastModified: 1, + })), + mkdirSftp: async () => {}, + statLocal: async () => ({ size: 8, lastModified: 1 }), + startStreamTransfer: async (options: { sourcePath: string; checkpointBytes: number }) => { + if (options.sourcePath === cancelled.sourcePath) assert.equal(options.checkpointBytes, 4); + uploads.push(options.sourcePath); + return {}; + }, + } as unknown as ReturnType); + store.publishOwner("closed-panel", [parent, ...children]); + walking = runtime.runWalk(parent.id, async () => { + await held; + if (completeDuring === "walk-settlement") { + store.ingestBackgroundEvent({ type: "completed", transferId: completed.id, transferred: 8, totalBytes: 8 }); + } + store.patchTask(cancelled.id, { status: "cancelled", checkpointBytes: 4 }); + store.patchTask(parent.id, { status: "cancelled" }); + }); + await store.cancel(parent.id); + assert.equal(runtime.isWalkInFlight(parent.id), true); + store.setDedicatedResumeHandler(async (root) => { + assert.equal(root.directoryResumeCheckpoint?.completedEntries, 1); + assert.equal(store.getTask(completed.id), undefined, "the finished child stays compacted"); + assert.equal(store.getTask(cancelled.id)?.status, "interrupted"); + assert.equal(store.getTask(failed.id)?.status, "attention"); + return resumeTransferWithDedicatedSession(root, { + hosts: [{ id: "fixture-host", label: "fixture", hostname: "fixture", port: 22, + username: "test", authMethod: "password", protocol: "ssh", tags: [], os: "linux" }], + keys: [], identities: [], + }, (value) => { progress.push(value.transferred); }, { + children: store.getSnapshot().tasks.filter((child) => child.parentTaskId === parent.id), + onChildUpdate: (child) => { store.upsertTasks([child]); }, + onDirectoryCheckpointUpdate: (checkpoint) => store.patchTask(parent.id, { directoryResumeCheckpoint: checkpoint }), + }); + }); + const resuming = store.resume(parent.id); + release(); + await Promise.all([walking, resuming]); + assert.deepEqual(uploads.sort(), [cancelled.sourcePath, failed.sourcePath].sort(), + "only cancelled and failed-to-cancel children should upload again"); + assert.equal(store.getTask(parent.id)?.status, "completed"); + assert.ok(progress.every((count) => count <= 3), "compacted completion must count exactly once"); + assert.equal(progress.at(-1), 3); + }); +} + +for (const rejects of [false, true]) { + test(`failed live compression cancellation resumes the same compression job: rejects=${rejects}`, async (t) => { + const originalGet = netcattyBridge.get; + t.after(() => { netcattyBridge.get = originalGet; resetTransferCancelLatchesForTests(); }); + const store = createSftpTransferCenterStore(); + const calls: string[] = []; + netcattyBridge.get = () => ({ + cancelCompressedUpload: async () => { + if (rejects) throw new Error("Cancel IPC failed"); + return { success: false }; + }, + resumeCompressedUpload: async () => { calls.push("compressed"); return { success: true }; }, + resumeTransfer: async () => { calls.push("generic"); return { success: false, reason: "not active" }; }, + clearPendingTransferCancel: async () => {}, + } as unknown as ReturnType); + store.setDedicatedResumeHandler(async () => { calls.push("dedicated"); return { success: true }; }); + store.publishOwner("closed-panel", [task("live-compressed", { + isDirectory: true, controlKind: "compressed-upload", reconnectRequired: false, + })]); + await store.cancel("live-compressed"); + assert.equal(store.getTask("live-compressed")?.status, "attention"); + await store.resume("live-compressed"); + assert.deepEqual(calls, ["compressed"], "failed cancellation must not start a second destination writer"); + assert.equal(store.getTask("live-compressed")?.status, "transferring"); + }); +} + +for (const hasConflict of [false, true]) { + test(`restored compressed attention uses reconnect and preserves conflict gating: conflict=${hasConflict}`, async (t) => { + const { serializeSftpTransferCenter } = await import("../../../domain/sftpTransferCenter"); + const { listGloballyResumableTransferIds } = await import("../../../domain/sftpTransferActions"); + const { restoreSftpTransferHistoryCooperatively } = await import("./transferHistoryRestoreMigration"); + const originalGet = netcattyBridge.get; + t.after(() => { netcattyBridge.get = originalGet; }); + const saved = task("restored-compressed", { + status: "attention", isDirectory: true, controlKind: "compressed-upload", reconnectRequired: false, + error: "Could not cancel the compressed upload.", + ...(hasConflict ? { conflict: { + transferId: "restored-compressed", fileName: "folder", sourcePath: "/source/folder", targetPath: "/target/folder", + isDirectory: true, existingSize: 8, newSize: 8, existingModified: 1, newModified: 1, + } } : {}), + }); + const raw = serializeSftpTransferCenter([saved]); + const restored = createSftpTransferCenterStore({ read: () => raw, write: () => {} }); + const cooperative = await restoreSftpTransferHistoryCooperatively(JSON.stringify({ version: 1, tasks: [saved] })); + assert.equal(restored.getTask(saved.id)?.reconnectRequired, true); + assert.equal(cooperative.tasks[0]?.reconnectRequired, true); + assert.equal(restored.getTask(saved.id)?.status, "attention"); + assert.deepEqual(restored.getTask(saved.id)?.conflict, saved.conflict); + assert.equal(saved.reconnectRequired, false, "saving must not reclassify the current live row"); + const calls: string[] = []; + netcattyBridge.get = () => ({ + resumeCompressedUpload: async () => { calls.push("compressed"); return { success: true }; }, + clearPendingTransferCancel: async () => {}, + } as unknown as ReturnType); + restored.setDedicatedResumeHandler(async () => { calls.push("dedicated"); return { success: true }; }); + const resumeIds = listGloballyResumableTransferIds(restored.getSnapshot().tasks); + assert.deepEqual(resumeIds, hasConflict ? [] : [saved.id]); + for (const id of resumeIds) await restored.resume(id); + assert.deepEqual(calls, hasConflict ? [] : ["dedicated"]); + }); +} diff --git a/application/state/sftp/transferDirectoryOps.ts b/application/state/sftp/transferDirectoryOps.ts index 032ad6f5c..8a758cb33 100644 --- a/application/state/sftp/transferDirectoryOps.ts +++ b/application/state/sftp/transferDirectoryOps.ts @@ -972,13 +972,10 @@ export function useSftpDirectoryTransferOps({ if (isTransferCancelledError(err)) { // Keep cancelled status; do not rethrow — other workers must finish // and the parent should not become a clean completed tree. - setTransfers((prev) => - prev.map((t) => - t.id === fileId - ? { ...t, status: "cancelled" as TransferStatus, error: undefined, endTime: Date.now() } - : t, - ), - ); + setTransfers((prev) => prev.map((task) => + task.id === fileId + ? { ...task, status: "cancelled" as TransferStatus, error: undefined, endTime: Date.now() } + : task)); errors.push(err instanceof Error ? err : new Error(message)); return; } diff --git a/application/state/sftp/transferRuntime.ts b/application/state/sftp/transferRuntime.ts index ad9e73c6d..aa6586883 100644 --- a/application/state/sftp/transferRuntime.ts +++ b/application/state/sftp/transferRuntime.ts @@ -51,6 +51,7 @@ export interface TransferRuntime { * walk is dead / reconnectRequired. Single external operation. */ resume(taskId: string): Promise; + /** Wait for backend cancellation requests; Resume remains fenced on walk settlement. */ cancel(taskId: string): Promise; /** diff --git a/application/state/sftp/transferTaskOps.ts b/application/state/sftp/transferTaskOps.ts index 94d9b2700..025c966df 100644 --- a/application/state/sftp/transferTaskOps.ts +++ b/application/state/sftp/transferTaskOps.ts @@ -71,21 +71,24 @@ export function useSftpTransferTaskOps({ const bridge = netcattyBridge.get(); const cancelTransferAtBackend = bridge?.cancelTransfer; const cancelCompressedUpload = bridge?.cancelCompressedUpload; - if (!cancelTransferAtBackend && !cancelCompressedUpload) return; + if (!cancelTransferAtBackend && !cancelCompressedUpload) return []; - await Promise.all( - Array.from(idsToCancel).map(async (id) => { + const failed: string[] = []; + const ids = [...idsToCancel]; + for (let offset = 0; offset < ids.length; offset += 32) { + await Promise.all(ids.slice(offset, offset + 32).map(async (id) => { const candidate = currentTransfers.find((task) => task.id === id); const compressed = candidate?.controlKind === "compressed-upload"; - const operation = compressed - ? cancelCompressedUpload?.(id) - : cancelTransferAtBackend?.(id); - const results = operation ? await Promise.allSettled([operation]) : []; - if (results.some((result) => result.status === "rejected")) { - logger.warn("Failed to cancel one or more transfer backends"); + try { + const result = await (compressed ? cancelCompressedUpload?.(id) : cancelTransferAtBackend?.(id)); + if (result && result.success === false) failed.push(id); + } catch { + failed.push(id); } - }), - ); + })); + } + if (failed.length > 0) logger.warn("Failed to cancel one or more transfer backends"); + return failed; }, [activeChildIdsRef, cancelledTasksRef, transfersRef]); const markBatchStopped = useCallback( diff --git a/application/state/sftp/transferWalkRegistry.ts b/application/state/sftp/transferWalkRegistry.ts index 128cf8e6b..fe6859162 100644 --- a/application/state/sftp/transferWalkRegistry.ts +++ b/application/state/sftp/transferWalkRegistry.ts @@ -6,6 +6,7 @@ */ const inFlightRootIds = new Set(); +const settlementWaiters = new Map void>>(); export function registerTransferWalk(rootTaskId: string): void { inFlightRootIds.add(rootTaskId); @@ -13,15 +14,28 @@ export function registerTransferWalk(rootTaskId: string): void { export function unregisterTransferWalk(rootTaskId: string): void { inFlightRootIds.delete(rootTaskId); + const waiters = settlementWaiters.get(rootTaskId); + settlementWaiters.delete(rootTaskId); + for (const resolve of waiters ?? []) resolve(); } export function isTransferWalkInFlight(rootTaskId: string): boolean { return inFlightRootIds.has(rootTaskId); } +/** Wait for the current walk to finish; cancellation must not resume a dying walk. */ +export function waitForTransferWalkSettled(rootTaskId: string): Promise { + if (!inFlightRootIds.has(rootTaskId)) return Promise.resolve(); + return new Promise((resolve) => { + const waiters = settlementWaiters.get(rootTaskId) ?? new Set(); + waiters.add(resolve); + settlementWaiters.set(rootTaskId, waiters); + }); +} + /** Test helper. */ export function resetTransferWalkRegistryForTests(): void { - inFlightRootIds.clear(); + for (const id of inFlightRootIds) unregisterTransferWalk(id); } export function listTransferWalksForTests(): string[] { diff --git a/application/state/sftp/useSftpExternalOperations.ts b/application/state/sftp/useSftpExternalOperations.ts index a8967a6c0..54cd29a2e 100644 --- a/application/state/sftp/useSftpExternalOperations.ts +++ b/application/state/sftp/useSftpExternalOperations.ts @@ -79,6 +79,7 @@ import { localStorageAdapter } from "../../../infrastructure/persistence/localSt import { STORAGE_KEY_SFTP_TRANSFER_CONCURRENCY } from "../../../infrastructure/config/storageKeys"; import { sftpTransferCenterStore } from "../sftpTransferCenterStore"; import { editorTabStore } from "../editorTabStore"; +import { notifyEditorWindowSessionRemap } from "../editorWindowClient"; import { resolveUploadStreamTargetSftpId, } from "../../../domain/sftpDedicatedStreamPolicy"; @@ -200,8 +201,8 @@ export const useSftpExternalOperations = ( return { sftpId, release: () => {} }; }, [ensureRemoteSftpId, getActivePane, getPaneByConnectionId, getPaneByTabId, getSideByTabId, sftpSessionsRef]); - const registerUploadController = useCallback((taskId: string, controller: UploadController) => { - registerExternalUploadController(taskId, controller); + const registerUploadController = useCallback((taskId: string, controller: UploadController, scope: "file" | "batch" = "batch") => { + registerExternalUploadController(taskId, controller, scope); }, []); const unregisterUploadController = useCallback((controller: UploadController) => { @@ -221,7 +222,7 @@ export const useSftpExternalOperations = ( callbacks.onScanningProgress?.(taskId, progress); }, onTaskCreated: (task) => { - registerUploadController(task.id, controller); + registerUploadController(task.id, controller, !task.isDirectory && !task.parentTaskId ? "file" : "batch"); if (task.parentTaskId) { registerUploadController(task.parentTaskId, controller); } @@ -443,6 +444,7 @@ export const useSftpExternalOperations = ( if (liveConnectionId !== connectionId) { editorTabStore.remapSessionId(connectionId, liveConnectionId); + notifyEditorWindowSessionRemap(connectionId, liveConnectionId); } const bridge = netcattyBridge.get(); diff --git a/application/state/sftp/useSftpTransfers.transferPlan.test.tsx b/application/state/sftp/useSftpTransfers.transferPlan.test.tsx index cc39d01ba..9197af356 100644 --- a/application/state/sftp/useSftpTransfers.transferPlan.test.tsx +++ b/application/state/sftp/useSftpTransfers.transferPlan.test.tsx @@ -561,3 +561,105 @@ test("files past the preflight bound stay un-statted after conflict resolution", restore(); } }); + +test("owned cancelled directory settles recoverable children without dropping completed bytes", async () => { + const { writeFile, readFile, mkdtemp, rm } = await import("node:fs/promises"); + const { tmpdir } = await import("node:os"); + const { join } = await import("node:path"); + const { transferRuntime } = await import("./transferRuntime"); + const root = await mkdtemp(join(tmpdir(), "netcatty-owner-cancel-")); + const completedPath = join(root, "completed.txt"); + await writeFile(completedPath, "completed bytes must remain"); + const ownerId = "cancel-owner-large"; + const rootId = "cancel-root-large"; + const base = { + id: rootId, fileName: "folder", sourcePath: "/opt/folder", targetPath: root, + sourceConnectionId: "remote-conn", targetConnectionId: "local-conn", + sourceHostId: "host-1", direction: "download" as const, status: "transferring" as const, + totalBytes: 2201, transferredBytes: 1, speed: 0, startTime: 1, isDirectory: true, + }; + const children = Array.from({ length: 2200 }, (_, index) => ({ + ...base, id: `cancel-child-${index}`, parentTaskId: rootId, isDirectory: false, + sourcePath: `/opt/folder/${index}`, targetPath: join(root, String(index)), + totalBytes: 10, transferredBytes: 4, status: "queued" as const, + })); + sftpTransferCenterStore.publishOwner(ownerId, [base, ...children, { + ...base, id: "cancel-completed", parentTaskId: rootId, isDirectory: false, + sourcePath: "/opt/folder/completed.txt", targetPath: completedPath, status: "completed", + }]); + let active = 0; + let peak = 0; + let clearing = 0; + let clearPeak = 0; + let clearCount = 0; + const cleaned: string[] = []; + const restore = installGlobals({ + cancelTransfer: async (id: string) => { + assert.notEqual(id, "cancel-completed"); + active += 1; + peak = Math.max(peak, active); + await new Promise((resolve) => setImmediate(resolve)); + active -= 1; + return { success: id !== "cancel-child-0" }; + }, + clearPendingTransferCancel: async () => { + clearing += 1; + clearPeak = Math.max(clearPeak, clearing); + await new Promise((resolve) => setImmediate(resolve)); + clearing -= 1; + clearCount += 1; + }, + cleanupTransferArtifacts: async ({ transferId }: { transferId: string }) => { cleaned.push(transferId); }, + }); + let ops: ReturnType | undefined; + let renderer: ReactTestRenderer | undefined; + function Probe() { + ops = useSftpTransfers({ + ownerId, + getActivePane: (side) => makePane(side), + getPaneByConnectionId: () => null, getTabByConnectionId: () => null, + updateTab: () => undefined, refresh: async () => undefined, + clearCacheForConnection: () => undefined, handleSessionError: () => undefined, + sftpSessionsRef: { current: new Map([["remote-conn", "sftp-remote"]]) }, + connectionCacheKeyMapRef: { current: new Map() }, + listLocalFiles: async () => [], listRemoteFiles: async () => [], + }); + return null; + } + let release!: () => void; + const held = new Promise((resolve) => { release = resolve; }); + try { + await act(async () => { renderer = create(React.createElement(Probe)); }); + const walking = transferRuntime.runWalk(rootId, async () => { + await held; + // Old scheduler callbacks settle as cancelled before the outcome is applied. + sftpTransferCenterStore.publishOwner(ownerId, sftpTransferCenterStore.getOwnerTasks(ownerId).map((task) => + task.status === "completed" ? task : { + ...task, status: "cancelled", + checkpointBytes: task.id === "cancel-child-1" ? 6 : task.checkpointBytes, + })); + }); + await act(async () => { + const cancellation = ops!.cancelTransfer(rootId); + await new Promise((resolve) => setImmediate(resolve)); + assert.equal(transferRuntime.isWalkInFlight(rootId), true); + release(); + await Promise.all([walking, cancellation]); + }); + assert.ok(peak <= 32, `bounded cancellation IPC, got ${peak}`); + assert.equal(clearCount, 2201); + assert.ok(clearPeak > 1 && clearPeak <= 32, `bounded concurrent cleanup, got ${clearPeak}`); + assert.equal(sftpTransferCenterStore.getTask(rootId)?.status, "attention"); + assert.equal(sftpTransferCenterStore.getTask("cancel-child-0")?.status, "attention"); + const remaining = sftpTransferCenterStore.getTask("cancel-child-1"); + assert.equal(remaining?.status, "interrupted", "evicted cancelled children remain recoverable"); + assert.equal(remaining?.checkpointBytes, 6, "retain the terminal checkpoint, not the pre-cancel snapshot"); + assert.deepEqual(cleaned, [], "no stage cleanup during partial cancellation recovery"); + assert.equal(await readFile(completedPath, "utf8"), "completed bytes must remain"); + } finally { + release(); + await act(async () => { renderer?.unmount(); }); + restore(); + await rm(root, { recursive: true, force: true }); + } +}); diff --git a/application/state/sftp/useSftpTransfers.ts b/application/state/sftp/useSftpTransfers.ts index 2cc726026..ba47944c1 100644 --- a/application/state/sftp/useSftpTransfers.ts +++ b/application/state/sftp/useSftpTransfers.ts @@ -1418,77 +1418,66 @@ export const useSftpTransfers = ({ releasePausedTransfer(transferId); globalSftpTransferScheduler.cancel(transferId); - // Cancel parent + remove child tasks - const childIdsToCancel = new Set(); - const childrenToCleanup: TransferTask[] = []; - for (const t of transfersRef.current) { - if (t.parentTaskId === transferId && !["completed", "cancelled", "failed"].includes(t.status)) { - childIdsToCancel.add(t.id); - childrenToCleanup.push(t); - } + // Keep child rows and checkpoints until the old walk has stopped. A + // partial backend failure needs a fresh re-walk, including queued jobs + // the scheduler has already rejected and will never run again. + const children = transfersRef.current.filter((task) => + task.parentTaskId === transferId + && !["completed", "cancelled", "failed"].includes(task.status)); + const childIds = new Set([ + ...children.map((task) => task.id), + ...(activeChildIdsRef.current.get(transferId) ?? []), + ]); + for (const id of childIds) { + cancelledTasksRef.current.add(id); + releasePausedTransfer(id); + globalSftpTransferScheduler.cancel(id); } - for (const cid of activeChildIdsRef.current.get(transferId) ?? []) { - if (!childIdsToCancel.has(cid)) { - childIdsToCancel.add(cid); - if (taskToCancel) { - childrenToCleanup.push({ - ...taskToCancel, - id: cid, - parentTaskId: transferId, - }); - } - } - } - for (const cid of childIdsToCancel) { - cancelledTasksRef.current.add(cid); - markTransferCancelled(cid); - globalSftpTransferScheduler.cancel(cid); - } - markTransferCancelledTree(transferId, [...childIdsToCancel]); - // Keep refs in sync immediately so same-turn resolveConflict/resume sees cancel. - const nextTransfers = transfersRef.current - .filter((t) => t.parentTaskId !== transferId) - .map((t) => - t.id === transferId - ? { ...t, status: "cancelled" as TransferStatus, endTime: Date.now(), conflict: undefined } - : t, - ); - setTransfers(nextTransfers); - conflictsRef.current = conflictsRef.current.filter((c) => c.transferId !== transferId && !childIdsToCancel.has(c.transferId)); + markTransferCancelledTree(transferId, [...childIds]); + conflictsRef.current = conflictsRef.current.filter((conflict) => + conflict.transferId !== transferId && !childIds.has(conflict.transferId)); setConflicts(conflictsRef.current); - await cancelBackendTransfers([transferId, ...childIdsToCancel]); - if (taskToCancel) await cleanupTaskArtifacts(taskToCancel); - // Child stages are keyed by per-file transferId — clean each known child. - for (const child of childrenToCleanup) { - try { - await cleanupTaskArtifacts(child); - } catch { - // best-effort - } - } + const ids = new Set([transferId, ...childIds]); + const retainedTasks = (taskToCancel ? [taskToCancel, ...children] : children) + .map((task) => ({ task, ...sftpTransferCenterStore.observeTaskSettlement(task) })); + const failedIds = new Set(await cancelBackendTransfers([...ids])); + await sftpTransferCenterStore.settleCancellation( + transferId, ids, failedIds, retainedTasks, + async () => { + setTransfers(sftpTransferCenterStore.getOwnerTasks(ownerId)); + for (const id of ids) clearCancelledTask(id); + clearTransferCancelledTree(transferId, [...childIds]); + if (failedIds.size > 0) { + // Preserve stages while any writer could not be cancelled. The + // fresh recovery walk reuses ids only after this settlement. + const recoveryIds = [...ids]; + for (let offset = 0; offset < recoveryIds.length; offset += 32) { + await Promise.all(recoveryIds.slice(offset, offset + 32).map(async (id) => { + try { await netcattyBridge.get()?.clearPendingTransferCancel?.(id); } catch { /* best-effort */ } + })); + } + return; + } + if (taskToCancel && sftpTransferCenterStore.getTask(transferId)?.status === "cancelled") { + try { await cleanupTaskArtifacts(taskToCancel); } catch { /* best-effort */ } + } + for (const child of children) { + if (sftpTransferCenterStore.getTask(child.id)?.status === "completed") continue; + try { await cleanupTaskArtifacts(child); } catch { /* best-effort */ } + } + }, + ); }, - [cancelBackendTransfers, cleanupTaskArtifacts, releasePausedTransfer, setTransfers], + [cancelBackendTransfers, clearCancelledTask, cleanupTaskArtifacts, ownerId, releasePausedTransfer, setTransfers], ); - // Soft pause/resume: single TransferRuntime entry (store soft-control + - // dedicated hard reconnect). No panel-local soft-control dual path. + // All controls go through the process-global runtime, including cancellation + // settlement. A panel must never clear cancellation latches on Resume. const resumeTransfer = useCallback(async (transferId: string) => { - // Clear sticky child cancel latches so re-walk can retry same child ids. - clearCancelledTask(transferId); - for (const child of transfersRef.current) { - if (child.parentTaskId === transferId) clearCancelledTask(child.id); - } - for (const childId of activeChildIdsRef.current.get(transferId) ?? []) { - clearCancelledTask(childId); - } - clearTransferCancelledTree( - transferId, - [...(activeChildIdsRef.current.get(transferId) ?? [])], - ); await transferRuntime.resume(transferId); - }, [clearCancelledTask]); + }, []); const prioritizeTransfer = useCallback((transferId: string) => { globalSftpTransferScheduler.prioritize(transferId); @@ -2265,7 +2254,6 @@ export const useSftpTransfers = ({ await transferRuntime.pause(taskId); }, resume: async (taskId: string) => { - // Use resumeTransfer so cancel latches are cleared before soft/hard resume. await resumeTransfer(taskId); }, cancel: cancelTransfer, @@ -2290,7 +2278,7 @@ export const useSftpTransfers = ({ activeTransfersCount, startTransfer, downloadToLocal, - cancelTransfer, + cancelTransfer: transferRuntime.cancel, // Single process-level control surface (TransferRuntime). pauseTransfer: async (transferId: string) => { await transferRuntime.pause(transferId); diff --git a/application/state/sftpTransferCenterStore.test.ts b/application/state/sftpTransferCenterStore.test.ts index b5884b927..730f53cb7 100644 --- a/application/state/sftpTransferCenterStore.test.ts +++ b/application/state/sftpTransferCenterStore.test.ts @@ -1622,6 +1622,46 @@ test("orphan compressed upload resume and cancel keep using the compression job" assert.equal(store.getSnapshot().tasks[0]?.status, "cancelled"); }); +test("compressed upload completing during cancel IPC keeps its completed paint", async (t) => { + const calls: string[] = []; + const previousWindow = Object.getOwnPropertyDescriptor(globalThis, "window"); + t.after(() => { + if (previousWindow) Object.defineProperty(globalThis, "window", previousWindow); + else Reflect.deleteProperty(globalThis, "window"); + }); + + let storeRef: ReturnType | undefined; + Object.defineProperty(globalThis, "window", { + configurable: true, + value: { + netcatty: { + // Inactive-compression responses report success even after the upload + // already finished; the completion repaint must survive the cancel. + cancelCompressedUpload: async (id: string) => { + calls.push(`cancel:${id}`); + storeRef?.ingestBackgroundEvent({ type: "completed", transferId: id }); + return { success: true }; + }, + }, + }, + }); + + const store = createSftpTransferCenterStore(); + storeRef = store; + store.publishOwner("closed-panel", [{ + ...makeTask("compressed-race", "transferring"), + fileName: "done (compressed)", + isDirectory: true, + phase: "compressing", + controlKind: "compressed-upload", + } as TransferTask]); + + await store.cancel("compressed-race"); + + assert.deepEqual(calls, ["cancel:compressed-race"]); + assert.equal(store.getSnapshot().tasks[0]?.status, "completed"); +}); + test("orphan cancel marks process-global cancel so surviving walks stop", async (t) => { const { isTransferCancelledFlag, @@ -1650,6 +1690,10 @@ test("orphan cancel marks process-global cancel so surviving walks stop", async }]); await store.cancel("walk-1"); assert.equal(isTransferCancelledFlag("walk-1"), true); + const resuming = store.resume("walk-1"); + unregisterTransferWalk("walk-1"); + await resuming; + assert.equal(isTransferCancelledFlag("walk-1"), false); assert.equal(store.getSnapshot().tasks.find((row) => row.id === "walk-1")?.status, "cancelled"); }); @@ -2706,6 +2750,39 @@ test("an interrupted task without its old controller can still be cancelled", as assert.equal(store.getSnapshot().tasks[0]?.status, "cancelled"); }); +test("orphan cancel keeps failed work visible and leaves completed children alone", async (t) => { + const previousWindow = Object.getOwnPropertyDescriptor(globalThis, "window"); + t.after(() => { + if (previousWindow) Object.defineProperty(globalThis, "window", previousWindow); + else Reflect.deleteProperty(globalThis, "window"); + }); + const cancelledIds: string[] = []; + Object.defineProperty(globalThis, "window", { + configurable: true, + value: { netcatty: { + cancelTransfer: async (id: string) => { + cancelledIds.push(id); + return { success: id !== "unfinished" }; + }, + } }, + }); + const store = createSftpTransferCenterStore(); + store.publishOwner("closed-panel", [ + { ...makeTask("directory", "paused"), isDirectory: true }, + { ...makeTask("finished", "completed"), parentTaskId: "directory" }, + { ...makeTask("unfinished", "queued"), parentTaskId: "directory" }, + ]); + const finishedBeforeCancel = store.getTask("finished"); + + await store.cancel("directory"); + + assert.deepEqual(cancelledIds, ["directory", "unfinished"]); + assert.equal(store.getTask("finished")?.status, finishedBeforeCancel?.status); + assert.equal(store.getTask("unfinished")?.status, "attention"); + assert.equal(store.getTask("directory")?.status, "attention"); + assert.match(store.getTask("directory")?.error ?? "", /Could not cancel/); +}); + test("concurrent resume clicks adopt a task only once", async () => { let adoptCount = 0; const store = createSftpTransferCenterStore(); @@ -3867,3 +3944,66 @@ for (const outcome of ["rejected", "stream-gone"] as const) { assert.equal(resumeCalls, 1, "obsolete resume must not issue another resume after the held run ends"); }); } + +test("partial cancel failure resets recovered folder children so the re-walk can re-admit them", async (t) => { + const { netcattyBridge } = await import("../../infrastructure/services/netcattyBridge"); + const { resetTransferCancelLatchesForTests, isTransferOrRootCancelled } = + await import("./sftp/transferCancelLatch"); + const { resetTransferWalkRegistryForTests } = await import("./sftp/transferWalkRegistry"); + const originalGet = netcattyBridge.get; + resetTransferCancelLatchesForTests(); + resetTransferWalkRegistryForTests(); + t.after(() => { + netcattyBridge.get = originalGet; + resetTransferCancelLatchesForTests(); + resetTransferWalkRegistryForTests(); + }); + // Only child2 fails to cancel; the folder must stay recoverable via Resume. + let failChild2 = true; + netcattyBridge.get = () => ({ + cancelTransfer: async (id: string) => ({ success: id !== "dir-child-2" || !failChild2 }), + cleanupTransferArtifacts: async () => ({}), + } as unknown as ReturnType); + const store = createSftpTransferCenterStore(); + const child = (id: string): TransferTask => ({ + ...makeTask(id), + parentTaskId: "dir-root", + isDirectory: false, + transferredBytes: 4, + directoryEntryIndex: 0, + directoryEntryIdentity: "a".repeat(64), + }); + store.publishOwner("gone-panel", [ + { ...makeTask("dir-root"), isDirectory: true, transferredBytes: 10, totalBytes: 20, directoryEntryIndex: 0 }, + child("dir-child-1"), + child("dir-child-2"), + ]); + await store.cancel("dir-root"); + + const rows = store.getSnapshot().tasks; + const parent = rows.find((row) => row.id === "dir-root"); + const child1 = rows.find((row) => row.id === "dir-child-1"); + const child2 = rows.find((row) => row.id === "dir-child-2"); + assert.equal(parent?.status, "attention"); + assert.equal(parent?.error, "Could not cancel transfer. Please try again."); + // A failed backend cancellation keeps the folder recoverable: successfully + // cancelled siblings must not settle as terminal "cancelled", or admitTaskRun + // would reject the recovery re-walk and leave the destination incomplete. + assert.equal(child1?.status, "interrupted"); + assert.equal(child1?.error, undefined); + assert.equal(child2?.status, "attention"); + assert.equal(isTransferOrRootCancelled("dir-root"), false, "recovery latch must be cleared"); + + // The directory re-walk (Resume) can re-admit the reset sibling. + const incoming = { ...child("dir-child-1"), directoryEntryIndex: 0, directoryEntryIdentity: "a".repeat(64) }; + assert.equal(store.admitTaskRun(incoming), "ready"); + assert.equal(store.getTask("dir-child-1")?.status, "transferring"); + + // Without any cancellation failure the tree still settles fully cancelled. + failChild2 = false; + await store.cancel("dir-root"); + const afterFullCancel = store.getSnapshot().tasks; + for (const id of ["dir-root", "dir-child-1", "dir-child-2"]) { + assert.equal(afterFullCancel.find((row) => row.id === id)?.status, "cancelled"); + } +}); diff --git a/application/state/sftpTransferCenterStore.ts b/application/state/sftpTransferCenterStore.ts index b3b27fa9e..0ab378a1a 100644 --- a/application/state/sftpTransferCenterStore.ts +++ b/application/state/sftpTransferCenterStore.ts @@ -26,8 +26,10 @@ import { isTransferControlEpochCurrent, settleTransferControlEpochTree, } from "./sftp/transferControlEpoch"; -import { isTransferWalkInFlight } from "./sftp/transferWalkRegistry"; +import { isTransferWalkInFlight, waitForTransferWalkSettled } from "./sftp/transferWalkRegistry"; +import { settleCancelledTransferTree } from "./sftp/transferCancelOutcome"; import { + clearTransferCancelledTree, isTransferOrRootCancelled, markTransferCancelledTree, settleTransferCancelTree, @@ -93,6 +95,12 @@ export type DedicatedTransferResumeHandler = (task: TransferTask) => Promise<{ resetCheckpoint?: boolean; }>; +interface RetainedCancellationTask { + task: TransferTask; + read(): TransferTask | undefined; + dispose(): void; +} + export interface SftpTransferCenterStore { subscribe(listener: Listener): () => void; /** @@ -121,7 +129,10 @@ export interface SftpTransferCenterStore { isResuming(taskId: string): boolean; pause(taskId: string): Promise; resume(taskId: string): Promise; + /** Wait for backend cancellation requests; live walks settle separately. */ cancel(taskId: string): Promise; + /** Publish one cancellation outcome after the old execution has settled. */ + settleCancellation(taskId: string, taskIds: ReadonlySet, failedIds: ReadonlySet, retainedTasks: readonly RetainedCancellationTask[], onSettled: () => Promise): Promise; retry(taskId: string): Promise; prioritize(taskId: string): Promise; dismiss(taskId: string): void; @@ -464,6 +475,8 @@ export function createSftpTransferCenterStore(persistence?: StorePersistence): S let persistenceDirty = false; let persistenceTimer: ReturnType | null = null; const resumeInvocations = new Map>(); + const cancelInvocations = new Map>(); + const cancelSettlements = new Map>(); // Ephemeral control intent: never serialize this or change bridge lifecycle epochs. const resumeRequests = new Map(); const resumeListeners = new Set(); @@ -818,8 +831,7 @@ export function createSftpTransferCenterStore(persistence?: StorePersistence): S // Compressed soft-control is process-global: do not gate on a live panel owner. const liveCompressedJob = task?.controlKind === "compressed-upload" && task.reconnectRequired !== true - && task.status !== "interrupted" - && task.status !== "attention"; + && task.status !== "interrupted"; if (liveCompressedJob && (action === "pause" || action === "resume" || action === "cancel")) { const bridge = netcattyBridge.get(); if (action === "pause") { @@ -879,8 +891,17 @@ export function createSftpTransferCenterStore(persistence?: StorePersistence): S if (!result.success && result.reason) notify.warning(result.reason, "SFTP"); return; } - const result = await (bridge?.cancelCompressedUpload?.(taskId) - ?? { success: false }); + const result = await Promise.resolve(bridge?.cancelCompressedUpload?.(taskId) + ?? { success: false }).catch(() => ({ success: false })); + // The upload may finish while this cancel is still awaiting IPC: its + // completion event has already repainted the row terminal. The new + // inactive-compression response still reports success, so recheck the + // latest row before applying the cancellation result — otherwise Cancel + // all misreports a successfully completed upload as cancelled. + const latestAfterCancelIpc = tasks.find((candidate) => candidate.id === taskId); + if (latestAfterCancelIpc && ["completed", "failed", "cancelled"].includes(latestAfterCancelIpc.status)) { + return; + } if (!result.success) { const reason = "Could not cancel the compressed upload."; tasks = tasks.map((candidate) => candidate.id === taskId ? { @@ -889,6 +910,13 @@ export function createSftpTransferCenterStore(persistence?: StorePersistence): S error: reason, speed: 0, } : candidate); + if (isTransferOrRootCancelled(taskId)) { + // The row is kept in attention for recovery instead of settling as + // cancelled. Drop the pre-installed cancellation latch now, or a + // later Resume is rejected outright by admitTaskRun's cancelled-root + // check (compressed uploads have no runWalk settlement to clear it). + clearTransferCancelledTree(taskId); + } emit(); notify.warning(reason, "SFTP"); return; @@ -1301,9 +1329,9 @@ export function createSftpTransferCenterStore(persistence?: StorePersistence): S emit(); } } - if (!controller && action === "cancel" && task && ["paused", "interrupted", "attention", "pending", "queued", "transferring", "pausing"].includes(task.status)) { + if (!controller && action === "cancel" && task && !["completed", "cancelled"].includes(task.status)) { const childIds = tasks - .filter((candidate) => candidate.parentTaskId === taskId) + .filter((candidate) => candidate.parentTaskId === taskId && !["completed", "cancelled", "failed"].includes(candidate.status)) .map((candidate) => candidate.id); // Stop surviving processTransfer walks immediately (panel-local // cancelledTasksRef is gone after unmount). @@ -1315,33 +1343,43 @@ export function createSftpTransferCenterStore(persistence?: StorePersistence): S } catch { // best-effort } - try { - await netcattyBridge.get()?.cancelTransfer?.(taskId); - for (const childId of childIds) { - try { await netcattyBridge.get()?.cancelTransfer?.(childId); } catch { /* best-effort */ } + const failedIds = new Set(); + const cancelIds = [taskId, ...childIds]; + const cancelledIdSet = new Set(cancelIds); + const retainedTasks = tasks.filter((candidate) => cancelledIdSet.has(candidate.id)) + .map((task) => ({ task, ...store.observeTaskSettlement(task) })); + for (let offset = 0; offset < cancelIds.length; offset += 32) { + await Promise.all(cancelIds.slice(offset, offset + 32).map(async (id) => { + try { + const result: unknown = await netcattyBridge.get()?.cancelTransfer?.(id); + if (result && typeof result === "object" && "success" in result && result.success === false) { + failedIds.add(id); + } + } catch { + failedIds.add(id); + } + })); + } + await store.settleCancellation(taskId, cancelledIdSet, failedIds, retainedTasks, async () => { + clearTransferCancelledTree(taskId, childIds); + if (failedIds.size > 0) { + const recoveryIds = cancelIds; + for (let offset = 0; offset < recoveryIds.length; offset += 32) { + await Promise.all(recoveryIds.slice(offset, offset + 32).map(async (id) => { + try { await netcattyBridge.get()?.clearPendingTransferCancel?.(id); } catch { /* best-effort */ } + })); + } + } else if (tasks.find((candidate) => candidate.id === taskId)?.status === "cancelled") { + try { + await netcattyBridge.get()?.cleanupTransferArtifacts?.({ + transferId: taskId, + sourcePath: task.sourcePath, + targetPath: task.targetPath, + stagedTargetPath: task.stagedTargetPath, + }); + } catch { /* best-effort */ } } - } catch { - // Best-effort backend cancel when the owning panel is gone / no window. - } - try { - await netcattyBridge.get()?.cleanupTransferArtifacts?.({ - transferId: taskId, - sourcePath: task.sourcePath, - targetPath: task.targetPath, - stagedTargetPath: task.stagedTargetPath, - }); - } catch { - // best-effort temp/.part cleanup - } - const cancelIds = new Set([taskId, ...childIds]); - tasks = tasks.map((candidate) => cancelIds.has(candidate.id) ? { - ...candidate, - status: "cancelled", - error: undefined, - endTime: Date.now(), - speed: 0, - conflict: undefined, - } : candidate); + }); emit(); return; } @@ -1794,10 +1832,28 @@ export function createSftpTransferCenterStore(persistence?: StorePersistence): S && ["paused", "interrupted", "attention", "pending", "queued"].includes(task.status); }, pause(taskId) { + if (cancelInvocations.has(taskId) || isTransferOrRootCancelled(taskId)) return Promise.resolve(); clearResumeRequest(taskId); return invoke(taskId, "pause"); }, async resume(taskId) { + // A cancelled scheduler job cannot be soft-resumed. Wait for the cancel + // outcome and fresh re-walk rather than unlatching the old walk. + const cancelling = cancelInvocations.get(taskId); + if (cancelling) { + await cancelling; + const task = tasks.find((candidate) => candidate.id === taskId); + if (!task || ["completed", "cancelled", "failed"].includes(task.status)) return; + } + // Failed cancellation reports immediately, but its old walk must still + // settle before Resume can start a fresh scan. A retried Cancel may have + // replaced the awaited outcome, so drain the latest settlement as well. + while (cancelSettlements.has(taskId)) await cancelSettlements.get(taskId); + const afterCancellation = tasks.find((candidate) => candidate.id === taskId); + if (!afterCancellation || ["completed", "cancelled"].includes(afterCancellation.status)) return; + // Cancel all latches every selected tree before its bounded IPC batches. + // A row-level Resume must not revive a tree whose batch has not run yet. + if (isTransferOrRootCancelled(taskId)) return; const request = Symbol("resume"); resumeRequests.set(taskId, request); notifyResume(); @@ -1913,9 +1969,52 @@ export function createSftpTransferCenterStore(persistence?: StorePersistence): S if (resumeRequests.get(taskId) === request) clearResumeRequest(taskId); } }, + async settleCancellation(taskId, taskIds, failedIds, retainedTasks, onSettled) { + const walkStillRunning = isTransferWalkInFlight(taskId); + const settlement = (async () => { + await waitForTransferWalkSettled(taskId); + // A later cancel attempt owns the outcome, even if this older attempt + // failed. Never repaint a successful retry as a failed cancellation. + if (cancelSettlements.get(taskId) !== settlement) return; + tasks = settleCancelledTransferTree( + tasks, taskId, taskIds, failedIds, + retainedTasks.map((retained) => retained.read() ?? retained.task), + ); + await onSettled(); + emit(); + })().finally(() => { + for (const retained of retainedTasks) retained.dispose(); + if (cancelSettlements.get(taskId) === settlement) cancelSettlements.delete(taskId); + }); + cancelSettlements.set(taskId, settlement); + if (walkStillRunning) { + if (failedIds.size > 0) { + tasks = tasks.map((task) => task.id === taskId + && !["completed", "failed"].includes(task.status) + ? { ...task, status: "attention", error: "Could not cancel transfer. Please try again.", speed: 0 } + : task); + emit(); + } + // Backend requests are done: let Cancel all dispatch its next batch. + // Resume, final status, and cleanup still wait on this settlement. + void settlement.catch(() => {}); + return; + } + await settlement; + }, cancel(taskId) { clearResumeRequest(taskId); - return invoke(taskId, "cancel"); + const existing = cancelInvocations.get(taskId); + if (existing) return existing; + bumpTransferControlEpoch(taskId); + for (const task of tasks) { + if (task.parentTaskId === taskId) bumpTransferControlEpoch(task.id); + } + const running = invoke(taskId, "cancel").finally(() => { + if (cancelInvocations.get(taskId) === running) cancelInvocations.delete(taskId); + }); + cancelInvocations.set(taskId, running); + return running; }, async retry(taskId) { const ownerId = findOwner(taskId); diff --git a/application/state/terminalAutoReconnect.test.ts b/application/state/terminalAutoReconnect.test.ts index 68d1d7ed3..6ace1ede3 100644 --- a/application/state/terminalAutoReconnect.test.ts +++ b/application/state/terminalAutoReconnect.test.ts @@ -58,6 +58,52 @@ test("normal shell exits do not auto reconnect", () => { ); }); +test("administrative idle, timeout, and exit-signal disconnects do not auto reconnect", () => { + for (const evt of [ + { reason: "timeout" as const }, + { reason: "timeout" as const, exitCode: 0 }, + { reason: "timeout" as const, error: "Connection timeout to example.com" }, + { reason: "signaled" as const, exitSignal: "SIGHUP" }, + { reason: "signaled" as const, exitSignal: "SIGTERM" }, + { reason: "signaled" as const }, + // A path that still says "closed" but plumbed the exit-signal is the same kick. + { reason: "closed" as const, exitSignal: "SIGUSR2" }, + { reason: "closed" as const, exitSignal: " SIGHUP " }, + ]) { + assert.equal( + shouldAutoReconnectAfterExit({ + evt, + host: sshHost, + terminalSettings: { sshAutoReconnectEnabled: true }, + hasEverConnected: true, + }), + false, + ); + } +}); + +test("transport drops still auto reconnect", () => { + for (const evt of [ + { reason: "closed" as const }, + { reason: "error" as const, error: "connection reset" }, + { reason: "closed" as const, remoteClosedWithoutExitStatus: true }, + { reason: "closed" as const, exitSignal: "" }, + { reason: "closed" as const, exitSignal: " " }, + { reason: "error" as const, error: "read ECONNRESET", exitSignal: "" }, + { reason: "error" as const, error: "read ECONNRESET", exitSignal: "SIGHUP" }, + ]) { + assert.equal( + shouldAutoReconnectAfterExit({ + evt, + host: sshHost, + terminalSettings: { sshAutoReconnectEnabled: true }, + hasEverConnected: true, + }), + true, + ); + } +}); + test("auto reconnect ignores protocols that already have different lifecycle semantics", () => { const variants = [ { protocol: "local" as const, hostname: "localhost" }, diff --git a/application/state/terminalAutoReconnect.ts b/application/state/terminalAutoReconnect.ts index b33db0360..360673dde 100644 --- a/application/state/terminalAutoReconnect.ts +++ b/application/state/terminalAutoReconnect.ts @@ -40,7 +40,16 @@ export function shouldAutoReconnectAfterExit({ if (!hasEverConnected) return false; if (!isTerminalAutoReconnectEnabled(terminalSettings)) return false; if (!isAutoReconnectableSshHost(host)) return false; - return evt.reason !== "exited"; + // "exited" is a shell exit. "timeout" is an administrative idle/TMOUT logout + // (or a connect timeout). "signaled" — or a plumbed exit-signal on an + // otherwise "closed" end — is a server kick (idle policy, SIGHUP, SIGTERM). + // Reconnecting would fight that policy. Transport "error" and bare "closed" + // still retry. + if (evt.reason === "error") return true; + if (evt.reason === "exited" || evt.reason === "timeout" || evt.reason === "signaled") { + return false; + } + return typeof evt.exitSignal !== "string" || evt.exitSignal.trim().length === 0; } export function shouldContinueAutoReconnectAfterFailure({ diff --git a/application/state/useAIState.ts b/application/state/useAIState.ts index 91fed6260..46e954b28 100644 --- a/application/state/useAIState.ts +++ b/application/state/useAIState.ts @@ -89,6 +89,11 @@ import { retargetWorkspaceActiveChatAfterMemberLoss, seedWorkspaceAIActiveSessionFromMembers, } from '../../domain/workspaceAiScopeHandoff'; +import { + buildUndoLastTurnBranch, + type UndoLastTurnRestoredDraft, +} from '../../domain/aiUndoLastTurn'; + function providerPatchIsNoop( current: ProviderConfig, @@ -794,6 +799,32 @@ export function useAIState() { return session; }, [defaultAgentId, persistSessions, setActiveSessionId]); + // Non-destructive "undo last turn": branch the session at the boundary + // before the latest user message and keep the original in history intact. + // Returns the new branch id plus the undone user message for the composer. + const undoLastTurnInSession = useCallback(async (sessionId: string): Promise<{ + sessionId: string; + restored: UndoLastTurnRestoredDraft; + } | null> => { + const nextId = `ai_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`; + const source = sessionsRef.current.find(s => s.id === sessionId); + if (!source) return null; + const result = buildUndoLastTurnBranch(source, { newId: nextId, now: Date.now() }); + if (!result) return null; + + const branched = result.session; + setSessionsRaw(prev => { + // Keep both recently used chats within the existing history limit. + const next = [branched, { ...source, updatedAt: branched.updatedAt }, ...prev.filter(session => session.id !== source.id)]; + setLatestAISessionsSnapshot(next); + persistSessions(next); + return next; + }); + const scopeKey = `${branched.scope.type}:${branched.scope.targetId ?? ''}`; + setActiveSessionId(scopeKey, branched.id); + return { sessionId: branched.id, restored: result.restored }; + }, [persistSessions, setActiveSessionId]); + const deleteSession = useCallback((sessionId: string, scopeKey?: string) => { cleanupDeletedAIChatSessions([sessionId]); if (persistTimerRef.current) { @@ -1372,6 +1403,7 @@ export function useAIState() { addDraftFiles, removeDraftFile, createSession, + undoLastTurnInSession, deleteSession, deleteSessionsByTarget, updateSessionTitle, @@ -1433,6 +1465,7 @@ export function useAIState() { addDraftFiles, removeDraftFile, createSession, + undoLastTurnInSession, deleteSession, deleteSessionsByTarget, updateSessionTitle, diff --git a/application/state/useAIState.undoLastTurn.test.ts b/application/state/useAIState.undoLastTurn.test.ts new file mode 100644 index 000000000..a31f3af79 --- /dev/null +++ b/application/state/useAIState.undoLastTurn.test.ts @@ -0,0 +1,194 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import React, { act } from 'react'; +import { createRoot } from 'react-dom/client'; +import { JSDOM } from 'jsdom'; + +import type { AISession, UploadedFile } from '../../infrastructure/ai/types.ts'; +import { getAgentRuntime } from '../../infrastructure/ai/harness/globalAgentRuntime.ts'; +import { useAIState } from './useAIState.ts'; +import { useAISessionsStore } from './aiSessionsStore.ts'; +import { setLatestAISessionsSnapshot } from './aiStateSnapshots.ts'; + +const SCOPE_KEY = 'terminal:terminal-a'; + +const SOURCE_SESSION: AISession = { + id: 'chat-source', + title: 'Undo source', + agentId: 'catty', + scope: { type: 'terminal', targetId: 'terminal-a' }, + messages: [ + { id: 'user-1', role: 'user', content: 'first prompt', timestamp: 1 }, + { id: 'assistant-1', role: 'assistant', content: 'first answer', timestamp: 2 }, + { + id: 'user-2', + role: 'user', + content: 'bad last prompt', + timestamp: 3, + attachments: [ + { base64Data: 'QUJD', mediaType: 'text/plain', filename: 'note.txt', lineCount: 2 }, + ], + }, + { id: 'assistant-2', role: 'assistant', content: 'bad answer', timestamp: 4 }, + ], + createdAt: 1, + updatedAt: 4, +}; + +type HookCapture = { + ai: ReturnType | null; + sessions: AISession[]; + activeSessionIdMap: Record; +}; + +function UndoHarness({ capture }: { capture: HookCapture }) { + capture.ai = useAIState(); + const store = useAISessionsStore(); + capture.sessions = store.sessions as AISession[]; + capture.activeSessionIdMap = store.activeSessionIdMap as Record; + return null; +} + +async function setupAiState(sessions: AISession[]) { + const dom = new JSDOM('', { + url: 'http://localhost', + }); + Object.defineProperty(globalThis, 'window', { configurable: true, value: dom.window }); + Object.defineProperty(globalThis, 'document', { configurable: true, value: dom.window.document }); + Object.defineProperty(globalThis, 'localStorage', { configurable: true, value: dom.window.localStorage }); + Object.defineProperty(globalThis, 'CustomEvent', { configurable: true, value: dom.window.CustomEvent }); + Object.defineProperty(globalThis, 'Event', { configurable: true, value: dom.window.Event }); + Object.defineProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT', { configurable: true, value: true }); + + setLatestAISessionsSnapshot(sessions); + dom.window.localStorage.setItem('netcatty_ai_sessions_v1', JSON.stringify(sessions)); + + const container = document.createElement('div'); + document.body.appendChild(container); + const root = createRoot(container); + const capture: HookCapture = { ai: null, sessions: [], activeSessionIdMap: {} }; + await act(async () => { + root.render(React.createElement(UndoHarness, { capture })); + }); + return { dom, container, root, capture }; +} + +test('undoLastTurnInSession branches and restores the last user turn', async () => { + const { dom, container, root, capture } = await setupAiState([SOURCE_SESSION]); + assert.ok(capture.ai); + + let result: Awaited> | null = null; + async function runUndo() { + return capture.ai!.undoLastTurnInSession('chat-source'); + } + + await act(async () => { + result = await runUndo(); + }); + + assert.ok(result); + assert.notEqual(result!.sessionId, 'chat-source'); + + const sessions = capture.sessions; + const branch = sessions.find((session) => session.id === result!.sessionId); + assert.ok(branch); + // Branch keeps the conversation up to the boundary before the last turn. + assert.deepEqual( + branch!.messages.map((message) => message.content), + ['first prompt', 'first answer'], + ); + // Original session stays intact (non-destructive). + const original = sessions.find((session) => session.id === 'chat-source'); + assert.equal(original?.messages.length, 4); + // Branch becomes the active session for the scope. + assert.equal(capture.activeSessionIdMap[SCOPE_KEY], result!.sessionId); + + const attachments = result!.restored.attachments as UploadedFile[]; + assert.equal(result!.restored.text, 'bad last prompt'); + assert.equal(attachments.length, 1); + assert.equal(attachments[0].filename, 'note.txt'); + assert.equal(attachments[0].dataUrl, 'data:text/plain;base64,QUJD'); + + await act(async () => root.unmount()); + container.remove(); + dom.window.close(); +}); + +test('undoLastTurnInSession returns null when there is nothing to undo', async () => { + const emptySession: AISession = { + ...SOURCE_SESSION, + id: 'chat-empty', + messages: [], + }; + const { dom, container, root, capture } = await setupAiState([emptySession]); + assert.ok(capture.ai); + + let result: unknown; + await act(async () => { + result = await capture.ai!.undoLastTurnInSession('chat-empty'); + }); + assert.equal(result, null); + + await act(async () => root.unmount()); + container.remove(); + dom.window.close(); +}); + +test('undo copies chat only, preserving original outputs and job ownership', async () => { + const runtime = getAgentRuntime(); + const outputStore = runtime.getToolOutputStore('chat-source'); + const handle = outputStore.store({ + chatSessionId: 'chat-source', capabilityId: 'terminal.exec', content: 'original saved output', + }); + const stateStore = runtime.getSessionStateStore(); + stateStore.updateFromToolResult('chat-source', 'terminal_start', + { sessionId: 'terminal-a', command: 'long-running-command' }, + JSON.stringify({ jobId: 'original-job', status: 'running', nextOffset: 7 }), false); + const source = structuredClone(SOURCE_SESSION); + source.messages[1].content = `Preview with handleId=${handle.id}`; + const { dom, container, root, capture } = await setupAiState([source]); + const original = JSON.stringify(source); + let result: { sessionId: string } | null = null; + await act(async () => { result = await capture.ai!.undoLastTurnInSession(source.id); }); + assert.ok(result); + assert.equal(outputStore.read({ handleId: handle.id }, result!.sessionId), null); + assert.equal(outputStore.read({ handleId: handle.id }, source.id), 'original saved output'); + assert.deepEqual(stateStore.get(result!.sessionId).activeJobs, {}); + assert.ok(stateStore.get(source.id).activeJobs['original-job']); + assert.equal(JSON.stringify({ ...capture.sessions.find(item => item.id === source.id), updatedAt: source.updatedAt }), original); + assert.ok(capture.sessions.find(item => item.id === result!.sessionId)!.messages[1].content.includes('Preview')); + stateStore.clear(source.id); + outputStore.prune(source.id); + await act(async () => root.unmount()); + container.remove(); dom.window.close(); +}); + +for (const newerCount of [49, 50]) { + test(`undo retains an old source and its branch after reload with ${newerCount} newer chats`, async () => { + const newer = Array.from({ length: newerCount }, (_, index) => ({ + ...structuredClone(SOURCE_SESSION), id: `history-${index}`, updatedAt: 100 + index, + })); + const { dom, container, root, capture } = await setupAiState([...newer, SOURCE_SESSION]); + let branchId: string | undefined; + await act(async () => { + branchId = (await capture.ai!.undoLastTurnInSession(SOURCE_SESSION.id))?.sessionId; + }); + assert.ok(branchId); + const saved: AISession[] = JSON.parse(dom.window.localStorage.getItem('netcatty_ai_sessions_v1')!); + assert.equal(saved.length, 50); + const original = saved.find(item => item.id === SOURCE_SESSION.id); + assert.ok(original, 'the original must survive updatedAt-based history pruning'); + assert.deepEqual({ ...original, updatedAt: SOURCE_SESSION.updatedAt }, SOURCE_SESSION); + assert.deepEqual(saved.find(item => item.id === branchId)?.messages, SOURCE_SESSION.messages.slice(0, 2)); + assert.ok(original.updatedAt >= newer.at(-1)!.updatedAt); + await act(async () => root.unmount()); + container.remove(); dom.window.close(); + + // Remount the actual hook from the persisted records, as on app reload. + const reloaded = await setupAiState(saved); + assert.deepEqual(reloaded.capture.sessions.find(item => item.id === SOURCE_SESSION.id)?.messages, SOURCE_SESSION.messages); + assert.deepEqual(reloaded.capture.sessions.find(item => item.id === branchId)?.messages, SOURCE_SESSION.messages.slice(0, 2)); + await act(async () => reloaded.root.unmount()); + reloaded.container.remove(); reloaded.dom.window.close(); + }); +} diff --git a/application/state/useEditorWindow.ts b/application/state/useEditorWindow.ts new file mode 100644 index 000000000..ef73730ed --- /dev/null +++ b/application/state/useEditorWindow.ts @@ -0,0 +1,74 @@ +import { useCallback, useEffect, useState } from "react"; +import { netcattyBridge } from "../../infrastructure/services/netcattyBridge"; +import { editorTabStore } from "./editorTabStore"; +import type { EditorWindowTabSnapshot } from "./editorWindowTypes"; +import type { EditorTabId } from "./editorTabStore"; + +export function useEditorWindowRuntime() { + const [activeTabId, setActiveTabId] = useState( + () => editorTabStore.getTabs()[editorTabStore.getTabs().length - 1]?.id ?? null, + ); + + const closeWindow = useCallback(async () => { + await netcattyBridge.get()?.windowClose?.(); + }, []); + + const setWindowTitle = useCallback(async (title: string) => { + await netcattyBridge.get()?.setWindowTitle?.(title); + }, []); + + useEffect(() => { + const bridge = netcattyBridge.get(); + if (!bridge?.onEditorWindowOpenTab) return () => {}; + return bridge.onEditorWindowOpenTab((snapshot: EditorWindowTabSnapshot) => { + const existing = editorTabStore.getTab(snapshot.editorId) + ?? editorTabStore.getTabs().find((tab) => ( + tab.sessionId === snapshot.sessionId && tab.remotePath === snapshot.remotePath + )); + if (existing) { + if (existing.sessionId !== snapshot.sessionId) { + editorTabStore.remapSessionId(existing.sessionId, snapshot.sessionId); + } + if (existing.sftpTabId !== snapshot.sftpTabId || existing.hostLabel !== snapshot.hostLabel) { + editorTabStore.upsertFromSnapshot({ + ...snapshot, + content: existing.content, + baselineContent: existing.baselineContent, + viewState: existing.viewState, + wordWrap: existing.wordWrap, + languageId: existing.languageId, + }, "tab"); + } + setActiveTabId(existing.id); + return; + } + const id = editorTabStore.upsertFromSnapshot(snapshot, "tab"); + setActiveTabId(id); + }); + }, []); + + useEffect(() => { + const bridge = netcattyBridge.get(); + if (!bridge?.onEditorWindowActivateTab) return () => {}; + return bridge.onEditorWindowActivateTab((payload) => { + if (payload?.editorId && editorTabStore.getTab(payload.editorId)) { + setActiveTabId(payload.editorId); + } + }); + }, []); + + useEffect(() => { + const bridge = netcattyBridge.get(); + if (!bridge?.onEditorWindowRemapSession) return () => {}; + return bridge.onEditorWindowRemapSession((payload) => { + editorTabStore.remapSessionId(payload.fromSessionId, payload.toSessionId); + }); + }, []); + + return { + activeTabId, + setActiveTabId, + closeWindow, + setWindowTitle, + }; +} diff --git a/application/state/useExternalMcpToggleState.test.ts b/application/state/useExternalMcpToggleState.test.ts index 6b0ed7537..d9342de58 100644 --- a/application/state/useExternalMcpToggleState.test.ts +++ b/application/state/useExternalMcpToggleState.test.ts @@ -1,5 +1,8 @@ import assert from 'node:assert/strict'; import { describe, it } from 'node:test'; +import { createSessionIdleManager } from '../../electron/bridges/mcpServerBridge/sessionIdleManager.cjs'; +import { STORAGE_KEY_AI_SESSION_IDLE_TIMEOUT_MINUTES } from '../../infrastructure/config/storageKeys'; +import { localStorageAdapter } from '../../infrastructure/persistence/localStorageAdapter'; import { EXTERNAL_MCP_RUNTIME_STATUS_POLL_MS, bumpExternalMcpEnableGenerationForTests, @@ -18,6 +21,7 @@ import { resetExternalMcpStartupSyncOnceForTests, shouldStartExternalMcpOnStartup, shouldWaitForExternalMcpStartupReady, + syncExternalMcpConfig, syncExternalMcpStartupState, syncExternalMcpStartupStateOnce, waitForExternalMcpStartupReady, @@ -63,10 +67,44 @@ describe('useExternalMcpToggleState helpers', () => { assert.equal(normalizeExternalMcpIdleTimeoutMinutes(null), 10); assert.equal(normalizeExternalMcpIdleTimeoutMinutes(0), 1); assert.equal(normalizeExternalMcpIdleTimeoutMinutes(99999), 24 * 60); - assert.equal(normalizeSessionIdleTimeoutMinutes(null), 30); + assert.equal(normalizeSessionIdleTimeoutMinutes(null), 8 * 60); assert.equal(normalizeSessionIdleTimeoutMinutes(0), 1); }); + for (const savedMinutes of [null, 30]) { + it(`syncs the session idle timeout without replacing saved value ${savedMinutes}`, async () => { + const restore = installMemoryLocalStorage(); + const manager = createSessionIdleManager(); + try { + assert.equal(manager.getTimeoutMinutes(), 8 * 60, 'main-process default'); + if (savedMinutes !== null) { + localStorageAdapter.writeNumber(STORAGE_KEY_AI_SESSION_IDLE_TIMEOUT_MINUTES, savedMinutes); + } + const expected = savedMinutes ?? manager.getTimeoutMinutes(); + const received: number[] = []; + const bridge = { + externalMcpSetConfig: (config: { sessionIdleTimeoutMinutes: number }) => { + received.push(config.sessionIdleTimeoutMinutes); + manager.setTimeoutMinutes(config.sessionIdleTimeoutMinutes); + }, + }; + await syncExternalMcpStartupState(bridge); + assert.equal(manager.getTimeoutMinutes(), expected, 'startup keeps the intended timeout'); + syncExternalMcpConfig(bridge); + assert.deepEqual(received, [expected, expected]); + assert.equal(manager.getTimeoutMinutes(), expected, 'config sync keeps the intended timeout'); + assert.equal( + localStorageAdapter.readNumber(STORAGE_KEY_AI_SESSION_IDLE_TIMEOUT_MINUTES), + savedMinutes, + 'sync does not migrate or overwrite the stored preference', + ); + } finally { + manager.clearAll(); + restore(); + } + }); + } + it('only starts on launch for persistent+enabled', () => { assert.equal(shouldStartExternalMcpOnStartup({ enabled: true, mode: 'persistent' }), true); assert.equal(shouldStartExternalMcpOnStartup({ enabled: true, mode: 'temporary' }), false); diff --git a/application/state/useExternalMcpToggleState.ts b/application/state/useExternalMcpToggleState.ts index 88de51fcf..d218f4a7d 100644 --- a/application/state/useExternalMcpToggleState.ts +++ b/application/state/useExternalMcpToggleState.ts @@ -18,7 +18,7 @@ export type ExternalMcpMode = 'temporary' | 'persistent'; const DEFAULT_EXTERNAL_MCP_IDLE_TIMEOUT_MINUTES = 10; const MIN_EXTERNAL_MCP_IDLE_TIMEOUT_MINUTES = 1; const MAX_EXTERNAL_MCP_IDLE_TIMEOUT_MINUTES = 24 * 60; -const DEFAULT_SESSION_IDLE_TIMEOUT_MINUTES = 30; +const DEFAULT_SESSION_IDLE_TIMEOUT_MINUTES = 480; /** Keep top-bar / settings switch state aligned with runtime auto-disable. */ export const EXTERNAL_MCP_RUNTIME_STATUS_POLL_MS = 3000; diff --git a/application/state/useGlobalSftpTransferActions.test.ts b/application/state/useGlobalSftpTransferActions.test.ts new file mode 100644 index 000000000..ba82b6750 --- /dev/null +++ b/application/state/useGlobalSftpTransferActions.test.ts @@ -0,0 +1,147 @@ +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 type { TransferTask } from "../../domain/models"; +import { netcattyBridge } from "../../infrastructure/services/netcattyBridge"; +import { sftpTransferCenterStore as store } from "./sftpTransferCenterStore"; +import { useGlobalSftpTransferActions } from "./useGlobalSftpTransferActions"; +import { isTransferOrRootCancelled, resetTransferCancelLatchesForTests } from "./sftp/transferCancelLatch"; +import { resetTransferRuntimeRunsForTests, transferRuntime } from "./sftp/transferRuntime"; +import { resetTransferWalkRegistryForTests } from "./sftp/transferWalkRegistry"; + +test("Cancel all releases a skipped late failure so one Retry executes while the old walk unwinds", async (t) => { + const previousAct = (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT; + Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true }); + const originalGet = netcattyBridge.get; + const tasks: TransferTask[] = Array.from({ length: 33 }, (_, index) => ({ + id: `late-failure-${index}`, fileName: `${index}.bin`, + sourcePath: `/source/${index}`, targetPath: `/target/${index}`, + sourceConnectionId: "local", targetConnectionId: "remote", targetHostId: "host", + direction: "upload", status: "transferring", totalBytes: 8, transferredBytes: 0, + speed: 0, startTime: Date.now() + index, isDirectory: false, resumable: true, + })); + const later = tasks[32]; + let release!: () => void; + const held = new Promise((resolve) => { release = resolve; }); + let renderer: ReactTestRenderer | undefined; + let actions: ReturnType | undefined; + function Probe() { + actions = useGlobalSftpTransferActions(store.getSnapshot().tasks); + return null; + } + store.publishOwner("closed-panel", tasks); + const walking = transferRuntime.runWalk(later.id, async () => { await held; }); + t.after(async () => { + release(); + await walking; + await act(async () => { renderer?.unmount(); }); + store.setDedicatedResumeHandler(null); + for (const task of tasks) store.dismiss(task.id); + netcattyBridge.get = originalGet; + resetTransferCancelLatchesForTests(); + resetTransferWalkRegistryForTests(); + resetTransferRuntimeRunsForTests(); + Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: previousAct }); + }); + const cancellations: string[] = []; + netcattyBridge.get = () => ({ + cancelTransfer: async (id: string) => { + cancellations.push(id); + if (id === tasks[0].id) store.patchTask(later.id, { status: "failed", error: "Connection lost" }); + return { success: true }; + }, + resumeTransfer: async () => ({ success: false, reason: "Transfer is no longer active" }), + clearPendingTransferCancel: async () => {}, cleanupTransferArtifacts: async () => {}, + } as unknown as ReturnType); + let retries = 0; + store.setDedicatedResumeHandler(async () => { retries += 1; return { success: true }; }); + await act(async () => { renderer = create(React.createElement(Probe)); }); + await act(async () => { await actions!.cancelAll(); }); + assert.equal(cancellations.length, 32, "the late failure must be skipped by the second batch"); + assert.equal(store.getTask(later.id)?.status, "failed"); + assert.equal(store.getTask(later.id)?.error, "Connection lost"); + assert.equal(transferRuntime.isWalkInFlight(later.id), true); + + await store.retry(later.id); + + assert.equal(retries, 1, "one Retry must reach recovery instead of being swallowed by the pre-latch"); + assert.equal(store.getTask(later.id)?.status, "completed"); + assert.equal(isTransferOrRootCancelled(later.id), false); +}); + +for (const firstCancelSucceeds of [true, false]) { + test(`Cancel all dispatches later batches before walk settlement: success=${firstCancelSucceeds}`, async (t) => { + const previousAct = (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT; + Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true }); + const originalGet = netcattyBridge.get; + const tasks: TransferTask[] = Array.from({ length: 33 }, (_, index) => ({ + id: `dispatch-${firstCancelSucceeds}-${index}`, fileName: `${index}.bin`, + sourcePath: `/source/${index}`, targetPath: `/target/${index}`, + sourceConnectionId: "local", targetConnectionId: "remote", targetHostId: "host", + direction: "upload", status: "transferring", totalBytes: 8, transferredBytes: 0, + speed: 0, startTime: Date.now() + index, isDirectory: false, resumable: true, + })); + let release!: () => void; + const held = new Promise((resolve) => { release = resolve; }); + let renderer: ReactTestRenderer | undefined; + let actions: ReturnType | undefined; + let cancelling: Promise | undefined; + function Probe() { + actions = useGlobalSftpTransferActions(store.getSnapshot().tasks); + return null; + } + store.publishOwner("closed-panel", tasks); + const first = tasks[0].id; + const walking = transferRuntime.runWalk(first, async () => { await held; }); + t.after(async () => { + release(); + await walking; + await act(async () => { await cancelling; renderer?.unmount(); }); + store.setDedicatedResumeHandler(null); + for (const task of tasks) store.dismiss(task.id); + netcattyBridge.get = originalGet; + resetTransferCancelLatchesForTests(); + resetTransferWalkRegistryForTests(); + resetTransferRuntimeRunsForTests(); + Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: previousAct }); + }); + const calls: string[] = []; + const cleaned: string[] = []; + let active = 0; + let peak = 0; + let recoveryCalls = 0; + netcattyBridge.get = () => ({ + cancelTransfer: async (id: string) => { + calls.push(id); + peak = Math.max(peak, ++active); + await new Promise((resolve) => setImmediate(resolve)); + active -= 1; + return { success: id !== first || firstCancelSucceeds }; + }, + clearPendingTransferCancel: async () => {}, + cleanupTransferArtifacts: async ({ transferId }: { transferId: string }) => { cleaned.push(transferId); }, + } as unknown as ReturnType); + store.setDedicatedResumeHandler(async () => { recoveryCalls += 1; return { success: true }; }); + await act(async () => { renderer = create(React.createElement(Probe)); }); + await act(async () => { + cancelling = actions!.cancelAll(); + for (let tick = 0; tick < 5; tick += 1) await new Promise((resolve) => setImmediate(resolve)); + }); + assert.equal(calls.length, 33, "the later active stream must receive cancellation before the first walk finishes"); + assert.ok(peak > 1 && peak <= 32, "backend cancellation remains bounded"); + assert.equal(transferRuntime.isWalkInFlight(first), true); + assert.equal(isTransferOrRootCancelled(first), true, "dispatch completion must not clear the old walk latch"); + assert.equal(cleaned.includes(first), false, "artifact cleanup still waits for settlement"); + let resumeFinished = false; + const resuming = store.resume(first).then(() => { resumeFinished = true; }); + await new Promise((resolve) => setImmediate(resolve)); + assert.equal(resumeFinished, false, "Resume must still wait for the cancelling walk"); + assert.equal(recoveryCalls, 0); + release(); + await Promise.all([walking, cancelling, resuming]); + assert.equal(recoveryCalls, firstCancelSucceeds ? 0 : 1); + assert.equal(store.getTask(first)?.status, firstCancelSucceeds ? "cancelled" : "completed"); + }); +} diff --git a/application/state/useGlobalSftpTransferActions.ts b/application/state/useGlobalSftpTransferActions.ts index 2ec06588f..4f582985a 100644 --- a/application/state/useGlobalSftpTransferActions.ts +++ b/application/state/useGlobalSftpTransferActions.ts @@ -1,28 +1,95 @@ -import { useCallback, useMemo } from "react"; +import { useCallback, useMemo, useRef, useState } from "react"; import type { TransferTask } from "../../domain/models"; import { getGlobalTransferBatchEligibility, + listGloballyCancellableTransferIds, listGloballyPausableTransferIds, listGloballyResumableTransferIds, } from "../../domain/sftpTransferActions"; import { sftpTransferCenterStore } from "./sftpTransferCenterStore"; +import { globalSftpTransferScheduler } from "./sftp/globalTransferScheduler"; +import { clearTransferCancelledTree, markTransferCancelledTree } from "./sftp/transferCancelLatch"; +import { transferRuntime } from "./sftp/transferRuntime"; export function useGlobalSftpTransferActions(tasks: readonly TransferTask[]) { + const cancellingRef = useRef(false); + const [isCancelling, setIsCancelling] = useState(false); const batchEligibility = useMemo( () => getGlobalTransferBatchEligibility(tasks), [tasks], ); const pauseAll = useCallback(() => { + // Ignore clicks racing a cancelAll batch: re-enabling these tasks would + // restart work cancelAll has already pre-cancelled at the scheduler. + if (cancellingRef.current) return; for (const taskId of listGloballyPausableTransferIds(tasks)) { void sftpTransferCenterStore.pause(taskId); } }, [tasks]); const resumeAll = useCallback(() => { + if (cancellingRef.current) return; for (const taskId of listGloballyResumableTransferIds(tasks)) { void sftpTransferCenterStore.resume(taskId); } }, [tasks]); - return { batchEligibility, pauseAll, resumeAll }; + const cancelAll = useCallback(async () => { + if (cancellingRef.current) return; + cancellingRef.current = true; + setIsCancelling(true); + const currentTasks = transferRuntime.getSnapshot().tasks; + const ids = listGloballyCancellableTransferIds(currentTasks); + try { + // Stop queued work before yielding to IPC, including children of folder + // transfers. Otherwise later batches could start while earlier ones stop. + const selected = new Set(ids); + const children = new Map(); + for (const task of currentTasks) { + if (!task.parentTaskId || !selected.has(task.parentTaskId) + || ["completed", "failed", "cancelled"].includes(task.status)) continue; + const idsForParent = children.get(task.parentTaskId) ?? []; + idsForParent.push(task.id); + children.set(task.parentTaskId, idsForParent); + globalSftpTransferScheduler.cancel(task.id); + } + for (const id of ids) { + markTransferCancelledTree(id, children.get(id)); + globalSftpTransferScheduler.cancel(id); + } + // Bound IPC work for large queues while active transfers stop promptly. + for (let offset = 0; offset < ids.length; offset += 32) { + await Promise.all(ids.slice(offset, offset + 32).map(async (taskId) => { + const current = transferRuntime.getTask(taskId); + // "failed" is terminal here just like the initial eligibility filter: + // a queued task can fail naturally while its cancel batch awaits IPC, + // and cancel's owner/orphan recovery paths would repaint the row as + // cancelled, hiding the real failure and its diagnostic. + if (!current || ["completed", "failed", "cancelled"].includes(current.status)) { + // No cancel invocation will own this pre-latch. Release it here so + // Retry is not discarded while the finished walk is unwinding. + clearTransferCancelledTree(taskId, children.get(taskId)); + return; + } + try { + await transferRuntime.cancel(taskId); + } catch { + // The row is kept in attention for recovery, so the pre-installed + // cancellation latch must be dropped or a later Resume is rejected + // outright by admitTaskRun's cancelled-root check. + clearTransferCancelledTree(taskId, children.get(taskId)); + sftpTransferCenterStore.patchTask(taskId, { + status: "attention", + error: "Could not cancel transfer. Please try again.", + }); + } + })); + } + } finally { + cancellingRef.current = false; + setIsCancelling(false); + } + }, []); + + return { batchEligibility, pauseAll, resumeAll, cancelAll, isCancelling }; } diff --git a/application/state/useHostConnectionTest.test.ts b/application/state/useHostConnectionTest.test.ts new file mode 100644 index 000000000..bfbe0db23 --- /dev/null +++ b/application/state/useHostConnectionTest.test.ts @@ -0,0 +1,108 @@ +import assert from "node:assert/strict"; +import test, { mock } from "node:test"; +import React from "react"; +import { act, create, type ReactTestRenderer } from "react-test-renderer"; + +import type { Host } from "../../domain/models.ts"; +import { useHostConnectionTest } from "./useHostConnectionTest.ts"; + +type HookApi = ReturnType; + +const targetHost: Host = { + id: "host-1", + label: "Target", + hostname: "target.example.test", + port: 22, + username: "alice", + password: "secret", + tags: [], + os: "linux", + protocol: "ssh", + // Shorter than the fixed 15s test auth timeout sent to the bridge. + sshAuthReadyTimeoutSeconds: 5, +}; + +test("connection test countdown follows the probe timeouts and invalidates the attempt on timeout", async (t) => { + const actEnvironment = globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }; + const previousActEnvironment = actEnvironment.IS_REACT_ACT_ENVIRONMENT; + actEnvironment.IS_REACT_ACT_ENVIRONMENT = true; + const previousWindowDescriptor = Object.getOwnPropertyDescriptor(globalThis, "window"); + + let resolveProbe: (value: unknown) => void = () => {}; + const cancelled: string[] = []; + const startedSessionIds: string[] = []; + const bridge = { + testConnection: (options: { sessionId: string }) => { + startedSessionIds.push(options.sessionId); + return new Promise((resolve) => { + resolveProbe = resolve; + }); + }, + cancelTestConnection: async (sessionId: string) => { + cancelled.push(sessionId); + return { success: true }; + }, + onChainProgress: () => () => {}, + onHostKeyVerification: () => () => {}, + respondHostKeyVerification: async () => ({ success: true }), + }; + Object.defineProperty(globalThis, "window", { + configurable: true, + writable: true, + value: { netcatty: bridge }, + }); + + mock.timers.enable({ apis: ["setTimeout", "setInterval"] }); + let api: HookApi | null = null; + let renderer: ReactTestRenderer | null = null; + const Probe = () => { + api = useHostConnectionTest({ host: targetHost, hosts: [targetHost], keys: [] }); + return null; + }; + const current = () => { + assert.ok(api); + return api; + }; + + t.after(async () => { + await act(async () => { + renderer?.unmount(); + }); + mock.timers.reset(); + if (previousWindowDescriptor) Object.defineProperty(globalThis, "window", previousWindowDescriptor); + else delete (globalThis as { window?: unknown }).window; + actEnvironment.IS_REACT_ACT_ENVIRONMENT = previousActEnvironment; + }); + + await act(async () => { + renderer = create(React.createElement(Probe)); + }); + await act(async () => { + void current().startTest(); + }); + assert.equal(current().state.status, "connecting"); + // 8s TCP + 15s auth sent to the bridge, plus a 2s grace. + assert.equal(current().state.timeLeft, 25); + + // The host's own 5s auth timeout must not end the test early. + await act(async () => { + mock.timers.tick(6000); + }); + assert.equal(current().state.status, "connecting"); + assert.deepEqual(cancelled, []); + + await act(async () => { + mock.timers.tick(19_000); + }); + assert.equal(current().state.status, "disconnected"); + assert.equal(current().state.error, "Connection timed out. Please try again."); + assert.deepEqual(cancelled, startedSessionIds, "timeout must cancel the in-flight probe"); + + // A late success from the cancelled probe must not overwrite the timeout. + await act(async () => { + resolveProbe({ sessionId: startedSessionIds[0], testResult: "connected" }); + await Promise.resolve(); + }); + assert.equal(current().state.status, "disconnected"); + assert.equal(current().state.error, "Connection timed out. Please try again."); +}); diff --git a/application/state/useHostConnectionTest.ts b/application/state/useHostConnectionTest.ts new file mode 100644 index 000000000..edb1a1519 --- /dev/null +++ b/application/state/useHostConnectionTest.ts @@ -0,0 +1,329 @@ +import { useCallback, useEffect, useRef, useState } from "react"; +import type { GroupConfig, Host, Identity, KnownHost, ProxyProfile, SSHKey, TerminalSettings } from "../../domain/models"; +import type { HostKeyInfo } from "../../domain/hostKey"; +import { toHostKeyInfo } from "../../domain/hostKey"; +import { createKnownHostFromHostKeyInfo } from "../../domain/knownHosts"; +import { + buildHostConnectionTestPlan, + formatConnectionTestProgressLog, + resolveHostConnectionTestDeadlineMs, + type HostConnectionTestAuthOverride, +} from "../../domain/hostConnectionTest"; +import { useTerminalBackend } from "./useTerminalBackend"; + +export type ChainProgress = { + currentHop: number; + totalHops: number; + currentHostLabel: string; + connectionPhase: string; +} | null; + +export type HostKeyVerificationState = { + requestId: string; + hostKeyInfo: HostKeyInfo; +}; + +export type HostConnectionTestStatus = "idle" | "connecting" | "connected" | "disconnected"; + +export type HostConnectionTestState = { + status: HostConnectionTestStatus; + error: string | null; + needsAuth: boolean; + progressValue: number; + chainProgress: ChainProgress; + progressLogs: string[]; + hostKeyVerification: HostKeyVerificationState | null; + timeLeft: number; + isAwaitingUserInput: boolean; + isCancelling: boolean; +}; + +export type HostConnectionTestInput = { + host: Host; + hosts: Host[]; + keys: SSHKey[]; + identities?: Identity[]; + knownHosts?: KnownHost[]; + groupConfigs?: GroupConfig[]; + proxyProfiles?: ProxyProfile[]; + terminalSettings?: TerminalSettings; + onAddKnownHost?: (knownHost: KnownHost) => void; +}; + +const messageOf = (err: unknown): string => (err instanceof Error ? err.message : String(err)); + +export function useHostConnectionTest(input: HostConnectionTestInput) { + const { + host, + hosts, + keys, + identities = [], + knownHosts, + groupConfigs = [], + proxyProfiles = [], + terminalSettings, + onAddKnownHost, + } = input; + const backend = useTerminalBackend(); + + const [status, setStatus] = useState("idle"); + const [error, setError] = useState(null); + const [needsAuth, setNeedsAuth] = useState(false); + const [progressValue, setProgressValue] = useState(0); + const [chainProgress, setChainProgress] = useState(null); + const [progressLogs, setProgressLogs] = useState([]); + const [hostKeyVerification, setHostKeyVerification] = useState(null); + const [timeLeft, setTimeLeft] = useState(0); + const [isAwaitingUserInput, setIsAwaitingUserInput] = useState(false); + const [isCancelling, setIsCancelling] = useState(false); + + const sessionIdRef = useRef(null); + const bootEpochRef = useRef(0); + const pendingHostKeyRequestIdRef = useRef(null); + const disposedRef = useRef(false); + // Deadline of the active attempt, derived from the options actually sent to + // the bridge so the countdown matches the probe's real timeouts. + const deadlineMsRef = useRef(0); + + const resetState = useCallback(() => { + setError(null); + setNeedsAuth(false); + setProgressValue(0); + setChainProgress(null); + setProgressLogs([]); + setHostKeyVerification(null); + setIsAwaitingUserInput(false); + setIsCancelling(false); + setTimeLeft(0); + pendingHostKeyRequestIdRef.current = null; + }, []); + + const clearSession = useCallback(() => { + const sessionId = sessionIdRef.current; + sessionIdRef.current = null; + if (sessionId && !disposedRef.current) { + void backend.cancelTestConnection(sessionId).catch(() => {}); + } + }, [backend]); + + // Unmount cleanup. The per-attempt subscriptions below dispose themselves via + // their own effect returns; nothing here touches them. + useEffect(() => { + disposedRef.current = false; + return () => { + disposedRef.current = true; + }; + }, []); + + // Subscribe to chain progress for the active attempt. + useEffect(() => { + const unsub = backend.onChainProgress?.((sessionId, hop, total, label, phase, phaseError) => { + if (sessionId !== sessionIdRef.current) return; + if (total > 1) { + setChainProgress({ + currentHop: hop, + totalHops: total, + currentHostLabel: label, + connectionPhase: phase, + }); + } + setProgressLogs((prev) => [ + ...prev, + formatConnectionTestProgressLog({ hop, total, label, phase, error: phaseError }), + ]); + const hopProgress = (hop / total) * 80 + 10; + setProgressValue((prev) => Math.max(prev, Math.min(95, hopProgress))); + setIsAwaitingUserInput(phase === "auth-attempt" && phaseError === "waiting for user input..."); + }); + return () => unsub?.(); + }, [backend]); + + // Subscribe to host-key verification for the active attempt. + useEffect(() => { + const unsub = backend.onHostKeyVerification?.((request) => { + if (request.sessionId !== sessionIdRef.current) return; + pendingHostKeyRequestIdRef.current = request.requestId; + setHostKeyVerification({ + requestId: request.requestId, + hostKeyInfo: toHostKeyInfo(request), + }); + }); + return () => unsub?.(); + }, [backend]); + + const respondHostKey = useCallback( + (requestId: string, accept: boolean, addToKnownHosts: boolean, hostKeyInfo: HostKeyInfo) => { + if (accept && addToKnownHosts && onAddKnownHost) { + onAddKnownHost(createKnownHostFromHostKeyInfo(hostKeyInfo)); + } + void backend.respondHostKeyVerification(requestId, accept, addToKnownHosts); + pendingHostKeyRequestIdRef.current = null; + setHostKeyVerification(null); + }, + [backend, onAddKnownHost], + ); + + const handleHostKeyContinue = useCallback(() => { + if (!hostKeyVerification) return; + respondHostKey(hostKeyVerification.requestId, true, false, hostKeyVerification.hostKeyInfo); + }, [hostKeyVerification, respondHostKey]); + + const handleHostKeyAddAndContinue = useCallback(() => { + if (!hostKeyVerification) return; + respondHostKey(hostKeyVerification.requestId, true, true, hostKeyVerification.hostKeyInfo); + }, [hostKeyVerification, respondHostKey]); + + const handleHostKeyClose = useCallback(() => { + if (!hostKeyVerification) return; + respondHostKey(hostKeyVerification.requestId, false, false, hostKeyVerification.hostKeyInfo); + }, [hostKeyVerification, respondHostKey]); + + const startTest = useCallback( + async (authOverride?: HostConnectionTestAuthOverride | null) => { + clearSession(); + resetState(); + + const sessionId = crypto.randomUUID(); + const bootEpoch = (bootEpochRef.current += 1); + sessionIdRef.current = sessionId; + + const plan = buildHostConnectionTestPlan({ + host, + hosts, + keys, + identities, + knownHosts, + groupConfigs, + proxyProfiles, + terminalSettings: terminalSettings + ? { + keepaliveInterval: terminalSettings.keepaliveInterval, + keepaliveCountMax: terminalSettings.keepaliveCountMax, + verifyHostKeys: terminalSettings.verifyHostKeys, + } + : undefined, + sessionId, + bootEpoch, + authOverride, + }); + + if (!plan.ok) { + setError(plan.error); + setStatus("disconnected"); + return; + } + + if (plan.needsCredentialReentry) { + setNeedsAuth(true); + setStatus("connecting"); + return; + } + + deadlineMsRef.current = resolveHostConnectionTestDeadlineMs(plan.options); + setStatus("connecting"); + setIsAwaitingUserInput(false); + + try { + await backend.testConnection(plan.options); + if (disposedRef.current || sessionIdRef.current !== sessionId) return; + setProgressValue(100); + setChainProgress(null); + setStatus("connected"); + } catch (err) { + if (disposedRef.current || sessionIdRef.current !== sessionId) return; + setError(messageOf(err)); + setStatus("disconnected"); + } + }, + [ + backend, + clearSession, + groupConfigs, + host, + hosts, + identities, + keys, + knownHosts, + proxyProfiles, + resetState, + terminalSettings, + ], + ); + + const submitAuth = useCallback( + (auth: HostConnectionTestAuthOverride) => { + void startTest(auth); + }, + [startTest], + ); + + const cancelTest = useCallback(() => { + setIsCancelling(true); + // Reject any pending host-key prompt so the dial fails fast, then clear + // this attempt's session. An in-flight TCP/auth dial without a prompt + // settles on its own (it is not registered as a terminal session). + if (pendingHostKeyRequestIdRef.current) { + void backend.respondHostKeyVerification(pendingHostKeyRequestIdRef.current, false, false); + pendingHostKeyRequestIdRef.current = null; + setHostKeyVerification(null); + } + clearSession(); + setStatus("idle"); + setNeedsAuth(false); + setError(null); + }, [backend, clearSession]); + + // Connection timeout countdown + gentle progress while connecting. + useEffect(() => { + if (status !== "connecting" || needsAuth || hostKeyVerification || isAwaitingUserInput) { + return; + } + const deadlineMs = deadlineMsRef.current; + if (deadlineMs <= 0) return; + setTimeLeft(Math.ceil(deadlineMs / 1000)); + const countdown = setInterval(() => { + setTimeLeft((prev) => (prev > 0 ? prev - 1 : 0)); + }, 1000); + const timeout = setTimeout(() => { + // Cancel the probe and invalidate this attempt so a late success or + // failure from the bridge cannot overwrite the timeout outcome. + clearSession(); + setError("Connection timed out. Please try again."); + setStatus("disconnected"); + }, deadlineMs); + const prog = setInterval(() => { + setProgressValue((prev) => { + if (prev >= 95) return prev; + const remaining = 95 - prev; + return Math.min(95, prev + Math.max(1, remaining * 0.15)); + }); + }, 200); + return () => { + clearInterval(countdown); + clearTimeout(timeout); + clearInterval(prog); + }; + }, [status, needsAuth, hostKeyVerification, isAwaitingUserInput, clearSession]); + + return { + state: { + status, + error, + needsAuth, + progressValue, + chainProgress, + progressLogs, + hostKeyVerification, + timeLeft, + isAwaitingUserInput, + isCancelling, + } satisfies HostConnectionTestState, + startTest, + submitAuth, + cancelTest, + retry: () => void startTest(), + handleHostKeyContinue, + handleHostKeyAddAndContinue, + handleHostKeyClose, + }; +} diff --git a/application/state/useHostOpenSessionReuse.test.ts b/application/state/useHostOpenSessionReuse.test.ts new file mode 100644 index 000000000..a68bb230f --- /dev/null +++ b/application/state/useHostOpenSessionReuse.test.ts @@ -0,0 +1,274 @@ +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 type { GroupConfig, Host, Identity, SSHKey, TerminalSession } from '../../domain/models'; +import { updateRestoredSessionStatusState } from './sessionRestoreState'; +import { resolveEffectiveTerminalHost, resolveTerminalSessionHost } from '../../domain/terminalHostResolution'; +import { createHostTerminalSession } from './sessionFactories'; +import { useHostOpenSessionReuse } from './useHostOpenSessionReuse'; + +(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; +const effective = (host: Host) => host; +const host = (extra: Partial = {}): Host => ({ + id: 'host', hostname: 'original.invalid', username: 'user', label: 'Original', os: 'linux', tags: [], protocol: 'ssh', ...extra, +}); + +async function harness(initial: Host, extra: { hosts?: Host[]; keys?: SSHKey[]; identities?: Identity[]; sessions?: TerminalSession[]; resolveEffectiveHost?: (host: Host) => Host } = {}) { + let hosts = [initial, ...(extra.hosts ?? [])]; + let keys = extra.keys ?? []; + let identities = extra.identities ?? []; + let sessions: TerminalSession[] = extra.sessions ?? [{ ...createHostTerminalSession('tab', initial), status: 'connected' }]; + let find: ReturnType; + function Harness() { + find = useHostOpenSessionReuse({ sessions, hosts, keys, identities, resolveEffectiveHost: extra.resolveEffectiveHost ?? effective }); + return null; + } + let renderer: ReactTestRenderer; + await act(async () => { renderer = create(React.createElement(Harness)); }); + return { + find: () => find(initial.id), + async update(input: { host?: Host; hosts?: Host[]; keys?: SSHKey[]; identities?: Identity[]; sessions?: TerminalSession[] }) { + hosts = input.hosts ?? (input.host ? [input.host, ...hosts.slice(1)] : hosts); + keys = input.keys ?? keys; + identities = input.identities ?? identities; + sessions = input.sessions ?? sessions; + await act(async () => renderer.update(React.createElement(Harness))); + }, + close: () => act(async () => renderer.unmount()), + }; +} + +for (const [field, value] of [ + ['hostname', 'replacement.invalid'], ['username', 'other-user'], ['port', 2222], + ['protocol', 'telnet'], ['password', 'new-password'], ['authMethod', 'key'], + ['hostChain', { hostIds: ['jump'] }], + ['proxyConfig', { type: 'socks5', host: 'proxy.invalid', port: 1080 }], +] as const) { + test(`a saved ${field} edit cannot reuse the old tab`, async (t) => { + const original = host({ password: 'old-password', authMethod: 'password' }); + const api = await harness(original, { hosts: [host({ id: 'jump', hostname: 'jump.invalid' })] }); + t.after(api.close); + assert.equal((await api.find())?.sessionId, 'tab'); + await api.update({ host: { ...original, [field]: value } }); + assert.equal(await api.find(), undefined); + }); +} + +test('renaming the target and jump host keeps the same tab eligible', async (t) => { + const original = host({ hostChain: { hostIds: ['jump'] } }); + const jump = host({ id: 'jump', hostname: 'jump.invalid' }); + const api = await harness(original, { hosts: [jump] }); + t.after(api.close); + await api.update({ hosts: [{ ...original, label: 'Renamed', fontSize: 16, notes: 'Updated note' }, { ...jump, label: 'Renamed jump' }] }); + assert.equal((await api.find())?.sessionId, 'tab'); +}); + +test('changing a jump endpoint or its password rejects the original connection', async (t) => { + const original = host({ hostChain: { hostIds: ['jump'] } }); + const jump = host({ id: 'jump', hostname: 'jump.invalid', password: 'old' }); + const api = await harness(original, { hosts: [jump] }); + t.after(api.close); + for (const changed of [{ ...jump, hostname: 'new-jump.invalid' }, { ...jump, password: 'new' }]) { + await api.update({ hosts: [original, changed] }); + assert.equal(await api.find(), undefined); + } +}); + +test('rotating a referenced identity or key keeps stable IDs from masking auth changes', async (t) => { + const identity: Identity = { id: 'identity', label: 'Login', username: 'user', password: 'old', authMethod: 'password', created: 0 }; + const original = host({ identityId: identity.id }); + const api = await harness(original, { identities: [identity] }); + t.after(api.close); + assert.equal((await api.find())?.sessionId, 'tab'); + await api.update({ identities: [{ ...identity, password: 'rotated' }] }); + assert.equal(await api.find(), undefined); + const key: SSHKey = { id: 'key', label: 'Key', type: 'ED25519', privateKey: 'old-key', source: 'generated', category: 'key', created: 0 }; + const keyHost = host({ identityFileId: key.id, authMethod: 'key' }); + const keyApi = await harness(keyHost, { keys: [key] }); + t.after(keyApi.close); + assert.equal((await keyApi.find())?.sessionId, 'tab'); + await keyApi.update({ keys: [{ ...key, privateKey: 'rotated-key' }] }); + assert.equal(await keyApi.find(), undefined); +}); + +for (const [field, value] of [ + ['baudRate', 19200], ['dataBits', 7], ['stopBits', 2], ['parity', 'even'], ['flowControl', 'rts/cts'], +] as const) { + test(`a serial ${field} edit must not reuse the opening parameters`, async (t) => { + const original = host({ protocol: 'serial', hostname: '/dev/tty.test', username: '', serialConfig: { path: '/dev/tty.test', baudRate: 9600, dataBits: 8, stopBits: 1, parity: 'none', flowControl: 'none' } }); + const api = await harness(original); + t.after(api.close); + assert.equal((await api.find())?.sessionId, 'tab'); + await api.update({ host: { ...original, serialConfig: { ...original.serialConfig!, [field]: value } } }); + assert.equal(await api.find(), undefined); + }); +} + +test('unchanged legacy serial defaults still match their opening snapshot', async (t) => { + const original = host({ protocol: 'serial', hostname: '/dev/tty.test', username: '', port: 9600 }); + const api = await harness(original); + t.after(api.close); + assert.equal((await api.find())?.sessionId, 'tab'); +}); + +test('the captured serial settings come from the opening session even if the vault was already edited', async (t) => { + const original = host({ protocol: 'serial', hostname: '/dev/tty.test', username: '', serialConfig: { path: '/dev/tty.test', baudRate: 9600 } }); + const session: TerminalSession = { ...createHostTerminalSession('tab', original), status: 'connected' }; + const api = await harness({ ...original, serialConfig: { ...original.serialConfig!, baudRate: 19200 } }, { sessions: [session] }); + t.after(api.close); + assert.equal(await api.find(), undefined); +}); + +for (const changedField of ['configuration', 'credentialId'] as const) { + test(`a restored plugin tab keeps its opening ${changedField} after manual reconnect`, async (t) => { + const providerId = 'com.example.transport.connection'; + const original = host({ protocol: `plugin:${providerId}`, pluginConnection: { providerId, configuration: { endpoint: 'original.invalid' }, credentialId: 'original-credential-reference' } }); + const edited = { ...original, pluginConnection: { ...original.pluginConnection!, [changedField]: changedField === 'configuration' ? { endpoint: 'replacement.invalid' } : 'replacement-credential-reference' } }; + const restored: TerminalSession = { ...createHostTerminalSession('tab', original), status: 'disconnected', restoreState: 'restored-disconnected' }; + const api = await harness(edited, { sessions: [restored] }); + t.after(api.close); + assert.equal(await api.find(), undefined); + const reconnecting = updateRestoredSessionStatusState([restored], restored.id, 'connecting'); + const actual = resolveTerminalSessionHost({ session: reconnecting[0], hosts: [edited], groupConfigs: [], proxyProfiles: [], localOs: 'linux' }); + assert.deepEqual(actual.pluginConnection, original.pluginConnection, 'the real terminal resolver uses the restored opening configuration'); + await api.update({ sessions: reconnecting }); + assert.equal(await api.find(), undefined, 'host_open must not call that old transport the edited vault connection'); + }); +} + +test('an unchanged restored plugin configuration can be reused after manual reconnect', async (t) => { + const providerId = 'com.example.transport.connection'; + const original = host({ protocol: `plugin:${providerId}`, pluginConnection: { providerId, configuration: { endpoint: 'original.invalid' } } }); + const restored: TerminalSession = { ...createHostTerminalSession('tab', original), status: 'disconnected', restoreState: 'restored-disconnected' }; + const api = await harness({ ...original, label: 'Renamed' }, { sessions: [restored] }); + t.after(api.close); + await api.update({ sessions: updateRestoredSessionStatusState([restored], restored.id, 'connecting') }); + assert.equal((await api.find())?.sessionId, 'tab'); +}); + +for (const [field, value] of [ + ['telnetPort', 2324], ['telnetUsername', 'other-telnet-user'], ['telnetPassword', 'rotated-telnet-password'], +] as const) { + test(`a Telnet ${field} edit cannot reuse the old transport`, async (t) => { + const original = host({ protocol: 'telnet', telnetPort: 2323, telnetUsername: 'telnet-user', telnetPassword: 'telnet-password' }); + const api = await harness(original); + t.after(api.close); + assert.equal((await api.find())?.sessionId, 'tab'); + await api.update({ host: { ...original, [field]: value } }); + assert.equal(await api.find(), undefined); + }); +} + +test('Telnet identity credential rotation and missing identity reject reuse', async (t) => { + const identity: Identity = { id: 'telnet-login', label: 'Telnet login', username: 'telnet-user', password: 'original', authMethod: 'password', created: 0 }; + const original = host({ protocol: 'telnet', telnetIdentityId: identity.id }); + const api = await harness(original, { identities: [identity] }); + t.after(api.close); + assert.equal((await api.find())?.sessionId, 'tab'); + for (const identities of [[{ ...identity, username: 'other-user' }], [{ ...identity, password: 'rotated' }], []]) { + await api.update({ identities }); + assert.equal(await api.find(), undefined); + } +}); + +test('Telnet explicit empty login does not fall back to SSH login fields', async (t) => { + const original = host({ protocol: 'telnet', telnetUsername: '', telnetPassword: '', password: 'ssh-only' }); + const api = await harness(original); + t.after(api.close); + assert.equal((await api.find())?.sessionId, 'tab'); + await api.update({ host: { ...original, label: 'Renamed', username: 'unrelated-ssh-user', password: 'unrelated-ssh-password', identityId: 'missing-ssh-identity', telnetPort: 23 } }); + assert.equal((await api.find())?.sessionId, 'tab'); + await api.update({ host: { ...original, telnetUsername: undefined, telnetPassword: undefined } }); + assert.equal(await api.find(), undefined, 'removing explicit empty overrides enables the actual fallback login'); +}); + +for (const transport of [{ protocol: 'et' as const }, { protocol: 'ssh' as const, etEnabled: true }]) { + test(`changing a jump ET server port rejects the old ${JSON.stringify(transport)} transport`, async (t) => { + const original = host({ ...transport, hostChain: { hostIds: ['jump'] } }); + const jump = host({ id: 'jump', hostname: 'jump.invalid', etPort: 2022 }); + const api = await harness(original, { hosts: [jump] }); + t.after(api.close); + assert.equal((await api.find())?.sessionId, 'tab'); + await api.update({ hosts: [original, { ...jump, etPort: 3022 }] }); + assert.equal(await api.find(), undefined); + }); +} + +test('serial direct auto-login password is not masked by an unrelated SSH identity', async (t) => { + const identity: Identity = { id: 'ssh-login', label: 'SSH login', username: 'ssh-user', password: 'ssh-password', authMethod: 'password', created: 0 }; + const original = host({ protocol: 'serial', hostname: '/dev/tty.test', username: '', password: 'serial-password', identityId: identity.id }); + const api = await harness(original, { identities: [identity] }); + t.after(api.close); + assert.equal((await api.find())?.sessionId, 'tab'); + await api.update({ host: { ...original, password: 'rotated-serial-password' } }); + assert.equal(await api.find(), undefined); +}); + +test('unchanged serial direct username matches the actual auto-login input', async (t) => { + const original = host({ protocol: 'serial', hostname: '/dev/tty.test', username: 'serial-user', password: 'serial-password' }); + const api = await harness(original); + t.after(api.close); + assert.equal((await api.find())?.sessionId, 'tab'); + await api.update({ host: { ...original, username: 'other-serial-user' } }); + assert.equal(await api.find(), undefined); +}); + +for (const [field, value] of [ + ['localShell', '/bin/bash'], ['localShellArgs', ['--norc']], ['localStartDir', '/replacement'], +] as const) { + test(`a saved local ${field} edit cannot reuse the old process`, async (t) => { + const original = host({ protocol: 'local', hostname: 'localhost', localShell: '/bin/zsh', localShellArgs: ['-l'], localStartDir: '/original' }); + const session = { ...createHostTerminalSession('tab', original), status: 'connected', localShell: original.localShell, localShellArgs: original.localShellArgs, localStartDir: original.localStartDir } as TerminalSession; + const api = await harness(original, { sessions: [session] }); + t.after(api.close); + assert.equal((await api.find())?.sessionId, 'tab'); + await api.update({ host: { ...original, [field]: value } }); + assert.equal(await api.find(), undefined); + const alreadyEdited = await harness({ ...original, [field]: value }, { sessions: [session] }); + t.after(alreadyEdited.close); + assert.equal(await alreadyEdited.find(), undefined, 'opening fields come from the actual local session'); + }); +} + +test('local default shell and cwd treat empty values as omitted but explicit empty args stay distinct', async (t) => { + const original = host({ protocol: 'local', hostname: 'localhost' }); + const api = await harness(original); + t.after(api.close); + await api.update({ host: { ...original, localShell: '', localStartDir: '', label: 'Renamed' } }); + assert.equal((await api.find())?.sessionId, 'tab'); + await api.update({ host: { ...original, localShellArgs: [] } }); + assert.equal(await api.find(), undefined, 'empty args bypass backend login-shell defaults'); +}); + +for (const [label, proxy] of [ + ['a missing saved profile', { proxyProfileId: 'missing-profile' }], + ['a usable proxy', { proxyConfig: { type: 'socks5', host: 'proxy.invalid', port: 1080 } }], + ['a missing proxy identity', { proxyConfig: { type: 'socks5', host: '', port: 0, identityId: 'missing-identity' } }], + ['an incomplete proxy identity', { proxyConfig: { type: 'socks5', host: '', port: 0, identityId: 'incomplete-identity' } }], +] as const) { + test(`Telnet adding ${label} cannot bypass actual starter rejection through reuse`, async (t) => { + const original = host({ protocol: 'telnet' }); + const api = await harness(original, { identities: [{ id: 'incomplete-identity', label: 'Incomplete', username: 'proxy-user', authMethod: 'password', created: 0 }] }); + t.after(api.close); + assert.equal((await api.find())?.sessionId, 'tab'); + await api.update({ host: { ...original, ...proxy } }); + assert.equal(await api.find(), undefined); + await api.update({ host: { ...original, label: 'Renamed without a proxy' } }); + assert.equal((await api.find())?.sessionId, 'tab'); + }); +} + +test('Telnet inheriting a proxy profile cannot reuse its previous direct session', async (t) => { + const original = host({ protocol: 'telnet', group: 'prod/web' }); + const groupConfigs: GroupConfig[] = []; + const proxyProfiles = [{ id: 'group-proxy', label: 'Proxy', createdAt: 0, config: { type: 'socks5' as const, host: 'proxy.invalid', port: 1080 } }]; + const resolveEffectiveHost = (candidate: Host) => resolveEffectiveTerminalHost({ host: candidate, groupConfigs, proxyProfiles }); + const api = await harness(original, { resolveEffectiveHost }); + t.after(api.close); + assert.equal((await api.find())?.sessionId, 'tab'); + groupConfigs.push({ path: 'prod', proxyProfileId: 'group-proxy' }); + assert.equal(resolveEffectiveHost(original).proxyConfig?.host, 'proxy.invalid'); + await api.update({ host: { ...original } }); + assert.equal(await api.find(), undefined); +}); diff --git a/application/state/useHostOpenSessionReuse.ts b/application/state/useHostOpenSessionReuse.ts new file mode 100644 index 000000000..48a9f9a83 --- /dev/null +++ b/application/state/useHostOpenSessionReuse.ts @@ -0,0 +1,132 @@ +import { useCallback, useLayoutEffect, useRef } from 'react'; +import type { Host, Identity, SSHKey, TerminalSession } from '../../domain/models'; +import { findReusableHostSession } from '../../domain/hostOpenSession'; +import { resolveTelnetPassword, resolveTelnetPort, resolveTelnetUsername } from '../../domain/host'; +import { isEncryptedCredentialPlaceholder, sanitizeCredentialValue } from '../../domain/credentials'; +import { resolveEffectiveTerminalProtocol } from '../../domain/terminalProtocol'; +import { findIncompleteProxyIdentityId, findMissingProxyIdentityId, hasUsableProxyConfig } from '../../domain/proxyProfiles'; +import { buildSftpHostCredentials } from './sftp/useSftpHostCredentials'; +import { buildTransferPoolKey } from './sftp/transferConnectionPool'; +import { createHostTerminalSession } from './sessionFactories'; + +type HostOpenSessionReuseInput = { + sessions: TerminalSession[]; + hosts: Host[]; + keys: SSHKey[]; + identities: Identity[]; + resolveEffectiveHost: (host: Host) => Host; +}; + +/** Retain the opening connection identity, never credentials in session storage. */ +export function useHostOpenSessionReuse({ sessions, hosts, keys, identities, resolveEffectiveHost }: HostOpenSessionReuseInput) { + const openingKeys = useRef(new Map>()); + const connectionKey = useCallback((host: Host) => { + const protocol = resolveEffectiveTerminalProtocol(host); + if (protocol === 'local') { + const options = { + hostname: host.hostname, + username: '', + localShell: host.localShell || undefined, + // undefined lets the backend choose login arguments; [] explicitly does not. + localShellArgs: host.localShellArgs, + localStartDir: host.localStartDir || undefined, + }; + return buildTransferPoolKey({ hostname: host.hostname, hostId: host.id, protocol, connectionOptions: options }); + } + if (protocol === 'telnet') { + // The actual Telnet starter rejects these settings before connecting. + if ((host.proxyProfileId && !host.proxyConfig) + || findMissingProxyIdentityId(host.proxyConfig, identities) + || findIncompleteProxyIdentityId(host.proxyConfig, identities) + || hasUsableProxyConfig(host.proxyConfig)) { + throw new Error('Telnet proxy configuration cannot be used'); + } + // Match the actual Telnet starter, including explicit empty overrides. + const identity = host.telnetIdentityId ? identities.find((item) => item.id === host.telnetIdentityId) : undefined; + if (host.telnetIdentityId && (!identity?.username?.trim() || identity.password === undefined)) { + throw new Error('Telnet identity is missing or incomplete'); + } + const rawPassword = identity ? identity.password : resolveTelnetPassword(host); + if (isEncryptedCredentialPlaceholder(rawPassword)) throw new Error('Telnet credentials are unavailable'); + const options = { + hostname: host.hostname, + port: resolveTelnetPort(host), + username: identity ? identity.username?.trim() : resolveTelnetUsername(host), + password: sanitizeCredentialValue(rawPassword), + }; + return buildTransferPoolKey({ ...options, hostId: host.id, protocol, connectionOptions: options }); + } + // Reuse the existing credential/chain expansion and transport identity + // comparison. Presentation names and SFTP-only choices do not change a + // terminal's destination or login. + if (protocol === 'serial' && isEncryptedCredentialPlaceholder(host.password)) throw new Error('Serial credentials are unavailable'); + const options = buildSftpHostCredentials({ + host, hosts: hosts.map(resolveEffectiveHost), keys, identities, + }); + delete options.sudo; + delete options.fileProtocol; + for (const hop of options.jumpHosts ?? []) delete hop.label; + const connectionOptions = { + ...options, + protocol, + moshEnabled: host.moshEnabled, + moshServerPath: host.moshServerPath, + etEnabled: host.etEnabled, + etPort: host.etPort, + etJumpPorts: protocol === 'et' + ? host.hostChain?.hostIds.map((id) => hosts.find((item) => item.id === id)).map((hop) => hop && (resolveEffectiveHost(hop).etPort ?? 2022)) + : undefined, + pluginConnection: host.pluginConnection, + serialConfig: protocol === 'serial' ? createHostTerminalSession('', host).serialConfig : undefined, + serialLogin: protocol === 'serial' ? { username: host.username?.trim(), password: sanitizeCredentialValue(host.password) } : undefined, + agentForwarding: host.agentForwarding, + x11Forwarding: host.x11Forwarding, + }; + return buildTransferPoolKey({ ...options, protocol: connectionOptions.protocol, connectionOptions }); + }, [hosts, keys, identities, resolveEffectiveHost]); + + useLayoutEffect(() => { + const liveIds = new Set(sessions.map((session) => session.id)); + for (const id of openingKeys.current.keys()) { + if (!liveIds.has(id)) openingKeys.current.delete(id); + } + for (const session of sessions) { + if (openingKeys.current.has(session.id) || session.restoreState) continue; + const host = hosts.find((candidate) => candidate.id === session.hostId); + if (!host) continue; + try { + openingKeys.current.set(session.id, connectionKey({ + ...resolveEffectiveHost(host), + hostname: session.hostname, + username: session.protocol === 'serial' ? resolveEffectiveHost(host).username : session.username, + port: session.port ?? host.port, + protocol: session.protocol ?? host.protocol, + moshEnabled: session.moshEnabled ?? host.moshEnabled, + etEnabled: session.etEnabled ?? host.etEnabled, + serialConfig: session.serialConfig ?? host.serialConfig, + pluginConnection: session.pluginConnection ?? host.pluginConnection, + localShell: session.localShell ?? host.localShell, + localShellArgs: session.localShellArgs ?? host.localShellArgs, + localStartDir: session.localStartDir ?? host.localStartDir, + }).catch(() => undefined)); + } catch { + openingKeys.current.set(session.id, Promise.resolve(undefined)); + } + } + }, [sessions, hosts, connectionKey, resolveEffectiveHost]); + + return useCallback(async (hostId: string) => { + const host = hosts.find((candidate) => candidate.id === hostId); + if (!host) return undefined; + try { + const requestedKey = await connectionKey(resolveEffectiveHost(host)); + const matches = await Promise.all(sessions.map(async (session) => ( + session.hostId === hostId && await openingKeys.current.get(session.id) === requestedKey ? session : undefined + ))); + return findReusableHostSession(matches.filter((session) => session !== undefined), hostId); + } catch { + // An unresolved/changed credential must never select an old transport. + return undefined; + } + }, [hosts, sessions, connectionKey, resolveEffectiveHost]); +} diff --git a/application/state/useScriptsViewMode.test.ts b/application/state/useScriptsViewMode.test.ts new file mode 100644 index 000000000..dab5eb04a --- /dev/null +++ b/application/state/useScriptsViewMode.test.ts @@ -0,0 +1,27 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; + +import { + parseScriptsViewMode, + SCRIPTS_VIEW_MODE_STORAGE_KEY, +} from "./useScriptsViewMode.ts"; + +test("scripts view mode parser only accepts 'stacked'", () => { + assert.equal(parseScriptsViewMode("stacked"), "stacked"); + assert.equal(parseScriptsViewMode("list"), "list"); + assert.equal(parseScriptsViewMode("grid"), "list"); + assert.equal(parseScriptsViewMode(null), "list"); + assert.equal(parseScriptsViewMode(""), "list"); +}); + +test("scripts view mode hook persists the chosen mode", () => { + const source = readFileSync(new URL("./useScriptsViewMode.ts", import.meta.url), "utf8"); + assert.match(source, /useStoredString/); + // The shared hook owns persistence and same-/cross-window subscriptions. + assert.match( + source, + /useStoredString\(SCRIPTS_VIEW_MODE_STORAGE_KEY, "list", isScriptsViewMode\)/, + ); + assert.equal(SCRIPTS_VIEW_MODE_STORAGE_KEY.startsWith("netcatty:"), true); +}); \ No newline at end of file diff --git a/application/state/useScriptsViewMode.ts b/application/state/useScriptsViewMode.ts new file mode 100644 index 000000000..014d5538a --- /dev/null +++ b/application/state/useScriptsViewMode.ts @@ -0,0 +1,19 @@ +import { STORAGE_KEY_SCRIPTS_SIDE_PANEL_VIEW } from "../../infrastructure/config/storageKeys"; +import { useStoredString } from "./useStoredString"; + +/** View modes for the scripts side panel library: + * 'list' (one snippet per row, tree of packages) and 'stacked' (compact + * wrap-around chips). */ +export type ScriptsViewMode = "list" | "stacked"; + +export const SCRIPTS_VIEW_MODE_STORAGE_KEY = STORAGE_KEY_SCRIPTS_SIDE_PANEL_VIEW; + +/** Pure resolver so callers and tests share one validation rule. */ +export const parseScriptsViewMode = (value: string | null): ScriptsViewMode => + value === "stacked" ? "stacked" : "list"; + +const isScriptsViewMode = (value: string | null): value is ScriptsViewMode => + value === "list" || value === "stacked"; + +export const useScriptsViewMode = () => + useStoredString(SCRIPTS_VIEW_MODE_STORAGE_KEY, "list", isScriptsViewMode); diff --git a/application/state/useScriptsViewMode.windows.test.ts b/application/state/useScriptsViewMode.windows.test.ts new file mode 100644 index 000000000..23f73d18f --- /dev/null +++ b/application/state/useScriptsViewMode.windows.test.ts @@ -0,0 +1,59 @@ +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 { SCRIPTS_VIEW_MODE_STORAGE_KEY, useScriptsViewMode } from "./useScriptsViewMode.ts"; + +test("script view changes follow native storage events from another window", async (t) => { + const windowA = new EventTarget(); + const windowB = new EventTarget(); + let activeWindow = windowA; + const data = new Map(); + const values = { + localStorage: { getItem: (key: string) => data.get(key) ?? null, setItem: (key: string, value: string) => { data.set(key, value); } }, + addEventListener: (type: string, listener: EventListener) => activeWindow.addEventListener(type, listener), + removeEventListener: (type: string, listener: EventListener) => activeWindow.removeEventListener(type, listener), + IS_REACT_ACT_ENVIRONMENT: true, + }; + const previous = new Map(); + for (const [key, value] of Object.entries(values)) { + previous.set(key, Object.getOwnPropertyDescriptor(globalThis, key)); + Object.defineProperty(globalThis, key, { configurable: true, value }); + } + let a!: ReturnType; + let b!: ReturnType; + function FirstWindow() { a = useScriptsViewMode(); return null; } + function SecondWindow() { b = useScriptsViewMode(); return null; } + let first!: ReactTestRenderer; + let second!: ReactTestRenderer; + t.after(async () => { + activeWindow = windowA; await act(async () => first?.unmount()); + activeWindow = windowB; await act(async () => second?.unmount()); + for (const [key, descriptor] of previous) { + if (descriptor) Object.defineProperty(globalThis, key, descriptor); + else Reflect.deleteProperty(globalThis, key); + } + }); + activeWindow = windowA; await act(async () => { first = create(React.createElement(FirstWindow)); }); + activeWindow = windowB; await act(async () => { second = create(React.createElement(SecondWindow)); }); + assert.equal(a[0], "list"); assert.equal(b[0], "list"); + // BrowserWindow A writes shared storage. Electron emits the native event + // in B, not an adapter CustomEvent from A's independent JS environment. + activeWindow = windowA; await act(async () => a[1]("stacked")); + assert.equal(data.get(SCRIPTS_VIEW_MODE_STORAGE_KEY), "stacked"); + assert.equal(b[0], "list"); + const notify = async (target: EventTarget, key = SCRIPTS_VIEW_MODE_STORAGE_KEY) => { + const event = new Event("storage"); + Object.defineProperty(event, "key", { value: key }); + await act(async () => { target.dispatchEvent(event); }); + }; + activeWindow = windowB; await notify(windowB); + assert.equal(b[0], "stacked"); + await act(async () => b[1]("list")); + assert.equal(a[0], "stacked"); + activeWindow = windowA; await notify(windowA); + assert.equal(a[0], "list"); + data.set(SCRIPTS_VIEW_MODE_STORAGE_KEY, "stacked"); + await notify(windowA, "unrelated"); + assert.equal(a[0], "list", "unrelated preferences must not trigger an update"); +}); diff --git a/application/state/useSessionHistorySearch.ts b/application/state/useSessionHistorySearch.ts new file mode 100644 index 000000000..550612c69 --- /dev/null +++ b/application/state/useSessionHistorySearch.ts @@ -0,0 +1,24 @@ +import { useEffect, useMemo } from 'react'; +import { + createSessionHistorySearch, + type SessionHistorySearch, +} from './sessionHistorySearch'; + +/** + * Owns one search instance per mounted session history drawer. + * + * The search index cache is bound to this instance (not module scope), so + * several concurrently mounted drawers — one per terminal tab — each keep + * their own indexes: pruning in one drawer never drops another drawer's + * entries, and the unmount below releases only this drawer's store instead of + * discarding the shared cache for every drawer. Cache keys are the session + * objects themselves, so clearing on unmount drops all strong session + * references together with the retained haystack characters. + */ +export function useSessionHistorySearch(): SessionHistorySearch { + const search = useMemo(() => createSessionHistorySearch(), []); + useEffect(() => () => { + search.clearSessionHistorySearchCache(); + }, [search]); + return search; +} diff --git a/application/state/useSettingsState.ts b/application/state/useSettingsState.ts index 948b33bc7..effeb8af7 100644 --- a/application/state/useSettingsState.ts +++ b/application/state/useSettingsState.ts @@ -151,6 +151,7 @@ import { isValidUiFontId, isValidUiThemeId, migrateIncomingTerminalFontId, + migrateMaterializedAutoCloseOnExitDefault, readStoredString, serializeTerminalSettings, type HostClickBehavior, @@ -355,6 +356,9 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable passwordVerifier: null, }); const [terminalSettings, setTerminalSettingsState] = useState(() => { + // One-time reset of the pre-flip materialized autoCloseOnExit: true before + // it is ingested; afterwards stored values are the user's own and win. + migrateMaterializedAutoCloseOnExitDefault(); const stored = localStorageAdapter.read(STORAGE_KEY_TERM_SETTINGS); return normalizeTerminalSettings(stored); }); diff --git a/application/state/useTerminalBackend.ts b/application/state/useTerminalBackend.ts index f106f3dd0..8288a16b6 100644 --- a/application/state/useTerminalBackend.ts +++ b/application/state/useTerminalBackend.ts @@ -133,6 +133,18 @@ export const useTerminalBackend = () => { return bridge.startSSHSession(options); }, []); + const testConnection = useCallback(async (options: NetcattySSHOptions) => { + const bridge = netcattyBridge.get(); + if (!bridge?.testConnection) throw new Error("testConnection unavailable"); + return bridge.testConnection(options); + }, []); + + const cancelTestConnection = useCallback(async (sessionId: string) => { + const bridge = netcattyBridge.get(); + if (!bridge?.cancelTestConnection) return { success: false, error: "cancelTestConnection unavailable" }; + return bridge.cancelTestConnection(sessionId); + }, []); + const startTelnetSession = useCallback(async (options: Parameters>[0]) => { const bridge = netcattyBridge.get(); if (!bridge?.startTelnetSession) throw new Error("startTelnetSession unavailable"); @@ -572,6 +584,8 @@ export const useTerminalBackend = () => { execAvailable, openExternalAvailable, startSSHSession, + testConnection, + cancelTestConnection, startTelnetSession, startMoshSession, startEtSession, @@ -655,6 +669,8 @@ export const useTerminalBackend = () => { execAvailable, openExternalAvailable, startSSHSession, + testConnection, + cancelTestConnection, startTelnetSession, startMoshSession, startEtSession, diff --git a/application/state/useVaultAgentBridge.ts b/application/state/useVaultAgentBridge.ts index 9b66a4c4d..7dac5c429 100644 --- a/application/state/useVaultAgentBridge.ts +++ b/application/state/useVaultAgentBridge.ts @@ -53,6 +53,7 @@ export interface UseVaultAgentBridgeInput { stopTunnel: VaultAgentApiDeps['stopTunnel']; stopRuleTunnels: VaultAgentApiDeps['stopRuleTunnels']; openHost?: VaultAgentApiDeps['openHost']; + findExistingSessionForHost?: VaultAgentApiDeps['findExistingSessionForHost']; closeSession?: VaultAgentApiDeps['closeSession']; getScriptSessionMeta?: (sessionId: string) => Pick | undefined; } @@ -271,6 +272,7 @@ export function useVaultAgentBridge(input: UseVaultAgentBridgeInput): void { openHost: current.openHost ? (host, isExternalMcpCall) => current.openHost!(host, isExternalMcpCall) : undefined, + findExistingSessionForHost: current.findExistingSessionForHost, closeSession: current.closeSession ? (sessionId) => current.closeSession!(sessionId) : undefined, diff --git a/application/state/useVaultState.ts b/application/state/useVaultState.ts index 2e1ae020a..9f699a0ef 100644 --- a/application/state/useVaultState.ts +++ b/application/state/useVaultState.ts @@ -80,6 +80,7 @@ import { registerNotesActions, } from "./notesStore"; import { commitVaultNotesWrite } from "./vaultNotesPersistence"; +import { rebasePendingVaultNotes } from "../../domain/vaultNotesReconciliation"; import { publishShellHistorySnapshot } from "./shellHistoryStore"; import { setVaultInitialized } from "./vaultInitStore"; import { notify } from "../notification"; @@ -306,6 +307,12 @@ export const useVaultState = () => { const snippetsRef = useRef([]); const groupConfigsRef = useRef([]); const notesRef = useRef([]); + const notesStorageRef = useRef([]); + // An optimistic React echo is not a persistence acknowledgement. Keep this + // owner until that exact generation has successfully reached storage. + const notesPendingRef = useRef(null); + // Failed clear/import keeps full-snapshot ownership across later local edits. + const notesPendingReplaceRef = useRef(false); const noteGroupsRef = useRef([]); const notesPersistFailureNotifiedAtRef = useRef(0); customGroupsRef.current = customGroups; @@ -834,17 +841,37 @@ export const useVaultState = () => { localStorageAdapter.write(STORAGE_KEY_SNIPPET_PACKAGES, data); }, []); - const updateNotes = useCallback((data: Partial[]) => { + const updateNotes = useCallback((data: Partial[], options?: { replace?: boolean }) => { + const stored = normalizeVaultNotes(localStorageAdapter.read(STORAGE_KEY_NOTES) ?? []); + const requested = normalizeVaultNotes(data); + // Explicit clear/restore keeps its existing full-snapshot semantics. + const replace = options?.replace === true || notesPendingReplaceRef.current; + const pending = replace ? requested : rebasePendingVaultNotes({ + base: notesStorageRef.current, + ours: requested, + theirs: stored, + }); + notesStorageRef.current = stored; + notesPendingRef.current = pending; + notesPendingReplaceRef.current = replace; + notesRef.current = pending; const { notes: cleaned, persisted } = commitVaultNotesWrite({ - data, + data: pending, write: (key, value) => localStorageAdapter.write(key, value), }); // Keep the in-session catalog updated so an autosave quota failure does not // snap the editor back to stale props and discard the user's draft. Disk // may still be behind — surface that explicitly. - notesRef.current = cleaned; - setNotes(cleaned); - publishNotesSnapshot({ notes: cleaned, noteGroups: noteGroupsRef.current }); + if (notesPendingRef.current === pending) { + notesRef.current = cleaned; + if (persisted) { + notesStorageRef.current = cleaned; + notesPendingRef.current = null; + notesPendingReplaceRef.current = false; + } + setNotes(cleaned); + publishNotesSnapshot({ notes: cleaned, noteGroups: noteGroupsRef.current }); + } if (!persisted) { const now = Date.now(); // Debounced autosave can hit quota repeatedly; avoid toast spam. @@ -1163,7 +1190,7 @@ export const useVaultState = () => { updateProxyProfiles([]); updateSnippets([], { replace: true }); updateSnippetPackages([]); - updateNotes([]); + updateNotes([], { replace: true }); updateNoteGroups([]); updateCustomGroups([]); updateKnownHosts([]); @@ -1534,8 +1561,10 @@ export const useVaultState = () => { if (savedGroups) setCustomGroups(savedGroups); if (savedSnippetPackages) setSnippetPackages(savedSnippetPackages); - if (savedNotes) { + if (savedNotes && notesPendingRef.current === null) { const cleanedNotes = normalizeVaultNotes(savedNotes); + notesStorageRef.current = cleanedNotes; + notesRef.current = cleanedNotes; setNotes(cleanedNotes); localStorageAdapter.write(STORAGE_KEY_NOTES, cleanedNotes); } @@ -1780,8 +1809,21 @@ export const useVaultState = () => { } if (key === STORAGE_KEY_NOTES) { - const next = safeParse(event.newValue) ?? []; - setNotes(normalizeVaultNotes(next)); + // Like snippets, ignore queued events for a snapshot no longer on disk. + if (event.newValue !== localStorageAdapter.readString(STORAGE_KEY_NOTES)) return; + // Only a successful current write can release an outstanding replacement. + if (notesPendingReplaceRef.current) return; + const next = normalizeVaultNotes(safeParse(event.newValue) ?? []); + const pending = notesPendingRef.current; + const visible = pending === null ? next : rebasePendingVaultNotes({ + base: notesStorageRef.current, + ours: pending, + theirs: next, + }); + notesStorageRef.current = next; + if (pending !== null) notesPendingRef.current = visible; + notesRef.current = visible; + setNotes(visible); return; } @@ -1952,7 +1994,7 @@ export const useVaultState = () => { } if (payload.customGroups) updateCustomGroups(payload.customGroups); if (payload.snippetPackages) updateSnippetPackages(payload.snippetPackages); - if (payload.notes) updateNotes(payload.notes); + if (payload.notes) updateNotes(payload.notes, { replace: true }); if (payload.noteGroups) updateNoteGroups(payload.noteGroups); if (payload.knownHosts) updateKnownHosts(payload.knownHosts); if (Array.isArray(payload.groupConfigs)) encryptedWrites.push(updateGroupConfigs(payload.groupConfigs)); diff --git a/application/syncPayload.test.ts b/application/syncPayload.test.ts index f9ff7a322..5a32b77a8 100644 --- a/application/syncPayload.test.ts +++ b/application/syncPayload.test.ts @@ -1667,6 +1667,50 @@ test("terminal auto-close preference survives sync round-trip", async () => { assert.equal(restored.autoCloseOnExit, false); }); +test("inbound sync does not resurrect the materialized auto-close default after the marker", async () => { + localStorage.setItem(storageKeys.STORAGE_KEY_TERM_AUTO_CLOSE_EXIT_MIGRATED, "true"); + try { + localStorage.setItem( + storageKeys.STORAGE_KEY_TERM_SETTINGS, + JSON.stringify({ autoCloseOnExit: false, scrollback: 3000 }), + ); + + await applySyncPayload({ + hosts: [], + keys: [], + identities: [], + snippets: [], + customGroups: [], + syncedAt: 1, + settings: { terminalSettings: { autoCloseOnExit: true } }, + }, { importVaultData: () => {} }); + + const resurrected = JSON.parse(localStorage.getItem(storageKeys.STORAGE_KEY_TERM_SETTINGS)!); + assert.equal(resurrected.autoCloseOnExit, false); + assert.equal(resurrected.scrollback, 3000); + + // An explicit opt-in made after the migration round-trips with a marker. + localStorage.setItem( + storageKeys.STORAGE_KEY_TERM_SETTINGS, + JSON.stringify({ autoCloseOnExit: true, scrollback: 3000 }), + ); + const optedIn = buildSyncPayload(vault()); + assert.equal(optedIn.settings?.autoCloseOnExitDefaultMigrated, true); + assert.equal(optedIn.settings?.terminalSettings?.autoCloseOnExit, true); + + localStorage.setItem( + storageKeys.STORAGE_KEY_TERM_SETTINGS, + JSON.stringify({ autoCloseOnExit: false, scrollback: 3000 }), + ); + await applySyncPayload(optedIn, { importVaultData: () => {} }); + const restoredChoice = JSON.parse(localStorage.getItem(storageKeys.STORAGE_KEY_TERM_SETTINGS)!); + assert.equal(restoredChoice.autoCloseOnExit, true); + assert.equal(localStorage.getItem(storageKeys.STORAGE_KEY_TERM_AUTO_CLOSE_EXIT_MIGRATED), "true"); + } finally { + localStorage.removeItem(storageKeys.STORAGE_KEY_TERM_AUTO_CLOSE_EXIT_MIGRATED); + } +}); + test("terminal disconnected notice preference survives sync round-trip", async () => { localStorage.setItem( storageKeys.STORAGE_KEY_TERM_SETTINGS, diff --git a/application/syncPayload.ts b/application/syncPayload.ts index da93face6..34eb69379 100644 --- a/application/syncPayload.ts +++ b/application/syncPayload.ts @@ -25,6 +25,7 @@ import { SYNC_PAYLOAD_ENTITY_KEYS, SYNC_STORAGE_KEYS, hasSyncPayloadEntityData, + reconcileInboundAutoCloseOnExit, retainLocalHostLastConnectedAt, sanitizeHostsForSync, type SyncPayload, @@ -62,6 +63,7 @@ import { } from './state/customAccentSync'; import { isValidHslToken } from './state/settingsStateDefaults'; import { + STORAGE_KEY_TERM_AUTO_CLOSE_EXIT_MIGRATED, STORAGE_KEY_THEME, STORAGE_KEY_UI_THEME_LIGHT, STORAGE_KEY_UI_THEME_DARK, @@ -505,6 +507,9 @@ export function collectSyncableSettings(): SyncPayload['settings'] { if (Object.keys(subset).length > 0) settings.terminalSettings = subset; } catch { /* ignore corrupt data */ } } + if (localStorageAdapter.readBoolean(STORAGE_KEY_TERM_AUTO_CLOSE_EXIT_MIGRATED)) { + settings.autoCloseOnExitDefaultMigrated = true; + } // Custom terminal themes const customThemesRaw = localStorageAdapter.readString(STORAGE_KEY_CUSTOM_THEMES); @@ -795,6 +800,23 @@ async function applySyncableSettings( ? 'disabled' : 'paste'; } + if ('autoCloseOnExit' in settings.terminalSettings) { + merged.autoCloseOnExit = reconcileInboundAutoCloseOnExit( + settings.terminalSettings.autoCloseOnExit, + settings.autoCloseOnExitDefaultMigrated, + ); + } + // A legacy true is the old default. A migrated peer's explicit choice must + // stick, so record the marker before the next local migration pass. + if ( + settings.autoCloseOnExitDefaultMigrated === true + || ( + settings.terminalSettings.autoCloseOnExit === true + && settings.autoCloseOnExitDefaultMigrated !== true + ) + ) { + localStorageAdapter.writeBoolean(STORAGE_KEY_TERM_AUTO_CLOSE_EXIT_MIGRATED, true); + } localStorageAdapter.writeString(STORAGE_KEY_TERM_SETTINGS, JSON.stringify(merged)); } diff --git a/components/AIChatPanelContent.tsx b/components/AIChatPanelContent.tsx index 546db5062..78f2593f0 100644 --- a/components/AIChatPanelContent.tsx +++ b/components/AIChatPanelContent.tsx @@ -1,5 +1,5 @@ import React, { type Dispatch, type SetStateAction } from 'react'; -import { History, Plus } from 'lucide-react'; +import { History, Plus, Undo2 } from 'lucide-react'; import type { AIPermissionMode, AISession, ChatMessage, DiscoveredAgent, ExternalAgentConfig, AgentModelPreset, ProviderConfig, UploadedFile } from '../infrastructure/ai/types'; import type { Host, VaultNote } from '../types'; import type { UserSkillOption } from './ai/userSkillsState'; @@ -57,6 +57,9 @@ interface AIChatPanelContentProps { setInputValue: (value: string) => void; handleSend: () => void; handleCompact: () => void; + /** Undo last turn is offered at a safe boundary (never mid-tool / mid-compaction). */ + canUndoLastTurn?: boolean; + handleUndoLastTurn?: () => void; canCompact?: boolean; handleSteer: () => void; handleStop: () => void; @@ -68,6 +71,10 @@ interface AIChatPanelContentProps { providerDisplayName?: string; modelDisplayName?: string; modelCatalogWarning?: string; + /** Live SDK model catalog is loading (no built-in presets to show yet). */ + modelCatalogLoading?: boolean; + /** Retry a failed SDK model catalog load from the warning banner. */ + handleModelCatalogRetry?: () => void; agentModelPresets: AgentModelPreset[]; /** Suppress the composer picker's "use custom model" action (locked model). */ allowCustomModelEntry?: boolean; @@ -131,9 +138,12 @@ export const AIChatPanelContent: React.FC = ({ setInputValue, handleSend, handleCompact, + canUndoLastTurn = false, + handleUndoLastTurn, canCompact = false, handleSteer, handleStop, + handleModelCatalogRetry, canSteer, isSteering, steerWarning, @@ -142,6 +152,7 @@ export const AIChatPanelContent: React.FC = ({ providerDisplayName, modelDisplayName, modelCatalogWarning, + modelCatalogLoading = false, agentModelPresets, allowCustomModelEntry = true, selectedAgentModel, @@ -229,6 +240,23 @@ export const AIChatPanelContent: React.FC = ({ onManageAgents={handleOpenSettings} />
+ {handleUndoLastTurn && ( + + + + + {t('ai.chat.undoLastTurn')} + + )} = ({ {steerWarning}
) : modelCatalogWarning ? ( -
- {modelCatalogWarning} +
+ {modelCatalogWarning} + {handleModelCatalogRetry && ( + + )} +
+ ) : modelCatalogLoading ? ( +
+ {t('ai.chat.loadingModels')}
) : null} = ({ modelName={modelDisplayName} agentName={currentAgentId === 'catty' ? 'Catty Agent' : externalAgents.find(a => a.id === currentAgentId)?.name} modelPresets={agentModelPresets} + modelCatalogLoading={modelCatalogLoading} allowCustomModelEntry={allowCustomModelEntry} selectedModelId={selectedAgentModel} onModelSelect={handleAgentModelSelect} diff --git a/components/AIChatSessionHistoryDrawer.test.tsx b/components/AIChatSessionHistoryDrawer.test.tsx new file mode 100644 index 000000000..29a38a17f --- /dev/null +++ b/components/AIChatSessionHistoryDrawer.test.tsx @@ -0,0 +1,126 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import React from "react"; + +import type { AISession } from "../infrastructure/ai/types.ts"; +import { MESSAGES_BY_LOCALE } from "../application/i18n/messages.ts"; +import { createDomRenderer, dispatchDomEvent, installDomEnvironment } from "./test-support/renderReactDom.tsx"; + +test("history drawer filters, pages, clears and explains the bounded search in every locale", async () => { + const dom = installDomEnvironment(); + const { SessionHistoryDrawer } = await import("./AIChatSessionHistoryDrawer.tsx"); + const { I18nProvider } = await import("../application/i18n/I18nProvider.tsx"); + const { TooltipProvider } = await import("./ui/tooltip.tsx"); + const renderer = await createDomRenderer(dom.document); + const sessions: AISession[] = Array.from({ length: 141 }, (_, i) => ({ + id: `s-${i}`, + title: `Session ${i}`, + agentId: "catty", + scope: { type: "terminal", targetId: "terminal-1" }, + messages: [{ + id: `m-${i}`, role: "user", timestamp: 0, + content: i === 140 ? "needle [prod].json" : "ordinary content", + }], + createdAt: 0, + updatedAt: 0, + })); + const selected: string[] = []; + const deleted: string[] = []; + let closed = 0; + const render = (items = sessions, locale = "en") => renderer.render( + + + selected.push(id)} + onDelete={(event, id) => { event.stopPropagation(); deleted.push(id); }} + onClose={() => { closed += 1; }} + /> + + , + ); + const rows = () => renderer.container.querySelectorAll('[role="button"]'); + const click = async (element: Element | null | undefined) => { + assert.ok(element); + await dispatchDomEvent(element, new dom.window.MouseEvent("click", { bubbles: true })); + }; + const enterQuery = async (query: string) => { + const input = renderer.container.querySelector("input"); + assert.ok(input); + const setValue = Object.getOwnPropertyDescriptor(dom.window.HTMLInputElement.prototype, "value")!.set!; + setValue.call(input, query); + await dispatchDomEvent(input, new dom.window.Event("input", { bubbles: true })); + }; + const loadMore = () => [...renderer.container.querySelectorAll("button")] + .find((button) => button.textContent?.includes("Load more")); + + try { + await render(); + assert.equal(rows().length, 80); + assert.match(loadMore()?.textContent ?? "", /61/); + await click(loadMore()); + assert.equal(rows().length, 140); + await enterQuery("[prod].json"); + assert.equal(rows().length, 1); + assert.match(rows()[0].textContent ?? "", /Session 140/); + assert.equal(loadMore(), undefined); + await click(rows()[0]); + await dispatchDomEvent(rows()[0], new dom.window.KeyboardEvent("keydown", { key: "Enter", bubbles: true })); + await click(rows()[0].querySelector("button")); + assert.deepEqual(selected, ["s-140", "s-140"]); + assert.deepEqual(deleted, ["s-140"]); + + // A pasted suffix must not remain visible while the matcher silently + // ignores it: the controlled input and search accept the same prefix. + const pastedQuery = "needle ".repeat(36) + "needle absent-tail"; + await enterQuery(pastedQuery); + const boundedInput = renderer.container.querySelector("input")!; + assert.equal(boundedInput.value, pastedQuery.slice(0, 256)); + assert.equal(boundedInput.maxLength, 256); + assert.ok(!boundedInput.value.includes("absent-tail")); + assert.equal(rows().length, 1); + assert.match(rows()[0].textContent ?? "", /Session 140/); + await enterQuery("needle absent-tail"); + assert.equal(boundedInput.value, "needle absent-tail"); + assert.equal(rows().length, 0, "every visible query token must affect matching"); + await enterQuery("x".repeat(255) + "\u{1f63a}"); + assert.equal(boundedInput.value, "x".repeat(255), "do not retain half a surrogate pair"); + + await enterQuery("[](){}"); + assert.equal(rows().length, 0); + assert.match(renderer.container.textContent ?? "", /No matches in titles or recent retained content/); + assert.match(renderer.container.textContent ?? "", /prioritizing newer messages and entries/); + assert.match(renderer.container.textContent ?? "", /only a limited portion at the beginning is searched/); + assert.match(renderer.container.textContent ?? "", /not full-text search/); + await click(renderer.container.querySelector("input")?.parentElement?.querySelector("button")); + assert.equal(renderer.container.querySelector("input")?.value, ""); + assert.equal(rows().length, 80); + assert.match(loadMore()?.textContent ?? "", /61/); + await enterQuery(" \t"); + assert.equal(rows().length, 80); + await click(renderer.container.querySelector("button")); + assert.equal(closed, 1); + + for (const locale of ["en", "zh-CN", "zh-TW", "es", "ru"]) { + await render(sessions, locale); + await enterQuery("no-such-content"); + const messages = MESSAGES_BY_LOCALE[locale]; + const input = renderer.container.querySelector("input")!; + const hint = dom.document.getElementById(input.getAttribute("aria-describedby")!); + for (const key of ["ai.chat.searchSessions", "ai.chat.searchSessionsHint", "ai.chat.noMatchingSessions"]) { + assert.ok(messages[key], `${locale} is missing ${key}`); + } + assert.equal(input.placeholder, messages["ai.chat.searchSessions"]); + assert.equal(input.getAttribute("aria-label"), messages["ai.chat.searchSessions"]); + assert.equal(hint?.textContent, messages["ai.chat.searchSessionsHint"]); + assert.ok(renderer.container.textContent?.includes(messages["ai.chat.noMatchingSessions"])); + await render([], locale); + assert.ok(renderer.container.textContent?.includes(messages["ai.chat.noSessions"])); + assert.ok(!renderer.container.textContent?.includes(messages["ai.chat.noMatchingSessions"])); + } + } finally { + await renderer.unmount(); + dom.cleanup(); + } +}); diff --git a/components/AIChatSessionHistoryDrawer.tsx b/components/AIChatSessionHistoryDrawer.tsx index 1da02128e..36582df4d 100644 --- a/components/AIChatSessionHistoryDrawer.tsx +++ b/components/AIChatSessionHistoryDrawer.tsx @@ -1,11 +1,14 @@ -import React, { useEffect, useState } from 'react'; -import { Trash2, X } from 'lucide-react'; +import React, { useEffect, useId, useRef, useState } from 'react'; +import { Search, Trash2, X } from 'lucide-react'; import type { AISession } from '../infrastructure/ai/types'; import { useI18n } from '../application/i18n/I18nProvider'; import { cn } from '../lib/utils'; +import { MAX_SEARCH_QUERY_LENGTH, truncateQueryForMatch } from '../lib/searchMatcher'; +import { Input } from './ui/input'; import { ScrollArea } from './ui/scroll-area'; import { Tooltip, TooltipContent, TooltipTrigger } from './ui/tooltip'; import { SESSION_HISTORY_ROW_CLASSNAMES } from './ai/sessionHistoryLayout'; +import { useSessionHistorySearch } from '../application/state/useSessionHistorySearch'; // ------------------------------------------------------------------- // Session History Drawer @@ -31,13 +34,28 @@ export const SessionHistoryDrawer: React.FC = ({ }) => { const { t } = useI18n(); const [renderCount, setRenderCount] = useState(SESSION_RENDER_BATCH); + const [searchQuery, setSearchQuery] = useState(''); + const searchHintId = useId(); + const searchInputRef = useRef(null); + // One cache instance per mounted drawer: caches of concurrently mounted + // drawers stay independent (no shared state to prune or discard across tabs). + const search = useSessionHistorySearch(); + const filteredSessions = React.useMemo( + // The localized fallback below is displayed for untitled sessions + // (row label), so it must be indexed for search as the displayed title. + () => search.filterSessionHistory(sessions, searchQuery, { untitledLabel: t('ai.chat.untitled') }), + [sessions, searchQuery, t, search], + ); + + // Reset the render batch when the list scope or query changes so matching + // sessions are never hidden behind stale paging. useEffect(() => { setRenderCount(SESSION_RENDER_BATCH); - }, [sessions]); + }, [sessions, searchQuery]); - const displayedSessions = sessions.slice(0, renderCount); - const hiddenSessionCount = Math.max(0, sessions.length - renderCount); + const displayedSessions = filteredSessions.slice(0, renderCount); + const hiddenSessionCount = Math.max(0, filteredSessions.length - renderCount); return (
@@ -50,6 +68,41 @@ export const SessionHistoryDrawer: React.FC = ({
+
+
+ + setSearchQuery(truncateQueryForMatch(event.target.value))} + className="h-8 pl-8 pr-7 text-[12px]" + /> + {searchQuery && ( + + )} +
+

+ {t('ai.chat.searchSessionsHint')} +

+
{sessions.length === 0 ? ( @@ -58,6 +111,12 @@ export const SessionHistoryDrawer: React.FC = ({ {t('ai.chat.noSessions')}

+ ) : filteredSessions.length === 0 ? ( +
+

+ {t('ai.chat.noMatchingSessions')} +

+
) : ( <> {hiddenSessionCount > 0 && ( diff --git a/components/AIChatSidePanel.mountRetention.test.ts b/components/AIChatSidePanel.mountRetention.test.ts index 292dfb18c..262df0603 100644 --- a/components/AIChatSidePanel.mountRetention.test.ts +++ b/components/AIChatSidePanel.mountRetention.test.ts @@ -120,7 +120,9 @@ test('send preflight aborts after the panel unmounts or the scope changes', () = test('send preflight locks header agent and new-chat controls', () => { const panel = readFileSync(new URL('./AIChatSidePanel.tsx', import.meta.url), 'utf8'); const content = readFileSync(new URL('./AIChatPanelContent.tsx', import.meta.url), 'utf8'); - assert.match(panel, /sending=\{isSending\}/); + // `sending` also covers an in-flight undo-last-turn so the header controls + // (and a quick send) stay locked while the branch materializes. + assert.match(panel, /sending=\{isSending \|\| isUndoingLastTurn\}/); assert.match(content, /disabled=\{sending\}/); const selector = readFileSync(new URL('./ai/AgentSelector.tsx', import.meta.url), 'utf8'); assert.match(selector, /if \(parked \|\| disabled\) setOpen\(false\)/); diff --git a/components/AIChatSidePanel.tsx b/components/AIChatSidePanel.tsx index 834000d15..43826a940 100644 --- a/components/AIChatSidePanel.tsx +++ b/components/AIChatSidePanel.tsx @@ -65,6 +65,7 @@ import { } from '../application/state/useAIChatStreaming'; import { getScopedHistorySessions } from './ai/scopedHistorySessions'; import { resolveInheritedAIActiveSessionId } from '../domain/aiWorkspaceScopeInherit'; +import { resolveUndoLastTurnBoundary } from '../domain/aiUndoLastTurn'; import { aiSessionIdSetEqual, exactScopeAISessionsEqual } from '../domain/aiSessionsForScope'; import { buildExternalAgentHistoryMessagesForBridge } from './ai/externalAgentHistory'; import { canSendWithAgent, findEnabledExternalAgent } from './ai/agentSendEligibility'; @@ -269,6 +270,7 @@ const AIChatSidePanelActive: React.FC = ({ addDraftFiles, removeDraftFile, createSession, + undoLastTurnInSession, deleteSession, updateSessionTitle, updateSessionExternalSessionId, @@ -320,8 +322,17 @@ const AIChatSidePanelActive: React.FC = ({ const [showHistory, setShowHistory] = useState(false); const [isSending, setIsSending] = useState(false); + // Undo-last-turn is still + // running. Unlike the send flow this is not reflected in `isSending`, so it + // is tracked separately to block sends until the branch settles: a turn + // submitted meanwhile would run against the (soon hidden) source session. + const [isUndoingLastTurn, setIsUndoingLastTurn] = useState(false); const [runtimeAgentModelPresets, setRuntimeAgentModelPresets] = useState>({}); const [runtimeModelWarnings, setRuntimeModelWarnings] = useState>({}); + // Tracks SDK runtime model catalogs currently loading (per agent scope): + // while an OpenCode catalog is pending we must not flash the built-in + // OPENCODE_MODEL_PRESETS for providers the user never configured (#3584). + const [runtimeModelLoading, setRuntimeModelLoading] = useState>({}); const [steerWarnings, setSteerWarnings] = useState>({}); const [steeringSessionId, setSteeringSessionId] = useState(null); const [userSkillOptions, setUserSkillOptions] = useState([]); @@ -818,6 +829,78 @@ const AIChatSidePanelActive: React.FC = ({ && Boolean(effectiveActiveProvider) && Boolean(effectiveActiveModelId.trim()); + // Undo last turn: offered only at a safe boundary (never inside a compacted + // prefix, never between an assistant tool call and its result). + const canUndoLastTurn = useMemo(() => Boolean( + activeSession + && !isStreaming + && resolveUndoLastTurnBoundary( + activeSession.messages, + activeSession.contextCompaction?.compactedMessageCount ?? 0, + ) != null, + ), [activeSession, isStreaming]); + + const undoInFlightRef = useRef(false); + const handleUndoLastTurn = useCallback(async () => { + const sessionId = activeSessionRef.current?.id; + if (!sessionId || !undoLastTurnInSession) return; + if (isStreaming || isAIChatSessionStreaming(sessionId)) return; + // Serialize: while a branch is still being created, `canUndoLastTurn` stays stale, so a quick second + // click must not start a parallel undo of the same session — that would + // publish duplicate branches and restore the same prompt twice. + if (undoInFlightRef.current) return; + undoInFlightRef.current = true; + setIsUndoingLastTurn(true); + try { + const result = await undoLastTurnInSession(sessionId); + if (!result) return; + + ensureScopeDraft(currentAgentId); + // Text typed since the last flush lives only in the live composer buffer + // (pendingComposerTextRef/currentDraftRef), not in the persisted draft; + // merge from the live buffer so it is not lost, then drop the pending + // buffer so the later flushDraftText() cannot overwrite the merged text. + const liveDraft = currentDraftRef.current; + updateScopeDraft(currentAgentId, (draft) => { + const liveText = liveDraft?.text ?? draft.text; + const liveSkillSlugs = liveDraft?.selectedUserSkillSlugs ?? draft.selectedUserSkillSlugs; + return { + ...draft, + // Keep anything the user is still typing; the undone prompt goes first. + text: liveText.trim() + ? `${result.restored.text}\n\n${liveText}` + : result.restored.text, + attachments: [ + ...result.restored.attachments, + ...(liveDraft?.attachments ?? draft.attachments), + ], + // The undone turn was selected with these pills; keep any pills the + // user picked since the send on top of them. + selectedUserSkillSlugs: [ + ...result.restored.selectedUserSkillSlugs, + ...liveSkillSlugs.filter((slug) => !result.restored.selectedUserSkillSlugs.includes(slug)), + ], + }; + }); + discardPendingComposerText(); + showScopeSessionView(result.sessionId); + // Tool side effects live outside the conversation and cannot be rolled back. + toast.info(t('ai.chat.undoLastTurnNotice')); + } finally { + undoInFlightRef.current = false; + setIsUndoingLastTurn(false); + } + }, [ + discardPendingComposerText, + ensureScopeDraft, + isStreaming, + showScopeSessionView, + t, + undoLastTurnInSession, + updateScopeDraft, + currentAgentId, + ]); + const providersRef = useRef(providers); providersRef.current = providers; @@ -934,6 +1017,14 @@ const AIChatSidePanelActive: React.FC = ({ : { ...prev, [agentId]: { cacheKey: target.cacheKey, models: runtimePresets } } )); } + // Another path (e.g. send preflight) applied the catalog before the + // deferred idle callback scheduled in the visibility effect: clear its + // stale pending flag so the composer stops claiming models are loading. + setRuntimeModelLoading((prev) => { + if (!prev[agentId] || prev[agentId].cacheKey !== target.cacheKey) return prev; + const { [agentId]: _removed, ...rest } = prev; + return rest; + }); const normalizedStoredModelId = normalizeStoredAgentModelSelection( storedModelId, @@ -951,7 +1042,7 @@ const AIChatSidePanelActive: React.FC = ({ ) { setAgentModel(agentId, catalog.currentModelId); } - }, [setAgentModel, buildExternalAgentRuntimeModelTarget, collectCustomModelIds]); + }, [setAgentModel, setRuntimeModelLoading, buildExternalAgentRuntimeModelTarget, collectCustomModelIds]); const loadSdkRuntimeModelCatalog = useCallback(( target: SdkRuntimeModelTarget, @@ -1030,17 +1121,46 @@ const AIChatSidePanelActive: React.FC = ({ // even when the user never selected OpenCode (#2184). Manual refresh still // passes force via the model selector path. Defer the network refresh so // expand → first type does not wait on CLI model listing. + const clearRuntimeModelLoading = () => { + setRuntimeModelLoading((prev) => { + if (!prev[target.agentId] || prev[target.agentId].cacheKey !== target.cacheKey) return prev; + const { [target.agentId]: _removed, ...rest } = prev; + return rest; + }); + }; let cancelled = false; + let idleFired = false; + // Mark the catalog pending as soon as the refresh is scheduled: otherwise + // `runtimeModelLoading` stays false for the whole idle delay (and longer + // while the composer stays focused), so the built-in OPENCODE_MODEL_PRESETS + // remain selectable the entire time (#3584). Only do this on a cache miss: + // when a cached catalog was already applied above, the runtime presets are + // available, so a pending flag would keep the composer banner/picker in the + // "loading" state indefinitely while the idle callback is deferred. + if (!cached) { + setRuntimeModelLoading((prev) => ( + prev[target.agentId]?.cacheKey === target.cacheKey ? prev : { ...prev, [target.agentId]: { cacheKey: target.cacheKey } } + )); + } const cancelIdle = scheduleWhenAiComposerIdle(() => { + idleFired = true; void loadSdkRuntimeModelCatalog(target).then((catalog) => { if (cancelled || !catalog) return; applySdkRuntimeModelCatalog(target, catalog, { adoptCurrentModel: true }); + }).finally(() => { + // Always clear, even after cancellation: a stale flag would keep the + // composer in the loading state forever. + clearRuntimeModelLoading(); }); }); return () => { cancelled = true; cancelIdle(); + // If the idle callback never fired, the load never started and nothing + // would clear the pending flag — do it here. In-flight loads clear their + // own flag in `.finally`, so leave them alone. + if (!idleFired) clearRuntimeModelLoading(); }; }, [ isVisible, @@ -1076,6 +1196,22 @@ const AIChatSidePanelActive: React.FC = ({ collectCustomModelIds(currentAgentId, runtimePresets), ); } + // While an OpenCode catalog load is pending, do not flash the built-in + // OPENCODE_MODEL_PRESETS: they enumerate well-known gateway providers the + // user may never have configured, so for a few seconds every switch looks + // like a broken default model (#3584). Show only the user's own custom + // model ids until the live catalog resolves (or a retryable warning + // appears in the composer banner). + if ( + target + && getExternalAgentSdkBackend(currentAgentConfig) === 'opencode' + && runtimeModelLoading[currentAgentId]?.cacheKey === target.cacheKey + ) { + return appendComposerCustomModelPresets( + [], + collectCustomModelIds(currentAgentId, []), + ); + } const presets = getAgentModelPresets( currentAgentConfig?.command, getExternalAgentSdkBackend(currentAgentConfig), @@ -1093,6 +1229,7 @@ const AIChatSidePanelActive: React.FC = ({ currentAgentConfig, currentAgentId, runtimeAgentModelPresets, + runtimeModelLoading, buildExternalAgentRuntimeModelTarget, hasCodexCustomConfig, codexConfigModel, @@ -1120,14 +1257,39 @@ const AIChatSidePanelActive: React.FC = ({ const inputAgentId = activeSession?.agentId ?? currentDraft?.agentId ?? currentAgentId; const canSendCurrentAgent = useMemo( - () => !isSending && canSendWithAgent(inputAgentId, externalAgents), - [inputAgentId, externalAgents, isSending], + () => !isSending && !isUndoingLastTurn && canSendWithAgent(inputAgentId, externalAgents), + [inputAgentId, externalAgents, isSending, isUndoingLastTurn], ); const handleAgentModelSelect = useCallback((modelId: string) => { setAgentModel(currentAgentId, modelId); }, [currentAgentId, setAgentModel]); + // Retry a failed SDK runtime model catalog load (composer warning banner). + // Failures are never cached, so a forced refresh re-queries the CLI/daemon. + const handleRetrySdkModelCatalog = useCallback(() => { + const target = buildExternalAgentRuntimeModelTarget(currentAgentConfig); + if (!target) return; + setRuntimeModelLoading((prev) => ( + prev[target.agentId]?.cacheKey === target.cacheKey ? prev : { ...prev, [target.agentId]: { cacheKey: target.cacheKey } } + )); + void loadSdkRuntimeModelCatalog(target, { force: true }).then((catalog) => { + if (!catalog) return; + applySdkRuntimeModelCatalog(target, catalog, { adoptCurrentModel: true }); + }).finally(() => { + setRuntimeModelLoading((prev) => { + if (!prev[target.agentId] || prev[target.agentId].cacheKey !== target.cacheKey) return prev; + const { [target.agentId]: _removed, ...rest } = prev; + return rest; + }); + }); + }, [ + currentAgentConfig, + buildExternalAgentRuntimeModelTarget, + loadSdkRuntimeModelCatalog, + applySdkRuntimeModelCatalog, + ]); + const selectedCattyThinking = agentThinkingMap.catty; const handleCattyThinkingSelect = useCallback((level: string) => { setAgentThinking('catty', level); @@ -1232,6 +1394,10 @@ const AIChatSidePanelActive: React.FC = ({ })); const hasInlineTextAttachments = attachments.some(isInlineTextAttachment); if ((!trimmed && !hasInlineTextAttachments) || isStreaming) return; + // While undo-last-turn is creating the branch, the target is still the source; a + // turn submitted now would run in the soon-hidden source session, so + // block it until the branch settles. + if (isUndoingLastTurn) return; // Note-only sends (empty text + a mentioned note) still need a usable // session title: fall back to the first mentioned note's title so these // conversations don't all show up as "Untitled" in history. @@ -1356,6 +1522,7 @@ const AIChatSidePanelActive: React.FC = ({ addMessageToSession(sessionId, { id: generateId(), role: 'user', content: trimmed, ...(attachments.length > 0 ? { attachments } : {}), + ...(selectedSkillSlugs.length > 0 ? { selectedUserSkillSlugs: selectedSkillSlugs } : {}), timestamp: Date.now(), }); addMessageToSession(sessionId, { id: generateId(), role: 'assistant', content: t('ai.chat.noProvider'), timestamp: Date.now() }); @@ -1370,6 +1537,7 @@ const AIChatSidePanelActive: React.FC = ({ addMessageToSession(sessionId, { id: generateId(), role: 'user', content: trimmed, ...(attachments.length > 0 ? { attachments } : {}), + ...(selectedSkillSlugs.length > 0 ? { selectedUserSkillSlugs: selectedSkillSlugs } : {}), timestamp: Date.now(), }); addMessageToSession(sessionId, { id: generateId(), role: 'assistant', content: t('ai.chat.noProviderModel'), timestamp: Date.now() }); @@ -1383,6 +1551,8 @@ const AIChatSidePanelActive: React.FC = ({ addMessageToSession(sessionId, { id: generateId(), role: 'user', content: trimmed, ...(attachments.length > 0 ? { attachments } : {}), + // Kept on the user message so undo last turn can restore the pills. + ...(selectedSkillSlugs.length > 0 ? { selectedUserSkillSlugs: selectedSkillSlugs } : {}), timestamp: Date.now(), }); clearScopeDraft({ keepPendingText: keepPendingAfterSend() }); @@ -1464,7 +1634,7 @@ const AIChatSidePanelActive: React.FC = ({ } }, [ validateNoteMentions, loadCodexConfigModel, - isStreaming, activeProvider, effectiveActiveProvider, effectiveActiveModelId, selectedCattyThinking, scopeKey, currentAgentId, + isStreaming, isUndoingLastTurn, activeProvider, effectiveActiveProvider, effectiveActiveModelId, selectedCattyThinking, scopeKey, currentAgentId, activeModelId, externalAgents, createSession, addMessageToSession, updateMessageById, updateLastMessage, setStreamingForScope, @@ -1785,7 +1955,7 @@ const AIChatSidePanelActive: React.FC = ({ > = ({ setInputValue={setInputValue} handleSend={handleSend} handleCompact={handleCompact} + canUndoLastTurn={canUndoLastTurn} + handleUndoLastTurn={undoLastTurnInSession ? handleUndoLastTurn : undefined} handleSteer={handleSteer} handleStop={handleStop} canSteer={canSteerCurrentTurn} @@ -1829,6 +2001,11 @@ const AIChatSidePanelActive: React.FC = ({ && runtimeModelWarnings[currentAgentId].cacheKey === buildExternalAgentRuntimeModelTarget(currentAgentConfig)?.cacheKey ? runtimeModelWarnings[currentAgentId].message : undefined} + modelCatalogLoading={ + getExternalAgentSdkBackend(currentAgentConfig) === 'opencode' + && runtimeModelLoading[currentAgentId]?.cacheKey === buildExternalAgentRuntimeModelTarget(currentAgentConfig)?.cacheKey + } + handleModelCatalogRetry={handleRetrySdkModelCatalog} agentModelPresets={agentModelPresets} // A managed Codex config's `model` field overrides every selection on // send, so the manual-entry action would be a silent no-op. diff --git a/components/AIChatSidePanel.types.ts b/components/AIChatSidePanel.types.ts index 66988122a..3f09e3933 100644 --- a/components/AIChatSidePanel.types.ts +++ b/components/AIChatSidePanel.types.ts @@ -37,6 +37,10 @@ export interface AIChatSidePanelProps { addDraftFiles: (scopeKey: string, fallbackAgentId: string, inputFiles: File[]) => Promise; removeDraftFile: (scopeKey: string, fallbackAgentId: string, fileId: string) => void; createSession: (scope: AISessionScope, agentId?: string) => AISession; + /** Non-destructively branch before the last user turn; returns the branch id + restored composer content. */ + undoLastTurnInSession?: ( + sessionId: string, + ) => Promise<{ sessionId: string; restored: { text: string; attachments: import('../infrastructure/ai/types').UploadedFile[]; selectedUserSkillSlugs: string[] } } | null>; deleteSession: (sessionId: string, scopeKey?: string) => void; updateSessionTitle: (sessionId: string, title: string) => void; updateSessionExternalSessionId: (sessionId: string, externalSessionId: string | undefined) => void; diff --git a/components/GlobalSftpTransferCenter.test.ts b/components/GlobalSftpTransferCenter.test.ts index 96e0762e8..14e681038 100644 --- a/components/GlobalSftpTransferCenter.test.ts +++ b/components/GlobalSftpTransferCenter.test.ts @@ -2,6 +2,7 @@ import assert from "node:assert/strict"; import test from "node:test"; import type { TransferTask } from "../domain/models"; +import { listGloballyCancellableTransferIds } from "../domain/sftpTransferActions"; import { buildGlobalTransferProgressDisplay, getGlobalConflictActionPresentation, @@ -65,14 +66,28 @@ test("batch actions are disabled when no task can accept them", () => { assert.deepEqual(getGlobalTransferBatchEligibility([ task("done", "completed"), task("cancelled", "cancelled"), - ]), { pausableCount: 0, resumableCount: 0 }); + ]), { cancellableCount: 0, pausableCount: 0, resumableCount: 0 }); assert.deepEqual(getGlobalTransferBatchEligibility([ task("running", "transferring"), task("waiting", "queued"), task("paused", "paused"), { ...task("failed", "failed"), checkpointBytes: 4 }, - ]), { pausableCount: 2, resumableCount: 2 }); + ]), { cancellableCount: 3, pausableCount: 2, resumableCount: 2 }); +}); + +test("cancel all selects unfinished top-level tasks without touching completed files", () => { + const statuses: TransferTask["status"][] = [ + "pending", "queued", "transferring", "pausing", "paused", "interrupted", "attention", + ]; + const tasks = [ + ...statuses.map((status) => task(status, status)), + task("completed", "completed"), + task("failed", "failed"), + task("cancelled", "cancelled"), + { ...task("child", "queued"), parentTaskId: "pending" }, + ]; + assert.deepEqual(listGloballyCancellableTransferIds(tasks), statuses); }); test("paused transfer errors remain visible instead of looking safely paused", () => { diff --git a/components/GlobalSftpTransferCenter.tsx b/components/GlobalSftpTransferCenter.tsx index d24af2109..9f0f4d1a3 100644 --- a/components/GlobalSftpTransferCenter.tsx +++ b/components/GlobalSftpTransferCenter.tsx @@ -837,7 +837,7 @@ export function GlobalSftpTransferCenter() { .sort((a, b) => (b.priority ?? 0) - (a.priority ?? 0) || b.startTime - a.startTime), [bucket, snapshot.tasks]); const { visible, collapsed } = splitBackgroundTransfers(bucketTasks); const displayed = showBackground ? [...visible, ...collapsed] : visible; - const { batchEligibility, pauseAll, resumeAll } = useGlobalSftpTransferActions(snapshot.tasks); + const { batchEligibility, pauseAll, resumeAll, cancelAll, isCancelling } = useGlobalSftpTransferActions(snapshot.tasks); return ( @@ -877,13 +877,16 @@ export function GlobalSftpTransferCenter() {
{t("sftp.transferCenter.title")}
-
- - +
diff --git a/components/HostDetailsPanel.tsx b/components/HostDetailsPanel.tsx index 98f64e79e..f446c0170 100644 --- a/components/HostDetailsPanel.tsx +++ b/components/HostDetailsPanel.tsx @@ -30,7 +30,7 @@ import { resolveHostTerminalFontSize, resolveHostTerminalThemeId, } from "../domain/terminalAppearance"; -import { EnvVar, GroupConfig, Host, Identity, ManagedSource, ProxyConfig, ProxyProfile, Snippet, SSHKey } from "../types"; +import { EnvVar, GroupConfig, Host, Identity, KnownHost, ManagedSource, ProxyConfig, ProxyProfile, Snippet, SSHKey, TerminalSettings } from "../types"; import { DISTRO_COLORS, DISTRO_LOGOS } from "./DistroAvatar"; import ThemeSelectPanel from "./ThemeSelectPanel"; import { @@ -77,6 +77,8 @@ import { stripBuiltInConnectionFieldsForPluginHost, } from "../domain/pluginConnection"; import { PluginConnectionSection } from "./PluginConnectionSection"; +import { useHostConnectionTest } from "../application/state/useHostConnectionTest"; +import { HostConnectionTestDialog } from "./host-details/HostConnectionTestDialog"; type CredentialType = "sshid" | "key" | "certificate" | "localKeyFile" | null; type SubPanel = @@ -111,6 +113,9 @@ interface HostDetailsPanelProps { snippets?: Snippet[]; onSnippetsChange?: (snippets: Snippet[]) => void; onHostsChange?: (hosts: Host[] | ((prev: Host[]) => Host[])) => void; + knownHosts?: KnownHost[]; + onAddKnownHost?: (knownHost: KnownHost) => void; + terminalSettings?: TerminalSettings; className?: string; } @@ -139,6 +144,9 @@ const HostDetailsPanel: React.FC = ({ snippets = [], onSnippetsChange, onHostsChange, + knownHosts, + onAddKnownHost, + terminalSettings, className, resizable, persistWidthStorageKey, @@ -198,6 +206,28 @@ const HostDetailsPanel: React.FC = ({ const [sshAgentStatus, setSshAgentStatus] = useState(null); const [sshForwardingAgentStatus, setSshForwardingAgentStatus] = useState(null); + const [testOpen, setTestOpen] = useState(false); + const hostConnectionTest = useHostConnectionTest({ + host: form, + hosts: allHosts, + keys: availableKeys, + identities, + knownHosts, + groupConfigs, + proxyProfiles, + terminalSettings, + onAddKnownHost, + }); + + const handleStartTest = () => { + setTestOpen(true); + void hostConnectionTest.startTest(); + }; + + const handleCloseTest = () => { + setTestOpen(false); + }; + useEffect(() => { let cancelled = false; if (form.agentForwarding || form.useSshAgent === true) { @@ -1309,15 +1339,32 @@ const HostDetailsPanel: React.FC = ({ )} - + + {!pluginProtocolActive && form.protocol !== "telnet" && ( + + )} + ); }; diff --git a/components/ScriptsSidePanel.stacked-lifecycle.test.tsx b/components/ScriptsSidePanel.stacked-lifecycle.test.tsx new file mode 100644 index 000000000..afea6a75b --- /dev/null +++ b/components/ScriptsSidePanel.stacked-lifecycle.test.tsx @@ -0,0 +1,73 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import React from "react"; +import { createDomRenderer, dispatchDomEvent, installDomEnvironment, runWithAct } from "./test-support/renderReactDom.tsx"; +import type { Snippet } from "../types"; + +test("stacked library continues loading after running a script and returning", async (t) => { + const env = installDomEnvironment(); + const previous = Object.getOwnPropertyDescriptor(globalThis, "IntersectionObserver"); + const previousStorage = Object.getOwnPropertyDescriptor(globalThis, "localStorage"); + Object.defineProperty(globalThis, "localStorage", { configurable: true, value: env.window.localStorage }); + const observers: Array<{ target: Element | null; intersect: () => void }> = []; + class Observer { + target: Element | null = null; + constructor(private callback: IntersectionObserverCallback) { observers.push(this); } + observe(target: Element) { this.target = target; } + disconnect() { this.target = null; } + intersect = () => { + if (this.target?.isConnected) { + this.callback([{ target: this.target, isIntersecting: true } as IntersectionObserverEntry], this as unknown as IntersectionObserver); + } + }; + } + Object.defineProperty(globalThis, "IntersectionObserver", { configurable: true, value: Observer }); + const { ScriptsSidePanel } = await import("./ScriptsSidePanel.tsx"); + const { I18nProvider } = await import("../application/i18n/I18nProvider.tsx"); + const { SCRIPTS_VIEW_MODE_STORAGE_KEY } = await import("../application/state/useScriptsViewMode.ts"); + env.window.localStorage.setItem(SCRIPTS_VIEW_MODE_STORAGE_KEY, "stacked"); + const renderer = await createDomRenderer(env.document); + t.after(async () => { + await renderer.unmount(); + if (previous) Object.defineProperty(globalThis, "IntersectionObserver", previous); + else Reflect.deleteProperty(globalThis, "IntersectionObserver"); + if (previousStorage) Object.defineProperty(globalThis, "localStorage", previousStorage); + else Reflect.deleteProperty(globalThis, "localStorage"); + env.cleanup(); + }); + const snippets: Snippet[] = Array.from({ length: 300 }, (_, i) => ({ + id: `script-${i}`, label: `Script ${String(i).padStart(3, "0")}`, command: "echo ok", kind: "script", package: "", + })); + const executed: string[] = []; + await renderer.render( + assert.fail("scripts use the existing script execution path")} + onRunScript={(snippet) => executed.push(snippet.id)} />, + ); + const library = () => env.document.querySelector('[data-scripts-view="stacked"]'); + const chipCount = () => library()?.querySelectorAll("button").length; + const button = (label: string) => { + const result = Array.from(env.document.querySelectorAll("button")).find((element) => element.textContent === label); + assert.ok(result, `missing button ${label}`); + return result; + }; + assert.equal(chipCount(), 120); + const firstSentinel = observers.at(-1)?.target; + assert.ok(firstSentinel?.isConnected); + await dispatchDomEvent(button("Script 000"), new env.window.MouseEvent("click", { bubbles: true })); + assert.deepEqual(executed, ["script-0"]); + assert.equal(library(), null, "execution opens Running"); + assert.equal(firstSentinel.isConnected, false); + await dispatchDomEvent(button("Library"), new env.window.MouseEvent("click", { bubbles: true })); + assert.equal(chipCount(), 120); + assert.ok(observers.at(-1)?.target?.isConnected, "the new sentinel must be observed"); + assert.notEqual(observers.at(-1)?.target, firstSentinel); + await runWithAct(() => { observers.at(-1)?.intersect(); }); + assert.equal(chipCount(), 240); + await dispatchDomEvent(button("Running"), new env.window.MouseEvent("click", { bubbles: true })); + await dispatchDomEvent(button("Library"), new env.window.MouseEvent("click", { bubbles: true })); + assert.equal(chipCount(), 240, "returning preserves the loaded window"); + await runWithAct(() => { observers.at(-1)?.intersect(); }); + assert.equal(chipCount(), 300); + assert.ok(button("Script 299")); +}); diff --git a/components/ScriptsSidePanel.test.ts b/components/ScriptsSidePanel.test.ts index 5d9931f91..f6f91ca45 100644 --- a/components/ScriptsSidePanel.test.ts +++ b/components/ScriptsSidePanel.test.ts @@ -174,3 +174,31 @@ test("scripts side panel does not start a drag from a non-primary pointer", () = assert.match(source, /isNonPrimaryPointer/); assert.match(source, /primaryOnlyDragHandlers/); }); + +test("scripts side panel offers a stacked view toggle that persists the choice", () => { + assert.match(source, /useScriptsViewMode/); + const toggle = source.match(/viewStacked[\s\S]*?\{viewMode === 'stacked' \? : \}/); + assert.ok(toggle, "view toggle button should swap list / stacked icons"); + assert.match(source, /aria-pressed=\{viewMode === 'stacked'\}/); + // The chosen mode persists via the shared hook so it survives app restarts. + assert.match(source, /useScriptsViewMode\(\)/); + assert.match(source, /setViewMode\(viewMode === 'stacked' \? 'list' : 'stacked'\)/); +}); + +test("scripts side panel stacked view renders wrap-around chips and keeps run behavior", () => { + const stacked = source.match(/viewMode === 'stacked' \? \([\s\S]*?\) : \(/); + assert.ok(stacked, "stacked branch should exist in the content area"); + assert.match(stacked[0], /flex flex-wrap/); + // Chips reuse the same click handler (multi-select / run / execute) as rows. + assert.match(stacked[0], /onClick=\{\(\) => handleSnippetClick\(snippet\)\}/); + // Packages stay visible as section headers with their counts. + assert.match(stacked[0], /item\.row\.name/); + assert.match(stacked[0], /item\.countLabel/); + assert.match(source, /const SnippetChip = memo/); + // The stacked chip keeps the same context menu contract (edit / delete / run). + const chip = source.match(/const SnippetChip = memo[\s\S]*?SnippetChip\.displayName/); + assert.ok(chip, "SnippetChip component should exist"); + assert.match(chip[0], /ContextMenuContent/); + assert.match(chip[0], /onRunParallel/); + assert.match(chip[0], /onEdit/); +}); diff --git a/components/ScriptsSidePanel.tsx b/components/ScriptsSidePanel.tsx index 0a0d38724..dfb6ef744 100644 --- a/components/ScriptsSidePanel.tsx +++ b/components/ScriptsSidePanel.tsx @@ -15,6 +15,8 @@ import { Expand, FolderPlus, Layers, + LayoutGrid, + List, Minimize2, Package, Play, @@ -27,6 +29,7 @@ import { import React, { memo, useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore } from 'react'; import { useI18n } from '../application/i18n/I18nProvider'; import { getScriptRecordingSnapshot, subscribeScriptRecording } from '../application/state/scriptRecordingStore.ts'; +import { useScriptsViewMode } from '../application/state/useScriptsViewMode.ts'; import { VaultDeleteConfirmDialog } from './vault/VaultDeleteConfirmDialog'; import { collectSnippetPackageTreePaths, @@ -61,6 +64,11 @@ const toolbarIconButtonClass = 'h-7 w-7 shrink-0 flex items-center justify-center rounded-md text-muted-foreground hover:text-foreground hover:bg-muted/60 transition-colors disabled:opacity-40 disabled:pointer-events-none'; const SCRIPT_ROW_HEIGHT = 34; +// The stacked view renders wrapping chips that cannot use FixedSizeVirtualList, +// so cap the initial render and grow lazily as the user scrolls to keep a huge +// library (or a broad search) from mounting every chip at once. +const STACKED_INITIAL_RENDER_COUNT = 120; +const STACKED_RENDER_BATCH = 120; const isRootPackagePath = (path: string): boolean => { const body = path.startsWith('/') ? path.slice(1) : path; @@ -171,10 +179,14 @@ export function buildScriptsSidePanelRows({ snippets, packages, expandedPaths, + snippetsBeforeChildren = false, }: { snippets: Snippet[]; packages: string[]; expandedPaths: Set; + /** Emit each package's own snippets before its sub-packages so flat views + * (stacked chips) keep every package's chips attached to its section. */ + snippetsBeforeChildren?: boolean; }): TreeRow[] { const normalizedPackages = new Set(collectScriptsSidePanelPackagePaths(packages, snippets)); @@ -227,6 +239,11 @@ export function buildScriptsSidePanelRows({ const snippetsIn = (pkg: string | null): Snippet[] => sortByVaultOrder(snippetsByPackage.get(pkg ?? '') ?? []); + const emitSnippets = (localSnippets: Snippet[], pkg: string, depth: number) => + localSnippets.forEach((snippet) => + rows.push({ type: 'snippet', id: snippet.id, depth: depth + 1, snippet, packagePath: pkg }), + ); + const rows: TreeRow[] = []; const walk = (pkg: string, depth: number) => { const children = childPackagesOf(pkg); @@ -246,10 +263,15 @@ export function buildScriptsSidePanelRows({ }); if (!isExpanded) return; + if (snippetsBeforeChildren) { + // Flat stacked view: a package's chips must follow its own section + // header, not trail after its nested children's chips. + emitSnippets(localSnippets, pkg, depth); + children.forEach((child) => walk(child, depth + 1)); + return; + } children.forEach((child) => walk(child, depth + 1)); - localSnippets.forEach((snippet) => - rows.push({ type: 'snippet', id: snippet.id, depth: depth + 1, snippet, packagePath: pkg }), - ); + emitSnippets(localSnippets, pkg, depth); }; snippetsIn(null).forEach((snippet) => @@ -281,6 +303,7 @@ const ScriptsSidePanelInner: React.FC = ({ const [search, setSearch] = useState(''); const [searchExpanded, setSearchExpanded] = useState(false); const [subView, setSubView] = useState<'library' | 'running'>('library'); + const [viewMode, setViewMode] = useScriptsViewMode(); const [expandedPaths, setExpandedPaths] = useState>(new Set()); const [isMultiSelectMode, setIsMultiSelectMode] = useState(false); const [selectedSnippetIds, setSelectedSnippetIds] = useState>(new Set()); @@ -291,6 +314,7 @@ const ScriptsSidePanelInner: React.FC = ({ const [renamingPackagePath, setRenamingPackagePath] = useState(''); const [newPackageName, setNewPackageName] = useState(''); const [packageError, setPackageError] = useState(''); + const [stackedRenderLimit, setStackedRenderLimit] = useState(STACKED_INITIAL_RENDER_COUNT); const packageDialogRef = useRef(null); const packageNameInputRef = useRef(null); const searchInputRef = useRef(null); @@ -481,8 +505,15 @@ const ScriptsSidePanelInner: React.FC = ({ if (!isVisible) return []; if (searchMatches !== null) return []; - return buildScriptsSidePanelRows({ snippets, packages, expandedPaths }); - }, [snippets, packages, expandedPaths, searchMatches, isVisible]); + return buildScriptsSidePanelRows({ + snippets, + packages, + expandedPaths, + // Stacked chips wrap flat, so each package's chips must stay attached + // to its own section header. + snippetsBeforeChildren: viewMode === 'stacked', + }); + }, [snippets, packages, expandedPaths, searchMatches, isVisible, viewMode]); type ScriptsListItem = | { key: string; kind: 'search'; snippet: Snippet } @@ -515,6 +546,48 @@ const ScriptsSidePanelInner: React.FC = ({ }); }, [rows, searchMatches, t, isVisible]); + const stackedSentinelRef = useRef(null); + + // Restart the lazy window whenever the underlying result set changes + // (new search, package list changes, snippet edits, view-mode switch) — but + // NOT on package expand/collapse. Expanding/collapsing only reshapes the + // same result set; resetting there would drop the user's lazy-loaded window + // and remount the package they just toggled, forcing them to re-scroll. + // Keyed by data identity, not by listItems reference, for that reason. + // Reset during render — not in an effect — so the first render of a new + // result set never slices with the previous, potentially very large window. + const stackedResultSetKey = useMemo(() => { + if (searchMatches !== null) { + return `search:${searchMatches.map((snippet) => snippet.id).join('\u0000')}`; + } + return `tree:${viewMode}:${packages.join('\u0000')}:${snippets + .map((snippet) => snippet.id) + .join('\u0000')}`; + }, [searchMatches, viewMode, packages, snippets]); + + const [prevResultSetKey, setPrevResultSetKey] = useState(stackedResultSetKey); + if (prevResultSetKey !== stackedResultSetKey) { + setPrevResultSetKey(stackedResultSetKey); + setStackedRenderLimit(STACKED_INITIAL_RENDER_COUNT); + } + + const hasMoreStackedItems = viewMode === 'stacked' && stackedRenderLimit < listItems.length; + + useEffect(() => { + if (subView !== 'library' || !hasMoreStackedItems) return; + const sentinel = stackedSentinelRef.current; + if (!sentinel) return; + // Re-created per batch: observing always emits an initial intersection + // callback, so short result sets that never scroll keep loading. + const observer = new IntersectionObserver((entries) => { + if (entries.some((entry) => entry.isIntersecting)) { + setStackedRenderLimit((limit) => limit + STACKED_RENDER_BATCH); + } + }); + observer.observe(sentinel); + return () => observer.disconnect(); + }, [hasMoreStackedItems, stackedRenderLimit, subView]); + const handleSnippetClick = useCallback( (snippet: Snippet) => { if (isMultiSelectMode) { @@ -973,6 +1046,30 @@ const ScriptsSidePanelInner: React.FC = ({ {t('snippets.searchPlaceholder')} + + + + + + {viewMode === 'stacked' + ? t('scripts.sidePanel.viewList') + : t('scripts.sidePanel.viewStacked')} + + + ); + if (!canMutatePackages) { + return header; + } + return ( + + + {header} + + + openRenamePackageDialog(item.row.path)} + > + {t('common.rename')} + + requestDeletePackage(item.row.path)} + > + {t('action.delete')} + + + + ); + } + const snippet = item.kind === 'search' ? item.snippet : item.row.snippet; + const isScript = isScriptSnippet(snippet); + // Search results drop the package headers, so surface each + // snippet's package on the chip itself to keep results + // distinguishable (labels are not unique across packages). + const chipSubtitle = item.kind === 'search' + ? (snippet.package || t('terminal.toolbar.library')) + : undefined; + return ( + handleSnippetClick(snippet)} + onEdit={() => handleEditSnippet(snippet)} + onDelete={() => handleDeleteSnippet(snippet.id)} + onCopyCommand={() => handleCopySnippetCommand(snippet)} + copyCommandLabel={t('scripts.actions.copyCommand')} + onRunParallel={onRunScriptOnWorkspace + ? () => onRunScriptOnWorkspace(snippet, 'parallel') + : undefined} + onRunSequential={isScript && onRunScriptOnWorkspace + ? () => onRunScriptOnWorkspace(snippet, 'sequential') + : undefined} + runParallelLabel={isScript + ? t('scripts.actions.runParallel') + : t('scripts.actions.runOnAllTabs')} + runSequentialLabel={t('scripts.actions.runSequential')} + editLabel={t('action.edit')} + deleteLabel={t('action.delete')} + /> + ); + })} + + {hasMoreStackedItems ? ( + ) : ( (({ )); SnippetRow.displayName = 'SnippetRow'; +interface SnippetChipProps { + snippet: Snippet; + /** Optional package context shown on the chip (used by stacked search + * results, where the package headers are dropped). */ + subtitle?: string; + selected?: boolean; + multiSelect?: boolean; + onClick: () => void; + onEdit: () => void; + onDelete: () => void; + onRunParallel?: () => void; + onRunSequential?: () => void; + onCopyCommand?: () => void; + copyCommandLabel?: string; + runParallelLabel?: string; + runSequentialLabel?: string; + editLabel: string; + deleteLabel: string; +} + +/** + * Compact chip for the stacked (wrap) view. Shares the same click → run / + * execute and right-click context menu as the list rows; only the layout is + * denser. Drag reordering stays list-view-only. + */ +const SnippetChip = memo(({ + snippet, + subtitle, + selected = false, + multiSelect = false, + onClick, + onEdit, + onDelete, + onRunParallel, + onRunSequential, + onCopyCommand, + copyCommandLabel, + runParallelLabel, + runSequentialLabel, + editLabel, + deleteLabel, +}) => ( + + {/* Radix ContextMenuTrigger needs a real DOM element (Tooltip.Root renders + none), so mirror SnippetRow and wrap the tooltip in a plain div. */} + +
+ + + + + + + + +
+
+ + {onRunParallel ? ( + + {runParallelLabel} + + ) : null} + {onRunSequential ? ( + + {runSequentialLabel} + + ) : null} + {onCopyCommand ? ( + + {copyCommandLabel} + + ) : null} + + {editLabel} + + + {deleteLabel} + + +
+)); +SnippetChip.displayName = 'SnippetChip'; + export const ScriptsSidePanel = memo(ScriptsSidePanelInner); ScriptsSidePanel.displayName = 'ScriptsSidePanel'; diff --git a/components/SftpSidePanel.tsx b/components/SftpSidePanel.tsx index 7e4264c0d..a8928c6a2 100644 --- a/components/SftpSidePanel.tsx +++ b/components/SftpSidePanel.tsx @@ -36,6 +36,7 @@ import { editorTabStore, useEditorTabPresenceRevision, } from "../application/state/editorTabStore"; +import { forceCloseOwnedEditors } from "../application/state/editorWindowClient"; import { releaseEditorTabSaveCoordinator } from "../application/state/editorTabSave"; import { useSftpBackend } from "../application/state/useSftpBackend"; import { useSftpFileAssociations } from "../application/state/useSftpFileAssociations"; @@ -534,7 +535,7 @@ const SftpSidePanelInner: React.FC = ({ if (id) ownedSessionIds.push(id); } if (ownedSessionIds.length === 0 && ownedSftpTabIds.length === 0) return; - const closed = editorTabStore.forceCloseByOwners({ + const closed = forceCloseOwnedEditors({ sessionIds: ownedSessionIds, sftpTabIds: ownedSftpTabIds, }); @@ -1607,6 +1608,7 @@ const SftpSidePanelInteractiveBody: React.FC setFileOpenerTarget, handleSaveTextFile, onPromoteToTab, + onPopOut, handleFileOpenerSelect, handleSelectSystemApp, } = useSftpViewPaneCallbacks({ @@ -2058,6 +2060,7 @@ const SftpSidePanelInteractiveBody: React.FC handleFileOpenerSelect={handleFileOpenerSelect} handleSelectSystemApp={handleSelectSystemApp} onPromoteToTab={onPromoteToTab} + onPopOut={onPopOut} onRequestTerminalFocus={onRequestTerminalFocus} t={t} /> diff --git a/components/SftpView.tsx b/components/SftpView.tsx index 57aabf4dc..3c5f8eb43 100644 --- a/components/SftpView.tsx +++ b/components/SftpView.tsx @@ -410,6 +410,7 @@ const SftpViewInner: React.FC = ({ setFileOpenerTarget, handleSaveTextFile, onPromoteToTab, + onPopOut, handleFileOpenerSelect, handleSelectSystemApp, } = useSftpViewPaneCallbacks({ @@ -797,6 +798,7 @@ const SftpViewInner: React.FC = ({ handleFileOpenerSelect={handleFileOpenerSelect} handleSelectSystemApp={handleSelectSystemApp} onPromoteToTab={onPromoteToTab} + onPopOut={onPopOut} t={t} /> diff --git a/components/Terminal.tsx b/components/Terminal.tsx index 362014f20..730c5082c 100644 --- a/components/Terminal.tsx +++ b/components/Terminal.tsx @@ -40,6 +40,7 @@ import { type TerminalEncodingAttachConnection, } from "../domain/terminalEncodingPreference"; import { resolveRestoreCwdIntent, resolveInheritedCwdIntent } from "../domain/sessionRestore"; +import { formatSessionClosedExitMessage } from "../application/state/resolveTerminalSessionExitIntent"; import { buildTerminalContextReadResult, buildTerminalContextSnapshotText, @@ -2066,7 +2067,7 @@ const TerminalComponent: React.FC = ({ if (evt.error) { setError(evt.error); } - const exitMessage = `\r\n[session closed${evt?.exitCode !== undefined ? ` (code ${evt.exitCode})` : ""}]`; + const exitMessage = formatSessionClosedExitMessage(evt); hibernatePendingBufferRef.current = hibernatePendingCapDisabledRef.current ? hibernatePendingBufferRef.current + exitMessage : appendHibernatePendingBuffer( diff --git a/components/TerminalLayer.tsx b/components/TerminalLayer.tsx index 5d9e81fd9..8bb50738e 100644 --- a/components/TerminalLayer.tsx +++ b/components/TerminalLayer.tsx @@ -669,7 +669,9 @@ const TerminalLayerInner: React.FC = ({ const handleSessionExit = useCallback((sessionId: string, evt: TerminalSessionExitEvent) => { const intent = resolveTerminalSessionExitIntent( evt, - terminalSettings?.autoCloseOnExit ?? true, + // No terminal settings loaded yet -> do not close the tab; auto-close is + // opt-in via Settings -> Terminal -> Behavior (DEFAULT_TERMINAL_SETTINGS). + terminalSettings?.autoCloseOnExit ?? false, ); if (intent.kind === "closeSession") { handleCloseSession(sessionId); diff --git a/components/TerminalPopupPage.test.ts b/components/TerminalPopupPage.test.ts index 6dd9bfae0..6ca676263 100644 --- a/components/TerminalPopupPage.test.ts +++ b/components/TerminalPopupPage.test.ts @@ -162,7 +162,7 @@ test('attach popup close preparation has a bounded timeout', () => { test('terminal exit auto-close setting reaches tabs, workspaces, and popups', () => { assert.match( terminalLayerSource, - /resolveTerminalSessionExitIntent\(\s+evt,\s+terminalSettings\?\.autoCloseOnExit \?\? true,\s+\)/, + /resolveTerminalSessionExitIntent\(\s+evt,[\s\S]*?terminalSettings\?\.autoCloseOnExit \?\? false,\s+\)/, ); assert.match( terminalSource, diff --git a/components/TextEditorModal.tsx b/components/TextEditorModal.tsx index e8e6bbee8..14be10921 100644 --- a/components/TextEditorModal.tsx +++ b/components/TextEditorModal.tsx @@ -71,6 +71,8 @@ interface TextEditorModalProps { keyBindings: KeyBinding[]; /** If provided, a maximize button is shown in the Pane header. */ onPromoteToTab?: (snapshot: TextEditorModalSnapshot) => void; + /** If provided, a pop-out button sends the file to the dedicated editor window. */ + onPopOut?: (snapshot: TextEditorModalSnapshot) => void; } const TextEditorModal: React.FC = ({ @@ -84,6 +86,7 @@ const TextEditorModal: React.FC = ({ hotkeyScheme, keyBindings, onPromoteToTab, + onPopOut, }) => { const { t } = useI18n(); @@ -229,6 +232,20 @@ const TextEditorModal: React.FC = ({ if (snapshot) onPromoteToTab(snapshot); }, [onPromoteToTab, fileName, languageId, editorWordWrap]); + const handlePopOut = useCallback(() => { + if (!onPopOut) return; + const snapshot = createTextEditorModalSnapshot({ + fileName, + getBaselineContent: () => baselineContentRef.current, + getContent: () => contentRef.current, + languageId, + wordWrap: editorWordWrap, + getViewState: () => viewStateRef.current, + isSaving: () => savingRef.current, + }); + if (snapshot) onPopOut(snapshot); + }, [onPopOut, fileName, languageId, editorWordWrap]); + return ( !isOpen && handleClose()}> = ({ onSave={handleSave} onRequestClose={handleClose} onPromoteToTab={onPromoteToTab ? handlePromote : undefined} + onPopOut={onPopOut ? handlePopOut : undefined} /> diff --git a/components/TopTabs.tsx b/components/TopTabs.tsx index 164693617..0cd0efa41 100644 --- a/components/TopTabs.tsx +++ b/components/TopTabs.tsx @@ -173,6 +173,8 @@ interface TopTabsProps { pluginViewTabs: readonly PluginViewTab[]; onClosePluginViewTab: (tabId: string) => void; onRequestCloseEditorTab: (editorTabId: string) => void; + onPopOutEditorTab?: (editorTabId: string) => void; + onFocusEditorWindowTab?: (editorTabId: string) => void; hostById: Map; } @@ -223,6 +225,8 @@ const TopTabsInner: React.FC = ({ pluginViewTabs, onClosePluginViewTab, onRequestCloseEditorTab, + onPopOutEditorTab, + onFocusEditorWindowTab, hostById, }) => { const { t } = useI18n(); @@ -830,6 +834,8 @@ const TopTabsInner: React.FC = ({ host={host} suffix={suffix} onRequestCloseEditorTab={onRequestCloseEditorTab} + onPopOutEditorTab={onPopOutEditorTab} + onFocusEditorWindowTab={onFocusEditorWindowTab} isBeingDragged={isBeingDragged} isDraggingForReorder={isDraggingForReorder} shiftStyle={shiftStyle} @@ -1283,6 +1289,8 @@ export const topTabsAreEqual = (prev: TopTabsProps, next: TopTabsProps): boolean // Editor open/close/fileName chrome only (presence list). Dirty dots use store. prev.editorTabs === next.editorTabs && prev.onRequestCloseEditorTab === next.onRequestCloseEditorTab && + prev.onPopOutEditorTab === next.onPopOutEditorTab && + prev.onFocusEditorWindowTab === next.onFocusEditorWindowTab && prev.pluginViewTabs === next.pluginViewTabs && prev.onClosePluginViewTab === next.onClosePluginViewTab && prev.draggingSessionId === next.draggingSessionId && diff --git a/components/VaultView.tsx b/components/VaultView.tsx index ab58c6a73..67b5d1ec9 100644 --- a/components/VaultView.tsx +++ b/components/VaultView.tsx @@ -73,6 +73,7 @@ import { upsertHostById, } from "../domain/host"; import { exportHostsToCsvWithStats } from "../domain/vaultImport"; +import { upsertKnownHost } from "../domain/knownHosts"; import { remapSnippetTargetGroupPaths, } from "../domain/hostGroupPathMutations"; @@ -664,6 +665,13 @@ const VaultViewInner: React.FC = ({ setIsHostPanelOpen(true); }, []); + const handleAddKnownHost = useCallback( + (kh: KnownHost) => { + onUpdateKnownHosts(upsertKnownHost(knownHosts, kh)); + }, + [knownHosts, onUpdateKnownHosts], + ); + const handleDuplicateHost = useCallback( (host: Host) => { setCurrentSection("hosts"); @@ -1494,6 +1502,7 @@ const VaultViewInner: React.FC = ({ keys, knownHosts, knownHostsManagerElement, + onAddKnownHost: handleAddKnownHost, Label, lastPinnedId, LayoutGrid, diff --git a/components/ai/ChatInput.tsx b/components/ai/ChatInput.tsx index e353cd5e7..f01d062c2 100644 --- a/components/ai/ChatInput.tsx +++ b/components/ai/ChatInput.tsx @@ -206,6 +206,8 @@ interface ChatInputProps { placeholder?: string; /** Available model presets for the current agent */ modelPresets?: AgentModelPreset[]; + /** Live agent model catalog (OpenCode) is still loading. */ + modelCatalogLoading?: boolean; /** * Offer the picker's "use custom model" action. Disable when the current * agent locks the model (e.g. managed Codex `model` config overrides the @@ -277,6 +279,7 @@ const ChatInput: React.FC = ({ agentName, placeholder, modelPresets = [], + modelCatalogLoading = false, allowCustomModelEntry = true, selectedModelId, onModelSelect, @@ -997,7 +1000,10 @@ const ChatInput: React.FC = ({ ? providerSwitcherChipLabel : (selectedPreset?.name || modelName || providerName || t('ai.chat.noModel')); const modelChipMaxWidth = hasProviderSwitcher ? 'max-w-[168px]' : 'max-w-[96px]'; - const hasModelPicker = hasProviderSwitcher || (modelPresets.length > 0 && !!onModelSelect); + // Keep the picker open-able while an OpenCode catalog load is pending so + // the user sees the loading row instead of stale built-in presets (#3584). + const hasModelPicker = hasProviderSwitcher + || ((modelPresets.length > 0 || modelCatalogLoading) && !!onModelSelect); const thinkingLevels = useMemo( () => ( hasProviderSwitcher && onThinkingLevelChange @@ -1592,6 +1598,7 @@ const ChatInput: React.FC = ({ selectedProviderId={providerSwitcher?.selectedProviderId} selectedModelId={hasProviderSwitcher ? providerSwitcher?.selectedModelId : selectedBaseModelId} modelPresets={hasProviderSwitcher ? undefined : modelPresets} + loading={!hasProviderSwitcher ? modelCatalogLoading : undefined} allowCustomEntry={allowCustomModelEntry} prefs={modelPrefs} onSelectProviderModel={(providerId, modelId, contextWindow) => { @@ -1805,6 +1812,7 @@ function chatInputPropsAreEqual(prev: ChatInputProps, next: ChatInputProps): boo && prev.agentName === next.agentName && prev.placeholder === next.placeholder && prev.modelPresets === next.modelPresets + && prev.modelCatalogLoading === next.modelCatalogLoading && prev.allowCustomModelEntry === next.allowCustomModelEntry && prev.selectedModelId === next.selectedModelId && prev.onModelSelect === next.onModelSelect diff --git a/components/ai/ComposerModelPicker.test.tsx b/components/ai/ComposerModelPicker.test.tsx index 0d65c720d..17c5154cd 100644 --- a/components/ai/ComposerModelPicker.test.tsx +++ b/components/ai/ComposerModelPicker.test.tsx @@ -109,3 +109,20 @@ test('external agent picker keeps preset rows truncation-friendly with a full-na assert.match(html, /title="GPT-5\.6 Sol \(preview\)"/); }); + +test('external agent picker shows the loading row while the live catalog is pending', () => { + const html = renderToStaticMarkup( + {}} + onTogglePinned={() => {}} + />, + ); + + assert.match(html, /ai\.chat\.loadingModels/); + // The host no longer renders built-in presets while loading (#3584). + assert.doesNotMatch(html, /gpt-5\.1/); +}); diff --git a/components/ai/ComposerModelPicker.tsx b/components/ai/ComposerModelPicker.tsx index e3b15ec90..7201f1523 100644 --- a/components/ai/ComposerModelPicker.tsx +++ b/components/ai/ComposerModelPicker.tsx @@ -22,6 +22,11 @@ export interface ComposerModelPickerProps { selectedProviderId?: string; selectedModelId?: string; modelPresets?: AgentModelPreset[]; + /** + * The host's live model catalog (external SDK agents like OpenCode) is still + * loading. Show the loading row instead of an empty/placeholder list. + */ + loading?: boolean; prefs: ComposerModelPrefs; /** * Offer the "use custom model" action. Disable when the host locks the @@ -89,6 +94,7 @@ export const ComposerModelPicker: React.FC = ({ selectedProviderId, selectedModelId, modelPresets = [], + loading = false, prefs, allowCustomEntry = true, onSelectProviderModel, @@ -271,7 +277,7 @@ export const ComposerModelPicker: React.FC = ({
- {catalog.loading && ( + {(catalog.loading || (loading && !hasProviders)) && (
{t('ai.chat.loadingModels')} @@ -344,7 +350,7 @@ export const ComposerModelPicker: React.FC = ({ /> ))} - {!catalog.loading && filtered.length === 0 && !showCustom && ( + {!catalog.loading && !loading && filtered.length === 0 && !showCustom && (
{catalog.error || t('ai.chat.noMatchingModels')}
diff --git a/components/editor/EditorWindowPage.close.test.ts b/components/editor/EditorWindowPage.close.test.ts new file mode 100644 index 000000000..a397f5d0b --- /dev/null +++ b/components/editor/EditorWindowPage.close.test.ts @@ -0,0 +1,17 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import test from "node:test"; + +test("the custom window close button delegates before touching any renderer-owned tabs", async () => { + const source = readFileSync(new URL("./EditorWindowPage.tsx", import.meta.url), "utf8"); + const body = source.match(/const handleWindowClose = useCallback\(async \(\) => \{([\s\S]*?)\n {2}\}, \[/)?.[1]; + assert.ok(body); + let closes = 0; + const fail = () => { throw new Error("Native close must own tab confirmation and the incoming-transfer gate"); }; + const close = new Function("closeWindow", "editorTabStore", "promptUnsavedChanges", "saveTab", "reportDetachedEditorTabsClosed", `return async () => {${body}}`)( + async () => { closes++; }, { getTabs: fail, close: fail }, fail, fail, fail, + ); + await close(); + assert.equal(closes, 1); + assert.match(source, / \{ void handleWindowClose\(\); \}\}/); +}); diff --git a/components/editor/EditorWindowPage.tsx b/components/editor/EditorWindowPage.tsx new file mode 100644 index 000000000..a5a52a0ac --- /dev/null +++ b/components/editor/EditorWindowPage.tsx @@ -0,0 +1,345 @@ +import { Copy, Minus, Square, X } from "lucide-react"; +import React, { useCallback, useEffect, useRef, useState } from "react"; +import type * as Monaco from "monaco-editor"; + +import { useI18n } from "../../application/i18n/I18nProvider"; +import { + editorTabStore, + tabIsDirty, + useEditorTab, + useEditorTabChromeList, + useEditorTabDirty, + type EditorTabId, +} from "../../application/state/editorTabStore"; +import { + dockDetachedEditorTab, + reportDetachedEditorDirty, + reportDetachedEditorTabsClosed, + reportEditorWindowCloseTabsResult, + saveDetachedEditorTab, + subscribeEditorWindowCloseTabs, + subscribeEditorWindowDirtyCheck, +} from "../../application/state/editorWindowClient"; +import { useEditorWindowRuntime } from "../../application/state/useEditorWindow"; +import { useWindowControls } from "../../application/state/useWindowControls"; +import type { AppLockGateRenderContext } from "../AppLockGate"; +import { toast } from "../ui/toast"; +import { promptUnsavedChanges, UnsavedChangesProvider } from "./UnsavedChangesDialog"; +import { EditorWindowTabBar } from "./EditorWindowTabBar"; +import { TextEditorPane } from "./TextEditorPane"; + +const isMac = typeof navigator !== "undefined" && /Mac|iPhone|iPad/.test(navigator.platform); + +type SettingsState = AppLockGateRenderContext["settings"]; + +function EditorWindowControls({ onClose }: { onClose: () => void }) { + const { minimize, maximize, isMaximized: fetchIsMaximized } = useWindowControls(); + const [isWindowMaximized, setIsWindowMaximized] = useState(false); + + useEffect(() => { + let cancelled = false; + void fetchIsMaximized().then((value) => { + if (!cancelled) setIsWindowMaximized(!!value); + }); + const handleResize = () => { + void fetchIsMaximized().then((value) => setIsWindowMaximized(!!value)); + }; + window.addEventListener("resize", handleResize); + return () => { + cancelled = true; + window.removeEventListener("resize", handleResize); + }; + }, [fetchIsMaximized]); + + if (isMac) return null; + + const buttonClass = + "app-no-drag flex h-10 w-11 items-center justify-center text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"; + + return ( +
+ + + +
+ ); +} + +function EditorWindowPaneHost({ + tabId, + isVisible, + settings, + onRequestClose, + onDock, +}: { + tabId: EditorTabId; + isVisible: boolean; + settings: SettingsState; + onRequestClose: (tabId: EditorTabId) => void; + onDock: (tabId: EditorTabId) => void; +}) { + const { t } = useI18n(); + const tab = useEditorTab(tabId); + const dirtyReportTimerRef = useRef(null); + const handleContentChange = useCallback( + (content: string, viewState: Monaco.editor.ICodeEditorViewState | null) => { + editorTabStore.updateContent(tabId, content, viewState); + const latest = editorTabStore.getTab(tabId); + if (!latest) return; + const dirty = content !== latest.baselineContent; + if (dirtyReportTimerRef.current != null) window.clearTimeout(dirtyReportTimerRef.current); + dirtyReportTimerRef.current = window.setTimeout(() => { + dirtyReportTimerRef.current = null; + reportDetachedEditorDirty(tabId, dirty); + }, 150); + }, + [tabId], + ); + useEffect(() => () => { + if (dirtyReportTimerRef.current != null) window.clearTimeout(dirtyReportTimerRef.current); + }, []); + const handleSave = useCallback(async () => { + if (dirtyReportTimerRef.current != null) { + window.clearTimeout(dirtyReportTimerRef.current); + dirtyReportTimerRef.current = null; + } + const result = await saveDetachedEditorTab(tabId); + if (result.ok) { + toast.success(t("sftp.editor.saved"), "SFTP"); + } else { + toast.error(result.error ?? t("sftp.editor.saveFailed"), "SFTP"); + } + }, [tabId, t]); + + if (!tab) return null; + const isDirty = tabIsDirty(tab); + const hostLabel = tab.hostLabel || tab.hostId; + + return ( +
+ editorTabStore.setLanguage(tabId, lang)} + onToggleWordWrap={() => { + const current = editorTabStore.getTab(tabId); + if (current) editorTabStore.setWordWrap(tabId, !current.wordWrap); + }} + onSave={() => { void handleSave(); }} + onRequestClose={() => onRequestClose(tabId)} + onDockToMain={() => onDock(tabId)} + initialViewState={tab.viewState} + /> +
+ ); +} + +function EditorWindowPageInner({ settings }: { settings: SettingsState }) { + const { t } = useI18n(); + const { activeTabId, setActiveTabId, closeWindow, setWindowTitle } = useEditorWindowRuntime(); + const { onWindowCommandCloseRequested } = useWindowControls(); + const tabs = useEditorTabChromeList(); + const closeInFlightRef = useRef | null>(null); + const activeTab = useEditorTab(activeTabId ?? ""); + const activeDirty = useEditorTabDirty(activeTabId ?? ""); + + useEffect(() => { + if (activeTabId && editorTabStore.getTab(activeTabId)) return; + if (tabs.length > 0) setActiveTabId(tabs[tabs.length - 1].id); + }, [activeTabId, setActiveTabId, tabs]); + + useEffect(() => { + const name = activeTab?.fileName; + if (!name) { + void setWindowTitle(t("sftp.editor.windowTitle")); + return; + } + void setWindowTitle(`${activeDirty ? "* " : ""}${name}`); + }, [activeDirty, activeTab?.fileName, setWindowTitle, t]); + + const saveTab = useCallback(async (tabId: EditorTabId): Promise => { + const result = await saveDetachedEditorTab(tabId); + if (result.ok) return true; + toast.error(result.error ?? t("sftp.editor.saveFailed"), "SFTP"); + return false; + }, [t]); + + const closeTab = useCallback(async (tabId: EditorTabId, force = false): Promise => { + const tab = editorTabStore.getTab(tabId); + if (!tab) return true; + if (!force && tabIsDirty(tab)) { + const choice = await promptUnsavedChanges(tab.fileName); + if (choice === "cancel") return false; + if (choice === "save") { + const ok = await saveTab(tabId); + if (!ok) return false; + } + } + editorTabStore.close(tabId); + reportDetachedEditorTabsClosed([tabId]); + return true; + }, [saveTab]); + + const closeTabAndMaybeWindow = useCallback(async (tabId: EditorTabId, force = false): Promise => { + if (closeInFlightRef.current) return closeInFlightRef.current; + const task = (async () => { + const ok = await closeTab(tabId, force); + if (!ok) return false; + const remaining = editorTabStore.getTabs(); + if (remaining.length === 0) { + await closeWindow(); + return true; + } + if (activeTabId === tabId) { + setActiveTabId(remaining[remaining.length - 1]?.id ?? null); + } + return true; + })().finally(() => { + closeInFlightRef.current = null; + }); + closeInFlightRef.current = task; + return task; + }, [activeTabId, closeTab, closeWindow, setActiveTabId]); + + const handleDock = useCallback(async (tabId: EditorTabId) => { + const result = await dockDetachedEditorTab(tabId); + if (!result.success) { + toast.error(result.error ?? t("sftp.editor.dockFailed"), "SFTP"); + return; + } + editorTabStore.close(tabId); + const remaining = editorTabStore.getTabs(); + if (remaining.length === 0) { + await closeWindow(); + return; + } + if (activeTabId === tabId) { + setActiveTabId(remaining[remaining.length - 1]?.id ?? null); + } + }, [activeTabId, closeWindow, setActiveTabId, t]); + + const handleWindowClose = useCallback(async () => { + // Native close owns confirmation and blocks new transfers until it settles. + await closeWindow(); + }, [closeWindow]); + + useEffect(() => { + return onWindowCommandCloseRequested(() => { + if (activeTabId) void closeTabAndMaybeWindow(activeTabId); + else void handleWindowClose(); + }); + }, [activeTabId, closeTabAndMaybeWindow, handleWindowClose, onWindowCommandCloseRequested]); + + useEffect(() => { + return subscribeEditorWindowDirtyCheck(() => { + const hasDirty = editorTabStore.getTabs().some((tab) => tabIsDirty(tab)); + if (hasDirty) toast.warning(t("sftp.editor.quitBlockedByDirty"), "SFTP"); + return hasDirty; + }); + }, [t]); + + useEffect(() => { + return subscribeEditorWindowCloseTabs(async (payload) => { + const closedIds: string[] = []; + let cancelled = false; + for (const editorId of payload.editorIds ?? []) { + const ok = await closeTab(editorId, payload.force === true); + if (!ok) { + cancelled = true; + break; + } + closedIds.push(editorId); + } + reportEditorWindowCloseTabsResult({ + requestId: payload.requestId, + cancelled, + closedIds, + }); + if (!cancelled && editorTabStore.getTabs().length === 0) { + await closeWindow(); + } else if (activeTabId && !editorTabStore.getTab(activeTabId)) { + setActiveTabId(editorTabStore.getTabs()[0]?.id ?? null); + } + }); + }, [activeTabId, closeTab, closeWindow, setActiveTabId]); + + return ( +
+
+ {isMac ?
: null} +
+ {t("sftp.editor.windowTitle")} +
+ { void closeTabAndMaybeWindow(tabId); }} + /> + { void handleWindowClose(); }} /> +
+
+ {tabs.length === 0 ? ( +
+ {t("sftp.editor.windowEmpty")} +
+ ) : ( + tabs.map((tab) => ( + { void closeTabAndMaybeWindow(id); }} + onDock={(id) => { void handleDock(id); }} + /> + )) + )} +
+
+ ); +} + +export default function EditorWindowPage({ + settings, +}: { + settings: SettingsState; + allowEdit?: boolean; +}) { + return ( + + {() => } + + ); +} diff --git a/components/editor/EditorWindowTabBar.tsx b/components/editor/EditorWindowTabBar.tsx new file mode 100644 index 000000000..41d98f3b2 --- /dev/null +++ b/components/editor/EditorWindowTabBar.tsx @@ -0,0 +1,84 @@ +import { FileCode, FileText, X } from "lucide-react"; +import React from "react"; + +import { useEditorTabDirty, type EditorTabChrome } from "../../application/state/editorTabStore"; +import { cn } from "../../lib/utils"; + +const CODE_EXTENSIONS_RE = /\.(js|jsx|ts|tsx|py|rb|go|rs|c|cpp|cs|java|php|sh|bash|zsh|fish|lua|r|scala|swift|kt|html|css|scss|less|json|yaml|yml|toml|xml|sql|graphql|gql|md|mdx|conf|ini|env|tf|hcl|dockerfile)$/i; + +export interface EditorWindowTabBarProps { + tabs: readonly EditorTabChrome[]; + activeTabId: string | null; + onSelect: (tabId: string) => void; + onClose: (tabId: string) => void; +} + +export const EditorWindowTabBar: React.FC = ({ + tabs, + activeTabId, + onSelect, + onClose, +}) => { + const fileNameCounts = new Map(); + for (const tab of tabs) { + fileNameCounts.set(tab.fileName, (fileNameCounts.get(tab.fileName) ?? 0) + 1); + } + + return ( +
+ {tabs.map((tab) => ( + 1 + ? ` · ${tab.hostLabel || tab.hostId}` + : "" + } + onSelect={onSelect} + onClose={onClose} + /> + ))} +
+ ); +}; + +const EditorWindowTab: React.FC<{ + tab: EditorTabChrome; + isActive: boolean; + suffix: string; + onSelect: (tabId: string) => void; + onClose: (tabId: string) => void; +}> = ({ tab, isActive, suffix, onSelect, onClose }) => { + const dirty = useEditorTabDirty(tab.id); + const FileIcon = CODE_EXTENSIONS_RE.test(tab.fileName) ? FileCode : FileText; + return ( + + ); +}; diff --git a/components/editor/TextEditorPane.shortcuts.test.ts b/components/editor/TextEditorPane.shortcuts.test.ts new file mode 100644 index 000000000..63e53ffdd --- /dev/null +++ b/components/editor/TextEditorPane.shortcuts.test.ts @@ -0,0 +1,52 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { readFileSync } from 'node:fs'; +import { createRequire } from 'node:module'; +const require = createRequire(import.meta.url); +const { ContextKeyExpr } = require('monaco-editor/esm/vs/platform/contextkey/common/contextkey.js'); +const { KeybindingResolver } = require('monaco-editor/esm/vs/platform/keybinding/common/keybindingResolver.js'); +import { registerTextEditorCommand } from './TextEditorPane.tsx'; + +test('save, close, find and paste resolve to the focused editor when two editors share a window', () => { + const commands = new Map[2]>(); + const bindings: Array<{ command: string; commandArgs: undefined; chords: string[]; when: ReturnType; isDefault: boolean; bubble: boolean }> = []; + const calls: string[] = []; + const actions = ['save', 'close', 'find', 'paste']; + // Match the native regression: beta was created first, alpha last, then + // beta was clicked. Unscoped Monaco commands incorrectly chose alpha. + for (const editorId of ['vs.editor.ICodeEditor:beta', 'vs.editor.ICodeEditor:alpha']) { + const editor = { + getId: () => editorId, + addCommand(keybinding: number, handler: Parameters[2], context?: string) { + const command = `${editorId}:${keybinding}`; + commands.set(command, handler); + bindings.push({ command, commandArgs: undefined, chords: [String(keybinding)], when: ContextKeyExpr.deserialize(context), isDefault: false, bubble: false }); + return command; + }, + }; + actions.forEach((action, keybinding) => registerTextEditorCommand(editor, keybinding, () => { calls.push(`${editorId}:${action}`); })); + } + const resolver = new KeybindingResolver([], bindings, () => {}); + function press(editorId: string | undefined, keybinding: number) { + const result = resolver.resolve({ getValue: (key: string) => key === 'editorId' ? editorId : undefined }, [], String(keybinding)) as { kind: number; commandId?: string }; + if (result.kind === 2) commands.get(result.commandId!)!(null as never); + return result.kind; + } + for (const editorId of ['vs.editor.ICodeEditor:beta', 'vs.editor.ICodeEditor:alpha', 'vs.editor.ICodeEditor:beta']) { + actions.forEach((action, keybinding) => { + const before = calls.length; + assert.equal(press(editorId, keybinding), 2); + assert.deepEqual(calls.slice(before), [`${editorId}:${action}`]); + }); + } + const before = calls.length; + actions.forEach((_, keybinding) => assert.equal(press(undefined, keybinding), 0)); + assert.equal(calls.length, before, 'no hidden editor handles keys outside editor context'); +}); + +test('every TextEditorPane Monaco shortcut uses the scoped registration', () => { + const source = readFileSync(new URL('./TextEditorPane.tsx', import.meta.url), 'utf8'); + const mount = source.slice(source.indexOf('const handleEditorMount:'), source.indexOf('// Capture-phase close-tab')); + assert.equal(mount.match(/registerTextEditorCommand\(editor,/g)?.length, 4); + assert.doesNotMatch(mount, /editor\.addCommand\(/); +}); diff --git a/components/editor/TextEditorPane.tsx b/components/editor/TextEditorPane.tsx index 68adb2680..b36a15b30 100644 --- a/components/editor/TextEditorPane.tsx +++ b/components/editor/TextEditorPane.tsx @@ -7,7 +7,9 @@ import { CloudUpload, Loader2, Maximize2, + PanelLeft, Search, + SquareArrowOutUpRight, WrapText, X, } from 'lucide-react'; @@ -93,8 +95,8 @@ export interface TextEditorPaneProps { saveError: string | null; hotkeyScheme: HotkeyScheme; keyBindings: KeyBinding[]; - /** Layout mode — affects header chrome (modal shows close+maximize; tab-form only shows content controls since tab has its own close). */ - chrome: 'modal' | 'tab'; + /** Layout mode — modal has close+maximize+popout; tab has popout; window has dock. */ + chrome: 'modal' | 'tab' | 'window'; /** Optional secondary label shown next to the filename in muted text — used by the tab form to display `host:remotePath`. */ subtitle?: string; onContentChange: (content: string, viewState: Monaco.editor.ICodeEditorViewState | null) => void; @@ -103,9 +105,20 @@ export interface TextEditorPaneProps { onSave: () => void; onRequestClose?: () => void; // modal only onPromoteToTab?: () => void; // modal only — omit to hide the maximize button + onPopOut?: () => void; // modal/tab — open in the dedicated editor window + onDockToMain?: () => void; // window chrome — dock current tab back to the main window initialViewState?: Monaco.editor.ICodeEditorViewState | null; } +/** Monaco commands share a window-level resolver; bind each to its owning editor. */ +export function registerTextEditorCommand( + editor: Pick, + keybinding: number, + handler: Monaco.editor.ICommandHandler, +): string | null { + return editor.addCommand(keybinding, handler, `editorId == '${editor.getId()}'`); +} + export const isTextEditorReadOnly = ({ saving }: { saving: boolean }): boolean => saving; export const canPromoteTextEditor = ({ saving }: { saving: boolean }): boolean => !saving; @@ -174,6 +187,8 @@ const TextEditorPaneInner: React.FC = ({ onSave, onRequestClose, onPromoteToTab, + onPopOut, + onDockToMain, initialViewState, }) => { const { t } = useI18n(); @@ -284,7 +299,7 @@ const TextEditorPaneInner: React.FC = ({ if (initialViewState) editor.restoreViewState(initialViewState); // Add save shortcut - use ref to avoid stale closure - editor.addCommand(monaco.KeyMod.CtrlCmd | monaco.KeyCode.KeyS, () => { + registerTextEditorCommand(editor, monaco.KeyMod.CtrlCmd | monaco.KeyCode.KeyS, () => { handleSaveRef.current(); }); @@ -292,20 +307,20 @@ const TextEditorPaneInner: React.FC = ({ // Pane's root div also tries to handle this, but Monaco's internal // key-event dispatcher fires first for focused editor keystrokes, so // registering the command here is the reliable path. - editor.addCommand(monaco.KeyMod.CtrlCmd | monaco.KeyCode.KeyW, () => { + registerTextEditorCommand(editor, monaco.KeyMod.CtrlCmd | monaco.KeyCode.KeyW, () => { if (!closeTabCommandWEnabledRef.current) return; handleCloseRef.current?.(); }); // Add find shortcut (Ctrl+F / Cmd+F) - editor.addCommand(monaco.KeyMod.CtrlCmd | monaco.KeyCode.KeyF, () => { + registerTextEditorCommand(editor, monaco.KeyMod.CtrlCmd | monaco.KeyCode.KeyF, () => { // Trigger Monaco's built-in find widget editor.trigger('keyboard', 'actions.find', null); }); // Fallback paste path for Electron environments where Monaco paste can fail. // When focus is in the find/replace widget, paste into that input instead of the body. - editor.addCommand(monaco.KeyMod.CtrlCmd | monaco.KeyCode.KeyV, () => { + registerTextEditorCommand(editor, monaco.KeyMod.CtrlCmd | monaco.KeyCode.KeyV, () => { void pasteForMonacoEditorCommand({ activeElement: document.activeElement, readClipboardText: () => readClipboardTextRef.current(), @@ -439,6 +454,42 @@ const TextEditorPaneInner: React.FC = ({ /> )} + {onPopOut && ( + + + + + {t('sftp.editor.popOut')} + + )} + + {chrome === 'window' && onDockToMain && ( + + + + + {t('sftp.editor.dock')} + + )} + {/* Close button — modal chrome only */} {chrome === 'modal' && onRequestClose && (
); diff --git a/components/host-details/HostConnectionTestDialog.tsx b/components/host-details/HostConnectionTestDialog.tsx new file mode 100644 index 000000000..d443cf9c8 --- /dev/null +++ b/components/host-details/HostConnectionTestDialog.tsx @@ -0,0 +1,153 @@ +import React, { useState } from "react"; +import { createPortal } from "react-dom"; +import { useI18n } from "../../application/i18n/I18nProvider"; +import type { Host, SSHKey } from "../../types"; +import { TerminalConnectionDialog } from "../terminal/TerminalConnectionDialog"; +import type { TerminalAuthMethod } from "../terminal/TerminalAuthDialog"; +import type { useHostConnectionTest } from "../../application/state/useHostConnectionTest"; + +type HostConnectionTest = ReturnType; + +export interface HostConnectionTestDialogProps { + open: boolean; + onClose: () => void; + host: Host; + keys: SSHKey[]; + test: HostConnectionTest; +} + +/** + * Host-editor "test connection" overlay. It reuses the terminal's own + * connection dialog verbatim (progress bar, per-hop chain header, auth prompt, + * and host-key verification) and keeps it mounted on success so the final + * "Connected" state shows through the same dialog with retry/close. + */ +export const HostConnectionTestDialog: React.FC = ({ + open, + onClose, + host, + keys, + test, +}) => { + const { t } = useI18n(); + const { state } = test; + const [showLogs, setShowLogs] = useState(false); + + // Auth re-entry form state (shown when state.needsAuth is true). + const [authMethod, setAuthMethod] = useState("password"); + const [authUsername, setAuthUsername] = useState(host.username || "root"); + const [authPassword, setAuthPassword] = useState(""); + const [authKeyId, setAuthKeyId] = useState(null); + const [authPassphrase, setAuthPassphrase] = useState(""); + const [showAuthPassphrase, setShowAuthPassphrase] = useState(false); + const [showAuthPassword, setShowAuthPassword] = useState(false); + + const handleClose = () => { + if (state.status === "connecting") { + test.cancelTest(); + } + setShowLogs(false); + onClose(); + }; + + const authIsValid = + authMethod === "password" ? authPassword.length > 0 : Boolean(authKeyId); + + const submitAuth = () => { + if (!authIsValid) return; + test.submitAuth({ + authMethod, + username: authUsername, + ...(authMethod === "password" + ? { password: authPassword } + : { keyId: authKeyId ?? undefined, passphrase: authPassphrase || undefined }), + }); + }; + + if (!open) return null; + + const dialogStatus = + state.status === "connecting" + ? "connecting" + : state.status === "connected" + ? "connected" + : "disconnected"; + + return createPortal( +
+ void test.retry(), + reconnectLabel: t("hostConnectionTest.retry"), + closeLabel: t("common.close"), + }} + /> +
, + document.body, + ); +}; + +export default HostConnectionTestDialog; diff --git a/components/notes/NotesManager.persistence.test.tsx b/components/notes/NotesManager.persistence.test.tsx new file mode 100644 index 000000000..20917cfa9 --- /dev/null +++ b/components/notes/NotesManager.persistence.test.tsx @@ -0,0 +1,336 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { register } from "node:module"; +import React, { act } from "react"; +import type { VaultNote } from "../../domain/models.ts"; + +// JSDOM has no stylesheet loader. All editor/application JavaScript stays real. +register(`data:text/javascript,${encodeURIComponent(` + export async function load(url, context, nextLoad) { + if (url.endsWith("/components/notes/noteMath.scss")) { + return { format: "module", source: "export {};", shortCircuit: true }; + } + return nextLoad(url, context); + } +`)}`, import.meta.url); + +// Same browser shims as InlineMarkdownEditor.unrenderableMarkdown.test.tsx. +import {JSDOM} from "jsdom"; +const setupDom = () => { + const dom = new JSDOM('
', { + pretendToBeVisual: true, + url: "http://localhost", + }); + const window = dom.window; + const previousGlobals = new Map(); + const installGlobal = (key: string, value: unknown) => { + previousGlobals.set(key, Object.getOwnPropertyDescriptor(globalThis, key)); + Object.defineProperty(globalThis, key, { configurable: true, writable: true, value }); + }; + + class ResizeObserverStub { + observe() {} + unobserve() {} + disconnect() {} + } + + function LoadedImageStub() { + const image = window.document.createElement("img"); + Object.defineProperty(image, "src", { + get: () => image.getAttribute("src") ?? "", + set: (src: string) => { + image.setAttribute("src", src); + queueMicrotask(() => image.dispatchEvent(new window.Event("load"))); + }, + }); + return image; + } + + Object.assign(window.Range.prototype, { + getClientRects: () => [], + getBoundingClientRect: () => new window.DOMRect(), + }); + + // CodeMirror needs these browser APIs when the full note contains code blocks. + window.matchMedia = (query: string) => ({ + matches: false, + media: query, + onchange: null, + addListener() {}, + removeListener() {}, + addEventListener() {}, + removeEventListener() {}, + dispatchEvent: () => false, + }); + + for (const [key, value] of Object.entries({ + window, + Window: window.Window, + Image: LoadedImageStub, + document: window.document, + navigator: window.navigator, + HTMLElement: window.HTMLElement, + HTMLImageElement: window.HTMLImageElement, + HTMLInputElement: window.HTMLInputElement, + HTMLTextAreaElement: window.HTMLTextAreaElement, + HTMLSelectElement: window.HTMLSelectElement, + Element: window.Element, + SVGElement: window.SVGElement, + Node: window.Node, + DocumentFragment: window.DocumentFragment, + Range: window.Range, + NodeFilter: window.NodeFilter, + MutationObserver: window.MutationObserver, + CustomEvent: window.CustomEvent, + DOMRect: window.DOMRect, + Event: window.Event, + KeyboardEvent: window.KeyboardEvent, + MouseEvent: window.MouseEvent, + getComputedStyle: window.getComputedStyle.bind(window), + requestAnimationFrame: window.requestAnimationFrame.bind(window), + cancelAnimationFrame: window.cancelAnimationFrame.bind(window), + ResizeObserver: ResizeObserverStub, + IS_REACT_ACT_ENVIRONMENT: true, + })) { + installGlobal(key, value); + } + + return { + window, + cleanup() { + for (const [key, descriptor] of previousGlobals) { + if (descriptor) Object.defineProperty(globalThis, key, descriptor); + else delete (globalThis as Record)[key]; + } + dom.window.close(); + }, + }; +}; + +for (const mode of ["source", "edit"] as const) { + test(`mounted notes ${mode}: failed save retains both versions through retry and switching`, { timeout: 30_000 }, async (t) => { + const { window, cleanup } = setupDom(); + // The app initializes unrelated vault collections under Web Locks. Serialize + // those calls in the fixture too (Node 22 does not always expose Web Locks). + const tails = new Map>(); + Object.defineProperty(window.navigator, "locks", { value: { + request(name: string, callback: () => unknown) { + const run = (tails.get(name) ?? Promise.resolve()).then(callback, callback); + tails.set(name, run.then(() => undefined, () => undefined)); + return run; + }, + } }); + const previousStorage = Object.getOwnPropertyDescriptor(globalThis, "localStorage"); + Object.defineProperty(globalThis, "localStorage", { configurable: true, value: window.localStorage }); + const keys = await import("../../infrastructure/config/storageKeys.ts"); + for (const [name, key] of Object.entries(keys)) { + if (name.startsWith("STORAGE_KEY_") && typeof key === "string") window.localStorage.setItem(key, "[]"); + } + const original: VaultNote = { id: "synthetic-note", title: "Synthetic note", content: "Persisted baseline", createdAt: 1, updatedAt: 1, order: 0 }; + const second: VaultNote = { ...original, id: "second-note", title: "Second note", content: "Second body", order: 1 }; + window.localStorage.setItem(keys.STORAGE_KEY_NOTES, JSON.stringify([original, second])); + window.localStorage.setItem(keys.STORAGE_KEY_VAULT_NOTES_EDITOR_MODE, mode); + const { useVaultState } = await import("../../application/state/useVaultState.ts"); + const { getNotesSnapshot, subscribeNotes } = await import("../../application/state/notesStore.ts"); + const { setNotify } = await import("../../application/notification.ts"); + const { I18nProvider } = await import("../../application/i18n/I18nProvider.tsx"); + const { TooltipProvider } = await import("../ui/tooltip.tsx"); + const { NotesManager } = await import("./NotesManager.tsx"); + const { createRoot } = await import("react-dom/client"); + const rootNode = window.document.getElementById("root")!; + const root = createRoot(rootNode); + let vault!: ReturnType; + let openNote!: React.Dispatch>; + const saveResults: boolean[] = []; + const notices: string[] = []; + let quota = false; + const realSetItem = window.Storage.prototype.setItem; + window.Storage.prototype.setItem = function (key: string, value: string) { + if (quota && key === keys.STORAGE_KEY_NOTES) throw new DOMException("Synthetic quota exceeded", "QuotaExceededError"); + return realSetItem.call(this, key, value); + }; + setNotify({ error: message => notices.push(message), info: () => {}, warning: () => {}, success: () => {} }); + function App() { + const state = useVaultState(); + vault = state; + const [noteId, setNoteId] = React.useState(original.id); + openNote = setNoteId; + const updateNotes = state.updateNotes; + const save = React.useCallback((notes: VaultNote[]) => { + const result = updateNotes(notes); + saveResults.push(result); + return result; + }, [updateNotes]); + return + ; + } + const editor = () => mode === "source" ? rootNode.querySelector("textarea") : rootNode.querySelector('[contenteditable="true"]'); + const visible = () => (mode === "source" ? (editor() as HTMLTextAreaElement)?.value : editor()?.textContent)?.trim(); + const waitFor = async (check: () => boolean, label: string) => { + const deadline = Date.now() + 6000; + while (!check()) { + assert.ok(Date.now() < deadline, `${label}; saves=${JSON.stringify(saveResults)}, visible=${visible()}, notes=${JSON.stringify(vault.notes)}`); + await act(async () => { await new Promise(resolve => setTimeout(resolve, 20)); }); + } + }; + const persisted = (): VaultNote[] => JSON.parse(window.localStorage.getItem(keys.STORAGE_KEY_NOTES)!); + const primary = () => vault.notes.find(note => note.id === original.id)!; + const edit = async (content: string) => { + await act(async () => { + if (mode === "source") { + const textarea = editor() as HTMLTextAreaElement; + Object.getOwnPropertyDescriptor(window.HTMLTextAreaElement.prototype, "value")!.set!.call(textarea, content); + textarea.dispatchEvent(new window.Event("input", { bubbles: true })); + } else { + const lexical = await import("lexical"); + const instance = lexical.getNearestEditorFromDOMNode(editor()!); + assert.ok(instance); + instance.update(() => { lexical.$getRoot().clear().append(lexical.$createParagraphNode().append(lexical.$createTextNode(content))); }, { discrete: true }); + } + }); + await waitFor(() => visible() === content, "local edit is visible"); + }; + const incoming = async (notes: VaultNote[]) => { + const value = JSON.stringify(notes); + // Independent writer bypasses only our synthetic quota failure. + realSetItem.call(window.localStorage, keys.STORAGE_KEY_NOTES, value); + await act(async () => { window.dispatchEvent(new window.StorageEvent("storage", { key: keys.STORAGE_KEY_NOTES, newValue: value, storageArea: window.localStorage })); }); + }; + try { + await act(async () => root.render()); + await waitFor(() => vault.isInitialized && visible() === original.content, "initial editor"); + await edit("Successfully saved"); + await waitFor(() => saveResults.length === 1, "normal successful debounce"); + assert.equal(saveResults[0], true); + assert.equal(persisted()[0].content.trim(), "Successfully saved"); + await incoming([{ ...original, content: "Legitimate external update", updatedAt: 5 }, second]); + await waitFor(() => visible() === "Legitimate external update", "external update after successful save"); + + quota = true; + const local = "Unsaved local draft"; + await edit(local); + assert.equal(saveResults.length, 1, "dirty control precedes autosave echo"); + await incoming([{ ...original, content: "Remote before echo", updatedAt: 10 }, second]); + assert.equal(primary().content, "Remote before echo"); + assert.equal(visible(), local, "existing editor dirty guard works before echo"); + await waitFor(() => saveResults.length === 2 && primary().content.trim() === local, "failed save optimistic echo"); + assert.equal(saveResults[1], false); + assert.equal(notices.length, 1); + assert.equal(persisted()[0].content, "Remote before echo"); + assert.ok(primary().updatedAt > 11, "incoming timestamps deliberately run behind local time"); + const remote = [{ ...original, content: "Remote after echo", updatedAt: 11 }, second]; + await incoming(remote); + assert.equal(visible(), local, "failed optimistic echo must not acknowledge durability"); + assert.equal(primary().content.trim(), local); + assert.equal(getNotesSnapshot().notes.find(note => note.id === original.id)!.content.trim(), local); + const copy = vault.notes.find(note => note.id !== original.id && note.content === "Remote after echo"); + assert.ok(copy, "external conflicting content must remain accessible as a note"); + const copyId = copy.id; + + await incoming(remote); + assert.equal(vault.notes.length, 3, "repeated storage delivery creates no duplicate copy"); + await edit("Unsaved local draft revision two"); + await waitFor(() => saveResults.length === 3, "second failed debounce"); + assert.equal(saveResults[2], false); + assert.equal(notices.length, 1, "repeated failure respects notification throttling"); + await act(async () => openNote(second.id)); + await waitFor(() => visible() === second.content, "switch to second note"); + await act(async () => openNote(original.id)); + await waitFor(() => visible() === "Unsaved local draft revision two", "switch back retains failed draft"); + // Switching notes intentionally defers the content swap by two frames. + await act(async () => { await new Promise(resolve => window.requestAnimationFrame(() => window.requestAnimationFrame(resolve))); }); + assert.equal(vault.notes.find(note => note.id === copyId)?.content, "Remote after echo"); + + quota = false; + await edit("Durable recovered draft"); + await waitFor(() => saveResults.length === 4, "successful retry"); + assert.equal(saveResults[3], true); + assert.equal(persisted().find(note => note.id === original.id)?.content.trim(), "Durable recovered draft"); + assert.equal(persisted().find(note => note.id === copyId)?.content, "Remote after echo"); + const afterSuccess = persisted().map(note => note.id === original.id ? { ...note, content: "External after recovery", updatedAt: 2 } : note); + await incoming(afterSuccess); + await waitFor(() => visible() === "External after recovery", "successful same-generation save releases pending protection"); + assert.equal(vault.notes.length, 3); + + await act(async () => { window.dispatchEvent(new window.StorageEvent("storage", { + key: keys.STORAGE_KEY_NOTES, newValue: JSON.stringify(remote), storageArea: window.localStorage, + })); }); + assert.equal(visible(), "External after recovery", "queued obsolete storage events must not roll back a successful save"); + + // Publishing a successful generation can synchronously trigger another + // write. The older acknowledgement must never clear the newer failure. + let nestedResult: boolean | undefined; + let triggered = false; + const unsubscribe = subscribeNotes(() => { + if (triggered) return; + triggered = true; + quota = true; + nestedResult = vault.updateNotes(getNotesSnapshot().notes.map(note => note.id === original.id ? { ...note, content: "Newer failed generation" } : note)); + }); + try { + await act(async () => { + assert.equal(vault.updateNotes(vault.notes.map(note => note.id === original.id ? { ...note, content: "Older successful generation" } : note)), true); + }); + } finally { unsubscribe(); } + assert.equal(nestedResult, false); + await incoming(persisted().map(note => note.id === original.id ? { ...note, content: "Concurrent with newer failure" } : note)); + assert.equal(primary().content, "Newer failed generation"); + assert.ok(vault.notes.some(note => note.content === "Concurrent with newer failure")); + + // Full-snapshot replacement (clear/restore) remains explicit and does not + // accidentally resurrect a disk-only note through the save-time rebase. + quota = false; + realSetItem.call(window.localStorage, keys.STORAGE_KEY_NOTES, JSON.stringify([...persisted(), { ...second, id: "disk-only" }])); + await act(async () => { assert.equal(vault.updateNotes([original], { replace: true }), true); }); + assert.deepEqual(persisted().map(note => note.id), [original.id]); + + for (const operation of ["clear", "import"] as const) { + await t.test(`failed ${operation} retains replacement ownership through events and retry`, async () => { + quota = false; + await act(async () => { assert.equal(vault.updateNotes([original, second], { replace: true }), true); }); + const replacement = operation === "clear" ? [] : [{ ...original, content: "Imported snapshot" }]; + quota = true; + await act(async () => { + if (operation === "clear") vault.clearVaultData(); + else await vault.importDataFromString(JSON.stringify({ notes: replacement })); + }); + const contents = (notes: VaultNote[]) => notes.map(({ id, content }) => [id, content]); + assert.deepEqual(contents(vault.notes), contents(replacement)); + assert.deepEqual(contents(persisted()), contents([original, second]), "replacement has not reached disk"); + const peer = [ + { ...original, content: "Changed in another window", updatedAt: 30 }, second, + { ...second, id: "peer-added", content: "Added in another window" }, + ]; + await incoming(peer); + assert.deepEqual(contents(vault.notes), contents(replacement), "failed replacement must not adopt peer edits or additions"); + assert.deepEqual(contents(getNotesSnapshot().notes), contents(replacement)); + + const edited = operation === "clear" + ? [{ ...original, id: "created-after-clear", content: "New local note" }] + : replacement.map(note => ({ ...note, content: "Edited after import" })); + await act(async () => { assert.equal(vault.updateNotes(edited), false); }); + await incoming([...peer, { ...second, id: "peer-added-again" }]); + assert.deepEqual(contents(vault.notes), contents(edited), "an ordinary failed edit must inherit replacement ownership"); + + // Also cover a peer write whose storage event has not arrived yet. + realSetItem.call(window.localStorage, keys.STORAGE_KEY_NOTES, JSON.stringify([...peer, { ...second, id: "unobserved-peer" }])); + quota = false; + await act(async () => { assert.equal(vault.updateNotes(vault.notes), true); }); + assert.deepEqual(contents(persisted()), contents(edited), "successful retry persists only the replacement catalog"); + const afterReplacement = [{ ...second, id: "legitimate-later-peer" }]; + await incoming(afterReplacement); + assert.deepEqual(contents(vault.notes), contents(afterReplacement), "successful current owner releases replacement protection"); + }); + } + console.log(`NOTES_FIX_${mode.toUpperCase()}: success, dirty-before-echo, failed-echo conflict, duplicate delivery, repeated failure, switch, retry, legal external update passed`); + } finally { + await act(async () => root.unmount()); + window.Storage.prototype.setItem = realSetItem; + setNotify({ error: () => {}, info: () => {}, warning: () => {}, success: () => {} }); + if (previousStorage) Object.defineProperty(globalThis, "localStorage", previousStorage); + else delete (globalThis as Record).localStorage; + cleanup(); + } + }); +} diff --git a/components/settings/tabs/SettingsShortcutsTab.test.tsx b/components/settings/tabs/SettingsShortcutsTab.test.tsx new file mode 100644 index 000000000..555c18fb3 --- /dev/null +++ b/components/settings/tabs/SettingsShortcutsTab.test.tsx @@ -0,0 +1,86 @@ +import assert from "node:assert/strict"; +import test, { type TestContext } from "node:test"; +import React from "react"; +import { DEFAULT_KEY_BINDINGS } from "../../../domain/models/keyBindings"; +import { createDomRenderer, dispatchDomEvent, installDomEnvironment } from "../../test-support/renderReactDom"; + +async function setup(t: TestContext, label: string) { + const env = installDomEnvironment(); + const previous = new Map(); + const globals = { + DocumentFragment: env.window.DocumentFragment, + MutationObserver: env.window.MutationObserver, + Element: env.window.Element, + requestAnimationFrame: (callback: FrameRequestCallback) => setTimeout(() => callback(Date.now()), 0), + cancelAnimationFrame: clearTimeout, + }; + for (const [key, value] of Object.entries(globals)) { + previous.set(key, Object.getOwnPropertyDescriptor(globalThis, key)); + Object.defineProperty(globalThis, key, { configurable: true, writable: true, value }); + } + const { default: SettingsShortcutsTab } = await import("./SettingsShortcutsTab"); + const { I18nProvider } = await import("../../../application/i18n/I18nProvider"); + const { Tabs } = await import("../../ui/tabs"); + const renderer = await createDomRenderer(env.document); + t.after(async () => { + await renderer.unmount(); + for (const [key, descriptor] of previous) { + if (descriptor) Object.defineProperty(globalThis, key, descriptor); + else Reflect.deleteProperty(globalThis, key); + } + env.cleanup(); + }); + const updates: string[][] = []; + await renderer.render( {}} + shellOnlyTabNumberShortcuts={false} setShellOnlyTabNumberShortcuts={() => {}} + showTabNumberBadges={true} setShowTabNumberBadges={() => {}} + disableTerminalFontZoom={false} setDisableTerminalFontZoom={() => {}} + keyBindings={DEFAULT_KEY_BINDINGS} resetAllKeyBindings={() => {}} + updateKeyBinding={(...args) => { updates.push(args); }} + />); + const row = [...env.document.querySelectorAll("span")].find((span) => span.textContent === label)?.parentElement; + const button = row?.querySelector("button"); + assert.ok(button); + await dispatchDomEvent(button, new env.window.MouseEvent("click", { bubbles: true })); + const key = (type: "keydown" | "keyup", init: KeyboardEventInit) => dispatchDomEvent( + env.window, new env.window.KeyboardEvent(type, { bubbles: true, cancelable: true, ...init }), + ); + return { updates, key, button }; +} + +test("the tab modifier editor saves left Alt on release without requiring a digit", async (t) => { + const { updates, key } = await setup(t, "Switch to Tab [1...9]"); + await key("keydown", { key: "Alt", code: "AltLeft", altKey: true }); + assert.deepEqual(updates, []); + await key("keyup", { key: "Alt", code: "AltLeft" }); + assert.deepEqual(updates, [["switch-tab-1-9", "pc", "Alt + [1...9]"]]); +}); + +test("the modifier editor waits for the full modifier chord to be released", async (t) => { + const { updates, key } = await setup(t, "Switch to Tab [1...9]"); + await key("keydown", { key: "Control", ctrlKey: true }); + await key("keydown", { key: "Alt", ctrlKey: true, altKey: true }); + await key("keyup", { key: "Alt", ctrlKey: true }); + assert.deepEqual(updates, []); + await key("keyup", { key: "Control" }); + assert.deepEqual(updates, [["switch-tab-1-9", "pc", "Ctrl + Alt + [1...9]"]]); +}); + +test("ordinary shortcuts still require a non-modifier key", async (t) => { + const { updates, key } = await setup(t, "Next Tab"); + await key("keydown", { key: "Alt", altKey: true }); + await key("keyup", { key: "Alt" }); + assert.deepEqual(updates, []); + await key("keydown", { key: "ArrowRight", altKey: true }); + assert.deepEqual(updates, [["next-tab", "pc", "Alt + \u2192"]]); +}); + +test("Escape cancels an unfinished modifier recording", async (t) => { + const { updates, key, button } = await setup(t, "Switch to Tab [1...9]"); + await key("keydown", { key: "Alt", altKey: true }); + await key("keydown", { key: "Escape", altKey: true }); + await key("keyup", { key: "Alt" }); + assert.deepEqual(updates, []); + assert.equal(button.textContent, "Ctrl"); +}); diff --git a/components/settings/tabs/SettingsShortcutsTab.tsx b/components/settings/tabs/SettingsShortcutsTab.tsx index 2b6a354c3..13bb01c3a 100644 --- a/components/settings/tabs/SettingsShortcutsTab.tsx +++ b/components/settings/tabs/SettingsShortcutsTab.tsx @@ -64,6 +64,7 @@ export default function SettingsShortcutsTab(props: { if (!recordingBindingId || !recordingScheme) return; const specialSuffix = getSpecialSuffix(recordingBindingId); + let pendingModifierBinding: string | null = null; const handleKeyDown = (e: KeyboardEvent) => { // Skip while app lock overlay is up so password keys never bind (Codex P2). @@ -77,8 +78,6 @@ export default function SettingsShortcutsTab(props: { } if (specialSuffix) { - if (["Meta", "Control", "Alt", "Shift"].includes(e.key)) return; - const parts: string[] = []; if (recordingScheme === "mac") { if (e.metaKey) parts.push("⌘"); @@ -95,6 +94,11 @@ export default function SettingsShortcutsTab(props: { const modifierString = parts.length > 0 ? `${parts.join(" + ")} + ` : ""; const fullKeyString = modifierString + specialSuffix; + if (["Meta", "Control", "Alt", "Shift"].includes(e.key)) { + pendingModifierBinding = fullKeyString; + return; + } + updateKeyBinding?.(recordingBindingId, recordingScheme, fullKeyString); cancelRecording(); return; @@ -106,6 +110,17 @@ export default function SettingsShortcutsTab(props: { cancelRecording(); }; + const handleKeyUp = (e: KeyboardEvent) => { + if (!specialSuffix || !pendingModifierBinding || isAppLockOverlayActive()) return; + if (!["Meta", "Control", "Alt", "Shift"].includes(e.key)) return; + e.preventDefault(); + e.stopPropagation(); + // Wait for release so a modifier-only editor can capture Ctrl+Alt, etc. + if (e.metaKey || e.ctrlKey || e.altKey || e.shiftKey) return; + updateKeyBinding?.(recordingBindingId, recordingScheme, pendingModifierBinding); + cancelRecording(); + }; + const handleClick = () => { cancelRecording(); }; @@ -115,9 +130,11 @@ export default function SettingsShortcutsTab(props: { }, 100); window.addEventListener("keydown", handleKeyDown, true); + window.addEventListener("keyup", handleKeyUp, true); return () => { clearTimeout(timer); window.removeEventListener("keydown", handleKeyDown, true); + window.removeEventListener("keyup", handleKeyUp, true); window.removeEventListener("click", handleClick, true); }; }, [recordingBindingId, recordingScheme, updateKeyBinding, cancelRecording, getSpecialSuffix]); diff --git a/components/sftp/SftpOverlays.tsx b/components/sftp/SftpOverlays.tsx index 44b5dd55a..979d70899 100644 --- a/components/sftp/SftpOverlays.tsx +++ b/components/sftp/SftpOverlays.tsx @@ -102,6 +102,7 @@ interface SftpOverlaysProps { handleFileOpenerSelect: (openerType: FileOpenerType, setAsDefault: boolean, systemApp?: SystemAppInfo) => void; handleSelectSystemApp: (systemApp: { path: string; name: string }) => void; onPromoteToTab?: (snapshot: TextEditorModalSnapshot) => void; + onPopOut?: (snapshot: TextEditorModalSnapshot) => void; onRequestTerminalFocus?: () => void; } @@ -145,6 +146,7 @@ export const SftpOverlays: React.FC = React.memo(({ handleFileOpenerSelect, handleSelectSystemApp, onPromoteToTab, + onPopOut, onRequestTerminalFocus, }) => { const textEditorFileName = textEditorTarget?.file.name || ""; @@ -269,6 +271,7 @@ export const SftpOverlays: React.FC = React.memo(({ hotkeyScheme={hotkeyScheme} keyBindings={keyBindings} onPromoteToTab={onPromoteToTab} + onPopOut={onPopOut} /> diff --git a/components/sftp/hooks/useSftpViewFileOps.ts b/components/sftp/hooks/useSftpViewFileOps.ts index 74e0d273e..0b090d6b4 100644 --- a/components/sftp/hooks/useSftpViewFileOps.ts +++ b/components/sftp/hooks/useSftpViewFileOps.ts @@ -14,6 +14,7 @@ import { isNavigableDirectory } from "../utils"; import { resolveDownloadSourceSnapshot } from "../../../application/state/sftp/downloadSourceSnapshot"; import { reportSftpUploadResults } from "../reportSftpUploadResults"; import { editorTabStore } from "../../../application/state/editorTabStore"; +import { popOutEditorTab } from "../../../application/state/editorWindowClient"; import { toEditorTabId, activeTabStore } from "../../../application/state/activeTabStore"; import type { TextEditorModalSnapshot } from "../../TextEditorModal"; import type { UseSftpViewFileOpsParams, UseSftpViewFileOpsResult } from "./useSftpViewFileOps.types"; @@ -27,6 +28,15 @@ const LOCAL_BLOB_DOWNLOAD_CONCURRENCY = 1; */ const MULTI_SELECT_ROOT_DOWNLOAD_CONCURRENCY = DEFAULT_SFTP_FILE_TRANSFER_CONCURRENCY; +const promoteModalSnapshot = (snapshot: Parameters[0]) => { + try { + return editorTabStore.promoteFromModal(snapshot); + } catch (error) { + toast.error(error instanceof Error ? error.message : "Failed to transfer editor", "SFTP"); + return null; + } +}; + export const useSftpViewFileOps = ({ sftpRef, behaviorRef, @@ -232,7 +242,7 @@ export const useSftpViewFileOps = ({ const connection = pane.connection; if (!connection || !target.hostId) return; - const editorId = editorTabStore.promoteFromModal({ + const editorId = promoteModalSnapshot({ sessionId: connection.id, sftpTabId: pane.id, hostId: target.hostId, @@ -244,6 +254,16 @@ export const useSftpViewFileOps = ({ wordWrap: snapshot.wordWrap, viewState: snapshot.viewState, }); + if (!editorId) return; + if (editorTabStore.getTab(editorId)?.placement === "window") { + void popOutEditorTab(editorId).then((ok) => { + if (!ok) return; + setShowTextEditor(false); + setTextEditorTarget(null); + setTextEditorContent(""); + }); + return; + } activeTabStore.setActiveTabId(toEditorTabId(editorId)); // Close the modal setShowTextEditor(false); @@ -251,6 +271,34 @@ export const useSftpViewFileOps = ({ setTextEditorContent(""); }, [sftpRef]); + const handlePopOut = useCallback((snapshot: TextEditorModalSnapshot) => { + const target = textEditorTargetRef.current; + if (!target) return; + const pane = target.side === "left" ? sftpRef.current.leftPane : sftpRef.current.rightPane; + const connection = pane.connection; + if (!connection || !target.hostId) return; + + const editorId = promoteModalSnapshot({ + sessionId: connection.id, + sftpTabId: pane.id, + hostId: target.hostId, + remotePath: target.fullPath, + fileName: target.file.name, + languageId: snapshot.languageId || getLanguageId(target.file.name), + content: snapshot.content, + baselineContent: snapshot.baselineContent, + wordWrap: snapshot.wordWrap, + viewState: snapshot.viewState, + }); + if (!editorId) return; + void popOutEditorTab(editorId).then((ok) => { + if (!ok) return; + setShowTextEditor(false); + setTextEditorTarget(null); + setTextEditorContent(""); + }); + }, [sftpRef]); + const onEditFileLeft = useCallback( (file: SftpFileEntry, fullPath?: string) => handleEditFileForSide("left", file, fullPath), [handleEditFileForSide], @@ -808,6 +856,7 @@ export const useSftpViewFileOps = ({ setFileOpenerTarget, handleSaveTextFile, onPromoteToTab: handlePromoteToTab, + onPopOut: handlePopOut, handleFileOpenerSelect, handleSelectSystemApp, onEditPermissionsLeft, diff --git a/components/sftp/hooks/useSftpViewFileOps.types.ts b/components/sftp/hooks/useSftpViewFileOps.types.ts index e8f1f5646..aefa788a9 100644 --- a/components/sftp/hooks/useSftpViewFileOps.types.ts +++ b/components/sftp/hooks/useSftpViewFileOps.types.ts @@ -64,6 +64,7 @@ export interface UseSftpViewFileOpsResult { >; handleSaveTextFile: (content: string) => Promise; onPromoteToTab: (snapshot: TextEditorModalSnapshot) => void; + onPopOut: (snapshot: TextEditorModalSnapshot) => void; handleFileOpenerSelect: ( openerType: FileOpenerType, setAsDefault: boolean, diff --git a/components/sftp/hooks/useSftpViewPaneActions.ts b/components/sftp/hooks/useSftpViewPaneActions.ts index 214548448..6851405f1 100644 --- a/components/sftp/hooks/useSftpViewPaneActions.ts +++ b/components/sftp/hooks/useSftpViewPaneActions.ts @@ -6,6 +6,7 @@ import { keepOnlyActivePaneSelections } from "./selectionScope"; import { editorTabStore } from "../../../application/state/editorTabStore"; import type { EditorTab, EditorTabId } from "../../../application/state/editorTabStore"; import { releaseEditorTabSaveCoordinator, saveEditorTab } from "../../../application/state/editorTabSave"; +import { confirmCloseOwnedEditors } from "../../../application/state/editorWindowClient"; import { promptUnsavedChanges } from "../../editor/UnsavedChangesDialog"; import { toast } from "../../ui/toast"; import { requireCopyToOtherPaneTarget } from "../copyToOtherPane"; @@ -182,7 +183,7 @@ export const useSftpViewPaneActions = ({ throw new Error(tab?.saveError ?? "Save failed"); } }; - return editorTabStore.confirmCloseByOwner( + return confirmCloseOwnedEditors( { sessionId: pane.connection?.id, sftpTabId: pane.id }, choice, saveTab, diff --git a/components/sftp/hooks/useSftpViewPaneCallbacks.ts b/components/sftp/hooks/useSftpViewPaneCallbacks.ts index f47251074..b84060e61 100644 --- a/components/sftp/hooks/useSftpViewPaneCallbacks.ts +++ b/components/sftp/hooks/useSftpViewPaneCallbacks.ts @@ -233,6 +233,7 @@ export const useSftpViewPaneCallbacks = ({ setFileOpenerTarget: fileOps.setFileOpenerTarget, handleSaveTextFile: fileOps.handleSaveTextFile, onPromoteToTab: fileOps.onPromoteToTab, + onPopOut: fileOps.onPopOut, handleFileOpenerSelect: fileOps.handleFileOpenerSelect, handleSelectSystemApp: fileOps.handleSelectSystemApp, }; diff --git a/components/sftp/hooks/useSftpViewTabs.ts b/components/sftp/hooks/useSftpViewTabs.ts index 5cac132e6..ce247b748 100644 --- a/components/sftp/hooks/useSftpViewTabs.ts +++ b/components/sftp/hooks/useSftpViewTabs.ts @@ -5,6 +5,7 @@ import type { SftpStateApi } from "../../../application/state/useSftpState"; import { editorTabStore } from "../../../application/state/editorTabStore"; import type { EditorTab, EditorTabId } from "../../../application/state/editorTabStore"; import { releaseEditorTabSaveCoordinator, saveEditorTab } from "../../../application/state/editorTabSave"; +import { confirmCloseOwnedEditors } from "../../../application/state/editorWindowClient"; import { promptUnsavedChanges } from "../../editor/UnsavedChangesDialog"; import { getSftpTabDuplicateRequest, @@ -84,7 +85,7 @@ export const useSftpViewTabs = ({ sftp, sftpRef, hosts = [] }: UseSftpViewTabsPa throw new Error(tab?.saveError ?? "Save failed"); } }; - return editorTabStore.confirmCloseByOwner( + return confirmCloseOwnedEditors( owner, choice, saveTab, diff --git a/components/terminal/AutocompletePopup.hover.test.tsx b/components/terminal/AutocompletePopup.hover.test.tsx index 4f988ea64..306386e39 100644 --- a/components/terminal/AutocompletePopup.hover.test.tsx +++ b/components/terminal/AutocompletePopup.hover.test.tsx @@ -1,6 +1,7 @@ import test from "node:test"; import assert from "node:assert/strict"; import { JSDOM } from "jsdom"; +import { reconcileAutocompletePopupState, type AutocompleteState } from "./autocomplete/useTerminalAutocomplete.ts"; /** * Regression test for #3426: hovering autocomplete rows must not change the @@ -10,7 +11,7 @@ import { JSDOM } from "jsdom"; * the hovered row and self-oscillating (violent mousemove jitter). */ -test("autocomplete popup keeps a fixed geometry while hovering rows (#3426)", async () => { +test("autocomplete popup keeps a fixed geometry while hovering rows (#3426)", async (t) => { const dom = new JSDOM('
', { pretendToBeVisual: true, url: "http://localhost", @@ -194,6 +195,38 @@ test("autocomplete popup keeps a fixed geometry while hovering rows (#3426)", as } } + await t.test("edited snippet rows cannot be clicked before the provider refresh", async () => { + let clicks = 0; + const state: AutocompleteState = { + suggestions: [{ text: "deploy", displayText: "deploy", source: "snippet", score: 2000, + snippet: { id: "deploy", label: "deploy", command: "echo deploy" } }], + selectedIndex: -1, popupVisible: true, + popupAnchorViewport: { left: 100, top: 300, bottom: 316 }, + expandUpward: false, subDirPanels: [], subDirFocusLevel: -1, + }; + const renderPopup = async (next: AutocompleteState) => { + await act(async () => { + root.render( { clicks++; }} containerRef={containerRef} + />); + }); + }; + await renderPopup(state); + const label = Array.from(rootNode.querySelectorAll("span")) + .find((el) => el.textContent === "deploy"); + const row = label?.closest("div[style*='cursor']"); + assert.ok(row); + await act(async () => { + row.dispatchEvent(new window.MouseEvent("mousedown", { bubbles: true })); + }); + assert.equal(clicks, 1, "an unselected fresh snippet is clickable"); + await renderPopup(reconcileAutocompletePopupState(state, "git pull xyz")); + assert.equal(Boolean(rootNode.querySelector(".xterm-autocomplete-popup")), false); + assert.equal(clicks, 1); + }); + await act(async () => { root.unmount(); }); diff --git a/components/terminal/TerminalConnectionProgress.tsx b/components/terminal/TerminalConnectionProgress.tsx index cd98b29c4..6ae8cc2bc 100644 --- a/components/terminal/TerminalConnectionProgress.tsx +++ b/components/terminal/TerminalConnectionProgress.tsx @@ -21,6 +21,7 @@ export interface TerminalConnectionProgressProps { onCloseSession: () => void; onRetry?: () => void; reconnectLabel?: string; + closeLabel?: string; } export interface TerminalConnectionLogListProps { @@ -65,6 +66,7 @@ export const TerminalConnectionProgress: React.FC { const { t } = useI18n(); @@ -81,6 +83,13 @@ export const TerminalConnectionProgress: React.FC + ) : status === 'connected' ? ( + <> +
+ + {t('terminal.progress.connected')} + + ) : ( <>
@@ -105,7 +114,7 @@ export const TerminalConnectionProgress: React.FC {onRetry && (
+ {tooltip} + + {!isDetached && onPopOutEditorTab && ( + onPopOutEditorTab(editorTab.id)}> + + {t('sftp.editor.popOut')} + + )} + {isDetached && onFocusEditorWindowTab && ( + onFocusEditorWindowTab(editorTab.id)}> + + {t('sftp.editor.focusWindow')} + + )} + onRequestCloseEditorTab(editorTab.id)}> + + {t('sftp.editor.close')} + + + ); }); EditorTopTab.displayName = 'EditorTopTab'; diff --git a/components/vault/VaultViewLayout.tsx b/components/vault/VaultViewLayout.tsx index 82d140825..d23b98a06 100644 --- a/components/vault/VaultViewLayout.tsx +++ b/components/vault/VaultViewLayout.tsx @@ -371,6 +371,7 @@ export function VaultViewLayout({ ctx }: { ctx: VaultViewLayoutContext }) { knownHosts, NotebookText, NotesManager, + onAddKnownHost, onOpenHostFromNote, onOpenNoteIdHandled, onOpenSnippetIdHandled, @@ -1470,6 +1471,9 @@ export function VaultViewLayout({ ctx }: { ctx: VaultViewLayoutContext }) { onSnippetsChange={onUpdateSnippets} onHostsChange={onUpdateHosts} onImportKey={onImportOrReuseKey} + knownHosts={knownHosts} + onAddKnownHost={onAddKnownHost} + terminalSettings={terminalSettings} onSave={(host) => { const latestHost = hosts.find( (entry: { id: string }) => entry.id === host.id, diff --git a/docs/assets/star-history/star-history-dark.svg b/docs/assets/star-history/star-history-dark.svg index c625c8bde..73fd87d34 100644 --- a/docs/assets/star-history/star-history-dark.svg +++ b/docs/assets/star-history/star-history-dark.svg @@ -31,20 +31,20 @@ 7K GitHub Stars Jan 2026 - Feb 2026 - Apr 2026 - May 2026 - Jun 2026 - Jul 2026 - Aug 2026 - Sep 2026 - Oct 2026 + Mar 2026 + Apr 2026 + May 2026 + Jun 2026 + Jul 2026 + Aug 2026 + Sep 2026 + Oct 2026 Date - - - -6385 + + + +6508 Made with carsteneu/mystarhistory \ No newline at end of file diff --git a/docs/assets/star-history/star-history-light.svg b/docs/assets/star-history/star-history-light.svg index 90c28b395..909cd898b 100644 --- a/docs/assets/star-history/star-history-light.svg +++ b/docs/assets/star-history/star-history-light.svg @@ -31,20 +31,20 @@ 7K GitHub Stars Jan 2026 - Feb 2026 - Apr 2026 - May 2026 - Jun 2026 - Jul 2026 - Aug 2026 - Sep 2026 - Oct 2026 + Mar 2026 + Apr 2026 + May 2026 + Jun 2026 + Jul 2026 + Aug 2026 + Sep 2026 + Oct 2026 Date - - - -6385 + + + +6508 Made with carsteneu/mystarhistory \ No newline at end of file diff --git a/domain/aiUndoLastTurn.test.ts b/domain/aiUndoLastTurn.test.ts new file mode 100644 index 000000000..b07cb7cf8 --- /dev/null +++ b/domain/aiUndoLastTurn.test.ts @@ -0,0 +1,245 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import type { AISession, ChatMessage } from '../infrastructure/ai/types.ts'; +import { + buildUndoLastTurnBranch, + hasUnresolvedToolCalls, + resolveUndoLastTurnBoundary, +} from './aiUndoLastTurn.ts'; + +const user = (content: string): ChatMessage => ({ + id: `u_${content}`, + role: 'user', + content, + timestamp: 1, +}); +const assistant = (content: string, extra: Partial = {}): ChatMessage => ({ + id: `a_${content}`, + role: 'assistant', + content, + timestamp: 2, + ...extra, +}); +const session = (messages: ChatMessage[], extra: Partial = {}): AISession => ({ + id: 'chat-1', + title: 'Chat', + agentId: 'catty', + scope: { type: 'terminal', targetId: 't1' }, + messages, + createdAt: 1, + updatedAt: 2, + ...extra, +}); + +test('resolveUndoLastTurnBoundary finds the last user message index', () => { + const messages = [user('one'), assistant('reply'), user('two'), assistant('reply2')]; + assert.equal(resolveUndoLastTurnBoundary(messages), 2); +}); + +test('resolveUndoLastTurnBoundary returns null without a user message', () => { + assert.equal(resolveUndoLastTurnBoundary([assistant('hello')]), null); + assert.equal(resolveUndoLastTurnBoundary([]), null); +}); + +test('resolveUndoLastTurnBoundary refuses boundaries inside the compacted prefix', () => { + const messages = [user('compacted'), assistant('x'), user('last'), assistant('y')]; + assert.equal(resolveUndoLastTurnBoundary(messages, 3), null); + assert.equal(resolveUndoLastTurnBoundary(messages, 2), 2); +}); + +test('resolveUndoLastTurnBoundary refuses cuts between a tool call and its result', () => { + // The call sits in the kept prefix while its result would be cut away. + const split = [ + assistant('', { + toolCalls: [{ id: 'call-1', name: 'harness.terminal.exec', arguments: {} }], + }), + user('again?'), + assistant('', { + toolResults: [{ toolCallId: 'call-1', content: 'ok' }], + }), + ]; + assert.equal(resolveUndoLastTurnBoundary(split), null); + assert.ok(hasUnresolvedToolCalls(split.slice(0, 2))); + // Result already inside the kept prefix — the boundary is safe. + const resolved = [ + assistant('', { + toolCalls: [{ id: 'call-1', name: 'harness.terminal.exec', arguments: {} }], + toolResults: [{ toolCallId: 'call-1', content: 'ok' }], + }), + user('again?'), + assistant('done'), + ]; + assert.equal(hasUnresolvedToolCalls(resolved.slice(0, 1)), false); + assert.equal(resolveUndoLastTurnBoundary(resolved), 1); +}); + +test('buildUndoLastTurnBranch branches before the last user turn and restores it', () => { + const source = session([ + user('first'), + assistant('answer'), + user('bad prompt'), + assistant('bad answer'), + ]); + const result = buildUndoLastTurnBranch(source, { newId: 'chat-2', now: 42 }); + assert.ok(result); + assert.equal(result.session.id, 'chat-2'); + assert.deepEqual(result.session.messages.map((m) => m.content), ['first', 'answer']); + assert.equal(result.restored.text, 'bad prompt'); + // Original session untouched (non-destructive). + assert.equal(source.messages.length, 4); +}); + +test('buildUndoLastTurnBranch clears the external agent session id and keeps compaction', () => { + const source = session( + [user('compacted'), assistant('x'), user('last'), assistant('y')], + { + externalSessionId: 'prov-1', + contextCompaction: { summary: 'summary', compactedMessageCount: 2 }, + }, + ); + const result = buildUndoLastTurnBranch(source, { newId: 'chat-2', now: 7 }); + assert.ok(result); + assert.equal(result.session.externalSessionId, undefined); + assert.deepEqual(result.session.contextCompaction, { summary: 'summary', compactedMessageCount: 2 }); +}); + +test('buildUndoLastTurnBranch restores message attachments into composer uploads', () => { + const source = session([ + user('with file'), + assistant('ok'), + { + id: 'u_last', + role: 'user', + content: 'look at this', + timestamp: 3, + attachments: [ + { base64Data: 'QUJD', mediaType: 'text/plain', filename: 'notes.txt', lineCount: 3 }, + ], + }, + ]); + const result = buildUndoLastTurnBranch(source, { newId: 'chat-2', now: 99 }); + assert.ok(result); + assert.deepEqual(result.restored.attachments, [ + { + id: 'undo_99_0', + filename: 'notes.txt', + mediaType: 'text/plain', + base64Data: 'QUJD', + dataUrl: 'data:text/plain;base64,QUJD', + lineCount: 3, + }, + ]); +}); + +test('buildUndoLastTurnBranch restores the undone turn selected user skills', () => { + const source = session([ + user('first'), + assistant('answer'), + { ...user('undo me'), selectedUserSkillSlugs: ['web-search', 'notes-writer'] }, + assistant('bad answer'), + ]); + const result = buildUndoLastTurnBranch(source, { newId: 'chat-2', now: 42 }); + assert.ok(result); + assert.deepEqual(result.restored.selectedUserSkillSlugs, ['web-search', 'notes-writer']); + // Original session untouched (non-destructive). + assert.deepEqual(source.messages[2]?.selectedUserSkillSlugs, ['web-search', 'notes-writer']); +}); + +test('buildUndoLastTurnBranch restores no skill slugs for a plain turn', () => { + const source = session([user('first'), assistant('answer'), user('undo me'), assistant('ok')]); + const result = buildUndoLastTurnBranch(source, { newId: 'chat-2', now: 42 }); + assert.ok(result); + assert.deepEqual(result.restored.selectedUserSkillSlugs, []); +}); + +test('buildUndoLastTurnBranch returns null when no undo boundary exists', () => { + assert.equal( + buildUndoLastTurnBranch(session([assistant('only answer')]), { newId: 'x', now: 1 }), + null, + ); +}); + +test('buildUndoLastTurnBranch resets the title when undo removes the first turn', () => { + // Undoing the first turn leaves the branch with no user message at all, so + // the inherited title was derived from the removed prompt and would stay + // stale forever (auto-titling only runs for untitled/New Chat sessions). + const source = session([user('first prompt'), assistant('reply')], { title: 'first prompt' }); + const result = buildUndoLastTurnBranch(source, { newId: 'chat-branch', now: 100 }); + assert.ok(result); + assert.equal(result?.session.messages.length, 0); + assert.equal(result?.restored.text, 'first prompt'); + assert.equal(result?.session.title, 'New Chat'); +}); + +test('buildUndoLastTurnBranch keeps the title when the retained user turn is note-only', () => { + // A note-only send persists empty user text while the mentioned note rides + // on the attachment (its title seeded the conversation title), so a + // blank-content retained user turn must still count as "a user message is + // kept" and must not reset the title to the untitled placeholder. + const noteOnlyUser: ChatMessage = { + id: 'u_note-only', + role: 'user', + content: '', + timestamp: 3, + attachments: [{ + base64Data: '', + mediaType: 'text/markdown', + filename: 'plan.md', + vaultNoteId: 'note-1', + vaultNoteTitle: 'Deploy plan', + previewText: '# Deploy plan', + }], + }; + const source = session( + [noteOnlyUser, assistant('reply'), user('second prompt'), assistant('reply2')], + { title: 'Deploy plan' }, + ); + const result = buildUndoLastTurnBranch(source, { newId: 'chat-branch', now: 100 }); + assert.ok(result); + assert.equal(result?.session.title, 'Deploy plan'); +}); + +test('buildUndoLastTurnBranch keeps the title when a user message is retained', () => { + const source = session( + [user('first prompt'), assistant('reply'), user('second prompt'), assistant('reply2')], + { title: 'renamed by user' }, + ); + const result = buildUndoLastTurnBranch(source, { newId: 'chat-branch', now: 100 }); + assert.ok(result); + assert.equal(result?.session.messages.length, 2); + assert.equal(result?.restored.text, 'second prompt'); + // The retained prefix still contains a user message, so the source title + // keeps describing the branch's conversation. + assert.equal(result?.session.title, 'renamed by user'); +}); + +test('undo allows a failed last reply while retaining portable replay metadata', () => { + const continuation = { reasoningParts: [{ text: 'checked', providerOptions: { anthropic: { signature: 'signed-thinking' } } }] }; + const source = session([ + user('first'), + assistant('first answer', { providerContinuation: continuation }), + user('retry this request'), + assistant('request failed', { executionStatus: 'failed' }), + ], { externalSessionId: 'original-provider-thread' }); + const result = buildUndoLastTurnBranch(source, { newId: 'branch', now: 99 }); + assert.ok(result); + assert.deepEqual(result.session.messages[1].providerContinuation, continuation); + assert.equal(result.session.externalSessionId, undefined); + assert.equal(result.restored.text, 'retry this request'); + assert.equal(source.messages.length, 4); +}); + +test('safe boundary does not pair a reused call ID with an earlier result', () => { + const prefix = [ + assistant('first call', { toolCalls: [{ id: 'reused', name: 'terminal_execute', arguments: {} }] }), + assistant('first result', { toolResults: [{ toolCallId: 'reused', content: 'done' }] }), + assistant('second call', { toolCalls: [{ id: 'reused', name: 'terminal_execute', arguments: {} }] }), + ]; + assert.equal(resolveUndoLastTurnBoundary([...prefix, user('latest')]), null); + assert.equal(resolveUndoLastTurnBoundary([ + ...prefix, + assistant('second result', { toolResults: [{ toolCallId: 'reused', content: 'done again' }] }), + user('latest'), + ]), 4); +}); diff --git a/domain/aiUndoLastTurn.ts b/domain/aiUndoLastTurn.ts new file mode 100644 index 000000000..5323fa00b --- /dev/null +++ b/domain/aiUndoLastTurn.ts @@ -0,0 +1,154 @@ +import type { + AISession, + ChatMessage, + ChatMessageAttachment, + UploadedFile, +} from '../infrastructure/ai/types'; + +/** + * Non-destructive "undo last turn" for AI chat sessions. + * + * Undo branches the conversation at the boundary *before* the latest user + * message instead of truncating the original session. The original session + * stays in history untouched; the branch (without the last turn) becomes the + * conversation the user continues, and the undone user message is restored + * into the composer for editing and resending. + */ + +export interface UndoLastTurnRestoredDraft { + text: string; + attachments: UploadedFile[]; + selectedUserSkillSlugs: string[]; +} + +export interface UndoLastTurnResult { + /** Branched copy of the source session without the last user turn. */ + session: AISession; + /** Undone user message content for the composer. */ + restored: UndoLastTurnRestoredDraft; +} + +/** + * True when any assistant tool call in `messages` has no matching tool result + * inside the same list. Cutting between a tool call and its result would + * desync agent tool bookkeeping, so such boundaries are rejected. + */ +export function hasUnresolvedToolCalls(messages: readonly ChatMessage[]): boolean { + // Providers may reuse tool-call IDs across a session. Pair each result with + // the nearest preceding unresolved call carrying the same ID (same strategy + // as `repairToolMessageIntegrity`), otherwise a result for an earlier + // occurrence would satisfy a later call that reused the ID. + const pendingCalls = new Map(); + for (const message of messages) { + for (const call of message.toolCalls ?? []) { + pendingCalls.set(call.id, (pendingCalls.get(call.id) ?? 0) + 1); + } + for (const result of message.toolResults ?? []) { + const pending = pendingCalls.get(result.toolCallId) ?? 0; + if (pending > 0) pendingCalls.set(result.toolCallId, pending - 1); + } + } + for (const pending of pendingCalls.values()) { + if (pending > 0) return true; + } + return false; +} + +/** + * Index of the cut point for undoing the last turn (the last user message). + * Returns null when undo is not offered: + * - there is no user message at all, + * - the boundary would fall inside the compacted prefix (its summary would no + * longer match the kept messages), + * - cutting there would split an assistant tool call from its result. + */ +export function resolveUndoLastTurnBoundary( + messages: readonly ChatMessage[], + compactedMessageCount = 0, +): number | null { + let lastUserIndex = -1; + for (let i = messages.length - 1; i >= 0; i -= 1) { + if (messages[i].role === 'user') { + lastUserIndex = i; + break; + } + } + if (lastUserIndex < 0) return null; + if (lastUserIndex < compactedMessageCount) return null; + const keptPrefix = messages.slice(0, lastUserIndex); + if (hasUnresolvedToolCalls(keptPrefix)) return null; + return lastUserIndex; +} + +/** Map a persisted message attachment back to a composer upload (by value). */ +export function buildRestoredAttachments( + message: ChatMessage, + now: number, +): UploadedFile[] { + const attachments: ChatMessageAttachment[] = message.attachments ?? message.images ?? []; + return attachments.map((attachment, index) => ({ + id: `undo_${now}_${index}`, + filename: attachment.filename ?? '', + mediaType: attachment.mediaType, + base64Data: attachment.base64Data, + dataUrl: attachment.base64Data + ? `data:${attachment.mediaType};base64,${attachment.base64Data}` + : '', + ...(attachment.filePath !== undefined ? { filePath: attachment.filePath } : {}), + ...(attachment.terminalSelection ? { terminalSelection: true } : {}), + ...(attachment.vaultNoteId !== undefined ? { vaultNoteId: attachment.vaultNoteId } : {}), + ...(attachment.vaultNoteTitle !== undefined ? { vaultNoteTitle: attachment.vaultNoteTitle } : {}), + ...(attachment.previewText !== undefined ? { previewText: attachment.previewText } : {}), + ...(attachment.lineCount !== undefined ? { lineCount: attachment.lineCount } : {}), + })); +} + +/** + * Build the branched "undo last turn" copy of a session. + * + * The branch keeps the compacted prefix intact (its summary still describes + * the kept messages) and drops the external agent session id: external agents + * resume provider-side sessions, so the branch must start a fresh one. + */ +export function buildUndoLastTurnBranch( + source: AISession, + options: { newId: string; now: number }, +): UndoLastTurnResult | null { + const compactedMessageCount = source.contextCompaction?.compactedMessageCount ?? 0; + const boundary = resolveUndoLastTurnBoundary(source.messages, compactedMessageCount); + if (boundary == null) return null; + + const removedUserMessage = source.messages[boundary]; + const retainedPrefix = source.messages.slice(0, boundary); + // When undo removes the first turn, the branch has no user message left and + // still carries the source title, which was auto-titled from the removed + // prompt. Reset it to the untitled placeholder so `autoTitleSession` can + // retitle the branch from the prompt the user sends next; otherwise the + // branch is permanently labeled with text that is no longer its prompt. + // A retained user turn can legitimately carry empty text: a note-only send + // leaves `content` blank while the mentioned note rides on the attachment + // (and its title) — that turn still anchored the conversation, so it must + // keep the note-derived title and suppress the reset. + const hasRetainedUserMessage = retainedPrefix.some( + message => message.role === 'user', + ); + const branchedSession: AISession = { + ...source, + id: options.newId, + messages: retainedPrefix, + ...(hasRetainedUserMessage ? {} : { title: 'New Chat' }), + externalSessionId: undefined, + createdAt: options.now, + updatedAt: options.now, + }; + return { + session: branchedSession, + restored: { + text: removedUserMessage.content, + attachments: buildRestoredAttachments(removedUserMessage, options.now), + // The undone turn was selected with these skill pills: they shaped the + // model context, so resending the turn must not silently omit them. + selectedUserSkillSlugs: [...(removedUserMessage.selectedUserSkillSlugs ?? [])], + }, + }; +} diff --git a/domain/host.test.ts b/domain/host.test.ts index e04a0a0c5..44ca06090 100644 --- a/domain/host.test.ts +++ b/domain/host.test.ts @@ -772,3 +772,14 @@ test("getHostAddressForClipboard omits synthesized plugin hostnames", () => { "", ); }); + +test("CloudLinker skips the cwd probe without restricting generic Go servers", () => { + assert.equal( + shouldProbeSessionCwd({ isNetworkDevice: false, remoteSshVersion: "SSH-2.0-CloudLinker" }), + false, + ); + assert.equal( + shouldProbeSessionCwd({ isNetworkDevice: false, remoteSshVersion: "SSH-2.0-Go" }), + true, + ); +}); diff --git a/domain/hostConnectionTest.test.ts b/domain/hostConnectionTest.test.ts new file mode 100644 index 000000000..523db9d10 --- /dev/null +++ b/domain/hostConnectionTest.test.ts @@ -0,0 +1,221 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +import { + buildHostConnectionTestPlan, + formatConnectionTestProgressLog, + resolveHostConnectionTestDeadlineMs, +} from "./hostConnectionTest"; +import type { Host, SSHKey } from "./models"; + +const host = (overrides: Partial = {}): Host => ({ + id: "host-1", + label: "Target", + hostname: "target.example.test", + port: 22, + username: "alice", + tags: [], + os: "linux", + protocol: "ssh", + ...overrides, +}); + +const key = (overrides: Partial = {}): SSHKey => ({ + id: "key-1", + label: "id_ed25519", + type: "ED25519", + privateKey: "PRIVATE KEY MATERIAL", + source: "generated", + category: "key", + created: 1, + ...overrides, +}); + +const base = { + hosts: [], + keys: [], + identities: [], + knownHosts: [], + groupConfigs: [], + proxyProfiles: [], + sessionId: "test-session", + bootEpoch: 7, +}; + +test("builds password-auth options without proxy or chain", () => { + const plan = buildHostConnectionTestPlan({ + ...base, + host: host({ password: "secret", authMethod: "password" }), + }); + + assert.equal(plan.ok, true); + if (!plan.ok) return; + assert.equal(plan.needsCredentialReentry, false); + assert.equal(plan.options.hostname, "target.example.test"); + assert.equal(plan.options.port, 22); + assert.equal(plan.options.username, "alice"); + assert.equal(plan.options.password, "secret"); + assert.equal(plan.options.authMethod, "password"); + assert.equal(plan.options.proxy, undefined); + assert.equal(plan.options.jumpHosts, undefined); + assert.equal(plan.options.sessionId, "test-session"); + assert.equal(plan.options.bootEpoch, 7); + assert.equal(plan.options.verifyHostKeys, true); +}); + +test("builds key-auth options with the resolved private key", () => { + const plan = buildHostConnectionTestPlan({ + ...base, + host: host({ identityFileId: "key-1" }), + keys: [key()], + }); + + assert.equal(plan.ok, true); + if (!plan.ok) return; + assert.equal(plan.options.privateKey, "PRIVATE KEY MATERIAL"); + assert.equal(plan.options.keyId, "key-1"); + assert.equal(plan.options.password, undefined); +}); + +test("resolves a jump-host chain by id", () => { + const plan = buildHostConnectionTestPlan({ + ...base, + host: host({ hostChain: { hostIds: ["jump-1"] } }), + hosts: [ + host({ + id: "jump-1", + label: "Bastion", + hostname: "jump.example.test", + username: "root", + password: "jump-pass", + authMethod: "password", + }), + ], + }); + + assert.equal(plan.ok, true); + if (!plan.ok) return; + assert.equal(plan.options.jumpHosts?.length, 1); + assert.equal(plan.options.jumpHosts?.[0].hostname, "jump.example.test"); + assert.equal(plan.options.jumpHosts?.[0].username, "root"); + assert.equal(plan.options.jumpHosts?.[0].password, "jump-pass"); +}); + +test("fails when a configured chain host is missing", () => { + const plan = buildHostConnectionTestPlan({ + ...base, + host: host({ hostChain: { hostIds: ["missing-1"] } }), + }); + + assert.equal(plan.ok, false); + if (plan.ok) return; + assert.match(plan.error, /jump host is missing/); +}); + +test("flags an undecryptable primary password for re-entry and strips it", () => { + const encrypted = `enc:v1:${Buffer.from(`v10${"\0".repeat(16)}`).toString("base64")}`; + const plan = buildHostConnectionTestPlan({ + ...base, + host: host({ password: encrypted, authMethod: "password" }), + }); + + assert.equal(plan.ok, true); + if (!plan.ok) return; + assert.equal(plan.needsCredentialReentry, true); + assert.equal(plan.options.password, undefined); +}); + +test("sanitizes an encrypted proxy credential into a failure plan", () => { + const encrypted = `enc:v1:${Buffer.from(`v10${"\0".repeat(16)}`).toString("base64")}`; + const plan = buildHostConnectionTestPlan({ + ...base, + host: host({ + proxyConfig: { + type: "socks5", + host: "proxy.example.test", + port: 1080, + username: "proxy-user", + password: encrypted, + }, + }), + }); + + assert.equal(plan.ok, false); + if (plan.ok) return; + assert.match(plan.error, /Proxy credentials cannot be decrypted/); +}); + +test("pins auto + password to a single password attempt", () => { + const plan = buildHostConnectionTestPlan({ + ...base, + host: host({ password: "secret", authPolicyVersion: 1 }), + }); + + assert.equal(plan.ok, true); + if (!plan.ok) return; + assert.equal(plan.options.authMethod, "password"); + assert.equal(plan.options.password, "secret"); +}); + +test("uses short test-only connection timeouts", () => { + const plan = buildHostConnectionTestPlan({ + ...base, + host: host({ password: "secret", authMethod: "password" }), + }); + + assert.equal(plan.ok, true); + if (!plan.ok) return; + assert.equal(plan.options.sshTcpConnectTimeoutMs, 8000); + assert.equal(plan.options.sshAuthReadyTimeoutMs, 15000); +}); + +test("formats single-hop progress log lines", () => { + assert.equal( + formatConnectionTestProgressLog({ hop: 1, total: 1, label: "target.example.test", phase: "connecting" }), + "Connecting to target.example.test...", + ); + assert.equal( + formatConnectionTestProgressLog({ hop: 1, total: 1, label: "target.example.test", phase: "authenticated" }), + "target.example.test - Authenticated", + ); +}); + +test("formats jump-host progress log lines with a hop prefix", () => { + assert.equal( + formatConnectionTestProgressLog({ hop: 1, total: 2, label: "jump.example.test", phase: "tcp-connected" }), + "[1/2] jump.example.test - TCP connected", + ); + assert.equal( + formatConnectionTestProgressLog({ hop: 2, total: 2, label: "target.example.test", phase: "authenticating" }), + "[2/2] target.example.test - Key exchange complete", + ); + assert.equal( + formatConnectionTestProgressLog({ hop: 2, total: 2, label: "target.example.test", phase: "error", error: "boom" }), + "[2/2] target.example.test - Error: boom", + ); +}); + +test("test deadline follows the probe timeouts, not the host's terminal timeouts", () => { + const plan = buildHostConnectionTestPlan({ + ...base, + host: host({ password: "secret", sshTcpConnectTimeoutSeconds: 3, sshAuthReadyTimeoutSeconds: 5 }), + }); + assert.equal(plan.ok, true); + if (!plan.ok) return; + + // 8s TCP + 15s auth sent to the bridge, plus grace — never the host's 5s. + assert.equal(resolveHostConnectionTestDeadlineMs(plan.options), 8000 + 15000 + 2000); +}); + +test("test deadline adds every jump hop's dial and auth timeouts", () => { + const deadline = resolveHostConnectionTestDeadlineMs({ + sshTcpConnectTimeoutMs: 8000, + sshAuthReadyTimeoutMs: 15000, + jumpHosts: [ + { sshTcpConnectTimeoutMs: 20000, sshAuthReadyTimeoutMs: 120000 }, + { sshTcpConnectTimeoutMs: 1000, sshAuthReadyTimeoutMs: 2000 }, + ], + }); + + assert.equal(deadline, 8000 + 15000 + 20000 + 120000 + 1000 + 2000 + 2000); +}); \ No newline at end of file diff --git a/domain/hostConnectionTest.ts b/domain/hostConnectionTest.ts new file mode 100644 index 000000000..0c076d2ce --- /dev/null +++ b/domain/hostConnectionTest.ts @@ -0,0 +1,337 @@ +import type { GroupConfig, Host, Identity, KnownHost, ProxyProfile, SSHKey, TerminalSettings } from "./models"; +import { resolveHostAuth, resolveBridgeSshAgentAuth } from "./sshAuth"; +import { resolveHostKeepalive } from "./host"; +import { resolveHostSshConnectionTimeouts } from "./sshConnectionTimeouts"; +import { + hasUnreadableProxyCredential, + hasUsableProxyConfig, + resolveProxyConfigAuth, +} from "./proxyProfiles"; +import { isEncryptedCredentialPlaceholder, sanitizeCredentialValue } from "./credentials"; +import { resolveEffectiveTerminalHost, resolveTerminalChainHosts } from "./terminalHostResolution"; + +/** Test-mode connection timeouts — a probe should fail fast, not wait out the + * terminal's full keep-alive/auth window. */ +const TEST_TCP_CONNECT_TIMEOUT_MS = 8000; +const TEST_AUTH_READY_TIMEOUT_MS = 15000; +/** Slack added on top of the bridge timeouts so the main process reports its + * own (more specific) timeout error before the renderer safety net fires. */ +const TEST_DEADLINE_GRACE_MS = 2000; + +type TimeoutCarrier = { + sshTcpConnectTimeoutMs?: number; + sshAuthReadyTimeoutMs?: number; +}; + +const positiveMs = (value: unknown, fallback: number): number => ( + typeof value === "number" && Number.isFinite(value) && value > 0 ? value : fallback +); + +/** + * Renderer-side deadline for a connection test, derived from the exact + * timeouts sent to the bridge (not the edited host's terminal timeouts). + * Each hop dials TCP and then waits for auth sequentially, so the worst case + * is the sum over every jump host plus the target, plus a small grace. + */ +export const resolveHostConnectionTestDeadlineMs = ( + options: TimeoutCarrier & { jumpHosts?: TimeoutCarrier[] }, +): number => { + const hopMs = (hop: TimeoutCarrier) => + positiveMs(hop.sshTcpConnectTimeoutMs, TEST_TCP_CONNECT_TIMEOUT_MS) + + positiveMs(hop.sshAuthReadyTimeoutMs, TEST_AUTH_READY_TIMEOUT_MS); + const chainMs = (options.jumpHosts ?? []).reduce((sum, hop) => sum + hopMs(hop), 0); + return chainMs + hopMs(options) + TEST_DEADLINE_GRACE_MS; +}; + +/** Build the human-readable progress log line for one chain-progress event, + * mirroring the terminal's per-hop "Connecting to … / TCP connected / Key + * exchange complete / Authenticated / …" lines (with the `[hop/total]` prefix + * for jump-host chains). */ +export const formatConnectionTestProgressLog = (input: { + hop: number; + total: number; + label: string; + phase: string; + error?: string; +}): string => { + const { hop, total, label, phase, error } = input; + const prefix = total > 1 ? `[${hop}/${total}] ` : ""; + switch (phase) { + case "connecting": + return `${prefix}Connecting to ${label}...`; + case "tcp-connected": + return `${prefix}${label} - TCP connected`; + case "authenticating": + return `${prefix}${label} - Key exchange complete`; + case "auth-attempt": + if (error?.endsWith("rejected")) return `${prefix}${label} - ✗ ${error}`; + if (error === "all methods exhausted") return `${prefix}${label} - ✗ All authentication methods exhausted`; + if (error === "waiting for user input..." || error === "user responded") return `${prefix}${label} - ${error}`; + return `${prefix}${label} - Trying ${error ?? "authentication"}...`; + case "authenticated": + return `${prefix}${label} - Authenticated`; + case "connected": + return `${prefix}${label} - Connected`; + case "forwarding": + return `${prefix}${label} - Forwarding...`; + case "shell": + return `${prefix}Opening shell...`; + case "error": + return `${prefix}${label} - ${error ? `Error: ${error}` : "Error"}`; + default: + return `${prefix}${label} - ${phase}${error ? `: ${error}` : ""}`; + } +}; + +export type HostConnectionTestSettings = Pick< + TerminalSettings, + "keepaliveInterval" | "keepaliveCountMax" | "verifyHostKeys" +>; + +export type HostConnectionTestAuthOverride = { + authMethod?: Host["authMethod"]; + username?: string; + password?: string; + keyId?: string; + passphrase?: string; +}; + +export type BuildHostConnectionTestOptionsInput = { + /** The (possibly unsaved) host draft to test. */ + host: Host; + /** All vault hosts, used to resolve jump-host chain entries by id. */ + hosts: Host[]; + keys: SSHKey[]; + identities?: Identity[]; + knownHosts?: KnownHost[]; + groupConfigs?: GroupConfig[]; + proxyProfiles?: ProxyProfile[]; + terminalSettings?: HostConnectionTestSettings; + sessionId: string; + /** Boot generation for correlating host-key prompts with this attempt. */ + bootEpoch: number; + /** Re-entered credentials after an undecryptable placeholder was detected. */ + authOverride?: HostConnectionTestAuthOverride | null; +}; + +export type HostConnectionTestPlan = + | { + ok: true; + options: NetcattySSHOptions; + /** + * True when the primary credential is an undecryptable placeholder, so + * the caller should prompt for re-entry (password/key) before dialing. + */ + needsCredentialReentry: boolean; + } + | { ok: false; error: string }; + +const buildTestJumpHost = ( + chainHost: Host, + keys: SSHKey[], + identities: Identity[], + settings: HostConnectionTestSettings, +): NetcattyJumpHost => { + const jumpAuth = resolveHostAuth({ host: chainHost, keys, identities }); + const jumpKey = jumpAuth.key; + const jumpAllowsLocalIdentityFallback = !jumpAuth.keyId; + const jumpReferenceKeyPath = jumpAuth.authMethod === "password" + ? undefined + : jumpKey?.source === "reference" + ? jumpKey.filePath + : undefined; + const jumpIdentityFilePaths = jumpAuth.authMethod === "password" + ? undefined + : jumpReferenceKeyPath + ? [jumpReferenceKeyPath] + : jumpAllowsLocalIdentityFallback + ? chainHost.identityFilePaths + : undefined; + const hopKeepalive = resolveHostKeepalive(chainHost, settings); + const hopConnectionTimeouts = resolveHostSshConnectionTimeouts(chainHost); + + return { + hostname: chainHost.hostname, + hostId: chainHost.id, + port: chainHost.port || 22, + username: jumpAuth.username || "root", + authMethod: jumpAuth.authMethod, + requiresMfa: !!chainHost.requiresMfa, + password: sanitizeCredentialValue(jumpAuth.password), + privateKey: jumpKey?.source === "reference" ? undefined : sanitizeCredentialValue(jumpKey?.privateKey), + certificate: jumpKey?.certificate, + passphrase: sanitizeCredentialValue(jumpAuth.passphrase || jumpKey?.passphrase), + publicKey: jumpKey?.publicKey, + keyId: jumpAuth.keyId, + keySource: jumpKey?.source, + label: chainHost.label, + proxy: hasUsableProxyConfig(chainHost.proxyConfig) + ? resolveProxyConfigAuth(chainHost.proxyConfig, identities) + : undefined, + identityFilePaths: jumpIdentityFilePaths, + ...resolveBridgeSshAgentAuth(chainHost, jumpKey, jumpAuth.authMethod), + keepaliveInterval: hopKeepalive.interval, + keepaliveCountMax: hopKeepalive.countMax, + sshTcpConnectTimeoutMs: hopConnectionTimeouts.tcpConnectTimeoutSeconds * 1000, + sshAuthReadyTimeoutMs: hopConnectionTimeouts.authReadyTimeoutSeconds * 1000, + verifyHostKeys: settings.verifyHostKeys, + legacyAlgorithms: chainHost.legacyAlgorithms, + skipEcdsaHostKey: chainHost.skipEcdsaHostKey, + algorithmOverrides: chainHost.algorithms, + }; +}; + +/** + * Build the SSH connection options for a host-editor "test connection". + * + * Faithfully reuses the terminal's resolution stack (effective host with group + * defaults + materialized proxy profile, auth resolution, jump-host chain, + * keepalive, timeouts, and algorithm settings) but omits the shell-only fields + * (PTY, session log, X11, sudo autofill, startup command). The caller dials + * through `netcatty:test-connection`, which authenticates and tears down + * without opening a shell. + */ +export const buildHostConnectionTestPlan = ( + input: BuildHostConnectionTestOptionsInput, +): HostConnectionTestPlan => { + const { + host, + hosts, + keys, + identities = [], + knownHosts, + groupConfigs = [], + proxyProfiles = [], + sessionId, + bootEpoch, + authOverride = null, + } = input; + const settings: HostConnectionTestSettings = { + verifyHostKeys: true, + keepaliveInterval: 30, + keepaliveCountMax: 10, + ...(input.terminalSettings ?? {}), + }; + + const effectiveHost = resolveEffectiveTerminalHost({ + host, + groupConfigs, + proxyProfiles, + }); + + const resolvedAuth = resolveHostAuth({ + host: effectiveHost, + keys, + identities, + override: authOverride ?? null, + }); + + const chainHosts = resolveTerminalChainHosts({ + host: effectiveHost, + hosts, + groupConfigs, + proxyProfiles, + }); + const requestedChainIds = effectiveHost.hostChain?.hostIds ?? []; + if (requestedChainIds.length > 0 && chainHosts.length !== requestedChainIds.length) { + return { + ok: false, + error: "A configured jump host is missing. Open host settings and repair the jump host chain.", + }; + } + + // A proxy whose saved credential cannot be decrypted cannot be dialed; fail + // before opening the test dialog's progress rather than timing out mid-dial. + if (hasUnreadableProxyCredential(effectiveHost.proxyConfig, identities)) { + return { + ok: false, + error: "Proxy credentials cannot be decrypted on this device. Open host settings and re-enter the proxy password.", + }; + } + + const key = resolvedAuth.key; + const effectiveUsername = resolvedAuth.username || "root"; + const effectivePassword = sanitizeCredentialValue(resolvedAuth.password); + const effectivePassphrase = sanitizeCredentialValue(resolvedAuth.passphrase); + const hasEncryptedPrimaryPassword = isEncryptedCredentialPlaceholder(resolvedAuth.password); + const hasEncryptedPrimaryKey = isEncryptedCredentialPlaceholder(key?.privateKey); + + // A connection test should exercise the specific credential the user + // configured, not the full "auto" discovery (agent + every ~/.ssh key + + // password + keyboard-interactive fallback). When the host resolved to + // "auto" and a password is present without a selected key, pin it to + // "password" so the bridge tries exactly one method. + const authMethod: Host["authMethod"] = + resolvedAuth.authMethod === "auto" && effectivePassword && !key + ? "password" + : resolvedAuth.authMethod; + const allowsLocalIdentityFallback = !resolvedAuth.keyId; + const targetReferenceKeyPath = key?.source === "reference" ? key.filePath : undefined; + const targetIdentityFilePaths = authMethod === "password" + ? undefined + : targetReferenceKeyPath + ? [targetReferenceKeyPath] + : allowsLocalIdentityFallback + ? effectiveHost.identityFilePaths + : undefined; + + const agentAuth = resolveBridgeSshAgentAuth(effectiveHost, key, authMethod); + const usesSystemAgent = agentAuth.useSshAgent === true; + const hasKeyMaterial = usesSystemAgent || Boolean( + (sanitizeCredentialValue(key?.privateKey) || targetIdentityFilePaths?.length) + && (authMethod !== "password" || effectiveHost.useSshAgent === true), + ); + const hasPassword = Boolean(effectivePassword); + + const needsCredentialReentry = + (authMethod === "password" && hasEncryptedPrimaryPassword && !hasPassword) + || ( + authMethod !== "password" + && authMethod !== "auto" + && hasEncryptedPrimaryKey + && !hasKeyMaterial + && !hasPassword + ); + + const keepalive = resolveHostKeepalive(effectiveHost, settings); + const proxyConfig = hasUsableProxyConfig(effectiveHost.proxyConfig) + ? resolveProxyConfigAuth(effectiveHost.proxyConfig, identities) + : undefined; + + const options: NetcattySSHOptions = { + sessionId, + hostLabel: effectiveHost.label, + hostname: effectiveHost.hostname, + hostId: effectiveHost.id, + username: effectiveUsername, + authMethod, + requiresMfa: !!effectiveHost.requiresMfa, + port: effectiveHost.port || 22, + password: effectivePassword, + privateKey: key?.source === "reference" ? undefined : sanitizeCredentialValue(key?.privateKey), + certificate: key?.certificate, + publicKey: key?.publicKey, + keyId: key?.id, + keySource: key?.source, + passphrase: key ? (effectivePassphrase || sanitizeCredentialValue(key.passphrase)) : undefined, + legacyAlgorithms: effectiveHost.legacyAlgorithms, + skipEcdsaHostKey: effectiveHost.skipEcdsaHostKey, + algorithmOverrides: effectiveHost.algorithms, + charset: effectiveHost.charset, + proxy: proxyConfig, + jumpHosts: chainHosts.length > 0 + ? chainHosts.map((chainHost) => buildTestJumpHost(chainHost, keys, identities, settings)) + : undefined, + keepaliveInterval: keepalive.interval, + keepaliveCountMax: keepalive.countMax, + sshTcpConnectTimeoutMs: TEST_TCP_CONNECT_TIMEOUT_MS, + sshAuthReadyTimeoutMs: TEST_AUTH_READY_TIMEOUT_MS, + verifyHostKeys: settings.verifyHostKeys, + bootEpoch, + identityFilePaths: targetIdentityFilePaths, + ...agentAuth, + knownHosts, + }; + + return { ok: true, options, needsCredentialReentry }; +}; diff --git a/domain/hostOpenSession.test.ts b/domain/hostOpenSession.test.ts new file mode 100644 index 000000000..5716b2625 --- /dev/null +++ b/domain/hostOpenSession.test.ts @@ -0,0 +1,31 @@ +import assert from 'node:assert/strict'; +import { test } from 'node:test'; +import type { TerminalSession } from './models'; +import { findReusableHostSession } from './hostOpenSession'; + +function tab(id: string, status: TerminalSession['status'], extra: Partial = {}): TerminalSession { + return { id, hostId: 'host-1', hostLabel: 'Host', hostname: 'example.invalid', username: 'user', status, ...extra }; +} + +test('host reuse prefers a connected tab over a connecting tab for the same host', () => { + assert.deepEqual(findReusableHostSession([ + tab('other-host', 'connected', { hostId: 'host-2' }), + tab('retrying', 'connecting'), + tab('live', 'connected'), + ], 'host-1'), { sessionId: 'live', connected: true }); +}); + +test('host reuse keeps the same tab during the reconnect delay and connection attempt', () => { + assert.deepEqual(findReusableHostSession([tab('retrying', 'connecting')], 'host-1'), { + sessionId: 'retrying', connected: false, + }); +}); + +test('host reuse excludes restored placeholders and inactive disconnected tabs', () => { + assert.equal(findReusableHostSession([ + tab('manual-disconnect', 'disconnected'), + tab('restored', 'disconnected', { restoreState: 'restored-disconnected' }), + tab('restoring', 'connecting', { restoreState: 'restored-disconnected' }), + ], 'host-1'), undefined); + assert.equal(findReusableHostSession([tab('live', 'connected')], ''), undefined); +}); diff --git a/domain/hostOpenSession.ts b/domain/hostOpenSession.ts new file mode 100644 index 000000000..5b250bfc1 --- /dev/null +++ b/domain/hostOpenSession.ts @@ -0,0 +1,14 @@ +import type { TerminalSession } from './models'; + +export function findReusableHostSession(sessions: TerminalSession[], hostId: string) { + if (!hostId) return undefined; + // Auto-reconnect publishes connecting even during the retry delay. A plain + // disconnected/restored tab needs user action and cannot satisfy host_open. + const matches = sessions.filter((session) => ( + session.hostId === hostId + && session.restoreState !== 'restored-disconnected' + && (session.status === 'connected' || session.status === 'connecting') + )); + const picked = matches.find((session) => session.status === 'connected') ?? matches[0]; + return picked ? { sessionId: picked.id, connected: picked.status === 'connected' } : undefined; +} diff --git a/domain/models/terminal.ts b/domain/models/terminal.ts index 6fa9c98bf..ee64ab9a2 100644 --- a/domain/models/terminal.ts +++ b/domain/models/terminal.ts @@ -536,7 +536,10 @@ const DEFAULT_TERMINAL_SETTINGS: TerminalSettings = { middleClickPaste: true, wordSeparators: DEFAULT_TERMINAL_WORD_SEPARATORS, linkModifier: 'none', - autoCloseOnExit: true, + // Off by default: remote graceful channel closes can be reported as a clean + // exit (code 0), which would auto-close tabs on network drops. Users who + // want close-on-exit can enable it in Settings -> Terminal -> Behavior. + autoCloseOnExit: false, // Issue #3087: keep terminal history visible after an established session disconnects. disconnectedNoticeMode: 'terminal', keywordHighlightEnabled: true, @@ -552,7 +555,11 @@ const DEFAULT_TERMINAL_SETTINGS: TerminalSettings = { verifyHostKeys: true, keepaliveInterval: 30, keepaliveCountMax: 10, - sshAutoReconnectEnabled: false, + // On by default: unexpected transport drops (VPN / Wi-Fi blips) schedule an + // in-place reconnect every 5s with the same session id. Clean shell exits + // still close the tab, and administrative idle/timeout exits stay + // disconnected. Users on always-stable links can turn it off. + sshAutoReconnectEnabled: true, x11Display: '', // Empty = use DISPLAY/default local X server moshClientPath: '', // Legacy mosh-client override; normal UI uses bundled mosh-client showHostInfoBar: true, // Preserve the existing host information bar by default diff --git a/domain/searchMatcher.test.ts b/domain/searchMatcher.test.ts index ac62cf7dc..6a7c2f671 100644 --- a/domain/searchMatcher.test.ts +++ b/domain/searchMatcher.test.ts @@ -2,6 +2,7 @@ import assert from "node:assert/strict"; import test from "node:test"; import { + PINYIN_CACHE_MAX_CHARS, PINYIN_CACHE_MAX_ENTRIES, getPinyinCacheStatsForTests, getHostSearchMatch, @@ -11,6 +12,21 @@ import { resetPinyinCacheForTests, } from "../lib/searchMatcher.ts"; +test("shared pinyin search reaches later fields after a long first field", () => { + const prefix = "a".repeat(256); + for (const query of ["fuwuqi", "fwq", "nginx fuwuqi"]) { + assert.equal(matchesSearchQuery(query, `${prefix} nginx`, "服务器"), true); + } + assert.equal(matchesSearchQuery("fuwuqi", prefix, "服务器"), true); + assert.equal(matchesSearchQuery("fuwuqi missing", prefix, "服务器"), false); +}); + +test("shared pinyin search preserves full fields and host labels", () => { + const label = `${"a".repeat(256)}服务器`; + assert.equal(matchesSearchQuery("fuwuqi", label), true); + assert.equal(matchesHostSearchQuery("fuwuqi", { label }), true); +}); + test("matches mixed Chinese and dash-separated numeric suffix with spaced query", () => { assert.equal( matchesSearchQuery("山东 6-1", "山东-业务交换机6-1"), @@ -125,6 +141,18 @@ test("punctuation-only query does not match every field", () => { assert.equal(matchesSearchQuery("-", "some-host"), false); }); +test("over-limit query keeps its full meaning instead of matching on a truncated prefix", () => { + // The shared matcher never truncates: a >256-char query whose distinguishing + // suffix is beyond the suggested `MAX_SEARCH_QUERY_LENGTH` cap must not + // match a field that only contains the cap-bounded prefix. Callers that + // rescan many haystacks (session history) bound their own input instead. + const field = "a".repeat(300); + const query = `${"a".repeat(300)} missing-suffix`; + assert.equal(matchesSearchQuery(query, field), false); + // The same query still matches when the whole query is present. + assert.equal(matchesSearchQuery(query, `${field} missing-suffix`), true); +}); + test("host search avoids compact hostname false positives on numeric segments", () => { assert.equal( matchesHostSearchQuery("61", { @@ -227,3 +255,22 @@ test("pinyin cache covers 8000 hosts and stays hard-bounded through import/edit assert.ok(!stats.keys.includes("主机1")); resetPinyinCacheForTests(); }); + +// Regression: short labels keep the pinyin cache under the entry cap, but the +// entry cap alone is not a memory bound — a Latin query that matches nothing +// transliterates every distinct field it scans, so long fields (e.g. up to +// MAX_SEARCHABLE_FIELD_LENGTH-character session titles) could retain several +// copies of each source in the global cache. The cache must also be bounded +// by retained characters. +test("pinyin cache stays hard-bounded by retained characters, not just entries", () => { + resetPinyinCacheForTests(); + // 17 long CJK titles (~20_000 chars each): their entries can never all fit + // under the 1,000,000-char budget, so earlier ones must be evicted instead + // of pinning memory within the 16_384-entry cap. + for (let index = 0; index < 17; index += 1) { + matchesSearchQuery("unmatched-latin", "重启".repeat(10_000) + String(index)); + } + assert.ok(getPinyinCacheStatsForTests().chars <= PINYIN_CACHE_MAX_CHARS); + assert.ok(getPinyinCacheStatsForTests().size > 0); + resetPinyinCacheForTests(); +}); diff --git a/domain/sessionHistorySearch.ts b/domain/sessionHistorySearch.ts new file mode 100644 index 000000000..b9abe2578 --- /dev/null +++ b/domain/sessionHistorySearch.ts @@ -0,0 +1,414 @@ +import type { AgentActivity } from './agentActivity'; +import { + matchesPreparedSearchQuery, + prepareSearchFields, + type PreparedSearchFields, + type PreparedSearchQuery, +} from '../lib/searchMatcher'; + +/** + * Minimal structural shapes for session history search. Declared here (not + * imported from `infrastructure/ai/types`) so this domain module keeps no + * dependency on the infrastructure layer; persisted `AISession`s satisfy + * these shapes structurally. + */ +export type SessionHistorySearchAttachment = { + /** Rendered file label (absent for terminal selections and note-only files). */ + filename?: string; + /** Rendered Vault note label. */ + vaultNoteTitle?: string; +}; + +export type SessionHistorySearchMessage = { + /** Persisted role; 'system' messages are hidden by the UI and never indexed. */ + role?: string; + content: string; + thinking?: string; + statusText?: string; + errorInfo?: { message: string }; + attachments?: readonly SessionHistorySearchAttachment[]; + /** @deprecated Legacy attachment field, kept for backward compatibility. */ + images?: readonly SessionHistorySearchAttachment[]; + toolCalls?: readonly { name: string; arguments: Record }[]; + toolResults?: readonly { toolName?: string; content: string }[]; + agentActivities?: readonly AgentActivity[]; +}; + +/** + * Searchable shape for session history search. Pure domain logic consumed by + * the session history drawer; matches on title plus user/assistant message + * text (hidden `system` messages are excluded, mirroring the reopened-chat + * rendering), including thinking, tool call names/arguments, tool result content, + * persisted agent activities (web-search queries, file paths, plan items, + * warnings) already stored on messages, visible attachment labels (file names + * and Vault note titles) and persisted error messages. + */ +export type SessionHistorySearchTarget = { + /** Raw persisted title; may be empty (the UI then renders a fallback). */ + title: string; + messages: readonly SessionHistorySearchMessage[]; +}; + +/** Cap per-field text so very large tool result payloads stay cheap to scan. */ +const MAX_SEARCHABLE_FIELD_LENGTH = 20_000; + +/** Bound history-only title transliteration without limiting shared search. */ +const MAX_TITLE_PINYIN_LENGTH = 256; + +/** + * Cap the total searchable text collected per session. Pinyin matching joins + * every collected field into one haystack, so a long retained session with + * hundreds of large messages could otherwise contribute megabytes that gets + * re-normalized (and pinyin-transliterated) on every keystroke. + */ +const MAX_SESSION_SEARCHABLE_TOTAL_LENGTH = 64_000; + +/** Cap serialized tool-call argument text so bulky payloads stay cheap to scan. */ +const MAX_TOOL_ARGUMENTS_FIELD_LENGTH = 2_000; + +type SearchFieldCollector = { + fields: string[]; + push: (text: unknown, maxLength?: number) => void; + /** True once the per-session budget is exhausted: further fields are dropped. */ + isFull: boolean; +}; + +function createSearchFieldCollector(): SearchFieldCollector { + const fields: string[] = []; + let totalLength = 0; + const collector = { + fields, + push(text: unknown, maxLength: number = MAX_SEARCHABLE_FIELD_LENGTH) { + if (typeof text !== 'string') return; + // Fill the remaining per-session budget with the freshest evidence: + // truncate an oversized field instead of discarding it wholesale (whole- + // field rejection would let older, smaller fields win the leftover space). + const remaining = MAX_SESSION_SEARCHABLE_TOTAL_LENGTH - totalLength; + if (remaining <= 0) return; + const effectiveMaxLength = Math.min(maxLength, MAX_SEARCHABLE_FIELD_LENGTH, remaining); + // Bound the input before trimming so whitespace outside the searchable + // window cannot trigger an unbounded scan on every keystroke. + const capped = text.slice(0, effectiveMaxLength).trim(); + if (!capped) return; + totalLength += capped.length; + fields.push(capped); + }, + get isFull() { + return totalLength >= MAX_SESSION_SEARCHABLE_TOTAL_LENGTH; + }, + }; + return collector; +} + +/** Depth bound for argument traversal so pathological nesting cannot recurse unboundedly. */ +const MAX_TOOL_ARGUMENTS_SERIALIZATION_DEPTH = 12; + +/** + * Serialize tool-call arguments within a hard character budget: traversal and + * emitted text both stop as soon as the field cap is reached, so a retained + * call carrying a multi-megabyte blob (e.g. an uncapped `content` for + * `sftp_write_file`) never materializes in full on every search keystroke. + * The output mirrors `JSON.stringify` for the region it covers. + */ +function serializeToolCallArguments(args: Record): string { + try { + let out = ''; + let exhausted = false; + const emit = (text: string): boolean => { + const remaining = MAX_TOOL_ARGUMENTS_FIELD_LENGTH - out.length; + if (remaining <= 0) { + exhausted = true; + return false; + } + if (text.length > remaining) { + out += text.slice(0, remaining); + exhausted = true; + return false; + } + out += text; + return true; + }; + const writeValue = (value: unknown, depth: number): boolean => { + if (exhausted) return false; + if (value === null || value === undefined || typeof value === 'function' || typeof value === 'symbol') { + return emit('null'); + } + switch (typeof value) { + case 'boolean': + return emit(value ? 'true' : 'false'); + case 'number': + return emit(Number.isFinite(value) ? String(value) : 'null'); + case 'bigint': + // `JSON.stringify` throws on BigInt; degrade to a cappable string. + return emit(JSON.stringify(String(value)) ?? 'null'); + case 'string': + // Keep per-value allocation bounded too: escaping a multi-megabyte + // string whole would defeat the budget even if `emit` slices after. + return emit( + JSON.stringify( + value.length > MAX_TOOL_ARGUMENTS_FIELD_LENGTH + ? value.slice(0, MAX_TOOL_ARGUMENTS_FIELD_LENGTH) + : value, + ) ?? 'null', + ); + default: { + if (depth <= 0) return emit('null'); + if (Array.isArray(value)) { + if (!emit('[')) return false; + let first = true; + for (const item of value) { + if (!first && !emit(',')) return false; + first = false; + if (!writeValue(item, depth - 1)) return false; + } + return emit(']'); + } + if (typeof value === 'object') { + if (!emit('{')) return false; + let first = true; + // Iterate own properties lazily instead of materializing the full + // `Object.entries` array first: an argument object with thousands + // of keys (or one very large key) must not allocate for every + // keystroke just to have most of it sliced away again. + for (const key in value as Record) { + if (!Object.prototype.hasOwnProperty.call(value, key)) continue; + if (!first && !emit(',')) return false; + first = false; + // Cap the key before escaping it: escaping a multi-megabyte + // property name whole would defeat the per-field budget. + const serializedKey = JSON.stringify( + key.length > MAX_TOOL_ARGUMENTS_FIELD_LENGTH + ? key.slice(0, MAX_TOOL_ARGUMENTS_FIELD_LENGTH) + : key, + ) ?? 'null'; + if (!emit(serializedKey)) return false; + if (!emit(':')) return false; + if (!writeValue(value[key as keyof typeof value], depth - 1)) return false; + } + return emit('}'); + } + return emit('null'); + } + } + }; + writeValue(args, MAX_TOOL_ARGUMENTS_SERIALIZATION_DEPTH); + return out; + } catch { + return ''; + } +} + +/** + * Collect the human-visible text carried by persisted agent activities + * (web-search queries, changed file paths, plan items, warnings) as + * individual fields. For external SDK sessions these live only in + * `message.agentActivities` and are rendered when the conversation is + * reopened, so they must be indexed too. Each value is fed to the collector + * on its own (capped per value, not per message) so activities accumulated + * later in one message stay searchable, and values are pushed newest-first to + * match the newest-first budget allocation of the message loop. + */ +function collectAgentActivityFields( + collector: SearchFieldCollector, + activities: readonly AgentActivity[], +): void { + for (let i = activities.length - 1; i >= 0; i--) { + if (collector.isFull) return; + const activity = activities[i]; + switch (activity.type) { + case 'file_change': + for (let j = activity.changes.length - 1; j >= 0; j--) { + if (collector.isFull) return; + collector.push(activity.changes[j].path, MAX_TOOL_ARGUMENTS_FIELD_LENGTH); + } + break; + case 'web_search': + collector.push(activity.query, MAX_TOOL_ARGUMENTS_FIELD_LENGTH); + break; + case 'plan_update': + for (let j = activity.items.length - 1; j >= 0; j--) { + if (collector.isFull) return; + collector.push(activity.items[j].text, MAX_TOOL_ARGUMENTS_FIELD_LENGTH); + } + break; + case 'warning': + collector.push(activity.message, MAX_TOOL_ARGUMENTS_FIELD_LENGTH); + break; + } + } +} + +/** + * Collect the human-visible labels of a message's attachments: file names + * and Vault note titles. Rendered from `message.attachments` when the + * conversation is reopened (with the legacy `images` field as fallback), so + * a session whose only mention of a term sits in an attachment label must + * stay findable. Only the short labels are indexed — never the base64 + * payloads — so attaching files cannot blow up the search haystack. + */ +function collectAttachmentLabelFields( + collector: SearchFieldCollector, + attachments: readonly SessionHistorySearchAttachment[], +): void { + // Individual labels are handed to the collector newest-first so the 2,000- + // character field cap (and the remaining session budget) can truncate the + // list at its oldest end instead of swallowing every later label from the + // reopened conversation. + for (let i = attachments.length - 1; i >= 0; i--) { + if (collector.isFull) return; + const attachment = attachments[i]; + collector.push(attachment.vaultNoteTitle, MAX_TOOL_ARGUMENTS_FIELD_LENGTH); + if (collector.isFull) return; + collector.push(attachment.filename, MAX_TOOL_ARGUMENTS_FIELD_LENGTH); + } +} + +/** + * The title exactly as the history drawer renders it: an empty persisted + * title is displayed with the localized "Untitled" fallback, so the fallback + * text (passed by the caller) must be indexed too — searching for the label + * the user actually sees must not hide the session. + */ +function resolveDisplayTitle(session: SessionHistorySearchTarget, untitledLabel: string): string { + return session.title || untitledLabel; +} + +export function collectSessionSearchFields( + session: SessionHistorySearchTarget, + untitledLabel: string = '', +): string[] { + const collector = createSearchFieldCollector(); + // The displayed title is indexed first so it always survives the + // total-length cap. Still skipped entirely when both the raw title and the + // fallback are blank. + collector.push(resolveDisplayTitle(session, untitledLabel)); + // Fields pushed before the message loop (0 or 1 entries; the title may be + // blank and therefore skipped). + const headCount = collector.fields.length; + // Allocate the remaining budget newest-first: when a long session exceeds + // the cap, recent messages stay searchable instead of being silently + // dropped in favor of the oldest content. + for (let i = session.messages.length - 1; i >= 0; i--) { + // Stop as soon as the per-session budget is exhausted: older messages + // cannot contribute anything, and traversing them (or serializing their + // tool arguments/activities) would waste work on every keystroke. + if (collector.isFull) break; + const message = session.messages[i]; + // `ChatMessageList` removes `system` messages from the reopened + // conversation, so their (hidden) content must not make a session + // searchable — otherwise a match would show no matching text when opened. + if (message.role === 'system') continue; + collector.push(message.content); + // `push` drops fields once the budget is spent; skip the remaining + // (potentially expensive) serialization for this message too. + if (collector.isFull) continue; + collector.push(message.thinking); + if (collector.isFull) continue; + // Persisted status text: when an external SDK turn stops after an + // `onStatus` update, the final status remains in `message.statusText`, + // is persisted, and is the only distinctive visible text rendered in the + // reopened conversation. Status strings stay short, hence the tight cap. + collector.push(message.statusText, MAX_TOOL_ARGUMENTS_FIELD_LENGTH); + if (collector.isFull) continue; + // Persisted failure diagnostics: when a Catty turn fails with empty + // content the error message is the only visible text in the reopened + // conversation, so it must be indexed. Error strings are short, hence the + // tight cap. + collector.push(message.errorInfo?.message, MAX_TOOL_ARGUMENTS_FIELD_LENGTH); + collectAttachmentLabelFields(collector, message.attachments ?? message.images ?? []); + // Tool calls accumulate at the end of `message.toolCalls`, so iterate + // newest-first: if a burst of calls from this message exhausts the + // remaining session budget, the newest (still visible) calls stay + // searchable instead of being dropped in favor of the oldest ones. + const toolCalls = message.toolCalls ?? []; + for (let i = toolCalls.length - 1; i >= 0; i--) { + if (collector.isFull) break; + const toolCall = toolCalls[i]; + collector.push(toolCall.name); + collector.push(serializeToolCallArguments(toolCall.arguments), MAX_TOOL_ARGUMENTS_FIELD_LENGTH); + } + // Tool results accumulate at the end of `message.toolResults` (and + // `ChatMessageList` renders them in array order), so iterate newest-first: + // if a burst of large results from this message exhausts the remaining + // session budget, the newest (still visible) results stay searchable + // instead of being dropped in favor of the oldest ones. + const toolResults = message.toolResults ?? []; + for (let i = toolResults.length - 1; i >= 0; i--) { + if (collector.isFull) break; + const toolResult = toolResults[i]; + collector.push(toolResult.toolName); + collector.push(toolResult.content); + } + if (collector.isFull) continue; + collectAgentActivityFields(collector, message.agentActivities ?? []); + } + const fields = collector.fields; + // Restore chronological output order: the head (title) stays first, while + // the message fields — collected newest-to-oldest — are reversed back. + const head = fields.slice(0, headCount); + const tail = fields.slice(headCount); + tail.reverse(); + return [...head, ...tail]; +} + +export type SessionSearchIndex = { + /** + * Displayed title the index was built for (raw title or the fallback), capped + * exactly like the title field admitted by `collectSessionSearchFields`: + * this string feeds the pinyin fallback (`pinyinFields`) on every keystroke, + * so an uncapped restored title could push megabytes through pinyin + * normalization, and characters beyond the collector's cap are never indexed + * (and must not become pinyin-matchable either). + */ + displayTitle: string; + prepared: PreparedSearchFields; +}; + +export function getSessionSearchTitle( + session: SessionHistorySearchTarget, + untitledLabel: string = '', +): string { + const displayTitle = resolveDisplayTitle(session, untitledLabel); + // Cap the title exactly like the collector does for the title field (the + // first field pushed, so the per-field cap applies in full; trailing + // whitespace is trimmed the same way). The capped form is what the index + // stores and what the cache-change check compares, so a title that changes + // only beyond the cap reuses the cached index — correct, because the + // collector never admits those characters into the prepared haystacks + // either. A title whose capped form is blank was skipped by the collector + // entirely; the blank pinyin field is filtered out downstream. + return displayTitle.slice(0, MAX_SEARCHABLE_FIELD_LENGTH).trim(); +} + +export function prepareSessionSearchIndex( + session: SessionHistorySearchTarget, + untitledLabel: string = '', +): SessionSearchIndex { + const fields = collectSessionSearchFields(session, untitledLabel); + return { + displayTitle: getSessionSearchTitle(session, untitledLabel), + prepared: prepareSearchFields(fields), + }; +} + +export function matchesSessionSearchIndex( + query: string | PreparedSearchQuery, + index: SessionSearchIndex, +): boolean { + const titlePrefix = index.displayTitle.slice(0, MAX_TITLE_PINYIN_LENGTH); + // Do not pass a split UTF-16 surrogate pair to the transliterator. + const tail = titlePrefix.charCodeAt(titlePrefix.length - 1); + const pinyinTitle = tail >= 0xd800 && tail <= 0xdbff + ? titlePrefix.slice(0, -1) + : titlePrefix; + // Pinyin transliteration is too expensive to run over every collected + // message field on each keystroke; the pinyin fallback is restricted to + // bounded title prefix, while literal/compact matching stays + // global. Restricting pinyin per field still allows tokens within one + // query to span fields: "chongqi nginx" matches a session titled + // "重启服务器" whose message contains "nginx" ("chongqi" via title + // pinyin, "nginx" literally in the message content). + return matchesPreparedSearchQuery(query, index.prepared, { + pinyinFields: [pinyinTitle], + }); +} diff --git a/domain/sftpTransferActions.ts b/domain/sftpTransferActions.ts index 52320c927..c0886c4a4 100644 --- a/domain/sftpTransferActions.ts +++ b/domain/sftpTransferActions.ts @@ -3,6 +3,7 @@ import type { TransferTask } from "./models"; export function getGlobalTransferBatchEligibility(tasks: readonly TransferTask[]) { const topLevel = tasks.filter((task) => !task.parentTaskId); return { + cancellableCount: listGloballyCancellableTransferIds(tasks).length, pausableCount: topLevel.filter((task) => ( ["pending", "queued", "transferring"].includes(task.status) && task.resumable !== false @@ -19,6 +20,13 @@ export function getGlobalTransferBatchEligibility(tasks: readonly TransferTask[] }; } +export function listGloballyCancellableTransferIds(tasks: readonly TransferTask[]): string[] { + return tasks.filter((task) => ( + !task.parentTaskId + && ["pending", "queued", "transferring", "pausing", "paused", "interrupted", "attention"].includes(task.status) + )).map((task) => task.id); +} + export function listGloballyPausableTransferIds(tasks: readonly TransferTask[]): string[] { return tasks.filter((task) => ( !task.parentTaskId diff --git a/domain/sftpTransferCenter.ts b/domain/sftpTransferCenter.ts index ab57a4ed7..d1b38d03f 100644 --- a/domain/sftpTransferCenter.ts +++ b/domain/sftpTransferCenter.ts @@ -93,6 +93,12 @@ export function sanitizeSftpTransferTask(value: unknown): TransferTask | null { // progress after dedicated reconnect. task.lifecycleEpoch = undefined; } + // An attention row can still control a live compression job after Cancel + // fails. A persisted copy cannot: keep its attention/conflict details, but + // require reconnect rather than routing Resume to the old compression job. + if (task.controlKind === "compressed-upload" && task.status === "attention") { + task.reconnectRequired = true; + } // Terminal rows must not keep conflict payloads. Older Skip paths cancelled the // task without clearing conflict, so SFTP remount reopened the conflict dialog // while the transfer center looked empty (no attention bucket row). diff --git a/domain/singleChannelSshBanner.shared.cjs b/domain/singleChannelSshBanner.shared.cjs index 234638e70..590ac5bc5 100644 --- a/domain/singleChannelSshBanner.shared.cjs +++ b/domain/singleChannelSshBanner.shared.cjs @@ -13,16 +13,20 @@ // CLOUDBILITY-4.14 — PR #3458 reproduction note: SSH-2.0-CLOUDBILITY-4.14 // drops the interactive shell when another session channel opens. No other // public packet was found; do not add aliases. +// CloudLinker — issue #3592: the vendor identifies a single-channel gateway +// and proposes this distinctive banner. Older "Go" banners cannot identify it +// safely; this compatibility entry requires the vendor banner change. // JumpServer KoKo advertises Version "JumpServer" and multiplexes. Omit it. const SINGLE_CHANNEL_SOFTWARE_TOKENS = Object.freeze([ "BHostSSH", "TERM-SSHD", "CLOUDBILITY", + "CLOUDLINKER", ]); // #3146 also drops multi-character writes. That evidence is only for these -// two tokens, not for CLOUDBILITY. +// two tokens, not for CLOUDBILITY or CLOUDLINKER. const CHUNKED_WRITE_SOFTWARE_TOKENS = Object.freeze([ "BHostSSH", "TERM-SSHD", diff --git a/domain/singleChannelSshBanner.shared.mjs b/domain/singleChannelSshBanner.shared.mjs index 7a9503f70..ebdee95f7 100644 --- a/domain/singleChannelSshBanner.shared.mjs +++ b/domain/singleChannelSshBanner.shared.mjs @@ -13,16 +13,20 @@ // CLOUDBILITY-4.14 — PR #3458 reproduction note: SSH-2.0-CLOUDBILITY-4.14 // drops the interactive shell when another session channel opens. No other // public packet was found; do not add aliases. +// CloudLinker — issue #3592: the vendor identifies a single-channel gateway +// and proposes this distinctive banner. Older "Go" banners cannot identify it +// safely; this compatibility entry requires the vendor banner change. // JumpServer KoKo advertises Version "JumpServer" and multiplexes. Omit it. const SINGLE_CHANNEL_SOFTWARE_TOKENS = Object.freeze([ "BHostSSH", "TERM-SSHD", "CLOUDBILITY", + "CLOUDLINKER", ]); // #3146 also drops multi-character writes. That evidence is only for these -// two tokens, not for CLOUDBILITY. +// two tokens, not for CLOUDBILITY or CLOUDLINKER. const CHUNKED_WRITE_SOFTWARE_TOKENS = Object.freeze([ "BHostSSH", "TERM-SSHD", diff --git a/domain/singleChannelSshBanner.shared.test.cjs b/domain/singleChannelSshBanner.shared.test.cjs index 3469786b8..4768fe7f2 100644 --- a/domain/singleChannelSshBanner.shared.test.cjs +++ b/domain/singleChannelSshBanner.shared.test.cjs @@ -8,7 +8,7 @@ const { optionsForPeerSingleChannel, } = require("./singleChannelSshBanner.shared.cjs"); -test("single-channel banners match only the observed software tokens", () => { +test("single-channel banners match only the supported software tokens", () => { for (const version of [ "BHostSSH_7.0", "SSH-2.0-BHostSSH_7.0", @@ -17,6 +17,9 @@ test("single-channel banners match only the observed software tokens", () => { "term-sshd", "CLOUDBILITY-4.14", "SSH-2.0-CLOUDBILITY-4.14", + "CloudLinker", + "SSH-2.0-CloudLinker", + "cloudlinker-1.0", ]) { assert.equal(remoteSoftwareRequiresSingleChannel(version), true, version); } @@ -42,6 +45,10 @@ test("single-channel banner match ignores OpenSSH, JumpServer, and lookalike tok "CmdbHostSSH", "BHost", "notcloudbility", + "Go", + "SSH-2.0-Go", + "notcloudlinker", + "CloudLinkerProxy", ]) { assert.equal(remoteSoftwareRequiresSingleChannel(version), false, String(version)); } @@ -51,6 +58,7 @@ test("chunked-write limit stays on BHostSSH and TERM-SSHD", () => { assert.equal(remoteDisallowsChunkedChannelWrite("BHostSSH_7.0"), true); assert.equal(remoteDisallowsChunkedChannelWrite("SSH-2.0-TERM-SSHD"), true); assert.equal(remoteDisallowsChunkedChannelWrite("CLOUDBILITY-4.14"), false); + assert.equal(remoteDisallowsChunkedChannelWrite("SSH-2.0-CloudLinker"), false); assert.equal(remoteDisallowsChunkedChannelWrite("OpenSSH_9.6"), false); assert.equal(remoteDisallowsChunkedChannelWrite("superterm-sshd"), false); }); @@ -74,7 +82,7 @@ test("CommonJS and renderer modules agree on SSH banner behavior", async () => { const browserModule = await import("./singleChannelSshBanner.shared.mjs"); const nodeModule = require("./singleChannelSshBanner.shared.cjs"); for (const version of [ - "BHostSSH_7.0", "TERM-SSHD", "CLOUDBILITY-4.14", "OpenSSH_9.6", "JumpServer", "", + "BHostSSH_7.0", "TERM-SSHD", "CLOUDBILITY-4.14", "CloudLinker", "SSH-2.0-Go", "OpenSSH_9.6", "JumpServer", "", ]) { assert.equal(browserModule.remoteSoftwareRequiresSingleChannel(version), nodeModule.remoteSoftwareRequiresSingleChannel(version)); assert.equal(browserModule.remoteDisallowsChunkedChannelWrite(version), nodeModule.remoteDisallowsChunkedChannelWrite(version)); diff --git a/domain/snippetTransfer.test.ts b/domain/snippetTransfer.test.ts index b44a1b681..098d629b5 100644 --- a/domain/snippetTransfer.test.ts +++ b/domain/snippetTransfer.test.ts @@ -280,3 +280,76 @@ test("mergeSnippetImportPayload keeps existing empty snippet packages", () => { assert.deepEqual(result.snippetPackages, ["local-empty", "imported", "imported/ops"]); }); + +test("snippet export and import preserve the all-hosts scope", () => { + const exported = buildSnippetExportPayload({ + snippets: [ + snippet({ + id: "all-hosts", + label: "Patch everything", + command: "apt-get update", + targetsAllHosts: true, + }), + ], + snippetPackages: [], + exportedAt: "2026-10-11T00:00:00.000Z", + }); + + // New exports carry the all-hosts scope, so they must advertise a version + // that pre-change releases do not silently accept while dropping the field. + assert.equal(exported.version, 3); + const parsed = parseSnippetImportPayload(JSON.stringify(exported)); + const result = mergeSnippetImportPayload({ + existingSnippets: [], + existingSnippetPackages: [], + payload: parsed, + conflictAction: "skip", + createId: () => "imported-all-hosts", + }); + + assert.equal(exported.snippets[0].targetsAllHosts, true); + assert.equal("targets" in exported.snippets[0], false); + assert.equal(parsed.snippets[0].targetsAllHosts, true); + assert.equal(result.snippets[0].targetsAllHosts, true); + assert.deepEqual(result.snippets[0].targets, []); + assert.equal(result.snippets[0].targetGroups, undefined); +}); + +test("snippet import resolves all-hosts scope against dynamic groups", () => { + const parsed = parseSnippetImportPayload(JSON.stringify({ + kind: "netcatty.snippets", + version: 2, + exportedAt: "2026-10-11T00:00:00.000Z", + snippetPackages: [], + snippets: [ + { + label: "All hosts beats groups", + command: "uptime", + targetGroups: ["Production"], + targetsAllHosts: true, + }, + { + label: "Explicit false stays unscoped", + command: "whoami", + targetsAllHosts: false, + }, + ], + })); + + assert.equal(parsed.snippets[0].targetsAllHosts, true); + assert.equal(parsed.snippets[0].targetGroups, undefined); + assert.deepEqual(parsed.snippets[0].targetGroups ?? [], []); + assert.deepEqual([...(parsed.snippets[0].tags ?? [])], []); + assert.equal(parsed.snippets[1].targetsAllHosts, undefined); + const merged = mergeSnippetImportPayload({ + existingSnippets: [], + existingSnippetPackages: [], + payload: parsed, + conflictAction: "skip", + createId: () => "import-1", + }); + assert.equal(merged.snippets[0].targetsAllHosts, true); + assert.deepEqual(merged.snippets[0].targetGroups, undefined); + assert.equal(merged.snippets[1].targetsAllHosts, undefined); + assert.deepEqual(merged.snippets[1].targetGroups, undefined); +}); diff --git a/domain/snippetTransfer.ts b/domain/snippetTransfer.ts index 402fb8ec3..661317aa9 100644 --- a/domain/snippetTransfer.ts +++ b/domain/snippetTransfer.ts @@ -4,7 +4,11 @@ import { normalizeVaultOrder } from "./vaultOrder"; import { normalizeGroupTargetPaths } from "./hostGroupPathMutations"; export const SNIPPET_EXPORT_KIND = "netcatty.snippets" as const; -export const SNIPPET_EXPORT_VERSION = 2 as const; +// Version 3 introduced the portable all-hosts scope (targetsAllHosts); older +// releases silently drop that field when they accept a version-2 payload, so +// new exports advertise 3 while imports still accept versions 1 and 2. +export const SNIPPET_EXPORT_VERSION = 3 as const; +export const SNIPPET_EXPORT_VERSION_PREVIOUS = 2 as const; export const SNIPPET_EXPORT_VERSION_LEGACY = 1 as const; export type SnippetImportConflictAction = "skip" | "overwrite"; @@ -15,6 +19,7 @@ export type SnippetExportItem = { tags?: string[]; package?: string; targetGroups?: string[]; + targetsAllHosts?: boolean; shortkey?: string; noAutoRun?: boolean; multiLineRunMode?: Snippet["multiLineRunMode"]; @@ -27,7 +32,10 @@ export type SnippetExportItem = { export type SnippetExportPayload = { kind: typeof SNIPPET_EXPORT_KIND; - version: typeof SNIPPET_EXPORT_VERSION | typeof SNIPPET_EXPORT_VERSION_LEGACY; + version: + | typeof SNIPPET_EXPORT_VERSION + | typeof SNIPPET_EXPORT_VERSION_PREVIOUS + | typeof SNIPPET_EXPORT_VERSION_LEGACY; exportedAt: string; snippetPackages: string[]; snippets: SnippetExportItem[]; @@ -88,12 +96,20 @@ const mergeSnippetPackagePaths = (...groups: string[][]): string[] => { return uniqueStrings(paths); }; +/** + * Concrete host-id bindings (targets) are intentionally omitted for portability; + * the portable all-hosts scope rides along and suppresses group scope on import. + */ const toExportItem = (snippet: Snippet): SnippetExportItem => ({ label: snippet.label, command: snippet.command, tags: Array.isArray(snippet.tags) ? [...snippet.tags] : [], package: snippet.package || "", - ...(snippet.targetGroups !== undefined ? { targetGroups: [...snippet.targetGroups] } : {}), + ...(snippet.targetsAllHosts + ? { targetsAllHosts: true } + : snippet.targetGroups !== undefined + ? { targetGroups: [...snippet.targetGroups] } + : {}), ...(snippet.shortkey ? { shortkey: snippet.shortkey } : {}), ...(snippet.noAutoRun ? { noAutoRun: snippet.noAutoRun } : {}), ...(snippet.multiLineRunMode ? { multiLineRunMode: snippet.multiLineRunMode } : {}), @@ -134,6 +150,8 @@ const sanitizeImportItem = (value: unknown): SnippetExportItem | null => { const label = typeof value.label === "string" && value.label.trim() ? value.label.trim() : fallbackLabel(value.command); + // "Apply to all hosts" is a portable scope; when set it wins over group scope. + const targetsAllHosts = value.targetsAllHosts === true; return { label, command: value.command, @@ -141,11 +159,14 @@ const sanitizeImportItem = (value: unknown): SnippetExportItem | null => { ? uniqueStrings(value.tags) : [], package: typeof value.package === "string" ? value.package.trim() : "", - targetGroups: Array.isArray(value.targetGroups) - ? normalizeGroupTargetPaths(value.targetGroups.filter( - (entry): entry is string => typeof entry === "string", - )) - : undefined, + targetGroups: targetsAllHosts + ? undefined + : Array.isArray(value.targetGroups) + ? normalizeGroupTargetPaths(value.targetGroups.filter( + (entry): entry is string => typeof entry === "string", + )) + : undefined, + targetsAllHosts: targetsAllHosts || undefined, shortkey: typeof value.shortkey === "string" && value.shortkey.trim() ? value.shortkey.trim() : undefined, @@ -174,7 +195,11 @@ const parseSnippetImportObject = (parsed: Record): SnippetExpor if (parsed.kind !== SNIPPET_EXPORT_KIND) { throw new Error("Unsupported snippet import file."); } - if (version !== SNIPPET_EXPORT_VERSION && version !== SNIPPET_EXPORT_VERSION_LEGACY) { + if ( + version !== SNIPPET_EXPORT_VERSION && + version !== SNIPPET_EXPORT_VERSION_PREVIOUS && + version !== SNIPPET_EXPORT_VERSION_LEGACY + ) { throw new Error("Unsupported snippet import file."); } if (!Array.isArray(parsed.snippets)) { @@ -187,7 +212,12 @@ const parseSnippetImportObject = (parsed: Record): SnippetExpor return { kind: SNIPPET_EXPORT_KIND, - version: version === SNIPPET_EXPORT_VERSION ? SNIPPET_EXPORT_VERSION : SNIPPET_EXPORT_VERSION_LEGACY, + version: + version === SNIPPET_EXPORT_VERSION + ? SNIPPET_EXPORT_VERSION + : version === SNIPPET_EXPORT_VERSION_PREVIOUS + ? SNIPPET_EXPORT_VERSION_PREVIOUS + : SNIPPET_EXPORT_VERSION_LEGACY, exportedAt: typeof parsed.exportedAt === "string" ? parsed.exportedAt : "", snippetPackages: collectSnippetPackagePaths( snippets, @@ -244,8 +274,13 @@ const toImportedSnippet = (item: SnippetExportItem, id: string, order?: number): command: item.command, tags: item.tags || [], package: item.package || "", + // Host-id bindings are never imported; the all-hosts flag wins over group scope. targets: [], - ...(item.targetGroups !== undefined ? { targetGroups: [...item.targetGroups] } : {}), + ...(item.targetsAllHosts + ? { targetsAllHosts: true } + : item.targetGroups !== undefined + ? { targetGroups: [...item.targetGroups] } + : {}), ...(item.shortkey ? { shortkey: item.shortkey } : {}), ...(item.noAutoRun ? { noAutoRun: item.noAutoRun } : {}), ...(item.multiLineRunMode ? { multiLineRunMode: item.multiLineRunMode } : {}), diff --git a/domain/sync.ts b/domain/sync.ts index 6137cbfba..d71e166e2 100644 --- a/domain/sync.ts +++ b/domain/sync.ts @@ -313,6 +313,12 @@ export interface SyncPayload { terminalFontFamily?: string; terminalFontSize?: number; terminalSettings?: Record; + /** + * Present once the sender has run the autoCloseOnExit default migration. + * Legacy payloads omit it; a materialized `true` in those payloads is the + * old default, not an explicit opt-in. + */ + autoCloseOnExitDefaultMigrated?: boolean; terminalSidePanelAutoOpen?: boolean; terminalSidePanelAutoOpenTab?: import('./terminalSidePanelAutoOpen').TerminalSidePanelAutoOpenTab; localShellSidePanelAutoOpen?: boolean; @@ -390,6 +396,27 @@ export interface SyncPayload { convergentSync?: ConvergentSyncEnvelopeV2; } +/** + * Pre-migration snapshots stored autoCloseOnExit: true as the default and do + * not carry `autoCloseOnExitDefaultMigrated`. Rewrite that legacy true to + * false. A payload that records the migration keeps an explicit true. + */ +export function reconcileInboundAutoCloseOnExit( + autoCloseOnExit: unknown, + payloadRecordsMigratedChoice: unknown, +): unknown { + if (autoCloseOnExit === true && payloadRecordsMigratedChoice !== true) return false; + return autoCloseOnExit; +} + +/** True when this side both migrated and explicitly enabled auto-close. */ +export function payloadOptsIntoAutoClose( + settings: SyncPayload['settings'] | undefined, +): boolean { + return settings?.autoCloseOnExitDefaultMigrated === true + && settings.terminalSettings?.autoCloseOnExit === true; +} + /** * Strip device-local connection telemetry from hosts before any sync * comparison or upload (#2629). `lastConnectedAt` updates on every successful diff --git a/domain/syncMerge.test.ts b/domain/syncMerge.test.ts index a6f432147..6643e14e9 100644 --- a/domain/syncMerge.test.ts +++ b/domain/syncMerge.test.ts @@ -631,3 +631,44 @@ test("group notes sync edits and clearing without dropping connection settings", const cleared = payload({ customGroups: ["Project"], groupConfigs: [{ path: "Project", username: "admin" }] }); assert.equal(mergeSyncPayloads(merged, merged, cleared).payload.groupConfigs?.[0].notes, undefined); }); + +test("mergeSyncPayloads does not glue a local migration marker onto a legacy auto-close true", () => { + const merged = mergeSyncPayloads( + null, + payload({ + settings: { + autoCloseOnExitDefaultMigrated: true, + terminalSettings: { autoCloseOnExit: false, scrollback: 3000 }, + }, + }), + payload({ + settings: { + terminalSettings: { autoCloseOnExit: true, scrollback: 3000 }, + }, + }), + ).payload.settings; + + assert.equal(merged?.terminalSettings?.autoCloseOnExit, false); + assert.equal(merged?.terminalSettings?.scrollback, 3000); +}); + +test("mergeSyncPayloads keeps an explicit auto-close opt-in from a migrated peer", () => { + const merged = mergeSyncPayloads( + null, + payload({ + settings: { + autoCloseOnExitDefaultMigrated: true, + terminalSettings: { autoCloseOnExit: false }, + }, + }), + payload({ + settings: { + autoCloseOnExitDefaultMigrated: true, + terminalSettings: { autoCloseOnExit: true }, + }, + }), + ).payload.settings; + + assert.equal(merged?.autoCloseOnExitDefaultMigrated, true); + assert.equal(merged?.terminalSettings?.autoCloseOnExit, true); +}); diff --git a/domain/syncMerge.ts b/domain/syncMerge.ts index 544241c98..3a324c250 100644 --- a/domain/syncMerge.ts +++ b/domain/syncMerge.ts @@ -21,6 +21,7 @@ import { carryForwardSyncDeletions, getDeletedEntityIds } from './syncReliability'; import { + payloadOptsIntoAutoClose, sanitizeHostsForSync, type CloudSyncPayloadEntityKey, type SyncPayload, @@ -403,6 +404,24 @@ function mergeSettings( } } + // A three-way merge can keep this device's migration flag while adopting a + // legacy remote autoCloseOnExit: true. That true is the old default unless + // one of the sides explicitly opted in after migrating. + const terminalSettings = merged.terminalSettings; + if ( + terminalSettings + && typeof terminalSettings === 'object' + && !Array.isArray(terminalSettings) + && (terminalSettings as Record).autoCloseOnExit === true + && !payloadOptsIntoAutoClose(local) + && !payloadOptsIntoAutoClose(remote) + ) { + merged.terminalSettings = { + ...(terminalSettings as Record), + autoCloseOnExit: false, + }; + } + return Object.keys(merged).length > 0 ? (merged as SettingsObj) : undefined; } diff --git a/domain/terminalSettings.test.ts b/domain/terminalSettings.test.ts index a41886a0d..2045217d3 100644 --- a/domain/terminalSettings.test.ts +++ b/domain/terminalSettings.test.ts @@ -185,16 +185,16 @@ test("normalizeTerminalSettings preserves disabled font ligatures", () => { assert.equal(normalizeTerminalSettings({ fontLigatures: false }).fontLigatures, false); }); -test("normalizeTerminalSettings enables terminal auto-close by default", () => { - assert.equal(normalizeTerminalSettings().autoCloseOnExit, true); +test("normalizeTerminalSettings disables terminal auto-close by default", () => { + assert.equal(normalizeTerminalSettings().autoCloseOnExit, false); }); test("normalizeTerminalSettings preserves disabled terminal auto-close", () => { assert.equal(normalizeTerminalSettings({ autoCloseOnExit: false }).autoCloseOnExit, false); }); -test("normalizeTerminalSettings disables SSH auto reconnect by default", () => { - assert.equal(normalizeTerminalSettings().sshAutoReconnectEnabled, false); +test("normalizeTerminalSettings enables SSH auto reconnect by default", () => { + assert.equal(normalizeTerminalSettings().sshAutoReconnectEnabled, true); }); test("normalizeTerminalSettings preserves explicit SSH auto reconnect settings", () => { diff --git a/domain/vaultNotesReconciliation.test.ts b/domain/vaultNotesReconciliation.test.ts new file mode 100644 index 000000000..fe7c10d0c --- /dev/null +++ b/domain/vaultNotesReconciliation.test.ts @@ -0,0 +1,52 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import type { VaultNote } from "./models.ts"; +import { normalizeVaultNotes } from "./notes.ts"; +import { rebasePendingVaultNotes } from "./vaultNotesReconciliation.ts"; + +const note = (id: string, content: string, extra: Partial = {}) => + normalizeVaultNotes([{ id, title: id, content, createdAt: 1, updatedAt: 1, order: 0, ...extra }])[0]; + +test("pending notes retain both conflicting versions without choosing by timestamp", () => { + const base = [note("a", "base")]; + const ours = [note("a", "local", { updatedAt: 1000 })]; + const theirs = [note("a", "remote", { updatedAt: 2, tags: ["external"], group: "folder" })]; + const merged = rebasePendingVaultNotes({ base, ours, theirs }); + assert.deepEqual(merged[0], ours[0]); + assert.equal(merged.length, 2); + assert.notEqual(merged[1].id, "a"); + assert.equal(merged[1].title, "a (conflict copy)"); + assert.equal(merged[1].content, "remote"); + assert.deepEqual(merged[1].tags, ["external"]); + assert.equal(merged[1].group, "folder"); + assert.equal(merged[1].updatedAt, 2); + assert.deepEqual(rebasePendingVaultNotes({ base: theirs, ours: merged, theirs }), merged, + "repeated delivery keeps the same copy ID"); +}); + +test("pending note edits preserve unrelated external updates, additions and deletions", () => { + const base = [note("a", "base"), note("b", "base"), note("c", "base")]; + const ours = [note("a", "local"), base[1], base[2], note("d", "local addition")]; + const theirs = [base[0], note("b", "remote"), note("e", "remote addition")]; + const result = rebasePendingVaultNotes({ base, ours, theirs }); + assert.deepEqual(result.map(({ id, content }) => [id, content]), [ + ["a", "local"], ["b", "remote"], ["d", "local addition"], ["e", "remote addition"], + ]); +}); + +test("conflicting delete/edit keeps the surviving content; uncontested deletes stay deleted", () => { + const base = [note("a", "base")]; + const edited = [note("a", "changed")]; + assert.deepEqual(rebasePendingVaultNotes({ base, ours: [], theirs: edited }), edited); + assert.deepEqual(rebasePendingVaultNotes({ base, ours: edited, theirs: [] }), edited); + assert.deepEqual(rebasePendingVaultNotes({ base, ours: [], theirs: base }), []); + assert.deepEqual(rebasePendingVaultNotes({ base, ours: base, theirs: [] }), []); +}); + +test("equal local and remote notes do not produce a conflict copy", () => { + const base = [note("a", "base")]; + const edited = [note("a", "same")]; + assert.deepEqual(rebasePendingVaultNotes({ base, ours: edited, theirs: edited }), edited); + assert.deepEqual(rebasePendingVaultNotes({ base: [], ours: edited, theirs: edited }), edited); +}); diff --git a/domain/vaultNotesReconciliation.ts b/domain/vaultNotesReconciliation.ts new file mode 100644 index 000000000..315861846 --- /dev/null +++ b/domain/vaultNotesReconciliation.ts @@ -0,0 +1,40 @@ +import type { VaultNote } from "./models.ts"; +import { normalizeVaultNotes } from "./notes.ts"; +import { getNextVaultOrder } from "./vaultOrder.ts"; + +/** Reconcile a pending local write with the last observed storage snapshot. + * Keep conflicting remote content as an ordinary, exportable note; timestamps + * are compared only for equality, never used to decide which writer wins. + */ +export function rebasePendingVaultNotes(input: { + base: VaultNote[]; + ours: VaultNote[]; + theirs: VaultNote[]; +}): VaultNote[] { + const base = new Map(input.base.map(note => [note.id, note])); + const ours = new Map(input.ours.map(note => [note.id, note])); + const theirs = new Map(input.theirs.map(note => [note.id, note])); + const same = (a: VaultNote | undefined, b: VaultNote | undefined) => + JSON.stringify(a) === JSON.stringify(b); + const notes: VaultNote[] = []; + const copies: VaultNote[] = []; + let copyOrder = getNextVaultOrder([...input.ours, ...input.theirs]); + for (const id of new Set([...ours.keys(), ...theirs.keys()])) { + const ancestor = base.get(id); + const local = ours.get(id); + const remote = theirs.get(id); + if (same(local, ancestor)) { + if (remote) notes.push(remote); + } else if (same(remote, ancestor) || same(local, remote)) { + if (local) notes.push(local); + } else { + if (local) notes.push(local); + if (remote) { + // A concurrent delete must not discard the surviving edited content. + if (!local) notes.push(remote); + else copies.push({ ...remote, id: crypto.randomUUID(), title: `${remote.title} (conflict copy)`, order: copyOrder++ }); + } + } + } + return normalizeVaultNotes([...notes, ...copies]); +} diff --git a/electron/backgroundWorkContinuity.test.cjs b/electron/backgroundWorkContinuity.test.cjs index 118e0442c..ddd031c6c 100644 --- a/electron/backgroundWorkContinuity.test.cjs +++ b/electron/backgroundWorkContinuity.test.cjs @@ -13,6 +13,7 @@ test('terminal windows do not throttle work while hidden or unfocused', () => { const windowSources = [ 'bridges/windowManager/mainWindow.cjs', 'bridges/windowManager/terminalPopupWindow.cjs', + 'bridges/windowManager/editorWindow.cjs', ]; for (const relativePath of windowSources) { diff --git a/electron/bridges/ai/liveShellProbe.cjs b/electron/bridges/ai/liveShellProbe.cjs index 8c77dae23..f38f6a362 100644 --- a/electron/bridges/ai/liveShellProbe.cjs +++ b/electron/bridges/ai/liveShellProbe.cjs @@ -6,7 +6,17 @@ const { buildBashHistoryCleanup, bashHistoryScratchNames } = require("./ptyExecH // short-lived sh in the interactive PTY, rather than the SSH login shell. function buildLiveShellProbe(marker) { const script = 'if test -r "/proc/$PPID/comm"; then IFS= read -r name < "/proc/$PPID/comm"; else name=$(ps -p "$PPID" -o comm= 2>/dev/null); fi; ' - + `printf "${marker}_P:%s\\n" "$name"`; + + `printf "\\n${marker}_P:%s\\n" "$name"`; + // Keep the initial probe short before the active shell is known. Quoted + // multiline cleanup input can leave a configured zsh editor waiting for + // continuation input, so defer that Bash-only work until after the reply. + return ` true ${marker}; printf '\\n%s\\n' '${marker}_I'\n : '${marker}'; command sh -c '${script}' 2>/dev/null; printf '%s' '${marker}_Q'\n`; +} + +// This is sent after the short probe, except for zsh/fish replies. Keep the +// existing guarded cleanup and bounded physical lines for Bash's history and +// canonical-input behavior, including renamed Bash executables. +function buildLiveShellProbeCleanup(marker) { // command eval bypasses an eval customization; plain eval is the fallback // when command itself is shadowed. Never invoke a shadowed builtin after the // command path already succeeded. Both eval bodies remain Bash-guarded. @@ -14,10 +24,7 @@ function buildLiveShellProbe(marker) { const { dispatcher } = bashHistoryScratchNames(marker); const clear = `[ -z "\${${dispatcher}-}" ]||$${dispatcher} unset ${dispatcher}`; const fallback = `[ "\${${dispatcher}-}" = command ]||{ ${cleanup}; };${clear}`; - // Start display suppression in the PTY before any continuation is read, - // independently of PS2 and echo mode. Keep every later physical line short. - return ` true ${marker}; printf '\\n%s\\n' '${marker}_I'\n : '${marker}'; command sh -c '${script}' 2>/dev/null; \\\n: '${marker}'; \\command eval '${cleanup}' 2>/dev/null || true; \\\n: '${marker}'; \\eval '${fallback}' 2>/dev/null || true; \\\n: '${marker}'; \\command eval '${clear}' 2>/dev/null || true; printf '%s' '${marker}_Q'\n`; - + return ` : '${marker}'; \\command eval '${cleanup}' 2>/dev/null || true; \\\n: '${marker}'; \\eval '${fallback}' 2>/dev/null || true; \\\n: '${marker}'; \\command eval '${clear}' 2>/dev/null || true\n`; } // Interactive bash prints PS2 (`> ` by default) on backslash continuations. @@ -35,11 +42,12 @@ function parseLiveShellProbe(output, marker) { if (!normalized.startsWith(`${marker}_P:`)) continue; const name = normalized.slice(marker.length + 3).trim().split("/").pop().replace(/^-/, ""); return { + shellName: name, kind: name === "fish" ? "fish" : /^(?:ba|da|z|k|a)?sh$/.test(name) ? "posix" : null, }; } - return { kind: null }; + return { kind: null, shellName: "" }; } -module.exports = { buildLiveShellProbe, parseLiveShellProbe, normalizeLiveShellProbeLine }; +module.exports = { buildLiveShellProbe, buildLiveShellProbeCleanup, parseLiveShellProbe, normalizeLiveShellProbeLine }; diff --git a/electron/bridges/ai/liveShellProbe.test.cjs b/electron/bridges/ai/liveShellProbe.test.cjs index aa48c3384..2d1dc621c 100644 --- a/electron/bridges/ai/liveShellProbe.test.cjs +++ b/electron/bridges/ai/liveShellProbe.test.cjs @@ -1,7 +1,7 @@ const test = require('node:test'); const assert = require('node:assert/strict'); const { EventEmitter } = require('node:events'); -const { buildLiveShellProbe, parseLiveShellProbe } = require('./liveShellProbe.cjs'); +const { buildLiveShellProbe, buildLiveShellProbeCleanup, parseLiveShellProbe } = require('./liveShellProbe.cjs'); const { startPtyJob } = require('./ptyExec.cjs'); test('live shell response excludes echoed commands, stale markers and partial lines', () => { @@ -9,11 +9,11 @@ test('live shell response excludes echoed commands, stale markers and partial li assert.equal(parseLiveShellProbe(buildLiveShellProbe(marker), marker), null); assert.equal(parseLiveShellProbe(`${marker}_P:fi`, marker), null); assert.equal(parseLiveShellProbe('__NCMCP_old___P:fish\n', marker), null); - assert.deepEqual(parseLiveShellProbe(`\r${marker}_P:/usr/bin/fish\r\n${marker}_Q`, marker), { kind: 'fish' }); - assert.deepEqual(parseLiveShellProbe(`${marker}_P:-zsh\n${marker}_Q`, marker), { kind: 'posix' }); - assert.deepEqual(parseLiveShellProbe(`${marker}_P:\n${marker}_Q`, marker), { kind: null }); - assert.deepEqual(parseLiveShellProbe(`> ${marker}_P:bash\n> ${marker}_Q`, marker), { kind: 'posix' }); - assert.deepEqual(parseLiveShellProbe(`> ${marker}_P:fish\n> ${marker}_Q`, marker), { kind: 'fish' }); + assert.deepEqual(parseLiveShellProbe(`\r${marker}_P:/usr/bin/fish\r\n${marker}_Q`, marker), { kind: 'fish', shellName: 'fish' }); + assert.deepEqual(parseLiveShellProbe(`${marker}_P:-zsh\n${marker}_Q`, marker), { kind: 'posix', shellName: 'zsh' }); + assert.deepEqual(parseLiveShellProbe(`${marker}_P:\n${marker}_Q`, marker), { kind: null, shellName: '' }); + assert.deepEqual(parseLiveShellProbe(`> ${marker}_P:bash\n> ${marker}_Q`, marker), { kind: 'posix', shellName: 'bash' }); + assert.deepEqual(parseLiveShellProbe(`> ${marker}_P:fish\n> ${marker}_Q`, marker), { kind: 'fish', shellName: 'fish' }); }); test('probe waits for complete reply before choosing the first wrapper', async () => { @@ -260,7 +260,7 @@ for (const [customization, listHistory] of [ const { buildWrappedCommand } = require('./ptyExecHelpers.cjs'); const marker = '__NCMCP_HISTORY_PROBE__'; const input = `HISTFILE=/dev/null; HISTCONTROL=; PS1=; PS2=\n${customization}\n${listHistory} -c\necho user_one\necho user_two\n` - + buildLiveShellProbe(marker) + + buildLiveShellProbe(marker) + buildLiveShellProbeCleanup(marker) + (executeCommand ? buildWrappedCommand('echo command_ok', 'posix', marker, true) : '') + '\nprintf \"\\n\"\n' + listHistory + '\nexit\n'; const result = spawnSync('/bin/bash', ['--noprofile', '--norc', '-i'], { @@ -337,7 +337,7 @@ for (const invocationName of ['sh', 'renamed-bash']) { const marker = '__NCMCP_RENAMED_BASH__'; const result = spawnSync(shell, ['--noprofile', '--norc', '-i'], { input: 'HISTFILE=/dev/null; HISTCONTROL=; PS1=; PS2=\nbuiltin history -c\necho preserve_user_history\n' - + buildLiveShellProbe(marker) + '\nprintf "\\n"\ncommand builtin history\nexit\n', + + buildLiveShellProbe(marker) + buildLiveShellProbeCleanup(marker) + '\nprintf "\\n"\ncommand builtin history\nexit\n', encoding: 'utf8', env: { ...process.env, TERM: 'dumb' }, timeout: 5000, }); assert.equal(result.status, 0, result.stderr); @@ -389,7 +389,7 @@ for (const historyControl of ['', 'ignorespace']) { const { buildWrappedCommand } = require('./ptyExecHelpers.cjs'); const marker = '__NCMCP_FALLBACK_GUARD__'; const input = `HISTFILE=/dev/null; HISTCONTROL=${historyControl}; PS1=; PS2=\nbuiltin() { printf UNEXPECTED_FALLBACK; }\ncommand history -c\necho user_one\n` - + buildLiveShellProbe(marker) + + buildLiveShellProbe(marker) + buildLiveShellProbeCleanup(marker) + buildWrappedCommand('echo command_ok', 'posix', marker, true) + '\ncommand history\nexit\n'; const result = spawnSync('/bin/bash', ['--noprofile', '--norc', '-i'], { @@ -406,7 +406,7 @@ for (const historyControl of ['', 'ignorespace']) { } for (const stop of ['cancel', 'timeout']) { - test(`paced probe stops writing after ${stop}`, async () => { + test(`paced Bash probe cleanup stops writing after ${stop}`, async () => { const pty = new EventEmitter(); const writes = []; pty.write = data => writes.push(data); @@ -414,8 +414,9 @@ for (const stop of ['cancel', 'timeout']) { shellKind: 'posix', probeLiveShell: true, timeoutMs: stop === 'timeout' ? 70 : 1000, enforceWallTimeout: stop === 'timeout', }); + pty.emit('data', `${job.marker}_P:bash\n${job.marker}_Q`); await new Promise(resolve => setTimeout(resolve, 45)); - assert.ok(writes.length >= 2, 'probe must have started a later chunk'); + assert.ok(writes.length >= 3, 'cleanup must have started a later chunk'); if (stop === 'cancel') { job.cancel(); pty.emit('close'); @@ -427,3 +428,19 @@ for (const stop of ['cancel', 'timeout']) { assert.ok(writes.every(data => !data.includes('must_not_run'))); }); } + +for (const shellName of ['zsh', '/bin/zsh', 'zsh-5.9', 'fish', 'bash', 'sh', 'renamed-bash']) { + test(`live probe defers Bash history input until shell reply (${shellName})`, async () => { + const pty = new EventEmitter(); + const writes = []; + pty.write = (data) => writes.push(String(data)); + const job = startPtyJob(pty, 'printf success', { shellKind: 'posix', probeLiveShell: true, timeoutMs: 1000 }); + assert.doesNotMatch(writes.join(''), /history -d|printf success/); + pty.emit('data', `${job.marker}_P:${shellName}\n${job.marker}_Q`); + const deferred = writes.slice(1).join(''); + const needsCleanup = shellName !== 'fish' && !/zsh/.test(shellName); + assert.equal(deferred.includes('\\command eval'), needsCleanup); + pty.emit('data', `${job.marker}_S\nsuccess\n${job.marker}_E:0\n`); + assert.equal((await job.resultPromise).exitCode, 0); + }); +} diff --git a/electron/bridges/ai/ptyExec.cjs b/electron/bridges/ai/ptyExec.cjs index f1b9ea9d5..7424d133a 100644 --- a/electron/bridges/ai/ptyExec.cjs +++ b/electron/bridges/ai/ptyExec.cjs @@ -28,7 +28,7 @@ const { } = require("./ptyExecHelpers.cjs"); const { extractTrailingIdlePrompt } = require("./shellUtils.cjs"); -const { buildLiveShellProbe, parseLiveShellProbe } = require("./liveShellProbe.cjs"); +const { buildLiveShellProbe, buildLiveShellProbeCleanup, parseLiveShellProbe } = require("./liveShellProbe.cjs"); const DEFAULT_FOREGROUND_PTY_CAPTURE_CHARS = 1024 * 1024; const END_MARKER_PROMPT_WAIT_MS = 30000; @@ -243,8 +243,9 @@ function startPtyJob(ptyStream, command, options) { // Cancellation replies use the same readable stream as terminal output. // Recover session-owned flow control after sending ETX, otherwise a paused // renderer can hide the returned prompt/end marker and trigger retries. + // Recovery belongs to the session and must still run when a timeout + // finishes this job before the queued callback executes. queueMicrotask(() => { - if (finished) return; try { onInterrupt?.(); } catch { @@ -559,7 +560,9 @@ function startPtyJob(ptyStream, command, options) { probeOutput = ""; if (probe.kind) resolvedShellKind = probe.kind; if (finished || cancelRequested) return; - writeWrappedCommand(); + const isZsh = /^zsh(?:[-.][0-9].*)?$/i.test(probe.shellName); + const skipProbeCleanup = probe.kind === "fish" || isZsh; + writeWrappedCommand(skipProbeCleanup ? "" : buildLiveShellProbeCleanup(marker), isZsh); return; } @@ -821,9 +824,14 @@ function startPtyJob(ptyStream, command, options) { writeNext(); } - function writeWrappedCommand() { - const wrapped = buildWrappedCommand(command, resolvedShellKind, marker, probeLiveShell); - writeInput(`${(skipPendingInputClear ? "" : buildPendingInputClearPrefix(resolvedShellKind))}${wrapped}`); + function writeWrappedCommand(probeCleanup = "", isZsh = false) { + // The Bash-only cleanup is a no-op in zsh, but its long input still goes + // through the configured editor. Keep the existing bounded wrapper and + // omit only that unused fragment after a live zsh reply. + const wrapped = buildWrappedCommand(command, resolvedShellKind, marker, probeLiveShell, { + omitBashHistoryCleanup: isZsh, + }); + writeInput(`${(skipPendingInputClear ? "" : buildPendingInputClearPrefix(resolvedShellKind))}${probeCleanup}${wrapped}`); } // Prime the renderer's display suppression before the first byte is typed diff --git a/electron/bridges/ai/ptyExecHelpers.cjs b/electron/bridges/ai/ptyExecHelpers.cjs index 9c1b5ca71..970d42167 100644 --- a/electron/bridges/ai/ptyExecHelpers.cjs +++ b/electron/bridges/ai/ptyExecHelpers.cjs @@ -58,11 +58,25 @@ function subscribeToPtyData(ptyStream, onData) { throw new Error("PTY stream does not support data subscriptions"); } +// Only the end of the output can hold the idle prompt, and this check runs for +// every PTY chunk while the retained output may be hundreds of thousands of +// characters long. Normalising the whole text (ANSI and carriage returns) on +// each chunk stalls the worker during a flood, so look at a tail window that is +// comfortably larger than the prompt including the escape codes it may carry. +const PROMPT_SUFFIX_MIN_WINDOW_CHARS = 2048; +const PROMPT_SUFFIX_WINDOW_PER_PROMPT_CHAR = 8; + function hasExpectedPromptSuffix(text, expectedPrompt) { if (!expectedPrompt) return false; - const normalizedText = stripAnsi(String(text || "")).replace(/\r/g, ""); const normalizedPrompt = stripAnsi(String(expectedPrompt || "")).replace(/\r/g, ""); - return !!normalizedPrompt && normalizedText.endsWith(normalizedPrompt); + if (!normalizedPrompt) return false; + const fullText = String(text || ""); + const windowChars = Math.max( + PROMPT_SUFFIX_MIN_WINDOW_CHARS, + String(expectedPrompt).length * PROMPT_SUFFIX_WINDOW_PER_PROMPT_CHAR, + ); + const tail = fullText.length > windowChars ? fullText.slice(-windowChars) : fullText; + return stripAnsi(tail).replace(/\r/g, "").endsWith(normalizedPrompt); } function escapePosixSingleQuoted(text) { @@ -229,7 +243,7 @@ function buildBashHistoryCleanup(marker, keepDispatcher = false) { ].join(` \\\n: "${marker}"; `); } -function buildPosixWrapperBody(command, marker, startFormat) { +function buildPosixWrapperBody(command, marker, startFormat, { omitBashHistoryCleanup = false } = {}) { const noPager = "PAGER=cat SYSTEMD_PAGER= GIT_PAGER=cat LESS= "; const commandLines = String(command || "").replace(/\r\n?/g, "\n").split("\n"); let cmdAssign = commandLines.length > 1 @@ -259,7 +273,7 @@ function buildPosixWrapperBody(command, marker, startFormat) { } cmdAssign = `${marker}_cmd=$(${writes.join(`; \\\n: '${marker}'; `)})`; } - const historyCleanup = buildBashHistoryCleanup(marker); + const historyCleanup = omitBashHistoryCleanup ? [] : [buildBashHistoryCleanup(marker)]; // OpenWrt builds BusyBox with a 512-byte interactive line editor. Keep // every physical line below that limit, even for short user commands. // Emit a short standalone input marker before the shell reads any PS2 @@ -268,12 +282,12 @@ function buildPosixWrapperBody(command, marker, startFormat) { ` : '${marker}'; ${cmdAssign}`, `{ printf '${startFormat}' '${marker}_S'; trap ':' INT; ( ${noPager}eval "$${marker}_cmd" ); __NCMCP_rc=$?; trap - INT`, `printf '%s\\n' '${marker}_E:'"$__NCMCP_rc"`, - historyCleanup, + ...historyCleanup, `(exit $__NCMCP_rc); }`, ].join(`; \\\n: '${marker}'; `); } -function buildWrappedCommand(command, shellKind, marker, separateStartMarker = false) { +function buildWrappedCommand(command, shellKind, marker, separateStartMarker = false, options = {}) { // A live probe leaves its completion marker unterminated to hide the next // prompt. POSIX input also hides arbitrary PS2 prompts until a fresh start // marker line; keep the fish behavior controlled by separateStartMarker. @@ -352,7 +366,7 @@ function buildWrappedCommand(command, shellKind, marker, separateStartMarker = f // can opt in by adding `HISTCONTROL=ignoreboth` to ~/.bashrc. // Without that config the prefix is harmless; it just doesn't // suppress history recording. - return ` ${buildPosixWrapperBody(command, marker, "\\n%s\\n")}\n`; + return ` ${buildPosixWrapperBody(command, marker, "\\n%s\\n", options)}\n`; } } } diff --git a/electron/bridges/ai/ptyExecPromptSuffix.test.cjs b/electron/bridges/ai/ptyExecPromptSuffix.test.cjs new file mode 100644 index 000000000..24e8af10a --- /dev/null +++ b/electron/bridges/ai/ptyExecPromptSuffix.test.cjs @@ -0,0 +1,52 @@ +"use strict"; + +const test = require("node:test"); +const assert = require("node:assert/strict"); +const { hasExpectedPromptSuffix } = require("./ptyExecHelpers.cjs"); +const { stripAnsi } = require("./shellUtils.cjs"); + +// The behaviour before the tail window was introduced: normalise the whole +// text, then compare its end. +function referenceHasExpectedPromptSuffix(text, expectedPrompt) { + if (!expectedPrompt) return false; + const normalizedText = stripAnsi(String(text || "")).replace(/\r/g, ""); + const normalizedPrompt = stripAnsi(String(expectedPrompt || "")).replace(/\r/g, ""); + return !!normalizedPrompt && normalizedText.endsWith(normalizedPrompt); +} + +const COLORED_PROMPT = "\x1b[01;32mroot@3160T\x1b[00m:\x1b[01;34m~\x1b[00m# "; + +const CASES = [ + ["plain prompt at the end", "output\r\nroot@3160T:~# ", "root@3160T:~# "], + ["prompt not at the end", "root@3160T:~# ls\r\nfile\r\n", "root@3160T:~# "], + ["colored prompt in the text, plain expected", `out\r\n${COLORED_PROMPT}`, "root@3160T:~# "], + ["colored prompt on both sides", `out\r\n${COLORED_PROMPT}`, COLORED_PROMPT], + ["carriage returns inside the prompt", "out\r\nroot@3160T:~\r# ", "root@3160T:~# "], + ["empty text", "", "root@3160T:~# "], + ["empty expected prompt", "root@3160T:~# ", ""], + ["null inputs", null, null], + ["text shorter than the prompt", "~# ", "root@3160T:~# "], +]; + +for (const [name, text, prompt] of CASES) { + test(`prompt suffix matches the full-text result: ${name}`, () => { + assert.equal(hasExpectedPromptSuffix(text, prompt), referenceHasExpectedPromptSuffix(text, prompt)); + }); +} + +test("prompt suffix is found after a very large flood of CRLF output", () => { + const flood = "y\r\n".repeat(700_000); + assert.equal(hasExpectedPromptSuffix(`${flood}root@3160T:~# `, "root@3160T:~# "), true); + assert.equal(hasExpectedPromptSuffix(`${flood}root@3160T:~# x`, "root@3160T:~# "), false); + assert.equal(hasExpectedPromptSuffix(`${flood}${COLORED_PROMPT}`, "root@3160T:~# "), true); +}); + +test("prompt suffix check does not scan the whole buffer on every call", () => { + // 2M characters of CRLF output used to cost seconds per call because every + // call stripped ANSI and carriage returns over the entire text. + const flood = "y\r\n".repeat(700_000); + const started = process.hrtime.bigint(); + for (let i = 0; i < 50; i += 1) hasExpectedPromptSuffix(flood, "root@3160T:~# "); + const elapsedMs = Number(process.hrtime.bigint() - started) / 1e6; + assert.ok(elapsedMs < 250, `50 checks took ${elapsedMs.toFixed(0)} ms`); +}); diff --git a/electron/bridges/ai/ptyExecStartupRecovery.test.cjs b/electron/bridges/ai/ptyExecStartupRecovery.test.cjs new file mode 100644 index 000000000..97a0065b4 --- /dev/null +++ b/electron/bridges/ai/ptyExecStartupRecovery.test.cjs @@ -0,0 +1,70 @@ +"use strict"; + +const test = require("node:test"); +const assert = require("node:assert/strict"); +const { Duplex } = require("node:stream"); +const { startPtyJob } = require("./ptyExec.cjs"); +const { buildLiveShellProbe } = require("./liveShellProbe.cjs"); +const { + clearSessionFlowState, + setRendererFlowPaused, +} = require("../terminalFlowAck.cjs"); + +const nextTick = () => new Promise((resolve) => setImmediate(resolve)); + +test("startup timeout recovers paused probe output after the job finishes without writing the command", async (t) => { + t.mock.timers.enable({ apis: ["setTimeout"] }); + const writes = []; + const pty = new Duplex({ + read() {}, + write(data, _encoding, callback) { + writes.push(data.toString()); + callback(); + }, + }); + const session = { protocol: "ssh", stream: pty }; + let displayedOutput = ""; + pty.on("data", (data) => { displayedOutput += data.toString(); }); + await nextTick(); + setRendererFlowPaused(session, true); + + try { + const job = startPtyJob(pty, "echo SHOULD_NOT_RUN", { + shellKind: "posix", + probeLiveShell: true, + timeoutMs: 3600000, + maxBufferedChars: 1024, + onInterrupt: () => clearSessionFlowState(session), + }); + const probeLength = 2 + buildLiveShellProbe(job.marker).length; + for (let tick = 0; tick < 100 && writes.join("").length < probeLength; tick += 1) { + t.mock.timers.tick(30); + } + assert.equal(writes.join("").length, probeLength, "the shell probe must drain before timeout"); + const probeWrites = [...writes]; + const probeReply = `${job.marker}_P:sh\n${job.marker}_Q`; + pty.push(probeReply); + await nextTick(); + assert.equal(pty.isPaused(), true); + assert.equal(displayedOutput, ""); + assert.equal(job.getSnapshot().foundStart, false); + assert.equal(job.getSnapshot().stdout, ""); + + t.mock.timers.tick(30000); + const result = await job.resultPromise; + await nextTick(); + + assert.equal(result.ok, false); + assert.equal(result.exitCode, -1); + assert.equal(result.error, "Background job startup timed out — start marker never arrived"); + assert.equal(session.flowState.appliedPause, false); + assert.equal(session.flowState.rendererPaused, false); + assert.equal(pty.isPaused(), false); + assert.equal(displayedOutput, probeReply); + assert.deepEqual(writes, [...probeWrites, "\x03"], "recovery must not launch the timed-out command"); + assert.equal(job.getSnapshot().foundStart, false); + } finally { + clearSessionFlowState(session); + pty.destroy(); + } +}); diff --git a/electron/bridges/ai/shellUtils.cjs b/electron/bridges/ai/shellUtils.cjs index 09fe98dd9..5f0dbb154 100644 --- a/electron/bridges/ai/shellUtils.cjs +++ b/electron/bridges/ai/shellUtils.cjs @@ -832,93 +832,98 @@ function getWindowsKnownCliPathDirs(env = process.env) { return dirs.filter((dir) => existsSync(dir)); } -// The hives a brand-new shell reads its PATH from, in Windows' own order: -// user scope first, then machine scope. +// Windows appends the user PATH to the machine PATH. Keep that ordering even +// though both registry queries run concurrently. const WINDOWS_PATH_HIVES = [ - "HKCU\\Environment", "HKLM\\SYSTEM\\CurrentControlSet\\Control\\Session Manager\\Environment", + "HKCU\\Environment", ]; -function collectWindowsRegistryPath(stdout, env, parts) { - const raw = parseRegQueryPath(stdout); - if (raw) parts.push(expandWindowsEnvRefs(raw, env)); -} - -async function readWindowsRegistryPath({ exec = execFileAsync, env = process.env } = {}) { - const parts = []; - for (const hive of WINDOWS_PATH_HIVES) { - try { - const { stdout } = await exec("reg", ["query", hive, "/v", "Path"], { - encoding: "utf8", - timeout: 3000, - }); - collectWindowsRegistryPath(stdout, env, parts); - } catch { - // Hive unreadable / value missing — skip and rely on other sources. - } - } - return parts.join(";"); -} - -/** - * Synchronous twin of readWindowsRegistryPath() for callers that cannot await, - * such as local terminal startup, which spawns the PTY inline. - */ -function readWindowsRegistryPathSync({ exec = execFileSync, env = process.env } = {}) { - const parts = []; - for (const hive of WINDOWS_PATH_HIVES) { - try { - const stdout = exec("reg", ["query", hive, "/v", "Path"], { - encoding: "utf8", - timeout: 3000, - windowsHide: true, - }); - collectWindowsRegistryPath(stdout, env, parts); - } catch { - // Hive unreadable / value missing — skip and rely on other sources. - } - } - return parts.join(";"); -} - -/** - * Windows only: put the live registry PATH ahead of an already-captured PATH. - * - * Long-lived processes keep the PATH they were launched with, so anything the - * user installs or upgrades afterwards is invisible to them — including tools - * whose installer rewrites a versioned user-PATH entry, where the stale entry - * points at a directory the upgrade removed. Merging the registry value (what - * a fresh shell inherits) ahead of the captured one resolves those CLIs - * without a restart. Returns `basePath` unchanged off Windows, or when the - * registry cannot be read. - */ -function resolveWindowsLivePath({ - basePath, - exec, +async function readWindowsRegistryPaths({ + exec = execFileAsync, env = process.env, - platform = process.platform, - knownCliDirs = getWindowsKnownCliPathDirs, + timeoutMs = 1000, } = {}) { - if (platform !== "win32") return basePath; - - let registryPath = ""; - try { - registryPath = readWindowsRegistryPathSync({ exec, env }); - } catch { - registryPath = ""; - } - - let knownDirs = ""; - try { - knownDirs = (knownCliDirs(env) || []).join(";"); - } catch { - knownDirs = ""; - } - - if (!registryPath && !knownDirs) return basePath; - return mergeWindowsPath(registryPath, knownDirs, basePath || ""); + return Promise.all(WINDOWS_PATH_HIVES.map(async (hive) => { + let timer; + try { + // The JS deadline also bounds an unresponsive process callback. The + // child-process timeout remains responsible for terminating reg.exe. + const result = await Promise.race([ + exec("reg", ["query", hive, "/v", "Path"], { + encoding: "utf8", + timeout: timeoutMs, + windowsHide: true, + }), + new Promise((resolve) => { + timer = setTimeout(() => resolve(null), timeoutMs); + }), + ]); + return result ? expandWindowsEnvRefs(parseRegQueryPath(result.stdout), env) : null; + } catch { + return null; + } finally { + clearTimeout(timer); + } + })); } +async function readWindowsRegistryPath(options) { + return mergeWindowsPath(...await readWindowsRegistryPaths(options)); +} + +// Share a short-lived registry snapshot across simultaneous local terminals. +// Cache raw values so each terminal expands references using its own env. +function createWindowsLivePathResolver({ + exec = execFileAsync, + now = Date.now, + ttlMs = 5000, + timeoutMs = 1000, +} = {}) { + const cachedPaths = [null, null]; + let refreshedAt = -Infinity; + let pending = null; + + return async function resolveWindowsLivePath({ + basePath, + env = process.env, + platform = process.platform, + knownCliDirs = getWindowsKnownCliPathDirs, + } = {}) { + if (platform !== "win32") return basePath; + if (!pending && now() - refreshedAt >= ttlMs) { + pending = readWindowsRegistryPaths({ exec, env: {}, timeoutMs }) + .then((paths) => { + // A failed hive retains its last successful value; one unavailable + // hive must not discard the other half of a cached PATH. + paths.forEach((value, index) => { + if (value !== null) cachedPaths[index] = value; + }); + refreshedAt = now(); + }) + .finally(() => { pending = null; }); + } + if (pending) await pending; + + let knownDirs = ""; + try { + knownDirs = (knownCliDirs(env) || []).join(";"); + } catch { /* Best-effort fallback only. */ } + + const registryPath = expandWindowsEnvRefs(mergeWindowsPath(...cachedPaths), env); + if (!registryPath && !knownDirs) return basePath; + // Registry order reflects a fresh shell; inherited entries remain a + // fallback, and guessed package-manager bins must never take precedence. + // With no machine snapshot, retain inherited system precedence rather + // than accidentally promoting user directories above system binaries. + return cachedPaths[0] === null + ? mergeWindowsPath(basePath || "", registryPath, knownDirs) + : mergeWindowsPath(registryPath, basePath || "", knownDirs); + }; +} + +const resolveWindowsLivePath = createWindowsLivePathResolver(); + async function getShellEnv() { if (_cachedShellEnv) return _cachedShellEnv; if (_shellEnvPromise) return _shellEnvPromise; @@ -946,7 +951,7 @@ async function getShellEnv() { const knownDirs = getWindowsKnownCliPathDirs().join(path.delimiter); const nextEnv = { ...process.env, - PATH: mergeWindowsPath(registryPath, knownDirs, process.env.PATH || ""), + PATH: mergeWindowsPath(registryPath, process.env[getPathEnvKey(process.env, "win32")] || "", knownDirs), }; if (generation === _shellEnvGeneration) { _cachedShellEnv = nextEnv; @@ -1062,7 +1067,7 @@ module.exports = { expandWindowsEnvRefs, mergeWindowsPath, readWindowsRegistryPath, - readWindowsRegistryPathSync, + createWindowsLivePathResolver, resolveWindowsLivePath, getShellEnv, invalidateShellEnvCache, diff --git a/electron/bridges/ai/shellUtils.test.cjs b/electron/bridges/ai/shellUtils.test.cjs index ab4ac83da..4bd1e72e8 100644 --- a/electron/bridges/ai/shellUtils.test.cjs +++ b/electron/bridges/ai/shellUtils.test.cjs @@ -21,8 +21,7 @@ const { expandWindowsEnvRefs, mergeWindowsPath, readWindowsRegistryPath, - readWindowsRegistryPathSync, - resolveWindowsLivePath, + createWindowsLivePathResolver, trackSessionIdlePrompt, } = require("./shellUtils.cjs"); const fs = require("node:fs"); @@ -591,7 +590,7 @@ test("readWindowsRegistryPath merges HKCU and HKLM and expands refs", async () = return { stdout: " Path REG_EXPAND_SZ C:\\Windows\\System32\r\n" }; }; const out = await readWindowsRegistryPath({ exec, env: { APPDATA: "C:\\Roaming" } }); - assert.equal(out, "C:\\Roaming\\npm;C:\\Windows\\System32"); + assert.equal(out, "C:\\Windows\\System32;C:\\Roaming\\npm"); }); test("readWindowsRegistryPath tolerates a failing hive query", async () => { @@ -603,79 +602,6 @@ test("readWindowsRegistryPath tolerates a failing hive query", async () => { assert.equal(out, "C:\\tools"); }); -test("readWindowsRegistryPathSync mirrors the async reader for non-awaiting callers", () => { - const exec = (cmd, args) => { - assert.equal(cmd, "reg"); - if (args[1] === "HKCU\\Environment") { - return " Path REG_EXPAND_SZ %APPDATA%\\npm\r\n"; - } - return " Path REG_EXPAND_SZ C:\\Windows\\System32\r\n"; - }; - const out = readWindowsRegistryPathSync({ exec, env: { APPDATA: "C:\\Roaming" } }); - assert.equal(out, "C:\\Roaming\\npm;C:\\Windows\\System32"); -}); - -test("readWindowsRegistryPathSync tolerates a failing hive query", () => { - const exec = (cmd, args) => { - if (args[1] === "HKCU\\Environment") throw new Error("ERROR: cannot read"); - return " Path REG_SZ C:\\tools\r\n"; - }; - assert.equal(readWindowsRegistryPathSync({ exec, env: {} }), "C:\\tools"); -}); - -test("resolveWindowsLivePath front-loads a freshly installed CLI over the stale PATH", () => { - // The reported regression: a long-running app captured a PATH whose herdr - // entry still points at the previous release directory, which the upgrade - // removed, so the stale entry no longer resolves anything. - const stalePath = "C:\\Users\\me\\.herdr\\packages\\standalone\\releases\\0.8.2-x86_64;C:\\Windows\\System32"; - const exec = (cmd, args) => { - if (args[1] === "HKCU\\Environment") { - return " Path REG_EXPAND_SZ C:\\Users\\me\\.herdr\\packages\\standalone\\releases\\0.9.1-x86_64\r\n"; - } - return " Path REG_EXPAND_SZ C:\\Windows\\System32\r\n"; - }; - - const out = resolveWindowsLivePath({ - basePath: stalePath, - exec, - env: {}, - platform: "win32", - knownCliDirs: () => [], - }); - - assert.equal( - out, - "C:\\Users\\me\\.herdr\\packages\\standalone\\releases\\0.9.1-x86_64;C:\\Windows\\System32;" - + "C:\\Users\\me\\.herdr\\packages\\standalone\\releases\\0.8.2-x86_64", - ); -}); - -test("resolveWindowsLivePath keeps the captured PATH off Windows", () => { - const exec = () => { - throw new Error("the registry must not be queried off Windows"); - }; - const out = resolveWindowsLivePath({ - basePath: "/usr/local/bin:/usr/bin", - exec, - platform: "darwin", - }); - assert.equal(out, "/usr/local/bin:/usr/bin"); -}); - -test("resolveWindowsLivePath falls back to the captured PATH when the registry is unreadable", () => { - const exec = () => { - throw new Error("access denied"); - }; - const out = resolveWindowsLivePath({ - basePath: "C:\\Windows\\System32", - exec, - env: {}, - platform: "win32", - knownCliDirs: () => [], - }); - assert.equal(out, "C:\\Windows\\System32"); -}); - test("tracks PowerShell idle prompt after SSH output", () => { const session = {}; @@ -820,3 +746,94 @@ function withExecPath(fakePath, fn) { Object.defineProperty(process, "execPath", { value: original, configurable: true, writable: true }); } } + +test("Windows live PATH refresh is shared, asynchronous, bounded, and cached", async () => { + let calls = 0; + let tick = 0; + const resolve = createWindowsLivePathResolver({ + exec: () => { calls += 1; return new Promise(() => {}); }, + timeoutMs: 20, + now: () => tick, + ttlMs: 50, + }); + const options = { basePath: "C:\\Windows\\System32", platform: "win32", env: {}, knownCliDirs: () => [] }; + let eventLoopRan = false; + setTimeout(() => { eventLoopRan = true; }, 0); + const first = resolve(options); + const second = resolve(options); + assert.equal(calls, 2, "concurrent terminals share one query per hive"); + assert.deepEqual(await Promise.all([first, second]), [options.basePath, options.basePath]); + assert.equal(eventLoopRan, true, "registry reads must not block the main event loop"); + await resolve(options); + assert.equal(calls, 2, "even failed queries are briefly cached"); + tick = 51; + await resolve(options); + assert.equal(calls, 4, "expired snapshots refresh"); +}); + +test("Windows live PATH keeps machine before user and guesses after inherited entries", async () => { + const resolve = createWindowsLivePathResolver({ + exec: async (_cmd, args, options) => { + assert.equal(options.windowsHide, true); + assert.equal(options.timeout, 1000); + return { stdout: args[1].startsWith("HKLM") + ? " Path REG_SZ C:\\Windows\\System32;C:\\shared\\\r\n" + : " Path REG_EXPAND_SZ %APPDATA%\\npm;c:\\SHARED;C:\\new-release\r\n" }; + }, + }); + const result = await resolve({ + basePath: "C:\\old-release;C:\\Windows\\System32;C:\\launcher", + env: { APPDATA: "C:\\Roaming" }, + platform: "win32", + knownCliDirs: () => ["C:\\guess", "C:\\launcher"], + }); + assert.equal(result, "C:\\Windows\\System32;C:\\shared\\;C:\\Roaming\\npm;C:\\new-release;C:\\old-release;C:\\launcher;C:\\guess"); +}); + +test("Windows live PATH picks up upgraded directories after cache expiry", async () => { + let tick = 0; + let release = "v1"; + let unavailable = false; + const resolve = createWindowsLivePathResolver({ + now: () => tick, + ttlMs: 10, + exec: async () => { + if (unavailable) throw new Error("registry unavailable"); + return { stdout: ` Path REG_SZ C:\\${release}\r\n` }; + }, + }); + const options = { platform: "win32", basePath: "C:\\old", env: {}, knownCliDirs: () => [] }; + assert.equal(await resolve(options), "C:\\v1;C:\\old"); + release = "v2"; + tick = 11; + assert.equal(await resolve(options), "C:\\v2;C:\\old"); + unavailable = true; + tick = 22; + assert.equal(await resolve(options), "C:\\v2;C:\\old", "query failure retains the last successful snapshot"); +}); + +test("cached Windows registry references expand per terminal environment", async () => { + const resolve = createWindowsLivePathResolver({ + exec: async () => ({ stdout: " Path REG_EXPAND_SZ %APPDATA%\\npm\r\n" }), + }); + const options = { platform: "win32", basePath: "", knownCliDirs: () => [] }; + assert.equal(await resolve({ ...options, env: { APPDATA: "C:\\first" } }), "C:\\first\\npm"); + assert.equal(await resolve({ ...options, env: { APPDATA: "C:\\second" } }), "C:\\second\\npm"); +}); + +test("Windows live PATH resolver does not query the registry off Windows", async () => { + const resolve = createWindowsLivePathResolver({ exec: () => { throw new Error("must not query"); } }); + assert.equal(await resolve({ basePath: "/usr/bin:/bin", platform: "linux" }), "/usr/bin:/bin"); +}); + +test("a failed machine hive does not promote user directories ahead of inherited system paths", async () => { + const resolve = createWindowsLivePathResolver({ + exec: async (_cmd, args) => { + if (args[1].startsWith("HKLM")) throw new Error("unavailable"); + return { stdout: " Path REG_SZ C:\\user-tools\r\n" }; + }, + }); + assert.equal(await resolve({ + platform: "win32", basePath: "C:\\Windows\\System32", env: {}, knownCliDirs: () => [], + }), "C:\\Windows\\System32;C:\\user-tools"); +}); diff --git a/electron/bridges/aiBridge/sdk/opencodeDriver.cjs b/electron/bridges/aiBridge/sdk/opencodeDriver.cjs index 8ba64c93f..bd0f6fc32 100644 --- a/electron/bridges/aiBridge/sdk/opencodeDriver.cjs +++ b/electron/bridges/aiBridge/sdk/opencodeDriver.cjs @@ -13,6 +13,12 @@ const { const OPENCODE_IMAGE_MEDIA_TYPES = new Set(["image/jpeg", "image/png", "image/gif", "image/webp"]); const DEFAULT_OPENCODE_PORT = 4096; +// The bundled @opencode-ai/sdk defaults `createOpencodeServer` to a 5000ms +// startup deadline, which is too tight for opencode server cold starts on +// Windows (antivirus scans / first-run autoupdate can push it past 8s, and +// community reports show 30s still missed there). Always pass an explicit, +// generous timeout so the SDK default never applies (#3579). +const OPENCODE_SERVER_START_TIMEOUT_MS = process.platform === "win32" ? 60000 : 30000; function resolveUsableOpenCodeBinPath(binPath, env) { const candidates = []; @@ -461,8 +467,10 @@ function getAvailablePort(host = "127.0.0.1") { } async function withOpenCodeServerPort(options = {}) { - if (options.port != null) return options; - return { ...options, port: await getAvailablePort(options.hostname || "127.0.0.1") }; + // Never let the SDK's hardcoded 5000ms startup deadline apply (#3579). + const timeout = options.timeout ?? OPENCODE_SERVER_START_TIMEOUT_MS; + if (options.port != null) return { ...options, timeout }; + return { ...options, timeout, port: await getAvailablePort(options.hostname || "127.0.0.1") }; } function closeOpenCodeInstance(opencode) { @@ -491,7 +499,13 @@ async function createDefaultOpenCode(options, env, binPath) { }; try { - const opencode = await withExclusiveProcessEnv(nextEnv, () => sdk.createOpencode(options)); + // The SDK spawns synchronously before waiting for server readiness. Only + // that spawn needs the temporary environment; holding the gate through + // cold startup serializes unrelated profiles and exhausts their deadlines. + const { startup } = await withExclusiveProcessEnv(nextEnv, () => ({ + startup: sdk.createOpencode(options), + })); + const opencode = await startup; const originalClose = opencode.server?.close?.bind(opencode.server); if (typeof originalClose === "function") { opencode.server.close = () => { @@ -772,9 +786,12 @@ function buildOpenCodeListServerKey(binPath, env) { } // Shared list-models servers: coalesce concurrent catalog loads for the same -// binary, then tear down after a short idle so idle Netcatty does not keep -// opencode processes around (issue #2184). -const OPENCODE_LIST_SERVER_IDLE_MS = 1500; +// binary, then tear down after an idle window so idle Netcatty does not keep +// opencode processes around (issue #2184). The idle window stays generous +// (30s, unref'd) because Windows opencode server cold starts take 4.6–8.9s +// (#3584): killing the server 1.5s after a load makes every agent switch pay +// the full spawn cost again and re-show the placeholder catalog. +const OPENCODE_LIST_SERVER_IDLE_MS = 30000; const openCodeListServers = new Map(); function clearOpenCodeListServerIdle(entry) { @@ -837,7 +854,8 @@ async function acquireOpenCodeListServer({ env, binPath, openCodeFactory, signal entry.ready = (async () => { const options = await withOpenCodeServerPort({ config: { autoupdate: false }, - timeout: 10000, + // Startup deadline comes from withOpenCodeServerPort; the SDK's 5000ms + // default is too tight for Windows cold starts (#3579). signal: createAbort.signal, }); const opencode = await factory(options); @@ -907,8 +925,15 @@ async function listOpenCodeModels({ env, binPath, openCodeFactory, abortControll currentModelId: getOpenCodeDefaultModelId(data), models: mapOpenCodeModels(data), }; - } catch { - return emptyOpenCodeModelCatalog(); + } catch (error) { + // Surface catalog load failures to the caller so the UI can tell "still + // loading" apart from "failed" (#3584). Silently swallowing spawn / + // providers() errors meant a bad OPENCODE_BIN or a Windows cold-start + // timeout degraded to an empty catalog with no error and no retry path. + // An aborted load keeps the silent empty result: the caller cancelled on + // purpose, so there is nothing to retry. + if (effectiveSignal?.aborted) return emptyOpenCodeModelCatalog(); + throw error; } finally { if (acquired) releaseOpenCodeListServer(acquired.key); } @@ -923,6 +948,7 @@ module.exports = { getOpenCodeDefaultModelId, getOpenCodeSessionIdFromEvent, withOpenCodeProcessEnv, + withOpenCodeServerPort, listOpenCodeModels, mapOpenCodeModels, parseOpenCodeModel, @@ -932,4 +958,5 @@ module.exports = { toOpenCodeMcpConfig, translateOpenCodeEvent, OPENCODE_LIST_SERVER_IDLE_MS, + OPENCODE_SERVER_START_TIMEOUT_MS, }; diff --git a/electron/bridges/aiBridge/sdk/opencodeDriver.startup.test.cjs b/electron/bridges/aiBridge/sdk/opencodeDriver.startup.test.cjs new file mode 100644 index 000000000..d2f0e1850 --- /dev/null +++ b/electron/bridges/aiBridge/sdk/opencodeDriver.startup.test.cjs @@ -0,0 +1,83 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const { getTempFilePath } = require('../../tempDirBridge.cjs'); +const { listOpenCodeModels, resetOpenCodeListServerPool } = require('./opencodeDriver.cjs'); + +test('OpenCode releases the process-env gate after spawning separate catalog profiles', { + skip: process.platform === 'win32' && 'fixture executable uses a POSIX shebang', + timeout: 15000, +}, async (t) => { + try { await import('@opencode-ai/sdk'); } catch (error) { + if (error.code !== 'ERR_MODULE_NOT_FOUND') throw error; + t.skip('optional OpenCode SDK is not installed'); + return; + } + const root = fs.mkdtempSync(`${getTempFilePath('opencode-startup-test')}-`); + const binPath = path.join(root, 'opencode'); + const startedPath = path.join(root, 'started'); + const readyPath = path.join(root, 'ready'); + const originalHome = process.env.HOME; + fs.writeFileSync(binPath, `#!${process.execPath} +const fs = require('node:fs'); +const http = require('node:http'); +const path = require('node:path'); +const root = ${JSON.stringify(root)}; +const profile = path.basename(process.env.HOME); +fs.appendFileSync(path.join(root, 'started'), profile + '\\n'); +if (profile === 'profile-failed') process.exit(7); +const port = Number(process.argv.find(arg => arg.startsWith('--port=')).split('=')[1]); +const server = http.createServer((_req, res) => { + res.setHeader('content-type', 'application/json'); + res.end(JSON.stringify({ providers: [], default: { fixture: profile } })); +}); +const poll = setInterval(() => { + if (!fs.existsSync(path.join(root, 'ready'))) return; + clearInterval(poll); + server.listen(port, '127.0.0.1', () => console.log('opencode server listening on http://127.0.0.1:' + port)); +}, 10); +`, { mode: 0o755 }); + const loads = ['profile-a', 'profile-b'].map(profile => listOpenCodeModels({ + binPath, + env: { PATH: process.env.PATH, HOME: path.join(root, profile) }, + })); + // Observe every rejection immediately, including failures during cleanup. + const settled = Promise.allSettled(loads); + try { + const deadline = Date.now() + 5000; + let profiles = []; + while (Date.now() < deadline) { + profiles = fs.existsSync(startedPath) ? fs.readFileSync(startedPath, 'utf8').trim().split('\n') : []; + if (profiles.length === 2) break; + await new Promise(resolve => setTimeout(resolve, 20)); + } + assert.deepEqual(profiles.sort(), ['profile-a', 'profile-b'], 'both profiles must spawn without waiting for either server to become ready'); + assert.equal(process.env.HOME, originalHome, 'the parent environment must be restored while servers start'); + fs.writeFileSync(readyPath, 'ready'); + const results = await settled; + assert.deepEqual(results.map(result => result.status), ['fulfilled', 'fulfilled']); + assert.deepEqual(results.map(result => result.value.currentModelId), ['fixture/profile-a', 'fixture/profile-b']); + // A rejected startup must restore the environment and release the gate + // as well, so a subsequent profile still starts with its own HOME. + resetOpenCodeListServerPool(); + const failed = await listOpenCodeModels({ + binPath, env: { PATH: process.env.PATH, HOME: path.join(root, 'profile-failed') }, + }).catch(error => { + assert.match(error.message, /Server exited with code 7/); + return { models: [] }; + }); + assert.equal(failed.models.length, 0); + assert.equal(process.env.HOME, originalHome); + const retry = await listOpenCodeModels({ + binPath, env: { PATH: process.env.PATH, HOME: path.join(root, 'profile-retry') }, + }); + assert.equal(retry.currentModelId, 'fixture/profile-retry'); + assert.equal(process.env.HOME, originalHome); + } finally { + fs.writeFileSync(readyPath, 'ready'); + await settled; + resetOpenCodeListServerPool(); + fs.rmSync(root, { recursive: true, force: true }); + } +}); diff --git a/electron/bridges/aiBridge/sdk/opencodeDriver.test.cjs b/electron/bridges/aiBridge/sdk/opencodeDriver.test.cjs index 778e11b66..bc1f94622 100644 --- a/electron/bridges/aiBridge/sdk/opencodeDriver.test.cjs +++ b/electron/bridges/aiBridge/sdk/opencodeDriver.test.cjs @@ -11,12 +11,14 @@ const { listOpenCodeModels, mapOpenCodeModels, OPENCODE_LIST_SERVER_IDLE_MS, + OPENCODE_SERVER_START_TIMEOUT_MS, parseOpenCodeModel, resetOpenCodeListServerPool, resolveUsableOpenCodeBinPath, runOpenCodeTurn, translateOpenCodeEvent, withOpenCodeProcessEnv, + withOpenCodeServerPort, } = require("./opencodeDriver.cjs"); function collector() { @@ -768,6 +770,52 @@ test("runOpenCodeTurn passes an explicit non-default port to the OpenCode SDK fa assert.notEqual(capturedPort, 4096); }); +test("runOpenCodeTurn passes an explicit startup timeout to the OpenCode SDK factory (#3579)", async () => { + const { emitter } = collector(); + const abortController = new AbortController(); + let capturedTimeout; + const stream = { + async *[Symbol.asyncIterator]() { + yield { type: "message.part.updated", properties: { part: { type: "text", sessionID: "sess-1", id: "p1", text: "ok" } } }; + yield { type: "session.idle", properties: { sessionID: "sess-1" } }; + }, + }; + const client = { + global: { event: async () => ({ stream }) }, + session: { + create: async () => ({ data: { id: "sess-1" } }), + promptAsync: async () => ({ data: true }), + }, + }; + + await runOpenCodeTurn({ + prompt: "hello", + emitter, + abortController, + openCodeFactory: async (options) => { + capturedTimeout = options.timeout; + return { client, server: { close() {} } }; + }, + }); + + assert.equal(capturedTimeout, OPENCODE_SERVER_START_TIMEOUT_MS); + assert.ok(capturedTimeout > 5000, "startup deadline must exceed the SDK's 5000ms default"); +}); + +test("withOpenCodeServerPort injects a generous startup timeout and preserves explicit values", async () => { + const defaulted = await withOpenCodeServerPort({}); + assert.equal(defaulted.timeout, OPENCODE_SERVER_START_TIMEOUT_MS); + assert.ok(defaulted.timeout > 5000, "startup deadline must exceed the SDK's 5000ms default"); + assert.equal(typeof defaulted.port, "number"); + + const withPort = await withOpenCodeServerPort({ port: 5555 }); + assert.equal(withPort.port, 5555); + assert.equal(withPort.timeout, OPENCODE_SERVER_START_TIMEOUT_MS); + + const withExplicitTimeout = await withOpenCodeServerPort({ port: 5555, timeout: 120000 }); + assert.equal(withExplicitTimeout.timeout, 120000); +}); + test("runOpenCodeTurn waits for the OpenCode event stream before prompting", async () => { const { emitter } = collector(); const abortController = new AbortController(); @@ -1012,3 +1060,22 @@ test("classifyOpenCodeSpawnError recognizes missing opencode CLI", () => { assert.equal(classifyOpenCodeSpawnError(Object.assign(new Error("spawn opencode ENOENT"), { code: "ENOENT" })).isSpawnEnoent, true); assert.equal(classifyOpenCodeSpawnError(new Error("other")).isSpawnEnoent, false); }); + +test("listOpenCodeModels rethrows catalog failures instead of silently returning empty", async () => { + resetOpenCodeListServerPool(); + await assert.rejects( + listOpenCodeModels({ + binPath: "/tmp/opencode-error-test", + openCodeFactory: async () => ({ + client: { + config: { + providers: async () => ({ error: { message: "provider fetch failed" } }), + }, + }, + server: { close() {} }, + }), + }), + /provider fetch failed/, + ); + resetOpenCodeListServerPool(); +}); diff --git a/electron/bridges/aiBridge/sdk/sdkStreamHandlers.cjs b/electron/bridges/aiBridge/sdk/sdkStreamHandlers.cjs index be7ddf0a6..d477ea493 100644 --- a/electron/bridges/aiBridge/sdk/sdkStreamHandlers.cjs +++ b/electron/bridges/aiBridge/sdk/sdkStreamHandlers.cjs @@ -13,6 +13,7 @@ const { CodexAppServerRuntime } = require("../codexAppServer/runtime.cjs"); const { probeCodexAppServer } = require("../codexAppServer/probe.cjs"); const { codebuddySessionManager } = require("./codebuddySessionManager.cjs"); const codebuddyDriver = require("./codebuddyDriver.cjs"); +const { OPENCODE_SERVER_START_TIMEOUT_MS } = require("./opencodeDriver.cjs"); const VALID_BACKENDS = new Set(listBackends()); @@ -982,7 +983,11 @@ function registerSdkStreamHandlers(ctx) { cursorCliBinPath: backendKey === "cursor" ? cursorCliBinPath : undefined, codexAppServerRuntime, }), - MODEL_LIST_TIMEOUT_MS, + // Include OpenCode cold startup in this single cancellable + // catalog budget, then allow the normal provider-query budget. + backendKey === "opencode" + ? OPENCODE_SERVER_START_TIMEOUT_MS + MODEL_LIST_TIMEOUT_MS + : MODEL_LIST_TIMEOUT_MS, abortController, ); const { currentModelId, models } = normalizeSdkListModelsResult(raw); diff --git a/electron/bridges/aiBridge/sdk/sdkStreamHandlers.test.cjs b/electron/bridges/aiBridge/sdk/sdkStreamHandlers.test.cjs index cf3c60e19..1bb802711 100644 --- a/electron/bridges/aiBridge/sdk/sdkStreamHandlers.test.cjs +++ b/electron/bridges/aiBridge/sdk/sdkStreamHandlers.test.cjs @@ -25,7 +25,7 @@ const { * (cleanup) can be invoked directly. registerSdkStreamHandlers exposes its * request-scoped maps on ctx for exactly this kind of test. */ -function registerWithStubbedCtx() { +function registerWithStubbedCtx(overrides = {}) { const handlers = new Map(); const ctx = { ipcMain: { handle: (channel, fn) => handlers.set(channel, fn) }, @@ -37,6 +37,7 @@ function registerWithStubbedCtx() { cancelWorkerBackgroundJobsForSession: () => {}, cleanupScopedMetadata: async () => {}, }, + ...overrides, }; registerSdkStreamHandlers(ctx); return { handlers, ctx }; @@ -1014,3 +1015,55 @@ test("resolveSdkBackendBinPath does not fall back to Windows shell shims for non }); assert.equal(out, undefined); }); + + +test("OpenCode catalog IPC allows cold startup past 10s and still aborts a stalled load", async (t) => { + const { getDriver } = require("./index.cjs"); + const { OPENCODE_SERVER_START_TIMEOUT_MS } = require("./opencodeDriver.cjs"); + const { handlers } = registerWithStubbedCtx({ + getShellEnv: async () => ({}), + normalizeAgentEnv: (env) => env || {}, + withCliDiscoveryEnv: (env) => env, + normalizeClaudeCodeExecutableEnvForSdk: (env) => env, + resolveCliFromPath: () => undefined, + normalizeCliPathForPlatform: (value) => value, + resolveSdkBinPath: () => undefined, + resolveClaudeCodeExecutableForSdk: undefined, + resolveCodexExecutableForSdk: undefined, + resolveCodebuddyExecutableForSdk: undefined, + }); + const listModels = handlers.get("netcatty:ai:sdk-agent:list-models"); + const catalog = { models: [{ id: "test/cold-start" }], currentModelId: "test/cold-start" }; + let signal; + let complete; + let started; + let entered = new Promise((resolve) => { started = resolve; }); + t.mock.method(getDriver("opencode"), "listModels", ({ abortController }) => { + signal = abortController.signal; + started(); + return new Promise((resolve) => { complete = resolve; }); + }); + t.mock.timers.enable({ apis: ["setTimeout"] }); + + const slow = listModels({ sender: {} }, { sdkBackend: "opencode", agentCommand: "/test/cold-start" }); + await entered; + t.mock.timers.tick(10001); + assert.equal(signal.aborted, false, "the old outer 10s deadline must not cancel OpenCode startup"); + complete(catalog); + assert.deepEqual(await slow, { ok: true, ...catalog }); + + entered = new Promise((resolve) => { started = resolve; }); + const stalled = listModels({ sender: {} }, { sdkBackend: "opencode", agentCommand: "/test/stalled-start" }); + await entered; + t.mock.timers.tick(OPENCODE_SERVER_START_TIMEOUT_MS + 10000); + const result = await stalled; + assert.equal(signal.aborted, true, "the bounded catalog deadline must abort the driver"); + assert.match(result.warning, /list-models timed out/); + assert.deepEqual(result.models, []); + // A timeout must leave neither an in-flight entry nor a cached failure. + entered = new Promise((resolve) => { started = resolve; }); + const retried = listModels({ sender: {} }, { sdkBackend: "opencode", agentCommand: "/test/stalled-start" }); + await entered; + complete(catalog); + assert.deepEqual(await retried, { ok: true, ...catalog }); +}); diff --git a/electron/bridges/compressUploadBridge.cjs b/electron/bridges/compressUploadBridge.cjs index 734947ec8..20594138d 100644 --- a/electron/bridges/compressUploadBridge.cjs +++ b/electron/bridges/compressUploadBridge.cjs @@ -942,9 +942,12 @@ async function cancelCompression(event, payload) { } } + // A missing registry entry means the compression already settled and was + // cleaned up: cancelling it is an idempotent no-op, not a failure. Report + // success so UI cancel paths do not revert settled rows back to attention. return compression ? { success: true } - : { success: false, reason: 'Compression is not active' }; + : { success: true, inactive: true }; } async function pauseCompression(event, payload) { diff --git a/electron/bridges/compressUploadBridge.lifecycle.test.cjs b/electron/bridges/compressUploadBridge.lifecycle.test.cjs index 8e78e4d93..1d25ecdd2 100644 --- a/electron/bridges/compressUploadBridge.lifecycle.test.cjs +++ b/electron/bridges/compressUploadBridge.lifecycle.test.cjs @@ -531,6 +531,30 @@ test("a late cancel after completion cannot overwrite the completed terminal eve assert.deepEqual(terminalAfter.map((event) => event.type), ["completed"]); }); +test("cancelling a settled compression that left the registry reports success", async (t) => { + const harness = createSuccessfulCompressionHarness(t); + await harness.handlers.get("netcatty:compress:start")({ sender: harness.sender }, { + compressionId: "settled-before-cancel", + folderPath: harness.folderPath, + targetPath: "/tmp", + sftpId: "sftp-harness", + folderName: "folder", + totalBytes: 7, + }); + + const result = await harness.handlers.get("netcatty:compress:cancel")({ sender: harness.sender }, { + compressionId: "settled-before-cancel", + }); + assert.equal(result.success, true); + assert.equal(result.inactive, true); + + const resultUnknownId = await harness.handlers.get("netcatty:compress:cancel")({ sender: harness.sender }, { + compressionId: "never-started", + }); + assert.equal(resultUnknownId.success, true); + assert.equal(resultUnknownId.inactive, true); +}); + test("compressed upload skips remote tar exec on single-channel SSH", async () => { let execCalls = 0; compressUploadBridge._resetCompressionSupportCacheForTests(); diff --git a/electron/bridges/crashLogBridge.cjs b/electron/bridges/crashLogBridge.cjs index 77cc26be0..ae52afbe3 100644 --- a/electron/bridges/crashLogBridge.cjs +++ b/electron/bridges/crashLogBridge.cjs @@ -3,7 +3,9 @@ * * Log files are stored as JSONL (one JSON object per line) under * {userData}/crash-logs/crash-YYYY-MM-DD.log so that appending is cheap and - * atomic. Files older than 30 days are pruned on startup. + * atomic. Files older than 30 days are pruned on startup and every file is + * capped at 16 MB: once today's file is full, further entries are dropped, + * and oversized files left by previous runs are trimmed to their tail. */ const fs = require("node:fs"); @@ -24,6 +26,15 @@ let electronShell = null; let sessionsMap = null; const LOG_RETENTION_DAYS = 30; +// Hard caps so a recurring error can never fill the disk (see issue #3626: +// a single crash-YYYY-MM-DD.log grew to ~150 GB). The per-day file stops +// accepting entries once it reaches MAX_LOG_FILE_BYTES and existing oversized +// files are trimmed down to their tail at startup. +const MAX_LOG_FILE_BYTES = 16 * 1024 * 1024; +// Cap the size of a single serialized entry before it is appended. +const MAX_LOG_ENTRY_BYTES = 128 * 1024; +const MAX_MESSAGE_CHARS = 4096; +const MAX_STACK_CHARS = 16384; const TERMINAL_PERF_DEBUG_ENV_KEYS = [ "NETCATTY_TERMINAL_PERF_DEBUG", "NETCATTY_TERMINAL_DEBUG", @@ -74,6 +85,11 @@ function todayFileName() { return `crash-${ymd}.log`; } +function boundedText(text, maxChars) { + if (typeof text !== "string" || text.length <= maxChars) return text; + return `${text.slice(0, maxChars)} ... [truncated]`; +} + function buildEntry(source, err, extra) { const error = err instanceof Error ? err : new Error(String(err ?? "unknown")); @@ -100,8 +116,8 @@ function buildEntry(source, err, extra) { return { timestamp: new Date().toISOString(), source, - message: error.message || String(err), - stack: error.stack || undefined, + message: boundedText(error.message || String(err), MAX_MESSAGE_CHARS), + stack: boundedText(error.stack, MAX_STACK_CHARS) || undefined, errorMeta: Object.keys(errorMeta).length > 0 ? errorMeta : undefined, extra: extra || undefined, pid: process.pid, @@ -120,6 +136,70 @@ function buildEntry(source, err, extra) { // Public API // --------------------------------------------------------------------------- +/** + * Append one JSONL line to filePath as long as the file stays under + * MAX_LOG_FILE_BYTES. Oversized entries are rejected; once a day's file is + * full, later entries are dropped instead of growing the file further. + * Returns true when the line was written. + */ +function appendEntryLine(filePath, line) { + const lineBytes = Buffer.byteLength(line, "utf-8"); + if (lineBytes + 1 > MAX_LOG_ENTRY_BYTES) return false; + + let size = 0; + try { + size = fs.statSync(filePath).size; + } catch { + size = 0; // file does not exist yet + } + if (size + lineBytes + 1 > MAX_LOG_FILE_BYTES) return false; + + try { + fs.appendFileSync(filePath, line + "\n", "utf-8"); + return true; + } catch { + return false; + } +} + +/** + * True when `line` (plus its trailing newline) fits inside the per-entry byte + * cap. JSON.stringify measures UTF-16 code units, but the file stores UTF-8 + * bytes, so multibyte text must be measured with Buffer.byteLength here to + * match what appendEntryLine actually enforces. + */ +function fitsEntryByteBudget(line) { + return Buffer.byteLength(line, "utf-8") + 1 <= MAX_LOG_ENTRY_BYTES; +} + +/** + * Serialize a crash entry into a bounded JSONL line. If the caller-supplied + * extra payload (or anything else) blows past MAX_LOG_ENTRY_BYTES, retry with + * progressively less detail so the line length is always bounded. + */ +function serializeEntry(entry) { + let line = JSON.stringify(entry); + if (fitsEntryByteBudget(line)) return line; + + const leanEntry = { + ...entry, + message: boundedText(entry.message, 1024), + stack: boundedText(entry.stack, MAX_STACK_CHARS), + extra: undefined, + }; + line = JSON.stringify(leanEntry); + if (fitsEntryByteBudget(line)) return line; + + const minimalEntry = { + timestamp: entry.timestamp, + source: boundedText(entry.source, 256), + message: boundedText(entry.message, 512), + pid: entry.pid, + version: entry.version, + }; + return JSON.stringify(minimalEntry); +} + /** * Write a crash/error entry to today's log file (sync, safe for use in * uncaughtException handlers). @@ -130,8 +210,9 @@ function captureError(source, err, extra) { if (!dir) return; const entry = buildEntry(source, err, extra); + const line = serializeEntry(entry); const filePath = path.join(dir, todayFileName()); - fs.appendFileSync(filePath, JSON.stringify(entry) + "\n", "utf-8"); + appendEntryLine(filePath, line); } catch { // Never throw from the crash logger itself. } @@ -174,6 +255,89 @@ function pruneOldLogs() { } } +/** + * Rewrite an oversized log file in place so only its last MAX_LOG_FILE_BYTES + * (aligned to a line boundary so every remaining line parses) is kept. This + * reads and writes at most MAX_LOG_FILE_BYTES, so it is safe even for a + * multi-gigabyte file. Returns true when the file was trimmed. + */ +function truncateFileToTail(filePath, maxBytes) { + let stat; + try { + stat = fs.lstatSync(filePath); + } catch { + return false; + } + if (!stat.isFile() || stat.size <= maxBytes) return false; + + let fd; + try { + fd = fs.openSync(filePath, fs.constants.O_RDWR | (fs.constants.O_NOFOLLOW || 0)); + // Validate the opened object before reading or truncating it. O_NOFOLLOW + // is not available on every platform, and the path may have changed. + const openedStat = fs.fstatSync(fd); + if (!openedStat.isFile() || openedStat.dev !== stat.dev || openedStat.ino !== stat.ino) { + return false; + } + if (openedStat.size <= maxBytes) return false; + const buf = Buffer.alloc(maxBytes); + const read = fs.readSync(fd, buf, 0, buf.length, openedStat.size - buf.length); + const tail = buf.subarray(0, read); + // Drop the first (possibly partial) line of the tail so every remaining + // line is a complete JSONL entry. Also trim anything after the last + // newline: a final write interrupted before its trailing newline would + // otherwise leave a partial object that the next appendEntryLine appends + // to, corrupting both lines. If the tail contains no newline at all it + // is one giant partial entry (e.g. from an interrupted write), so + // discard it entirely rather than keeping an unappendable file: the file + // would sit exactly at the cap and reject every later report for the day. + const newline = tail.indexOf("\n"); + let keep = Buffer.alloc(0); + if (newline >= 0) { + const lastNewline = tail.lastIndexOf("\n"); + keep = tail.subarray(newline + 1, lastNewline + 1); + } + fs.ftruncateSync(fd, keep.length); + if (keep.length > 0) { + fs.writeSync(fd, keep, 0, keep.length, 0); + } + return true; + } catch { + return false; + } finally { + try { + if (fd !== undefined) fs.closeSync(fd); + } catch { + // ignore + } + } +} + +/** + * Cap every existing crash log file at MAX_LOG_FILE_BYTES. Runs at startup + * before any new entries are appended, so a bloated file from a previous run + * (e.g. issue #3626) is shrunk instead of filling the disk for another day. + */ +function trimOversizedLogs() { + try { + const dir = ensureLogDir(); + if (!dir) return; + + for (const file of fs.readdirSync(dir)) { + if (!file.startsWith("crash-") || !file.endsWith(".log")) continue; + try { + if (truncateFileToTail(path.join(dir, file), MAX_LOG_FILE_BYTES)) { + console.log(`[CrashLog] Trimmed oversized log: ${file}`); + } + } catch { + // skip + } + } + } catch { + // skip + } +} + // --------------------------------------------------------------------------- // IPC handlers // --------------------------------------------------------------------------- @@ -331,6 +495,7 @@ function init(deps) { // ignore } pruneOldLogs(); + trimOversizedLogs(); console.log(`[CrashLog] Crash log directory: ${logDir}`); } @@ -359,9 +524,22 @@ function registerHandlers(ipcMain) { }); } +// Internals exposed for tests only; not part of the public bridge API. +const __internals = { + MAX_LOG_FILE_BYTES, + MAX_LOG_ENTRY_BYTES, + MAX_MESSAGE_CHARS, + MAX_STACK_CHARS, + appendEntryLine, + boundedText, + serializeEntry, + truncateFileToTail, +}; + module.exports = { init, captureError, captureDiagnostic, registerHandlers, + __internals, }; diff --git a/electron/bridges/crashLogBridge.test.cjs b/electron/bridges/crashLogBridge.test.cjs new file mode 100644 index 000000000..3aa296a0c --- /dev/null +++ b/electron/bridges/crashLogBridge.test.cjs @@ -0,0 +1,162 @@ +"use strict"; + +const test = require("node:test"); +const assert = require("node:assert/strict"); +const fs = require("node:fs"); +const os = require("node:os"); +const path = require("node:path"); + +const { + MAX_LOG_FILE_BYTES, + MAX_LOG_ENTRY_BYTES, + appendEntryLine, + boundedText, + serializeEntry, + truncateFileToTail, +} = require("./crashLogBridge.cjs").__internals; + +// Creates a unique temp directory for one test and registers teardown on the +// test context so fixture files never outlive the run. +function tmpFile(t, name = "crash-2026-10-10.log") { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-crashlog-")); + t.after(() => { + try { + fs.rmSync(dir, { recursive: true, force: true }); + } catch { + // best effort cleanup + } + }); + return { dir, filePath: path.join(dir, name) }; +} + +function entryLine(padLen = 0) { + return JSON.stringify({ + timestamp: "2026-10-10T00:00:00.000Z", + source: "test", + message: "boom", + ...(padLen > 0 ? { pad: "p".repeat(padLen) } : {}), + }); +} + +test("appendEntryLine stops accepting entries once the per-day file reaches the size cap", (t) => { + const { filePath } = tmpFile(t); + + assert.equal(appendEntryLine(filePath, entryLine()), true, "first entry written"); + assert.equal( + appendEntryLine(filePath, "x".repeat(MAX_LOG_ENTRY_BYTES)), + false, + "oversized entry rejected", + ); + + // Model the boundary with a small real file, without allocating a full log. + const filler = JSON.stringify({ message: "\u4e2d" }); + const lineBytes = Buffer.byteLength(filler, "utf-8"); + const before = fs.readFileSync(filePath, "utf-8"); + const stat = t.mock.method(fs, "statSync", () => ({ size: MAX_LOG_FILE_BYTES - lineBytes })); + assert.equal(appendEntryLine(filePath, filler), false, "newline would exceed cap by one byte"); + assert.equal(fs.readFileSync(filePath, "utf-8"), before); + stat.mock.mockImplementation(() => ({ size: MAX_LOG_FILE_BYTES - lineBytes - 1 })); + assert.equal(appendEntryLine(filePath, filler), true, "entry plus newline fits exactly"); + assert.equal(fs.readFileSync(filePath, "utf-8"), before + filler + "\n"); +}); + +test("serializeEntry keeps serialized lines bounded even for huge extras", () => { + const huge = { blob: "z".repeat(MAX_LOG_ENTRY_BYTES + 1) }; + const line = serializeEntry({ + timestamp: "2026-10-10T00:00:00.000Z", + source: "test", + message: "boom", + stack: new Error("boom").stack, + extra: huge, + }); + assert.ok(line.length <= MAX_LOG_ENTRY_BYTES, `line length ${line.length} exceeds cap`); + const entry = JSON.parse(line); + assert.equal(entry.source, "test"); + assert.equal(entry.message, "boom"); +}); + +test("boundedText truncates long strings and leaves short ones alone", () => { + assert.equal(boundedText("short", 100), "short"); + const cut = boundedText("a".repeat(5000), 4096); + assert.equal(cut.length, 4096 + " ... [truncated]".length); + assert.ok(cut.startsWith("a".repeat(4096))); + assert.ok(cut.endsWith("[truncated]")); +}); + +test("truncateFileToTail trims oversized files to their last lines", (t) => { + const { filePath } = tmpFile(t); + + const maxBytes = 1024; + const line = entryLine(128); + const content = (line + "\n").repeat(8); + fs.writeFileSync(filePath, content); + assert.ok(fs.statSync(filePath).size > maxBytes); + + assert.equal(truncateFileToTail(filePath, maxBytes), true); + const newSize = fs.statSync(filePath).size; + assert.ok(newSize <= maxBytes, `trimmed size ${newSize} exceeds cap`); + assert.equal(appendEntryLine(filePath, entryLine()), true, "trimmed log remains appendable"); + // Every remaining line must be a complete, parseable JSONL entry. + const remaining = fs.readFileSync(filePath, "utf-8").split("\n").filter(Boolean); + assert.ok(remaining.length > 0); + for (const l of remaining) assert.doesNotThrow(() => JSON.parse(l)); + + // Files under the cap are left untouched. + assert.equal(truncateFileToTail(filePath, maxBytes), false); +}); + +test("truncateFileToTail tolerates a tail with no newline and missing files", (t) => { + const { dir, filePath } = tmpFile(t); + + // Missing file + assert.equal(truncateFileToTail(path.join(dir, "crash-2026-01-01.log"), MAX_LOG_FILE_BYTES), false); + + // Discard an oversized unterminated line so later entries can be appended. + fs.appendFileSync(filePath, "g".repeat(8192)); + assert.equal(truncateFileToTail(filePath, 4096), true); + assert.equal(fs.statSync(filePath).size, 0); + + // Oversized tail whose final write was interrupted before its trailing + // newline: three terminated entries followed by one entry whose trailing + // newline was never written. The partial final line must be dropped so the + // rewritten file ends on a complete JSONL boundary and the next append does + // not merge with a partial object. + fs.appendFileSync( + filePath, + Array.from({ length: 3 }, () => entryLine(1200) + "\n").join("") + + entryLine(1200), + ); + assert.ok(fs.statSync(filePath).size > 4096); + assert.equal(truncateFileToTail(filePath, 4096), true); + const trimmed = fs.readFileSync(filePath, "utf-8"); + assert.ok(trimmed.endsWith("\n"), "trimmed file must end on a line boundary"); + for (const l of trimmed.split("\n").filter(Boolean)) { + assert.doesNotThrow(() => JSON.parse(l)); + } +}); + +test("truncateFileToTail leaves symlink targets and non-regular files untouched", (t) => { + const { dir, filePath } = tmpFile(t); + const target = path.join(dir, "unrelated.txt"); + const content = (entryLine() + "\n").repeat(4); + fs.writeFileSync(target, content); + fs.symlinkSync(target, filePath); + assert.equal(truncateFileToTail(filePath, 128), false); + assert.equal(fs.readFileSync(target, "utf-8"), content); + assert.ok(fs.lstatSync(filePath).isSymbolicLink()); + assert.equal(truncateFileToTail(dir, 1), false); +}); + +test("truncateFileToTail verifies the opened file before modifying it", (t) => { + const { dir, filePath } = tmpFile(t); + const replacement = path.join(dir, "replacement.log"); + const content = (entryLine() + "\n").repeat(4); + fs.writeFileSync(filePath, content); + fs.writeFileSync(replacement, content); + const openSync = fs.openSync; + const open = t.mock.method(fs, "openSync", (_filePath, flags) => openSync(replacement, flags)); + assert.equal(truncateFileToTail(filePath, 128), false); + open.mock.restore(); + assert.equal(fs.readFileSync(filePath, "utf-8"), content); + assert.equal(fs.readFileSync(replacement, "utf-8"), content); +}); diff --git a/electron/bridges/localFilePublish.cjs b/electron/bridges/localFilePublish.cjs index 94f076cd5..3b35fa149 100644 --- a/electron/bridges/localFilePublish.cjs +++ b/electron/bridges/localFilePublish.cjs @@ -8,24 +8,27 @@ const fs = require("node:fs"); // but no pathname cleanup may remove a concurrent writer's replacement. // Resolves with the published inode identity ({ dev, ino, size }) so callers can // verify an open destination handle before later metadata stamping. -async function publishLocalFileExclusive(source, target, assertNotCancelled = () => {}, preparedHandle) { +async function publishLocalFileExclusive(source, target, assertNotCancelled = () => {}, preparedHandle, options = {}) { assertNotCancelled(); - try { - await fs.promises.link(source, target); - // The hardlink shares the published inode; stat either name. - const linkedStat = await fs.promises.lstat(source); - return { dev: linkedStat.dev, ino: linkedStat.ino, size: linkedStat.size }; - } catch (error) { - // EISDIR is the misleading libuv mapping for Win32 ERROR_INVALID_FUNCTION - // when the volume has no hard-link support (exFAT/FAT32); the source here - // is always a regular prepared file, never a directory (nodejs/node#65817). - if (!["ENOTSUP", "EOPNOTSUPP", "ENOSYS", "EPERM", "EACCES", "EXDEV", "EISDIR"].includes(error?.code)) throw error; + if (!options.copyOnly) { + try { + await fs.promises.link(source, target); + // The hardlink shares the published inode; stat either name. + const linkedStat = await fs.promises.lstat(source); + return { dev: linkedStat.dev, ino: linkedStat.ino, size: linkedStat.size }; + } catch (error) { + // EISDIR is the misleading libuv mapping for Win32 ERROR_INVALID_FUNCTION + // when the volume has no hard-link support (exFAT/FAT32); the source here + // is always a regular prepared file, never a directory (nodejs/node#65817). + if (!["ENOTSUP", "EOPNOTSUPP", "ENOSYS", "EPERM", "EACCES", "EXDEV", "EISDIR"].includes(error?.code)) throw error; + } } let input; let output; let failure; let publishedIdentity; + let timestampsPreserved = true; try { input = preparedHandle ?? await fs.promises.open(source, "r"); const stat = await input.stat(); @@ -49,14 +52,31 @@ async function publishLocalFileExclusive(source, target, assertNotCancelled = () assertNotCancelled(); // Restore metadata after writes (which may clear special permission bits), // through the owned handle rather than a potentially replaced pathname. - await output.chmod(stat.mode & 0o7777); - await output.utimes(stat.atime, stat.mtime); + const mode = stat.mode & 0o7777; + if (((await output.stat()).mode & 0o7777) !== mode) { + try { + await output.chmod(mode); + } catch (error) { + // New files follow the mount's access policy (GVFS can synthesize mode + // bits). Replacements must still preserve the validated protection. + if (!isOptionalMetadataError(error) || options.preserveMode !== false) throw error; + } + if (options.preserveMode !== false && ((await output.stat()).mode & 0o7777) !== mode) { + throw new Error("Cannot preserve local destination permissions"); + } + } + try { + await output.utimes(stat.atime, stat.mtime); + } catch (error) { + if (!isOptionalMetadataError(error) || options.requireTimestamps !== false) throw error; + timestampsPreserved = false; + } const ownedStat = await output.stat(); const targetStat = await fs.promises.lstat(target); if (!targetStat.isFile() || targetStat.dev !== ownedStat.dev || targetStat.ino !== ownedStat.ino) { throw new Error("Local download target changed during replacement"); } - publishedIdentity = { dev: ownedStat.dev, ino: ownedStat.ino, size: ownedStat.size }; + publishedIdentity = { dev: ownedStat.dev, ino: ownedStat.ino, size: ownedStat.size, timestampsPreserved }; } catch (error) { failure = error; } finally { @@ -73,4 +93,14 @@ async function publishLocalFileExclusive(source, target, assertNotCancelled = () return publishedIdentity; } -module.exports = { publishLocalFileExclusive }; +// Only optional chmod/utimes may ignore permission refusal. Never use this +// predicate for creation, data writes, close, or required permission changes. +function isOptionalMetadataError(error) { + return isMetadataUnsupportedError(error) || ["EPERM", "EACCES"].includes(error?.code); +} + +function isMetadataUnsupportedError(error) { + return ["ENOTSUP", "EOPNOTSUPP", "ENOSYS"].includes(error?.code); +} + +module.exports = { publishLocalFileExclusive, isMetadataUnsupportedError }; diff --git a/electron/bridges/mcpServerBridge.cjs b/electron/bridges/mcpServerBridge.cjs index 6179168a4..6178c34a7 100644 --- a/electron/bridges/mcpServerBridge.cjs +++ b/electron/bridges/mcpServerBridge.cjs @@ -727,6 +727,15 @@ function syncLiveSessionsToExternalScope(chatSessionId = EXTERNAL_MCP_CHAT_SESSI } return { ok: true, count: 0, chatSessionId }; } + // A reconnecting renderer tab can be absent from the backend map while + // another terminal is still live. Preserve its already scoped metadata; + // authoritative renderer updates remove it when the tab actually closes. + const liveIds = new Set(sessionList.map((entry) => entry.sessionId)); + for (const [sessionId, meta] of existingMeta) { + if (!liveIds.has(sessionId) && meta.connected === false) { + sessionList.push({ sessionId, ...meta }); + } + } updateSessionMetadata(sessionList, chatSessionId); return { ok: true, count: sessionList.length, chatSessionId }; } @@ -1615,6 +1624,17 @@ const dispatchCapabilityRpc = createCapabilityRpcDispatcher({ // explicitly closes it while that reply is still in flight, the eventual // result must not revive ownership or scope metadata for the closed tab. if ((closedTerminalSessionRevisions.get(sessionId) || 0) > generation?.closeRevision) return; + // Reuse adds visibility only: never grant ownership or change the original + // owner's idle lifecycle merely because another chat referenced the tab. + if (result?.reused === true) { + if (generation?.ownershipGeneration?.revoked) { + // A late renderer merge must not recreate a deleted chat's scope. + scopedMetadata.delete(chatSessionId); + return; + } + ensureOpenedSessionMetadata(chatSessionId, sessionId, result); + return; + } if (!openedSessionOwnership.register( chatSessionId, sessionId, @@ -2232,10 +2252,16 @@ async function handleGetContext(params) { const hasCommandablePty = ptyStream && typeof ptyStream.write === "function"; const hasSshExec = sshClient && typeof sshClient.exec === "function"; const hasSerialPort = session.serialPort && typeof session.serialPort.write === "function"; - if (!hasCommandablePty && !hasSshExec && !hasSerialPort) continue; - - // Look up metadata scoped to this chat session - const meta = getSessionMeta(sessionId, chatSessionId) || {}; + const hasTransport = hasCommandablePty || hasSshExec || hasSerialPort; + // Look up metadata scoped to this chat session. + const scopeMeta = getSessionMeta(sessionId, chatSessionId); + // Disconnected/reconnecting tabs keep their session entry (SSH + // auto-reconnect reuses the sessionId), so keep them visible with + // connected:false instead of hiding them — otherwise the agent cannot + // see or wait for a tab it opened and reopens it as a duplicate. + // Without scoped metadata the session is not in this agent's scope. + if (!hasTransport && !scopeMeta) continue; + const meta = scopeMeta || {}; hosts.push({ sessionId, hostname: meta.hostname || session.hostname || "", @@ -2245,7 +2271,7 @@ async function handleGetContext(params) { protocol: meta.protocol || session.protocol || session.type || "", shellType: meta.shellType || session.shellKind || "", deviceType: meta.deviceType || "", - connected: meta.connected !== undefined ? meta.connected : !!(session.sshClient || session.conn || ptyStream || session.serialPort), + connected: meta.connected !== undefined ? meta.connected : hasTransport, hostId: meta.hostId || "", hostChain: meta.hostChain || [], activePortForwards: meta.activePortForwards || [], @@ -2257,7 +2283,10 @@ async function handleGetContext(params) { for (const sessionId of resolvedScopedIds) { if (addedHostIds.has(sessionId)) continue; const meta = getSessionMeta(sessionId, chatSessionId); - if (!meta || meta.connected === false) continue; + if (!meta) continue; + // Keep disconnected/reconnecting sessions listed with connected:false so + // the agent can wait on the existing sessionId instead of reopening the + // host as a duplicate tab (SSH auto-reconnect preserves the sessionId). hosts.push(buildHostFromMetadata(sessionId, meta)); addedHostIds.add(sessionId); } @@ -2278,6 +2307,7 @@ async function handleGetContext(params) { "Use the provided tools to execute commands through the sessions exposed by Netcatty. " + buildTerminalToolGuidance(toolHints) + "Serial sessions (protocol: serial, shellType: raw) do not run a standard shell — commands are sent as-is. " + + "A session listed with connected false is still in the scope. SSH auto-reconnect keeps the same sessionId: wait and retry that session while it reconnects. Inactive disconnected or restored tabs may require user action; host_open reuses connected or connecting sessions by default and opens a connection when none is usable. " + "Network device sessions (deviceType: network) use vendor CLIs (Huawei VRP, Cisco IOS, etc.) — commands are sent as-is without shell wrapping, and exit codes are unavailable. " + "Vault snippets, port forwarding rules/tunnels, and SFTP read/write tools are available when exposed in the tool list. " + "Always prefer these tools over suggesting the user to do things manually.", diff --git a/electron/bridges/mcpServerBridge.reconnectingSession.test.cjs b/electron/bridges/mcpServerBridge.reconnectingSession.test.cjs new file mode 100644 index 000000000..a23caf26a --- /dev/null +++ b/electron/bridges/mcpServerBridge.reconnectingSession.test.cjs @@ -0,0 +1,162 @@ +"use strict"; + +const assert = require("node:assert/strict"); +const test = require("node:test"); + +const { isolateCliDiscoveryFile } = require("./cliDiscoveryTestIsolation.cjs"); +isolateCliDiscoveryFile(); + +function loadFreshBridge() { + const bridgePath = require.resolve("./mcpServerBridge.cjs"); + delete require.cache[bridgePath]; + return require("./mcpServerBridge.cjs"); +} + +function setup(t, { sessions } = {}) { + const bridge = loadFreshBridge(); + t.after(() => bridge.cleanup()); + bridge.init({ sessions: sessions || new Map(), electronModule: null }); + bridge.setPermissionMode("auto"); + return bridge; +} + +test('reusing a user tab adds visibility without ownership or idle tracking', async (t) => { + const bridge = setup(t); + bridge.setVaultAgentInvoker(async () => ({ ok: true, reused: true, sessionId: 'user-tab', hostId: 'host-b', status: 'connected' })); + await bridge.dispatchBuiltinRpc('public/vault/hosts/open', { chatSessionId: 'chat-b', hostId: 'host-b' }); + assert.deepEqual(bridge.getScopedSessionIds('chat-b'), ['user-tab']); + assert.equal(bridge.reportOpenedSessionActivity({ sessionId: 'user-tab', phase: 'touch' }), false); + const close = await bridge.dispatchBuiltinRpc('public/session/close', { chatSessionId: 'chat-b', sessionId: 'user-tab' }); + assert.equal(close.ok, false); + assert.match(close.error, /not opened by the current AI scope/); +}); + +test('reuse keeps the original chat owner and its idle tracking', async (t) => { + const bridge = setup(t); + const calls = []; + bridge.setVaultAgentInvoker(async (op, params) => { + calls.push({ op, params }); + if (op === 'session.close') return { ok: true }; + return { ok: true, reused: params.chatSessionId !== 'owner', sessionId: 'shared-tab', hostId: 'host-b', status: 'connected' }; + }); + for (const chatSessionId of ['owner', 'borrower']) { + await bridge.dispatchBuiltinRpc('public/vault/hosts/open', { chatSessionId, hostId: 'host-b' }); + } + assert.equal(bridge.reportOpenedSessionActivity({ sessionId: 'shared-tab', phase: 'touch' }), true); + assert.equal((await bridge.dispatchBuiltinRpc('public/session/close', { chatSessionId: 'borrower', sessionId: 'shared-tab' })).ok, false); + assert.equal((await bridge.dispatchBuiltinRpc('public/session/close', { chatSessionId: 'owner', sessionId: 'shared-tab' })).ok, true); + assert.equal(calls.filter(({ op }) => op === 'session.close').length, 1); +}); + +test('a deleted chat cannot reclaim a reused tab or revive its own scope', async (t) => { + const bridge = setup(t); + let finishOpen; + const calls = []; + bridge.updateSessionMetadata([{ sessionId: 'user-tab', connected: true }], 'original'); + bridge.setVaultAgentInvoker((op) => { + calls.push(op); + return new Promise((resolve) => { finishOpen = resolve; }); + }); + const opening = bridge.dispatchBuiltinRpc('public/vault/hosts/open', { chatSessionId: 'deleted', hostId: 'host-b' }); + await bridge.cleanupScopedMetadata('deleted'); + bridge.mergeSessionMetadata([{ sessionId: 'user-tab', connected: true }], 'deleted'); + finishOpen({ ok: true, reused: true, sessionId: 'user-tab', status: 'connected' }); + assert.equal((await opening).ok, true); + assert.deepEqual(calls, ['host.open']); + assert.deepEqual(bridge.getScopedSessionIds('deleted'), []); + assert.deepEqual(bridge.getScopedSessionIds('original'), ['user-tab']); + assert.equal(bridge.reportOpenedSessionActivity({ sessionId: 'user-tab', phase: 'touch' }), false); +}); + +test("getContext keeps a scoped disconnected session visible with connected:false", async (t) => { + const bridge = setup(t); + bridge.updateSessionMetadata([ + { + sessionId: "sess-reconnecting", + hostname: "10.0.0.2", + label: "server-b", + connected: true, + hostId: "host-b", + protocol: "ssh", + }, + ], "chat-1"); + // Connection dropped: renderer pushes connected:false while the tab + // auto-reconnects. The scope keeps the same sessionId. + bridge.updateSessionMetadata([ + { + sessionId: "sess-reconnecting", + hostname: "10.0.0.2", + label: "server-b", + connected: false, + hostId: "host-b", + protocol: "ssh", + }, + ], "chat-1"); + + const environment = await bridge.dispatchBuiltinRpc("netcatty/getContext", { + chatSessionId: "chat-1", + }); + const host = environment.hosts.find((entry) => entry.sessionId === "sess-reconnecting"); + assert.ok(host, "reconnecting session must stay visible to the agent"); + assert.equal(host.connected, false); +}); + +test("getContext keeps a live-map session without transport visible when scoped", async (t) => { + const bridge = setup(t, { + sessions: new Map([["sess-live", { hostname: "10.0.0.3", label: "server-c" }]]), + }); + bridge.updateSessionMetadata([ + { + sessionId: "sess-live", + hostname: "10.0.0.3", + label: "server-c", + connected: false, + hostId: "host-c", + protocol: "ssh", + }, + ], "chat-1"); + + const environment = await bridge.dispatchBuiltinRpc("netcatty/getContext", { + chatSessionId: "chat-1", + }); + const host = environment.hosts.find((entry) => entry.sessionId === "sess-live"); + assert.ok(host, "reconnecting live-map session must stay visible"); + assert.equal(host.connected, false); +}); + +test("getContext still hides disconnected sessions outside the agent scope", async (t) => { + const bridge = setup(t); + bridge.updateSessionMetadata([ + { + sessionId: "foreign", + hostname: "10.0.0.9", + label: "foreign", + connected: false, + hostId: "host-x", + protocol: "ssh", + }, + ], "chat-other"); + + const environment = await bridge.dispatchBuiltinRpc("netcatty/getContext", { + chatSessionId: "chat-1", + }); + assert.equal(environment.hosts.length, 0); +}); + +test("external context preserves reconnecting metadata beside another live transport", async (t) => { + const bridge = setup(t, { sessions: new Map([["online", { hostname: "online", stream: { write() {} } }]]) }); + const scope = bridge.EXTERNAL_MCP_CHAT_SESSION_ID; + bridge.setExternalMcpHooks({ isEnabled: () => true }); + bridge.updateSessionMetadata([ + { sessionId: "online", hostname: "online", connected: true }, + { sessionId: "retrying", hostname: "retrying", connected: false }, + ], scope); + const context = await bridge.dispatchBuiltinRpc("netcatty/getContext", { chatSessionId: scope }); + assert.equal(context.hostCount, 2); + assert.equal(context.hosts.find(host => host.sessionId === "retrying")?.connected, false); + assert.equal(context.hosts.find(host => host.sessionId === "online")?.connected, true); + // A renderer-authoritative removal must still remove the closed tab. + bridge.updateSessionMetadata([{ sessionId: "online", connected: true }], scope); + const afterClose = await bridge.dispatchBuiltinRpc("netcatty/getContext", { chatSessionId: scope }); + assert.deepEqual(afterClose.hosts.map(host => host.sessionId), ["online"]); +}); diff --git a/electron/bridges/mcpServerBridge/sessionIdleManager.cjs b/electron/bridges/mcpServerBridge/sessionIdleManager.cjs index 85f55f4c7..1f654eb96 100644 --- a/electron/bridges/mcpServerBridge/sessionIdleManager.cjs +++ b/electron/bridges/mcpServerBridge/sessionIdleManager.cjs @@ -1,6 +1,10 @@ "use strict"; -const DEFAULT_SESSION_IDLE_TIMEOUT_MINUTES = 30; +// 8h. AI/agent-opened sessions are long-lived, stateful surfaces (prod DB +// operations, deployments); a 30-minute silent close destroyed more in-flight +// work than it saved. 8h covers a workday without sitting on the 24h clamp +// maximum. Users can still set a shorter window in Settings -> AI. +const DEFAULT_SESSION_IDLE_TIMEOUT_MINUTES = 480; const MIN_SESSION_IDLE_TIMEOUT_MINUTES = 1; const MAX_SESSION_IDLE_TIMEOUT_MINUTES = 24 * 60; diff --git a/electron/bridges/sftpBridge.otpTransportReuse.test.cjs b/electron/bridges/sftpBridge.otpTransportReuse.test.cjs index 6841c7046..9a0c57a39 100644 --- a/electron/bridges/sftpBridge.otpTransportReuse.test.cjs +++ b/electron/bridges/sftpBridge.otpTransportReuse.test.cjs @@ -18,6 +18,7 @@ const test = require("node:test"); const assert = require("node:assert/strict"); +const { generateKeyPairSync } = require("node:crypto"); const fs = require("node:fs"); const net = require("node:net"); const path = require("node:path"); @@ -25,7 +26,6 @@ const path = require("node:path"); const tempDirBridge = require("./tempDirBridge.cjs"); const { Server } = require("ssh2"); -const keygen = require("ssh2/lib/keygen.js"); const pool = require("./sshConnectionPool.cjs"); const kiHandler = require("./keyboardInteractiveHandler.cjs"); @@ -201,62 +201,62 @@ function makeSftpHandlers(files, dirs) { function startOtpOnlyServer() { return new Promise((resolve, reject) => { - keygen.generateKeyPair("ed25519", (err, keys) => { - if (err) return reject(err); - const files = new Map(); - const dirs = new Set(["/", "/home", "/home/otpuser"]); - const handlers = makeSftpHandlers(files, dirs); - let keyboardInteractiveRounds = 0; + const { privateKey } = generateKeyPairSync("rsa", { modulusLength: 2048 }); + const files = new Map(); + const dirs = new Set(["/", "/home", "/home/otpuser"]); + const handlers = makeSftpHandlers(files, dirs); + let keyboardInteractiveRounds = 0; - const sshServer = new Server({ hostKeys: [keys.private] }, (client) => { - client.on("authentication", (ctx) => { - if (ctx.method === "keyboard-interactive") { - keyboardInteractiveRounds += 1; - ctx.prompt( - [{ prompt: "Verification code (OTP):", echo: false }], - "OTP required", - "", - (answers) => { - if (answers && answers[0] === OTP_CODE) ctx.accept(); - else ctx.reject(["keyboard-interactive"]); - }, - ); - return; - } - // The server refuses everything else: OTP is the only way in. - ctx.reject(["keyboard-interactive"]); - }); - client.on("ready", () => { - client.on("session", (accept) => { - const session = accept(); - session.on("sftp", (acceptChannel) => { - const sftp = acceptChannel(); - handlers.attach(sftp); - }); + const sshServer = new Server({ + hostKeys: [privateKey.export({ type: "pkcs1", format: "pem" })], + }, (client) => { + client.on("authentication", (ctx) => { + if (ctx.method === "keyboard-interactive") { + keyboardInteractiveRounds += 1; + ctx.prompt( + [{ prompt: "Verification code (OTP):", echo: false }], + "OTP required", + "", + (answers) => { + if (answers && answers[0] === OTP_CODE) ctx.accept(); + else ctx.reject(["keyboard-interactive"]); + }, + ); + return; + } + // The server refuses everything else: OTP is the only way in. + ctx.reject(["keyboard-interactive"]); + }); + client.on("ready", () => { + client.on("session", (accept) => { + const session = accept(); + session.on("sftp", (acceptChannel) => { + const sftp = acceptChannel(); + handlers.attach(sftp); }); }); }); - - const sockets = new Set(); - const server = net.createServer((socket) => { - sockets.add(socket); - socket.on("close", () => sockets.delete(socket)); - sshServer.injectSocket(socket); - }); - server.on("error", reject); - server.listen(0, "127.0.0.1", () => resolve({ - port: server.address().port, - files, - dirs, - getKeyboardInteractiveRounds: () => keyboardInteractiveRounds, - close: () => new Promise((done) => { - // Include clients still waiting for authentication, before registration. - for (const socket of sockets) socket.destroy(); - sshServer.close?.(); - server.close(() => done()); - }), - })); }); + + const sockets = new Set(); + const server = net.createServer((socket) => { + sockets.add(socket); + socket.on("close", () => sockets.delete(socket)); + sshServer.injectSocket(socket); + }); + server.on("error", reject); + server.listen(0, "127.0.0.1", () => resolve({ + port: server.address().port, + files, + dirs, + getKeyboardInteractiveRounds: () => keyboardInteractiveRounds, + close: () => new Promise((done) => { + // Include clients still waiting for authentication, before registration. + for (const socket of sockets) socket.destroy(); + sshServer.close?.(); + server.close(() => done()); + }), + })); }); } diff --git a/electron/bridges/sftpBridge/scpAiTransferAbort.test.cjs b/electron/bridges/sftpBridge/scpAiTransferAbort.test.cjs index c8fd9987d..58265135f 100644 --- a/electron/bridges/sftpBridge/scpAiTransferAbort.test.cjs +++ b/electron/bridges/sftpBridge/scpAiTransferAbort.test.cjs @@ -560,6 +560,54 @@ describe("AI/MCP SCP transfer abort on shipped download/upload entry points", () assert.deepEqual(leftovers, []); }); + it("downloadSftpToLocal cancels and clears the stage before fallback creates output", async (t) => { + const controller = new AbortController(); + const payload = Buffer.from("complete-download-before-cancel"); + const localPath = path.join(tmpDir, "cancel-before-fallback.bin"); + fs.writeFileSync(localPath, "original"); + let stagedPath; + t.after(() => { if (stagedPath) fs.rmSync(stagedPath, { force: true }); }); + const events = []; + sftpClients.set("scp-pre-fallback-cancel", { + __netcattyFileProtocol: "scp", + __netcattyScpBackend: { + async stat() { return { type: "file", isDirectory: false, size: payload.length }; }, + async downloadFile(_remotePath, stage) { + stagedPath = stage; + await fs.promises.writeFile(stage, payload); + return { fileSize: payload.length, transferred: payload.length }; + }, + }, + }); + for (const name of ["rename", "copyFile"]) { + const original = fs.promises[name]; + t.after(() => { fs.promises[name] = original; }); + fs.promises[name] = async (...args) => { + if (args[0] === stagedPath && String(args[1]).endsWith(".ready")) { + if (name === "copyFile") controller.abort(); + throw Object.assign(new Error("injected cross-device copy refusal"), { + code: name === "rename" ? "EXDEV" : "ENOTSUP", + }); + } + return original.apply(fs.promises, args); + }; + } + await assert.rejects(() => sftpBridge.downloadSftpToLocal(null, { + sftpId: "scp-pre-fallback-cancel", remotePath: "/remote/file.bin", localPath, + abortSignal: controller.signal, + onTransferEvent: (channel) => events.push(channel), + }), (error) => { + assert.match(error.message, /cancel|abort/i); + assert.doesNotMatch(error.message, /Recovery files preserved/); + return true; + }); + assert.ok(stagedPath); + assert.equal(fs.existsSync(stagedPath), false); + assert.equal(fs.readFileSync(localPath, "utf8"), "original"); + assert.ok(events.includes("netcatty:transfer:cancelled")); + assert.equal(events.includes("netcatty:transfer:error"), false); + }); + it("downloadSftpToLocal leaves the original untouched when mode setup fails", { skip: process.platform === "win32", }, async (t) => { @@ -582,16 +630,11 @@ describe("AI/MCP SCP transfer abort on shipped download/upload entry points", () await fs.promises.writeFile(localPath, original, { mode: 0o600 }); await fs.promises.chmod(localPath, 0o600); - const originalChmod = fs.promises.chmod; - fs.promises.chmod = async (filePath, mode) => { - if (String(filePath).includes(".netcatty-") && String(filePath).endsWith(".ready")) { - const error = new Error("injected chmod failure"); - error.code = "EPERM"; - throw error; - } - return originalChmod(filePath, mode); - }; - t.after(() => { fs.promises.chmod = originalChmod; }); + interceptPreparedChmod(t, async () => { + const error = new Error("injected chmod failure"); + error.code = "EPERM"; + throw error; + }); await assert.rejects( () => sftpBridge.downloadSftpToLocal(null, { @@ -630,18 +673,14 @@ describe("AI/MCP SCP transfer abort on shipped download/upload entry points", () const original = Buffer.from("original-before-late-change"); await fs.promises.writeFile(localPath, original, { mode: 0o600 }); - const originalChmod = fs.promises.chmod; let releaseChmod; let markChmodStarted; const chmodStarted = new Promise((resolve) => { markChmodStarted = resolve; }); - fs.promises.chmod = async (filePath, mode) => { - if (String(filePath).includes(".netcatty-") && String(filePath).endsWith(".ready")) { - markChmodStarted(); - await new Promise((resolve) => { releaseChmod = resolve; }); - } - return originalChmod(filePath, mode); - }; - t.after(() => { fs.promises.chmod = originalChmod; }); + interceptPreparedChmod(t, async (chmod, mode) => { + markChmodStarted(); + await new Promise((resolve) => { releaseChmod = resolve; }); + return chmod(mode); + }); const running = sftpBridge.downloadSftpToLocal(null, { sftpId: "scp-late-directory", @@ -685,18 +724,14 @@ describe("AI/MCP SCP transfer abort on shipped download/upload entry points", () await fs.promises.writeFile(localPath, original, { mode: 0o600 }); await fs.promises.chmod(localPath, 0o600); - const originalChmod = fs.promises.chmod; let releaseChmod; let markChmodStarted; const chmodStarted = new Promise((resolve) => { markChmodStarted = resolve; }); - fs.promises.chmod = async (filePath, mode) => { - if (String(filePath).includes(".netcatty-") && String(filePath).endsWith(".ready")) { - markChmodStarted(); - await new Promise((resolve) => { releaseChmod = resolve; }); - } - return originalChmod(filePath, mode); - }; - t.after(() => { fs.promises.chmod = originalChmod; }); + interceptPreparedChmod(t, async (chmod, mode) => { + markChmodStarted(); + await new Promise((resolve) => { releaseChmod = resolve; }); + return chmod(mode); + }); const running = sftpBridge.downloadSftpToLocal(null, { sftpId: "scp-same-mode-replacement", @@ -986,3 +1021,17 @@ describe("AI/MCP SCP transfer abort on shipped download/upload entry points", () assert.match(unifiedBlock, /transferBridge\.cancelTransfer/); }); }); + + +function interceptPreparedChmod(t, callback) { + const originalOpen = fs.promises.open; + fs.promises.open = async (filePath, ...args) => { + const handle = await originalOpen.call(fs.promises, filePath, ...args); + if (String(filePath).includes(".netcatty-") && String(filePath).endsWith(".ready") && args[0] === "r") { + const chmod = handle.chmod.bind(handle); + handle.chmod = (mode) => callback(chmod, mode); + } + return handle; + }; + t.after(() => { fs.promises.open = originalOpen; }); +} diff --git a/electron/bridges/sshBridge.agentFallback.test.cjs b/electron/bridges/sshBridge.agentFallback.test.cjs new file mode 100644 index 000000000..3d9569a8b --- /dev/null +++ b/electron/bridges/sshBridge.agentFallback.test.cjs @@ -0,0 +1,193 @@ +"use strict"; + +const test = require("node:test"); +const assert = require("node:assert/strict"); +const crypto = require("node:crypto"); +const fs = require("node:fs"); +const net = require("node:net"); +const os = require("node:os"); +const path = require("node:path"); +const { Server } = require("ssh2"); +const bridge = require("./sshBridge.cjs"); +const tempDirBridge = require("./tempDirBridge.cjs"); + +const listen = (server, ...args) => new Promise((resolve, reject) => { + server.once("error", reject); + server.listen(...args, resolve); +}); +const close = (server) => new Promise((resolve) => server.close(resolve)); + +test("normal SSH startup preserves fallback after an unavailable system agent", { + skip: process.platform === "win32", // This fixture exercises Unix agent sockets. + timeout: 20_000, +}, async (t) => { + const fixture = fs.mkdtempSync(`${tempDirBridge.getTempFilePath("a")}-`); + const originalHomedir = os.homedir; + const originalAgentSocket = process.env.SSH_AUTH_SOCK; + os.homedir = () => fixture; // Never discover the developer's default keys. + t.after(() => { + os.homedir = originalHomedir; + if (originalAgentSocket === undefined) delete process.env.SSH_AUTH_SOCK; + else process.env.SSH_AUTH_SOCK = originalAgentSocket; + fs.rmSync(fixture, { recursive: true, force: true }); + }); + const hostKey = crypto.generateKeyPairSync("rsa", { + modulusLength: 2048, + privateKeyEncoding: { type: "pkcs1", format: "pem" }, + publicKeyEncoding: { type: "spki", format: "pem" }, + }).privateKey; + + let sequence = 0; + async function run({ stale = true, useSshAgent, password = "fixture-password", stall = false, jump = false } = {}) { + const senderId = 3640 + sequence++; + const socketPath = path.join(fixture, "s"); + fs.rmSync(socketPath, { force: true }); + const agentClients = new Set(); + let agentChecks = 0; + const agent = net.createServer((socket) => { + agentClients.add(socket); + agentChecks += 1; + socket.on("error", () => {}); + socket.on("close", () => agentClients.delete(socket)); + // SSH_AGENT_IDENTITIES_ANSWER with zero keys. + socket.once("data", () => socket.end(Buffer.from([0, 0, 0, 5, 12, 0, 0, 0, 0]))); + }); + await listen(agent, socketPath); + if (stale) { + // Preserve a real socket entry with no listener, without a timing race. + fs.renameSync(socketPath, socketPath + ".stale"); + await close(agent); + fs.renameSync(socketPath + ".stale", socketPath); + assert.ok(fs.statSync(socketPath).isSocket()); + } + process.env.SSH_AUTH_SOCK = socketPath; + const sshClients = new Set(); + const forwardingSockets = new Set(); + const methods = []; + let shells = 0; + const ssh = new Server({ hostKeys: [hostKey] }, (client) => { + sshClients.add(client); + client.on("error", () => {}); + client.on("close", () => sshClients.delete(client)); + client.on("tcpip", (accept, reject, info) => { + if (info.destIP !== "127.0.0.1" || info.destPort !== ssh.address().port) return reject(); + const socket = net.connect(info.destPort, info.destIP, () => { + const stream = accept(); + stream.on("error", () => socket.destroy()); + stream.pipe(socket).pipe(stream); + }); + forwardingSockets.add(socket); + socket.on("close", () => forwardingSockets.delete(socket)); + socket.on("error", () => reject()); + }); + client.on("authentication", (ctx) => { + methods.push(ctx.method); + if (ctx.method === "password" && stall) return; + if (ctx.method === "password" && ctx.password === "fixture-password") ctx.accept(); + else ctx.reject(["publickey", "password"]); + }); + client.on("ready", () => client.on("session", (accept) => { + const session = accept(); + session.on("pty", (acceptPty) => acceptPty()); + session.on("shell", (acceptShell) => { + shells += 1; + acceptShell().write("fixture ready\r\n"); + }); + })); + }); + await listen(ssh, 0, "127.0.0.1"); + const handlers = new Map(); + const sent = []; + bridge.init({ sessions: new Map(), electronModule: {} }); + bridge.registerHandlers({ handle: (name, fn) => handlers.set(name, fn), on() {} }); + const event = { sender: { id: senderId, isDestroyed: () => false, + send: (channel, payload) => sent.push({ channel, payload }) } }; + let guard; + try { + const start = handlers.get("netcatty:start")(event, { + sessionId: `agent-fallback-${senderId}`, hostname: "127.0.0.1", port: ssh.address().port, + username: "fixture", password, authMethod: "auto", useSshAgent, + verifyHostKeys: false, reuseTransport: false, skipShellPidDiscovery: true, + sshTcpConnectTimeoutMs: 1000, sshAuthReadyTimeoutMs: 1000, + ...(jump ? { jumpHosts: [{ + hostname: "127.0.0.1", port: ssh.address().port, username: "fixture", + password, authMethod: "auto", useSshAgent, + sshTcpConnectTimeoutMs: 1000, sshAuthReadyTimeoutMs: 1000, + }] } : {}), + }); + let error; + try { + await Promise.race([start, new Promise((_, reject) => { + guard = setTimeout(() => reject(new Error("Fixture exceeded authentication deadline")), 4000); + })]); + } catch (err) { error = err; } + return { error, methods, shells, sent: sent.slice(), agentChecks }; + } finally { + clearTimeout(guard); + for (const client of sshClients) client.end(); + for (const socket of forwardingSockets) socket.destroy(); + for (const client of agentClients) client.destroy(); + if (agent.listening) await close(agent); + await close(ssh); + } + } + + for (const [name, options] of [ + ["stale agent", {}], + ["disabled agent", { useSshAgent: false }], + ["healthy empty agent", { stale: false }], + ]) { + await t.test(`${name} still opens a real shell using the saved password`, async () => { + const result = await run(options); + assert.equal(result.error, undefined); + assert.deepEqual(result.methods, ["none", "password"]); + assert.equal(result.shells, 1); + assert.equal(result.agentChecks, options.stale === false ? 1 : 0); + assert.equal(result.sent.some(({ channel }) => channel === "netcatty:auth:failed"), false); + assert.equal(result.sent.some(({ channel }) => channel === "netcatty:exit"), false); + }); + } + for (const [name, password, methods] of [ + ["agent without other credentials", undefined, ["none"]], + ["incorrect password", "wrong-password", ["none", "password"]], + ]) { + await t.test(`${name} still reports a real authentication failure`, async () => { + // null deliberately omits a password rather than taking run's default. + const result = await run({ password: password ?? null }); + assert.equal(result.error?.level, "client-authentication"); + assert.match(result.error.message, /All configured authentication methods failed/); + assert.deepEqual(result.methods, methods); + assert.equal(result.shells, 0); + assert.ok(result.sent.some(({ channel }) => channel === "netcatty:auth:failed")); + }); + } + await t.test("stalled password fallback retains its authentication deadline", async () => { + const result = await run({ stall: true }); + assert.match(result.error?.message || "", /Connection timeout to 127\.0\.0\.1/); + assert.deepEqual(result.methods, ["none", "password"]); + assert.equal(result.shells, 0); + assert.ok(result.sent.some(({ channel, payload }) => ( + channel === "netcatty:exit" && payload.reason === "timeout" + ))); + }); + await t.test("a stale jump-host agent still reaches a shell through saved passwords", async () => { + const result = await run({ jump: true }); + assert.equal(result.error, undefined); + assert.deepEqual(result.methods, ["none", "password", "none", "password"]); + assert.equal(result.shells, 1); + assert.equal(result.sent.some(({ channel }) => channel === "netcatty:auth:failed"), false); + }); + await t.test("a fatal jump-host auth error after its agent error still rejects", async () => { + const result = await run({ jump: true, password: "wrong-password" }); + assert.equal(result.error?.level, "client-authentication"); + assert.equal(result.error?.isJumpHostAuthError, true); + assert.deepEqual(result.methods, ["none", "password"]); + assert.equal(result.shells, 0); + }); + await t.test("a stalled jump-host password retains its authentication deadline", async () => { + const result = await run({ jump: true, stall: true }); + assert.match(result.error?.message || "", /Connection timeout to 127\.0\.0\.1/); + assert.deepEqual(result.methods, ["none", "password"]); + assert.equal(result.shells, 0); + }); +}); diff --git a/electron/bridges/sshBridge.authRetryExit.test.cjs b/electron/bridges/sshBridge.authRetryExit.test.cjs index 6330b84d1..e65ea560e 100644 --- a/electron/bridges/sshBridge.authRetryExit.test.cjs +++ b/electron/bridges/sshBridge.authRetryExit.test.cjs @@ -467,6 +467,30 @@ function loadBridgeWithAuthRetryMocks(t, options = {}) { ); return; } + if (eventName === "jump-mfa-prompt") { + // Bastion that demands a human-entered OTP via keyboard-interactive. + this.keyboardInteractiveResponses = []; + this.emit("connect"); + this.emit("handshake"); + this.emit( + "keyboard-interactive", + "Bastion MFA", + "Enter the one-time code from your authenticator.", + "", + [{ prompt: "Verification code:", echo: true }], + (responses) => { + this.keyboardInteractiveResponses.push(responses); + if (responses?.[0] === "123456") { + this.emit("ready"); + return; + } + const err = new Error("All configured authentication methods failed"); + err.level = "client-authentication"; + this.emit("error", err); + }, + ); + return; + } if (eventName === "auth-error") { const err = new Error("All configured authentication methods failed"); err.level = "client-authentication"; @@ -549,6 +573,12 @@ function loadBridgeWithAuthRetryMocks(t, options = {}) { utils: { parseKey: () => options.parseKeyResult || new Error("no key parse needed") }, }; } + if (options.macLocalNetworkAccess && request.endsWith("macLocalNetworkAccess.cjs")) { + return { + ...originalLoad.call(this, request, parent, isMain), + ...options.macLocalNetworkAccess, + }; + } if (request === "./netcattyAgent.cjs" || request.endsWith("/netcattyAgent.cjs")) { return { NetcattyAgent: class MockNetcattyAgent {} }; } @@ -2035,3 +2065,151 @@ test("cancelled encrypted-key retry emits one final exit", async (t) => { true, ); }); + +/** + * Mirrors the renderer gate in application/app/useAppStartupEffects.ts + * (shouldQueueKeyboardInteractiveRequest): terminal-scoped prompts whose + * session is not in the renderer session list are auto-cancelled with an + * empty answer; other prompts are shown and answered by the user. + */ +function makeRendererEmulatingSender(rendererSessionIds, answer) { + const sender = makeSender(); + const send = sender.send.bind(sender); + sender.send = (channel, payload) => { + send(channel, payload); + if (channel !== "netcatty:keyboard-interactive") return; + const rejected = payload.scope === "terminal" && !rendererSessionIds.has(payload.sessionId); + keyboardInteractiveHandler.handleResponse( + { sender }, + rejected + ? { requestId: payload.requestId, responses: [], cancelled: true } + : { requestId: payload.requestId, responses: [answer], cancelled: false }, + ); + }; + return sender; +} + +function findBastionClient(MockSSHClient) { + return MockSSHClient.instances.find((client) => Array.isArray(client.keyboardInteractiveResponses)); +} + +function jumpMfaPayload(sessionId) { + return { + sessionId, + hostname: "target.example", + username: "target-user", + authMethod: "password", + password: "target-password", + useSshAgent: false, + port: 22, + knownHosts: [], + jumpHosts: [{ + hostname: "bastion.example", + username: "jump-user", + port: 22, + label: "Bastion", + }], + }; +} + +test("connection test shows and answers jump-host MFA prompts, then connects", async (t) => { + const { bridge, MockSSHClient } = loadBridgeWithAuthRetryMocks(t, { + connectEvents: ["jump-mfa-prompt", "ready"], + }); + const ipcMain = makeIpcMain(); + bridge.init({ sessions: new Map(), electronModule: {} }); + bridge.registerHandlers(ipcMain); + // A connection test never registers a renderer terminal session. + const sender = makeRendererEmulatingSender(new Set(), "123456"); + + const result = await ipcMain.handlers.get("netcatty:test-connection")( + { sender }, + jumpMfaPayload("jump-mfa-test-session"), + ); + + assert.deepEqual(result, { sessionId: "jump-mfa-test-session", testResult: "connected" }); + const prompts = sender.sent.filter((message) => message.channel === "netcatty:keyboard-interactive"); + assert.equal(prompts.length, 1); + assert.equal(prompts[0].payload.scope, "external"); + assert.equal(prompts[0].payload.prompts[0].prompt, "Verification code:"); + assert.deepEqual(findBastionClient(MockSSHClient).keyboardInteractiveResponses, [["123456"]]); + assert.equal( + sender.sent.some((message) => ( + message.channel === "netcatty:test:result" + && message.payload.sessionId === "jump-mfa-test-session" + && message.payload.ok === true + )), + true, + ); +}); + +test("terminal SSH still answers jump-host MFA prompts through the terminal scope", async (t) => { + const { bridge, MockSSHClient } = loadBridgeWithAuthRetryMocks(t, { + connectEvents: ["jump-mfa-prompt", "ready"], + }); + const ipcMain = makeIpcMain(); + bridge.init({ sessions: new Map(), electronModule: {} }); + bridge.registerHandlers(ipcMain); + const sender = makeRendererEmulatingSender(new Set(["jump-mfa-terminal-session"]), "123456"); + + const result = await ipcMain.handlers.get("netcatty:start")( + { sender }, + jumpMfaPayload("jump-mfa-terminal-session"), + ); + + assert.deepEqual(result, { sessionId: "jump-mfa-terminal-session" }); + const prompts = sender.sent.filter((message) => message.channel === "netcatty:keyboard-interactive"); + assert.equal(prompts.length, 1); + assert.equal(prompts[0].payload.scope, "terminal"); + assert.deepEqual(findBastionClient(MockSSHClient).keyboardInteractiveResponses, [["123456"]]); +}); + +test("connection test runs the macOS Local Network preflight before dialing", async (t) => { + const order = []; + const probedPayloads = []; + const attachedProbeResults = []; + const originalMacAccess = require("./macLocalNetworkAccess.cjs"); + const { bridge } = loadBridgeWithAuthRetryMocks(t, { + connectEvents: ["ready"], + macLocalNetworkAccess: { + ensureMacLocalNetworkAccess: async (payload) => { + order.push("probe"); + probedPayloads.push(payload); + return { hostname: "192.168.1.20" }; + }, + attachMacLocalNetworkProbeResult: (options, probeResult) => { + attachedProbeResults.push(probeResult); + return originalMacAccess.attachMacLocalNetworkProbeResult(options, probeResult); + }, + }, + }); + const ipcMain = makeIpcMain(); + bridge.init({ sessions: new Map(), electronModule: {} }); + bridge.registerHandlers(ipcMain); + const sender = makeSender(); + const send = sender.send.bind(sender); + sender.send = (channel, payload) => { + if (channel === "netcatty:chain:progress" && payload.status === "connecting") order.push("dial"); + send(channel, payload); + }; + + const result = await ipcMain.handlers.get("netcatty:test-connection")( + { sender }, + { + sessionId: "mac-preflight-test-session", + hostname: "nas.local", + username: "alice", + authMethod: "password", + password: "secret", + useSshAgent: false, + port: 22, + knownHosts: [], + }, + ); + + assert.deepEqual(result, { sessionId: "mac-preflight-test-session", testResult: "connected" }); + assert.equal(probedPayloads.length, 1); + assert.equal(probedPayloads[0].hostname, "nas.local"); + assert.deepEqual(attachedProbeResults, [{ hostname: "192.168.1.20" }]); + assert.equal(order[0], "probe", "the Local Network preflight must run before the SSH dial"); +}); diff --git a/electron/bridges/sshBridge.cjs b/electron/bridges/sshBridge.cjs index 1b4a2a537..0a5e6741e 100644 --- a/electron/bridges/sshBridge.cjs +++ b/electron/bridges/sshBridge.cjs @@ -66,6 +66,7 @@ const PREFERRED_KEY_NAMES = ["id_ed25519", "id_ecdsa", "id_rsa"]; const SSH_KEY_PATTERN = /^id_[\w-]+$/; const { createStartSessionApi, + cancelTestConnection, resolveSshConnectionTimeouts, } = require("./sshBridge/startSession.cjs"); const { ensureMacLocalNetworkAccess, attachMacLocalNetworkProbeResult } = require("./macLocalNetworkAccess.cjs"); @@ -812,7 +813,12 @@ async function connectThroughChain(event, options, jumpHosts, targetHost, target } resolve(); }); - conn.once('error', (err) => { + conn.on('error', (err) => { + // ssh2 continues to the next auth method after agent errors. + if (err.level === 'agent') { + console.log(`[Chain] Hop ${i + 1} non-fatal agent auth error (will try next method):`, err.message); + return; + } if (settled) return; settled = true; clearAuthReadyTimer(); @@ -1361,6 +1367,31 @@ async function startSSHSessionWrapper(event, options) { } } +/** + * Headless "test connection" for the host editor. Establishes a fresh SSH + * connection through the same dial/auth/host-key/jump-host/proxy machinery as + * a terminal session (`startSSHSession`), reports success the moment + * authentication completes, and tears down without ever opening a shell. + * Connection reuse and retry wrappers are deliberately skipped so a test is + * always an honest fresh dial. The renderer consumes `netcatty:test:result` + * plus the shared `netcatty:chain:progress` / host-key / keyboard-interactive + * channels. + */ +async function testConnection(event, options) { + const payload = options && typeof options === "object" ? options : {}; + const sessionId = payload.sessionId || require("node:crypto").randomUUID(); + // Same macOS Local Network preflight as terminal starts (#2663 / #2673): + // trigger the TCC prompt from the main process and carry the resolved + // first-hop address so private-LAN / split-DNS failures get annotated. + const probeResult = await ensureMacLocalNetworkAccess(payload); + return startSSHSession(event, { + ...attachMacLocalNetworkProbeResult(payload, probeResult), + sessionId, + testMode: true, + reuseTransport: false, + }); +} + /** * Get current working directory from an active SSH session * This sends 'pwd' to the existing shell stream and captures the output @@ -1517,6 +1548,8 @@ function registerHandlers(ipcMain, options = {}) { hostKeyVerifier.registerHandler(ipcMain); } ipcMain.handle("netcatty:key:generate", generateKeyPair); + ipcMain.handle("netcatty:test-connection", testConnection); + ipcMain.handle("netcatty:test-connection:cancel", (_event, sessionId) => cancelTestConnection(sessionId)); ipcMain.handle("netcatty:sshDebugLog:info", getSshDebugLogInfo); ipcMain.handle("netcatty:sshDebugLog:openDir", openSshDebugLogDir); ipcMain.handle("netcatty:ssh:check-agent", async (_event, options = {}) => { diff --git a/electron/bridges/sshBridge.connectionReuse.test.cjs b/electron/bridges/sshBridge.connectionReuse.test.cjs index 4ed9eb5b8..af5e25d79 100644 --- a/electron/bridges/sshBridge.connectionReuse.test.cjs +++ b/electron/bridges/sshBridge.connectionReuse.test.cjs @@ -2318,3 +2318,187 @@ test("single-channel Copy Tab dials separately and keeps the original shell", as assert.notEqual(sessions.get("copy").conn, sourceConn); assert.equal(sessions.get("source").conn, sourceConn); }); + +function waitForExitDeliveries() { + // finalizeClose runs after the paced output buffer flushes on the next + // event-loop turns; give both queue turns and timers a chance to run. + return new Promise((resolve) => setTimeout(resolve, 50)); +} + +function getExitPayloads(sender) { + return sender.sent + .filter((m) => m.channel === "netcatty:exit") + .map((m) => m.payload); +} + +test("a channel closed without exit-status or exit-signal reports no exit code (#3591)", async (t) => { + const { bridge } = loadBridgeWithMockedSsh2(t, { connectReady: true }); + const sessions = new Map(); + const start = registerStartHandler(bridge, sessions); + const sender = makeSender(); + const options = { + hostname: "bastion.example", + username: "alice", + port: 22, + authMethod: "password", + password: "secret", + useSshAgent: false, + verifyHostKeys: false, + }; + + await start({ sender }, { ...options, sessionId: "no-status-close" }); + const stream = sessions.get("no-status-close").stream; + + // Bastions/gateways built on stacks like golang.org/x/crypto/ssh close the + // session channel without ever sending exit-status or exit-signal, so ssh2 + // never emits stream "exit". + stream.emit("close"); + await waitForExitDeliveries(); + + const exits = getExitPayloads(sender); + assert.equal(exits.length, 1); + assert.equal(exits[0].reason, "closed"); + assert.equal(exits[0].exitCode, undefined, "the sentinel 0 must not be reported as a real exit code"); + assert.equal(exits[0].remoteClosedWithoutExitStatus, true); +}); + +test("a real exit-status still reports its numeric exit code", async (t) => { + const { bridge } = loadBridgeWithMockedSsh2(t, { connectReady: true }); + const sessions = new Map(); + const start = registerStartHandler(bridge, sessions); + const sender = makeSender(); + const options = { + hostname: "10.0.0.50", + username: "alice", + port: 22, + authMethod: "password", + password: "secret", + useSshAgent: false, + verifyHostKeys: false, + }; + + await start({ sender }, { ...options, sessionId: "clean-exit" }); + const stream = sessions.get("clean-exit").stream; + + stream.emit("exit", 0); + stream.emit("close"); + await waitForExitDeliveries(); + + const exits = getExitPayloads(sender); + assert.equal(exits.length, 1); + assert.equal(exits[0].reason, "exited"); + assert.equal(exits[0].exitCode, 0); + assert.equal(exits[0].remoteClosedWithoutExitStatus, undefined); +}); + +test("an exit-signal with no code does not fabricate an exit code 0", async (t) => { + const { bridge } = loadBridgeWithMockedSsh2(t, { connectReady: true }); + const sessions = new Map(); + const start = registerStartHandler(bridge, sessions); + const sender = makeSender(); + const options = { + hostname: "10.0.0.50", + username: "alice", + port: 22, + authMethod: "password", + password: "secret", + useSshAgent: false, + verifyHostKeys: false, + }; + + await start({ sender }, { ...options, sessionId: "signal-exit" }); + const stream = sessions.get("signal-exit").stream; + + stream.emit("exit", undefined, "SIGUSR2"); + stream.emit("close"); + await waitForExitDeliveries(); + + const exits = getExitPayloads(sender); + assert.equal(exits.length, 1); + assert.equal(exits[0].reason, "signaled"); + assert.equal(exits[0].exitSignal, "SIGUSR2"); + assert.equal(exits[0].exitCode, undefined); + assert.equal(exits[0].remoteClosedWithoutExitStatus, undefined, "the remote did report a signal"); +}); + +test("a connection close after an exit-signal stays an administrative disconnect", async (t) => { + const { bridge } = loadBridgeWithMockedSsh2(t, { connectReady: true }); + const sessions = new Map(); + const start = registerStartHandler(bridge, sessions); + const sender = makeSender(); + const options = { + hostname: "10.0.0.50", + username: "alice", + port: 22, + authMethod: "password", + password: "secret", + useSshAgent: false, + verifyHostKeys: false, + }; + + await start({ sender }, { ...options, sessionId: "signal-then-conn-close" }); + const session = sessions.get("signal-then-conn-close"); + + // ssh2 can emit conn "close" before the channel's paced finalize. The + // signal recorded on "exit" must still win over a generic transport close. + session.stream.emit("exit", null, "SIGHUP"); + session.conn.emit("close"); + await waitForExitDeliveries(); + + const exits = getExitPayloads(sender); + assert.equal(exits.length, 1); + assert.equal(exits[0].reason, "signaled"); + assert.equal(exits[0].exitSignal, "SIGHUP"); + assert.equal(exits[0].exitCode, undefined); +}); + +test("a connection close without an exit-signal stays a transport close", async (t) => { + const { bridge } = loadBridgeWithMockedSsh2(t, { connectReady: true }); + const sessions = new Map(); + const start = registerStartHandler(bridge, sessions); + const sender = makeSender(); + const options = { + hostname: "10.0.0.50", + username: "alice", + port: 22, + authMethod: "password", + password: "secret", + useSshAgent: false, + verifyHostKeys: false, + }; + + await start({ sender }, { ...options, sessionId: "transport-conn-close" }); + sessions.get("transport-conn-close").conn.emit("close"); + await waitForExitDeliveries(); + + const exits = getExitPayloads(sender); + assert.equal(exits.length, 1); + assert.equal(exits[0].reason, "closed"); + assert.equal(exits[0].exitCode, undefined); + assert.equal(exits[0].remoteClosedWithoutExitStatus, true); + assert.equal(exits[0].exitSignal, undefined); +}); + +for (const exitCode of [0, 7]) { + test(`connection close preserves reported shell status ${exitCode} before channel finalization`, async (t) => { + const { bridge } = loadBridgeWithMockedSsh2(t, { connectReady: true }); + const sessions = new Map(); + const start = registerStartHandler(bridge, sessions); + const sender = makeSender(); + const sessionId = `status-then-conn-close-${exitCode}`; + await start({ sender }, { + hostname: "10.0.0.50", username: "alice", port: 22, + authMethod: "password", password: "secret", + useSshAgent: false, verifyHostKeys: false, sessionId, + }); + const session = sessions.get(sessionId); + session.stream.emit("exit", exitCode); + session.conn.emit("close"); + await waitForExitDeliveries(); + const exits = getExitPayloads(sender); + assert.equal(exits.length, 1); + assert.equal(exits[0].reason, "exited"); + assert.equal(exits[0].exitCode, exitCode); + assert.equal(exits[0].remoteClosedWithoutExitStatus, undefined); + }); +} diff --git a/electron/bridges/sshBridge.testConnectionCancel.test.cjs b/electron/bridges/sshBridge.testConnectionCancel.test.cjs new file mode 100644 index 000000000..5bb8d47c7 --- /dev/null +++ b/electron/bridges/sshBridge.testConnectionCancel.test.cjs @@ -0,0 +1,83 @@ +"use strict"; + +const test = require("node:test"); +const assert = require("node:assert/strict"); +const fs = require("node:fs"); +const net = require("node:net"); +const os = require("node:os"); +const path = require("node:path"); + +const originalHome = process.env.HOME; +const originalTmpdir = process.env.TMPDIR; +const isolatedHome = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-test-cancel-")); +process.env.HOME = isolatedHome; +process.env.TMPDIR = isolatedHome; +const bridge = require("./sshBridge.cjs"); + +test.after(() => { + if (originalHome === undefined) delete process.env.HOME; + else process.env.HOME = originalHome; + if (originalTmpdir === undefined) delete process.env.TMPDIR; + else process.env.TMPDIR = originalTmpdir; + fs.rmSync(isolatedHome, { recursive: true, force: true }); +}); + +function registerBridge() { + const handlers = new Map(); + const sessions = new Map(); + bridge.init({ sessions, electronModule: {} }); + bridge.registerHandlers({ + handle: (channel, handler) => handlers.set(channel, handler), + on() {}, + }); + return { handlers, sessions }; +} + +test("registered test-cancel IPC returns a result for an unknown connection", async () => { + const { handlers } = registerBridge(); + const result = await handlers.get("netcatty:test-connection:cancel")(null, "missing-test"); + assert.deepEqual(result, { success: false, error: "Test connection not found" }); +}); + +test("registered test-cancel IPC closes a live pending transport and settles its probe", { timeout: 10000 }, async (t) => { + const { handlers, sessions } = registerBridge(); + const sockets = new Set(); + let acceptSocket; + let closeSocket; + const accepted = new Promise((resolve) => { acceptSocket = resolve; }); + const closed = new Promise((resolve) => { closeSocket = resolve; }); + // Accept TCP but withhold the SSH banner so authentication stays pending. + // Both the SSH client and IPC registration are production implementations. + const server = net.createServer((socket) => { + sockets.add(socket); + socket.on("error", () => {}); + socket.on("close", () => { sockets.delete(socket); closeSocket(); }); + socket.resume(); + acceptSocket(); + }); + t.after(async () => { + for (const socket of sockets) socket.destroy(); + await new Promise((resolve) => server.close(resolve)); + }); + await new Promise((resolve, reject) => { + server.once("error", reject); + server.listen(0, "127.0.0.1", resolve); + }); + const sent = []; + const event = { sender: { id: 2984, isDestroyed: () => false, send: (channel, payload) => sent.push({ channel, payload }) } }; + const sessionId = "cancel-live-probe"; + const probe = handlers.get("netcatty:test-connection")(event, { + sessionId, hostname: "127.0.0.1", port: server.address().port, + username: "fixture", password: "fixture-password", authMethod: "password", + useSshAgent: false, verifyHostKeys: false, + sshTcpConnectTimeoutMs: 60000, sshAuthReadyTimeoutMs: 60000, + }); + // Observe rejection immediately so a fast cancellation cannot be unhandled. + const rejected = assert.rejects(probe, /Connection lost before handshake|closed unexpectedly/); + await accepted; + + assert.deepEqual(await handlers.get("netcatty:test-connection:cancel")(event, sessionId), { success: true }); + await Promise.all([rejected, closed]); + assert.equal(sessions.size, 0, "a headless probe never opens a terminal session"); + assert.ok(sent.some(({ channel, payload }) => channel === "netcatty:test:result" && payload.sessionId === sessionId && payload.ok === false)); +}); diff --git a/electron/bridges/sshBridge/remoteShellExit.cjs b/electron/bridges/sshBridge/remoteShellExit.cjs new file mode 100644 index 000000000..bf4649c32 --- /dev/null +++ b/electron/bridges/sshBridge/remoteShellExit.cjs @@ -0,0 +1,57 @@ +/** + * Classify how a remote SSH shell channel ended. + * + * An exit-signal (ssh2: code null, signal set) is an administrative + * termination — idle kick, SIGHUP, SIGTERM — not a VPN/transport flap. + * Those stay "signaled" so auto-reconnect can leave them alone. + * A channel that closes with neither exit-status nor exit-signal stays + * "closed" and is still eligible to reconnect. + */ + +function normalizeRemoteExitSignal(signal) { + return typeof signal === "string" && signal.length > 0 ? signal : null; +} + +function classifyRemoteShellClose({ idleTimedOut, exitSignal, shellExited }) { + if (idleTimedOut) return "timeout"; + const signal = normalizeRemoteExitSignal(exitSignal); + if (signal) return "signaled"; + if (shellExited) return "exited"; + return "closed"; +} + +function buildRemoteShellExitPayload({ + sessionId, + generation, + exitCode, + exitSignal, + shellExited, + idleTimedOut, +}) { + const signal = normalizeRemoteExitSignal(exitSignal); + const reason = classifyRemoteShellClose({ + idleTimedOut: idleTimedOut === true, + exitSignal: signal, + shellExited: shellExited === true, + }); + const payload = { + sessionId, + reason, + _terminalSessionGeneration: generation, + }; + if (typeof exitCode === "number") { + payload.exitCode = exitCode; + } else if (!signal) { + payload.remoteClosedWithoutExitStatus = true; + } + if (signal) { + payload.exitSignal = signal; + } + return payload; +} + +module.exports = { + normalizeRemoteExitSignal, + classifyRemoteShellClose, + buildRemoteShellExitPayload, +}; diff --git a/electron/bridges/sshBridge/remoteShellExit.test.cjs b/electron/bridges/sshBridge/remoteShellExit.test.cjs new file mode 100644 index 000000000..65c55b105 --- /dev/null +++ b/electron/bridges/sshBridge/remoteShellExit.test.cjs @@ -0,0 +1,73 @@ +const test = require("node:test"); +const assert = require("node:assert/strict"); + +const { + buildRemoteShellExitPayload, + classifyRemoteShellClose, + normalizeRemoteExitSignal, +} = require("./remoteShellExit.cjs"); + +test("normalizeRemoteExitSignal keeps a reported signal and drops empty values", () => { + assert.equal(normalizeRemoteExitSignal("SIGHUP"), "SIGHUP"); + assert.equal(normalizeRemoteExitSignal("SIGUSR2"), "SIGUSR2"); + assert.equal(normalizeRemoteExitSignal(""), null); + assert.equal(normalizeRemoteExitSignal(null), null); + assert.equal(normalizeRemoteExitSignal(undefined), null); +}); + +test("an exit-signal is an administrative signaled close, not a transport close", () => { + for (const exitSignal of ["SIGHUP", "SIGTERM", "SIGUSR2"]) { + assert.equal( + classifyRemoteShellClose({ idleTimedOut: false, exitSignal, shellExited: false }), + "signaled", + ); + } +}); + +test("a channel close without exit-status or exit-signal stays closed", () => { + assert.equal( + classifyRemoteShellClose({ idleTimedOut: false, exitSignal: null, shellExited: false }), + "closed", + ); +}); + +test("a numeric shell exit stays exited, and a TMOUT banner stays timeout", () => { + assert.equal( + classifyRemoteShellClose({ idleTimedOut: false, exitSignal: null, shellExited: true }), + "exited", + ); + assert.equal( + classifyRemoteShellClose({ idleTimedOut: true, exitSignal: null, shellExited: true }), + "timeout", + ); +}); + +test("an exit-signal payload reports the signal and does not invent an exit code", () => { + const payload = buildRemoteShellExitPayload({ + sessionId: "signal-exit", + generation: 3, + exitSignal: "SIGUSR2", + shellExited: false, + idleTimedOut: false, + }); + + assert.equal(payload.reason, "signaled"); + assert.equal(payload.exitSignal, "SIGUSR2"); + assert.equal(payload.exitCode, undefined); + assert.equal(payload.remoteClosedWithoutExitStatus, undefined); + assert.equal(payload._terminalSessionGeneration, 3); +}); + +test("a bare channel-close payload stays a reconnectable transport close", () => { + const payload = buildRemoteShellExitPayload({ + sessionId: "transport-close", + exitSignal: null, + shellExited: false, + idleTimedOut: false, + }); + + assert.equal(payload.reason, "closed"); + assert.equal(payload.exitSignal, undefined); + assert.equal(payload.exitCode, undefined); + assert.equal(payload.remoteClosedWithoutExitStatus, true); +}); diff --git a/electron/bridges/sshBridge/sessionOps.cjs b/electron/bridges/sshBridge/sessionOps.cjs index 109a15c08..dc860c1c9 100644 --- a/electron/bridges/sshBridge/sessionOps.cjs +++ b/electron/bridges/sshBridge/sessionOps.cjs @@ -1734,7 +1734,7 @@ function createSessionOpsApi(ctx) { disks, // Array of all mounted disks netRxSpeed, // Total network receive speed (bytes/sec) netTxSpeed, // Total network transmit speed (bytes/sec) - latencyMs, // TCP connection establishment latency to the SSH endpoint (ms) + latencyMs, // SSH transport ping on the existing session (ms); never a second TCP dial (#3630) netInterfaces, // Per-interface network stats hostname, osName, diff --git a/electron/bridges/sshBridge/sessionOps.cwd.test.cjs b/electron/bridges/sshBridge/sessionOps.cwd.test.cjs index 46ed075f2..2a2a64fcd 100644 --- a/electron/bridges/sshBridge/sessionOps.cwd.test.cjs +++ b/electron/bridges/sshBridge/sessionOps.cwd.test.cjs @@ -6,6 +6,7 @@ const os = require("node:os"); const path = require("node:path"); const { createSessionOpsApi, decodeLsofFileName } = require("./sessionOps.cjs"); +const { optionsForPeerSingleChannel } = require("../../../domain/singleChannelSshBanner.shared.cjs"); function quoteShellArg(value) { return "'" + String(value).replace(/'/g, "'\\''") + "'"; @@ -880,3 +881,19 @@ test("listSessionDir skips extra exec when singleChannelSsh is set", async () => assert.deepEqual(result.entries, []); assert.equal(execCalls, 0); }); + +test("getSessionPwd skips exec after the CloudLinker banner stamps the runtime capability", async () => { + let execCalls = 0; + const api = makeApi({ + ...optionsForPeerSingleChannel({}, { _remoteVer: "CloudLinker" }), + remoteSshVersion: "CloudLinker", + stream: {}, + conn: { exec() { execCalls += 1; } }, + }); + + const result = await api.getSessionPwd(null, { sessionId: "session-1" }); + + assert.equal(result.success, false); + assert.match(result.error, /extra exec channels/); + assert.equal(execCalls, 0); +}); diff --git a/electron/bridges/sshBridge/sessionOps.serverStats.test.cjs b/electron/bridges/sshBridge/sessionOps.serverStats.test.cjs index fa0b12871..d4d0c9490 100644 --- a/electron/bridges/sshBridge/sessionOps.serverStats.test.cjs +++ b/electron/bridges/sshBridge/sessionOps.serverStats.test.cjs @@ -746,6 +746,46 @@ test("getServerStats measures latency by pinging the stats connection", async () assert.deepEqual(pingedConns, [session.moshStatsConn]); }); +test("getServerStats does not open an unauthenticated SSH handshake (#3630)", async (t) => { + const net = require("node:net"); + const { createSshPingLatencyProbe } = require("./sshPingLatency.cjs"); + let accepted = 0; + const server = net.createServer((socket) => { + accepted += 1; + socket.destroy(); + }); + t.after(() => new Promise((resolve) => server.close(() => resolve()))); + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + const port = server.address().port; + + const conn = Object.assign(fakeConn(LINUX_STATS), { + config: { keepaliveInterval: 30000 }, + }); + const sessions = new Map([["sid", { + type: "ssh", + conn, + hostname: "127.0.0.1", + port, + _reuseEndpoint: { hostname: "127.0.0.1", port }, + }]]); + let pingTarget = null; + const measureSshPingLatency = createSshPingLatencyProbe(); + const api = makeSessionOps(sessions, { + measureSshPingLatency: (target, timeoutMs) => { + pingTarget = target; + return measureSshPingLatency(target, timeoutMs); + }, + }); + + const result = await api.getServerStats({ sender: {} }, { sessionId: "sid" }); + await new Promise((resolve) => setTimeout(resolve, 50)); + + assert.equal(result.success, true); + assert.equal(pingTarget, conn); + assert.equal(result.stats.latencyMs, null); + assert.equal(accepted, 0); +}); + test("getServerStats pings the companion connection for jump-host sessions", async () => { const sessions = new Map([["sid", { type: "mosh", diff --git a/electron/bridges/sshBridge/startSession.cjs b/electron/bridges/sshBridge/startSession.cjs index b0a082067..3198d88fd 100644 --- a/electron/bridges/sshBridge/startSession.cjs +++ b/electron/bridges/sshBridge/startSession.cjs @@ -27,6 +27,10 @@ const { annotateMacLocalNetworkErrorMessage, resolveFirstTcpEndpoint, } = require("../macLocalNetworkAccess.cjs"); +const { + buildRemoteShellExitPayload, + normalizeRemoteExitSignal, +} = require("./remoteShellExit.cjs"); const SSH_TCP_CONNECT_TIMEOUT_MS = 20000; const SSH_AUTH_READY_TIMEOUT_MS = 120000; @@ -47,6 +51,36 @@ function buildInteractiveShellOptions(options) { } +/** + * In-flight headless "test connection" transports, keyed by sessionId. A test + * connection never enters the terminal `sessions` registry (it opens no shell), + * so a dedicated registry is needed so the renderer can cancel an in-flight + * dial. Entries are removed when the connection closes. + */ +const testConnectionTransports = new Map(); + +function registerTestConnectionTransport(sessionId, transport) { + testConnectionTransports.set(sessionId, transport); +} + +function unregisterTestConnectionTransport(sessionId) { + testConnectionTransports.delete(sessionId); +} + +function cancelTestConnection(sessionId) { + const transport = testConnectionTransports.get(sessionId); + if (!transport) return { success: false, error: "Test connection not found" }; + const end = (item) => { + try { item?.end?.(); } catch { /* ignore */ } + try { item?.destroy?.(); } catch { /* ignore */ } + }; + for (const chain of transport.chainConnections || []) end(chain); + end(transport.pendingConn); + end(transport.socket); + end(transport.conn); + return { success: true }; +} + /** * Fan out netcatty:exit to the primary contents plus any attach-home owner * (AI observe popup rebind) so neither side is left stale. @@ -208,6 +242,31 @@ async function prepareAgentForwardingOptions(options, resolveForwardingAgentSock }; } +/** + * Emit the one-shot outcome for a headless "test connection" attempt. + * + * A connection test authenticates (and, for Telnet, completes auto-login) + * and then tears down without ever opening a shell channel. There is no + * persistent terminal session to carry progress, so success/failure is + * reported through a dedicated `netcatty:test:result` event consumed by the + * host-editor test dialog. The same `netcatty:chain:progress`, + * `netcatty:host-key:verify`, `netcatty:keyboard-interactive`, and + * `netcatty:passphrase-auth-failed` channels keep flowing unchanged so the + * dialog reuses the terminal's progress/log/auth/host-key UI verbatim. + */ +function sendConnectionTestResult(sender, sessionId, ok, error) { + if (!sender || sender.isDestroyed?.()) return; + try { + sender.send("netcatty:test:result", { + sessionId, + ok: Boolean(ok), + ...(error ? { error: String(error) } : {}), + }); + } catch { + // Renderer was destroyed mid-send; nothing to do. + } +} + function createStartSessionApi(ctx) { with (ctx) { const listInteractiveShellPids = (conn) => listInteractiveShellPidsShared(conn, { @@ -489,6 +548,15 @@ function createStartSessionApi(ctx) { ), }); }, + // Upload progress tracks bytes the transport actually accepted instead + // of bytes queued (issue #3663): ssh2 keeps writableLength unchanged + // until one queued write fully completes but shrinks _chunk as the + // channel window opens, so summing both approximates the outstanding + // backlog while it drains. Without this, an rz progress bar freezes + // mid-file for as long as the channel window takes to flush. + getTransportPendingBytes() { + return (Number(stream.writableLength) || 0) + (stream._chunk?.length || 0); + }, interruptRemote() { try { stream.signal?.("INT"); } catch { /* ignore */ } }, @@ -610,13 +678,29 @@ function createStartSessionApi(ctx) { // "close" fires whenever the channel closes (could be network drop). // Only treat it as user-initiated exit if "exit" fired with a numeric // code and no signal. Signal terminations (e.g. server kill, idle - // timeout) have code=null and signal set — those are not user exits. - let streamExitCode = 0; + // timeout) have code=null and signal set — those are not user exits + // and are reported as "signaled", not the reconnectable "closed". + // A third case exists: a remote that closes the channel without sending + // exit-status or exit-signal never fires "exit" at all (#3591). The + // initial values below stay null in that case and must not be reported + // as a real "code 0" — that reads like a clean, user-typed `exit`. + let streamExitCode = null; + let streamExitSignal = null; let streamExited = false; stream.on("exit", (code, signal) => { log("shell exit", { sessionId, hostname: options.hostname, code, signal, reused: !!isReused }); - streamExitCode = typeof code === "number" ? code : 0; - streamExited = typeof code === "number" && !signal; + streamExitCode = typeof code === "number" ? code : null; + streamExitSignal = normalizeRemoteExitSignal(signal); + streamExited = typeof code === "number" && !streamExitSignal; + // conn "close" can win the race against this channel's paced + // finalize. Stamp the signal so that path can still tell an + // administrative exit-signal from a transport drop. + if (streamExitSignal && sessions.get(sessionId) === session) { + session._remoteExitSignal = streamExitSignal; + } + if (streamExited && sessions.get(sessionId) === session) { + session._remoteExitCode = streamExitCode; + } }); let closeFinalized = false; @@ -625,6 +709,7 @@ function createStartSessionApi(ctx) { sessionId, hostname: options.hostname, streamExitCode, + streamExitSignal, streamExited, reused: !!isReused, transportError: sessions.get(sessionId)?._transportError, @@ -656,19 +741,21 @@ function createStartSessionApi(ctx) { _terminalSessionGeneration: liveSession?._terminalSessionGeneration, }); } else { - // A shell TMOUT auto-logout is a clean exit (numeric code, no - // signal) — identical to a user-typed `exit` by code/signal — - // so detect it via the banner the shell prints just before - // exiting and report it as a timeout. That keeps the tab open - // for reconnect instead of auto-closing it (#1062 / #977). + // TMOUT auto-logout is a clean exit (numeric code, no signal) and + // is reported as "timeout" via the shell banner (#1062 / #977). + // An exit-signal is a separate administrative kick ("signaled"). + // A channel close with neither exit-status nor exit-signal stays + // "closed" and must not be reported as code 0 (#3591). const idleTimedOut = streamExited && looksLikeIdleAutoLogout(liveSession?._promptTrackTail); - const reason = idleTimedOut ? "timeout" : (streamExited ? "exited" : "closed"); - safeSendSessionExit({ safeSend, electronModule, sessions }, contents, sessionId, { + const exitPayload = buildRemoteShellExitPayload({ sessionId, + generation: liveSession?._terminalSessionGeneration, exitCode: streamExitCode, - reason, - _terminalSessionGeneration: liveSession?._terminalSessionGeneration, + exitSignal: streamExitSignal, + shellExited: streamExited, + idleTimedOut, }); + safeSendSessionExit({ safeSend, electronModule, sessions }, contents, sessionId, exitPayload); } liveSession?.zmodemSentry?.cancel(); // Release this channel's hold on the shared connection. The transport @@ -1218,6 +1305,7 @@ function createStartSessionApi(ctx) { async function startSSHSession(event, options) { const sessionId = options.sessionId || randomUUID(); + const testMode = options.testMode === true; const sender = event.sender; const log = createSshDiagnosticLogger( !!options.sshDebugLogEnabled || process.env.NETCATTY_SSH_DEBUG === "1", @@ -1434,6 +1522,16 @@ function createStartSessionApi(ctx) { let chainConnections = []; let connectionSocket = null; + // Register the test transport BEFORE the chain/proxy dial so an + // in-flight bastion/proxy attempt can be cancelled. `connectThroughChain` + // updates `pendingConn` and `chainConnections` via `options._tunnelRef` + // as each hop/proxy socket is established. + let testTransport = null; + if (testMode) { + testTransport = { conn, socket: null, pendingConn: null, chainConnections }; + registerTestConnectionTransport(sessionId, testTransport); + } + // Determine if we have jump hosts const jumpHosts = options.jumpHosts || []; const hasJumpHosts = jumpHosts.length > 0; @@ -2099,6 +2197,17 @@ function createStartSessionApi(ctx) { // Pass fetched keys to chain connection to avoid re-reading files options._defaultKeys = discoveredDefaultKeys; options._sshDiagnosticLogger = log; + if (testTransport) { + options._tunnelRef = testTransport; + options._connectionsRef = testTransport.chainConnections; + } + if (testMode) { + // A test has no terminal session in the renderer's session list, so + // jump-host keyboard-interactive/MFA prompts must use the external + // scope (same as the target host below); the terminal scope default + // in connectThroughChain would be rejected by the renderer queue. + options._keyboardInteractiveScope = "external"; + } const chainResult = await connectThroughChain( event, @@ -2122,11 +2231,20 @@ function createStartSessionApi(ctx) { options.proxy, options.hostname, options.port || 22, - { timeoutMs: tcpConnectTimeoutMs } + { + timeoutMs: tcpConnectTimeoutMs, + ...(testTransport + ? { onSocket: (socket) => { testTransport.pendingConn = socket; } } + : {}), + } ); connectOpts.sock = connectionSocket; delete connectOpts.host; delete connectOpts.port; + if (testTransport) { + testTransport.socket = connectionSocket; + testTransport.pendingConn = null; + } } else { // Direct connection (no jump hosts, no proxy) sendProgress(1, 1, options.hostname, 'connecting'); @@ -2230,6 +2348,23 @@ function createStartSessionApi(ctx) { } sendProgress(totalHops, totalHops, options.hostname, 'authenticated'); + + if (testMode) { + // Headless connection test: authentication succeeded. Report the + // outcome and tear down without opening a shell channel. The + // auth-method cache write above is intentionally kept so a + // successful test warms the same cache a real connection uses. + clearAuthReadyTimer(); + sendConnectionTestResult(sender, sessionId, true); + settled = true; + try { conn.end(); } catch { /* ignore */ } + for (const c of chainConnections) { + try { c.end(); } catch { /* ignore */ } + } + resolve({ sessionId, testResult: "connected" }); + return; + } + sendProgress(totalHops, totalHops, options.hostname, 'shell'); let establishedOwnerSession = null; @@ -2346,6 +2481,16 @@ function createStartSessionApi(ctx) { }); conn.on("error", (err) => { + // ssh2 reports agent failures before trying the next auth method. + // Keep the connection and its authentication deadline alive. + if (err.level === "agent") { + log("non-fatal agent auth error (will try next method)", { + sessionId, + hostname: options.hostname, + error: err.message, + }); + return; + } clearAuthReadyTimer(); // After the promise is settled, we can't reject again. But if the // session was already established (resolved), we still need to notify @@ -2448,6 +2593,9 @@ function createStartSessionApi(ctx) { // Destroy the connection to prevent further socket errors from leaking // as uncaught exceptions (e.g. ECONNRESET on embedded devices). try { conn.destroy(); } catch { } + if (testMode) { + sendConnectionTestResult(sender, sessionId, false, visibleError); + } settled = true; reject(err); }); @@ -2476,12 +2624,16 @@ function createStartSessionApi(ctx) { sessionDecoders.delete(sessionId); teardownTransport(); try { conn.destroy(); } catch { } + if (testMode) { + sendConnectionTestResult(sender, sessionId, false, err.message); + } settled = true; reject(err); }); conn.once("close", () => { clearAuthReadyTimer(); + unregisterTestConnectionTransport(sessionId); const contents = event.sender; const currentSession = sessions.get(sessionId); const ownsCurrentSession = Boolean(connRef && currentSession?.connRef === connRef); @@ -2513,13 +2665,28 @@ function createStartSessionApi(ctx) { reason: "error", _terminalSessionGeneration: session?._terminalSessionGeneration, }); - } else { - safeSendSessionExit({ safeSend, electronModule, sessions }, contents, sessionId, { + } else if (normalizeRemoteExitSignal(session?._remoteExitSignal)) { + // The shell already reported an exit-signal. This connection + // close is the server finishing that administrative kick, not + // a transport flap — keep it out of the reconnect loop. + safeSendSessionExit({ safeSend, electronModule, sessions }, contents, sessionId, buildRemoteShellExitPayload({ sessionId, - exitCode: 0, - reason: "closed", - _terminalSessionGeneration: session?._terminalSessionGeneration, - }); + generation: session?._terminalSessionGeneration, + exitSignal: session._remoteExitSignal, + shellExited: false, + idleTimedOut: false, + })); + } else { + // Preserve a shell status already received before transport + // close; otherwise do not invent a successful exit code. + const shellExited = typeof session?._remoteExitCode === "number"; + safeSendSessionExit({ safeSend, electronModule, sessions }, contents, sessionId, buildRemoteShellExitPayload({ + sessionId, + generation: session?._terminalSessionGeneration, + exitCode: session?._remoteExitCode, + shellExited, + idleTimedOut: shellExited && looksLikeIdleAutoLogout(session?._promptTrackTail), + })); } // Use this connection's captured token so a late close from an // old transport can't stop a newer same-sessionId stream (#916). @@ -2544,6 +2711,14 @@ function createStartSessionApi(ctx) { } } if (!settled) { + if (testMode) { + sendConnectionTestResult( + sender, + sessionId, + false, + `Connection to ${options.hostname} closed unexpectedly`, + ); + } settled = true; reject(new Error(`Connection to ${options.hostname} closed unexpectedly`)); } @@ -2560,7 +2735,11 @@ function createStartSessionApi(ctx) { hostname: options.hostname, password: options.password, logPrefix, - scope: "terminal", + // A test has no terminal session registered in the renderer's + // session list, so the terminal-scoped keyboard-interactive queue + // would auto-reject the prompt. Use the external scope so the + // host-editor test dialog can still render password/2FA prompts. + scope: testMode ? "external" : "terminal", bootEpoch: options.bootEpoch, getAuthBanner: () => authBanner, shouldSkipAutoFill: () => shouldSkipKiPasswordAutoFill(authPhase), @@ -2612,6 +2791,31 @@ function createStartSessionApi(ctx) { } // If authHandler is a function, it already handles keyboard-interactive + if (testMode) { + // A connection test exercises the single configured credential, + // skipping the full agent / ~/.ssh key / method fallback chain. + // Password credentials still allow keyboard-interactive after + // "password" so PAM/2FA hosts (which reject the raw "password" + // method) are covered without re-prompting the user. Hosts marked + // `requiresMfa` always get keyboard-interactive, including key / + // certificate-only credentials where the server accepts the key as + // the first factor and then asks for an OTP (matching the terminal + // auth handler, which always appends keyboard-interactive). + const order = []; + if (connectOpts.privateKey) { + order.push("publickey"); + } else if (connectOpts.agent) { + order.push("agent"); + } + if (connectOpts.password && !options.requiresMfa) { + order.push("password"); + } + if (connectOpts.password || options.requiresMfa || order.length === 0) { + order.push("keyboard-interactive"); + } + connectOpts.authHandler = order; + } + console.log(`${logPrefix} Connecting to ${options.hostname}...`); log("connect options prepared", { sessionId, @@ -2632,6 +2836,8 @@ function createStartSessionApi(ctx) { : undefined, }); conn.connect(connectOpts); + // The test transport was already registered before the dial; nothing + // to re-register here. }).catch((err) => { if (pendingDialCoordination && !options._deferPendingDialFailure) { failTransportDial(pendingDialCoordination, err); @@ -2654,6 +2860,10 @@ function createStartSessionApi(ctx) { { sessionId, exitCode: 1, error: userVisibleSshErrorMessage(err, options) }, ); } + if (testMode) { + unregisterTestConnectionTransport(sessionId); + sendConnectionTestResult(sender, sessionId, false, userVisibleSshErrorMessage(err, options)); + } throw err; } } @@ -2672,4 +2882,6 @@ module.exports = { shouldPromoteCachedAuthMethod, applyAgentForwarding, prepareAgentForwardingOptions, + sendConnectionTestResult, + cancelTestConnection, }; diff --git a/electron/bridges/sshBridge/startSession.testConnection.test.cjs b/electron/bridges/sshBridge/startSession.testConnection.test.cjs new file mode 100644 index 000000000..9fd51d239 --- /dev/null +++ b/electron/bridges/sshBridge/startSession.testConnection.test.cjs @@ -0,0 +1,372 @@ +"use strict"; + +const test = require("node:test"); +const assert = require("node:assert/strict"); +const { EventEmitter } = require("node:events"); + +const { createStartSessionApi, sendConnectionTestResult, cancelTestConnection } = require("./startSession.cjs"); + +const MUTED_CONSOLE = { + log() {}, + error() {}, + warn() {}, + info() {}, +}; + +function createHarness({ onConnect, chainDial, ctxOverrides } = {}) { + const sent = []; + const calls = { + connectOptions: null, + ends: 0, + destroys: 0, + defaultKeyScans: 0, + }; + const sender = { + send: (channel, payload) => { + sent.push({ channel, payload }); + }, + isDestroyed: () => false, + }; + + class MockSSHClient extends EventEmitter { + connect(options) { + calls.connectOptions = options; + if (onConnect) onConnect(this); + } + + end() { + calls.ends += 1; + } + + destroy() { + calls.destroys += 1; + } + } + + const sessions = new Map(); + const ctx = { + randomUUID: () => "session-test", + SSHClient: MockSSHClient, + createSshDiagnosticLogger: () => () => {}, + buildConnectionReuseEndpoint: () => "endpoint", + resolveConnectionKeepalivePolicy: () => ({ keepaliveIntervalMs: 0, keepaliveCountMax: 0 }), + buildAlgorithms: () => undefined, + attachSshDebugLogger: () => {}, + logSshAlgorithms: () => {}, + hostKeyVerifier: { createHostVerifier: () => (_rawKey, callback) => callback(true) }, + findAllDefaultPrivateKeys: async () => { + calls.defaultKeyScans += 1; + return []; + }, + isPasswordProvided: () => true, + hasUserConfiguredKey: () => false, + getCachedAuthMethod: () => null, + setCachedAuthMethod: () => {}, + clearCachedAuthMethod: () => {}, + createAuthPhase: () => ({}), + markAuthPhasePartialSuccess: () => {}, + shouldSkipKiPasswordAutoFill: () => false, + createKeyboardInteractiveHandler: () => () => {}, + createOrderedStringAuthHandler: () => () => {}, + getAvailableAgentSocket: async () => null, + getAvailableForwardingAgentSocket: async () => null, + prepareSystemSshAgentForAuth: async () => null, + loadFirstIdentityFileForAuth: async () => null, + preparePrivateKeyForAuth: async () => null, + connectThroughChain: chainDial ?? (async () => { + throw new Error("unexpected chain connect"); + }), + createProxySocket: async () => { + throw new Error("unexpected proxy connect"); + }, + enableSshNoDelay: () => {}, + enableTcpNoDelay: () => {}, + quoteShellArg: (value) => value, + console: MUTED_CONSOLE, + sessions, + safeSend: () => {}, + electronModule: { webContents: { fromId: () => null } }, + sessionLogStreamManager: { stopStream: () => {} }, + closeTerminalOutputSession: () => {}, + sessionEncodings: new Map(), + sessionDecoders: new Map(), + ...(ctxOverrides || {}), + }; + + const api = createStartSessionApi(ctx); + return { api, sent, calls, sender }; +} + +function baseOptions() { + return { + sessionId: "session-test", + hostname: "example.test", + port: 22, + username: "alice", + authMethod: "password", + password: "secret", + testMode: true, + reuseTransport: false, + }; +} + +test("sendConnectionTestResult emits a one-shot success payload", () => { + const sent = []; + const sender = { + isDestroyed: () => false, + send: (channel, payload) => sent.push({ channel, payload }), + }; + + sendConnectionTestResult(sender, "session-1", true); + + assert.deepEqual(sent, [ + { channel: "netcatty:test:result", payload: { sessionId: "session-1", ok: true } }, + ]); +}); + +test("sendConnectionTestResult ignores destroyed senders", () => { + const sent = []; + const sender = { + isDestroyed: () => true, + send: () => sent.push("should-not-fire"), + }; + + sendConnectionTestResult(sender, "session-1", false, "boom"); + + assert.deepEqual(sent, []); +}); + +test("test mode resolves success on authentication without opening a shell", async () => { + const { api, sent, calls, sender } = createHarness({ + onConnect: (conn) => { + queueMicrotask(() => conn.emit("ready")); + }, + }); + + const result = await api.startSSHSession({ sender }, baseOptions()); + + assert.equal(result.sessionId, "session-test"); + assert.equal(result.testResult, "connected"); + + const resultEvent = sent.find((entry) => entry.channel === "netcatty:test:result"); + assert.ok(resultEvent, "netcatty:test:result should be emitted"); + assert.deepEqual(resultEvent.payload, { sessionId: "session-test", ok: true }); + assert.equal(calls.ends, 1, "connection should be torn down after success"); +}); + +test("test mode does not emit a terminal exit on success", async () => { + const { api, sent, sender } = createHarness({ + onConnect: (conn) => { + queueMicrotask(() => conn.emit("ready")); + }, + }); + + await api.startSSHSession({ sender }, baseOptions()); + + assert.equal( + sent.some((entry) => entry.channel === "netcatty:exit"), + false, + "test success must not emit a terminal session exit", + ); +}); + +test("test mode reports failure through netcatty:test:result", async () => { + const { api, sent, sender } = createHarness({ + onConnect: (conn) => { + queueMicrotask(() => conn.emit("error", new Error("boom"))); + }, + }); + + await assert.rejects( + () => api.startSSHSession({ sender }, baseOptions()), + /boom/, + ); + + const resultEvent = sent.find((entry) => entry.channel === "netcatty:test:result"); + assert.ok(resultEvent, "netcatty:test:result should be emitted on failure"); + assert.equal(resultEvent.payload.sessionId, "session-test"); + assert.equal(resultEvent.payload.ok, false); + assert.equal(typeof resultEvent.payload.error, "string"); + assert.ok(resultEvent.payload.error.length > 0); +}); + +test("test mode reports an unexpected close as failure", async () => { + const { api, sent, sender } = createHarness({ + onConnect: (conn) => { + queueMicrotask(() => conn.emit("close")); + }, + }); + + await assert.rejects( + () => api.startSSHSession({ sender }, baseOptions()), + /closed unexpectedly/, + ); + + const resultEvent = sent.find((entry) => entry.channel === "netcatty:test:result"); + assert.ok(resultEvent); + assert.equal(resultEvent.payload.ok, false); +}); + +test("cancelTestConnection ends an in-flight test transport", async () => { + const { api, calls, sender } = createHarness({ + onConnect: () => { + // Leave the dial pending (no ready/error/close). + }, + }); + + void api.startSSHSession({ sender }, baseOptions()); + // Let the async body reach conn.connect() + transport registration. + await new Promise((resolve) => setTimeout(resolve, 0)); + + const result = cancelTestConnection("session-test"); + + assert.equal(result.success, true); + assert.ok(calls.ends >= 1, "cancelTestConnection should end the SSH client"); +}); + +test("cancelTestConnection reports unknown session ids", () => { + assert.deepEqual(cancelTestConnection("nope"), { + success: false, + error: "Test connection not found", + }); +}); + +test("cancelTestConnection ends an in-flight chain hop registered before the dial", async () => { + const hopConns = []; + const { api, sender } = createHarness({ + chainDial: (_event, options) => { + const conn = new EventEmitter(); + conn.ended = false; + conn.destroyed = false; + conn.end = () => { conn.ended = true; }; + conn.destroy = () => { conn.destroyed = true; }; + hopConns.push(conn); + if (options._tunnelRef) { + options._tunnelRef.pendingConn = conn; + options._tunnelRef.chainConnections = options._connectionsRef || []; + (options._connectionsRef || []).push(conn); + } + // Never resolves — simulates a slow/unreachable bastion. + return new Promise(() => {}); + }, + }); + + void api.startSSHSession({ sender }, { + ...baseOptions(), + jumpHosts: [ + { hostname: "jump.example.test", port: 22, username: "root", password: "jump-pass", authMethod: "password" }, + ], + }); + + // Let the async body reach connectThroughChain and register the hop. + await new Promise((resolve) => setTimeout(resolve, 0)); + + const result = cancelTestConnection("session-test"); + + assert.equal(result.success, true); + assert.ok(hopConns.length >= 1, "chain dial should have registered a hop conn"); + assert.ok(hopConns[0].ended || hopConns[0].destroyed, "cancel should end the in-flight hop conn"); +}); + +test("test mode pins authHandler to a minimal method list", async () => { + const { api, calls, sender } = createHarness({ + onConnect: (conn) => { + queueMicrotask(() => conn.emit("ready")); + }, + }); + + await api.startSSHSession({ sender }, baseOptions()); + + assert.deepEqual(calls.connectOptions.authHandler, ["password", "keyboard-interactive"]); +}); + +function keyOnlyHarness() { + return createHarness({ + onConnect: (conn) => { + queueMicrotask(() => conn.emit("ready")); + }, + ctxOverrides: { + isPasswordProvided: (value) => typeof value === "string" && value.length > 0, + preparePrivateKeyForAuth: async ({ privateKey }) => ({ privateKey }), + }, + }); +} + +function keyOnlyOptions(extra = {}) { + const { password: _password, ...rest } = baseOptions(); + return { ...rest, authMethod: "key", privateKey: "PRIVATE KEY", ...extra }; +} + +test("test mode includes keyboard-interactive for key-only MFA hosts", async () => { + const { api, calls, sender } = keyOnlyHarness(); + + await api.startSSHSession({ sender }, keyOnlyOptions({ requiresMfa: true })); + + assert.deepEqual(calls.connectOptions.authHandler, ["publickey", "keyboard-interactive"]); +}); + +test("test mode keeps key-only non-MFA hosts on the single key credential", async () => { + const { api, calls, sender } = keyOnlyHarness(); + + await api.startSSHSession({ sender }, keyOnlyOptions()); + + assert.deepEqual(calls.connectOptions.authHandler, ["publickey"]); +}); + +test("test mode skips the raw password method for password MFA hosts", async () => { + const { api, calls, sender } = createHarness({ + onConnect: (conn) => { + queueMicrotask(() => conn.emit("ready")); + }, + }); + + await api.startSSHSession({ sender }, { ...baseOptions(), requiresMfa: true }); + + assert.deepEqual(calls.connectOptions.authHandler, ["keyboard-interactive"]); +}); + +function jumpOptions(extra = {}) { + return { + ...baseOptions(), + jumpHosts: [ + { hostname: "jump.example.test", port: 22, username: "root", authMethod: "password" }, + ], + ...extra, + }; +} + +function capturingChainDial(captured) { + return async (_event, options) => { + captured.scope = options._keyboardInteractiveScope; + return { socket: new EventEmitter(), connections: [] }; + }; +} + +test("test mode routes jump-host keyboard-interactive prompts through the external scope", async () => { + const captured = {}; + const { api, sender } = createHarness({ + chainDial: capturingChainDial(captured), + onConnect: (conn) => { + queueMicrotask(() => conn.emit("ready")); + }, + }); + + await api.startSSHSession({ sender }, jumpOptions()); + + assert.equal(captured.scope, "external"); +}); + +test("terminal starts keep the default terminal scope for jump-host prompts", async () => { + const captured = {}; + const { api, sender } = createHarness({ + chainDial: capturingChainDial(captured), + onConnect: () => { + // Terminal path would open a shell next; the chain options are all we need. + }, + }); + + void api.startSSHSession({ sender }, jumpOptions({ testMode: false, sessionId: "terminal-session" })); + await new Promise((resolve) => setTimeout(resolve, 0)); + + assert.equal(captured.scope, undefined); +}); diff --git a/electron/bridges/terminalBridge.cjs b/electron/bridges/terminalBridge.cjs index f2651701e..17ed934ad 100644 --- a/electron/bridges/terminalBridge.cjs +++ b/electron/bridges/terminalBridge.cjs @@ -936,7 +936,7 @@ const applyLocaleDefaults = (env) => { * Host-level `environmentVariables` overrides are applied on top, and an * explicit PATH among them always wins — widening it would defeat the override. */ -function buildLocalSessionEnv( +async function buildLocalSessionEnv( payload, { baseEnv = process.env, platform = process.platform, refreshWindowsPath } = {}, ) { @@ -953,7 +953,7 @@ function buildLocalSessionEnv( const pathKey = overridePathKey || getPathEnvKey(env, platform); if (!overridePathKey) { - const livePath = (refreshWindowsPath || resolveWindowsLivePath)({ + const livePath = await (refreshWindowsPath || resolveWindowsLivePath)({ basePath: env[pathKey], env, platform, @@ -972,7 +972,7 @@ function buildLocalSessionEnv( /** * Start a local terminal session */ -function startLocalSession(event, payload) { +async function startLocalSession(event, payload) { const sessionId = payload?.sessionId || randomUUID(); const defaultShell = getDefaultLocalShell(); // payload.shell may be a discovered shell ID (e.g., "wsl-ubuntu") — resolve it @@ -995,12 +995,29 @@ function startLocalSession(event, payload) { requestedCwd, ); const shellKind = detectShellKind(shell); + const { registerPendingBootAbort, clearPendingBootAbort } = require("./sessionBootEpoch.cjs"); + const pendingBootAbort = registerPendingBootAbort(sessionId, payload?.bootEpoch); + let sessionEnv; + try { + sessionEnv = await buildLocalSessionEnv(payload); + } finally { + clearPendingBootAbort(sessionId, pendingBootAbort); + } + if (pendingBootAbort.signal.aborted) { + const supersededError = new Error("Local session closed or superseded before startup"); + supersededError.code = "NETCATTY_BOOT_SUPERSEDED"; + throw supersededError; + } const env = applyLocaleDefaults({ - ...buildLocalSessionEnv(payload), + ...sessionEnv, TERM: "xterm-256color", COLORTERM: "truecolor", }); + if (event.sender.isDestroyed?.()) { + throw new Error("Local terminal window closed before startup"); + } + // Determine the starting directory // Default to home directory if not specified or if specified path is invalid const defaultCwd = os.homedir(); diff --git a/electron/bridges/terminalBridge.localSessionEnv.test.cjs b/electron/bridges/terminalBridge.localSessionEnv.test.cjs index ae7b86563..e89c87f37 100644 --- a/electron/bridges/terminalBridge.localSessionEnv.test.cjs +++ b/electron/bridges/terminalBridge.localSessionEnv.test.cjs @@ -37,8 +37,8 @@ const STALE_PATH = const LIVE_PATH = "C:\\Users\\me\\.herdr\\packages\\standalone\\releases\\0.9.1-x86_64-pc-windows-msvc;C:\\Windows\\System32"; -test("buildLocalSessionEnv keeps the captured PATH untouched off Windows", () => { - const env = bridge.buildLocalSessionEnv( +test("buildLocalSessionEnv keeps the captured PATH untouched off Windows", async () => { + const env = await bridge.buildLocalSessionEnv( { env: { NETCATTY_MARKER: "1" } }, { baseEnv: { PATH: "/usr/bin:/bin", HOME: "/home/me" }, @@ -51,14 +51,14 @@ test("buildLocalSessionEnv keeps the captured PATH untouched off Windows", () => assert.equal(env.NETCATTY_MARKER, "1"); }); -test("buildLocalSessionEnv refreshes PATH from the registry on Windows", () => { +test("buildLocalSessionEnv refreshes PATH from the registry on Windows", async () => { const calls = []; - const env = bridge.buildLocalSessionEnv( + const env = await bridge.buildLocalSessionEnv( {}, { baseEnv: { Path: STALE_PATH, SystemRoot: "C:\\Windows" }, platform: "win32", - refreshWindowsPath: (options) => { + refreshWindowsPath: async (options) => { calls.push(options); return LIVE_PATH; }, @@ -73,8 +73,8 @@ test("buildLocalSessionEnv refreshes PATH from the registry on Windows", () => { assert.equal(env.SystemRoot, "C:\\Windows"); }); -test("buildLocalSessionEnv collapses a duplicate PATH sitting next to Path", () => { - const env = bridge.buildLocalSessionEnv( +test("buildLocalSessionEnv collapses a duplicate PATH sitting next to Path", async () => { + const env = await bridge.buildLocalSessionEnv( {}, { baseEnv: { Path: STALE_PATH, PATH: STALE_PATH }, @@ -91,9 +91,9 @@ test("buildLocalSessionEnv collapses a duplicate PATH sitting next to Path", () ); }); -test("buildLocalSessionEnv never widens an explicit PATH override", () => { +test("buildLocalSessionEnv never widens an explicit PATH override", async () => { let refreshed = false; - const env = bridge.buildLocalSessionEnv( + const env = await bridge.buildLocalSessionEnv( { env: { PATH: "C:\\pinned\\bin" } }, { baseEnv: { Path: STALE_PATH }, @@ -109,3 +109,18 @@ test("buildLocalSessionEnv never widens an explicit PATH override", () => { assert.equal(env.PATH, "C:\\pinned\\bin"); assert.equal(env.Path, undefined); }); + +for (const pathKey of ["Path", "PATH", "path"]) { + test(`an explicit empty ${pathKey} override stays empty without a registry query`, async () => { + const env = await bridge.buildLocalSessionEnv( + { env: { [pathKey]: "" } }, + { + baseEnv: { Path: STALE_PATH, PATH: STALE_PATH }, + platform: "win32", + refreshWindowsPath: () => { throw new Error("must not refresh an explicit override"); }, + }, + ); + assert.equal(env[pathKey], ""); + assert.deepEqual(Object.keys(env).filter((key) => key.toLowerCase() === "path"), [pathKey]); + }); +} diff --git a/electron/bridges/terminalBridge.outputFlood.test.cjs b/electron/bridges/terminalBridge.outputFlood.test.cjs index 953169080..eac57bcb5 100644 --- a/electron/bridges/terminalBridge.outputFlood.test.cjs +++ b/electron/bridges/terminalBridge.outputFlood.test.cjs @@ -160,7 +160,7 @@ test("WSL default directory stays outside the Linux command and option values", } }); -test("WSL local sessions pass the home directory option to the spawned process", () => { +test("WSL local sessions pass the home directory option to the spawned process", async () => { const spawns = []; const bridge = loadBridgeWithFakes(spawns, []); const platformDescriptor = Object.getOwnPropertyDescriptor(process, "platform"); @@ -172,10 +172,10 @@ test("WSL local sessions pass the home directory option to the spawned process", }); const shellArgs = ["-d", "Ubuntu", "--", "zsh", "-l"]; const payload = { shell: "C:\\Windows\\System32\\wsl.exe", shellArgs }; - bridge.startLocalSession({ sender: { id: 7 } }, { ...payload, sessionId: "wsl-home" }); + await bridge.startLocalSession({ sender: { id: 7 } }, { ...payload, sessionId: "wsl-home" }); assert.deepEqual(spawns[0].spawnArgs[1], ["-d", "Ubuntu", "--cd", "~", "--", "zsh", "-l"]); assert.deepEqual(shellArgs, ["-d", "Ubuntu", "--", "zsh", "-l"]); - bridge.startLocalSession( + await bridge.startLocalSession( { sender: { id: 7 } }, { ...payload, sessionId: "wsl-explicit-cwd", cwd: process.cwd() }, ); @@ -186,7 +186,7 @@ test("WSL local sessions pass the home directory option to the spawned process", } }); -test("Windows local terminals enable the bundled ConPTY implementation required for clear", () => { +test("Windows local terminals enable the bundled ConPTY implementation required for clear", async () => { const spawns = []; const sentries = []; const sessions = new Map(); @@ -203,7 +203,7 @@ test("Windows local terminals enable the bundled ConPTY implementation required }, }, }); - bridge.startLocalSession( + await bridge.startLocalSession( { sender: { id: 7 } }, { sessionId: "windows-clear", shell: "C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe" }, ); @@ -215,7 +215,7 @@ test("Windows local terminals enable the bundled ConPTY implementation required } }); -test("local terminal buffers incoming flood while renderer flow is paused", () => { +test("local terminal buffers incoming flood while renderer flow is paused", async () => { const spawns = []; const sentries = []; const sent = []; @@ -233,7 +233,7 @@ test("local terminal buffers incoming flood while renderer flow is paused", () = }, }); - bridge.startLocalSession( + await bridge.startLocalSession( { sender: { id: 7 } }, { sessionId: "local-flood", shell: "/bin/sh", cols: 80, rows: 24 }, ); @@ -258,7 +258,7 @@ test("local terminal buffers incoming flood while renderer flow is paused", () = assert.equal(sentries[0].consumeCalls.length, 2); }); -test("local terminal keeps source paused while paced backlog absorbs fresh flood", () => { +test("local terminal keeps source paused while paced backlog absorbs fresh flood", async () => { const spawns = []; const sentries = []; const sent = []; @@ -276,7 +276,7 @@ test("local terminal keeps source paused while paced backlog absorbs fresh flood }, }); - bridge.startLocalSession( + await bridge.startLocalSession( { sender: { id: 7 } }, { sessionId: "local-flood-paced-fresh", shell: "/bin/sh", cols: 80, rows: 24 }, ); @@ -307,7 +307,7 @@ test("local terminal keeps source paused while paced backlog absorbs fresh flood assert.ok(session.flowState.bufferedBytes >= FLOW_HIGH_WATER_MARK); }); -test("closing a local terminal discards buffered output instead of flushing it", () => { +test("closing a local terminal discards buffered output instead of flushing it", async () => { const spawns = []; const sentries = []; const sent = []; @@ -325,7 +325,7 @@ test("closing a local terminal discards buffered output instead of flushing it", }, }); - bridge.startLocalSession( + await bridge.startLocalSession( { sender: { id: 7 } }, { sessionId: "local-flood-close", shell: "/bin/sh", cols: 80, rows: 24 }, ); @@ -339,7 +339,7 @@ test("closing a local terminal discards buffered output instead of flushing it", }]); }); -test("app cleanup discards buffered output instead of flushing it", () => { +test("app cleanup discards buffered output instead of flushing it", async () => { const spawns = []; const sentries = []; const sent = []; @@ -357,7 +357,7 @@ test("app cleanup discards buffered output instead of flushing it", () => { }, }); - bridge.startLocalSession( + await bridge.startLocalSession( { sender: { id: 7 } }, { sessionId: "local-flood-cleanup", shell: "/bin/sh", cols: 80, rows: 24 }, ); @@ -387,7 +387,7 @@ test("local terminal exit waits for paced buffered output drain", async () => { }, }); - bridge.startLocalSession( + await bridge.startLocalSession( { sender: { id: 7 } }, { sessionId: "local-flood-exit", shell: "/bin/sh", cols: 80, rows: 24 }, ); @@ -414,7 +414,7 @@ test("local terminal exit waits for paced buffered output drain", async () => { assert.equal(sessions.has("local-flood-exit"), false); }); -test("local terminal exit completes while renderer flow is paused", () => { +test("local terminal exit completes while renderer flow is paused", async () => { const spawns = []; const sentries = []; const sent = []; @@ -432,7 +432,7 @@ test("local terminal exit completes while renderer flow is paused", () => { }, }); - bridge.startLocalSession( + await bridge.startLocalSession( { sender: { id: 7 } }, { sessionId: "local-paused-exit", shell: "/bin/sh", cols: 80, rows: 24 }, ); @@ -448,3 +448,181 @@ test("local terminal exit completes while renderer flow is paused", () => { assert.equal(sent.some((item) => item.channel === "netcatty:exit"), true); assert.equal(sessions.has("local-paused-exit"), false); }); + +test("a window closed while preparing the local environment does not spawn a PTY", async () => { + const spawns = []; + const bridge = loadBridgeWithFakes(spawns, []); + let destroyed = false; + const pending = bridge.startLocalSession( + { sender: { id: 7, isDestroyed: () => destroyed } }, + { sessionId: "closed-before-spawn", shell: "/bin/sh" }, + ); + destroyed = true; + await assert.rejects(pending, /window closed before startup/); + assert.equal(spawns.length, 0); +}); + +async function withPendingLocalPath(run) { + const shellUtils = require("./ai/shellUtils.cjs"); + const originalResolve = shellUtils.resolveWindowsLivePath; + const platformDescriptor = Object.getOwnPropertyDescriptor(process, "platform"); + const resolvers = []; + const spawns = []; + const sessions = new Map(); + const bridge = loadBridgeWithFakes(spawns, []); + bridge.init({ sessions, electronModule: { webContents: { fromId: () => null } } }); + try { + Object.defineProperty(process, "platform", { configurable: true, value: "win32" }); + shellUtils.resolveWindowsLivePath = () => new Promise((resolve) => resolvers.push(resolve)); + await run({ bridge, resolvers, spawns, sessions }); + } finally { + shellUtils.resolveWindowsLivePath = originalResolve; + Object.defineProperty(process, "platform", platformDescriptor); + } +} + +test("closing a local tab during PATH refresh prevents the pending PTY spawn", async () => { + await withPendingLocalPath(async ({ bridge, resolvers, spawns, sessions }) => { + const event = { sender: { id: 7, isDestroyed: () => false } }; + const payload = { sessionId: "closed-pending-path", bootEpoch: 1, shell: "C:\\Windows\\cmd.exe" }; + const pending = bridge.startLocalSession(event, payload); + const rejected = assert.rejects(pending, { code: "NETCATTY_BOOT_SUPERSEDED" }); + assert.equal(resolvers.length, 1); + bridge.closeSession(event, payload); + resolvers[0]("C:\\Windows\\System32"); + await rejected; + assert.equal(spawns.length, 0); + assert.equal(sessions.has(payload.sessionId), false); + }); +}); + +test("a stale close and old PATH completion cannot cancel a newer local boot", async () => { + await withPendingLocalPath(async ({ bridge, resolvers, spawns, sessions }) => { + const event = { sender: { id: 7, isDestroyed: () => false } }; + const payload = { sessionId: "restarted-pending-path", shell: "C:\\Windows\\cmd.exe" }; + const older = bridge.startLocalSession(event, { ...payload, bootEpoch: 1 }); + const rejected = assert.rejects(older, { code: "NETCATTY_BOOT_SUPERSEDED" }); + const newer = bridge.startLocalSession(event, { ...payload, bootEpoch: 2 }); + assert.equal(resolvers.length, 2); + assert.deepEqual(bridge.closeSession(event, { ...payload, bootEpoch: 1 }), { + skipped: true, reason: "boot-epoch-mismatch", + }); + resolvers[0]("C:\\old"); + await rejected; + const { hasPendingBootAfter } = require("./sessionBootEpoch.cjs"); + assert.equal(hasPendingBootAfter(payload.sessionId, 1), true); + resolvers[1]("C:\\new"); + await newer; + assert.equal(spawns.length, 1); + assert.equal(sessions.get(payload.sessionId)?.bootEpoch, 2); + bridge.closeSession(event, { ...payload, bootEpoch: 1 }); + assert.equal(sessions.get(payload.sessionId)?.bootEpoch, 2); + bridge.closeSession(event, { ...payload, bootEpoch: 2 }); + }); +}); + +for (const closeChannel of ["netcatty:close", "netcatty:close:await"]) { + test(`worker ${closeChannel} cancels a local start before PATH refresh can spawn it`, async () => { + await withPendingLocalPath(async ({ bridge, resolvers, spawns, sessions }) => { + const { createTerminalWorkerRuntime } = require("../terminalWorker/runtime.cjs"); + const messages = []; + let receiveMessage; + const parentPort = { + on: (_channel, listener) => { receiveMessage = listener; }, + postMessage: (message) => messages.push(message), + }; + createTerminalWorkerRuntime({ + parentPort, + registerBridges: (ipcMain) => bridge.registerHandlers(ipcMain), + }).start(); + const payload = { sessionId: `worker-pending-${closeChannel}`, shell: "C:\\Windows\\cmd.exe" }; + const start = (bootEpoch) => receiveMessage({ + kind: "request", requestId: `start-${bootEpoch}`, channel: "netcatty:local:start", + webContentsId: 7, payload: { ...payload, bootEpoch }, + }); + const close = (bootEpoch) => receiveMessage({ + kind: closeChannel.endsWith(":await") ? "request" : "send", + requestId: `close-${bootEpoch}`, channel: closeChannel, + webContentsId: 7, payload: { ...payload, bootEpoch }, + }); + const drainMessages = () => new Promise((resolve) => setImmediate(resolve)); + start(1); + await drainMessages(); + assert.equal(resolvers.length, 1, "the real bridge is waiting for PATH inside the worker queue"); + close(1); + resolvers[0]("C:\\Windows\\System32"); + await drainMessages(); + await drainMessages(); + assert.equal(spawns.length, 0, "a closed tab must not run shell startup scripts"); + assert.equal(sessions.has(payload.sessionId), false); + assert.match(messages.find((message) => message.requestId === "start-1")?.error ?? "", /closed or superseded/); + if (closeChannel.endsWith(":await")) { + assert.equal(messages.find((message) => message.requestId === "close-1")?.result?.closed, false); + } + + start(2); + await drainMessages(); + assert.equal(resolvers.length, 2); + close(1); // A delayed old close must not abort the replacement's PATH wait. + resolvers[1]("C:\\Windows\\System32"); + await drainMessages(); + await drainMessages(); + assert.equal(spawns.length, 1); + assert.equal(sessions.get(payload.sessionId)?.bootEpoch, 2); + close(2); + await drainMessages(); + await drainMessages(); + assert.equal(sessions.has(payload.sessionId), false); + }); + }); +} + +test("worker replacements abort pending and skip superseded queued local starts", async () => { + await withPendingLocalPath(async ({ bridge, resolvers, spawns, sessions }) => { + const { createTerminalWorkerRuntime } = require("../terminalWorker/runtime.cjs"); + const messages = []; + let receiveMessage; + createTerminalWorkerRuntime({ + parentPort: { + on: (_channel, listener) => { receiveMessage = listener; }, + postMessage: (message) => messages.push(message), + }, + registerBridges: (ipcMain) => bridge.registerHandlers(ipcMain), + }).start(); + const payload = { sessionId: "worker-replaced-path", shell: "C:\\Windows\\cmd.exe" }; + const start = (bootEpoch, requestId) => receiveMessage({ + kind: "request", requestId, channel: "netcatty:local:start", + webContentsId: 7, payload: { ...payload, bootEpoch }, + }); + const drainMessages = () => new Promise((resolve) => setImmediate(resolve)); + start(1, "old-start"); + await drainMessages(); + start(2, "queued-replacement"); + start(3, "replacement"); + resolvers[0]("C:\\Windows\\System32"); + await drainMessages(); + await drainMessages(); + assert.equal(spawns.length, 0, "a superseded PATH wait must not run shell startup scripts"); + assert.equal(resolvers.length, 2, "the replacement now owns the startup queue"); + assert.match(messages.find((message) => message.requestId === "old-start")?.error ?? "", /closed or superseded/); + assert.match(messages.find((message) => message.requestId === "queued-replacement")?.error ?? "", /superseded/); + start(2, "stale-start"); + receiveMessage({ + kind: "send", channel: "netcatty:close", webContentsId: 7, + payload: { ...payload, bootEpoch: 2 }, + }); + resolvers[1]("C:\\Windows\\System32"); + await drainMessages(); + await drainMessages(); + assert.equal(spawns.length, 1); + assert.equal(resolvers.length, 2, "a stale start must not even prepare a new environment"); + assert.equal(sessions.get(payload.sessionId)?.bootEpoch, 3); + assert.match(messages.find((message) => message.requestId === "stale-start")?.error ?? "", /superseded/); + receiveMessage({ + kind: "send", channel: "netcatty:close", webContentsId: 7, + payload: { ...payload, bootEpoch: 3 }, + }); + await drainMessages(); + await drainMessages(); + }); +}); diff --git a/electron/bridges/terminalBridge/etSession.cjs b/electron/bridges/terminalBridge/etSession.cjs index a992f4ef2..619f0b205 100644 --- a/electron/bridges/terminalBridge/etSession.cjs +++ b/electron/bridges/terminalBridge/etSession.cjs @@ -832,13 +832,19 @@ main(); try { // Resolve the real OpenSSH binary to an absolute path BEFORE we // prepend the wrapper directory to PATH. Embedding a bare `ssh` - // name would recurse into the wrapper forever. - let realSsh = process.platform === "win32" - ? (findExecutable("ssh") || "") - : ""; + // name would recurse into the wrapper forever. findExecutable + // returns that bare name off Windows, so only an absolute result + // is usable; otherwise fall back to command -v and well-known paths. + let realSsh = ""; + try { + const found = typeof findExecutable === "function" ? findExecutable("ssh") : ""; + if (found && path.isAbsolute(String(found))) realSsh = String(found); + } catch { + // Fall through to command -v / well-known paths. + } if (!realSsh || !path.isAbsolute(realSsh)) { try { - const resolved = execFileSync( + const lookup = execFileSync( process.platform === "win32" ? "where" : "sh", process.platform === "win32" ? ["ssh"] : ["-c", "command -v ssh"], { @@ -847,8 +853,9 @@ main(); windowsHide: true, env: process.env, }, - ).trim().split(/\r?\n/)[0]; - if (resolved) realSsh = resolved; + ); + const resolved = String(lookup ?? "").trim().split(/\r?\n/).find(Boolean) || ""; + if (resolved && path.isAbsolute(resolved)) realSsh = resolved; } catch { // Fall through to common absolute locations. } diff --git a/electron/bridges/terminalBridge/etSession.test.cjs b/electron/bridges/terminalBridge/etSession.test.cjs index 791bcdfcf..319d3c7cc 100644 --- a/electron/bridges/terminalBridge/etSession.test.cjs +++ b/electron/bridges/terminalBridge/etSession.test.cjs @@ -26,6 +26,13 @@ function makeApi(t, overrides = {}) { getTempFilePath: (name) => path.join(base, name), getTempDir: () => base, }; + // Absolute stand-in for OpenSSH. prepareEtSshEnvironment must be able to + // inject the PATH wrapper without the host image having /usr/bin/ssh, and + // without the execFileSync stub (used to skip Windows icacls) answering + // `command -v ssh`. + const fakeSsh = path.join(base, "openssh", process.platform === "win32" ? "ssh.exe" : "ssh"); + fs.mkdirSync(path.dirname(fakeSsh), { recursive: true }); + fs.writeFileSync(fakeSsh, "#!/bin/sh\nexit 0\n"); const osMock = { homedir: () => fakeHome, userInfo: () => ({ username: "tester" }), @@ -53,7 +60,7 @@ function makeApi(t, overrides = {}) { trackSessionIdlePrompt: () => {}, createPtyOutputBuffer: overrides.createPtyOutputBuffer || (() => ({ bufferData() {}, flush() {}, flushPaced() {} })), - findExecutable: () => "ssh", + findExecutable: (name) => (name === "ssh" ? fakeSsh : name), bundledEtClient, }); return { api, base, sessions }; diff --git a/electron/bridges/transferBridge.cjs b/electron/bridges/transferBridge.cjs index 02bd34c6c..d34964a59 100644 --- a/electron/bridges/transferBridge.cjs +++ b/electron/bridges/transferBridge.cjs @@ -1040,7 +1040,7 @@ function stableLocalFileIdentity(statLike) { } async function promoteLocalTransfer(stagedPath, targetPath, options = {}) { - const { publishLocalFileExclusive } = require("./localFilePublish.cjs"); + const { publishLocalFileExclusive, isMetadataUnsupportedError } = require("./localFilePublish.cjs"); const assertNotCancelled = options.assertNotCancelled || (() => {}); const token = crypto.randomUUID().replace(/-/g, ""); const base = path.join(path.dirname(targetPath), `.${path.basename(targetPath)}.netcatty-${token}`); @@ -1050,6 +1050,7 @@ async function promoteLocalTransfer(stagedPath, targetPath, options = {}) { let backedUp = false; let committed = false; let keepRecoveryFiles = false; + let skipReadyCleanup = false; let preparedHandle; let originalHandle; let restoreProbeCreated = false; @@ -1060,7 +1061,28 @@ async function promoteLocalTransfer(stagedPath, targetPath, options = {}) { await fs.promises.rename(stagedPath, readyPath); } catch (error) { if (error?.code !== "EXDEV") throw error; - await fs.promises.copyFile(stagedPath, readyPath, fs.constants.COPYFILE_EXCL); + try { + await fs.promises.copyFile(stagedPath, readyPath, fs.constants.COPYFILE_EXCL); + } catch (copyError) { + if (!["ENOTSUP", "EOPNOTSUPP"].includes(copyError?.code)) throw copyError; + // copyFile includes metadata operations that writable GVFS mounts may + // reject. Reuse the exclusive bounded read/write publication path. + try { + await publishLocalFileExclusive(stagedPath, readyPath, assertNotCancelled, undefined, { + copyOnly: true, preserveMode: false, requireTimestamps: false, + }); + } catch (fallbackError) { + // Do not remove a name this fallback never owned: copyFile may + // have left a partial, or an exclusive open may have lost a race. + skipReadyCleanup = true; + const cancelledBeforeOutput = !fallbackError.localPublicationIncomplete + && (fallbackError.code === "ABORT_ERR" || fallbackError.message === "Transfer cancelled"); + // An early cancellation needs ordinary stage cleanup, not recovery. + // Once output exists, retain the complete stage as before. + keepRecoveryFiles = !cancelledBeforeOutput; + throw fallbackError; + } + } } // Stamp the private prepared file before applying possibly unreadable // destination permissions. Publication carries these times to the target. @@ -1085,7 +1107,17 @@ async function promoteLocalTransfer(stagedPath, targetPath, options = {}) { ? validatedTarget.existingMode & 0o7777 : Number.isInteger(options.existingMode) ? options.existingMode & 0o7777 : null; if (mode !== null && mode !== appliedMode) { - await fs.promises.chmod(readyPath, mode); + if (((await preparedHandle.stat()).mode & 0o7777) !== mode) { + try { + await preparedHandle.chmod(mode); + } catch (error) { + if (!isMetadataUnsupportedError(error) + || ((await preparedHandle.stat()).mode & 0o7777) !== mode) throw error; + } + if (((await preparedHandle.stat()).mode & 0o7777) !== mode) { + throw new Error("Cannot preserve local destination permissions"); + } + } appliedMode = mode; continue; } @@ -1131,7 +1163,9 @@ async function promoteLocalTransfer(stagedPath, targetPath, options = {}) { assertNotCancelled(); let publishedIdentity = null; try { - publishedIdentity = await publishLocalFileExclusive(readyPath, targetPath, assertNotCancelled, preparedHandle) ?? null; + publishedIdentity = await publishLocalFileExclusive(readyPath, targetPath, assertNotCancelled, preparedHandle, { + preserveMode: appliedMode !== null, requireTimestamps: false, + }) ?? null; } catch (error) { if (error?.code === "EEXIST") { throw new Error("Local download target changed during replacement", { cause: error }); @@ -1144,12 +1178,13 @@ async function promoteLocalTransfer(stagedPath, targetPath, options = {}) { committed = true; // Hand the published inode identity to the caller for descriptor-based // metadata stamping after publication. - options.onCommit?.(publishedIdentity, localMtimePrepared); + options.onCommit?.(publishedIdentity, localMtimePrepared && publishedIdentity?.timestampsPreserved !== false); if (backedUp) await fs.promises.unlink(backupPath).catch(() => {}); await fs.promises.unlink(readyPath).catch(() => {}); await fs.promises.unlink(stagedPath).catch(() => {}); } catch (error) { if (committed) throw error; + if (error?.localPublicationIncomplete) keepRecoveryFiles = true; if (backedUp && !keepRecoveryFiles) { try { // The pathname may have changed between open and rename. Never copy @@ -1179,7 +1214,7 @@ async function promoteLocalTransfer(stagedPath, targetPath, options = {}) { if (backedUp) failure.remoteBackupPath = backupPath; throw failure; } - await fs.promises.unlink(readyPath).catch(() => {}); + if (!skipReadyCleanup) await fs.promises.unlink(readyPath).catch(() => {}); throw error; } finally { await preparedHandle?.close().catch(() => {}); diff --git a/electron/bridges/transferBridge.gvfsPublish.test.cjs b/electron/bridges/transferBridge.gvfsPublish.test.cjs new file mode 100644 index 000000000..0a99045ce --- /dev/null +++ b/electron/bridges/transferBridge.gvfsPublish.test.cjs @@ -0,0 +1,189 @@ +"use strict"; + +const test = require("node:test"); +const assert = require("node:assert/strict"); +const fs = require("node:fs"); +const path = require("node:path"); +const bridge = require("./transferBridge.cjs"); +const temp = require("./tempDirBridge.cjs"); + +const unsupported = (code = "ENOTSUP") => Object.assign(new Error(`Injected ${code}`), { code }); + +// Real files and handles; only unavailable mount operations and virtual mode +// reporting are simulated. This is not a real Linux/GVFS acceptance test. +async function mountedDestination(t, options = {}) { + const root = fs.mkdtempSync(`${temp.getTempFilePath("gvfs-publish")}-`); + t.after(() => fs.rmSync(root, { recursive: true, force: true })); + const stage = path.join(root, "stage.part"); + const target = path.join(root, "target"); + const payload = Buffer.alloc(2 * 1024 * 1024 + 17, 173); + fs.writeFileSync(stage, payload, { mode: 0o644 }); + if (options.existingMode !== undefined) { + fs.writeFileSync(target, "original"); + fs.chmodSync(target, options.existingMode); + } + const calls = []; + let cancelled = false; + for (const name of ["rename", "copyFile", "link", "open"]) { + const original = fs.promises[name]; + t.after(() => { fs.promises[name] = original; }); + fs.promises[name] = async (...args) => { + if (name === "rename" && args[0] === stage) throw unsupported("EXDEV"); + if (name === "copyFile") { + calls.push("copyFile"); + if (options.readyCollision) fs.writeFileSync(args[1], "other writer"); + if (options.cancelBeforeFallback) cancelled = true; + if (options.copyError !== false) throw unsupported(options.copyError || "ENOTSUP"); + } + if (name === "link") throw unsupported(); + const result = await original.apply(fs.promises, args); + if (name !== "open" || args[0] === stage) return result; + const created = args[1] === "wx"; + const virtualMode = args[0] === target ? options.finalVirtualMode ?? options.virtualMode : options.virtualMode; + if (created && virtualMode !== undefined) fs.chmodSync(args[0], virtualMode); + result.chmod = async () => { calls.push("chmod"); throw unsupported(options.metadataError); }; + result.utimes = async () => { calls.push("utimes"); throw unsupported(options.metadataError); }; + if (created) { + const final = args[0] === target; + calls.push(final ? "final-open" : "ready-open"); + const write = result.write.bind(result); + result.write = async (buffer, offset, length, position) => { + if (options.writeError && final) throw unsupported(options.writeError); + const written = await write(buffer, offset, Math.min(length, 32768), position); + if (options.cancel && !final) cancelled = true; + return written; + }; + const close = result.close.bind(result); + result.close = async () => { + await close(); + if (options.closeError === (final ? "final" : "ready")) throw unsupported("EIO"); + }; + } + return result; + }; + } + const run = () => bridge._promoteLocalTransferForTests(stage, target, { + sourceSoftIdentity: { mtimeMs: 1700000000000 }, + validateTarget: async () => ({ + targetIdentity: options.existingMode === undefined ? "missing" : undefined, + existingMode: options.existingMode, + }), + assertNotCancelled: () => { + if (cancelled || (options.cancelBeforeFinal && !fs.existsSync(target))) throw unsupported("ABORT_ERR"); + }, + onCommit: (_identity, timestampsPreserved) => { calls.push(["commit", timestampsPreserved]); }, + }); + return { root, stage, target, payload, calls, run }; +} + +for (const [virtualMode, copyError, metadataError] of [[0o644, "ENOTSUP"], [0o700, "EOPNOTSUPP"], [0o700, "ENOTSUP", "EPERM"], [0o700, "ENOTSUP", "EACCES"]]) { + test(`new mounted destination accepts creation policy ${virtualMode.toString(8)} with ${metadataError || "ENOTSUP"} through both copy stages`, async (t) => { + const setup = await mountedDestination(t, { virtualMode, copyError, metadataError }); + await setup.run(); + assert.deepEqual(fs.readFileSync(setup.target), setup.payload); + assert.deepEqual(fs.readdirSync(setup.root), ["target"]); + assert.ok(setup.calls.includes("ready-open")); + assert.ok(setup.calls.includes("final-open")); + assert.deepEqual(setup.calls.at(-1), ["commit", false]); + }); +} + +for (const metadataError of ["ENOTSUP", "EPERM", "EACCES"]) { + test(`existing mounted file keeps matching virtual permissions: ${metadataError}`, async (t) => { + const setup = await mountedDestination(t, { virtualMode: 0o700, existingMode: 0o700, metadataError }); + await setup.run(); + assert.deepEqual(fs.readFileSync(setup.target), setup.payload); + assert.equal(fs.statSync(setup.target).mode & 0o7777, 0o700); + assert.deepEqual(fs.readdirSync(setup.root), ["target"]); + }); + + test(`refused chmod cannot broaden an existing destination: ${metadataError}`, async (t) => { + const setup = await mountedDestination(t, { virtualMode: 0o700, existingMode: 0o600, metadataError }); + await assert.rejects(setup.run, { code: metadataError }); + assert.equal(fs.readFileSync(setup.target, "utf8"), "original"); + assert.equal(fs.statSync(setup.target).mode & 0o7777, 0o600); + assert.deepEqual(fs.readFileSync(setup.stage), setup.payload); + }); +} + +for (const copyError of ["EACCES", "EPERM", "ENOSPC", "EIO", "EEXIST", "EINVAL"]) { + test(`copyFile ${copyError} remains a failure`, async (t) => { + const setup = await mountedDestination(t, { copyError }); + await assert.rejects(setup.run, { code: copyError }); + assert.equal(fs.existsSync(setup.target), false); + assert.deepEqual(fs.readFileSync(setup.stage), setup.payload); + assert.equal(setup.calls.includes("ready-open"), false); + }); +} + +for (const failure of [{ cancel: true }, { closeError: "ready" }, { closeError: "final" }, { writeError: "ENOSPC" }, { writeError: "EPERM" }, { writeError: "EACCES" }]) { + test(`copy failure preserves original and complete stage: ${JSON.stringify(failure)}`, async (t) => { + const setup = await mountedDestination(t, { existingMode: 0o644, virtualMode: 0o644, ...failure }); + await assert.rejects(setup.run, /Recovery files preserved/); + assert.deepEqual(fs.readFileSync(setup.stage), setup.payload); + const backup = fs.readdirSync(setup.root).find(name => name.endsWith(".backup")); + assert.equal(fs.readFileSync(backup ? path.join(setup.root, backup) : setup.target, "utf8"), "original"); + assert.equal(setup.calls.some(call => Array.isArray(call) && call[0] === "commit"), false); + }); +} + + +test("failed copy followed by exclusive-open collision leaves that entry intact", async (t) => { + const setup = await mountedDestination(t, { readyCollision: true }); + await assert.rejects(setup.run, /Recovery files preserved/); + const ready = fs.readdirSync(setup.root).find(name => name.endsWith(".ready")); + assert.equal(fs.readFileSync(path.join(setup.root, ready), "utf8"), "other writer"); + assert.deepEqual(fs.readFileSync(setup.stage), setup.payload); + assert.equal(fs.existsSync(setup.target), false); +}); + +for (const metadataError of ["ENOTSUP", "EPERM", "EACCES"]) { + test(`rollback retains backup when its original timestamps cannot be restored: ${metadataError}`, async (t) => { + const setup = await mountedDestination(t, { existingMode: 0o644, cancelBeforeFinal: true, metadataError }); + await assert.rejects(setup.run, /Recovery files preserved/); + const backup = fs.readdirSync(setup.root).find(name => name.endsWith(".backup")); + assert.ok(backup); + assert.equal(fs.readFileSync(path.join(setup.root, backup), "utf8"), "original"); + assert.deepEqual(fs.readFileSync(setup.stage), setup.payload); + }); +} + +for (const metadataError of ["EPERM", "EACCES"]) { + test(`final copy cannot broaden an existing destination after chmod ${metadataError}`, async (t) => { + const setup = await mountedDestination(t, { + existingMode: 0o644, virtualMode: 0o644, finalVirtualMode: 0o700, metadataError, + }); + await assert.rejects(setup.run, /Recovery files preserved/); + const backup = fs.readdirSync(setup.root).find(name => name.endsWith(".backup")); + assert.ok(backup); + assert.equal(fs.readFileSync(path.join(setup.root, backup), "utf8"), "original"); + assert.deepEqual(fs.readFileSync(setup.stage), setup.payload); + assert.equal(setup.calls.some(call => Array.isArray(call) && call[0] === "commit"), false); + }); +} + +for (const readyCollision of [false, true]) { + test(`cancellation before fallback output remains cancellation, existing ready=${readyCollision}`, async (t) => { + const setup = await mountedDestination(t, { cancelBeforeFallback: true, readyCollision }); + const originalLstat = fs.promises.lstat; + const originalUnlink = fs.promises.unlink; + t.after(() => { fs.promises.lstat = originalLstat; fs.promises.unlink = originalUnlink; }); + for (const [name, original] of [["lstat", originalLstat], ["unlink", originalUnlink]]) { + fs.promises[name] = async (...args) => { + assert.equal(String(args[0]).endsWith(".ready"), false, "no destination probe/cleanup after early cancellation"); + return original.apply(fs.promises, args); + }; + } + await assert.rejects(setup.run, (error) => { + assert.equal(error.code, "ABORT_ERR"); + assert.equal(Boolean(error.recoveryFailed), false); + return true; + }); + assert.equal(setup.calls.includes("ready-open"), false); + assert.equal(fs.existsSync(setup.target), false); + if (readyCollision) { + const ready = fs.readdirSync(setup.root).find(name => name.endsWith(".ready")); + assert.equal(fs.readFileSync(path.join(setup.root, ready), "utf8"), "other writer"); + } else assert.deepEqual(fs.readdirSync(setup.root), ["stage.part"]); + }); +} diff --git a/electron/bridges/windowManager.cjs b/electron/bridges/windowManager.cjs index d681b9b4b..36cba59da 100644 --- a/electron/bridges/windowManager.cjs +++ b/electron/bridges/windowManager.cjs @@ -76,6 +76,9 @@ const DEFAULT_WINDOW_WIDTH = 1400; const DEFAULT_WINDOW_HEIGHT = 900; // Minimum window size: enough to render the expanded sidebar + a usable // host list + the 420px host details / new-host aside panel without overflow. +// Small displays shrink this fit to their work area at window-creation time +// (see resolveWindowMinimumSize in windowManager/mainWindow.cjs, #3647); +// the minimum tracks the display the window currently occupies. const MIN_WINDOW_WIDTH = 1100; const MIN_WINDOW_HEIGHT = 640; @@ -946,6 +949,8 @@ const mainWindowApi = createMainWindowApi({ createAppWindowOpenHandler, attachOAuthLoadingOverlay, queryDirtyEditors: (...args) => require("./dirtyEditorGuard.cjs").queryDirtyEditors(...args), + hasEditorTabsForSource: (webContents) => editorWindowApi.hasEditorTabsForSource(webContents), + closeEditorTabsForSource: (...args) => editorWindowApi.closeEditorTabsForSource(...args), registerWindowHandlers, requestWindowCommandClose, shouldCloseWindowFromInput, @@ -1039,6 +1044,36 @@ const { getTerminalPopupWindows, } = terminalPopupWindowApi; +const { createEditorWindowApi } = require("./windowManager/editorWindow.cjs"); +const editorWindowApi = createEditorWindowApi({ + get mainWindow() { return mainWindow; }, + get currentTheme() { return currentTheme; }, + get isQuitting() { return isQuitting; }, + V8_CACHE_OPTIONS, + __dirname, + resolveFrontendBackgroundColor, + createExternalOnlyWindowOpenHandler, + getDevRendererBaseUrl, + applyWindowOpacityToWindow, + showAndFocusWindow, + resolveSettingsWindowBounds, + registerAppContentWindow, + unregisterAppContentWindow, + notifyAppContentWindowClosed, + queryDirtyEditors: (...args) => require("./dirtyEditorGuard.cjs").queryDirtyEditors(...args), +}); +const { + openEditorWindow, + focusEditorTab, + closeEditorTabs: closeEditorWindowTabs, + saveEditorTab: saveEditorWindowTab, + dockEditorTab, + reportEditorDirty, + reportEditorTabsClosed, + remapEditorSession, + getEditorWindow, +} = editorWindowApi; + /** * Register window control IPC handlers (only once) */ @@ -1491,6 +1526,15 @@ module.exports = { openTerminalPopupWindow, closeTerminalPopupWindow, getTerminalPopupWindows, + openEditorWindow, + focusEditorTab, + closeEditorWindowTabs, + saveEditorWindowTab, + dockEditorTab, + reportEditorDirty, + reportEditorTabsClosed, + remapEditorSession, + getEditorWindow, prewarmSettingsWindow, buildAppMenu, getCurrentLanguage, diff --git a/electron/bridges/windowManager/editorWindow.cjs b/electron/bridges/windowManager/editorWindow.cjs new file mode 100644 index 000000000..38498c7a8 --- /dev/null +++ b/electron/bridges/windowManager/editorWindow.cjs @@ -0,0 +1,542 @@ +/* eslint-disable no-undef */ + +const { randomUUID } = require("node:crypto"); + +const { + windowsFramelessContentChromeOptions, +} = require("./windowsWindowChrome.cjs"); + +const EDITOR_WIDTH = 1100; +const EDITOR_HEIGHT = 760; +const CLOSE_TABS_PROMPT_TIMEOUT_MS = 120000; +const CLOSE_TABS_FORCE_TIMEOUT_MS = 8000; +const SAVE_TIMEOUT_MS = 30000; +const DOCK_TIMEOUT_MS = 8000; +const OPEN_TAB_TIMEOUT_MS = 30000; +const MAX_EDITOR_CONTENT_BYTES = 10 * 1024 * 1024; + +function isLiveWindow(win) { + return Boolean(win && typeof win.isDestroyed === "function" && !win.isDestroyed()); +} + +function sanitizeEditorSnapshot(payload) { + if (!payload || typeof payload !== "object") return null; + const editorId = typeof payload.editorId === "string" ? payload.editorId.trim() : ""; + const sessionId = typeof payload.sessionId === "string" ? payload.sessionId : ""; + const sftpTabId = typeof payload.sftpTabId === "string" ? payload.sftpTabId : ""; + const hostId = typeof payload.hostId === "string" ? payload.hostId : ""; + const remotePath = typeof payload.remotePath === "string" ? payload.remotePath : ""; + const fileName = typeof payload.fileName === "string" ? payload.fileName.trim() : ""; + if (!editorId || !sessionId || !sftpTabId || !hostId || !remotePath || !fileName) { + return null; + } + const content = typeof payload.content === "string" ? payload.content : ""; + const baselineContent = typeof payload.baselineContent === "string" ? payload.baselineContent : ""; + try { + if (Buffer.byteLength(content, "utf8") > MAX_EDITOR_CONTENT_BYTES) return null; + if (Buffer.byteLength(baselineContent, "utf8") > MAX_EDITOR_CONTENT_BYTES) return null; + } catch { + return null; + } + return { + editorId, + sessionId, + sftpTabId, + hostId, + hostLabel: typeof payload.hostLabel === "string" ? payload.hostLabel : undefined, + remotePath, + fileName, + languageId: typeof payload.languageId === "string" && payload.languageId + ? payload.languageId + : "plaintext", + content, + baselineContent, + wordWrap: payload.wordWrap === true, + viewState: payload.viewState && typeof payload.viewState === "object" ? payload.viewState : null, + }; +} + +function createEditorWindowApi(ctx) { + let editorWindow = null; + let editorWindowLoaded = null; + let nativeClosePendingWindow = null; + const tabSources = new Map(); + + function getEditorWindow() { + if (!isLiveWindow(editorWindow)) { + editorWindow = null; + return null; + } + return editorWindow; + } + + function hasEditorTabsForSource(webContents) { + return Array.from(tabSources.values()).some((owner) => owner.webContentsId === webContents?.id); + } + + async function closeEditorTabsForSource(electronModule, webContents) { + const editorIds = Array.from(tabSources).filter(([, owner]) => owner.webContentsId === webContents?.id).map(([id]) => id); + return closeEditorTabs(electronModule, { editorIds }); + } + + function resolveSourceWebContents(electronModule, editorId) { + const id = tabSources.get(editorId)?.webContentsId; + if (!Number.isFinite(id)) return null; + try { + const wc = electronModule.webContents.fromId(id); + if (!wc || wc.isDestroyed?.()) return null; + return wc; + } catch { + return null; + } + } + + function invokeWebContents(electronModule, webContents, sendChannel, replyChannel, payload, timeoutMs) { + const ipcMain = electronModule.ipcMain; + if (!ipcMain || !webContents || webContents.isDestroyed?.()) { + return Promise.resolve({ success: false, error: "Target window is gone" }); + } + const requestId = payload.requestId || randomUUID(); + const message = { ...payload, requestId }; + return new Promise((resolve) => { + let settled = false; + let timeoutId = null; + const settle = (result) => { + if (settled) return; + settled = true; + if (timeoutId !== null) clearTimeout(timeoutId); + ipcMain.removeListener(replyChannel, onResult); + webContents.removeListener("destroyed", onDestroyed); + resolve(result); + }; + function onResult(evt, result) { + if (evt?.sender !== webContents) return; + if (!result || result.requestId !== requestId) return; + settle({ success: true, ...result }); + } + function onDestroyed() { + settle({ success: false, error: "Target window is gone" }); + } + ipcMain.on(replyChannel, onResult); + webContents.once("destroyed", onDestroyed); + timeoutId = setTimeout(() => { + settle({ success: false, error: "Timed out waiting for editor window" }); + }, timeoutMs); + try { + webContents.send(sendChannel, message); + } catch (err) { + settle({ success: false, error: err?.message || "Failed to send to renderer" }); + } + }); + } + + function notifySourcesTabsClosed(electronModule, editorIds) { + if (!Array.isArray(editorIds) || editorIds.length === 0) return; + const bySource = new Map(); + for (const editorId of editorIds) { + if (typeof editorId !== "string" || !editorId) continue; + const owner = tabSources.get(editorId); + const wc = resolveSourceWebContents(electronModule, editorId); + tabSources.delete(editorId); + if (!owner?.accepted || !wc) continue; + const list = bySource.get(wc) || []; + list.push(editorId); + bySource.set(wc, list); + } + for (const [wc, ids] of bySource) { + try { + wc.send("netcatty:window:editorTabsClosed", { editorIds: ids }); + } catch { + // ignore + } + } + } + + function sendOpenTab(electronModule, win, snapshot, source, reused) { + if (!source || source.isDestroyed?.()) return Promise.resolve({ success: false, error: "Source window is gone" }); + const owner = { webContentsId: source.id, accepted: false, opening: null }; + tabSources.set(snapshot.editorId, owner); + owner.opening = (async () => { + try { + // All opens, including reuse during cold start, share the same load. + await editorWindowLoaded; + if (!isLiveWindow(win)) return { success: false, error: "Editor window is gone" }; + const result = await invokeWebContents( + electronModule, + win.webContents, + "netcatty:window:editorOpenTab", + "netcatty:window:editorOpenTabResult", + snapshot, + OPEN_TAB_TIMEOUT_MS, + ); + if (!result.success || result.ok !== true || !isLiveWindow(win)) { + return { success: false, error: result.error || "Editor did not accept the file" }; + } + // Pending ownership keeps the source alive while opening, but only + // accepted tabs may close the source copy during window cleanup. + owner.accepted = true; + ctx.showAndFocusWindow(win); + return { success: true, reused }; + } catch (error) { + return { success: false, error: error?.message || "Failed to open editor tab" }; + } finally { + if (!owner.accepted && tabSources.get(snapshot.editorId) === owner) tabSources.delete(snapshot.editorId); + } + })(); + return owner.opening; + } + + async function openEditorWindow(electronModule, options, payload) { + const snapshot = sanitizeEditorSnapshot(payload); + if (!snapshot) return { success: false, error: "Invalid editor payload" }; + + const { BrowserWindow, shell } = electronModule; + const { preload, devServerUrl, isDev, appIcon, isMac, electronDir, sourceWindow, sourceWebContents } = options; + const source = sourceWebContents || sourceWindow?.webContents; + + const existing = getEditorWindow(); + if (existing) { + if (nativeClosePendingWindow === existing) return { success: false, error: "Editor window is closing" }; + return sendOpenTab(electronModule, existing, snapshot, source, true); + } + + const osTheme = electronModule?.nativeTheme?.shouldUseDarkColors ? "dark" : "light"; + const effectiveTheme = ctx.currentTheme === "dark" || ctx.currentTheme === "light" ? ctx.currentTheme : osTheme; + const frontendBackground = ctx.resolveFrontendBackgroundColor(electronDir || ctx.__dirname || __dirname, effectiveTheme); + const backgroundColor = frontendBackground || "#1a1a1a"; + const { x: editorX, y: editorY } = ctx.resolveSettingsWindowBounds(electronModule, { + sourceWindow: sourceWindow || ctx.mainWindow, + settingsWidth: EDITOR_WIDTH, + settingsHeight: EDITOR_HEIGHT, + }); + + const windowsChrome = windowsFramelessContentChromeOptions(); + let editorWindowCloseConfirmed = false; + const win = new BrowserWindow({ + title: snapshot.fileName, + width: EDITOR_WIDTH, + height: EDITOR_HEIGHT, + ...(editorX !== undefined && editorY !== undefined ? { x: editorX, y: editorY } : {}), + minWidth: 640, + minHeight: 420, + backgroundColor, + icon: appIcon, + show: false, + frame: false, + ...(isMac ? { trafficLightPosition: { x: 12, y: 12 } } : {}), + ...windowsChrome, + webPreferences: { + preload, + contextIsolation: true, + nodeIntegration: false, + sandbox: false, + spellcheck: false, + backgroundThrottling: false, + v8CacheOptions: ctx.V8_CACHE_OPTIONS, + }, + }); + editorWindow = win; + + const releaseLifecycle = () => { + if (editorWindow === win) { + editorWindow = null; + editorWindowLoaded = null; + } + const leftoverIds = Array.from(tabSources.keys()); + notifySourcesTabsClosed(electronModule, leftoverIds); + tabSources.clear(); + ctx.unregisterAppContentWindow(win); + ctx.notifyAppContentWindowClosed(win); + }; + + ctx.registerAppContentWindow(win, { queryDirtyEditors: true }); + + try { + win.webContents?.setWindowOpenHandler?.(ctx.createExternalOnlyWindowOpenHandler(shell)); + } catch { + // ignore + } + + win.on("close", (event) => { + if (ctx.isQuitting || editorWindowCloseConfirmed) return; + event.preventDefault(); + if (nativeClosePendingWindow === win) return; + nativeClosePendingWindow = win; + // OS close (Alt+F4/titlebar) uses the same renderer Save/Discard/Cancel + // flow as closing an owner or a tab, rather than a dirty-only veto. + closeEditorTabs(electronModule, { editorIds: Array.from(tabSources.keys()) }) + .then((result) => { + if (!result.success || result.cancelled) return; + editorWindowCloseConfirmed = true; + if (isLiveWindow(win)) win.close(); + }) + .catch((error) => { + console.warn("[EditorWindow] Close confirmation failed", error); + }) + .finally(() => { if (nativeClosePendingWindow === win) nativeClosePendingWindow = null; }); + }); + win.on("closed", releaseLifecycle); + win.webContents.on("render-process-gone", () => { + // Destroying reuses closed cleanup for ownership and pending requests. + if (isLiveWindow(win)) win.destroy(); + }); + win.on("page-title-updated", (e) => { e.preventDefault(); }); + + try { + win.setBackgroundColor(backgroundColor); + } catch { + // ignore + } + ctx.applyWindowOpacityToWindow(win); + + if (isMac) { + try { + win.setWindowButtonVisibility(true); + } catch { + // ignore + } + try { + win.setWindowButtonPosition({ x: 12, y: 12 }); + } catch { + // ignore + } + } + + // loadURL only guarantees document load, not that React installed the + // receiver. Subscribe before navigation so a fast renderer cannot race us. + const rendererReady = new Promise((resolve, reject) => { + const cleanup = () => { + clearTimeout(timeout); + electronModule.ipcMain.removeListener("netcatty:window:editorReady", onReady); + win.removeListener("closed", onClosed); + }; + const onReady = (event) => { + if (event.sender !== win.webContents) return; + cleanup(); + resolve(); + }; + const onClosed = () => { + cleanup(); + reject(new Error("Editor window closed before it was ready")); + }; + const timeout = setTimeout(() => { + cleanup(); + reject(new Error("Timed out waiting for editor receiver")); + }, OPEN_TAB_TIMEOUT_MS); + electronModule.ipcMain.on("netcatty:window:editorReady", onReady); + win.once("closed", onClosed); + }); + const editorPath = "#/editor-window"; + const loadRenderer = async () => { + try { + if (isDev) { + try { + const baseUrl = ctx.getDevRendererBaseUrl(devServerUrl); + await win.loadURL(`${baseUrl}${editorPath}`); + } catch (e) { + console.warn("[EditorWindow] Dev server not reachable", e); + await win.loadURL(`app://netcatty/index.html${editorPath}`); + } + } else { + await win.loadURL(`app://netcatty/index.html${editorPath}`); + } + } catch (error) { + if (isLiveWindow(win)) win.destroy(); + throw error; + } + }; + editorWindowLoaded = Promise.all([loadRenderer(), rendererReady]).catch((error) => { + if (isLiveWindow(win)) win.destroy(); + throw error; + }); + return sendOpenTab(electronModule, win, snapshot, source, false); + } + + function focusEditorTab(electronModule, editorId) { + const win = getEditorWindow(); + if (!win) return { success: false, error: "Editor window is not open" }; + try { + if (typeof editorId === "string" && editorId) { + win.webContents.send("netcatty:window:editorActivateTab", { editorId }); + } + ctx.showAndFocusWindow(win); + return { success: true }; + } catch (err) { + return { success: false, error: err?.message || "Failed to focus editor window" }; + } + } + + async function closeEditorTabs(electronModule, payload) { + const editorIds = Array.isArray(payload?.editorIds) + ? payload.editorIds.filter((id) => typeof id === "string" && id) + : []; + if (editorIds.length === 0) return { success: true, cancelled: false, closedIds: [] }; + // Closing an owner or a placeholder during cold start must not overtake + // its open receipt. Reuse the same readiness/ownership boundary. + const opening = editorIds.map((id) => tabSources.get(id)?.opening).filter(Boolean); + const opened = await Promise.all(opening); + if (opened.some((result) => !result.success)) { + return { success: false, cancelled: true, closedIds: [], error: "Editor transfer did not complete" }; + } + try { + await editorWindowLoaded; + } catch (error) { + return { success: false, cancelled: true, closedIds: [], error: error?.message || "Editor is not ready" }; + } + const win = getEditorWindow(); + if (!win) { + notifySourcesTabsClosed(electronModule, editorIds); + return { success: true, cancelled: false, closedIds: editorIds }; + } + const force = payload?.force === true; + if (!force) ctx.showAndFocusWindow(win); + const result = await invokeWebContents( + electronModule, + win.webContents, + "netcatty:window:editorCloseTabs", + "netcatty:window:editorCloseTabsResult", + { editorIds, force }, + force ? CLOSE_TABS_FORCE_TIMEOUT_MS : CLOSE_TABS_PROMPT_TIMEOUT_MS, + ); + if (!result.success) { + if (force) { + try { + win.webContents.send("netcatty:window:editorCloseTabs", { + requestId: randomUUID(), + editorIds, + force: true, + }); + } catch { + // ignore + } + notifySourcesTabsClosed(electronModule, editorIds); + return { success: true, cancelled: false, closedIds: editorIds }; + } + return { success: false, cancelled: true, closedIds: [], error: result.error }; + } + const closedIds = Array.isArray(result.closedIds) ? result.closedIds : []; + notifySourcesTabsClosed(electronModule, closedIds); + if (result.cancelled === true) { + return { success: true, cancelled: true, closedIds }; + } + return { success: true, cancelled: false, closedIds }; + } + + async function saveEditorTab(electronModule, payload) { + const snapshotLike = sanitizeEditorSnapshot({ + ...payload, + fileName: payload?.fileName || "file", + content: payload?.content, + baselineContent: payload?.content, + languageId: "plaintext", + wordWrap: false, + viewState: null, + }); + if (!snapshotLike) return { ok: false, error: "Invalid save payload" }; + const source = resolveSourceWebContents(electronModule, snapshotLike.editorId); + if (!source) return { ok: false, error: "SFTP editor bridge not registered — cannot save (no SFTP view mounted)" }; + const result = await invokeWebContents( + electronModule, + source, + "netcatty:window:editorSaveRequest", + "netcatty:window:editorSaveResult", + { + editorId: snapshotLike.editorId, + sessionId: snapshotLike.sessionId, + sftpTabId: snapshotLike.sftpTabId, + hostId: snapshotLike.hostId, + remotePath: snapshotLike.remotePath, + content: typeof payload?.content === "string" ? payload.content : "", + }, + SAVE_TIMEOUT_MS, + ); + if (!result.success) return { ok: false, error: result.error || "Save failed" }; + if (result.ok === false) return { ok: false, error: result.error || "Save failed" }; + if (typeof result.liveConnectionId === "string" && result.liveConnectionId) { + const win = getEditorWindow(); + try { + win?.webContents.send("netcatty:window:editorRemapSession", { + fromSessionId: snapshotLike.sessionId, + toSessionId: result.liveConnectionId, + }); + } catch { + // ignore + } + } + return { + ok: true, + liveConnectionId: result.liveConnectionId, + }; + } + + async function dockEditorTab(electronModule, payload) { + const snapshot = sanitizeEditorSnapshot(payload); + if (!snapshot) return { success: false, error: "Invalid dock payload" }; + const source = resolveSourceWebContents(electronModule, snapshot.editorId); + if (!source) return { success: false, error: "Source window is gone" }; + const result = await invokeWebContents( + electronModule, + source, + "netcatty:window:editorDockRequest", + "netcatty:window:editorDockResult", + snapshot, + DOCK_TIMEOUT_MS, + ); + if (!result.success || result.ok === false) { + return { success: false, error: result.error || "Failed to dock editor tab" }; + } + tabSources.delete(snapshot.editorId); + return { success: true }; + } + + function reportEditorDirty(electronModule, payload) { + const editorId = typeof payload?.editorId === "string" ? payload.editorId : ""; + if (!editorId) return; + const source = resolveSourceWebContents(electronModule, editorId); + if (!source) return; + try { + source.send("netcatty:window:editorDirtyChanged", { + editorId, + dirty: payload?.dirty === true, + }); + } catch { + // ignore + } + } + + function reportEditorTabsClosed(electronModule, payload) { + const editorIds = Array.isArray(payload?.editorIds) + ? payload.editorIds.filter((id) => typeof id === "string" && id) + : (typeof payload?.editorId === "string" ? [payload.editorId] : []); + notifySourcesTabsClosed(electronModule, editorIds); + } + + function remapEditorSession(electronModule, payload) { + const fromSessionId = typeof payload?.fromSessionId === "string" ? payload.fromSessionId : ""; + const toSessionId = typeof payload?.toSessionId === "string" ? payload.toSessionId : ""; + if (!fromSessionId || !toSessionId || fromSessionId === toSessionId) return; + const win = getEditorWindow(); + if (!win) return; + try { + win.webContents.send("netcatty:window:editorRemapSession", { fromSessionId, toSessionId }); + } catch { + // ignore + } + } + + return { + openEditorWindow, + hasEditorTabsForSource, + closeEditorTabsForSource, + focusEditorTab, + closeEditorTabs, + saveEditorTab, + dockEditorTab, + reportEditorDirty, + reportEditorTabsClosed, + remapEditorSession, + getEditorWindow, + }; +} + +module.exports = { createEditorWindowApi }; diff --git a/electron/bridges/windowManager/editorWindow.test.cjs b/electron/bridges/windowManager/editorWindow.test.cjs new file mode 100644 index 000000000..9fc0c08ee --- /dev/null +++ b/electron/bridges/windowManager/editorWindow.test.cjs @@ -0,0 +1,263 @@ +const assert = require("node:assert/strict"); +const test = require("node:test"); +const { EventEmitter } = require("node:events"); +const { createEditorWindowApi } = require("./editorWindow.cjs"); + +const snapshot = (id) => ({ editorId: id, sessionId: "ssh", sftpTabId: "sftp", hostId: "host", remotePath: `/${id}`, fileName: id, content: `edited ${id}`, baselineContent: "original" }); +const tick = () => new Promise((resolve) => setImmediate(resolve)); +function harness() { + const ipcMain = new EventEmitter(); + const windows = []; + class BrowserWindow extends EventEmitter { + constructor() { + super(); + this.destroyed = false; + this.sent = []; + this.webContents = new EventEmitter(); + this.webContents.isDestroyed = () => this.destroyed; + this.webContents.send = (channel, payload) => this.sent.push({ channel, payload }); + windows.push(this); + } + isDestroyed() { return this.destroyed; } + loadURL() { return new Promise((resolve, reject) => { this.loaded = resolve; this.failed = reject; }); } + setBackgroundColor() {} + close() { const event = { prevented: false, preventDefault() { this.prevented = true; } }; this.emit("close", event); if (!event.prevented) this.destroy(); } + destroy() { this.destroyed = true; this.webContents.emit("destroyed"); this.emit("closed"); } + } + const source = Object.assign(new EventEmitter(), { id: 99, isDestroyed: () => false, sent: [], send(channel, payload) { this.sent.push({ channel, payload }); } }); + const sources = new Map([[source.id, source]]); + const electron = { BrowserWindow, ipcMain, webContents: { fromId: (id) => sources.get(id) } }; + const context = { currentTheme: "dark", mainWindow: null, isQuitting: false, + V8_CACHE_OPTIONS: "code", resolveFrontendBackgroundColor: () => "#000", resolveSettingsWindowBounds: () => ({}), + registerAppContentWindow() {}, unregisterAppContentWindow() {}, notifyAppContentWindowClosed() {}, + createExternalOnlyWindowOpenHandler() {}, applyWindowOpacityToWindow() {}, showAndFocusWindow(win) { win.shown = true; }, + }; + const api = createEditorWindowApi(context); + const open = (id, owner = source) => { sources.set(owner.id, owner); return api.openEditorWindow(electron, { sourceWebContents: owner }, snapshot(id)); }; + const ready = (win) => ipcMain.emit("netcatty:window:editorReady", { sender: win.webContents }); + const accept = (win, request, ok = true) => ipcMain.emit("netcatty:window:editorOpenTabResult", { sender: win.webContents }, { requestId: request.payload.requestId, ok }); + return { windows, source, ipcMain, open, ready, accept, api, electron, context }; +} + +test("concurrent cold opens wait for load and the mounted receiver, then for each receipt", async (t) => { + const h = harness(); + t.after(() => h.windows.forEach((win) => { if (!win.destroyed) win.destroy(); })); + let completed = 0; + const first = h.open("first").then((result) => { completed++; return result; }); + const second = h.open("second").then((result) => { completed++; return result; }); + const win = h.windows[0]; + assert.equal(h.windows.length, 1); + assert.equal(win.sent.length, 0); + win.loaded(); + await tick(); + assert.equal(win.sent.length, 0, "document load alone is not renderer readiness"); + h.ipcMain.emit("netcatty:window:editorReady", { sender: {} }); + await tick(); + assert.equal(win.sent.length, 0, "another window cannot claim readiness"); + h.ready(win); + await tick(); + assert.equal(win.sent.length, 2); + assert.equal(completed, 0); + assert.equal(win.shown, undefined); + h.accept(win, win.sent[1]); + assert.deepEqual(await second, { success: true, reused: true }); + assert.equal(completed, 1); + h.accept(win, win.sent[0]); + assert.deepEqual(await first, { success: true, reused: false }); + assert.deepEqual(win.sent.map((request) => request.payload.content), ["edited first", "edited second"]); + assert.equal(h.ipcMain.listenerCount("netcatty:window:editorOpenTabResult"), 0); +}); + +test("failed cold load leaves every unaccepted source tab intact", async () => { + const h = harness(); + const first = h.open("first"); + const second = h.open("second"); + h.windows[0].failed(new Error("load failed")); + assert.equal((await first).success, false); + assert.equal((await second).success, false); + assert.deepEqual(h.source.sent, [], "cleanup must not close tabs still owned by the source"); + assert.equal(h.ipcMain.listenerCount("netcatty:window:editorReady"), 0); +}); + +test("receiver rejection and window loss do not report successful delivery", async () => { + const h = harness(); + const opening = h.open("first"); + const win = h.windows[0]; + win.loaded(); h.ready(win); await tick(); + h.accept(win, win.sent[0], false); + assert.equal((await opening).success, false); + const next = h.open("second"); + await tick(); win.destroy(); + assert.equal((await next).success, false); + assert.deepEqual(h.source.sent, []); + assert.equal(h.ipcMain.listenerCount("netcatty:window:editorOpenTabResult"), 0); +}); + + +test("close during cold start waits for the receiver and the open receipt", async () => { + const h = harness(); + const opening = h.open("first"); + const closing = h.api.closeEditorTabs(h.electron, { editorIds: ["first"] }); + const win = h.windows[0]; + assert.equal(h.api.hasEditorTabsForSource(h.source), true, "pending transfers retain source ownership"); + assert.equal(win.sent.length, 0); + win.loaded(); h.ready(win); await tick(); + assert.deepEqual(win.sent.map((request) => request.channel), ["netcatty:window:editorOpenTab"]); + h.accept(win, win.sent[0]); await opening; await tick(); + const closeRequest = win.sent[1]; + assert.equal(closeRequest.channel, "netcatty:window:editorCloseTabs"); + h.ipcMain.emit("netcatty:window:editorCloseTabsResult", { sender: win.webContents }, { requestId: closeRequest.payload.requestId, cancelled: false, closedIds: ["first"] }); + assert.deepEqual(await closing, { success: true, cancelled: false, closedIds: ["first"] }); + assert.equal(h.api.hasEditorTabsForSource(h.source), false); + assert.deepEqual(h.source.sent.at(-1), { channel: "netcatty:window:editorTabsClosed", payload: { editorIds: ["first"] } }); + win.destroy(); +}); + +test("cancelling source-window close preserves its save and dock route and does not close other owners", async () => { + const h = harness(); + const other = Object.assign(new EventEmitter(), { id: 100, isDestroyed: () => false, send() {} }); + const first = h.open("first"); + const second = h.open("second", other); + const win = h.windows[0]; + win.loaded(); h.ready(win); await tick(); + for (const request of win.sent) h.accept(win, request); + await Promise.all([first, second]); + const closing = h.api.closeEditorTabsForSource(h.electron, h.source); + await tick(); + const request = win.sent.at(-1); + assert.deepEqual(request.payload.editorIds, ["first"]); + h.ipcMain.emit("netcatty:window:editorCloseTabsResult", { sender: win.webContents }, { requestId: request.payload.requestId, cancelled: true, closedIds: [] }); + assert.equal((await closing).cancelled, true); + assert.equal(h.api.hasEditorTabsForSource(h.source), true); + assert.equal(h.api.hasEditorTabsForSource(other), true); + const saving = h.api.saveEditorTab(h.electron, snapshot("first")); + const saveRequest = h.source.sent.at(-1); + assert.equal(saveRequest.channel, "netcatty:window:editorSaveRequest"); + h.ipcMain.emit("netcatty:window:editorSaveResult", { sender: h.source }, { requestId: saveRequest.payload.requestId, ok: true }); + assert.equal((await saving).ok, true); + const docking = h.api.dockEditorTab(h.electron, snapshot("first")); + const dockRequest = h.source.sent.at(-1); + assert.equal(dockRequest.channel, "netcatty:window:editorDockRequest"); + h.ipcMain.emit("netcatty:window:editorDockResult", { sender: h.source }, { requestId: dockRequest.payload.requestId, ok: true }); + assert.equal((await docking).success, true); + assert.equal(h.api.hasEditorTabsForSource(h.source), false); + assert.equal(h.api.hasEditorTabsForSource(other), true); + win.destroy(); +}); + +test("a failed transfer vetoes a simultaneous source close without deleting its retained copy", async () => { + const h = harness(); + const opening = h.open("first"); + const closing = h.api.closeEditorTabsForSource(h.electron, h.source); + h.windows[0].failed(new Error("load failed")); + assert.equal((await opening).success, false); + assert.equal((await closing).cancelled, true); + assert.deepEqual(h.source.sent, []); +}); + + +test("native editor close uses renderer confirmation, preserves Cancel, and coalesces repeated close events", async () => { + const h = harness(); + const opening = h.open("first"); + const win = h.windows[0]; + win.loaded(); h.ready(win); await tick(); + h.accept(win, win.sent[0]); await opening; + win.close(); win.close(); await tick(); + assert.equal(win.isDestroyed(), false); + const requests = () => win.sent.filter((request) => request.channel === "netcatty:window:editorCloseTabs"); + assert.equal(requests().length, 1); + assert.deepEqual(requests()[0].payload.editorIds, ["first"]); + assert.equal(requests()[0].payload.force, false); + h.ipcMain.emit("netcatty:window:editorCloseTabsResult", { sender: win.webContents }, { requestId: requests()[0].payload.requestId, cancelled: true, closedIds: [] }); + await tick(); + assert.equal(win.isDestroyed(), false, "Cancel keeps the native window and its contents open"); + assert.equal(h.api.hasEditorTabsForSource(h.source), true); + win.close(); await tick(); + assert.equal(requests().length, 2, "native close can be retried after Cancel"); + h.ipcMain.emit("netcatty:window:editorCloseTabsResult", { sender: win.webContents }, { requestId: requests()[1].payload.requestId, cancelled: false, closedIds: ["first"] }); + await tick(); + assert.equal(win.isDestroyed(), true, "Save/Discard completion permits native destruction"); + assert.equal(h.api.hasEditorTabsForSource(h.source), false); +}); + + +test("editor module parses in strict mode without a with scope", () => { + const source = require("node:fs").readFileSync(require.resolve("./editorWindow.cjs"), "utf8"); + assert.doesNotThrow(() => new Function("require", "module", "exports", '"use strict";\n' + source)); +}); + +test("editor close reads the live quitting context instead of a captured value", async () => { + const h = harness(); + const opening = h.open("first"); + const win = h.windows[0]; + win.loaded(); h.ready(win); await tick(); + h.accept(win, win.sent[0]); await opening; + Object.defineProperty(h.context, "isQuitting", { get: () => true }); + win.close(); + assert.equal(win.isDestroyed(), true); + assert.equal(win.sent.some((request) => request.channel === "netcatty:window:editorCloseTabs"), false); +}); + +test("renderer loss destroys the dead window, releases ownership, and allows a fresh open", async (t) => { + const h = harness(); + t.after(() => h.windows.forEach((win) => { if (!win.destroyed) win.destroy(); })); + const opening = h.open("first"); + const win = h.windows[0]; + win.loaded(); h.ready(win); await tick(); + h.accept(win, win.sent[0]); await opening; + const closing = h.api.closeEditorTabs(h.electron, { editorIds: ["first"] }); + await tick(); + win.webContents.emit("render-process-gone", {}, { reason: "crashed" }); + assert.equal(win.isDestroyed(), true); + assert.equal(h.api.getEditorWindow(), null); + assert.equal(h.api.hasEditorTabsForSource(h.source), false); + assert.equal((await closing).cancelled, true, "pending requests settle without the prompt timeout"); + assert.deepEqual(h.source.sent.at(-1), { channel: "netcatty:window:editorTabsClosed", payload: { editorIds: ["first"] } }); + const nextOpening = h.open("second"); + const replacement = h.windows[1]; + assert.ok(replacement); + replacement.loaded(); h.ready(replacement); await tick(); + h.accept(replacement, replacement.sent[0]); + assert.equal((await nextOpening).success, true); +}); + +test("renderer loss during a pending handoff leaves its source contents owned by the source", async (t) => { + const h = harness(); + t.after(() => h.windows.forEach((win) => { if (!win.destroyed) win.destroy(); })); + const opening = h.open("first"); + const win = h.windows[0]; + win.webContents.emit("render-process-gone", {}, { reason: "oom" }); + assert.equal(win.isDestroyed(), true); + assert.equal((await opening).success, false); + assert.deepEqual(h.source.sent, [], "unaccepted source copies must not receive a tab-closed notification"); +}); + + +test("native close rejects new transfers before ownership registration and Cancel restores acceptance", async (t) => { + const h = harness(); + t.after(() => h.windows.forEach((win) => { if (!win.destroyed) win.destroy(); })); + const opening = h.open("first"); + const win = h.windows[0]; + win.loaded(); h.ready(win); await tick(); + h.accept(win, win.sent[0]); await opening; + win.close(); await tick(); + const closeRequest = win.sent.at(-1); + assert.equal(closeRequest.channel, "netcatty:window:editorCloseTabs"); + const other = Object.assign(new EventEmitter(), { id: 100, isDestroyed: () => false, sent: [], send(channel, payload) { this.sent.push({ channel, payload }); } }); + let incomingResult; + const incoming = h.open("incoming", other).then((result) => { incomingResult = result; return result; }); + await tick(); + // Assert before awaiting the result so the old, erroneously pending open fails promptly. + assert.deepEqual(incomingResult, { success: false, error: "Editor window is closing" }); + await incoming; + assert.equal(h.api.hasEditorTabsForSource(other), false); + assert.equal(win.sent.some((request) => request.channel === "netcatty:window:editorOpenTab" && request.payload.editorId === "incoming"), false); + h.ipcMain.emit("netcatty:window:editorCloseTabsResult", { sender: win.webContents }, { requestId: closeRequest.payload.requestId, cancelled: true, closedIds: [] }); + await tick(); + const retry = h.open("incoming", other); + await tick(); + h.accept(win, win.sent.at(-1)); + assert.equal((await retry).success, true); + assert.equal(h.api.hasEditorTabsForSource(other), true); + assert.equal(win.isDestroyed(), false); +}); diff --git a/electron/bridges/windowManager/mainWindow.cjs b/electron/bridges/windowManager/mainWindow.cjs index 1d083d13b..c02944ca5 100644 --- a/electron/bridges/windowManager/mainWindow.cjs +++ b/electron/bridges/windowManager/mainWindow.cjs @@ -6,6 +6,104 @@ const { attachDisplayRecovery } = require("./displayRecovery.cjs"); const TERMINAL_KEYBOARD_FOCUS = Symbol("netcattyTerminalKeyboardFocus"); +// #3647: the 1100×640 layout minimum is larger than the usable workspace on +// small laptops (e.g. 1366×768 at 125% Windows scaling leaves only ~1093×574 +// logical px), which made those windows impossible to fit — let alone shrink. +// Keep the layout minimum on capable screens, but shrink it to the work area +// of the display the window targets so the window can always fit with a small +// margin. +const MIN_SIZE_FIT_FACTOR = 0.9; +const MIN_WINDOW_FLOOR_WIDTH = 320; +const MIN_WINDOW_FLOOR_HEIGHT = 240; + +function pickTargetDisplay(screenModule, bounds) { + if (!screenModule || typeof screenModule.getDisplayMatching !== "function") { + return screenModule?.getPrimaryDisplay?.() || null; + } + try { + return screenModule.getDisplayMatching(bounds) || screenModule.getPrimaryDisplay?.() || null; + } catch { + try { + return screenModule.getPrimaryDisplay?.() || null; + } catch { + return null; + } + } +} + +function resolveWindowMinimumSize(screenModule, minWidth, minHeight, bounds) { + let effectiveWidth = minWidth; + let effectiveHeight = minHeight; + try { + // Size for the display the window actually lives on (saved position or + // current placement), falling back to the primary display. + const display = bounds + ? pickTargetDisplay(screenModule, bounds) + : screenModule?.getPrimaryDisplay?.(); + const workArea = display?.workArea; + if (Number.isFinite(workArea?.width) && workArea.width > 0) { + effectiveWidth = Math.min(effectiveWidth, Math.floor(workArea.width * MIN_SIZE_FIT_FACTOR)); + } + if (Number.isFinite(workArea?.height) && workArea.height > 0) { + effectiveHeight = Math.min(effectiveHeight, Math.floor(workArea.height * MIN_SIZE_FIT_FACTOR)); + } + } catch { + // screen unavailable (e.g. before app ready): keep the static minimums + } + return { + width: Math.max(MIN_WINDOW_FLOOR_WIDTH, effectiveWidth), + height: Math.max(MIN_WINDOW_FLOOR_HEIGHT, effectiveHeight), + }; +} + +// Keep the BrowserWindow minimum in sync with the display it currently lives +// on: docking, unplugging a screen, or display-scaling changes can move the +// window onto a display with a different work area. +function attachDisplayMinimumSync({ win, screen: screenModule, minWidth, minHeight }) { + if (!win || !screenModule || typeof screenModule.on !== "function") return () => {}; + + const applyMinimumForCurrentDisplay = () => { + try { + if (win.isDestroyed?.()) return; + const next = resolveWindowMinimumSize(screenModule, minWidth, minHeight, win.getBounds()); + const [currentWidth, currentHeight] = win.getMinimumSize?.() || []; + if (next.width === currentWidth && next.height === currentHeight) return; + win.setMinimumSize(next.width, next.height); + } catch { + // Best-effort only. + } + }; + + const onDisplayMetricsChanged = () => applyMinimumForCurrentDisplay(); + const onDisplayRemoved = () => applyMinimumForCurrentDisplay(); + const onWindowMoved = () => applyMinimumForCurrentDisplay(); + try { + screenModule.on("display-metrics-changed", onDisplayMetricsChanged); + screenModule.on("display-removed", onDisplayRemoved); + win.on("move", onWindowMoved); + } catch { + try { + win.removeListener?.("move", onWindowMoved); + } catch { + // ignore + } + return () => {}; + } + return function detachDisplayMinimumSync() { + try { + screenModule.removeListener?.("display-metrics-changed", onDisplayMetricsChanged); + screenModule.removeListener?.("display-removed", onDisplayRemoved); + } catch { + // ignore + } + try { + win.removeListener?.("move", onWindowMoved); + } catch { + // ignore + } + }; +} + function setTerminalKeyboardFocusForWindow(win, focused) { if (!win || win.isDestroyed?.() || !win.webContents) return false; const isFocused = focused === true; @@ -113,11 +211,24 @@ function createMainWindowApi(ctx) { height: DEFAULT_WINDOW_HEIGHT, }; + // Size the minimum to the display the restored window will live on + // (a saved position may target a smaller secondary display), falling + // back to the primary display when there is no usable saved position. + const savedTargetBounds = savedState && Number.isFinite(savedState.x) && Number.isFinite(savedState.y) + ? { x: savedState.x, y: savedState.y, width: savedState.width, height: savedState.height } + : null; + const windowMinimumSize = resolveWindowMinimumSize( + screen, + MIN_WINDOW_WIDTH, + MIN_WINDOW_HEIGHT, + savedTargetBounds, + ); + if (savedState) { // Use saved dimensions, but clamp to the minimum so a previously // shrunk window from an older build cannot start below the minimum. - windowBounds.width = Math.max(savedState.width, MIN_WINDOW_WIDTH); - windowBounds.height = Math.max(savedState.height, MIN_WINDOW_HEIGHT); + windowBounds.width = Math.max(savedState.width, windowMinimumSize.width); + windowBounds.height = Math.max(savedState.height, windowMinimumSize.height); // Only use saved position if the screen is available at that location if (typeof savedState.x === "number" && typeof savedState.y === "number") { @@ -149,8 +260,8 @@ function createMainWindowApi(ctx) { const win = new BrowserWindow({ ...windowBounds, - minWidth: MIN_WINDOW_WIDTH, - minHeight: MIN_WINDOW_HEIGHT, + minWidth: windowMinimumSize.width, + minHeight: windowMinimumSize.height, backgroundColor, icon: appIcon, show: false, @@ -190,6 +301,20 @@ function createMainWindowApi(ctx) { detachDisplayRecovery = null; } + // #3647: docking / display-scaling changes can move the window onto a + // display with a different work area; keep the minimum in sync so the + // window can always fit its current display. + const detachDisplayMinimumSync = attachDisplayMinimumSync({ + win, + screen, + // Pass the configured (layout) minimum as the baseline, not the + // display-fitted value resolved above: otherwise a reduced minimum + // would become a permanent ceiling and could never grow back to + // MIN_WINDOW_WIDTH × MIN_WINDOW_HEIGHT on a larger display. + minWidth: MIN_WINDOW_WIDTH, + minHeight: MIN_WINDOW_HEIGHT, + }); + // Clear reference when the main window is destroyed win.on('closed', () => { try { @@ -197,6 +322,11 @@ function createMainWindowApi(ctx) { } catch { // ignore } + try { + detachDisplayMinimumSync?.(); + } catch { + // ignore + } try { if (win?.webContents?.id) { unhealthyWebContentsIds.delete(win.webContents.id); @@ -347,6 +477,7 @@ function createMainWindowApi(ctx) { let saveStateTimer = null; let thisWindowCloseRequested = false; let dirtyEditorCloseConfirmed = false; + let editorCloseCheckPending = false; const updateNormalBounds = () => { if (!win.isDestroyed() && !win.isMaximized() && !win.isFullScreen()) { @@ -382,12 +513,22 @@ function createMainWindowApi(ctx) { const queryDirtyEditorsBeforeClose = (event, { markCloseRequested = false } = {}) => { event.preventDefault(); + if (editorCloseCheckPending) return; + editorCloseCheckPending = true; if (markCloseRequested) { thisWindowCloseRequested = true; } - const dirtyEditorQuery = typeof queryDirtyEditors === "function" - ? queryDirtyEditors(win.webContents, 5000, { ipcMain: electronModule.ipcMain }) - : false; + const dirtyEditorQuery = (async () => { + // A detached editor still uses this renderer's SFTP save/dock route. + // Run its existing Save/Discard/Cancel flow before destroying the owner. + if (typeof closeEditorTabsForSource === "function") { + const result = await closeEditorTabsForSource(electronModule, win.webContents); + if (!result.success || result.cancelled) return true; + } + return typeof queryDirtyEditors === "function" + ? queryDirtyEditors(win.webContents, 5000, { ipcMain: electronModule.ipcMain }) + : false; + })(); Promise.resolve(dirtyEditorQuery) .then((hasDirty) => { if (hasDirty) { @@ -402,17 +543,18 @@ function createMainWindowApi(ctx) { } }) .catch(() => { - dirtyEditorCloseConfirmed = true; - try { - win.close(); - } catch { - // ignore - } - }); + // A failed ownership check must keep the source save route alive. + thisWindowCloseRequested = false; + }) + .finally(() => { editorCloseCheckPending = false; }); }; // Save state when window is about to close win.on("close", (event) => { + // Recheck if another editor was opened while the local dirty query ran. + if (typeof hasEditorTabsForSource === "function" && hasEditorTabsForSource(win.webContents)) { + dirtyEditorCloseConfirmed = false; + } if (!registerAsMainWindow && registerAsAppContentWindow && !isQuitting && !dirtyEditorCloseConfirmed) { queryDirtyEditorsBeforeClose(event, { markCloseRequested: true }); return; @@ -583,6 +725,8 @@ function createMainWindowApi(ctx) { } module.exports = { + attachDisplayMinimumSync, createMainWindowApi, + resolveWindowMinimumSize, setTerminalKeyboardFocusForWindow, }; diff --git a/electron/bridges/windowManager/mainWindow.editorOwnership.test.cjs b/electron/bridges/windowManager/mainWindow.editorOwnership.test.cjs new file mode 100644 index 000000000..25840b6fe --- /dev/null +++ b/electron/bridges/windowManager/mainWindow.editorOwnership.test.cjs @@ -0,0 +1,63 @@ +const assert = require("node:assert/strict"); +const test = require("node:test"); +const { EventEmitter } = require("node:events"); +const { createMainWindowApi } = require("./mainWindow.cjs"); +const tick = () => new Promise((resolve) => setImmediate(resolve)); + +for (const peer of [true, false]) { + test(`${peer ? "peer session" : "main"} window keeps its renderer until detached ownership is released`, async () => { + let finish; + let ownsTabs = true; + let ownerChecks = 0; + let dirtyChecks = 0; + class Window extends EventEmitter { + constructor() { + super(); this.destroyed = false; + this.webContents = Object.assign(new EventEmitter(), { id: 71, isDestroyed: () => this.destroyed, setWindowOpenHandler() {}, openDevTools() {}, send() {} }); + } + close() { const event = { prevented: false, preventDefault() { this.prevented = true; } }; this.emit("close", event); if (!event.prevented) { this.destroyed = true; this.emit("closed"); } } + isDestroyed() { return this.destroyed; } + isMaximized() { return false; } + isFullScreen() { return false; } + getBounds() { return { width: 1000, height: 700 }; } + setBackgroundColor() {} + loadURL() { return Promise.resolve(); } + } + const api = createMainWindowApi({ + mainWindow: null, electronApp: null, currentTheme: "light", isQuitting: false, + pendingWindowStateWrite: null, queuedWindowState: null, + DEFAULT_WINDOW_WIDTH: 1400, DEFAULT_WINDOW_HEIGHT: 900, MIN_WINDOW_WIDTH: 1100, MIN_WINDOW_HEIGHT: 640, + V8_CACHE_OPTIONS: "code", THEME_COLORS: { light: {} }, + unhealthyWebContentsIds: new Set(), rendererReadySeenByWebContentsId: new Set(), + __dirname, URL, require, console, setTimeout, clearTimeout, + getGlobalShortcutBridge: () => ({ handleWindowClose: () => false }), + getMainWindowCount: () => 1, + debugLog() {}, resolveFrontendBackgroundColor() { return null; }, loadWindowState() { return null; }, + getDevRendererBaseUrl: (url) => url, getWindowBoundsState() { return null; }, + queueWindowStateSave() {}, saveWindowStateSync() {}, setupDeferredShow() {}, + createExternalOnlyWindowOpenHandler() { return {}; }, createAppWindowOpenHandler() { return {}; }, + attachOAuthLoadingOverlay() {}, registerWindowHandlers() {}, requestWindowCommandClose() {}, shouldCloseWindowFromInput() { return false; }, + registerMainWindow() {}, unregisterMainWindow() {}, registerAppContentWindow() {}, unregisterAppContentWindow() {}, + notifyAppContentWindowClosed() {}, applyWindowOpacityToWindow() {}, closeSettingsWindow() {}, hideSettingsWindow() {}, + hasEditorTabsForSource: () => ownsTabs, + closeEditorTabsForSource: () => { ownerChecks++; return new Promise((resolve) => { finish = resolve; }); }, + queryDirtyEditors: async () => { dirtyChecks++; return false; }, + }); + const win = await api.createWindow({ BrowserWindow: Window, nativeTheme: {}, app: {}, screen: {}, shell: {}, ipcMain: {} }, { + preload: "/tmp/preload.cjs", devServerUrl: "http://localhost:5173", isDev: true, isMac: true, + electronDir: __dirname, route: peer ? "session-window" : undefined, registerAsMainWindow: !peer, + }); + win.close(); win.close(); + assert.equal(ownerChecks, 1, "repeat close must share the ownership check"); + assert.equal(dirtyChecks, 0, "the short dirty query must not race a detached Save/Cancel prompt"); + assert.equal(win.destroyed, false); + finish({ success: true, cancelled: true }); await tick(); + assert.equal(win.destroyed, false, "Cancel keeps the source save/dock renderer alive"); + assert.equal(dirtyChecks, 0); + win.close(); + ownsTabs = false; + finish({ success: true, cancelled: false }); await tick(); + assert.equal(dirtyChecks, 1); + assert.equal(win.destroyed, true, "close proceeds only after all owned editors settled"); + }); +} diff --git a/electron/bridges/windowManager/mainWindow.windowMinSize.test.cjs b/electron/bridges/windowManager/mainWindow.windowMinSize.test.cjs new file mode 100644 index 000000000..ac7cba213 --- /dev/null +++ b/electron/bridges/windowManager/mainWindow.windowMinSize.test.cjs @@ -0,0 +1,194 @@ +const test = require("node:test"); +const assert = require("node:assert/strict"); + +const { resolveWindowMinimumSize, attachDisplayMinimumSync } = require("./mainWindow.cjs"); + +test("uses the static layout minimums when they fit the display", () => { + const size = resolveWindowMinimumSize( + { getPrimaryDisplay: () => ({ workArea: { width: 1920, height: 1080 } }) }, + 1100, + 640, + ); + assert.deepEqual(size, { width: 1100, height: 640 }); +}); + +test("sizes the minimum for the display containing the target bounds", () => { + const primary = { workArea: { width: 1920, height: 1080 } }; + const secondary = { workArea: { width: 1093, height: 585 } }; + const size = resolveWindowMinimumSize( + { + getPrimaryDisplay: () => primary, + getDisplayMatching: (bounds) => (bounds.x > 1920 ? secondary : primary), + }, + 1100, + 640, + { x: 2000, y: 0, width: 1000, height: 700 }, + ); + assert.deepEqual(size, { width: Math.floor(1093 * 0.9), height: Math.floor(585 * 0.9) }); +}); + +test("falls back to the primary display when the target cannot be matched", () => { + const size = resolveWindowMinimumSize( + { + getPrimaryDisplay: () => ({ workArea: { width: 1093, height: 585 } }), + getDisplayMatching: () => null, + }, + 1100, + 640, + { x: 0, y: 0, width: 1000, height: 700 }, + ); + assert.deepEqual(size, { width: Math.floor(1093 * 0.9), height: Math.floor(585 * 0.9) }); +}); + +test("refreshes the window minimum when display metrics change", () => { + const { EventEmitter } = require("node:events"); + const screenModule = new EventEmitter(); + screenModule.getPrimaryDisplay = () => ({ workArea: { width: 1920, height: 1080 } }); + const win = new EventEmitter(); + win.isDestroyed = () => false; + win.getBounds = () => ({ x: 0, y: 0, width: 1100, height: 640 }); + let minimum = { width: 1100, height: 640 }; + win.getMinimumSize = () => [minimum.width, minimum.height]; + win.setMinimumSize = (width, height) => { minimum = { width, height }; }; + + const detach = attachDisplayMinimumSync({ win, screen: screenModule, minWidth: 1100, minHeight: 640 }); + try { + // Docking change shrinks the primary work area under the window. + screenModule.getPrimaryDisplay = () => ({ workArea: { width: 1093, height: 585 } }); + screenModule.emit("display-metrics-changed"); + assert.deepEqual(minimum, { width: Math.floor(1093 * 0.9), height: Math.floor(585 * 0.9) }); + + // No-op emission keeps the same minimum. + screenModule.emit("display-metrics-changed"); + assert.deepEqual(minimum, { width: Math.floor(1093 * 0.9), height: Math.floor(585 * 0.9) }); + } finally { + detach(); + } +}); + +test("ignores display events after detach", () => { + const { EventEmitter } = require("node:events"); + const screenModule = new EventEmitter(); + const win = new EventEmitter(); + win.isDestroyed = () => false; + win.getBounds = () => ({ x: 0, y: 0, width: 1100, height: 640 }); + win.getMinimumSize = () => [1100, 640]; + let called = 0; + win.setMinimumSize = () => { called += 1; }; + + const detach = attachDisplayMinimumSync({ win, screen: screenModule, minWidth: 1100, minHeight: 640 }); + detach(); + screenModule.emit("display-metrics-changed"); + screenModule.emit("display-removed"); + assert.equal(called, 0); +}); + +test("shrinks the minimum to fit a small primary work area (#3647)", () => { + // 1366×768 at 125% Windows scaling leaves ~1093×585 logical px of work area. + const size = resolveWindowMinimumSize( + { getPrimaryDisplay: () => ({ workArea: { width: 1093, height: 585 } }) }, + 1100, + 640, + ); + assert.equal(size.width, Math.floor(1093 * 0.9)); + assert.equal(size.height, Math.floor(585 * 0.9)); + assert.ok(size.width < 1100 && size.height < 640); +}); + +test("never shrinks below a small usable floor", () => { + const size = resolveWindowMinimumSize( + { getPrimaryDisplay: () => ({ workArea: { width: 300, height: 200 } }) }, + 1100, + 640, + ); + assert.deepEqual(size, { width: 320, height: 240 }); +}); + +test("keeps the static minimums when screen is unavailable", () => { + assert.deepEqual(resolveWindowMinimumSize(null, 1100, 640), { width: 1100, height: 640 }); + assert.deepEqual(resolveWindowMinimumSize({}, 1100, 640), { width: 1100, height: 640 }); + assert.deepEqual( + resolveWindowMinimumSize( + { getPrimaryDisplay: () => { throw new Error("screen not ready"); } }, + 1100, + 640, + ), + { width: 1100, height: 640 }, + ); +}); + +test("createWindow clamps saved bounds and BrowserWindow minima to the fitted size", async () => { + const { EventEmitter } = require("node:events"); + const { createMainWindowApi } = require("./mainWindow.cjs"); + + let constructedOptions = null; + class Window extends EventEmitter { + constructor(options) { + super(); + constructedOptions = options; + this.destroyed = false; + this.webContents = Object.assign(new EventEmitter(), { + id: 71, + isDestroyed: () => this.destroyed, + setWindowOpenHandler() {}, + openDevTools() {}, + send() {}, + }); + } + close() {} + isDestroyed() { return this.destroyed; } + isMaximized() { return false; } + isFullScreen() { return false; } + getBounds() { return { width: 1000, height: 700 }; } + setBackgroundColor() {} + loadURL() { return Promise.resolve(); } + } + + const api = createMainWindowApi({ + mainWindow: null, electronApp: null, currentTheme: "light", isQuitting: false, + pendingWindowStateWrite: null, queuedWindowState: null, windowStateCloseRequested: false, + DEFAULT_WINDOW_WIDTH: 1400, DEFAULT_WINDOW_HEIGHT: 900, MIN_WINDOW_WIDTH: 1100, MIN_WINDOW_HEIGHT: 640, + V8_CACHE_OPTIONS: "code", THEME_COLORS: { light: {} }, + unhealthyWebContentsIds: new Set(), rendererReadySeenByWebContentsId: new Set(), + __dirname, URL, require, console, setTimeout, clearTimeout, + getGlobalShortcutBridge: () => ({ handleWindowClose: () => false }), + getMainWindowCount: () => 1, + debugLog() {}, resolveFrontendBackgroundColor() { return null; }, + loadWindowState() { return { width: 980, height: 560, x: 0, y: 0 }; }, + getDevRendererBaseUrl: (url) => url, getWindowBoundsState() { return null; }, + queueWindowStateSave() {}, saveWindowStateSync() {}, setupDeferredShow() {}, + createExternalOnlyWindowOpenHandler() { return {}; }, createAppWindowOpenHandler() { return {}; }, + attachOAuthLoadingOverlay() {}, registerWindowHandlers() {}, requestWindowCommandClose() {}, + shouldCloseWindowFromInput() { return false; }, + registerMainWindow() {}, unregisterMainWindow() {}, registerAppContentWindow() {}, unregisterAppContentWindow() {}, + closeSettingsWindow() {}, hideSettingsWindow() {}, applyWindowOpacityToWindow() {}, + }); + + await api.createWindow( + { + BrowserWindow: Window, + nativeTheme: {}, + app: {}, + screen: { + getPrimaryDisplay: () => ({ workArea: { width: 1093, height: 585 } }), + getAllDisplays: () => [], + }, + shell: {}, + ipcMain: {}, + }, + { + preload: "/tmp/preload.cjs", + devServerUrl: "http://localhost:5173", + isDev: true, + appIcon: null, + isMac: true, + electronDir: __dirname, + }, + ); + + assert.ok(constructedOptions); + assert.equal(constructedOptions.minWidth, Math.floor(1093 * 0.9)); + assert.equal(constructedOptions.minHeight, Math.floor(585 * 0.9)); + assert.equal(constructedOptions.width, Math.max(980, Math.floor(1093 * 0.9))); + assert.equal(constructedOptions.height, Math.max(560, Math.floor(585 * 0.9))); +}); diff --git a/electron/bridges/windowManager/windowsWindowChrome.test.cjs b/electron/bridges/windowManager/windowsWindowChrome.test.cjs index dd10579ad..eec36da58 100644 --- a/electron/bridges/windowManager/windowsWindowChrome.test.cjs +++ b/electron/bridges/windowManager/windowsWindowChrome.test.cjs @@ -60,6 +60,7 @@ test("main/settings/tray call sites wire Windows chrome helpers", () => { const main = readFileSync(path.join(here, "mainWindow.cjs"), "utf8"); const settings = readFileSync(path.join(here, "settingsWindow.cjs"), "utf8"); const popup = readFileSync(path.join(here, "terminalPopupWindow.cjs"), "utf8"); + const editor = readFileSync(path.join(here, "editorWindow.cjs"), "utf8"); const tray = readFileSync(path.join(here, "../globalShortcutBridge.cjs"), "utf8"); const css = readFileSync(path.join(here, "../../../index.css"), "utf8"); const html = readFileSync(path.join(here, "../../../index.html"), "utf8"); @@ -69,6 +70,7 @@ test("main/settings/tray call sites wire Windows chrome helpers", () => { ["mainWindow", main], ["settingsWindow", settings], ["terminalPopupWindow", popup], + ["editorWindow", editor], ]) { assert.match(source, /require\("\.\/windowsWindowChrome\.cjs"\)/, `${label} must require chrome helpers`); assert.match(source, /windowsFramelessContentChromeOptions/, `${label} must use content chrome helper`); @@ -76,7 +78,7 @@ test("main/settings/tray call sites wire Windows chrome helpers", () => { const requireIndex = source.indexOf('require("./windowsWindowChrome.cjs")'); const withIndex = source.indexOf("with (ctx)"); assert.ok( - requireIndex !== -1 && withIndex !== -1 && requireIndex < withIndex, + requireIndex !== -1 && (withIndex === -1 || requireIndex < withIndex), `${label}: require chrome helpers before with(ctx) so injected require cannot remount the path`, ); } diff --git a/electron/bridges/zmodemHelper.cjs b/electron/bridges/zmodemHelper.cjs index 8002d1b13..4af62cc6c 100644 --- a/electron/bridges/zmodemHelper.cjs +++ b/electron/bridges/zmodemHelper.cjs @@ -107,6 +107,11 @@ function buildModeRestores(dir, names, removeIndices, modes) { * Optional. When writeToRemote returns false, the upload loop awaits this * before sending more file data (typically waitForWritableDrain(stream)). * Callers should forward `opts.signal` so cancel can abort a blocked drain. + * @param {() => number} [opts.getTransportPendingBytes] + * Optional. Return the bytes the transport has queued but not delivered yet + * (e.g. the SSH channel backlog). Enables delivered-byte upload progress: + * while parked on backpressure the loop reports queued-minus-outstanding + * bytes so the progress indicator tracks actual delivery (issue #3663). * @param {() => import('electron').WebContents | null} opts.getWebContents * Returns the Electron WebContents for sending progress IPC events. * @param {string} [opts.label] @@ -131,6 +136,11 @@ function createZmodemSentry(opts) { const pendingEchoes = []; let pendingTerminalSuppression = null; let pendingTerminalSuppressionAt = 0; + let pendingHexHeaderFragment = null; + let pendingHexHeaderFragmentTimer = null; + // Set when a stripped hex header's trailing XON (0x11) may arrive in the + // next terminal-output chunk; at most one byte is then dropped. + let suppressNextTerminalXon = false; function setPendingTerminalSuppression(buf) { pendingTerminalSuppression = buf; @@ -152,6 +162,16 @@ function createZmodemSentry(opts) { const COOLDOWN_MS = 2000; const ECHO_TTL_MS = 1500; const ECHO_MAX_BYTES = 256; + // "**" + ZDLE + 'B': the start of every ZMODEM hex header. + const ZMODEM_HEX_HEADER_PREFIX = Buffer.from([0x2a, 0x2a, 0x18, 0x42]); + // 4-byte pattern + 14 hex digits + CR is the most a still-incomplete + // header can look like before the final LF (plus optional XON) arrives. + const ZMODEM_HEX_HEADER_MAX_PARTIAL = 19; + // How long a withheld partial-header fragment may sit unresolved before + // it is flushed through to the terminal anyway. + const HEX_HEADER_FRAGMENT_TTL_MS = Number.isFinite(opts.hexHeaderFragmentTtlMs) + ? Math.max(1, opts.hexHeaderFragmentTtlMs) + : 1000; /** * Upper bound on how long a drag-drop echo suppression may stay armed. * The suppression hides the terminal echo of the upload command, whose echo @@ -240,6 +260,14 @@ function createZmodemSentry(opts) { return buf; } + function isHexDigitByte(byte) { + return ( + (byte >= 0x30 && byte <= 0x39) + || (byte >= 0x41 && byte <= 0x46) + || (byte >= 0x61 && byte <= 0x66) + ); + } + function stripVisibleZmodemHeaders(data) { let buf = Buffer.isBuffer(data) ? data : Buffer.from(data); let searchFrom = 0; @@ -253,12 +281,7 @@ function createZmodemSentry(opts) { let isHexHeader = true; for (let i = 0; i < 14; i += 1) { - const byte = buf[prefixAt + 4 + i]; - const isHexDigit = - (byte >= 0x30 && byte <= 0x39) || - (byte >= 0x41 && byte <= 0x46) || - (byte >= 0x61 && byte <= 0x66); - if (!isHexDigit) { + if (!isHexDigitByte(buf[prefixAt + 4 + i])) { isHexHeader = false; break; } @@ -274,6 +297,10 @@ function createZmodemSentry(opts) { headerLength = 20; if (buf[prefixAt + 20] === 0x11) { headerLength = 21; + } else if (prefixAt + 20 === buf.length) { + // The optional XON that follows the CR/LF may arrive in the next + // chunk; strip at most one leading XON byte from it (#3651). + suppressNextTerminalXon = true; } } @@ -287,6 +314,119 @@ function createZmodemSentry(opts) { return buf; } + function flushPendingHexHeaderFragment() { + pendingHexHeaderFragmentTimer = null; + const buf = pendingHexHeaderFragment; + pendingHexHeaderFragment = null; + if (buf?.length) onData(buf); + } + + /** + * A ZMODEM hex header (the ZRQINIT/ZRINIT preamble of `sz`/`rz`) can be + * split across transport chunks. zmodem.js hands each original chunk to + * to_terminal verbatim and only complete headers can be stripped, so a + * chunk that ends in the middle of the preamble painted it as terminal + * garble (#3651). Detect the trailing bytes that may still form one and + * hold them back; the next chunk either completes the header (stripped + * above) or proves the buffer invalid. A TTL guard flushes held bytes so + * deferrals can never lose legitimate terminal output. + * + * Returns the index from which the buffer should be withheld, or -1. + */ + function findTrailingHexHeaderFragment(buf) { + if (!buf.length) return -1; + + // Longest still-incomplete header tail: the 4-byte pattern, 14 hex + // digits, and a CR — i.e. everything up to the final LF (+ optional XON). + const windowStart = Math.max(0, buf.length - ZMODEM_HEX_HEADER_MAX_PARTIAL); + + for (let i = windowStart; i <= buf.length - 4; i += 1) { + let matched = true; + for (let j = 0; j < 4; j += 1) { + if (buf[i + j] !== ZMODEM_HEX_HEADER_PREFIX[j]) { + matched = false; + break; + } + } + if (!matched) continue; + + let digits = 0; + while (i + 4 + digits < buf.length && isHexDigitByte(buf[i + 4 + digits])) { + digits += 1; + } + + // Chunk ends inside the hex digits — the header may still complete. + if (i + 4 + digits === buf.length) return i; + + // Digit run ended before the 14 required digits, or overshoots them: + // not a hex header. + if (digits !== 14) continue; + + // A CR (but not yet the trailing LF) may still complete the header. + if (buf.length - i <= 19) return i; + + break; + } + + // The "**" + ZDLE + 'B' pattern itself may be split: withhold any + // trailing bytes that are a prefix of it until the next chunk resolves. + for (let k = Math.min(3, buf.length); k >= 1; k -= 1) { + let matches = true; + for (let j = 0; j < k; j += 1) { + if (buf[buf.length - k + j] !== ZMODEM_HEX_HEADER_PREFIX[j]) { + matches = false; + break; + } + } + if (matches) return buf.length - k; + } + + return -1; + } + + /** + * Join any previously withheld partial-header bytes onto the new chunk so + * the suppression/stripping stages can see a complete header again. This + * must run BEFORE the stripping stages, or a header split across two + * chunks would be judged complete too late and reach the terminal. + */ + function assembleTerminalChunk(octets) { + const buf = Buffer.isBuffer(octets) ? octets : Buffer.from(octets); + if (!pendingHexHeaderFragment?.length) { + return buf; + } + if (pendingHexHeaderFragmentTimer) { + clearTimeout(pendingHexHeaderFragmentTimer); + pendingHexHeaderFragmentTimer = null; + } + const merged = Buffer.concat([pendingHexHeaderFragment, buf]); + pendingHexHeaderFragment = null; + return merged; + } + + /** + * Withhold a trailing partial hex header (if any) and arm a timer that + * flushes it when no more bytes arrive to resolve it. + */ + function deferIncompleteHexHeader(data) { + const buf = Buffer.isBuffer(data) ? data : Buffer.from(data); + + const fragmentAt = findTrailingHexHeaderFragment(buf); + if (fragmentAt === -1) return buf; + + if (fragmentAt < buf.length) { + pendingHexHeaderFragment = Buffer.from(buf.subarray(fragmentAt)); + if (!pendingHexHeaderFragmentTimer) { + pendingHexHeaderFragmentTimer = setTimeout( + flushPendingHexHeaderFragment, + HEX_HEADER_FRAGMENT_TTL_MS, + ); + } + } + + return fragmentAt ? buf.subarray(0, fragmentAt) : Buffer.alloc(0); + } + function looksLikeResidualZmodemData(data) { const buf = Buffer.isBuffer(data) ? data : Buffer.from(data); if (!buf.length) return true; @@ -453,8 +593,19 @@ function createZmodemSentry(opts) { const sentry = new Zmodem.Sentry({ to_terminal(octets) { // Normal data – pass raw bytes to the caller for charset-aware decoding. - let sanitizedOctets = stripPendingTerminalSuppression(Buffer.from(octets)); + // Bytes that may be the start of a ZMODEM hex header split across + // chunks are withheld first (and later stripped or flushed) so the + // preamble of sz/rz never reaches the terminal as garble (#3651). + let sanitizedOctets = assembleTerminalChunk(octets); + if (suppressNextTerminalXon) { + suppressNextTerminalXon = false; + if (sanitizedOctets[0] === 0x11) { + sanitizedOctets = sanitizedOctets.subarray(1); + } + } + sanitizedOctets = stripPendingTerminalSuppression(sanitizedOctets); sanitizedOctets = stripVisibleZmodemHeaders(sanitizedOctets); + sanitizedOctets = deferIncompleteHexHeader(sanitizedOctets); if (!sanitizedOctets.length) return; onData(sanitizedOctets); }, @@ -766,6 +917,8 @@ const UPLOAD_DRAIN_TIMEOUT_MS = 60000; const UPLOAD_CHUNK_SIZE = 64 * 1024; /** Default interval between non-final upload progress IPC events. */ const DEFAULT_UPLOAD_PROGRESS_THROTTLE_MS = 100; +/** Interval between delivered-byte progress reports while parked on drain. */ +const UPLOAD_DRAIN_PROGRESS_INTERVAL_MS = 100; function resolveTimeoutMs(value, fallback) { return Number.isFinite(value) && value >= 0 ? value : fallback; @@ -981,6 +1134,76 @@ function createZmodemUploadDrainWaiter(opts) { }; } +/** + * Park the upload loop on transport backpressure while keeping progress + * updates alive (issue #3663). + * + * Once a large write backlog fills the transport, the upload loop otherwise + * sits silent on the "drain" event while the transport keeps delivering the + * queued backlog at full link speed: on SSH the entire channel window drains + * between two upload-loop steps, so the progress indicator freezes mid-file + * and jumps when the loop resumes. While the drain wait stays pending, poll + * it and let the caller emit progress events showing bytes the transport has + * actually accepted instead. + * + * Falls back to a plain drain wait when the transport cannot report its + * outstanding byte count. Rejections (timeout, cancellation) propagate. + * + * @param {{ + * waitForDrain?: () => Promise, + * getTransportPendingBytes?: () => unknown, + * progressThrottleMs?: number, + * }} opts + * @param {() => void} emitProgress Called at most once per throttle interval + * while the drain wait stays pending. + * @returns {Promise} + */ +async function awaitUploadDrainWithProgress(opts, emitProgress) { + if (!opts.waitForDrain) return; + if (typeof opts.getTransportPendingBytes !== "function") { + await opts.waitForDrain(); + return; + } + + let settled = false; + const drain = opts.waitForDrain().finally(() => { + settled = true; + }); + drain.catch(() => {}); // observed at the final await; never surface early + // A drain wait without pending backpressure resolves after one event-loop + // turn; yield once so backpressure-free uploads do not gain polling delay. + await new Promise((resolve) => setImmediate(resolve)); + if (settled) { + await drain; + return; + } + + const intervalMs = Math.max( + UPLOAD_DRAIN_PROGRESS_INTERVAL_MS, + resolveProgressThrottleMs(opts.progressThrottleMs), + ); + // Parked: report accepted bytes right away, then once per interval while + // the transport drains its backlog. Progress emissions must never break + // (or slow) the transfer itself. + try { emitProgress(); } catch { /* ignore */ } + // Race each poll delay against drain. A wait that settles mid-interval + // must resume the upload immediately; sleeping out the rest of the + // interval throttles every backpressure cycle by up to ~100ms. + while (!settled) { + let timer; + await Promise.race([ + drain.then(() => {}, () => {}), + new Promise((resolve) => { + timer = setTimeout(resolve, intervalMs); + }), + ]); + clearTimeout(timer); + if (settled) break; + try { emitProgress(); } catch { /* ignore */ } + } + await drain; +} + /** * Resolve when `promise` settles, or reject with NETCATTY_ZMODEM_CANCELLED * if `signal` aborts first. Used so cancel unblocks upload drain waits even @@ -1183,19 +1406,42 @@ async function handleUpload(zsession, opts) { const { originalIndex, filePath, stat, name } = offers[i]; opts.resetUploadBackpressure?.(); - safeSend(getWebContents(), "netcatty:zmodem:progress", { - sessionId, - filename: name, - transferred: 0, - total: stat.size, - fileIndex: i, - fileCount: offers.length, - transferType: "upload", - }); - let bytesRemaining = 0; for (let j = i; j < offers.length; j++) bytesRemaining += offers[j].stat.size; + // Progress should track bytes the transport accepted, not just bytes + // queued (#3663): on SSH the channel can buffer its whole window before + // write() returns false, so queued-only reporting freezes the progress + // bar while the backlog drains at link speed. When the transport can + // report outstanding bytes, show `sent - outstanding` and never move + // the bar backwards. + const getOutstandingBytes = typeof opts.getTransportPendingBytes === "function" + ? opts.getTransportPendingBytes + : null; + let sent = 0; + let displayedTransferred = 0; + const emitFileProgress = () => { + const outstanding = getOutstandingBytes ? Number(getOutstandingBytes()) : NaN; + const delivered = Number.isFinite(outstanding) + ? sent - Math.max(outstanding, 0) + : sent; + displayedTransferred = Math.min( + Math.max(delivered, displayedTransferred), + stat.size, + ); + safeSend(getWebContents(), "netcatty:zmodem:progress", { + sessionId, + filename: name, + transferred: displayedTransferred, + total: stat.size, + fileIndex: i, + fileCount: offers.length, + transferType: "upload", + }); + }; + + emitFileProgress(); + // The offer handshake is the only upload step without a built-in // deadline: if rz dies before answering ZFILE (crash, YMODEM fallback), // send_offer() would park this loop forever and block all future @@ -1223,7 +1469,6 @@ async function handleUpload(zsession, opts) { // Read and send in chunks const fd = fs.openSync(filePath, "r"); const buf = Buffer.alloc(UPLOAD_CHUNK_SIZE); - let sent = 0; // Progress IPC is throttled by default for every transport. Pass 0 only // when a caller explicitly needs one event per chunk. const progressThrottleMs = resolveProgressThrottleMs(opts.progressThrottleMs); @@ -1243,21 +1488,16 @@ async function handleUpload(zsession, opts) { const now = Date.now(); if (progressThrottleMs === 0 || now - lastProgressEmitAt >= progressThrottleMs) { - safeSend(getWebContents(), "netcatty:zmodem:progress", { - sessionId, - filename: name, - transferred: sent, - total: stat.size, - fileIndex: i, - fileCount: offers.length, - transferType: "upload", - }); + emitFileProgress(); lastProgressEmitAt = now; } - // Wait for transport to drain if its buffer is full, then yield - // so inbound ZMODEM control frames can be processed. - if (opts.waitForDrain) await opts.waitForDrain(); + // Wait for transport to drain if its buffer is full (emitting + // delivered-byte progress while parked, #3663), then yield so + // inbound ZMODEM control frames can be processed. + if (opts.waitForDrain) { + await awaitUploadDrainWithProgress(opts, emitFileProgress); + } await yieldToIO(); } // All data written to Node.js buffer — but TCP may still be @@ -1542,6 +1782,8 @@ module.exports = { handleDownload, waitForWritableDrain, createZmodemUploadDrainWaiter, + awaitUploadDrainWithProgress, UPLOAD_CHUNK_SIZE, UPLOAD_DRAIN_TIMEOUT_MS, + UPLOAD_DRAIN_PROGRESS_INTERVAL_MS, }; diff --git a/electron/bridges/zmodemHelper.test.cjs b/electron/bridges/zmodemHelper.test.cjs index a307edf0a..640fdb09f 100644 --- a/electron/bridges/zmodemHelper.test.cjs +++ b/electron/bridges/zmodemHelper.test.cjs @@ -11,6 +11,7 @@ const { handleDownload, waitForWritableDrain, createZmodemUploadDrainWaiter, + awaitUploadDrainWithProgress, UPLOAD_CHUNK_SIZE, UPLOAD_DRAIN_TIMEOUT_MS, } = require("./zmodemHelper.cjs"); @@ -422,6 +423,282 @@ test("handleUpload does not read the next chunk until transport backpressure cle fs.rmSync(tempDir, { recursive: true, force: true }); }); +test("handleUpload reports transport-accepted bytes instead of the queued backlog", async () => { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-zmodem-delivered-")); + const filePath = path.join(tempDir, "upload.bin"); + const size = UPLOAD_CHUNK_SIZE * 2; + fs.writeFileSync(filePath, Buffer.alloc(size, 0x5a)); + const progress = []; + + const zsession = { + async send_offer() { + return { + send() {}, + async end() {}, + }; + }, + async close() {}, + }; + + await handleUpload(zsession, { + sessionId: "session-1", + getWebContents: () => ({ + isDestroyed: () => false, + send(channel, data) { + if (channel === "netcatty:zmodem:progress") progress.push(data); + }, + }), + takeDragDropUpload: () => ({ + filePaths: [filePath], + remoteNames: ["upload.bin"], + }), + progressThrottleMs: 0, + // 4096 bytes of every queued amount are still outstanding in the transport. + getTransportPendingBytes: () => 4096, + }); + + assert.deepEqual( + progress.map((event) => [event.transferred, Boolean(event.finalizing)]), + [ + [0, false], + [UPLOAD_CHUNK_SIZE - 4096, false], + [size - 4096, false], + [size, true], + ], + ); + fs.rmSync(tempDir, { recursive: true, force: true }); +}); + +test("handleUpload emits delivered-byte progress while parked on transport drain", async () => { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-zmodem-park-")); + const filePath = path.join(tempDir, "upload.bin"); + const size = UPLOAD_CHUNK_SIZE * 2 + 1; + fs.writeFileSync(filePath, Buffer.alloc(size, 0x5a)); + const progress = []; + // Bytes queued into the transport (never consumed; delivery is simulated + // below on the pending-byte readout). + let outstanding = 0; + let drainStartedAt = null; + let releaseFirstDrain; + const firstDrain = new Promise((resolve) => { + releaseFirstDrain = resolve; + }); + let drainCalls = 0; + + const zsession = { + async send_offer() { + return { + send(chunk) { + outstanding += chunk.byteLength; + }, + async end() {}, + }; + }, + async close() {}, + }; + + const upload = handleUpload(zsession, { + sessionId: "session-1", + getWebContents: () => ({ + isDestroyed: () => false, + send(channel, data) { + if (channel === "netcatty:zmodem:progress") progress.push(data); + }, + }), + takeDragDropUpload: () => ({ + filePaths: [filePath], + remoteNames: ["upload.bin"], + }), + progressThrottleMs: 0, + getTransportPendingBytes: () => { + if (drainStartedAt === null) return outstanding; + // The backlog drains at 512 B/ms once the parked wait begins. + const delivered = Math.min(outstanding, (Date.now() - drainStartedAt) * 512); + return outstanding - delivered; + }, + async waitForDrain() { + drainCalls += 1; + if (drainCalls === 1) { + drainStartedAt = Date.now(); + await firstDrain; + } + }, + }); + + while (drainCalls === 0) { + await new Promise((resolve) => setImmediate(resolve)); + } + const progressedBefore = progress.length; + await new Promise((resolve) => setTimeout(resolve, 250)); + const parkedEvents = progress.slice(progressedBefore); + releaseFirstDrain(); + await upload; + + // While parked, delivered-byte progress must keep flowing (previously the + // indicator stayed frozen until the whole backlog drained). + assert.ok( + parkedEvents.some((event) => event.transferred > 0 && !event.finalizing), + `expected delivered-byte emissions while parked, got ${JSON.stringify(parkedEvents.map((e) => [e.transferred, Boolean(e.finalizing)]))}`, + ); + + const values = progress.map((event) => event.transferred); + for (let k = 1; k < values.length; k++) { + assert.ok( + values[k] >= values[k - 1], + `progress must never move backwards: ${values.join(",")}`, + ); + assert.ok(values[k] <= size, `progress must not exceed the file size: ${values.join(",")}`); + } + assert.equal(progress.at(-1).finalizing, true); + fs.rmSync(tempDir, { recursive: true, force: true }); +}); + +test("awaitUploadDrainWithProgress falls back to a plain drain wait without pending-byte accounting", async () => { + let drainCalls = 0; + await awaitUploadDrainWithProgress( + { + waitForDrain: () => { + drainCalls += 1; + return Promise.resolve(); + }, + }, + () => { + throw new Error("no progress emissions without pending-byte accounting"); + }, + ); + assert.equal(drainCalls, 1); +}); + +test("awaitUploadDrainWithProgress adds no polling delay when drain resolves immediately", async () => { + const startedAt = Date.now(); + await awaitUploadDrainWithProgress( + { + waitForDrain: () => Promise.resolve(), + getTransportPendingBytes: () => 0, + }, + () => { + throw new Error("no emissions without parked backpressure"); + }, + ); + assert.ok(Date.now() - startedAt < 50); +}); + +test("awaitUploadDrainWithProgress returns promptly when drain settles during the poll interval", async () => { + let releaseDrain; + const drainGate = new Promise((resolve) => { + releaseDrain = resolve; + }); + let parked = false; + const done = awaitUploadDrainWithProgress( + { + waitForDrain: () => drainGate, + getTransportPendingBytes: () => 2048, + }, + () => { + parked = true; + }, + ); + + const waitStarted = Date.now(); + while (!parked) { + if (Date.now() - waitStarted > 1000) { + throw new Error("upload drain wait never parked"); + } + await new Promise((resolve) => setImmediate(resolve)); + } + // The immediate park emission has run and the poll timer (~100ms) is armed. + // Settle on the next turn, still inside that interval. + await new Promise((resolve) => setImmediate(resolve)); + const releasedAt = Date.now(); + releaseDrain(); + await done; + const elapsed = Date.now() - releasedAt; + assert.ok( + elapsed < 50, + `drain settlement during a poll interval resumed in ${elapsed}ms`, + ); +}); + +test("awaitUploadDrainWithProgress propagates a rejection that arrives during the poll interval", async () => { + let rejectDrain; + const drainGate = new Promise((_, reject) => { + rejectDrain = reject; + }); + const err = new Error("Transport drain timeout"); + err.code = "NETCATTY_ZMODEM_TIMEOUT"; + let parked = false; + const done = awaitUploadDrainWithProgress( + { + waitForDrain: () => drainGate, + getTransportPendingBytes: () => 2048, + }, + () => { + parked = true; + }, + ); + + const waitStarted = Date.now(); + while (!parked) { + if (Date.now() - waitStarted > 1000) { + throw new Error("upload drain wait never parked"); + } + await new Promise((resolve) => setImmediate(resolve)); + } + await new Promise((resolve) => setImmediate(resolve)); + const releasedAt = Date.now(); + rejectDrain(err); + await assert.rejects(done, (caught) => caught === err); + const elapsed = Date.now() - releasedAt; + assert.ok( + elapsed < 50, + `drain rejection during a poll interval resumed in ${elapsed}ms`, + ); +}); + +test("awaitUploadDrainWithProgress emits progress while parked until drain settles", async () => { + let releaseDrain; + const drainGate = new Promise((resolve) => { + releaseDrain = resolve; + }); + let displayed = 0; + const emitted = []; + const deliverTimer = setInterval(() => { + displayed += 512; + }, 40); + + try { + const parked = awaitUploadDrainWithProgress( + { + waitForDrain: () => drainGate, + getTransportPendingBytes: () => 1024, + }, + () => emitted.push(displayed), + ); + await new Promise((resolve) => setTimeout(resolve, 280)); + assert.ok(emitted.length >= 2, `expected poll emissions, got ${emitted.length}`); + assert.ok(emitted.every((v) => v === 0 || v % 512 === 0)); + releaseDrain(); + await parked; + } finally { + clearInterval(deliverTimer); + } +}); + +test("awaitUploadDrainWithProgress propagates drain-wait rejections", async () => { + const err = new Error("Transport drain timeout"); + err.code = "NETCATTY_ZMODEM_TIMEOUT"; + await assert.rejects( + awaitUploadDrainWithProgress( + { + waitForDrain: () => Promise.reject(err), + getTransportPendingBytes: () => 0, + }, + () => {}, + ), + (caught) => caught === err, + ); +}); + test("handleUpload throttles progress IPC by default for every transport", async () => { const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-zmodem-progress-")); const filePath = path.join(tempDir, "large-upload.bin"); @@ -1489,3 +1766,132 @@ test("createZmodemUploadDrainWaiter falls back to a single yield without transpo await waitForDrain(); assert.equal(needsDrain, false); }); + +// --------------------------------------------------------------------------- +// Split hex-header preamble (#3651): the ZRQINIT/ZRINIT hex header that opens +// every sz/rz session can be fragmented across transport chunks; it must +// never reach the terminal as garble, and withheld bytes must never be lost. +// --------------------------------------------------------------------------- + +const ZmodemLib = require("zmodem.js"); + +const DOWNLOAD_PREAMBLE_TEXT = "sz: Sending test.bin\r\nrz\r"; + +function buildHexHeaderBytes(name, args) { + return Buffer.from(ZmodemLib.Header.build(name, ...(args || [])).to_hex()); +} + +function createProbeSentry(extra) { + const terminal = []; + const sentry = createZmodemSentry({ + sessionId: "probe", + onData: (buf) => terminal.push(Buffer.from(buf)), + writeToRemote: () => true, + getWebContents: () => null, + selectDownloadDirectory: async () => ({ canceled: true }), + selectUploadFiles: async () => null, + hexHeaderFragmentTtlMs: 30, + ...extra, + }); + return { sentry, terminal }; +} + +function joinedText(terminal) { + return Buffer.concat(terminal).toString("latin1"); +} + +function assertNoZmodemHeaderBytes(terminal, context) { + const joined = Buffer.concat(terminal); + assert.equal( + joined.indexOf(Buffer.from([0x2a, 0x2a, 0x18, 0x42])), + -1, + `${context}: ZRQINIT/ZRINIT preamble leaked to the terminal`, + ); + assert.equal( + joined.includes(Buffer.from([0x18])), + false, + `${context}: ZDLE/CAN byte leaked to the terminal`, + ); +} + +test("fragmented sz ZRQINIT preamble never reaches the terminal", () => { + const { sentry, terminal } = createProbeSentry(); + const stream = Buffer.concat([ + Buffer.from(DOWNLOAD_PREAMBLE_TEXT), + buildHexHeaderBytes("ZRQINIT"), + Buffer.from("$ "), + ]); + + for (const byte of stream) sentry.consume(Buffer.from([byte])); + + assert.equal( + joinedText(terminal), + `${DOWNLOAD_PREAMBLE_TEXT}$ `, + "only the human-readable sz preamble text should be shown", + ); + assertNoZmodemHeaderBytes(terminal, "byte-at-a-time"); +}); + +test("split sz preamble leaks nothing at any chunk boundary", () => { + const header = buildHexHeaderBytes("ZRQINIT"); + // Header.build().to_hex() already includes the trailing XON byte. + const stream = Buffer.concat([ + Buffer.from(DOWNLOAD_PREAMBLE_TEXT), + header, + Buffer.from("$ "), + ]); + + for (let chunkSize = 1; chunkSize <= 24; chunkSize += 1) { + const { sentry, terminal } = createProbeSentry(); + for (let i = 0; i < stream.length; i += chunkSize) { + sentry.consume(stream.subarray(i, i + chunkSize)); + } + assert.equal(joinedText(terminal), `${DOWNLOAD_PREAMBLE_TEXT}$ `); + assertNoZmodemHeaderBytes(terminal, `chunkSize=${chunkSize}`); + } +}); + +test("fragmented rz ZRINIT preamble never reaches the terminal", () => { + const header = buildHexHeaderBytes("ZRINIT", [["CANFDX", "CANOVIO", "CANFC32"]]); + const stream = Buffer.concat([Buffer.from("root@host:~$ "), header]); + + for (let chunkSize = 1; chunkSize <= 24; chunkSize += 1) { + const { sentry, terminal } = createProbeSentry(); + for (let i = 0; i < stream.length; i += chunkSize) { + sentry.consume(stream.subarray(i, i + chunkSize)); + } + assert.equal( + joinedText(terminal), + "root@host:~$ ", + `split ZRINIT preamble leaked for chunkSize=${chunkSize}`, + ); + assertNoZmodemHeaderBytes(terminal, `rz chunkSize=${chunkSize}`); + } +}); + +test("bytes withheld as a header fragment are flushed after the TTL", async () => { + const { sentry, terminal } = createProbeSentry(); + const stream = Buffer.from("hello *"); + for (const byte of stream) sentry.consume(Buffer.from([byte])); + + // The trailing "*" may be the start of a hex header, so it is withheld. + assert.equal(joinedText(terminal), "hello "); + + await new Promise((resolve) => setTimeout(resolve, 80)); + assert.equal(joinedText(terminal), "hello *", "TTL flush must not lose terminal output"); +}); + +test("output that disproves a header fragment is emitted without delay", () => { + const stream = Buffer.from("text **\x18Bxy more"); + for (let chunkSize = 1; chunkSize <= 6; chunkSize += 1) { + const { sentry, terminal } = createProbeSentry(); + for (let i = 0; i < stream.length; i += chunkSize) { + sentry.consume(stream.subarray(i, i + chunkSize)); + } + assert.equal( + joinedText(terminal), + "text **\x18Bxy more", + "a non-hex byte after the pattern stops the deferral", + ); + } +}); diff --git a/electron/capabilities/catalog/vault.cjs b/electron/capabilities/catalog/vault.cjs index b83c2e450..f3bef7405 100644 --- a/electron/capabilities/catalog/vault.cjs +++ b/electron/capabilities/catalog/vault.cjs @@ -48,7 +48,7 @@ const VAULT_CAPABILITIES = [ domain: "vault", status: CAPABILITY_STATUS.IMPLEMENTED, description: - "Open a vault host by creating a new terminal tab and starting the connection. Returns the new sessionId so you can run terminal/SFTP tools against it. Use vault_hosts_list first when you only know the label or hostname.", + "Open a vault host by reusing its existing session when its connection settings still match (connected or connecting, including auto-reconnecting — same sessionId, no duplicate tab). Only opens a new terminal tab when no usable session for this host exists, or when newTab is true. Inactive disconnected and restored tabs are not reused. Returns the sessionId so you can run terminal/SFTP tools against it. Use vault_hosts_list first when you only know the label or hostname.", // Sidebar Catty is scoped to already-open terminals/workspaces and must not // expand that scope mid-turn. Keep host_open for MCP / CLI / global agent. agentKinds: [AGENT_KINDS.GLOBAL], diff --git a/electron/capabilities/schemas/toolInputs.cjs b/electron/capabilities/schemas/toolInputs.cjs index fe911c681..8b770004a 100644 --- a/electron/capabilities/schemas/toolInputs.cjs +++ b/electron/capabilities/schemas/toolInputs.cjs @@ -83,6 +83,7 @@ const TOOL_INPUT_FIELDS = Object.freeze({ "vault.host.list": {}, "vault.host.open": { hostId: { type: "string", description: "Vault host ID to open. Use vault_hosts_list / host_get to resolve id from label or hostname." }, + newTab: { type: "string", optional: true, description: "true or false. Force a brand-new tab even when a session for this host already exists. Default false — reuse the existing session." }, }, "vault.hosts.create": { hosts: { @@ -420,7 +421,7 @@ const MODEL_DESCRIPTION_HINTS = Object.freeze({ "vault.host.notes.set": "Host metadata notes on a saved host — not Vault → Notes sidebar entries. Prefer vault_notes_create/update when the user wants vault notes they can open in the Notes sidebar.", "vault.host.open": - "Opens a terminal tab for a saved vault host (same as clicking the host in Netcatty). Connection may still be establishing when the tool returns — use get_environment or wait briefly before terminal_execute if needed. Call session_close with the returned sessionId when the task is finished. Auth prompts (passphrase / keyboard-interactive) still require the user in the Netcatty UI.", + "Reuse-first: if this hostId already has a connected or connecting session with matching connection settings (including one auto-reconnecting after a drop, which keeps the same sessionId), that sessionId is returned and no new tab is opened — retry terminal_execute against it rather than reopening the host. Open a new tab only when get_environment shows no session for the host, when the task truly needs a parallel connection, or with newTab true. Call session_close when finished only for a newly opened session (reused is not true). A reused session keeps its original owner; do not close it as task cleanup. Disconnected or restored tabs needing user action are not reused. Connection may still be establishing when the tool returns — use get_environment or wait briefly before terminal_execute if needed. Auth prompts (passphrase / keyboard-interactive) still require the user in the Netcatty UI.", "vault.host.list": "Use when the user names a saved Netcatty host but get_environment has no matching live session. Resolve the hostId here, call host_open, then call get_environment to obtain the new sessionId.", "vault.host.import": diff --git a/electron/capabilities/services/vaultService.cjs b/electron/capabilities/services/vaultService.cjs index 9650f6337..1b274c560 100644 --- a/electron/capabilities/services/vaultService.cjs +++ b/electron/capabilities/services/vaultService.cjs @@ -47,6 +47,7 @@ function createVaultService(ctx = {}) { return invokeVaultAgent("host.open", { hostId: params.hostId, chatSessionId: params.chatSessionId, + newTab: params.newTab, }); }, createHosts: async (params = {}) => { diff --git a/electron/capabilities/services/vaultService.test.cjs b/electron/capabilities/services/vaultService.test.cjs index 14aa81f47..68996177d 100644 --- a/electron/capabilities/services/vaultService.test.cjs +++ b/electron/capabilities/services/vaultService.test.cjs @@ -36,10 +36,11 @@ test("vault service delegates host open to vault agent bridge", async () => { return { ok: true, sessionId: "sess-1", hostId: params.hostId, status: "connecting" }; }, }); - const result = await service.openHost({ hostId: "host-1", chatSessionId: "chat-1" }); + const result = await service.openHost({ hostId: "host-1", chatSessionId: "chat-1", newTab: "true" }); assert.equal(invokedOp, "host.open"); assert.equal(invokedParams.hostId, "host-1"); assert.equal(invokedParams.chatSessionId, "chat-1"); + assert.equal(invokedParams.newTab, "true"); assert.equal(result.ok, true); assert.equal(result.sessionId, "sess-1"); }); diff --git a/electron/main/registerBridges.cjs b/electron/main/registerBridges.cjs index 5a5c648d1..210a1706d 100644 --- a/electron/main/registerBridges.cjs +++ b/electron/main/registerBridges.cjs @@ -788,6 +788,81 @@ function createBridgeRegistrar(context) { return { success: false, error: err?.message || "Failed to open terminal popup" }; } }); + + ipcMain.handle("netcatty:window:openEditor", async (event, payload) => { + try { + const sourceWindow = BrowserWindow.fromWebContents(event.sender); + return await getWindowManager().openEditorWindow(electronModule, { + preload, + devServerUrl: effectiveDevServerUrl, + isDev, + appIcon: getAppIconPath(), + isMac, + electronDir, + sourceWindow, + sourceWebContents: event.sender, + }, payload); + } catch (err) { + console.error("[Main] Failed to open editor window:", err); + return { success: false, error: err?.message || "Failed to open editor window" }; + } + }); + + ipcMain.handle("netcatty:window:focusEditor", async (_event, payload) => { + try { + return getWindowManager().focusEditorTab(electronModule, payload?.editorId); + } catch (err) { + return { success: false, error: err?.message || "Failed to focus editor window" }; + } + }); + + ipcMain.handle("netcatty:window:closeEditorTabs", async (_event, payload) => { + try { + return await getWindowManager().closeEditorWindowTabs(electronModule, payload); + } catch (err) { + return { success: false, cancelled: true, closedIds: [], error: err?.message || "Failed to close editor tabs" }; + } + }); + + ipcMain.handle("netcatty:editorWindow:save", async (_event, payload) => { + try { + return await getWindowManager().saveEditorWindowTab(electronModule, payload); + } catch (err) { + return { ok: false, error: err?.message || "Save failed" }; + } + }); + + ipcMain.handle("netcatty:editorWindow:dock", async (_event, payload) => { + try { + return await getWindowManager().dockEditorTab(electronModule, payload); + } catch (err) { + return { success: false, error: err?.message || "Failed to dock editor tab" }; + } + }); + + ipcMain.on("netcatty:editorWindow:dirty", (_event, payload) => { + try { + getWindowManager().reportEditorDirty(electronModule, payload); + } catch { + // ignore + } + }); + + ipcMain.on("netcatty:editorWindow:tabsClosed", (_event, payload) => { + try { + getWindowManager().reportEditorTabsClosed(electronModule, payload); + } catch { + // ignore + } + }); + + ipcMain.on("netcatty:editorWindow:remapSession", (_event, payload) => { + try { + getWindowManager().remapEditorSession(electronModule, payload); + } catch { + // ignore + } + }); // Cloud sync master password (stored in-memory + persisted via safeStorage) ipcMain.handle("netcatty:cloudSync:session:setPassword", async (_event, password) => { diff --git a/electron/preload.cjs b/electron/preload.cjs index 4ce18b29a..b6f94f6d2 100644 --- a/electron/preload.cjs +++ b/electron/preload.cjs @@ -63,6 +63,10 @@ const terminalPopupConfigState = { lastPayload: null, listeners: new Set(), }; +const editorOpenTabState = { + pending: [], + listeners: new Set(), +}; function dispatchGlobalSftpTransferEvent(payload) { for (const cb of globalSftpTransferListeners) { @@ -411,6 +415,19 @@ ipcRenderer.on("netcatty:window:terminalPopupConfig", (_event, payload) => { } }); }); +ipcRenderer.on("netcatty:window:editorOpenTab", (_event, payload) => { + if (editorOpenTabState.listeners.size === 0) { + editorOpenTabState.pending.push(payload); + return; + } + editorOpenTabState.listeners.forEach((cb) => { + try { + cb(payload); + } catch (err) { + console.error("Editor window open-tab callback failed", err); + } + }); +}); ipcRenderer.on("netcatty:zmodem:progress", (_event, payload) => { if (closedTerminalDataSessions.has(payload.sessionId)) return; const set = zmodemListeners.get(payload.sessionId); @@ -879,6 +896,7 @@ const api = createPreloadApi({ updateErrorListeners, updateNeedsSaveListeners, terminalPopupConfigState, + editorOpenTabState, portForwardStatusListeners, portForwardRuntimeListeners, fileWatchSyncedListeners, diff --git a/electron/preload/api.cjs b/electron/preload/api.cjs index c6b125b0b..7d0d34d35 100644 --- a/electron/preload/api.cjs +++ b/electron/preload/api.cjs @@ -293,6 +293,13 @@ function createPreloadApi(ctx) { markTerminalDataSessionOpen(result?.sessionId); return result.sessionId; }, + testConnection: async (options) => { + const result = await ipcRenderer.invoke("netcatty:test-connection", options); + return result.sessionId; + }, + cancelTestConnection: async (sessionId) => { + return ipcRenderer.invoke("netcatty:test-connection:cancel", sessionId); + }, startTelnetSession: async (options) => { markRequestedTerminalDataSessionOpen(options); const result = await ipcRenderer.invoke("netcatty:telnet:start", options); @@ -1116,6 +1123,79 @@ function createPreloadApi(ctx) { ), setWindowTitle: (title) => ipcRenderer.invoke("netcatty:window:setTitle", title), openSessionInNewWindow: (payload) => ipcRenderer.invoke("netcatty:window:openSession", payload), + openEditorWindow: (payload) => ipcRenderer.invoke("netcatty:window:openEditor", payload), + focusEditorWindow: (editorId) => ipcRenderer.invoke("netcatty:window:focusEditor", { editorId }), + closeEditorWindowTabs: (payload) => ipcRenderer.invoke("netcatty:window:closeEditorTabs", payload), + saveEditorWindowTab: (payload) => ipcRenderer.invoke("netcatty:editorWindow:save", payload), + dockEditorWindowTab: (payload) => ipcRenderer.invoke("netcatty:editorWindow:dock", payload), + reportEditorWindowDirty: (payload) => ipcRenderer.send("netcatty:editorWindow:dirty", payload), + reportEditorWindowTabsClosed: (payload) => ipcRenderer.send("netcatty:editorWindow:tabsClosed", payload), + remapEditorWindowSession: (payload) => ipcRenderer.send("netcatty:editorWindow:remapSession", payload), + onEditorWindowOpenTab: (cb) => { + const editorOpenTabState = ctx.editorOpenTabState || { pending: [], listeners: new Set() }; + const receive = (payload) => { + try { + cb(payload); + ipcRenderer.send("netcatty:window:editorOpenTabResult", { requestId: payload.requestId, ok: true }); + } catch (err) { + ipcRenderer.send("netcatty:window:editorOpenTabResult", { + requestId: payload.requestId, + ok: false, + error: err?.message || "Failed to open editor tab", + }); + } + }; + editorOpenTabState.listeners.add(receive); + // Drain only to the currently mounted receiver. Deferring this would let + // StrictMode cleanup leave a stale callback holding the transfer. + for (const payload of editorOpenTabState.pending.splice(0)) receive(payload); + ipcRenderer.send("netcatty:window:editorReady"); + return () => editorOpenTabState.listeners.delete(receive); + }, + onEditorWindowActivateTab: (cb) => { + const handler = (_event, payload) => cb(payload); + ipcRenderer.on("netcatty:window:editorActivateTab", handler); + return () => ipcRenderer.removeListener("netcatty:window:editorActivateTab", handler); + }, + onEditorWindowCloseTabs: (cb) => { + const handler = (_event, payload) => cb(payload); + ipcRenderer.on("netcatty:window:editorCloseTabs", handler); + return () => ipcRenderer.removeListener("netcatty:window:editorCloseTabs", handler); + }, + reportEditorWindowCloseTabsResult: (payload) => { + ipcRenderer.send("netcatty:window:editorCloseTabsResult", payload); + }, + onEditorWindowRemapSession: (cb) => { + const handler = (_event, payload) => cb(payload); + ipcRenderer.on("netcatty:window:editorRemapSession", handler); + return () => ipcRenderer.removeListener("netcatty:window:editorRemapSession", handler); + }, + onEditorWindowSaveRequest: (cb) => { + const handler = (_event, payload) => cb(payload); + ipcRenderer.on("netcatty:window:editorSaveRequest", handler); + return () => ipcRenderer.removeListener("netcatty:window:editorSaveRequest", handler); + }, + reportEditorWindowSaveResult: (payload) => { + ipcRenderer.send("netcatty:window:editorSaveResult", payload); + }, + onEditorWindowDockRequest: (cb) => { + const handler = (_event, payload) => cb(payload); + ipcRenderer.on("netcatty:window:editorDockRequest", handler); + return () => ipcRenderer.removeListener("netcatty:window:editorDockRequest", handler); + }, + reportEditorWindowDockResult: (payload) => { + ipcRenderer.send("netcatty:window:editorDockResult", payload); + }, + onEditorWindowDirtyChanged: (cb) => { + const handler = (_event, payload) => cb(payload); + ipcRenderer.on("netcatty:window:editorDirtyChanged", handler); + return () => ipcRenderer.removeListener("netcatty:window:editorDirtyChanged", handler); + }, + onEditorWindowTabsClosed: (cb) => { + const handler = (_event, payload) => cb(payload); + ipcRenderer.on("netcatty:window:editorTabsClosed", handler); + return () => ipcRenderer.removeListener("netcatty:window:editorTabsClosed", handler); + }, onOpenSessionInNewWindow: (cb) => { const handler = (_event, payload) => cb(payload); ipcRenderer.on("netcatty:window:openSession", handler); diff --git a/electron/preload/api.editorWindow.test.cjs b/electron/preload/api.editorWindow.test.cjs new file mode 100644 index 000000000..79c8a0ae9 --- /dev/null +++ b/electron/preload/api.editorWindow.test.cjs @@ -0,0 +1,43 @@ +const assert = require("node:assert/strict"); +const test = require("node:test"); +const { createPreloadApi } = require("./api.cjs"); + +function harness(pending = []) { + const sent = []; + const editorOpenTabState = { pending, listeners: new Set() }; + const api = createPreloadApi({ editorOpenTabState, ipcRenderer: { send: (...args) => sent.push(args) }, webUtils: {} }); + return { api, sent, editorOpenTabState }; +} + +test("editor receiver announces readiness after subscribing and acknowledges installed contents", () => { + const h = harness(); + const received = []; + const off = h.api.onEditorWindowOpenTab((payload) => { + assert.equal(h.sent.length, 1, "receipt must follow installation"); + received.push(payload.content); + }); + assert.deepEqual(h.sent, [["netcatty:window:editorReady"]]); + const message = { requestId: "open-1", content: "unsaved" }; + for (const receive of h.editorOpenTabState.listeners) receive(message); + assert.deepEqual(received, ["unsaved"]); + assert.deepEqual(h.sent[1], ["netcatty:window:editorOpenTabResult", { requestId: "open-1", ok: true }]); + off(); + assert.equal(h.editorOpenTabState.listeners.size, 0); +}); + +test("failed renderer installation sends a negative receipt", () => { + const h = harness(); + h.api.onEditorWindowOpenTab(() => { throw new Error("cannot install"); }); + for (const receive of h.editorOpenTabState.listeners) receive({ requestId: "open-2" }); + assert.deepEqual(h.sent.at(-1), ["netcatty:window:editorOpenTabResult", { requestId: "open-2", ok: false, error: "cannot install" }]); +}); + +test("queued tabs are installed by the mounted receiver before cleanup", () => { + const h = harness([{ requestId: "queued", content: "unsaved" }]); + let installed = 0; + const off = h.api.onEditorWindowOpenTab(() => { installed++; }); + off(); + h.api.onEditorWindowOpenTab(() => { installed++; }); + assert.equal(installed, 1); + assert.equal(h.editorOpenTabState.pending.length, 0); +}); diff --git a/electron/startupMemoryGuards.test.cjs b/electron/startupMemoryGuards.test.cjs index bcb02fa58..a2173d090 100644 --- a/electron/startupMemoryGuards.test.cjs +++ b/electron/startupMemoryGuards.test.cjs @@ -13,6 +13,7 @@ const WINDOW_SOURCES_WITH_SPELLCHECK = [ 'bridges/windowManager/mainWindow.cjs', 'bridges/windowManager/settingsWindow.cjs', 'bridges/windowManager/terminalPopupWindow.cjs', + 'bridges/windowManager/editorWindow.cjs', 'bridges/globalShortcutBridge.cjs', 'bridges/windowManager/externalWindows.cjs', 'plugins/contributionIconRasterizer.cjs', diff --git a/electron/terminalWorker/runtime.cjs b/electron/terminalWorker/runtime.cjs index 410e2b54e..ac8633337 100644 --- a/electron/terminalWorker/runtime.cjs +++ b/electron/terminalWorker/runtime.cjs @@ -402,11 +402,11 @@ function createTerminalWorkerRuntime(options = {}) { pendingSessionStartBootEpochs.set(sessionId, normalized); } - function shouldSkipStaleEpochClose(sessionId, bootEpoch) { - const closeEpoch = normalizeBootEpoch(bootEpoch); - if (closeEpoch === undefined || !sessionId) return false; + function isStaleSessionBootEpoch(sessionId, bootEpoch) { + const requestedEpoch = normalizeBootEpoch(bootEpoch); + if (requestedEpoch === undefined || !sessionId) return false; const ownerEpoch = pendingSessionStartBootEpochs.get(sessionId); - return ownerEpoch !== undefined && ownerEpoch > closeEpoch; + return ownerEpoch !== undefined && ownerEpoch > requestedEpoch; } function pruneSessionLifecycleTombstones() { @@ -614,20 +614,30 @@ function createTerminalWorkerRuntime(options = {}) { void handleRequest(message); return; } + if (isStaleSessionBootEpoch(sessionId, message.payload?.bootEpoch)) { + postRequestError(message, new Error("Terminal session start was superseded by a newer boot")); + return; + } + // The replacement cannot register its own controller until the queued + // start settles, so cancel that start before waiting for its PATH refresh. + const { abortPendingBoot } = require("../bridges/sessionBootEpoch.cjs"); + abortPendingBoot(sessionId, message.payload?.bootEpoch); rememberPendingStartBootEpoch(sessionId, message.payload?.bootEpoch); const closeEpoch = sessionCloseEpochs.get(sessionId) ?? 0; const previous = sessionOperationTails.get(sessionId); const previousKind = sessionOperationKinds.get(sessionId); const shouldClosePreviousStart = previousKind === "start" || sessionStartMarkers.has(sessionId); const current = (previous || Promise.resolve()).catch(() => {}).then(async () => { - if ((sessionCloseEpochs.get(sessionId) ?? 0) !== closeEpoch) { - postRequestError(message, new Error("Terminal session start was cancelled by close")); + if ((sessionCloseEpochs.get(sessionId) ?? 0) !== closeEpoch + || isStaleSessionBootEpoch(sessionId, message.payload?.bootEpoch)) { + postRequestError(message, new Error("Terminal session start was cancelled by close or superseded by a newer boot")); return; } try { if (shouldClosePreviousStart) await closeSupersededSessionStart(message); - if ((sessionCloseEpochs.get(sessionId) ?? 0) !== closeEpoch) { - postRequestError(message, new Error("Terminal session start was cancelled by close")); + if ((sessionCloseEpochs.get(sessionId) ?? 0) !== closeEpoch + || isStaleSessionBootEpoch(sessionId, message.payload?.bootEpoch)) { + postRequestError(message, new Error("Terminal session start was cancelled by close or superseded by a newer boot")); return; } sessionStartMarkers.add(sessionId); @@ -646,7 +656,7 @@ function createTerminalWorkerRuntime(options = {}) { else handleSend(message); return; } - if (shouldSkipStaleEpochClose(sessionId, message.payload?.bootEpoch)) { + if (isStaleSessionBootEpoch(sessionId, message.payload?.bootEpoch)) { if (expectsResponse) { parentPort.postMessage({ kind: "response", @@ -656,6 +666,10 @@ function createTerminalWorkerRuntime(options = {}) { } return; } + // Signal cancellation before waiting for the start operation. Otherwise a + // pending PATH refresh can spawn a PTY before its queued close runs. + const { abortPendingBoot } = require("../bridges/sessionBootEpoch.cjs"); + abortPendingBoot(sessionId, message.payload?.bootEpoch); sessionCloseEpochs.set(sessionId, (sessionCloseEpochs.get(sessionId) ?? 0) + 1); pendingSessionStartBootEpochs.delete(sessionId); touchSessionLifecycleTombstone(sessionId); diff --git a/index.tsx b/index.tsx index cfe5d6641..b46833d7e 100755 --- a/index.tsx +++ b/index.tsx @@ -17,6 +17,7 @@ import { AppLockGate } from './components/AppLockGate'; const LazySettingsPage = lazy(() => import('./components/SettingsPage')); const LazyTrayPanel = lazy(() => import('./components/TrayPanel')); const LazyTerminalPopupPage = lazy(() => import('./components/TerminalPopupPage')); +const LazyEditorWindowPage = lazy(() => import('./components/editor/EditorWindowPage')); function SettingsWindowFallback() { return ( @@ -144,6 +145,9 @@ const getRoute = () => { if (hash === '#/terminal-popup' || hash.startsWith('#/terminal-popup')) { return 'terminal-popup'; } + if (hash === '#/editor-window' || hash.startsWith('#/editor-window')) { + return 'editor-window'; + } if (hash === '#/session-window' || hash.startsWith('#/session-window')) { return 'main'; } @@ -165,12 +169,14 @@ const syncTrayWindowClass = (route: string) => { const renderApp = () => { const route = getRoute(); const isPeerSessionWindow = window.location.hash.startsWith('#/session-window'); + const isEditorWindow = route === 'editor-window'; // Peer session windows must not drive the main window's settings IPC sync // and must not re-apply OS-level system settings effects (tray, global // shortcuts, …) — they follow the main window through the chrome stores. + // Editors consume their own settings instance, so keep IPC/storage sync on. const settingsOptions = isPeerSessionWindow ? { enableSettingsSync: false, enableSystemEffects: false } - : undefined; + : isEditorWindow ? { enableSystemEffects: false } : undefined; syncTrayWindowClass(route); if (route === 'settings') { @@ -222,6 +228,25 @@ const renderApp = () => { ); + } else if (route === 'editor-window') { + root.render( + + + {({ settings, appLock }) => ( + }> + + + )} + + + ); } else { root.render( diff --git a/infrastructure/ai/harness/generated/globalAgentToolSpecs.json b/infrastructure/ai/harness/generated/globalAgentToolSpecs.json index c5c4ece41..43e9b989b 100644 --- a/infrastructure/ai/harness/generated/globalAgentToolSpecs.json +++ b/infrastructure/ai/harness/generated/globalAgentToolSpecs.json @@ -585,12 +585,17 @@ "toolName": "host_open", "rpcMethod": "vault/hosts/open", "localExecution": false, - "description": "Open a vault host by creating a new terminal tab and starting the connection. Returns the new sessionId so you can run terminal/SFTP tools against it. Use vault_hosts_list first when you only know the label or hostname. Opens a terminal tab for a saved vault host (same as clicking the host in Netcatty). Connection may still be establishing when the tool returns — use get_environment or wait briefly before terminal_execute if needed. Call session_close with the returned sessionId when the task is finished. Auth prompts (passphrase / keyboard-interactive) still require the user in the Netcatty UI.", + "description": "Open a vault host by reusing its existing session when its connection settings still match (connected or connecting, including auto-reconnecting — same sessionId, no duplicate tab). Only opens a new terminal tab when no usable session for this host exists, or when newTab is true. Inactive disconnected and restored tabs are not reused. Returns the sessionId so you can run terminal/SFTP tools against it. Use vault_hosts_list first when you only know the label or hostname. Reuse-first: if this hostId already has a connected or connecting session with matching connection settings (including one auto-reconnecting after a drop, which keeps the same sessionId), that sessionId is returned and no new tab is opened — retry terminal_execute against it rather than reopening the host. Open a new tab only when get_environment shows no session for the host, when the task truly needs a parallel connection, or with newTab true. Call session_close when finished only for a newly opened session (reused is not true). A reused session keeps its original owner; do not close it as task cleanup. Disconnected or restored tabs needing user action are not reused. Connection may still be establishing when the tool returns — use get_environment or wait briefly before terminal_execute if needed. Auth prompts (passphrase / keyboard-interactive) still require the user in the Netcatty UI.", "inputShape": { "hostId": { "type": "string", "optional": false, "description": "Vault host ID to open. Use vault_hosts_list / host_get to resolve id from label or hostname." + }, + "newTab": { + "type": "string", + "optional": true, + "description": "true or false. Force a brand-new tab even when a session for this host already exists. Default false — reuse the existing session." } }, "policy": { diff --git a/infrastructure/ai/hostOpen.integration.test.ts b/infrastructure/ai/hostOpen.integration.test.ts new file mode 100644 index 000000000..1806ba8b4 --- /dev/null +++ b/infrastructure/ai/hostOpen.integration.test.ts @@ -0,0 +1,146 @@ +import assert from 'node:assert/strict'; +import { createRequire } from 'node:module'; +import { test } from 'node:test'; +import type { Host, TerminalSession } from '../../domain/models'; +import React from 'react'; +import { act, create, type ReactTestRenderer } from 'react-test-renderer'; +import { updateRestoredSessionStatusState } from '../../application/state/sessionRestoreState'; +import { createHostTerminalSession } from '../../application/state/sessionFactories'; +import { useHostOpenSessionReuse } from '../../application/state/useHostOpenSessionReuse'; +import { handleVaultAgentOp, type VaultAgentApiDeps } from './vaultAgentBridgeClient'; + +const require = createRequire(import.meta.url); +const { isolateCliDiscoveryFile } = require('../../electron/bridges/cliDiscoveryTestIsolation.cjs'); +isolateCliDiscoveryFile(); +const bridge = require('../../electron/bridges/mcpServerBridge.cjs'); +const { registerMcpTools } = require('../../electron/capabilities/codegen/mcpToolRegistry.cjs'); + +test('MCP host_open forwards the schema input through RPC and vault service to the real renderer handler', async (t) => { + bridge.init({ sessions: new Map(), electronModule: null }); + bridge.setPermissionMode('auto'); + t.after(() => bridge.cleanup()); + const host: Host = { id: 'host-1', label: 'Host', hostname: 'example.invalid', username: 'user', protocol: 'ssh', os: 'linux', tags: [] }; + const tabs: TerminalSession[] = [{ id: 'existing', hostId: host.id, hostLabel: host.label, hostname: host.hostname, username: host.username, status: 'connecting' }]; + let finder: ReturnType; + let renderer: ReactTestRenderer; + function Harness() { + finder = useHostOpenSessionReuse({ sessions: [...tabs], hosts: [{ ...host }], keys: [], identities: [], resolveEffectiveHost: (value) => value }); + return null; + } + const render = async () => act(async () => { + if (renderer) renderer.update(React.createElement(Harness)); + else renderer = create(React.createElement(Harness)); + }); + (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + t.after(async () => act(async () => renderer.unmount())); + await render(); + const deps = { + getHosts: () => [host], + resolveEffectiveHost: (value: Host) => value, + findExistingSessionForHost: (hostId: string) => finder(hostId), + openHost: (openedHost: Host) => { + const sessionId = `new-${tabs.length}`; + tabs.push(createHostTerminalSession(sessionId, openedHost)); + return { ok: true as const, sessionId, host }; + }, + } as unknown as VaultAgentApiDeps; + bridge.setVaultAgentInvoker((op: string, params: Record) => handleVaultAgentOp(op, params, deps)); + let openTool: ((args: Record) => Promise) | undefined; + let schema: { parse: (args: Record) => Record }; + registerMcpTools({ + tool(name: string, _description: string, shape: Record, handler: typeof openTool) { + if (name !== 'host_open') return; + schema = require('zod').z.object(shape); + openTool = handler; + }, + }, { + rpcCall: (method: string, params: Record) => bridge.dispatchBuiltinRpc(method, params), + scopeParams: { chatSessionId: 'integration-chat' }, + guardWriteOperation: () => null, + catalogDescription: (_name: string, fallback: string) => fallback, + }); + assert.ok(openTool); + const run = async (args: Record) => { + await render(); + const response = await openTool!(schema.parse(args)) as { content: { text: string }[] }; + await render(); + return JSON.parse(response.content[0].text); + }; + for (let index = 0; index < 3; index++) { + const reused = await run({ hostId: host.id }); + assert.equal(reused.sessionId, 'existing'); + assert.equal(reused.reused, true); + } + assert.equal(tabs.length, 1); + const fresh = await run({ hostId: host.id, newTab: 'true' }); + assert.equal(fresh.sessionId, 'new-1'); + assert.equal(tabs.length, 2); + // An inactive tab requires a real open instead of an endless wait. + for (const tab of tabs) tab.status = 'disconnected'; + const recovered = await run({ hostId: host.id, newTab: 'false' }); + assert.equal(recovered.sessionId, 'new-2'); + assert.equal(tabs.length, 3); + tabs[2].status = 'connected'; + host.label = 'Renamed only'; + assert.equal((await run({ hostId: host.id })).sessionId, recovered.sessionId); + host.hostname = 'replacement.invalid'; + const editedTarget = await run({ hostId: host.id }); + assert.notEqual(editedTarget.sessionId, recovered.sessionId, 'a saved endpoint edit must not reuse the old transport'); + assert.equal(editedTarget.reused, undefined); + tabs.length = 0; + host.protocol = 'telnet'; + host.telnetPort = 2323; + host.telnetUsername = 'telnet-user'; + host.telnetPassword = 'telnet-password'; + const telnet = await run({ hostId: host.id }); + tabs[0].status = 'connected'; + assert.equal((await run({ hostId: host.id })).sessionId, telnet.sessionId); + host.telnetPort = 2324; + const changedTelnet = await run({ hostId: host.id }); + assert.notEqual(changedTelnet.sessionId, telnet.sessionId, 'the actual Telnet destination port change requires a new transport'); + assert.equal(changedTelnet.reused, undefined); + // Current-main fields only: saved-local creation/default snapshots remain #3616's responsibility. + tabs.length = 0; + host.protocol = 'local'; + host.hostname = 'localhost'; + host.localShell = '/bin/zsh'; + host.localShellArgs = ['-l']; + host.localStartDir = '/original'; + const local = await run({ hostId: host.id }); + tabs[0].status = 'connected'; + assert.equal((await run({ hostId: host.id })).sessionId, local.sessionId); + host.localStartDir = '/replacement'; + const changedLocal = await run({ hostId: host.id }); + assert.notEqual(changedLocal.sessionId, local.sessionId, 'an explicit saved local directory change must not reuse the old process'); + assert.equal(changedLocal.reused, undefined); + // Saved serial hosts use the same first-party host_open path and factory. + tabs.length = 0; + host.protocol = 'serial'; + host.hostname = '/dev/tty.test'; + host.username = ''; + host.serialConfig = { path: host.hostname, baudRate: 9600, dataBits: 8, parity: 'none', flowControl: 'none' }; + const serial = await run({ hostId: host.id }); + assert.equal(serial.protocol, 'serial'); + assert.equal(tabs[0].serialConfig?.baudRate, 9600); + tabs[0].status = 'connected'; + assert.equal((await run({ hostId: host.id })).sessionId, serial.sessionId); + host.serialConfig = { ...host.serialConfig, baudRate: 19200 }; + const changedSerial = await run({ hostId: host.id }); + assert.notEqual(changedSerial.sessionId, serial.sessionId, 'an edited serial configuration must not reuse the old parameters'); + assert.equal(changedSerial.reused, undefined); + assert.equal(tabs[1].serialConfig?.baudRate, 19200); + assert.equal(tabs[0].serialConfig?.baudRate, 9600); + tabs.length = 0; + host.protocol = 'plugin:com.example.transport.connection'; + host.hostname = 'plugin-host'; + host.pluginConnection = { providerId: 'com.example.transport.connection', configuration: { endpoint: 'original.invalid' } }; + tabs.push({ ...createHostTerminalSession('restored-plugin', host), status: 'disconnected', restoreState: 'restored-disconnected' }); + await render(); + host.pluginConnection = { ...host.pluginConnection, configuration: { endpoint: 'replacement.invalid' } }; + tabs.splice(0, tabs.length, ...updateRestoredSessionStatusState(tabs, 'restored-plugin', 'connecting')); + const changedPlugin = await run({ hostId: host.id }); + assert.notEqual(changedPlugin.sessionId, 'restored-plugin', 'restored plugin transport must not mask the edited provider configuration'); + assert.equal(changedPlugin.reused, undefined); + assert.deepEqual(tabs[0].pluginConnection?.configuration, { endpoint: 'original.invalid' }); + assert.deepEqual(tabs[1].pluginConnection?.configuration, { endpoint: 'replacement.invalid' }); +}); diff --git a/infrastructure/ai/types.test.ts b/infrastructure/ai/types.test.ts index 75df72eda..8f81bcc88 100644 --- a/infrastructure/ai/types.test.ts +++ b/infrastructure/ai/types.test.ts @@ -1,4 +1,5 @@ import test from 'node:test'; +import { normalizeStoredAgentModelSelection, shouldUseStoredAgentModel } from '../../components/AIChatSidePanelHelpers'; import assert from 'node:assert/strict'; import { @@ -19,10 +20,22 @@ import { } from './types'; test('Claude presets advertise effort levels separately from the model id', () => { - for (const preset of CLAUDE_MODEL_PRESETS) { + assert.equal(CLAUDE_MODEL_PRESETS.length, 15); + const withLevels = CLAUDE_MODEL_PRESETS.filter((preset) => preset.thinkingLevels); + assert.ok(withLevels.length > 0); + for (const preset of withLevels) { assert.deepEqual(preset.thinkingLevels, ['low', 'medium', 'high', 'max']); assert.ok(preset.defaultThinkingLevel); } + // Current-generation entries keep explicit effort levels; older fallback + // entries and unverified generations omit them rather than inventing. + const byId = new Map(CLAUDE_MODEL_PRESETS.map((preset) => [preset.id, preset])); + for (const id of ['claude-opus-5-5', 'claude-sonnet-5-5', 'claude-haiku-5-5', 'claude-haiku-4-5']) { + assert.ok(byId.get(id)?.thinkingLevels, `${id} should keep effort levels`); + } + for (const id of ['claude-fable-5-1', 'claude-sonnet-5', 'claude-opus-4-6']) { + assert.equal(byId.get(id)?.thinkingLevels, undefined, `${id} should not invent effort levels`); + } assert.equal(resolveAgentModelSelection(CLAUDE_MODEL_PRESETS[0]!), 'default/medium'); }); @@ -252,3 +265,26 @@ test('getAgentModelPresets resolves Windows command paths with backslashes', () CLAUDE_MODEL_PRESETS, ); }); + +test('Claude fallback preserves saved family aliases and effort', () => { + for (const selection of ['sonnet/medium', 'haiku/low']) { + assert.equal(shouldUseStoredAgentModel(selection, CLAUDE_MODEL_PRESETS), true); + assert.equal(normalizeStoredAgentModelSelection(selection, CLAUDE_MODEL_PRESETS), selection); + } + assert.equal(CLAUDE_MODEL_PRESETS.find((preset) => preset.id === 'sonnet')?.name, 'Sonnet'); + assert.equal(CLAUDE_MODEL_PRESETS.find((preset) => preset.id === 'haiku')?.name, 'Haiku'); +}); + + +test('explicit Claude fallback IDs preserve unspecified effort and explicit effort choices', () => { + for (const preset of CLAUDE_MODEL_PRESETS.filter((model) => model.id.startsWith('claude-'))) { + assert.equal(normalizeStoredAgentModelSelection(preset.id, CLAUDE_MODEL_PRESETS), preset.id); + assert.equal(resolveAgentModelSelection(preset), preset.id); + for (const level of preset.thinkingLevels ?? []) { + const explicit = `${preset.id}/${level}`; + assert.equal(normalizeStoredAgentModelSelection(explicit, CLAUDE_MODEL_PRESETS), explicit); + } + } + assert.equal(normalizeStoredAgentModelSelection('sonnet/medium', CLAUDE_MODEL_PRESETS), 'sonnet/medium'); + assert.equal(normalizeStoredAgentModelSelection('haiku/low', CLAUDE_MODEL_PRESETS), 'haiku/low'); +}); diff --git a/infrastructure/ai/types.ts b/infrastructure/ai/types.ts index 4b4087f49..cd9d044ce 100644 --- a/infrastructure/ai/types.ts +++ b/infrastructure/ai/types.ts @@ -162,6 +162,8 @@ export interface ChatMessage { providerContinuation?: ProviderContinuation; toolCalls?: ToolCall[]; toolResults?: ToolResult[]; + /** User-skill pills selected for this turn (user messages); restored on undo. */ + selectedUserSkillSlugs?: string[]; agentActivities?: AgentActivity[]; usage?: AgentUsage; timestamp: number; @@ -559,25 +561,76 @@ export interface AgentModelPreset { const CLAUDE_REASONING_LEVELS = ['low', 'medium', 'high', 'max'] as const; +// Fallback presets used when the live catalog (SDK listModels) is unavailable. +// Mirrors the live model picker catalog observed 2026-10-10 (claude CLI +// 2.1.296); ids are explicit model ids sent to the CLI verbatim and were +// verified against the binary's model-id strings and alias table +// (opus/sonnet/haiku → claude-*-5-5, fable → claude-fable-5-1). Live +// discovery supersedes this list whenever it succeeds. `default` has no +// fixed alias target and keeps the CLI's own neutral label. Explicit IDs +// preserve a saved bare selection so CLI/settings effort remains in control. export const CLAUDE_MODEL_PRESETS: AgentModelPreset[] = [ { id: 'default', - name: 'Opus 4.6', + name: 'Default (recommended)', description: 'Recommended', thinkingLevels: [...CLAUDE_REASONING_LEVELS], defaultThinkingLevel: 'medium', }, { - id: 'sonnet', - name: 'Sonnet 4.6', + id: 'claude-opus-5-5', + name: 'Opus 5.5', + description: 'Flagship', + thinkingLevels: [...CLAUDE_REASONING_LEVELS], + defaultThinkingLevel: 'medium', + encodeDefaultThinking: false, + }, + { + id: 'claude-fable-5-1', + name: 'Fable 5.1', + description: 'Latest Fable', + }, + { + id: 'claude-sonnet-5-5', + name: 'Sonnet 5.5', description: 'Everyday tasks', thinkingLevels: [...CLAUDE_REASONING_LEVELS], defaultThinkingLevel: 'medium', + encodeDefaultThinking: false, + }, + { + id: 'claude-haiku-5-5', + name: 'Haiku 5.5', + description: 'Fastest current', + thinkingLevels: [...CLAUDE_REASONING_LEVELS], + defaultThinkingLevel: 'low', + encodeDefaultThinking: false, + }, + { + id: 'claude-haiku-4-5', + name: 'Haiku 4.5', + thinkingLevels: [...CLAUDE_REASONING_LEVELS], + defaultThinkingLevel: 'low', + encodeDefaultThinking: false, + }, + { id: 'claude-sonnet-5', name: 'Sonnet 5' }, + { id: 'claude-opus-5', name: 'Opus 5' }, + { id: 'claude-fable-5', name: 'Fable 5' }, + { id: 'claude-opus-4-8', name: 'Opus 4.8' }, + { id: 'claude-opus-4-7', name: 'Opus 4.7' }, + { id: 'claude-opus-4-6', name: 'Opus 4.6' }, + { id: 'claude-sonnet-4-6', name: 'Sonnet 4.6' }, + // Keep persisted pre-expansion selections valid. The installed CLI resolves + // these aliases, so their labels must not claim a particular generation. + { + id: 'sonnet', + name: 'Sonnet', + thinkingLevels: [...CLAUDE_REASONING_LEVELS], + defaultThinkingLevel: 'medium', }, { id: 'haiku', - name: 'Haiku 4.5', - description: 'Fastest', + name: 'Haiku', thinkingLevels: [...CLAUDE_REASONING_LEVELS], defaultThinkingLevel: 'low', }, diff --git a/infrastructure/ai/vaultAgentBridgeClient.test.ts b/infrastructure/ai/vaultAgentBridgeClient.test.ts index f8ec36537..939bbdbd0 100644 --- a/infrastructure/ai/vaultAgentBridgeClient.test.ts +++ b/infrastructure/ai/vaultAgentBridgeClient.test.ts @@ -496,6 +496,110 @@ describe('handleVaultAgentOp vault hosts', () => { ]); }); + it('host.open reuses an existing session for the same host instead of opening a new tab', async (t) => { + const { netcattyBridge } = await import('../services/netcattyBridge'); + const merged: Array<{ sessions: Array<{ sessionId: string; connected?: boolean }>; scope: string }> = []; + t.mock.method(netcattyBridge, 'get', () => ({ + aiMcpMergeSessions: async ( + sessions: Array<{ sessionId: string; connected?: boolean }>, + scope: string, + ) => { + merged.push({ sessions, scope }); + }, + } as unknown as NetcattyBridge)); + const host: Host = { + id: 'host-reuse-1', + label: 'edge', + hostname: 'edge.example.com', + username: 'ops', + port: 22, + }; + let openHostCalls = 0; + const deps = createDeps({ + hosts: [host], + openHost: (hostToOpen: Host) => { + openHostCalls += 1; + return { ok: true, sessionId: `session-${hostToOpen.id}`, host: hostToOpen }; + }, + findExistingSessionForHost: (hostId: string) => hostId === host.id + ? { sessionId: 'session-existing-1', connected: false } + : undefined, + }); + + const result = await handleVaultAgentOp( + 'host.open', + { hostId: host.id, chatSessionId: '__external_mcp__' }, + deps, + ); + + assert.equal(result.ok, true); + assert.equal((result as { sessionId?: string }).sessionId, 'session-existing-1'); + assert.equal((result as { reused?: boolean }).reused, true); + assert.equal((result as { status?: string }).status, 'reconnecting'); + assert.equal(openHostCalls, 0, 'must not open a duplicate tab'); + // The reused session is re-registered in the MCP scope with its live + // reconnecting state so terminal_execute can target it immediately. + assert.deepEqual(merged.map(({ sessions, scope }) => ({ scope, connected: sessions[0]?.connected })), [ + { scope: '__external_mcp__', connected: false }, + ]); + }); + + it('host.open reuse reports a connected session as connected', async () => { + const host: Host = { id: 'host-reuse-2', label: 'edge', hostname: 'edge.example.com' }; + let openHostCalls = 0; + const deps = createDeps({ + hosts: [host], + openHost: (hostToOpen: Host) => { + openHostCalls += 1; + return { ok: true, sessionId: `session-${hostToOpen.id}`, host: hostToOpen }; + }, + findExistingSessionForHost: (hostId: string) => hostId === host.id + ? { sessionId: 'session-existing-2', connected: true } + : undefined, + }); + + const result = await handleVaultAgentOp('host.open', { hostId: host.id }, deps); + + assert.equal(result.ok, true); + assert.equal((result as { sessionId?: string }).sessionId, 'session-existing-2'); + assert.equal((result as { status?: string }).status, 'connected'); + assert.equal((result as { connected?: boolean }).connected, true); + assert.equal(openHostCalls, 0); + }); + + it('host.open newTab forces a fresh session even when one exists', async () => { + const host: Host = { id: 'host-reuse-3', label: 'edge', hostname: 'edge.example.com' }; + const deps = createDeps({ + hosts: [host], + findExistingSessionForHost: () => ({ sessionId: 'session-existing-3', connected: true }), + }); + + const result = await handleVaultAgentOp( + 'host.open', + { hostId: host.id, newTab: 'true' }, + deps, + ); + + assert.equal(result.ok, true); + assert.equal((result as { sessionId?: string }).sessionId, 'session-host-reuse-3'); + assert.equal((result as { status?: string }).status, 'connecting'); + }); + + it('host.open still opens a new tab when no session exists for the host', async () => { + const host: Host = { id: 'host-reuse-4', label: 'edge', hostname: 'edge.example.com' }; + const deps = createDeps({ + hosts: [host], + findExistingSessionForHost: () => undefined, + }); + + const result = await handleVaultAgentOp('host.open', { hostId: host.id }, deps); + + assert.equal(result.ok, true); + assert.equal((result as { sessionId?: string }).sessionId, 'session-host-reuse-4'); + assert.equal((result as { reused?: boolean }).reused, undefined); + assert.equal((result as { status?: string }).status, 'connecting'); + }); + it('host.open does not treat a missing chatSessionId as an external MCP call', async () => { const host: Host = { id: 'host-open-4', diff --git a/infrastructure/ai/vaultAgentBridgeClient.ts b/infrastructure/ai/vaultAgentBridgeClient.ts index 4ef8f8900..dd16f9a20 100644 --- a/infrastructure/ai/vaultAgentBridgeClient.ts +++ b/infrastructure/ai/vaultAgentBridgeClient.ts @@ -556,6 +556,15 @@ export interface VaultAgentApiDeps { ok: false; error: string; }; + /** + * Find an existing terminal session for a vault host so `host.open` can + * reuse it instead of always opening a new tab. Reconnecting SSH tabs keep + * the same sessionId. Check the opening connection identity before reuse. + */ + findExistingSessionForHost?: (hostId: string) => { + sessionId: string; + connected: boolean; + } | undefined | Promise<{ sessionId: string; connected: boolean } | undefined>; closeSession?: (sessionId: string) => { ok: true; } | { @@ -584,6 +593,7 @@ async function registerOpenedSessionInMcpScope( sessionId: string, host: Host, chatSessionId?: string, + connected = false, ): Promise { let bridge: ReturnType | undefined; try { @@ -612,7 +622,7 @@ async function registerOpenedSessionInMcpScope( username: host.username || '', protocol, deviceType: host.deviceType || '', - connected: false, + connected, hostChain: [], activePortForwards: [], }; @@ -671,9 +681,6 @@ export async function handleVaultAgentOp( if (!hostId) return { ok: false, error: 'hostId is required.' }; const host = deps.getHosts().find((entry) => entry.id === hostId); if (!host) return { ok: false, error: `Host "${hostId}" was not found.` }; - if (typeof deps.openHost !== 'function') { - return { ok: false, error: 'Host open is not available in this window.' }; - } const chatSessionId = typeof params.chatSessionId === 'string' ? params.chatSessionId @@ -683,12 +690,6 @@ export async function handleVaultAgentOp( // not a reliable signal — compare against the constant instead. const isExternalMcpCall = chatSessionId === EXTERNAL_MCP_CHAT_SESSION_ID; const effectiveHost = deps.resolveEffectiveHost(host); - const opened = deps.openHost(effectiveHost, isExternalMcpCall); - if (!opened.ok) { - return { ok: false, error: opened.error }; - } - - await registerOpenedSessionInMcpScope(opened.sessionId, effectiveHost, chatSessionId); const protocol = effectiveHost.etEnabled ? 'et' @@ -696,6 +697,49 @@ export async function handleVaultAgentOp( ? 'mosh' : (effectiveHost.protocol || 'ssh'); + // Reuse-first: a terminal tab keeps its sessionId across SSH + // auto-reconnect, so blindly opening a new tab stacks duplicate tabs + // (and steals focus) after every network blip. Reuse the live or + // reconnecting session for this host unless the caller forces a new tab. + const wantsNewTab = parseOptionalBoolean(params.newTab) ?? false; + const existing = !wantsNewTab + ? await deps.findExistingSessionForHost?.(effectiveHost.id) + : undefined; + if (existing) { + await registerOpenedSessionInMcpScope( + existing.sessionId, + effectiveHost, + chatSessionId, + existing.connected, + ); + return { + ok: true, + sessionId: existing.sessionId, + hostId: effectiveHost.id, + ...(isSavedVaultHost(effectiveHost) && protocol !== 'serial' && protocol !== 'local' + ? { savedHostId: effectiveHost.id } + : {}), + reused: true, + connected: existing.connected, + status: existing.connected ? 'connected' : 'reconnecting', + protocol, + host: summarizeHostForList(effectiveHost), + message: + 'Reused the existing session for this host (same sessionId). If it is not connected yet, the tab is connecting or reconnecting — wait briefly and use get_environment or terminal_execute against this sessionId. Complete any authentication prompt in the Netcatty UI if needed. Do not call session_close on it unless the user asks; pass newTab true to force a separate tab instead.', + }; + } + + if (typeof deps.openHost !== 'function') { + return { ok: false, error: 'Host open is not available in this window.' }; + } + + const opened = deps.openHost(effectiveHost, isExternalMcpCall); + if (!opened.ok) { + return { ok: false, error: opened.error }; + } + + await registerOpenedSessionInMcpScope(opened.sessionId, effectiveHost, chatSessionId); + return { ok: true, sessionId: opened.sessionId, diff --git a/infrastructure/config/storageKeys.ts b/infrastructure/config/storageKeys.ts index ddf95b12a..bfeb649c5 100644 --- a/infrastructure/config/storageKeys.ts +++ b/infrastructure/config/storageKeys.ts @@ -24,6 +24,8 @@ export const STORAGE_KEY_TERM_THEME_LIGHT = 'netcatty_term_theme_light_v1'; export const STORAGE_KEY_TERM_FONT_FAMILY = 'netcatty_term_font_family_v1'; export const STORAGE_KEY_TERM_FONT_SIZE = 'netcatty_term_font_size_v1'; export const STORAGE_KEY_TERM_SETTINGS = 'netcatty_term_settings_v1'; +/** One-time marker: pre-#3619 builds materialized autoCloseOnExit: true into saved settings. */ +export const STORAGE_KEY_TERM_AUTO_CLOSE_EXIT_MIGRATED = 'netcatty_term_auto_close_exit_migrated_v1'; export const STORAGE_KEY_HOTKEY_SCHEME = 'netcatty_hotkey_scheme_v1'; export const STORAGE_KEY_CUSTOM_KEY_BINDINGS = 'netcatty_custom_key_bindings_v1'; export const STORAGE_KEY_HOTKEY_RECORDING = 'netcatty_hotkey_recording_v1'; @@ -288,6 +290,8 @@ export const STORAGE_KEY_LOCAL_SHELL_SIDE_PANEL_AUTO_OPEN_TAB = 'netcatty_local_ export const STORAGE_KEY_WORKSPACE_FOCUS_SIDEBAR_WIDTH = 'netcatty_workspace_focus_sidebar_width'; export const STORAGE_KEY_TERMINAL_HOST_TREE_WIDTH = 'netcatty_terminal_host_tree_width_v1'; export const STORAGE_KEY_TERMINAL_HOST_TREE_COLLAPSED = 'netcatty_terminal_host_tree_collapsed_v1'; +/** Terminal scripts side panel library: 'list' vs 'stacked' view mode. */ +export const STORAGE_KEY_SCRIPTS_SIDE_PANEL_VIEW = 'netcatty:scripts:sidePanelView'; export const STORAGE_KEY_TERMINAL_COMPOSE_BAR_OPEN = 'netcatty_terminal_compose_bar_open_v1'; /** Opt-in "broadcast without password protection" switch (terminal compose bar, issue #3488). */ export const STORAGE_KEY_TERMINAL_BROADCAST_PASSWORD_BYPASS = 'netcatty_terminal_broadcast_password_bypass_v1'; diff --git a/lib/searchMatcher.ts b/lib/searchMatcher.ts index 97b84ba1a..1064bb4d3 100644 --- a/lib/searchMatcher.ts +++ b/lib/searchMatcher.ts @@ -6,7 +6,19 @@ const SEARCH_QUERY_SEGMENT_SPLIT_REGEX = /\s+/u; const SEARCH_QUERY_PUNCT_REGEX = /[\p{Pd}_/\\|.,,。;;::!!??()()[\]{}<>《》、"'`~·]/u; const DASH_SEPARATOR_REGEX = /[\u2010-\u2015\u2212\uFE58\uFE63\uFF0D]/gu; export const PINYIN_CACHE_MAX_ENTRIES = 16_384; +/** + * Hard cap on the total characters the pinyin cache retains (cache key + + * full + initials transliterations per entry, i.e. several copies of the + * source text). A per-entry cap alone is not enough: callers transliterate + * long fields (e.g. up to 20,000-character session titles), so a scan over + * many distinct large fields could retain tens or hundreds of megabytes + * within the entry count. Sources whose transliterations alone exceed this + * budget are not cached at all — they are simply re-transliterated on the + * next lookup instead of pinning memory. + */ +export const PINYIN_CACHE_MAX_CHARS = 1_000_000; const PINYIN_CACHE = new Map(); +let pinyinCacheChars = 0; const IPV4_LIKE_REGEX = /^\d{1,3}(?:\.\d{1,3})+$/; const HOST_PHASE_SCORE_BONUS = { strict: 1_000_000, @@ -33,6 +45,13 @@ function compactText(input: string): string { return normalizeText(input).replace(SEARCH_REMOVE_REGEX, ""); } +function pinyinCacheEntryChars( + key: string, + entry: { full: string; initials: string }, +): number { + return key.length + entry.full.length + entry.initials.length; +} + function getPinyinVariants(sourceText: string): { full: string; initials: string } { const cacheKey = normalizeText(sourceText); const cached = PINYIN_CACHE.get(cacheKey); @@ -62,11 +81,22 @@ function getPinyinVariants(sourceText: string): { full: string; initials: string } const next = { full, initials }; + const entryChars = pinyinCacheEntryChars(cacheKey, next); + // Never cache an entry that by itself exceeds the retained-char budget: + // admitting it (and evicting everything older to fit) would still let one + // oversized field pin memory above the budget, and skipping keeps it a + // simple re-transliteration on the next lookup instead of unbounded decay. + if (entryChars > PINYIN_CACHE_MAX_CHARS) return next; + pinyinCacheChars += entryChars; PINYIN_CACHE.delete(cacheKey); PINYIN_CACHE.set(cacheKey, next); - while (PINYIN_CACHE.size > PINYIN_CACHE_MAX_ENTRIES) { + while (PINYIN_CACHE.size > PINYIN_CACHE_MAX_ENTRIES + || pinyinCacheChars > PINYIN_CACHE_MAX_CHARS) { const oldestKey = PINYIN_CACHE.keys().next().value as string | undefined; if (oldestKey === undefined) break; + const oldest = PINYIN_CACHE.get(oldestKey); + if (!oldest) break; + pinyinCacheChars -= pinyinCacheEntryChars(oldestKey, oldest); PINYIN_CACHE.delete(oldestKey); } return next; @@ -74,10 +104,19 @@ function getPinyinVariants(sourceText: string): { full: string; initials: string export function resetPinyinCacheForTests(): void { PINYIN_CACHE.clear(); + pinyinCacheChars = 0; } -export function getPinyinCacheStatsForTests(): { size: number; keys: string[] } { - return { size: PINYIN_CACHE.size, keys: [...PINYIN_CACHE.keys()] }; +export function getPinyinCacheStatsForTests(): { + size: number; + chars: number; + keys: string[]; +} { + return { + size: PINYIN_CACHE.size, + chars: pinyinCacheChars, + keys: [...PINYIN_CACHE.keys()], + }; } export function tokenizeSearchQuery(query: string): string[] { @@ -86,35 +125,132 @@ export function tokenizeSearchQuery(query: string): string[] { return normalized.split(SEARCH_SPLIT_REGEX).filter(Boolean); } -export function matchesSearchQuery( - query: string, - ...fields: Array -): boolean { - const normalizedQuery = normalizeText(query); - if (!normalizedQuery) return true; +/** + * Optional matching tweaks. Callers with large unbounded haystacks (e.g. + * full conversation histories) can keep literal/compact matching global + * while restricting the (expensive) pinyin fallback to specific fields via + * `pinyinFields`: each token may still match literally anywhere in the + * haystack, but is only transliteration-matched against the listed fields. + */ +export type SearchMatchOptions = { + allowPinyin?: boolean; + /** Restrict the pinyin fallback to these fields; defaults to every field. */ + pinyinFields?: string[]; +}; +/** + * Suggested query bound for callers that rescan many haystacks per keystroke + * (e.g. session-history search). The shared matcher never truncates: callers + * apply this cap at their own input so the bound stays local to them and the + * global match semantics (full-query meaning) are unchanged for everyone else. + */ +export const MAX_SEARCH_QUERY_LENGTH = 256; + +/** + * Bound a caller-side search query to `MAX_SEARCH_QUERY_LENGTH` UTF-16 code + * units without splitting a UTF-16 surrogate pair when the cap lands mid-pair + * (e.g. an astral-plane character such as an emoji; drop the lone high + * surrogate). Truncation is a caller concern: see `MAX_SEARCH_QUERY_LENGTH`. + */ +export function truncateQueryForMatch(query: string): string { + if (query.length <= MAX_SEARCH_QUERY_LENGTH) return query; + const truncated = query.slice(0, MAX_SEARCH_QUERY_LENGTH); + // Avoid splitting a UTF-16 surrogate pair when the cap lands mid-pair (e.g. + // an astral-plane character such as an emoji); drop the lone high surrogate. + const tail = truncated.charCodeAt(truncated.length - 1); + return tail >= 0xd800 && tail <= 0xdbff ? truncated.slice(0, -1) : truncated; +} + +/** + * Pre-normalized search query. Callers that match the same query against many + * haystacks (e.g. session-history search scans every session per keystroke) + * can prepare the query once per scan instead of paying NFKC normalization, + * tokenization and compaction once per session. + */ +export type PreparedSearchQuery = { + /** Normalized (NFKC, dash-folded, lowercased, trimmed) query. */ + normalized: string; + /** Non-empty tokens of `normalized` after separator splitting. */ + tokens: string[]; + /** Separator-removed form of `normalized` (same as `compactText(normalized)`). */ + compact: string; + /** `normalized` looks like a dotted IPv4 address (contiguous-literal mode). */ + ipv4Like: boolean; +}; + +const EMPTY_PREPARED_QUERY: PreparedSearchQuery = { + normalized: "", + tokens: [], + compact: "", + ipv4Like: false, +}; + +export function prepareSearchQuery(query: string): PreparedSearchQuery { + // No length cap here: truncating globally would silently change match + // semantics for every caller (an over-limit query's tail would be dropped, + // so a field containing only the prefix would report a match). Callers that + // rescan many haystacks bound their own input via `truncateQueryForMatch`. + const normalized = normalizeText(query); + if (!normalized) return EMPTY_PREPARED_QUERY; + return { + normalized, + tokens: normalized.split(SEARCH_SPLIT_REGEX).filter(Boolean), + compact: compactText(normalized), + ipv4Like: IPV4_LIKE_REGEX.test(normalized), + }; +} + +/** + * Pre-normalized search haystack. Callers that match the same fields against + * many queries (e.g. session-history search fires on every keystroke) can + * prepare the haystack once, cache it, and reuse it per query instead of + * re-running NFKC normalization and compaction over megabytes each time. + */ +export type PreparedSearchFields = { + /** Per-field normalized text, in input order. */ + normalizedFields: string[]; + /** Normalized fields joined with a single space. */ + haystack: string; + /** Separator-removed form of `haystack` (same as `compactText(haystack)`). */ + haystackCompact: string; +}; + +export function prepareSearchFields( + fields: Array, +): PreparedSearchFields { const normalizedFields = fields .filter((field): field is string => typeof field === "string" && field.trim().length > 0) .map((field) => normalizeText(field)); - if (normalizedFields.length === 0) return false; + const haystack = normalizedFields.join(" "); + return { normalizedFields, haystack, haystackCompact: compactText(haystack) }; +} + +export function matchesPreparedSearchQuery( + query: string | PreparedSearchQuery, + prepared: PreparedSearchFields, + options: SearchMatchOptions = {}, +): boolean { + // Raw strings are still accepted (and prepared inline) for one-off callers; + // repeated-match callers pass `prepareSearchQuery(query)` instead. + const { normalized: normalizedQuery, tokens, compact: compactQuery, ipv4Like } = + typeof query === "string" ? prepareSearchQuery(query) : query; + if (!normalizedQuery) return true; + + if (prepared.normalizedFields.length === 0) return false; // For dotted numeric input (IPv4-like), require contiguous literal match. - if (IPV4_LIKE_REGEX.test(normalizedQuery)) { - return normalizedFields.some((field) => field.includes(normalizedQuery)); + if (ipv4Like) { + return prepared.normalizedFields.some((field) => field.includes(normalizedQuery)); } - const tokens = tokenizeSearchQuery(normalizedQuery); if (tokens.length === 0) return false; - const sourceText = normalizedFields.join(" "); - const haystack = sourceText; + const haystack = prepared.haystack; if (haystack.includes(normalizedQuery)) { return true; } - const haystackCompact = compactText(sourceText); - const compactQuery = compactText(normalizedQuery); - if (compactQuery && haystackCompact.includes(compactQuery)) { + if (compactQuery && prepared.haystackCompact.includes(compactQuery)) { return true; } @@ -123,9 +259,15 @@ export function matchesSearchQuery( } const hasLatinToken = tokens.some((token) => /[a-z]/i.test(token)); - if (!hasLatinToken) return false; + if (!hasLatinToken || options.allowPinyin === false) return false; - const { full, initials } = getPinyinVariants(sourceText); + const pinyinSource = options.pinyinFields === undefined + ? haystack + : options.pinyinFields + .filter((field): field is string => typeof field === "string" && field.trim().length > 0) + .map((field) => normalizeText(field)) + .join(" "); + const { full, initials } = getPinyinVariants(pinyinSource); if (!full && !initials) return false; return tokens.every((token) => { @@ -138,6 +280,68 @@ export function matchesSearchQuery( }); } +const DEFAULT_SEARCH_MATCH_OPTIONS: Required> = { + allowPinyin: true, +}; + +export function matchesSearchQuery( + query: string, + ...rest: Array +): boolean { + const args = [...rest]; + const options: Pick & { pinyinFields?: string[] } = + args.length > 0 + && typeof args[args.length - 1] === "object" + && args[args.length - 1] !== null + ? { ...DEFAULT_SEARCH_MATCH_OPTIONS, ...(args.pop() as SearchMatchOptions) } + : DEFAULT_SEARCH_MATCH_OPTIONS; + const fields = args as Array; + return matchesPreparedSearchQuery( + query, + prepareSearchFields(fields), + { allowPinyin: options.allowPinyin, pinyinFields: options.pinyinFields }, + ); +} + +/** + * Word-prefix matcher: the query must prefix-match a whole token (word) of one + * of the fields — including that token's pinyin full/initials forms. Strictly + * weaker than matchesSearchQuery's substring behavior; used where substring + * noise is undesirable, e.g. snippet completions while typing an argument + * (issue #3622). Dashes/underscores separate tokens, so "set-email" still + * matches from the word "email". + */ +export function matchesWordPrefixQuery( + query: string, + ...fields: Array +): boolean { + const normalizedQuery = normalizeText(query); + if (!normalizedQuery) return true; + + const words = fields + .filter((field): field is string => typeof field === "string" && field.trim().length > 0) + .flatMap((field) => normalizeText(field).split(SEARCH_SPLIT_REGEX)) + .filter((word) => word.length > 0); + if (words.length === 0) return false; + + // The compact/pinyin fallback must not discard the query's leading + // punctuation: option-style needles like "--ve" or "-d" would otherwise + // match words ("version", "deploy") that never literally start with them + // and displace the real Fig option completions while typing flags. + const compactQuery = /^[a-z0-9]/.test(normalizedQuery) + ? compactText(normalizedQuery) + : ""; + return words.some((word) => { + if (word.startsWith(normalizedQuery)) return true; + if (!compactQuery) return false; + const { full, initials } = getPinyinVariants(word); + return ( + (full !== "" && full.startsWith(compactQuery)) + || (initials !== "" && initials.startsWith(normalizedQuery)) + ); + }); +} + /** * Host search should avoid mixing label/group tokens with hostname/IP tokens. * Otherwise queries like "山东 6-1" can accidentally match: diff --git a/lib/uploadController.ts b/lib/uploadController.ts index 206ec43db..062526420 100644 --- a/lib/uploadController.ts +++ b/lib/uploadController.ts @@ -2,6 +2,7 @@ import type { UploadBridge } from "./uploadService.types"; export class UploadController { private cancelled = false; + private cancelledFileIds = new Set(); private activeFileTransferIds = new Set(); private activeCompressionIds = new Set(); private currentTransferId = ""; @@ -47,16 +48,7 @@ export class UploadController { // Cancel all active file uploads const activeIds = Array.from(this.activeFileTransferIds); for (const transferId of activeIds) { - try { - if (this.bridge?.cancelStagedUploadFile) { - await this.bridge.cancelStagedUploadFile(transferId); - } - if (this.bridge?.cancelTransfer) { - await this.bridge.cancelTransfer(transferId); - } - } catch { - // Ignore cancel errors - } + await this.cancelFile(transferId); } // Also cancel current one if not in the set @@ -71,11 +63,22 @@ export class UploadController { } } + /** Cancel one standalone file without stopping the shared upload queue. */ + async cancelFile(transferId: string): Promise { + this.cancelledFileIds.add(transferId); + try { + await this.bridge?.cancelStagedUploadFile?.(transferId); + await this.bridge?.cancelTransfer?.(transferId); + } catch { + // The transfer runtime reconciles backend cancellation failures. + } + } + /** * Check if upload was cancelled */ - isCancelled(): boolean { - return this.cancelled; + isCancelled(fileId?: string): boolean { + return this.cancelled || (!!fileId && this.cancelledFileIds.has(fileId)); } /** @@ -98,6 +101,7 @@ export class UploadController { */ reset(): void { this.cancelled = false; + this.cancelledFileIds.clear(); this.cancelListeners.clear(); this.activeFileTransferIds.clear(); this.activeCompressionIds.clear(); diff --git a/lib/uploadService.ts b/lib/uploadService.ts index 0e12fa463..0074fe992 100644 --- a/lib/uploadService.ts +++ b/lib/uploadService.ts @@ -769,6 +769,7 @@ async function uploadEntries( standaloneTransferId: string, fileTotalBytes: number, ): Promise<{ cancelled?: boolean; error?: string }> => { + if (controller?.isCancelled(standaloneTransferId)) return { cancelled: true }; let localFilePath = getDropEntryLocalPath(entry); let ownedTempPath: string | undefined; @@ -784,13 +785,13 @@ async function uploadEntries( ownedTempPath = await bridge.stageUploadFile(entry.file, standaloneTransferId); } catch (error) { controller?.removeActiveTransfer(standaloneTransferId); - if (controller?.isCancelled() || /cancel/i.test(error instanceof Error ? error.message : String(error))) { + if (controller?.isCancelled(standaloneTransferId) || /cancel/i.test(error instanceof Error ? error.message : String(error))) { return { cancelled: true }; } throw error; } localFilePath = ownedTempPath; - if (controller?.isCancelled()) { + if (controller?.isCancelled(standaloneTransferId)) { await bridge.deleteTempFile(ownedTempPath).catch(() => {}); return { cancelled: true }; } @@ -956,6 +957,11 @@ async function uploadEntries( ); if (uploadResult.cancelled) { + if (!bundleTaskId && !controller?.isCancelled()) { + settleFileTask(standaloneTransferId, lifecycleManaged, (taskId) => callbacks?.onTaskCancelled?.(taskId)); + results.push({ fileName: entry.relativePath, success: false, cancelled: true }); + continue; + } wasCancelled = true; settleFileTask(bundledChildTaskId, lifecycleManaged, (taskId) => callbacks?.onTaskCancelled?.(taskId)); settleTask(bundleTaskId ?? "", (taskId) => callbacks?.onTaskCancelled?.(taskId)); diff --git a/package-lock.json b/package-lock.json index ba6d32eb2..36fb25bda 100644 --- a/package-lock.json +++ b/package-lock.json @@ -100,7 +100,7 @@ "@vitejs/plugin-react": "^5.1.2", "concurrently": "^9.2.1", "cross-env": "^10.1.0", - "electron": "^42.3.3", + "electron": "^42.11.12", "electron-builder": "^26.15.2", "esbuild": "0.27.2", "eslint": "^9.39.1", @@ -2400,6 +2400,16 @@ "url": "https://github.com/sponsors/colinhacks" } }, + "node_modules/@electron-internal/extract-zip": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/@electron-internal/extract-zip/-/extract-zip-1.0.5.tgz", + "integrity": "sha512-+bqFCP98pLI0Tt0XQo1TmlXtwjWchISndDOxCkEcIuUgXWpBnLyRI+2DU+mesvnMMX6L1XDqYNA0lXNDHd/yiA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=22.12.0" + } + }, "node_modules/@electron/asar": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/@electron/asar/-/asar-4.2.0.tgz", @@ -10076,16 +10086,6 @@ "ieee754": "^1.1.13" } }, - "node_modules/buffer-crc32": { - "version": "0.2.13", - "resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-0.2.13.tgz", - "integrity": "sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": "*" - } - }, "node_modules/buffer-from": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", @@ -11366,15 +11366,15 @@ } }, "node_modules/electron": { - "version": "42.3.3", - "resolved": "https://registry.npmjs.org/electron/-/electron-42.3.3.tgz", - "integrity": "sha512-0MwYp9wTb7TrtTalOYqeW+suqd9T/Znstr/nDLKqFGIjHdBZX339guo3mQqTPURRZ/UQmYM4uMpzKpI5wLptfQ==", + "version": "42.11.12", + "resolved": "https://registry.npmjs.org/electron/-/electron-42.11.12.tgz", + "integrity": "sha512-fHlCHh1uqfvWykQUq8eoSoB5q1gej7z/KKd3m7rqEEhHhihm7qz/EvBIkDat7vQoIq3kGTJcrskLbJ6UFkYGyg==", "dev": true, "license": "MIT", "dependencies": { + "@electron-internal/extract-zip": "^1.0.1", "@electron/get": "^5.0.0", - "@types/node": "^24.9.0", - "extract-zip": "^2.0.1" + "@types/node": "^24.9.0" }, "bin": { "electron": "cli.js", @@ -12203,27 +12203,6 @@ "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==", "license": "MIT" }, - "node_modules/extract-zip": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/extract-zip/-/extract-zip-2.0.1.tgz", - "integrity": "sha512-GDhU9ntwuKyGXdZBUgTIe+vXnWj0fppUEtMDL0+idd5Sta8TGpHssn/eusA9mrPr9qNDym6SxAYZjNvCn/9RBg==", - "dev": true, - "license": "BSD-2-Clause", - "dependencies": { - "debug": "^4.1.1", - "get-stream": "^5.1.0", - "yauzl": "^2.10.0" - }, - "bin": { - "extract-zip": "cli.js" - }, - "engines": { - "node": ">= 10.17.0" - }, - "optionalDependencies": { - "@types/yauzl": "^2.9.1" - } - }, "node_modules/fast-check": { "version": "4.9.0", "resolved": "https://registry.npmjs.org/fast-check/-/fast-check-4.9.0.tgz", @@ -12339,16 +12318,6 @@ "url": "https://github.com/sponsors/wooorm" } }, - "node_modules/fd-slicer": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/fd-slicer/-/fd-slicer-1.1.0.tgz", - "integrity": "sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g==", - "dev": true, - "license": "MIT", - "dependencies": { - "pend": "~1.2.0" - } - }, "node_modules/fetch-blob": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/fetch-blob/-/fetch-blob-3.2.0.tgz", @@ -20198,17 +20167,6 @@ "node": ">=12" } }, - "node_modules/yauzl": { - "version": "2.10.0", - "resolved": "https://registry.npmjs.org/yauzl/-/yauzl-2.10.0.tgz", - "integrity": "sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g==", - "dev": true, - "license": "MIT", - "dependencies": { - "buffer-crc32": "~0.2.3", - "fd-slicer": "~1.1.0" - } - }, "node_modules/yjs": { "version": "13.6.31", "resolved": "https://registry.npmjs.org/yjs/-/yjs-13.6.31.tgz", diff --git a/package.json b/package.json index dfac80b90..b933ab6b1 100644 --- a/package.json +++ b/package.json @@ -157,7 +157,7 @@ "@vitejs/plugin-react": "^5.1.2", "concurrently": "^9.2.1", "cross-env": "^10.1.0", - "electron": "^42.3.3", + "electron": "^42.11.12", "electron-builder": "^26.15.2", "esbuild": "0.27.2", "eslint": "^9.39.1", diff --git a/types/global/netcatty-bridge-session.d.ts b/types/global/netcatty-bridge-session.d.ts index 19ca90002..84af401c2 100644 --- a/types/global/netcatty-bridge-session.d.ts +++ b/types/global/netcatty-bridge-session.d.ts @@ -39,6 +39,8 @@ declare global { interface NetcattyBridge { getWindowsPtyInfo?(): NetcattyWindowsPtyInfo | null; startSSHSession(options: NetcattySSHOptions): Promise; + testConnection?(options: NetcattySSHOptions): Promise; + cancelTestConnection?(sessionId: string): Promise<{ success: boolean; error?: string }>; startTelnetSession?(options: { sessionId?: string; hostname: string; @@ -316,7 +318,7 @@ declare global { }>; netRxSpeed: number; // Total network receive speed (bytes/sec) netTxSpeed: number; // Total network transmit speed (bytes/sec) - latencyMs: number | null; // TCP connection establishment latency to the SSH endpoint + latencyMs: number | null; // SSH transport ping on the existing session, not a second TCP dial netInterfaces: Array<{ // Per-interface network stats name: string; // Interface name (e.g., eth0, ens33) rxBytes: number; // Total received bytes @@ -529,7 +531,7 @@ declare global { ): () => void; onSessionExit( sessionId: string, - cb: (evt: { exitCode?: number; signal?: number; error?: string; reason?: "exited" | "error" | "timeout" | "closed" }) => void + cb: (evt: { exitCode?: number; signal?: number; exitSignal?: string; error?: string; reason?: "exited" | "error" | "timeout" | "closed" | "signaled"; remoteClosedWithoutExitStatus?: boolean }) => void ): () => void; onTelnetAutoLoginComplete?( sessionId: string, diff --git a/types/global/netcatty-bridge-sftp.d.ts b/types/global/netcatty-bridge-sftp.d.ts index f12beee9a..3e77f0f78 100644 --- a/types/global/netcatty-bridge-sftp.d.ts +++ b/types/global/netcatty-bridge-sftp.d.ts @@ -47,7 +47,7 @@ declare global { totalBytes: number; } ): Promise<{ compressionId: string; success?: boolean; error?: string }>; - cancelCompressedUpload?(compressionId: string): Promise<{ success: boolean }>; + cancelCompressedUpload?(compressionId: string): Promise<{ success: boolean; inactive?: boolean }>; pauseCompressedUpload?(compressionId: string): Promise<{ success: boolean; deferred?: boolean; lifecycleEpoch?: number; reason?: string }>; resumeCompressedUpload?(compressionId: string): Promise<{ success: boolean; lifecycleEpoch?: number; reason?: string }>; checkCompressedUploadSupport?(sftpId: string): Promise<{ diff --git a/types/global/netcatty-bridge-sync.d.ts b/types/global/netcatty-bridge-sync.d.ts index 588a4ea3c..18e666527 100644 --- a/types/global/netcatty-bridge-sync.d.ts +++ b/types/global/netcatty-bridge-sync.d.ts @@ -58,6 +58,37 @@ declare global { sourceSession: import("../../domain/models").TerminalSession; localShellType?: import("../../domain/models").TerminalSession['shellType']; }): Promise<{ success: boolean; error?: string }>; + openEditorWindow?(payload: import("../../application/state/editorWindowTypes").EditorWindowTabSnapshot): Promise<{ success: boolean; reused?: boolean; error?: string }>; + focusEditorWindow?(editorId: string): Promise<{ success: boolean; error?: string }>; + closeEditorWindowTabs?(payload: { editorIds: string[]; force?: boolean }): Promise<{ + success: boolean; + cancelled?: boolean; + closedIds?: string[]; + error?: string; + }>; + saveEditorWindowTab?(payload: { + editorId: string; + sessionId: string; + sftpTabId: string; + hostId: string; + remotePath: string; + content: string; + }): Promise<{ ok: boolean; liveConnectionId?: string; error?: string }>; + dockEditorWindowTab?(payload: import("../../application/state/editorWindowTypes").EditorWindowTabSnapshot): Promise<{ success: boolean; error?: string }>; + reportEditorWindowDirty?(payload: { editorId: string; dirty: boolean }): void; + reportEditorWindowTabsClosed?(payload: { editorIds?: string[]; editorId?: string }): void; + remapEditorWindowSession?(payload: { fromSessionId: string; toSessionId: string }): void; + onEditorWindowOpenTab?(cb: (payload: import("../../application/state/editorWindowTypes").EditorWindowTabSnapshot) => void): () => void; + onEditorWindowActivateTab?(cb: (payload: { editorId: string }) => void): () => void; + onEditorWindowCloseTabs?(cb: (payload: import("../../application/state/editorWindowTypes").EditorWindowCloseTabsRequest) => void): () => void; + reportEditorWindowCloseTabsResult?(payload: import("../../application/state/editorWindowTypes").EditorWindowCloseTabsResult): void; + onEditorWindowRemapSession?(cb: (payload: { fromSessionId: string; toSessionId: string }) => void): () => void; + onEditorWindowSaveRequest?(cb: (payload: import("../../application/state/editorWindowTypes").EditorWindowSaveRequest) => void): () => void; + reportEditorWindowSaveResult?(payload: import("../../application/state/editorWindowTypes").EditorWindowSaveResult): void; + onEditorWindowDockRequest?(cb: (payload: import("../../application/state/editorWindowTypes").EditorWindowTabSnapshot & { requestId: string }) => void): () => void; + reportEditorWindowDockResult?(payload: { requestId: string; ok: boolean; error?: string }): void; + onEditorWindowDirtyChanged?(cb: (payload: { editorId: string; dirty: boolean }) => void): () => void; + onEditorWindowTabsClosed?(cb: (payload: { editorIds: string[] }) => void): () => void; onOpenSessionInNewWindow?(cb: (payload: { title: string; sourceSession: import("../../domain/models").TerminalSession;