mirror of
https://github.com/binaricat/Netcatty.git
synced 2026-11-06 14:30:13 +00:00
Compare commits
25
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
55f4e9cc56 | ||
|
|
80479e84d3 | ||
|
|
b27f0ad7d3 | ||
|
|
0c36ef7275 | ||
|
|
110c79c8dc | ||
|
|
e8c31a7800 | ||
|
|
2527f140c0 | ||
|
|
aaee43417f | ||
|
|
a622d953ad | ||
|
|
16c9af6e32 | ||
|
|
64717fffd5 | ||
|
|
2ba045c8e7 | ||
|
|
8b0b7706a3 | ||
|
|
fa34a5602e | ||
|
|
d14591b36a | ||
|
|
2bb7baebde | ||
|
|
aa597135b1 | ||
|
|
42b8e2f347 | ||
|
|
9c312b7b98 | ||
|
|
05e4bf69f7 | ||
|
|
eb57b73442 | ||
|
|
b366967bcc | ||
|
|
8771ec9e3d | ||
|
|
55002aefa0 | ||
|
|
1a7490497b |
No files matched your search
@@ -4,7 +4,7 @@ on:
|
||||
issues:
|
||||
types: [opened, reopened, edited]
|
||||
issue_comment:
|
||||
types: [created]
|
||||
types: [created, edited]
|
||||
|
||||
permissions: {}
|
||||
|
||||
@@ -24,14 +24,48 @@ jobs:
|
||||
echo "Grok Bot webhook secrets not set; skipping."
|
||||
exit 0
|
||||
fi
|
||||
jq --arg event "$GITHUB_EVENT_NAME" '{
|
||||
event: $event,
|
||||
action: .action,
|
||||
repository: .repository.full_name,
|
||||
sender: .sender.login,
|
||||
issue: {number: .issue.number, title: .issue.title, author: .issue.user.login, url: .issue.html_url},
|
||||
comment: (if .comment then {id: .comment.id, author: .comment.user.login, url: .comment.html_url} else null end)
|
||||
}' "$GITHUB_EVENT_PATH" \
|
||||
jq --arg event "$GITHUB_EVENT_NAME" '
|
||||
def clip:
|
||||
if type == "string" then .[0:4000] else . end;
|
||||
{
|
||||
event: $event,
|
||||
action: .action,
|
||||
repository: .repository.full_name,
|
||||
sender: .sender.login,
|
||||
issue: (
|
||||
{
|
||||
number: .issue.number,
|
||||
title: .issue.title,
|
||||
author: .issue.user.login,
|
||||
url: .issue.html_url
|
||||
} + (
|
||||
if $event == "issues" then {
|
||||
author_association: .issue.author_association,
|
||||
body: (.issue.body | clip)
|
||||
} else {} end
|
||||
)
|
||||
),
|
||||
comment: (
|
||||
if .comment then
|
||||
{
|
||||
id: .comment.id,
|
||||
author: .comment.user.login,
|
||||
url: .comment.html_url,
|
||||
node_id: .comment.node_id,
|
||||
author_association: .comment.author_association,
|
||||
user_type: .comment.user.type,
|
||||
created_at: .comment.created_at,
|
||||
updated_at: .comment.updated_at,
|
||||
body: (.comment.body | clip)
|
||||
} + (
|
||||
if .action == "edited" then {
|
||||
previous_body: (.changes.body.from | clip)
|
||||
} else {} end
|
||||
)
|
||||
else null end
|
||||
)
|
||||
}
|
||||
' "$GITHUB_EVENT_PATH" \
|
||||
| curl -fsS --max-time 20 -X POST \
|
||||
-H "Authorization: Bearer $WEBHOOK_KEY" \
|
||||
-H "Content-Type: application/json" \
|
||||
|
||||
@@ -2,7 +2,7 @@ name: spam-comments
|
||||
|
||||
on:
|
||||
issue_comment:
|
||||
types: [created]
|
||||
types: [created, edited]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
@@ -10,13 +10,13 @@ permissions:
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
delete-spam:
|
||||
hide-spam:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout filter rules
|
||||
uses: actions/checkout@v7
|
||||
|
||||
- name: Delete suspected malware spam
|
||||
- name: Hide suspected malware spam
|
||||
uses: actions/github-script@v9
|
||||
with:
|
||||
script: |
|
||||
@@ -25,26 +25,59 @@ jobs:
|
||||
path.join(process.env.GITHUB_WORKSPACE, 'scripts/spam-comment-filter.cjs')
|
||||
);
|
||||
|
||||
const comment = context.payload.comment;
|
||||
const comment = context.payload.comment || {};
|
||||
const issue = context.payload.issue || {};
|
||||
const login = (comment.user && comment.user.login) || 'unknown';
|
||||
const result = detectSpamComment({
|
||||
body: comment.body || '',
|
||||
authorAssociation: comment.author_association,
|
||||
userType: comment.user && comment.user.type,
|
||||
commentAuthor: comment.user && comment.user.login,
|
||||
issueAuthor: issue.user && issue.user.login,
|
||||
});
|
||||
|
||||
core.info(JSON.stringify(result, null, 2));
|
||||
|
||||
if (!result.spam) {
|
||||
core.info('Comment did not match the zip/dangerous-file spam filter.');
|
||||
core.info('Comment did not match the risky-download spam filter.');
|
||||
return;
|
||||
}
|
||||
|
||||
await github.rest.issues.deleteComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
comment_id: comment.id,
|
||||
});
|
||||
const matchedUrl = result.matchedUrls.join(', ');
|
||||
if (!comment.node_id) {
|
||||
core.setFailed(
|
||||
`Comment ${comment.id} by @${login} on #${issue.number} matched ${matchedUrl} but has no node_id.`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await github.graphql(
|
||||
`mutation($input: MinimizeCommentInput!) {
|
||||
minimizeComment(input: $input) {
|
||||
minimizedComment {
|
||||
isMinimized
|
||||
}
|
||||
}
|
||||
}`,
|
||||
{
|
||||
input: {
|
||||
subjectId: comment.node_id,
|
||||
classifier: 'SPAM',
|
||||
},
|
||||
}
|
||||
);
|
||||
} catch (error) {
|
||||
const message = String((error && error.message) || error);
|
||||
if (/already minimized/i.test(message)) {
|
||||
core.info(
|
||||
`Comment ${comment.id} by @${login} on #${issue.number} is already minimized. Matched URL: ${matchedUrl}`
|
||||
);
|
||||
return;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
core.notice(
|
||||
`Deleted comment with zip/dangerous attachment by @${comment.user.login} on #${context.payload.issue.number}: ${result.reasons.join('; ')}`
|
||||
`Hid comment ${comment.id} by @${login} on #${issue.number} as spam. Matched URL: ${matchedUrl}`
|
||||
);
|
||||
@@ -413,7 +413,7 @@ export const enCoreMessages: Messages = {
|
||||
'settings.appearance.themeColor.dark': 'Dark palette',
|
||||
'settings.appearance.customCss': 'Custom CSS',
|
||||
'settings.appearance.customCss.desc':
|
||||
'Add custom CSS to personalize the app appearance. Changes apply immediately.',
|
||||
'Add custom CSS to personalize the app appearance. Changes apply immediately. Selected top tabs (`.top-tab[data-active="true"]`) read `--top-tab-active-bg`, `--top-tab-active-fg`, and `--top-tab-active-border`. Leave them unset to keep the current look.',
|
||||
'settings.appearance.customCss.help.ariaLabel': 'Custom CSS help',
|
||||
'settings.appearance.customCss.help.title': 'Custom CSS guide',
|
||||
'settings.appearance.customCss.help.body': `Major UI regions expose a \`data-section\` attribute you can target. Changes apply as soon as you edit the CSS.
|
||||
@@ -440,6 +440,22 @@ Because many styles come from Tailwind utilities, you often need \`!important\`
|
||||
- \`top-tabs-host-tree-toggle\`
|
||||
- \`top-tabs-quick-switcher-toggle\`
|
||||
|
||||
Selected top tabs use the class \`top-tab\` and \`data-active="true"\`. Set these variables to recolor the selected tab. Leave them unset to keep the current look. They apply without \`!important\`.
|
||||
|
||||
- \`--top-tab-active-bg\` — selected fill (defaults to the current tab background)
|
||||
- \`--top-tab-active-fg\` — selected label color (defaults to the current tab text)
|
||||
- \`--top-tab-active-border\` — 1px inset outline (defaults to transparent, so there is no border)
|
||||
|
||||
The Vaults tab stays flat and does not use the fill.
|
||||
|
||||
\`\`\`css
|
||||
.top-tab[data-active="true"] {
|
||||
--top-tab-active-bg: #2563eb;
|
||||
--top-tab-active-fg: #ffffff;
|
||||
--top-tab-active-border: #1d4ed8;
|
||||
}
|
||||
\`\`\`
|
||||
|
||||
### Vault
|
||||
|
||||
- \`vault-view\`
|
||||
@@ -533,7 +549,7 @@ Highlight the focused split pane:
|
||||
\`\`\`
|
||||
`,
|
||||
'settings.appearance.customCss.placeholder':
|
||||
'/* Examples — use !important to beat Tailwind utility specificity */\n\n/* Hide the host-list toggle in the top tab bar */\n[data-section="top-tabs-host-tree-toggle"] {\n width: 0 !important;\n opacity: 0 !important;\n pointer-events: none !important;\n}\n\n/* Hide the plus button that opens the quick switcher */\n[data-section="top-tabs-quick-switcher-toggle"] {\n display: none !important;\n}\n\n/* Border around the SFTP / side panel (does not linger after closing) */\n[data-section="terminal-side-panel"] {\n border: 2px solid #00c851 !important;\n border-radius: 6px !important;\n}\n\n/* Change the whole side panel background, not only the top tabs */\n[data-section="terminal-side-panel"],\n[data-section="terminal-side-panel-tabs"],\n[data-section="terminal-side-panel-content"],\n[data-section="terminal-sftp-panel"],\n[data-section="terminal-sftp-pane"],\n[data-section="terminal-sftp-list"],\n[data-section="terminal-sftp-tree"],\n[data-section="terminal-sftp-transfer-queue"] {\n background-color: #1c384a !important;\n}\n\n/* Style selected SFTP file rows */\n[data-section="terminal-sftp-list-row"][data-selected="true"] {\n background-color: #00c851 !important;\n color: #001b10 !important;\n}\n\n/* Thicker split dividers */\n[data-section="terminal-split-resizer-bar"] {\n background-color: hsl(var(--primary)) !important;\n transform: scale(2) !important;\n}\n\n/* Highlight the focused split pane */\n[data-section="terminal-split-pane"][data-focused="true"] {\n outline: 2px solid hsl(var(--primary)) !important;\n outline-offset: -2px;\n}\n\n/* Or use Settings → Terminal → Workspace Focus Indicator → Border on focused pane */',
|
||||
'/* Examples — use !important to beat Tailwind utility specificity */\n\n/* Selected top tab. Leave these unset to keep the current look. */\n.top-tab[data-active="true"] {\n --top-tab-active-bg: #2563eb;\n --top-tab-active-fg: #ffffff;\n --top-tab-active-border: #1d4ed8;\n}\n\n/* Hide the host-list toggle in the top tab bar */\n[data-section="top-tabs-host-tree-toggle"] {\n width: 0 !important;\n opacity: 0 !important;\n pointer-events: none !important;\n}\n\n/* Hide the plus button that opens the quick switcher */\n[data-section="top-tabs-quick-switcher-toggle"] {\n display: none !important;\n}\n\n/* Border around the SFTP / side panel (does not linger after closing) */\n[data-section="terminal-side-panel"] {\n border: 2px solid #00c851 !important;\n border-radius: 6px !important;\n}\n\n/* Change the whole side panel background, not only the top tabs */\n[data-section="terminal-side-panel"],\n[data-section="terminal-side-panel-tabs"],\n[data-section="terminal-side-panel-content"],\n[data-section="terminal-sftp-panel"],\n[data-section="terminal-sftp-pane"],\n[data-section="terminal-sftp-list"],\n[data-section="terminal-sftp-tree"],\n[data-section="terminal-sftp-transfer-queue"] {\n background-color: #1c384a !important;\n}\n\n/* Style selected SFTP file rows */\n[data-section="terminal-sftp-list-row"][data-selected="true"] {\n background-color: #00c851 !important;\n color: #001b10 !important;\n}\n\n/* Thicker split dividers */\n[data-section="terminal-split-resizer-bar"] {\n background-color: hsl(var(--primary)) !important;\n transform: scale(2) !important;\n}\n\n/* Highlight the focused split pane */\n[data-section="terminal-split-pane"][data-focused="true"] {\n outline: 2px solid hsl(var(--primary)) !important;\n outline-offset: -2px;\n}\n\n/* Or use Settings → Terminal → Workspace Focus Indicator → Border on focused pane */',
|
||||
'settings.appearance.language': 'Language',
|
||||
'settings.appearance.language.desc': 'Choose the UI language',
|
||||
'settings.appearance.uiFont': 'Interface Font',
|
||||
@@ -712,7 +728,7 @@ Highlight the focused split pane:
|
||||
'Send configured text instead of normal Enter when pressing Shift+Enter in the terminal.',
|
||||
'settings.terminal.behavior.shiftEnterNewlineText': 'Text to send',
|
||||
'settings.terminal.behavior.shiftEnterNewlineText.desc':
|
||||
'Use \\n for newline, \\t for tab, \\e for Escape, and \\\\ for backslash.',
|
||||
'Use \\n for newline, \\t for tab, \\e for Escape, and \\\\ for backslash. Defaults to \\e\\r (Escape + Enter, the legacy Alt+Enter encoding), which most TUI CLIs treat as a newline.',
|
||||
'settings.terminal.behavior.shiftEnterForceText': 'Send text in Win32 input mode',
|
||||
'settings.terminal.behavior.shiftEnterForceText.desc':
|
||||
'Also send the text above when a local Windows ConPTY session negotiates Win32 input mode. Needed for Node/Bun CLIs (Claude Code, CodeBuddy) that cannot read modifier keys from console input records.',
|
||||
|
||||
@@ -109,6 +109,8 @@ export const enTerminalMessages: Messages = {
|
||||
'terminal.serverStats.cpu': 'CPU Usage',
|
||||
'terminal.serverStats.cpuCores': 'CPU Core Usage',
|
||||
'terminal.serverStats.gpu': 'GPU Usage',
|
||||
'terminal.serverStats.gpuDevices': 'GPU Device Usage',
|
||||
'terminal.serverStats.gpuIndex': 'GPU {{index}}',
|
||||
'terminal.serverStats.memory': 'Memory Usage',
|
||||
'terminal.serverStats.memoryDetails': 'Memory Details',
|
||||
'terminal.serverStats.memUsed': 'Used',
|
||||
|
||||
@@ -328,7 +328,7 @@ export const esCoreMessages: Messages = {
|
||||
'settings.appearance.themeColor.dark': 'Paleta oscura',
|
||||
'settings.appearance.customCss': 'CSS personalizado',
|
||||
'settings.appearance.customCss.desc':
|
||||
'Agrega CSS personalizado para personalizar la apariencia de la aplicación. Los cambios se aplican de inmediato.',
|
||||
'Agrega CSS personalizado para personalizar la apariencia de la aplicación. Los cambios se aplican de inmediato. Las pestañas superiores seleccionadas (`.top-tab[data-active="true"]`) leen `--top-tab-active-bg`, `--top-tab-active-fg` y `--top-tab-active-border`. Si no las defines, se conserva el aspecto actual.',
|
||||
'settings.appearance.customCss.help.ariaLabel': 'Ayuda de CSS personalizado',
|
||||
'settings.appearance.customCss.help.title': 'Guía de CSS personalizado',
|
||||
'settings.appearance.customCss.help.body': `Las principales regiones de la interfaz exponen un atributo \`data-section\` que puedes usar como destino. Los cambios se aplican en cuanto editas el CSS.
|
||||
@@ -355,6 +355,22 @@ Como muchos estilos provienen de utilidades de Tailwind, a menudo necesitas \`!i
|
||||
- \`top-tabs-host-tree-toggle\`
|
||||
- \`top-tabs-quick-switcher-toggle\`
|
||||
|
||||
Las pestañas superiores seleccionadas usan la clase \`top-tab\` y \`data-active="true"\`. Define estas variables para cambiar el color de la pestaña seleccionada. Si no las defines, se conserva el aspecto actual. Se aplican sin \`!important\`.
|
||||
|
||||
- \`--top-tab-active-bg\` — relleno seleccionado (por defecto, el fondo actual de la pestaña)
|
||||
- \`--top-tab-active-fg\` — color del texto seleccionado (por defecto, el texto actual)
|
||||
- \`--top-tab-active-border\` — contorno interior de 1px (transparente por defecto, así que no hay borde)
|
||||
|
||||
La pestaña Vaults permanece plana y no usa el relleno.
|
||||
|
||||
\`\`\`css
|
||||
.top-tab[data-active="true"] {
|
||||
--top-tab-active-bg: #2563eb;
|
||||
--top-tab-active-fg: #ffffff;
|
||||
--top-tab-active-border: #1d4ed8;
|
||||
}
|
||||
\`\`\`
|
||||
|
||||
### Vault
|
||||
|
||||
- \`vault-view\`
|
||||
@@ -448,7 +464,7 @@ Resalta el panel dividido enfocado:
|
||||
\`\`\`
|
||||
`,
|
||||
'settings.appearance.customCss.placeholder':
|
||||
'/* Ejemplos: usa !important para superar la especificidad de las utilidades de Tailwind */\n\n/* Oculta el botón de lista de hosts en la barra de pestañas superior */\n[data-section="top-tabs-host-tree-toggle"] {\n width: 0 !important;\n opacity: 0 !important;\n pointer-events: none !important;\n}\n\n/* Oculta el botón + que abre el selector rápido */\n[data-section="top-tabs-quick-switcher-toggle"] {\n display: none !important;\n}\n\n/* Borde alrededor del panel SFTP / lateral (no persiste tras cerrarlo) */\n[data-section="terminal-side-panel"] {\n border: 2px solid #00c851 !important;\n border-radius: 6px !important;\n}\n\n/* Cambia el fondo de todo el panel lateral, no solo las pestañas superiores */\n[data-section="terminal-side-panel"],\n[data-section="terminal-side-panel-tabs"],\n[data-section="terminal-side-panel-content"],\n[data-section="terminal-sftp-panel"],\n[data-section="terminal-sftp-pane"],\n[data-section="terminal-sftp-list"],\n[data-section="terminal-sftp-tree"],\n[data-section="terminal-sftp-transfer-queue"] {\n background-color: #1c384a !important;\n}\n\n/* Estiliza las filas de archivos SFTP seleccionadas */\n[data-section="terminal-sftp-list-row"][data-selected="true"] {\n background-color: #00c851 !important;\n color: #001b10 !important;\n}\n\n/* Divisores de división más gruesos */\n[data-section="terminal-split-resizer-bar"] {\n background-color: hsl(var(--primary)) !important;\n transform: scale(2) !important;\n}\n\n/* Resalta el panel dividido enfocado */\n[data-section="terminal-split-pane"][data-focused="true"] {\n outline: 2px solid hsl(var(--primary)) !important;\n outline-offset: -2px;\n}\n\n/* O usa Configuración → Terminal → Indicador de foco del espacio de trabajo → Borde en el panel enfocado */',
|
||||
'/* Ejemplos: usa !important para superar la especificidad de las utilidades de Tailwind */\n\n/* Pestaña superior seleccionada. Si no defines estas variables, se conserva el aspecto actual. */\n.top-tab[data-active="true"] {\n --top-tab-active-bg: #2563eb;\n --top-tab-active-fg: #ffffff;\n --top-tab-active-border: #1d4ed8;\n}\n\n/* Oculta el botón de lista de hosts en la barra de pestañas superior */\n[data-section="top-tabs-host-tree-toggle"] {\n width: 0 !important;\n opacity: 0 !important;\n pointer-events: none !important;\n}\n\n/* Oculta el botón + que abre el selector rápido */\n[data-section="top-tabs-quick-switcher-toggle"] {\n display: none !important;\n}\n\n/* Borde alrededor del panel SFTP / lateral (no persiste tras cerrarlo) */\n[data-section="terminal-side-panel"] {\n border: 2px solid #00c851 !important;\n border-radius: 6px !important;\n}\n\n/* Cambia el fondo de todo el panel lateral, no solo las pestañas superiores */\n[data-section="terminal-side-panel"],\n[data-section="terminal-side-panel-tabs"],\n[data-section="terminal-side-panel-content"],\n[data-section="terminal-sftp-panel"],\n[data-section="terminal-sftp-pane"],\n[data-section="terminal-sftp-list"],\n[data-section="terminal-sftp-tree"],\n[data-section="terminal-sftp-transfer-queue"] {\n background-color: #1c384a !important;\n}\n\n/* Estiliza las filas de archivos SFTP seleccionadas */\n[data-section="terminal-sftp-list-row"][data-selected="true"] {\n background-color: #00c851 !important;\n color: #001b10 !important;\n}\n\n/* Divisores de división más gruesos */\n[data-section="terminal-split-resizer-bar"] {\n background-color: hsl(var(--primary)) !important;\n transform: scale(2) !important;\n}\n\n/* Resalta el panel dividido enfocado */\n[data-section="terminal-split-pane"][data-focused="true"] {\n outline: 2px solid hsl(var(--primary)) !important;\n outline-offset: -2px;\n}\n\n/* O usa Configuración → Terminal → Indicador de foco del espacio de trabajo → Borde en el panel enfocado */',
|
||||
'settings.appearance.language': 'Idioma',
|
||||
'settings.appearance.language.desc': 'Elige el idioma de la interfaz',
|
||||
'settings.appearance.uiFont': 'Fuente de la interfaz',
|
||||
@@ -627,7 +643,7 @@ Resalta el panel dividido enfocado:
|
||||
'Envía el texto configurado en lugar del Enter normal al presionar Shift+Enter en la terminal.',
|
||||
'settings.terminal.behavior.shiftEnterNewlineText': 'Texto a enviar',
|
||||
'settings.terminal.behavior.shiftEnterNewlineText.desc':
|
||||
'Usa \\n para nueva línea, \\t para tabulación, \\e para Escape y \\\\ para barra invertida.',
|
||||
'Usa \\n para nueva línea, \\t para tabulación, \\e para Escape y \\\\ para barra invertida. Por defecto \\e\\r (Escape + Enter, la codificación clásica de Alt+Enter), que la mayoría de las CLI TUI interpretan como nueva línea.',
|
||||
'settings.terminal.behavior.shiftEnterForceText': 'Enviar texto también en modo de entrada Win32',
|
||||
'settings.terminal.behavior.shiftEnterForceText.desc':
|
||||
'Envía también el texto anterior cuando una sesión ConPTY local de Windows negocia el modo de entrada Win32. Necesario para CLI escritas en Node/Bun (Claude Code, CodeBuddy) que no pueden leer modificadores desde los registros de entrada de la consola.',
|
||||
|
||||
@@ -109,6 +109,8 @@ export const esTerminalMessages: Messages = {
|
||||
'terminal.serverStats.cpu': 'Uso de CPU',
|
||||
'terminal.serverStats.cpuCores': 'Uso de núcleos de CPU',
|
||||
'terminal.serverStats.gpu': 'Uso de GPU',
|
||||
'terminal.serverStats.gpuDevices': 'Uso de cada GPU',
|
||||
'terminal.serverStats.gpuIndex': 'GPU {{index}}',
|
||||
'terminal.serverStats.memory': 'Uso de memoria',
|
||||
'terminal.serverStats.memoryDetails': 'Detalles de memoria',
|
||||
'terminal.serverStats.memUsed': 'Usada',
|
||||
|
||||
@@ -397,7 +397,7 @@ export const ruCoreMessages: Messages = {
|
||||
'settings.appearance.themeColor.dark': 'Палитра тёмной темы',
|
||||
'settings.appearance.customCss': 'Пользовательский CSS',
|
||||
'settings.appearance.customCss.desc':
|
||||
'Добавьте пользовательский CSS, чтобы настроить внешний вид. Изменения применяются сразу.',
|
||||
'Добавьте пользовательский CSS, чтобы настроить внешний вид. Изменения применяются сразу. Выбранные верхние вкладки (`.top-tab[data-active="true"]`) читают `--top-tab-active-bg`, `--top-tab-active-fg` и `--top-tab-active-border`. Не задавайте их, чтобы сохранить текущий вид.',
|
||||
'settings.appearance.customCss.help.ariaLabel': 'Справка по пользовательскому CSS',
|
||||
'settings.appearance.customCss.help.title': 'Руководство по пользовательскому CSS',
|
||||
'settings.appearance.customCss.help.body': `Основные области интерфейса помечены атрибутом \`data-section\` для CSS-селекторов. Изменения применяются сразу после правки.
|
||||
@@ -423,6 +423,22 @@ export const ruCoreMessages: Messages = {
|
||||
- \`top-tabs-host-tree-toggle\`
|
||||
- \`top-tabs-quick-switcher-toggle\`
|
||||
|
||||
У выбранных верхних вкладок есть класс \`top-tab\` и атрибут \`data-active="true"\`. Задайте эти переменные, чтобы перекрасить выбранную вкладку. Не задавайте их, чтобы сохранить текущий вид. \`!important\` не нужен.
|
||||
|
||||
- \`--top-tab-active-bg\` — заливка выбранной вкладки (по умолчанию текущий фон вкладки)
|
||||
- \`--top-tab-active-fg\` — цвет подписи (по умолчанию текущий цвет текста)
|
||||
- \`--top-tab-active-border\` — внутренняя обводка 1px (по умолчанию прозрачная, то есть без рамки)
|
||||
|
||||
Вкладка Vaults остаётся плоской и не использует заливку.
|
||||
|
||||
\`\`\`css
|
||||
.top-tab[data-active="true"] {
|
||||
--top-tab-active-bg: #2563eb;
|
||||
--top-tab-active-fg: #ffffff;
|
||||
--top-tab-active-border: #1d4ed8;
|
||||
}
|
||||
\`\`\`
|
||||
|
||||
### Хранилище
|
||||
|
||||
- \`vault-view\`
|
||||
@@ -502,7 +518,7 @@ export const ruCoreMessages: Messages = {
|
||||
\`\`\`
|
||||
`,
|
||||
'settings.appearance.customCss.placeholder':
|
||||
'/* Примеры — используйте !important, чтобы переопределить специфичность утилит Tailwind */\n\n/* Скрыть переключатель списка хостов в верхней панели вкладок */\n[data-section="top-tabs-host-tree-toggle"] {\n width: 0 !important;\n opacity: 0 !important;\n pointer-events: none !important;\n}\n\n/* Скрыть кнопку плюса, открывающую быстрый переключатель */\n[data-section="top-tabs-quick-switcher-toggle"] {\n display: none !important;\n}\n\n/* Рамка вокруг боковой панели SFTP (не остаётся после закрытия) */\n[data-section="terminal-side-panel"] {\n border: 2px solid #00c851 !important;\n border-radius: 6px !important;\n}\n\n/* Изменить фон всей боковой панели, а не только верхних вкладок */\n[data-section="terminal-side-panel"],\n[data-section="terminal-side-panel-tabs"],\n[data-section="terminal-side-panel-content"],\n[data-section="terminal-sftp-panel"],\n[data-section="terminal-sftp-pane"],\n[data-section="terminal-sftp-list"],\n[data-section="terminal-sftp-tree"],\n[data-section="terminal-sftp-transfer-queue"] {\n background-color: #1c384a !important;\n}\n\n/* Настроить выбранные строки SFTP */\n[data-section="terminal-sftp-list-row"][data-selected="true"] {\n background-color: #00c851 !important;\n color: #001b10 !important;\n}\n\n/* Более заметные разделители сплита */\n[data-section="terminal-split-resizer-bar"] {\n background-color: hsl(var(--primary)) !important;\n transform: scale(2) !important;\n}\n\n/* Подсветка активной панели сплита */\n[data-section="terminal-split-pane"][data-focused="true"] {\n outline: 2px solid hsl(var(--primary)) !important;\n outline-offset: -2px;\n}\n\n/* Или: Настройки → Терминал → Индикатор фокуса → Рамка вокруг активной панели */',
|
||||
'/* Примеры — используйте !important, чтобы переопределить специфичность утилит Tailwind */\n\n/* Выбранная верхняя вкладка. Не задавайте переменные, чтобы сохранить текущий вид. */\n.top-tab[data-active="true"] {\n --top-tab-active-bg: #2563eb;\n --top-tab-active-fg: #ffffff;\n --top-tab-active-border: #1d4ed8;\n}\n\n/* Скрыть переключатель списка хостов в верхней панели вкладок */\n[data-section="top-tabs-host-tree-toggle"] {\n width: 0 !important;\n opacity: 0 !important;\n pointer-events: none !important;\n}\n\n/* Скрыть кнопку плюса, открывающую быстрый переключатель */\n[data-section="top-tabs-quick-switcher-toggle"] {\n display: none !important;\n}\n\n/* Рамка вокруг боковой панели SFTP (не остаётся после закрытия) */\n[data-section="terminal-side-panel"] {\n border: 2px solid #00c851 !important;\n border-radius: 6px !important;\n}\n\n/* Изменить фон всей боковой панели, а не только верхних вкладок */\n[data-section="terminal-side-panel"],\n[data-section="terminal-side-panel-tabs"],\n[data-section="terminal-side-panel-content"],\n[data-section="terminal-sftp-panel"],\n[data-section="terminal-sftp-pane"],\n[data-section="terminal-sftp-list"],\n[data-section="terminal-sftp-tree"],\n[data-section="terminal-sftp-transfer-queue"] {\n background-color: #1c384a !important;\n}\n\n/* Настроить выбранные строки SFTP */\n[data-section="terminal-sftp-list-row"][data-selected="true"] {\n background-color: #00c851 !important;\n color: #001b10 !important;\n}\n\n/* Более заметные разделители сплита */\n[data-section="terminal-split-resizer-bar"] {\n background-color: hsl(var(--primary)) !important;\n transform: scale(2) !important;\n}\n\n/* Подсветка активной панели сплита */\n[data-section="terminal-split-pane"][data-focused="true"] {\n outline: 2px solid hsl(var(--primary)) !important;\n outline-offset: -2px;\n}\n\n/* Или: Настройки → Терминал → Индикатор фокуса → Рамка вокруг активной панели */',
|
||||
'settings.appearance.language': 'Язык',
|
||||
'settings.appearance.language.desc': 'Выберите язык интерфейса',
|
||||
'settings.appearance.uiFont': 'Шрифт интерфейса',
|
||||
@@ -663,7 +679,7 @@ export const ruCoreMessages: Messages = {
|
||||
'Отправлять настроенный текст вместо обычного Enter при нажатии Shift+Enter в терминале.',
|
||||
'settings.terminal.behavior.shiftEnterNewlineText': 'Отправляемый текст',
|
||||
'settings.terminal.behavior.shiftEnterNewlineText.desc':
|
||||
'Используйте \\n для перевода строки, \\t для табуляции, \\e для Escape и \\\\ для обратной косой черты.',
|
||||
'Используйте \\n для перевода строки, \\t для табуляции, \\e для Escape и \\\\ для обратной косой черты. По умолчанию \\e\\r (Escape + Enter, классическая кодировка Alt+Enter), которую большинство TUI-CLI распознают как перевод строки.',
|
||||
'settings.terminal.behavior.shiftEnterForceText': 'Отправлять текст и в режиме ввода Win32',
|
||||
'settings.terminal.behavior.shiftEnterForceText.desc':
|
||||
'Отправлять указанный выше текст даже когда локальная сессия Windows ConPTY согласует режим ввода Win32. Нужно для CLI на Node/Bun (Claude Code, CodeBuddy), которые не читают модификаторы из записей ввода консоли.',
|
||||
|
||||
@@ -130,6 +130,8 @@ export const ruTerminalMessages: Messages = {
|
||||
'terminal.serverStats.cpu': 'Использование CPU',
|
||||
'terminal.serverStats.cpuCores': 'Использование ядер CPU',
|
||||
'terminal.serverStats.gpu': 'Использование GPU',
|
||||
'terminal.serverStats.gpuDevices': 'Использование видеокарт',
|
||||
'terminal.serverStats.gpuIndex': 'GPU {{index}}',
|
||||
'terminal.serverStats.memory': 'Использование памяти',
|
||||
'terminal.serverStats.memoryDetails': 'Сведения о памяти',
|
||||
'terminal.serverStats.memUsed': 'Использовано',
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import { enTerminalMessages } from "./en/terminal";
|
||||
import { esTerminalMessages } from "./es/terminal";
|
||||
import { ruTerminalMessages } from "./ru/terminal";
|
||||
import { zhCNVaultMessages } from "./zh-CN/vault";
|
||||
import { zhTWVaultMessages } from "./zh-TW/vault";
|
||||
|
||||
test("per-GPU stats strings exist in every locale", () => {
|
||||
const labels = [
|
||||
[enTerminalMessages, "GPU Device Usage", "GPU {{index}}"],
|
||||
[esTerminalMessages, "Uso de cada GPU", "GPU {{index}}"],
|
||||
[ruTerminalMessages, "Использование видеокарт", "GPU {{index}}"],
|
||||
[zhCNVaultMessages, "GPU 设备使用率", "GPU {{index}}"],
|
||||
[zhTWVaultMessages, "GPU 裝置使用率", "GPU {{index}}"],
|
||||
] as const;
|
||||
|
||||
for (const [messages, devices, indexLabel] of labels) {
|
||||
assert.equal(messages["terminal.serverStats.gpuDevices"], devices);
|
||||
assert.equal(messages["terminal.serverStats.gpuIndex"], indexLabel);
|
||||
}
|
||||
});
|
||||
@@ -417,7 +417,7 @@ export const zhCNCoreMessages: Messages = {
|
||||
'settings.appearance.themeColor.dark': '深色主题',
|
||||
'settings.appearance.customCss': '自定义 CSS',
|
||||
'settings.appearance.customCss.desc':
|
||||
'使用自定义 CSS 个性化界面,修改会立即生效。',
|
||||
'使用自定义 CSS 个性化界面,修改会立即生效。选中的顶部标签(`.top-tab[data-active="true"]`)会读取 `--top-tab-active-bg`、`--top-tab-active-fg` 和 `--top-tab-active-border`。不设置则保持当前外观。',
|
||||
'settings.appearance.customCss.help.ariaLabel': '自定义 CSS 说明',
|
||||
'settings.appearance.customCss.help.title': '自定义 CSS 说明',
|
||||
'settings.appearance.customCss.help.body': `主要 UI 区块都暴露了 \`data-section\` 属性,供你用 CSS 定位。编辑后会立即生效。
|
||||
@@ -444,6 +444,22 @@ export const zhCNCoreMessages: Messages = {
|
||||
- \`top-tabs-host-tree-toggle\`
|
||||
- \`top-tabs-quick-switcher-toggle\`
|
||||
|
||||
选中的顶部标签带有 \`top-tab\` 类和 \`data-active="true"\`。设置下面的变量即可改变选中标签的颜色;不设置则保持当前外观。这些变量不需要 \`!important\`。
|
||||
|
||||
- \`--top-tab-active-bg\` — 选中标签的背景(默认是现在的标签背景)
|
||||
- \`--top-tab-active-fg\` — 选中标签的文字颜色(默认是现在的文字颜色)
|
||||
- \`--top-tab-active-border\` — 1px 内描边(默认透明,也就是没有边框)
|
||||
|
||||
Vaults 标签保持扁平,不使用这块背景。
|
||||
|
||||
\`\`\`css
|
||||
.top-tab[data-active="true"] {
|
||||
--top-tab-active-bg: #2563eb;
|
||||
--top-tab-active-fg: #ffffff;
|
||||
--top-tab-active-border: #1d4ed8;
|
||||
}
|
||||
\`\`\`
|
||||
|
||||
### 保险库
|
||||
|
||||
- \`vault-view\`
|
||||
@@ -537,7 +553,7 @@ export const zhCNCoreMessages: Messages = {
|
||||
\`\`\`
|
||||
`,
|
||||
'settings.appearance.customCss.placeholder':
|
||||
'/* 示例 — 由于 Tailwind 优先级较高,需要使用 !important */\n\n/* 隐藏顶部标签栏里的主机列表开关 */\n[data-section="top-tabs-host-tree-toggle"] {\n width: 0 !important;\n opacity: 0 !important;\n pointer-events: none !important;\n}\n\n/* 隐藏打开快速切换器的加号按钮 */\n[data-section="top-tabs-quick-switcher-toggle"] {\n display: none !important;\n}\n\n/* SFTP / 操作侧栏边框(关闭侧栏后不会残留) */\n[data-section="terminal-side-panel"] {\n border: 2px solid #00c851 !important;\n border-radius: 6px !important;\n}\n\n/* 修改整个操作侧栏背景,而不只是顶部标签 */\n[data-section="terminal-side-panel"],\n[data-section="terminal-side-panel-tabs"],\n[data-section="terminal-side-panel-content"],\n[data-section="terminal-sftp-panel"],\n[data-section="terminal-sftp-pane"],\n[data-section="terminal-sftp-list"],\n[data-section="terminal-sftp-tree"],\n[data-section="terminal-sftp-transfer-queue"] {\n background-color: #1c384a !important;\n}\n\n/* 修改选中的 SFTP 文件行 */\n[data-section="terminal-sftp-list-row"][data-selected="true"] {\n background-color: #00c851 !important;\n color: #001b10 !important;\n}\n\n/* 加粗分屏分割线 */\n[data-section="terminal-split-resizer-bar"] {\n background-color: hsl(var(--primary)) !important;\n transform: scale(2) !important;\n}\n\n/* 高亮当前聚焦的分屏 */\n[data-section="terminal-split-pane"][data-focused="true"] {\n outline: 2px solid hsl(var(--primary)) !important;\n outline-offset: -2px;\n}\n\n/* 也可在 设置 → 终端 → 工作区聚焦指示 → 聚焦窗格显示边框 */',
|
||||
'/* 示例 — 由于 Tailwind 优先级较高,需要使用 !important */\n\n/* 选中的顶部标签。不设置这些变量则保持当前外观。 */\n.top-tab[data-active="true"] {\n --top-tab-active-bg: #2563eb;\n --top-tab-active-fg: #ffffff;\n --top-tab-active-border: #1d4ed8;\n}\n\n/* 隐藏顶部标签栏里的主机列表开关 */\n[data-section="top-tabs-host-tree-toggle"] {\n width: 0 !important;\n opacity: 0 !important;\n pointer-events: none !important;\n}\n\n/* 隐藏打开快速切换器的加号按钮 */\n[data-section="top-tabs-quick-switcher-toggle"] {\n display: none !important;\n}\n\n/* SFTP / 操作侧栏边框(关闭侧栏后不会残留) */\n[data-section="terminal-side-panel"] {\n border: 2px solid #00c851 !important;\n border-radius: 6px !important;\n}\n\n/* 修改整个操作侧栏背景,而不只是顶部标签 */\n[data-section="terminal-side-panel"],\n[data-section="terminal-side-panel-tabs"],\n[data-section="terminal-side-panel-content"],\n[data-section="terminal-sftp-panel"],\n[data-section="terminal-sftp-pane"],\n[data-section="terminal-sftp-list"],\n[data-section="terminal-sftp-tree"],\n[data-section="terminal-sftp-transfer-queue"] {\n background-color: #1c384a !important;\n}\n\n/* 修改选中的 SFTP 文件行 */\n[data-section="terminal-sftp-list-row"][data-selected="true"] {\n background-color: #00c851 !important;\n color: #001b10 !important;\n}\n\n/* 加粗分屏分割线 */\n[data-section="terminal-split-resizer-bar"] {\n background-color: hsl(var(--primary)) !important;\n transform: scale(2) !important;\n}\n\n/* 高亮当前聚焦的分屏 */\n[data-section="terminal-split-pane"][data-focused="true"] {\n outline: 2px solid hsl(var(--primary)) !important;\n outline-offset: -2px;\n}\n\n/* 也可在 设置 → 终端 → 工作区聚焦指示 → 聚焦窗格显示边框 */',
|
||||
'settings.appearance.language': '语言',
|
||||
'settings.appearance.language.desc': '选择界面语言',
|
||||
'settings.appearance.uiFont': '界面字体',
|
||||
|
||||
@@ -352,7 +352,7 @@ export const zhCNTerminalMessages: Messages = {
|
||||
'在终端中按 Shift+Enter 时发送配置的文本,而不是普通回车。',
|
||||
'settings.terminal.behavior.shiftEnterNewlineText': '发送内容',
|
||||
'settings.terminal.behavior.shiftEnterNewlineText.desc':
|
||||
'使用 \\n 表示换行,\\t 表示 Tab,\\e 表示 ESC,\\\\ 表示反斜杠。',
|
||||
'使用 \\n 表示换行,\\t 表示 Tab,\\e 表示 ESC,\\\\ 表示反斜杠。默认 \\e\\r(ESC+回车,传统 Alt+Enter 编码),大多数 TUI CLI 会将其识别为换行。',
|
||||
'settings.terminal.behavior.shiftEnterForceText': '在 Win32 输入模式下也发送文本',
|
||||
'settings.terminal.behavior.shiftEnterForceText.desc':
|
||||
'本机 Windows ConPTY 会话协商 Win32 输入模式时,仍然发送上面的文本。Node/Bun 写的 CLI(Claude Code、CodeBuddy)读不到控制台输入记录里的修饰键,需要这个开关才能区分 Shift+Enter。',
|
||||
|
||||
@@ -532,6 +532,8 @@ export const zhCNVaultMessages: Messages = {
|
||||
'terminal.serverStats.cpu': 'CPU 使用率',
|
||||
'terminal.serverStats.cpuCores': 'CPU 核心使用率',
|
||||
'terminal.serverStats.gpu': 'GPU 使用率',
|
||||
'terminal.serverStats.gpuDevices': 'GPU 设备使用率',
|
||||
'terminal.serverStats.gpuIndex': 'GPU {{index}}',
|
||||
'terminal.serverStats.memory': '内存使用',
|
||||
'terminal.serverStats.memoryDetails': '内存详情',
|
||||
'terminal.serverStats.memUsed': '已用',
|
||||
|
||||
@@ -419,7 +419,7 @@ export const zhTWCoreMessages: Messages = {
|
||||
'settings.appearance.themeColor.dark': '深色主題',
|
||||
'settings.appearance.customCss': '自訂 CSS',
|
||||
'settings.appearance.customCss.desc':
|
||||
'使用自訂 CSS 個人化介面,修改會立即生效。',
|
||||
'使用自訂 CSS 個人化介面,修改會立即生效。選取的頂部分頁(`.top-tab[data-active="true"]`)會讀取 `--top-tab-active-bg`、`--top-tab-active-fg` 和 `--top-tab-active-border`。不設定則維持目前外觀。',
|
||||
'settings.appearance.customCss.help.ariaLabel': '自訂 CSS 說明',
|
||||
'settings.appearance.customCss.help.title': '自訂 CSS 說明',
|
||||
'settings.appearance.customCss.help.body': `主要 UI 區塊都公開了 \`data-section\` 屬性,供你用 CSS 定位。編輯後會立即生效。
|
||||
@@ -446,6 +446,22 @@ export const zhTWCoreMessages: Messages = {
|
||||
- \`top-tabs-host-tree-toggle\`
|
||||
- \`top-tabs-quick-switcher-toggle\`
|
||||
|
||||
選取的頂部分頁帶有 \`top-tab\` 類別和 \`data-active="true"\`。設定下列變數即可改變選取分頁的顏色;不設定則維持目前外觀。這些變數不需要 \`!important\`。
|
||||
|
||||
- \`--top-tab-active-bg\` — 選取分頁的背景(預設為目前的分頁背景)
|
||||
- \`--top-tab-active-fg\` — 選取分頁的文字顏色(預設為目前的文字顏色)
|
||||
- \`--top-tab-active-border\` — 1px 內描邊(預設透明,也就是沒有邊框)
|
||||
|
||||
Vaults 分頁維持扁平,不使用這塊背景。
|
||||
|
||||
\`\`\`css
|
||||
.top-tab[data-active="true"] {
|
||||
--top-tab-active-bg: #2563eb;
|
||||
--top-tab-active-fg: #ffffff;
|
||||
--top-tab-active-border: #1d4ed8;
|
||||
}
|
||||
\`\`\`
|
||||
|
||||
### 保險庫
|
||||
|
||||
- \`vault-view\`
|
||||
@@ -539,7 +555,7 @@ export const zhTWCoreMessages: Messages = {
|
||||
\`\`\`
|
||||
`,
|
||||
'settings.appearance.customCss.placeholder':
|
||||
'/* 範例 — 由於 Tailwind 優先順序較高,需要使用 !important */\n\n/* 隱藏頂部分頁列裡的主機列表開關 */\n[data-section="top-tabs-host-tree-toggle"] {\n width: 0 !important;\n opacity: 0 !important;\n pointer-events: none !important;\n}\n\n/* 隱藏開啟快速切換器的加號按鈕 */\n[data-section="top-tabs-quick-switcher-toggle"] {\n display: none !important;\n}\n\n/* SFTP / 操作側欄邊框(關閉側欄後不會殘留) */\n[data-section="terminal-side-panel"] {\n border: 2px solid #00c851 !important;\n border-radius: 6px !important;\n}\n\n/* 修改整個操作側欄背景,而不只是頂部分頁 */\n[data-section="terminal-side-panel"],\n[data-section="terminal-side-panel-tabs"],\n[data-section="terminal-side-panel-content"],\n[data-section="terminal-sftp-panel"],\n[data-section="terminal-sftp-pane"],\n[data-section="terminal-sftp-list"],\n[data-section="terminal-sftp-tree"],\n[data-section="terminal-sftp-transfer-queue"] {\n background-color: #1c384a !important;\n}\n\n/* 修改選取的 SFTP 檔案行 */\n[data-section="terminal-sftp-list-row"][data-selected="true"] {\n background-color: #00c851 !important;\n color: #001b10 !important;\n}\n\n/* 加粗分割分割線 */\n[data-section="terminal-split-resizer-bar"] {\n background-color: hsl(var(--primary)) !important;\n transform: scale(2) !important;\n}\n\n/* 標示目前聚焦的分割 */\n[data-section="terminal-split-pane"][data-focused="true"] {\n outline: 2px solid hsl(var(--primary)) !important;\n outline-offset: -2px;\n}\n\n/* 也可在 設定 → 終端 → 工作區聚焦指示 → 聚焦窗格顯示邊框 */',
|
||||
'/* 範例 — 由於 Tailwind 優先順序較高,需要使用 !important */\n\n/* 選取的頂部分頁。不設定這些變數則維持目前外觀。 */\n.top-tab[data-active="true"] {\n --top-tab-active-bg: #2563eb;\n --top-tab-active-fg: #ffffff;\n --top-tab-active-border: #1d4ed8;\n}\n\n/* 隱藏頂部分頁列裡的主機列表開關 */\n[data-section="top-tabs-host-tree-toggle"] {\n width: 0 !important;\n opacity: 0 !important;\n pointer-events: none !important;\n}\n\n/* 隱藏開啟快速切換器的加號按鈕 */\n[data-section="top-tabs-quick-switcher-toggle"] {\n display: none !important;\n}\n\n/* SFTP / 操作側欄邊框(關閉側欄後不會殘留) */\n[data-section="terminal-side-panel"] {\n border: 2px solid #00c851 !important;\n border-radius: 6px !important;\n}\n\n/* 修改整個操作側欄背景,而不只是頂部分頁 */\n[data-section="terminal-side-panel"],\n[data-section="terminal-side-panel-tabs"],\n[data-section="terminal-side-panel-content"],\n[data-section="terminal-sftp-panel"],\n[data-section="terminal-sftp-pane"],\n[data-section="terminal-sftp-list"],\n[data-section="terminal-sftp-tree"],\n[data-section="terminal-sftp-transfer-queue"] {\n background-color: #1c384a !important;\n}\n\n/* 修改選取的 SFTP 檔案行 */\n[data-section="terminal-sftp-list-row"][data-selected="true"] {\n background-color: #00c851 !important;\n color: #001b10 !important;\n}\n\n/* 加粗分割分割線 */\n[data-section="terminal-split-resizer-bar"] {\n background-color: hsl(var(--primary)) !important;\n transform: scale(2) !important;\n}\n\n/* 標示目前聚焦的分割 */\n[data-section="terminal-split-pane"][data-focused="true"] {\n outline: 2px solid hsl(var(--primary)) !important;\n outline-offset: -2px;\n}\n\n/* 也可在 設定 → 終端 → 工作區聚焦指示 → 聚焦窗格顯示邊框 */',
|
||||
'settings.appearance.language': '語言',
|
||||
'settings.appearance.language.desc': '選擇介面語言',
|
||||
'settings.appearance.uiFont': '介面字型',
|
||||
|
||||
@@ -352,7 +352,7 @@ export const zhTWTerminalMessages: Messages = {
|
||||
'在終端中按 Shift+Enter 時傳送設定的文字,而不是一般 Enter。',
|
||||
'settings.terminal.behavior.shiftEnterNewlineText': '傳送內容',
|
||||
'settings.terminal.behavior.shiftEnterNewlineText.desc':
|
||||
'使用 \\n 表示換行,\\t 表示 Tab,\\e 表示 ESC,\\\\ 表示反斜線。',
|
||||
'使用 \\n 表示換行,\\t 表示 Tab,\\e 表示 ESC,\\\\ 表示反斜線。預設 \\e\\r(ESC+Enter,傳統 Alt+Enter 編碼),大多數 TUI CLI 會將其識別為換行。',
|
||||
'settings.terminal.behavior.shiftEnterForceText': '在 Win32 輸入模式下也傳送文字',
|
||||
'settings.terminal.behavior.shiftEnterForceText.desc':
|
||||
'本機 Windows ConPTY 工作階段協商 Win32 輸入模式時,仍然傳送上面的文字。Node/Bun 寫的 CLI(Claude Code、CodeBuddy)讀不到主控台輸入記錄中的修飾鍵,需要這個開關才能區分 Shift+Enter。',
|
||||
|
||||
@@ -532,6 +532,8 @@ export const zhTWVaultMessages: Messages = {
|
||||
'terminal.serverStats.cpu': 'CPU 使用率',
|
||||
'terminal.serverStats.cpuCores': 'CPU 核心使用率',
|
||||
'terminal.serverStats.gpu': 'GPU 使用率',
|
||||
'terminal.serverStats.gpuDevices': 'GPU 裝置使用率',
|
||||
'terminal.serverStats.gpuIndex': 'GPU {{index}}',
|
||||
'terminal.serverStats.memory': '記憶體使用',
|
||||
'terminal.serverStats.memoryDetails': '記憶體詳情',
|
||||
'terminal.serverStats.memUsed': '已用',
|
||||
|
||||
@@ -530,8 +530,12 @@ test("only trusted live terminal cwd recovery resumes follow after initial probe
|
||||
await act(async () => renderer?.unmount());
|
||||
});
|
||||
|
||||
test("an in-flight follow probe cannot navigate after the focused session changes", async () => {
|
||||
test("regular follow waits for trusted live cwd instead of polling after command clear", async () => {
|
||||
// Regression for #3588: command submit clears cwd to null and schedules a
|
||||
// backend-strict probe. Auto-follow must not preferFreshBackend-poll in that
|
||||
// gap — that race aborted the navigate and left the panel stuck.
|
||||
const navigatedPaths: string[] = [];
|
||||
let probeCalls = 0;
|
||||
const connection = {
|
||||
id: "conn-1",
|
||||
hostId: "host-1",
|
||||
@@ -550,20 +554,15 @@ test("an in-flight follow probe cannot navigate after the focused session change
|
||||
},
|
||||
},
|
||||
};
|
||||
let activeSessionId = "session-a";
|
||||
let activeTerminalCwd: string | null = "/home/alice";
|
||||
let resolveCwd: ((cwd: string) => void) | null = null;
|
||||
let deferProbe = false;
|
||||
let activeTerminalCwdTrusted = false;
|
||||
let renderer: ReactTestRenderer | null = null;
|
||||
const onGetTerminalCwd = () => {
|
||||
if (!deferProbe) return Promise.resolve("/root/session-a");
|
||||
return new Promise<string>((resolve) => { resolveCwd = resolve; });
|
||||
};
|
||||
|
||||
function Probe() {
|
||||
useSftpFollowTerminalCwd({
|
||||
activeSessionId,
|
||||
activeSessionId: "session-1",
|
||||
activeTerminalCwd,
|
||||
activeTerminalCwdTrusted,
|
||||
canFollowTerminalCwd: true,
|
||||
connectionId: connection.id,
|
||||
connectionIsLocal: connection.isLocal,
|
||||
@@ -575,7 +574,11 @@ test("an in-flight follow probe cannot navigate after the focused session change
|
||||
hasActiveWork: false,
|
||||
isVisible: true,
|
||||
ownerPanelOpen: true,
|
||||
onGetTerminalCwd,
|
||||
onGetTerminalCwd: async () => {
|
||||
probeCalls += 1;
|
||||
// First-open may probe; keep the pane where it already is.
|
||||
return connection.currentPath;
|
||||
},
|
||||
onPendingFollowOverride: () => {},
|
||||
sftpRef,
|
||||
});
|
||||
@@ -586,23 +589,28 @@ test("an in-flight follow probe cannot navigate after the focused session change
|
||||
renderer = create(React.createElement(Probe));
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
});
|
||||
assert.deepEqual(navigatedPaths, ["/root/session-a"]);
|
||||
const probesAfterOpen = probeCalls;
|
||||
assert.ok(probesAfterOpen >= 1, "first-open sync still probes");
|
||||
assert.deepEqual(navigatedPaths, []);
|
||||
|
||||
deferProbe = true;
|
||||
// Command-submit style clear: no trusted cwd yet.
|
||||
activeTerminalCwd = null;
|
||||
activeTerminalCwdTrusted = false;
|
||||
await act(async () => {
|
||||
renderer?.update(React.createElement(Probe));
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
});
|
||||
assert.ok(resolveCwd);
|
||||
assert.equal(probeCalls, probesAfterOpen, "regular follow must not poll while cwd is cleared");
|
||||
assert.deepEqual(navigatedPaths, []);
|
||||
|
||||
activeSessionId = "session-b";
|
||||
await act(async () => renderer?.update(React.createElement(Probe)));
|
||||
// Post-command backend-strict (or OSC 7) publishes a trusted live cwd.
|
||||
activeTerminalCwd = "/etc";
|
||||
activeTerminalCwdTrusted = true;
|
||||
await act(async () => {
|
||||
resolveCwd?.("/root/from-session-a");
|
||||
renderer?.update(React.createElement(Probe));
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
});
|
||||
|
||||
assert.deepEqual(navigatedPaths, ["/root/session-a"]);
|
||||
assert.deepEqual(navigatedPaths, ["/etc"]);
|
||||
assert.equal(probeCalls, probesAfterOpen, "trusted live cwd must navigate without another poll");
|
||||
await act(async () => renderer?.unmount());
|
||||
});
|
||||
@@ -302,6 +302,12 @@ export function useSftpFollowTerminalCwd({
|
||||
&& previousOriginId !== expectedSessionId,
|
||||
);
|
||||
if (expectedSessionId) lastFollowOriginIdRef.current = expectedSessionId;
|
||||
// Auto-follow must wait for a trusted live cwd (OSC 7, post-command
|
||||
// backend-strict probe, or inferred). Polling preferFreshBackend here while
|
||||
// cwd is null/untrusted races that probe: navigate starts, the probe then
|
||||
// invalidates the sync generation, abort restores the previous path, and
|
||||
// without a connectionPath effect dependency the panel stays put (#3588).
|
||||
// Manual "Go to terminal directory" and first-open sync still probe.
|
||||
const usesLiveTerminalCwd = Boolean(activeTerminalCwd && activeTerminalCwdTrusted);
|
||||
let terminalCwd = usesLiveTerminalCwd ? activeTerminalCwd : null;
|
||||
if (!terminalCwd && originChanged) {
|
||||
@@ -311,12 +317,6 @@ export function useSftpFollowTerminalCwd({
|
||||
homeDir: sftpRef.current.leftPane.connection?.homeDir,
|
||||
currentPath: sftpRef.current.leftPane.connection?.currentPath,
|
||||
});
|
||||
} else if (!terminalCwd) {
|
||||
terminalCwd = await onGetTerminalCwd({
|
||||
preferFreshBackend: true,
|
||||
allowRendererFallback: false,
|
||||
requireActiveShellCwd: true,
|
||||
});
|
||||
}
|
||||
if (!terminalCwd) return;
|
||||
if (!shouldApplyFollowTerminalCwdSyncResult({
|
||||
@@ -430,6 +430,7 @@ export function useSftpFollowTerminalCwd({
|
||||
canFollowTerminalCwd,
|
||||
connectionId,
|
||||
connectionIsLocal,
|
||||
connectionPath,
|
||||
connectionStatus,
|
||||
effectiveFollowTerminalCwd,
|
||||
focusedSessionId,
|
||||
|
||||
@@ -56,6 +56,28 @@ test('active reconnect state blocks duplicate requests and notifies subscribers'
|
||||
unsubscribe();
|
||||
});
|
||||
|
||||
test('enter reconnect stays off until the session allows it and does not queue', () => {
|
||||
const registry = createTerminalReconnectRegistry();
|
||||
const requests: string[] = [];
|
||||
|
||||
assert.equal(registry.requestEnterReconnect('session-1'), false);
|
||||
registry.register('session-1', () => requests.push('session-1'));
|
||||
assert.deepEqual(requests, []);
|
||||
|
||||
registry.setEnterReconnectEnabled('session-1', true);
|
||||
assert.equal(registry.requestEnterReconnect('session-1'), true);
|
||||
assert.deepEqual(requests, ['session-1']);
|
||||
|
||||
registry.setActive('session-1', true);
|
||||
assert.equal(registry.requestEnterReconnect('session-1'), false);
|
||||
assert.deepEqual(requests, ['session-1']);
|
||||
|
||||
registry.setActive('session-1', false);
|
||||
registry.setEnterReconnectEnabled('session-1', false);
|
||||
assert.equal(registry.requestEnterReconnect('session-1'), false);
|
||||
assert.deepEqual(requests, ['session-1']);
|
||||
});
|
||||
|
||||
test('unregistering the current handler clears active reconnect state', () => {
|
||||
const registry = createTerminalReconnectRegistry();
|
||||
const unregister = registry.register('session-1', () => {});
|
||||
|
||||
@@ -4,6 +4,7 @@ export const createTerminalReconnectRegistry = () => {
|
||||
const handlers = new Map<string, TerminalReconnectHandler>();
|
||||
const pendingRequests = new Set<string>();
|
||||
const activeSessionIds = new Set<string>();
|
||||
const enterReconnectEnabledIds = new Set<string>();
|
||||
const listeners = new Set<() => void>();
|
||||
|
||||
const emit = () => {
|
||||
@@ -51,7 +52,27 @@ export const createTerminalReconnectRegistry = () => {
|
||||
return true;
|
||||
};
|
||||
|
||||
return { register, request, setActive, isActive, subscribe };
|
||||
// Enter-reconnect while a compose bar is open. Explicit reconnect clicks use
|
||||
// request(); this path stays off during auth, host-key, and blocking overlays.
|
||||
const setEnterReconnectEnabled = (sessionId: string, enabled: boolean): void => {
|
||||
if (enabled) enterReconnectEnabledIds.add(sessionId);
|
||||
else enterReconnectEnabledIds.delete(sessionId);
|
||||
};
|
||||
|
||||
const requestEnterReconnect = (sessionId: string): boolean => {
|
||||
if (!enterReconnectEnabledIds.has(sessionId)) return false;
|
||||
return request(sessionId);
|
||||
};
|
||||
|
||||
return {
|
||||
register,
|
||||
request,
|
||||
requestEnterReconnect,
|
||||
setEnterReconnectEnabled,
|
||||
setActive,
|
||||
isActive,
|
||||
subscribe,
|
||||
};
|
||||
};
|
||||
|
||||
export const terminalReconnectRegistry = createTerminalReconnectRegistry();
|
||||
@@ -0,0 +1,96 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import React, { act } from 'react';
|
||||
import { create, type ReactTestRenderer } from 'react-test-renderer';
|
||||
import { useServerStats } from './useServerStats';
|
||||
import { netcattyBridge } from '../../infrastructure/services/netcattyBridge';
|
||||
|
||||
for (const scenario of ['retry', 'pause', 'manual'] as const) {
|
||||
test(`server stats ${scenario} after three failures`, async t => {
|
||||
t.mock.timers.enable({ apis: ['setTimeout', 'setInterval', 'Date'], now: 10_000 });
|
||||
const originalActEnvironment = Object.getOwnPropertyDescriptor(globalThis, 'IS_REACT_ACT_ENVIRONMENT');
|
||||
Object.defineProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT', { configurable: true, value: true });
|
||||
let calls = 0;
|
||||
t.mock.method(netcattyBridge, 'get', () => ({
|
||||
getServerStats: async () => {
|
||||
calls++;
|
||||
return calls <= 3 ? { success: false, error: 'temporary failure' }
|
||||
: { success: true, stats: { cpu: 42 } };
|
||||
},
|
||||
}));
|
||||
let current: ReturnType<typeof useServerStats>;
|
||||
function Probe({ enabled = true }: { enabled?: boolean }) {
|
||||
current = useServerStats({ sessionId: `recovery-${scenario}`, enabled,
|
||||
refreshInterval: 5, isSupportedOs: true, isConnected: true });
|
||||
return null;
|
||||
}
|
||||
let root: ReactTestRenderer;
|
||||
try {
|
||||
await act(async () => { root = create(React.createElement(Probe)); });
|
||||
await act(async () => t.mock.timers.tick(2000));
|
||||
await act(async () => t.mock.timers.tick(3000));
|
||||
await act(async () => t.mock.timers.tick(5000));
|
||||
assert.equal(calls, 3);
|
||||
assert.equal(current!.error, 'temporary failure');
|
||||
if (scenario === 'pause') {
|
||||
await act(async () => root!.update(React.createElement(Probe, { enabled: false })));
|
||||
await act(async () => t.mock.timers.tick(60_000));
|
||||
assert.equal(calls, 3, 'disabled clients must not retry');
|
||||
await act(async () => root!.update(React.createElement(Probe)));
|
||||
await act(async () => t.mock.timers.tick(1));
|
||||
} else if (scenario === 'manual') {
|
||||
await act(async () => current!.refresh());
|
||||
} else {
|
||||
await act(async () => t.mock.timers.tick(29_999));
|
||||
assert.equal(calls, 3);
|
||||
await act(async () => t.mock.timers.tick(1));
|
||||
await act(async () => t.mock.timers.tick(1));
|
||||
}
|
||||
assert.equal(calls, 4);
|
||||
assert.equal(current!.stats.cpu, 42);
|
||||
assert.equal(current!.error, null);
|
||||
await act(async () => t.mock.timers.tick(5000));
|
||||
assert.equal(calls, 5, 'successful recovery must resume normal polling');
|
||||
} finally {
|
||||
await act(async () => root?.unmount());
|
||||
if (originalActEnvironment) Object.defineProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT', originalActEnvironment);
|
||||
else Reflect.deleteProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT');
|
||||
t.mock.timers.reset();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
test('stats reply after disabling cannot publish or schedule recovery', async t => {
|
||||
t.mock.timers.enable({ apis: ['setTimeout', 'setInterval', 'Date'], now: 10_000 });
|
||||
const originalActEnvironment = Object.getOwnPropertyDescriptor(globalThis, 'IS_REACT_ACT_ENVIRONMENT');
|
||||
Object.defineProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT', { configurable: true, value: true });
|
||||
let finish: (result: unknown) => void;
|
||||
let calls = 0;
|
||||
t.mock.method(netcattyBridge, 'get', () => ({ getServerStats: () => {
|
||||
calls++;
|
||||
return new Promise(resolve => { finish = resolve; });
|
||||
} }));
|
||||
let current: ReturnType<typeof useServerStats>;
|
||||
function Probe({ enabled }: { enabled: boolean }) {
|
||||
current = useServerStats({ sessionId: 'late-disabled', enabled,
|
||||
refreshInterval: 5, isSupportedOs: true, isConnected: true });
|
||||
return null;
|
||||
}
|
||||
let root: ReactTestRenderer;
|
||||
try {
|
||||
await act(async () => { root = create(React.createElement(Probe, { enabled: true })); });
|
||||
await act(async () => t.mock.timers.tick(2000));
|
||||
assert.equal(calls, 1);
|
||||
await act(async () => root!.update(React.createElement(Probe, { enabled: false })));
|
||||
await act(async () => finish!({ success: true, stats: { cpu: 99 } }));
|
||||
assert.equal(current!.stats.lastUpdated, null);
|
||||
assert.equal(current!.isLoading, false);
|
||||
await act(async () => t.mock.timers.tick(60_000));
|
||||
assert.equal(calls, 1);
|
||||
} finally {
|
||||
await act(async () => root?.unmount());
|
||||
if (originalActEnvironment) Object.defineProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT', originalActEnvironment);
|
||||
else Reflect.deleteProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT');
|
||||
t.mock.timers.reset();
|
||||
}
|
||||
});
|
||||
@@ -24,6 +24,14 @@ export interface ProcessInfo {
|
||||
command: string;
|
||||
}
|
||||
|
||||
export interface GpuDeviceStats {
|
||||
index: number;
|
||||
name: string;
|
||||
utilization: number | null; // percent, null when nvidia-smi reports N/A
|
||||
memoryUsed: number | null; // MiB
|
||||
memoryTotal: number | null; // MiB
|
||||
}
|
||||
|
||||
export interface ServerStats {
|
||||
hostname?: string; // Remote hostname
|
||||
osName?: string; // Remote operating system name
|
||||
@@ -37,6 +45,7 @@ export interface ServerStats {
|
||||
gpuName: string | null; // First GPU name (tooltip)
|
||||
gpuMemUsed: number | null; // Summed VRAM used in MB
|
||||
gpuMemTotal: number | null; // Summed VRAM total in MB
|
||||
gpus: GpuDeviceStats[]; // Per-GPU utilization and VRAM
|
||||
memTotal: number | null; // Total memory in MB
|
||||
memUsed: number | null; // Used memory in MB (excluding buffers/cache)
|
||||
memFree: number | null; // Free memory in MB
|
||||
@@ -86,6 +95,7 @@ interface SharedServerStatsSession {
|
||||
clients: Map<number, ServerStatsClient>;
|
||||
intervalRef: ReturnType<typeof setInterval> | null;
|
||||
initialTimerRef: ReturnType<typeof setTimeout> | null;
|
||||
retryTimerRef: ReturnType<typeof setTimeout> | null;
|
||||
connectedAt: number;
|
||||
hasFetched: boolean;
|
||||
fetchGeneration: number;
|
||||
@@ -115,6 +125,7 @@ function createEmptyServerStats(): ServerStats {
|
||||
gpuName: null,
|
||||
gpuMemUsed: null,
|
||||
gpuMemTotal: null,
|
||||
gpus: [],
|
||||
memTotal: null,
|
||||
memUsed: null,
|
||||
memFree: null,
|
||||
@@ -154,6 +165,7 @@ function getSharedServerStatsSession(sessionId: string): SharedServerStatsSessio
|
||||
clients: new Map(),
|
||||
intervalRef: null,
|
||||
initialTimerRef: null,
|
||||
retryTimerRef: null,
|
||||
connectedAt: 0,
|
||||
hasFetched: false,
|
||||
fetchGeneration: 0,
|
||||
@@ -180,6 +192,10 @@ function updateServerStatsState(session: SharedServerStatsSession, patch: Partia
|
||||
}
|
||||
|
||||
function clearServerStatsTimers(session: SharedServerStatsSession): void {
|
||||
if (session.retryTimerRef) {
|
||||
clearTimeout(session.retryTimerRef);
|
||||
session.retryTimerRef = null;
|
||||
}
|
||||
if (session.initialTimerRef) {
|
||||
clearTimeout(session.initialTimerRef);
|
||||
session.initialTimerRef = null;
|
||||
@@ -212,6 +228,7 @@ function normalizeServerStats(stats: Partial<ServerStats>): ServerStats {
|
||||
gpuName: stats.gpuName ?? null,
|
||||
gpuMemUsed: stats.gpuMemUsed ?? null,
|
||||
gpuMemTotal: stats.gpuMemTotal ?? null,
|
||||
gpus: Array.isArray(stats.gpus) ? stats.gpus : [],
|
||||
memTotal: stats.memTotal ?? null,
|
||||
memUsed: stats.memUsed ?? null,
|
||||
memFree: stats.memFree ?? null,
|
||||
@@ -236,10 +253,17 @@ function markServerStatsFailure(session: SharedServerStatsSession, message: stri
|
||||
session.consecutiveFailures += 1;
|
||||
updateServerStatsState(session, { error: message });
|
||||
if (session.consecutiveFailures >= CONSECUTIVE_FAILURE_LIMIT) {
|
||||
// Stop polling this session. This covers network devices that advertise
|
||||
// OpenSSH but do not support the POSIX stats shell command.
|
||||
// Back off unsupported or temporarily unavailable probes, then recover
|
||||
// automatically. Three transient failures must not disable the panel for
|
||||
// the entire lifetime of an otherwise healthy terminal session.
|
||||
session.givenUp = true;
|
||||
clearServerStatsTimers(session);
|
||||
session.retryTimerRef = setTimeout(() => {
|
||||
session.retryTimerRef = null;
|
||||
session.givenUp = false;
|
||||
session.consecutiveFailures = 0;
|
||||
reconcileSharedServerStatsSession(session);
|
||||
}, 30_000);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -256,6 +280,10 @@ async function fetchSharedServerStats(session: SharedServerStatsSession, force =
|
||||
if (!bridge?.getServerStats) return;
|
||||
|
||||
if (force) {
|
||||
if (session.retryTimerRef) {
|
||||
clearTimeout(session.retryTimerRef);
|
||||
session.retryTimerRef = null;
|
||||
}
|
||||
session.givenUp = false;
|
||||
session.consecutiveFailures = 0;
|
||||
}
|
||||
@@ -319,6 +347,11 @@ function reconcileSharedServerStatsSession(session: SharedServerStatsSession): v
|
||||
// (or re-enabling a terminal stats strip) can paint immediately instead of
|
||||
// flashing "No system overview data yet." Still clear give-up / failure so a
|
||||
// later resume can auto-retry (old reset wiped stats + flags together).
|
||||
// Invalidate a request before pausing: a late reply must not publish data
|
||||
// or re-arm failure/retry timers after disconnect/disable.
|
||||
if (session.pollingActive || session.inflight || session.givenUp) {
|
||||
session.fetchGeneration += 1;
|
||||
}
|
||||
clearServerStatsTimers(session);
|
||||
session.givenUp = false;
|
||||
session.consecutiveFailures = 0;
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import React from "react";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
|
||||
import { I18nProvider } from "../application/i18n/I18nProvider";
|
||||
import type { Host, PortForwardingRule, PortForwardingType } from "../domain/models";
|
||||
import { EditPanel } from "./port-forwarding/EditPanel";
|
||||
import { NewFormPanel } from "./port-forwarding/NewFormPanel";
|
||||
import { WizardContent } from "./port-forwarding/WizardContent";
|
||||
import { TooltipProvider } from "./ui/tooltip";
|
||||
|
||||
const baseHost: Omit<Host, "os"> = {
|
||||
id: "host-1",
|
||||
label: "Forwarding host",
|
||||
hostname: "example.com",
|
||||
username: "user",
|
||||
tags: [],
|
||||
};
|
||||
|
||||
const baseRule: PortForwardingRule = {
|
||||
id: "rule-1",
|
||||
label: "Test forwarding rule",
|
||||
type: "local",
|
||||
hostId: baseHost.id,
|
||||
localPort: 8080,
|
||||
bindAddress: "127.0.0.1",
|
||||
remoteHost: "localhost",
|
||||
remotePort: 80,
|
||||
status: "inactive",
|
||||
createdAt: 1,
|
||||
};
|
||||
|
||||
const noop = () => undefined;
|
||||
const panelRenderers = {
|
||||
edit: (hosts: Host[], draft: PortForwardingRule) => (
|
||||
<EditPanel
|
||||
rule={draft}
|
||||
draft={draft}
|
||||
hosts={hosts}
|
||||
onDraftChange={noop}
|
||||
onSave={noop}
|
||||
onClose={noop}
|
||||
onDuplicate={noop}
|
||||
onDelete={noop}
|
||||
onOpenHostSelector={noop}
|
||||
/>
|
||||
),
|
||||
new: (hosts: Host[], draft: PortForwardingRule) => (
|
||||
<NewFormPanel
|
||||
draft={draft}
|
||||
hosts={hosts}
|
||||
onDraftChange={noop}
|
||||
onSave={noop}
|
||||
onClose={noop}
|
||||
onOpenHostSelector={noop}
|
||||
onOpenWizard={noop}
|
||||
isValid={false}
|
||||
/>
|
||||
),
|
||||
"remote-host-selection": (hosts: Host[], draft: PortForwardingRule) => (
|
||||
<WizardContent
|
||||
step="remote-host-selection"
|
||||
type={draft.type}
|
||||
draft={draft}
|
||||
hosts={hosts}
|
||||
onTypeChange={noop}
|
||||
onDraftChange={noop}
|
||||
onOpenHostSelector={noop}
|
||||
/>
|
||||
),
|
||||
"host-selection": (hosts: Host[], draft: PortForwardingRule) => (
|
||||
<WizardContent
|
||||
step="host-selection"
|
||||
type={draft.type}
|
||||
draft={draft}
|
||||
hosts={hosts}
|
||||
onTypeChange={noop}
|
||||
onDraftChange={noop}
|
||||
onOpenHostSelector={noop}
|
||||
/>
|
||||
),
|
||||
};
|
||||
|
||||
const hostCases: { name: string; fields: { os?: Host["os"] | "" | null } }[] = [
|
||||
{ name: "missing OS", fields: {} },
|
||||
{ name: "undefined OS", fields: { os: undefined } },
|
||||
{ name: "null OS", fields: { os: null } },
|
||||
{ name: "empty OS", fields: { os: "" } },
|
||||
{ name: "Linux", fields: { os: "linux" } },
|
||||
{ name: "Windows", fields: { os: "windows" } },
|
||||
{ name: "macOS", fields: { os: "macos" } },
|
||||
];
|
||||
|
||||
function render(panel: React.ReactNode): string {
|
||||
return renderToStaticMarkup(
|
||||
<I18nProvider locale="en">
|
||||
<TooltipProvider>{panel}</TooltipProvider>
|
||||
</I18nProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
for (const [panelName, renderPanel] of Object.entries(panelRenderers)) {
|
||||
for (const type of ["local", "remote", "dynamic"] as PortForwardingType[]) {
|
||||
for (const { name, fields } of hostCases) {
|
||||
test(`${panelName} renders ${type} forwarding with ${name}`, () => {
|
||||
// Persisted legacy records can violate the required Host.os type at runtime.
|
||||
const host = { ...baseHost, ...fields } as unknown as Host;
|
||||
const markup = render(renderPanel([host], { ...baseRule, type }));
|
||||
|
||||
assert.match(markup, /Forwarding host/);
|
||||
assert.match(markup, /lucide-server/);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
test(`${panelName} preserves the selected host's distro icon`, () => {
|
||||
const host: Host = { ...baseHost, os: "linux", distro: "ubuntu" };
|
||||
const markup = render(renderPanel([host], baseRule));
|
||||
|
||||
assert.match(markup, /Forwarding host/);
|
||||
assert.match(markup, /src="\/distro\/ubuntu\.svg"/);
|
||||
});
|
||||
|
||||
test(`${panelName} renders the host picker when no host is selected`, () => {
|
||||
const markup = render(renderPanel([], { ...baseRule, hostId: undefined }));
|
||||
|
||||
assert.match(markup, /Select a host/);
|
||||
assert.doesNotMatch(markup, /Forwarding host/);
|
||||
});
|
||||
|
||||
test(`${panelName} renders the host picker when the selected host is unavailable`, () => {
|
||||
const markup = render(renderPanel([], baseRule));
|
||||
|
||||
assert.match(markup, /Select a host/);
|
||||
assert.doesNotMatch(markup, /Forwarding host/);
|
||||
});
|
||||
}
|
||||
@@ -76,8 +76,8 @@ import {
|
||||
resolveLocateSftpPathInTerminalAction,
|
||||
resolveLocateSftpPathSessionId,
|
||||
} from "../domain/sftpLocatePathInTerminal";
|
||||
import { sftpRequiresDedicatedLogin } from "../domain/sftpDedicatedLogin";
|
||||
import { classifyDistroId } from "../domain/host";
|
||||
import { remoteSoftwareRequiresSingleChannel } from "../domain/singleChannelSshBanner.shared.mjs";
|
||||
import { useTerminalBackend } from "../application/state/useTerminalBackend";
|
||||
import { isTerminalSensitiveInputActive } from "./terminal/runtime/terminalSensitiveInputRegistry";
|
||||
import { isTerminalReadyForCommandInjection } from "./terminal/runtime/terminalCommandInjectionReadyRegistry";
|
||||
@@ -577,7 +577,7 @@ const SftpSidePanelInner: React.FC<SftpSidePanelProps> = ({
|
||||
const terminalBackend = useTerminalBackend();
|
||||
const [sftpBannerDecision, setSftpBannerDecision] = useState<{
|
||||
sessionId: string;
|
||||
singleChannel: boolean;
|
||||
remoteSshVersion?: string;
|
||||
} | null>(null);
|
||||
const activeSessionStatusForBanner = activeSessionId
|
||||
? sessions.find((session) => session.id === activeSessionId)?.status ?? null
|
||||
@@ -590,7 +590,7 @@ const SftpSidePanelInner: React.FC<SftpSidePanelProps> = ({
|
||||
}
|
||||
const readRemoteInfo = terminalBackend.getSessionRemoteInfo;
|
||||
if (!readRemoteInfo) {
|
||||
setSftpBannerDecision({ sessionId: activeSessionId, singleChannel: false });
|
||||
setSftpBannerDecision({ sessionId: activeSessionId });
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
@@ -598,11 +598,11 @@ const SftpSidePanelInner: React.FC<SftpSidePanelProps> = ({
|
||||
if (cancelled) return;
|
||||
setSftpBannerDecision({
|
||||
sessionId: activeSessionId,
|
||||
singleChannel: remoteSoftwareRequiresSingleChannel(info?.remoteSshVersion),
|
||||
remoteSshVersion: info?.remoteSshVersion,
|
||||
});
|
||||
}).catch(() => {
|
||||
if (!cancelled) {
|
||||
setSftpBannerDecision({ sessionId: activeSessionId, singleChannel: false });
|
||||
setSftpBannerDecision({ sessionId: activeSessionId });
|
||||
}
|
||||
});
|
||||
return () => {
|
||||
@@ -733,8 +733,8 @@ const SftpSidePanelInner: React.FC<SftpSidePanelProps> = ({
|
||||
nextStatus: activeSessionStatus ?? (activeSessionId ? "connected" : null),
|
||||
});
|
||||
// The peer banner is known only after the terminal handshake. Wait for it
|
||||
// before choosing a shared terminal channel, so a one-channel bastion
|
||||
// dials its file login directly.
|
||||
// before choosing a shared terminal channel, so a one-channel bastion or
|
||||
// network-vendor switch dials its file login directly.
|
||||
if (
|
||||
activeSessionId
|
||||
&& activeSessionStatus === "connected"
|
||||
@@ -935,8 +935,14 @@ const SftpSidePanelInner: React.FC<SftpSidePanelProps> = ({
|
||||
|
||||
connectedKeyRef.current = connectionKey;
|
||||
connectedHostObjRef.current = activeHost;
|
||||
const dedicatedSftpLogin = sftpBannerDecision?.sessionId === activeSessionId
|
||||
&& sftpBannerDecision.singleChannel;
|
||||
// Independent login: network-device mode, a network-vendor banner, or a
|
||||
// one-channel bastion. Do not open SFTP on the terminal connection.
|
||||
const dedicatedSftpLogin = sftpRequiresDedicatedLogin({
|
||||
host: activeHost,
|
||||
remoteSshVersion: sftpBannerDecision?.sessionId === activeSessionId
|
||||
? sftpBannerDecision.remoteSshVersion
|
||||
: undefined,
|
||||
});
|
||||
const reuseTerminalTransport = !dedicatedSftpLogin && (
|
||||
Boolean(pendingStrictSourceSessionId) || activeSessionStatus === "connected"
|
||||
);
|
||||
@@ -1382,10 +1388,12 @@ const SftpSidePanelInner: React.FC<SftpSidePanelProps> = ({
|
||||
sftpDefaultViewMode={sftpDefaultViewMode}
|
||||
activeHost={activeHost}
|
||||
activeSessionId={activeSessionId}
|
||||
bannerRequiresSingleChannel={
|
||||
sftpBannerDecision?.sessionId === activeSessionId
|
||||
&& sftpBannerDecision.singleChannel
|
||||
}
|
||||
bannerRequiresSingleChannel={sftpRequiresDedicatedLogin({
|
||||
host: activeHost,
|
||||
remoteSshVersion: sftpBannerDecision?.sessionId === activeSessionId
|
||||
? sftpBannerDecision.remoteSshVersion
|
||||
: undefined,
|
||||
})}
|
||||
focusedSessionId={focusedSessionId}
|
||||
showWorkspaceHostHeader={showWorkspaceHostHeader}
|
||||
renderOverlays={renderOverlays}
|
||||
|
||||
+12
-2
@@ -1162,7 +1162,17 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
allowRendererFallback?: boolean;
|
||||
requireActiveShellCwd?: boolean;
|
||||
}): Promise<string | undefined> => {
|
||||
const skipBackendPwd = hostRestrictsExtraSshChannels(host);
|
||||
// The backend pwd probe opens an exec channel on the terminal connection;
|
||||
// network devices (mode, detected distro, or vendor banner such as Huawei
|
||||
// `-`) can drop the whole session for it (#1043, #3631).
|
||||
let skipBackendPwd = hostRestrictsExtraSshChannels(host);
|
||||
if (!skipBackendPwd && sessionRef.current) {
|
||||
const info = await terminalBackend.getSessionRemoteInfo?.(sessionRef.current).catch(() => null);
|
||||
skipBackendPwd = !shouldProbeSessionCwd({
|
||||
isNetworkDevice: classifyDistroId(host.distro) === 'network-device',
|
||||
remoteSshVersion: info?.remoteSshVersion,
|
||||
});
|
||||
}
|
||||
const cwd = await resolvePreferredTerminalCwd({
|
||||
rendererCwd: terminalCwdTracker.getRendererCwd(),
|
||||
rendererCwdSource: terminalCwdTracker.getRendererCwdSource(),
|
||||
@@ -1175,7 +1185,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
requireActiveShellCwd: options?.requireActiveShellCwd,
|
||||
});
|
||||
return cwd ?? undefined;
|
||||
}, [terminalBackend, terminalCwdTracker]);
|
||||
}, [host, terminalBackend, terminalCwdTracker]);
|
||||
|
||||
const clearTerminalCwd = useCallback((options?: { persistRestoreMetadata?: boolean }) => {
|
||||
terminalCwdTracker.clearRendererCwd();
|
||||
|
||||
@@ -1594,7 +1594,15 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
}): Promise<string | null> => {
|
||||
const sessionId = getActiveTerminalSessionId();
|
||||
const host = sessionId ? sessionHostsMapRef.current.get(sessionId) : undefined;
|
||||
const skipBackendPwd = hostRestrictsExtraSshChannels(host);
|
||||
// Same rule as the terminal's own SFTP path lookup (#3631).
|
||||
let skipBackendPwd = hostRestrictsExtraSshChannels(host);
|
||||
if (!skipBackendPwd && sessionId && host) {
|
||||
const info = await terminalBackend.getSessionRemoteInfo?.(sessionId).catch(() => null);
|
||||
skipBackendPwd = !shouldProbeSessionCwd({
|
||||
isNetworkDevice: classifyDistroId(host.distro) === 'network-device',
|
||||
remoteSshVersion: info?.remoteSshVersion,
|
||||
});
|
||||
}
|
||||
return resolvePreferredTerminalCwd({
|
||||
rendererCwd: sessionId ? terminalRendererCwdBySessionRef.current.get(sessionId) : undefined,
|
||||
rendererCwdSource: sessionId
|
||||
|
||||
@@ -294,6 +294,26 @@ test("session close button double click stays on the close button", () => {
|
||||
assert.match(topTabItemsSource, /onDoubleClick=\{stopCloseButtonDoubleClickPropagation\}/);
|
||||
});
|
||||
|
||||
test("main window controls keep mouse presses from taking focus", () => {
|
||||
const windowControlsSource = topTabItemsSource.slice(
|
||||
topTabItemsSource.indexOf("export const WindowControls"),
|
||||
topTabItemsSource.indexOf("WindowControls.displayName"),
|
||||
);
|
||||
|
||||
assert.match(
|
||||
windowControlsSource,
|
||||
/const preventWindowControlFocus: React\.MouseEventHandler<HTMLButtonElement> = \(event\) => \{\s*event\.preventDefault\(\);\s*\};/,
|
||||
);
|
||||
assert.equal(windowControlsSource.match(/onMouseDown=\{preventWindowControlFocus\}/g)?.length, 2);
|
||||
const closeButton = windowControlsSource.match(
|
||||
/<button type="button" className=\{closeControlClassName\}[^>]*>/,
|
||||
)?.[0] ?? "";
|
||||
assert.equal(closeButton.includes("onMouseDown"), false);
|
||||
assert.match(windowControlsSource, /onClick=\{handleMinimize\}/);
|
||||
assert.match(windowControlsSource, /onClick=\{handleMaximize\}/);
|
||||
assert.match(windowControlsSource, /onClick=\{handleClose\}/);
|
||||
});
|
||||
|
||||
test("session top tab label only shows the host label for ssh sessions", () => {
|
||||
assert.equal(
|
||||
formatSessionTopTabLabel({
|
||||
|
||||
@@ -134,7 +134,7 @@ export const EditPanel: React.FC<EditPanelProps> = ({
|
||||
<div className="flex items-center gap-2">
|
||||
<DistroAvatar
|
||||
host={selectedHost}
|
||||
fallback={selectedHost.os[0].toUpperCase()}
|
||||
fallback={(selectedHost.os || 'unknown').charAt(0).toUpperCase()}
|
||||
size="tree"
|
||||
/>
|
||||
<span>{selectedHost.label}</span>
|
||||
|
||||
@@ -134,7 +134,7 @@ export const NewFormPanel: React.FC<NewFormPanelProps> = ({
|
||||
<div className="flex items-center gap-2">
|
||||
<DistroAvatar
|
||||
host={selectedHost}
|
||||
fallback={selectedHost.os[0].toUpperCase()}
|
||||
fallback={(selectedHost.os || 'unknown').charAt(0).toUpperCase()}
|
||||
size="tree"
|
||||
/>
|
||||
<span>{selectedHost.label}</span>
|
||||
|
||||
@@ -123,7 +123,11 @@ export const WizardContent: React.FC<WizardContentProps> = ({
|
||||
>
|
||||
{selectedHost ? (
|
||||
<div className="flex items-center gap-2 w-full">
|
||||
<DistroAvatar host={selectedHost} fallback={selectedHost.os[0].toUpperCase()} size="sm" />
|
||||
<DistroAvatar
|
||||
host={selectedHost}
|
||||
fallback={(selectedHost.os || 'unknown').charAt(0).toUpperCase()}
|
||||
size="sm"
|
||||
/>
|
||||
<span>{selectedHost.label}</span>
|
||||
<Check size={14} className="ml-auto" />
|
||||
</div>
|
||||
@@ -228,7 +232,11 @@ export const WizardContent: React.FC<WizardContentProps> = ({
|
||||
>
|
||||
{selectedHost ? (
|
||||
<div className="flex items-center gap-2 w-full">
|
||||
<DistroAvatar host={selectedHost} fallback={selectedHost.os[0].toUpperCase()} size="sm" />
|
||||
<DistroAvatar
|
||||
host={selectedHost}
|
||||
fallback={(selectedHost.os || 'unknown').charAt(0).toUpperCase()}
|
||||
size="sm"
|
||||
/>
|
||||
<span>{selectedHost.label}</span>
|
||||
<Check size={14} className="ml-auto" />
|
||||
</div>
|
||||
|
||||
@@ -224,9 +224,9 @@ export const TerminalBehaviorSettings: React.FC<TerminalBehaviorSettingsProps> =
|
||||
description={t("settings.terminal.behavior.shiftEnterNewlineText.desc")}
|
||||
>
|
||||
<Input
|
||||
value={terminalSettings.shiftEnterNewlineText ?? "\\n"}
|
||||
value={terminalSettings.shiftEnterNewlineText ?? "\\e\\r"}
|
||||
onChange={(e) => updateTerminalSetting("shiftEnterNewlineText", e.target.value)}
|
||||
placeholder="\\n"
|
||||
placeholder="\\e\\r"
|
||||
className="w-56 font-mono"
|
||||
spellCheck={false}
|
||||
/>
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import test from "node:test";
|
||||
import { remoteSoftwareRequiresSingleChannel } from "../../domain/singleChannelSshBanner.shared.mjs";
|
||||
import { sftpRequiresDedicatedLogin } from "../../domain/sftpDedicatedLogin";
|
||||
|
||||
const sidePanelSource = readFileSync(new URL("../SftpSidePanel.tsx", import.meta.url), "utf8");
|
||||
const slotSource = readFileSync(
|
||||
@@ -49,12 +51,99 @@ test("locate-path uses focused session fallback when SFTP cannot reuse the termi
|
||||
test("single-channel SFTP opens a dedicated connection instead of the terminal channel", () => {
|
||||
assert.match(
|
||||
sidePanelSource,
|
||||
/const dedicatedSftpLogin = sftpBannerDecision\?\.sessionId === activeSessionId\s*&& sftpBannerDecision\.singleChannel;/,
|
||||
/const dedicatedSftpLogin = sftpRequiresDedicatedLogin\(\{\s*host: activeHost,\s*remoteSshVersion: sftpBannerDecision\?\.sessionId === activeSessionId\s*\? sftpBannerDecision\.remoteSshVersion\s*: undefined,\s*\}\);/,
|
||||
);
|
||||
assert.doesNotMatch(sidePanelSource, /activeHost\.singleChannelSsh/);
|
||||
assert.doesNotMatch(sidePanelSource, /sftpBannerDecision\.singleChannel/);
|
||||
assert.match(sidePanelSource, /reuseTransport: false/);
|
||||
assert.match(
|
||||
sidePanelSource,
|
||||
/const reuseTerminalTransport = !dedicatedSftpLogin &&/,
|
||||
);
|
||||
assert.match(
|
||||
sidePanelSource,
|
||||
/sourceSessionId: reuseTerminalTransport/,
|
||||
);
|
||||
});
|
||||
|
||||
test("network-device mode and network-vendor banners force dedicated SFTP", () => {
|
||||
assert.equal(
|
||||
sftpRequiresDedicatedLogin({ host: { deviceType: "network" }, remoteSshVersion: "OpenSSH_9.6" }),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
sftpRequiresDedicatedLogin({ host: { deviceType: "network" } }),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
sftpRequiresDedicatedLogin({ host: { deviceType: "general" }, remoteSshVersion: "-" }),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
sftpRequiresDedicatedLogin({ host: { deviceType: "general" }, remoteSshVersion: "SSH-2.0--" }),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
sftpRequiresDedicatedLogin({ host: { deviceType: "general" }, remoteSshVersion: "SSH-2.0-RGOS_SSH" }),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
sftpRequiresDedicatedLogin({ host: { deviceType: "general" }, remoteSshVersion: "OpenSSH_9.6" }),
|
||||
false,
|
||||
);
|
||||
assert.equal(remoteSoftwareRequiresSingleChannel("-"), false);
|
||||
assert.equal(remoteSoftwareRequiresSingleChannel("SSH-2.0--"), false);
|
||||
});
|
||||
|
||||
test("vendor banners block follow-terminal-cwd the same way bastions do", () => {
|
||||
assert.match(
|
||||
sidePanelSource,
|
||||
/bannerRequiresSingleChannel=\{sftpRequiresDedicatedLogin\(\{\s*host: activeHost,\s*remoteSshVersion: sftpBannerDecision\?\.sessionId === activeSessionId\s*\? sftpBannerDecision\.remoteSshVersion\s*: undefined,\s*\}\)\}/,
|
||||
);
|
||||
assert.doesNotMatch(sidePanelSource, /remoteSoftwareRequiresSingleChannel/);
|
||||
for (const remoteSshVersion of ["-", "SSH-2.0--", "BHostSSH_7.0"]) {
|
||||
assert.equal(
|
||||
sftpRequiresDedicatedLogin({
|
||||
host: { deviceType: "general" },
|
||||
remoteSshVersion,
|
||||
}),
|
||||
true,
|
||||
remoteSshVersion,
|
||||
);
|
||||
}
|
||||
assert.equal(
|
||||
sftpRequiresDedicatedLogin({
|
||||
host: { deviceType: "network" },
|
||||
remoteSshVersion: "OpenSSH_9.6",
|
||||
}),
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
test("opening SFTP skips the terminal pwd exec for network-vendor banners", () => {
|
||||
const terminalSource = readFileSync(new URL("../Terminal.tsx", import.meta.url), "utf8");
|
||||
const layerSource = readFileSync(new URL("../TerminalLayer.tsx", import.meta.url), "utf8");
|
||||
|
||||
const assertBannerPwdGuard = (source: string, fnName: string) => {
|
||||
const start = source.indexOf(`const ${fnName} = useCallback`);
|
||||
assert.ok(start >= 0, fnName);
|
||||
const body = source.slice(start, start + 1600);
|
||||
assert.match(body, /let skipBackendPwd = hostRestrictsExtraSshChannels\(host\);/);
|
||||
assert.match(body, /getSessionRemoteInfo\?\./);
|
||||
assert.match(body, /skipBackendPwd = !shouldProbeSessionCwd\(\{/);
|
||||
assert.match(body, /classifyDistroId\(host\.distro\) === 'network-device'/);
|
||||
assert.match(body, /remoteSshVersion: info\?\.remoteSshVersion/);
|
||||
assert.match(body, /getSessionPwd: skipBackendPwd/);
|
||||
};
|
||||
|
||||
assertBannerPwdGuard(terminalSource, "resolveSftpInitialPath");
|
||||
assertBannerPwdGuard(layerSource, "getTerminalCwd");
|
||||
assert.match(
|
||||
terminalSource,
|
||||
/import \{ classifyDistroId, hostRestrictsExtraSshChannels, shouldProbeSessionCwd \} from "\.\.\/domain\/host";/,
|
||||
);
|
||||
assert.match(
|
||||
layerSource,
|
||||
/import \{ classifyDistroId, hostRestrictsExtraSshChannels, shouldProbeSessionCwd \} from '\.\.\/domain\/host';/,
|
||||
);
|
||||
});
|
||||
@@ -17,8 +17,13 @@ test("SFTP side panel rebinds after same-tab SSH start-over", () => {
|
||||
/previousStatus:\s*lastSourceSessionStatusRef\.current/,
|
||||
);
|
||||
// Reuse the terminal transport only when SSH is connected, or when a strict
|
||||
// pending source id is already pinned. A dedicated single-channel login never
|
||||
// reuses that transport. A linked id may arrive while reconnecting.
|
||||
// pending source id is already pinned. A dedicated login (bastion banner,
|
||||
// network-vendor banner, or network-device mode) never reuses that
|
||||
// transport. A linked id may arrive while reconnecting.
|
||||
assert.match(
|
||||
sidePanelSource,
|
||||
/const dedicatedSftpLogin = sftpRequiresDedicatedLogin\(\{/,
|
||||
);
|
||||
assert.match(
|
||||
sidePanelSource,
|
||||
/const reuseTerminalTransport = !dedicatedSftpLogin && \(\s*Boolean\(pendingStrictSourceSessionId\) \|\| activeSessionStatus === "connected"\s*\);/,
|
||||
|
||||
@@ -6,6 +6,7 @@ import { act, create, type ReactTestRenderer } from 'react-test-renderer';
|
||||
import { TerminalComposeBar } from './TerminalComposeBar';
|
||||
import { TooltipProvider } from '../ui/tooltip';
|
||||
import { getComposeBarHistory, pruneComposeBarHistory, recordComposeBarHistory } from '../../application/state/composeBarHistoryStore';
|
||||
import { terminalReconnectRegistry } from '../../application/state/terminalReconnectRegistry';
|
||||
|
||||
test('compose bar preserves a draft on pane focus and records only successful async sends', async (t) => {
|
||||
const dom = new JSDOM('<!doctype html><html><body></body></html>', { url: 'http://localhost' });
|
||||
@@ -102,3 +103,95 @@ test('compose bar preserves a draft on pane focus and records only successful as
|
||||
await act(async () => { root.unmount(); });
|
||||
pruneComposeBarHistory([]);
|
||||
});
|
||||
|
||||
test('empty compose draft Enter reconnects; a typed draft still sends', async (t) => {
|
||||
const dom = new JSDOM('<!doctype html><html><body></body></html>', { url: 'http://localhost' });
|
||||
const originals = new Map<string, PropertyDescriptor | undefined>();
|
||||
for (const name of ['window', 'document', 'localStorage']) {
|
||||
originals.set(name, Object.getOwnPropertyDescriptor(globalThis, name));
|
||||
Object.defineProperty(globalThis, name, { configurable: true, value: dom.window[name as keyof Window] });
|
||||
}
|
||||
const sessionId = 'compose-enter-reconnect';
|
||||
const reconnects: string[] = [];
|
||||
const unregister = terminalReconnectRegistry.register(sessionId, () => {
|
||||
reconnects.push(sessionId);
|
||||
});
|
||||
t.after(() => {
|
||||
unregister();
|
||||
terminalReconnectRegistry.setEnterReconnectEnabled(sessionId, false);
|
||||
dom.window.close();
|
||||
for (const [name, descriptor] of originals) {
|
||||
if (descriptor) Object.defineProperty(globalThis, name, descriptor);
|
||||
else Reflect.deleteProperty(globalThis, name);
|
||||
}
|
||||
});
|
||||
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
|
||||
const createTextarea = () => ({
|
||||
value: '', selectionStart: 0, selectionEnd: 0,
|
||||
focus() {},
|
||||
setSelectionRange() {},
|
||||
});
|
||||
let textarea = createTextarea();
|
||||
const sent: string[] = [];
|
||||
let root!: ReactTestRenderer;
|
||||
await act(async () => {
|
||||
root = create(
|
||||
<TooltipProvider>
|
||||
<TerminalComposeBar sessionId={sessionId} onSend={(text) => { sent.push(text); return true; }} onClose={() => {}} />
|
||||
</TooltipProvider>,
|
||||
{ createNodeMock: (el) => {
|
||||
if (el.type !== 'textarea') return null;
|
||||
textarea = createTextarea();
|
||||
return textarea;
|
||||
} },
|
||||
);
|
||||
});
|
||||
const key = (name: string, extra: Record<string, unknown> = {}) => {
|
||||
let prevented = false;
|
||||
root.root.findByType('textarea').props.onKeyDown({
|
||||
key: name,
|
||||
shiftKey: false,
|
||||
altKey: false,
|
||||
ctrlKey: false,
|
||||
metaKey: false,
|
||||
nativeEvent: {},
|
||||
currentTarget: textarea,
|
||||
preventDefault() { prevented = true; },
|
||||
...extra,
|
||||
});
|
||||
return prevented;
|
||||
};
|
||||
|
||||
terminalReconnectRegistry.setEnterReconnectEnabled(sessionId, true);
|
||||
assert.equal(key('Enter'), true);
|
||||
assert.deepEqual(reconnects, [sessionId]);
|
||||
assert.deepEqual(sent, []);
|
||||
assert.equal(textarea.value, '');
|
||||
|
||||
textarea.value = 'echo hi';
|
||||
assert.equal(key('Enter'), true);
|
||||
assert.deepEqual(reconnects, [sessionId]);
|
||||
assert.deepEqual(sent, ['echo hi']);
|
||||
assert.equal(textarea.value, '');
|
||||
|
||||
textarea.value = 'keep me';
|
||||
assert.equal(key('Enter', { shiftKey: true }), false);
|
||||
assert.equal(textarea.value, 'keep me');
|
||||
assert.deepEqual(sent, ['echo hi']);
|
||||
assert.deepEqual(reconnects, [sessionId]);
|
||||
|
||||
textarea.value = '';
|
||||
assert.equal(key('Enter', { ctrlKey: true }), true);
|
||||
assert.deepEqual(sent, ['echo hi']);
|
||||
assert.deepEqual(reconnects, [sessionId]);
|
||||
|
||||
assert.equal(key('Enter', { nativeEvent: { isComposing: true, keyCode: 229 } }), false);
|
||||
assert.deepEqual(reconnects, [sessionId]);
|
||||
|
||||
terminalReconnectRegistry.setEnterReconnectEnabled(sessionId, false);
|
||||
assert.equal(key('Enter'), true);
|
||||
assert.deepEqual(reconnects, [sessionId]);
|
||||
assert.deepEqual(sent, ['echo hi']);
|
||||
|
||||
await act(async () => { root.unmount(); });
|
||||
});
|
||||
@@ -5,6 +5,7 @@
|
||||
*/
|
||||
import { GripHorizontal, Pin, Plus, Radio, Search, X } from 'lucide-react';
|
||||
import React, { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { terminalReconnectRegistry } from '../../application/state/terminalReconnectRegistry';
|
||||
import { useComposeBarHistory } from '../../application/state/useComposeBarHistory';
|
||||
import { canNavigateComposeBarHistory } from '../../domain/composeBarHistory';
|
||||
import { STORAGE_KEY_TERMINAL_BROADCAST_PASSWORD_BYPASS } from '../../infrastructure/config/storageKeys';
|
||||
@@ -496,12 +497,22 @@ export const TerminalComposeBar: React.FC<TerminalComposeBarProps> = ({
|
||||
}
|
||||
if (e.key === 'Enter' && !e.shiftKey && !isComposingRef.current) {
|
||||
e.preventDefault();
|
||||
// Plain Enter on an empty draft reconnects a disconnected session.
|
||||
// Modified Enter and a non-empty draft keep the existing send path (#3767).
|
||||
const plainEnter = !e.altKey && !e.ctrlKey && !e.metaKey;
|
||||
if (
|
||||
plainEnter
|
||||
&& !e.currentTarget.value
|
||||
&& terminalReconnectRegistry.requestEnterReconnect(sessionId)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
handleSend();
|
||||
} else if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
onClose();
|
||||
}
|
||||
}, [handleSend, navigate, onClose]);
|
||||
}, [handleSend, navigate, onClose, sessionId]);
|
||||
|
||||
const handleResizeStart = useCallback((e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import React from "react";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
|
||||
import { I18nProvider } from "../../application/i18n/I18nProvider.tsx";
|
||||
import type { GpuDeviceStats } from "../../application/state/useServerStats.ts";
|
||||
import { TerminalGpuCards } from "./TerminalGpuCards.tsx";
|
||||
|
||||
function renderCards(gpus: GpuDeviceStats[], gpu = 40) {
|
||||
return renderToStaticMarkup(
|
||||
<I18nProvider locale="en">
|
||||
<TerminalGpuCards
|
||||
gpus={gpus}
|
||||
gpu={gpu}
|
||||
gpuName="NVIDIA GeForce RTX 4090"
|
||||
gpuMemUsed={2048}
|
||||
gpuMemTotal={24576}
|
||||
/>
|
||||
</I18nProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
const card = (index: number, utilization: number | null, name = `NVIDIA A${index}`): GpuDeviceStats => ({
|
||||
index,
|
||||
name,
|
||||
utilization,
|
||||
memoryUsed: 1024 * (index + 1),
|
||||
memoryTotal: 8192,
|
||||
});
|
||||
|
||||
test("one GPU uses the same per-core bar, colors, and spacing", () => {
|
||||
const markup = renderCards([card(0, 73, "NVIDIA GeForce RTX 4090")], 73);
|
||||
|
||||
assert.match(markup, /GPU Device Usage/);
|
||||
assert.match(markup, /GPU 0/);
|
||||
assert.match(markup, /NVIDIA GeForce RTX 4090/);
|
||||
assert.match(markup, /73%/);
|
||||
assert.match(markup, /1\.0\/8\.0G/);
|
||||
assert.match(markup, /grid gap-1\.5 max-h-\[260px\] overflow-y-auto/);
|
||||
assert.match(markup, /min-w-\[160px\]/);
|
||||
assert.match(markup, /line-clamp-2/);
|
||||
assert.match(markup, /title="NVIDIA GeForce RTX 4090"/);
|
||||
assert.doesNotMatch(markup, /max-w-\[88px\]/);
|
||||
assert.match(markup, /h-1\.5 bg-muted rounded-full overflow-hidden/);
|
||||
assert.match(markup, /bg-amber-500/);
|
||||
assert.match(markup, /text-amber-400/);
|
||||
assert.match(markup, /repeat\(1, 1fr\)/);
|
||||
});
|
||||
|
||||
test("eight GPUs stay in four scrolling columns", () => {
|
||||
const markup = renderCards(Array.from({ length: 8 }, (_, index) => card(index, 10 + index)));
|
||||
|
||||
assert.match(markup, /repeat\(4, 1fr\)/);
|
||||
assert.match(markup, /max-h-\[260px\] overflow-y-auto/);
|
||||
assert.match(markup, /max-w-\[26rem\]/);
|
||||
assert.match(markup, /line-clamp-2/);
|
||||
assert.match(markup, /title="NVIDIA A0"/);
|
||||
assert.doesNotMatch(markup, /max-w-\[88px\]/);
|
||||
for (let index = 0; index < 8; index += 1) {
|
||||
assert.match(markup, new RegExp(`GPU ${index}`));
|
||||
}
|
||||
assert.match(markup, /text-emerald-400/);
|
||||
assert.doesNotMatch(markup, /repeat\(8,/);
|
||||
});
|
||||
|
||||
test("high utilization uses the CPU red threshold and unknown cards stay muted", () => {
|
||||
const markup = renderCards([
|
||||
card(0, 95, "NVIDIA H100"),
|
||||
{ index: 3, name: "NVIDIA A100", utilization: null, memoryUsed: null, memoryTotal: null },
|
||||
], 95);
|
||||
|
||||
assert.match(markup, /bg-red-500/);
|
||||
assert.match(markup, /text-red-400/);
|
||||
assert.match(markup, /95%/);
|
||||
assert.match(markup, /GPU 3/);
|
||||
assert.match(markup, />--</);
|
||||
assert.match(markup, /repeat\(2, 1fr\)/);
|
||||
});
|
||||
|
||||
test("a summary without per-GPU rows falls back to one aggregate bar", () => {
|
||||
const markup = renderCards([], 12);
|
||||
|
||||
assert.match(markup, /min-w-\[160px\]/);
|
||||
assert.match(markup, /h-2 bg-muted rounded-full/);
|
||||
assert.match(markup, /12%/);
|
||||
assert.match(markup, /2\.0\/24\.0G/);
|
||||
assert.match(markup, /text-emerald-400/);
|
||||
assert.doesNotMatch(markup, /grid gap-1\.5/);
|
||||
});
|
||||
@@ -0,0 +1,109 @@
|
||||
import React from "react";
|
||||
|
||||
import { useI18n } from "../../application/i18n/I18nProvider";
|
||||
import type { GpuDeviceStats } from "../../application/state/useServerStats";
|
||||
import { cn } from "../../lib/utils";
|
||||
|
||||
export function formatGpuMemoryGib(usedMb: number | null, totalMb: number | null): string | null {
|
||||
if (usedMb === null || totalMb === null || !(totalMb > 0)) return null;
|
||||
return `${(usedMb / 1024).toFixed(1)}/${(totalMb / 1024).toFixed(1)}G`;
|
||||
}
|
||||
|
||||
function usageBarClass(usage: number): string {
|
||||
return usage >= 90 ? "bg-red-500" : usage >= 70 ? "bg-amber-500" : "bg-emerald-500";
|
||||
}
|
||||
|
||||
function usageTextClass(usage: number): string {
|
||||
return usage >= 90 ? "text-red-400" : usage >= 70 ? "text-amber-400" : "text-emerald-400";
|
||||
}
|
||||
|
||||
interface TerminalGpuCardsProps {
|
||||
gpus: GpuDeviceStats[];
|
||||
gpu: number;
|
||||
gpuName: string | null;
|
||||
gpuMemUsed: number | null;
|
||||
gpuMemTotal: number | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-GPU details for the terminal stats hover card.
|
||||
* Mirrors the CPU per-core grid: four columns, the same bar heights,
|
||||
* threshold colors, and a scroll cap so many cards stay inside the popover.
|
||||
*/
|
||||
export const TerminalGpuCards: React.FC<TerminalGpuCardsProps> = ({
|
||||
gpus,
|
||||
gpu,
|
||||
gpuName,
|
||||
gpuMemUsed,
|
||||
gpuMemTotal,
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
const aggregateMemory = formatGpuMemoryGib(gpuMemUsed, gpuMemTotal);
|
||||
|
||||
const columns = Math.min(4, gpus.length);
|
||||
|
||||
return (
|
||||
<div className={cn("text-xs space-y-2 w-max max-w-[26rem]", gpus.length === 1 && "min-w-[160px]")}>
|
||||
<div className="font-medium text-sm mb-2">{t("terminal.serverStats.gpuDevices")}</div>
|
||||
{gpus.length > 0 ? (
|
||||
<div
|
||||
className="grid gap-1.5 max-h-[260px] overflow-y-auto pr-1 overscroll-contain"
|
||||
style={{ gridTemplateColumns: `repeat(${columns}, 1fr)` }}
|
||||
>
|
||||
{gpus.map((device) => {
|
||||
const usage = device.utilization;
|
||||
const memory = formatGpuMemoryGib(device.memoryUsed, device.memoryTotal);
|
||||
return (
|
||||
<div key={device.index} className="flex w-full min-w-0 flex-col items-center gap-1">
|
||||
<div className="text-[10px] text-muted-foreground">
|
||||
{t("terminal.serverStats.gpuIndex", { index: device.index })}
|
||||
</div>
|
||||
{device.name ? (
|
||||
<div
|
||||
className="line-clamp-2 w-full break-words text-center text-[10px] leading-tight text-muted-foreground"
|
||||
title={device.name}
|
||||
>
|
||||
{device.name}
|
||||
</div>
|
||||
) : null}
|
||||
<div className="w-full h-1.5 bg-muted rounded-full overflow-hidden">
|
||||
<div
|
||||
className={cn(
|
||||
"h-full rounded-full transition-all",
|
||||
usage === null ? "bg-muted" : usageBarClass(usage),
|
||||
)}
|
||||
style={{ width: `${usage ?? 0}%` }}
|
||||
/>
|
||||
</div>
|
||||
<div className={cn(
|
||||
"text-[11px] font-medium",
|
||||
usage === null ? "text-muted-foreground" : usageTextClass(usage),
|
||||
)}>
|
||||
{usage === null ? "--" : `${usage}%`}
|
||||
</div>
|
||||
{memory ? (
|
||||
<div className="text-[10px] text-muted-foreground whitespace-nowrap">{memory}</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col gap-1.5 min-w-[160px]">
|
||||
{gpuName ? (
|
||||
<div className="text-[10px] text-muted-foreground truncate" title={gpuName}>{gpuName}</div>
|
||||
) : null}
|
||||
<div className="w-full h-2 bg-muted rounded-full overflow-hidden">
|
||||
<div
|
||||
className={cn("h-full rounded-full transition-all", usageBarClass(gpu))}
|
||||
style={{ width: `${gpu}%` }}
|
||||
/>
|
||||
</div>
|
||||
<div className={cn("text-center text-[11px] font-medium", usageTextClass(gpu))}>
|
||||
{gpu}%{aggregateMemory ? ` · ${aggregateMemory}` : ""}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -28,4 +28,7 @@ test("GPU chip only renders when the remote reports GPU utilization", () => {
|
||||
source,
|
||||
/terminal\.serverStats\.gpu[\s\S]{0,2000}terminal\.serverStats\.memory/,
|
||||
);
|
||||
// Same hover card as CPU: compact aggregate trigger, per-device body.
|
||||
assert.match(source, /HoverCardTrigger asChild[\s\S]*terminal\.serverStats\.gpu[\s\S]*<TerminalGpuCards/);
|
||||
assert.match(source, /className="w-auto p-3"[\s\S]*side="bottom"[\s\S]*align="start"[\s\S]*sideOffset=\{8\}[\s\S]*<TerminalGpuCards/);
|
||||
});
|
||||
@@ -8,6 +8,7 @@ import { Tooltip, TooltipContent, TooltipTrigger } from '../ui/tooltip';
|
||||
import { formatNetSpeed } from './terminalHelpers';
|
||||
import { useServerStats } from '../../application/state/useServerStats';
|
||||
import { formatDiskCapacityRange, resolveTerminalDiskSummary } from './serverStatsFormat';
|
||||
import { formatGpuMemoryGib, TerminalGpuCards } from './TerminalGpuCards';
|
||||
|
||||
interface TerminalServerStatsProps {
|
||||
sessionId: string;
|
||||
@@ -48,6 +49,7 @@ export const TerminalServerStats: React.FC<TerminalServerStatsProps> = ({
|
||||
const hasNetworkDetails = serverStats.netInterfaces.length > 0;
|
||||
const hasLatency = serverStats.latencyMs !== null;
|
||||
const diskSummary = resolveTerminalDiskSummary(serverStats);
|
||||
const gpuMemory = formatGpuMemoryGib(serverStats.gpuMemUsed, serverStats.gpuMemTotal);
|
||||
|
||||
if (!enabled || !isConnected || !serverStats.lastUpdated) return null;
|
||||
|
||||
@@ -122,28 +124,37 @@ export const TerminalServerStats: React.FC<TerminalServerStatsProps> = ({
|
||||
</div>
|
||||
</HoverCardContent>
|
||||
</HoverCard>
|
||||
{/* GPU — only rendered when the remote reports NVIDIA GPU data (Linux nvidia-smi) */}
|
||||
{/* GPU — only rendered when the remote reports NVIDIA GPU data (Linux nvidia-smi).
|
||||
Compact chip stays aggregate, matching CPU; per-card detail is the hover card. */}
|
||||
{serverStats.gpu !== null && (
|
||||
<div
|
||||
className="flex items-center gap-0.5 min-w-0 shrink"
|
||||
aria-label={t("terminal.serverStats.gpu")}
|
||||
title={
|
||||
[
|
||||
serverStats.gpuName,
|
||||
serverStats.gpuMemUsed !== null && serverStats.gpuMemTotal !== null && serverStats.gpuMemTotal > 0
|
||||
? `VRAM ${(serverStats.gpuMemUsed / 1024).toFixed(1)}/${(serverStats.gpuMemTotal / 1024).toFixed(1)}G`
|
||||
: null,
|
||||
].filter(Boolean).join(' · ') || undefined
|
||||
}
|
||||
>
|
||||
<Gpu size={10} className="flex-shrink-0" />
|
||||
<span className="truncate">
|
||||
{serverStats.gpu}%
|
||||
{serverStats.gpuMemUsed !== null && serverStats.gpuMemTotal !== null && serverStats.gpuMemTotal > 0
|
||||
? ` (${(serverStats.gpuMemUsed / 1024).toFixed(1)}/${(serverStats.gpuMemTotal / 1024).toFixed(1)}G)`
|
||||
: ''}
|
||||
</span>
|
||||
</div>
|
||||
<HoverCard openDelay={200} closeDelay={100}>
|
||||
<HoverCardTrigger asChild>
|
||||
<button
|
||||
className="flex items-center gap-0.5 hover:opacity-100 opacity-80 transition-opacity cursor-pointer min-w-0 shrink"
|
||||
aria-label={t("terminal.serverStats.gpu")}
|
||||
>
|
||||
<Gpu size={10} className="flex-shrink-0" />
|
||||
<span className="truncate">
|
||||
{serverStats.gpu}%
|
||||
{gpuMemory ? ` (${gpuMemory})` : ''}
|
||||
</span>
|
||||
</button>
|
||||
</HoverCardTrigger>
|
||||
<HoverCardContent
|
||||
className="w-auto p-3"
|
||||
side="bottom"
|
||||
align="start"
|
||||
sideOffset={8}
|
||||
>
|
||||
<TerminalGpuCards
|
||||
gpus={serverStats.gpus}
|
||||
gpu={serverStats.gpu}
|
||||
gpuName={serverStats.gpuName}
|
||||
gpuMemUsed={serverStats.gpuMemUsed}
|
||||
gpuMemTotal={serverStats.gpuMemTotal}
|
||||
/>
|
||||
</HoverCardContent>
|
||||
</HoverCard>
|
||||
)}
|
||||
{/* Memory with HoverCard for htop-style bar and top processes */}
|
||||
<HoverCard openDelay={200} closeDelay={100}>
|
||||
|
||||
@@ -220,7 +220,6 @@ test("disconnected terminal reconnects on plain Enter when input is not claimed
|
||||
key: "Enter",
|
||||
status: "disconnected",
|
||||
hasRetryHandler: true,
|
||||
isComposeBarOpen: false,
|
||||
needsAuth: false,
|
||||
needsHostKeyVerification: false,
|
||||
hasBlockingOverlay: false,
|
||||
@@ -234,7 +233,6 @@ test("terminal enter reconnect ignores active controls and non-disconnected stat
|
||||
key: "Enter",
|
||||
status: "disconnected" as const,
|
||||
hasRetryHandler: true,
|
||||
isComposeBarOpen: false,
|
||||
needsAuth: false,
|
||||
needsHostKeyVerification: false,
|
||||
hasBlockingOverlay: false,
|
||||
@@ -243,14 +241,16 @@ test("terminal enter reconnect ignores active controls and non-disconnected stat
|
||||
assert.equal(shouldReconnectTerminalOnEnterKey({ ...base, status: "connected" }), false);
|
||||
assert.equal(shouldReconnectTerminalOnEnterKey({ ...base, key: "a" }), false);
|
||||
assert.equal(shouldReconnectTerminalOnEnterKey({ ...base, hasRetryHandler: false }), false);
|
||||
// Open search must not globally suppress Enter reconnect / the hint (#2546).
|
||||
// Open search and an open compose bar must not globally suppress Enter
|
||||
// reconnect / the hint (#2546 / #3767).
|
||||
assert.equal(shouldReconnectTerminalOnEnterKey({ ...base }), true);
|
||||
assert.equal(shouldReconnectTerminalOnEnterKey({ ...base, isComposeBarOpen: true }), false);
|
||||
assert.equal(shouldReconnectTerminalOnEnterKey({ ...base, needsAuth: true }), false);
|
||||
assert.equal(shouldReconnectTerminalOnEnterKey({ ...base, needsHostKeyVerification: true }), false);
|
||||
assert.equal(shouldReconnectTerminalOnEnterKey({ ...base, hasBlockingOverlay: true }), false);
|
||||
assert.equal(shouldReconnectTerminalOnEnterKey({ ...base, isReconnectActive: true }), false);
|
||||
assert.equal(shouldReconnectTerminalOnEnterKey({ ...base, altKey: true }), false);
|
||||
assert.equal(shouldReconnectTerminalOnEnterKey({ ...base, shiftKey: true }), false);
|
||||
assert.equal(shouldReconnectTerminalOnEnterKey({ ...base, isComposing: true }), false);
|
||||
});
|
||||
|
||||
test("terminal enter reconnect ignores interactive controls outside xterm only", () => {
|
||||
|
||||
@@ -7,6 +7,7 @@ import { isSessionReconnectDisabled } from '../top-tabs/SessionTabContextMenuCon
|
||||
import { resolveEffectiveTerminalProtocol } from '../../domain/terminalProtocol';
|
||||
import { classifyDistroId } from '../../domain/host';
|
||||
import type { HostInfoBarTitleMode } from '../../domain/models';
|
||||
import { terminalReconnectRegistry } from '../../application/state/terminalReconnectRegistry';
|
||||
import { useNetworkDeviceModeSuggestion } from '../../application/state/useNetworkDeviceModeSuggestion';
|
||||
import { isPluginHostProtocol } from '../../domain/pluginConnection';
|
||||
import { OSC7_SETUP_TARGETS } from './osc7Setup';
|
||||
@@ -184,7 +185,6 @@ export function shouldReconnectTerminalOnEnterKey({
|
||||
key,
|
||||
status,
|
||||
hasRetryHandler,
|
||||
isComposeBarOpen,
|
||||
needsAuth,
|
||||
needsHostKeyVerification,
|
||||
hasBlockingOverlay,
|
||||
@@ -198,7 +198,6 @@ export function shouldReconnectTerminalOnEnterKey({
|
||||
key: string;
|
||||
status?: string;
|
||||
hasRetryHandler: boolean;
|
||||
isComposeBarOpen: boolean;
|
||||
needsAuth: boolean;
|
||||
needsHostKeyVerification: boolean;
|
||||
hasBlockingOverlay: boolean;
|
||||
@@ -209,15 +208,15 @@ export function shouldReconnectTerminalOnEnterKey({
|
||||
shiftKey?: boolean;
|
||||
isComposing?: boolean;
|
||||
}): boolean {
|
||||
// Search-bar open state is intentionally not a global gate. While disconnected,
|
||||
// find-next is less useful than reconnect; the capture handler still refuses
|
||||
// Enter only when a real interactive control outside xterm owns the event
|
||||
// (compose/auth/buttons). The terminal search input is allow-listed so an open
|
||||
// search bar cannot hide the hint or swallow reconnect (#2544 / #2546).
|
||||
// Search and compose bars are not global gates. While disconnected, plain
|
||||
// Enter still reconnects from the terminal and the disconnected dialog.
|
||||
// Capture still skips real controls outside xterm (compose textarea, auth,
|
||||
// buttons). Search input is allow-listed so an open search bar cannot hide
|
||||
// the hint or swallow reconnect (#2544 / #2546). An empty compose draft
|
||||
// reconnects from TerminalComposeBar; a non-empty draft keeps send (#3767).
|
||||
return key === "Enter"
|
||||
&& status === "disconnected"
|
||||
&& hasRetryHandler
|
||||
&& !isComposeBarOpen
|
||||
&& !needsAuth
|
||||
&& !needsHostKeyVerification
|
||||
&& !hasBlockingOverlay
|
||||
@@ -529,18 +528,24 @@ function TerminalViewInner({ ctx, isPaneMagnified = false }: { ctx: TerminalView
|
||||
key: "Enter",
|
||||
status,
|
||||
hasRetryHandler: Boolean(handleRetry) && terminalReconnectAvailable !== false,
|
||||
isComposeBarOpen,
|
||||
needsAuth: Boolean(auth.needsAuth),
|
||||
needsHostKeyVerification: Boolean(needsHostKeyVerification),
|
||||
hasBlockingOverlay: hasBlockingReconnectOverlay,
|
||||
isReconnectActive,
|
||||
});
|
||||
// Publish Enter-reconnect so an empty compose draft can reconnect this
|
||||
// session, including the workspace compose bar (#3767).
|
||||
useEffect(() => {
|
||||
terminalReconnectRegistry.setEnterReconnectEnabled(sessionId, showEnterReconnectHint);
|
||||
return () => {
|
||||
terminalReconnectRegistry.setEnterReconnectEnabled(sessionId, false);
|
||||
};
|
||||
}, [sessionId, showEnterReconnectHint]);
|
||||
const handleTerminalKeyDownCapture = useCallback((event: React.KeyboardEvent<HTMLDivElement>) => {
|
||||
if (!shouldReconnectTerminalOnEnterKey({
|
||||
key: event.key,
|
||||
status,
|
||||
hasRetryHandler: Boolean(handleRetry) && terminalReconnectAvailable !== false,
|
||||
isComposeBarOpen,
|
||||
needsAuth: Boolean(auth.needsAuth),
|
||||
needsHostKeyVerification: Boolean(needsHostKeyVerification),
|
||||
hasBlockingOverlay: hasBlockingReconnectOverlay,
|
||||
@@ -563,7 +568,6 @@ function TerminalViewInner({ ctx, isPaneMagnified = false }: { ctx: TerminalView
|
||||
auth.needsAuth,
|
||||
handleRetry,
|
||||
hasBlockingReconnectOverlay,
|
||||
isComposeBarOpen,
|
||||
isReconnectActive,
|
||||
needsHostKeyVerification,
|
||||
status,
|
||||
|
||||
@@ -80,6 +80,10 @@ export function handleTerminalAutocompleteKeyEvent(
|
||||
now = Date.now,
|
||||
} = context;
|
||||
if (!settingsRef.current.enabled || e.type !== "keydown") return true;
|
||||
// Enter/arrows belong to the IME candidate window during composition.
|
||||
// Consuming them here can accept/execute a stale terminal suggestion and
|
||||
// prevent the input method from committing the user's characters.
|
||||
if (e.isComposing || e.keyCode === 229) return true;
|
||||
|
||||
const metaFollowUp = autocompleteEscMetaFollowUpSequence(e);
|
||||
if (metaFollowUp && now() < escMetaPrefixUntilRef.current) {
|
||||
|
||||
@@ -762,6 +762,7 @@ export function useTerminalAutocomplete(
|
||||
candidate: newCommand,
|
||||
os: hostOsRef.current,
|
||||
});
|
||||
if (seq === null) return;
|
||||
if (seq) writeToTerminal(seq);
|
||||
typedInputBufferRef.current = newCommand;
|
||||
typedBufferReliableRef.current = true;
|
||||
|
||||
@@ -389,6 +389,23 @@ test("disconnected connection dialog keeps an Enter-reconnect focus sink", () =>
|
||||
assert.match(source, /restoreTerminalFocusFromDisconnectedDialog\(\{[\s\S]*isFocusedPane/);
|
||||
});
|
||||
|
||||
test("open compose bar does not globally gate enter reconnect", () => {
|
||||
const viewSource = readFileSync(new URL("./TerminalView.tsx", import.meta.url), "utf8");
|
||||
const gateIndex = viewSource.indexOf("export function shouldReconnectTerminalOnEnterKey");
|
||||
const bodyEnd = viewSource.indexOf("export function shouldBlockTerminalReconnectForTarget", gateIndex);
|
||||
const gateBody = viewSource.slice(gateIndex, bodyEnd);
|
||||
assert.equal(gateBody.includes("!isComposeBarOpen"), false);
|
||||
assert.match(viewSource, /setEnterReconnectEnabled\(sessionId, showEnterReconnectHint\)/);
|
||||
assert.match(
|
||||
readFileSync(new URL("./TerminalComposeBar.tsx", import.meta.url), "utf8"),
|
||||
/requestEnterReconnect\(sessionId\)/,
|
||||
);
|
||||
assert.match(
|
||||
readFileSync(new URL("./terminalHelpers.ts", import.meta.url), "utf8"),
|
||||
/data-section='terminal-compose-bar'/,
|
||||
);
|
||||
});
|
||||
|
||||
test("open terminal search does not globally gate enter reconnect", () => {
|
||||
const viewSource = readFileSync(new URL("./TerminalView.tsx", import.meta.url), "utf8");
|
||||
const gateIndex = viewSource.indexOf("export function shouldReconnectTerminalOnEnterKey");
|
||||
@@ -402,7 +419,7 @@ test("open terminal search does not globally gate enter reconnect", () => {
|
||||
);
|
||||
});
|
||||
|
||||
test("terminal view receives the effective compose bar state for enter reconnect gating", () => {
|
||||
test("terminal view receives the effective compose bar state", () => {
|
||||
const source = readFileSync(new URL("../Terminal.tsx", import.meta.url), "utf8");
|
||||
const effectiveDefinitionIndex = source.indexOf("const effectiveComposeBarOpen =");
|
||||
const viewIndex = source.indexOf("<TerminalView ctx={{");
|
||||
@@ -413,7 +430,7 @@ test("terminal view receives the effective compose bar state for enter reconnect
|
||||
assert.notEqual(passEffectiveIndex, -1);
|
||||
assert.ok(
|
||||
effectiveDefinitionIndex < passEffectiveIndex,
|
||||
"TerminalView must use the visible workspace compose state before deciding whether Enter can reconnect",
|
||||
"TerminalView must receive the visible workspace compose state",
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -541,7 +541,7 @@ test("Shift+Enter broadcast uses CSI-u only after the target negotiated a preser
|
||||
applicationCursorMode: false,
|
||||
encodedKeys: new Set<string>(),
|
||||
}), {
|
||||
data: "\n",
|
||||
data: "\u001b\r",
|
||||
kittyEncoded: false,
|
||||
urgentInterrupt: false,
|
||||
});
|
||||
@@ -552,7 +552,7 @@ test("Shift+Enter broadcast uses CSI-u only after the target negotiated a preser
|
||||
applicationCursorMode: false,
|
||||
encodedKeys: new Set<string>(),
|
||||
}), {
|
||||
data: "\n",
|
||||
data: "\u001b\r",
|
||||
kittyEncoded: false,
|
||||
urgentInterrupt: false,
|
||||
});
|
||||
|
||||
@@ -26,8 +26,8 @@ const keyEvent = (overrides: Partial<KeyboardEvent> = {}) => ({
|
||||
...overrides,
|
||||
}) as KeyboardEvent;
|
||||
|
||||
test("shift enter text defaults to newline", () => {
|
||||
assert.equal(resolveShiftEnterText(), "\n");
|
||||
test("shift enter text defaults to the legacy Alt+Enter encoding (ESC + CR)", () => {
|
||||
assert.equal(resolveShiftEnterText(), "\u001b\r");
|
||||
});
|
||||
|
||||
test("shift enter text decodes newline, tab, carriage return, and backslash escapes", () => {
|
||||
@@ -71,6 +71,20 @@ test("shift enter submitted input detects single command text with a line ending
|
||||
assert.equal(getShiftEnterSubmittedInput("foo\nbar\n"), null);
|
||||
});
|
||||
|
||||
test("Alt+Enter ESC+CR is a non-submitting logical line break", () => {
|
||||
assert.equal(getShiftEnterSubmittedInput("\u001b\r"), null);
|
||||
assert.equal(getShiftEnterSubmittedInput("echo hi\u001b\r"), null);
|
||||
assert.equal(getShiftEnterSubmittedInput("echo hi\u001b\r\n"), null);
|
||||
assert.deepEqual(getShiftEnterSubmittedInput("echo hi\r"), {
|
||||
text: "echo hi",
|
||||
lineEnding: "\r",
|
||||
});
|
||||
assert.deepEqual(getShiftEnterSubmittedInput("\u001b\n"), {
|
||||
text: "\u001b",
|
||||
lineEnding: "\n",
|
||||
});
|
||||
});
|
||||
|
||||
test("shift enter handler only matches plain Shift+Enter keydown", () => {
|
||||
assert.equal(shouldSendShiftEnterText(keyEvent()), true);
|
||||
assert.equal(shouldSendShiftEnterText(keyEvent({ type: "keyup" })), false);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { TerminalSettings } from "../../../domain/models";
|
||||
|
||||
export const DEFAULT_SHIFT_ENTER_TEXT = "\\n";
|
||||
export const DEFAULT_SHIFT_ENTER_TEXT = "\\e\\r";
|
||||
|
||||
/** Kitty CSI-u encoding for Shift+Enter (keycode 13, modifier shift → 2). */
|
||||
export const SHIFT_ENTER_CSI_U_SEQUENCE = "\u001b[13;2u";
|
||||
@@ -109,10 +109,15 @@ export type ShiftEnterSubmittedInput = {
|
||||
lineEnding: "\r\n" | "\r" | "\n";
|
||||
};
|
||||
|
||||
/** ESC immediately before a terminating CR is the legacy Alt+Enter line break. */
|
||||
function isAltEnterLineBreak(text: string): boolean {
|
||||
return text.endsWith("\u001b\r") || text.endsWith("\u001b\r\n");
|
||||
}
|
||||
|
||||
export function getShiftEnterSubmittedInput(
|
||||
text: string,
|
||||
): ShiftEnterSubmittedInput | null {
|
||||
if (isShiftEnterLineContinuationText(text)) return null;
|
||||
if (isShiftEnterLineContinuationText(text) || isAltEnterLineBreak(text)) return null;
|
||||
const match = text.match(/^([^\r\n]*)(\r\n|\r|\n)$/);
|
||||
if (!match) return null;
|
||||
return {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import { reconcileAutocompletePopupState } from "./autocomplete/useTerminalAutocomplete.ts";
|
||||
import { DEFAULT_AUTOCOMPLETE_SETTINGS, reconcileAutocompletePopupState } from "./autocomplete/useTerminalAutocomplete.ts";
|
||||
import type { AutocompleteState } from "./autocomplete/useTerminalAutocomplete.ts";
|
||||
import { handleTerminalAutocompleteKeyEvent } from "./autocomplete/terminalAutocompleteKeyEvent.ts";
|
||||
|
||||
@@ -58,6 +58,7 @@ function createContext(overrides: Record<string, unknown> = {}) {
|
||||
context: {
|
||||
settingsRef: {
|
||||
current: {
|
||||
...DEFAULT_AUTOCOMPLETE_SETTINGS,
|
||||
enabled: true,
|
||||
showGhostText: false,
|
||||
showPopupMenu: true,
|
||||
@@ -379,3 +380,19 @@ for (const edited of [false, true]) {
|
||||
assert.equal(runs, edited ? 0 : 1);
|
||||
});
|
||||
}
|
||||
|
||||
for (const key of ['Enter', 'ArrowUp', 'ArrowDown', 'ArrowRight', 'Escape', 'Tab']) {
|
||||
for (const sentinel of ['isComposing', 'keyCode'] as const) {
|
||||
test(`IME ${sentinel} ${key} passes through autocomplete without accepting or rewriting input`, () => {
|
||||
const { context, writes, previews, accepted, clears } = createContext();
|
||||
context.stateRef.current.selectedIndex = 0;
|
||||
const original = context.stateRef.current;
|
||||
const event = keyEvent(key);
|
||||
Object.defineProperty(event, sentinel, { value: sentinel === 'isComposing' ? true : 229 });
|
||||
assert.equal(handleTerminalAutocompleteKeyEvent(event, context), true);
|
||||
assert.equal(event.defaultPrevented, false);
|
||||
assert.equal(context.stateRef.current, original);
|
||||
assert.deepEqual([...writes, ...previews, ...accepted, ...clears], []);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -448,6 +448,11 @@ export function shouldClaimDisconnectedDialogFocus({
|
||||
// Sink already focused.
|
||||
return active !== dialogNode;
|
||||
}
|
||||
// An open compose draft owns Enter: empty reconnects from the bar, and a
|
||||
// non-empty draft must keep send behavior (#3767). Do not park focus here.
|
||||
if (active.closest("[data-section='terminal-compose-bar']")) {
|
||||
return false;
|
||||
}
|
||||
const terminalRoot = resolveDisconnectedDialogTerminalRoot(dialogNode, sessionRoot);
|
||||
if (terminalRoot?.contains(active)) {
|
||||
return true;
|
||||
|
||||
@@ -98,6 +98,7 @@ test("disconnected dialog focus claim never steals from other panes", () => {
|
||||
id: string;
|
||||
parent?: FakeNode | null;
|
||||
interactive?: boolean;
|
||||
composeBar?: boolean;
|
||||
children?: FakeNode[];
|
||||
},
|
||||
) {
|
||||
@@ -118,6 +119,7 @@ test("disconnected dialog focus claim never steals from other panes", () => {
|
||||
return false;
|
||||
}
|
||||
closest(selector: string) {
|
||||
if (selector.includes("terminal-compose-bar") && this.opts.composeBar) return this;
|
||||
if (selector.includes("button") && this.opts.interactive) return this;
|
||||
return null;
|
||||
}
|
||||
@@ -227,6 +229,23 @@ test("disconnected dialog focus claim never steals from other panes", () => {
|
||||
}),
|
||||
false,
|
||||
);
|
||||
// Compose-bar focus stays put so an empty draft can reconnect and a
|
||||
// non-empty draft is not stolen by the disconnected dialog (#3767).
|
||||
const composeInput = new FakeNode({ id: "compose", composeBar: true });
|
||||
const composeDialog = new FakeNode({ id: "compose-dialog" });
|
||||
const composeSession = new FakeNode({
|
||||
id: "compose-session",
|
||||
children: [composeInput, composeDialog],
|
||||
});
|
||||
assert.equal(
|
||||
shouldClaimDisconnectedDialogFocus({
|
||||
activeElement: composeInput as unknown as Element,
|
||||
dialogNode: composeDialog as unknown as HTMLElement,
|
||||
sessionRoot: composeSession as unknown as Element,
|
||||
documentBody: body as unknown as Element,
|
||||
}),
|
||||
false,
|
||||
);
|
||||
|
||||
assert.equal(
|
||||
restoreTerminalFocusFromDisconnectedDialog({
|
||||
|
||||
@@ -30,6 +30,13 @@ import { handleTabMiddleClickClose, handleTabMiddleMouseDown } from '../../lib/t
|
||||
import { ContextMenu, ContextMenuContent, ContextMenuItem, ContextMenuSeparator, ContextMenuTrigger } from '../ui/context-menu';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '../ui/tooltip';
|
||||
import { SessionTabContextMenuContent } from './SessionTabContextMenuContent';
|
||||
import {
|
||||
TOP_TAB_ITEM_CLASS,
|
||||
clearInactiveTopTabHover,
|
||||
paintInactiveTopTabHover,
|
||||
resolveTopTabChromeStyle,
|
||||
topTabActiveValue,
|
||||
} from './topTabActiveStyle';
|
||||
import { renderHostIconGlyph } from '../hostIconRenderer';
|
||||
import type { PluginViewTab } from '../../application/state/pluginViewTabStore';
|
||||
import { PluginContributionIcon } from '../plugins/PluginContributionIcon';
|
||||
@@ -310,13 +317,19 @@ export const WindowControls: React.FC = memo(() => {
|
||||
|
||||
const controlClassName = 'window-control-btn app-no-drag';
|
||||
const closeControlClassName = 'window-control-btn window-control-btn--close app-no-drag';
|
||||
// Mouse press would move DOM focus onto minimize/maximize. After restore
|
||||
// that leaves the terminal textarea unfocused, so keystrokes do nothing.
|
||||
// Close skips this so the active field still blurs (inline rename commits on blur).
|
||||
const preventWindowControlFocus: React.MouseEventHandler<HTMLButtonElement> = (event) => {
|
||||
event.preventDefault();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="ml-2 flex h-7 items-center overflow-visible app-no-drag">
|
||||
<button type="button" className={controlClassName} onClick={handleMinimize}>
|
||||
<button type="button" className={controlClassName} onMouseDown={preventWindowControlFocus} onClick={handleMinimize}>
|
||||
<Minus size={16} />
|
||||
</button>
|
||||
<button type="button" className={controlClassName} onClick={handleMaximize}>
|
||||
<button type="button" className={controlClassName} onMouseDown={preventWindowControlFocus} onClick={handleMaximize}>
|
||||
{isMaximized ? <Copy size={14} /> : <Square size={14} />}
|
||||
</button>
|
||||
<button type="button" className={closeControlClassName} onClick={handleClose}>
|
||||
@@ -440,31 +453,20 @@ export const RootTopTab: React.FC<RootTopTabProps> = memo(({ tabId, label, icon,
|
||||
data-tab-id={tabId}
|
||||
data-tab-type="root"
|
||||
data-state={isActive ? 'active' : 'inactive'}
|
||||
data-active={topTabActiveValue(isActive)}
|
||||
onClick={handleClick}
|
||||
className={cn(
|
||||
TOP_TAB_ITEM_CLASS,
|
||||
"netcatty-tab relative h-7 overflow-hidden text-xs font-semibold cursor-pointer flex items-center app-no-drag transition-[padding,gap] duration-300 ease-out",
|
||||
compact ? "px-2 gap-0" : "px-3 gap-2",
|
||||
className,
|
||||
)}
|
||||
style={{
|
||||
backgroundColor: isActive && !suppressActiveBg
|
||||
? 'var(--top-tabs-active-bg, hsl(var(--background)))'
|
||||
: 'transparent',
|
||||
color: isActive
|
||||
? 'var(--top-tabs-fg, hsl(var(--foreground)))'
|
||||
: 'var(--top-tabs-muted, hsl(var(--muted-foreground)))',
|
||||
}}
|
||||
style={resolveTopTabChromeStyle(isActive, { suppressActiveBg })}
|
||||
onMouseEnter={(e) => {
|
||||
if (!isActive) {
|
||||
e.currentTarget.style.backgroundColor = 'color-mix(in srgb, var(--top-tabs-active-bg, hsl(var(--background))) 40%, transparent)';
|
||||
e.currentTarget.style.color = 'var(--top-tabs-fg, hsl(var(--foreground)))';
|
||||
}
|
||||
if (!isActive) paintInactiveTopTabHover(e.currentTarget);
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
if (!isActive) {
|
||||
e.currentTarget.style.backgroundColor = 'transparent';
|
||||
e.currentTarget.style.color = 'var(--top-tabs-muted, hsl(var(--muted-foreground)))';
|
||||
}
|
||||
if (!isActive) clearInactiveTopTabHover(e.currentTarget);
|
||||
}}
|
||||
>
|
||||
{shortcutNumber != null ? <TabShortcutNumberBadge number={shortcutNumber} /> : icon}
|
||||
@@ -523,6 +525,7 @@ export const PluginViewTopTab: React.FC<PluginViewTopTabProps> = memo(({
|
||||
data-tab-id={tab.id}
|
||||
data-tab-type="plugin-view"
|
||||
data-state={isActive ? 'active' : 'inactive'}
|
||||
data-active={topTabActiveValue(isActive)}
|
||||
onClick={() => activeTabStore.setActiveTabId(tab.id)}
|
||||
onMouseDown={handleTabMiddleMouseDown}
|
||||
onAuxClick={(event) => handleTabMiddleClickClose(event, close)}
|
||||
@@ -533,6 +536,7 @@ export const PluginViewTopTab: React.FC<PluginViewTopTabProps> = memo(({
|
||||
onDragLeave={onTabDragLeave}
|
||||
onDrop={(event) => onTabDrop(event, tab.id)}
|
||||
className={cn(
|
||||
TOP_TAB_ITEM_CLASS,
|
||||
'netcatty-tab relative h-7 min-w-[140px] max-w-[240px] flex-shrink-0 cursor-pointer items-center justify-between gap-2 overflow-hidden rounded-t-md pl-3 pr-2 text-xs font-semibold app-no-drag',
|
||||
'flex transition-transform duration-150',
|
||||
isBeingDragged && isDraggingForReorder && 'scale-95 opacity-40',
|
||||
@@ -540,8 +544,7 @@ export const PluginViewTopTab: React.FC<PluginViewTopTabProps> = memo(({
|
||||
)}
|
||||
style={{
|
||||
...shiftStyle,
|
||||
backgroundColor: isActive ? 'var(--top-tabs-active-bg, hsl(var(--background)))' : 'transparent',
|
||||
color: isActive ? 'var(--top-tabs-fg, hsl(var(--foreground)))' : 'var(--top-tabs-muted, hsl(var(--muted-foreground)))',
|
||||
...resolveTopTabChromeStyle(isActive),
|
||||
}}
|
||||
>
|
||||
{showDropIndicatorBefore && isDraggingForReorder && <div className="absolute -left-0.5 bottom-1 top-1 w-0.5 rounded-full bg-primary" />}
|
||||
@@ -643,6 +646,7 @@ export const EditorTopTab: React.FC<EditorTopTabProps> = memo(({
|
||||
data-tab-id={tabId}
|
||||
data-tab-type="editor"
|
||||
data-state={isActive ? 'active' : 'inactive'}
|
||||
data-active={topTabActiveValue(isActive)}
|
||||
onClick={handleClick}
|
||||
onMouseDown={handleTabMiddleMouseDown}
|
||||
onAuxClick={(e) => handleTabMiddleClickClose(e, () => onRequestCloseEditorTab(editorTab.id))}
|
||||
@@ -653,6 +657,7 @@ export const EditorTopTab: React.FC<EditorTopTabProps> = memo(({
|
||||
onDragLeave={onTabDragLeave}
|
||||
onDrop={(e) => onTabDrop(e, tabId)}
|
||||
className={cn(
|
||||
TOP_TAB_ITEM_CLASS,
|
||||
"netcatty-tab relative h-7 pl-3 pr-2 min-w-[140px] max-w-[240px] rounded-t-md overflow-hidden text-xs font-semibold cursor-pointer flex items-center justify-between gap-2 app-no-drag flex-shrink-0",
|
||||
"transition-transform duration-150",
|
||||
isBeingDragged && isDraggingForReorder ? "opacity-40 scale-95" : "",
|
||||
@@ -660,24 +665,13 @@ export const EditorTopTab: React.FC<EditorTopTabProps> = memo(({
|
||||
)}
|
||||
style={{
|
||||
...shiftStyle,
|
||||
backgroundColor: isActive
|
||||
? 'var(--top-tabs-active-bg, hsl(var(--background)))'
|
||||
: 'transparent',
|
||||
color: isActive
|
||||
? 'var(--top-tabs-fg, hsl(var(--foreground)))'
|
||||
: 'var(--top-tabs-muted, hsl(var(--muted-foreground)))',
|
||||
...resolveTopTabChromeStyle(isActive),
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
if (!isActive) {
|
||||
e.currentTarget.style.backgroundColor = 'color-mix(in srgb, var(--top-tabs-active-bg, hsl(var(--background))) 40%, transparent)';
|
||||
e.currentTarget.style.color = 'var(--top-tabs-fg, hsl(var(--foreground)))';
|
||||
}
|
||||
if (!isActive) paintInactiveTopTabHover(e.currentTarget);
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
if (!isActive) {
|
||||
e.currentTarget.style.backgroundColor = 'transparent';
|
||||
e.currentTarget.style.color = 'var(--top-tabs-muted, hsl(var(--muted-foreground)))';
|
||||
}
|
||||
if (!isActive) clearInactiveTopTabHover(e.currentTarget);
|
||||
}}
|
||||
>
|
||||
{showDropIndicatorBefore && isDraggingForReorder && (
|
||||
@@ -827,6 +821,7 @@ export const SessionTopTab: React.FC<SessionTopTabProps> = memo(({
|
||||
data-tab-id={session.id}
|
||||
data-tab-type="session"
|
||||
data-state={isActive ? 'active' : 'inactive'}
|
||||
data-active={topTabActiveValue(isActive)}
|
||||
onClick={handleClick}
|
||||
onDoubleClick={handleDoubleClick}
|
||||
onMouseDown={handleTabMiddleMouseDown}
|
||||
@@ -838,6 +833,7 @@ export const SessionTopTab: React.FC<SessionTopTabProps> = memo(({
|
||||
onDragLeave={onTabDragLeave}
|
||||
onDrop={(e) => onTabDrop(e, session.id)}
|
||||
className={cn(
|
||||
TOP_TAB_ITEM_CLASS,
|
||||
"netcatty-tab relative h-7 pl-3 pr-2 min-w-[140px] max-w-[240px] rounded-t-md overflow-hidden text-xs font-semibold cursor-pointer flex items-center justify-between gap-2 app-no-drag flex-shrink-0",
|
||||
"transition-transform duration-150",
|
||||
isBeingDragged && isDraggingForReorder ? "opacity-40 scale-95" : "",
|
||||
@@ -845,24 +841,13 @@ export const SessionTopTab: React.FC<SessionTopTabProps> = memo(({
|
||||
)}
|
||||
style={{
|
||||
...shiftStyle,
|
||||
backgroundColor: isActive
|
||||
? 'var(--top-tabs-active-bg, hsl(var(--background)))'
|
||||
: 'transparent',
|
||||
color: isActive
|
||||
? 'var(--top-tabs-fg, hsl(var(--foreground)))'
|
||||
: 'var(--top-tabs-muted, hsl(var(--muted-foreground)))',
|
||||
...resolveTopTabChromeStyle(isActive),
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
if (!isActive) {
|
||||
e.currentTarget.style.backgroundColor = 'color-mix(in srgb, var(--top-tabs-active-bg, hsl(var(--background))) 40%, transparent)';
|
||||
e.currentTarget.style.color = 'var(--top-tabs-fg, hsl(var(--foreground)))';
|
||||
}
|
||||
if (!isActive) paintInactiveTopTabHover(e.currentTarget);
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
if (!isActive) {
|
||||
e.currentTarget.style.backgroundColor = 'transparent';
|
||||
e.currentTarget.style.color = 'var(--top-tabs-muted, hsl(var(--muted-foreground)))';
|
||||
}
|
||||
if (!isActive) clearInactiveTopTabHover(e.currentTarget);
|
||||
}}
|
||||
>
|
||||
{showDropIndicatorBefore && isDraggingForReorder && (
|
||||
@@ -1039,6 +1024,7 @@ export const WorkspaceTopTab: React.FC<WorkspaceTopTabProps> = memo(({
|
||||
data-tab-id={workspace.id}
|
||||
data-tab-type="workspace"
|
||||
data-state={isActive ? 'active' : 'inactive'}
|
||||
data-active={topTabActiveValue(isActive)}
|
||||
data-host-drop-active={isHostDropTarget ? 'true' : 'false'}
|
||||
onClick={handleClick}
|
||||
onDoubleClick={handleDoubleClick}
|
||||
@@ -1051,6 +1037,7 @@ export const WorkspaceTopTab: React.FC<WorkspaceTopTabProps> = memo(({
|
||||
onDragLeave={onTabDragLeave}
|
||||
onDrop={(e) => onTabDrop(e, workspace.id)}
|
||||
className={cn(
|
||||
TOP_TAB_ITEM_CLASS,
|
||||
"netcatty-tab relative h-7 pl-3 pr-2 min-w-[150px] max-w-[260px] rounded-t-md overflow-hidden text-xs font-semibold cursor-pointer flex items-center justify-between gap-2 app-no-drag flex-shrink-0",
|
||||
"transition-transform duration-150",
|
||||
isBeingDragged && isDraggingForReorder ? "opacity-40 scale-95" : "",
|
||||
@@ -1059,24 +1046,13 @@ export const WorkspaceTopTab: React.FC<WorkspaceTopTabProps> = memo(({
|
||||
)}
|
||||
style={{
|
||||
...shiftStyle,
|
||||
backgroundColor: isActive
|
||||
? 'var(--top-tabs-active-bg, hsl(var(--background)))'
|
||||
: 'transparent',
|
||||
color: isActive
|
||||
? 'var(--top-tabs-fg, hsl(var(--foreground)))'
|
||||
: 'var(--top-tabs-muted, hsl(var(--muted-foreground)))',
|
||||
...resolveTopTabChromeStyle(isActive),
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
if (!isActive) {
|
||||
e.currentTarget.style.backgroundColor = 'color-mix(in srgb, var(--top-tabs-active-bg, hsl(var(--background))) 40%, transparent)';
|
||||
e.currentTarget.style.color = 'var(--top-tabs-fg, hsl(var(--foreground)))';
|
||||
}
|
||||
if (!isActive) paintInactiveTopTabHover(e.currentTarget);
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
if (!isActive) {
|
||||
e.currentTarget.style.backgroundColor = 'transparent';
|
||||
e.currentTarget.style.color = 'var(--top-tabs-muted, hsl(var(--muted-foreground)))';
|
||||
}
|
||||
if (!isActive) clearInactiveTopTabHover(e.currentTarget);
|
||||
}}
|
||||
>
|
||||
{showDropIndicatorBefore && isDraggingForReorder && (
|
||||
@@ -1197,6 +1173,7 @@ export const LogViewTopTab: React.FC<LogViewTopTabProps> = memo(({
|
||||
data-tab-id={logView.id}
|
||||
data-tab-type="logView"
|
||||
data-state={isActive ? 'active' : 'inactive'}
|
||||
data-active={topTabActiveValue(isActive)}
|
||||
onClick={handleClick}
|
||||
onMouseDown={handleTabMiddleMouseDown}
|
||||
onAuxClick={(e) => handleTabMiddleClickClose(e, () => onCloseLogView(logView.id))}
|
||||
@@ -1207,6 +1184,7 @@ export const LogViewTopTab: React.FC<LogViewTopTabProps> = memo(({
|
||||
onDragLeave={onTabDragLeave}
|
||||
onDrop={(e) => onTabDrop(e, logView.id)}
|
||||
className={cn(
|
||||
TOP_TAB_ITEM_CLASS,
|
||||
"netcatty-tab relative h-7 pl-3 pr-2 min-w-[140px] max-w-[240px] rounded-t-md overflow-hidden text-xs font-semibold cursor-pointer flex items-center justify-between gap-2 app-no-drag flex-shrink-0",
|
||||
"transition-transform duration-150",
|
||||
isBeingDragged && isDraggingForReorder ? "opacity-40 scale-95" : "",
|
||||
@@ -1214,24 +1192,13 @@ export const LogViewTopTab: React.FC<LogViewTopTabProps> = memo(({
|
||||
)}
|
||||
style={{
|
||||
...shiftStyle,
|
||||
backgroundColor: isActive
|
||||
? 'var(--top-tabs-active-bg, hsl(var(--background)))'
|
||||
: 'transparent',
|
||||
color: isActive
|
||||
? 'var(--top-tabs-fg, hsl(var(--foreground)))'
|
||||
: 'var(--top-tabs-muted, hsl(var(--muted-foreground)))',
|
||||
...resolveTopTabChromeStyle(isActive),
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
if (!isActive) {
|
||||
e.currentTarget.style.backgroundColor = 'color-mix(in srgb, var(--top-tabs-active-bg, hsl(var(--background))) 40%, transparent)';
|
||||
e.currentTarget.style.color = 'var(--top-tabs-fg, hsl(var(--foreground)))';
|
||||
}
|
||||
if (!isActive) paintInactiveTopTabHover(e.currentTarget);
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
if (!isActive) {
|
||||
e.currentTarget.style.backgroundColor = 'transparent';
|
||||
e.currentTarget.style.color = 'var(--top-tabs-muted, hsl(var(--muted-foreground)))';
|
||||
}
|
||||
if (!isActive) clearInactiveTopTabHover(e.currentTarget);
|
||||
}}
|
||||
>
|
||||
{showDropIndicatorBefore && isDraggingForReorder && (
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
/**
|
||||
* Stable hooks for the selected top tab.
|
||||
*
|
||||
* Defaults fall through to the existing chrome colors, so leaving the custom
|
||||
* properties unset keeps today's look. Custom CSS can set them on
|
||||
* `.top-tab[data-active="true"]` (or any ancestor) without !important.
|
||||
* `--top-tab-active-border` is read by index.css as a 1px inset outline and
|
||||
* defaults to transparent.
|
||||
*/
|
||||
|
||||
export const TOP_TAB_ITEM_CLASS = 'top-tab';
|
||||
|
||||
/** Previous selected fill, kept as the fallback when the hook is unset. */
|
||||
export const TOP_TAB_LEGACY_ACTIVE_BG = 'var(--top-tabs-active-bg, hsl(var(--background)))';
|
||||
|
||||
/** Previous selected label color, kept as the fallback when the hook is unset. */
|
||||
export const TOP_TAB_LEGACY_ACTIVE_FG = 'var(--top-tabs-fg, hsl(var(--foreground)))';
|
||||
|
||||
export const TOP_TAB_ACTIVE_BG = `var(--top-tab-active-bg, ${TOP_TAB_LEGACY_ACTIVE_BG})`;
|
||||
|
||||
export const TOP_TAB_ACTIVE_FG = `var(--top-tab-active-fg, ${TOP_TAB_LEGACY_ACTIVE_FG})`;
|
||||
|
||||
export const TOP_TAB_ACTIVE_BORDER = 'var(--top-tab-active-border, transparent)';
|
||||
|
||||
export const TOP_TAB_INACTIVE_FG = 'var(--top-tabs-muted, hsl(var(--muted-foreground)))';
|
||||
|
||||
/** Same 40% mix as the previous hover fill. The inner color is the active-tab hook. */
|
||||
export const TOP_TAB_HOVER_BG = `color-mix(in srgb, ${TOP_TAB_ACTIVE_BG} 40%, transparent)`;
|
||||
|
||||
export const TOP_TAB_ACTIVE_OUTLINE = `1px solid ${TOP_TAB_ACTIVE_BORDER}`;
|
||||
|
||||
export function topTabActiveValue(isActive: boolean): 'true' | 'false' {
|
||||
return isActive ? 'true' : 'false';
|
||||
}
|
||||
|
||||
export interface TopTabChromeStyle {
|
||||
backgroundColor: string;
|
||||
color: string;
|
||||
}
|
||||
|
||||
export function resolveTopTabChromeStyle(
|
||||
isActive: boolean,
|
||||
options?: { suppressActiveBg?: boolean },
|
||||
): TopTabChromeStyle {
|
||||
if (!isActive) {
|
||||
return {
|
||||
backgroundColor: 'transparent',
|
||||
color: TOP_TAB_INACTIVE_FG,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
backgroundColor: options?.suppressActiveBg ? 'transparent' : TOP_TAB_ACTIVE_BG,
|
||||
color: TOP_TAB_ACTIVE_FG,
|
||||
};
|
||||
}
|
||||
|
||||
type TopTabStyleTarget = {
|
||||
style: {
|
||||
backgroundColor: string;
|
||||
color: string;
|
||||
};
|
||||
};
|
||||
|
||||
export function paintInactiveTopTabHover(element: TopTabStyleTarget): void {
|
||||
element.style.backgroundColor = TOP_TAB_HOVER_BG;
|
||||
element.style.color = TOP_TAB_ACTIVE_FG;
|
||||
}
|
||||
|
||||
export function clearInactiveTopTabHover(element: TopTabStyleTarget): void {
|
||||
element.style.backgroundColor = 'transparent';
|
||||
element.style.color = TOP_TAB_INACTIVE_FG;
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import test from 'node:test';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
import { JSDOM } from 'jsdom';
|
||||
|
||||
import en from '../application/i18n/locales/en';
|
||||
import es from '../application/i18n/locales/es';
|
||||
import ru from '../application/i18n/locales/ru';
|
||||
import zhCN from '../application/i18n/locales/zh-CN';
|
||||
import zhTW from '../application/i18n/locales/zh-TW';
|
||||
import {
|
||||
TOP_TAB_ACTIVE_BG,
|
||||
TOP_TAB_ACTIVE_BORDER,
|
||||
TOP_TAB_ACTIVE_FG,
|
||||
TOP_TAB_ACTIVE_OUTLINE,
|
||||
TOP_TAB_HOVER_BG,
|
||||
TOP_TAB_INACTIVE_FG,
|
||||
TOP_TAB_ITEM_CLASS,
|
||||
TOP_TAB_LEGACY_ACTIVE_BG,
|
||||
TOP_TAB_LEGACY_ACTIVE_FG,
|
||||
clearInactiveTopTabHover,
|
||||
paintInactiveTopTabHover,
|
||||
resolveTopTabChromeStyle,
|
||||
topTabActiveValue,
|
||||
} from './top-tabs/topTabActiveStyle';
|
||||
|
||||
const topTabItemsSource = readFileSync(
|
||||
fileURLToPath(new URL('./top-tabs/TopTabItems.tsx', import.meta.url)),
|
||||
'utf8',
|
||||
);
|
||||
const indexCss = readFileSync(fileURLToPath(new URL('../index.css', import.meta.url)), 'utf8');
|
||||
|
||||
const TAB_TYPES = ['root', 'plugin-view', 'editor', 'session', 'workspace', 'logView'] as const;
|
||||
const LOCALES = [
|
||||
{ name: 'en', messages: en },
|
||||
{ name: 'zh-CN', messages: zhCN },
|
||||
{ name: 'zh-TW', messages: zhTW },
|
||||
{ name: 'es', messages: es },
|
||||
{ name: 'ru', messages: ru },
|
||||
];
|
||||
const HOOKS = ['--top-tab-active-bg', '--top-tab-active-fg', '--top-tab-active-border'] as const;
|
||||
|
||||
test('active top tab colors fall back to the current chrome colors', () => {
|
||||
assert.equal(TOP_TAB_ACTIVE_BG, `var(--top-tab-active-bg, ${TOP_TAB_LEGACY_ACTIVE_BG})`);
|
||||
assert.equal(TOP_TAB_ACTIVE_FG, `var(--top-tab-active-fg, ${TOP_TAB_LEGACY_ACTIVE_FG})`);
|
||||
assert.equal(TOP_TAB_ACTIVE_BORDER, 'var(--top-tab-active-border, transparent)');
|
||||
assert.equal(TOP_TAB_LEGACY_ACTIVE_BG, 'var(--top-tabs-active-bg, hsl(var(--background)))');
|
||||
assert.equal(TOP_TAB_LEGACY_ACTIVE_FG, 'var(--top-tabs-fg, hsl(var(--foreground)))');
|
||||
assert.equal(
|
||||
TOP_TAB_HOVER_BG,
|
||||
`color-mix(in srgb, ${TOP_TAB_ACTIVE_BG} 40%, transparent)`,
|
||||
);
|
||||
|
||||
const active = resolveTopTabChromeStyle(true);
|
||||
assert.deepEqual(active, {
|
||||
backgroundColor: TOP_TAB_ACTIVE_BG,
|
||||
color: TOP_TAB_ACTIVE_FG,
|
||||
});
|
||||
|
||||
const inactive = resolveTopTabChromeStyle(false);
|
||||
assert.deepEqual(inactive, {
|
||||
backgroundColor: 'transparent',
|
||||
color: TOP_TAB_INACTIVE_FG,
|
||||
});
|
||||
assert.equal(TOP_TAB_INACTIVE_FG, 'var(--top-tabs-muted, hsl(var(--muted-foreground)))');
|
||||
|
||||
const vault = resolveTopTabChromeStyle(true, { suppressActiveBg: true });
|
||||
assert.equal(vault.backgroundColor, 'transparent');
|
||||
assert.equal(vault.color, TOP_TAB_ACTIVE_FG);
|
||||
});
|
||||
|
||||
test('top tab active attribute is a stable true/false hook', () => {
|
||||
assert.equal(topTabActiveValue(true), 'true');
|
||||
assert.equal(topTabActiveValue(false), 'false');
|
||||
assert.equal(TOP_TAB_ITEM_CLASS, 'top-tab');
|
||||
});
|
||||
|
||||
test('inactive hover uses the active-tab hook and clears back to the flat tab', () => {
|
||||
const element = { style: { backgroundColor: '', color: '' } };
|
||||
paintInactiveTopTabHover(element);
|
||||
assert.equal(element.style.backgroundColor, TOP_TAB_HOVER_BG);
|
||||
assert.equal(element.style.color, TOP_TAB_ACTIVE_FG);
|
||||
clearInactiveTopTabHover(element);
|
||||
assert.equal(element.style.backgroundColor, 'transparent');
|
||||
assert.equal(element.style.color, TOP_TAB_INACTIVE_FG);
|
||||
});
|
||||
|
||||
test('every top tab kind exposes the active style hook', () => {
|
||||
for (const type of TAB_TYPES) {
|
||||
const marker = `data-tab-type="${type}"`;
|
||||
const start = topTabItemsSource.indexOf(marker);
|
||||
assert.notEqual(start, -1, marker);
|
||||
const next = topTabItemsSource.indexOf('data-tab-type=', start + marker.length);
|
||||
const slice = topTabItemsSource.slice(start, next === -1 ? undefined : next);
|
||||
assert.match(slice, /data-active=\{topTabActiveValue\(isActive\)\}/);
|
||||
assert.match(slice, /TOP_TAB_ITEM_CLASS/);
|
||||
assert.match(slice, /resolveTopTabChromeStyle\(isActive/);
|
||||
}
|
||||
|
||||
assert.equal(topTabItemsSource.split('data-active={topTabActiveValue(isActive)}').length - 1, TAB_TYPES.length);
|
||||
assert.doesNotMatch(topTabItemsSource, /backgroundColor: isActive/);
|
||||
});
|
||||
|
||||
test('selected top tab border hook defaults to a transparent inset outline', () => {
|
||||
const start = indexCss.indexOf('[data-section="top-tabs"] .top-tab[data-active="true"]');
|
||||
assert.notEqual(start, -1);
|
||||
const rule = indexCss.slice(start, indexCss.indexOf('}', start));
|
||||
assert.match(rule, /outline:\s*1px solid var\(--top-tab-active-border, transparent\)/);
|
||||
assert.match(rule, /outline-offset:\s*-1px/);
|
||||
assert.equal(TOP_TAB_ACTIVE_OUTLINE, '1px solid var(--top-tab-active-border, transparent)');
|
||||
});
|
||||
|
||||
test('custom CSS sets the top tab hooks on the selected tab', () => {
|
||||
const dom = new JSDOM(`<!doctype html><html><head></head><body>
|
||||
<div data-section="top-tabs">
|
||||
<div id="active" class="top-tab" data-active="true"></div>
|
||||
<div id="idle" class="top-tab" data-active="false"></div>
|
||||
</div>
|
||||
</body></html>`);
|
||||
const { document, getComputedStyle } = dom.window;
|
||||
const active = document.getElementById('active')!;
|
||||
const idle = document.getElementById('idle')!;
|
||||
const activeStyle = resolveTopTabChromeStyle(true);
|
||||
active.style.backgroundColor = activeStyle.backgroundColor;
|
||||
active.style.color = activeStyle.color;
|
||||
Object.assign(idle.style, resolveTopTabChromeStyle(false));
|
||||
|
||||
assert.equal(active.getAttribute('data-active'), 'true');
|
||||
assert.equal(active.style.backgroundColor, TOP_TAB_ACTIVE_BG);
|
||||
assert.equal(active.style.color, TOP_TAB_ACTIVE_FG);
|
||||
assert.equal(idle.style.backgroundColor, 'transparent');
|
||||
assert.equal(getComputedStyle(active).getPropertyValue('--top-tab-active-bg').trim(), '');
|
||||
|
||||
const custom = document.createElement('style');
|
||||
custom.id = 'netcatty-custom-css';
|
||||
custom.textContent = `
|
||||
.top-tab[data-active="true"] {
|
||||
--top-tab-active-bg: rgb(37, 99, 235);
|
||||
--top-tab-active-fg: rgb(255, 255, 255);
|
||||
--top-tab-active-border: rgb(29, 78, 216);
|
||||
}
|
||||
`;
|
||||
document.head.appendChild(custom);
|
||||
|
||||
const styled = getComputedStyle(active);
|
||||
const normalized = (value: string) => value.replace(/\s+/g, '');
|
||||
assert.equal(normalized(styled.getPropertyValue('--top-tab-active-bg')), 'rgb(37,99,235)');
|
||||
assert.equal(normalized(styled.getPropertyValue('--top-tab-active-fg')), 'rgb(255,255,255)');
|
||||
assert.equal(normalized(styled.getPropertyValue('--top-tab-active-border')), 'rgb(29,78,216)');
|
||||
assert.equal(getComputedStyle(idle).getPropertyValue('--top-tab-active-bg').trim(), '');
|
||||
assert.match(active.style.backgroundColor, /var\(--top-tab-active-bg,/);
|
||||
});
|
||||
|
||||
test('custom CSS help mentions the top tab hooks in every locale', () => {
|
||||
for (const locale of LOCALES) {
|
||||
const desc = locale.messages['settings.appearance.customCss.desc'];
|
||||
const help = locale.messages['settings.appearance.customCss.help.body'];
|
||||
const placeholder = locale.messages['settings.appearance.customCss.placeholder'];
|
||||
assert.equal(typeof desc, 'string', locale.name);
|
||||
assert.equal(typeof help, 'string', locale.name);
|
||||
assert.equal(typeof placeholder, 'string', locale.name);
|
||||
for (const hook of HOOKS) {
|
||||
assert.ok(desc.includes(hook), `${locale.name} description missing ${hook}`);
|
||||
assert.ok(help.includes(hook), `${locale.name} help missing ${hook}`);
|
||||
assert.ok(placeholder.includes(hook), `${locale.name} placeholder missing ${hook}`);
|
||||
}
|
||||
assert.match(help, /\.top-tab\[data-active="true"\]/);
|
||||
assert.match(placeholder, /\.top-tab\[data-active="true"\]/);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,106 @@
|
||||
# Agent Note: Keep port forwarding usable when host OS metadata is missing
|
||||
|
||||
Status: implemented
|
||||
|
||||
## Problem
|
||||
|
||||
[Issue #3748](https://github.com/binaricat/Netcatty/issues/3748) reports that the
|
||||
port-forwarding page displays `Port forwarding could not load.` when rendering
|
||||
a selected host whose legacy `os` value is absent, null, or an empty string.
|
||||
|
||||
`EditPanel`, `NewFormPanel`, and both host-selection steps in `WizardContent`
|
||||
evaluate `selectedHost.os[0].toUpperCase()` while constructing `DistroAvatar`
|
||||
props. Missing or null values fail on indexing; an empty string fails on
|
||||
`toUpperCase`. This happens before the avatar can render its generic icon.
|
||||
Although `Host.os` is required by TypeScript, persisted runtime records can lack
|
||||
that metadata. The original source of the affected records is not established.
|
||||
|
||||
## Decision
|
||||
|
||||
The four call sites use `(selectedHost.os || 'unknown').charAt(0).toUpperCase()`
|
||||
to keep the required string fallback safe without changing the shared avatar
|
||||
API. Valid OS values retain their existing initials. Missing and empty values
|
||||
use the explicit unknown fallback, and the avatar keeps its existing generic
|
||||
icon behavior.
|
||||
|
||||
The fix stays in the affected presentation code. It does not change host data,
|
||||
normalization, persistence, or tunnel lifecycle behavior. A shared helper for a
|
||||
single short expression would add indirection without a separate responsibility,
|
||||
so the four call sites use the same expression directly.
|
||||
|
||||
## Validation
|
||||
|
||||
`components/PortForwardingPanels.test.tsx` renders the real components with
|
||||
`react-dom/server`, the existing i18n provider, and the tooltip provider. It does
|
||||
not mock the panels or avatar and does not inspect their source text.
|
||||
|
||||
The table-driven cases cover all four entry points, all three forwarding types,
|
||||
and missing, undefined, null, empty, Linux, Windows, and macOS values. Separate
|
||||
checks preserve distro icons and host-picker behavior when no host is selected
|
||||
or the selected host is no longer available. Invalid legacy fixture shapes use
|
||||
one explicit type assertion rather than weakening the production `Host` type.
|
||||
|
||||
Before the fix, 48 of the 96 new cases fail with the reported TypeError; the
|
||||
other 48 pass. After the fix, all 96 pass. Together with the existing avatar,
|
||||
port-forwarding toolbar, and utility tests, the focused run passes 109 tests:
|
||||
|
||||
```sh
|
||||
node --test --import tsx components/PortForwardingPanels.test.tsx \
|
||||
components/PortForwardingNew.test.ts components/portForwardingUtils.test.ts \
|
||||
components/DistroAvatar.test.tsx --test-concurrency=2
|
||||
```
|
||||
|
||||
These are rendering regression tests, not SSH or network end-to-end tests.
|
||||
|
||||
Additional checks against upstream commit `786a317`:
|
||||
|
||||
- `npm run build` succeeds.
|
||||
- `npm run lint` succeeds with the same eight existing warnings as upstream.
|
||||
The four changed TypeScript/TSX files also pass lint with `--max-warnings=0`.
|
||||
- `LC_ALL=C npm test -- --test-concurrency=1` runs 13,986 tests: 13,944 pass,
|
||||
four fail, and 38 are skipped. An unmodified upstream worktree runs 13,890
|
||||
tests with the same four failures and 38 skips, so this change adds 96 passing
|
||||
cases without adding a failing case. The baseline failures are the SFTP
|
||||
large-directory persistence timing test, two Bash OSC 7 setup tests, and the
|
||||
README clipboard-paste screenshot-dimension assertion. The SFTP timing test
|
||||
passes in isolated runs on both checkouts but fails in these full-suite runs.
|
||||
A non-English locale additionally causes the existing fish error-message
|
||||
assertion to fail; both comparison runs use the C locale.
|
||||
- The full-project `tsc --noEmit --pretty false` check has 759 existing errors.
|
||||
Its complete output matches the unmodified upstream output after normalizing
|
||||
checkout paths; none of the changed files has a diagnostic. This is not a
|
||||
claim that full-project type checking passes.
|
||||
|
||||
### Upstream CI follow-up
|
||||
|
||||
The original PR CI run [39215269619, attempt 2](https://github.com/binaricat/Netcatty/actions/runs/39215269619/attempts/2)
|
||||
passes all 96 new regression cases and fails only the existing README paste
|
||||
assertion. Upstream commit `b366967` fixes that assertion by removing its
|
||||
coupling to changing screenshot dimensions. The branch incorporates this fix
|
||||
through a normal merge of upstream `main` at `42b8e2f`, without rewriting the
|
||||
original fix commit or adding an independent change to the unrelated test.
|
||||
|
||||
After synchronization, the focused run including the entire clipboard-paste
|
||||
test file passes 149 tests. Lint and the production build also pass; lint keeps
|
||||
the same eight existing warnings. The earlier full-suite and type-check
|
||||
comparison above describes the original `786a317` baseline, not the updated
|
||||
base. Current remote CI results are tracked on the PR.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
- Remove the unused fallback prop from the four calls: the shared avatar still
|
||||
requires it in its TypeScript contract. Changing that API and cleaning up all
|
||||
callers is unnecessary for this targeted bug fix.
|
||||
- Normalize or rewrite stored host records: broadens the persistence scope,
|
||||
obscures the rendering assumption, and cannot replace guarding optional
|
||||
runtime metadata at the point of use.
|
||||
- Add a new avatar wrapper or fallback utility: unnecessary for this small
|
||||
expression and would expand the production change without improving the
|
||||
boundary being fixed.
|
||||
|
||||
## Consequences
|
||||
|
||||
Incomplete host OS metadata no longer prevents new/edit panels or wizard
|
||||
host-selection steps from rendering. Existing icons, component contracts, and
|
||||
host records remain unchanged. The guard is local to port forwarding; auditing
|
||||
unrelated callers or redesigning the unused fallback API remains out of scope.
|
||||
@@ -522,7 +522,7 @@ const DEFAULT_TERMINAL_SETTINGS: TerminalSettings = {
|
||||
altAsMeta: false,
|
||||
optionArrowWordJump: false,
|
||||
shiftEnterNewlineEnabled: true,
|
||||
shiftEnterNewlineText: '\\n',
|
||||
shiftEnterNewlineText: '\\e\\r',
|
||||
shiftEnterForceText: false,
|
||||
kittyKeyboardProtocolEnabled: false,
|
||||
scrollOnInput: true,
|
||||
|
||||
@@ -269,9 +269,6 @@ test("repo README paste collapses shields badges without debris", () => {
|
||||
const payload = resolveNoteClipboardPaste({ plainText: readmeHead, htmlText: "" });
|
||||
assert.ok(payload.text.length > 50);
|
||||
assert.doesNotMatch(payload.text, /^\s*\]\([^)\n]+\)\s*$/m);
|
||||
// Large screenshot keeps dimensions in source.
|
||||
assert.match(payload.text, /width="3142"/);
|
||||
assert.match(payload.text, /height="1764"/);
|
||||
});
|
||||
|
||||
test("resolve uses full turndown for browser StartFragment html", () => {
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import { remoteSoftwareRequiresSingleChannel } from "./singleChannelSshBanner.shared.mjs";
|
||||
import { sftpRequiresDedicatedLogin } from "./sftpDedicatedLogin";
|
||||
|
||||
test("network-device mode forces a dedicated SFTP login before any banner", () => {
|
||||
assert.equal(
|
||||
sftpRequiresDedicatedLogin({ host: { deviceType: "network" } }),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
sftpRequiresDedicatedLogin({
|
||||
host: { deviceType: "network" },
|
||||
remoteSshVersion: "OpenSSH_9.6",
|
||||
}),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
sftpRequiresDedicatedLogin({
|
||||
host: { deviceType: "network" },
|
||||
remoteSshVersion: "SSH-2.0-OpenSSH_for_Windows_9.5",
|
||||
}),
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
test("Huawei dash and Ruijie banners force a dedicated SFTP login", () => {
|
||||
for (const remoteSshVersion of ["-", "SSH-2.0--", "SSH-1.99--", "RGOS_SSH", "SSH-2.0-RGOS_SSH"]) {
|
||||
assert.equal(
|
||||
sftpRequiresDedicatedLogin({
|
||||
host: { deviceType: "general" },
|
||||
remoteSshVersion,
|
||||
}),
|
||||
true,
|
||||
remoteSshVersion,
|
||||
);
|
||||
}
|
||||
// Huawei `-` must not become a global single-channel bastion token.
|
||||
assert.equal(remoteSoftwareRequiresSingleChannel("-"), false);
|
||||
assert.equal(remoteSoftwareRequiresSingleChannel("SSH-2.0--"), false);
|
||||
});
|
||||
|
||||
test("other network-vendor banners force a dedicated SFTP login", () => {
|
||||
for (const remoteSshVersion of [
|
||||
"SSH-2.0-Cisco-1.25",
|
||||
"SSH-2.0-H3C-Comware-7.1.064",
|
||||
"SSH-2.0-ROSSSH",
|
||||
]) {
|
||||
assert.equal(
|
||||
sftpRequiresDedicatedLogin({ host: { deviceType: "general" }, remoteSshVersion }),
|
||||
true,
|
||||
remoteSshVersion,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test("one-channel bastion banners still force a dedicated SFTP login", () => {
|
||||
for (const remoteSshVersion of [
|
||||
"BHostSSH_7.0",
|
||||
"SSH-2.0-TERM-SSHD",
|
||||
"CLOUDBILITY-4.14",
|
||||
"SSH-2.0-CloudLinker",
|
||||
]) {
|
||||
assert.equal(
|
||||
sftpRequiresDedicatedLogin({ host: { deviceType: "general" }, remoteSshVersion }),
|
||||
true,
|
||||
remoteSshVersion,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test("ordinary SSH servers keep the shared terminal SFTP transport", () => {
|
||||
assert.equal(sftpRequiresDedicatedLogin({ host: { deviceType: "general" } }), false);
|
||||
assert.equal(sftpRequiresDedicatedLogin({ host: null }), false);
|
||||
assert.equal(sftpRequiresDedicatedLogin({}), false);
|
||||
for (const remoteSshVersion of ["OpenSSH_9.6", "SSH-2.0-OpenSSH_9.6", "JumpServer", ""]) {
|
||||
assert.equal(
|
||||
sftpRequiresDedicatedLogin({ host: { deviceType: "general" }, remoteSshVersion }),
|
||||
false,
|
||||
remoteSshVersion,
|
||||
);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,25 @@
|
||||
import { detectVendorFromSshVersion, hostRestrictsExtraSshChannels } from "./host";
|
||||
import type { Host } from "./models";
|
||||
import { remoteSoftwareRequiresSingleChannel } from "./singleChannelSshBanner.shared.mjs";
|
||||
|
||||
export type SftpDedicatedLoginInput = {
|
||||
host?: Pick<Host, "deviceType"> | null;
|
||||
remoteSshVersion?: string | null;
|
||||
};
|
||||
|
||||
/**
|
||||
* SFTP must dial its own SSH login when an extra subsystem on the terminal
|
||||
* TCP session would drop the shell (#3631).
|
||||
*
|
||||
* Network-device mode is enough on its own, with or without a banner.
|
||||
* A network-vendor identification string (Huawei `-` / `SSH-2.0--`, Ruijie
|
||||
* RGOS, and the other `detectVendorFromSshVersion` matches) and one-channel
|
||||
* bastion software tokens are included here. This does not stamp
|
||||
* session.singleChannelSsh; that flag stays reserved for bastion banners.
|
||||
*/
|
||||
export function sftpRequiresDedicatedLogin(input: SftpDedicatedLoginInput): boolean {
|
||||
if (hostRestrictsExtraSshChannels(input.host)) return true;
|
||||
const remoteSshVersion = input.remoteSshVersion || undefined;
|
||||
return remoteSoftwareRequiresSingleChannel(remoteSshVersion)
|
||||
|| detectVendorFromSshVersion(remoteSshVersion) !== "";
|
||||
}
|
||||
@@ -93,7 +93,7 @@ test("normalizeTerminalSettings enables Shift+Enter newline by default", () => {
|
||||
const settings = normalizeTerminalSettings();
|
||||
|
||||
assert.equal(settings.shiftEnterNewlineEnabled, true);
|
||||
assert.equal(settings.shiftEnterNewlineText, "\\n");
|
||||
assert.equal(settings.shiftEnterNewlineText, "\\e\\r");
|
||||
});
|
||||
|
||||
test("normalizeTerminalSettings disables Kitty keyboard protocol by default", () => {
|
||||
@@ -114,7 +114,7 @@ test("normalizeTerminalSettings preserves Shift+Enter text", () => {
|
||||
test("normalizeTerminalSettings falls back when Shift+Enter text is not a string", () => {
|
||||
assert.equal(
|
||||
normalizeTerminalSettings({ shiftEnterNewlineText: null as never }).shiftEnterNewlineText,
|
||||
"\\n",
|
||||
"\\e\\r",
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -5,6 +5,12 @@ const { StringDecoder } = require("node:string_decoder");
|
||||
const DEFAULT_CODEX_CLI_TIMEOUT_MS = 10_000;
|
||||
const CODEX_AUTH_VALIDATION_TIMEOUT_MS = 10_000;
|
||||
const MAX_AGENT_CLI_BUFFER_CHARS = 10 * 1024 * 1024;
|
||||
// Bun-compiled OpenCode-family CLIs start slowly: `mimo --version` takes
|
||||
// 3.9–5.1s warm (6s+ cold) and `opencode --version` can exceed 5s on a cold
|
||||
// cache, so a 5s timeout killed healthy CLIs and dropped them from discovery.
|
||||
// 15s only affects CLIs that exist but are slow to answer — a missing command
|
||||
// fails spawn immediately.
|
||||
const VERSION_PROBE_TIMEOUT_MS = 15_000;
|
||||
|
||||
function createAgentCliHelpers(ctx) {
|
||||
with (ctx) {
|
||||
@@ -120,7 +126,7 @@ function createAgentCliHelpers(ctx) {
|
||||
|
||||
async function probeCliVersion(probeCmd, probeArgs, env) {
|
||||
try {
|
||||
const result = await runCommand(probeCmd, probeArgs, { env, timeoutMs: 5000 });
|
||||
const result = await runCommand(probeCmd, probeArgs, { env, timeoutMs: VERSION_PROBE_TIMEOUT_MS });
|
||||
return {
|
||||
launched: true,
|
||||
exitCode: result.exitCode,
|
||||
@@ -430,4 +436,5 @@ module.exports = {
|
||||
CODEX_AUTH_VALIDATION_TIMEOUT_MS,
|
||||
DEFAULT_CODEX_CLI_TIMEOUT_MS,
|
||||
MAX_AGENT_CLI_BUFFER_CHARS,
|
||||
VERSION_PROBE_TIMEOUT_MS,
|
||||
};
|
||||
@@ -10,6 +10,7 @@ const {
|
||||
CODEX_AUTH_VALIDATION_TIMEOUT_MS,
|
||||
DEFAULT_CODEX_CLI_TIMEOUT_MS,
|
||||
MAX_AGENT_CLI_BUFFER_CHARS,
|
||||
VERSION_PROBE_TIMEOUT_MS,
|
||||
} = require("./agentCliHelpers.cjs");
|
||||
|
||||
function createHungChild() {
|
||||
@@ -55,6 +56,37 @@ test("runCodexCli applies a default timeout to short status commands", async ()
|
||||
assert.deepEqual(child.kills, ["SIGTERM", "SIGKILL"]);
|
||||
});
|
||||
|
||||
test("probeCliVersion allows slow-starting CLIs beyond the old 5s probe limit", async () => {
|
||||
const child = createHungChild();
|
||||
const scheduled = [];
|
||||
const helpers = createAgentCliHelpers({
|
||||
prepareCommandForSpawn: (command, args) => ({ command, args, shell: false }),
|
||||
spawn: () => {
|
||||
queueMicrotask(() => {
|
||||
child.stdout.emit("data", Buffer.from("0.1.15\n"));
|
||||
child.emit("close", 0);
|
||||
});
|
||||
return child;
|
||||
},
|
||||
stripAnsi: (value) => value,
|
||||
getShellEnv: async () => ({}),
|
||||
normalizeCliPathForPlatform: (value) => value,
|
||||
resolveCliFromPathAsync: async () => "/fake/mimo",
|
||||
setTimeout: (callback, delay) => {
|
||||
scheduled.push(delay);
|
||||
return { unref() {} };
|
||||
},
|
||||
clearTimeout() {},
|
||||
});
|
||||
|
||||
const result = await helpers.probeCliVersion("/fake/mimo", ["--version"], {});
|
||||
assert.equal(result.launched, true);
|
||||
assert.equal(result.exitCode, 0);
|
||||
assert.equal(result.version, "0.1.15");
|
||||
assert.equal(scheduled[0], VERSION_PROBE_TIMEOUT_MS);
|
||||
assert.ok(scheduled[0] > 5000, "version probe timeout must exceed 5s for bun-compiled OpenCode-family CLIs");
|
||||
});
|
||||
|
||||
test("runCodexCli slices a single oversized output chunk to the hard limit", async () => {
|
||||
const child = createHungChild();
|
||||
const helpers = createAgentCliHelpers({
|
||||
|
||||
@@ -7,6 +7,11 @@ const DEFAULT_SSH_EXEC_OPEN_TIMEOUT_MS = 15_000;
|
||||
const DEFAULT_SSH_EXEC_RUN_TIMEOUT_MS = 10 * 60_000;
|
||||
const DEFAULT_SSH_EXEC_MAX_OUTPUT_BYTES = 64 * 1024;
|
||||
|
||||
// A best-effort probe may abandon an open without disconnecting other leases.
|
||||
// ssh2 cannot cancel that open. Fence further execs until its callback arrives,
|
||||
// bounding retained requests even when a panel keeps polling a stalled server.
|
||||
const abandonedExecOpens = new WeakMap();
|
||||
|
||||
function terminateSshExecStream(stream) {
|
||||
if (!stream) return;
|
||||
// Teardown can race with a final transport error after normal listeners have
|
||||
@@ -112,6 +117,11 @@ function executeBoundedSshCommand(sshClient, command, options = {}) {
|
||||
if (!sshClient || typeof sshClient.exec !== "function") {
|
||||
return Promise.reject(new Error("SSH exec unavailable"));
|
||||
}
|
||||
if (abandonedExecOpens.get(sshClient)?.size) {
|
||||
const error = new Error("SSH exec channel open still pending on this connection");
|
||||
error.code = "SSH_EXEC_OPEN_PENDING";
|
||||
return Promise.reject(error);
|
||||
}
|
||||
const signal = options.signal || null;
|
||||
const setTimeoutFn = options.setTimeoutFn || setTimeout;
|
||||
const clearTimeoutFn = options.clearTimeoutFn || clearTimeout;
|
||||
@@ -132,6 +142,19 @@ function executeBoundedSshCommand(sshClient, command, options = {}) {
|
||||
const stderrDecoder = new StringDecoder("utf8");
|
||||
let decodersEnded = false;
|
||||
let cleanupStreamListeners = () => {};
|
||||
const openToken = {};
|
||||
let invoked = false;
|
||||
const abandonOpen = () => {
|
||||
if (!invoked || streamRef || settled) return;
|
||||
let pending = abandonedExecOpens.get(sshClient);
|
||||
if (!pending) abandonedExecOpens.set(sshClient, pending = new Set());
|
||||
pending.add(openToken);
|
||||
};
|
||||
const releaseAbandonedOpen = () => {
|
||||
const pending = abandonedExecOpens.get(sshClient);
|
||||
pending?.delete(openToken);
|
||||
if (pending?.size === 0) abandonedExecOpens.delete(sshClient);
|
||||
};
|
||||
|
||||
const cleanup = () => {
|
||||
if (openingTimer) clearTimeoutFn(openingTimer);
|
||||
@@ -159,12 +182,15 @@ function executeBoundedSshCommand(sshClient, command, options = {}) {
|
||||
}
|
||||
return true;
|
||||
};
|
||||
const onAbort = () => finish(createAbortError(signal), null, {
|
||||
terminate: true,
|
||||
// Before the callback arrives ssh2 owns an uncancellable channel-open
|
||||
// request. Closing the physical transport is the only public cleanup.
|
||||
invalidateTransport: !streamRef && options.invalidateTransportOnAbort !== false,
|
||||
});
|
||||
const onAbort = () => {
|
||||
if (options.invalidateTransportOnAbort === false) abandonOpen();
|
||||
return finish(createAbortError(signal), null, {
|
||||
terminate: true,
|
||||
// Before the callback arrives ssh2 owns an uncancellable channel-open
|
||||
// request. Preserve-and-fence callers wait for late callback cleanup.
|
||||
invalidateTransport: !streamRef && options.invalidateTransportOnAbort !== false,
|
||||
});
|
||||
};
|
||||
const append = (target, chunk) => {
|
||||
if (settled) return;
|
||||
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(String(chunk));
|
||||
@@ -190,11 +216,14 @@ function executeBoundedSshCommand(sshClient, command, options = {}) {
|
||||
openingTimer = setTimeoutFn(() => {
|
||||
const error = new Error(`SSH exec channel open timed out after ${openingTimeoutMs} ms`);
|
||||
error.code = "SSH_EXEC_OPEN_TIMEOUT";
|
||||
if (!invalidateOnOpenTimeout) abandonOpen();
|
||||
finish(error, null, { terminate: true, invalidateTransport: invalidateOnOpenTimeout });
|
||||
}, openingTimeoutMs);
|
||||
|
||||
try {
|
||||
invoked = true;
|
||||
sshClient.exec(command, (error, stream) => {
|
||||
releaseAbandonedOpen();
|
||||
if (openingTimer) clearTimeoutFn(openingTimer);
|
||||
openingTimer = null;
|
||||
if (settled) {
|
||||
|
||||
@@ -349,3 +349,42 @@ test("bounded SSH exec still honors the channel close when no exit-status is rep
|
||||
stream.emit("close", 7);
|
||||
assert.deepEqual(await result, { stdout: "done", stderr: "", code: 7 });
|
||||
});
|
||||
|
||||
for (const abandon of ['timeout', 'abort']) {
|
||||
test(`preserved ${abandon} fences exec retries until the late callback settles`, async () => {
|
||||
let callback;
|
||||
let opens = 0;
|
||||
let destroyed = 0;
|
||||
const client = {
|
||||
exec(_command, next) { opens++; callback = next; },
|
||||
end() { destroyed++; },
|
||||
destroy() { destroyed++; },
|
||||
};
|
||||
const controller = new AbortController();
|
||||
const request = executeBoundedSshCommand(client, 'probe', {
|
||||
openingTimeoutMs: 5,
|
||||
invalidateOnOpenTimeout: false,
|
||||
invalidateTransportOnAbort: false,
|
||||
signal: controller.signal,
|
||||
});
|
||||
if (abandon === 'abort') controller.abort();
|
||||
await assert.rejects(request);
|
||||
for (let i = 0; i < 10; i++) {
|
||||
await assert.rejects(executeBoundedSshCommand(client, 'probe'),
|
||||
{ code: 'SSH_EXEC_OPEN_PENDING' });
|
||||
}
|
||||
assert.equal(opens, 1);
|
||||
assert.equal(destroyed, 0);
|
||||
const lateStream = createStream();
|
||||
callback(null, lateStream);
|
||||
assert.equal(lateStream.closed, 1);
|
||||
const retry = executeBoundedSshCommand(client, 'probe');
|
||||
const stream = createStream();
|
||||
callback(null, stream);
|
||||
stream.emit('data', Buffer.from('recovered'));
|
||||
stream.emit('close', 0);
|
||||
assert.equal((await retry).stdout, 'recovered');
|
||||
assert.equal(opens, 2);
|
||||
assert.equal(destroyed, 0);
|
||||
});
|
||||
}
|
||||
@@ -1,13 +1,20 @@
|
||||
"use strict";
|
||||
const { executeBoundedSshCommand } = require("../boundedSshExec.cjs");
|
||||
|
||||
const GPU_ASSIGNMENT = `gpustat=$(gpucsv=$(timeout -s KILL 2 nvidia-smi --query-gpu=utilization.gpu,memory.used,memory.total,name --format=csv,noheader,nounits 2>/dev/null) && printf '%s\\n' "$gpucsv" | awk -F', *' 'NF >= 4 {g++; if($1 ~ /^[0-9]+$/) {n++; u+=$1} if($2 ~ /^[0-9]+$/) {mu+=$2; uc++} if($3 ~ /^[0-9]+$/) {mt+=$3; tc++} if(name=="") name=$4} END{if(n>0) printf "%.0f %s %s %s", u/n, (uc==g ? sprintf("%.0f",mu) : "N/A"), (tc==g ? sprintf("%.0f",mt) : "N/A"), name}' 2>/dev/null || echo "")`;
|
||||
// One bounded nvidia-smi query per poll. The awk summary stays
|
||||
// "<avgUtil> <sumUsed|N/A> <sumTotal|N/A> <firstName>" and, when cards parse,
|
||||
// appends " ;; index<TAB>name<TAB>util|N/A<TAB>used|N/A<TAB>total|N/A" records.
|
||||
// Names are taken from the middle columns so a comma inside the product name
|
||||
// does not shift utilization or memory. "|" cannot survive into the stats line.
|
||||
const GPU_ASSIGNMENT = `gpustat=$(gpucsv=$(timeout -s KILL 2 nvidia-smi --query-gpu=index,name,utilization.gpu,memory.used,memory.total --format=csv,noheader,nounits 2>/dev/null) && printf '%s\\n' "$gpucsv" | awk -F', *' 'NF >= 5 {idx=$1; util=$(NF-2); used=$(NF-1); total=$NF; gsub(/^[ \\t"]+|[ \\t"]+$/, "", idx); gsub(/^[ \\t"]+|[ \\t"]+$/, "", util); gsub(/^[ \\t"]+|[ \\t"]+$/, "", used); gsub(/^[ \\t"]+|[ \\t"]+$/, "", total); if (idx !~ /^[0-9]+$/) next; name=$2; for (i=3; i<=NF-3; i++) name=name ", " $i; gsub(/^[ \\t"]+|[ \\t"]+$/, "", name); gsub(/\\t/, " ", name); gsub(/\\|/, "/", name); gsub(/ ;; /, " ", name); g++; if (util ~ /^[0-9]+$/) {n++; u+=util} if (used ~ /^[0-9]+$/) {mu+=used; uc++} if (total ~ /^[0-9]+$/) {mt+=total; tc++} if (name0=="") name0=name; utilOut=(util ~ /^[0-9]+$/) ? util : "N/A"; usedOut=(used ~ /^[0-9]+$/) ? used : "N/A"; totalOut=(total ~ /^[0-9]+$/) ? total : "N/A"; rec=idx "\\t" name "\\t" utilOut "\\t" usedOut "\\t" totalOut; list=(list=="") ? rec : list " ;; " rec} END{if (n>0) {printf "%.0f %s %s %s", u/n, (uc==g ? sprintf("%.0f", mu) : "N/A"), (tc==g ? sprintf("%.0f", mt) : "N/A"), name0; if (list != "") printf " ;; %s", list}}' 2>/dev/null || echo "")`;
|
||||
const EMPTY_GPU = {
|
||||
gpu: null,
|
||||
gpuName: null,
|
||||
gpuMemUsed: null,
|
||||
gpuMemTotal: null,
|
||||
gpus: [],
|
||||
};
|
||||
const GPU_LIST_MARK = " ;; ";
|
||||
const states = new WeakMap();
|
||||
function stateFor(conn) {
|
||||
let state = states.get(conn);
|
||||
@@ -24,8 +31,23 @@ function stateFor(conn) {
|
||||
}
|
||||
return state;
|
||||
}
|
||||
function sameGpuList(left, right) {
|
||||
const a = left || [];
|
||||
const b = right || [];
|
||||
return a.length === b.length && a.every((gpu, index) => {
|
||||
const other = b[index];
|
||||
return gpu.index === other.index
|
||||
&& gpu.name === other.name
|
||||
&& gpu.utilization === other.utilization
|
||||
&& gpu.memoryUsed === other.memoryUsed
|
||||
&& gpu.memoryTotal === other.memoryTotal;
|
||||
});
|
||||
}
|
||||
function updateGpuStats(state, next) {
|
||||
if (Object.keys(EMPTY_GPU).some((key) => state.stats[key] !== next[key])) {
|
||||
const changed = Object.keys(EMPTY_GPU).some((key) => (
|
||||
key === "gpus" ? !sameGpuList(state.stats[key], next[key]) : state.stats[key] !== next[key]
|
||||
));
|
||||
if (changed) {
|
||||
// Publication time is separate from CPU history and strictly advances even
|
||||
// when a fast GPU reply shares the CPU sample's millisecond.
|
||||
state.updatedAt = Math.max(Date.now() + 1, state.updatedAt + 1);
|
||||
@@ -35,23 +57,51 @@ function updateGpuStats(state, next) {
|
||||
function monitorGpuUpdatedAt(conn) {
|
||||
return states.get(conn)?.updatedAt ?? 0;
|
||||
}
|
||||
function emptyGpuStats() {
|
||||
return { ...EMPTY_GPU, gpus: [] };
|
||||
}
|
||||
function parseGpuNumber(value) {
|
||||
if (value == null || !/^[0-9]+$/.test(String(value).trim())) return null;
|
||||
return parseInt(value, 10);
|
||||
}
|
||||
function parseGpuCard(record) {
|
||||
const fields = String(record || "").split("\t");
|
||||
if (fields.length < 5) return null;
|
||||
const index = parseGpuNumber(fields[0]);
|
||||
if (index == null) return null;
|
||||
const memoryTotal = parseGpuNumber(fields[fields.length - 1]);
|
||||
const memoryUsed = parseGpuNumber(fields[fields.length - 2]);
|
||||
const utilization = parseGpuNumber(fields[fields.length - 3]);
|
||||
return {
|
||||
index,
|
||||
name: fields.slice(1, fields.length - 3).join("\t").trim(),
|
||||
utilization: utilization == null ? null : Math.max(0, Math.min(100, utilization)),
|
||||
memoryUsed,
|
||||
memoryTotal,
|
||||
};
|
||||
}
|
||||
function parseGpuStats(output) {
|
||||
const raw =
|
||||
String(output || "")
|
||||
.split("|")
|
||||
.find((part) => part.startsWith("GPU:"))
|
||||
?.slice(4)
|
||||
.trim()
|
||||
.split(/\s+/) ?? [];
|
||||
const utilization = parseInt(raw[0], 10);
|
||||
if (!Number.isFinite(utilization)) return EMPTY_GPU;
|
||||
const number = (value) =>
|
||||
Number.isFinite(parseInt(value, 10)) ? parseInt(value, 10) : null;
|
||||
const payload = String(output || "")
|
||||
.split("|")
|
||||
.find((part) => part.startsWith("GPU:"))
|
||||
?.slice(4)
|
||||
.trim() ?? "";
|
||||
if (!payload) return emptyGpuStats();
|
||||
const markAt = payload.indexOf(GPU_LIST_MARK);
|
||||
const summary = (markAt === -1 ? payload : payload.slice(0, markAt)).trim();
|
||||
const list = markAt === -1 ? "" : payload.slice(markAt + GPU_LIST_MARK.length);
|
||||
const raw = summary.split(/\s+/).filter(Boolean);
|
||||
const utilization = parseGpuNumber(raw[0]);
|
||||
if (utilization == null) return emptyGpuStats();
|
||||
const gpus = list
|
||||
? list.split(GPU_LIST_MARK).map(parseGpuCard).filter(Boolean)
|
||||
: [];
|
||||
return {
|
||||
gpu: Math.max(0, Math.min(100, utilization)),
|
||||
gpuName: raw.slice(3).join(" ") || null,
|
||||
gpuMemUsed: number(raw[1]),
|
||||
gpuMemTotal: number(raw[2]),
|
||||
gpuMemUsed: parseGpuNumber(raw[1]),
|
||||
gpuMemTotal: parseGpuNumber(raw[2]),
|
||||
gpus,
|
||||
};
|
||||
}
|
||||
function withCachedMonitorGpu(conn, result) {
|
||||
@@ -66,7 +116,7 @@ function cacheFullGpuStats(conn, stats) {
|
||||
updateGpuStats(
|
||||
state,
|
||||
Object.fromEntries(
|
||||
Object.keys(EMPTY_GPU).map((key) => [key, stats[key] ?? null]),
|
||||
Object.keys(EMPTY_GPU).map((key) => [key, stats[key] ?? (key === "gpus" ? [] : null)]),
|
||||
),
|
||||
);
|
||||
state.nextAt = Date.now() + 10000;
|
||||
|
||||
@@ -3,6 +3,8 @@ const test = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const { EventEmitter } = require("node:events");
|
||||
const {
|
||||
GPU_ASSIGNMENT,
|
||||
parseGpuStats,
|
||||
startMonitorGpuProbe,
|
||||
withCachedMonitorGpu,
|
||||
cacheFullGpuStats,
|
||||
@@ -17,6 +19,75 @@ function stream() {
|
||||
return s;
|
||||
}
|
||||
const sample = { success: true, stats: { cpu: 12, memTotal: 4096, gpu: null } };
|
||||
|
||||
test("GPU collection stays one nvidia-smi query and keeps per-GPU fields", () => {
|
||||
assert.equal(
|
||||
GPU_ASSIGNMENT.match(/nvidia-smi/g).length,
|
||||
1,
|
||||
"each poll must run a single nvidia-smi command",
|
||||
);
|
||||
assert.match(
|
||||
GPU_ASSIGNMENT,
|
||||
/nvidia-smi --query-gpu=index,name,utilization\.gpu,memory\.used,memory\.total --format=csv,noheader,nounits/,
|
||||
);
|
||||
});
|
||||
|
||||
test("parseGpuStats keeps a single aggregate GPU and an empty device list", () => {
|
||||
assert.deepEqual(parseGpuStats("GPU:73 2048 24576 NVIDIA GeForce RTX 4090"), {
|
||||
gpu: 73,
|
||||
gpuName: "NVIDIA GeForce RTX 4090",
|
||||
gpuMemUsed: 2048,
|
||||
gpuMemTotal: 24576,
|
||||
gpus: [],
|
||||
});
|
||||
});
|
||||
|
||||
test("parseGpuStats reads each GPU index, name, utilization, and memory", () => {
|
||||
const parsed = parseGpuStats(
|
||||
"GPU:72 3072 49152 NVIDIA RTX 4090 ;; 0\tNVIDIA RTX 4090\t73\t2048\t24576 ;; 1\tNVIDIA A100 80GB\t71\t1024\t24576",
|
||||
);
|
||||
assert.equal(parsed.gpu, 72);
|
||||
assert.equal(parsed.gpuMemUsed, 3072);
|
||||
assert.equal(parsed.gpuMemTotal, 49152);
|
||||
assert.deepEqual(parsed.gpus, [
|
||||
{ index: 0, name: "NVIDIA RTX 4090", utilization: 73, memoryUsed: 2048, memoryTotal: 24576 },
|
||||
{ index: 1, name: "NVIDIA A100 80GB", utilization: 71, memoryUsed: 1024, memoryTotal: 24576 },
|
||||
]);
|
||||
});
|
||||
|
||||
test("parseGpuStats keeps eight GPUs and clamps utilization", () => {
|
||||
const cards = Array.from({ length: 8 }, (_, index) =>
|
||||
`${index}\tNVIDIA A100 ${index}\t${index === 3 ? 140 : 10 + index}\t${1024 * (index + 1)}\t81920`,
|
||||
).join(" ;; ");
|
||||
const parsed = parseGpuStats(`GPU:40 36864 655360 NVIDIA A100 0 ;; ${cards}`);
|
||||
assert.equal(parsed.gpus.length, 8);
|
||||
assert.equal(parsed.gpus[3].utilization, 100);
|
||||
assert.equal(parsed.gpus[7].index, 7);
|
||||
assert.equal(parsed.gpus[7].memoryTotal, 81920);
|
||||
});
|
||||
|
||||
test("parseGpuStats skips a broken card and unknown memory without hiding the rest", () => {
|
||||
const parsed = parseGpuStats(
|
||||
"GPU:73 2048 24576 NVIDIA RTX 4090 ;; not-a-card ;; 0\tNVIDIA RTX 4090\t73\tN/A\t24576 ;; 2\tNVIDIA A100\tN/A\t1024\t8192",
|
||||
);
|
||||
assert.equal(parsed.gpu, 73);
|
||||
assert.deepEqual(parsed.gpus, [
|
||||
{ index: 0, name: "NVIDIA RTX 4090", utilization: 73, memoryUsed: null, memoryTotal: 24576 },
|
||||
{ index: 2, name: "NVIDIA A100", utilization: null, memoryUsed: 1024, memoryTotal: 8192 },
|
||||
]);
|
||||
});
|
||||
|
||||
test("parseGpuStats hides GPU data when nvidia-smi output cannot be parsed", () => {
|
||||
for (const output of ["", "GPU:", "GPU:[Not Supported]", "GPU:N/A N/A N/A", "CPURAW:1 1|MEMINFO:1"]) {
|
||||
assert.deepEqual(parseGpuStats(output), {
|
||||
gpu: null,
|
||||
gpuName: null,
|
||||
gpuMemUsed: null,
|
||||
gpuMemTotal: null,
|
||||
gpus: [],
|
||||
});
|
||||
}
|
||||
});
|
||||
test("GPU work is coalesced, cached independently, and never changes CPU readings", async (t) => {
|
||||
let now = 100000,
|
||||
calls = 0,
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
'use strict';
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const { EventEmitter } = require('node:events');
|
||||
const { createSessionOpsApi } = require('./sessionOps.cjs');
|
||||
|
||||
for (const method of ['getSessionDistroInfo', 'readRemoteHistory', 'getSessionPwd', 'listSessionDir']) {
|
||||
test(`${method} timeout cannot disconnect the terminal or accumulate pending opens`, async t => {
|
||||
t.mock.timers.enable({ apis: ['setTimeout'] });
|
||||
let callback;
|
||||
let opens = 0;
|
||||
let destroys = 0;
|
||||
const conn = {
|
||||
exec(_command, next) { opens++; callback = next; },
|
||||
end() { destroys++; },
|
||||
destroy() { destroys++; },
|
||||
};
|
||||
const api = createSessionOpsApi({
|
||||
sessions: new Map([['ssh', { type: 'ssh', conn, shellPid: '4242' }]]),
|
||||
log() {},
|
||||
quoteShellArg: value => "'" + String(value).replace(/'/g, "'\\''") + "'",
|
||||
setTimeout, clearTimeout, Buffer,
|
||||
});
|
||||
const request = api[method]({}, { sessionId: 'ssh', path: '/tmp', limit: 5 });
|
||||
assert.equal(opens, 1);
|
||||
t.mock.timers.tick(10_000);
|
||||
assert.equal((await request).success, false);
|
||||
assert.equal(destroys, 0);
|
||||
assert.equal((await api[method]({}, { sessionId: 'ssh', path: '/tmp', limit: 5 })).success, false);
|
||||
assert.equal(opens, 1);
|
||||
const late = new EventEmitter();
|
||||
let closed = 0;
|
||||
late.close = () => { closed++; };
|
||||
callback(null, late);
|
||||
assert.equal(closed, 1);
|
||||
assert.equal(destroys, 0);
|
||||
});
|
||||
}
|
||||
|
||||
for (const method of ['getSessionDistroInfo', 'readRemoteHistory', 'getServerStats']) {
|
||||
test(`${method} still tears down an unresponsive dedicated companion`, async t => {
|
||||
t.mock.timers.enable({ apis: ['setTimeout'] });
|
||||
let destroys = 0;
|
||||
const conn = { exec() {}, destroy() { destroys++; } };
|
||||
const api = createSessionOpsApi({
|
||||
sessions: new Map([['mosh', { type: 'mosh', moshStatsConn: conn }]]),
|
||||
quoteShellArg: value => "'" + String(value).replace(/'/g, "'\\''") + "'",
|
||||
setTimeout, clearTimeout, Buffer, log() {},
|
||||
});
|
||||
const request = api[method]({}, { sessionId: 'mosh', limit: 5 });
|
||||
t.mock.timers.tick(10_000);
|
||||
assert.equal((await request).success, false);
|
||||
assert.equal(destroys, 1);
|
||||
});
|
||||
}
|
||||
@@ -190,6 +190,7 @@ function createSessionOpsApi(ctx) {
|
||||
openingTimeoutMs: 5000,
|
||||
runTimeoutMs: 5000,
|
||||
maxOutputBytes: 256 * 1024,
|
||||
invalidateOnOpenTimeout: !session.conn,
|
||||
});
|
||||
return { success: true, stdout, stderr };
|
||||
} catch (err) {
|
||||
@@ -307,6 +308,7 @@ function createSessionOpsApi(ctx) {
|
||||
openingTimeoutMs: 8000,
|
||||
runTimeoutMs: 8000,
|
||||
maxOutputBytes: 10 * 1024 * 1024,
|
||||
invalidateOnOpenTimeout: !session.conn,
|
||||
});
|
||||
return { success: true, ...parse(stdout) };
|
||||
} catch (err) {
|
||||
@@ -760,14 +762,14 @@ function createSessionOpsApi(ctx) {
|
||||
)}`;
|
||||
|
||||
void executeBoundedSshCommand(session.conn, cmd, {
|
||||
// Do not shorten channel opening: a timeout there invalidates the
|
||||
// shared SSH transport. Only bound the best-effort command itself.
|
||||
// Directory tracking is auxiliary work on the terminal transport.
|
||||
// Preserve it on a slow open; the executor fences further probes.
|
||||
openingTimeoutMs: 5000,
|
||||
runTimeoutMs: timeoutMs,
|
||||
maxOutputBytes: 256 * 1024,
|
||||
setTimeoutFn: setTimeout,
|
||||
clearTimeoutFn: clearTimeout,
|
||||
invalidateOnOpenTimeout: !isTargetedRecovery,
|
||||
invalidateOnOpenTimeout: false,
|
||||
}).then(({ stdout, stderr, code }) => {
|
||||
if (!isCurrentPwdOwner()) {
|
||||
settle({ success: false, error: 'Session changed during cwd probe' });
|
||||
@@ -1018,6 +1020,7 @@ function createSessionOpsApi(ctx) {
|
||||
openingTimeoutMs: 3000,
|
||||
runTimeoutMs: 3000,
|
||||
maxOutputBytes: 1024 * 1024,
|
||||
invalidateOnOpenTimeout: false,
|
||||
}).then(({ stdout, stderr: errOut }) => {
|
||||
if (settled) return;
|
||||
try {
|
||||
@@ -1309,11 +1312,10 @@ function createSessionOpsApi(ctx) {
|
||||
`kernel=$(uname -r 2>/dev/null || echo "")`,
|
||||
`uptime=$(awk '{printf "%.0f",$1}' /proc/uptime 2>/dev/null || echo "")`,
|
||||
`loadavg=$(awk '{print $1" "$2" "$3}' /proc/loadavg 2>/dev/null || echo "")`,
|
||||
// GPU: NVIDIA GPUs expose utilization + VRAM through the driver-bundled
|
||||
// nvidia-smi. Best-effort — empty on hosts without the tool, so the UI
|
||||
// simply hides the GPU chip. Utilization is averaged across GPUs and
|
||||
// VRAM is summed independently; incomplete memory totals remain unknown.
|
||||
// The first GPU's name is kept for the tooltip.
|
||||
// GPU: NVIDIA GPUs expose utilization + VRAM through one bounded
|
||||
// nvidia-smi query. Best-effort — empty on hosts without the tool, so
|
||||
// the UI hides the GPU chip. Utilization is averaged across GPUs and
|
||||
// VRAM is summed independently; the same reply also lists each card.
|
||||
// Bound GPU queries independently so a stuck driver cannot stop CPU/memory
|
||||
// polling. Hosts without timeout also omit this optional metric.
|
||||
// macOS is skipped: powermetrics needs root, which stats must not.
|
||||
@@ -1352,6 +1354,9 @@ function createSessionOpsApi(ctx) {
|
||||
const statsExecOptions = {
|
||||
signal: session.statsSignal,
|
||||
invalidateTransportOnAbort: false,
|
||||
// Preserve shared terminal transports; dedicated Mosh/ET companions
|
||||
// still need timeout teardown so their existing recovery can reconnect.
|
||||
invalidateOnOpenTimeout: !session.conn,
|
||||
openingTimeoutMs: statsRunTimeoutMs,
|
||||
runTimeoutMs: statsRunTimeoutMs,
|
||||
maxOutputBytes: 1024 * 1024,
|
||||
@@ -1455,6 +1460,7 @@ function createSessionOpsApi(ctx) {
|
||||
let gpuName = null;
|
||||
let gpuMemUsed = null;
|
||||
let gpuMemTotal = null;
|
||||
let gpus = [];
|
||||
for (const part of parts) {
|
||||
if (part.startsWith('CPU:')) {
|
||||
// macOS: command reports normalized CPU% directly (no delta needed)
|
||||
@@ -1582,7 +1588,7 @@ function createSessionOpsApi(ctx) {
|
||||
}
|
||||
}
|
||||
} else if (part.startsWith('GPU:')) {
|
||||
({gpu,gpuName,gpuMemUsed,gpuMemTotal}=parseGpuStats(part));
|
||||
({gpu,gpuName,gpuMemUsed,gpuMemTotal,gpus}=parseGpuStats(part));
|
||||
} else if (part.startsWith('HOST:')) {
|
||||
hostname = part.substring(5).trim();
|
||||
} else if (part.startsWith('OS:')) {
|
||||
@@ -1735,6 +1741,7 @@ function createSessionOpsApi(ctx) {
|
||||
gpuName, // First GPU name (tooltip), null when unavailable
|
||||
gpuMemUsed, // Summed VRAM used in MB, null when unavailable
|
||||
gpuMemTotal, // Summed VRAM total in MB, null when unavailable
|
||||
gpus, // Per-GPU index, name, utilization, and VRAM in MB
|
||||
memTotal, // Total memory in MB
|
||||
memUsed, // Used memory in MB (excluding buffers/cache)
|
||||
memFree, // Free memory in MB
|
||||
|
||||
@@ -634,6 +634,7 @@ test("getServerStats reports null GPU fields when the host has no nvidia-smi dat
|
||||
assert.equal(result.stats.gpuName, null);
|
||||
assert.equal(result.stats.gpuMemUsed, null);
|
||||
assert.equal(result.stats.gpuMemTotal, null);
|
||||
assert.deepEqual(result.stats.gpus, []);
|
||||
});
|
||||
|
||||
test("getServerStats parses NVIDIA GPU utilization and VRAM from the stats line", async () => {
|
||||
@@ -653,6 +654,7 @@ test("getServerStats parses NVIDIA GPU utilization and VRAM from the stats line"
|
||||
assert.equal(result.stats.gpuName, "NVIDIA GeForce RTX 4090");
|
||||
assert.equal(result.stats.gpuMemUsed, 2048);
|
||||
assert.equal(result.stats.gpuMemTotal, 24576);
|
||||
assert.deepEqual(result.stats.gpus, []);
|
||||
});
|
||||
|
||||
test("getServerStats tolerates a malformed GPU section", async () => {
|
||||
@@ -668,16 +670,40 @@ test("getServerStats tolerates a malformed GPU section", async () => {
|
||||
assert.equal(result.success, true);
|
||||
assert.equal(result.stats.gpu, null);
|
||||
assert.equal(result.stats.gpuName, null);
|
||||
assert.deepEqual(result.stats.gpus, []);
|
||||
});
|
||||
|
||||
for (const scenario of [
|
||||
{ name: "multiple GPUs", script: "printf '%s\\n' '73, 2048, 24576, NVIDIA RTX 4090' '71, 1024, 24576, NVIDIA RTX 4090'", gpu: 72, used: 3072, total: 49152 },
|
||||
{ name: "idle GPU", script: "printf '%s\\n' '0, 0, 24576, NVIDIA RTX 4090'", gpu: 0, used: 0, total: 24576 },
|
||||
{ name: "mixed utilization availability", script: "printf '%s\\n' '73, 2048, 24576, NVIDIA RTX 4090' '[N/A], 1024, 8192, NVIDIA A100 MIG'", gpu: 73, used: 3072, total: 32768 },
|
||||
{ name: "unavailable memory usage", script: "printf '%s\\n' '73, [N/A], 24576, NVIDIA RTX 4090'", gpu: 73, total: 24576 },
|
||||
{ name: "partial memory totals", script: "printf '%s\\n' '73, 2048, 24576, NVIDIA RTX 4090' '71, 1024, [N/A], NVIDIA RTX 4090'", gpu: 72, used: 3072 },
|
||||
{ name: "unsupported utilization", script: "printf '%s\\n' '[N/A], 2048, 24576, NVIDIA RTX 4090'", gpu: null },
|
||||
{ name: "failed query with partial output", script: "printf '%s\\n' '73, 2048, 24576, NVIDIA RTX 4090'; exit 1", gpu: null },
|
||||
{ name: "multiple GPUs", script: "printf '%s\\n' '0, NVIDIA RTX 4090, 73, 2048, 24576' '1, NVIDIA RTX 4090, 71, 1024, 24576'", gpu: 72, used: 3072, total: 49152, gpus: [
|
||||
{ index: 0, name: "NVIDIA RTX 4090", utilization: 73, memoryUsed: 2048, memoryTotal: 24576 },
|
||||
{ index: 1, name: "NVIDIA RTX 4090", utilization: 71, memoryUsed: 1024, memoryTotal: 24576 },
|
||||
] },
|
||||
{ name: "idle GPU", script: "printf '%s\\n' '0, NVIDIA RTX 4090, 0, 0, 24576'", gpu: 0, used: 0, total: 24576, gpus: [
|
||||
{ index: 0, name: "NVIDIA RTX 4090", utilization: 0, memoryUsed: 0, memoryTotal: 24576 },
|
||||
] },
|
||||
{ name: "mixed utilization availability", script: "printf '%s\\n' '0, NVIDIA RTX 4090, 73, 2048, 24576' '1, NVIDIA A100 MIG, [N/A], 1024, 8192'", gpu: 73, used: 3072, total: 32768, gpus: [
|
||||
{ index: 0, name: "NVIDIA RTX 4090", utilization: 73, memoryUsed: 2048, memoryTotal: 24576 },
|
||||
{ index: 1, name: "NVIDIA A100 MIG", utilization: null, memoryUsed: 1024, memoryTotal: 8192 },
|
||||
] },
|
||||
{ name: "unavailable memory usage", script: "printf '%s\\n' '0, NVIDIA RTX 4090, 73, [N/A], 24576'", gpu: 73, total: 24576, gpus: [
|
||||
{ index: 0, name: "NVIDIA RTX 4090", utilization: 73, memoryUsed: null, memoryTotal: 24576 },
|
||||
] },
|
||||
{ name: "partial memory totals", script: "printf '%s\\n' '0, NVIDIA RTX 4090, 73, 2048, 24576' '1, NVIDIA RTX 4090, 71, 1024, [N/A]'", gpu: 72, used: 3072, gpus: [
|
||||
{ index: 0, name: "NVIDIA RTX 4090", utilization: 73, memoryUsed: 2048, memoryTotal: 24576 },
|
||||
{ index: 1, name: "NVIDIA RTX 4090", utilization: 71, memoryUsed: 1024, memoryTotal: null },
|
||||
] },
|
||||
{ name: "comma in GPU name", script: "printf '%s\\n' '0, NVIDIA Foo, 80GB, 41, 2048, 24576'", gpu: 41, used: 2048, total: 24576, gpus: [
|
||||
{ index: 0, name: "NVIDIA Foo, 80GB", utilization: 41, memoryUsed: 2048, memoryTotal: 24576 },
|
||||
] },
|
||||
{ name: "eight GPUs", script: `printf '%s\\n' ${Array.from({ length: 8 }, (_, index) => `'${index}, NVIDIA A100 ${index}, ${10 + index * 2}, ${1024 * (index + 1)}, 81920'`).join(" ")}`, gpu: 17, used: 36864, total: 655360, gpus: Array.from({ length: 8 }, (_, index) => ({
|
||||
index,
|
||||
name: `NVIDIA A100 ${index}`,
|
||||
utilization: 10 + index * 2,
|
||||
memoryUsed: 1024 * (index + 1),
|
||||
memoryTotal: 81920,
|
||||
})) },
|
||||
{ name: "unsupported utilization", script: "printf '%s\\n' '0, NVIDIA RTX 4090, [N/A], 2048, 24576'", gpu: null },
|
||||
{ name: "failed query with partial output", script: "printf '%s\\n' '0, NVIDIA RTX 4090, 73, 2048, 24576'; exit 1", gpu: null },
|
||||
{ name: "missing query tool", script: "exit 127", gpu: null },
|
||||
{ name: "missing timeout tool", script: "exec sleep 20", gpu: null, noTimeout: true },
|
||||
{ name: "blocked GPU query", script: "trap '' TERM; exec sleep 20", gpu: null },
|
||||
@@ -741,6 +767,7 @@ for (const scenario of [
|
||||
assert.equal(result.stats.gpu, scenario.gpu);
|
||||
assert.equal(result.stats.gpuMemUsed, scenario.used ?? null);
|
||||
assert.equal(result.stats.gpuMemTotal, scenario.total ?? null);
|
||||
assert.deepEqual(result.stats.gpus, scenario.gpus ?? []);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -919,7 +946,7 @@ test("getServerStats closes a stats stream delivered after timeout", async () =>
|
||||
assert.equal(closeCalls, 1);
|
||||
});
|
||||
|
||||
test("three stats retries on an unresponsive exec open leave no pooled transport or channel callbacks", async () => {
|
||||
test("stats open timeout preserves shared transport and fences repeated probes", async () => {
|
||||
resetSshTransportRegistryForTests({ defaultIdleTtlMs: 0 });
|
||||
const endpoint = { hostId: "stats-host", hostname: "wedged.example", username: "root" };
|
||||
const conn = new EventEmitter();
|
||||
@@ -962,9 +989,16 @@ test("three stats retries on an unresponsive exec open leave no pooled transport
|
||||
results.push(await api.getServerStats({ sender: {} }, { sessionId: "sid" }));
|
||||
|
||||
assert.ok(results.every((result) => result.success === false));
|
||||
assert.equal(conn.pendingChannelCallbacks.length, 0);
|
||||
assert.equal(getTransportStats().transports, 0);
|
||||
assert.equal(findTransportByEndpoint(endpoint), null);
|
||||
assert.equal(conn.pendingChannelCallbacks.length, 1, "retries must not accumulate opens");
|
||||
assert.equal(getTransportStats().transports, 1);
|
||||
assert.equal(findTransportByEndpoint(endpoint), transport);
|
||||
assert.equal(conn._sock.destroyed, false);
|
||||
const lateStream = fakeStream("");
|
||||
let lateClosed = 0;
|
||||
lateStream.close = () => { lateClosed++; };
|
||||
conn.pendingChannelCallbacks.shift()(null, lateStream);
|
||||
assert.equal(lateClosed, 1);
|
||||
conn.destroy();
|
||||
|
||||
const replacement = new EventEmitter();
|
||||
replacement._sock = { destroyed: false };
|
||||
|
||||
@@ -7,42 +7,48 @@ const identities = new Map();
|
||||
const MAX_SAMPLERS = 512;
|
||||
function prune() {
|
||||
const now = Date.now();
|
||||
for (const [key, state] of identities) {
|
||||
state.points = state.points.filter((p) => now - p.at <= 30 * 60000);
|
||||
if (!state.pending && now - state.at > 30 * 60000) identities.delete(key);
|
||||
for (const [key, history] of identities) {
|
||||
history.points = history.points.filter((p) => now - p.at <= 30 * 60000);
|
||||
if (now - history.at > 30 * 60000) identities.delete(key);
|
||||
}
|
||||
}
|
||||
const cleanupTimer = setInterval(prune, 60000);
|
||||
cleanupTimer.unref?.();
|
||||
function stateFor(conn, endpoint) {
|
||||
const key = endpoint ? buildEndpointKey(endpoint) : null;
|
||||
let state = (key ? identities.get(key) : null) || states.get(conn);
|
||||
let state = states.get(conn);
|
||||
const history = (key ? identities.get(key) : null) || state?.history || {
|
||||
latest: null,
|
||||
at: 0,
|
||||
points: [],
|
||||
latestFull: false,
|
||||
fullResult: null,
|
||||
fullAt: 0,
|
||||
sequence: 0,
|
||||
};
|
||||
if (!state) {
|
||||
state = {
|
||||
context: { conn },
|
||||
context: { conn: null },
|
||||
pending: null,
|
||||
latest: null,
|
||||
at: 0,
|
||||
points: [],
|
||||
controller: null,
|
||||
latestFull: false,
|
||||
pendingFull: false,
|
||||
fullResult: null,
|
||||
fullAt: 0,
|
||||
history,
|
||||
};
|
||||
states.set(conn, state);
|
||||
} else if (key && !state.pending && !state.history.latest) {
|
||||
// A panel may read a snapshot before its first sample supplies the endpoint.
|
||||
// Attach that empty runtime to the existing history without moving live work.
|
||||
state.history = history;
|
||||
}
|
||||
states.set(conn, state);
|
||||
// Share measurements/history by identity, but in-flight requests, abort
|
||||
// controllers and CPU/network baselines belong to the physical transport.
|
||||
// A reconnect must never join (or cancel) work on an older connection.
|
||||
if (key) {
|
||||
identities.delete(key);
|
||||
identities.set(key, state);
|
||||
identities.set(key, state.history);
|
||||
if (identities.size > MAX_SAMPLERS) {
|
||||
for (const [oldKey, oldState] of identities) {
|
||||
if (!oldState.pending && oldKey !== key) {
|
||||
oldState.points = [];
|
||||
identities.delete(oldKey);
|
||||
break;
|
||||
}
|
||||
}
|
||||
const oldKey = identities.keys().next().value;
|
||||
identities.delete(oldKey);
|
||||
}
|
||||
}
|
||||
return state;
|
||||
@@ -51,11 +57,11 @@ function appendPoint(state, result, now, freshAfter = 0) {
|
||||
if (
|
||||
!result.success ||
|
||||
!result.stats ||
|
||||
(now - (state.points.at(-1)?.at ?? 0) < 1000 && (state.points.at(-1)?.at ?? 0) > freshAfter)
|
||||
(now - (state.history.points.at(-1)?.at ?? 0) < 1000 && (state.history.points.at(-1)?.at ?? 0) > freshAfter)
|
||||
)
|
||||
return;
|
||||
const s = result.stats;
|
||||
state.points.push({
|
||||
state.history.points.push({
|
||||
at: now,
|
||||
cpu: s.cpu,
|
||||
memory:
|
||||
@@ -66,7 +72,7 @@ function appendPoint(state, result, now, freshAfter = 0) {
|
||||
rx: s.networkReady ? s.netRxSpeed : null,
|
||||
tx: s.networkReady ? s.netTxSpeed : null,
|
||||
});
|
||||
state.points = state.points
|
||||
state.history.points = state.history.points
|
||||
.filter((p) => now - p.at <= 30 * 60000)
|
||||
.slice(-1800);
|
||||
}
|
||||
@@ -79,26 +85,26 @@ async function sampleSharedStats(session, collect, now = Date.now()) {
|
||||
const state = stateFor(companion);
|
||||
state.context.prevNetStats = session.prevNetStats;
|
||||
state.context.prevCpuStats = session.prevCpuStats;
|
||||
state.latest = result;
|
||||
state.at = Date.now();
|
||||
state.latestFull = true;
|
||||
state.fullResult = result;
|
||||
state.fullAt = state.at;
|
||||
state.history.latest = result;
|
||||
state.history.at = Date.now();
|
||||
state.history.latestFull = true;
|
||||
state.history.fullResult = result;
|
||||
state.history.fullAt = state.history.at;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
const state = stateFor(conn, session._reuseEndpoint);
|
||||
const full = !session._monitorStats;
|
||||
const cacheMs = full ? 4000 : Math.min(4000, session._monitorIntervalMs ?? 5000);
|
||||
if (full && state.fullResult?.success && now - state.fullAt < 4000)
|
||||
return state.fullResult;
|
||||
if (full && state.history.fullResult?.success && now - state.history.fullAt < 4000)
|
||||
return state.history.fullResult;
|
||||
if (state.pending) {
|
||||
if (full) state.terminalJoined = true;
|
||||
if (!full || state.pendingFull) {
|
||||
const result = await state.pending;
|
||||
// A reset can join a terminal query that began before the new boundary.
|
||||
if (!full && state.at > session._monitorFreshAfter)
|
||||
appendPoint(state, result, state.at, session._monitorFreshAfter);
|
||||
if (!full && state.history.at > session._monitorFreshAfter)
|
||||
appendPoint(state, result, state.history.at, session._monitorFreshAfter);
|
||||
return result;
|
||||
}
|
||||
// A terminal needs process and GPU fields omitted from a monitor-only query.
|
||||
@@ -107,12 +113,12 @@ async function sampleSharedStats(session, collect, now = Date.now()) {
|
||||
return sampleSharedStats(session, collect);
|
||||
}
|
||||
if (
|
||||
state.latest?.success &&
|
||||
now - state.at < cacheMs &&
|
||||
(full || state.at > (session._monitorFreshAfter ?? 0)) &&
|
||||
(!full || state.latestFull)
|
||||
state.history.latest?.success &&
|
||||
now - state.history.at < cacheMs &&
|
||||
(full || state.history.at > (session._monitorFreshAfter ?? 0)) &&
|
||||
(!full || state.history.latestFull)
|
||||
)
|
||||
return state.latest;
|
||||
return state.history.latest;
|
||||
const collectFull = full;
|
||||
// Keep transport capabilities on the collection context; baseline is independent of a terminal.
|
||||
const companion = !session.conn;
|
||||
@@ -125,21 +131,23 @@ async function sampleSharedStats(session, collect, now = Date.now()) {
|
||||
hostname: session.hostname,
|
||||
_monitorStats: !collectFull,
|
||||
});
|
||||
// Starting a replacement retires older samples even if the new attempt fails.
|
||||
const sequence = ++state.history.sequence;
|
||||
state.controller = new AbortController();
|
||||
state.terminalJoined = full;
|
||||
state.pendingFull = collectFull;
|
||||
state.context.statsSignal = state.controller.signal;
|
||||
state.pending = Promise.resolve(collect(state.context))
|
||||
.then((result) => {
|
||||
if (!state.controller.signal.aborted && result.success) {
|
||||
state.latest = result;
|
||||
state.latestFull = collectFull;
|
||||
state.at = Date.now();
|
||||
if (!state.controller.signal.aborted && result.success && sequence === state.history.sequence) {
|
||||
state.history.latest = result;
|
||||
state.history.latestFull = collectFull;
|
||||
state.history.at = Date.now();
|
||||
if (collectFull) {
|
||||
state.fullResult = result;
|
||||
state.fullAt = state.at;
|
||||
state.history.fullResult = result;
|
||||
state.history.fullAt = state.history.at;
|
||||
}
|
||||
appendPoint(state, result, state.at, session._monitorFreshAfter);
|
||||
appendPoint(state, result, state.history.at, session._monitorFreshAfter);
|
||||
}
|
||||
return result;
|
||||
})
|
||||
@@ -155,9 +163,9 @@ async function sampleSharedStats(session, collect, now = Date.now()) {
|
||||
}
|
||||
function readSharedStats(conn) {
|
||||
const state = stateFor(conn);
|
||||
if (state.points[0] && Date.now() - state.points[0].at > 30 * 60000)
|
||||
state.points = state.points.filter((p) => Date.now() - p.at <= 30 * 60000);
|
||||
return { result: state.latestFull ? state.latest : withCachedMonitorGpu(conn, state.latest), at: state.at, statsAt: Math.max(state.at, monitorGpuUpdatedAt(conn)), points: state.points };
|
||||
if (state.history.points[0] && Date.now() - state.history.points[0].at > 30 * 60000)
|
||||
state.history.points = state.history.points.filter((p) => Date.now() - p.at <= 30 * 60000);
|
||||
return { result: state.history.latestFull ? state.history.latest : withCachedMonitorGpu(conn, state.history.latest), at: state.history.at, statsAt: Math.max(state.history.at, monitorGpuUpdatedAt(conn)), points: state.history.points };
|
||||
}
|
||||
function cancelSharedStats(conn) {
|
||||
cancelMonitorGpuProbe(conn);
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
'use strict';
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const { sampleSharedStats, readSharedStats, cancelSharedStats, resetSharedStatsForTests } = require('./sharedStats.cjs');
|
||||
|
||||
const endpoint = { hostname: 'stats.example', username: 'test', port: 22 };
|
||||
const result = cpu => ({ success: true, stats: { cpu, memTotal: 100, memUsed: 50 } });
|
||||
|
||||
test('new transport samples without waiting for an old same-endpoint request', async () => {
|
||||
resetSharedStatsForTests();
|
||||
const oldConn = {}, newConn = {};
|
||||
let finishOld;
|
||||
let oldContext;
|
||||
const old = sampleSharedStats({ conn: oldConn, _reuseEndpoint: endpoint }, context => {
|
||||
oldContext = context;
|
||||
return new Promise(resolve => { finishOld = resolve; });
|
||||
});
|
||||
let newCalls = 0;
|
||||
const fresh = await sampleSharedStats({ conn: newConn, _reuseEndpoint: endpoint }, async context => {
|
||||
newCalls++;
|
||||
assert.equal(context.conn, newConn);
|
||||
return result(80);
|
||||
});
|
||||
assert.equal(newCalls, 1);
|
||||
assert.equal(fresh.stats.cpu, 80);
|
||||
assert.equal(oldContext.statsSignal.aborted, false);
|
||||
finishOld(result(10));
|
||||
await old;
|
||||
assert.equal(readSharedStats(newConn).result.stats.cpu, 80, 'late old reply must not replace newer measurements');
|
||||
assert.equal(readSharedStats(oldConn).points, readSharedStats(newConn).points);
|
||||
});
|
||||
|
||||
test('a failed replacement sample still retires pending results from the old transport', async () => {
|
||||
resetSharedStatsForTests();
|
||||
const oldConn = {}, newConn = {};
|
||||
await sampleSharedStats({ conn: oldConn, _reuseEndpoint: endpoint }, async () => result(42));
|
||||
const before = readSharedStats(oldConn);
|
||||
const now = Date.now() + 5000;
|
||||
let finishOld;
|
||||
const old = sampleSharedStats({ conn: oldConn, _reuseEndpoint: endpoint }, () =>
|
||||
new Promise(resolve => { finishOld = resolve; }), now);
|
||||
const failed = await sampleSharedStats({ conn: newConn, _reuseEndpoint: endpoint }, async () =>
|
||||
({ success: false, error: 'Connection closed' }), now);
|
||||
assert.equal(failed.success, false);
|
||||
finishOld(result(10));
|
||||
await old;
|
||||
const after = readSharedStats(newConn);
|
||||
assert.equal(after.result.stats.cpu, 42, 'keep the last published measurement');
|
||||
assert.equal(after.at, before.at);
|
||||
assert.deepEqual(after.points.map(point => point.cpu), [42], 'do not publish a late old sample');
|
||||
});
|
||||
|
||||
test('cancelling a monitor on one transport cannot abort another transport', async () => {
|
||||
resetSharedStatsForTests();
|
||||
const a = {}, b = {};
|
||||
const contexts = [];
|
||||
const finish = [];
|
||||
const collect = context => {
|
||||
contexts.push(context);
|
||||
return new Promise(resolve => finish.push(resolve));
|
||||
};
|
||||
const first = sampleSharedStats({ conn: a, _reuseEndpoint: endpoint, _monitorStats: true }, collect);
|
||||
const second = sampleSharedStats({ conn: b, _reuseEndpoint: endpoint, _monitorStats: true }, collect);
|
||||
assert.equal(contexts.length, 2);
|
||||
cancelSharedStats(a);
|
||||
assert.equal(contexts[0].statsSignal.aborted, true);
|
||||
assert.equal(contexts[1].statsSignal.aborted, false);
|
||||
finish[0](result(10));
|
||||
finish[1](result(80));
|
||||
await Promise.all([first, second]);
|
||||
assert.equal(readSharedStats(b).result.stats.cpu, 80);
|
||||
});
|
||||
|
||||
test('readers sharing one physical transport still coalesce an active sample', async () => {
|
||||
resetSharedStatsForTests();
|
||||
const conn = {};
|
||||
let resolve;
|
||||
let calls = 0;
|
||||
const collect = () => { calls++; return new Promise(done => { resolve = done; }); };
|
||||
const a = sampleSharedStats({ conn, _reuseEndpoint: endpoint }, collect);
|
||||
const b = sampleSharedStats({ conn, _reuseEndpoint: endpoint }, collect);
|
||||
assert.equal(calls, 1);
|
||||
resolve(result(42));
|
||||
assert.deepEqual(await a, await b);
|
||||
});
|
||||
|
||||
test('a snapshot read before sampling still joins the existing endpoint history', async () => {
|
||||
resetSharedStatsForTests();
|
||||
const terminal = {}, monitor = {};
|
||||
await sampleSharedStats({ conn: terminal, _reuseEndpoint: endpoint }, async () => result(42));
|
||||
assert.equal(readSharedStats(monitor).result, null);
|
||||
await sampleSharedStats({ conn: monitor, _reuseEndpoint: endpoint }, async () => result(80));
|
||||
assert.equal(readSharedStats(monitor).points, readSharedStats(terminal).points);
|
||||
assert.equal(readSharedStats(monitor).result.stats.cpu, 42);
|
||||
});
|
||||
@@ -92,12 +92,15 @@ function createExecOnSessionApi(ctx) {
|
||||
return conn;
|
||||
}
|
||||
|
||||
function execOnConnection(conn, command, timeoutMs, execOptions = {}) {
|
||||
function execOnConnection(conn, command, timeoutMs, execOptions = {}, sharedTransport = true) {
|
||||
const maxBuffer = normalizeExecMaxBuffer(execOptions.maxBuffer);
|
||||
return executeBoundedSshCommand(conn, command, {
|
||||
openingTimeoutMs: timeoutMs,
|
||||
runTimeoutMs: timeoutMs,
|
||||
maxOutputBytes: maxBuffer,
|
||||
// A slow auxiliary channel must not tear down the interactive shell
|
||||
// or SFTP transfers on the same transport. The executor fences retries.
|
||||
invalidateOnOpenTimeout: !sharedTransport,
|
||||
onStream(stream) {
|
||||
if (typeof execOptions.stdin === "string") {
|
||||
stream.write(execOptions.stdin);
|
||||
@@ -144,7 +147,7 @@ function createExecOnSessionApi(ctx) {
|
||||
return { success: false, error: "Session not found or not connected" };
|
||||
}
|
||||
|
||||
const result = await execOnConnection(conn, command, timeoutMs, execOptions);
|
||||
const result = await execOnConnection(conn, command, timeoutMs, execOptions, !!session.conn);
|
||||
if (
|
||||
allowCompanionRetry
|
||||
&& !result.success
|
||||
|
||||
@@ -142,3 +142,53 @@ test("execOnSession refuses extra channels when singleChannelSsh is set", async
|
||||
assert.equal(result.code, "ERR_SINGLE_CHANNEL_BASTION");
|
||||
assert.equal(execCalls, 0);
|
||||
});
|
||||
|
||||
test("auxiliary exec timeout preserves the terminal and manual retry recovers after late open", async () => {
|
||||
let opens = 0;
|
||||
let destroyed = 0;
|
||||
let callback;
|
||||
const conn = {
|
||||
_sock: { destroyed: false },
|
||||
exec(_command, next) { opens++; callback = next; },
|
||||
end() { destroyed++; },
|
||||
destroy() { destroyed++; },
|
||||
};
|
||||
const api = createExecOnSessionApi({ sessions: new Map([['ssh', { conn, type: 'ssh' }]]) });
|
||||
assert.equal((await api.execOnSession(null, 'ssh', 'docker ps', 5)).success, false);
|
||||
assert.equal(destroyed, 0);
|
||||
const fenced = await api.execOnSession(null, 'ssh', 'docker ps', 5);
|
||||
assert.equal(fenced.success, false);
|
||||
assert.match(fenced.error, /still pending/);
|
||||
assert.equal(opens, 1);
|
||||
const late = new EventEmitter();
|
||||
let closed = 0;
|
||||
late.close = () => { closed++; };
|
||||
callback(null, late);
|
||||
assert.equal(closed, 1);
|
||||
conn.exec = (_command, next) => {
|
||||
opens++;
|
||||
const stream = new EventEmitter();
|
||||
stream.stderr = new EventEmitter();
|
||||
next(null, stream);
|
||||
queueMicrotask(() => {
|
||||
stream.emit('data', Buffer.from('container recovered'));
|
||||
stream.emit('close', 0);
|
||||
});
|
||||
};
|
||||
const recovered = await api.execOnSession(null, 'ssh', 'docker ps', 1000);
|
||||
assert.equal(recovered.success, true);
|
||||
assert.equal(recovered.stdout, 'container recovered');
|
||||
assert.equal(opens, 2);
|
||||
assert.equal(destroyed, 0);
|
||||
});
|
||||
|
||||
test("auxiliary exec still reclaims an unresponsive dedicated Mosh companion", async () => {
|
||||
let destroys = 0;
|
||||
const conn = { exec() {}, destroy() { destroys++; } };
|
||||
const api = createExecOnSessionApi({
|
||||
sessions: new Map([['mosh', { type: 'mosh', moshStatsConn: conn }]]),
|
||||
});
|
||||
const result = await api.execOnSession(null, 'mosh', 'docker ps', 5);
|
||||
assert.equal(result.success, false);
|
||||
assert.equal(destroys, 1);
|
||||
});
|
||||
@@ -5107,6 +5107,7 @@ async function downloadFile(
|
||||
await new Promise((resolve, reject) => {
|
||||
let settled = false;
|
||||
let onFastSftpError = null;
|
||||
const onFastSftpClose = () => finish(new Error("SFTP channel closed during download"));
|
||||
const finish = (err) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
@@ -5117,6 +5118,8 @@ async function downloadFile(
|
||||
try { fastSftp.removeListener("error", onFastSftpError); } catch { }
|
||||
onFastSftpError = null;
|
||||
}
|
||||
fastSftp.removeListener("end", onFastSftpClose);
|
||||
fastSftp.removeListener("close", onFastSftpClose);
|
||||
releaseIsolatedDownloadChannel(client, fastSftp, {
|
||||
dispose: !!err || transfer.cancelled,
|
||||
});
|
||||
@@ -5133,6 +5136,8 @@ async function downloadFile(
|
||||
transfer.abort = abortFastTransfer;
|
||||
onFastSftpError = (err) => finish(err);
|
||||
fastSftp.once("error", onFastSftpError);
|
||||
fastSftp.once("end", onFastSftpClose);
|
||||
fastSftp.once("close", onFastSftpClose);
|
||||
|
||||
if (transfer.cancelled) {
|
||||
finish(new Error("Transfer cancelled"));
|
||||
@@ -6173,13 +6178,15 @@ async function startTransferNow(event, payload, onProgress) {
|
||||
lastObservedTotal = fileSize;
|
||||
lastObservedTransferred = Math.min(lastObservedTransferred, fileSize);
|
||||
}
|
||||
// fastGet's success callback alone does not establish a complete file.
|
||||
// Enforce the planned size for direct/non-resumable downloads as well.
|
||||
if (transfer.cancelled) throw new Error("Transfer cancelled");
|
||||
const downloadedStat = await fs.promises.stat(downloadTargetPath);
|
||||
if (transfer.cancelled) throw new Error("Transfer cancelled");
|
||||
if (downloadedStat.size !== fileSize) {
|
||||
throw new Error(`Downloaded file size mismatch: expected ${fileSize}, got ${downloadedStat.size}`);
|
||||
}
|
||||
if (stageLocalDownload) {
|
||||
if (transfer.cancelled) throw new Error("Transfer cancelled");
|
||||
const stagedStat = await fs.promises.stat(downloadTargetPath);
|
||||
if (transfer.cancelled) throw new Error("Transfer cancelled");
|
||||
if (stagedStat.size !== fileSize) {
|
||||
throw new Error(`Downloaded file size mismatch: expected ${fileSize}, got ${stagedStat.size}`);
|
||||
}
|
||||
if (transfer.cancelled) throw new Error("Transfer cancelled");
|
||||
const {
|
||||
promotionTargetPath,
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
'use strict';
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs/promises');
|
||||
const { randomUUID } = require('node:crypto');
|
||||
const { EventEmitter } = require('node:events');
|
||||
const bridge = require('./transferBridge.cjs');
|
||||
const tempDirBridge = require('./tempDirBridge.cjs');
|
||||
|
||||
for (const scenario of ['truncated', 'oversized', 'end', 'close', 'complete']) {
|
||||
test(`non-resumable fastGet handles ${scenario} without a false completion or hang`, async t => {
|
||||
const id = `integrity-${randomUUID()}`;
|
||||
const targetPath = tempDirBridge.getTempFilePath(`${id}.bin`);
|
||||
t.after(() => fs.rm(targetPath, { force: true }));
|
||||
const channel = new EventEmitter();
|
||||
channel.end = () => {};
|
||||
channel.fastGet = (_remote, local, _options, done) => {
|
||||
if (scenario === 'end' || scenario === 'close') {
|
||||
queueMicrotask(() => channel.emit(scenario));
|
||||
} else {
|
||||
const bytes = scenario === 'truncated' ? 'ab' : scenario === 'oversized' ? 'abcdef' : 'abcd';
|
||||
fs.writeFile(local, bytes).then(() => done(), done);
|
||||
}
|
||||
};
|
||||
const sftp = new EventEmitter();
|
||||
sftp.readdir = (_path, callback) => callback(null, []);
|
||||
sftp.stat = (_path, callback) => callback(null, { size: 4 });
|
||||
sftp.mkdir = (_path, callback) => callback(null);
|
||||
sftp.unlink = (_path, callback) => callback(null);
|
||||
for (const method of ['readdir', 'stat', 'mkdir', 'unlink']) channel[method] = sftp[method];
|
||||
const client = { sftp, stat: async () => ({ size: 4 }), client: {
|
||||
sftp(callback) { callback(null, channel); },
|
||||
} };
|
||||
bridge.init({ sftpClients: new Map([['source', client]]) });
|
||||
const events = [];
|
||||
let timer;
|
||||
const running = bridge.startTransfer({ sender: { send: (event, data) => events.push({ event, data }) } }, {
|
||||
transferId: id, sourceType: 'sftp', targetType: 'local', sourceSftpId: 'source',
|
||||
sourcePath: '/file.bin', targetPath, totalBytes: 4, resumable: false,
|
||||
});
|
||||
t.after(async () => { clearTimeout(timer); await bridge.cancelTransfer({}, { transferId: id }); await running; });
|
||||
const response = await Promise.race([running, new Promise((_, reject) => {
|
||||
timer = setTimeout(() => reject(new Error('download did not settle')), 1000);
|
||||
})]);
|
||||
if (scenario === 'complete') {
|
||||
assert.equal(response.error, undefined);
|
||||
assert.equal(await fs.readFile(targetPath, 'utf8'), 'abcd');
|
||||
assert.ok(events.some(({ event }) => event === 'netcatty:transfer:complete'));
|
||||
} else {
|
||||
assert.match(response.error, scenario === 'truncated' || scenario === 'oversized'
|
||||
? /size mismatch/ : /channel closed/);
|
||||
assert.ok(!events.some(({ event }) => event === 'netcatty:transfer:complete'));
|
||||
}
|
||||
assert.equal(channel.listenerCount('end'), 0);
|
||||
assert.equal(channel.listenerCount('close'), 0);
|
||||
});
|
||||
}
|
||||
@@ -819,6 +819,15 @@ html[data-theme-transition] *::after {
|
||||
-webkit-user-select: auto;
|
||||
}
|
||||
|
||||
/* Selected top-tab hooks. Fill and text stay on the tab's inline style so the
|
||||
unset custom properties fall through to the existing chrome colors. The
|
||||
border is a 1px inset outline and stays invisible until
|
||||
--top-tab-active-border is set (Settings → Appearance → Custom CSS). */
|
||||
[data-section="top-tabs"] .top-tab[data-active="true"] {
|
||||
outline: 1px solid var(--top-tab-active-border, transparent);
|
||||
outline-offset: -1px;
|
||||
}
|
||||
|
||||
/* Window controls: icons sit in the h-7 tab row; hover fills the full h-9 title bar */
|
||||
[data-section="top-tabs"] .window-control-btn {
|
||||
position: relative;
|
||||
|
||||
@@ -363,3 +363,56 @@ test('prepareTurnContext calls summarize when over dynamic threshold', async ()
|
||||
assert.equal(prepared.trace?.didLlmSummarize, true);
|
||||
assert.match(JSON.stringify(prepared.messages), /summary of earlier work/);
|
||||
});
|
||||
|
||||
test('normal turn and step preparation keep user attachment parts', async () => {
|
||||
const messages: ModelMessage[] = [
|
||||
{
|
||||
role: 'user',
|
||||
content: [
|
||||
{ type: 'text', text: 'what does this image say?' },
|
||||
{ type: 'file', data: 'QUJD'.repeat(750), mediaType: 'image/png' },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const turn = await prepareTurnContext({
|
||||
messages,
|
||||
backend: 'catty',
|
||||
contextWindow: 1_300_000,
|
||||
trigger: 'pre-turn',
|
||||
});
|
||||
const step = await prepareStepContext({
|
||||
messages: turn.messages,
|
||||
contextWindow: 1_300_000,
|
||||
stepNumber: 0,
|
||||
});
|
||||
|
||||
for (const prepared of [turn, step]) {
|
||||
const content = prepared.messages.find(message => message.role === 'user')?.content;
|
||||
assert.ok(Array.isArray(content));
|
||||
assert.equal(content.some(part => (part as { type?: string }).type === 'file'), true);
|
||||
assert.doesNotMatch(JSON.stringify(prepared.messages), /attachment omitted to keep the AI request small/);
|
||||
}
|
||||
});
|
||||
|
||||
test('413 retry still trades attachments for a text placeholder', async () => {
|
||||
const messages: ModelMessage[] = [
|
||||
{
|
||||
role: 'user',
|
||||
content: [
|
||||
{ type: 'text', text: 'what does this image say?' },
|
||||
{ type: 'file', data: 'QUJD'.repeat(750), mediaType: 'image/png' },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const prepared = await prepareTurnContext({
|
||||
messages,
|
||||
backend: 'catty',
|
||||
contextWindow: 1_300_000,
|
||||
trigger: '413-retry',
|
||||
compressForRequestTooLargeRetry: true,
|
||||
});
|
||||
|
||||
assert.match(JSON.stringify(prepared.messages), /attachment omitted to keep the AI request small/);
|
||||
});
|
||||
@@ -7,7 +7,10 @@ import {
|
||||
keepRecentContextMessages,
|
||||
prepareContextCompaction,
|
||||
} from '../contextCompaction';
|
||||
import { compressMessagesForRequestTooLargeRetry } from '../requestPayloadCompression';
|
||||
import {
|
||||
compressMessagesForRequestTooLargeRetry,
|
||||
type CompressMessagesForRequestTooLargeRetryOptions,
|
||||
} from '../requestPayloadCompression';
|
||||
import {
|
||||
computeCompactionThreshold,
|
||||
computeTotalInputTokens,
|
||||
@@ -96,11 +99,14 @@ function emitCompactionStart(
|
||||
});
|
||||
}
|
||||
|
||||
function applyTypedMessageCompression(messages: ModelMessage[]): {
|
||||
function applyTypedMessageCompression(
|
||||
messages: ModelMessage[],
|
||||
options?: CompressMessagesForRequestTooLargeRetryOptions,
|
||||
): {
|
||||
messages: ModelMessage[];
|
||||
didAdjust: boolean;
|
||||
} {
|
||||
const compressed = compressMessagesForRequestTooLargeRetry(messages);
|
||||
const compressed = compressMessagesForRequestTooLargeRetry(messages, options);
|
||||
return { messages: compressed.messages, didAdjust: compressed.didAdjust };
|
||||
}
|
||||
|
||||
@@ -225,7 +231,9 @@ export async function prepareTurnContext(
|
||||
did413Fallback = typed.didAdjust;
|
||||
didAdjust = didAdjust || typed.didAdjust;
|
||||
} else {
|
||||
const typed = applyTypedMessageCompression(working);
|
||||
// Routine pre-turn pass: keep the user's attachment parts. Only the explicit
|
||||
// 413 retry above may trade them for text placeholders.
|
||||
const typed = applyTypedMessageCompression(working, { preserveAttachments: true });
|
||||
if (typed.didAdjust) {
|
||||
working = typed.messages;
|
||||
didTypedCompression = true;
|
||||
@@ -412,7 +420,9 @@ export async function prepareStepContext(
|
||||
});
|
||||
let working = stale.messages;
|
||||
|
||||
const typed = compressMessagesForRequestTooLargeRetry(working);
|
||||
// Step-level typed compression runs before every model call, so it must keep
|
||||
// multimodal parts: the user's image would otherwise never reach the model.
|
||||
const typed = compressMessagesForRequestTooLargeRetry(working, { preserveAttachments: true });
|
||||
working = typed.messages;
|
||||
|
||||
const budgetGuard = applyStepBudgetGuard(working, {
|
||||
|
||||
@@ -90,3 +90,77 @@ test("compressMessagesForRequestTooLargeRetry reports no adjustment for compact
|
||||
assert.equal(result.didAdjust, false);
|
||||
assert.deepEqual(result.messages, messages);
|
||||
});
|
||||
|
||||
test("compressMessagesForRequestTooLargeRetry keeps attachments when preserveAttachments is set", () => {
|
||||
const imageData = "A".repeat(300_000);
|
||||
const fileData = Buffer.from("label,hostname\nprod,prod.example.com\n").toString("base64");
|
||||
const messages: ModelMessage[] = [
|
||||
{
|
||||
role: "user",
|
||||
content: [
|
||||
{ type: "text", text: "what does this screenshot say?" },
|
||||
{ type: "file", data: imageData, mediaType: "image/png" },
|
||||
],
|
||||
},
|
||||
{
|
||||
role: "user",
|
||||
content: [
|
||||
{ type: "text", text: "import these hosts" },
|
||||
{ type: "file", data: fileData, mediaType: "text/csv", filename: "hosts.csv" },
|
||||
],
|
||||
},
|
||||
{
|
||||
role: "tool",
|
||||
content: [{
|
||||
type: "tool-result",
|
||||
toolCallId: "call-1",
|
||||
toolName: "terminal_execute",
|
||||
output: { type: "text", value: "X".repeat(200_000) },
|
||||
}],
|
||||
},
|
||||
];
|
||||
|
||||
const result = compressMessagesForRequestTooLargeRetry(messages, { preserveAttachments: true });
|
||||
|
||||
// Text and tool output are still reduced. Images stay; other files become placeholders.
|
||||
assert.equal(result.didAdjust, true);
|
||||
|
||||
const imageContent = result.messages[0].content;
|
||||
assert.ok(Array.isArray(imageContent));
|
||||
assert.deepEqual(imageContent[1], messages[0].content[1]);
|
||||
assert.doesNotMatch(JSON.stringify(imageContent), /attachment omitted to keep the AI request small/);
|
||||
|
||||
const fileContent = result.messages[1].content;
|
||||
assert.ok(Array.isArray(fileContent));
|
||||
assert.deepEqual(fileContent[1], {
|
||||
type: "text",
|
||||
text: `[file attachment omitted to keep the AI request small: filename=hosts.csv, mediaType=text/csv, ${fileData.length} chars]`,
|
||||
});
|
||||
|
||||
const toolContent = result.messages[2].content;
|
||||
assert.ok(Array.isArray(toolContent));
|
||||
const toolPart = toolContent[0] as { output?: { value?: string } };
|
||||
assert.ok((toolPart.output?.value?.length ?? 0) < 5_000);
|
||||
});
|
||||
|
||||
test("compressMessagesForRequestTooLargeRetry still placeholders text/plain files when preserveAttachments is set", () => {
|
||||
const text = "hello from a text file";
|
||||
const messages: ModelMessage[] = [
|
||||
{
|
||||
role: "user",
|
||||
content: [
|
||||
{ type: "text", text: "read this note" },
|
||||
{ type: "file", data: text, mediaType: "text/plain", filename: "note.txt" },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const result = compressMessagesForRequestTooLargeRetry(messages, { preserveAttachments: true });
|
||||
const content = result.messages[0].content;
|
||||
assert.ok(Array.isArray(content));
|
||||
assert.deepEqual(content[0], { type: "text", text: "read this note" });
|
||||
assert.deepEqual(content[1], {
|
||||
type: "text",
|
||||
text: `[file attachment omitted to keep the AI request small: filename=note.txt, mediaType=text/plain, ${text.length} chars]`,
|
||||
});
|
||||
});
|
||||
@@ -16,6 +16,18 @@ export interface CompressMessagesForRequestTooLargeRetryResult {
|
||||
didAdjust: boolean;
|
||||
}
|
||||
|
||||
export interface CompressMessagesForRequestTooLargeRetryOptions {
|
||||
/**
|
||||
* Keep image attachments (`image` parts and `file` parts whose media type
|
||||
* starts with `image/`) instead of replacing them with a text placeholder.
|
||||
*
|
||||
* Other file parts stay placeholders. Chat providers reject types such as
|
||||
* `text/plain`, and the model reads those through attachment tools.
|
||||
* Only the 413 retry drops images as well.
|
||||
*/
|
||||
preserveAttachments?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Collapse noisy terminal/build output.
|
||||
* Keeps semantics while removing repeated blank lines and long duplicate runs.
|
||||
@@ -108,10 +120,12 @@ export function truncateTextWithHeadAndTail(
|
||||
|
||||
export function compressMessagesForRequestTooLargeRetry(
|
||||
messages: ModelMessage[],
|
||||
options: CompressMessagesForRequestTooLargeRetryOptions = {},
|
||||
): CompressMessagesForRequestTooLargeRetryResult {
|
||||
const preserveAttachments = options.preserveAttachments === true;
|
||||
let didAdjust = false;
|
||||
const compressedMessages = messages.map((message) => {
|
||||
const compressed = compressModelMessageForRequestRetry(message);
|
||||
const compressed = compressModelMessageForRequestRetry(message, preserveAttachments);
|
||||
if (compressed !== message) didAdjust = true;
|
||||
return compressed;
|
||||
});
|
||||
@@ -122,7 +136,10 @@ export function compressMessagesForRequestTooLargeRetry(
|
||||
};
|
||||
}
|
||||
|
||||
function compressModelMessageForRequestRetry(message: ModelMessage): ModelMessage {
|
||||
function compressModelMessageForRequestRetry(
|
||||
message: ModelMessage,
|
||||
preserveAttachments: boolean,
|
||||
): ModelMessage {
|
||||
if (typeof message.content === "string") {
|
||||
// User text is an instruction or exact input, not terminal output. Lossy
|
||||
// cleanup can alter commands even when the request is far below its limit.
|
||||
@@ -136,7 +153,7 @@ function compressModelMessageForRequestRetry(message: ModelMessage): ModelMessag
|
||||
let didAdjust = false;
|
||||
const content = message.content.map((part) => {
|
||||
if (message.role === "user" && part.type === "text") return part;
|
||||
const compressed = compressContentPartForRequestRetry(part);
|
||||
const compressed = compressContentPartForRequestRetry(part, preserveAttachments);
|
||||
if (compressed !== part) didAdjust = true;
|
||||
return compressed;
|
||||
});
|
||||
@@ -144,7 +161,7 @@ function compressModelMessageForRequestRetry(message: ModelMessage): ModelMessag
|
||||
return didAdjust ? ({ ...message, content } as ModelMessage) : message;
|
||||
}
|
||||
|
||||
function compressContentPartForRequestRetry(part: unknown): unknown {
|
||||
function compressContentPartForRequestRetry(part: unknown, preserveAttachments: boolean): unknown {
|
||||
if (!part || typeof part !== "object") return part;
|
||||
const record = part as Record<string, unknown>;
|
||||
const type = record.type;
|
||||
@@ -178,12 +195,18 @@ function compressContentPartForRequestRetry(part: unknown): unknown {
|
||||
}
|
||||
}
|
||||
|
||||
if (type === "image" && typeof record.image === "string") {
|
||||
return omittedAttachmentTextPart("image", record.image, record);
|
||||
}
|
||||
const mediaType = typeof record.mediaType === "string" ? record.mediaType : "";
|
||||
const preserveImage = preserveAttachments && (
|
||||
type === "image" || (type === "file" && mediaType.startsWith("image/"))
|
||||
);
|
||||
if (!preserveImage) {
|
||||
if (type === "image" && typeof record.image === "string") {
|
||||
return omittedAttachmentTextPart("image", record.image, record);
|
||||
}
|
||||
|
||||
if (type === "file" && typeof record.data === "string") {
|
||||
return omittedAttachmentTextPart("file", record.data, record);
|
||||
if (type === "file" && typeof record.data === "string") {
|
||||
return omittedAttachmentTextPart("file", record.data, record);
|
||||
}
|
||||
}
|
||||
|
||||
return part;
|
||||
|
||||
+2
-1
@@ -42,7 +42,7 @@
|
||||
"pack:linux": "npm run build && cross-env NODE_OPTIONS=--disable-warning=DEP0190 electron-builder --config electron-builder.config.cjs --linux --publish=never",
|
||||
"pack:linux-x64": "npm run build && cross-env npm_config_arch=x64 NODE_OPTIONS=--disable-warning=DEP0190 electron-builder --config electron-builder.config.cjs --linux --x64 --publish=never",
|
||||
"pack:linux-arm64": "npm run build && cross-env npm_config_arch=arm64 NODE_OPTIONS=--disable-warning=DEP0190 electron-builder --config electron-builder.config.cjs --linux --arm64 --publish=never",
|
||||
"postinstall": "patch-package && node scripts/patch-xterm-macos-column-selection.cjs && node scripts/patch-xterm-foreground-intense.cjs && electron-builder install-app-deps && node scripts/rebuildPatchedNodePty.cjs && node scripts/patch-xterm-webgl-atlas.cjs",
|
||||
"postinstall": "patch-package && node scripts/patch-xterm-macos-column-selection.cjs && node scripts/patch-xterm-foreground-intense.cjs && node scripts/patch-xterm-image-tile-stride.cjs && node scripts/patch-xterm-image-iip-type.cjs && electron-builder install-app-deps && node scripts/rebuildPatchedNodePty.cjs && node scripts/patch-xterm-webgl-atlas.cjs",
|
||||
"rebuild": "electron-builder install-app-deps",
|
||||
"tool:cli": "node electron/cli/netcatty-tool-cli.cjs",
|
||||
"generate:capability-tools": "node scripts/generate-capability-tools.cjs",
|
||||
@@ -68,6 +68,7 @@
|
||||
"test:ssh-mfa-models:live": "SSH_MFA_LIVE=1 node --test electron/bridges/sshMfaModels.live.test.cjs",
|
||||
"test:local-file-drag:electron": "electron scripts/local-file-drag.live.test.cjs",
|
||||
"test:tray-panel-layout": "electron scripts/tray-panel-layout.live.test.cjs",
|
||||
"test:xterm-image-resize": "electron scripts/xterm-image-resize.live.test.cjs",
|
||||
"test:xterm-foreground-intense": "electron scripts/xterm-foreground-intense.live.test.cjs",
|
||||
"test:terminal-background-rendering": "electron scripts/terminal-background-rendering.live.test.cjs",
|
||||
"test:xterm-webgl-overflow": "electron scripts/xterm-webgl-atlas-overflow.live.test.cjs",
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
#!/usr/bin/env node
|
||||
/* global process, console */
|
||||
"use strict";
|
||||
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
|
||||
const EXPECTED_VERSION = "0.10.0-beta.292";
|
||||
|
||||
// External IIP metadata must not overwrite fields.type, the parser's internal
|
||||
// SequenceType discriminator. In particular, nu_plugin_charton emits type=image/png
|
||||
// and otherwise skips FILE decoder initialization (Netcatty #3700).
|
||||
// Pinned-version workaround pending an upstream release; patch both runtime bundles.
|
||||
const PATCHES = [
|
||||
...["lib/addon-image.js", "lib/addon-image.mjs"].map(file => ({
|
||||
file,
|
||||
original: "this._key=t,this.fields[t]=null,!0",
|
||||
replacement: 'this._key=t,t!=="type"&&(this.fields[t]=null),!0',
|
||||
})),
|
||||
...["lib/addon-image.js", "lib/addon-image.mjs"].map(file => ({
|
||||
file,
|
||||
original: "_storeValue(e){if(this._key){try{",
|
||||
replacement: '_storeValue(e){if(this._key){if(this._key==="type")return!0;try{',
|
||||
})),
|
||||
];
|
||||
|
||||
function patchImageSource(source, patch) {
|
||||
const originalCount = source.split(patch.original).length - 1;
|
||||
const patchedCount = source.split(patch.replacement).length - 1;
|
||||
if (originalCount === 1 && patchedCount === 0) {
|
||||
return { source: source.replace(patch.original, patch.replacement), changed: true };
|
||||
}
|
||||
if (originalCount === 0 && patchedCount === 1) return { source, changed: false };
|
||||
throw new Error(
|
||||
`${patch.file}: unexpected image-iip-type patch shape ` +
|
||||
`(original=${originalCount}, patched=${patchedCount})`,
|
||||
);
|
||||
}
|
||||
|
||||
function patchInstalledImage(root = process.cwd()) {
|
||||
const base = path.resolve(root, "node_modules/@xterm/addon-image");
|
||||
const version = JSON.parse(fs.readFileSync(path.join(base, "package.json"), "utf8")).version;
|
||||
if (version !== EXPECTED_VERSION) {
|
||||
throw new Error(`unsupported @xterm/addon-image version ${version}; expected ${EXPECTED_VERSION}`);
|
||||
}
|
||||
// Validate both complete bundles before writing either one.
|
||||
const files = new Map();
|
||||
let changed = 0;
|
||||
for (const patch of PATCHES) {
|
||||
const source = files.get(patch.file) ?? fs.readFileSync(path.join(base, patch.file), "utf8");
|
||||
const result = patchImageSource(source, patch);
|
||||
files.set(patch.file, result.source);
|
||||
if (result.changed) changed++;
|
||||
}
|
||||
if (changed) {
|
||||
for (const [file, source] of files) fs.writeFileSync(path.join(base, file), source);
|
||||
fs.rmSync(path.resolve(root, "node_modules/.vite"), { recursive: true, force: true });
|
||||
}
|
||||
return { changed, checked: PATCHES.length };
|
||||
}
|
||||
|
||||
if (require.main === module) {
|
||||
try {
|
||||
const result = patchInstalledImage();
|
||||
console.log(`[patch-xterm-image-iip-type] changed=${result.changed} checked=${result.checked}`);
|
||||
} catch (error) {
|
||||
console.error(`[patch-xterm-image-iip-type] ERROR: ${error.message}`);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { EXPECTED_VERSION, PATCHES, patchImageSource, patchInstalledImage };
|
||||
@@ -0,0 +1,130 @@
|
||||
"use strict";
|
||||
const assert = require("node:assert/strict");
|
||||
const fs = require("node:fs");
|
||||
const os = require("node:os");
|
||||
const path = require("node:path");
|
||||
const test = require("node:test");
|
||||
const { pathToFileURL } = require("node:url");
|
||||
const { EXPECTED_VERSION, PATCHES, patchImageSource, patchInstalledImage } = require("./patch-xterm-image-iip-type.cjs");
|
||||
|
||||
for (const patch of PATCHES) {
|
||||
test(`${patch.file}: ${patch.original.slice(0, 24)} patches once and rejects drift`, () => {
|
||||
const first = patchImageSource(`before;${patch.original}after`, patch);
|
||||
assert.equal(first.changed, true);
|
||||
assert.equal(first.source, `before;${patch.replacement}after`);
|
||||
assert.deepEqual(patchImageSource(first.source, patch), { source: first.source, changed: false });
|
||||
for (const source of ["upstream changed", patch.original.repeat(2), patch.replacement.repeat(2), patch.original + patch.replacement]) {
|
||||
assert.throws(() => patchImageSource(source, patch), /unexpected image-iip-type patch shape/);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function fixture(t) {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-image-iip-type-"));
|
||||
t.after(() => fs.rmSync(root, { recursive: true, force: true }));
|
||||
const base = path.join(root, "node_modules/@xterm/addon-image");
|
||||
fs.mkdirSync(path.join(base, "lib"), { recursive: true });
|
||||
fs.writeFileSync(path.join(base, "package.json"), JSON.stringify({ version: EXPECTED_VERSION }));
|
||||
for (const file of new Set(PATCHES.map(p => p.file))) {
|
||||
fs.writeFileSync(path.join(base, file), PATCHES.filter(p => p.file === file).map(p => p.original).join("\n"));
|
||||
}
|
||||
return { root, base };
|
||||
}
|
||||
|
||||
test("install patches both bundles, invalidates Vite cache and is idempotent", t => {
|
||||
const { root, base } = fixture(t);
|
||||
const cache = path.join(root, "node_modules/.vite");
|
||||
fs.mkdirSync(cache);
|
||||
assert.deepEqual(patchInstalledImage(root), { changed: 4, checked: 4 });
|
||||
assert.equal(fs.existsSync(cache), false);
|
||||
for (const patch of PATCHES) assert.ok(fs.readFileSync(path.join(base, patch.file), "utf8").includes(patch.replacement));
|
||||
assert.deepEqual(patchInstalledImage(root), { changed: 0, checked: 4 });
|
||||
});
|
||||
|
||||
test("install fails closed on a changed version or bundle before writing anything", t => {
|
||||
const { root, base } = fixture(t);
|
||||
const cjs = path.join(base, "lib/addon-image.js");
|
||||
const original = fs.readFileSync(cjs, "utf8");
|
||||
fs.writeFileSync(path.join(base, "package.json"), JSON.stringify({ version: "future" }));
|
||||
assert.throws(() => patchInstalledImage(root), /unsupported @xterm\/addon-image version/);
|
||||
assert.equal(fs.readFileSync(cjs, "utf8"), original);
|
||||
fs.writeFileSync(path.join(base, "package.json"), JSON.stringify({ version: EXPECTED_VERSION }));
|
||||
fs.writeFileSync(path.join(base, "lib/addon-image.mjs"), "upstream changed");
|
||||
assert.throws(() => patchInstalledImage(root), /unexpected image-iip-type patch shape/);
|
||||
assert.equal(fs.readFileSync(cjs, "utf8"), original);
|
||||
});
|
||||
|
||||
// Use the shipped handler and WASM decoder, with only browser image decoding and
|
||||
// storage mocked. The desktop smoke covers the actual bitmap rendering.
|
||||
const PNG = Buffer.from("iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVQIHWP4z8DwHwAFgAI/ScLbtAAAAABJRU5ErkJggg==", "base64");
|
||||
|
||||
function setup(ImageAddon) {
|
||||
const event = () => ({ dispose() {} });
|
||||
const term = {
|
||||
cols: 80, rows: 24,
|
||||
_core: {
|
||||
open() {},
|
||||
_coreBrowserService: { window: { document: {} } },
|
||||
optionsService: { onOptionChange: event },
|
||||
_inputHandler: { onRequestReset: event, _parser: { registerOscHandler: event } },
|
||||
},
|
||||
parser: { registerCsiHandler: event, registerEscHandler: event },
|
||||
buffer: { active: { type: "normal" }, onBufferChange: event },
|
||||
onRender: event, onResize: event,
|
||||
};
|
||||
const addon = new ImageAddon({ enableSizeReports: false, sixelSupport: false, kittySupport: false });
|
||||
addon.activate(term);
|
||||
const images = [];
|
||||
addon._storage.addImage = image => images.push(image);
|
||||
return { addon, handler: addon._handlers.get("iip"), images };
|
||||
}
|
||||
|
||||
async function send(handler, data, chunkSize) {
|
||||
const codes = Uint32Array.from(data, c => c.charCodeAt(0));
|
||||
handler.start();
|
||||
for (let start = 0; start < codes.length; start += chunkSize) {
|
||||
handler.put(codes, start, Math.min(start + chunkSize, codes.length));
|
||||
}
|
||||
await handler.end(true);
|
||||
}
|
||||
|
||||
for (const format of ["js", "mjs"]) {
|
||||
test(`${format}: type metadata preserves FILE and multipart decoding, whole and bytewise`, async t => {
|
||||
const file = path.resolve(__dirname, `../node_modules/@xterm/addon-image/lib/addon-image.${format}`);
|
||||
const { ImageAddon } = format === "js" ? require(file) : await import(pathToFileURL(file).href);
|
||||
const originalBitmap = Object.getOwnPropertyDescriptor(globalThis, "createImageBitmap");
|
||||
globalThis.createImageBitmap = async (blob, options) => ({ blob, ...options });
|
||||
t.after(() => {
|
||||
if (originalBitmap) Object.defineProperty(globalThis, "createImageBitmap", originalBitmap);
|
||||
else delete globalThis.createImageBitmap;
|
||||
});
|
||||
for (const chunkSize of [4096, 1]) {
|
||||
const { addon, handler, images } = setup(ImageAddon);
|
||||
const body = PNG.toString("base64");
|
||||
const headers = [
|
||||
"inline=1", // unchanged standard header
|
||||
"inline=1;type=image/png", // reporter's final metadata field
|
||||
"type=image/png;inline=1", // do not prevent subsequent known fields
|
||||
"inline=1;type=application/octet-stream", // no MIME hardcoding
|
||||
];
|
||||
for (const header of headers) {
|
||||
const count = images.length;
|
||||
await send(handler, `File=size=${PNG.length};${header}:${body}`, chunkSize);
|
||||
assert.equal(images.length, count + 1, header);
|
||||
assert.equal(handler._hp.fields.type, 1);
|
||||
assert.equal(images.at(-1).blob.type, "image/png", "image type still comes from payload bytes");
|
||||
assert.deepEqual(Buffer.from(await images.at(-1).blob.arrayBuffer()), PNG);
|
||||
}
|
||||
const count = images.length;
|
||||
await send(handler, `MultipartFile=inline=1;size=${PNG.length};type=image/png`, chunkSize);
|
||||
assert.equal(handler._hp.fields.type, 2, "final type value must not break HeaderParser.end()");
|
||||
await send(handler, `FilePart=${body}`, chunkSize);
|
||||
await send(handler, "FileEnd", chunkSize);
|
||||
assert.equal(images.length, count + 1);
|
||||
assert.deepEqual(Buffer.from(await images.at(-1).blob.arrayBuffer()), PNG);
|
||||
await send(handler, `File=inline=0;type=image/png:${body}`, chunkSize);
|
||||
assert.equal(images.length, count + 1, "non-inline files stay suppressed");
|
||||
addon.dispose();
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
#!/usr/bin/env node
|
||||
/* global process, console */
|
||||
"use strict";
|
||||
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
|
||||
const EXPECTED_VERSION = "0.10.0-beta.292";
|
||||
|
||||
// ImageStorage.addImage assigns tile IDs using ceil(orig.width/origCellSize.width).
|
||||
// Keep that stride for draw, extractTile and viewportResize. Recomputing it from
|
||||
// the rescaled canvas can add a column: ceil(400 * 9.51 / 10) / 9.51 > 40.
|
||||
// DOM cell widths can change fractionally on window resize, shifting each image
|
||||
// row one more tile and producing diagonal corruption (Netcatty #3746).
|
||||
// Local pinned-version workaround pending an upstream release, not a merged
|
||||
// upstream backport. The open draft uses the same original-grid principle:
|
||||
// https://github.com/xtermjs/xterm.js/issues/5861
|
||||
// https://github.com/xtermjs/xterm.js/pull/6145
|
||||
// Patch both runtime bundles using the existing install-time patch pattern.
|
||||
const PATCHES = [
|
||||
{
|
||||
file: "lib/addon-image.js",
|
||||
original: "const n=e.actual,h=Math.ceil(n.width/o),",
|
||||
replacement: "const n=e.actual,h=Math.ceil(e.orig.width/e.origCellSize.width),",
|
||||
},
|
||||
{
|
||||
file: "lib/addon-image.js",
|
||||
original: "const s=e.actual,o=Math.ceil(s.width/A),",
|
||||
replacement: "const s=e.actual,o=Math.ceil(e.orig.width/e.origCellSize.width),",
|
||||
},
|
||||
{
|
||||
file: "lib/addon-image.js",
|
||||
original: "const h=Math.ceil(((null===(t=n.actual)||void 0===t?void 0:t.width)||0)/n.actualCellSize.width);",
|
||||
replacement: "const h=Math.ceil(((null===(t=n.orig)||void 0===t?void 0:t.width)||0)/n.origCellSize.width);",
|
||||
},
|
||||
{
|
||||
file: "lib/addon-image.mjs",
|
||||
original: "let h=t.actual,g=Math.ceil(h.width/o),",
|
||||
replacement: "let h=t.actual,g=Math.ceil(t.orig.width/t.origCellSize.width),",
|
||||
},
|
||||
{
|
||||
file: "lib/addon-image.mjs",
|
||||
original: "let a=t.actual,n=Math.ceil(a.width/A),",
|
||||
replacement: "let a=t.actual,n=Math.ceil(t.orig.width/t.origCellSize.width),",
|
||||
},
|
||||
{
|
||||
file: "lib/addon-image.mjs",
|
||||
original: "let h=Math.ceil((d.actual?.width||0)/d.actualCellSize.width);",
|
||||
replacement: "let h=Math.ceil((d.orig?.width||0)/d.origCellSize.width);",
|
||||
},
|
||||
];
|
||||
|
||||
function patchImageSource(source, patch) {
|
||||
const originalCount = source.split(patch.original).length - 1;
|
||||
const patchedCount = source.split(patch.replacement).length - 1;
|
||||
if (originalCount === 1 && patchedCount === 0) {
|
||||
return { source: source.replace(patch.original, patch.replacement), changed: true };
|
||||
}
|
||||
if (originalCount === 0 && patchedCount === 1) return { source, changed: false };
|
||||
throw new Error(
|
||||
`${patch.file}: unexpected image-tile-stride patch shape ` +
|
||||
`(original=${originalCount}, patched=${patchedCount})`,
|
||||
);
|
||||
}
|
||||
|
||||
function patchInstalledImage(root = process.cwd()) {
|
||||
const base = path.resolve(root, "node_modules/@xterm/addon-image");
|
||||
const version = JSON.parse(fs.readFileSync(path.join(base, "package.json"), "utf8")).version;
|
||||
if (version !== EXPECTED_VERSION) {
|
||||
throw new Error(`unsupported @xterm/addon-image version ${version}; expected ${EXPECTED_VERSION}`);
|
||||
}
|
||||
// Validate both complete bundles before writing either one.
|
||||
const files = new Map();
|
||||
let changed = 0;
|
||||
for (const patch of PATCHES) {
|
||||
const source = files.get(patch.file) ?? fs.readFileSync(path.join(base, patch.file), "utf8");
|
||||
const result = patchImageSource(source, patch);
|
||||
files.set(patch.file, result.source);
|
||||
if (result.changed) changed++;
|
||||
}
|
||||
if (changed) {
|
||||
for (const [file, source] of files) fs.writeFileSync(path.join(base, file), source);
|
||||
fs.rmSync(path.resolve(root, "node_modules/.vite"), { recursive: true, force: true });
|
||||
}
|
||||
return { changed, checked: PATCHES.length };
|
||||
}
|
||||
|
||||
if (require.main === module) {
|
||||
try {
|
||||
const result = patchInstalledImage();
|
||||
console.log(`[patch-xterm-image-tile-stride] changed=${result.changed} checked=${result.checked}`);
|
||||
} catch (error) {
|
||||
console.error(`[patch-xterm-image-tile-stride] ERROR: ${error.message}`);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { EXPECTED_VERSION, PATCHES, patchImageSource, patchInstalledImage };
|
||||
@@ -0,0 +1,148 @@
|
||||
"use strict";
|
||||
const assert = require("node:assert/strict");
|
||||
const fs = require("node:fs");
|
||||
const os = require("node:os");
|
||||
const path = require("node:path");
|
||||
const test = require("node:test");
|
||||
const { pathToFileURL } = require("node:url");
|
||||
const { EXPECTED_VERSION, PATCHES, patchImageSource, patchInstalledImage } = require("./patch-xterm-image-tile-stride.cjs");
|
||||
|
||||
for (const patch of PATCHES) {
|
||||
test(`${patch.file}: ${patch.original.slice(0, 24)} patches once and rejects drift`, () => {
|
||||
const first = patchImageSource(`before;${patch.original}after`, patch);
|
||||
assert.equal(first.changed, true);
|
||||
assert.equal(first.source, `before;${patch.replacement}after`);
|
||||
assert.deepEqual(patchImageSource(first.source, patch), { source: first.source, changed: false });
|
||||
for (const source of ["upstream changed", patch.original.repeat(2), patch.replacement.repeat(2), patch.original + patch.replacement]) {
|
||||
assert.throws(() => patchImageSource(source, patch), /unexpected image-tile-stride patch shape/);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function fixture(t) {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-image-stride-"));
|
||||
t.after(() => fs.rmSync(root, { recursive: true, force: true }));
|
||||
const base = path.join(root, "node_modules/@xterm/addon-image");
|
||||
fs.mkdirSync(path.join(base, "lib"), { recursive: true });
|
||||
fs.writeFileSync(path.join(base, "package.json"), JSON.stringify({ version: EXPECTED_VERSION }));
|
||||
for (const file of new Set(PATCHES.map(p => p.file))) {
|
||||
fs.writeFileSync(path.join(base, file), PATCHES.filter(p => p.file === file).map(p => p.original).join("\n"));
|
||||
}
|
||||
return { root, base };
|
||||
}
|
||||
|
||||
test("install patches both bundles, invalidates Vite cache and is idempotent", t => {
|
||||
const { root, base } = fixture(t);
|
||||
const cache = path.join(root, "node_modules/.vite");
|
||||
fs.mkdirSync(cache);
|
||||
assert.deepEqual(patchInstalledImage(root), { changed: 6, checked: 6 });
|
||||
assert.equal(fs.existsSync(cache), false);
|
||||
for (const patch of PATCHES) assert.ok(fs.readFileSync(path.join(base, patch.file), "utf8").includes(patch.replacement));
|
||||
assert.deepEqual(patchInstalledImage(root), { changed: 0, checked: 6 });
|
||||
});
|
||||
|
||||
test("install fails closed on a changed version or bundle before writing anything", t => {
|
||||
const { root, base } = fixture(t);
|
||||
const cjs = path.join(base, "lib/addon-image.js");
|
||||
const original = fs.readFileSync(cjs, "utf8");
|
||||
fs.writeFileSync(path.join(base, "package.json"), JSON.stringify({ version: "future" }));
|
||||
assert.throws(() => patchInstalledImage(root), /unsupported @xterm\/addon-image version/);
|
||||
assert.equal(fs.readFileSync(cjs, "utf8"), original);
|
||||
fs.writeFileSync(path.join(base, "package.json"), JSON.stringify({ version: EXPECTED_VERSION }));
|
||||
fs.writeFileSync(path.join(base, "lib/addon-image.mjs"), "upstream changed");
|
||||
assert.throws(() => patchInstalledImage(root), /unexpected image-tile-stride patch shape/);
|
||||
assert.equal(fs.readFileSync(cjs, "utf8"), original);
|
||||
});
|
||||
|
||||
// Exercise the installed addon itself, not a copy of its stride expression.
|
||||
// Canvas calls are captured so these regressions also run in the normal Node suite.
|
||||
function canvas(width, height) {
|
||||
const calls = [];
|
||||
const element = { width, height, calls, remove() {} };
|
||||
element.getContext = () => ({ canvas: element, drawImage: (...args) => calls.push(args), clearRect() {} });
|
||||
return element;
|
||||
}
|
||||
|
||||
function setup(ImageAddon, { imageWidth = 400, cellWidth = 10, cols = 80, layer = "top" } = {}) {
|
||||
const event = () => ({ dispose() {} });
|
||||
const lines = Array.from({ length: 24 }, () => ({
|
||||
_data: new Uint32Array(100 * 3), _extendedAttrs: {},
|
||||
getBg(x) { return this._data[x * 3 + 2]; },
|
||||
}));
|
||||
const cell = { width: cellWidth, height: 20 };
|
||||
const buffer = { x: 0, y: 0, ybase: 0, lines: { get: y => lines[y], length: lines.length } };
|
||||
const term = {
|
||||
cols, rows: 24, dimensions: { css: { cell } },
|
||||
_core: {
|
||||
open() {}, buffer,
|
||||
_coreBrowserService: { window: { document: { createElement: () => canvas(0, 0) } } },
|
||||
optionsService: { onOptionChange: event },
|
||||
_inputHandler: { onRequestReset: event, _dirtyRowTracker: { markDirty() {} }, lineFeed: () => buffer.y++ },
|
||||
},
|
||||
parser: { registerCsiHandler: event, registerEscHandler: event },
|
||||
buffer: { active: { type: "normal" }, onBufferChange: event },
|
||||
onRender: event, onResize: event,
|
||||
registerMarker: () => ({ onDispose() {}, dispose() {} }),
|
||||
};
|
||||
const addon = new ImageAddon({ enableSizeReports: false, sixelSupport: false, iipSupport: false, kittySupport: false });
|
||||
addon.activate(term);
|
||||
const image = canvas(imageWidth, 400);
|
||||
const imageId = addon._storage.addImage(image, { scrolling: true, layer, zIndex: 0, cursorPos: "vt340" });
|
||||
const spec = addon._storage._images.get(imageId);
|
||||
const output = canvas(1000, 480);
|
||||
addon._renderer._layers.set(layer, output.getContext("2d"));
|
||||
return { addon, cell, spec, output, lines };
|
||||
}
|
||||
|
||||
for (const format of ["js", "mjs"]) {
|
||||
const loadAddon = async () => {
|
||||
const file = path.resolve(__dirname, `../node_modules/@xterm/addon-image/lib/addon-image.${format}`);
|
||||
return format === "js" ? require(file) : import(pathToFileURL(file).href);
|
||||
};
|
||||
for (const layer of ["top", "bottom"]) {
|
||||
test(`${format}: ${layer} image rows and extracted tiles keep their original stride through repeated resizes`, async () => {
|
||||
const { ImageAddon } = await loadAddon();
|
||||
for (const options of [{}, { imageWidth: 397 }, { imageWidth: 384, cellWidth: 9.6 }]) {
|
||||
const { addon, cell, spec, output, lines } = setup(ImageAddon, { ...options, layer });
|
||||
const stride = Math.ceil(spec.orig.width / spec.origCellSize.width);
|
||||
assert.equal(stride, 40);
|
||||
assert.equal(lines[1]._extendedAttrs[0].tileId, stride);
|
||||
const originalWidth = cell.width;
|
||||
for (const width of [originalWidth, 9.51, 10.02, 12, 8.73, originalWidth]) {
|
||||
cell.width = width;
|
||||
for (const tileId of [40, 41, 79, 80, 799]) {
|
||||
addon._renderer.draw(spec, tileId, 3, 2);
|
||||
const [, sx, sy, , , dx, dy] = output.calls.at(-1);
|
||||
assert.equal(sx, Math.floor((tileId % stride) * width), `draw x: tile=${tileId}, width=${width}`);
|
||||
assert.equal(sy, Math.floor(tileId / stride) * cell.height, `draw y: tile=${tileId}, width=${width}`);
|
||||
assert.equal(dx, Math.floor(3 * width));
|
||||
assert.equal(dy, 2 * cell.height);
|
||||
const tile = addon._renderer.extractTile(spec, tileId);
|
||||
assert.equal(tile.calls[0][1], sx, "extractTile uses the same source column as draw");
|
||||
assert.equal(tile.calls[0][2], sy, "extractTile uses the same source row as draw");
|
||||
}
|
||||
addon._renderer.draw(spec, stride, 0, 1, stride);
|
||||
assert.equal(output.calls.at(-1)[1], 0, "batched second row starts at the left edge");
|
||||
assert.equal(output.calls.at(-1)[2], cell.height);
|
||||
}
|
||||
assert.equal(spec.actual, spec.orig, "restoring cell metrics restores the original image");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
test(`${format}: viewport growth after rescaling does not append the next image row`, async () => {
|
||||
const { ImageAddon } = await loadAddon();
|
||||
for (const cols of [20, 40]) {
|
||||
const { addon, cell, spec, lines } = setup(ImageAddon, { cols });
|
||||
cell.width = 9.51;
|
||||
addon._renderer.draw(spec, 0, 0, 0);
|
||||
assert.equal(spec.actual.width, 381, "rescaled integer canvas would incorrectly imply 41 columns");
|
||||
addon._storage.viewportResize({ cols: 50, rows: 24 });
|
||||
for (let row = 0; row < 20; row++) {
|
||||
for (let col = 0; col < 40; col++) assert.equal(lines[row]._extendedAttrs[col].tileId, row * 40 + col);
|
||||
assert.equal(lines[row]._extendedAttrs[40], undefined, "must stop at the original row boundary");
|
||||
}
|
||||
assert.equal(spec.tileCount, 800);
|
||||
}
|
||||
});
|
||||
}
|
||||
+103
-50
@@ -6,21 +6,32 @@ const TRUSTED_AUTHOR_ASSOCIATIONS = new Set([
|
||||
"COLLABORATOR",
|
||||
]);
|
||||
|
||||
const DANGEROUS_FILE_EXTENSION =
|
||||
"(?:zip|7z|rar|tar\\.gz|tgz|exe|msi|dmg|pkg|deb|rpm|appimage|bat|cmd|ps1|scr|vbs)";
|
||||
const RISKY_EXTENSIONS = [
|
||||
".tar.gz",
|
||||
".tar.xz",
|
||||
".appimage",
|
||||
".msix",
|
||||
".zip",
|
||||
".7z",
|
||||
".rar",
|
||||
".tgz",
|
||||
".exe",
|
||||
".msi",
|
||||
".dmg",
|
||||
".pkg",
|
||||
".deb",
|
||||
".rpm",
|
||||
".bat",
|
||||
".cmd",
|
||||
".ps1",
|
||||
".scr",
|
||||
".vbs",
|
||||
".jar",
|
||||
".iso",
|
||||
".lnk",
|
||||
];
|
||||
|
||||
const dangerousFilePattern = new RegExp(
|
||||
`(?:^|[\\s([<"'=])([^\\s()[\\]<>\"']+\\.${DANGEROUS_FILE_EXTENSION})(?=$|[\\s)\\]>\"']|[.,!?;:](?:$|\\s))`,
|
||||
"gi"
|
||||
);
|
||||
|
||||
const zipFilePattern = new RegExp(
|
||||
`(?:^|[\\s([<"'=])([^\\s()[\\]<>\"']+\\.zip)(?=$|[\\s)\\]>\"']|[.,!?;:](?:$|\\s))`,
|
||||
"gi"
|
||||
);
|
||||
|
||||
const githubUserAttachmentPattern =
|
||||
/^https:\/\/github\.com\/user-attachments\/files\/\d+\//i;
|
||||
const URL_PATTERN = /https?:\/\/[^\s<>"'`]+/gi;
|
||||
|
||||
function normalizeAssociation(authorAssociation) {
|
||||
return String(authorAssociation || "").trim().toUpperCase();
|
||||
@@ -30,65 +41,107 @@ function isTrustedAuthor(authorAssociation) {
|
||||
return TRUSTED_AUTHOR_ASSOCIATIONS.has(normalizeAssociation(authorAssociation));
|
||||
}
|
||||
|
||||
function extractMatches(body, pattern) {
|
||||
const files = [];
|
||||
for (const match of body.matchAll(pattern)) {
|
||||
files.push(match[1]);
|
||||
function isSameLogin(left, right) {
|
||||
const a = String(left || "").trim().toLowerCase();
|
||||
const b = String(right || "").trim().toLowerCase();
|
||||
return a.length > 0 && a === b;
|
||||
}
|
||||
|
||||
function trimUrlCandidate(raw) {
|
||||
let value = String(raw || "");
|
||||
while (value.length > 0) {
|
||||
const last = value[value.length - 1];
|
||||
if (".,;:!?\"']".includes(last)) {
|
||||
value = value.slice(0, -1);
|
||||
continue;
|
||||
}
|
||||
if (last === ")") {
|
||||
const opens = (value.match(/\(/g) || []).length;
|
||||
const closes = (value.match(/\)/g) || []).length;
|
||||
if (closes > opens) {
|
||||
value = value.slice(0, -1);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
return [...new Set(files)];
|
||||
return value;
|
||||
}
|
||||
|
||||
function extractDangerousFiles(body) {
|
||||
return extractMatches(body, dangerousFilePattern);
|
||||
function hasRiskyExtension(segment) {
|
||||
const name = String(segment || "").toLowerCase();
|
||||
if (!name || name === "." || name === "..") return false;
|
||||
return RISKY_EXTENSIONS.some((extension) => name.endsWith(extension));
|
||||
}
|
||||
|
||||
function extractZipFiles(body) {
|
||||
return extractMatches(body, zipFilePattern);
|
||||
function urlHasRiskyPath(raw) {
|
||||
let parsed;
|
||||
try {
|
||||
parsed = new URL(raw);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") return false;
|
||||
|
||||
let pathname = parsed.pathname || "";
|
||||
try {
|
||||
pathname = decodeURIComponent(pathname);
|
||||
} catch {
|
||||
// Keep the raw pathname when it is not valid percent-encoding.
|
||||
}
|
||||
|
||||
return pathname.split("/").some((segment) => hasRiskyExtension(segment));
|
||||
}
|
||||
|
||||
function isGitHubUserAttachment(file) {
|
||||
return githubUserAttachmentPattern.test(file);
|
||||
function extractRiskyUrls(body) {
|
||||
const text = String(body || "");
|
||||
const found = [];
|
||||
const seen = new Set();
|
||||
for (const match of text.matchAll(URL_PATTERN)) {
|
||||
const raw = trimUrlCandidate(match[0]);
|
||||
if (!raw || seen.has(raw) || !urlHasRiskyPath(raw)) continue;
|
||||
seen.add(raw);
|
||||
found.push(raw);
|
||||
}
|
||||
return found;
|
||||
}
|
||||
|
||||
function detectSpamComment({ body, authorAssociation, userType } = {}) {
|
||||
const normalizedBody = String(body || "").replace(/\s+/g, " ").trim();
|
||||
const dangerousFiles = extractDangerousFiles(normalizedBody);
|
||||
const zipFiles = extractZipFiles(normalizedBody);
|
||||
function detectSpamComment({
|
||||
body,
|
||||
authorAssociation,
|
||||
userType,
|
||||
commentAuthor,
|
||||
issueAuthor,
|
||||
} = {}) {
|
||||
const trustedAuthor = isTrustedAuthor(authorAssociation);
|
||||
const botAuthor = String(userType || "").toLowerCase() === "bot";
|
||||
const botAuthor = String(userType || "").trim().toLowerCase() === "bot";
|
||||
const authorIsIssueAuthor = isSameLogin(commentAuthor, issueAuthor);
|
||||
const matchedUrls = extractRiskyUrls(body);
|
||||
|
||||
const reasons = [];
|
||||
let score = 0;
|
||||
|
||||
if (zipFiles.length > 0) {
|
||||
score += 10;
|
||||
reasons.push(`zip attachment or link: ${zipFiles.join(", ")}`);
|
||||
} else if (dangerousFiles.length > 0) {
|
||||
score += 10;
|
||||
reasons.push(`dangerous downloadable file: ${dangerousFiles.join(", ")}`);
|
||||
}
|
||||
|
||||
// Hard rule: any zip (or other dangerous downloadable) from an untrusted
|
||||
// human is deleted. Maintainers and bots are exempt.
|
||||
// Hard rule: hide when an untrusted human who is not the issue author posts
|
||||
// an http(s) URL whose path contains a risky archive or executable.
|
||||
// Maintainers, bots, and the issue author are exempt. Bare filenames and
|
||||
// local paths are not links, so they do not match.
|
||||
const spam =
|
||||
!trustedAuthor &&
|
||||
!botAuthor &&
|
||||
(zipFiles.length > 0 || dangerousFiles.length > 0);
|
||||
!authorIsIssueAuthor &&
|
||||
matchedUrls.length > 0;
|
||||
|
||||
return {
|
||||
spam,
|
||||
score: spam ? score : 0,
|
||||
reasons: spam ? reasons : [],
|
||||
dangerousFiles: zipFiles.length > 0 ? zipFiles : dangerousFiles,
|
||||
score: spam ? 10 : 0,
|
||||
reasons: spam ? [`risky download URL: ${matchedUrls.join(", ")}`] : [],
|
||||
matchedUrls,
|
||||
trustedAuthor,
|
||||
botAuthor,
|
||||
authorIsIssueAuthor,
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
detectSpamComment,
|
||||
extractDangerousFiles,
|
||||
extractZipFiles,
|
||||
isGitHubUserAttachment,
|
||||
extractRiskyUrls,
|
||||
isTrustedAuthor,
|
||||
RISKY_EXTENSIONS,
|
||||
};
|
||||
@@ -2,113 +2,164 @@
|
||||
|
||||
const assert = require("node:assert/strict");
|
||||
const test = require("node:test");
|
||||
const {
|
||||
detectSpamComment,
|
||||
extractDangerousFiles,
|
||||
extractZipFiles,
|
||||
isGitHubUserAttachment,
|
||||
} = require("./spam-comment-filter.cjs");
|
||||
const { detectSpamComment, extractRiskyUrls } = require("./spam-comment-filter.cjs");
|
||||
|
||||
test("flags the fake Netcatty patch spam pattern", () => {
|
||||
test("issue author pasting a local SecureCRT.exe path is not hidden", () => {
|
||||
const result = detectSpamComment({
|
||||
authorAssociation: "NONE",
|
||||
userType: "User",
|
||||
body: "[netcatty_patch.zip](https://example.com/netcatty_patch.zip)\n\nMan, that terminal rendering bug is such a pain. It looks like the sftp module is tripping over the encoding when it tries to sync the current path. I found a quick patch that fixes the character mapping in the backend so those black boxes finally disappear.",
|
||||
});
|
||||
|
||||
assert.equal(result.spam, true);
|
||||
});
|
||||
|
||||
test("flags fake patch spam when the filename ends a sentence", () => {
|
||||
const result = detectSpamComment({
|
||||
authorAssociation: "NONE",
|
||||
userType: "User",
|
||||
body: "Download this patch.zip. It fixes the backend encoding so the terminal rendering black boxes disappear.",
|
||||
});
|
||||
|
||||
assert.equal(result.spam, true);
|
||||
assert.deepEqual(result.dangerousFiles, ["patch.zip"]);
|
||||
});
|
||||
|
||||
test("flags any zip from an outside user, including hash-named soft bait", () => {
|
||||
const result = detectSpamComment({
|
||||
authorAssociation: "NONE",
|
||||
userType: "User",
|
||||
body: "[63bf1862b52422e38b8cb170.zip](https://github.com/user-attachments/files/29784176/63bf1862b52422e38b8cb170.zip)\nI'd start with the docs inside",
|
||||
});
|
||||
|
||||
assert.equal(result.spam, true);
|
||||
assert.ok(
|
||||
result.dangerousFiles.some((file) => file.includes("63bf1862b52422e38b8cb170.zip"))
|
||||
);
|
||||
});
|
||||
|
||||
test("flags ordinary zip log attachments from outside users", () => {
|
||||
const result = detectSpamComment({
|
||||
authorAssociation: "FIRST_TIME_CONTRIBUTOR",
|
||||
userType: "User",
|
||||
body: "[debug-logs.zip](https://github.com/user-attachments/files/29784176/debug-logs.zip)\nI attached logs from a failed connection. The app hangs after I click connect, and the logs show the SSH handshake timing out.",
|
||||
});
|
||||
|
||||
assert.equal(result.spam, true);
|
||||
});
|
||||
|
||||
test("flags other dangerous archives from outside users", () => {
|
||||
const result = detectSpamComment({
|
||||
authorAssociation: "NONE",
|
||||
userType: "User",
|
||||
body: "See hotfix.dmg for the workaround.",
|
||||
});
|
||||
|
||||
assert.equal(result.spam, true);
|
||||
assert.deepEqual(result.dangerousFiles, ["hotfix.dmg"]);
|
||||
});
|
||||
|
||||
test("does not flag trusted maintainers even when sharing zip archives", () => {
|
||||
const result = detectSpamComment({
|
||||
authorAssociation: "OWNER",
|
||||
userType: "User",
|
||||
body: "Try this temporary netcatty_patch.zip while I prepare the signed release. It fixes the rendering issue.",
|
||||
commentAuthor: "reporter",
|
||||
issueAuthor: "reporter",
|
||||
body: "It crashes here: C:\\Program Files\\SecureCRT\\SecureCRT.exe",
|
||||
});
|
||||
|
||||
assert.equal(result.spam, false);
|
||||
assert.deepEqual(result.matchedUrls, []);
|
||||
});
|
||||
|
||||
test("does not flag bot comments that mention zip files", () => {
|
||||
const result = detectSpamComment({
|
||||
authorAssociation: "NONE",
|
||||
userType: "Bot",
|
||||
body: "Uploaded build-artifacts.zip for CI.",
|
||||
});
|
||||
|
||||
assert.equal(result.spam, false);
|
||||
});
|
||||
|
||||
test("does not flag comments without downloadable archives", () => {
|
||||
test("bare Windows path without a URL is not hidden even from a third party", () => {
|
||||
const result = detectSpamComment({
|
||||
authorAssociation: "NONE",
|
||||
userType: "User",
|
||||
body: "I attached screenshots of the hang after connect. The SSH handshake times out.",
|
||||
commentAuthor: "mallory",
|
||||
issueAuthor: "reporter",
|
||||
body: "Try Files\\SecureCRT\\SecureCRT.exe and also hotfix.dmg.",
|
||||
});
|
||||
|
||||
assert.equal(result.spam, false);
|
||||
assert.deepEqual(result.matchedUrls, []);
|
||||
});
|
||||
|
||||
test("extracts risky file names from markdown links and plain text", () => {
|
||||
assert.deepEqual(
|
||||
extractDangerousFiles("[fix.zip](https://example.com/fix.zip) also hotfix.dmg."),
|
||||
["fix.zip", "https://example.com/fix.zip", "hotfix.dmg"]
|
||||
);
|
||||
assert.deepEqual(extractZipFiles("[fix.zip](https://example.com/fix.zip) also hotfix.dmg."), [
|
||||
"fix.zip",
|
||||
"https://example.com/fix.zip",
|
||||
test("third party github user-attachments zip URL is hidden", () => {
|
||||
const result = detectSpamComment({
|
||||
authorAssociation: "NONE",
|
||||
userType: "User",
|
||||
commentAuthor: "mallory",
|
||||
issueAuthor: "reporter",
|
||||
body: "Grab https://github.com/user-attachments/files/123/fix.zip and run it.",
|
||||
});
|
||||
|
||||
assert.equal(result.spam, true);
|
||||
assert.deepEqual(result.matchedUrls, [
|
||||
"https://github.com/user-attachments/files/123/fix.zip",
|
||||
]);
|
||||
});
|
||||
|
||||
test("identifies GitHub user attachment URLs", () => {
|
||||
assert.equal(
|
||||
isGitHubUserAttachment("https://github.com/user-attachments/files/29784176/netcatty_fix.zip"),
|
||||
true
|
||||
test("third party mediafire zip URL is hidden when the extension is a path segment", () => {
|
||||
const result = detectSpamComment({
|
||||
authorAssociation: "NONE",
|
||||
userType: "User",
|
||||
commentAuthor: "mallory",
|
||||
issueAuthor: "reporter",
|
||||
body: "Download https://www.mediafire.com/file/abc/netcatty_fix.zip/file",
|
||||
});
|
||||
|
||||
assert.equal(result.spam, true);
|
||||
assert.deepEqual(result.matchedUrls, [
|
||||
"https://www.mediafire.com/file/abc/netcatty_fix.zip/file",
|
||||
]);
|
||||
});
|
||||
|
||||
test("maintainer posting a release dmg link is not hidden", () => {
|
||||
const result = detectSpamComment({
|
||||
authorAssociation: "OWNER",
|
||||
userType: "User",
|
||||
commentAuthor: "maintainer",
|
||||
issueAuthor: "reporter",
|
||||
body: "Signed build: https://github.com/binaricat/Netcatty/releases/download/v1.2.3/Netcatty.dmg",
|
||||
});
|
||||
|
||||
assert.equal(result.spam, false);
|
||||
assert.equal(result.trustedAuthor, true);
|
||||
assert.deepEqual(result.matchedUrls, [
|
||||
"https://github.com/binaricat/Netcatty/releases/download/v1.2.3/Netcatty.dmg",
|
||||
]);
|
||||
});
|
||||
|
||||
test("issue author uploading their own repro zip is not hidden", () => {
|
||||
const result = detectSpamComment({
|
||||
authorAssociation: "NONE",
|
||||
userType: "User",
|
||||
commentAuthor: "Reporter",
|
||||
issueAuthor: "reporter",
|
||||
body: "[repro.zip](https://github.com/user-attachments/files/99/repro.zip)",
|
||||
});
|
||||
|
||||
assert.equal(result.spam, false);
|
||||
assert.equal(result.authorIsIssueAuthor, true);
|
||||
assert.deepEqual(result.matchedUrls, [
|
||||
"https://github.com/user-attachments/files/99/repro.zip",
|
||||
]);
|
||||
});
|
||||
|
||||
test("query strings do not block a risky path, and a query-only filename does not match", () => {
|
||||
assert.deepEqual(
|
||||
extractRiskyUrls("https://example.com/dropper.exe?download=1&arch=win"),
|
||||
["https://example.com/dropper.exe?download=1&arch=win"]
|
||||
);
|
||||
assert.equal(isGitHubUserAttachment("https://example.com/netcatty_fix.zip"), false);
|
||||
assert.deepEqual(
|
||||
extractRiskyUrls("https://example.com/download?file=fix.zip"),
|
||||
[]
|
||||
);
|
||||
});
|
||||
|
||||
test("matches newly added extensions and ignores non-risky links", () => {
|
||||
const body = [
|
||||
"https://example.com/a.jar",
|
||||
"https://example.com/b.iso",
|
||||
"https://example.com/c.msix",
|
||||
"https://example.com/d.lnk",
|
||||
"https://example.com/e.tar.xz",
|
||||
"https://example.com/f.tar.gz",
|
||||
"https://github.com/user-attachments/assets/e1302660-dece-46be-9857-1678a9b13d00",
|
||||
"https://example.com/package.json",
|
||||
"https://example.com/notes.zip.txt",
|
||||
].join("\n");
|
||||
|
||||
assert.deepEqual(extractRiskyUrls(body), [
|
||||
"https://example.com/a.jar",
|
||||
"https://example.com/b.iso",
|
||||
"https://example.com/c.msix",
|
||||
"https://example.com/d.lnk",
|
||||
"https://example.com/e.tar.xz",
|
||||
"https://example.com/f.tar.gz",
|
||||
]);
|
||||
});
|
||||
|
||||
test("member and collaborator exemptions still apply to http links", () => {
|
||||
for (const authorAssociation of ["MEMBER", "COLLABORATOR"]) {
|
||||
const result = detectSpamComment({
|
||||
authorAssociation,
|
||||
userType: "User",
|
||||
commentAuthor: "maintainer",
|
||||
issueAuthor: "reporter",
|
||||
body: "Mirror: http://example.com/setup.msi",
|
||||
});
|
||||
assert.equal(result.spam, false);
|
||||
assert.deepEqual(result.matchedUrls, ["http://example.com/setup.msi"]);
|
||||
}
|
||||
});
|
||||
|
||||
test("does not flag bot comments that include a risky URL", () => {
|
||||
const result = detectSpamComment({
|
||||
authorAssociation: "NONE",
|
||||
userType: "Bot",
|
||||
commentAuthor: "netcatty[bot]",
|
||||
issueAuthor: "reporter",
|
||||
body: "Uploaded https://example.com/build-artifacts.zip for CI.",
|
||||
});
|
||||
|
||||
assert.equal(result.spam, false);
|
||||
assert.equal(result.botAuthor, true);
|
||||
});
|
||||
|
||||
test("strips markdown and sentence punctuation around the URL", () => {
|
||||
assert.deepEqual(
|
||||
extractRiskyUrls("See [patch](https://example.com/patch.zip). Thanks!"),
|
||||
["https://example.com/patch.zip"]
|
||||
);
|
||||
assert.deepEqual(
|
||||
extractRiskyUrls("<https://example.com/tool.EXE>"),
|
||||
["https://example.com/tool.EXE"]
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,100 @@
|
||||
"use strict";
|
||||
/* global process, __dirname, console, document, requestAnimationFrame */
|
||||
if (!process.versions.electron) {
|
||||
require("node:test")("inline image pixels stay aligned across terminal resizes", {
|
||||
skip: "run with Electron to exercise real DOM and canvas rendering",
|
||||
}, () => {});
|
||||
} else {
|
||||
const { app, BrowserWindow } = require("electron");
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
const esbuild = require("esbuild");
|
||||
const temp = require("../electron/bridges/tempDirBridge.cjs");
|
||||
const root = path.resolve(__dirname, "..");
|
||||
const userData = fs.mkdtempSync(`${temp.getTempFilePath("image-resize")}-`);
|
||||
app.setPath("userData", userData);
|
||||
app.on("window-all-closed", () => {});
|
||||
let win;
|
||||
const cleanup = code => {
|
||||
win?.destroy();
|
||||
fs.rmSync(userData, { recursive: true, force: true });
|
||||
app.exit(code);
|
||||
};
|
||||
|
||||
async function exercise({ Terminal, ImageAddon }) {
|
||||
const check = (actual, expected, label) => {
|
||||
if (JSON.stringify(actual) !== JSON.stringify(expected)) {
|
||||
throw new Error(`${label}: expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`);
|
||||
}
|
||||
};
|
||||
const host = document.createElement("div");
|
||||
document.body.appendChild(host);
|
||||
const term = new Terminal({ cols: 40, rows: 18, fontFamily: "monospace", fontSize: 16, cursorBlink: false, allowProposedApi: true });
|
||||
term.open(host);
|
||||
const addon = new ImageAddon({ sixelSupport: false, iipSupport: false, kittySupport: false });
|
||||
term.loadAddon(addon);
|
||||
const render = async () => {
|
||||
term.refresh(0, term.rows - 1);
|
||||
await new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve)));
|
||||
};
|
||||
await render();
|
||||
const originalCell = { ...addon._renderer.cellSize };
|
||||
const source = document.createElement("canvas");
|
||||
source.width = Math.round(originalCell.width * 40);
|
||||
source.height = Math.round(originalCell.height * 8);
|
||||
check(Math.ceil(source.width / originalCell.width), 40, "fixture covers 40 image columns");
|
||||
const context = source.getContext("2d");
|
||||
const color = (col, row) => [32 + (col % 7) * 32, 32 + row * 24, 192, 255];
|
||||
for (let row = 0; row < 8; row++) {
|
||||
for (let col = 0; col < 40; col++) {
|
||||
const [r, g, b] = color(col, row);
|
||||
context.fillStyle = `rgb(${r},${g},${b})`;
|
||||
context.fillRect(Math.floor(col * originalCell.width), Math.floor(row * originalCell.height), Math.ceil(originalCell.width), Math.ceil(originalCell.height));
|
||||
}
|
||||
}
|
||||
// Insert once into the real terminal buffer. All subsequent frames must use
|
||||
// the original tile IDs, just like an image already printed by any protocol.
|
||||
const id = addon._storage.addImage(source, { scrolling: true, layer: "top", zIndex: 0, cursorPos: "vt340" });
|
||||
const spec = addon._storage._images.get(id);
|
||||
const samples = [];
|
||||
let roundedStrideChanged = false;
|
||||
for (const cols of [40, 53, 67, 91, 40, 53, 40]) {
|
||||
term.resize(cols, 18);
|
||||
await render();
|
||||
const cell = addon._renderer.cellSize;
|
||||
const badStride = Math.ceil(spec.actual.width / cell.width);
|
||||
roundedStrideChanged ||= badStride !== 40;
|
||||
const output = host.querySelector(".xterm-image-layer-top").getContext("2d");
|
||||
for (const row of [0, 1, 2, 7]) {
|
||||
for (const col of [0, 1, 5, 20, 38]) {
|
||||
const pixel = [...output.getImageData(Math.floor((col + 0.5) * cell.width), Math.floor((row + 0.5) * cell.height), 1, 1).data];
|
||||
check(pixel, color(col, row), `canvas pixel at cols=${cols}, cell=${col},${row}`);
|
||||
const tile = addon.extractTileAtBufferCell(col, row);
|
||||
const extracted = [...tile.getContext("2d").getImageData(Math.floor(tile.width / 2), Math.floor(tile.height / 2), 1, 1).data];
|
||||
check(extracted, color(col, row), `extracted tile at cols=${cols}, cell=${col},${row}`);
|
||||
}
|
||||
}
|
||||
samples.push({ cols, cellWidth: cell.width, canvasWidth: spec.actual.width, roundedStride: badStride });
|
||||
}
|
||||
check(roundedStrideChanged, true, "exercise fractional cell widths that triggered the regression");
|
||||
term.dispose();
|
||||
host.remove();
|
||||
return { samples, pixelChecks: 280, repeatedResizes: true };
|
||||
}
|
||||
|
||||
void app.whenReady().then(async () => {
|
||||
const bundle = esbuild.buildSync({
|
||||
stdin: {
|
||||
contents: 'export { Terminal } from "@xterm/xterm"; export { ImageAddon } from "@xterm/addon-image";',
|
||||
resolveDir: root,
|
||||
},
|
||||
bundle: true, format: "iife", globalName: "ImageResizeTest", platform: "browser", write: false,
|
||||
}).outputFiles[0].text;
|
||||
win = new BrowserWindow({ show: true, width: 1000, height: 600, webPreferences: { backgroundThrottling: false } });
|
||||
await win.loadURL("data:text/html,<body style='margin:0;background:black'>");
|
||||
await win.webContents.insertCSS(fs.readFileSync(require.resolve("@xterm/xterm/css/xterm.css"), "utf8"));
|
||||
const result = await win.webContents.executeJavaScript(`${bundle}\n(${exercise.toString()})(ImageResizeTest)`);
|
||||
console.log("IMAGE_RESIZE_RENDERING_OK", JSON.stringify(result));
|
||||
cleanup(0);
|
||||
}).catch(error => { console.error(error); cleanup(1); });
|
||||
}
|
||||
+7
@@ -301,6 +301,13 @@ declare global {
|
||||
gpuName?: string | null; // First GPU name
|
||||
gpuMemUsed?: number | null; // Summed VRAM used in MB
|
||||
gpuMemTotal?: number | null; // Summed VRAM total in MB
|
||||
gpus?: Array<{ // Per-GPU utilization and VRAM (MiB)
|
||||
index: number;
|
||||
name: string;
|
||||
utilization: number | null;
|
||||
memoryUsed: number | null;
|
||||
memoryTotal: number | null;
|
||||
}>;
|
||||
memTotal: number | null; // Total memory in MB
|
||||
memUsed: number | null; // Used memory in MB (excluding buffers/cache)
|
||||
memFree: number | null; // Free memory in MB
|
||||
|
||||
Reference in new issue
Block a user