mirror of
https://github.com/binaricat/Netcatty.git
synced 2026-11-05 14:03:01 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
65f20b899b | ||
|
|
3068bd9ffa | ||
|
|
755eab661d | ||
|
|
b27f0ad7d3 | ||
|
|
0c36ef7275 | ||
|
|
110c79c8dc | ||
|
|
e8c31a7800 | ||
|
|
2527f140c0 | ||
|
|
aaee43417f | ||
|
|
a622d953ad | ||
|
|
16c9af6e32 | ||
|
|
64717fffd5 | ||
|
|
2ba045c8e7 | ||
|
|
8b0b7706a3 | ||
|
|
fa34a5602e | ||
|
|
d14591b36a | ||
|
|
2bb7baebde | ||
|
|
aa597135b1 | ||
|
|
42b8e2f347 | ||
|
|
9c312b7b98 | ||
|
|
05e4bf69f7 |
No files matched your search
@@ -0,0 +1,72 @@
|
||||
name: Forward issue events to Grok Bot
|
||||
|
||||
on:
|
||||
issues:
|
||||
types: [opened, reopened, edited]
|
||||
issue_comment:
|
||||
types: [created, edited]
|
||||
|
||||
permissions: {}
|
||||
|
||||
jobs:
|
||||
forward:
|
||||
# PR comments are handled elsewhere; only forward real issues.
|
||||
if: github.event_name == 'issues' || github.event.issue.pull_request == null
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 2
|
||||
steps:
|
||||
- name: Forward minimal event to Grok Bot webhook
|
||||
env:
|
||||
WEBHOOK_URL: ${{ secrets.GROKBOT_WEBHOOK_URL }}
|
||||
WEBHOOK_KEY: ${{ secrets.GROKBOT_WEBHOOK_KEY }}
|
||||
run: |
|
||||
if [ -z "$WEBHOOK_URL" ] || [ -z "$WEBHOOK_KEY" ]; then
|
||||
echo "Grok Bot webhook secrets not set; skipping."
|
||||
exit 0
|
||||
fi
|
||||
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" \
|
||||
--data @- "$WEBHOOK_URL"
|
||||
@@ -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.',
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -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), которые не читают модификаторы из записей ввода консоли.',
|
||||
|
||||
@@ -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。',
|
||||
|
||||
@@ -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。',
|
||||
|
||||
@@ -10,12 +10,14 @@ import {
|
||||
resolveThemeAccentForeground,
|
||||
migrateIncomingTerminalFontId,
|
||||
migrateMaterializedAutoCloseOnExitDefault,
|
||||
migrateMaterializedShiftEnterNewlineDefault,
|
||||
} from "./settingsStateDefaults.ts";
|
||||
import { TERMINAL_FONT_AUTO } from "../../infrastructure/config/fonts.ts";
|
||||
import {
|
||||
STORAGE_KEY_TERM_AUTO_CLOSE_EXIT_MIGRATED,
|
||||
STORAGE_KEY_TERM_FONT_FAMILY,
|
||||
STORAGE_KEY_TERM_SETTINGS,
|
||||
STORAGE_KEY_TERM_SHIFT_ENTER_NEWLINE_MIGRATED,
|
||||
} from "../../infrastructure/config/storageKeys.ts";
|
||||
import type { UiThemeTokens } from "../../infrastructure/config/uiThemes.ts";
|
||||
|
||||
@@ -214,3 +216,76 @@ test("migrateMaterializedAutoCloseOnExitDefault leaves fresh installs and explic
|
||||
delete (globalThis as { localStorage?: unknown }).localStorage;
|
||||
}
|
||||
});
|
||||
|
||||
test("migrateMaterializedShiftEnterNewlineDefault rewrites the old default once", () => {
|
||||
const store = installMemoryLocalStorage();
|
||||
try {
|
||||
store.set(
|
||||
STORAGE_KEY_TERM_SETTINGS,
|
||||
JSON.stringify({ scrollback: 3000, shiftEnterNewlineText: "\\n" }),
|
||||
);
|
||||
migrateMaterializedShiftEnterNewlineDefault();
|
||||
const migrated = JSON.parse(store.get(STORAGE_KEY_TERM_SETTINGS)!);
|
||||
assert.equal(migrated.shiftEnterNewlineText, "\\e\\r");
|
||||
assert.equal(migrated.scrollback, 3000);
|
||||
assert.equal(store.get(STORAGE_KEY_TERM_SHIFT_ENTER_NEWLINE_MIGRATED), "true");
|
||||
|
||||
store.set(
|
||||
STORAGE_KEY_TERM_SETTINGS,
|
||||
JSON.stringify({ scrollback: 3000, shiftEnterNewlineText: "\\n" }),
|
||||
);
|
||||
migrateMaterializedShiftEnterNewlineDefault();
|
||||
assert.equal(
|
||||
JSON.parse(store.get(STORAGE_KEY_TERM_SETTINGS)!).shiftEnterNewlineText,
|
||||
"\\n",
|
||||
);
|
||||
} finally {
|
||||
delete (globalThis as { localStorage?: unknown }).localStorage;
|
||||
}
|
||||
});
|
||||
|
||||
test("migrateMaterializedShiftEnterNewlineDefault rewrites a raw stored newline", () => {
|
||||
const store = installMemoryLocalStorage();
|
||||
try {
|
||||
store.set(
|
||||
STORAGE_KEY_TERM_SETTINGS,
|
||||
JSON.stringify({ shiftEnterNewlineText: "\n" }),
|
||||
);
|
||||
migrateMaterializedShiftEnterNewlineDefault();
|
||||
assert.equal(
|
||||
JSON.parse(store.get(STORAGE_KEY_TERM_SETTINGS)!).shiftEnterNewlineText,
|
||||
"\\e\\r",
|
||||
);
|
||||
} finally {
|
||||
delete (globalThis as { localStorage?: unknown }).localStorage;
|
||||
}
|
||||
});
|
||||
|
||||
test("migrateMaterializedShiftEnterNewlineDefault keeps custom Shift+Enter text", () => {
|
||||
const store = installMemoryLocalStorage();
|
||||
try {
|
||||
store.set(
|
||||
STORAGE_KEY_TERM_SETTINGS,
|
||||
JSON.stringify({ shiftEnterNewlineText: "\\r", scrollback: 1000 }),
|
||||
);
|
||||
migrateMaterializedShiftEnterNewlineDefault();
|
||||
assert.deepEqual(JSON.parse(store.get(STORAGE_KEY_TERM_SETTINGS)!), {
|
||||
shiftEnterNewlineText: "\\r",
|
||||
scrollback: 1000,
|
||||
});
|
||||
assert.equal(store.get(STORAGE_KEY_TERM_SHIFT_ENTER_NEWLINE_MIGRATED), "true");
|
||||
} finally {
|
||||
delete (globalThis as { localStorage?: unknown }).localStorage;
|
||||
}
|
||||
});
|
||||
|
||||
test("migrateMaterializedShiftEnterNewlineDefault leaves a fresh install without a settings blob", () => {
|
||||
const store = installMemoryLocalStorage();
|
||||
try {
|
||||
migrateMaterializedShiftEnterNewlineDefault();
|
||||
assert.equal(store.has(STORAGE_KEY_TERM_SETTINGS), false);
|
||||
assert.equal(store.get(STORAGE_KEY_TERM_SHIFT_ENTER_NEWLINE_MIGRATED), "true");
|
||||
} finally {
|
||||
delete (globalThis as { localStorage?: unknown }).localStorage;
|
||||
}
|
||||
});
|
||||
@@ -1,8 +1,15 @@
|
||||
import type { HotkeyScheme, SessionLogFormat, TerminalSettings } from '../../domain/models';
|
||||
import {
|
||||
DEFAULT_SHIFT_ENTER_NEWLINE_TEXT,
|
||||
isLegacyDefaultShiftEnterNewlineText,
|
||||
type HotkeyScheme,
|
||||
type SessionLogFormat,
|
||||
type TerminalSettings,
|
||||
} from '../../domain/models';
|
||||
import {
|
||||
STORAGE_KEY_TERM_AUTO_CLOSE_EXIT_MIGRATED,
|
||||
STORAGE_KEY_TERM_FONT_FAMILY,
|
||||
STORAGE_KEY_TERM_SETTINGS,
|
||||
STORAGE_KEY_TERM_SHIFT_ENTER_NEWLINE_MIGRATED,
|
||||
} from '../../infrastructure/config/storageKeys';
|
||||
import {
|
||||
isDeprecatedPrimaryFontId,
|
||||
@@ -91,6 +98,26 @@ export function migrateMaterializedAutoCloseOnExitDefault(): void {
|
||||
localStorageAdapter.writeBoolean(STORAGE_KEY_TERM_AUTO_CLOSE_EXIT_MIGRATED, true);
|
||||
}
|
||||
|
||||
/**
|
||||
* One-time migration for the Shift+Enter default flip (#3770). Builds before
|
||||
* that change materialized `\\n` into saved settings, so upgraders kept a
|
||||
* bare newline and TUI CLIs still submitted. Rewrite only that old default
|
||||
* (escaped or raw) to ESC+CR, then persist the marker. A later explicit `\\n`
|
||||
* is left alone. Custom text is never rewritten. Sync and import do not
|
||||
* read this marker; an old `\\n` from another device stays as stored.
|
||||
*/
|
||||
export function migrateMaterializedShiftEnterNewlineDefault(): void {
|
||||
if (localStorageAdapter.readBoolean(STORAGE_KEY_TERM_SHIFT_ENTER_NEWLINE_MIGRATED)) return;
|
||||
const stored = localStorageAdapter.read<Partial<TerminalSettings>>(STORAGE_KEY_TERM_SETTINGS);
|
||||
if (stored && isLegacyDefaultShiftEnterNewlineText(stored.shiftEnterNewlineText)) {
|
||||
localStorageAdapter.write(STORAGE_KEY_TERM_SETTINGS, {
|
||||
...stored,
|
||||
shiftEnterNewlineText: DEFAULT_SHIFT_ENTER_NEWLINE_TEXT,
|
||||
});
|
||||
}
|
||||
localStorageAdapter.writeBoolean(STORAGE_KEY_TERM_SHIFT_ENTER_NEWLINE_MIGRATED, true);
|
||||
}
|
||||
|
||||
// Auto-detect default hotkey scheme based on platform
|
||||
export const DEFAULT_HOTKEY_SCHEME: HotkeyScheme =
|
||||
typeof navigator !== 'undefined' && /Mac|iPhone|iPad|iPod/i.test(navigator.platform)
|
||||
|
||||
@@ -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();
|
||||
@@ -151,6 +151,7 @@ import {
|
||||
isValidUiThemeId,
|
||||
migrateIncomingTerminalFontId,
|
||||
migrateMaterializedAutoCloseOnExitDefault,
|
||||
migrateMaterializedShiftEnterNewlineDefault,
|
||||
readStoredString,
|
||||
serializeTerminalSettings,
|
||||
type HostClickBehavior,
|
||||
@@ -360,6 +361,7 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
|
||||
// One-time reset of the pre-flip materialized autoCloseOnExit: true before
|
||||
// it is ingested; afterwards stored values are the user's own and win.
|
||||
migrateMaterializedAutoCloseOnExitDefault();
|
||||
migrateMaterializedShiftEnterNewlineDefault();
|
||||
const stored = localStorageAdapter.read<TerminalSettings>(STORAGE_KEY_TERM_SETTINGS);
|
||||
return normalizeTerminalSettings(stored);
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -114,6 +114,8 @@ import { handleSerialLineModeInput } from "./serialLineInput";
|
||||
import { isTerminalReportSequence } from "./terminalReportSequence";
|
||||
import {
|
||||
getShiftEnterSubmittedInput,
|
||||
nativeKittyKeyboardPreservesShiftEnter,
|
||||
readNativeKittyKeyboardFlags,
|
||||
resolveShiftEnterText,
|
||||
shouldClaimShiftEnterForText,
|
||||
shouldSendShiftEnterText,
|
||||
@@ -596,8 +598,12 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
|
||||
// ConPTY asks the frontend for Win32 INPUT_RECORD encoding with DECSET
|
||||
// 9001. Preserve browser key modifiers for native Windows applications
|
||||
// instead of collapsing them into legacy VT bytes.
|
||||
// kittyKeyboard lets an app opt into xterm's CSI u. Netcatty's own
|
||||
// handlers, when that setting is on, register later and consume those
|
||||
// sequences first, so the two parsers do not both apply the protocol.
|
||||
vtExtensions: {
|
||||
win32InputMode: windowsPty?.backend === "conpty",
|
||||
kittyKeyboard: true,
|
||||
},
|
||||
// Override ignoreBracketedPasteMode if user explicitly disables bracketed paste
|
||||
ignoreBracketedPasteMode: settings?.disableBracketedPaste ?? performanceConfig.options.ignoreBracketedPasteMode,
|
||||
@@ -2528,6 +2534,8 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
|
||||
shouldClaimShiftEnterForText(e, ctx.terminalSettingsRef.current, {
|
||||
win32InputMode: term.modes.win32InputMode,
|
||||
kittySequenceForKeyDown,
|
||||
nativeKittyPreservesShiftEnter: !kittyKeyboardProtocolEnabled
|
||||
&& nativeKittyKeyboardPreservesShiftEnter(readNativeKittyKeyboardFlags(term)),
|
||||
})
|
||||
) {
|
||||
const id = ctx.sessionRef.current;
|
||||
@@ -3123,6 +3131,8 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
|
||||
legacySuppressedKeys: broadcastLegacySuppressedKeys,
|
||||
win32InputMode: term.modes.win32InputMode,
|
||||
shiftEnterSettings: ctx.terminalSettingsRef.current,
|
||||
nativeKittyPreservesShiftEnter: !kittyKeyboardProtocolEnabled
|
||||
&& nativeKittyKeyboardPreservesShiftEnter(readNativeKittyKeyboardFlags(term)),
|
||||
win32ShiftEnterTextKeys: broadcastWin32ShiftEnterTextKeys,
|
||||
}),
|
||||
getSessionId: () => ctx.sessionRef.current,
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
@@ -571,6 +571,61 @@ test("Shift+Enter broadcast uses CSI-u only after the target negotiated a preser
|
||||
});
|
||||
});
|
||||
|
||||
test("native kitty broadcast targets keep Shift+Enter as CSI-u when custom kitty is off", () => {
|
||||
const shiftEnter = {
|
||||
kind: "key" as const,
|
||||
fallbackToLegacy: true,
|
||||
event: {
|
||||
type: "keydown" as const,
|
||||
key: "Enter",
|
||||
code: "Enter",
|
||||
shiftKey: true,
|
||||
},
|
||||
};
|
||||
const encodedKeys = new Set<string>();
|
||||
const legacySuppressedKeys = new Set<string>();
|
||||
const base = {
|
||||
kittyProtocolEnabled: false,
|
||||
kittyMode: createKittyKeyboardModeState(),
|
||||
applicationCursorMode: false,
|
||||
shiftEnterSettings: {
|
||||
shiftEnterNewlineEnabled: true,
|
||||
shiftEnterNewlineText: "\\e\\r",
|
||||
},
|
||||
};
|
||||
|
||||
assert.deepEqual(resolveKittyKeyboardBroadcastInput(shiftEnter, {
|
||||
...base,
|
||||
encodedKeys,
|
||||
legacySuppressedKeys,
|
||||
nativeKittyPreservesShiftEnter: true,
|
||||
}), {
|
||||
data: SHIFT_ENTER_CSI_U_SEQUENCE,
|
||||
kittyEncoded: true,
|
||||
urgentInterrupt: false,
|
||||
});
|
||||
assert.equal(resolveKittyKeyboardBroadcastInput({
|
||||
kind: "legacy",
|
||||
data: SHIFT_ENTER_CSI_U_SEQUENCE,
|
||||
keyIdentity: "Enter",
|
||||
}, {
|
||||
...base,
|
||||
encodedKeys,
|
||||
legacySuppressedKeys,
|
||||
nativeKittyPreservesShiftEnter: true,
|
||||
}), null);
|
||||
|
||||
assert.deepEqual(resolveKittyKeyboardBroadcastInput(shiftEnter, {
|
||||
...base,
|
||||
encodedKeys: new Set<string>(),
|
||||
nativeKittyPreservesShiftEnter: false,
|
||||
}), {
|
||||
data: "\u001b\r",
|
||||
kittyEncoded: false,
|
||||
urgentInterrupt: false,
|
||||
});
|
||||
});
|
||||
|
||||
test("Win32 broadcast targets resolve Shift+Enter from their own force-text setting", () => {
|
||||
const shiftEnterRecord = "\u001b[13;28;13;1;16;1_";
|
||||
const press = { type: "keydown" as const, key: "Enter", code: "Enter", shiftKey: true };
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
isBareShiftEnterLineEnding,
|
||||
resolveShiftEnterText,
|
||||
resolveWin32ForcedShiftEnterText,
|
||||
SHIFT_ENTER_CSI_U_SEQUENCE,
|
||||
shouldSendShiftEnterText,
|
||||
} from "./shiftEnterText";
|
||||
|
||||
@@ -253,6 +254,11 @@ type ResolveKittyKeyboardBroadcastOptions = {
|
||||
TerminalSettings,
|
||||
"shiftEnterNewlineEnabled" | "shiftEnterNewlineText" | "shiftEnterForceText"
|
||||
>;
|
||||
/**
|
||||
* Target xterm negotiated native kitty flags that encode Shift+Enter as
|
||||
* CSI 13;2u, while Netcatty's custom kitty setting is off.
|
||||
*/
|
||||
nativeKittyPreservesShiftEnter?: boolean;
|
||||
/**
|
||||
* Presses this Win32 target turned into its configured Shift+Enter text
|
||||
* instead of a native record. Their paired releases are dropped so ConPTY
|
||||
@@ -418,6 +424,18 @@ export const resolveKittyKeyboardBroadcastInput = (
|
||||
if (remapped) return remapped;
|
||||
return { data: encoded, kittyEncoded: true, urgentInterrupt: false };
|
||||
}
|
||||
// Custom kitty is off. A target whose xterm already negotiated a preserving
|
||||
// native mode must get CSI 13;2u, and the source's follow-up legacy packet
|
||||
// (the same sequence) must not be written again.
|
||||
if (options.nativeKittyPreservesShiftEnter && shouldSendShiftEnterText(input.event)) {
|
||||
options.encodedKeys.add(identity);
|
||||
legacySuppressedKeys.add(identity);
|
||||
return {
|
||||
data: SHIFT_ENTER_CSI_U_SEQUENCE,
|
||||
kittyEncoded: true,
|
||||
urgentInterrupt: false,
|
||||
};
|
||||
}
|
||||
if (!input.fallbackToLegacy || input.event.type === "keyup") return null;
|
||||
const legacy = encodeLegacyKeyboardEvent(input.event, options.applicationCursorMode);
|
||||
if (!legacy) return null;
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import xterm from "@xterm/xterm";
|
||||
|
||||
import {
|
||||
nativeKittyKeyboardPreservesShiftEnter,
|
||||
readNativeKittyKeyboardFlags,
|
||||
SHIFT_ENTER_CSI_U_SEQUENCE,
|
||||
} from "./shiftEnterText.ts";
|
||||
|
||||
const { Terminal } = xterm;
|
||||
|
||||
type KittyProbe = {
|
||||
flags: number;
|
||||
shiftEnter: string | undefined;
|
||||
};
|
||||
|
||||
const probeShiftEnter = (setup: string): Promise<KittyProbe> => {
|
||||
const term = new Terminal({
|
||||
cols: 80,
|
||||
rows: 24,
|
||||
allowProposedApi: true,
|
||||
vtExtensions: { kittyKeyboard: true },
|
||||
});
|
||||
return new Promise((resolve) => {
|
||||
term.write(setup, () => {
|
||||
const event = {
|
||||
type: "keydown",
|
||||
key: "Enter",
|
||||
code: "Enter",
|
||||
shiftKey: true,
|
||||
altKey: false,
|
||||
ctrlKey: false,
|
||||
metaKey: false,
|
||||
repeat: false,
|
||||
keyCode: 13,
|
||||
};
|
||||
const core = (term as unknown as {
|
||||
_core: {
|
||||
_keyboardService: {
|
||||
evaluateKeyDown: (ev: object) => { key?: string };
|
||||
};
|
||||
};
|
||||
})._core;
|
||||
resolve({
|
||||
flags: readNativeKittyKeyboardFlags(term),
|
||||
shiftEnter: core._keyboardService.evaluateKeyDown(event).key,
|
||||
});
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
test("xterm kitty keyboard encodes opted-in Shift+Enter as CSI 13;2u", async () => {
|
||||
const idle = await probeShiftEnter("");
|
||||
assert.equal(idle.flags, 0);
|
||||
assert.equal(idle.shiftEnter, "\r");
|
||||
assert.equal(nativeKittyKeyboardPreservesShiftEnter(idle.flags), false);
|
||||
|
||||
const disambiguate = await probeShiftEnter("\u001b[=1u");
|
||||
assert.equal(disambiguate.flags, 1);
|
||||
assert.equal(disambiguate.shiftEnter, SHIFT_ENTER_CSI_U_SEQUENCE);
|
||||
assert.equal(nativeKittyKeyboardPreservesShiftEnter(disambiguate.flags), true);
|
||||
|
||||
const alternateOnly = await probeShiftEnter("\u001b[=4u");
|
||||
assert.equal(alternateOnly.flags, 4);
|
||||
assert.equal(alternateOnly.shiftEnter, "\r");
|
||||
assert.equal(nativeKittyKeyboardPreservesShiftEnter(alternateOnly.flags), false);
|
||||
});
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
decodeTerminalTextEscapes,
|
||||
doesKittyEncodingPreserveShiftEnter,
|
||||
getShiftEnterSubmittedInput,
|
||||
nativeKittyKeyboardPreservesShiftEnter,
|
||||
isBareShiftEnterLineEnding,
|
||||
isShiftEnterLineContinuationText,
|
||||
resolveShiftEnterText,
|
||||
@@ -26,8 +27,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 +72,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);
|
||||
@@ -147,6 +162,41 @@ test("force-text opt-out only overrides the Win32 hand-off", () => {
|
||||
assert.equal(shouldClaimShiftEnterForText(keyEvent({ ctrlKey: true }), forced, win32), false);
|
||||
});
|
||||
|
||||
test("native kitty Shift+Enter steps aside only for CSI-u flag sets", () => {
|
||||
assert.equal(nativeKittyKeyboardPreservesShiftEnter(0), false);
|
||||
assert.equal(nativeKittyKeyboardPreservesShiftEnter(0b0001), true);
|
||||
assert.equal(nativeKittyKeyboardPreservesShiftEnter(0b0010), true);
|
||||
assert.equal(nativeKittyKeyboardPreservesShiftEnter(0b1000), true);
|
||||
assert.equal(nativeKittyKeyboardPreservesShiftEnter(0b0100), false);
|
||||
assert.equal(nativeKittyKeyboardPreservesShiftEnter(0b10000), false);
|
||||
|
||||
const settings = { shiftEnterNewlineEnabled: true, shiftEnterForceText: false };
|
||||
assert.equal(
|
||||
shouldClaimShiftEnterForText(keyEvent(), settings, {
|
||||
win32InputMode: false,
|
||||
kittySequenceForKeyDown: null,
|
||||
nativeKittyPreservesShiftEnter: true,
|
||||
}),
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
shouldClaimShiftEnterForText(keyEvent(), settings, {
|
||||
win32InputMode: false,
|
||||
kittySequenceForKeyDown: null,
|
||||
nativeKittyPreservesShiftEnter: false,
|
||||
}),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
shouldClaimShiftEnterForText(keyEvent(), { ...settings, shiftEnterForceText: true }, {
|
||||
win32InputMode: true,
|
||||
kittySequenceForKeyDown: null,
|
||||
nativeKittyPreservesShiftEnter: true,
|
||||
}),
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
test("Win32 forced Shift+Enter text requires the opt-out and non-empty text", () => {
|
||||
const settings = {
|
||||
shiftEnterNewlineEnabled: true,
|
||||
@@ -179,7 +229,7 @@ test("runtime routes Shift+Enter text through the shared input handler", () => {
|
||||
// skipping the ConPTY Win32 hand-off only when the force-text opt-out is on.
|
||||
assert.match(
|
||||
source,
|
||||
/if \(\s*shouldClaimShiftEnterForText\(e, ctx\.terminalSettingsRef\.current, \{\s*win32InputMode: term\.modes\.win32InputMode,\s*kittySequenceForKeyDown,\s*\}\)\s*\) \{[\s\S]*?const shiftEnterText = resolveShiftEnterText\([\s\S]*?\)[\s\S]*?\}\s*if \(kittySequenceForKeyDown\)/s,
|
||||
/if \(\s*shouldClaimShiftEnterForText\(e, ctx\.terminalSettingsRef\.current, \{\s*win32InputMode: term\.modes\.win32InputMode,\s*kittySequenceForKeyDown,\s*nativeKittyPreservesShiftEnter: !kittyKeyboardProtocolEnabled\s*&& nativeKittyKeyboardPreservesShiftEnter\(readNativeKittyKeyboardFlags\(term\)\),\s*\}\)\s*\) \{[\s\S]*?const shiftEnterText = resolveShiftEnterText\([\s\S]*?\)[\s\S]*?\}\s*if \(kittySequenceForKeyDown\)/s,
|
||||
);
|
||||
// The gate above only decides whether Shift+Enter is *eligible* for send-text.
|
||||
// It must then claim the keydown only when resolveShiftEnterText() returned
|
||||
@@ -207,7 +257,7 @@ test("runtime routes Shift+Enter text through the shared input handler", () => {
|
||||
);
|
||||
assert.match(
|
||||
source,
|
||||
/vtExtensions: \{\s*win32InputMode: windowsPty\?\.backend === "conpty",\s*\},/s,
|
||||
/vtExtensions: \{\s*win32InputMode: windowsPty\?\.backend === "conpty",\s*kittyKeyboard: true,\s*\},/s,
|
||||
);
|
||||
assert.match(
|
||||
source,
|
||||
@@ -223,7 +273,7 @@ test("runtime routes Shift+Enter text through the shared input handler", () => {
|
||||
);
|
||||
assert.match(
|
||||
source,
|
||||
/resolveOptions: \(\) => \(\{[\s\S]*?shiftEnterSettings: ctx\.terminalSettingsRef\.current,[\s\S]*?\}\),/s,
|
||||
/resolveOptions: \(\) => \(\{[\s\S]*?shiftEnterSettings: ctx\.terminalSettingsRef\.current,[\s\S]*?nativeKittyPreservesShiftEnter: !kittyKeyboardProtocolEnabled\s*&& nativeKittyKeyboardPreservesShiftEnter\(readNativeKittyKeyboardFlags\(term\)\),[\s\S]*?\}\),/s,
|
||||
);
|
||||
assert.doesNotMatch(source, /resolveShiftEnterText\([\s\S]*?alternateScreen:/s);
|
||||
assert.match(source, /getShiftEnterSubmittedInput\(logicalData\)/);
|
||||
|
||||
@@ -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";
|
||||
@@ -100,6 +100,24 @@ export function doesKittyEncodingPreserveShiftEnter(
|
||||
&& !isBareShiftEnterLineEnding(encoded);
|
||||
}
|
||||
|
||||
/**
|
||||
* xterm 6.1 flag bits that encode Shift+Enter as CSI 13;2u.
|
||||
* Disambiguate (1), report event types (2), and report all keys (8).
|
||||
* Alternate-key (4) or associated-text (16) alone still emit a bare CR.
|
||||
*/
|
||||
const NATIVE_KITTY_SHIFT_ENTER_CSI_U_FLAGS = 0b0001 | 0b0010 | 0b1000;
|
||||
|
||||
export function nativeKittyKeyboardPreservesShiftEnter(flags: number): boolean {
|
||||
return (flags & NATIVE_KITTY_SHIFT_ENTER_CSI_U_FLAGS) !== 0;
|
||||
}
|
||||
|
||||
export function readNativeKittyKeyboardFlags(term: {
|
||||
_core?: { coreService?: { kittyKeyboard?: { flags?: unknown } } };
|
||||
}): number {
|
||||
const flags = term._core?.coreService?.kittyKeyboard?.flags;
|
||||
return typeof flags === "number" && Number.isFinite(flags) ? flags : 0;
|
||||
}
|
||||
|
||||
export function isShiftEnterLineContinuationText(text: string): boolean {
|
||||
return /\\(?:\r\n|\r|\n)$/.test(text);
|
||||
}
|
||||
@@ -109,10 +127,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 {
|
||||
@@ -145,6 +168,8 @@ export function resolveWin32ForcedShiftEnterText(
|
||||
* opted out with shiftEnterForceText; elsewhere the text is used only when the
|
||||
* negotiated Kitty encoding would collapse the chord to a bare CR/LF, so the
|
||||
* Win32 opt-out can never override a Kitty encoding that preserves it.
|
||||
* xterm's own CSI-u mode is the same kind of preserving encoding: step aside
|
||||
* so the app receives CSI 13;2u.
|
||||
*/
|
||||
export function shouldClaimShiftEnterForText(
|
||||
event: ShiftEnterEvent,
|
||||
@@ -152,10 +177,15 @@ export function shouldClaimShiftEnterForText(
|
||||
TerminalSettings,
|
||||
"shiftEnterNewlineEnabled" | "shiftEnterForceText"
|
||||
> | undefined,
|
||||
mode: { win32InputMode: boolean; kittySequenceForKeyDown: string | null },
|
||||
mode: {
|
||||
win32InputMode: boolean;
|
||||
kittySequenceForKeyDown: string | null;
|
||||
nativeKittyPreservesShiftEnter?: boolean;
|
||||
},
|
||||
): boolean {
|
||||
if (!shouldSendShiftEnterText(event, settings)) return false;
|
||||
return mode.win32InputMode
|
||||
? settings?.shiftEnterForceText === true
|
||||
: !doesKittyEncodingPreserveShiftEnter(mode.kittySequenceForKeyDown);
|
||||
if (mode.win32InputMode) return settings?.shiftEnterForceText === true;
|
||||
if (doesKittyEncodingPreserveShiftEnter(mode.kittySequenceForKeyDown)) return false;
|
||||
if (mode.nativeKittyPreservesShiftEnter) return false;
|
||||
return true;
|
||||
}
|
||||
@@ -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.
|
||||
@@ -503,6 +503,17 @@ export const normalizeTerminalSettings = (
|
||||
/** Default scrollback rows for new installs (VS Code uses 1000; we keep a modest headroom). */
|
||||
export const DEFAULT_TERMINAL_SCROLLBACK = 3000;
|
||||
|
||||
/** Backslash-escaped Shift+Enter text for a fresh install (legacy Alt+Enter). */
|
||||
export const DEFAULT_SHIFT_ENTER_NEWLINE_TEXT = '\\e\\r';
|
||||
|
||||
/**
|
||||
* Stored forms of the pre-#3770 Shift+Enter default. `\\n` is the escaped
|
||||
* setting string; `\n` is a raw newline if one was ever persisted.
|
||||
*/
|
||||
export function isLegacyDefaultShiftEnterNewlineText(value: unknown): boolean {
|
||||
return value === '\\n' || value === '\n';
|
||||
}
|
||||
|
||||
const DEFAULT_TERMINAL_SETTINGS: TerminalSettings = {
|
||||
scrollback: DEFAULT_TERMINAL_SCROLLBACK,
|
||||
drawBoldInBrightColors: true,
|
||||
@@ -522,7 +533,7 @@ const DEFAULT_TERMINAL_SETTINGS: TerminalSettings = {
|
||||
altAsMeta: false,
|
||||
optionArrowWordJump: false,
|
||||
shiftEnterNewlineEnabled: true,
|
||||
shiftEnterNewlineText: '\\n',
|
||||
shiftEnterNewlineText: DEFAULT_SHIFT_ENTER_NEWLINE_TEXT,
|
||||
shiftEnterForceText: false,
|
||||
kittyKeyboardProtocolEnabled: false,
|
||||
scrollOnInput: true,
|
||||
|
||||
@@ -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", () => {
|
||||
@@ -111,10 +111,21 @@ test("normalizeTerminalSettings preserves Shift+Enter text", () => {
|
||||
);
|
||||
});
|
||||
|
||||
test("normalizeTerminalSettings keeps an explicit legacy Shift+Enter newline", () => {
|
||||
assert.equal(
|
||||
normalizeTerminalSettings({ shiftEnterNewlineText: "\\n" }).shiftEnterNewlineText,
|
||||
"\\n",
|
||||
);
|
||||
assert.equal(
|
||||
normalizeTerminalSettings({ shiftEnterNewlineText: "\n" }).shiftEnterNewlineText,
|
||||
"\n",
|
||||
);
|
||||
});
|
||||
|
||||
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({
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -26,6 +26,8 @@ export const STORAGE_KEY_TERM_FONT_SIZE = 'netcatty_term_font_size_v1';
|
||||
export const STORAGE_KEY_TERM_SETTINGS = 'netcatty_term_settings_v1';
|
||||
/** One-time marker: pre-#3619 builds materialized autoCloseOnExit: true into saved settings. */
|
||||
export const STORAGE_KEY_TERM_AUTO_CLOSE_EXIT_MIGRATED = 'netcatty_term_auto_close_exit_migrated_v1';
|
||||
/** One-time marker: pre-#3770 builds materialized shiftEnterNewlineText "\\n" into saved settings. */
|
||||
export const STORAGE_KEY_TERM_SHIFT_ENTER_NEWLINE_MIGRATED = 'netcatty_term_shift_enter_newline_migrated_v1';
|
||||
export const STORAGE_KEY_HOTKEY_SCHEME = 'netcatty_hotkey_scheme_v1';
|
||||
export const STORAGE_KEY_CUSTOM_KEY_BINDINGS = 'netcatty_custom_key_bindings_v1';
|
||||
export const STORAGE_KEY_HOTKEY_RECORDING = 'netcatty_hotkey_recording_v1';
|
||||
|
||||
+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); });
|
||||
}
|
||||
Reference in new issue
Block a user