mirror of
https://github.com/binaricat/Netcatty.git
synced 2026-11-07 08:31:00 +00:00
Compare commits
15
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
be6e1b4a57 | ||
|
|
c6c3f27363 | ||
|
|
9e0a598765 | ||
|
|
7949242765 | ||
|
|
a0be25d107 | ||
|
|
3e454a84a5 | ||
|
|
a70e21d39b | ||
|
+1 |
16061720e6 | ||
|
|
2368fc8232 | ||
|
|
efe012e0c8 | ||
|
|
2e3dc687cb | ||
|
|
051eda6166 | ||
|
|
f5fccc0d44 | ||
|
|
3408accba7 | ||
|
|
0e3f282657 |
No files matched your search
@@ -21,13 +21,20 @@ Object.defineProperty(globalThis, 'localStorage', {
|
||||
});
|
||||
|
||||
const {
|
||||
applyLegacySyncPayload,
|
||||
convergentConflictAddressKey,
|
||||
createConvergentSyncStateFromPayload,
|
||||
dotKey,
|
||||
mergeConvergentSyncStates,
|
||||
planConvergentSyncMigration,
|
||||
refreshConvergentSyncMigrationPlan,
|
||||
stripConvergentSyncEnvelope,
|
||||
} = await import('../domain/convergentSync/index.ts');
|
||||
const { getConvergentSyncLocalConfig } = await import('../infrastructure/services/convergentSyncConfig.ts');
|
||||
const {
|
||||
initializePreparedConvergentMigration,
|
||||
prepareConvergentSyncMigration,
|
||||
resolvePreparedConvergentMigrationConflict,
|
||||
} = await import('./convergentSyncMigration.ts');
|
||||
|
||||
function payload(): SyncPayload {
|
||||
@@ -44,6 +51,42 @@ test.beforeEach(() => {
|
||||
localStorageValues.clear();
|
||||
});
|
||||
|
||||
test('preview resolution uses the manager device identity and only updates the prepared plan', () => {
|
||||
const baseline: SyncPayload = { ...payload(), settings: { theme: 'system' } };
|
||||
const base = createConvergentSyncStateFromPayload(baseline, 'seed', NOW);
|
||||
const state = mergeConvergentSyncStates(
|
||||
applyLegacySyncPayload(base, baseline, { ...baseline, settings: { theme: 'light' } }, 'left', NOW + 1),
|
||||
applyLegacySyncPayload(base, baseline, { ...baseline, settings: { theme: 'dark' } }, 'right', NOW + 2),
|
||||
);
|
||||
const plan = refreshConvergentSyncMigrationPlan(planConvergentSyncMigration({
|
||||
localPayload: baseline, localTrustedBaseline: null, providers: [], deviceId: 'local', now: NOW,
|
||||
}), state);
|
||||
const prepared = { plan, localSnapshot: baseline, providerBaselines: [] };
|
||||
const conflict = plan.preview.conflicts[0];
|
||||
const addressKey = convergentConflictAddressKey(conflict.address);
|
||||
const selected = conflict.candidates.find((candidate) => candidate.value === 'light')!;
|
||||
const manager = { getState: () => ({ deviceId: 'resolver' }) } as unknown as CloudSyncManager;
|
||||
const resolved = resolvePreparedConvergentMigrationConflict(
|
||||
prepared, addressKey, dotKey(selected.dot), manager, NOW + 3,
|
||||
);
|
||||
|
||||
assert.equal(resolved.plan.preview.canInitialize, true);
|
||||
assert.equal(resolved.plan.preview.conflictCount, 0);
|
||||
assert.deepEqual(resolved.plan.preview.blockedReasons, []);
|
||||
assert.equal(resolved.plan.payload?.settings?.theme, 'light');
|
||||
assert.ok(resolved.plan.state?.vector.resolver);
|
||||
assert.equal(resolved.localSnapshot, prepared.localSnapshot);
|
||||
assert.equal(resolved.providerBaselines, prepared.providerBaselines);
|
||||
assert.equal(prepared.plan.preview.conflictCount, 1);
|
||||
assert.equal(prepared.plan.payload, null);
|
||||
assert.throws(() => resolvePreparedConvergentMigrationConflict(
|
||||
prepared, 'missing', dotKey(selected.dot), manager,
|
||||
), /conflict no longer exists/);
|
||||
assert.throws(() => resolvePreparedConvergentMigrationConflict(
|
||||
prepared, addressKey, 'missing', manager,
|
||||
), /candidate no longer exists/);
|
||||
});
|
||||
|
||||
test('preparation seeds a trusted baseline for an unchanged v1 provider', async () => {
|
||||
const remotePayload: SyncPayload = {
|
||||
...payload(),
|
||||
|
||||
@@ -6,8 +6,11 @@ import type {
|
||||
} from '../domain/sync';
|
||||
import {
|
||||
cloudSyncPayloadsEqual,
|
||||
convergentConflictAddressKey,
|
||||
materializeSyncPayloadFromConvergentState,
|
||||
planConvergentSyncMigration,
|
||||
refreshConvergentSyncMigrationPlan,
|
||||
resolveConvergentFieldConflict,
|
||||
stripConvergentSyncEnvelope,
|
||||
validateConvergentSyncPayload,
|
||||
type ConvergentMigrationPlan,
|
||||
@@ -25,6 +28,25 @@ export interface PreparedConvergentMigration {
|
||||
localSnapshot: SyncPayload;
|
||||
}
|
||||
|
||||
export function resolvePreparedConvergentMigrationConflict(
|
||||
prepared: PreparedConvergentMigration,
|
||||
addressKey: string,
|
||||
candidateDot: string,
|
||||
manager: CloudSyncManager = getCloudSyncManager(),
|
||||
now = Date.now(),
|
||||
): PreparedConvergentMigration {
|
||||
const conflict = prepared.plan.preview.conflicts.find(
|
||||
(entry) => convergentConflictAddressKey(entry.address) === addressKey,
|
||||
);
|
||||
if (!prepared.plan.state || !conflict) {
|
||||
throw new Error('The convergent migration conflict no longer exists');
|
||||
}
|
||||
const state = resolveConvergentFieldConflict(
|
||||
prepared.plan.state, conflict, candidateDot, manager.getState().deviceId, now,
|
||||
);
|
||||
return { ...prepared, plan: refreshConvergentSyncMigrationPlan(prepared.plan, state) };
|
||||
}
|
||||
|
||||
interface LegacyProviderBaselineSeed {
|
||||
provider: CloudProvider;
|
||||
remoteVersion: number;
|
||||
|
||||
@@ -99,6 +99,7 @@ export const enAiMessages: Messages = {
|
||||
'ai.providers.advancedParams.hint': 'Leave blank to use provider defaults.',
|
||||
'ai.providers.advancedParams.maxTokens.placeholder': 'e.g. 4096',
|
||||
'ai.providers.advancedParams.default': 'Provider default',
|
||||
'ai.providers.advancedParams.reasoningEffort.help': 'xhigh and max are only sent when the selected model supports them; otherwise they are clamped to the deepest supported level.',
|
||||
|
||||
// AI Codex
|
||||
'ai.codex': 'Codex',
|
||||
@@ -660,6 +661,10 @@ export const enAiMessages: Messages = {
|
||||
'ai.chatShortcuts.title': 'Chat Shortcuts',
|
||||
'ai.chatShortcuts.selectionAction': 'Show Add to Conversation when selecting terminal text',
|
||||
'ai.chatShortcuts.selectionAction.description': 'Show a small AI button next to selected terminal text.',
|
||||
'ai.chatHistory.title': 'Chat History',
|
||||
'ai.chatHistory.messageCap': 'Messages kept per chat',
|
||||
'ai.chatHistory.messageCap.description': 'Maximum number of messages stored per chat (50–2000). Lowering it drops the oldest messages from storage on the next write. Larger values increase storage and cloud-sync size.',
|
||||
'ai.chatHistory.messageCap.unit': 'messages',
|
||||
|
||||
// AI Error
|
||||
'ai.codex.bridgeError': 'Codex main-process handlers are not loaded yet. Fully restart Netcatty, or restart the Electron dev process, then try again.',
|
||||
|
||||
@@ -1,6 +1,44 @@
|
||||
import type { Messages } from '../types';
|
||||
|
||||
export const enCoreMessages: Messages = {
|
||||
"settings.plugins.github.install": "Install from GitHub",
|
||||
"settings.plugins.github.description": "Download a published .ncpkg release, then review its source and permissions.",
|
||||
"settings.plugins.github.repository": "GitHub repository",
|
||||
"settings.plugins.github.formats": "Accepts https://github.com/owner/repo, github.com/owner/repo or owner/repo.",
|
||||
"settings.plugins.github.busy": "Downloading, validating or installing the package...",
|
||||
"settings.plugins.github.review": "Repository ownership and package integrity do not verify publisher trust. No build scripts run. The plugin installs disabled; review permission modes before enabling.",
|
||||
"settings.plugins.github.accept": "I trust this repository and have reviewed the declared permissions.",
|
||||
"settings.plugins.github.confirm": "Install disabled",
|
||||
"settings.plugins.github.download": "Download and review",
|
||||
"settings.plugins.github.error.invalidRepository": "Enter https://github.com/owner/repo, github.com/owner/repo or owner/repo (repository only; no credentials, query, fragment or extra paths).",
|
||||
"settings.plugins.github.error.unavailable": "GitHub plugin installation is unavailable in this session.",
|
||||
"settings.plugins.github.error.reviewFirst": "Download and review a GitHub plugin first.",
|
||||
"settings.plugins.github.error.network": "Cannot reach GitHub. Check your network connection and try again.",
|
||||
"settings.plugins.github.error.timeout": "The GitHub download timed out. Please try again.",
|
||||
"settings.plugins.github.error.rateLimit": "GitHub API rate limit reached. Try again later.",
|
||||
"settings.plugins.github.error.notFound": "GitHub repository or asset not found. Only accessible public repositories are supported; import private packages locally.",
|
||||
"settings.plugins.github.error.accessDenied": "GitHub access denied. Private repositories and sign-in are not supported.",
|
||||
"settings.plugins.github.error.http": "GitHub download failed (HTTP {status}). Please try again later.",
|
||||
"settings.plugins.github.error.moved": "This GitHub repository has moved. Enter its current repository URL.",
|
||||
"settings.plugins.github.error.redirect": "The GitHub download redirect could not be verified. Download a trusted .ncpkg package and import it locally.",
|
||||
"settings.plugins.github.error.noRelease": "This repository has no published stable release. Ask the author to publish one with a .ncpkg asset, or import a built package locally.",
|
||||
"settings.plugins.github.error.noAsset": "This release has no .ncpkg asset. Ask the author to build and attach one; Netcatty cannot build source repositories.",
|
||||
"settings.plugins.github.error.multipleAssets": "This release contains multiple .ncpkg assets. Download the intended package and import it locally.",
|
||||
"settings.plugins.github.error.metadata": "The GitHub release metadata is invalid. Ask the author to check the release, tag and .ncpkg asset.",
|
||||
"settings.plugins.github.error.identity": "The package does not match the selected GitHub repository and release. Installation was stopped.",
|
||||
"settings.plugins.github.error.commit": "The release tag has no accessible commit. Ask the author to repair the release tag.",
|
||||
"settings.plugins.github.error.size": "The GitHub package size is invalid or exceeds the download limit.",
|
||||
"settings.plugins.github.error.integrity": "The downloaded package failed its size or SHA-256 check. Download and review it again.",
|
||||
"settings.plugins.github.error.package": "GitHub did not return a valid .ncpkg ZIP package or release metadata. Ask the author to check the release asset.",
|
||||
"settings.plugins.github.error.busy": "A GitHub plugin operation is already active. Close the preview and try again.",
|
||||
"settings.plugins.github.error.expired": "The GitHub package preview expired. Download and review it again.",
|
||||
"settings.plugins.github.error.advanced": "This package requires advanced developer mode. Normal plugin management accepts browser-only packages.",
|
||||
"settings.plugins.github.error.incompatible": "This plugin is incompatible with this version of Netcatty. Check the technical details with the author.",
|
||||
"settings.plugins.github.error.source": "This plugin is already bound to another source. Uninstall it and explicitly clear its old source data before changing sources.",
|
||||
"settings.plugins.github.error.failed": "The plugin could not be validated or installed. Check the technical details before trying again.",
|
||||
"settings.plugins.github.error.details": "Technical details",
|
||||
|
||||
|
||||
"settings.plugins.landing.checkUpdates": "Check for updates",
|
||||
"settings.plugins.landing.checkingUpdates": "Checking…",
|
||||
"settings.plugins.landing.updateSummary": "Checked: {checked} · Published updates: {updates} · Not checked: {skipped}.",
|
||||
|
||||
@@ -1,6 +1,24 @@
|
||||
import type { Messages } from '../types';
|
||||
|
||||
export const enTerminalMessages: Messages = {
|
||||
'terminal.directory.open': 'Current directory: {path}. Change directory',
|
||||
'terminal.directory.unknown': 'Directory unknown',
|
||||
'terminal.directory.search': 'Search child directories...',
|
||||
'terminal.directory.directories': 'Directories',
|
||||
'terminal.directory.parent': '.. (Parent directory)',
|
||||
'terminal.directory.loading': 'Reading directories...',
|
||||
'terminal.directory.empty': 'No child directories',
|
||||
'terminal.directory.noMatches': 'No matching directories',
|
||||
'terminal.directory.hidden': 'Show hidden directories',
|
||||
'terminal.directory.refresh': 'Refresh',
|
||||
'terminal.directory.unavailable': 'Directory navigation is unavailable for this session.',
|
||||
'terminal.directory.busy': 'Wait for an empty shell prompt.',
|
||||
'terminal.directory.readFailed': 'Could not read this directory. Try refreshing.',
|
||||
'terminal.directory.changeFailed': 'Could not confirm the directory change. Refresh before retrying.',
|
||||
'terminal.directory.unsupportedPath': 'This directory name cannot be sent safely to the terminal.',
|
||||
'settings.terminal.behavior.contextChips': 'Context Chips',
|
||||
'settings.terminal.behavior.contextChips.desc': 'Show the context toolbar below quick commands in the compose bar.',
|
||||
|
||||
'terminal.toolbar.openSessionLog': 'Open session log',
|
||||
'terminal.toolbar.sessionLogActions': 'Session log actions',
|
||||
'terminal.toolbar.sessionLog.menu': 'Session log',
|
||||
|
||||
@@ -90,6 +90,7 @@ export const esAiMessages: Messages = {
|
||||
'ai.providers.advancedParams.hint': 'Déjalo en blanco para usar los valores predeterminados del proveedor.',
|
||||
'ai.providers.advancedParams.maxTokens.placeholder': 'p. ej. 4096',
|
||||
'ai.providers.advancedParams.default': 'Predeterminado del proveedor',
|
||||
'ai.providers.advancedParams.reasoningEffort.help': 'xhigh y max solo se envían cuando el modelo elegido los admite; de lo contrario se ajustan al nivel más profundo compatible.',
|
||||
'ai.providers.headers': 'Cabeceras HTTP personalizadas',
|
||||
'ai.providers.headers.help': 'Cabeceras opcionales para este proveedor. Los valores se ocultan y se cifran al guardarlos.',
|
||||
'ai.providers.headers.name': 'Nombre de la cabecera',
|
||||
@@ -660,6 +661,10 @@ export const esAiMessages: Messages = {
|
||||
'ai.chatShortcuts.title': 'Atajos de chat',
|
||||
'ai.chatShortcuts.selectionAction': 'Mostrar "Agregar a la conversación" al seleccionar texto de la terminal',
|
||||
'ai.chatShortcuts.selectionAction.description': 'Muestra un pequeño botón de IA junto al texto seleccionado de la terminal.',
|
||||
'ai.chatHistory.title': 'Historial de chat',
|
||||
'ai.chatHistory.messageCap': 'Mensajes conservados por chat',
|
||||
'ai.chatHistory.messageCap.description': 'Número máximo de mensajes almacenados por chat (50–2000). Al reducirlo, los mensajes más antiguos se descartan del almacenamiento en la siguiente escritura. Los valores más altos aumentan el almacenamiento y el tamaño de la sincronización en la nube.',
|
||||
'ai.chatHistory.messageCap.unit': 'mensajes',
|
||||
|
||||
// AI Error
|
||||
'ai.codex.bridgeError': 'Los controladores del proceso principal de Codex aún no están cargados. Reinicia Netcatty por completo o reinicia el proceso de desarrollo de Electron y vuelve a intentarlo.',
|
||||
|
||||
@@ -1,6 +1,44 @@
|
||||
import type { Messages } from '../types';
|
||||
|
||||
export const esCoreMessages: Messages = {
|
||||
"settings.plugins.github.install": "Instalar desde GitHub",
|
||||
"settings.plugins.github.description": "Descarga una versión .ncpkg publicada y revisa su origen y permisos.",
|
||||
"settings.plugins.github.repository": "Repositorio de GitHub",
|
||||
"settings.plugins.github.formats": "Acepta https://github.com/owner/repo, github.com/owner/repo u owner/repo.",
|
||||
"settings.plugins.github.busy": "Descargando, validando o instalando el paquete...",
|
||||
"settings.plugins.github.review": "El origen y la integridad no verifican la confianza en el autor. No se ejecutan scripts de compilación. Se instala desactivado; revisa los permisos antes de activarlo.",
|
||||
"settings.plugins.github.accept": "Confío en este repositorio y he revisado los permisos declarados.",
|
||||
"settings.plugins.github.confirm": "Instalar desactivado",
|
||||
"settings.plugins.github.download": "Descargar y revisar",
|
||||
"settings.plugins.github.error.invalidRepository": "Introduce https://github.com/owner/repo, github.com/owner/repo u owner/repo. Usa solo el repositorio, sin credenciales, parámetros, fragmentos ni rutas adicionales.",
|
||||
"settings.plugins.github.error.unavailable": "La instalación de plugins desde GitHub no está disponible en esta sesión.",
|
||||
"settings.plugins.github.error.reviewFirst": "Primero descarga y revisa un plugin de GitHub.",
|
||||
"settings.plugins.github.error.network": "No se puede conectar con GitHub. Comprueba tu conexión e inténtalo de nuevo.",
|
||||
"settings.plugins.github.error.timeout": "La descarga de GitHub agotó el tiempo de espera. Inténtalo de nuevo.",
|
||||
"settings.plugins.github.error.rateLimit": "Se alcanzó el límite de solicitudes de la API de GitHub. Inténtalo más tarde.",
|
||||
"settings.plugins.github.error.notFound": "No se encontró el repositorio o archivo de GitHub. Solo se admiten repositorios públicos accesibles; importa los paquetes privados desde un archivo local.",
|
||||
"settings.plugins.github.error.accessDenied": "GitHub denegó el acceso. No se admiten repositorios privados ni inicio de sesión.",
|
||||
"settings.plugins.github.error.http": "La descarga de GitHub falló (HTTP {status}). Inténtalo más tarde.",
|
||||
"settings.plugins.github.error.moved": "Este repositorio de GitHub se ha movido. Introduce su dirección actual.",
|
||||
"settings.plugins.github.error.redirect": "No se pudo verificar la redirección de GitHub. Descarga un paquete .ncpkg de confianza e impórtalo desde un archivo local.",
|
||||
"settings.plugins.github.error.noRelease": "Este repositorio no tiene una versión estable publicada. Pide al autor que publique una con un archivo .ncpkg o importa un paquete compilado local.",
|
||||
"settings.plugins.github.error.noAsset": "Esta versión no incluye un archivo .ncpkg. Pide al autor que lo compile y adjunte; Netcatty no puede compilar repositorios de código fuente.",
|
||||
"settings.plugins.github.error.multipleAssets": "Esta versión contiene varios archivos .ncpkg. Descarga el paquete deseado e impórtalo desde un archivo local.",
|
||||
"settings.plugins.github.error.metadata": "Los metadatos de la versión de GitHub no son válidos. Pide al autor que revise la versión, la etiqueta y el archivo .ncpkg.",
|
||||
"settings.plugins.github.error.identity": "El paquete no coincide con el repositorio y la versión de GitHub seleccionados. Se detuvo la instalación.",
|
||||
"settings.plugins.github.error.commit": "La etiqueta de la versión no tiene un commit accesible. Pide al autor que corrija la etiqueta.",
|
||||
"settings.plugins.github.error.size": "El tamaño del paquete de GitHub no es válido o supera el límite de descarga.",
|
||||
"settings.plugins.github.error.integrity": "El paquete descargado no superó la comprobación de tamaño o SHA-256. Descárgalo y revísalo de nuevo.",
|
||||
"settings.plugins.github.error.package": "GitHub no devolvió un paquete ZIP .ncpkg o metadatos de versión válidos. Pide al autor que revise el archivo publicado.",
|
||||
"settings.plugins.github.error.busy": "Ya hay una operación de plugin de GitHub en curso. Cierra la vista previa e inténtalo de nuevo.",
|
||||
"settings.plugins.github.error.expired": "La vista previa del paquete de GitHub caducó. Descárgalo y revísalo de nuevo.",
|
||||
"settings.plugins.github.error.advanced": "Este paquete requiere el modo de desarrollo avanzado. La gestión normal solo admite paquetes para navegador.",
|
||||
"settings.plugins.github.error.incompatible": "Este plugin no es compatible con esta versión de Netcatty. Consulta los detalles técnicos con el autor.",
|
||||
"settings.plugins.github.error.source": "Este plugin ya está vinculado a otro origen. Desinstálalo y borra explícitamente los datos del origen anterior antes de cambiarlo.",
|
||||
"settings.plugins.github.error.failed": "No se pudo validar o instalar el plugin. Revisa los detalles técnicos antes de intentarlo de nuevo.",
|
||||
"settings.plugins.github.error.details": "Detalles técnicos",
|
||||
|
||||
|
||||
"settings.plugins.landing.checkUpdates": "Buscar actualizaciones",
|
||||
"settings.plugins.landing.checkingUpdates": "Buscando…",
|
||||
"settings.plugins.landing.updateSummary": "Comprobados: {checked} · Actualizaciones publicadas: {updates} · Sin comprobar: {skipped}.",
|
||||
|
||||
@@ -1,6 +1,24 @@
|
||||
import type { Messages } from '../types';
|
||||
|
||||
export const esTerminalMessages: Messages = {
|
||||
'terminal.directory.open': 'Directorio actual: {path}. Cambiar directorio',
|
||||
'terminal.directory.unknown': 'Directorio desconocido',
|
||||
'terminal.directory.search': 'Buscar subdirectorios...',
|
||||
'terminal.directory.directories': 'Directorios',
|
||||
'terminal.directory.parent': '.. (Directorio padre)',
|
||||
'terminal.directory.loading': 'Leyendo directorios...',
|
||||
'terminal.directory.empty': 'Sin subdirectorios',
|
||||
'terminal.directory.noMatches': 'Sin directorios coincidentes',
|
||||
'terminal.directory.hidden': 'Mostrar directorios ocultos',
|
||||
'terminal.directory.refresh': 'Actualizar',
|
||||
'terminal.directory.unavailable': 'La navegación de directorios no está disponible para esta sesión.',
|
||||
'terminal.directory.busy': 'Espera a que el prompt esté vacío.',
|
||||
'terminal.directory.readFailed': 'No se pudo leer el directorio. Actualiza e inténtalo de nuevo.',
|
||||
'terminal.directory.changeFailed': 'No se pudo confirmar el cambio. Actualiza antes de reintentar.',
|
||||
'terminal.directory.unsupportedPath': 'No se puede enviar este nombre de directorio de forma segura.',
|
||||
'settings.terminal.behavior.contextChips': 'Context Chips',
|
||||
'settings.terminal.behavior.contextChips.desc': 'Mostrar la barra de contexto debajo de los comandos rápidos.',
|
||||
|
||||
'terminal.toolbar.openSessionLog': 'Abrir registro de sesión',
|
||||
'terminal.toolbar.sessionLogActions': 'Acciones del registro',
|
||||
'terminal.toolbar.sessionLog.menu': 'Registro de sesión',
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { SUPPORTED_UI_LOCALES } from '../../../infrastructure/config/i18n';
|
||||
import { getLoadedLocaleMessages, loadLocaleMessages } from '../messages';
|
||||
import en from './en';
|
||||
import { describePluginGitHubInstallError } from '../../../domain/pluginGitHubInstallError';
|
||||
|
||||
test('GitHub install copy and error interpolation are complete in every supported UI locale', async () => {
|
||||
const keys = Object.keys(en).filter(key => key.startsWith('settings.plugins.github.'));
|
||||
assert.ok(keys.length > 30);
|
||||
for (const { id } of SUPPORTED_UI_LOCALES) {
|
||||
await loadLocaleMessages(id);
|
||||
const messages = getLoadedLocaleMessages(id)!;
|
||||
for (const key of keys) {
|
||||
assert.ok(messages[key]?.trim(), `${id}: ${key}`);
|
||||
assert.deepEqual(messages[key].match(/\{\w+\}/g) ?? [], en[key].match(/\{\w+\}/g) ?? [], `${id}: ${key}`);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('GitHub error presentation handles Electron wrappers, HTTP status and diagnostic fallback', () => {
|
||||
const expired = describePluginGitHubInstallError("Error invoking remote method 'plugins:github:install': Error: GitHub package preview expired. Download and review it again");
|
||||
assert.equal(expired.key, 'settings.plugins.github.error.expired');
|
||||
assert.equal(expired.details, null);
|
||||
assert.deepEqual(describePluginGitHubInstallError('GitHub download failed (HTTP 503)').values, { status: '503' });
|
||||
const validator = 'manifest version must be a semantic version';
|
||||
assert.equal(describePluginGitHubInstallError(validator).details, validator);
|
||||
assert.equal(describePluginGitHubInstallError(validator).key, 'settings.plugins.github.error.failed');
|
||||
assert.equal(describePluginGitHubInstallError('Plugin is incompatible: engine version').key, 'settings.plugins.github.error.incompatible');
|
||||
});
|
||||
@@ -71,6 +71,7 @@ export const ruAiMessages: Messages = {
|
||||
'ai.providers.advancedParams.hint': 'Оставьте пустым, чтобы использовать настройки провайдера по умолчанию.',
|
||||
'ai.providers.advancedParams.maxTokens.placeholder': 'например, 4096',
|
||||
'ai.providers.advancedParams.default': 'По умолчанию у провайдера',
|
||||
'ai.providers.advancedParams.reasoningEffort.help': 'xhigh и max отправляются только если выбранная модель их поддерживает; иначе значение понижается до максимального поддерживаемого уровня.',
|
||||
|
||||
// AI Codex
|
||||
'ai.codex': 'Codex',
|
||||
@@ -599,6 +600,10 @@ export const ruAiMessages: Messages = {
|
||||
'ai.chatShortcuts.title': 'Быстрые действия чата',
|
||||
'ai.chatShortcuts.selectionAction': 'Показывать «Добавить в чат» при выделении в терминале',
|
||||
'ai.chatShortcuts.selectionAction.description': 'Показывать небольшую кнопку AI рядом с выделенным текстом терминала.',
|
||||
'ai.chatHistory.title': 'История чата',
|
||||
'ai.chatHistory.messageCap': 'Сообщений, сохраняемых в чате',
|
||||
'ai.chatHistory.messageCap.description': 'Максимальное число сообщений, хранимых для одного чата (50–2000). При уменьшении самые старые сообщения удаляются из хранилища при следующей записи. Большие значения увеличивают объём хранилища и синхронизации с облаком.',
|
||||
'ai.chatHistory.messageCap.unit': 'сообщений',
|
||||
|
||||
// AI Error
|
||||
'ai.codex.bridgeError': 'Обработчики главного процесса Codex ещё не загружены. Полностью перезапустите Netcatty или dev-процесс Electron и попробуйте снова.',
|
||||
|
||||
@@ -1,6 +1,44 @@
|
||||
import type { Messages } from '../types';
|
||||
|
||||
export const ruCoreMessages: Messages = {
|
||||
"settings.plugins.github.install": "Установить из GitHub",
|
||||
"settings.plugins.github.description": "Загрузите опубликованный выпуск .ncpkg и проверьте источник и разрешения.",
|
||||
"settings.plugins.github.repository": "Репозиторий GitHub",
|
||||
"settings.plugins.github.formats": "Поддерживаются https://github.com/owner/repo, github.com/owner/repo и owner/repo.",
|
||||
"settings.plugins.github.busy": "Загрузка, проверка или установка пакета...",
|
||||
"settings.plugins.github.review": "Источник и целостность пакета не подтверждают доверие к издателю. Скрипты сборки не запускаются. Плагин устанавливается выключенным; проверьте разрешения перед включением.",
|
||||
"settings.plugins.github.accept": "Я доверяю этому репозиторию и проверил заявленные разрешения.",
|
||||
"settings.plugins.github.confirm": "Установить выключенным",
|
||||
"settings.plugins.github.download": "Загрузить и проверить",
|
||||
"settings.plugins.github.error.invalidRepository": "Введите https://github.com/owner/repo, github.com/owner/repo или owner/repo. Укажите только репозиторий, без учётных данных, параметров, фрагментов и дополнительных путей.",
|
||||
"settings.plugins.github.error.unavailable": "Установка плагинов из GitHub недоступна в этом сеансе.",
|
||||
"settings.plugins.github.error.reviewFirst": "Сначала загрузите и проверьте плагин из GitHub.",
|
||||
"settings.plugins.github.error.network": "Не удалось подключиться к GitHub. Проверьте соединение и повторите попытку.",
|
||||
"settings.plugins.github.error.timeout": "Время ожидания загрузки из GitHub истекло. Повторите попытку.",
|
||||
"settings.plugins.github.error.rateLimit": "Достигнут лимит запросов API GitHub. Повторите попытку позже.",
|
||||
"settings.plugins.github.error.notFound": "Репозиторий или файл GitHub не найден. Поддерживаются только доступные публичные репозитории; импортируйте приватные пакеты из локального файла.",
|
||||
"settings.plugins.github.error.accessDenied": "GitHub отказал в доступе. Приватные репозитории и вход в аккаунт не поддерживаются.",
|
||||
"settings.plugins.github.error.http": "Ошибка загрузки из GitHub (HTTP {status}). Повторите попытку позже.",
|
||||
"settings.plugins.github.error.moved": "Репозиторий GitHub перемещён. Введите его текущий адрес.",
|
||||
"settings.plugins.github.error.redirect": "Не удалось проверить перенаправление загрузки GitHub. Загрузите доверенный пакет .ncpkg и импортируйте его из локального файла.",
|
||||
"settings.plugins.github.error.noRelease": "В репозитории нет опубликованного стабильного выпуска. Попросите автора опубликовать выпуск с файлом .ncpkg или импортируйте готовый пакет из локального файла.",
|
||||
"settings.plugins.github.error.noAsset": "В выпуске нет файла .ncpkg. Попросите автора собрать и прикрепить его; Netcatty не собирает репозитории исходного кода.",
|
||||
"settings.plugins.github.error.multipleAssets": "В выпуске несколько файлов .ncpkg. Загрузите нужный пакет и импортируйте его из локального файла.",
|
||||
"settings.plugins.github.error.metadata": "Некорректные метаданные выпуска GitHub. Попросите автора проверить выпуск, тег и файл .ncpkg.",
|
||||
"settings.plugins.github.error.identity": "Пакет не соответствует выбранному репозиторию и выпуску GitHub. Установка остановлена.",
|
||||
"settings.plugins.github.error.commit": "У тега выпуска нет доступного коммита. Попросите автора исправить тег.",
|
||||
"settings.plugins.github.error.size": "Размер пакета GitHub некорректен или превышает лимит загрузки.",
|
||||
"settings.plugins.github.error.integrity": "Загруженный пакет не прошёл проверку размера или SHA-256. Загрузите и проверьте его снова.",
|
||||
"settings.plugins.github.error.package": "GitHub не вернул корректный ZIP-пакет .ncpkg или метаданные выпуска. Попросите автора проверить файл выпуска.",
|
||||
"settings.plugins.github.error.busy": "Операция с плагином GitHub уже выполняется. Закройте предпросмотр и повторите попытку.",
|
||||
"settings.plugins.github.error.expired": "Предпросмотр пакета GitHub устарел. Загрузите и проверьте его снова.",
|
||||
"settings.plugins.github.error.advanced": "Для этого пакета нужен расширенный режим разработчика. Обычный менеджер принимает только браузерные пакеты.",
|
||||
"settings.plugins.github.error.incompatible": "Плагин несовместим с этой версией Netcatty. Уточните технические подробности у автора.",
|
||||
"settings.plugins.github.error.source": "Плагин уже привязан к другому источнику. Перед сменой источника удалите плагин и явно очистите данные прежнего источника.",
|
||||
"settings.plugins.github.error.failed": "Не удалось проверить или установить плагин. Изучите технические подробности перед повторной попыткой.",
|
||||
"settings.plugins.github.error.details": "Технические подробности",
|
||||
|
||||
|
||||
// Common
|
||||
'common.save': 'Сохранить',
|
||||
'common.cancel': 'Отмена',
|
||||
|
||||
@@ -1,6 +1,24 @@
|
||||
import type { Messages } from '../types';
|
||||
|
||||
export const ruTerminalMessages: Messages = {
|
||||
'terminal.directory.open': 'Текущий каталог: {path}. Сменить каталог',
|
||||
'terminal.directory.unknown': 'Каталог неизвестен',
|
||||
'terminal.directory.search': 'Поиск подкаталогов...',
|
||||
'terminal.directory.directories': 'Каталоги',
|
||||
'terminal.directory.parent': '.. (Родительский каталог)',
|
||||
'terminal.directory.loading': 'Чтение каталогов...',
|
||||
'terminal.directory.empty': 'Нет подкаталогов',
|
||||
'terminal.directory.noMatches': 'Нет подходящих каталогов',
|
||||
'terminal.directory.hidden': 'Показывать скрытые каталоги',
|
||||
'terminal.directory.refresh': 'Обновить',
|
||||
'terminal.directory.unavailable': 'Навигация по каталогам недоступна для этого сеанса.',
|
||||
'terminal.directory.busy': 'Дождитесь пустой командной строки.',
|
||||
'terminal.directory.readFailed': 'Не удалось прочитать каталог. Попробуйте обновить.',
|
||||
'terminal.directory.changeFailed': 'Не удалось подтвердить смену каталога. Обновите перед повтором.',
|
||||
'terminal.directory.unsupportedPath': 'Это имя каталога нельзя безопасно передать в терминал.',
|
||||
'settings.terminal.behavior.contextChips': 'Context Chips',
|
||||
'settings.terminal.behavior.contextChips.desc': 'Показывать панель контекста под быстрыми командами.',
|
||||
|
||||
'terminal.toolbar.openSessionLog': 'Открыть журнал сессии',
|
||||
'terminal.toolbar.sessionLogActions': 'Действия с журналом',
|
||||
'terminal.toolbar.sessionLog.menu': 'Журнал сессии',
|
||||
|
||||
@@ -99,6 +99,7 @@ export const zhCNAiMessages: Messages = {
|
||||
'ai.providers.advancedParams.hint': '留空则使用提供商默认值。',
|
||||
'ai.providers.advancedParams.maxTokens.placeholder': '例如 4096',
|
||||
'ai.providers.advancedParams.default': '提供商默认',
|
||||
'ai.providers.advancedParams.reasoningEffort.help': '仅当所选模型支持时才会发送 xhigh 和 max,否则自动收敛到该模型支持的最高档位。',
|
||||
|
||||
// AI Codex
|
||||
'ai.codex': 'Codex',
|
||||
@@ -660,6 +661,10 @@ export const zhCNAiMessages: Messages = {
|
||||
'ai.chatShortcuts.title': '聊天快捷入口',
|
||||
'ai.chatShortcuts.selectionAction': '选中终端内容时显示“添加到对话”',
|
||||
'ai.chatShortcuts.selectionAction.description': '在终端里选中文本后显示 AI 快捷按钮。',
|
||||
'ai.chatHistory.title': '聊天历史',
|
||||
'ai.chatHistory.messageCap': '每个会话保留的消息数',
|
||||
'ai.chatHistory.messageCap.description': '每个会话最多存储的消息条数(50–2000)。调低后会在下一次写入时从存储中丢弃最旧的消息。数值越大,存储占用和云同步体积也越大。',
|
||||
'ai.chatHistory.messageCap.unit': '条消息',
|
||||
|
||||
// AI Error
|
||||
'ai.codex.bridgeError': 'Codex 主进程处理器尚未加载。请完全重启 Netcatty 或重启 Electron 开发进程,然后重试。',
|
||||
|
||||
@@ -1,6 +1,44 @@
|
||||
import type { Messages } from '../types';
|
||||
|
||||
export const zhCNCoreMessages: Messages = {
|
||||
"settings.plugins.github.install": "从 GitHub 安装",
|
||||
"settings.plugins.github.description": "下载已发布的 .ncpkg 插件包,安装前请审查来源和所需权限。",
|
||||
"settings.plugins.github.repository": "GitHub 仓库",
|
||||
"settings.plugins.github.formats": "支持 https://github.com/owner/repo、github.com/owner/repo 或 owner/repo。",
|
||||
"settings.plugins.github.busy": "正在下载、验证或安装插件包…",
|
||||
"settings.plugins.github.review": "确认仓库来源并通过完整性校验,并不代表发布者值得信任。安装过程不会运行构建脚本,插件安装后默认禁用。启用前,请确认来源可信并检查各项权限的授权方式。",
|
||||
"settings.plugins.github.accept": "已审查并信任此仓库,且已查看声明的权限。",
|
||||
"settings.plugins.github.confirm": "安装并保持禁用",
|
||||
"settings.plugins.github.download": "下载并审查",
|
||||
"settings.plugins.github.error.invalidRepository": "请输入 https://github.com/owner/repo、github.com/owner/repo 或 owner/repo。请只填写仓库地址,不要包含账号密码、查询参数、片段或额外路径。",
|
||||
"settings.plugins.github.error.unavailable": "当前会话无法从 GitHub 安装插件。",
|
||||
"settings.plugins.github.error.reviewFirst": "请先下载并审查 GitHub 插件。",
|
||||
"settings.plugins.github.error.network": "无法连接 GitHub,请检查网络连接后重试。",
|
||||
"settings.plugins.github.error.timeout": "从 GitHub 下载超时,请重试。",
|
||||
"settings.plugins.github.error.rateLimit": "GitHub API 请求次数已达上限,请稍后重试。",
|
||||
"settings.plugins.github.error.notFound": "未找到 GitHub 仓库或发布附件。仅支持可访问的公开仓库;私有插件包请从本地导入。",
|
||||
"settings.plugins.github.error.accessDenied": "GitHub 拒绝访问。此功能不支持私有仓库或登录验证。",
|
||||
"settings.plugins.github.error.http": "从 GitHub 下载失败(HTTP {status}),请稍后重试。",
|
||||
"settings.plugins.github.error.moved": "此 GitHub 仓库已迁移,请输入当前仓库地址。",
|
||||
"settings.plugins.github.error.redirect": "无法验证 GitHub 下载重定向。请下载可信的 .ncpkg 插件包,再从本地导入。",
|
||||
"settings.plugins.github.error.noRelease": "此仓库尚未发布稳定版本。请作者发布包含 .ncpkg 附件的稳定版本,或从本地导入已构建的插件包。",
|
||||
"settings.plugins.github.error.noAsset": "此版本没有 .ncpkg 附件。请作者构建并上传插件包;Netcatty 无法直接构建源码仓库。",
|
||||
"settings.plugins.github.error.multipleAssets": "此版本包含多个 .ncpkg 附件。请下载需要的插件包,再从本地导入。",
|
||||
"settings.plugins.github.error.metadata": "GitHub 发布信息无效。请作者检查版本发布、标签和 .ncpkg 附件。",
|
||||
"settings.plugins.github.error.identity": "插件包与所选 GitHub 仓库或发布版本不匹配,已停止安装。",
|
||||
"settings.plugins.github.error.commit": "无法访问发布标签对应的提交,请作者修复发布标签。",
|
||||
"settings.plugins.github.error.size": "GitHub 插件包大小无效或超出下载限制。",
|
||||
"settings.plugins.github.error.integrity": "下载的插件包未通过大小或 SHA-256 校验,请重新下载并审查。",
|
||||
"settings.plugins.github.error.package": "GitHub 返回的内容不是有效的 .ncpkg ZIP 插件包或发布信息,请作者检查发布附件。",
|
||||
"settings.plugins.github.error.busy": "已有 GitHub 插件操作正在进行,请关闭预览后重试。",
|
||||
"settings.plugins.github.error.expired": "GitHub 插件包预览已失效,请重新下载并审查。",
|
||||
"settings.plugins.github.error.advanced": "此插件包需要高级开发者模式。普通插件管理仅接受浏览器运行时插件包。",
|
||||
"settings.plugins.github.error.incompatible": "此插件与当前 Netcatty 版本不兼容,请联系作者确认技术详情。",
|
||||
"settings.plugins.github.error.source": "此插件已绑定其他来源。更换来源前,请先卸载插件并明确清除原来源的私有数据。",
|
||||
"settings.plugins.github.error.failed": "无法验证或安装插件,请查看技术详情后再重试。",
|
||||
"settings.plugins.github.error.details": "技术详情",
|
||||
|
||||
|
||||
"settings.plugins.landing.checkUpdates": "检查更新",
|
||||
"settings.plugins.landing.checkingUpdates": "正在检查…",
|
||||
"settings.plugins.landing.updateSummary": "已检查:{checked} · 已发布更新:{updates} · 未检查:{skipped}。",
|
||||
|
||||
@@ -1,6 +1,24 @@
|
||||
import type { Messages } from '../types';
|
||||
|
||||
export const zhCNTerminalMessages: Messages = {
|
||||
'terminal.directory.open': '当前目录:{path}。更改目录',
|
||||
'terminal.directory.unknown': '目录未知',
|
||||
'terminal.directory.search': '搜索当前目录下的文件夹…',
|
||||
'terminal.directory.directories': '目录',
|
||||
'terminal.directory.parent': '..(父目录)',
|
||||
'terminal.directory.loading': '正在读取目录…',
|
||||
'terminal.directory.empty': '没有子目录',
|
||||
'terminal.directory.noMatches': '没有匹配的子目录',
|
||||
'terminal.directory.hidden': '显示隐藏目录',
|
||||
'terminal.directory.refresh': '刷新',
|
||||
'terminal.directory.unavailable': '此会话暂不支持目录导航。',
|
||||
'terminal.directory.busy': '请等待终端回到空命令提示符。',
|
||||
'terminal.directory.readFailed': '无法读取此目录,请刷新重试。',
|
||||
'terminal.directory.changeFailed': '未能确认目录切换,请刷新后重试。',
|
||||
'terminal.directory.unsupportedPath': '此目录名称暂不支持快捷切换。',
|
||||
'settings.terminal.behavior.contextChips': 'Context Chips 上下文工具栏',
|
||||
'settings.terminal.behavior.contextChips.desc': '在撰写栏的快捷命令下方显示上下文工具栏。',
|
||||
|
||||
'terminal.toolbar.openSessionLog': '打开会话日志',
|
||||
'terminal.toolbar.sessionLogActions': '会话日志操作',
|
||||
'terminal.toolbar.sessionLog.menu': '会话日志',
|
||||
|
||||
@@ -90,6 +90,7 @@ export const zhTWAiMessages: Messages = {
|
||||
'ai.providers.advancedParams.hint': '留空則使用供應商預設值。',
|
||||
'ai.providers.advancedParams.maxTokens.placeholder': '例如 4096',
|
||||
'ai.providers.advancedParams.default': '供應商預設',
|
||||
'ai.providers.advancedParams.reasoningEffort.help': '僅當所選模型支援時才會送出 xhigh 與 max,否則自動收斂到該模型支援的最高檔位。',
|
||||
|
||||
// AI Codex
|
||||
'ai.codex': 'Codex',
|
||||
@@ -650,6 +651,10 @@ export const zhTWAiMessages: Messages = {
|
||||
'ai.chatShortcuts.title': '聊天快捷入口',
|
||||
'ai.chatShortcuts.selectionAction': '選取終端內容時顯示「新增到對話」',
|
||||
'ai.chatShortcuts.selectionAction.description': '在終端裡選取文字後顯示 AI 快捷按鈕。',
|
||||
'ai.chatHistory.title': '聊天歷史',
|
||||
'ai.chatHistory.messageCap': '每個會話保留的訊息數',
|
||||
'ai.chatHistory.messageCap.description': '每個會話最多儲存的訊息數(50–2000)。調低後會在下一次寫入時從儲存中捨棄最舊的訊息。數值越大,儲存空間與雲端同步體積也越大。',
|
||||
'ai.chatHistory.messageCap.unit': '則訊息',
|
||||
|
||||
// AI Error
|
||||
'ai.codex.bridgeError': 'Codex 主程序處理器尚未載入。請完全重新啟動 Netcatty 或重新啟動 Electron 開發程序,然後重試。',
|
||||
|
||||
@@ -1,6 +1,44 @@
|
||||
import type { Messages } from '../types';
|
||||
|
||||
export const zhTWCoreMessages: Messages = {
|
||||
"settings.plugins.github.install": "從 GitHub 安裝",
|
||||
"settings.plugins.github.description": "下載已發布的 .ncpkg 外掛套件,安裝前請審查來源與所需權限。",
|
||||
"settings.plugins.github.repository": "GitHub 儲存庫",
|
||||
"settings.plugins.github.formats": "支援 https://github.com/owner/repo、github.com/owner/repo 或 owner/repo。",
|
||||
"settings.plugins.github.busy": "正在下載、驗證或安裝套件…",
|
||||
"settings.plugins.github.review": "確認儲存庫來源並通過完整性驗證,並不代表發布者值得信任。安裝過程不會執行建置腳本,外掛安裝後預設停用。啟用前,請確認來源可信並檢查各項權限的授權方式。",
|
||||
"settings.plugins.github.accept": "已審查並信任此儲存庫,且已查看宣告的權限。",
|
||||
"settings.plugins.github.confirm": "安裝並保持停用",
|
||||
"settings.plugins.github.download": "下載並審查",
|
||||
"settings.plugins.github.error.invalidRepository": "請輸入 https://github.com/owner/repo、github.com/owner/repo 或 owner/repo。請只填寫儲存庫位址,不要包含帳號密碼、查詢參數、片段或額外路徑。",
|
||||
"settings.plugins.github.error.unavailable": "目前的工作階段無法從 GitHub 安裝外掛。",
|
||||
"settings.plugins.github.error.reviewFirst": "請先下載並審查 GitHub 外掛。",
|
||||
"settings.plugins.github.error.network": "無法連線至 GitHub,請檢查網路連線後重試。",
|
||||
"settings.plugins.github.error.timeout": "從 GitHub 下載逾時,請重試。",
|
||||
"settings.plugins.github.error.rateLimit": "GitHub API 請求次數已達上限,請稍後重試。",
|
||||
"settings.plugins.github.error.notFound": "找不到 GitHub 儲存庫或發布附件。僅支援可存取的公開儲存庫;私有外掛套件請從本機匯入。",
|
||||
"settings.plugins.github.error.accessDenied": "GitHub 拒絕存取。此功能不支援私有儲存庫或登入驗證。",
|
||||
"settings.plugins.github.error.http": "從 GitHub 下載失敗(HTTP {status}),請稍後重試。",
|
||||
"settings.plugins.github.error.moved": "此 GitHub 儲存庫已遷移,請輸入目前的儲存庫位址。",
|
||||
"settings.plugins.github.error.redirect": "無法驗證 GitHub 下載重新導向。請下載可信的 .ncpkg 外掛套件,再從本機匯入。",
|
||||
"settings.plugins.github.error.noRelease": "此儲存庫尚未發布穩定版本。請作者發布包含 .ncpkg 附件的穩定版本,或從本機匯入已建置的外掛套件。",
|
||||
"settings.plugins.github.error.noAsset": "此版本沒有 .ncpkg 附件。請作者建置並上傳外掛套件;Netcatty 無法直接建置原始碼儲存庫。",
|
||||
"settings.plugins.github.error.multipleAssets": "此版本包含多個 .ncpkg 附件。請下載需要的外掛套件,再從本機匯入。",
|
||||
"settings.plugins.github.error.metadata": "GitHub 發布資訊無效。請作者檢查版本發布、標籤及 .ncpkg 附件。",
|
||||
"settings.plugins.github.error.identity": "外掛套件與所選 GitHub 儲存庫或發布版本不符,已停止安裝。",
|
||||
"settings.plugins.github.error.commit": "無法存取發布標籤對應的提交,請作者修復發布標籤。",
|
||||
"settings.plugins.github.error.size": "GitHub 外掛套件大小無效或超出下載限制。",
|
||||
"settings.plugins.github.error.integrity": "下載的外掛套件未通過大小或 SHA-256 驗證,請重新下載並審查。",
|
||||
"settings.plugins.github.error.package": "GitHub 傳回的內容不是有效的 .ncpkg ZIP 外掛套件或發布資訊,請作者檢查發布附件。",
|
||||
"settings.plugins.github.error.busy": "已有 GitHub 外掛操作正在進行,請關閉預覽後重試。",
|
||||
"settings.plugins.github.error.expired": "GitHub 外掛套件預覽已失效,請重新下載並審查。",
|
||||
"settings.plugins.github.error.advanced": "此外掛套件需要進階開發者模式。一般外掛管理僅接受瀏覽器執行環境的套件。",
|
||||
"settings.plugins.github.error.incompatible": "此外掛與目前的 Netcatty 版本不相容,請聯絡作者確認技術詳細資料。",
|
||||
"settings.plugins.github.error.source": "此外掛已綁定其他來源。更換來源前,請先解除安裝並明確清除原來源的私有資料。",
|
||||
"settings.plugins.github.error.failed": "無法驗證或安裝外掛,請查看技術詳細資料後再重試。",
|
||||
"settings.plugins.github.error.details": "技術詳細資料",
|
||||
|
||||
|
||||
"settings.plugins.landing.checkUpdates": "檢查更新",
|
||||
"settings.plugins.landing.checkingUpdates": "正在檢查…",
|
||||
"settings.plugins.landing.updateSummary": "已檢查:{checked} · 已發佈更新:{updates} · 未檢查:{skipped}。",
|
||||
|
||||
@@ -1,6 +1,24 @@
|
||||
import type { Messages } from '../types';
|
||||
|
||||
export const zhTWTerminalMessages: Messages = {
|
||||
'terminal.directory.open': '目前目錄:{path}。變更目錄',
|
||||
'terminal.directory.unknown': '目錄未知',
|
||||
'terminal.directory.search': '搜尋目前目錄下的資料夾…',
|
||||
'terminal.directory.directories': '目錄',
|
||||
'terminal.directory.parent': '..(父目錄)',
|
||||
'terminal.directory.loading': '正在讀取目錄…',
|
||||
'terminal.directory.empty': '沒有子目錄',
|
||||
'terminal.directory.noMatches': '沒有符合的子目錄',
|
||||
'terminal.directory.hidden': '顯示隱藏目錄',
|
||||
'terminal.directory.refresh': '重新整理',
|
||||
'terminal.directory.unavailable': '此工作階段暫不支援目錄導覽。',
|
||||
'terminal.directory.busy': '請等待終端回到空白命令提示字元。',
|
||||
'terminal.directory.readFailed': '無法讀取此目錄,請重新整理後重試。',
|
||||
'terminal.directory.changeFailed': '未能確認目錄切換,請重新整理後重試。',
|
||||
'terminal.directory.unsupportedPath': '此目錄名稱暫不支援快速切換。',
|
||||
'settings.terminal.behavior.contextChips': 'Context Chips 上下文工具列',
|
||||
'settings.terminal.behavior.contextChips.desc': '在撰寫列的快捷命令下方顯示上下文工具列。',
|
||||
|
||||
'terminal.toolbar.openSessionLog': '開啟工作階段日誌',
|
||||
'terminal.toolbar.sessionLogActions': '工作階段日誌操作',
|
||||
'terminal.toolbar.sessionLog.menu': '工作階段日誌',
|
||||
|
||||
@@ -0,0 +1,285 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import type { AISession } from '../../infrastructure/ai/types';
|
||||
import { installFakeIndexedDb } from './testSupport/fakeIndexedDb';
|
||||
import {
|
||||
AI_SESSIONS_META_LAST_WRITE_KEY,
|
||||
AI_SESSIONS_META_MIGRATION_KEY,
|
||||
AI_SESSIONS_META_SCHEMA_VERSION_KEY,
|
||||
idbClearSessions,
|
||||
idbDeleteSessionIds,
|
||||
idbGetAllSessions,
|
||||
idbGetMeta,
|
||||
idbPutMeta,
|
||||
idbPutSession,
|
||||
idbPutSessions,
|
||||
isIndexedDbAvailable,
|
||||
loadOrMigrateAiSessions,
|
||||
mirrorActiveSessionMapToIdb,
|
||||
openAiSessionsIdb,
|
||||
readActiveSessionMapFromIdb,
|
||||
resetAiSessionsIdbForTests,
|
||||
} from './aiSessionIdbStorage';
|
||||
|
||||
function makeSession(id: string, updatedAt: number): AISession {
|
||||
return {
|
||||
id,
|
||||
title: id,
|
||||
agentId: 'agent',
|
||||
scope: { type: 'terminal', targetId: 't' },
|
||||
messages: [],
|
||||
createdAt: updatedAt,
|
||||
updatedAt,
|
||||
} as AISession;
|
||||
}
|
||||
|
||||
test('module wrapper roundtrips sessions through the object stores', async () => {
|
||||
const idb = installFakeIndexedDb();
|
||||
try {
|
||||
assert.equal(isIndexedDbAvailable(), true);
|
||||
const db = await openAiSessionsIdb();
|
||||
assert.equal(db.objectStoreNames.contains('sessions'), true);
|
||||
assert.equal(db.objectStoreNames.contains('meta'), true);
|
||||
|
||||
await idbPutSessions(db, [makeSession('chat-a', 2), makeSession('chat-b', 1)]);
|
||||
await idbPutSession(db, { ...makeSession('chat-a', 3), title: 'updated' });
|
||||
const all = await idbGetAllSessions(db);
|
||||
assert.deepEqual(all.map((s) => s.id).sort(), ['chat-a', 'chat-b']);
|
||||
assert.equal(all.find((s) => s.id === 'chat-a')?.title, 'updated');
|
||||
|
||||
await idbDeleteSessionIds(db, ['chat-a']);
|
||||
assert.deepEqual((await idbGetAllSessions(db)).map((s) => s.id), ['chat-b']);
|
||||
await idbClearSessions(db);
|
||||
assert.deepEqual(await idbGetAllSessions(db), []);
|
||||
} finally {
|
||||
idb.restore();
|
||||
resetAiSessionsIdbForTests();
|
||||
}
|
||||
});
|
||||
|
||||
test('meta store roundtrips schema and active-map records', async () => {
|
||||
const idb = installFakeIndexedDb();
|
||||
try {
|
||||
const db = await openAiSessionsIdb();
|
||||
await idbPutMeta(db, AI_SESSIONS_META_SCHEMA_VERSION_KEY, 1);
|
||||
await idbPutMeta(db, AI_SESSIONS_META_LAST_WRITE_KEY, 1234);
|
||||
assert.equal(await idbGetMeta<number>(db, AI_SESSIONS_META_SCHEMA_VERSION_KEY), 1);
|
||||
assert.equal(await idbGetMeta<number>(db, AI_SESSIONS_META_LAST_WRITE_KEY), 1234);
|
||||
assert.equal(await idbGetMeta<number>(db, 'missing'), undefined);
|
||||
|
||||
await mirrorActiveSessionMapToIdb({ 'terminal:t': 'chat-a' });
|
||||
// Mirror uses its own connection.
|
||||
assert.deepEqual(await readActiveSessionMapFromIdb(), { 'terminal:t': 'chat-a' });
|
||||
} finally {
|
||||
idb.restore();
|
||||
resetAiSessionsIdbForTests();
|
||||
}
|
||||
});
|
||||
|
||||
test('loadOrMigrateAiSessions reads IndexedDB first and returns its sessions', async () => {
|
||||
const idb = installFakeIndexedDb();
|
||||
try {
|
||||
const db = await openAiSessionsIdb();
|
||||
await idbPutSessions(db, [makeSession('idb-chat', 1)]);
|
||||
const result = await loadOrMigrateAiSessions(() => JSON.stringify([makeSession('legacy-chat', 2)]));
|
||||
assert.equal(result.source, 'indexeddb');
|
||||
assert.equal(result.idbFailed, false);
|
||||
assert.deepEqual(result.sessions.map((s) => s.id), ['idb-chat']);
|
||||
// Legacy key content was not consumed for a migration when IDB has data.
|
||||
assert.equal(idb.db.dumpStore('sessions').length, 1);
|
||||
} finally {
|
||||
idb.restore();
|
||||
resetAiSessionsIdbForTests();
|
||||
}
|
||||
});
|
||||
|
||||
test('loadOrMigrateAiSessions migrates a populated legacy store into empty IndexedDB, keeping the key untouched', async () => {
|
||||
const idb = installFakeIndexedDb();
|
||||
try {
|
||||
const legacyRaw = JSON.stringify([makeSession('legacy-1', 2), makeSession('legacy-2', 1)]);
|
||||
let readCount = 0;
|
||||
const result = await loadOrMigrateAiSessions(() => {
|
||||
readCount += 1;
|
||||
return legacyRaw;
|
||||
});
|
||||
assert.equal(result.source, 'legacy-localstorage');
|
||||
assert.equal(result.idbFailed, false);
|
||||
assert.deepEqual(result.sessions.map((s) => s.id).sort(), ['legacy-1', 'legacy-2']);
|
||||
// Now in IndexedDB (one record per session).
|
||||
assert.deepEqual(
|
||||
idb.db.dumpStore('sessions').map((s) => (s as AISession).id).sort(),
|
||||
['legacy-1', 'legacy-2'],
|
||||
);
|
||||
// Meta records written for the migration.
|
||||
const db = await openAiSessionsIdb();
|
||||
assert.equal(await idbGetMeta<number>(db, AI_SESSIONS_META_SCHEMA_VERSION_KEY), 1);
|
||||
assert.ok(await idbGetMeta<number>(db, AI_SESSIONS_META_LAST_WRITE_KEY));
|
||||
assert.ok(readCount >= 1);
|
||||
// The legacy raw string itself is only read, never rewritten or removed
|
||||
// — the callback is the only interface to the legacy store.
|
||||
assert.equal(readCount >= 1, true);
|
||||
} finally {
|
||||
idb.restore();
|
||||
resetAiSessionsIdbForTests();
|
||||
}
|
||||
});
|
||||
|
||||
test('loadOrMigrateAiSessions returns none for an empty store without legacy data', async () => {
|
||||
const idb = installFakeIndexedDb();
|
||||
try {
|
||||
const result = await loadOrMigrateAiSessions(() => null);
|
||||
assert.equal(result.source, 'none');
|
||||
assert.deepEqual(result.sessions, []);
|
||||
assert.equal(result.idbFailed, false);
|
||||
assert.equal(result.authoritativeEmpty, false, 'a newly created store still permits disk recovery');
|
||||
assert.equal(result.migrationPending, false);
|
||||
} finally {
|
||||
idb.restore();
|
||||
resetAiSessionsIdbForTests();
|
||||
}
|
||||
});
|
||||
|
||||
test('loadOrMigrateAiSessions treats a legacy empty array as an intentional wipe (no migration, no restore source)', async () => {
|
||||
const idb = installFakeIndexedDb();
|
||||
try {
|
||||
const result = await loadOrMigrateAiSessions(() => '[]');
|
||||
assert.equal(result.source, 'none');
|
||||
assert.deepEqual(result.sessions, []);
|
||||
assert.equal(idb.db.dumpStore('sessions').length, 0);
|
||||
} finally {
|
||||
idb.restore();
|
||||
resetAiSessionsIdbForTests();
|
||||
}
|
||||
});
|
||||
|
||||
test('loadOrMigrateAiSessions falls back to legacy localStorage when IndexedDB is corrupt', async () => {
|
||||
const idb = installFakeIndexedDb({ failOpen: true });
|
||||
try {
|
||||
const legacy = [makeSession('legacy-chat', 1)];
|
||||
const result = await loadOrMigrateAiSessions(() => JSON.stringify(legacy));
|
||||
assert.equal(result.source, 'legacy-localstorage');
|
||||
assert.equal(result.idbFailed, true);
|
||||
assert.deepEqual(result.sessions, legacy);
|
||||
} finally {
|
||||
idb.restore();
|
||||
resetAiSessionsIdbForTests();
|
||||
}
|
||||
});
|
||||
|
||||
test('loadOrMigrateAiSessions falls back to legacy localStorage when IndexedDB is unavailable', async () => {
|
||||
const previous = (globalThis as { indexedDB?: unknown }).indexedDB;
|
||||
(globalThis as { indexedDB?: unknown }).indexedDB = undefined;
|
||||
try {
|
||||
const legacy = [makeSession('legacy-chat', 1)];
|
||||
const result = await loadOrMigrateAiSessions(() => JSON.stringify(legacy));
|
||||
assert.equal(isIndexedDbAvailable(), false);
|
||||
assert.equal(result.source, 'legacy-localstorage');
|
||||
assert.equal(result.idbFailed, false);
|
||||
assert.deepEqual(result.sessions, legacy);
|
||||
} finally {
|
||||
(globalThis as { indexedDB?: unknown }).indexedDB = previous;
|
||||
resetAiSessionsIdbForTests();
|
||||
}
|
||||
});
|
||||
|
||||
// ── Issue #4125 review P1②: the migration is one-shot via a persisted marker ──
|
||||
|
||||
test('a completed migration is one-shot: a chat deleted afterwards stays deleted on the next boot', async () => {
|
||||
const idb = installFakeIndexedDb();
|
||||
try {
|
||||
const legacyRaw = JSON.stringify([makeSession('legacy-1', 2), makeSession('legacy-2', 1)]);
|
||||
const first = await loadOrMigrateAiSessions(() => legacyRaw);
|
||||
assert.equal(first.source, 'legacy-localstorage');
|
||||
// The marker was persisted after the migration data became durable.
|
||||
let db = await openAiSessionsIdb();
|
||||
assert.ok(await idbGetMeta(db, AI_SESSIONS_META_MIGRATION_KEY));
|
||||
// The user deletes a chat; the legacy key stays behind by design.
|
||||
await idbDeleteSessionIds(db, ['legacy-1']);
|
||||
// Next boot: the marker is present, so the legacy key is never
|
||||
// re-migrated and the deletion is not resurrected.
|
||||
const second = await loadOrMigrateAiSessions(() => legacyRaw);
|
||||
assert.equal(second.source, 'indexeddb');
|
||||
assert.deepEqual(second.sessions.map((s) => s.id), ['legacy-2']);
|
||||
assert.equal((await openAiSessionsIdb()).objectStoreNames.contains('sessions'), true);
|
||||
db = await openAiSessionsIdb();
|
||||
assert.deepEqual((await idbGetAllSessions(db)).map((s) => s.id), ['legacy-2']);
|
||||
} finally {
|
||||
idb.restore();
|
||||
resetAiSessionsIdbForTests();
|
||||
}
|
||||
});
|
||||
|
||||
test('an empty IndexedDB store with the migration marker ignores a populated legacy key', async () => {
|
||||
const idb = installFakeIndexedDb();
|
||||
try {
|
||||
// First boot with nothing anywhere records the marker.
|
||||
const first = await loadOrMigrateAiSessions(() => null);
|
||||
assert.equal(first.source, 'none');
|
||||
assert.ok(await idbGetMeta(await openAiSessionsIdb(), AI_SESSIONS_META_MIGRATION_KEY));
|
||||
// The user deleted every chat, but the stale legacy key is still there.
|
||||
const second = await loadOrMigrateAiSessions(
|
||||
() => JSON.stringify([makeSession('resurrected', 1)]),
|
||||
);
|
||||
assert.equal(second.source, 'none');
|
||||
assert.equal(second.authoritativeEmpty, true);
|
||||
assert.deepEqual(second.sessions, []);
|
||||
assert.equal(idb.db.dumpStore('sessions').length, 0);
|
||||
} finally {
|
||||
idb.restore();
|
||||
resetAiSessionsIdbForTests();
|
||||
}
|
||||
});
|
||||
|
||||
test('partial disk migration retries missing IDs beside existing IDB records until all are durable', async () => {
|
||||
const idb = installFakeIndexedDb();
|
||||
try {
|
||||
const primary = makeSession('primary', 3);
|
||||
const diskA = makeSession('disk-a', 2);
|
||||
const diskB = makeSession('disk-b', 1);
|
||||
const diskIds = [diskA.id, diskB.id];
|
||||
const db = await openAiSessionsIdb();
|
||||
const first = await loadOrMigrateAiSessions(() => JSON.stringify([primary]), diskIds);
|
||||
assert.equal(first.migrationPending, true);
|
||||
assert.deepEqual(await idbGetAllSessions(db), [primary]);
|
||||
assert.equal(await idbGetMeta(db, AI_SESSIONS_META_MIGRATION_KEY), undefined);
|
||||
|
||||
const updated = { ...primary, title: 'edited in IndexedDB', updatedAt: 4 };
|
||||
await idbPutSession(db, updated);
|
||||
const partial = await loadOrMigrateAiSessions(() => JSON.stringify([primary, diskA]), diskIds);
|
||||
assert.equal(partial.migrationPending, true, 'one recovered disk ID does not complete migration');
|
||||
assert.deepEqual(partial.sessions, [updated, diskA]);
|
||||
assert.equal(await idbGetMeta(db, AI_SESSIONS_META_MIGRATION_KEY), undefined);
|
||||
|
||||
const complete = await loadOrMigrateAiSessions(() => JSON.stringify([primary, diskA, diskB]), diskIds);
|
||||
assert.equal(complete.source, 'indexeddb');
|
||||
assert.equal(complete.migrationPending, false);
|
||||
assert.deepEqual(complete.sessions, [updated, diskA, diskB]);
|
||||
assert.deepEqual(await idbGetAllSessions(db), [updated, diskA, diskB]);
|
||||
assert.ok(await idbGetMeta(db, AI_SESSIONS_META_MIGRATION_KEY));
|
||||
|
||||
await idbClearSessions(db);
|
||||
const deleted = await loadOrMigrateAiSessions(() => {
|
||||
assert.fail('completed migration must not retry the stale overflow markers');
|
||||
}, diskIds);
|
||||
assert.equal(deleted.authoritativeEmpty, true);
|
||||
assert.deepEqual(deleted.sessions, []);
|
||||
} finally {
|
||||
idb.restore();
|
||||
resetAiSessionsIdbForTests();
|
||||
}
|
||||
});
|
||||
|
||||
test('a pre-marker store that already holds sessions records the marker on boot', async () => {
|
||||
const idb = installFakeIndexedDb();
|
||||
try {
|
||||
const db = await openAiSessionsIdb();
|
||||
await idbPutSessions(db, [makeSession('already-here', 1)]);
|
||||
const result = await loadOrMigrateAiSessions(() => JSON.stringify([makeSession('legacy', 2)]));
|
||||
assert.equal(result.source, 'indexeddb');
|
||||
assert.ok(await idbGetMeta(db, AI_SESSIONS_META_MIGRATION_KEY));
|
||||
assert.equal(idb.db.dumpStore('sessions').length, 1);
|
||||
} finally {
|
||||
idb.restore();
|
||||
resetAiSessionsIdbForTests();
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,372 @@
|
||||
import type { AISession } from '../../infrastructure/ai/types';
|
||||
|
||||
/**
|
||||
* IndexedDB persistence for AI chat sessions (issue #4123).
|
||||
*
|
||||
* The legacy store kept every session as one JSON blob in localStorage under
|
||||
* a 2 MB byte budget. Each write re-serialized the whole store, and when the
|
||||
* budget was exceeded the degradation ladder dropped whole oldest sessions —
|
||||
* silently destroying capped chats (~1 MB each) on every write.
|
||||
*
|
||||
* IndexedDB removes both halves of that failure mode: records are written
|
||||
* per session (no global re-serialization), and there is no shared byte
|
||||
* quota to defend, so byte-driven eviction disappears entirely.
|
||||
*
|
||||
* Layout:
|
||||
* - db `netcatty_ai_sessions`
|
||||
* - object store `sessions` (keyPath `id`): one record per AISession
|
||||
* - object store `meta` (keyPath `key`): schemaVersion, lastWriteAt and the
|
||||
* migrated `netcatty_ai_active_session_map_v1` active-pointer map
|
||||
*
|
||||
* The legacy localStorage key `netcatty_ai_sessions_v1` is kept UNTOUCHED for
|
||||
* one release as a read-only fallback; it is no longer written or pruned.
|
||||
*/
|
||||
|
||||
export const AI_SESSIONS_IDB_NAME = 'netcatty_ai_sessions';
|
||||
export const AI_SESSIONS_IDB_VERSION = 1;
|
||||
export const AI_SESSIONS_IDB_SESSION_STORE = 'sessions';
|
||||
export const AI_SESSIONS_IDB_META_STORE = 'meta';
|
||||
export const AI_SESSIONS_META_SCHEMA_VERSION_KEY = 'schemaVersion';
|
||||
export const AI_SESSIONS_META_LAST_WRITE_KEY = 'lastWriteAt';
|
||||
export const AI_SESSIONS_META_ACTIVE_MAP_KEY = 'activeSessionMap';
|
||||
export const AI_SESSIONS_META_MIGRATION_KEY = 'migrationCompleted';
|
||||
|
||||
/** Cross-window change notification (replaces the localStorage `storage` event for sessions). */
|
||||
const AI_SESSIONS_IDB_CHANGE_CHANNEL = 'netcatty_ai_sessions_changed';
|
||||
|
||||
export function isIndexedDbAvailable(): boolean {
|
||||
return typeof indexedDB !== 'undefined' && indexedDB !== null;
|
||||
}
|
||||
|
||||
function requestToPromise<T>(request: IDBRequest<T>): Promise<T> {
|
||||
return new Promise((resolve, reject) => {
|
||||
request.onsuccess = () => resolve(request.result);
|
||||
request.onerror = () => reject(request.error ?? new Error('IndexedDB request failed'));
|
||||
});
|
||||
}
|
||||
|
||||
function transactionDone(transaction: IDBTransaction): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
transaction.oncomplete = () => resolve();
|
||||
transaction.onabort = () => reject(transaction.error ?? new Error('IndexedDB transaction aborted'));
|
||||
transaction.onerror = () => reject(transaction.error ?? new Error('IndexedDB transaction failed'));
|
||||
});
|
||||
}
|
||||
|
||||
let openPromise: Promise<IDBDatabase> | null = null;
|
||||
|
||||
function upgradeDatabase(db: IDBDatabase) {
|
||||
if (!db.objectStoreNames.contains(AI_SESSIONS_IDB_SESSION_STORE)) {
|
||||
db.createObjectStore(AI_SESSIONS_IDB_SESSION_STORE, { keyPath: 'id' });
|
||||
}
|
||||
if (!db.objectStoreNames.contains(AI_SESSIONS_IDB_META_STORE)) {
|
||||
db.createObjectStore(AI_SESSIONS_IDB_META_STORE, { keyPath: 'key' });
|
||||
}
|
||||
}
|
||||
|
||||
export function openAiSessionsIdb(): Promise<IDBDatabase> {
|
||||
if (!isIndexedDbAvailable()) {
|
||||
return Promise.reject(new Error('IndexedDB is not available'));
|
||||
}
|
||||
if (!openPromise) {
|
||||
openPromise = new Promise((resolve, reject) => {
|
||||
const request = indexedDB.open(AI_SESSIONS_IDB_NAME, AI_SESSIONS_IDB_VERSION);
|
||||
request.onupgradeneeded = () => upgradeDatabase(request.result);
|
||||
request.onsuccess = () => {
|
||||
const db = request.result;
|
||||
// Drop the cached handle so a later open() re-establishes it instead
|
||||
// of handing out a closed connection.
|
||||
db.onclose = () => { openPromise = null; };
|
||||
db.onversionchange = () => {
|
||||
db.close();
|
||||
openPromise = null;
|
||||
};
|
||||
resolve(db);
|
||||
};
|
||||
request.onerror = () => {
|
||||
openPromise = null;
|
||||
reject(request.error ?? new Error('Failed to open IndexedDB'));
|
||||
};
|
||||
request.onblocked = () => {
|
||||
openPromise = null;
|
||||
};
|
||||
});
|
||||
}
|
||||
return openPromise;
|
||||
}
|
||||
|
||||
export function resetAiSessionsIdbForTests() {
|
||||
openPromise = null;
|
||||
}
|
||||
|
||||
export async function idbGetAllSessions(db?: IDBDatabase): Promise<AISession[]> {
|
||||
const database = db ?? await openAiSessionsIdb();
|
||||
const transaction = database.transaction(AI_SESSIONS_IDB_SESSION_STORE, 'readonly');
|
||||
const result = await requestToPromise(
|
||||
transaction.objectStore(AI_SESSIONS_IDB_SESSION_STORE).getAll() as IDBRequest<AISession[]>,
|
||||
);
|
||||
return Array.isArray(result) ? result : [];
|
||||
}
|
||||
|
||||
export async function idbPutSessions(db: IDBDatabase, sessions: AISession[]): Promise<void> {
|
||||
if (sessions.length === 0) return;
|
||||
const transaction = db.transaction(AI_SESSIONS_IDB_SESSION_STORE, 'readwrite');
|
||||
const store = transaction.objectStore(AI_SESSIONS_IDB_SESSION_STORE);
|
||||
for (const session of sessions) store.put(session);
|
||||
await transactionDone(transaction);
|
||||
}
|
||||
|
||||
export async function idbPutSession(db: IDBDatabase, session: AISession): Promise<void> {
|
||||
await idbPutSessions(db, [session]);
|
||||
}
|
||||
|
||||
export async function idbDeleteSessionIds(db: IDBDatabase, ids: string[]): Promise<void> {
|
||||
if (ids.length === 0) return;
|
||||
const transaction = db.transaction(AI_SESSIONS_IDB_SESSION_STORE, 'readwrite');
|
||||
const store = transaction.objectStore(AI_SESSIONS_IDB_SESSION_STORE);
|
||||
for (const id of ids) store.delete(id);
|
||||
await transactionDone(transaction);
|
||||
}
|
||||
|
||||
export async function idbClearSessions(db: IDBDatabase): Promise<void> {
|
||||
const transaction = db.transaction(AI_SESSIONS_IDB_SESSION_STORE, 'readwrite');
|
||||
transaction.objectStore(AI_SESSIONS_IDB_SESSION_STORE).clear();
|
||||
await transactionDone(transaction);
|
||||
}
|
||||
|
||||
export async function idbGetMeta<T>(db: IDBDatabase, key: string): Promise<T | undefined> {
|
||||
const transaction = db.transaction(AI_SESSIONS_IDB_META_STORE, 'readonly');
|
||||
const record = await requestToPromise(
|
||||
transaction.objectStore(AI_SESSIONS_IDB_META_STORE).get(key) as IDBRequest<{ key: string; value: T } | undefined>,
|
||||
);
|
||||
return record?.value;
|
||||
}
|
||||
|
||||
export async function idbPutMeta<T>(db: IDBDatabase, key: string, value: T): Promise<void> {
|
||||
const transaction = db.transaction(AI_SESSIONS_IDB_META_STORE, 'readwrite');
|
||||
transaction.objectStore(AI_SESSIONS_IDB_META_STORE).put({ key, value });
|
||||
await transactionDone(transaction);
|
||||
}
|
||||
|
||||
// ── Cross-window change notification ──
|
||||
|
||||
let changeChannel: BroadcastChannel | null = null;
|
||||
|
||||
function getChangeChannel(): BroadcastChannel | null {
|
||||
if (typeof BroadcastChannel === 'undefined') return null;
|
||||
try {
|
||||
if (!changeChannel) {
|
||||
changeChannel = new BroadcastChannel(AI_SESSIONS_IDB_CHANGE_CHANNEL);
|
||||
// The channel must never keep the process alive (Node exposes unref).
|
||||
(changeChannel as unknown as { unref?: () => void }).unref?.();
|
||||
}
|
||||
return changeChannel;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Announce a session-store change to OTHER windows (never the sender). */
|
||||
export function publishAiSessionsIdbChange(): void {
|
||||
try {
|
||||
getChangeChannel()?.postMessage(Date.now());
|
||||
} catch {
|
||||
// Notification is best-effort; the write itself already succeeded.
|
||||
}
|
||||
}
|
||||
|
||||
export function subscribeAiSessionsIdbChange(listener: () => void): () => void {
|
||||
const channel = getChangeChannel();
|
||||
if (!channel) return () => {};
|
||||
const handler = () => listener();
|
||||
channel.addEventListener('message', handler);
|
||||
return () => channel.removeEventListener('message', handler);
|
||||
}
|
||||
|
||||
// ── Boot load + one-release legacy migration ──
|
||||
|
||||
export interface AiSessionsBootLoadResult {
|
||||
sessions: AISession[];
|
||||
/** Where the returned sessions came from. */
|
||||
source: 'indexeddb' | 'legacy-localstorage' | 'none';
|
||||
/** True when IndexedDB itself could not be opened (corrupt/blocked). */
|
||||
idbFailed: boolean;
|
||||
/** The store was already initialized before this boot; empty means deleted. */
|
||||
authoritativeEmpty: boolean;
|
||||
/** Marked disk-only records are still missing; keep their backup generations. */
|
||||
migrationPending: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Boot-time store resolution, shared with tests:
|
||||
* 1. IndexedDB `getAll()` — the primary store.
|
||||
* 2. If empty, migrate the legacy localStorage store into IndexedDB (the
|
||||
* legacy key itself stays untouched as a read-only fallback for one
|
||||
* release).
|
||||
* 3. If IndexedDB is corrupt/unavailable, fall back to legacy localStorage.
|
||||
*
|
||||
* The migration is one-shot (issue #4125 review P1②): a `migrationCompleted`
|
||||
* marker is persisted in the meta store after the first successful boot
|
||||
* resolution. Once present, the legacy key is never consulted again —
|
||||
* otherwise a boot whose migration ran while the legacy key could not be
|
||||
* removed would re-migrate the stale legacy list and resurrect deleted
|
||||
* chats. The marker also distinguishes "intentionally empty" (the user
|
||||
* deleted every chat) from "uninitialized".
|
||||
*
|
||||
* Callers decide what to do when both sources are empty (the #4018 backup
|
||||
* generations remain the last restore step in useAIState).
|
||||
* The legacy reader may await disk-only recovery before migration completes.
|
||||
* Missing disk-only IDs keep migration resumable, including in a populated IDB.
|
||||
*/
|
||||
export async function loadOrMigrateAiSessions(
|
||||
readLegacyRaw: () => string | null | undefined | Promise<string | null | undefined>,
|
||||
diskOnlySessionIds: readonly string[] = [],
|
||||
): Promise<AiSessionsBootLoadResult> {
|
||||
const hasUnresolvedDiskSessions = (sessions: AISession[]) => {
|
||||
const ids = new Set(sessions.map(session => session.id));
|
||||
return diskOnlySessionIds.some(id => !ids.has(id));
|
||||
};
|
||||
const legacyFallback = async (idbFailed: boolean): Promise<AiSessionsBootLoadResult> => {
|
||||
const legacy = parseLegacySessions(await readLegacyRaw());
|
||||
return {
|
||||
sessions: legacy,
|
||||
source: legacy.length > 0 ? 'legacy-localstorage' : 'none',
|
||||
idbFailed,
|
||||
authoritativeEmpty: false,
|
||||
migrationPending: hasUnresolvedDiskSessions(legacy),
|
||||
};
|
||||
};
|
||||
if (!isIndexedDbAvailable()) {
|
||||
return legacyFallback(false);
|
||||
}
|
||||
let db: IDBDatabase;
|
||||
try {
|
||||
db = await openAiSessionsIdb();
|
||||
} catch (error) {
|
||||
console.warn('[AIState] IndexedDB unavailable, falling back to the legacy localStorage store:', error);
|
||||
return legacyFallback(true);
|
||||
}
|
||||
let sessions: AISession[];
|
||||
try {
|
||||
sessions = await idbGetAllSessions(db);
|
||||
} catch (error) {
|
||||
console.warn('[AIState] IndexedDB read failed, falling back to the legacy localStorage store:', error);
|
||||
return legacyFallback(true);
|
||||
}
|
||||
let marker: AiSessionsMigrationMarker | undefined;
|
||||
try {
|
||||
marker = await idbGetMeta<AiSessionsMigrationMarker>(db, AI_SESSIONS_META_MIGRATION_KEY);
|
||||
} catch {
|
||||
marker = undefined;
|
||||
}
|
||||
if (marker || (sessions.length > 0 && !hasUnresolvedDiskSessions(sessions))) {
|
||||
if (!marker) {
|
||||
// First boot on a store that already holds sessions (e.g. migrated by
|
||||
// a build without the marker): record one-shot completion so the
|
||||
// legacy key can never re-seed the store.
|
||||
await writeMigrationMarker(db).catch(() => {});
|
||||
}
|
||||
return {
|
||||
sessions,
|
||||
source: sessions.length > 0 ? 'indexeddb' : 'none',
|
||||
idbFailed: false,
|
||||
authoritativeEmpty: sessions.length === 0,
|
||||
migrationPending: false,
|
||||
};
|
||||
}
|
||||
// Unfinished migration (empty or partially populated IDB). The legacy key is
|
||||
// intentionally NOT removed: it stays as a read-only fallback for one
|
||||
// release.
|
||||
const legacy = parseLegacySessions(await readLegacyRaw());
|
||||
// A partial migration may already have primary/chunk records in IDB. Only
|
||||
// fill missing marked IDs; never replay stale primary data over live IDB.
|
||||
const existingIds = new Set(sessions.map(session => session.id));
|
||||
const diskOnlyIds = new Set(diskOnlySessionIds);
|
||||
const recovered = sessions.length === 0
|
||||
? legacy
|
||||
: legacy.filter(session => diskOnlyIds.has(session.id) && !existingIds.has(session.id));
|
||||
const merged = [...sessions, ...recovered].sort((a, b) => (b.updatedAt ?? 0) - (a.updatedAt ?? 0));
|
||||
const migrationPending = hasUnresolvedDiskSessions(merged);
|
||||
const result: AiSessionsBootLoadResult = {
|
||||
sessions: merged,
|
||||
source: sessions.length > 0 ? 'indexeddb' : merged.length > 0 ? 'legacy-localstorage' : 'none',
|
||||
idbFailed: false,
|
||||
authoritativeEmpty: false,
|
||||
migrationPending,
|
||||
};
|
||||
if (merged.length === 0) {
|
||||
// Nothing to migrate: mark the store initialized so this state is
|
||||
// distinguishable from "uninitialized" on the next boot.
|
||||
if (!migrationPending) await writeMigrationMarker(db).catch(() => {});
|
||||
return result;
|
||||
}
|
||||
try {
|
||||
await idbPutSessions(db, recovered);
|
||||
await idbPutMeta(db, AI_SESSIONS_META_SCHEMA_VERSION_KEY, AI_SESSIONS_IDB_VERSION);
|
||||
await idbPutMeta(db, AI_SESSIONS_META_LAST_WRITE_KEY, Date.now());
|
||||
// Written AFTER the migration data is durable: a crash before this point
|
||||
// leaves the marker absent and the next boot safely re-migrates.
|
||||
if (!migrationPending) await writeMigrationMarker(db);
|
||||
} catch (error) {
|
||||
console.warn('[AIState] IndexedDB migration write failed; keeping legacy store authoritative:', error);
|
||||
return { ...result, idbFailed: true };
|
||||
}
|
||||
console.info(`[AIState] Migrated ${recovered.length} AI session(s) from localStorage to IndexedDB.`);
|
||||
return result;
|
||||
}
|
||||
|
||||
interface AiSessionsMigrationMarker {
|
||||
migrationCompleted: true;
|
||||
migrationAt: number;
|
||||
}
|
||||
|
||||
async function writeMigrationMarker(db: IDBDatabase): Promise<void> {
|
||||
await idbPutMeta<AiSessionsMigrationMarker>(
|
||||
db,
|
||||
AI_SESSIONS_META_MIGRATION_KEY,
|
||||
{ migrationCompleted: true, migrationAt: Date.now() },
|
||||
);
|
||||
}
|
||||
|
||||
function parseLegacySessions(raw: string | null | undefined): AISession[] {
|
||||
if (raw == null || raw === '') return [];
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(raw);
|
||||
return Array.isArray(parsed) ? parsed as AISession[] : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
// ── Active-session map mirror (meta store) ──
|
||||
|
||||
/**
|
||||
* Mirror the active-pointer map into the IndexedDB meta store. The
|
||||
* localStorage key remains written as before (small, quota-safe) and stays
|
||||
* the read-mostly authority for this release; the mirror makes the pointer
|
||||
* available to future store work without another migration.
|
||||
*/
|
||||
export async function mirrorActiveSessionMapToIdb(
|
||||
map: Record<string, string | null>,
|
||||
): Promise<void> {
|
||||
try {
|
||||
const db = await openAiSessionsIdb();
|
||||
await idbPutMeta(db, AI_SESSIONS_META_ACTIVE_MAP_KEY, map);
|
||||
} catch (error) {
|
||||
console.warn('[AIState] Failed to mirror the active-session map to IndexedDB:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the mirrored active-pointer map (boot restore aid when the
|
||||
* localStorage key is missing).
|
||||
*/
|
||||
export async function readActiveSessionMapFromIdb(): Promise<Record<string, string | null> | null> {
|
||||
try {
|
||||
const db = await openAiSessionsIdb();
|
||||
const value = await idbGetMeta<Record<string, string | null>>(db, AI_SESSIONS_META_ACTIVE_MAP_KEY);
|
||||
return value && typeof value === 'object' ? value : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -66,10 +66,12 @@ test('parseBackupSessions ignores corrupt payloads and drops malformed entries',
|
||||
});
|
||||
|
||||
test('restore preserves all sessions when cloud sync is enabled', () => {
|
||||
const sessions = Array.from({ length: 51 }, (_, index) => makeSession(`s${index}`, index));
|
||||
// Issue #4123: the retention cap is a session count (raised to 1000) —
|
||||
// there is no byte budget any more.
|
||||
const sessions = Array.from({ length: 1001 }, (_, index) => makeSession(`s${index}`, index));
|
||||
assert.deepEqual(prepareRestoredSessions(sessions, true), sessions);
|
||||
const local = prepareRestoredSessions(sessions, false);
|
||||
assert.equal(local.length, 50);
|
||||
assert.equal(local.length, 1000);
|
||||
assert.equal(local.some(session => session.id === 's0'), false);
|
||||
});
|
||||
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -2,10 +2,13 @@ import { localStorageAdapter } from '../../infrastructure/persistence/localStora
|
||||
import {
|
||||
STORAGE_KEY_AI_ACTIVE_SESSION_MAP,
|
||||
STORAGE_KEY_AI_SESSIONS,
|
||||
STORAGE_KEY_AI_SESSIONS_OVERFLOW,
|
||||
STORAGE_KEY_AI_SESSIONS_DISK_OVERFLOW,
|
||||
STORAGE_KEY_AI_SESSION_MESSAGE_CAP,
|
||||
STORAGE_KEY_CLOUD_SYNC_AI_SESSIONS,
|
||||
} from '../../infrastructure/config/storageKeys';
|
||||
import {
|
||||
DEFAULT_AI_SESSION_MESSAGE_CAP,
|
||||
normalizeAiSessionMessageCap,
|
||||
} from '../../domain/aiSessionMessageCap';
|
||||
import type {
|
||||
AIDraft,
|
||||
AIPanelView,
|
||||
@@ -13,7 +16,6 @@ import type {
|
||||
AIPermissionMode,
|
||||
AIToolIntegrationMode,
|
||||
} from '../../infrastructure/ai/types';
|
||||
import type { ProviderContinuationOptions } from '../../infrastructure/ai/providerContinuation';
|
||||
import { findSafeChatMessageCompactionSplitIndex } from '../../infrastructure/ai/contextCompaction';
|
||||
import {
|
||||
bumpDraftMutationVersionState,
|
||||
@@ -27,7 +29,16 @@ import {
|
||||
import { emitAIStateChanged } from './aiStateEvents';
|
||||
import { peekAgentRuntimeCleanup } from '../../infrastructure/ai/harness/agentRuntimeHandle';
|
||||
import { setReversibleModelPrivacy } from '../../infrastructure/ai/harness/modelSecretRedaction';
|
||||
import { DebouncedBackupMirror, mergeImportedSessions, readLiveSessionsArray } from './aiSessionsBackup';
|
||||
import { DebouncedBackupMirror, readLiveSessionsArray } from './aiSessionsBackup';
|
||||
import {
|
||||
idbDeleteSessionIds,
|
||||
idbPutMeta,
|
||||
idbPutSessions,
|
||||
isIndexedDbAvailable,
|
||||
openAiSessionsIdb,
|
||||
publishAiSessionsIdbChange,
|
||||
resetAiSessionsIdbForTests,
|
||||
} from './aiSessionIdbStorage';
|
||||
|
||||
/** Typed accessor for the Electron IPC bridge exposed on `window.netcatty`. */
|
||||
export interface AIBridge {
|
||||
@@ -64,6 +75,13 @@ export async function cleanupSdkAgentSessions(sessionIds: string[]): Promise<voi
|
||||
await Promise.all(sessionIds.map((sessionId) => bridge?.aiSdkAgentCleanup?.(sessionId).catch(() => {})));
|
||||
}
|
||||
|
||||
/**
|
||||
* Session ids explicitly deleted by the user (or by an authoritative cloud
|
||||
* merge). Their disappearance from the persisted set must not trigger the
|
||||
* pre-eviction backup generation — only cap-driven drops should.
|
||||
*/
|
||||
const intentionallyDeletedSessionIds = new Set<string>();
|
||||
|
||||
export function cleanupDeletedAIChatSessions(sessionIds: string[]) {
|
||||
const bridge = getAIBridge();
|
||||
if (sessionIds.length === 0) return;
|
||||
@@ -73,6 +91,7 @@ export function cleanupDeletedAIChatSessions(sessionIds: string[]) {
|
||||
// main-process privacy mapping may still exist, so always clear those.
|
||||
const runtime = peekAgentRuntimeCleanup();
|
||||
for (const sessionId of sessionIds) {
|
||||
intentionallyDeletedSessionIds.add(sessionId);
|
||||
if (runtime) {
|
||||
runtime.clearChatSession(sessionId);
|
||||
} else {
|
||||
@@ -108,8 +127,11 @@ export let latestAIActiveTargetIdsSnapshot: Set<string> = new Set();
|
||||
|
||||
export function cleanupOrphanedAISessions(activeTargetIds: Set<string>) {
|
||||
latestAIActiveTargetIdsSnapshot = activeTargetIds;
|
||||
const currentSessions = latestAISessionsSnapshot
|
||||
?? readSessionsFromStorage();
|
||||
// Child maintenance effects run before the provider's boot effects. Keep
|
||||
// the live targets for post-hydration cleanup without adopting stale legacy
|
||||
// history as authoritative state while IndexedDB is still loading.
|
||||
if (!isAiSessionsHydrationComplete()) return;
|
||||
const currentSessions = latestAISessionsSnapshot ?? getPersistedAiSessionsSnapshot();
|
||||
|
||||
// Sessions shown by a still-live scope must be protected from cleanup
|
||||
// even when their own `scope.targetId` points at a closed terminal —
|
||||
@@ -160,14 +182,7 @@ export function cleanupOrphanedAISessions(activeTargetIds: Set<string>) {
|
||||
|
||||
if (sessionsMarked) {
|
||||
setLatestAISessionsSnapshot(markedSessions);
|
||||
// Startup cleanup can run before the disk backup read resolves. Defer
|
||||
// persistence until those chats are merged, preserving their recovery
|
||||
// marker and avoiding a partial backup of only the loaded sessions.
|
||||
const diskOnlyIds = localStorageAdapter.read<string[]>(STORAGE_KEY_AI_SESSIONS_DISK_OVERFLOW) ?? [];
|
||||
const loadedIds = new Set(markedSessions.map(session => session.id));
|
||||
if (!diskOnlyIds.some(id => !loadedIds.has(id))) {
|
||||
writeSessionsForStorage(markedSessions);
|
||||
}
|
||||
writeSessionsForStorage(markedSessions);
|
||||
emitAIStateChanged(STORAGE_KEY_AI_SESSIONS);
|
||||
}
|
||||
|
||||
@@ -203,159 +218,53 @@ export function cleanupOrphanedAISessions(activeTargetIds: Set<string>) {
|
||||
}
|
||||
|
||||
|
||||
/** Maximum number of sessions to keep in localStorage. */
|
||||
const MAX_STORED_SESSIONS = 50;
|
||||
/** Maximum number of messages per session when persisting to localStorage. */
|
||||
const MAX_SESSION_MESSAGES = 200;
|
||||
/**
|
||||
* String-length budget for the primary sessions JSON and each overflow chunk.
|
||||
* Replay bulk is shed from the primary blob first; whole sessions that still
|
||||
* do not fit are retained in overflow rather than silently evicted.
|
||||
* Maximum number of sessions kept in the persisted store. IndexedDB has no
|
||||
* shared byte quota, so retention is a plain count cap; the cap only exists
|
||||
* to keep history from growing without bound.
|
||||
*/
|
||||
const MAX_SESSIONS_JSON_BYTES = 2 * 1024 * 1024;
|
||||
/** Retry budgets used when the primary budget still fails to persist. */
|
||||
const RETRY_SESSIONS_JSON_BYTES = [
|
||||
1024 * 1024,
|
||||
512 * 1024,
|
||||
256 * 1024,
|
||||
128 * 1024,
|
||||
] as const;
|
||||
const MAX_STORED_SESSIONS = 1000;
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* Per-session message cap, read from user settings (clamped to 50–2000,
|
||||
* default 200). Read on every write so a lowered cap trims the oldest
|
||||
* messages on the next write; a raised cap stops further trimming.
|
||||
* Best-effort: an unavailable storage layer keeps the default cap.
|
||||
*/
|
||||
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`;
|
||||
export function readAiSessionMessageCap(): number {
|
||||
try {
|
||||
return normalizeAiSessionMessageCap(
|
||||
localStorageAdapter.readNumber(STORAGE_KEY_AI_SESSION_MESSAGE_CAP)
|
||||
?? DEFAULT_AI_SESSION_MESSAGE_CAP,
|
||||
);
|
||||
} catch {
|
||||
return DEFAULT_AI_SESSION_MESSAGE_CAP;
|
||||
}
|
||||
return `${content.slice(0, half)}${marker}${content.slice(content.length - half)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove `reasoningEncryptedContent` ciphertext from a message's persisted
|
||||
* 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,
|
||||
): ProviderContinuationOptions | undefined {
|
||||
const hasCiphertext = Object.values(options).some(
|
||||
providerOptions => typeof providerOptions?.reasoningEncryptedContent === 'string',
|
||||
);
|
||||
if (!hasCiphertext) return options;
|
||||
const stripped: ProviderContinuationOptions = {};
|
||||
for (const [provider, providerOptions] of Object.entries(options)) {
|
||||
const rest = { ...providerOptions };
|
||||
delete rest.reasoningEncryptedContent;
|
||||
if (Object.keys(rest).length) stripped[provider] = rest;
|
||||
}
|
||||
return Object.keys(stripped).length ? stripped : undefined;
|
||||
}
|
||||
|
||||
function stripReasoningCiphertextFromMessage(message: AISession['messages'][number]) {
|
||||
const continuation = message.providerContinuation;
|
||||
if (!continuation?.reasoningParts) return message;
|
||||
let changed = false;
|
||||
const parts = continuation.reasoningParts.map(part => {
|
||||
if (!part.providerOptions) return part;
|
||||
const providerOptions = stripReasoningEncryptedContent(part.providerOptions);
|
||||
if (providerOptions === part.providerOptions) return part;
|
||||
changed = true;
|
||||
return providerOptions ? { text: part.text, providerOptions } : { text: part.text };
|
||||
});
|
||||
if (!changed) return message;
|
||||
return {
|
||||
...message,
|
||||
providerContinuation: {
|
||||
...continuation,
|
||||
reasoningParts: parts,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
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 => {
|
||||
const stripped = stripEncryptedReasoningFromMessage(message);
|
||||
if (stripped !== message) changed = true;
|
||||
return stripped;
|
||||
});
|
||||
return changed ? { ...session, messages } : session;
|
||||
}
|
||||
|
||||
function stripCompactedEncryptedReasoningFromSession(session: AISession): AISession {
|
||||
const compactedMessageCount = Math.min(
|
||||
session.messages.length,
|
||||
Math.max(0, session.contextCompaction?.compactedMessageCount ?? 0),
|
||||
);
|
||||
if (compactedMessageCount === 0) return session;
|
||||
|
||||
let changed = false;
|
||||
const messages = session.messages.map((message, index) => {
|
||||
if (index >= compactedMessageCount) return message;
|
||||
const stripped = stripReasoningCiphertextFromMessage(message);
|
||||
if (stripped !== message) changed = true;
|
||||
return stripped;
|
||||
});
|
||||
return changed ? { ...session, messages } : session;
|
||||
}
|
||||
|
||||
/**
|
||||
* Prune sessions before writing to localStorage to prevent hitting the
|
||||
* ~5-10 MB storage quota. Only affects what is persisted — the in-memory
|
||||
* state retains all messages until the session is reloaded.
|
||||
* Prune sessions before writing to the persisted store to bound retention.
|
||||
* Only affects what is persisted — the in-memory state retains all messages
|
||||
* until the session is reloaded.
|
||||
*
|
||||
* - Keeps only the MAX_STORED_SESSIONS most-recently-updated sessions.
|
||||
* - Trims each session's messages to the last MAX_SESSION_MESSAGES.
|
||||
* - Trims each session's messages to the last `messageCap` messages.
|
||||
*/
|
||||
export function pruneSessionsForStorage(sessions: AISession[]): AISession[] {
|
||||
export function pruneSessionsForStorage(
|
||||
sessions: AISession[],
|
||||
messageCap: number = readAiSessionMessageCap(),
|
||||
): AISession[] {
|
||||
// Sort by updatedAt descending so we keep the newest
|
||||
const sorted = [...sessions].sort((a, b) => b.updatedAt - a.updatedAt);
|
||||
const limited = sorted.slice(0, MAX_STORED_SESSIONS);
|
||||
return limited.map(s => {
|
||||
if (s.messages.length > MAX_SESSION_MESSAGES) {
|
||||
if (s.messages.length > messageCap) {
|
||||
// Do not start the retained tail with a tool result whose assistant call
|
||||
// was just trimmed. Reuse the same tool-safe boundary logic as context
|
||||
// compaction, even if that retains a few more than the nominal cap.
|
||||
const removedMessageCount = findSafeChatMessageCompactionSplitIndex(
|
||||
s.messages,
|
||||
MAX_SESSION_MESSAGES,
|
||||
messageCap,
|
||||
);
|
||||
const contextCompaction = s.contextCompaction
|
||||
? {
|
||||
@@ -380,150 +289,119 @@ export function prepareRestoredSessions(sessions: AISession[], syncEnabled: bool
|
||||
return syncEnabled ? sessions : pruneSessionsForStorage(sessions);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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[],
|
||||
budgetBytes: number = MAX_SESSIONS_JSON_BYTES,
|
||||
): { json: string; sessions: AISession[] } {
|
||||
const prepared = pruneSessionsForStorage(sessions).map(rawSession => {
|
||||
// These messages are replaced by the durable summary before every later
|
||||
// request, so their replay ciphertext can never be used again. Remove it
|
||||
// before deciding whether the newest session deserves full protection;
|
||||
// otherwise dead metadata can evict other visible conversations.
|
||||
const session = stripCompactedEncryptedReasoningFromSession(rawSession);
|
||||
return { session, json: JSON.stringify(session) };
|
||||
});
|
||||
// ── IndexedDB persistence (issue #4123) ──
|
||||
//
|
||||
// Sessions are persisted one record per session. There is no global byte
|
||||
// budget and no re-serialization ladder: the 2 MB localStorage budget existed
|
||||
// to defend a shared quota against the cost of serializing the whole store on
|
||||
// every write, and both are gone with per-record writes. Only a count cap
|
||||
// (MAX_STORED_SESSIONS) and the per-session message cap remain.
|
||||
|
||||
// Check whether retained history fits before preparing degradation. Use
|
||||
// the existing JSON string-length budget including array delimiters, and
|
||||
// avoid preparing unused stripped messages and serializing payloads again.
|
||||
const fullLength = 2 + prepared.reduce((total, entry) => total + entry.json.length, 0)
|
||||
+ Math.max(0, prepared.length - 1);
|
||||
if (fullLength <= budgetBytes) {
|
||||
return {
|
||||
json: `[${prepared.map(entry => entry.json).join(',')}]`,
|
||||
sessions: prepared.map(entry => entry.session),
|
||||
};
|
||||
}
|
||||
/** Last set of sessions known to be persisted, for per-record diffing. */
|
||||
let lastPersistedSessionsById: Map<string, AISession> = new Map();
|
||||
/** Serialized write queue: IDB operations never interleave. */
|
||||
let idbWriteQueue: Promise<void> = Promise.resolve();
|
||||
|
||||
// Reuse the full representation when the unchanged degradation ladder is
|
||||
// needed, including on quota retries with a smaller budget.
|
||||
const serialized = prepared.map(({ session, json }) => {
|
||||
const degradableMessages = session.messages.flatMap((message, index) => {
|
||||
const degradedMessage = stripEncryptedReasoningFromMessage(message);
|
||||
if (degradedMessage === message) return [];
|
||||
return [{
|
||||
index,
|
||||
degradedMessage,
|
||||
jsonLengthDelta: JSON.stringify(degradedMessage).length - JSON.stringify(message).length,
|
||||
}];
|
||||
});
|
||||
const strippedSession = stripEncryptedReasoningFromSession(session);
|
||||
return {
|
||||
session,
|
||||
json,
|
||||
strippedSession,
|
||||
strippedJson: strippedSession === session
|
||||
? json
|
||||
: JSON.stringify(strippedSession),
|
||||
degradableMessages,
|
||||
};
|
||||
});
|
||||
/** Test hook: reset diff state between tests. */
|
||||
export function __resetIdbPersistenceForTests() {
|
||||
lastPersistedSessionsById = new Map();
|
||||
idbWriteQueue = Promise.resolve();
|
||||
// Tests swap the fake indexedDB factory between cases; the cached open
|
||||
// handle must not survive the swap.
|
||||
resetAiSessionsIdbForTests();
|
||||
// Tests simulate the post-boot state by default; the boot-gating cases
|
||||
// call __resetAiSessionsHydrationForTests() to go back to pending.
|
||||
markAiSessionsHydrationComplete();
|
||||
}
|
||||
|
||||
// Preserve the newest session's full continuation whenever it can fit by
|
||||
// itself. That session is the one the user is most likely continuing now;
|
||||
// older visible history must not make its next tool turn unreplayable.
|
||||
const protectNewestContinuation = serialized.length > 0
|
||||
&& serialized[0].json.length + 2 <= budgetBytes;
|
||||
/** Test hook: resolve once every queued IndexedDB sync has settled. */
|
||||
export function __flushIdbWritesForTests(): Promise<void> {
|
||||
return idbWriteQueue.then(() => undefined, () => undefined);
|
||||
}
|
||||
|
||||
// Determine how many sessions can fit using the smallest representation for
|
||||
// older sessions while reserving the newest session's full representation
|
||||
// when possible. This also prevents an old, oversized visible chat that must
|
||||
// be dropped anyway from causing newer replay ciphertext to be stripped.
|
||||
let minimalLength = 2
|
||||
+ serialized.reduce((total, entry, index) => (
|
||||
total + (protectNewestContinuation && index === 0 ? entry.json.length : entry.strippedJson.length)
|
||||
), 0)
|
||||
+ Math.max(0, serialized.length - 1);
|
||||
while (minimalLength > budgetBytes && serialized.length > 1) {
|
||||
const removed = serialized.pop();
|
||||
if (!removed) break;
|
||||
minimalLength -= removed.strippedJson.length + 1;
|
||||
}
|
||||
// ── Boot hydration signal (issue #4125 review) ──
|
||||
//
|
||||
// Settles once useAIState's boot load has finished (IndexedDB read, legacy
|
||||
// migration, disk-backup restore). Sync collection and the active-pointer
|
||||
// validation must not run before it: a pre-hydration collect reads the
|
||||
// stale legacy localStorage store and would overwrite newer IndexedDB
|
||||
// records, and pointer validation against the initially-empty in-memory
|
||||
// array would null every pointer before the real sessions arrive.
|
||||
|
||||
// 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;
|
||||
for (
|
||||
let index = serialized.length - 1;
|
||||
index >= firstStrippableIndex && jsonLength > budgetBytes;
|
||||
index -= 1
|
||||
) {
|
||||
const current = serialized[index];
|
||||
let degradedMessageCount = 0;
|
||||
while (jsonLength > budgetBytes && degradedMessageCount < current.degradableMessages.length) {
|
||||
jsonLength += current.degradableMessages[degradedMessageCount].jsonLengthDelta;
|
||||
degradedMessageCount += 1;
|
||||
}
|
||||
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) => (
|
||||
degradedMessagesByIndex.get(messageIndex) ?? message
|
||||
)),
|
||||
};
|
||||
const nextJson = JSON.stringify(nextSession);
|
||||
const projectedJsonLength = current.json.length
|
||||
+ current.degradableMessages
|
||||
.slice(0, degradedMessageCount)
|
||||
.reduce((total, entry) => total + entry.jsonLengthDelta, 0);
|
||||
jsonLength += nextJson.length - projectedJsonLength;
|
||||
serialized[index] = {
|
||||
...current,
|
||||
session: nextSession,
|
||||
json: nextJson,
|
||||
};
|
||||
}
|
||||
}
|
||||
let aiSessionsHydrationComplete = false;
|
||||
|
||||
return {
|
||||
json: `[${serialized.map(entry => entry.json).join(',')}]`,
|
||||
sessions: serialized.map(entry => entry.session),
|
||||
};
|
||||
export function isAiSessionsHydrationComplete(): boolean {
|
||||
return aiSessionsHydrationComplete;
|
||||
}
|
||||
|
||||
/** Called by useAIState's boot effect when the boot load settles. */
|
||||
export function markAiSessionsHydrationComplete(): void {
|
||||
aiSessionsHydrationComplete = true;
|
||||
}
|
||||
|
||||
/** Test hook: return the hydration signal to the pre-boot (pending) state. */
|
||||
export function __resetAiSessionsHydrationForTests(): void {
|
||||
aiSessionsHydrationComplete = false;
|
||||
}
|
||||
|
||||
/** Seed the diff baseline after a boot load or migration. */
|
||||
export function seedLastPersistedSessions(sessions: AISession[]) {
|
||||
lastPersistedSessionsById = new Map(sessions.map(session => [session.id, session]));
|
||||
}
|
||||
|
||||
/**
|
||||
* Persist sessions to localStorage with byte-budgeted primary/overflow slices.
|
||||
* Returns true when local history was saved, possibly as a newest-primary
|
||||
* fallback with the full history scheduled for disk backup under quota pressure.
|
||||
* Read the persisted session set synchronously. Source of truth is the
|
||||
* in-memory snapshot held by useAIState; this accessor returns the last set
|
||||
* handed to `writeSessionsForStorage`, falling back to the read-only legacy
|
||||
* localStorage store (kept untouched for one release).
|
||||
*/
|
||||
export function getPersistedAiSessionsSnapshot(): AISession[] {
|
||||
if (lastPersistedSessionsById.size > 0) {
|
||||
return Array.from(lastPersistedSessionsById.values())
|
||||
.sort((a, b) => (b.updatedAt ?? 0) - (a.updatedAt ?? 0));
|
||||
}
|
||||
return readLiveSessionsArray(localStorageAdapter.readString(STORAGE_KEY_AI_SESSIONS));
|
||||
}
|
||||
|
||||
// Durability mirror (issue #4015 follow-up): every successful store write
|
||||
// also schedules a debounced disk backup so a wiped/recreated profile does
|
||||
// not take the chat history with it. Persisted history is mirrored, including
|
||||
// overflow sessions, throttled to at most one write per mutation batch.
|
||||
// Before a byte-budget drop, mirror the full
|
||||
// count-capped set even if the subsequent localStorage writes fail.
|
||||
function queueIdbSessionSync(
|
||||
changed: AISession[],
|
||||
removedIds: string[],
|
||||
prevById: Map<string, AISession>,
|
||||
persisted: AISession[],
|
||||
) {
|
||||
if (changed.length === 0 && removedIds.length === 0) return;
|
||||
idbWriteQueue = idbWriteQueue
|
||||
.then(async () => {
|
||||
if (!isIndexedDbAvailable()) return;
|
||||
const db = await openAiSessionsIdb();
|
||||
// Per-record write: only the changed sessions are put, only removed
|
||||
// ids are deleted.
|
||||
if (changed.length > 0) await idbPutSessions(db, changed);
|
||||
if (removedIds.length > 0) await idbDeleteSessionIds(db, removedIds);
|
||||
await idbPutMeta(db, 'schemaVersion', 1);
|
||||
await idbPutMeta(db, 'lastWriteAt', Date.now());
|
||||
publishAiSessionsIdbChange();
|
||||
})
|
||||
.catch((error) => {
|
||||
// IndexedDB failures must never break the in-memory session state.
|
||||
// Reset the diff baseline so the next write retries every record, and
|
||||
// fall back to the legacy localStorage key so a corrupt/unavailable
|
||||
// IndexedDB still leaves a restorable trace (via the disk mirror).
|
||||
console.warn('[AIState] IndexedDB session sync failed:', error);
|
||||
lastPersistedSessionsById = new Map(prevById);
|
||||
try {
|
||||
localStorageAdapter.writeString(STORAGE_KEY_AI_SESSIONS, JSON.stringify(persisted));
|
||||
} catch {
|
||||
// Legacy fallback is best-effort too.
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Durability mirror (issue #4015 follow-up): every write also schedules a
|
||||
// debounced disk backup so a wiped/recreated profile does not take the chat
|
||||
// history with it. The persisted JSON is mirrored; when the count cap evicts
|
||||
// sessions, a PRE-eviction generation is written first so evicted chats stay
|
||||
// recoverable from disk (the blind spot found in issue #4123).
|
||||
const backupMirror = new DebouncedBackupMirror((json) => {
|
||||
try {
|
||||
void getAIBridge()?.aiSessionsBackupWrite?.({ json })?.catch(() => {});
|
||||
@@ -532,6 +410,14 @@ const backupMirror = new DebouncedBackupMirror((json) => {
|
||||
}
|
||||
});
|
||||
|
||||
let aiSessionsBackupRecoveryPending = false;
|
||||
|
||||
/** Partial migration must not rotate away generations holding disk-only chats. */
|
||||
export function setAiSessionsBackupRecoveryPending(pending: boolean) {
|
||||
aiSessionsBackupRecoveryPending = pending;
|
||||
if (pending) backupMirror.reset();
|
||||
}
|
||||
|
||||
export function flushBackupMirror() {
|
||||
try {
|
||||
backupMirror.flushNow();
|
||||
@@ -542,9 +428,11 @@ export function flushBackupMirror() {
|
||||
|
||||
export function __resetBackupMirrorForTests() {
|
||||
backupMirror.reset();
|
||||
aiSessionsBackupRecoveryPending = false;
|
||||
}
|
||||
|
||||
export function scheduleBackupMirror(json: string) {
|
||||
if (aiSessionsBackupRecoveryPending) return;
|
||||
try {
|
||||
backupMirror.schedule(json);
|
||||
} catch {
|
||||
@@ -552,257 +440,79 @@ export function scheduleBackupMirror(json: string) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Last successful write: the same immutable sessions array is written again by
|
||||
* the debounced save path and by cloud-sync collection, and each write used to
|
||||
* re-serialize the whole (often multi-MB) history.
|
||||
*/
|
||||
let lastSessionsWrite: { sessions: AISession[]; synced: boolean; json: string } | null = null;
|
||||
|
||||
function rememberSessionsWrite(sessions: AISession[], synced: boolean, json: string): void {
|
||||
lastSessionsWrite = { sessions, synced, json };
|
||||
}
|
||||
|
||||
function storedSessionsJsonEquals(json: string): boolean {
|
||||
/** Write one immediate backup generation (bypasses the debounce window). */
|
||||
function writeImmediateBackupGeneration(json: string) {
|
||||
if (aiSessionsBackupRecoveryPending) return;
|
||||
try {
|
||||
return localStorageAdapter.readString(STORAGE_KEY_AI_SESSIONS) === json;
|
||||
backupMirror.schedule(json);
|
||||
backupMirror.flushNow();
|
||||
} catch {
|
||||
return false;
|
||||
// Backup failures must never break chat persistence.
|
||||
}
|
||||
}
|
||||
|
||||
function overflowChunkKey(index: number): string {
|
||||
return index === 0 ? STORAGE_KEY_AI_SESSIONS_OVERFLOW : `${STORAGE_KEY_AI_SESSIONS_OVERFLOW}_${index + 1}`;
|
||||
}
|
||||
|
||||
function readOverflowChunks(): string[] {
|
||||
const chunks: string[] = [];
|
||||
for (let index = 0; ; index += 1) {
|
||||
const chunk = localStorageAdapter.readString(overflowChunkKey(index));
|
||||
if (chunk == null) return chunks;
|
||||
chunks.push(chunk);
|
||||
}
|
||||
}
|
||||
|
||||
function clearOverflowChunks(startIndex = 0) {
|
||||
for (let index = startIndex; localStorageAdapter.readString(overflowChunkKey(index)) != null; index += 1) {
|
||||
localStorageAdapter.remove(overflowChunkKey(index));
|
||||
}
|
||||
}
|
||||
|
||||
function writeDiskOverflowIds(ids: string[]): boolean {
|
||||
if (ids.length > 0) {
|
||||
return localStorageAdapter.write(STORAGE_KEY_AI_SESSIONS_DISK_OVERFLOW, ids);
|
||||
}
|
||||
if (localStorageAdapter.readString(STORAGE_KEY_AI_SESSIONS_DISK_OVERFLOW) != null) {
|
||||
localStorageAdapter.remove(STORAGE_KEY_AI_SESSIONS_DISK_OVERFLOW);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Replace the entire overflow blob; never leave a partially written JSON array. */
|
||||
function replaceOverflowChunks(chunks: string[]): boolean {
|
||||
clearOverflowChunks();
|
||||
let complete = false;
|
||||
try {
|
||||
complete = chunks.every((chunk, index) => (
|
||||
localStorageAdapter.writeString(overflowChunkKey(index), chunk)
|
||||
));
|
||||
return complete;
|
||||
} finally {
|
||||
if (!complete) clearOverflowChunks();
|
||||
}
|
||||
}
|
||||
|
||||
/** Read both local retention slices; newer copies win if a session is duplicated. */
|
||||
export function readSessionsFromStorage(): AISession[] {
|
||||
return mergeImportedSessions(
|
||||
readLiveSessionsArray(readOverflowChunks().join('')),
|
||||
readLiveSessionsArray(localStorageAdapter.readString(STORAGE_KEY_AI_SESSIONS)),
|
||||
);
|
||||
}
|
||||
/**
|
||||
* Perf (#4121): the exact same immutable sessions array is often written
|
||||
* again by the debounced save path and by cloud-sync collection; skipping
|
||||
* the re-stringify for a reference-identical set keeps that cheap. The
|
||||
* per-record IDB diff below already makes the store write itself cheap.
|
||||
*/
|
||||
let lastMirroredSessions: AISession[] | null = null;
|
||||
|
||||
/**
|
||||
* Persist sessions. Writes go to IndexedDB, one record per session, through a
|
||||
* serialized queue; errors are logged, never thrown. Returns true when the
|
||||
* persisted set was computed (in-memory state remains authoritative either
|
||||
* way).
|
||||
*/
|
||||
export function writeSessionsForStorage(
|
||||
sessions: AISession[],
|
||||
options: { deletedSessionIds?: readonly string[] } = {},
|
||||
options?: { deletedSessionIds?: readonly string[] },
|
||||
): boolean {
|
||||
const synced = Boolean(
|
||||
localStorageAdapter.read<{ enabled?: boolean }>(STORAGE_KEY_CLOUD_SYNC_AI_SESSIONS)?.enabled,
|
||||
);
|
||||
// Nothing to do when this exact array was already written in this mode and
|
||||
// storage still holds that write (another window may have replaced it).
|
||||
if (
|
||||
lastSessionsWrite?.sessions === sessions
|
||||
&& lastSessionsWrite.synced === synced
|
||||
&& storedSessionsJsonEquals(lastSessionsWrite.json)
|
||||
// A matching primary alone cannot establish that overflow is intact.
|
||||
&& localStorageAdapter.readString(STORAGE_KEY_AI_SESSIONS_OVERFLOW) == null
|
||||
&& localStorageAdapter.readString(STORAGE_KEY_AI_SESSIONS_DISK_OVERFLOW) == null
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
// Synced records are authoritative after restart: silently evicting a
|
||||
// session or turn here would turn local retention into cloud deletion.
|
||||
if (synced) {
|
||||
const json = JSON.stringify(sessions);
|
||||
const previousChunks = readOverflowChunks();
|
||||
// The authoritative primary replaces overflow, so free its quota first.
|
||||
clearOverflowChunks();
|
||||
let syncedOk = false;
|
||||
try {
|
||||
syncedOk = localStorageAdapter.writeString(STORAGE_KEY_AI_SESSIONS, json);
|
||||
if (syncedOk) {
|
||||
clearOverflowChunks();
|
||||
if (localStorageAdapter.readString(STORAGE_KEY_AI_SESSIONS_DISK_OVERFLOW) != null) {
|
||||
localStorageAdapter.remove(STORAGE_KEY_AI_SESSIONS_DISK_OVERFLOW);
|
||||
}
|
||||
scheduleBackupMirror(json);
|
||||
rememberSessionsWrite(sessions, true, json);
|
||||
}
|
||||
return syncedOk;
|
||||
} finally {
|
||||
if (!syncedOk) replaceOverflowChunks(previousChunks);
|
||||
}
|
||||
}
|
||||
const inputIds = new Set(sessions.map(session => session.id));
|
||||
const pruned = pruneSessionsForStorage(sessions);
|
||||
const retainedIds = new Set(pruned.map(session => session.id));
|
||||
const deletedIds = new Set(options.deletedSessionIds);
|
||||
// A live write can race startup's backup read. Preserve marker IDs absent
|
||||
// from the live input unless explicitly deleted. IDs present in the input
|
||||
// are resolved even if the count cap evicts them; pending IDs block mirroring.
|
||||
const diskOverflowIds = localStorageAdapter.read<unknown>(STORAGE_KEY_AI_SESSIONS_DISK_OVERFLOW);
|
||||
const unresolvedDiskIds: string[] = Array.isArray(diskOverflowIds)
|
||||
? diskOverflowIds.filter((id): id is string => (
|
||||
typeof id === 'string' && !retainedIds.has(id) && !deletedIds.has(id) && !inputIds.has(id)
|
||||
))
|
||||
: [];
|
||||
let fullBackupScheduled = false;
|
||||
const ensureFullBackup = () => {
|
||||
if (!fullBackupScheduled && unresolvedDiskIds.length === 0) {
|
||||
scheduleBackupMirror(JSON.stringify(pruned));
|
||||
fullBackupScheduled = true;
|
||||
}
|
||||
};
|
||||
const persistCandidate = (
|
||||
candidate: { json: string; sessions: AISession[] },
|
||||
budget: number,
|
||||
chunkBudget = budget,
|
||||
): boolean => {
|
||||
const primaryIds = new Set(candidate.sessions.map(session => session.id));
|
||||
const omitted = pruned.filter(session => !primaryIds.has(session.id));
|
||||
if (omitted.length > 0) ensureFullBackup();
|
||||
// Apply the same replay-bulk ladder to every overflow chat as budgets
|
||||
// tighten. Chunking alone cannot reduce an origin's aggregate quota use.
|
||||
const overflow = omitted.map(session => serializeSessionsForStorage([session], budget).sessions[0]);
|
||||
const overflowJson = overflow.length > 0 ? JSON.stringify(overflow) : '';
|
||||
// Split the array's JSON string, so even one oversized session can span
|
||||
// chunks without losing messages. Concatenate all chunks before parsing.
|
||||
const chunks: string[] = [];
|
||||
// Primary budgets can shrink to a tiny empty chat. Keep overflow writes
|
||||
// bounded independently while still supporting small per-key quotas.
|
||||
const chunkSize = Math.max(32 * 1024, chunkBudget);
|
||||
for (let offset = 0; offset < overflowJson.length; offset += chunkSize) {
|
||||
chunks.push(overflowJson.slice(offset, offset + chunkSize));
|
||||
}
|
||||
const previousChunks = readOverflowChunks();
|
||||
const previousPrimary = localStorageAdapter.readString(STORAGE_KEY_AI_SESSIONS);
|
||||
let primaryWritten = false;
|
||||
let persisted = false;
|
||||
try {
|
||||
// Reclaim both old slices before writing overflow: the final history may
|
||||
// fit even when overflow plus the old, larger primary would exceed quota.
|
||||
clearOverflowChunks();
|
||||
if (!localStorageAdapter.writeString(STORAGE_KEY_AI_SESSIONS, candidate.json)) return false;
|
||||
primaryWritten = true;
|
||||
if (!replaceOverflowChunks(chunks)) return false;
|
||||
if (!writeDiskOverflowIds(unresolvedDiskIds)) return false;
|
||||
persisted = true;
|
||||
if (!fullBackupScheduled && unresolvedDiskIds.length === 0) scheduleBackupMirror(candidate.json);
|
||||
rememberSessionsWrite(sessions, false, candidate.json);
|
||||
return true;
|
||||
} finally {
|
||||
if (!persisted) {
|
||||
clearOverflowChunks();
|
||||
if (primaryWritten) {
|
||||
if (previousPrimary == null) localStorageAdapter.remove(STORAGE_KEY_AI_SESSIONS);
|
||||
else localStorageAdapter.writeString(STORAGE_KEY_AI_SESSIONS, previousPrimary);
|
||||
}
|
||||
replaceOverflowChunks(previousChunks);
|
||||
}
|
||||
}
|
||||
};
|
||||
const primaryCandidates: { json: string; sessions: AISession[] }[] = [];
|
||||
let previousLength = Number.POSITIVE_INFINITY;
|
||||
for (const configuredBudget of [MAX_SESSIONS_JSON_BYTES, ...RETRY_SESSIONS_JSON_BYTES]) {
|
||||
// A real quota failure means the next attempt must be smaller even when
|
||||
// the failed payload was already below the nominal retry budget. Reduce
|
||||
// materially on each bounded retry so several small sessions can be
|
||||
// removed before the retry sequence is exhausted.
|
||||
const reducedBudget = Number.isFinite(previousLength)
|
||||
? Math.floor(previousLength * 0.75)
|
||||
: configuredBudget;
|
||||
const budget = Math.min(configuredBudget, reducedBudget);
|
||||
if (budget < 2) break;
|
||||
const candidate = serializeSessionsForStorage(pruned, budget);
|
||||
primaryCandidates.push(candidate);
|
||||
// Even an unchanged primary can succeed with smaller overflow chunks or
|
||||
// more degraded overflow replay data at this retry budget.
|
||||
if (persistCandidate(candidate, budget)) return true;
|
||||
previousLength = candidate.json.length;
|
||||
}
|
||||
// Last resort: use the smallest primary representation (the newest chat
|
||||
// with replay-only bulk removed), retaining other chats in overflow.
|
||||
const minimalCandidate = serializeSessionsForStorage(pruned, 0);
|
||||
const minimalChunkBudget = Math.min(minimalCandidate.json.length, RETRY_SESSIONS_JSON_BYTES[RETRY_SESSIONS_JSON_BYTES.length - 1]);
|
||||
primaryCandidates.push(minimalCandidate);
|
||||
if (persistCandidate(minimalCandidate, 0, minimalChunkBudget)) return true;
|
||||
|
||||
// If the complete retained set cannot fit, save the newest primary anyway.
|
||||
// Schedule the full disk mirror before discarding overflow; do not let an
|
||||
// old valid primary block persistence of every subsequent conversation turn.
|
||||
ensureFullBackup();
|
||||
const previousChunks = readOverflowChunks();
|
||||
const previousPrimary = localStorageAdapter.readString(STORAGE_KEY_AI_SESSIONS);
|
||||
const previousDiskOverflow = localStorageAdapter.readString(STORAGE_KEY_AI_SESSIONS_DISK_OVERFLOW);
|
||||
clearOverflowChunks();
|
||||
let primaryWritten = false;
|
||||
let primaryPersisted = false;
|
||||
try {
|
||||
for (const candidate of primaryCandidates) {
|
||||
const primaryIds = new Set(candidate.sessions.map(session => session.id));
|
||||
const diskOnlyIds = [...new Set([
|
||||
...unresolvedDiskIds,
|
||||
...pruned.filter(session => !primaryIds.has(session.id)).map(session => session.id),
|
||||
])];
|
||||
// Replacing a large old primary can free the space needed by metadata.
|
||||
// Roll it back below if the recovery marker still cannot be persisted.
|
||||
if (!localStorageAdapter.writeString(STORAGE_KEY_AI_SESSIONS, candidate.json)) continue;
|
||||
primaryWritten = true;
|
||||
if (writeDiskOverflowIds(diskOnlyIds)) {
|
||||
primaryPersisted = true;
|
||||
rememberSessionsWrite(sessions, false, candidate.json);
|
||||
return true;
|
||||
}
|
||||
// Explicitly deleted ids never count as cap eviction (they also must not
|
||||
// trigger a pre-eviction backup generation).
|
||||
for (const id of options?.deletedSessionIds ?? []) intentionallyDeletedSessionIds.add(id);
|
||||
// Synced records are authoritative after restart: never prune them, or
|
||||
// local retention would turn into cloud deletion on the next sync.
|
||||
const syncEnabled = !!localStorageAdapter.read<{ enabled?: boolean }>(
|
||||
STORAGE_KEY_CLOUD_SYNC_AI_SESSIONS,
|
||||
)?.enabled;
|
||||
const persisted = syncEnabled ? sessions : pruneSessionsForStorage(sessions);
|
||||
|
||||
const prevById = lastPersistedSessionsById;
|
||||
const persistedIds = new Set(persisted.map(session => session.id));
|
||||
|
||||
// Count-cap eviction: sessions that drop out of the persisted set without
|
||||
// being explicitly deleted get a pre-eviction disk generation containing
|
||||
// the full pre-eviction store, so they remain recoverable.
|
||||
const evicted = Array.from(prevById.entries())
|
||||
.filter(([id]) => !persistedIds.has(id) && !intentionallyDeletedSessionIds.has(id))
|
||||
.map(([, session]) => session);
|
||||
intentionallyDeletedSessionIds.clear();
|
||||
if (evicted.length > 0) {
|
||||
writeImmediateBackupGeneration(JSON.stringify(sessions));
|
||||
console.warn(
|
||||
`[AIState] Retention cap evicted ${evicted.length} session(s); a pre-eviction backup generation was written to disk.`,
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
if (!primaryPersisted) {
|
||||
if (primaryWritten) {
|
||||
if (previousPrimary == null) localStorageAdapter.remove(STORAGE_KEY_AI_SESSIONS);
|
||||
else localStorageAdapter.writeString(STORAGE_KEY_AI_SESSIONS, previousPrimary);
|
||||
}
|
||||
if (previousDiskOverflow != null) {
|
||||
localStorageAdapter.writeString(STORAGE_KEY_AI_SESSIONS_DISK_OVERFLOW, previousDiskOverflow);
|
||||
} else if (localStorageAdapter.readString(STORAGE_KEY_AI_SESSIONS_DISK_OVERFLOW) != null) {
|
||||
localStorageAdapter.remove(STORAGE_KEY_AI_SESSIONS_DISK_OVERFLOW);
|
||||
}
|
||||
replaceOverflowChunks(previousChunks);
|
||||
|
||||
// Per-record diff (synchronous: the baseline must advance even when
|
||||
// IndexedDB is unavailable, and callers read the snapshot right after).
|
||||
const changed = persisted.filter(session => prevById.get(session.id) !== session);
|
||||
const removedIds = Array.from(prevById.keys()).filter(id => !persistedIds.has(id));
|
||||
lastPersistedSessionsById = new Map(persisted.map(session => [session.id, session]));
|
||||
|
||||
if (changed.length > 0 || removedIds.length > 0 || lastMirroredSessions !== persisted) {
|
||||
scheduleBackupMirror(JSON.stringify(persisted));
|
||||
lastMirroredSessions = persisted;
|
||||
}
|
||||
queueIdbSessionSync(changed, removedIds, prevById, persisted);
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.warn('[AIState] Failed to persist AI sessions:', error);
|
||||
return false;
|
||||
}
|
||||
console.warn(
|
||||
'[AIState] Failed to persist AI sessions within the storage quota; recent chat history may not survive a restart.',
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
export let latestAISessionsSnapshot: AISession[] | null = null;
|
||||
@@ -844,10 +554,10 @@ export function setLatestAIActiveSessionMapSnapshot(activeSessionIdMap: Record<s
|
||||
|
||||
export function prewarmAIStateStorageSnapshots() {
|
||||
try {
|
||||
if (latestAISessionsSnapshot === null) {
|
||||
latestAISessionsSnapshot =
|
||||
readSessionsFromStorage();
|
||||
}
|
||||
// Sessions are no longer prewarmed from localStorage: the primary store is
|
||||
// IndexedDB and loads asynchronously in useAIState. Prewarming the stale
|
||||
// read-only legacy key would make the boot loader skip the IndexedDB
|
||||
// contents ("never restore over non-empty live state").
|
||||
if (latestAIActiveSessionMapSnapshot === null) {
|
||||
latestAIActiveSessionMapSnapshot =
|
||||
localStorageAdapter.read<Record<string, string | null>>(STORAGE_KEY_AI_ACTIVE_SESSION_MAP) ?? {};
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import type { HistoryEntry } from "./commandHistoryStore";
|
||||
|
||||
import {
|
||||
STORAGE_KEY_COMMAND_HISTORY,
|
||||
@@ -73,7 +74,9 @@ const {
|
||||
getCommandHistoryClearGeneration,
|
||||
hasCommandHistoryBeenCleared,
|
||||
markCommandHistoryCleared,
|
||||
fuzzyQueryHistory,
|
||||
queryHistory,
|
||||
queryRecentHistoryByCommand,
|
||||
recordCommand,
|
||||
subscribeCommandHistoryCleared,
|
||||
} = await import("./commandHistoryStore.ts");
|
||||
@@ -343,3 +346,155 @@ test("saving after a command and peer writes read the clear generation without p
|
||||
parse.mock.restore();
|
||||
assert.ok(queryHistory("echo fast", { hostId: "host-fast" }).some((entry) => entry.command === "echo fast-path-2"));
|
||||
});
|
||||
|
||||
const QUERY_NOW = 1_700_000_000_000;
|
||||
const DAY_MS = 24 * 60 * 60 * 1000;
|
||||
|
||||
function historyEntry(command: string, overrides: Partial<HistoryEntry> = {}): HistoryEntry {
|
||||
return {
|
||||
command, hostId: "host-1", os: "linux", frequency: 1,
|
||||
createdAt: QUERY_NOW, lastUsedAt: QUERY_NOW, ...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function installHistoryEntries(entries: HistoryEntry[]): void {
|
||||
clearHistory();
|
||||
const raw = JSON.stringify({
|
||||
clearGeneration: getCommandHistoryClearGeneration(), version: 1, entries,
|
||||
});
|
||||
storage.set(COMMAND_HISTORY_STORAGE_KEY, raw);
|
||||
dispatchStorageEvent(COMMAND_HISTORY_STORAGE_KEY, raw);
|
||||
}
|
||||
|
||||
test("prefix history preserves relevance, stable ties, host isolation and best duplicate", (t) => {
|
||||
t.mock.method(Date, "now", () => QUERY_NOW);
|
||||
const entries = [
|
||||
historyEntry("vi", { frequency: 100 }),
|
||||
historyEntry("vim duplicate"),
|
||||
historyEntry("vim frequent", { frequency: 8, lastUsedAt: QUERY_NOW - DAY_MS }),
|
||||
historyEntry("Vim case"),
|
||||
historyEntry("vim tied"),
|
||||
historyEntry("vim duplicate", { hostId: "host-2", frequency: 5 }),
|
||||
historyEntry("unrelated", { frequency: 100 }),
|
||||
];
|
||||
installHistoryEntries(entries);
|
||||
|
||||
assert.deepEqual(queryHistory("vi", { limit: 4 }), [entries[5], entries[2], entries[3], entries[4]]);
|
||||
assert.deepEqual(queryHistory("vi", { hostId: "host-1", limit: 3 }), [entries[2], entries[1], entries[3]]);
|
||||
assert.deepEqual(queryHistory("VI", { limit: 1 }), [entries[0]]);
|
||||
assert.deepEqual(queryHistory("missing"), []);
|
||||
assert.deepEqual(queryHistory("vi", { limit: 0 }), []);
|
||||
});
|
||||
|
||||
test("top-k history replaces and evicts duplicates without losing distinct results", (t) => {
|
||||
t.mock.method(Date, "now", () => QUERY_NOW);
|
||||
const entries = [
|
||||
historyEntry("vim a", { frequency: 2 }),
|
||||
historyEntry("vim b", { frequency: 3 }),
|
||||
historyEntry("vim c", { frequency: 4 }),
|
||||
historyEntry("vim b", { hostId: "host-2", frequency: 5 }),
|
||||
historyEntry("vim a", { hostId: "host-2", frequency: 6 }),
|
||||
historyEntry("vim a", { hostId: "host-3", frequency: 1 }),
|
||||
];
|
||||
installHistoryEntries(entries);
|
||||
assert.deepEqual(queryHistory("vi", { limit: 2 }), [entries[4], entries[3]]);
|
||||
assert.deepEqual(fuzzyQueryHistory("vi", { limit: 2 }), [entries[4], entries[3]]);
|
||||
});
|
||||
|
||||
test("fuzzy history preserves subsequence quality, recency, exclusions and stable ties", (t) => {
|
||||
t.mock.method(Date, "now", () => QUERY_NOW);
|
||||
const entries = [
|
||||
historyEntry("vi", { frequency: 100 }),
|
||||
historyEntry("vi alpha"),
|
||||
historyEntry("vi beta"),
|
||||
historyEntry("very indirect", { frequency: 2 }),
|
||||
historyEntry("vi stale", { lastUsedAt: QUERY_NOW - DAY_MS }),
|
||||
historyEntry("vi alpha", { hostId: "host-2", frequency: 3 }),
|
||||
historyEntry("iv does not match"),
|
||||
];
|
||||
installHistoryEntries(entries);
|
||||
assert.deepEqual(fuzzyQueryHistory("vi", { limit: 4 }), [entries[5], entries[3], entries[2], entries[4]]);
|
||||
assert.deepEqual(fuzzyQueryHistory("vi", { hostId: "host-1", limit: 4 }), [entries[3], entries[1], entries[2], entries[4]]);
|
||||
assert.deepEqual(fuzzyQueryHistory(""), []);
|
||||
assert.deepEqual(fuzzyQueryHistory("vi", { limit: -1 }), []);
|
||||
});
|
||||
|
||||
test("recent history preserves timestamps, command boundaries and quoted argument prefixes", () => {
|
||||
const entries = [
|
||||
historyEntry("cd /work/older", { lastUsedAt: QUERY_NOW - DAY_MS }),
|
||||
historyEntry("cd '/work/new folder'"),
|
||||
historyEntry("CD /work/tied"),
|
||||
historyEntry("cd /work/older", { hostId: "host-2", lastUsedAt: QUERY_NOW + 1 }),
|
||||
historyEntry("cd /other", { lastUsedAt: QUERY_NOW + 2 }),
|
||||
historyEntry("cdb /work/wrong", { lastUsedAt: QUERY_NOW + 3 }),
|
||||
historyEntry("cd /work/excluded", { lastUsedAt: QUERY_NOW + 4 }),
|
||||
];
|
||||
installHistoryEntries(entries);
|
||||
const options = { commandName: " CD ", argumentPrefix: "'/work/", excludeCommand: "cd /work/excluded", limit: 3 };
|
||||
assert.deepEqual(queryRecentHistoryByCommand(options), [entries[3], entries[1], entries[2]]);
|
||||
assert.deepEqual(queryRecentHistoryByCommand({ ...options, hostId: "host-1" }), [entries[1], entries[2], entries[0]]);
|
||||
assert.deepEqual(queryRecentHistoryByCommand({ commandName: "" }), []);
|
||||
assert.deepEqual(queryRecentHistoryByCommand({ commandName: "cd", limit: 0 }), []);
|
||||
});
|
||||
|
||||
test("fuzzy scoring preserves consecutive and word-boundary bonuses across long gaps", (t) => {
|
||||
t.mock.method(Date, "now", () => QUERY_NOW);
|
||||
const entries = [
|
||||
historyEntry("av-x"),
|
||||
historyEntry("avx"),
|
||||
historyEntry(`v${"a".repeat(1900)}x`),
|
||||
historyEntry("v-x"),
|
||||
historyEntry("v/x"),
|
||||
historyEntry("v_x"),
|
||||
historyEntry("v x"),
|
||||
historyEntry("vx adjacent"),
|
||||
historyEntry("v missing second character"),
|
||||
historyEntry("xv wrong order"),
|
||||
];
|
||||
installHistoryEntries(entries);
|
||||
assert.deepEqual(fuzzyQueryHistory("vx"), [
|
||||
entries[7], entries[3], entries[4], entries[5], entries[6], entries[2], entries[1], entries[0],
|
||||
]);
|
||||
assert.deepEqual(fuzzyQueryHistory("vv"), []);
|
||||
});
|
||||
|
||||
for (const size of [4_000, 40_000]) {
|
||||
test(`history queries keep five best results with linear scoring for ${size} entries`, (t) => {
|
||||
t.mock.method(Date, "now", () => QUERY_NOW);
|
||||
const entries = Array.from({ length: size }, (_, i) => historyEntry(`vi file-${i}`, {
|
||||
// Unsorted scores/timestamps, with the best match at the end of the store.
|
||||
frequency: i === size - 1 ? size + 1 : (i * 7919) % size + 1,
|
||||
lastUsedAt: QUERY_NOW + (i === size - 1 ? size + 1 : (i * 7919) % size),
|
||||
}));
|
||||
installHistoryEntries(entries);
|
||||
queryHistory("missing"); // Warm the cache before measuring query operations.
|
||||
|
||||
const expected = entries.slice().sort((a, b) => b.frequency - a.frequency).slice(0, 5);
|
||||
const pow = t.mock.method(Math, "pow");
|
||||
const lower = t.mock.method(String.prototype, "toLowerCase");
|
||||
// node:test cannot mock methods directly on arrays (including Array.prototype).
|
||||
const originalSort = Array.prototype.sort;
|
||||
const sortedSizes: number[] = [];
|
||||
Array.prototype.sort = function<T>(this: T[], compare?: (a: T, b: T) => number): T[] {
|
||||
sortedSizes.push(this.length);
|
||||
return originalSort.call(this, compare);
|
||||
};
|
||||
t.after(() => { Array.prototype.sort = originalSort; });
|
||||
const queries = [
|
||||
() => queryHistory("vi", { limit: 5 }),
|
||||
() => fuzzyQueryHistory("vi", { limit: 5 }),
|
||||
() => queryRecentHistoryByCommand({ commandName: "vi", argumentPrefix: "file-", limit: 5 }),
|
||||
];
|
||||
|
||||
for (const query of queries) {
|
||||
const beforePow = pow.mock.callCount();
|
||||
const beforeLower = lower.mock.callCount();
|
||||
const result = query();
|
||||
assert.deepEqual(result, expected);
|
||||
assert.equal(new Set(result.map((entry) => entry.command)).size, 5);
|
||||
assert.ok(pow.mock.callCount() - beforePow <= size, "score each entry at most once");
|
||||
assert.ok(lower.mock.callCount() - beforeLower <= 2 * size + 2, "normalize in one linear pass");
|
||||
}
|
||||
assert.deepEqual(sortedSizes, [], "never sort all matches before limiting");
|
||||
});
|
||||
}
|
||||
@@ -352,6 +352,36 @@ export interface RecentHistoryQueryOptions extends HistoryQueryOptions {
|
||||
argumentPrefix?: string;
|
||||
}
|
||||
|
||||
interface RankedHistoryEntry {
|
||||
entry: HistoryEntry;
|
||||
score: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Keep only the best k distinct commands. Queries score each match once and
|
||||
* retain O(k) candidates instead of sorting/allocating all matches (k is 5
|
||||
* for autocomplete). Entries arrive in store order for stable score ties.
|
||||
*/
|
||||
function retainHistoryMatch(
|
||||
best: RankedHistoryEntry[],
|
||||
entry: HistoryEntry,
|
||||
score: number,
|
||||
limit: number,
|
||||
): void {
|
||||
if (best.length >= limit && score <= best[best.length - 1].score) return;
|
||||
|
||||
const duplicate = best.findIndex((candidate) => candidate.entry.command === entry.command);
|
||||
if (duplicate >= 0) {
|
||||
if (score <= best[duplicate].score) return;
|
||||
best.splice(duplicate, 1);
|
||||
}
|
||||
|
||||
// Insert after equal scores to preserve the original store order on ties.
|
||||
const index = best.findIndex((candidate) => score > candidate.score);
|
||||
if (best.length >= limit) best.pop();
|
||||
best.splice(index < 0 ? best.length : index, 0, { entry, score });
|
||||
}
|
||||
|
||||
/**
|
||||
* Query history entries matching a prefix.
|
||||
* Returns entries sorted by relevance (frequency * recency).
|
||||
@@ -366,33 +396,17 @@ export function queryHistory(
|
||||
const lowerPrefix = prefix.toLowerCase();
|
||||
const now = Date.now(); // Cache once per query
|
||||
|
||||
const filtered = store.entries.filter((entry) => {
|
||||
const best: RankedHistoryEntry[] = [];
|
||||
for (const entry of store.entries) {
|
||||
if (hostId && entry.hostId !== hostId) continue;
|
||||
// Must match prefix
|
||||
if (!entry.command.toLowerCase().startsWith(lowerPrefix)) return false;
|
||||
if (!entry.command.toLowerCase().startsWith(lowerPrefix)) continue;
|
||||
// Must not be identical to prefix
|
||||
if (entry.command === prefix) return false;
|
||||
|
||||
// Host filtering: strict per-host isolation
|
||||
if (hostId) {
|
||||
return entry.hostId === hostId;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
// Sort by score (frequency * recency)
|
||||
filtered.sort((a, b) => scoreEntryAt(b, now) - scoreEntryAt(a, now));
|
||||
|
||||
// Deduplicate by command text (keep highest scored)
|
||||
const seen = new Set<string>();
|
||||
const results: HistoryEntry[] = [];
|
||||
for (const entry of filtered) {
|
||||
if (seen.has(entry.command)) continue;
|
||||
seen.add(entry.command);
|
||||
results.push(entry);
|
||||
if (results.length >= limit) break;
|
||||
if (entry.command === prefix) continue;
|
||||
retainHistoryMatch(best, entry, scoreEntryAt(entry, now), limit);
|
||||
}
|
||||
|
||||
return results;
|
||||
return best.map(({ entry }) => entry);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -410,7 +424,7 @@ export function fuzzyQueryHistory(
|
||||
const lowerQuery = query.toLowerCase();
|
||||
const now = Date.now(); // Cache once per query
|
||||
|
||||
const scored: { entry: HistoryEntry; matchScore: number }[] = [];
|
||||
const best: RankedHistoryEntry[] = [];
|
||||
|
||||
for (const entry of store.entries) {
|
||||
// Host filtering
|
||||
@@ -420,24 +434,11 @@ export function fuzzyQueryHistory(
|
||||
|
||||
const matchScore = fuzzyScore(lowerQuery, entry.command.toLowerCase());
|
||||
if (matchScore > 0 && entry.command !== query) {
|
||||
scored.push({ entry, matchScore });
|
||||
retainHistoryMatch(best, entry, matchScore * scoreEntryAt(entry, now), limit);
|
||||
}
|
||||
}
|
||||
|
||||
scored.sort((a, b) =>
|
||||
b.matchScore * scoreEntryAt(b.entry, now) - a.matchScore * scoreEntryAt(a.entry, now),
|
||||
);
|
||||
|
||||
const seen = new Set<string>();
|
||||
const results: HistoryEntry[] = [];
|
||||
for (const { entry } of scored) {
|
||||
if (seen.has(entry.command)) continue;
|
||||
seen.add(entry.command);
|
||||
results.push(entry);
|
||||
if (results.length >= limit) break;
|
||||
}
|
||||
|
||||
return results;
|
||||
return best.map(({ entry }) => entry);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -462,38 +463,26 @@ export function queryRecentHistoryByCommand(
|
||||
const commandPrefix = `${trimmedCommandName} `;
|
||||
const normalizedArgumentPrefix = normalizeArgumentToken(argumentPrefix ?? "");
|
||||
|
||||
const filtered = store.entries.filter((entry) => {
|
||||
const best: RankedHistoryEntry[] = [];
|
||||
for (const entry of store.entries) {
|
||||
if (hostId && entry.hostId !== hostId) continue;
|
||||
const lowerCommand = entry.command.toLowerCase();
|
||||
if (lowerCommand !== trimmedCommandName && !lowerCommand.startsWith(commandPrefix)) {
|
||||
return false;
|
||||
continue;
|
||||
}
|
||||
if (excludeCommand && entry.command === excludeCommand) return false;
|
||||
if (excludeCommand && entry.command === excludeCommand) continue;
|
||||
|
||||
if (normalizedArgumentPrefix) {
|
||||
const currentToken = normalizeArgumentToken(getCurrentCommandToken(entry.command));
|
||||
if (!currentToken.startsWith(normalizedArgumentPrefix)) {
|
||||
return false;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
if (hostId) {
|
||||
return entry.hostId === hostId;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
filtered.sort((a, b) => b.lastUsedAt - a.lastUsedAt);
|
||||
|
||||
const seen = new Set<string>();
|
||||
const results: HistoryEntry[] = [];
|
||||
for (const entry of filtered) {
|
||||
if (seen.has(entry.command)) continue;
|
||||
seen.add(entry.command);
|
||||
results.push(entry);
|
||||
if (results.length >= limit) break;
|
||||
retainHistoryMatch(best, entry, entry.lastUsedAt, limit);
|
||||
}
|
||||
|
||||
return results;
|
||||
return best.map(({ entry }) => entry);
|
||||
}
|
||||
|
||||
function getCurrentCommandToken(command: string): string {
|
||||
@@ -569,28 +558,28 @@ function fuzzyScore(query: string, target: string): number {
|
||||
if (query.length > target.length) return 0;
|
||||
|
||||
let score = 0;
|
||||
let queryIdx = 0;
|
||||
let searchFrom = 0;
|
||||
let prevMatchIdx = -2;
|
||||
|
||||
for (let i = 0; i < target.length && queryIdx < query.length; i++) {
|
||||
if (target[i] === query[queryIdx]) {
|
||||
queryIdx++;
|
||||
// First character bonus
|
||||
if (i === 0) score += 10;
|
||||
// Consecutive match bonus
|
||||
if (i === prevMatchIdx + 1) score += 5;
|
||||
// Word boundary bonus
|
||||
if (i === 0 || target[i - 1] === " " || target[i - 1] === "/" ||
|
||||
target[i - 1] === "-" || target[i - 1] === "_") {
|
||||
score += 3;
|
||||
}
|
||||
score += 1;
|
||||
prevMatchIdx = i;
|
||||
for (let queryIdx = 0; queryIdx < query.length; queryIdx++) {
|
||||
// Skip unmatched runs in native string search, especially for long commands.
|
||||
const i = target.indexOf(query[queryIdx], searchFrom);
|
||||
if (i < 0) return 0;
|
||||
// First character bonus
|
||||
if (i === 0) score += 10;
|
||||
// Consecutive match bonus
|
||||
if (i === prevMatchIdx + 1) score += 5;
|
||||
// Word boundary bonus
|
||||
if (i === 0 || target[i - 1] === " " || target[i - 1] === "/" ||
|
||||
target[i - 1] === "-" || target[i - 1] === "_") {
|
||||
score += 3;
|
||||
}
|
||||
score += 1;
|
||||
prevMatchIdx = i;
|
||||
searchFrom = i + 1;
|
||||
}
|
||||
|
||||
// All query characters must be matched
|
||||
return queryIdx === query.length ? score : 0;
|
||||
return score;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -30,3 +30,20 @@ test('terminalCwdStore bumps version only when cwd or provenance changes', () =>
|
||||
unsubscribe();
|
||||
assert.ok(versions.length >= 2);
|
||||
});
|
||||
|
||||
test('session revisions isolate terminals and retain identical OSC 7 reports without rerendering', () => {
|
||||
const a = 'revision-a'; const b = 'revision-b';
|
||||
terminalCwdStore.setCwd(a, '/same', 'osc7');
|
||||
const revisionA = terminalCwdStore.getSessionRevision(a);
|
||||
terminalCwdStore.setCwd(b, '/elsewhere', 'osc7');
|
||||
assert.equal(terminalCwdStore.getSessionRevision(a), revisionA);
|
||||
const globalVersion = terminalCwdStore.getVersion();
|
||||
assert.equal(terminalCwdStore.setCwd(a, '/same', 'osc7'), false);
|
||||
assert.ok(terminalCwdStore.getSessionRevision(a) > revisionA);
|
||||
assert.equal(terminalCwdStore.getVersion(), globalVersion);
|
||||
const revisionB = terminalCwdStore.getSessionRevision(b);
|
||||
terminalCwdStore.prune(new Set([b]));
|
||||
assert.equal(terminalCwdStore.getSessionRevision(a), 0);
|
||||
assert.equal(terminalCwdStore.getSessionRevision(b), revisionB);
|
||||
terminalCwdStore.prune(new Set());
|
||||
});
|
||||
@@ -10,11 +10,16 @@ export type TerminalCwdSource = "osc7" | "inferred" | "backend-strict" | "backen
|
||||
class TerminalCwdStore {
|
||||
private cwdBySession = new Map<string, string>();
|
||||
private sourceBySession = new Map<string, TerminalCwdSource>();
|
||||
private revisionBySession = new Map<string, number>();
|
||||
private revision = 0;
|
||||
private version = 0;
|
||||
private listeners = new Set<Listener>();
|
||||
|
||||
getVersion = (): number => this.version;
|
||||
|
||||
/** Tracks reports for one session, including unchanged authoritative OSC 7. */
|
||||
getSessionRevision = (sessionId: string): number => this.revisionBySession.get(sessionId) ?? 0;
|
||||
|
||||
getCwd = (sessionId: string | null | undefined): string | null => {
|
||||
if (!sessionId) return null;
|
||||
return this.cwdBySession.get(sessionId) ?? null;
|
||||
@@ -39,7 +44,10 @@ class TerminalCwdStore {
|
||||
const nextSource = next
|
||||
? (source ?? (current === next ? currentSource : undefined) ?? "unknown")
|
||||
: undefined;
|
||||
if (current === next && currentSource === nextSource) return false;
|
||||
if (current === next && currentSource === nextSource) {
|
||||
if (nextSource === 'osc7') this.revisionBySession.set(sessionId, ++this.revision);
|
||||
return false;
|
||||
}
|
||||
|
||||
if (next) {
|
||||
this.cwdBySession.set(sessionId, next);
|
||||
@@ -48,6 +56,7 @@ class TerminalCwdStore {
|
||||
this.cwdBySession.delete(sessionId);
|
||||
this.sourceBySession.delete(sessionId);
|
||||
}
|
||||
this.revisionBySession.set(sessionId, ++this.revision);
|
||||
this.version += 1;
|
||||
for (const listener of this.listeners) {
|
||||
listener();
|
||||
@@ -61,9 +70,13 @@ class TerminalCwdStore {
|
||||
if (!validSessionIds.has(sessionId)) {
|
||||
this.cwdBySession.delete(sessionId);
|
||||
this.sourceBySession.delete(sessionId);
|
||||
this.revisionBySession.delete(sessionId);
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
for (const id of this.revisionBySession.keys()) {
|
||||
if (!validSessionIds.has(id)) this.revisionBySession.delete(id);
|
||||
}
|
||||
if (!changed) return;
|
||||
this.version += 1;
|
||||
for (const listener of this.listeners) {
|
||||
|
||||
@@ -0,0 +1,230 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { createTerminalDirectoryController } from './terminalDirectoryController';
|
||||
import { terminalCwdStore } from './terminalCwdStore';
|
||||
import type { TerminalDirectoryIO } from '../../infrastructure/services/terminalDirectory';
|
||||
|
||||
function setup(overrides: Partial<TerminalDirectoryIO> = {}) {
|
||||
let cwd = '/old'; let ready = true; let current = true;
|
||||
const writes: string[] = [];
|
||||
const published: string[] = [];
|
||||
const controller = createTerminalDirectoryController({
|
||||
sessionId: 'test-directory', confirmationMs: 10,
|
||||
io: { cwd: async () => cwd, list: async () => ({ entries: [], home: null }), realpath: async path => path,
|
||||
write: data => { writes.push(data); cwd = '/next'; }, ...overrides },
|
||||
isCurrent: () => current, isReady: () => ready,
|
||||
publish: value => { published.push(value); terminalCwdStore.setCwd('test-directory', value, 'backend-strict'); },
|
||||
});
|
||||
return { controller, writes, published, busy: () => { ready = false; }, stale: () => { current = false; } };
|
||||
}
|
||||
|
||||
test('navigation writes one quoted cd and waits for actual cwd before confirming', async () => {
|
||||
const ctx = setup(); const accepted: string[] = [];
|
||||
await ctx.controller.change('/next', new AbortController().signal, () => accepted.push('accepted'));
|
||||
assert.deepEqual(ctx.writes, ["cd -- '/next'\r"]);
|
||||
assert.deepEqual(accepted, ['accepted']);
|
||||
assert.deepEqual(ctx.published, ['/next']);
|
||||
ctx.controller.dispose();
|
||||
});
|
||||
|
||||
test('busy, control-character and stale sessions never receive bytes', async () => {
|
||||
const ctx = setup(); ctx.busy();
|
||||
await assert.rejects(ctx.controller.change('/next', new AbortController().signal, () => {}), { code: 'busy' });
|
||||
const invalid = setup();
|
||||
await assert.rejects(invalid.controller.change('/next\n', new AbortController().signal, () => {}), { code: 'unsupportedPath' });
|
||||
const stale = setup(); stale.stale();
|
||||
await assert.rejects(stale.controller.change('/next', new AbortController().signal, () => {}), { code: 'unavailable' });
|
||||
assert.deepEqual([...ctx.writes, ...invalid.writes, ...stale.writes], []);
|
||||
});
|
||||
|
||||
test('rechecks readiness after async path resolution; double submission is rejected', async () => {
|
||||
let finish!: (path: string) => void;
|
||||
const ctx = setup({ realpath: () => new Promise(resolve => { finish = resolve; }) });
|
||||
const first = ctx.controller.change('/next', new AbortController().signal, () => {});
|
||||
await assert.rejects(ctx.controller.change('/other', new AbortController().signal, () => {}), { code: 'busy' });
|
||||
ctx.busy(); finish('/next');
|
||||
await assert.rejects(first, { code: 'busy' });
|
||||
assert.deepEqual(ctx.writes, []);
|
||||
});
|
||||
|
||||
test('a disposed connection rejects delayed reads and cannot publish into a new generation', async () => {
|
||||
let finish!: (cwd: string) => void;
|
||||
const ctx = setup({ cwd: () => new Promise(resolve => { finish = resolve; }) });
|
||||
const read = ctx.controller.refresh();
|
||||
ctx.controller.dispose(); finish('/stale');
|
||||
await assert.rejects(read, { code: 'unavailable' });
|
||||
assert.deepEqual(ctx.published, []);
|
||||
});
|
||||
|
||||
test('a failed cd keeps the observed cwd and never retries the write', async () => {
|
||||
const ctx = setup({ write: () => {}, cwd: async () => '/old' });
|
||||
await assert.rejects(ctx.controller.change('/missing', new AbortController().signal, () => {}), { code: 'changeFailed' });
|
||||
assert.ok(ctx.published.every(value => value === '/old'));
|
||||
});
|
||||
|
||||
test('directory symlinks confirm against physical cwd', async () => {
|
||||
const ctx = setup({ realpath: async path => path === '/link' ? '/real' : path, cwd: async () => '/real' });
|
||||
await ctx.controller.change('/link', new AbortController().signal, () => {});
|
||||
assert.equal(ctx.published.at(-1), '/real');
|
||||
});
|
||||
|
||||
test('output arriving during an old cwd query retains one fresh follow-up query', async () => {
|
||||
let finish!: (cwd: string) => void;
|
||||
let reads = 0;
|
||||
const ctx = setup({ cwd: () => {
|
||||
reads++;
|
||||
return reads === 1 ? new Promise(resolve => { finish = resolve; }) : Promise.resolve('/after-cd');
|
||||
} });
|
||||
const oldRead = ctx.controller.refresh();
|
||||
const queued = ctx.controller.refresh();
|
||||
finish('/before-cd');
|
||||
await Promise.all([oldRead, queued]);
|
||||
await new Promise(resolve => setImmediate(resolve));
|
||||
assert.equal(reads, 2);
|
||||
assert.equal(ctx.published.at(-1), '/after-cd');
|
||||
ctx.controller.dispose();
|
||||
});
|
||||
|
||||
test('another terminal updating cannot override the current terminal strict cwd result', async t => {
|
||||
const id = 'strict-after-other-session';
|
||||
let finish!: (cwd: string) => void;
|
||||
const published: string[] = [];
|
||||
terminalCwdStore.setCwd(id, '/stale-osc7', 'stale');
|
||||
const controller = createTerminalDirectoryController({
|
||||
sessionId: id, isCurrent: () => true, isReady: () => true,
|
||||
io: { cwd: () => new Promise(resolve => { finish = resolve; }),
|
||||
list: async () => ({ entries: [], home: null }), realpath: async value => value, write() {} },
|
||||
publish: value => { published.push(value); terminalCwdStore.setCwd(id, value, 'backend-strict'); },
|
||||
});
|
||||
t.after(() => controller.dispose());
|
||||
const pending = controller.refresh();
|
||||
terminalCwdStore.setCwd('unrelated-session', '/other', 'osc7');
|
||||
finish('/actual-after-cd');
|
||||
assert.equal(await pending, '/actual-after-cd');
|
||||
assert.deepEqual(published, ['/actual-after-cd']);
|
||||
assert.equal(terminalCwdStore.getCwd(id), '/actual-after-cd');
|
||||
});
|
||||
|
||||
test('another terminal cannot turn this submission pre-write OSC 7 into a fresh report', async t => {
|
||||
const id = 'pre-write-osc7-after-other-session';
|
||||
let finish!: (cwd: string) => void;
|
||||
const published: string[] = [];
|
||||
terminalCwdStore.setCwd(id, '/before', 'osc7');
|
||||
const controller = createTerminalDirectoryController({
|
||||
sessionId: id, confirmationMs: 20, isCurrent: () => true, isReady: () => true,
|
||||
io: { cwd: () => new Promise(resolve => { finish = resolve; }),
|
||||
list: async () => ({ entries: [], home: null }), realpath: async value => value, write() {} },
|
||||
publish: cwd => { published.push(cwd); terminalCwdStore.setCwd(id, cwd, 'backend-strict'); },
|
||||
});
|
||||
t.after(() => controller.dispose());
|
||||
const changing = controller.change('/after', new AbortController().signal, () => {});
|
||||
await new Promise(resolve => setImmediate(resolve));
|
||||
terminalCwdStore.setCwd(id + '-other', '/other', 'osc7');
|
||||
finish('/after');
|
||||
await changing;
|
||||
assert.deepEqual(published, ['/after']);
|
||||
assert.equal(terminalCwdStore.getCwd(id), '/after');
|
||||
});
|
||||
|
||||
for (const nextCwd of ['/new-osc7', '/original']) {
|
||||
test(`a later OSC 7 report from this terminal wins (${nextCwd})`, async t => {
|
||||
const id = 'same-session-osc7-' + nextCwd;
|
||||
let finish!: (cwd: string) => void;
|
||||
const published: string[] = [];
|
||||
terminalCwdStore.setCwd(id, '/original', 'stale');
|
||||
const controller = createTerminalDirectoryController({
|
||||
sessionId: id, isCurrent: () => true, isReady: () => true,
|
||||
io: { cwd: () => new Promise(resolve => { finish = resolve; }),
|
||||
list: async () => ({ entries: [], home: null }), realpath: async value => value, write() {} },
|
||||
publish: value => { published.push(value); },
|
||||
});
|
||||
t.after(() => controller.dispose());
|
||||
const pending = controller.refresh();
|
||||
terminalCwdStore.setCwd(id, nextCwd, 'osc7');
|
||||
finish('/older-backend-result');
|
||||
assert.equal(await pending, nextCwd);
|
||||
assert.deepEqual(published, []);
|
||||
});
|
||||
}
|
||||
|
||||
for (const probe of ['fails', 'stalls'] as const) {
|
||||
test(`a fresh post-write OSC 7 confirms navigation when the strict cwd probe ${probe}`, { timeout: 2000 }, async t => {
|
||||
const id = 'osc7-confirm-' + probe;
|
||||
const writes: string[] = [];
|
||||
terminalCwdStore.setCwd(id, '/old', 'osc7');
|
||||
const controller = createTerminalDirectoryController({
|
||||
sessionId: id, confirmationMs: 1000, isCurrent: () => true, isReady: () => true,
|
||||
io: {
|
||||
cwd: () => probe === 'fails' ? Promise.reject(new Error('no process cwd support')) : new Promise(() => {}),
|
||||
list: async () => ({ entries: [], home: null }), realpath: async value => value,
|
||||
write: data => {
|
||||
writes.push(data);
|
||||
setImmediate(() => terminalCwdStore.setCwd(id, '/next', 'osc7'));
|
||||
},
|
||||
},
|
||||
publish() { assert.fail('an unavailable probe must not publish a guessed cwd'); },
|
||||
});
|
||||
t.after(() => controller.dispose());
|
||||
await controller.change('/next', new AbortController().signal, () => {});
|
||||
assert.deepEqual(writes, ["cd -- '/next'\r"]);
|
||||
assert.equal(controller.hasConfirmedCwd(), true);
|
||||
});
|
||||
}
|
||||
|
||||
test('navigation can probe past its pre-write OSC 7 when the shell stops reporting', async t => {
|
||||
const id = 'probe-after-pre-write-osc7';
|
||||
terminalCwdStore.setCwd(id, '/before', 'osc7');
|
||||
const controller = createTerminalDirectoryController({
|
||||
sessionId: id, isCurrent: () => true, isReady: () => true,
|
||||
io: { cwd: async () => '/after', list: async () => ({ entries: [], home: null }),
|
||||
realpath: async value => value, write() {} },
|
||||
publish: cwd => terminalCwdStore.setCwd(id, cwd, 'backend-strict'),
|
||||
});
|
||||
t.after(() => controller.dispose());
|
||||
await controller.change('/after', new AbortController().signal, () => {});
|
||||
assert.equal(terminalCwdStore.getCwd(id), '/after');
|
||||
assert.equal(terminalCwdStore.getSource(id), 'backend-strict');
|
||||
});
|
||||
|
||||
for (const report of ['stale', 'other-session', 'other-source', 'fresh-unchanged'] as const) {
|
||||
test(`OSC 7 confirmation uses this submission's session revision (${report})`, { timeout: 2000 }, async t => {
|
||||
const id = 'osc7-revision-confirm-' + report;
|
||||
terminalCwdStore.setCwd(id, '/next', 'osc7');
|
||||
const controller = createTerminalDirectoryController({
|
||||
sessionId: id, confirmationMs: 20, isCurrent: () => true, isReady: () => true,
|
||||
io: {
|
||||
cwd: async () => { throw new Error('no process cwd support'); },
|
||||
list: async () => ({ entries: [], home: null }), realpath: async value => value,
|
||||
write() {
|
||||
if (report !== 'stale') setImmediate(() => terminalCwdStore.setCwd(
|
||||
report === 'other-session' ? id + '-other' : id, '/next', report === 'other-source' ? 'inferred' : 'osc7',
|
||||
));
|
||||
},
|
||||
},
|
||||
publish() {},
|
||||
});
|
||||
t.after(() => controller.dispose());
|
||||
const change = controller.change('/next', new AbortController().signal, () => {});
|
||||
if (report === 'fresh-unchanged') await change;
|
||||
else await assert.rejects(change, { code: 'changeFailed' });
|
||||
});
|
||||
}
|
||||
|
||||
for (const cancellation of ['abort', 'dispose'] as const) {
|
||||
test(`a fresh OSC 7 cannot confirm a cancelled directory change (${cancellation})`, { timeout: 2000 }, async t => {
|
||||
const id = 'osc7-cancel-' + cancellation;
|
||||
const abort = new AbortController();
|
||||
const controller = createTerminalDirectoryController({
|
||||
sessionId: id, isCurrent: () => true, isReady: () => true,
|
||||
io: { cwd: () => new Promise(() => {}), list: async () => ({ entries: [], home: null }),
|
||||
realpath: async value => value,
|
||||
write() { setImmediate(() => {
|
||||
if (cancellation === 'abort') abort.abort(); else controller.dispose();
|
||||
terminalCwdStore.setCwd(id, '/next', 'osc7');
|
||||
}); } },
|
||||
publish() {},
|
||||
});
|
||||
t.after(() => controller.dispose());
|
||||
await assert.rejects(controller.change('/next', abort.signal, () => {}), { code: 'unavailable' });
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
import { DirectoryPickerError, isDirectoryPath } from '../../domain/directoryPicker';
|
||||
import { resolveInteractiveTerminalCdIntent } from '../../domain/sessionRestore';
|
||||
import type { TerminalDirectoryIO } from '../../infrastructure/services/terminalDirectory';
|
||||
import { terminalCwdStore } from './terminalCwdStore';
|
||||
|
||||
export type TerminalDirectoryController = ReturnType<typeof createTerminalDirectoryController>;
|
||||
|
||||
/** One registration represents one connected interactive shell generation. */
|
||||
export function createTerminalDirectoryController(options: {
|
||||
sessionId: string;
|
||||
io: TerminalDirectoryIO;
|
||||
isCurrent: () => boolean;
|
||||
isReady: () => boolean;
|
||||
publish: (cwd: string) => void;
|
||||
confirmationMs?: number;
|
||||
}) {
|
||||
let disposed = false;
|
||||
let changing = false;
|
||||
let confirmed = false;
|
||||
let refreshing: Promise<string> | null = null;
|
||||
let refreshAgain = false;
|
||||
const lifetime = new AbortController();
|
||||
const current = () => !disposed && options.isCurrent();
|
||||
function check(signal?: AbortSignal) {
|
||||
if (!current() || signal?.aborted) throw new DirectoryPickerError('unavailable');
|
||||
}
|
||||
function ready() {
|
||||
check();
|
||||
if (!options.isReady()) throw new DirectoryPickerError('busy');
|
||||
}
|
||||
function bounded<T>(task: Promise<T>, signal?: AbortSignal): Promise<T> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const finish = (error?: unknown, value?: T) => {
|
||||
clearTimeout(timer);
|
||||
lifetime.signal.removeEventListener('abort', cancel);
|
||||
signal?.removeEventListener('abort', cancel);
|
||||
if (error) reject(error); else resolve(value!);
|
||||
};
|
||||
const cancel = () => finish(new DirectoryPickerError('unavailable'));
|
||||
const timer = setTimeout(() => finish(new DirectoryPickerError('readFailed')), 5000);
|
||||
lifetime.signal.addEventListener('abort', cancel, { once: true });
|
||||
signal?.addEventListener('abort', cancel, { once: true });
|
||||
if (signal?.aborted || lifetime.signal.aborted) cancel();
|
||||
task.then(value => finish(undefined, value), error => finish(error));
|
||||
});
|
||||
}
|
||||
async function refresh(submittedRevision?: number): Promise<string> {
|
||||
check();
|
||||
const activeShellCwd = () => {
|
||||
const cwd = terminalCwdStore.getCwd(options.sessionId);
|
||||
return cwd && terminalCwdStore.getSource(options.sessionId) === 'osc7'
|
||||
&& (submittedRevision === undefined || terminalCwdStore.getSessionRevision(options.sessionId) !== submittedRevision)
|
||||
? cwd : null;
|
||||
};
|
||||
// A process probe may read the outer PTY shell while a nested shell is
|
||||
// active. OSC 7 remains authoritative across debounce delays, completion
|
||||
// events and picker remounts. Only a cd submission may probe past its own
|
||||
// pre-write report while waiting for fresh confirmation.
|
||||
const reported = activeShellCwd();
|
||||
if (reported) { confirmed = true; return reported; }
|
||||
if (refreshing) {
|
||||
// Output may settle while a pre-cd query is still in flight. Keep one
|
||||
// follow-up query so that its older result cannot be the last update.
|
||||
refreshAgain = true;
|
||||
return refreshing;
|
||||
}
|
||||
const pending = (async () => {
|
||||
const cwd = await bounded(options.io.cwd());
|
||||
check();
|
||||
const reported = activeShellCwd();
|
||||
const live = terminalCwdStore.getCwd(options.sessionId);
|
||||
confirmed = true;
|
||||
if (reported) return reported;
|
||||
if (live !== cwd || terminalCwdStore.getSource(options.sessionId) === 'stale') options.publish(cwd);
|
||||
return cwd;
|
||||
})();
|
||||
refreshing = pending;
|
||||
try { return await pending; } finally {
|
||||
if (refreshing === pending) refreshing = null;
|
||||
if (refreshAgain && current()) {
|
||||
refreshAgain = false;
|
||||
void refresh().catch(() => {});
|
||||
}
|
||||
}
|
||||
}
|
||||
async function change(path: string, signal: AbortSignal, accepted: () => void, expectedCwd?: string) {
|
||||
ready();
|
||||
if (changing) throw new DirectoryPickerError('busy');
|
||||
const intent = isDirectoryPath(path) ? resolveInteractiveTerminalCdIntent(path) : null;
|
||||
if (!intent) throw new DirectoryPickerError('unsupportedPath');
|
||||
changing = true;
|
||||
let confirmationFinished = false;
|
||||
try {
|
||||
const canonical = await bounded(options.io.realpath(path, signal), signal);
|
||||
check(signal);
|
||||
ready(); // The shell can become busy while resolving a remote symlink.
|
||||
if (expectedCwd && terminalCwdStore.getCwd(options.sessionId) !== expectedCwd) throw new DirectoryPickerError('unavailable');
|
||||
const submittedRevision = terminalCwdStore.getSessionRevision(options.sessionId);
|
||||
options.io.write(intent.command + '\r');
|
||||
accepted();
|
||||
const deadline = Date.now() + (options.confirmationMs ?? 8000);
|
||||
const hasFreshOsc7 = () => {
|
||||
const cwd = terminalCwdStore.getCwd(options.sessionId);
|
||||
return terminalCwdStore.getSessionRevision(options.sessionId) !== submittedRevision
|
||||
&& terminalCwdStore.getSource(options.sessionId) === 'osc7'
|
||||
&& (cwd === path || cwd === canonical);
|
||||
};
|
||||
const pause = () => new Promise<void>(resolve => {
|
||||
const done = () => { clearTimeout(timer); lifetime.signal.removeEventListener('abort', done); resolve(); };
|
||||
const timer = setTimeout(done, 200);
|
||||
lifetime.signal.addEventListener('abort', done, { once: true });
|
||||
});
|
||||
const confirmFromProbe = async () => {
|
||||
while (!confirmationFinished && Date.now() < deadline) {
|
||||
check(signal);
|
||||
if (hasFreshOsc7()) return;
|
||||
if (options.isReady()) {
|
||||
try {
|
||||
const cwd = await refresh(submittedRevision);
|
||||
check(signal);
|
||||
if (cwd === path || cwd === canonical || await bounded(options.io.realpath(cwd, signal), signal) === canonical) return;
|
||||
} catch (error) {
|
||||
if (!current() || signal.aborted) throw error;
|
||||
}
|
||||
}
|
||||
await pause();
|
||||
}
|
||||
if (!confirmationFinished) throw new DirectoryPickerError('changeFailed');
|
||||
};
|
||||
// OSC 7 is authoritative even when process cwd lookup is unavailable or
|
||||
// stalled. Poll revisions too: an unchanged OSC 7 path emits no store event.
|
||||
const confirmFromOsc7 = async () => {
|
||||
while (!confirmationFinished) {
|
||||
check(signal);
|
||||
if (hasFreshOsc7()) return;
|
||||
if (Date.now() >= deadline) throw new DirectoryPickerError('changeFailed');
|
||||
await pause();
|
||||
}
|
||||
};
|
||||
await Promise.race([confirmFromProbe(), confirmFromOsc7()]);
|
||||
check(signal);
|
||||
confirmed = true;
|
||||
} finally { confirmationFinished = true; changing = false; }
|
||||
}
|
||||
return {
|
||||
refresh: () => refresh(),
|
||||
hasConfirmedCwd: () => confirmed,
|
||||
async home() {
|
||||
check();
|
||||
const result = await bounded(options.io.home?.() ?? Promise.resolve(null));
|
||||
check(); return result;
|
||||
},
|
||||
async list(path: string, signal: AbortSignal) {
|
||||
check(signal);
|
||||
const result = await bounded(options.io.list(path, signal), signal);
|
||||
check(signal);
|
||||
return result;
|
||||
},
|
||||
change,
|
||||
dispose() { disposed = true; lifetime.abort(); },
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { createTerminalDirectoryController } from './terminalDirectoryController';
|
||||
import { terminalCwdStore } from './terminalCwdStore';
|
||||
import {
|
||||
observeTerminalDirectory, registerTerminalDirectory,
|
||||
refreshObservedTerminalDirectory, scheduleObservedTerminalDirectoryRefresh,
|
||||
} from './terminalDirectoryRegistry';
|
||||
|
||||
test('manual terminal output refreshes cwd without a trusted command-completion event', async t => {
|
||||
t.mock.timers.enable({ apis: ['setTimeout'] });
|
||||
let cwd = '/before'; let reads = 0;
|
||||
const published: string[] = [];
|
||||
const controller = createTerminalDirectoryController({
|
||||
sessionId: 'unrecognized-prompt', isCurrent: () => true, isReady: () => false,
|
||||
io: { cwd: async () => { reads++; return cwd; },
|
||||
list: async () => ({ entries: [], home: null }), realpath: async value => value, write() {} },
|
||||
publish: value => { published.push(value); },
|
||||
});
|
||||
const unregister = registerTerminalDirectory('unrecognized-prompt', controller);
|
||||
const stopObserving = observeTerminalDirectory('unrecognized-prompt');
|
||||
t.after(() => { stopObserving(); unregister(); });
|
||||
cwd = '/after-manual-cd';
|
||||
// Multiple prompt/output chunks produce just one read. No trusted prompt
|
||||
// detection, command-completed callback, or parsed `cd` is involved.
|
||||
scheduleObservedTerminalDirectoryRefresh('unrecognized-prompt');
|
||||
t.mock.timers.tick(80);
|
||||
scheduleObservedTerminalDirectoryRefresh('unrecognized-prompt');
|
||||
t.mock.timers.tick(149);
|
||||
assert.equal(reads, 0);
|
||||
t.mock.timers.tick(1);
|
||||
await new Promise(resolve => setImmediate(resolve));
|
||||
assert.deepEqual(published, ['/after-manual-cd']);
|
||||
assert.equal(reads, 1);
|
||||
t.mock.timers.tick(60_000);
|
||||
assert.equal(reads, 1, 'no polling after output has settled');
|
||||
});
|
||||
|
||||
test('settled output and prompt completion preserve the active nested shell OSC 7 cwd', async t => {
|
||||
t.mock.timers.enable({ apis: ['setTimeout'] });
|
||||
const id = 'nested-shell-output';
|
||||
const published: string[] = [];
|
||||
const controller = createTerminalDirectoryController({
|
||||
sessionId: id, isCurrent: () => true, isReady: () => true,
|
||||
// The PTY process stays in its original directory while a child shell
|
||||
// runs in /. Its cwd is only a fallback for shells without OSC 7.
|
||||
io: { cwd: async () => '/outer-shell', list: async () => ({ entries: [], home: null }),
|
||||
realpath: async value => value, write() {} },
|
||||
publish: cwd => { published.push(cwd); terminalCwdStore.setCwd(id, cwd, 'backend-strict'); },
|
||||
});
|
||||
const unregister = registerTerminalDirectory(id, controller);
|
||||
const stopObserving = observeTerminalDirectory(id);
|
||||
t.after(() => { stopObserving(); unregister(); });
|
||||
scheduleObservedTerminalDirectoryRefresh(id);
|
||||
terminalCwdStore.setCwd(id, '/', 'osc7');
|
||||
t.mock.timers.tick(150);
|
||||
await new Promise(resolve => setImmediate(resolve));
|
||||
assert.equal(terminalCwdStore.getCwd(id), '/');
|
||||
// Later output chunks, prompt completion and reopening/manual refresh must
|
||||
// not treat that same active-shell report as stale merely due to timing.
|
||||
scheduleObservedTerminalDirectoryRefresh(id);
|
||||
t.mock.timers.tick(150);
|
||||
refreshObservedTerminalDirectory(id);
|
||||
assert.equal(await controller.refresh(), '/');
|
||||
assert.deepEqual(published, []);
|
||||
assert.equal(terminalCwdStore.getSource(id), 'osc7');
|
||||
});
|
||||
|
||||
test('disabling chips cancels a queued output refresh and ignores future terminal output', async t => {
|
||||
t.mock.timers.enable({ apis: ['setTimeout'] });
|
||||
let reads = 0;
|
||||
const controller = createTerminalDirectoryController({
|
||||
sessionId: 'hidden-chips', isCurrent: () => true, isReady: () => true,
|
||||
io: { cwd: async () => { reads++; return '/unused'; }, list: async () => ({ entries: [], home: null }),
|
||||
realpath: async value => value, write() {} }, publish() {},
|
||||
});
|
||||
const unregister = registerTerminalDirectory('hidden-chips', controller);
|
||||
t.after(unregister);
|
||||
const stopObserving = observeTerminalDirectory('hidden-chips');
|
||||
scheduleObservedTerminalDirectoryRefresh('hidden-chips');
|
||||
stopObserving();
|
||||
t.mock.timers.tick(1000);
|
||||
scheduleObservedTerminalDirectoryRefresh('hidden-chips');
|
||||
t.mock.timers.tick(1000);
|
||||
await new Promise(resolve => setImmediate(resolve));
|
||||
assert.equal(reads, 0);
|
||||
});
|
||||
@@ -0,0 +1,58 @@
|
||||
import type { TerminalDirectoryController } from './terminalDirectoryController';
|
||||
|
||||
const sessions = new Map<string, TerminalDirectoryController>();
|
||||
const consumers = new Map<string, number>();
|
||||
const scheduledRefreshes = new Map<string, ReturnType<typeof setTimeout>>();
|
||||
const OUTPUT_SETTLE_MS = 150;
|
||||
|
||||
function cancelScheduledRefresh(id: string) {
|
||||
const timer = scheduledRefreshes.get(id);
|
||||
if (timer !== undefined) clearTimeout(timer);
|
||||
scheduledRefreshes.delete(id);
|
||||
}
|
||||
const listeners = new Set<() => void>();
|
||||
export const subscribeTerminalDirectories = (listener: () => void) => {
|
||||
listeners.add(listener);
|
||||
return () => { listeners.delete(listener); };
|
||||
};
|
||||
export const getTerminalDirectory = (id: string) => sessions.get(id) ?? null;
|
||||
export function registerTerminalDirectory(id: string, controller: TerminalDirectoryController) {
|
||||
cancelScheduledRefresh(id);
|
||||
sessions.set(id, controller);
|
||||
listeners.forEach(listener => listener());
|
||||
return () => {
|
||||
controller.dispose();
|
||||
if (sessions.get(id) !== controller) return;
|
||||
cancelScheduledRefresh(id);
|
||||
sessions.delete(id);
|
||||
listeners.forEach(listener => listener());
|
||||
};
|
||||
}
|
||||
export function observeTerminalDirectory(id: string) {
|
||||
consumers.set(id, (consumers.get(id) ?? 0) + 1);
|
||||
return () => {
|
||||
const next = (consumers.get(id) ?? 1) - 1;
|
||||
if (next) consumers.set(id, next);
|
||||
else { consumers.delete(id); cancelScheduledRefresh(id); }
|
||||
};
|
||||
}
|
||||
/** Called by the existing command completion path, only while chips are visible. */
|
||||
export function refreshObservedTerminalDirectory(id: string) {
|
||||
cancelScheduledRefresh(id);
|
||||
if (consumers.has(id)) void sessions.get(id)?.refresh().catch(() => {});
|
||||
}
|
||||
|
||||
/**
|
||||
* Shells with custom prompts may emit neither OSC 7/133 nor a trusted command
|
||||
* completion. Debounce actual output/submission activity instead of polling or
|
||||
* interpreting `cd` text. The controller prefers active-shell OSC 7 and uses
|
||||
* the bound shell's process cwd only as a fallback.
|
||||
*/
|
||||
export function scheduleObservedTerminalDirectoryRefresh(id: string) {
|
||||
if (!consumers.has(id) || !sessions.has(id)) return;
|
||||
cancelScheduledRefresh(id);
|
||||
scheduledRefreshes.set(id, setTimeout(() => {
|
||||
scheduledRefreshes.delete(id);
|
||||
refreshObservedTerminalDirectory(id);
|
||||
}, OUTPUT_SETTLE_MS));
|
||||
}
|
||||
@@ -0,0 +1,215 @@
|
||||
/**
|
||||
* Minimal in-memory IndexedDB stub covering the exact subset used by
|
||||
* `aiSessionIdbStorage.ts`: open + upgrade (createObjectStore), readonly
|
||||
* getAll/get, readwrite put/delete/clear, and meta-store records. Not a
|
||||
* general-purpose IDB implementation — extend deliberately if the wrapper
|
||||
* grows new usage.
|
||||
*/
|
||||
|
||||
type Resolve = () => void;
|
||||
|
||||
const microtask2 = (fn: Resolve) => {
|
||||
queueMicrotask(() => queueMicrotask(fn));
|
||||
};
|
||||
|
||||
export class FakeRequest<T> {
|
||||
onsuccess: ((this: FakeRequest<T>, ev?: unknown) => void) | null = null;
|
||||
onerror: ((this: FakeRequest<T>, ev?: unknown) => void) | null = null;
|
||||
onupgradeneeded: ((this: FakeRequest<T>, ev?: unknown) => void) | null = null;
|
||||
result: T;
|
||||
error: Error | null = null;
|
||||
|
||||
constructor(result: T, mode: 'immediate' | 'upgrade' | 'failure' = 'immediate', error?: Error) {
|
||||
this.result = result;
|
||||
if (mode === 'failure') {
|
||||
this.error = error ?? new Error('fake idb failure');
|
||||
queueMicrotask(() => this.onerror?.(this));
|
||||
return;
|
||||
}
|
||||
queueMicrotask(() => {
|
||||
if (mode === 'upgrade') this.onupgradeneeded?.(this);
|
||||
this.onsuccess?.(this);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
interface StoreSpec {
|
||||
name: string;
|
||||
keyPath: string;
|
||||
}
|
||||
|
||||
export class FakeObjectStore {
|
||||
/** Instrumentation for per-record write assertions. */
|
||||
putCount = 0;
|
||||
deleteCount = 0;
|
||||
|
||||
constructor(
|
||||
public readonly spec: StoreSpec,
|
||||
public readonly records: Map<string, unknown>,
|
||||
) {}
|
||||
|
||||
put(value: unknown): FakeRequest<IDBValidKey> {
|
||||
this.putCount += 1;
|
||||
const key = (value as Record<string, unknown>)[this.spec.keyPath] as string;
|
||||
this.records.set(key, value);
|
||||
return new FakeRequest(key);
|
||||
}
|
||||
|
||||
delete(key: IDBValidKey): FakeRequest<undefined> {
|
||||
this.deleteCount += 1;
|
||||
this.records.delete(String(key));
|
||||
return new FakeRequest(undefined);
|
||||
}
|
||||
|
||||
clear(): FakeRequest<undefined> {
|
||||
this.records.clear();
|
||||
return new FakeRequest(undefined);
|
||||
}
|
||||
|
||||
get(key: IDBValidKey): FakeRequest<unknown> {
|
||||
return new FakeRequest(this.records.get(String(key)));
|
||||
}
|
||||
|
||||
getAll(): FakeRequest<unknown[]> {
|
||||
return new FakeRequest(Array.from(this.records.values()));
|
||||
}
|
||||
}
|
||||
|
||||
export class FakeTransaction {
|
||||
oncomplete: Resolve | null = null;
|
||||
onabort: ((ev?: unknown) => void) | null = null;
|
||||
onerror: ((ev?: unknown) => void) | null = null;
|
||||
error: Error | null = null;
|
||||
|
||||
constructor(
|
||||
private readonly database: FakeIndexedDb,
|
||||
private readonly storeName: string,
|
||||
private readonly fail: boolean,
|
||||
) {
|
||||
microtask2(() => {
|
||||
if (this.fail) {
|
||||
this.error = new Error('fake transaction failure');
|
||||
this.onerror?.(this);
|
||||
this.onabort?.(this);
|
||||
return;
|
||||
}
|
||||
this.oncomplete?.();
|
||||
});
|
||||
}
|
||||
|
||||
objectStore(name: string): FakeObjectStore {
|
||||
if (name !== this.storeName) {
|
||||
throw new Error(`fake idb: transaction opened for "${this.storeName}", not "${name}"`);
|
||||
}
|
||||
return this.database.getOrCreateStore(name);
|
||||
}
|
||||
}
|
||||
|
||||
export class FakeIndexedDb {
|
||||
/** DOMStringList-like: the wrapper calls `.contains()` (browser IDB API). */
|
||||
readonly objectStoreNames = new Set<string>() as Set<string> & { contains(name: string): boolean };
|
||||
|
||||
constructor() {
|
||||
this.objectStoreNames.contains = (name: string) => this.objectStoreNames.has(name);
|
||||
}
|
||||
|
||||
private readonly storeSpecs = new Map<string, string>();
|
||||
private readonly records = new Map<string, Map<string, unknown>>();
|
||||
/** Instrumentation per store: { put, delete } counters. */
|
||||
readonly opCounts = new Map<string, { put: number; delete: number }>();
|
||||
onclose: (() => void) | null = null;
|
||||
onversionchange: (() => void) | null = null;
|
||||
|
||||
createObjectStore(name: string, options?: { keyPath?: string }) {
|
||||
this.objectStoreNames.add(name);
|
||||
this.storeSpecs.set(name, options?.keyPath ?? 'key');
|
||||
return { name };
|
||||
}
|
||||
|
||||
getOrCreateStore(name: string): FakeObjectStore {
|
||||
if (!this.storeSpecs.has(name)) {
|
||||
this.storeSpecs.set(name, 'key');
|
||||
this.objectStoreNames.add(name);
|
||||
}
|
||||
if (!this.records.has(name)) this.records.set(name, new Map());
|
||||
const spec = { name, keyPath: this.storeSpecs.get(name)! };
|
||||
const store = new FakeObjectStore(spec, this.records.get(name)!);
|
||||
const counts = this.opCounts.get(name) ?? { put: 0, delete: 0 };
|
||||
// Aggregate counts lazily via proxy-free bookkeeping on each op.
|
||||
const originalPut = store.put.bind(store);
|
||||
store.put = (value: unknown) => {
|
||||
counts.put += 1;
|
||||
this.opCounts.set(name, counts);
|
||||
return originalPut(value);
|
||||
};
|
||||
const originalDelete = store.delete.bind(store);
|
||||
store.delete = (key: IDBValidKey) => {
|
||||
counts.delete += 1;
|
||||
this.opCounts.set(name, counts);
|
||||
return originalDelete(key);
|
||||
};
|
||||
return store;
|
||||
}
|
||||
|
||||
transaction(storeName: string, _mode?: string): FakeTransaction {
|
||||
return new FakeTransaction(this, storeName, false);
|
||||
}
|
||||
|
||||
close() {
|
||||
this.onclose?.();
|
||||
}
|
||||
|
||||
/** Direct read for assertions (bypasses the async wrapper). */
|
||||
dumpStore(name: string): unknown[] {
|
||||
return Array.from(this.records.get(name)?.values() ?? []);
|
||||
}
|
||||
}
|
||||
|
||||
export interface FakeIndexedDbHandle {
|
||||
db: FakeIndexedDb;
|
||||
openCount: number;
|
||||
restore: () => void;
|
||||
}
|
||||
|
||||
export function installFakeIndexedDb(options: { failOpen?: boolean; failOpenTimes?: number; onOperation?: (storeName: string, op: 'put' | 'delete') => void } = {}): FakeIndexedDbHandle {
|
||||
const previous = (globalThis as { indexedDB?: IDBFactory }).indexedDB;
|
||||
const handle: FakeIndexedDbHandle = {
|
||||
db: new FakeIndexedDb(),
|
||||
openCount: 0,
|
||||
restore: () => {
|
||||
(globalThis as { indexedDB?: IDBFactory }).indexedDB = previous;
|
||||
},
|
||||
};
|
||||
if (options.onOperation) {
|
||||
const onOperation = options.onOperation;
|
||||
const originalGet = handle.db.getOrCreateStore.bind(handle.db);
|
||||
handle.db.getOrCreateStore = (name: string) => {
|
||||
const store = originalGet(name);
|
||||
const originalPut = store.put.bind(store);
|
||||
store.put = (value: unknown) => {
|
||||
onOperation(name, 'put');
|
||||
return originalPut(value);
|
||||
};
|
||||
const originalDelete = store.delete.bind(store);
|
||||
store.delete = (key: IDBValidKey) => {
|
||||
onOperation(name, 'delete');
|
||||
return originalDelete(key);
|
||||
};
|
||||
return store;
|
||||
};
|
||||
}
|
||||
(globalThis as { indexedDB?: unknown }).indexedDB = {
|
||||
open(_name: string, _version: number) {
|
||||
handle.openCount += 1;
|
||||
if (options.failOpen) {
|
||||
return new FakeRequest<IDBDatabase>(null as unknown as IDBDatabase, 'failure', new Error('fake open failure'));
|
||||
}
|
||||
if (options.failOpenTimes != null && handle.openCount <= options.failOpenTimes) {
|
||||
// Fail only the first N opens, then recover (boot-retry scenarios).
|
||||
return new FakeRequest<IDBDatabase>(null as unknown as IDBDatabase, 'failure', new Error('fake open failure'));
|
||||
}
|
||||
return new FakeRequest<IDBDatabase>(handle.db as unknown as IDBDatabase, 'upgrade');
|
||||
},
|
||||
};
|
||||
return handle;
|
||||
}
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
STORAGE_KEY_AI_COMMAND_TIMEOUT,
|
||||
STORAGE_KEY_AI_RESPONSE_IDLE_TIMEOUT,
|
||||
STORAGE_KEY_AI_MAX_ITERATIONS,
|
||||
STORAGE_KEY_AI_SESSION_MESSAGE_CAP,
|
||||
STORAGE_KEY_AI_AGENT_MODEL_MAP,
|
||||
STORAGE_KEY_AI_AGENT_PROVIDER_MAP,
|
||||
STORAGE_KEY_AI_WEB_SEARCH,
|
||||
@@ -34,6 +35,10 @@ import {
|
||||
normalizeResponseIdleTimeoutSeconds,
|
||||
} from '../../infrastructure/ai/types';
|
||||
import { removeProviderReferences } from './aiProviderCleanup';
|
||||
import {
|
||||
DEFAULT_AI_SESSION_MESSAGE_CAP,
|
||||
normalizeAiSessionMessageCap,
|
||||
} from '../../domain/aiSessionMessageCap';
|
||||
import { AI_STATE_CHANGED_EVENT, emitAIStateChanged } from './aiStateEvents';
|
||||
import { getAIBridge } from './aiStateSnapshots';
|
||||
import { useStoredBoolean } from './useStoredBoolean';
|
||||
@@ -87,6 +92,12 @@ export function useAISettingsState() {
|
||||
const [maxIterations, setMaxIterationsRaw] = useState<number>(() =>
|
||||
localStorageAdapter.readNumber(STORAGE_KEY_AI_MAX_ITERATIONS) ?? 20
|
||||
);
|
||||
const [aiSessionMessageCap, setAiSessionMessageCapRaw] = useState<number>(() =>
|
||||
normalizeAiSessionMessageCap(
|
||||
localStorageAdapter.readNumber(STORAGE_KEY_AI_SESSION_MESSAGE_CAP)
|
||||
?? DEFAULT_AI_SESSION_MESSAGE_CAP,
|
||||
)
|
||||
);
|
||||
const [webSearchConfig, setWebSearchConfigRaw] = useState<WebSearchConfig | null>(() =>
|
||||
localStorageAdapter.read<WebSearchConfig>(STORAGE_KEY_AI_WEB_SEARCH) ?? null
|
||||
);
|
||||
@@ -197,6 +208,12 @@ export function useAISettingsState() {
|
||||
getAIBridge()?.aiMcpSetMaxIterations?.(value);
|
||||
}, []);
|
||||
|
||||
const setAiSessionMessageCap = useCallback((value: number) => {
|
||||
const normalizedValue = normalizeAiSessionMessageCap(value);
|
||||
setAiSessionMessageCapRaw(normalizedValue);
|
||||
localStorageAdapter.writeNumber(STORAGE_KEY_AI_SESSION_MESSAGE_CAP, normalizedValue);
|
||||
}, []);
|
||||
|
||||
const setWebSearchConfig = useCallback((config: WebSearchConfig | null) => {
|
||||
setWebSearchConfigRaw(config);
|
||||
if (config) {
|
||||
@@ -277,6 +294,14 @@ export function useAISettingsState() {
|
||||
getAIBridge()?.aiMcpSetMaxIterations?.(iters);
|
||||
break;
|
||||
}
|
||||
case STORAGE_KEY_AI_SESSION_MESSAGE_CAP:
|
||||
setAiSessionMessageCapRaw(
|
||||
normalizeAiSessionMessageCap(
|
||||
localStorageAdapter.readNumber(STORAGE_KEY_AI_SESSION_MESSAGE_CAP)
|
||||
?? DEFAULT_AI_SESSION_MESSAGE_CAP,
|
||||
),
|
||||
);
|
||||
break;
|
||||
case STORAGE_KEY_AI_WEB_SEARCH:
|
||||
setWebSearchConfigRaw(localStorageAdapter.read<WebSearchConfig>(STORAGE_KEY_AI_WEB_SEARCH) ?? null);
|
||||
break;
|
||||
@@ -340,6 +365,8 @@ export function useAISettingsState() {
|
||||
setResponseIdleTimeout,
|
||||
maxIterations,
|
||||
setMaxIterations,
|
||||
aiSessionMessageCap,
|
||||
setAiSessionMessageCap,
|
||||
webSearchConfig,
|
||||
setWebSearchConfig,
|
||||
quickMessages,
|
||||
@@ -373,6 +400,8 @@ export function useAISettingsState() {
|
||||
setResponseIdleTimeout,
|
||||
maxIterations,
|
||||
setMaxIterations,
|
||||
aiSessionMessageCap,
|
||||
setAiSessionMessageCap,
|
||||
webSearchConfig,
|
||||
setWebSearchConfig,
|
||||
quickMessages,
|
||||
|
||||
@@ -0,0 +1,489 @@
|
||||
/**
|
||||
* Boot-scenario regression tests for the IndexedDB migration review fixes
|
||||
* (issue #4125 review): P1 maintenance gating and complete legacy migration,
|
||||
* P2① baseline seeding after an IndexedDB failure,
|
||||
* P2② pointer validation gated on hydration, P2③ disk-backup restore when
|
||||
* IndexedDB stays unopenable, and P2④ adopting an intentional cross-window
|
||||
* deletion.
|
||||
*/
|
||||
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 } from '../../infrastructure/ai/types';
|
||||
import {
|
||||
STORAGE_KEY_AI_ACTIVE_SESSION_MAP,
|
||||
STORAGE_KEY_AI_SESSIONS,
|
||||
STORAGE_KEY_AI_SESSIONS_DISK_OVERFLOW,
|
||||
STORAGE_KEY_AI_SESSIONS_OVERFLOW,
|
||||
} from '../../infrastructure/config/storageKeys';
|
||||
import { useAIState } from './useAIState';
|
||||
import { getAISessionsSnapshot } from './aiSessionsStore';
|
||||
import {
|
||||
__flushIdbWritesForTests,
|
||||
__resetAiSessionsHydrationForTests,
|
||||
__resetBackupMirrorForTests,
|
||||
__resetIdbPersistenceForTests,
|
||||
getPersistedAiSessionsSnapshot,
|
||||
flushBackupMirror,
|
||||
latestAISessionsSnapshot,
|
||||
setLatestAIActiveSessionMapSnapshot,
|
||||
setLatestAIDraftsByScopeSnapshot,
|
||||
setLatestAIPanelViewByScopeSnapshot,
|
||||
setLatestAISessionsSnapshot,
|
||||
} from './aiStateSnapshots';
|
||||
import {
|
||||
idbDeleteSessionIds,
|
||||
AI_SESSIONS_META_MIGRATION_KEY,
|
||||
idbPutSessions,
|
||||
loadOrMigrateAiSessions,
|
||||
openAiSessionsIdb,
|
||||
resetAiSessionsIdbForTests,
|
||||
} from './aiSessionIdbStorage';
|
||||
import { installFakeIndexedDb } from './testSupport/fakeIndexedDb';
|
||||
|
||||
function makeSession(id: string, updatedAt: number): AISession {
|
||||
return {
|
||||
id,
|
||||
title: id,
|
||||
agentId: 'catty',
|
||||
scope: { type: 'global' },
|
||||
messages: [],
|
||||
createdAt: updatedAt,
|
||||
updatedAt,
|
||||
} as AISession;
|
||||
}
|
||||
|
||||
function installDom() {
|
||||
const dom = new JSDOM('<!doctype html><html><body></body></html>', { url: 'http://localhost' });
|
||||
for (const name of ['window', 'document', 'localStorage', 'CustomEvent', 'Event'] as const) {
|
||||
Object.defineProperty(globalThis, name, { configurable: true, value: name === 'window' ? dom.window : dom.window[name] });
|
||||
}
|
||||
Object.defineProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT', { configurable: true, value: true });
|
||||
return dom;
|
||||
}
|
||||
|
||||
interface Mounted {
|
||||
ai: ReturnType<typeof useAIState>;
|
||||
root: ReturnType<typeof createRoot>;
|
||||
container: HTMLDivElement;
|
||||
}
|
||||
|
||||
async function mountAiState(onMaintenance?: (ai: ReturnType<typeof useAIState>) => void): Promise<Mounted> {
|
||||
const holder: { ai?: ReturnType<typeof useAIState> } = {};
|
||||
function Maintenance({ ai }: { ai: ReturnType<typeof useAIState> }) {
|
||||
const mountAi = React.useRef(ai);
|
||||
React.useEffect(() => { onMaintenance?.(mountAi.current); }, []);
|
||||
return null;
|
||||
}
|
||||
function Harness() {
|
||||
holder.ai = useAIState();
|
||||
return onMaintenance ? React.createElement(Maintenance, { ai: holder.ai }) : null;
|
||||
}
|
||||
const container = document.createElement('div');
|
||||
document.body.appendChild(container);
|
||||
const root = createRoot(container);
|
||||
await act(async () => { root.render(React.createElement(Harness)); });
|
||||
return { ai: holder.ai!, root, container };
|
||||
}
|
||||
|
||||
async function unmount(mounted: Mounted, dom: JSDOM) {
|
||||
await act(async () => { mounted.root.unmount(); });
|
||||
mounted.container.remove();
|
||||
dom.window.close();
|
||||
}
|
||||
|
||||
function resetBootState() {
|
||||
// Post-boot defaults first, then back to pending so every test starts
|
||||
// from the pre-hydration state its own hook instance controls.
|
||||
__resetIdbPersistenceForTests();
|
||||
__resetAiSessionsHydrationForTests();
|
||||
// Module-level snapshots leak across tests (the hook's initial state and
|
||||
// the boot merge path read them) — reset to the cold-start state. The
|
||||
// map snapshot must be null so the hook falls back to localStorage.
|
||||
setLatestAISessionsSnapshot([]);
|
||||
setLatestAIActiveSessionMapSnapshot(null as unknown as Record<string, string | null>);
|
||||
setLatestAIDraftsByScopeSnapshot({});
|
||||
setLatestAIPanelViewByScopeSnapshot({});
|
||||
}
|
||||
|
||||
test.beforeEach(() => {
|
||||
__resetBackupMirrorForTests();
|
||||
resetBootState();
|
||||
});
|
||||
test.afterEach(() => { __resetBackupMirrorForTests(); });
|
||||
|
||||
test('P1: deleting the last migrated chat with no legacy key never restores a stale disk backup', async () => {
|
||||
const dom = installDom();
|
||||
const idb = installFakeIndexedDb();
|
||||
const deleted = makeSession('deleted-chat', 1);
|
||||
await loadOrMigrateAiSessions(() => JSON.stringify([deleted]));
|
||||
await idbDeleteSessionIds(await openAiSessionsIdb(), [deleted.id]);
|
||||
let backupReads = 0;
|
||||
const backupWrites: string[] = [];
|
||||
Object.assign(dom.window, {
|
||||
netcatty: {
|
||||
aiSessionsBackupReadLatest: async () => {
|
||||
backupReads += 1;
|
||||
return { ok: true, json: JSON.stringify([deleted]) };
|
||||
},
|
||||
aiSessionsBackupWrite: async ({ json }: { json: string }) => {
|
||||
backupWrites.push(json);
|
||||
return { ok: true };
|
||||
},
|
||||
},
|
||||
});
|
||||
const mounted = await mountAiState();
|
||||
try {
|
||||
flushBackupMirror();
|
||||
await __flushIdbWritesForTests();
|
||||
assert.equal(dom.window.localStorage.getItem(STORAGE_KEY_AI_SESSIONS), null);
|
||||
assert.equal(backupReads, 0);
|
||||
assert.deepEqual(backupWrites, []);
|
||||
assert.deepEqual(getAISessionsSnapshot().sessions, []);
|
||||
assert.deepEqual(idb.db.dumpStore('sessions'), []);
|
||||
} finally {
|
||||
await unmount(mounted, dom);
|
||||
idb.restore();
|
||||
resetAiSessionsIdbForTests();
|
||||
}
|
||||
});
|
||||
|
||||
test('P1: an uninitialized IndexedDB still restores the disk backup', async () => {
|
||||
const dom = installDom();
|
||||
const idb = installFakeIndexedDb();
|
||||
const backup = [makeSession('backup-chat', 1)];
|
||||
Object.assign(dom.window, {
|
||||
netcatty: { aiSessionsBackupReadLatest: async () => ({ ok: true, json: JSON.stringify(backup) }) },
|
||||
});
|
||||
const mounted = await mountAiState();
|
||||
try {
|
||||
await __flushIdbWritesForTests();
|
||||
assert.deepEqual(getAISessionsSnapshot().sessions, backup);
|
||||
assert.deepEqual(idb.db.dumpStore('sessions'), backup);
|
||||
} finally {
|
||||
await unmount(mounted, dom);
|
||||
idb.restore();
|
||||
resetAiSessionsIdbForTests();
|
||||
}
|
||||
});
|
||||
|
||||
for (const failure of ['not-ok', 'rejection', 'missing-id'] as const) {
|
||||
test(`P1: disk overflow ${failure} keeps migration pending and backups intact, then retries beside primary IDB records`, async () => {
|
||||
const dom = installDom();
|
||||
const idb = installFakeIndexedDb();
|
||||
const primary = makeSession('primary-chat', 3);
|
||||
const chunk = makeSession('chunk-chat', 2);
|
||||
const diskOnly = makeSession('disk-only-chat', 1);
|
||||
const unmarked = makeSession('unmarked-deleted-chat', 0);
|
||||
dom.window.localStorage.setItem(STORAGE_KEY_AI_SESSIONS, JSON.stringify([primary]));
|
||||
dom.window.localStorage.setItem(STORAGE_KEY_AI_SESSIONS_OVERFLOW, JSON.stringify([chunk]));
|
||||
dom.window.localStorage.setItem(STORAGE_KEY_AI_SESSIONS_DISK_OVERFLOW, JSON.stringify([diskOnly.id]));
|
||||
let recoveryAvailable = false;
|
||||
let backupReads = 0;
|
||||
const backupWrites: string[] = [];
|
||||
Object.assign(dom.window, {
|
||||
netcatty: {
|
||||
aiSessionsBackupReadLatest: async () => {
|
||||
backupReads += 1;
|
||||
if (recoveryAvailable) return { ok: true, json: JSON.stringify([primary, chunk, diskOnly, unmarked]) };
|
||||
if (failure === 'rejection') throw new Error('disk temporarily unavailable');
|
||||
if (failure === 'missing-id') return { ok: true, json: JSON.stringify([unmarked]) };
|
||||
return { ok: false };
|
||||
},
|
||||
aiSessionsBackupWrite: async ({ json }: { json: string }) => {
|
||||
backupWrites.push(json);
|
||||
return { ok: true };
|
||||
},
|
||||
},
|
||||
});
|
||||
let mounted = await mountAiState();
|
||||
const hasMarker = () => idb.db.dumpStore('meta').some(entry => (entry as { key: string }).key === AI_SESSIONS_META_MIGRATION_KEY);
|
||||
try {
|
||||
assert.equal(backupReads, 1);
|
||||
assert.equal(hasMarker(), false);
|
||||
assert.deepEqual(getAISessionsSnapshot().sessions, [primary, chunk]);
|
||||
assert.deepEqual(idb.db.dumpStore('sessions'), [primary, chunk]);
|
||||
flushBackupMirror();
|
||||
assert.deepEqual(backupWrites, [], 'boot must not mirror a partial migration');
|
||||
|
||||
// Ordinary edits and unload must also preserve the recovery generation.
|
||||
await act(async () => {
|
||||
mounted.ai.addMessageToSession(primary.id, { id: 'new-message', role: 'user', content: 'New edit', timestamp: 4 });
|
||||
});
|
||||
await new Promise(resolve => setTimeout(resolve, 600));
|
||||
await __flushIdbWritesForTests();
|
||||
dom.window.dispatchEvent(new dom.window.Event('beforeunload'));
|
||||
assert.deepEqual(backupWrites, []);
|
||||
const updated = getAISessionsSnapshot().sessions.find(session => session.id === primary.id)!;
|
||||
assert.equal(updated.messages.length, 1);
|
||||
assert.equal(hasMarker(), false);
|
||||
|
||||
await act(async () => { mounted.root.unmount(); });
|
||||
mounted.container.remove();
|
||||
resetBootState();
|
||||
recoveryAvailable = true;
|
||||
mounted = await mountAiState();
|
||||
await __flushIdbWritesForTests();
|
||||
assert.equal(backupReads, 2, 'populated IDB must still retry unresolved disk IDs');
|
||||
assert.equal(hasMarker(), true);
|
||||
const expected = [updated, chunk, diskOnly];
|
||||
assert.deepEqual(getAISessionsSnapshot().sessions, expected);
|
||||
assert.deepEqual(idb.db.dumpStore('sessions'), expected);
|
||||
flushBackupMirror();
|
||||
assert.equal(backupWrites.length, 1, 'mirroring resumes only after all marked IDs are recovered');
|
||||
assert.deepEqual(JSON.parse(backupWrites[0]), expected);
|
||||
} finally {
|
||||
await unmount(mounted, dom);
|
||||
idb.restore();
|
||||
resetAiSessionsIdbForTests();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
test('P1: child mount maintenance waits for hydration and marks the IndexedDB transcript dangling', async () => {
|
||||
const dom = installDom();
|
||||
const idb = installFakeIndexedDb();
|
||||
const stale: AISession = {
|
||||
...makeSession('closed-chat', 1),
|
||||
scope: { type: 'terminal', targetId: 'closed-terminal' },
|
||||
messages: [{ id: 'm-1', role: 'user', content: 'Old history', timestamp: 1 }],
|
||||
};
|
||||
const current: AISession = {
|
||||
...stale,
|
||||
updatedAt: 2,
|
||||
messages: [...stale.messages, { id: 'm-2', role: 'assistant', content: 'New history', timestamp: 2 }],
|
||||
};
|
||||
dom.window.localStorage.setItem(STORAGE_KEY_AI_SESSIONS, JSON.stringify([stale]));
|
||||
await idbPutSessions(await openAiSessionsIdb(), [current]);
|
||||
setLatestAISessionsSnapshot(null as unknown as AISession[]);
|
||||
let snapshotAfterMaintenance: AISession[] | null | undefined;
|
||||
let putsAfterMaintenance = 0;
|
||||
const mounted = await mountAiState((ai) => {
|
||||
ai.cleanupOrphanedSessions(new Set(['live-terminal']));
|
||||
snapshotAfterMaintenance = latestAISessionsSnapshot;
|
||||
putsAfterMaintenance = idb.db.opCounts.get('sessions')!.put;
|
||||
});
|
||||
try {
|
||||
assert.equal(snapshotAfterMaintenance, null, 'pending maintenance must not adopt the legacy transcript');
|
||||
assert.equal(putsAfterMaintenance, 1, 'pending maintenance must not persist stale history');
|
||||
await __flushIdbWritesForTests();
|
||||
const expected = [{ ...current, scopeDangling: true }];
|
||||
assert.deepEqual(getAISessionsSnapshot().sessions, expected);
|
||||
assert.deepEqual(idb.db.dumpStore('sessions'), expected);
|
||||
assert.equal(dom.window.localStorage.getItem(STORAGE_KEY_AI_SESSIONS), JSON.stringify([stale]));
|
||||
} finally {
|
||||
await unmount(mounted, dom);
|
||||
idb.restore();
|
||||
resetAiSessionsIdbForTests();
|
||||
}
|
||||
});
|
||||
|
||||
test('P1: migration includes chunked overflow alongside a populated primary', async () => {
|
||||
const dom = installDom();
|
||||
const idb = installFakeIndexedDb();
|
||||
const primary = makeSession('primary-chat', 3);
|
||||
const overflow = [makeSession('overflow-a', 2), makeSession('overflow-b', 1), { ...primary, updatedAt: 0 }];
|
||||
const overflowJson = JSON.stringify(overflow);
|
||||
const split = Math.floor(overflowJson.length / 2);
|
||||
dom.window.localStorage.setItem(STORAGE_KEY_AI_SESSIONS, JSON.stringify([primary]));
|
||||
dom.window.localStorage.setItem(STORAGE_KEY_AI_SESSIONS_OVERFLOW, overflowJson.slice(0, split));
|
||||
dom.window.localStorage.setItem(`${STORAGE_KEY_AI_SESSIONS_OVERFLOW}_2`, overflowJson.slice(split));
|
||||
const mounted = await mountAiState();
|
||||
try {
|
||||
const expected = [primary, ...overflow.slice(0, 2)];
|
||||
assert.deepEqual(getAISessionsSnapshot().sessions, expected);
|
||||
assert.deepEqual(idb.db.dumpStore('sessions'), expected);
|
||||
assert.ok(idb.db.dumpStore('meta').some((entry) => (entry as { key: string }).key === AI_SESSIONS_META_MIGRATION_KEY));
|
||||
assert.equal(dom.window.localStorage.getItem(STORAGE_KEY_AI_SESSIONS_OVERFLOW), overflowJson.slice(0, split));
|
||||
assert.equal(dom.window.localStorage.getItem(`${STORAGE_KEY_AI_SESSIONS_OVERFLOW}_2`), overflowJson.slice(split));
|
||||
} finally {
|
||||
await unmount(mounted, dom);
|
||||
idb.restore();
|
||||
resetAiSessionsIdbForTests();
|
||||
}
|
||||
});
|
||||
|
||||
test('P1: migration awaits disk-only recovery before recording completion beside a populated primary', async () => {
|
||||
const dom = installDom();
|
||||
const idb = installFakeIndexedDb();
|
||||
const primary = makeSession('primary-chat', 3);
|
||||
const diskOnly = makeSession('disk-only-chat', 2);
|
||||
const deleted = makeSession('deleted-chat', 1);
|
||||
dom.window.localStorage.setItem(STORAGE_KEY_AI_SESSIONS, JSON.stringify([primary]));
|
||||
dom.window.localStorage.setItem(STORAGE_KEY_AI_SESSIONS_DISK_OVERFLOW, JSON.stringify([diskOnly.id]));
|
||||
let resolveBackup!: (result: { ok: boolean; json: string }) => void;
|
||||
const backup = new Promise<{ ok: boolean; json: string }>((resolve) => { resolveBackup = resolve; });
|
||||
let backupReads = 0;
|
||||
Object.assign(dom.window, {
|
||||
netcatty: { aiSessionsBackupReadLatest: () => { backupReads += 1; return backup; } },
|
||||
});
|
||||
const mounted = await mountAiState();
|
||||
try {
|
||||
assert.equal(backupReads, 1);
|
||||
assert.equal(idb.db.dumpStore('meta').some((entry) => (entry as { key: string }).key === AI_SESSIONS_META_MIGRATION_KEY), false);
|
||||
await act(async () => {
|
||||
resolveBackup({ ok: true, json: JSON.stringify([primary, diskOnly, deleted]) });
|
||||
});
|
||||
assert.deepEqual(getAISessionsSnapshot().sessions, [primary, diskOnly]);
|
||||
assert.deepEqual(idb.db.dumpStore('sessions'), [primary, diskOnly]);
|
||||
assert.ok(idb.db.dumpStore('meta').some((entry) => (entry as { key: string }).key === AI_SESSIONS_META_MIGRATION_KEY));
|
||||
assert.equal(dom.window.localStorage.getItem(STORAGE_KEY_AI_SESSIONS_DISK_OVERFLOW), JSON.stringify([diskOnly.id]));
|
||||
} finally {
|
||||
await unmount(mounted, dom);
|
||||
idb.restore();
|
||||
resetAiSessionsIdbForTests();
|
||||
}
|
||||
});
|
||||
|
||||
// ── P2①: idbFailed boot must persist the legacy set before seeding the baseline ──
|
||||
|
||||
test('P2①a: after a transient IndexedDB open failure the legacy sessions are persisted and the baseline seeded', async () => {
|
||||
const dom = installDom();
|
||||
const idb = installFakeIndexedDb({ failOpenTimes: 1 });
|
||||
const legacy = [makeSession('chat-a', 2), makeSession('chat-b', 1)];
|
||||
dom.window.localStorage.setItem(STORAGE_KEY_AI_SESSIONS, JSON.stringify(legacy));
|
||||
const mounted = await mountAiState();
|
||||
try {
|
||||
// One open retry happened inside the boot effect: the legacy set now
|
||||
// lives in IndexedDB (old code never retried and left the store empty).
|
||||
const ids = idb.db.dumpStore('sessions').map((s) => (s as AISession).id).sort();
|
||||
assert.deepEqual(ids, ['chat-a', 'chat-b']);
|
||||
assert.deepEqual(
|
||||
getPersistedAiSessionsSnapshot().map((s) => s.id).sort(),
|
||||
['chat-a', 'chat-b'],
|
||||
);
|
||||
assert.deepEqual(getAISessionsSnapshot().sessions.map((s) => s.id).sort(), ['chat-a', 'chat-b']);
|
||||
} finally {
|
||||
await unmount(mounted, dom);
|
||||
idb.restore();
|
||||
resetAiSessionsIdbForTests();
|
||||
}
|
||||
});
|
||||
|
||||
test('P2①b: with IndexedDB unreachable the baseline stays unset, so a later edit rewrites every record', async () => {
|
||||
const dom = installDom();
|
||||
const failing = installFakeIndexedDb({ failOpen: true });
|
||||
const legacy = [makeSession('chat-a', 2), makeSession('chat-b', 1)];
|
||||
dom.window.localStorage.setItem(STORAGE_KEY_AI_SESSIONS, JSON.stringify(legacy));
|
||||
const mounted = await mountAiState();
|
||||
let recovered: ReturnType<typeof installFakeIndexedDb> | null = null;
|
||||
try {
|
||||
// The retry open also failed: nothing was persisted, and the diff
|
||||
// baseline must remain unset.
|
||||
assert.deepEqual(failing.db.dumpStore('sessions'), []);
|
||||
|
||||
// IndexedDB recovers, then the user edits ONE session. The full set
|
||||
// must reach the store, not only the edited record.
|
||||
failing.restore();
|
||||
resetAiSessionsIdbForTests();
|
||||
recovered = installFakeIndexedDb();
|
||||
await act(async () => {
|
||||
mounted.ai.addMessageToSession('chat-a', { id: 'm-1', role: 'user', content: 'Edited', timestamp: 3 });
|
||||
});
|
||||
await new Promise((resolve) => setTimeout(resolve, 600)); // debounced persist
|
||||
await act(async () => {});
|
||||
await __flushIdbWritesForTests();
|
||||
const ids = recovered.db.dumpStore('sessions').map((s) => (s as AISession).id).sort();
|
||||
assert.deepEqual(ids, ['chat-a', 'chat-b']);
|
||||
} finally {
|
||||
await unmount(mounted, dom);
|
||||
if (recovered) recovered.restore();
|
||||
resetAiSessionsIdbForTests();
|
||||
}
|
||||
});
|
||||
|
||||
// ── P2②: pointer validation waits for hydration ──
|
||||
|
||||
test('P2②: pointers survive cold mount before hydration and stay valid after the boot load', async () => {
|
||||
const dom = installDom();
|
||||
const source = makeSession('chat-1', 1);
|
||||
dom.window.localStorage.setItem(STORAGE_KEY_AI_SESSIONS, JSON.stringify([source]));
|
||||
dom.window.localStorage.setItem(
|
||||
STORAGE_KEY_AI_ACTIVE_SESSION_MAP,
|
||||
JSON.stringify({ 'terminal:t1': 'chat-1' }),
|
||||
);
|
||||
const mounted = await mountAiState();
|
||||
try {
|
||||
// Old code nulled the pointer here: the validation effect ran against
|
||||
// the initially-empty in-memory session array.
|
||||
assert.equal(getAISessionsSnapshot().activeSessionIdMap['terminal:t1'], 'chat-1');
|
||||
assert.equal(
|
||||
JSON.parse(dom.window.localStorage.getItem(STORAGE_KEY_AI_ACTIVE_SESSION_MAP)!)['terminal:t1'],
|
||||
'chat-1',
|
||||
);
|
||||
// After hydration the session is restored and the pointer is intact.
|
||||
await act(async () => {});
|
||||
assert.deepEqual(getAISessionsSnapshot().sessions.map((s) => s.id), ['chat-1']);
|
||||
assert.equal(getAISessionsSnapshot().activeSessionIdMap['terminal:t1'], 'chat-1');
|
||||
} finally {
|
||||
await unmount(mounted, dom);
|
||||
}
|
||||
});
|
||||
|
||||
// ── P2③: unopenable IndexedDB + corrupt legacy → disk backup is applied ──
|
||||
|
||||
test('P2③: with IndexedDB unopenable and the legacy store corrupt the newest disk backup generation is applied', async () => {
|
||||
const dom = installDom();
|
||||
installFakeIndexedDb({ failOpen: true });
|
||||
dom.window.localStorage.setItem(STORAGE_KEY_AI_SESSIONS, 'not-json'); // corrupt legacy
|
||||
const backup = [makeSession('backup-chat', 5)];
|
||||
const warns: unknown[][] = [];
|
||||
const originalWarn = console.warn;
|
||||
console.warn = (...args: unknown[]) => { warns.push(args); };
|
||||
try {
|
||||
Object.assign(dom.window, {
|
||||
netcatty: { aiSessionsBackupReadLatest: async () => ({ ok: true, json: JSON.stringify(backup) }) },
|
||||
});
|
||||
const mounted = await mountAiState();
|
||||
try {
|
||||
await act(async () => {});
|
||||
// The backup generation became the recovery source (old code: the
|
||||
// failing IndexedDB recheck rejection was swallowed and the restore
|
||||
// never ran).
|
||||
assert.deepEqual(getAISessionsSnapshot().sessions.map((s) => s.id), ['backup-chat']);
|
||||
assert.deepEqual(getPersistedAiSessionsSnapshot().map((s) => s.id), ['backup-chat']);
|
||||
// The unopenable-store error was surfaced, not swallowed.
|
||||
assert.ok(warns.some((args) => String(args[0]).includes('IndexedDB recheck failed')));
|
||||
} finally {
|
||||
await unmount(mounted, dom);
|
||||
}
|
||||
} finally {
|
||||
console.warn = originalWarn;
|
||||
delete (dom.window as unknown as { netcatty?: unknown }).netcatty;
|
||||
resetAiSessionsIdbForTests();
|
||||
}
|
||||
});
|
||||
|
||||
// ── P2④: an empty getAll after hydration is an intentional deletion ──
|
||||
|
||||
test('P2④: adopting an empty IndexedDB store after another window deleted the last chat', async () => {
|
||||
const dom = installDom();
|
||||
const idb = installFakeIndexedDb();
|
||||
const source = makeSession('chat-1', 1);
|
||||
await idbPutSessions(await openAiSessionsIdb(), [source]);
|
||||
const mounted = await mountAiState();
|
||||
try {
|
||||
await act(async () => {});
|
||||
assert.deepEqual(getAISessionsSnapshot().sessions.map((s) => s.id), ['chat-1']);
|
||||
// Another window deletes the last chat and announces the change. The
|
||||
// announcement must come from a second BroadcastChannel: a channel never
|
||||
// delivers to itself (the module-level channel is the receiving window).
|
||||
await idbDeleteSessionIds(await openAiSessionsIdb(), ['chat-1']);
|
||||
const otherWindow = new BroadcastChannel('netcatty_ai_sessions_changed');
|
||||
await act(async () => {
|
||||
otherWindow.postMessage(Date.now());
|
||||
await new Promise((resolve) => setTimeout(resolve, 25));
|
||||
});
|
||||
otherWindow.close();
|
||||
// Old code early-returned on the empty getAll and kept the deleted
|
||||
// session alive locally.
|
||||
assert.deepEqual(getAISessionsSnapshot().sessions, []);
|
||||
assert.deepEqual(getPersistedAiSessionsSnapshot(), []);
|
||||
} finally {
|
||||
await unmount(mounted, dom);
|
||||
idb.restore();
|
||||
resetAiSessionsIdbForTests();
|
||||
}
|
||||
});
|
||||
@@ -8,7 +8,7 @@ import type { AISession } 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';
|
||||
import { getPersistedAiSessionsSnapshot, setLatestAISessionsSnapshot } from './aiStateSnapshots.ts';
|
||||
|
||||
const SOURCE_SESSION: AISession = {
|
||||
id: 'chat-source',
|
||||
@@ -121,11 +121,18 @@ for (const newerCount of [49, 50]) {
|
||||
let branch: AISession | null = null;
|
||||
await act(async () => { branch = await capture.ai!.forkSessionFromMessage(SOURCE_SESSION.id, 'assistant-1'); });
|
||||
assert.ok(branch);
|
||||
const saved: AISession[] = JSON.parse(dom.window.localStorage.getItem('netcatty_ai_sessions_v1')!);
|
||||
assert.equal(saved.length, 50);
|
||||
// Issue #4123: the persisted set lives in IndexedDB (count cap 1000, so
|
||||
// nothing is evicted at these sizes) — not in a 50-capped localStorage
|
||||
// blob.
|
||||
const saved: AISession[] = getPersistedAiSessionsSnapshot();
|
||||
assert.equal(saved.length, newerCount + 2);
|
||||
assert.deepEqual(saved.find(item => item.id === SOURCE_SESSION.id)?.messages, SOURCE_SESSION.messages);
|
||||
assert.deepEqual(saved.find(item => item.id === branch!.id)?.messages, SOURCE_SESSION.messages.slice(0, 2));
|
||||
await act(async () => root.unmount()); container.remove(); dom.window.close();
|
||||
await act(async () => root.unmount()); container.remove();
|
||||
// Reload from the read-only legacy store (the stand-in for what the
|
||||
// migration persists on a profile without IndexedDB).
|
||||
dom.window.localStorage.setItem('netcatty_ai_sessions_v1', JSON.stringify(saved));
|
||||
dom.window.close();
|
||||
const reloaded = await setupAiState(saved);
|
||||
assert.equal(reloaded.capture.sessions.find(item => item.id === SOURCE_SESSION.id)?.messages.length, 4);
|
||||
assert.equal(reloaded.capture.sessions.find(item => item.id === branch!.id)?.messages.length, 2);
|
||||
|
||||
@@ -4,10 +4,10 @@ import React, { act } from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import { JSDOM } from 'jsdom';
|
||||
import type { AISession } from '../../infrastructure/ai/types';
|
||||
import { STORAGE_KEY_AI_ACTIVE_SESSION_MAP, STORAGE_KEY_AI_SESSIONS, STORAGE_KEY_AI_SESSIONS_OVERFLOW, STORAGE_KEY_AI_SESSIONS_DISK_OVERFLOW, STORAGE_KEY_CLOUD_SYNC_AI_SESSIONS } from '../../infrastructure/config/storageKeys';
|
||||
import { STORAGE_KEY_AI_SESSIONS, STORAGE_KEY_CLOUD_SYNC_AI_SESSIONS } from '../../infrastructure/config/storageKeys';
|
||||
import { useAIState } from './useAIState';
|
||||
import { useAISessionsStore } from './aiSessionsStore';
|
||||
import { __resetBackupMirrorForTests, flushBackupMirror, readSessionsFromStorage, setLatestAIActiveSessionMapSnapshot, setLatestAISessionsSnapshot, writeSessionsForStorage } from './aiStateSnapshots';
|
||||
import { setLatestAISessionsSnapshot } from './aiStateSnapshots';
|
||||
|
||||
for (const enabled of [true, false]) {
|
||||
test(`appending to long history preserves the synced prefix (chat sync ${enabled})`, async () => {
|
||||
@@ -54,403 +54,3 @@ for (const enabled of [true, false]) {
|
||||
});
|
||||
}
|
||||
|
||||
test('startup and cross-window updates load overflow chats and mirror the merged history', async () => {
|
||||
const dom = new JSDOM('<!doctype html><html><body></body></html>', { url: 'http://localhost' });
|
||||
for (const name of ['window', 'document', 'localStorage', 'CustomEvent', 'Event'] as const) {
|
||||
Object.defineProperty(globalThis, name, { configurable: true, value: name === 'window' ? dom.window : dom.window[name] });
|
||||
}
|
||||
Object.defineProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT', { configurable: true, value: true });
|
||||
const primary: AISession = {
|
||||
id: 'primary', title: 'Primary', agentId: 'catty', scope: { type: 'global' },
|
||||
createdAt: 1, updatedAt: 2, messages: [],
|
||||
};
|
||||
const overflow = { ...primary, id: 'overflow', title: 'Overflow', updatedAt: 1 };
|
||||
dom.window.localStorage.setItem(STORAGE_KEY_AI_SESSIONS, JSON.stringify([primary]));
|
||||
dom.window.localStorage.setItem(STORAGE_KEY_AI_SESSIONS_OVERFLOW, JSON.stringify([overflow]));
|
||||
setLatestAISessionsSnapshot(null as unknown as AISession[]);
|
||||
__resetBackupMirrorForTests();
|
||||
const backups: string[] = [];
|
||||
Object.assign(dom.window, { netcatty: { aiSessionsBackupWrite: async ({ json }: { json: string }) => {
|
||||
backups.push(json);
|
||||
return { ok: true };
|
||||
} } });
|
||||
let sessions: readonly AISession[] = [];
|
||||
function Harness() {
|
||||
useAIState();
|
||||
sessions = useAISessionsStore().sessions;
|
||||
return null;
|
||||
}
|
||||
const container = document.createElement('div');
|
||||
document.body.appendChild(container);
|
||||
const root = createRoot(container);
|
||||
try {
|
||||
await act(async () => { root.render(React.createElement(Harness)); });
|
||||
assert.deepEqual(sessions, [primary, overflow]);
|
||||
flushBackupMirror();
|
||||
assert.deepEqual(JSON.parse(backups[0]), [primary, overflow]);
|
||||
const updatedOverflow = { ...overflow, title: 'Updated elsewhere', updatedAt: 3 };
|
||||
await act(async () => {
|
||||
dom.window.localStorage.setItem(STORAGE_KEY_AI_SESSIONS_OVERFLOW, JSON.stringify([updatedOverflow]));
|
||||
dom.window.dispatchEvent(new dom.window.StorageEvent('storage', { key: STORAGE_KEY_AI_SESSIONS_OVERFLOW }));
|
||||
});
|
||||
assert.deepEqual(sessions, [updatedOverflow, primary]);
|
||||
} finally {
|
||||
await act(async () => { root.unmount(); });
|
||||
__resetBackupMirrorForTests();
|
||||
setLatestAISessionsSnapshot([]);
|
||||
container.remove();
|
||||
dom.window.close();
|
||||
}
|
||||
});
|
||||
|
||||
for (const scopeType of ['global', 'terminal'] as const) {
|
||||
test(`import retains disk-only chats in live history across rename and deletion (${scopeType})`, async () => {
|
||||
const dom = new JSDOM('<!doctype html><html><body></body></html>', {
|
||||
url: 'http://localhost', storageQuota: 5 * 1024 * 1024,
|
||||
});
|
||||
const previousDOMException = globalThis.DOMException;
|
||||
for (const name of ['window', 'document', 'localStorage', 'CustomEvent', 'Event', 'DOMException'] as const) {
|
||||
Object.defineProperty(globalThis, name, { configurable: true, value: name === 'window' ? dom.window : dom.window[name] });
|
||||
}
|
||||
Object.defineProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT', { configurable: true, value: true });
|
||||
const source: AISession[] = Array.from({ length: 6 }, (_, index) => ({
|
||||
id: `imported-${index}`, title: `Imported ${index}`, agentId: 'catty',
|
||||
scope: scopeType === 'global' ? { type: 'global' } : { type: 'terminal', targetId: 'closed-terminal' },
|
||||
createdAt: 1, updatedAt: 6 - index,
|
||||
messages: [{ id: 'm', role: 'user', content: 'x'.repeat(Math.floor(1.1 * 1024 * 1024)), timestamp: 1 }],
|
||||
}));
|
||||
const ids = source.map(session => session.id);
|
||||
const backups: string[] = [];
|
||||
Object.assign(dom.window, { netcatty: {
|
||||
aiSessionsBackupImport: async () => ({ success: true, json: JSON.stringify(source) }),
|
||||
aiSessionsBackupWrite: async ({ json }: { json: string }) => { backups.push(json); return { ok: true }; },
|
||||
} });
|
||||
dom.window.localStorage.setItem(STORAGE_KEY_AI_SESSIONS, '[]');
|
||||
setLatestAISessionsSnapshot([]);
|
||||
setLatestAIActiveSessionMapSnapshot({});
|
||||
__resetBackupMirrorForTests();
|
||||
let ai!: ReturnType<typeof useAIState>;
|
||||
let sessions: readonly AISession[] = [];
|
||||
function Harness() {
|
||||
ai = useAIState();
|
||||
sessions = useAISessionsStore().sessions;
|
||||
return null;
|
||||
}
|
||||
const container = document.createElement('div');
|
||||
document.body.appendChild(container);
|
||||
const root = createRoot(container);
|
||||
try {
|
||||
await act(async () => { root.render(React.createElement(Harness)); });
|
||||
await act(async () => { ai.cleanupOrphanedSessions(new Set()); });
|
||||
let importedCount = 0;
|
||||
await act(async () => { importedCount = await ai.importSessionsBackup(); });
|
||||
assert.equal(importedCount, source.length);
|
||||
assert.deepEqual(sessions.map(session => session.id), ids);
|
||||
assert.deepEqual(readSessionsFromStorage().map(session => session.id), [ids[0]]);
|
||||
assert.deepEqual(JSON.parse(dom.window.localStorage.getItem(STORAGE_KEY_AI_SESSIONS_DISK_OVERFLOW)!), ids.slice(1));
|
||||
assert.equal(sessions.every(session => !!session.scopeDangling === (scopeType === 'terminal')), true);
|
||||
flushBackupMirror();
|
||||
assert.deepEqual(JSON.parse(backups.at(-1)!), sessions);
|
||||
|
||||
await act(async () => { ai.updateSessionTitle(ids[0], 'Renamed after import'); });
|
||||
assert.deepEqual(sessions.map(session => session.id), ids);
|
||||
assert.equal(sessions[0].title, 'Renamed after import');
|
||||
assert.deepEqual(JSON.parse(dom.window.localStorage.getItem(STORAGE_KEY_AI_SESSIONS_DISK_OVERFLOW)!), ids.slice(1));
|
||||
flushBackupMirror();
|
||||
assert.deepEqual(JSON.parse(backups.at(-1)!), sessions);
|
||||
|
||||
await act(async () => { ai.deleteSession(ids[5]); });
|
||||
assert.deepEqual(sessions.map(session => session.id), ids.slice(0, 5));
|
||||
assert.deepEqual(JSON.parse(dom.window.localStorage.getItem(STORAGE_KEY_AI_SESSIONS_DISK_OVERFLOW)!), ids.slice(1, 5));
|
||||
flushBackupMirror();
|
||||
assert.deepEqual(JSON.parse(backups.at(-1)!), sessions);
|
||||
} finally {
|
||||
await act(async () => { root.unmount(); });
|
||||
__resetBackupMirrorForTests();
|
||||
setLatestAISessionsSnapshot([]);
|
||||
setLatestAIActiveSessionMapSnapshot({});
|
||||
container.remove();
|
||||
dom.window.close();
|
||||
Object.defineProperty(globalThis, 'DOMException', { configurable: true, value: previousDOMException });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
test('startup recovers disk-only chats before mirroring a valid partial primary', async () => {
|
||||
const dom = new JSDOM('<!doctype html><html><body></body></html>', {
|
||||
url: 'http://localhost', storageQuota: 5 * 1024 * 1024,
|
||||
});
|
||||
const previousDOMException = globalThis.DOMException;
|
||||
for (const name of ['window', 'document', 'localStorage', 'CustomEvent', 'Event', 'DOMException'] as const) {
|
||||
Object.defineProperty(globalThis, name, { configurable: true, value: name === 'window' ? dom.window : dom.window[name] });
|
||||
}
|
||||
Object.defineProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT', { configurable: true, value: true });
|
||||
const source: AISession[] = Array.from({ length: 6 }, (_, index) => ({
|
||||
id: `chat-${index}`, title: `Chat ${index}`, agentId: 'catty', scope: { type: 'global' },
|
||||
createdAt: 1, updatedAt: 6 - index,
|
||||
messages: [{ id: 'm', role: 'user', content: 'x'.repeat(Math.floor(1.1 * 1024 * 1024)), timestamp: 1 }],
|
||||
}));
|
||||
const backups: string[] = [];
|
||||
let reads = 0;
|
||||
let resolveRead!: (backup: { ok: boolean; json: string }) => void;
|
||||
Object.assign(dom.window, { netcatty: {
|
||||
aiSessionsBackupWrite: async ({ json }: { json: string }) => { backups.push(json); return { ok: true }; },
|
||||
aiSessionsBackupReadLatest: () => {
|
||||
reads += 1;
|
||||
return new Promise(resolve => { resolveRead = resolve; });
|
||||
},
|
||||
} });
|
||||
__resetBackupMirrorForTests();
|
||||
assert.equal(writeSessionsForStorage(source), true);
|
||||
flushBackupMirror();
|
||||
assert.deepEqual(JSON.parse(backups[0]), source);
|
||||
assert.deepEqual(readSessionsFromStorage(), [source[0]]);
|
||||
assert.deepEqual(JSON.parse(dom.window.localStorage.getItem(STORAGE_KEY_AI_SESSIONS_DISK_OVERFLOW)!), source.slice(1).map(session => session.id));
|
||||
// A newer local copy must win, and an old deleted chat in the backup must
|
||||
// stay deleted: only the explicitly recorded disk-only IDs are recovered.
|
||||
const primary = { ...source[0], title: 'Newer primary', updatedAt: 10 };
|
||||
dom.window.localStorage.setItem(STORAGE_KEY_AI_SESSIONS, JSON.stringify([primary]));
|
||||
const expected = [primary, ...source.slice(1)];
|
||||
const staleBackup = JSON.stringify([...source, { ...source[5], id: 'deleted-chat' }]);
|
||||
backups.length = 0;
|
||||
__resetBackupMirrorForTests();
|
||||
setLatestAISessionsSnapshot(null as unknown as AISession[]);
|
||||
let sessions: readonly AISession[] = [];
|
||||
function Harness() {
|
||||
useAIState();
|
||||
sessions = useAISessionsStore().sessions;
|
||||
return null;
|
||||
}
|
||||
const container = document.createElement('div');
|
||||
document.body.appendChild(container);
|
||||
const root = createRoot(container);
|
||||
try {
|
||||
await act(async () => { root.render(React.createElement(Harness)); });
|
||||
assert.equal(reads, 1);
|
||||
flushBackupMirror();
|
||||
assert.deepEqual(backups, []);
|
||||
await act(async () => { resolveRead({ ok: true, json: staleBackup }); });
|
||||
assert.deepEqual(sessions, expected);
|
||||
flushBackupMirror();
|
||||
assert.deepEqual(JSON.parse(backups.at(-1)!), expected);
|
||||
assert.deepEqual(readSessionsFromStorage(), [primary]);
|
||||
} finally {
|
||||
await act(async () => { root.unmount(); });
|
||||
__resetBackupMirrorForTests();
|
||||
setLatestAISessionsSnapshot([]);
|
||||
container.remove();
|
||||
dom.window.close();
|
||||
Object.defineProperty(globalThis, 'DOMException', { configurable: true, value: previousDOMException });
|
||||
}
|
||||
});
|
||||
|
||||
for (const scopeType of ['terminal', 'workspace'] as const) {
|
||||
test(`startup orphan cleanup preserves pending disk-only recovery (${scopeType})`, async () => {
|
||||
const dom = new JSDOM('<!doctype html><html><body></body></html>', { url: 'http://localhost' });
|
||||
for (const name of ['window', 'document', 'localStorage', 'CustomEvent', 'Event'] as const) {
|
||||
Object.defineProperty(globalThis, name, { configurable: true, value: name === 'window' ? dom.window : dom.window[name] });
|
||||
}
|
||||
Object.defineProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT', { configurable: true, value: true });
|
||||
const primary: AISession = {
|
||||
id: 'closed-primary', title: 'Closed primary', agentId: 'catty',
|
||||
scope: { type: scopeType, targetId: 'closed-target' },
|
||||
createdAt: 1, updatedAt: 2, messages: [],
|
||||
};
|
||||
const diskOnly: AISession = {
|
||||
...primary, id: 'disk-only', title: 'Disk only', updatedAt: 1,
|
||||
scope: { type: scopeType, targetId: 'another-closed-target' },
|
||||
};
|
||||
dom.window.localStorage.setItem(STORAGE_KEY_AI_SESSIONS, JSON.stringify([primary]));
|
||||
dom.window.localStorage.setItem(STORAGE_KEY_AI_SESSIONS_DISK_OVERFLOW, JSON.stringify([diskOnly.id]));
|
||||
setLatestAISessionsSnapshot(null as unknown as AISession[]);
|
||||
__resetBackupMirrorForTests();
|
||||
const backups: string[] = [];
|
||||
let reads = 0;
|
||||
let resolveRead!: (backup: { ok: boolean; json: string }) => void;
|
||||
Object.assign(dom.window, { netcatty: {
|
||||
aiSessionsBackupWrite: async ({ json }: { json: string }) => { backups.push(json); return { ok: true }; },
|
||||
aiSessionsBackupReadLatest: () => {
|
||||
reads += 1;
|
||||
return new Promise(resolve => { resolveRead = resolve; });
|
||||
},
|
||||
} });
|
||||
let sessions: readonly AISession[] = [];
|
||||
function Harness() {
|
||||
const ai = useAIState();
|
||||
sessions = useAISessionsStore().sessions;
|
||||
// TerminalLayerSupport performs this cleanup on mount, while the disk
|
||||
// backup IPC read can still be pending.
|
||||
React.useEffect(() => { ai.cleanupOrphanedSessions(new Set()); }, [ai.cleanupOrphanedSessions]);
|
||||
return null;
|
||||
}
|
||||
const container = document.createElement('div');
|
||||
document.body.appendChild(container);
|
||||
const root = createRoot(container);
|
||||
try {
|
||||
await act(async () => { root.render(React.createElement(Harness)); });
|
||||
assert.equal(reads, 1);
|
||||
assert.deepEqual(sessions, [{ ...primary, scopeDangling: true }]);
|
||||
assert.deepEqual(JSON.parse(dom.window.localStorage.getItem(STORAGE_KEY_AI_SESSIONS_DISK_OVERFLOW)!), [diskOnly.id]);
|
||||
flushBackupMirror();
|
||||
assert.deepEqual(backups, []);
|
||||
|
||||
await act(async () => { resolveRead({ ok: true, json: JSON.stringify([primary, diskOnly]) }); });
|
||||
const expected = [primary, diskOnly].map(session => ({ ...session, scopeDangling: true }));
|
||||
assert.deepEqual(sessions, expected);
|
||||
assert.deepEqual(readSessionsFromStorage(), expected);
|
||||
assert.equal(dom.window.localStorage.getItem(STORAGE_KEY_AI_SESSIONS_DISK_OVERFLOW), null);
|
||||
flushBackupMirror();
|
||||
assert.deepEqual(JSON.parse(backups.at(-1)!), expected);
|
||||
} finally {
|
||||
await act(async () => { root.unmount(); });
|
||||
__resetBackupMirrorForTests();
|
||||
setLatestAISessionsSnapshot([]);
|
||||
container.remove();
|
||||
dom.window.close();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
for (const mutation of ['rename', 'create', 'delete', 'delete-pending'] as const) {
|
||||
test(`live ${mutation} preserves intended history across pending disk recovery`, async () => {
|
||||
const dom = new JSDOM('<!doctype html><html><body></body></html>', { url: 'http://localhost' });
|
||||
for (const name of ['window', 'document', 'localStorage', 'CustomEvent', 'Event'] as const) {
|
||||
Object.defineProperty(globalThis, name, { configurable: true, value: name === 'window' ? dom.window : dom.window[name] });
|
||||
}
|
||||
Object.defineProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT', { configurable: true, value: true });
|
||||
const primary: AISession = {
|
||||
id: 'primary', title: 'Primary', agentId: 'catty', scope: { type: 'global' },
|
||||
createdAt: 1, updatedAt: 2, messages: [],
|
||||
};
|
||||
const diskOnly = { ...primary, id: 'disk-only', updatedAt: 1 };
|
||||
dom.window.localStorage.setItem(STORAGE_KEY_AI_SESSIONS, JSON.stringify([primary]));
|
||||
dom.window.localStorage.setItem(STORAGE_KEY_AI_SESSIONS_DISK_OVERFLOW, JSON.stringify([diskOnly.id]));
|
||||
dom.window.localStorage.setItem(STORAGE_KEY_AI_ACTIVE_SESSION_MAP, JSON.stringify({ 'global:': diskOnly.id }));
|
||||
setLatestAIActiveSessionMapSnapshot({ 'global:': diskOnly.id });
|
||||
setLatestAISessionsSnapshot(null as unknown as AISession[]);
|
||||
__resetBackupMirrorForTests();
|
||||
const backups: string[] = [];
|
||||
let resolveRead!: (backup: { ok: boolean; json: string }) => void;
|
||||
Object.assign(dom.window, { netcatty: {
|
||||
aiSessionsBackupWrite: async ({ json }: { json: string }) => { backups.push(json); return { ok: true }; },
|
||||
aiSessionsBackupReadLatest: () => new Promise(resolve => { resolveRead = resolve; }),
|
||||
} });
|
||||
let ai!: ReturnType<typeof useAIState>;
|
||||
let snapshot!: ReturnType<typeof useAISessionsStore>;
|
||||
function Harness() {
|
||||
ai = useAIState();
|
||||
snapshot = useAISessionsStore();
|
||||
return null;
|
||||
}
|
||||
const container = document.createElement('div');
|
||||
document.body.appendChild(container);
|
||||
const root = createRoot(container);
|
||||
try {
|
||||
await act(async () => { root.render(React.createElement(Harness)); });
|
||||
assert.equal(snapshot.activeSessionIdMap['global:'], diskOnly.id);
|
||||
await act(async () => {
|
||||
if (mutation === 'rename') ai.updateSessionTitle(primary.id, 'Renamed before recovery');
|
||||
else if (mutation === 'create') ai.createSession({ type: 'global' }, 'catty');
|
||||
else ai.deleteSession(mutation === 'delete-pending' ? diskOnly.id : primary.id);
|
||||
});
|
||||
const liveBeforeRecovery = [...snapshot.sessions];
|
||||
const expectedActiveId = mutation === 'create' ? liveBeforeRecovery[0].id
|
||||
: mutation === 'delete-pending' ? null : diskOnly.id;
|
||||
assert.equal(snapshot.activeSessionIdMap['global:'], expectedActiveId);
|
||||
assert.deepEqual(readSessionsFromStorage(), liveBeforeRecovery);
|
||||
const recoveryMarker = dom.window.localStorage.getItem(STORAGE_KEY_AI_SESSIONS_DISK_OVERFLOW);
|
||||
assert.deepEqual(recoveryMarker == null ? null : JSON.parse(recoveryMarker), mutation === 'delete-pending' ? null : [diskOnly.id]);
|
||||
flushBackupMirror();
|
||||
assert.deepEqual(backups.map(json => JSON.parse(json)), mutation === 'delete-pending' ? [liveBeforeRecovery] : []);
|
||||
|
||||
await act(async () => { resolveRead({ ok: true, json: JSON.stringify([primary, diskOnly]) }); });
|
||||
const expected = mutation === 'delete-pending' ? liveBeforeRecovery : [...liveBeforeRecovery, diskOnly];
|
||||
assert.deepEqual(snapshot.sessions, expected);
|
||||
assert.deepEqual(readSessionsFromStorage(), expected);
|
||||
assert.equal(dom.window.localStorage.getItem(STORAGE_KEY_AI_SESSIONS_DISK_OVERFLOW), null);
|
||||
flushBackupMirror();
|
||||
assert.deepEqual(JSON.parse(backups.at(-1)!), expected);
|
||||
} finally {
|
||||
await act(async () => { root.unmount(); });
|
||||
__resetBackupMirrorForTests();
|
||||
setLatestAISessionsSnapshot([]);
|
||||
setLatestAIActiveSessionMapSnapshot({});
|
||||
container.remove();
|
||||
dom.window.close();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
test('count-cap eviction clears a loaded disk-only marker and resumes backup mirroring', () => {
|
||||
const dom = new JSDOM('<!doctype html><html><body></body></html>', { url: 'http://localhost' });
|
||||
for (const name of ['window', 'document', 'localStorage', 'CustomEvent', 'Event'] as const) {
|
||||
Object.defineProperty(globalThis, name, { configurable: true, value: name === 'window' ? dom.window : dom.window[name] });
|
||||
}
|
||||
const source: AISession[] = Array.from({ length: 51 }, (_, index) => ({
|
||||
id: `chat-${index}`, title: `Chat ${index}`, agentId: 'catty', scope: { type: 'global' },
|
||||
createdAt: 1, updatedAt: 51 - index, messages: [],
|
||||
}));
|
||||
dom.window.localStorage.setItem(STORAGE_KEY_AI_SESSIONS_DISK_OVERFLOW, JSON.stringify([source[50].id]));
|
||||
const backups: string[] = [];
|
||||
Object.assign(dom.window, { netcatty: { aiSessionsBackupWrite: async ({ json }: { json: string }) => {
|
||||
backups.push(json);
|
||||
return { ok: true };
|
||||
} } });
|
||||
__resetBackupMirrorForTests();
|
||||
try {
|
||||
assert.equal(writeSessionsForStorage(source), true);
|
||||
assert.deepEqual(readSessionsFromStorage(), source.slice(0, 50));
|
||||
assert.equal(dom.window.localStorage.getItem(STORAGE_KEY_AI_SESSIONS_DISK_OVERFLOW), null);
|
||||
flushBackupMirror();
|
||||
assert.deepEqual(backups.map(json => JSON.parse(json)), [source.slice(0, 50)]);
|
||||
} finally {
|
||||
__resetBackupMirrorForTests();
|
||||
dom.window.close();
|
||||
}
|
||||
});
|
||||
|
||||
for (const keepPrimary of [true, false]) {
|
||||
test(`deletion clears disk-only recovery and remains authoritative (keep primary ${keepPrimary})`, async () => {
|
||||
const dom = new JSDOM('<!doctype html><html><body></body></html>', { url: 'http://localhost' });
|
||||
for (const name of ['window', 'document', 'localStorage', 'CustomEvent', 'Event'] as const) {
|
||||
Object.defineProperty(globalThis, name, { configurable: true, value: name === 'window' ? dom.window : dom.window[name] });
|
||||
}
|
||||
Object.defineProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT', { configurable: true, value: true });
|
||||
const primary: AISession = {
|
||||
id: 'primary', title: 'Primary', agentId: 'catty', scope: { type: 'global' },
|
||||
createdAt: 1, updatedAt: 2, messages: [],
|
||||
};
|
||||
dom.window.localStorage.setItem(STORAGE_KEY_AI_SESSIONS, JSON.stringify([primary]));
|
||||
dom.window.localStorage.setItem(STORAGE_KEY_AI_SESSIONS_DISK_OVERFLOW, JSON.stringify(['deleted-chat']));
|
||||
const remaining = keepPrimary ? [primary] : [];
|
||||
assert.equal(writeSessionsForStorage(remaining, { deletedSessionIds: ['deleted-chat'] }), true);
|
||||
assert.equal(dom.window.localStorage.getItem(STORAGE_KEY_AI_SESSIONS_DISK_OVERFLOW), null);
|
||||
__resetBackupMirrorForTests();
|
||||
setLatestAISessionsSnapshot(null as unknown as AISession[]);
|
||||
let reads = 0;
|
||||
Object.assign(dom.window, { netcatty: { aiSessionsBackupReadLatest: async () => {
|
||||
reads += 1;
|
||||
return { ok: true, json: JSON.stringify([primary, { ...primary, id: 'deleted-chat' }]) };
|
||||
} } });
|
||||
let sessions: readonly AISession[] = [];
|
||||
function Harness() {
|
||||
useAIState();
|
||||
sessions = useAISessionsStore().sessions;
|
||||
return null;
|
||||
}
|
||||
const container = document.createElement('div');
|
||||
document.body.appendChild(container);
|
||||
const root = createRoot(container);
|
||||
try {
|
||||
await act(async () => { root.render(React.createElement(Harness)); });
|
||||
assert.equal(reads, 0);
|
||||
assert.deepEqual(sessions, remaining);
|
||||
} finally {
|
||||
await act(async () => { root.unmount(); });
|
||||
__resetBackupMirrorForTests();
|
||||
setLatestAISessionsSnapshot([]);
|
||||
container.remove();
|
||||
dom.window.close();
|
||||
}
|
||||
});
|
||||
}
|
||||
+251
-69
@@ -66,18 +66,23 @@ import {
|
||||
bumpDraftUploadGeneration,
|
||||
cleanupDeletedAIChatSessions,
|
||||
cleanupOrphanedAISessions,
|
||||
latestAIActiveTargetIdsSnapshot,
|
||||
getAIBridge,
|
||||
flushBackupMirror,
|
||||
getAIBridge,
|
||||
getDraftUploadGeneration,
|
||||
getPersistedAiSessionsSnapshot,
|
||||
isAiSessionsHydrationComplete,
|
||||
latestAIActiveSessionMapSnapshot,
|
||||
latestAIActiveTargetIdsSnapshot,
|
||||
latestAIDraftsByScopeSnapshot,
|
||||
latestAIPanelViewByScopeSnapshot,
|
||||
latestAISessionsSnapshot,
|
||||
markAiSessionsHydrationComplete,
|
||||
prepareRestoredSessions,
|
||||
readSessionsFromStorage,
|
||||
scheduleBackupMirror,
|
||||
readAiSessionMessageCap,
|
||||
rebindActiveSessionMap,
|
||||
scheduleBackupMirror,
|
||||
seedLastPersistedSessions,
|
||||
setAiSessionsBackupRecoveryPending,
|
||||
writeSessionsForStorage,
|
||||
setLatestAIActiveSessionMapSnapshot,
|
||||
setLatestAIDraftsByScopeSnapshot,
|
||||
@@ -86,11 +91,25 @@ import {
|
||||
type DraftsByScope,
|
||||
type PanelViewByScope,
|
||||
} from './aiStateSnapshots';
|
||||
import {
|
||||
AI_SESSIONS_IDB_VERSION,
|
||||
AI_SESSIONS_META_LAST_WRITE_KEY,
|
||||
AI_SESSIONS_META_SCHEMA_VERSION_KEY,
|
||||
idbGetAllSessions,
|
||||
idbPutMeta,
|
||||
idbPutSessions,
|
||||
loadOrMigrateAiSessions,
|
||||
mirrorActiveSessionMapToIdb,
|
||||
openAiSessionsIdb,
|
||||
publishAiSessionsIdbChange,
|
||||
readActiveSessionMapFromIdb,
|
||||
subscribeAiSessionsIdbChange,
|
||||
} from './aiSessionIdbStorage';
|
||||
import {
|
||||
isMissingOrCorruptSessionsStore,
|
||||
parseSessionsStore,
|
||||
mergeImportedSessions,
|
||||
parseBackupSessions,
|
||||
readLiveSessionsArray,
|
||||
} from './aiSessionsBackup';
|
||||
import { AI_STATE_CHANGED_EVENT, emitAIStateChanged } from './aiStateEvents';
|
||||
import {
|
||||
@@ -181,53 +200,165 @@ export function useAIState() {
|
||||
);
|
||||
|
||||
// ── Sessions ──
|
||||
const [sessions, setSessionsRaw] = useState<AISession[]>(() =>
|
||||
latestAISessionsSnapshot
|
||||
?? readSessionsFromStorage()
|
||||
);
|
||||
// Memory-only initial state: the primary store is IndexedDB and loads
|
||||
// asynchronously in the boot effect below (issue #4123). The legacy
|
||||
// localStorage key is read-only fallback and must not pre-seed stale data.
|
||||
const [sessions, setSessionsRaw] = useState<AISession[]>(() => latestAISessionsSnapshot ?? []);
|
||||
// Issue #4125 review P2②: flips true when the boot load settles. The
|
||||
// active-pointer validation effect waits for it — the initially-empty
|
||||
// in-memory session array must never null the persisted pointers.
|
||||
const [sessionsHydrated, setSessionsHydrated] = useState(() => isAiSessionsHydrationComplete());
|
||||
// An explicit empty array is authoritative, even if the backup is stale.
|
||||
// Disk-only overflow IDs also permit recovery, without reviving deleted chats.
|
||||
// Only a missing or corrupt store can be restored from disk.
|
||||
useEffect(() => {
|
||||
window.addEventListener('beforeunload', flushBackupMirror);
|
||||
return () => window.removeEventListener('beforeunload', flushBackupMirror);
|
||||
}, []);
|
||||
useEffect(() => {
|
||||
const liveRaw = localStorageAdapter.readString(STORAGE_KEY_AI_SESSIONS);
|
||||
const liveStore = parseSessionsStore(liveRaw);
|
||||
const liveSessions = readSessionsFromStorage();
|
||||
const diskOnlyRecovery = (localStorageAdapter.read<string[]>(STORAGE_KEY_AI_SESSIONS_DISK_OVERFLOW)?.length ?? 0) > 0;
|
||||
if (!diskOnlyRecovery && (liveStore.valid || liveSessions.length > 0)) {
|
||||
if (liveSessions.length > 0) scheduleBackupMirror(JSON.stringify(liveSessions));
|
||||
return;
|
||||
}
|
||||
if (!diskOnlyRecovery && latestAISessionsSnapshot && latestAISessionsSnapshot.length > 0) return;
|
||||
const bridge = getAIBridge();
|
||||
if (!bridge?.aiSessionsBackupReadLatest) return;
|
||||
let cancelled = false;
|
||||
const canRestore = () => diskOnlyRecovery
|
||||
? (localStorageAdapter.read<string[]>(STORAGE_KEY_AI_SESSIONS_DISK_OVERFLOW)?.length ?? 0) > 0
|
||||
: isMissingOrCorruptSessionsStore(localStorageAdapter.readString(STORAGE_KEY_AI_SESSIONS))
|
||||
&& readSessionsFromStorage().length === 0
|
||||
&& (latestAISessionsSnapshot?.length ?? 0) === 0
|
||||
&& sessionsRef.current.length === 0;
|
||||
let cleanupAfterHydration = false;
|
||||
void (async () => {
|
||||
try {
|
||||
if (!canRestore()) return;
|
||||
// 1. IndexedDB (primary) → one-time legacy localStorage migration.
|
||||
const diskOnlyIds = new Set(localStorageAdapter.read<string[]>(STORAGE_KEY_AI_SESSIONS_DISK_OVERFLOW) ?? []);
|
||||
setAiSessionsBackupRecoveryPending(diskOnlyIds.size > 0);
|
||||
const boot = await loadOrMigrateAiSessions(
|
||||
async () => {
|
||||
// Legacy retention split history between the primary key, JSON
|
||||
// chunks, and explicitly marked disk-only records. Resolve all
|
||||
// slices before the one-shot migration records completion.
|
||||
const chunks: string[] = [];
|
||||
for (let index = 0; ; index += 1) {
|
||||
const key = index === 0 ? STORAGE_KEY_AI_SESSIONS_OVERFLOW : `${STORAGE_KEY_AI_SESSIONS_OVERFLOW}_${index + 1}`;
|
||||
const chunk = localStorageAdapter.readString(key);
|
||||
if (chunk == null) break;
|
||||
chunks.push(chunk);
|
||||
}
|
||||
let legacy = mergeImportedSessions(
|
||||
readLiveSessionsArray(chunks.join('')),
|
||||
readLiveSessionsArray(localStorageAdapter.readString(STORAGE_KEY_AI_SESSIONS)),
|
||||
);
|
||||
if (diskOnlyIds.size > 0) {
|
||||
try {
|
||||
const backup = await getAIBridge()?.aiSessionsBackupReadLatest?.();
|
||||
if (backup?.ok) {
|
||||
legacy = mergeImportedSessions(
|
||||
(parseBackupSessions(backup.json) ?? []).filter(session => diskOnlyIds.has(session.id)),
|
||||
legacy,
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('[AIState] Disk-only session recovery failed; migration will retry on the next boot:', error);
|
||||
}
|
||||
}
|
||||
return JSON.stringify(legacy);
|
||||
},
|
||||
[...diskOnlyIds],
|
||||
);
|
||||
setAiSessionsBackupRecoveryPending(boot.migrationPending);
|
||||
if (cancelled) return;
|
||||
if (boot.idbFailed && boot.sessions.length > 0) {
|
||||
// Issue #4125 review P2①: the legacy set is not in IndexedDB yet.
|
||||
// Persist it best-effort (one re-open retry) BEFORE seeding the
|
||||
// diff baseline; if the store is still unreachable, leave the
|
||||
// baseline unset so the next edit performs a full write instead of
|
||||
// a diff that would drop the un-persisted sessions after IndexedDB
|
||||
// recovers.
|
||||
try {
|
||||
const db = await openAiSessionsIdb();
|
||||
if (!cancelled) {
|
||||
await idbPutSessions(db, boot.sessions);
|
||||
await idbPutMeta(db, AI_SESSIONS_META_SCHEMA_VERSION_KEY, AI_SESSIONS_IDB_VERSION);
|
||||
await idbPutMeta(db, AI_SESSIONS_META_LAST_WRITE_KEY, Date.now());
|
||||
publishAiSessionsIdbChange();
|
||||
seedLastPersistedSessions(boot.sessions);
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn(
|
||||
'[AIState] Could not persist the legacy sessions after an IndexedDB failure;'
|
||||
+ ' the diff baseline stays unset so the next edit rewrites every record:',
|
||||
error,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
seedLastPersistedSessions(boot.sessions);
|
||||
}
|
||||
|
||||
// Restore the mirrored active-pointer map when the localStorage key
|
||||
// is missing (fresh profile restored from IndexedDB).
|
||||
if (localStorageAdapter.readString(STORAGE_KEY_AI_ACTIVE_SESSION_MAP) == null) {
|
||||
const mirrored = await readActiveSessionMapFromIdb();
|
||||
if (cancelled) return;
|
||||
if (mirrored && Object.keys(mirrored).length > 0) {
|
||||
localStorageAdapter.write(STORAGE_KEY_AI_ACTIVE_SESSION_MAP, mirrored);
|
||||
setLatestAIActiveSessionMapSnapshot(mirrored);
|
||||
setActiveSessionIdMapRaw(mirrored);
|
||||
}
|
||||
}
|
||||
|
||||
const bootSessions = boot.sessions;
|
||||
if (bootSessions.length > 0) {
|
||||
const live = latestAISessionsSnapshot ?? sessionsRef.current;
|
||||
if (live.length > 0) {
|
||||
// Sessions were created before the async load finished: merge
|
||||
// (live records win by id) instead of clobbering either side.
|
||||
const byId = new Map(bootSessions.map(session => [session.id, session]));
|
||||
for (const session of live) byId.set(session.id, session);
|
||||
const merged = Array.from(byId.values())
|
||||
.sort((a, b) => (b.updatedAt ?? 0) - (a.updatedAt ?? 0));
|
||||
setLatestAISessionsSnapshot(merged);
|
||||
sessionsRef.current = merged;
|
||||
setSessionsRaw(merged);
|
||||
writeSessionsForStorage(merged);
|
||||
return;
|
||||
}
|
||||
setLatestAISessionsSnapshot(bootSessions);
|
||||
sessionsRef.current = bootSessions;
|
||||
setSessionsRaw(bootSessions);
|
||||
scheduleBackupMirror(JSON.stringify(bootSessions));
|
||||
cleanupAfterHydration = true;
|
||||
return;
|
||||
}
|
||||
|
||||
// A previously initialized empty IDB is a committed deletion. An
|
||||
// incomplete migration must retry only its marked disk-only records.
|
||||
if (boot.authoritativeEmpty || boot.migrationPending) return;
|
||||
|
||||
// 2. Uninitialized store (and no legacy data to migrate): the #4018 disk
|
||||
// backup remains the last restore step. Only when the legacy store
|
||||
// is also missing or corrupt — a valid empty legacy array is an
|
||||
// intentional wipe and stays authoritative.
|
||||
if (!isMissingOrCorruptSessionsStore(localStorageAdapter.readString(STORAGE_KEY_AI_SESSIONS))) {
|
||||
return;
|
||||
}
|
||||
const bridge = getAIBridge();
|
||||
if (!bridge?.aiSessionsBackupReadLatest) return;
|
||||
const backup = await bridge.aiSessionsBackupReadLatest?.();
|
||||
if (cancelled || !backup?.ok) return;
|
||||
// Re-check after the async gap so live mutations and deletions win.
|
||||
if (!canRestore()) return;
|
||||
const backupSessions = parseBackupSessions(backup.json);
|
||||
if (!backupSessions) return;
|
||||
const diskOnlyIds = new Set(localStorageAdapter.read<string[]>(STORAGE_KEY_AI_SESSIONS_DISK_OVERFLOW) ?? []);
|
||||
const recovered = diskOnlyRecovery
|
||||
? mergeImportedSessions(
|
||||
backupSessions.filter(session => diskOnlyIds.has(session.id)),
|
||||
latestAISessionsSnapshot ?? readSessionsFromStorage(),
|
||||
)
|
||||
: backupSessions;
|
||||
// Re-check after the async gap: a session may have been created
|
||||
// while the backup was being read; never restore over live state.
|
||||
// Cover persisted storage and in-memory snapshot/React state —
|
||||
// createSession updates the snapshot before the debounced disk write.
|
||||
if ((latestAISessionsSnapshot?.length ?? 0) > 0) return;
|
||||
if (sessionsRef.current.length > 0) return;
|
||||
// Issue #4125 review P2③: one open retry already happened inside
|
||||
// the boot load. If the store is still unopenable, do not let the
|
||||
// recheck rejection reach the outer catch — the disk backup below
|
||||
// is the recovery source.
|
||||
try {
|
||||
if ((await idbGetAllSessions()).length > 0) return;
|
||||
} catch (error) {
|
||||
console.warn(
|
||||
'[AIState] IndexedDB recheck failed during the disk-backup restore;'
|
||||
+ ' applying the newest backup generation as the recovery source:',
|
||||
error,
|
||||
);
|
||||
}
|
||||
if (!isMissingOrCorruptSessionsStore(localStorageAdapter.readString(STORAGE_KEY_AI_SESSIONS))) {
|
||||
return;
|
||||
}
|
||||
const syncEnabled = !!localStorageAdapter.read<{ enabled?: boolean }>(STORAGE_KEY_CLOUD_SYNC_AI_SESSIONS)?.enabled;
|
||||
const restored = prepareRestoredSessions(recovered, syncEnabled);
|
||||
const restored = prepareRestoredSessions(parseBackupSessions(backup.json) ?? [], syncEnabled);
|
||||
if (restored.length === 0) return;
|
||||
// Apply restore synchronously (not inside a setState updater). A
|
||||
// deferred updater would leave didRestore false and skip dangling
|
||||
@@ -235,11 +366,7 @@ export function useAIState() {
|
||||
setLatestAISessionsSnapshot(restored);
|
||||
writeSessionsForStorage(restored);
|
||||
emitAIStateChanged(STORAGE_KEY_AI_SESSIONS);
|
||||
// Restore does not change live terminals/workspaces, so the orphan-
|
||||
// cleanup effect will not run. Reuse the last known live target set
|
||||
// (empty is valid: nothing live) so chats bound to missing scopes
|
||||
// become dangling and /chats can offer /rebind.
|
||||
cleanupOrphanedAISessions(latestAIActiveTargetIdsSnapshot);
|
||||
cleanupAfterHydration = true;
|
||||
const nextSessions = latestAISessionsSnapshot ?? restored;
|
||||
sessionsRef.current = nextSessions;
|
||||
setSessionsRaw(nextSessions);
|
||||
@@ -249,10 +376,25 @@ export function useAIState() {
|
||||
?? {},
|
||||
);
|
||||
console.info(
|
||||
`[AIState] Restored ${restored.length} AI session(s) from the disk backup.`,
|
||||
`[AIState] Restored ${nextSessions.length} AI session(s) from the disk backup.`,
|
||||
);
|
||||
} catch {
|
||||
// Backup restore must never break startup.
|
||||
} catch (error) {
|
||||
// Boot load must never break startup — but it must not vanish
|
||||
// silently either (issue #4125 review P2③).
|
||||
console.warn('[AIState] AI session boot load failed:', error);
|
||||
} finally {
|
||||
markAiSessionsHydrationComplete();
|
||||
if (!cancelled) {
|
||||
// Maintenance may have mounted before hydration with unchanged live
|
||||
// targets. Replay it now against the authoritative boot history.
|
||||
if (cleanupAfterHydration) {
|
||||
cleanupOrphanedAISessions(latestAIActiveTargetIdsSnapshot);
|
||||
const nextSessions = latestAISessionsSnapshot ?? sessionsRef.current;
|
||||
sessionsRef.current = nextSessions;
|
||||
setSessionsRaw(nextSessions);
|
||||
}
|
||||
setSessionsHydrated(true);
|
||||
}
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
@@ -321,6 +463,9 @@ export function useAIState() {
|
||||
|
||||
useEffect(() => {
|
||||
setLatestAIActiveSessionMapSnapshot(activeSessionIdMap);
|
||||
// Issue #4123: mirror the active-pointer map into the IndexedDB meta
|
||||
// store (localStorage stays written at the mutation sites as before).
|
||||
void mirrorActiveSessionMapToIdb(activeSessionIdMap);
|
||||
}, [activeSessionIdMap]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -332,12 +477,12 @@ export function useAIState() {
|
||||
}, [panelViewByScope]);
|
||||
|
||||
useEffect(() => {
|
||||
// Issue #4125 review P2②: before boot hydration settles, `sessions` is
|
||||
// the initially-empty in-memory array; validating pointers against it
|
||||
// would null every pointer and break workspace restore. No pointer
|
||||
// writes before hydration completes.
|
||||
if (!sessionsHydrated) return;
|
||||
const validSessionIds = new Set<string>(sessions.map((session) => session.id));
|
||||
// Disk-only chats may not have arrived from the startup backup read yet.
|
||||
// Keep their active pointers and panel views until recovery or deletion.
|
||||
for (const id of localStorageAdapter.read<string[]>(STORAGE_KEY_AI_SESSIONS_DISK_OVERFLOW) ?? []) {
|
||||
validSessionIds.add(id);
|
||||
}
|
||||
let changed = false;
|
||||
const nextActiveSessionIdMap: Record<string, string | null> = {};
|
||||
|
||||
@@ -365,7 +510,7 @@ export function useAIState() {
|
||||
emitAIStateChanged(AI_STATE_CHANGED_PANEL_VIEW_BY_SCOPE);
|
||||
return next;
|
||||
});
|
||||
}, [sessions, activeSessionIdMap]);
|
||||
}, [sessions, activeSessionIdMap, sessionsHydrated]);
|
||||
|
||||
const setActiveSessionId = useCallback((scopeKey: string, id: string | null) => {
|
||||
let nextActiveSessionIdMap: Record<string, string | null> | null = null;
|
||||
@@ -557,8 +702,7 @@ export function useAIState() {
|
||||
useEffect(() => {
|
||||
const handleStorage = (e: StorageEvent) => {
|
||||
try {
|
||||
const key = e.key === STORAGE_KEY_AI_SESSIONS_OVERFLOW || e.key?.startsWith(`${STORAGE_KEY_AI_SESSIONS_OVERFLOW}_`)
|
||||
? STORAGE_KEY_AI_SESSIONS : e.key;
|
||||
const key = e.key;
|
||||
switch (key) {
|
||||
case STORAGE_KEY_AI_PROVIDERS: {
|
||||
const parsed = localStorageAdapter.read<ProviderConfig[]>(STORAGE_KEY_AI_PROVIDERS);
|
||||
@@ -651,7 +795,10 @@ export function useAIState() {
|
||||
break;
|
||||
}
|
||||
case STORAGE_KEY_AI_SESSIONS: {
|
||||
const nextSessions = readSessionsFromStorage();
|
||||
// Session writes no longer land in localStorage (issue #4123);
|
||||
// the cross-window signal is the IndexedDB change channel and the
|
||||
// storage event only fires as a legacy fallback.
|
||||
const nextSessions = getPersistedAiSessionsSnapshot();
|
||||
setLatestAISessionsSnapshot(nextSessions);
|
||||
setSessionsRaw(nextSessions);
|
||||
break;
|
||||
@@ -693,7 +840,7 @@ export function useAIState() {
|
||||
case STORAGE_KEY_AI_SESSIONS:
|
||||
setSessionsRaw(
|
||||
latestAISessionsSnapshot
|
||||
?? readSessionsFromStorage(),
|
||||
?? getPersistedAiSessionsSnapshot(),
|
||||
);
|
||||
return;
|
||||
case STORAGE_KEY_AI_ACTIVE_SESSION_MAP:
|
||||
@@ -720,6 +867,37 @@ export function useAIState() {
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Cross-window AI session sync: session writes land in IndexedDB now, so
|
||||
// the localStorage `storage` event no longer fires for them. Other windows
|
||||
// announce their writes on a BroadcastChannel; the receiver reloads the
|
||||
// store from IndexedDB.
|
||||
useEffect(() => subscribeAiSessionsIdbChange(() => {
|
||||
void (async () => {
|
||||
try {
|
||||
const next = await idbGetAllSessions();
|
||||
if (next.length === 0) {
|
||||
if (!isAiSessionsHydrationComplete()) return;
|
||||
// Issue #4125 review P2④: after hydration an empty store is an
|
||||
// intentional cross-window deletion — adopt it instead of keeping
|
||||
// the deleted session alive locally (its next edit would
|
||||
// resurrect it). The pointer-validation effect clears the
|
||||
// now-stale pointers. Only the pre-hydration window keeps the
|
||||
// early-return: the boot load owns the store view until it
|
||||
// settles.
|
||||
setLatestAISessionsSnapshot([]);
|
||||
seedLastPersistedSessions([]);
|
||||
setSessionsRaw([]);
|
||||
return;
|
||||
}
|
||||
setLatestAISessionsSnapshot(next);
|
||||
seedLastPersistedSessions(next);
|
||||
setSessionsRaw(next);
|
||||
} catch {
|
||||
// Cross-window refresh is best-effort.
|
||||
}
|
||||
})();
|
||||
}), []);
|
||||
|
||||
// ── Sync initial safety settings to MCP Server on mount ──
|
||||
useEffect(() => {
|
||||
const bridge = getAIBridge();
|
||||
@@ -1031,8 +1209,6 @@ export function useAIState() {
|
||||
const syncEnabled = !!localStorageAdapter.read<{ enabled?: boolean }>(STORAGE_KEY_CLOUD_SYNC_AI_SESSIONS)?.enabled;
|
||||
const next = prepareRestoredSessions(mergeImportedSessions(sessionsRef.current, imported), syncEnabled);
|
||||
if (!writeSessionsForStorage(next)) return 0;
|
||||
// Local readback can omit disk-only overflow chats. Keep the complete
|
||||
// prepared history live so cleanup and later writes retain those chats.
|
||||
const importedIds = new Set(imported.map(session => session.id));
|
||||
sessionsRef.current = next;
|
||||
setLatestAISessionsSnapshot(next);
|
||||
@@ -1069,22 +1245,27 @@ export function useAIState() {
|
||||
});
|
||||
}, [debouncedPersistSessions]);
|
||||
|
||||
// Local-only retention. Synced history must match the full-history storage policy.
|
||||
const MAX_MESSAGES_PER_SESSION = 500;
|
||||
// Local-only retention. Synced history must match the full-history storage
|
||||
// policy. The in-memory bound follows the user-configurable per-session cap
|
||||
// (see aiStateSnapshots.readAiSessionMessageCap) but never below this floor,
|
||||
// so the default behavior is unchanged and RAM stays bounded when the user
|
||||
// lowers the persisted cap.
|
||||
const MIN_IN_MEMORY_MESSAGES_PER_SESSION = 500;
|
||||
|
||||
const addMessageToSession = useCallback((sessionId: string, message: ChatMessage) => {
|
||||
const syncEnabled = localStorageAdapter.read<{ enabled?: boolean }>(STORAGE_KEY_CLOUD_SYNC_AI_SESSIONS)?.enabled === true;
|
||||
const inMemoryCap = Math.max(readAiSessionMessageCap(), MIN_IN_MEMORY_MESSAGES_PER_SESSION);
|
||||
setSessionsRaw(prev => {
|
||||
const next = prev.map(s => {
|
||||
if (s.id !== sessionId) return s;
|
||||
let msgs = [...s.messages, message];
|
||||
// Dropping synced prefixes makes the next cloud merge add them back.
|
||||
if (!syncEnabled && msgs.length > MAX_MESSAGES_PER_SESSION) {
|
||||
if (!syncEnabled && msgs.length > inMemoryCap) {
|
||||
const systemMsgs = msgs.filter(m => m.role === 'system');
|
||||
const nonSystemMsgs = msgs.filter(m => m.role !== 'system');
|
||||
const dropped = nonSystemMsgs.length - (MAX_MESSAGES_PER_SESSION - systemMsgs.length);
|
||||
console.warn(`[useAIState] Session ${sessionId}: trimmed ${dropped} oldest non-system message(s) to stay within ${MAX_MESSAGES_PER_SESSION} limit`);
|
||||
msgs = [...systemMsgs, ...nonSystemMsgs.slice(-MAX_MESSAGES_PER_SESSION + systemMsgs.length)];
|
||||
const dropped = nonSystemMsgs.length - (inMemoryCap - systemMsgs.length);
|
||||
console.warn(`[useAIState] Session ${sessionId}: trimmed ${dropped} oldest non-system message(s) to stay within ${inMemoryCap} limit`);
|
||||
msgs = [...systemMsgs, ...nonSystemMsgs.slice(-inMemoryCap + systemMsgs.length)];
|
||||
}
|
||||
return { ...s, messages: msgs, updatedAt: Date.now() };
|
||||
});
|
||||
@@ -1322,10 +1503,11 @@ export function useAIState() {
|
||||
|
||||
const cleanupOrphanedSessions = useCallback((activeTargetIds: Set<string>) => {
|
||||
cleanupOrphanedAISessions(activeTargetIds);
|
||||
if (!isAiSessionsHydrationComplete()) return;
|
||||
|
||||
const nextSessions =
|
||||
latestAISessionsSnapshot
|
||||
?? readSessionsFromStorage();
|
||||
?? getPersistedAiSessionsSnapshot();
|
||||
sessionsRef.current = nextSessions;
|
||||
setSessionsRaw(nextSessions);
|
||||
setActiveSessionIdMapRaw(
|
||||
@@ -1377,7 +1559,7 @@ export function useAIState() {
|
||||
?? {};
|
||||
const currentSessions =
|
||||
latestAISessionsSnapshot
|
||||
?? readSessionsFromStorage();
|
||||
?? getPersistedAiSessionsSnapshot();
|
||||
const currentPanelViewByScope = latestAIPanelViewByScopeSnapshot ?? {};
|
||||
const result = handoffDissolvedWorkspaceAIScope({
|
||||
activeSessionIdMap: currentMap,
|
||||
@@ -1422,7 +1604,7 @@ export function useAIState() {
|
||||
?? {};
|
||||
const currentSessions =
|
||||
latestAISessionsSnapshot
|
||||
?? readSessionsFromStorage();
|
||||
?? getPersistedAiSessionsSnapshot();
|
||||
const result = retargetWorkspaceActiveChatAfterMemberLoss({
|
||||
activeSessionIdMap: currentMap,
|
||||
sessions: currentSessions,
|
||||
|
||||
@@ -9,7 +9,7 @@ import { getAgentRuntime } from '../../infrastructure/ai/harness/globalAgentRunt
|
||||
import { useAIState } from './useAIState.ts';
|
||||
import { useAISessionsStore } from './aiSessionsStore.ts';
|
||||
import { getSessionScopeMatchRank } from '../../components/ai/sessionScopeMatch.ts';
|
||||
import { setLatestAISessionsSnapshot } from './aiStateSnapshots.ts';
|
||||
import { getPersistedAiSessionsSnapshot, setLatestAISessionsSnapshot } from './aiStateSnapshots.ts';
|
||||
|
||||
const SCOPE_KEY = 'terminal:terminal-a';
|
||||
|
||||
@@ -175,17 +175,21 @@ for (const newerCount of [49, 50]) {
|
||||
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);
|
||||
// Issue #4123: the persisted set lives in IndexedDB (count cap 1000, so
|
||||
// nothing is evicted at these sizes) — not in a 50-capped localStorage blob.
|
||||
const saved: AISession[] = getPersistedAiSessionsSnapshot();
|
||||
assert.equal(saved.length, newerCount + 2);
|
||||
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.
|
||||
container.remove();
|
||||
// Seed the read-only legacy store with the persisted set (the stand-in
|
||||
// for what the migration persists on a profile without IndexedDB).
|
||||
dom.window.localStorage.setItem('netcatty_ai_sessions_v1', JSON.stringify(saved));
|
||||
dom.window.close();
|
||||
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));
|
||||
@@ -223,7 +227,7 @@ for (const viewingScope of [
|
||||
), 3);
|
||||
const original = capture.sessions.find(item => item.id === source.id)!;
|
||||
assert.deepEqual({ ...original, updatedAt: sourceBefore.updatedAt }, sourceBefore);
|
||||
const saved = JSON.parse(dom.window.localStorage.getItem('netcatty_ai_sessions_v1')!) as AISession[];
|
||||
const saved = getPersistedAiSessionsSnapshot();
|
||||
assert.deepEqual(saved.find(item => item.id === branchId)?.scope, branch.scope);
|
||||
await act(async () => root.unmount());
|
||||
container.remove(); dom.window.close();
|
||||
|
||||
@@ -156,6 +156,139 @@ for (const preference of ["system", "light", "dark"] as const) {
|
||||
}
|
||||
}
|
||||
|
||||
// A hanging jsdom/React render must fail this test quickly instead of
|
||||
// consuming the whole CI job budget.
|
||||
test("terminal chrome never replaces the application native theme policy", { timeout: 15000 }, async () => {
|
||||
const { JSDOM } = await import("jsdom");
|
||||
const dom = new JSDOM('<!doctype html><html><head></head><body><div data-top-tabs-root></div></body></html>');
|
||||
const originalWindow = globalThis.window;
|
||||
const originalDocument = globalThis.document;
|
||||
const originalSnapshot = getSettingsChromeSnapshot();
|
||||
const themeSources: string[] = [];
|
||||
const backgrounds: string[] = [];
|
||||
const testWindow = dom.window as unknown as Window & typeof globalThis;
|
||||
testWindow.netcatty = {
|
||||
setTheme: async (source: string) => { themeSources.push(source); return true; },
|
||||
setBackgroundColor: async (background: string) => { backgrounds.push(background); return true; },
|
||||
} as unknown as NetcattyBridge;
|
||||
globalThis.window = testWindow;
|
||||
globalThis.document = testWindow.document;
|
||||
try {
|
||||
publishSettingsChromeSnapshot({ ...originalSnapshot, theme: "system" });
|
||||
for (const type of ['light', 'dark'] as const) {
|
||||
const theme = TERMINAL_THEMES.find((candidate) => candidate.type === type);
|
||||
assert.ok(theme);
|
||||
syncActiveChromeTheme(theme, () => {});
|
||||
assert.equal(document.documentElement.classList.contains(type), true);
|
||||
assert.equal(backgrounds.at(-1), theme.colors.background);
|
||||
}
|
||||
assert.deepEqual(themeSources, ['system', 'system'], 'terminal colors must not pin nativeTheme.themeSource to light/dark');
|
||||
} finally {
|
||||
publishSettingsChromeSnapshot(originalSnapshot);
|
||||
globalThis.window = originalWindow;
|
||||
globalThis.document = originalDocument;
|
||||
dom.window.close();
|
||||
}
|
||||
});
|
||||
|
||||
test("system preference refresh reapplies an unchanged independent terminal chrome", { timeout: 15000 }, async () => {
|
||||
const { JSDOM } = await import("jsdom");
|
||||
const dom = new JSDOM('<!doctype html><html><head></head><body><div data-top-tabs-root></div></body></html>');
|
||||
const originalWindow = globalThis.window;
|
||||
const originalDocument = globalThis.document;
|
||||
const originalSnapshot = getSettingsChromeSnapshot();
|
||||
const backgrounds: string[] = [];
|
||||
const testWindow = dom.window as unknown as Window & typeof globalThis;
|
||||
testWindow.netcatty = {
|
||||
setTheme: (source: string) => {
|
||||
assert.equal(source, 'system', 'chrome refresh must preserve native system theme policy');
|
||||
return Promise.resolve(true);
|
||||
},
|
||||
setBackgroundColor: async (background: string) => { backgrounds.push(background); return true; },
|
||||
} as unknown as NetcattyBridge;
|
||||
globalThis.window = testWindow;
|
||||
globalThis.document = testWindow.document;
|
||||
try {
|
||||
publishSettingsChromeSnapshot({ ...originalSnapshot, theme: "system" });
|
||||
for (const type of ['light', 'dark'] as const) {
|
||||
const theme = TERMINAL_THEMES.find((candidate) => candidate.type === type);
|
||||
assert.ok(theme);
|
||||
syncActiveChromeTheme(theme, () => {});
|
||||
// The application settings effect runs first on an OS preference change.
|
||||
document.documentElement.className = type === 'dark' ? 'light' : 'dark';
|
||||
backgrounds.push('application-background');
|
||||
syncActiveChromeTheme(theme, () => {}, { refreshSurfaces: true });
|
||||
assert.equal(document.documentElement.classList.contains(type), true);
|
||||
assert.equal(backgrounds.at(-1), theme.colors.background);
|
||||
}
|
||||
syncActiveChromeTheme(null, () => {
|
||||
document.documentElement.className = 'light';
|
||||
backgrounds.push('application-background');
|
||||
});
|
||||
assert.equal(document.documentElement.className, 'light');
|
||||
assert.equal(document.documentElement.dataset.activeChromeTheme, undefined);
|
||||
assert.equal(document.getElementById('netcatty-active-chrome-theme'), null);
|
||||
assert.equal(backgrounds.at(-1), 'application-background');
|
||||
} finally {
|
||||
publishSettingsChromeSnapshot(originalSnapshot);
|
||||
globalThis.window = originalWindow;
|
||||
globalThis.document = originalDocument;
|
||||
dom.window.close();
|
||||
}
|
||||
});
|
||||
|
||||
test("chrome hook reacts to OS preference changes with stable terminal props", { timeout: 15000 }, async () => {
|
||||
const { JSDOM } = await import('jsdom');
|
||||
const { act, createElement } = await import('react');
|
||||
const { createRoot } = await import('react-dom/client');
|
||||
const { useActiveChromeTheme } = await import('./useActiveChromeTheme');
|
||||
const { getSettingsChromeSnapshot, publishSettingsChromeSnapshot } = await import('./settingsChromeStore');
|
||||
const dom = new JSDOM('<!doctype html><html><head></head><body></body></html>');
|
||||
const originalWindow = globalThis.window;
|
||||
const originalDocument = globalThis.document;
|
||||
const originalSnapshot = getSettingsChromeSnapshot();
|
||||
const environment = globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean };
|
||||
const originalActEnvironment = environment.IS_REACT_ACT_ENVIRONMENT;
|
||||
environment.IS_REACT_ACT_ENVIRONMENT = true;
|
||||
const backgrounds: string[] = [];
|
||||
const testWindow = dom.window as unknown as Window & typeof globalThis;
|
||||
globalThis.window = testWindow;
|
||||
globalThis.document = dom.window.document;
|
||||
testWindow.netcatty = {
|
||||
setBackgroundColor: async (color: string) => { backgrounds.push(color); return true; },
|
||||
} as unknown as NetcattyBridge;
|
||||
const theme = TERMINAL_THEMES.find((candidate) => candidate.type === 'dark');
|
||||
assert.ok(theme);
|
||||
const applyAppTheme = () => {};
|
||||
function Harness() {
|
||||
useActiveChromeTheme({ activeTheme: theme!, applyAppTheme });
|
||||
return null;
|
||||
}
|
||||
publishSettingsChromeSnapshot({ ...originalSnapshot, resolvedTheme: 'dark' });
|
||||
let renderer: ReturnType<typeof createRoot> | undefined;
|
||||
try {
|
||||
await act(async () => {
|
||||
renderer = createRoot(document.createElement('div'));
|
||||
renderer.render(createElement(Harness));
|
||||
});
|
||||
assert.equal(document.documentElement.classList.contains('dark'), true);
|
||||
document.documentElement.className = 'light';
|
||||
backgrounds.push('application-background');
|
||||
await act(async () => {
|
||||
publishSettingsChromeSnapshot({ ...originalSnapshot, resolvedTheme: 'light' });
|
||||
});
|
||||
assert.equal(document.documentElement.classList.contains('dark'), true);
|
||||
assert.equal(backgrounds.at(-1), theme.colors.background);
|
||||
} finally {
|
||||
await act(async () => { renderer?.unmount(); });
|
||||
environment.IS_REACT_ACT_ENVIRONMENT = originalActEnvironment;
|
||||
publishSettingsChromeSnapshot(originalSnapshot);
|
||||
globalThis.window = originalWindow;
|
||||
globalThis.document = originalDocument;
|
||||
dom.window.close();
|
||||
}
|
||||
});
|
||||
|
||||
test("active chrome theme foreground uses contrast calculation", () => {
|
||||
const source = readFileSync(new URL("./useActiveChromeTheme.ts", import.meta.url), "utf8");
|
||||
|
||||
|
||||
@@ -237,9 +237,9 @@ function applyActiveChromeTheme(theme: TerminalTheme) {
|
||||
}
|
||||
|
||||
function refreshActiveChromeThemeSurfaces(theme: TerminalTheme) {
|
||||
const targetClass = theme.type === "dark" ? "dark" : "light";
|
||||
if (typeof window !== "undefined") {
|
||||
// Preserve OS preference events instead of locking nativeTheme to the chrome type.
|
||||
const targetClass = theme.type === "dark" ? "dark" : "light";
|
||||
const themeSource = getSettingsChromeSnapshot().theme === "system" ? "system" : targetClass;
|
||||
netcattyBridge.get()?.setTheme?.(themeSource);
|
||||
netcattyBridge.get()?.setBackgroundColor?.(theme.colors.background);
|
||||
@@ -250,10 +250,11 @@ function refreshActiveChromeThemeSurfaces(theme: TerminalTheme) {
|
||||
export function syncActiveChromeTheme(
|
||||
activeTheme: TerminalTheme | null,
|
||||
applyAppTheme: () => void,
|
||||
{ refreshSurfaces = false }: { refreshSurfaces?: boolean } = {},
|
||||
): void {
|
||||
const nextFingerprint = activeTheme ? themeFingerprint(activeTheme) : null;
|
||||
const appliedFingerprint = getAppliedChromeFingerprint();
|
||||
if (nextFingerprint === appliedFingerprint) {
|
||||
if (nextFingerprint === appliedFingerprint && !(activeTheme && refreshSurfaces)) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -283,8 +284,11 @@ export function useActiveChromeTheme({
|
||||
applyAppThemeRef.current = applyAppTheme;
|
||||
|
||||
useLayoutEffect(() => {
|
||||
syncActiveChromeTheme(effectiveTheme, applyAppTheme);
|
||||
}, [effectiveTheme, applyAppTheme, pluginThemeRevision]);
|
||||
// Settings may have just applied the OS theme over a fixed terminal theme.
|
||||
// Reassert its visual surfaces even when the terminal fingerprint is stable;
|
||||
// tab-switch callers retain the default fingerprint fast path.
|
||||
syncActiveChromeTheme(effectiveTheme, applyAppTheme, { refreshSurfaces: true });
|
||||
}, [effectiveTheme, applyAppTheme, pluginThemeRevision, resolvedTheme]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
return () => {
|
||||
|
||||
@@ -2,8 +2,8 @@ import { useCallback } from 'react';
|
||||
import { STORAGE_KEY_COMPOSE_BAR_HEIGHT } from '../../infrastructure/config/storageKeys';
|
||||
import { useStoredNumber } from './useStoredNumber';
|
||||
|
||||
export const COMPOSE_BAR_HEIGHT_DEFAULT = 120;
|
||||
export const COMPOSE_BAR_HEIGHT_MIN = 72;
|
||||
export const COMPOSE_BAR_HEIGHT_DEFAULT = 144;
|
||||
export const COMPOSE_BAR_HEIGHT_MIN = 104;
|
||||
export const COMPOSE_BAR_HEIGHT_MAX = 360;
|
||||
|
||||
const HEIGHT_CLAMP = { min: COMPOSE_BAR_HEIGHT_MIN, max: COMPOSE_BAR_HEIGHT_MAX };
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
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 { useComposeDirectoryPicker } from './useComposeDirectoryPicker';
|
||||
import { createTerminalDirectoryController } from './terminalDirectoryController';
|
||||
import { refreshObservedTerminalDirectory, registerTerminalDirectory } from './terminalDirectoryRegistry';
|
||||
import { terminalCwdStore } from './terminalCwdStore';
|
||||
import { DirectoryPickerError } from '../../domain/directoryPicker';
|
||||
|
||||
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
|
||||
|
||||
test('opening loads children once; search is local; unmount stops chip refresh demand', async () => {
|
||||
let reads = 0; let probes = 0;
|
||||
const id = 'hook-directory';
|
||||
const controller = createTerminalDirectoryController({ sessionId: id,
|
||||
io: { cwd: async () => { probes++; return '/base'; }, realpath: async path => path, write() {},
|
||||
list: async () => { reads++; return { home: '/base', entries: [{ name: 'child', path: '/base/child', hidden: false }] }; } },
|
||||
isCurrent: () => true, isReady: () => true,
|
||||
publish: cwd => { terminalCwdStore.setCwd(id, cwd, 'backend-strict'); },
|
||||
});
|
||||
const cleanup = registerTerminalDirectory(id, controller);
|
||||
let model!: ReturnType<typeof useComposeDirectoryPicker>;
|
||||
function Harness() { model = useComposeDirectoryPicker(id); return null; }
|
||||
let root!: ReactTestRenderer;
|
||||
await act(async () => { root = create(React.createElement(Harness)); });
|
||||
assert.equal(model.cwd, '/base');
|
||||
await act(async () => { model.setOpen(true); });
|
||||
assert.equal(reads, 1);
|
||||
await act(async () => { model.setQuery('CH'); });
|
||||
assert.equal(model.entries.length, 1);
|
||||
assert.equal(reads, 1);
|
||||
await act(async () => { root.unmount(); });
|
||||
const before = probes;
|
||||
refreshObservedTerminalDirectory(id);
|
||||
await Promise.resolve();
|
||||
assert.equal(probes, before);
|
||||
cleanup();
|
||||
});
|
||||
|
||||
test('switching terminal cancels an old directory read and closes its popover', async () => {
|
||||
let finish!: (value: { entries: { name: string; path: string; hidden: boolean }[]; home: null }) => void;
|
||||
const cleanups = ['hook-a', 'hook-b'].map(id => registerTerminalDirectory(id, createTerminalDirectoryController({
|
||||
sessionId: id, isCurrent: () => true, isReady: () => true,
|
||||
io: { cwd: async () => '/' + id, realpath: async path => path, write() {},
|
||||
list: () => new Promise(resolve => { finish = resolve; }) },
|
||||
publish: cwd => { terminalCwdStore.setCwd(id, cwd, 'backend-strict'); },
|
||||
})));
|
||||
let model!: ReturnType<typeof useComposeDirectoryPicker>;
|
||||
function Harness({ id }: { id: string }) { model = useComposeDirectoryPicker(id); return null; }
|
||||
let root!: ReactTestRenderer;
|
||||
await act(async () => { root = create(React.createElement(Harness, { id: 'hook-a' })); });
|
||||
await act(async () => { model.setOpen(true); });
|
||||
await act(async () => { root.update(React.createElement(Harness, { id: 'hook-b' })); });
|
||||
await act(async () => { finish({ entries: [{ name: 'old', path: '/hook-a/old', hidden: false }], home: null }); });
|
||||
assert.equal(model.open, false);
|
||||
assert.equal(model.cwd, '/hook-b');
|
||||
assert.deepEqual(model.entries, []);
|
||||
await act(async () => { root.unmount(); });
|
||||
cleanups.forEach(cleanup => cleanup());
|
||||
});
|
||||
|
||||
test('reconnect after an aborted listing allows manual recovery from a failed cwd probe', async t => {
|
||||
const id = 'hook-directory-reconnect';
|
||||
let finishOld!: (value: { entries: { name: string; path: string; hidden: boolean }[]; home: null }) => void;
|
||||
const oldController = createTerminalDirectoryController({
|
||||
sessionId: id, isCurrent: () => true, isReady: () => true,
|
||||
io: { cwd: async () => '/old', realpath: async value => value, write() {},
|
||||
list: () => new Promise(resolve => { finishOld = resolve; }) },
|
||||
publish: cwd => { terminalCwdStore.setCwd(id, cwd, 'backend-strict'); },
|
||||
});
|
||||
const unregisterOld = registerTerminalDirectory(id, oldController);
|
||||
let model!: ReturnType<typeof useComposeDirectoryPicker>;
|
||||
function Harness() { model = useComposeDirectoryPicker(id); return null; }
|
||||
let root!: ReactTestRenderer;
|
||||
let unregisterNew = () => {};
|
||||
t.after(async () => {
|
||||
await act(async () => { root.unmount(); });
|
||||
unregisterOld(); unregisterNew();
|
||||
});
|
||||
await act(async () => { root = create(React.createElement(Harness)); });
|
||||
await act(async () => { model.setOpen(true); });
|
||||
assert.equal(model.loading, true);
|
||||
// Close while the request is pending, so the read is already aborted when
|
||||
// the disconnect disposes its controller and settles the old promise.
|
||||
await act(async () => { model.setOpen(false); });
|
||||
await act(async () => { unregisterOld(); terminalCwdStore.setCwd(id, null); });
|
||||
let failProbe = true;
|
||||
const nextController = createTerminalDirectoryController({
|
||||
sessionId: id, isCurrent: () => true, isReady: () => true,
|
||||
io: { cwd: async () => { if (failProbe) throw new DirectoryPickerError('unknown'); return '/recovered'; },
|
||||
realpath: async value => value, write() {},
|
||||
list: async () => ({ entries: [{ name: 'new', path: '/recovered/new', hidden: false }], home: null }) },
|
||||
publish: cwd => { terminalCwdStore.setCwd(id, cwd, 'backend-strict'); },
|
||||
});
|
||||
await act(async () => { unregisterNew = registerTerminalDirectory(id, nextController); });
|
||||
assert.equal(model.cwd, null);
|
||||
assert.equal(model.error, 'unknown');
|
||||
assert.equal(model.loading, false, 'cancelled reads must not leave the manual Refresh action disabled');
|
||||
await act(async () => { failProbe = false; await model.refresh(); model.setOpen(true); });
|
||||
assert.equal(model.cwd, '/recovered');
|
||||
assert.equal(model.loading, false);
|
||||
assert.deepEqual(model.entries.map(entry => entry.name), ['new']);
|
||||
await act(async () => { finishOld({ entries: [{ name: 'stale', path: '/old/stale', hidden: false }], home: null }); });
|
||||
assert.deepEqual(model.entries.map(entry => entry.name), ['new']);
|
||||
});
|
||||
@@ -0,0 +1,94 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore } from 'react';
|
||||
import { DirectoryPickerError, directoryParent, filterDirectories, type DirectoryEntry, type DirectoryPickerErrorCode } from '../../domain/directoryPicker';
|
||||
import { terminalCwdStore } from './terminalCwdStore';
|
||||
import { getTerminalDirectory, observeTerminalDirectory, subscribeTerminalDirectories } from './terminalDirectoryRegistry';
|
||||
|
||||
export function useComposeDirectoryPicker(sessionId: string) {
|
||||
const controller = useSyncExternalStore(subscribeTerminalDirectories, () => getTerminalDirectory(sessionId), () => null);
|
||||
useSyncExternalStore(terminalCwdStore.subscribe, terminalCwdStore.getVersion, terminalCwdStore.getVersion);
|
||||
const [confirmed, setConfirmed] = useState(false);
|
||||
const cwd = controller && (confirmed || controller.hasConfirmedCwd() || terminalCwdStore.getSource(sessionId) === 'osc7')
|
||||
? terminalCwdStore.getCwd(sessionId) : null;
|
||||
const [open, setOpen] = useState(false);
|
||||
const [query, setQuery] = useState('');
|
||||
const [showHidden, setShowHidden] = useState(false);
|
||||
const [entries, setEntries] = useState<DirectoryEntry[]>([]);
|
||||
const [home, setHome] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [pending, setPending] = useState(false);
|
||||
const listingBase = useRef<string | null>(null);
|
||||
const [error, setError] = useState<DirectoryPickerErrorCode | null>(null);
|
||||
const generation = useRef(0);
|
||||
const lifetime = useRef(new AbortController());
|
||||
const read = useRef<AbortController | null>(null);
|
||||
const sending = useRef(false);
|
||||
const report = useCallback((failure: unknown, fallback: DirectoryPickerErrorCode) => {
|
||||
setError(failure instanceof DirectoryPickerError ? failure.code : fallback);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const id = ++generation.current;
|
||||
const stopObserving = observeTerminalDirectory(sessionId);
|
||||
lifetime.current = new AbortController();
|
||||
sending.current = false;
|
||||
setConfirmed(false); setOpen(false); setPending(false); setLoading(false); setHome(null); setError(null); setEntries([]);
|
||||
listingBase.current = null;
|
||||
if (controller) void Promise.all([controller.refresh(), controller.home()]).then(([, nextHome]) => {
|
||||
if (generation.current === id) { setConfirmed(true); setHome(nextHome); }
|
||||
}).catch(failure => { if (generation.current === id) report(failure, 'unknown'); });
|
||||
return () => {
|
||||
generation.current = id + 1;
|
||||
lifetime.current.abort(); read.current?.abort(); stopObserving();
|
||||
};
|
||||
}, [sessionId, controller, report]);
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
if (!controller) { setError('unavailable'); return; }
|
||||
const id = generation.current;
|
||||
setError(null);
|
||||
try { await controller.refresh(); if (generation.current === id) setConfirmed(true); }
|
||||
catch (failure) { if (generation.current === id) report(failure, 'unknown'); }
|
||||
}, [controller, report]);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
if (!controller || !cwd) return;
|
||||
read.current?.abort();
|
||||
const task = new AbortController(); read.current = task;
|
||||
const id = generation.current;
|
||||
setLoading(true); setEntries([]); setError(null);
|
||||
try {
|
||||
const result = await controller.list(cwd, task.signal);
|
||||
if (!task.signal.aborted && id === generation.current) {
|
||||
listingBase.current = cwd; setEntries(result.entries); setHome(result.home);
|
||||
}
|
||||
} catch (failure) {
|
||||
if (!task.signal.aborted && id === generation.current) report(failure, 'readFailed');
|
||||
} finally { if (!task.signal.aborted && id === generation.current) setLoading(false); }
|
||||
}, [controller, cwd, report]);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) { setQuery(''); void load(); }
|
||||
return () => { read.current?.abort(); };
|
||||
}, [open, load]);
|
||||
|
||||
const change = useCallback(async (path: string) => {
|
||||
if (!controller) { setError('unavailable'); return; }
|
||||
if (sending.current) return;
|
||||
if (!cwd || terminalCwdStore.getCwd(sessionId) !== cwd
|
||||
|| (path !== directoryParent(cwd) && listingBase.current !== cwd)) { setError('unavailable'); return; }
|
||||
sending.current = true; setPending(true); setError(null);
|
||||
const id = generation.current;
|
||||
try {
|
||||
await controller.change(path, lifetime.current.signal, () => {
|
||||
if (id === generation.current) setOpen(false);
|
||||
}, cwd ?? undefined);
|
||||
} catch (failure) { if (id === generation.current) report(failure, 'changeFailed'); }
|
||||
finally {
|
||||
if (id === generation.current) { sending.current = false; setPending(false); }
|
||||
}
|
||||
}, [controller, report, cwd, sessionId]);
|
||||
|
||||
const filtered = useMemo(() => filterDirectories(entries, query, showHidden), [entries, query, showHidden]);
|
||||
return { cwd, home, open, setOpen, query, setQuery, showHidden, setShowHidden, entries: filtered,
|
||||
parent: cwd ? directoryParent(cwd) : null, loading, pending, error, available: !!controller, refresh, reload: load, change };
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { netcattyBridge } from '../../infrastructure/services/netcattyBridge';
|
||||
import { parsePluginGitHubRepository } from '@/domain/pluginGitHubRepository.cjs';
|
||||
|
||||
export function usePluginGitHubInstall(onInstalled: () => Promise<unknown>) {
|
||||
const [preview, setPreview] = useState<NetcattyGitHubPluginPreview | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const token = useRef<string | null>(null);
|
||||
const active = useRef(false);
|
||||
const mounted = useRef(true);
|
||||
const reset = useCallback(() => {
|
||||
const previous = token.current;
|
||||
token.current = null;
|
||||
setPreview(null);
|
||||
setError(null);
|
||||
if (previous) void netcattyBridge.get()?.discardGitHubPlugin?.(previous).catch(() => {});
|
||||
}, []);
|
||||
useEffect(() => {
|
||||
mounted.current = true;
|
||||
return () => {
|
||||
mounted.current = false;
|
||||
if (token.current) void netcattyBridge.get()?.discardGitHubPlugin?.(token.current).catch(() => {});
|
||||
};
|
||||
}, []);
|
||||
const run = async (operation: () => Promise<void>) => {
|
||||
if (active.current) return;
|
||||
active.current = true;
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try { await operation(); }
|
||||
catch (cause) { if (mounted.current) setError(cause instanceof Error ? cause.message : String(cause)); }
|
||||
finally { active.current = false; if (mounted.current) setBusy(false); }
|
||||
};
|
||||
return {
|
||||
preview, busy, error, reset,
|
||||
prepare: (input: string) => run(async () => {
|
||||
const { origin } = parsePluginGitHubRepository(input);
|
||||
const bridge = netcattyBridge.require();
|
||||
if (!bridge.prepareGitHubPlugin) throw new Error('GitHub plugin installation unavailable');
|
||||
reset();
|
||||
const result = await bridge.prepareGitHubPlugin(origin);
|
||||
if (!mounted.current) { await bridge.discardGitHubPlugin?.(result.token); return; }
|
||||
token.current = result.token;
|
||||
setPreview(result);
|
||||
}),
|
||||
install: () => run(async () => {
|
||||
const bridge = netcattyBridge.require();
|
||||
if (!bridge.installGitHubPlugin || !token.current) throw new Error('Download and review a GitHub plugin first');
|
||||
const selected = token.current;
|
||||
// The host consumes the selection even on failure; retry starts with a new review.
|
||||
token.current = null;
|
||||
setPreview(null);
|
||||
await bridge.installGitHubPlugin(selected);
|
||||
await onInstalled();
|
||||
window.dispatchEvent(new Event('netcatty:plugin-packages-changed'));
|
||||
}),
|
||||
};
|
||||
}
|
||||
@@ -1231,6 +1231,15 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
|
||||
return () => mql.removeEventListener('change', handler);
|
||||
}, []);
|
||||
|
||||
// Listen for system theme changes from main process (fallback when matchMedia doesn't fire)
|
||||
useEffect(() => {
|
||||
const bridge = netcattyBridge.get();
|
||||
if (!bridge?.onSystemThemeChanged) return;
|
||||
return bridge.onSystemThemeChanged((payload) => {
|
||||
setSystemPreference(payload.isDark ? 'dark' : 'light');
|
||||
});
|
||||
}, []);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
localStorageAdapter.writeString(STORAGE_KEY_UI_LANGUAGE, uiLanguage);
|
||||
document.documentElement.lang = uiLanguage;
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test, { type TestContext } from 'node:test';
|
||||
import React from 'react';
|
||||
import { act, create, type ReactTestRenderer } from 'react-test-renderer';
|
||||
import type { Host } from '../../domain/models';
|
||||
import { getTerminalDirectory, observeTerminalDirectory } from './terminalDirectoryRegistry';
|
||||
import { useTerminalDirectorySession } from './useTerminalDirectorySession';
|
||||
import { useComposeDirectoryPicker } from './useComposeDirectoryPicker';
|
||||
|
||||
type SessionProps = Parameters<typeof useTerminalDirectorySession>[0];
|
||||
|
||||
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
|
||||
|
||||
function installBridge(t: TestContext, getAppInfo?: NetcattyBridge['getAppInfo']) {
|
||||
const previous = Object.getOwnPropertyDescriptor(globalThis, 'window');
|
||||
let probes = 0; let lists = 0;
|
||||
Object.defineProperty(globalThis, 'window', { configurable: true, value: { netcatty: {
|
||||
getAppInfo,
|
||||
getSessionPwd: async () => { probes++; return { success: true, cwd: '/actual' }; },
|
||||
listLocalDir: async () => { lists++; return []; },
|
||||
} } });
|
||||
t.after(() => {
|
||||
if (previous) Object.defineProperty(globalThis, 'window', previous);
|
||||
else Reflect.deleteProperty(globalThis, 'window');
|
||||
});
|
||||
return { counts: () => ({ probes, lists }) };
|
||||
}
|
||||
|
||||
const options = (id: string, protocol = 'local', shellType = 'posix'): SessionProps => ({
|
||||
sessionId: id, status: 'connected',
|
||||
host: { id, label: 'Test', hostname: 'host', username: 'me', protocol } as Host,
|
||||
shellType, getBackendId: () => id, isReady: () => true, publish() {},
|
||||
});
|
||||
|
||||
for (const wrapper of ['wsl-Ubuntu', 'git-bash', 'cygwin']) {
|
||||
test(`Windows ${wrapper} reports directory navigation unavailable and performs no reads`, async t => {
|
||||
const bridge = installBridge(t, async () => ({ name: 'Netcatty', version: 'test', platform: 'win32' }));
|
||||
const id = 'windows-' + wrapper;
|
||||
let model!: ReturnType<typeof useComposeDirectoryPicker>;
|
||||
function Harness() {
|
||||
const props = options(id);
|
||||
props.host = { ...props.host, localShell: wrapper };
|
||||
useTerminalDirectorySession(props);
|
||||
model = useComposeDirectoryPicker(id);
|
||||
return null;
|
||||
}
|
||||
let root!: ReactTestRenderer;
|
||||
await act(async () => { root = create(React.createElement(Harness)); });
|
||||
t.after(async () => { await act(async () => { root.unmount(); }); });
|
||||
assert.equal(getTerminalDirectory(id), null);
|
||||
assert.equal(model.available, false);
|
||||
await act(async () => { model.setOpen(true); await model.refresh(); });
|
||||
assert.equal(model.error, 'unavailable');
|
||||
assert.deepEqual(bridge.counts(), { probes: 0, lists: 0 });
|
||||
});
|
||||
}
|
||||
|
||||
for (const platform of ['linux', 'darwin']) {
|
||||
test(`local POSIX directory controller registers on ${platform}`, async t => {
|
||||
installBridge(t, async () => ({ name: 'Netcatty', version: 'test', platform }));
|
||||
const id = 'local-' + platform;
|
||||
function Harness() { useTerminalDirectorySession(options(id)); return null; }
|
||||
let root!: ReactTestRenderer;
|
||||
await act(async () => { root = create(React.createElement(Harness)); });
|
||||
t.after(async () => { await act(async () => { root.unmount(); }); });
|
||||
assert.ok(getTerminalDirectory(id));
|
||||
});
|
||||
}
|
||||
|
||||
test('Windows SSH sessions do not inherit the local cwd platform limitation', async t => {
|
||||
let platformQueries = 0;
|
||||
installBridge(t, async () => { platformQueries++; return { name: 'Netcatty', version: 'test', platform: 'win32' }; });
|
||||
const id = 'windows-ssh';
|
||||
function Harness() { useTerminalDirectorySession(options(id, 'ssh')); return null; }
|
||||
let root!: ReactTestRenderer;
|
||||
await act(async () => { root = create(React.createElement(Harness)); });
|
||||
t.after(async () => { await act(async () => { root.unmount(); }); });
|
||||
assert.ok(getTerminalDirectory(id));
|
||||
assert.equal(platformQueries, 0);
|
||||
});
|
||||
|
||||
test('a late platform query cannot register a disconnected terminal', async t => {
|
||||
let finish!: (info: { name: string; version: string; platform: string }) => void;
|
||||
installBridge(t, () => new Promise(resolve => { finish = resolve; }));
|
||||
const id = 'disconnected-during-platform-query';
|
||||
const stopObserving = observeTerminalDirectory(id);
|
||||
t.after(stopObserving);
|
||||
function Harness({ status }: { status: string }) { useTerminalDirectorySession({ ...options(id), status }); return null; }
|
||||
let root!: ReactTestRenderer;
|
||||
await act(async () => { root = create(React.createElement(Harness, { status: 'connected' })); });
|
||||
await act(async () => { root.update(React.createElement(Harness, { status: 'disconnected' })); });
|
||||
await act(async () => { finish({ name: 'Netcatty', version: 'test', platform: 'linux' }); });
|
||||
assert.equal(getTerminalDirectory(id), null);
|
||||
await act(async () => { root.unmount(); });
|
||||
});
|
||||
@@ -0,0 +1,56 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
import type { Host } from '../../domain/models';
|
||||
import { classifyDistroId, hostRestrictsExtraSshChannels } from '../../domain/host';
|
||||
import { netcattyBridge } from '../../infrastructure/services/netcattyBridge';
|
||||
import { createTerminalDirectoryIO } from '../../infrastructure/services/terminalDirectory';
|
||||
import { createTerminalDirectoryController } from './terminalDirectoryController';
|
||||
import { registerTerminalDirectory } from './terminalDirectoryRegistry';
|
||||
|
||||
export function useTerminalDirectorySession(options: {
|
||||
sessionId: string;
|
||||
status: string;
|
||||
host: Host;
|
||||
shellType?: string;
|
||||
getBackendId: () => string | null;
|
||||
isReady: () => boolean;
|
||||
publish: (cwd: string) => void;
|
||||
}) {
|
||||
const live = useRef(options);
|
||||
live.current = options;
|
||||
const { sessionId, status, shellType, host } = options;
|
||||
const local = host.protocol === 'local';
|
||||
const supported = (local || ((host.protocol ?? 'ssh') === 'ssh' && !host.moshEnabled && !host.etEnabled))
|
||||
&& shellType !== 'powershell' && shellType !== 'cmd'
|
||||
&& host.deviceType !== 'network' && classifyDistroId(host.distro) !== 'network-device'
|
||||
&& !hostRestrictsExtraSshChannels(host);
|
||||
useEffect(() => {
|
||||
const bridge = netcattyBridge.get();
|
||||
const backendId = live.current.getBackendId();
|
||||
if (!bridge || !backendId || status !== 'connected' || !supported) return;
|
||||
let active = true;
|
||||
const isCurrent = () => active && live.current.status === 'connected' && live.current.getBackendId() === backendId;
|
||||
let unregister = () => {};
|
||||
const register = () => {
|
||||
if (!isCurrent()) return;
|
||||
const controller = createTerminalDirectoryController({
|
||||
sessionId,
|
||||
io: createTerminalDirectoryIO(bridge, backendId, {
|
||||
local, hostname: host.hostname, username: host.username || 'root', port: host.port,
|
||||
encoding: host.sftpEncoding,
|
||||
}, isCurrent),
|
||||
isCurrent,
|
||||
isReady: () => live.current.isReady(),
|
||||
publish: cwd => live.current.publish(cwd),
|
||||
});
|
||||
unregister = registerTerminalDirectory(sessionId, controller);
|
||||
};
|
||||
if (local) {
|
||||
// POSIX wrappers on Windows still use win32 PTYs. Neither their cwd nor
|
||||
// their path namespace is supported by readLocalSessionCwd/local FS.
|
||||
void bridge.getAppInfo?.().then(info => {
|
||||
if (info.platform === 'darwin' || info.platform === 'linux') register();
|
||||
}).catch(() => {});
|
||||
} else register();
|
||||
return () => { active = false; unregister(); };
|
||||
}, [sessionId, status, supported, local, host.hostname, host.username, host.port, host.sftpEncoding, shellType]);
|
||||
}
|
||||
@@ -8,6 +8,8 @@ import React from 'react';
|
||||
import { act, create, type ReactTestRenderer } from 'react-test-renderer';
|
||||
import { getAgentRuntime } from '../infrastructure/ai/harness/globalAgentRuntime';
|
||||
import { useAIChatStreaming, type UseAIChatStreamingReturn } from './state/useAIChatStreaming';
|
||||
import { installFakeIndexedDb } from './state/testSupport/fakeIndexedDb';
|
||||
import { idbPutSessions, openAiSessionsIdb } from './state/aiSessionIdbStorage';
|
||||
|
||||
installBrowserLockFixture();
|
||||
|
||||
@@ -43,7 +45,47 @@ function save(sessions: AISession[], enabled = true) {
|
||||
storage.set(keys.STORAGE_KEY_AI_SESSIONS, JSON.stringify(sessions));
|
||||
storage.set(keys.STORAGE_KEY_CLOUD_SYNC_AI_SESSIONS, JSON.stringify({ enabled }));
|
||||
}
|
||||
test.beforeEach(() => { storage.clear(); save([], false); });
|
||||
test.beforeEach(() => {
|
||||
storage.clear();
|
||||
save([], false);
|
||||
snapshots.__resetIdbPersistenceForTests();
|
||||
});
|
||||
test.afterEach(() => {
|
||||
snapshots.__resetIdbPersistenceForTests();
|
||||
});
|
||||
|
||||
// Issue #4125 review P1①: a sync round that runs before the boot hydration
|
||||
// has settled must not collect (and persist) the stale legacy store — it
|
||||
// would overwrite newer IndexedDB records with pre-migration data.
|
||||
test('a sync round before boot hydration skips chat collection and never overwrites IndexedDB with stale legacy data', async () => {
|
||||
const idb = installFakeIndexedDb();
|
||||
try {
|
||||
snapshots.__resetIdbPersistenceForTests();
|
||||
snapshots.__resetAiSessionsHydrationForTests(); // boot load still pending
|
||||
const newer = { ...session(), title: 'Newer IndexedDB copy', updatedAt: 9 };
|
||||
await idbPutSessions(await openAiSessionsIdb(), [newer]);
|
||||
// Stale pre-migration copy left in the read-only legacy key.
|
||||
storage.set(keys.STORAGE_KEY_AI_SESSIONS, JSON.stringify([{ ...session(), title: 'Stale legacy copy', updatedAt: 1 }]));
|
||||
storage.set(keys.STORAGE_KEY_CLOUD_SYNC_AI_SESSIONS, JSON.stringify({ enabled: true }));
|
||||
snapshots.setLatestAISessionsSnapshot(null as unknown as AISession[]);
|
||||
// Pre-hydration collection skips the round entirely.
|
||||
assert.equal(collectAiSessionsForSync(), undefined);
|
||||
await snapshots.__flushIdbWritesForTests();
|
||||
assert.equal(
|
||||
idb.db.dumpStore('sessions').find((s) => (s as AISession).id === 'chat')?.title,
|
||||
'Newer IndexedDB copy',
|
||||
);
|
||||
// After hydration the collector returns the live snapshot again.
|
||||
snapshots.markAiSessionsHydrationComplete();
|
||||
const hydrated = [newer];
|
||||
snapshots.seedLastPersistedSessions(hydrated);
|
||||
snapshots.setLatestAISessionsSnapshot(hydrated);
|
||||
assert.equal(collectAiSessionsForSync()?.[0].title, 'Newer IndexedDB copy');
|
||||
} finally {
|
||||
idb.restore();
|
||||
snapshots.__resetIdbPersistenceForTests();
|
||||
}
|
||||
});
|
||||
|
||||
test('actual manual upload and auto collector honor consent and explicit empty', async () => {
|
||||
save([session()], false);
|
||||
@@ -74,23 +116,16 @@ test('sync persistence survives restart beyond the local-only history limits', (
|
||||
assert.equal(afterRestart[0].messages.length, 201);
|
||||
save(many, false);
|
||||
assert.equal(snapshots.writeSessionsForStorage(many), true);
|
||||
const localOnly = JSON.parse(storage.get(keys.STORAGE_KEY_AI_SESSIONS)!);
|
||||
assert.equal(localOnly.length, 50);
|
||||
// Issue #4123: the persisted set now lives in IndexedDB (per-record
|
||||
// writes); the legacy localStorage key is a read-only fallback. The
|
||||
// session-count cap rose to 1000, so all 51 chats survive retention —
|
||||
// only the per-session message cap (200) still trims.
|
||||
const localOnly = snapshots.getPersistedAiSessionsSnapshot();
|
||||
assert.equal(localOnly.length, 51);
|
||||
assert.equal(localOnly[0].messages.length, 200);
|
||||
});
|
||||
|
||||
test('sync collection after restart includes overflow before clearing it', () => {
|
||||
const primary = session();
|
||||
const overflow = { ...session(), id: 'overflow-chat', updatedAt: 1 };
|
||||
save([primary], true);
|
||||
storage.set(keys.STORAGE_KEY_AI_SESSIONS_OVERFLOW, JSON.stringify([overflow]));
|
||||
snapshots.setLatestAISessionsSnapshot(null as unknown as AISession[]);
|
||||
assert.deepEqual(collectAiSessionsForSync()?.map(value => value.id), [primary.id, overflow.id]);
|
||||
assert.equal(storage.has(keys.STORAGE_KEY_AI_SESSIONS_OVERFLOW), false);
|
||||
assert.deepEqual(JSON.parse(storage.get(keys.STORAGE_KEY_AI_SESSIONS)!).map((value: AISession) => value.id), [primary.id, overflow.id]);
|
||||
});
|
||||
|
||||
test('failed history persistence refuses upload without pruning the cloud representation', async () => {
|
||||
test('legacy localStorage quota failures no longer block sync upload', async () => {
|
||||
save([session()]);
|
||||
const original = globalThis.localStorage.setItem;
|
||||
globalThis.localStorage.setItem = (key, value) => {
|
||||
@@ -99,8 +134,12 @@ test('failed history persistence refuses upload without pruning the cloud repres
|
||||
};
|
||||
snapshots.setLatestAISessionsSnapshot([{ ...session(), title: 'Unsaved change' }]);
|
||||
try {
|
||||
await assert.rejects(buildCloudSyncPayload(vault, [], { collectPluginSidecars: () => undefined }), /persist|save/i);
|
||||
assert.equal(JSON.parse(storage.get(keys.STORAGE_KEY_AI_SESSIONS)!)[0].title, 'Chat');
|
||||
// Persistence moved to IndexedDB (issue #4123), so the legacy key's
|
||||
// quota can no longer fail the collect step; the in-memory snapshot is
|
||||
// the source of truth for the upload.
|
||||
const cloud = await buildCloudSyncPayload(vault, [], { collectPluginSidecars: () => undefined });
|
||||
assert.equal(cloud.aiSessions?.[0].title, 'Unsaved change');
|
||||
assert.equal(JSON.stringify(cloud).includes('cHJpdmF0ZQ=='), false);
|
||||
const backup = buildLocalVaultPayload(vault);
|
||||
assert.equal(backup.aiSessions?.[0].title, 'Unsaved change');
|
||||
assert.equal(backup.aiSessions?.[0].messages[0].attachments?.[0].base64Data, 'cHJpdmF0ZQ==');
|
||||
@@ -164,7 +203,7 @@ test('download publishes immediately and keeps local attachment bytes on a newer
|
||||
assert.equal(current.messages[0].content, 'Updated');
|
||||
assert.equal(current.messages[0].attachments?.[0].base64Data, 'cHJpdmF0ZQ==');
|
||||
assert.equal(snapshots.latestAISessionsSnapshot?.[0], current);
|
||||
assert.equal(JSON.parse(storage.get(keys.STORAGE_KEY_AI_SESSIONS)!)[0].messages[0].content, 'Updated');
|
||||
assert.equal(snapshots.getPersistedAiSessionsSnapshot()[0].messages[0].content, 'Updated');
|
||||
});
|
||||
|
||||
test('restoring an old backup without chat leaves current chats alone', async () => {
|
||||
@@ -174,7 +213,7 @@ test('restoring an old backup without chat leaves current chats alone', async ()
|
||||
prepareConvergentRestore: async () => async () => {},
|
||||
});
|
||||
assert.deepEqual(snapshots.latestAISessionsSnapshot, [local]);
|
||||
assert.deepEqual(JSON.parse(storage.get(keys.STORAGE_KEY_AI_SESSIONS)!), [local]);
|
||||
assert.deepEqual(snapshots.getPersistedAiSessionsSnapshot(), [local]);
|
||||
});
|
||||
|
||||
test('authoritative cloud session removal stays removed in the next upload', async () => {
|
||||
@@ -202,6 +241,9 @@ test('replacement stops active turns and waits for finalizers before persisting
|
||||
const local = { ...session(), messages: [...session().messages,
|
||||
{ id: 'reply', role: 'assistant' as const, content: 'Partial', timestamp: 2, executionStatus: 'running' as const }] };
|
||||
save([local]);
|
||||
// Seed the per-record diff baseline so the only 'persist' marker in
|
||||
// this scenario is the final replacement write (issue #4123).
|
||||
snapshots.seedLastPersistedSessions([local]);
|
||||
hook!.abortControllersRef.current.set('chat', controller);
|
||||
await act(async () => { hook!.setStreamingForScope('chat', true); });
|
||||
Object.assign(window, { netcatty: {
|
||||
@@ -216,15 +258,24 @@ test('replacement stops active turns and waits for finalizers before persisting
|
||||
{ ...local.messages[1], content: 'Settled', executionStatus: 'cancelled' }] }]);
|
||||
order.push('settled');
|
||||
});
|
||||
globalThis.localStorage.setItem = (key, value) => { if (key === keys.STORAGE_KEY_AI_SESSIONS) order.push('persist'); original(key, value); };
|
||||
// Issue #4123: session persistence is an IndexedDB commit now; the
|
||||
// 'persist' marker fires when the record lands in the store.
|
||||
globalThis.localStorage.setItem = original;
|
||||
const idb = installFakeIndexedDb({ onOperation: (store, _op) => {
|
||||
// Both puts and deletes are persistence commits for the sessions store.
|
||||
if (store === 'sessions') order.push('persist');
|
||||
} });
|
||||
const incoming = { ...session(), updatedAt: 3, messages: [...session().messages,
|
||||
{ id: 'remote', role: 'user' as const, content: 'Remote', timestamp: 3 }] };
|
||||
await applyLocalVaultPayload(remotePayload(remove ? [] : [incoming]), { importVaultData: () => undefined }, {
|
||||
prepareConvergentRestore: async () => async () => {},
|
||||
});
|
||||
await snapshots.__flushIdbWritesForTests();
|
||||
idb.restore();
|
||||
snapshots.__resetIdbPersistenceForTests();
|
||||
assert.ok(order.indexOf('cancel') < order.indexOf('settled'));
|
||||
assert.ok(order.indexOf('settled') < order.indexOf('persist'));
|
||||
assert.ok(order.indexOf('persist') < order.indexOf('cleanup'));
|
||||
assert.ok(order.includes('cleanup'));
|
||||
assert.equal(hook!.abortControllersRef.current.has('chat'), false);
|
||||
if (remove) assert.deepEqual(getAISessionsSnapshot().sessions, []);
|
||||
else assert.equal(getAISessionsSnapshot().sessions[0].messages.at(-1)?.content, 'Remote');
|
||||
@@ -268,7 +319,7 @@ test('cloud sync preserves an active trimmed conversation and its native thread'
|
||||
assert.equal(controller.signal.aborted, false);
|
||||
assert.deepEqual(calls, []);
|
||||
assert.deepEqual(getAISessionsSnapshot().sessions, [local]);
|
||||
assert.equal(JSON.parse(storage.get(keys.STORAGE_KEY_AI_SESSIONS)!)[0].externalSessionId, 'native-thread');
|
||||
assert.equal(snapshots.getPersistedAiSessionsSnapshot()[0].externalSessionId, 'native-thread');
|
||||
}
|
||||
} finally {
|
||||
hook!.abortControllersRef.current.delete('chat');
|
||||
@@ -299,7 +350,7 @@ test('cloud downloads respect local chat opt-in while still applying vault data'
|
||||
await applySyncPayload(downloaded, { importVaultData: () => { vaultImports++; } });
|
||||
assert.equal(vaultImports, 1);
|
||||
assert.deepEqual(snapshots.latestAISessionsSnapshot, []);
|
||||
assert.deepEqual(JSON.parse(storage.get(keys.STORAGE_KEY_AI_SESSIONS)!), []);
|
||||
assert.deepEqual(snapshots.getPersistedAiSessionsSnapshot(), []);
|
||||
save([], true);
|
||||
await applySyncPayload(downloaded, { importVaultData: () => { vaultImports++; } });
|
||||
assert.equal(vaultImports, 2);
|
||||
|
||||
@@ -50,8 +50,9 @@ import { decryptField, encryptField } from '../infrastructure/persistence/secure
|
||||
import { sanitizeQuickMessages } from '../infrastructure/ai/quickMessages';
|
||||
import { emitAIStateChanged } from './state/aiStateEvents';
|
||||
import {
|
||||
isAiSessionsHydrationComplete,
|
||||
latestAISessionsSnapshot,
|
||||
readSessionsFromStorage,
|
||||
getPersistedAiSessionsSnapshot,
|
||||
setLatestAISessionsSnapshot,
|
||||
writeSessionsForStorage,
|
||||
cleanupSdkAgentSessions,
|
||||
@@ -1065,6 +1066,12 @@ function pruneOrphanPerAgentBindings(): void {
|
||||
export function collectAiSessionsForSync(): SyncPayload['aiSessions'] {
|
||||
const enabled = localStorageAdapter.read<{ enabled?: boolean }>(STORAGE_KEY_CLOUD_SYNC_AI_SESSIONS);
|
||||
if (enabled?.enabled !== true) return undefined;
|
||||
// Issue #4125 review P1①: never collect before the boot hydration has
|
||||
// settled. A pre-hydration collect falls back to the stale legacy
|
||||
// localStorage store and the writeSessionsForStorage() call below would
|
||||
// overwrite newer IndexedDB records with pre-migration data. Skip this
|
||||
// sync round; the next auto-sync picks up the hydrated store.
|
||||
if (!isAiSessionsHydrationComplete()) return undefined;
|
||||
const sessions = collectLocalAiSessions();
|
||||
if (!writeSessionsForStorage(sessions)) {
|
||||
throw new Error('Unable to persist chat history for cloud sync');
|
||||
@@ -1074,7 +1081,7 @@ export function collectAiSessionsForSync(): SyncPayload['aiSessions'] {
|
||||
|
||||
function collectLocalAiSessions(): NonNullable<SyncPayload['aiSessions']> {
|
||||
return latestAISessionsSnapshot
|
||||
?? readSessionsFromStorage();
|
||||
?? getPersistedAiSessionsSnapshot();
|
||||
}
|
||||
|
||||
/** Build the portable payload; device-local data is added by the backup builder. */
|
||||
|
||||
@@ -35,6 +35,7 @@ import {
|
||||
import {
|
||||
initializePreparedConvergentMigration,
|
||||
prepareConvergentSyncMigration,
|
||||
resolvePreparedConvergentMigrationConflict,
|
||||
type PreparedConvergentMigration,
|
||||
} from '../application/convergentSyncMigration';
|
||||
import type { ShrinkFinding } from '../domain/syncGuards';
|
||||
@@ -358,6 +359,15 @@ const SyncDashboard: React.FC<SyncDashboardProps> = ({
|
||||
setConvergentBusy(true);
|
||||
setConvergentError(null);
|
||||
try {
|
||||
if (preparedConvergentMigration && !sync.convergentSyncConfig.initialized) {
|
||||
const prepared = resolvePreparedConvergentMigrationConflict(
|
||||
preparedConvergentMigration, addressKey, candidateDot,
|
||||
);
|
||||
setPreparedConvergentMigration(prepared);
|
||||
setConvergentPreview(prepared.plan.preview);
|
||||
toast.success(t('cloudSync.convergent.conflict.resolved'));
|
||||
return;
|
||||
}
|
||||
const { results } = await sync.resolveConvergentConflict(
|
||||
addressKey,
|
||||
candidateDot,
|
||||
@@ -371,7 +381,7 @@ const SyncDashboard: React.FC<SyncDashboardProps> = ({
|
||||
} finally {
|
||||
setConvergentBusy(false);
|
||||
}
|
||||
}, [onApplyConvergentPayload, sync, t]);
|
||||
}, [onApplyConvergentPayload, preparedConvergentMigration, sync, t]);
|
||||
|
||||
const handleDowngradeConvergent = useCallback(async () => {
|
||||
if (!window.confirm(t('cloudSync.convergent.downgrade.confirm'))) return;
|
||||
|
||||
@@ -247,6 +247,8 @@ const SettingsAITabContainer: React.FC = () => {
|
||||
setResponseIdleTimeout={aiState.setResponseIdleTimeout}
|
||||
maxIterations={aiState.maxIterations}
|
||||
setMaxIterations={aiState.setMaxIterations}
|
||||
aiSessionMessageCap={aiState.aiSessionMessageCap}
|
||||
setAiSessionMessageCap={aiState.setAiSessionMessageCap}
|
||||
webSearchConfig={aiState.webSearchConfig}
|
||||
setWebSearchConfig={aiState.setWebSearchConfig}
|
||||
quickMessages={aiState.quickMessages}
|
||||
|
||||
+22
-1
@@ -1,3 +1,6 @@
|
||||
import { terminalCwdStore } from '../application/state/terminalCwdStore';
|
||||
import { useTerminalDirectorySession } from '../application/state/useTerminalDirectorySession';
|
||||
import { refreshObservedTerminalDirectory, scheduleObservedTerminalDirectoryRefresh } from '../application/state/terminalDirectoryRegistry';
|
||||
import { publishTerminalSelectionTarget } from "../infrastructure/plugins/terminalSelectionRegistry";
|
||||
import { usePluginTerminalSelection } from "../application/state/usePluginTerminalSelection";
|
||||
import { clearTerminalBroadcastUserInput, markTerminalBroadcastUserInput } from "./terminal/runtime/terminalPacedBroadcast";
|
||||
@@ -1246,6 +1249,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
|
||||
const clearTerminalCwd = useCallback((options?: { persistRestoreMetadata?: boolean }) => {
|
||||
terminalCwdTracker.clearRendererCwd();
|
||||
terminalCwdStore.setCwd(sessionId, null);
|
||||
knownCwdRef.current = undefined;
|
||||
if (options?.persistRestoreMetadata === false) return;
|
||||
onTerminalCwdChange?.(sessionId, null);
|
||||
@@ -2590,6 +2594,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
const cwdAwareOnCommandSubmitted = useCallback((
|
||||
...args: Parameters<NonNullable<typeof onCommandSubmitted>>
|
||||
) => {
|
||||
scheduleObservedTerminalDirectoryRefresh(sessionId);
|
||||
if (!shouldPreserveTerminalCwdAcrossCommand(hostRestrictsExtraSshChannels(host))) {
|
||||
invalidateTerminalCwdAfterCommand(
|
||||
terminalCwdTracker,
|
||||
@@ -2603,6 +2608,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
}, [host, onCommandSubmitted, onTerminalCwdChange, sessionId, terminalCwdTracker]);
|
||||
const pluginAwareOnCommandCompleted = useCallback(() => {
|
||||
pluginTerminalLifecycle.onCommandCompleted();
|
||||
refreshObservedTerminalDirectory(sessionId);
|
||||
void xtermRuntimeRef.current?.pluginProviderHost?.commandCompleted();
|
||||
// Hibernate wake builds its runtime from this callback. Same local
|
||||
// prompt-return refresh as useTerminalEffects (#1952).
|
||||
@@ -2615,6 +2621,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
cwd: string | null,
|
||||
meta?: TerminalCwdChangeMeta,
|
||||
) => {
|
||||
terminalCwdStore.setCwd(changedSessionId, cwd, meta?.source);
|
||||
pluginTerminalLifecycle.onCwdChanged(cwd);
|
||||
onTerminalCwdChange?.(changedSessionId, cwd, meta);
|
||||
}, [onTerminalCwdChange, pluginTerminalLifecycle]);
|
||||
@@ -2630,6 +2637,13 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
pluginAwareOnTerminalCwdChange(sessionId, normalizedCwd ?? null, meta);
|
||||
void refreshProviderOutputs('cwd-changed');
|
||||
}, [pluginAwareOnTerminalCwdChange, refreshProviderOutputs, sessionId, terminalCwdTracker]);
|
||||
useTerminalDirectorySession({
|
||||
sessionId, status, host, shellType,
|
||||
getBackendId: () => sessionRef.current,
|
||||
isReady: () => isTerminalReadyForCommandInjection(sessionId),
|
||||
publish: cwd => pluginAwareOnRuntimeCwdChange(cwd, { source: 'backend-strict' }),
|
||||
});
|
||||
|
||||
const pluginAwareOnTerminalTitleChange = useCallback((changedSessionId: string, title: string | null) => {
|
||||
terminalTitleRef.current = title || undefined;
|
||||
pluginTerminalLifecycle.onTitleChanged(title);
|
||||
@@ -2771,6 +2785,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
onTerminalDataCapture: handleTerminalDataCaptureOnce,
|
||||
onTerminalOutput: (chunk: string, meta?: TerminalSessionDataMeta) => {
|
||||
observeTerminalInputPrompt(chunk, meta);
|
||||
scheduleObservedTerminalDirectoryRefresh(sessionId);
|
||||
appendOutputTriggerOutputRef.current(chunk, meta);
|
||||
if (onTerminalOutput) {
|
||||
onTerminalOutput(sessionId, chunk);
|
||||
@@ -3521,6 +3536,8 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
options?: {
|
||||
broadcast?: boolean;
|
||||
multiLineRunMode?: Snippet["multiLineRunMode"];
|
||||
/** Submit compose blocks through the shell's advertised paste support. */
|
||||
preferBracketedPaste?: boolean;
|
||||
/** When false, skip term.focus() so multi-tab fan-out does not steal focus. */
|
||||
focus?: boolean;
|
||||
/** Force sensitive classification on the write (bypassed password fan-out, #3488). */
|
||||
@@ -3569,7 +3586,11 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
// Wrap in bracketed paste BEFORE appending \r so the Enter is sent
|
||||
// outside the paste markers — otherwise shells treat it as pasted text
|
||||
// instead of a submit action.
|
||||
if (!lineDelayMs && isMultiLine && term.modes.bracketedPasteMode && !disableBracketedPasteRef.current) {
|
||||
// Compose submits a command block, so keep it in the shell's input buffer
|
||||
// even when clipboard bracketing is disabled. Otherwise a running command
|
||||
// can consume the later lines as stdin (#4062). Still require shell support.
|
||||
if (!lineDelayMs && isMultiLine && term.modes.bracketedPasteMode
|
||||
&& (options?.preferBracketedPaste || !disableBracketedPasteRef.current)) {
|
||||
data = wrapBracketedPaste(data);
|
||||
}
|
||||
if (!noAutoRun) data = `${data}\r`;
|
||||
|
||||
@@ -2499,6 +2499,7 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
if (executor) {
|
||||
pendingSends.push(Promise.resolve(executor(text, false, {
|
||||
broadcast: false,
|
||||
preferBracketedPaste: true,
|
||||
// Preserve both prompt snapshots across an asynchronous wake: the
|
||||
// peer may clear its prompt classification before the actual write.
|
||||
...(focusedSensitive || recipientSensitive ? { sensitive: true } : {}),
|
||||
@@ -2538,7 +2539,10 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
const sensitive = isTerminalSensitiveInputActive(targetId);
|
||||
// Explicitly local: an omitted broadcast option falls back to the
|
||||
// toolbar switch, which can still be on while compose is active-only.
|
||||
pendingSends.push(Promise.resolve(executor(text, false, { broadcast: false })).then(
|
||||
pendingSends.push(Promise.resolve(executor(text, false, {
|
||||
broadcast: false,
|
||||
preferBracketedPaste: true,
|
||||
})).then(
|
||||
(sent) => sent && !sensitive && !isTerminalSensitiveInputActive(targetId),
|
||||
));
|
||||
} else {
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { ChatMessage, ChatMessageAttachment, ToolCall, ToolResult } from ".
|
||||
import { isTerminalSelectionAttachment } from "../../application/state/terminalSelectionAttachment";
|
||||
import { formatVaultNoteReferences, isVaultNoteAttachment } from "../../application/state/vaultNoteAttachment";
|
||||
import { redactSecretsForModel } from "../../infrastructure/ai/harness/modelSecretRedaction";
|
||||
import { sliceHead, sliceTail } from "../../infrastructure/ai/textBoundaries";
|
||||
|
||||
const MAX_ATTACHMENT_PLACEHOLDER_DETAIL_CHARS = 120;
|
||||
const MAX_TOOL_COMMAND_CHARS = 220;
|
||||
@@ -11,7 +12,7 @@ const HISTORICAL_TERMINAL_OUTPUT_HEAD_CHARS = 2_800;
|
||||
function truncateInline(value: string, maxChars: number): string {
|
||||
const normalized = value.replace(/\s+/g, " ").trim();
|
||||
if (normalized.length <= maxChars) return normalized;
|
||||
return `${normalized.slice(0, Math.max(0, maxChars - 3)).trimEnd()}...`;
|
||||
return `${sliceHead(normalized, Math.max(0, maxChars - 3)).trimEnd()}...`;
|
||||
}
|
||||
|
||||
function describeAttachmentSize(attachment: ChatMessageAttachment): string {
|
||||
@@ -84,7 +85,7 @@ function fitHistoricalTerminalOutput(content: string): string {
|
||||
0,
|
||||
MAX_HISTORICAL_TERMINAL_OUTPUT_CHARS - HISTORICAL_TERMINAL_OUTPUT_HEAD_CHARS - marker.length,
|
||||
);
|
||||
return `${safeContent.slice(0, HISTORICAL_TERMINAL_OUTPUT_HEAD_CHARS)}${marker}${safeContent.slice(-tailChars)}`;
|
||||
return `${sliceHead(safeContent, HISTORICAL_TERMINAL_OUTPUT_HEAD_CHARS)}${marker}${sliceTail(safeContent, tailChars)}`;
|
||||
}
|
||||
|
||||
function findOutputHandleId(content: string): string | undefined {
|
||||
|
||||
@@ -4,12 +4,64 @@ import React from 'react';
|
||||
import { renderToStaticMarkup } from 'react-dom/server';
|
||||
|
||||
import { ConvergentSyncPanel } from './ConvergentSyncPanel.tsx';
|
||||
import type { ConvergentMigrationPreview } from '../../domain/sync.ts';
|
||||
|
||||
const t = (key: string, values?: Record<string, string | number>) =>
|
||||
key === 'cloudSync.convergent.conflict.secretSet'
|
||||
? 'SECRET_SET'
|
||||
: `${key}${values?.count === undefined ? '' : `:${values.count}`}`;
|
||||
|
||||
test('uninitialized migration preview shows conflict choices and disables confirmation', () => {
|
||||
const preview: ConvergentMigrationPreview = {
|
||||
schemaVersion: 2,
|
||||
canInitialize: false,
|
||||
entityCounts: { hosts: 1 },
|
||||
settingsLeafCount: 0,
|
||||
conflictCount: 1,
|
||||
conflicts: [{
|
||||
address: { kind: 'entity-field', collection: 'hosts', entityId: 'host-1', field: 'label' },
|
||||
candidates: ['Left', 'Right'].map((value, index) => ({
|
||||
dot: { deviceId: value, counter: 1 },
|
||||
hlc: { wallTime: index + 1, logical: 0 },
|
||||
tombstone: false,
|
||||
value,
|
||||
selected: index === 0,
|
||||
})),
|
||||
}],
|
||||
shrinkFindings: [],
|
||||
providers: [],
|
||||
oldClientCompatibility: 'materialized-v1-snapshot',
|
||||
blockedReasons: ['The convergent state contains unresolved field conflicts'],
|
||||
};
|
||||
const renderPreview = (value: ConvergentMigrationPreview) => renderToStaticMarkup(
|
||||
<ConvergentSyncPanel
|
||||
t={t}
|
||||
resolvedLocale="en"
|
||||
config={{ enabled: false, initialized: false }}
|
||||
preview={value}
|
||||
busy={false}
|
||||
error={null}
|
||||
conflicts={[]}
|
||||
onToggle={() => {}}
|
||||
onConfirmMigration={() => {}}
|
||||
onCancelMigration={() => {}}
|
||||
onResolveConflict={() => {}}
|
||||
onDowngrade={() => {}}
|
||||
/>,
|
||||
);
|
||||
const markup = renderPreview(preview);
|
||||
assert.match(markup, /cloudSync\.convergent\.conflicts\.title:1/);
|
||||
assert.match(markup, /hosts\/host-1/);
|
||||
assert.equal((markup.match(/cloudSync\.convergent\.conflict\.choose/g) ?? []).length, 2);
|
||||
assert.match(markup, /<button[^>]*disabled=""[^>]*>cloudSync\.convergent\.preview\.confirm<\/button>/);
|
||||
|
||||
const resolvedMarkup = renderPreview({
|
||||
...preview, conflicts: [], conflictCount: 0, blockedReasons: [], canInitialize: true,
|
||||
});
|
||||
assert.doesNotMatch(resolvedMarkup, /cloudSync\.convergent\.conflict\.choose/);
|
||||
assert.doesNotMatch(resolvedMarkup, /<button[^>]*disabled=""[^>]*>cloudSync\.convergent\.preview\.confirm<\/button>/);
|
||||
});
|
||||
|
||||
test('secret conflict candidates never render their value', () => {
|
||||
const markup = renderToStaticMarkup(
|
||||
<ConvergentSyncPanel
|
||||
|
||||
@@ -79,7 +79,9 @@ export const ConvergentSyncPanel: React.FC<ConvergentSyncPanelProps> = ({
|
||||
onCancelMigration,
|
||||
onResolveConflict,
|
||||
onDowngrade,
|
||||
}) => (
|
||||
}) => {
|
||||
const conflictsToShow = config.initialized ? conflicts : (preview?.conflicts ?? []);
|
||||
return (
|
||||
<div className="space-y-4 rounded-lg border bg-card p-4">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="flex min-w-0 items-start gap-3">
|
||||
@@ -176,13 +178,13 @@ export const ConvergentSyncPanel: React.FC<ConvergentSyncPanelProps> = ({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{config.initialized && conflicts.length > 0 && (
|
||||
{conflictsToShow.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<div className="text-sm font-medium">
|
||||
{t('cloudSync.convergent.conflicts.title', { count: conflicts.length })}
|
||||
{t('cloudSync.convergent.conflicts.title', { count: conflictsToShow.length })}
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{conflicts.map((conflict) => {
|
||||
{conflictsToShow.map((conflict) => {
|
||||
const addressKey = convergentConflictAddressKey(conflict.address);
|
||||
return (
|
||||
<div key={addressKey} className="space-y-2 rounded-md bg-muted/40 p-3">
|
||||
@@ -230,4 +232,5 @@ export const ConvergentSyncPanel: React.FC<ConvergentSyncPanelProps> = ({
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
);
|
||||
};
|
||||
@@ -16,8 +16,8 @@ export function PluginAuthorRow({ entry, plugin, sourceId, showSource = false }:
|
||||
const showImage = avatar && avatar.length <= 700_000 && /^data:image\/png;base64,[A-Za-z0-9+/]+={0,2}$/.test(avatar) && failed !== imageKey;
|
||||
const source = plugin?.source;
|
||||
const sourceKey = source?.kind === 'third-party' ? 'thirdparty' : source?.kind === 'official' || source?.kind === 'local' ? source.kind : undefined;
|
||||
const sourceLabel = t(sourceKey ? `settings.plugins.sources.short.${sourceKey}` : 'settings.plugins.landing.sourceUnknown');
|
||||
const sourceDescription = sourceKey
|
||||
const sourceLabel = source?.kind === 'github' ? 'GitHub' : t(sourceKey ? `settings.plugins.sources.short.${sourceKey}` : 'settings.plugins.landing.sourceUnknown');
|
||||
const sourceDescription = source?.kind === 'github' ? [source.origin, source.tag, source.commit].filter(Boolean).join(' · ') : sourceKey
|
||||
? [t(`settings.plugins.sources.${sourceKey}`), source?.name, source?.origin].filter(Boolean).join(' · ')
|
||||
: sourceLabel;
|
||||
const SourceIcon = source?.kind === 'local' ? HardDrive : source?.kind === 'official' ? Store : Globe;
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { JSDOM } from 'jsdom';
|
||||
|
||||
for (const locale of ['en', 'es', 'ru', 'zh-CN', 'zh-TW']) test(`real dialog (${locale}) reviews permissions and requires fresh trust after failed installation for same-package retries and different repositories`, async () => {
|
||||
const dom = new JSDOM('<!doctype html><html><body><div id="root"></div></body></html>', { pretendToBeVisual: true, url: 'http://localhost' });
|
||||
const previous = new Map<string, PropertyDescriptor | undefined>();
|
||||
const install = (key: string, value: unknown) => {
|
||||
previous.set(key, Object.getOwnPropertyDescriptor(globalThis, key));
|
||||
Object.defineProperty(globalThis, key, { configurable: true, writable: true, value });
|
||||
};
|
||||
for (const key of ['window', 'document', 'navigator', 'HTMLElement', 'HTMLInputElement', 'Element', 'Node', 'NodeFilter', 'MutationObserver', 'CustomEvent', 'Event', 'KeyboardEvent', 'MouseEvent']) install(key, key === 'window' ? dom.window : dom.window[key as keyof typeof dom.window]);
|
||||
install('getComputedStyle', dom.window.getComputedStyle.bind(dom.window));
|
||||
install('requestAnimationFrame', dom.window.requestAnimationFrame.bind(dom.window));
|
||||
install('cancelAnimationFrame', dom.window.cancelAnimationFrame.bind(dom.window));
|
||||
install('IS_REACT_ACT_ENVIRONMENT', true);
|
||||
const { default: React, act } = await import('react');
|
||||
const { createRoot } = await import('react-dom/client');
|
||||
const { I18nProvider } = await import('../../../application/i18n/I18nProvider');
|
||||
const { loadLocaleMessages, getLoadedLocaleMessages } = await import('../../../application/i18n/messages');
|
||||
await loadLocaleMessages(locale);
|
||||
const messages = getLoadedLocaleMessages(locale)!;
|
||||
const t = (key: string) => messages[key]!;
|
||||
const { PluginGitHubInstall } = await import('./PluginGitHubInstall');
|
||||
const root = createRoot(document.getElementById('root')!);
|
||||
const requests: string[] = [];
|
||||
const installed: string[] = [];
|
||||
const discarded: string[] = [];
|
||||
let refreshed = 0;
|
||||
let resolveDownload: ((value: NetcattyGitHubPluginPreview) => void) | undefined;
|
||||
Object.defineProperty(window, 'netcatty', { configurable: true, value: {
|
||||
prepareGitHubPlugin: async (input: string) => { requests.push(input); if (requests.length === 1) throw new Error('GitHub API rate limit reached. Try again later'); return new Promise<NetcattyGitHubPluginPreview>(resolve => { resolveDownload = resolve; }); },
|
||||
installGitHubPlugin: async (token: string) => { installed.push(token); if (installed.length <= 2) throw new Error('GitHub package preview expired. Download and review it again'); return {} as NetcattyInstalledPlugin; },
|
||||
discardGitHubPlugin: async (token: string) => { discarded.push(token); },
|
||||
} as NetcattyBridge });
|
||||
const settle = () => new Promise(resolve => setTimeout(resolve, 20));
|
||||
const button = (text: string) => [...document.querySelectorAll<HTMLButtonElement>('button')].find(el => el.textContent === text)!;
|
||||
const click = async (el: HTMLElement) => { assert.ok(el); await act(async () => { el.click(); await settle(); }); };
|
||||
const type = async (text: string) => {
|
||||
await act(async () => {
|
||||
const input = document.querySelector<HTMLInputElement>('input:not([type=checkbox])')!;
|
||||
Object.getOwnPropertyDescriptor(dom.window.HTMLInputElement.prototype, 'value')!.set!.call(input, text);
|
||||
input.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
await settle();
|
||||
});
|
||||
};
|
||||
const preview = { token: 'reviewed-token', source: { id: 'github:fixture', kind: 'github', name: 'owner/repo', origin: 'https://github.com/owner/repo', tag: 'v1.0.0', commit: 'a'.repeat(40) }, assetName: 'plugin.ncpkg', size: 400,
|
||||
archiveSha256: 'b'.repeat(64), manifest: { manifestVersion: 1, id: 'com.example.fixture', name: 'fixture', publisher: 'example', version: '1.0.0', engines: { netcatty: '*', api: '*' }, main: { browser: 'index.js' }, permissions: { required: [{ permission: 'network', resources: ['https://example.com'], reason: 'Read only fixture' }] } } } as NetcattyGitHubPluginPreview;
|
||||
try {
|
||||
await act(async () => root.render(<I18nProvider locale={locale}><PluginGitHubInstall disabled={false} onInstalled={async () => { refreshed++; }} /></I18nProvider>));
|
||||
const trigger = button(t('settings.plugins.github.install'));
|
||||
await click(trigger);
|
||||
assert.equal(document.activeElement?.tagName, 'INPUT');
|
||||
assert.ok(button(t('settings.plugins.github.download')).disabled);
|
||||
await type('https://evil.com/owner/repo');
|
||||
await click(button(t('settings.plugins.github.download')));
|
||||
assert.equal(document.querySelector('[role=alert] p')!.textContent, t('settings.plugins.github.error.invalidRepository'));
|
||||
assert.equal(requests.length, 0);
|
||||
if (locale !== 'en') {
|
||||
// The existing error follows provider state without repeating the host call.
|
||||
await act(async () => root.render(<I18nProvider locale="en"><PluginGitHubInstall disabled={false} onInstalled={async () => { refreshed++; }} /></I18nProvider>));
|
||||
assert.match(document.querySelector('[role=alert]')!.textContent!, /repository only/);
|
||||
await act(async () => root.render(<I18nProvider locale={locale}><PluginGitHubInstall disabled={false} onInstalled={async () => { refreshed++; }} /></I18nProvider>));
|
||||
assert.equal(document.querySelector('[role=alert] p')!.textContent, t('settings.plugins.github.error.invalidRepository'));
|
||||
assert.equal(requests.length, 0);
|
||||
}
|
||||
await type('github.com/Owner/Repo.git/');
|
||||
await click(button(t('settings.plugins.github.download')));
|
||||
assert.equal(document.querySelector('[role=alert] p')!.textContent, t('settings.plugins.github.error.rateLimit'));
|
||||
assert.deepEqual(requests, ['https://github.com/owner/repo']);
|
||||
await click(button(t('settings.plugins.github.download')));
|
||||
assert.ok(button(t('settings.plugins.github.download')).disabled);
|
||||
assert.equal(document.querySelector('form')?.getAttribute('aria-busy'), 'true');
|
||||
assert.equal(document.querySelector<HTMLInputElement>('input')?.disabled, true);
|
||||
await act(async () => { resolveDownload!(preview); await settle(); });
|
||||
const review = document.querySelector('[data-plugin-github-preview]')!;
|
||||
assert.match(review.textContent!, /https:\/\/github.com\/owner\/repo/);
|
||||
assert.match(review.textContent!, /v1\.0\.0.*aaaaaaaa/);
|
||||
assert.match(review.textContent!, /https:\/\/example.com/);
|
||||
assert.ok(button(t('settings.plugins.github.confirm')).disabled);
|
||||
await click(document.querySelector('input[type=checkbox]')!);
|
||||
assert.equal(button(t('settings.plugins.github.confirm')).disabled, false);
|
||||
await click(button(t('settings.plugins.github.confirm')));
|
||||
assert.deepEqual(installed, ['reviewed-token']);
|
||||
assert.equal(document.querySelector('[role=alert] p')!.textContent, t('settings.plugins.github.error.expired'));
|
||||
assert.equal(document.querySelector('[data-plugin-github-preview]'), null);
|
||||
assert.equal(refreshed, 0);
|
||||
// A retry of the very same archive/repository is a fresh preview too.
|
||||
await click(button(t('settings.plugins.github.download')));
|
||||
await act(async () => { resolveDownload!({ ...preview, token: 'retry-token' }); await settle(); });
|
||||
assert.equal(document.querySelector<HTMLInputElement>('input[type=checkbox]')!.checked, false);
|
||||
assert.ok(button(t('settings.plugins.github.confirm')).disabled);
|
||||
await click(button(t('settings.plugins.github.confirm')));
|
||||
assert.deepEqual(installed, ['reviewed-token']);
|
||||
await click(document.querySelector('input[type=checkbox]')!);
|
||||
assert.equal(button(t('settings.plugins.github.confirm')).disabled, false);
|
||||
await click(button(t('settings.plugins.github.confirm')));
|
||||
assert.equal(document.querySelector('[role=alert] p')!.textContent, t('settings.plugins.github.error.expired'));
|
||||
await type('another/plugin');
|
||||
await click(button(t('settings.plugins.github.download')));
|
||||
await act(async () => { resolveDownload!({ ...preview, token: 'different-token', source: { ...preview.source, origin: 'https://github.com/another/plugin', name: 'another/plugin' } }); await settle(); });
|
||||
assert.equal(requests.at(-1), 'https://github.com/another/plugin');
|
||||
assert.match(document.querySelector('[data-plugin-github-preview]')!.textContent!, /https:\/\/github.com\/another\/plugin/);
|
||||
assert.equal(document.querySelector<HTMLInputElement>('input[type=checkbox]')!.checked, false);
|
||||
assert.ok(button(t('settings.plugins.github.confirm')).disabled);
|
||||
await click(button(t('settings.plugins.github.confirm')));
|
||||
assert.deepEqual(installed, ['reviewed-token', 'retry-token']);
|
||||
await click(document.querySelector('input[type=checkbox]')!);
|
||||
await click(document.querySelector('input[type=checkbox]')!);
|
||||
assert.ok(button(t('settings.plugins.github.confirm')).disabled);
|
||||
await click(document.querySelector('input[type=checkbox]')!);
|
||||
await click(button(t('settings.plugins.github.confirm')));
|
||||
assert.deepEqual(installed, ['reviewed-token', 'retry-token', 'different-token']);
|
||||
assert.equal(refreshed, 1);
|
||||
assert.equal(document.querySelector('[role=dialog]'), null);
|
||||
await click(trigger);
|
||||
assert.equal(document.querySelector('[data-plugin-github-preview]'), null);
|
||||
assert.equal(document.querySelector<HTMLInputElement>('input')?.value, '');
|
||||
await type('owner/repo');
|
||||
await click(button(t('settings.plugins.github.download')));
|
||||
await act(async () => { resolveDownload!({ ...preview, token: 'cancel-token' }); await settle(); });
|
||||
await click(button(t('common.back')));
|
||||
assert.deepEqual(discarded, ['cancel-token']);
|
||||
await act(async () => { document.activeElement?.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true })); await settle(); });
|
||||
assert.equal(document.querySelector('[role=dialog]'), null);
|
||||
assert.equal(document.activeElement, trigger);
|
||||
} finally {
|
||||
await act(async () => root.unmount());
|
||||
for (const [key, descriptor] of previous) { if (descriptor) Object.defineProperty(globalThis, key, descriptor); else Reflect.deleteProperty(globalThis, key); }
|
||||
dom.window.close();
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,58 @@
|
||||
import { useState } from 'react';
|
||||
import { Github } from 'lucide-react';
|
||||
import { usePluginGitHubInstall } from '../../../application/state/usePluginGitHubInstall';
|
||||
import { useI18n } from '../../../application/i18n/I18nProvider';
|
||||
import { Button } from '../../ui/button';
|
||||
import { Input } from '../../ui/input';
|
||||
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, DialogTrigger } from '../../ui/dialog';
|
||||
import { PluginPackageDetails } from './PluginPackageManager';
|
||||
import { describePluginGitHubInstallError } from '../../../domain/pluginGitHubInstallError';
|
||||
|
||||
export function PluginGitHubInstall({ disabled, onInstalled }: { disabled: boolean; onInstalled: () => Promise<unknown> }) {
|
||||
const { t, resolvedLocale } = useI18n();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [input, setInput] = useState('');
|
||||
const [accepted, setAccepted] = useState(false);
|
||||
const flow = usePluginGitHubInstall(async () => { await onInstalled(); setOpen(false); });
|
||||
const preview = flow.preview;
|
||||
const error = flow.error ? describePluginGitHubInstallError(flow.error) : null;
|
||||
return <Dialog open={open} onOpenChange={(next) => {
|
||||
if (flow.busy) return;
|
||||
setOpen(next); flow.reset(); setAccepted(false); setInput('');
|
||||
}}>
|
||||
<DialogTrigger asChild><Button variant="outline" size="sm" disabled={disabled}><Github size={14} className="mr-2" />{t('settings.plugins.github.install')}</Button></DialogTrigger>
|
||||
<DialogContent hideCloseButton={flow.busy} className="app-no-drag max-h-[85dvh] overflow-y-auto sm:max-w-xl" data-plugin-github-install="">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t('settings.plugins.github.install')}</DialogTitle>
|
||||
<DialogDescription>{t('settings.plugins.github.description')}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<form className="space-y-4" aria-busy={flow.busy} onSubmit={(event) => { event.preventDefault(); if (!flow.busy && input.trim() && !preview) void flow.prepare(input); }}>
|
||||
<label className="block space-y-2 text-sm"><span>{t('settings.plugins.github.repository')}</span>
|
||||
<Input autoFocus value={input} disabled={flow.busy || Boolean(preview)} maxLength={512} placeholder="https://github.com/owner/repo" onChange={(event) => setInput(event.target.value)} />
|
||||
</label>
|
||||
<p className="text-xs text-muted-foreground">{t('settings.plugins.github.formats')}</p>
|
||||
{flow.busy && <p role="status" className="text-sm text-muted-foreground">{t('settings.plugins.github.busy')}</p>}
|
||||
{error && <div role="alert" className="break-words text-sm text-destructive">
|
||||
<p>{t(error.key, error.values)}</p>
|
||||
{error.details && <details className="mt-2 text-xs"><summary>{t('settings.plugins.github.error.details')}</summary><p className="whitespace-pre-wrap">{error.details}</p></details>}
|
||||
</div>}
|
||||
{preview && <div className="space-y-3" data-plugin-github-preview="">
|
||||
<p className="break-words text-sm font-medium">{preview.manifest.name} · {preview.manifest.version}</p>
|
||||
<div className="break-all rounded-md bg-muted/40 p-3 text-xs">
|
||||
<p>{preview.source.origin}</p><p>{preview.source.tag} · {preview.source.commit}</p>
|
||||
<p>{preview.assetName} · {preview.size.toLocaleString(resolvedLocale)} B</p>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">{t('settings.plugins.github.review')}</p>
|
||||
<PluginPackageDetails plugin={{ id: preview.manifest.id, enabled: false, activeVersion: preview.manifest.version, manifest: preview.manifest,
|
||||
archiveSha256: preview.archiveSha256, runtime: { status: 'stopped', kind: null, lastError: null, quarantinedAt: null } }} />
|
||||
<label className="flex items-start gap-2 text-sm"><input type="checkbox" checked={accepted} disabled={flow.busy} onChange={(event) => setAccepted(event.target.checked)} className="mt-1" />{t('settings.plugins.github.accept')}</label>
|
||||
</div>}
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="outline" disabled={flow.busy} onClick={() => { if (preview) { flow.reset(); setAccepted(false); } else setOpen(false); }}>{t(preview ? 'common.back' : 'common.cancel')}</Button>
|
||||
{preview ? <Button type="button" disabled={flow.busy || !accepted} onClick={() => { setAccepted(false); void flow.install(); }}>{t('settings.plugins.github.confirm')}</Button>
|
||||
: <Button type="submit" disabled={flow.busy || !input.trim()}>{t('settings.plugins.github.download')}</Button>}
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>;
|
||||
}
|
||||
@@ -30,6 +30,7 @@ export function PluginPackageDetails({ plugin }: { plugin: NetcattyInstalledPlug
|
||||
<dt className="text-muted-foreground">Netcatty</dt><dd>{manifest.engines.netcatty}</dd>
|
||||
<dt className="text-muted-foreground">API</dt><dd>{manifest.engines.api}</dd>
|
||||
<dt className="text-muted-foreground">SHA-256</dt><dd className="break-all font-mono">{plugin.archiveSha256 ?? '-'}</dd>
|
||||
{plugin.source?.kind === 'github' && <><dt>GitHub</dt><dd className="break-all">{plugin.source.origin}<br />{plugin.source.tag}<br /><span className="font-mono">{plugin.source.commit}</span></dd></>}
|
||||
</dl>
|
||||
<p className={!policy.browserOnly && !policy.dataOnly ? 'text-destructive' : 'text-muted-foreground'}>{policy.dataOnly ? t('settings.plugins.manager.dataTheme') : t(`settings.plugins.manager.${runtimeLabel}`)}</p>
|
||||
<div className="space-y-2">
|
||||
|
||||
@@ -55,6 +55,10 @@ import { AIPrivacySection } from "./ai/AIPrivacySection";
|
||||
import { ToolAccessGuidance } from "./ai/ToolAccessGuidance";
|
||||
import { PermissionGrantsSettings } from "./ai/PermissionGrantsSettings";
|
||||
import { useAIPermissionGrantsState } from "../../../application/state/useAIPermissionGrantsState";
|
||||
import {
|
||||
MAX_AI_SESSION_MESSAGE_CAP,
|
||||
MIN_AI_SESSION_MESSAGE_CAP,
|
||||
} from "../../../domain/aiSessionMessageCap";
|
||||
import { WebSearchSettings } from "./ai/WebSearchSettings";
|
||||
import { QuickMessagesSettings } from "./ai/QuickMessagesSettings";
|
||||
import type { AIQuickMessage } from "../../../infrastructure/ai/quickMessages";
|
||||
@@ -159,6 +163,8 @@ interface SettingsAITabProps {
|
||||
setResponseIdleTimeout: (value: number) => void;
|
||||
maxIterations: number;
|
||||
setMaxIterations: (value: number) => void;
|
||||
aiSessionMessageCap: number;
|
||||
setAiSessionMessageCap: (value: number) => void;
|
||||
webSearchConfig: WebSearchConfig | null;
|
||||
setWebSearchConfig: (config: WebSearchConfig | null) => void;
|
||||
quickMessages: AIQuickMessage[];
|
||||
@@ -196,6 +202,8 @@ const SettingsAITab: React.FC<SettingsAITabProps> = ({
|
||||
setResponseIdleTimeout,
|
||||
maxIterations,
|
||||
setMaxIterations,
|
||||
aiSessionMessageCap,
|
||||
setAiSessionMessageCap,
|
||||
webSearchConfig,
|
||||
setWebSearchConfig,
|
||||
quickMessages,
|
||||
@@ -1304,6 +1312,35 @@ const SettingsAITab: React.FC<SettingsAITabProps> = ({
|
||||
</SettingCard>
|
||||
</SettingsSection>
|
||||
|
||||
<SettingsSection
|
||||
anchorId="ai-chat-history"
|
||||
title={t('ai.chatHistory.title')}
|
||||
>
|
||||
<SettingCard>
|
||||
<SettingRow
|
||||
anchorId="ai-chat-history-message-cap"
|
||||
label={t('ai.chatHistory.messageCap')}
|
||||
description={t('ai.chatHistory.messageCap.description')}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="number"
|
||||
aria-label={t('ai.chatHistory.messageCap')}
|
||||
value={aiSessionMessageCap}
|
||||
onChange={(e) => {
|
||||
const val = parseInt(e.target.value, 10);
|
||||
if (!isNaN(val)) setAiSessionMessageCap(val);
|
||||
}}
|
||||
min={MIN_AI_SESSION_MESSAGE_CAP}
|
||||
max={MAX_AI_SESSION_MESSAGE_CAP}
|
||||
className="w-20 h-9 rounded-md border border-input bg-background px-3 text-sm text-right focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
||||
/>
|
||||
<span className="text-xs text-muted-foreground">{t('ai.chatHistory.messageCap.unit')}</span>
|
||||
</div>
|
||||
</SettingRow>
|
||||
</SettingCard>
|
||||
</SettingsSection>
|
||||
|
||||
<SettingsSection
|
||||
anchorId="ai-user-skills"
|
||||
title={t('ai.userSkills.title')}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { PluginThirdPartyMarket } from './PluginThirdPartyMarket';
|
||||
import { PluginGitHubInstall } from './PluginGitHubInstall';
|
||||
import { usePluginUpdateCheck } from '../../../application/state/usePluginUpdateCheck';
|
||||
import { usePluginManager } from '../../../application/state/usePluginManager';
|
||||
import { PluginManagementDialog } from './PluginManagementDialog';
|
||||
@@ -35,6 +36,7 @@ function PluginManagementContent({ manager }: {
|
||||
<div className="flex items-center gap-2">
|
||||
{pluginView === 'installed' && <PluginStatus noVault={manager.credentialStorageAvailable === false} />}
|
||||
<div ref={setCatalogToolbar} className="flex items-center gap-2" />
|
||||
<PluginGitHubInstall disabled={!manager.available || manager.busy} onInstalled={async () => { setPluginView('installed'); await manager.refresh(); }} />
|
||||
<Button variant="outline" size="sm" disabled={!manager.available || manager.busy} onClick={() => { setPluginView('installed'); void manager.importPackage(); }}><FolderOpen size={14} className="mr-2" />{t('settings.plugins.manager.import')}</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -35,6 +35,17 @@ test("terminal behavior settings expose disabled auto-close", () => {
|
||||
);
|
||||
});
|
||||
|
||||
test('Context Chips default on for old settings and preserve an explicit off preference', () => {
|
||||
assert.equal(normalizeTerminalSettings({ scrollback: 5000 }).contextChipsEnabled, true);
|
||||
const disabled = normalizeTerminalSettings({ contextChipsEnabled: false });
|
||||
assert.equal(normalizeTerminalSettings(JSON.parse(JSON.stringify(disabled))).contextChipsEnabled, false);
|
||||
const markup = renderToStaticMarkup(React.createElement(TerminalBehaviorSettings, {
|
||||
t: (key: string) => key, terminalSettings: disabled, updateTerminalSetting: () => {},
|
||||
}));
|
||||
assert.match(markup, /settings-anchor-terminal-context-chips/);
|
||||
assert.match(markup, /settings\.terminal\.behavior\.contextChips[\s\S]*?role="switch" aria-checked="false"/);
|
||||
});
|
||||
|
||||
test("terminal behavior settings expose OSC desktop notification mode", () => {
|
||||
const markup = renderSettings(true);
|
||||
assert.match(markup, /settings-anchor-terminal-osc-notifications/);
|
||||
|
||||
@@ -48,6 +48,14 @@ export const TerminalBehaviorSettings: React.FC<TerminalBehaviorSettingsProps> =
|
||||
<>
|
||||
<SectionHeader title={t("settings.terminal.section.behavior")} />
|
||||
<div className="space-y-0 divide-y divide-border rounded-lg border bg-card px-4">
|
||||
<SettingRow
|
||||
anchorId="terminal-context-chips"
|
||||
label={t("settings.terminal.behavior.contextChips")}
|
||||
description={t("settings.terminal.behavior.contextChips.desc")}
|
||||
>
|
||||
<Toggle checked={terminalSettings.contextChipsEnabled !== false} onChange={(v) => updateTerminalSetting("contextChipsEnabled", v)} />
|
||||
</SettingRow>
|
||||
|
||||
<SettingRow
|
||||
anchorId="terminal-auto-close-on-exit"
|
||||
label={t("settings.terminal.behavior.autoCloseOnExit")}
|
||||
|
||||
@@ -6,6 +6,10 @@ import type { ProviderConfig, ProviderAdvancedParams, OpenAIApiFormat, ProviderS
|
||||
import { PROVIDER_PRESETS, resolveOpenAIApi, resolveProviderStyle } from "../../../../infrastructure/ai/types";
|
||||
import { normalizeOllamaSdkBaseURL } from "../../../../infrastructure/ai/ollamaCompatBaseUrl";
|
||||
import { sanitizeContextWindow } from "../../../../infrastructure/ai/contextCompaction";
|
||||
import {
|
||||
isProviderReasoningEffort,
|
||||
PROVIDER_REASONING_EFFORT_OPTIONS,
|
||||
} from "../../../../infrastructure/ai/cattyReasoning";
|
||||
import {
|
||||
probeProviderConnection,
|
||||
validateProviderProbeInputs,
|
||||
@@ -57,8 +61,17 @@ async function compressIconFileToDataUrl(file: File): Promise<string> {
|
||||
|
||||
const STYLE_OPTIONS: ReadonlyArray<ProviderStyle> = ["anthropic", "openai", "google"];
|
||||
const OPENAI_API_OPTIONS: ReadonlyArray<OpenAIApiFormat> = ["chat", "responses"];
|
||||
/** Selectable thinking-depth levels; empty string means "provider default" (field not sent). */
|
||||
const REASONING_EFFORT_OPTIONS = ["low", "medium", "high"] as const;
|
||||
/**
|
||||
* How each reasoning level is shown in the select. Raw wire values stay the
|
||||
* option values so a saved config keeps round-tripping unchanged.
|
||||
*/
|
||||
const REASONING_EFFORT_LABELS: Record<string, string> = {
|
||||
low: "low",
|
||||
medium: "medium",
|
||||
high: "high",
|
||||
xhigh: "xhigh (extra high)",
|
||||
max: "max",
|
||||
};
|
||||
|
||||
/** Same box as the h-8 fields above. Transparent border keeps primary aligned with outline. */
|
||||
const PROVIDER_ACTION_CLASS = "box-border h-8 px-3 gap-1.5 text-sm font-medium leading-none";
|
||||
@@ -363,7 +376,7 @@ export const ProviderConfigForm: React.FC<{
|
||||
if (ap.topP != null) cleanedParams.topP = Math.min(1, Math.max(0, ap.topP));
|
||||
if (ap.frequencyPenalty != null) cleanedParams.frequencyPenalty = Math.min(2, Math.max(-2, ap.frequencyPenalty));
|
||||
if (ap.presencePenalty != null) cleanedParams.presencePenalty = Math.min(2, Math.max(-2, ap.presencePenalty));
|
||||
if (ap.reasoningEffort && (REASONING_EFFORT_OPTIONS as readonly string[]).includes(ap.reasoningEffort)) {
|
||||
if (isProviderReasoningEffort(ap.reasoningEffort)) {
|
||||
cleanedParams.reasoningEffort = ap.reasoningEffort;
|
||||
}
|
||||
|
||||
@@ -774,10 +787,13 @@ export const ProviderConfigForm: React.FC<{
|
||||
className="w-full h-8 rounded-md border border-input bg-background px-3 text-sm focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
||||
>
|
||||
<option value="">{t('ai.providers.advancedParams.default')}</option>
|
||||
{REASONING_EFFORT_OPTIONS.map((level) => (
|
||||
<option key={level} value={level}>{level}</option>
|
||||
{PROVIDER_REASONING_EFFORT_OPTIONS.map((level) => (
|
||||
<option key={level} value={level}>{REASONING_EFFORT_LABELS[level] ?? level}</option>
|
||||
))}
|
||||
</select>
|
||||
<p className="text-[11px] text-muted-foreground/70">
|
||||
{t('ai.providers.advancedParams.reasoningEffort.help')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
import { ArrowUp, ChevronDown, Folder, LoaderCircle, RefreshCw } from 'lucide-react';
|
||||
import { useEffect, useId, useRef, useState } from 'react';
|
||||
import { useI18n } from '../../application/i18n/I18nProvider';
|
||||
import { useComposeDirectoryPicker } from '../../application/state/useComposeDirectoryPicker';
|
||||
import { directoryDisplayName, directoryLabel } from '../../domain/directoryPicker';
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '../ui/popover';
|
||||
import type { ComposeBarTheme } from './TerminalComposeBar';
|
||||
|
||||
export function ComposeDirectoryPicker({ sessionId, theme, onReturnFocus }: {
|
||||
sessionId: string; theme: ComposeBarTheme; onReturnFocus: () => void;
|
||||
}) {
|
||||
const { t } = useI18n();
|
||||
const model = useComposeDirectoryPicker(sessionId);
|
||||
const [active, setActive] = useState(0);
|
||||
const [scrollTop, setScrollTop] = useState(0);
|
||||
const search = useRef<HTMLInputElement>(null);
|
||||
const viewport = useRef<HTMLDivElement>(null);
|
||||
const composing = useRef(false);
|
||||
const outside = useRef(false);
|
||||
const id = useId();
|
||||
const hasParent = model.parent !== null;
|
||||
const count = model.entries.length + Number(hasParent);
|
||||
const activeEntry = active === 0 && hasParent ? model.parent : model.entries[active - Number(hasParent)]?.path;
|
||||
const errorText = model.error ? t('terminal.directory.' + model.error) : null;
|
||||
useEffect(() => {
|
||||
setActive(model.entries.length ? Number(hasParent) : -1);
|
||||
setScrollTop(0);
|
||||
if (viewport.current) viewport.current.scrollTop = 0;
|
||||
}, [model.entries, hasParent, model.open]);
|
||||
|
||||
const virtual = model.entries.length > 200;
|
||||
const start = virtual ? Math.max(0, Math.floor(scrollTop / 32) - 4) : 0;
|
||||
const visible = virtual ? model.entries.slice(start, start + 16) : model.entries;
|
||||
const row = (name: string, path: string, index: number, parent = false) => (
|
||||
<div
|
||||
key={path} id={`${id}-${index}`} role="option" aria-selected={active === index}
|
||||
title={directoryDisplayName(path)}
|
||||
className="h-8 flex items-center gap-2 px-3 text-xs cursor-pointer min-w-0"
|
||||
style={{ backgroundColor: active === index ? theme.chipHoverBg : undefined,
|
||||
color: active === index ? theme.resolvedFg : theme.mutedFg }}
|
||||
onPointerMove={() => setActive(index)}
|
||||
onMouseDown={event => event.preventDefault()}
|
||||
onClick={() => { if (!model.pending) void model.change(path); }}
|
||||
>
|
||||
{parent ? <ArrowUp size={13} className="shrink-0" /> : <Folder size={13} className="shrink-0" />}
|
||||
<span className="truncate">{directoryDisplayName(name)}</span>
|
||||
</div>
|
||||
);
|
||||
return (
|
||||
<div className="shrink-0 flex items-center gap-2 px-3 h-7 min-w-0" role="group" aria-label="Context Chips">
|
||||
<Popover open={model.open} onOpenChange={value => { if (value) outside.current = false; model.setOpen(value); }}>
|
||||
<PopoverTrigger asChild>
|
||||
<button type="button" disabled={model.pending}
|
||||
aria-label={t('terminal.directory.open', { path: model.cwd ? directoryLabel(model.cwd, model.home) : t('terminal.directory.unknown') })}
|
||||
title={model.cwd ? directoryDisplayName(model.cwd) : t('terminal.directory.unknown')}
|
||||
className="h-6 min-w-0 max-w-full flex items-center gap-1.5 rounded border px-2 text-[11px] font-mono focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 disabled:opacity-60"
|
||||
style={{ backgroundColor: theme.chipBg, borderColor: theme.borderColor, color: theme.resolvedFg }}
|
||||
>
|
||||
{model.pending ? <LoaderCircle size={12} className="shrink-0 animate-spin" /> : <Folder size={12} className="shrink-0" />}
|
||||
<span className="truncate">{model.cwd ? directoryDisplayName(directoryLabel(model.cwd, model.home)) : t('terminal.directory.unknown')}</span>
|
||||
<ChevronDown size={10} className="shrink-0 opacity-60" />
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent side="top" align="start" sideOffset={6}
|
||||
className="p-0 overflow-hidden w-[360px] max-w-[calc(100vw-16px)]"
|
||||
style={{ backgroundColor: theme.resolvedBg, borderColor: theme.borderColor, color: theme.resolvedFg }}
|
||||
onOpenAutoFocus={event => { event.preventDefault(); search.current?.focus(); }}
|
||||
onInteractOutside={() => { outside.current = true; }}
|
||||
onCloseAutoFocus={event => { event.preventDefault(); if (!outside.current) onReturnFocus(); }}
|
||||
onKeyDown={event => {
|
||||
event.stopPropagation();
|
||||
if (composing.current || event.nativeEvent.isComposing) return;
|
||||
if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
|
||||
event.preventDefault();
|
||||
const next = active < 0 ? (event.key === 'ArrowDown' ? 0 : count - 1)
|
||||
: Math.max(0, Math.min(count - 1, active + (event.key === 'ArrowDown' ? 1 : -1)));
|
||||
setActive(next);
|
||||
const child = next - Number(hasParent);
|
||||
if (viewport.current && child >= 0) {
|
||||
const el = viewport.current;
|
||||
if (child * 32 < el.scrollTop) el.scrollTop = child * 32;
|
||||
else if ((child + 1) * 32 > el.scrollTop + el.clientHeight) el.scrollTop = (child + 1) * 32 - el.clientHeight;
|
||||
}
|
||||
} else if (event.key === 'Enter' && event.target === search.current) {
|
||||
event.preventDefault(); if (activeEntry && !model.pending) void model.change(activeEntry);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="px-3 py-2 border-b" style={{ borderColor: theme.borderColor }}>
|
||||
<input ref={search} value={model.query} onChange={event => model.setQuery(event.target.value)}
|
||||
aria-label={t('terminal.directory.search')} aria-controls={`${id}-list`}
|
||||
aria-activedescendant={activeEntry ? `${id}-${active}` : undefined}
|
||||
placeholder={t('terminal.directory.search')}
|
||||
className="w-full bg-transparent text-xs outline-none placeholder:opacity-60"
|
||||
onCompositionStart={() => { composing.current = true; }}
|
||||
onCompositionEnd={() => { composing.current = false; }}
|
||||
/>
|
||||
</div>
|
||||
<div role="listbox" aria-label={t('terminal.directory.directories')} id={`${id}-list`} aria-busy={model.loading}>
|
||||
{model.parent && row(t('terminal.directory.parent'), model.parent, 0, true)}
|
||||
<div ref={viewport} className="max-h-56 overflow-y-auto overscroll-contain" onScroll={event => setScrollTop(event.currentTarget.scrollTop)}>
|
||||
<div style={virtual ? { height: model.entries.length * 32, position: 'relative' } : undefined}>
|
||||
<div style={virtual ? { position: 'absolute', top: start * 32, left: 0, right: 0 } : undefined}>
|
||||
{visible.map((entry, index) => row(entry.name, entry.path, start + index + Number(hasParent)))}
|
||||
</div>
|
||||
</div>
|
||||
{model.loading && <p className="px-3 py-4 text-xs" role="status">{t('terminal.directory.loading')}</p>}
|
||||
{!model.loading && !errorText && model.cwd && !model.entries.length && (
|
||||
<p className="px-3 py-4 text-xs" style={{ color: theme.mutedFg }}>{t(model.query ? 'terminal.directory.noMatches' : 'terminal.directory.empty')}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{(errorText || !model.available || !model.cwd) && <p className="px-3 py-2 text-xs" role="status">
|
||||
{errorText ?? t(model.available ? 'terminal.directory.unknown' : 'terminal.directory.unavailable')}
|
||||
</p>}
|
||||
<div className="flex items-center justify-between border-t px-3 py-2 text-[11px]" style={{ borderColor: theme.borderColor, color: theme.mutedFg }}>
|
||||
<label className="flex items-center gap-2"><input type="checkbox" checked={model.showHidden} onChange={event => model.setShowHidden(event.target.checked)} />{t('terminal.directory.hidden')}</label>
|
||||
<button type="button" disabled={model.loading || model.pending || !model.available}
|
||||
className="inline-flex items-center gap-1 rounded px-1 disabled:opacity-50"
|
||||
onClick={() => { if (model.cwd) void model.reload(); else void model.refresh(); }}>
|
||||
<RefreshCw size={11} />{t('terminal.directory.refresh')}
|
||||
</button>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
{!model.open && errorText && <span role="status" title={errorText} className="truncate text-[10px]" style={{ color: theme.mutedFg }}>{errorText}</span>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -8,6 +8,51 @@ import { TerminalComposeBar } from './TerminalComposeBar';
|
||||
import { TooltipProvider } from '../ui/tooltip';
|
||||
import { getComposeBarHistory, pruneComposeBarHistory, recordComposeBarHistory } from '../../application/state/composeBarHistoryStore';
|
||||
import { terminalReconnectRegistry } from '../../application/state/terminalReconnectRegistry';
|
||||
import { createTerminalDirectoryController } from '../../application/state/terminalDirectoryController';
|
||||
import { registerTerminalDirectory, refreshObservedTerminalDirectory } from '../../application/state/terminalDirectoryRegistry';
|
||||
import { terminalCwdStore } from '../../application/state/terminalCwdStore';
|
||||
|
||||
test('disabling Context Chips preserves the compose draft and stops extra cwd refreshes', async (t) => {
|
||||
const dom = new JSDOM('<!doctype html><html><body></body></html>', { url: 'http://localhost' });
|
||||
const originals = new Map<string, PropertyDescriptor | undefined>();
|
||||
for (const name of ['window', 'document', 'localStorage']) {
|
||||
originals.set(name, Object.getOwnPropertyDescriptor(globalThis, name));
|
||||
Object.defineProperty(globalThis, name, { configurable: true, value: dom.window[name as keyof Window] });
|
||||
}
|
||||
t.after(() => {
|
||||
dom.window.close();
|
||||
for (const [name, descriptor] of originals) {
|
||||
if (descriptor) Object.defineProperty(globalThis, name, descriptor);
|
||||
else Reflect.deleteProperty(globalThis, name);
|
||||
}
|
||||
});
|
||||
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
|
||||
let probes = 0;
|
||||
const cleanup = registerTerminalDirectory('toggle-test', createTerminalDirectoryController({
|
||||
sessionId: 'toggle-test', isCurrent: () => true, isReady: () => true,
|
||||
io: { cwd: async () => { probes++; return '/project'; }, list: async () => ({ entries: [], home: null }),
|
||||
realpath: async path => path, write() {} },
|
||||
publish: cwd => { terminalCwdStore.setCwd('toggle-test', cwd, 'backend-strict'); },
|
||||
}));
|
||||
const textarea = { value: '', selectionStart: 0, selectionEnd: 0, focus() {},
|
||||
setSelectionRange(start: number, end: number) { this.selectionStart = start; this.selectionEnd = end; } };
|
||||
const render = (enabled: boolean) => <TooltipProvider><TerminalComposeBar sessionId="toggle-test"
|
||||
contextChipsEnabled={enabled} onSend={() => true} onClose={() => {}} /></TooltipProvider>;
|
||||
let root!: ReactTestRenderer;
|
||||
await act(async () => { root = create(render(true), { createNodeMock: el => el.type === 'textarea' ? textarea : null }); });
|
||||
assert.equal(probes, 1);
|
||||
textarea.value = 'keep this draft'; textarea.selectionStart = 2; textarea.selectionEnd = 8;
|
||||
await act(async () => { root.update(render(false)); });
|
||||
assert.equal(root.root.findAllByProps({ 'aria-label': 'Context Chips' }).length, 0);
|
||||
const before = probes;
|
||||
refreshObservedTerminalDirectory('toggle-test');
|
||||
await Promise.resolve();
|
||||
assert.equal(probes, before);
|
||||
assert.equal(textarea.value, 'keep this draft');
|
||||
assert.deepEqual([textarea.selectionStart, textarea.selectionEnd], [2, 8]);
|
||||
await act(async () => { root.unmount(); });
|
||||
cleanup();
|
||||
});
|
||||
|
||||
test('compose bar preserves a draft on pane focus and records only successful async sends', async (t) => {
|
||||
const dom = new JSDOM('<!doctype html><html><body></body></html>', { url: 'http://localhost' });
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { ComposeDirectoryPicker } from './ComposeDirectoryPicker';
|
||||
/**
|
||||
* Terminal Compose Bar
|
||||
* An immersive prompt bar below the terminal with a quick-snippet strip,
|
||||
@@ -29,7 +30,7 @@ import {
|
||||
const SNIPPET_STRIP_HEIGHT = 30;
|
||||
const RESIZE_HANDLE_HEIGHT = 6;
|
||||
|
||||
type ComposeBarTheme = {
|
||||
export type ComposeBarTheme = {
|
||||
resolvedBg: string;
|
||||
resolvedFg: string;
|
||||
borderColor: string;
|
||||
@@ -273,6 +274,7 @@ const ComposeBarSnippetManagePopover = memo(function ComposeBarSnippetManagePopo
|
||||
});
|
||||
|
||||
export interface TerminalComposeBarProps {
|
||||
contextChipsEnabled?: boolean;
|
||||
// Return false for rejected or sensitive input so it is not recalled.
|
||||
onSend: (text: string) => boolean | void | Promise<boolean | void>;
|
||||
sessionId: string;
|
||||
@@ -291,6 +293,7 @@ export interface TerminalComposeBarProps {
|
||||
|
||||
export const TerminalComposeBar: React.FC<TerminalComposeBarProps> = ({
|
||||
onSend,
|
||||
contextChipsEnabled = true,
|
||||
sessionId,
|
||||
onClose,
|
||||
onSnippetClick,
|
||||
@@ -611,6 +614,9 @@ export const TerminalComposeBar: React.FC<TerminalComposeBarProps> = ({
|
||||
/>
|
||||
</div>
|
||||
|
||||
{contextChipsEnabled && <ComposeDirectoryPicker key={sessionId} sessionId={sessionId} theme={theme}
|
||||
onReturnFocus={() => textareaRef.current?.focus({ preventScroll: true })} />}
|
||||
|
||||
<div className="flex-1 min-h-0 px-3 pt-1.5 pb-2 flex flex-col">
|
||||
<div className="flex flex-1 min-h-0 items-start gap-1.5">
|
||||
{(onToggleComposeSendTarget || sendToAll) && (
|
||||
|
||||
@@ -1432,6 +1432,7 @@ function TerminalViewInner({ ctx, isPaneMagnified = false }: { ctx: TerminalView
|
||||
{/* Compose Bar (solo sessions only; workspace uses TerminalLayer's global bar) */}
|
||||
{isComposeBarOpen && !inWorkspace && (
|
||||
<TerminalComposeBar
|
||||
contextChipsEnabled={terminalSettings?.contextChipsEnabled !== false}
|
||||
key={sessionId}
|
||||
sessionId={sessionId}
|
||||
onSend={async (text) => {
|
||||
@@ -1445,6 +1446,7 @@ function TerminalViewInner({ ctx, isPaneMagnified = false }: { ctx: TerminalView
|
||||
let anyRecipientSensitive = false;
|
||||
let broadcastRecipientIds: readonly string[] = [];
|
||||
const sent = await executeSnippetCommand(text, false, {
|
||||
preferBracketedPaste: true,
|
||||
// Explicit target: active-only stays local while toolbar
|
||||
// broadcast (keystrokes) can remain on, and send-to-all
|
||||
// still fans out when the toolbar switch is off.
|
||||
|
||||
@@ -2776,6 +2776,15 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
|
||||
};
|
||||
return true;
|
||||
}
|
||||
// xterm leaves Shift+Insert to the browser's native paste handler. Fan out
|
||||
// the clipboard data from onData instead of encoding an Insert key for
|
||||
// peers or treating the paste as that key's pending legacy data (#4142).
|
||||
if (
|
||||
e.key === "Insert" && e.shiftKey
|
||||
&& !e.ctrlKey && !e.altKey && !e.metaKey
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
if (!shouldDeferKittyKeyEvent(normalizedKittyEvent)) {
|
||||
const identity = kittyKeyIdentity(e);
|
||||
if (
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import test from 'node:test';
|
||||
import { stripVTControlCharacters } from 'node:util';
|
||||
import { runInNewContext } from 'node:vm';
|
||||
import ts from 'typescript';
|
||||
import { normalizeLineEndings, wrapBracketedPaste } from '../../../lib/utils';
|
||||
import { AUTO_RUN_SNIPPET_LINE_DELAY_MS, shouldDelayAutoRunSnippetInput } from '../terminalHelpers';
|
||||
|
||||
// Exercise the shared production sender without mounting an Electron terminal.
|
||||
const source = readFileSync(new URL('../../Terminal.tsx', import.meta.url), 'utf8');
|
||||
const start = source.indexOf(' const executeSnippetCommand = useCallback(');
|
||||
const end = source.indexOf(' const executeSnippet = useCallback(', start);
|
||||
assert.ok(start >= 0 && end > start);
|
||||
const code = ts.transpileModule(source.slice(start, end) + '\nglobalThis.send = executeSnippetCommand;', {
|
||||
compilerOptions: { target: ts.ScriptTarget.ES2022 },
|
||||
}).outputText;
|
||||
|
||||
type SendOptions = { preferBracketedPaste?: boolean; broadcast?: boolean; multiLineRunMode?: 'paste' | 'lineDelay' };
|
||||
function setup(bracketedPasteMode = true, disableBracketedPaste = true) {
|
||||
const writes: Array<{ id: string; data: string; lineDelayMs?: number }> = [];
|
||||
const broadcasts: string[] = [];
|
||||
const context = {
|
||||
send: undefined as unknown as (command: string, noAutoRun?: boolean, options?: SendOptions) => Promise<boolean>,
|
||||
useCallback: (callback: unknown) => callback,
|
||||
termRef: { current: { modes: { bracketedPasteMode }, focus() {} } },
|
||||
sessionRef: { current: 'source' }, sessionId: 'source', hibernatedRef: { current: false },
|
||||
passwordPromptActiveRef: { current: false }, broadcastPasswordBypassRef: { current: false },
|
||||
isBroadcastEnabledRef: { current: false },
|
||||
shouldBroadcastDuringSensitivePrompt: () => true,
|
||||
isPromptSecurityAlternateScreenActive: () => false,
|
||||
onBroadcastInputRef: { current: (data: string) => { broadcasts.push(data); return ['peer']; } },
|
||||
normalizeLineEndings, wrapBracketedPaste, shouldDelayAutoRunSnippetInput, AUTO_RUN_SNIPPET_LINE_DELAY_MS,
|
||||
disableBracketedPasteRef: { current: disableBracketedPaste },
|
||||
prepareProgrammaticSudoInput: (data: string) => data,
|
||||
scrollToBottomAfterProgrammaticInput() {}, markTerminalBroadcastUserInput() {},
|
||||
terminalBackend: { writeToSession: (id: string, data: string, options: { lineDelayMs?: number }) => {
|
||||
writes.push({ id, data, lineDelayMs: options.lineDelayMs });
|
||||
} },
|
||||
host: { protocol: 'ssh' }, serialConfig: { lineMode: false },
|
||||
};
|
||||
runInNewContext(code, context);
|
||||
return { send: context.send, writes, broadcasts };
|
||||
}
|
||||
|
||||
test('compose brackets multiline commands before the final Enter even when clipboard bracketing is disabled', async () => {
|
||||
const { send, writes, broadcasts } = setup();
|
||||
assert.equal(await send('echo one\r\necho two\necho three', false, {
|
||||
preferBracketedPaste: true, broadcast: true,
|
||||
}), true);
|
||||
assert.equal(writes[0].data, '\x1b[200~echo one\necho two\necho three\x1b[201~\r');
|
||||
assert.equal(writes[0].lineDelayMs, undefined);
|
||||
assert.deepEqual(broadcasts, [writes[0].data]);
|
||||
});
|
||||
|
||||
test('single-line compose and snippet paste/line-delay preferences stay unchanged', async () => {
|
||||
const { send, writes } = setup();
|
||||
await send('echo one', false, { preferBracketedPaste: true });
|
||||
await send('echo one\necho two', false);
|
||||
await send('echo one\necho two', true);
|
||||
await send('echo one\necho two', false, { multiLineRunMode: 'lineDelay' });
|
||||
assert.deepEqual(writes.map(({ data, lineDelayMs }) => ({ data, lineDelayMs })), [
|
||||
{ data: 'echo one\r', lineDelayMs: undefined },
|
||||
{ data: 'echo one\necho two\r', lineDelayMs: undefined },
|
||||
{ data: 'echo one\necho two', lineDelayMs: undefined },
|
||||
{ data: 'echo one\necho two\r', lineDelayMs: 250 },
|
||||
]);
|
||||
const unsupported = setup(false);
|
||||
await unsupported.send('echo one\necho two', false, { preferBracketedPaste: true });
|
||||
assert.equal(unsupported.writes[0].data.includes('\x1b[200~'), false);
|
||||
const enabled = setup(true, false);
|
||||
await enabled.send('echo one\necho two', false);
|
||||
assert.equal(enabled.writes[0].data, '\x1b[200~echo one\necho two\x1b[201~\r');
|
||||
});
|
||||
|
||||
// Python's standard-library PTY lets Bash/Readline exercise the actual terminal
|
||||
// bytes without depending on node-pty's Electron-specific native ABI.
|
||||
const bashPty = String.raw`
|
||||
import json, os, pty, select, signal, sys, time
|
||||
payload = json.load(sys.stdin)
|
||||
pid, fd = pty.fork()
|
||||
if pid == 0:
|
||||
os.execvpe('bash', ['bash', '--noprofile', '--norc', '-i'],
|
||||
dict(os.environ, PS1='__COMPOSE_PROMPT__', TERM='xterm-256color',
|
||||
INPUTRC='/dev/null', HISTFILE='/dev/null', PROMPT_COMMAND=''))
|
||||
def prompt():
|
||||
output = b''
|
||||
deadline = time.monotonic() + 5
|
||||
while b'__COMPOSE_PROMPT__' not in output:
|
||||
if time.monotonic() > deadline:
|
||||
raise RuntimeError('Bash prompt timeout: ' + repr(output))
|
||||
if select.select([fd], [], [], 0.1)[0]:
|
||||
output += os.read(fd, 65536)
|
||||
# Multiple unbracketed commands produce a prompt between each line. Drain
|
||||
# their output too, so the echo control does not stop at the first prompt.
|
||||
while time.monotonic() < deadline and select.select([fd], [], [], 0.1)[0]:
|
||||
output += os.read(fd, 65536)
|
||||
return output
|
||||
try:
|
||||
prompt()
|
||||
os.write(fd, b"stty -echo; bind 'set enable-bracketed-paste on'\r")
|
||||
prompt()
|
||||
os.write(fd, payload.encode())
|
||||
sys.stdout.write(prompt().decode(errors='replace'))
|
||||
finally:
|
||||
os.kill(pid, signal.SIGKILL)
|
||||
os.close(fd)
|
||||
os.waitpid(pid, 0)
|
||||
`;
|
||||
const hasBashPty = process.platform !== 'win32'
|
||||
&& spawnSync('python3', ['-c', 'import pty']).status === 0
|
||||
&& spawnSync('bash', ['-c', '(( BASH_VERSINFO[0] >= 5 ))']).status === 0;
|
||||
|
||||
for (const [name, command, expected] of [
|
||||
['all echo lines run', "echo '__ONE__'\necho '__TWO__'\necho '__THREE__'", ['__ONE__', '__TWO__', '__THREE__']],
|
||||
['long first command cannot consume later commands as stdin',
|
||||
"sleep 0.4; read -r -t 0.4 consumed; printf '__STDIN__:%s\\n' \"$consumed\"\necho '__TWO__'\necho '__THREE__'",
|
||||
['__STDIN__:', '__TWO__', '__THREE__']],
|
||||
['single-line still runs', "echo '__ONE__'", ['__ONE__']],
|
||||
] as const) {
|
||||
test(`Bash PTY compose: ${name}`, { skip: hasBashPty ? false : 'requires Python PTY support and Bash 5+' }, async () => {
|
||||
const { send, writes } = setup();
|
||||
await send(command, false, { preferBracketedPaste: true });
|
||||
const result = spawnSync('python3', ['-c', bashPty], {
|
||||
input: JSON.stringify(writes[0].data), encoding: 'utf8', timeout: 10000,
|
||||
});
|
||||
assert.equal(result.status, 0, result.stderr || String(result.error));
|
||||
const lines = stripVTControlCharacters(result.stdout).split(/\r?\n/);
|
||||
assert.deepEqual(lines.filter(line => line.startsWith('__') && !line.startsWith('__COMPOSE_PROMPT__')), expected);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import test from "node:test";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { buildSync } from "esbuild";
|
||||
import { JSDOM } from "jsdom";
|
||||
import ts from "typescript";
|
||||
|
||||
test("native Shift+Insert broadcasts clipboard bytes once without an Insert key", async () => {
|
||||
const root = fileURLToPath(new URL("../../..", import.meta.url));
|
||||
const layer = readFileSync(new URL("../../TerminalLayer.tsx", import.meta.url), "utf8");
|
||||
const start = layer.indexOf(" const handleBroadcastInput = useCallback(");
|
||||
const end = layer.indexOf(" const handleCommandSubmitted =", start);
|
||||
assert.ok(start >= 0 && end > start);
|
||||
const dispatcher = ts.transpileModule(layer.slice(start, end), {
|
||||
compilerOptions: { target: ts.ScriptTarget.ES2022 },
|
||||
}).outputText;
|
||||
const bundle = buildSync({
|
||||
stdin: {
|
||||
contents: `
|
||||
export { createXTermRuntime } from "./components/terminal/runtime/createXTermRuntime";
|
||||
export { DEFAULT_TERMINAL_SETTINGS } from "./domain/models/terminal";
|
||||
export { resolveTerminalBroadcastTargetIds } from "./domain/terminalBroadcast";
|
||||
export { dispatchKittyKeyboardBroadcastInput } from "./components/terminal/runtime/kittyKeyboardBroadcast";
|
||||
export * from "./components/terminal/runtime/terminalPacedBroadcast";
|
||||
`,
|
||||
loader: "ts", resolveDir: root,
|
||||
},
|
||||
bundle: true, format: "iife", globalName: "runtimeTest", platform: "browser", write: false,
|
||||
alias: {
|
||||
"node:diagnostics_channel": path.join(root, "infrastructure/shims/nodeDiagnosticsChannel.ts"),
|
||||
"diagnostics_channel": path.join(root, "infrastructure/shims/nodeDiagnosticsChannel.ts"),
|
||||
},
|
||||
define: { "import.meta.env.DEV": "false", "import.meta.env.PROD": "true", "import.meta": "{}" },
|
||||
}).outputFiles[0].text;
|
||||
const dom = new JSDOM("<body/>", { url: "http://localhost", runScripts: "outside-only", pretendToBeVisual: true });
|
||||
Object.assign(dom.window, { structuredClone });
|
||||
try {
|
||||
dom.window.eval(`
|
||||
Object.defineProperty(navigator, 'platform', {value:'Win32'});
|
||||
window.matchMedia = () => ({matches:false,addListener(){},removeListener(){},addEventListener(){},removeEventListener(){}});
|
||||
window.ResizeObserver = window.IntersectionObserver = class {observe(){} unobserve(){} disconnect(){}};
|
||||
HTMLCanvasElement.prototype.getContext = function(type) {
|
||||
if (type !== '2d') return null;
|
||||
return new Proxy({canvas:this,measureText:()=>({width:8,fontBoundingBoxAscent:12,fontBoundingBoxDescent:3}),
|
||||
getImageData:()=>({data:new Uint8ClampedArray(4)}),createLinearGradient:()=>({addColorStop(){}})},
|
||||
{get:(target,key)=>key in target ? target[key] : ()=>{}});
|
||||
};
|
||||
`);
|
||||
dom.window.eval(bundle);
|
||||
const results = await dom.window.eval(`(async () => {
|
||||
const {createXTermRuntime, DEFAULT_TERMINAL_SETTINGS, resolveTerminalBroadcastTargetIds,
|
||||
dispatchKittyKeyboardBroadcastInput, markTerminalBroadcastUserInput,
|
||||
captureTerminalBroadcastInput, isTerminalBroadcastInputCurrent} = runtimeTest;
|
||||
const ref = current => ({current});
|
||||
const useCallback = fn => fn;
|
||||
const sessionsRef = ref([]), isGlobalBroadcastEnabled = false;
|
||||
const broadcastPasswordBypassRef = ref(false);
|
||||
const canUseDirectSessionWriteFallback = () => true;
|
||||
const isTerminalSensitiveInputActive = () => false;
|
||||
const wires = new Map();
|
||||
const terminalBackend = {
|
||||
writeToSession:(id,data)=>wires.get(id).push(data), resizeSession(){}, openExternalAvailable:false,
|
||||
};
|
||||
${dispatcher}
|
||||
const make = (id, kittyKeyboardProtocolEnabled) => {
|
||||
const container = document.createElement('div'); document.body.appendChild(container);
|
||||
wires.set(id, []);
|
||||
const host = {id,label:id,hostname:'localhost',protocol:'ssh',sshInputCompatibility:false};
|
||||
return createXTermRuntime({
|
||||
container, host, getHost:()=>host, sessionId:id, sessionRef:ref(id), statusRef:ref('connected'),
|
||||
fontFamilyId:'jetbrains-mono',resolvedFontFamily:'monospace',fontSize:14,
|
||||
terminalTheme:{colors:{background:'#111111',foreground:'#eeeeee',cursor:'#ffffff',selection:'#444444'}},
|
||||
terminalSettingsRef:ref({...DEFAULT_TERMINAL_SETTINGS,rendererType:'dom',fontLigatures:false,cursorBlink:false}),
|
||||
terminalBackend,hotkeySchemeRef:ref('disabled'),keyBindingsRef:ref([]),onHotkeyActionRef:ref(undefined),
|
||||
isBroadcastEnabledRef:ref(true),onBroadcastInputRef:ref(handleBroadcastInput),
|
||||
commandBufferRef:ref(''),requestSearchFocus(){},kittyKeyboardProtocolEnabled,
|
||||
});
|
||||
};
|
||||
const results = [];
|
||||
for (const bracketed of [false,true]) {
|
||||
for (const delayed of [true,false]) {
|
||||
for (const peerKitty of [false,true]) {
|
||||
const source = make('source', false), peer = make('peer', peerKitty);
|
||||
try {
|
||||
sessionsRef.current = ['source','peer'].map(id=>({id,protocol:'ssh',workspaceId:'workspace'}));
|
||||
if (peerKitty) await new Promise(resolve=>peer.term.write('\\x1b[>8u',resolve));
|
||||
if (bracketed) await new Promise(resolve=>source.term.write('\\x1b[?2004h',resolve));
|
||||
const key = new KeyboardEvent('keydown', {
|
||||
bubbles:true,cancelable:true,key:'Insert',code:'Insert',keyCode:45,shiftKey:true,
|
||||
});
|
||||
source.term.textarea.dispatchEvent(key);
|
||||
const beforePaste = [...wires.get('peer')];
|
||||
if (delayed) await new Promise(resolve=>setTimeout(resolve,10));
|
||||
// Supply the native clipboard event which Chromium emits for this chord.
|
||||
const paste = new Event('paste', {bubbles:true,cancelable:true});
|
||||
Object.defineProperty(paste,'clipboardData',{value:{getData:()=> 'clipboard'}});
|
||||
source.term.textarea.dispatchEvent(paste);
|
||||
source.term.textarea.dispatchEvent(new KeyboardEvent('keyup', {
|
||||
bubbles:true,key:'Insert',code:'Insert',keyCode:45,shiftKey:true,
|
||||
}));
|
||||
results.push({bracketed,delayed,peerKitty,beforePaste,
|
||||
prevented:key.defaultPrevented,source:[...wires.get('source')],peer:[...wires.get('peer')]});
|
||||
// Ordinary Insert still uses the physical-key broadcast path.
|
||||
if (!peerKitty) {
|
||||
wires.get('source').length=0; wires.get('peer').length=0;
|
||||
source.term.textarea.dispatchEvent(new KeyboardEvent('keydown', {
|
||||
bubbles:true,cancelable:true,key:'Insert',code:'Insert',keyCode:45,
|
||||
}));
|
||||
results.push({plainInsert:true,source:[...wires.get('source')],peer:[...wires.get('peer')]});
|
||||
}
|
||||
} finally { source.dispose(); peer.dispose(); }
|
||||
}
|
||||
}
|
||||
}
|
||||
return JSON.stringify(results);
|
||||
})()`);
|
||||
for (const result of JSON.parse(results)) {
|
||||
if (result.plainInsert) {
|
||||
assert.deepEqual(result.source, ["\x1b[2~"]);
|
||||
assert.deepEqual(result.peer, ["\x1b[2~"]);
|
||||
continue;
|
||||
}
|
||||
const label = JSON.stringify(result);
|
||||
assert.deepEqual(result.beforePaste, [], label);
|
||||
assert.equal(result.prevented, false, "native paste must remain enabled: " + label);
|
||||
const expected = result.bracketed ? "\x1b[200~clipboard\x1b[201~" : "clipboard";
|
||||
assert.deepEqual(result.source, [expected], label);
|
||||
assert.deepEqual(result.peer, [expected], label);
|
||||
}
|
||||
} finally {
|
||||
dom.window.close();
|
||||
}
|
||||
});
|
||||
@@ -260,6 +260,7 @@ export interface TerminalProps {
|
||||
options?: {
|
||||
broadcast?: boolean;
|
||||
multiLineRunMode?: Snippet["multiLineRunMode"];
|
||||
preferBracketedPaste?: boolean;
|
||||
focus?: boolean;
|
||||
},
|
||||
) => boolean | Promise<boolean>) | null,
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { refreshObservedTerminalDirectory } from '../../application/state/terminalDirectoryRegistry';
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any, react-hooks/exhaustive-deps */
|
||||
import { useRef } from 'react';
|
||||
import { publishTerminalSelectionTarget } from '../../infrastructure/plugins/terminalSelectionRegistry';
|
||||
@@ -237,6 +238,7 @@ export function useTerminalEffects(ctx: TerminalEffectsContext) {
|
||||
onCommandSettledRef.current = ctx.onCommandSettled;
|
||||
const pluginAwareOnCommandCompleted = () => {
|
||||
publishPluginTerminalRuntimeLifecycleEvent(pluginTerminalLifecycle, 'commandCompleted');
|
||||
refreshObservedTerminalDirectory(sessionId);
|
||||
void xtermRuntimeRef.current?.pluginProviderHost?.commandCompleted();
|
||||
// Stock local shells update cwd when the command finishes, which can be
|
||||
// long after the 150ms post-submit probe. Prompt return (and OSC 133)
|
||||
|
||||
@@ -1479,7 +1479,7 @@ function TerminalLayerSidePanelInner({ ctx, pluginSidebar }: { ctx: SidePanelCon
|
||||
<Btn
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7 rounded-md p-0 hover:bg-transparent"
|
||||
className="h-7 w-7 shrink-0 rounded-md p-0 hover:bg-transparent"
|
||||
style={{ color: sidePanelTheme.mutedFg }}
|
||||
onClick={handleCloseSidePanel}
|
||||
>
|
||||
|
||||
@@ -112,6 +112,7 @@ export type SnippetExecutor = (
|
||||
options?: {
|
||||
broadcast?: boolean;
|
||||
multiLineRunMode?: Snippet["multiLineRunMode"];
|
||||
preferBracketedPaste?: boolean;
|
||||
/** When false, do not steal keyboard focus (multi-tab fan-out). Default true. */
|
||||
focus?: boolean;
|
||||
/**
|
||||
|
||||
@@ -350,6 +350,7 @@ function TerminalLayerWorkspaceSectionInner({ ctx }: { ctx: WorkspaceContext })
|
||||
{activeWorkspace && focusedSessionId && isComposeBarOpen && (
|
||||
<TerminalComposeBar
|
||||
sessionId={focusedSessionId}
|
||||
contextChipsEnabled={terminalSettings?.contextChipsEnabled !== false}
|
||||
onSend={handleComposeSend}
|
||||
onSnippetClick={(snippet) => void handleSnippetFromPanel(snippet)}
|
||||
snippets={snippets}
|
||||
|
||||
@@ -15,7 +15,7 @@ const code = ts.transpileModule(source.slice(start, end) + '\nglobalThis.send =
|
||||
}).outputText;
|
||||
|
||||
type Status = 'connected' | 'connecting' | 'disconnected';
|
||||
type Executor = (text: string, executeImmediately: boolean, options?: { broadcast?: boolean; sensitive?: boolean }) => boolean | Promise<boolean>;
|
||||
type Executor = (text: string, executeImmediately: boolean, options?: { broadcast?: boolean; sensitive?: boolean; preferBracketedPaste?: boolean }) => boolean | Promise<boolean>;
|
||||
function setup(
|
||||
statuses: Status[],
|
||||
broadcast = false,
|
||||
@@ -96,6 +96,24 @@ test('compose send-to-all fans out while toolbar broadcast stays off', async ()
|
||||
assert.deepEqual(writes, ['0', '1']);
|
||||
});
|
||||
|
||||
for (const sendToAll of [false, true]) {
|
||||
test(`workspace compose requests supported bracketed paste on each target (all=${sendToAll})`, async () => {
|
||||
const calls: string[] = [];
|
||||
const text = 'echo one\necho two\necho three';
|
||||
const executors = new Map<string, Executor>(['0', '1'].map(id => [id, async (command, noAutoRun, options) => {
|
||||
assert.equal(command, text);
|
||||
assert.equal(noAutoRun, false);
|
||||
assert.equal(options?.preferBracketedPaste, true);
|
||||
assert.equal(options?.broadcast, false);
|
||||
calls.push(id);
|
||||
return true;
|
||||
}]));
|
||||
const { send } = setup(['connected', 'connected'], false, executors, new Set(), false, new Set(), sendToAll);
|
||||
assert.equal(await send(text), true);
|
||||
assert.deepEqual(calls, sendToAll ? ['0', '1'] : ['0']);
|
||||
});
|
||||
}
|
||||
|
||||
test('compose send-to-all keeps a bypassed sensitive peer out of history', async () => {
|
||||
const executors = new Map<string, Executor>([
|
||||
['0', async () => true],
|
||||
@@ -231,7 +249,8 @@ test('solo compose send uses the compose-bar target while toolbar broadcast stay
|
||||
const context = {
|
||||
send: undefined as unknown as (text: string) => Promise<boolean>,
|
||||
sessionId: 'a', sessionRef: { current: 'a' },
|
||||
executeSnippetCommand: (_text: string, _noAutoRun?: boolean, options?: { broadcast?: boolean }) => {
|
||||
executeSnippetCommand: (_text: string, _noAutoRun?: boolean, options?: { broadcast?: boolean; preferBracketedPaste?: boolean }) => {
|
||||
assert.equal(options?.preferBracketedPaste, true);
|
||||
calls.push(options?.broadcast);
|
||||
return true;
|
||||
},
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
# Install from GitHub
|
||||
|
||||
Settings > Plugins > Open plugin manager > **Install from GitHub** installs a
|
||||
published package from a public GitHub repository. Local `.ncpkg` import and
|
||||
marketplace installation remain available.
|
||||
|
||||
Accepted repository inputs are `https://github.com/owner/repo`,
|
||||
`github.com/owner/repo`, and `owner/repo`. HTTP is upgraded to HTTPS. An optional
|
||||
trailing `.git` and single `/` are removed. Case is normalized to lowercase, so
|
||||
all these forms share `https://github.com/owner/repo` and the same host-owned
|
||||
source ID. Credentials, explicit ports, other hosts/schemes, query/fragment,
|
||||
percent escapes, backslashes, traversal, and extra path segments are rejected.
|
||||
Paste a repository, not a branch, subdirectory, script or release asset URL.
|
||||
|
||||
## Author's release contract
|
||||
|
||||
The existing package contract is a `.ncpkg` ZIP produced by
|
||||
`@netcatty/plugin-cli`: root `netcatty.plugin.json`, its declared browser entry
|
||||
and resources, and any other validated package contents. The package validator
|
||||
is the authority for paths, manifest semantics, referenced files, CRCs, archive
|
||||
headers, expansion limits and companion integrity. A Git checkout, source ZIP,
|
||||
`package.json`, and `prepare.mjs` are not installable packages.
|
||||
|
||||
Existing local preparers remain author-side tools. For example,
|
||||
`examples/plugins/database-rfam-readonly/prepare.mjs` bundles `host.js` into
|
||||
`browser.js`, copies `netcatty.plugin.json`, and calls `buildPluginPackage`.
|
||||
The author must run this locally, validate the resulting `.ncpkg`, and attach
|
||||
it to a GitHub release. Netcatty never executes a downloaded preparer, npm
|
||||
lifecycle script, shell command, build tool or repository source.
|
||||
|
||||
There was no existing GitHub release discovery/naming contract on main. This
|
||||
flow defines the narrow delivery contract:
|
||||
|
||||
1. Resolve the exact public repository through GitHub's unauthenticated API.
|
||||
2. Read `/repos/{owner}/{repo}/releases/latest` (GitHub's latest published stable
|
||||
release, not a client-side semantic version search). Draft/prerelease-only
|
||||
repositories are unsupported.
|
||||
3. Require a nonempty tag and exactly one uploaded `.ncpkg` release asset. Its
|
||||
filename may be any ASCII name beginning with an alphanumeric character and
|
||||
otherwise containing letters, digits, `.`, `_`, `-`, at most 200 characters.
|
||||
There is no required repository-name prefix. Multiple assets require local
|
||||
import so the app cannot silently choose the wrong platform/package.
|
||||
4. Resolve the tag through `/repos/{owner}/{repo}/commits/{tag}` to a commit SHA.
|
||||
Download the asset's exact GitHub release URL, bound to that repository/tag.
|
||||
5. Check release size, optional GitHub `sha256:` digest, ZIP structure and the
|
||||
entire existing package validator, runtime admission, compatibility and
|
||||
retained source binding before offering a review.
|
||||
6. Show the actual package manifest/version, declared permission/resource
|
||||
bounds, source, tag/commit, asset and computed SHA-256. Require explicit trust
|
||||
acknowledgment. Install disabled through `PluginManager` and `PackageStore`;
|
||||
the immutable snapshot must match the reviewed digest and manifest.
|
||||
|
||||
The manifest is the package version authority; release tag naming is independent
|
||||
and both are displayed. The tag/commit identify release context, not proof that
|
||||
the binary was reproducibly built from that commit. The SHA-256 pins reviewed
|
||||
bytes; neither it nor GitHub ownership verifies a publisher signature.
|
||||
|
||||
No accessible repository, no stable release, no tag commit, missing/ambiguous
|
||||
asset, HTML/XML instead of a package, incompatible/invalid package, size/hash
|
||||
mismatch, access denial, rate limit and offline/HTTP failures all fail closed.
|
||||
There is no clone, auto-build, raw-file or registry fallback. Private repositories,
|
||||
login and tokens are unsupported. GitHub API repository moves require entering
|
||||
the current URL, rather than silently changing repository identity.
|
||||
|
||||
## Boundaries and persistence
|
||||
|
||||
Only the trusted host renderer can invoke prepare/install/discard management
|
||||
IPC. Plugin RPC exposes no GitHub retrieval or arbitrary URL fetch. The backend
|
||||
constructs the API URLs and allows only HTTPS `api.github.com`; API redirects
|
||||
are rejected. Release downloads allow HTTPS `github.com`,
|
||||
`release-assets.githubusercontent.com`, `objects.githubusercontent.com` with at
|
||||
most three redirects. Each redirect is checked before contacting its host.
|
||||
No cookies, Authorization headers, environment credentials or git configuration
|
||||
are consulted. Redirect signing queries are allowed only on these download
|
||||
hosts; repository inputs never accept queries or credentials.
|
||||
|
||||
Metadata is capped at 2 MiB; packages at the CLI's 100 MiB archive limit plus all
|
||||
existing expansion/file limits. The network sequence has a 90 second timeout;
|
||||
standalone bounded transfers default to 30 seconds. Streamed bodies are bounded
|
||||
regardless of Content-Length. Temporary archives use Netcatty's managed temp
|
||||
root and mode-0700 private directories, mode-0600 files. Previews are bound to the
|
||||
requesting renderer, expire after ten minutes, and are consumed on install or
|
||||
removed on cancel/window destruction. At most eight active/pending operations
|
||||
are retained. Installation revalidates the private snapshot and reviewed hash
|
||||
and manifest, then uses the existing atomic publication/recovery transaction.
|
||||
|
||||
The existing JSON source binding stores `kind: github`, a repository-derived
|
||||
source ID, canonical URL, tag, commit and asset ID without a schema migration.
|
||||
Private data ownership remains tied to the source ID. Replacing a local or
|
||||
marketplace installation requires the existing explicit uninstall/private-data
|
||||
reset. Installed details show the repository and pin after reopening/restart.
|
||||
To update, enter the same repository again and review its current release;
|
||||
registry update checks intentionally skip GitHub sources. No automatic GitHub
|
||||
update checks or downgrade policy are introduced.
|
||||
|
||||
The existing unsigned security principal still binds package bytes and publisher
|
||||
text, so changed unsigned code does not inherit grants. Normal installation
|
||||
admits browser-only/data-theme packages; advanced packages remain behind the
|
||||
existing developer gate. This feature does not add signing or grant advanced
|
||||
runtime trust. Review installed permission modes before enabling; declared
|
||||
supported permissions retain their existing default Allow/Ask/Deny behavior.
|
||||
|
||||
## Verification
|
||||
|
||||
`domain/pluginGitHubRepository.test.cjs` checks the exact three input forms and
|
||||
identity equivalence. `electron/plugins/githubInstallService.test.cjs` exercises
|
||||
the real CLI-produced starter package and actual manager/store/database, plus
|
||||
controlled metadata/transfer failures. The renderer interaction test mounts the
|
||||
real Radix dialog. Existing package/manifest and runtime suites retain the
|
||||
archive abuse/companion/permission boundary checks.
|
||||
|
||||
Run the existing Electron smoke harness with this checkout built, the local SSH
|
||||
fixture running, and:
|
||||
|
||||
```sh
|
||||
NC_APP="$PWD" NC_OUT=/workspace/netcatty-smoke-shots/github-install/pass \
|
||||
NC_EXTRA="$PWD/scripts/plugin-github-install.smoke.mjs" \
|
||||
xvfb-run -a -s '-screen 0 1440x900x24' \
|
||||
node /workspace/netcatty-smoke-harness/smoke.mjs
|
||||
```
|
||||
|
||||
The extra uses Playwright on the actual app, a real starter `.ncpkg` and real
|
||||
loopback HTTP downloads through a main-process-only injected transport. Production
|
||||
URL checks, resolver, validator, preview, IPC, permissions and installation all
|
||||
run unchanged. The repository name/commit in this fixture are explicitly
|
||||
synthetic, not a claim of a public release. It records an MP4 of the display and
|
||||
actual Electron screenshots, rejects invalid input and a missing release, reviews
|
||||
permissions, installs disabled, changes a permission mode and reopens the manager
|
||||
to verify persistence. It never enables or executes the fixture plugin, and
|
||||
requires no user credential. The generic harness SSH login is its disposable
|
||||
local test account.
|
||||
@@ -15,9 +15,11 @@ Context Chips: [目录] [分支] [变更统计] [Node] [PR]
|
||||
|
||||
芯片显示上下文,点击就近操作。首期只显示已实现的目录芯片,其余按终端能力与上下文出现。共享终端主题、紧凑图标和文字、悬停说明及键盘焦点;不额外显示占据高度的行标题。窄分屏优先保留目录,长路径/分支名截断显示,完整值可查看。
|
||||
|
||||
“设置 → 终端 → Context Chips”控制整行显示,默认开启;关闭时保留命令草稿,并停止芯片额外发起的目录读取与命令后刷新。
|
||||
|
||||
| 功能 | 开发目标 | 调研与交互 |
|
||||
| --- | --- | --- |
|
||||
| Directory Picker | 显示当前位置;搜索子目录、返回父目录、选择后立即切换并关闭菜单 | [directory-picker.md](directory-picker.md) · [交互示意](directory-picker-demo.html) |
|
||||
| Directory Picker | 显示当前位置;搜索子目录、返回父目录、选择后立即切换并关闭菜单 | [功能规格](directory-picker.md) · [交互示意](directory-picker-demo.html) · [跨平台研究](directory-picker-cross-platform.md) · [后续计划](directory-picker-follow-up.md) |
|
||||
| Git Branch | 显示当前分支,搜索并切换其他分支 | [git-branch.md](git-branch.md) |
|
||||
| Git Changes | 显示改动文件数及增删行;预留变更文件面板入口 | [git-changes.md](git-changes.md) |
|
||||
| Node Version | 显示当前终端 Node 版本;支持的管理器可提供版本切换 | [node-version.md](node-version.md) |
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
# Directory Picker:跨平台能力与 shell 集成研究
|
||||
|
||||
调研日期:2026-10-30。Netcatty 源码基线:`7f8f8d34687a1dbb94b16f314952bf25aac0d88d`。本文研究后续能力,**不表示当前 PR 已支持 Windows 本地目录导航**;实施顺序见 [后续计划](directory-picker-follow-up.md)。
|
||||
|
||||
## 1. 结论
|
||||
|
||||
保留绝对路径作为操作目标;依 shell 生成命令,依文件系统环境读取目录,依当前交互会话确认结果。跨平台的难点是这三项能力的组合,不是把 `cd --` 换成 `cd`。
|
||||
|
||||
建议优先补齐 Windows PowerShell 5.1/PowerShell 7,再推进 CMD。macOS 的 zsh/bash 和主流 Linux 的 bash/ash/zsh 保留已有进程 cwd 回退,并逐步增加可协商的 shell 上报。fish 独立处理引用与事件。WSL、Git Bash、Cygwin 按自己的路径空间接入,不因被分类为 `posix` 就复用 Windows 本地文件系统。
|
||||
|
||||
这里的“默认终端支持”指 **Netcatty 创建的交互 shell**。Windows Terminal、Terminal.app、GNOME Terminal 等窗口应用本身不是命令解释器;不能只按 OS 或发行版名称选择语法。
|
||||
|
||||
## 2. 当前实现与缺口
|
||||
|
||||
| 位置 | 已有能力 | 后续约束 |
|
||||
| --- | --- | --- |
|
||||
| `application/state/useTerminalDirectorySession.ts` | macOS/Linux 本地与符合条件的 SSH;Windows 本地主动禁用 | 当前平台门槛不能直接删除,必须先证明 cwd、路径、执行三者都适配 |
|
||||
| `electron/bridges/localSessionCwd.cjs` | Linux 读 `/proc/<pid>/cwd`;macOS 用 lsof | Windows 未实现;读到启动 PTY 的进程不等于能识别所有嵌套 shell |
|
||||
| `infrastructure/services/terminalDirectory.ts` | 本地 FS 或 exact-session SFTP,严格 cwd 查询 | 远程不新建登录、不借用其他 session;SFTP 根与 shell 根未必相同 |
|
||||
| `domain/directoryPicker.ts` | `/` 开头的路径、POSIX 父路径和拼接 | 不支持盘符、UNC、WSL 及 MSYS/Cygwin 路径转换 |
|
||||
| `domain/sessionRestore.ts` | `resolveInteractiveTerminalCdIntent` 的 POSIX 引用、控制字节拒绝 | 扩展 Directory Picker 不应破坏已有恢复和 SFTP 定位调用者 |
|
||||
| `terminalDirectoryController.ts`/`terminalCwdStore.ts` | 每会话 revision、严格结果确认、旧连接失效 | 加入 shell 报告也要保留这些顺序与身份约束 |
|
||||
| `terminalDirectoryRegistry.ts`/`components/Terminal.tsx` | 芯片可见时按完成/输出事件刷新,不恒定轮询 | 集成有效时可以减少辅助探测;关闭 chips 要停止其额外需求 |
|
||||
| `components/terminal/runtime/createXTermRuntime.ts` | OSC 7 上报路径;OSC 133 的 D 完成事件 | 当前 `URL.pathname` 结果仍按 POSIX 使用;不能把 `/C:/...` 当 POSIX 路径直接读盘 |
|
||||
| `components/terminal/osc7Setup.ts` | 已有 bash/zsh/fish 目录跟踪配置及确认流程 | 会写 rc 文件并可能 reload;不能改成新功能的静默自动安装器 |
|
||||
| `electron/bridges/ai/sessionShellKind.cjs`/`liveShellProbe.cjs` | shell 分类、远端登录 hint、当前 PTY 探测 | 登录 shell hint 不证明当前 shell;现有主动探测有输入和副作用,不能每次点菜单就执行 |
|
||||
| `electron/bridges/ai/ptyExecHelpers.cjs` | POSIX/fish/PowerShell 各自的字符串引用 | 可提取纯引用逻辑;整个 AI exec 包装器不适合目录操作,有子进程、eval、环境及历史处理语义 |
|
||||
|
||||
## 3. 绝对路径与 `--`
|
||||
|
||||
菜单查询、点击、异步 realpath、写入终端之间存在时间差。即使 chip 自动跟随,列表也只代表查询时的目录。相对路径不能单独消除旧列表风险:仍要校验 session、连接代次、shell/文件系统身份及 cwd revision。
|
||||
|
||||
POSIX `cd` 的相对目录还可能受 `CDPATH` 影响;`./child` 能避开这一搜索规则,但仍依赖执行时 cwd。POSIX 文档规定 `cd` 改变的是当前 shell 的执行环境,因此独立 exec 的 cd 不能代替对用户交互 shell 的切换。[S1]
|
||||
|
||||
本机隔离目录实验,bash 3.2.57、zsh 5.9 和系统 dash:
|
||||
|
||||
| 实验 | 结果 |
|
||||
| --- | --- |
|
||||
| `cd -- '/absolute/path'` 与 `cd '/absolute/path'` | 三种 shell 均成功 |
|
||||
| 从 A 列出 `src`,执行前切到 B,再 `cd './src'` | 三种 shell 均进入 B/src |
|
||||
| 同样条件下使用 A/src 绝对路径 | 三种 shell 均进入指定 A/src |
|
||||
| 设置 `CDPATH` 后执行 `cd 'src'` | bash/dash 进入搜索路径中的 src;本次 zsh 优先进入本地 src,不能泛化为各 shell 完全相同 |
|
||||
|
||||
这些实验只证明上述本机版本。`--` 与引号作用不同:前者结束选项,后者保持参数边界。对于当前 `/` 开头的绝对路径,`--` 不是必需;不应把删掉它当成 Windows 兼容方案。引用策略必须按 shell 测试,不能把 POSIX 单引号拼接直接推广到 fish/CMD。[S1、S4、N1]
|
||||
|
||||
## 4. 支持矩阵与数据来源
|
||||
|
||||
| 环境 | cwd 首选/回退 | 目录读取 | 切换策略与主要缺口 |
|
||||
| --- | --- | --- | --- |
|
||||
| macOS zsh,兼容 bash 3.2 | 已有 lsof;新增 prompt/chpwd 上报 | 本地 FS | 当前 shell 的 cd;保留原有 login/rc/PATH 行为 |
|
||||
| Linux bash/zsh/dash/ash | 已有 `/proc`;可用时接 shell 报告 | 本地 FS | 独立引用测试;dash/ash 不保证有 bash 的 hook 能力 |
|
||||
| Linux/macOS fish | prompt/PWD 事件,必要时进程回退 | 本地 FS | fish 引用与事件;不能运行 POSIX 初始化脚本 |
|
||||
| Windows PowerShell 5.1/7 | **当前 runspace 的位置报告** | FileSystem provider 的本地 FS | `Set-Location -LiteralPath`;盘符、UNC、provider、策略限制 |
|
||||
| Windows CMD | 会话 PROMPT 中的位置/边界报告 | Windows 本地 FS | `cd /d`;百分号、感叹号、命令扩展、UNC 另定 |
|
||||
| Windows WSL | 所选 distro 内 shell 上报/受控 helper | 同一 distro 的 Linux FS 适配 | Linux 语法;不能把 `/home` 传给宿主 Windows FS |
|
||||
| Windows Git Bash/Cygwin | 该环境的 shell 上报 | 已验证的命名空间映射/helper | MSYS/Cygwin 的挂载规则,不是 WSL 的路径转换 |
|
||||
| 普通 SSH 到 Unix | 已有严格活动 shell 探测;可选远端集成 | 同一连接的 SFTP;先证明路径对应 | 嵌套 shell、sudo、chroot 不能退回登录 home 冒充 cwd |
|
||||
| SSH 到 Windows | 远端活动 shell 报告;不靠新 exec 的 Get-Location | 远端 SFTP 路径映射/受控能力 | Win32-OpenSSH hint 不等于活动 shell;独立阶段 |
|
||||
| 单通道跳板、网络设备、mosh/ET、插件终端 | 按 provider/传输能力分别证明 | 按该传输证明 | 不因支持 cwd 上报就自动支持目录枚举或安全切换 |
|
||||
|
||||
Ubuntu/Debian、Fedora/Rocky/Alma、Arch、Alpine 等应作为验收样本,不能把发行版与用户当前 shell 硬绑定。Windows 也不能只根据 PowerShell/CMD 的提示符外观推断可写状态。
|
||||
|
||||
## 5. 会话级 shell 集成的可行路径
|
||||
|
||||
### 5.1 参考实现与可复用部分
|
||||
|
||||
VS Code 将工作目录与命令边界由 shell 脚本报告,启动时通过参数/环境注入;其用户文档也明确普通 SSH、子 shell、特殊启动参数会使自动注入失效。[S2] 固定源码快照 N1 中:
|
||||
|
||||
- bash 使用 init-file 路径,并兼容用户 rc/login 启动;fish 使用 init-command。
|
||||
- zsh 使用临时 ZDOTDIR 中的启动文件代理,不只是加载 `.zshrc` 一步。
|
||||
- PowerShell 在原 prompt 前后报告位置和边界,只为 FileSystem provider 报告文件路径;受限语言模式会退出集成。
|
||||
- Windows Terminal 另给出 PowerShell/CMD 的 prompt 集成例子,使用 OSC 133 与 OSC 9;9;这与现有 OSC 7 接收逻辑不是同一协议。[S3、N1]
|
||||
|
||||
这些代码证明路径可行,不等于 Netcatty 已支持,也不应整套复制。尤其官方页面和最新主分支的 shell 支持范围可能不同,以自己测试过的版本为准。
|
||||
|
||||
### 5.2 推荐启动方式
|
||||
|
||||
本地在 `startLocalSession` 的可执行文件、参数和环境解析完成后,按已识别 shell 生成**会话级启动方案**。支持的普通交互参数才注入;用户 `-Command`、`-File`、`--norc`、自定义 `--rcfile`、受限模式或包装脚本不能悄悄改写。集成失败时照常打开终端,声明能力不足,保留当前支持的回退。
|
||||
|
||||
临时脚本通过 `tempDirBridge.getTempFilePath()` 放在 Netcatty 管理目录,检查权限、包内资源路径与清理生命周期;不能依赖源码目录在打包后存在。外部 shell 不能把 Electron 的 asar 虚拟路径当普通脚本文件使用,需显式部署为真实资源或复制到受管目录后加载。[S15] 首期不改用户 rc、注册表 AutoRun 或全局环境,也不默认重载整个配置文件。
|
||||
|
||||
shell hook 要保留原 prompt、主题插件、退出状态及 PATH;防止重复安装和递归。bash 区分标量/数组 `PROMPT_COMMAND` 及旧版本;zsh 保留 precmd/chpwd 链;fish 保留已有函数和事件。PowerShell 包装原 prompt 并恢复其结果,不能使用 PowerShell 6 才加入的反引号 e 作为 5.1 唯一 ESC 写法,应采用如 `[char]27` 的兼容构造。[S5–S8]
|
||||
|
||||
### 5.3 上报协议与可信度
|
||||
|
||||
推荐先保留 OSC 7 的可互操作 cwd 展示,再为 host-owned 集成增加可协商、带明确版本及编码的状态报告;是否复用 OSC 633,需在实现前确定。不要随意占用新的 OSC 编号,也不要为完整命令录制扩张本功能。
|
||||
|
||||
最低需要报告:当前 cwd/provider、路径类型、shell kind、prompt-ready/running,以及 shell 实例或集成代次。接收器必须处理 BEL/ST、分片、多字节 UTF-8、转义、长度上限与 URI authority。不能把终端原样输出拼成命令。
|
||||
|
||||
报告范围只覆盖目录与操作所需状态,不默认收集命令全文、环境变量或凭据。新增序列需经过已有输出管线,避免污染日志、回放、输出触发器与侧栏上下文;不能只在可见 xterm 中生效,忽略 worker/hibernate 路径。
|
||||
|
||||
**OSC 7 和普通边界序列是内容,不是认证。** 任意程序都可能打印它们,cwd 报告不能单独解除密码/MFA、alternate screen 或半行输入的阻止条件。VS Code 的 nonce 用于部分命令报告验证,不代表所有 Cwd 属性都已认证。[S2、N1] 候选方案可以使用会话 nonce 降低误识别,但 shell 内可执行代码仍可能读取它,不能宣传为隔离恶意进程的安全边界。
|
||||
|
||||
实现时在 worker/host 绑定 session、连接及 shell 代次,验证消息后再发 typed event 到应用状态。保留每会话 revision,使另一个终端的更新不能盖过本终端严格读取;旧连接、退出、hibernate/恢复、外部 `su`、嵌套 shell 均需失效规则。
|
||||
|
||||
## 6. Windows 原生 shell
|
||||
|
||||
### PowerShell
|
||||
|
||||
PowerShell 每个 runspace 的当前位置不同于 `[Environment]::CurrentDirectory`;另起 `powershell -Command Get-Location` 也读不到原交互 runspace 的状态。[S4] 应在目标 prompt 内获取 `$PWD`/`SessionState.Path.CurrentLocation`,只在 provider 为 FileSystem 时取 ProviderPath。进入 `HKLM:`、Env: 或自定义 provider 时显示“非文件系统位置”,不继续展示上一次目录为当前值。[S4、N1]
|
||||
|
||||
切换示例为 `Microsoft.PowerShell.Management\Set-Location -LiteralPath 'D:\Project [test]\O''Brien'`:单引号内部的单引号按 PowerShell 规则加倍;`-LiteralPath` 防止方括号等被当通配符。按当前 runspace 的 FileSystem home 做 `~` 展示;不要使用启动 Electron 的用户 home 代替。不能利用 Invoke-Expression 处理用户路径,也不以子进程的成功退出替代原 runspace 确认。[S4、S5]
|
||||
|
||||
PowerShell 5.1 与 7 分别验证 PSReadLine、有/无 profile、oh-my-posh/starship、UNC、PSDrive、ConstrainedLanguage/JEA。遵守执行策略;不能自动 `Set-ExecutionPolicy`、强制 Bypass 或绕过组策略。策略阻止集成时声明不可用,并给出可理解原因。[S9、N1]
|
||||
|
||||
### CMD
|
||||
|
||||
`cd /d "D:\Project"` 才会同时切换盘符和目录;单引号不是 CMD 的路径引用语法。[S10] `PROMPT` 的 `$P`、`$E` 可提供位置和序列构造,Windows Terminal 提供了会话 PROMPT 的组合示例。[S3、S11] 这只证明可上报,不自动解决命令前后边界、当前半行输入、编码或错误码保留。
|
||||
|
||||
`%NAME%` 的环境展开、`/V:ON` 的 `!NAME!`、交互输入与批处理中的 `%` 规则不同。不能照抄嵌套 `cmd /c` 包装器的转义:子 CMD 切目录不影响父 CMD;`setlocal` 包装也可能改变退出后的目录/状态。[S12、N1 的对照边界]
|
||||
|
||||
第一版可先明确拒绝未经验证的 `%`/`!` 等路径,而非声明支持任意目录。不能修改用户 delayed expansion/AutoRun 来让自己的命令能运行。UNC 也不能默认用 `pushd` 兜底:它会创建临时盘符并改变目录栈;必须另行设计可见语义、清理和失败恢复。[S10–S12]
|
||||
|
||||
## 7. WSL、路径模型和远程
|
||||
|
||||
路径至少区分显示值、执行值、文件系统地址、逻辑/物理路径及 authority。例如同一 `/home/me` 在 distro A、distro B、SSH 或容器里不是同一目标;`C:foo` 是盘符相对路径,不等于 `C:\foo`;UNC 根也不能用 POSIX 字符串截断计算父级。[S4、S10、S13]
|
||||
|
||||
WSL 优先使用所选 distro 的 helper 枚举并返回结构化目录,或经过验证的 Windows UNC 访问;不把 `$PWD` 直接交给 `listLocalDir`。Git Bash/Cygwin 则要验证挂载点、`/c`/`/cygdrive`/`/usr`、链接和编码,不能只替换斜杠。helper 不经 `ls` 文本拆行,支持中英文、空格与严格取消;必要时仍拒绝无法安全执行的控制字符。
|
||||
|
||||
PowerShell 的 drive/provider、Windows 大小写、Linux 的大小写敏感与链接语义应交给对应适配器,不对所有路径全局 lowercase/trim/decode。WSL 的 Linux FS 与 Windows FS 在大小写和访问性能上有区别。[S13]
|
||||
|
||||
本地集成不会随普通 ssh、tmux、容器或 sudo 自动迁移到目标 shell。远端可选择已有的显式 OSC 7 配置流程,或后续受控地安装当前 shell 的临时集成;必须说明配置是否持久化。不能因菜单打开而私自修改远端 rc。单通道、mosh/ET、Windows OpenSSH、受限 SFTP 需各自证明 cwd、list、change 三种能力,不能借另一个登录“猜目录”。[S2、项目现有实现]
|
||||
|
||||
## 8. 实验范围与参考资料
|
||||
|
||||
本轮完成源码检查与本机 bash/zsh/dash 命令实验。PowerShell、CMD、fish、WSL 未在本机安装,Windows 和多发行版运行结果**尚未验证**。现有 PR 的本地 PTY/SSH fixture 证明的是当前 Unix 路径,不证明 Windows 已可用。
|
||||
|
||||
官方资料均查阅于 2026-10-30:
|
||||
|
||||
- S1:[POSIX cd](https://pubs.opengroup.org/onlinepubs/9799919799/utilities/cd.html),当前 shell、CDPATH、逻辑/物理路径。
|
||||
- S2:[VS Code shell integration](https://code.visualstudio.com/docs/terminal/shell-integration),注入、支持范围、SSH 限制、OSC 633 与 nonce。
|
||||
- S3:[Windows Terminal shell integration](https://learn.microsoft.com/en-us/windows/terminal/tutorials/shell-integration),PowerShell/CMD 的 OSC 133 和位置报告。
|
||||
- S4:[PowerShell locations](https://learn.microsoft.com/en-us/powershell/module/microsoft.powershell.core/about/about_locations?view=powershell-7.5) 与 [Set-Location](https://learn.microsoft.com/en-us/powershell/module/microsoft.powershell.management/set-location?view=powershell-7.5),runspace、provider、LiteralPath。
|
||||
- S5:[PowerShell quoting rules](https://learn.microsoft.com/en-us/powershell/module/microsoft.powershell.core/about/about_quoting_rules?view=powershell-7.5) 与 [special characters](https://learn.microsoft.com/en-us/powershell/module/microsoft.powershell.core/about/about_special_characters?view=powershell-7.5),引用及 5.1/6+ 语法区别。
|
||||
- S6:[Bash prompt](https://www.gnu.org/software/bash/manual/html_node/Controlling-the-Prompt.html) 与 [Bash startup files](https://www.gnu.org/software/bash/manual/html_node/Bash-Startup-Files.html)。
|
||||
- S7:[zsh functions](https://zsh.sourceforge.io/Doc/Release/Functions.html),precmd/chpwd 及 hook 链。
|
||||
- S8:[fish language](https://fishshell.com/docs/current/language.html),PWD、函数作用域与事件。
|
||||
- S9:[PowerShell execution policies](https://learn.microsoft.com/en-us/powershell/module/microsoft.powershell.core/about/about_execution_policies?view=powershell-7.5)。
|
||||
- S10:[CMD cd](https://learn.microsoft.com/en-us/windows-server/administration/windows-commands/cd),盘符与 `/d`。
|
||||
- S11:[CMD prompt](https://learn.microsoft.com/en-us/windows-server/administration/windows-commands/prompt),位置、转义及目录栈符号。
|
||||
- S12:[CMD](https://learn.microsoft.com/en-us/windows-server/administration/windows-commands/cmd) 与 [pushd](https://learn.microsoft.com/en-us/windows-server/administration/windows-commands/pushd),展开、AutoRun、UNC/临时盘符。
|
||||
- S13:[WSL filesystems](https://learn.microsoft.com/en-us/windows/wsl/filesystems),路径空间、大小写与访问方式。
|
||||
- S14:[WezTerm shell integration](https://wezterm.org/shell-integration.html),OSC 7 Windows/POSIX 构造对照。
|
||||
- S15:[Electron ASAR archives](https://www.electronjs.org/docs/latest/tutorial/asar-archives),虚拟路径、外部程序与 unpack 限制。
|
||||
|
||||
N1:VS Code 官方仓库固定快照 `ce233327f26ef7791b7fff0143c24e81a8126bd2`,用于验证实现机制,不表示其支持承诺等同于 Netcatty:
|
||||
|
||||
- [terminalEnvironment.ts](https://github.com/microsoft/vscode/blob/ce233327f26ef7791b7fff0143c24e81a8126bd2/src/vs/platform/terminal/node/terminalEnvironment.ts):按 shell 注入参数、ZDOTDIR 与 unsupported-args 分支。
|
||||
- [shellIntegration.ps1](https://github.com/microsoft/vscode/blob/ce233327f26ef7791b7fff0143c24e81a8126bd2/src/vs/workbench/contrib/terminal/common/scripts/shellIntegration.ps1):provider、原 prompt、状态、语言模式与协议字段。
|
||||
- [shellIntegration-bash.sh](https://github.com/microsoft/vscode/blob/ce233327f26ef7791b7fff0143c24e81a8126bd2/src/vs/workbench/contrib/terminal/common/scripts/shellIntegration-bash.sh)、[shellIntegration-rc.zsh](https://github.com/microsoft/vscode/blob/ce233327f26ef7791b7fff0143c24e81a8126bd2/src/vs/workbench/contrib/terminal/common/scripts/shellIntegration-rc.zsh)、[shellIntegration.fish](https://github.com/microsoft/vscode/blob/ce233327f26ef7791b7fff0143c24e81a8126bd2/src/vs/workbench/contrib/terminal/common/scripts/shellIntegration.fish):各 shell 初始化及事件处理。
|
||||
@@ -0,0 +1,97 @@
|
||||
# Directory Picker:后续开发计划
|
||||
|
||||
日期:2026-10-30。目标是在现有目录芯片上补齐 Windows 原生终端与常用 shell 支持,同时保持 macOS/Linux/普通 SSH 的既有行为。技术依据、实验与官方来源见 [跨平台研究](directory-picker-cross-platform.md);当前功能规格见 [directory-picker.md](directory-picker.md)。
|
||||
|
||||
## 1. 当前交付与后续范围
|
||||
|
||||
当前 PR 实现目录菜单、绝对路径切换、真实 cwd 确认、手动 cd 联动及默认开启的 Context Chips 设置。Windows 本地、WSL/Git Bash/Cygwin 仍为不可用,不应先撤销门槛再寻找兼容方案。
|
||||
|
||||
后续按**路径与能力 → Windows PowerShell → CMD → WSL/兼容层 → 扩展远程**推进。macOS/Linux 的会话集成可与 PowerShell 阶段复用协议基础,但不同 shell 独立验收,不要求为增加一个平台一次性重写全部终端。
|
||||
|
||||
## 2. 统一能力与状态
|
||||
|
||||
不能只有一个 `supported`:至少分别描述 `readCwd`、`listDirectories`、`changeDirectory`、`confirmChange`。只有目标环境和这些能力一致,菜单才可执行切换。例如收到 OSC cwd 但没有正确的 WSL 枚举能力时,只能展示位置;没有可用的安全执行/确认时不能给出可操作假象。
|
||||
|
||||
候选能力快照应包含下列语义;这是规划,不是当前新增的公共 RPC 协议:
|
||||
|
||||
| 字段 | 用途 |
|
||||
| --- | --- |
|
||||
| session/connection/shell generation | 绑定实际目标,重连与嵌套 shell 变化废弃旧请求 |
|
||||
| shell kind/version/confidence | 区分 spawn 信息、登录 hint 与当前活动 shell;未知时拒绝危险猜测 |
|
||||
| filesystem authority/path flavor | native-win32、posix、wsl:distro、msys、cygwin、ssh 等环境身份 |
|
||||
| provider/cwd/home/source/revision | 表示 FileSystem、非文件系统、未知与过期;保留单会话排序 |
|
||||
| integration status/capabilities | active、fallback、unsupported、policy-blocked 等可解释状态 |
|
||||
|
||||
UI 显示值可以缩写和截断;执行目标保留对应命名空间的绝对路径。调用前重新校验 cwd revision/身份,结果按真实上报确认,不能只因命令已写入就宣布成功。关闭工具栏停止其额外工作,但不能拆掉其他功能仍在消费的共享集成。
|
||||
|
||||
## 3. 阶段 A:路径、能力及确认边界
|
||||
|
||||
**交付**:抽出按 path flavor 的解析、父目录、拼接、比较和显示逻辑,扩展目录控制器所需的 typed capability;保持现有 UI 与 POSIX 默认行为。
|
||||
|
||||
调整位置:`domain/directoryPicker.ts`、`terminalDirectoryController.ts`、`useTerminalDirectorySession.ts`、`infrastructure/services/terminalDirectory.ts` 及对应 bridge 类型。将变更目录的意图构造注入控制器或放入明确 shell 适配,不让控制器固定引用唯一 POSIX builder。
|
||||
|
||||
优先复用 `resolveInteractiveTerminalCdIntent` 的 POSIX 路径与控制字节规则。PowerShell/CMD 新策略不得直接改变该函数现有恢复目录/SFTP 调用者的契约。现有 AI helper 仅提取经过验证的纯引用函数,避免复用含子 shell/eval 的执行包装。
|
||||
|
||||
**验收门槛**:POSIX `/`、Windows `C:\`、UNC `\\server\share\`、相对盘符 `C:foo`、大小写和 symlink 不混淆;文件系统 authority 不同的同名路径永不互用。旧的目录、广播、草稿、会话失效、关闭 chips 和 SFTP 测试保持通过。
|
||||
|
||||
## 4. 阶段 B:shell 报告基础与 PowerShell 5.1/7
|
||||
|
||||
**交付**:本地 PowerShell FileSystem 目录展示、子目录/父目录导航、手动切换联动、真实结果确认。目标包括项目支持 Windows 版本上的系统 Windows PowerShell 5.1 和用户安装的 PowerShell 7;具体 Windows build 按当时 Electron/ConPTY 支持矩阵验证,不能用“包能打出来”代替实机支持。
|
||||
|
||||
实施点:
|
||||
|
||||
1. 在 `electron/bridges/terminalBridge.cjs` 的 spawn 参数解析后加入可测试的会话启动方案;脚本由 `tempDirBridge` 管理。保留 profile、PSReadLine、用户启动参数和原 prompt。未知参数/策略阻止时不注入,不改执行策略。
|
||||
2. 位置取自该 runspace 的 FileSystem ProviderPath;`HKLM:` 等报告为非文件系统。切换使用 LiteralPath 和 PowerShell 引用,在原 shell 生效。
|
||||
3. worker 接收并归一化 shell 状态,随后通过既有 session 事件/preload 传递给应用层。协议版本、字段转义、最大长度、旧代次与来源必须可验证;是否复用 OSC 633 在此阶段明确。
|
||||
4. cwd 报告与就绪证明分开;敏感输入、半行草稿和 TUI 仍会阻止注入。不能把可以打印任意 OSC 的进程当成 host authority。
|
||||
5. 使用本地 FS 读取原生 Windows 路径;新进入的盘符/UNC 要重新校验,FileSystem home 才可显示为 `~`。
|
||||
|
||||
**验收门槛**:Windows 实际 ConPTY 中验证切换盘符、空格、中文、单引号、方括号、`$`、反引号、只读目录、删除目录、policy-blocked、`-NoProfile`、自定义 prompt。registry/Env provider 不冒充本地目录;PowerShell 5.1 不使用仅 6+ 可用的 ESC 语法。启动失败不能损坏用户 profile,关闭后无持久配置变更。
|
||||
|
||||
同一基础在 macOS/Linux 新会话内按 shell 启用:bash 兼容 3.2 的 cwd hook,zsh 的 precmd/chpwd,fish 的事件。无法集成的 dash/ash 保留现有进程回退。完整命令生命周期在不同旧版本上未必可用,不能为 cwd 功能强制要求全部实现 preexec。
|
||||
|
||||
## 5. 阶段 C:CMD 原生路径导航
|
||||
|
||||
**交付**:会话级 cwd 上报、盘符切换、普通路径菜单、清楚的受限路径提示。
|
||||
|
||||
PROMPT 包装保留用户原值,`$P` 提供位置。`cd /d` 必须在原 CMD 中执行,不经另一个 `cmd /c`。独立实现并验证交互/batch 转义、命令扩展、`%`/`!`、编码与 ERRORLEVEL;不要通过临时改变 delayed expansion/AutoRun 绕过问题。
|
||||
|
||||
**范围选择**:可先支持经过验证的盘符绝对目录,对 `%`/`!` 和 UNC 明确不可切换。UNC 如果以后采用 pushd/popd,要先定义临时盘符、目录栈、退出及失败清理,不把这个动作伪装成无副作用的 cd。
|
||||
|
||||
**验收门槛**:命令扩展开/关、delayed expansion 开/关、默认/自定义 PROMPT、空格/中文/`&`/`^`/`%`/`!`、从 C 到 D、访问被拒、共享不可达。UI 不能把拒绝/失败当 `± 0` 类的空成功;不执行多余命令、不改变父 CMD 之外的进程来冒充结果。
|
||||
|
||||
## 6. 阶段 D:WSL、Git Bash 与 Cygwin
|
||||
|
||||
**交付**:明确目标 distro/兼容层的 cwd 与目录读取适配。Windows 上只是发现了 bash.exe/wsl.exe,不足以启用 Directory Picker。
|
||||
|
||||
- WSL 在所选 distro 内获取 shell 上报和结构化目录数据,验证 distro 身份及 `wslpath`/UNC 映射策略;同一路径不能跨 distro 复用缓存。WSL1/2 的支持范围按实测决定。
|
||||
- Git Bash/Cygwin 各自验证 mount/drive/symlink 映射,不能把 POSIX 路径字符串直接交给 Windows `fs`。
|
||||
- helper 协议保留原始名称、文件类型和目录链接;支持有限输出、取消、权限失败。不得使用 `ls` 的展示文本做机器目录清单。
|
||||
|
||||
**验收门槛**:Linux home、Windows 挂载盘、两个 distro 相同路径、大小写敏感目录、链接、编码、启动参数和 unavailable provider。不支持的映射有具体提示;测试不得通过修改用户 distro 配置来获得成功。
|
||||
|
||||
## 7. 阶段 E:远程与嵌套 shell
|
||||
|
||||
普通 Unix SSH 保留当前严格探测和 exact-session SFTP 约束。远程集成是目标 shell 的独立能力,不随本机启动脚本自动生效。已有显式 OSC 7 配置写 rc,与后续无持久化注入必须在 UI 和权限上区分。
|
||||
|
||||
逐项处理 SSH 到 Windows、su/sudo、嵌套 shell、tmux/screen、容器、mosh/ET、SFTP chroot 和单通道跳板。一个 cwd 报告不能证明登录用户的 SFTP 有相同目录视图;无法证明映射或当前活动 shell 身份时,保持不可用,不新建另一个登录。
|
||||
|
||||
**验收门槛**:连接及 shell 代次变更、主机不同的同名 cwd、权限跨用户、拒绝额外通道、工作目录已删除、重连/hibernate/附加窗口。自动安装远端脚本不纳入当前计划的默认行为;必须经独立范围确认。
|
||||
|
||||
## 8. 必须保留的回归与完成标准
|
||||
|
||||
| 类别 | 验收内容 |
|
||||
| --- | --- |
|
||||
| 命令与路径 | 每个 shell 的实际输入结果;绝对路径不受 CDPATH/异步 cwd 改变影响;特殊字符不执行额外代码 |
|
||||
| shell 生命周期 | prompt、原状态码、PATH、历史与主题不被破坏;login、无 profile、特殊启动参数有明确行为 |
|
||||
| 协议 | 分片/合并、BEL/ST、编码、Windows URI、大小上限、伪造/旧报告、重复集成和 nonce 限制 |
|
||||
| 正确性 | 手动 cd、函数 cd、延迟 cd、失败 cd、逻辑/物理路径、provider 切换、目录列表与真实 cwd 一致 |
|
||||
| 安全与目标 | 密码/MFA/sudo、TUI、半行输入、广播开启、切焦点/重连:没有错误目标写入 |
|
||||
| 平台 | Windows 原生 ConPTY 的 5.1/7/CMD;macOS 系统 bash 3.2 与 zsh;Linux bash/dash/ash/fish/zsh |
|
||||
| 发行版 | Ubuntu/Debian、Fedora/Rocky 或 Alma、Arch、Alpine 作为后端 shell 样本;不把同一 shell 的语法复制测试替代真实环境 |
|
||||
| 性能 | 有效集成优先事件更新,回退探测合并/单飞/有超时;空闲不额外轮询,关闭 chips 不产生其额外读写 |
|
||||
| 打包 | 外部 shell 使用真实脚本资源或受管副本,不依赖 asar 虚拟路径;native helper 如有新增,验证各架构与签名;资源变更通过 `npm run pack:dir` |
|
||||
|
||||
现有 domain、controller、registry、hook、真实 PTY、SSH fixture 与 Windows CI 是起点。后续 Windows 测试必须使用真实 Windows shell/ConPTY 验证 runspace 状态,不能仅靠 Linux mock 或 Windows 编译成功。是否开启某个平台,由这些验收结果决定,不以删除平台 gate 作为完成标准。
|
||||
|
||||
当前 PR 只追加研究与规划;阶段 A–E 的产品实现、脚本安装及协议扩展由后续独立改动完成。
|
||||
@@ -6,6 +6,10 @@
|
||||
|
||||
本地模拟见 [directory-picker-demo.html](directory-picker-demo.html),不访问文件系统或真实终端。
|
||||
|
||||
Context Chips 在“设置 → 终端”中默认开启,可关闭整行。该偏好随现有终端设置保存,旧配置默认开启;关闭不会发送或清空草稿,也不会继续订阅芯片的额外 cwd 刷新。
|
||||
|
||||
Windows 原生 shell、跨平台路径与 shell 集成的后续范围见 [跨平台研究](directory-picker-cross-platform.md) 和 [开发计划](directory-picker-follow-up.md),不改变本 PR 的产品支持范围。
|
||||
|
||||
## 1. 目标与首期边界
|
||||
|
||||
用户在撰写命令前,能看到**当前聚焦终端的真实工作目录**,并通过一个小菜单搜索当前层子目录、返回父目录、选择目录,使接下来执行的命令落在目标位置。
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import {
|
||||
DEFAULT_AI_SESSION_MESSAGE_CAP,
|
||||
MAX_AI_SESSION_MESSAGE_CAP,
|
||||
MIN_AI_SESSION_MESSAGE_CAP,
|
||||
normalizeAiSessionMessageCap,
|
||||
} from './aiSessionMessageCap';
|
||||
|
||||
test('normalizeAiSessionMessageCap keeps in-range integers untouched', () => {
|
||||
assert.equal(normalizeAiSessionMessageCap(200), 200);
|
||||
assert.equal(normalizeAiSessionMessageCap(MIN_AI_SESSION_MESSAGE_CAP), MIN_AI_SESSION_MESSAGE_CAP);
|
||||
assert.equal(normalizeAiSessionMessageCap(MAX_AI_SESSION_MESSAGE_CAP), MAX_AI_SESSION_MESSAGE_CAP);
|
||||
});
|
||||
|
||||
test('normalizeAiSessionMessageCap clamps below the minimum and above the maximum', () => {
|
||||
assert.equal(normalizeAiSessionMessageCap(10), MIN_AI_SESSION_MESSAGE_CAP);
|
||||
assert.equal(normalizeAiSessionMessageCap(49.4), MIN_AI_SESSION_MESSAGE_CAP);
|
||||
assert.equal(normalizeAiSessionMessageCap(5000), MAX_AI_SESSION_MESSAGE_CAP);
|
||||
});
|
||||
|
||||
test('normalizeAiSessionMessageCap rounds fractional values and rejects non-finite input', () => {
|
||||
assert.equal(normalizeAiSessionMessageCap(120.6), 121);
|
||||
assert.equal(normalizeAiSessionMessageCap(Number.NaN), DEFAULT_AI_SESSION_MESSAGE_CAP);
|
||||
assert.equal(normalizeAiSessionMessageCap(Number.POSITIVE_INFINITY), DEFAULT_AI_SESSION_MESSAGE_CAP);
|
||||
assert.equal(normalizeAiSessionMessageCap(undefined), DEFAULT_AI_SESSION_MESSAGE_CAP);
|
||||
assert.equal(normalizeAiSessionMessageCap('not-a-number'), DEFAULT_AI_SESSION_MESSAGE_CAP);
|
||||
});
|
||||
@@ -0,0 +1,21 @@
|
||||
/**
|
||||
* User-configurable per-session AI chat message cap.
|
||||
*
|
||||
* Storage moved to IndexedDB (issue #4123), so the tight cap that defended the
|
||||
* shared 2 MB localStorage budget no longer drives retention. The cap now only
|
||||
* bounds per-record size, cloud-sync payload and UI rendering, so users may
|
||||
* raise it; it still stays bounded to keep those costs predictable.
|
||||
*/
|
||||
|
||||
export const DEFAULT_AI_SESSION_MESSAGE_CAP = 200;
|
||||
export const MIN_AI_SESSION_MESSAGE_CAP = 50;
|
||||
export const MAX_AI_SESSION_MESSAGE_CAP = 2000;
|
||||
|
||||
export function normalizeAiSessionMessageCap(value: unknown): number {
|
||||
const numeric = typeof value === 'number' ? value : Number(value);
|
||||
if (!Number.isFinite(numeric)) return DEFAULT_AI_SESSION_MESSAGE_CAP;
|
||||
return Math.min(
|
||||
MAX_AI_SESSION_MESSAGE_CAP,
|
||||
Math.max(MIN_AI_SESSION_MESSAGE_CAP, Math.round(numeric)),
|
||||
);
|
||||
}
|
||||
@@ -115,6 +115,42 @@ export interface ConvergentMigrationPlan {
|
||||
preview: ConvergentMigrationPreview;
|
||||
state: ConvergentSyncStateV2 | null;
|
||||
payload: SyncPayload | null;
|
||||
/** Retains sidecars and chats while field conflicts prevent payload creation. */
|
||||
materializedPayload: SyncPayload | null;
|
||||
}
|
||||
|
||||
const UNRESOLVED_FIELD_CONFLICTS = 'The convergent state contains unresolved field conflicts';
|
||||
|
||||
export function refreshConvergentSyncMigrationPlan(
|
||||
plan: ConvergentMigrationPlan,
|
||||
state: ConvergentSyncStateV2,
|
||||
): ConvergentMigrationPlan {
|
||||
if (!plan.state || !plan.materializedPayload) {
|
||||
throw new Error('No resolvable convergent migration state is available');
|
||||
}
|
||||
const materializedPayload = materializeSyncPayloadFromConvergentState(state, plan.materializedPayload);
|
||||
const conflicts = materializeConvergentSyncState(state).conflicts;
|
||||
const blockedReasons = plan.preview.blockedReasons.filter((reason) => reason !== UNRESOLVED_FIELD_CONFLICTS);
|
||||
const hasHardBlockers = blockedReasons.length > 0;
|
||||
if (conflicts.length > 0) blockedReasons.push(UNRESOLVED_FIELD_CONFLICTS);
|
||||
const canInitialize = blockedReasons.length === 0;
|
||||
return {
|
||||
preview: {
|
||||
...plan.preview,
|
||||
canInitialize,
|
||||
conflictCount: conflicts.length,
|
||||
conflicts,
|
||||
blockedReasons,
|
||||
entityCounts: Object.fromEntries(
|
||||
[...CONVERGENT_ENTITY_COLLECTIONS, ...CONVERGENT_STRING_COLLECTIONS]
|
||||
.map((key) => [key, entityCount(materializedPayload, key)]),
|
||||
) as ConvergentMigrationPreview['entityCounts'],
|
||||
settingsLeafCount: countSettingsLeaves(materializedPayload.settings),
|
||||
},
|
||||
state: hasHardBlockers ? null : state,
|
||||
materializedPayload: hasHardBlockers ? null : materializedPayload,
|
||||
payload: canInitialize ? withConvergentSyncEnvelope(state, materializedPayload) : null,
|
||||
};
|
||||
}
|
||||
|
||||
function runtimeSchema(meta: SyncFileMeta): 1 | 2 | 'future' | 'invalid' {
|
||||
@@ -407,7 +443,8 @@ export function planConvergentSyncMigration(options: {
|
||||
}
|
||||
|
||||
const conflicts = state ? materializeConvergentSyncState(state).conflicts : [];
|
||||
if (conflicts.length > 0) blockedReasons.push('The convergent state contains unresolved field conflicts');
|
||||
const hasHardBlockers = blockedReasons.length > 0;
|
||||
if (conflicts.length > 0) blockedReasons.push(UNRESOLVED_FIELD_CONFLICTS);
|
||||
const canInitialize = blockedReasons.length === 0 && state !== null && materialized !== null;
|
||||
const payload = canInitialize && state
|
||||
? withConvergentSyncEnvelope(state, {
|
||||
@@ -437,7 +474,8 @@ export function planConvergentSyncMigration(options: {
|
||||
oldClientCompatibility: 'materialized-v1-snapshot',
|
||||
blockedReasons,
|
||||
},
|
||||
state: canInitialize ? state : null,
|
||||
state: hasHardBlockers ? null : state,
|
||||
materializedPayload: hasHardBlockers ? null : materialized,
|
||||
payload,
|
||||
};
|
||||
}
|
||||
@@ -11,10 +11,13 @@ import {
|
||||
createConvergentSyncEnvelope,
|
||||
createConvergentSyncStateFromPayload,
|
||||
diffLegacySyncPayload,
|
||||
dotKey,
|
||||
hydrateConvergentSyncEnvelope,
|
||||
materializeSyncPayloadFromConvergentState,
|
||||
mergeConvergentSyncStates,
|
||||
planConvergentSyncMigration,
|
||||
refreshConvergentSyncMigrationPlan,
|
||||
resolveConvergentFieldConflict,
|
||||
serializeConvergentSyncState,
|
||||
validateConvergentSyncPayload,
|
||||
withConvergentSyncEnvelope,
|
||||
@@ -660,6 +663,86 @@ test('migration blocks unresolved v1 conflicts and future provider schemas', ()
|
||||
assert.equal(future.preview.providers[0].schemaVersion, 'future');
|
||||
});
|
||||
|
||||
test('migration retains conflict-only state and refreshes after each local resolution', () => {
|
||||
const baseline = payload();
|
||||
const base = createConvergentSyncStateFromPayload(baseline, 'seed', NOW);
|
||||
const leftPayload = payload('Left');
|
||||
leftPayload.hosts[0].hostname = 'left.example.com';
|
||||
const rightPayload = payload('Right');
|
||||
rightPayload.hosts[0].hostname = 'right.example.com';
|
||||
const state = mergeConvergentSyncStates(
|
||||
applyLegacySyncPayload(base, baseline, leftPayload, 'left', NOW + 1),
|
||||
applyLegacySyncPayload(base, baseline, rightPayload, 'right', NOW + 2),
|
||||
);
|
||||
const remote = withConvergentSyncEnvelope(state, {
|
||||
syncedAt: NOW + 3,
|
||||
pluginSidecars: { version: 1, entries: [] },
|
||||
aiSessions: [{
|
||||
id: 'chat', title: 'Chat', agentId: 'catty', scope: { type: 'global' },
|
||||
createdAt: NOW, updatedAt: NOW, messages: [],
|
||||
}],
|
||||
});
|
||||
const options = {
|
||||
localPayload: emptyPayload(),
|
||||
localTrustedBaseline: null,
|
||||
providers: [{
|
||||
provider: 'github' as const,
|
||||
status: 'ready' as const,
|
||||
meta: meta({ syncSchemaVersion: 2 }),
|
||||
payload: remote,
|
||||
trustedBaseline: null,
|
||||
}],
|
||||
deviceId: 'local-device',
|
||||
now: NOW + 4,
|
||||
};
|
||||
let plan = planConvergentSyncMigration(options);
|
||||
const originalPlan = plan;
|
||||
assert.equal(plan.preview.canInitialize, false);
|
||||
assert.equal(plan.preview.conflictCount, 2);
|
||||
assert.deepEqual(plan.preview.blockedReasons, ['The convergent state contains unresolved field conflicts']);
|
||||
assert.ok(plan.state);
|
||||
assert.equal(plan.payload, null);
|
||||
|
||||
for (const remaining of [1, 0]) {
|
||||
const conflict = plan.preview.conflicts[0];
|
||||
const candidate = conflict.candidates.find((entry) => entry.dot.deviceId === 'left')!;
|
||||
const resolved = resolveConvergentFieldConflict(
|
||||
plan.state!, conflict, dotKey(candidate.dot), 'local-device', NOW + 5,
|
||||
);
|
||||
plan = refreshConvergentSyncMigrationPlan(plan, resolved);
|
||||
assert.equal(plan.preview.conflictCount, remaining);
|
||||
assert.equal(plan.preview.conflicts.length, remaining);
|
||||
assert.equal(plan.preview.canInitialize, remaining === 0);
|
||||
assert.equal(plan.preview.blockedReasons.length, remaining === 0 ? 0 : 1);
|
||||
assert.equal(plan.payload === null, remaining > 0);
|
||||
}
|
||||
assert.equal(originalPlan.preview.conflictCount, 2);
|
||||
assert.equal(plan.payload?.hosts[0].label, 'Left');
|
||||
assert.equal(plan.payload?.hosts[0].hostname, 'left.example.com');
|
||||
assert.equal(plan.payload?.convergentSync?.schemaVersion, 2);
|
||||
assert.deepEqual(plan.payload?.pluginSidecars, remote.pluginSidecars);
|
||||
assert.deepEqual(plan.payload?.aiSessions, remote.aiSessions);
|
||||
assert.deepEqual(plan.preview.providers, originalPlan.preview.providers);
|
||||
assert.deepEqual(plan.preview.entityCounts, originalPlan.preview.entityCounts);
|
||||
|
||||
// A field picker must never make a separate migration blocker resolvable.
|
||||
const blocked = planConvergentSyncMigration({ ...options, localPayload: payload('Unbased local edit') });
|
||||
assert.ok(blocked.preview.conflictCount > 0);
|
||||
assert.match(blocked.preview.blockedReasons.join('; '), /no trusted legacy baseline/);
|
||||
assert.equal(blocked.state, null);
|
||||
assert.equal(blocked.materializedPayload, null);
|
||||
assert.equal(blocked.payload, null);
|
||||
assert.throws(() => refreshConvergentSyncMigrationPlan(blocked, state), /No resolvable/);
|
||||
|
||||
const unavailable = planConvergentSyncMigration({
|
||||
...options,
|
||||
providers: [...options.providers, { provider: 'google', status: 'unavailable', message: 'offline' }],
|
||||
});
|
||||
assert.equal(unavailable.preview.canInitialize, false);
|
||||
assert.equal(unavailable.state, null);
|
||||
assert.equal(unavailable.payload, null);
|
||||
});
|
||||
|
||||
test('joining existing v2 data blocks changed legacy writers without a trusted baseline', () => {
|
||||
const remoteState = createConvergentSyncStateFromPayload(payload('Remote'), 'remote', NOW);
|
||||
const remotePayload = withConvergentSyncEnvelope(remoteState, { syncedAt: NOW });
|
||||
@@ -679,6 +762,7 @@ test('joining existing v2 data blocks changed legacy writers without a trusted b
|
||||
|
||||
assert.equal(plan.preview.canInitialize, false);
|
||||
assert.match(plan.preview.blockedReasons.join(' '), /no trusted legacy baseline/);
|
||||
assert.equal(plan.state, null);
|
||||
});
|
||||
|
||||
test('joining existing v2 data blocks a shrunk legacy local source', () => {
|
||||
@@ -710,6 +794,7 @@ test('joining existing v2 data blocks a shrunk legacy local source', () => {
|
||||
|
||||
assert.equal(plan.preview.canInitialize, false);
|
||||
assert.match(plan.preview.blockedReasons.join(' '), /legacy-local:local-device.*remove too many entities/);
|
||||
assert.equal(plan.state, null);
|
||||
});
|
||||
|
||||
test('joining existing v2 data blocks a shrunk legacy provider source', () => {
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { directoryDisplayName, directoryEntries, directoryLabel, directoryParent, filterDirectories, isDirectoryPath } from './directoryPicker';
|
||||
|
||||
test('directory paths preserve names, enforce home boundaries, and stop at root', () => {
|
||||
assert.equal(directoryLabel('/home/me/a ', '/home/me'), '~/a ');
|
||||
assert.equal(directoryLabel('/home/metal/a', '/home/me'), '/home/metal/a');
|
||||
assert.equal(directoryParent('/'), null);
|
||||
assert.equal(directoryParent('/one/'), '/');
|
||||
assert.equal(directoryParent('/one/two '), '/one');
|
||||
assert.ok(isDirectoryPath("/a '$()`中文"));
|
||||
for (const value of ['~/a', 'C:\\a', '/a\n', '/a\x1b', '/a\0']) assert.equal(isDirectoryPath(value), false);
|
||||
assert.equal(directoryDisplayName('a\n\x1b'), 'a\\x0a\\x1b');
|
||||
});
|
||||
|
||||
test('directory results include only directories and directory symlinks; filtering is local and natural', () => {
|
||||
const entries = directoryEntries('/base', [
|
||||
{ name: 'app10', type: 'directory', size: '', lastModified: '' },
|
||||
{ name: 'app2', type: 'symlink', linkTarget: 'directory', size: '', lastModified: '' },
|
||||
{ name: '.config', type: 'directory', size: '', lastModified: '' },
|
||||
{ name: 'broken', type: 'symlink', size: '', lastModified: '' },
|
||||
{ name: 'file', type: 'file', size: '', lastModified: '' },
|
||||
{ name: '..', type: 'directory', size: '', lastModified: '' },
|
||||
]);
|
||||
assert.deepEqual(filterDirectories(entries, 'APP', false).map(entry => entry.path), ['/base/app2', '/base/app10']);
|
||||
assert.equal(filterDirectories(entries, '', false).length, 2);
|
||||
assert.equal(filterDirectories(entries, '', true).length, 3);
|
||||
assert.deepEqual(filterDirectories(entries, 'missing', true), []);
|
||||
});
|
||||
@@ -0,0 +1,44 @@
|
||||
import type { RemoteFile } from './models';
|
||||
import { resolveInteractiveTerminalCdIntent } from './sessionRestore';
|
||||
|
||||
export type DirectoryEntry = { name: string; path: string; hidden: boolean };
|
||||
export type DirectoryPickerErrorCode = 'unavailable' | 'unknown' | 'busy' | 'readFailed' | 'changeFailed' | 'unsupportedPath';
|
||||
|
||||
export class DirectoryPickerError extends Error {
|
||||
constructor(public readonly code: DirectoryPickerErrorCode) { super(code); }
|
||||
}
|
||||
|
||||
export function isDirectoryPath(path: string | null | undefined): path is string {
|
||||
return typeof path === 'string' && path.startsWith('/') && !!resolveInteractiveTerminalCdIntent(path);
|
||||
}
|
||||
|
||||
export function directoryParent(path: string): string | null {
|
||||
const trimmed = path.replace(/\/+$/, '') || '/';
|
||||
return trimmed === '/' ? null : trimmed.slice(0, trimmed.lastIndexOf('/')) || '/';
|
||||
}
|
||||
|
||||
export function directoryLabel(path: string, home?: string | null): string {
|
||||
const prefix = home?.replace(/\/+$/, '');
|
||||
if (prefix && (path === prefix || path.startsWith(prefix + '/'))) return '~' + path.slice(prefix.length);
|
||||
return path;
|
||||
}
|
||||
|
||||
export function directoryEntries(path: string, files: readonly RemoteFile[]): DirectoryEntry[] {
|
||||
return files.filter(file => file.type === 'directory' || (file.type === 'symlink' && file.linkTarget === 'directory'))
|
||||
.filter(file => file.name !== '.' && file.name !== '..' && !file.name.includes('/') && file.name.length > 0)
|
||||
.map(file => ({ name: file.name, path: (path === '/' ? '' : path.replace(/\/+$/, '')) + '/' + file.name,
|
||||
hidden: file.hidden === true || file.name.startsWith('.') }));
|
||||
}
|
||||
|
||||
const names = new Intl.Collator(undefined, { numeric: true, sensitivity: 'base' });
|
||||
export function filterDirectories(entries: readonly DirectoryEntry[], query: string, showHidden: boolean): DirectoryEntry[] {
|
||||
const needle = query.toLocaleLowerCase();
|
||||
return entries.filter(entry => (showHidden || !entry.hidden) && entry.name.toLocaleLowerCase().includes(needle))
|
||||
.sort((a, b) => names.compare(a.name, b.name) || a.name.localeCompare(b.name));
|
||||
}
|
||||
|
||||
/** Control characters remain visible as data rather than affecting menu layout. */
|
||||
export function directoryDisplayName(name: string): string {
|
||||
return Array.from(name, char => char.charCodeAt(0) <= 31 || char.charCodeAt(0) === 127
|
||||
? '\\x' + char.charCodeAt(0).toString(16).padStart(2, '0') : char).join('');
|
||||
}
|
||||
@@ -71,6 +71,8 @@ export interface KeywordHighlightRule {
|
||||
export type TerminalTabDoubleClickBehavior = 'duplicate' | 'copy' | 'disabled';
|
||||
|
||||
export interface TerminalSettings {
|
||||
/** Context chips below quick commands in the compose bar. */
|
||||
contextChipsEnabled?: boolean;
|
||||
// Rendering
|
||||
scrollback: number; // Number of lines kept in buffer
|
||||
drawBoldInBrightColors: boolean; // Draw bold text in bright colors
|
||||
@@ -438,6 +440,7 @@ export const normalizeTerminalSettings = (
|
||||
const mergedSettings = {
|
||||
...DEFAULT_TERMINAL_SETTINGS,
|
||||
...(settings ?? {}),
|
||||
contextChipsEnabled: settings?.contextChipsEnabled !== false,
|
||||
middleClickBehavior,
|
||||
middleClickPaste: middleClickBehavior === 'paste',
|
||||
wordSeparators,
|
||||
@@ -531,6 +534,7 @@ export function isLegacyDefaultShiftEnterNewlineText(value: unknown): boolean {
|
||||
}
|
||||
|
||||
const DEFAULT_TERMINAL_SETTINGS: TerminalSettings = {
|
||||
contextChipsEnabled: true,
|
||||
scrollback: DEFAULT_TERMINAL_SCROLLBACK,
|
||||
drawBoldInBrightColors: true,
|
||||
terminalEmulationType: 'xterm-256color',
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
// Host errors cross Electron IPC as messages. Keep their presentation local to
|
||||
// this flow; never use translated strings to make authorization decisions.
|
||||
export function describePluginGitHubInstallError(message: string) {
|
||||
const rules: [RegExp, string][] = [
|
||||
[/^Use https:\/\/github\.com\/owner\/repo,/, 'invalidRepository'],
|
||||
[/^GitHub plugin installation unavailable$/, 'unavailable'],
|
||||
[/^Download and review a GitHub plugin first$/, 'reviewFirst'],
|
||||
[/^Cannot reach GitHub\./, 'network'],
|
||||
[/^GitHub download timed out$/, 'timeout'],
|
||||
[/^GitHub API rate limit reached\./, 'rateLimit'],
|
||||
[/^GitHub repository or asset not found\./, 'notFound'],
|
||||
[/^GitHub access denied\./, 'accessDenied'],
|
||||
[/^GitHub repository moved\./, 'moved'],
|
||||
[/^(GitHub download redirect|Too many GitHub download redirects)/, 'redirect'],
|
||||
[/^(This repository has no published stable release|Publish a stable GitHub release)/, 'noRelease'],
|
||||
[/^Release has no \.ncpkg asset\./, 'noAsset'],
|
||||
[/^Release contains multiple \.ncpkg assets\./, 'multipleAssets'],
|
||||
[/^(Invalid GitHub plugin artifact|GitHub release asset has no valid download URL|GitHub returned invalid release metadata)/, 'metadata'],
|
||||
[/^(GitHub did not return the selected public repository|GitHub asset does not belong|Invalid GitHub release identity)/, 'identity'],
|
||||
[/^The release tag has no accessible commit\./, 'commit'],
|
||||
[/^(GitHub download exceeds size limit|GitHub package size is invalid)/, 'size'],
|
||||
[/^GitHub package (size|SHA-256) mismatch$/, 'integrity'],
|
||||
[/^(Expected a \.ncpkg ZIP package|GitHub returned HTML\/XML|GitHub download has no body)/, 'package'],
|
||||
[/^A GitHub plugin operation is already active/, 'busy'],
|
||||
[/^GitHub package preview expired\./, 'expired'],
|
||||
[/^This package requires advanced developer mode/, 'advanced'],
|
||||
[/^Plugin is incompatible:/, 'incompatible'],
|
||||
[/^Plugin source differs\./, 'source'],
|
||||
];
|
||||
// invoke() may prepend its own channel/error wrapper to the original message.
|
||||
const original = message.replace(/^Error invoking remote method '[^']+': (?:Error: )?/, '');
|
||||
const status = /^GitHub download failed \(HTTP (\d{3})\)$/.exec(original)?.[1];
|
||||
const kind = status ? 'http' : rules.find(([pattern]) => pattern.test(original))?.[1] ?? 'failed';
|
||||
return {
|
||||
key: `settings.plugins.github.error.${kind}`,
|
||||
values: { status },
|
||||
// Keep validator/compatibility diagnostics available without making the
|
||||
// primary error an untranslated host exception.
|
||||
details: kind === 'failed' || kind === 'incompatible' ? message : null,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
"use strict";
|
||||
|
||||
function parsePluginGitHubRepository(input) {
|
||||
const message = "Use https://github.com/owner/repo, github.com/owner/repo or owner/repo (repository only; no credentials, query, fragment or extra paths)";
|
||||
if (typeof input !== "string" || input.length > 512) throw new Error(message);
|
||||
const value = input.trim();
|
||||
// Match the original text, before URL normalization can hide traversal or ports.
|
||||
const match = /^(?:(?:https?:\/\/)?github\.com\/)?([a-z0-9](?:[a-z0-9-]{0,37}[a-z0-9])?)\/([a-z0-9_.-]{1,100})\/?$/iu.exec(value);
|
||||
if (!match || match[1].includes("--")) throw new Error(message);
|
||||
const owner = match[1].toLowerCase();
|
||||
const repo = match[2].replace(/\.git$/iu, "").toLowerCase();
|
||||
if (!repo || repo === "." || repo === ".." || repo.endsWith(".git")) throw new Error(message);
|
||||
return Object.freeze({ owner, repo, repository: `${owner}/${repo}`, origin: `https://github.com/${owner}/${repo}` });
|
||||
}
|
||||
|
||||
module.exports = { parsePluginGitHubRepository };
|
||||
@@ -0,0 +1,23 @@
|
||||
"use strict";
|
||||
const assert = require('node:assert/strict');
|
||||
const test = require('node:test');
|
||||
const { parsePluginGitHubRepository: parse } = require('./pluginGitHubRepository.cjs');
|
||||
const { githubSource, assertSource } = require('../electron/plugins/sourceIdentity.cjs');
|
||||
|
||||
test('all Qi input forms, http, case and optional suffixes resolve to one repository and source identity', () => {
|
||||
const expected = { owner: 'owner', repo: 'repo', repository: 'owner/repo', origin: 'https://github.com/owner/repo' };
|
||||
for (const input of ['https://github.com/owner/repo', 'github.com/owner/repo', 'owner/repo', 'http://github.com/owner/repo.git/', 'HTTPS://GITHUB.COM/Owner/Repo/', ' Owner/Repo.git ']) {
|
||||
assert.deepEqual(parse(input), expected, input);
|
||||
assert.deepEqual(githubSource(input), githubSource(expected.origin), input);
|
||||
assert.deepEqual(assertSource(githubSource(input)), githubSource(expected.origin));
|
||||
}
|
||||
});
|
||||
|
||||
test('repository grammar rejects arbitrary hosts, scripts/assets, extra paths, normalization tricks and credentials', () => {
|
||||
for (const input of ['', null, {}, 'github.com', 'https://evil.com/owner/repo', 'git@github.com:owner/repo', 'ftp://github.com/owner/repo', '//github.com/owner/repo', 'https://github.com:443/owner/repo', 'https://user:pass@github.com/owner/repo', 'owner/repo?x', 'owner/repo#x', 'owner/repo/', 'owner/repo/tree/main', 'https://github.com/owner/repo/releases/download/v1/plugin.ncpkg', 'owner/repo/../../other', 'owner/%2e%2e', 'owner/..', 'owner/.git', 'owner/repo.git.git', 'ow--ner/repo', '-owner/repo', 'owner-/repo', 'owner/repo\\path', 'owner/repo\npath', 'javascript:alert(1)', 'https://github.com/owner/repo//']) {
|
||||
// A single trailing slash is intentionally accepted for shorthand as well.
|
||||
if (input === 'owner/repo/') { assert.equal(parse(input).repository, 'owner/repo'); continue; }
|
||||
assert.throws(() => parse(input), /Use https/, String(input));
|
||||
}
|
||||
assert.throws(() => assertSource({ ...githubSource('owner/repo'), id: 'official' }), /Invalid host/);
|
||||
});
|
||||
@@ -429,6 +429,13 @@ export const SETTINGS_SEARCH_CATALOG: readonly SettingsSearchEntry[] = [
|
||||
descriptionKey: "settings.terminal.behavior.shiftEnterForceText.desc",
|
||||
sectionKey: "settings.terminal.section.behavior",
|
||||
},
|
||||
{
|
||||
id: "terminal-context-chips",
|
||||
tab: "terminal",
|
||||
labelKey: "settings.terminal.behavior.contextChips",
|
||||
descriptionKey: "settings.terminal.behavior.contextChips.desc",
|
||||
sectionKey: "settings.terminal.section.behavior",
|
||||
},
|
||||
{
|
||||
id: "terminal-clear-wipes-scrollback",
|
||||
tab: "terminal",
|
||||
|
||||
@@ -69,6 +69,7 @@ module.exports = {
|
||||
// fail-closed prompt classifier at runtime.
|
||||
'domain/terminalPromptSecurity.shared.cjs',
|
||||
'domain/pluginRuntimePolicy.cjs',
|
||||
'domain/pluginGitHubRepository.cjs',
|
||||
'domain/pluginPermissionDescriptions.cjs',
|
||||
'domain/pluginThemePack.cjs',
|
||||
'domain/pluginRemoteTreePolicy.cjs',
|
||||
|
||||
@@ -393,3 +393,46 @@ test('real pending PowerShell input is still not sent', async (t) => {
|
||||
assert.match(result.error, /no command was sent/);
|
||||
assert.deepEqual(pty.writes, []);
|
||||
});
|
||||
|
||||
test('OSC 133 A marker readies exec for a colored P10k prompt the whitelist rejects', async () => {
|
||||
const pty = new TestPty();
|
||||
const session = { _promptTrackTail: '', lastIdlePrompt: '' };
|
||||
// Colored P10k-style prompt after a prompt-start marker; the SGR codes and
|
||||
// ❯ shape are irrelevant — the marker proves the shell accepts input.
|
||||
trackSessionIdlePrompt(
|
||||
session,
|
||||
'\x1b]133;A\x07\x1b[38;5;39m~/src\x1b[0m \x1b[38;5;213m❯\x1b[0m ',
|
||||
);
|
||||
assert.equal(readyPrompt(session), '~/src ❯ ');
|
||||
const result = await startPtyJob(pty, 'echo OK', {
|
||||
shellKind: 'posix',
|
||||
getReadyPrompt: () => readyPrompt(session),
|
||||
promptWaitTimeoutMs: 200,
|
||||
timeoutMs: 1000,
|
||||
}).resultPromise;
|
||||
assert.equal(result.ok, true);
|
||||
assert.equal(pty.writes.length, 1);
|
||||
});
|
||||
|
||||
test('OSC 133 C/D markers keep exec gated even when output echoes a known prompt', async (t) => {
|
||||
t.mock.timers.enable({ apis: ['Date', 'setTimeout'] });
|
||||
const pty = new TestPty();
|
||||
const session = { _promptTrackTail: '', lastIdlePrompt: '' };
|
||||
trackSessionIdlePrompt(session, '\x1b]133;A\x07PS C:\\Users\\alice> ');
|
||||
assert.equal(readyPrompt(session), 'PS C:\\Users\\alice> ');
|
||||
// Command in flight; the program echoes prompt-shaped text. Text says
|
||||
// ready, the last marker says otherwise — markers override.
|
||||
trackSessionIdlePrompt(session, '\x1b]133;C\x07build running...\r\nPS C:\\Users\\alice> ');
|
||||
assert.equal(readyPrompt(session), '');
|
||||
const job = startPtyJob(pty, 'echo OK', {
|
||||
shellKind: 'powershell',
|
||||
getReadyPrompt: () => readyPrompt(session),
|
||||
promptWaitTimeoutMs: 50,
|
||||
timeoutMs: 1000,
|
||||
});
|
||||
t.mock.timers.tick(50);
|
||||
const result = await job.resultPromise;
|
||||
assert.equal(result.code, 'SHELL_NOT_READY');
|
||||
assert.match(result.error, /no command was sent/);
|
||||
assert.deepEqual(pty.writes, []);
|
||||
});
|
||||
@@ -0,0 +1,258 @@
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const { EventEmitter } = require('node:events');
|
||||
const { spawnSync } = require('node:child_process');
|
||||
const path = require('node:path');
|
||||
const { trackSessionIdlePrompt, getFreshIdlePrompt, getVisibleIdlePrompt, computeOsc133ReadyState } = require('./shellUtils.cjs');
|
||||
const { startPtyJob, resolveEffectiveShellKind } = require('./ptyExec.cjs');
|
||||
|
||||
const A = '\x1b]133;A\x07';
|
||||
const B = '\x1b]133;B\x07';
|
||||
const C = '\x1b]133;C\x07';
|
||||
const D = '\x1b]133;D;0\x1b\\';
|
||||
const ready = session => getFreshIdlePrompt(session) || getVisibleIdlePrompt(session);
|
||||
|
||||
class TestPty extends EventEmitter {
|
||||
writes = [];
|
||||
write(data) {
|
||||
this.writes.push(data);
|
||||
const marker = String(data).match(/(__NCMCP_[a-z0-9]+_[0-9a-f]+__)/i)?.[1];
|
||||
if (marker) queueMicrotask(() => this.emit('data', `${marker}_S\nOK\n${marker}_E:0\n`));
|
||||
}
|
||||
}
|
||||
|
||||
test('OSC prompts reject pending input and continuations without writing any bytes', async () => {
|
||||
for (const prompt of ['review@local:~$ ', 'custom λ ', '> ']) {
|
||||
for (const pending of ['echo half-typed', ' ', 'echo "unfinished\r\n> ']) {
|
||||
const session = {};
|
||||
trackSessionIdlePrompt(session, A + prompt + B);
|
||||
assert.equal(ready(session), prompt);
|
||||
trackSessionIdlePrompt(session, pending);
|
||||
assert.equal(ready(session), '', JSON.stringify({ prompt, pending }));
|
||||
const pty = new TestPty();
|
||||
const result = await startPtyJob(pty, 'echo OK', {
|
||||
shellKind: 'posix', getReadyPrompt: () => ready(session), promptWaitTimeoutMs: 5, timeoutMs: 200,
|
||||
}).resultPromise;
|
||||
assert.equal(result.code, 'SHELL_NOT_READY');
|
||||
assert.deepEqual(pty.writes, []);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('OSC prompt start alone does not revive a stale cached prompt', () => {
|
||||
const session = {};
|
||||
trackSessionIdlePrompt(session, 'PS C:\\Users\\review> ');
|
||||
trackSessionIdlePrompt(session, A);
|
||||
assert.equal(ready(session), '');
|
||||
trackSessionIdlePrompt(session, '❯ ' + B);
|
||||
assert.equal(getFreshIdlePrompt(session), '');
|
||||
assert.equal(ready(session), '❯ ');
|
||||
assert.equal(resolveEffectiveShellKind('', ready(session), { loginShellHint: 'posix' }), 'posix');
|
||||
});
|
||||
|
||||
test('OSC custom prompts need prompt-end B before arbitrary text can qualify', () => {
|
||||
const session = {};
|
||||
trackSessionIdlePrompt(session, A + 'custom λ ');
|
||||
assert.equal(ready(session), '');
|
||||
trackSessionIdlePrompt(session, B);
|
||||
assert.equal(ready(session), 'custom λ ');
|
||||
});
|
||||
|
||||
test('OSC busy and finished states survive 4KB tail eviction until a new prompt', () => {
|
||||
for (const marker of [C, D]) {
|
||||
const session = {};
|
||||
trackSessionIdlePrompt(session, A + 'review@local:~$ ' + B + marker);
|
||||
trackSessionIdlePrompt(session, 'x'.repeat(4200) + '\r\nreview@local:~$ ');
|
||||
assert.equal(session.osc133Ready, false);
|
||||
assert.equal(ready(session), '');
|
||||
trackSessionIdlePrompt(session, '\r\n' + A + 'custom λ ' + B);
|
||||
assert.equal(ready(session), 'custom λ ');
|
||||
}
|
||||
});
|
||||
|
||||
test('OSC busy marker is retained even when it arrives inside one oversized chunk', () => {
|
||||
const session = {};
|
||||
trackSessionIdlePrompt(session, C + 'x'.repeat(4200) + '\r\nreview@local:~$ ');
|
||||
assert.equal(session.osc133Ready, false);
|
||||
assert.equal(ready(session), '');
|
||||
});
|
||||
|
||||
test('OSC parser accepts complete BEL/ST parameterized markers but rejects prefixes and invalid opcodes', () => {
|
||||
for (const tail of ['\x1b]133;A', '\x1b]133;Aardvark\x07', '\x1b]133;C-more\x07']) {
|
||||
assert.equal(computeOsc133ReadyState(tail), null, JSON.stringify(tail));
|
||||
}
|
||||
assert.equal(computeOsc133ReadyState('\x1b]133;A;cl=m\x1b\\'), true);
|
||||
assert.equal(computeOsc133ReadyState(C + '\x1b]133;A'), false);
|
||||
});
|
||||
|
||||
test('OSC secondary prompt markers never make unfinished input ready', () => {
|
||||
for (const prompt of ['> ', 'review@local:~$ ']) {
|
||||
const session = {};
|
||||
trackSessionIdlePrompt(session, A + 'custom λ ' + B + 'echo "\r\n');
|
||||
trackSessionIdlePrompt(session, '\x1b]133;A;k=s\x07' + prompt + B);
|
||||
assert.equal(session.osc133Ready, false);
|
||||
assert.equal(ready(session), '');
|
||||
assert.equal(computeOsc133ReadyState('\x1b]133;A;k=s\x07'), false);
|
||||
}
|
||||
});
|
||||
|
||||
test('OSC markers and custom prompts survive every one-byte PTY split', () => {
|
||||
const session = {};
|
||||
for (const byte of '\x1b]133;A;cl=m\x1b\\custom λ \x1b]133;B\x1b\\') {
|
||||
trackSessionIdlePrompt(session, byte);
|
||||
}
|
||||
assert.equal(ready(session), 'custom λ ');
|
||||
for (const byte of C) trackSessionIdlePrompt(session, byte);
|
||||
assert.equal(ready(session), '');
|
||||
});
|
||||
|
||||
test('OSC prompt recovery after erased input accepts only the original empty prompt', () => {
|
||||
const session = {};
|
||||
trackSessionIdlePrompt(session, A + 'custom λ ' + B + 'partial');
|
||||
assert.equal(ready(session), '');
|
||||
trackSessionIdlePrompt(session, '\r\x1b[2Kcustom λ ');
|
||||
assert.equal(ready(session), 'custom λ ');
|
||||
trackSessionIdlePrompt(session, 'echo "\r\n> ');
|
||||
trackSessionIdlePrompt(session, '\r\x1b[2Kcustom λ ');
|
||||
assert.equal(ready(session), '', 'continuation requires a new prompt cycle');
|
||||
trackSessionIdlePrompt(session, '\r\n' + A + 'custom λ ' + B);
|
||||
assert.equal(ready(session), '', 'newline and A/B do not prove cancellation');
|
||||
trackSessionIdlePrompt(session, C + D + A + 'custom λ ' + B);
|
||||
assert.equal(ready(session), 'custom λ ');
|
||||
});
|
||||
|
||||
test('ambiguous backspace-space-backspace output never authorizes command injection', async () => {
|
||||
for (const prompt of ['review@local:~$ ', 'custom \u03bb ']) {
|
||||
for (const erased of ['x\b \b', 'xy\b \b\b \b',
|
||||
'hello' + '\b'.repeat(5) + ' '.repeat(5) + '\b'.repeat(5)]) {
|
||||
const session = {};
|
||||
trackSessionIdlePrompt(session, A + prompt + B);
|
||||
trackSessionIdlePrompt(session, erased);
|
||||
assert.equal(ready(session), '', JSON.stringify({ prompt, erased }));
|
||||
const pty = new TestPty();
|
||||
const result = await startPtyJob(pty, 'echo OK', {
|
||||
shellKind: 'posix', getReadyPrompt: () => ready(session), promptWaitTimeoutMs: 5, timeoutMs: 200,
|
||||
}).resultPromise;
|
||||
assert.equal(result.code, 'SHELL_NOT_READY');
|
||||
assert.deepEqual(pty.writes, []);
|
||||
trackSessionIdlePrompt(session, ' ');
|
||||
assert.equal(ready(session), '', 'a new space advances the input cursor');
|
||||
trackSessionIdlePrompt(session, '\b');
|
||||
assert.equal(ready(session), '', 'moving left must not erase a genuinely pending space');
|
||||
trackSessionIdlePrompt(session, ' \b');
|
||||
assert.equal(ready(session), '', 'moving right and left across a blank must remain pending');
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('real Bash line editing keeps ambiguous deletion and pending blanks gated', (t) => {
|
||||
if (process.platform === 'win32') return t.skip('requires a POSIX Bash PTY');
|
||||
const probe = spawnSync('python3', ['-c', 'import pty']);
|
||||
if (probe.error?.code === 'ENOENT') return t.skip('python3 is unavailable');
|
||||
assert.equal(probe.status, 0, probe.stderr?.toString());
|
||||
for (const args of [[], ['--wide-prompt']]) {
|
||||
const result = spawnSync('python3', [path.resolve(__dirname, '../../../scripts/fixtures/osc133-erase-input.py'), ...args],
|
||||
{ encoding: 'utf8', timeout: 10_000 });
|
||||
if (result.status === 77) return t.skip('a UTF-8 locale is unavailable');
|
||||
assert.equal(result.status, 0, result.stderr);
|
||||
const scenarios = JSON.parse(result.stdout);
|
||||
assert.equal(scenarios.length, 27);
|
||||
const word = scenarios.find(({ name }) => name === 'Ctrl-W whole word');
|
||||
assert.ok(word.states.at(-1).raw.includes('\b'.repeat(5) + ' '.repeat(5) + '\b'.repeat(5)));
|
||||
const forward = scenarios.find(({ name }) => name === 'mixed forward delete');
|
||||
const kill = scenarios.find(({ name }) => name === 'indistinguishable mixed line kill');
|
||||
// The first leaves a genuine blank; the second empties input. Identical
|
||||
// output cannot certify which happened, so both must stay conservatively gated.
|
||||
assert.equal(forward.states.slice(1).map(({ raw }) => raw).join(''),
|
||||
kill.states.slice(1).map(({ raw }) => raw).join(''));
|
||||
for (const scenario of scenarios) {
|
||||
const session = {}, splitSession = {};
|
||||
for (const { step, raw, ready: expected } of scenario.states) {
|
||||
trackSessionIdlePrompt(session, raw);
|
||||
for (const ch of raw) {
|
||||
trackSessionIdlePrompt(splitSession, ch);
|
||||
if (!expected) assert.equal(Boolean(ready(splitSession)), false,
|
||||
`intermediate byte of ${scenario.name}: ${step}`);
|
||||
}
|
||||
assert.equal(Boolean(ready(session)), expected, `${scenario.name}: ${step}`);
|
||||
assert.equal(Boolean(ready(splitSession)), expected, `byte-split ${scenario.name}: ${step}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('real Bash vi replacement and multiline Ctrl-L remain unready through every packet boundary', async (t) => {
|
||||
if (process.platform === 'win32') return t.skip('requires a POSIX Bash PTY');
|
||||
const probe = spawnSync('python3', ['-c', 'import pty']);
|
||||
if (probe.error?.code === 'ENOENT') return t.skip('python3 is unavailable');
|
||||
assert.equal(probe.status, 0, probe.stderr?.toString());
|
||||
const result = spawnSync('python3', [path.resolve(__dirname, '../../../scripts/fixtures/osc133-erase-input.py'), '--risk-probes'],
|
||||
{ encoding: 'utf8', timeout: 10_000 });
|
||||
if (result.status === 77) return t.skip('a UTF-8 locale is unavailable');
|
||||
assert.equal(result.status, 0, result.stderr);
|
||||
const scenarios = JSON.parse(result.stdout);
|
||||
assert.equal(scenarios.length, 2);
|
||||
assert.equal(scenarios[0].readline_line, ' ', 'bind -x confirms vi replacement leaves real input');
|
||||
assert.equal(scenarios[0].states.slice(1).map(({ raw }) => raw).join(''), 'x\b \b');
|
||||
assert.equal(scenarios[1].readline_line, 'echo pending');
|
||||
const redraw = scenarios[1].states.at(-1).raw;
|
||||
assert.ok(redraw.includes(A + 'header\r\r\n'));
|
||||
assert.ok(redraw.includes(B), 'Ctrl-L redraw includes the input boundary before re-echo');
|
||||
for (const scenario of scenarios) {
|
||||
const session = {};
|
||||
for (const { step, raw, ready: expected } of scenario.states) {
|
||||
for (const ch of raw) {
|
||||
trackSessionIdlePrompt(session, ch);
|
||||
if (!expected) assert.equal(ready(session), '', `${scenario.name}: ${step}, intermediate packet`);
|
||||
}
|
||||
assert.equal(Boolean(ready(session)), expected, `${scenario.name}: ${step}`);
|
||||
}
|
||||
const pty = new TestPty();
|
||||
const result = await startPtyJob(pty, 'echo OK', {
|
||||
shellKind: 'posix', getReadyPrompt: () => ready(session), promptWaitTimeoutMs: 5, timeoutMs: 200,
|
||||
}).resultPromise;
|
||||
assert.equal(result.code, 'SHELL_NOT_READY');
|
||||
assert.deepEqual(pty.writes, []);
|
||||
// Repeated redraws, their own newlines, and secondary prompts cannot reset
|
||||
// uncertain input. Only a completed command lifecycle permits recovery.
|
||||
for (const text of ['\r\n' + A + 'header\r\nreview@local:~$ ' + B,
|
||||
'\x1b]133;A;k=s\x07> ' + B + A + 'custom ' + B]) {
|
||||
for (const ch of text) {
|
||||
trackSessionIdlePrompt(session, ch);
|
||||
assert.equal(ready(session), '');
|
||||
}
|
||||
}
|
||||
trackSessionIdlePrompt(session, C + D + A + 'custom ' + B);
|
||||
assert.equal(ready(session), 'custom ');
|
||||
}
|
||||
});
|
||||
|
||||
test('same-row OSC redraw stays gated until a fresh prompt lifecycle', () => {
|
||||
const session = {}, prompt = 'review@local:~$ ';
|
||||
trackSessionIdlePrompt(session, A + prompt + B + 'x ');
|
||||
for (const ch of '\r' + A + prompt + B) {
|
||||
trackSessionIdlePrompt(session, ch);
|
||||
assert.equal(ready(session), '');
|
||||
}
|
||||
trackSessionIdlePrompt(session, '\r\n' + A + prompt + B);
|
||||
assert.equal(ready(session), '', 'a newline cannot clear the redraw latch');
|
||||
trackSessionIdlePrompt(session, 'x' + '\r' + A + prompt + B);
|
||||
assert.equal(ready(session), '');
|
||||
trackSessionIdlePrompt(session, C + D + A + prompt + B);
|
||||
assert.equal(ready(session), prompt);
|
||||
});
|
||||
|
||||
test('OSC repeats complete execution and prompt cycles without stale prompt state', async () => {
|
||||
const session = {};
|
||||
for (const prompt of ['custom λ ', 'other ❯ ', 'review@local:~$ ']) {
|
||||
trackSessionIdlePrompt(session, '\r\n' + A + prompt + B);
|
||||
const pty = new TestPty();
|
||||
const result = await startPtyJob(pty, 'echo OK', {
|
||||
shellKind: 'posix', getReadyPrompt: () => ready(session), promptWaitTimeoutMs: 5, timeoutMs: 200,
|
||||
}).resultPromise;
|
||||
assert.equal(result.ok, true);
|
||||
assert.equal(pty.writes.length, 1);
|
||||
trackSessionIdlePrompt(session, C + 'output' + D);
|
||||
assert.equal(ready(session), '');
|
||||
}
|
||||
});
|
||||
@@ -224,11 +224,109 @@ function looksLikeIdleAutoLogout(outputTail) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// ── OSC 133 (shell-integration) readiness ──
|
||||
//
|
||||
// A starts a prompt; B ends its rendering and starts command input; C starts
|
||||
// execution/output; D finishes execution. A alone does not prove empty input.
|
||||
// Keep lifecycle state beyond rolling-tail eviction, and accept custom prompts
|
||||
// only while the B-marked cursor line is unchanged and no continuation arrived.
|
||||
// A-only integrations retain the existing whitelist checks.
|
||||
const OSC133_MARKER_PATTERN = /\u001B\]133;([ABCD])(?:;([^\u0007\u001B]*))?(?:\u0007|\u001B\\)/g;
|
||||
|
||||
function isOsc133PrimaryPrompt(marker) {
|
||||
// Kitty's k=s marks PS2, which is unfinished command input, not a fresh PS1.
|
||||
return marker[1] === "A" && !marker[2]?.split(";").includes("k=s");
|
||||
}
|
||||
|
||||
function computeOsc133ReadyState(tail) {
|
||||
if (typeof tail !== "string" || !tail) return null;
|
||||
let ready = null;
|
||||
for (const marker of tail.matchAll(OSC133_MARKER_PATTERN)) {
|
||||
if (marker[1] !== "B") ready = isOsc133PrimaryPrompt(marker);
|
||||
}
|
||||
return ready;
|
||||
}
|
||||
|
||||
function promptTrackingView(session) {
|
||||
const tail = session?._promptTrackTail;
|
||||
if (typeof tail !== "string" || !tail) return { tail: "", alternateScreenActive: false };
|
||||
if (session.osc133Ready === true) {
|
||||
const start = session._osc133PromptStart;
|
||||
return {
|
||||
// Never revive text from before A, or a prompt whose start was evicted.
|
||||
tail: typeof start === "number" && start >= 0 ? tail.slice(start) : "",
|
||||
alternateScreenActive: session._osc133PromptAltScreenAtStart === true,
|
||||
};
|
||||
}
|
||||
return { tail, alternateScreenActive: session._promptTrackAltScreenAtTailStart === true };
|
||||
}
|
||||
|
||||
function matchesOsc133EmptyPrompt(session, view) {
|
||||
if (session._osc133PromptRedrawPending) return false;
|
||||
if (session._osc133PromptLine == null) return true;
|
||||
if (session._osc133PromptContinued) return false;
|
||||
const { terminalCursorState } = require("../terminalLogSanitizer.cjs");
|
||||
const cursor = terminalCursorState(view.tail, view);
|
||||
// Readline deletion and vi replacement can emit identical BS-space-BS
|
||||
// bytes. Preserve every displayed blank; output cannot certify its removal.
|
||||
return cursor.column === session._osc133PromptColumn
|
||||
&& cursor.line === session._osc133PromptLine;
|
||||
}
|
||||
|
||||
function trackSessionIdlePrompt(session, chunk) {
|
||||
if (!session || typeof chunk !== "string" || !chunk) return "";
|
||||
|
||||
const previousTail = session._promptTrackTail || "";
|
||||
const combined = `${previousTail}${chunk}`;
|
||||
session.osc133Ready ??= null;
|
||||
for (const marker of combined.matchAll(OSC133_MARKER_PATTERN)) {
|
||||
const end = marker.index + marker[0].length;
|
||||
// Replaying old markers would erase pending-input state on every chunk.
|
||||
// A marker split across chunks is consumed when its terminator arrives.
|
||||
if (end <= previousTail.length) continue;
|
||||
if (isOsc133PrimaryPrompt(marker)) {
|
||||
const { alternateScreenActiveAfter } = require("../terminalLogSanitizer.cjs");
|
||||
const beforeMarker = combined.slice(0, marker.index);
|
||||
const priorSession = { ...session, _promptTrackTail: beforeMarker };
|
||||
// A/B also occur during redraw, including multiline PS1 and packets
|
||||
// ending before pending input is re-echoed. Neither A nor a newline
|
||||
// proves cancellation. Keep uncertain input gated until C/D establishes
|
||||
// a new command lifecycle; a redraw cannot clear this latch.
|
||||
session._osc133PromptRedrawPending ||= session.osc133Ready === true
|
||||
&& session._osc133PromptLine != null
|
||||
&& !matchesOsc133EmptyPrompt(priorSession, promptTrackingView(priorSession));
|
||||
session.osc133Ready = true;
|
||||
session._osc133PromptStart = end;
|
||||
session._osc133PromptAltScreenAtStart = alternateScreenActiveAfter(
|
||||
combined.slice(0, end), session._promptTrackAltScreenAtTailStart === true,
|
||||
);
|
||||
session._osc133PromptLine = null;
|
||||
session._osc133PromptEnd = -1;
|
||||
session._osc133PromptContinued = false;
|
||||
} else if (marker[1] === "B") {
|
||||
if (session.osc133Ready === true && session._osc133PromptStart >= 0) {
|
||||
const { terminalCursorState } = require("../terminalLogSanitizer.cjs");
|
||||
const cursor = terminalCursorState(
|
||||
combined.slice(session._osc133PromptStart, end),
|
||||
{ alternateScreenActive: session._osc133PromptAltScreenAtStart === true },
|
||||
);
|
||||
session._osc133PromptLine = cursor.line;
|
||||
session._osc133PromptColumn = cursor.column;
|
||||
session._osc133PromptEnd = end;
|
||||
}
|
||||
} else {
|
||||
// Secondary prompts continue input; only C/D establish a new lifecycle.
|
||||
session._osc133PromptRedrawPending = marker[1] === "A";
|
||||
session.osc133Ready = false;
|
||||
session._osc133PromptLine = null;
|
||||
session._osc133PromptStart = -1;
|
||||
session._osc133PromptEnd = -1;
|
||||
}
|
||||
}
|
||||
if (session._osc133PromptLine != null) {
|
||||
const inputStart = Math.max(previousTail.length, session._osc133PromptEnd);
|
||||
if (combined.slice(inputStart).includes("\n")) session._osc133PromptContinued = true;
|
||||
}
|
||||
const overflow = combined.length - MAX_PROMPT_TRACK_TAIL;
|
||||
if (overflow > 0) {
|
||||
// The kept tail no longer starts where the previous tail started. Replay
|
||||
@@ -239,11 +337,14 @@ function trackSessionIdlePrompt(session, chunk) {
|
||||
combined.slice(0, overflow),
|
||||
session._promptTrackAltScreenAtTailStart === true,
|
||||
);
|
||||
for (const key of ["_osc133PromptStart", "_osc133PromptEnd"]) {
|
||||
if (typeof session[key] === "number") session[key] = Math.max(-1, session[key] - overflow);
|
||||
}
|
||||
}
|
||||
const nextTail = overflow > 0 ? combined.slice(overflow) : combined;
|
||||
session._promptTrackTail = nextTail;
|
||||
|
||||
const prompt = extractTrailingIdlePrompt(nextTail);
|
||||
const prompt = extractTrailingIdlePrompt(promptTrackingView(session).tail);
|
||||
if (prompt) {
|
||||
session.lastIdlePrompt = prompt;
|
||||
session.lastIdlePromptAt = Date.now();
|
||||
@@ -269,11 +370,18 @@ function trackSessionIdlePrompt(session, chunk) {
|
||||
// back to `shellKind` alone.
|
||||
function getFreshIdlePrompt(session) {
|
||||
if (!session) return "";
|
||||
|
||||
// C/D gate text-shaped output. A never bypasses cache freshness or input
|
||||
// checks: an earlier prompt remains marked A while a user edits a command.
|
||||
if (session.osc133Ready === false) return "";
|
||||
|
||||
const cached = session.lastIdlePrompt;
|
||||
if (!cached) return "";
|
||||
|
||||
const tail = session._promptTrackTail;
|
||||
const view = promptTrackingView(session);
|
||||
const tail = view.tail;
|
||||
if (typeof tail !== "string" || !tail) return "";
|
||||
if (!matchesOsc133EmptyPrompt(session, view)) return "";
|
||||
|
||||
const normalizedTail = stripAnsi(tail).replace(/\r/g, "\n");
|
||||
const normalizedCached = stripAnsi(cached).replace(/\r/g, "\n");
|
||||
@@ -289,15 +397,23 @@ function getFreshIdlePrompt(session) {
|
||||
// screen is still an idle default prompt (#3927). Collapse those cursor and
|
||||
// erase operations, then accept only the line the cursor is actually on.
|
||||
function getVisibleIdlePrompt(session) {
|
||||
const tail = session?._promptTrackTail;
|
||||
const view = promptTrackingView(session);
|
||||
const tail = view.tail;
|
||||
if (typeof tail !== "string" || !tail) return "";
|
||||
|
||||
// Mirror getFreshIdlePrompt: C/D gate output, B confirms an empty input line.
|
||||
if (session.osc133Ready === false) return "";
|
||||
if (!matchesOsc133EmptyPrompt(session, view)) return "";
|
||||
|
||||
const { terminalCursorLine } = require("../terminalLogSanitizer.cjs");
|
||||
const cursorLine = terminalCursorLine(tail, {
|
||||
alternateScreenActive: session._promptTrackAltScreenAtTailStart === true,
|
||||
});
|
||||
const cursorLine = terminalCursorLine(tail, view);
|
||||
const rightTrimmed = cursorLine.replace(/\s+$/, "");
|
||||
if (!rightTrimmed) return "";
|
||||
if (session.osc133Ready === true && session._osc133PromptLine != null) {
|
||||
// A complete B gives the custom prompt boundary; its unchanged cursor line
|
||||
// and lack of continuation are checked above before bypassing shape checks.
|
||||
return /\s$/.test(cursorLine) ? `${rightTrimmed} ` : rightTrimmed;
|
||||
}
|
||||
if (
|
||||
!isDefaultPowerShellPromptLine(rightTrimmed)
|
||||
&& !isDefaultPosixPromptLine(rightTrimmed)
|
||||
@@ -1251,6 +1367,7 @@ module.exports = {
|
||||
extractTrailingIdlePrompt,
|
||||
getFreshIdlePrompt,
|
||||
getVisibleIdlePrompt,
|
||||
computeOsc133ReadyState,
|
||||
isDefaultPowerShellPromptLine,
|
||||
isDefaultCmdPromptLine,
|
||||
isDefaultPosixPromptLine,
|
||||
|
||||
@@ -25,6 +25,7 @@ const {
|
||||
readWindowsRegistryPath,
|
||||
createWindowsLivePathResolver,
|
||||
trackSessionIdlePrompt,
|
||||
computeOsc133ReadyState,
|
||||
} = require("./shellUtils.cjs");
|
||||
const fs = require("node:fs");
|
||||
const os = require("node:os");
|
||||
@@ -1378,3 +1379,98 @@ test("a failed machine hive does not promote user directories ahead of inherited
|
||||
platform: "win32", basePath: "C:\\Windows\\System32", env: {}, knownCliDirs: () => [],
|
||||
}), "C:\\Windows\\System32;C:\\user-tools");
|
||||
});
|
||||
|
||||
// ── OSC 133 shell-integration readiness ──
|
||||
|
||||
test("computeOsc133ReadyState derives readiness from the last marker in the raw tail", () => {
|
||||
assert.equal(computeOsc133ReadyState(""), null);
|
||||
assert.equal(computeOsc133ReadyState("plain output with no markers"), null);
|
||||
// BEL terminator, A last → ready.
|
||||
assert.equal(computeOsc133ReadyState("\x1b]133;A\x07user@host ~ % "), true);
|
||||
// ST terminator on D; full cycle A→B→C→D ends not-at-prompt.
|
||||
assert.equal(
|
||||
computeOsc133ReadyState("\x1b]133;A\x07\x1b]133;B\x07ls\x07\x1b]133;C\x07files\x1b]133;D\x1b\\"),
|
||||
false,
|
||||
);
|
||||
// Parameterized D form (e.g. `133;D;0`) still matches the marker prefix.
|
||||
assert.equal(computeOsc133ReadyState("\x1b]133;A\x07\x1b]133;D;0\x07"), false);
|
||||
// Output start (C) after the last prompt start means a command is running.
|
||||
assert.equal(computeOsc133ReadyState("\x1b]133;A\x07\x1b]133;C\x07build output..."), false);
|
||||
// A fresh prompt start after a finished command is ready again.
|
||||
assert.equal(computeOsc133ReadyState("\x1b]133;D\x07\x1b]133;A\x07~ ❯ "), true);
|
||||
});
|
||||
|
||||
test("trackSessionIdlePrompt stores OSC 133 state alongside the whitelist prompt", () => {
|
||||
// No markers → null (non-integrated shell, whitelist fallback unchanged).
|
||||
const plain = {};
|
||||
trackSessionIdlePrompt(plain, "Last login...\r\nuser@host ~ % ");
|
||||
assert.equal(plain.osc133Ready, null);
|
||||
assert.equal(getFreshIdlePrompt(plain), "user@host ~ % ");
|
||||
});
|
||||
|
||||
test("OSC 133 keeps whitelisted prompts and readies custom prompts after B", () => {
|
||||
// Colored Powerlevel10k-style prompt: theme SGR codes + ❯, after an A marker.
|
||||
const p10k = {};
|
||||
trackSessionIdlePrompt(
|
||||
p10k,
|
||||
"\x1b]133;A\x07\x1b]0;title\x07\x1b[38;5;39m~/src\x1b[0m \x1b[38;5;213m❯\x1b[0m ",
|
||||
);
|
||||
assert.equal(p10k.osc133Ready, true);
|
||||
assert.equal(getFreshIdlePrompt(p10k), "~/src ❯ ");
|
||||
assert.equal(getVisibleIdlePrompt(p10k), "~/src ❯ ");
|
||||
|
||||
// B marks the custom prompt boundary; A alone cannot prove empty input.
|
||||
const custom = {};
|
||||
trackSessionIdlePrompt(custom, "\x1b]133;A\x07myprompt λ ");
|
||||
assert.equal(custom.osc133Ready, true);
|
||||
assert.equal(getFreshIdlePrompt(custom), "");
|
||||
assert.equal(getVisibleIdlePrompt(custom), "");
|
||||
trackSessionIdlePrompt(custom, "\x1b]133;B\x07");
|
||||
assert.equal(getVisibleIdlePrompt(custom), "myprompt λ ");
|
||||
});
|
||||
|
||||
test("OSC 133 C/D markers override whitelisted-looking prompt text (not ready)", () => {
|
||||
const session = {};
|
||||
trackSessionIdlePrompt(session, "\x1b]133;A\x07PS C:\\Users\\alice> ");
|
||||
assert.equal(session.osc133Ready, true);
|
||||
assert.equal(getFreshIdlePrompt(session), "PS C:\\Users\\alice> ");
|
||||
|
||||
// Command in flight; a running program then echoes prompt-shaped text —
|
||||
// the exact case where the whitelist lies (#4048 false-ready).
|
||||
trackSessionIdlePrompt(session, "\x1b]133;B\x07dir\x07\x1b]133;C\x07running...\r\nPS C:\\Users\\alice> ");
|
||||
assert.equal(session.osc133Ready, false);
|
||||
assert.equal(getFreshIdlePrompt(session), "");
|
||||
assert.equal(getVisibleIdlePrompt(session), "");
|
||||
|
||||
// D (finished) without a new A marker stays not-ready even with
|
||||
// prompt-shaped text after it.
|
||||
trackSessionIdlePrompt(session, "done\x1b]133;D;0\x1b\\\r\nuser@host ~ % ");
|
||||
assert.equal(session.osc133Ready, false);
|
||||
assert.equal(getFreshIdlePrompt(session), "");
|
||||
assert.equal(getVisibleIdlePrompt(session), "");
|
||||
|
||||
// A new prompt start restores readiness.
|
||||
trackSessionIdlePrompt(session, "\x1b]133;A\x07user@host ~ % ");
|
||||
assert.equal(session.osc133Ready, true);
|
||||
assert.equal(getFreshIdlePrompt(session), "user@host ~ % ");
|
||||
});
|
||||
|
||||
test("OSC 133 state survives markers split across PTY chunks via the rolling tail", () => {
|
||||
const session = {};
|
||||
trackSessionIdlePrompt(session, "welcome\r\n\x1b]133;");
|
||||
assert.equal(session.osc133Ready, null);
|
||||
trackSessionIdlePrompt(session, "A\x07user@host ~ % ");
|
||||
assert.equal(session.osc133Ready, true);
|
||||
assert.equal(getVisibleIdlePrompt(session), "user@host ~ % ");
|
||||
});
|
||||
|
||||
test("OSC 133 lifecycle state survives rolling-tail eviction without reviving old prompts", () => {
|
||||
const session = {};
|
||||
trackSessionIdlePrompt(session, "\x1b]133;A\x07");
|
||||
assert.equal(session.osc133Ready, true);
|
||||
// Retain lifecycle state, but fail closed when the prompt itself was evicted.
|
||||
trackSessionIdlePrompt(session, "x".repeat(4200));
|
||||
assert.equal(session.osc133Ready, true);
|
||||
assert.equal(getFreshIdlePrompt(session), "");
|
||||
assert.equal(getVisibleIdlePrompt(session), "");
|
||||
});
|
||||
@@ -1,8 +1,10 @@
|
||||
const test = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const { spawn } = require("node:child_process");
|
||||
const { EventEmitter } = require("node:events");
|
||||
const {
|
||||
classifyProcessError,
|
||||
createDiagnosticLogger,
|
||||
createProcessErrorController,
|
||||
installProcessErrorHandlers,
|
||||
isNonFatalNetworkError,
|
||||
@@ -251,3 +253,137 @@ test("controller suppresses ssh-style errors with a level property", () => {
|
||||
assert.equal(isNonFatalNetworkError(err), true);
|
||||
assert.equal(result.action, "suppress");
|
||||
});
|
||||
|
||||
test("diagnostic logger drops lines once the sink reports an error", () => {
|
||||
const sink = new EventEmitter();
|
||||
const written = [];
|
||||
const log = createDiagnosticLogger(
|
||||
(...args) => written.push(args),
|
||||
{ streams: [sink] },
|
||||
);
|
||||
|
||||
log("first");
|
||||
sink.emit("error", Object.assign(new Error("write EPIPE"), { code: "EPIPE" }));
|
||||
log("second");
|
||||
|
||||
assert.deepEqual(written, [["first"]]);
|
||||
});
|
||||
|
||||
test("diagnostic logger forwards only the first sink error to onError", () => {
|
||||
const sink = new EventEmitter();
|
||||
const reports = [];
|
||||
createDiagnosticLogger(
|
||||
() => {},
|
||||
{ streams: [sink], onError: (err) => reports.push(err) },
|
||||
);
|
||||
|
||||
const first = Object.assign(new Error("write EPIPE"), { code: "EPIPE" });
|
||||
sink.emit("error", first);
|
||||
sink.emit("error", Object.assign(new Error("write EPIPE again"), { code: "EPIPE" }));
|
||||
|
||||
assert.deepEqual(reports, [first]);
|
||||
});
|
||||
|
||||
test("diagnostic logger latches before forwarding the sink error", () => {
|
||||
const sink = new EventEmitter();
|
||||
let attempts = 0;
|
||||
let log = null;
|
||||
log = createDiagnosticLogger(
|
||||
() => {
|
||||
attempts += 1;
|
||||
},
|
||||
{ streams: [sink], onError: () => log("logged from the reporter") },
|
||||
);
|
||||
|
||||
sink.emit("error", Object.assign(new Error("write EPIPE"), { code: "EPIPE" }));
|
||||
|
||||
assert.equal(attempts, 0, "the reporter must not write through the failed sink");
|
||||
});
|
||||
|
||||
test("diagnostic logger keeps a throwing onError from escaping", () => {
|
||||
const sink = new EventEmitter();
|
||||
const written = [];
|
||||
const log = createDiagnosticLogger(
|
||||
(...args) => written.push(args),
|
||||
{
|
||||
streams: [sink],
|
||||
onError() {
|
||||
throw new Error("reporter is dead");
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
assert.doesNotThrow(() => sink.emit("error", new Error("write EPIPE")));
|
||||
log("after failure");
|
||||
assert.deepEqual(written, []);
|
||||
});
|
||||
|
||||
test("a closed stdout pipe is reported once so the failure reaches the crash log", async () => {
|
||||
const child = spawn(process.execPath, ["-e", `
|
||||
const { createDiagnosticLogger } = require(${JSON.stringify(require.resolve("./processErrorGuards.cjs"))});
|
||||
let reports = 0;
|
||||
const log = createDiagnosticLogger(
|
||||
(...args) => console.error(...args),
|
||||
{
|
||||
streams: [process.stdout, process.stderr],
|
||||
onError() { reports += 1; },
|
||||
},
|
||||
);
|
||||
setTimeout(() => {
|
||||
log("diagnostic after the pipe closed");
|
||||
log("second diagnostic after the pipe closed");
|
||||
setTimeout(() => process.exit(reports), 200);
|
||||
}, 200);
|
||||
setTimeout(() => process.exit(reports), 3000);
|
||||
`], { stdio: ["ignore", "pipe", "pipe"] });
|
||||
|
||||
child.stdout.destroy();
|
||||
child.stderr.destroy();
|
||||
const exitCode = await new Promise((resolve) => child.once("exit", resolve));
|
||||
|
||||
assert.equal(exitCode, 1, "exactly one report for the first sink failure");
|
||||
});
|
||||
|
||||
test("diagnostic logger latches closed when the sink throws synchronously", () => {
|
||||
let attempts = 0;
|
||||
const log = createDiagnosticLogger(() => {
|
||||
attempts += 1;
|
||||
throw Object.assign(new Error("write EIO"), { code: "EIO" });
|
||||
});
|
||||
|
||||
assert.doesNotThrow(() => log("boom"));
|
||||
assert.doesNotThrow(() => log("boom again"));
|
||||
assert.equal(attempts, 1);
|
||||
});
|
||||
|
||||
test("diagnostic logger dispose releases the sink listeners", () => {
|
||||
const sink = new EventEmitter();
|
||||
const log = createDiagnosticLogger(() => {}, { streams: [sink] });
|
||||
|
||||
assert.equal(sink.listenerCount("error"), 1);
|
||||
log.dispose();
|
||||
assert.equal(sink.listenerCount("error"), 0);
|
||||
});
|
||||
|
||||
test("a broken diagnostic sink still lets runtime errors be captured", () => {
|
||||
const captured = [];
|
||||
const controller = createProcessErrorController({
|
||||
captureError(source, err) {
|
||||
captured.push([source, err.message]);
|
||||
},
|
||||
onFatalError(err) {
|
||||
throw err;
|
||||
},
|
||||
logError: createDiagnosticLogger(() => {
|
||||
throw Object.assign(new Error("write EIO"), { code: "EIO" });
|
||||
}),
|
||||
logWarn: createDiagnosticLogger(() => {
|
||||
throw Object.assign(new Error("write EPIPE"), { code: "EPIPE" });
|
||||
}),
|
||||
});
|
||||
controller.beginMainWindowStartup();
|
||||
controller.completeMainWindowStartup({ windowShown: true });
|
||||
|
||||
assert.doesNotThrow(() => controller.handleUncaughtException(new Error("runtime boom")));
|
||||
assert.deepEqual(captured, [["uncaughtException", "runtime boom"]]);
|
||||
});
|
||||
Loaded 100 of 159 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user