Compare commits

..
Author SHA1 Message Date
Cursor Agentand陈大猫 abe2b0edc0 fix(terminal): merge pending side-panel writes across urgent renders
Icon switches inside startTransition used to publish absolute maps from
refs, and the next render copied the last committed state back over
those refs. A second tab's switch could then replace the queued map and
drop the first tab's selection and split. Patch each tab onto the queued
map, and leave a pending ref in place until that render commits.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-10-24 07:13:44 +00:00
Cursor Agentand陈大猫 945147c600 perf(terminal): commit side-panel tool switches in one render
Icon clicks write the focused tool and the layout together through
selectSidePanelTool. External open-tab writes still reconcile in an effect,
and that effect skips when the layout is already the selection.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-10-24 07:06:52 +00:00
71 changed files with 245 additions and 4315 deletions

No files matched your search

+1 -45
View File
@@ -964,52 +964,8 @@ Highlight the focused split pane:
'settings.shortcuts.category.terminal': 'Terminal',
'settings.shortcuts.category.navigation': 'Navigation',
'settings.shortcuts.category.app': 'App',
'settings.shortcuts.category.sftp': 'SFTP',
// Settings > Shortcuts -> key bindings
'settings.shortcuts.binding.switch-tab-1-9': 'Switch to Tab [1...9]',
'settings.shortcuts.binding.next-tab': 'Next Tab',
'settings.shortcuts.binding.prev-tab': 'Previous Tab',
'settings.shortcuts.binding.close-tab': 'Close Tab',
'settings.shortcuts.binding.close-session': 'Close Session Pane',
'settings.shortcuts.binding.new-tab': 'New Local Tab',
'settings.shortcuts.binding.copy': 'Copy from Terminal',
'settings.shortcuts.binding.paste': 'Paste to Terminal',
'settings.shortcuts.binding.paste-selection': 'Paste Selection to Terminal',
'settings.shortcuts.binding.select-all': 'Select All in Terminal',
'settings.shortcuts.binding.clear-buffer': 'Clear Terminal Buffer',
'settings.shortcuts.binding.search-terminal': 'Open Terminal Search',
'settings.shortcuts.binding.increase-terminal-font-size': 'Increase Terminal Font Size',
'settings.shortcuts.binding.decrease-terminal-font-size': 'Decrease Terminal Font Size',
'settings.shortcuts.binding.reset-terminal-font-size': 'Reset Terminal Font Size',
'settings.shortcuts.binding.move-focus': 'Move focus between Split View panes',
'settings.shortcuts.binding.split-horizontal': 'Split Horizontal',
'settings.shortcuts.binding.split-vertical': 'Split Vertical',
'settings.shortcuts.binding.toggle-pane-zoom': 'Toggle Pane Zoom',
'settings.shortcuts.binding.open-hosts': 'Open Hosts Page',
'settings.shortcuts.binding.open-local': 'Open Local Terminal',
'settings.shortcuts.binding.open-sftp': 'Open SFTP',
'settings.shortcuts.binding.port-forwarding': 'Open Port Forwarding',
'settings.shortcuts.binding.command-palette': 'Open Command Palette',
'settings.shortcuts.binding.quick-switch': 'Quick Switch',
'settings.shortcuts.binding.new-workspace': 'New Workspace',
'settings.shortcuts.binding.snippets': 'Open Snippets',
'settings.shortcuts.binding.broadcast': 'Switch the Broadcast Mode',
'settings.shortcuts.binding.toggle-side-panel': 'Toggle Side Panel',
'settings.shortcuts.binding.toggle-host-tree': 'Toggle Host List',
'settings.shortcuts.binding.open-settings': 'Open Settings',
'settings.shortcuts.binding.toggle-opacity': 'Toggle Window Transparency',
'settings.shortcuts.binding.sftp-copy': 'Copy Files',
'settings.shortcuts.binding.sftp-cut': 'Cut Files',
'settings.shortcuts.binding.sftp-paste': 'Paste Files',
'settings.shortcuts.binding.sftp-select-all': 'Select All Files',
'settings.shortcuts.binding.sftp-rename': 'Rename File',
'settings.shortcuts.binding.sftp-delete': 'Delete Files',
'settings.shortcuts.binding.sftp-refresh': 'Refresh',
'settings.shortcuts.binding.sftp-new-folder': 'New Folder',
'settings.shortcuts.binding.sftp-open': 'Open File / Enter Directory',
'settings.shortcuts.binding.sftp-go-parent': 'Go to Parent Directory',
'settings.shortcuts.binding.sftp-navigate-to': 'Navigate to Selected Directory',
'settings.shortcuts.category.sftp': 'SFTP',
// Context menus / common actions
'action.newHost': 'New Host',
-20
View File
@@ -90,15 +90,6 @@ 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.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',
'ai.providers.headers.value': 'Valor de la cabecera',
'ai.providers.headers.add': 'Agregar cabecera',
'ai.providers.headers.remove': 'Quitar cabecera',
'ai.providers.headers.invalid': 'Usa cabeceras HTTP válidas y únicas. Content-Length y Transfer-Encoding se gestionan automáticamente.',
'ai.providers.headers.loadError': 'No se pudieron desbloquear las cabeceras guardadas. Cancela y vuelve a intentarlo después de desbloquear el llavero del sistema.',
'ai.providers.headers.saveError': 'No se pudo guardar de forma segura. Comprueba que el llavero del sistema esté disponible y vuelve a intentarlo.',
// AI Codex
'ai.codex': 'Codex',
@@ -289,14 +280,6 @@ export const esAiMessages: Messages = {
'ai.opencode.customPathPlaceholder': 'p. ej. /usr/local/bin/opencode',
'ai.opencode.check': 'Verificar',
'ai.opencode.resetPath': 'Restablecer',
'ai.opencode.v2.startupTimeout': 'OpenCode 2 no estuvo listo a tiempo. Confirma que `opencode serve` funciona en una terminal y vuelve a intentarlo.',
'ai.opencode.v2.exited': 'OpenCode 2 se detuvo antes de que el chat pudiera iniciarse.',
'ai.opencode.v2.unauthorized': 'OpenCode 2 rechazó la conexión. Vuelve a intentar el chat.',
'ai.opencode.v2.unavailable': 'No se pudo contactar con OpenCode 2. Comprueba que esté instalado y vuelve a intentarlo.',
'ai.opencode.v2.sessionFailed': 'OpenCode 2 no pudo completar este turno.',
'ai.opencode.v2.empty': 'OpenCode 2 devolvió una respuesta vacía. Ejecuta `opencode` en una terminal para configurar la autenticación y los modelos.',
'ai.opencode.v2.contextSkipped': 'OpenCode 2 no pudo aplicar el contexto de la conversación de Netcatty.',
'ai.opencode.v2.mcpSkipped': 'OpenCode 2 no pudo adjuntar una herramienta MCP. El chat continuará sin ella.',
// AI MiMo Code (CLI de Xiaomi MiMo Code — fork de OpenCode)
'ai.mimo.title': 'MiMo Code',
@@ -711,9 +694,6 @@ export const esAiMessages: Messages = {
'terminal.layer.splitHorizontal': 'Dividir arriba y abajo',
'terminal.layer.splitVertical': 'Dividir izquierda y derecha',
'terminal.layer.openInNewSplit': 'Abrir en una división nueva',
'terminal.layer.saveLayoutAsDefault': 'Guardar diseño como predeterminado',
'terminal.layer.layoutSavedAsDefault': 'Diseño guardado como predeterminado',
'terminal.layer.layoutSaveFailed': 'No se pudo guardar el diseño',
'terminal.layer.hostTree.search': 'Buscar hosts...',
'terminal.layer.hostTree.searchButton': 'Buscar',
'terminal.layer.hostTree.tagsButton': 'Filtrar por etiquetas',
-85
View File
@@ -84,32 +84,6 @@ export const esCoreMessages: Messages = {
'credentials.protectionUnavailable.message': 'Las contraseñas y claves guardadas no se pueden descifrar automáticamente en este dispositivo. Vuelve a ingresar las credenciales antes de conectarte.',
'credentials.protectionUnavailable.action': 'Abrir configuración',
// App Lock
'appLock.title': 'Netcatty está bloqueado',
'appLock.reason.default': 'Ingresa tu contraseña de bloqueo para continuar.',
'appLock.reason.startup': 'Netcatty se bloquea cada vez que se abre esta ventana.',
'appLock.reason.idle': 'Netcatty se bloqueó tras el tiempo de inactividad.',
'appLock.reason.manual': 'Netcatty se bloqueó manualmente.',
'appLock.passwordLabel': 'Contraseña de bloqueo',
'appLock.passwordPlaceholder': 'Ingresa la contraseña para desbloquear',
'appLock.unlock': 'Desbloquear',
'appLock.unlocking': 'Desbloqueando...',
'appLock.usePassword': 'Usar contraseña',
'appLock.error.emptyPassword': 'Ingresa la contraseña de bloqueo.',
'appLock.error.incorrectPassword': 'La contraseña de bloqueo es incorrecta.',
'appLock.systemUnlock.unlockWith': 'Desbloquear con {label}',
'appLock.systemUnlock.preparing': 'Preparando {label}...',
'appLock.systemUnlock.verifying': 'Esperando a {label}...',
'appLock.systemUnlock.error': 'No se completó el desbloqueo del sistema. Usa la contraseña de bloqueo para continuar.',
'appLock.logoLabel': 'Netcatty',
'appLock.reset.title': 'Restablecer el bloqueo de la aplicación',
'appLock.reset.description': 'Esto elimina la contraseña de bloqueo actual y desactiva el bloqueo de la aplicación. Crea una nueva contraseña desde Configuración cuando la necesites.',
'appLock.reset.cancel': 'Cancelar',
'appLock.reset.confirm': 'Restablecer el bloqueo de la aplicación',
'appLock.reset.resetting': 'Restableciendo...',
'appLock.reset.error': 'No se pudo restablecer el bloqueo de la aplicación. Inténtalo de nuevo.',
'topTabs.lockApp': 'Bloquear Netcatty',
// Settings shell
'settings.title': 'Configuración',
'settings.tab.application': 'Aplicación',
@@ -145,65 +119,6 @@ export const esCoreMessages: Messages = {
'settings.system.credentials.unavailableHint': 'Las credenciales cifradas en otro perfil de usuario o máquina no se pueden descifrar aquí. Vuelve a ingresar y guarda las credenciales en este dispositivo.',
'settings.system.credentials.portabilityHint': 'La sincronización en la nube es portátil porque usa tu cifrado de clave maestra. El cifrado local de safeStorage está limitado al dispositivo y al usuario.',
// Settings > System > App Lock
'settings.appLock.title': 'Bloqueo de la aplicación',
'settings.appLock.description': 'Solicita una contraseña de bloqueo local cuando Netcatty se abre, tras un periodo de inactividad o cuando lo bloqueas manualmente.',
'settings.appLock.setupTitle': 'Define una contraseña de bloqueo',
'settings.appLock.setupDescription': 'Crea la contraseña a continuación. El bloqueo de la aplicación se activa en cuanto se guarda.',
'settings.appLock.manageTitle': 'El bloqueo de la aplicación está listo',
'settings.appLock.enabledStatus': 'Activado. Netcatty se bloquea al iniciar, tras un periodo de inactividad y cuando lo bloqueas manualmente.',
'settings.appLock.disabledStatus': 'Contraseña guardada, pero el bloqueo de la aplicación está desactivado.',
'settings.appLock.disableTitle': 'Desactivar el bloqueo de la aplicación',
'settings.appLock.disableDescription': 'Ingresa la contraseña de bloqueo actual para dejar de bloquear Netcatty. La contraseña guardada se eliminará; para volver a activar el bloqueo tendrás que crear una nueva.',
'settings.appLock.disable': 'Desactivar el bloqueo de la aplicación',
'settings.appLock.disabling': 'Desactivando...',
'settings.appLock.setupPasswordTitle': 'Crear contraseña de bloqueo',
'settings.appLock.setupPasswordDescription': 'Esta contraseña es necesaria para desbloquear Netcatty y para cambiar o desactivar el bloqueo de la aplicación más adelante.',
'settings.appLock.changePasswordTitle': 'Cambiar la contraseña de bloqueo',
'settings.appLock.changePasswordDescription': 'Ingresa la contraseña de bloqueo actual y luego elige una nueva.',
'settings.appLock.enable': 'Activar el bloqueo de la aplicación',
'settings.appLock.enableDesc': 'Bloquea al iniciar y tras el tiempo de inactividad seleccionado.',
'settings.appLock.timeout': 'Tiempo de inactividad',
'settings.appLock.timeoutDesc': 'Bloquea tras un periodo de inactividad o al cerrar la ventana en la bandeja. Nunca omite ambos casos.',
'settings.appLock.timeout.0': 'Nunca bloquear automáticamente',
'settings.appLock.timeout.1': '1 minuto',
'settings.appLock.timeout.5': '5 minutos',
'settings.appLock.timeout.15': '15 minutos',
'settings.appLock.timeout.30': '30 minutos',
'settings.appLock.timeout.60': '60 minutos',
'settings.appLock.systemUnlock.label': 'Desbloqueo con {label}',
'settings.appLock.systemUnlock.desc': 'Usa {label} como forma alternativa de desbloquear Netcatty en este dispositivo.',
'settings.appLock.systemUnlock.unavailableDesc': '{label} no está disponible en este dispositivo ahora mismo.',
'settings.appLock.systemUnlock.unavailable': 'El desbloqueo del sistema no está disponible en este dispositivo.',
'settings.appLock.systemUnlock.locked': 'Desbloquea Netcatty antes de cambiar el desbloqueo del sistema.',
'settings.appLock.systemUnlock.autoPrompt.label': 'Abrir {label} automáticamente',
'settings.appLock.systemUnlock.autoPrompt.desc': 'Muestra el aviso de {label} automáticamente cuando Netcatty está esperando para desbloquearse.',
'settings.appLock.currentPassword': 'Contraseña actual',
'settings.appLock.currentPasswordPlaceholder': 'Necesaria para desactivar o reemplazar',
'settings.appLock.currentPasswordForDisablePlaceholder': 'Contraseña actual para desactivar el bloqueo de la aplicación',
'settings.appLock.currentPasswordForChangePlaceholder': 'Contraseña actual para cambiarla',
'settings.appLock.newPassword': 'Nueva contraseña',
'settings.appLock.newPasswordPlaceholder': 'Define la contraseña de bloqueo',
'settings.appLock.confirmPassword': 'Confirmar contraseña',
'settings.appLock.confirmPasswordPlaceholder': 'Repite la contraseña',
'settings.appLock.savePassword': 'Definir y activar',
'settings.appLock.savingPassword': 'Guardando...',
'settings.appLock.passwordSet': 'Contraseña configurada',
'settings.appLock.replacePassword': 'Reemplazar contraseña',
'settings.appLock.enableAfterPassword': 'Define una contraseña de bloqueo antes de activar el bloqueo de la aplicación.',
'settings.appLock.localOnlyHint': 'Bloqueo de privacidad local del dispositivo. No se sincroniza y no es una barrera de seguridad del sistema operativo.',
'settings.appLock.validation.currentRequired': 'Ingresa la contraseña de bloqueo actual.',
'settings.appLock.validation.newRequired': 'Ingresa una nueva contraseña de bloqueo.',
'settings.appLock.validation.confirmRequired': 'Confirma la nueva contraseña de bloqueo.',
'settings.appLock.validation.mismatch': 'Las contraseñas nuevas no coinciden.',
'settings.appLock.validation.incorrect': 'La contraseña de bloqueo actual es incorrecta.',
// Settings > System > Auto Launch
'settings.autoLaunch.title': 'Abrir al iniciar sesión',
'settings.autoLaunch.enabled': 'Iniciar al arrancar el sistema',
'settings.autoLaunch.enabledDesc': 'Inicia Netcatty automáticamente al iniciar sesión, oculto en la bandeja del sistema.',
'settings.autoLaunch.unsupportedDesc': 'No disponible en esta plataforma ni en una compilación de desarrollo.',
// Settings > System > Network Proxy
'settings.system.networkProxy.title': 'Proxy de red',
'settings.system.networkProxy.description': 'Proxy HTTP(S) para la sincronización en la nube y los proveedores de IA. Esto es independiente de los perfiles de ProxyJump / ProxyCommand de SSH.',
@@ -33,9 +33,6 @@ export const esSystemManagerMessages: Messages = {
'systemManager.errors.loadPorts': 'No se pudieron cargar los puertos en escucha',
'systemManager.errors.loadServices': 'No se pudieron cargar los servicios de systemd',
'systemManager.errors.sshChannelUnavailable': 'El servidor se negó a abrir un nuevo canal de ejecución. Inténtalo de nuevo más tarde o vuelve a conectar este host.',
'systemManager.errors.nerdctlUnreachable': 'nerdctl no puede conectarse a containerd',
'systemManager.errors.clusterUnreachable': 'No se puede conectar al clúster para el contexto {{context}}',
'systemManager.errors.clusterUnreachableGeneric': 'No se puede conectar al clúster',
'systemManager.unsupportedRemote': 'Este host no permite canales SSH de ejecución adicionales, por lo que el administrador del sistema no está disponible. Abrir este panel ya no desconectará la terminal.',
'systemManager.overview.empty': 'Aún no hay datos de resumen del sistema.',
@@ -215,20 +212,6 @@ export const esSystemManagerMessages: Messages = {
'systemManager.k8s.restarts': '{{count}} reinicios',
'systemManager.k8s.chooseContainer': 'Elegir un contenedor',
'systemManager.k8s.noContext': 'No hay un contexto de Kubernetes en este host',
'systemManager.k8s.status.Running': 'En ejecución',
'systemManager.k8s.status.Pending': 'Pendiente',
'systemManager.k8s.status.Completed': 'Completado',
'systemManager.k8s.status.Succeeded': 'Con éxito',
'systemManager.k8s.status.Failed': 'Fallido',
'systemManager.k8s.status.Unknown': 'Desconocido',
'systemManager.k8s.status.CrashLoopBackOff': 'Bucle de fallos',
'systemManager.k8s.status.ContainerCreating': 'Creando contenedor',
'systemManager.k8s.status.Terminating': 'Terminando',
'systemManager.k8s.status.ImagePullBackOff': 'Reintentando descarga de imagen',
'systemManager.k8s.status.ErrImagePull': 'Error al descargar la imagen',
'systemManager.k8s.status.Error': 'Error',
'systemManager.k8s.status.Evicted': 'Desalojado',
'systemManager.k8s.status.OOMKilled': 'Sin memoria',
'systemManager.ports.unavailable': 'No se detectaron herramientas de puertos en escucha (ss / netstat) en este host.',
'systemManager.ports.loading': 'Cargando puertos en escucha…',
-40
View File
@@ -219,7 +219,6 @@ export const esTerminalMessages: Messages = {
'terminal.progress.timeoutIn': 'Tiempo de espera en {seconds}s',
'terminal.progress.waitingForUserInput': 'Esperando entrada del usuario',
'terminal.progress.disconnected': 'Desconectado',
'terminal.progress.connected': 'Conectado',
'terminal.progress.cancelling': 'Cancelando...',
'terminal.progress.startOver': 'Empezar de nuevo',
'terminal.progress.enterReconnectHint': 'Presiona Enter para reconectar',
@@ -676,45 +675,6 @@ export const esTerminalMessages: Messages = {
'keychain.keyDialog.editTitle': 'Editar clave',
'keychain.keyDialog.editDesc': 'Actualizar esta clave SSH',
'keychain.keyDialog.updateKey': 'Actualizar clave',
'keychain.generate.standardKeys': 'Claves estándar',
'keychain.generate.securityKeys': 'Llaves de seguridad · FIDO2',
'keychain.generate.residentDesc': 'Permite recuperar las credenciales desde esta llave de seguridad.',
'keychain.generate.verifyRequiredDesc': 'Solicita el PIN del dispositivo antes de firmar.',
'keychain.generate.generating': 'Generando…',
'keychain.generate.fidoHint': 'Conecta una llave de seguridad FIDO2. Es posible que se te solicite su PIN o un toque.',
'keychain.generate.resident': 'Guardar las credenciales en la llave',
'keychain.generate.verifyRequired': 'Verificar el PIN cada vez',
'keychain.action.loadResident': 'Cargar claves residentes desde la llave de seguridad',
// FIDO2 security key prompts and recovery states
'fido.prompt.pinTouchDesc': 'Ingresa tu PIN y luego toca la llave para confirmar.',
'fido.prompt.details': 'Detalles de la solicitud',
'fido.prompt.showPin': 'Mostrar PIN',
'fido.prompt.hidePin': 'Ocultar PIN',
'fido.prompt.verifying': 'Verificando…',
'fido.prompt.pinTitle': 'PIN de la llave de seguridad',
'fido.prompt.touchTitle': 'Toca tu llave de seguridad',
'fido.prompt.pinDesc': 'Ingresa el PIN de tu llave de seguridad para continuar.',
'fido.prompt.touchDesc': 'Toca tu llave de seguridad para confirmar tu identidad.',
'fido.prompt.pinLabel': 'PIN',
'fido.prompt.touchWaiting': 'Esperando tu llave…',
'fido.prompt.touchAutomatic': 'Continúa automáticamente después de que la toques',
'fido.prompt.cancelled': 'Se canceló la autenticación con llave de seguridad.',
'fido.prompt.touchDone': 'Ya la toqué',
'fido.prompt.timeout': 'El aviso de FIDO agotó el tiempo de espera. Intenta conectarte de nuevo.',
'fido.recovery.device-unavailable.title': 'Conecta la llave de seguridad correspondiente',
'fido.recovery.device-unavailable.desc': 'Conecta el dispositivo que contiene la credencial de esta llave y vuelve a intentarlo.',
'fido.recovery.timeout.title': 'Se agotó el tiempo de confirmación de la llave de seguridad',
'fido.recovery.timeout.desc': 'La operación del dispositivo se detuvo. Comprueba que tu llave esté conectada, vuelve a intentarlo y sigue el aviso de toque o verificación.',
'fido.recovery.pin-failed.title': 'No se aceptó el PIN de la llave de seguridad',
'fido.recovery.pin-failed.desc': 'Verifica el PIN FIDO2 de este dispositivo antes de reintentar. Varios intentos incorrectos pueden bloquear el dispositivo.',
'fido.recovery.failed.title': 'No se completó la verificación de la llave de seguridad',
'fido.recovery.failed.desc': 'Comprueba que el dispositivo correspondiente esté conectado y admita esta llave. Reintenta o cancela para revisar el dispositivo y la configuración de OpenSSH.',
'fido.recovery.retry': 'Reintentar verificación',
'fido.recovery.usePassword': 'Usar la contraseña configurada',
'fido.recovery.passwordFallback': 'Probando la contraseña configurada para este host',
'fido.error.opensshMissing': 'Se requiere OpenSSH con FIDO/libfido2. En macOS: brew install openssh libfido2.',
'fido.error.agentUnavailable': 'No se pudo iniciar un agente SSH compatible con FIDO.',
// Tabs
'tabs.closeSessionAria': 'Cerrar sesión',
-12
View File
@@ -1044,18 +1044,6 @@ export const esVaultMessages: Messages = {
'hostDetails.addTag': 'Agregar una etiqueta...',
'hostDetails.createTag': 'Crear etiqueta',
'hostDetails.createGroup': 'Crear grupo',
// Host connection test
'hostConnectionTest.title': 'Probar conexión',
'hostConnectionTest.test': 'Probar',
'hostConnectionTest.chainOf': 'Cadena {current} de {total}',
'hostConnectionTest.success': 'Conexión correcta',
'hostConnectionTest.successHint': 'El host aceptó tus credenciales. Puedes cerrar esta ventana.',
'hostConnectionTest.connecting': 'Conectando… tiempo de espera en {seconds}s',
'hostConnectionTest.failed': 'No se pudo conectar.',
'hostConnectionTest.retry': 'Reintentar',
'hostConnectionTest.credentialsUnavailable': 'Las credenciales guardadas no se pueden descifrar en este dispositivo. Vuelve a ingresarlas abajo.',
// Entradas de shell local (CMD) en la bóveda
'localShell.subtitle': 'Shell local',
'localShell.panel.title': 'Nueva shell local',
@@ -1,120 +0,0 @@
import assert from "node:assert/strict";
import test from "node:test";
import { DEFAULT_KEY_BINDINGS } from "../../../domain/models/keyBindings.ts";
import en from "./en.ts";
import es from "./es.ts";
import ru from "./ru.ts";
import zhCN from "./zh-CN.ts";
import zhTW from "./zh-TW.ts";
// Locales that are not at full key parity with `en` yet. They are tracked here
// so the remaining debt stays visible instead of silently rendering in English
// through the `DEFAULT_UI_LOCALE` fallback in `I18nProvider.resolveMessage`.
// Move a locale out of this list once it covers every `en` key.
const LOCALES_PENDING_PARITY = ["ru", "zh-CN", "zh-TW"] as const;
const hasKey = (messages: Record<string, string>, key: string) =>
Object.hasOwn(messages, key);
const interpolationTokens = (value: string) =>
[...value.matchAll(/\{\{\s*\w+\s*\}\}|\{\s*\w+\s*\}/g)]
.map((match) => match[0].replace(/[{}]/g, "").trim())
.sort();
test("es defines every message key that en defines", () => {
const missing = Object.keys(en).filter((key) => !hasKey(es, key));
assert.deepEqual(
missing,
[],
`es is missing ${missing.length} message keys`,
);
});
test("es defines no message keys that en does not define", () => {
const extra = Object.keys(es).filter((key) => !hasKey(en, key));
assert.deepEqual(
extra,
[],
`es defines ${extra.length} keys that no longer exist in en`,
);
});
test("es keeps the same interpolation placeholders as en", () => {
const mismatched: string[] = [];
for (const key of Object.keys(es)) {
if (!hasKey(en, key)) continue;
const expected = interpolationTokens(en[key] ?? "").join(",");
const actual = interpolationTokens(es[key] ?? "").join(",");
if (expected !== actual) mismatched.push(key);
}
assert.deepEqual(
mismatched,
[],
`es changes interpolation placeholders for ${mismatched.length} keys`,
);
});
test("every default shortcut has a label in en and es", () => {
const locales = [
{ name: "en", messages: en },
{ name: "es", messages: es },
];
for (const locale of locales) {
const missing = DEFAULT_KEY_BINDINGS.map(
(binding) => `settings.shortcuts.binding.${binding.id}`,
).filter((key) => !hasKey(locale.messages, key));
assert.deepEqual(
missing,
[],
`${locale.name} is missing ${missing.length} shortcut labels`,
);
}
});
test("en shortcut labels stay identical to DEFAULT_KEY_BINDINGS", () => {
// SettingsShortcutsTab falls back to `binding.label` only while the locale
// resolves the key to itself. Once `en` defines the key, the locale becomes
// the rendered source, so the two must not drift apart.
for (const binding of DEFAULT_KEY_BINDINGS) {
const key = `settings.shortcuts.binding.${binding.id}`;
assert.equal(
en[key],
binding.label,
`en label drifted from DEFAULT_KEY_BINDINGS for ${binding.id}`,
);
}
});
test("every locale outside the pending list is at full parity with en", () => {
const locales = [
{ name: "es", messages: es },
{ name: "ru", messages: ru },
{ name: "zh-CN", messages: zhCN },
{ name: "zh-TW", messages: zhTW },
];
for (const locale of locales) {
if ((LOCALES_PENDING_PARITY as readonly string[]).includes(locale.name)) {
continue;
}
const missing = Object.keys(en).filter(
(key) => !hasKey(locale.messages, key),
);
assert.deepEqual(
missing,
[],
`${locale.name} is missing ${missing.length} message keys`,
);
}
});
@@ -18,19 +18,12 @@ const LOCALIZED_SETTINGS_LOCALES = [
const APP_LOCK_LOCALES = [
{ name: "en", messages: en },
{ name: "es", messages: es },
{ name: "ru", messages: ru },
{ name: "zh-CN", messages: zhCN },
{ name: "zh-TW", messages: zhTW },
];
const ALL_SETTINGS_LOCALES = [
{ name: "en", messages: en },
...LOCALIZED_SETTINGS_LOCALES,
{ name: "ru", messages: ru },
];
test("localized settings include names for every default shortcut", () => {
for (const locale of ALL_SETTINGS_LOCALES) {
for (const locale of LOCALIZED_SETTINGS_LOCALES) {
const missing = DEFAULT_KEY_BINDINGS
.map((binding) => `settings.shortcuts.binding.${binding.id}`)
.filter((key) => !locale.messages[key]);
-255
View File
@@ -1,255 +0,0 @@
import assert from "node:assert/strict";
import test from "node:test";
import { decideHiddenTabKeepCountAction } from "../../domain/terminalHibernate.ts";
import { retainLeavingTabAfterEviction } from "./hiddenTabEviction.ts";
import { terminalHiddenRendererStore } from "./terminalHiddenRendererStore.ts";
test("a refused eviction tries the next candidate, then full-hibernates", async () => {
const attempts: string[] = [];
const outcomes: string[] = [];
await retainLeavingTabAfterEviction({
sessionId: "leaving",
candidates: ["images", "plain"],
keepCount: 2,
stillWantsRetention: () => true,
softHide: () => outcomes.push("soft-hide"),
fullHibernate: () => outcomes.push("full-hibernate"),
evict: async (sessionId) => {
attempts.push(sessionId);
return sessionId === "plain";
},
});
assert.deepEqual(attempts, ["images", "plain"]);
assert.deepEqual(outcomes, ["soft-hide"]);
const blocked: string[] = [];
const blockedOutcomes: string[] = [];
await retainLeavingTabAfterEviction({
sessionId: "leaving",
candidates: ["images", "also-images"],
keepCount: 2,
stillWantsRetention: () => true,
softHide: () => blockedOutcomes.push("soft-hide"),
fullHibernate: () => blockedOutcomes.push("full-hibernate"),
evict: async (sessionId) => {
blocked.push(sessionId);
return false;
},
});
assert.deepEqual(blocked, ["images", "also-images"]);
assert.deepEqual(blockedOutcomes, ["full-hibernate"]);
assert.equal(terminalHiddenRendererStore.getSoftHiddenOccupancy(), 0);
});
test("hibernate failure after the soft-hidden marker drops does not retain the leaving tab", async () => {
const attempts: string[] = [];
const outcomes: string[] = [];
await retainLeavingTabAfterEviction({
sessionId: "leaving",
candidates: ["victim"],
keepCount: 2,
stillWantsRetention: () => true,
softHide: () => outcomes.push("soft-hide"),
fullHibernate: () => outcomes.push("full-hibernate"),
evict: async (sessionId) => {
attempts.push(sessionId);
await Promise.resolve();
return false;
},
});
assert.deepEqual(attempts, ["victim"]);
assert.deepEqual(outcomes, ["full-hibernate"]);
const recovered: string[] = [];
const recoveredOutcomes: string[] = [];
await retainLeavingTabAfterEviction({
sessionId: "leaving",
candidates: ["victim", "next"],
keepCount: 2,
stillWantsRetention: () => true,
softHide: () => recoveredOutcomes.push("soft-hide"),
fullHibernate: () => recoveredOutcomes.push("full-hibernate"),
evict: async (sessionId) => {
recovered.push(sessionId);
await Promise.resolve();
return sessionId === "next";
},
});
assert.deepEqual(recovered, ["victim", "next"]);
assert.deepEqual(recoveredOutcomes, ["soft-hide"]);
});
test("a live keep count that still fits after eviction soft-hides the leaving tab", async () => {
let keepCount = 0;
const outcomes: string[] = [];
await retainLeavingTabAfterEviction({
sessionId: "leaving",
candidates: ["victim"],
keepCount,
resolveKeepCount: () => keepCount,
stillWantsRetention: () => true,
softHide: () => outcomes.push("soft-hide"),
fullHibernate: () => outcomes.push("full-hibernate"),
evict: async () => {
keepCount = 2;
await Promise.resolve();
return true;
},
});
assert.deepEqual(outcomes, ["soft-hide"]);
assert.equal(terminalHiddenRendererStore.getSoftHiddenOccupancy(), 0);
});
test("a keep count dropped to zero during eviction full-hibernates the leaving tab", async () => {
let keepCount = 2;
const outcomes: string[] = [];
await retainLeavingTabAfterEviction({
sessionId: "leaving",
candidates: ["victim"],
keepCount,
resolveKeepCount: () => keepCount,
stillWantsRetention: () => true,
softHide: () => outcomes.push("soft-hide"),
fullHibernate: () => outcomes.push("full-hibernate"),
evict: async () => {
keepCount = 0;
await Promise.resolve();
return true;
},
});
assert.deepEqual(outcomes, ["full-hibernate"]);
assert.equal(terminalHiddenRendererStore.getSoftHiddenOccupancy(), 0);
});
test("a keep count that no longer fits after eviction full-hibernates the leaving tab", async () => {
const sessions = ["victim", "other", "leaving"];
for (const sessionId of sessions) terminalHiddenRendererStore.clearSoftHidden(sessionId);
terminalHiddenRendererStore.markSoftHidden("other");
let keepCount = 2;
const outcomes: string[] = [];
try {
await retainLeavingTabAfterEviction({
sessionId: "leaving",
candidates: ["victim"],
keepCount,
resolveKeepCount: () => keepCount,
stillWantsRetention: () => true,
softHide: () => outcomes.push("soft-hide"),
fullHibernate: () => outcomes.push("full-hibernate"),
evict: async () => {
keepCount = 1;
await Promise.resolve();
return true;
},
});
assert.deepEqual(outcomes, ["full-hibernate"]);
assert.equal(terminalHiddenRendererStore.isSoftHidden("leaving"), false);
} finally {
for (const sessionId of sessions) terminalHiddenRendererStore.clearSoftHidden(sessionId);
}
});
test("a leaving tab that no longer wants retention is not soft-hidden or hibernated", async () => {
const outcomes: string[] = [];
await retainLeavingTabAfterEviction({
sessionId: "leaving",
candidates: ["victim"],
keepCount: 2,
stillWantsRetention: () => false,
softHide: () => outcomes.push("soft-hide"),
fullHibernate: () => outcomes.push("full-hibernate"),
evict: async () => true,
});
assert.deepEqual(outcomes, []);
assert.equal(terminalHiddenRendererStore.getSoftHiddenOccupancy(), 0);
});
test("a pending reservation keeps a second tab from seeing spare capacity", async () => {
const sessions = ["victim", "other", "leaving"];
for (const sessionId of sessions) terminalHiddenRendererStore.clearSoftHidden(sessionId);
terminalHiddenRendererStore.markSoftHidden("victim");
terminalHiddenRendererStore.markSoftHidden("other");
const unsubscribe = terminalHiddenRendererStore.subscribe(() => {
if (!terminalHiddenRendererStore.consumeEvictionRequest("victim")) return;
terminalHiddenRendererStore.clearSoftHidden("victim");
});
const outcomes: string[] = [];
const pending = retainLeavingTabAfterEviction({
sessionId: "leaving",
candidates: ["victim"],
keepCount: 2,
stillWantsRetention: () => true,
softHide: () => {
terminalHiddenRendererStore.markSoftHidden("leaving");
outcomes.push("soft-hide");
},
fullHibernate: () => outcomes.push("full-hibernate"),
evict: (sessionId) => terminalHiddenRendererStore.evictSoftHidden(sessionId),
});
try {
assert.equal(terminalHiddenRendererStore.isSoftHidden("victim"), false);
assert.equal(terminalHiddenRendererStore.getSoftHiddenCount(), 1);
assert.equal(terminalHiddenRendererStore.getSoftHiddenOccupancy(), 2);
assert.deepEqual(decideHiddenTabKeepCountAction({
keepCount: 2,
softHiddenCount: terminalHiddenRendererStore.getSoftHiddenOccupancy(),
sessionId: "second",
candidatesOldestFirst: terminalHiddenRendererStore.listSoftHiddenOldestFirst(),
}), { action: "await-eviction", candidates: ["other"] });
terminalHiddenRendererStore.settleEviction("victim", true);
await pending;
assert.deepEqual(outcomes, ["soft-hide"]);
assert.equal(terminalHiddenRendererStore.getSoftHiddenCount(), 2);
assert.equal(terminalHiddenRendererStore.getSoftHiddenOccupancy(), 2);
} finally {
unsubscribe();
terminalHiddenRendererStore.settleEviction("victim", false);
for (const sessionId of sessions) terminalHiddenRendererStore.clearSoftHidden(sessionId);
await pending.catch(() => undefined);
}
});
test("two leaving tabs share one freed slot and stay within keep-count", async () => {
const sessions = ["victim", "other", "leaving-a", "leaving-b"];
for (const sessionId of sessions) terminalHiddenRendererStore.clearSoftHidden(sessionId);
terminalHiddenRendererStore.markSoftHidden("victim");
terminalHiddenRendererStore.markSoftHidden("other");
const unsubscribe = terminalHiddenRendererStore.subscribe(() => {
if (!terminalHiddenRendererStore.consumeEvictionRequest("victim")) return;
terminalHiddenRendererStore.clearSoftHidden("victim");
queueMicrotask(() => {
terminalHiddenRendererStore.settleEviction("victim", true);
});
});
const retained: string[] = [];
const hibernated: string[] = [];
const retain = (sessionId: string) => retainLeavingTabAfterEviction({
sessionId,
candidates: ["victim"],
keepCount: 2,
stillWantsRetention: () => true,
softHide: () => {
terminalHiddenRendererStore.markSoftHidden(sessionId);
retained.push(sessionId);
},
fullHibernate: () => hibernated.push(sessionId),
});
try {
await Promise.all([retain("leaving-a"), retain("leaving-b")]);
assert.equal(retained.length, 1);
assert.equal(hibernated.length, 1);
assert.equal(terminalHiddenRendererStore.getSoftHiddenCount(), 2);
assert.equal(terminalHiddenRendererStore.getSoftHiddenOccupancy(), 2);
} finally {
unsubscribe();
for (const sessionId of sessions) terminalHiddenRendererStore.clearSoftHidden(sessionId);
}
});
-53
View File
@@ -1,53 +0,0 @@
import { terminalHiddenRendererStore } from "./terminalHiddenRendererStore.ts";
/**
* Walk keep-count victims until one hibernate completes, then retain the
* leaving tab only if its reserved slot still fits.
*
* The reservation is taken before the first await. Clearing a victim marker
* during wake would otherwise look like spare capacity to a second hidden-tab
* timer, and both tabs would soft-hide once the same eviction resolved.
*/
export async function retainLeavingTabAfterEviction(input: {
sessionId: string;
candidates: readonly string[];
/** Count captured when this leave started. Used only if resolveKeepCount is absent. */
keepCount: number;
/**
* Live keep-count read after eviction settles. Settings can change while the
* victim drains or serializes; retaining against the captured count would
* soft-hide a tab the user no longer wants kept.
*/
resolveKeepCount?: () => number;
stillWantsRetention: () => boolean;
softHide: () => void;
fullHibernate: () => void;
evict?: (sessionId: string) => Promise<boolean> | boolean;
}): Promise<void> {
const token = terminalHiddenRendererStore.reserveLeavingSlot(input.sessionId);
try {
const evict = input.evict ?? ((sessionId: string) => (
terminalHiddenRendererStore.evictSoftHidden(sessionId)
));
let completed = false;
for (const candidate of input.candidates) {
if (!candidate || candidate === input.sessionId) continue;
if (await evict(candidate)) {
completed = true;
break;
}
}
if (!input.stillWantsRetention()) return;
const keepCount = input.resolveKeepCount?.() ?? input.keepCount;
if (
completed
&& terminalHiddenRendererStore.leavingSlotStillFits(input.sessionId, keepCount)
) {
input.softHide();
return;
}
input.fullHibernate();
} finally {
terminalHiddenRendererStore.releaseLeavingSlot(input.sessionId, token);
}
}
@@ -9,106 +9,6 @@ test("terminalHiddenRendererStore evicts oldest soft-hidden session", () => {
terminalHiddenRendererStore.markSoftHidden("a");
terminalHiddenRendererStore.markSoftHidden("b");
assert.equal(terminalHiddenRendererStore.pickEvictionCandidate(2), "a");
assert.deepEqual(terminalHiddenRendererStore.listSoftHiddenOldestFirst(), ["a", "b"]);
terminalHiddenRendererStore.clearSoftHidden("a");
terminalHiddenRendererStore.clearSoftHidden("b");
});
test("evictSoftHidden reports refusal and drops an unconsumed request", async () => {
const sessions = ["images", "plain", "other"];
for (const sessionId of sessions) terminalHiddenRendererStore.clearSoftHidden(sessionId);
terminalHiddenRendererStore.markSoftHidden("images");
terminalHiddenRendererStore.markSoftHidden("plain");
const unsubscribe = terminalHiddenRendererStore.subscribe(() => {
if (terminalHiddenRendererStore.consumeEvictionRequest("images")) return;
if (terminalHiddenRendererStore.consumeEvictionRequest("plain")) {
terminalHiddenRendererStore.clearSoftHidden("plain");
terminalHiddenRendererStore.settleEviction("plain", true);
}
});
try {
assert.equal(await terminalHiddenRendererStore.evictSoftHidden("images"), false);
assert.equal(terminalHiddenRendererStore.isSoftHidden("images"), true);
assert.equal(await terminalHiddenRendererStore.evictSoftHidden("plain"), true);
assert.equal(terminalHiddenRendererStore.isSoftHidden("plain"), false);
} finally {
unsubscribe();
for (const sessionId of sessions) terminalHiddenRendererStore.clearSoftHidden(sessionId);
}
terminalHiddenRendererStore.markSoftHidden("images");
assert.equal(await terminalHiddenRendererStore.evictSoftHidden("images"), false);
const delivered: string[] = [];
const later = terminalHiddenRendererStore.subscribe(() => {
if (terminalHiddenRendererStore.consumeEvictionRequest("images")) delivered.push("images");
});
try {
terminalHiddenRendererStore.markSoftHidden("other");
assert.deepEqual(delivered, []);
assert.equal(terminalHiddenRendererStore.isSoftHidden("images"), true);
} finally {
later();
for (const sessionId of sessions) terminalHiddenRendererStore.clearSoftHidden(sessionId);
}
});
test("a reserved leaving slot counts toward occupancy until it is released", () => {
const sessions = ["a", "b", "c", "d"];
for (const sessionId of sessions) terminalHiddenRendererStore.clearSoftHidden(sessionId);
terminalHiddenRendererStore.markSoftHidden("a");
terminalHiddenRendererStore.markSoftHidden("b");
assert.equal(terminalHiddenRendererStore.getSoftHiddenOccupancy(), 2);
const alreadyHidden = terminalHiddenRendererStore.reserveLeavingSlot("a");
assert.equal(terminalHiddenRendererStore.getSoftHiddenOccupancy(), 2);
terminalHiddenRendererStore.releaseLeavingSlot("a", alreadyHidden);
const tokenC = terminalHiddenRendererStore.reserveLeavingSlot("c");
assert.equal(terminalHiddenRendererStore.getSoftHiddenOccupancy(), 3);
assert.equal(terminalHiddenRendererStore.leavingSlotStillFits("c", 2), false);
terminalHiddenRendererStore.clearSoftHidden("a");
assert.equal(terminalHiddenRendererStore.getSoftHiddenOccupancy(), 2);
assert.equal(terminalHiddenRendererStore.leavingSlotStillFits("c", 2), true);
const tokenD = terminalHiddenRendererStore.reserveLeavingSlot("d");
assert.equal(terminalHiddenRendererStore.getSoftHiddenOccupancy(), 3);
assert.equal(terminalHiddenRendererStore.leavingSlotStillFits("c", 2), false);
assert.equal(terminalHiddenRendererStore.leavingSlotStillFits("d", 2), false);
terminalHiddenRendererStore.releaseLeavingSlot("c", tokenD);
assert.equal(terminalHiddenRendererStore.getSoftHiddenOccupancy(), 3);
terminalHiddenRendererStore.releaseLeavingSlot("c", tokenC);
assert.equal(terminalHiddenRendererStore.getSoftHiddenOccupancy(), 2);
terminalHiddenRendererStore.releaseLeavingSlot("d", tokenD);
assert.equal(terminalHiddenRendererStore.getSoftHiddenOccupancy(), 1);
for (const sessionId of sessions) terminalHiddenRendererStore.clearSoftHidden(sessionId);
assert.equal(terminalHiddenRendererStore.getSoftHiddenOccupancy(), 0);
});
test("evictSoftHidden waits for hibernate after the marker is cleared", async () => {
terminalHiddenRendererStore.clearSoftHidden("victim");
terminalHiddenRendererStore.markSoftHidden("victim");
let markerClearedBeforeSettle = false;
const unsubscribe = terminalHiddenRendererStore.subscribe(() => {
if (!terminalHiddenRendererStore.consumeEvictionRequest("victim")) return;
terminalHiddenRendererStore.clearSoftHidden("victim");
markerClearedBeforeSettle = !terminalHiddenRendererStore.isSoftHidden("victim");
queueMicrotask(() => {
terminalHiddenRendererStore.markSoftHidden("victim");
terminalHiddenRendererStore.settleEviction("victim", false);
});
});
try {
const completed = await terminalHiddenRendererStore.evictSoftHidden("victim");
assert.equal(markerClearedBeforeSettle, true);
assert.equal(completed, false);
assert.equal(terminalHiddenRendererStore.isSoftHidden("victim"), true);
} finally {
unsubscribe();
terminalHiddenRendererStore.clearSoftHidden("victim");
}
});
@@ -3,85 +3,14 @@ type Listener = () => void;
const softHiddenSessions = new Map<string, number>();
const listeners = new Set<Listener>();
let evictionRequestSessionId: string | null = null;
/** Leaving tabs that have claimed a keep slot while a victim hibernate is in flight. */
const reservedLeavingSlots = new Map<string, number>();
let reservationToken = 0;
function reservedLeavingCount(): number {
let extra = 0;
for (const sessionId of reservedLeavingSlots.keys()) {
if (!softHiddenSessions.has(sessionId)) extra += 1;
}
return extra;
}
type EvictionWaiter = {
promise: Promise<boolean>;
resolve: (completed: boolean) => void;
settled: boolean;
};
const evictionWaiters = new Map<string, EvictionWaiter>();
function finishEviction(sessionId: string, completed: boolean): void {
const waiter = evictionWaiters.get(sessionId);
if (!waiter || waiter.settled) return;
waiter.settled = true;
evictionWaiters.delete(sessionId);
if (evictionRequestSessionId === sessionId) evictionRequestSessionId = null;
waiter.resolve(completed);
}
function emit(): void {
listeners.forEach((listener) => listener());
}
function listSoftHiddenOldestFirst(): string[] {
return [...softHiddenSessions.entries()]
.sort((left, right) => left[1] - right[1])
.map(([sessionId]) => sessionId);
}
export const terminalHiddenRendererStore = {
getSoftHiddenCount: () => softHiddenSessions.size,
/**
* Soft-hidden markers plus leaving tabs that reserved a slot and are not
* yet in the marker set. Keep-count decisions use this so a second timer
* cannot take the slot a pending eviction already claimed.
*/
getSoftHiddenOccupancy: () => softHiddenSessions.size + reservedLeavingCount(),
/**
* Claim one keep slot for a leaving tab before awaiting eviction.
* A session already in the marker set is not counted twice. A later claim
* for the same id replaces the token; only that token can release it.
*/
reserveLeavingSlot: (sessionId: string): number => {
const token = ++reservationToken;
if (sessionId && !softHiddenSessions.has(sessionId)) {
reservedLeavingSlots.set(sessionId, token);
}
return token;
},
releaseLeavingSlot: (sessionId: string, token: number) => {
if (reservedLeavingSlots.get(sessionId) === token) {
reservedLeavingSlots.delete(sessionId);
}
},
/**
* True when retaining this leaving tab would stay within keepCount.
* Its own reservation is not counted against the slots held by others.
*/
leavingSlotStillFits: (sessionId: string, keepCount: number): boolean => {
if (!(keepCount > 0)) return false;
const reservedHere = reservedLeavingSlots.has(sessionId) && !softHiddenSessions.has(sessionId);
const others = softHiddenSessions.size + reservedLeavingCount() - (reservedHere ? 1 : 0);
return others < keepCount;
},
isSoftHidden: (sessionId: string) => softHiddenSessions.has(sessionId),
markSoftHidden: (sessionId: string) => {
@@ -94,12 +23,18 @@ export const terminalHiddenRendererStore = {
emit();
},
listSoftHiddenOldestFirst: (): string[] => listSoftHiddenOldestFirst(),
/** Returns the oldest soft-hidden session id when over the keep limit. */
pickEvictionCandidate: (keepCount: number): string | null => {
if (softHiddenSessions.size < keepCount) return null;
return listSoftHiddenOldestFirst()[0] ?? null;
let oldestId: string | null = null;
let oldestTs = Number.POSITIVE_INFINITY;
for (const [sessionId, ts] of softHiddenSessions) {
if (ts < oldestTs) {
oldestTs = ts;
oldestId = sessionId;
}
}
return oldestId;
},
requestEviction: (sessionId: string) => {
@@ -107,44 +42,6 @@ export const terminalHiddenRendererStore = {
emit();
},
/** True while this session's hibernate has been requested and not settled. */
hasPendingEviction: (sessionId: string): boolean => {
const waiter = evictionWaiters.get(sessionId);
return waiter != null && !waiter.settled;
},
/**
* Ask one soft-hidden session to upgrade. Resolves true only when
* settleEviction reports that hibernation finished. Clearing the marker
* during wake is not success: drain, snapshot, or alternate-screen failure
* can still restore that renderer. A synchronous refusal (marker still
* present, including inline images) resolves false and drops an unconsumed
* request so a later emit cannot upgrade it.
*/
evictSoftHidden: (sessionId: string): Promise<boolean> => {
const existing = evictionWaiters.get(sessionId);
if (existing) return existing.promise;
if (!softHiddenSessions.has(sessionId)) return Promise.resolve(false);
let resolvePromise!: (completed: boolean) => void;
const promise = new Promise<boolean>((resolve) => {
resolvePromise = resolve;
});
const waiter: EvictionWaiter = { promise, resolve: resolvePromise, settled: false };
evictionWaiters.set(sessionId, waiter);
evictionRequestSessionId = sessionId;
emit();
if (!waiter.settled && softHiddenSessions.has(sessionId)) {
finishEviction(sessionId, false);
}
return promise;
},
/** Record whether the in-flight eviction's hibernate actually completed. */
settleEviction: (sessionId: string, completed: boolean) => {
finishEviction(sessionId, completed);
},
consumeEvictionRequest: (sessionId: string): boolean => {
if (evictionRequestSessionId !== sessionId) return false;
evictionRequestSessionId = null;
@@ -279,82 +279,6 @@ test('a pending side-panel write survives an urgent render on another tab', asyn
}
});
test('a pending tool switch does not leave another tab ref behind an urgent open', async () => {
const dom = new JSDOM('<!doctype html><html><body></body></html>', { url: 'http://localhost' });
const previousWindow = globalThis.window;
const previousDocument = globalThis.document;
Object.defineProperty(globalThis, 'window', { configurable: true, value: dom.window });
Object.defineProperty(globalThis, 'document', { configurable: true, value: dom.window.document });
const actEnvironment = globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean };
const previousActEnvironment = actEnvironment.IS_REACT_ACT_ENVIRONMENT;
actEnvironment.IS_REACT_ACT_ENVIRONMENT = true;
let state: ReturnType<typeof useTerminalSidePanelLayoutState> | null = null;
const Probe = () => {
state = useTerminalSidePanelLayoutState();
return null;
};
const container = document.createElement('div');
document.body.appendChild(container);
const root: Root = createRoot(container);
const focusedTool = (tabId: string, fromRef = false) => {
const layout = fromRef
? state?.sidePanelLayoutsRef.current.get(tabId)
: state?.sidePanelLayouts.get(tabId);
if (!layout) return null;
return collectSidePanelPanes(layout.root).find((pane) => pane.id === layout.focusedPaneId)?.tool ?? null;
};
try {
await act(async () => {
root.render(React.createElement(Probe));
});
await act(async () => {
state?.selectSidePanelToolForTab('terminal-a', 'notes');
state?.selectSidePanelToolForTab('terminal-b', 'scripts');
});
await act(async () => {
startTransition(() => {
state?.selectSidePanelToolForTab('terminal-a', 'history');
});
flushSync(() => {
state?.setSidePanelOpenTabs((previous) => new Map(previous).set('terminal-b', 'browser'));
});
});
assert.equal(state!.sidePanelOpenTabs.get('terminal-a'), 'history');
assert.equal(state!.sidePanelOpenTabs.get('terminal-b'), 'browser');
assert.equal(state!.sidePanelOpenTabsRef.current.get('terminal-a'), 'history');
assert.equal(
state!.sidePanelOpenTabsRef.current.get('terminal-b'),
'browser',
'B stays rendered as browser while its ref still reports the pre-queue tool',
);
assert.equal(focusedTool('terminal-b', true), 'browser');
// handleSwitchSidePanelTab returns before select when the ref already
// matches the clicked tool. A stale scripts ref would ignore this click.
assert.notEqual(state!.sidePanelOpenTabsRef.current.get('terminal-b'), 'scripts');
await act(async () => {
state?.selectSidePanelToolForTab('terminal-b', 'scripts');
});
assert.equal(state!.sidePanelOpenTabs.get('terminal-b'), 'scripts');
assert.equal(state!.sidePanelOpenTabsRef.current.get('terminal-b'), 'scripts');
assert.equal(focusedTool('terminal-b'), 'scripts');
assert.equal(state!.sidePanelOpenTabs.get('terminal-a'), 'history');
} finally {
await act(async () => {
root.unmount();
});
actEnvironment.IS_REACT_ACT_ENVIRONMENT = previousActEnvironment;
Object.defineProperty(globalThis, 'window', { configurable: true, value: previousWindow });
Object.defineProperty(globalThis, 'document', { configurable: true, value: previousDocument });
dom.window.close();
}
});
test('side panel icon clicks write the tool through selectSidePanelToolForTab', () => {
const layerSource = readFileSync(new URL('../../components/TerminalLayer.tsx', import.meta.url), 'utf8');
const start = layerSource.indexOf('const handleSwitchSidePanelTab = useCallback');
@@ -27,64 +27,6 @@ function deleteMapEntry<T>(previous: Map<string, T>, key: string): Map<string, T
return next;
}
function entryMatches<T>(left: Map<string, T>, right: Map<string, T>, key: string): boolean {
return left.has(key) === right.has(key) && left.get(key) === right.get(key);
}
/**
* Keep a ref aligned with committed state without dropping a write React has
* not rendered yet. An urgent update can rebase onto a transition: the
* transition updater stores an intermediate map in the ref, and the later
* updater changes only state. Copy committed entries the pending map did not
* change, and follow committed once it moves past that pending value.
*/
function syncTrackedMap<T>(
pending: Map<string, T>,
lastRendered: Map<string, T>,
committed: Map<string, T>,
): Map<string, T> {
if (pending === lastRendered) return committed;
if (pending === committed) return pending;
let changed = false;
const merged = new Map(pending);
for (const key of committed.keys()) {
if (entryMatches(pending, committed, key)) continue;
const pendingMatchesRendered = entryMatches(pending, lastRendered, key);
const committedMatchesRendered = entryMatches(committed, lastRendered, key);
if (pendingMatchesRendered || !committedMatchesRendered) {
merged.set(key, committed.get(key) as T);
changed = true;
}
}
for (const key of pending.keys()) {
if (committed.has(key)) continue;
if (entryMatches(pending, lastRendered, key)) {
merged.delete(key);
changed = true;
}
}
return changed ? merged : pending;
}
function publishQueuedMap<T>(
live: Map<string, T>,
capturedBase: Map<string, T>,
previous: Map<string, T>,
stateNext: Map<string, T>,
tabId: string,
value: T | null,
): Map<string, T> {
// The captured map is the synchronous write. Replaying it after an
// intervening render drops entries that render already merged. When the ref
// has moved ahead of this updater's base, reapply only this tab.
if (live === capturedBase || live === previous) return stateNext;
return value === null ? deleteMapEntry(live, tabId) : replaceMapEntry(live, tabId, value);
}
function reconcileSidePanelLayouts(
layouts: Map<string, SidePanelLayout>,
openTabs: Map<string, SidePanelTool>,
@@ -120,22 +62,17 @@ export function useTerminalSidePanelLayoutState() {
const sidePanelLayoutsRef = useRef(sidePanelLayouts);
// Last maps this hook rendered. A transition can update the refs before React
// commits them; an urgent render in between must not copy the stale state
// back over that pending write. Once the queue commits, entries the pending
// write did not touch still have to catch up, or the ref stays on the
// transition updater's intermediate map.
// back over that pending write. Functional updates still merge onto queued
// state when the ref and the committed map have diverged.
const renderedOpenTabsRef = useRef(sidePanelOpenTabs);
const renderedLayoutsRef = useRef(sidePanelLayouts);
sidePanelOpenTabsRef.current = syncTrackedMap(
sidePanelOpenTabsRef.current,
renderedOpenTabsRef.current,
sidePanelOpenTabs,
);
if (sidePanelOpenTabsRef.current === renderedOpenTabsRef.current) {
sidePanelOpenTabsRef.current = sidePanelOpenTabs;
}
renderedOpenTabsRef.current = sidePanelOpenTabs;
sidePanelLayoutsRef.current = syncTrackedMap(
sidePanelLayoutsRef.current,
renderedLayoutsRef.current,
sidePanelLayouts,
);
if (sidePanelLayoutsRef.current === renderedLayoutsRef.current) {
sidePanelLayoutsRef.current = sidePanelLayouts;
}
renderedLayoutsRef.current = sidePanelLayouts;
// Legacy and external open paths write the focused-tool map. Reconcile that
@@ -180,28 +117,14 @@ export function useTerminalSidePanelLayoutState() {
const next = previous === layoutBase
? layouts
: (openTab === null ? deleteMapEntry(previous, tabId) : replaceMapEntry(previous, tabId, layout));
sidePanelLayoutsRef.current = publishQueuedMap(
sidePanelLayoutsRef.current,
layoutBase,
previous,
next,
tabId,
openTab === null ? null : layout,
);
sidePanelLayoutsRef.current = next;
return next;
});
setSidePanelOpenTabs((previous) => {
const next = previous === openTabBase
? openTabs
: (openTab === null ? deleteMapEntry(previous, tabId) : replaceMapEntry(previous, tabId, openTab));
sidePanelOpenTabsRef.current = publishQueuedMap(
sidePanelOpenTabsRef.current,
openTabBase,
previous,
next,
tabId,
openTab,
);
sidePanelOpenTabsRef.current = next;
return next;
});
}, []);
-11
View File
@@ -1,14 +1,3 @@
!macro customInit
; #3946: external MCP hosts launch Netcatty.exe with ELECTRON_RUN_AS_NODE=1
; via netcatty-external-mcp.cmd. Those processes can outlive the host UI as
; orphans and keep $INSTDIR locked, so the in-app updater hangs (AppHangB1).
; Force-kill leftover Netcatty.exe before files are replaced. Do not pass /T:
; electron-updater spawns this installer as a child of the UI Netcatty.exe,
; and /T would terminate the installer itself. Ignore a missing process.
nsExec::ExecToLog "taskkill /F /IM Netcatty.exe"
Pop $0
!macroend
!macro customInstall
; Open a selected folder in a new Netcatty local terminal.
; Args go after `--` so Electron/Chromium does not swallow them. Use
+1 -2
View File
@@ -523,8 +523,7 @@ const SettingsPageContent: React.FC<{ settings: SettingsState; appLock?: AppLock
</TabsList>
</div>
{/* min-w-0: row flex item must shrink so long Agent paths truncate inside the pane. */}
<div className="flex-1 h-full flex flex-col min-h-0 min-w-0 bg-muted/10">
<div className="flex-1 h-full flex flex-col min-h-0 bg-muted/10">
{mountedTabs.has("application") && (
<SettingsLazyTab value="application">
<LazySettingsApplicationTab
+22 -82
View File
@@ -243,7 +243,6 @@ import {
import {
canHibernateTerminalRuntimeSession,
canHibernateTerminalRuntimeStatus,
decideHiddenTabKeepCountAction,
isTerminalFileTransferActive,
resolveHibernateKeepRendererCount,
resolveHibernatePreferWasmSerialize,
@@ -255,7 +254,6 @@ import {
} from "../domain/terminalHibernate";
import { getLastChar, removeLastChar } from "../domain/serialCharMetrics";
import { stringCellWidth } from "./terminal/autocomplete/terminalStringCellWidth";
import { retainLeavingTabAfterEviction } from "../application/state/hiddenTabEviction";
import { terminalHiddenRendererStore } from "../application/state/terminalHiddenRendererStore";
import {
wakeTerminalFromHibernate,
@@ -524,7 +522,6 @@ const TerminalComponent: React.FC<TerminalProps> = ({
const hibernateAlternateScreenRef = useRef(false);
const hibernateRetryTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const fullHibernateRuntimeRef = useRef<(() => Promise<boolean>) | null>(null);
const hibernateEvictionAttemptRef = useRef(0);
const wakeSoftHiddenRuntimeRef = useRef<(() => void) | null>(null);
const resumeRendererAfterCancelledHibernateUpgradeRef = useRef<(() => void) | null>(null);
const wakeInProgressRef = useRef(false);
@@ -2300,6 +2297,20 @@ const TerminalComponent: React.FC<TerminalProps> = ({
]);
fullHibernateRuntimeRef.current = fullHibernateRuntime;
const upgradeSoftHiddenRuntimeToHibernate = useCallback(() => {
if (!wakeSoftHiddenRuntimeRef.current) return;
wakeSoftHiddenRuntimeRef.current?.();
void fullHibernateRuntime().then(
(completed) => {
if (!completed) resumeRendererAfterCancelledHibernateUpgradeRef.current?.();
},
(error) => {
logger.error("[Terminal] Failed to upgrade soft-hidden runtime to hibernate", { sessionId, error });
resumeRendererAfterCancelledHibernateUpgradeRef.current?.();
},
);
}, [fullHibernateRuntime, sessionId]);
const hideRuntimeOnly = useCallback(() => {
if (hibernatedRef.current || softHiddenRef.current || !hasRuntimeRef.current) return;
xtermRuntimeRef.current?.suspendWebglRenderer();
@@ -2308,39 +2319,6 @@ const TerminalComponent: React.FC<TerminalProps> = ({
logger.info("[Terminal] Soft-hidden runtime", { sessionId });
}, [sessionId]);
const upgradeSoftHiddenRuntimeToHibernate = useCallback(() => {
const pendingEviction = terminalHiddenRendererStore.hasPendingEviction(sessionId);
if (!wakeSoftHiddenRuntimeRef.current) {
terminalHiddenRendererStore.settleEviction(sessionId, false);
return;
}
wakeSoftHiddenRuntimeRef.current?.();
void fullHibernateRuntime().then(
(completed) => {
if (!completed) {
resumeRendererAfterCancelledHibernateUpgradeRef.current?.();
// Wake already dropped the marker. Restore the soft-hidden slot only
// while this tab is still hidden and hibernate is still enabled.
// Selecting the victim during the drain makes fullHibernateRuntime
// return false because the tab is visible; soft-hiding it again
// would hide the tab the user just opened.
if (pendingEviction && !isVisibleRef.current && hibernateEnabledRef.current) {
hideRuntimeOnly();
}
}
terminalHiddenRendererStore.settleEviction(sessionId, completed);
},
(error) => {
logger.error("[Terminal] Failed to upgrade soft-hidden runtime to hibernate", { sessionId, error });
resumeRendererAfterCancelledHibernateUpgradeRef.current?.();
if (pendingEviction && !isVisibleRef.current && hibernateEnabledRef.current) {
hideRuntimeOnly();
}
terminalHiddenRendererStore.settleEviction(sessionId, false);
},
);
}, [fullHibernateRuntime, hideRuntimeOnly, sessionId]);
const hibernateRuntime = useCallback(() => {
if (hibernatedRef.current || !termRef.current) return;
@@ -2355,14 +2333,8 @@ const TerminalComponent: React.FC<TerminalProps> = ({
return;
}
// Disconnected sessions never stay soft-hidden. A connected session whose
// keep count dropped to zero takes the same upgrade. A positive count stays
// retained until a later leave evicts overflow.
if (softHiddenRef.current) {
const retainedKeepCount = statusRef.current === "connected"
? resolveHibernateKeepRendererCount(terminalSettingsRef.current)
: 0;
if (statusRef.current === "disconnected" || !(retainedKeepCount > 0)) {
if (statusRef.current === "disconnected") {
upgradeSoftHiddenRuntimeToHibernate();
}
return;
@@ -2371,47 +2343,16 @@ const TerminalComponent: React.FC<TerminalProps> = ({
const keepCount = statusRef.current === "connected"
? resolveHibernateKeepRendererCount(terminalSettings)
: 0;
const keepDecision = decideHiddenTabKeepCountAction({
keepCount,
softHiddenCount: terminalHiddenRendererStore.getSoftHiddenOccupancy(),
sessionId,
candidatesOldestFirst: keepCount > 0
? terminalHiddenRendererStore.listSoftHiddenOldestFirst()
: [],
});
if (keepDecision.action === "soft-hide") {
if (keepCount > 0 && terminalHiddenRendererStore.getSoftHiddenCount() < keepCount) {
hideRuntimeOnly();
return;
}
if (keepDecision.action === "await-eviction") {
const attempt = ++hibernateEvictionAttemptRef.current;
// Reserves this tab's slot before the first await, then soft-hides only
// if a victim hibernate completes and the slot still fits. A sync marker
// clear is not enough: alt-screen and drain/snapshot failure restore the
// victim, and this tab stays out of the soft-hidden set.
void retainLeavingTabAfterEviction({
sessionId,
candidates: keepDecision.candidates,
keepCount,
// Re-read after the victim drain. The count above was captured when
// this timer fired; a zero or tighter setting must full-hibernate.
resolveKeepCount: () => (
statusRef.current === "connected"
? resolveHibernateKeepRendererCount(terminalSettingsRef.current)
: 0
),
stillWantsRetention: () => (
hibernateEvictionAttemptRef.current === attempt
&& !isVisibleRef.current
&& !hibernatedRef.current
&& !softHiddenRef.current
),
softHide: () => hideRuntimeOnly(),
fullHibernate: () => {
void fullHibernateRuntime();
},
});
return;
if (keepCount > 0) {
const victim = terminalHiddenRendererStore.pickEvictionCandidate(keepCount);
if (victim && victim !== sessionId) {
terminalHiddenRendererStore.requestEviction(victim);
}
}
void fullHibernateRuntime();
@@ -4769,7 +4710,6 @@ const TerminalComponent: React.FC<TerminalProps> = ({
isSearchOpen,
hibernateEnabled: hibernateEnabled,
hibernateDelayMs: resolveTerminalHibernateDelayMs(terminalSettings),
hibernateKeepRendererCount: resolveHibernateKeepRendererCount(terminalSettings),
fileTransferActive: hibernateFileTransferActive,
hibernatedRef,
softHiddenRef,
@@ -43,26 +43,6 @@ test("note content drafts stay in refs so MDX keystrokes do not rebuild the shel
);
});
test("hidden notes flush drafts on the isActive edge without rebuilding the tree", () => {
assert.match(
managerSource,
/useLayoutEffect\(\(\) => \{\s*if \(isActive\) return;\s*flushNoteDraftRef\.current\(\);\s*\}, \[isActive\]\);/,
);
assert.match(managerSource, /const NotesTreePane = memo\(/);
assert.match(managerSource, /notesTreePanePropsEqual/);
assert.match(managerSource, /prev\.selectedNoteId === next\.selectedNoteId/);
assert.match(managerSource, /prev\.editingNoteId === next\.editingNoteId/);
assert.match(managerSource, /prev\.editingGroupPath === next\.editingGroupPath/);
assert.match(managerSource, /prev\.expandedGroups === next\.expandedGroups/);
assert.match(managerSource, /<NotesTreePane/);
assert.doesNotMatch(managerSource, /key=\{isActive\}/);
assert.doesNotMatch(
managerSource,
/if \(!isActive\) return null;/,
"hiding notes must not unmount NotesManager",
);
});
test("NotesManager teardown flush uses a stable ref under StrictMode", () => {
assert.match(managerSource, /flushNoteDraftRef\.current = flushNoteDraft/);
assert.match(
@@ -93,21 +73,6 @@ test("hidden terminal notes side panel does not subscribe to notes publishes", (
"utf8",
);
assert.match(slotsSource, /useNotesStore\(\{\s*enabled:\s*isVisible\s*\}\)/);
const notesSlot = slotsSource.slice(
slotsSource.indexOf("function SidePanelNotesSlotInner"),
slotsSource.indexOf("export const SidePanelNotesSlot"),
);
assert.doesNotMatch(notesSlot, /if \(!isVisible\) return null/);
assert.match(notesSlot, /<NotesManager/);
const notesStoreSource = readFileSync(
new URL("../../application/state/notesStore.ts", import.meta.url),
"utf8",
);
assert.doesNotMatch(
notesStoreSource,
/enabled \? getNotesSnapshot : getEmptyNotesSnapshot/,
"enabled:false must keep the live snapshot so reopen does not flash empty",
);
});
test("notes manager prefetches the MDXEditor chunk when becoming active", () => {
+1 -33
View File
@@ -22,9 +22,7 @@ import {
isNoteFolderTreeSelected,
isNoteEditorMode,
normalizeNoteEditorMode,
notesTreePanePropsEqual,
NotesManager,
type NotesTreePaneProps,
} from "./NotesManager.tsx";
const note = (overrides: Partial<VaultNote> = {}): VaultNote => ({
@@ -371,40 +369,10 @@ test("NotesManager flushes drafts when retained mounts become inactive", () => {
const source = readFileSync(new URL("./NotesManager.tsx", import.meta.url), "utf8");
assert.match(source, /isActive\?: boolean/);
assert.match(source, /if \(isActive\) return;/);
assert.match(source, /useLayoutEffect\(\(\) => \{\s*if \(isActive\) return;\s*flushNoteDraftRef\.current\(\);/);
assert.match(source, /useLayoutEffect\(\(\) => \{\s*if \(isActive\) return;/);
assert.match(source, /visibilitychange/);
});
const treePaneProps = (overrides: Partial<NotesTreePaneProps> = {}): NotesTreePaneProps => ({
notes: [note()],
noteGroups: ["Ops"],
selectedNoteId: "note-1",
selectedGroup: null,
expandedGroups: new Set(["Ops"]),
editingNoteId: null,
editingGroupPath: null,
query: "",
locale: "en",
hosts: [],
draggingNoteId: null,
draggingGroupPath: null,
creatingGroupParent: undefined,
isSidebarMode: true,
renderRows: () => null,
...overrides,
});
test("notes tree pane equality ignores render callbacks and compares selection state", () => {
const base = treePaneProps();
assert.equal(notesTreePanePropsEqual(base, { ...base, renderRows: () => "other" }), true);
assert.equal(notesTreePanePropsEqual(base, { ...base, selectedNoteId: "note-2" }), false);
assert.equal(notesTreePanePropsEqual(base, { ...base, selectedGroup: "Ops" }), false);
assert.equal(notesTreePanePropsEqual(base, { ...base, editingNoteId: "note-1" }), false);
assert.equal(notesTreePanePropsEqual(base, { ...base, editingGroupPath: "Ops" }), false);
assert.equal(notesTreePanePropsEqual(base, { ...base, expandedGroups: new Set(["Ops"]) }), false);
assert.equal(notesTreePanePropsEqual(base, { ...base, notes: [note({ title: "Renamed" })] }), false);
});
test("NotesManager tree rename allows clearing note titles", () => {
const source = readFileSync(new URL("./NotesManager.tsx", import.meta.url), "utf8");
const renameBlock = source.match(/onRenameCommit=\{\(name\) => \{[\s\S]*?\}\}/)?.[0] ?? "";
+18 -97
View File
@@ -16,7 +16,7 @@ import {
Upload,
X,
} from "lucide-react";
import React, { lazy, memo, Suspense, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
import React, { lazy, Suspense, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
import { useI18n } from "../../application/i18n/I18nProvider";
import { useApplicationBackend } from "../../application/state/useApplicationBackend";
import { useStoredNumber } from "../../application/state/useStoredNumber";
@@ -357,57 +357,6 @@ export const getValidatedNoteSelectionState = (
return null;
};
export type NotesTreePaneProps = {
notes: VaultNote[];
noteGroups: string[];
selectedNoteId: string | null;
selectedGroup: string | null;
expandedGroups: ReadonlySet<string>;
editingNoteId: string | null;
editingGroupPath: string | null;
query: string;
locale: string;
hosts: Host[];
draggingNoteId: string | null;
draggingGroupPath: string | null;
creatingGroupParent: string | null | undefined;
isSidebarMode: boolean;
/**
* Row elements. Identity changes every parent render; equality ignores it so
* a visibility flip does not rebuild the tree. Event handlers close over the
* render that last changed one of the compared fields.
*/
renderRows: () => React.ReactNode;
};
/**
* Hide/show passes a new `isActive` into NotesManager, but that flag is not a
* tree input. Equality therefore keys off the data the rows actually paint.
* `selected` / `editing` / `expanded` are required so a memo bailout cannot
* freeze selection, rename, or folder expansion.
*/
export function notesTreePanePropsEqual(prev: NotesTreePaneProps, next: NotesTreePaneProps): boolean {
return prev.notes === next.notes
&& prev.noteGroups === next.noteGroups
&& prev.selectedNoteId === next.selectedNoteId
&& prev.selectedGroup === next.selectedGroup
&& prev.expandedGroups === next.expandedGroups
&& prev.editingNoteId === next.editingNoteId
&& prev.editingGroupPath === next.editingGroupPath
&& prev.query === next.query
&& prev.locale === next.locale
&& prev.hosts === next.hosts
&& prev.draggingNoteId === next.draggingNoteId
&& prev.draggingGroupPath === next.draggingGroupPath
&& prev.creatingGroupParent === next.creatingGroupParent
&& prev.isSidebarMode === next.isSidebarMode;
}
const NotesTreePane = memo(function NotesTreePane({ renderRows }: NotesTreePaneProps) {
return <>{renderRows()}</>;
}, notesTreePanePropsEqual);
NotesTreePane.displayName = "NotesTreePane";
export const getNotesGroupDropAction = (
sourceGroup: string | null,
targetGroup: string,
@@ -432,7 +381,7 @@ export const NotesManager: React.FC<NotesManagerProps> = ({
openNoteRequestId = null,
onOpenNoteIdHandled,
}) => {
const { t, locale } = useI18n();
const { t } = useI18n();
const { openExternal } = useApplicationBackend();
const isSidebarMode = displayMode === "sidebar";
const initialOpenNoteId = openNoteId && notes.some((note) => note.id === openNoteId)
@@ -612,12 +561,10 @@ export const NotesManager: React.FC<NotesManagerProps> = ({
flushNoteDraftRef.current();
}, []);
// Hide edge only. The ref is the latest flush, so this does not subscribe
// the tree render to draft-writer identity, and it does not remount the editor.
useLayoutEffect(() => {
if (isActive) return;
flushNoteDraftRef.current();
}, [isActive]);
flushNoteDraft();
}, [flushNoteDraft, isActive]);
useEffect(() => {
if (!isActive) return;
@@ -1539,28 +1486,11 @@ export const NotesManager: React.FC<NotesManagerProps> = ({
);
};
// Built only when NotesTreePane's compared props change. isActive / the
// hidden class stay on the parent, so a side-panel hide does not reconcile rows.
const renderTreeRows = () => {
const visibleRootNotes = noteTree.rootNotes.filter(noteMatches);
const visibleTree = noteTree.children
.map((child) => renderFolderRow(child, 0))
.filter(Boolean);
const treeIsEmpty = visibleRootNotes.length === 0 && visibleTree.length === 0;
return (
<>
{renderCreateGroupRow(null, 0)}
{visibleTree}
{visibleRootNotes.map((note) => renderNoteRow(note, 0))}
{treeIsEmpty && (
<div className="rounded-lg border border-dashed border-border/70 p-4 text-center text-sm text-muted-foreground">
<Search size={20} className="mx-auto mb-2 opacity-60" />
{query.trim() ? t("notes.search.noResults") : t("notes.empty.group")}
</div>
)}
</>
);
};
const visibleRootNotes = noteTree.rootNotes.filter(noteMatches);
const visibleTree = noteTree.children
.map((child) => renderFolderRow(child, 0))
.filter(Boolean);
const treeIsEmpty = visibleRootNotes.length === 0 && visibleTree.length === 0;
const hasSearch = query.trim().length > 0;
const canExpandCollapse = allGroupPaths.length > 0 && !hasSearch;
const shouldShowNotesTree = isSidebarMode || sortedNotes.length > 0;
@@ -1820,23 +1750,15 @@ export const NotesManager: React.FC<NotesManagerProps> = ({
}}
onDrop={(event) => handleGroupDrop(null, event)}
>
<NotesTreePane
notes={sortedNotes}
noteGroups={groups}
selectedNoteId={selectedNoteId}
selectedGroup={selectedGroup}
expandedGroups={expandedGroups}
editingNoteId={editingNoteId}
editingGroupPath={editingGroupPath}
query={query}
locale={locale}
hosts={hosts}
draggingNoteId={draggingNoteId}
draggingGroupPath={draggingGroupPath}
creatingGroupParent={creatingGroupParent}
isSidebarMode={isSidebarMode}
renderRows={renderTreeRows}
/>
{renderCreateGroupRow(null, 0)}
{visibleTree}
{visibleRootNotes.map((note) => renderNoteRow(note, 0))}
{treeIsEmpty && (
<div className="rounded-lg border border-dashed border-border/70 p-4 text-center text-sm text-muted-foreground">
<Search size={20} className="mx-auto mb-2 opacity-60" />
{query.trim() ? t("notes.search.noResults") : t("notes.empty.group")}
</div>
)}
</div>
</ScrollArea>
</div>
@@ -2126,7 +2048,6 @@ export const NotesManager: React.FC<NotesManagerProps> = ({
)}
</div>
{/* Kept mounted while the panel is hidden. Do not key this on isActive. */}
{isSidebarMode && overlayNoteView && (
<div className="absolute inset-0 z-30 flex min-h-0 flex-col bg-background text-foreground">
<div className={cn(
@@ -8,40 +8,3 @@ test("SFTP settings imports the shared Toggle used by its option rows", () => {
assert.match(source, /<Toggle\b/);
assert.match(source, /Toggle,\s*\n\s*Select,/);
});
function functionBody(functionName: string): string {
const start = source.indexOf(`const ${functionName} = useCallback`);
assert.notEqual(start, -1, `${functionName} should exist`);
const next = source.indexOf("\n const ", start + 1);
return source.slice(start, next === -1 ? source.length : next);
}
test("removing an SFTP file association confirms with the in-app dialog", () => {
assert.match(source, /import \{ ConfirmDialog \} from "\.\.\/\.\.\/ui\/confirm-dialog";/);
assert.doesNotMatch(source, /window\.confirm|globalThis\.confirm|\bconfirm\(/);
assert.match(
source,
/const \[removeConfirmExt, setRemoveConfirmExt\] = useState<string \| null>\(null\);/,
);
const handleRemove = functionBody("handleRemove");
assert.match(handleRemove, /setRemoveConfirmExt\(extension\)/);
assert.doesNotMatch(handleRemove, /removeAssociation\(/);
const handleConfirm = functionBody("handleConfirmRemove");
assert.match(handleConfirm, /if \(!removeConfirmExt\) return;/);
assert.match(handleConfirm, /removeAssociation\(removeConfirmExt\);\s*setRemoveConfirmExt\(null\);/);
const dialogStart = source.indexOf("<ConfirmDialog");
assert.notEqual(dialogStart, -1, "ConfirmDialog should be rendered");
const dialog = source.slice(dialogStart);
assert.match(dialog, /open=\{removeConfirmExt !== null\}/);
assert.match(dialog, /settings\.sftpFileAssociations\.removeConfirm/);
assert.match(dialog, /confirmLabel=\{t\('action\.remove'\)\}/);
assert.match(dialog, /\bdestructive\b/);
assert.match(dialog, /onConfirm=\{handleConfirmRemove\}/);
const onOpenChange = dialog.slice(dialog.indexOf("onOpenChange"), dialog.indexOf("onConfirm"));
assert.match(onOpenChange, /if \(!open\) setRemoveConfirmExt\(null\);/);
assert.doesNotMatch(onOpenChange, /removeAssociation\(/);
});
@@ -9,7 +9,6 @@ import { useSettingsState } from "../../../application/state/useSettingsState";
import type { FileOpenerType, SystemAppInfo } from "../../../lib/sftpFileUtils";
import { netcattyBridge } from "../../../infrastructure/services/netcattyBridge";
import { Button } from "../../ui/button";
import { ConfirmDialog } from "../../ui/confirm-dialog";
import { Tooltip, TooltipContent, TooltipTrigger } from "../../ui/tooltip";
import {
SectionHeader,
@@ -53,7 +52,6 @@ export default function SettingsFileAssociationsTab() {
const defaultOpener = getDefaultOpener();
const [editingExtension, setEditingExtension] = useState<string | null>(null);
const [isSelectingDefaultApp, setIsSelectingDefaultApp] = useState(false);
const [removeConfirmExt, setRemoveConfirmExt] = useState<string | null>(null);
const defaultOpenerValue = useMemo(() => {
if (!defaultOpener) return 'ask';
@@ -62,14 +60,10 @@ export default function SettingsFileAssociationsTab() {
}, [defaultOpener]);
const handleRemove = useCallback((extension: string) => {
setRemoveConfirmExt(extension);
}, []);
const handleConfirmRemove = useCallback(() => {
if (!removeConfirmExt) return;
removeAssociation(removeConfirmExt);
setRemoveConfirmExt(null);
}, [removeAssociation, removeConfirmExt]);
if (confirm(t('settings.sftpFileAssociations.removeConfirm', { ext: extension === 'file' ? t('sftp.opener.noExtension') : extension }))) {
removeAssociation(extension);
}
}, [removeAssociation, t]);
const handleSelectDefaultSystemApp = useCallback(async () => {
setIsSelectingDefaultApp(true);
@@ -378,16 +372,6 @@ export default function SettingsFileAssociationsTab() {
</table>
</div>
)}
<ConfirmDialog
open={removeConfirmExt !== null}
title={removeConfirmExt ? t('settings.sftpFileAssociations.removeConfirm', { ext: removeConfirmExt === 'file' ? t('sftp.opener.noExtension') : removeConfirmExt }) : ''}
confirmLabel={t('action.remove')}
destructive
onOpenChange={(open) => {
if (!open) setRemoveConfirmExt(null);
}}
onConfirm={handleConfirmRemove}
/>
</SettingsTabContent>
);
}
@@ -77,13 +77,13 @@ export const ClaudeCodeCard: React.FC<{
</div>
{found && (
<div className="flex items-center gap-2 text-xs min-w-0">
<span className="text-muted-foreground shrink-0">{t('ai.claude.path')}</span>
<span className="font-mono text-foreground min-w-0 flex-1 truncate" title={pathInfo.path ?? undefined}>{pathInfo.path}</span>
<div className="flex items-center gap-2 text-xs">
<span className="text-muted-foreground">{t('ai.claude.path')}</span>
<span className="font-mono text-foreground truncate">{pathInfo.path}</span>
{pathInfo.version && (
<>
<span className="text-muted-foreground shrink-0">|</span>
<span className="text-muted-foreground shrink-0">{pathInfo.version}</span>
<span className="text-muted-foreground">|</span>
<span className="text-muted-foreground">{pathInfo.version}</span>
</>
)}
</div>
@@ -102,7 +102,7 @@ export const ClaudeCodeCard: React.FC<{
value={customPath}
onChange={(e) => onCustomPathChange(e.target.value)}
placeholder={t('ai.claude.customPathPlaceholder')}
className="flex-1 min-w-0 h-8 rounded-md border border-input bg-background px-3 text-sm font-mono placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
className="flex-1 h-8 rounded-md border border-input bg-background px-3 text-sm font-mono placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
/>
<Button variant="outline" size="sm" onClick={onRecheckPath} disabled={!customPath.trim()}>
<RefreshCw size={14} className="mr-1.5" />
@@ -117,13 +117,13 @@ export const CodebuddyCard: React.FC<{
</div>
{found && (
<div className="flex items-center gap-2 text-xs min-w-0">
<span className="text-muted-foreground shrink-0">{t('ai.codebuddy.path')}</span>
<span className="font-mono text-foreground min-w-0 flex-1 truncate" title={pathInfo.path ?? undefined}>{pathInfo.path}</span>
<div className="flex items-center gap-2 text-xs">
<span className="text-muted-foreground">{t('ai.codebuddy.path')}</span>
<span className="font-mono text-foreground truncate">{pathInfo.path}</span>
{pathInfo.version && (
<>
<span className="text-muted-foreground shrink-0">|</span>
<span className="text-muted-foreground shrink-0">{pathInfo.version}</span>
<span className="text-muted-foreground">|</span>
<span className="text-muted-foreground">{pathInfo.version}</span>
</>
)}
</div>
@@ -142,7 +142,7 @@ export const CodebuddyCard: React.FC<{
value={customPath}
onChange={(e) => onCustomPathChange(e.target.value)}
placeholder={t('ai.codebuddy.customPathPlaceholder')}
className="flex-1 min-w-0 h-8 rounded-md border border-input bg-background px-3 text-sm font-mono placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
className="flex-1 h-8 rounded-md border border-input bg-background px-3 text-sm font-mono placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
/>
<Button variant="outline" size="sm" onClick={onRecheckPath} disabled={!customPath.trim()}>
<RefreshCw size={14} className="mr-1.5" />
@@ -108,13 +108,13 @@ export const CodexConnectionCard: React.FC<{
</div>
{found && (
<div className="flex items-center gap-2 text-xs min-w-0">
<span className="text-muted-foreground shrink-0">{t('ai.codex.path')}</span>
<span className="font-mono text-foreground min-w-0 flex-1 truncate" title={pathInfo.path ?? undefined}>{pathInfo.path}</span>
<div className="flex items-center gap-2 text-xs">
<span className="text-muted-foreground">{t('ai.codex.path')}</span>
<span className="font-mono text-foreground truncate">{pathInfo.path}</span>
{pathInfo.version && (
<>
<span className="text-muted-foreground shrink-0">|</span>
<span className="text-muted-foreground shrink-0">{pathInfo.version}</span>
<span className="text-muted-foreground">|</span>
<span className="text-muted-foreground">{pathInfo.version}</span>
</>
)}
</div>
@@ -133,7 +133,7 @@ export const CodexConnectionCard: React.FC<{
value={customPath}
onChange={(e) => onCustomPathChange(e.target.value)}
placeholder={t('ai.codex.customPathPlaceholder')}
className="flex-1 min-w-0 h-8 rounded-md border border-input bg-background px-3 text-sm font-mono placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
className="flex-1 h-8 rounded-md border border-input bg-background px-3 text-sm font-mono placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
/>
<Button variant="outline" size="sm" onClick={onRecheckPath} disabled={!customPath.trim()}>
<RefreshCw size={14} className="mr-1.5" />
@@ -61,13 +61,13 @@ export const CopilotCliCard: React.FC<{
</div>
{found && (
<div className="flex items-center gap-2 text-xs min-w-0">
<span className="text-muted-foreground shrink-0">{t(`${i18nPrefix}.path`)}</span>
<span className="font-mono text-foreground min-w-0 flex-1 truncate" title={pathInfo.path ?? undefined}>{pathInfo.path}</span>
<div className="flex items-center gap-2 text-xs">
<span className="text-muted-foreground">{t(`${i18nPrefix}.path`)}</span>
<span className="font-mono text-foreground truncate">{pathInfo.path}</span>
{pathInfo.version && (
<>
<span className="text-muted-foreground shrink-0">|</span>
<span className="text-muted-foreground shrink-0">{pathInfo.version}</span>
<span className="text-muted-foreground">|</span>
<span className="text-muted-foreground">{pathInfo.version}</span>
</>
)}
</div>
@@ -87,7 +87,7 @@ export const CopilotCliCard: React.FC<{
value={customPath}
onChange={(e) => onCustomPathChange(e.target.value)}
placeholder={t(`${i18nPrefix}.customPathPlaceholder`)}
className="flex-1 min-w-0 h-8 rounded-md border border-input bg-background px-3 text-sm font-mono placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
className="flex-1 h-8 rounded-md border border-input bg-background px-3 text-sm font-mono placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
/>
)}
<Button variant="outline" size="sm" onClick={onRecheckPath} disabled={!allowEmptyCheck && !customPath.trim()}>
@@ -1,132 +0,0 @@
import test from "node:test";
import assert from "node:assert/strict";
import React from "react";
import { renderToStaticMarkup } from "react-dom/server";
import { ClaudeCodeCard } from "./ClaudeCodeCard";
import { CodebuddyCard } from "./CodebuddyCard";
import { CodexConnectionCard } from "./CodexConnectionCard";
import { CopilotCliCard } from "./CopilotCliCard";
const LONG_PATH = "C:\\Users\\70541\\AppData\\Local\\Microsoft\\WinGet\\Packages\\Anthropic.ClaudeCode_Microsoft.Winget.Source_8wekyb3d8bbwe\\claude.exe";
const VERSION = "9.9.9-test";
function classList(tag: string): Set<string> {
const match = tag.match(/class="([^"]*)"/);
assert.ok(match, `missing class on ${tag}`);
return new Set(match[1].split(/\s+/));
}
function assertClasses(tag: string, expected: string[]) {
const have = classList(tag);
for (const name of expected) {
assert.ok(have.has(name), `expected ${name} on ${tag}`);
}
}
function openingTagContaining(markup: string, text: string): string {
const idx = markup.indexOf(`>${text}<`);
assert.ok(idx >= 0, `missing text node ${text}`);
const start = markup.lastIndexOf("<", idx);
return markup.slice(start, idx + 1);
}
function pathRowClass(markup: string): string {
const idx = markup.indexOf(LONG_PATH);
assert.ok(idx >= 0, "missing path text");
const matches = [...markup.slice(0, idx).matchAll(/<div\b[^>]*class="([^"]*)"/g)];
assert.ok(matches.length > 0, "missing path row");
return matches[matches.length - 1][1];
}
function assertLongPathTruncates(markup: string) {
const pathTag = openingTagContaining(markup, LONG_PATH);
assertClasses(pathTag, ["truncate", "min-w-0", "flex-1"]);
assert.match(pathTag, new RegExp(`title="${LONG_PATH.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}"`));
assert.ok(pathRowClass(markup).split(/\s+/).includes("min-w-0"), "path row missing min-w-0");
const versionTag = openingTagContaining(markup, VERSION);
assertClasses(versionTag, ["shrink-0"]);
const input = markup.match(/<input\b[^>]*>/);
assert.ok(input, "missing custom path input");
assertClasses(input[0], ["flex-1", "min-w-0"]);
assert.match(input[0], new RegExp(`value="${LONG_PATH.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}"`));
}
test("Claude Code card truncates a long CLI path inside the card", () => {
const markup = renderToStaticMarkup(
<ClaudeCodeCard
pathInfo={{ path: LONG_PATH, version: VERSION, available: true }}
isResolvingPath={false}
customPath={LONG_PATH}
onCustomPathChange={() => {}}
onRecheckPath={() => {}}
onResetPath={() => {}}
configDir=""
onConfigDirChange={() => {}}
settingsPath=""
onSettingsPathChange={() => {}}
envText=""
onEnvTextChange={() => {}}
/>,
);
assertLongPathTruncates(markup);
});
test("Codex card truncates a long CLI path inside the card", () => {
const markup = renderToStaticMarkup(
<CodexConnectionCard
pathInfo={{ path: LONG_PATH, version: VERSION, available: true }}
isResolvingPath={false}
customPath={LONG_PATH}
onCustomPathChange={() => {}}
onRecheckPath={() => {}}
onResetPath={() => {}}
integration={null}
loginSession={null}
isLoading={false}
error={null}
onRefresh={() => {}}
onConnect={() => {}}
onCancel={() => {}}
onOpenUrl={() => {}}
onLogout={() => {}}
appServerRuntime="sdk"
appServerStatus={null}
onAppServerRuntimeChange={() => {}}
/>,
);
assertLongPathTruncates(markup);
});
test("Codebuddy card truncates a long CLI path inside the card", () => {
const markup = renderToStaticMarkup(
<CodebuddyCard
pathInfo={{ path: LONG_PATH, version: VERSION, available: true }}
isResolvingPath={false}
customPath={LONG_PATH}
onCustomPathChange={() => {}}
onRecheckPath={() => {}}
onResetPath={() => {}}
internetEnv=""
onInternetEnvChange={() => {}}
envText=""
onEnvTextChange={() => {}}
/>,
);
assertLongPathTruncates(markup);
});
test("Copilot CLI card truncates a long CLI path inside the card", () => {
const markup = renderToStaticMarkup(
<CopilotCliCard
pathInfo={{ path: LONG_PATH, version: VERSION, available: true }}
isResolvingPath={false}
customPath={LONG_PATH}
onCustomPathChange={() => {}}
onRecheckPath={() => {}}
onResetPath={() => {}}
/>,
);
assertLongPathTruncates(markup);
});
@@ -84,7 +84,7 @@ test("a session holding inline images degrades to soft-hide instead of full hibe
const gateIndex = hibernateBody.indexOf("if (runtimeHasInlineImages()) {");
const softHideIndex = hibernateBody.indexOf("hideRuntimeOnly();", gateIndex);
const keepCountIndex = hibernateBody.indexOf("resolveHibernateKeepRendererCount(terminalSettings)");
const evictionIndex = hibernateBody.indexOf("retainLeavingTabAfterEviction(");
const evictionIndex = hibernateBody.indexOf("terminalHiddenRendererStore.pickEvictionCandidate(");
const fullHibernateIndex = hibernateBody.indexOf("void fullHibernateRuntime()");
assert.notEqual(gateIndex, -1, "hibernate must check for inline images");
@@ -5,7 +5,6 @@ import { createRequire } from "node:module";
import type { SerializeAddon as SerializeAddonType } from "@xterm/addon-serialize";
import type { Terminal as XTermType } from "@xterm/xterm";
import { DEFAULT_KEYWORD_HIGHLIGHT_RULES } from "../../domain/models/terminal.ts";
import { writeTerminalDataWithLineTimestamps } from "./runtime/terminalLineTimestamps.ts";
import {
noteTerminalOutputPressureData,
@@ -82,76 +81,6 @@ test("new output is colored on the written cells without decorations", async ()
term.dispose();
});
test("keyword highlight skips cells whose PTY foreground is already colored", async () => {
// Starship directory segments set a truecolor foreground (#3940). Those
// cells must keep the PTY color; only default-foreground matches are recolored.
const unixPath = DEFAULT_KEYWORD_HIGHLIGHT_RULES.find((entry) => entry.id === "unix-path");
assert.ok(unixPath);
const keywordRgb = Number.parseInt(unixPath.color.slice(1), 16);
const cream = 0xfbf1c7;
const yellow = 0xfabd2f;
const term = new XTerm({ allowProposedApi: true, cols: 80, rows: 5, scrollback: 20 });
const highlighter = new KeywordHighlighter(term);
highlighter.setRules([unixPath], true);
await write(
term,
"\x1b[38;2;251;241;199m\x1b[48;2;250;189;47m/opt\x1b[0m"
+ " \x1b[38;5;220m/mnt\x1b[0m"
+ " \x1b[31m/bin\x1b[0m"
+ " /var",
);
const tokenCells = (text: string) => {
const line = term.buffer.active.getLine(0);
const index = (line?.translateToString(true) ?? "").indexOf(text);
assert.ok(index >= 0, text);
return Array.from({ length: text.length }, (_, offset) => {
const cell = line?.getCell(index + offset);
assert.ok(cell, `${text}[${offset}]`);
return cell;
});
};
for (const cell of tokenCells("/opt")) {
assert.equal(cell.isFgRGB(), true);
assert.equal(cell.getFgColorMode(), 0x3000000);
assert.equal(cell.getFgColor(), cream);
assert.equal(cell.isBgRGB(), true);
assert.equal(cell.getBgColor(), yellow);
}
for (const cell of tokenCells("/mnt")) {
assert.equal(cell.isFgPalette(), true);
assert.equal(cell.getFgColorMode(), 0x2000000);
assert.equal(cell.getFgColor(), 220);
}
for (const cell of tokenCells("/bin")) {
assert.equal(cell.isFgPalette(), true);
assert.equal(cell.getFgColorMode(), 0x1000000);
assert.equal(cell.getFgColor(), 1);
}
for (const cell of tokenCells("/var")) {
assert.equal(cell.isFgRGB(), true);
assert.equal(cell.getFgColor(), keywordRgb);
}
highlighter.setRules([{ ...unixPath, color: "#60A5FA" }], true);
await highlighter.whenSettled();
assert.equal(tokenCells("/opt")[0]?.getFgColor(), cream);
assert.equal(tokenCells("/mnt")[0]?.getFgColor(), 220);
assert.equal(tokenCells("/bin")[0]?.getFgColor(), 1);
assert.equal(tokenCells("/var")[0]?.getFgColor(), BLUE);
highlighter.setRules([{ ...unixPath, color: "#60A5FA" }], false);
await highlighter.whenSettled();
assert.equal(tokenCells("/opt")[0]?.getFgColor(), cream);
assert.equal(tokenCells("/mnt")[0]?.getFgColor(), 220);
assert.equal(tokenCells("/bin")[0]?.getFgColor(), 1);
assert.equal(tokenCells("/var")[0]?.getFgColor(), -1);
highlighter.dispose();
term.dispose();
});
test("ordinary Enter output does not revisit existing history", async () => {
const term = new XTerm({ allowProposedApi: true, cols: 80, rows: 5, scrollback: 20 });
const highlighter = new KeywordHighlighter(term);
+1 -8
View File
@@ -67,7 +67,6 @@ const CELL_INDICES = 3;
const CELL_CONTENT = 0;
const CELL_FG = 1;
const STYLE_MASK = 0xfc000000;
const CM_MASK = 0x3000000;
const CM_RGB = 0x3000000;
const MAX_PLUGIN_HIGHLIGHT_SCAN_CHARS = 4_096;
const MAX_PLUGIN_HIGHLIGHT_MATCHES_PER_WRITE = 256;
@@ -1062,13 +1061,7 @@ export class KeywordHighlighter implements IDisposable {
const dataIndex = x * CELL_INDICES;
const content = internal._data[dataIndex + CELL_CONTENT];
const currentFg = internal._data[dataIndex + CELL_FG];
// A cell already carrying a PTY palette/truecolor foreground (starship
// directory segments, ANSI logs) must keep that color. Keyword RGB is
// only for the default foreground.
const maskedForContent = originals.mask[x] === 1 && originals.content[x] === content;
const ptyFg = maskedForContent ? originals.fg[x] : currentFg;
if ((ptyFg & CM_MASK) !== 0) continue;
if (!maskedForContent) {
if (!originals.mask[x] || originals.content[x] !== content) {
originals.fg[x] = currentFg;
originals.content[x] = content;
originals.mask[x] = 1;
@@ -348,62 +348,6 @@ test("startSerial does not arm the fallback when auto-login is cancelled before
assert.deepEqual(writtenCommands, []);
});
test("restored serial attach ignores a startup command hydrated while the port open is pending", async () => {
const writtenCommands: string[] = [];
let autoLoginComplete: ((evt: { sessionId: string }) => void) | null = null;
let releaseOpen: (() => void) | undefined;
const openPending = new Promise<void>((resolve) => {
releaseOpen = resolve;
});
const bootHost = {
id: "serial-1",
hostname: "/dev/ttyUSB0",
protocol: "serial" as const,
username: "admin",
password: "secret",
};
const hostRef = { current: bootHost };
const backend = buildBackend({
startSerialSession: async () => {
await openPending;
return "serial-session";
},
onTelnetAutoLoginComplete: (
_sessionId: string,
cb: (evt: { sessionId: string }) => void,
) => {
autoLoginComplete = cb;
return noop;
},
onTelnetAutoLoginCancelled: () => noop,
writeToSession: (_sessionId: string, data: string) => {
writtenCommands.push(data);
},
});
const ctx = buildCtx(backend, {
host: bootHost,
hostRef,
terminalSettings: { startupCommandDelayMs: 0 },
});
const started = createTerminalSessionStarters(ctx as never).startSerial(term as never);
// Vault hydration can replace the fallback host after preflight has already
// decided there is no command to hold for auto-login.
hostRef.current = { ...bootHost, startupCommand: "show version" };
releaseOpen?.();
await started;
await new Promise((resolve) => setTimeout(resolve, 0));
autoLoginComplete?.({ sessionId: "session-1" });
await new Promise((resolve) => setTimeout(resolve, 0));
assert.equal(autoLoginComplete, null);
assert.equal(ctx.hasRunStartupCommandRef.current, false);
assert.deepEqual(writtenCommands, []);
});
for (const runMode of ['paste', 'lineDelay', 'noAutoRun']) {
test(`serial reconnect discards the previous delayed startup command (${runMode})`, async (t) => {
t.mock.timers.enable({ apis: ['setTimeout'] });
@@ -3653,207 +3653,6 @@ test("restored local reconnect runs the host startup command while automatic ret
assert.deepEqual(sessionWrites, []);
});
test("restored SSH attach runs the live host startup command when the boot snapshot predates vault hydration", async () => {
const sessionWrites: Array<{ id: string; data: string; automated?: boolean }> = [];
// Session restore mounts the pane before the vault host (and group defaults)
// replace the fallback snapshot. The dial can still succeed from that
// snapshot; the startup command is only known on the effective host by the
// time the shell is attached.
const bootHost = {
id: "host-1",
label: "Target",
hostname: "target.example.test",
username: "alice",
port: 22,
protocol: "ssh" as const,
password: "secret",
};
const effectiveHost = {
...bootHost,
startupCommand: "echo restored-startup",
startupCommandRunMode: "paste" as const,
};
const hostRef = { current: bootHost };
let releaseDial: (() => void) | undefined;
const dialStarted = new Promise<void>((resolve) => {
releaseDial = resolve;
});
const terminalBackend = {
backendAvailable: () => true,
startSSHSession: async () => {
await dialStarted;
return "ssh-session";
},
onSessionData: () => noop,
onSessionExit: () => noop,
onChainProgress: () => noop,
writeToSession: (id: string, data: string, options?: { automated?: boolean }) => {
sessionWrites.push({ id, data, automated: options?.automated });
},
resizeSession: noop,
closeSession: noop,
};
const ctx = createStarterContext({
host: bootHost,
hostRef,
terminalSettings: { startupCommandDelayMs: 0 },
terminalBackend,
startupCommand: undefined,
suppressHostStartupCommandRef: {
current: shouldSuppressHostStartupCommandOnReconnect("restored"),
},
promptLineBreakStateRef: undefined,
});
const started = createTerminalSessionStarters(ctx as never).startSSH(createTermStub() as never);
hostRef.current = effectiveHost;
releaseDial?.();
await started;
await new Promise((resolve) => setTimeout(resolve, 0));
assert.deepEqual(sessionWrites, [
{ id: "ssh-session", data: "echo restored-startup\r", automated: true },
]);
sessionWrites.length = 0;
hostRef.current = effectiveHost;
const automaticCtx = createStarterContext({
...ctx,
sessionRef: { current: null },
hasRunStartupCommandRef: { current: false },
suppressHostStartupCommandRef: {
current: shouldSuppressHostStartupCommandOnReconnect("automatic"),
},
});
await createTerminalSessionStarters(automaticCtx as never).startSSH(createTermStub() as never);
await new Promise((resolve) => setTimeout(resolve, 0));
assert.deepEqual(sessionWrites, []);
});
test("restored SSH attach ignores a live startup command for a different dial target", async () => {
const bootHost = {
id: "host-1",
label: "Target",
hostname: "target.example.test",
username: "alice",
port: 22,
protocol: "ssh" as const,
password: "secret",
};
const mismatches = [
{ label: "hostname", patch: { hostname: "moved.example.test" } },
{ label: "port", patch: { port: 2222 } },
{ label: "username", patch: { username: "bob" } },
{ label: "protocol", patch: { protocol: "telnet" as const } },
];
for (const { label, patch } of mismatches) {
const sessionWrites: Array<{ id: string; data: string; automated?: boolean }> = [];
const hostRef = { current: bootHost };
let releaseDial: (() => void) | undefined;
const dialStarted = new Promise<void>((resolve) => {
releaseDial = resolve;
});
const terminalBackend = {
backendAvailable: () => true,
startSSHSession: async () => {
await dialStarted;
return "ssh-session";
},
onSessionData: () => noop,
onSessionExit: () => noop,
onChainProgress: () => noop,
writeToSession: (id: string, data: string, options?: { automated?: boolean }) => {
sessionWrites.push({ id, data, automated: options?.automated });
},
resizeSession: noop,
closeSession: noop,
};
const ctx = createStarterContext({
host: bootHost,
hostRef,
terminalSettings: { startupCommandDelayMs: 0 },
terminalBackend,
startupCommand: undefined,
suppressHostStartupCommandRef: {
current: shouldSuppressHostStartupCommandOnReconnect("restored"),
},
promptLineBreakStateRef: undefined,
});
const started = createTerminalSessionStarters(ctx as never).startSSH(createTermStub() as never);
hostRef.current = {
...bootHost,
...patch,
startupCommand: "echo moved-startup",
};
releaseDial?.();
await started;
await new Promise((resolve) => setTimeout(resolve, 0));
assert.equal(ctx.hasRunStartupCommandRef.current, false, label);
assert.deepEqual(sessionWrites, [], label);
}
});
test("restored SSH attach still runs a hydrated startup command when only the default port is materialized", async () => {
const sessionWrites: Array<{ id: string; data: string; automated?: boolean }> = [];
const bootHost = {
id: "host-1",
label: "Target",
hostname: "target.example.test",
username: "alice",
port: 22,
protocol: "ssh" as const,
password: "secret",
};
const hostRef = { current: bootHost };
let releaseDial: (() => void) | undefined;
const dialStarted = new Promise<void>((resolve) => {
releaseDial = resolve;
});
const terminalBackend = {
backendAvailable: () => true,
startSSHSession: async () => {
await dialStarted;
return "ssh-session";
},
onSessionData: () => noop,
onSessionExit: () => noop,
onChainProgress: () => noop,
writeToSession: (id: string, data: string, options?: { automated?: boolean }) => {
sessionWrites.push({ id, data, automated: options?.automated });
},
resizeSession: noop,
closeSession: noop,
};
const ctx = createStarterContext({
host: bootHost,
hostRef,
terminalSettings: { startupCommandDelayMs: 0 },
terminalBackend,
startupCommand: undefined,
suppressHostStartupCommandRef: {
current: shouldSuppressHostStartupCommandOnReconnect("restored"),
},
promptLineBreakStateRef: undefined,
});
const started = createTerminalSessionStarters(ctx as never).startSSH(createTermStub() as never);
hostRef.current = {
...bootHost,
port: undefined,
startupCommand: "echo restored-startup",
};
releaseDial?.();
await started;
await new Promise((resolve) => setTimeout(resolve, 0));
assert.deepEqual(sessionWrites, [
{ id: "ssh-session", data: "echo restored-startup\r", automated: true },
]);
});
for (const { name, hostDirectory, globalDirectory, expectedDirectory } of [
{ name: "uses saved directory before global default", hostDirectory: "/Users/alice/project", globalDirectory: "/Users/alice/default", expectedDirectory: "/Users/alice/project" },
{ name: "inherits global directory when unset", hostDirectory: undefined, globalDirectory: "/Users/alice/default", expectedDirectory: "/Users/alice/default" },
@@ -3,56 +3,6 @@ import { normalizeLineEndings, wrapBracketedPaste } from "../../../lib/utils";
import { markPromptLineBreakCommandPending } from "./promptLineBreak";
import type { TerminalSessionStartersContext } from "./createTerminalSessionStarters.types";
/**
* Host startup is scheduled only after the shell attaches. Restored SSH panes
* boot from the session fallback first; vault hydration (including group
* defaults) replaces `hostRef` while that dial is still in flight. `startSSH`
* keeps dialing the boot snapshot (`ctx.host`). The startup command may come
* from the live host only when that object is still this dial: same id,
* hostname, username, and dial port (`port || 22`), and the same protocol
* when the live host sets one. A missing protocol is a legacy SSH host.
* A saved host edited onto another endpoint since the workspace was saved
* keeps the snapshot, so its command is not typed into the old session.
*
* Serial preflight uses this same resolver to decide whether to wait for
* auto-login, then schedules again after the port opens. Non-SSH transports
* stay on the snapshot, so a command that appears on `hostRef` during the
* open is not typed into a saved username/password exchange.
* A ref that only carries a write-path toggle (no id) is not a host snapshot.
* A different host id is a later pane, not this dial, so keep the snapshot.
*/
const prefersLiveStartupHost = (protocol: string | undefined): boolean =>
protocol === "ssh" || protocol === undefined;
const sshDialPort = (port: number | undefined): number => port || 22;
const sameSshDialTarget = (
dial: TerminalSessionStartersContext["host"],
live: TerminalSessionStartersContext["host"],
): boolean => {
if (live.hostname !== dial.hostname) return false;
if ((live.username ?? "") !== (dial.username ?? "")) return false;
if (sshDialPort(live.port) !== sshDialPort(dial.port)) return false;
// A live protocol, when set, must still be the protocol this dial used.
if (live.protocol !== undefined && live.protocol !== (dial.protocol || "ssh")) return false;
return true;
};
const resolveStartupHost = (
ctx: TerminalSessionStartersContext,
): TerminalSessionStartersContext["host"] => {
const live = ctx.hostRef?.current;
if (
prefersLiveStartupHost(ctx.host.protocol)
&& live
&& live.id === ctx.host.id
&& sameSshDialTarget(ctx.host, live)
) {
return live;
}
return ctx.host;
};
const STARTUP_COMMAND_DEFAULT_DELAY_MS = 600;
const STARTUP_COMMAND_MAX_DELAY_MS = 10000;
@@ -88,7 +38,7 @@ export const resolveStartupCommand = (
ctx: TerminalSessionStartersContext,
options?: { consumeSuppressHostStartupCommand?: boolean },
): string | undefined => {
const command = ctx.startupCommand || (ctx.suppressHostStartupCommandRef?.current ? undefined : resolveStartupHost(ctx).startupCommand);
const command = ctx.startupCommand || (ctx.suppressHostStartupCommandRef?.current ? undefined : ctx.host.startupCommand);
if (options?.consumeSuppressHostStartupCommand && ctx.suppressHostStartupCommandRef) {
ctx.suppressHostStartupCommandRef.current = false;
}
@@ -146,7 +96,7 @@ export const scheduleStartupCommand = (
const runMode = ctx.startupCommand
? (ctx.multiLineRunMode ?? "paste")
: (resolveStartupHost(ctx).startupCommandRunMode ?? "paste");
: (ctx.host.startupCommandRunMode ?? "paste");
if (runMode === "paste") {
timeoutId = setTimeout(() => {
if (cancelled) return;
@@ -211,7 +211,7 @@ test("ended soft-hidden and alternate-screen sessions can fully hibernate", () =
);
assert.match(
hibernateBody,
/if \(softHiddenRef\.current\) \{\s*const retainedKeepCount = statusRef\.current === "connected"\s*\? resolveHibernateKeepRendererCount\(terminalSettingsRef\.current\)\s*: 0;\s*if \(statusRef\.current === "disconnected" \|\| !\(retainedKeepCount > 0\)\) \{\s*upgradeSoftHiddenRuntimeToHibernate\(\);\s*\}\s*return;/,
/if \(softHiddenRef\.current\) \{\s*if \(statusRef\.current === "disconnected"\) \{\s*upgradeSoftHiddenRuntimeToHibernate\(\);\s*\}\s*return;/,
);
assert.match(
alternateSkipBody,
@@ -227,7 +227,7 @@ test("a cancelled soft-hidden upgrade resumes its renderer", () => {
assert.match(
hibernateBody,
/if \(softHiddenRef\.current\) \{\s*const retainedKeepCount = statusRef\.current === "connected"\s*\? resolveHibernateKeepRendererCount\(terminalSettingsRef\.current\)\s*: 0;\s*if \(statusRef\.current === "disconnected" \|\| !\(retainedKeepCount > 0\)\) \{\s*upgradeSoftHiddenRuntimeToHibernate\(\);/,
/if \(softHiddenRef\.current\) \{\s*if \(statusRef\.current === "disconnected"\) \{\s*upgradeSoftHiddenRuntimeToHibernate\(\);/,
);
const wakeIndex = upgradeBody.indexOf("wakeSoftHiddenRuntimeRef.current?.()");
const upgradeIndex = upgradeBody.indexOf("fullHibernateRuntime().then(");
@@ -239,16 +239,6 @@ test("a cancelled soft-hidden upgrade resumes its renderer", () => {
assert.match(helperBody, /ensureWebglRenderer\(\)/);
assert.match(helperBody, /clearTextureAtlas\(\)/);
assert.match(helperBody, /safeFitRef\.current\(\{ force: true \}\)/);
const restoreGuard = "if (pendingEviction && !isVisibleRef.current && hibernateEnabledRef.current)";
assert.equal(
upgradeBody.split(restoreGuard).length - 1,
2,
"both the failed hibernate and the rejection path must skip soft-hide when the tab is visible or hibernate is off",
);
assert.ok(
upgradeBody.indexOf(restoreGuard) > upgradeIndex,
"the visibility guard belongs on the settled upgrade, after the renderer was resumed",
);
});
test("snapshot and handoff paths reject output that misses the settle deadline", () => {
@@ -292,34 +282,3 @@ test("snapshot and handoff paths reject output that misses the settle deadline",
/const flushed = await flushPendingTerminalWritesBeforeHibernate\(snapshotTerm\);\s*if \(!flushed\) \{\s*throw new Error\("Terminal output did not settle before closing the attached display"\);\s*\}/,
);
});
test("a full keep count soft-hides the leaving session instead of snapshotting it", () => {
const source = readFileSync(new URL("../Terminal.tsx", import.meta.url), "utf8");
const body = readFunctionBody(source, "const hibernateRuntime = useCallback(() =>");
const imageGate = body.indexOf("if (runtimeHasInlineImages()) {");
const alternateGate = body.indexOf("shouldSkipHibernateForActiveAlternateScreen(termRef.current)");
const decision = body.indexOf("decideHiddenTabKeepCountAction(");
const softHide = body.indexOf('if (keepDecision.action === "soft-hide")');
const hide = body.indexOf("hideRuntimeOnly();", softHide);
const full = body.indexOf("void fullHibernateRuntime()");
assert.ok(alternateGate !== -1 && alternateGate < imageGate);
assert.ok(imageGate !== -1 && imageGate < decision);
assert.ok(decision < softHide && softHide < hide && hide < full);
const awaited = body.indexOf("retainLeavingTabAfterEviction({");
assert.ok(awaited > decision, "a full keep set retains the leaving tab from the application eviction walk");
assert.match(body, /getSoftHiddenOccupancy\(\)/);
assert.match(body, /stillWantsRetention:/);
assert.match(body, /softHide: \(\) => hideRuntimeOnly\(\)/);
assert.match(
body,
/resolveKeepCount: \(\) => \(\s*statusRef\.current === "connected"\s*\? resolveHibernateKeepRendererCount\(terminalSettingsRef\.current\)\s*: 0\s*\)/,
"retention after eviction must read the live keep count, not the captured one",
);
assert.doesNotMatch(body, /resolveHiddenTabEviction/);
assert.doesNotMatch(body, /requestEviction\(keepDecision/);
assert.match(
body,
/if \(softHiddenRef\.current\) \{\s*const retainedKeepCount = statusRef\.current === "connected"\s*\? resolveHibernateKeepRendererCount\(terminalSettingsRef\.current\)\s*: 0;\s*if \(statusRef\.current === "disconnected" \|\| !\(retainedKeepCount > 0\)\) \{\s*upgradeSoftHiddenRuntimeToHibernate\(\);\s*\}\s*return;\s*\}/,
);
});
@@ -20,8 +20,6 @@ type UseTerminalHibernateEffectOptions = {
isSearchOpen: boolean;
hibernateEnabled: boolean;
hibernateDelayMs: number;
/** Current retained-renderer limit. Zero rechecks soft-hidden tabs immediately. */
hibernateKeepRendererCount?: number;
fileTransferActive: boolean;
hibernatedRef: React.MutableRefObject<boolean>;
softHiddenRef: React.MutableRefObject<boolean>;
@@ -51,7 +49,6 @@ export function useTerminalHibernateEffect({
isSearchOpen,
hibernateEnabled,
hibernateDelayMs,
hibernateKeepRendererCount,
fileTransferActive,
hibernatedRef,
softHiddenRef,
@@ -213,27 +210,4 @@ export function useTerminalHibernateEffect({
softHiddenRef,
status,
]);
// Separate from the visibility timer so a keep-count edit does not clear an
// in-flight leave. Already soft-hidden tabs have no timer left to notice a
// later zero; this one re-enters hibernate without stacking a second snapshot
// when StrictMode runs the effect twice.
useEffect(() => {
if (!hibernateEnabled || hibernateKeepRendererCount !== 0) return;
if (!softHiddenRef.current) return;
if (resolvePaneVisible(sessionId, isVisibleRef.current)) return;
const timer = window.setTimeout(() => {
if (resolvePaneVisible(sessionId, isVisibleRef.current)) return;
if (!softHiddenRef.current) return;
onHibernateRef.current();
}, 0);
return () => window.clearTimeout(timer);
}, [
hibernateEnabled,
hibernateKeepRendererCount,
isVisible,
isVisibleRef,
sessionId,
softHiddenRef,
]);
}
@@ -58,90 +58,6 @@ test("soft-hidden wake keeps its marker until the runtime has resumed", () => {
assert.doesNotMatch(softWakeBranch, /softHiddenRef\.current\s*=\s*false/);
});
test("a keep-count of zero rechecks a soft-hidden tab while hibernate stays enabled", async (t) => {
const sessionId = "keep-count-zero-recheck";
const previousWindow = Object.getOwnPropertyDescriptor(globalThis, "window");
const previousActEnvironment = Object.getOwnPropertyDescriptor(
globalThis,
"IS_REACT_ACT_ENVIRONMENT",
);
Object.defineProperty(globalThis, "window", {
configurable: true,
value: { setTimeout, clearTimeout },
});
Object.defineProperty(globalThis, "IS_REACT_ACT_ENVIRONMENT", {
configurable: true,
value: true,
});
t.after(() => {
removePaneVisible(sessionId);
if (previousWindow) Object.defineProperty(globalThis, "window", previousWindow);
else Reflect.deleteProperty(globalThis, "window");
if (previousActEnvironment) {
Object.defineProperty(globalThis, "IS_REACT_ACT_ENVIRONMENT", previousActEnvironment);
} else {
Reflect.deleteProperty(globalThis, "IS_REACT_ACT_ENVIRONMENT");
}
});
let hibernateCalls = 0;
let renderer: ReactTestRenderer | null = null;
const softHiddenRef = { current: true };
const ref = <T>(current: T) => ({ current });
function Probe({ keepCount }: { keepCount: number }) {
useTerminalHibernateEffect({
sessionId,
isVisible: false,
isVisibleRef: ref(false),
getSessionConnectedRef: ref(() => true),
status: "connected",
isSearchOpen: false,
hibernateEnabled: true,
hibernateDelayMs: 60_000,
hibernateKeepRendererCount: keepCount,
fileTransferActive: false,
hibernatedRef: ref(false),
softHiddenRef,
hibernatePendingBufferRef: ref(""),
hibernateSnapshotRef: ref(""),
hibernateViewportSnapshotRef: ref(""),
hibernateScrollbackSnapshotRef: ref(""),
hibernateContextSnapshotRef: ref(""),
hibernateContextViewportSnapshotRef: ref(""),
hibernateContextScrollbackSnapshotRef: ref(""),
hibernateAlternateScreenRef: ref(false),
hasRuntimeRef: ref(true),
onHibernate: () => { hibernateCalls += 1; },
onSoftHideWake: () => {},
onWake: () => true,
});
return null;
}
const element = (keepCount: number) => React.createElement(
React.StrictMode,
null,
React.createElement(Probe, { keepCount }),
);
await act(async () => { renderer = create(element(2)); });
assert.equal(hibernateCalls, 0, "a positive keep count must leave the retained runtime alone");
await act(async () => { renderer!.update(element(1)); });
assert.equal(hibernateCalls, 0, "a still-positive keep count must not drop every retained runtime");
await act(async () => {
renderer!.update(element(0));
});
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 0));
});
assert.equal(hibernateCalls, 1, "zero retention must re-enter hibernate for a soft-hidden tab");
await act(async () => renderer!.unmount());
});
test("hidden disconnected remote terminals release their retained runtime", async (t) => {
const sessionId = "disconnected-hidden-hibernate-test";
const previousWindow = Object.getOwnPropertyDescriptor(globalThis, "window");
@@ -1,9 +1,7 @@
import test from "node:test";
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import type { Workspace } from "../../types";
import { terminalPaneSessionsEqual } from "../../domain/terminalPaneSessionsEqual.ts";
import {
terminalLayerFocusSidebarPropsEqual,
terminalLayerSidePanelCtxEqual,
@@ -104,27 +102,6 @@ test("terminal layer memo ignores activeWorkspace-only tab flips (live store own
);
});
test("solo tab id change still invalidates workspace ctx; panes host does not read it", () => {
const sessions = [{ id: "session-a" }, { id: "session-b" }];
assert.equal(
terminalLayerWorkspaceCtxEqual(
{ activeTabId: "session-a", sessions },
{ activeTabId: "session-b", sessions },
),
false,
);
const hostSource = readFileSync(new URL("./TerminalLayerSupport.tsx", import.meta.url), "utf8");
const equalStart = hostSource.indexOf("const terminalPanesHostPropsAreEqual");
const equalEnd = hostSource.indexOf("export const TerminalPanesHost");
const equalBody = hostSource.slice(equalStart, equalEnd);
assert.ok(equalStart !== -1 && equalEnd > equalStart);
assert.doesNotMatch(equalBody, /prev\.activeTabId/);
assert.doesNotMatch(equalBody, /next\.activeTabId/);
assert.match(equalBody, /terminalPaneSessionsEqual\(prev\.sessions, next\.sessions\)/);
assert.equal(terminalPaneSessionsEqual(sessions, sessions), true);
});
test("terminal layer memo re-renders when active resizers change", () => {
const base = {
activeResizers: [
@@ -343,11 +343,6 @@ const SIDE_PANEL_STABLE_CTX_KEYS = [
] as const;
const WORKSPACE_CTX_KEYS = [
// Stay. Solo SSH A->B already keeps TerminalPanesHost equal when the sessions
// ref is unchanged (this compare does not feed the panes host). The visible
// pane still pays reveal flush + repaint, and focus retries at 50ms.
// Dropping activeTabId only skips one workspace-section pass; the live-store
// host/theme update still renders the section. That is not the settle cost.
'activeTabId',
'workspaceInnerRef',
'workspaceOuterRef',
+12 -9
View File
@@ -31,17 +31,20 @@
<text x="85" y="85.0" fill="#e6edf3" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="end">7K</text>
<text x="43" y="271.5" fill="#e6edf3" font-size="17" font-family="Handlee, cursive" font-weight="bold" transform="rotate(-90, 43, 271.5)">GitHub Stars</text>
<text x="90.0" y="488" fill="#e6edf3" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Jan 2026</text>
<text x="220.4" y="488" fill="#e6edf3" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Mar 2026</text>
<text x="291.4" y="488" fill="#e6edf3" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Apr 2026</text>
<text x="430.9" y="488" fill="#e6edf3" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Jun 2026</text>
<text x="570.5" y="488" fill="#e6edf3" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Aug 2026</text>
<text x="710.1" y="488" fill="#e6edf3" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Oct 2026</text>
<text x="223.6" y="488" fill="#e6edf3" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Mar 2026</text>
<text x="296.2" y="488" fill="#e6edf3" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Apr 2026</text>
<text x="366.6" y="488" fill="#e6edf3" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">May 2026</text>
<text x="439.2" y="488" fill="#e6edf3" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Jun 2026</text>
<text x="509.5" y="488" fill="#e6edf3" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Jul 2026</text>
<text x="582.2" y="488" fill="#e6edf3" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Aug 2026</text>
<text x="654.8" y="488" fill="#e6edf3" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Sep 2026</text>
<text x="725.2" y="488" fill="#e6edf3" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Oct 2026</text>
<text x="50%" y="525" fill="#e6edf3" font-size="17" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Date</text>
<line x1="90" y1="80" x2="90" y2="463" stroke="#30363d" stroke-width="2.5" filter="url(#xkcdify)"/>
<line x1="90" y1="463" x2="765" y2="463" stroke="#30363d" stroke-width="2.5" filter="url(#xkcdify)"/>
<path d="M 90.0,462.2 C 90.4,462.1 91.9,461.8 92.3,461.7 C 93.1,461.6 93.8,461.4 94.6,461.2 C 95.3,461.1 96.1,460.8 96.9,460.8 C 97.6,460.7 98.4,460.7 99.2,460.7 C 99.9,460.7 100.7,460.6 101.4,460.6 C 102.2,460.5 103.0,460.5 103.7,460.4 C 104.5,460.4 104.9,460.3 106.0,460.3 C 107.2,460.2 109.4,460.1 110.6,460.1 C 111.7,460.1 112.1,460.0 112.9,460.0 C 113.6,460.0 114.4,460.0 115.2,459.9 C 115.9,459.9 116.7,459.9 117.5,459.9 C 118.2,459.9 119.0,459.9 119.7,459.8 C 120.5,459.8 121.3,459.7 122.0,459.7 C 122.8,459.6 123.6,459.6 124.3,459.4 C 125.1,459.3 125.8,459.0 126.6,458.7 C 127.4,458.3 128.1,457.6 128.9,457.3 C 129.7,457.1 130.4,457.2 131.2,457.1 C 131.9,457.1 132.7,457.1 133.5,457.0 C 134.2,457.0 134.6,457.0 135.8,456.9 C 136.9,456.9 138.8,456.9 140.3,456.9 C 141.9,456.8 143.8,456.8 144.9,456.8 C 146.1,456.7 146.4,456.7 147.2,456.7 C 148.0,456.6 148.7,456.6 149.5,456.5 C 150.3,456.5 151.0,456.5 151.8,456.4 C 152.5,456.4 153.3,456.4 154.1,456.4 C 154.8,456.4 155.2,456.4 156.4,456.3 C 157.5,456.3 159.0,456.2 160.9,456.2 C 162.8,456.2 165.9,456.2 167.8,456.2 C 169.7,456.1 170.1,456.2 172.4,456.1 C 174.7,456.1 179.6,455.9 181.5,455.9 C 183.4,455.8 182.7,455.9 183.8,455.8 C 185.0,455.8 186.5,455.8 188.4,455.8 C 190.3,455.8 191.4,455.7 195.3,455.7 C 199.1,455.6 208.2,455.5 211.3,455.5 C 214.3,455.5 212.8,455.5 213.6,455.4 C 214.3,455.4 214.7,456.1 215.8,455.4 C 217.0,454.7 219.3,454.1 220.4,451.3 C 221.6,448.4 221.9,440.7 222.7,438.3 C 223.5,435.9 224.2,437.3 225.0,436.9 C 225.8,436.5 226.5,436.3 227.3,436.0 C 228.1,435.8 228.8,435.6 229.6,435.4 C 230.3,435.3 231.1,435.3 231.9,435.2 C 232.6,435.1 233.4,434.9 234.2,434.8 C 234.9,434.8 235.7,434.8 236.4,434.8 C 237.2,434.7 238.0,434.5 238.7,434.4 C 239.5,434.3 240.3,434.3 241.0,434.2 C 241.8,434.1 242.5,433.7 243.3,433.6 C 244.1,433.5 244.8,433.5 245.6,433.5 C 246.4,433.4 246.7,433.2 247.9,433.1 C 249.0,433.0 251.3,433.0 252.5,432.9 C 253.6,432.9 254.0,432.8 254.7,432.7 C 255.5,432.7 256.3,432.7 257.0,432.6 C 257.8,432.5 258.6,432.2 259.3,432.1 C 260.1,431.9 260.8,432.6 261.6,431.7 C 262.4,430.8 263.1,427.6 263.9,426.6 C 264.7,425.5 265.4,425.7 266.2,425.5 C 266.9,425.3 267.7,425.5 268.5,425.4 C 269.2,425.3 270.0,424.9 270.8,424.6 C 271.5,424.4 272.3,424.2 273.1,423.7 C 273.8,423.2 274.6,422.2 275.3,421.6 C 276.1,421.0 276.9,420.5 277.6,420.0 C 278.4,419.6 279.2,419.3 279.9,419.0 C 280.7,418.8 281.4,418.6 282.2,418.5 C 283.0,418.4 283.7,418.4 284.5,418.3 C 285.3,418.2 286.0,418.0 286.8,417.9 C 287.5,417.8 288.3,417.8 289.1,417.8 C 289.8,417.7 290.6,417.7 291.4,417.5 C 292.1,417.4 292.9,417.5 293.6,417.0 C 294.4,416.6 295.2,415.6 295.9,415.1 C 296.7,414.6 297.5,414.4 298.2,414.0 C 299.0,413.7 299.7,414.4 300.5,412.8 C 301.3,411.3 302.0,406.5 302.8,404.6 C 303.6,402.8 304.3,402.5 305.1,401.7 C 305.8,400.9 306.6,400.4 307.4,399.9 C 308.1,399.3 308.9,398.8 309.7,398.4 C 310.4,398.0 311.2,397.6 311.9,397.3 C 312.7,397.1 313.5,397.0 314.2,396.8 C 315.0,396.6 315.8,396.3 316.5,396.0 C 317.3,395.7 318.1,395.2 318.8,395.0 C 319.6,394.7 320.3,394.8 321.1,394.5 C 321.9,394.2 322.6,393.8 323.4,393.5 C 324.2,393.1 324.9,392.9 325.7,392.6 C 326.4,392.4 327.2,392.2 328.0,392.0 C 328.7,391.9 329.5,391.8 330.3,391.7 C 331.0,391.6 331.8,391.4 332.5,391.2 C 333.3,391.0 334.1,390.9 334.8,390.7 C 335.6,390.5 336.4,390.3 337.1,390.0 C 337.9,389.6 338.6,389.0 339.4,388.8 C 340.2,388.5 340.9,388.4 341.7,388.2 C 342.5,388.1 343.2,387.9 344.0,387.8 C 344.7,387.7 345.5,387.6 346.3,387.5 C 347.0,387.4 347.8,387.2 348.6,387.2 C 349.3,387.1 350.1,387.1 350.8,387.0 C 351.6,386.9 352.4,386.7 353.1,386.4 C 353.9,386.1 354.7,385.6 355.4,385.3 C 356.2,385.0 356.9,384.6 357.7,384.4 C 358.5,384.2 359.2,384.1 360.0,384.0 C 360.8,383.9 361.5,383.8 362.3,383.6 C 363.1,383.3 363.8,382.8 364.6,382.6 C 365.3,382.4 366.1,382.4 366.9,382.3 C 367.6,382.2 368.4,382.0 369.2,381.8 C 369.9,381.6 370.7,381.4 371.4,381.3 C 372.2,381.1 373.0,380.8 373.7,380.7 C 374.5,380.5 375.3,380.4 376.0,380.2 C 376.8,379.9 377.5,379.6 378.3,379.3 C 379.1,379.1 379.8,379.0 380.6,378.7 C 381.4,378.5 382.1,378.2 382.9,377.9 C 383.6,377.6 384.4,377.4 385.2,377.1 C 385.9,376.8 386.7,376.8 387.5,376.4 C 388.2,376.0 389.0,375.1 389.7,374.7 C 390.5,374.3 391.3,374.3 392.0,374.1 C 392.8,374.0 393.6,374.0 394.3,373.9 C 395.1,373.8 395.8,373.7 396.6,373.5 C 397.4,373.4 398.1,373.6 398.9,373.3 C 399.7,372.9 400.4,371.8 401.2,371.4 C 401.9,371.0 402.7,371.1 403.5,370.8 C 404.2,370.6 405.0,370.1 405.8,369.9 C 406.5,369.7 407.3,369.8 408.1,369.6 C 408.8,369.4 409.6,368.9 410.3,368.7 C 411.1,368.5 411.9,368.5 412.6,368.4 C 413.4,368.3 414.2,368.3 414.9,368.1 C 415.7,367.9 416.4,367.6 417.2,367.1 C 418.0,366.6 418.7,365.5 419.5,365.0 C 420.3,364.5 421.0,364.2 421.8,363.9 C 422.5,363.5 423.3,363.4 424.1,362.7 C 424.8,361.9 425.6,360.2 426.4,359.4 C 427.1,358.6 427.9,358.3 428.6,357.8 C 429.4,357.4 430.2,357.3 430.9,356.6 C 431.7,355.9 432.5,354.2 433.2,353.5 C 434.0,352.8 434.7,352.7 435.5,352.3 C 436.3,351.9 437.0,351.6 437.8,351.1 C 438.6,35Line truncated
<path d="M 90.0,462.2 C 90.4,462.1 91.9,461.8 92.3,461.7 C 93.1,461.6 93.8,461.4 94.6,461.2 C 95.3,461.1 96.1,460.8 96.9,460.8 C 97.6,460.7 98.4,460.7 99.2,460.7 C 99.9,460.7 100.7,460.6 101.4,460.6 C 102.2,460.5 103.0,460.5 103.7,460.4 C 104.5,460.4 104.9,460.3 106.0,460.3 C 107.2,460.2 109.4,460.1 110.6,460.1 C 111.7,460.1 112.1,460.0 112.9,460.0 C 113.6,460.0 114.4,460.0 115.2,459.9 C 115.9,459.9 116.7,459.9 117.5,459.9 C 118.2,459.9 119.0,459.9 119.7,459.8 C 120.5,459.8 121.3,459.7 122.0,459.7 C 122.8,459.6 123.6,459.6 124.3,459.4 C 125.1,459.3 125.8,459.0 126.6,458.7 C 127.4,458.3 128.1,457.6 128.9,457.3 C 129.7,457.1 130.4,457.2 131.2,457.1 C 131.9,457.1 132.7,457.1 133.5,457.0 C 134.2,457.0 134.6,457.0 135.8,456.9 C 136.9,456.9 138.8,456.9 140.3,456.9 C 141.9,456.8 143.8,456.8 144.9,456.8 C 146.1,456.7 146.4,456.7 147.2,456.7 C 148.0,456.6 148.7,456.6 149.5,456.5 C 150.3,456.5 151.0,456.5 151.8,456.4 C 152.5,456.4 153.3,456.4 154.1,456.4 C 154.8,456.4 155.2,456.4 156.4,456.3 C 157.5,456.3 159.0,456.2 160.9,456.2 C 162.8,456.2 165.9,456.2 167.8,456.2 C 169.7,456.1 170.1,456.2 172.4,456.1 C 174.7,456.1 179.6,455.9 181.5,455.9 C 183.4,455.8 182.7,455.9 183.8,455.8 C 185.0,455.8 186.5,455.8 188.4,455.8 C 190.3,455.8 191.4,455.7 195.3,455.7 C 199.1,455.6 208.2,455.5 211.3,455.5 C 214.3,455.5 212.8,455.5 213.6,455.4 C 214.3,455.4 214.7,456.1 215.8,455.4 C 217.0,454.7 219.3,454.1 220.4,451.3 C 221.6,448.4 221.9,440.7 222.7,438.3 C 223.5,435.9 224.2,437.3 225.0,436.9 C 225.8,436.5 226.5,436.3 227.3,436.0 C 228.1,435.8 228.8,435.6 229.6,435.4 C 230.3,435.3 231.1,435.3 231.9,435.2 C 232.6,435.1 233.4,434.9 234.2,434.8 C 234.9,434.8 235.7,434.8 236.4,434.8 C 237.2,434.7 238.0,434.5 238.7,434.4 C 239.5,434.3 240.3,434.3 241.0,434.2 C 241.8,434.1 242.5,433.7 243.3,433.6 C 244.1,433.5 244.8,433.5 245.6,433.5 C 246.4,433.4 246.7,433.2 247.9,433.1 C 249.0,433.0 251.3,433.0 252.5,432.9 C 253.6,432.9 254.0,432.8 254.7,432.7 C 255.5,432.7 256.3,432.7 257.0,432.6 C 257.8,432.5 258.6,432.2 259.3,432.1 C 260.1,431.9 260.8,432.6 261.6,431.7 C 262.4,430.8 263.1,427.6 263.9,426.6 C 264.7,425.5 265.4,425.7 266.2,425.5 C 266.9,425.3 267.7,425.5 268.5,425.4 C 269.2,425.3 270.0,424.9 270.8,424.6 C 271.5,424.4 272.3,424.2 273.1,423.7 C 273.8,423.2 274.6,422.2 275.3,421.6 C 276.1,421.0 276.9,420.5 277.6,420.0 C 278.4,419.6 279.2,419.3 279.9,419.0 C 280.7,418.8 281.4,418.6 282.2,418.5 C 283.0,418.4 283.7,418.4 284.5,418.3 C 285.3,418.2 286.0,418.0 286.8,417.9 C 287.5,417.8 288.3,417.8 289.1,417.8 C 289.8,417.7 290.6,417.7 291.4,417.5 C 292.1,417.4 292.9,417.5 293.6,417.0 C 294.4,416.6 295.2,415.6 295.9,415.1 C 296.7,414.6 297.5,414.4 298.2,414.0 C 299.0,413.7 299.7,414.4 300.5,412.8 C 301.3,411.3 302.0,406.5 302.8,404.6 C 303.6,402.8 304.3,402.5 305.1,401.7 C 305.8,400.9 306.6,400.4 307.4,399.9 C 308.1,399.3 308.9,398.8 309.7,398.4 C 310.4,398.0 311.2,397.6 311.9,397.3 C 312.7,397.1 313.5,397.0 314.2,396.8 C 315.0,396.6 315.8,396.3 316.5,396.0 C 317.3,395.7 318.1,395.2 318.8,395.0 C 319.6,394.7 320.3,394.8 321.1,394.5 C 321.9,394.2 322.6,393.8 323.4,393.5 C 324.2,393.1 324.9,392.9 325.7,392.6 C 326.4,392.4 327.2,392.2 328.0,392.0 C 328.7,391.9 329.5,391.8 330.3,391.7 C 331.0,391.6 331.8,391.4 332.5,391.2 C 333.3,391.0 334.1,390.9 334.8,390.7 C 335.6,390.5 336.4,390.3 337.1,390.0 C 337.9,389.6 338.6,389.0 339.4,388.8 C 340.2,388.5 340.9,388.4 341.7,388.2 C 342.5,388.1 343.2,387.9 344.0,387.8 C 344.7,387.7 345.5,387.6 346.3,387.5 C 347.0,387.4 347.8,387.2 348.6,387.2 C 349.3,387.1 350.1,387.1 350.8,387.0 C 351.6,386.9 352.4,386.7 353.1,386.4 C 353.9,386.1 354.7,385.6 355.4,385.3 C 356.2,385.0 356.9,384.6 357.7,384.4 C 358.5,384.2 359.2,384.1 360.0,384.0 C 360.8,383.9 361.5,383.8 362.3,383.6 C 363.1,383.3 363.8,382.8 364.6,382.6 C 365.3,382.4 366.1,382.4 366.9,382.3 C 367.6,382.2 368.4,382.0 369.2,381.8 C 369.9,381.6 370.7,381.4 371.4,381.3 C 372.2,381.1 373.0,380.8 373.7,380.7 C 374.5,380.5 375.3,380.4 376.0,380.2 C 376.8,379.9 377.5,379.6 378.3,379.3 C 379.1,379.1 379.8,379.0 380.6,378.7 C 381.4,378.5 382.1,378.2 382.9,377.9 C 383.6,377.6 384.4,377.4 385.2,377.1 C 385.9,376.8 386.7,376.8 387.5,376.4 C 388.2,376.0 389.0,375.1 389.7,374.7 C 390.5,374.3 391.3,374.3 392.0,374.1 C 392.8,374.0 393.6,374.0 394.3,373.9 C 395.1,373.8 395.8,373.7 396.6,373.5 C 397.4,373.4 398.1,373.6 398.9,373.3 C 399.7,372.9 400.4,371.8 401.2,371.4 C 401.9,371.0 402.7,371.1 403.5,370.8 C 404.2,370.6 405.0,370.1 405.8,369.9 C 406.5,369.7 407.3,369.8 408.1,369.6 C 408.8,369.4 409.6,368.9 410.3,368.7 C 411.1,368.5 411.9,368.5 412.6,368.4 C 413.4,368.3 414.2,368.3 414.9,368.1 C 415.7,367.9 416.4,367.6 417.2,367.1 C 418.0,366.6 418.7,365.5 419.5,365.0 C 420.3,364.5 421.0,364.2 421.8,363.9 C 422.5,363.5 423.3,363.4 424.1,362.7 C 424.8,361.9 425.6,360.2 426.4,359.4 C 427.1,358.6 427.9,358.3 428.6,357.8 C 429.4,357.4 430.2,357.3 430.9,356.6 C 431.7,355.9 432.5,354.2 433.2,353.5 C 434.0,352.8 434.7,352.7 435.5,352.3 C 436.3,351.9 437.0,351.6 437.8,351.1 C 438.6,35Line truncated
<circle cx="765.0" cy="90.8" r="5" fill="#dd4528" stroke="#0d1117" stroke-width="2" filter="url(#xkcdify)"/>
<text x="753.0" y="77.0" fill="#dd4528" font-size="18" font-family="Handlee, cursive" font-weight="bold" text-anchor="end">6802</text>
<path d="M 90.0,462.2 C 90.4,462.1 92.0,461.8 92.3,461.7 C 93.1,461.6 93.9,461.4 94.7,461.2 C 95.5,461.1 96.2,460.8 97.0,460.8 C 97.8,460.7 98.6,460.7 99.4,460.7 C 100.2,460.7 100.9,460.6 101.7,460.6 C 102.5,460.5 103.3,460.5 104.1,460.4 C 104.8,460.4 105.2,460.3 106.4,460.3 C 107.6,460.2 109.9,460.1 111.1,460.1 C 112.3,460.1 112.7,460.0 113.4,460.0 C 114.2,460.0 115.0,460.0 115.8,459.9 C 116.6,459.9 117.3,459.9 118.1,459.9 C 118.9,459.9 119.7,459.9 120.5,459.8 C 121.2,459.8 122.0,459.7 122.8,459.7 C 123.6,459.6 124.4,459.6 125.2,459.4 C 125.9,459.3 126.7,459.0 127.5,458.7 C 128.3,458.3 129.1,457.6 129.8,457.3 C 130.6,457.1 131.4,457.2 132.2,457.1 C 133.0,457.1 133.8,457.1 134.5,457.0 C 135.3,457.0 135.7,457.0 136.9,456.9 C 138.0,456.9 140.0,456.9 141.6,456.9 C 143.1,456.8 145.1,456.8 146.2,456.8 C 147.4,456.7 147.8,456.7 148.6,456.7 C 149.4,456.6 150.2,456.6 150.9,456.5 C 151.7,456.5 152.5,456.5 153.3,456.4 C 154.1,456.4 154.8,456.4 155.6,456.4 C 156.4,456.4 156.8,456.4 158.0,456.3 C 159.1,456.3 160.7,456.2 162.7,456.2 C 164.6,456.2 167.7,456.2 169.7,456.2 C 171.6,456.1 172.0,456.2 174.4,456.1 C 176.7,456.1 181.8,455.9 183.8,455.9 C 185.7,455.8 184.9,455.9 186.1,455.8 C 187.3,455.8 188.8,455.8 190.8,455.8 C 192.7,455.8 193.9,455.7 197.8,455.7 C 201.7,455.6 211.1,455.5 214.2,455.5 C 217.3,455.5 215.8,455.5 216.6,455.4 C 217.3,455.4 217.7,456.1 218.9,455.4 C 220.1,454.7 222.4,454.1 223.6,451.3 C 224.8,448.4 225.2,440.7 225.9,438.3 C 226.7,435.9 227.5,437.3 228.3,436.9 C 229.1,436.5 229.8,436.3 230.6,436.0 C 231.4,435.8 232.2,435.6 233.0,435.4 C 233.8,435.3 234.5,435.3 235.3,435.2 C 236.1,435.1 236.9,434.9 237.7,434.8 C 238.4,434.8 239.2,434.8 240.0,434.8 C 240.8,434.7 241.6,434.5 242.3,434.4 C 243.1,434.3 243.9,434.3 244.7,434.2 C 245.5,434.1 246.2,433.7 247.0,433.6 C 247.8,433.5 248.6,433.5 249.4,433.5 C 250.2,433.4 250.5,433.2 251.7,433.1 C 252.9,433.0 255.2,433.0 256.4,432.9 C 257.6,432.9 258.0,432.8 258.8,432.7 C 259.5,432.7 260.3,432.7 261.1,432.6 C 261.9,432.5 262.7,432.2 263.4,432.1 C 264.2,431.9 265.0,432.6 265.8,431.7 C 266.6,430.8 267.3,427.6 268.1,426.6 C 268.9,425.5 269.7,425.7 270.5,425.5 C 271.2,425.3 272.0,425.5 272.8,425.4 C 273.6,425.3 274.4,424.9 275.2,424.6 C 275.9,424.4 276.7,424.2 277.5,423.7 C 278.3,423.2 279.1,422.2 279.8,421.6 C 280.6,421.0 281.4,420.4 282.2,420.0 C 283.0,419.5 283.8,419.2 284.5,419.0 C 285.3,418.7 286.1,418.6 286.9,418.5 C 287.7,418.3 288.4,418.4 289.2,418.2 C 290.0,418.1 290.8,417.9 291.6,417.8 C 292.3,417.7 293.1,417.8 293.9,417.7 C 294.7,417.6 295.5,417.6 296.2,417.5 C 297.0,417.4 297.8,417.4 298.6,417.0 C 299.4,416.6 300.2,415.5 300.9,415.0 C 301.7,414.5 302.5,414.3 303.3,414.0 C 304.1,413.6 304.8,414.3 305.6,412.8 C 306.4,411.2 307.2,406.4 308.0,404.6 C 308.8,402.7 309.5,402.4 310.3,401.7 C 311.1,400.9 311.9,400.4 312.7,399.9 C 313.4,399.3 314.2,398.8 315.0,398.4 C 315.8,398.0 316.6,397.6 317.3,397.3 C 318.1,397.1 318.9,397.0 319.7,396.8 C 320.5,396.6 321.2,396.3 322.0,396.0 C 322.8,395.7 323.6,395.2 324.4,395.0 C 325.2,394.7 325.9,394.8 326.7,394.5 C 327.5,394.2 328.3,393.8 329.1,393.5 C 329.8,393.1 330.6,392.9 331.4,392.6 C 332.2,392.4 333.0,392.2 333.8,392.0 C 334.5,391.9 335.3,391.8 336.1,391.7 C 336.9,391.6 337.7,391.4 338.4,391.2 C 339.2,391.0 340.0,390.9 340.8,390.7 C 341.6,390.5 342.3,390.3 343.1,390.0 C 343.9,389.6 344.7,389.0 345.5,388.8 C 346.2,388.5 347.0,388.4 347.8,388.2 C 348.6,388.1 349.4,387.9 350.2,387.8 C 350.9,387.7 351.7,387.6 352.5,387.5 C 353.3,387.4 354.1,387.2 354.8,387.2 C 355.6,387.1 356.4,387.1 357.2,387.0 C 358.0,386.9 358.8,386.7 359.5,386.4 C 360.3,386.1 361.1,385.6 361.9,385.3 C 362.7,385.0 363.4,384.6 364.2,384.4 C 365.0,384.2 365.8,384.1 366.6,384.0 C 367.3,383.9 368.1,383.8 368.9,383.6 C 369.7,383.3 370.5,382.8 371.2,382.6 C 372.0,382.4 372.8,382.4 373.6,382.3 C 374.4,382.2 375.2,382.0 375.9,381.8 C 376.7,381.6 377.5,381.4 378.3,381.3 C 379.1,381.1 379.8,380.8 380.6,380.7 C 381.4,380.5 382.2,380.4 383.0,380.2 C 383.8,379.9 384.5,379.6 385.3,379.3 C 386.1,379.1 386.9,379.0 387.7,378.7 C 388.4,378.5 389.2,378.2 390.0,377.9 C 390.8,377.6 391.6,377.4 392.3,377.1 C 393.1,376.8 393.9,376.8 394.7,376.4 C 395.5,376.0 396.2,375.1 397.0,374.7 C 397.8,374.3 398.6,374.3 399.4,374.1 C 400.2,374.0 400.9,374.0 401.7,373.9 C 402.5,373.8 403.3,373.7 404.1,373.5 C 404.8,373.4 405.6,373.6 406.4,373.3 C 407.2,372.9 408.0,371.8 408.8,371.4 C 409.5,371.0 410.3,371.1 411.1,370.8 C 411.9,370.6 412.7,370.1 413.4,369.9 C 414.2,369.7 415.0,369.8 415.8,369.6 C 416.6,369.4 417.3,368.9 418.1,368.7 C 418.9,368.5 419.7,368.5 420.5,368.4 C 421.2,368.3 422.0,368.3 422.8,368.1 C 423.6,367.9 424.4,367.6 425.2,367.1 C 425.9,366.6 426.7,365.5 427.5,365.0 C 428.3,364.5 429.1,364.2 429.8,363.9 C 430.6,363.5 431.4,363.4 432.2,362.7 C 433.0,361.9 433.8,360.2 434.5,359.4 C 435.3,358.6 436.1,358.3 436.9,357.8 C 437.7,357.3 438.4,357.2 439.2,356.5 C 440.0,355.8 440.8,354.2 441.6,353.5 C 442.3,352.8 443.1,352.7 443.9,352.3 C 444.7,351.9 445.5,351.5 446.2,351.1 C 447.0,3Line truncated
<path d="M 90.0,462.2 C 90.4,462.1 92.0,461.8 92.3,461.7 C 93.1,461.6 93.9,461.4 94.7,461.2 C 95.5,461.1 96.2,460.8 97.0,460.8 C 97.8,460.7 98.6,460.7 99.4,460.7 C 100.2,460.7 100.9,460.6 101.7,460.6 C 102.5,460.5 103.3,460.5 104.1,460.4 C 104.8,460.4 105.2,460.3 106.4,460.3 C 107.6,460.2 109.9,460.1 111.1,460.1 C 112.3,460.1 112.7,460.0 113.4,460.0 C 114.2,460.0 115.0,460.0 115.8,459.9 C 116.6,459.9 117.3,459.9 118.1,459.9 C 118.9,459.9 119.7,459.9 120.5,459.8 C 121.2,459.8 122.0,459.7 122.8,459.7 C 123.6,459.6 124.4,459.6 125.2,459.4 C 125.9,459.3 126.7,459.0 127.5,458.7 C 128.3,458.3 129.1,457.6 129.8,457.3 C 130.6,457.1 131.4,457.2 132.2,457.1 C 133.0,457.1 133.8,457.1 134.5,457.0 C 135.3,457.0 135.7,457.0 136.9,456.9 C 138.0,456.9 140.0,456.9 141.6,456.9 C 143.1,456.8 145.1,456.8 146.2,456.8 C 147.4,456.7 147.8,456.7 148.6,456.7 C 149.4,456.6 150.2,456.6 150.9,456.5 C 151.7,456.5 152.5,456.5 153.3,456.4 C 154.1,456.4 154.8,456.4 155.6,456.4 C 156.4,456.4 156.8,456.4 158.0,456.3 C 159.1,456.3 160.7,456.2 162.7,456.2 C 164.6,456.2 167.7,456.2 169.7,456.2 C 171.6,456.1 172.0,456.2 174.4,456.1 C 176.7,456.1 181.8,455.9 183.8,455.9 C 185.7,455.8 184.9,455.9 186.1,455.8 C 187.3,455.8 188.8,455.8 190.8,455.8 C 192.7,455.8 193.9,455.7 197.8,455.7 C 201.7,455.6 211.1,455.5 214.2,455.5 C 217.3,455.5 215.8,455.5 216.6,455.4 C 217.3,455.4 217.7,456.1 218.9,455.4 C 220.1,454.7 222.4,454.1 223.6,451.3 C 224.8,448.4 225.2,440.7 225.9,438.3 C 226.7,435.9 227.5,437.3 228.3,436.9 C 229.1,436.5 229.8,436.3 230.6,436.0 C 231.4,435.8 232.2,435.6 233.0,435.4 C 233.8,435.3 234.5,435.3 235.3,435.2 C 236.1,435.1 236.9,434.9 237.7,434.8 C 238.4,434.8 239.2,434.8 240.0,434.8 C 240.8,434.7 241.6,434.5 242.3,434.4 C 243.1,434.3 243.9,434.3 244.7,434.2 C 245.5,434.1 246.2,433.7 247.0,433.6 C 247.8,433.5 248.6,433.5 249.4,433.5 C 250.2,433.4 250.5,433.2 251.7,433.1 C 252.9,433.0 255.2,433.0 256.4,432.9 C 257.6,432.9 258.0,432.8 258.8,432.7 C 259.5,432.7 260.3,432.7 261.1,432.6 C 261.9,432.5 262.7,432.2 263.4,432.1 C 264.2,431.9 265.0,432.6 265.8,431.7 C 266.6,430.8 267.3,427.6 268.1,426.6 C 268.9,425.5 269.7,425.7 270.5,425.5 C 271.2,425.3 272.0,425.5 272.8,425.4 C 273.6,425.3 274.4,424.9 275.2,424.6 C 275.9,424.4 276.7,424.2 277.5,423.7 C 278.3,423.2 279.1,422.2 279.8,421.6 C 280.6,421.0 281.4,420.4 282.2,420.0 C 283.0,419.5 283.8,419.2 284.5,419.0 C 285.3,418.7 286.1,418.6 286.9,418.5 C 287.7,418.3 288.4,418.4 289.2,418.2 C 290.0,418.1 290.8,417.9 291.6,417.8 C 292.3,417.7 293.1,417.8 293.9,417.7 C 294.7,417.6 295.5,417.6 296.2,417.5 C 297.0,417.4 297.8,417.4 298.6,417.0 C 299.4,416.6 300.2,415.5 300.9,415.0 C 301.7,414.5 302.5,414.3 303.3,414.0 C 304.1,413.6 304.8,414.3 305.6,412.8 C 306.4,411.2 307.2,406.4 308.0,404.6 C 308.8,402.7 309.5,402.4 310.3,401.7 C 311.1,400.9 311.9,400.4 312.7,399.9 C 313.4,399.3 314.2,398.8 315.0,398.4 C 315.8,398.0 316.6,397.6 317.3,397.3 C 318.1,397.1 318.9,397.0 319.7,396.8 C 320.5,396.6 321.2,396.3 322.0,396.0 C 322.8,395.7 323.6,395.2 324.4,395.0 C 325.2,394.7 325.9,394.8 326.7,394.5 C 327.5,394.2 328.3,393.8 329.1,393.5 C 329.8,393.1 330.6,392.9 331.4,392.6 C 332.2,392.4 333.0,392.2 333.8,392.0 C 334.5,391.9 335.3,391.8 336.1,391.7 C 336.9,391.6 337.7,391.4 338.4,391.2 C 339.2,391.0 340.0,390.9 340.8,390.7 C 341.6,390.5 342.3,390.3 343.1,390.0 C 343.9,389.6 344.7,389.0 345.5,388.8 C 346.2,388.5 347.0,388.4 347.8,388.2 C 348.6,388.1 349.4,387.9 350.2,387.8 C 350.9,387.7 351.7,387.6 352.5,387.5 C 353.3,387.4 354.1,387.2 354.8,387.2 C 355.6,387.1 356.4,387.1 357.2,387.0 C 358.0,386.9 358.8,386.7 359.5,386.4 C 360.3,386.1 361.1,385.6 361.9,385.3 C 362.7,385.0 363.4,384.6 364.2,384.4 C 365.0,384.2 365.8,384.1 366.6,384.0 C 367.3,383.9 368.1,383.8 368.9,383.6 C 369.7,383.3 370.5,382.8 371.2,382.6 C 372.0,382.4 372.8,382.4 373.6,382.3 C 374.4,382.2 375.2,382.0 375.9,381.8 C 376.7,381.6 377.5,381.4 378.3,381.3 C 379.1,381.1 379.8,380.8 380.6,380.7 C 381.4,380.5 382.2,380.4 383.0,380.2 C 383.8,379.9 384.5,379.6 385.3,379.3 C 386.1,379.1 386.9,379.0 387.7,378.7 C 388.4,378.5 389.2,378.2 390.0,377.9 C 390.8,377.6 391.6,377.4 392.3,377.1 C 393.1,376.8 393.9,376.8 394.7,376.4 C 395.5,376.0 396.2,375.1 397.0,374.7 C 397.8,374.3 398.6,374.3 399.4,374.1 C 400.2,374.0 400.9,374.0 401.7,373.9 C 402.5,373.8 403.3,373.7 404.1,373.5 C 404.8,373.4 405.6,373.6 406.4,373.3 C 407.2,372.9 408.0,371.8 408.8,371.4 C 409.5,371.0 410.3,371.1 411.1,370.8 C 411.9,370.6 412.7,370.1 413.4,369.9 C 414.2,369.7 415.0,369.8 415.8,369.6 C 416.6,369.4 417.3,368.9 418.1,368.7 C 418.9,368.5 419.7,368.5 420.5,368.4 C 421.2,368.3 422.0,368.3 422.8,368.1 C 423.6,367.9 424.4,367.6 425.2,367.1 C 425.9,366.6 426.7,365.5 427.5,365.0 C 428.3,364.5 429.1,364.2 429.8,363.9 C 430.6,363.5 431.4,363.4 432.2,362.7 C 433.0,361.9 433.8,360.2 434.5,359.4 C 435.3,358.6 436.1,358.3 436.9,357.8 C 437.7,357.3 438.4,357.2 439.2,356.5 C 440.0,355.8 440.8,354.2 441.6,353.5 C 442.3,352.8 443.1,352.7 443.9,352.3 C 444.7,351.9 445.5,351.5 446.2,351.1 C 447.0,3Line truncated
<circle cx="765.0" cy="99.0" r="5" fill="#dd4528" stroke="#0d1117" stroke-width="2" filter="url(#xkcdify)"/>
<text x="753.0" y="77.0" fill="#dd4528" font-size="18" font-family="Handlee, cursive" font-weight="bold" text-anchor="end">6652</text>
<a href="https://github.com/carsteneu/mystarhistory" target="_blank" rel="noopener"><text x="763" y="527" font-size="13" font-family="Handlee, cursive" fill="#fff" text-anchor="end">Made with carsteneu/mystarhistory</text></a>
</svg>

Before

Width:  |  Height:  |  Size: 36 KiB

After

Width:  |  Height:  |  Size: 36 KiB

@@ -31,17 +31,20 @@
<text x="85" y="85.0" fill="#000" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="end">7K</text>
<text x="43" y="271.5" fill="#000" font-size="17" font-family="Handlee, cursive" font-weight="bold" transform="rotate(-90, 43, 271.5)">GitHub Stars</text>
<text x="90.0" y="488" fill="#000" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Jan 2026</text>
<text x="220.4" y="488" fill="#000" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Mar 2026</text>
<text x="291.4" y="488" fill="#000" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Apr 2026</text>
<text x="430.9" y="488" fill="#000" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Jun 2026</text>
<text x="570.5" y="488" fill="#000" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Aug 2026</text>
<text x="710.1" y="488" fill="#000" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Oct 2026</text>
<text x="223.6" y="488" fill="#000" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Mar 2026</text>
<text x="296.2" y="488" fill="#000" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Apr 2026</text>
<text x="366.6" y="488" fill="#000" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">May 2026</text>
<text x="439.2" y="488" fill="#000" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Jun 2026</text>
<text x="509.5" y="488" fill="#000" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Jul 2026</text>
<text x="582.2" y="488" fill="#000" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Aug 2026</text>
<text x="654.8" y="488" fill="#000" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Sep 2026</text>
<text x="725.2" y="488" fill="#000" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Oct 2026</text>
<text x="50%" y="525" fill="#000" font-size="17" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Date</text>
<line x1="90" y1="80" x2="90" y2="463" stroke="#222" stroke-width="2.5" filter="url(#xkcdify)"/>
<line x1="90" y1="463" x2="765" y2="463" stroke="#222" stroke-width="2.5" filter="url(#xkcdify)"/>
<path d="M 90.0,462.2 C 90.4,462.1 91.9,461.8 92.3,461.7 C 93.1,461.6 93.8,461.4 94.6,461.2 C 95.3,461.1 96.1,460.8 96.9,460.8 C 97.6,460.7 98.4,460.7 99.2,460.7 C 99.9,460.7 100.7,460.6 101.4,460.6 C 102.2,460.5 103.0,460.5 103.7,460.4 C 104.5,460.4 104.9,460.3 106.0,460.3 C 107.2,460.2 109.4,460.1 110.6,460.1 C 111.7,460.1 112.1,460.0 112.9,460.0 C 113.6,460.0 114.4,460.0 115.2,459.9 C 115.9,459.9 116.7,459.9 117.5,459.9 C 118.2,459.9 119.0,459.9 119.7,459.8 C 120.5,459.8 121.3,459.7 122.0,459.7 C 122.8,459.6 123.6,459.6 124.3,459.4 C 125.1,459.3 125.8,459.0 126.6,458.7 C 127.4,458.3 128.1,457.6 128.9,457.3 C 129.7,457.1 130.4,457.2 131.2,457.1 C 131.9,457.1 132.7,457.1 133.5,457.0 C 134.2,457.0 134.6,457.0 135.8,456.9 C 136.9,456.9 138.8,456.9 140.3,456.9 C 141.9,456.8 143.8,456.8 144.9,456.8 C 146.1,456.7 146.4,456.7 147.2,456.7 C 148.0,456.6 148.7,456.6 149.5,456.5 C 150.3,456.5 151.0,456.5 151.8,456.4 C 152.5,456.4 153.3,456.4 154.1,456.4 C 154.8,456.4 155.2,456.4 156.4,456.3 C 157.5,456.3 159.0,456.2 160.9,456.2 C 162.8,456.2 165.9,456.2 167.8,456.2 C 169.7,456.1 170.1,456.2 172.4,456.1 C 174.7,456.1 179.6,455.9 181.5,455.9 C 183.4,455.8 182.7,455.9 183.8,455.8 C 185.0,455.8 186.5,455.8 188.4,455.8 C 190.3,455.8 191.4,455.7 195.3,455.7 C 199.1,455.6 208.2,455.5 211.3,455.5 C 214.3,455.5 212.8,455.5 213.6,455.4 C 214.3,455.4 214.7,456.1 215.8,455.4 C 217.0,454.7 219.3,454.1 220.4,451.3 C 221.6,448.4 221.9,440.7 222.7,438.3 C 223.5,435.9 224.2,437.3 225.0,436.9 C 225.8,436.5 226.5,436.3 227.3,436.0 C 228.1,435.8 228.8,435.6 229.6,435.4 C 230.3,435.3 231.1,435.3 231.9,435.2 C 232.6,435.1 233.4,434.9 234.2,434.8 C 234.9,434.8 235.7,434.8 236.4,434.8 C 237.2,434.7 238.0,434.5 238.7,434.4 C 239.5,434.3 240.3,434.3 241.0,434.2 C 241.8,434.1 242.5,433.7 243.3,433.6 C 244.1,433.5 244.8,433.5 245.6,433.5 C 246.4,433.4 246.7,433.2 247.9,433.1 C 249.0,433.0 251.3,433.0 252.5,432.9 C 253.6,432.9 254.0,432.8 254.7,432.7 C 255.5,432.7 256.3,432.7 257.0,432.6 C 257.8,432.5 258.6,432.2 259.3,432.1 C 260.1,431.9 260.8,432.6 261.6,431.7 C 262.4,430.8 263.1,427.6 263.9,426.6 C 264.7,425.5 265.4,425.7 266.2,425.5 C 266.9,425.3 267.7,425.5 268.5,425.4 C 269.2,425.3 270.0,424.9 270.8,424.6 C 271.5,424.4 272.3,424.2 273.1,423.7 C 273.8,423.2 274.6,422.2 275.3,421.6 C 276.1,421.0 276.9,420.5 277.6,420.0 C 278.4,419.6 279.2,419.3 279.9,419.0 C 280.7,418.8 281.4,418.6 282.2,418.5 C 283.0,418.4 283.7,418.4 284.5,418.3 C 285.3,418.2 286.0,418.0 286.8,417.9 C 287.5,417.8 288.3,417.8 289.1,417.8 C 289.8,417.7 290.6,417.7 291.4,417.5 C 292.1,417.4 292.9,417.5 293.6,417.0 C 294.4,416.6 295.2,415.6 295.9,415.1 C 296.7,414.6 297.5,414.4 298.2,414.0 C 299.0,413.7 299.7,414.4 300.5,412.8 C 301.3,411.3 302.0,406.5 302.8,404.6 C 303.6,402.8 304.3,402.5 305.1,401.7 C 305.8,400.9 306.6,400.4 307.4,399.9 C 308.1,399.3 308.9,398.8 309.7,398.4 C 310.4,398.0 311.2,397.6 311.9,397.3 C 312.7,397.1 313.5,397.0 314.2,396.8 C 315.0,396.6 315.8,396.3 316.5,396.0 C 317.3,395.7 318.1,395.2 318.8,395.0 C 319.6,394.7 320.3,394.8 321.1,394.5 C 321.9,394.2 322.6,393.8 323.4,393.5 C 324.2,393.1 324.9,392.9 325.7,392.6 C 326.4,392.4 327.2,392.2 328.0,392.0 C 328.7,391.9 329.5,391.8 330.3,391.7 C 331.0,391.6 331.8,391.4 332.5,391.2 C 333.3,391.0 334.1,390.9 334.8,390.7 C 335.6,390.5 336.4,390.3 337.1,390.0 C 337.9,389.6 338.6,389.0 339.4,388.8 C 340.2,388.5 340.9,388.4 341.7,388.2 C 342.5,388.1 343.2,387.9 344.0,387.8 C 344.7,387.7 345.5,387.6 346.3,387.5 C 347.0,387.4 347.8,387.2 348.6,387.2 C 349.3,387.1 350.1,387.1 350.8,387.0 C 351.6,386.9 352.4,386.7 353.1,386.4 C 353.9,386.1 354.7,385.6 355.4,385.3 C 356.2,385.0 356.9,384.6 357.7,384.4 C 358.5,384.2 359.2,384.1 360.0,384.0 C 360.8,383.9 361.5,383.8 362.3,383.6 C 363.1,383.3 363.8,382.8 364.6,382.6 C 365.3,382.4 366.1,382.4 366.9,382.3 C 367.6,382.2 368.4,382.0 369.2,381.8 C 369.9,381.6 370.7,381.4 371.4,381.3 C 372.2,381.1 373.0,380.8 373.7,380.7 C 374.5,380.5 375.3,380.4 376.0,380.2 C 376.8,379.9 377.5,379.6 378.3,379.3 C 379.1,379.1 379.8,379.0 380.6,378.7 C 381.4,378.5 382.1,378.2 382.9,377.9 C 383.6,377.6 384.4,377.4 385.2,377.1 C 385.9,376.8 386.7,376.8 387.5,376.4 C 388.2,376.0 389.0,375.1 389.7,374.7 C 390.5,374.3 391.3,374.3 392.0,374.1 C 392.8,374.0 393.6,374.0 394.3,373.9 C 395.1,373.8 395.8,373.7 396.6,373.5 C 397.4,373.4 398.1,373.6 398.9,373.3 C 399.7,372.9 400.4,371.8 401.2,371.4 C 401.9,371.0 402.7,371.1 403.5,370.8 C 404.2,370.6 405.0,370.1 405.8,369.9 C 406.5,369.7 407.3,369.8 408.1,369.6 C 408.8,369.4 409.6,368.9 410.3,368.7 C 411.1,368.5 411.9,368.5 412.6,368.4 C 413.4,368.3 414.2,368.3 414.9,368.1 C 415.7,367.9 416.4,367.6 417.2,367.1 C 418.0,366.6 418.7,365.5 419.5,365.0 C 420.3,364.5 421.0,364.2 421.8,363.9 C 422.5,363.5 423.3,363.4 424.1,362.7 C 424.8,361.9 425.6,360.2 426.4,359.4 C 427.1,358.6 427.9,358.3 428.6,357.8 C 429.4,357.4 430.2,357.3 430.9,356.6 C 431.7,355.9 432.5,354.2 433.2,353.5 C 434.0,352.8 434.7,352.7 435.5,352.3 C 436.3,351.9 437.0,351.6 437.8,351.1 C 438.6,35Line truncated
<path d="M 90.0,462.2 C 90.4,462.1 91.9,461.8 92.3,461.7 C 93.1,461.6 93.8,461.4 94.6,461.2 C 95.3,461.1 96.1,460.8 96.9,460.8 C 97.6,460.7 98.4,460.7 99.2,460.7 C 99.9,460.7 100.7,460.6 101.4,460.6 C 102.2,460.5 103.0,460.5 103.7,460.4 C 104.5,460.4 104.9,460.3 106.0,460.3 C 107.2,460.2 109.4,460.1 110.6,460.1 C 111.7,460.1 112.1,460.0 112.9,460.0 C 113.6,460.0 114.4,460.0 115.2,459.9 C 115.9,459.9 116.7,459.9 117.5,459.9 C 118.2,459.9 119.0,459.9 119.7,459.8 C 120.5,459.8 121.3,459.7 122.0,459.7 C 122.8,459.6 123.6,459.6 124.3,459.4 C 125.1,459.3 125.8,459.0 126.6,458.7 C 127.4,458.3 128.1,457.6 128.9,457.3 C 129.7,457.1 130.4,457.2 131.2,457.1 C 131.9,457.1 132.7,457.1 133.5,457.0 C 134.2,457.0 134.6,457.0 135.8,456.9 C 136.9,456.9 138.8,456.9 140.3,456.9 C 141.9,456.8 143.8,456.8 144.9,456.8 C 146.1,456.7 146.4,456.7 147.2,456.7 C 148.0,456.6 148.7,456.6 149.5,456.5 C 150.3,456.5 151.0,456.5 151.8,456.4 C 152.5,456.4 153.3,456.4 154.1,456.4 C 154.8,456.4 155.2,456.4 156.4,456.3 C 157.5,456.3 159.0,456.2 160.9,456.2 C 162.8,456.2 165.9,456.2 167.8,456.2 C 169.7,456.1 170.1,456.2 172.4,456.1 C 174.7,456.1 179.6,455.9 181.5,455.9 C 183.4,455.8 182.7,455.9 183.8,455.8 C 185.0,455.8 186.5,455.8 188.4,455.8 C 190.3,455.8 191.4,455.7 195.3,455.7 C 199.1,455.6 208.2,455.5 211.3,455.5 C 214.3,455.5 212.8,455.5 213.6,455.4 C 214.3,455.4 214.7,456.1 215.8,455.4 C 217.0,454.7 219.3,454.1 220.4,451.3 C 221.6,448.4 221.9,440.7 222.7,438.3 C 223.5,435.9 224.2,437.3 225.0,436.9 C 225.8,436.5 226.5,436.3 227.3,436.0 C 228.1,435.8 228.8,435.6 229.6,435.4 C 230.3,435.3 231.1,435.3 231.9,435.2 C 232.6,435.1 233.4,434.9 234.2,434.8 C 234.9,434.8 235.7,434.8 236.4,434.8 C 237.2,434.7 238.0,434.5 238.7,434.4 C 239.5,434.3 240.3,434.3 241.0,434.2 C 241.8,434.1 242.5,433.7 243.3,433.6 C 244.1,433.5 244.8,433.5 245.6,433.5 C 246.4,433.4 246.7,433.2 247.9,433.1 C 249.0,433.0 251.3,433.0 252.5,432.9 C 253.6,432.9 254.0,432.8 254.7,432.7 C 255.5,432.7 256.3,432.7 257.0,432.6 C 257.8,432.5 258.6,432.2 259.3,432.1 C 260.1,431.9 260.8,432.6 261.6,431.7 C 262.4,430.8 263.1,427.6 263.9,426.6 C 264.7,425.5 265.4,425.7 266.2,425.5 C 266.9,425.3 267.7,425.5 268.5,425.4 C 269.2,425.3 270.0,424.9 270.8,424.6 C 271.5,424.4 272.3,424.2 273.1,423.7 C 273.8,423.2 274.6,422.2 275.3,421.6 C 276.1,421.0 276.9,420.5 277.6,420.0 C 278.4,419.6 279.2,419.3 279.9,419.0 C 280.7,418.8 281.4,418.6 282.2,418.5 C 283.0,418.4 283.7,418.4 284.5,418.3 C 285.3,418.2 286.0,418.0 286.8,417.9 C 287.5,417.8 288.3,417.8 289.1,417.8 C 289.8,417.7 290.6,417.7 291.4,417.5 C 292.1,417.4 292.9,417.5 293.6,417.0 C 294.4,416.6 295.2,415.6 295.9,415.1 C 296.7,414.6 297.5,414.4 298.2,414.0 C 299.0,413.7 299.7,414.4 300.5,412.8 C 301.3,411.3 302.0,406.5 302.8,404.6 C 303.6,402.8 304.3,402.5 305.1,401.7 C 305.8,400.9 306.6,400.4 307.4,399.9 C 308.1,399.3 308.9,398.8 309.7,398.4 C 310.4,398.0 311.2,397.6 311.9,397.3 C 312.7,397.1 313.5,397.0 314.2,396.8 C 315.0,396.6 315.8,396.3 316.5,396.0 C 317.3,395.7 318.1,395.2 318.8,395.0 C 319.6,394.7 320.3,394.8 321.1,394.5 C 321.9,394.2 322.6,393.8 323.4,393.5 C 324.2,393.1 324.9,392.9 325.7,392.6 C 326.4,392.4 327.2,392.2 328.0,392.0 C 328.7,391.9 329.5,391.8 330.3,391.7 C 331.0,391.6 331.8,391.4 332.5,391.2 C 333.3,391.0 334.1,390.9 334.8,390.7 C 335.6,390.5 336.4,390.3 337.1,390.0 C 337.9,389.6 338.6,389.0 339.4,388.8 C 340.2,388.5 340.9,388.4 341.7,388.2 C 342.5,388.1 343.2,387.9 344.0,387.8 C 344.7,387.7 345.5,387.6 346.3,387.5 C 347.0,387.4 347.8,387.2 348.6,387.2 C 349.3,387.1 350.1,387.1 350.8,387.0 C 351.6,386.9 352.4,386.7 353.1,386.4 C 353.9,386.1 354.7,385.6 355.4,385.3 C 356.2,385.0 356.9,384.6 357.7,384.4 C 358.5,384.2 359.2,384.1 360.0,384.0 C 360.8,383.9 361.5,383.8 362.3,383.6 C 363.1,383.3 363.8,382.8 364.6,382.6 C 365.3,382.4 366.1,382.4 366.9,382.3 C 367.6,382.2 368.4,382.0 369.2,381.8 C 369.9,381.6 370.7,381.4 371.4,381.3 C 372.2,381.1 373.0,380.8 373.7,380.7 C 374.5,380.5 375.3,380.4 376.0,380.2 C 376.8,379.9 377.5,379.6 378.3,379.3 C 379.1,379.1 379.8,379.0 380.6,378.7 C 381.4,378.5 382.1,378.2 382.9,377.9 C 383.6,377.6 384.4,377.4 385.2,377.1 C 385.9,376.8 386.7,376.8 387.5,376.4 C 388.2,376.0 389.0,375.1 389.7,374.7 C 390.5,374.3 391.3,374.3 392.0,374.1 C 392.8,374.0 393.6,374.0 394.3,373.9 C 395.1,373.8 395.8,373.7 396.6,373.5 C 397.4,373.4 398.1,373.6 398.9,373.3 C 399.7,372.9 400.4,371.8 401.2,371.4 C 401.9,371.0 402.7,371.1 403.5,370.8 C 404.2,370.6 405.0,370.1 405.8,369.9 C 406.5,369.7 407.3,369.8 408.1,369.6 C 408.8,369.4 409.6,368.9 410.3,368.7 C 411.1,368.5 411.9,368.5 412.6,368.4 C 413.4,368.3 414.2,368.3 414.9,368.1 C 415.7,367.9 416.4,367.6 417.2,367.1 C 418.0,366.6 418.7,365.5 419.5,365.0 C 420.3,364.5 421.0,364.2 421.8,363.9 C 422.5,363.5 423.3,363.4 424.1,362.7 C 424.8,361.9 425.6,360.2 426.4,359.4 C 427.1,358.6 427.9,358.3 428.6,357.8 C 429.4,357.4 430.2,357.3 430.9,356.6 C 431.7,355.9 432.5,354.2 433.2,353.5 C 434.0,352.8 434.7,352.7 435.5,352.3 C 436.3,351.9 437.0,351.6 437.8,351.1 C 438.6,35Line truncated
<circle cx="765.0" cy="90.8" r="5" fill="#dd4528" stroke="#fff" stroke-width="2" filter="url(#xkcdify)"/>
<text x="753.0" y="77.0" fill="#dd4528" font-size="18" font-family="Handlee, cursive" font-weight="bold" text-anchor="end">6802</text>
<path d="M 90.0,462.2 C 90.4,462.1 92.0,461.8 92.3,461.7 C 93.1,461.6 93.9,461.4 94.7,461.2 C 95.5,461.1 96.2,460.8 97.0,460.8 C 97.8,460.7 98.6,460.7 99.4,460.7 C 100.2,460.7 100.9,460.6 101.7,460.6 C 102.5,460.5 103.3,460.5 104.1,460.4 C 104.8,460.4 105.2,460.3 106.4,460.3 C 107.6,460.2 109.9,460.1 111.1,460.1 C 112.3,460.1 112.7,460.0 113.4,460.0 C 114.2,460.0 115.0,460.0 115.8,459.9 C 116.6,459.9 117.3,459.9 118.1,459.9 C 118.9,459.9 119.7,459.9 120.5,459.8 C 121.2,459.8 122.0,459.7 122.8,459.7 C 123.6,459.6 124.4,459.6 125.2,459.4 C 125.9,459.3 126.7,459.0 127.5,458.7 C 128.3,458.3 129.1,457.6 129.8,457.3 C 130.6,457.1 131.4,457.2 132.2,457.1 C 133.0,457.1 133.8,457.1 134.5,457.0 C 135.3,457.0 135.7,457.0 136.9,456.9 C 138.0,456.9 140.0,456.9 141.6,456.9 C 143.1,456.8 145.1,456.8 146.2,456.8 C 147.4,456.7 147.8,456.7 148.6,456.7 C 149.4,456.6 150.2,456.6 150.9,456.5 C 151.7,456.5 152.5,456.5 153.3,456.4 C 154.1,456.4 154.8,456.4 155.6,456.4 C 156.4,456.4 156.8,456.4 158.0,456.3 C 159.1,456.3 160.7,456.2 162.7,456.2 C 164.6,456.2 167.7,456.2 169.7,456.2 C 171.6,456.1 172.0,456.2 174.4,456.1 C 176.7,456.1 181.8,455.9 183.8,455.9 C 185.7,455.8 184.9,455.9 186.1,455.8 C 187.3,455.8 188.8,455.8 190.8,455.8 C 192.7,455.8 193.9,455.7 197.8,455.7 C 201.7,455.6 211.1,455.5 214.2,455.5 C 217.3,455.5 215.8,455.5 216.6,455.4 C 217.3,455.4 217.7,456.1 218.9,455.4 C 220.1,454.7 222.4,454.1 223.6,451.3 C 224.8,448.4 225.2,440.7 225.9,438.3 C 226.7,435.9 227.5,437.3 228.3,436.9 C 229.1,436.5 229.8,436.3 230.6,436.0 C 231.4,435.8 232.2,435.6 233.0,435.4 C 233.8,435.3 234.5,435.3 235.3,435.2 C 236.1,435.1 236.9,434.9 237.7,434.8 C 238.4,434.8 239.2,434.8 240.0,434.8 C 240.8,434.7 241.6,434.5 242.3,434.4 C 243.1,434.3 243.9,434.3 244.7,434.2 C 245.5,434.1 246.2,433.7 247.0,433.6 C 247.8,433.5 248.6,433.5 249.4,433.5 C 250.2,433.4 250.5,433.2 251.7,433.1 C 252.9,433.0 255.2,433.0 256.4,432.9 C 257.6,432.9 258.0,432.8 258.8,432.7 C 259.5,432.7 260.3,432.7 261.1,432.6 C 261.9,432.5 262.7,432.2 263.4,432.1 C 264.2,431.9 265.0,432.6 265.8,431.7 C 266.6,430.8 267.3,427.6 268.1,426.6 C 268.9,425.5 269.7,425.7 270.5,425.5 C 271.2,425.3 272.0,425.5 272.8,425.4 C 273.6,425.3 274.4,424.9 275.2,424.6 C 275.9,424.4 276.7,424.2 277.5,423.7 C 278.3,423.2 279.1,422.2 279.8,421.6 C 280.6,421.0 281.4,420.4 282.2,420.0 C 283.0,419.5 283.8,419.2 284.5,419.0 C 285.3,418.7 286.1,418.6 286.9,418.5 C 287.7,418.3 288.4,418.4 289.2,418.2 C 290.0,418.1 290.8,417.9 291.6,417.8 C 292.3,417.7 293.1,417.8 293.9,417.7 C 294.7,417.6 295.5,417.6 296.2,417.5 C 297.0,417.4 297.8,417.4 298.6,417.0 C 299.4,416.6 300.2,415.5 300.9,415.0 C 301.7,414.5 302.5,414.3 303.3,414.0 C 304.1,413.6 304.8,414.3 305.6,412.8 C 306.4,411.2 307.2,406.4 308.0,404.6 C 308.8,402.7 309.5,402.4 310.3,401.7 C 311.1,400.9 311.9,400.4 312.7,399.9 C 313.4,399.3 314.2,398.8 315.0,398.4 C 315.8,398.0 316.6,397.6 317.3,397.3 C 318.1,397.1 318.9,397.0 319.7,396.8 C 320.5,396.6 321.2,396.3 322.0,396.0 C 322.8,395.7 323.6,395.2 324.4,395.0 C 325.2,394.7 325.9,394.8 326.7,394.5 C 327.5,394.2 328.3,393.8 329.1,393.5 C 329.8,393.1 330.6,392.9 331.4,392.6 C 332.2,392.4 333.0,392.2 333.8,392.0 C 334.5,391.9 335.3,391.8 336.1,391.7 C 336.9,391.6 337.7,391.4 338.4,391.2 C 339.2,391.0 340.0,390.9 340.8,390.7 C 341.6,390.5 342.3,390.3 343.1,390.0 C 343.9,389.6 344.7,389.0 345.5,388.8 C 346.2,388.5 347.0,388.4 347.8,388.2 C 348.6,388.1 349.4,387.9 350.2,387.8 C 350.9,387.7 351.7,387.6 352.5,387.5 C 353.3,387.4 354.1,387.2 354.8,387.2 C 355.6,387.1 356.4,387.1 357.2,387.0 C 358.0,386.9 358.8,386.7 359.5,386.4 C 360.3,386.1 361.1,385.6 361.9,385.3 C 362.7,385.0 363.4,384.6 364.2,384.4 C 365.0,384.2 365.8,384.1 366.6,384.0 C 367.3,383.9 368.1,383.8 368.9,383.6 C 369.7,383.3 370.5,382.8 371.2,382.6 C 372.0,382.4 372.8,382.4 373.6,382.3 C 374.4,382.2 375.2,382.0 375.9,381.8 C 376.7,381.6 377.5,381.4 378.3,381.3 C 379.1,381.1 379.8,380.8 380.6,380.7 C 381.4,380.5 382.2,380.4 383.0,380.2 C 383.8,379.9 384.5,379.6 385.3,379.3 C 386.1,379.1 386.9,379.0 387.7,378.7 C 388.4,378.5 389.2,378.2 390.0,377.9 C 390.8,377.6 391.6,377.4 392.3,377.1 C 393.1,376.8 393.9,376.8 394.7,376.4 C 395.5,376.0 396.2,375.1 397.0,374.7 C 397.8,374.3 398.6,374.3 399.4,374.1 C 400.2,374.0 400.9,374.0 401.7,373.9 C 402.5,373.8 403.3,373.7 404.1,373.5 C 404.8,373.4 405.6,373.6 406.4,373.3 C 407.2,372.9 408.0,371.8 408.8,371.4 C 409.5,371.0 410.3,371.1 411.1,370.8 C 411.9,370.6 412.7,370.1 413.4,369.9 C 414.2,369.7 415.0,369.8 415.8,369.6 C 416.6,369.4 417.3,368.9 418.1,368.7 C 418.9,368.5 419.7,368.5 420.5,368.4 C 421.2,368.3 422.0,368.3 422.8,368.1 C 423.6,367.9 424.4,367.6 425.2,367.1 C 425.9,366.6 426.7,365.5 427.5,365.0 C 428.3,364.5 429.1,364.2 429.8,363.9 C 430.6,363.5 431.4,363.4 432.2,362.7 C 433.0,361.9 433.8,360.2 434.5,359.4 C 435.3,358.6 436.1,358.3 436.9,357.8 C 437.7,357.3 438.4,357.2 439.2,356.5 C 440.0,355.8 440.8,354.2 441.6,353.5 C 442.3,352.8 443.1,352.7 443.9,352.3 C 444.7,351.9 445.5,351.5 446.2,351.1 C 447.0,3Line truncated
<path d="M 90.0,462.2 C 90.4,462.1 92.0,461.8 92.3,461.7 C 93.1,461.6 93.9,461.4 94.7,461.2 C 95.5,461.1 96.2,460.8 97.0,460.8 C 97.8,460.7 98.6,460.7 99.4,460.7 C 100.2,460.7 100.9,460.6 101.7,460.6 C 102.5,460.5 103.3,460.5 104.1,460.4 C 104.8,460.4 105.2,460.3 106.4,460.3 C 107.6,460.2 109.9,460.1 111.1,460.1 C 112.3,460.1 112.7,460.0 113.4,460.0 C 114.2,460.0 115.0,460.0 115.8,459.9 C 116.6,459.9 117.3,459.9 118.1,459.9 C 118.9,459.9 119.7,459.9 120.5,459.8 C 121.2,459.8 122.0,459.7 122.8,459.7 C 123.6,459.6 124.4,459.6 125.2,459.4 C 125.9,459.3 126.7,459.0 127.5,458.7 C 128.3,458.3 129.1,457.6 129.8,457.3 C 130.6,457.1 131.4,457.2 132.2,457.1 C 133.0,457.1 133.8,457.1 134.5,457.0 C 135.3,457.0 135.7,457.0 136.9,456.9 C 138.0,456.9 140.0,456.9 141.6,456.9 C 143.1,456.8 145.1,456.8 146.2,456.8 C 147.4,456.7 147.8,456.7 148.6,456.7 C 149.4,456.6 150.2,456.6 150.9,456.5 C 151.7,456.5 152.5,456.5 153.3,456.4 C 154.1,456.4 154.8,456.4 155.6,456.4 C 156.4,456.4 156.8,456.4 158.0,456.3 C 159.1,456.3 160.7,456.2 162.7,456.2 C 164.6,456.2 167.7,456.2 169.7,456.2 C 171.6,456.1 172.0,456.2 174.4,456.1 C 176.7,456.1 181.8,455.9 183.8,455.9 C 185.7,455.8 184.9,455.9 186.1,455.8 C 187.3,455.8 188.8,455.8 190.8,455.8 C 192.7,455.8 193.9,455.7 197.8,455.7 C 201.7,455.6 211.1,455.5 214.2,455.5 C 217.3,455.5 215.8,455.5 216.6,455.4 C 217.3,455.4 217.7,456.1 218.9,455.4 C 220.1,454.7 222.4,454.1 223.6,451.3 C 224.8,448.4 225.2,440.7 225.9,438.3 C 226.7,435.9 227.5,437.3 228.3,436.9 C 229.1,436.5 229.8,436.3 230.6,436.0 C 231.4,435.8 232.2,435.6 233.0,435.4 C 233.8,435.3 234.5,435.3 235.3,435.2 C 236.1,435.1 236.9,434.9 237.7,434.8 C 238.4,434.8 239.2,434.8 240.0,434.8 C 240.8,434.7 241.6,434.5 242.3,434.4 C 243.1,434.3 243.9,434.3 244.7,434.2 C 245.5,434.1 246.2,433.7 247.0,433.6 C 247.8,433.5 248.6,433.5 249.4,433.5 C 250.2,433.4 250.5,433.2 251.7,433.1 C 252.9,433.0 255.2,433.0 256.4,432.9 C 257.6,432.9 258.0,432.8 258.8,432.7 C 259.5,432.7 260.3,432.7 261.1,432.6 C 261.9,432.5 262.7,432.2 263.4,432.1 C 264.2,431.9 265.0,432.6 265.8,431.7 C 266.6,430.8 267.3,427.6 268.1,426.6 C 268.9,425.5 269.7,425.7 270.5,425.5 C 271.2,425.3 272.0,425.5 272.8,425.4 C 273.6,425.3 274.4,424.9 275.2,424.6 C 275.9,424.4 276.7,424.2 277.5,423.7 C 278.3,423.2 279.1,422.2 279.8,421.6 C 280.6,421.0 281.4,420.4 282.2,420.0 C 283.0,419.5 283.8,419.2 284.5,419.0 C 285.3,418.7 286.1,418.6 286.9,418.5 C 287.7,418.3 288.4,418.4 289.2,418.2 C 290.0,418.1 290.8,417.9 291.6,417.8 C 292.3,417.7 293.1,417.8 293.9,417.7 C 294.7,417.6 295.5,417.6 296.2,417.5 C 297.0,417.4 297.8,417.4 298.6,417.0 C 299.4,416.6 300.2,415.5 300.9,415.0 C 301.7,414.5 302.5,414.3 303.3,414.0 C 304.1,413.6 304.8,414.3 305.6,412.8 C 306.4,411.2 307.2,406.4 308.0,404.6 C 308.8,402.7 309.5,402.4 310.3,401.7 C 311.1,400.9 311.9,400.4 312.7,399.9 C 313.4,399.3 314.2,398.8 315.0,398.4 C 315.8,398.0 316.6,397.6 317.3,397.3 C 318.1,397.1 318.9,397.0 319.7,396.8 C 320.5,396.6 321.2,396.3 322.0,396.0 C 322.8,395.7 323.6,395.2 324.4,395.0 C 325.2,394.7 325.9,394.8 326.7,394.5 C 327.5,394.2 328.3,393.8 329.1,393.5 C 329.8,393.1 330.6,392.9 331.4,392.6 C 332.2,392.4 333.0,392.2 333.8,392.0 C 334.5,391.9 335.3,391.8 336.1,391.7 C 336.9,391.6 337.7,391.4 338.4,391.2 C 339.2,391.0 340.0,390.9 340.8,390.7 C 341.6,390.5 342.3,390.3 343.1,390.0 C 343.9,389.6 344.7,389.0 345.5,388.8 C 346.2,388.5 347.0,388.4 347.8,388.2 C 348.6,388.1 349.4,387.9 350.2,387.8 C 350.9,387.7 351.7,387.6 352.5,387.5 C 353.3,387.4 354.1,387.2 354.8,387.2 C 355.6,387.1 356.4,387.1 357.2,387.0 C 358.0,386.9 358.8,386.7 359.5,386.4 C 360.3,386.1 361.1,385.6 361.9,385.3 C 362.7,385.0 363.4,384.6 364.2,384.4 C 365.0,384.2 365.8,384.1 366.6,384.0 C 367.3,383.9 368.1,383.8 368.9,383.6 C 369.7,383.3 370.5,382.8 371.2,382.6 C 372.0,382.4 372.8,382.4 373.6,382.3 C 374.4,382.2 375.2,382.0 375.9,381.8 C 376.7,381.6 377.5,381.4 378.3,381.3 C 379.1,381.1 379.8,380.8 380.6,380.7 C 381.4,380.5 382.2,380.4 383.0,380.2 C 383.8,379.9 384.5,379.6 385.3,379.3 C 386.1,379.1 386.9,379.0 387.7,378.7 C 388.4,378.5 389.2,378.2 390.0,377.9 C 390.8,377.6 391.6,377.4 392.3,377.1 C 393.1,376.8 393.9,376.8 394.7,376.4 C 395.5,376.0 396.2,375.1 397.0,374.7 C 397.8,374.3 398.6,374.3 399.4,374.1 C 400.2,374.0 400.9,374.0 401.7,373.9 C 402.5,373.8 403.3,373.7 404.1,373.5 C 404.8,373.4 405.6,373.6 406.4,373.3 C 407.2,372.9 408.0,371.8 408.8,371.4 C 409.5,371.0 410.3,371.1 411.1,370.8 C 411.9,370.6 412.7,370.1 413.4,369.9 C 414.2,369.7 415.0,369.8 415.8,369.6 C 416.6,369.4 417.3,368.9 418.1,368.7 C 418.9,368.5 419.7,368.5 420.5,368.4 C 421.2,368.3 422.0,368.3 422.8,368.1 C 423.6,367.9 424.4,367.6 425.2,367.1 C 425.9,366.6 426.7,365.5 427.5,365.0 C 428.3,364.5 429.1,364.2 429.8,363.9 C 430.6,363.5 431.4,363.4 432.2,362.7 C 433.0,361.9 433.8,360.2 434.5,359.4 C 435.3,358.6 436.1,358.3 436.9,357.8 C 437.7,357.3 438.4,357.2 439.2,356.5 C 440.0,355.8 440.8,354.2 441.6,353.5 C 442.3,352.8 443.1,352.7 443.9,352.3 C 444.7,351.9 445.5,351.5 446.2,351.1 C 447.0,3Line truncated
<circle cx="765.0" cy="99.0" r="5" fill="#dd4528" stroke="#fff" stroke-width="2" filter="url(#xkcdify)"/>
<text x="753.0" y="77.0" fill="#dd4528" font-size="18" font-family="Handlee, cursive" font-weight="bold" text-anchor="end">6652</text>
<a href="https://github.com/carsteneu/mystarhistory" target="_blank" rel="noopener"><text x="763" y="527" font-size="13" font-family="Handlee, cursive" fill="#000" text-anchor="end">Made with carsteneu/mystarhistory</text></a>
</svg>

Before

Width:  |  Height:  |  Size: 36 KiB

After

Width:  |  Height:  |  Size: 36 KiB

-10
View File
@@ -47,16 +47,6 @@ test("effectiveDefaultAlgorithms (modern) includes chacha20-poly1305", () => {
assert.ok(result.cipher.includes("chacha20-poly1305@openssh.com"));
});
test("effectiveDefaultAlgorithms offers OpenSSH host certificates before plain host keys", () => {
const cert = "ssh-ed25519-cert-v01@openssh.com";
const modern = effectiveDefaultAlgorithms(false);
assert.ok(modern.serverHostKey.includes(cert));
assert.ok(modern.serverHostKey.indexOf(cert) < modern.serverHostKey.indexOf("ssh-ed25519"));
const legacy = effectiveDefaultAlgorithms(true);
assert.ok(legacy.serverHostKey.includes(cert));
assert.ok(legacy.serverHostKey.includes("ssh-dss"));
});
test("effectiveDefaultAlgorithms (legacy) appends sha1 KEX, CBC, and ssh-dss", () => {
const modern = effectiveDefaultAlgorithms(false);
const legacy = effectiveDefaultAlgorithms(true);
-14
View File
@@ -77,13 +77,6 @@ export const SUPPORTED_HMAC_ALGORITHMS: readonly string[] = [
];
export const SUPPORTED_SERVER_HOST_KEY_ALGORITHMS: readonly string[] = [
"ssh-ed25519-cert-v01@openssh.com",
"ecdsa-sha2-nistp256-cert-v01@openssh.com",
"ecdsa-sha2-nistp384-cert-v01@openssh.com",
"ecdsa-sha2-nistp521-cert-v01@openssh.com",
"rsa-sha2-512-cert-v01@openssh.com",
"rsa-sha2-256-cert-v01@openssh.com",
"ssh-rsa-cert-v01@openssh.com",
"ssh-ed25519",
"ecdsa-sha2-nistp256",
"ecdsa-sha2-nistp384",
@@ -155,13 +148,6 @@ const MODERN_DEFAULT_ALGORITHMS: Readonly<Record<SSHAlgorithmCategory, readonly
"hmac-sha1",
],
serverHostKey: [
"ssh-ed25519-cert-v01@openssh.com",
"ecdsa-sha2-nistp256-cert-v01@openssh.com",
"ecdsa-sha2-nistp384-cert-v01@openssh.com",
"ecdsa-sha2-nistp521-cert-v01@openssh.com",
"rsa-sha2-512-cert-v01@openssh.com",
"rsa-sha2-256-cert-v01@openssh.com",
"ssh-rsa-cert-v01@openssh.com",
"ssh-ed25519",
"ecdsa-sha2-nistp256",
"ecdsa-sha2-nistp384",
-49
View File
@@ -6,7 +6,6 @@ import {
canHibernateTerminalRuntimeStatus,
capHibernateBuffer,
capHibernateBufferByLines,
decideHiddenTabKeepCountAction,
isTerminalFileTransferActive,
normalizeHibernateHiddenTabsDelaySec,
resolveTerminalHibernateDelayMs,
@@ -93,54 +92,6 @@ test("normalizeHibernateHiddenTabsDelaySec clamps to the allowed range", () => {
assert.equal(normalizeHibernateHiddenTabsDelaySec(9999), TERMINAL_HIBERNATE_DELAY_SEC_MAX);
});
test("a full keep-count lists eviction candidates and does not evict them itself", () => {
assert.deepEqual(decideHiddenTabKeepCountAction({
keepCount: 2,
softHiddenCount: 1,
sessionId: "leaving",
candidatesOldestFirst: ["older"],
}), { action: "soft-hide", candidates: [] });
assert.deepEqual(decideHiddenTabKeepCountAction({
keepCount: 2,
softHiddenCount: 2,
sessionId: "leaving",
candidatesOldestFirst: ["oldest", "newer"],
}), { action: "await-eviction", candidates: ["oldest", "newer"] });
assert.deepEqual(decideHiddenTabKeepCountAction({
keepCount: 2,
softHiddenCount: 3,
sessionId: "leaving",
candidatesOldestFirst: ["oldest"],
}), { action: "await-eviction", candidates: ["oldest"] });
});
test("keep-count zero still full-hibernates and does not evict", () => {
assert.deepEqual(decideHiddenTabKeepCountAction({
keepCount: 0,
softHiddenCount: 4,
sessionId: "leaving",
candidatesOldestFirst: ["oldest"],
}), { action: "full-hibernate", candidates: [] });
});
test("a full keep-count does not evict the session that is deciding", () => {
assert.deepEqual(decideHiddenTabKeepCountAction({
keepCount: 2,
softHiddenCount: 2,
sessionId: "leaving",
candidatesOldestFirst: ["leaving"],
}), { action: "full-hibernate", candidates: [] });
assert.deepEqual(decideHiddenTabKeepCountAction({
keepCount: 2,
softHiddenCount: 2,
sessionId: "leaving",
candidatesOldestFirst: ["leaving", "other"],
}), { action: "await-eviction", candidates: ["other"] });
});
test("resolveTerminalHibernateDelayMs converts seconds to milliseconds", () => {
assert.equal(resolveTerminalHibernateDelayMs(), TERMINAL_HIBERNATE_DELAY_SEC_DEFAULT * 1000);
assert.equal(resolveTerminalHibernateDelayMs({ hibernateHiddenTabsDelaySec: 10 }), 10_000);
-44
View File
@@ -158,50 +158,6 @@ export function shouldSkipHibernateForAltScreen(
return alternateScreen && resolveHibernateSkipAltScreen(settings);
}
export type HiddenTabKeepCountDecision = {
/**
* Soft-hide immediately when the keep set has room. Await eviction when it
* is full. Full-hibernate when keepCount is 0 or no other candidate exists.
* Callers retain the leaving tab only after a victim's hibernate completes.
*/
action: "soft-hide" | "full-hibernate" | "await-eviction";
/** Oldest-first victims to try when action is await-eviction. */
candidates: readonly string[];
};
/**
* Keep-count policy for a hidden session that is not already soft-hidden.
* Callers still handle alternate screen, inline images, and a soft-hidden
* session that later disconnects before asking. keepCount 0 (disconnected, or
* the setting) full-hibernates. When the soft-hide set is full this only
* lists candidates. It does not evict, reserve a slot, or touch renderer
* lifecycle. Waking a victim clears its marker before hibernate finishes, so
* the caller awaits that outside this helper. Wake replay order stays in
* resolveHibernateWakeHistory (#2762). Atlas clear on a soft wake stays in
* wakeSoftHiddenRuntime (#1063).
*/
export function decideHiddenTabKeepCountAction(input: {
keepCount: number;
softHiddenCount: number;
sessionId: string;
/** Oldest soft-hidden session first. Snapshot this before eviction mutates it. */
candidatesOldestFirst: readonly string[];
}): HiddenTabKeepCountDecision {
if (!(input.keepCount > 0)) {
return { action: "full-hibernate", candidates: [] };
}
if (input.softHiddenCount < input.keepCount) {
return { action: "soft-hide", candidates: [] };
}
const candidates = input.candidatesOldestFirst.filter((candidate) => (
Boolean(candidate) && candidate !== input.sessionId
));
if (candidates.length === 0) {
return { action: "full-hibernate", candidates: [] };
}
return { action: "await-eviction", candidates };
}
/** Snapshot + buffered output to replay when recreating xterm after hibernate. */
export type TerminalHibernateWakePayload = {
/** Full serialized terminal state -- preferred wake history (coherent ANSI). */
-3
View File
@@ -195,9 +195,6 @@ module.exports = {
'node_modules/@cursor/sdk-*/**/*',
'node_modules/sqlite3/**/*',
'node_modules/@modelcontextprotocol/sdk/**/*',
// External MCP scripts run from asarUnpack via ELECTRON_RUN_AS_NODE and
// must resolve re2js from a real directory (issue #3910).
'node_modules/re2js/**/*',
'lib/**/*.cjs',
'lib/**/*.json',
'node_modules/zod/**/*',
@@ -2,18 +2,6 @@ const test = require('node:test');
const assert = require('node:assert/strict');
const { EventEmitter } = require('node:events');
const { startPtyJob } = require('./ptyExec.cjs');
const { getFreshIdlePrompt, getVisibleIdlePrompt, trackSessionIdlePrompt } = require('./shellUtils.cjs');
const { terminalCursorLine } = require('../terminalLogSanitizer.cjs');
function readyPrompt(session) {
return getFreshIdlePrompt(session) || getVisibleIdlePrompt(session);
}
// PSReadLine echoes the PowerShell clear-prefix keys, then erases them with
// cursor-column + ED without rewriting the prompt into the byte stream.
function powershellEraseRedraw(prompt) {
return `${prompt}ggd2147483647d\x1b[20G\x1b[0J`;
}
class TestPty extends EventEmitter {
writes = [];
@@ -170,116 +158,3 @@ test('wall deadline during authorization reports authorization failure rather th
assert.match(result.error, /authorization timed out/);
assert.deepEqual(pty.writes, []);
});
test('idle PowerShell whose raw tail was CSI-erased still receives the command', async (t) => {
t.mock.timers.enable({ apis: ['Date', 'setTimeout'] });
const pty = new TestPty();
const session = { _promptTrackTail: '', lastIdlePrompt: '' };
const prompt = 'PS C:\\Users\\alice> ';
trackSessionIdlePrompt(session, `Windows PowerShell\r\n${powershellEraseRedraw(prompt)}`);
assert.equal(getFreshIdlePrompt(session), '');
const job = startPtyJob(pty, 'Write-Output ok', {
shellKind: 'powershell',
getReadyPrompt: () => readyPrompt(session),
promptWaitTimeoutMs: 200,
timeoutMs: 1000,
typedInput: true,
});
t.mock.timers.tick(200);
const result = await job.resultPromise;
assert.equal(result.ok, true);
assert.equal(result.stdout, 'OK');
assert.equal(pty.writes.length, 1);
assert.match(pty.writes[0], /\$__NCMCP_/);
});
test('the next PowerShell exec runs after the clear-prefix echo is erased on screen', async () => {
const prompt = 'PS C:\\Users\\alice> ';
const session = { _promptTrackTail: '', lastIdlePrompt: '' };
trackSessionIdlePrompt(session, prompt);
const pty = new EventEmitter();
pty.writes = [];
pty.write = (data) => {
const text = String(data);
pty.writes.push(text);
const marker = text.match(/(__NCMCP_[0-9a-z]+_[0-9a-f]+__)/i)?.[1];
if (!marker) return;
queueMicrotask(() => {
const chunk = `${marker}_S\r\nok\r\n${marker}_E:0\r\n${powershellEraseRedraw(prompt)}`;
trackSessionIdlePrompt(session, chunk);
pty.emit('data', chunk);
});
};
const first = startPtyJob(pty, 'Write-Output ok', {
shellKind: 'powershell',
getReadyPrompt: () => readyPrompt(session),
timeoutMs: 1000,
typedInput: true,
});
const firstResult = await first.resultPromise;
assert.equal(firstResult.ok, true);
assert.equal(firstResult.stdout, 'ok');
assert.equal(getFreshIdlePrompt(session), '');
assert.equal(getVisibleIdlePrompt(session), prompt);
const second = startPtyJob(pty, 'Write-Output next', {
shellKind: 'powershell',
getReadyPrompt: () => readyPrompt(session),
promptWaitTimeoutMs: 200,
timeoutMs: 1000,
typedInput: true,
});
const secondResult = await second.resultPromise;
assert.equal(secondResult.ok, true);
assert.equal(secondResult.stdout, 'ok');
assert.equal(pty.writes.length, 2);
});
test('alt-screen paint is not ready after the entry sequence leaves the prompt tail', async (t) => {
t.mock.timers.enable({ apis: ['Date', 'setTimeout'] });
const pty = new TestPty();
const session = { _promptTrackTail: '', lastIdlePrompt: '' };
const prompt = 'PS C:\\Users\\alice> ';
// A full-screen app can emit more than the 4096-byte prompt tail. Once the
// alternate-screen entry scrolls off, a painted idle prompt must not unlock
// ptyExec and type into the still-running program.
trackSessionIdlePrompt(session, prompt);
trackSessionIdlePrompt(session, `\x1b[?1049h${'x'.repeat(4200)}`);
trackSessionIdlePrompt(session, `\r\n${prompt}\r`);
assert.equal(session._promptTrackTail.includes('\x1b[?1049h'), false);
assert.equal(terminalCursorLine(session._promptTrackTail).trimEnd(), prompt.trimEnd());
assert.equal(getFreshIdlePrompt(session), '');
assert.equal(getVisibleIdlePrompt(session), '');
assert.equal(readyPrompt(session), '');
const job = startPtyJob(pty, 'Write-Output 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, []);
});
test('real pending PowerShell input is still not sent', async (t) => {
t.mock.timers.enable({ apis: ['Date', 'setTimeout'] });
const pty = new TestPty();
const session = { _promptTrackTail: '', lastIdlePrompt: '' };
trackSessionIdlePrompt(session, 'PS C:\\Users\\alice> Write-Output ok');
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, []);
});
+2 -13
View File
@@ -68,8 +68,7 @@ const PROMPT_SUFFIX_WINDOW_PER_PROMPT_CHAR = 8;
function hasExpectedPromptSuffix(text, expectedPrompt) {
if (!expectedPrompt) return false;
const ansiFreePrompt = stripAnsi(String(expectedPrompt || ""));
const normalizedPrompt = ansiFreePrompt.replace(/\r/g, "");
const normalizedPrompt = stripAnsi(String(expectedPrompt || "")).replace(/\r/g, "");
if (!normalizedPrompt) return false;
const fullText = String(text || "");
const windowChars = Math.max(
@@ -77,17 +76,7 @@ function hasExpectedPromptSuffix(text, expectedPrompt) {
String(expectedPrompt).length * PROMPT_SUFFIX_WINDOW_PER_PROMPT_CHAR,
);
const tail = fullText.length > windowChars ? fullText.slice(-windowChars) : fullText;
const strippedTail = stripAnsi(tail);
// Drop CR so a redraw inside one line (`root@host:~\r# `) still matches
// the cached prompt. That collapses a two-line PS1's `\r\r\n` to a single
// LF, which no longer ends with the cache (`user@host: /root\n\n\n$ `).
if (strippedTail.replace(/\r/g, "").endsWith(normalizedPrompt)) return true;
// The idle-prompt cache turns those CRs into LFs and keeps the blank
// lines. Compare that form too, only when the cache itself spans lines,
// so a bare `$`/`#` is unchanged.
if (!ansiFreePrompt.includes("\n")) return false;
const crAsLfPrompt = ansiFreePrompt.replace(/\r/g, "\n");
return strippedTail.replace(/\r/g, "\n").endsWith(crAsLfPrompt);
return stripAnsi(tail).replace(/\r/g, "").endsWith(normalizedPrompt);
}
function escapePosixSingleQuoted(text) {
@@ -3,7 +3,7 @@
const test = require("node:test");
const assert = require("node:assert/strict");
const { hasExpectedPromptSuffix } = require("./ptyExecHelpers.cjs");
const { extractTrailingIdlePrompt, stripAnsi } = require("./shellUtils.cjs");
const { stripAnsi } = require("./shellUtils.cjs");
// The behaviour before the tail window was introduced: normalise the whole
// text, then compare its end.
@@ -41,38 +41,6 @@ test("prompt suffix is found after a very large flood of CRLF output", () => {
assert.equal(hasExpectedPromptSuffix(`${flood}${COLORED_PROMPT}`, "root@3160T:~# "), true);
});
test("live PTY CR bytes match a cached two-line idle prompt", () => {
// Bash writes the PS1 newline as CR and ONLCR adds another, so the bytes
// are `\r\r\n`. The idle-prompt cache keeps those as blank lines. Suffix
// matching has to accept that form; stripping CR down to one LF does not.
const live = "user@host: /workspace\r\r\n$ ";
const cached = extractTrailingIdlePrompt(`welcome\r\n${live}`);
assert.equal(cached, "user@host: /workspace\n\n\n$ ");
const matchLive = hasExpectedPromptSuffix(live, cached);
assert.equal(matchLive, true);
assert.equal(hasExpectedPromptSuffix(`hostname\r\nbox\r\n${live}`, cached), true);
assert.equal(
hasExpectedPromptSuffix("\x1b[?2004huser@host: /workspace\r\r\n$ ", cached),
true,
);
const rootLive = "user@host: /root\r\r\n# ";
const rootCached = extractTrailingIdlePrompt(rootLive);
assert.equal(rootCached, "user@host: /root\n\n\n# ");
assert.equal(hasExpectedPromptSuffix(rootLive, rootCached), true);
// Readiness for a real LF two-line prompt still matches, and a bare
// `$`/`#` is neither this prompt nor a POSIX prompt on its own.
const lfCached = extractTrailingIdlePrompt("welcome\r\nuser@host: /workspace\n$ ");
assert.equal(lfCached, "user@host: /workspace\n$ ");
assert.equal(hasExpectedPromptSuffix("user@host: /workspace\n$ ", lfCached), true);
assert.equal(hasExpectedPromptSuffix("output\r\n$ ", cached), false);
assert.equal(hasExpectedPromptSuffix("$ ", cached), false);
assert.equal(hasExpectedPromptSuffix("# ", rootCached), false);
assert.equal(extractTrailingIdlePrompt("$ "), "");
assert.equal(extractTrailingIdlePrompt("# "), "");
});
test("prompt suffix check does not scan the whole buffer on every call", () => {
// 2M characters of CRLF output used to cost seconds per call because every
// call stripped ANSI and carriage returns over the entire text.
+9 -93
View File
@@ -61,23 +61,10 @@ const POWERSHELL_PROMPT_PATTERN = /^PS(?:\s+\S.*)?>$/;
// PowerShell's `PS C:\...>` (handled by POWERSHELL_PROMPT_PATTERN first).
const CMD_PROMPT_PATTERN = /^[A-Za-z]:(?:\\[^<>"|]*)?>$/;
// Default login prompts, kept narrow so custom / fish / starship prompts do
// not flip a Windows DefaultShell soft hint:
// - bash/zsh `user@host[:path][#$]` (colon before the path; `root@host ~#` stays rejected)
// - RHEL-family `[\u@\h \W]\$` -> `[user@host path][#$]`, or `[user@host][#$]`
// with no path. `\W` may contain whitespace or `]` (`[user@host My [Project]]$`);
// the final `]` before `$`/`#` is the delimiter.
const POSIX_PROMPT_PATTERN = /^(?:[^\s@]+@[^\s:]+(?::[^\n\r]*)?|\[[^\s@\]]+@[^\s\]:]+(?:\s+[^\n\r]+)?\])[#$]$/;
// Host line of a two-line bash PS1 (`\u@\h: \w` then a following `$` or `#`).
// Same user@host[:path] class as POSIX_PROMPT_PATTERN's first alternative,
// without a prompt marker on this line. A bare `$`/`#` alone is not a prompt.
const POSIX_PROMPT_HOST_LINE_PATTERN = /^[^\s@]+@[^\s:]+(?::[^\n\r]*)?$/;
// Alpine's default ash PS1 omits the username: `\h:\w\$ ` -> `localhost:~# `.
// Require a hostname and a home/absolute path, excluding prompt delimiters
// within the path so partially entered commands do not count as idle prompts.
const ALPINE_PROMPT_PATTERN = /^[A-Za-z0-9_][A-Za-z0-9_.-]*:(?:~(?:\/[^#$\n\r]*)?|\/[^#$\n\r]*)[#$]$/;
// Classic `user@host:...$` / `user@host:...#` login prompt (bash/zsh in WSL,
// remote Linux, etc.). Intentionally narrow so custom / fish prompts do not
// flip a Windows DefaultShell soft hint.
const POSIX_PROMPT_PATTERN = /^[^\s@]+@[^\s:]+(?::[^\n\r]*)?[#$]$/;
function isDefaultPowerShellPromptLine(line) {
return POWERSHELL_PROMPT_PATTERN.test(String(line || ""));
@@ -88,15 +75,7 @@ function isDefaultCmdPromptLine(line) {
}
function isDefaultPosixPromptLine(line) {
const normalized = String(line || "").replace(/\s+$/, "");
// Optional virtualenv / conda / BT-panel style env name prefix: "(name) "
// before the shell prompt. Recognition only; callers still return the full
// prompt line so suffix matching sees the visible prefix (#3947).
const withoutEnvPrefix = normalized.replace(/^\([^()\n\r]+\)\s+/, "");
return (
POSIX_PROMPT_PATTERN.test(withoutEnvPrefix) ||
ALPINE_PROMPT_PATTERN.test(withoutEnvPrefix)
);
return POSIX_PROMPT_PATTERN.test(String(line || "").replace(/\s+$/, ""));
}
function extractTrailingIdlePrompt(output) {
@@ -107,8 +86,7 @@ function extractTrailingIdlePrompt(output) {
const normalized = stripAnsi(output).replace(/\r/g, "\n");
if (!normalized || normalized.endsWith("\n")) return "";
const lines = normalized.split("\n");
const lastLine = lines.pop() || "";
const lastLine = normalized.split("\n").pop() || "";
const rightTrimmed = lastLine.replace(/\s+$/, "");
if (!rightTrimmed) return "";
@@ -124,24 +102,6 @@ function extractTrailingIdlePrompt(output) {
return lastLine;
}
// Two-line PS1 (`export PS1="\u@\h: \w\n$ "`). Bash emits the newline as
// CR and the PTY's ONLCR adds another, so the bytes are `\r\r\n`. The
// CR-to-LF normalize above turns that into blank lines between the host
// line and `$`/`#`. Keep those blanks in the cache: getFreshIdlePrompt
// uses the same normalize, and hasExpectedPromptSuffix compares this
// CR-as-LF form against live PTY bytes. A lone `$` must not satisfy endsWith.
if (rightTrimmed === "$" || rightTrimmed === "#") {
let hostIndex = lines.length - 1;
while (hostIndex >= 0 && lines[hostIndex].replace(/\s+$/, "") === "") {
hostIndex -= 1;
}
const previousLine = hostIndex >= 0 ? lines[hostIndex] : "";
const previousRightTrimmed = previousLine.replace(/\s+$/, "");
if (POSIX_PROMPT_HOST_LINE_PATTERN.test(previousRightTrimmed)) {
return `${lines.slice(hostIndex).join("\n")}\n${lastLine}`;
}
}
return "";
}
@@ -174,20 +134,7 @@ function looksLikeIdleAutoLogout(outputTail) {
function trackSessionIdlePrompt(session, chunk) {
if (!session || typeof chunk !== "string" || !chunk) return "";
const previousTail = session._promptTrackTail || "";
const combined = `${previousTail}${chunk}`;
const overflow = combined.length - MAX_PROMPT_TRACK_TAIL;
if (overflow > 0) {
// The kept tail no longer starts where the previous tail started. Replay
// the dropped prefix so an alternate-screen enter or leave that scrolls
// off is still the mode at the first kept byte.
const { alternateScreenActiveAfter } = require("../terminalLogSanitizer.cjs");
session._promptTrackAltScreenAtTailStart = alternateScreenActiveAfter(
combined.slice(0, overflow),
session._promptTrackAltScreenAtTailStart === true,
);
}
const nextTail = overflow > 0 ? combined.slice(overflow) : combined;
const nextTail = `${session._promptTrackTail || ""}${chunk}`.slice(-MAX_PROMPT_TRACK_TAIL);
session._promptTrackTail = nextTail;
const prompt = extractTrailingIdlePrompt(nextTail);
@@ -202,10 +149,8 @@ function trackSessionIdlePrompt(session, chunk) {
// Return `session.lastIdlePrompt` only if the PTY's recent rolling tail
// still ends with it. The cached prompt is updated only when
// extractTrailingIdlePrompt recognizes a known shape (PowerShell, cmd.exe,
// `user@host[:path][#$]`, two-line `user@host[:path]` + `$`/`#`,
// RHEL `[user@host path][#$]`, or Alpine `host:path[#$]`);
// a remote shell switch into another shell, an oh-my-posh / starship / custom
// PS1, or any unrecognized prompt would
// or `user@host[:path][#$]`); a remote shell switch into another shell, an
// oh-my-posh / starship / custom PS1, or any unrecognized prompt would
// otherwise leave a stale value behind, which `resolveEffectiveShellKind`
// would then keep using to coerce future commands into a PowerShell
// wrapper. By re-checking the live tail we self-correct: if the visible
@@ -227,34 +172,6 @@ function getFreshIdlePrompt(session) {
return normalizedTail.endsWith(normalizedCached) ? cached : "";
}
// Readiness companion to getFreshIdlePrompt. PSReadLine echoes keys (including
// the pending-input clear prefix) and then erases them with cursor-column +
// ED/EL without appending a fresh copy of the prompt. stripAnsi removes the
// erase, so the raw tail no longer ends with the cached prompt while the
// 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;
if (typeof tail !== "string" || !tail) return "";
const { terminalCursorLine } = require("../terminalLogSanitizer.cjs");
const cursorLine = terminalCursorLine(tail, {
alternateScreenActive: session._promptTrackAltScreenAtTailStart === true,
});
const rightTrimmed = cursorLine.replace(/\s+$/, "");
if (!rightTrimmed) return "";
if (
!isDefaultPowerShellPromptLine(rightTrimmed)
&& !isDefaultPosixPromptLine(rightTrimmed)
&& !isDefaultCmdPromptLine(rightTrimmed)
) {
return "";
}
// Keep one trailing space when the visible prompt has one. Erase-to-end
// padding must not become part of the expected prompt.
return /\s$/.test(cursorLine) ? `${rightTrimmed} ` : rightTrimmed;
}
// ── URL helpers ──
function isLocalhostHostname(hostname) {
@@ -1195,7 +1112,6 @@ module.exports = {
formatSyntheticEcho,
extractTrailingIdlePrompt,
getFreshIdlePrompt,
getVisibleIdlePrompt,
isDefaultPowerShellPromptLine,
isDefaultCmdPromptLine,
isDefaultPosixPromptLine,
-258
View File
@@ -8,7 +8,6 @@ const {
extractTrailingIdlePrompt,
formatSyntheticEcho,
getFreshIdlePrompt,
getVisibleIdlePrompt,
isDefaultPowerShellPromptLine,
isDefaultCmdPromptLine,
isDefaultPosixPromptLine,
@@ -134,182 +133,14 @@ test("isDefaultCmdPromptLine matches drive-letter cmd prompts only", () => {
test("isDefaultPosixPromptLine matches classic user@host prompts", () => {
assert.equal(isDefaultPosixPromptLine("alice@host:~$"), true);
assert.equal(isDefaultPosixPromptLine("user@host:~$"), true);
assert.equal(isDefaultPosixPromptLine("alice@wsl:/mnt/c$"), true);
assert.equal(isDefaultPosixPromptLine("root@box:/#"), true);
assert.equal(isDefaultPosixPromptLine("[opc@instance-20260905-1821 ~]$"), true);
assert.equal(isDefaultPosixPromptLine("[opc@instance-20260905-1821 ~]$ "), true);
assert.equal(isDefaultPosixPromptLine("[root@host ~]#"), true);
assert.equal(isDefaultPosixPromptLine("[user@host]$"), true);
assert.equal(isDefaultPosixPromptLine("[user@host My Project]$"), true);
assert.equal(isDefaultPosixPromptLine("[user@host My Project]$ "), true);
assert.equal(isDefaultPosixPromptLine("[user@host My [Project]]$"), true);
assert.equal(isDefaultPosixPromptLine("[user@host My [Project]]$ "), true);
assert.equal(isDefaultPosixPromptLine("root@host ~#"), false);
assert.equal(isDefaultPosixPromptLine("PS C:\\Users\\alice>"), false);
assert.equal(isDefaultPosixPromptLine("C:\\Users\\alice>"), false);
assert.equal(isDefaultPosixPromptLine(""), false);
});
test("isDefaultPosixPromptLine matches Alpine host:path prompts without a username", () => {
for (const prompt of [
"localhost:~#",
"localhost:~# ",
"alpine:~$ ",
"alpine-host:~/projects$",
"alpine-host:/# ",
"alpine.example:/srv/My Project$ ",
]) {
assert.equal(isDefaultPosixPromptLine(prompt), true, prompt);
}
});
test("Alpine prompt recognition rejects output and partially entered commands", () => {
for (const line of [
"#",
"$",
"status: ready#",
"progress:100%$",
":~#",
"alpine:#",
"alpine:relative$",
"alpine:~user$",
"localhost:~# printf busy",
"localhost:/tmp# printf busy#",
"alpine:/tmp$ printf busy$",
]) {
assert.equal(isDefaultPosixPromptLine(line), false, line);
assert.equal(extractTrailingIdlePrompt(`welcome\r\n${line}`), "", line);
}
});
test("extracts an Alpine idle prompt while preserving its trailing whitespace", () => {
assert.equal(
extractTrailingIdlePrompt("Welcome to Alpine!\r\n\x1b[32mlocalhost:~# \x1b[0m"),
"localhost:~# ",
);
assert.equal(extractTrailingIdlePrompt("alpine:/srv$ "), "alpine:/srv$ ");
assert.equal(extractTrailingIdlePrompt("localhost:~# \r\n"), "");
});
test("extracts a trailing RHEL-family bracketed idle prompt", () => {
// Oracle Linux / RHEL / CentOS / Fedora default PS1 is `[\u@\h \W]\$ `.
// Trailing space must round-trip, same as the PowerShell prompt capture.
assert.equal(
extractTrailingIdlePrompt("[opc@instance-20260905-1821 ~]$ "),
"[opc@instance-20260905-1821 ~]$ ",
);
assert.equal(
extractTrailingIdlePrompt("Last login: Fri Oct 24\r\n[root@host ~]#"),
"[root@host ~]#",
);
assert.equal(
extractTrailingIdlePrompt("welcome\r\nuser@host:~$ "),
"user@host:~$ ",
);
assert.equal(
extractTrailingIdlePrompt("welcome\r\n[user@host My Project]$ "),
"[user@host My Project]$ ",
);
assert.equal(
extractTrailingIdlePrompt("welcome\r\n[user@host My [Project]]$ "),
"[user@host My [Project]]$ ",
);
// Unbracketed space form is still not a recognized default prompt.
assert.equal(extractTrailingIdlePrompt("welcome\r\nroot@host ~#"), "");
});
test("extractTrailingIdlePrompt recognizes a two-line user@host then bare $/# PS1", () => {
// export PS1="\u@\h: \w\n$ " paints the host line, then a lone "$ ".
// The cached prompt must keep both lines so a lone "$" cannot satisfy endsWith.
assert.equal(
extractTrailingIdlePrompt("welcome\r\nuser@host: /home/foo\n$ "),
"user@host: /home/foo\n$ ",
);
assert.equal(
extractTrailingIdlePrompt("welcome\r\nroot@host: /root\n# "),
"root@host: /root\n# ",
);
// Live bash PTY: prompt CR plus ONLCR is `\r\r\n`, which this normalizer
// stores as blank lines. The cache must keep them so endsWith still matches.
assert.equal(
extractTrailingIdlePrompt("\x1b[?2004huser@host: /home/foo\r\r\n$ "),
"user@host: /home/foo\n\n\n$ ",
);
assert.equal(
extractTrailingIdlePrompt("\x1b[?2004hroot@host: /root\r\r\n# "),
"root@host: /root\n\n\n# ",
);
assert.equal(extractTrailingIdlePrompt("$ "), "");
assert.equal(extractTrailingIdlePrompt("# "), "");
assert.equal(extractTrailingIdlePrompt("welcome\r\n$ "), "");
assert.equal(extractTrailingIdlePrompt("not a host line\n$ "), "");
assert.equal(extractTrailingIdlePrompt("copied output\n# "), "");
assert.equal(isDefaultPosixPromptLine("$"), false);
assert.equal(isDefaultPosixPromptLine("#"), false);
assert.equal(isDefaultPosixPromptLine("$ "), false);
assert.equal(isDefaultPosixPromptLine("# "), false);
});
test("trackSessionIdlePrompt and getFreshIdlePrompt accept a two-line user@host PS1", () => {
const session = {};
const prompt = "user@host: /home/foo\n$ ";
trackSessionIdlePrompt(session, `welcome\r\n${prompt}`);
assert.equal(getFreshIdlePrompt(session), prompt);
const ptySession = {};
trackSessionIdlePrompt(ptySession, "\x1b[?2004huser@host: /home/foo\r\r\n$ ");
assert.equal(getFreshIdlePrompt(ptySession), "user@host: /home/foo\n\n\n$ ");
});
test("extractTrailingIdlePrompt still recognizes a classic single-line POSIX prompt", () => {
assert.equal(extractTrailingIdlePrompt("welcome\r\nuser@host:~$ "), "user@host:~$ ");
});
test("isDefaultPosixPromptLine accepts virtualenv, conda, and BT-panel env prefixes", () => {
// Python venv / conda / BT-panel prepend "(name) " before the real prompt.
// Recognition strips that prefix; a missing space after ")" is already
// accepted because the username class absorbs the parens (#3947).
for (const prompt of [
"(3.12.12) root@example:~# ",
"(3.12.12) root@example:~#",
"(venv) alice@host:~$ ",
"(base) [opc@host ~]$ ",
"(my-env) localhost:~# ",
"(3.12.12)root@example:~# ",
]) {
assert.equal(isDefaultPosixPromptLine(prompt), true, prompt);
}
});
test("extractTrailingIdlePrompt keeps the full env-prefixed POSIX prompt", () => {
// Suffix matching compares the visible PTY line, so the captured prompt
// must still include the "(name) " prefix rather than the stripped form.
for (const prompt of [
"(3.12.12) root@example:~# ",
"(3.12.12) root@example:~#",
"(venv) alice@host:~$ ",
"(base) [opc@host ~]$ ",
"(my-env) localhost:~# ",
]) {
assert.equal(extractTrailingIdlePrompt(`Last login\r\n${prompt}`), prompt, prompt);
}
});
test("env-prefixed POSIX recognition rejects incomplete prefixes and non-prompts", () => {
for (const line of [
"(3.12.12 root@example:~# ",
"(3.12.12) root@example:~# printf busy",
"(venv) not a prompt",
"root@host ~#",
"(base) [opc@host ~]$ printf busy",
"(my-env) localhost:~# printf busy",
]) {
assert.equal(isDefaultPosixPromptLine(line), false, line);
assert.equal(extractTrailingIdlePrompt(`welcome\r\n${line}`), "", line);
}
});
test("isPlausibleCliVersionOutput rejects stack traces and file URLs", () => {
assert.equal(isPlausibleCliVersionOutput("2.1.123 (Claude Code)"), true);
assert.equal(isPlausibleCliVersionOutput("codex-cli 0.125.0"), true);
@@ -843,95 +674,6 @@ test("getFreshIdlePrompt returns empty string when the session has no cached pro
);
});
test("tracks split Alpine prompts and expires them while the shell is busy", () => {
const session = {};
assert.equal(trackSessionIdlePrompt(session, "Welcome to Alpine!\r\nlocalhost:"), "");
assert.equal(trackSessionIdlePrompt(session, "~# "), "localhost:~# ");
assert.equal(getFreshIdlePrompt(session), "localhost:~# ");
assert.equal(trackSessionIdlePrompt(session, "printf busy"), "");
assert.equal(getFreshIdlePrompt(session), "");
trackSessionIdlePrompt(session, "\r\nbusy\r\nlocalhost:/srv# ");
assert.equal(getFreshIdlePrompt(session), "localhost:/srv# ");
trackSessionIdlePrompt(session, "printf working\r\nworking...");
assert.equal(getFreshIdlePrompt(session), "");
});
test("trackSessionIdlePrompt and getFreshIdlePrompt accept an Oracle/RHEL bracketed bash prompt", () => {
const session = {};
trackSessionIdlePrompt(session, "Last login...\r\n[opc@instance-20260905-1821 ~]$ ");
assert.equal(session.lastIdlePrompt, "[opc@instance-20260905-1821 ~]$ ");
assert.equal(getFreshIdlePrompt(session), "[opc@instance-20260905-1821 ~]$ ");
const rootSession = {};
trackSessionIdlePrompt(rootSession, "[root@host ~]#");
assert.equal(getFreshIdlePrompt(rootSession), "[root@host ~]#");
const classic = {};
trackSessionIdlePrompt(classic, "user@host:~$ ");
assert.equal(getFreshIdlePrompt(classic), "user@host:~$ ");
const unbracketed = {};
assert.equal(trackSessionIdlePrompt(unbracketed, "root@host ~#"), "");
assert.equal(getFreshIdlePrompt(unbracketed), "");
const spacedPath = {};
trackSessionIdlePrompt(spacedPath, "Last login...\r\n[user@host My Project]$ ");
assert.equal(spacedPath.lastIdlePrompt, "[user@host My Project]$ ");
assert.equal(getFreshIdlePrompt(spacedPath), "[user@host My Project]$ ");
const bracketedPath = {};
trackSessionIdlePrompt(bracketedPath, "Last login...\r\n[user@host My [Project]]$ ");
assert.equal(bracketedPath.lastIdlePrompt, "[user@host My [Project]]$ ");
assert.equal(getFreshIdlePrompt(bracketedPath), "[user@host My [Project]]$ ");
});
test("visible idle prompt survives a PSReadLine erase that leaves the raw tail dirty", () => {
// PSReadLine echoes the pending-input clear prefix (`ggd2147483647d` / `i`),
// then homes the cursor and erases those keys with CSI. stripAnsi drops the
// erase but keeps the keys, so the cached prompt no longer matches the tail.
// The screen is still an idle default PowerShell prompt (#3927).
const session = {};
const prompt = "PS C:\\Users\\alice> ";
trackSessionIdlePrompt(session, `Windows PowerShell\r\n${prompt}ggd2147483647d\x1b[20G\x1b[0J`);
assert.equal(getFreshIdlePrompt(session), "");
assert.equal(extractTrailingIdlePrompt(session._promptTrackTail), "");
assert.equal(getVisibleIdlePrompt(session), prompt);
const backspaced = {};
trackSessionIdlePrompt(backspaced, `${prompt}\x08`);
assert.equal(getFreshIdlePrompt(backspaced), "");
assert.equal(getVisibleIdlePrompt(backspaced), prompt);
const crHomed = {};
trackSessionIdlePrompt(crHomed, `${prompt}\r`);
assert.equal(getFreshIdlePrompt(crHomed), "");
assert.equal(getVisibleIdlePrompt(crHomed), prompt);
});
test("visible idle prompt stays empty for pending input, a following line, and an unrecognized prompt", () => {
const pending = {};
trackSessionIdlePrompt(pending, "PS C:\\Users\\alice> Write-Output ok");
assert.equal(getFreshIdlePrompt(pending), "");
assert.equal(getVisibleIdlePrompt(pending), "");
const movedOn = {};
trackSessionIdlePrompt(movedOn, "PS C:\\Users\\alice> \r\n");
assert.equal(getVisibleIdlePrompt(movedOn), "");
const custom = {};
trackSessionIdlePrompt(custom, "PS C:\\Users\\alice>\r\nexit\r\n❯ ");
assert.equal(getVisibleIdlePrompt(custom), "");
const fullscreen = {};
trackSessionIdlePrompt(fullscreen, "PS C:\\Users\\alice> \x1b[?1049h\x1b[Hvim");
assert.equal(getVisibleIdlePrompt(fullscreen), "");
assert.equal(getVisibleIdlePrompt({ _promptTrackTail: "" }), "");
assert.equal(getVisibleIdlePrompt(null), "");
});
test("getFreshIdlePrompt and trackSessionIdlePrompt round-trip through a real PTY-like flow", () => {
// (1) Remote PowerShell prompt arrives — lastIdlePrompt is captured.
const session = {};
@@ -3,7 +3,7 @@
// runs under `with (ctx)` where bare `require` resolves to ctx.require
// (based in electron/bridges/). Requiring here keeps the path unambiguous.
const { emitTerminalSessionData } = require("../emitTerminalSessionData.cjs");
const { formatSyntheticEcho, getVisibleIdlePrompt } = require("../ai/shellUtils.cjs");
const { formatSyntheticEcho } = require("../ai/shellUtils.cjs");
const { clearSessionFlowState } = require("../terminalFlowAck.cjs");
const { remoteDisallowsExecChannelProbe, sessionDisallowsExtraSshChannel, ensureSessionShellKindForExec } = require("../ai/sessionShellKind.cjs");
const defaultAiPrivacyService = require("../../shared/aiPrivacyService.cjs");
@@ -217,7 +217,7 @@ function registerCattyExecHandlers(ctx) {
chatSessionId,
expectedPrompt: getFreshIdlePrompt(session),
getReadyPrompt: typeof session._promptTrackTail === "string"
? () => getFreshIdlePrompt(session) || getVisibleIdlePrompt(session) : undefined,
? () => getFreshIdlePrompt(session) : undefined,
typedInput: true,
echoCommand: (rawCommand) => {
const contents = electronModule?.webContents?.fromId?.(session.webContentsId);
@@ -329,43 +329,6 @@ test("expired local and unknown jobs do not claim a lost worker route", (t) => {
});
test("MCP exec sends on an idle PowerShell prompt hidden by a CSI erase", async () => {
const pty = new FakePty();
pty.write = (data) => {
pty.writes.push(String(data));
const marker = String(data).match(/(__NCMCP_[0-9a-z]+_[0-9a-f]+__)/i)?.[1];
if (!marker) return;
queueMicrotask(() => pty.emit("data", `${marker}_S\r\nok\r\n${marker}_E:0\r\n`));
};
const { trackSessionIdlePrompt } = require("./ai/shellUtils.cjs");
const session = {
protocol: "ssh",
pty,
shellKind: "powershell",
_promptTrackTail: "",
lastIdlePrompt: "",
};
trackSessionIdlePrompt(session, "PS C:\\Users\\alice> ggd2147483647d\x1b[20G\x1b[0J");
const ctx = createExecHandlerTestContext({
sessions: new Map([["ps", session]]),
backgroundJobs: new Map(),
});
ctx.commandTimeoutMs = 200;
ctx.startPtyJob = require("./ai/ptyExec.cjs").startPtyJob;
ctx.execViaPty = require("./ai/ptyExec.cjs").execViaPty;
ctx.getFreshIdlePrompt = require("./ai/shellUtils.cjs").getFreshIdlePrompt;
const api = createExecHandlerApi(ctx);
const result = await api.handleExec({
sessionId: "ps",
chatSessionId: "ps-chat",
command: "Write-Output ok",
});
assert.equal(result.ok, true);
assert.equal(result.stdout, "ok");
assert.equal(pty.writes.length, 1);
assert.match(pty.writes[0], /\$__NCMCP_/);
});
test("MCP background readiness timeout survives job polling without PTY writes", async (t) => {
t.mock.timers.enable({ apis: ["Date", "setTimeout"] });
const pty = new FakePty();
@@ -2,7 +2,6 @@
// Module-level require: code inside createExecHandlerApi runs under `with (ctx)`
// where bare `require` resolves to ctx.require (based in electron/bridges/).
const { clearSessionFlowState } = require("../terminalFlowAck.cjs");
const { getVisibleIdlePrompt } = require("../ai/shellUtils.cjs");
const { emitJobDiag } = require("../ai/jobStartupDiagnostics.cjs");
const {
ensureSessionShellKind,
@@ -160,7 +159,7 @@ function createExecHandlerApi(ctx) {
onEchoSuppressionPrime: (marker) => echoCommandToSession(session, sessionId, `${marker}_I`, { syntheticEcho: false }),
expectedPrompt: getFreshIdlePrompt(session),
getReadyPrompt: typeof session._promptTrackTail === "string"
? () => getFreshIdlePrompt(session) || getVisibleIdlePrompt(session) : undefined,
? () => getFreshIdlePrompt(session) : undefined,
typedInput: true,
echoCommand: (rawCommand) => echoCommandToSession(session, sessionId, rawCommand),
chatSessionId,
@@ -342,7 +341,7 @@ function createExecHandlerApi(ctx) {
chatSessionId,
expectedPrompt: getFreshIdlePrompt(session),
getReadyPrompt: typeof session._promptTrackTail === "string"
? () => getFreshIdlePrompt(session) || getVisibleIdlePrompt(session) : undefined,
? () => getFreshIdlePrompt(session) : undefined,
typedInput: true,
echoCommand: (rawCommand) => echoCommandToSession(session, sessionId, rawCommand),
maxBufferedChars: MAX_BACKGROUND_JOB_OUTPUT_CHARS,
@@ -836,105 +836,6 @@ test("sudo SFTP-page transfer reuses the elevated channel instead of dialing aga
assert.equal(sftpClients.has("browse-sudo"), true);
});
for (const [ident, sequential] of [
["OpenSSH_9.6", false],
["CLOUDBILITY-4.14", true],
["TERM-SSHD", true],
]) {
test(`sudo transfer aliases preserve ${ident} upload concurrency`, { timeout: 15000 }, async (t) => {
const server = await startOtpOnlyServer({ ident });
pool.resetSshTransportRegistryForTests({ defaultIdleTtlMs: 0 });
const sftpClients = new Map();
sftpBridge.init({ sftpClients, sessions: new Map(), electronModule: {} });
transferBridge.init({ sftpClients });
t.after(async () => {
for (const sftpId of [...sftpClients.keys()].reverse()) {
await sftpBridge.closeSftp(null, { sftpId }).catch(() => {});
}
pool.discardAllTransports("test-end");
pool.resetSshTransportRegistryForTests({ defaultIdleTtlMs: 0 });
await server.close();
});
const options = { ...BASE_OPTIONS(server.port), reuseTransport: false };
const sender = createSender();
const opening = sftpBridge.openSftp({ sender }, { ...options, sessionId: "sudo-owner" });
opening.catch(() => {});
await waitFor(() => answerOtpPrompts(sender));
const opened = await opening;
const owner = sftpClients.get(opened.sftpId);
// Model an already-elevated channel. The real SSH/SFTP peer remains the
// ordinary server or bastion above; no second sudo/authentication is needed.
const sudoOptions = { ...BASE_OPTIONS(server.port), sudo: true };
const endpoint = pool.buildConnectionReuseEndpoint(sudoOptions, { sftpSudo: true });
owner.__netcattySudoMode = true;
owner.__netcattyEndpointKey = pool.buildEndpointKey(endpoint);
owner.__netcattyChannelAdoptionKey = pool.buildChannelAdoptionKey(endpoint);
const firstAlias = await sftpBridge.openSftp({ sender: createSender() }, sudoOptions);
const secondAlias = await sftpBridge.openSftp({ sender: createSender() }, sudoOptions);
for (const alias of [firstAlias, secondAlias]) {
const client = sftpClients.get(alias.sftpId);
assert.equal(client.__netcattySharedChannelAlias, true);
assert.equal(client.__netcattySudoMode, true);
assert.equal(client.sftp, owner.sftp, "both uploads must retain the elevated channel");
}
const root = fs.mkdtempSync(`${tempDirBridge.getTempFilePath("sudo-concurrency")}-`);
t.after(() => fs.rmSync(root, { recursive: true, force: true }));
const source = path.join(root, "source.bin");
const payload = Buffer.alloc(3 * 32768 + 17, 0x5a);
fs.writeFileSync(source, payload);
const firstPath = "/home/otpuser/first.bin";
const secondPath = "/home/otpuser/second.bin";
server.files.set(firstPath, Buffer.from("original file"));
const channel = owner.sftp;
const originalLstat = channel.lstat.bind(channel);
let firstTargetChecks = 0;
let promotionStarted;
const promotionStartedPromise = new Promise((resolve) => { promotionStarted = resolve; });
let releasePromotion;
const promotionGate = new Promise((resolve) => { releasePromotion = resolve; });
channel.lstat = (remotePath, ...args) => {
if (String(remotePath) === firstPath && ++firstTargetChecks === 2) {
promotionStarted();
void promotionGate.then(() => originalLstat(remotePath, ...args));
return;
}
originalLstat(remotePath, ...args);
};
const upload = (alias, targetPath) => transferBridge.startTransfer({ sender: createSender() }, {
transferId: `sudo-concurrency-${ident}-${path.basename(targetPath)}`,
sourcePath: source, targetPath,
sourceType: "local", targetType: "sftp", targetSftpId: alias.sftpId,
totalBytes: payload.length, resumable: true,
});
let firstFinished = false;
let secondFinished = false;
const first = upload(firstAlias, firstPath).then((result) => { firstFinished = true; return result; });
let second;
let results;
try {
await promotionStartedPromise;
second = upload(secondAlias, secondPath).then((result) => { secondFinished = true; return result; });
await waitFor(() => secondFinished
|| transferBridge._getSessionFastDownloadWaiterCountForTests(channel) > 0);
assert.equal(firstFinished, false, "the first upload must still be held before publication");
assert.equal(secondFinished, !sequential,
sequential ? "a bastion must wait for the first promotion" : "ordinary sudo uploads must remain concurrent");
assert.equal(transferBridge._getSessionFastDownloadWaiterCountForTests(channel), sequential ? 1 : 0);
} finally {
releasePromotion();
results = await Promise.all(second ? [first, second] : [first]);
channel.lstat = originalLstat;
}
for (const result of results) assert.equal(result.error, undefined, result.error);
assert.deepEqual(server.files.get(firstPath), payload);
assert.deepEqual(server.files.get(secondPath), payload);
assert.equal(server.getConnectionCount(), 1, "sudo uploads must not open an unelevated connection");
assert.equal(server.getKeyboardInteractiveRounds(), 1, "uploads must not ask for another OTP");
});
}
test("reconnecting the soft-closed owner id does not replace it with a self-alias (#3310)", async (t) => {
const { sftpTransferSessionLeaseStore, channelAliasLeaseId } = require("./sftpTransferSessionLease.cjs");
pool.resetSshTransportRegistryForTests({ defaultIdleTtlMs: 0 });
@@ -1,289 +0,0 @@
"use strict";
const test = require("node:test");
const assert = require("node:assert/strict");
const fs = require("node:fs");
const os = require("node:os");
const path = require("node:path");
const { execFileSync } = require("node:child_process");
const {
DEFAULT_SERVER_HOST_KEY,
SUPPORTED_SERVER_HOST_KEY,
} = require("ssh2/lib/protocol/constants.js");
const {
parseDERKey,
parseKey,
} = require("ssh2/lib/protocol/keyParser.js");
const {
hostKeyBlobTypeAccepted,
hostKeySignatureTypeAccepted,
} = require("ssh2/lib/protocol/kex.js");
// Manual live check (optional; needs network and real Instabox credentials):
// ssh -vv -p 2222 USER@ssh.instabox.sh
// Instabox advertises only ssh-ed25519-cert-v01@openssh.com and signs the
// exchange hash as ssh-ed25519. After this patch, host-key negotiation should
// succeed; authentication still depends on the real certificate credentials.
const HOST_CERT = "ssh-ed25519-cert-v01@openssh.com";
const HOST_CERT_ALGORITHMS = [
HOST_CERT,
"ecdsa-sha2-nistp256-cert-v01@openssh.com",
"ecdsa-sha2-nistp384-cert-v01@openssh.com",
"ecdsa-sha2-nistp521-cert-v01@openssh.com",
"rsa-sha2-512-cert-v01@openssh.com",
"rsa-sha2-256-cert-v01@openssh.com",
"ssh-rsa-cert-v01@openssh.com",
];
function sshKeygenSkipReason(error) {
if (error && error.code === "ENOENT")
return "ssh-keygen is not installed";
return false;
}
function detectSshKeygenSkip() {
try {
execFileSync("ssh-keygen", ["-h"], { stdio: "ignore" });
return false;
} catch (error) {
return sshKeygenSkipReason(error);
}
}
const sshKeygenSkip = detectSshKeygenSkip();
function mintEd25519HostCert(dir) {
const ca = path.join(dir, "ca");
const host = path.join(dir, "host");
execFileSync("ssh-keygen", ["-q", "-t", "ed25519", "-N", "", "-f", ca]);
execFileSync("ssh-keygen", ["-q", "-t", "ed25519", "-N", "", "-f", host]);
execFileSync("ssh-keygen", [
"-q",
"-s",
ca,
"-I",
"netcatty-hostcert",
"-h",
"-n",
"127.0.0.1",
"-V",
"+52w",
`${host}.pub`,
]);
const certLine = fs.readFileSync(`${host}-cert.pub`, "utf8").trim();
const [type, b64] = certLine.split(/\s+/);
const privateKey = parseKey(fs.readFileSync(host));
return {
type,
certBlob: Buffer.from(b64, "base64"),
privateKeyPem: fs.readFileSync(host),
privateKey,
};
}
test("ssh2 default and supported host key lists offer OpenSSH host certificates", () => {
for (const algo of HOST_CERT_ALGORITHMS) {
assert.equal(DEFAULT_SERVER_HOST_KEY.includes(algo), true, `${algo} missing from defaults`);
assert.equal(SUPPORTED_SERVER_HOST_KEY.includes(algo), true, `${algo} missing from supported`);
}
assert.ok(
DEFAULT_SERVER_HOST_KEY.indexOf(HOST_CERT) < DEFAULT_SERVER_HOST_KEY.indexOf("ssh-ed25519"),
"host certificates should be offered before the matching plain key",
);
assert.equal(DEFAULT_SERVER_HOST_KEY.includes("ssh-ed25519"), true);
assert.equal(DEFAULT_SERVER_HOST_KEY.includes("ssh-rsa"), true);
assert.equal(
SUPPORTED_SERVER_HOST_KEY.includes("sk-ssh-ed25519-cert-v01@openssh.com"),
true,
);
assert.equal(
SUPPORTED_SERVER_HOST_KEY.includes("sk-ecdsa-sha2-nistp256-cert-v01@openssh.com"),
true,
);
});
test("missing ssh-keygen skips host-certificate minting instead of failing", () => {
const missing = Object.assign(new Error("spawn ssh-keygen ENOENT"), { code: "ENOENT" });
assert.equal(sshKeygenSkipReason(missing), "ssh-keygen is not installed");
const present = Object.assign(new Error("usage"), { status: 1 });
assert.equal(sshKeygenSkipReason(present), false);
assert.equal(sshKeygenSkipReason(null), false);
});
test("parseDERKey reads an OpenSSH ed25519 host certificate the way KEX does", { skip: sshKeygenSkip }, () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-hostcert-"));
try {
const minted = mintEd25519HostCert(dir);
assert.equal(minted.type, HOST_CERT);
assert.equal(minted.privateKey instanceof Error, false, minted.privateKey.message);
const typeLen = minted.certBlob.readUInt32BE(0);
const type = minted.certBlob.subarray(4, 4 + typeLen).toString();
const keyMaterial = minted.certBlob.subarray(4 + typeLen);
const parsed = parseDERKey(keyMaterial, type);
assert.equal(parsed instanceof Error, false, parsed instanceof Error ? parsed.message : "");
assert.equal(parsed.type, HOST_CERT);
const message = Buffer.from("kex-exchange-hash");
const signature = minted.privateKey.sign(message);
assert.equal(signature instanceof Error, false, signature instanceof Error ? signature.message : "");
assert.equal(parsed.verify(message, signature), true);
} finally {
fs.rmSync(dir, { recursive: true, force: true });
}
});
test("host key signature type accepts the underlying algorithm for host certificates", () => {
assert.equal(typeof hostKeySignatureTypeAccepted, "function");
assert.equal(hostKeySignatureTypeAccepted(HOST_CERT, "ssh-ed25519"), true);
assert.equal(hostKeySignatureTypeAccepted(HOST_CERT, HOST_CERT), true);
assert.equal(hostKeySignatureTypeAccepted(HOST_CERT, "ssh-rsa"), false);
assert.equal(
hostKeySignatureTypeAccepted("ecdsa-sha2-nistp256-cert-v01@openssh.com", "ecdsa-sha2-nistp256"),
true,
);
assert.equal(
hostKeySignatureTypeAccepted("rsa-sha2-256-cert-v01@openssh.com", "rsa-sha2-256"),
true,
);
assert.equal(
hostKeySignatureTypeAccepted("rsa-sha2-256-cert-v01@openssh.com", "rsa-sha2-512"),
false,
);
assert.equal(
hostKeySignatureTypeAccepted("rsa-sha2-256-cert-v01@openssh.com", "ssh-rsa"),
false,
);
assert.equal(
hostKeySignatureTypeAccepted("rsa-sha2-256-cert-v00@openssh.com", "ssh-rsa"),
false,
);
assert.equal(
hostKeySignatureTypeAccepted("rsa-sha2-512-cert-v01@openssh.com", "rsa-sha2-512"),
true,
);
assert.equal(
hostKeySignatureTypeAccepted("rsa-sha2-512-cert-v01@openssh.com", "rsa-sha2-256"),
false,
);
assert.equal(
hostKeySignatureTypeAccepted("rsa-sha2-512-cert-v01@openssh.com", "ssh-rsa"),
false,
);
assert.equal(
hostKeySignatureTypeAccepted("ssh-rsa-cert-v01@openssh.com", "rsa-sha2-256"),
true,
);
assert.equal(
hostKeySignatureTypeAccepted("ssh-rsa-cert-v01@openssh.com", "rsa-sha2-512"),
true,
);
assert.equal(
hostKeySignatureTypeAccepted("ssh-rsa-cert-v01@openssh.com", "ssh-rsa"),
true,
);
assert.equal(
hostKeySignatureTypeAccepted("ssh-rsa-cert-v00@openssh.com", "ssh-rsa"),
true,
);
assert.equal(
hostKeyBlobTypeAccepted("rsa-sha2-256-cert-v01@openssh.com", "ssh-rsa-cert-v01@openssh.com"),
true,
);
assert.equal(
hostKeyBlobTypeAccepted("rsa-sha2-512-cert-v01@openssh.com", "ssh-rsa-cert-v01@openssh.com"),
true,
);
assert.equal(
hostKeyBlobTypeAccepted("rsa-sha2-256-cert-v01@openssh.com", "ssh-rsa"),
false,
);
assert.equal(
hostKeyBlobTypeAccepted("rsa-sha2-512-cert-v01@openssh.com", "ssh-rsa"),
false,
);
assert.equal(hostKeyBlobTypeAccepted("rsa-sha2-256", "ssh-rsa"), true);
assert.equal(hostKeyBlobTypeAccepted("rsa-sha2-512", "ssh-rsa"), true);
assert.equal(hostKeySignatureTypeAccepted("ssh-ed25519", "ssh-ed25519"), true);
assert.equal(hostKeySignatureTypeAccepted("ssh-ed25519", HOST_CERT), false);
assert.equal(hostKeySignatureTypeAccepted("rsa-sha2-256", "ssh-rsa"), false);
assert.equal(hostKeySignatureTypeAccepted("rsa-sha2-512", "rsa-sha2-256"), false);
});
function loadSsh2WithCertHostKey(pem, certBlob) {
// ssh2.Server only accepts PEM text and then advertises privateKey.type.
// Reload it after wrapping parseKey so this one key is presented as an
// OpenSSH host certificate while signing still uses the embedded key.
const keyParser = require("ssh2/lib/protocol/keyParser.js");
const originalParseKey = keyParser.parseKey;
keyParser.parseKey = function parseCertHostKey(data, passphrase) {
const parsed = originalParseKey(data, passphrase);
if (!(parsed instanceof Error) && data === pem) {
parsed.type = HOST_CERT;
parsed.getPublicSSH = () => certBlob;
}
return parsed;
};
const reloadIds = ["ssh2/lib/server.js", "ssh2/lib/index.js", "ssh2"];
const resolved = reloadIds.map((id) => require.resolve(id));
const previous = resolved.map((filename) => require.cache[filename]);
for (const filename of resolved) delete require.cache[filename];
const ssh2 = require("ssh2");
for (let i = 0; i < resolved.length; i++) {
if (previous[i]) require.cache[resolved[i]] = previous[i];
else delete require.cache[resolved[i]];
}
keyParser.parseKey = originalParseKey;
return ssh2;
}
test("ssh2 client completes KEX against a cert-only ed25519 host key", { skip: sshKeygenSkip }, async () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-hostcert-kex-"));
const minted = mintEd25519HostCert(dir);
assert.equal(minted.privateKey instanceof Error, false, minted.privateKey.message);
const { Client, Server } = loadSsh2WithCertHostKey(minted.privateKeyPem, minted.certBlob);
const server = new Server({ hostKeys: [minted.privateKeyPem] }, (client) => {
client.on("error", () => {});
client.on("authentication", (ctx) => ctx.reject());
});
await new Promise((resolve, reject) => {
server.once("error", reject);
server.listen(0, "127.0.0.1", resolve);
});
try {
const authError = await new Promise((resolve, reject) => {
const client = new Client();
const timer = setTimeout(() => {
client.end();
reject(new Error("timed out waiting for KEX/auth"));
}, 10000);
client.on("ready", () => {
clearTimeout(timer);
client.end();
reject(new Error("authentication unexpectedly succeeded"));
});
client.on("error", (err) => {
clearTimeout(timer);
client.end();
resolve(err);
});
client.connect({
host: "127.0.0.1",
port: server.address().port,
username: "netcatty",
password: "not-the-password",
readyTimeout: 8000,
});
});
assert.match(authError.message, /authentication/i);
assert.doesNotMatch(
authError.message,
/Handshake failed|no matching host key|Wrong signature type|Unsupported OpenSSH|signature verification failed|Host key does not match/,
);
} finally {
await new Promise((resolve) => server.close(resolve));
fs.rmSync(dir, { recursive: true, force: true });
}
});
-88
View File
@@ -2,12 +2,10 @@
const test = require("node:test");
const assert = require("node:assert/strict");
const { createHash, generateKeyPairSync, sign } = require("node:crypto");
const {
parseKey,
getOpenSSHCertSignatureAlgorithm,
} = require("ssh2/lib/protocol/keyParser.js");
const { convertSignature } = require("ssh2/lib/protocol/utils.js");
function buildSkEd25519PublicLine() {
const type = Buffer.from("sk-ssh-ed25519@openssh.com");
@@ -148,89 +146,3 @@ test("ssh2 agent protocol preserves SK signature framing, flags and counter", as
}
}
});
function sshString(value) {
const body = Buffer.isBuffer(value) ? value : Buffer.from(value);
const out = Buffer.alloc(4 + body.length);
out.writeUInt32BE(body.length, 0);
body.copy(out, 4);
return out;
}
function opensshPublicLine(typeName, fields) {
const blob = Buffer.concat([
sshString(typeName),
...fields.map((field) => sshString(field)),
]);
return `${typeName} ${blob.toString("base64")} netcatty@sk`;
}
function skEcdsaSignature(rawSig, flags, counter) {
const out = Buffer.alloc(4 + rawSig.length + 5);
out.writeUInt32BE(rawSig.length, 0);
rawSig.copy(out, 4);
const pos = 4 + rawSig.length;
out[pos] = flags;
out.writeUInt32BE(counter, pos + 1);
return out;
}
test("ssh2 verifies an SK ECDSA certificate with the same P-256 signature as the plain key", () => {
const { publicKey, privateKey } = generateKeyPairSync("ec", {
namedCurve: "prime256v1",
});
const jwk = publicKey.export({ format: "jwk" });
const point = Buffer.concat([
Buffer.from([0x04]),
Buffer.from(jwk.x, "base64url"),
Buffer.from(jwk.y, "base64url"),
]);
const application = "ssh:";
const plain = parseKey(opensshPublicLine("sk-ecdsa-sha2-nistp256@openssh.com", [
"nistp256",
point,
application,
]));
const cert = parseKey(opensshPublicLine("sk-ecdsa-sha2-nistp256-cert-v01@openssh.com", [
Buffer.alloc(16, 3),
"nistp256",
point,
application,
]));
assert.equal(plain instanceof Error, false, plain instanceof Error ? plain.message : "");
assert.equal(cert instanceof Error, false, cert instanceof Error ? cert.message : "");
assert.equal(cert.type, "sk-ecdsa-sha2-nistp256-cert-v01@openssh.com");
assert.equal(cert.getApplication(), application);
const message = Buffer.from("kex-exchange-hash");
const flags = 0x01;
const counter = 0x12345678;
const appHash = createHash("sha256").update(application).digest();
const dataHash = createHash("sha256").update(message).digest();
const signedData = Buffer.alloc(appHash.length + 1 + 4 + dataHash.length);
let offset = 0;
appHash.copy(signedData, offset);
offset += appHash.length;
signedData[offset++] = flags;
signedData.writeUInt32BE(counter, offset);
offset += 4;
dataHash.copy(signedData, offset);
const der = sign("sha256", signedData, privateKey);
const rawSig = convertSignature(der, "ecdsa-sha2-nistp256");
assert.ok(Buffer.isBuffer(rawSig));
const signature = skEcdsaSignature(rawSig, flags, counter);
const plainResult = plain.verify(message, signature);
assert.equal(
plainResult,
true,
plainResult instanceof Error ? plainResult.message : String(plainResult),
);
const certResult = cert.verify(message, signature);
assert.equal(
certResult,
true,
certResult instanceof Error ? certResult.message : String(certResult),
);
});
+3 -16
View File
@@ -114,13 +114,6 @@ function applyLegacyAlgorithms(algorithms) {
]),
);
algorithms.serverHostKey = [
"ssh-ed25519-cert-v01@openssh.com",
"ecdsa-sha2-nistp256-cert-v01@openssh.com",
"ecdsa-sha2-nistp384-cert-v01@openssh.com",
"ecdsa-sha2-nistp521-cert-v01@openssh.com",
"rsa-sha2-512-cert-v01@openssh.com",
"rsa-sha2-256-cert-v01@openssh.com",
"ssh-rsa-cert-v01@openssh.com",
"ssh-ed25519", "ecdsa-sha2-nistp256", "ecdsa-sha2-nistp384", "ecdsa-sha2-nistp521",
"rsa-sha2-512", "rsa-sha2-256",
"ssh-rsa", "ssh-dss",
@@ -157,16 +150,10 @@ function applyLegacyHmacAlgorithms(algorithms) {
}
// Mirror of ssh2's DEFAULT_SERVER_HOST_KEY (see ssh2/lib/protocol/constants.js)
// minus every ecdsa-sha2-* (plain keys and host certificates). Used when
// skipEcdsaHostKey is on but legacy mode is off, so the field would otherwise
// be undefined and ssh2 would fall back to its built-in defaults (which still
// include ecdsa-sha2-*). Non-ECDSA host certificates stay so a cert-only
// bastion can still be reached.
// minus every ecdsa-sha2-*. Used when skipEcdsaHostKey is on but legacy mode
// is off, so the field would otherwise be undefined and ssh2 would fall back
// to its built-in defaults (which still include ecdsa-sha2-*).
const MODERN_SERVER_HOST_KEY_WITHOUT_ECDSA = Object.freeze([
"ssh-ed25519-cert-v01@openssh.com",
"rsa-sha2-512-cert-v01@openssh.com",
"rsa-sha2-256-cert-v01@openssh.com",
"ssh-rsa-cert-v01@openssh.com",
"ssh-ed25519",
"rsa-sha2-512",
"rsa-sha2-256",
-20
View File
@@ -269,19 +269,6 @@ for (const [label, buildAlgorithms] of [
["SSH", sshBridge.buildAlgorithms],
["SFTP", sftpBridge.buildSftpAlgorithms],
]) {
test(`${label} legacy serverHostKey offers OpenSSH host certificates before plain keys`, () => {
withAlgorithmRuntime({}, () => {
const keys = buildAlgorithms(true).serverHostKey;
assert.ok(keys.includes("ssh-ed25519-cert-v01@openssh.com"));
assert.ok(keys.indexOf("ssh-ed25519-cert-v01@openssh.com") < keys.indexOf("ssh-ed25519"));
assert.ok(keys.includes("ecdsa-sha2-nistp256-cert-v01@openssh.com"));
assert.ok(keys.includes("rsa-sha2-512-cert-v01@openssh.com"));
assert.ok(keys.includes("rsa-sha2-256-cert-v01@openssh.com"));
assert.ok(keys.includes("ssh-rsa-cert-v01@openssh.com"));
assert.ok(keys.includes("ssh-dss"));
});
});
test(`${label} skipEcdsaHostKey removes every ecdsa-sha2-* from serverHostKey (legacy on)`, () => {
withAlgorithmRuntime({}, () => {
const algorithms = buildAlgorithms(true, { skipEcdsaHostKey: true });
@@ -293,9 +280,6 @@ for (const [label, buildAlgorithms] of [
assert.ok(algorithms.serverHostKey.includes("ssh-rsa"));
assert.ok(algorithms.serverHostKey.includes("ssh-dss"));
assert.ok(algorithms.serverHostKey.includes("ssh-ed25519"));
assert.ok(algorithms.serverHostKey.includes("ssh-ed25519-cert-v01@openssh.com"));
assert.ok(algorithms.serverHostKey.includes("rsa-sha2-512-cert-v01@openssh.com"));
assert.ok(algorithms.serverHostKey.includes("ssh-rsa-cert-v01@openssh.com"));
});
});
@@ -309,10 +293,6 @@ for (const [label, buildAlgorithms] of [
for (const algo of algorithms.serverHostKey) {
assert.ok(!algo.startsWith("ecdsa-sha2-"), `${algo} should be filtered out`);
}
assert.ok(algorithms.serverHostKey.includes("ssh-ed25519-cert-v01@openssh.com"));
assert.ok(algorithms.serverHostKey.includes("rsa-sha2-512-cert-v01@openssh.com"));
assert.ok(algorithms.serverHostKey.includes("rsa-sha2-256-cert-v01@openssh.com"));
assert.ok(algorithms.serverHostKey.includes("ssh-rsa-cert-v01@openssh.com"));
});
});
+1 -26
View File
@@ -73,11 +73,6 @@ function normalizePassword(password) {
return typeof password === "string" ? password : "";
}
// Bare CR/LF used to wake a silent serial console before "login:" appears.
function isAutoLoginWakeInput(data) {
return typeof data === "string" && /^[\r\n]+$/.test(data);
}
function createTelnetAutoLogin(options = {}) {
const username = normalizeUsername(options.username);
const password = normalizePassword(options.password);
@@ -212,26 +207,7 @@ function createTelnetAutoLogin(options = {}) {
notifyIncomplete();
}
},
// Silent serial consoles need a bare CR/LF to wake getty before "login:"
// appears. While the detector is still active that wake is not a
// takeover: keep autofill armed, leave the caller's write on the port,
// and do not consume the one-shot continue-prompt response. A later
// "Press RETURN to continue" must still be answered. Once the detector
// is disabled (expiry, completion, or a prior cancel), the same Enter
// notifies like any other user input so callers can drop a deferred
// startup command. Any other user input still cancels.
handleUserInput(data) {
const wakeOnly = isAutoLoginWakeInput(data);
if (
wakeOnly
&& !disabled
&& !sentUsername
&& !sentPassword
&& !isUsernamePrompt(tail)
&& !isPasswordPrompt(tail)
) {
return;
}
handleUserInput() {
disabled = true;
clearExpiryTimer();
notifyUserInput();
@@ -248,7 +224,6 @@ function createTelnetAutoLogin(options = {}) {
module.exports = {
createTelnetAutoLogin,
isAutoLoginWakeInput,
isCommandPrompt,
isContinuePrompt,
isPasswordPrompt,
-149
View File
@@ -224,155 +224,6 @@ test("telnet auto-login stops when the user starts typing manually", () => {
assert.equal(cancelCount, 1);
});
test("telnet auto-login stays armed when a wake-only enter arrives before any login prompt", () => {
const writes = [];
let cancelCount = 0;
const autoLogin = createTelnetAutoLogin({
username: "admin",
password: "secret",
write: (data) => writes.push(data),
onUserInput: () => { cancelCount += 1; },
});
// Silent serial consoles need a bare CR to wake getty before "login:" appears.
autoLogin.handleUserInput("\r");
autoLogin.handleText("login: ");
autoLogin.handleText("\r\nPassword: ");
assert.equal(cancelCount, 0);
assert.deepEqual(writes, ["admin\r", "secret\r"]);
});
test("telnet auto-login treats a bare newline as a wake and still autofills", () => {
const writes = [];
let cancelCount = 0;
const autoLogin = createTelnetAutoLogin({
username: "admin",
password: "secret",
write: (data) => writes.push(data),
onUserInput: () => { cancelCount += 1; },
});
autoLogin.handleUserInput("\n");
autoLogin.handleUserInput("\r\n");
autoLogin.handleText("login: ");
autoLogin.handleText("\r\nPassword: ");
assert.equal(cancelCount, 0);
assert.deepEqual(writes, ["admin\r", "secret\r"]);
});
test("telnet auto-login notifies when a wake enter arrives after expiry", () => {
let cancelCount = 0;
let timerFn = null;
const writes = [];
const autoLogin = createTelnetAutoLogin({
username: "admin",
password: "secret",
write: (data) => writes.push(data),
timeoutMs: 60_000,
onUserInput: () => { cancelCount += 1; },
setTimeout: (fn) => {
timerFn = fn;
return { unref() {} };
},
clearTimeout: () => {},
});
// Quiet device: the 60s window elapses with no prompt, which disables the
// detector without a user-input notification. Serial startup still has its
// 65s fallback armed until this cancellation arrives.
timerFn();
assert.equal(cancelCount, 0);
autoLogin.handleUserInput("\r");
assert.equal(cancelCount, 1);
autoLogin.handleText("Press RETURN to continue");
autoLogin.handleText("login: ");
autoLogin.handleText("\r\nPassword: ");
assert.equal(cancelCount, 1);
assert.deepEqual(writes, []);
});
test("telnet auto-login still answers a continue prompt after a blind wake enter", () => {
const writes = [];
let cancelCount = 0;
const autoLogin = createTelnetAutoLogin({
username: "admin",
password: "secret",
write: (data) => writes.push(data),
onUserInput: () => { cancelCount += 1; },
});
// The user's Enter woke a silent console. It must not consume the
// detector's one-shot response when the device then asks to continue.
autoLogin.handleUserInput("\r");
autoLogin.handleText("Press RETURN to continue");
autoLogin.handleText("\r\nlogin: ");
autoLogin.handleText("\r\nPassword: ");
assert.equal(cancelCount, 0);
assert.deepEqual(writes, ["\r", "admin\r", "secret\r"]);
});
test("telnet auto-login still cancels when a normal character arrives before login", () => {
const writes = [];
let cancelCount = 0;
const autoLogin = createTelnetAutoLogin({
username: "admin",
password: "secret",
write: (data) => writes.push(data),
onUserInput: () => { cancelCount += 1; },
});
autoLogin.handleUserInput("x");
autoLogin.handleText("login: ");
autoLogin.handleText("\r\nPassword: ");
assert.equal(cancelCount, 1);
assert.deepEqual(writes, []);
});
test("telnet auto-login still cancels an enter after credentials have started", () => {
const writes = [];
let cancelCount = 0;
const autoLogin = createTelnetAutoLogin({
username: "admin",
password: "secret",
write: (data) => writes.push(data),
onUserInput: () => { cancelCount += 1; },
});
autoLogin.handleText("login: ");
autoLogin.handleUserInput("\r");
autoLogin.handleText("\r\nPassword: ");
assert.equal(cancelCount, 1);
assert.deepEqual(writes, ["admin\r"]);
});
test("telnet auto-login still cancels an enter when a login prompt is already in the tail", () => {
let clock = 0;
const writes = [];
let cancelCount = 0;
const autoLogin = createTelnetAutoLogin({
username: "admin",
password: "secret",
write: (data) => writes.push(data),
now: () => clock,
timeoutMs: 60_000,
onUserInput: () => { cancelCount += 1; },
});
// After the window, the prompt is recorded but credentials are not sent.
clock = 60_001;
autoLogin.handleText("login: ");
autoLogin.handleUserInput("\r");
assert.equal(cancelCount, 1);
assert.deepEqual(writes, []);
});
test("telnet auto-login avoids common non-prompt login text", () => {
const writes = [];
const autoLogin = createTelnetAutoLogin({
+7 -27
View File
@@ -63,7 +63,7 @@ const { discoverShells } = require("./shellDiscovery.cjs");
const { isWindowsAppExecutionAliasPath } = require("../../lib/localShell.cjs");
const moshHandshake = require("./moshHandshake.cjs");
const tempDirBridge = require("./tempDirBridge.cjs");
const { createTelnetAutoLogin, isAutoLoginWakeInput } = require("./telnetAutoLogin.cjs");
const { createTelnetAutoLogin } = require("./telnetAutoLogin.cjs");
const telnetProtocol = require("./telnetProtocol.cjs");
const { createPtyOutputBuffer } = require("./ptyOutputBuffer.cjs");
const { enableTcpNoDelay } = require("./tcpNoDelay.cjs");
@@ -1713,9 +1713,8 @@ function writeToSessionNow(payload, data, logRewrite = payload.logRewrite) {
(session.type === 'telnet-native' || session.type === 'serial')
&& !payload.automated
&& !isTerminalReportSequence(data)
&& !payload.autoLoginInputHandled
) {
session.autoLogin?.handleUserInput(data);
session.autoLogin?.handleUserInput();
}
// Encode keystrokes with the SAME charset the output path decodes with so
@@ -1806,8 +1805,6 @@ function writeToSessionWithInterception(
paste = null,
index = 0,
) {
// Stays the original payload until ingress classification marks this write.
let sessionWritePayload = payload;
const writeWithReceipt = (nextData) => {
if (paste && !paste.active) return;
const current = sessions.get(payload.sessionId);
@@ -1820,7 +1817,7 @@ function writeToSessionWithInterception(
paste.finish(index, true);
return;
}
const written = writeToSessionNow(sessionWritePayload, nextData, logRewrite);
const written = writeToSessionNow(payload, nextData, logRewrite);
if (paste) paste.finish(written ? index : undefined);
};
const bypass = payload?.sensitive === true || isTerminalReportSequence(data);
@@ -1833,24 +1830,20 @@ function writeToSessionWithInterception(
writeWithReceipt(data);
return;
}
// Classify auto-login at input ingress, before the asynchronous
// Cancel the auto-login detector at input ingress, before the asynchronous
// interception pipeline: a slow interceptor would otherwise leave the
// detector armed while the user's keystrokes are queued, letting a login
// prompt that arrives during that wait trigger a saved-credential
// transmission after the user has already taken over. Same guard
// conditions as writeToSessionNow. Wake-only CR/LF is not idempotent once
// credentials start, so mark this write and do not classify those bytes
// again when they reach writeToSessionNow. That preserved classification
// describes the queued bytes only while they stay wake-only; writeIfCurrent
// classifies an interceptor rewrite that is no longer wake-only.
// conditions as writeToSessionNow; handleUserInput is idempotent, so the
// later call there stays a no-op.
if (
expectedSession
&& (expectedSession.type === 'telnet-native' || expectedSession.type === 'serial')
&& !payload.automated
&& !isTerminalReportSequence(data)
) {
expectedSession.autoLogin?.handleUserInput(data);
sessionWritePayload = { ...payload, autoLoginInputHandled: true };
expectedSession.autoLogin?.handleUserInput();
}
const writeIfCurrent = (nextData) => {
const current = sessions.get(payload.sessionId);
@@ -1858,19 +1851,6 @@ function writeToSessionWithInterception(
paste?.finish();
return;
}
// An Enter-to-command macro can turn the wake CR/LF classified above
// into ordinary text. Classify that transformed value before it is
// written. autoLoginInputHandled stays set so writeToSessionNow does not
// classify the same write again. A rewrite that is still only CR/LF keeps
// the ingress classification: those bytes are not idempotent once
// credentials have started.
if (
sessionWritePayload.autoLoginInputHandled
&& !isAutoLoginWakeInput(nextData)
&& !isTerminalReportSequence(nextData)
) {
current.autoLogin?.handleUserInput(nextData);
}
writeWithReceipt(nextData);
};
const write = async () => {
@@ -1,32 +1,6 @@
const test = require("node:test");
const assert = require("node:assert/strict");
const terminalBridge = require("./terminalBridge.cjs");
const { createTelnetAutoLogin } = require("./telnetAutoLogin.cjs");
function createSerialAutoLoginSession() {
const writes = [];
const events = [];
const session = {
type: "serial",
protocol: "serial",
encoding: "utf-8",
serialPort: {
write: (data) => {
writes.push(typeof data === "string" ? data : Buffer.from(data).toString());
return true;
},
},
};
session.autoLogin = createTelnetAutoLogin({
username: "admin",
password: "secret",
write: (data) => {
session.serialPort.write(data);
},
onUserInput: () => { events.push("cancelled"); },
});
return { session, writes, events };
}
test("serial auto-login cancels on interactive user input but not automated writes", () => {
let userInputs = 0;
@@ -87,221 +61,6 @@ test("serial line-mode buffered input notification cancels auto-login", () => {
}
});
test("serial wake-only enter keeps auto-login armed and still reaches the port", () => {
const { session, writes, events } = createSerialAutoLoginSession();
terminalBridge.init({ sessions: new Map([["s", session]]), electronModule: {} });
try {
terminalBridge.writeToSession({}, { sessionId: "s", data: "\r" });
assert.deepEqual(events, []);
assert.deepEqual(writes, ["\r"]);
session.autoLogin.handleText("login: ");
session.autoLogin.handleText("\r\nPassword: ");
assert.deepEqual(events, []);
assert.deepEqual(writes, ["\r", "admin\r", "secret\r"]);
} finally {
terminalBridge.cleanupAllSessions();
}
});
test("serial character input before login still cancels auto-login", () => {
const { session, writes, events } = createSerialAutoLoginSession();
terminalBridge.init({ sessions: new Map([["s", session]]), electronModule: {} });
try {
terminalBridge.writeToSession({}, { sessionId: "s", data: "x" });
session.autoLogin.handleText("login: ");
session.autoLogin.handleText("\r\nPassword: ");
assert.deepEqual(events, ["cancelled"]);
assert.deepEqual(writes, ["x"]);
} finally {
terminalBridge.cleanupAllSessions();
}
});
test("serial wake-only enter stays armed when an input interceptor is pending", async () => {
const { session, writes, events } = createSerialAutoLoginSession();
let interceptorResolved = false;
terminalBridge.init({
sessions: new Map([["s", session]]),
electronModule: {},
terminalDataPipeline: {
has: () => true,
interceptInput: async (sessionId, data) => {
await new Promise((resolve) => setTimeout(resolve, 20));
interceptorResolved = true;
return data;
},
},
});
try {
terminalBridge.writeToSession({}, { sessionId: "s", data: "\n" });
assert.deepEqual(events, []);
assert.equal(interceptorResolved, false);
await new Promise((resolve) => setTimeout(resolve, 40));
assert.equal(interceptorResolved, true);
assert.deepEqual(writes, ["\n"]);
session.autoLogin.handleText("login: ");
session.autoLogin.handleText("\r\nPassword: ");
assert.deepEqual(events, []);
assert.deepEqual(writes, ["\n", "admin\r", "secret\r"]);
} finally {
terminalBridge.cleanupAllSessions();
}
});
test("serial wake enter is not cancelled when login arrives during interceptor delay", async () => {
const { session, writes, events } = createSerialAutoLoginSession();
let interceptorStarted = false;
let releaseInterceptor = () => {};
const interceptorGate = new Promise((resolve) => {
releaseInterceptor = resolve;
});
terminalBridge.init({
sessions: new Map([["s", session]]),
electronModule: {},
terminalDataPipeline: {
has: () => true,
interceptInput: async (_sessionId, data) => {
interceptorStarted = true;
await new Promise((resolve) => setTimeout(resolve, 30));
await interceptorGate;
return data;
},
},
});
try {
terminalBridge.writeToSession({}, { sessionId: "s", data: "\r" });
await Promise.resolve();
assert.equal(interceptorStarted, true);
// Prompt arrives while the wake byte is still queued in the interceptor.
session.autoLogin.handleText("login: ");
assert.deepEqual(events, []);
assert.deepEqual(writes, ["admin\r"]);
releaseInterceptor();
const deadline = Date.now() + 500;
while (writes.length < 2 && Date.now() < deadline) {
await new Promise((resolve) => setTimeout(resolve, 10));
}
assert.deepEqual(events, []);
assert.deepEqual(writes, ["admin\r", "\r"]);
session.autoLogin.handleText("\r\nPassword: ");
assert.deepEqual(events, []);
assert.deepEqual(writes, ["admin\r", "\r", "secret\r"]);
} finally {
releaseInterceptor();
terminalBridge.cleanupAllSessions();
}
});
test("serial interceptor that rewrites a wake enter into text cancels auto-login", async () => {
const { session, writes, events } = createSerialAutoLoginSession();
let interceptorStarted = false;
terminalBridge.init({
sessions: new Map([["s", session]]),
electronModule: {},
terminalDataPipeline: {
has: () => true,
interceptInput: async (_sessionId, data) => {
interceptorStarted = true;
assert.equal(data, "\r");
await new Promise((resolve) => setTimeout(resolve, 20));
// Enter-to-command macro: the bare wake CR is no longer what is written.
return "uptime\r";
},
},
});
try {
terminalBridge.writeToSession({}, { sessionId: "s", data: "\r" });
await Promise.resolve();
assert.equal(interceptorStarted, true);
// Ingress still sees a wake, so autofill stays armed until the rewrite lands.
assert.deepEqual(events, []);
assert.deepEqual(writes, []);
const deadline = Date.now() + 500;
while (writes.length < 1 && Date.now() < deadline) {
await new Promise((resolve) => setTimeout(resolve, 10));
}
assert.deepEqual(events, ["cancelled"]);
assert.deepEqual(writes, ["uptime\r"]);
session.autoLogin.handleText("login: ");
session.autoLogin.handleText("\r\nPassword: ");
assert.deepEqual(events, ["cancelled"]);
assert.deepEqual(writes, ["uptime\r"]);
} finally {
terminalBridge.cleanupAllSessions();
}
});
test("serial interceptor that rewrites a wake enter into another wake still autofills", async () => {
const { session, writes, events } = createSerialAutoLoginSession();
let releaseInterceptor = () => {};
const interceptorGate = new Promise((resolve) => {
releaseInterceptor = resolve;
});
terminalBridge.init({
sessions: new Map([["s", session]]),
electronModule: {},
terminalDataPipeline: {
has: () => true,
interceptInput: async (_sessionId, data) => {
assert.equal(data, "\r");
await interceptorGate;
return "\r\n";
},
},
});
try {
terminalBridge.writeToSession({}, { sessionId: "s", data: "\r" });
await Promise.resolve();
session.autoLogin.handleText("login: ");
assert.deepEqual(events, []);
assert.deepEqual(writes, ["admin\r"]);
releaseInterceptor();
const deadline = Date.now() + 500;
while (writes.length < 2 && Date.now() < deadline) {
await new Promise((resolve) => setTimeout(resolve, 10));
}
assert.deepEqual(events, []);
assert.deepEqual(writes, ["admin\r", "\r\n"]);
session.autoLogin.handleText("\r\nPassword: ");
assert.deepEqual(events, []);
assert.deepEqual(writes, ["admin\r", "\r\n", "secret\r"]);
} finally {
releaseInterceptor();
terminalBridge.cleanupAllSessions();
}
});
test("serial line-mode notification still cancels auto-login without keystroke data", () => {
const { session, writes, events } = createSerialAutoLoginSession();
terminalBridge.init({ sessions: new Map([["s", session]]), electronModule: {} });
try {
terminalBridge.notifySessionUserInput({}, { sessionId: "s" });
session.autoLogin.handleText("login: ");
session.autoLogin.handleText("\r\nPassword: ");
assert.deepEqual(events, ["cancelled"]);
assert.deepEqual(writes, []);
} finally {
terminalBridge.cleanupAllSessions();
}
});
test("serial sessions without auto-login credentials accept input normally", () => {
const writes = [];
const session = {
-90
View File
@@ -76,11 +76,6 @@ class TerminalTextRenderer {
return this.toString();
}
getCursorLine() {
const line = this.lines[this.row] || [];
return line.map((cell) => cell?.ch || " ").join("");
}
toString({ includePendingClearedScreen = false, formatLine = (line) => line } = {}) {
const lines = includePendingClearedScreen ? this.#linesWithPendingClearedScreen() : this.lines;
return lines
@@ -520,89 +515,6 @@ function terminalDataToPlainText(terminalData) {
return renderer.finish();
}
function terminalCursorLine(terminalData, options) {
// Prompt tracking keeps a rolling tail, so the alternate-screen entry may
// already have scrolled off. Seed the mode that was active at the first
// byte still in that tail; a fresh renderer would otherwise treat TUI paint
// as the main screen.
const renderer = new TerminalTextRenderer({
alternateScreenActive: options?.alternateScreenActive === true,
});
renderer.feed(terminalData || "");
renderer.finish();
// A full-screen program owns the display. The main buffer may still hold
// the shell prompt from before the alternate screen was entered.
if (renderer.alternateScreenActive) return "";
return renderer.getCursorLine();
}
// Alternate-screen transitions from TerminalTextRenderer, without a text
// buffer. Callers replay the bytes a rolling tail dropped so truncation
// cannot clear the guard.
function alternateScreenActiveAfter(text, alternateScreenActive) {
let active = alternateScreenActive === true;
if (!text) return active;
let state = "normal";
let escapeBuffer = "";
for (const ch of text) {
if (state === "esc") {
if (ch === "[") {
state = "csi";
escapeBuffer = "";
continue;
}
if (ch === "]") {
state = "osc";
continue;
}
if (ch === "c") active = false;
state = "normal";
continue;
}
if (state === "csi") {
escapeBuffer += ch;
if (CSI_FINAL_RE.test(ch)) {
active = alternateScreenActiveFromCsi(escapeBuffer, active);
state = "normal";
escapeBuffer = "";
}
continue;
}
if (state === "osc") {
if (ch === "\x07") state = "normal";
else if (ch === "\x1b") state = "oscEsc";
continue;
}
if (state === "oscEsc") {
state = ch === "\\" ? "normal" : "osc";
continue;
}
if (ch === "\x1b") {
state = "esc";
continue;
}
if (ch === "\x9b") {
state = "csi";
escapeBuffer = "";
}
}
return active;
}
function alternateScreenActiveFromCsi(sequence, active) {
const final = sequence.at(-1);
if (final !== "h" && final !== "l") return active;
const params = sequence.slice(0, -1);
if (!params.includes("?")) return active;
for (const part of params.replace(/[?><=]/g, "").split(";")) {
if (part === "") continue;
const mode = Number.parseInt(part, 10);
if (mode === 47 || mode === 1047 || mode === 1049) return final === "h";
}
return active;
}
function terminalDataToHtmlContent(terminalData) {
const renderer = new TerminalTextRenderer();
renderer.feed(terminalData || "");
@@ -619,8 +531,6 @@ module.exports = {
createTerminalTextRenderer,
terminalDataToHtmlContent,
terminalDataToPlainText,
terminalCursorLine,
alternateScreenActiveAfter,
};
function createDefaultStyle() {
+6 -78
View File
@@ -2474,68 +2474,16 @@ async function acquireSessionFastDownloadSlot(client, transfer) {
}
}
function isSingleChannelSftpClient(client) {
return Boolean(
client?.__netcattySingleChannelSsh
|| client?.client?.__netcattySingleChannelSsh,
);
}
function requiresExclusiveSftpTransferChannel(client, channel) {
// Aliases stay on one authenticated/elevated channel, even on ordinary SSH
// servers. Only the owning peer's restriction requires serial transfers.
const peer = client?.__netcattySharedChannelAlias
? sftpClients?.get(client.__netcattySharedChannelOwnerId) || client
: client;
return isSingleChannelSftpClient(peer)
|| getSftpTransferConcurrency(channel, DOWNLOAD_TRANSFER_CONCURRENCY) === 1;
}
/**
* Some single-channel gateways cannot safely interleave SFTP metadata and
* write requests from different files. Serialize the complete upload
* transaction per raw SFTP stream while leaving independent connections free.
*/
async function acquireExclusiveSftpTransferTransaction(client, transfer) {
if (isScpModeClient(client)) return null;
// Preserve the normal lazy SFTP-open path. Only known single-channel peers
// acquire the transaction slot before the upload itself opens a handle.
const knownChannel = client?.sftp || { _client: client?.client };
if (!requiresExclusiveSftpTransferChannel(client, knownChannel)) return null;
await requireSftpChannel(client);
const channel = client.sftp;
if (transfer?._exclusiveSftpTransactionChannel === channel) return null;
await acquireSessionFastDownloadSlot(channel, transfer);
transfer._exclusiveSftpTransactionChannel = channel;
let released = false;
return {
client,
channel,
release() {
if (released) return;
released = true;
if (transfer._exclusiveSftpTransactionChannel === channel) {
transfer._exclusiveSftpTransactionChannel = null;
}
releaseSessionFastDownloadSlot(channel);
},
};
}
// Single-channel compatibility permits only one transfer operation per raw
// SFTP stream. A per-file request limit is insufficient because pooled aliases
// share the physical channel, and even a tiny file's second OPEN or verification
// read can fail. Reuse the cancelable one-slot queue keyed by stream rather
// than wrapper client across both directions and complete verification.
// TERM-SSHD compatibility requires one file handle per channel. Per-file request
// concurrency is not enough: pooled SFTP aliases share that physical channel,
// and a second body or verification OPEN would fail even for tiny files.
// Reuse the cancelable one-slot queue, keyed by the stream rather than its
// wrapper client, across both directions and the complete verification phase.
async function withSequentialSftpTransferSlot(client, transfer, operation) {
if (isScpModeClient(client)) return operation();
await requireSftpChannel(client);
const channel = client.sftp;
if (transfer?._exclusiveSftpTransactionChannel === channel) {
return operation();
}
if (!requiresExclusiveSftpTransferChannel(client, channel)) {
if (getSftpTransferConcurrency(channel, DOWNLOAD_TRANSFER_CONCURRENCY) !== 1) {
return operation();
}
await acquireSessionFastDownloadSlot(channel, transfer);
@@ -6677,7 +6625,6 @@ async function startTransferNow(event, payload, onProgress) {
});
};
let exclusiveSftpUploadTransaction = null;
try {
// Explicit 0 is a valid empty-snapshot plan (e.g. download of an empty log
// that may grow later). Do not treat it as "size unknown" and re-stat into
@@ -6805,16 +6752,6 @@ async function startTransferNow(event, payload, onProgress) {
else await verify();
}
// A single-channel upload holds the raw SFTP stream from directory setup
// through stage verification, destination safety checks, promotion, and
// timestamp restoration so directory children cannot interleave requests.
if (sourceType === "local" && targetType === "sftp" && targetClient) {
exclusiveSftpUploadTransaction = await acquireExclusiveSftpTransferTransaction(
targetClient,
transfer,
);
}
sendProgress(transfer.checkpointBytes, fileSize, { force: true });
if (sourceType === 'local' && targetType === 'sftp') {
@@ -7443,13 +7380,6 @@ async function startTransferNow(event, payload, onProgress) {
return { transferId, totalBytes: fileSize, ...(transfer.warning ? { warning: transfer.warning } : {}) };
} catch (err) {
if (
exclusiveSftpUploadTransaction
&& err?.uncleanSftpChannel
&& exclusiveSftpUploadTransaction.client.sftp === exclusiveSftpUploadTransaction.channel
) {
abandonWedgedVerificationSftpChannel(exclusiveSftpUploadTransaction.client);
}
const failureMessage = err?.message || String(err);
if (
sourceType === "local"
@@ -7535,8 +7465,6 @@ async function startTransferNow(event, payload, onProgress) {
sendError(err);
}
return { transferId, error: err.message };
} finally {
exclusiveSftpUploadTransaction?.release();
}
}
@@ -233,7 +233,6 @@ async function createSequentialTransferFixture(t) {
return {
payload, files, sftp, handles, clients,
rejectedOpens: () => rejectedOpens,
openCount: () => opens,
async start(direction, sftpId = "first", options = {}) {
const transferId = options.transferId || crypto.randomUUID();
const localPath = tempDirBridge.getTempFilePath(`term-concurrent-${transferId}.bin`);
@@ -264,61 +263,6 @@ async function createSequentialTransferFixture(t) {
};
}
test("single-channel upload waits for the prior promotion before starting the next file", { timeout: 5_000 }, async (t) => {
const fixture = await createSequentialTransferFixture(t);
const originalLstat = fixture.sftp.lstat;
let lstatCalls = 0;
let promotionStarted;
const promotionStartedPromise = new Promise((resolve) => { promotionStarted = resolve; });
let releasePromotion;
const promotionGate = new Promise((resolve) => { releasePromotion = resolve; });
fixture.sftp.lstat = (remotePath, callback) => {
lstatCalls += 1;
// The first two calls inspect the recovery backup and initial destination.
// The third is the final safety check after the .part write; hold it to
// prove that the next file cannot acquire the channel yet.
if (lstatCalls === 3) {
promotionStarted();
void promotionGate.then(() => originalLstat(remotePath, callback));
return;
}
originalLstat(remotePath, callback);
};
const first = fixture.start("upload", "first");
await promotionStartedPromise;
const openCountAtPromotion = fixture.openCount();
const second = fixture.start("upload", "alias");
let firstResult;
let secondResult;
try {
// start() first writes its local source. Wait until the second upload
// actually queues, or incorrectly starts remote work, before asserting.
const deadline = Date.now() + 1_000;
while (
transferBridge._getSessionFastDownloadWaiterCountForTests(fixture.sftp) === 0
&& lstatCalls === 3
&& fixture.openCount() === openCountAtPromotion
&& Date.now() < deadline
) {
await new Promise((resolve) => setImmediate(resolve));
}
assert.equal(lstatCalls, 3, "the next file must not inspect its target during prior promotion");
assert.equal(fixture.openCount(), openCountAtPromotion, "the next file must not open a remote handle during prior promotion");
assert.equal(transferBridge._getSessionFastDownloadWaiterCountForTests(fixture.sftp), 1,
"the second upload must be waiting for the shared channel");
} finally {
releasePromotion();
[firstResult, secondResult] = await Promise.all([first, second]);
}
assert.equal(firstResult.result.error, undefined);
assert.equal(secondResult.result.error, undefined);
assert.deepEqual(fixture.files.get(firstResult.targetPath), fixture.payload);
assert.deepEqual(fixture.files.get(secondResult.targetPath), fixture.payload);
assert.equal(fixture.rejectedOpens(), 0);
});
for (const secondId of ["first", "alias"]) {
test(`TERM-SSHD small downloads serialize through body and verification (${secondId})`, async (t) => {
const fixture = await createSequentialTransferFixture(t);
+3 -3
View File
@@ -8,7 +8,7 @@ const {
execViaChannel,
execViaRawPty,
} = require("../bridges/ai/ptyExec.cjs");
const { getFreshIdlePrompt, getVisibleIdlePrompt, formatSyntheticEcho } = require("../bridges/ai/shellUtils.cjs");
const { getFreshIdlePrompt, formatSyntheticEcho } = require("../bridges/ai/shellUtils.cjs");
const {
ensureSessionShellKind,
remoteDisallowsExecChannelProbe, sessionDisallowsExtraSshChannel, ensureSessionShellKindForExec,
@@ -338,7 +338,7 @@ function createWorkerAiExecHandler({
chatSessionId,
expectedPrompt: getFreshIdlePrompt(session),
getReadyPrompt: typeof session._promptTrackTail === "string"
? () => getFreshIdlePrompt(session) || getVisibleIdlePrompt(session) : undefined,
? () => getFreshIdlePrompt(session) : undefined,
typedInput: true,
echoCommand: (rawCommand) => {
event?.sender?.send?.("netcatty:data", {
@@ -567,7 +567,7 @@ function createWorkerAiJobStartHandler({
chatSessionId,
expectedPrompt: getFreshIdlePrompt(session),
getReadyPrompt: typeof session._promptTrackTail === "string"
? () => getFreshIdlePrompt(session) || getVisibleIdlePrompt(session) : undefined,
? () => getFreshIdlePrompt(session) : undefined,
typedInput: true,
echoCommand: (rawCommand) => {
event?.sender?.send?.("netcatty:data", {
-16
View File
@@ -80,22 +80,6 @@ describe('queryLocalFonts deduplication', () => {
assert.ok(!names.includes('Microsoft YaHei UI'));
});
it('recognizes SFMono Nerd Font as a monospace terminal font (#3929)', async () => {
installMockWindow(async () => [
{ family: 'SFMono Nerd Font' },
{ family: 'SF Mono' },
{ family: 'Segoe UI' },
]);
const monoFonts = await getMonospaceFonts();
const names = monoFonts.map((f) => f.name);
// Glued typographic family (name ID 16) and the spaced family both belong.
assert.ok(names.includes('SFMono Nerd Font'));
assert.ok(names.includes('SF Mono'));
assert.ok(!names.includes('Segoe UI'));
});
it('a second sequential call also reuses the resolved promise (no second API call)', async () => {
let callCount = 0;
installMockWindow(async () => {
-3
View File
@@ -37,9 +37,6 @@ const KNOWN_MONOSPACE_FONTS = new Set([
'victor mono',
'ibm plex mono',
'sf mono',
// Nerd Font / patched builds often use the glued typographic family
// "SFMono Nerd Font" (name ID 16), which does not match "sf mono" (#3929).
'sfmono',
'operator mono',
'input mono',
'pragmata pro',
+3 -3
View File
@@ -1,14 +1,14 @@
{
version = "1.1.90";
version = "1.1.85";
sources = {
x86_64-linux = {
appImageArch = "x86_64";
hash = "sha256-oa/T3Zey0FHsH+SOt4C1H4CT6aZRJta+x4x/11EAOJE=";
hash = "sha256-tiFQ/IGprMO9rc6nB6JWCxetdgBmGD+Zn6wKHPR8v6I=";
};
aarch64-linux = {
appImageArch = "arm64";
hash = "sha256-r5hr3+gpIGrv9XJ7PQkHCdhOd2COkhELMHlnr3xIG7A=";
hash = "sha256-LK/evHTu4wEu9TR/ULN1YDCEYlaoqVNIZ0gEhYDAL/s=";
};
};
}
+84 -308
View File
@@ -1,8 +1,7 @@
diff --git a/node_modules/ssh2/lib/agent.js b/node_modules/ssh2/lib/agent.js
index bb495d1..32c48a0 100644
--- a/node_modules/ssh2/lib/agent.js
+++ b/node_modules/ssh2/lib/agent.js
@@ -6,7 +6,7 @@ const { resolve } = require('path');
@@ -6,7 +6,7 @@
const { readFile } = require('fs');
const { execFile, spawn } = require('child_process');
@@ -11,7 +10,7 @@ index bb495d1..32c48a0 100644
const {
makeBufferParser,
@@ -655,8 +655,12 @@ const AgentProtocol = (() => {
@@ -655,8 +655,12 @@
// TODO: verify signature type based on key and options used
// during initial sign request
binaryParser.init(signature, 0);
@@ -26,7 +25,7 @@ index bb495d1..32c48a0 100644
binaryParser.clear();
if (signature === undefined)
@@ -711,7 +715,10 @@ const AgentProtocol = (() => {
@@ -711,7 +715,10 @@
hash: undefined,
};
let ctx;
@@ -38,7 +37,7 @@ index bb495d1..32c48a0 100644
if (flagsVal & SSH_AGENT_RSA_SHA2_256) {
ctx = 'rsa-sha2-256';
flags.hash = 'sha256';
@@ -721,7 +728,7 @@ const AgentProtocol = (() => {
@@ -721,7 +728,7 @@
}
}
if (ctx === undefined)
@@ -47,7 +46,7 @@ index bb495d1..32c48a0 100644
const req = new AgentInboundRequest(msgType, ctx);
this[SYM_REQS].push(req);
@@ -732,6 +739,9 @@ const AgentProtocol = (() => {
@@ -732,6 +739,9 @@
default: {
const req = new AgentInboundRequest(msgType);
this[SYM_REQS].push(req);
@@ -57,7 +56,7 @@ index bb495d1..32c48a0 100644
this.failureReply(req);
}
}
@@ -781,7 +791,11 @@ const AgentProtocol = (() => {
@@ -781,7 +791,11 @@
if (pubKey instanceof Error)
throw new Error('Invalid public key argument');
@@ -70,7 +69,7 @@ index bb495d1..32c48a0 100644
switch (options.hash) {
case 'sha256':
flags = SSH_AGENT_RSA_SHA2_256;
@@ -992,19 +1006,24 @@ const AgentProtocol = (() => {
@@ -992,19 +1006,24 @@
let p = 0;
const sigFormat = req.getContext();
const sigFormatLen = Buffer.byteLength(sigFormat);
@@ -100,10 +99,9 @@ index bb495d1..32c48a0 100644
return respond(this, req, buf);
}
diff --git a/node_modules/ssh2/lib/client.js b/node_modules/ssh2/lib/client.js
index 7291c2c..8943c9a 100644
--- a/node_modules/ssh2/lib/client.js
+++ b/node_modules/ssh2/lib/client.js
@@ -2170,6 +2170,29 @@ function getKeyAlgos(client, key, serverSigAlgs) {
@@ -2170,6 +2170,29 @@
return [];
}
return [['ssh-rsa', 'sha1']];
@@ -134,10 +132,9 @@ index 7291c2c..8943c9a 100644
}
diff --git a/node_modules/ssh2/lib/protocol/Protocol.js b/node_modules/ssh2/lib/protocol/Protocol.js
index 7302488..df0c9b3 100644
--- a/node_modules/ssh2/lib/protocol/Protocol.js
+++ b/node_modules/ssh2/lib/protocol/Protocol.js
@@ -58,6 +58,7 @@ const {
@@ -58,6 +58,7 @@
} = require('./kex.js');
const {
parseKey,
@@ -145,7 +142,7 @@ index 7302488..df0c9b3 100644
} = require('./keyParser.js');
const MESSAGE_HANDLERS = require('./handlers.js');
const {
@@ -701,11 +702,19 @@ class Protocol {
@@ -701,11 +702,19 @@
if (signature === false)
throw new Error('Error while converting handshake signature');
@@ -166,7 +163,7 @@ index 7302488..df0c9b3 100644
);
// TODO: simply copy from original "packet" to new `packet` to avoid
@@ -729,13 +738,17 @@ class Protocol {
@@ -729,13 +738,17 @@
writeUInt32BE(packet, pubKeyLen, p += algoLen);
packet.set(pubKey, p += 4);
@@ -189,7 +186,7 @@ index 7302488..df0c9b3 100644
// Servers shouldn't send packet type 60 in response to signed publickey
// attempts, but if they do, interpret as type 60.
@@ -810,20 +823,32 @@ class Protocol {
@@ -810,20 +823,32 @@
if (!signature)
throw new Error('Error while converting handshake signature');
@@ -228,7 +225,7 @@ index 7302488..df0c9b3 100644
this._authsQueue.push('hostbased');
@@ -1916,7 +1941,10 @@ class Protocol {
@@ -1916,7 +1941,10 @@
}
// SSH-protoversion-softwareversion (SP comments) CR LF
@@ -241,10 +238,9 @@ index 7302488..df0c9b3 100644
// TODO: optimize this by starting n bytes from the end of this._buffer instead
// of the beginning
diff --git a/node_modules/ssh2/lib/protocol/SFTP.js b/node_modules/ssh2/lib/protocol/SFTP.js
index 9f33c02..9751164 100644
--- a/node_modules/ssh2/lib/protocol/SFTP.js
+++ b/node_modules/ssh2/lib/protocol/SFTP.js
@@ -117,6 +117,20 @@ const OPENSSH_MAX_PKT_LEN = 256 * 1024;
@@ -117,6 +117,20 @@
const bufferParser = makeBufferParser();
@@ -265,7 +261,7 @@ index 9f33c02..9751164 100644
const fakeStderr = {
readable: false,
writable: false,
@@ -155,6 +169,8 @@ class SFTP extends EventEmitter {
@@ -155,6 +169,8 @@
this._writeReqid = -1;
this._requests = {};
this._maxInPktLen = OPENSSH_MAX_PKT_LEN;
@@ -274,7 +270,7 @@ index 9f33c02..9751164 100644
this._maxOutPktLen = 34000;
this._maxReadLen =
(this._isOpenSSH ? OPENSSH_MAX_PKT_LEN : 34000) - PKT_RW_OVERHEAD;
@@ -196,6 +212,53 @@ class SFTP extends EventEmitter {
@@ -196,6 +212,53 @@
this.emit('end');
return;
}
@@ -328,7 +324,7 @@ index 9f33c02..9751164 100644
/*
uint32 length
byte type
@@ -339,7 +402,7 @@ class SFTP extends EventEmitter {
@@ -339,7 +402,7 @@
uint32 pflags
ATTRS attrs
*/
@@ -337,7 +333,7 @@ index 9f33c02..9751164 100644
let p = 9;
const buf = Buffer.allocUnsafe(4 + 1 + 4 + 4 + pathLen + 4 + 4 + attrsLen);
@@ -349,7 +412,7 @@ class SFTP extends EventEmitter {
@@ -349,7 +412,7 @@
writeUInt32BE(buf, reqid, 5);
writeUInt32BE(buf, pathLen, p);
@@ -346,7 +342,7 @@ index 9f33c02..9751164 100644
writeUInt32BE(buf, flags, p += pathLen);
writeUInt32BE(buf, attrsFlags, p += 4);
if (attrsLen) {
@@ -734,7 +797,7 @@ class SFTP extends EventEmitter {
@@ -734,7 +797,7 @@
uint32 id
string filename
*/
@@ -355,7 +351,7 @@ index 9f33c02..9751164 100644
let p = 9;
const buf = Buffer.allocUnsafe(4 + 1 + 4 + 4 + fnameLen);
@@ -744,7 +807,7 @@ class SFTP extends EventEmitter {
@@ -744,7 +807,7 @@
writeUInt32BE(buf, reqid, 5);
writeUInt32BE(buf, fnameLen, p);
@@ -364,7 +360,7 @@ index 9f33c02..9751164 100644
this._requests[reqid] = { cb };
@@ -762,8 +825,8 @@ class SFTP extends EventEmitter {
@@ -762,8 +825,8 @@
string oldpath
string newpath
*/
@@ -375,7 +371,7 @@ index 9f33c02..9751164 100644
let p = 9;
const buf = Buffer.allocUnsafe(4 + 1 + 4 + 4 + oldLen + 4 + newLen);
@@ -773,9 +836,9 @@ class SFTP extends EventEmitter {
@@ -773,9 +836,9 @@
writeUInt32BE(buf, reqid, 5);
writeUInt32BE(buf, oldLen, p);
@@ -387,7 +383,7 @@ index 9f33c02..9751164 100644
this._requests[reqid] = { cb };
@@ -806,7 +869,7 @@ class SFTP extends EventEmitter {
@@ -806,7 +869,7 @@
string path
ATTRS attrs
*/
@@ -396,7 +392,7 @@ index 9f33c02..9751164 100644
let p = 9;
const buf = Buffer.allocUnsafe(4 + 1 + 4 + 4 + pathLen + 4 + attrsLen);
@@ -816,7 +879,7 @@ class SFTP extends EventEmitter {
@@ -816,7 +879,7 @@
writeUInt32BE(buf, reqid, 5);
writeUInt32BE(buf, pathLen, p);
@@ -405,7 +401,7 @@ index 9f33c02..9751164 100644
writeUInt32BE(buf, flags, p += pathLen);
if (attrsLen) {
p += 4;
@@ -844,7 +907,7 @@ class SFTP extends EventEmitter {
@@ -844,7 +907,7 @@
uint32 id
string path
*/
@@ -414,7 +410,7 @@ index 9f33c02..9751164 100644
let p = 9;
const buf = Buffer.allocUnsafe(4 + 1 + 4 + 4 + pathLen);
@@ -854,7 +917,7 @@ class SFTP extends EventEmitter {
@@ -854,7 +917,7 @@
writeUInt32BE(buf, reqid, 5);
writeUInt32BE(buf, pathLen, p);
@@ -423,7 +419,7 @@ index 9f33c02..9751164 100644
this._requests[reqid] = { cb };
@@ -987,7 +1050,7 @@ class SFTP extends EventEmitter {
@@ -987,7 +1050,7 @@
uint32 id
string path
*/
@@ -432,7 +428,7 @@ index 9f33c02..9751164 100644
let p = 9;
const buf = Buffer.allocUnsafe(4 + 1 + 4 + 4 + pathLen);
@@ -997,7 +1060,7 @@ class SFTP extends EventEmitter {
@@ -997,7 +1060,7 @@
writeUInt32BE(buf, reqid, 5);
writeUInt32BE(buf, pathLen, p);
@@ -441,7 +437,7 @@ index 9f33c02..9751164 100644
this._requests[reqid] = { cb };
@@ -1014,7 +1077,7 @@ class SFTP extends EventEmitter {
@@ -1014,7 +1077,7 @@
uint32 id
string path
*/
@@ -450,7 +446,7 @@ index 9f33c02..9751164 100644
let p = 9;
const buf = Buffer.allocUnsafe(4 + 1 + 4 + 4 + pathLen);
@@ -1024,7 +1087,7 @@ class SFTP extends EventEmitter {
@@ -1024,7 +1087,7 @@
writeUInt32BE(buf, reqid, 5);
writeUInt32BE(buf, pathLen, p);
@@ -459,7 +455,7 @@ index 9f33c02..9751164 100644
this._requests[reqid] = { cb };
@@ -1041,7 +1104,7 @@ class SFTP extends EventEmitter {
@@ -1041,7 +1104,7 @@
uint32 id
string path
*/
@@ -468,7 +464,7 @@ index 9f33c02..9751164 100644
let p = 9;
const buf = Buffer.allocUnsafe(4 + 1 + 4 + 4 + pathLen);
@@ -1051,7 +1114,7 @@ class SFTP extends EventEmitter {
@@ -1051,7 +1114,7 @@
writeUInt32BE(buf, reqid, 5);
writeUInt32BE(buf, pathLen, p);
@@ -477,7 +473,7 @@ index 9f33c02..9751164 100644
this._requests[reqid] = { cb };
@@ -1080,7 +1143,7 @@ class SFTP extends EventEmitter {
@@ -1080,7 +1143,7 @@
string path
ATTRS attrs
*/
@@ -486,7 +482,7 @@ index 9f33c02..9751164 100644
let p = 9;
const buf = Buffer.allocUnsafe(4 + 1 + 4 + 4 + pathLen + 4 + attrsLen);
@@ -1090,7 +1153,7 @@ class SFTP extends EventEmitter {
@@ -1090,7 +1153,7 @@
writeUInt32BE(buf, reqid, 5);
writeUInt32BE(buf, pathLen, p);
@@ -495,7 +491,7 @@ index 9f33c02..9751164 100644
writeUInt32BE(buf, flags, p += pathLen);
if (attrsLen) {
p += 4;
@@ -1205,7 +1268,7 @@ class SFTP extends EventEmitter {
@@ -1205,7 +1268,7 @@
uint32 id
string path
*/
@@ -504,7 +500,7 @@ index 9f33c02..9751164 100644
let p = 9;
const buf = Buffer.allocUnsafe(4 + 1 + 4 + 4 + pathLen);
@@ -1215,7 +1278,7 @@ class SFTP extends EventEmitter {
@@ -1215,7 +1278,7 @@
writeUInt32BE(buf, reqid, 5);
writeUInt32BE(buf, pathLen, p);
@@ -513,7 +509,7 @@ index 9f33c02..9751164 100644
this._requests[reqid] = {
cb: (err, names) => {
@@ -1243,8 +1306,8 @@ class SFTP extends EventEmitter {
@@ -1243,8 +1306,8 @@
string linkpath
string targetpath
*/
@@ -524,7 +520,7 @@ index 9f33c02..9751164 100644
let p = 9;
const buf = Buffer.allocUnsafe(4 + 1 + 4 + 4 + linkLen + 4 + targetLen);
@@ -1256,14 +1319,14 @@ class SFTP extends EventEmitter {
@@ -1256,14 +1319,14 @@
if (this._isOpenSSH) {
// OpenSSH has linkpath and targetpath positions switched
writeUInt32BE(buf, targetLen, p);
@@ -543,7 +539,7 @@ index 9f33c02..9751164 100644
}
this._requests[reqid] = { cb };
@@ -1281,7 +1344,7 @@ class SFTP extends EventEmitter {
@@ -1281,7 +1344,7 @@
uint32 id
string path
*/
@@ -552,7 +548,7 @@ index 9f33c02..9751164 100644
let p = 9;
const buf = Buffer.allocUnsafe(4 + 1 + 4 + 4 + pathLen);
@@ -1291,7 +1354,7 @@ class SFTP extends EventEmitter {
@@ -1291,7 +1354,7 @@
writeUInt32BE(buf, reqid, 5);
writeUInt32BE(buf, pathLen, p);
@@ -561,7 +557,7 @@ index 9f33c02..9751164 100644
this._requests[reqid] = {
cb: (err, names) => {
@@ -1325,8 +1388,8 @@ class SFTP extends EventEmitter {
@@ -1325,8 +1388,8 @@
string oldpath
string newpath
*/
@@ -572,7 +568,7 @@ index 9f33c02..9751164 100644
let p = 9;
const buf =
Buffer.allocUnsafe(4 + 1 + 4 + 4 + 24 + 4 + oldLen + 4 + newLen);
@@ -1337,11 +1400,11 @@ class SFTP extends EventEmitter {
@@ -1337,11 +1400,11 @@
writeUInt32BE(buf, reqid, 5);
writeUInt32BE(buf, 24, p);
@@ -587,7 +583,7 @@ index 9f33c02..9751164 100644
this._requests[reqid] = { cb };
@@ -1364,7 +1427,7 @@ class SFTP extends EventEmitter {
@@ -1364,7 +1427,7 @@
string "statvfs@openssh.com"
string path
*/
@@ -596,7 +592,7 @@ index 9f33c02..9751164 100644
let p = 9;
const buf = Buffer.allocUnsafe(4 + 1 + 4 + 4 + 19 + 4 + pathLen);
@@ -1374,9 +1437,9 @@ class SFTP extends EventEmitter {
@@ -1374,9 +1437,9 @@
writeUInt32BE(buf, reqid, 5);
writeUInt32BE(buf, 19, p);
@@ -608,7 +604,7 @@ index 9f33c02..9751164 100644
this._requests[reqid] = { extended: 'statvfs@openssh.com', cb };
@@ -1411,7 +1474,7 @@ class SFTP extends EventEmitter {
@@ -1411,7 +1474,7 @@
writeUInt32BE(buf, reqid, 5);
writeUInt32BE(buf, 20, p);
@@ -617,7 +613,7 @@ index 9f33c02..9751164 100644
writeUInt32BE(buf, handleLen, p += 20);
buf.set(handle, p += 4);
@@ -1437,8 +1500,8 @@ class SFTP extends EventEmitter {
@@ -1437,8 +1500,8 @@
string oldpath
string newpath
*/
@@ -628,7 +624,7 @@ index 9f33c02..9751164 100644
let p = 9;
const buf =
Buffer.allocUnsafe(4 + 1 + 4 + 4 + 20 + 4 + oldLen + 4 + newLen);
@@ -1449,11 +1512,11 @@ class SFTP extends EventEmitter {
@@ -1449,11 +1512,11 @@
writeUInt32BE(buf, reqid, 5);
writeUInt32BE(buf, 20, p);
@@ -643,7 +639,7 @@ index 9f33c02..9751164 100644
this._requests[reqid] = { cb };
@@ -1488,7 +1551,7 @@ class SFTP extends EventEmitter {
@@ -1488,7 +1551,7 @@
writeUInt32BE(buf, reqid, 5);
writeUInt32BE(buf, 17, p);
@@ -652,7 +648,7 @@ index 9f33c02..9751164 100644
writeUInt32BE(buf, handleLen, p += 17);
buf.set(handle, p += 4);
@@ -1524,7 +1587,7 @@ class SFTP extends EventEmitter {
@@ -1524,7 +1587,7 @@
string path
ATTRS attrs
*/
@@ -661,7 +657,7 @@ index 9f33c02..9751164 100644
let p = 9;
const buf =
Buffer.allocUnsafe(4 + 1 + 4 + 4 + 20 + 4 + pathLen + 4 + attrsLen);
@@ -1535,10 +1598,10 @@ class SFTP extends EventEmitter {
@@ -1535,10 +1598,10 @@
writeUInt32BE(buf, reqid, 5);
writeUInt32BE(buf, 20, p);
@@ -674,7 +670,7 @@ index 9f33c02..9751164 100644
writeUInt32BE(buf, flags, p += pathLen);
if (attrsLen) {
@@ -1573,7 +1636,7 @@ class SFTP extends EventEmitter {
@@ -1573,7 +1636,7 @@
string "expand-path@openssh.com"
string path
*/
@@ -683,7 +679,7 @@ index 9f33c02..9751164 100644
let p = 9;
const buf = Buffer.allocUnsafe(4 + 1 + 4 + 4 + 23 + 4 + pathLen);
@@ -1583,10 +1646,10 @@ class SFTP extends EventEmitter {
@@ -1583,10 +1646,10 @@
writeUInt32BE(buf, reqid, 5);
writeUInt32BE(buf, 23, p);
@@ -696,7 +692,7 @@ index 9f33c02..9751164 100644
this._requests[reqid] = {
cb: (err, names) => {
@@ -1653,7 +1716,7 @@ class SFTP extends EventEmitter {
@@ -1653,7 +1716,7 @@
writeUInt32BE(buf, 9, p);
p += 4;
@@ -705,7 +701,7 @@ index 9f33c02..9751164 100644
p += 9;
writeUInt32BE(buf, srcHandle.length, p);
@@ -1708,7 +1771,7 @@ class SFTP extends EventEmitter {
@@ -1708,7 +1771,7 @@
string username
*/
let p = 0;
@@ -714,7 +710,7 @@ index 9f33c02..9751164 100644
const buf = Buffer.allocUnsafe(
4 + 1
+ 4
@@ -1728,12 +1791,12 @@ class SFTP extends EventEmitter {
@@ -1728,12 +1791,12 @@
writeUInt32BE(buf, 14, p);
p += 4;
@@ -729,7 +725,7 @@ index 9f33c02..9751164 100644
p += usernameLen;
this._requests[reqid] = {
@@ -1806,7 +1869,7 @@ class SFTP extends EventEmitter {
@@ -1806,7 +1869,7 @@
writeUInt32BE(buf, 30, p);
p += 4;
@@ -738,7 +734,7 @@ index 9f33c02..9751164 100644
p += 30;
writeUInt32BE(buf, 4 * uids.length, p);
@@ -1871,7 +1934,7 @@ class SFTP extends EventEmitter {
@@ -1871,7 +1934,7 @@
message || (message = '');
@@ -747,7 +743,7 @@ index 9f33c02..9751164 100644
let p = 9;
const buf = Buffer.allocUnsafe(4 + 1 + 4 + 4 + 4 + msgLen + 4);
@@ -1884,7 +1947,7 @@ class SFTP extends EventEmitter {
@@ -1884,7 +1947,7 @@
writeUInt32BE(buf, msgLen, p += 4);
p += 4;
if (msgLen) {
@@ -756,7 +752,7 @@ index 9f33c02..9751164 100644
p += msgLen;
}
@@ -1913,7 +1976,7 @@ class SFTP extends EventEmitter {
@@ -1913,7 +1976,7 @@
const dataLen = (
isBuffer
? data.length
@@ -765,7 +761,7 @@ index 9f33c02..9751164 100644
);
let p = 9;
const buf = Buffer.allocUnsafe(4 + 1 + 4 + 4 + dataLen);
@@ -1927,7 +1990,7 @@ class SFTP extends EventEmitter {
@@ -1927,7 +1990,7 @@
if (isBuffer)
buf.set(data, p += 4);
else if (isUTF8)
@@ -774,7 +770,7 @@ index 9f33c02..9751164 100644
else
buf.write(data, p += 4, dataLen, encoding);
}
@@ -1959,13 +2022,13 @@ class SFTP extends EventEmitter {
@@ -1959,13 +2022,13 @@
? ''
: name.filename
);
@@ -790,7 +786,7 @@ index 9f33c02..9751164 100644
if (typeof name.attrs === 'object' && name.attrs !== null) {
nameAttrs = attrsToBytes(name.attrs);
@@ -2011,11 +2074,11 @@ class SFTP extends EventEmitter {
@@ -2011,11 +2074,11 @@
? ''
: name.filename
);
@@ -804,7 +800,7 @@ index 9f33c02..9751164 100644
p += len;
}
}
@@ -2026,11 +2089,11 @@ class SFTP extends EventEmitter {
@@ -2026,11 +2089,11 @@
? ''
: name.longname
);
@@ -818,7 +814,7 @@ index 9f33c02..9751164 100644
p += len;
}
}
@@ -2749,7 +2812,7 @@ function requestLimits(sftp, cb) {
@@ -2749,7 +2812,7 @@
writeUInt32BE(buf, reqid, 5);
writeUInt32BE(buf, 18, p);
@@ -827,7 +823,7 @@ index 9f33c02..9751164 100644
sftp._requests[reqid] = { extended: 'limits@openssh.com', cb };
@@ -2953,18 +3016,28 @@ const CLIENT_HANDLERS = {
@@ -2953,18 +3016,28 @@
// spec not specifying an encoding because the specs for newer
// versions of the protocol all explicitly specify UTF-8 for
// filenames
@@ -860,41 +856,21 @@ index 9f33c02..9751164 100644
if (names !== undefined) {
sftp._debug && sftp._debug(
diff --git a/node_modules/ssh2/lib/protocol/constants.js b/node_modules/ssh2/lib/protocol/constants.js
index ad77592..a249e00 100644
--- a/node_modules/ssh2/lib/protocol/constants.js
+++ b/node_modules/ssh2/lib/protocol/constants.js
@@ -71,9 +71,30 @@ const DEFAULT_SERVER_HOST_KEY = [
];
if (eddsaSupported)
@@ -73,7 +73,11 @@
DEFAULT_SERVER_HOST_KEY.unshift('ssh-ed25519');
+// OpenSSH host certificates, offered before the matching plain key so a
+// cert-only server (for example ssh-ed25519-cert-v01@openssh.com) can
+// negotiate. Plain keys stay as fallbacks.
+const OPENSSH_HOST_CERT_ALGS = [
+ 'ecdsa-sha2-nistp256-cert-v01@openssh.com',
+ 'ecdsa-sha2-nistp384-cert-v01@openssh.com',
+ 'ecdsa-sha2-nistp521-cert-v01@openssh.com',
+ 'rsa-sha2-512-cert-v01@openssh.com',
+ 'rsa-sha2-256-cert-v01@openssh.com',
+ 'ssh-rsa-cert-v01@openssh.com',
+];
+if (eddsaSupported)
+ OPENSSH_HOST_CERT_ALGS.unshift('ssh-ed25519-cert-v01@openssh.com');
+DEFAULT_SERVER_HOST_KEY.unshift(...OPENSSH_HOST_CERT_ALGS);
const SUPPORTED_SERVER_HOST_KEY = DEFAULT_SERVER_HOST_KEY.concat([
'ssh-dss',
+ // FIDO/U2F security keys (client publickey auth / rare host keys).
+ 'sk-ecdsa-sha2-nistp256@openssh.com',
+ 'sk-ecdsa-sha2-nistp256-cert-v01@openssh.com',
]);
+if (eddsaSupported) {
+if (eddsaSupported)
+ SUPPORTED_SERVER_HOST_KEY.push('sk-ssh-ed25519@openssh.com');
+ SUPPORTED_SERVER_HOST_KEY.push('sk-ssh-ed25519-cert-v01@openssh.com');
+}
const canUseCipher = (() => {
@@ -160,6 +181,7 @@ const COMPAT = {
@@ -160,6 +164,7 @@
DYN_RPORT_BUG: 1 << 2,
BUG_DHGEX_LARGE: 1 << 3,
IMPLY_RSA_SHA2_SIGALGS: 1 << 4,
@@ -902,7 +878,7 @@ index ad77592..a249e00 100644
};
module.exports = {
@@ -330,6 +352,7 @@ module.exports = {
@@ -330,6 +335,7 @@
COMPAT_CHECKS: [
[ 'Cisco-1.25', COMPAT.BAD_DHGEX ],
[ /^Cisco-1[.]/, COMPAT.BUG_DHGEX_LARGE ],
@@ -911,198 +887,9 @@ index ad77592..a249e00 100644
[ /^OpenSSH_5[.][0-9]+/, COMPAT.DYN_RPORT_BUG ],
[ /^OpenSSH_7[.]4/, COMPAT.IMPLY_RSA_SHA2_SIGALGS ],
diff --git a/node_modules/ssh2/lib/protocol/kex.js b/node_modules/ssh2/lib/protocol/kex.js
index 811e631..422a198 100644
--- a/node_modules/ssh2/lib/protocol/kex.js
+++ b/node_modules/ssh2/lib/protocol/kex.js
@@ -30,7 +30,10 @@ const {
createDecipher,
MAC_INFO,
} = require('./crypto.js');
-const { parseDERKey } = require('./keyParser.js');
+const {
+ parseDERKey,
+ getOpenSSHCertSignatureAlgorithm,
+} = require('./keyParser.js');
const {
bufferFill,
bufferParser,
@@ -49,6 +52,68 @@ const {
let MESSAGE_HANDLERS;
+const LEGACY_RSA_HOST_CERT = /^ssh-rsa-cert-v0[01]@openssh\.com$/;
+const RSA_SHA2_256_HOST_CERT = /^rsa-sha2-256-cert-v0[01]@openssh\.com$/;
+const RSA_SHA2_512_HOST_CERT = /^rsa-sha2-512-cert-v0[01]@openssh\.com$/;
+const LEGACY_RSA_HOST_SIGNATURES = new Set([
+ 'rsa-sha2-256',
+ 'rsa-sha2-512',
+ 'ssh-rsa',
+]);
+
+// Client KEX accepts the negotiated host-key name or, for OpenSSH host
+// certificates, the underlying key algorithm. Real servers sign with the
+// embedded key (ssh-ed25519, ecdsa-sha2-*, rsa-sha2-*) rather than the
+// certificate algorithm name. OpenSSH allows any RSA signature only for the
+// legacy ssh-rsa certificate. rsa-sha2-256/512 certificates must use the
+// hash that was negotiated.
+function hostKeySignatureTypeAccepted(negotiatedServerHostKey, sigType) {
+ if (RSA_SHA2_256_HOST_CERT.test(negotiatedServerHostKey))
+ return sigType === 'rsa-sha2-256';
+ if (RSA_SHA2_512_HOST_CERT.test(negotiatedServerHostKey))
+ return sigType === 'rsa-sha2-512';
+ if (sigType === negotiatedServerHostKey)
+ return true;
+ if (sigType === getOpenSSHCertSignatureAlgorithm(negotiatedServerHostKey))
+ return true;
+ return LEGACY_RSA_HOST_CERT.test(negotiatedServerHostKey)
+ && LEGACY_RSA_HOST_SIGNATURES.has(sigType);
+}
+
+// Plain rsa-sha2-* keeps an ssh-rsa key blob. An RSA certificate negotiation
+// is KEY_RSA_CERT: the blob must be ssh-rsa-cert-v01@openssh.com, never a
+// plain ssh-rsa key.
+function hostKeyBlobTypeAccepted(negotiatedServerHostKey, hostPubKeyType) {
+ if (hostPubKeyType === negotiatedServerHostKey)
+ return true;
+ switch (negotiatedServerHostKey) {
+ case 'rsa-sha2-256':
+ case 'rsa-sha2-512':
+ return hostPubKeyType === 'ssh-rsa';
+ case 'rsa-sha2-256-cert-v01@openssh.com':
+ case 'rsa-sha2-512-cert-v01@openssh.com':
+ return hostPubKeyType === 'ssh-rsa-cert-v01@openssh.com';
+ default:
+ return false;
+ }
+}
+
+function hashAlgoForHostKey(negotiatedServerHostKey, sigType) {
+ // Negotiated RSA-SHA2 wins over the signature label so a mismatched
+ // signature cannot select a weaker hash.
+ if (negotiatedServerHostKey === 'rsa-sha2-256'
+ || RSA_SHA2_256_HOST_CERT.test(negotiatedServerHostKey))
+ return 'sha256';
+ if (negotiatedServerHostKey === 'rsa-sha2-512'
+ || RSA_SHA2_512_HOST_CERT.test(negotiatedServerHostKey))
+ return 'sha512';
+ if (sigType === 'rsa-sha2-256')
+ return 'sha256';
+ if (sigType === 'rsa-sha2-512')
+ return 'sha512';
+ return undefined;
+}
+
const GEX_MIN_BITS = 2048; // RFC 8270
const GEX_MAX_BITS = 8192; // RFC 8270
@@ -635,7 +700,7 @@ const createKeyExchange = (() => {
);
}
- if (sigType !== negotiated.serverHostKey) {
+ if (!hostKeySignatureTypeAccepted(negotiated.serverHostKey, sigType)) {
return doFatalError(
this._protocol,
`Wrong signature type: ${sigType}, `
@@ -659,7 +724,10 @@ const createKeyExchange = (() => {
);
}
- if (!(sigValue = sigSSHToASN1(sigValue, sigType))) {
+ if (!(sigValue = sigSSHToASN1(
+ sigValue,
+ getOpenSSHCertSignatureAlgorithm(sigType)
+ ))) {
return doFatalError(
this._protocol,
'Malformed signature',
@@ -685,12 +753,10 @@ const createKeyExchange = (() => {
}
}
- let hashAlgo;
- // Check if we need to override the default hash algorithm
- switch (this.negotiated.serverHostKey) {
- case 'rsa-sha2-256': hashAlgo = 'sha256'; break;
- case 'rsa-sha2-512': hashAlgo = 'sha512'; break;
- }
+ const hashAlgo = hashAlgoForHostKey(
+ this.negotiated.serverHostKey,
+ sigType
+ );
this._protocol._debug
&& this._protocol._debug('Verifying signature ...');
@@ -717,12 +783,7 @@ const createKeyExchange = (() => {
} else {
// Server
- let hashAlgo;
- // Check if we need to override the default hash algorithm
- switch (this.negotiated.serverHostKey) {
- case 'rsa-sha2-256': hashAlgo = 'sha256'; break;
- case 'rsa-sha2-512': hashAlgo = 'sha512'; break;
- }
+ const hashAlgo = hashAlgoForHostKey(this.negotiated.serverHostKey);
this._protocol._debug && this._protocol._debug(
'Generating signature ...'
@@ -739,7 +800,12 @@ const createKeyExchange = (() => {
);
}
- signature = convertSignature(signature, this._hostKey.type);
+ // OpenSSH signs a host certificate with the embedded key algorithm,
+ // not the certificate algorithm name.
+ const sigType = getOpenSSHCertSignatureAlgorithm(
+ this.negotiated.serverHostKey
+ );
+ signature = convertSignature(signature, sigType);
if (signature === false) {
return doFatalError(
this._protocol,
@@ -759,7 +825,6 @@ const createKeyExchange = (() => {
string <method-specific data>
string signature of H
*/
- const sigType = this.negotiated.serverHostKey;
const sigTypeLen = Buffer.byteLength(sigType);
const sigLen = 4 + sigTypeLen + 4 + signature.length;
let p = this._protocol._packetRW.write.allocStartKEX;
@@ -1167,22 +1232,16 @@ const createKeyExchange = (() => {
DISCONNECT_REASON.KEY_EXCHANGE_FAILED
);
}
- if (hostPubKeyType !== this.negotiated.serverHostKey) {
- // Check if we need to make an exception
- switch (this.negotiated.serverHostKey) {
- case 'rsa-sha2-256':
- case 'rsa-sha2-512':
- if (hostPubKeyType === 'ssh-rsa')
- break;
- // FALLTHROUGH
- default:
- return doFatalError(
- this._protocol,
- 'Host key does not match negotiated type',
- 'handshake',
- DISCONNECT_REASON.KEY_EXCHANGE_FAILED
- );
- }
+ if (!hostKeyBlobTypeAccepted(
+ this.negotiated.serverHostKey,
+ hostPubKeyType
+ )) {
+ return doFatalError(
+ this._protocol,
+ 'Host key does not match negotiated type',
+ 'handshake',
+ DISCONNECT_REASON.KEY_EXCHANGE_FAILED
+ );
}
this._hostKey = hostPubKey;
@@ -1377,8 +1436,13 @@ const createKeyExchange = (() => {
@@ -1377,8 +1377,13 @@
this._generator = null;
this._minBits = GEX_MIN_BITS;
this._prefBits = dhEstimate(this.negotiated);
@@ -1117,20 +904,10 @@ index 811e631..422a198 100644
this._maxBits = GEX_MAX_BITS;
}
start() {
@@ -1868,6 +1932,8 @@ function trySendNEWKEYS(kex) {
module.exports = {
KexInit,
+ hostKeyBlobTypeAccepted,
+ hostKeySignatureTypeAccepted,
kexinit,
onKEXPayload,
DEFAULT_KEXINIT_CLIENT: new KexInit({
diff --git a/node_modules/ssh2/lib/protocol/keyParser.js b/node_modules/ssh2/lib/protocol/keyParser.js
index a276c1a..c8ff9d1 100644
--- a/node_modules/ssh2/lib/protocol/keyParser.js
+++ b/node_modules/ssh2/lib/protocol/keyParser.js
@@ -35,6 +35,9 @@ const SYM_PRIV_PEM = Symbol('Private key PEM');
@@ -35,6 +35,9 @@
const SYM_PUB_PEM = Symbol('Public key PEM');
const SYM_PUB_SSH = Symbol('Public key SSH');
const SYM_DECRYPTED = Symbol('Decrypted Key');
@@ -1140,7 +917,7 @@ index a276c1a..c8ff9d1 100644
// Create OpenSSL cipher name -> SSH cipher name conversion table
const CIPHER_INFO_OPENSSL = Object.create(null);
@@ -258,6 +261,55 @@ function genOpenSSHEdPub(pub) {
@@ -258,6 +261,55 @@
return publicKey;
}
@@ -1196,7 +973,7 @@ index a276c1a..c8ff9d1 100644
function genOpenSSLEdPriv(priv) {
const asnWriter = new Ber.Writer();
asnWriter.startSequence();
@@ -451,6 +503,82 @@ const BaseKey = {
@@ -451,6 +503,82 @@
},
};
@@ -1258,7 +1035,7 @@ index a276c1a..c8ff9d1 100644
+ signedData.set(dataHash, p);
+
+ let toVerify = rawSig;
+ if (getOpenSSHCertBaseType(this.type) === 'sk-ecdsa-sha2-nistp256@openssh.com') {
+ if (this.type === 'sk-ecdsa-sha2-nistp256@openssh.com') {
+ // Convert SSH ECDSA r,s blob to ASN.1 for OpenSSL
+ const { sigSSHToASN1 } = require('./utils.js');
+ toVerify = sigSSHToASN1(rawSig, 'ecdsa-sha2-nistp256');
@@ -1279,7 +1056,7 @@ index a276c1a..c8ff9d1 100644
function OpenSSH_Private(type, comment, privPEM, pubPEM, pubSSH, algo,
decrypted) {
@@ -463,6 +591,26 @@ function OpenSSH_Private(type, comment, privPEM, pubPEM, pubSSH, algo,
@@ -463,6 +591,26 @@
this[SYM_DECRYPTED] = decrypted;
}
OpenSSH_Private.prototype = BaseKey;
@@ -1306,7 +1083,7 @@ index a276c1a..c8ff9d1 100644
{
const regexp = /^-----BEGIN OPENSSH PRIVATE KEY-----(?:\r\n|\n)([\s\S]+)(?:\r\n|\n)-----END OPENSSH PRIVATE KEY-----$/;
OpenSSH_Private.parse = (str, passphrase) => {
@@ -771,6 +919,87 @@ OpenSSH_Private.prototype = BaseKey;
@@ -771,6 +919,87 @@
privPEM = genOpenSSLECDSAPriv(oid, ecpub, ecpriv);
break;
}
@@ -1394,7 +1171,7 @@ index a276c1a..c8ff9d1 100644
default:
return new Error(`Unsupported OpenSSH private key type: ${type}`);
}
@@ -1186,31 +1415,97 @@ function OpenSSH_Public(type, comment, pubPEM, pubSSH, algo) {
@@ -1186,31 +1415,97 @@
this[SYM_DECRYPTED] = false;
}
OpenSSH_Public.prototype = BaseKey;
@@ -1504,7 +1281,7 @@ index a276c1a..c8ff9d1 100644
};
}
@@ -1309,7 +1604,7 @@ RFC4716_Public.prototype = BaseKey;
@@ -1309,7 +1604,7 @@
}
@@ -1513,7 +1290,7 @@ index a276c1a..c8ff9d1 100644
if (!isSupportedKeyType(baseType))
return new Error(`Unsupported OpenSSH public key type: ${baseType}`);
@@ -1317,6 +1612,10 @@ function parseDER(data, baseType, comment, fullType) {
@@ -1317,6 +1612,10 @@
let oid;
let pubPEM = null;
let pubSSH = null;
@@ -1524,7 +1301,7 @@ index a276c1a..c8ff9d1 100644
switch (baseType) {
case 'ssh-rsa': {
@@ -1383,11 +1682,66 @@ function parseDER(data, baseType, comment, fullType) {
@@ -1383,11 +1682,66 @@
pubSSH = genOpenSSHECDSAPub(oid, ecpub);
break;
}
@@ -1592,7 +1369,7 @@ index a276c1a..c8ff9d1 100644
}
function isSupportedKeyType(type) {
@@ -1397,8 +1751,10 @@ function isSupportedKeyType(type) {
@@ -1397,8 +1751,10 @@
case 'ecdsa-sha2-nistp256':
case 'ecdsa-sha2-nistp384':
case 'ecdsa-sha2-nistp521':
@@ -1603,7 +1380,7 @@ index a276c1a..c8ff9d1 100644
if (eddsaSupported)
return true;
// FALLTHROUGH
@@ -1460,7 +1816,13 @@ function parseKey(data, passphrase) {
@@ -1460,7 +1816,13 @@
if (type !== undefined) {
data = binaryKeyParser.readRaw();
if (data !== undefined) {
@@ -1618,12 +1395,11 @@ index a276c1a..c8ff9d1 100644
// Ignore potentially useless errors in case the data was not actually
// in the binary format
if (ret instanceof Error)
@@ -1479,6 +1841,7 @@ function parseKey(data, passphrase) {
@@ -1479,6 +1841,7 @@
module.exports = {
isParsedKey,
isSupportedKeyType,
- parseDERKey: (data, type) => parseDER(data, type, '', type),
+ getOpenSSHCertSignatureAlgorithm,
+ parseDERKey: (data, type) => parseDER(data, getOpenSSHCertBaseType(type), '', type),
parseDERKey: (data, type) => parseDER(data, type, '', type),
parseKey,
};
-27
View File
@@ -128,13 +128,6 @@ test("asarUnpack no longer references removed legacy agent packages", () => {
test("asarUnpack keeps MCP server runtime deps unpacked", () => {
// @modelcontextprotocol/sdk is now a direct dep and the MCP server hard-requires it.
assert.ok(config.asarUnpack.includes("node_modules/@modelcontextprotocol/sdk/**/*"));
// externalMcpRedaction.cjs requires re2js. The launcher runs from
// app.asar.unpacked with ELECTRON_RUN_AS_NODE, which cannot see modules
// that exist only inside app.asar (issue #3910).
assert.ok(
config.asarUnpack.includes("node_modules/re2js/**/*"),
"external MCP bridge must unpack re2js so the stdio launcher can resolve it",
);
});
test("asarUnpack keeps Cursor SDK runtime deps unpacked", () => {
@@ -345,26 +338,6 @@ test("windows packaging includes a zip archive target", () => {
);
});
test("windows installer kills leftover Netcatty.exe before file replacement", () => {
const fs = require("node:fs");
const path = require("node:path");
const installerScript = fs.readFileSync(
path.join(__dirname, "..", config.nsis.include),
"utf8",
);
assert.match(installerScript, /!macro customInit\b/);
assert.match(installerScript, /#3946/);
assert.match(installerScript, /ELECTRON_RUN_AS_NODE/);
assert.match(
installerScript,
/nsExec::ExecToLog "taskkill \/F \/IM Netcatty\.exe"/,
);
assert.match(installerScript, /Pop \$0/);
// /T would kill this installer while it is still a child of the UI Netcatty.exe.
assert.doesNotMatch(installerScript, /taskkill [^\r\n]*\/T/);
});
test("windows installer registers and removes Explorer folder context menu entries", () => {
const fs = require("node:fs");
const path = require("node:path");