mirror of
https://github.com/binaricat/Netcatty.git
synced 2026-10-29 18:11:40 +00:00
Compare commits
40
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0d942d4563 | ||
|
|
71373d1ad1 | ||
|
|
b4e056dd4a | ||
|
|
d5c7d3fbf4 | ||
|
|
1ab07ffee4 | ||
|
|
4af4af274b | ||
|
|
ccfc88cd50 | ||
|
|
602a24a991 | ||
|
|
5df2181392 | ||
|
|
451205770e | ||
|
|
231982d673 | ||
|
|
8eb52853a0 | ||
|
|
11c90b45ec | ||
|
|
ac644a5e51 | ||
|
|
0f890701d4 | ||
|
|
21e6a91f36 | ||
|
|
d3c4273886 | ||
|
|
747fdeb0f3 | ||
|
|
0bb75055c4 | ||
|
|
f82355cf1b | ||
|
|
43249cd7e0 | ||
|
|
483410b132 | ||
|
|
201ebcb21a | ||
|
|
6ac6c3dbfa | ||
|
|
11f2b7dbd8 | ||
|
|
af574078e7 | ||
|
|
c89e4c7246 | ||
|
|
282a557f67 | ||
|
|
7cb3e2e2ad | ||
|
|
90ab87efcc | ||
|
|
4cd6a7191d | ||
|
|
3a0528bbd4 | ||
|
|
c349d4716f | ||
|
|
f7483f0593 | ||
|
|
1209bfa61c | ||
|
|
6baa87e3c3 | ||
|
|
a1d1115e0a | ||
|
|
16376a2117 | ||
|
|
f0833049e0 | ||
|
|
75c155b748 |
No files matched your search
@@ -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: |
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<Array<{ ruleId: string; start: boolean }>>([]);
|
||||
|
||||
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),
|
||||
});
|
||||
|
||||
@@ -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) => (
|
||||
<LazyLoadBoundary key={tab.id} name="Editor" resetKey={tab.id}>
|
||||
<Suspense fallback={<TextEditorTabFallback tabId={tab.id} />}>
|
||||
<LazyTextEditorTabView
|
||||
@@ -797,6 +827,7 @@ function AppViewInner({ domains }: AppViewProps) {
|
||||
keyBindings={keyBindings}
|
||||
hostById={hostById}
|
||||
onRequestClose={(id) => handleRequestCloseEditorTabRef.current(id)}
|
||||
onPopOut={(id) => { void handlePopOutEditorTab(id); }}
|
||||
/>
|
||||
</Suspense>
|
||||
</LazyLoadBoundary>
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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<void>;
|
||||
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");
|
||||
});
|
||||
@@ -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) {
|
||||
|
||||
@@ -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.retryHint': 'You can retry by sending your message again.',
|
||||
'ai.chat.approvalTimeout': 'Tool approval timed out after 5 minutes. You can retry by sending your message again.',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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.retryHint': 'Puedes reintentar enviando tu mensaje de nuevo.',
|
||||
'ai.chat.approvalTimeout': 'La aprobación de la herramienta expiró después de 5 minutos. Puedes reintentar enviando tu mensaje de nuevo.',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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.retryHint': 'Вы можете повторить попытку, отправив сообщение ещё раз.',
|
||||
'ai.chat.approvalTimeout': 'Время ожидания одобрения инструмента истекло через 5 минут. Вы можете повторить попытку, отправив сообщение ещё раз.',
|
||||
|
||||
@@ -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': 'Выполняется',
|
||||
|
||||
@@ -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': 'Отбросить',
|
||||
|
||||
@@ -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': 'Общие',
|
||||
|
||||
@@ -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<string, string>)[key]);
|
||||
assert.deepEqual(missing, [], `${locale} is missing scripts view-toggle copy`);
|
||||
}
|
||||
});
|
||||
@@ -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`);
|
||||
}
|
||||
});
|
||||
@@ -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.retryHint': '你可以重新发送消息来重试。',
|
||||
'ai.chat.approvalTimeout': '工具审批已超时(5 分钟)。你可以重新发送消息来重试。',
|
||||
|
||||
@@ -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': '隐藏后台任务',
|
||||
|
||||
@@ -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': '运行中',
|
||||
|
||||
@@ -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': '不保存',
|
||||
|
||||
@@ -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': '关闭断连提示',
|
||||
|
||||
@@ -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.retryHint': '你可以重新發送訊息來重試。',
|
||||
'ai.chat.approvalTimeout': '工具核准已超時(5 分鐘)。你可以重新發送訊息來重試。',
|
||||
|
||||
@@ -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': '隱藏背景工作',
|
||||
|
||||
@@ -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': '執行中',
|
||||
|
||||
@@ -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': '不儲存',
|
||||
|
||||
@@ -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': '關閉斷連提示',
|
||||
|
||||
@@ -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[] = [];
|
||||
|
||||
@@ -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] = {
|
||||
|
||||
@@ -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<typeof ops.onEditFileLeft>[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<typeof ops.onEditFileLeft>[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");
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
@@ -28,6 +28,20 @@ const makeTab = (overrides: Partial<EditorTab> = {}): 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());
|
||||
|
||||
@@ -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<EditorTab> = {
|
||||
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<void>,
|
||||
onCloseTab?: (tabId: EditorTabId) => void,
|
||||
): Promise<boolean> => {
|
||||
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 = (
|
||||
|
||||
@@ -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<void>((resolve) => { finish = resolve; });
|
||||
t.after(registerEditorSftpWriterScoped(async () => { await pending; return "conn"; }));
|
||||
let onSave!: (request: EditorWindowSaveRequest) => Promise<void>;
|
||||
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);
|
||||
});
|
||||
@@ -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<boolean> {
|
||||
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<boolean> {
|
||||
const result = await netcattyBridge.get()?.focusEditorWindow?.(editorId);
|
||||
return result?.success === true;
|
||||
}
|
||||
|
||||
export async function popOutEditorTab(
|
||||
tabId: EditorTabId,
|
||||
hostLabel?: string,
|
||||
): Promise<boolean> {
|
||||
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<void>,
|
||||
onCloseTab?: (tabId: EditorTabId) => void,
|
||||
): Promise<boolean> {
|
||||
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>,
|
||||
): () => 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();
|
||||
};
|
||||
}
|
||||
@@ -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[];
|
||||
}
|
||||
@@ -8,10 +8,20 @@ import {
|
||||
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" },
|
||||
);
|
||||
});
|
||||
|
||||
@@ -57,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", () => {
|
||||
@@ -133,6 +151,17 @@ test("exit messages show the reported code for a real remote exit", () => {
|
||||
);
|
||||
});
|
||||
|
||||
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({
|
||||
@@ -150,3 +179,25 @@ test("exit messages without a reported code stay plain instead of showing a sent
|
||||
);
|
||||
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]",
|
||||
);
|
||||
});
|
||||
@@ -4,7 +4,10 @@ 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".
|
||||
@@ -16,6 +19,13 @@ export function formatSessionClosedExitMessage(evt: TerminalSessionExitEvent): s
|
||||
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})` : ""}]`;
|
||||
}
|
||||
|
||||
@@ -34,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" };
|
||||
|
||||
@@ -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<Record<string, unknown>>,
|
||||
) {
|
||||
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<string, unknown> = {};
|
||||
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<string>();
|
||||
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<string>();
|
||||
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<string>();
|
||||
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<string>();
|
||||
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);
|
||||
});
|
||||
@@ -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<SessionHistorySearchTarget, SessionSearchIndex>;
|
||||
/**
|
||||
* 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<SessionHistorySearchTarget>;
|
||||
/**
|
||||
* 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<SessionHistorySearchTarget>(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<T extends SessionHistorySearchTarget>(
|
||||
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<SessionHistorySearchTarget>(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: <T extends SessionHistorySearchTarget>(
|
||||
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);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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<string, string> {
|
||||
@@ -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;
|
||||
}
|
||||
});
|
||||
@@ -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<Partial<TerminalSettings>>(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)
|
||||
|
||||
@@ -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<string, string>();
|
||||
const pending = new Map<string, (cancelled?: boolean) => 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 }
|
||||
)));
|
||||
});
|
||||
}
|
||||
@@ -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<string, UploadController>();
|
||||
const scopesByTaskId = new Map<string, "file" | "batch">();
|
||||
const taskIdsByController = new Map<UploadController, Set<string>>();
|
||||
|
||||
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<string>();
|
||||
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<boolean> {
|
||||
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<bool
|
||||
|
||||
export function resetExternalUploadRuntimeForTests(): void {
|
||||
controllersByTaskId.clear();
|
||||
scopesByTaskId.clear();
|
||||
taskIdsByController.clear();
|
||||
}
|
||||
@@ -520,3 +520,24 @@ test("folder resume releases a completed child compacted while pause was drainin
|
||||
assert.equal(resumed.handled, true);
|
||||
assert.equal(isTransferPauseLatched(child.id), false, "the worker still waits on this child even though its history row is gone");
|
||||
});
|
||||
|
||||
|
||||
test("late pause acknowledgement cannot restart a cancelling stream before settlement", async (t) => {
|
||||
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);
|
||||
});
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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<string>,
|
||||
failedIds: ReadonlySet<string>,
|
||||
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,
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -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> = {}): 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<void>((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<void>((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<typeof netcattyBridge.get>);
|
||||
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<typeof netcattyBridge.get>);
|
||||
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<typeof netcattyBridge.get>);
|
||||
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<void>((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<typeof netcattyBridge.get>);
|
||||
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<void>((resolve) => setImmediate(resolve));
|
||||
active -= 1;
|
||||
cleared += 1;
|
||||
},
|
||||
} as unknown as ReturnType<typeof netcattyBridge.get>);
|
||||
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<void>((resolve) => { release = resolve; });
|
||||
let walking: Promise<unknown> | 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<typeof netcattyBridge.get>);
|
||||
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<typeof netcattyBridge.get>);
|
||||
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<typeof netcattyBridge.get>);
|
||||
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"]);
|
||||
});
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -51,6 +51,7 @@ export interface TransferRuntime {
|
||||
* walk is dead / reconnectRequired. Single external operation.
|
||||
*/
|
||||
resume(taskId: string): Promise<void>;
|
||||
/** Wait for backend cancellation requests; Resume remains fenced on walk settlement. */
|
||||
cancel(taskId: string): Promise<void>;
|
||||
|
||||
/**
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
*/
|
||||
|
||||
const inFlightRootIds = new Set<string>();
|
||||
const settlementWaiters = new Map<string, Set<() => 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<void> {
|
||||
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[] {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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<typeof useSftpTransfers> | 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<void>((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 });
|
||||
}
|
||||
});
|
||||
@@ -1418,77 +1418,66 @@ export const useSftpTransfers = ({
|
||||
releasePausedTransfer(transferId);
|
||||
globalSftpTransferScheduler.cancel(transferId);
|
||||
|
||||
// Cancel parent + remove child tasks
|
||||
const childIdsToCancel = new Set<string>();
|
||||
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);
|
||||
|
||||
@@ -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<typeof createSftpTransferCenterStore> | 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<typeof netcattyBridge.get>);
|
||||
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");
|
||||
}
|
||||
});
|
||||
@@ -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<void>;
|
||||
resume(taskId: string): Promise<void>;
|
||||
/** Wait for backend cancellation requests; live walks settle separately. */
|
||||
cancel(taskId: string): Promise<void>;
|
||||
/** Publish one cancellation outcome after the old execution has settled. */
|
||||
settleCancellation(taskId: string, taskIds: ReadonlySet<string>, failedIds: ReadonlySet<string>, retainedTasks: readonly RetainedCancellationTask[], onSettled: () => Promise<void>): Promise<void>;
|
||||
retry(taskId: string): Promise<void>;
|
||||
prioritize(taskId: string): Promise<void>;
|
||||
dismiss(taskId: string): void;
|
||||
@@ -464,6 +475,8 @@ export function createSftpTransferCenterStore(persistence?: StorePersistence): S
|
||||
let persistenceDirty = false;
|
||||
let persistenceTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
const resumeInvocations = new Map<string, Promise<void>>();
|
||||
const cancelInvocations = new Map<string, Promise<void>>();
|
||||
const cancelSettlements = new Map<string, Promise<void>>();
|
||||
// Ephemeral control intent: never serialize this or change bridge lifecycle epochs.
|
||||
const resumeRequests = new Map<string, symbol>();
|
||||
const resumeListeners = new Set<Listener>();
|
||||
@@ -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<string>();
|
||||
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);
|
||||
|
||||
@@ -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" },
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -86,6 +86,11 @@ import {
|
||||
retargetWorkspaceActiveChatAfterMemberLoss,
|
||||
seedWorkspaceAIActiveSessionFromMembers,
|
||||
} from '../../domain/workspaceAiScopeHandoff';
|
||||
import {
|
||||
buildUndoLastTurnBranch,
|
||||
type UndoLastTurnRestoredDraft,
|
||||
} from '../../domain/aiUndoLastTurn';
|
||||
|
||||
|
||||
function providerPatchIsNoop(
|
||||
current: ProviderConfig,
|
||||
@@ -697,6 +702,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) {
|
||||
@@ -1275,6 +1306,7 @@ export function useAIState() {
|
||||
addDraftFiles,
|
||||
removeDraftFile,
|
||||
createSession,
|
||||
undoLastTurnInSession,
|
||||
deleteSession,
|
||||
deleteSessionsByTarget,
|
||||
updateSessionTitle,
|
||||
@@ -1335,6 +1367,7 @@ export function useAIState() {
|
||||
addDraftFiles,
|
||||
removeDraftFile,
|
||||
createSession,
|
||||
undoLastTurnInSession,
|
||||
deleteSession,
|
||||
deleteSessionsByTarget,
|
||||
updateSessionTitle,
|
||||
|
||||
@@ -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<typeof useAIState> | null;
|
||||
sessions: AISession[];
|
||||
activeSessionIdMap: Record<string, string | null>;
|
||||
};
|
||||
|
||||
function UndoHarness({ capture }: { capture: HookCapture }) {
|
||||
capture.ai = useAIState();
|
||||
const store = useAISessionsStore();
|
||||
capture.sessions = store.sessions as AISession[];
|
||||
capture.activeSessionIdMap = store.activeSessionIdMap as Record<string, string | null>;
|
||||
return null;
|
||||
}
|
||||
|
||||
async function setupAiState(sessions: AISession[]) {
|
||||
const dom = new JSDOM('<!doctype html><html><body></body></html>', {
|
||||
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<ReturnType<typeof runUndo>> | 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();
|
||||
});
|
||||
}
|
||||
@@ -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<EditorTabId | null>(
|
||||
() => 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,
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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<void>((resolve) => { release = resolve; });
|
||||
let renderer: ReactTestRenderer | undefined;
|
||||
let actions: ReturnType<typeof useGlobalSftpTransferActions> | 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<typeof netcattyBridge.get>);
|
||||
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<void>((resolve) => { release = resolve; });
|
||||
let renderer: ReactTestRenderer | undefined;
|
||||
let actions: ReturnType<typeof useGlobalSftpTransferActions> | undefined;
|
||||
let cancelling: Promise<void> | 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<typeof netcattyBridge.get>);
|
||||
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");
|
||||
});
|
||||
}
|
||||
@@ -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<string, string[]>();
|
||||
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 };
|
||||
}
|
||||
@@ -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<typeof useHostConnectionTest>;
|
||||
|
||||
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.");
|
||||
});
|
||||
@@ -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<HostConnectionTestStatus>("idle");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [needsAuth, setNeedsAuth] = useState(false);
|
||||
const [progressValue, setProgressValue] = useState(0);
|
||||
const [chainProgress, setChainProgress] = useState<ChainProgress>(null);
|
||||
const [progressLogs, setProgressLogs] = useState<string[]>([]);
|
||||
const [hostKeyVerification, setHostKeyVerification] = useState<HostKeyVerificationState | null>(null);
|
||||
const [timeLeft, setTimeLeft] = useState(0);
|
||||
const [isAwaitingUserInput, setIsAwaitingUserInput] = useState(false);
|
||||
const [isCancelling, setIsCancelling] = useState(false);
|
||||
|
||||
const sessionIdRef = useRef<string | null>(null);
|
||||
const bootEpochRef = useRef(0);
|
||||
const pendingHostKeyRequestIdRef = useRef<string | null>(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,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
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 { Host, Identity, SSHKey, TerminalSession } from '../../domain/models';
|
||||
import { updateRestoredSessionStatusState } from './sessionRestoreState';
|
||||
import { 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> = {}): 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[] } = {}) {
|
||||
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<typeof useHostOpenSessionReuse>;
|
||||
function Harness() {
|
||||
find = useHostOpenSessionReuse({ sessions, hosts, keys, identities, 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');
|
||||
});
|
||||
@@ -0,0 +1,85 @@
|
||||
import { useCallback, useLayoutEffect, useRef } from 'react';
|
||||
import type { Host, Identity, SSHKey, TerminalSession } from '../../domain/models';
|
||||
import { findReusableHostSession } from '../../domain/hostOpenSession';
|
||||
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<string, Promise<string | undefined>>());
|
||||
const connectionKey = useCallback((host: Host) => {
|
||||
// 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.
|
||||
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: host.protocol || 'ssh',
|
||||
moshEnabled: host.moshEnabled,
|
||||
moshServerPath: host.moshServerPath,
|
||||
etEnabled: host.etEnabled,
|
||||
etPort: host.etPort,
|
||||
pluginConnection: host.pluginConnection,
|
||||
serialConfig: host.protocol === 'serial' ? createHostTerminalSession('', host).serialConfig : 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.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,
|
||||
}).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]);
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
@@ -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<string, string>();
|
||||
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<string, PropertyDescriptor | undefined>();
|
||||
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<typeof useScriptsViewMode>;
|
||||
let b!: ReturnType<typeof useScriptsViewMode>;
|
||||
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");
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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<TerminalSettings>(() => {
|
||||
// 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<TerminalSettings>(STORAGE_KEY_TERM_SETTINGS);
|
||||
return normalizeTerminalSettings(stored);
|
||||
});
|
||||
|
||||
@@ -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<NonNullable<NetcattyBridge["startTelnetSession"]>>[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,
|
||||
|
||||
@@ -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<TerminalSession, 'status' | 'customName' | 'hostLabel' | 'hostname' | 'username'> | 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,
|
||||
|
||||
@@ -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<Snippet[]>([]);
|
||||
const groupConfigsRef = useRef<GroupConfig[]>([]);
|
||||
const notesRef = useRef<VaultNote[]>([]);
|
||||
const notesStorageRef = useRef<VaultNote[]>([]);
|
||||
// An optimistic React echo is not a persistence acknowledgement. Keep this
|
||||
// owner until that exact generation has successfully reached storage.
|
||||
const notesPendingRef = useRef<VaultNote[] | null>(null);
|
||||
// Failed clear/import keeps full-snapshot ownership across later local edits.
|
||||
const notesPendingReplaceRef = useRef(false);
|
||||
const noteGroupsRef = useRef<string[]>([]);
|
||||
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<VaultNote>[]) => {
|
||||
const updateNotes = useCallback((data: Partial<VaultNote>[], options?: { replace?: boolean }) => {
|
||||
const stored = normalizeVaultNotes(localStorageAdapter.read<VaultNote[]>(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<VaultNote[]>(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<VaultNote[]>(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));
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
|
||||
@@ -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';
|
||||
@@ -55,6 +55,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;
|
||||
@@ -66,6 +69,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;
|
||||
@@ -128,9 +135,12 @@ export const AIChatPanelContent: React.FC<AIChatPanelContentProps> = ({
|
||||
setInputValue,
|
||||
handleSend,
|
||||
handleCompact,
|
||||
canUndoLastTurn = false,
|
||||
handleUndoLastTurn,
|
||||
canCompact = false,
|
||||
handleSteer,
|
||||
handleStop,
|
||||
handleModelCatalogRetry,
|
||||
canSteer,
|
||||
isSteering,
|
||||
steerWarning,
|
||||
@@ -139,6 +149,7 @@ export const AIChatPanelContent: React.FC<AIChatPanelContentProps> = ({
|
||||
providerDisplayName,
|
||||
modelDisplayName,
|
||||
modelCatalogWarning,
|
||||
modelCatalogLoading = false,
|
||||
agentModelPresets,
|
||||
allowCustomModelEntry = true,
|
||||
selectedAgentModel,
|
||||
@@ -226,6 +237,23 @@ export const AIChatPanelContent: React.FC<AIChatPanelContentProps> = ({
|
||||
onManageAgents={handleOpenSettings}
|
||||
/>
|
||||
<div className="flex items-center gap-0.5">
|
||||
{handleUndoLastTurn && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6 rounded-md text-muted-foreground/62 hover:bg-white/[0.05] hover:text-foreground"
|
||||
aria-label={t('ai.chat.undoLastTurn')}
|
||||
disabled={sending || isStreaming || !canUndoLastTurn}
|
||||
onClick={handleUndoLastTurn}
|
||||
>
|
||||
<Undo2 size={12} />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{t('ai.chat.undoLastTurn')}</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
<ConversationExport
|
||||
session={activeSession}
|
||||
onExport={handleExport}
|
||||
@@ -335,8 +363,20 @@ export const AIChatPanelContent: React.FC<AIChatPanelContentProps> = ({
|
||||
{steerWarning}
|
||||
</div>
|
||||
) : modelCatalogWarning ? (
|
||||
<div role="status" className="mx-3 mb-1.5 rounded-md border border-amber-500/25 bg-amber-500/10 px-2.5 py-1.5 text-[11px] leading-4 text-amber-600 dark:text-amber-400">
|
||||
{modelCatalogWarning}
|
||||
<div role="status" className="mx-3 mb-1.5 flex items-center gap-2 rounded-md border border-amber-500/25 bg-amber-500/10 px-2.5 py-1.5 text-[11px] leading-4 text-amber-600 dark:text-amber-400">
|
||||
<span className="min-w-0 flex-1">{modelCatalogWarning}</span>
|
||||
{handleModelCatalogRetry && (
|
||||
<button
|
||||
onClick={handleModelCatalogRetry}
|
||||
className="shrink-0 rounded px-1.5 py-0.5 text-[11px] font-medium text-amber-600 underline-offset-2 transition-colors hover:bg-amber-500/10 hover:text-amber-500 hover:underline cursor-pointer dark:text-amber-400 dark:hover:text-amber-300"
|
||||
>
|
||||
{t('ai.chat.modelCatalogRetry')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
) : modelCatalogLoading ? (
|
||||
<div role="status" className="mx-3 mb-1.5 rounded-md bg-muted/40 px-2.5 py-1.5 text-[11px] leading-4 text-muted-foreground/70">
|
||||
{t('ai.chat.loadingModels')}
|
||||
</div>
|
||||
) : null}
|
||||
<ChatInput
|
||||
@@ -357,6 +397,7 @@ export const AIChatPanelContent: React.FC<AIChatPanelContentProps> = ({
|
||||
modelName={modelDisplayName}
|
||||
agentName={currentAgentId === 'catty' ? 'Catty Agent' : externalAgents.find(a => a.id === currentAgentId)?.name}
|
||||
modelPresets={agentModelPresets}
|
||||
modelCatalogLoading={modelCatalogLoading}
|
||||
allowCustomModelEntry={allowCustomModelEntry}
|
||||
selectedModelId={selectedAgentModel}
|
||||
onModelSelect={handleAgentModelSelect}
|
||||
|
||||
@@ -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(
|
||||
<I18nProvider locale={locale}>
|
||||
<TooltipProvider>
|
||||
<SessionHistoryDrawer
|
||||
sessions={items}
|
||||
activeSessionId="s-140"
|
||||
onSelect={(id) => selected.push(id)}
|
||||
onDelete={(event, id) => { event.stopPropagation(); deleted.push(id); }}
|
||||
onClose={() => { closed += 1; }}
|
||||
/>
|
||||
</TooltipProvider>
|
||||
</I18nProvider>,
|
||||
);
|
||||
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();
|
||||
}
|
||||
});
|
||||
@@ -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<SessionHistoryDrawerProps> = ({
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
const [renderCount, setRenderCount] = useState(SESSION_RENDER_BATCH);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const searchHintId = useId();
|
||||
const searchInputRef = useRef<HTMLInputElement>(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 (
|
||||
<div className="flex-1 flex flex-col min-h-0">
|
||||
@@ -50,6 +68,41 @@ export const SessionHistoryDrawer: React.FC<SessionHistoryDrawerProps> = ({
|
||||
<X size={14} />
|
||||
</button>
|
||||
</div>
|
||||
<div className="px-3 py-2 shrink-0 border-b border-border/30">
|
||||
<div className="relative">
|
||||
<Search
|
||||
size={13}
|
||||
className="absolute left-2.5 top-1/2 -translate-y-1/2 text-muted-foreground/50 pointer-events-none"
|
||||
/>
|
||||
<Input
|
||||
ref={searchInputRef}
|
||||
type="text"
|
||||
value={searchQuery}
|
||||
maxLength={MAX_SEARCH_QUERY_LENGTH}
|
||||
placeholder={t('ai.chat.searchSessions')}
|
||||
aria-label={t('ai.chat.searchSessions')}
|
||||
aria-describedby={searchHintId}
|
||||
onChange={(event) => setSearchQuery(truncateQueryForMatch(event.target.value))}
|
||||
className="h-8 pl-8 pr-7 text-[12px]"
|
||||
/>
|
||||
{searchQuery && (
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t('ai.chat.clearSessionSearch')}
|
||||
onClick={() => {
|
||||
setSearchQuery('');
|
||||
searchInputRef.current?.focus();
|
||||
}}
|
||||
className="absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground/50 hover:text-muted-foreground transition-colors cursor-pointer"
|
||||
>
|
||||
<X size={12} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<p id={searchHintId} className="mt-1.5 text-[11px] text-muted-foreground/60">
|
||||
{t('ai.chat.searchSessionsHint')}
|
||||
</p>
|
||||
</div>
|
||||
<ScrollArea className="flex-1">
|
||||
<div className="px-3">
|
||||
{sessions.length === 0 ? (
|
||||
@@ -58,6 +111,12 @@ export const SessionHistoryDrawer: React.FC<SessionHistoryDrawerProps> = ({
|
||||
{t('ai.chat.noSessions')}
|
||||
</p>
|
||||
</div>
|
||||
) : filteredSessions.length === 0 ? (
|
||||
<div className="py-12 text-center">
|
||||
<p className="text-[13px] text-muted-foreground/40">
|
||||
{t('ai.chat.noMatchingSessions')}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{hiddenSessionCount > 0 && (
|
||||
|
||||
@@ -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\)/);
|
||||
|
||||
@@ -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<AIChatSidePanelProps> = ({
|
||||
addDraftFiles,
|
||||
removeDraftFile,
|
||||
createSession,
|
||||
undoLastTurnInSession,
|
||||
deleteSession,
|
||||
updateSessionTitle,
|
||||
updateSessionExternalSessionId,
|
||||
@@ -319,8 +321,17 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
|
||||
|
||||
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<Record<string, { cacheKey: string; models: AgentModelPreset[] }>>({});
|
||||
const [runtimeModelWarnings, setRuntimeModelWarnings] = useState<Record<string, { cacheKey: string; message: string }>>({});
|
||||
// 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<Record<string, { cacheKey: string }>>({});
|
||||
const [steerWarnings, setSteerWarnings] = useState<Record<string, SteerWarning>>({});
|
||||
const [steeringSessionId, setSteeringSessionId] = useState<string | null>(null);
|
||||
const [userSkillOptions, setUserSkillOptions] = useState<UserSkillOption[]>([]);
|
||||
@@ -817,6 +828,78 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
|
||||
&& 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;
|
||||
|
||||
@@ -933,6 +1016,14 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
|
||||
: { ...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,
|
||||
@@ -950,7 +1041,7 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
|
||||
) {
|
||||
setAgentModel(agentId, catalog.currentModelId);
|
||||
}
|
||||
}, [setAgentModel, buildExternalAgentRuntimeModelTarget, collectCustomModelIds]);
|
||||
}, [setAgentModel, setRuntimeModelLoading, buildExternalAgentRuntimeModelTarget, collectCustomModelIds]);
|
||||
|
||||
const loadSdkRuntimeModelCatalog = useCallback((
|
||||
target: SdkRuntimeModelTarget,
|
||||
@@ -1029,17 +1120,46 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
|
||||
// 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,
|
||||
@@ -1075,6 +1195,22 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
|
||||
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),
|
||||
@@ -1092,6 +1228,7 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
|
||||
currentAgentConfig,
|
||||
currentAgentId,
|
||||
runtimeAgentModelPresets,
|
||||
runtimeModelLoading,
|
||||
buildExternalAgentRuntimeModelTarget,
|
||||
hasCodexCustomConfig,
|
||||
codexConfigModel,
|
||||
@@ -1119,14 +1256,39 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
|
||||
|
||||
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);
|
||||
@@ -1231,6 +1393,10 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
|
||||
}));
|
||||
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.
|
||||
@@ -1355,6 +1521,7 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
|
||||
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() });
|
||||
@@ -1369,6 +1536,7 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
|
||||
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() });
|
||||
@@ -1382,6 +1550,8 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
|
||||
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() });
|
||||
@@ -1463,7 +1633,7 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
|
||||
}
|
||||
}, [
|
||||
validateNoteMentions, loadCodexConfigModel,
|
||||
isStreaming, activeProvider, effectiveActiveProvider, effectiveActiveModelId, selectedCattyThinking, scopeKey, currentAgentId,
|
||||
isStreaming, isUndoingLastTurn, activeProvider, effectiveActiveProvider, effectiveActiveModelId, selectedCattyThinking, scopeKey, currentAgentId,
|
||||
activeModelId, externalAgents,
|
||||
createSession, addMessageToSession, updateMessageById, updateLastMessage,
|
||||
setStreamingForScope,
|
||||
@@ -1751,7 +1921,7 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
|
||||
>
|
||||
<AIChatPanelContent
|
||||
parked={!isVisible}
|
||||
sending={isSending}
|
||||
sending={isSending || isUndoingLastTurn}
|
||||
t={t}
|
||||
currentAgentId={currentAgentId}
|
||||
externalAgents={externalAgents}
|
||||
@@ -1781,6 +1951,8 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
|
||||
setInputValue={setInputValue}
|
||||
handleSend={handleSend}
|
||||
handleCompact={handleCompact}
|
||||
canUndoLastTurn={canUndoLastTurn}
|
||||
handleUndoLastTurn={undoLastTurnInSession ? handleUndoLastTurn : undefined}
|
||||
handleSteer={handleSteer}
|
||||
handleStop={handleStop}
|
||||
canSteer={canSteerCurrentTurn}
|
||||
@@ -1794,6 +1966,11 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
|
||||
&& 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.
|
||||
|
||||
@@ -37,6 +37,10 @@ export interface AIChatSidePanelProps {
|
||||
addDraftFiles: (scopeKey: string, fallbackAgentId: string, inputFiles: File[]) => Promise<void>;
|
||||
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;
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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 (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
@@ -877,13 +877,16 @@ export function GlobalSftpTransferCenter() {
|
||||
<div className="min-w-0 pr-2 text-sm font-semibold">
|
||||
{t("sftp.transferCenter.title")}
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<Button variant="ghost" size="sm" className="h-7 text-xs" onClick={pauseAll} disabled={batchEligibility.pausableCount === 0}>
|
||||
<div className="flex flex-wrap items-center justify-end gap-1">
|
||||
<Button variant="ghost" size="sm" className="h-7 text-xs" onClick={pauseAll} disabled={isCancelling || batchEligibility.pausableCount === 0}>
|
||||
<Pause size={12} className="mr-1" />{t("sftp.transferCenter.pauseAll")}
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" className="h-7 text-xs" onClick={resumeAll} disabled={batchEligibility.resumableCount === 0}>
|
||||
<Button variant="ghost" size="sm" className="h-7 text-xs" onClick={resumeAll} disabled={isCancelling || batchEligibility.resumableCount === 0}>
|
||||
<Play size={12} className="mr-1" />{t("sftp.transferCenter.resumeAll")}
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" className="h-7 text-xs text-destructive" onClick={() => { void cancelAll(); }} disabled={isCancelling || batchEligibility.cancellableCount === 0}>
|
||||
<X size={12} className="mr-1" />{t("sftp.transferCenter.cancelAll")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -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<HostDetailsPanelPropsWithResize> = ({
|
||||
snippets = [],
|
||||
onSnippetsChange,
|
||||
onHostsChange,
|
||||
knownHosts,
|
||||
onAddKnownHost,
|
||||
terminalSettings,
|
||||
className,
|
||||
resizable,
|
||||
persistWidthStorageKey,
|
||||
@@ -198,6 +206,28 @@ const HostDetailsPanel: React.FC<HostDetailsPanelPropsWithResize> = ({
|
||||
const [sshAgentStatus, setSshAgentStatus] = useState<SshAgentStatus | null>(null);
|
||||
const [sshForwardingAgentStatus, setSshForwardingAgentStatus] = useState<SshAgentStatus | null>(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<HostDetailsPanelPropsWithResize> = ({
|
||||
</Button>
|
||||
)}
|
||||
</AsidePanelContent>
|
||||
<AsidePanelFooter>
|
||||
<AsidePanelFooter className="flex flex-row gap-2">
|
||||
{!pluginProtocolActive && form.protocol !== "telnet" && (
|
||||
<Button
|
||||
variant="outline"
|
||||
className="h-10 px-4"
|
||||
onClick={handleStartTest}
|
||||
disabled={!canSaveHost}
|
||||
>
|
||||
{t("hostConnectionTest.test")}
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
className="w-full h-10"
|
||||
className="flex-1 h-10"
|
||||
onClick={handleSubmit}
|
||||
disabled={!canSaveHost}
|
||||
>
|
||||
{t("common.save")}
|
||||
</Button>
|
||||
</AsidePanelFooter>
|
||||
<HostConnectionTestDialog
|
||||
open={testOpen}
|
||||
onClose={handleCloseTest}
|
||||
host={form}
|
||||
keys={availableKeys}
|
||||
test={hostConnectionTest}
|
||||
/>
|
||||
</AsidePanel>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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(
|
||||
<I18nProvider locale="en"><ScriptsSidePanel snippets={snippets} packages={[]}
|
||||
onSnippetClick={() => assert.fail("scripts use the existing script execution path")}
|
||||
onRunScript={(snippet) => executed.push(snippet.id)} /></I18nProvider>,
|
||||
);
|
||||
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"));
|
||||
});
|
||||
@@ -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' \? <List size=\{14\} \/> : <LayoutGrid size=\{14\} \/>\}/);
|
||||
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/);
|
||||
});
|
||||
@@ -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<string>;
|
||||
/** 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<ScriptsSidePanelProps> = ({
|
||||
const [search, setSearch] = useState('');
|
||||
const [searchExpanded, setSearchExpanded] = useState(false);
|
||||
const [subView, setSubView] = useState<'library' | 'running'>('library');
|
||||
const [viewMode, setViewMode] = useScriptsViewMode();
|
||||
const [expandedPaths, setExpandedPaths] = useState<Set<string>>(new Set());
|
||||
const [isMultiSelectMode, setIsMultiSelectMode] = useState(false);
|
||||
const [selectedSnippetIds, setSelectedSnippetIds] = useState<Set<string>>(new Set());
|
||||
@@ -291,6 +314,7 @@ const ScriptsSidePanelInner: React.FC<ScriptsSidePanelProps> = ({
|
||||
const [renamingPackagePath, setRenamingPackagePath] = useState('');
|
||||
const [newPackageName, setNewPackageName] = useState('');
|
||||
const [packageError, setPackageError] = useState('');
|
||||
const [stackedRenderLimit, setStackedRenderLimit] = useState(STACKED_INITIAL_RENDER_COUNT);
|
||||
const packageDialogRef = useRef<HTMLDivElement>(null);
|
||||
const packageNameInputRef = useRef<HTMLInputElement>(null);
|
||||
const searchInputRef = useRef<HTMLInputElement>(null);
|
||||
@@ -481,8 +505,15 @@ const ScriptsSidePanelInner: React.FC<ScriptsSidePanelProps> = ({
|
||||
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<ScriptsSidePanelProps> = ({
|
||||
});
|
||||
}, [rows, searchMatches, t, isVisible]);
|
||||
|
||||
const stackedSentinelRef = useRef<HTMLDivElement | null>(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<ScriptsSidePanelProps> = ({
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">{t('snippets.searchPlaceholder')}</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
toolbarIconButtonClass,
|
||||
viewMode === 'stacked' && 'bg-muted/70 text-foreground',
|
||||
)}
|
||||
disabled={!hasAnyContent}
|
||||
aria-label={viewMode === 'stacked'
|
||||
? t('scripts.sidePanel.viewList')
|
||||
: t('scripts.sidePanel.viewStacked')}
|
||||
aria-pressed={viewMode === 'stacked'}
|
||||
onClick={() => setViewMode(viewMode === 'stacked' ? 'list' : 'stacked')}
|
||||
>
|
||||
{viewMode === 'stacked' ? <List size={14} /> : <LayoutGrid size={14} />}
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">
|
||||
{viewMode === 'stacked'
|
||||
? t('scripts.sidePanel.viewList')
|
||||
: t('scripts.sidePanel.viewStacked')}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
@@ -1120,6 +1217,107 @@ const ScriptsSidePanelInner: React.FC<ScriptsSidePanelProps> = ({
|
||||
<div className="px-3 py-4 text-xs text-muted-foreground italic text-center">
|
||||
{t('common.noResultsFound')}
|
||||
</div>
|
||||
) : viewMode === 'stacked' ? (
|
||||
<div
|
||||
className="h-full overflow-auto px-2 py-1.5"
|
||||
data-scripts-view="stacked"
|
||||
>
|
||||
<div className="flex flex-wrap items-center content-start gap-1.5">
|
||||
{listItems.slice(0, stackedRenderLimit).map((item) => {
|
||||
if (item.kind === 'package') {
|
||||
// Mirror the list view's PackageRow context menu so stacked
|
||||
// mode keeps rename/delete reachable without switching views.
|
||||
const canMutatePackages = Boolean(onPackagesChange && onSnippetsChange);
|
||||
const header = (
|
||||
<button
|
||||
key={item.key}
|
||||
type="button"
|
||||
onClick={() => togglePackage(item.row.path)}
|
||||
aria-expanded={item.row.isExpanded}
|
||||
className="w-full basis-full flex items-center gap-1.5 pt-1.5 pb-0.5 text-left rounded-sm hover:bg-accent/40 transition-colors"
|
||||
style={{ paddingLeft: 8 + Math.min(item.row.depth, 4) * 10 }}
|
||||
>
|
||||
<ChevronRight
|
||||
size={11}
|
||||
className={cn(
|
||||
'shrink-0 text-muted-foreground transition-transform',
|
||||
item.row.isExpanded && 'rotate-90',
|
||||
!item.row.hasChildren && 'opacity-0',
|
||||
)}
|
||||
/>
|
||||
<Package size={11} className="shrink-0 text-primary/80" />
|
||||
<span className="min-w-0 truncate text-[10px] font-medium text-muted-foreground">
|
||||
{item.row.name}
|
||||
</span>
|
||||
<span className="shrink-0 text-[10px] text-muted-foreground tabular-nums">
|
||||
{item.countLabel}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
if (!canMutatePackages) {
|
||||
return header;
|
||||
}
|
||||
return (
|
||||
<ContextMenu key={item.key}>
|
||||
<ContextMenuTrigger asChild>
|
||||
{header}
|
||||
</ContextMenuTrigger>
|
||||
<ContextMenuContent>
|
||||
<ContextMenuItem
|
||||
onClick={() => openRenamePackageDialog(item.row.path)}
|
||||
>
|
||||
<Edit2 className="mr-2 h-4 w-4" /> {t('common.rename')}
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem
|
||||
className="text-destructive"
|
||||
onClick={() => requestDeletePackage(item.row.path)}
|
||||
>
|
||||
<Trash2 className="mr-2 h-4 w-4" /> {t('action.delete')}
|
||||
</ContextMenuItem>
|
||||
</ContextMenuContent>
|
||||
</ContextMenu>
|
||||
);
|
||||
}
|
||||
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 (
|
||||
<SnippetChip
|
||||
key={item.key}
|
||||
snippet={snippet}
|
||||
subtitle={chipSubtitle}
|
||||
selected={selectedSnippetIds.has(snippet.id)}
|
||||
multiSelect={isMultiSelectMode}
|
||||
onClick={() => 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')}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{hasMoreStackedItems ? (
|
||||
<div ref={stackedSentinelRef} className="h-px w-full" aria-hidden="true" />
|
||||
) : null}
|
||||
</div>
|
||||
) : (
|
||||
<FixedSizeVirtualList
|
||||
className="h-full"
|
||||
@@ -1644,5 +1842,117 @@ const SnippetRow = memo<SnippetRowProps>(({
|
||||
));
|
||||
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<SnippetChipProps>(({
|
||||
snippet,
|
||||
subtitle,
|
||||
selected = false,
|
||||
multiSelect = false,
|
||||
onClick,
|
||||
onEdit,
|
||||
onDelete,
|
||||
onRunParallel,
|
||||
onRunSequential,
|
||||
onCopyCommand,
|
||||
copyCommandLabel,
|
||||
runParallelLabel,
|
||||
runSequentialLabel,
|
||||
editLabel,
|
||||
deleteLabel,
|
||||
}) => (
|
||||
<ContextMenu>
|
||||
{/* Radix ContextMenuTrigger needs a real DOM element (Tooltip.Root renders
|
||||
none), so mirror SnippetRow and wrap the tooltip in a plain div. */}
|
||||
<ContextMenuTrigger asChild>
|
||||
<div className="min-w-0 max-w-full">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
aria-pressed={multiSelect ? selected : undefined}
|
||||
className={cn(
|
||||
'h-6 max-w-full min-w-0 px-2 rounded-md border border-border/60 bg-muted/40 hover:bg-accent/60 text-[11px] flex items-center gap-1 transition-colors',
|
||||
selected && 'bg-primary/10 border-primary/40 hover:bg-primary/15',
|
||||
)}
|
||||
>
|
||||
{multiSelect ? (
|
||||
<CheckSquare
|
||||
size={11}
|
||||
className={cn('shrink-0', selected ? 'text-primary' : 'text-muted-foreground/70')}
|
||||
/>
|
||||
) : isScriptSnippet(snippet) ? (
|
||||
<Play size={11} className="shrink-0 text-primary" />
|
||||
) : (
|
||||
<Zap size={11} className="shrink-0 text-muted-foreground" />
|
||||
)}
|
||||
<span className="min-w-0 truncate">{snippet.label}</span>
|
||||
{subtitle ? (
|
||||
<>
|
||||
<span aria-hidden="true" className="shrink-0 text-muted-foreground/50">·</span>
|
||||
<span className="min-w-0 truncate text-[10px] text-muted-foreground/80">
|
||||
{subtitle}
|
||||
</span>
|
||||
</>
|
||||
) : null}
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right" align="start">
|
||||
<SnippetCommandTooltipContent label={snippet.label} command={snippet.command} />
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</ContextMenuTrigger>
|
||||
<ContextMenuContent>
|
||||
{onRunParallel ? (
|
||||
<ContextMenuItem onClick={onRunParallel}>
|
||||
<Layers className="mr-2 h-4 w-4" /> {runParallelLabel}
|
||||
</ContextMenuItem>
|
||||
) : null}
|
||||
{onRunSequential ? (
|
||||
<ContextMenuItem onClick={onRunSequential}>
|
||||
<Layers className="mr-2 h-4 w-4" /> {runSequentialLabel}
|
||||
</ContextMenuItem>
|
||||
) : null}
|
||||
{onCopyCommand ? (
|
||||
<ContextMenuItem onClick={onCopyCommand}>
|
||||
<Copy className="mr-2 h-4 w-4" /> {copyCommandLabel}
|
||||
</ContextMenuItem>
|
||||
) : null}
|
||||
<ContextMenuItem onClick={onEdit}>
|
||||
<Edit2 className="mr-2 h-4 w-4" /> {editLabel}
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem className="text-destructive" onClick={onDelete}>
|
||||
<Trash2 className="mr-2 h-4 w-4" /> {deleteLabel}
|
||||
</ContextMenuItem>
|
||||
</ContextMenuContent>
|
||||
</ContextMenu>
|
||||
));
|
||||
SnippetChip.displayName = 'SnippetChip';
|
||||
|
||||
export const ScriptsSidePanel = memo(ScriptsSidePanelInner);
|
||||
ScriptsSidePanel.displayName = 'ScriptsSidePanel';
|
||||
@@ -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<SftpSidePanelProps> = ({
|
||||
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<SftpSidePanelInteractiveBodyProps>
|
||||
setFileOpenerTarget,
|
||||
handleSaveTextFile,
|
||||
onPromoteToTab,
|
||||
onPopOut,
|
||||
handleFileOpenerSelect,
|
||||
handleSelectSystemApp,
|
||||
} = useSftpViewPaneCallbacks({
|
||||
@@ -2058,6 +2060,7 @@ const SftpSidePanelInteractiveBody: React.FC<SftpSidePanelInteractiveBodyProps>
|
||||
handleFileOpenerSelect={handleFileOpenerSelect}
|
||||
handleSelectSystemApp={handleSelectSystemApp}
|
||||
onPromoteToTab={onPromoteToTab}
|
||||
onPopOut={onPopOut}
|
||||
onRequestTerminalFocus={onRequestTerminalFocus}
|
||||
t={t}
|
||||
/>
|
||||
|
||||
@@ -410,6 +410,7 @@ const SftpViewInner: React.FC<SftpViewProps> = ({
|
||||
setFileOpenerTarget,
|
||||
handleSaveTextFile,
|
||||
onPromoteToTab,
|
||||
onPopOut,
|
||||
handleFileOpenerSelect,
|
||||
handleSelectSystemApp,
|
||||
} = useSftpViewPaneCallbacks({
|
||||
@@ -797,6 +798,7 @@ const SftpViewInner: React.FC<SftpViewProps> = ({
|
||||
handleFileOpenerSelect={handleFileOpenerSelect}
|
||||
handleSelectSystemApp={handleSelectSystemApp}
|
||||
onPromoteToTab={onPromoteToTab}
|
||||
onPopOut={onPopOut}
|
||||
t={t}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -669,7 +669,9 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
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);
|
||||
|
||||
Loaded 100 of 244 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user