mirror of
https://github.com/binaricat/Netcatty.git
synced 2026-11-06 22:40:09 +00:00
Compare commits
53
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2c73442a87 | ||
|
|
4913a043da | ||
|
|
ba88ea7e4f | ||
|
|
1deea15e0f | ||
|
|
d920d076d4 | ||
|
|
95606cb62c | ||
|
|
083ee55455 | ||
|
|
c4edefdbe4 | ||
|
|
b7325218fb | ||
|
|
428df79d87 | ||
|
|
074acc3bed | ||
|
|
282d1570cd | ||
|
|
4cacfac04c | ||
|
|
a2532dde35 | ||
|
|
d91724e9ce | ||
|
|
51a5f8b7aa | ||
|
|
3bcc01f134 | ||
|
|
63da7edd7d | ||
|
|
fea8444adc | ||
|
|
5083a81681 | ||
|
|
545178b201 | ||
|
|
c75feb0c1a | ||
|
|
69c26101cd | ||
|
|
9b0cbf0a4c | ||
|
|
f44e200874 | ||
|
|
2f701c1605 | ||
|
|
d7f12834df | ||
|
|
3f919955fa | ||
|
|
1fc22d4cad | ||
|
|
647970f697 | ||
|
|
2a52837657 | ||
|
|
d1912f539a | ||
|
|
7a0443d39a | ||
|
|
dd02279cd3 | ||
|
|
781b8d642d | ||
|
|
ff1f9efe5f | ||
|
|
19e89934c1 | ||
|
|
137deac897 | ||
|
|
f4ff04897e | ||
|
|
b6b433dc88 | ||
|
|
2136f43b32 | ||
|
|
dc7477804f | ||
|
|
6719e35909 | ||
|
|
be22711041 | ||
|
|
40d43e88ba | ||
|
|
0222736349 | ||
|
|
7333ee1d11 | ||
|
|
4291df8942 | ||
|
|
40be3b1582 | ||
|
|
586a86b781 | ||
|
|
275f752642 | ||
|
|
e66e4dd26e | ||
|
|
0b18e1b366 |
No files matched your search
@@ -3,6 +3,7 @@ import assert from 'node:assert/strict';
|
||||
|
||||
import en from '../locales/en.ts';
|
||||
import ru from '../locales/ru.ts';
|
||||
import es from '../locales/es.ts';
|
||||
import zhCN from '../locales/zh-CN.ts';
|
||||
import zhTW from '../locales/zh-TW.ts';
|
||||
|
||||
@@ -39,7 +40,7 @@ const keys = [
|
||||
] as const;
|
||||
|
||||
test('convergent sync copy exists in every bundled locale', () => {
|
||||
for (const [locale, messages] of Object.entries({ en, ru, zhCN, zhTW })) {
|
||||
for (const [locale, messages] of Object.entries({ en, ru, es, zhCN, zhTW })) {
|
||||
for (const key of keys) {
|
||||
assert.equal(typeof messages[key], 'string', `${locale} is missing ${key}`);
|
||||
assert.notEqual(messages[key], '', `${locale} has empty ${key}`);
|
||||
|
||||
@@ -3,6 +3,7 @@ import assert from "node:assert/strict";
|
||||
|
||||
import en from "../locales/en.ts";
|
||||
import ru from "../locales/ru.ts";
|
||||
import es from "../locales/es.ts";
|
||||
import zhCN from "../locales/zh-CN.ts";
|
||||
|
||||
const strategyKeys = [
|
||||
@@ -17,7 +18,7 @@ const strategyKeys = [
|
||||
] as const;
|
||||
|
||||
test("cloud sync strategy copy exists in every bundled locale", () => {
|
||||
for (const [locale, messages] of Object.entries({ en, ru, zhCN })) {
|
||||
for (const [locale, messages] of Object.entries({ en, ru, es, zhCN })) {
|
||||
for (const key of strategyKeys) {
|
||||
assert.equal(
|
||||
typeof messages[key],
|
||||
|
||||
@@ -3,6 +3,7 @@ import test from 'node:test';
|
||||
|
||||
import en from './en.ts';
|
||||
import ru from './ru.ts';
|
||||
import es from './es.ts';
|
||||
import zhCN from './zh-CN.ts';
|
||||
import zhTW from './zh-TW.ts';
|
||||
|
||||
@@ -19,7 +20,7 @@ const STEER_KEYS = [
|
||||
] as const;
|
||||
|
||||
test('Codex steering UI is localized in every supported locale', () => {
|
||||
for (const [name, messages] of Object.entries({ en, 'zh-CN': zhCN, 'zh-TW': zhTW, ru })) {
|
||||
for (const [name, messages] of Object.entries({ en, es, 'zh-CN': zhCN, 'zh-TW': zhTW, ru })) {
|
||||
const missing = STEER_KEYS.filter(key => !messages[key]);
|
||||
assert.deepEqual(missing, [], `${name} is missing Codex steering labels`);
|
||||
}
|
||||
|
||||
@@ -610,6 +610,12 @@ Highlight the focused split pane:
|
||||
'settings.terminal.behavior.dynamicTabTitle.off': 'Disabled',
|
||||
'settings.terminal.behavior.dynamicTabTitle.agent': 'Agents only',
|
||||
'settings.terminal.behavior.dynamicTabTitle.all': 'All sessions',
|
||||
'settings.terminal.behavior.oscNotifications': 'OSC desktop notifications',
|
||||
'settings.terminal.behavior.oscNotifications.desc':
|
||||
'Show a system notification when a program emits OSC 9, OSC 777 notify, or kitty OSC 99. Used by Codex and other agents for turn-complete and approval alerts.',
|
||||
'settings.terminal.behavior.oscNotifications.off': 'Disabled',
|
||||
'settings.terminal.behavior.oscNotifications.unfocused': 'When unfocused',
|
||||
'settings.terminal.behavior.oscNotifications.always': 'Always',
|
||||
'settings.terminal.behavior.osc52Clipboard': 'OSC-52 clipboard',
|
||||
'settings.terminal.behavior.osc52Clipboard.desc':
|
||||
'Allow remote programs (tmux, vim, etc.) to access the local clipboard via OSC-52 escape sequences.',
|
||||
|
||||
@@ -748,7 +748,7 @@ export const enTerminalMessages: Messages = {
|
||||
'snippets.shortkey.recording': 'Press a key combination...',
|
||||
'snippets.shortkey.hint': 'Press this shortcut in terminal to quickly send the command.',
|
||||
'snippets.shortkey.clear': 'Clear shortcut',
|
||||
'snippets.shortkey.error.systemConflict': 'This shortcut conflicts with a system shortcut',
|
||||
'snippets.shortkey.error.systemConflict': 'This shortcut conflicts with {name}',
|
||||
'snippets.shortkey.error.snippetConflict': 'This shortcut is already used by snippet: {name}',
|
||||
|
||||
'snippets.variables.dialogTitle': 'Snippet variables',
|
||||
|
||||
@@ -229,6 +229,10 @@ export const enVaultMessages: Messages = {
|
||||
'sftp.moveTo.confirm': 'Move',
|
||||
'sftp.moveTo.pathNotFound': 'Directory not found or inaccessible',
|
||||
'sftp.context.download': 'Download',
|
||||
'sftp.context.extract': 'Extract here',
|
||||
'sftp.extract.extracting': 'Extracting {fileName}...',
|
||||
'sftp.extract.success': 'Extracted {fileName}',
|
||||
'sftp.extract.error': 'Failed to extract {fileName}: {error}',
|
||||
'sftp.context.copyToOtherPane': 'Copy to other pane',
|
||||
'sftp.copyToOtherPane.unavailable': 'Open the two-pane SFTP view and connect the destination pane before copying files.',
|
||||
'sftp.copyCurrentPath': 'Copy current path',
|
||||
@@ -489,7 +493,7 @@ export const enVaultMessages: Messages = {
|
||||
'settings.sftp.doubleClickBehavior.transferDesc': 'Transfer the file to the other pane\'s active host',
|
||||
'settings.sshDeepLink.title': 'SSH and Telnet links',
|
||||
'settings.sshDeepLink.enable': 'Open ssh:// and telnet:// links with Netcatty',
|
||||
'settings.sshDeepLink.enableDesc': 'Allow Netcatty to handle ssh:// and telnet:// links from browsers and other apps.',
|
||||
'settings.sshDeepLink.enableDesc': 'Allow Netcatty to handle ssh:// and telnet:// links from browsers and other apps, plus PuTTY-style launches such as -ssh user@host -P 22 -pw password.',
|
||||
'settings.explorerContextMenu.title': 'Windows Explorer',
|
||||
'settings.explorerContextMenu.enable': 'Show “Open in Netcatty” in folder context menus',
|
||||
'settings.explorerContextMenu.enableDesc': 'Adds a right-click item for folders and folder backgrounds so you can open a local terminal there. Applies immediately; no reinstall needed.',
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
import type { Messages } from './types';
|
||||
import { esCoreMessages } from './es/core';
|
||||
import { esVaultMessages } from './es/vault';
|
||||
import { esTerminalMessages } from './es/terminal';
|
||||
import { esAiMessages } from './es/ai';
|
||||
import { esSystemManagerMessages } from './es/systemManager';
|
||||
import { esScriptsMessages } from './es/scripts';
|
||||
|
||||
export type { Messages } from './types';
|
||||
|
||||
const es: Messages = {
|
||||
...esCoreMessages,
|
||||
...esVaultMessages,
|
||||
...esTerminalMessages,
|
||||
...esAiMessages,
|
||||
...esSystemManagerMessages,
|
||||
...esScriptsMessages,
|
||||
};
|
||||
|
||||
export default es;
|
||||
@@ -0,0 +1,638 @@
|
||||
import type { Messages } from '../types';
|
||||
|
||||
export const esAiMessages: Messages = {
|
||||
// AI Settings
|
||||
'ai.agentSettings': 'Configuración del agente',
|
||||
'ai.chat.preparing': 'Preparando…',
|
||||
'ai.chat.compactingContext': 'Compactando el contexto anterior…',
|
||||
'ai.chat.compactingStep': 'Recortando el contexto para el siguiente paso…',
|
||||
'ai.chat.compactionRetry': 'La petición era demasiado grande. Compactando el contexto y reintentando…',
|
||||
'ai.chat.compactionBanner': 'Contexto compactado: {before}K → {after}K tokens',
|
||||
'ai.chat.contextUsage': 'Uso de contexto: {used} / {max} tokens',
|
||||
'ai.chat.activity.title': 'Actividad del agente',
|
||||
'ai.chat.activity.plan': 'Plan',
|
||||
'ai.chat.activity.webSearch': 'Búsqueda web',
|
||||
'ai.chat.activity.fileChanges': 'Cambios de archivos',
|
||||
'ai.chat.activity.status.running': 'En ejecución',
|
||||
'ai.chat.activity.status.completed': 'Completado',
|
||||
'ai.chat.activity.status.failed': 'Falló',
|
||||
'ai.chat.activity.file.add': 'Agregar',
|
||||
'ai.chat.activity.file.update': 'Actualizar',
|
||||
'ai.chat.activity.file.delete': 'Eliminar',
|
||||
'ai.chat.activity.usage': 'Tokens',
|
||||
'ai.chat.activity.usage.input': 'entrada',
|
||||
'ai.chat.activity.usage.output': 'salida',
|
||||
'ai.chat.activity.usage.cached': 'en caché',
|
||||
'ai.chat.activity.usage.reasoning': 'razonamiento',
|
||||
'ai.title': 'IA',
|
||||
'ai.description': 'Configura proveedores de IA, agentes y ajustes de seguridad',
|
||||
'ai.providers': 'Proveedores',
|
||||
'ai.agents': 'Agentes',
|
||||
'ai.providers.empty': 'No hay proveedores configurados. Agrega un proveedor para comenzar.',
|
||||
'ai.providers.add': 'Agregar proveedor',
|
||||
'ai.providers.active': 'Activo',
|
||||
'ai.providers.apiKeyConfigured': 'Clave de API configurada',
|
||||
'ai.providers.noApiKey': 'Sin clave de API',
|
||||
'ai.providers.configure': 'Configurar',
|
||||
'ai.providers.remove': 'Quitar',
|
||||
'ai.providers.name': 'Nombre para mostrar',
|
||||
'ai.providers.name.placeholder': 'p. ej. Mi proveedor',
|
||||
'ai.providers.style': 'Estilo de protocolo',
|
||||
'ai.providers.style.anthropic': 'Compatible con Anthropic',
|
||||
'ai.providers.style.openai': 'Compatible con OpenAI',
|
||||
'ai.providers.style.google': 'Compatible con Google',
|
||||
'ai.providers.style.inherited': 'auto',
|
||||
'ai.providers.style.help': 'Selecciona qué formato de API usan las peticiones. Anúlalo cuando un endpoint de terceros hable un dialecto distinto al que sugiere su tipo de proveedor.',
|
||||
'ai.providers.icon.change': 'Cambiar ícono',
|
||||
'ai.providers.icon.upload': 'Subir imagen',
|
||||
'ai.providers.icon.reset': 'Restablecer',
|
||||
'ai.providers.icon.close': 'Cerrar',
|
||||
'ai.providers.icon.uploadedNote': 'Ícono personalizado (64×64 WebP)',
|
||||
'ai.providers.icon.errorType': 'Elige un archivo de imagen.',
|
||||
'ai.providers.apiKey': 'Clave de API',
|
||||
'ai.providers.apiKey.placeholder': 'Ingresa la clave de API',
|
||||
'ai.providers.apiKey.decrypting': 'Descifrando...',
|
||||
'ai.providers.baseUrl': 'URL base',
|
||||
'ai.providers.baseUrl.anthropicHelp': 'Compatible con Anthropic: host con o sin /v1 (por ejemplo https://gateway.example o https://gateway.example/v1). La detección y el chat usan /v1/models y /v1/messages.',
|
||||
'ai.providers.skipTLSVerify': 'Omitir la verificación del certificado TLS (para certificados autofirmados)',
|
||||
'ai.providers.defaultModel': 'Modelo predeterminado',
|
||||
'ai.providers.defaultModel.placeholder': 'p. ej. gpt-4o, claude-sonnet-4-20250514',
|
||||
'ai.providers.contextWindow': 'Ventana de contexto',
|
||||
'ai.providers.contextWindow.placeholder': 'p. ej. 128000',
|
||||
'ai.providers.contextWindow.help': 'Déjalo en blanco para usar el valor de la lista de modelos cuando esté disponible; de lo contrario, Netcatty usa un valor predeterminado seguro.',
|
||||
'ai.providers.contextWindow.error': 'Ingresa un número entero positivo o déjalo en blanco.',
|
||||
'ai.providers.refreshModels': 'Actualizar modelos',
|
||||
'ai.providers.test': 'Probar',
|
||||
'ai.providers.test.testing': 'Probando…',
|
||||
'ai.providers.test.ok': 'Conectado ({latency} ms)',
|
||||
'ai.providers.test.warn': 'Se alcanzó el endpoint, pero la respuesta parece incompleta ({latency} ms)',
|
||||
'ai.providers.test.warnSlow': 'Conectado, pero lento ({latency} ms)',
|
||||
'ai.providers.test.error': 'Falló ({detail})',
|
||||
'ai.providers.test.missingBaseUrl': 'Primero ingresa una URL base',
|
||||
'ai.providers.test.missingApiKey': 'Primero ingresa una clave de API',
|
||||
'ai.providers.test.unavailable': 'La prueba de conexión no está disponible en este entorno',
|
||||
'ai.providers.searchModel': 'Busca o escribe el ID del modelo...',
|
||||
'ai.providers.filterModels': 'Filtrar modelos...',
|
||||
'ai.providers.loadingModels': 'Cargando modelos...',
|
||||
'ai.providers.noMatchingModels': 'No hay modelos que coincidan',
|
||||
'ai.providers.clickToLoadModels': 'Haz clic para cargar los modelos',
|
||||
'ai.providers.showingModels': 'Mostrando los primeros 100 de {count} modelos. Escribe para filtrar.',
|
||||
'ai.providers.advancedParams': 'Parámetros avanzados',
|
||||
'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 Codex
|
||||
'ai.codex': 'Codex',
|
||||
'ai.codex.title': 'Codex CLI',
|
||||
'ai.codex.description': 'Conecta OpenAI Codex. Inicia sesión con ChatGPT aquí, o habilita una clave de API de un proveedor compatible con OpenAI y un endpoint personalizado en Configuración.',
|
||||
'ai.codex.appServer.title': 'Usar Codex App Server',
|
||||
'ai.codex.appServer.experimental': 'Experimental',
|
||||
'ai.codex.appServer.description': 'Usa el protocolo persistente de Codex para aprobaciones nativas, controles de sandbox, modelos en vivo y preguntas a mitad de turno. El SDK sigue siendo el predeterminado.',
|
||||
'ai.codex.appServer.checking': 'Comprobando la compatibilidad con App Server…',
|
||||
'ai.codex.appServer.available': 'App Server está disponible para esta Codex CLI.',
|
||||
'ai.codex.appServer.modelCatalogWarning': 'El catálogo de modelos de Codex en vivo no está disponible. Se usa la lista de modelos integrada.',
|
||||
'ai.codex.appServer.approval.allowSession': 'Permitir por sesión',
|
||||
'ai.codex.appServer.userInput.title': 'Codex necesita tu aporte',
|
||||
'ai.codex.appServer.userInput.description': 'Responde estas preguntas para continuar el turno actual.',
|
||||
'ai.codex.appServer.userInput.other': 'Ingresa otra respuesta',
|
||||
'ai.codex.appServer.userInput.autoResolve': 'Codex continuará automáticamente si no se proporciona una respuesta a tiempo.',
|
||||
'ai.codex.appServer.userInput.skip': 'Omitir',
|
||||
'ai.codex.appServer.userInput.submit': 'Continuar',
|
||||
'ai.codex.steer.addInstruction': 'Agregar instrucción',
|
||||
'ai.codex.steer.sending': 'Agregando instrucción…',
|
||||
'ai.codex.steer.placeholder': 'Agrega una instrucción mientras Codex está trabajando…',
|
||||
'ai.codex.steer.notSteerableReview': 'Este turno de revisión de Codex no puede aceptar instrucciones adicionales. Tu borrador se conservó.',
|
||||
'ai.codex.steer.notSteerableCompact': 'Este turno de compactación de Codex no puede aceptar instrucciones adicionales. Tu borrador se conservó.',
|
||||
'ai.codex.steer.busy': 'Ya se está enviando otra instrucción a Codex.',
|
||||
'ai.codex.steer.inactive': 'El turno de Codex ya terminó. Tu borrador se conservó.',
|
||||
'ai.codex.steer.unsupported': 'Las instrucciones en curso requieren el runtime de Codex App Server.',
|
||||
'ai.codex.steer.failed': 'Codex no pudo aceptar la instrucción adicional. Tu borrador se conservó.',
|
||||
'ai.codex.detecting': 'Detectando...',
|
||||
'ai.codex.notFound': 'No encontrado',
|
||||
'ai.codex.awaitingLogin': 'Esperando inicio de sesión',
|
||||
'ai.codex.connectedChatGPT': 'Conectado mediante ChatGPT',
|
||||
'ai.codex.connectedApiKey': 'Conectado mediante clave de API',
|
||||
'ai.codex.connectedCustomConfig': 'Conectado mediante ~/.codex/config.toml',
|
||||
'ai.codex.customConfigIncomplete': 'Configuración personalizada detectada (falta la variable de entorno)',
|
||||
'ai.codex.customConfigHint': 'Usando el proveedor personalizado "{provider}" configurado en ~/.codex/config.toml: no se necesita inicio de sesión con ChatGPT.',
|
||||
'ai.codex.customConfigMissingEnvKey': 'Advertencia: {envKey} no está definida en tu entorno de shell. Expórtala (o inicia netcatty desde un shell que la tenga) para que Codex pueda autenticarse.',
|
||||
'ai.codex.notConnected': 'No conectado',
|
||||
'ai.codex.statusUnknown': 'Estado desconocido',
|
||||
'ai.codex.path': 'Ruta:',
|
||||
'ai.codex.notFoundHint': 'No se pudo encontrar codex en el PATH. Instálalo o especifica la ruta del ejecutable a continuación.',
|
||||
'ai.codex.customPathPlaceholder': 'p. ej. /usr/local/bin/codex',
|
||||
'ai.codex.check': 'Verificar',
|
||||
'ai.codex.resetPath': 'Restablecer',
|
||||
'ai.codex.openLogin': 'Abrir inicio de sesión',
|
||||
'ai.codex.logout': 'Cerrar sesión',
|
||||
'ai.codex.connectChatGPT': 'Conectar ChatGPT',
|
||||
'ai.codex.refreshStatus': 'Actualizar estado',
|
||||
|
||||
// AI Claude Code
|
||||
'ai.claude.title': 'Claude Code',
|
||||
'ai.claude.description': "El asistente de codificación agéntica de Anthropic. Requiere la CLI de Claude Code del sistema.",
|
||||
'ai.claude.detecting': 'Detectando...',
|
||||
'ai.claude.detected': 'Detectado',
|
||||
'ai.claude.notFound': 'No encontrado',
|
||||
'ai.claude.path': 'Ruta:',
|
||||
'ai.claude.notFoundHint': 'No se pudo encontrar claude en el PATH. Instálalo o especifica la ruta del ejecutable a continuación.',
|
||||
'ai.claude.customPathPlaceholder': 'p. ej. /usr/local/bin/claude',
|
||||
'ai.claude.configSection': 'Autenticación y configuración (opcional)',
|
||||
'ai.claude.configDir': 'Directorio de configuración',
|
||||
'ai.claude.configDir.placeholder': '~/.claude (déjalo en blanco para usar el predeterminado)',
|
||||
'ai.claude.configDir.hint': 'Define CLAUDE_CONFIG_DIR: apunta a una carpeta donde hayas ejecutado el inicio de sesión `claude` (contiene settings.json + credenciales).',
|
||||
'ai.claude.settings': 'Archivo de configuración',
|
||||
'ai.claude.settings.placeholder': '~/team-settings.json (ruta o JSON en línea {"model":"..."})',
|
||||
'ai.claude.settings.hint': 'Opcional. Una ruta a settings.json o un JSON en línea, pasado al SDK como `settings`. Se suma a — y es independiente de — el directorio de configuración anterior (se combina por encima, no lo reemplaza).',
|
||||
'ai.claude.envVars': 'Variables de entorno',
|
||||
'ai.claude.envVars.placeholder': 'ANTHROPIC_BASE_URL=https://...\nANTHROPIC_MODEL=...',
|
||||
'ai.claude.envVars.hint': 'Una KEY=VALUE por línea, pasada al agente de Claude. Se almacena localmente en texto plano; para claves de API o credenciales, prefiere el directorio de configuración anterior (un inicio de sesión `claude`).',
|
||||
'ai.claude.check': 'Verificar',
|
||||
'ai.claude.resetPath': 'Restablecer',
|
||||
|
||||
// AI GitHub Copilot CLI
|
||||
'ai.copilot.title': 'GitHub Copilot CLI',
|
||||
'ai.copilot.description': 'Usa la CLI de GitHub Copilot. Una vez detectada, se puede seleccionar como agente de codificación externo.',
|
||||
'ai.copilot.detecting': 'Detectando...',
|
||||
'ai.copilot.detected': 'Detectado',
|
||||
'ai.copilot.notFound': 'No encontrado',
|
||||
'ai.copilot.path': 'Ruta:',
|
||||
'ai.copilot.notFoundHint': 'No se pudo encontrar copilot en el PATH. Instálalo o especifica la ruta del ejecutable a continuación.',
|
||||
'ai.copilot.customPathPlaceholder': 'p. ej. /usr/local/bin/copilot',
|
||||
'ai.copilot.check': 'Verificar',
|
||||
'ai.copilot.resetPath': 'Restablecer',
|
||||
|
||||
// AI Cursor SDK
|
||||
'ai.cursor.title': 'Cursor',
|
||||
'ai.cursor.description': 'Usa el SDK de Cursor o el inicio de sesión local de la CLI de Agent.',
|
||||
'ai.cursor.detecting': 'Detectando...',
|
||||
'ai.cursor.detected': 'Disponible',
|
||||
'ai.cursor.notFound': 'No disponible',
|
||||
'ai.cursor.path': 'Runtime:',
|
||||
'ai.cursor.notFoundHint': 'Ingresa una clave de API para habilitar Cursor o cambia al inicio de sesión por CLI.',
|
||||
'ai.cursor.notInstalledHint': 'No se detectó el SDK de Cursor ni la CLI de Agent.',
|
||||
'ai.cursor.installStatus': 'Runtime de Cursor',
|
||||
'ai.cursor.installed': 'Detectado',
|
||||
'ai.cursor.notInstalled': 'No detectado',
|
||||
'ai.cursor.modeCli': 'Inicio de sesión por CLI',
|
||||
'ai.cursor.modeApiKey': 'Clave de API',
|
||||
'ai.cursor.modeCliHint': 'Usa tu sesión local de `cursor-agent login` y la cuota Automática de la suscripción. La clave de API guardada se conserva, pero no se usa en este modo.',
|
||||
'ai.cursor.modeApiKeyHint': 'Usa la API de Cursor medida. El inicio de sesión por CLI se ignora mientras este modo esté activo.',
|
||||
'ai.cursor.cliLoginStatus': 'Inicio de sesión por CLI',
|
||||
'ai.cursor.cliLoginOk': 'Sesión iniciada',
|
||||
'ai.cursor.cliLoginAs': 'Sesión iniciada como {{email}}',
|
||||
'ai.cursor.cliLoginMissing': 'Sin sesión iniciada',
|
||||
'ai.cursor.cliLoginHint': 'Ejecuta `cursor-agent login` en una terminal y luego haz clic en Verificar.',
|
||||
'ai.cursor.apiKeyStatus': 'Clave de API',
|
||||
'ai.cursor.apiKeyConfigured': 'Configurada',
|
||||
'ai.cursor.apiKeyMissing': 'Faltante',
|
||||
'ai.cursor.apiKeyFromEnv': 'Desde el entorno',
|
||||
'ai.cursor.apiKey': 'Clave de API',
|
||||
'ai.cursor.apiKeyPlaceholder': 'Ingresa la clave de API de Cursor',
|
||||
'ai.cursor.apiKeyPlaceholder.env': 'Usando CURSOR_API_KEY; ingresa una clave para anularla',
|
||||
'ai.cursor.apiKeyEnvHint': 'Cursor puede usar CURSOR_API_KEY de tu shell. Guarda una clave aquí solo si quieres que Netcatty la anule.',
|
||||
'ai.cursor.apiKeyOverrideHint': 'Netcatty usará la clave guardada aquí antes que CURSOR_API_KEY.',
|
||||
'ai.cursor.saveApiKey': 'Guardar',
|
||||
'ai.cursor.saved': 'Guardada',
|
||||
'ai.cursor.showApiKey': 'Mostrar clave de API',
|
||||
'ai.cursor.hideApiKey': 'Ocultar clave de API',
|
||||
'ai.cursor.customPathPlaceholder': 'p. ej. /usr/local/bin/cursor',
|
||||
'ai.cursor.check': 'Verificar',
|
||||
|
||||
// AI CodeBuddy Code
|
||||
'ai.codebuddy.title': 'CodeBuddy Code',
|
||||
'ai.codebuddy.description': 'Usa CodeBuddy Code mediante el SDK oficial de Agent (`@tencent-ai/agent-sdk`). Una vez detectado, se puede seleccionar como agente de codificación externo.',
|
||||
'ai.codebuddy.detecting': 'Detectando...',
|
||||
'ai.codebuddy.detected': 'Detectado',
|
||||
'ai.codebuddy.notFound': 'No encontrado',
|
||||
'ai.codebuddy.path': 'Ruta:',
|
||||
'ai.codebuddy.notFoundHint': 'No se pudo encontrar codebuddy en el PATH. Instálalo o especifica la ruta del ejecutable a continuación.',
|
||||
'ai.codebuddy.customPathPlaceholder': 'p. ej. /usr/local/bin/codebuddy',
|
||||
'ai.codebuddy.check': 'Verificar',
|
||||
'ai.codebuddy.resetPath': 'Restablecer',
|
||||
'ai.codebuddy.configSection': 'Autenticación y configuración (opcional)',
|
||||
'ai.codebuddy.internetEnv': 'Entorno de Internet',
|
||||
'ai.codebuddy.internetEnv.default': 'Predeterminado (internacional)',
|
||||
'ai.codebuddy.internetEnv.internal': 'Interno',
|
||||
'ai.codebuddy.internetEnv.ioa': 'IOA',
|
||||
'ai.codebuddy.internetEnv.hint': 'Define CODEBUDDY_INTERNET_ENVIRONMENT: elige Interno o IOA para entornos de red restringidos.',
|
||||
'ai.codebuddy.envVars': 'Variables de entorno',
|
||||
'ai.codebuddy.envVars.placeholder': 'CODEBUDDY_API_KEY=...\nCODEBUDDY_AUTH_TOKEN=...\nOTHER_VAR=...',
|
||||
'ai.codebuddy.envVars.hint': 'Una KEY=VALUE por línea, pasada al agente de CodeBuddy. Define CODEBUDDY_API_KEY o CODEBUDDY_AUTH_TOKEN aquí para la autenticación. Se almacena localmente en texto plano.',
|
||||
'ai.codebuddy.advancedSection': 'Opciones avanzadas (SDK 0.3.230)',
|
||||
'ai.codebuddy.effort': 'Esfuerzo de razonamiento',
|
||||
'ai.codebuddy.effort.default': 'Predeterminado',
|
||||
'ai.codebuddy.effort.low': 'Bajo',
|
||||
'ai.codebuddy.effort.medium': 'Medio',
|
||||
'ai.codebuddy.effort.high': 'Alto',
|
||||
'ai.codebuddy.effort.xhigh': 'XHigh',
|
||||
'ai.codebuddy.effort.hint': 'Controla la profundidad de razonamiento del modelo. Usa Bajo para comandos simples y ahorrar tokens, y Alto/XHigh para diagnósticos complejos.',
|
||||
'ai.codebuddy.maxTurns': 'Máximo de turnos',
|
||||
'ai.codebuddy.maxTurns.hint': 'Limita el máximo de turnos de conversación por petición para evitar bucles descontrolados. Déjalo vacío para usar el predeterminado.',
|
||||
'ai.codebuddy.maxBudget': 'Presupuesto máximo (USD)',
|
||||
'ai.codebuddy.maxBudget.hint': 'Gasto máximo (USD) por petición. Se detiene automáticamente al superarse. Déjalo vacío para no tener límite.',
|
||||
'ai.codebuddy.sandbox': 'Modo sandbox',
|
||||
'ai.codebuddy.sandbox.hint': 'Ejecuta llamadas a herramientas en un sandbox, restringiendo el acceso al sistema de archivos y a la red.',
|
||||
'ai.codebuddy.fileCheckpointing': 'Puntos de control de archivos',
|
||||
'ai.codebuddy.fileCheckpointing.hint': 'Habilita puntos de control de operaciones de archivos para poder revertir las modificaciones de IA a los archivos.',
|
||||
'ai.codebuddy.elicitation.title': 'CodeBuddy necesita tu aporte',
|
||||
'ai.codebuddy.elicitation.description': 'Revisa la petición para continuar el turno actual.',
|
||||
'ai.codebuddy.elicitation.select': 'Selecciona una opción',
|
||||
'ai.codebuddy.elicitation.yes': 'Sí',
|
||||
'ai.codebuddy.elicitation.no': 'No',
|
||||
'ai.codebuddy.elicitation.decline': 'Rechazar',
|
||||
'ai.codebuddy.elicitation.accept': 'Continuar',
|
||||
'ai.codebuddy.elicitation.validation.required': '{field} es obligatorio.',
|
||||
'ai.codebuddy.elicitation.validation.invalidType': '{field} tiene un valor no válido.',
|
||||
'ai.codebuddy.elicitation.validation.integer': '{field} debe ser un número entero.',
|
||||
'ai.codebuddy.elicitation.validation.notInteger': '{field} debe ser un número entero.',
|
||||
'ai.codebuddy.elicitation.validation.minimum': '{field} debe ser al menos {limit}.',
|
||||
'ai.codebuddy.elicitation.validation.maximum': '{field} debe ser como máximo {limit}.',
|
||||
'ai.codebuddy.elicitation.validation.minLength': '{field} debe contener al menos {limit} caracteres.',
|
||||
'ai.codebuddy.elicitation.validation.maxLength': '{field} debe contener como máximo {limit} caracteres.',
|
||||
'ai.codebuddy.elicitation.validation.minItems': 'Selecciona al menos {limit} opciones para {field}.',
|
||||
'ai.codebuddy.elicitation.validation.maxItems': 'Selecciona como máximo {limit} opciones para {field}.',
|
||||
'ai.codebuddy.elicitation.validation.format': '{field} debe coincidir con el formato {format}.',
|
||||
'ai.codebuddy.elicitation.validation.option': 'Selecciona una opción válida para {field}.',
|
||||
|
||||
// AI OpenCode
|
||||
'ai.opencode.title': 'OpenCode',
|
||||
'ai.opencode.description': 'Usa OpenCode mediante el SDK oficial. Configura proveedores y claves en OpenCode y luego selecciónalo como agente de codificación externo.',
|
||||
'ai.opencode.detecting': 'Detectando...',
|
||||
'ai.opencode.detected': 'Detectado',
|
||||
'ai.opencode.notFound': 'No encontrado',
|
||||
'ai.opencode.path': 'Ruta:',
|
||||
'ai.opencode.notFoundHint': 'No se pudo encontrar opencode en el PATH. Instálalo o especifica la ruta del ejecutable a continuación.',
|
||||
'ai.opencode.customPathPlaceholder': 'p. ej. /usr/local/bin/opencode',
|
||||
'ai.opencode.check': 'Verificar',
|
||||
'ai.opencode.resetPath': 'Restablecer',
|
||||
|
||||
// AI Grok Build (in-app managed agent — distinct from External MCP client install)
|
||||
'ai.grok.title': 'Grok Build',
|
||||
'ai.grok.description': "La CLI de agente de codificación Grok Build de xAI. Instala la CLI de Grok, inicia sesión con `grok login` o define XAI_API_KEY, y luego selecciónala como agente externo.",
|
||||
'ai.grok.detecting': 'Detectando...',
|
||||
'ai.grok.detected': 'Detectado',
|
||||
'ai.grok.notFound': 'No encontrado',
|
||||
'ai.grok.path': 'Ruta:',
|
||||
'ai.grok.notFoundHint': 'No se pudo encontrar grok en el PATH. Instala la CLI de Grok Build o especifica la ruta del ejecutable a continuación.',
|
||||
'ai.grok.customPathPlaceholder': 'p. ej. /usr/local/bin/grok',
|
||||
'ai.grok.check': 'Verificar',
|
||||
'ai.grok.resetPath': 'Restablecer',
|
||||
'ai.grok.runtime.acp.title': 'Usar Grok ACP (agente stdio)',
|
||||
'ai.grok.runtime.acp.default': 'Predeterminado',
|
||||
'ai.grok.runtime.acp.description':
|
||||
'Habla con Grok mediante el Protocolo de Cliente de Agente (grok agent stdio). Inyecta el MCP de Netcatty en session/new. Apágalo para usar la ruta original de CLI headless con streaming-json.',
|
||||
'ai.grok.runtime.streamingJson.hint':
|
||||
'Usando streaming-json headless (grok -p --output-format streaming-json). El archivo .grok/config.toml del proyecto se usa para la inyección de MCP.',
|
||||
|
||||
// AI Default Agent
|
||||
'ai.defaultAgent': 'Agente predeterminado',
|
||||
'ai.defaultAgent.description': 'Agente que se usará al iniciar una nueva sesión de IA',
|
||||
'ai.defaultAgent.catty': 'Catty (Integrado)',
|
||||
'ai.toolAccess.title': 'Acceso a herramientas',
|
||||
'ai.toolAccess.mode': 'Modo de acceso de Netcatty',
|
||||
'ai.toolAccess.description': 'Elige cómo acceden los agentes externos a las sesiones de Netcatty. MCP expone el servidor integrado, mientras que Habilidades + CLI apunta a los agentes hacia las habilidades locales de Netcatty y los comandos de CLI.',
|
||||
'ai.toolAccess.mode.mcp': 'MCP',
|
||||
'ai.toolAccess.mode.skills': 'Habilidades + CLI',
|
||||
|
||||
// External MCP (productized catalog MCP for Codex / Claude Code / Cursor)
|
||||
'ai.externalMcp.title': 'MCP externo',
|
||||
'ai.externalMcp.description': 'Expón Netcatty como un servidor MCP para clientes externos como Codex, Claude Code, Cursor y Grok. Usa las mismas herramientas del catálogo que los agentes de la aplicación (terminal, SFTP, Vault, reenvío de puertos). Mantén Netcatty en ejecución mientras los clientes estén conectados.',
|
||||
'ai.externalMcp.sessionsExposed': 'Sesiones en alcance: {count}',
|
||||
'ai.externalMcp.mode': 'Modo de disponibilidad',
|
||||
'ai.externalMcp.mode.temporary': 'Temporal',
|
||||
'ai.externalMcp.mode.persistent': 'Siempre activo',
|
||||
'ai.externalMcp.mode.description': 'El modo temporal se desactiva automáticamente tras el tiempo de inactividad. El modo Siempre activo restaura el MCP externo cuando se inicia Netcatty.',
|
||||
'ai.externalMcp.idleTimeout': 'Tiempo de inactividad',
|
||||
'ai.externalMcp.idleTimeout.description': 'En modo temporal, desactiva el MCP externo después de esta cantidad de minutos sin operaciones de MCP.',
|
||||
'ai.externalMcp.idleTimeout.minutes': 'min',
|
||||
'ai.externalMcp.focusOnHostOpen': 'Enfocar ventana en host_open',
|
||||
'ai.externalMcp.focusOnHostOpen.description': 'Cuando un cliente de MCP abre un host, trae la ventana principal al frente. Apágalo para seguir trabajando sin interrupciones.',
|
||||
'ai.externalMcp.silentSessions': 'Sesiones MCP silenciosas',
|
||||
'ai.externalMcp.silentSessions.description': 'Las sesiones abiertas por la IA se mantienen fuera de tu barra de pestañas y no se restauran tras reiniciar. Puedes verlas en cualquier momento desde el panel de la bandeja.',
|
||||
'ai.externalMcp.sessionIdleTimeout': 'Tiempo de inactividad de la sesión abierta',
|
||||
'ai.externalMcp.sessionIdleTimeout.description': 'Cierra automáticamente las sesiones abiertas por una IA después de esta cantidad de minutos sin actividad de terminal o archivos.',
|
||||
'ai.externalMcp.usage.title': 'Cómo usarlo',
|
||||
'ai.externalMcp.usage.keepRunning': '1. Activa el MCP externo y mantén Netcatty en ejecución.',
|
||||
'ai.externalMcp.usage.localhost': '2. Los clientes se conectan mediante el launcher local (solo 127.0.0.1). El descubrimiento se elimina cuando desactivas el interruptor.',
|
||||
'ai.externalMcp.usage.permissions': '3. Las operaciones de escritura siguen Configuración → IA → Seguridad (observador / confirmar / automático) y la lista de bloqueo de comandos.',
|
||||
'ai.externalMcp.usage.capabilities': '4. Todas las herramientas del catálogo están disponibles: terminal, SFTP, Vault y reenvío de puertos. Los secretos (contraseñas / claves privadas) nunca se devuelven.',
|
||||
'ai.externalMcp.help.ariaLabel': 'Ayuda del MCP externo',
|
||||
'ai.externalMcp.security': 'Seguridad',
|
||||
'ai.externalMcp.security.description': 'Escucha en 127.0.0.1 con un token rotativo, reutiliza el modo de permisos de IA para las escrituras y elimina el descubrimiento al desactivarse. Sin OAuth: este es un puente local de escritorio.',
|
||||
'ai.externalMcp.permissionMode': 'Modo de permisos actual: {mode}',
|
||||
'ai.externalMcp.permissionMode.label': 'Modo de permiso de escritura',
|
||||
'ai.externalMcp.permissionMode.hint': 'La misma configuración que Configuración → IA → Seguridad. Automático ejecuta las herramientas de escritura de Netcatty sin solicitar aprobación de Netcatty; Confirmar pregunta cada vez. Los clientes externos (Codex / Claude / Grok) aún pueden mostrar su propia interfaz de aprobación de herramientas.',
|
||||
'ai.externalMcp.permissionMode.unknown': 'Desconocido',
|
||||
'ai.externalMcp.discovery': 'Descubrimiento',
|
||||
'ai.externalMcp.launcher': 'Launcher',
|
||||
'ai.externalMcp.unavailable': 'No disponible',
|
||||
'ai.externalMcp.bridgeUnavailable': 'Puente de MCP externo no disponible',
|
||||
'ai.externalMcp.copy': 'Copiar',
|
||||
'ai.externalMcp.copied': 'Copiado',
|
||||
'ai.externalMcp.copyFailed': 'No se pudo copiar. Intenta copiarlo manualmente.',
|
||||
'ai.externalMcp.refresh': 'Actualizar',
|
||||
'ai.externalMcp.clientConfiguration': 'Configuración del cliente',
|
||||
'ai.externalMcp.clientConfiguration.description': 'Elige un cliente para instalarlo con un clic, o copia fragmentos de CLI o configuración.',
|
||||
'ai.externalMcp.client.codex': 'Codex',
|
||||
'ai.externalMcp.client.claude': 'Claude Code',
|
||||
'ai.externalMcp.client.grok': 'Grok',
|
||||
'ai.externalMcp.client.cursor': 'Cursor',
|
||||
'ai.externalMcp.cliCommand': 'Comando CLI',
|
||||
'ai.externalMcp.configSnippet': 'Fragmento de configuración',
|
||||
'ai.externalMcp.addToCodex': 'Agregar a Codex',
|
||||
'ai.externalMcp.addToClaude': 'Agregar a Claude Code',
|
||||
'ai.externalMcp.addToGrok': 'Agregar a Grok',
|
||||
'ai.externalMcp.codexAdded': 'Entrada de MCP de Codex agregada. Reinicia Codex o abre una nueva sesión de Codex.',
|
||||
'ai.externalMcp.claudeAdded': 'Entrada de MCP de Claude Code agregada. Reinicia Claude Code o abre una nueva sesión de Claude Code.',
|
||||
'ai.externalMcp.grokAdded': 'Entrada de MCP de Grok agregada. Reinicia Grok o abre una nueva sesión de Grok.',
|
||||
'ai.externalMcp.installCodex': 'Instala Codex por separado y luego haz clic en Actualizar.',
|
||||
'ai.externalMcp.installClaude': 'Instala Claude Code por separado y luego haz clic en Actualizar.',
|
||||
'ai.externalMcp.installGrok': 'Instala la CLI de Grok por separado y luego haz clic en Actualizar.',
|
||||
'ai.externalMcp.conflict.description': 'Ya existe una entrada netcatty-external que apunta a otro lugar. Quítala o edítala manualmente.',
|
||||
'ai.externalMcp.enableForLauncher': 'Habilita el MCP externo para obtener una ruta de launcher utilizable.',
|
||||
'ai.externalMcp.cursor.title': 'Cursor / otros clientes',
|
||||
'ai.externalMcp.cursor.description': 'Combina esto en tu configuración de MCP (por ejemplo, ~/.cursor/mcp.json). No reemplaces todo el archivo si ya tienes otros servidores.',
|
||||
'ai.externalMcp.status.unavailable': 'No disponible',
|
||||
'ai.externalMcp.status.disabled': 'Desactivado',
|
||||
'ai.externalMcp.status.running': 'En ejecución',
|
||||
'ai.externalMcp.status.starting': 'Iniciando',
|
||||
'ai.externalMcp.status.error': 'Error',
|
||||
'ai.externalMcp.status.configured': 'Configurado',
|
||||
'ai.externalMcp.status.notConfigured': 'No configurado',
|
||||
'ai.externalMcp.status.checking': 'Comprobando',
|
||||
'ai.externalMcp.status.codexNotFound': 'Codex no encontrado',
|
||||
'ai.externalMcp.status.claudeNotFound': 'Claude Code no encontrado',
|
||||
'ai.externalMcp.status.grokNotFound': 'Grok no encontrado',
|
||||
'ai.externalMcp.status.conflict': 'Conflicto',
|
||||
'ai.userSkills.title': 'Habilidades de usuario',
|
||||
'ai.userSkills.description': 'Abre la carpeta de habilidades de Netcatty para agregar tus propios directorios de habilidades. Netcatty escanea estas habilidades automáticamente e inyecta solo índices ligeros, salvo que una habilidad coincida claramente con la petición actual.',
|
||||
'ai.userSkills.openFolder': 'Abrir carpeta de habilidades',
|
||||
'ai.userSkills.reload': 'Recargar habilidades',
|
||||
'ai.userSkills.location': 'Ubicación',
|
||||
'ai.userSkills.loading': 'Escaneando habilidades de usuario...',
|
||||
'ai.userSkills.summary': '{ready} listas, {warnings} advertencias',
|
||||
'ai.userSkills.empty': 'Aún no se encontraron habilidades de usuario. Abre la carpeta para agregar directorios de habilidades con un archivo SKILL.md.',
|
||||
'ai.userSkills.unavailable': 'Las habilidades de usuario no están disponibles en este entorno.',
|
||||
'ai.userSkills.status.ready': 'Lista',
|
||||
'ai.userSkills.status.warning': 'Advertencia',
|
||||
|
||||
// AI Quick Messages
|
||||
'ai.quickMessages.title': 'Mensajes rápidos',
|
||||
'ai.quickMessages.description': 'Crea prompts reutilizables que puedes insertar desde el chat de IA con / o con el botón de mensajes rápidos. A diferencia de las habilidades de usuario, los mensajes rápidos llenan el compositor con texto.',
|
||||
'ai.quickMessages.add': 'Agregar mensaje rápido',
|
||||
'ai.quickMessages.createTitle': 'Nuevo mensaje rápido',
|
||||
'ai.quickMessages.editTitle': 'Editar mensaje rápido',
|
||||
'ai.quickMessages.name': 'Nombre',
|
||||
'ai.quickMessages.name.placeholder': 'p. ej. Verificar espacio en disco',
|
||||
'ai.quickMessages.slug': 'Comando',
|
||||
'ai.quickMessages.slug.placeholder': 'disk-check',
|
||||
'ai.quickMessages.descriptionField': 'Descripción (opcional)',
|
||||
'ai.quickMessages.descriptionField.placeholder': 'Breve indicación sobre lo que hace este prompt',
|
||||
'ai.quickMessages.content': 'Contenido del mensaje',
|
||||
'ai.quickMessages.content.placeholder': 'Texto completo del prompt para insertar al seleccionarlo...',
|
||||
'ai.quickMessages.empty': 'Aún no hay mensajes rápidos. Agrega algunos prompts que uses seguido.',
|
||||
'ai.quickMessages.confirmDelete': '¿Eliminar el mensaje rápido "{name}"?',
|
||||
'ai.quickMessages.error.nameRequired': 'El nombre es obligatorio.',
|
||||
'ai.quickMessages.error.invalidSlug': 'El comando solo puede contener letras minúsculas, números y guiones.',
|
||||
'ai.quickMessages.error.contentRequired': 'El contenido del mensaje es obligatorio.',
|
||||
'ai.quickMessages.error.slugTaken': 'Este comando ya lo usa otro mensaje rápido.',
|
||||
'ai.quickMessages.error.slugConflictsWithSkill': 'Este comando entra en conflicto con la habilidad de usuario "/{slug}". Elige otro.',
|
||||
'ai.quickMessages.error.maxItems': 'Puedes guardar como máximo {max} mensajes rápidos.',
|
||||
|
||||
// AI Chat
|
||||
'ai.chat.noProvider': 'No hay ningún proveedor de IA configurado. Ve a **Configuración → IA → Proveedores** para agregar y habilitar un proveedor.',
|
||||
'ai.chat.toolDenied': 'La acción fue rechazada por el usuario.',
|
||||
'ai.chat.toolApproved': 'Aprobado',
|
||||
'ai.chat.toolApprovalHint': 'Presiona Enter una vez · Esc para rechazar',
|
||||
'ai.chat.approve': 'Aprobar',
|
||||
'ai.chat.approveOnce': 'Una vez',
|
||||
'ai.chat.alwaysAllow': 'Siempre',
|
||||
'ai.chat.slashStopDesc': 'Detener el turno actual de IA y cancelar las herramientas en curso',
|
||||
'ai.chat.slashCompactDesc': 'Resumir el contexto anterior de la conversación',
|
||||
'ai.chat.reject': 'Rechazar',
|
||||
'ai.chat.toolLabel': 'Herramienta',
|
||||
'ai.chat.targetLabel': 'Destino',
|
||||
'ai.chat.rawCommand': 'Comando',
|
||||
'ai.chat.copyCommand': 'Copiar',
|
||||
'ai.chat.commandCopied': 'Copiado',
|
||||
'ai.chat.approvalSession': 'Sesión',
|
||||
'ai.chat.approvalShell': 'Shell',
|
||||
'ai.chat.approvalCwd': 'Cwd',
|
||||
'ai.chat.approvalReason': 'Motivo',
|
||||
'ai.chat.approvalInvocation': 'Invocación',
|
||||
'ai.chat.permissionRequired': 'Permiso requerido',
|
||||
'ai.chat.permissionDescription': 'El agente de IA quiere ejecutar una llamada a una herramienta que requiere tu aprobación.',
|
||||
'ai.chat.commandBlocked': 'Este comando está bloqueado por tu política de seguridad y no se puede ejecutar.',
|
||||
'ai.chat.recommendAllow': 'Permitir',
|
||||
'ai.chat.recommendConfirm': 'Confirmar',
|
||||
'ai.chat.recommendDeny': 'Denegar',
|
||||
'ai.chat.exportConversation': 'Exportar conversación',
|
||||
'ai.chat.exportAs': 'Exportar como',
|
||||
'ai.chat.exportMarkdown': 'Markdown',
|
||||
'ai.chat.exportJSON': 'JSON',
|
||||
'ai.chat.exportPlainText': 'Texto plano',
|
||||
'ai.chat.thinking': 'Pensando',
|
||||
'ai.chat.thoughtFor': 'Pensó durante {duration}',
|
||||
'ai.chat.thought': 'Pensamiento',
|
||||
'ai.chat.agents': 'Agentes',
|
||||
'ai.chat.detectedOnMachine': 'Detectado en esta máquina',
|
||||
'ai.chat.rescan': 'Volver a escanear',
|
||||
'ai.chat.permObserver': 'Observador',
|
||||
'ai.chat.permConfirm': 'Confirmar',
|
||||
'ai.chat.permAuto': 'Automático',
|
||||
'ai.chat.permObserverDesc': 'Solo lectura (Cursor CLI: Ask)',
|
||||
'ai.chat.permConfirmDesc': 'Preguntar antes de que Netcatty escriba (Cursor CLI: agent + force)',
|
||||
'ai.chat.permAutoDesc': 'Ejecutar libremente (Cursor CLI: agent + force)',
|
||||
'ai.chat.emptyHint': 'Pregunta sobre tus servidores, ejecuta comandos u obtén ayuda con las configuraciones.',
|
||||
'ai.chat.placeholder': 'Mensaje a {agent} — @ para incluir contexto, / para comandos',
|
||||
'ai.chat.placeholderDefault': 'Mensaje al agente Catty...',
|
||||
'ai.chat.noModel': 'Sin modelo',
|
||||
'ai.chat.noProviderModel': 'No hay modelo predeterminado: define uno en Configuración → IA → Proveedores.',
|
||||
'ai.chat.selectProvider': 'Seleccionar proveedor',
|
||||
'ai.chat.recent': 'Recientes',
|
||||
'ai.chat.viewAll': 'Ver todo',
|
||||
'ai.chat.untitled': 'Sin título',
|
||||
'ai.chat.justNow': 'Ahora mismo',
|
||||
'ai.chat.minutesAgo': 'hace {n} min',
|
||||
'ai.chat.hoursAgo': 'hace {n} h',
|
||||
'ai.chat.daysAgo': 'hace {n} d',
|
||||
'ai.chat.newChat': 'Nuevo chat',
|
||||
'ai.chat.allSessions': 'Todas las sesiones',
|
||||
'ai.chat.loadEarlierMessages': 'Cargar mensajes anteriores ({n} más)',
|
||||
'ai.chat.jumpNav': 'Ir al mensaje',
|
||||
'ai.chat.jumpUntitled': '(mensaje vacío)',
|
||||
'ai.chat.usedTools': 'Herramientas usadas: {n}',
|
||||
'ai.chat.loadMoreSessions': 'Cargar más sesiones ({n} más)',
|
||||
'ai.chat.noSessions': 'No hay sesiones anteriores',
|
||||
'ai.chat.retryHint': 'Puedes reintentar enviando tu mensaje de nuevo.',
|
||||
'ai.chat.approvalTimeout': 'La aprobación de la herramienta expiró después de 5 minutos. Puedes reintentar enviando tu mensaje de nuevo.',
|
||||
'ai.chat.menuHosts': 'Hosts',
|
||||
'ai.chat.menuContext': 'Contexto',
|
||||
'ai.chat.menuFiles': 'Archivos',
|
||||
'ai.chat.menuImage': 'Imagen',
|
||||
'ai.chat.menuMentionHost': 'Mencionar host',
|
||||
'ai.chat.menuUserSkills': 'Habilidades de usuario',
|
||||
'ai.chat.menuSlashCommands': 'Comandos de barra',
|
||||
'ai.chat.slashCommands': 'Comandos de barra',
|
||||
'ai.chat.slashSystemCommands': 'Comandos',
|
||||
'ai.chat.slashQuickMessages': 'Mensajes rápidos',
|
||||
'ai.chat.slashUserSkills': 'Habilidades de usuario',
|
||||
'ai.chat.quickMessages': 'Comandos de barra',
|
||||
'ai.chat.slashNoResults': 'No hay comandos que coincidan',
|
||||
'ai.chat.slashEmptyHint': 'Agrega prompts en Configuración → IA → Mensajes rápidos.',
|
||||
|
||||
// AI Chat Shortcuts
|
||||
'ai.chatShortcuts.title': 'Atajos de chat',
|
||||
'ai.chatShortcuts.selectionAction': 'Mostrar "Agregar a la conversación" al seleccionar texto de la terminal',
|
||||
'ai.chatShortcuts.selectionAction.description': 'Muestra un pequeño botón de IA junto al texto seleccionado de la terminal.',
|
||||
|
||||
// AI Error
|
||||
'ai.codex.bridgeError': 'Los controladores del proceso principal de Codex aún no están cargados. Reinicia Netcatty por completo o reinicia el proceso de desarrollo de Electron y vuelve a intentarlo.',
|
||||
|
||||
// AI Web Search
|
||||
'ai.webSearch.title': 'Búsqueda web',
|
||||
'ai.webSearch.enable': 'Habilitar búsqueda web',
|
||||
'ai.webSearch.enable.description': 'Permite que el agente de IA busque en la web información actual.',
|
||||
'ai.webSearch.provider': 'Proveedor de búsqueda',
|
||||
'ai.webSearch.provider.description': 'Elige un proveedor de API de búsqueda web.',
|
||||
'ai.webSearch.apiKey': 'Clave de API',
|
||||
'ai.webSearch.apiKey.description': 'Clave de API para el proveedor de búsqueda seleccionado.',
|
||||
'ai.webSearch.apiKey.placeholder': 'Ingresa la clave de API...',
|
||||
'ai.webSearch.apiHost': 'Host de API',
|
||||
'ai.webSearch.apiHost.description': 'Endpoint de API personalizado. Deja el predeterminado salvo que uses un proxy.',
|
||||
'ai.webSearch.apiHost.searxngDescription': 'URL de tu instancia de SearXNG (obligatoria).',
|
||||
'ai.webSearch.maxResults': 'Máximo de resultados',
|
||||
'ai.webSearch.maxResults.description': 'Cantidad máxima de resultados de búsqueda a devolver (1-20).',
|
||||
|
||||
// AI Safety Settings
|
||||
'ai.safety.title': 'Seguridad',
|
||||
'ai.safety.permissionMode': 'Modo de permisos',
|
||||
'ai.safety.permissionMode.description': 'Controla cómo interactúa la IA con tus sesiones de terminal de Netcatty. El modo Observador bloquea las operaciones de escritura que pasan por Netcatty. Las CLIs de agentes externos pueden seguir teniendo sus propias herramientas locales y flujo de aprobación.',
|
||||
'ai.safety.permissionMode.observer': 'Observador: solo lectura, sin acciones',
|
||||
'ai.safety.permissionMode.confirm': 'Confirmar: preguntar antes de las acciones',
|
||||
'ai.safety.permissionMode.auto': 'Automático: ejecutar libremente',
|
||||
'ai.safety.commandTimeout': 'Tiempo de espera de comandos',
|
||||
'ai.safety.commandTimeout.description': 'Máximo de segundos que un comando puede ejecutarse antes de ser terminado mediante la ejecución de Netcatty.',
|
||||
'ai.safety.commandTimeout.unit': 'seg',
|
||||
'ai.safety.maxIterations': 'Máximo de iteraciones',
|
||||
'ai.safety.maxIterations.description': 'Máximo de bucles de uso de herramientas de IA para evitar una ejecución descontrolada. Los agentes externos pueden tener sus propios límites de iteración internos que tienen prioridad.',
|
||||
'ai.safety.blocklist': 'Lista de bloqueo de comandos',
|
||||
'ai.safety.blocklist.description': 'Patrones regex para bloquear comandos peligrosos ejecutados mediante Netcatty.',
|
||||
'ai.safety.blocklist.placeholder': 'Patrón regex...',
|
||||
'ai.safety.blocklist.reset': 'Restablecer a los valores predeterminados',
|
||||
'ai.safety.blocklist.add': 'Agregar patrón',
|
||||
'ai.safety.grants.title': 'Memoria de permisos',
|
||||
'ai.safety.grants.heading': 'Reglas de permitir en modo Confirmar',
|
||||
'ai.safety.grants.description': 'El modo Confirmar pregunta antes de ejecutar una operación. Las reglas guardadas permiten automáticamente las operaciones que coinciden en todas las sesiones/nodos de terminal, y se pueden editar manualmente.',
|
||||
'ai.safety.grants.empty': 'Aún no hay reglas guardadas. Aprueba una herramienta con "Permitir siempre" o agrega una manualmente.',
|
||||
'ai.safety.grants.capability': 'Capacidad',
|
||||
'ai.safety.grants.sessionPattern': 'Patrón de sesión',
|
||||
'ai.safety.grants.commandPattern': 'Patrón de comando (opcional)',
|
||||
'ai.safety.grants.note': 'Nota (opcional)',
|
||||
'ai.safety.grants.add': 'Agregar regla',
|
||||
'ai.safety.grants.remove': 'Quitar',
|
||||
'ai.safety.grants.export': 'Exportar JSON',
|
||||
'ai.safety.grants.import': 'Importar JSON',
|
||||
'ai.safety.note': 'Estos ajustes de seguridad se aplican a las acciones que pasan por Netcatty. Las CLIs de agentes externos también pueden exponer herramientas locales regidas por el propio agente.',
|
||||
|
||||
// Unified tooltips for terminal workspace and top tabs (issue #954)
|
||||
'terminal.layer.addTerminal': 'Agregar terminal',
|
||||
'terminal.layer.switchToSplitView': 'Cambiar a vista dividida',
|
||||
'terminal.layer.sftp': 'SFTP',
|
||||
'terminal.layer.scripts': 'Scripts',
|
||||
'terminal.layer.history': 'Historial',
|
||||
'terminal.layer.theme': 'Tema',
|
||||
'terminal.layer.notes': 'Notas',
|
||||
'terminal.layer.aiChat': 'Chat de IA',
|
||||
'terminal.layer.movePanelLeft': 'Mover panel a la izquierda',
|
||||
'terminal.layer.movePanelRight': 'Mover panel a la derecha',
|
||||
'terminal.layer.closePanel': 'Cerrar panel',
|
||||
'terminal.layer.closePane': 'Cerrar división',
|
||||
'terminal.layer.resizeSplit': 'Redimensionar división',
|
||||
'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.hostTree.search': 'Buscar hosts...',
|
||||
'terminal.layer.hostTree.searchButton': 'Buscar',
|
||||
'terminal.layer.hostTree.tagsButton': 'Filtrar por etiquetas',
|
||||
'terminal.layer.hostTree.newHost': 'Nuevo host',
|
||||
'terminal.layer.hostTree.newHostInGroup': 'Nuevo host en este grupo',
|
||||
'terminal.layer.hostTree.editHost': 'Editar host',
|
||||
'terminal.layer.hostTree.hostSavedNextConnection': 'Host actualizado. Los ajustes de conexión se aplicarán la próxima vez que te conectes.',
|
||||
'terminal.layer.hostTree.newGroup': 'Nuevo grupo',
|
||||
'terminal.layer.hostTree.localShell': 'Shell local',
|
||||
'terminal.layer.hostTree.tagsEmpty': 'No hay etiquetas disponibles',
|
||||
'terminal.layer.hostTree.clearTags': 'Borrar selección',
|
||||
'terminal.layer.hostTree.collapse': 'Contraer lista de hosts',
|
||||
'terminal.layer.hostTree.expand': 'Expandir lista de hosts',
|
||||
'terminal.layer.hostTree.empty': 'No se encontraron hosts',
|
||||
'terminal.layer.hostTree.details.host': 'Host',
|
||||
'terminal.layer.hostTree.details.user': 'Usuario',
|
||||
'terminal.layer.hostTree.details.port': 'Puerto',
|
||||
'terminal.layer.hostTree.details.protocol': 'Protocolo',
|
||||
'terminal.layer.hostTree.details.group': 'Grupo',
|
||||
'terminal.layer.hostTree.details.tags': 'Etiquetas',
|
||||
'terminal.layer.hostTree.details.lastConnected': 'Última conexión',
|
||||
'topTabs.openQuickSwitcher': 'Abrir selector rápido',
|
||||
'topTabs.moreTabs': 'Más pestañas',
|
||||
'topTabs.aiAssistant': 'Asistente de IA',
|
||||
'topTabs.newLocalTerminal': 'Nueva terminal local',
|
||||
'topTabs.controlPanel': 'Controles rápidos',
|
||||
'topTabs.controlPanel.externalMcp': 'MCP externo',
|
||||
'topTabs.controlPanel.theme': 'Tema',
|
||||
'topTabs.controlPanel.theme.light': 'Claro',
|
||||
'topTabs.controlPanel.theme.dark': 'Oscuro',
|
||||
'topTabs.controlPanel.theme.system': 'Sistema',
|
||||
'topTabs.externalMcp.enable': 'Habilitar MCP externo',
|
||||
'topTabs.externalMcp.disable': 'Desactivar MCP externo',
|
||||
'topTabs.windowOpacity': 'Opacidad de la ventana',
|
||||
'topTabs.openSettings': 'Abrir Configuración',
|
||||
'ai.chat.sessionHistory': 'Historial de sesiones',
|
||||
'ai.chat.resizeInput': 'Arrastra para redimensionar el campo de mensaje',
|
||||
'ai.chat.attach': 'Adjuntar',
|
||||
'ai.chat.terminalSelectionAttachment': 'Selección de la terminal',
|
||||
'ai.chat.terminalSelectionLines': 'líneas: {count}',
|
||||
'ai.chat.collapse': 'Contraer',
|
||||
'ai.chat.expand': 'Expandir',
|
||||
'ai.chat.enableAgent': 'Habilitar {name}',
|
||||
'ai.chat.artifact.noteFallback': 'Nota de Vault',
|
||||
'ai.chat.artifact.openNotes': 'Abrir Notas',
|
||||
'ai.chat.artifact.openHosts': 'Abrir Hosts',
|
||||
'ai.chat.artifact.notesSummary': '{count} notas en Vault',
|
||||
'ai.chat.artifact.hostsSummary': '{count} hosts en Vault',
|
||||
'ai.chat.artifact.hostsAdded': 'Se agregaron {count} hosts',
|
||||
'ai.chat.artifact.hostsPreview': 'Vista previa de {count} hosts',
|
||||
'ai.chat.artifact.failed': 'La operación de Vault falló',
|
||||
'ai.chat.artifact.unavailableTitle': 'No disponible',
|
||||
'ai.chat.artifact.noteMissing': 'Esta nota ya no está en tu Vault.',
|
||||
'ai.chat.artifact.hostMissing': 'Este host ya no está en tu Vault.',
|
||||
'ai.chat.artifact.snippetMissing': 'Este snippet o script ya no está en tu Vault.',
|
||||
'ai.chat.artifact.openSnippets': 'Abrir Snippets',
|
||||
'ai.chat.artifact.snippetsSummary': '{count} snippets en Vault',
|
||||
'ai.chat.artifact.scriptsSummary': '{count} scripts en Vault',
|
||||
'ai.chat.artifact.snippetFallback': 'Snippet de Vault',
|
||||
'ai.chat.artifact.scriptFallback': 'Script de automatización',
|
||||
'ai.chat.artifact.scriptLanguage': 'Script de {language}',
|
||||
'ai.chat.artifact.snippetDeleted': 'Snippet eliminado',
|
||||
'ai.chat.artifact.scriptDeleted': 'Script eliminado',
|
||||
'ai.chat.artifact.snippetRan': 'Snippet ejecutado',
|
||||
'ai.chat.artifact.scriptStarted': 'Se inició la ejecución del script',
|
||||
'ai.chat.artifact.scriptRunStatus': 'Ejecución del script: {status}',
|
||||
'ai.chat.artifact.scriptRunsSummary': '{count} ejecuciones de scripts',
|
||||
'ai.chat.artifact.scriptRunStopped': 'Ejecución del script detenida',
|
||||
'ai.chat.artifact.scriptRunPaused': 'Ejecución del script en pausa',
|
||||
'ai.chat.artifact.scriptRunResumed': 'Ejecución del script reanudada',
|
||||
'ai.chat.artifact.scriptReference': 'referencia de la API de nct',
|
||||
'zmodem.waitingForRemote': 'Esperando al servidor remoto...',
|
||||
'zmodem.uploading': 'Subiendo',
|
||||
'zmodem.downloading': 'Descargando',
|
||||
'zmodem.cancelTransfer': 'Cancelar transferencia (Ctrl+C)',
|
||||
'zmodem.overwrite.title': 'El archivo remoto ya existe',
|
||||
'zmodem.overwrite.applyToRest': 'Aplicar a los conflictos restantes',
|
||||
'zmodem.overwrite.overwrite': 'Sobrescribir',
|
||||
'zmodem.overwrite.skip': 'Omitir',
|
||||
'zmodem.overwrite.cancel': 'Cancelar',
|
||||
'settings.shortcuts.resetToDefault': 'Restablecer al predeterminado',
|
||||
};
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,133 @@
|
||||
export const esScriptsMessages = {
|
||||
'scripts.meta.name': 'Nombre',
|
||||
'scripts.meta.language': 'Lenguaje',
|
||||
'scripts.meta.description': 'Descripción',
|
||||
'scripts.meta.descriptionPlaceholder': 'Notas opcionales sobre este script',
|
||||
'scripts.meta.trigger': 'Disparador',
|
||||
'scripts.meta.triggerPattern': 'Patrón de salida (regex)',
|
||||
'scripts.meta.code': 'Script',
|
||||
'scripts.trigger.manual': 'Ejecución manual',
|
||||
'scripts.trigger.onConnect': 'Ejecutar al conectar',
|
||||
'scripts.trigger.onOutput': 'Ejecutar al coincidir con la salida',
|
||||
'scripts.trigger.onOutputHint': 'Se dispara cuando la salida del servidor coincide con el patrón (el eco de las teclas del usuario se ignora). Sin hosts de destino configurados, escucha en la sesión conectada actual; con hosts de destino, solo en esos hosts. Deshabilitado en aplicaciones de pantalla alterna como vim o htop. Se suprime mientras otro script se ejecuta en esta sesión; se vuelve a revisar cuando termina. Para esperas dentro del flujo, usa nct.screen.waitForText o nct.screen.waitForRegex en el código del script.',
|
||||
'scripts.actions.save': 'Guardar',
|
||||
'scripts.actions.runNow': 'Ejecutar ahora',
|
||||
'scripts.actions.openEditor': 'Abrir editor',
|
||||
'scripts.actions.openEditorHint': 'Editar el script en una ventana más grande',
|
||||
'scripts.editor.modalTitle': 'Editor de scripts',
|
||||
'scripts.editor.modalSubtitle': 'Edita los metadatos y el código del script en un espacio de trabajo más amplio.',
|
||||
'scripts.editor.lineCount': '{count} líneas',
|
||||
'scripts.editor.resize': 'Redimensionar el editor',
|
||||
'scripts.targets.hint': 'Los grupos seleccionados se resuelven dinámicamente, por lo que los hosts agregados después se incluyen automáticamente.',
|
||||
'scripts.targets.connectOrderHint': 'El orden de ejecución de los scripts de conexión se configura por host en Detalles del host → Automatización.',
|
||||
'scripts.targets.currentHostMismatch': 'Este script no está asignado al host actual.',
|
||||
'hostDetails.automation.groupScripts': 'Scripts de grupo heredados',
|
||||
'hostDetails.automation.groupScriptsHint': 'Estos scripts siguen al grupo del host dinámicamente y se ordenan antes de la cola específica del host.',
|
||||
'scripts.actions.runNowHint': 'Ejecutar en los destinos seleccionados, o en todos los hosts conectables cuando esa opción está habilitada.',
|
||||
'scripts.actions.runParallel': 'Ejecutar en todas las pestañas (en paralelo)',
|
||||
'scripts.actions.runSequential': 'Ejecutar en todas las pestañas (en secuencia)',
|
||||
'scripts.actions.runOnAllTabs': 'Ejecutar en todas las pestañas',
|
||||
'scripts.actions.skippedConnectingSessions': '{count} pestaña(s) aún conectándose y fueron omitidas',
|
||||
'scripts.actions.skippedSensitiveSessions': '{count} pestaña(s) omitidas (entrada de contraseña/datos sensibles)',
|
||||
'scripts.actions.noRunnableHosts': 'Ningún host conectable coincide con los destinos de este script',
|
||||
'scripts.sidePanel.library': 'Biblioteca',
|
||||
'scripts.sidePanel.running': 'En ejecución',
|
||||
'scripts.sidePanel.newScript': 'Nuevo script',
|
||||
'scripts.running.empty': 'No hay scripts en ejecución en esta sesión.',
|
||||
'scripts.running.unnamed': 'Script sin título',
|
||||
'scripts.running.status.running': 'En ejecución',
|
||||
'scripts.running.status.paused': 'En pausa',
|
||||
'scripts.running.status.completed': 'Completado',
|
||||
'scripts.running.status.failed': 'Falló',
|
||||
'scripts.running.waitingFor': 'Esperando {pattern}',
|
||||
'scripts.running.waitingForLabel': 'Esperando',
|
||||
'scripts.running.waitingForShellPrompt': 'indicador del shell (# o $)',
|
||||
'scripts.running.lastSent': 'Enviado: {command}',
|
||||
'scripts.recording.start': 'Comenzar grabación',
|
||||
'scripts.recording.active': 'Detener grabación',
|
||||
'scripts.recording.startHint': 'Graba las acciones en la terminal enfocada y genera código de script nct',
|
||||
'scripts.recording.unavailableHint': 'La grabación no está disponible aquí — usa la barra lateral de scripts a la derecha',
|
||||
'scripts.recording.activeHint': 'Grabando esta terminal. Escribe comandos como de costumbre; haz clic en Detener o usa el control REC en la barra de herramientas para finalizar y guardar.',
|
||||
'scripts.recording.started': 'Grabación iniciada — opera en la terminal',
|
||||
'scripts.recording.noSession': 'Conecta una terminal primero (pestaña única o espacio de trabajo)',
|
||||
'scripts.recording.alreadyActive': 'Otra terminal ya está grabando',
|
||||
'scripts.recording.stop': 'Detener grabación',
|
||||
'scripts.recording.pause': 'Pausar grabación',
|
||||
'scripts.recording.resume': 'Reanudar grabación',
|
||||
'scripts.recording.saveTitle': 'Guardar script grabado',
|
||||
'scripts.recording.namePlaceholder': 'Nombre del script',
|
||||
'scripts.recording.packagePlaceholder': 'Guardar en carpeta',
|
||||
'scripts.recording.rootPackage': 'Raíz',
|
||||
'scripts.recording.save': 'Guardar',
|
||||
'scripts.recording.saveAndEdit': 'Guardar y editar',
|
||||
'scripts.recording.helpTitle': 'Cómo grabar un script',
|
||||
'scripts.recording.helpIntro': 'La grabación convierte lo que haces en la terminal en un script de automatización reutilizable — útil para despliegues, verificaciones de salud y otras tareas repetitivas.',
|
||||
'scripts.recording.helpStep1': 'Conecta a un host. Una pestaña de terminal independiente o una terminal dentro de un espacio de trabajo funcionan igual — abre la barra lateral de scripts a la derecha.',
|
||||
'scripts.recording.helpStep2': 'Haz clic en Comenzar grabación. Una insignia REC roja en la barra de herramientas de la terminal indica que la grabación está activa.',
|
||||
'scripts.recording.helpStep3': 'Escribe comandos en la terminal como lo harías normalmente. Cada pulsación de Enter se captura como un paso.',
|
||||
'scripts.recording.helpStep4': 'Cuando termines, haz clic en Detener grabación, o usa el control de detención junto a REC en la barra de herramientas de la terminal.',
|
||||
'scripts.recording.helpStep5': 'Nombra el script y guárdalo. Elige Guardar y editar si quieres ajustar el código generado en el editor de scripts.',
|
||||
'scripts.recording.helpTipsTitle': 'Consejos',
|
||||
'scripts.recording.helpTip1': 'Las pausas de más de 1 segundo entre acciones se registran como tiempo de espera para que la reproducción no sea demasiado rápida.',
|
||||
'scripts.recording.helpTip2': 'Después de cada comando, la grabación espera un indicador del shell (como $ o #) antes del siguiente paso.',
|
||||
'scripts.recording.helpTip3': 'La entrada de contraseñas se marca como sensible y no se almacena en texto plano en el script.',
|
||||
'scripts.recording.helpTip4': 'Para vim, menús u otros flujos interactivos, graba primero los comandos principales y luego refina el script manualmente.',
|
||||
'scripts.dialog.title': 'Script',
|
||||
'scripts.dialog.ok': 'Aceptar',
|
||||
'scripts.dialog.required': 'Obligatorio',
|
||||
'scripts.dialog.numberInvalid': 'Ingresa un número válido',
|
||||
'scripts.dialog.numberMin': 'Debe ser al menos {min}',
|
||||
'scripts.dialog.numberMax': 'Debe ser como máximo {max}',
|
||||
'scripts.dialog.numberStep': 'Debe usar incrementos de {step}',
|
||||
'scripts.dialog.waitForTimeoutTitle': 'La espera agotó el tiempo',
|
||||
'scripts.dialog.retry': 'Reintentar',
|
||||
'scripts.dialog.skip': 'Omitir',
|
||||
'scripts.dialog.abort': 'Abortar',
|
||||
'scripts.running.stepProgress': 'Paso {current} / {total}',
|
||||
'scripts.running.determinateProgress': '{label} {current}/{total}',
|
||||
'scripts.running.progressFallback': 'Progreso',
|
||||
'scripts.running.operationsCount': '{count} operaciones',
|
||||
'scripts.running.opsPrefix': '',
|
||||
'scripts.running.opsSuffix': ' operaciones',
|
||||
'scripts.running.elapsedLabel': 'Transcurrido',
|
||||
'scripts.running.lastSentLabel': 'Enviado:',
|
||||
'scripts.running.elapsed': '{elapsed}',
|
||||
'scripts.running.completedSummary': 'Completado · {count} operaciones · {elapsed}',
|
||||
'scripts.running.dismissHint': 'Toca cerrar para descartar',
|
||||
'scripts.running.dismiss': 'Cerrar',
|
||||
'scripts.running.viewLogs': 'Ver registros',
|
||||
'scripts.running.logTitle': '{name} · Registro de ejecución',
|
||||
'scripts.running.logEmpty': 'Aún no hay salida de registro',
|
||||
'scripts.running.pause': 'Pausar',
|
||||
'scripts.running.resume': 'Reanudar',
|
||||
'scripts.running.stop': 'Detener',
|
||||
'scripts.recording.saved': 'Script guardado',
|
||||
'scripts.recording.savedNamed': 'Guardado "{name}"',
|
||||
'scripts.observer.blocked': 'El modo observador bloquea los scripts que escriben en la terminal.',
|
||||
'vault.section.scripts': 'Scripts',
|
||||
'vault.nav.scripts': 'Scripts',
|
||||
'snippets.action.newScript': 'Nuevo script de automatización',
|
||||
'hostDetails.section.automation': 'Automatización',
|
||||
'hostDetails.automation.loginScript': 'Script de inicio de sesión',
|
||||
'hostDetails.automation.loginScriptPlaceholder': 'Selecciona un script',
|
||||
'hostDetails.automation.none': 'Ninguno',
|
||||
'hostDetails.automation.outputTriggers': 'Disparadores de salida',
|
||||
'hostDetails.automation.addTrigger': 'Agregar disparador',
|
||||
'hostDetails.automation.triggerPatternPlaceholder': 'Patrón de regex',
|
||||
'hostDetails.automation.linkedScripts': 'Scripts vinculados',
|
||||
'hostDetails.automation.linkedScriptsEmpty': 'Aún no hay scripts vinculados a este host.',
|
||||
'hostDetails.automation.linkScriptPlaceholder': 'Vincular script existente…',
|
||||
'hostDetails.automation.unlink': 'Desvincular',
|
||||
'hostDetails.automation.equivalenceHint': 'Los vínculos agregan este host a la lista de destinos del script en Scripts. Ambas vistas se mantienen sincronizadas.',
|
||||
'hostDetails.automation.queueHint': 'Los scripts se ejecutan en orden cuando este host se conecta. Los scripts globales se ejecutan primero, luego esta cola.',
|
||||
'hostDetails.automation.globalScripts': 'Scripts de conexión globales',
|
||||
'hostDetails.automation.globalScriptsHint': 'Se ejecutan primero en cada conexión. Reordena según el orden de clasificación de la biblioteca de Scripts.',
|
||||
'hostDetails.automation.connectQueue': 'Cola de ejecución de este host',
|
||||
'hostDetails.automation.connectQueueEmpty': 'Aún no hay scripts de conexión en cola para este host.',
|
||||
'hostDetails.automation.addToQueuePlaceholder': 'Agregar script a la cola…',
|
||||
'hostDetails.automation.moveUp': 'Subir',
|
||||
'hostDetails.automation.moveDown': 'Bajar',
|
||||
'hostDetails.automation.removeFromQueue': 'Quitar de la cola',
|
||||
'hostDetails.automation.dragHandle': 'Arrastra para reordenar',
|
||||
'hostDetails.automation.queueDragHint': 'Arrastra los elementos para reordenar la cola de conexión.',
|
||||
};
|
||||
@@ -0,0 +1,261 @@
|
||||
import type { Messages } from '../types';
|
||||
|
||||
export const esSystemManagerMessages: Messages = {
|
||||
'terminal.layer.system': 'Sistema',
|
||||
|
||||
'systemManager.noSession': 'No hay una sesión de terminal activa.',
|
||||
'systemManager.notConnected': 'Conéctate a un host para administrar procesos y servicios.',
|
||||
'systemManager.empty': 'No hay datos disponibles.',
|
||||
'systemManager.tabs.overview': 'Resumen',
|
||||
'systemManager.tabs.processes': 'Procesos',
|
||||
'systemManager.tabs.ports': 'Puertos',
|
||||
'systemManager.tabs.services': 'Servicios',
|
||||
'systemManager.tabs.tmux': 'tmux',
|
||||
'systemManager.tabs.docker': 'Docker',
|
||||
'systemManager.tabs.gpu': 'GPU',
|
||||
'systemManager.tabs.ariaLabel': 'Secciones del administrador del sistema',
|
||||
'systemManager.popup.loading': 'Abriendo terminal…',
|
||||
'systemManager.popup.startupFailed': 'El comando de inicio no se completó correctamente. Verifica que el destino siga disponible e inténtalo de nuevo.',
|
||||
|
||||
'systemManager.errors.loadProcesses': 'No se pudieron cargar los procesos',
|
||||
'systemManager.errors.loadTmux': 'No se pudieron cargar las sesiones de tmux',
|
||||
'systemManager.errors.loadTmuxWindows': 'No se pudieron cargar las ventanas de tmux',
|
||||
'systemManager.errors.loadTmuxPanes': 'No se pudieron cargar los paneles de tmux',
|
||||
'systemManager.errors.loadTmuxClients': 'No se pudieron cargar los clientes de tmux',
|
||||
'systemManager.errors.actionFailed': 'La acción falló',
|
||||
'systemManager.errors.loadDocker': 'No se pudieron cargar los contenedores',
|
||||
'systemManager.errors.loadDockerStats': 'No se pudieron cargar las estadísticas de los contenedores',
|
||||
'systemManager.errors.loadDockerImages': 'No se pudieron cargar las imágenes',
|
||||
'systemManager.errors.loadOverview': 'No se pudo cargar el resumen del sistema',
|
||||
'systemManager.errors.loadGpu': 'No se pudieron cargar las estadísticas de GPU / NPU',
|
||||
'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.overview.empty': 'Aún no hay datos de resumen del sistema.',
|
||||
'systemManager.overview.loading': 'Cargando resumen del sistema…',
|
||||
'systemManager.overview.memory': 'Memoria',
|
||||
'systemManager.overview.disk': 'Disco',
|
||||
'systemManager.overview.network': 'Red',
|
||||
'systemManager.overview.rx': 'RX',
|
||||
'systemManager.overview.tx': 'TX',
|
||||
'systemManager.overview.cores': '{{count}} núcleos',
|
||||
'systemManager.overview.load': 'Carga',
|
||||
'systemManager.overview.uptime': 'Tiempo activo',
|
||||
'systemManager.overview.duration.daysHours': '{{days}}d {{hours}}h',
|
||||
'systemManager.overview.duration.hoursMinutes': '{{hours}}h {{minutes}}m',
|
||||
'systemManager.overview.duration.minutes': '{{minutes}}m',
|
||||
'systemManager.overview.system': 'Sistema',
|
||||
'systemManager.overview.kernel': 'Kernel',
|
||||
'systemManager.overview.swap': 'Swap',
|
||||
'systemManager.overview.latency': 'Latencia de red SSH',
|
||||
'systemManager.overview.cpuCores': 'Núcleos de CPU',
|
||||
'systemManager.overview.disks': 'Discos',
|
||||
'systemManager.overview.interfaces': 'Interfaces de red',
|
||||
'systemManager.overview.topProcesses': 'Procesos con más memoria',
|
||||
'systemManager.overview.noData': 'Sin datos',
|
||||
'systemManager.overview.noDisks': 'Sin datos de disco',
|
||||
'systemManager.overview.noInterfaces': 'Sin datos de interfaces',
|
||||
'systemManager.overview.noTopProcesses': 'Sin datos de procesos',
|
||||
|
||||
'systemManager.processes.search': 'Buscar procesos…',
|
||||
'systemManager.processes.command': 'Comando',
|
||||
'systemManager.processes.user': 'Usuario',
|
||||
'systemManager.processes.term': 'Terminar',
|
||||
'systemManager.processes.kill': 'Forzar cierre',
|
||||
'systemManager.processes.stop': 'Detener (SIGSTOP)',
|
||||
'systemManager.processes.cont': 'Continuar (SIGCONT)',
|
||||
'systemManager.processes.hup': 'Colgar (SIGHUP)',
|
||||
'systemManager.processes.renice': 'Renice',
|
||||
'systemManager.processes.renicePrompt': 'Valor de nice (-20 a 19)',
|
||||
'systemManager.processes.reniceInvalid': 'El valor de nice debe estar entre -20 y 19',
|
||||
'systemManager.processes.confirmKill': '¿Enviar SIGKILL al proceso {{pid}}?',
|
||||
'systemManager.processes.confirmSignal': '¿Enviar SIG{{signal}} al proceso {{pid}}?',
|
||||
'systemManager.processes.filter.all': 'Todos',
|
||||
'systemManager.processes.filter.running': 'En ejecución',
|
||||
'systemManager.processes.ppid': 'PID padre',
|
||||
'systemManager.processes.rss': 'RSS',
|
||||
'systemManager.processes.vsz': 'Tamaño virtual',
|
||||
'systemManager.processes.elapsed': 'Transcurrido',
|
||||
'systemManager.processes.stat': 'Estado',
|
||||
'systemManager.processes.meta': '{{count}} proceso(s)',
|
||||
'systemManager.processes.loading': 'Cargando procesos…',
|
||||
'systemManager.processes.loadingMore': 'Cargando más procesos…',
|
||||
'systemManager.processes.state.running': 'En ejecución',
|
||||
'systemManager.processes.state.sleeping': 'En reposo',
|
||||
'systemManager.processes.state.stopped': 'Detenido',
|
||||
'systemManager.processes.state.zombie': 'Zombie',
|
||||
'systemManager.processes.sort.cpu': 'CPU',
|
||||
'systemManager.processes.sort.mem': 'MEM',
|
||||
'systemManager.processes.sort.pid': 'PID',
|
||||
'systemManager.processes.sort.command': 'Comando',
|
||||
'systemManager.processes.sort.user': 'Usuario',
|
||||
|
||||
'systemManager.common.dismiss': 'Descartar',
|
||||
'systemManager.common.checkingAvailability': 'Verificando disponibilidad…',
|
||||
'systemManager.common.loading': 'Cargando…',
|
||||
'systemManager.common.loadingDetails': 'Cargando detalles…',
|
||||
'systemManager.common.loadingStats': 'Cargando estadísticas…',
|
||||
|
||||
'systemManager.tmux.new': 'Nuevo',
|
||||
'systemManager.tmux.search': 'Buscar sesiones…',
|
||||
'systemManager.tmux.newSessionTitle': 'Nueva sesión de tmux',
|
||||
'systemManager.tmux.newSessionDesc': 'Ponle nombre a la sesión y, opcionalmente, ejecuta un script al iniciar.',
|
||||
'systemManager.tmux.newSessionTabCustom': 'Comando personalizado',
|
||||
'systemManager.tmux.newSessionTabSnippet': 'Desde snippet',
|
||||
'systemManager.tmux.pickSnippet': 'Desde snippets',
|
||||
'systemManager.tmux.pickSnippetEmpty': 'Aún no hay snippets; agrega algunos en el panel de Scripts o en Vault.',
|
||||
'systemManager.tmux.selectedSnippet': 'Usando snippet: {{label}}',
|
||||
'systemManager.tmux.newSessionName': 'Nombre de la sesión',
|
||||
'systemManager.tmux.newSessionCommand': 'Comando de inicio',
|
||||
'systemManager.tmux.newSessionCommandPlaceholder': 'p. ej. htop o npm run dev (opcional)',
|
||||
'systemManager.tmux.newSessionCommandHint': 'Déjalo vacío para una sesión de shell predeterminada.',
|
||||
'systemManager.tmux.creating': 'Creando…',
|
||||
'systemManager.tmux.newSessionPlaceholder': 'mi-sesion',
|
||||
'systemManager.tmux.newSessionRequired': 'Primero ingresa un nombre de sesión',
|
||||
'systemManager.tmux.empty': 'No hay sesiones de tmux',
|
||||
'systemManager.tmux.attach': 'Conectar',
|
||||
'systemManager.tmux.attached': 'Conectada',
|
||||
'systemManager.tmux.detached': 'Desacoplada',
|
||||
'systemManager.tmux.windows': '{{count}} ventana(s)',
|
||||
'systemManager.tmux.created': 'Creada',
|
||||
'systemManager.tmux.activity': 'Actividad',
|
||||
'systemManager.tmux.rename': 'Renombrar',
|
||||
'systemManager.tmux.detach': 'Desacoplar todo',
|
||||
'systemManager.tmux.killSession': 'Terminar sesión',
|
||||
'systemManager.tmux.killServer': 'Terminar servidor',
|
||||
'systemManager.tmux.loadingDetails': 'Cargando detalles…',
|
||||
'systemManager.tmux.clients': 'Clientes conectados',
|
||||
'systemManager.tmux.windowList': 'Ventanas',
|
||||
'systemManager.tmux.newWindow': 'Nueva ventana',
|
||||
'systemManager.tmux.newWindowPlaceholder': 'Nombre de la ventana (opcional)',
|
||||
'systemManager.tmux.noWindows': 'No hay ventanas',
|
||||
'systemManager.tmux.unavailable': 'tmux no está disponible en este host',
|
||||
'systemManager.docker.unavailable': 'Docker no está disponible en este host',
|
||||
'systemManager.tmux.windowsMismatch': 'La sesión reporta {{count}} ventana(s), pero list-windows no devolvió ninguna',
|
||||
'systemManager.tmux.lastCommand': 'último comando: {{command}}',
|
||||
'systemManager.tmux.noPanes': 'No hay paneles',
|
||||
'systemManager.tmux.panes': '{{count}} panel(es)',
|
||||
'systemManager.tmux.active': 'activo',
|
||||
'systemManager.tmux.unnamedWindow': 'Ventana sin nombre',
|
||||
'systemManager.tmux.unnamedPane': 'Panel sin nombre',
|
||||
'systemManager.tmux.attachWindow': 'Conectar a la ventana',
|
||||
'systemManager.tmux.selectWindow': 'Seleccionar ventana',
|
||||
'systemManager.tmux.killWindow': 'Terminar ventana',
|
||||
'systemManager.tmux.killPane': 'Terminar panel',
|
||||
'systemManager.tmux.splitHorizontal': 'Dividir horizontal',
|
||||
'systemManager.tmux.splitVertical': 'Dividir vertical',
|
||||
'systemManager.tmux.sendKeys': 'Enviar teclas',
|
||||
'systemManager.tmux.sendKeysTo': 'Enviar teclas a la ventana {{window}} panel {{pane}}',
|
||||
'systemManager.tmux.sendKeysPlaceholder': 'Comando o texto…',
|
||||
'systemManager.tmux.renameSessionPrompt': 'Renombrar sesión',
|
||||
'systemManager.tmux.renameWindowPrompt': 'Renombrar ventana',
|
||||
'systemManager.tmux.windowName': 'Nombre de la ventana',
|
||||
'systemManager.tmux.confirmKillSession': '¿Terminar la sesión de tmux "{{name}}"?',
|
||||
'systemManager.tmux.confirmDetachSession': '¿Desacoplar todos los clientes de "{{name}}"?',
|
||||
'systemManager.tmux.confirmKillWindow': '¿Terminar la ventana "{{name}}"?',
|
||||
'systemManager.tmux.confirmKillPane': '¿Terminar el panel #{{index}}?',
|
||||
'systemManager.tmux.confirmKillServer': '¿Terminar el servidor de tmux? Se terminarán todas las sesiones.',
|
||||
'systemManager.tmux.meta': '{{count}} sesión(es)',
|
||||
|
||||
'systemManager.docker.title': 'Contenedores',
|
||||
'systemManager.docker.subTabs.containers': 'Contenedores',
|
||||
'systemManager.docker.subTabs.images': 'Imágenes',
|
||||
'systemManager.docker.empty': 'No se encontraron contenedores',
|
||||
'systemManager.docker.imagesEmpty': 'No se encontraron imágenes',
|
||||
'systemManager.docker.search': 'Buscar contenedores…',
|
||||
'systemManager.docker.searchImages': 'Buscar imágenes…',
|
||||
'systemManager.docker.filter.all': 'Todos',
|
||||
'systemManager.docker.filter.running': 'En ejecución',
|
||||
'systemManager.docker.filter.stopped': 'Detenidos',
|
||||
'systemManager.docker.filter.paused': 'En pausa',
|
||||
'systemManager.docker.shell': 'Shell',
|
||||
'systemManager.docker.logs': 'Registros',
|
||||
'systemManager.docker.details': 'Detalles',
|
||||
'systemManager.docker.inspect': 'Inspeccionar',
|
||||
'systemManager.docker.imageInspect': 'Inspección de imagen',
|
||||
'systemManager.docker.confirmRemove': '¿Eliminar este contenedor?',
|
||||
'systemManager.docker.confirmKill': '¿Terminar por la fuerza este contenedor?',
|
||||
'systemManager.docker.confirmRemoveImage': '¿Eliminar la imagen "{{name}}"?',
|
||||
'systemManager.docker.confirmPrune': '¿Eliminar las imágenes colgantes?',
|
||||
'systemManager.docker.confirmPruneAll': '¿Eliminar todas las imágenes no utilizadas?',
|
||||
'systemManager.docker.pause': 'Pausar',
|
||||
'systemManager.docker.unpause': 'Reanudar',
|
||||
'systemManager.docker.restart': 'Reiniciar',
|
||||
'systemManager.docker.kill': 'Terminar',
|
||||
'systemManager.docker.renamePrompt': 'Nombre del contenedor',
|
||||
'systemManager.docker.prune': 'Limpiar',
|
||||
'systemManager.docker.pruneAll': 'Limpiar todo',
|
||||
'systemManager.docker.tag': 'Etiqueta',
|
||||
'systemManager.docker.tagRepoPrompt': 'Nombre del repositorio',
|
||||
'systemManager.docker.tagNamePrompt': 'Nombre de la etiqueta',
|
||||
'systemManager.docker.meta': '{{count}} contenedor(es)',
|
||||
'systemManager.docker.imagesMeta': '{{count}} imagen(es)',
|
||||
'systemManager.docker.start': 'Iniciar',
|
||||
'systemManager.docker.stop': 'Detener',
|
||||
|
||||
'systemManager.ports.unavailable': 'No se detectaron herramientas de puertos en escucha (ss / netstat) en este host.',
|
||||
'systemManager.ports.loading': 'Cargando puertos en escucha…',
|
||||
'systemManager.ports.empty': 'No se reportaron puertos en escucha.',
|
||||
'systemManager.ports.search': 'Buscar puertos…',
|
||||
'systemManager.ports.meta': '{{count}} en escucha',
|
||||
'systemManager.ports.filter.all': 'Todos',
|
||||
'systemManager.ports.unknownProcess': 'Proceso desconocido',
|
||||
'systemManager.ports.terminate': 'Terminar',
|
||||
'systemManager.ports.confirmTerminate': '¿Enviar SIGTERM al proceso {{pid}} que ocupa este puerto?',
|
||||
|
||||
'systemManager.services.unavailable': 'systemctl no está disponible en este host.',
|
||||
'systemManager.services.loading': 'Cargando servicios de systemd…',
|
||||
'systemManager.services.empty': 'No se encontraron servicios de systemd.',
|
||||
'systemManager.services.search': 'Buscar servicios…',
|
||||
'systemManager.services.meta': '{{count}} servicio(s)',
|
||||
'systemManager.services.filter.all': 'Todos',
|
||||
'systemManager.services.filter.running': 'En ejecución',
|
||||
'systemManager.services.filter.failed': 'Con error',
|
||||
'systemManager.services.filter.inactive': 'Inactivo',
|
||||
'systemManager.services.start': 'Iniciar',
|
||||
'systemManager.services.stop': 'Detener',
|
||||
'systemManager.services.restart': 'Reiniciar',
|
||||
'systemManager.services.enable': 'Habilitar',
|
||||
'systemManager.services.disable': 'Deshabilitar',
|
||||
'systemManager.services.reload': 'Recargar',
|
||||
'systemManager.services.scope.user': 'usuario',
|
||||
'systemManager.services.confirmAction': '¿{{action}} {{name}}?',
|
||||
|
||||
'systemManager.gpu.unavailable': 'No se detectaron herramientas de GPU NVIDIA o NPU Ascend en este host.',
|
||||
'systemManager.gpu.loading': 'Cargando estadísticas del acelerador…',
|
||||
'systemManager.gpu.empty': 'Las herramientas del acelerador están presentes, pero no se reportó ningún dispositivo.',
|
||||
'systemManager.gpu.meta': '{{devices}} dispositivo(s) · {{processes}} proceso(s)',
|
||||
'systemManager.gpu.devices': 'Dispositivos',
|
||||
'systemManager.gpu.processes': 'Procesos de cómputo',
|
||||
'systemManager.gpu.noProcesses': 'No se reportaron procesos de cómputo.',
|
||||
'systemManager.gpu.vendor.nvidia': 'NVIDIA',
|
||||
'systemManager.gpu.vendor.ascend': 'Ascend',
|
||||
'systemManager.gpu.util': 'Util',
|
||||
'systemManager.gpu.memory': 'VRAM',
|
||||
'systemManager.gpu.hbm': 'HBM',
|
||||
'systemManager.gpu.temperature': 'Temperatura',
|
||||
'systemManager.gpu.power': 'Energía',
|
||||
'systemManager.gpu.fan': 'Ventilador {{value}}%',
|
||||
'systemManager.gpu.driver': 'Controlador {{version}}',
|
||||
|
||||
'systemManager.inspect.status': 'Estado',
|
||||
'systemManager.inspect.image': 'Imagen',
|
||||
'systemManager.inspect.created': 'Creado',
|
||||
'systemManager.inspect.started': 'Iniciado',
|
||||
'systemManager.inspect.restartPolicy': 'Política de reinicio',
|
||||
'systemManager.inspect.command': 'Comando',
|
||||
'systemManager.inspect.ports': 'Puertos',
|
||||
'systemManager.inspect.networks': 'Redes',
|
||||
'systemManager.inspect.mounts': 'Montajes',
|
||||
'systemManager.inspect.env': 'Entorno',
|
||||
'systemManager.inspect.labels': 'Etiquetas',
|
||||
'systemManager.inspect.tags': 'Etiquetas',
|
||||
'systemManager.inspect.digests': 'Digests',
|
||||
'systemManager.inspect.size': 'Tamaño',
|
||||
'systemManager.inspect.platform': 'Plataforma',
|
||||
'systemManager.inspect.workdir': 'Directorio de trabajo',
|
||||
'systemManager.inspect.exposedPorts': 'Puertos expuestos',
|
||||
'systemManager.inspect.showRaw': 'JSON',
|
||||
'systemManager.inspect.hideRaw': 'Ocultar JSON',
|
||||
};
|
||||
@@ -0,0 +1,843 @@
|
||||
import type { Messages } from '../types';
|
||||
|
||||
export const esTerminalMessages: Messages = {
|
||||
'terminal.sudoHint.pressEnter': 'Presiona Enter para pegar la contraseña guardada',
|
||||
'terminal.passwordPicker.title': 'Contraseñas guardadas',
|
||||
'terminal.passwordPicker.empty': 'No hay contraseñas guardadas',
|
||||
// Network Device Mode auto-detection tip (session header)
|
||||
'terminal.networkDevice.tip.message': 'Esto parece un dispositivo de red. Activa el Modo de Dispositivo de Red para enviar comandos tal cual (sin envoltorio de shell).',
|
||||
'terminal.networkDevice.tip.action': 'Activar',
|
||||
'terminal.networkDevice.tip.dismiss': 'Descartar',
|
||||
'terminal.networkDevice.tip.enabled': 'Modo de Dispositivo de Red activado para {host}',
|
||||
// Terminal toolbar / search / context menu / auth
|
||||
'terminal.toolbar.openSftp': 'Abrir SFTP',
|
||||
'terminal.toolbar.availableAfterConnect': 'Disponible después de conectar',
|
||||
'terminal.toolbar.sendYmodem': 'Enviar con YMODEM',
|
||||
'terminal.toolbar.receiveYmodem': 'Recibir con YMODEM',
|
||||
'terminal.toolbar.sftp': 'SFTP',
|
||||
'terminal.toolbar.more': 'Más acciones',
|
||||
'terminal.toolbar.scripts': 'Scripts',
|
||||
'terminal.toolbar.history': 'Historial de comandos',
|
||||
'terminal.toolbar.configureOsc7': 'Configurar seguimiento de directorio',
|
||||
'history.scope.label': 'Alcance del historial',
|
||||
'history.tab.host': 'Host',
|
||||
'history.tab.global': 'Global',
|
||||
'history.searchPlaceholder': 'Buscar en el historial...',
|
||||
'history.loading': 'Cargando historial remoto...',
|
||||
'history.meta.count': '{count} comandos',
|
||||
'history.empty.noSession': 'Abre una sesión remota para ver su historial de comandos.',
|
||||
'history.empty.unsupportedProtocol': 'El historial de comandos solo está disponible para sesiones SSH/Mosh/ET.',
|
||||
'history.empty.noHistory': 'No se encontró historial de comandos en este host.',
|
||||
'history.empty.noGlobalHistory': 'Aún no hay historial global de comandos. Los comandos que ejecutes aparecerán aquí.',
|
||||
'history.action.refresh': 'Actualizar',
|
||||
'history.action.retry': 'Reintentar',
|
||||
'history.action.paste': 'Pegar en la terminal',
|
||||
'history.action.run': 'Ejecutar en la terminal',
|
||||
'history.action.saveAsSnippet': 'Guardar como snippet',
|
||||
'history.action.delete': 'Eliminar del historial',
|
||||
'terminal.toolbar.library': 'Biblioteca',
|
||||
'terminal.toolbar.noSnippets': 'No hay snippets disponibles',
|
||||
'terminal.toolbar.terminalSettings': 'Configuración de la terminal',
|
||||
'terminal.toolbar.searchTerminal': 'Buscar en la terminal',
|
||||
'terminal.toolbar.search': 'Buscar',
|
||||
'terminal.toolbar.startSessionLog': 'Iniciar registro de sesión',
|
||||
'terminal.toolbar.stopSessionLog': 'Detener registro de sesión',
|
||||
'terminal.toolbar.timestampsEnable': 'Mostrar marcas de tiempo',
|
||||
'terminal.toolbar.timestampsDisable': 'Ocultar marcas de tiempo',
|
||||
'terminal.toolbar.broadcast': 'Difundir',
|
||||
'terminal.toolbar.broadcastEnable': 'Activar Modo de Difusión',
|
||||
'terminal.toolbar.broadcastDisable': 'Desactivar Modo de Difusión',
|
||||
'terminal.toolbar.composeBar': 'Barra de redacción',
|
||||
'terminal.composeBar.placeholder': 'Escribe el comando aquí y presiona Enter para enviar...',
|
||||
'terminal.composeBar.send': 'Enviar',
|
||||
'terminal.composeBar.close': 'Cerrar barra de redacción',
|
||||
'terminal.composeBar.broadcasting': 'Difundiendo a todas las sesiones',
|
||||
'terminal.composeBar.resize': 'Cambiar la altura de la barra de redacción',
|
||||
'terminal.composeBar.manageSnippets': 'Administrar snippets rápidos',
|
||||
'terminal.composeBar.searchSnippets': 'Buscar snippets...',
|
||||
'terminal.composeBar.noPinnedSnippets': 'Fija snippets con + para acceso rápido',
|
||||
'terminal.composeBar.noMatchingSnippets': 'No hay snippets que coincidan',
|
||||
'terminal.composeBar.pinnedCount': '{count} fijados',
|
||||
'terminal.composeBar.unpinSnippet': 'Quitar {label} de la barra rápida',
|
||||
'terminal.composeBar.snippetClickHint': 'Clic para insertar · Shift+Clic para enviar',
|
||||
'terminal.toolbar.focus': 'Enfocar',
|
||||
'terminal.toolbar.focusMode': 'Modo de Enfoque',
|
||||
'terminal.toolbar.detach': 'Desacoplar a pestaña independiente',
|
||||
'terminal.toolbar.dragPane': 'Arrastrar panel de terminal',
|
||||
'terminal.toolbar.showActions': 'Mostrar acciones de la terminal',
|
||||
'terminal.toolbar.encoding': 'Codificación de la terminal',
|
||||
'terminal.toolbar.encoding.utf8': 'UTF-8',
|
||||
'terminal.toolbar.encoding.gb18030': 'GB18030',
|
||||
'terminal.toolbar.closeSession': 'Cerrar sesión',
|
||||
'terminal.toolbar.hostHighlight.title': 'Resaltado de palabras clave del host',
|
||||
'terminal.toolbar.hostHighlight.noRules': 'No hay reglas de resaltado personalizadas definidas para este host',
|
||||
'terminal.toolbar.hostHighlight.addRule': 'Agregar nueva regla',
|
||||
'terminal.toolbar.hostHighlight.labelPlaceholder': 'Etiqueta (p. ej., Error)',
|
||||
'terminal.toolbar.hostHighlight.patternPlaceholder': 'Patrón regex (p. ej., \\bfailed\\b)',
|
||||
'terminal.toolbar.hostHighlight.invalidPattern': 'Patrón regex no válido',
|
||||
'terminal.toolbar.hostHighlight.clearAll': 'Borrar todo',
|
||||
'terminal.toolbar.hostHighlight.changeColor': 'Cambiar el color de resaltado de',
|
||||
'terminal.toolbar.hostHighlight.selectColor': 'Seleccionar el color de la nueva regla',
|
||||
'terminal.statusbar.copyHostname.label': 'Copiar dirección del host',
|
||||
'terminal.statusbar.copyHostname.tooltip': 'Copiar dirección del host ({hostname})',
|
||||
'terminal.statusbar.copyHostname.toast': 'Dirección del host copiada: {hostname}',
|
||||
'terminal.statusbar.copyHostname.error': 'No se pudo copiar la dirección del host al portapapeles',
|
||||
'terminal.statusbar.disconnect.label': 'Desconectar',
|
||||
'terminal.statusbar.disconnect.tooltip': 'Desconectar esta sesión sin cerrar la pestaña',
|
||||
'terminal.statusbar.reconnect.label': 'Reconectar',
|
||||
'terminal.statusbar.reconnect.tooltip': 'Reconectar esta sesión',
|
||||
'terminal.serverStats.cpu': 'Uso de CPU',
|
||||
'terminal.serverStats.cpuCores': 'Uso de núcleos de CPU',
|
||||
'terminal.serverStats.memory': 'Uso de memoria',
|
||||
'terminal.serverStats.memoryDetails': 'Detalles de memoria',
|
||||
'terminal.serverStats.memUsed': 'Usada',
|
||||
'terminal.serverStats.memBuffers': 'Búfers',
|
||||
'terminal.serverStats.memCached': 'Caché',
|
||||
'terminal.serverStats.memFree': 'Libre',
|
||||
'terminal.serverStats.swap': 'Swap',
|
||||
'terminal.serverStats.swapUsed': 'Swap usada',
|
||||
'terminal.serverStats.swapFree': 'Swap libre',
|
||||
'terminal.serverStats.swapTotal': 'Total',
|
||||
'terminal.serverStats.topProcesses': 'Principales procesos por memoria',
|
||||
'terminal.serverStats.disk': 'Uso de disco',
|
||||
'terminal.serverStats.diskDetails': 'Discos montados',
|
||||
'terminal.serverStats.network': 'Velocidad de red',
|
||||
'terminal.serverStats.latency': 'Latencia de red SSH',
|
||||
'terminal.serverStats.networkDetails': 'Interfaces de red',
|
||||
'terminal.serverStats.noData': 'No hay datos disponibles',
|
||||
'terminal.dragDrop.localTitle': 'Soltar para insertar rutas',
|
||||
'terminal.dragDrop.localMessage': 'Las rutas de los archivos se insertarán en la terminal',
|
||||
'terminal.dragDrop.remoteTitle': 'Soltar para subir archivos',
|
||||
'terminal.dragDrop.remoteZmodemMessage': 'Los archivos se subirán mediante ZMODEM (PTY)',
|
||||
'terminal.dragDrop.remoteSftpMessage': 'Los archivos se subirán mediante SFTP',
|
||||
'terminal.dragDrop.noFiles': 'No hay archivos para subir',
|
||||
'terminal.dragDrop.notConnected': 'No se pueden soltar archivos: la terminal no está conectada',
|
||||
'terminal.dragDrop.errorTitle': 'Error al soltar',
|
||||
'terminal.dragDrop.errorMessage': 'No se pudieron procesar los archivos soltados',
|
||||
'terminal.search.placeholder': 'Buscar...',
|
||||
'terminal.search.noResults': 'Sin resultados',
|
||||
'terminal.search.prevMatch': 'Coincidencia anterior (Shift+Enter)',
|
||||
'terminal.search.nextMatch': 'Coincidencia siguiente (Enter)',
|
||||
'terminal.menu.copy': 'Copiar',
|
||||
'terminal.menu.paste': 'Pegar',
|
||||
'terminal.menu.uploadClipboardImage': 'Subir imagen del portapapeles',
|
||||
'terminal.menu.addSelectionToAI': 'Agregar a la conversación',
|
||||
'terminal.menu.pasteSelection': 'Pegar selección',
|
||||
'terminal.menu.selectAll': 'Seleccionar todo',
|
||||
'terminal.menu.reconnect': 'Reconectar',
|
||||
'terminal.menu.sendYmodem': 'Enviar con YMODEM',
|
||||
'terminal.menu.receiveYmodem': 'Recibir con YMODEM',
|
||||
'terminal.menu.splitHorizontal': 'Dividir en horizontal',
|
||||
'terminal.menu.splitVertical': 'Dividir en vertical',
|
||||
'terminal.menu.clearBuffer': 'Limpiar búfer',
|
||||
'terminal.menu.closeTerminal': 'Cerrar terminal',
|
||||
'terminal.menu.rename': 'Renombrar',
|
||||
'terminal.menu.detach': 'Desacoplar del espacio de trabajo',
|
||||
'terminal.menu.detachSession': 'Desacoplar {name}',
|
||||
'terminal.clipboardImageUpload.noImage': 'El portapapeles no contiene una imagen',
|
||||
'terminal.clipboardImageUpload.failed': 'No se pudo subir la imagen del portapapeles',
|
||||
'terminal.osc7Setup.title': 'Configurar seguimiento de directorio',
|
||||
'terminal.osc7Setup.desc': 'Netcatty agregará hooks de prompt OSC 7 para el usuario remoto actual. Esto ayuda a que SFTP siga el directorio de la terminal después de sudo o su.',
|
||||
'terminal.osc7Setup.targets': 'Posibles archivos a actualizar',
|
||||
'terminal.osc7Setup.command': 'Comando a ejecutar',
|
||||
'terminal.osc7Setup.run': 'Ejecutar configuración',
|
||||
'terminal.osc7Setup.running': 'Configurando...',
|
||||
'terminal.osc7Setup.configured': 'Seguimiento de directorio configurado',
|
||||
'terminal.osc7Setup.failed': 'Falló la configuración del seguimiento de directorio',
|
||||
'terminal.osc7Setup.sent': 'Configuración de seguimiento de directorio enviada a la terminal',
|
||||
'terminal.ymodem.selectFile': 'Selecciona el archivo a enviar',
|
||||
'terminal.ymodem.allFiles': 'Todos los archivos',
|
||||
'terminal.ymodem.started': 'YMODEM enviando {fileName}',
|
||||
'terminal.ymodem.complete': 'YMODEM envió {fileName}',
|
||||
'terminal.ymodem.failed': 'Falló el envío con YMODEM',
|
||||
'terminal.ymodem.selectReceiveDirectory': 'Selecciona la carpeta para guardar los archivos recibidos',
|
||||
'terminal.ymodem.receiveStarted': 'YMODEM recibiendo...',
|
||||
'terminal.ymodem.receiveComplete': 'YMODEM recibió {fileName}',
|
||||
'terminal.ymodem.receiveCompleteMultiple': 'YMODEM recibió {count} archivos',
|
||||
'terminal.ymodem.receiveEmpty': 'No se recibieron archivos YMODEM',
|
||||
'terminal.ymodem.receiveFailed': 'Falló la recepción con YMODEM',
|
||||
'terminal.ymodem.unavailable': 'YMODEM no está disponible',
|
||||
'terminal.selection.addToAI': 'Agregar a la conversación',
|
||||
'terminal.selection.addToAIDesc': 'Adjuntar la salida seleccionada de la terminal al borrador de IA',
|
||||
'terminal.auth.password': 'Contraseña',
|
||||
'terminal.auth.sshKey': 'Clave SSH',
|
||||
'terminal.auth.username': 'Nombre de usuario',
|
||||
'terminal.auth.username.placeholder': 'root',
|
||||
'terminal.auth.passwordLabel': 'Contraseña',
|
||||
'terminal.auth.password.placeholder': 'Ingresa la contraseña',
|
||||
'terminal.auth.passphrase': 'Frase de contraseña',
|
||||
'terminal.auth.passphrase.placeholder': 'Frase de contraseña opcional para la clave privada seleccionada',
|
||||
'terminal.auth.certificate': 'Certificado',
|
||||
'terminal.auth.selectKey': 'Seleccionar clave',
|
||||
'terminal.auth.retryMessage': 'Falló la autenticación. Verifica tus credenciales e inténtalo de nuevo.',
|
||||
'terminal.auth.retryLog': 'Falló la autenticación. Inténtalo de nuevo.',
|
||||
'terminal.auth.noKeysHint': 'No hay claves disponibles. Agrega claves en Keychain.',
|
||||
'terminal.auth.continueSave': 'Continuar y guardar',
|
||||
'terminal.auth.credentialsUnavailable': 'Las credenciales guardadas no se pueden descifrar en este dispositivo. Vuelve a ingresarlas y guárdalas de nuevo.',
|
||||
'terminal.auth.jumpCredentialsUnavailable': 'Un host de salto tiene credenciales guardadas que no se pueden descifrar en este dispositivo. Abre la configuración del host y vuelve a ingresarlas.',
|
||||
'terminal.auth.proxyCredentialsUnavailable': 'Las credenciales del proxy no se pueden descifrar en este dispositivo. Abre la configuración del host y vuelve a ingresar la contraseña del proxy.',
|
||||
'terminal.auth.keyUnavailableFallbackPassword': 'La clave SSH guardada no está disponible en este dispositivo. Se usará autenticación por contraseña como respaldo.',
|
||||
'terminal.progress.timeoutIn': 'Tiempo de espera en {seconds}s',
|
||||
'terminal.progress.waitingForUserInput': 'Esperando entrada del usuario',
|
||||
'terminal.progress.disconnected': 'Desconectado',
|
||||
'terminal.progress.cancelling': 'Cancelando...',
|
||||
'terminal.progress.startOver': 'Empezar de nuevo',
|
||||
'terminal.progress.enterReconnectHint': 'Presiona Enter para reconectar',
|
||||
'terminal.progress.autoReconnectScheduled': 'Conexión perdida. Reconectando en {seconds}s (intento {attempt}).',
|
||||
'terminal.progress.autoReconnectAttempt': 'Intento de reconexión automática {attempt}...',
|
||||
'terminal.connection.dismissDisconnectedDialog': 'Descartar aviso de desconexión',
|
||||
'terminal.connection.chainOf': 'Cadena {current} de {total}',
|
||||
'terminal.connection.showLogs': 'Mostrar registros',
|
||||
'terminal.connection.hideLogs': 'Ocultar registros',
|
||||
'terminal.connection.protocol.ssh': 'SSH',
|
||||
'terminal.connection.protocol.telnet': 'Telnet',
|
||||
'terminal.connection.protocol.mosh': 'Mosh',
|
||||
'terminal.connection.protocol.et': 'EternalTerminal',
|
||||
'terminal.connection.protocol.plugin': 'Conexión de plugin',
|
||||
'terminal.et.proxyUnsupported': 'EternalTerminal no admite actualmente la configuración de proxy de Netcatty. Usa SSH o quita el proxy de este host.',
|
||||
'terminal.et.multiJumpUnsupported': 'EternalTerminal admite actualmente como máximo un host de salto en Netcatty.',
|
||||
'terminal.connection.protocol.serial': 'Serial',
|
||||
'terminal.connection.protocol.local': 'Shell local',
|
||||
'terminal.hostKey.unknownTitle': 'Confirmar esta clave de host',
|
||||
'terminal.hostKey.changedTitle': 'La clave del host cambió',
|
||||
'terminal.hostKey.unknownDescription': 'Aún no se puede establecer la autenticidad de {host}.',
|
||||
'terminal.hostKey.changedDescription': 'La clave guardada de {host} ya no coincide con este servidor.',
|
||||
'terminal.hostKey.fingerprintLabel': 'La huella digital de {keyType} es SHA256:',
|
||||
'terminal.hostKey.savedFingerprintLabel': 'Huella guardada',
|
||||
'terminal.hostKey.unknownHint': 'Recuérdala si esta huella pertenece al servidor que esperabas.',
|
||||
'terminal.hostKey.changedHint': 'Continúa solo si esperabas que este host cambiara.',
|
||||
'terminal.hostKey.addAndContinue': 'Agregar y continuar',
|
||||
'terminal.hostKey.updateAndContinue': 'Actualizar y continuar',
|
||||
'terminal.themeModal.title': 'Apariencia de la terminal',
|
||||
'terminal.themeModal.tab.theme': 'Tema',
|
||||
'terminal.themeModal.tab.font': 'Fuente',
|
||||
'terminal.themeModal.tab.custom': 'Personalizado',
|
||||
'terminal.themeModal.globalTheme': 'Tema global',
|
||||
'terminal.themeModal.globalFont': 'Fuente global',
|
||||
'terminal.themeModal.fontSize': 'Tamaño de fuente',
|
||||
'terminal.themeModal.fontWeight': 'Grosor de fuente',
|
||||
'terminal.themeModal.livePreview': 'Vista previa en vivo',
|
||||
'terminal.themeModal.themeType': 'Tema {type}',
|
||||
'terminal.hiddenTheme.title': 'Tema oculto actual',
|
||||
'terminal.hiddenTheme.desc': 'Este tema está oculto de la selección manual y se reemplazará cuando elijas otro tema.',
|
||||
'topTabs.toggleTheme.systemExitTitle': 'El tema del sistema está activo',
|
||||
'topTabs.toggleTheme.systemExitMessage': 'Abre Configuración para elegir un tema fijo de Claro u Oscuro.',
|
||||
'topTabs.toggleTheme.openSettings': 'Abrir Configuración',
|
||||
|
||||
// Custom Themes
|
||||
'terminal.customTheme.section': 'Temas personalizados',
|
||||
'terminal.customTheme.yourThemes': 'Tus temas',
|
||||
'terminal.customTheme.new': 'Nuevo tema',
|
||||
'terminal.customTheme.newDesc': 'Clonar el tema actual y personalizarlo',
|
||||
'terminal.customTheme.newTitle': 'Nuevo tema personalizado',
|
||||
'terminal.customTheme.editTitle': 'Editar tema',
|
||||
'terminal.customTheme.import': 'Importar .itermcolors',
|
||||
'terminal.customTheme.importDesc': 'Importar desde archivo de esquema de color de iTerm2',
|
||||
'terminal.customTheme.importError': 'No se pudo analizar el archivo seleccionado. Asegúrate de que sea un archivo XML .itermcolors válido.',
|
||||
'terminal.customTheme.delete': 'Eliminar tema',
|
||||
'terminal.customTheme.confirmDelete': 'Confirmar eliminación',
|
||||
'terminal.customTheme.name': 'Nombre',
|
||||
'terminal.customTheme.namePlaceholder': 'Mi tema personalizado',
|
||||
'terminal.customTheme.type': 'Tipo',
|
||||
'terminal.customTheme.group.general': 'General',
|
||||
'terminal.customTheme.group.normal': 'Colores normales',
|
||||
'terminal.customTheme.group.bright': 'Colores brillantes',
|
||||
'terminal.customTheme.color.background': 'Fondo',
|
||||
'terminal.customTheme.color.foreground': 'Primer plano',
|
||||
'terminal.customTheme.color.cursor': 'Cursor',
|
||||
'terminal.customTheme.color.selection': 'Selección',
|
||||
'terminal.customTheme.color.black': 'Negro',
|
||||
'terminal.customTheme.color.red': 'Rojo',
|
||||
'terminal.customTheme.color.green': 'Verde',
|
||||
'terminal.customTheme.color.yellow': 'Amarillo',
|
||||
'terminal.customTheme.color.blue': 'Azul',
|
||||
'terminal.customTheme.color.magenta': 'Magenta',
|
||||
'terminal.customTheme.color.cyan': 'Cian',
|
||||
'terminal.customTheme.color.white': 'Blanco',
|
||||
'terminal.customTheme.color.brightBlack': 'Negro brillante',
|
||||
'terminal.customTheme.color.brightRed': 'Rojo brillante',
|
||||
'terminal.customTheme.color.brightGreen': 'Verde brillante',
|
||||
'terminal.customTheme.color.brightYellow': 'Amarillo brillante',
|
||||
'terminal.customTheme.color.brightBlue': 'Azul brillante',
|
||||
'terminal.customTheme.color.brightMagenta': 'Magenta brillante',
|
||||
'terminal.customTheme.color.brightCyan': 'Cian brillante',
|
||||
'terminal.customTheme.color.brightWhite': 'Blanco brillante',
|
||||
|
||||
// Cloud Sync Settings
|
||||
'cloudSync.gate.title': 'Sincronización cifrada de extremo a extremo',
|
||||
'cloudSync.gate.desc':
|
||||
'Tus datos se cifran localmente antes de sincronizarse. Los proveedores de la nube nunca ven tus datos en texto plano. Configura una clave maestra para habilitar la sincronización segura.',
|
||||
'cloudSync.gate.masterKey': 'Clave maestra',
|
||||
'cloudSync.gate.confirmMasterKey': 'Confirmar clave maestra',
|
||||
'cloudSync.gate.placeholder': 'Ingresa una contraseña segura',
|
||||
'cloudSync.gate.confirmPlaceholder': 'Confirma tu contraseña',
|
||||
'cloudSync.gate.mismatch': 'Las contraseñas no coinciden',
|
||||
'cloudSync.gate.warning':
|
||||
'Entiendo que, si olvido mi clave maestra, mis datos no se podrán recuperar. No hay restablecimiento de contraseña.',
|
||||
'cloudSync.gate.enableVault': 'Habilitar bóveda cifrada',
|
||||
'cloudSync.gate.enabledToast': 'Bóveda cifrada habilitada',
|
||||
'cloudSync.gate.setupFailed': 'No se pudo configurar la clave maestra',
|
||||
'cloudSync.passwordStrength.tooShort': 'Demasiado corta',
|
||||
'cloudSync.passwordStrength.weak': 'Débil',
|
||||
'cloudSync.passwordStrength.moderate': 'Moderada',
|
||||
'cloudSync.passwordStrength.strong': 'Segura',
|
||||
'cloudSync.passwordStrength.veryStrong': 'Muy segura',
|
||||
'cloudSync.provider.notConnected': 'No conectado',
|
||||
'cloudSync.provider.sync': 'Sincronizar',
|
||||
'cloudSync.provider.connect': 'Conectar',
|
||||
'cloudSync.provider.connecting': 'Conectando...',
|
||||
'cloudSync.provider.webdav': 'WebDAV',
|
||||
'cloudSync.provider.webdav.desc': 'Conectar a un endpoint WebDAV autoalojado',
|
||||
'cloudSync.provider.s3': 'Compatible con S3',
|
||||
'cloudSync.provider.s3.desc': 'Conectar a almacenamiento de objetos compatible con S3',
|
||||
'cloudSync.provider.comingSoon': 'Próximamente',
|
||||
'cloudSync.webdav.title': 'Configuración de WebDAV',
|
||||
'cloudSync.webdav.desc': 'Configura un endpoint WebDAV para la sincronización cifrada.',
|
||||
'cloudSync.webdav.endpoint': 'URL del endpoint',
|
||||
'cloudSync.webdav.authType': 'Tipo de autenticación',
|
||||
'cloudSync.webdav.auth.basic': 'Básica',
|
||||
'cloudSync.webdav.auth.digest': 'Digest',
|
||||
'cloudSync.webdav.auth.token': 'Token',
|
||||
'cloudSync.webdav.username': 'Nombre de usuario',
|
||||
'cloudSync.webdav.password': 'Contraseña',
|
||||
'cloudSync.webdav.token': 'Token',
|
||||
'cloudSync.webdav.showSecret': 'Mostrar secreto',
|
||||
'cloudSync.webdav.allowInsecure': 'Permitir conexión insegura (ignorar errores de certificado)',
|
||||
'cloudSync.webdav.validation.endpoint': 'Ingresa un endpoint WebDAV válido.',
|
||||
'cloudSync.webdav.validation.credentials': 'El nombre de usuario y la contraseña son obligatorios.',
|
||||
'cloudSync.webdav.validation.token': 'El token es obligatorio.',
|
||||
'cloudSync.s3.title': 'Configuración de S3',
|
||||
'cloudSync.s3.desc': 'Conéctate al almacenamiento de objetos compatible con S3 para la sincronización cifrada.',
|
||||
'cloudSync.s3.endpoint': 'URL del endpoint',
|
||||
'cloudSync.s3.region': 'Región',
|
||||
'cloudSync.s3.bucket': 'Bucket',
|
||||
'cloudSync.s3.accessKeyId': 'ID de clave de acceso',
|
||||
'cloudSync.s3.secretAccessKey': 'Clave de acceso secreta',
|
||||
'cloudSync.s3.sessionToken': 'Token de sesión (opcional)',
|
||||
'cloudSync.s3.prefix': 'Prefijo de clave (opcional)',
|
||||
'cloudSync.s3.forcePathStyle': 'Forzar URLs con estilo de ruta (para MinIO/R2, etc.)',
|
||||
'cloudSync.s3.allowInsecure': 'Permitir conexión insegura (ignorar errores de certificado)',
|
||||
'cloudSync.s3.showSecret': 'Mostrar secretos',
|
||||
'cloudSync.s3.validation.required': 'Endpoint, región, bucket, clave de acceso y secreto son obligatorios.',
|
||||
'cloudSync.smb.title': 'Configuración de SMB',
|
||||
'cloudSync.smb.desc': 'Conéctate a un recurso compartido de archivos SMB/CIFS para la sincronización cifrada.',
|
||||
'cloudSync.smb.share': 'Ruta del recurso compartido',
|
||||
'cloudSync.smb.username': 'Nombre de usuario',
|
||||
'cloudSync.smb.password': 'Contraseña',
|
||||
'cloudSync.smb.domain': 'Dominio (opcional)',
|
||||
'cloudSync.smb.domainPlaceholder': 'p. ej., WORKGROUP',
|
||||
'cloudSync.smb.port': 'Puerto (opcional)',
|
||||
'cloudSync.smb.showSecret': 'Mostrar contraseña',
|
||||
'cloudSync.smb.validation.share': 'La ruta del recurso compartido es obligatoria.',
|
||||
'cloudSync.smb.validation.port': 'El puerto debe ser un número entre 1 y 65535.',
|
||||
'cloudSync.connect.smb.success': 'SMB se conectó correctamente',
|
||||
'cloudSync.connect.smb.failedTitle': 'Falló la conexión a SMB',
|
||||
'cloudSync.provider.smb': 'Recurso compartido SMB',
|
||||
'cloudSync.connect.webdav.success': 'WebDAV se conectó correctamente',
|
||||
'cloudSync.connect.webdav.failedTitle': 'Falló la conexión a WebDAV',
|
||||
'cloudSync.connect.s3.success': 'S3 se conectó correctamente',
|
||||
'cloudSync.connect.s3.failedTitle': 'Falló la conexión a S3',
|
||||
'cloudSync.connect.plugin.success': 'El proveedor de sincronización de plugin se conectó correctamente',
|
||||
'cloudSync.connect.plugin.failedTitle': 'Falló la conexión del plugin de sincronización',
|
||||
'cloudSync.pluginConfig.title': 'Configurar {name}',
|
||||
'cloudSync.pluginConfig.desc': 'Ingresa la configuración JSON requerida por este proveedor de sincronización de plugin.',
|
||||
'cloudSync.pluginConfig.label': 'Configuración del proveedor (JSON)',
|
||||
'cloudSync.pluginConfig.invalidJson': 'La configuración debe ser JSON válido.',
|
||||
'cloudSync.pluginConfig.schemaInvalid': 'La configuración no coincide con el esquema del proveedor.',
|
||||
'cloudSync.lastSync.never': 'Nunca',
|
||||
'cloudSync.lastSync.justNow': 'Justo ahora',
|
||||
'cloudSync.lastSync.minutesAgo': 'Hace {minutes} min',
|
||||
'cloudSync.changeKey': 'Cambiar clave',
|
||||
'cloudSync.providers.title': 'Proveedores de nube',
|
||||
'cloudSync.syncAll': 'Sincronizar todos los proveedores conectados',
|
||||
'cloudSync.autoSync.title': 'Sincronización automática',
|
||||
'cloudSync.autoSync.desc': 'Sincronizar automáticamente cuando se realicen cambios',
|
||||
'cloudSync.strategy.title': 'Estrategia de sincronización',
|
||||
'cloudSync.strategy.desc': 'Elige qué sucede cuando cambian tanto los datos locales como los de la nube.',
|
||||
'cloudSync.strategy.smartMerge': 'Fusión inteligente (recomendada)',
|
||||
'cloudSync.strategy.smartMergeDesc': 'Combina los cambios de ambos lados cuando sea posible; si Netcatty no puede decidir con seguridad, te pedirá que elijas.',
|
||||
'cloudSync.strategy.preferCloud': 'Gana la nube',
|
||||
'cloudSync.strategy.preferCloudDesc': 'Cuando ambos lados cambiaron, descarga la versión de la nube y reemplaza los cambios locales.',
|
||||
'cloudSync.strategy.preferLocal': 'Gana lo local',
|
||||
'cloudSync.strategy.preferLocalDesc': 'Cuando ambos lados cambiaron, sube la versión local y reemplaza los cambios de la nube.',
|
||||
'cloudSync.convergent.title': 'Sincronización convergente de múltiples dispositivos',
|
||||
'cloudSync.convergent.experimental': 'Experimental',
|
||||
'cloudSync.convergent.desc': 'Usa una réplica CRDT cifrada para preservar ediciones sin conexión, eliminaciones concurrentes y cambios de cada proveedor conectado.',
|
||||
'cloudSync.convergent.active': 'CRDT v2 está activo. Las escrituras del proveedor se verifican después de la subida.',
|
||||
'cloudSync.convergent.paused': 'CRDT v2 está en pausa en este dispositivo; los metadatos de la nube se conservan.',
|
||||
'cloudSync.convergent.enabled': 'Sincronización convergente habilitada',
|
||||
'cloudSync.convergent.preview.title': 'Vista previa de migración',
|
||||
'cloudSync.convergent.preview.entities': 'Entidades',
|
||||
'cloudSync.convergent.preview.providers': 'Proveedores',
|
||||
'cloudSync.convergent.preview.conflicts': 'Conflictos',
|
||||
'cloudSync.convergent.preview.compatibility': 'Una instantánea v1 completa permanece en cada payload cifrado para clientes más antiguos.',
|
||||
'cloudSync.convergent.preview.confirm': 'Crear réplica CRDT',
|
||||
'cloudSync.convergent.preview.status.ready': 'Listo',
|
||||
'cloudSync.convergent.preview.status.empty': 'Vacío',
|
||||
'cloudSync.convergent.preview.status.unavailable': 'No disponible',
|
||||
'cloudSync.convergent.preview.status.blocked': 'Bloqueado',
|
||||
'cloudSync.convergent.preview.schema': 'esquema',
|
||||
'cloudSync.convergent.field.presence': 'presencia',
|
||||
'cloudSync.convergent.field.position': 'posición',
|
||||
'cloudSync.convergent.conflicts.title': 'Conflictos de campo ({count})',
|
||||
'cloudSync.convergent.conflict.empty': 'Vacío / eliminado',
|
||||
'cloudSync.convergent.conflict.secretSet': 'El secreto está establecido',
|
||||
'cloudSync.convergent.conflict.current': 'ganador actual',
|
||||
'cloudSync.convergent.conflict.choose': 'Elegir',
|
||||
'cloudSync.convergent.conflict.resolved': 'Conflicto resuelto y sincronizado',
|
||||
'cloudSync.convergent.downgrade.desc': 'Reemplaza los archivos v2 de cada proveedor conectado con una instantánea heredada.',
|
||||
'cloudSync.convergent.downgrade.button': 'Degradar',
|
||||
'cloudSync.convergent.downgrade.confirm': '¿Degradar cada proveedor conectado a la sincronización heredada? Esto elimina los metadatos CRDT después de la verificación de escritura.',
|
||||
'cloudSync.convergent.downgrade.done': 'Sincronización convergente degradada',
|
||||
'cloudSync.status.title': 'Estado de sincronización',
|
||||
'cloudSync.status.localVersion': 'Versión local',
|
||||
'cloudSync.status.remoteVersion': 'Versión remota',
|
||||
'cloudSync.history.title': 'Historial de sincronización',
|
||||
'cloudSync.history.upload': 'Subida',
|
||||
'cloudSync.history.download': 'Descarga',
|
||||
'cloudSync.history.resolved': 'Resuelto',
|
||||
'cloudSync.history.error': 'Error',
|
||||
'cloudSync.localBackups.title': 'Historial de copias de seguridad locales',
|
||||
'cloudSync.localBackups.desc': 'Netcatty conserva puntos de restauración locales antes de los cambios de versión de la app y antes de las restauraciones de la bóveda.',
|
||||
'cloudSync.localBackups.retentionTitle': 'Retención de copias de seguridad',
|
||||
'cloudSync.localBackups.retentionDesc': 'Elige cuántas copias de seguridad locales debe conservar Netcatty.',
|
||||
'cloudSync.localBackups.maxCount': 'Copias máximas',
|
||||
'cloudSync.localBackups.maxSaved': 'Retención de copia guardada: {count}',
|
||||
'cloudSync.localBackups.maxInvalid': 'Ingresa un número entre 1 y 100.',
|
||||
'cloudSync.localBackups.empty': 'Aún no hay copias de seguridad locales.',
|
||||
'cloudSync.localBackups.reason.appVersionChange': 'Antes del cambio de versión de la app',
|
||||
'cloudSync.localBackups.reason.beforeRestore': 'Antes de la restauración',
|
||||
'cloudSync.localBackups.versionChange': '{from} -> {to}',
|
||||
'cloudSync.localBackups.counts': '{hosts} hosts, {keys} claves, {snippets} snippets, {notes} notas',
|
||||
'cloudSync.localBackups.restore': 'Restaurar',
|
||||
'cloudSync.localBackups.restoreSuccess': 'Copia de seguridad local restaurada.',
|
||||
'cloudSync.localBackups.restoreFailedTitle': 'Falló la restauración',
|
||||
'cloudSync.localBackups.restoreMissing': 'No se encontró la copia de seguridad.',
|
||||
'cloudSync.localBackups.protectiveBackupFailed': 'No se pudo crear la copia de seguridad de protección, por lo que la restauración se canceló para proteger tus datos actuales. Resuelve el problema subyacente (p. ej., acceso al keychain) e inténtalo de nuevo. Detalles: {message}',
|
||||
'cloudSync.localBackups.restoreConfirmTitle': '¿Restaurar esta copia de seguridad?',
|
||||
'cloudSync.localBackups.restoreConfirmDesc': 'Tus hosts, claves, snippets y configuraciones actuales se reemplazarán con el contenido de esta copia de seguridad. Se crea automáticamente una instantánea de protección de tus datos actuales primero.',
|
||||
'cloudSync.localBackups.restoreConfirmButton': 'Restaurar',
|
||||
'cloudSync.localBackups.restoreConfirmCancel': 'Cancelar',
|
||||
'cloudSync.localBackups.unavailableTitle': 'Copias de seguridad locales no disponibles',
|
||||
'cloudSync.localBackups.unavailableDesc': 'Esta plataforma no expone un keychain seguro a Netcatty, por lo que las copias de seguridad locales no se pueden escribir de forma segura. Instala Netcatty en un sistema con un keychain compatible para habilitar el historial de copias de seguridad locales.',
|
||||
'cloudSync.localBackups.lockedTitle': 'Se requiere clave maestra',
|
||||
'cloudSync.localBackups.lockedDesc': 'Configura o desbloquea tu clave maestra antes de restaurar una copia de seguridad, para que las credenciales restauradas permanezcan cifradas.',
|
||||
'cloudSync.revisionHistory.viewButton': 'Historial',
|
||||
'cloudSync.revisionHistory.title': 'Historial de versiones de la bóveda',
|
||||
'cloudSync.revisionHistory.description': 'Explora y restaura versiones anteriores de tu bóveda desde el historial de revisiones de Gist.',
|
||||
'cloudSync.revisionHistory.empty': 'No se encontraron revisiones.',
|
||||
'cloudSync.revisionHistory.current': 'Actual',
|
||||
'cloudSync.revisionHistory.revision': 'Revisión',
|
||||
'cloudSync.revisionHistory.revisionPreview': 'Contenido de la revisión',
|
||||
'cloudSync.revisionHistory.device': 'Dispositivo',
|
||||
'cloudSync.revisionHistory.hosts': 'Hosts',
|
||||
'cloudSync.revisionHistory.keys': 'Claves',
|
||||
'cloudSync.revisionHistory.snippets': 'Snippets',
|
||||
'cloudSync.revisionHistory.notes': 'Notas',
|
||||
'cloudSync.revisionHistory.identities': 'Identidades',
|
||||
'cloudSync.revisionHistory.restoreButton': 'Restaurar esta versión',
|
||||
'cloudSync.revisionHistory.restored': 'Bóveda restaurada desde la revisión seleccionada.',
|
||||
'cloudSync.revisionHistory.revisionNotFound': 'Revisión no encontrada o no contiene datos de la bóveda.',
|
||||
'cloudSync.revisionHistory.decryptFailed': 'No se puede descifrar esta revisión. Puede que se haya cifrado con una contraseña maestra diferente.',
|
||||
'cloudSync.changeKey.title': 'Cambiar clave maestra',
|
||||
'cloudSync.changeKey.current': 'Clave maestra actual',
|
||||
'cloudSync.changeKey.new': 'Nueva clave maestra',
|
||||
'cloudSync.changeKey.confirmNew': 'Confirmar nueva clave maestra',
|
||||
'cloudSync.changeKey.currentPlaceholder': 'Ingresa la clave maestra actual',
|
||||
'cloudSync.changeKey.newPlaceholder': 'Ingresa la nueva clave maestra',
|
||||
'cloudSync.changeKey.confirmPlaceholder': 'Confirma la nueva clave maestra',
|
||||
'cloudSync.changeKey.fillAll': 'Completa todos los campos',
|
||||
'cloudSync.changeKey.minLength': 'La nueva clave maestra debe tener al menos 8 caracteres',
|
||||
'cloudSync.changeKey.notMatch': 'Las nuevas claves maestras no coinciden',
|
||||
'cloudSync.changeKey.incorrectCurrent': 'Clave maestra actual incorrecta',
|
||||
'cloudSync.changeKey.failed': 'No se pudo cambiar la clave maestra',
|
||||
'cloudSync.changeKey.desc': 'Esto volverá a cifrar tu bóveda. Asegúrate de recordar la nueva clave.',
|
||||
'cloudSync.changeKey.showKeys': 'Mostrar claves',
|
||||
'cloudSync.changeKey.updatedToast': 'Clave maestra actualizada',
|
||||
'cloudSync.changeKey.updateButton': 'Actualizar clave',
|
||||
'cloudSync.unlock.title': 'Ingresa la clave maestra',
|
||||
'cloudSync.unlock.masterKey': 'Clave maestra',
|
||||
'cloudSync.unlock.desc':
|
||||
'Ingresa tu clave maestra una vez para habilitar la sincronización cifrada. Se almacenará de forma segura usando el keychain de tu sistema operativo.',
|
||||
'cloudSync.unlock.placeholder': 'Ingresa tu clave maestra',
|
||||
'cloudSync.unlock.empty': 'Ingresa tu clave maestra',
|
||||
'cloudSync.unlock.incorrect': 'Clave maestra incorrecta',
|
||||
'cloudSync.unlock.failed': 'No se pudo desbloquear la bóveda',
|
||||
'cloudSync.unlock.showKey': 'Mostrar clave',
|
||||
'cloudSync.unlock.notNow': 'Ahora no',
|
||||
'cloudSync.unlock.readyToast': 'Bóveda lista',
|
||||
'cloudSync.unlock.unlockButton': 'Desbloquear',
|
||||
'cloudSync.header.vaultReady': 'Bóveda lista',
|
||||
'cloudSync.header.preparingVault': 'Preparando bóveda...',
|
||||
'cloudSync.header.providersConnected': '{count} proveedor(es) conectado(s)',
|
||||
'cloudSync.githubFlow.title': 'Conectar con GitHub',
|
||||
'cloudSync.githubFlow.desc': 'Copia el código de abajo e ingrésalo en GitHub para autorizar a Netcatty.',
|
||||
'cloudSync.githubFlow.copyCode': 'Copiar código',
|
||||
'cloudSync.githubFlow.copied': '¡Copiado!',
|
||||
'cloudSync.githubFlow.openGitHub': 'Abrir GitHub',
|
||||
'cloudSync.githubFlow.waiting': 'Esperando autorización...',
|
||||
'cloudSync.conflict.title': 'Se detectó un conflicto de versiones',
|
||||
'cloudSync.conflict.desc': 'Elige qué versión conservar',
|
||||
'cloudSync.conflict.local': 'LOCAL',
|
||||
'cloudSync.conflict.cloud': 'NUBE',
|
||||
'cloudSync.conflict.detailsTitle': 'Datos cambiados',
|
||||
'cloudSync.conflict.detailsCounts': 'Local {local} · Nube {cloud} · Conflictos {conflicts}',
|
||||
'cloudSync.conflict.entity.hosts': 'Hosts',
|
||||
'cloudSync.conflict.entity.keys': 'Claves',
|
||||
'cloudSync.conflict.entity.identities': 'Identidades',
|
||||
'cloudSync.conflict.entity.proxyProfiles': 'Perfiles de proxy',
|
||||
'cloudSync.conflict.entity.snippets': 'Snippets',
|
||||
'cloudSync.conflict.entity.notes': 'Notas',
|
||||
'cloudSync.conflict.entity.noteGroups': 'Grupos de notas',
|
||||
'cloudSync.conflict.entity.customGroups': 'Grupos',
|
||||
'cloudSync.conflict.entity.snippetPackages': 'Paquetes de snippets',
|
||||
'cloudSync.conflict.entity.portForwardingRules': 'Reenvío de puertos',
|
||||
'cloudSync.conflict.entity.groupConfigs': 'Configuración de grupos',
|
||||
'cloudSync.conflict.entity.settings': 'Configuración',
|
||||
'cloudSync.conflict.keepLocal': 'Sobrescribir la nube (conservar local)',
|
||||
'cloudSync.conflict.useCloud': 'Descargar la nube (sobrescribir local)',
|
||||
'cloudSync.connect.browserContinue': 'Completar la autorización en el navegador',
|
||||
'cloudSync.connect.browserCancelled': 'La autorización anterior del navegador fue cancelada',
|
||||
'cloudSync.connect.github.success': 'GitHub se conectó correctamente',
|
||||
'cloudSync.connect.github.failedTitle': 'Falló la conexión a GitHub',
|
||||
'cloudSync.connect.github.timeout': 'Se agotó el tiempo de espera de la conexión a GitHub. Verifica tu red o la configuración del proxy.',
|
||||
'cloudSync.connect.github.networkError': 'No se puede acceder a GitHub. Verifica tu red o la configuración del proxy.',
|
||||
'cloudSync.connect.google.failedTitle': 'Falló la conexión a Google',
|
||||
'cloudSync.connect.onedrive.failedTitle': 'Falló la conexión a OneDrive',
|
||||
'cloudSync.sync.success': 'Sincronizado con {provider}',
|
||||
'cloudSync.sync.failed': 'Falló la sincronización',
|
||||
'cloudSync.sync.failedTitle': 'Falló la sincronización',
|
||||
'cloudSync.sync.errorTitle': 'Error de sincronización',
|
||||
'cloudSync.resolve.downloaded': 'Datos de la nube descargados',
|
||||
'cloudSync.resolve.uploaded': 'Datos locales subidos',
|
||||
'cloudSync.resolve.failedTitle': 'Falló la resolución del conflicto',
|
||||
'cloudSync.clearLocal.title': 'Borrar datos locales',
|
||||
'cloudSync.clearLocal.desc': 'Restablece la versión local y el historial de sincronización. La próxima sincronización descargará desde la nube.',
|
||||
'cloudSync.clearLocal.button': 'Borrar',
|
||||
'cloudSync.clearLocal.dialog.title': '¿Borrar los datos locales de la bóveda?',
|
||||
'cloudSync.clearLocal.dialog.desc': 'Esto restablecerá la versión local a 0 y borrará el historial de sincronización. Tu próxima sincronización descargará los datos de la nube, reemplazando los datos locales.',
|
||||
'cloudSync.clearLocal.dialog.cancel': 'Cancelar',
|
||||
'cloudSync.clearLocal.dialog.confirm': 'Borrar datos locales',
|
||||
'cloudSync.clearLocal.toast.title': 'Datos locales borrados',
|
||||
'cloudSync.clearLocal.toast.desc': 'Versión local restablecida a 0. Sincroniza para descargar desde la nube.',
|
||||
|
||||
// Keychain
|
||||
'keychain.filter.key': 'CLAVE',
|
||||
'keychain.filter.certificate': 'CERTIFICADO',
|
||||
'keychain.action.generateKey': 'Generar clave',
|
||||
'keychain.action.importKey': 'Importar clave',
|
||||
'keychain.action.newIdentity': 'Nueva identidad',
|
||||
'keychain.action.importCertificate': 'Importar certificado',
|
||||
'keychain.view.grid': 'Cuadrícula',
|
||||
'keychain.view.list': 'Lista',
|
||||
'keychain.section.keys': 'Claves',
|
||||
'keychain.section.identities': 'Identidades',
|
||||
'keychain.count.items': '{count} elementos',
|
||||
'keychain.empty.title': 'Configura tus claves',
|
||||
'keychain.empty.desc': 'Importa o genera claves SSH para una autenticación segura.',
|
||||
'keychain.panel.generateKey': 'Generar clave',
|
||||
'keychain.panel.newKey': 'Nueva clave',
|
||||
'keychain.panel.keyDetails': 'Detalles de la clave',
|
||||
'keychain.panel.editKey': 'Editar clave',
|
||||
'keychain.panel.editIdentity': 'Editar identidad',
|
||||
'keychain.panel.newIdentity': 'Nueva identidad',
|
||||
'keychain.panel.keyExport': 'Exportación de clave',
|
||||
'keychain.validation.labelRequired': 'Ingresa una etiqueta para la clave',
|
||||
'keychain.validation.labelAndPrivateKeyRequired': 'La etiqueta y la clave privada son obligatorias',
|
||||
'keychain.validation.labelAndUsernameRequired': 'La etiqueta y el nombre de usuario son obligatorios',
|
||||
'keychain.error.generationUnavailable':
|
||||
'La generación de claves no está disponible: asegúrate de que la app se esté ejecutando en Electron',
|
||||
'keychain.error.generateKeyPairFailed': 'No se pudo generar el par de claves',
|
||||
'keychain.error.generateKeyFailed': 'No se pudo generar la clave',
|
||||
'keychain.error.keyGenerationTitle': 'Generación de claves',
|
||||
'keychain.export.exportTo': 'Exportar a *',
|
||||
'keychain.export.selectHost': 'Seleccionar host',
|
||||
'keychain.export.location': 'Ubicación ~ $1 *',
|
||||
'keychain.export.filename': 'Nombre de archivo ~ $2 *',
|
||||
'keychain.export.note':
|
||||
'La exportación de claves actualmente solo admite sistemas {unix}. Usa la sección {advanced} para personalizar el script de exportación.',
|
||||
'keychain.export.script': 'Script *',
|
||||
'keychain.export.scriptPlaceholder': 'Script de exportación...',
|
||||
'keychain.export.missingCredentials':
|
||||
'El host no tiene contraseña ni clave guardadas. Primero agrega credenciales de contraseña al host.',
|
||||
'keychain.export.successTitle': 'Exportación exitosa',
|
||||
'keychain.export.successMessage': 'Clave pública exportada y adjuntada a {host}',
|
||||
'keychain.export.failedTitle': 'Falló la exportación',
|
||||
'keychain.export.failedMessage': 'No se pudo exportar la clave: {error}',
|
||||
'keychain.export.failedPrefix': 'Falló la exportación: {error}',
|
||||
'keychain.export.exitCode': 'El comando terminó con el código {code}',
|
||||
'keychain.export.exporting': 'Exportando...',
|
||||
'keychain.export.exportAndAttach': 'Exportar y adjuntar',
|
||||
'keychain.export.title': 'Exportación de clave',
|
||||
'keychain.export.exportToRequired': 'Exportar a *',
|
||||
'keychain.export.selectHostPlaceholder': 'Selecciona un host...',
|
||||
'keychain.export.locationLabel': 'Ubicación ~ $1 *',
|
||||
'keychain.export.filenameLabel': 'Nombre de archivo ~ $2 *',
|
||||
'keychain.export.advanced': 'Avanzado',
|
||||
'keychain.export.note.supportsOnly': 'La exportación de claves actualmente solo admite',
|
||||
'keychain.export.note.systems': 'sistemas.',
|
||||
'keychain.export.note.use': 'Usa la',
|
||||
'keychain.export.note.customize': 'sección para personalizar el script de exportación.',
|
||||
'keychain.export.scriptRequired': 'Script *',
|
||||
'keychain.export.exportToHost': 'Exportar al host',
|
||||
'keychain.export.failedGeneric': 'Falló la exportación: {message}',
|
||||
'keychain.field.label': 'Etiqueta',
|
||||
'keychain.field.labelRequired': 'Etiqueta *',
|
||||
'keychain.field.labelPlaceholder': 'Etiqueta de la clave',
|
||||
'keychain.field.privateKeyRequired': 'Clave privada *',
|
||||
'keychain.field.publicKey': 'Clave pública',
|
||||
'keychain.field.certificatePlaceholder': 'Contenido del certificado (opcional)',
|
||||
'keychain.generate.keyType': 'Tipo de clave',
|
||||
'keychain.generate.keySize': 'Tamaño de clave',
|
||||
'keychain.generate.labelPlaceholder': 'Etiqueta de la clave',
|
||||
'keychain.generate.passphrasePlaceholder': 'Frase de contraseña (opcional)',
|
||||
'keychain.generate.savePassphrase': 'Guardar frase de contraseña',
|
||||
'keychain.generate.generate': 'Generar',
|
||||
'keychain.generate.generateSave': 'Generar y guardar',
|
||||
'keychain.import.dropHint': 'Suelta un archivo de clave aquí',
|
||||
'keychain.import.importFromFile': 'Importar desde archivo',
|
||||
'keychain.import.saveKey': 'Guardar clave',
|
||||
'keychain.import.importedKeyLabel': 'Clave importada',
|
||||
'keychain.identity.usernameRequired': 'Nombre de usuario *',
|
||||
'keychain.identity.method.passwordOnly': 'Contraseña',
|
||||
'keychain.identity.summary.password': 'Contraseña de autenticación',
|
||||
'keychain.identity.summary.key': 'Clave de autenticación',
|
||||
'keychain.identity.summary.certificate': 'Certificado de autenticación',
|
||||
'keychain.identity.summary.passwordAndKey': 'Contraseña y clave de autenticación',
|
||||
'keychain.identity.summary.passwordAndCertificate': 'Contraseña y certificado de autenticación',
|
||||
'keychain.identity.summary.none': 'Sin credenciales',
|
||||
'keychain.identity.selectCredential': 'Seleccionar {kind}',
|
||||
'keychain.identity.save': 'Guardar',
|
||||
'keychain.identity.update': 'Actualizar',
|
||||
'keychain.keyDialog.newTitle': 'Nueva clave',
|
||||
'keychain.keyDialog.newDesc': 'Agregar una nueva clave SSH',
|
||||
'keychain.keyDialog.editTitle': 'Editar clave',
|
||||
'keychain.keyDialog.editDesc': 'Actualizar esta clave SSH',
|
||||
'keychain.keyDialog.updateKey': 'Actualizar clave',
|
||||
|
||||
// Tabs
|
||||
'tabs.closeSessionAria': 'Cerrar sesión',
|
||||
'tabs.closeLogViewAria': 'Cerrar vista de registro',
|
||||
'tabs.closePluginViewAria': 'Cerrar {title}',
|
||||
'tabs.logPrefix': 'Registro:',
|
||||
'tabs.logLocal': 'Local',
|
||||
'tabs.copyTab': 'Copiar pestaña',
|
||||
'tabs.copyTabToNewWindow': 'Copiar pestaña a una nueva ventana',
|
||||
'tabs.copyTabToNewWindowFailed': 'No se pudo abrir la pestaña en una nueva ventana',
|
||||
'tabs.closeOthers': 'Cerrar las demás',
|
||||
'tabs.closeToRight': 'Cerrar pestañas a la derecha',
|
||||
'tabs.closeAll': 'Cerrar todas',
|
||||
'keychain.edit.labelRequired': 'Etiqueta *',
|
||||
'keychain.edit.keyLabelPlaceholder': 'Etiqueta de la clave',
|
||||
'keychain.edit.privateKeyRequired': 'Clave privada *',
|
||||
'keychain.edit.publicKey': 'Clave pública',
|
||||
'keychain.edit.certificate': 'Certificado',
|
||||
'keychain.edit.certificatePlaceholder': 'Contenido del certificado (opcional)',
|
||||
'keychain.edit.filePath': 'Ruta del archivo',
|
||||
'keychain.edit.keyExport': 'Exportación de clave',
|
||||
'keychain.edit.exportToHost': 'Exportar al host',
|
||||
|
||||
// Snippets
|
||||
'snippets.searchPlaceholder': 'Buscar scripts...',
|
||||
'snippets.action.newSnippet': 'Nuevo snippet',
|
||||
'snippets.action.newPackage': 'Nuevo paquete de scripts',
|
||||
'snippets.action.import': 'Importar',
|
||||
'snippets.action.selectSnippets': 'Seleccionar snippets',
|
||||
'snippets.panel.newTitle': 'Nuevo snippet',
|
||||
'snippets.panel.editTitle': 'Editar snippet',
|
||||
'snippets.panel.newAutomationTitle': 'Nuevo script de automatización',
|
||||
'snippets.panel.editAutomationTitle': 'Editar script de automatización',
|
||||
'snippets.panel.resizeWidth': 'Cambiar el ancho del panel',
|
||||
'snippets.field.description': 'Descripción de la acción',
|
||||
'snippets.field.descriptionPlaceholder': 'Ejemplo: verificar la carga de la red',
|
||||
'snippets.field.package': 'Agregar un paquete de scripts',
|
||||
'snippets.field.packagePlaceholder': 'Selecciona o crea un paquete de scripts',
|
||||
'snippets.field.createPackage': 'Crear paquete de scripts',
|
||||
'snippets.field.scriptRequired': 'Script *',
|
||||
'snippets.scriptEditor.expand': 'Abrir en diálogo',
|
||||
'snippets.scriptEditor.resize': 'Cambiar la altura del editor',
|
||||
'snippets.scriptEditor.modalTitle': 'Editar script',
|
||||
'snippets.targets.title': 'Destinos',
|
||||
'snippets.targets.add': 'Agregar destinos',
|
||||
'snippets.targets.selectHosts': 'Hosts',
|
||||
'snippets.targets.selectGroups': 'Grupos',
|
||||
'snippets.targets.noGroups': 'No se encontraron grupos',
|
||||
'snippets.targets.allHosts': 'Aplicar a todos los hosts',
|
||||
'snippets.targets.allHostsShort': 'Todos los hosts',
|
||||
'snippets.targets.allHostsActive': 'Se aplica a cada host conectable.',
|
||||
'snippets.history.title': 'Historial de Shell',
|
||||
'snippets.history.subtitle': '{count} comandos',
|
||||
'snippets.history.emptyTitle': 'Aún no hay historial de Shell',
|
||||
'snippets.history.emptyDesc': 'Los comandos que ejecutes aparecerán aquí',
|
||||
'snippets.history.loadMore': 'Cargar más',
|
||||
'snippets.history.separator': '•',
|
||||
'snippets.history.labelPlaceholder': 'Establece una etiqueta para este snippet',
|
||||
'snippets.history.saveAsSnippet': 'Guardar como snippet',
|
||||
'snippets.history.time.justNow': 'justo ahora',
|
||||
'snippets.history.time.minutesAgo': 'hace {count}m',
|
||||
'snippets.history.time.hoursAgo': 'hace {count}h',
|
||||
'snippets.history.time.daysAgo': 'hace {count}d',
|
||||
'snippets.breadcrumb.allPackages': 'Todos los paquetes de scripts',
|
||||
'snippets.breadcrumb.separator': '›',
|
||||
'snippets.empty.title': 'Crea scripts',
|
||||
'snippets.empty.desc': 'Guarda comandos comunes como snippets de código, o escribe scripts de automatización para operaciones repetibles.',
|
||||
'snippets.search.noResults.title': 'Sin coincidencias',
|
||||
'snippets.search.noResults.desc': 'Ningún script o paquete de scripts coincide con "{query}". Prueba con otro término de búsqueda o limpia la búsqueda para explorar.',
|
||||
'snippets.section.packages': 'Paquetes de scripts',
|
||||
'snippets.section.snippets': 'Scripts',
|
||||
'snippets.kind.codeSnippet': 'Snippet de código',
|
||||
'snippets.kind.automationScript': 'Script de automatización',
|
||||
'snippets.package.count': '{count} script(s)',
|
||||
'snippets.commandFallback': 'Comando',
|
||||
'snippets.view.grid': 'Cuadrícula',
|
||||
'snippets.view.list': 'Lista',
|
||||
'snippets.selection.selected': '{count} seleccionados',
|
||||
'snippets.selection.selectVisible': 'Seleccionar visibles',
|
||||
'snippets.selection.deselectAll': 'Deseleccionar todo',
|
||||
'snippets.selection.exportSelected': 'Exportar seleccionados ({count})',
|
||||
'snippets.selection.deleteSelected': 'Eliminar ({count})',
|
||||
'snippets.selection.deleteConfirmTitle': '¿Eliminar los elementos seleccionados ({count})?',
|
||||
'snippets.selection.deleteConfirmDesc': 'Los elementos seleccionados se eliminarán de forma permanente. Esta acción no se puede deshacer.',
|
||||
'snippets.selection.deleteSuccess': 'Elementos seleccionados eliminados: {count}.',
|
||||
'snippets.export.snippet': 'Exportar snippet',
|
||||
'snippets.export.package': 'Exportar paquete de scripts',
|
||||
'snippets.export.toast.empty': 'No hay snippets para exportar.',
|
||||
'snippets.export.toast.successTitle': 'Exportación lista',
|
||||
'snippets.export.toast.success': 'Se exportaron {count} snippet(s).',
|
||||
'snippets.import.toast.empty': 'No se encontraron snippets importables.',
|
||||
'snippets.import.toast.failedTitle': 'Falló la importación',
|
||||
'snippets.import.toast.invalidDesc': 'Este no es un archivo de snippets válido de Netcatty.',
|
||||
'snippets.import.toast.successTitle': 'Importación completada',
|
||||
'snippets.import.toast.summary': 'Importados {imported}, sobrescritos {overwritten}, omitidos {skipped}.',
|
||||
'snippets.import.modal.title': 'Importar snippets',
|
||||
'snippets.import.modal.desc': 'Elige uno o más archivos JSON de snippets de Netcatty. Netcatty los mostrará en vista previa antes de importar cualquier cosa.',
|
||||
'snippets.import.modal.exampleTitle': 'Ejemplo de JSON',
|
||||
'snippets.import.modal.noFile': 'Aún no se seleccionó ningún archivo. Usa el formato de ejemplo de abajo, o un arreglo JSON simple de snippets, y luego elige uno o más archivos.',
|
||||
'snippets.import.modal.chooseFile': 'Elegir archivo',
|
||||
'snippets.import.modal.downloadExamples': 'Descargar ejemplos',
|
||||
'snippets.import.modal.multipleFiles': '{count} archivos seleccionados',
|
||||
'snippets.import.modal.parsedSummary': '{files} archivo(s), {total} script(s), {packages} paquete(s) de scripts, {conflicts} comando(s) duplicado(s). Las vinculaciones de host se ignoran.',
|
||||
'snippets.import.modal.confirm': 'Confirmar importación',
|
||||
'snippets.import.conflict.title': '¿Importar snippets?',
|
||||
'snippets.import.conflict.desc': '{file} contiene {total} snippet(s). Ya existen {conflicts} comando(s) duplicado(s).',
|
||||
'snippets.import.conflict.hostBindingsNote': 'Las vinculaciones de host no se importan ni se exportan con los snippets.',
|
||||
'snippets.import.conflict.skip': 'Omitir duplicados',
|
||||
'snippets.import.conflict.overwrite': 'Sobrescribir duplicados',
|
||||
'snippets.packageDialog.title': 'Nuevo paquete de scripts',
|
||||
'snippets.packageDialog.parent': 'Padre: {parent}',
|
||||
'snippets.packageDialog.root': 'Raíz',
|
||||
'snippets.packageDialog.placeholder': 'p. ej., ops/maintenance',
|
||||
'snippets.packageDialog.hint': 'Usa "/" para crear paquetes de scripts anidados.',
|
||||
|
||||
// Snippets Rename Dialog
|
||||
'snippets.renameDialog.title': 'Renombrar paquete de scripts',
|
||||
'snippets.renameDialog.currentPath': 'Ruta actual: {path}',
|
||||
'snippets.renameDialog.placeholder': 'Ingresa el nuevo nombre',
|
||||
'snippets.renameDialog.error.empty': 'El nombre del paquete de scripts no puede estar vacío',
|
||||
'snippets.renameDialog.error.duplicate': 'Ya existe un paquete de scripts con este nombre',
|
||||
'snippets.renameDialog.error.invalidChars': 'El nombre del paquete de scripts solo puede contener letras, números, guiones y guiones bajos',
|
||||
|
||||
'snippets.field.noAutoRun': 'Solo pegar (no auto-ejecutar)',
|
||||
'snippets.field.multiLineRunMode': 'Ejecución multilínea',
|
||||
'snippets.field.multiLineRunMode.paste': 'Enviar todo de una vez',
|
||||
'snippets.field.multiLineRunMode.lineDelay': 'Enviar línea por línea',
|
||||
'snippets.field.multiLineRunModeHint': 'Usa línea por línea para inicios de sesión que piden credenciales (prompt) o macros de dispositivos.',
|
||||
// Snippet Shortkey
|
||||
'snippets.field.shortkey': 'Atajo de teclado',
|
||||
'snippets.shortkey.placeholder': 'Haz clic para configurar el atajo',
|
||||
'snippets.shortkey.recording': 'Presiona una combinación de teclas...',
|
||||
'snippets.shortkey.hint': 'Presiona este atajo en la terminal para enviar el comando rápidamente.',
|
||||
'snippets.shortkey.clear': 'Borrar atajo',
|
||||
'snippets.shortkey.error.systemConflict': 'Este atajo entra en conflicto con {name}',
|
||||
'snippets.shortkey.error.snippetConflict': 'Este atajo ya lo usa el snippet: {name}',
|
||||
|
||||
'snippets.variables.dialogTitle': 'Variables del snippet',
|
||||
'snippets.variables.dialogDesc': 'Completa los valores de "{label}" antes de ejecutar.',
|
||||
'snippets.variables.hint': 'Los valores se insertan tal cual en el script (sin escapado de shell).',
|
||||
'snippets.variables.preview': 'Vista previa',
|
||||
'snippets.variables.placeholder': 'Ingresa un valor',
|
||||
'snippets.variables.placeholderDefault': 'Predeterminado: {value}',
|
||||
'snippets.variables.required': 'Esta variable es obligatoria',
|
||||
'snippets.variables.run': 'Ejecutar',
|
||||
'snippets.field.variablesHelp': 'Usa {{name}} o {{name:default}} para los marcadores de posición en el script.',
|
||||
'snippets.field.variablesDetected': 'Variables',
|
||||
'snippets.field.variableDefault': 'predeterminado {value}',
|
||||
|
||||
// Serial Port
|
||||
'serial.button': 'Serial',
|
||||
'serial.modal.title': 'Conectar a puerto serial',
|
||||
'serial.modal.desc': 'Configura los ajustes de conexión del puerto serial',
|
||||
'serial.field.port': 'Puerto serial',
|
||||
'serial.field.selectPort': 'Selecciona un puerto...',
|
||||
'serial.field.baudRate': 'Velocidad de baudios',
|
||||
'serial.field.dataBits': 'Bits de datos',
|
||||
'serial.field.stopBits': 'Bits de parada',
|
||||
'serial.field.stopBits15Warning': 'Los 1.5 bits de parada pueden no ser compatibles con todos los dispositivos Windows',
|
||||
'serial.field.parity': 'Paridad',
|
||||
'serial.field.flowControl': 'Control de flujo',
|
||||
'serial.noPorts': 'No se detectaron puertos seriales. Conecta un dispositivo y actualiza.',
|
||||
'serial.field.customPort': 'Ruta de puerto personalizada',
|
||||
'serial.field.customPortPlaceholder': 'p. ej., /dev/ttys001 o COM1',
|
||||
'serial.type.hardware': 'Hardware',
|
||||
'serial.type.pseudo': 'Pseudoterminal',
|
||||
'serial.type.custom': 'Personalizado',
|
||||
'serial.parity.none': 'Ninguna',
|
||||
'serial.parity.even': 'Par',
|
||||
'serial.parity.odd': 'Impar',
|
||||
'serial.parity.mark': 'Marca',
|
||||
'serial.parity.space': 'Espacio',
|
||||
'serial.flowControl.none': 'Ninguno',
|
||||
'serial.flowControl.xon/xoff': 'XON/XOFF (Software)',
|
||||
'serial.flowControl.rts/cts': 'RTS/CTS (Hardware)',
|
||||
'serial.field.localEcho': 'Forzar eco local',
|
||||
'serial.field.localEchoDesc': 'Hacer eco de los caracteres escritos localmente (para dispositivos sin eco remoto)',
|
||||
'serial.field.lineMode': 'Modo de línea',
|
||||
'serial.field.lineModeDesc': 'Almacenar en búfer la entrada y enviarla al presionar Enter (en lugar de carácter por carácter)',
|
||||
'serial.field.backspaceBehavior': 'Tecla de retroceso',
|
||||
'serial.field.backspaceBehaviorDesc': 'Usa Ctrl+H para dispositivos de red que no responden al código de Retroceso predeterminado.',
|
||||
'serial.backspace.default': 'Predeterminado (DEL, 0x7F)',
|
||||
'serial.backspace.ctrlH': 'Ctrl+H (BS, 0x08)',
|
||||
'serial.field.charset': 'Codificación de caracteres',
|
||||
'serial.connectionError': 'No se pudo conectar al puerto serial',
|
||||
'serial.field.baudRatePlaceholder': 'Selecciona o ingresa la velocidad de baudios...',
|
||||
'serial.field.baudRateEmpty': 'Ingresa una velocidad de baudios personalizada',
|
||||
'serial.field.customBaudRate': 'Usando velocidad de baudios personalizada',
|
||||
'serial.field.saveConfig': 'Guardar configuración',
|
||||
'serial.field.saveConfigDesc': 'Guarda esta configuración serial en hosts para acceso rápido',
|
||||
'serial.field.configLabel': 'Nombre de configuración',
|
||||
'serial.field.configLabelPlaceholder': 'p. ej., Arduino Uno',
|
||||
'serial.connectAndSave': 'Conectar y guardar',
|
||||
'serial.edit.title': 'Configuración del puerto serial',
|
||||
|
||||
// Keyboard Interactive Authentication (2FA/MFA)
|
||||
'keyboard.interactive.title': 'Se requiere autenticación',
|
||||
'keyboard.interactive.desc': 'El servidor requiere autenticación adicional.',
|
||||
'keyboard.interactive.descWithHost': 'El servidor {hostname} requiere autenticación adicional.',
|
||||
'keyboard.interactive.response': 'Respuesta',
|
||||
'keyboard.interactive.enterCode': 'Ingresa el código de verificación',
|
||||
'keyboard.interactive.enterResponse': 'Ingresa la respuesta',
|
||||
'keyboard.interactive.submit': 'Enviar',
|
||||
'keyboard.interactive.verifying': 'Verificando...',
|
||||
'keyboard.interactive.savePassword': 'Guardar contraseña',
|
||||
|
||||
// Passphrase Modal for encrypted SSH keys
|
||||
'passphrase.title': 'Frase de contraseña de la clave SSH',
|
||||
'passphrase.desc': 'Ingresa la frase de contraseña de {keyName}',
|
||||
'passphrase.descWithHost': 'Ingresa la frase de contraseña de {keyName} para conectarte a {hostname}',
|
||||
'passphrase.label': 'Frase de contraseña',
|
||||
'passphrase.keyPath': 'Clave',
|
||||
'passphrase.unlock': 'Desbloquear',
|
||||
'passphrase.unlocking': 'Desbloqueando...',
|
||||
'passphrase.skip': 'Omitir',
|
||||
'passphrase.remember': 'Recordar esta frase de contraseña',
|
||||
|
||||
// Text Editor
|
||||
'sftp.editor.wordWrap': 'Ajuste de línea',
|
||||
'sftp.editor.maximize': 'Maximizar',
|
||||
'sftp.editor.unsavedTitle': 'Cambios sin guardar',
|
||||
'sftp.editor.unsavedMessage': '{fileName} tiene cambios sin guardar. ¿Guardar antes de cerrar?',
|
||||
'sftp.editor.discardChanges': 'Descartar',
|
||||
'sftp.editor.saveAndClose': 'Guardar y cerrar',
|
||||
'sftp.editor.quitBlockedByDirty': 'Editores sin guardar: guarda o descarta antes de salir',
|
||||
|
||||
};
|
||||
@@ -0,0 +1,987 @@
|
||||
import type { Messages } from '../types';
|
||||
|
||||
export const esVaultMessages: Messages = {
|
||||
// Notes
|
||||
'notes.search.placeholder': 'Buscar notas, hosts, etiquetas...',
|
||||
'notes.action.newNote': 'Nueva nota',
|
||||
'notes.action.newGroup': 'Nueva carpeta',
|
||||
'notes.action.importMarkdown': 'Importar Markdown',
|
||||
'notes.action.exportNote': 'Exportar nota',
|
||||
'notes.action.exportGroup': 'Exportar carpeta',
|
||||
'notes.action.exportAll': 'Exportar notas',
|
||||
'notes.import.toast.success': 'Se importaron {count} nota(s)',
|
||||
'notes.import.toast.noNotes': 'No se importaron archivos Markdown.',
|
||||
'notes.import.toast.failed': 'No se pudieron importar los archivos Markdown.',
|
||||
'notes.import.toast.inProgress': 'Ya hay una importación de Markdown en curso.',
|
||||
'notes.import.toast.skipped': 'Se omitieron {count} archivo(s) no compatible(s).',
|
||||
'notes.export.toast.success': 'Se exportaron {count} nota(s)',
|
||||
'notes.export.toast.empty': 'No hay notas para exportar.',
|
||||
'notes.export.toast.failed': 'No se pudieron exportar las notas.',
|
||||
'notes.codeBlock.copied': 'Copiado',
|
||||
'notes.codeBlock.copyFailed': 'No se pudo copiar el bloque de código.',
|
||||
'notes.group.prompt': 'Ruta de la carpeta',
|
||||
'notes.group.all': 'Cuaderno',
|
||||
'notes.group.ungrouped': 'Sin grupo',
|
||||
'notes.section.notebooks': 'Cuaderno',
|
||||
'notes.section.notes': 'Notas',
|
||||
'notes.mode.edit': 'Editar',
|
||||
'notes.mode.split': 'Dividir',
|
||||
'notes.mode.preview': 'Vista previa',
|
||||
'notes.title.placeholder': 'Título de la nota',
|
||||
'notes.editor.placeholder': 'Escribe notas Markdown aquí...',
|
||||
'notes.preview.empty': 'Aún no hay nada para previsualizar.',
|
||||
'notes.empty.title': 'Aún no hay notas',
|
||||
'notes.empty.desc': 'Crea notas Markdown para runbooks, recordatorios y detalles de traspaso de servidores.',
|
||||
'notes.empty.group': 'No hay notas en este cuaderno.',
|
||||
'notes.search.noResults': 'No se encontraron notas.',
|
||||
|
||||
// Vault import
|
||||
'vault.import.title': 'Agrega datos a tu Vault',
|
||||
'vault.import.desc':
|
||||
'Transfiere tus conexiones desde clientes populares. Selecciona un formato de archivo para comenzar la migración.',
|
||||
'vault.import.chooseFormat': 'Selecciona un formato de archivo',
|
||||
'vault.import.destination.title': '¿Dónde deberían ir los hosts importados?',
|
||||
'vault.import.destination.preserve': 'Conservar los grupos originales',
|
||||
'vault.import.destination.existing': 'Elegir un grupo',
|
||||
'vault.import.destination.new': 'Crear un grupo',
|
||||
'vault.import.destination.noGroups': 'No hay grupos disponibles',
|
||||
'vault.import.destination.newPlaceholder': 'Nombre del grupo nuevo',
|
||||
'vault.import.destination.settings': 'Ubicación de importación',
|
||||
'vault.import.destination.settingsHint': 'Elige dónde se colocarán los hosts nuevos',
|
||||
'vault.import.destination.done': 'Listo',
|
||||
'vault.import.securecrt.directoryHint': 'Selecciona la carpeta de sesiones de SecureCRT.',
|
||||
'vault.import.securecrt.chooseSource': 'Elige una fuente de importación de SecureCRT.',
|
||||
'vault.import.securecrt.sourceQuestion': '¿Qué te gustaría importar?',
|
||||
'vault.import.securecrt.promptTitle': 'Importar desde SecureCRT',
|
||||
'vault.import.securecrt.promptDesc':
|
||||
'Elige la carpeta de sesiones de SecureCRT para importar todas las sesiones conservando la estructura de carpetas, o selecciona un solo archivo de sesión .ini.',
|
||||
'vault.import.securecrt.folder': 'Carpeta de sesiones',
|
||||
'vault.import.securecrt.folderDesc': 'Importa todas las sesiones y conserva la estructura de carpetas.',
|
||||
'vault.import.securecrt.file': 'Archivo de sesión único',
|
||||
'vault.import.securecrt.fileDesc': 'Importa una sesión .ini de SecureCRT.',
|
||||
'vault.import.csv.tip': 'Importación masiva: usa la plantilla CSV.',
|
||||
'vault.import.csv.downloadTemplate': 'Descargar plantilla CSV',
|
||||
'vault.import.toast.start': 'Importando desde {format}...',
|
||||
'vault.import.toast.completedTitle': 'Importación completada',
|
||||
'vault.import.toast.failedTitle': 'Importación fallida',
|
||||
'vault.import.toast.noEntries': 'No se encontraron entradas importables en {format}.',
|
||||
'vault.import.toast.noNewHosts': 'No se importaron hosts nuevos desde {format}.',
|
||||
'vault.import.toast.summary':
|
||||
'Se importaron {count} hosts (omitidos {skipped}, duplicados {duplicates}).',
|
||||
'vault.import.toast.firstIssue': 'Primer problema: {issue}',
|
||||
'vault.import.progress.title': 'Importando hosts',
|
||||
'vault.import.progress.reading': 'Leyendo archivo',
|
||||
'vault.import.progress.parsing': 'Analizando hosts',
|
||||
'vault.import.progress.preparing': 'Preparando cambios',
|
||||
'vault.import.progress.saving': 'Guardando hosts',
|
||||
'vault.import.progress.complete': 'Importación completa',
|
||||
'vault.import.progress.failed': 'Importación fallida',
|
||||
'vault.import.progress.persistFailed': 'No se pudieron guardar los hosts importados. Libera algo de espacio de almacenamiento local e inténtalo de nuevo.',
|
||||
'vault.import.progress.rollbackFailed': 'La importación no se pudo revertir de forma segura. Es posible que queden algunos hosts importados; revisa el Vault antes de reintentar.',
|
||||
'vault.import.progress.summary':
|
||||
'Se importaron {count} hosts; omitidos {skipped}; duplicados {duplicates}.',
|
||||
'vault.import.progress.keepOpen': 'Mantén Netcatty abierto mientras la importación se ejecuta en segundo plano.',
|
||||
'vault.import.progress.fileSummary': '{name} · {count} archivos',
|
||||
'vault.import.progress.fileCount': '{completed} de {total} archivos',
|
||||
'vault.import.mobaxterm.chooseEncoding': 'Elige cómo leer tu archivo de MobaXterm.',
|
||||
'vault.import.mobaxterm.encodingQuestion': '¿Qué codificación de texto usa este archivo?',
|
||||
'vault.import.mobaxterm.auto': 'Detección automática',
|
||||
'vault.import.mobaxterm.autoDesc': 'Recomendado. Usa UTF-8 cuando sea válido; de lo contrario, la codificación de Windows en chino.',
|
||||
'vault.import.mobaxterm.utf8': 'UTF-8',
|
||||
'vault.import.mobaxterm.utf8Desc': 'Úsalo para archivos guardados o convertidos como UTF-8.',
|
||||
'vault.import.mobaxterm.gb18030': 'Windows en chino',
|
||||
'vault.import.mobaxterm.gb18030Desc': 'Úsalo para archivos antiguos exportados en Windows en chino.',
|
||||
'vault.import.sshConfig.chooseMode': 'Elige cómo importar tu archivo de configuración SSH.',
|
||||
'vault.import.sshConfig.modeQuestion': '¿Cómo te gustaría importar?',
|
||||
'vault.import.sshConfig.importOnly': 'Solo importar',
|
||||
'vault.import.sshConfig.importOnlyDesc': 'Importación única. Los cambios no se sincronizarán de vuelta al archivo.',
|
||||
'vault.import.sshConfig.managed': 'Sincronización administrada',
|
||||
'vault.import.sshConfig.managedDesc': 'Mantén la sincronización. Los cambios se guardarán de vuelta en el archivo.',
|
||||
'vault.import.sshConfig.managedDestinationHint': 'La sincronización administrada siempre usa su propio grupo administrado; el destino anterior aplica solo a la importación única.',
|
||||
'vault.import.sshConfig.managedGroup': 'ssh config',
|
||||
'vault.import.sshConfig.managedSuccess': 'Se importaron {count} hosts. El archivo ahora está administrado.',
|
||||
'vault.import.sshConfig.alreadyManaged': 'Este archivo ya está siendo administrado.',
|
||||
'vault.import.sshConfig.alreadyManagedDesc': 'Este archivo ya está administrado en el grupo "{group}". Elimina primero la fuente administrada existente si quieres volver a importar.',
|
||||
'vault.import.sshConfig.noFilePath': 'No se puede administrar este archivo.',
|
||||
'vault.import.sshConfig.noFilePathDesc': 'No se pudo determinar la ruta del archivo. La sincronización administrada requiere acceso al sistema de archivos.',
|
||||
|
||||
// Known Hosts
|
||||
'knownHosts.search.placeholder': 'Buscar hosts conocidos...',
|
||||
'knownHosts.action.scanSystem': 'Escanear sistema',
|
||||
'knownHosts.action.importFile': 'Importar archivo',
|
||||
'knownHosts.action.browseFile': 'Explorar archivo',
|
||||
'knownHosts.empty.title': 'No hay hosts conocidos',
|
||||
'knownHosts.empty.desc':
|
||||
"Los hosts conocidos son servidores SSH a los que te conectaste antes. Importa desde el archivo known_hosts de tu sistema para comenzar.",
|
||||
'knownHosts.results.showingLimited':
|
||||
'Mostrando {shown} de {total} hosts. Usa la búsqueda para encontrar hosts específicos.',
|
||||
'knownHosts.toast.scanUnavailable': 'El escaneo del sistema no está disponible en esta plataforma.',
|
||||
'knownHosts.toast.scanNoFile': 'No se encontró un archivo known_hosts del sistema.',
|
||||
'knownHosts.toast.scanNoEntries': 'No se encontraron entradas utilizables en known_hosts.',
|
||||
'knownHosts.toast.scanImported': 'Se importaron {count} hosts nuevos.',
|
||||
'knownHosts.toast.scanNoNew': 'No se encontraron hosts nuevos.',
|
||||
'knownHosts.toast.scanFailed': 'No se pudo escanear el known_hosts del sistema.',
|
||||
|
||||
// Port Forwarding
|
||||
'pf.empty.title': 'Configura el reenvío de puertos',
|
||||
'pf.empty.desc': 'Guarda el reenvío de puertos para acceder a bases de datos, aplicaciones web y otros servicios.',
|
||||
'pf.title': 'Reenvío de puertos',
|
||||
'pf.rulesCount': '{count} reglas',
|
||||
'pf.wizard.editTitle': 'Editar reenvío de puertos',
|
||||
'pf.wizard.newTitle': 'Nuevo reenvío de puertos',
|
||||
'pf.wizard.saveChanges': 'Guardar cambios',
|
||||
'pf.wizard.done': 'Listo',
|
||||
'pf.wizard.continue': 'Continuar',
|
||||
'pf.wizard.cancel': 'Cancelar',
|
||||
'pf.wizard.skipWizard': 'Omitir asistente',
|
||||
'pf.error.hostNotFound': 'No se encontró el host',
|
||||
'pf.toast.titleWithLabel': 'Reenvío de puertos: {label}',
|
||||
'pf.type.local': 'Local',
|
||||
'pf.type.remote': 'Remoto',
|
||||
'pf.type.dynamic': 'Dinámico',
|
||||
'pf.type.menu.local': 'Reenvío local',
|
||||
'pf.type.menu.remote': 'Reenvío remoto',
|
||||
'pf.type.menu.dynamic': 'Reenvío dinámico',
|
||||
'pf.type.local.desc': 'El reenvío local te permite acceder al puerto de escucha de un servidor remoto como si fuera local.',
|
||||
'pf.type.remote.desc': 'El reenvío remoto abre un puerto en la máquina remota y reenvía las conexiones al host local (actual).',
|
||||
'pf.type.dynamic.desc': 'El reenvío dinámico de puertos convierte a Netcatty en un servidor proxy SOCKS.',
|
||||
'pf.wizard.type.title': 'Selecciona el tipo de reenvío de puertos:',
|
||||
'pf.wizard.localConfig.title': 'Configura el puerto local y la dirección de enlace:',
|
||||
'pf.wizard.localConfig.desc': 'Este puerto estará abierto en el dispositivo local (actual) y recibirá el tráfico.',
|
||||
'pf.wizard.localConfig.localPort': 'Número de puerto local *',
|
||||
'pf.wizard.bindAddress': 'Dirección de enlace',
|
||||
'pf.wizard.remoteHost.title': 'Selecciona el host remoto:',
|
||||
'pf.wizard.remoteHost.desc': 'Selecciona un host donde estará abierto el puerto. El tráfico de este puerto se reenviará al host de destino.',
|
||||
'pf.wizard.remoteConfig.title': 'Configura el puerto y la dirección de enlace:',
|
||||
'pf.wizard.remoteConfig.desc': 'El tráfico se reenviará desde el puerto especificado y la dirección de interfaz del host seleccionado.',
|
||||
'pf.wizard.remoteConfig.remotePort': 'Número de puerto remoto *',
|
||||
'pf.wizard.destination.title': 'Selecciona el host de destino:',
|
||||
'pf.wizard.destination.desc.local': 'Ingresa el destino remoto al que quieres acceder a través del túnel.',
|
||||
'pf.wizard.destination.desc.remote': 'La dirección y el puerto de destino hacia donde se reenviará el tráfico.',
|
||||
'pf.wizard.destination.address': 'Dirección de destino *',
|
||||
'pf.wizard.destination.addressPlaceholder': 'p. ej. 127.0.0.1 o 192.168.1.100',
|
||||
'pf.wizard.destination.port': 'Número de puerto de destino *',
|
||||
'pf.wizard.sshServer.title': 'Selecciona el servidor SSH:',
|
||||
'pf.wizard.sshServer.desc.dynamic': 'Selecciona el servidor SSH que actuará como tu proxy SOCKS.',
|
||||
'pf.wizard.sshServer.desc.default': 'Selecciona el servidor SSH que tunelizará tu tráfico hacia el destino.',
|
||||
'pf.wizard.label.title': 'Selecciona la etiqueta:',
|
||||
'pf.wizard.label.placeholder.dynamic': 'p. ej. Proxy SOCKS',
|
||||
'pf.wizard.label.placeholder.default': 'p. ej. MySQL Producción',
|
||||
'pf.wizard.label.placeholder.remoteRule': 'p. ej. Regla remota',
|
||||
'pf.wizard.placeholders.portExample': 'p. ej. {port}',
|
||||
'pf.action.newForwarding': 'Nuevo reenvío',
|
||||
'pf.form.labelPlaceholder': 'Etiqueta de la regla',
|
||||
'pf.form.intermediateHost': 'Host intermedio *',
|
||||
'pf.form.createRule': 'Crear regla',
|
||||
'pf.form.openWizard': 'Abrir asistente',
|
||||
'pf.form.openWizardTitle': 'Abrir el asistente de reenvío de puertos',
|
||||
'pf.view.grid': 'Cuadrícula',
|
||||
'pf.view.list': 'Lista',
|
||||
'pf.rule.summary.local': 'Local {bindAddress}:{localPort} -> Túnel SSH -> {remoteHost}:{remotePort}',
|
||||
'pf.rule.summary.remote': 'Remoto {bindAddress}:{localPort} -> Túnel SSH -> local {remoteHost}:{remotePort}',
|
||||
'pf.rule.summary.dynamic': 'SOCKS en {bindAddress}:{localPort}',
|
||||
'pf.tooltip.relayHost': 'Host de relevo',
|
||||
'pf.tooltip.hostLabel': 'Host',
|
||||
'pf.tooltip.hostAddress': 'Dirección',
|
||||
'pf.tooltip.noHost': 'No hay host de relevo configurado',
|
||||
'pf.tooltip.localDesc': 'Reenvío de puerto local: accede a servicios remotos a través del túnel SSH',
|
||||
'pf.tooltip.remoteDesc': 'Reenvío de puerto remoto: expone servicios locales al host remoto',
|
||||
'pf.tooltip.dynamicDesc': 'Proxy SOCKS dinámico: enruta el tráfico a través del túnel SSH',
|
||||
'pf.deleteActive.title': '¿Eliminar el reenvío de puertos activo?',
|
||||
'pf.deleteActive.desc': 'Esta regla de reenvío de puertos "{label}" está activa actualmente. Eliminarla primero detendrá el túnel.',
|
||||
'pf.deleteActive.confirm': 'Detener y eliminar',
|
||||
'pf.form.autoStart': 'Inicio automático',
|
||||
'pf.form.autoStartDesc': 'Inicia automáticamente esta regla cuando se abra la aplicación',
|
||||
|
||||
// SFTP
|
||||
'sftp.newFolder': 'Nueva carpeta',
|
||||
'sftp.newFile': 'Nuevo archivo',
|
||||
'sftp.filter': 'Filtrar',
|
||||
'sftp.filter.placeholder': 'Filtrar por nombre de archivo...',
|
||||
'sftp.bookmark.add': 'Marcar esta ruta',
|
||||
'sftp.bookmark.remove': 'Quitar marcador',
|
||||
'sftp.bookmark.list': 'Rutas marcadas',
|
||||
'sftp.bookmark.addGlobal': '+Global',
|
||||
'sftp.bookmark.addGlobalTooltip': 'Guardar como marcador global (compartido entre todos los hosts)',
|
||||
'sftp.bookmark.empty': 'Aún no hay marcadores',
|
||||
'sftp.columns.name': 'Nombre',
|
||||
'sftp.columns.modified': 'Modificado',
|
||||
'sftp.columns.size': 'Tamaño',
|
||||
'sftp.columns.kind': 'Tipo',
|
||||
'sftp.columns.configure': 'Elegir columnas visibles',
|
||||
'sftp.columns.actions': 'Acciones',
|
||||
'sftp.sort.directoriesFirst': 'Carpetas primero',
|
||||
'sftp.emptyDirectory': 'Directorio vacío',
|
||||
'sftp.nav.up': 'Subir un nivel',
|
||||
'sftp.nav.home': 'Ir al inicio',
|
||||
'sftp.nav.refresh': 'Actualizar',
|
||||
'sftp.upload': 'Subir',
|
||||
'sftp.uploadFiles': 'Subir archivos',
|
||||
'sftp.uploadFolder': 'Subir carpeta',
|
||||
'sftp.dragDropToUpload': 'Arrastra y suelta archivos aquí para subirlos',
|
||||
'sftp.retry': 'Reintentar',
|
||||
'sftp.context.open': 'Abrir',
|
||||
'sftp.context.navigateTo': 'Navegar a',
|
||||
'sftp.context.moveTo': 'Mover a...',
|
||||
'sftp.context.moveToParent': 'Mover al directorio principal',
|
||||
'sftp.moveTo.title': 'Mover a directorio',
|
||||
'sftp.moveTo.placeholder': 'Ingresa la ruta del directorio de destino',
|
||||
'sftp.moveTo.confirm': 'Mover',
|
||||
'sftp.moveTo.pathNotFound': 'Directorio no encontrado o inaccesible',
|
||||
'sftp.context.download': 'Descargar',
|
||||
'sftp.context.extract': 'Extraer aquí',
|
||||
'sftp.extract.extracting': 'Extrayendo {fileName}...',
|
||||
'sftp.extract.success': 'Se extrajo {fileName}',
|
||||
'sftp.extract.error': 'No se pudo extraer {fileName}: {error}',
|
||||
'sftp.context.copyToOtherPane': 'Copiar al otro panel',
|
||||
'sftp.copyToOtherPane.unavailable': 'Abre la vista SFTP de dos paneles y conecta el panel de destino antes de copiar archivos.',
|
||||
'sftp.copyCurrentPath': 'Copiar ruta actual',
|
||||
'sftp.copyCurrentPath.success': 'Ruta actual copiada',
|
||||
'sftp.copyCurrentPath.error': 'No se pudo copiar la ruta actual',
|
||||
'sftp.viewMode.label': 'Modo de vista',
|
||||
'sftp.viewMode.list': 'Vista de lista',
|
||||
'sftp.viewMode.tree': 'Vista de árbol',
|
||||
'sftp.viewMode.switchToList': 'Cambiar a vista de lista',
|
||||
'sftp.viewMode.switchToTree': 'Cambiar a vista de árbol',
|
||||
'sftp.tree.loadError': 'No se pudo cargar el directorio',
|
||||
'sftp.tree.loading': 'Cargando...',
|
||||
'sftp.kind.folder': 'Carpeta',
|
||||
'sftp.context.rename': 'Renombrar',
|
||||
'sftp.context.permissions': 'Permisos',
|
||||
'sftp.context.delete': 'Eliminar',
|
||||
'sftp.context.refresh': 'Actualizar',
|
||||
'sftp.context.uploadFiles': 'Subir archivo(s)...',
|
||||
'sftp.context.uploadFilesHere': 'Subir archivo(s) aquí...',
|
||||
'sftp.context.uploadFolder': 'Subir carpeta...',
|
||||
'sftp.context.uploadFolderHere': 'Subir carpeta aquí...',
|
||||
'sftp.context.downloadSelected': 'Descargar seleccionados ({count})',
|
||||
'sftp.context.deleteSelected': 'Eliminar seleccionados ({count})',
|
||||
'sftp.dropFilesHere': 'Suelta los archivos aquí',
|
||||
'sftp.itemsCount': '{count} elementos',
|
||||
'sftp.selectedCount': '{count} seleccionados',
|
||||
'sftp.path.doubleClickToEdit': 'Haz doble clic para editar la ruta',
|
||||
'sftp.showHiddenPaths': 'Rutas ocultas',
|
||||
'sftp.task.waiting': 'Esperando...',
|
||||
'sftp.transfer.preparing': 'preparando...',
|
||||
'sftp.status.loading': 'Cargando...',
|
||||
'sftp.status.uploading': 'Subiendo...',
|
||||
'sftp.status.ready': 'Listo',
|
||||
'sftp.transfers': 'Transferencias',
|
||||
'sftp.transfers.active': '{count} activas',
|
||||
'sftp.transfers.clearCompleted': 'Limpiar completadas',
|
||||
'sftp.transfers.calculatingTotal': 'Calculando el tamaño total...',
|
||||
'sftp.transfers.filesCount': '{count} archivos',
|
||||
'sftp.transfers.filesProgress': '{current}/{total} archivos',
|
||||
// Progressive folder walk: total is "found so far", not a fixed grand total.
|
||||
'sftp.transfers.filesDiscoveredProgress': '{completed} completados · {discovered} encontrados',
|
||||
'sftp.transfers.expandChildren': 'Mostrar archivos',
|
||||
'sftp.transfers.collapseChildren': 'Ocultar archivos',
|
||||
'sftp.transfers.expandChildList': 'Mostrar detalle',
|
||||
'sftp.transfers.collapseChildList': 'Ocultar',
|
||||
'sftp.transfers.retryAction': 'Reintentar',
|
||||
'sftp.transfers.dismissAction': 'Descartar',
|
||||
'sftp.transfers.openTargetFolder': 'Abrir carpeta de destino',
|
||||
'sftp.transfers.openTargetFolderError': 'No se pudo abrir la carpeta de destino',
|
||||
'sftp.transfers.copyTargetPath': 'Copiar ruta de destino',
|
||||
'sftp.transfers.copyTargetPathSuccess': 'Ruta de destino copiada',
|
||||
'sftp.transfers.copyTargetPathError': 'No se pudo copiar la ruta de destino',
|
||||
'sftp.transfers.pathConflict.title': 'Transferencia bloqueada',
|
||||
'sftp.transfers.pathConflict.paused': 'Otra transferencia de "{name}" está en pausa. Reanúdala o cancélala primero.',
|
||||
'sftp.transfers.pathConflict.inProgress': 'Otra transferencia de "{name}" ya está en curso.',
|
||||
'sftp.transferCenter.title': 'Transferencias de archivos',
|
||||
'sftp.transferCenter.quitConfirm': '{count} transferencia(s) de archivos quedaron sin terminar. El progreso se guardará y deberá reanudarse manualmente la próxima vez. ¿Salir de todos modos?',
|
||||
'sftp.transferCenter.phase.scanning': 'Escaneando',
|
||||
'sftp.transferCenter.phase.compressing': 'Comprimiendo',
|
||||
'sftp.transferCenter.phase.uploading': 'Subiendo',
|
||||
'sftp.transferCenter.phase.transferring': 'Transfiriendo',
|
||||
'sftp.transferCenter.phase.extracting': 'Extrayendo',
|
||||
'sftp.transferCenter.phase.verifying': 'Verificando',
|
||||
'sftp.transferCenter.bucket.active': 'En progreso',
|
||||
'sftp.transferCenter.bucket.all': 'Todas',
|
||||
'sftp.transferCenter.bucket.queued': 'En cola',
|
||||
'sftp.transferCenter.bucket.paused': 'En pausa',
|
||||
'sftp.transferCenter.bucket.failed': 'Fallidas',
|
||||
'sftp.transferCenter.bucket.attention': 'Requieren atención',
|
||||
'sftp.transferCenter.bucket.completed': 'Completadas',
|
||||
'sftp.transferCenter.status.pending': 'Preparando',
|
||||
'sftp.transferCenter.status.resuming': 'Reconectando y reanudando…',
|
||||
'sftp.transferCenter.status.queued': 'En cola',
|
||||
'sftp.transferCenter.status.transferring': 'Transfiriendo',
|
||||
'sftp.transferCenter.status.pausing': 'Pausando',
|
||||
'sftp.transferCenter.status.paused': 'En pausa',
|
||||
'sftp.transferCenter.status.attention': 'Requiere atención',
|
||||
'sftp.transferCenter.status.interrupted': 'Interrumpida: reanúdala manualmente',
|
||||
'sftp.transferCenter.status.completed': 'Completada',
|
||||
'sftp.transferCenter.status.failed': 'Fallida',
|
||||
'sftp.transferCenter.status.cancelled': 'Cancelada',
|
||||
'sftp.transferCenter.background': 'Segundo plano',
|
||||
'sftp.transferCenter.pause': 'Pausar',
|
||||
'sftp.transferCenter.resume': 'Reanudar',
|
||||
'sftp.transferCenter.prioritize': 'Transferir a continuación',
|
||||
'sftp.transferCenter.pauseAll': 'Pausar todas',
|
||||
'sftp.transferCenter.resumeAll': 'Reanudar todas',
|
||||
'sftp.transferCenter.empty': 'No hay transferencias en esta categoría',
|
||||
'sftp.transferCenter.showBackground': 'Mostrar {count} transferencias en segundo plano',
|
||||
'sftp.transferCenter.hideBackground': 'Ocultar transferencias en segundo plano',
|
||||
'sftp.transferCenter.clear': 'Limpiar registros',
|
||||
'sftp.transferCenter.applyAll': 'Aplicar a todas',
|
||||
'sftp.transfers.resizeNameColumn': 'Redimensionar la columna de nombre de archivo',
|
||||
'sftp.transfers.dragToResize': 'Arrastra para redimensionar',
|
||||
'sftp.goUp': 'Subir un nivel',
|
||||
'sftp.goToTerminalCwd': 'Ir al directorio de la terminal',
|
||||
'sftp.locatePathInTerminal': 'Abrir ruta en la terminal',
|
||||
'sftp.followTerminalCwd': 'Seguir el directorio de la terminal',
|
||||
'sftp.followTerminalCwd.enable': 'Habilitar el seguimiento del directorio de la terminal',
|
||||
'sftp.followTerminalCwd.disable': 'Deshabilitar el seguimiento del directorio de la terminal',
|
||||
'sftp.encoding.label': 'Codificación de nombres de archivo',
|
||||
'sftp.encoding.auto': 'Auto',
|
||||
'sftp.encoding.utf8': 'UTF-8',
|
||||
'sftp.encoding.gb18030': 'GB18030',
|
||||
'sftp.goHome': 'Ir al inicio',
|
||||
'sftp.folderName': 'Nombre de la carpeta',
|
||||
'sftp.folderName.placeholder': 'Ingresa el nombre de la carpeta',
|
||||
'sftp.fileName': 'Nombre del archivo',
|
||||
'sftp.fileName.placeholder': 'Ingresa el nombre del archivo',
|
||||
'sftp.prompt.newFolderName': '¿Nombre de la carpeta nueva?',
|
||||
'sftp.rename.title': 'Renombrar',
|
||||
'sftp.rename.newName': 'Nombre nuevo',
|
||||
'sftp.rename.placeholder': 'Ingresa el nombre nuevo',
|
||||
'sftp.confirm.deleteOne': '¿Eliminar "{name}"?',
|
||||
'sftp.deleteConfirm.single': '¿Eliminar "{name}"?',
|
||||
'sftp.deleteConfirm.title': '¿Eliminar {count} elemento(s)?',
|
||||
'sftp.deleteConfirm.desc': 'Esta acción no se puede deshacer. Se eliminará lo siguiente:',
|
||||
'sftp.deleteConfirm.descSingle': 'Esta acción no se puede deshacer.',
|
||||
'sftp.deleteConfirm.host': 'Host',
|
||||
'sftp.deleteConfirm.path': 'Ruta',
|
||||
'sftp.error.loadFailed': 'No se pudo cargar el directorio',
|
||||
'sftp.error.downloadFailed': 'No se pudo descargar',
|
||||
'sftp.error.uploadFailed': 'No se pudo subir',
|
||||
'sftp.error.deleteFailed': 'No se pudo eliminar',
|
||||
'sftp.error.createFolderFailed': 'No se pudo crear la carpeta',
|
||||
'sftp.error.createFileFailed': 'No se pudo crear el archivo',
|
||||
'sftp.error.invalidFileName': 'El nombre de archivo contiene caracteres no válidos: {chars}',
|
||||
'sftp.error.reservedName': 'Este nombre de archivo está reservado por el sistema',
|
||||
'sftp.overwrite.title': 'El archivo ya existe',
|
||||
'sftp.overwrite.desc': 'Ya existe un archivo llamado "{name}". ¿Quieres reemplazarlo?',
|
||||
'sftp.overwrite.confirm': 'Reemplazar',
|
||||
'sftp.error.renameFailed': 'No se pudo renombrar',
|
||||
'sftp.picker.title': 'Seleccionar host',
|
||||
'sftp.picker.desc': 'Elige un host para el panel {side}',
|
||||
'sftp.picker.searchPlaceholder': 'Buscar hosts...',
|
||||
'sftp.picker.local.title': 'Sistema de archivos local',
|
||||
'sftp.picker.local.desc': 'Explorar archivos locales',
|
||||
'sftp.picker.local.badge': 'Local',
|
||||
'sftp.picker.connected.section': 'Conectados',
|
||||
'sftp.picker.connected.badge': 'En vivo',
|
||||
'sftp.picker.connected.connecting': 'Conectando',
|
||||
'sftp.picker.noMatch': 'No hay hosts que coincidan',
|
||||
'sftp.permissions.title': 'Editar permisos',
|
||||
'sftp.permissions.owner': 'Propietario',
|
||||
'sftp.permissions.group': 'Grupo',
|
||||
'sftp.permissions.others': 'Otros',
|
||||
'sftp.permissions.octal': 'Octal',
|
||||
'sftp.permissions.symbolic': 'Simbólico',
|
||||
'sftp.permissions.success': 'Permisos actualizados correctamente',
|
||||
'sftp.permissions.failed': 'No se pudieron actualizar los permisos',
|
||||
'sftp.pane.local': 'Local',
|
||||
'sftp.pane.remote': 'Remoto',
|
||||
'sftp.pane.selectHost': 'Seleccionar host',
|
||||
'sftp.pane.selectHostToStart': 'Selecciona un host para comenzar',
|
||||
'sftp.pane.chooseFilesystem': 'Elige un sistema de archivos local o remoto para explorar',
|
||||
'sftp.tabs.addTab': 'Agregar pestaña nueva',
|
||||
'sftp.tabs.closeTab': 'Cerrar pestaña',
|
||||
'sftp.tabs.newTab': 'Pestaña nueva',
|
||||
'sftp.tabs.copyDefaultPath': 'Copiar pestaña (ruta predeterminada)',
|
||||
'sftp.tabs.copyCurrentPath': 'Copiar e ir a la ruta actual',
|
||||
'sftp.conflict.title': 'Conflicto de archivos',
|
||||
'sftp.conflict.desc': 'Ya existe un archivo con el mismo nombre en el destino',
|
||||
'sftp.conflict.alreadyExistsSuffix': 'ya existe',
|
||||
'sftp.conflict.existingFile': 'Archivo existente',
|
||||
'sftp.conflict.newFile': 'Archivo nuevo',
|
||||
'sftp.conflict.size': 'Tamaño:',
|
||||
'sftp.conflict.modified': 'Modificado:',
|
||||
'sftp.conflict.applyToAll': 'Aplicar esta acción a los {count} conflictos restantes',
|
||||
'sftp.conflict.action.stop': 'Detener',
|
||||
'sftp.conflict.action.skip': 'Omitir',
|
||||
'sftp.conflict.action.keepBoth': 'Conservar ambos',
|
||||
'sftp.conflict.action.duplicate': 'Duplicar',
|
||||
'sftp.conflict.action.merge': 'Combinar',
|
||||
'sftp.conflict.action.replace': 'Reemplazar',
|
||||
|
||||
// SFTP Upload Phases
|
||||
'sftp.upload.phase.compressing': 'Comprimiendo',
|
||||
'sftp.upload.phase.uploading': 'Subiendo',
|
||||
'sftp.upload.phase.extracting': 'Extrayendo',
|
||||
'sftp.upload.phase.compressed': 'Comprimido',
|
||||
|
||||
// SFTP File Opener
|
||||
'sftp.context.copyPath': 'Copiar ruta del archivo',
|
||||
'sftp.context.openWithDefault': 'Abrir con la aplicación predeterminada del sistema',
|
||||
'sftp.context.openWith': 'Abrir con...',
|
||||
'sftp.context.edit': 'Editar',
|
||||
'sftp.context.preview': 'Vista previa',
|
||||
'sftp.opener.title': 'Abrir con',
|
||||
'sftp.opener.desc': 'Elige una aplicación para abrir este archivo',
|
||||
'sftp.opener.builtInEditor': 'Editor integrado',
|
||||
'sftp.opener.editDescription': 'Editar archivos de texto',
|
||||
'sftp.opener.builtInImageViewer': 'Visor de imágenes integrado',
|
||||
'sftp.opener.previewDescription': 'Previsualizar imágenes',
|
||||
'sftp.opener.systemApp': 'Elegir aplicación...',
|
||||
'sftp.opener.systemAppDescription': 'Selecciona una aplicación de tu computadora',
|
||||
'sftp.opener.onlySystemApp': 'Este archivo solo se puede abrir con una aplicación externa',
|
||||
'sftp.opener.noAppsAvailable': 'No hay aplicaciones disponibles',
|
||||
'sftp.opener.noExtension': 'archivos sin extensión',
|
||||
'sftp.opener.setDefault': 'Usar siempre esto para archivos {ext}',
|
||||
'sftp.opener.confirmTitle': '¿Establecer como predeterminado?',
|
||||
'sftp.opener.confirmDescription': '¿Quieres usar siempre {app} para archivos {ext}?',
|
||||
'sftp.opener.yesRemember': 'Sí, recordar esta elección',
|
||||
'sftp.opener.justOnce': 'Solo esta vez',
|
||||
'sftp.opener.confirm.title': 'Establecer aplicación predeterminada',
|
||||
'sftp.opener.confirm.desc': '¿Quieres abrir siempre los archivos .{ext} con esta aplicación?',
|
||||
'sftp.editor.title': 'Editor de texto',
|
||||
'sftp.editor.save': 'Guardar en remoto',
|
||||
'sftp.editor.saving': 'Guardando...',
|
||||
'sftp.editor.saved': 'Guardado correctamente',
|
||||
'sftp.editor.saveFailed': 'No se pudo guardar el archivo',
|
||||
'sftp.editor.unsavedChanges': 'Tienes cambios sin guardar. ¿Cerrar de todos modos?',
|
||||
'sftp.editor.syntaxHighlight': 'Resaltado de sintaxis',
|
||||
'sftp.preview.title': 'Vista previa de imagen',
|
||||
'sftp.preview.zoomIn': 'Acercar',
|
||||
'sftp.preview.zoomOut': 'Alejar',
|
||||
'sftp.preview.resetZoom': 'Restablecer zoom',
|
||||
'sftp.preview.fitToWindow': 'Ajustar a la ventana',
|
||||
|
||||
// Settings > SFTP File Associations
|
||||
'settings.tab.sftpFileAssociations': 'SFTP',
|
||||
'settings.sftp.transferConcurrency': 'Concurrencia de transferencias',
|
||||
'settings.sftp.transferConcurrency.desc': 'Cantidad máxima de archivos transferidos en paralelo para el mismo endpoint, incluidos los contenidos de carpetas y los archivos seleccionados por separado. Los valores más altos pueden mejorar la velocidad, pero pueden abrumar a algunos servidores.',
|
||||
'settings.sftp.transferPoolIdleTtl': 'Mantener activa la conexión de transferencia',
|
||||
'settings.sftp.transferPoolIdleTtl.desc': 'Cuánto tiempo las conexiones SFTP dedicadas de transferencia permanecen activas después de estar inactivas. Los valores más largos reducen la demora de arranque en frío antes de la siguiente subida o descarga. Las transferencias ocupadas nunca se cierran.',
|
||||
'settings.sftp.transferPoolIdleTtl.1m': '1 minuto',
|
||||
'settings.sftp.transferPoolIdleTtl.5m': '5 minutos',
|
||||
'settings.sftp.transferPoolIdleTtl.15m': '15 minutos',
|
||||
'settings.sftp.transferPoolIdleTtl.30m': '30 minutos',
|
||||
'settings.sftp.transferPoolIdleTtl.never': 'Hasta que se cierre la aplicación',
|
||||
'settings.ssh.transportIdleTtl': 'Mantener activa la conexión SSH',
|
||||
'settings.ssh.transportIdleTtl.desc': 'Cuánto tiempo una conexión SSH compartida permanece activa después de liberar el último terminal, la exploración SFTP, la transferencia masiva o el arrendamiento de reenvío de puertos. Mientras esté activa, reconectarse al mismo host puede omitir la reautenticación (incluida la MFA). Las sesiones y transferencias activas nunca se cierran.',
|
||||
'settings.ssh.transportIdleTtl.1m': '1 minuto',
|
||||
'settings.ssh.transportIdleTtl.5m': '5 minutos',
|
||||
'settings.ssh.transportIdleTtl.15m': '15 minutos',
|
||||
'settings.ssh.transportIdleTtl.30m': '30 minutos',
|
||||
'settings.ssh.transportIdleTtl.never': 'Hasta que se cierre la aplicación',
|
||||
'settings.sftp.defaultOpener': 'Aplicación predeterminada para abrir archivos',
|
||||
'settings.sftp.defaultOpener.desc': 'Elige la aplicación predeterminada para abrir archivos sin una asociación de archivos específica',
|
||||
'settings.sftp.defaultOpener.ask': 'Preguntar siempre',
|
||||
'settings.sftp.defaultOpener.askDesc': 'Muestra un diálogo para elegir una aplicación cada vez',
|
||||
'settings.sftp.defaultOpener.builtInDesc': 'Abrir archivos de texto en el editor integrado por defecto',
|
||||
'settings.sftp.defaultOpener.systemApp': 'Elegir aplicación...',
|
||||
'settings.sftp.defaultOpener.systemAppDesc': 'Abrir archivos con una aplicación específica por defecto',
|
||||
'settings.sftpFileAssociations.title': 'Asociaciones de archivos SFTP',
|
||||
'settings.sftpFileAssociations.desc': 'Configura las aplicaciones predeterminadas para abrir archivos por extensión',
|
||||
'settings.sftpFileAssociations.extension': 'Extensión',
|
||||
'settings.sftpFileAssociations.application': 'Aplicación',
|
||||
'settings.sftpFileAssociations.noAssociations': 'No hay asociaciones de archivos configuradas',
|
||||
'settings.sftpFileAssociations.remove': 'Quitar',
|
||||
'settings.sftpFileAssociations.removeConfirm': '¿Quitar la asociación para .{ext}?',
|
||||
|
||||
// Settings > SFTP Behavior
|
||||
'settings.sftp.doubleClickBehavior': 'Comportamiento del doble clic',
|
||||
'settings.sftp.doubleClickBehavior.desc': 'Elige la acción al hacer doble clic en un archivo en la vista SFTP',
|
||||
'settings.sftp.doubleClickBehavior.open': 'Abrir archivo',
|
||||
'settings.sftp.doubleClickBehavior.transfer': 'Transferir al otro panel',
|
||||
'settings.sftp.doubleClickBehavior.openDesc': 'Abrir el archivo en la aplicación predeterminada',
|
||||
'settings.sftp.doubleClickBehavior.transferDesc': 'Transferir el archivo al host activo del otro panel',
|
||||
'settings.sshDeepLink.title': 'Enlaces SSH y Telnet',
|
||||
'settings.sshDeepLink.enable': 'Abrir enlaces ssh:// y telnet:// con Netcatty',
|
||||
'settings.sshDeepLink.enableDesc': 'Permite que Netcatty maneje los enlaces ssh:// y telnet:// de los navegadores y otras aplicaciones, además de lanzamientos al estilo PuTTY como -ssh user@host -P 22 -pw password.',
|
||||
'settings.explorerContextMenu.title': 'Explorador de Windows',
|
||||
'settings.explorerContextMenu.enable': 'Mostrar "Abrir en Netcatty" en los menús contextuales de carpetas',
|
||||
'settings.explorerContextMenu.enableDesc': 'Agrega un elemento de clic derecho para las carpetas y los fondos de carpeta, para que puedas abrir una terminal local allí. Se aplica de inmediato; no es necesario reinstalar.',
|
||||
'settings.jmsDeepLink.title': 'Enlaces de JumpServer',
|
||||
'settings.jmsDeepLink.enable': 'Manejar enlaces jms://',
|
||||
'settings.jmsDeepLink.enableDesc': 'Registra a Netcatty como el controlador de los enlaces jms:// del cliente de JumpServer. Puede entrar en conflicto con el cliente oficial de JumpServer.',
|
||||
|
||||
// Settings > SFTP Auto Sync
|
||||
'settings.sftp.autoSync': 'Sincronización automática con el remoto',
|
||||
'settings.sftp.autoSync.desc': 'Sincroniza automáticamente los cambios de archivos de vuelta al servidor remoto al abrir archivos con aplicaciones externas',
|
||||
'settings.sftp.autoSync.enable': 'Habilitar la sincronización automática',
|
||||
'settings.sftp.autoSync.enableDesc': 'Cuando guardas un archivo en una aplicación externa, los cambios se subirán automáticamente al servidor remoto',
|
||||
|
||||
// Settings > SFTP Auto Open Sidebar
|
||||
'settings.sftp.autoOpenSidebar': 'Abrir la barra lateral automáticamente al conectar',
|
||||
'settings.sftp.autoOpenSidebar.desc': 'Abre automáticamente la barra lateral del explorador de archivos SFTP al conectar a un host',
|
||||
'settings.sftp.autoOpenSidebar.enable': 'Habilitar la apertura automática de la barra lateral',
|
||||
'settings.sftp.autoOpenSidebar.enableDesc': 'La barra lateral de SFTP se abrirá automáticamente cuando una sesión de terminal se conecte a un host remoto',
|
||||
'settings.sftp.followTerminalCwd': 'Seguir el directorio de la terminal',
|
||||
'settings.sftp.followTerminalCwd.desc': 'Sincroniza automáticamente el explorador SFTP de la barra lateral con el directorio de trabajo de la terminal (alternar en la barra de herramientas)',
|
||||
'settings.sftp.followTerminalCwd.enable': 'Habilitar el seguimiento del directorio de la terminal por defecto',
|
||||
'settings.sftp.followTerminalCwd.enableDesc': 'Cuando la barra lateral de SFTP está abierta, el modo de seguimiento permanece activado por defecto y se actualiza después de los comandos cd de la terminal',
|
||||
|
||||
'settings.sftp.defaultViewMode': 'Modo de vista predeterminado',
|
||||
'settings.sftp.defaultViewMode.desc': 'Elige el modo de vista predeterminado al abrir una pestaña SFTP nueva. Las preferencias por host anulan esta configuración.',
|
||||
'settings.sftp.defaultViewMode.list': 'Vista de lista',
|
||||
'settings.sftp.defaultViewMode.listDesc': 'Muestra los archivos en una lista plana del directorio actual',
|
||||
'settings.sftp.defaultViewMode.tree': 'Vista de árbol',
|
||||
'settings.sftp.defaultViewMode.treeDesc': 'Muestra los archivos en una estructura de árbol jerárquica',
|
||||
|
||||
'sftp.autoSync.success': 'Archivo sincronizado con el remoto: {fileName}',
|
||||
'sftp.autoSync.error': 'No se pudo sincronizar el archivo: {error}',
|
||||
|
||||
// SFTP Folder Upload Progress
|
||||
'sftp.upload.progress': 'Subiendo {current} de {total} archivos...',
|
||||
'sftp.upload.uploading': 'Subiendo...',
|
||||
'sftp.upload.compressing': 'Comprimiendo...',
|
||||
'sftp.upload.extracting': 'Extrayendo...',
|
||||
'sftp.upload.scanning': 'Escaneando archivos...',
|
||||
'sftp.upload.completed': 'Completado',
|
||||
'sftp.upload.compressed': 'Transferencia comprimida',
|
||||
'sftp.upload.currentFile': 'Actual: {fileName}',
|
||||
'sftp.upload.cancelled': 'Subida cancelada',
|
||||
'sftp.upload.noFiles': 'No hay archivos para subir',
|
||||
'sftp.upload.partialSuccess': 'Se subieron {success} archivo(s); {failed} fallaron',
|
||||
'sftp.upload.cancel': 'Cancelar',
|
||||
'sftp.upload.completedToPath': 'Subido a {path}',
|
||||
|
||||
// SFTP Download
|
||||
'sftp.download.completed': 'Descargado',
|
||||
'sftp.download.cancelled': 'Descarga cancelada',
|
||||
|
||||
// SFTP Reconnecting
|
||||
'sftp.reconnecting.title': 'Reconectando...',
|
||||
'sftp.reconnecting.desc': 'Se perdió la conexión, intentando reconectar',
|
||||
'sftp.reconnected': 'Conexión restablecida',
|
||||
'sftp.error.reconnectFailed': 'No se pudo reconectar. Inténtalo de nuevo.',
|
||||
'sftp.error.connectionLostManual': 'Se perdió la conexión. Vuelve a conectar manualmente.',
|
||||
'sftp.error.connectionLostReconnecting': 'Se perdió la conexión. Reconectando...',
|
||||
'sftp.error.sessionLost': 'Se perdió la sesión SFTP. Vuelve a conectar.',
|
||||
|
||||
// Settings > SFTP Show Hidden Files
|
||||
'settings.sftp.showHiddenFiles': 'Mostrar archivos ocultos',
|
||||
'settings.sftp.showHiddenFiles.desc': 'Muestra los archivos ocultos (dotfiles en Unix/macOS y archivos con el atributo oculto en Windows) en el explorador de archivos SFTP.',
|
||||
'settings.sftp.showHiddenFiles.enable': 'Mostrar archivos ocultos',
|
||||
'settings.sftp.showHiddenFiles.enableDesc': 'Muestra los archivos ocultos al explorar sistemas de archivos locales y remotos',
|
||||
|
||||
// Settings > SFTP Compressed Upload
|
||||
'settings.sftp.compressedUpload': 'Transferencia de carpetas comprimidas',
|
||||
'settings.sftp.compressedUpload.desc': 'Comprime las carpetas antes de subirlas para reducir significativamente el tiempo de transferencia.',
|
||||
'settings.sftp.compressedUpload.enable': 'Habilitar la compresión de carpetas',
|
||||
'settings.sftp.compressedUpload.enableDesc': 'Comprime automáticamente las carpetas con tar antes de la transferencia. Requiere soporte de tar en el servidor. Si no está disponible, se recurre a una transferencia normal.',
|
||||
|
||||
'settings.sftp.skipUnchanged.enable': 'Omitir archivos sin cambios',
|
||||
'settings.sftp.skipUnchanged.enableDesc': 'Omite los archivos cuyo tamaño y hora de modificación ya coinciden con el destino (estilo rsync). Deshabilitado al reemplazar una carpeta en una ruta de staging.',
|
||||
|
||||
// Quick Switcher
|
||||
'qs.search.placeholder': 'Buscar hosts o pestañas',
|
||||
'qs.jumpTo': 'Ir a',
|
||||
'qs.localTerminal': 'Terminal local',
|
||||
'qs.localShells': 'Shells locales',
|
||||
'qs.default': 'Predeterminado',
|
||||
|
||||
// Select Host panel
|
||||
'selectHost.title': 'Seleccionar host',
|
||||
'selectHost.noHostsFound': 'No se encontraron hosts',
|
||||
'selectHost.newHost': 'Host nuevo',
|
||||
'selectHost.continue': 'Continuar',
|
||||
'selectHost.continueWithCount': 'Continuar ({count} seleccionados)',
|
||||
'selectHost.toggleGroup': 'Alternar todos los hosts en {name}',
|
||||
'selectHost.toggleHost': 'Alternar {name}',
|
||||
|
||||
// Quick Connect
|
||||
'quickConnect.knownHost.title': '¿Estás seguro de que quieres conectarte?',
|
||||
'quickConnect.knownHost.authenticity': 'No se puede establecer la autenticidad de {hostname}.',
|
||||
'quickConnect.knownHost.fingerprintLabel': 'La huella digital de {keyType} es SHA256:',
|
||||
'quickConnect.knownHost.addQuestion': '¿Quieres agregarlo a la lista de hosts conocidos?',
|
||||
'quickConnect.knownHost.addAndContinue': 'Agregar y continuar',
|
||||
'quickConnect.addKey': 'Agregar clave',
|
||||
'quickConnect.warning.unparsedOptions': 'Se ignoraron algunos argumentos SSH: {options}',
|
||||
'quickConnect.identity.label': 'Preajuste de credenciales',
|
||||
'quickConnect.identity.placeholder': 'Elige una identidad guardada',
|
||||
'quickConnect.identity.empty': 'No se encontraron identidades guardadas',
|
||||
'quickConnect.identity.hint': 'Elige una identidad para completar automáticamente su nombre de usuario y autenticación.',
|
||||
'quickConnect.identity.selectedHint': 'Listo para conectar como {username}.',
|
||||
'quickConnect.identity.method.password': 'Contraseña',
|
||||
'quickConnect.identity.method.key': 'Clave SSH',
|
||||
'quickConnect.identity.method.certificate': 'Certificado',
|
||||
'quickConnect.identity.unavailable': 'A esta identidad le faltan credenciales utilizables en este dispositivo.',
|
||||
'quickConnect.identity.useCustom': 'Usar credenciales personalizadas',
|
||||
'quickConnect.identity.connect': 'Conectar con preajuste',
|
||||
'quickConnect.connectTitle': 'Conectar a {host}',
|
||||
|
||||
// Terminal
|
||||
'terminal.connectionErrorTitle': 'Error de conexión',
|
||||
|
||||
// Protocol select dialog
|
||||
'protocolSelect.chooseProtocol': 'Elegir protocolo',
|
||||
'protocolSelect.port': 'puerto:',
|
||||
|
||||
// Host Details
|
||||
'hostDetails.title.details': 'Detalles del host',
|
||||
'hostDetails.title.new': 'Host nuevo',
|
||||
'vault.panel.resizeWidth': 'Redimensionar el ancho del panel',
|
||||
'hostDetails.saveAria': 'Guardar',
|
||||
'hostDetails.section.address': 'Dirección',
|
||||
'hostDetails.hostname.placeholder': 'IP o nombre de host',
|
||||
'hostDetails.section.general': 'General',
|
||||
'hostDetails.section.sftp': 'Configuración de SFTP',
|
||||
'hostDetails.sftp.fileProtocol': 'Protocolo de archivos',
|
||||
'hostDetails.sftp.fileProtocol.desc': 'Auto intenta SFTP y luego recurre al modo SCP cuando el subsistema SFTP no está disponible (p. ej., algunos NAS). Fuerza SCP para hosts que solo admiten SCP.',
|
||||
'hostDetails.sftp.fileProtocol.auto': 'Auto',
|
||||
'hostDetails.sftp.fileProtocol.sftp': 'Solo SFTP',
|
||||
'hostDetails.sftp.fileProtocol.scp': 'Modo SCP',
|
||||
'hostDetails.sftp.sudo': 'Modo Sudo',
|
||||
'hostDetails.sftp.sudo.desc': 'Adquiere automáticamente privilegios de root usando la contraseña guardada',
|
||||
'hostDetails.sftp.sudo.passwordWarning': 'El modo Sudo requiere una contraseña. Configura una arriba, o asegúrate de que el servidor permita sudo sin contraseña.',
|
||||
'hostDetails.sftp.encoding': 'Codificación de nombres de archivo',
|
||||
'hostDetails.sftp.encoding.desc': 'Selecciona la codificación utilizada para decodificar y enviar los nombres de archivo SFTP.',
|
||||
'hostDetails.label.placeholder': 'Etiqueta (p. ej., servidor de producción)',
|
||||
'hostDetails.notes.label': 'Notas',
|
||||
'hostDetails.notes.placeholder': 'Hardware, proyecto, cliente, región, rol...',
|
||||
'hostDetails.notes.help': 'Admite Markdown. No guardes contraseñas ni claves privadas aquí.',
|
||||
'hostDetails.notes.tab.edit': 'Editar',
|
||||
'hostDetails.notes.tab.preview': 'Vista previa',
|
||||
'hostDetails.notes.preview.empty': 'Aún no hay nada para previsualizar.',
|
||||
'hostDetails.notes.toggle.show': 'Mostrar el editor de notas',
|
||||
'hostDetails.notes.toggle.hide': 'Ocultar el editor de notas',
|
||||
'hostDetails.group.placeholder': 'Grupo padre',
|
||||
'hostDetails.section.credentials': 'Credenciales',
|
||||
'hostDetails.section.portCredentials': 'Puerto y credenciales',
|
||||
'hostDetails.section.appearance': 'Apariencia',
|
||||
'hostDetails.distro.title': 'Distribución de Linux',
|
||||
'hostDetails.distro.desc': 'Detecta automáticamente al conectar, o anula el ícono de la distro manualmente.',
|
||||
'hostDetails.icon.title': 'Ícono del host',
|
||||
'hostDetails.icon.sectionTitle': 'Color e ícono',
|
||||
'hostDetails.icon.desc': 'Elige la fuente y el color del ícono para este host.',
|
||||
'hostDetails.icon.source': 'Fuente',
|
||||
'hostDetails.icon.manualLabel': 'Ícono manual',
|
||||
'hostDetails.icon.tab.brand': 'Marca',
|
||||
'hostDetails.icon.tab.type': 'Tipo',
|
||||
'hostDetails.icon.mode.auto': 'Automático',
|
||||
'hostDetails.icon.mode.custom': 'Personalizado',
|
||||
'hostDetails.icon.reset': 'Restablecer el ícono del host',
|
||||
'hostDetails.icon.colorLabel': 'Color del ícono',
|
||||
'hostDetails.icon.colorDefault': 'Predeterminado',
|
||||
'hostDetails.icon.colorAuto': 'Automático',
|
||||
'hostDetails.icon.colorManual': 'Manual',
|
||||
'hostDetails.icon.colorChoice': 'Color',
|
||||
'hostDetails.icon.autoColorLabel': 'Color actual',
|
||||
'hostDetails.icon.preview': 'Vista previa del ícono',
|
||||
'hostDetails.icon.showLibrary': 'Mostrar la biblioteca de íconos',
|
||||
'hostDetails.icon.hideLibrary': 'Ocultar la biblioteca de íconos',
|
||||
'hostDetails.icon.autoUsesDistro': 'Usa el ícono de la distribución de Linux y el color seleccionado para este host.',
|
||||
'hostDetails.icon.customOverridesDistro': 'El ícono integrado reemplaza la distribución de Linux para este host.',
|
||||
'hostDetails.icon.option.server': 'Servidor',
|
||||
'hostDetails.icon.option.terminal': 'Terminal',
|
||||
'hostDetails.icon.option.database': 'Base de datos',
|
||||
'hostDetails.icon.option.cloud': 'Nube',
|
||||
'hostDetails.icon.option.router': 'Router',
|
||||
'hostDetails.icon.option.shield': 'Escudo',
|
||||
'hostDetails.icon.option.code': 'Código',
|
||||
'hostDetails.icon.option.box': 'Caja',
|
||||
'hostDetails.icon.option.globe': 'Globo',
|
||||
'hostDetails.icon.option.cpu': 'CPU',
|
||||
'hostDetails.icon.option.hard-drive': 'Almacenamiento',
|
||||
'hostDetails.icon.option.network': 'Red',
|
||||
'hostDetails.icon.option.wifi': 'Inalámbrico',
|
||||
'hostDetails.icon.option.lock': 'Bloqueo',
|
||||
'hostDetails.icon.option.key': 'Clave',
|
||||
'hostDetails.icon.option.monitor': 'Monitor',
|
||||
'hostDetails.icon.option.container': 'Contenedor',
|
||||
'hostDetails.icon.option.activity': 'Actividad',
|
||||
'hostDetails.icon.option.zap': 'Rápido',
|
||||
'hostDetails.icon.option.server-cog': 'Configuración del servidor',
|
||||
'hostDetails.icon.color.blue': 'Azul',
|
||||
'hostDetails.icon.color.green': 'Verde',
|
||||
'hostDetails.icon.color.red': 'Rojo',
|
||||
'hostDetails.icon.color.amber': 'Ámbar',
|
||||
'hostDetails.icon.color.purple': 'Púrpura',
|
||||
'hostDetails.icon.color.cyan': 'Cian',
|
||||
'hostDetails.icon.color.orange': 'Naranja',
|
||||
'hostDetails.icon.color.slate': 'Pizarra',
|
||||
'hostDetails.icon.color.violet': 'Violeta',
|
||||
'hostDetails.icon.color.pink': 'Rosa',
|
||||
'hostDetails.icon.color.rose': 'Rosado',
|
||||
'hostDetails.icon.color.lime': 'Lima',
|
||||
'hostDetails.icon.color.teal': 'Verde azulado',
|
||||
'hostDetails.icon.color.sky': 'Celeste',
|
||||
'hostDetails.icon.color.indigo': 'Índigo',
|
||||
'hostDetails.icon.color.zinc': 'Zinc',
|
||||
'hostDetails.icon.color.custom': 'Color personalizado',
|
||||
'hostDetails.distro.mode': 'Fuente',
|
||||
'hostDetails.distro.mode.auto': 'Detección automática',
|
||||
'hostDetails.distro.mode.manual': 'Anulación manual',
|
||||
'hostDetails.distro.detectedLabel': 'Actual',
|
||||
'hostDetails.distro.manualLabel': 'Anulación',
|
||||
'hostDetails.distro.pending': 'Detectar después de la primera conexión',
|
||||
'hostDetails.distro.unknown': 'Desconocido',
|
||||
'hostDetails.distro.option.linux': 'Linux genérico',
|
||||
'hostDetails.distro.option.ubuntu': 'Ubuntu',
|
||||
'hostDetails.distro.option.debian': 'Debian',
|
||||
'hostDetails.distro.option.centos': 'CentOS',
|
||||
'hostDetails.distro.option.rocky': 'Rocky Linux',
|
||||
'hostDetails.distro.option.fedora': 'Fedora',
|
||||
'hostDetails.distro.option.arch': 'Arch Linux',
|
||||
'hostDetails.distro.option.alpine': 'Alpine',
|
||||
'hostDetails.distro.option.amazon': 'Amazon Linux',
|
||||
'hostDetails.distro.option.opensuse': 'openSUSE / SLES',
|
||||
'hostDetails.distro.option.redhat': 'Red Hat / RHEL',
|
||||
'hostDetails.distro.option.almalinux': 'AlmaLinux',
|
||||
'hostDetails.distro.option.alinux': 'Alibaba Cloud Linux',
|
||||
'hostDetails.distro.option.openeuler': 'openEuler',
|
||||
'hostDetails.distro.option.freebsd': 'FreeBSD',
|
||||
'hostDetails.distro.option.oracle': 'Oracle Linux',
|
||||
'hostDetails.distro.option.kali': 'Kali Linux',
|
||||
'hostDetails.distro.option.cisco': 'Cisco',
|
||||
'hostDetails.distro.option.juniper': 'Juniper Networks',
|
||||
'hostDetails.distro.option.huawei': 'Huawei',
|
||||
'hostDetails.distro.option.h3c': 'H3C',
|
||||
'hostDetails.distro.option.hpe': 'HPE',
|
||||
'hostDetails.distro.option.mikrotik': 'MikroTik',
|
||||
'hostDetails.distro.option.fortinet': 'Fortinet',
|
||||
'hostDetails.distro.option.paloalto': 'Palo Alto Networks',
|
||||
'hostDetails.distro.option.zyxel': 'ZyXEL',
|
||||
'hostDetails.distro.option.ruijie': 'Ruijie',
|
||||
'hostDetails.section.mosh': 'Mosh',
|
||||
'hostDetails.section.et': 'EternalTerminal',
|
||||
'hostDetails.et.port': 'Puerto del servidor ET',
|
||||
'hostDetails.et.port.desc': 'Puerto en el que escucha etserver (predeterminado 2022)',
|
||||
'hostDetails.username.placeholder': 'Nombre de usuario',
|
||||
'hostDetails.password.placeholder': 'Contraseña',
|
||||
'hostDetails.password.show': 'Mostrar contraseña',
|
||||
'hostDetails.password.hide': 'Ocultar contraseña',
|
||||
'hostDetails.password.save': 'Guardar contraseña',
|
||||
'hostDetails.auth.method': 'Método de inicio de sesión',
|
||||
'hostDetails.auth.auto': 'Automático',
|
||||
'hostDetails.auth.auto.desc': 'Intenta con el agente SSH y las claves locales, y luego usa la contraseña guardada.',
|
||||
'hostDetails.auth.passwordOnly': 'Solo contraseña',
|
||||
'hostDetails.auth.password.desc': 'Usa solo la contraseña guardada y las solicitudes interactivas de contraseña.',
|
||||
'hostDetails.auth.key': 'Clave',
|
||||
'hostDetails.auth.key.desc': 'Usa la clave seleccionada, con la contraseña guardada como respaldo cuando esté presente.',
|
||||
'hostDetails.auth.certificate': 'Certificado',
|
||||
'hostDetails.auth.certificate.desc': 'Usa el certificado SSH seleccionado, con la contraseña guardada como respaldo cuando esté presente.',
|
||||
'hostDetails.auth.mfaFirst': 'Preferir autenticación interactiva',
|
||||
'hostDetails.auth.mfaFirst.desc': 'Para servidores que requieren contraseña y MFA a través de keyboard-interactive.',
|
||||
'hostDetails.auth.credentialRequired': 'Selecciona una clave o un certificado antes de guardar este método de inicio de sesión.',
|
||||
'hostDetails.identity.suggestions': 'Identidades',
|
||||
'hostDetails.identity.missing': 'Identidad no encontrada',
|
||||
'hostDetails.credential.keyCertificate': 'Clave, certificado, archivo de clave local',
|
||||
'hostDetails.credential.key': 'Clave',
|
||||
'hostDetails.credential.certificate': 'Certificado',
|
||||
'hostDetails.credential.localKeyFile': 'Archivo de clave local',
|
||||
'hostDetails.credential.localKeyFilePlaceholder': '~/.ssh/id_ed25519',
|
||||
'hostDetails.credential.browseKeyFile': 'Explorar...',
|
||||
'hostDetails.credential.missing': 'Credencial no encontrada',
|
||||
'hostDetails.section.systemSshAgent': 'Agente SSH del sistema',
|
||||
'hostDetails.systemSshAgent': 'Usar el agente SSH del sistema para iniciar sesión',
|
||||
'hostDetails.systemSshAgent.desc': 'Usa claves ya desbloqueadas por macOS, Linux, Windows, 1Password u otro agente SSH compatible',
|
||||
'hostDetails.systemSshAgent.socket': 'Socket del agente',
|
||||
'hostDetails.systemSshAgent.socket.desc': 'Déjalo vacío para usar SSH_AUTH_SOCK',
|
||||
'hostDetails.systemSshAgent.identitiesOnly': 'Usar solo las claves configuradas',
|
||||
'hostDetails.systemSshAgent.identitiesOnly.desc': 'No intentar con claves no relacionadas actualmente cargadas en el agente',
|
||||
'hostDetails.systemSshAgent.notRunning': 'El agente SSH del sistema no está disponible',
|
||||
'hostDetails.systemSshAgent.notRunningHint': 'Inicia o desbloquea tu agente SSH, o proporciona la ruta de su socket arriba.',
|
||||
'hostDetails.keys.search': 'Buscar claves...',
|
||||
'hostDetails.keys.empty': 'No hay claves disponibles',
|
||||
'hostDetails.certs.search': 'Buscar certificados...',
|
||||
'hostDetails.certs.empty': 'No hay certificados disponibles',
|
||||
'hostDetails.agentForwarding': 'Reenviar el agente SSH',
|
||||
'hostDetails.agentForwarding.desc': 'Permite que el servidor remoto use tus claves SSH locales (p. ej., para operaciones de git)',
|
||||
'hostDetails.agentForwarding.agentNotRunning': 'El agente SSH no está disponible',
|
||||
'hostDetails.agentForwarding.agentNotRunningHint': 'No se detectó ningún agente SSH. Habilita el agente de autenticación de OpenSSH en los servicios de Windows, o usa un agente compatible como Bitwarden, 1Password o gpg-agent.',
|
||||
'hostDetails.section.agentForwarding': 'Agente SSH',
|
||||
'hostDetails.x11Forwarding': 'Reenviar aplicaciones X11',
|
||||
'hostDetails.x11Forwarding.desc': 'Muestra las aplicaciones gráficas remotas en tu escritorio local cuando se ejecuta un servidor X local.',
|
||||
'hostDetails.section.x11Forwarding': 'Reenvío X11',
|
||||
'hostDetails.section.deviceType': 'Tipo de dispositivo',
|
||||
'hostDetails.deviceType': 'Modo de dispositivo de red',
|
||||
'hostDetails.deviceType.desc': 'Habilítalo para equipos de red (switches, routers, firewalls) conectados mediante SSH. Los comandos se envían tal cual, sin envoltura de shell, compatible con las CLI de proveedores como Huawei VRP y Cisco IOS.',
|
||||
'hostDetails.deviceType.warning': 'Los comandos del agente de IA se enviarán directamente sin seguimiento del código de salida. Habilítalo solo para dispositivos que no ejecutan un shell estándar.',
|
||||
'hostDetails.section.sshAlgorithms': 'Algoritmos SSH',
|
||||
'hostDetails.section.terminalBehavior': 'Comportamiento de la terminal',
|
||||
'hostDetails.lineTimestamps': 'Mostrar marcas de tiempo de salida',
|
||||
'hostDetails.lineTimestamps.desc': 'Muestra la hora local junto a las líneas de salida visibles de este host sin cambiar el texto de la terminal.',
|
||||
'hostDetails.legacyAlgorithms': 'Permitir algoritmos heredados',
|
||||
'hostDetails.legacyAlgorithms.desc': 'Habilita algoritmos SSH obsoletos (diffie-hellman-group1, ssh-dss, 3des-cbc, etc.) para conectar con equipos de red más antiguos.',
|
||||
'hostDetails.legacyAlgorithms.warning': 'Estos algoritmos tienen debilidades de seguridad conocidas. Habilítalos solo para dispositivos heredados que no admiten criptografía moderna.',
|
||||
'hostDetails.skipEcdsaHostKey': 'Omitir la clave de host ECDSA',
|
||||
'hostDetails.skipEcdsaHostKey.desc': 'Algunos switches antiguos de Huawei / Cisco generan firmas de clave de host ECDSA no estándar que provocan "signature verification failed". Al activar esto, se eliminan todos los ecdsa-sha2-* de la oferta del cliente para que la negociación recurra a RSA / Ed25519.',
|
||||
'hostDetails.algorithms.advanced': 'Anulaciones avanzadas de algoritmos',
|
||||
'hostDetails.algorithms.advanced.desc': 'Reemplaza la lista de algoritmos ofrecidos para cualquier categoría por cada host. Dejar una categoría sin cambios usa la predeterminada; seleccionar un subconjunto reemplaza por completo la lista predeterminada. Los valores incorrectos pueden hacer que el host sea inalcanzable.',
|
||||
'hostDetails.algorithms.inheritedNotice': 'El grupo actual tiene anulaciones de algoritmos configuradas para: {categories}. El botón "Restablecer" aquí recurre a las listas del grupo, no a las predeterminadas de Netcatty. Para ignorar la restricción del grupo, borra la anulación en la configuración de algoritmos del grupo.',
|
||||
'hostDetails.algorithms.customized': 'personalizado',
|
||||
'hostDetails.algorithms.reset': 'Restablecer',
|
||||
'hostDetails.algorithms.category.kex': 'Intercambio de claves (KEX)',
|
||||
'hostDetails.algorithms.category.cipher': 'Cifrado',
|
||||
'hostDetails.algorithms.category.hmac': 'MAC (HMAC)',
|
||||
'hostDetails.algorithms.category.serverHostKey': 'Clave de host',
|
||||
'hostDetails.algorithms.category.compress': 'Compresión',
|
||||
'hostDetails.section.keepalive': 'Keepalive',
|
||||
'hostDetails.keepalive.override': 'Anular el keepalive global',
|
||||
'hostDetails.keepalive.desc': 'Usa una política de keepalive personalizada para este host en lugar de la configuración global. Útil para routers o switches antiguos cuyo servidor SSH no responde a las solicitudes keepalive@openssh.com: establece el intervalo en 0 para deshabilitar el keepalive por completo en este host.',
|
||||
'hostDetails.keepalive.interval': 'Intervalo (segundos)',
|
||||
'hostDetails.keepalive.countMax': 'Máximo de keepalives sin respuesta',
|
||||
'hostDetails.keepalive.disabledHint': 'Intervalo = 0 deshabilita el keepalive para este host. La sesión dependerá de los tiempos de espera a nivel de TCP para detectar una conexión caída.',
|
||||
'hostDetails.section.sshTimeouts': 'Tiempos de espera de conexión SSH',
|
||||
'hostDetails.sshTimeouts.tcpConnect': 'Tiempo de espera de TCP',
|
||||
'hostDetails.sshTimeouts.tcpConnect.desc': 'Segundos de espera para la conexión de red a este host.',
|
||||
'hostDetails.sshTimeouts.authReady': 'Tiempo de espera de autenticación SSH',
|
||||
'hostDetails.sshTimeouts.authReady.desc': 'Segundos de espera después de conectar para que terminen la negociación y la autenticación SSH.',
|
||||
'hostDetails.backspaceBehavior': 'Comportamiento de la tecla Retroceso',
|
||||
'hostDetails.backspaceBehavior.default': 'Predeterminado',
|
||||
'hostDetails.jumpHosts': 'Proxy mediante hosts',
|
||||
'hostDetails.jumpHosts.hops': '{count} salto(s)',
|
||||
'hostDetails.jumpHosts.direct': 'Directo',
|
||||
'hostDetails.jumpHosts.configure': 'Configurar hosts proxy',
|
||||
'hostDetails.proxy': 'Proxy mediante HTTP/SOCKS5/Comando',
|
||||
'hostDetails.proxy.none': 'Ninguno',
|
||||
'hostDetails.proxy.edit': 'Editar proxy',
|
||||
'hostDetails.proxy.configure': 'Configurar proxy',
|
||||
'hostDetails.proxyPanel.title': 'Proxy mediante HTTP/SOCKS5/Comando',
|
||||
'hostDetails.proxyPanel.hostPlaceholder': 'Host del proxy',
|
||||
'hostDetails.proxyPanel.command': 'ProxyCommand',
|
||||
'hostDetails.proxyPanel.commandPlaceholder': 'cloudflared access ssh --hostname %h',
|
||||
'hostDetails.proxyPanel.commandHelp': 'Usa %h para el host de destino, %p para el puerto de destino y %% para un porcentaje literal.',
|
||||
'hostDetails.proxyPanel.credentials': 'Credenciales',
|
||||
'hostDetails.proxyPanel.usernamePlaceholder': 'Nombre de usuario',
|
||||
'hostDetails.proxyPanel.passwordPlaceholder': 'Contraseña',
|
||||
'hostDetails.proxyPanel.identities': 'Identidades',
|
||||
'hostDetails.proxyPanel.keychainIdentity': 'Identidad del llavero',
|
||||
'hostDetails.proxyPanel.manualCredentials': 'Credenciales manuales',
|
||||
'hostDetails.proxyPanel.remove': 'Quitar proxy',
|
||||
'hostDetails.proxyPanel.savedProxy': 'Proxy guardado',
|
||||
'hostDetails.proxyPanel.selectSaved': 'Seleccionar proxy guardado',
|
||||
'hostDetails.proxyPanel.customProxy': 'Proxy personalizado',
|
||||
'hostDetails.proxyPanel.missing': 'Faltante',
|
||||
'hostDetails.proxyPanel.missingSaved': 'Falta el proxy guardado',
|
||||
'hostDetails.proxyPanel.missingIdentity': 'Falta la identidad del llavero',
|
||||
'hostDetails.proxyPanel.incompleteIdentity': 'La identidad del proxy necesita un nombre de usuario y una contraseña',
|
||||
'hostDetails.proxyPanel.unreadableIdentity': 'No se puede leer la contraseña de la identidad del proxy en este dispositivo',
|
||||
'hostDetails.proxyPanel.error.required': 'Se requieren el host y el puerto del proxy, o un ProxyCommand.',
|
||||
'hostDetails.envVars': 'Variables de entorno',
|
||||
'hostDetails.envVars.add': 'Agregar variable de entorno',
|
||||
'hostDetails.envVars.title': 'Variables de entorno',
|
||||
'hostDetails.envVars.desc': 'Establece una variable de entorno para {host}.',
|
||||
'hostDetails.envVars.note':
|
||||
'Algunos servidores SSH por defecto solo permiten variables con el prefijo LC_ y LANG_.',
|
||||
'hostDetails.envVars.variable': 'Variable',
|
||||
'hostDetails.envVars.value': 'Valor',
|
||||
'hostDetails.envVars.newVariable': 'Variable nueva',
|
||||
'hostDetails.envVars.variableName': 'Nombre de la variable',
|
||||
'hostDetails.chain.title': 'Editar cadena',
|
||||
'hostDetails.chain.desc': 'Agregar otro host creará una conexión a {host}.',
|
||||
'hostDetails.chain.addHost': 'Agregar un host',
|
||||
'hostDetails.chain.target': 'Destino',
|
||||
'hostDetails.chain.availableHosts': 'Hosts disponibles',
|
||||
'hostDetails.chain.clear': 'Limpiar',
|
||||
'hostDetails.group.title': 'Grupo nuevo',
|
||||
'hostDetails.group.general': 'General',
|
||||
'hostDetails.group.namePlaceholder': 'Nombre del grupo',
|
||||
'hostDetails.group.parentPlaceholder': 'Grupo padre',
|
||||
'hostDetails.group.cloudSync': 'Sincronización en la nube',
|
||||
'hostDetails.group.addProtocol': 'Agregar protocolo',
|
||||
'hostDetails.startupCommand': 'Comando de inicio',
|
||||
'hostDetails.startupCommand.placeholder': 'Comando para ejecutar al conectar (p. ej., cd /app && ls)',
|
||||
'hostDetails.startupCommand.help':
|
||||
'Este comando se ejecutará automáticamente después de que se establezca la conexión SSH.',
|
||||
'hostDetails.startupCommand.runMode': 'Ejecución multilínea',
|
||||
'hostDetails.startupCommand.runMode.help': 'Elige cómo se envían los comandos de inicio multilínea después de la conexión.',
|
||||
'hostDetails.startupCommand.runMode.lineDelay': 'Enviar línea por línea',
|
||||
'hostDetails.startupCommand.runMode.paste': 'Enviar todo de una vez',
|
||||
'hostDetails.otherProtocols': 'Otros protocolos',
|
||||
'hostDetails.telnetOn': 'Telnet activado',
|
||||
'hostDetails.port': 'puerto',
|
||||
'hostDetails.telnet.credentials': 'Credenciales',
|
||||
'hostDetails.telnet.identity.placeholder': 'Identidad manual',
|
||||
'hostDetails.telnet.username': 'Nombre de usuario de Telnet',
|
||||
'hostDetails.telnet.password': 'Contraseña de Telnet',
|
||||
'hostDetails.charset.placeholder': 'Codificación (p. ej., UTF-8)',
|
||||
'hostDetails.telnet.add': 'Agregar protocolo Telnet',
|
||||
'hostDetails.telnet.setDefault': 'Conectar con Telnet por defecto',
|
||||
'hostDetails.tags': 'Etiquetas',
|
||||
'hostDetails.group': 'Grupo',
|
||||
'hostDetails.selectGroup': 'Seleccionar grupo',
|
||||
'hostDetails.addTag': 'Agregar una etiqueta...',
|
||||
'hostDetails.createTag': 'Crear etiqueta',
|
||||
'hostDetails.createGroup': 'Crear grupo',
|
||||
'hostDetails.plugin.title': 'Conexión de plugin',
|
||||
'hostDetails.plugin.provider.placeholder': 'Selecciona un proveedor de conexión',
|
||||
'hostDetails.plugin.provider.empty': 'No hay proveedores de conexión instalados',
|
||||
'hostDetails.plugin.authentication.placeholder': 'Selecciona un proveedor de autenticación',
|
||||
'hostDetails.plugin.authentication.none': 'Administrada por el proveedor o sin autenticación',
|
||||
'hostDetails.plugin.credential.placeholder': 'Selecciona una credencial del host',
|
||||
'hostDetails.plugin.credential.none': 'No hay credencial guardada',
|
||||
'hostDetails.plugin.credential.password': 'Identidad de contraseña',
|
||||
'hostDetails.plugin.credential.privateKey': 'Clave privada',
|
||||
'hostDetails.plugin.credential.unavailable': 'Credencial guardada no disponible',
|
||||
'hostDetails.plugin.configuration.label': 'Configuración del proveedor (JSON)',
|
||||
'hostDetails.plugin.configuration.invalid': 'La configuración debe ser un JSON válido.',
|
||||
'hostDetails.plugin.configuration.schemaInvalid': 'La configuración no coincide con el esquema del proveedor.',
|
||||
'hostDetails.plugin.unavailable': 'El proveedor configurado no está disponible. La configuración se conserva hasta que se restaure el plugin.',
|
||||
'hostDetails.plugin.useSsh': 'Usar SSH en su lugar',
|
||||
|
||||
// Host form (legacy modal)
|
||||
'hostForm.title.edit': 'Editar host',
|
||||
'hostForm.title.new': 'Host nuevo',
|
||||
'hostForm.desc.edit': 'Actualiza los detalles de conexión de este host',
|
||||
'hostForm.desc.new': 'Crea una nueva entrada de host SSH',
|
||||
'hostForm.field.label': 'Etiqueta',
|
||||
'hostForm.placeholder.label': 'Mi servidor de producción',
|
||||
'hostForm.field.hostname': 'Nombre de host / IP',
|
||||
'hostForm.placeholder.hostname': '192.168.1.1',
|
||||
'hostForm.field.port': 'Puerto',
|
||||
'hostForm.field.username': 'Nombre de usuario',
|
||||
'hostForm.field.osType': 'Tipo de SO',
|
||||
'hostForm.placeholder.selectOs': 'Seleccionar SO',
|
||||
'hostForm.field.group': 'Grupo',
|
||||
'hostForm.placeholder.group': 'p. ej., AWS, DigitalOcean',
|
||||
'hostForm.field.tags': 'Etiquetas',
|
||||
'hostForm.placeholder.addTag': 'Agregar una etiqueta...',
|
||||
'hostForm.auth.method': 'Método de autenticación',
|
||||
'hostForm.auth.password': 'Contraseña',
|
||||
'hostForm.auth.sshKey': 'Clave SSH',
|
||||
'hostForm.auth.selectKey': 'Seleccionar una clave SSH',
|
||||
'hostForm.auth.noKeys': 'No hay claves disponibles',
|
||||
'hostForm.auth.noKeysHint': 'No se encontraron claves SSH en el llavero. Crea una primero.',
|
||||
'hostForm.saveHost': 'Guardar host',
|
||||
|
||||
// Connection logs
|
||||
'logs.table.date': 'Fecha',
|
||||
'logs.table.user': 'Usuario',
|
||||
'logs.table.host': 'Host',
|
||||
'logs.table.saved': 'Guardado',
|
||||
'logs.empty.title': 'No hay registros de conexión',
|
||||
'logs.empty.desc':
|
||||
'Tu historial de conexiones aparecerá aquí cuando te conectes a hosts o abras terminales locales.',
|
||||
'logs.loadMore': 'Cargar {count} registros más',
|
||||
'logs.ongoing': 'en curso',
|
||||
'logs.localTerminal': 'Terminal local',
|
||||
'logs.action.save': 'Guardar',
|
||||
'logs.action.unsave': 'Quitar de guardados',
|
||||
'logs.action.delete': 'Eliminar',
|
||||
|
||||
// Log view
|
||||
'logView.customizeAppearance': 'Personalizar la apariencia',
|
||||
'logView.appearance': 'Apariencia',
|
||||
'logView.readOnly': 'Solo lectura',
|
||||
'logView.export': 'Exportar',
|
||||
'vault.import.plugins.title': 'Importadores de plugins',
|
||||
'vault.import.plugins.loading': 'Generando una vista previa de importación segura…',
|
||||
'vault.import.plugins.progress': '{completed} de {total} procesados {message}',
|
||||
'vault.import.plugins.preview': 'Vista previa de importación',
|
||||
'vault.import.plugins.empty': 'No se devolvieron borradores importables.',
|
||||
'vault.import.plugins.summary': '{parsed} borradores · {warnings} advertencias · {errors} errores',
|
||||
'vault.import.plugins.commit': 'Vista previa de importación',
|
||||
'vault.import.plugins.fileUnavailable': 'El archivo seleccionado no está disponible para el bridge del importador de escritorio.',
|
||||
'vault.import.plugins.notRecognized': 'Este importador no reconoció el archivo seleccionado.',
|
||||
'vault.import.plugins.containsErrors': 'La vista previa de importación contiene errores y no se puede confirmar.',
|
||||
'vault.import.plugins.committed': 'Se importaron {count} elementos validados.',
|
||||
'vault.import.plugins.duplicates': '{count} elementos duplicados se omitirán.',
|
||||
'vault.import.plugins.validationErrors': '{count} borradores fallaron la validación de host y deben corregirse mediante el importador.',
|
||||
'vault.import.plugins.kind.host': 'Host',
|
||||
'vault.import.plugins.kind.identity': 'Identidad',
|
||||
'vault.import.plugins.kind.key': 'Clave',
|
||||
'vault.import.plugins.kind.snippet': 'Fragmento',
|
||||
'vault.import.plugins.kind.group': 'Grupo',
|
||||
'vault.import.plugins.moreItems': '{count} elementos más están incluidos en esta importación.',
|
||||
'vault.import.plugins.moreDiagnostics': '{count} diagnósticos más se omitieron de esta vista previa.',
|
||||
|
||||
};
|
||||
@@ -567,6 +567,12 @@ export const ruCoreMessages: Messages = {
|
||||
'settings.terminal.behavior.dynamicTabTitle.off': 'Отключено',
|
||||
'settings.terminal.behavior.dynamicTabTitle.agent': 'Только агенты',
|
||||
'settings.terminal.behavior.dynamicTabTitle.all': 'Все сеансы',
|
||||
'settings.terminal.behavior.oscNotifications': 'Системные уведомления OSC',
|
||||
'settings.terminal.behavior.oscNotifications.desc':
|
||||
'Показывать системное уведомление, когда программа отправляет OSC 9, OSC 777 notify или kitty OSC 99. Так Codex и другие агенты сообщают о завершении хода и запросе подтверждения.',
|
||||
'settings.terminal.behavior.oscNotifications.off': 'Отключено',
|
||||
'settings.terminal.behavior.oscNotifications.unfocused': 'Когда окно не в фокусе',
|
||||
'settings.terminal.behavior.oscNotifications.always': 'Всегда',
|
||||
'settings.terminal.behavior.osc52Clipboard': 'Буфер обмена OSC-52',
|
||||
'settings.terminal.behavior.osc52Clipboard.desc':
|
||||
'Разрешить удалённым программам (tmux, vim и т. д.) доступ к локальному буферу обмена через escape-последовательности OSC-52.',
|
||||
|
||||
@@ -765,7 +765,7 @@ export const ruTerminalMessages: Messages = {
|
||||
'snippets.shortkey.recording': 'Нажмите сочетание клавиш...',
|
||||
'snippets.shortkey.hint': 'Нажмите это сочетание в терминале, чтобы быстро отправить команду.',
|
||||
'snippets.shortkey.clear': 'Очистить сочетание',
|
||||
'snippets.shortkey.error.systemConflict': 'Это сочетание конфликтует с системным сочетанием',
|
||||
'snippets.shortkey.error.systemConflict': 'Это сочетание конфликтует с «{name}»',
|
||||
'snippets.shortkey.error.snippetConflict': 'Это сочетание уже используется сниппетом: {name}',
|
||||
|
||||
// Serial Port
|
||||
|
||||
@@ -266,6 +266,10 @@ export const ruVaultMessages: Messages = {
|
||||
'sftp.moveTo.confirm': 'Переместить',
|
||||
'sftp.moveTo.pathNotFound': 'Каталог не найден или недоступен',
|
||||
'sftp.context.download': 'Скачать',
|
||||
'sftp.context.extract': 'Распаковать здесь',
|
||||
'sftp.extract.extracting': 'Распаковка {fileName}...',
|
||||
'sftp.extract.success': 'Распакован {fileName}',
|
||||
'sftp.extract.error': 'Не удалось распаковать {fileName}: {error}',
|
||||
'sftp.context.copyToOtherPane': 'Копировать в другую панель',
|
||||
'sftp.copyToOtherPane.unavailable': 'Откройте двухпанельный режим SFTP и подключите панель назначения перед копированием файлов.',
|
||||
'sftp.copyCurrentPath': 'Копировать текущий путь',
|
||||
@@ -525,7 +529,7 @@ export const ruVaultMessages: Messages = {
|
||||
'settings.sftp.doubleClickBehavior.transferDesc': 'Передать файл на активный хост другой панели',
|
||||
'settings.sshDeepLink.title': 'SSH- и Telnet-ссылки',
|
||||
'settings.sshDeepLink.enable': 'Открывать ssh:// и telnet:// ссылки в Netcatty',
|
||||
'settings.sshDeepLink.enableDesc': 'Разрешить Netcatty обрабатывать ssh:// и telnet:// ссылки из браузеров и других приложений.',
|
||||
'settings.sshDeepLink.enableDesc': 'Разрешить Netcatty обрабатывать ssh:// и telnet:// ссылки из браузеров и других приложений, а также запуски в стиле PuTTY, например -ssh user@host -P 22 -pw password.',
|
||||
'settings.explorerContextMenu.title': 'Проводник Windows',
|
||||
'settings.explorerContextMenu.enable': 'Показывать «Open in Netcatty» в контекстном меню папок',
|
||||
'settings.explorerContextMenu.enableDesc': 'Добавляет пункт в меню папок и фона папки, чтобы открыть локальный терминал в этом каталоге. Применяется сразу, переустановка не нужна.',
|
||||
|
||||
@@ -5,6 +5,8 @@ import { enSystemManagerMessages } from "./en/systemManager";
|
||||
import { enTerminalMessages } from "./en/terminal";
|
||||
import { ruSystemManagerMessages } from "./ru/systemManager";
|
||||
import { ruTerminalMessages } from "./ru/terminal";
|
||||
import { esSystemManagerMessages } from "./es/systemManager";
|
||||
import { esTerminalMessages } from "./es/terminal";
|
||||
import { zhCnSystemManagerMessages } from "./zh-CN/systemManager";
|
||||
import { zhCNVaultMessages } from "./zh-CN/vault";
|
||||
import { zhTwSystemManagerMessages } from "./zh-TW/systemManager";
|
||||
@@ -14,6 +16,7 @@ test("SSH network latency is explicit in every locale and UI surface", () => {
|
||||
const labels = [
|
||||
[enTerminalMessages, enSystemManagerMessages, "SSH network latency"],
|
||||
[ruTerminalMessages, ruSystemManagerMessages, "Сетевая задержка SSH"],
|
||||
[esTerminalMessages, esSystemManagerMessages, "Latencia de red SSH"],
|
||||
[zhCNVaultMessages, zhCnSystemManagerMessages, "SSH 网络延迟"],
|
||||
[zhTWVaultMessages, zhTwSystemManagerMessages, "SSH 網路延遲"],
|
||||
] as const;
|
||||
|
||||
@@ -5,10 +5,12 @@ import { DEFAULT_KEY_BINDINGS } from "../../../domain/models/keyBindings.ts";
|
||||
import { HOST_ICON_COLORS, HOST_ICON_IDS } from "../../../domain/hostIcon.ts";
|
||||
import zhCN from "./zh-CN.ts";
|
||||
import ru from "./ru.ts";
|
||||
import es from "./es.ts";
|
||||
|
||||
const LOCALIZED_SETTINGS_LOCALES = [
|
||||
{ name: "zh-CN", messages: zhCN },
|
||||
{ name: "ru", messages: ru },
|
||||
{ name: "es", messages: es },
|
||||
];
|
||||
|
||||
test("localized settings include names for every default shortcut", () => {
|
||||
@@ -59,6 +61,21 @@ test("localized settings include network proxy labels", () => {
|
||||
}
|
||||
});
|
||||
|
||||
test("localized settings include OSC desktop notification labels", () => {
|
||||
const keys = [
|
||||
"settings.terminal.behavior.oscNotifications",
|
||||
"settings.terminal.behavior.oscNotifications.desc",
|
||||
"settings.terminal.behavior.oscNotifications.off",
|
||||
"settings.terminal.behavior.oscNotifications.unfocused",
|
||||
"settings.terminal.behavior.oscNotifications.always",
|
||||
];
|
||||
|
||||
for (const locale of LOCALIZED_SETTINGS_LOCALES) {
|
||||
const missing = keys.filter((key) => !locale.messages[key]);
|
||||
assert.deepEqual(missing, [], `${locale.name} is missing OSC notification labels`);
|
||||
}
|
||||
});
|
||||
|
||||
test("localized settings include terminal font weight option labels", () => {
|
||||
const keys = [
|
||||
"settings.terminal.font.weight.thin",
|
||||
|
||||
@@ -3,6 +3,7 @@ import test from 'node:test';
|
||||
|
||||
import en from './en.ts';
|
||||
import ru from './ru.ts';
|
||||
import es from './es.ts';
|
||||
import zhCN from './zh-CN.ts';
|
||||
import zhTW from './zh-TW.ts';
|
||||
|
||||
@@ -14,12 +15,23 @@ const KEYS = [
|
||||
] as const;
|
||||
|
||||
test('snippet bulk-delete copy exists in every locale', () => {
|
||||
for (const [locale, messages] of Object.entries({ en, ru, zhCN, zhTW })) {
|
||||
for (const [locale, messages] of Object.entries({ en, ru, es, zhCN, zhTW })) {
|
||||
const missing = KEYS.filter((key) => !messages[key]);
|
||||
assert.deepEqual(missing, [], `${locale} is missing snippet bulk-delete copy`);
|
||||
}
|
||||
});
|
||||
|
||||
test('snippet shortkey system-conflict copy names the conflicting action', () => {
|
||||
for (const [locale, messages] of Object.entries({ en, ru, es, zhCN, zhTW })) {
|
||||
const text = messages['snippets.shortkey.error.systemConflict'];
|
||||
assert.match(
|
||||
text ?? '',
|
||||
/\{name\}/,
|
||||
`${locale} system-conflict copy should include {name}`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('English bulk-delete copy is entity-neutral and grammatical for one item', () => {
|
||||
assert.equal(
|
||||
en['snippets.selection.deleteConfirmTitle'].replace('{count}', '1'),
|
||||
|
||||
@@ -3,6 +3,7 @@ import test from 'node:test';
|
||||
|
||||
import en from './en';
|
||||
import ru from './ru';
|
||||
import es from './es';
|
||||
import zhCN from './zh-CN';
|
||||
import zhTW from './zh-TW';
|
||||
|
||||
@@ -14,7 +15,7 @@ const keys = [
|
||||
] as const;
|
||||
|
||||
test('terminal host management strings exist in every shipped locale', () => {
|
||||
for (const [locale, messages] of Object.entries({ en, 'zh-CN': zhCN, 'zh-TW': zhTW, ru })) {
|
||||
for (const [locale, messages] of Object.entries({ en, es, 'zh-CN': zhCN, 'zh-TW': zhTW, ru })) {
|
||||
for (const key of keys) {
|
||||
assert.equal(typeof messages[key], 'string', `${locale} is missing ${key}`);
|
||||
assert.notEqual(messages[key], '', `${locale} has an empty ${key}`);
|
||||
|
||||
@@ -3,6 +3,7 @@ import test from "node:test";
|
||||
|
||||
import en from "./en.ts";
|
||||
import ru from "./ru.ts";
|
||||
import es from "./es.ts";
|
||||
import zhCN from "./zh-CN.ts";
|
||||
import zhTW from "./zh-TW.ts";
|
||||
|
||||
@@ -29,6 +30,7 @@ const INLINE_IMAGE_KEYS = [
|
||||
|
||||
const LOCALES = [
|
||||
{ name: "en", messages: en },
|
||||
{ name: "es", messages: es },
|
||||
{ name: "zh-CN", messages: zhCN },
|
||||
{ name: "zh-TW", messages: zhTW },
|
||||
{ name: "ru", messages: ru },
|
||||
|
||||
@@ -3,6 +3,7 @@ import assert from "node:assert/strict";
|
||||
|
||||
import en from "./en.ts";
|
||||
import ru from "./ru.ts";
|
||||
import es from "./es.ts";
|
||||
import zhCN from "./zh-CN.ts";
|
||||
|
||||
const osc7Keys = [
|
||||
@@ -19,7 +20,7 @@ const osc7Keys = [
|
||||
] as const;
|
||||
|
||||
test("OSC 7 setup copy exists in every bundled locale", () => {
|
||||
for (const [locale, messages] of Object.entries({ en, ru, zhCN })) {
|
||||
for (const [locale, messages] of Object.entries({ en, ru, es, zhCN })) {
|
||||
for (const key of osc7Keys) {
|
||||
assert.equal(typeof messages[key], "string", `${locale} is missing ${key}`);
|
||||
assert.notEqual(messages[key], "", `${locale} has empty ${key}`);
|
||||
|
||||
@@ -3,6 +3,7 @@ import test from "node:test";
|
||||
|
||||
import en from "./en.ts";
|
||||
import ru from "./ru.ts";
|
||||
import es from "./es.ts";
|
||||
import zhCN from "./zh-CN.ts";
|
||||
import zhTW from "./zh-TW.ts";
|
||||
|
||||
@@ -26,7 +27,7 @@ const KEYS = [
|
||||
] as const;
|
||||
|
||||
test("bulk vault import and group-selection copy exists in every locale", () => {
|
||||
for (const [locale, messages] of Object.entries({ en, ru, zhCN, zhTW })) {
|
||||
for (const [locale, messages] of Object.entries({ en, ru, es, zhCN, zhTW })) {
|
||||
const missing = KEYS.filter((key) => !messages[key]);
|
||||
assert.deepEqual(missing, [], `${locale} is missing bulk vault labels`);
|
||||
}
|
||||
|
||||
@@ -833,6 +833,10 @@ export const zhCNCoreMessages: Messages = {
|
||||
'sftp.moveTo.confirm': '移动',
|
||||
'sftp.moveTo.pathNotFound': '目录不存在或无法访问',
|
||||
'sftp.context.download': '下载',
|
||||
'sftp.context.extract': '解压到当前目录',
|
||||
'sftp.extract.extracting': '正在解压 {fileName}...',
|
||||
'sftp.extract.success': '已解压 {fileName}',
|
||||
'sftp.extract.error': '解压 {fileName} 失败:{error}',
|
||||
'sftp.context.copyToOtherPane': '复制到另一侧',
|
||||
'sftp.copyToOtherPane.unavailable': '请打开双栏 SFTP 文件管理,并连接目标侧后再复制文件。',
|
||||
'sftp.copyCurrentPath': '复制当前路径',
|
||||
|
||||
@@ -131,7 +131,7 @@ export const zhCNTerminalMessages: Messages = {
|
||||
'settings.sftp.doubleClickBehavior.transferDesc': '将文件传输到另一窗格的活动主机',
|
||||
'settings.sshDeepLink.title': 'SSH 和 Telnet 链接',
|
||||
'settings.sshDeepLink.enable': '用 Netcatty 打开 ssh:// 和 telnet:// 链接',
|
||||
'settings.sshDeepLink.enableDesc': '允许 Netcatty 接管来自浏览器和其他应用的 ssh://、telnet:// 链接。',
|
||||
'settings.sshDeepLink.enableDesc': '允许 Netcatty 接管来自浏览器和其他应用的 ssh://、telnet:// 链接,以及堡垒机常用的 PuTTY 启动参数(例如 -ssh user@host -P 22 -pw password)。',
|
||||
'settings.explorerContextMenu.title': 'Windows 资源管理器',
|
||||
'settings.explorerContextMenu.enable': '在文件夹右键菜单中显示 “Open in Netcatty”',
|
||||
'settings.explorerContextMenu.enableDesc': '为文件夹和文件夹空白处添加右键项,可直接在该目录打开本地终端。开关立即生效,无需重装。',
|
||||
@@ -338,6 +338,12 @@ export const zhCNTerminalMessages: Messages = {
|
||||
'settings.terminal.behavior.dynamicTabTitle.off': '关闭',
|
||||
'settings.terminal.behavior.dynamicTabTitle.agent': '仅 Agent',
|
||||
'settings.terminal.behavior.dynamicTabTitle.all': '全部会话',
|
||||
'settings.terminal.behavior.oscNotifications': 'OSC 桌面通知',
|
||||
'settings.terminal.behavior.oscNotifications.desc':
|
||||
'当程序发出 OSC 9、OSC 777 notify 或 kitty OSC 99 时弹出系统通知。Codex 等 Agent 用它提示回合完成或等待审批。',
|
||||
'settings.terminal.behavior.oscNotifications.off': '关闭',
|
||||
'settings.terminal.behavior.oscNotifications.unfocused': '未聚焦时',
|
||||
'settings.terminal.behavior.oscNotifications.always': '始终',
|
||||
'settings.terminal.behavior.osc52Clipboard': 'OSC-52 剪贴板',
|
||||
'settings.terminal.behavior.osc52Clipboard.desc':
|
||||
'允许远程程序(tmux、vim 等)通过 OSC-52 转义序列访问本地剪贴板。',
|
||||
@@ -833,7 +839,7 @@ export const zhCNTerminalMessages: Messages = {
|
||||
'snippets.shortkey.recording': '请按下快捷键组合...',
|
||||
'snippets.shortkey.hint': '在终端中按下此快捷键可快速发送命令。',
|
||||
'snippets.shortkey.clear': '清除快捷键',
|
||||
'snippets.shortkey.error.systemConflict': '此快捷键与系统快捷键冲突',
|
||||
'snippets.shortkey.error.systemConflict': '此快捷键与「{name}」冲突',
|
||||
'snippets.shortkey.error.snippetConflict': '此快捷键已被代码片段使用:{name}',
|
||||
|
||||
'snippets.variables.dialogTitle': '填写变量',
|
||||
|
||||
@@ -833,6 +833,10 @@ export const zhTWCoreMessages: Messages = {
|
||||
'sftp.moveTo.confirm': '移動',
|
||||
'sftp.moveTo.pathNotFound': '目錄不存在或無法存取',
|
||||
'sftp.context.download': '下載',
|
||||
'sftp.context.extract': '解壓到目前目錄',
|
||||
'sftp.extract.extracting': '正在解壓 {fileName}...',
|
||||
'sftp.extract.success': '已解壓 {fileName}',
|
||||
'sftp.extract.error': '解壓 {fileName} 失敗:{error}',
|
||||
'sftp.context.copyToOtherPane': '複製到另一側',
|
||||
'sftp.copyToOtherPane.unavailable': '請開啟雙欄 SFTP 檔案管理,並連線目標側後再複製檔案。',
|
||||
'sftp.copyCurrentPath': '複製目前路徑',
|
||||
|
||||
@@ -131,7 +131,7 @@ export const zhTWTerminalMessages: Messages = {
|
||||
'settings.sftp.doubleClickBehavior.transferDesc': '將檔案傳輸到另一窗格的使用中主機',
|
||||
'settings.sshDeepLink.title': 'SSH 和 Telnet 連結',
|
||||
'settings.sshDeepLink.enable': '用 Netcatty 開啟 ssh:// 和 telnet:// 連結',
|
||||
'settings.sshDeepLink.enableDesc': '允許 Netcatty 接管來自瀏覽器和其他應用程式的 ssh://、telnet:// 連結。',
|
||||
'settings.sshDeepLink.enableDesc': '允許 Netcatty 接管來自瀏覽器和其他應用程式的 ssh://、telnet:// 連結,以及堡壘機常用的 PuTTY 啟動參數(例如 -ssh user@host -P 22 -pw password)。',
|
||||
'settings.explorerContextMenu.title': 'Windows 檔案總管',
|
||||
'settings.explorerContextMenu.enable': '在資料夾右鍵選單中顯示 “Open in Netcatty”',
|
||||
'settings.explorerContextMenu.enableDesc': '為資料夾與資料夾空白處加入右鍵項目,可直接在該目錄開啟本機終端機。開關立即生效,無需重裝。',
|
||||
@@ -338,6 +338,12 @@ export const zhTWTerminalMessages: Messages = {
|
||||
'settings.terminal.behavior.dynamicTabTitle.off': '關閉',
|
||||
'settings.terminal.behavior.dynamicTabTitle.agent': '僅 Agent',
|
||||
'settings.terminal.behavior.dynamicTabTitle.all': '全部工作階段',
|
||||
'settings.terminal.behavior.oscNotifications': 'OSC 桌面通知',
|
||||
'settings.terminal.behavior.oscNotifications.desc':
|
||||
'當程式發出 OSC 9、OSC 777 notify 或 kitty OSC 99 時顯示系統通知。Codex 等 Agent 會用它提示回合完成或等待核准。',
|
||||
'settings.terminal.behavior.oscNotifications.off': '關閉',
|
||||
'settings.terminal.behavior.oscNotifications.unfocused': '未聚焦時',
|
||||
'settings.terminal.behavior.oscNotifications.always': '一律',
|
||||
'settings.terminal.behavior.osc52Clipboard': 'OSC-52 剪貼簿',
|
||||
'settings.terminal.behavior.osc52Clipboard.desc':
|
||||
'允許遠端程式(tmux、vim 等)透過 OSC-52 轉義序列存取本機剪貼簿。',
|
||||
@@ -833,7 +839,7 @@ export const zhTWTerminalMessages: Messages = {
|
||||
'snippets.shortkey.recording': '請按下快捷鍵組合...',
|
||||
'snippets.shortkey.hint': '在終端中按下此快捷鍵可快速傳送指令。',
|
||||
'snippets.shortkey.clear': '清除快捷鍵',
|
||||
'snippets.shortkey.error.systemConflict': '此快捷鍵與系統快捷鍵衝突',
|
||||
'snippets.shortkey.error.systemConflict': '此快捷鍵與「{name}」衝突',
|
||||
'snippets.shortkey.error.snippetConflict': '此快捷鍵已被程式碼片段使用:{name}',
|
||||
|
||||
'snippets.variables.dialogTitle': '填寫變數',
|
||||
|
||||
@@ -2,6 +2,7 @@ import en, { type Messages } from './locales/en';
|
||||
import zhCN from './locales/zh-CN';
|
||||
import zhTW from './locales/zh-TW';
|
||||
import ru from './locales/ru';
|
||||
import es from './locales/es';
|
||||
|
||||
// Keep keys stable; add new locales by adding another import and map entry.
|
||||
export { type Messages };
|
||||
@@ -9,6 +10,7 @@ export { type Messages };
|
||||
export const MESSAGES_BY_LOCALE: Record<string, Messages> = {
|
||||
en,
|
||||
ru,
|
||||
es,
|
||||
'zh-CN': zhCN,
|
||||
'zh-TW': zhTW,
|
||||
};
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import {
|
||||
handleTerminalOscNotification,
|
||||
showOscDesktopNotification,
|
||||
} from "./oscDesktopNotifications.ts";
|
||||
|
||||
const installNotificationBridge = () => {
|
||||
const calls: Array<{ title: string; body: string; sessionId?: string }> = [];
|
||||
const previousWindow = (globalThis as { window?: unknown }).window;
|
||||
const previousDocument = (globalThis as { document?: { hasFocus: () => boolean } }).document;
|
||||
|
||||
(globalThis as { document: { hasFocus: () => boolean } }).document = { hasFocus: () => true };
|
||||
(globalThis as { window: { netcatty: { showSystemNotification: (payload: { title: string; body: string; sessionId?: string }) => Promise<{ shown: boolean }> } } }).window = {
|
||||
netcatty: {
|
||||
showSystemNotification: async (payload) => {
|
||||
calls.push(payload);
|
||||
return { shown: true };
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
return {
|
||||
calls,
|
||||
restore() {
|
||||
if (previousWindow === undefined) delete (globalThis as { window?: unknown }).window;
|
||||
else (globalThis as { window: unknown }).window = previousWindow;
|
||||
if (previousDocument === undefined) delete (globalThis as { document?: unknown }).document;
|
||||
else (globalThis as { document: typeof previousDocument }).document = previousDocument;
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
test("showOscDesktopNotification skips disabled and focused-unfocused modes", () => {
|
||||
const fixture = installNotificationBridge();
|
||||
try {
|
||||
showOscDesktopNotification({
|
||||
notification: { title: "", body: "hidden", protocol: "osc9" },
|
||||
mode: "off",
|
||||
sessionFocused: false,
|
||||
sessionId: "s1",
|
||||
fallbackTitle: "host",
|
||||
});
|
||||
showOscDesktopNotification({
|
||||
notification: { title: "", body: "quiet", protocol: "osc9" },
|
||||
mode: "unfocused",
|
||||
sessionFocused: true,
|
||||
sessionId: "s1",
|
||||
fallbackTitle: "host",
|
||||
});
|
||||
showOscDesktopNotification({
|
||||
notification: { title: "Codex", body: "Turn complete", protocol: "osc9" },
|
||||
mode: "always",
|
||||
sessionFocused: true,
|
||||
sessionId: "s1",
|
||||
fallbackTitle: "host",
|
||||
});
|
||||
assert.deepEqual(fixture.calls, [{
|
||||
title: "Codex",
|
||||
body: "Turn complete",
|
||||
sessionId: "s1",
|
||||
}]);
|
||||
} finally {
|
||||
fixture.restore();
|
||||
}
|
||||
});
|
||||
|
||||
test("handleTerminalOscNotification does not mark activity when notifications are off", () => {
|
||||
const fixture = installNotificationBridge();
|
||||
let activity = 0;
|
||||
try {
|
||||
assert.equal(handleTerminalOscNotification({
|
||||
notification: { title: "", body: "hidden", protocol: "osc9" },
|
||||
mode: "off",
|
||||
sessionFocused: false,
|
||||
sessionId: "s-off",
|
||||
fallbackTitle: "host",
|
||||
onSessionActivity: () => { activity += 1; },
|
||||
}), false);
|
||||
assert.equal(activity, 0);
|
||||
assert.equal(fixture.calls.length, 0);
|
||||
|
||||
assert.equal(handleTerminalOscNotification({
|
||||
notification: { title: "Codex", body: "Turn complete", protocol: "osc9" },
|
||||
mode: "always",
|
||||
sessionFocused: false,
|
||||
sessionId: "s-on",
|
||||
fallbackTitle: "host",
|
||||
onSessionActivity: () => { activity += 1; },
|
||||
}), true);
|
||||
assert.equal(activity, 1);
|
||||
assert.equal(fixture.calls.length, 1);
|
||||
} finally {
|
||||
fixture.restore();
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,60 @@
|
||||
import {
|
||||
DEFAULT_OSC_NOTIFICATION_TITLE,
|
||||
OscNotificationLimiter,
|
||||
resolveOscNotificationPresentation,
|
||||
shouldShowOscDesktopNotification,
|
||||
type OscNotification,
|
||||
} from "../../domain/terminalOscNotifications";
|
||||
import type { OscNotificationMode } from "../../domain/models/terminal";
|
||||
import { netcattyBridge } from "../../infrastructure/services/netcattyBridge";
|
||||
|
||||
const sessionLimiters = new Map<string, OscNotificationLimiter>();
|
||||
|
||||
const limiterForSession = (sessionId: string): OscNotificationLimiter => {
|
||||
const existing = sessionLimiters.get(sessionId);
|
||||
if (existing) return existing;
|
||||
const limiter = new OscNotificationLimiter();
|
||||
sessionLimiters.set(sessionId, limiter);
|
||||
return limiter;
|
||||
};
|
||||
|
||||
export function showOscDesktopNotification(options: {
|
||||
notification: OscNotification;
|
||||
mode: OscNotificationMode | undefined;
|
||||
sessionFocused: boolean;
|
||||
sessionId: string;
|
||||
fallbackTitle?: string;
|
||||
}): boolean {
|
||||
if (!shouldShowOscDesktopNotification(options.mode, {
|
||||
windowFocused: typeof document !== "undefined" && document.hasFocus(),
|
||||
sessionFocused: options.sessionFocused,
|
||||
})) {
|
||||
return false;
|
||||
}
|
||||
if (!limiterForSession(options.sessionId).allow(options.sessionId)) return false;
|
||||
|
||||
const presented = resolveOscNotificationPresentation(
|
||||
options.notification,
|
||||
options.fallbackTitle || DEFAULT_OSC_NOTIFICATION_TITLE,
|
||||
);
|
||||
void netcattyBridge.get()?.showSystemNotification?.({
|
||||
title: presented.title,
|
||||
body: presented.body,
|
||||
sessionId: options.sessionId,
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
export function handleTerminalOscNotification(options: {
|
||||
notification: OscNotification;
|
||||
mode: OscNotificationMode | undefined;
|
||||
sessionFocused: boolean;
|
||||
sessionId: string;
|
||||
fallbackTitle?: string;
|
||||
onSessionActivity?: () => void;
|
||||
}): boolean {
|
||||
if (options.mode === "off") return false;
|
||||
options.onSessionActivity?.();
|
||||
showOscDesktopNotification(options);
|
||||
return true;
|
||||
}
|
||||
@@ -125,6 +125,30 @@ test("closing a workspace session dissolves the workspace when one terminal rema
|
||||
);
|
||||
});
|
||||
|
||||
test("closing the focused session hands focus to a remaining session (#3046)", () => {
|
||||
const result = closeSessionWorkspaceLayoutState(
|
||||
[workspace(["s1", "s2", "s3", "s4"])],
|
||||
"ws-1",
|
||||
"s1",
|
||||
);
|
||||
|
||||
assert.equal(result.workspaces.length, 1);
|
||||
assert.equal(result.workspaces[0].focusedSessionId, "s2");
|
||||
assert.deepEqual(result.workspaces[0].focusSessionOrder, ["s2", "s3", "s4"]);
|
||||
});
|
||||
|
||||
test("closing a non-focused session keeps the focused session", () => {
|
||||
const result = closeSessionWorkspaceLayoutState(
|
||||
[workspace(["s1", "s2", "s3", "s4"])],
|
||||
"ws-1",
|
||||
"s3",
|
||||
);
|
||||
|
||||
assert.equal(result.workspaces.length, 1);
|
||||
assert.equal(result.workspaces[0].focusedSessionId, "s1");
|
||||
assert.deepEqual(result.workspaces[0].focusSessionOrder, ["s1", "s2", "s4"]);
|
||||
});
|
||||
|
||||
test("closing one split pane keeps the other terminal as an orphan tab", () => {
|
||||
const layoutResult = closeSessionWorkspaceLayoutState(
|
||||
[workspace(["s1", "s2"])],
|
||||
|
||||
@@ -96,7 +96,24 @@ export function closeSessionWorkspaceLayoutState(
|
||||
return null;
|
||||
}
|
||||
|
||||
return { ...workspace, root: prunedRoot };
|
||||
// Closing the focused session must hand focus to a remaining one, or a
|
||||
// focus-mode workspace is left with a dangling focusedSessionId: no pane
|
||||
// qualifies as visible and the continuously rendered hidden panes all
|
||||
// paint on top of each other (#3046).
|
||||
const nextFocusedSessionId =
|
||||
workspace.focusedSessionId && !remainingSessionIds.includes(workspace.focusedSessionId)
|
||||
? remainingSessionIds[0]
|
||||
: workspace.focusedSessionId;
|
||||
const nextFocusSessionOrder = workspace.focusSessionOrder?.filter(
|
||||
(candidateId) => remainingSessionIds.includes(candidateId),
|
||||
);
|
||||
|
||||
return {
|
||||
...workspace,
|
||||
root: prunedRoot,
|
||||
focusedSessionId: nextFocusedSessionId,
|
||||
focusSessionOrder: nextFocusSessionOrder,
|
||||
};
|
||||
})
|
||||
.filter((workspace): workspace is Workspace => Boolean(workspace));
|
||||
|
||||
|
||||
@@ -249,7 +249,7 @@ const SYNCABLE_TERMINAL_KEYS = [
|
||||
'linkModifier', 'keywordHighlightEnabled', 'keywordHighlightRules',
|
||||
'keepaliveInterval', 'keepaliveCountMax', 'disableBracketedPaste', 'clearWipesScrollback',
|
||||
'autoUploadClipboardImageOnPaste',
|
||||
'preserveSelectionOnInput', 'forcePromptNewLine', 'osc52Clipboard', 'dynamicTabTitleMode',
|
||||
'preserveSelectionOnInput', 'forcePromptNewLine', 'osc52Clipboard', 'oscNotifications', 'dynamicTabTitleMode',
|
||||
'autoCloseOnExit',
|
||||
'showHostInfoBar', 'hostInfoBarTitleMode', 'showServerStats',
|
||||
'serverStatsRefreshInterval',
|
||||
|
||||
@@ -29,6 +29,7 @@ test("quick add snippet form binds shortkeys and uses a centered Dialog modal",
|
||||
assert.match(source, /from '\.\/ui\/dialog'/);
|
||||
assert.doesNotMatch(source, /AsidePanel/);
|
||||
assert.match(source, /snippets\.field\.shortkey/);
|
||||
assert.match(source, /findActiveSystemShortcutConflict/);
|
||||
assert.match(source, /keyEventToString/);
|
||||
assert.match(source, /shortkey: shortkey \|\| undefined/);
|
||||
assert.match(source, /if \(e\.defaultPrevented\) return/);
|
||||
|
||||
@@ -12,12 +12,13 @@ import { Keyboard, Package, RotateCcw } from 'lucide-react';
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useI18n } from '../application/i18n/I18nProvider';
|
||||
import type { Snippet } from '../domain/models';
|
||||
import { findActiveSystemShortcutConflict } from '../domain/activeKeyBindings';
|
||||
import {
|
||||
type HotkeyScheme,
|
||||
type KeyBinding,
|
||||
keyEventToString,
|
||||
keyStringToKeyboardEvent,
|
||||
matchesKeyBinding,
|
||||
parseKeyCombo,
|
||||
} from '../domain/models';
|
||||
import { isScriptSnippet } from '../domain/snippetScript.ts';
|
||||
import { cn, isMacPlatform } from '../lib/utils';
|
||||
@@ -132,78 +133,6 @@ export const QuickAddSnippetDialog: React.FC<QuickAddSnippetDialogProps> = ({
|
||||
snippets.filter((s) => Boolean(s.shortkey) && s.id !== editing?.id)
|
||||
), [snippets, editing?.id]);
|
||||
|
||||
const activeSystemBindings = useMemo(() => {
|
||||
return keyBindings.flatMap((binding) => {
|
||||
const entries: { binding: string; isMac: boolean }[] = [];
|
||||
const macBinding = binding.mac;
|
||||
const pcBinding = binding.pc;
|
||||
|
||||
if (hotkeyScheme === 'mac') {
|
||||
if (macBinding && macBinding !== 'Disabled') {
|
||||
entries.push({ binding: macBinding, isMac: true });
|
||||
}
|
||||
return entries;
|
||||
}
|
||||
|
||||
if (hotkeyScheme === 'pc') {
|
||||
if (pcBinding && pcBinding !== 'Disabled') {
|
||||
entries.push({ binding: pcBinding, isMac: false });
|
||||
}
|
||||
return entries;
|
||||
}
|
||||
|
||||
if (macBinding && macBinding !== 'Disabled') {
|
||||
entries.push({ binding: macBinding, isMac: true });
|
||||
}
|
||||
if (pcBinding && pcBinding !== 'Disabled') {
|
||||
entries.push({ binding: pcBinding, isMac: false });
|
||||
}
|
||||
return entries;
|
||||
});
|
||||
}, [hotkeyScheme, keyBindings]);
|
||||
|
||||
const buildKeyEventFromString = useCallback((keyString: string) => {
|
||||
const parsed = parseKeyCombo(keyString);
|
||||
if (!parsed) return null;
|
||||
|
||||
const modifiers = new Set(parsed.modifiers);
|
||||
const key = parsed.key;
|
||||
const normalizedKey = (() => {
|
||||
switch (key) {
|
||||
case 'Space':
|
||||
return ' ';
|
||||
case '↑':
|
||||
return 'ArrowUp';
|
||||
case '↓':
|
||||
return 'ArrowDown';
|
||||
case '←':
|
||||
return 'ArrowLeft';
|
||||
case '→':
|
||||
return 'ArrowRight';
|
||||
case 'Esc':
|
||||
return 'Escape';
|
||||
case '⌫':
|
||||
return 'Backspace';
|
||||
case 'Del':
|
||||
return 'Delete';
|
||||
case '↵':
|
||||
return 'Enter';
|
||||
case '⇥':
|
||||
return 'Tab';
|
||||
default:
|
||||
return key.length === 1 ? key.toLowerCase() : key;
|
||||
}
|
||||
})();
|
||||
|
||||
return new KeyboardEvent('keydown', {
|
||||
key: normalizedKey,
|
||||
metaKey: modifiers.has('⌘') || modifiers.has('Win'),
|
||||
ctrlKey: modifiers.has('⌃') || modifiers.has('Ctrl'),
|
||||
altKey: modifiers.has('⌥') || modifiers.has('Alt'),
|
||||
shiftKey: modifiers.has('Shift'),
|
||||
});
|
||||
}, []);
|
||||
|
||||
const normalizeKeyString = useCallback((value: string) => (
|
||||
value.toLowerCase().replace(/\s+/g, '')
|
||||
), []);
|
||||
@@ -211,16 +140,14 @@ export const QuickAddSnippetDialog: React.FC<QuickAddSnippetDialogProps> = ({
|
||||
const validateShortkey = useCallback((key: string): string | null => {
|
||||
if (!key) return null;
|
||||
|
||||
const syntheticEvent = buildKeyEventFromString(key);
|
||||
if (syntheticEvent) {
|
||||
const conflictsSystem = activeSystemBindings.some(({ binding, isMac: bindingIsMac }) => (
|
||||
matchesKeyBinding(syntheticEvent, binding, bindingIsMac)
|
||||
));
|
||||
if (conflictsSystem) {
|
||||
return t('snippets.shortkey.error.systemConflict');
|
||||
}
|
||||
const systemConflict = findActiveSystemShortcutConflict(key, hotkeyScheme, keyBindings);
|
||||
if (systemConflict) {
|
||||
const nameKey = `settings.shortcuts.binding.${systemConflict.id}`;
|
||||
const name = t(nameKey) !== nameKey ? t(nameKey) : systemConflict.label;
|
||||
return t('snippets.shortkey.error.systemConflict', { name });
|
||||
}
|
||||
|
||||
const syntheticEvent = keyStringToKeyboardEvent(key);
|
||||
if (syntheticEvent) {
|
||||
for (const snippet of existingShortkeys) {
|
||||
if (snippet.shortkey && matchesKeyBinding(syntheticEvent, snippet.shortkey, isMac)) {
|
||||
@@ -239,10 +166,10 @@ export const QuickAddSnippetDialog: React.FC<QuickAddSnippetDialogProps> = ({
|
||||
|
||||
return null;
|
||||
}, [
|
||||
activeSystemBindings,
|
||||
buildKeyEventFromString,
|
||||
existingShortkeys,
|
||||
hotkeyScheme,
|
||||
isMac,
|
||||
keyBindings,
|
||||
normalizeKeyString,
|
||||
t,
|
||||
]);
|
||||
|
||||
@@ -9,7 +9,8 @@ import {
|
||||
getShellHistorySnapshot,
|
||||
subscribeShellHistory,
|
||||
} from '../application/state/shellHistoryStore';
|
||||
import { HotkeyScheme, KeyBinding, keyEventToString, ManagedSource, matchesKeyBinding, parseKeyCombo } from '../domain/models';
|
||||
import { findActiveSystemShortcutConflict } from '../domain/activeKeyBindings';
|
||||
import { HotkeyScheme, KeyBinding, keyEventToString, keyStringToKeyboardEvent, ManagedSource, matchesKeyBinding } from '../domain/models';
|
||||
import {
|
||||
buildSnippetExportPayload,
|
||||
combineSnippetImportPayloads,
|
||||
@@ -576,95 +577,21 @@ const SnippetsManager: React.FC<SnippetsManagerProps> = ({
|
||||
hotkeyScheme === 'mac' || (hotkeyScheme === 'disabled' && isMacPlatform())
|
||||
), [hotkeyScheme]);
|
||||
|
||||
const activeSystemBindings = useMemo(() => {
|
||||
return keyBindings.flatMap((binding) => {
|
||||
const entries: { binding: string; isMac: boolean }[] = [];
|
||||
const macBinding = binding.mac;
|
||||
const pcBinding = binding.pc;
|
||||
|
||||
if (hotkeyScheme === 'mac') {
|
||||
if (macBinding && macBinding !== 'Disabled') {
|
||||
entries.push({ binding: macBinding, isMac: true });
|
||||
}
|
||||
return entries;
|
||||
}
|
||||
|
||||
if (hotkeyScheme === 'pc') {
|
||||
if (pcBinding && pcBinding !== 'Disabled') {
|
||||
entries.push({ binding: pcBinding, isMac: false });
|
||||
}
|
||||
return entries;
|
||||
}
|
||||
|
||||
if (macBinding && macBinding !== 'Disabled') {
|
||||
entries.push({ binding: macBinding, isMac: true });
|
||||
}
|
||||
if (pcBinding && pcBinding !== 'Disabled') {
|
||||
entries.push({ binding: pcBinding, isMac: false });
|
||||
}
|
||||
return entries;
|
||||
});
|
||||
}, [hotkeyScheme, keyBindings]);
|
||||
|
||||
const buildKeyEventFromString = useCallback((keyString: string) => {
|
||||
const parsed = parseKeyCombo(keyString);
|
||||
if (!parsed) return null;
|
||||
|
||||
const modifiers = new Set(parsed.modifiers);
|
||||
const key = parsed.key;
|
||||
const normalizedKey = (() => {
|
||||
switch (key) {
|
||||
case 'Space':
|
||||
return ' ';
|
||||
case '↑':
|
||||
return 'ArrowUp';
|
||||
case '↓':
|
||||
return 'ArrowDown';
|
||||
case '←':
|
||||
return 'ArrowLeft';
|
||||
case '→':
|
||||
return 'ArrowRight';
|
||||
case 'Esc':
|
||||
return 'Escape';
|
||||
case '⌫':
|
||||
return 'Backspace';
|
||||
case 'Del':
|
||||
return 'Delete';
|
||||
case '↵':
|
||||
return 'Enter';
|
||||
case '⇥':
|
||||
return 'Tab';
|
||||
default:
|
||||
return key.length === 1 ? key.toLowerCase() : key;
|
||||
}
|
||||
})();
|
||||
|
||||
return new KeyboardEvent('keydown', {
|
||||
key: normalizedKey,
|
||||
metaKey: modifiers.has('⌘') || modifiers.has('Win'),
|
||||
ctrlKey: modifiers.has('⌃') || modifiers.has('Ctrl'),
|
||||
altKey: modifiers.has('⌥') || modifiers.has('Alt'),
|
||||
shiftKey: modifiers.has('Shift'),
|
||||
});
|
||||
}, []);
|
||||
|
||||
const normalizeKeyString = useCallback((value: string) => (
|
||||
value.toLowerCase().replace(/\s+/g, '')
|
||||
), []);
|
||||
|
||||
const validateShortkey = useCallback((key: string): string | null => {
|
||||
if (!key) return null;
|
||||
|
||||
const syntheticEvent = buildKeyEventFromString(key);
|
||||
if (syntheticEvent) {
|
||||
const conflictsSystem = activeSystemBindings.some(({ binding, isMac: bindingIsMac }) => (
|
||||
matchesKeyBinding(syntheticEvent, binding, bindingIsMac)
|
||||
));
|
||||
if (conflictsSystem) {
|
||||
return t('snippets.shortkey.error.systemConflict');
|
||||
}
|
||||
|
||||
const systemConflict = findActiveSystemShortcutConflict(key, hotkeyScheme, keyBindings);
|
||||
if (systemConflict) {
|
||||
const nameKey = `settings.shortcuts.binding.${systemConflict.id}`;
|
||||
const name = t(nameKey) !== nameKey ? t(nameKey) : systemConflict.label;
|
||||
return t('snippets.shortkey.error.systemConflict', { name });
|
||||
}
|
||||
|
||||
|
||||
const syntheticEvent = keyStringToKeyboardEvent(key);
|
||||
if (syntheticEvent) {
|
||||
for (const snippet of existingShortkeys) {
|
||||
if (snippet.shortkey && matchesKeyBinding(syntheticEvent, snippet.shortkey, isMac)) {
|
||||
@@ -680,13 +607,13 @@ const SnippetsManager: React.FC<SnippetsManagerProps> = ({
|
||||
return t('snippets.shortkey.error.snippetConflict', { name: conflictingSnippet.label });
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
return null;
|
||||
}, [
|
||||
activeSystemBindings,
|
||||
buildKeyEventFromString,
|
||||
existingShortkeys,
|
||||
hotkeyScheme,
|
||||
isMac,
|
||||
keyBindings,
|
||||
normalizeKeyString,
|
||||
t,
|
||||
]);
|
||||
|
||||
+44
-8
@@ -120,6 +120,8 @@ import {
|
||||
import { isVaultInitialized } from "@/application/state/vaultInitStore.ts";
|
||||
import { useVaultSnapshotField } from "@/application/state/vaultSnapshotStore.ts";
|
||||
import { netcattyBridge } from "@/infrastructure/services/netcattyBridge.ts";
|
||||
import { handleTerminalOscNotification } from "@/application/state/oscDesktopNotifications.ts";
|
||||
import { OscNotificationStreamScanner } from "@/domain/terminalOscNotifications.ts";
|
||||
import { ScriptExecutionOverlay } from "./terminal/ScriptExecutionOverlay";
|
||||
import { isScriptSnippet } from "@/domain/snippetScript.ts";
|
||||
import { snippetCanRunInTerminal } from "@/domain/snippetTargets.ts";
|
||||
@@ -134,6 +136,7 @@ import { createConnectionLogBuffer } from "./terminal/connectionLogBuffer";
|
||||
import { createProgrammaticCommandLogRewriter, type ProgrammaticCommandLogRewrite } from "./terminal/programmaticCommandLog";
|
||||
import { getSessionLogInitialLine } from "./terminal/sessionLogInitialLine";
|
||||
import { getTerminalSelectionForClipboard } from "./terminal/normalizeTerminalSelection";
|
||||
import { getHistoryPreviewSelectionFromRoot } from "./terminal/runtime/terminalHistoryScrollOverride";
|
||||
import { useZmodemTransfer } from "./terminal/hooks/useZmodemTransfer";
|
||||
import {
|
||||
createTerminalSessionStarters,
|
||||
@@ -604,6 +607,9 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
onTerminalDataCaptureRef.current = onTerminalDataCapture;
|
||||
const isVisibleRef = useRef(isVisible);
|
||||
isVisibleRef.current = isVisible;
|
||||
const isFocusedRef = useRef(!!isFocused);
|
||||
isFocusedRef.current = !!isFocused;
|
||||
const oscNotificationScannerRef = useRef(new OscNotificationStreamScanner());
|
||||
const hibernateEnabled =
|
||||
resolveTerminalHibernateEnabledForProtocol(terminalSettings, effectiveTerminalProtocol) &&
|
||||
!kittyKeyboardProtocolEnabledForSession &&
|
||||
@@ -1000,6 +1006,13 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
onBroadcastInputRef.current(text, sessionId);
|
||||
}
|
||||
|
||||
// ESC-prefixed writes (Esc+. yank-last-arg) are shell editor commands.
|
||||
// Walking printable bytes would append "." and leave history/completions
|
||||
// tracking a stale line.
|
||||
if (text.startsWith("\x1b")) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Update command buffer for onCommandExecuted tracking
|
||||
for (const ch of text) {
|
||||
if (handledSubmittedInput) {
|
||||
@@ -1898,15 +1911,27 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
flushTerminalSessionFlowAck(backendId);
|
||||
terminalBackend.setSessionFlowPaused?.(backendId, false);
|
||||
hibernatePendingBufferRef.current = "";
|
||||
oscNotificationScannerRef.current = new OscNotificationStreamScanner();
|
||||
disposeDataRef.current = terminalBackend.onSessionData(
|
||||
backendId,
|
||||
(chunk, meta) => {
|
||||
observeTerminalInputPrompt(chunk, meta);
|
||||
const scanned = oscNotificationScannerRef.current.consume(chunk);
|
||||
for (const notification of scanned.notifications) {
|
||||
handleTerminalOscNotification({
|
||||
notification,
|
||||
mode: terminalSettingsRef.current?.oscNotifications,
|
||||
sessionFocused: isFocusedRef.current,
|
||||
sessionId,
|
||||
fallbackTitle: host.label || host.hostname || "Netcatty",
|
||||
onSessionActivity: () => onTerminalBell?.(sessionId),
|
||||
});
|
||||
}
|
||||
hibernatePendingBufferRef.current = hibernatePendingCapDisabledRef.current
|
||||
? hibernatePendingBufferRef.current + chunk
|
||||
? hibernatePendingBufferRef.current + scanned.remainder
|
||||
: appendHibernatePendingBuffer(
|
||||
hibernatePendingBufferRef.current,
|
||||
chunk,
|
||||
scanned.remainder,
|
||||
);
|
||||
const pluginPipelineIngressBytes = Number.isFinite(meta?.pluginPipelineIngressBytes)
|
||||
? Math.max(0, Number(meta.pluginPipelineIngressBytes))
|
||||
@@ -1936,7 +1961,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
onSessionExitRef.current?.(sessionId, evt);
|
||||
scheduleAutoReconnect({ evt });
|
||||
});
|
||||
}, [observeTerminalInputPrompt, scheduleAutoReconnect, sessionId, terminalBackend]);
|
||||
}, [host.hostname, host.label, observeTerminalInputPrompt, onTerminalBell, scheduleAutoReconnect, sessionId, terminalBackend]);
|
||||
|
||||
const clearHibernateRetry = useCallback(() => {
|
||||
if (hibernateRetryTimerRef.current === null) return;
|
||||
@@ -3147,10 +3172,11 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
const handleAddSelectionToAI = useCallback(() => {
|
||||
const term = termRef.current;
|
||||
if (!term) return;
|
||||
const selection = getTerminalSelectionForClipboard(
|
||||
term,
|
||||
terminalSettings?.normalizeTextOnCopy ?? true,
|
||||
);
|
||||
const selection = getHistoryPreviewSelectionFromRoot(term.element?.parentElement)
|
||||
|| getTerminalSelectionForClipboard(
|
||||
term,
|
||||
terminalSettings?.normalizeTextOnCopy ?? true,
|
||||
);
|
||||
if (!selection.trim()) return;
|
||||
onAddSelectionToAI?.(sessionId, selection);
|
||||
}, [onAddSelectionToAI, sessionId, terminalSettings?.normalizeTextOnCopy]);
|
||||
@@ -3918,6 +3944,16 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
onBell: () => {
|
||||
onTerminalBell?.(sessionId);
|
||||
},
|
||||
onOscNotification: (notification) => {
|
||||
handleTerminalOscNotification({
|
||||
notification,
|
||||
mode: terminalSettingsRef.current?.oscNotifications,
|
||||
sessionFocused: isFocusedRef.current,
|
||||
sessionId,
|
||||
fallbackTitle: host.label || host.hostname || "Netcatty",
|
||||
onSessionActivity: () => onTerminalBell?.(sessionId),
|
||||
});
|
||||
},
|
||||
onOsc52ReadRequest: handleOsc52ReadRequest,
|
||||
onAutocompleteKeyEvent: (e: KeyboardEvent) => autocompleteKeyEventRef.current?.(e) ?? true,
|
||||
onAutocompleteInput: (data: string) => autocompleteInputRef.current?.(data),
|
||||
@@ -4033,7 +4069,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
return false;
|
||||
},
|
||||
takePendingBuffer: () => {
|
||||
const pending = hibernatePendingBufferRef.current;
|
||||
const pending = hibernatePendingBufferRef.current + oscNotificationScannerRef.current.flush();
|
||||
hibernatePendingBufferRef.current = "";
|
||||
return pending;
|
||||
},
|
||||
|
||||
@@ -3,6 +3,7 @@ import test from "node:test";
|
||||
|
||||
import type { PortForwardingRule } from "../domain/models";
|
||||
import en from "../application/i18n/locales/en";
|
||||
import es from "../application/i18n/locales/es";
|
||||
import zhCN from "../application/i18n/locales/zh-CN";
|
||||
import ru from "../application/i18n/locales/ru";
|
||||
import { buildRuleSummary, stopRuntimeTunnelBeforeDelete } from "./port-forwarding/utils";
|
||||
@@ -45,7 +46,7 @@ test("buildRuleSummary describes local, remote, and dynamic forwarding direction
|
||||
});
|
||||
|
||||
test("bundled locales include port forwarding summary copy for every forwarding type", () => {
|
||||
for (const [locale, messages] of Object.entries({ en, zhCN, ru })) {
|
||||
for (const [locale, messages] of Object.entries({ en, es, zhCN, ru })) {
|
||||
for (const key of [
|
||||
"pf.rule.summary.local",
|
||||
"pf.rule.summary.remote",
|
||||
|
||||
@@ -31,3 +31,10 @@ test("terminal behavior settings expose disabled auto-close", () => {
|
||||
/settings\.terminal\.behavior\.autoCloseOnExit[\s\S]*?role="switch" aria-checked="false"/,
|
||||
);
|
||||
});
|
||||
|
||||
test("terminal behavior settings expose OSC desktop notification mode", () => {
|
||||
const markup = renderSettings(true);
|
||||
assert.match(markup, /settings-anchor-terminal-osc-notifications/);
|
||||
assert.match(markup, /settings\.terminal\.behavior\.oscNotifications/);
|
||||
assert.match(markup, /settings\.terminal\.behavior\.oscNotifications\.always/);
|
||||
});
|
||||
@@ -1,6 +1,6 @@
|
||||
import React from "react";
|
||||
import { DEFAULT_TERMINAL_WORD_SEPARATORS } from "../../../domain/models";
|
||||
import type { DynamicTabTitleMode, LinkModifier, MiddleClickBehavior, RightClickBehavior, TerminalSettings } from "../../../domain/models";
|
||||
import type { DynamicTabTitleMode, LinkModifier, MiddleClickBehavior, OscNotificationMode, RightClickBehavior, TerminalSettings } from "../../../domain/models";
|
||||
import { Input } from "../../ui/input";
|
||||
import { Label } from "../../ui/label";
|
||||
import { SectionHeader, Select, SettingsAnchor, SettingRow, Toggle } from "../settings-ui";
|
||||
@@ -204,6 +204,23 @@ export const TerminalBehaviorSettings: React.FC<TerminalBehaviorSettingsProps> =
|
||||
/>
|
||||
</SettingRow>
|
||||
|
||||
<SettingRow
|
||||
anchorId="terminal-osc-notifications"
|
||||
label={t("settings.terminal.behavior.oscNotifications")}
|
||||
description={t("settings.terminal.behavior.oscNotifications.desc")}
|
||||
>
|
||||
<Select
|
||||
value={terminalSettings.oscNotifications ?? "always"}
|
||||
options={[
|
||||
{ value: "always", label: t("settings.terminal.behavior.oscNotifications.always") },
|
||||
{ value: "unfocused", label: t("settings.terminal.behavior.oscNotifications.unfocused") },
|
||||
{ value: "off", label: t("settings.terminal.behavior.oscNotifications.off") },
|
||||
]}
|
||||
onChange={(v) => updateTerminalSetting("oscNotifications", v as OscNotificationMode)}
|
||||
className="w-40"
|
||||
/>
|
||||
</SettingRow>
|
||||
|
||||
<SettingRow
|
||||
anchorId="terminal-osc52-clipboard"
|
||||
label={t("settings.terminal.behavior.osc52Clipboard")}
|
||||
|
||||
@@ -59,6 +59,7 @@ export interface SftpPaneCallbacks {
|
||||
onOpenFileWith?: (entry: SftpFileEntry, fullPath?: string) => void; // Always show opener dialog
|
||||
onDownloadFile?: (entry: SftpFileEntry, fullPath?: string) => void; // Download to local filesystem
|
||||
onDownloadFiles?: (entries: SftpFileEntry[]) => void; // Batch download — picks one target directory for remote panes
|
||||
onExtractArchive?: (entry: SftpFileEntry, fullPath?: string) => void | Promise<void>;
|
||||
// External file upload (supports folders via DataTransfer)
|
||||
onUploadExternalFiles?: (dataTransfer: DataTransfer, targetPath?: string) => Promise<void>;
|
||||
// External file upload from <input type="file" multiple> picker (FileList).
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { AppWindow, ArrowDown, ArrowRight, ArrowUp, ChevronDown, ClipboardCopy, Copy, Download, Edit2, ExternalLink, FilePlus, Folder, FolderPlus, Loader2, Pencil, RefreshCw, Shield, Trash2, Unplug, Upload } from "lucide-react";
|
||||
import { AppWindow, Archive, ArrowDown, ArrowRight, ArrowUp, ChevronDown, ClipboardCopy, Copy, Download, Edit2, ExternalLink, FilePlus, Folder, FolderPlus, Loader2, Pencil, RefreshCw, Shield, Trash2, Unplug, Upload } from "lucide-react";
|
||||
import { Button } from "../ui/button";
|
||||
import {
|
||||
ContextMenu,
|
||||
@@ -17,6 +17,7 @@ import { sftpListOrderStore } from "./hooks/useSftpListOrderStore";
|
||||
import type { UseSftpPaneSortingResult } from "../../application/state/sftp/useSftpPaneSorting";
|
||||
import { buildSftpColumnTemplate, isNavigableDirectory, isSftpColumnMenuKey } from "./utils";
|
||||
import { isKnownBinaryFile } from "../../lib/sftpFileUtils";
|
||||
import { isExtractableArchive } from "../../domain/sftpArchive";
|
||||
import { SftpFileRow } from "./SftpFileRow";
|
||||
import { SftpColumnMenuItems } from "./SftpColumnMenuItems";
|
||||
import { getSftpVirtualListScrollTop } from "../../domain/sftpVirtualList";
|
||||
@@ -65,6 +66,7 @@ interface SftpPaneFileListProps {
|
||||
onEditFile?: (entry: SftpFileEntry) => void;
|
||||
onDownloadFile?: (entry: SftpFileEntry) => void;
|
||||
onDownloadFiles?: (entries: SftpFileEntry[]) => void;
|
||||
onExtractArchive?: (entry: SftpFileEntry) => void;
|
||||
onEditPermissions?: (entry: SftpFileEntry) => void;
|
||||
onUploadExternalFileList?: (fileList: FileList, targetPath?: string) => Promise<void> | void;
|
||||
onUploadExternalFolder?: (targetPath?: string) => Promise<void> | void;
|
||||
@@ -153,6 +155,7 @@ export const SftpPaneFileList: React.FC<SftpPaneFileListProps> = React.memo(({
|
||||
onEditFile,
|
||||
onDownloadFile,
|
||||
onDownloadFiles,
|
||||
onExtractArchive,
|
||||
onEditPermissions,
|
||||
onUploadExternalFileList,
|
||||
onUploadExternalFolder,
|
||||
@@ -347,6 +350,12 @@ export const SftpPaneFileList: React.FC<SftpPaneFileListProps> = React.memo(({
|
||||
{t("sftp.context.download")}
|
||||
</ContextMenuItem>
|
||||
)}
|
||||
{!isNavigableDirectory(entry) && onExtractArchive && isExtractableArchive(entry.name) && (
|
||||
<ContextMenuItem onClick={() => onExtractArchive(entry)}>
|
||||
<Archive size={14} className="mr-2" />{" "}
|
||||
{t("sftp.context.extract")}
|
||||
</ContextMenuItem>
|
||||
)}
|
||||
<ContextMenuSeparator />
|
||||
<ContextMenuItem
|
||||
onClick={() => {
|
||||
@@ -472,6 +481,7 @@ export const SftpPaneFileList: React.FC<SftpPaneFileListProps> = React.memo(({
|
||||
onMoveEntriesToPath,
|
||||
onDownloadFile,
|
||||
onDownloadFiles,
|
||||
onExtractArchive,
|
||||
onDragEnd,
|
||||
onEditFile,
|
||||
onEditPermissions,
|
||||
|
||||
@@ -44,6 +44,7 @@ export const SftpPaneTreeView = React.memo<SftpPaneTreeViewProps>(({
|
||||
onOpenFileWith,
|
||||
onEditFile,
|
||||
onDownloadFile,
|
||||
onExtractArchive,
|
||||
onEditPermissions,
|
||||
draggedFiles,
|
||||
openNewFolderDialog,
|
||||
@@ -197,6 +198,8 @@ export const SftpPaneTreeView = React.memo<SftpPaneTreeViewProps>(({
|
||||
onEditFileRef.current = onEditFile;
|
||||
const onDownloadFileRef = useRef(onDownloadFile);
|
||||
onDownloadFileRef.current = onDownloadFile;
|
||||
const onExtractArchiveRef = useRef(onExtractArchive);
|
||||
onExtractArchiveRef.current = onExtractArchive;
|
||||
const onEditPermissionsRef = useRef(onEditPermissions);
|
||||
onEditPermissionsRef.current = onEditPermissions;
|
||||
const openRenameDialogRef = useRef(openRenameDialog);
|
||||
@@ -917,6 +920,7 @@ export const SftpPaneTreeView = React.memo<SftpPaneTreeViewProps>(({
|
||||
onOpenFileWithRef,
|
||||
onEditFileRef,
|
||||
onDownloadFileRef,
|
||||
onExtractArchiveRef,
|
||||
onEditPermissionsRef,
|
||||
openDeleteConfirmRef,
|
||||
openRenameDialogRef,
|
||||
|
||||
@@ -23,6 +23,7 @@ export interface SftpPaneTreeViewProps {
|
||||
onOpenFileWith?: (entry: SftpFileEntry, fullPath?: string) => void;
|
||||
onEditFile?: (entry: SftpFileEntry, fullPath?: string) => void;
|
||||
onDownloadFile?: (entry: SftpFileEntry, fullPath?: string) => void;
|
||||
onExtractArchive?: (entry: SftpFileEntry, fullPath?: string) => void | Promise<void>;
|
||||
onEditPermissions?: (entry: SftpFileEntry, fullPath?: string) => void;
|
||||
draggedFiles: (SftpTransferSource & { side: 'left' | 'right' })[] | null;
|
||||
openNewFolderDialog: (targetPath: string) => void;
|
||||
|
||||
@@ -19,7 +19,7 @@ import {
|
||||
useSftpWritableHosts,
|
||||
} from "./SftpContext";
|
||||
import type { SftpPane } from "../../application/state/sftp/types";
|
||||
import { joinPath } from "../../application/state/sftp/utils";
|
||||
import { joinPath, getParentPath } from "../../application/state/sftp/utils";
|
||||
import type { Host } from "../../domain/models";
|
||||
import { useSftpPaneDialogs } from "./hooks/useSftpPaneDialogs";
|
||||
import { useSftpPaneDragAndSelect } from "./hooks/useSftpPaneDragAndSelect";
|
||||
@@ -328,6 +328,14 @@ const SftpPaneViewInner: React.FC<SftpPaneViewProps> = ({
|
||||
}
|
||||
}, [callbacks, pane.connection?.currentPath, requestTreeReload]);
|
||||
|
||||
const handleExtractArchive = useCallback((entry: Parameters<NonNullable<typeof callbacks.onExtractArchive>>[0], fullPath?: string) => {
|
||||
const archivePath = fullPath ?? joinPath(pane.connection?.currentPath ?? "", entry.name);
|
||||
void Promise.resolve(callbacks.onExtractArchive?.(entry, fullPath)).then(() => {
|
||||
const parentPath = getParentPath(archivePath);
|
||||
if (parentPath) requestNestedTreeReload([parentPath]);
|
||||
});
|
||||
}, [callbacks, pane.connection?.currentPath, requestNestedTreeReload]);
|
||||
|
||||
const handleMoveEntriesToPath = useCallback(async (sourcePaths: string[], targetPath: string) => {
|
||||
await callbacks.onMoveEntriesToPath(sourcePaths, targetPath);
|
||||
}, [callbacks]);
|
||||
@@ -602,6 +610,7 @@ const SftpPaneViewInner: React.FC<SftpPaneViewProps> = ({
|
||||
onOpenFileWith={callbacks.onOpenFileWith}
|
||||
onEditFile={callbacks.onEditFile}
|
||||
onDownloadFile={callbacks.onDownloadFile}
|
||||
onExtractArchive={callbacks.onExtractArchive ? handleExtractArchive : undefined}
|
||||
onEditPermissions={callbacks.onEditPermissions}
|
||||
draggedFiles={draggedFiles}
|
||||
openNewFolderDialog={openNewFolderDialogAtPath}
|
||||
@@ -653,6 +662,7 @@ const SftpPaneViewInner: React.FC<SftpPaneViewProps> = ({
|
||||
onEditFile={callbacks.onEditFile}
|
||||
onDownloadFile={callbacks.onDownloadFile}
|
||||
onDownloadFiles={callbacks.onDownloadFiles}
|
||||
onExtractArchive={callbacks.onExtractArchive ? handleExtractArchive : undefined}
|
||||
onEditPermissions={callbacks.onEditPermissions}
|
||||
onUploadExternalFileList={handleUploadExternalFileList}
|
||||
onUploadExternalFolder={handleUploadExternalFolder}
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
} from "../../../application/state/sftp/transferConcurrency";
|
||||
import { logger } from "../../../lib/logger";
|
||||
import { toast } from "../../ui/toast";
|
||||
import { netcattyBridge } from "../../../infrastructure/services/netcattyBridge";
|
||||
import { getFileExtension, getLanguageId, FileOpenerType, SystemAppInfo } from "../../../lib/sftpFileUtils";
|
||||
import { isNavigableDirectory } from "../utils";
|
||||
import { reportSftpUploadResults } from "../reportSftpUploadResults";
|
||||
@@ -535,6 +536,52 @@ export const useSftpViewFileOps = ({
|
||||
[handleDownloadFileForSide],
|
||||
);
|
||||
|
||||
const handleExtractArchiveForSide = useCallback(
|
||||
async (side: "left" | "right", file: SftpFileEntry, fullPath?: string) => {
|
||||
const pane = side === "left" ? sftpRef.current.leftPane : sftpRef.current.rightPane;
|
||||
if (!pane.connection) return;
|
||||
|
||||
const resolvedPath = fullPath ?? sftpRef.current.joinPath(pane.connection.currentPath, file.name);
|
||||
toast.info(t("sftp.extract.extracting", { fileName: file.name }), "SFTP");
|
||||
try {
|
||||
const bridge = netcattyBridge.get();
|
||||
if (pane.connection.isLocal) {
|
||||
if (!bridge?.extractLocalArchive) {
|
||||
throw new Error("Local extract unavailable");
|
||||
}
|
||||
await bridge.extractLocalArchive(resolvedPath);
|
||||
} else {
|
||||
const sftpId = getSftpIdForConnection?.(pane.connection.id);
|
||||
if (!sftpId || !bridge?.extractSftpArchive) {
|
||||
throw new Error("SFTP session not found");
|
||||
}
|
||||
await bridge.extractSftpArchive(sftpId, resolvedPath, pane.filenameEncoding);
|
||||
}
|
||||
await sftpRef.current.refresh(side);
|
||||
toast.success(t("sftp.extract.success", { fileName: file.name }), "SFTP");
|
||||
} catch (e) {
|
||||
logger.error("[SftpView] Failed to extract archive:", e);
|
||||
toast.error(
|
||||
t("sftp.extract.error", {
|
||||
fileName: file.name,
|
||||
error: e instanceof Error ? e.message : String(e),
|
||||
}),
|
||||
"SFTP",
|
||||
);
|
||||
}
|
||||
},
|
||||
[getSftpIdForConnection, sftpRef, t],
|
||||
);
|
||||
|
||||
const onExtractArchiveLeft = useCallback(
|
||||
(file: SftpFileEntry, fullPath?: string) => handleExtractArchiveForSide("left", file, fullPath),
|
||||
[handleExtractArchiveForSide],
|
||||
);
|
||||
const onExtractArchiveRight = useCallback(
|
||||
(file: SftpFileEntry, fullPath?: string) => handleExtractArchiveForSide("right", file, fullPath),
|
||||
[handleExtractArchiveForSide],
|
||||
);
|
||||
|
||||
// Multi-file download. For local panes, each file auto-downloads as a blob
|
||||
// (no prompt). For remote panes, prompts for a target directory once and
|
||||
// streams all selected entries into it — avoids the per-file save dialog
|
||||
@@ -738,6 +785,8 @@ export const useSftpViewFileOps = ({
|
||||
onOpenFileWithRight,
|
||||
onDownloadFileLeft,
|
||||
onDownloadFileRight,
|
||||
onExtractArchiveLeft,
|
||||
onExtractArchiveRight,
|
||||
onDownloadFilesLeft,
|
||||
onDownloadFilesRight,
|
||||
onUploadExternalFilesLeft,
|
||||
|
||||
@@ -81,6 +81,8 @@ export interface UseSftpViewFileOpsResult {
|
||||
onOpenFileWithRight: (file: SftpFileEntry, fullPath?: string) => void;
|
||||
onDownloadFileLeft: (file: SftpFileEntry, fullPath?: string) => void;
|
||||
onDownloadFileRight: (file: SftpFileEntry, fullPath?: string) => void;
|
||||
onExtractArchiveLeft: (file: SftpFileEntry, fullPath?: string) => void | Promise<void>;
|
||||
onExtractArchiveRight: (file: SftpFileEntry, fullPath?: string) => void | Promise<void>;
|
||||
onDownloadFilesLeft: (files: SftpFileEntry[]) => void;
|
||||
onDownloadFilesRight: (files: SftpFileEntry[]) => void;
|
||||
onUploadExternalFilesLeft: (dataTransfer: DataTransfer, targetPath?: string) => void;
|
||||
|
||||
@@ -154,6 +154,7 @@ export const useSftpViewPaneCallbacks = ({
|
||||
onOpenFileWith: fileOps.onOpenFileWithLeft,
|
||||
onDownloadFile: fileOps.onDownloadFileLeft,
|
||||
onDownloadFiles: fileOps.onDownloadFilesLeft,
|
||||
onExtractArchive: fileOps.onExtractArchiveLeft,
|
||||
onUploadExternalFiles: fileOps.onUploadExternalFilesLeft,
|
||||
onUploadExternalFileList: fileOps.onUploadExternalFileListLeft,
|
||||
onUploadExternalFolder: fileOps.onUploadExternalFolderLeft,
|
||||
@@ -196,6 +197,7 @@ export const useSftpViewPaneCallbacks = ({
|
||||
onOpenFileWith: fileOps.onOpenFileWithRight,
|
||||
onDownloadFile: fileOps.onDownloadFileRight,
|
||||
onDownloadFiles: fileOps.onDownloadFilesRight,
|
||||
onExtractArchive: fileOps.onExtractArchiveRight,
|
||||
onUploadExternalFiles: fileOps.onUploadExternalFilesRight,
|
||||
onUploadExternalFileList: fileOps.onUploadExternalFileListRight,
|
||||
onUploadExternalFolder: fileOps.onUploadExternalFolderRight,
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { useMemo } from 'react';
|
||||
import { AppWindow, ArrowRight, ArrowUp, ClipboardCopy, Copy, Download, Edit2, ExternalLink, FilePlus, Folder, FolderInput, FolderPlus, Pencil, RefreshCw, Shield, Trash2, Upload } from 'lucide-react';
|
||||
import { AppWindow, Archive, ArrowRight, ArrowUp, ClipboardCopy, Copy, Download, Edit2, ExternalLink, FilePlus, Folder, FolderInput, FolderPlus, Pencil, RefreshCw, Shield, Trash2, Upload } from 'lucide-react';
|
||||
import { ContextMenuContent, ContextMenuItem, ContextMenuSeparator } from '../ui/context-menu';
|
||||
import { getParentPath } from '../../application/state/sftp/utils';
|
||||
import { isKnownBinaryFile } from '../../lib/sftpFileUtils';
|
||||
import { isExtractableArchive } from '../../domain/sftpArchive';
|
||||
import { isNavigableDirectory } from './utils';
|
||||
import { getSftpTreeUploadFilesTargetPath, getSftpUploadFilesLabelKey, getSftpUploadFolderLabelKey } from './sftpUploadMenu';
|
||||
|
||||
@@ -15,7 +16,7 @@ export function useSftpPaneTreeContextMenu(props: SftpPaneTreeContextMenuProps)
|
||||
executeMoveAction, triggerUploadPicker, onUploadExternalFolder, uploadEnabled, folderUploadEnabled,
|
||||
setMoveTargetPaths, setMoveToPath, setMoveToError, setMoveToSuggestions, setMoveToSuggestionIndex,
|
||||
setIsMoving, setShowMoveToDialog, tRef, onCopyToOtherPaneRef, onNavigateToRef, onOpenFileWithSystemDefaultRef, onOpenFileWithRef,
|
||||
onEditFileRef, onDownloadFileRef, onEditPermissionsRef, openDeleteConfirmRef, openRenameDialogRef,
|
||||
onEditFileRef, onDownloadFileRef, onExtractArchiveRef, onEditPermissionsRef, openDeleteConfirmRef, openRenameDialogRef,
|
||||
openNewFolderDialogRef, openNewFileDialogRef,
|
||||
} = props;
|
||||
|
||||
@@ -82,6 +83,11 @@ export function useSftpPaneTreeContextMenu(props: SftpPaneTreeContextMenuProps)
|
||||
<Download size={14} className="mr-2" />{tRef.current('sftp.context.download')}
|
||||
</ContextMenuItem>
|
||||
)}
|
||||
{!isDir && onExtractArchiveRef.current && isExtractableArchive(entry.name) && (
|
||||
<ContextMenuItem onClick={() => onExtractArchiveRef.current?.(entry, entryPath)}>
|
||||
<Archive size={14} className="mr-2" />{tRef.current('sftp.context.extract')}
|
||||
</ContextMenuItem>
|
||||
)}
|
||||
<ContextMenuSeparator />
|
||||
<ContextMenuItem onClick={handleCopyToOtherPane}>
|
||||
<Copy size={14} className="mr-2" />{tRef.current('sftp.context.copyToOtherPane')}
|
||||
@@ -175,6 +181,7 @@ export function useSftpPaneTreeContextMenu(props: SftpPaneTreeContextMenuProps)
|
||||
onUploadExternalFolder,
|
||||
onCopyToOtherPaneRef,
|
||||
onDownloadFileRef,
|
||||
onExtractArchiveRef,
|
||||
onEditFileRef,
|
||||
onEditPermissionsRef,
|
||||
onNavigateToRef,
|
||||
|
||||
@@ -3,6 +3,7 @@ import assert from "node:assert/strict";
|
||||
|
||||
import en from "../../application/i18n/locales/en.ts";
|
||||
import ru from "../../application/i18n/locales/ru.ts";
|
||||
import es from "../../application/i18n/locales/es.ts";
|
||||
import zhCN from "../../application/i18n/locales/zh-CN.ts";
|
||||
import { markMiddleClickContextMenuEvent } from "./runtime/middleClickBehavior.ts";
|
||||
import * as terminalContextMenu from "./TerminalContextMenu.tsx";
|
||||
@@ -112,7 +113,7 @@ test("shows upload clipboard image context menu action when a handler exists", (
|
||||
});
|
||||
|
||||
test("localizes the upload clipboard image context menu label", () => {
|
||||
const locales = { en, ru, "zh-CN": zhCN };
|
||||
const locales = { en, ru, es, "zh-CN": zhCN };
|
||||
const keys = [
|
||||
"terminal.menu.uploadClipboardImage",
|
||||
"terminal.clipboardImageUpload.noImage",
|
||||
@@ -240,6 +241,26 @@ test("forceMenuInAlternateScreen opts out of alternate-screen suppression", () =
|
||||
}),
|
||||
true,
|
||||
);
|
||||
|
||||
assert.equal(
|
||||
shouldSuppressMouseTrackingContextMenu({
|
||||
isAlternateScreen: true,
|
||||
showReconnectAction: false,
|
||||
forceMenuInAlternateScreen: false,
|
||||
isHistoryPreviewTarget: true,
|
||||
}),
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
shouldOpenTerminalContextMenu({
|
||||
event: { shiftKey: false, nativeEvent: {} as MouseEvent },
|
||||
rightClickBehavior: "paste",
|
||||
isAlternateScreen: true,
|
||||
showReconnectAction: false,
|
||||
isHistoryPreviewTarget: true,
|
||||
}),
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
test("opens a middle-click menu even when right-click is configured to paste", () => {
|
||||
|
||||
@@ -28,6 +28,7 @@ import {
|
||||
ContextMenuTrigger,
|
||||
} from '../ui/context-menu';
|
||||
import { isMiddleClickContextMenuEvent, isMouseTrackingActive } from './runtime/middleClickBehavior';
|
||||
import { isHistoryPreviewContextMenuTarget } from './runtime/terminalHistoryScrollOverride';
|
||||
import { collectOwnedPluginMenus, comparePluginMenus, usePluginContributions } from '../../application/state/usePluginContributions';
|
||||
import { buildTerminalPluginContributionContext } from '../../application/state/pluginContributionContexts';
|
||||
import { PluginContributionIcon } from '../plugins/PluginContributionIcon';
|
||||
@@ -80,13 +81,16 @@ export const shouldSuppressMouseTrackingContextMenu = ({
|
||||
terminalMouseTrackingMode,
|
||||
showReconnectAction,
|
||||
forceMenuInAlternateScreen,
|
||||
isHistoryPreviewTarget,
|
||||
}: {
|
||||
isAlternateScreen?: boolean;
|
||||
terminalMouseTrackingMode?: string;
|
||||
showReconnectAction?: boolean;
|
||||
forceMenuInAlternateScreen?: boolean;
|
||||
isHistoryPreviewTarget?: boolean;
|
||||
}): boolean => Boolean(
|
||||
isMouseTrackingActive({
|
||||
!isHistoryPreviewTarget
|
||||
&& isMouseTrackingActive({
|
||||
mouseTracking: Boolean(isAlternateScreen),
|
||||
terminalMouseTrackingMode,
|
||||
})
|
||||
@@ -108,15 +112,23 @@ export const shouldRenderTerminalContextMenuContent = ({
|
||||
showReconnectAction,
|
||||
allowSuppressedMenuContent,
|
||||
forceMenuInAlternateScreen,
|
||||
isHistoryPreviewTarget,
|
||||
}: {
|
||||
isAlternateScreen?: boolean;
|
||||
terminalMouseTrackingMode?: string;
|
||||
showReconnectAction?: boolean;
|
||||
allowSuppressedMenuContent?: boolean;
|
||||
forceMenuInAlternateScreen?: boolean;
|
||||
isHistoryPreviewTarget?: boolean;
|
||||
}): boolean =>
|
||||
allowSuppressedMenuContent ||
|
||||
!shouldSuppressMouseTrackingContextMenu({ isAlternateScreen, terminalMouseTrackingMode, showReconnectAction, forceMenuInAlternateScreen });
|
||||
!shouldSuppressMouseTrackingContextMenu({
|
||||
isAlternateScreen,
|
||||
terminalMouseTrackingMode,
|
||||
showReconnectAction,
|
||||
forceMenuInAlternateScreen,
|
||||
isHistoryPreviewTarget,
|
||||
});
|
||||
|
||||
export const shouldAllowSuppressedTerminalContextMenuContent = ({
|
||||
event,
|
||||
@@ -124,15 +136,23 @@ export const shouldAllowSuppressedTerminalContextMenuContent = ({
|
||||
terminalMouseTrackingMode,
|
||||
showReconnectAction,
|
||||
forceMenuInAlternateScreen,
|
||||
isHistoryPreviewTarget,
|
||||
}: {
|
||||
event: { shiftKey?: boolean; nativeEvent: MouseEvent };
|
||||
isAlternateScreen?: boolean;
|
||||
terminalMouseTrackingMode?: string;
|
||||
showReconnectAction?: boolean;
|
||||
forceMenuInAlternateScreen?: boolean;
|
||||
isHistoryPreviewTarget?: boolean;
|
||||
}): boolean =>
|
||||
isMiddleClickContextMenuEvent(event.nativeEvent)
|
||||
|| Boolean(event.shiftKey && shouldSuppressMouseTrackingContextMenu({ isAlternateScreen, terminalMouseTrackingMode, showReconnectAction, forceMenuInAlternateScreen }));
|
||||
|| Boolean(isHistoryPreviewTarget)
|
||||
|| Boolean(event.shiftKey && shouldSuppressMouseTrackingContextMenu({
|
||||
isAlternateScreen,
|
||||
terminalMouseTrackingMode,
|
||||
showReconnectAction,
|
||||
forceMenuInAlternateScreen,
|
||||
}));
|
||||
|
||||
export const shouldOpenTerminalContextMenu = ({
|
||||
event,
|
||||
@@ -141,6 +161,7 @@ export const shouldOpenTerminalContextMenu = ({
|
||||
terminalMouseTrackingMode,
|
||||
showReconnectAction,
|
||||
forceMenuInAlternateScreen,
|
||||
isHistoryPreviewTarget,
|
||||
}: {
|
||||
event: { shiftKey?: boolean; nativeEvent: MouseEvent };
|
||||
rightClickBehavior?: RightClickBehavior;
|
||||
@@ -148,16 +169,23 @@ export const shouldOpenTerminalContextMenu = ({
|
||||
terminalMouseTrackingMode?: string;
|
||||
showReconnectAction?: boolean;
|
||||
forceMenuInAlternateScreen?: boolean;
|
||||
isHistoryPreviewTarget?: boolean;
|
||||
}): boolean => {
|
||||
if (isMiddleClickContextMenuEvent(event.nativeEvent)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (event.shiftKey) {
|
||||
if (event.shiftKey || isHistoryPreviewTarget) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (shouldSuppressMouseTrackingContextMenu({ isAlternateScreen, terminalMouseTrackingMode, showReconnectAction, forceMenuInAlternateScreen })) {
|
||||
if (shouldSuppressMouseTrackingContextMenu({
|
||||
isAlternateScreen,
|
||||
terminalMouseTrackingMode,
|
||||
showReconnectAction,
|
||||
forceMenuInAlternateScreen,
|
||||
isHistoryPreviewTarget,
|
||||
})) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -262,6 +290,7 @@ export const TerminalContextMenu: React.FC<TerminalContextMenuProps> = ({
|
||||
// handle right-click natively to avoid conflicting menus. Reconnect is
|
||||
// still available after disconnect, even if mouse tracking was left on.
|
||||
const currentMouseTrackingMode = getMouseTrackingMode?.();
|
||||
const isHistoryPreviewTarget = isHistoryPreviewContextMenuTarget(e.target);
|
||||
const shouldOpenMenu = shouldOpenTerminalContextMenu({
|
||||
event: e,
|
||||
rightClickBehavior,
|
||||
@@ -269,9 +298,16 @@ export const TerminalContextMenu: React.FC<TerminalContextMenuProps> = ({
|
||||
terminalMouseTrackingMode: currentMouseTrackingMode,
|
||||
showReconnectAction,
|
||||
forceMenuInAlternateScreen: showContextMenuOverFullscreenApps,
|
||||
isHistoryPreviewTarget,
|
||||
});
|
||||
|
||||
if (!shouldOpenMenu && shouldSuppressMouseTrackingContextMenu({ isAlternateScreen, terminalMouseTrackingMode: currentMouseTrackingMode, showReconnectAction, forceMenuInAlternateScreen: showContextMenuOverFullscreenApps })) {
|
||||
if (!shouldOpenMenu && shouldSuppressMouseTrackingContextMenu({
|
||||
isAlternateScreen,
|
||||
terminalMouseTrackingMode: currentMouseTrackingMode,
|
||||
showReconnectAction,
|
||||
forceMenuInAlternateScreen: showContextMenuOverFullscreenApps,
|
||||
isHistoryPreviewTarget,
|
||||
})) {
|
||||
e.preventDefault();
|
||||
return;
|
||||
}
|
||||
@@ -290,6 +326,7 @@ export const TerminalContextMenu: React.FC<TerminalContextMenuProps> = ({
|
||||
terminalMouseTrackingMode: currentMouseTrackingMode,
|
||||
showReconnectAction,
|
||||
forceMenuInAlternateScreen: showContextMenuOverFullscreenApps,
|
||||
isHistoryPreviewTarget,
|
||||
}));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -6,6 +6,12 @@ import React, { memo, useEffect, useState, type RefObject } from 'react';
|
||||
import type { Terminal as XTerm } from '@xterm/xterm';
|
||||
import { Sparkles } from 'lucide-react';
|
||||
import { useI18n } from '../../application/i18n/I18nProvider';
|
||||
import {
|
||||
createCopyOnSelectUserGestureTracker,
|
||||
shouldWriteCopyOnSelect,
|
||||
subscribeCopyOnSelectUserCommand,
|
||||
subscribeCopyOnSelectUserGesture,
|
||||
} from './copyOnSelect';
|
||||
import { getTerminalSelectionForClipboard } from './normalizeTerminalSelection';
|
||||
import { resolveSelectionOverlayPosition } from './useTerminalEffects';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '../ui/tooltip';
|
||||
@@ -64,6 +70,8 @@ function TerminalSelectionAIOverlayInner({
|
||||
let scrollDisposable: { dispose: () => void } | null | undefined = null;
|
||||
let resizeDisposable: { dispose: () => void } | null | undefined = null;
|
||||
let resizeObserver: ResizeObserver | null = null;
|
||||
let userGestureUnsubscribe: (() => void) | null = null;
|
||||
let userGestureTracker: ReturnType<typeof createCopyOnSelectUserGestureTracker> | null = null;
|
||||
|
||||
const requestFrame = typeof requestAnimationFrame === 'function'
|
||||
? requestAnimationFrame
|
||||
@@ -89,10 +97,23 @@ function TerminalSelectionAIOverlayInner({
|
||||
resizeDisposable = null;
|
||||
resizeObserver?.disconnect();
|
||||
resizeObserver = null;
|
||||
userGestureUnsubscribe?.();
|
||||
userGestureUnsubscribe = null;
|
||||
userGestureTracker?.dispose();
|
||||
userGestureTracker = null;
|
||||
};
|
||||
|
||||
const attach = (term: XTerm) => {
|
||||
cleanupListeners();
|
||||
userGestureTracker = createCopyOnSelectUserGestureTracker();
|
||||
const unsubscribePointer = subscribeCopyOnSelectUserGesture(term, userGestureTracker);
|
||||
const unsubscribeCommand = subscribeCopyOnSelectUserCommand(term, () => {
|
||||
userGestureTracker?.pulse();
|
||||
});
|
||||
userGestureUnsubscribe = () => {
|
||||
unsubscribePointer();
|
||||
unsubscribeCommand();
|
||||
};
|
||||
|
||||
const publishSelectionOverlayPosition = () => {
|
||||
overlayRafId = null;
|
||||
@@ -131,15 +152,16 @@ function TerminalSelectionAIOverlayInner({
|
||||
}
|
||||
scheduleSelectionOverlayPosition();
|
||||
|
||||
// Skip programmatic restore (preserveSelectionOnInput) and the initial
|
||||
// attach snapshot so reopening a tab does not re-copy a retained
|
||||
// selection over the user's current clipboard.
|
||||
if (
|
||||
allowCopy
|
||||
&& hasText
|
||||
&& copyOnSelect
|
||||
&& !isRestoringSelectionRef?.current
|
||||
) {
|
||||
// Skip programmatic selections: preserveSelectionOnInput restore,
|
||||
// SearchAddon match highlight (issue #3007), and the initial attach
|
||||
// snapshot so those writes cannot clobber a user copy.
|
||||
if (shouldWriteCopyOnSelect({
|
||||
allowCopy,
|
||||
hasText,
|
||||
copyOnSelect: !!copyOnSelect,
|
||||
isRestoringSelection: !!isRestoringSelectionRef?.current,
|
||||
isUserSelection: !!userGestureTracker?.isActive(),
|
||||
})) {
|
||||
const selection = getTerminalSelectionForClipboard(term, normalizeTextOnCopy);
|
||||
if (!selection) return;
|
||||
copyTimer = setTimeout(() => {
|
||||
|
||||
@@ -25,6 +25,7 @@ import {
|
||||
DialogTitle,
|
||||
} from '../ui/dialog';
|
||||
import { TerminalSelectionAIOverlay } from './TerminalSelectionAIOverlay';
|
||||
import { getHistoryPreviewSelectionFromRoot } from './runtime/terminalHistoryScrollOverride';
|
||||
|
||||
type TerminalViewContext = Record<string, any>;
|
||||
type HostLineTimestampToggle = {
|
||||
@@ -514,7 +515,11 @@ function TerminalViewInner({ ctx }: { ctx: TerminalViewContext }) {
|
||||
>
|
||||
<div
|
||||
onContextMenu={() => {
|
||||
setContextMenuHasSelection(Boolean(termRef.current?.hasSelection()));
|
||||
const term = termRef.current;
|
||||
setContextMenuHasSelection(Boolean(
|
||||
term?.hasSelection()
|
||||
|| getHistoryPreviewSelectionFromRoot(term?.element?.parentElement),
|
||||
));
|
||||
}}
|
||||
className={cn(
|
||||
"relative h-full w-full flex min-h-0 overflow-hidden",
|
||||
|
||||
@@ -21,6 +21,26 @@ interface TerminalAutocompleteKeyEventContext {
|
||||
renderPreviewSelection: (index: number) => void;
|
||||
acceptPreviewlessSelection: (index: number) => boolean;
|
||||
acceptSnippet: (snippet: Snippet) => boolean;
|
||||
/** Deadline (ms) until which `.` / `_` are treated as readline Meta follow-ups. */
|
||||
escMetaPrefixUntilRef: MutableRefObject<number>;
|
||||
now?: () => number;
|
||||
}
|
||||
|
||||
/** Readline keyseq-timeout default; Esc then . within this window is M-. */
|
||||
export const AUTOCOMPLETE_ESC_META_TIMEOUT_MS = 500;
|
||||
|
||||
export function autocompleteEscMetaFollowUpSequence(e: {
|
||||
key: string;
|
||||
altKey: boolean;
|
||||
ctrlKey: boolean;
|
||||
metaKey: boolean;
|
||||
shiftKey: boolean;
|
||||
}): string | null {
|
||||
if (e.altKey || e.ctrlKey || e.metaKey) return null;
|
||||
// `_` is Shift+Minus on a standard keyboard; Shift+. is `>` and must not yank.
|
||||
if (e.key === "." && !e.shiftKey) return "\x1b.";
|
||||
if (e.key === "_") return "\x1b_";
|
||||
return null;
|
||||
}
|
||||
|
||||
const isAutocompleteConfirmEnter = (
|
||||
@@ -56,9 +76,27 @@ export function handleTerminalAutocompleteKeyEvent(
|
||||
renderPreviewSelection,
|
||||
acceptPreviewlessSelection,
|
||||
acceptSnippet,
|
||||
escMetaPrefixUntilRef,
|
||||
now = Date.now,
|
||||
} = context;
|
||||
if (!settingsRef.current.enabled || e.type !== "keydown") return true;
|
||||
|
||||
const metaFollowUp = autocompleteEscMetaFollowUpSequence(e);
|
||||
if (metaFollowUp && now() < escMetaPrefixUntilRef.current) {
|
||||
escMetaPrefixUntilRef.current = 0;
|
||||
e.preventDefault();
|
||||
writeToTerminal(metaFollowUp);
|
||||
// Match handleTerminalAutocompleteInput's ESC-sequence path: yank-last-arg
|
||||
// rewrites the shell line, so the append-only typed buffer is stale.
|
||||
typedInputBufferRef.current = "";
|
||||
typedBufferReliableRef.current = false;
|
||||
lastAcceptedCommandRef.current = null;
|
||||
return false;
|
||||
}
|
||||
if (e.key !== "Escape" && e.key !== "Shift" && e.key !== "Control" && e.key !== "Alt" && e.key !== "Meta") {
|
||||
escMetaPrefixUntilRef.current = 0;
|
||||
}
|
||||
|
||||
const s = stateRef.current;
|
||||
const ghost = ghostAddonRef.current;
|
||||
|
||||
@@ -328,9 +366,11 @@ export function handleTerminalAutocompleteKeyEvent(
|
||||
}
|
||||
}
|
||||
|
||||
// Escape: close popup and hide ghost text
|
||||
// Escape: close popup and hide ghost text.
|
||||
// Only consume Escape if popup is visible; don't block Escape for vi-mode shells
|
||||
// when only ghost text is showing (ghost text is passive/non-intrusive)
|
||||
// when only ghost text is showing (ghost text is passive/non-intrusive).
|
||||
// After dismissing the popup, arm a short Meta prefix so Esc+. / Esc+_ still
|
||||
// reach readline yank-last-arg (issue #2364) without entering vi-cmd mode.
|
||||
if (e.key === "Escape" && s.popupVisible) {
|
||||
e.preventDefault();
|
||||
if (previewActiveRef.current) {
|
||||
@@ -339,6 +379,7 @@ export function handleTerminalAutocompleteKeyEvent(
|
||||
ghost?.hide();
|
||||
clearState();
|
||||
previewActiveRef.current = false;
|
||||
escMetaPrefixUntilRef.current = now() + AUTOCOMPLETE_ESC_META_TIMEOUT_MS;
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
@@ -276,6 +276,8 @@ export function useTerminalAutocomplete(
|
||||
const completionAbortRef = useRef<AbortController | null>(null);
|
||||
/** Last accepted suggestion text — for accurate history recording on fast Enter after accept */
|
||||
const lastAcceptedCommandRef = useRef<string | null>(null);
|
||||
/** Deadline for treating the next `.` / `_` as readline Meta after Esc dismissed the popup. */
|
||||
const escMetaPrefixUntilRef = useRef(0);
|
||||
/** The user's typed input that produced the current popup suggestions (live-preview baseline). */
|
||||
const previewBaselineRef = useRef<string>("");
|
||||
/** Whether a popup candidate is currently rendered into the command line (#1005). */
|
||||
@@ -1146,6 +1148,7 @@ export function useTerminalAutocomplete(
|
||||
typedBufferReliableRef,
|
||||
previewActiveRef,
|
||||
lastAcceptedCommandRef,
|
||||
escMetaPrefixUntilRef,
|
||||
setState,
|
||||
expandSubDir,
|
||||
writeToTerminal,
|
||||
|
||||
@@ -0,0 +1,304 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import {
|
||||
COPY_ON_SELECT_USER_GESTURE_RELEASE_MS,
|
||||
createCopyOnSelectUserGestureTracker,
|
||||
pulseCopyOnSelectUserCommand,
|
||||
shouldWriteCopyOnSelect,
|
||||
subscribeCopyOnSelectUserCommand,
|
||||
subscribeCopyOnSelectUserGesture,
|
||||
} from "./copyOnSelect.ts";
|
||||
|
||||
test("copy-on-select writes only after a user selection gesture", () => {
|
||||
assert.equal(shouldWriteCopyOnSelect({
|
||||
hasText: true,
|
||||
copyOnSelect: true,
|
||||
isRestoringSelection: false,
|
||||
isUserSelection: true,
|
||||
}), true);
|
||||
});
|
||||
|
||||
test("copy-on-select skips SearchAddon and other programmatic selections", () => {
|
||||
assert.equal(shouldWriteCopyOnSelect({
|
||||
hasText: true,
|
||||
copyOnSelect: true,
|
||||
isRestoringSelection: false,
|
||||
isUserSelection: false,
|
||||
}), false);
|
||||
});
|
||||
|
||||
test("copy-on-select still skips restore and attach snapshots", () => {
|
||||
assert.equal(shouldWriteCopyOnSelect({
|
||||
allowCopy: false,
|
||||
hasText: true,
|
||||
copyOnSelect: true,
|
||||
isRestoringSelection: false,
|
||||
isUserSelection: true,
|
||||
}), false);
|
||||
assert.equal(shouldWriteCopyOnSelect({
|
||||
hasText: true,
|
||||
copyOnSelect: true,
|
||||
isRestoringSelection: true,
|
||||
isUserSelection: true,
|
||||
}), false);
|
||||
assert.equal(shouldWriteCopyOnSelect({
|
||||
hasText: false,
|
||||
copyOnSelect: true,
|
||||
isRestoringSelection: false,
|
||||
isUserSelection: true,
|
||||
}), false);
|
||||
assert.equal(shouldWriteCopyOnSelect({
|
||||
hasText: true,
|
||||
copyOnSelect: false,
|
||||
isRestoringSelection: false,
|
||||
isUserSelection: true,
|
||||
}), false);
|
||||
});
|
||||
|
||||
test("user gesture stays armed until shortly after pointer-up", () => {
|
||||
assert.ok(COPY_ON_SELECT_USER_GESTURE_RELEASE_MS < 200);
|
||||
|
||||
const scheduled: Array<{ cb: () => void; ms: number }> = [];
|
||||
const tracker = createCopyOnSelectUserGestureTracker({
|
||||
setTimeoutFn: ((cb: () => void, ms?: number) => {
|
||||
scheduled.push({ cb, ms: ms ?? 0 });
|
||||
return scheduled.length as unknown as ReturnType<typeof setTimeout>;
|
||||
}) as typeof setTimeout,
|
||||
clearTimeoutFn: (() => {}) as typeof clearTimeout,
|
||||
});
|
||||
|
||||
assert.equal(tracker.isActive(), false);
|
||||
tracker.mark();
|
||||
assert.equal(tracker.isActive(), true);
|
||||
|
||||
tracker.release();
|
||||
assert.equal(tracker.isActive(), true);
|
||||
assert.equal(scheduled.at(-1)?.ms, COPY_ON_SELECT_USER_GESTURE_RELEASE_MS);
|
||||
|
||||
// A later SearchAddon revival (200ms) must not still look like a drag.
|
||||
scheduled.at(-1)?.cb();
|
||||
assert.equal(tracker.isActive(), false);
|
||||
|
||||
tracker.dispose();
|
||||
});
|
||||
|
||||
test("marking again cancels a pending release so a new drag can copy", () => {
|
||||
const cleared: number[] = [];
|
||||
let nextId = 1;
|
||||
const tracker = createCopyOnSelectUserGestureTracker({
|
||||
setTimeoutFn: ((cb: () => void) => {
|
||||
const id = nextId;
|
||||
nextId += 1;
|
||||
void cb;
|
||||
return id as unknown as ReturnType<typeof setTimeout>;
|
||||
}) as typeof setTimeout,
|
||||
clearTimeoutFn: ((id: ReturnType<typeof setTimeout>) => {
|
||||
cleared.push(id as unknown as number);
|
||||
}) as typeof clearTimeout,
|
||||
});
|
||||
|
||||
tracker.mark();
|
||||
tracker.release();
|
||||
tracker.mark();
|
||||
|
||||
assert.deepEqual(cleared, [1]);
|
||||
assert.equal(tracker.isActive(), true);
|
||||
tracker.dispose();
|
||||
});
|
||||
|
||||
const listenerKey = (
|
||||
type: string,
|
||||
options?: boolean | AddEventListenerOptions,
|
||||
): string => {
|
||||
const capture = options === true || (
|
||||
typeof options === "object" && options?.capture === true
|
||||
);
|
||||
return `${type}:${capture ? "capture" : "bubble"}`;
|
||||
};
|
||||
|
||||
const createEventTargetStub = () => {
|
||||
const listeners = new Map<string, Set<EventListener>>();
|
||||
return {
|
||||
addEventListener(
|
||||
type: string,
|
||||
listener: EventListener,
|
||||
options?: boolean | AddEventListenerOptions,
|
||||
) {
|
||||
const key = listenerKey(type, options);
|
||||
const set = listeners.get(key) ?? new Set();
|
||||
set.add(listener);
|
||||
listeners.set(key, set);
|
||||
},
|
||||
removeEventListener(
|
||||
type: string,
|
||||
listener: EventListener,
|
||||
options?: boolean | AddEventListenerOptions,
|
||||
) {
|
||||
listeners.get(listenerKey(type, options))?.delete(listener);
|
||||
},
|
||||
dispatch(type: string, event: Event, phase: "capture" | "bubble") {
|
||||
for (const listener of listeners.get(`${type}:${phase}`) ?? []) {
|
||||
listener(event);
|
||||
}
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
const eventOn = (type: string, target: EventTarget): Event => {
|
||||
const event = new Event(type);
|
||||
Object.defineProperty(event, "target", { value: target });
|
||||
return event;
|
||||
};
|
||||
|
||||
test("pointer listeners mark on capture down and release on document up", () => {
|
||||
const el = createEventTargetStub();
|
||||
const root = createEventTargetStub();
|
||||
const view = createEventTargetStub();
|
||||
let marked = 0;
|
||||
let pulsed = 0;
|
||||
let released = 0;
|
||||
const unsubscribe = subscribeCopyOnSelectUserGesture(
|
||||
{ element: el },
|
||||
{
|
||||
mark: () => {
|
||||
marked += 1;
|
||||
},
|
||||
release: () => {
|
||||
released += 1;
|
||||
},
|
||||
pulse: () => {
|
||||
pulsed += 1;
|
||||
},
|
||||
},
|
||||
root,
|
||||
view,
|
||||
);
|
||||
|
||||
root.dispatch("mousedown", eventOn("mousedown", el), "capture");
|
||||
root.dispatch("mouseup", eventOn("mouseup", el), "bubble");
|
||||
root.dispatch("contextmenu", eventOn("contextmenu", el), "capture");
|
||||
root.dispatch("touchcancel", eventOn("touchcancel", el), "bubble");
|
||||
view.dispatch("blur", eventOn("blur", el), "bubble");
|
||||
assert.equal(marked, 1);
|
||||
assert.equal(released, 3);
|
||||
assert.equal(pulsed, 1);
|
||||
|
||||
unsubscribe();
|
||||
root.dispatch("mousedown", eventOn("mousedown", el), "capture");
|
||||
root.dispatch("mouseup", eventOn("mouseup", el), "bubble");
|
||||
root.dispatch("touchcancel", eventOn("touchcancel", el), "bubble");
|
||||
view.dispatch("blur", eventOn("blur", el), "bubble");
|
||||
assert.equal(marked, 1);
|
||||
assert.equal(released, 3);
|
||||
assert.equal(pulsed, 1);
|
||||
});
|
||||
|
||||
test("late contextmenu after mouseup is a one-shot and then releases", () => {
|
||||
const scheduled: Array<() => void> = [];
|
||||
const tracker = createCopyOnSelectUserGestureTracker({
|
||||
setTimeoutFn: ((cb: () => void) => {
|
||||
scheduled.push(cb);
|
||||
return scheduled.length as unknown as ReturnType<typeof setTimeout>;
|
||||
}) as typeof setTimeout,
|
||||
clearTimeoutFn: (() => {}) as typeof clearTimeout,
|
||||
});
|
||||
|
||||
tracker.mark();
|
||||
tracker.release();
|
||||
// Windows: contextmenu after mouseup used to cancel this timer and stick.
|
||||
tracker.pulse();
|
||||
assert.equal(tracker.isActive(), true);
|
||||
scheduled.at(-1)?.();
|
||||
assert.equal(tracker.isActive(), false);
|
||||
|
||||
tracker.dispose();
|
||||
});
|
||||
|
||||
test("document-capture contextmenu still counts when the terminal never sees the bubble", () => {
|
||||
const el = createEventTargetStub();
|
||||
const outside = createEventTargetStub();
|
||||
const root = createEventTargetStub();
|
||||
let pulsed = 0;
|
||||
subscribeCopyOnSelectUserGesture(
|
||||
{ element: el },
|
||||
{
|
||||
mark: () => {},
|
||||
release: () => {},
|
||||
pulse: () => {
|
||||
pulsed += 1;
|
||||
},
|
||||
},
|
||||
root,
|
||||
);
|
||||
|
||||
// tmux/vim capture handler on the container stops the event before
|
||||
// term.element bubble listeners would run.
|
||||
root.dispatch("contextmenu", eventOn("contextmenu", el), "capture");
|
||||
root.dispatch("contextmenu", eventOn("contextmenu", outside), "capture");
|
||||
assert.equal(pulsed, 1);
|
||||
});
|
||||
|
||||
test("user-invoked Select All pulses only the selected terminal", () => {
|
||||
let pulsedA = 0;
|
||||
let pulsedB = 0;
|
||||
const terminalA = { id: "a" };
|
||||
const terminalB = { id: "b" };
|
||||
const unsubscribeA = subscribeCopyOnSelectUserCommand(terminalA, () => {
|
||||
pulsedA += 1;
|
||||
});
|
||||
const unsubscribeB = subscribeCopyOnSelectUserCommand(terminalB, () => {
|
||||
pulsedB += 1;
|
||||
});
|
||||
|
||||
pulseCopyOnSelectUserCommand(terminalA);
|
||||
assert.equal(pulsedA, 1);
|
||||
assert.equal(pulsedB, 0);
|
||||
|
||||
unsubscribeA();
|
||||
unsubscribeB();
|
||||
pulseCopyOnSelectUserCommand(terminalA);
|
||||
assert.equal(pulsedA, 1);
|
||||
assert.equal(pulsedB, 0);
|
||||
});
|
||||
|
||||
test("issue 3007: search match then later revival does not copy", () => {
|
||||
const scheduled: Array<() => void> = [];
|
||||
const tracker = createCopyOnSelectUserGestureTracker({
|
||||
setTimeoutFn: ((cb: () => void) => {
|
||||
scheduled.push(cb);
|
||||
return scheduled.length as unknown as ReturnType<typeof setTimeout>;
|
||||
}) as typeof setTimeout,
|
||||
clearTimeoutFn: (() => {}) as typeof clearTimeout,
|
||||
});
|
||||
|
||||
// Typing in the search bar selects the match with no terminal pointer.
|
||||
assert.equal(shouldWriteCopyOnSelect({
|
||||
hasText: true,
|
||||
copyOnSelect: true,
|
||||
isRestoringSelection: false,
|
||||
isUserSelection: tracker.isActive(),
|
||||
}), false);
|
||||
|
||||
// User drag-selects a docker image id in the buffer.
|
||||
tracker.mark();
|
||||
assert.equal(shouldWriteCopyOnSelect({
|
||||
hasText: true,
|
||||
copyOnSelect: true,
|
||||
isRestoringSelection: false,
|
||||
isUserSelection: tracker.isActive(),
|
||||
}), true);
|
||||
tracker.release();
|
||||
scheduled.at(-1)?.();
|
||||
|
||||
// Opening the snippet dialog resizes the terminal; SearchAddon re-selects
|
||||
// the search term. Clipboard must stay on the image id.
|
||||
assert.equal(shouldWriteCopyOnSelect({
|
||||
hasText: true,
|
||||
copyOnSelect: true,
|
||||
isRestoringSelection: false,
|
||||
isUserSelection: tracker.isActive(),
|
||||
}), false);
|
||||
|
||||
tracker.dispose();
|
||||
});
|
||||
@@ -0,0 +1,168 @@
|
||||
/**
|
||||
* Copy-on-select policy for the xterm selection overlay.
|
||||
*
|
||||
* SearchAddon (and other programmatic paths) call terminal.select() to mark
|
||||
* the active match. Those selection-change events must not write the
|
||||
* clipboard — otherwise a later user selection is overwritten by the search
|
||||
* term after a resize/write revival (issue #3007).
|
||||
*/
|
||||
|
||||
export const COPY_ON_SELECT_USER_GESTURE_RELEASE_MS = 80;
|
||||
|
||||
export type CopyOnSelectUserGestureTracker = {
|
||||
mark: () => void;
|
||||
release: () => void;
|
||||
/** Mark then release — one-shot gestures such as a late contextmenu. */
|
||||
pulse: () => void;
|
||||
isActive: () => boolean;
|
||||
dispose: () => void;
|
||||
};
|
||||
|
||||
export const createCopyOnSelectUserGestureTracker = ({
|
||||
releaseDelayMs = COPY_ON_SELECT_USER_GESTURE_RELEASE_MS,
|
||||
setTimeoutFn = setTimeout,
|
||||
clearTimeoutFn = clearTimeout,
|
||||
}: {
|
||||
releaseDelayMs?: number;
|
||||
setTimeoutFn?: typeof setTimeout;
|
||||
clearTimeoutFn?: typeof clearTimeout;
|
||||
} = {}): CopyOnSelectUserGestureTracker => {
|
||||
let active = false;
|
||||
let releaseTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
const clearReleaseTimer = () => {
|
||||
if (releaseTimer === null) return;
|
||||
clearTimeoutFn(releaseTimer);
|
||||
releaseTimer = null;
|
||||
};
|
||||
|
||||
const mark = () => {
|
||||
clearReleaseTimer();
|
||||
active = true;
|
||||
};
|
||||
|
||||
const release = () => {
|
||||
clearReleaseTimer();
|
||||
releaseTimer = setTimeoutFn(() => {
|
||||
releaseTimer = null;
|
||||
active = false;
|
||||
}, releaseDelayMs);
|
||||
};
|
||||
|
||||
const pulse = () => {
|
||||
mark();
|
||||
release();
|
||||
};
|
||||
|
||||
const dispose = () => {
|
||||
clearReleaseTimer();
|
||||
active = false;
|
||||
};
|
||||
|
||||
return {
|
||||
mark,
|
||||
release,
|
||||
pulse,
|
||||
isActive: () => active,
|
||||
dispose,
|
||||
};
|
||||
};
|
||||
|
||||
const CAPTURE = { capture: true } as const;
|
||||
|
||||
const eventIsInsideTerminal = (
|
||||
event: Event,
|
||||
el: EventTarget,
|
||||
): boolean => {
|
||||
const target = event.target;
|
||||
if (!target) return false;
|
||||
if (target === el) return true;
|
||||
const host = el as { contains?: (node: EventTarget) => boolean };
|
||||
return typeof host.contains === "function" && host.contains(target);
|
||||
};
|
||||
|
||||
export const subscribeCopyOnSelectUserGesture = (
|
||||
term: { element?: EventTarget | null } | null | undefined,
|
||||
tracker: Pick<CopyOnSelectUserGestureTracker, "mark" | "release" | "pulse">,
|
||||
root: Pick<EventTarget, "addEventListener" | "removeEventListener"> | null = (
|
||||
typeof document === "undefined" ? null : document
|
||||
),
|
||||
view: Pick<EventTarget, "addEventListener" | "removeEventListener"> | null = (
|
||||
typeof window === "undefined" ? null : window
|
||||
),
|
||||
): (() => void) => {
|
||||
const el = term?.element;
|
||||
if (!el || !root) return () => {};
|
||||
|
||||
const onPointerDown = (event: Event) => {
|
||||
if (!eventIsInsideTerminal(event, el)) return;
|
||||
tracker.mark();
|
||||
};
|
||||
const onContextMenu = (event: Event) => {
|
||||
if (!eventIsInsideTerminal(event, el)) return;
|
||||
// Capture on the document so we still see right-clicks that
|
||||
// useTerminalEffects intercepts with stopImmediatePropagation
|
||||
// (tmux/vim mouse tracking + select-word). Pulse so a late
|
||||
// contextmenu after mouseup cannot leave the tracker armed.
|
||||
tracker.pulse();
|
||||
};
|
||||
const onPointerUp = () => tracker.release();
|
||||
|
||||
root.addEventListener("mousedown", onPointerDown, CAPTURE);
|
||||
root.addEventListener("touchstart", onPointerDown, CAPTURE);
|
||||
root.addEventListener("contextmenu", onContextMenu, CAPTURE);
|
||||
root.addEventListener("mouseup", onPointerUp);
|
||||
root.addEventListener("touchend", onPointerUp);
|
||||
root.addEventListener("touchcancel", onPointerUp);
|
||||
// Alt-tab / window blur drops the matching mouseup in Electron.
|
||||
view?.addEventListener("blur", onPointerUp);
|
||||
|
||||
return () => {
|
||||
root.removeEventListener("mousedown", onPointerDown, CAPTURE);
|
||||
root.removeEventListener("touchstart", onPointerDown, CAPTURE);
|
||||
root.removeEventListener("contextmenu", onContextMenu, CAPTURE);
|
||||
root.removeEventListener("mouseup", onPointerUp);
|
||||
root.removeEventListener("touchend", onPointerUp);
|
||||
root.removeEventListener("touchcancel", onPointerUp);
|
||||
view?.removeEventListener("blur", onPointerUp);
|
||||
};
|
||||
};
|
||||
|
||||
const userCommandPulses = new Map<unknown, () => void>();
|
||||
|
||||
/** Select All / Select Word from a shortcut or menu — not SearchAddon. */
|
||||
export const subscribeCopyOnSelectUserCommand = (
|
||||
key: unknown,
|
||||
pulse: () => void,
|
||||
): (() => void) => {
|
||||
userCommandPulses.set(key, pulse);
|
||||
return () => {
|
||||
if (userCommandPulses.get(key) === pulse) {
|
||||
userCommandPulses.delete(key);
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
export const pulseCopyOnSelectUserCommand = (key: unknown): void => {
|
||||
userCommandPulses.get(key)?.();
|
||||
};
|
||||
|
||||
export const shouldWriteCopyOnSelect = ({
|
||||
allowCopy = true,
|
||||
hasText,
|
||||
copyOnSelect,
|
||||
isRestoringSelection,
|
||||
isUserSelection,
|
||||
}: {
|
||||
allowCopy?: boolean;
|
||||
hasText: boolean;
|
||||
copyOnSelect: boolean;
|
||||
isRestoringSelection: boolean;
|
||||
isUserSelection: boolean;
|
||||
}): boolean => (
|
||||
allowCopy
|
||||
&& hasText
|
||||
&& copyOnSelect
|
||||
&& !isRestoringSelection
|
||||
&& isUserSelection
|
||||
);
|
||||
@@ -109,7 +109,7 @@ test("hibernate wake pauses flow before replay and resumes only after reattach",
|
||||
|
||||
assert.match(
|
||||
terminalSource,
|
||||
/takePendingBuffer:\s*\(\)\s*=>\s*\{\s*const pending = hibernatePendingBufferRef\.current;\s*hibernatePendingBufferRef\.current = "";\s*return pending;\s*\}/,
|
||||
/takePendingBuffer:\s*\(\)\s*=>\s*\{\s*const pending = hibernatePendingBufferRef\.current \+ oscNotificationScannerRef\.current\.flush\(\);\s*hibernatePendingBufferRef\.current = "";\s*return pending;\s*\}/,
|
||||
);
|
||||
assert.match(
|
||||
terminalSource,
|
||||
@@ -139,7 +139,7 @@ test("hibernate wake pauses flow before replay and resumes only after reattach",
|
||||
);
|
||||
assert.match(
|
||||
terminalSource,
|
||||
/hibernatePendingCapDisabledRef\.current\s*\?\s*hibernatePendingBufferRef\.current \+ chunk/,
|
||||
/hibernatePendingCapDisabledRef\.current\s*\?\s*hibernatePendingBufferRef\.current \+ scanned\.remainder/,
|
||||
);
|
||||
assert.match(
|
||||
terminalSource,
|
||||
|
||||
@@ -10,7 +10,14 @@ import {
|
||||
type RemoteClipboardImageUploadResult,
|
||||
} from "../clipboardImagePaste";
|
||||
import { handleTerminalClipboardPaste } from "../terminalClipboardPaste";
|
||||
import { pulseCopyOnSelectUserCommand } from "../copyOnSelect";
|
||||
import { getTerminalSelectionForClipboard } from "../normalizeTerminalSelection";
|
||||
import {
|
||||
getHistoryPreviewSelectionFromRoot,
|
||||
requestHistoryPreviewHide,
|
||||
selectHistoryPreviewAll,
|
||||
findHistoryPreviewOverlay,
|
||||
} from "../runtime/terminalHistoryScrollOverride";
|
||||
|
||||
type BroadcastPasteRefs = {
|
||||
sourceSessionId: string;
|
||||
@@ -97,10 +104,11 @@ export const useTerminalContextActions = ({
|
||||
const onCopy = useCallback(() => {
|
||||
const term = termRef.current;
|
||||
if (!term) return;
|
||||
const selection = getTerminalSelectionForClipboard(
|
||||
term,
|
||||
normalizeTextOnCopyRef?.current ?? true,
|
||||
);
|
||||
const selection = getHistoryPreviewSelectionFromRoot(term.element?.parentElement)
|
||||
|| getTerminalSelectionForClipboard(
|
||||
term,
|
||||
normalizeTextOnCopyRef?.current ?? true,
|
||||
);
|
||||
if (selection) {
|
||||
navigator.clipboard.writeText(selection);
|
||||
}
|
||||
@@ -109,6 +117,8 @@ export const useTerminalContextActions = ({
|
||||
const onPaste = useCallback(async () => {
|
||||
const term = termRef.current;
|
||||
if (!term) return;
|
||||
requestHistoryPreviewHide(term.element?.parentElement);
|
||||
term.focus();
|
||||
try {
|
||||
const bridge = netcattyBridge.get();
|
||||
await handleTerminalClipboardPaste({
|
||||
@@ -179,11 +189,14 @@ export const useTerminalContextActions = ({
|
||||
const onPasteSelection = useCallback(() => {
|
||||
const term = termRef.current;
|
||||
if (!term) return;
|
||||
const selection = getTerminalSelectionForClipboard(
|
||||
term,
|
||||
normalizeTextOnCopyRef?.current ?? true,
|
||||
);
|
||||
const selection = getHistoryPreviewSelectionFromRoot(term.element?.parentElement)
|
||||
|| getTerminalSelectionForClipboard(
|
||||
term,
|
||||
normalizeTextOnCopyRef?.current ?? true,
|
||||
);
|
||||
if (!selection || !sessionRef.current) return;
|
||||
requestHistoryPreviewHide(term.element?.parentElement);
|
||||
term.focus();
|
||||
pasteTextIntoTerminal(term, selection, {
|
||||
scrollOnPaste: scrollOnPasteRef?.current ?? false,
|
||||
onPasteData: broadcastUserPasteData,
|
||||
@@ -193,6 +206,12 @@ export const useTerminalContextActions = ({
|
||||
const onSelectAll = useCallback(() => {
|
||||
const term = termRef.current;
|
||||
if (!term) return;
|
||||
pulseCopyOnSelectUserCommand(term);
|
||||
const previewOverlay = findHistoryPreviewOverlay(term.element?.parentElement);
|
||||
if (previewOverlay && selectHistoryPreviewAll(previewOverlay)) {
|
||||
onHasSelectionChange?.(true);
|
||||
return;
|
||||
}
|
||||
term.selectAll();
|
||||
onHasSelectionChange?.(true);
|
||||
}, [onHasSelectionChange, termRef]);
|
||||
@@ -214,6 +233,7 @@ export const useTerminalContextActions = ({
|
||||
const onSelectWord = useCallback(() => {
|
||||
const term = termRef.current;
|
||||
if (!term) return;
|
||||
pulseCopyOnSelectUserCommand(term);
|
||||
term.selectAll();
|
||||
onHasSelectionChange?.(true);
|
||||
}, [onHasSelectionChange, termRef]);
|
||||
|
||||
@@ -265,6 +265,101 @@ test("ordinary Enter does not rebuild a saturated scrollback", async () => {
|
||||
term.dispose();
|
||||
});
|
||||
|
||||
test("Enter fused with bracketed-paste CR keeps the previous line highlighted", async () => {
|
||||
const term = new XTerm({ allowProposedApi: true, cols: 80, rows: 10, scrollback: 50 });
|
||||
const highlighter = new KeywordHighlighter(term);
|
||||
highlighter.setRules(rule(), true);
|
||||
await write(term, "[root@host ~]# echo ERROR");
|
||||
assert.equal(cellRgb(term, 0, "ERROR"), RED);
|
||||
|
||||
// bash 5.1+ disables bracketed paste on Enter with `\x1b[?2004l\r`; the bare
|
||||
// CR arrives fused with the echoed newline, output, and next prompt.
|
||||
await write(term, "\r\n\x1b[?2004l\rERROR\r\n\x1b[?2004h[root@host ~]# ");
|
||||
|
||||
assert.equal(cellRgb(term, 1, "ERROR"), RED, "new output must be highlighted");
|
||||
assert.equal(cellRgb(term, 0, "ERROR"), RED, "previous command line must stay highlighted");
|
||||
highlighter.dispose();
|
||||
term.dispose();
|
||||
});
|
||||
|
||||
test("newline-prefixed chunks that climb back up do not leave stale highlight colors", async () => {
|
||||
const term = new XTerm({ allowProposedApi: true, cols: 80, rows: 10, scrollback: 50 });
|
||||
const highlighter = new KeywordHighlighter(term);
|
||||
highlighter.setRules(rule(), true);
|
||||
await write(term, "ERROR");
|
||||
assert.equal(cellRgb(term, 0, "ERROR"), RED);
|
||||
|
||||
// Multi-line progress style redraw: advance, climb back up, partially
|
||||
// overwrite the highlighted keyword, and return to the newer row.
|
||||
await write(term, "\r\n\x1b[1A\rOK\x1b[1B");
|
||||
|
||||
assert.equal(term.buffer.active.getLine(0)?.translateToString(true), "OKROR");
|
||||
const line = term.buffer.active.getLine(0);
|
||||
for (let x = 0; x < 5; x += 1) {
|
||||
assert.notEqual(line?.getCell(x)?.getFgColor(), RED, `cell ${x} must not keep the injected color`);
|
||||
}
|
||||
highlighter.dispose();
|
||||
term.dispose();
|
||||
});
|
||||
|
||||
test("VPA jumps back to an earlier row without leaving stale highlight colors", async () => {
|
||||
const term = new XTerm({ allowProposedApi: true, cols: 80, rows: 10, scrollback: 50 });
|
||||
const highlighter = new KeywordHighlighter(term);
|
||||
highlighter.setRules(rule(), true);
|
||||
await write(term, "ERROR");
|
||||
assert.equal(cellRgb(term, 0, "ERROR"), RED);
|
||||
|
||||
// Absolute vertical positioning (CSI Ps d) can also target the earlier row.
|
||||
await write(term, "\r\n\x1b[1d\rOK\x1b[2d");
|
||||
|
||||
assert.equal(term.buffer.active.getLine(0)?.translateToString(true), "OKROR");
|
||||
const line = term.buffer.active.getLine(0);
|
||||
for (let x = 0; x < 5; x += 1) {
|
||||
assert.notEqual(line?.getCell(x)?.getFgColor(), RED, `cell ${x} must not keep the injected color`);
|
||||
}
|
||||
highlighter.dispose();
|
||||
term.dispose();
|
||||
});
|
||||
|
||||
test("DECSTBM homing does not leave stale highlight colors on the earlier row", async () => {
|
||||
const term = new XTerm({ allowProposedApi: true, cols: 80, rows: 10, scrollback: 50 });
|
||||
const highlighter = new KeywordHighlighter(term);
|
||||
highlighter.setRules(rule(), true);
|
||||
await write(term, "ERROR");
|
||||
assert.equal(cellRgb(term, 0, "ERROR"), RED);
|
||||
|
||||
// Setting a scroll region (CSI Ps;Ps r) homes the cursor back to row 0.
|
||||
await write(term, "\r\n\x1b[1;10r\rOK\x1b[1B");
|
||||
|
||||
assert.equal(term.buffer.active.getLine(0)?.translateToString(true), "OKROR");
|
||||
const line = term.buffer.active.getLine(0);
|
||||
for (let x = 0; x < 5; x += 1) {
|
||||
assert.notEqual(line?.getCell(x)?.getFgColor(), RED, `cell ${x} must not keep the injected color`);
|
||||
}
|
||||
highlighter.dispose();
|
||||
term.dispose();
|
||||
});
|
||||
|
||||
test("unenumerated homing controls are caught by the start-row fingerprint", async () => {
|
||||
const term = new XTerm({ allowProposedApi: true, cols: 80, rows: 10, scrollback: 50 });
|
||||
const highlighter = new KeywordHighlighter(term);
|
||||
highlighter.setRules(rule(), true);
|
||||
await write(term, "ERROR");
|
||||
assert.equal(cellRgb(term, 0, "ERROR"), RED);
|
||||
|
||||
// DECOM (CSI ?6h) homes the cursor but is not in the backtracking regex;
|
||||
// the mutated start row is detected by its changed text instead.
|
||||
await write(term, "\r\n\x1b[?6h\rOK\x1b[1B");
|
||||
|
||||
assert.equal(term.buffer.active.getLine(0)?.translateToString(true), "OKROR");
|
||||
const line = term.buffer.active.getLine(0);
|
||||
for (let x = 0; x < 5; x += 1) {
|
||||
assert.notEqual(line?.getCell(x)?.getFgColor(), RED, `cell ${x} must not keep the injected color`);
|
||||
}
|
||||
highlighter.dispose();
|
||||
term.dispose();
|
||||
});
|
||||
|
||||
test("carriage-return rewrites rematch the current line", async () => {
|
||||
const term = new XTerm({ allowProposedApi: true, cols: 80, rows: 5, scrollback: 20 });
|
||||
const highlighter = new KeywordHighlighter(term);
|
||||
|
||||
@@ -384,13 +384,32 @@ export class KeywordHighlighter implements IDisposable {
|
||||
}
|
||||
// In-place CR / backspace / EL / ICH / DCH rewrite the current row.
|
||||
// `\r\n` is a line advance and must not restore/repaint the previous prompt.
|
||||
const eraseInLine = typeof data === "string" && /\x1b\[[\d;]*[K@PMLGHf]/.test(data); // eslint-disable-line no-control-regex
|
||||
const rewritesCurrentLine = typeof data === "string"
|
||||
&& (/\r(?!\n)/.test(data) || data.includes("\x08") || eraseInLine);
|
||||
const startsWithLineAdvance = typeof data === "string" && /^(?:\r\n|\n)/.test(data);
|
||||
// CUU / CPL / CUP / VPA / DECSTBM (homes the cursor) / restore-cursor /
|
||||
// RI / DECRC can move back onto rows the leading newline already left
|
||||
// (multi-line progress redraws). Controls this heuristic misses are caught
|
||||
// by the start-row fingerprint safety net below.
|
||||
const movesCursorUp = typeof data === "string"
|
||||
&& (/\x1b(?:\[[\d;]*[AFHfudr]|M|8)/.test(data)); // eslint-disable-line no-control-regex
|
||||
const eraseInLine = typeof data === "string" && /\x1b\[[\d;]*[K@PMLGHf]/.test(data); // eslint-disable-line no-control-regex
|
||||
// A chunk that starts with a line advance leaves the cursor row before any
|
||||
// later CR/EL can touch it (bash's bracketed-paste `\x1b[?2004l\r` arrives
|
||||
// fused with the echoed newline). Restoring startY here would strip the
|
||||
// previous prompt's highlight while the post-write repaint skips that row.
|
||||
// Chunks that can climb back up keep the restore and repaint startY below.
|
||||
const rewritesCurrentLine = typeof data === "string"
|
||||
&& (!startsWithLineAdvance || movesCursorUp)
|
||||
&& (/\r(?!\n)/.test(data) || data.includes("\x08") || eraseInLine);
|
||||
if (this.compiledPatterns.length > 0 && rewritesCurrentLine) {
|
||||
this.restorePhysicalLine(startY);
|
||||
}
|
||||
const skipStartRow = startsWithLineAdvance && !movesCursorUp;
|
||||
// Safety net for controls the backtracking heuristic does not enumerate
|
||||
// (DECOM, DECCOLM, ...): if the skipped start row's text changed during
|
||||
// the write, something climbed back onto it and it must be repainted.
|
||||
const startRowTextBefore = skipStartRow
|
||||
? this.term.buffer.active.getLine(startY)?.translateToString(false)
|
||||
: undefined;
|
||||
const writeMarker = this.term.registerMarker(0);
|
||||
return this.originalWrite(data, () => {
|
||||
const active = this.term.buffer.active;
|
||||
@@ -402,7 +421,11 @@ export class KeywordHighlighter implements IDisposable {
|
||||
this.scheduleCatchUp();
|
||||
}
|
||||
} else {
|
||||
const fromY = startsWithLineAdvance ? Math.min(endY, writeMarker.line + 1) : writeMarker.line;
|
||||
const startRowMutated = startRowTextBefore !== undefined
|
||||
&& active.getLine(writeMarker.line)?.translateToString(false) !== startRowTextBefore;
|
||||
const fromY = skipStartRow && !startRowMutated
|
||||
? Math.min(endY, writeMarker.line + 1)
|
||||
: writeMarker.line;
|
||||
if (this.compiledPatterns.length === 0) {
|
||||
if (this.hasStoredOriginalsInRange(fromY, endY)) {
|
||||
this.markCatchUp(fromY);
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { createRequire } from "node:module";
|
||||
import test from "node:test";
|
||||
import type { Terminal as XTermType } from "@xterm/xterm";
|
||||
import {
|
||||
getNormalizedTerminalSelection,
|
||||
getTerminalSelectionForClipboard,
|
||||
@@ -9,6 +11,11 @@ import {
|
||||
type SelectionTerminal,
|
||||
} from "./normalizeTerminalSelection.ts";
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
const { Terminal: XTerm } = require("@xterm/xterm") as {
|
||||
Terminal: typeof XTermType;
|
||||
};
|
||||
|
||||
/**
|
||||
* Fake line that matches real xterm translateToString(true) semantics:
|
||||
* trimRight only drops *empty* cells (trailing chars that were never written),
|
||||
@@ -16,7 +23,16 @@ import {
|
||||
*/
|
||||
function makeLine(
|
||||
text: string,
|
||||
options: { isWrapped?: boolean; emptyCells?: number; cellWidths?: number[] } = {},
|
||||
options: {
|
||||
isWrapped?: boolean;
|
||||
emptyCells?: number;
|
||||
cellWidths?: number[];
|
||||
/**
|
||||
* Model xterm.js 6 string-cache: a no-endColumn translateToString(true)
|
||||
* drops written trailing spaces via String.trimEnd().
|
||||
*/
|
||||
canonicalTrimEnd?: boolean;
|
||||
} = {},
|
||||
): SelectionBufferLine {
|
||||
const emptyCells = options.emptyCells ?? 0;
|
||||
// Optional per-character terminal widths (default 1). Used to model CJK.
|
||||
@@ -59,8 +75,9 @@ function makeLine(
|
||||
getWidth: () => width,
|
||||
};
|
||||
},
|
||||
translateToString(trimRight = false, startColumn = 0, endColumn = fullCols) {
|
||||
let end = Math.max(startColumn, Math.min(endColumn, fullCols));
|
||||
translateToString(trimRight = false, startColumn = 0, endColumn?: number) {
|
||||
const endSpecified = arguments.length >= 3;
|
||||
let end = Math.max(startColumn, Math.min(endColumn ?? fullCols, fullCols));
|
||||
if (trimRight) {
|
||||
while (end > startColumn && (cols[end - 1] === "\0" || cols[end - 1] === "")) {
|
||||
end -= 1;
|
||||
@@ -71,13 +88,22 @@ function makeLine(
|
||||
const cell = cols[c];
|
||||
if (cell && cell !== "\0") result += cell;
|
||||
}
|
||||
if (trimRight && options.canonicalTrimEnd && !endSpecified) {
|
||||
return result.trimEnd();
|
||||
}
|
||||
return result;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function makeTerm(
|
||||
lines: Array<{ text: string; isWrapped?: boolean; emptyCells?: number; cellWidths?: number[] }>,
|
||||
lines: Array<{
|
||||
text: string;
|
||||
isWrapped?: boolean;
|
||||
emptyCells?: number;
|
||||
cellWidths?: number[];
|
||||
canonicalTrimEnd?: boolean;
|
||||
}>,
|
||||
range: { start: { x: number; y: number }; end: { x: number; y: number } } | null,
|
||||
options: {
|
||||
rawSelection?: string;
|
||||
@@ -89,6 +115,7 @@ function makeTerm(
|
||||
isWrapped: line.isWrapped,
|
||||
emptyCells: line.emptyCells,
|
||||
cellWidths: line.cellWidths,
|
||||
canonicalTrimEnd: line.canonicalTrimEnd,
|
||||
}),
|
||||
);
|
||||
return {
|
||||
@@ -362,3 +389,39 @@ test("soft-wrapped CJK with padding joins without inserted spaces", () => {
|
||||
);
|
||||
assert.equal(getNormalizedTerminalSelection(term), "Pi: 用 /copy 最稳");
|
||||
});
|
||||
|
||||
test("keeps a wrap-boundary space that canonical translateToString trimEnd would drop", () => {
|
||||
const term = makeTerm(
|
||||
[
|
||||
{ text: "|hbase ", canonicalTrimEnd: true },
|
||||
{ text: "shell", isWrapped: true, canonicalTrimEnd: true },
|
||||
],
|
||||
{ start: { x: 0, y: 0 }, end: { x: 5, y: 1 } },
|
||||
);
|
||||
assert.equal(getNormalizedTerminalSelection(term), "|hbase shell");
|
||||
});
|
||||
|
||||
test("real xterm keeps a wrap-boundary space after an untrimmed string-cache prime", async () => {
|
||||
const xterm = new XTerm({ cols: 20, rows: 8, scrollback: 20, allowProposedApi: true });
|
||||
try {
|
||||
await new Promise<void>((resolve) => {
|
||||
xterm.write("12345678901234hbase shell\r\n", resolve);
|
||||
});
|
||||
// Prompt detector / autocomplete / keyword highlight all call this first.
|
||||
xterm.buffer.active.getLine(0)?.translateToString(false);
|
||||
|
||||
const wrapped: SelectionTerminal = {
|
||||
getSelection: () => xterm.getSelection(),
|
||||
getSelectionPosition: () => ({ start: { x: 0, y: 0 }, end: { x: 5, y: 1 } }),
|
||||
buffer: xterm.buffer,
|
||||
};
|
||||
|
||||
assert.equal(
|
||||
xterm.buffer.active.getLine(0)?.translateToString(true),
|
||||
"12345678901234hbase",
|
||||
);
|
||||
assert.equal(getNormalizedTerminalSelection(wrapped), "12345678901234hbase shell");
|
||||
} finally {
|
||||
xterm.dispose();
|
||||
}
|
||||
});
|
||||
@@ -155,13 +155,11 @@ function buildLinearSelection(
|
||||
}
|
||||
|
||||
const startCol = y === start.y ? start.x : 0;
|
||||
// Match xterm: on multi-row selections the first row runs to the line end
|
||||
// (undefined endCol → line length), not only to the selection's end.x.
|
||||
const endCol = y === end.y ? end.x : undefined;
|
||||
const rowText =
|
||||
endCol === undefined
|
||||
? line.translateToString(true, startCol)
|
||||
: line.translateToString(true, startCol, endCol);
|
||||
// Always pass an exclusive end column. xterm.js 6 may serve a no-endCol
|
||||
// translateToString(true) from its string cache via String.trimEnd(),
|
||||
// which drops a wrap-boundary space (#3023).
|
||||
const endCol = y === end.y ? end.x : line.length;
|
||||
const rowText = line.translateToString(true, startCol, endCol);
|
||||
|
||||
if (y === start.y) {
|
||||
current = rowText;
|
||||
|
||||
@@ -937,3 +937,20 @@ test("command execution records direct sends from host-style greater-than prompt
|
||||
assert.equal(commandBufferRef.current, "", lineText);
|
||||
}
|
||||
});
|
||||
|
||||
test("alternate-screen history preview stays selectable and copyable", async () => {
|
||||
const { readFileSync } = await import("node:fs");
|
||||
const source = readFileSync(new URL("./createXTermRuntime.ts", import.meta.url), "utf8");
|
||||
|
||||
assert.match(source, /pointerEvents:\s*"auto"/);
|
||||
assert.match(source, /userSelect:\s*"text"/);
|
||||
assert.match(source, /shouldHideHistoryPreviewOnMouseDown/);
|
||||
assert.match(source, /shouldKeepHistoryPreviewOnKey/);
|
||||
assert.match(source, /previewSelection \|\| getTerminalSelectionForClipboard/);
|
||||
assert.match(source, /hasCopyableSelection = term\.hasSelection\(\) \|\| Boolean\(previewSelection\)/);
|
||||
assert.match(source, /shouldUseUrgentTerminalInterrupt\(e, \{ hasSelection: hasCopyableSelection \}\)/);
|
||||
assert.match(source, /isHistoryPreviewDismissClick/);
|
||||
assert.match(source, /HISTORY_PREVIEW_WRAP_ATTR/);
|
||||
assert.match(source, /HISTORY_PREVIEW_HIDE_EVENT/);
|
||||
assert.match(source, /document\.addEventListener\("copy", handlePreviewNativeCopy, true\)/);
|
||||
});
|
||||
@@ -41,6 +41,12 @@ import {
|
||||
resolveHostTerminalFontWeight,
|
||||
} from "../../../domain/terminalAppearance";
|
||||
import { DEFAULT_TERMINAL_SCROLLBACK } from "../../../domain/models/terminal";
|
||||
import {
|
||||
Osc99Assembler,
|
||||
parseOsc777Payload,
|
||||
parseOsc9Payload,
|
||||
type OscNotification,
|
||||
} from "../../../domain/terminalOscNotifications";
|
||||
import { resolveFontWeightBold } from "../../../lib/fontWeightAvailability";
|
||||
import { isPluginHostProtocol } from "../../../domain/pluginConnection";
|
||||
import { resolveTerminalFontFamilyId } from "../../../infrastructure/config/fonts";
|
||||
@@ -51,6 +57,7 @@ import {
|
||||
clearTerminalViewportAndSyncPty,
|
||||
installEraseInDisplayHandlers,
|
||||
} from "../clearTerminalViewport";
|
||||
import { pulseCopyOnSelectUserCommand } from "../copyOnSelect";
|
||||
import { getTerminalSelectionForClipboard } from "../normalizeTerminalSelection";
|
||||
import {
|
||||
createKittyKeyboardSessionStateStore,
|
||||
@@ -82,6 +89,7 @@ import {
|
||||
import { installUserCursorPreferenceGuard } from "./cursorPreference";
|
||||
import { terminalAltKeyOptions } from "./altKeyOptions";
|
||||
import { optionArrowWordJumpSequence } from "./optionArrowWordJump";
|
||||
import { optionYankLastArgSequence } from "./optionYankLastArg";
|
||||
import { watchDevicePixelRatio } from "./rendererDprWatch";
|
||||
import { shouldDeferWebglUntilVisible } from "./webglRendererPolicy";
|
||||
import { createWebglRendererController } from "./webglRendererController";
|
||||
@@ -114,12 +122,22 @@ import {
|
||||
terminalFontSizeWheelListenerOptions,
|
||||
} from "./terminalFontZoom";
|
||||
import {
|
||||
getHistoryPreviewLines,
|
||||
HISTORY_PREVIEW_HIDE_EVENT,
|
||||
HISTORY_PREVIEW_OVERLAY_ATTR,
|
||||
HISTORY_PREVIEW_WRAP_ATTR,
|
||||
encodeHistoryPreviewWrapFlags,
|
||||
getHistoryPreviewRows,
|
||||
getHistoryPreviewSelectionFromRoot,
|
||||
forcedHistoryScrollLinesForWheel,
|
||||
forcedHistoryScrollPageToLines,
|
||||
forcedHistoryScrollPagesForKey,
|
||||
forcedHistoryScrollWheelListenerOptions,
|
||||
isHistoryPreviewDismissClick,
|
||||
isHistoryPreviewPointerTarget,
|
||||
nextHistoryPreviewTop,
|
||||
selectHistoryPreviewAll,
|
||||
shouldHideHistoryPreviewOnMouseDown,
|
||||
shouldKeepHistoryPreviewOnKey,
|
||||
} from "./terminalHistoryScrollOverride";
|
||||
import { shouldPassThroughCopyShortcut } from "./terminalCopyShortcut";
|
||||
import { shouldUseUrgentTerminalInterrupt } from "./terminalInterruptShortcut";
|
||||
@@ -341,6 +359,9 @@ export type CreateXTermRuntimeContext = {
|
||||
// Callback when the shell rings the terminal bell
|
||||
onBell?: () => void;
|
||||
|
||||
// Callback when a remote program requests a desktop notification via OSC 9/777/99
|
||||
onOscNotification?: (notification: OscNotification) => void;
|
||||
|
||||
// Callback when remote requests clipboard read in 'prompt' mode; resolves to user's decision
|
||||
onOsc52ReadRequest?: () => Promise<boolean>;
|
||||
|
||||
@@ -695,7 +716,28 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
|
||||
|
||||
// Intercept native copy (Edit > Copy, browser/Electron copy event) before
|
||||
// xterm's built-in handler writes selectionText, so normalizeTextOnCopy applies.
|
||||
const writePreviewSelectionToClipboard = (event: ClipboardEvent, previewSelection: string) => {
|
||||
if (event.clipboardData) {
|
||||
event.clipboardData.setData("text/plain", previewSelection);
|
||||
} else {
|
||||
void navigator.clipboard.writeText(previewSelection).catch((err) => {
|
||||
logger.warn("[XTerm] History preview copy failed:", err);
|
||||
});
|
||||
}
|
||||
event.preventDefault();
|
||||
event.stopImmediatePropagation();
|
||||
};
|
||||
const handlePreviewNativeCopy = (event: ClipboardEvent) => {
|
||||
const previewSelection = getHistoryPreviewSelectionFromRoot(ctx.container);
|
||||
if (!previewSelection) return;
|
||||
writePreviewSelectionToClipboard(event, previewSelection);
|
||||
};
|
||||
const handleNativeCopy = (event: ClipboardEvent) => {
|
||||
const previewSelection = getHistoryPreviewSelectionFromRoot(ctx.container);
|
||||
if (previewSelection) {
|
||||
writePreviewSelectionToClipboard(event, previewSelection);
|
||||
return;
|
||||
}
|
||||
if (!term.hasSelection()) return;
|
||||
const normalize = ctx.terminalSettingsRef.current?.normalizeTextOnCopy ?? true;
|
||||
if (!normalize) return; // let xterm write raw selectionText
|
||||
@@ -712,6 +754,7 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
|
||||
event.stopImmediatePropagation();
|
||||
};
|
||||
term.element?.addEventListener("copy", handleNativeCopy, true);
|
||||
ctx.container.addEventListener("copy", handleNativeCopy, true);
|
||||
|
||||
let webglLoaded = false;
|
||||
let runtimeDisposed = false;
|
||||
@@ -898,15 +941,37 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
|
||||
};
|
||||
let historyPreviewOverlay: HTMLPreElement | null = null;
|
||||
let historyPreviewTop: number | null = null;
|
||||
let historyPreviewPointerDown: { clientX: number; clientY: number } | null = null;
|
||||
const hideHistoryPreview = () => {
|
||||
historyPreviewOverlay?.remove();
|
||||
if (!historyPreviewOverlay) {
|
||||
historyPreviewPointerDown = null;
|
||||
document.removeEventListener("copy", handlePreviewNativeCopy, true);
|
||||
return;
|
||||
}
|
||||
historyPreviewOverlay.remove();
|
||||
historyPreviewOverlay = null;
|
||||
historyPreviewTop = null;
|
||||
historyPreviewPointerDown = null;
|
||||
document.removeEventListener("copy", handlePreviewNativeCopy, true);
|
||||
term.focus();
|
||||
};
|
||||
const copyHistoryPreviewSelectionIfEnabled = () => {
|
||||
if (!ctx.terminalSettingsRef.current?.copyOnSelect) return;
|
||||
if (ctx.isRestoringSelectionRef?.current) return;
|
||||
const selection = getHistoryPreviewSelectionFromRoot(ctx.container);
|
||||
if (selection) {
|
||||
void navigator.clipboard.writeText(selection).catch((err) => {
|
||||
logger.warn("[XTerm] History preview copy-on-select failed:", err);
|
||||
});
|
||||
}
|
||||
};
|
||||
const ensureHistoryPreviewOverlay = () => {
|
||||
if (historyPreviewOverlay) return historyPreviewOverlay;
|
||||
const overlay = document.createElement("pre");
|
||||
overlay.setAttribute("aria-hidden", "true");
|
||||
overlay.setAttribute(HISTORY_PREVIEW_OVERLAY_ATTR, "");
|
||||
overlay.setAttribute("role", "document");
|
||||
overlay.addEventListener(HISTORY_PREVIEW_HIDE_EVENT, hideHistoryPreview);
|
||||
document.addEventListener("copy", handlePreviewNativeCopy, true);
|
||||
Object.assign(overlay.style, {
|
||||
position: "absolute",
|
||||
inset: "0",
|
||||
@@ -914,7 +979,10 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
|
||||
margin: "0",
|
||||
padding: "0 6px",
|
||||
overflow: "hidden",
|
||||
pointerEvents: "none",
|
||||
pointerEvents: "auto",
|
||||
userSelect: "text",
|
||||
webkitUserSelect: "text",
|
||||
cursor: "text",
|
||||
whiteSpace: "pre",
|
||||
fontFamily: String(term.options.fontFamily ?? fontFamily),
|
||||
fontSize: `${currentTerminalFontSize()}px`,
|
||||
@@ -938,11 +1006,13 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
|
||||
overlay.style.fontSize = `${currentTerminalFontSize()}px`;
|
||||
overlay.style.fontFamily = String(term.options.fontFamily ?? fontFamily);
|
||||
overlay.style.lineHeight = String(term.options.lineHeight ?? lineHeight);
|
||||
overlay.textContent = getHistoryPreviewLines({
|
||||
const previewRows = getHistoryPreviewRows({
|
||||
buffer: normalBuffer,
|
||||
rows: term.rows,
|
||||
top: historyPreviewTop,
|
||||
}).join("\n");
|
||||
});
|
||||
overlay.textContent = previewRows.map((row) => row.text).join("\n");
|
||||
overlay.setAttribute(HISTORY_PREVIEW_WRAP_ATTR, encodeHistoryPreviewWrapFlags(previewRows));
|
||||
return true;
|
||||
};
|
||||
const scrollForcedHistoryLines = (lines: number) => {
|
||||
@@ -1423,6 +1493,8 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
|
||||
markKittyCompositionPending(true);
|
||||
}
|
||||
|
||||
const previewSelection = getHistoryPreviewSelectionFromRoot(ctx.container);
|
||||
const hasCopyableSelection = term.hasSelection() || Boolean(previewSelection);
|
||||
const forcedHistoryScrollPages = forcedHistoryScrollPagesForKey(e);
|
||||
if (forcedHistoryScrollPages !== null) {
|
||||
e.preventDefault();
|
||||
@@ -1435,7 +1507,24 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
|
||||
term.scrollPages(forcedHistoryScrollPages);
|
||||
return false;
|
||||
}
|
||||
hideHistoryPreview();
|
||||
const previewKeepAliveScheme = ctx.hotkeySchemeRef.current;
|
||||
const previewKeepAliveIsMac =
|
||||
previewKeepAliveScheme === "mac"
|
||||
|| (previewKeepAliveScheme === "disabled" && isMacPlatform());
|
||||
const previewKeepAliveBindings = ctx.keyBindingsRef.current;
|
||||
const previewKeepAliveAction =
|
||||
previewKeepAliveScheme !== "disabled" && previewKeepAliveBindings.length > 0
|
||||
? checkAppShortcut(e, previewKeepAliveBindings, previewKeepAliveIsMac)?.action
|
||||
: undefined;
|
||||
if (
|
||||
!shouldKeepHistoryPreviewOnKey(e, {
|
||||
action: previewKeepAliveAction,
|
||||
hasPreviewSelection: Boolean(previewSelection),
|
||||
overlayVisible: Boolean(historyPreviewOverlay),
|
||||
})
|
||||
) {
|
||||
hideHistoryPreview();
|
||||
}
|
||||
|
||||
// Password prompt assist (sudo/su): while pending, Enter confirms the
|
||||
// selected/host password; arrows move the picker; Esc soft-dismisses (keeps
|
||||
@@ -1489,7 +1578,7 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
|
||||
// the interrupt path hard-aborts instead (#2191).
|
||||
if (
|
||||
e.key.length === 1
|
||||
&& !shouldUseUrgentTerminalInterrupt(e, { hasSelection: term.hasSelection() })
|
||||
&& !shouldUseUrgentTerminalInterrupt(e, { hasSelection: hasCopyableSelection })
|
||||
) {
|
||||
sudoAutofill.cancelHint();
|
||||
// fall through: key becomes the first char of the manually typed password
|
||||
@@ -1522,7 +1611,7 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
|
||||
: null;
|
||||
if (
|
||||
(!kittySequenceForKeyDown || kittySequenceForKeyDown === "\x03") &&
|
||||
shouldUseUrgentTerminalInterrupt(e, { hasSelection: term.hasSelection() })
|
||||
shouldUseUrgentTerminalInterrupt(e, { hasSelection: hasCopyableSelection })
|
||||
) {
|
||||
const id = ctx.sessionRef.current;
|
||||
if (id && ctx.statusRef.current === "connected") {
|
||||
@@ -1650,14 +1739,18 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
|
||||
// No xterm selection: pass Ctrl+C through for SIGINT, and Cmd+C for
|
||||
// Kitty Super+C (nested TUIs). Other copy chords stay a safe no-op.
|
||||
const shouldForwardCopyToTerminal =
|
||||
shouldPassThroughCopyShortcut(action, term.hasSelection(), e);
|
||||
shouldPassThroughCopyShortcut(
|
||||
action,
|
||||
hasCopyableSelection,
|
||||
e,
|
||||
);
|
||||
if (shouldForwardCopyToTerminal && !kittySequenceForKeyDown) return true;
|
||||
if (!shouldForwardCopyToTerminal) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
switch (action) {
|
||||
case "copy": {
|
||||
const selection = getTerminalSelectionForClipboard(
|
||||
const selection = previewSelection || getTerminalSelectionForClipboard(
|
||||
term,
|
||||
ctx.terminalSettingsRef.current?.normalizeTextOnCopy ?? true,
|
||||
);
|
||||
@@ -1672,12 +1765,13 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
|
||||
break;
|
||||
}
|
||||
case "pasteSelection": {
|
||||
const selection = getTerminalSelectionForClipboard(
|
||||
const selection = previewSelection || getTerminalSelectionForClipboard(
|
||||
term,
|
||||
ctx.terminalSettingsRef.current?.normalizeTextOnCopy ?? true,
|
||||
);
|
||||
const id = ctx.sessionRef.current;
|
||||
if (selection && id) {
|
||||
hideHistoryPreview();
|
||||
pasteTextIntoTerminal(term, selection, {
|
||||
scrollOnPaste: shouldScrollOnTerminalPaste(ctx.terminalSettingsRef.current),
|
||||
onPasteData: broadcastUserPasteData,
|
||||
@@ -1686,6 +1780,10 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
|
||||
break;
|
||||
}
|
||||
case "selectAll": {
|
||||
pulseCopyOnSelectUserCommand(term);
|
||||
if (historyPreviewOverlay && selectHistoryPreviewAll(historyPreviewOverlay)) {
|
||||
break;
|
||||
}
|
||||
term.selectAll();
|
||||
break;
|
||||
}
|
||||
@@ -1797,7 +1895,7 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
|
||||
event: kittyEvent,
|
||||
fallbackToLegacy: true,
|
||||
urgentInterrupt: shouldUseUrgentTerminalInterrupt(e, {
|
||||
hasSelection: term.hasSelection(),
|
||||
hasSelection: hasCopyableSelection,
|
||||
}),
|
||||
});
|
||||
if (forwarded) {
|
||||
@@ -1832,6 +1930,22 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
|
||||
}
|
||||
}
|
||||
|
||||
// macOS Option+. / Option+_ → ESC+. / ESC+_ so readline yank-last-arg and
|
||||
// zsh insert-last-word work without enabling Option-as-Meta (issue #2364).
|
||||
const yankLastArgSequence = isKittyKeyboardModeActive(kittyKeyboardMode)
|
||||
? null
|
||||
: optionYankLastArgSequence(e, isMacPlatform());
|
||||
if (yankLastArgSequence) {
|
||||
const id = ctx.sessionRef.current;
|
||||
if (id) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
handleTerminalInputData(yankLastArgSequence);
|
||||
scrollToBottomAfterInput(yankLastArgSequence);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
const normalizedKittyEvent = toKittyKeyboardEvent(e);
|
||||
if (!shouldDeferKittyKeyEvent(normalizedKittyEvent)) {
|
||||
const identity = kittyKeyIdentity(e);
|
||||
@@ -1896,9 +2010,30 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
|
||||
ctx.container.dispatchEvent(contextMenuEvent);
|
||||
};
|
||||
|
||||
const handleHistoryPreviewMouseDown = (event: MouseEvent) => {
|
||||
if (isHistoryPreviewPointerTarget(event.target, historyPreviewOverlay)) {
|
||||
if (event.button === 0) {
|
||||
historyPreviewPointerDown = { clientX: event.clientX, clientY: event.clientY };
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (!shouldHideHistoryPreviewOnMouseDown(event.target, historyPreviewOverlay)) return;
|
||||
hideHistoryPreview();
|
||||
};
|
||||
const handleHistoryPreviewMouseUp = (event: MouseEvent) => {
|
||||
const down = historyPreviewPointerDown;
|
||||
historyPreviewPointerDown = null;
|
||||
if (!down || !isHistoryPreviewPointerTarget(event.target, historyPreviewOverlay)) return;
|
||||
if (isHistoryPreviewDismissClick(down, event)) {
|
||||
hideHistoryPreview();
|
||||
return;
|
||||
}
|
||||
copyHistoryPreviewSelectionIfEnabled();
|
||||
};
|
||||
ctx.container.addEventListener("mousedown", captureMiddleClickTerminalMouseEvent, true);
|
||||
ctx.container.addEventListener("mouseup", captureMiddleClickTerminalMouseEvent, true);
|
||||
ctx.container.addEventListener("mousedown", hideHistoryPreview, true);
|
||||
ctx.container.addEventListener("mousedown", handleHistoryPreviewMouseDown, true);
|
||||
ctx.container.addEventListener("mouseup", handleHistoryPreviewMouseUp, true);
|
||||
ctx.container.addEventListener("auxclick", handleMiddleClick);
|
||||
|
||||
fitAddon.fit();
|
||||
@@ -2157,6 +2292,25 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
|
||||
return true;
|
||||
});
|
||||
|
||||
// OSC 9 / 777 / 99 — desktop notifications (Codex, iTerm2, rxvt, kitty)
|
||||
const osc99Assembler = new Osc99Assembler();
|
||||
const emitOscNotification = (notification: OscNotification | null) => {
|
||||
if (!notification) return;
|
||||
ctx.onOscNotification?.(notification);
|
||||
};
|
||||
const osc9Disposable = term.parser.registerOscHandler(9, (data) => {
|
||||
emitOscNotification(parseOsc9Payload(data));
|
||||
return true;
|
||||
});
|
||||
const osc777Disposable = term.parser.registerOscHandler(777, (data) => {
|
||||
emitOscNotification(parseOsc777Payload(data));
|
||||
return true;
|
||||
});
|
||||
const osc99Disposable = term.parser.registerOscHandler(99, (data) => {
|
||||
emitOscNotification(osc99Assembler.consume(data));
|
||||
return true;
|
||||
});
|
||||
|
||||
// OSC 52 — clipboard integration
|
||||
// Format: 52;<target>;<base64-data> (write) or 52;<target>;? (query/read)
|
||||
// <target> is typically "c" (clipboard) or "p" (primary selection)
|
||||
@@ -2303,6 +2457,7 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
|
||||
resizeScheduler.dispose();
|
||||
webglController.dispose();
|
||||
term.element?.removeEventListener("copy", handleNativeCopy, true);
|
||||
ctx.container.removeEventListener("copy", handleNativeCopy, true);
|
||||
ctx.container.removeEventListener(
|
||||
"wheel",
|
||||
handleForcedHistoryScrollWheel,
|
||||
@@ -2316,7 +2471,8 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
|
||||
ctx.container.removeEventListener("auxclick", handleMiddleClick);
|
||||
ctx.container.removeEventListener("mousedown", captureMiddleClickTerminalMouseEvent, true);
|
||||
ctx.container.removeEventListener("mouseup", captureMiddleClickTerminalMouseEvent, true);
|
||||
ctx.container.removeEventListener("mousedown", hideHistoryPreview, true);
|
||||
ctx.container.removeEventListener("mousedown", handleHistoryPreviewMouseDown, true);
|
||||
ctx.container.removeEventListener("mouseup", handleHistoryPreviewMouseUp, true);
|
||||
hideHistoryPreview();
|
||||
historyPreviewBufferChangeDisposable.dispose();
|
||||
stopDprWatch();
|
||||
@@ -2339,6 +2495,9 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
|
||||
clearKittyTransientInputState();
|
||||
osc7Disposable.dispose();
|
||||
osc133Disposable.dispose();
|
||||
osc9Disposable.dispose();
|
||||
osc777Disposable.dispose();
|
||||
osc99Disposable.dispose();
|
||||
osc52Disposable.dispose();
|
||||
titleChangeDisposable.dispose();
|
||||
bellDisposable.dispose();
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
|
||||
import { optionYankLastArgSequence } from "./optionYankLastArg";
|
||||
|
||||
// Issue #2364: Esc+. / Alt+. is readline yank-last-arg (zsh insert-last-word).
|
||||
// Traditional terminals just pass Meta-. through. On macOS, Option+. types "≥"
|
||||
// unless Option is Meta, so map the physical period / underscore keys to ESC+.
|
||||
// / ESC+_ — same idea as Option+←/→ word-jump, but always-on because ≥ is
|
||||
// almost never wanted in a terminal.
|
||||
|
||||
const ev = (over: Partial<Parameters<typeof optionYankLastArgSequence>[0]> = {}) => ({
|
||||
key: ".",
|
||||
code: "Period",
|
||||
altKey: true,
|
||||
ctrlKey: false,
|
||||
metaKey: false,
|
||||
shiftKey: false,
|
||||
...over,
|
||||
});
|
||||
|
||||
test("Option+. → ESC+. (yank-last-arg) on macOS", () => {
|
||||
assert.equal(optionYankLastArgSequence(ev(), true), "\x1b.");
|
||||
});
|
||||
|
||||
test("Option+. with composed ≥ (US layout) still maps to ESC+.", () => {
|
||||
assert.equal(
|
||||
optionYankLastArgSequence(ev({ key: "≥", code: "Period" }), true),
|
||||
"\x1b.",
|
||||
);
|
||||
});
|
||||
|
||||
test("Option+_ → ESC+_ (yank-last-arg synonym) on macOS", () => {
|
||||
assert.equal(
|
||||
optionYankLastArgSequence(ev({ key: "_", code: "Minus", shiftKey: true }), true),
|
||||
"\x1b_",
|
||||
);
|
||||
});
|
||||
|
||||
test("not macOS → null (Linux/Windows Alt+. already sends Meta via xterm)", () => {
|
||||
assert.equal(optionYankLastArgSequence(ev(), false), null);
|
||||
assert.equal(
|
||||
optionYankLastArgSequence(ev({ key: "_", code: "Minus", shiftKey: true }), false),
|
||||
null,
|
||||
);
|
||||
});
|
||||
|
||||
test("no Option held → null", () => {
|
||||
assert.equal(optionYankLastArgSequence(ev({ altKey: false }), true), null);
|
||||
});
|
||||
|
||||
test("Ctrl/Cmd with Option → null (don't hijack other chords)", () => {
|
||||
assert.equal(optionYankLastArgSequence(ev({ ctrlKey: true }), true), null);
|
||||
assert.equal(optionYankLastArgSequence(ev({ metaKey: true }), true), null);
|
||||
});
|
||||
|
||||
test("Option+Shift+. (>) → null", () => {
|
||||
assert.equal(optionYankLastArgSequence(ev({ shiftKey: true }), true), null);
|
||||
});
|
||||
|
||||
test("other Option keys → null", () => {
|
||||
assert.equal(optionYankLastArgSequence(ev({ key: "f", code: "KeyF" }), true), null);
|
||||
assert.equal(optionYankLastArgSequence(ev({ key: "ArrowLeft", code: "ArrowLeft" }), true), null);
|
||||
});
|
||||
|
||||
test("runtime sends Option+. after kitty mode, same as word-jump", () => {
|
||||
const source = readFileSync(new URL("./createXTermRuntime.ts", import.meta.url), "utf8");
|
||||
assert.match(source, /from "\.\/optionYankLastArg"/);
|
||||
assert.match(
|
||||
source,
|
||||
/optionArrowWordJumpSequence\([\s\S]*?const yankLastArgSequence = isKittyKeyboardModeActive\(kittyKeyboardMode\)\s*\? null\s*: optionYankLastArgSequence\(/s,
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,37 @@
|
||||
export interface OptionYankLastArgKeyEvent {
|
||||
key: string;
|
||||
code?: string;
|
||||
altKey: boolean;
|
||||
ctrlKey: boolean;
|
||||
metaKey: boolean;
|
||||
shiftKey: boolean;
|
||||
}
|
||||
|
||||
const isPeriodKey = (e: OptionYankLastArgKeyEvent): boolean => (
|
||||
e.code === "Period" || e.key === "." || e.key === "≥"
|
||||
);
|
||||
|
||||
const isUnderscoreKey = (e: OptionYankLastArgKeyEvent): boolean => (
|
||||
e.key === "_" || (e.code === "Minus" && e.shiftKey)
|
||||
);
|
||||
|
||||
/**
|
||||
* macOS Option+. / Option+_ → readline yank-last-arg (issue #2364).
|
||||
*
|
||||
* Ghostty/iTerm2/kitty/VS Code do not implement this themselves — they pass
|
||||
* Meta-. through to bash/zsh. On macOS, Option+. types "≥" unless Option is
|
||||
* Meta, so map those two physical keys to ESC+. / ESC+_ without turning every
|
||||
* Option chord into Meta (unlike `altAsMeta`).
|
||||
*
|
||||
* Gated to macOS: Linux/Windows Alt+. already sends the ESC prefix via xterm.
|
||||
*/
|
||||
export function optionYankLastArgSequence(
|
||||
e: OptionYankLastArgKeyEvent,
|
||||
isMac: boolean,
|
||||
): string | null {
|
||||
if (!isMac) return null;
|
||||
if (!e.altKey || e.ctrlKey || e.metaKey) return null;
|
||||
if (isPeriodKey(e) && !e.shiftKey) return "\x1b.";
|
||||
if (isUnderscoreKey(e)) return "\x1b_";
|
||||
return null;
|
||||
}
|
||||
@@ -2,12 +2,22 @@ import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import {
|
||||
HISTORY_PREVIEW_OVERLAY_ATTR,
|
||||
HISTORY_PREVIEW_WRAP_ATTR,
|
||||
encodeHistoryPreviewWrapFlags,
|
||||
getHistoryPreviewLines,
|
||||
getHistoryPreviewRows,
|
||||
getHistoryPreviewSelectionText,
|
||||
forcedHistoryScrollLinesForWheel,
|
||||
forcedHistoryScrollPageToLines,
|
||||
forcedHistoryScrollPagesForKey,
|
||||
forcedHistoryScrollWheelListenerOptions,
|
||||
isHistoryPreviewContextMenuTarget,
|
||||
isHistoryPreviewDismissClick,
|
||||
joinHistoryPreviewSelectionText,
|
||||
nextHistoryPreviewTop,
|
||||
shouldHideHistoryPreviewOnMouseDown,
|
||||
shouldKeepHistoryPreviewOnKey,
|
||||
} from "./terminalHistoryScrollOverride.ts";
|
||||
|
||||
const wheel = (
|
||||
@@ -118,3 +128,220 @@ test("alternate-screen history preview reads normal-buffer history", () => {
|
||||
alternateBuffer.getLine(1)?.translateToString(),
|
||||
]);
|
||||
});
|
||||
|
||||
test("history preview stays up for pointer selection and copy chords", () => {
|
||||
const overlay = { contains: (node: unknown) => node === "inside" };
|
||||
|
||||
assert.equal(shouldHideHistoryPreviewOnMouseDown("inside", overlay), false);
|
||||
assert.equal(shouldHideHistoryPreviewOnMouseDown("outside", overlay), true);
|
||||
assert.equal(shouldHideHistoryPreviewOnMouseDown("inside", null), false);
|
||||
|
||||
assert.equal(shouldKeepHistoryPreviewOnKey(key({ key: "Shift" })), true);
|
||||
assert.equal(shouldKeepHistoryPreviewOnKey(key({ key: "Meta" })), true);
|
||||
assert.equal(
|
||||
shouldKeepHistoryPreviewOnKey(key({ key: "c", metaKey: true, shiftKey: false }), {
|
||||
hasPreviewSelection: true,
|
||||
}),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
shouldKeepHistoryPreviewOnKey(key({ key: "c", ctrlKey: true, shiftKey: false }), {
|
||||
action: "copy",
|
||||
hasPreviewSelection: true,
|
||||
}),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
shouldKeepHistoryPreviewOnKey(key({ key: "c", ctrlKey: true, shiftKey: false }), {
|
||||
action: "copy",
|
||||
}),
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
shouldKeepHistoryPreviewOnKey(key({ key: "a", metaKey: true, shiftKey: false }), {
|
||||
action: "selectAll",
|
||||
overlayVisible: true,
|
||||
}),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
shouldKeepHistoryPreviewOnKey(key({ key: "a", metaKey: true, shiftKey: false }), {
|
||||
overlayVisible: true,
|
||||
}),
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
shouldKeepHistoryPreviewOnKey(key({ key: "j", shiftKey: false })),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test("history preview copy uses only overlay-owned DOM selection", () => {
|
||||
const overlay = {
|
||||
contains(node: unknown) {
|
||||
return node === "preview";
|
||||
},
|
||||
};
|
||||
|
||||
assert.equal(
|
||||
getHistoryPreviewSelectionText(overlay, {
|
||||
rangeCount: 1,
|
||||
anchorNode: "preview",
|
||||
focusNode: "preview",
|
||||
toString: () => "old prompt output",
|
||||
}),
|
||||
"old prompt output",
|
||||
);
|
||||
assert.equal(
|
||||
getHistoryPreviewSelectionText(overlay, {
|
||||
rangeCount: 1,
|
||||
anchorNode: "xterm",
|
||||
focusNode: "xterm",
|
||||
toString: () => "vim buffer",
|
||||
}),
|
||||
"",
|
||||
);
|
||||
assert.equal(
|
||||
getHistoryPreviewSelectionText(overlay, {
|
||||
rangeCount: 1,
|
||||
isCollapsed: true,
|
||||
anchorNode: "preview",
|
||||
focusNode: "preview",
|
||||
toString: () => "old prompt output",
|
||||
}),
|
||||
"",
|
||||
);
|
||||
});
|
||||
|
||||
test("history preview click dismisses and a drag keeps the overlay", () => {
|
||||
assert.equal(
|
||||
isHistoryPreviewDismissClick(
|
||||
{ clientX: 10, clientY: 10 },
|
||||
{ button: 0, clientX: 11, clientY: 12 },
|
||||
),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
isHistoryPreviewDismissClick(
|
||||
{ clientX: 10, clientY: 10 },
|
||||
{ button: 0, clientX: 40, clientY: 30 },
|
||||
),
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
isHistoryPreviewDismissClick(
|
||||
{ clientX: 10, clientY: 10 },
|
||||
{ button: 2, clientX: 10, clientY: 10 },
|
||||
),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test("select-all overlay ranges still join soft-wrapped preview rows", () => {
|
||||
const text = "ssh host long-command-name\n --flag";
|
||||
const overlay = {
|
||||
firstChild: "text",
|
||||
textContent: text,
|
||||
contains() {
|
||||
return true;
|
||||
},
|
||||
getAttribute(name: string) {
|
||||
return name === HISTORY_PREVIEW_WRAP_ATTR ? "01" : null;
|
||||
},
|
||||
};
|
||||
const copied = getHistoryPreviewSelectionText(overlay, {
|
||||
rangeCount: 1,
|
||||
anchorNode: overlay,
|
||||
focusNode: overlay,
|
||||
anchorOffset: 0,
|
||||
focusOffset: 1,
|
||||
toString: () => text,
|
||||
});
|
||||
assert.equal(copied.includes("\n"), false);
|
||||
assert.match(copied, /long-command-name\s*--flag/);
|
||||
});
|
||||
|
||||
test("history preview copy keeps a selected hard line break", () => {
|
||||
assert.equal(
|
||||
joinHistoryPreviewSelectionText({
|
||||
text: "abc\ndef",
|
||||
startOffset: 0,
|
||||
endOffset: 4,
|
||||
wrapFlags: [false, false],
|
||||
}),
|
||||
"abc\n",
|
||||
);
|
||||
assert.equal(
|
||||
joinHistoryPreviewSelectionText({
|
||||
text: "abc\ndef",
|
||||
startOffset: 3,
|
||||
endOffset: 4,
|
||||
wrapFlags: [false, false],
|
||||
}),
|
||||
"\n",
|
||||
);
|
||||
assert.equal(
|
||||
joinHistoryPreviewSelectionText({
|
||||
text: "abc\ndef",
|
||||
startOffset: 0,
|
||||
endOffset: 4,
|
||||
wrapFlags: [false, true],
|
||||
}),
|
||||
"abc",
|
||||
);
|
||||
});
|
||||
|
||||
test("history preview copy joins soft-wrapped buffer rows", () => {
|
||||
const text = "ssh user@host tail -f /var/log/very-long-name.log\n | grep error";
|
||||
const joined = joinHistoryPreviewSelectionText({
|
||||
text,
|
||||
startOffset: 0,
|
||||
endOffset: text.length,
|
||||
wrapFlags: [false, true],
|
||||
});
|
||||
assert.equal(joined.includes("\n"), false);
|
||||
assert.match(joined, /very-long-name\.log\s*\| grep error/);
|
||||
|
||||
const rows = getHistoryPreviewRows({
|
||||
buffer: {
|
||||
baseY: 1,
|
||||
length: 2,
|
||||
type: "normal",
|
||||
viewportY: 1,
|
||||
getLine(y: number) {
|
||||
if (y === 0) {
|
||||
return {
|
||||
isWrapped: false,
|
||||
translateToString() {
|
||||
return "ssh user@host tail -f /var/log/very-long-name.log";
|
||||
},
|
||||
};
|
||||
}
|
||||
return {
|
||||
isWrapped: true,
|
||||
translateToString() {
|
||||
return " | grep error";
|
||||
},
|
||||
};
|
||||
},
|
||||
},
|
||||
rows: 2,
|
||||
top: 0,
|
||||
});
|
||||
assert.equal(encodeHistoryPreviewWrapFlags(rows), "01");
|
||||
});
|
||||
|
||||
test("history preview right-click is recognized as an app-menu target", () => {
|
||||
assert.equal(
|
||||
isHistoryPreviewContextMenuTarget({
|
||||
closest: (selector: string) => selector === `[${HISTORY_PREVIEW_OVERLAY_ATTR}]` ? {} as Element : null,
|
||||
}),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
isHistoryPreviewContextMenuTarget({
|
||||
closest: () => null,
|
||||
}),
|
||||
false,
|
||||
);
|
||||
});
|
||||
@@ -1,3 +1,5 @@
|
||||
import { joinSoftWrappedRows } from "../normalizeTerminalSelection";
|
||||
|
||||
type WheelLike = Pick<
|
||||
WheelEvent,
|
||||
"altKey" | "ctrlKey" | "deltaMode" | "deltaY" | "metaKey" | "shiftKey"
|
||||
@@ -9,6 +11,7 @@ type KeyLike = Pick<
|
||||
>;
|
||||
|
||||
type BufferLineLike = {
|
||||
isWrapped?: boolean;
|
||||
translateToString(trimRight?: boolean): string;
|
||||
};
|
||||
|
||||
@@ -79,6 +82,33 @@ export const nextHistoryPreviewTop = ({
|
||||
buffer,
|
||||
);
|
||||
|
||||
export type HistoryPreviewRow = {
|
||||
isWrapped: boolean;
|
||||
text: string;
|
||||
};
|
||||
|
||||
export const getHistoryPreviewRows = ({
|
||||
buffer,
|
||||
rows,
|
||||
top,
|
||||
}: {
|
||||
buffer: BufferLike;
|
||||
rows: number;
|
||||
top: number;
|
||||
}): HistoryPreviewRow[] => {
|
||||
const clampedTop = clampHistoryPreviewTop(top, buffer);
|
||||
const visibleRows = Math.max(1, rows);
|
||||
const lines: HistoryPreviewRow[] = [];
|
||||
for (let row = 0; row < visibleRows; row += 1) {
|
||||
const line = buffer.getLine(clampedTop + row);
|
||||
lines.push({
|
||||
isWrapped: Boolean(line?.isWrapped),
|
||||
text: line?.translateToString(true) ?? "",
|
||||
});
|
||||
}
|
||||
return lines;
|
||||
};
|
||||
|
||||
export const getHistoryPreviewLines = ({
|
||||
buffer,
|
||||
rows,
|
||||
@@ -87,12 +117,232 @@ export const getHistoryPreviewLines = ({
|
||||
buffer: BufferLike;
|
||||
rows: number;
|
||||
top: number;
|
||||
}): string[] => {
|
||||
const clampedTop = clampHistoryPreviewTop(top, buffer);
|
||||
const visibleRows = Math.max(1, rows);
|
||||
const lines: string[] = [];
|
||||
for (let row = 0; row < visibleRows; row += 1) {
|
||||
lines.push(buffer.getLine(clampedTop + row)?.translateToString(true) ?? "");
|
||||
}
|
||||
return lines;
|
||||
}): string[] => getHistoryPreviewRows({ buffer, rows, top }).map((row) => row.text);
|
||||
|
||||
export const encodeHistoryPreviewWrapFlags = (rows: Array<Pick<HistoryPreviewRow, "isWrapped">>): string =>
|
||||
rows.map((row) => (row.isWrapped ? "1" : "0")).join("");
|
||||
|
||||
export const HISTORY_PREVIEW_OVERLAY_ATTR = "data-terminal-history-preview";
|
||||
export const HISTORY_PREVIEW_WRAP_ATTR = "data-terminal-history-preview-wraps";
|
||||
export const HISTORY_PREVIEW_CLICK_SLOP_PX = 4;
|
||||
|
||||
const MODIFIER_ONLY_KEYS = new Set(["Shift", "Control", "Meta", "Alt"]);
|
||||
|
||||
export type HistoryPreviewSelectionLike = {
|
||||
rangeCount: number;
|
||||
isCollapsed?: boolean;
|
||||
anchorNode: { nodeType?: number } | null;
|
||||
focusNode: { nodeType?: number } | null;
|
||||
anchorOffset?: number;
|
||||
focusOffset?: number;
|
||||
toString(): string;
|
||||
};
|
||||
|
||||
export type HistoryPreviewNodeLike = {
|
||||
contains(node: { nodeType?: number } | null): boolean;
|
||||
firstChild?: { nodeType?: number } | null;
|
||||
getAttribute?(name: string): string | null;
|
||||
textContent?: string | null;
|
||||
};
|
||||
|
||||
export const isHistoryPreviewPointerTarget = (
|
||||
target: EventTarget | null | undefined,
|
||||
overlay: EventTarget | null | undefined,
|
||||
): boolean => {
|
||||
if (!target || !overlay) return false;
|
||||
if (target === overlay) return true;
|
||||
if (typeof (overlay as HistoryPreviewNodeLike).contains === "function") {
|
||||
return (overlay as HistoryPreviewNodeLike).contains(target as HistoryPreviewNodeLike);
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
export const shouldHideHistoryPreviewOnMouseDown = (
|
||||
target: EventTarget | null | undefined,
|
||||
overlay: EventTarget | null | undefined,
|
||||
): boolean => Boolean(overlay) && !isHistoryPreviewPointerTarget(target, overlay);
|
||||
|
||||
export const isHistoryPreviewContextMenuTarget = (
|
||||
target: EventTarget | null | undefined,
|
||||
): boolean => {
|
||||
if (!target || typeof target !== "object") return false;
|
||||
const element = target as { closest?: (selector: string) => Element | null };
|
||||
return Boolean(element.closest?.(`[${HISTORY_PREVIEW_OVERLAY_ATTR}]`));
|
||||
};
|
||||
|
||||
export const shouldKeepHistoryPreviewOnKey = (
|
||||
event: KeyLike,
|
||||
options: {
|
||||
action?: string | null;
|
||||
hasPreviewSelection?: boolean;
|
||||
overlayVisible?: boolean;
|
||||
} = {},
|
||||
): boolean => {
|
||||
if (forcedHistoryScrollPagesForKey(event) !== null) return true;
|
||||
if (MODIFIER_ONLY_KEYS.has(event.key)) return true;
|
||||
if (options.action === "selectAll" && options.overlayVisible) return true;
|
||||
if (options.action === "copy" && options.hasPreviewSelection) return true;
|
||||
return Boolean(
|
||||
options.hasPreviewSelection
|
||||
&& options.action == null
|
||||
&& (event.metaKey || event.ctrlKey)
|
||||
&& !event.altKey
|
||||
&& event.key.toLowerCase() === "c",
|
||||
);
|
||||
};
|
||||
|
||||
export const isHistoryPreviewDismissClick = (
|
||||
down: Pick<MouseEvent, "clientX" | "clientY">,
|
||||
up: Pick<MouseEvent, "button" | "clientX" | "clientY">,
|
||||
slop = HISTORY_PREVIEW_CLICK_SLOP_PX,
|
||||
): boolean => {
|
||||
if (up.button !== 0) return false;
|
||||
return Math.hypot(up.clientX - down.clientX, up.clientY - down.clientY) <= slop;
|
||||
};
|
||||
|
||||
const lineOffsetsForPreviewText = (text: string): number[] => {
|
||||
const offsets = [0];
|
||||
for (let index = 0; index < text.length; index += 1) {
|
||||
if (text.charCodeAt(index) === 10) offsets.push(index + 1);
|
||||
}
|
||||
return offsets;
|
||||
};
|
||||
|
||||
export const joinHistoryPreviewSelectionText = ({
|
||||
text,
|
||||
startOffset,
|
||||
endOffset,
|
||||
wrapFlags,
|
||||
}: {
|
||||
text: string;
|
||||
startOffset: number;
|
||||
endOffset: number;
|
||||
wrapFlags: boolean[];
|
||||
}): string => {
|
||||
const start = Math.max(0, Math.min(startOffset, endOffset));
|
||||
const end = Math.max(0, Math.max(startOffset, endOffset));
|
||||
if (end <= start) return "";
|
||||
|
||||
const lineStarts = lineOffsetsForPreviewText(text);
|
||||
const lineIndexAt = (offset: number): number => {
|
||||
let index = 0;
|
||||
while (index + 1 < lineStarts.length && lineStarts[index + 1]! <= offset) {
|
||||
index += 1;
|
||||
}
|
||||
return index;
|
||||
};
|
||||
const startLine = lineIndexAt(start);
|
||||
const endLine = lineIndexAt(Math.max(start, end - 1));
|
||||
const boundaryLine = lineStarts.indexOf(end);
|
||||
const includesTrailingHardBreak = boundaryLine > 0 && !wrapFlags[boundaryLine];
|
||||
const sliceLine = (line: number, from: number, to: number): string => {
|
||||
const lineStart = lineStarts[line] ?? 0;
|
||||
const lineEnd = line + 1 < lineStarts.length ? lineStarts[line + 1]! - 1 : text.length;
|
||||
return text.slice(Math.max(lineStart, from), Math.min(lineEnd, to));
|
||||
};
|
||||
|
||||
let current = sliceLine(startLine, start, startLine === endLine ? end : Number.POSITIVE_INFINITY);
|
||||
const logical: string[] = [];
|
||||
for (let line = startLine + 1; line <= endLine; line += 1) {
|
||||
const row = sliceLine(line, 0, line === endLine ? end : Number.POSITIVE_INFINITY);
|
||||
if (wrapFlags[line]) {
|
||||
current = joinSoftWrappedRows(current, row);
|
||||
continue;
|
||||
}
|
||||
logical.push(current);
|
||||
current = row;
|
||||
}
|
||||
logical.push(current);
|
||||
const joined = logical.join("\n");
|
||||
return includesTrailingHardBreak ? `${joined}\n` : joined;
|
||||
};
|
||||
|
||||
const resolveOverlayTextOffset = (
|
||||
overlay: HistoryPreviewNodeLike,
|
||||
textNode: { nodeType?: number },
|
||||
node: { nodeType?: number } | null,
|
||||
offset: number,
|
||||
): number | null => {
|
||||
if (node === textNode) return offset;
|
||||
if (node === overlay) return offset <= 0 ? 0 : overlay.textContent?.length ?? 0;
|
||||
return null;
|
||||
};
|
||||
|
||||
const selectionOffsetsInOverlay = (
|
||||
overlay: HistoryPreviewNodeLike,
|
||||
selection: HistoryPreviewSelectionLike,
|
||||
): { start: number; end: number } | null => {
|
||||
const textNode = overlay.firstChild;
|
||||
if (!textNode) return null;
|
||||
const { anchorNode, focusNode, anchorOffset, focusOffset } = selection;
|
||||
if (anchorOffset == null || focusOffset == null) return null;
|
||||
const start = resolveOverlayTextOffset(overlay, textNode, anchorNode, anchorOffset);
|
||||
const end = resolveOverlayTextOffset(overlay, textNode, focusNode, focusOffset);
|
||||
if (start == null || end == null) return null;
|
||||
return {
|
||||
start: Math.min(start, end),
|
||||
end: Math.max(start, end),
|
||||
};
|
||||
};
|
||||
|
||||
export const getHistoryPreviewSelectionText = (
|
||||
overlay: HistoryPreviewNodeLike | null | undefined,
|
||||
selection: HistoryPreviewSelectionLike | null | undefined,
|
||||
): string => {
|
||||
if (!overlay || !selection || selection.rangeCount === 0 || selection.isCollapsed) {
|
||||
return "";
|
||||
}
|
||||
const { anchorNode, focusNode } = selection;
|
||||
if (!anchorNode || !focusNode) return "";
|
||||
if (!overlay.contains(anchorNode) || !overlay.contains(focusNode)) return "";
|
||||
const raw = selection.toString();
|
||||
const wrapAttr = overlay.getAttribute?.(HISTORY_PREVIEW_WRAP_ATTR);
|
||||
const offsets = selectionOffsetsInOverlay(overlay, selection);
|
||||
if (!wrapAttr || !offsets) return raw;
|
||||
return joinHistoryPreviewSelectionText({
|
||||
text: overlay.textContent ?? "",
|
||||
startOffset: offsets.start,
|
||||
endOffset: offsets.end,
|
||||
wrapFlags: [...wrapAttr].map((flag) => flag === "1"),
|
||||
}) || raw;
|
||||
};
|
||||
|
||||
export const findHistoryPreviewOverlay = (
|
||||
root: ParentNode | Element | null | undefined,
|
||||
): HTMLElement | null => {
|
||||
if (!root || !("querySelector" in root)) return null;
|
||||
return root.querySelector<HTMLElement>(`[${HISTORY_PREVIEW_OVERLAY_ATTR}]`);
|
||||
};
|
||||
|
||||
export const getHistoryPreviewSelectionFromRoot = (
|
||||
root: ParentNode | Element | null | undefined,
|
||||
selection?: HistoryPreviewSelectionLike | null,
|
||||
): string => {
|
||||
const overlay = findHistoryPreviewOverlay(root);
|
||||
const activeSelection = selection ?? overlay?.ownerDocument.getSelection() ?? null;
|
||||
return getHistoryPreviewSelectionText(overlay, activeSelection);
|
||||
};
|
||||
|
||||
export const HISTORY_PREVIEW_HIDE_EVENT = "netcatty-history-preview-hide";
|
||||
|
||||
export const requestHistoryPreviewHide = (
|
||||
root: ParentNode | Element | null | undefined,
|
||||
): boolean => {
|
||||
const overlay = findHistoryPreviewOverlay(root);
|
||||
if (!overlay) return false;
|
||||
overlay.dispatchEvent(new Event(HISTORY_PREVIEW_HIDE_EVENT, { bubbles: true }));
|
||||
return true;
|
||||
};
|
||||
|
||||
export const selectHistoryPreviewAll = (overlay: HTMLElement | null | undefined): boolean => {
|
||||
if (!overlay) return false;
|
||||
const selection = overlay.ownerDocument.getSelection();
|
||||
if (!selection) return false;
|
||||
const range = overlay.ownerDocument.createRange();
|
||||
const textNode = overlay.firstChild;
|
||||
if (textNode) range.selectNodeContents(textNode);
|
||||
else range.selectNodeContents(overlay);
|
||||
selection.removeAllRanges();
|
||||
selection.addRange(range);
|
||||
return !selection.isCollapsed;
|
||||
};
|
||||
@@ -73,6 +73,8 @@ function createContext(overrides: Record<string, unknown> = {}) {
|
||||
typedBufferReliableRef: { current: true },
|
||||
previewActiveRef: { current: false },
|
||||
lastAcceptedCommandRef: { current: null },
|
||||
escMetaPrefixUntilRef: { current: 0 },
|
||||
now: () => 1_000,
|
||||
setState(update: typeof state | ((prev: typeof state) => typeof state)) {
|
||||
state = typeof update === "function" ? update(state) : update;
|
||||
stateRef.current = state;
|
||||
@@ -202,3 +204,147 @@ test("serial-style popup Shift+Enter confirms candidate when terminal shortcut i
|
||||
assert.deepEqual(accepted, [0]);
|
||||
assert.deepEqual(clears, []);
|
||||
});
|
||||
|
||||
test("Esc dismisses the popup and arms Meta prefix so Esc+. can yank-last-arg", () => {
|
||||
const { context, clears } = createContext();
|
||||
const event = keyEvent("Escape");
|
||||
|
||||
const result = handleTerminalAutocompleteKeyEvent(event, context);
|
||||
|
||||
assert.equal(result, false);
|
||||
assert.equal(event.defaultPrevented, true);
|
||||
assert.deepEqual(clears, [1]);
|
||||
assert.ok(context.escMetaPrefixUntilRef.current > 1_000);
|
||||
});
|
||||
|
||||
test("Esc+. after dismissing the popup sends ESC+. to the shell", () => {
|
||||
const { context, writes } = createContext();
|
||||
handleTerminalAutocompleteKeyEvent(keyEvent("Escape"), context);
|
||||
context.stateRef.current = {
|
||||
...context.stateRef.current,
|
||||
popupVisible: false,
|
||||
suggestions: [],
|
||||
};
|
||||
|
||||
const period = keyEvent(".");
|
||||
const result = handleTerminalAutocompleteKeyEvent(period, context);
|
||||
|
||||
assert.equal(result, false);
|
||||
assert.equal(period.defaultPrevented, true);
|
||||
assert.deepEqual(writes, ["\x1b."]);
|
||||
assert.equal(context.typedInputBufferRef.current, "");
|
||||
assert.equal(context.typedBufferReliableRef.current, false);
|
||||
assert.equal(context.lastAcceptedCommandRef.current, null);
|
||||
});
|
||||
|
||||
test("Esc then Shift+. does not yank (Shift+. is >)", () => {
|
||||
const { context, writes } = createContext();
|
||||
handleTerminalAutocompleteKeyEvent(keyEvent("Escape"), context);
|
||||
context.stateRef.current = {
|
||||
...context.stateRef.current,
|
||||
popupVisible: false,
|
||||
suggestions: [],
|
||||
};
|
||||
|
||||
const greaterThan = shiftKeyEvent(">");
|
||||
const result = handleTerminalAutocompleteKeyEvent(greaterThan, context);
|
||||
|
||||
assert.equal(result, true);
|
||||
assert.deepEqual(writes, []);
|
||||
});
|
||||
|
||||
test("Esc+_ after dismissing the popup sends ESC+_ to the shell", () => {
|
||||
const { context, writes } = createContext();
|
||||
handleTerminalAutocompleteKeyEvent(keyEvent("Escape"), context);
|
||||
context.stateRef.current = {
|
||||
...context.stateRef.current,
|
||||
popupVisible: false,
|
||||
suggestions: [],
|
||||
};
|
||||
|
||||
const underscore = shiftKeyEvent("_");
|
||||
const result = handleTerminalAutocompleteKeyEvent(underscore, context);
|
||||
|
||||
assert.equal(result, false);
|
||||
assert.deepEqual(writes, ["\x1b_"]);
|
||||
});
|
||||
|
||||
test("Esc then Shift keydown then _ still yanks (Shift is a separate event)", () => {
|
||||
const { context, writes } = createContext();
|
||||
handleTerminalAutocompleteKeyEvent(keyEvent("Escape"), context);
|
||||
context.stateRef.current = {
|
||||
...context.stateRef.current,
|
||||
popupVisible: false,
|
||||
suggestions: [],
|
||||
};
|
||||
|
||||
const shiftDown = shiftKeyEvent("Shift");
|
||||
assert.equal(handleTerminalAutocompleteKeyEvent(shiftDown, context), true);
|
||||
assert.ok(context.escMetaPrefixUntilRef.current > 1_000);
|
||||
|
||||
const underscore = shiftKeyEvent("_");
|
||||
const result = handleTerminalAutocompleteKeyEvent(underscore, context);
|
||||
|
||||
assert.equal(result, false);
|
||||
assert.deepEqual(writes, ["\x1b_"]);
|
||||
});
|
||||
|
||||
test("Esc then a non-Meta follow-up does not inject ESC and lets the key through", () => {
|
||||
const { context, writes } = createContext();
|
||||
handleTerminalAutocompleteKeyEvent(keyEvent("Escape"), context);
|
||||
context.stateRef.current = {
|
||||
...context.stateRef.current,
|
||||
popupVisible: false,
|
||||
suggestions: [],
|
||||
};
|
||||
|
||||
const letter = keyEvent("l");
|
||||
const result = handleTerminalAutocompleteKeyEvent(letter, context);
|
||||
|
||||
assert.equal(result, true);
|
||||
assert.deepEqual(writes, []);
|
||||
assert.equal(context.escMetaPrefixUntilRef.current, 0);
|
||||
});
|
||||
|
||||
test("Esc+. after the Meta prefix timeout does not yank", () => {
|
||||
let now = 1_000;
|
||||
const { context, writes } = createContext({ now: () => now });
|
||||
handleTerminalAutocompleteKeyEvent(keyEvent("Escape"), context);
|
||||
context.stateRef.current = {
|
||||
...context.stateRef.current,
|
||||
popupVisible: false,
|
||||
suggestions: [],
|
||||
};
|
||||
now = 1_000 + 501;
|
||||
|
||||
const period = keyEvent(".");
|
||||
const result = handleTerminalAutocompleteKeyEvent(period, context);
|
||||
|
||||
assert.equal(result, true);
|
||||
assert.deepEqual(writes, []);
|
||||
});
|
||||
|
||||
test("subdir Escape does not arm Meta prefix", () => {
|
||||
const { context, clears } = createContext({
|
||||
stateRef: {
|
||||
current: {
|
||||
suggestions: [suggestion("show version")],
|
||||
selectedIndex: 0,
|
||||
popupVisible: true,
|
||||
popupAnchorViewport: { left: 0, top: 0, bottom: 0 },
|
||||
expandUpward: false,
|
||||
subDirPanels: [{ entries: [{ name: "src", type: "directory" }], selectedIndex: 0, dirPath: "/src" }],
|
||||
subDirFocusLevel: 0,
|
||||
},
|
||||
},
|
||||
});
|
||||
const event = keyEvent("Escape");
|
||||
|
||||
const result = handleTerminalAutocompleteKeyEvent(event, context);
|
||||
|
||||
assert.equal(result, false);
|
||||
assert.equal(event.defaultPrevented, true);
|
||||
assert.deepEqual(clears, []);
|
||||
assert.equal(context.escMetaPrefixUntilRef.current, 0);
|
||||
});
|
||||
|
||||
@@ -57,9 +57,19 @@ test("background split workspaces keep their live geometry without hibernate", (
|
||||
supportSource,
|
||||
/const initializeHiddenFullSize = !hibernateHiddenTabs[\s\S]*&& !rect[\s\S]*&& !lastVisiblePaneSizeRef\.current;[\s\S]*bumpHiddenPaneSizeVersion/,
|
||||
);
|
||||
// Split rects must never feed the hidden-pane pinned size, or a
|
||||
// split -> focus viewMode switch pins hidden panes (and their PTYs) at the
|
||||
// cached fragment width (#3046).
|
||||
assert.match(
|
||||
supportSource,
|
||||
/if \(isVisible\) \{[\s\S]*const observer = new ResizeObserver\(\(\) => \{[\s\S]*capturePaneSize\(\)/,
|
||||
/if \(isVisible && !rect\) \{[\s\S]*const observer = new ResizeObserver\(\(\) => \{[\s\S]*capturePaneSize\(\)/,
|
||||
);
|
||||
// A split-visible pane must drop the cached full-size measurement so a later
|
||||
// hidden full-size layout re-measures the current area instead of pinning
|
||||
// stale pixels (#3046).
|
||||
assert.match(
|
||||
supportSource,
|
||||
/if \(isVisible\) \{[\s\S]{0,400}lastVisiblePaneSizeRef\.current = null;[\s\S]{0,20}return;/,
|
||||
);
|
||||
assert.match(
|
||||
layerEffectsSource,
|
||||
|
||||
@@ -36,6 +36,13 @@ test("terminal context paste reports false when broadcast is disabled", () => {
|
||||
assert.equal(didBroadcast, false);
|
||||
});
|
||||
|
||||
test("paste selection dismisses the history preview before sending text", async () => {
|
||||
const { readFileSync } = await import("node:fs");
|
||||
const source = readFileSync(new URL("./hooks/useTerminalContextActions.ts", import.meta.url), "utf8");
|
||||
assert.match(source, /requestHistoryPreviewHide\(term\.element\?\.parentElement\)/);
|
||||
assert.equal(source.split("requestHistoryPreviewHide(term.element?.parentElement)").length - 1, 2);
|
||||
});
|
||||
|
||||
test("terminal context paste never broadcasts password-prompt input", () => {
|
||||
const didBroadcast = broadcastTerminalPasteData("secret", {
|
||||
sourceSessionId: "workspace-session-1",
|
||||
|
||||
@@ -48,6 +48,7 @@ import {
|
||||
type XTermFontRemeasureTarget,
|
||||
} from './runtime/terminalFontRemeasure';
|
||||
import { shouldClaimTerminalKeyboardFocus } from '../../domain/terminalKeyboardFocus';
|
||||
import { handleTerminalOscNotification } from '../../application/state/oscDesktopNotifications';
|
||||
import { settleTerminalSearchAfterLayout } from './hooks/useTerminalSearch';
|
||||
import {
|
||||
isTerminalCloseGenerationCurrent,
|
||||
@@ -178,6 +179,8 @@ export function useTerminalEffects(ctx: TerminalEffectsContext) {
|
||||
) && !kittyKeyboardProtocolEnabledForSession;
|
||||
ctx.pluginDecorationRulesRef.current = pluginDecorationRules;
|
||||
const prevIsSearchOpenRef = useRef(isSearchOpen);
|
||||
const isFocusedRef = useRef(!!isFocused);
|
||||
isFocusedRef.current = !!isFocused;
|
||||
const pluginAwareOnCommandSubmitted = (...args: Parameters<NonNullable<typeof onCommandSubmitted>>) => {
|
||||
markTerminalCommandCompletionPending(promptLineBreakStateRef);
|
||||
publishPluginTerminalRuntimeLifecycleEvent(pluginTerminalLifecycle, 'commandSubmitted');
|
||||
@@ -531,6 +534,16 @@ export function useTerminalEffects(ctx: TerminalEffectsContext) {
|
||||
onBell: () => {
|
||||
onTerminalBell?.(sessionId);
|
||||
},
|
||||
onOscNotification: (notification) => {
|
||||
handleTerminalOscNotification({
|
||||
notification,
|
||||
mode: terminalSettingsRef.current?.oscNotifications,
|
||||
sessionFocused: isFocusedRef.current,
|
||||
sessionId,
|
||||
fallbackTitle: host.label || host.hostname || "Netcatty",
|
||||
onSessionActivity: () => onTerminalBell?.(sessionId),
|
||||
});
|
||||
},
|
||||
onOsc52ReadRequest: handleOsc52ReadRequest,
|
||||
// Autocomplete integration
|
||||
onAutocompleteKeyEvent: (e: KeyboardEvent) => autocompleteKeyEventRef.current?.(e) ?? true,
|
||||
|
||||
@@ -1330,7 +1330,11 @@ const TerminalPane: React.FC<TerminalPaneProps> = memo(({
|
||||
return true;
|
||||
};
|
||||
|
||||
if (isVisible) {
|
||||
// Only full-size layouts may feed the hidden-pane pinned size. Split rects
|
||||
// must never be cached: after a split -> focus viewMode switch the cached
|
||||
// fragment size would pin the hidden pane (and its PTY) narrow again,
|
||||
// recreating the \r progress-refresh scrollback spam (#3046).
|
||||
if (isVisible && !rect) {
|
||||
capturePaneSize();
|
||||
const observer = new ResizeObserver(() => {
|
||||
capturePaneSize();
|
||||
@@ -1338,6 +1342,14 @@ const TerminalPane: React.FC<TerminalPaneProps> = memo(({
|
||||
observer.observe(element);
|
||||
return () => observer.disconnect();
|
||||
}
|
||||
if (isVisible) {
|
||||
// A split-visible pane invalidates the cached full-size measurement:
|
||||
// after an intervening viewport resize the stale pixels would pin later
|
||||
// hidden full-size layouts at the wrong width (#3046). The hidden
|
||||
// initialization below re-measures the current area instead.
|
||||
lastVisiblePaneSizeRef.current = null;
|
||||
return;
|
||||
}
|
||||
|
||||
const initializeHiddenFullSize = !hibernateHiddenTabs
|
||||
&& !rect
|
||||
|
||||
@@ -142,7 +142,7 @@ test("terminal pane render snapshot combines visibility and focus in one token",
|
||||
assert.equal(parsed.isFocusedPane, true);
|
||||
});
|
||||
|
||||
test("hidden focus workspaces keep the focused pane full-size and other panes split", () => {
|
||||
test("focus workspaces never use split geometry so hidden panes keep full size (#3046)", () => {
|
||||
const workspace = createWorkspace("ws-focus", ["f-1", "f-2"], {
|
||||
viewMode: "focus",
|
||||
focusedSessionId: "f-2",
|
||||
@@ -154,12 +154,15 @@ test("hidden focus workspaces keep the focused pane full-size and other panes sp
|
||||
isVisible: false,
|
||||
hibernateHiddenTabs: false,
|
||||
}), false);
|
||||
// A hidden, non-focused pane must not fall back to its split rect: the
|
||||
// continuously rendered xterm and the remote PTY would shrink to a 1/N-width
|
||||
// fragment and \r progress refreshes would wrap into scrollback spam.
|
||||
assert.equal(shouldUseTerminalPaneSplitLayout({
|
||||
workspace,
|
||||
sessionId: "f-1",
|
||||
isVisible: false,
|
||||
hibernateHiddenTabs: false,
|
||||
}), true);
|
||||
}), false);
|
||||
assert.equal(shouldUseTerminalPaneSplitLayout({
|
||||
workspace,
|
||||
sessionId: "f-1",
|
||||
|
||||
@@ -28,9 +28,6 @@ export type TerminalPaneStyle = {
|
||||
|
||||
export function shouldUseTerminalPaneSplitLayout({
|
||||
workspace,
|
||||
sessionId,
|
||||
isVisible,
|
||||
hibernateHiddenTabs,
|
||||
}: {
|
||||
workspace: Workspace | undefined;
|
||||
sessionId: string;
|
||||
@@ -39,14 +36,12 @@ export function shouldUseTerminalPaneSplitLayout({
|
||||
}): boolean {
|
||||
if (!workspace) return false;
|
||||
// Default viewMode is tiled split (including undefined from createWorkspaceFromSessions).
|
||||
// Only focus mode uses a full-size focused pane; other panes keep split geometry while
|
||||
// continuously rendered in the background.
|
||||
if (workspace.viewMode === "focus") {
|
||||
return !isVisible
|
||||
&& !hibernateHiddenTabs
|
||||
&& workspace.focusedSessionId !== sessionId;
|
||||
}
|
||||
return true;
|
||||
// Focus mode never uses split geometry: every pane is viewed full-size, so hidden
|
||||
// panes must keep full-size geometry too. Laying continuously rendered background
|
||||
// panes out at their split rects shrank the live xterm and the remote PTY to a
|
||||
// 1/N-width fragment, so \r-refresh progress output (e.g. rsync --info=progress2)
|
||||
// soft-wrapped and left one scrollback line per refresh (#3046).
|
||||
return workspace.viewMode !== "focus";
|
||||
}
|
||||
|
||||
export function shouldMeasureTerminalLayerLayout({
|
||||
|
||||
@@ -15,31 +15,33 @@
|
||||
<text x="50%" y="30" fill="#e6edf3" font-size="20" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Star History</text>
|
||||
<rect width="178" height="32" x="105" y="90" fill="#161b22" fill-opacity="0.9" stroke="#30363d" stroke-width="1.5" rx="4" ry="4" filter="url(#xkcdify)"/><rect width="8" height="8" x="115" y="102" rx="2" ry="2" fill="#dd4528" filter="url(#xkcdify)"/><text x="131" y="110" fill="#e6edf3" font-size="15" font-family="Handlee, cursive" font-weight="bold">binaricat/Netcatty</text>
|
||||
<text x="85" y="468.0" fill="#e6edf3" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="end">0</text>
|
||||
<line x1="90" y1="386.4" x2="765" y2="386.4" stroke="#21262d" stroke-width="1" filter="url(#xkcdify)"/>
|
||||
<text x="85" y="391.4" fill="#e6edf3" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="end">1K</text>
|
||||
<line x1="90" y1="309.8" x2="765" y2="309.8" stroke="#21262d" stroke-width="1" filter="url(#xkcdify)"/>
|
||||
<text x="85" y="314.79999999999995" fill="#e6edf3" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="end">2K</text>
|
||||
<line x1="90" y1="233.2" x2="765" y2="233.2" stroke="#21262d" stroke-width="1" filter="url(#xkcdify)"/>
|
||||
<text x="85" y="238.20000000000002" fill="#e6edf3" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="end">3K</text>
|
||||
<line x1="90" y1="156.6" x2="765" y2="156.6" stroke="#21262d" stroke-width="1" filter="url(#xkcdify)"/>
|
||||
<text x="85" y="161.59999999999997" fill="#e6edf3" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="end">4K</text>
|
||||
<line x1="90" y1="399.2" x2="765" y2="399.2" stroke="#21262d" stroke-width="1" filter="url(#xkcdify)"/>
|
||||
<text x="85" y="404.1666666666667" fill="#e6edf3" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="end">1K</text>
|
||||
<line x1="90" y1="335.3" x2="765" y2="335.3" stroke="#21262d" stroke-width="1" filter="url(#xkcdify)"/>
|
||||
<text x="85" y="340.33333333333337" fill="#e6edf3" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="end">2K</text>
|
||||
<line x1="90" y1="271.5" x2="765" y2="271.5" stroke="#21262d" stroke-width="1" filter="url(#xkcdify)"/>
|
||||
<text x="85" y="276.5" fill="#e6edf3" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="end">3K</text>
|
||||
<line x1="90" y1="207.7" x2="765" y2="207.7" stroke="#21262d" stroke-width="1" filter="url(#xkcdify)"/>
|
||||
<text x="85" y="212.66666666666669" fill="#e6edf3" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="end">4K</text>
|
||||
<line x1="90" y1="143.8" x2="765" y2="143.8" stroke="#21262d" stroke-width="1" filter="url(#xkcdify)"/>
|
||||
<text x="85" y="148.83333333333331" fill="#e6edf3" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="end">5K</text>
|
||||
<line x1="90" y1="80.0" x2="765" y2="80.0" stroke="#21262d" stroke-width="1" filter="url(#xkcdify)"/>
|
||||
<text x="85" y="85.0" fill="#e6edf3" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="end">5K</text>
|
||||
<text x="85" y="85.0" fill="#e6edf3" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="end">6K</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="182.3" y="488" fill="#e6edf3" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Feb 2026</text>
|
||||
<text x="271.5" y="488" fill="#e6edf3" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Mar 2026</text>
|
||||
<text x="370.2" y="488" fill="#e6edf3" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Apr 2026</text>
|
||||
<text x="465.7" y="488" fill="#e6edf3" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">May 2026</text>
|
||||
<text x="564.4" y="488" fill="#e6edf3" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Jun 2026</text>
|
||||
<text x="659.9" y="488" fill="#e6edf3" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Jul 2026</text>
|
||||
<text x="758.6" y="488" fill="#e6edf3" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Aug 2026</text>
|
||||
<text x="177.0" y="488" fill="#e6edf3" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Feb 2026</text>
|
||||
<text x="261.0" y="488" fill="#e6edf3" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Mar 2026</text>
|
||||
<text x="354.0" y="488" fill="#e6edf3" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Apr 2026</text>
|
||||
<text x="444.0" y="488" fill="#e6edf3" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">May 2026</text>
|
||||
<text x="537.0" y="488" fill="#e6edf3" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Jun 2026</text>
|
||||
<text x="627.0" y="488" fill="#e6edf3" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Jul 2026</text>
|
||||
<text x="720.0" y="488" fill="#e6edf3" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Aug 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,461.9 C 90.5,461.7 92.7,461.3 93.2,461.2 C 94.2,461.0 95.3,460.8 96.4,460.5 C 97.4,460.3 98.5,460.0 99.6,459.9 C 100.6,459.7 101.7,459.8 102.7,459.8 C 103.8,459.7 104.9,459.7 105.9,459.6 C 107.0,459.6 108.0,459.5 109.1,459.4 C 110.2,459.3 110.7,459.2 112.3,459.2 C 113.9,459.1 117.1,459.0 118.7,458.9 C 120.2,458.9 120.8,458.8 121.8,458.8 C 122.9,458.7 124.0,458.7 125.0,458.7 C 126.1,458.7 127.1,458.7 128.2,458.6 C 129.3,458.6 130.3,458.6 131.4,458.6 C 132.5,458.5 133.5,458.4 134.6,458.3 C 135.6,458.2 136.7,458.3 137.8,458.0 C 138.8,457.8 139.9,457.4 140.9,456.9 C 142.0,456.5 143.1,455.4 144.1,455.0 C 145.2,454.7 146.2,454.9 147.3,454.8 C 148.4,454.7 149.4,454.7 150.5,454.7 C 151.6,454.6 152.1,454.5 153.7,454.5 C 155.3,454.5 157.9,454.5 160.0,454.4 C 162.2,454.4 164.8,454.3 166.4,454.3 C 168.0,454.2 168.5,454.2 169.6,454.1 C 170.7,454.1 171.7,454.0 172.8,454.0 C 173.8,453.9 174.9,453.8 176.0,453.8 C 177.0,453.8 178.1,453.8 179.2,453.7 C 180.2,453.7 180.7,453.7 182.3,453.7 C 183.9,453.6 186.0,453.5 188.7,453.5 C 191.4,453.5 195.6,453.5 198.3,453.4 C 200.9,453.4 201.4,453.4 204.6,453.3 C 207.8,453.3 214.7,453.1 217.4,453.0 C 220.0,453.0 219.0,453.0 220.5,453.0 C 222.1,452.9 224.3,452.9 226.9,452.9 C 229.6,452.9 231.2,452.8 236.5,452.7 C 241.8,452.7 254.5,452.6 258.8,452.5 C 263.0,452.5 260.9,452.5 261.9,452.4 C 263.0,452.4 263.5,453.3 265.1,452.4 C 266.7,451.4 269.9,450.6 271.5,446.5 C 273.1,442.5 273.6,431.6 274.7,428.2 C 275.7,424.8 276.8,426.8 277.9,426.2 C 278.9,425.7 280.0,425.4 281.0,425.0 C 282.1,424.7 283.2,424.4 284.2,424.2 C 285.3,423.9 286.3,423.9 287.4,423.7 C 288.5,423.6 289.5,423.3 290.6,423.2 C 291.7,423.2 292.7,423.3 293.8,423.2 C 294.8,423.1 295.9,422.8 297.0,422.6 C 298.0,422.5 299.1,422.6 300.1,422.4 C 301.2,422.2 302.3,421.7 303.3,421.6 C 304.4,421.4 305.4,421.5 306.5,421.3 C 307.6,421.2 308.6,420.9 309.7,420.8 C 310.8,420.7 311.8,420.8 312.9,420.7 C 313.9,420.7 315.0,420.6 316.1,420.5 C 317.1,420.4 318.2,420.3 319.2,420.3 C 320.3,420.2 321.4,420.2 322.4,420.0 C 323.5,419.9 324.6,419.5 325.6,419.3 C 326.7,419.1 327.7,420.1 328.8,418.8 C 329.9,417.5 330.9,413.1 332.0,411.6 C 333.0,410.1 334.1,410.3 335.2,410.1 C 336.2,409.8 337.3,410.2 338.3,410.0 C 339.4,409.8 340.5,409.3 341.5,408.9 C 342.6,408.5 343.7,408.2 344.7,407.5 C 345.8,406.7 346.8,405.4 347.9,404.6 C 349.0,403.7 350.0,402.9 351.1,402.3 C 352.1,401.6 353.2,401.2 354.3,400.8 C 355.3,400.4 356.4,400.3 357.5,400.1 C 358.5,399.9 359.6,400.0 360.6,399.8 C 361.7,399.7 362.8,399.3 363.8,399.2 C 364.9,399.1 365.9,399.1 367.0,399.0 C 368.1,399.0 369.1,398.9 370.2,398.7 C 371.2,398.6 372.3,398.6 373.4,398.0 C 374.4,397.5 375.5,396.0 376.6,395.3 C 377.6,394.6 378.7,394.4 379.7,393.8 C 380.8,393.3 381.9,394.4 382.9,392.1 C 384.0,389.9 385.0,383.2 386.1,380.6 C 387.2,378.0 388.2,377.6 389.3,376.4 C 390.4,375.3 391.4,374.7 392.5,373.9 C 393.5,373.1 394.6,372.4 395.7,371.8 C 396.7,371.2 397.8,370.7 398.8,370.3 C 399.9,369.9 401.0,369.9 402.0,369.5 C 403.1,369.2 404.2,368.9 405.2,368.5 C 406.3,368.1 407.3,367.4 408.4,367.0 C 409.5,366.7 410.5,366.7 411.6,366.3 C 412.6,366.0 413.7,365.3 414.8,364.9 C 415.8,364.4 416.9,364.1 417.9,363.7 C 419.0,363.4 420.1,363.1 421.1,362.9 C 422.2,362.7 423.3,362.6 424.3,362.4 C 425.4,362.2 426.4,361.9 427.5,361.7 C 428.6,361.4 429.6,361.2 430.7,360.9 C 431.7,360.6 432.8,360.3 433.9,359.9 C 434.9,359.4 436.0,358.6 437.1,358.2 C 438.1,357.8 439.2,357.7 440.2,357.4 C 441.3,357.2 442.4,357.1 443.4,356.9 C 444.5,356.7 445.5,356.6 446.6,356.4 C 447.7,356.3 448.7,356.1 449.8,356.0 C 450.8,355.9 451.9,356.0 453.0,355.8 C 454.0,355.6 455.1,355.2 456.2,354.8 C 457.2,354.4 458.3,353.8 459.3,353.3 C 460.4,352.8 461.5,352.3 462.5,352.0 C 463.6,351.7 464.6,351.7 465.7,351.5 C 466.8,351.3 467.8,351.2 468.9,350.9 C 470.0,350.5 471.0,349.8 472.1,349.5 C 473.1,349.2 474.2,349.3 475.3,349.1 C 476.3,348.9 477.4,348.5 478.4,348.3 C 479.5,348.0 480.6,347.8 481.6,347.5 C 482.7,347.2 483.8,346.9 484.8,346.6 C 485.9,346.4 486.9,346.3 488.0,346.0 C 489.1,345.6 490.1,345.1 491.2,344.8 C 492.2,344.5 493.3,344.3 494.4,344.0 C 495.4,343.6 496.5,343.1 497.5,342.7 C 498.6,342.3 499.7,341.9 500.7,341.6 C 501.8,341.2 502.9,341.2 503.9,340.6 C 505.0,340.0 506.0,338.7 507.1,338.2 C 508.2,337.7 509.2,337.6 510.3,337.5 C 511.3,337.3 512.4,337.3 513.5,337.1 C 514.5,337.0 515.6,336.8 516.7,336.6 C 517.7,336.5 518.8,336.7 519.8,336.2 C 520.9,335.7 522.0,334.1 523.0,333.5 C 524.1,332.9 525.1,333.0 526.2,332.6 C 527.3,332.3 528.3,331.6 529.4,331.3 C 530.4,331.0 531.5,331.2 532.6,330.9 C 533.6,330.6 534.7,329.9 535.8,329.6 C 536.8,329.3 537.9,329.3 538.9,329.2 C 540.0,329.0 541.1,329.0 542.1,328.7 C 543.2,328.4 544.2,328.0 545.3,327.3 C 546.4,326.5 547.4,325.1 548.5,324.4 C 549.6,323.6 550.6,323.3 551.7,322.7 C 552.7,322.2 553.8,322.1 554.9,321.1 C 555.9,320.0 557.0,317.7 558.0,316.5 C 559.1,315.4 560.2,314.9 561.2,314.2 C 562.3,313.5 563.3,313.4 564.4,312.3 C 565.5,311.3 566.5,309.0 567.6,308.0 C 568.7,307.0 569.7,306.9 570.8,306.3 C 571.Line truncated
|
||||
<path d="M 90.0,461.9 C 90.5,461.7 92.7,461.3 93.2,461.2 C 94.2,461.0 95.3,460.8 96.4,460.5 C 97.4,460.3 98.5,460.0 99.6,459.9 C 100.6,459.7 101.7,459.8 102.7,459.8 C 103.8,459.7 104.9,459.7 105.9,459.6 C 107.0,459.6 108.0,459.5 109.1,459.4 C 110.2,459.3 110.7,459.2 112.3,459.2 C 113.9,459.1 117.1,459.0 118.7,458.9 C 120.2,458.9 120.8,458.8 121.8,458.8 C 122.9,458.7 124.0,458.7 125.0,458.7 C 126.1,458.7 127.1,458.7 128.2,458.6 C 129.3,458.6 130.3,458.6 131.4,458.6 C 132.5,458.5 133.5,458.4 134.6,458.3 C 135.6,458.2 136.7,458.3 137.8,458.0 C 138.8,457.8 139.9,457.4 140.9,456.9 C 142.0,456.5 143.1,455.4 144.1,455.0 C 145.2,454.7 146.2,454.9 147.3,454.8 C 148.4,454.7 149.4,454.7 150.5,454.7 C 151.6,454.6 152.1,454.5 153.7,454.5 C 155.3,454.5 157.9,454.5 160.0,454.4 C 162.2,454.4 164.8,454.3 166.4,454.3 C 168.0,454.2 168.5,454.2 169.6,454.1 C 170.7,454.1 171.7,454.0 172.8,454.0 C 173.8,453.9 174.9,453.8 176.0,453.8 C 177.0,453.8 178.1,453.8 179.2,453.7 C 180.2,453.7 180.7,453.7 182.3,453.7 C 183.9,453.6 186.0,453.5 188.7,453.5 C 191.4,453.5 195.6,453.5 198.3,453.4 C 200.9,453.4 201.4,453.4 204.6,453.3 C 207.8,453.3 214.7,453.1 217.4,453.0 C 220.0,453.0 219.0,453.0 220.5,453.0 C 222.1,452.9 224.3,452.9 226.9,452.9 C 229.6,452.9 231.2,452.8 236.5,452.7 C 241.8,452.7 254.5,452.6 258.8,452.5 C 263.0,452.5 260.9,452.5 261.9,452.4 C 263.0,452.4 263.5,453.3 265.1,452.4 C 266.7,451.4 269.9,450.6 271.5,446.5 C 273.1,442.5 273.6,431.6 274.7,428.2 C 275.7,424.8 276.8,426.8 277.9,426.2 C 278.9,425.7 280.0,425.4 281.0,425.0 C 282.1,424.7 283.2,424.4 284.2,424.2 C 285.3,423.9 286.3,423.9 287.4,423.7 C 288.5,423.6 289.5,423.3 290.6,423.2 C 291.7,423.2 292.7,423.3 293.8,423.2 C 294.8,423.1 295.9,422.8 297.0,422.6 C 298.0,422.5 299.1,422.6 300.1,422.4 C 301.2,422.2 302.3,421.7 303.3,421.6 C 304.4,421.4 305.4,421.5 306.5,421.3 C 307.6,421.2 308.6,420.9 309.7,420.8 C 310.8,420.7 311.8,420.8 312.9,420.7 C 313.9,420.7 315.0,420.6 316.1,420.5 C 317.1,420.4 318.2,420.3 319.2,420.3 C 320.3,420.2 321.4,420.2 322.4,420.0 C 323.5,419.9 324.6,419.5 325.6,419.3 C 326.7,419.1 327.7,420.1 328.8,418.8 C 329.9,417.5 330.9,413.1 332.0,411.6 C 333.0,410.1 334.1,410.3 335.2,410.1 C 336.2,409.8 337.3,410.2 338.3,410.0 C 339.4,409.8 340.5,409.3 341.5,408.9 C 342.6,408.5 343.7,408.2 344.7,407.5 C 345.8,406.7 346.8,405.4 347.9,404.6 C 349.0,403.7 350.0,402.9 351.1,402.3 C 352.1,401.6 353.2,401.2 354.3,400.8 C 355.3,400.4 356.4,400.3 357.5,400.1 C 358.5,399.9 359.6,400.0 360.6,399.8 C 361.7,399.7 362.8,399.3 363.8,399.2 C 364.9,399.1 365.9,399.1 367.0,399.0 C 368.1,399.0 369.1,398.9 370.2,398.7 C 371.2,398.6 372.3,398.6 373.4,398.0 C 374.4,397.5 375.5,396.0 376.6,395.3 C 377.6,394.6 378.7,394.4 379.7,393.8 C 380.8,393.3 381.9,394.4 382.9,392.1 C 384.0,389.9 385.0,383.2 386.1,380.6 C 387.2,378.0 388.2,377.6 389.3,376.4 C 390.4,375.3 391.4,374.7 392.5,373.9 C 393.5,373.1 394.6,372.4 395.7,371.8 C 396.7,371.2 397.8,370.7 398.8,370.3 C 399.9,369.9 401.0,369.9 402.0,369.5 C 403.1,369.2 404.2,368.9 405.2,368.5 C 406.3,368.1 407.3,367.4 408.4,367.0 C 409.5,366.7 410.5,366.7 411.6,366.3 C 412.6,366.0 413.7,365.3 414.8,364.9 C 415.8,364.4 416.9,364.1 417.9,363.7 C 419.0,363.4 420.1,363.1 421.1,362.9 C 422.2,362.7 423.3,362.6 424.3,362.4 C 425.4,362.2 426.4,361.9 427.5,361.7 C 428.6,361.4 429.6,361.2 430.7,360.9 C 431.7,360.6 432.8,360.3 433.9,359.9 C 434.9,359.4 436.0,358.6 437.1,358.2 C 438.1,357.8 439.2,357.7 440.2,357.4 C 441.3,357.2 442.4,357.1 443.4,356.9 C 444.5,356.7 445.5,356.6 446.6,356.4 C 447.7,356.3 448.7,356.1 449.8,356.0 C 450.8,355.9 451.9,356.0 453.0,355.8 C 454.0,355.6 455.1,355.2 456.2,354.8 C 457.2,354.4 458.3,353.8 459.3,353.3 C 460.4,352.8 461.5,352.3 462.5,352.0 C 463.6,351.7 464.6,351.7 465.7,351.5 C 466.8,351.3 467.8,351.2 468.9,350.9 C 470.0,350.5 471.0,349.8 472.1,349.5 C 473.1,349.2 474.2,349.3 475.3,349.1 C 476.3,348.9 477.4,348.5 478.4,348.3 C 479.5,348.0 480.6,347.8 481.6,347.5 C 482.7,347.2 483.8,346.9 484.8,346.6 C 485.9,346.4 486.9,346.3 488.0,346.0 C 489.1,345.6 490.1,345.1 491.2,344.8 C 492.2,344.5 493.3,344.3 494.4,344.0 C 495.4,343.6 496.5,343.1 497.5,342.7 C 498.6,342.3 499.7,341.9 500.7,341.6 C 501.8,341.2 502.9,341.2 503.9,340.6 C 505.0,340.0 506.0,338.7 507.1,338.2 C 508.2,337.7 509.2,337.6 510.3,337.5 C 511.3,337.3 512.4,337.3 513.5,337.1 C 514.5,337.0 515.6,336.8 516.7,336.6 C 517.7,336.5 518.8,336.7 519.8,336.2 C 520.9,335.7 522.0,334.1 523.0,333.5 C 524.1,332.9 525.1,333.0 526.2,332.6 C 527.3,332.3 528.3,331.6 529.4,331.3 C 530.4,331.0 531.5,331.2 532.6,330.9 C 533.6,330.6 534.7,329.9 535.8,329.6 C 536.8,329.3 537.9,329.3 538.9,329.2 C 540.0,329.0 541.1,329.0 542.1,328.7 C 543.2,328.4 544.2,328.0 545.3,327.3 C 546.4,326.5 547.4,325.1 548.5,324.4 C 549.6,323.6 550.6,323.3 551.7,322.7 C 552.7,322.2 553.8,322.1 554.9,321.1 C 555.9,320.0 557.0,317.7 558.0,316.5 C 559.1,315.4 560.2,314.9 561.2,314.2 C 562.3,313.5 563.3,313.4 564.4,312.3 C 565.5,311.3 566.5,309.0 567.6,308.0 C 568.7,307.0 569.7,306.9 570.8,306.3 C 571.Line truncated
|
||||
<circle cx="765.0" cy="116.9" 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">4518</text>
|
||||
<path d="M 90.0,462.0 C 90.5,462.0 92.5,461.6 93.0,461.5 C 94.0,461.4 95.0,461.1 96.0,461.0 C 97.0,460.8 98.0,460.5 99.0,460.4 C 100.0,460.3 101.0,460.4 102.0,460.3 C 103.0,460.3 104.0,460.2 105.0,460.2 C 106.0,460.1 107.0,460.1 108.0,460.0 C 109.0,459.9 109.5,459.9 111.0,459.8 C 112.5,459.7 115.5,459.7 117.0,459.6 C 118.5,459.6 119.0,459.5 120.0,459.5 C 121.0,459.5 122.0,459.4 123.0,459.4 C 124.0,459.4 125.0,459.4 126.0,459.4 C 127.0,459.3 128.0,459.3 129.0,459.3 C 130.0,459.3 131.0,459.2 132.0,459.1 C 133.0,459.0 134.0,459.0 135.0,458.9 C 136.0,458.7 137.0,458.4 138.0,458.0 C 139.0,457.5 140.0,456.7 141.0,456.4 C 142.0,456.1 143.0,456.2 144.0,456.2 C 145.0,456.1 146.0,456.1 147.0,456.0 C 148.0,456.0 148.5,455.9 150.0,455.9 C 151.5,455.9 154.0,455.9 156.0,455.9 C 158.0,455.8 160.5,455.8 162.0,455.7 C 163.5,455.7 164.0,455.6 165.0,455.6 C 166.0,455.6 167.0,455.5 168.0,455.5 C 169.0,455.4 170.0,455.4 171.0,455.3 C 172.0,455.3 173.0,455.3 174.0,455.3 C 175.0,455.3 175.5,455.2 177.0,455.2 C 178.5,455.2 180.5,455.1 183.0,455.1 C 185.5,455.1 189.5,455.0 192.0,455.0 C 194.5,455.0 195.0,455.0 198.0,455.0 C 201.0,454.9 207.5,454.8 210.0,454.7 C 212.5,454.6 211.5,454.7 213.0,454.6 C 214.5,454.6 216.5,454.6 219.0,454.6 C 221.5,454.5 223.0,454.5 228.0,454.4 C 233.0,454.4 245.0,454.3 249.0,454.3 C 253.0,454.2 251.0,454.2 252.0,454.2 C 253.0,454.2 253.5,454.9 255.0,454.1 C 256.5,453.3 259.5,452.6 261.0,449.3 C 262.5,445.9 263.0,436.9 264.0,434.1 C 265.0,431.3 266.0,432.9 267.0,432.4 C 268.0,432.0 269.0,431.7 270.0,431.4 C 271.0,431.1 272.0,430.9 273.0,430.7 C 274.0,430.5 275.0,430.4 276.0,430.3 C 277.0,430.2 278.0,430.0 279.0,429.9 C 280.0,429.9 281.0,430.0 282.0,429.9 C 283.0,429.8 284.0,429.5 285.0,429.4 C 286.0,429.3 287.0,429.4 288.0,429.2 C 289.0,429.1 290.0,428.7 291.0,428.5 C 292.0,428.4 293.0,428.4 294.0,428.3 C 295.0,428.2 296.0,428.0 297.0,427.9 C 298.0,427.8 299.0,427.9 300.0,427.8 C 301.0,427.8 302.0,427.7 303.0,427.6 C 304.0,427.6 305.0,427.5 306.0,427.4 C 307.0,427.4 308.0,427.4 309.0,427.3 C 310.0,427.1 311.0,426.8 312.0,426.7 C 313.0,426.5 314.0,427.3 315.0,426.2 C 316.0,425.2 317.0,421.4 318.0,420.2 C 319.0,419.0 320.0,419.2 321.0,419.0 C 322.0,418.7 323.0,419.1 324.0,418.9 C 325.0,418.7 326.0,418.3 327.0,418.0 C 328.0,417.7 329.0,417.4 330.0,416.8 C 331.0,416.3 332.0,415.1 333.0,414.4 C 334.0,413.7 335.0,413.0 336.0,412.5 C 337.0,412.0 338.0,411.6 339.0,411.3 C 340.0,411.0 341.0,410.9 342.0,410.7 C 343.0,410.6 344.0,410.6 345.0,410.5 C 346.0,410.3 347.0,410.1 348.0,410.0 C 349.0,409.8 350.0,409.9 351.0,409.8 C 352.0,409.8 353.0,409.7 354.0,409.6 C 355.0,409.4 356.0,409.5 357.0,409.0 C 358.0,408.5 359.0,407.3 360.0,406.7 C 361.0,406.1 362.0,405.9 363.0,405.5 C 364.0,405.0 365.0,405.9 366.0,404.1 C 367.0,402.3 368.0,396.7 369.0,394.5 C 370.0,392.3 371.0,392.0 372.0,391.1 C 373.0,390.1 374.0,389.6 375.0,389.0 C 376.0,388.3 377.0,387.7 378.0,387.2 C 379.0,386.7 380.0,386.3 381.0,386.0 C 382.0,385.6 383.0,385.6 384.0,385.3 C 385.0,385.1 386.0,384.8 387.0,384.4 C 388.0,384.1 389.0,383.5 390.0,383.2 C 391.0,382.9 392.0,382.9 393.0,382.6 C 394.0,382.3 395.0,381.8 396.0,381.4 C 397.0,381.1 398.0,380.7 399.0,380.5 C 400.0,380.2 401.0,379.9 402.0,379.8 C 403.0,379.6 404.0,379.5 405.0,379.4 C 406.0,379.2 407.0,379.0 408.0,378.7 C 409.0,378.5 410.0,378.3 411.0,378.1 C 412.0,377.9 413.0,377.6 414.0,377.3 C 415.0,376.9 416.0,376.2 417.0,375.9 C 418.0,375.5 419.0,375.4 420.0,375.2 C 421.0,375.0 422.0,374.9 423.0,374.8 C 424.0,374.6 425.0,374.5 426.0,374.4 C 427.0,374.3 428.0,374.1 429.0,374.0 C 430.0,373.9 431.0,374.0 432.0,373.8 C 433.0,373.7 434.0,373.4 435.0,373.1 C 436.0,372.7 437.0,372.2 438.0,371.8 C 439.0,371.4 440.0,371.0 441.0,370.7 C 442.0,370.4 443.0,370.4 444.0,370.3 C 445.0,370.1 446.0,370.0 447.0,369.7 C 448.0,369.5 449.0,368.8 450.0,368.6 C 451.0,368.3 452.0,368.4 453.0,368.3 C 454.0,368.1 455.0,367.8 456.0,367.6 C 457.0,367.3 458.0,367.2 459.0,366.9 C 460.0,366.7 461.0,366.4 462.0,366.2 C 463.0,366.0 464.0,365.9 465.0,365.7 C 466.0,365.4 467.0,365.0 468.0,364.7 C 469.0,364.4 470.0,364.3 471.0,364.0 C 472.0,363.7 473.0,363.4 474.0,363.0 C 475.0,362.7 476.0,362.4 477.0,362.1 C 478.0,361.8 479.0,361.7 480.0,361.2 C 481.0,360.8 482.0,359.7 483.0,359.3 C 484.0,358.8 485.0,358.8 486.0,358.6 C 487.0,358.5 488.0,358.5 489.0,358.4 C 490.0,358.3 491.0,358.1 492.0,357.9 C 493.0,357.8 494.0,358.0 495.0,357.6 C 496.0,357.2 497.0,355.9 498.0,355.4 C 499.0,354.9 500.0,355.0 501.0,354.7 C 502.0,354.4 503.0,353.8 504.0,353.6 C 505.0,353.4 506.0,353.5 507.0,353.3 C 508.0,353.0 509.0,352.4 510.0,352.2 C 511.0,352.0 512.0,352.0 513.0,351.9 C 514.0,351.7 515.0,351.7 516.0,351.5 C 517.0,351.2 518.0,350.9 519.0,350.3 C 520.0,349.7 521.0,348.5 522.0,347.8 C 523.0,347.2 524.0,347.0 525.0,346.5 C 526.0,346.0 527.0,346.0 528.0,345.1 C 529.0,344.2 530.0,342.3 531.0,341.3 C 532.0,340.4 533.0,340.0 534.0,339.4 C 535.0,338.8 536.0,338.7 537.0,337.9 C 538.0,337.0 539.0,335.2 540.0,334.3 C 541.0,333.5 542.0,333.3 543.0,332.8 C 544.Line truncated
|
||||
<path d="M 90.0,462.0 C 90.5,462.0 92.5,461.6 93.0,461.5 C 94.0,461.4 95.0,461.1 96.0,461.0 C 97.0,460.8 98.0,460.5 99.0,460.4 C 100.0,460.3 101.0,460.4 102.0,460.3 C 103.0,460.3 104.0,460.2 105.0,460.2 C 106.0,460.1 107.0,460.1 108.0,460.0 C 109.0,459.9 109.5,459.9 111.0,459.8 C 112.5,459.7 115.5,459.7 117.0,459.6 C 118.5,459.6 119.0,459.5 120.0,459.5 C 121.0,459.5 122.0,459.4 123.0,459.4 C 124.0,459.4 125.0,459.4 126.0,459.4 C 127.0,459.3 128.0,459.3 129.0,459.3 C 130.0,459.3 131.0,459.2 132.0,459.1 C 133.0,459.0 134.0,459.0 135.0,458.9 C 136.0,458.7 137.0,458.4 138.0,458.0 C 139.0,457.5 140.0,456.7 141.0,456.4 C 142.0,456.1 143.0,456.2 144.0,456.2 C 145.0,456.1 146.0,456.1 147.0,456.0 C 148.0,456.0 148.5,455.9 150.0,455.9 C 151.5,455.9 154.0,455.9 156.0,455.9 C 158.0,455.8 160.5,455.8 162.0,455.7 C 163.5,455.7 164.0,455.6 165.0,455.6 C 166.0,455.6 167.0,455.5 168.0,455.5 C 169.0,455.4 170.0,455.4 171.0,455.3 C 172.0,455.3 173.0,455.3 174.0,455.3 C 175.0,455.3 175.5,455.2 177.0,455.2 C 178.5,455.2 180.5,455.1 183.0,455.1 C 185.5,455.1 189.5,455.0 192.0,455.0 C 194.5,455.0 195.0,455.0 198.0,455.0 C 201.0,454.9 207.5,454.8 210.0,454.7 C 212.5,454.6 211.5,454.7 213.0,454.6 C 214.5,454.6 216.5,454.6 219.0,454.6 C 221.5,454.5 223.0,454.5 228.0,454.4 C 233.0,454.4 245.0,454.3 249.0,454.3 C 253.0,454.2 251.0,454.2 252.0,454.2 C 253.0,454.2 253.5,454.9 255.0,454.1 C 256.5,453.3 259.5,452.6 261.0,449.3 C 262.5,445.9 263.0,436.9 264.0,434.1 C 265.0,431.3 266.0,432.9 267.0,432.4 C 268.0,432.0 269.0,431.7 270.0,431.4 C 271.0,431.1 272.0,430.9 273.0,430.7 C 274.0,430.5 275.0,430.4 276.0,430.3 C 277.0,430.2 278.0,430.0 279.0,429.9 C 280.0,429.9 281.0,430.0 282.0,429.9 C 283.0,429.8 284.0,429.5 285.0,429.4 C 286.0,429.3 287.0,429.4 288.0,429.2 C 289.0,429.1 290.0,428.7 291.0,428.5 C 292.0,428.4 293.0,428.4 294.0,428.3 C 295.0,428.2 296.0,428.0 297.0,427.9 C 298.0,427.8 299.0,427.9 300.0,427.8 C 301.0,427.8 302.0,427.7 303.0,427.6 C 304.0,427.6 305.0,427.5 306.0,427.4 C 307.0,427.4 308.0,427.4 309.0,427.3 C 310.0,427.1 311.0,426.8 312.0,426.7 C 313.0,426.5 314.0,427.3 315.0,426.2 C 316.0,425.2 317.0,421.4 318.0,420.2 C 319.0,419.0 320.0,419.2 321.0,419.0 C 322.0,418.7 323.0,419.1 324.0,418.9 C 325.0,418.7 326.0,418.3 327.0,418.0 C 328.0,417.7 329.0,417.4 330.0,416.8 C 331.0,416.3 332.0,415.1 333.0,414.4 C 334.0,413.7 335.0,413.0 336.0,412.5 C 337.0,412.0 338.0,411.6 339.0,411.3 C 340.0,411.0 341.0,410.9 342.0,410.7 C 343.0,410.6 344.0,410.6 345.0,410.5 C 346.0,410.3 347.0,410.1 348.0,410.0 C 349.0,409.8 350.0,409.9 351.0,409.8 C 352.0,409.8 353.0,409.7 354.0,409.6 C 355.0,409.4 356.0,409.5 357.0,409.0 C 358.0,408.5 359.0,407.3 360.0,406.7 C 361.0,406.1 362.0,405.9 363.0,405.5 C 364.0,405.0 365.0,405.9 366.0,404.1 C 367.0,402.3 368.0,396.7 369.0,394.5 C 370.0,392.3 371.0,392.0 372.0,391.1 C 373.0,390.1 374.0,389.6 375.0,389.0 C 376.0,388.3 377.0,387.7 378.0,387.2 C 379.0,386.7 380.0,386.3 381.0,386.0 C 382.0,385.6 383.0,385.6 384.0,385.3 C 385.0,385.1 386.0,384.8 387.0,384.4 C 388.0,384.1 389.0,383.5 390.0,383.2 C 391.0,382.9 392.0,382.9 393.0,382.6 C 394.0,382.3 395.0,381.8 396.0,381.4 C 397.0,381.1 398.0,380.7 399.0,380.5 C 400.0,380.2 401.0,379.9 402.0,379.8 C 403.0,379.6 404.0,379.5 405.0,379.4 C 406.0,379.2 407.0,379.0 408.0,378.7 C 409.0,378.5 410.0,378.3 411.0,378.1 C 412.0,377.9 413.0,377.6 414.0,377.3 C 415.0,376.9 416.0,376.2 417.0,375.9 C 418.0,375.5 419.0,375.4 420.0,375.2 C 421.0,375.0 422.0,374.9 423.0,374.8 C 424.0,374.6 425.0,374.5 426.0,374.4 C 427.0,374.3 428.0,374.1 429.0,374.0 C 430.0,373.9 431.0,374.0 432.0,373.8 C 433.0,373.7 434.0,373.4 435.0,373.1 C 436.0,372.7 437.0,372.2 438.0,371.8 C 439.0,371.4 440.0,371.0 441.0,370.7 C 442.0,370.4 443.0,370.4 444.0,370.3 C 445.0,370.1 446.0,370.0 447.0,369.7 C 448.0,369.5 449.0,368.8 450.0,368.6 C 451.0,368.3 452.0,368.4 453.0,368.3 C 454.0,368.1 455.0,367.8 456.0,367.6 C 457.0,367.3 458.0,367.2 459.0,366.9 C 460.0,366.7 461.0,366.4 462.0,366.2 C 463.0,366.0 464.0,365.9 465.0,365.7 C 466.0,365.4 467.0,365.0 468.0,364.7 C 469.0,364.4 470.0,364.3 471.0,364.0 C 472.0,363.7 473.0,363.4 474.0,363.0 C 475.0,362.7 476.0,362.4 477.0,362.1 C 478.0,361.8 479.0,361.7 480.0,361.2 C 481.0,360.8 482.0,359.7 483.0,359.3 C 484.0,358.8 485.0,358.8 486.0,358.6 C 487.0,358.5 488.0,358.5 489.0,358.4 C 490.0,358.3 491.0,358.1 492.0,357.9 C 493.0,357.8 494.0,358.0 495.0,357.6 C 496.0,357.2 497.0,355.9 498.0,355.4 C 499.0,354.9 500.0,355.0 501.0,354.7 C 502.0,354.4 503.0,353.8 504.0,353.6 C 505.0,353.4 506.0,353.5 507.0,353.3 C 508.0,353.0 509.0,352.4 510.0,352.2 C 511.0,352.0 512.0,352.0 513.0,351.9 C 514.0,351.7 515.0,351.7 516.0,351.5 C 517.0,351.2 518.0,350.9 519.0,350.3 C 520.0,349.7 521.0,348.5 522.0,347.8 C 523.0,347.2 524.0,347.0 525.0,346.5 C 526.0,346.0 527.0,346.0 528.0,345.1 C 529.0,344.2 530.0,342.3 531.0,341.3 C 532.0,340.4 533.0,340.0 534.0,339.4 C 535.0,338.8 536.0,338.7 537.0,337.9 C 538.0,337.0 539.0,335.2 540.0,334.3 C 541.0,333.5 542.0,333.3 543.0,332.8 C 544.Line truncated
|
||||
<circle cx="765.0" cy="134.6" 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">5144</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: 30 KiB After Width: | Height: | Size: 31 KiB |
@@ -15,31 +15,33 @@
|
||||
<text x="50%" y="30" fill="#000" font-size="20" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Star History</text>
|
||||
<rect width="178" height="32" x="105" y="90" fill="#fff" fill-opacity="0.9" stroke="#000" stroke-width="1.5" rx="4" ry="4" filter="url(#xkcdify)"/><rect width="8" height="8" x="115" y="102" rx="2" ry="2" fill="#dd4528" filter="url(#xkcdify)"/><text x="131" y="110" fill="#000" font-size="15" font-family="Handlee, cursive" font-weight="bold">binaricat/Netcatty</text>
|
||||
<text x="85" y="468.0" fill="#000" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="end">0</text>
|
||||
<line x1="90" y1="386.4" x2="765" y2="386.4" stroke="#eee" stroke-width="1" filter="url(#xkcdify)"/>
|
||||
<text x="85" y="391.4" fill="#000" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="end">1K</text>
|
||||
<line x1="90" y1="309.8" x2="765" y2="309.8" stroke="#eee" stroke-width="1" filter="url(#xkcdify)"/>
|
||||
<text x="85" y="314.79999999999995" fill="#000" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="end">2K</text>
|
||||
<line x1="90" y1="233.2" x2="765" y2="233.2" stroke="#eee" stroke-width="1" filter="url(#xkcdify)"/>
|
||||
<text x="85" y="238.20000000000002" fill="#000" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="end">3K</text>
|
||||
<line x1="90" y1="156.6" x2="765" y2="156.6" stroke="#eee" stroke-width="1" filter="url(#xkcdify)"/>
|
||||
<text x="85" y="161.59999999999997" fill="#000" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="end">4K</text>
|
||||
<line x1="90" y1="399.2" x2="765" y2="399.2" stroke="#eee" stroke-width="1" filter="url(#xkcdify)"/>
|
||||
<text x="85" y="404.1666666666667" fill="#000" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="end">1K</text>
|
||||
<line x1="90" y1="335.3" x2="765" y2="335.3" stroke="#eee" stroke-width="1" filter="url(#xkcdify)"/>
|
||||
<text x="85" y="340.33333333333337" fill="#000" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="end">2K</text>
|
||||
<line x1="90" y1="271.5" x2="765" y2="271.5" stroke="#eee" stroke-width="1" filter="url(#xkcdify)"/>
|
||||
<text x="85" y="276.5" fill="#000" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="end">3K</text>
|
||||
<line x1="90" y1="207.7" x2="765" y2="207.7" stroke="#eee" stroke-width="1" filter="url(#xkcdify)"/>
|
||||
<text x="85" y="212.66666666666669" fill="#000" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="end">4K</text>
|
||||
<line x1="90" y1="143.8" x2="765" y2="143.8" stroke="#eee" stroke-width="1" filter="url(#xkcdify)"/>
|
||||
<text x="85" y="148.83333333333331" fill="#000" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="end">5K</text>
|
||||
<line x1="90" y1="80.0" x2="765" y2="80.0" stroke="#eee" stroke-width="1" filter="url(#xkcdify)"/>
|
||||
<text x="85" y="85.0" fill="#000" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="end">5K</text>
|
||||
<text x="85" y="85.0" fill="#000" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="end">6K</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="182.3" y="488" fill="#000" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Feb 2026</text>
|
||||
<text x="271.5" y="488" fill="#000" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Mar 2026</text>
|
||||
<text x="370.2" y="488" fill="#000" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Apr 2026</text>
|
||||
<text x="465.7" y="488" fill="#000" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">May 2026</text>
|
||||
<text x="564.4" y="488" fill="#000" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Jun 2026</text>
|
||||
<text x="659.9" y="488" fill="#000" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Jul 2026</text>
|
||||
<text x="758.6" y="488" fill="#000" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Aug 2026</text>
|
||||
<text x="177.0" y="488" fill="#000" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Feb 2026</text>
|
||||
<text x="261.0" y="488" fill="#000" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Mar 2026</text>
|
||||
<text x="354.0" y="488" fill="#000" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Apr 2026</text>
|
||||
<text x="444.0" y="488" fill="#000" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">May 2026</text>
|
||||
<text x="537.0" y="488" fill="#000" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Jun 2026</text>
|
||||
<text x="627.0" y="488" fill="#000" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Jul 2026</text>
|
||||
<text x="720.0" y="488" fill="#000" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Aug 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,461.9 C 90.5,461.7 92.7,461.3 93.2,461.2 C 94.2,461.0 95.3,460.8 96.4,460.5 C 97.4,460.3 98.5,460.0 99.6,459.9 C 100.6,459.7 101.7,459.8 102.7,459.8 C 103.8,459.7 104.9,459.7 105.9,459.6 C 107.0,459.6 108.0,459.5 109.1,459.4 C 110.2,459.3 110.7,459.2 112.3,459.2 C 113.9,459.1 117.1,459.0 118.7,458.9 C 120.2,458.9 120.8,458.8 121.8,458.8 C 122.9,458.7 124.0,458.7 125.0,458.7 C 126.1,458.7 127.1,458.7 128.2,458.6 C 129.3,458.6 130.3,458.6 131.4,458.6 C 132.5,458.5 133.5,458.4 134.6,458.3 C 135.6,458.2 136.7,458.3 137.8,458.0 C 138.8,457.8 139.9,457.4 140.9,456.9 C 142.0,456.5 143.1,455.4 144.1,455.0 C 145.2,454.7 146.2,454.9 147.3,454.8 C 148.4,454.7 149.4,454.7 150.5,454.7 C 151.6,454.6 152.1,454.5 153.7,454.5 C 155.3,454.5 157.9,454.5 160.0,454.4 C 162.2,454.4 164.8,454.3 166.4,454.3 C 168.0,454.2 168.5,454.2 169.6,454.1 C 170.7,454.1 171.7,454.0 172.8,454.0 C 173.8,453.9 174.9,453.8 176.0,453.8 C 177.0,453.8 178.1,453.8 179.2,453.7 C 180.2,453.7 180.7,453.7 182.3,453.7 C 183.9,453.6 186.0,453.5 188.7,453.5 C 191.4,453.5 195.6,453.5 198.3,453.4 C 200.9,453.4 201.4,453.4 204.6,453.3 C 207.8,453.3 214.7,453.1 217.4,453.0 C 220.0,453.0 219.0,453.0 220.5,453.0 C 222.1,452.9 224.3,452.9 226.9,452.9 C 229.6,452.9 231.2,452.8 236.5,452.7 C 241.8,452.7 254.5,452.6 258.8,452.5 C 263.0,452.5 260.9,452.5 261.9,452.4 C 263.0,452.4 263.5,453.3 265.1,452.4 C 266.7,451.4 269.9,450.6 271.5,446.5 C 273.1,442.5 273.6,431.6 274.7,428.2 C 275.7,424.8 276.8,426.8 277.9,426.2 C 278.9,425.7 280.0,425.4 281.0,425.0 C 282.1,424.7 283.2,424.4 284.2,424.2 C 285.3,423.9 286.3,423.9 287.4,423.7 C 288.5,423.6 289.5,423.3 290.6,423.2 C 291.7,423.2 292.7,423.3 293.8,423.2 C 294.8,423.1 295.9,422.8 297.0,422.6 C 298.0,422.5 299.1,422.6 300.1,422.4 C 301.2,422.2 302.3,421.7 303.3,421.6 C 304.4,421.4 305.4,421.5 306.5,421.3 C 307.6,421.2 308.6,420.9 309.7,420.8 C 310.8,420.7 311.8,420.8 312.9,420.7 C 313.9,420.7 315.0,420.6 316.1,420.5 C 317.1,420.4 318.2,420.3 319.2,420.3 C 320.3,420.2 321.4,420.2 322.4,420.0 C 323.5,419.9 324.6,419.5 325.6,419.3 C 326.7,419.1 327.7,420.1 328.8,418.8 C 329.9,417.5 330.9,413.1 332.0,411.6 C 333.0,410.1 334.1,410.3 335.2,410.1 C 336.2,409.8 337.3,410.2 338.3,410.0 C 339.4,409.8 340.5,409.3 341.5,408.9 C 342.6,408.5 343.7,408.2 344.7,407.5 C 345.8,406.7 346.8,405.4 347.9,404.6 C 349.0,403.7 350.0,402.9 351.1,402.3 C 352.1,401.6 353.2,401.2 354.3,400.8 C 355.3,400.4 356.4,400.3 357.5,400.1 C 358.5,399.9 359.6,400.0 360.6,399.8 C 361.7,399.7 362.8,399.3 363.8,399.2 C 364.9,399.1 365.9,399.1 367.0,399.0 C 368.1,399.0 369.1,398.9 370.2,398.7 C 371.2,398.6 372.3,398.6 373.4,398.0 C 374.4,397.5 375.5,396.0 376.6,395.3 C 377.6,394.6 378.7,394.4 379.7,393.8 C 380.8,393.3 381.9,394.4 382.9,392.1 C 384.0,389.9 385.0,383.2 386.1,380.6 C 387.2,378.0 388.2,377.6 389.3,376.4 C 390.4,375.3 391.4,374.7 392.5,373.9 C 393.5,373.1 394.6,372.4 395.7,371.8 C 396.7,371.2 397.8,370.7 398.8,370.3 C 399.9,369.9 401.0,369.9 402.0,369.5 C 403.1,369.2 404.2,368.9 405.2,368.5 C 406.3,368.1 407.3,367.4 408.4,367.0 C 409.5,366.7 410.5,366.7 411.6,366.3 C 412.6,366.0 413.7,365.3 414.8,364.9 C 415.8,364.4 416.9,364.1 417.9,363.7 C 419.0,363.4 420.1,363.1 421.1,362.9 C 422.2,362.7 423.3,362.6 424.3,362.4 C 425.4,362.2 426.4,361.9 427.5,361.7 C 428.6,361.4 429.6,361.2 430.7,360.9 C 431.7,360.6 432.8,360.3 433.9,359.9 C 434.9,359.4 436.0,358.6 437.1,358.2 C 438.1,357.8 439.2,357.7 440.2,357.4 C 441.3,357.2 442.4,357.1 443.4,356.9 C 444.5,356.7 445.5,356.6 446.6,356.4 C 447.7,356.3 448.7,356.1 449.8,356.0 C 450.8,355.9 451.9,356.0 453.0,355.8 C 454.0,355.6 455.1,355.2 456.2,354.8 C 457.2,354.4 458.3,353.8 459.3,353.3 C 460.4,352.8 461.5,352.3 462.5,352.0 C 463.6,351.7 464.6,351.7 465.7,351.5 C 466.8,351.3 467.8,351.2 468.9,350.9 C 470.0,350.5 471.0,349.8 472.1,349.5 C 473.1,349.2 474.2,349.3 475.3,349.1 C 476.3,348.9 477.4,348.5 478.4,348.3 C 479.5,348.0 480.6,347.8 481.6,347.5 C 482.7,347.2 483.8,346.9 484.8,346.6 C 485.9,346.4 486.9,346.3 488.0,346.0 C 489.1,345.6 490.1,345.1 491.2,344.8 C 492.2,344.5 493.3,344.3 494.4,344.0 C 495.4,343.6 496.5,343.1 497.5,342.7 C 498.6,342.3 499.7,341.9 500.7,341.6 C 501.8,341.2 502.9,341.2 503.9,340.6 C 505.0,340.0 506.0,338.7 507.1,338.2 C 508.2,337.7 509.2,337.6 510.3,337.5 C 511.3,337.3 512.4,337.3 513.5,337.1 C 514.5,337.0 515.6,336.8 516.7,336.6 C 517.7,336.5 518.8,336.7 519.8,336.2 C 520.9,335.7 522.0,334.1 523.0,333.5 C 524.1,332.9 525.1,333.0 526.2,332.6 C 527.3,332.3 528.3,331.6 529.4,331.3 C 530.4,331.0 531.5,331.2 532.6,330.9 C 533.6,330.6 534.7,329.9 535.8,329.6 C 536.8,329.3 537.9,329.3 538.9,329.2 C 540.0,329.0 541.1,329.0 542.1,328.7 C 543.2,328.4 544.2,328.0 545.3,327.3 C 546.4,326.5 547.4,325.1 548.5,324.4 C 549.6,323.6 550.6,323.3 551.7,322.7 C 552.7,322.2 553.8,322.1 554.9,321.1 C 555.9,320.0 557.0,317.7 558.0,316.5 C 559.1,315.4 560.2,314.9 561.2,314.2 C 562.3,313.5 563.3,313.4 564.4,312.3 C 565.5,311.3 566.5,309.0 567.6,308.0 C 568.7,307.0 569.7,306.9 570.8,306.3 C 571.Line truncated
|
||||
<path d="M 90.0,461.9 C 90.5,461.7 92.7,461.3 93.2,461.2 C 94.2,461.0 95.3,460.8 96.4,460.5 C 97.4,460.3 98.5,460.0 99.6,459.9 C 100.6,459.7 101.7,459.8 102.7,459.8 C 103.8,459.7 104.9,459.7 105.9,459.6 C 107.0,459.6 108.0,459.5 109.1,459.4 C 110.2,459.3 110.7,459.2 112.3,459.2 C 113.9,459.1 117.1,459.0 118.7,458.9 C 120.2,458.9 120.8,458.8 121.8,458.8 C 122.9,458.7 124.0,458.7 125.0,458.7 C 126.1,458.7 127.1,458.7 128.2,458.6 C 129.3,458.6 130.3,458.6 131.4,458.6 C 132.5,458.5 133.5,458.4 134.6,458.3 C 135.6,458.2 136.7,458.3 137.8,458.0 C 138.8,457.8 139.9,457.4 140.9,456.9 C 142.0,456.5 143.1,455.4 144.1,455.0 C 145.2,454.7 146.2,454.9 147.3,454.8 C 148.4,454.7 149.4,454.7 150.5,454.7 C 151.6,454.6 152.1,454.5 153.7,454.5 C 155.3,454.5 157.9,454.5 160.0,454.4 C 162.2,454.4 164.8,454.3 166.4,454.3 C 168.0,454.2 168.5,454.2 169.6,454.1 C 170.7,454.1 171.7,454.0 172.8,454.0 C 173.8,453.9 174.9,453.8 176.0,453.8 C 177.0,453.8 178.1,453.8 179.2,453.7 C 180.2,453.7 180.7,453.7 182.3,453.7 C 183.9,453.6 186.0,453.5 188.7,453.5 C 191.4,453.5 195.6,453.5 198.3,453.4 C 200.9,453.4 201.4,453.4 204.6,453.3 C 207.8,453.3 214.7,453.1 217.4,453.0 C 220.0,453.0 219.0,453.0 220.5,453.0 C 222.1,452.9 224.3,452.9 226.9,452.9 C 229.6,452.9 231.2,452.8 236.5,452.7 C 241.8,452.7 254.5,452.6 258.8,452.5 C 263.0,452.5 260.9,452.5 261.9,452.4 C 263.0,452.4 263.5,453.3 265.1,452.4 C 266.7,451.4 269.9,450.6 271.5,446.5 C 273.1,442.5 273.6,431.6 274.7,428.2 C 275.7,424.8 276.8,426.8 277.9,426.2 C 278.9,425.7 280.0,425.4 281.0,425.0 C 282.1,424.7 283.2,424.4 284.2,424.2 C 285.3,423.9 286.3,423.9 287.4,423.7 C 288.5,423.6 289.5,423.3 290.6,423.2 C 291.7,423.2 292.7,423.3 293.8,423.2 C 294.8,423.1 295.9,422.8 297.0,422.6 C 298.0,422.5 299.1,422.6 300.1,422.4 C 301.2,422.2 302.3,421.7 303.3,421.6 C 304.4,421.4 305.4,421.5 306.5,421.3 C 307.6,421.2 308.6,420.9 309.7,420.8 C 310.8,420.7 311.8,420.8 312.9,420.7 C 313.9,420.7 315.0,420.6 316.1,420.5 C 317.1,420.4 318.2,420.3 319.2,420.3 C 320.3,420.2 321.4,420.2 322.4,420.0 C 323.5,419.9 324.6,419.5 325.6,419.3 C 326.7,419.1 327.7,420.1 328.8,418.8 C 329.9,417.5 330.9,413.1 332.0,411.6 C 333.0,410.1 334.1,410.3 335.2,410.1 C 336.2,409.8 337.3,410.2 338.3,410.0 C 339.4,409.8 340.5,409.3 341.5,408.9 C 342.6,408.5 343.7,408.2 344.7,407.5 C 345.8,406.7 346.8,405.4 347.9,404.6 C 349.0,403.7 350.0,402.9 351.1,402.3 C 352.1,401.6 353.2,401.2 354.3,400.8 C 355.3,400.4 356.4,400.3 357.5,400.1 C 358.5,399.9 359.6,400.0 360.6,399.8 C 361.7,399.7 362.8,399.3 363.8,399.2 C 364.9,399.1 365.9,399.1 367.0,399.0 C 368.1,399.0 369.1,398.9 370.2,398.7 C 371.2,398.6 372.3,398.6 373.4,398.0 C 374.4,397.5 375.5,396.0 376.6,395.3 C 377.6,394.6 378.7,394.4 379.7,393.8 C 380.8,393.3 381.9,394.4 382.9,392.1 C 384.0,389.9 385.0,383.2 386.1,380.6 C 387.2,378.0 388.2,377.6 389.3,376.4 C 390.4,375.3 391.4,374.7 392.5,373.9 C 393.5,373.1 394.6,372.4 395.7,371.8 C 396.7,371.2 397.8,370.7 398.8,370.3 C 399.9,369.9 401.0,369.9 402.0,369.5 C 403.1,369.2 404.2,368.9 405.2,368.5 C 406.3,368.1 407.3,367.4 408.4,367.0 C 409.5,366.7 410.5,366.7 411.6,366.3 C 412.6,366.0 413.7,365.3 414.8,364.9 C 415.8,364.4 416.9,364.1 417.9,363.7 C 419.0,363.4 420.1,363.1 421.1,362.9 C 422.2,362.7 423.3,362.6 424.3,362.4 C 425.4,362.2 426.4,361.9 427.5,361.7 C 428.6,361.4 429.6,361.2 430.7,360.9 C 431.7,360.6 432.8,360.3 433.9,359.9 C 434.9,359.4 436.0,358.6 437.1,358.2 C 438.1,357.8 439.2,357.7 440.2,357.4 C 441.3,357.2 442.4,357.1 443.4,356.9 C 444.5,356.7 445.5,356.6 446.6,356.4 C 447.7,356.3 448.7,356.1 449.8,356.0 C 450.8,355.9 451.9,356.0 453.0,355.8 C 454.0,355.6 455.1,355.2 456.2,354.8 C 457.2,354.4 458.3,353.8 459.3,353.3 C 460.4,352.8 461.5,352.3 462.5,352.0 C 463.6,351.7 464.6,351.7 465.7,351.5 C 466.8,351.3 467.8,351.2 468.9,350.9 C 470.0,350.5 471.0,349.8 472.1,349.5 C 473.1,349.2 474.2,349.3 475.3,349.1 C 476.3,348.9 477.4,348.5 478.4,348.3 C 479.5,348.0 480.6,347.8 481.6,347.5 C 482.7,347.2 483.8,346.9 484.8,346.6 C 485.9,346.4 486.9,346.3 488.0,346.0 C 489.1,345.6 490.1,345.1 491.2,344.8 C 492.2,344.5 493.3,344.3 494.4,344.0 C 495.4,343.6 496.5,343.1 497.5,342.7 C 498.6,342.3 499.7,341.9 500.7,341.6 C 501.8,341.2 502.9,341.2 503.9,340.6 C 505.0,340.0 506.0,338.7 507.1,338.2 C 508.2,337.7 509.2,337.6 510.3,337.5 C 511.3,337.3 512.4,337.3 513.5,337.1 C 514.5,337.0 515.6,336.8 516.7,336.6 C 517.7,336.5 518.8,336.7 519.8,336.2 C 520.9,335.7 522.0,334.1 523.0,333.5 C 524.1,332.9 525.1,333.0 526.2,332.6 C 527.3,332.3 528.3,331.6 529.4,331.3 C 530.4,331.0 531.5,331.2 532.6,330.9 C 533.6,330.6 534.7,329.9 535.8,329.6 C 536.8,329.3 537.9,329.3 538.9,329.2 C 540.0,329.0 541.1,329.0 542.1,328.7 C 543.2,328.4 544.2,328.0 545.3,327.3 C 546.4,326.5 547.4,325.1 548.5,324.4 C 549.6,323.6 550.6,323.3 551.7,322.7 C 552.7,322.2 553.8,322.1 554.9,321.1 C 555.9,320.0 557.0,317.7 558.0,316.5 C 559.1,315.4 560.2,314.9 561.2,314.2 C 562.3,313.5 563.3,313.4 564.4,312.3 C 565.5,311.3 566.5,309.0 567.6,308.0 C 568.7,307.0 569.7,306.9 570.8,306.3 C 571.Line truncated
|
||||
<circle cx="765.0" cy="116.9" 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">4518</text>
|
||||
<path d="M 90.0,462.0 C 90.5,462.0 92.5,461.6 93.0,461.5 C 94.0,461.4 95.0,461.1 96.0,461.0 C 97.0,460.8 98.0,460.5 99.0,460.4 C 100.0,460.3 101.0,460.4 102.0,460.3 C 103.0,460.3 104.0,460.2 105.0,460.2 C 106.0,460.1 107.0,460.1 108.0,460.0 C 109.0,459.9 109.5,459.9 111.0,459.8 C 112.5,459.7 115.5,459.7 117.0,459.6 C 118.5,459.6 119.0,459.5 120.0,459.5 C 121.0,459.5 122.0,459.4 123.0,459.4 C 124.0,459.4 125.0,459.4 126.0,459.4 C 127.0,459.3 128.0,459.3 129.0,459.3 C 130.0,459.3 131.0,459.2 132.0,459.1 C 133.0,459.0 134.0,459.0 135.0,458.9 C 136.0,458.7 137.0,458.4 138.0,458.0 C 139.0,457.5 140.0,456.7 141.0,456.4 C 142.0,456.1 143.0,456.2 144.0,456.2 C 145.0,456.1 146.0,456.1 147.0,456.0 C 148.0,456.0 148.5,455.9 150.0,455.9 C 151.5,455.9 154.0,455.9 156.0,455.9 C 158.0,455.8 160.5,455.8 162.0,455.7 C 163.5,455.7 164.0,455.6 165.0,455.6 C 166.0,455.6 167.0,455.5 168.0,455.5 C 169.0,455.4 170.0,455.4 171.0,455.3 C 172.0,455.3 173.0,455.3 174.0,455.3 C 175.0,455.3 175.5,455.2 177.0,455.2 C 178.5,455.2 180.5,455.1 183.0,455.1 C 185.5,455.1 189.5,455.0 192.0,455.0 C 194.5,455.0 195.0,455.0 198.0,455.0 C 201.0,454.9 207.5,454.8 210.0,454.7 C 212.5,454.6 211.5,454.7 213.0,454.6 C 214.5,454.6 216.5,454.6 219.0,454.6 C 221.5,454.5 223.0,454.5 228.0,454.4 C 233.0,454.4 245.0,454.3 249.0,454.3 C 253.0,454.2 251.0,454.2 252.0,454.2 C 253.0,454.2 253.5,454.9 255.0,454.1 C 256.5,453.3 259.5,452.6 261.0,449.3 C 262.5,445.9 263.0,436.9 264.0,434.1 C 265.0,431.3 266.0,432.9 267.0,432.4 C 268.0,432.0 269.0,431.7 270.0,431.4 C 271.0,431.1 272.0,430.9 273.0,430.7 C 274.0,430.5 275.0,430.4 276.0,430.3 C 277.0,430.2 278.0,430.0 279.0,429.9 C 280.0,429.9 281.0,430.0 282.0,429.9 C 283.0,429.8 284.0,429.5 285.0,429.4 C 286.0,429.3 287.0,429.4 288.0,429.2 C 289.0,429.1 290.0,428.7 291.0,428.5 C 292.0,428.4 293.0,428.4 294.0,428.3 C 295.0,428.2 296.0,428.0 297.0,427.9 C 298.0,427.8 299.0,427.9 300.0,427.8 C 301.0,427.8 302.0,427.7 303.0,427.6 C 304.0,427.6 305.0,427.5 306.0,427.4 C 307.0,427.4 308.0,427.4 309.0,427.3 C 310.0,427.1 311.0,426.8 312.0,426.7 C 313.0,426.5 314.0,427.3 315.0,426.2 C 316.0,425.2 317.0,421.4 318.0,420.2 C 319.0,419.0 320.0,419.2 321.0,419.0 C 322.0,418.7 323.0,419.1 324.0,418.9 C 325.0,418.7 326.0,418.3 327.0,418.0 C 328.0,417.7 329.0,417.4 330.0,416.8 C 331.0,416.3 332.0,415.1 333.0,414.4 C 334.0,413.7 335.0,413.0 336.0,412.5 C 337.0,412.0 338.0,411.6 339.0,411.3 C 340.0,411.0 341.0,410.9 342.0,410.7 C 343.0,410.6 344.0,410.6 345.0,410.5 C 346.0,410.3 347.0,410.1 348.0,410.0 C 349.0,409.8 350.0,409.9 351.0,409.8 C 352.0,409.8 353.0,409.7 354.0,409.6 C 355.0,409.4 356.0,409.5 357.0,409.0 C 358.0,408.5 359.0,407.3 360.0,406.7 C 361.0,406.1 362.0,405.9 363.0,405.5 C 364.0,405.0 365.0,405.9 366.0,404.1 C 367.0,402.3 368.0,396.7 369.0,394.5 C 370.0,392.3 371.0,392.0 372.0,391.1 C 373.0,390.1 374.0,389.6 375.0,389.0 C 376.0,388.3 377.0,387.7 378.0,387.2 C 379.0,386.7 380.0,386.3 381.0,386.0 C 382.0,385.6 383.0,385.6 384.0,385.3 C 385.0,385.1 386.0,384.8 387.0,384.4 C 388.0,384.1 389.0,383.5 390.0,383.2 C 391.0,382.9 392.0,382.9 393.0,382.6 C 394.0,382.3 395.0,381.8 396.0,381.4 C 397.0,381.1 398.0,380.7 399.0,380.5 C 400.0,380.2 401.0,379.9 402.0,379.8 C 403.0,379.6 404.0,379.5 405.0,379.4 C 406.0,379.2 407.0,379.0 408.0,378.7 C 409.0,378.5 410.0,378.3 411.0,378.1 C 412.0,377.9 413.0,377.6 414.0,377.3 C 415.0,376.9 416.0,376.2 417.0,375.9 C 418.0,375.5 419.0,375.4 420.0,375.2 C 421.0,375.0 422.0,374.9 423.0,374.8 C 424.0,374.6 425.0,374.5 426.0,374.4 C 427.0,374.3 428.0,374.1 429.0,374.0 C 430.0,373.9 431.0,374.0 432.0,373.8 C 433.0,373.7 434.0,373.4 435.0,373.1 C 436.0,372.7 437.0,372.2 438.0,371.8 C 439.0,371.4 440.0,371.0 441.0,370.7 C 442.0,370.4 443.0,370.4 444.0,370.3 C 445.0,370.1 446.0,370.0 447.0,369.7 C 448.0,369.5 449.0,368.8 450.0,368.6 C 451.0,368.3 452.0,368.4 453.0,368.3 C 454.0,368.1 455.0,367.8 456.0,367.6 C 457.0,367.3 458.0,367.2 459.0,366.9 C 460.0,366.7 461.0,366.4 462.0,366.2 C 463.0,366.0 464.0,365.9 465.0,365.7 C 466.0,365.4 467.0,365.0 468.0,364.7 C 469.0,364.4 470.0,364.3 471.0,364.0 C 472.0,363.7 473.0,363.4 474.0,363.0 C 475.0,362.7 476.0,362.4 477.0,362.1 C 478.0,361.8 479.0,361.7 480.0,361.2 C 481.0,360.8 482.0,359.7 483.0,359.3 C 484.0,358.8 485.0,358.8 486.0,358.6 C 487.0,358.5 488.0,358.5 489.0,358.4 C 490.0,358.3 491.0,358.1 492.0,357.9 C 493.0,357.8 494.0,358.0 495.0,357.6 C 496.0,357.2 497.0,355.9 498.0,355.4 C 499.0,354.9 500.0,355.0 501.0,354.7 C 502.0,354.4 503.0,353.8 504.0,353.6 C 505.0,353.4 506.0,353.5 507.0,353.3 C 508.0,353.0 509.0,352.4 510.0,352.2 C 511.0,352.0 512.0,352.0 513.0,351.9 C 514.0,351.7 515.0,351.7 516.0,351.5 C 517.0,351.2 518.0,350.9 519.0,350.3 C 520.0,349.7 521.0,348.5 522.0,347.8 C 523.0,347.2 524.0,347.0 525.0,346.5 C 526.0,346.0 527.0,346.0 528.0,345.1 C 529.0,344.2 530.0,342.3 531.0,341.3 C 532.0,340.4 533.0,340.0 534.0,339.4 C 535.0,338.8 536.0,338.7 537.0,337.9 C 538.0,337.0 539.0,335.2 540.0,334.3 C 541.0,333.5 542.0,333.3 543.0,332.8 C 544.Line truncated
|
||||
<path d="M 90.0,462.0 C 90.5,462.0 92.5,461.6 93.0,461.5 C 94.0,461.4 95.0,461.1 96.0,461.0 C 97.0,460.8 98.0,460.5 99.0,460.4 C 100.0,460.3 101.0,460.4 102.0,460.3 C 103.0,460.3 104.0,460.2 105.0,460.2 C 106.0,460.1 107.0,460.1 108.0,460.0 C 109.0,459.9 109.5,459.9 111.0,459.8 C 112.5,459.7 115.5,459.7 117.0,459.6 C 118.5,459.6 119.0,459.5 120.0,459.5 C 121.0,459.5 122.0,459.4 123.0,459.4 C 124.0,459.4 125.0,459.4 126.0,459.4 C 127.0,459.3 128.0,459.3 129.0,459.3 C 130.0,459.3 131.0,459.2 132.0,459.1 C 133.0,459.0 134.0,459.0 135.0,458.9 C 136.0,458.7 137.0,458.4 138.0,458.0 C 139.0,457.5 140.0,456.7 141.0,456.4 C 142.0,456.1 143.0,456.2 144.0,456.2 C 145.0,456.1 146.0,456.1 147.0,456.0 C 148.0,456.0 148.5,455.9 150.0,455.9 C 151.5,455.9 154.0,455.9 156.0,455.9 C 158.0,455.8 160.5,455.8 162.0,455.7 C 163.5,455.7 164.0,455.6 165.0,455.6 C 166.0,455.6 167.0,455.5 168.0,455.5 C 169.0,455.4 170.0,455.4 171.0,455.3 C 172.0,455.3 173.0,455.3 174.0,455.3 C 175.0,455.3 175.5,455.2 177.0,455.2 C 178.5,455.2 180.5,455.1 183.0,455.1 C 185.5,455.1 189.5,455.0 192.0,455.0 C 194.5,455.0 195.0,455.0 198.0,455.0 C 201.0,454.9 207.5,454.8 210.0,454.7 C 212.5,454.6 211.5,454.7 213.0,454.6 C 214.5,454.6 216.5,454.6 219.0,454.6 C 221.5,454.5 223.0,454.5 228.0,454.4 C 233.0,454.4 245.0,454.3 249.0,454.3 C 253.0,454.2 251.0,454.2 252.0,454.2 C 253.0,454.2 253.5,454.9 255.0,454.1 C 256.5,453.3 259.5,452.6 261.0,449.3 C 262.5,445.9 263.0,436.9 264.0,434.1 C 265.0,431.3 266.0,432.9 267.0,432.4 C 268.0,432.0 269.0,431.7 270.0,431.4 C 271.0,431.1 272.0,430.9 273.0,430.7 C 274.0,430.5 275.0,430.4 276.0,430.3 C 277.0,430.2 278.0,430.0 279.0,429.9 C 280.0,429.9 281.0,430.0 282.0,429.9 C 283.0,429.8 284.0,429.5 285.0,429.4 C 286.0,429.3 287.0,429.4 288.0,429.2 C 289.0,429.1 290.0,428.7 291.0,428.5 C 292.0,428.4 293.0,428.4 294.0,428.3 C 295.0,428.2 296.0,428.0 297.0,427.9 C 298.0,427.8 299.0,427.9 300.0,427.8 C 301.0,427.8 302.0,427.7 303.0,427.6 C 304.0,427.6 305.0,427.5 306.0,427.4 C 307.0,427.4 308.0,427.4 309.0,427.3 C 310.0,427.1 311.0,426.8 312.0,426.7 C 313.0,426.5 314.0,427.3 315.0,426.2 C 316.0,425.2 317.0,421.4 318.0,420.2 C 319.0,419.0 320.0,419.2 321.0,419.0 C 322.0,418.7 323.0,419.1 324.0,418.9 C 325.0,418.7 326.0,418.3 327.0,418.0 C 328.0,417.7 329.0,417.4 330.0,416.8 C 331.0,416.3 332.0,415.1 333.0,414.4 C 334.0,413.7 335.0,413.0 336.0,412.5 C 337.0,412.0 338.0,411.6 339.0,411.3 C 340.0,411.0 341.0,410.9 342.0,410.7 C 343.0,410.6 344.0,410.6 345.0,410.5 C 346.0,410.3 347.0,410.1 348.0,410.0 C 349.0,409.8 350.0,409.9 351.0,409.8 C 352.0,409.8 353.0,409.7 354.0,409.6 C 355.0,409.4 356.0,409.5 357.0,409.0 C 358.0,408.5 359.0,407.3 360.0,406.7 C 361.0,406.1 362.0,405.9 363.0,405.5 C 364.0,405.0 365.0,405.9 366.0,404.1 C 367.0,402.3 368.0,396.7 369.0,394.5 C 370.0,392.3 371.0,392.0 372.0,391.1 C 373.0,390.1 374.0,389.6 375.0,389.0 C 376.0,388.3 377.0,387.7 378.0,387.2 C 379.0,386.7 380.0,386.3 381.0,386.0 C 382.0,385.6 383.0,385.6 384.0,385.3 C 385.0,385.1 386.0,384.8 387.0,384.4 C 388.0,384.1 389.0,383.5 390.0,383.2 C 391.0,382.9 392.0,382.9 393.0,382.6 C 394.0,382.3 395.0,381.8 396.0,381.4 C 397.0,381.1 398.0,380.7 399.0,380.5 C 400.0,380.2 401.0,379.9 402.0,379.8 C 403.0,379.6 404.0,379.5 405.0,379.4 C 406.0,379.2 407.0,379.0 408.0,378.7 C 409.0,378.5 410.0,378.3 411.0,378.1 C 412.0,377.9 413.0,377.6 414.0,377.3 C 415.0,376.9 416.0,376.2 417.0,375.9 C 418.0,375.5 419.0,375.4 420.0,375.2 C 421.0,375.0 422.0,374.9 423.0,374.8 C 424.0,374.6 425.0,374.5 426.0,374.4 C 427.0,374.3 428.0,374.1 429.0,374.0 C 430.0,373.9 431.0,374.0 432.0,373.8 C 433.0,373.7 434.0,373.4 435.0,373.1 C 436.0,372.7 437.0,372.2 438.0,371.8 C 439.0,371.4 440.0,371.0 441.0,370.7 C 442.0,370.4 443.0,370.4 444.0,370.3 C 445.0,370.1 446.0,370.0 447.0,369.7 C 448.0,369.5 449.0,368.8 450.0,368.6 C 451.0,368.3 452.0,368.4 453.0,368.3 C 454.0,368.1 455.0,367.8 456.0,367.6 C 457.0,367.3 458.0,367.2 459.0,366.9 C 460.0,366.7 461.0,366.4 462.0,366.2 C 463.0,366.0 464.0,365.9 465.0,365.7 C 466.0,365.4 467.0,365.0 468.0,364.7 C 469.0,364.4 470.0,364.3 471.0,364.0 C 472.0,363.7 473.0,363.4 474.0,363.0 C 475.0,362.7 476.0,362.4 477.0,362.1 C 478.0,361.8 479.0,361.7 480.0,361.2 C 481.0,360.8 482.0,359.7 483.0,359.3 C 484.0,358.8 485.0,358.8 486.0,358.6 C 487.0,358.5 488.0,358.5 489.0,358.4 C 490.0,358.3 491.0,358.1 492.0,357.9 C 493.0,357.8 494.0,358.0 495.0,357.6 C 496.0,357.2 497.0,355.9 498.0,355.4 C 499.0,354.9 500.0,355.0 501.0,354.7 C 502.0,354.4 503.0,353.8 504.0,353.6 C 505.0,353.4 506.0,353.5 507.0,353.3 C 508.0,353.0 509.0,352.4 510.0,352.2 C 511.0,352.0 512.0,352.0 513.0,351.9 C 514.0,351.7 515.0,351.7 516.0,351.5 C 517.0,351.2 518.0,350.9 519.0,350.3 C 520.0,349.7 521.0,348.5 522.0,347.8 C 523.0,347.2 524.0,347.0 525.0,346.5 C 526.0,346.0 527.0,346.0 528.0,345.1 C 529.0,344.2 530.0,342.3 531.0,341.3 C 532.0,340.4 533.0,340.0 534.0,339.4 C 535.0,338.8 536.0,338.7 537.0,337.9 C 538.0,337.0 539.0,335.2 540.0,334.3 C 541.0,333.5 542.0,333.3 543.0,332.8 C 544.Line truncated
|
||||
<circle cx="765.0" cy="134.6" 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">5144</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: 30 KiB After Width: | Height: | Size: 31 KiB |
@@ -0,0 +1,68 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import {
|
||||
findActiveSystemShortcutConflict,
|
||||
listActiveSystemBindings,
|
||||
} from './activeKeyBindings.ts';
|
||||
import { DEFAULT_KEY_BINDINGS, type KeyBinding } from './models/keyBindings.ts';
|
||||
|
||||
const withBindingOverride = (
|
||||
id: string,
|
||||
override: Partial<Pick<KeyBinding, 'mac' | 'pc'>>,
|
||||
): KeyBinding[] => (
|
||||
DEFAULT_KEY_BINDINGS.map((binding) => (
|
||||
binding.id === id ? { ...binding, ...override } : binding
|
||||
))
|
||||
);
|
||||
|
||||
test('disabled scheme exposes no active system bindings', () => {
|
||||
assert.deepEqual(listActiveSystemBindings('disabled', DEFAULT_KEY_BINDINGS), []);
|
||||
});
|
||||
|
||||
test('disabled scheme does not treat default tab or broadcast keys as occupied', () => {
|
||||
assert.equal(
|
||||
findActiveSystemShortcutConflict('Ctrl + 1', 'disabled', DEFAULT_KEY_BINDINGS),
|
||||
null,
|
||||
);
|
||||
assert.equal(
|
||||
findActiveSystemShortcutConflict('Ctrl + B', 'disabled', DEFAULT_KEY_BINDINGS),
|
||||
null,
|
||||
);
|
||||
assert.equal(
|
||||
findActiveSystemShortcutConflict('⌘ + 1', 'disabled', DEFAULT_KEY_BINDINGS),
|
||||
null,
|
||||
);
|
||||
});
|
||||
|
||||
test('pc scheme reports the tab-switch binding for Ctrl+1', () => {
|
||||
const conflict = findActiveSystemShortcutConflict('Ctrl + 1', 'pc', DEFAULT_KEY_BINDINGS);
|
||||
|
||||
assert.equal(conflict?.id, 'switch-tab-1-9');
|
||||
});
|
||||
|
||||
test('pc scheme reports the broadcast binding for Ctrl+B', () => {
|
||||
const conflict = findActiveSystemShortcutConflict('Ctrl + B', 'pc', DEFAULT_KEY_BINDINGS);
|
||||
|
||||
assert.equal(conflict?.id, 'broadcast');
|
||||
});
|
||||
|
||||
test('mac scheme does not treat Ctrl+1 as a tab shortcut', () => {
|
||||
assert.equal(
|
||||
findActiveSystemShortcutConflict('Ctrl + 1', 'mac', DEFAULT_KEY_BINDINGS),
|
||||
null,
|
||||
);
|
||||
});
|
||||
|
||||
test('mac scheme reports the tab-switch binding for Cmd+1', () => {
|
||||
const conflict = findActiveSystemShortcutConflict('⌘ + 1', 'mac', DEFAULT_KEY_BINDINGS);
|
||||
|
||||
assert.equal(conflict?.id, 'switch-tab-1-9');
|
||||
});
|
||||
|
||||
test('a Disabled individual binding is not a conflict', () => {
|
||||
const bindings = withBindingOverride('switch-tab-1-9', { pc: 'Disabled' });
|
||||
|
||||
assert.equal(findActiveSystemShortcutConflict('Ctrl + 1', 'pc', bindings), null);
|
||||
assert.equal(findActiveSystemShortcutConflict('Ctrl + B', 'pc', bindings)?.id, 'broadcast');
|
||||
});
|
||||
@@ -0,0 +1,52 @@
|
||||
import {
|
||||
type HotkeyScheme,
|
||||
type KeyBinding,
|
||||
keyStringToKeyboardEvent,
|
||||
matchesKeyBinding,
|
||||
} from './models/keyBindings.ts';
|
||||
|
||||
export type ActiveSystemBinding = {
|
||||
binding: KeyBinding;
|
||||
key: string;
|
||||
isMac: boolean;
|
||||
};
|
||||
|
||||
/** App shortcuts that can actually fire for the current scheme. */
|
||||
export const listActiveSystemBindings = (
|
||||
hotkeyScheme: HotkeyScheme,
|
||||
keyBindings: readonly KeyBinding[],
|
||||
): ActiveSystemBinding[] => {
|
||||
if (hotkeyScheme === 'disabled') return [];
|
||||
|
||||
return keyBindings.flatMap((binding) => {
|
||||
if (hotkeyScheme === 'mac') {
|
||||
return binding.mac && binding.mac !== 'Disabled'
|
||||
? [{ binding, key: binding.mac, isMac: true }]
|
||||
: [];
|
||||
}
|
||||
|
||||
if (hotkeyScheme === 'pc') {
|
||||
return binding.pc && binding.pc !== 'Disabled'
|
||||
? [{ binding, key: binding.pc, isMac: false }]
|
||||
: [];
|
||||
}
|
||||
|
||||
return [];
|
||||
});
|
||||
};
|
||||
|
||||
export const findActiveSystemShortcutConflict = (
|
||||
key: string,
|
||||
hotkeyScheme: HotkeyScheme,
|
||||
keyBindings: readonly KeyBinding[],
|
||||
): KeyBinding | null => {
|
||||
if (!key) return null;
|
||||
|
||||
const event = keyStringToKeyboardEvent(key);
|
||||
if (!event) return null;
|
||||
|
||||
const conflict = listActiveSystemBindings(hotkeyScheme, keyBindings).find((entry) => (
|
||||
matchesKeyBinding(event, entry.key, entry.isMac)
|
||||
));
|
||||
return conflict?.binding ?? null;
|
||||
};
|
||||
+15
-1
@@ -1,7 +1,7 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import { keyEventToString, matchesKeyBinding, tabShortcutDigitFromEvent } from './models.ts';
|
||||
import { keyEventToString, keyStringToKeyboardEvent, matchesKeyBinding, tabShortcutDigitFromEvent } from './models.ts';
|
||||
|
||||
const keyboardEvent = (
|
||||
key: string,
|
||||
@@ -74,3 +74,17 @@ test('tab shortcut digit falls back to e.key when code is missing', () => {
|
||||
assert.equal(tabShortcutDigitFromEvent({ key: '3', code: '' }), 3);
|
||||
assert.equal(tabShortcutDigitFromEvent({ key: '!', code: '' }), null);
|
||||
});
|
||||
|
||||
test('stored shortcut strings rebuild matching keyboard events', () => {
|
||||
const event = keyStringToKeyboardEvent('Ctrl + 1');
|
||||
assert.ok(event);
|
||||
assert.equal(event.key, '1');
|
||||
assert.equal(event.ctrlKey, true);
|
||||
assert.equal(matchesKeyBinding(event, 'Ctrl + [1...9]', false), true);
|
||||
|
||||
const macEvent = keyStringToKeyboardEvent('⌘ + B');
|
||||
assert.ok(macEvent);
|
||||
assert.equal(macEvent.key, 'b');
|
||||
assert.equal(macEvent.metaKey, true);
|
||||
assert.equal(matchesKeyBinding(macEvent, '⌘ + B', true), true);
|
||||
});
|
||||
@@ -21,6 +21,38 @@ export const parseKeyCombo = (keyStr: string): { modifiers: string[]; key: strin
|
||||
return { modifiers: parts, key };
|
||||
};
|
||||
|
||||
const KEY_STRING_TO_EVENT_KEY: Record<string, string> = {
|
||||
Space: ' ',
|
||||
'↑': 'ArrowUp',
|
||||
'↓': 'ArrowDown',
|
||||
'←': 'ArrowLeft',
|
||||
'→': 'ArrowRight',
|
||||
Esc: 'Escape',
|
||||
'⌫': 'Backspace',
|
||||
Del: 'Delete',
|
||||
'↵': 'Enter',
|
||||
'⇥': 'Tab',
|
||||
};
|
||||
|
||||
/** Rebuild a keydown-like event from a stored shortcut string for matching. */
|
||||
export const keyStringToKeyboardEvent = (keyString: string): KeyboardEvent | null => {
|
||||
const parsed = parseKeyCombo(keyString);
|
||||
if (!parsed) return null;
|
||||
|
||||
const modifiers = new Set(parsed.modifiers);
|
||||
const mappedKey = KEY_STRING_TO_EVENT_KEY[parsed.key];
|
||||
const key = mappedKey ?? (parsed.key.length === 1 ? parsed.key.toLowerCase() : parsed.key);
|
||||
|
||||
return {
|
||||
key,
|
||||
code: '',
|
||||
metaKey: modifiers.has('⌘') || modifiers.has('Win'),
|
||||
ctrlKey: modifiers.has('⌃') || modifiers.has('Ctrl'),
|
||||
altKey: modifiers.has('⌥') || modifiers.has('Alt'),
|
||||
shiftKey: modifiers.has('Shift'),
|
||||
} as KeyboardEvent;
|
||||
};
|
||||
|
||||
const PHYSICAL_SHORTCUT_KEY_NAMES: Record<string, string> = {
|
||||
Backquote: '`',
|
||||
Minus: '-',
|
||||
|
||||
@@ -41,6 +41,13 @@ export type PasswordPromptAssistMode = 'off' | 'hint' | 'picker';
|
||||
* - global: commands recorded across all hosts
|
||||
*/
|
||||
export type AutocompleteHistoryScope = 'host' | 'global';
|
||||
/**
|
||||
* When remote programs emit OSC 9 / 777 / 99 desktop-notification sequences.
|
||||
* - off: ignore
|
||||
* - unfocused: notify only when this session is not the focused pane or the window is in the background
|
||||
* - always: honor every notification (default; matches iTerm2 / Ghostty / Codex osc9)
|
||||
*/
|
||||
export type OscNotificationMode = 'off' | 'unfocused' | 'always';
|
||||
|
||||
export const DEFAULT_TERMINAL_WORD_SEPARATORS = ' ()[]{}\'"';
|
||||
|
||||
@@ -174,6 +181,9 @@ export interface TerminalSettings {
|
||||
// Clipboard
|
||||
osc52Clipboard: 'off' | 'write-only' | 'read-write' | 'prompt'; // OSC-52 clipboard access: off, write-only (default), read-write, or prompt on read
|
||||
|
||||
// Desktop notifications from OSC 9 / OSC 777 notify / OSC 99
|
||||
oscNotifications: OscNotificationMode;
|
||||
|
||||
// Tab titles
|
||||
dynamicTabTitleMode: DynamicTabTitleMode; // off, agent-only, or all shell-reported titles
|
||||
|
||||
@@ -360,6 +370,12 @@ const isAutocompleteHistoryScope = (value: unknown): value is AutocompleteHistor
|
||||
value === 'global'
|
||||
);
|
||||
|
||||
const isOscNotificationMode = (value: unknown): value is OscNotificationMode => (
|
||||
value === 'off' ||
|
||||
value === 'unfocused' ||
|
||||
value === 'always'
|
||||
);
|
||||
|
||||
export const normalizeTerminalSettings = (
|
||||
settings?: Partial<TerminalSettings> | null,
|
||||
): TerminalSettings => {
|
||||
@@ -389,6 +405,9 @@ export const normalizeTerminalSettings = (
|
||||
autocompleteHistoryScope: isAutocompleteHistoryScope(settings?.autocompleteHistoryScope)
|
||||
? settings.autocompleteHistoryScope
|
||||
: DEFAULT_TERMINAL_SETTINGS.autocompleteHistoryScope,
|
||||
oscNotifications: isOscNotificationMode(settings?.oscNotifications)
|
||||
? settings.oscNotifications
|
||||
: DEFAULT_TERMINAL_SETTINGS.oscNotifications,
|
||||
};
|
||||
|
||||
// Migrate legacy 'canvas' renderer to 'dom' (canvas removed in xterm.js 6.0)
|
||||
@@ -500,6 +519,7 @@ const DEFAULT_TERMINAL_SETTINGS: TerminalSettings = {
|
||||
preserveSelectionOnInput: false, // Opt-in: keep selection alive when typing
|
||||
forcePromptNewLine: false, // Opt-in: keep the next shell prompt visually separated from unterminated final output lines
|
||||
osc52Clipboard: 'write-only', // OSC-52: allow remote programs to write clipboard by default
|
||||
oscNotifications: 'always', // Honor OSC 9/777/99 desktop notifications by default
|
||||
dynamicTabTitleMode: 'agent',
|
||||
rendererType: 'auto', // Auto-detect best renderer based on hardware
|
||||
hibernateHiddenTabs: false,
|
||||
|
||||
@@ -377,6 +377,13 @@ export const SETTINGS_SEARCH_CATALOG: readonly SettingsSearchEntry[] = [
|
||||
descriptionKey: "settings.terminal.behavior.dynamicTabTitle.desc",
|
||||
sectionKey: "settings.terminal.section.behavior",
|
||||
},
|
||||
{
|
||||
id: "terminal-osc-notifications",
|
||||
tab: "terminal",
|
||||
labelKey: "settings.terminal.behavior.oscNotifications",
|
||||
descriptionKey: "settings.terminal.behavior.oscNotifications.desc",
|
||||
sectionKey: "settings.terminal.section.behavior",
|
||||
},
|
||||
{
|
||||
id: "terminal-osc52-clipboard",
|
||||
tab: "terminal",
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import { getSftpArchiveKind, isExtractableArchive } from "./sftpArchive.ts";
|
||||
|
||||
test("SFTP extract menu matches compound archive suffixes", () => {
|
||||
assert.equal(getSftpArchiveKind("backup.tar.gz"), "tar.gz");
|
||||
assert.equal(getSftpArchiveKind("logs.tgz"), "tar.gz");
|
||||
assert.equal(getSftpArchiveKind("src.tar.bz2"), "tar.bz2");
|
||||
assert.equal(getSftpArchiveKind("app.zip"), "zip");
|
||||
assert.equal(getSftpArchiveKind("notes.txt.gz"), "gz");
|
||||
assert.equal(isExtractableArchive("/var/tmp/payload.tar.xz"), true);
|
||||
assert.equal(isExtractableArchive("readme.md"), false);
|
||||
assert.equal(isExtractableArchive(".zip"), false);
|
||||
});
|
||||
@@ -0,0 +1,32 @@
|
||||
const ARCHIVE_SUFFIXES: Array<{ kind: string; suffixes: string[] }> = [
|
||||
{ kind: "tar.gz", suffixes: [".tar.gz", ".tgz"] },
|
||||
{ kind: "tar.bz2", suffixes: [".tar.bz2", ".tbz2", ".tar.bzip2"] },
|
||||
{ kind: "tar.xz", suffixes: [".tar.xz", ".txz"] },
|
||||
{ kind: "tar.zst", suffixes: [".tar.zst", ".tzst"] },
|
||||
{ kind: "tar", suffixes: [".tar"] },
|
||||
{ kind: "zip", suffixes: [".zip"] },
|
||||
{ kind: "gz", suffixes: [".gz"] },
|
||||
{ kind: "bz2", suffixes: [".bz2"] },
|
||||
{ kind: "xz", suffixes: [".xz"] },
|
||||
];
|
||||
|
||||
function archiveBaseName(fileName: string): string {
|
||||
const normalized = String(fileName || "").replace(/\\/g, "/");
|
||||
const parts = normalized.split("/");
|
||||
return parts[parts.length - 1] || "";
|
||||
}
|
||||
|
||||
export function getSftpArchiveKind(fileName: string): string | null {
|
||||
const base = archiveBaseName(fileName).toLowerCase();
|
||||
if (!base) return null;
|
||||
for (const entry of ARCHIVE_SUFFIXES) {
|
||||
if (entry.suffixes.some((suffix) => base.endsWith(suffix) && base.length > suffix.length)) {
|
||||
return entry.kind;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function isExtractableArchive(fileName: string): boolean {
|
||||
return getSftpArchiveKind(fileName) != null;
|
||||
}
|
||||
@@ -0,0 +1,216 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import {
|
||||
DEFAULT_OSC_NOTIFICATION_TITLE,
|
||||
Osc99Assembler,
|
||||
OscNotificationLimiter,
|
||||
OscNotificationStreamScanner,
|
||||
parseOsc777Payload,
|
||||
parseOsc9Payload,
|
||||
resolveOscNotificationPresentation,
|
||||
sanitizeOscNotificationText,
|
||||
shouldShowOscDesktopNotification,
|
||||
} from "./terminalOscNotifications.ts";
|
||||
|
||||
test("parseOsc9Payload treats iTerm-style bodies as notifications", () => {
|
||||
assert.deepEqual(parseOsc9Payload("Codex turn complete"), {
|
||||
title: "",
|
||||
body: "Codex turn complete",
|
||||
protocol: "osc9",
|
||||
});
|
||||
assert.deepEqual(parseOsc9Payload(" approval requested "), {
|
||||
title: "",
|
||||
body: "approval requested",
|
||||
protocol: "osc9",
|
||||
});
|
||||
});
|
||||
|
||||
test("parseOsc9Payload ignores ConEmu OSC 9;n sequences", () => {
|
||||
assert.equal(parseOsc9Payload("4"), null);
|
||||
assert.equal(parseOsc9Payload("4;1;50"), null);
|
||||
assert.equal(parseOsc9Payload("3;/tmp"), null);
|
||||
assert.equal(parseOsc9Payload(""), null);
|
||||
assert.equal(parseOsc9Payload(" "), null);
|
||||
});
|
||||
|
||||
test("parseOsc9Payload keeps bodies that merely start with digits", () => {
|
||||
assert.deepEqual(parseOsc9Payload("4 tests failed"), {
|
||||
title: "",
|
||||
body: "4 tests failed",
|
||||
protocol: "osc9",
|
||||
});
|
||||
});
|
||||
|
||||
test("parseOsc777Payload handles notify title and body", () => {
|
||||
assert.deepEqual(parseOsc777Payload("notify;Nightly Tests;All suites passed"), {
|
||||
title: "Nightly Tests",
|
||||
body: "All suites passed",
|
||||
protocol: "osc777",
|
||||
});
|
||||
assert.deepEqual(parseOsc777Payload("notify;Deploy;Production;complete"), {
|
||||
title: "Deploy",
|
||||
body: "Production;complete",
|
||||
protocol: "osc777",
|
||||
});
|
||||
assert.deepEqual(parseOsc777Payload("notify;Only body"), {
|
||||
title: "",
|
||||
body: "Only body",
|
||||
protocol: "osc777",
|
||||
});
|
||||
assert.equal(parseOsc777Payload("sleep;1"), null);
|
||||
assert.equal(parseOsc777Payload("notify;"), null);
|
||||
});
|
||||
|
||||
test("Osc99Assembler emits simple kitty notifications", () => {
|
||||
const assembler = new Osc99Assembler();
|
||||
assert.deepEqual(assembler.consume(";Hello world"), {
|
||||
title: "Hello world",
|
||||
body: "",
|
||||
protocol: "osc99",
|
||||
});
|
||||
});
|
||||
|
||||
test("Osc99Assembler assembles chunked title and body", () => {
|
||||
const assembler = new Osc99Assembler();
|
||||
assert.equal(assembler.consume("i=1:d=0;Hello world"), null);
|
||||
assert.deepEqual(assembler.consume("i=1:p=body;This is cool"), {
|
||||
title: "Hello world",
|
||||
body: "This is cool",
|
||||
protocol: "osc99",
|
||||
});
|
||||
});
|
||||
|
||||
test("Osc99Assembler preserves whitespace across chunk boundaries", () => {
|
||||
const assembler = new Osc99Assembler();
|
||||
assert.equal(assembler.consume("i=1:d=0;Hello "), null);
|
||||
assert.deepEqual(assembler.consume("i=1;world"), {
|
||||
title: "Hello world",
|
||||
body: "",
|
||||
protocol: "osc99",
|
||||
});
|
||||
});
|
||||
|
||||
test("Osc99Assembler decodes base64 payloads", () => {
|
||||
const assembler = new Osc99Assembler();
|
||||
assert.deepEqual(assembler.consume("p=body:e=1;SGVsbG8="), {
|
||||
title: "",
|
||||
body: "Hello",
|
||||
protocol: "osc99",
|
||||
});
|
||||
});
|
||||
|
||||
test("Osc99Assembler ignores close metadata", () => {
|
||||
const assembler = new Osc99Assembler();
|
||||
assert.equal(assembler.consume("i=1:d=0;pending"), null);
|
||||
assert.equal(assembler.consume("i=1:p=close;"), null);
|
||||
assert.deepEqual(assembler.consume("i=1:p=body;too late"), {
|
||||
title: "",
|
||||
body: "too late",
|
||||
protocol: "osc99",
|
||||
});
|
||||
});
|
||||
|
||||
test("resolveOscNotificationPresentation prefers explicit title and body", () => {
|
||||
assert.deepEqual(
|
||||
resolveOscNotificationPresentation({
|
||||
title: "Codex",
|
||||
body: "Turn complete",
|
||||
protocol: "osc9",
|
||||
}, "prod-box"),
|
||||
{ title: "Codex", body: "Turn complete" },
|
||||
);
|
||||
assert.deepEqual(
|
||||
resolveOscNotificationPresentation({
|
||||
title: "",
|
||||
body: "Turn complete",
|
||||
protocol: "osc9",
|
||||
}, "prod-box"),
|
||||
{ title: "prod-box", body: "Turn complete" },
|
||||
);
|
||||
assert.deepEqual(
|
||||
resolveOscNotificationPresentation({
|
||||
title: "Hello world",
|
||||
body: "",
|
||||
protocol: "osc99",
|
||||
}, "prod-box"),
|
||||
{ title: "prod-box", body: "Hello world" },
|
||||
);
|
||||
assert.equal(
|
||||
resolveOscNotificationPresentation({
|
||||
title: "",
|
||||
body: "",
|
||||
protocol: "osc9",
|
||||
}).title,
|
||||
DEFAULT_OSC_NOTIFICATION_TITLE,
|
||||
);
|
||||
});
|
||||
|
||||
test("sanitizeOscNotificationText strips controls and truncates", () => {
|
||||
assert.equal(sanitizeOscNotificationText("hello\u0007world", 20), "helloworld");
|
||||
assert.equal(sanitizeOscNotificationText(" a b ", 20), "a b");
|
||||
assert.equal(sanitizeOscNotificationText("abcdefghij", 6), "abcde…");
|
||||
});
|
||||
|
||||
test("shouldShowOscDesktopNotification honors mode and focus", () => {
|
||||
assert.equal(shouldShowOscDesktopNotification("off", { windowFocused: false, sessionFocused: false }), false);
|
||||
assert.equal(shouldShowOscDesktopNotification("always", { windowFocused: true, sessionFocused: true }), true);
|
||||
assert.equal(shouldShowOscDesktopNotification("unfocused", { windowFocused: true, sessionFocused: true }), false);
|
||||
assert.equal(shouldShowOscDesktopNotification("unfocused", { windowFocused: true, sessionFocused: false }), true);
|
||||
assert.equal(shouldShowOscDesktopNotification("unfocused", { windowFocused: false, sessionFocused: true }), true);
|
||||
assert.equal(shouldShowOscDesktopNotification(undefined, { windowFocused: true, sessionFocused: true }), true);
|
||||
});
|
||||
|
||||
test("OscNotificationStreamScanner extracts split OSC 9 sequences and leaves other text", () => {
|
||||
const scanner = new OscNotificationStreamScanner();
|
||||
const first = scanner.consume("hello\x1b]9;Codex turn");
|
||||
assert.deepEqual(first.notifications, []);
|
||||
assert.equal(first.remainder, "hello");
|
||||
const second = scanner.consume(" complete\x07world");
|
||||
assert.deepEqual(second.notifications, [{
|
||||
title: "",
|
||||
body: "Codex turn complete",
|
||||
protocol: "osc9",
|
||||
}]);
|
||||
assert.equal(second.remainder, "world");
|
||||
});
|
||||
|
||||
test("OscNotificationStreamScanner keeps non-notification OSC in the remainder", () => {
|
||||
const scanner = new OscNotificationStreamScanner();
|
||||
const result = scanner.consume("pre\x1b]7;file://host/tmp\x07post");
|
||||
assert.deepEqual(result.notifications, []);
|
||||
assert.equal(result.remainder, "pre\x1b]7;file://host/tmp\x07post");
|
||||
});
|
||||
|
||||
test("OscNotificationStreamScanner flushes an unfinished prefix", () => {
|
||||
const scanner = new OscNotificationStreamScanner();
|
||||
scanner.consume("\x1b]9;pending");
|
||||
assert.equal(scanner.flush(), "\x1b]9;pending");
|
||||
assert.equal(scanner.flush(), "");
|
||||
});
|
||||
|
||||
test("OscNotificationStreamScanner aborts a notification OSC at a non-ST escape", () => {
|
||||
const scanner = new OscNotificationStreamScanner();
|
||||
const result = scanner.consume("hello\x1b]9;bad\x1b[31mRED\x07world");
|
||||
assert.deepEqual(result.notifications, []);
|
||||
assert.equal(result.remainder, "hello\x1b[31mRED\x07world");
|
||||
});
|
||||
|
||||
test("OscNotificationStreamScanner aborts a notification OSC on CAN or SUB", () => {
|
||||
const scanner = new OscNotificationStreamScanner();
|
||||
const can = scanner.consume("pre\x1b]9;bad\x18visible\x07");
|
||||
assert.deepEqual(can.notifications, []);
|
||||
assert.equal(can.remainder, "previsible\x07");
|
||||
const sub = scanner.consume("\x1b]777;notify;x;y\x1aafter");
|
||||
assert.deepEqual(sub.notifications, []);
|
||||
assert.equal(sub.remainder, "after");
|
||||
});
|
||||
|
||||
test("OscNotificationLimiter rate-limits a noisy session", () => {
|
||||
const limiter = new OscNotificationLimiter(1_000, 2, 100);
|
||||
assert.equal(limiter.allow("s1", 1_000), true);
|
||||
assert.equal(limiter.allow("s1", 1_050), false);
|
||||
assert.equal(limiter.allow("s1", 1_200), true);
|
||||
assert.equal(limiter.allow("s1", 1_400), false);
|
||||
assert.equal(limiter.allow("s2", 1_400), true);
|
||||
});
|
||||
@@ -0,0 +1,359 @@
|
||||
export type OscNotificationProtocol = 'osc9' | 'osc777' | 'osc99';
|
||||
|
||||
export type OscNotification = {
|
||||
title: string;
|
||||
body: string;
|
||||
protocol: OscNotificationProtocol;
|
||||
};
|
||||
|
||||
export const DEFAULT_OSC_NOTIFICATION_TITLE = 'Netcatty';
|
||||
export const OSC_NOTIFICATION_TITLE_MAX = 120;
|
||||
export const OSC_NOTIFICATION_BODY_MAX = 500;
|
||||
|
||||
const CONEMU_OSC9_COMMAND = /^\d+(?:;|$)/;
|
||||
const OSC_NOTIFICATION_CARRY_MAX = 8192;
|
||||
const OSC99_PENDING_TTL_MS = 10_000;
|
||||
const OSC99_PENDING_MAX = 16;
|
||||
|
||||
const DEFAULT_LIMIT_WINDOW_MS = 10_000;
|
||||
const DEFAULT_LIMIT_MAX = 4;
|
||||
const DEFAULT_LIMIT_MIN_GAP_MS = 400;
|
||||
|
||||
const isForbiddenOscNotificationChar = (code: number): boolean => (
|
||||
code <= 8
|
||||
|| code === 0x0b
|
||||
|| code === 0x0c
|
||||
|| (code >= 0x0e && code <= 0x1f)
|
||||
|| code === 0x7f
|
||||
);
|
||||
|
||||
export function sanitizeOscNotificationText(value: string, max: number): string {
|
||||
if (typeof value !== 'string' || max <= 0) return '';
|
||||
let cleaned = '';
|
||||
for (const char of value) {
|
||||
if (!isForbiddenOscNotificationChar(char.charCodeAt(0))) cleaned += char;
|
||||
}
|
||||
cleaned = cleaned.replace(/\s+/g, ' ').trim();
|
||||
if (cleaned.length <= max) return cleaned;
|
||||
return `${cleaned.slice(0, Math.max(0, max - 1)).trimEnd()}…`;
|
||||
}
|
||||
|
||||
export function resolveOscNotificationPresentation(
|
||||
notification: OscNotification,
|
||||
fallbackTitle = DEFAULT_OSC_NOTIFICATION_TITLE,
|
||||
): { title: string; body: string } {
|
||||
const fallback = sanitizeOscNotificationText(fallbackTitle, OSC_NOTIFICATION_TITLE_MAX)
|
||||
|| DEFAULT_OSC_NOTIFICATION_TITLE;
|
||||
const title = sanitizeOscNotificationText(notification.title, OSC_NOTIFICATION_TITLE_MAX);
|
||||
const body = sanitizeOscNotificationText(notification.body, OSC_NOTIFICATION_BODY_MAX);
|
||||
if (title && body) return { title, body };
|
||||
if (body) return { title: fallback, body };
|
||||
if (title) return { title: fallback, body: title };
|
||||
return { title: fallback, body: '' };
|
||||
}
|
||||
|
||||
export function shouldShowOscDesktopNotification(
|
||||
mode: 'off' | 'unfocused' | 'always' | undefined,
|
||||
context: { windowFocused: boolean; sessionFocused: boolean },
|
||||
): boolean {
|
||||
if (mode === 'off') return false;
|
||||
if (mode === 'unfocused') return !context.windowFocused || !context.sessionFocused;
|
||||
return true;
|
||||
}
|
||||
|
||||
export function parseOsc9Payload(data: string): OscNotification | null {
|
||||
if (typeof data !== 'string') return null;
|
||||
const payload = data.trim();
|
||||
if (!payload) return null;
|
||||
// ConEmu/Windows Terminal also use OSC 9;n (especially 9;4 progress).
|
||||
// Those must not become desktop notifications.
|
||||
if (CONEMU_OSC9_COMMAND.test(payload)) return null;
|
||||
return {
|
||||
title: '',
|
||||
body: payload,
|
||||
protocol: 'osc9',
|
||||
};
|
||||
}
|
||||
|
||||
export function parseOsc777Payload(data: string): OscNotification | null {
|
||||
if (typeof data !== 'string') return null;
|
||||
const match = data.match(/^notify;(.*)$/i);
|
||||
if (!match) return null;
|
||||
const rest = match[1] ?? '';
|
||||
const separator = rest.indexOf(';');
|
||||
if (separator < 0) {
|
||||
const body = rest.trim();
|
||||
if (!body) return null;
|
||||
return { title: '', body, protocol: 'osc777' };
|
||||
}
|
||||
const title = rest.slice(0, separator).trim();
|
||||
const body = rest.slice(separator + 1).trim();
|
||||
if (!title && !body) return null;
|
||||
return { title, body, protocol: 'osc777' };
|
||||
}
|
||||
|
||||
type Osc99Pending = {
|
||||
title: string;
|
||||
body: string;
|
||||
updatedAt: number;
|
||||
};
|
||||
|
||||
const decodeOsc99Payload = (payload: string, encoded: boolean): string => {
|
||||
if (!encoded) return payload;
|
||||
try {
|
||||
const binary = atob(payload);
|
||||
const bytes = Uint8Array.from(binary, (char) => char.charCodeAt(0));
|
||||
return new TextDecoder().decode(bytes);
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
};
|
||||
|
||||
export class Osc99Assembler {
|
||||
private readonly pending = new Map<string, Osc99Pending>();
|
||||
|
||||
consume(data: string, now = Date.now()): OscNotification | null {
|
||||
if (typeof data !== 'string') return null;
|
||||
this.prune(now);
|
||||
|
||||
const separator = data.indexOf(';');
|
||||
const metadata = separator < 0 ? '' : data.slice(0, separator);
|
||||
const rawPayload = separator < 0 ? data : data.slice(separator + 1);
|
||||
const fields = parseOsc99Metadata(metadata);
|
||||
if (fields.close) {
|
||||
if (fields.id) this.pending.delete(fields.id);
|
||||
return null;
|
||||
}
|
||||
|
||||
const payload = decodeOsc99Payload(rawPayload, fields.encoded);
|
||||
const current = this.pending.get(fields.id) ?? { title: '', body: '', updatedAt: now };
|
||||
if (fields.part === 'body') current.body += payload;
|
||||
else current.title += payload;
|
||||
current.updatedAt = now;
|
||||
|
||||
if (!fields.done) {
|
||||
this.pending.set(fields.id, current);
|
||||
this.evictOldest();
|
||||
return null;
|
||||
}
|
||||
|
||||
this.pending.delete(fields.id);
|
||||
if (!current.title.trim() && !current.body.trim()) return null;
|
||||
return {
|
||||
title: current.title,
|
||||
body: current.body,
|
||||
protocol: 'osc99',
|
||||
};
|
||||
}
|
||||
|
||||
private prune(now: number): void {
|
||||
for (const [id, entry] of this.pending) {
|
||||
if (now - entry.updatedAt > OSC99_PENDING_TTL_MS) this.pending.delete(id);
|
||||
}
|
||||
}
|
||||
|
||||
private evictOldest(): void {
|
||||
while (this.pending.size > OSC99_PENDING_MAX) {
|
||||
const oldest = this.pending.keys().next().value;
|
||||
if (oldest === undefined) return;
|
||||
this.pending.delete(oldest);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function parseOsc99Metadata(metadata: string): {
|
||||
id: string;
|
||||
part: 'title' | 'body';
|
||||
done: boolean;
|
||||
encoded: boolean;
|
||||
close: boolean;
|
||||
} {
|
||||
const fields = {
|
||||
id: '',
|
||||
part: 'title' as 'title' | 'body',
|
||||
done: true,
|
||||
encoded: false,
|
||||
close: false,
|
||||
};
|
||||
if (!metadata.trim()) return fields;
|
||||
|
||||
for (const token of metadata.split(':')) {
|
||||
const eq = token.indexOf('=');
|
||||
if (eq <= 0) continue;
|
||||
const key = token.slice(0, eq).trim();
|
||||
const value = token.slice(eq + 1).trim();
|
||||
if (key === 'i') fields.id = value;
|
||||
else if (key === 'p' && (value === 'title' || value === 'body')) fields.part = value;
|
||||
else if (key === 'd') fields.done = value !== '0';
|
||||
else if (key === 'e') fields.encoded = value === '1';
|
||||
else if ((key === 'p' || key === 'a') && value === 'close') fields.close = true;
|
||||
}
|
||||
return fields;
|
||||
}
|
||||
|
||||
type OscScanResult =
|
||||
| { incomplete: true }
|
||||
| { incomplete: false; notification: boolean; aborted?: boolean; id: number; payload: string; end: number };
|
||||
|
||||
const isNotificationOscId = (id: number): boolean => id === 9 || id === 777 || id === 99;
|
||||
|
||||
const readOscAt = (input: string, start: number): OscScanResult => {
|
||||
if (input[start] !== '\x1b') return { incomplete: false, notification: false, id: -1, payload: '', end: start + 1 };
|
||||
if (start + 1 >= input.length) return { incomplete: true };
|
||||
if (input[start + 1] !== ']') {
|
||||
return { incomplete: false, notification: false, id: -1, payload: '', end: start + 1 };
|
||||
}
|
||||
|
||||
let index = start + 2;
|
||||
if (index >= input.length) return { incomplete: true };
|
||||
|
||||
let id = 0;
|
||||
let sawDigit = false;
|
||||
while (index < input.length) {
|
||||
const code = input.charCodeAt(index);
|
||||
if (code >= 48 && code <= 57) {
|
||||
sawDigit = true;
|
||||
id = id * 10 + (code - 48);
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
break;
|
||||
}
|
||||
if (!sawDigit) {
|
||||
return { incomplete: index >= input.length, notification: false, id: -1, payload: '', end: index };
|
||||
}
|
||||
if (index >= input.length) return { incomplete: true };
|
||||
|
||||
if (input[index] === ';') index += 1;
|
||||
|
||||
const payloadStart = index;
|
||||
while (index < input.length) {
|
||||
if (input[index] === '\x07') {
|
||||
return {
|
||||
incomplete: false,
|
||||
notification: isNotificationOscId(id),
|
||||
id,
|
||||
payload: input.slice(payloadStart, index),
|
||||
end: index + 1,
|
||||
};
|
||||
}
|
||||
if (input[index] === '\x18' || input[index] === '\x1a') {
|
||||
return {
|
||||
incomplete: false,
|
||||
notification: false,
|
||||
aborted: isNotificationOscId(id),
|
||||
id,
|
||||
payload: '',
|
||||
end: index + 1,
|
||||
};
|
||||
}
|
||||
if (input[index] === '\x1b') {
|
||||
if (index + 1 >= input.length) {
|
||||
return isNotificationOscId(id) ? { incomplete: true } : {
|
||||
incomplete: false,
|
||||
notification: false,
|
||||
id,
|
||||
payload: '',
|
||||
end: index,
|
||||
};
|
||||
}
|
||||
if (input[index + 1] === '\\') {
|
||||
return {
|
||||
incomplete: false,
|
||||
notification: isNotificationOscId(id),
|
||||
id,
|
||||
payload: input.slice(payloadStart, index),
|
||||
end: index + 2,
|
||||
};
|
||||
}
|
||||
// xterm aborts an OSC at a non-ST ESC and then processes that ESC.
|
||||
return { incomplete: false, notification: false, aborted: isNotificationOscId(id), id, payload: '', end: index };
|
||||
}
|
||||
index += 1;
|
||||
}
|
||||
|
||||
return isNotificationOscId(id) ? { incomplete: true } : {
|
||||
incomplete: false,
|
||||
notification: false,
|
||||
id,
|
||||
payload: '',
|
||||
end: input.length,
|
||||
};
|
||||
};
|
||||
|
||||
export class OscNotificationStreamScanner {
|
||||
private carry = '';
|
||||
private readonly assembler = new Osc99Assembler();
|
||||
|
||||
consume(chunk: string): { notifications: OscNotification[]; remainder: string } {
|
||||
const input = this.carry + (typeof chunk === 'string' ? chunk : '');
|
||||
this.carry = '';
|
||||
const notifications: OscNotification[] = [];
|
||||
let remainder = '';
|
||||
let index = 0;
|
||||
|
||||
while (index < input.length) {
|
||||
const esc = input.indexOf('\x1b', index);
|
||||
if (esc < 0) {
|
||||
remainder += input.slice(index);
|
||||
break;
|
||||
}
|
||||
remainder += input.slice(index, esc);
|
||||
const scanned = readOscAt(input, esc);
|
||||
if (scanned.incomplete) {
|
||||
const leftover = input.slice(esc);
|
||||
if (leftover.length > OSC_NOTIFICATION_CARRY_MAX) {
|
||||
remainder += leftover;
|
||||
} else {
|
||||
this.carry = leftover;
|
||||
}
|
||||
break;
|
||||
}
|
||||
if (scanned.notification) {
|
||||
const notification = scanned.id === 9
|
||||
? parseOsc9Payload(scanned.payload)
|
||||
: scanned.id === 777
|
||||
? parseOsc777Payload(scanned.payload)
|
||||
: this.assembler.consume(scanned.payload);
|
||||
if (notification) notifications.push(notification);
|
||||
} else if (!scanned.aborted) {
|
||||
remainder += input.slice(esc, scanned.end);
|
||||
}
|
||||
index = scanned.end;
|
||||
}
|
||||
|
||||
return { notifications, remainder };
|
||||
}
|
||||
|
||||
flush(): string {
|
||||
const leftover = this.carry;
|
||||
this.carry = '';
|
||||
return leftover;
|
||||
}
|
||||
}
|
||||
|
||||
export class OscNotificationLimiter {
|
||||
private readonly stamps = new Map<string, number[]>();
|
||||
|
||||
constructor(
|
||||
private readonly windowMs = DEFAULT_LIMIT_WINDOW_MS,
|
||||
private readonly max = DEFAULT_LIMIT_MAX,
|
||||
private readonly minGapMs = DEFAULT_LIMIT_MIN_GAP_MS,
|
||||
) {}
|
||||
|
||||
allow(key: string, now = Date.now()): boolean {
|
||||
const recent = (this.stamps.get(key) ?? []).filter((stamp) => now - stamp < this.windowMs);
|
||||
const last = recent[recent.length - 1];
|
||||
if (last !== undefined && now - last < this.minGapMs) {
|
||||
this.stamps.set(key, recent);
|
||||
return false;
|
||||
}
|
||||
if (recent.length >= this.max) {
|
||||
this.stamps.set(key, recent);
|
||||
return false;
|
||||
}
|
||||
recent.push(now);
|
||||
this.stamps.set(key, recent);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -106,6 +106,23 @@ test("normalizeTerminalSettings falls back for unsupported dynamic tab title mod
|
||||
);
|
||||
});
|
||||
|
||||
test("normalizeTerminalSettings enables OSC desktop notifications by default", () => {
|
||||
assert.equal(normalizeTerminalSettings().oscNotifications, "always");
|
||||
});
|
||||
|
||||
test("normalizeTerminalSettings preserves supported OSC notification modes", () => {
|
||||
assert.equal(normalizeTerminalSettings({ oscNotifications: "off" }).oscNotifications, "off");
|
||||
assert.equal(normalizeTerminalSettings({ oscNotifications: "unfocused" }).oscNotifications, "unfocused");
|
||||
assert.equal(normalizeTerminalSettings({ oscNotifications: "always" }).oscNotifications, "always");
|
||||
});
|
||||
|
||||
test("normalizeTerminalSettings falls back for unsupported OSC notification modes", () => {
|
||||
assert.equal(
|
||||
normalizeTerminalSettings({ oscNotifications: "legacy" as never }).oscNotifications,
|
||||
"always",
|
||||
);
|
||||
});
|
||||
|
||||
test("normalizeTerminalSettings enables font smoothing by default", () => {
|
||||
assert.equal(normalizeTerminalSettings().fontSmoothing, true);
|
||||
});
|
||||
|
||||
@@ -272,15 +272,19 @@ function resolveWindowsShimToNativeExe(command, platform = process.platform) {
|
||||
return null;
|
||||
}
|
||||
|
||||
function prepareCommandForSpawn(command, args) {
|
||||
function prepareCommandForSpawn(command, args, options = {}) {
|
||||
const spawnArgs = Array.isArray(args) ? args : [];
|
||||
if (!shouldUseShellForCommand(command)) {
|
||||
return { command, args: spawnArgs, shell: false };
|
||||
}
|
||||
|
||||
const nativeExePath = resolveWindowsShimToNativeExe(command);
|
||||
if (nativeExePath) {
|
||||
return { command: nativeExePath, args: spawnArgs, shell: false };
|
||||
// Cursor's Windows installer .cmd launches node.exe + index.js. Unwrapping
|
||||
// to the first quoted .exe would drop the script and run node with CLI args.
|
||||
if (options.unwrapNativeExe !== false) {
|
||||
const nativeExePath = resolveWindowsShimToNativeExe(command);
|
||||
if (nativeExePath) {
|
||||
return { command: nativeExePath, args: spawnArgs, shell: false };
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
|
||||
@@ -242,6 +242,45 @@ test("resolveWindowsShimToNativeExe resolves npm .cmd shim to native exe", () =>
|
||||
}
|
||||
});
|
||||
|
||||
test("prepareCommandForSpawn can skip native exe unwrap for node+script shims", () => {
|
||||
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-spawn-no-unwrap-"));
|
||||
try {
|
||||
const shimPath = path.join(tmp, "cursor-agent.cmd");
|
||||
const nodeExe = path.join(tmp, "versions", "2026.06.01-abc", "node.exe");
|
||||
fs.mkdirSync(path.dirname(nodeExe), { recursive: true });
|
||||
fs.writeFileSync(nodeExe, "", "utf8");
|
||||
fs.writeFileSync(
|
||||
shimPath,
|
||||
'@ECHO off\r\n"%~dp0\\versions\\2026.06.01-abc\\node.exe" "%~dp0\\versions\\2026.06.01-abc\\index.js" %*\r\n',
|
||||
"utf8",
|
||||
);
|
||||
|
||||
assert.equal(resolveWindowsShimToNativeExe(shimPath, "win32"), nodeExe);
|
||||
|
||||
const unwrapped = prepareCommandForSpawn(shimPath, ["status", "--format", "json"]);
|
||||
const wrapped = prepareCommandForSpawn(shimPath, ["status", "--format", "json"], {
|
||||
unwrapNativeExe: false,
|
||||
});
|
||||
if (process.platform === "win32") {
|
||||
assert.deepEqual(unwrapped, {
|
||||
command: nodeExe,
|
||||
args: ["status", "--format", "json"],
|
||||
shell: false,
|
||||
});
|
||||
assert.deepEqual(wrapped, {
|
||||
command: buildWindowsShellCommandLine(shimPath, ["status", "--format", "json"]),
|
||||
args: [],
|
||||
shell: true,
|
||||
});
|
||||
} else {
|
||||
assert.equal(unwrapped.shell, false);
|
||||
assert.equal(wrapped.shell, false);
|
||||
}
|
||||
} finally {
|
||||
fs.rmSync(tmp, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("prepareCommandForSpawn resolves Windows cmd shim to native exe with shell:false", () => {
|
||||
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-spawn-native-"));
|
||||
try {
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
/**
|
||||
* Extract the payload of an SSE `data:` field.
|
||||
*
|
||||
* The WHATWG EventSource spec treats the space after the colon as optional
|
||||
* and strips at most one leading U+0020. Older AxonHub (0.9) and some
|
||||
* intranet OpenAI-compat proxies emit `data:{json}` with no space, which
|
||||
* a `data: ` prefix check silently drops (issue #3020).
|
||||
*
|
||||
* @param {unknown} line
|
||||
* @returns {string | null}
|
||||
*/
|
||||
function extractSseDataPayload(line) {
|
||||
const trimmed = typeof line === "string" ? line.trim() : "";
|
||||
if (!trimmed.startsWith("data:")) return null;
|
||||
const payload = trimmed.slice("data:".length);
|
||||
return payload.startsWith(" ") ? payload.slice(1) : payload;
|
||||
}
|
||||
|
||||
module.exports = { extractSseDataPayload };
|
||||
@@ -0,0 +1,26 @@
|
||||
const test = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const { extractSseDataPayload } = require("./sseDataLine.cjs");
|
||||
|
||||
test("extractSseDataPayload accepts data: with or without the optional space", () => {
|
||||
assert.equal(extractSseDataPayload('data: {"a":1}'), '{"a":1}');
|
||||
assert.equal(extractSseDataPayload('data:{"a":1}'), '{"a":1}');
|
||||
assert.equal(extractSseDataPayload(' data:{"a":1} \r'), '{"a":1}');
|
||||
assert.equal(extractSseDataPayload("data: [DONE]"), "[DONE]");
|
||||
assert.equal(extractSseDataPayload("data:[DONE]"), "[DONE]");
|
||||
});
|
||||
|
||||
test("extractSseDataPayload strips only one leading space after the colon", () => {
|
||||
assert.equal(extractSseDataPayload("data: keep"), " keep");
|
||||
assert.equal(extractSseDataPayload("data:"), "");
|
||||
assert.equal(extractSseDataPayload("data: "), "");
|
||||
});
|
||||
|
||||
test("extractSseDataPayload ignores non-data SSE lines", () => {
|
||||
assert.equal(extractSseDataPayload(""), null);
|
||||
assert.equal(extractSseDataPayload(": comment"), null);
|
||||
assert.equal(extractSseDataPayload("event: message"), null);
|
||||
assert.equal(extractSseDataPayload("DATA: foo"), null);
|
||||
assert.equal(extractSseDataPayload(null), null);
|
||||
assert.equal(extractSseDataPayload(undefined), null);
|
||||
});
|
||||
@@ -51,6 +51,7 @@ const {
|
||||
} = require("./ai/shellUtils.cjs");
|
||||
|
||||
const { detectClaudeAuthPresence, expandHomePath } = require("./ai/claudeAuth.cjs");
|
||||
const { extractSseDataPayload } = require("./ai/sseDataLine.cjs");
|
||||
|
||||
const CLAUDE_AUTH_HELP_MESSAGE =
|
||||
"Claude Code has no usable authentication. Open Settings -> AI -> Claude Code and set a Config directory (point it at a folder where you've run `claude` login) or add an ANTHROPIC_API_KEY under Environment variables. Alternatively, run `claude` in a terminal to log in.";
|
||||
@@ -520,6 +521,13 @@ function safeReadJson(filePath) {
|
||||
}
|
||||
}
|
||||
|
||||
// Hard cap for a single model HTTP stream. Matches Catty's 30-minute floor so
|
||||
// long reasoning can finish; runaway bodies are still bounded by time + size.
|
||||
const DEFAULT_AI_STREAM_TOTAL_TIMEOUT_MS = 30 * 60 * 1000;
|
||||
// Abort only after this much silence. Thinking tokens re-arm the timer, so an
|
||||
// active reasoning stream is not cut off at two minutes (issue #3051).
|
||||
const DEFAULT_AI_STREAM_IDLE_TIMEOUT_MS = 2 * 60 * 1000;
|
||||
|
||||
/**
|
||||
* Start a streaming HTTP request. The returned promise resolves as soon as
|
||||
* the HTTP response headers arrive (with { statusCode, statusText }) so the
|
||||
@@ -557,18 +565,29 @@ async function streamRequest(url, options, event, requestId, skipTLS) {
|
||||
const parsedUrl = new URL(url);
|
||||
// Register cancellation before any await so Stop during PAC/proxy lookup works.
|
||||
const controller = new AbortController();
|
||||
const totalTimeoutMs = Math.max(1, Number(options.totalTimeoutMs) || 120_000);
|
||||
const totalTimeoutMs = Math.max(1, Number(options.totalTimeoutMs) || DEFAULT_AI_STREAM_TOTAL_TIMEOUT_MS);
|
||||
const idleTimeoutMs = Math.max(1, Number(options.idleTimeoutMs) || DEFAULT_AI_STREAM_IDLE_TIMEOUT_MS);
|
||||
const maxErrorBodyBytes = Math.max(1, Number(options.maxErrorBodyBytes) || 64 * 1024);
|
||||
let lifecycleFinished = false;
|
||||
let idleTimer;
|
||||
const finishLifecycle = () => {
|
||||
if (lifecycleFinished) return;
|
||||
lifecycleFinished = true;
|
||||
clearTimeout(totalTimer);
|
||||
clearTimeout(idleTimer);
|
||||
activeStreams.delete(requestId);
|
||||
};
|
||||
const noteStreamActivity = () => {
|
||||
if (lifecycleFinished) return;
|
||||
clearTimeout(idleTimer);
|
||||
idleTimer = setTimeout(() => {
|
||||
controller.abort(new Error(`AI stream idle deadline exceeded after ${idleTimeoutMs} ms`));
|
||||
}, idleTimeoutMs);
|
||||
};
|
||||
const totalTimer = setTimeout(() => {
|
||||
controller.abort(new Error(`AI stream total deadline exceeded after ${totalTimeoutMs} ms`));
|
||||
}, totalTimeoutMs);
|
||||
noteStreamActivity();
|
||||
activeStreams.set(requestId, controller);
|
||||
if (controller.signal.aborted) {
|
||||
finishLifecycle();
|
||||
@@ -664,6 +683,7 @@ async function streamRequest(url, options, event, requestId, skipTLS) {
|
||||
res.destroy?.();
|
||||
return;
|
||||
}
|
||||
noteStreamActivity();
|
||||
// Decode the response as one continuous UTF-8 stream. Calling
|
||||
// Buffer#toString() on each network chunk corrupts multi-byte
|
||||
// characters when a chunk boundary falls in the middle of one.
|
||||
@@ -677,6 +697,7 @@ async function streamRequest(url, options, event, requestId, skipTLS) {
|
||||
let errorBodyBytes = 0;
|
||||
res.on("data", (chunk) => {
|
||||
if (streamFinished) return;
|
||||
noteStreamActivity();
|
||||
const text = chunk.toString();
|
||||
errorBodyBytes += Buffer.byteLength(text);
|
||||
if (errorBodyBytes > maxErrorBodyBytes) {
|
||||
@@ -717,6 +738,7 @@ async function streamRequest(url, options, event, requestId, skipTLS) {
|
||||
const MAX_BUFFER_SIZE = 10 * 1024 * 1024; // 10MB safety limit
|
||||
|
||||
res.on("data", (chunk) => {
|
||||
noteStreamActivity();
|
||||
const previousBufferLength = buffer.length;
|
||||
buffer += chunk.toString();
|
||||
// Guard against unbounded buffer growth
|
||||
@@ -731,14 +753,11 @@ async function streamRequest(url, options, event, requestId, skipTLS) {
|
||||
const line = buffer.slice(consumedUntil, newlineIndex);
|
||||
consumedUntil = newlineIndex + 1;
|
||||
searchFrom = consumedUntil;
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed) continue;
|
||||
|
||||
// Forward raw SSE data line to renderer
|
||||
if (trimmed.startsWith("data: ")) {
|
||||
const payload = extractSseDataPayload(line);
|
||||
if (payload != null) {
|
||||
safeSend(event.sender, "netcatty:ai:stream:data", {
|
||||
requestId,
|
||||
data: trimmed.slice(6),
|
||||
data: payload,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -748,10 +767,11 @@ async function streamRequest(url, options, event, requestId, skipTLS) {
|
||||
res.on("end", () => {
|
||||
if (streamFinished) return;
|
||||
// Flush any remaining buffer
|
||||
if (buffer.trim().startsWith("data: ")) {
|
||||
const trailingPayload = extractSseDataPayload(buffer);
|
||||
if (trailingPayload != null) {
|
||||
safeSend(event.sender, "netcatty:ai:stream:data", {
|
||||
requestId,
|
||||
data: buffer.trim().slice(6),
|
||||
data: trailingPayload,
|
||||
});
|
||||
}
|
||||
safeSend(event.sender, "netcatty:ai:stream:end", { requestId });
|
||||
@@ -987,5 +1007,7 @@ module.exports = {
|
||||
buildExternalAgentContextualPrompt,
|
||||
_streamRequestForTests: streamRequest,
|
||||
_getActiveStreamCountForTests: () => activeStreams.size,
|
||||
DEFAULT_AI_STREAM_TOTAL_TIMEOUT_MS,
|
||||
DEFAULT_AI_STREAM_IDLE_TIMEOUT_MS,
|
||||
getExternalMcpController: () => externalMcpController,
|
||||
};
|
||||
@@ -298,6 +298,17 @@ test("non-2xx streaming responses are bounded and actively terminated", async ()
|
||||
}
|
||||
});
|
||||
|
||||
test("AI stream default total timeout is long enough for extended reasoning", () => {
|
||||
const { bridge, restore } = loadBridgeWithMocks();
|
||||
try {
|
||||
assert.equal(bridge.DEFAULT_AI_STREAM_IDLE_TIMEOUT_MS, 120_000);
|
||||
assert.equal(bridge.DEFAULT_AI_STREAM_TOTAL_TIMEOUT_MS, 30 * 60 * 1000);
|
||||
assert.ok(bridge.DEFAULT_AI_STREAM_TOTAL_TIMEOUT_MS > bridge.DEFAULT_AI_STREAM_IDLE_TIMEOUT_MS);
|
||||
} finally {
|
||||
restore();
|
||||
}
|
||||
});
|
||||
|
||||
test("streaming requests enforce a total deadline even while bytes keep arriving", async () => {
|
||||
let requestClosed = false;
|
||||
let resolveRequestClosed;
|
||||
@@ -347,6 +358,163 @@ test("streaming requests enforce a total deadline even while bytes keep arriving
|
||||
}
|
||||
});
|
||||
|
||||
test("streaming requests do not abort active thinking output at the idle deadline", async () => {
|
||||
let requestClosed = false;
|
||||
let resolveRequestClosed;
|
||||
const requestClosedPromise = new Promise((resolve) => { resolveRequestClosed = resolve; });
|
||||
const server = http.createServer((_request, response) => {
|
||||
response.writeHead(200, { "content-type": "text/event-stream" });
|
||||
let n = 0;
|
||||
const interval = setInterval(() => {
|
||||
n += 1;
|
||||
response.write(`data:{"choices":[{"delta":{"reasoning_content":"${n}"}}]}\n`);
|
||||
if (n >= 6) {
|
||||
clearInterval(interval);
|
||||
response.end("data:[DONE]\n\n");
|
||||
}
|
||||
}, 25);
|
||||
response.on("close", () => {
|
||||
clearInterval(interval);
|
||||
requestClosed = true;
|
||||
resolveRequestClosed();
|
||||
});
|
||||
});
|
||||
await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
|
||||
const address = server.address();
|
||||
const sentEvents = [];
|
||||
const { bridge, restore } = loadBridgeWithMocks({
|
||||
safeSend: (_sender, channel, payload) => {
|
||||
sentEvents.push({ channel, payload });
|
||||
},
|
||||
});
|
||||
bridge.init({
|
||||
sessions: new Map(),
|
||||
sftpClients: new Map(),
|
||||
electronModule: { app: { getPath: () => process.cwd() }, session: {} },
|
||||
});
|
||||
|
||||
try {
|
||||
const result = await bridge._streamRequestForTests(
|
||||
`http://127.0.0.1:${address.port}/thinking`,
|
||||
{
|
||||
method: "GET",
|
||||
idleTimeoutMs: 80,
|
||||
totalTimeoutMs: 2_000,
|
||||
},
|
||||
{ sender: { id: 1 } },
|
||||
"thinking",
|
||||
false,
|
||||
);
|
||||
assert.equal(result.statusCode, 200);
|
||||
await Promise.race([
|
||||
new Promise((resolve, reject) => {
|
||||
const started = Date.now();
|
||||
const poll = () => {
|
||||
if (sentEvents.some(({ channel }) => channel === "netcatty:ai:stream:end")) {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
const errorEvent = sentEvents.find(({ channel }) => channel === "netcatty:ai:stream:error");
|
||||
if (errorEvent) {
|
||||
reject(new Error(errorEvent.payload.error));
|
||||
return;
|
||||
}
|
||||
if (Date.now() - started > 1_500) {
|
||||
reject(new Error("thinking stream never finished"));
|
||||
return;
|
||||
}
|
||||
setTimeout(poll, 10);
|
||||
};
|
||||
poll();
|
||||
}),
|
||||
]);
|
||||
await Promise.race([
|
||||
requestClosedPromise,
|
||||
new Promise((_, reject) => setTimeout(() => reject(new Error("server request stayed open")), 500)),
|
||||
]);
|
||||
assert.equal(requestClosed, true);
|
||||
assert.equal(bridge._getActiveStreamCountForTests(), 0);
|
||||
assert.equal(
|
||||
sentEvents.some(({ channel }) => channel === "netcatty:ai:stream:error"),
|
||||
false,
|
||||
);
|
||||
} finally {
|
||||
restore();
|
||||
await new Promise((resolve) => server.close(resolve));
|
||||
}
|
||||
});
|
||||
|
||||
test("streaming requests abort after the idle deadline when no more bytes arrive", async () => {
|
||||
let requestClosed = false;
|
||||
let resolveRequestClosed;
|
||||
const requestClosedPromise = new Promise((resolve) => { resolveRequestClosed = resolve; });
|
||||
const server = http.createServer((_request, response) => {
|
||||
response.writeHead(200, { "content-type": "text/event-stream" });
|
||||
response.write('data:{"choices":[{"delta":{"reasoning_content":"start"}}]}\n');
|
||||
response.on("close", () => {
|
||||
requestClosed = true;
|
||||
resolveRequestClosed();
|
||||
});
|
||||
});
|
||||
await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
|
||||
const address = server.address();
|
||||
const sentEvents = [];
|
||||
const { bridge, restore } = loadBridgeWithMocks({
|
||||
safeSend: (_sender, channel, payload) => {
|
||||
sentEvents.push({ channel, payload });
|
||||
},
|
||||
});
|
||||
bridge.init({
|
||||
sessions: new Map(),
|
||||
sftpClients: new Map(),
|
||||
electronModule: { app: { getPath: () => process.cwd() }, session: {} },
|
||||
});
|
||||
|
||||
try {
|
||||
const result = await bridge._streamRequestForTests(
|
||||
`http://127.0.0.1:${address.port}/stalled`,
|
||||
{
|
||||
method: "GET",
|
||||
idleTimeoutMs: 40,
|
||||
totalTimeoutMs: 2_000,
|
||||
},
|
||||
{ sender: { id: 1 } },
|
||||
"stalled",
|
||||
false,
|
||||
);
|
||||
assert.equal(result.statusCode, 200);
|
||||
await Promise.race([
|
||||
new Promise((resolve, reject) => {
|
||||
const started = Date.now();
|
||||
const poll = () => {
|
||||
const errorEvent = sentEvents.find(({ channel }) => channel === "netcatty:ai:stream:error");
|
||||
if (errorEvent) {
|
||||
resolve(errorEvent.payload.error);
|
||||
return;
|
||||
}
|
||||
if (Date.now() - started > 1_000) {
|
||||
reject(new Error("stalled stream was not aborted"));
|
||||
return;
|
||||
}
|
||||
setTimeout(poll, 10);
|
||||
};
|
||||
poll();
|
||||
}).then((error) => {
|
||||
assert.match(error, /idle deadline exceeded/i);
|
||||
}),
|
||||
]);
|
||||
await Promise.race([
|
||||
requestClosedPromise,
|
||||
new Promise((_, reject) => setTimeout(() => reject(new Error("server request stayed open")), 500)),
|
||||
]);
|
||||
assert.equal(requestClosed, true);
|
||||
assert.equal(bridge._getActiveStreamCountForTests(), 0);
|
||||
} finally {
|
||||
restore();
|
||||
await new Promise((resolve) => server.close(resolve));
|
||||
}
|
||||
});
|
||||
|
||||
test("mcp attachment update handler forwards current chat attachments", async () => {
|
||||
const calls = [];
|
||||
const { bridge, restore } = loadBridgeWithMocks({
|
||||
@@ -481,6 +649,148 @@ test("streaming AI responses preserve UTF-8 characters split across network chun
|
||||
}
|
||||
});
|
||||
|
||||
test("streaming AI responses accept SSE data: lines without a space after the colon", { timeout: 5_000 }, async (t) => {
|
||||
const sentEvents = [];
|
||||
let handleStreamEvent = () => {};
|
||||
const { bridge, restore } = loadBridgeWithMocks({
|
||||
safeSend: (_sender, channel, payload) => {
|
||||
sentEvents.push({ channel, payload });
|
||||
handleStreamEvent(channel, payload);
|
||||
},
|
||||
});
|
||||
const ipcMain = createIpcMainStub();
|
||||
const server = require("node:http").createServer((req, res) => {
|
||||
req.resume();
|
||||
req.on("end", () => {
|
||||
res.writeHead(200, { "content-type": "text/event-stream" });
|
||||
// Older AxonHub 0.9 emits `data:{json}` with no space (issue #3020).
|
||||
res.end('data:{"choices":[{"delta":{"content":"hello"}}]}\ndata:[DONE]\n\n');
|
||||
});
|
||||
});
|
||||
|
||||
await new Promise((resolve, reject) => {
|
||||
server.once("error", reject);
|
||||
server.listen(0, "127.0.0.1", resolve);
|
||||
});
|
||||
t.after(() => new Promise((resolve) => server.close(resolve)));
|
||||
|
||||
bridge.init({
|
||||
sessions: new Map(),
|
||||
sftpClients: new Map(),
|
||||
electronModule: { app: { getPath: () => process.cwd() } },
|
||||
});
|
||||
bridge.registerHandlers(ipcMain);
|
||||
|
||||
try {
|
||||
const address = server.address();
|
||||
assert.ok(address && typeof address === "object");
|
||||
const baseURL = `http://127.0.0.1:${address.port}`;
|
||||
const sender = { id: 1 };
|
||||
await ipcMain.handlers.get("netcatty:ai:sync-providers")(
|
||||
{ sender },
|
||||
{ providers: [{ id: "axonhub-legacy", baseURL }] },
|
||||
);
|
||||
|
||||
const streamFinished = new Promise((resolve, reject) => {
|
||||
handleStreamEvent = (channel, payload) => {
|
||||
if (channel === "netcatty:ai:stream:end") resolve();
|
||||
else if (channel === "netcatty:ai:stream:error") reject(new Error(payload.error));
|
||||
};
|
||||
});
|
||||
|
||||
const result = await ipcMain.handlers.get("netcatty:ai:chat:stream")(
|
||||
{ sender },
|
||||
{
|
||||
requestId: "axonhub-legacy-request",
|
||||
url: `${baseURL}/v1/chat/completions`,
|
||||
headers: { "content-type": "application/json" },
|
||||
body: '{"stream":true}',
|
||||
providerId: "axonhub-legacy",
|
||||
},
|
||||
);
|
||||
await streamFinished;
|
||||
|
||||
assert.equal(result.ok, true);
|
||||
const dataEvents = sentEvents
|
||||
.filter(({ channel }) => channel === "netcatty:ai:stream:data")
|
||||
.map(({ payload }) => payload.data);
|
||||
assert.deepEqual(dataEvents, [
|
||||
'{"choices":[{"delta":{"content":"hello"}}]}',
|
||||
"[DONE]",
|
||||
]);
|
||||
} finally {
|
||||
restore();
|
||||
}
|
||||
});
|
||||
|
||||
test("streaming AI responses flush a trailing data: line that has no space and no newline", { timeout: 5_000 }, async (t) => {
|
||||
const sentEvents = [];
|
||||
let handleStreamEvent = () => {};
|
||||
const { bridge, restore } = loadBridgeWithMocks({
|
||||
safeSend: (_sender, channel, payload) => {
|
||||
sentEvents.push({ channel, payload });
|
||||
handleStreamEvent(channel, payload);
|
||||
},
|
||||
});
|
||||
const ipcMain = createIpcMainStub();
|
||||
const server = require("node:http").createServer((req, res) => {
|
||||
req.resume();
|
||||
req.on("end", () => {
|
||||
res.writeHead(200, { "content-type": "text/event-stream" });
|
||||
res.end('data:{"choices":[{"delta":{"content":"tail"}}]}');
|
||||
});
|
||||
});
|
||||
|
||||
await new Promise((resolve, reject) => {
|
||||
server.once("error", reject);
|
||||
server.listen(0, "127.0.0.1", resolve);
|
||||
});
|
||||
t.after(() => new Promise((resolve) => server.close(resolve)));
|
||||
|
||||
bridge.init({
|
||||
sessions: new Map(),
|
||||
sftpClients: new Map(),
|
||||
electronModule: { app: { getPath: () => process.cwd() } },
|
||||
});
|
||||
bridge.registerHandlers(ipcMain);
|
||||
|
||||
try {
|
||||
const address = server.address();
|
||||
assert.ok(address && typeof address === "object");
|
||||
const baseURL = `http://127.0.0.1:${address.port}`;
|
||||
const sender = { id: 1 };
|
||||
await ipcMain.handlers.get("netcatty:ai:sync-providers")(
|
||||
{ sender },
|
||||
{ providers: [{ id: "flush-no-space", baseURL }] },
|
||||
);
|
||||
|
||||
const streamFinished = new Promise((resolve, reject) => {
|
||||
handleStreamEvent = (channel, payload) => {
|
||||
if (channel === "netcatty:ai:stream:end") resolve();
|
||||
else if (channel === "netcatty:ai:stream:error") reject(new Error(payload.error));
|
||||
};
|
||||
});
|
||||
|
||||
const result = await ipcMain.handlers.get("netcatty:ai:chat:stream")(
|
||||
{ sender },
|
||||
{
|
||||
requestId: "flush-no-space-request",
|
||||
url: `${baseURL}/v1/chat/completions`,
|
||||
headers: { "content-type": "application/json" },
|
||||
body: '{"stream":true}',
|
||||
providerId: "flush-no-space",
|
||||
},
|
||||
);
|
||||
await streamFinished;
|
||||
|
||||
assert.equal(result.ok, true);
|
||||
const dataEvent = sentEvents.find(({ channel }) => channel === "netcatty:ai:stream:data");
|
||||
assert.equal(dataEvent?.payload.data, '{"choices":[{"delta":{"content":"tail"}}]}');
|
||||
} finally {
|
||||
restore();
|
||||
}
|
||||
});
|
||||
|
||||
test("discover returns the 3-layer contract for an installed, authenticated agent", async (t) => {
|
||||
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-discover-contract-"));
|
||||
t.after(() => fs.rmSync(tempDir, { recursive: true, force: true }));
|
||||
|
||||
@@ -11,6 +11,7 @@ const { existsSync, readFileSync } = require("node:fs");
|
||||
const os = require("node:os");
|
||||
const path = require("node:path");
|
||||
const { execFileSync } = require("node:child_process");
|
||||
const { resolveCursorCliSpawnSpec } = require("./cursorCliSpawn.cjs");
|
||||
|
||||
function defaultFileExists(p) {
|
||||
try { return existsSync(p); } catch { return false; }
|
||||
@@ -156,12 +157,15 @@ function defaultResolveCursorCliBinary(name, env) {
|
||||
}
|
||||
|
||||
function defaultRunCursorStatus(binPath, env) {
|
||||
const spec = resolveCursorCliSpawnSpec(binPath, ["status", "--format", "json"]);
|
||||
try {
|
||||
const stdout = execFileSync(binPath, ["status", "--format", "json"], {
|
||||
const stdout = execFileSync(spec.command, spec.args, {
|
||||
encoding: "utf8",
|
||||
timeout: 8000,
|
||||
env: env || process.env,
|
||||
stdio: ["pipe", "pipe", "pipe"],
|
||||
shell: spec.shell,
|
||||
windowsHide: true,
|
||||
});
|
||||
return { exitCode: 0, stdout: String(stdout || ""), stderr: "" };
|
||||
} catch (err) {
|
||||
@@ -173,19 +177,34 @@ function defaultRunCursorStatus(binPath, env) {
|
||||
}
|
||||
}
|
||||
|
||||
function extractFirstJsonObject(text) {
|
||||
const raw = String(text || "").replace(/^\uFEFF/, "").trim();
|
||||
if (!raw) return null;
|
||||
const candidates = [raw];
|
||||
const start = raw.indexOf("{");
|
||||
const end = raw.lastIndexOf("}");
|
||||
if (start >= 0 && end > start) {
|
||||
const sliced = raw.slice(start, end + 1);
|
||||
if (sliced !== raw) candidates.push(sliced);
|
||||
}
|
||||
for (const candidate of candidates) {
|
||||
try {
|
||||
const parsed = JSON.parse(candidate);
|
||||
if (parsed && typeof parsed === "object") return parsed;
|
||||
} catch { /* try next */ }
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function parseCursorStatusJson(stdout) {
|
||||
try {
|
||||
const parsed = JSON.parse(String(stdout || "").trim());
|
||||
if (!parsed || typeof parsed !== "object") return null;
|
||||
// Real Cursor status always exposes isAuthenticated and/or status.
|
||||
// Reject unrelated CLIs that accept unknown flags or emit other JSON.
|
||||
if (typeof parsed.isAuthenticated !== "boolean" && typeof parsed.status !== "string") {
|
||||
return null;
|
||||
}
|
||||
return parsed;
|
||||
} catch {
|
||||
const parsed = extractFirstJsonObject(stdout);
|
||||
if (!parsed) return null;
|
||||
// Real Cursor status always exposes isAuthenticated and/or status.
|
||||
// Reject unrelated CLIs that accept unknown flags or emit other JSON.
|
||||
if (typeof parsed.isAuthenticated !== "boolean" && typeof parsed.status !== "string") {
|
||||
return null;
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -260,4 +279,6 @@ module.exports = {
|
||||
CURSOR_CLI_BINARY_CANDIDATES,
|
||||
defaultRunSecurity,
|
||||
defaultRunGhAuthStatus,
|
||||
parseCursorStatusJson,
|
||||
resolveCursorCliSpawnSpec,
|
||||
};
|
||||
@@ -2,7 +2,10 @@ const test = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const {
|
||||
probeClaudeAuth, probeCopilotAuth, probeCodexAuth, probeCodebuddyAuth, probeCursorCliAuth, probeGrokAuth,
|
||||
parseCursorStatusJson, resolveCursorCliSpawnSpec,
|
||||
} = require("./agentAuthProbes.cjs");
|
||||
const { prepareCommandForSpawn } = require("../ai/shellUtils.cjs");
|
||||
const { resolveCursorCliSpawnSpec: resolveSharedCursorCliSpawnSpec } = require("./cursorCliSpawn.cjs");
|
||||
|
||||
test("probeClaudeAuth: env ANTHROPIC_API_KEY -> authenticated env", () => {
|
||||
const r = probeClaudeAuth({
|
||||
@@ -271,3 +274,40 @@ test("probeCursorCliAuth: status command failure -> not authenticated", () => {
|
||||
});
|
||||
assert.equal(r.authenticated, false);
|
||||
});
|
||||
|
||||
test("probeCursorCliAuth: extracts authenticated JSON wrapped in cmd noise", () => {
|
||||
const r = probeCursorCliAuth({
|
||||
resolveBinary: () => "C:\\Users\\me\\AppData\\Local\\cursor-agent\\cursor-agent.cmd",
|
||||
runStatus: () => ({
|
||||
exitCode: 0,
|
||||
stdout: "Starting...\r\n{\"status\":\"authenticated\",\"isAuthenticated\":true,\"userInfo\":{\"email\":\"user@example.com\"}}\r\n",
|
||||
}),
|
||||
});
|
||||
assert.equal(r.authenticated, true);
|
||||
assert.equal(r.authSource, "cli-login");
|
||||
assert.equal(r.email, "user@example.com");
|
||||
});
|
||||
|
||||
test("parseCursorStatusJson accepts BOM and surrounding text", () => {
|
||||
const parsed = parseCursorStatusJson(
|
||||
"\uFEFFnoise\n{\"isAuthenticated\":true,\"status\":\"authenticated\"}\n",
|
||||
);
|
||||
assert.equal(parsed.isAuthenticated, true);
|
||||
assert.equal(parsed.status, "authenticated");
|
||||
});
|
||||
|
||||
test("resolveCursorCliSpawnSpec re-exports the shared native launch helper", () => {
|
||||
const shim = "C:\\Users\\me\\AppData\\Local\\cursor-agent\\cursor-agent.cmd";
|
||||
const args = ["status", "--format", "json"];
|
||||
assert.deepEqual(
|
||||
resolveCursorCliSpawnSpec(shim, args),
|
||||
resolveSharedCursorCliSpawnSpec(shim, args),
|
||||
);
|
||||
assert.deepEqual(
|
||||
resolveSharedCursorCliSpawnSpec(shim, args, {
|
||||
exists: () => false,
|
||||
readFile: () => { throw new Error("missing"); },
|
||||
}),
|
||||
prepareCommandForSpawn(shim, args, { unwrapNativeExe: false }),
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,124 @@
|
||||
"use strict";
|
||||
|
||||
/**
|
||||
* Windows launch helper for the Cursor Agent installer shim.
|
||||
*
|
||||
* `%LOCALAPPDATA%\cursor-agent\cursor-agent.cmd` is a batch file that runs
|
||||
* `versions\<id>\node.exe` + `versions\<id>\index.js`. Node cannot spawn .cmd
|
||||
* directly (EINVAL). Routing the full turn prompt through cmd.exe is also
|
||||
* unsafe: 8191-char limit, %VAR% expansion, and broken quote escaping.
|
||||
*
|
||||
* Prefer the native node+script argv so prompts stay out of a shell.
|
||||
*/
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
const { prepareCommandForSpawn } = require("../ai/shellUtils.cjs");
|
||||
|
||||
function defaultExists(filePath) {
|
||||
try { return fs.existsSync(filePath); } catch { return false; }
|
||||
}
|
||||
|
||||
function defaultReadFile(filePath) {
|
||||
return fs.readFileSync(filePath, "utf8");
|
||||
}
|
||||
|
||||
function defaultReaddir(dirPath) {
|
||||
return fs.readdirSync(dirPath);
|
||||
}
|
||||
|
||||
function defaultStat(filePath) {
|
||||
return fs.statSync(filePath);
|
||||
}
|
||||
|
||||
function expandWindowsShimPath(raw, shimDir) {
|
||||
const dp0 = /[\\/]$/.test(shimDir) ? shimDir : `${shimDir}${path.sep}`;
|
||||
let resolved = String(raw || "").replace(/%~dp0/gi, dp0);
|
||||
// Installer shims use Windows separators; normalize so existsSync works
|
||||
// when this helper is unit-tested on POSIX.
|
||||
resolved = resolved.replace(/\\/g, path.sep);
|
||||
if (!path.isAbsolute(resolved) && !path.win32.isAbsolute(resolved)) {
|
||||
resolved = path.resolve(shimDir, resolved);
|
||||
}
|
||||
return path.normalize(resolved);
|
||||
}
|
||||
|
||||
function parseCursorAgentCmdLaunch(shimPath, { exists, readFile } = {}) {
|
||||
const existsFn = exists || defaultExists;
|
||||
const readFn = readFile || defaultReadFile;
|
||||
let contents;
|
||||
try {
|
||||
contents = readFn(shimPath);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
const match = String(contents || "").match(/"([^"\r\n]*node\.exe)"\s+"([^"\r\n]*index\.js)"/i);
|
||||
if (!match) return null;
|
||||
const shimDir = path.dirname(shimPath);
|
||||
const nodeExe = expandWindowsShimPath(match[1], shimDir);
|
||||
const script = expandWindowsShimPath(match[2], shimDir);
|
||||
if (!existsFn(nodeExe) || !existsFn(script)) return null;
|
||||
return { nodeExe, script };
|
||||
}
|
||||
|
||||
function resolveCursorAgentVersionsLaunch(installDir, { exists, readdir, stat } = {}) {
|
||||
const existsFn = exists || defaultExists;
|
||||
const readdirFn = readdir || defaultReaddir;
|
||||
const statFn = stat || defaultStat;
|
||||
const versionsDir = path.join(installDir, "versions");
|
||||
if (!existsFn(versionsDir)) return null;
|
||||
let names;
|
||||
try {
|
||||
names = readdirFn(versionsDir);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
const candidates = [];
|
||||
for (const name of names) {
|
||||
const dir = path.join(versionsDir, name);
|
||||
const nodeExe = path.join(dir, "node.exe");
|
||||
const script = path.join(dir, "index.js");
|
||||
if (!existsFn(nodeExe) || !existsFn(script)) continue;
|
||||
let mtime = 0;
|
||||
try { mtime = Number(statFn(dir)?.mtimeMs) || 0; } catch { /* ignore */ }
|
||||
candidates.push({ name: String(name), nodeExe, script, mtime });
|
||||
}
|
||||
if (candidates.length === 0) return null;
|
||||
candidates.sort((a, b) => b.mtime - a.mtime || b.name.localeCompare(a.name));
|
||||
return { nodeExe: candidates[0].nodeExe, script: candidates[0].script };
|
||||
}
|
||||
|
||||
function resolveCursorAgentNativeLaunch(binPath, io = {}) {
|
||||
const normalized = String(binPath || "").trim();
|
||||
if (!normalized) return null;
|
||||
const ext = path.extname(normalized).toLowerCase();
|
||||
if (ext !== ".cmd" && ext !== ".bat") return null;
|
||||
|
||||
const fromShim = parseCursorAgentCmdLaunch(normalized, io);
|
||||
if (fromShim) return fromShim;
|
||||
|
||||
return resolveCursorAgentVersionsLaunch(path.dirname(normalized), io);
|
||||
}
|
||||
|
||||
function resolveCursorCliSpawnSpec(binPath, args, io = {}) {
|
||||
const command = String(binPath || "").trim();
|
||||
const spawnArgs = Array.isArray(args) ? args : [];
|
||||
const native = resolveCursorAgentNativeLaunch(command, io);
|
||||
if (native) {
|
||||
return {
|
||||
command: native.nodeExe,
|
||||
args: [native.script, ...spawnArgs],
|
||||
shell: false,
|
||||
};
|
||||
}
|
||||
// Last resort for unknown shims / short probes (status, models). Turns with
|
||||
// a long prompt should have resolved the official versions/ layout above.
|
||||
return prepareCommandForSpawn(command, spawnArgs, { unwrapNativeExe: false });
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
expandWindowsShimPath,
|
||||
parseCursorAgentCmdLaunch,
|
||||
resolveCursorAgentNativeLaunch,
|
||||
resolveCursorAgentVersionsLaunch,
|
||||
resolveCursorCliSpawnSpec,
|
||||
};
|
||||
@@ -0,0 +1,107 @@
|
||||
"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 { prepareCommandForSpawn } = require("../ai/shellUtils.cjs");
|
||||
const {
|
||||
expandWindowsShimPath,
|
||||
parseCursorAgentCmdLaunch,
|
||||
resolveCursorAgentNativeLaunch,
|
||||
resolveCursorAgentVersionsLaunch,
|
||||
resolveCursorCliSpawnSpec,
|
||||
} = require("./cursorCliSpawn.cjs");
|
||||
|
||||
function writeCursorAgentInstall(root, version = "2026.06.01-abc") {
|
||||
const versionDir = path.join(root, "versions", version);
|
||||
fs.mkdirSync(versionDir, { recursive: true });
|
||||
const nodeExe = path.join(versionDir, "node.exe");
|
||||
const script = path.join(versionDir, "index.js");
|
||||
fs.writeFileSync(nodeExe, "", "utf8");
|
||||
fs.writeFileSync(script, "", "utf8");
|
||||
const shimPath = path.join(root, "cursor-agent.cmd");
|
||||
fs.writeFileSync(
|
||||
shimPath,
|
||||
`@ECHO off\r\n"%~dp0\\versions\\${version}\\node.exe" "%~dp0\\versions\\${version}\\index.js" %*\r\n`,
|
||||
"utf8",
|
||||
);
|
||||
return { shimPath, nodeExe, script, versionDir };
|
||||
}
|
||||
|
||||
test("expandWindowsShimPath expands %~dp0 relative to the shim directory", () => {
|
||||
const shimDir = path.join("C:", "Users", "me", "AppData", "Local", "cursor-agent");
|
||||
const resolved = expandWindowsShimPath("%~dp0\\versions\\2026.06.01-abc\\node.exe", shimDir);
|
||||
assert.equal(
|
||||
path.normalize(resolved),
|
||||
path.normalize(path.join(shimDir, "versions", "2026.06.01-abc", "node.exe")),
|
||||
);
|
||||
});
|
||||
|
||||
test("parseCursorAgentCmdLaunch reads node.exe + index.js from the installer shim", () => {
|
||||
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-cursor-shim-"));
|
||||
try {
|
||||
const { shimPath, nodeExe, script } = writeCursorAgentInstall(tmp);
|
||||
const launch = parseCursorAgentCmdLaunch(shimPath);
|
||||
assert.deepEqual(launch, { nodeExe, script });
|
||||
} finally {
|
||||
fs.rmSync(tmp, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("resolveCursorAgentVersionsLaunch picks the newest version directory", () => {
|
||||
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-cursor-versions-"));
|
||||
try {
|
||||
const older = writeCursorAgentInstall(tmp, "2026.01.01-old");
|
||||
const newer = writeCursorAgentInstall(tmp, "2026.08.01-new");
|
||||
const olderTime = new Date("2026-01-01T00:00:00Z");
|
||||
const newerTime = new Date("2026-08-01T00:00:00Z");
|
||||
fs.utimesSync(older.versionDir, olderTime, olderTime);
|
||||
fs.utimesSync(newer.versionDir, newerTime, newerTime);
|
||||
|
||||
const launch = resolveCursorAgentVersionsLaunch(tmp);
|
||||
assert.deepEqual(launch, { nodeExe: newer.nodeExe, script: newer.script });
|
||||
} finally {
|
||||
fs.rmSync(tmp, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("resolveCursorAgentNativeLaunch prefers the shim's node+script over a lone exe unwrap", () => {
|
||||
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-cursor-native-"));
|
||||
try {
|
||||
const { shimPath, nodeExe, script } = writeCursorAgentInstall(tmp);
|
||||
const launch = resolveCursorAgentNativeLaunch(shimPath);
|
||||
assert.deepEqual(launch, { nodeExe, script });
|
||||
} finally {
|
||||
fs.rmSync(tmp, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("resolveCursorCliSpawnSpec puts the prompt on argv, not a cmd.exe line", () => {
|
||||
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-cursor-spawn-"));
|
||||
try {
|
||||
const { shimPath, nodeExe, script } = writeCursorAgentInstall(tmp);
|
||||
const prompt = 'do "%USERPROFILE%" and `whoami` then say hello';
|
||||
const spec = resolveCursorCliSpawnSpec(shimPath, ["--print", "--trust", prompt]);
|
||||
assert.deepEqual(spec, {
|
||||
command: nodeExe,
|
||||
args: [script, "--print", "--trust", prompt],
|
||||
shell: false,
|
||||
});
|
||||
assert.equal(spec.command.includes("cmd.exe"), false);
|
||||
assert.equal(spec.args.includes(prompt), true);
|
||||
} finally {
|
||||
fs.rmSync(tmp, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("resolveCursorCliSpawnSpec falls back to the cmd shim when versions/ is missing", () => {
|
||||
const shim = "C:\\Users\\me\\AppData\\Local\\cursor-agent\\cursor-agent.cmd";
|
||||
const args = ["status", "--format", "json"];
|
||||
const spec = resolveCursorCliSpawnSpec(shim, args, {
|
||||
exists: () => false,
|
||||
readFile: () => { throw new Error("missing"); },
|
||||
});
|
||||
assert.deepEqual(spec, prepareCommandForSpawn(shim, args, { unwrapNativeExe: false }));
|
||||
});
|
||||
@@ -10,6 +10,7 @@ const { spawn } = require("node:child_process");
|
||||
const { StringDecoder } = require("node:string_decoder");
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
const { resolveCursorCliSpawnSpec } = require("../cursorCliSpawn.cjs");
|
||||
const { mcpEnvPairsToObject } = require("./injectMcp.cjs");
|
||||
|
||||
const DEFAULT_CURSOR_CLI_MODEL = "auto";
|
||||
@@ -56,6 +57,15 @@ function stripCursorApiKeyFromEnv(env) {
|
||||
return out;
|
||||
}
|
||||
|
||||
function spawnCursorCliProcess(spawnImpl, cliPath, args, options = {}) {
|
||||
const spawnFn = spawnImpl || spawn;
|
||||
const spawnSpec = resolveCursorCliSpawnSpec(cliPath, args);
|
||||
return spawnFn(spawnSpec.command, spawnSpec.args, {
|
||||
...options,
|
||||
shell: spawnSpec.shell,
|
||||
});
|
||||
}
|
||||
|
||||
function resolveCursorCliModel(model) {
|
||||
const raw = String(model || "").trim();
|
||||
return raw || DEFAULT_CURSOR_CLI_MODEL;
|
||||
@@ -520,7 +530,6 @@ async function runCursorCliTurn({
|
||||
failed: false,
|
||||
};
|
||||
|
||||
const spawnFn = spawnImpl || spawn;
|
||||
let child = null;
|
||||
let settled = false;
|
||||
|
||||
@@ -529,7 +538,7 @@ async function runCursorCliTurn({
|
||||
};
|
||||
|
||||
try {
|
||||
child = spawnFn(cliPath, args, {
|
||||
child = spawnCursorCliProcess(spawnImpl, cliPath, args, {
|
||||
cwd: effectiveCwd,
|
||||
env: childEnv,
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
@@ -667,7 +676,6 @@ async function listCursorCliModels({
|
||||
if (abortSignal?.aborted) return { currentModelId: null, models: [] };
|
||||
|
||||
const childEnv = stripCursorApiKeyFromEnv(env || process.env);
|
||||
const spawnFn = spawnImpl || spawn;
|
||||
|
||||
return await new Promise((resolve) => {
|
||||
let stdout = "";
|
||||
@@ -690,7 +698,7 @@ async function listCursorCliModels({
|
||||
|
||||
let child;
|
||||
try {
|
||||
child = spawnFn(cliPath, ["models"], {
|
||||
child = spawnCursorCliProcess(spawnImpl, cliPath, ["models"], {
|
||||
env: childEnv,
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
windowsHide: true,
|
||||
@@ -771,8 +779,10 @@ module.exports = {
|
||||
resetMcpMergeRefcountsForTests,
|
||||
resolveCursorCliExecMode,
|
||||
resolveCursorCliModel,
|
||||
resolveCursorCliSpawnSpec,
|
||||
resolveCursorCliWorkspaceCwd,
|
||||
runCursorCliTurn,
|
||||
spawnCursorCliProcess,
|
||||
stripCursorApiKeyFromEnv,
|
||||
translateCursorCliEvent,
|
||||
};
|
||||
Loaded 100 of 133 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user