Compare commits

...
Author SHA1 Message Date
bincxz e57ec0b476 fix: show the effective device mode in host system details 2026-09-07 16:15:18 +08:00
bincxz 999f85763f fix: detect remote systems after Mosh becomes ready 2026-09-07 16:05:15 +08:00
bincxz 70a68900ce fix: retain detected systems for temporary terminal hosts 2026-09-07 15:51:12 +08:00
bincxz 361dc6117f fix: unify host system detection and simplify host details 2026-09-07 15:40:33 +08:00
fe2f14f032 feat(ai): add Mention Note context picker for Vault Notes (#3290)
* fix(#3289): automated AI fix

* fix: address Codex review on PR #3290

* fix: address Codex review on PR #3290

* fix: address Codex review on PR #3290

* fix: address Codex review on PR #3290

* fix: address Codex review on PR #3290

* fix: address Codex review on PR #3290

* fix: address Codex review on PR #3290

* fix: address Codex review on PR #3290

* fix: address Codex review on PR #3290

* fix: address Codex review on PR #3290

* fix: address Codex review on PR #3290

* fix: address Codex review on PR #3290

* fix: address Codex review on PR #3290

* fix: address Codex review on PR #3290

* fix: address Codex review on PR #3290

* fix: address Codex review on PR #3290

* fix: address Codex review on PR #3290

* fix: address Codex review on PR #3290

* fix: address Codex review on PR #3290

* fix: address Codex review on PR #3290

* fix: address Codex review on PR #3290

* fix: address Codex review on PR #3290

* fix(ai): apply note attachment budgets synchronously

* fix(ai): reuse normalized note search fields while typing

* fix(ai): retain draft state binding in panel host

* fix(ai): reject carriage returns in note references

* fix(ai): protect continuation before pruning note payloads

* refactor(ai): keep note mentions as lightweight references

* fix(ai): gate note references on reader availability

* fix(ai): retain note references ahead of replay prose

* refactor(ai): share one instruction across note references

* fix(ai): keep accepted note references within replay space

* Bound agent note reads and add resumable literal search

* Preserve compact note requests and announce picker selection

* Retain mixed attachment context in compact note replay

* Keep imported long note identities readable

* Keep note picker selection and untitled labels consistent

---------

Co-authored-by: netcatty-bot <308658023+netcatty-bot@users.noreply.github.com>
Co-authored-by: bincxz <16399091+binaricat@users.noreply.github.com>
2026-09-07 14:32:08 +08:00
1a9c57b0a9 fix(terminal): keep reading position across fit resize and settled output (#3300)
* fix(#3299): automated AI fix

* fix: address Codex review on PR #3300

* fix: address Codex review on PR #3300

* fix: address Codex review on PR #3300

* fix: address Codex review on PR #3300

* fix: address Codex review on PR #3300

* fix: address Codex review on PR #3300

* fix: address Codex review on PR #3300

* fix: address Codex review on PR #3300

* fix: address Codex review on PR #3300

* fix: address Codex review on PR #3300

* fix: address Codex review on PR #3300

* fix: address Codex review on PR #3300

* fix: address Codex review on PR #3300

* fix: address Codex review on PR #3300

* fix: address Codex review on PR #3300

* fix: address Codex review on PR #3300

* fix: address Codex review on PR #3300

* fix: address Codex review on PR #3300

* fix: address Codex review on PR #3300

* fix: address Codex review on PR #3300

* fix: address Codex review on PR #3300

* fix: address Codex review on PR #3300

* fix: address Codex review on PR #3300

* fix(terminal): defer fit until synchronized output settles

---------

Co-authored-by: netcatty-bot <308658023+netcatty-bot@users.noreply.github.com>
Co-authored-by: bincxz <16399091+binaricat@users.noreply.github.com>
2026-09-07 11:46:23 +08:00
058727b0d9 fix(ssh): refresh login on new sessions and reconnects (#3294)
* fix(#3293): automated AI fix

* fix(ssh): refresh new sessions and discard reconnect source reuse

---------

Co-authored-by: netcatty-bot <308658023+netcatty-bot@users.noreply.github.com>
Co-authored-by: bincxz <16399091+binaricat@users.noreply.github.com>
2026-09-07 11:41:48 +08:00
陈大猫andnetcatty-bot 35ed0b6dbd fix(terminal): preserve row boundaries in alternate history preview (#3297)
* fix(terminal): preserve row boundaries in alternate history preview

* fix(terminal): retain columns across history row moves

* fix(terminal): keep cursor-only moves empty and overwrite whole glyphs

* fix: address Codex review on PR #3297

* fix: address Codex review on PR #3297

* fix: address Codex review on PR #3297

* fix: address Codex review on PR #3297

* fix: address Codex review on PR #3297

* fix(terminal): consume unprintable wide glyphs in history capture

* fix(terminal): preserve deferred wrap between display chunks

* fix: address Codex review on PR #3297

* fix: address Codex review on PR #3297

* fix: address Codex review on PR #3297

* fix: address Codex review on PR #3297

* fix: address Codex review on PR #3297

* fix: address Codex review on PR #3297

* fix: address Codex review on PR #3297

* fix: address Codex review on PR #3297

* fix: address Codex review on PR #3297

* fix: address Codex review on PR #3297

* fix: address Codex review on PR #3297

* fix: address Codex review on PR #3297

* fix(terminal): retain blank rows when reflowing history

* fix: address Codex review on PR #3297

* fix: address Codex review on PR #3297

* fix: address Codex review on PR #3297

* fix: address Codex review on PR #3297

* fix(terminal): refresh cached history after resize trims text

* fix(terminal): synchronize history with actual resized buffer

* fix(terminal): preserve final wide glyph during nonwrapping overflow

* fix(terminal): restore saved cursor modes in history

---------

Co-authored-by: netcatty-bot <308658023+netcatty-bot@users.noreply.github.com>
2026-09-07 11:11:10 +08:00
陈大猫 ab80d04039 fix(ci): allow exact research helper paths in Claude runs (#3306) 2026-09-07 10:30:46 +08:00
陈大猫 aaac962179 fix(sftp): share pending resume feedback across transfer views (#3305) 2026-09-07 10:30:42 +08:00
陈大猫 5d61462b20 fix(terminal): keep full-width history preview rows visible (#3304)
* fix(terminal): align history preview with rendered cell dimensions

* fix(terminal): fit wide history rows without changing copied text

* fix(terminal): select every history preview row
2026-09-07 10:30:38 +08:00
陈大猫 8cc8d938b8 fix(ai): preserve user text during request compression (#3303) 2026-09-07 10:30:35 +08:00
陈大猫andnetcatty-bot 1babda6fa9 fix(sync): resume auto-sync after first master-key setup (#3302)
* fix(sync): retry peer unlock when the shared password becomes available

* fix(sync): retain deliberate locks after availability notifications

* fix(sync): preserve deliberate locks after peer password events

* fix(sync): retain deliberate locks across consumer remounts

* fix: address Codex review on PR #3302

* fix: address Codex review on PR #3302

* fix: address Codex review on PR #3302

* fix: address Codex review on PR #3302

* test(sync): cover pending unlock owner handoff

* fix(sync): distinguish stale unlocks from invalid passwords

* fix(sync): retry completed auto unlock after consumers remount

* fix(sync): share auto unlock retry generation across consumers

---------

Co-authored-by: netcatty-bot <308658023+netcatty-bot@users.noreply.github.com>
2026-09-07 10:30:30 +08:00
陈大猫 0eebc77b1b fix(ssh): resolve watchdog PID without an intermediate shell (#3301) 2026-09-07 10:30:26 +08:00
陈大猫andnetcatty-bot 7069bdb1c8 fix(ai): keep live shell probes out of Bash history (#3298)
* fix(ai): keep live shell probes out of Bash history

* fix(ai): bypass custom eval when cleaning probe history

* fix(ai): clean Bash probes without external shell detection

* fix(ai): keep probe cleanup nonfatal in errexit shells

* fix(ai): bypass custom builtin definitions in history cleanup

* fix: address Codex review on PR #3298

* fix(ai): verify history cleanup across dispatcher fallbacks

* fix: address Codex review on PR #3298

* fix: address Codex review on PR #3298

* fix: address Codex review on PR #3298

* fix: address Codex review on PR #3298

* fix: address Codex review on PR #3298

* fix(ai): deliver shell probes without input overruns

* fix: address Codex review on PR #3298

* fix(ai): suspend wait timers during paced terminal input

* fix(ai): bound long command assignments for canonical terminals

* fix(ai): clear canonical pending input without a control command

---------

Co-authored-by: netcatty-bot <308658023+netcatty-bot@users.noreply.github.com>
2026-09-07 10:30:22 +08:00
陈大猫 10ad14223f fix(sftp): isolate side panel snapshots across tab switches (#3296) 2026-09-07 10:30:15 +08:00
陈大猫 f7f0d804af fix(settings): restore AI detection after effect replay (#3295) 2026-09-07 10:30:11 +08:00
陈大猫 4ba196510d fix(terminal): avoid duplicate broadcast keypress text (#3292) 2026-09-07 10:30:07 +08:00
陈大猫 fd54339367 fix(sftp): settle direct folder downloads after pause and resume (#3291) 2026-09-07 10:30:03 +08:00
github-actions[bot]andgithub-actions[bot] 28b9a8db89 chore: update star history charts [skip ci] (#3307)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-09-07 00:10:36 +00:00
cabdcdf31c fix(logs): adaptive light/dark theme for HTML session logs (#3264)
* fix(#3263): automated AI fix

* fix(logs): keep adaptive HTML colors readable

* fix(logs): adapt extended foreground colors to light pages

* perf(logs): calculate light foreground colors without iteration

---------

Co-authored-by: netcatty-bot <308658023+netcatty-bot@users.noreply.github.com>
Co-authored-by: bincxz <16399091+binaricat@users.noreply.github.com>
2026-09-06 15:11:13 +08:00
陈大猫andnetcatty-bot e2a9e54a90 fix(sftp): preserve concurrent files during local publication (#3286)
* fix(sftp): preserve concurrent files during local publication

* fix(sftp): preserve metadata during fallback restoration

* fix: address Codex review on PR #3286

* fix(sftp): prepare timestamps before publishing local downloads

* fix: address Codex review on PR #3286

* fix(sftp): preserve file access through restricted-mode publication

* fix(sftp): retry timestamps when preparation fails

* fix(sftp): verify unreadable destination recovery before replacement

---------

Co-authored-by: netcatty-bot <308658023+netcatty-bot@users.noreply.github.com>
2026-09-06 14:23:15 +08:00
陈大猫 c9d22b3b4d fix(sftp): preserve latest pause across delayed transfer controls (#3284)
* fix(sftp): preserve newer pause decisions across delayed transfer controls

* fix(sftp): stop obsolete recovery after held transfers settle

* fix(sftp): treat superseded cross-window controls as no-ops

* fix(sftp): preserve latest folder control in pause watcher

* fix(sftp): reconcile superseded controls across windows

* test(sftp): expect authoritative action on superseded pause

* fix(sftp): distinguish mixed resume outcomes and IPC failures

* docs(sftp): publish audit findings and validation boundaries

* docs(sftp): record final publication and restart validation

* fix(sftp): reflect partial resumes and reconcile remote watcher controls

* fix(sftp): preserve resolved resume failures in partial folders
2026-09-06 14:13:49 +08:00
陈大猫 2dea078c6d fix(sftp): preserve shared SSH transport when channel opens abort (#3288) 2026-09-06 13:29:08 +08:00
陈大猫 446e82180b fix(sftp): settle folder children before history compaction removes their rows (#3287)
* fix(sftp): observe folder child completion before history compaction

* fix(sftp): carry current child identity through recovery events

* fix(sftp): admit recovered children before waiting for completion

* test(sftp): keep independent audit regressions mergeable

* fix(sftp): resume retained pauses without bypassing newer controls
2026-09-06 13:29:05 +08:00
陈大猫 2eaddf0baf fix(sftp): refuse ambiguous or replaced resume endpoints (#3285) 2026-09-06 13:29:02 +08:00
135 changed files with 8333 additions and 1005 deletions

No files matched your search

+2
View File
@@ -877,6 +877,7 @@ jobs:
--bare -p --permission-mode dontAsk \
--settings "$HOME/.claude/settings.json" \
--allowedTools "Read" "Bash(web-search *)" "Bash(web-fetch *)" \
"Bash($research_dir/web-search *)" "Bash($research_dir/web-fetch *)" \
--disallowedTools "WebSearch" "WebFetch" "Edit" "Write" "NotebookEdit" \
--output-format stream-json --verbose --model "$AI_MODEL" \
"$prompt"
@@ -1695,6 +1696,7 @@ jobs:
--bare -p --permission-mode dontAsk \
--settings "$HOME/.claude/settings.json" \
--allowedTools "Read" "Bash(web-search *)" "Bash(web-fetch *)" \
"Bash($research_dir/web-search *)" "Bash($research_dir/web-fetch *)" \
--disallowedTools "WebSearch" "WebFetch" "Edit" "Write" "NotebookEdit" \
--output-format stream-json --verbose --model "$AI_MODEL" \
"$prompt"
@@ -225,3 +225,9 @@ test('AppShell uses default memo (store-driven), not always-rerender comparator'
assert.match(appShellSource, /memo\s*\(\s*AppShellView\s*\)/);
assert.doesNotMatch(appShellSource, /memo\s*\(\s*AppShellView\s*,\s*\(\s*\)\s*=>\s*false\s*\)/);
});
test('terminal system detection routes through the owner of temporary hosts', () => {
const sideEffects = readFileSync(join(here, 'AppSideEffects.tsx'), 'utf8');
assert.match(hostSources['TerminalHost.tsx'], /updateHostDistro: handlers\.updateTerminalHostDistro/);
assert.match(sideEffects, /if \(ephemeralHostIds\.has\(hostId\)\) \{\s*setEphemeralHosts\([\s\S]*?applyEphemeralHostDistroUpdate[\s\S]*?return;\s*\}\s*updateHostDistro\(hostId, distro\)/);
});
+12 -1
View File
@@ -43,7 +43,7 @@ import { materializeHostProxyProfile } from '../../domain/proxyProfiles';
import { buildSshDeepLinkConnectionHost, buildSshDeepLinkEphemeralHost, buildSshDeepLinkEphemeralHostFromSaved, buildSshDeepLinkHostDraft, findSshDeepLinkHost, parseSshDeepLink } from '../../domain/sshDeepLink';
import { buildTelnetDeepLinkConnectionHost, buildTelnetDeepLinkEphemeralHostFromSaved, buildTelnetDeepLinkOpenHost, findTelnetDeepLinkHost, materializeTelnetDeepLinkMatchHost, parseTelnetDeepLink } from '../../domain/telnetDeepLink';
import { buildJmsDeepLinkEphemeralHost, isSupportedJmsProtocol, parseJmsDeepLink } from '../../domain/jmsDeepLink';
import { applyEphemeralHostsUpdate, splitHostsUpdateByEphemeral } from '../../domain/ephemeralHosts';
import { applyEphemeralHostDistroUpdate, applyEphemeralHostsUpdate, splitHostsUpdateByEphemeral } from '../../domain/ephemeralHosts';
import { resolveHostAuth } from '../../domain/sshAuth';
import { isEncryptedCredentialPlaceholder, stripSyncPayloadEncryptedCredentials } from '../../domain/credentials';
import {
@@ -202,6 +202,7 @@ export function AppSideEffects() {
addConnectionLog,
updateConnectionLog,
updateHostLastConnected,
updateHostDistro,
importDataFromString,
readPersistedHosts,
groupConfigs,
@@ -1751,6 +1752,14 @@ export function AppSideEffects() {
updateHosts(vaultHosts);
}, [ephemeralHostIds, updateHosts]);
const updateTerminalHostDistro = useCallback((hostId: string, distro: string) => {
if (ephemeralHostIds.has(hostId)) {
setEphemeralHosts((previous) => applyEphemeralHostDistroUpdate(previous, hostId, distro));
return;
}
updateHostDistro(hostId, distro);
}, [ephemeralHostIds, updateHostDistro]);
// Wrapper to create serial session with logging
const handleConnectSerial = useCallback((config: SerialConfig, options?: { charset?: string }) => {
const { username, hostname } = systemInfoRef.current;
@@ -1886,6 +1895,7 @@ export function AppSideEffects() {
handleTerminalDataCapture,
handleUpdateHostFromTerminal,
updateTerminalHosts,
updateTerminalHostDistro,
runSnippet: handleRunSnippet,
splitSessionWithCurrentShell,
toggleScriptsSidePanelRef,
@@ -1962,6 +1972,7 @@ export function AppSideEffects() {
handleTerminalDataCapture,
handleUpdateHostFromTerminal,
updateTerminalHosts,
updateTerminalHostDistro,
handleRunSnippet,
splitSessionWithCurrentShell,
handleEndSessionDrag,
+1 -4
View File
@@ -20,7 +20,6 @@ import {
import { useThemeRuntime, useTerminalAppearanceInjection } from '../../state/useThemeRuntime';
import {
useVaultSnapshot,
useVaultSnapshotActions,
} from '../../state/vaultSnapshotStore';
import { getAppHandlers, subscribeAppHandlers } from '../appHandlersBridge';
import { publishAppShellDomainSlice } from '../appShellPropsStore';
@@ -37,7 +36,6 @@ export function TerminalHost() {
const session = useSessionSnapshot();
const sessionActions = useSessionSnapshotActions();
const vault = useVaultSnapshot();
const vaultActions = useVaultSnapshotActions();
const terminalSettings = useTerminalSettingsStore();
const terminalSettingsActions = useTerminalSettingsActions();
const {
@@ -247,7 +245,7 @@ export function TerminalHost() {
terminalPaneMagnificationRef: handlers.terminalPaneMagnificationRef,
sftpPaneMagnificationRef: handlers.sftpPaneMagnificationRef,
toggleWorkspaceViewMode: sessionActions?.toggleWorkspaceViewMode,
updateHostDistro: vaultActions?.updateHostDistro,
updateHostDistro: handlers.updateTerminalHostDistro,
updateSplitSizes: sessionActions?.updateSplitSizes,
updateSessionFontSize: sessionActions?.updateSessionFontSize,
updateSessionRestoreCwd: sessionActions?.updateSessionRestoreCwd,
@@ -275,7 +273,6 @@ export function TerminalHost() {
terminalHosts,
terminalSettings,
terminalSettingsActions,
vaultActions,
]);
useLayoutEffect(() => {
+7
View File
@@ -497,6 +497,13 @@ export const enAiMessages: Messages = {
'ai.chat.menuFiles': 'Files',
'ai.chat.menuImage': 'Image',
'ai.chat.menuMentionHost': 'Mention Host',
'ai.chat.menuMentionNote': 'Mention Note',
'ai.chat.mentionNoteSearch': 'Search notes…',
'ai.chat.mentionNoteEmpty': 'No matching notes',
'ai.chat.mentionNoteUnavailable': 'This agent cannot read Vault notes in the current connection mode.',
'ai.chat.mentionNoteTooMany': 'These notes cannot all be referenced together. Please select fewer notes.',
'ai.chat.mentionNoteInvalid': '"{{title}}" could not be attached: the note has an invalid identifier.',
'ai.chat.untitledNote': 'Untitled note',
'ai.chat.menuUserSkills': 'User Skills',
'ai.chat.menuSlashCommands': 'Slash Commands',
'ai.chat.slashCommands': 'Slash commands',
+7 -2
View File
@@ -710,8 +710,13 @@ export const enVaultMessages: Messages = {
'hostDetails.section.address': 'Address',
'hostDetails.hostname.placeholder': 'IP or Hostname',
'hostDetails.section.general': 'General',
'hostDetails.os.title': 'Operating System',
'hostDetails.os.desc': 'Used for environment reporting and shell-aware behavior.',
'hostDetails.os.title': 'System identification',
'hostDetails.os.desc': 'Prefer automatic detection. Override only when the detected system is incorrect. Network Device Mode is configured separately.',
'hostDetails.os.auto': 'Automatic',
'hostDetails.os.unknown': 'Other / unknown',
'hostDetails.os.selection': 'System selection',
'hostDetails.os.current': 'Current system',
'hostDetails.os.freebsd': 'FreeBSD',
'hostDetails.os.linux': 'Linux',
'hostDetails.os.windows': 'Windows',
'hostDetails.os.macos': 'macOS',
+7
View File
@@ -497,6 +497,13 @@ export const esAiMessages: Messages = {
'ai.chat.menuFiles': 'Archivos',
'ai.chat.menuImage': 'Imagen',
'ai.chat.menuMentionHost': 'Mencionar host',
'ai.chat.menuMentionNote': 'Mencionar nota',
'ai.chat.mentionNoteSearch': 'Buscar notas…',
'ai.chat.mentionNoteEmpty': 'Sin notas coincidentes',
'ai.chat.mentionNoteUnavailable': 'Este agente no puede leer notas de la bóveda en el modo de conexión actual.',
'ai.chat.mentionNoteTooMany': 'No se pueden referenciar todas estas notas juntas. Selecciona menos notas.',
'ai.chat.mentionNoteInvalid': 'No se pudo adjuntar «{{title}}»: la nota tiene un identificador no válido.',
'ai.chat.untitledNote': 'Nota sin título',
'ai.chat.menuUserSkills': 'Habilidades de usuario',
'ai.chat.menuSlashCommands': 'Comandos de barra',
'ai.chat.slashCommands': 'Comandos de barra',
+7 -2
View File
@@ -710,8 +710,13 @@ export const esVaultMessages: Messages = {
'hostDetails.section.address': 'Dirección',
'hostDetails.hostname.placeholder': 'IP o nombre de host',
'hostDetails.section.general': 'General',
'hostDetails.os.title': 'Sistema operativo',
'hostDetails.os.desc': 'Se usa para informar del entorno y adaptar el comportamiento del shell.',
'hostDetails.os.title': 'Identificación del sistema',
'hostDetails.os.desc': 'Usa la detección automática. Selecciona un sistema solo si la detección es incorrecta. El modo de dispositivo de red se configura por separado.',
'hostDetails.os.auto': 'Automático',
'hostDetails.os.unknown': 'Otro / desconocido',
'hostDetails.os.selection': 'Selección del sistema',
'hostDetails.os.current': 'Sistema actual',
'hostDetails.os.freebsd': 'FreeBSD',
'hostDetails.os.linux': 'Linux',
'hostDetails.os.windows': 'Windows',
'hostDetails.os.macos': 'macOS',
+7
View File
@@ -449,6 +449,13 @@ export const ruAiMessages: Messages = {
'ai.chat.menuFiles': 'Файлы',
'ai.chat.menuImage': 'Изображение',
'ai.chat.menuMentionHost': 'Упомянуть хост',
'ai.chat.menuMentionNote': 'Упомянуть заметку',
'ai.chat.mentionNoteSearch': 'Поиск заметок…',
'ai.chat.mentionNoteEmpty': 'Нет подходящих заметок',
'ai.chat.mentionNoteUnavailable': 'Этот агент не может читать заметки хранилища в текущем режиме подключения.',
'ai.chat.mentionNoteTooMany': 'Невозможно сослаться на все эти заметки одновременно. Выберите меньше заметок.',
'ai.chat.mentionNoteInvalid': '«{{title}}» не удалось прикрепить: у заметки недопустимый идентификатор.',
'ai.chat.untitledNote': 'Заметка без названия',
'ai.chat.menuUserSkills': 'Пользовательские навыки',
'ai.chat.menuSlashCommands': 'Команды /',
'ai.chat.slashCommands': 'Команды /',
+7 -2
View File
@@ -747,8 +747,13 @@ export const ruVaultMessages: Messages = {
'hostDetails.section.address': 'Адрес',
'hostDetails.hostname.placeholder': 'IP или имя хоста',
'hostDetails.section.general': 'Общие',
'hostDetails.os.title': 'Операционная система',
'hostDetails.os.desc': 'Используется для сведений о среде и адаптации командной оболочки.',
'hostDetails.os.title': 'Определение системы',
'hostDetails.os.desc': 'Используйте автоматическое определение. Укажите систему вручную только при ошибке. Режим сетевого устройства настраивается отдельно.',
'hostDetails.os.auto': 'Автоматически',
'hostDetails.os.unknown': 'Другая / неизвестно',
'hostDetails.os.selection': 'Выбор системы',
'hostDetails.os.current': 'Текущая система',
'hostDetails.os.freebsd': 'FreeBSD',
'hostDetails.os.linux': 'Linux',
'hostDetails.os.windows': 'Windows',
'hostDetails.os.macos': 'macOS',
+7
View File
@@ -497,6 +497,13 @@ export const zhCNAiMessages: Messages = {
'ai.chat.menuFiles': '文件',
'ai.chat.menuImage': '图片',
'ai.chat.menuMentionHost': '提及主机',
'ai.chat.menuMentionNote': '提及笔记',
'ai.chat.mentionNoteSearch': '搜索笔记…',
'ai.chat.mentionNoteEmpty': '没有匹配的笔记',
'ai.chat.mentionNoteUnavailable': '当前连接方式下,此 AI 无法读取保险箱笔记。',
'ai.chat.mentionNoteTooMany': '这些笔记无法一起引用,请减少选择。',
'ai.chat.mentionNoteInvalid': '无法附加「{{title}}」:笔记的标识符无效。',
'ai.chat.untitledNote': '未命名笔记',
'ai.chat.menuUserSkills': '用户 Skills',
'ai.chat.menuSlashCommands': '快捷命令',
'ai.chat.slashCommands': '快捷命令',
+7 -2
View File
@@ -133,8 +133,13 @@ export const zhCNVaultMessages: Messages = {
'hostDetails.section.address': '地址',
'hostDetails.hostname.placeholder': 'IP 或 主机名',
'hostDetails.section.general': '通用',
'hostDetails.os.title': '操作系统',
'hostDetails.os.desc': '用于环境信息和适配对应的命令行行为。',
'hostDetails.os.title': '系统识别',
'hostDetails.os.desc': '默认自动识别,仅在识别错误时手动指定。网络设备模式单独设置。',
'hostDetails.os.auto': '自动识别',
'hostDetails.os.unknown': '其他 / 未知',
'hostDetails.os.selection': '识别方式',
'hostDetails.os.current': '当前系统',
'hostDetails.os.freebsd': 'FreeBSD',
'hostDetails.os.linux': 'Linux',
'hostDetails.os.windows': 'Windows',
'hostDetails.os.macos': 'macOS',
+7
View File
@@ -496,6 +496,13 @@ export const zhTWAiMessages: Messages = {
'ai.chat.menuFiles': '檔案',
'ai.chat.menuImage': '圖片',
'ai.chat.menuMentionHost': '提及主機',
'ai.chat.menuMentionNote': '提及筆記',
'ai.chat.mentionNoteSearch': '搜尋筆記…',
'ai.chat.mentionNoteEmpty': '沒有符合的筆記',
'ai.chat.mentionNoteUnavailable': '目前連線方式下,此 AI 無法讀取保險箱筆記。',
'ai.chat.mentionNoteTooMany': '這些筆記無法一起引用,請減少選擇。',
'ai.chat.mentionNoteInvalid': '無法附加「{{title}}」:筆記的識別碼無效。',
'ai.chat.untitledNote': '未命名筆記',
'ai.chat.menuUserSkills': '使用者 Skills',
'ai.chat.menuSlashCommands': '快捷指令',
'ai.chat.slashCommands': '快捷指令',
+7 -2
View File
@@ -132,8 +132,13 @@ export const zhTWVaultMessages: Messages = {
'hostDetails.section.address': '位址',
'hostDetails.hostname.placeholder': 'IP 或 主機名',
'hostDetails.section.general': '通用',
'hostDetails.os.title': '作業系統',
'hostDetails.os.desc': '用於環境資訊和配合對應的命令列行為。',
'hostDetails.os.title': '系統識別',
'hostDetails.os.desc': '預設自動辨識,僅在辨識錯誤時手動指定。網路裝置模式另行設定。',
'hostDetails.os.auto': '自動辨識',
'hostDetails.os.unknown': '其他 / 未知',
'hostDetails.os.selection': '辨識方式',
'hostDetails.os.current': '目前系統',
'hostDetails.os.freebsd': 'FreeBSD',
'hostDetails.os.linux': 'Linux',
'hostDetails.os.windows': 'Windows',
'hostDetails.os.macos': 'macOS',
@@ -39,6 +39,108 @@ const host = (id: string, label: string, hostname = label): Host => ({
protocol: "ssh",
} as Host);
for (const retainedStatus of [undefined, "interrupted", "failed", "paused"] as const) {
for (const newerPause of retainedStatus === "paused" ? [false, true] : [false]) {
const batchExistingIdentity = retainedStatus !== undefined;
test(`superseded folder child settles when its completion was compacted into the parent: retained=${retainedStatus ?? "none"}, newerPause=${newerPause}`, async (t) => {
const { sftpTransferCenterStore } = await import("../sftpTransferCenterStore");
const previousLocalStorage = Object.getOwnPropertyDescriptor(globalThis, "localStorage");
Object.defineProperty(globalThis, "localStorage", {
configurable: true,
value: { getItem: () => null, setItem: () => {}, removeItem: () => {} },
});
const originalGet = netcattyBridge.get;
let abort = false;
let running: Promise<unknown> | undefined;
let childId: string | undefined;
const parent: TransferTask = {
id: "superseded-compacted-folder", fileName: "folder",
sourcePath: "/local/folder", targetPath: "/remote/folder",
sourceConnectionId: "local", targetConnectionId: "expired-sftp",
targetHostId: "h1", direction: "upload", status: "transferring",
totalBytes: 1, transferredBytes: 0, speed: 0, startTime: Date.now(),
isDirectory: true, progressMode: "files", reconnectRequired: true,
};
t.after(async () => {
abort = true;
await running;
netcattyBridge.get = originalGet;
sftpTransferCenterStore.patchTask(parent.id, { status: "completed" });
sftpTransferCenterStore.dismiss(parent.id);
if (previousLocalStorage) Object.defineProperty(globalThis, "localStorage", previousLocalStorage);
else Reflect.deleteProperty(globalThis, "localStorage");
resetDedicatedSessionOpenGateForTests();
});
const persisted: TransferTask = {
...parent, id: "retained-child", parentTaskId: parent.id, isDirectory: false,
sourcePath: "/local/folder/a.bin", targetPath: "/remote/folder/a.bin",
totalBytes: 10, sourceLastModified: 1, status: retainedStatus ?? "interrupted",
directoryEntryIndex: 0, directoryEntryIdentity: "a".repeat(64),
};
const { createDedicatedResumeChildUpdateBatcher } = await import("../../app/dedicatedResumeProgress");
// Exercise the real large-history batching branch with one relevant retained row.
const childBatcher = createDedicatedResumeChildUpdateBatcher({
getTaskCount: () => 4096,
hasTask: (id) => id === persisted.id,
upsertTasks: (updates) => sftpTransferCenterStore.upsertTasks(updates),
});
sftpTransferCenterStore.publishOwner("compacted-owner", [parent, ...(batchExistingIdentity ? [persisted] : [])]);
(netcattyBridge as { get: () => unknown }).get = () => ({
openSftp: async () => "dedicated-sftp", closeSftp: async () => {},
listLocalTree: async () => [{
localPath: "/local/folder/a.bin", relativePath: "a.bin", type: "file", size: 10, lastModified: 1,
}],
mkdirSftp: async () => {},
statLocal: async () => {
if (newerPause) sftpTransferCenterStore.patchTask(persisted.id, { status: "paused", lifecycleEpoch: 9 });
return { size: 10, lastModified: 1 };
},
startStreamTransfer: async (options: {
transferId: string; parentTaskId?: string; directoryEntryIndex?: number; directoryEntryIdentity?: string;
}) => {
childId = options.transferId;
// Main-process lifecycle events carry the stream's metadata, not queued UI updates.
sftpTransferCenterStore.ingestBackgroundEvent({
type: "started", transferId: childId, lifecycleEpoch: 0,
parentTaskId: options.parentTaskId,
directoryEntryIndex: options.directoryEntryIndex,
directoryEntryIdentity: options.directoryEntryIdentity,
});
// A newer same-id owner completes before this older invocation rejoins.
sftpTransferCenterStore.ingestBackgroundEvent({
type: "completed", transferId: childId, transferred: 10, totalBytes: 10, lifecycleEpoch: 0,
});
return { superseded: true };
},
});
running = resumeTransferWithDedicatedSession(parent, {
hosts: [host("h1", "box", "1.2.3.4")], keys: [], identities: [],
}, undefined, {
children: batchExistingIdentity ? [persisted] : [], shouldAbort: () => abort,
onChildUpdate: (child) => batchExistingIdentity
? childBatcher.push(child)
: sftpTransferCenterStore.publishOwner("compacted-owner", [parent, child]),
});
const result = await Promise.race([
running,
new Promise<"still-waiting">((resolve) => setTimeout(() => resolve("still-waiting"), 450)),
]);
if (newerPause) {
assert.equal(result, "still-waiting", "a later pause must still hold fresh recovery");
assert.equal(childId, undefined, "newer paused child must not start");
assert.equal(sftpTransferCenterStore.getTask(persisted.id)?.lifecycleEpoch, 9);
return;
}
assert.notEqual(result, "still-waiting", "completed compacted child must not leave its folder waiting forever");
assert.ok(childId);
assert.equal(sftpTransferCenterStore.getTask(childId), undefined, "completed row was compacted");
assert.equal(sftpTransferCenterStore.getTask(parent.id)?.directoryResumeCheckpoint?.completedEntries, 1);
assert.notEqual(result, "still-waiting", "completed compacted child must not leave its folder waiting forever");
assert.equal((result as { success: boolean }).success, true);
});
}
}
test("resolveDirectoryResumeTargetRoot prefers staged replace path", () => {
assert.equal(
resolveDirectoryResumeTargetRoot({
@@ -97,10 +199,11 @@ test("persisted resume child lookup handles 50,000 retained rows in linear time"
assert.ok(Date.now() - startedAt < 2_000, "50k retained-child lookup should stay linear");
});
test("resolveHostForTransferEndpoint prefers id then label", () => {
test("resolveHostForTransferEndpoint requires recorded id and supports unique legacy names", () => {
const hosts = [host("id-1", "CI-Build-01", "ci-01.example")];
assert.equal(resolveHostForTransferEndpoint(hosts, "id-1", "other")?.id, "id-1");
assert.equal(resolveHostForTransferEndpoint(hosts, "missing", "CI-Build-01")?.id, "id-1");
assert.equal(resolveHostForTransferEndpoint(hosts, "missing", "CI-Build-01"), null);
assert.equal(resolveHostForTransferEndpoint(hosts, undefined, "CI-Build-01")?.id, "id-1");
assert.equal(resolveHostForTransferEndpoint(hosts, undefined, "ci-01.example")?.id, "id-1");
assert.equal(resolveHostForTransferEndpoint(hosts, "missing", "gone"), null);
});
@@ -1998,3 +2101,33 @@ test("corrupted replace-stage history cannot delete an unrelated directory", asy
resetDedicatedSessionOpenGateForTests();
}
});
for (const scenario of ["deleted-id", "ambiguous-legacy-label"] as const) {
test(`restart upload does not select a different server by ${scenario}`, async (t) => {
const originalGet = netcattyBridge.get;
t.after(() => { netcattyBridge.get = originalGet; });
const opened: string[] = [];
let uploads = 0;
(netcattyBridge as { get: () => unknown }).get = () => ({
openSftp: async (options: { hostname: string }) => { opened.push(options.hostname); return "new-channel"; },
closeSftp: async () => {},
statLocal: async () => ({ size: 100, lastModified: 123 }),
startStreamTransfer: async () => { uploads++; return { transferId: "wrong-host-resume" }; },
});
const hosts = scenario === "deleted-id"
? [host("different-id", "production", "different.example")]
: [host("first-id", "production", "first.example"), host("second-id", "production", "second.example")];
const result = await resumeTransferWithDedicatedSession({
id: "wrong-host-resume", fileName: "deployment.zip",
sourcePath: "/fixture/deployment.zip", targetPath: "/srv/deployment.zip",
sourceConnectionId: "local", targetConnectionId: "right-expired",
targetHostId: scenario === "deleted-id" ? "deleted-original-id" : undefined,
targetHostLabel: "production", direction: "upload", status: "interrupted",
totalBytes: 100, transferredBytes: 20, checkpointBytes: 20,
speed: 0, startTime: 1, isDirectory: false, reconnectRequired: true,
}, {hosts, keys: [], identities: []});
assert.equal(result.success, false, "resume must stop instead of selecting another endpoint by display name");
assert.deepEqual(opened, [], "must not authenticate to an unproven replacement host");
assert.equal(uploads, 0);
});
}
@@ -1,3 +1,4 @@
import { runTransferAndWaitForOwner } from "./waitForTransferOwner";
import type { Host, Identity, KnownHost, SSHKey, TerminalSettings, TransferTask } from "../../../domain/models";
import { validateTransferResumeSource } from "../../../domain/sftpTransferCenter";
import { STORAGE_KEY_SFTP_TRANSFER_CONCURRENCY } from "../../../infrastructure/config/storageKeys";
@@ -95,8 +96,8 @@ export function resetDedicatedSessionOpenGateForTests(): void {
}
/**
* Resolve a vault host for a transfer endpoint. Prefer stable id; fall back to
* label/hostname when the id is stale after vault edits or older task records.
* Resolve the recorded endpoint, never a same-named replacement. Legacy tasks
* without an id may use label/hostname only when exactly one host matches.
*/
export function resolveHostForTransferEndpoint(
hosts: readonly Host[],
@@ -104,16 +105,16 @@ export function resolveHostForTransferEndpoint(
hostLabel?: string,
): Host | null {
if (hostId) {
const byId = hosts.find((host) => host.id === hostId);
if (byId) return byId;
return hosts.find((host) => host.id === hostId) ?? null;
}
const needle = (hostLabel || "").trim().toLowerCase();
if (!needle) return null;
return hosts.find((host) => {
const matches = hosts.filter((host) => {
const label = (host.label || "").trim().toLowerCase();
const hostname = (host.hostname || "").trim().toLowerCase();
return label === needle || hostname === needle;
}) ?? null;
});
return matches.length === 1 ? matches[0] : null;
}
export type OpenTransferSftpSessionOptions = {
@@ -1000,6 +1001,11 @@ async function resumeDirectoryWithDedicatedSession(
onProgress?: (progress: DedicatedResumeProgress) => void,
options?: DedicatedResumeOptions,
): Promise<DedicatedResumeResult> {
// Fresh recovery authorizes only pauses already present at its entry. Keep
// row references so a newer cross-window pause invalidates that permission.
const pausedAtResume = new Map(sftpTransferCenterStore.getSnapshot().tasks
.filter((child) => child.parentTaskId === parent.id && child.status === "paused")
.map((child) => [child.id, child]));
const bridge = netcattyBridge.get();
if (!bridge?.startStreamTransfer) {
return { success: false, error: "Transfer bridge unavailable" };
@@ -1247,8 +1253,13 @@ async function resumeDirectoryWithDedicatedSession(
if (options?.shouldAbort?.()) throw new Error("Transfer cancelled");
options?.onChildUpdate?.(childBase);
const streamResult = await bridge.startStreamTransfer!({
const streamResult = await runTransferAndWaitForOwner(childBase, () => bridge.startStreamTransfer!({
transferId: childId,
// Lifecycle events must carry the current identity even while
// large-history renderer child updates remain batched.
parentTaskId: childBase.parentTaskId,
directoryEntryIndex: childBase.directoryEntryIndex,
directoryEntryIdentity: childBase.directoryEntryIdentity,
sourcePath: file.sourcePath,
targetPath: file.targetPath,
sourceType,
@@ -1267,19 +1278,9 @@ async function resumeDirectoryWithDedicatedSession(
uploadCheckpointBytes: childBase.uploadCheckpointBytes,
sourceFingerprint: childBase.sourceFingerprint,
skipAdmission: true,
});
}), () => options?.shouldAbort?.() === true, pausedAtResume.get(childId));
if (streamResult?.superseded === true) {
for (;;) {
if (options?.shouldAbort?.()) throw new Error("Transfer cancelled");
const latest = sftpTransferCenterStore.getTask(childBase.id);
const status = latest?.status;
if (status === "completed") break;
if (status === "failed") throw new Error(latest?.error || "Transfer failed");
if (status === "cancelled") throw new Error("Transfer cancelled");
await new Promise((resolve) => setTimeout(resolve, 200));
}
} else if (streamResult?.error || streamResult?.cancelled) {
if (streamResult?.error || streamResult?.cancelled) {
throw new Error(streamResult.error || "Transfer cancelled");
}
@@ -0,0 +1,100 @@
import assert from "node:assert/strict";
import test from "node:test";
import React from "react";
import { act, create, type ReactTestRenderer } from "react-test-renderer";
import { useSftpTransfers } from "./useSftpTransfers";
import { transferRuntime } from "./transferRuntime";
import { sftpTransferCenterStore } from "../sftpTransferCenterStore";
import { releaseTransferPauseTree } from "./transferPauseLatch";
for (const closePanel of [false, true]) {
test(`direct folder download resumes discovery with panel ${closePanel ? "closed" : "open"}`, async () => {
const previousWindow = globalThis.window;
const previousStorage = globalThis.localStorage;
const globals = globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean };
const previousAct = globals.IS_REACT_ACT_ENVIRONMENT;
globals.IS_REACT_ACT_ENVIRONMENT = true;
let finishListing!: () => void;
const listingGate = new Promise<void>((resolve) => { finishListing = resolve; });
let listingStarted!: () => void;
const started = new Promise<void>((resolve) => { listingStarted = resolve; });
let listCalls = 0;
let maxCount = 0;
const unsubscribe = sftpTransferCenterStore.subscribe(() => {
const root = sftpTransferCenterStore.getOwnerTasks("direct-runtime-owner").find((row) => !row.parentTaskId);
maxCount = Math.max(maxCount, root?.transferredBytes ?? 0);
});
(globalThis as { window?: unknown }).window = { netcatty: {
mkdirLocal: async () => undefined,
statLocal: async () => undefined,
startStreamTransfer: async (options: { transferId: string }) => {
sftpTransferCenterStore.ingestBackgroundEvent({ type: "completed", transferId: options.transferId, transferred: 1, totalBytes: 1, lifecycleEpoch: 0 });
return {};
},
resumeTransfer: async () => ({ success: false, reason: "Transfer is no longer active" }),
pauseTransfer: async () => ({ success: false, reason: "Transfer is no longer active" }),
} };
(globalThis as { localStorage?: unknown }).localStorage = {
getItem: () => null, setItem: () => undefined, removeItem: () => undefined,
};
let ops: ReturnType<typeof useSftpTransfers> | undefined;
let renderer: ReactTestRenderer | undefined;
let running: Promise<unknown> | undefined;
let rootId = "";
let reconnects = 0;
sftpTransferCenterStore.setDedicatedResumeHandler(async () => {
reconnects++;
return { success: false, error: "unexpected reconnect" };
});
function Probe() {
ops = useSftpTransfers({
ownerId: "direct-runtime-owner", getActivePane: () => null,
getPaneByConnectionId: () => null, getTabByConnectionId: () => null,
updateTab: () => undefined, refresh: async () => undefined,
clearCacheForConnection: () => undefined, handleSessionError: () => undefined,
sftpSessionsRef: { current: new Map() }, connectionCacheKeyMapRef: { current: new Map() },
listLocalFiles: async () => [], listRemoteFiles: async () => {
listCalls++; listingStarted(); await listingGate;
return ["one.txt", "two.txt"].map((name) => ({ name, type: "file" as const, size: 1, sizeFormatted: "1 B", lastModified: 0, lastModifiedFormatted: "" }));
},
});
return null;
}
try {
await act(async () => { renderer = create(React.createElement(Probe)); });
assert.ok(ops);
await act(async () => {
running = ops!.downloadToLocal({ fileName: "folder", sourcePath: "/folder", targetPath: "/download/folder-runtime", sftpId: "sftp", connectionId: "ssh", sourceHostId: "host", sourceHostLabel: "Test", isDirectory: true });
await started;
});
const root = sftpTransferCenterStore.getOwnerTasks("direct-runtime-owner")[0];
assert.ok(root); rootId = root.id;
assert.equal(root.status, "transferring", "live root must be visible in In Progress");
assert.equal(transferRuntime.isWalkInFlight(rootId), true, "direct download registers before discovery");
await act(async () => { await ops!.pauseTransfer(rootId); });
assert.equal(sftpTransferCenterStore.getTask(rootId)?.status, "paused");
if (closePanel) await act(async () => { renderer?.unmount(); renderer = undefined; });
await act(async () => { await ops!.resumeTransfer(rootId); });
assert.equal(sftpTransferCenterStore.getTask(rootId)?.status, "transferring", "resume must rejoin live directory discovery even without an active child stream");
assert.equal(transferRuntime.isWalkInFlight(rootId), true);
assert.equal(reconnects, 0);
finishListing();
await act(async () => { assert.equal(await running, "completed"); });
assert.equal(sftpTransferCenterStore.getTask(rootId)?.status, "completed");
assert.equal(sftpTransferCenterStore.getTask(rootId)?.transferredBytes, 2);
assert.ok(maxCount <= 2, `completed children must be counted once, observed ${maxCount}`);
assert.equal(listCalls, 1);
assert.equal(transferRuntime.isWalkInFlight(rootId), false);
} finally {
releaseTransferPauseTree(rootId, []);
finishListing();
await act(async () => { await running; renderer?.unmount(); });
if (rootId) sftpTransferCenterStore.dismiss(rootId);
unsubscribe();
sftpTransferCenterStore.setDedicatedResumeHandler(null);
(globalThis as { window?: unknown }).window = previousWindow;
(globalThis as { localStorage?: unknown }).localStorage = previousStorage;
globals.IS_REACT_ACT_ENVIRONMENT = previousAct;
}
});
}
@@ -51,6 +51,46 @@ function createHost(initial: TransferTask[], bridge?: TransferControlHost["getBr
};
}
test("an older pause response must not resume a newer pause", async (t) => {
t.after(resetTransferPauseLatchesForTests);
let finishFirstPause!: (value: { success: boolean; lifecycleEpoch: number }) => void;
let calls = 0;
let backendPaused = true;
const { host, getTasks } = createHost([makeTask("overlapping-pause")], () => ({
pauseTransfer: async () => {
backendPaused = true;
if (++calls === 1) return new Promise((resolve) => { finishFirstPause = resolve; });
return { success: true, lifecycleEpoch: 2 };
},
resumeTransfer: async () => {
backendPaused = false;
return { success: true, lifecycleEpoch: 3 };
},
}));
const first = softPauseTransfer(host, "overlapping-pause");
await softPauseTransfer(host, "overlapping-pause");
finishFirstPause({ success: true, lifecycleEpoch: 1 });
await first;
assert.equal(getTasks()[0].status, "paused");
assert.equal(backendPaused, true, "late pause acknowledgement must not restart file writes");
});
test("a delayed resume response must not repaint a newer pause", async (t) => {
t.after(resetTransferPauseLatchesForTests);
let finishResume!: (value: { success: boolean; lifecycleEpoch: number }) => void;
const { host, getTasks } = createHost([makeTask("resume-then-pause", "paused")], () => ({
resumeTransfer: () => new Promise((resolve) => { finishResume = resolve; }),
pauseTransfer: async () => ({ success: true, lifecycleEpoch: 3 }),
}));
const resume = softResumeTransfer(host, "resume-then-pause");
await softPauseTransfer(host, "resume-then-pause");
finishResume({ success: true, lifecycleEpoch: 2 });
await resume;
assert.equal(isTransferPauseLatched("resume-then-pause"), true);
assert.equal(getTasks()[0].status, "paused", "latest user intent must win over an older response");
assert.equal(getTasks()[0].lifecycleEpoch, 3);
});
test("softPauseTransfer latches and paints paused for a live directory walk without a panel", async () => {
resetTransferPauseLatchesForTests();
resetTransferWalkRegistryForTests();
@@ -245,3 +285,238 @@ test("directory softResume stamps bridge epoch only on successIds; queued siblin
resetTransferPauseLatchesForTests();
resetTransferWalkRegistryForTests();
});
for (const isDirectory of [false, true]) {
for (const newerLocalPause of [false, true]) {
test(`cross-window resume releases ${isDirectory ? "folder" : "file"} latches unless local pause is newer: ${newerLocalPause}`, async (t) => {
t.after(resetTransferPauseLatchesForTests);
let finish!: (result: { success: boolean; superseded: true; supersededBy: "resume" }) => void;
let pauses = 0;
const id = `remote-resume-${isDirectory}-${newerLocalPause}`;
const initial: TransferTask[] = [{ ...makeTask(id), isDirectory }];
if (isDirectory) initial.push({ ...makeTask(`${id}-child`), parentTaskId: id });
const { host, getTasks } = createHost(initial, () => ({
pauseTransfer: () => ++pauses === 1
? new Promise((resolve) => { finish = resolve; })
: Promise.resolve({ success: true, lifecycleEpoch: 9 }),
}));
const pending = softPauseTransfer(host, id);
host.setTasks(getTasks().map(task => isDirectory && task.id === id ? task : ({ ...task, status: "transferring", lifecycleEpoch: 8 })));
if (newerLocalPause) await softPauseTransfer(host, id);
finish({ success: false, superseded: true, supersededBy: "resume" });
await pending;
await new Promise((resolve) => setImmediate(resolve));
assert.equal(isTransferPauseLatched(id), newerLocalPause, "authoritative resume must release the old root pause barrier");
assert.equal(getTasks().find(task => task.id === id)?.status, newerLocalPause ? "paused" : "transferring");
if (isDirectory) assert.equal(isTransferPauseLatched(`${id}-child`), newerLocalPause);
});
}
}
for (const action of ["pause", "resume"] as const) {
test(`superseded cross-window ${action} preserves authoritative paused state`, async (t) => {
t.after(resetTransferPauseLatchesForTests);
let finish!: (result: { success: boolean; superseded: boolean }) => void;
const deferred = () => new Promise<{ success: boolean; superseded: boolean }>((resolve) => { finish = resolve; });
const { host, getTasks } = createHost([makeTask(`cross-window-${action}`, action === "pause" ? "transferring" : "paused")], () => ({ pauseTransfer: deferred, resumeTransfer: deferred }));
const id = getTasks()[0].id;
const operation = action === "pause" ? softPauseTransfer(host, id) : softResumeTransfer(host, id);
// A global event from another window changes lifecycle, not this window's local control epoch.
host.setTasks(getTasks().map(task => ({ ...task, status: "paused", lifecycleEpoch: 8 })));
finish({ success: false, superseded: true });
const result = await operation;
assert.equal(getTasks()[0].status, "paused");
assert.equal(getTasks()[0].lifecycleEpoch, 8);
if (action === "pause") assert.equal(isTransferPauseLatched(id), true);
else assert.deepEqual(result, { handled: true }, "obsolete response must not trigger dedicated recovery");
});
}
for (const isDirectory of [false, true]) {
test(`remote pause restores released ${isDirectory ? "folder" : "file"} barriers after stale resume`, async (t) => {
t.after(resetTransferPauseLatchesForTests);
const id = `remote-pause-${isDirectory}`;
const tasks: TransferTask[] = [{ ...makeTask(id, "paused"), isDirectory }];
if (isDirectory) tasks.push({ ...makeTask(`${id}-child`, "paused"), parentTaskId: id });
const { host, getTasks } = createHost(tasks, () => ({
resumeTransfer: async () => ({ success: false, superseded: true, supersededBy: "pause" }),
}));
assert.deepEqual(await softResumeTransfer(host, id), { handled: true });
assert.equal(isTransferPauseLatched(id), true);
if (isDirectory) assert.equal(isTransferPauseLatched(`${id}-child`), true);
assert.equal(getTasks()[0].status, "paused");
});
}
test("a child-only remote resume does not release the folder pause", async (t) => {
t.after(resetTransferPauseLatchesForTests);
const { host } = createHost([
{ ...makeTask("mixed-root"), isDirectory: true },
{ ...makeTask("mixed-one"), parentTaskId: "mixed-root" },
{ ...makeTask("mixed-two"), parentTaskId: "mixed-root" },
], () => ({ pauseTransfer: async id => id === "mixed-one"
? { success: false, superseded: true, supersededBy: "resume" }
: { success: true } }));
await softPauseTransfer(host, "mixed-root");
await new Promise(resolve => setImmediate(resolve));
assert.equal(isTransferPauseLatched("mixed-root"), true);
assert.equal(isTransferPauseLatched("mixed-two"), true);
});
test("directory resume joins successful and remotely resumed children", async (t) => {
t.after(resetTransferPauseLatchesForTests);
const id = "mixed-success-resume";
const { host, getTasks } = createHost([
{ ...makeTask(id, "paused"), isDirectory: true },
{ ...makeTask(`${id}-one`, "paused"), parentTaskId: id },
{ ...makeTask(`${id}-two`, "paused"), parentTaskId: id },
], () => ({ resumeTransfer: async childId => childId === `${id}-one`
? { success: true, lifecycleEpoch: 3 }
: { success: false, superseded: true, supersededBy: "resume" } }));
assert.deepEqual(await softResumeTransfer(host, id), { handled: true });
assert.equal(getTasks().find(task => task.id === id)?.status, "transferring");
assert.equal(isTransferPauseLatched(id), false);
});
for (const newerPause of [false, true]) {
test(`directory live resume rejection is ignored only when superseded: ${newerPause}`, async (t) => {
t.after(resetTransferPauseLatchesForTests);
t.after(resetTransferWalkRegistryForTests);
const id = `rejected-folder-resume-${newerPause}`;
registerTransferWalk(id);
let rejectResume!: (error: Error) => void;
let backendPaused = true;
const { host, getTasks } = createHost([
{ ...makeTask(id, "paused"), isDirectory: true },
{ ...makeTask(`${id}-child`, "paused"), parentTaskId: id },
], () => ({
resumeTransfer: () => new Promise((_, reject) => { rejectResume = reject; }),
pauseTransfer: async () => { backendPaused = true; return { success: true, lifecycleEpoch: 4 }; },
}));
const running = softResumeTransfer(host, id);
if (newerPause) await softPauseTransfer(host, id);
rejectResume(new Error("resume transport disconnected"));
const result = await running;
assert.equal(result.handled, newerPause);
if (!newerPause) assert.match(result.reason || "", /resume transport disconnected/);
assert.equal(backendPaused, true);
assert.equal(getTasks().find(task => task.id === id)?.status, "paused");
if (newerPause) assert.equal(isTransferPauseLatched(id), true);
});
}
for (const newerResume of [false, true]) {
test(`partial folder resume rejection reports running and paused children unless superseded: ${newerResume}`, async (t) => {
t.after(resetTransferPauseLatchesForTests);
const id = `partial-reject-${newerResume}`;
const successfulId = `${id}-one`;
let rejectResume!: (error: Error) => void;
let successfulBackendPaused = true;
let round = 0;
let rollbackCalls = 0;
const { host, getTasks } = createHost([
{ ...makeTask(id, "paused"), isDirectory: true },
{ ...makeTask(successfulId, "paused"), parentTaskId: id },
{ ...makeTask(`${id}-two`, "paused"), parentTaskId: id },
], () => ({
resumeTransfer: async childId => {
if (childId === successfulId) { successfulBackendPaused = false; return { success: true }; }
if (round > 0) return { success: true };
return new Promise((_, reject) => { rejectResume = reject; });
},
pauseTransfer: async childId => {
rollbackCalls++;
if (childId === successfulId) successfulBackendPaused = true;
return { success: true };
},
}));
const running = softResumeTransfer(host, id);
if (newerResume) { round++; await softResumeTransfer(host, id); }
rejectResume(new Error("second child IPC rejected"));
const result = await running;
assert.equal(result.handled, true);
assert.equal(successfulBackendPaused, false, "successful child keeps running after partial resume");
assert.equal(rollbackCalls, 0, "partial reporting must not introduce compensating controls");
assert.equal(getTasks()[0].status, "transferring", "root must report the successful child still running");
assert.equal(isTransferPauseLatched(id), false);
const rejected = getTasks().find(task => task.id === `${id}-two`);
assert.equal(rejected?.status, newerResume ? "transferring" : "paused");
assert.equal(isTransferPauseLatched(`${id}-two`), !newerResume);
if (!newerResume) assert.match(rejected?.error || "", /second child IPC rejected/);
});
}
for (const resolvedFailure of [false, true]) {
test(`remote-resumed child remains visibly running when sibling resume fails: resolved=${resolvedFailure}`, async (t) => {
t.after(resetTransferPauseLatchesForTests);
const id = "remote-partial-reject";
const runningId = `${id}-one`;
const rejectedId = `${id}-two`;
const { host, getTasks } = createHost([
{ ...makeTask(id, "paused"), isDirectory: true },
{ ...makeTask(runningId, "paused"), parentTaskId: id },
{ ...makeTask(rejectedId, "paused"), parentTaskId: id },
], () => ({ resumeTransfer: async childId => {
if (childId === rejectedId) {
if (resolvedFailure) return { success: false, reason: "sibling resume rejected" };
throw new Error("sibling resume rejected");
}
host.setTasks(getTasks().map(task => task.id === runningId ? { ...task, status: "transferring", lifecycleEpoch: 8 } : task));
return { success: false, superseded: true, supersededBy: "resume" };
} }));
assert.equal((await softResumeTransfer(host, id)).handled, true);
assert.equal(getTasks()[0].status, "transferring");
assert.equal(isTransferPauseLatched(id), false);
assert.equal(getTasks().find(task => task.id === runningId)?.status, "transferring");
assert.equal(getTasks().find(task => task.id === runningId)?.lifecycleEpoch, 8);
assert.equal(getTasks().find(task => task.id === rejectedId)?.status, "paused");
assert.equal(isTransferPauseLatched(rejectedId), true);
assert.match(getTasks().find(task => task.id === rejectedId)?.error || "", /sibling resume rejected/);
});
}
for (const newerResume of [false, true]) {
test(`resolved verification failure holds live folder unless newer resume won: ${newerResume}`, async (t) => {
t.after(resetTransferPauseLatchesForTests);
t.after(resetTransferWalkRegistryForTests);
const id = `resolved-verification-${newerResume}`;
registerTransferWalk(id);
let finish!: (value: { success: boolean; reason: string }) => void;
let calls = 0;
const { host, getTasks } = createHost([
{ ...makeTask(id, "paused"), isDirectory: true },
{ ...makeTask(`${id}-child`, "paused"), parentTaskId: id },
], () => ({ resumeTransfer: () => ++calls === 1
? new Promise(resolve => { finish = resolve; }) : Promise.resolve({ success: true }) }));
const running = softResumeTransfer(host, id);
if (newerResume) await softResumeTransfer(host, id);
finish({ success: false, reason: "Could not verify the source file for resume" });
const result = await running;
assert.equal(result.handled, newerResume);
if (!newerResume) assert.match(result.reason || "", /verify the source file/);
assert.equal(getTasks()[0].status, newerResume ? "transferring" : "paused");
assert.equal(isTransferPauseLatched(id), !newerResume);
assert.equal(isTransferPauseLatched(`${id}-child`), !newerResume);
});
}
test("folder resume releases a completed child compacted while pause was draining", async (t) => {
t.after(resetTransferPauseLatchesForTests);
t.after(resetTransferWalkRegistryForTests);
const root = { ...makeTask("compacted-pause-root"), isDirectory: true };
const child = { ...makeTask("compacted-pause-child"), parentTaskId: root.id };
registerTransferWalk(root.id);
const { host, getTasks } = createHost([root, child], () => ({
pauseTransfer: async () => ({ success: true }),
resumeTransfer: async () => ({ success: false, reason: "Transfer is no longer active" }),
}));
await softPauseTransfer(host, root.id);
await new Promise<void>((resolve) => setImmediate(resolve));
assert.equal(isTransferPauseLatched(child.id), true);
// Completion during soft-drain compacts this row before the user resumes.
host.setTasks(getTasks().filter((task) => task.id !== child.id));
const resumed = await softResumeTransfer(host, root.id);
assert.equal(resumed.handled, true);
assert.equal(isTransferPauseLatched(child.id), false, "the worker still waits on this child even though its history row is gone");
});
@@ -20,6 +20,7 @@ import {
isTransferControlEpochCurrent,
} from "./transferControlEpoch";
import {
isTransferOrRootPauseLatched,
latchTransferPauseTree,
releaseTransferPauseTree,
} from "./transferPauseLatch";
@@ -28,6 +29,9 @@ import { isTransferWalkInFlight } from "./transferWalkRegistry";
export type TransferControlBridge = {
pauseTransfer?: (id: string) => Promise<{
success: boolean;
/** A newer control in another window owns the authoritative state. */
superseded?: boolean;
supersededBy?: "pause" | "resume" | "cancel";
reason?: string;
checkpointBytes?: number;
resumeStage?: TransferTask["resumeStage"];
@@ -39,12 +43,67 @@ export type TransferControlBridge = {
}>;
resumeTransfer?: (id: string) => Promise<{
success: boolean;
/** A newer control in another window owns the authoritative state. */
superseded?: boolean;
supersededBy?: "pause" | "resume" | "cancel";
reason?: string;
lifecycleEpoch?: number;
}>;
cancelTransfer?: (id: string) => Promise<unknown>;
};
function wasSuperseded(result: { success?: boolean; superseded?: boolean } | undefined | null): boolean {
return result?.superseded === true;
}
type SupersededControlResult = {
success?: boolean;
superseded?: boolean;
supersededBy?: "pause" | "resume" | "cancel";
};
export function reconcileSupersededControls(
host: TransferControlHost,
taskId: string,
childIds: string[],
backendIds: string[],
results: ReadonlyArray<SupersededControlResult | undefined>,
epoch: number,
requestedAction: "pause" | "resume",
controlTaskId = taskId,
): void {
if (!isTransferControlEpochCurrent(controlTaskId, epoch)) return;
const task = host.getTasks().find((candidate) => candidate.id === taskId);
if (!task || ["completed", "cancelled", "failed"].includes(task.status)) return;
const apply = (id: string, descendants: string[], action: "pause" | "resume" | "cancel") => {
if (action === "resume") releaseTransferPauseTree(id, descendants);
else latchTransferPauseTree(id, descendants);
for (const affectedId of [id, ...descendants]) {
try {
if (action === "resume") globalSftpTransferScheduler.resume(affectedId);
else globalSftpTransferScheduler.pause(affectedId);
} catch { /* best-effort */ }
}
};
const decisions = results.map((result) => result?.superseded
? result.supersededBy
: result?.success ? requestedAction : undefined);
// A child-only resume does not establish a folder-wide decision. Require all
// relevant children to agree, or an already-authoritative resumed root row.
if (decisions.length > 0 && decisions.every((action) => action === "resume")) {
apply(taskId, childIds, "resume");
host.setTasks(host.getTasks().map((row) => row.id === taskId ? { ...row, status: "transferring" } : row));
} else if (decisions.length > 0 && decisions.every((action) => action === "pause")) {
apply(taskId, childIds, "pause");
host.setTasks(host.getTasks().map((row) => row.id === taskId ? { ...row, status: "paused" } : row));
} else if (task.status === "transferring" && decisions.includes("resume")) {
apply(taskId, [], "resume");
}
decisions.forEach((action, index) => {
if (action) apply(backendIds[index], [], action);
});
}
/** Prefer the highest bridge lifecycleEpoch from successful pause/resume results. */
function maxBridgeLifecycleEpoch(
results: ReadonlyArray<{ success?: boolean; lifecycleEpoch?: number } | undefined | null>,
@@ -170,14 +229,23 @@ export async function softPauseTransfer(
}
const backendIds = treeIds.length > 0 ? treeIds : [taskId];
// An obsolete pause can be superseded by another pause or cancellation,
// not only by resume. Compensate only while the tree still wants to run.
const undoObsoletePause = async (id: string) => {
const live = host.getTasks().find((candidate) => candidate.id === taskId);
if (!live || ["completed", "cancelled", "failed", "interrupted"].includes(live.status)) return;
if (isTransferOrRootPauseLatched(taskId, id)) return;
try { await bridge!.resumeTransfer?.(id); } catch { /* best-effort */ }
};
const pauseOne = async (id: string) => {
let result = await bridge!.pauseTransfer?.(id)
?? { success: false, reason: "Pause unavailable" };
const maxAttempts = task.isDirectory ? 4 : 16;
for (let attempt = 0; attempt < maxAttempts; attempt += 1) {
if (wasSuperseded(result)) return result;
if (!isTransferControlEpochCurrent(taskId, pauseEpoch)) {
if (result.success) {
try { await bridge!.resumeTransfer?.(id); } catch { /* best-effort */ }
await undoObsoletePause(id);
}
return { success: false, reason: "Pause superseded by resume" };
}
@@ -186,6 +254,9 @@ export async function softPauseTransfer(
return result;
}
await new Promise((resolve) => setTimeout(resolve, 40));
if (!isTransferControlEpochCurrent(taskId, pauseEpoch)) {
return { success: false, reason: "Pause superseded by newer control" };
}
result = await bridge!.pauseTransfer?.(id)
?? { success: false, reason: "Pause unavailable" };
}
@@ -200,17 +271,22 @@ export async function softPauseTransfer(
return { id, result: { success: false, reason: "Pause superseded by resume" } };
}
const result = await pauseOne(id);
if (wasSuperseded(result)) return { id, result };
const live = host.getTasks().find((candidate) => candidate.id === taskId);
const userResumed = !pauseStillCurrent()
|| !live
|| (live.status !== "paused" && live.status !== "pausing");
if (userResumed) {
try { await bridge!.resumeTransfer?.(id); } catch { /* best-effort */ }
await undoObsoletePause(id);
return { id, result: { success: false, reason: "Pause superseded by resume" } };
}
return { id, result };
}));
if (!pauseStillCurrent()) return;
if (pauseResults.some(({ result }) => wasSuperseded(result))) {
reconcileSupersededControls(host, taskId, childIds, backendIds, pauseResults.map(({ result }) => result), pauseEpoch, "pause");
return;
}
const after = host.getTasks().find((candidate) => candidate.id === taskId);
if (!after || after.status === "cancelled") return;
if (after.status !== "paused" && after.status !== "pausing") return;
@@ -259,6 +335,10 @@ export async function softPauseTransfer(
id,
result: await pauseOne(id),
})));
if (pauseResults.some(({ result }) => wasSuperseded(result))) {
reconcileSupersededControls(host, taskId, childIds, backendIds, pauseResults.map(({ result }) => result), pauseEpoch, "pause");
return "noop";
}
const afterLivePause = host.getTasks().find((candidate) => candidate.id === taskId);
if (afterLivePause?.status === "cancelled") {
releaseTransferPauseTree(taskId, childIds);
@@ -271,7 +351,7 @@ export async function softPauseTransfer(
if (userAlreadyResumed) {
for (const { id, result } of pauseResults) {
if (result?.success) {
try { await bridge.resumeTransfer?.(id); } catch { /* best-effort */ }
await undoObsoletePause(id);
}
}
return "noop";
@@ -284,7 +364,7 @@ export async function softPauseTransfer(
if (!isTransferControlEpochCurrent(taskId, pauseEpoch)) {
for (const { id, result } of pauseResults) {
if (result?.success) {
try { await bridge.resumeTransfer?.(id); } catch { /* best-effort */ }
await undoObsoletePause(id);
}
}
return "noop";
@@ -367,8 +447,9 @@ export async function softPauseTransfer(
bridgeResults: pauseResults.map((row) => row.result),
});
for (const id of rollback.bridgeIdsToResume) {
try { await bridge.resumeTransfer?.(id); } catch { /* best-effort */ }
await undoObsoletePause(id);
}
if (!isTransferControlEpochCurrent(taskId, pauseEpoch)) return "noop";
const hard = pauseResults.find(({ result }) =>
result && !result.success && !isBenignPauseMiss(result.reason),
)?.result;
@@ -428,7 +509,7 @@ export async function softResumeTransfer(
: [taskId, ...childIds.filter((id) => id !== taskId)];
// Supersede in-flight soft-drain / pauseWatch only — not a bridge lifecycle stamp.
bumpTransferControlEpoch(taskId);
const resumeEpoch = bumpTransferControlEpoch(taskId);
releaseTransferPauseTree(taskId, releaseIds);
for (const id of [taskId, ...releaseIds]) {
try { globalSftpTransferScheduler.resume(id); } catch { /* best-effort */ }
@@ -438,17 +519,50 @@ export async function softResumeTransfer(
try { bridge = host.getBridge(); } catch { bridge = undefined; }
const resumeIds = treeIds.length > 0 ? treeIds : [taskId];
const results = await Promise.all(resumeIds.map(async (id) =>
bridge?.resumeTransfer?.(id) ?? { success: false, reason: "Resume unavailable" },
));
const failedIds = new Set<string>();
const results = await Promise.all(resumeIds.map(async (id) => {
try {
return await bridge?.resumeTransfer?.(id) ?? { success: false, reason: "Resume unavailable" };
} catch (error) {
// Rejections must pass the same stale-control check as ordinary failures.
failedIds.add(id);
return { success: false, reason: error instanceof Error && error.message ? error.message : "Resume request failed" };
}
}));
const after = host.getTasks().find((candidate) => candidate.id === taskId);
if (after?.status === "cancelled") return { handled: true };
if (!isTransferControlEpochCurrent(taskId, resumeEpoch)) return { handled: true };
if (!after || ["completed", "cancelled", "failed"].includes(after.status)) return { handled: true };
results.forEach((result, index) => {
// A missing stream can belong to queued directory work. Verification and
// other explicit failures still own a paused stream and must remain held.
const benignMiss = /^(?:Transfer is no longer active|not active|Resume unavailable)$/i.test(result.reason || "");
if (!result.success && !result.superseded && !benignMiss) failedIds.add(resumeIds[index]);
});
if (results.some(wasSuperseded)) {
reconcileSupersededControls(host, taskId, releaseIds, resumeIds, results, resumeEpoch, "resume");
}
const successIds = resumeIds.filter((_, index) => results[index]?.success);
const effectiveRunning = results.some((result) => result?.success || (result?.superseded && result.supersededBy === "resume"));
const failedIndex = resumeIds.findIndex((id) => failedIds.has(id));
const failureReason = failedIndex >= 0 ? results[failedIndex]?.reason || "Resume request failed" : undefined;
if (failedIds.size > 0) {
// A partial resume stays visibly active; hold only failed children. Do not
// hide successfully running streams behind a paused root barrier.
const heldIds = effectiveRunning ? [...failedIds] : [taskId, ...failedIds];
for (const id of heldIds) {
latchTransferPauseTree(id, []);
try { globalSftpTransferScheduler.pause(id); } catch { /* best-effort */ }
}
if (!effectiveRunning) return { handled: false, reason: failureReason };
}
if (results.some(wasSuperseded) && failedIds.size === 0) {
return { handled: true };
}
const walkAlive = isTransferWalkInFlight(taskId);
// Directory walk can continue after unlatch without bridge resume on every child.
// Single-file must not claim success when every bridge resume fails (stuck bar).
if (successIds.length === 0) {
if (!effectiveRunning) {
if (task.isDirectory && walkAlive) {
host.setTasks(paintTreeStatus(
host.getTasks(),
@@ -491,6 +605,12 @@ export async function softResumeTransfer(
return candidate;
}
if (failedIds.has(candidate.id)) {
return { ...candidate, status: "paused" as const, speed: 0, error: failureReason };
}
const candidateResult = results[resumeIds.indexOf(candidate.id)];
if (candidate.id !== taskId && candidateResult?.superseded) return candidate;
// Parent: transferring. Prefer bridge epoch; never wipe to undefined or a
// late pause fanout re-applies "paused" (acceptsLifecycle treats missing as any).
if (candidate.id === taskId) {
@@ -502,7 +622,7 @@ export async function softResumeTransfer(
return {
...candidate,
status: "transferring" as const,
error: undefined,
error: failureReason,
reconnectRequired: false,
pauseUnavailableReason: undefined,
phase: undefined,
@@ -41,6 +41,106 @@ const rootTask = (): TransferTask => ({
progressMode: "files",
});
for (const newestAction of ["pause", "cancel", "resume", "remote-resume", "remote-child-resume"] as const) {
test(`directory pause watcher respects a newer ${newestAction}`, async () => {
const { bumpTransferControlEpoch, resetTransferControlEpochsForTests } = await import("./transferControlEpoch");
const { latchTransferPauseTree, isTransferPauseLatched, resetTransferPauseLatchesForTests } = await import("./transferPauseLatch");
const previousWindow = (globalThis as { window?: unknown }).window;
const previousLocalStorage = (globalThis as { localStorage?: unknown }).localStorage;
const previousActEnvironment = (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT;
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
const root = { ...rootTask(), id: `watch-root-${newestAction}` };
let tasks: TransferTask[] = [root];
const transfersRef = { current: tasks };
const cancelledTasksRef = { current: new Set<string>() };
const pausedTasksRef = { current: new Set<string>() };
let pauseCalls = 0;
let finishPause!: (value: { success: boolean; superseded?: boolean; supersededBy?: "resume" }) => void;
let finishTransfer!: (value: { error?: string }) => void;
let pauseStarted!: () => void;
const pauseGate = new Promise<void>((resolve) => { pauseStarted = resolve; });
const transferGate = new Promise<{ error?: string }>((resolve) => { finishTransfer = resolve; });
let resumeCalls = 0;
let childId = "";
(globalThis as { window?: unknown }).window = { netcatty: {
mkdirLocal: async () => undefined,
statLocal: async () => ({ type: "directory" }),
startStreamTransfer: (options: { transferId: string }) => {
childId = options.transferId;
// Reproduce a stream arming during the parent's initial pause round.
bumpTransferControlEpoch(root.id);
latchTransferPauseTree(root.id, [childId]);
if (newestAction === "remote-resume" || newestAction === "remote-child-resume") { pausedTasksRef.current.add(root.id); pausedTasksRef.current.add(childId); }
return transferGate;
},
pauseTransfer: () => {
pauseCalls++;
pauseStarted();
return new Promise<{ success: boolean; superseded?: boolean; supersededBy?: "resume" }>((resolve) => { finishPause = resolve; });
},
resumeTransfer: async () => { resumeCalls++; return { success: true }; },
} };
(globalThis as { localStorage?: unknown }).localStorage = {
getItem: () => null, setItem: () => undefined, removeItem: () => undefined,
};
let operations: ReturnType<typeof useSftpDirectoryTransferOps> | undefined;
let renderer: ReactTestRenderer | null = null;
let running: Promise<unknown> | undefined;
const Probe = () => {
operations = useSftpDirectoryTransferOps({
ownerId: `watch-owner-${newestAction}`, cancelledTasksRef,
pausedTasksRef,
waitUntilTransferResumed: async () => undefined,
activeChildIdsRef: { current: new Map() }, transfersRef,
setTransfers: (update) => {
tasks = typeof update === "function" ? update(tasks) : update;
transfersRef.current = tasks;
},
listLocalFiles: async () => [], listRemoteFiles: async () => [fileEntry("file.txt")],
});
return null;
};
try {
await act(async () => { renderer = create(React.createElement(Probe)); });
assert.ok(operations);
running = operations.transferDirectory(root, "source-sftp", null, false, true, "auto", "auto", root.id);
await pauseGate;
if (newestAction === "remote-resume" || newestAction === "remote-child-resume") {
// Another window's resumed event updates rows without bumping this local epoch.
transfersRef.current = tasks = tasks.map(task => ({ ...task, status: task.id === root.id && newestAction === "remote-child-resume" ? "paused" : "transferring", lifecycleEpoch: 8 }));
finishPause({ success: false, superseded: true, supersededBy: "resume" });
await new Promise((resolve) => setTimeout(resolve, 110));
assert.equal(pauseCalls, 1, "superseded watcher must not re-pause the resumed backend");
assert.equal(isTransferPauseLatched(root.id), newestAction === "remote-child-resume");
assert.equal(isTransferPauseLatched(childId), false);
assert.equal(pausedTasksRef.current.has(root.id), newestAction === "remote-child-resume");
assert.equal(pausedTasksRef.current.has(childId), false);
return;
}
bumpTransferControlEpoch(root.id);
if (newestAction === "pause") latchTransferPauseTree(root.id, [childId]);
else {
if (newestAction === "cancel") cancelledTasksRef.current.add(root.id);
resetTransferPauseLatchesForTests();
}
finishPause({ success: true });
await new Promise((resolve) => setImmediate(resolve));
assert.equal(resumeCalls, newestAction === "resume" ? 1 : 0, "compensation must follow the latest decision");
} finally {
pausedTasksRef.current.clear();
resetTransferPauseLatchesForTests();
finishPause?.({ success: true });
finishTransfer({});
await running?.catch(() => {});
await act(async () => { renderer?.unmount(); });
resetTransferControlEpochsForTests();
(globalThis as { window?: unknown }).window = previousWindow;
(globalThis as { localStorage?: unknown }).localStorage = previousLocalStorage;
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = previousActEnvironment;
}
});
}
test("directory transfer discovers each directory once with bounded listing concurrency", async () => {
const previousWindow = (globalThis as { window?: unknown }).window;
const previousLocalStorage = (globalThis as { localStorage?: unknown }).localStorage;
@@ -233,3 +333,74 @@ test("live directory download rejects a Windows backslash traversal entry", asyn
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = previousActEnvironment;
}
});
test("live folder settles a superseded child whose completed row was compacted", async () => {
const { sftpTransferCenterStore } = await import("../sftpTransferCenterStore");
const previousWindow = (globalThis as { window?: unknown }).window;
const previousLocalStorage = Object.getOwnPropertyDescriptor(globalThis, "localStorage");
const previousActEnvironment = (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT;
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
Object.defineProperty(globalThis, "localStorage", {
configurable: true,
value: { getItem: () => null, setItem: () => {}, removeItem: () => {} },
});
const root = { ...rootTask(), id: "live-compacted-root", startTime: Date.now() };
let tasks: TransferTask[] = [root];
const transfersRef = { current: tasks };
const cancelledTasksRef = { current: new Set<string>() };
let childId: string | undefined;
(globalThis as { window?: unknown }).window = { netcatty: {
mkdirLocal: async () => undefined,
statLocal: async () => ({ type: "directory" }),
startStreamTransfer: async (options: { transferId: string }) => {
childId = options.transferId;
sftpTransferCenterStore.publishOwner("live-compacted-owner", tasks);
// The winning invocation has already completed; history compaction drops its row.
sftpTransferCenterStore.ingestBackgroundEvent({
type: "completed", transferId: childId, transferred: 1, totalBytes: 1, lifecycleEpoch: 0,
});
return { superseded: true };
},
} };
let operations: ReturnType<typeof useSftpDirectoryTransferOps> | undefined;
let renderer: ReactTestRenderer | null = null;
let running: Promise<number> | undefined;
const Probe = () => {
operations = useSftpDirectoryTransferOps({
ownerId: "live-compacted-owner", cancelledTasksRef,
pausedTasksRef: { current: new Set() }, waitUntilTransferResumed: async () => undefined,
activeChildIdsRef: { current: new Map() }, transfersRef,
setTransfers: (update) => {
tasks = typeof update === "function" ? update(tasks) : update;
transfersRef.current = tasks;
},
listLocalFiles: async () => [], listRemoteFiles: async () => [fileEntry("file.txt")],
});
return null;
};
try {
await act(async () => { renderer = create(React.createElement(Probe)); });
assert.ok(operations);
running = operations.transferDirectory(root, "source-sftp", null, false, true, "auto", "auto", root.id);
const result = await Promise.race([
running,
new Promise<"still-waiting">((resolve) => setTimeout(() => resolve("still-waiting"), 450)),
]);
assert.ok(childId);
assert.equal(sftpTransferCenterStore.getTask(childId), undefined);
assert.equal(sftpTransferCenterStore.getTask(root.id)?.directoryResumeCheckpoint?.completedEntries, 1);
assert.notEqual(result, "still-waiting", "compacted completion must settle the live folder");
assert.equal(result, 0);
} finally {
cancelledTasksRef.current.add(root.id);
await running?.catch(() => {});
await act(async () => { renderer?.unmount(); });
sftpTransferCenterStore.patchTask(root.id, { status: "completed" });
sftpTransferCenterStore.dismiss(root.id);
(globalThis as { window?: unknown }).window = previousWindow;
if (previousLocalStorage) Object.defineProperty(globalThis, "localStorage", previousLocalStorage);
else Reflect.deleteProperty(globalThis, "localStorage");
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = previousActEnvironment;
}
});
+39 -31
View File
@@ -1,3 +1,5 @@
import { reconcileSupersededControls } from "./globalSftpTransferControl";
import { runTransferAndWaitForOwner } from "./waitForTransferOwner";
import { useCallback, type Dispatch, type MutableRefObject, type SetStateAction } from "react";
import type { Host, SftpFileEntry, SftpFilenameEncoding, TransferStatus, TransferTask } from "../../../domain/models";
import {
@@ -412,7 +414,11 @@ export function useSftpDirectoryTransferOps({
}
// Await the invoke result — cancel resolves with { error } and may
// not fire onComplete/onError after preload clears listeners.
const transferPromise = netcattyBridge.require().startStreamTransfer!(options);
const transferPromise = runTransferAndWaitForOwner(
task,
() => netcattyBridge.require().startStreamTransfer!(options),
() => isCancelledLocalOrGlobal(cancelledTasksRef, rootTaskId, task.id),
);
// Streams that arm after the parent pause round never receive the
// initial pauseTransfer. Keep pausing while the folder is latched.
// Capture epoch per attempt so Resume (epoch bump) undoes a late pause.
@@ -427,12 +433,33 @@ export function useSftpDirectoryTransferOps({
)
) {
const epochAtAttempt = getTransferControlEpoch(rootTaskId);
const childEpochAtAttempt = getTransferControlEpoch(task.id);
try {
const result = await netcattyBridge.get()?.pauseTransfer?.(task.id);
// Resume won while we were awaiting pause — undo.
if (
result?.superseded && result.supersededBy === "resume"
&& isTransferControlEpochCurrent(rootTaskId, epochAtAttempt)
&& getTransferControlEpoch(task.id) === childEpochAtAttempt
&& !isCancelledLocalOrGlobal(cancelledTasksRef, rootTaskId, task.id)
) {
const rootAlreadyRunning = transfersRef.current.find((row) => row.id === rootTaskId)?.status === "transferring";
reconcileSupersededControls({
getTasks: () => transfersRef.current,
setTasks: (next) => setTransfers(next),
getBridge: () => netcattyBridge.get(),
}, rootAlreadyRunning ? rootTaskId : task.id, rootAlreadyRunning ? [task.id] : [], [task.id], [result],
epochAtAttempt, "pause", rootTaskId);
if (rootAlreadyRunning) pausedTasksRef.current.delete(rootTaskId);
pausedTasksRef.current.delete(task.id);
break;
}
// Compensate only if the newest decision still wants this file running.
// A new pause or cancellation also changes the epoch.
if (
result?.success
&& !isTransferControlEpochCurrent(rootTaskId, epochAtAttempt)
&& !isPauseLatched(rootTaskId, task.id)
&& !isCancelledLocalOrGlobal(cancelledTasksRef, rootTaskId, task.id)
) {
try {
await netcattyBridge.get()?.resumeTransfer?.(task.id);
@@ -450,33 +477,6 @@ export function useSftpDirectoryTransferOps({
watchPaused = false;
await pauseWatch.catch(() => {});
}
// Same-id retry stole ownership while OPEN was pending. The live
// owner still drives progress/completion events; this invoke must
// not mark the child completed or failed (Codex P2 on b17f64e9).
if (result?.superseded === true) {
// Wait for the live same-id owner only — no fixed deadline so
// long transfers are not failed while still running (Codex P2).
for (;;) {
if (
cancelledTasksRef.current.has(task.id)
|| cancelledTasksRef.current.has(rootTaskId)
) {
throw new Error("Transfer cancelled");
}
const latest = sftpTransferCenterStore.getTask(task.id)
?? transfersRef.current.find((candidate) => candidate.id === task.id);
const status = latest?.status;
if (status === "completed") break;
if (status === "failed") {
throw new Error(latest?.error || "Transfer failed");
}
if (status === "cancelled") {
throw new Error("Transfer cancelled");
}
await new Promise((resolve) => setTimeout(resolve, 200));
}
break;
}
if (result?.error || result?.cancelled) {
throw new Error(result.error || "Transfer cancelled");
}
@@ -840,7 +840,9 @@ export function useSftpDirectoryTransferOps({
if (row.status === "paused" || row.status === "pausing" || isPauseLatched(rootTaskId)) {
return { ...row, speed: 0 };
}
return { ...row, transferredBytes: row.transferredBytes + 1 };
const completed = (row.directoryResumeCheckpoint?.completedEntries ?? 0)
+ base.filter((child) => child.parentTaskId === rootTaskId && child.status === "completed").length;
return { ...row, transferredBytes: completed };
});
});
return;
@@ -924,6 +926,12 @@ export function useSftpDirectoryTransferOps({
|| parentRow.status === "pausing"
|| isPauseLatched(rootTaskId)
);
// Background completion may have already compacted this child and
// advanced the checkpoint. Count retained + compacted completions
// instead of incrementing the same file again when invoke settles.
const completed = (parentRow?.directoryResumeCheckpoint?.completedEntries ?? 0)
+ prev.filter((row) => row.parentTaskId === rootTaskId
&& (row.status === "completed" || row.id === fileId)).length;
const updated = prev.map((t) => {
if (t.id === fileId) {
return { ...t, status: "completed" as TransferStatus, endTime: Date.now(), transferredBytes: t.totalBytes };
@@ -934,7 +942,7 @@ export function useSftpDirectoryTransferOps({
}
return {
...t,
transferredBytes: t.transferredBytes + 1,
transferredBytes: completed,
speed: t.speed,
};
}
+12 -1
View File
@@ -8,6 +8,9 @@
const pausedIds = new Set<string>();
const barriers = new Map<string, { promise: Promise<void>; resolve: () => void }>();
// A completed child's history row can disappear while its worker still waits
// for the folder to resume. Keep the paused tree independent of UI history.
const pausedChildren = new Map<string, Set<string>>();
function ensureBarrier(taskId: string): { promise: Promise<void>; resolve: () => void } {
let barrier = barriers.get(taskId);
@@ -45,11 +48,18 @@ export function releaseTransferPause(taskId: string): void {
}
export function releaseTransferPauseTree(rootTaskId: string, childIds: readonly string[] = []): void {
const children = new Set([...childIds, ...(pausedChildren.get(rootTaskId) ?? [])]);
pausedChildren.delete(rootTaskId);
releaseTransferPause(rootTaskId);
for (const id of childIds) releaseTransferPause(id);
for (const id of children) releaseTransferPause(id);
}
export function latchTransferPauseTree(rootTaskId: string, childIds: readonly string[] = []): void {
if (childIds.length > 0) {
const children = pausedChildren.get(rootTaskId) ?? new Set<string>();
for (const id of childIds) children.add(id);
pausedChildren.set(rootTaskId, children);
}
latchTransferPause(rootTaskId);
for (const id of childIds) latchTransferPause(id);
}
@@ -77,6 +87,7 @@ export function resetTransferPauseLatchesForTests(): void {
for (const id of ids) releaseTransferPause(id);
pausedIds.clear();
barriers.clear();
pausedChildren.clear();
}
export function listTransferPauseLatchesForTests(): string[] {
@@ -0,0 +1,171 @@
import assert from "node:assert/strict";
import test from "node:test";
import type { TransferTask } from "../../../domain/models";
import { createSftpTransferCenterStore } from "../sftpTransferCenterStore";
function child(): TransferTask {
return {
id: "child", parentTaskId: "root", directoryEntryIndex: 0, directoryEntryIdentity: "a".repeat(64),
sourcePath: "/source/a", targetPath: "/target/a", fileName: "a",
sourceConnectionId: "local", targetConnectionId: "remote", direction: "upload",
totalBytes: 1, transferredBytes: 0, speed: 0, startTime: 0, isDirectory: false, status: "transferring",
};
}
for (const status of ["completed", "failed", "cancelled"] as const) {
test(`settlement observation retains exact ${status} before history pruning`, () => {
const store = createSftpTransferCenterStore();
const task = child();
store.upsertTasks([{ ...task, id: "root", parentTaskId: undefined, isDirectory: true }, task]);
const observation = store.observeTaskSettlement(task);
store.patchTask(task.id, { status, error: status === "failed" ? "write failed" : undefined });
assert.equal(observation.read()?.status, status);
if (status === "completed") assert.equal(store.getTask(task.id), undefined);
observation.dispose();
assert.equal(observation.read(), undefined);
});
}
test("another file reusing an id and index is not completion evidence", () => {
const store = createSftpTransferCenterStore();
const task = child();
store.upsertTasks([task]);
const observation = store.observeTaskSettlement(task);
store.upsertTasks([{ ...task, directoryEntryIdentity: "b".repeat(64), status: "completed" }]);
assert.equal(observation.read(), undefined);
observation.dispose();
});
test("a disposed observer receives no later completion", () => {
const store = createSftpTransferCenterStore();
const task = child();
store.upsertTasks([task]);
const observation = store.observeTaskSettlement(task);
observation.dispose();
store.patchTask(task.id, { status: "completed" });
assert.equal(observation.read(), undefined);
});
test("explicit dispatch refreshes failed identity without overwriting checkpoints", () => {
const store = createSftpTransferCenterStore();
const task = { ...child(), status: "failed" as const, checkpointBytes: 7 };
store.upsertTasks([task]);
const next = { ...task, directoryEntryIdentity: "b".repeat(64), checkpointBytes: 0 };
assert.equal(store.admitTaskRun(next), "ready");
assert.equal(store.getTask(task.id)?.status, "transferring");
assert.equal(store.getTask(task.id)?.directoryEntryIdentity, next.directoryEntryIdentity);
assert.equal(store.getTask(task.id)?.checkpointBytes, 7);
});
for (const status of ["cancelled", "completed", "paused", "pausing"] as const) {
test(`dispatch cannot revive a retained ${status} row`, () => {
const store = createSftpTransferCenterStore();
const task = child();
store.upsertTasks([
{ ...task, id: "root", parentTaskId: undefined },
{ ...task, status },
]);
assert.equal(store.admitTaskRun(task), status === "pausing" ? "paused" : status);
assert.equal(store.getTask(task.id)?.status, status);
});
}
test("dispatch preserves active lifecycle guards and avoids replacing unchanged rows", () => {
const store = createSftpTransferCenterStore();
const task = { ...child(), lifecycleEpoch: 9 };
store.upsertTasks([task]);
const before = store.getTask(task.id);
assert.equal(store.admitTaskRun(task), "ready");
assert.equal(store.getTask(task.id), before);
assert.equal(store.admitTaskRun({ ...task, directoryEntryIdentity: "b".repeat(64) }), "ready");
assert.equal(store.getTask(task.id)?.lifecycleEpoch, 9);
});
test("dispatch rejects a later pause or cancellation before changing identity", async (t) => {
const { latchTransferPause, resetTransferPauseLatchesForTests } = await import("./transferPauseLatch");
const { markTransferCancelledTree, settleTransferCancelTree } = await import("./transferCancelLatch");
const store = createSftpTransferCenterStore();
const task = { ...child(), status: "failed" as const };
store.upsertTasks([task]);
t.after(() => { resetTransferPauseLatchesForTests(); settleTransferCancelTree("root", [task.id]); });
latchTransferPause("root");
assert.equal(store.admitTaskRun(task), "paused");
resetTransferPauseLatchesForTests();
markTransferCancelledTree("root", [task.id]);
assert.equal(store.admitTaskRun(task), "cancelled");
assert.equal(store.getTask(task.id)?.status, "failed");
});
for (const nextStatus of ["transferring", "completed", "cancelled"] as const) {
test(`dispatch waits through a later pause until ${nextStatus}`, async (t) => {
const { sftpTransferCenterStore: store } = await import("../sftpTransferCenterStore");
const { runTransferAndWaitForOwner } = await import("./waitForTransferOwner");
const task = { ...child(), id: `paused-admission-${nextStatus}`, parentTaskId: undefined };
store.upsertTasks([{ ...task, status: "paused" }]);
let starts = 0;
let abort = false;
const running = runTransferAndWaitForOwner(task, async () => { starts += 1; return {}; }, () => abort);
// Attach rejection handling before the cancellation event is delivered.
const settled = running.then(() => "completed", (error: Error) => error.message);
t.after(async () => { abort = true; await settled; store.dismiss(task.id); });
await new Promise((resolve) => setTimeout(resolve, 30));
assert.equal(starts, 0);
assert.equal(store.getTask(task.id)?.status, "paused");
if (nextStatus !== "cancelled") store.patchTask(task.id, { status: "transferring", lifecycleEpoch: 1 });
if (nextStatus === "cancelled") {
const { markTransferCancelledTree, settleTransferCancelTree } = await import("./transferCancelLatch");
markTransferCancelledTree(task.id, []);
t.after(() => settleTransferCancelTree(task.id, []));
} else store.patchTask(task.id, { status: nextStatus });
const outcome = await Promise.race([settled, new Promise((resolve) => setTimeout(() => resolve("still-waiting"), 1000))]);
assert.equal(outcome, nextStatus === "cancelled" ? "Transfer cancelled" : "completed");
assert.equal(starts, nextStatus === "transferring" ? 1 : 0);
});
}
test("completed dispatch consumes only exact identity and never restarts", async (t) => {
const { sftpTransferCenterStore: store } = await import("../sftpTransferCenterStore");
const { runTransferAndWaitForOwner } = await import("./waitForTransferOwner");
const task = { ...child(), id: "completed-admission", parentTaskId: undefined };
store.upsertTasks([{ ...task, status: "completed" }]);
t.after(() => store.dismiss(task.id));
const start = async () => { assert.fail("completed transfer must not restart"); };
await runTransferAndWaitForOwner(task, start, () => false);
await assert.rejects(runTransferAndWaitForOwner({ ...task, directoryEntryIdentity: "b".repeat(64) }, start, () => false), /identity changed/);
assert.equal(store.getTask(task.id)?.status, "completed");
});
test("a resumed retry does not inherit failed settlement captured while admission was paused", async (t) => {
const { sftpTransferCenterStore: store } = await import("../sftpTransferCenterStore");
const { runTransferAndWaitForOwner } = await import("./waitForTransferOwner");
const { latchTransferPause, resetTransferPauseLatchesForTests } = await import("./transferPauseLatch");
const task = { ...child(), id: "failed-paused-admission", parentTaskId: undefined };
store.upsertTasks([{ ...task, status: "failed", error: "previous attempt failed" }]);
latchTransferPause(task.id);
let abort = false;
const running = runTransferAndWaitForOwner(task, async () => {
store.patchTask(task.id, { status: "completed" });
return { superseded: true };
}, () => abort);
const settled = running.then(() => "completed", (error: Error) => error.message);
t.after(async () => { abort = true; resetTransferPauseLatchesForTests(); await settled; store.dismiss(task.id); });
// An unrelated lifecycle publication captures the previous failed row while waiting.
store.upsertTasks([{ ...child(), id: "unrelated-admission", parentTaskId: undefined }]);
t.after(() => store.dismiss("unrelated-admission"));
resetTransferPauseLatchesForTests();
assert.equal(await Promise.race([settled, new Promise((resolve) => setTimeout(() => resolve("still-waiting"), 1000))]), "completed");
});
test("fresh directory recovery authorizes only an unchanged retained pause under an active parent", () => {
const store = createSftpTransferCenterStore();
const task = child();
store.upsertTasks([{ ...task, id: "root", parentTaskId: undefined, isDirectory: true, status: "pending" }, { ...task, status: "paused", lifecycleEpoch: 4 }]);
const paused = store.getTask(task.id)!;
assert.equal(store.admitTaskRun(task), "paused", "ordinary live dispatch must respect cross-window pause");
assert.equal(store.admitTaskRun(task, paused), "ready");
store.patchTask(task.id, { status: "paused", lifecycleEpoch: 5 });
assert.equal(store.admitTaskRun(task, paused), "paused", "newer child pause invalidates fresh recovery permission");
const currentPause = store.getTask(task.id)!;
store.patchTask("root", { status: "paused", lifecycleEpoch: 6 });
assert.equal(store.admitTaskRun(task, currentPause), "paused", "parent pause still wins");
});
+100 -122
View File
@@ -37,10 +37,7 @@ import {
releaseTransferPauseTree,
waitUntilTransferPauseReleased,
} from "./transferPauseLatch";
import {
bumpTransferControlEpoch,
settleTransferControlEpochTree,
} from "./transferControlEpoch";
import { bumpTransferControlEpoch } from "./transferControlEpoch";
import { transferRuntime } from "./transferRuntime";
import {
clearTransferCancelled,
@@ -48,7 +45,6 @@ import {
isTransferCancelledFlag,
markTransferCancelled,
markTransferCancelledTree,
settleTransferCancelTree,
} from "./transferCancelLatch";
import type { TransferResult, UseSftpTransfersParams, UseSftpTransfersResult } from "./useSftpTransfers.types";
import type { TransferConnectionLease } from "./transferConnectionPool";
@@ -1809,127 +1805,109 @@ export const useSftpTransfers = ({
return "attention";
}
const sourceEncoding = params.sourceEncoding ?? "auto";
// Mutable counter to track child failures outside React state,
// so the final status check doesn't depend on render timing.
let childFailureCount = 0;
const patchDownload = (updates: Partial<TransferTask>) => {
transferRuntime.patchTask(task.id, updates);
const canonical = transferRuntime.getTask(task.id);
if (canonical) setTransfers((prev) => prev.map((row) => row.id === task.id ? canonical : row));
};
const executeDownload = async (): Promise<TransferStatus> => {
const sourceEncoding = params.sourceEncoding ?? "auto";
// Mutable counter to track child failures outside React state,
// so the final status check doesn't depend on render timing.
let childFailureCount = 0;
// Dedicated pool only when host id is known — never fall back to browse
// (tab close would kill the download and freeze the global center).
let sourceWorkLease: TransferConnectionLease | null = null;
let workingSourceSftpId = params.sftpId;
if (acquireTransferSession && params.sourceHostId) {
sourceWorkLease = await acquireTransferSession(
params.sourceHostId,
`${task.id}:work-source`,
);
workingSourceSftpId = sourceWorkLease.sftpId;
}
// Dedicated pool only when host id is known — never fall back to browse
// (tab close would kill the download and freeze the global center).
let sourceWorkLease: TransferConnectionLease | null = null;
let workingSourceSftpId = params.sftpId;
try {
if (acquireTransferSession && params.sourceHostId) {
sourceWorkLease = await acquireTransferSession(
params.sourceHostId,
`${task.id}:work-source`,
);
workingSourceSftpId = sourceWorkLease.sftpId;
}
await waitUntilTransferResumed(task.id);
if (cancelledTasksRef.current.has(task.id)) throw new Error("Transfer cancelled");
if (params.isDirectory) patchDownload({ status: "transferring", error: undefined });
try {
if (params.isDirectory) {
childFailureCount = await transferDirectory(
task,
workingSourceSftpId,
null, // targetSftpId = null (local)
false, // sourceIsLocal = false
true, // targetIsLocal = true
sourceEncoding,
"auto", // targetEncoding
task.id,
false, // sameHost
0, // symlinkDepth
true, // followSymlinks — download should expand symlink dirs
);
} else {
await transferFile(
task,
workingSourceSftpId,
null,
false,
true,
sourceEncoding,
"auto",
task.id,
);
}
if (params.isDirectory) {
childFailureCount = await transferDirectory(
task,
workingSourceSftpId,
null, // targetSftpId = null (local)
false, // sourceIsLocal = false
true, // targetIsLocal = true
sourceEncoding,
"auto", // targetEncoding
task.id,
false, // sameHost
0, // symlinkDepth
true, // followSymlinks — download should expand symlink dirs
);
} else {
await transferFile(
task,
workingSourceSftpId,
null,
false,
true,
sourceEncoding,
"auto",
task.id,
);
}
// Use childFailureCount (tracked outside React state) to determine
// final status reliably, regardless of render timing.
// Cancel must win: transferDirectory counts cancelled children as errors,
// but cancelTransfer already marked the parent cancelled — do not demote
// it to failed with "Some files failed to transfer".
// Re-read cancel inside the state update so a cancel that lands after
// transferDirectory returns cannot be overwritten by completed/failed.
let appliedStatus: TransferStatus = "completed";
setTransfers((prev) => {
const liveParent = prev.find((candidate) => candidate.id === task.id);
const completedCount = (liveParent?.directoryResumeCheckpoint?.completedEntries ?? 0) + prev.filter(
(t) => t.parentTaskId === task.id && t.status === "completed",
).length;
return prev.map((t) => {
if (t.id !== task.id) return t;
const parentCancelled = t.status === "cancelled"
|| cancelledTasksRef.current.has(task.id);
const resolved = resolveDirectDirectoryDownloadFinalStatus({
parentCancelled,
childFailureCount,
});
appliedStatus = resolved.status;
if (resolved.status === "cancelled") {
cancelledTasksRef.current.delete(task.id);
return {
...t,
status: "cancelled" as TransferStatus,
error: undefined,
endTime: Date.now(),
// Keep partial progress — do not look 100% complete when cancelled.
speed: 0,
};
}
const finalTotal = t.totalBytes > 0 ? t.totalBytes : completedCount;
const hasFailures = resolved.status === "failed";
return {
...t,
status: resolved.status,
error: resolved.error,
endTime: Date.now(),
totalBytes: finalTotal,
transferredBytes: hasFailures ? completedCount : finalTotal,
speed: 0,
};
// Runtime owns lifecycle while the walk is registered. Publish the
// final root there before mirroring it to the panel.
const rows = sftpTransferCenterStore.getSnapshot().tasks;
const liveParent = transferRuntime.getTask(task.id) ?? task;
const completedCount = (liveParent.directoryResumeCheckpoint?.completedEntries ?? 0)
+ rows.filter((row) => row.parentTaskId === task.id && row.status === "completed").length;
const resolved = resolveDirectDirectoryDownloadFinalStatus({
parentCancelled: liveParent.status === "cancelled" || cancelledTasksRef.current.has(task.id),
childFailureCount,
});
});
activeChildIdsRef.current.delete(task.id);
return appliedStatus;
} catch (err) {
activeChildIdsRef.current.delete(task.id);
const isCancelled = cancelledTasksRef.current.has(task.id);
// Clean up cancelled task tracking to prevent memory leak
if (isCancelled) cancelledTasksRef.current.delete(task.id);
const errMsg = err instanceof Error ? err.message : String(err);
setTransfers((prev) =>
prev.map((t) =>
t.id === task.id
? {
...t,
status: isCancelled ? ("cancelled" as TransferStatus) : ("failed" as TransferStatus),
error: isCancelled ? undefined : errMsg,
endTime: Date.now(),
}
: t,
),
);
return isCancelled ? "cancelled" : "failed";
} finally {
sourceWorkLease?.release();
sourceWorkLease = null;
const childIds = sftpTransferCenterStore.getSnapshot().tasks
.filter((candidate) => candidate.parentTaskId === task.id)
.map((candidate) => candidate.id);
const relatedChildIds = settleTransferCancelTree(task.id, childIds);
settleTransferControlEpochTree(task.id, relatedChildIds);
}
const finalTotal = liveParent.totalBytes > 0 ? liveParent.totalBytes : completedCount;
patchDownload({
status: resolved.status,
error: resolved.error,
endTime: Date.now(),
speed: 0,
...(resolved.status === "cancelled" ? {} : {
totalBytes: finalTotal,
transferredBytes: resolved.status === "failed" ? completedCount : finalTotal,
}),
});
const appliedStatus = transferRuntime.getTask(task.id)?.status ?? resolved.status;
activeChildIdsRef.current.delete(task.id);
return appliedStatus;
} catch (err) {
activeChildIdsRef.current.delete(task.id);
const isCancelled = cancelledTasksRef.current.has(task.id);
// Clean up cancelled task tracking to prevent memory leak
if (isCancelled) cancelledTasksRef.current.delete(task.id);
const errMsg = err instanceof Error ? err.message : String(err);
patchDownload({
status: isCancelled ? "cancelled" : "failed",
error: isCancelled ? undefined : errMsg,
endTime: Date.now(),
speed: 0,
});
return isCancelled ? "cancelled" : "failed";
} finally {
sourceWorkLease?.release();
sourceWorkLease = null;
}
};
let result: TransferStatus = "failed";
await runTrackedTransferAttempt(inFlightTransferIdsRef.current, task.id, () =>
transferRuntime.runWalk(task.id, async () => {
result = await executeDownload();
}),
);
return result;
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[sftpSessionsRef, acquireTransferSession],
@@ -0,0 +1,41 @@
import type { TransferTask } from "../../../domain/models";
import { sftpTransferCenterStore } from "../sftpTransferCenterStore";
type StreamResult = { error?: string; cancelled?: boolean; superseded?: boolean } | undefined;
/** A live waiter owns bounded settlement evidence; persisted history stays compact. */
export async function runTransferAndWaitForOwner(
task: TransferTask,
start: () => Promise<StreamResult>,
shouldAbort: () => boolean,
pausedAtResume?: TransferTask,
): Promise<StreamResult> {
// Register before admission/start: an owner may finish while dispatch waits for resume.
let observation = sftpTransferCenterStore.observeTaskSettlement(task);
try {
for (;;) {
if (shouldAbort()) throw new Error("Transfer cancelled");
const admission = sftpTransferCenterStore.admitTaskRun(task, pausedAtResume);
if (admission === "cancelled") throw new Error("Transfer cancelled");
if (admission === "completed" || observation.read()?.status === "completed") return {};
if (admission === "conflict") throw new Error("Transfer identity changed before dispatch");
if (admission === "ready") break;
await new Promise((resolve) => setTimeout(resolve, 200));
}
// Admission begins a new attempt; discard any previous failed settlement.
observation.dispose();
observation = sftpTransferCenterStore.observeTaskSettlement(task);
const result = await start();
if (!result?.superseded) return result;
for (;;) {
if (shouldAbort()) throw new Error("Transfer cancelled");
const latest = observation.read();
if (latest?.status === "completed") return { ...result, superseded: false };
if (latest?.status === "failed") throw new Error(latest.error || "Transfer failed");
if (latest?.status === "cancelled") throw new Error("Transfer cancelled");
await new Promise((resolve) => setTimeout(resolve, 200));
}
} finally {
observation.dispose();
}
}
@@ -3762,3 +3762,108 @@ test("hard directory resume failure falls back to bounded fresh retry history",
assert.equal(retainedParent?.checkpointBytes, 0);
assert.ok(snapshot.length <= 200, `failed directory history must stay bounded, got ${snapshot.length}`);
});
test("held dedicated file resume cannot overwrite a subsequent pause", async (t) => {
const store = createSftpTransferCenterStore();
const previousWindow = Object.getOwnPropertyDescriptor(globalThis, "window");
let releaseRun!: () => void;
let started!: () => void;
let releaseResume!: (result: { success: boolean; lifecycleEpoch: number }) => void;
const runGate = new Promise<void>((resolve) => { releaseRun = resolve; });
const startGate = new Promise<void>((resolve) => { started = resolve; });
const resumeGate = new Promise<{ success: boolean; lifecycleEpoch: number }>((resolve) => { releaseResume = resolve; });
t.after(async () => {
releaseRun();
if (previousWindow) Object.defineProperty(globalThis, "window", previousWindow);
else Reflect.deleteProperty(globalThis, "window");
const { resetTransferPauseLatchesForTests } = await import("./sftp/transferPauseLatch");
resetTransferPauseLatchesForTests();
});
Object.defineProperty(globalThis, "window", { configurable: true, value: { netcatty: {
pauseTransfer: async () => ({ success: true, checkpointBytes: 2, lifecycleEpoch: 3 }),
resumeTransfer: () => resumeGate,
} } });
store.publishOwner("dedicated-resume", [{
...makeTask("held-file", "interrupted"),
ownerId: "dedicated-resume", targetHostId: "host-a", reconnectRequired: true,
}]);
store.setDedicatedResumeHandler(async () => {
store.patchTask("held-file", { status: "transferring", reconnectRequired: false });
started();
await runGate;
return { success: true };
});
const first = store.resume("held-file");
await startGate;
await store.pause("held-file");
const second = store.resume("held-file");
await store.pause("held-file");
releaseResume({ success: true, lifecycleEpoch: 2 });
await new Promise((resolve) => setImmediate(resolve));
const row = store.getSnapshot().tasks.find((task) => task.id === "held-file");
releaseRun();
await Promise.all([first, second]);
assert.equal(row?.status, "paused", "held transfer must respect the latest pause");
assert.equal(row?.lifecycleEpoch, 3);
});
for (const outcome of ["rejected", "stream-gone"] as const) {
test(`held file ${outcome} resume cannot restart after a newer pause during wind-down`, async (t) => {
const store = createSftpTransferCenterStore();
const previousWindow = Object.getOwnPropertyDescriptor(globalThis, "window");
let releaseRun!: () => void;
let started!: () => void;
let resolveResume!: (result: { success: boolean; reason: string }) => void;
let rejectResume!: (error: Error) => void;
let resumeCalls = 0;
const runGate = new Promise<void>((resolve) => { releaseRun = resolve; });
const startGate = new Promise<void>((resolve) => { started = resolve; });
const resumeGate = new Promise<{ success: boolean; reason: string }>((resolve, reject) => {
resolveResume = resolve;
rejectResume = reject;
});
t.after(async () => {
releaseRun();
if (previousWindow) Object.defineProperty(globalThis, "window", previousWindow);
else Reflect.deleteProperty(globalThis, "window");
const { resetTransferPauseLatchesForTests } = await import("./sftp/transferPauseLatch");
resetTransferPauseLatchesForTests();
});
Object.defineProperty(globalThis, "window", { configurable: true, value: { netcatty: {
pauseTransfer: async () => ({ success: true, checkpointBytes: 2, lifecycleEpoch: 3 }),
resumeTransfer: () => ++resumeCalls === 1
? resumeGate
: Promise.resolve({ success: true, lifecycleEpoch: 4 }),
} } });
const id = `held-file-${outcome}`;
store.publishOwner("dedicated-resume", [{
...makeTask(id, "interrupted"),
ownerId: "dedicated-resume", targetHostId: "host-a", reconnectRequired: true,
}]);
store.setDedicatedResumeHandler(async () => {
store.patchTask(id, { status: "transferring", reconnectRequired: false });
started();
await runGate;
return { success: false, error: "Transfer cancelled" };
});
const first = store.resume(id);
await startGate;
await store.pause(id);
const second = store.resume(id);
await new Promise((resolve) => setImmediate(resolve));
if (outcome === "stream-gone") {
resolveResume({ success: false, reason: "Transfer is no longer active" });
await new Promise((resolve) => setImmediate(resolve));
}
await store.pause(id);
if (outcome === "rejected") {
rejectResume(new Error("worker channel unavailable"));
await new Promise((resolve) => setImmediate(resolve));
}
releaseRun();
await Promise.all([first, second]);
assert.equal(store.getSnapshot().tasks.find((task) => task.id === id)?.status, "paused");
assert.equal(resumeCalls, 1, "obsolete resume must not issue another resume after the held run ends");
});
}
+227 -104
View File
@@ -28,6 +28,7 @@ import {
} from "./sftp/transferControlEpoch";
import { isTransferWalkInFlight } from "./sftp/transferWalkRegistry";
import {
isTransferOrRootCancelled,
markTransferCancelledTree,
settleTransferCancelTree,
} from "./sftp/transferCancelLatch";
@@ -96,6 +97,10 @@ export interface SftpTransferCenterStore {
getBadgeSnapshot(): { count: number; hasAttention: boolean };
/** Single task row — same object identity until that row is patched. */
getTask(taskId: string): TransferTask | undefined;
/** Observe exact task settlement before completed child rows are compacted. */
observeTaskSettlement(task: TransferTask): { read(): TransferTask | undefined; dispose(): void };
/** Publish an explicit dispatch identity without flushing large child-history batches. */
admitTaskRun(task: TransferTask, pausedAtResume?: TransferTask): "ready" | "paused" | "completed" | "cancelled" | "conflict";
getOwnerTasks(ownerId: string): TransferTask[];
publishOwner(ownerId: string, tasks: readonly TransferTask[]): void;
registerOwner(ownerId: string, controls: SftpTransferOwnerControls): () => void;
@@ -104,6 +109,8 @@ export interface SftpTransferCenterStore {
/** Insert or merge tasks by id (used by dedicated directory resume for children). */
upsertTasks(incoming: readonly TransferTask[]): void;
canControl(taskId: string): boolean;
subscribeResume(listener: Listener): () => void;
isResuming(taskId: string): boolean;
pause(taskId: string): Promise<void>;
resume(taskId: string): Promise<void>;
cancel(taskId: string): Promise<void>;
@@ -398,6 +405,15 @@ export function createSftpTransferCenterStore(persistence?: StorePersistence): S
let snapshotDirty = false;
const listeners = new Set<Listener>();
const progressListeners = new Set<Listener>();
const settlementObservers = new Map<string, Set<{ expected: TransferTask; settled?: TransferTask }>>();
const matchesObservedTask = (expected: TransferTask, candidate: TransferTask) => (
expected.id === candidate.id
&& expected.sourcePath === candidate.sourcePath
&& expected.targetPath === candidate.targetPath
&& expected.parentTaskId === candidate.parentTaskId
&& expected.directoryEntryIndex === candidate.directoryEntryIndex
&& expected.directoryEntryIdentity === candidate.directoryEntryIdentity
);
const refreshBadgeSnapshot = () => {
const next = buildBadgeSnapshot(tasks);
if (
@@ -417,6 +433,13 @@ export function createSftpTransferCenterStore(persistence?: StorePersistence): S
let persistenceDirty = false;
let persistenceTimer: ReturnType<typeof setTimeout> | null = null;
const resumeInvocations = new Map<string, Promise<void>>();
// Ephemeral control intent: never serialize this or change bridge lifecycle epochs.
const resumeRequests = new Map<string, symbol>();
const resumeListeners = new Set<Listener>();
const notifyResume = () => resumeListeners.forEach((listener) => listener());
const clearResumeRequest = (taskId: string) => {
if (resumeRequests.delete(taskId)) notifyResume();
};
const resumePreparationFailures = new Map<string, string>();
let dedicatedResumeHandler: DedicatedTransferResumeHandler | null = null;
const dedicatedResumeWaiters = new Set<{
@@ -609,6 +632,14 @@ export function createSftpTransferCenterStore(persistence?: StorePersistence): S
// live renderer walk keeps its controls until TransferRuntime.runWalk
// settles; everything else can release its whole tree here.
settleFinishedTransferControlState(tasks, tasks);
if (settlementObservers.size > 0) {
for (const task of tasks) {
if (!TERMINAL_OWNER_STATUSES.has(task.status)) continue;
for (const observer of settlementObservers.get(task.id) ?? []) {
if (!observer.settled && matchesObservedTask(observer.expected, task)) observer.settled = task;
}
}
}
const beforePrune = tasks;
tasks = pruneSftpTransferHistory(tasks);
const retainedIds = new Set(tasks.map((task) => task.id));
@@ -1307,6 +1338,70 @@ export function createSftpTransferCenterStore(persistence?: StorePersistence): S
},
getSnapshot: () => ensureSnapshot(),
getBadgeSnapshot: () => badgeSnapshot,
admitTaskRun(incoming, pausedAtResume) {
const rootId = incoming.parentTaskId ?? incoming.id;
if (isTransferOrRootCancelled(rootId, incoming.id)) return "cancelled";
const parent = incoming.parentTaskId ? tasks.find((task) => task.id === rootId) : undefined;
if (parent?.status === "cancelled") return "cancelled";
const index = tasks.findIndex((task) => task.id === incoming.id);
const existing = index < 0 ? undefined : tasks[index];
if (existing && (existing.sourcePath !== incoming.sourcePath || existing.targetPath !== incoming.targetPath
|| existing.parentTaskId !== incoming.parentTaskId)) return "conflict";
if (existing?.status === "cancelled") return "cancelled";
if (existing?.status === "completed") {
return matchesObservedTask(incoming, existing) ? "completed" : "conflict";
}
const resumesUnchangedPause = existing === pausedAtResume
&& existing?.status === "paused"
&& (parent?.status === "pending" || parent?.status === "transferring");
if (isTransferOrRootPauseLatched(rootId, incoming.id)
|| parent?.status === "paused" || parent?.status === "pausing"
|| (existing?.status === "paused" && !resumesUnchangedPause) || existing?.status === "pausing") return "paused";
if (parent?.status === "completed") return "conflict";
if (!existing) return "ready";
if (existing.status === "transferring"
&& existing.directoryEntryIndex === incoming.directoryEntryIndex
&& existing.directoryEntryIdentity === incoming.directoryEntryIdentity) return "ready";
// Explicit retry may leave failed/interrupted and replace its manifest identity.
// Do not overwrite byte checkpoints with a delayed renderer snapshot.
const next = tasks.slice();
next[index] = {
...existing,
directoryEntryIndex: incoming.directoryEntryIndex,
directoryEntryIdentity: incoming.directoryEntryIdentity,
...(existing.status === "transferring" ? {} : {
status: "transferring" as const,
lifecycleEpoch: undefined,
error: undefined,
endTime: undefined,
reconnectRequired: false,
}),
};
tasks = next;
emitProgress(false);
return "ready";
},
observeTaskSettlement(expected) {
const observer: { expected: TransferTask; settled?: TransferTask } = { expected: { ...expected } };
const observers = settlementObservers.get(expected.id) ?? new Set();
observers.add(observer);
settlementObservers.set(expected.id, observers);
let disposed = false;
return {
read() {
if (disposed) return undefined;
if (observer.settled) return observer.settled;
const current = tasks.find((task) => task.id === expected.id);
return current && matchesObservedTask(observer.expected, current) ? current : undefined;
},
dispose() {
disposed = true;
observer.settled = undefined;
observers.delete(observer);
if (observers.size === 0) settlementObservers.delete(expected.id);
},
};
},
getTask(taskId) {
return tasks.find((task) => task.id === taskId);
},
@@ -1639,125 +1734,140 @@ export function createSftpTransferCenterStore(persistence?: StorePersistence): S
controls.adopt && controls.canAdopt?.(task)
)));
},
pause: (taskId) => invoke(taskId, "pause"),
subscribeResume(listener) {
resumeListeners.add(listener);
return () => { resumeListeners.delete(listener); };
},
isResuming(taskId) {
if (!resumeRequests.has(taskId)) return false;
const task = tasks.find((candidate) => candidate.id === taskId);
return !!task && !task.error
&& ["paused", "interrupted", "attention", "pending", "queued"].includes(task.status);
},
pause(taskId) {
clearResumeRequest(taskId);
return invoke(taskId, "pause");
},
async resume(taskId) {
const startFresh = () => {
const running = invoke(taskId, "resume").finally(() => {
if (resumeInvocations.get(taskId) === running) resumeInvocations.delete(taskId);
});
resumeInvocations.set(taskId, running);
return running;
};
const request = Symbol("resume");
resumeRequests.set(taskId, request);
notifyResume();
const runResume = async () => {
const startFresh = () => {
const running = invoke(taskId, "resume").finally(() => {
if (resumeInvocations.get(taskId) === running) resumeInvocations.delete(taskId);
});
resumeInvocations.set(taskId, running);
return running;
};
const existing = resumeInvocations.get(taskId);
if (existing) {
// Dedicated resume holds the invocation for the full stream lifetime.
const task = tasks.find((candidate) => candidate.id === taskId);
if (task?.status === "cancelled") return existing;
const existing = resumeInvocations.get(taskId);
if (existing) {
// Dedicated resume holds the invocation for the full stream lifetime.
const task = tasks.find((candidate) => candidate.id === taskId);
if (task?.status === "cancelled") return existing;
// Soft-unpause live backend streams when still paused under a held run.
// Dedicated *directory* walks treat status===paused as shouldAbort between
// files, so soft-rejoin is unsafe (dying promise + false transferring).
// Wind down soft-paused children then startFresh from checkpoints.
// Single-file dedicated (and non-directory live streams) soft-unpause.
if (task && (task.status === "paused" || task.status === "pausing")) {
const childIds = tasks
.filter((candidate) => candidate.parentTaskId === taskId
&& (candidate.status === "paused"
|| candidate.status === "pausing"
|| candidate.status === "transferring"))
.map((candidate) => candidate.id);
// Soft-unpause live backend streams when still paused under a held run.
// Dedicated *directory* walks treat status===paused as shouldAbort between
// files, so soft-rejoin is unsafe (dying promise + false transferring).
// Wind down soft-paused children then startFresh from checkpoints.
// Single-file dedicated (and non-directory live streams) soft-unpause.
if (task && (task.status === "paused" || task.status === "pausing")) {
const childIds = tasks
.filter((candidate) => candidate.parentTaskId === taskId
&& (candidate.status === "paused"
|| candidate.status === "pausing"
|| candidate.status === "transferring"))
.map((candidate) => candidate.id);
// Always clear process-global latches + control epoch so walks wake and
// late soft-drain / pauseWatch cannot re-pause streams. Do not stamp
// control epoch as task.lifecycleEpoch (bridge-aligned only).
bumpTransferControlEpoch(taskId);
releaseTransferPauseTree(taskId, childIds);
// Always clear process-global latches + control epoch so walks wake and
// late soft-drain / pauseWatch cannot re-pause streams. Do not stamp
// control epoch as task.lifecycleEpoch (bridge-aligned only).
let resumeEpoch = bumpTransferControlEpoch(taskId);
releaseTransferPauseTree(taskId, childIds);
if (task.ownerId === "dedicated-resume" && task.isDirectory) {
const bridge = netcattyBridge.get();
// Cancel soft-paused child streams so the held walk settles. When a
// child is not in activeTransfers, cancelTransfer leaves a sticky
// pendingCancel latch — clear it before startFresh reuses the same
// child transferIds (otherwise startStreamTransfer aborts immediately).
for (const id of childIds) {
try { await bridge?.cancelTransfer?.(id); } catch { /* best-effort wind-down */ }
try { await bridge?.clearPendingTransferCancel?.(id); } catch { /* best-effort */ }
if (task.ownerId === "dedicated-resume" && task.isDirectory) {
const bridge = netcattyBridge.get();
// Cancel soft-paused child streams so the held walk settles. When a
// child is not in activeTransfers, cancelTransfer leaves a sticky
// pendingCancel latch — clear it before startFresh reuses the same
// child transferIds (otherwise startStreamTransfer aborts immediately).
for (const id of childIds) {
try { await bridge?.cancelTransfer?.(id); } catch { /* best-effort wind-down */ }
try { await bridge?.clearPendingTransferCancel?.(id); } catch { /* best-effort */ }
}
try { await bridge?.clearPendingTransferCancel?.(taskId); } catch { /* best-effort */ }
try {
await existing;
} catch { /* previous aborted / cancelled */ }
// Clear again after wind-down in case cancel raced during await.
for (const id of childIds) {
try { await bridge?.clearPendingTransferCancel?.(id); } catch { /* best-effort */ }
}
try { await bridge?.clearPendingTransferCancel?.(taskId); } catch { /* best-effort */ }
if (!isTransferControlEpochCurrent(taskId, resumeEpoch)) return existing;
return resumeInvocations.get(taskId) ?? startFresh();
}
try { await bridge?.clearPendingTransferCancel?.(taskId); } catch { /* best-effort */ }
try {
// Reuse the live control path: held dedicated transfers must obey
// the same ordering and per-child lifecycle rules as panel jobs.
const softOperation = softResumeTransfer({
getTasks: () => tasks,
setTasks: (next) => { tasks = next; emit(); },
getBridge: defaultTransferControlBridge,
}, taskId);
resumeEpoch = getTransferControlEpoch(taskId);
const soft = await softOperation;
if (soft.handled) return existing;
const streamGone = /no longer active|not active|not found|session is no longer|Resume unavailable|Transfer not found/i
.test(soft.reason ?? "");
if (!streamGone) {
tasks = tasks.map((candidate) => candidate.id === taskId ? {
...candidate,
status: "paused" as const,
speed: 0,
phase: undefined,
error: soft.reason,
} : candidate);
emit();
return existing;
}
} catch {
// Fall through to await + restart.
}
if (!isTransferControlEpochCurrent(taskId, resumeEpoch)) return existing;
try {
await existing;
} catch { /* previous aborted / cancelled */ }
// Clear again after wind-down in case cancel raced during await.
for (const id of childIds) {
try { await bridge?.clearPendingTransferCancel?.(id); } catch { /* best-effort */ }
}
try { await bridge?.clearPendingTransferCancel?.(taskId); } catch { /* best-effort */ }
} catch { /* previous aborted */ }
if (!isTransferControlEpochCurrent(taskId, resumeEpoch)) return existing;
return resumeInvocations.get(taskId) ?? startFresh();
}
try {
const resumeIds = [taskId, ...childIds.filter((id) => id !== taskId)];
const results = await Promise.all(resumeIds.map(async (id) =>
netcattyBridge.get()?.resumeTransfer?.(id) ?? { success: false },
));
const after = tasks.find((candidate) => candidate.id === taskId);
if (after?.status === "cancelled") return existing;
// Only rejoin when at least one backend stream actually resumed.
// Empty/all-fail must not paint transferring over a dead held run.
const successIds = resumeIds.filter((_, index) => results[index]?.success);
if (successIds.length > 0) {
const resumed = new Set(successIds);
// Align with softResumeTransfer: prefer bridge lifecycleEpoch; clear
// if omitted so main-process progress is not stale-dropped.
let bridgeEpoch: number | undefined;
for (let index = 0; index < results.length; index += 1) {
if (!results[index]?.success) continue;
const epoch = (results[index] as { lifecycleEpoch?: number } | undefined)?.lifecycleEpoch;
if (!Number.isFinite(epoch)) continue;
bridgeEpoch = bridgeEpoch === undefined
? (epoch as number)
: Math.max(bridgeEpoch, epoch as number);
}
tasks = tasks.map((candidate) => {
if (candidate.id === taskId || resumed.has(candidate.id)) {
return {
...candidate,
status: "transferring" as const,
error: undefined,
reconnectRequired: false,
pauseUnavailableReason: undefined,
phase: undefined,
lifecycleEpoch: bridgeEpoch,
};
}
return candidate;
});
emit();
return existing;
}
} catch {
// Fall through to await + restart.
// After demotion to interrupted/attention/failed while work unwinds:
// wait then re-invoke (do not rejoin a dying canceling promise).
if (task && (task.status === "interrupted" || task.status === "attention" || task.status === "failed")) {
const resumeEpoch = getTransferControlEpoch(taskId);
try {
await existing;
} catch { /* previous aborted */ }
if (getTransferControlEpoch(taskId) !== resumeEpoch) return existing;
return resumeInvocations.get(taskId) ?? startFresh();
}
try {
await existing;
} catch { /* previous aborted */ }
return resumeInvocations.get(taskId) ?? startFresh();
return existing;
}
// After demotion to interrupted/attention/failed while work unwinds:
// wait then re-invoke (do not rejoin a dying canceling promise).
if (task && (task.status === "interrupted" || task.status === "attention" || task.status === "failed")) {
try {
await existing;
} catch { /* previous aborted */ }
return resumeInvocations.get(taskId) ?? startFresh();
}
return existing;
return startFresh();
};
try {
return await runResume();
} finally {
if (resumeRequests.get(taskId) === request) clearResumeRequest(taskId);
}
return startFresh();
},
cancel: (taskId) => invoke(taskId, "cancel"),
cancel(taskId) {
clearResumeRequest(taskId);
return invoke(taskId, "cancel");
},
async retry(taskId) {
const ownerId = findOwner(taskId);
const task = tasks.find((candidate) => candidate.id === taskId);
@@ -2355,3 +2465,16 @@ export function useSftpTransferTask(taskId: string, fallback: TransferTask): Tra
() => fallbackRef.current,
);
}
/** Shared control feedback for every transfer surface, including newly mounted rows. */
export function useSftpTransferResuming(taskId: string): boolean {
return useSyncExternalStore(
(listener) => {
const unsubscribeTask = sftpTransferCenterStore.subscribe(listener);
const unsubscribeResume = sftpTransferCenterStore.subscribeResume(listener);
return () => { unsubscribeTask(); unsubscribeResume(); };
},
() => sftpTransferCenterStore.isResuming(taskId),
() => false,
);
}
@@ -0,0 +1,46 @@
import assert from "node:assert/strict";
import test from "node:test";
import { createSftpTransferCenterStore } from "./sftpTransferCenterStore";
for (const outcome of ["success", "failure", "pause", "cancel"] as const) {
test(`resume feedback clears after ${outcome} without changing the paused checkpoint`, async (t) => {
const previous = Object.getOwnPropertyDescriptor(globalThis, "window");
t.after(() => {
if (previous) Object.defineProperty(globalThis, "window", previous);
else Reflect.deleteProperty(globalThis, "window");
});
let settle!: (value: { success: boolean; reason?: string }) => void;
let started!: () => void;
const entered = new Promise<void>((resolve) => { started = resolve; });
const gate = new Promise<{ success: boolean; reason?: string }>((resolve) => { settle = resolve; });
Object.defineProperty(globalThis, "window", { configurable: true, value: { netcatty: {
resumeTransfer: () => { started(); return gate; },
pauseTransfer: async () => ({ success: true, checkpointBytes: 100 }),
cancelTransfer: async () => ({ success: true }),
} } });
const store = createSftpTransferCenterStore();
store.publishOwner("feedback", [{
id: "feedback", fileName: "file.bin", sourcePath: "/source.bin", targetPath: "/target.bin",
sourceConnectionId: "remote", targetConnectionId: "local", direction: "download",
status: "paused", totalBytes: 1000, transferredBytes: 100, checkpointBytes: 100,
speed: 0, startTime: 1, isDirectory: false, resumable: true,
}]);
let notifications = 0;
const unsubscribe = store.subscribeResume(() => { notifications += 1; });
t.after(unsubscribe);
const running = store.resume("feedback");
assert.equal(store.isResuming("feedback"), true);
assert.equal(store.getTask("feedback")?.status, "paused");
assert.equal(store.getTask("feedback")?.checkpointBytes, 100);
await entered;
const followup = outcome === "pause" ? store.pause("feedback")
: outcome === "cancel" ? store.cancel("feedback") : Promise.resolve();
if (outcome === "pause" || outcome === "cancel") assert.equal(store.isResuming("feedback"), false);
settle(outcome === "failure" ? { success: false, reason: "Resume safety check failed" } : { success: true });
await Promise.all([running, followup]);
assert.equal(store.isResuming("feedback"), false);
assert.ok(notifications >= 2);
if (outcome === "pause") assert.equal(store.getTask("feedback")?.status, "paused");
if (outcome === "cancel") assert.equal(store.getTask("feedback")?.status, "cancelled");
});
}
@@ -0,0 +1,79 @@
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import test from 'node:test';
import vm from 'node:vm';
import React, { useCallback, useRef, useSyncExternalStore } from 'react';
import { act, create } from 'react-test-renderer';
import ts from 'typescript';
import { getSidePanelLiveSnapshot, subscribeSidePanelLiveSnapshot, sidePanelLiveStore, SIDE_PANEL_INACTIVE_LIVE_SNAPSHOT, type SidePanelLiveSnapshot } from './sidePanelLiveStore';
// Exercise the production hook without importing the unrelated panel UI modules.
const source = readFileSync(new URL('../../components/terminalLayer/terminalLayerSidePanelSlots.tsx', import.meta.url), 'utf8');
const ast = ts.createSourceFile('slots.tsx', source, ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX);
const hook = ast.statements.find((node) => ts.isFunctionDeclaration(node) && node.name?.text === 'useSidePanelLiveSnapshotForTab');
assert.ok(hook);
const code = ts.transpile(hook.getText(ast), { target: ts.ScriptTarget.ES2022 });
const useSnapshot = vm.runInNewContext(`${code}; useSidePanelLiveSnapshotForTab`, {
useCallback, useRef, useSyncExternalStore, getSidePanelLiveSnapshot,
subscribeSidePanelLiveSnapshot, SIDE_PANEL_INACTIVE_LIVE_SNAPSHOT,
}) as (tabId: string, enabled: boolean) => SidePanelLiveSnapshot;
const env = globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean };
test('returning to a tab never exposes the previous tab snapshot before its publisher catches up', async () => {
const previous = env.IS_REACT_ACT_ENVIRONMENT;
env.IS_REACT_ACT_ENVIRONMENT = true;
const own = { ...SIDE_PANEL_INACTIVE_LIVE_SNAPSHOT, focusedSessionId: 'ssh-a', activeTerminalSessionIdForSftp: 'ssh-a', activeTerminalCwd: '/root', activeTerminalCwdTrusted: true };
const other = { ...SIDE_PANEL_INACTIVE_LIVE_SNAPSHOT, focusedSessionId: 'local-b' };
let visible = true;
let observed: SidePanelLiveSnapshot = SIDE_PANEL_INACTIVE_LIVE_SNAPSHOT;
function Probe() { observed = useSnapshot('ssh-a', visible); return null; }
sidePanelLiveStore.update(own);
let renderer: ReturnType<typeof create> | undefined;
try {
await act(async () => { renderer = create(React.createElement(Probe)); });
assert.equal(observed.focusedSessionId, 'ssh-a');
visible = false;
await act(async () => { renderer?.update(React.createElement(Probe)); });
await act(async () => { sidePanelLiveStore.update(other); });
visible = true;
await act(async () => { renderer?.update(React.createElement(Probe)); });
assert.equal(observed.focusedSessionId, 'ssh-a', 'stale local session must not enter the SSH panel');
assert.equal(observed.activeTerminalCwd, '/root', 'unchanged cwd must survive the reveal gap');
await act(async () => { sidePanelLiveStore.update({ ...own, activeTerminalCwd: '/srv/changed' }); });
assert.equal(observed.activeTerminalCwd, '/srv/changed', 'real cwd changes must still arrive');
} finally {
await act(async () => renderer?.unmount());
sidePanelLiveStore.update(SIDE_PANEL_INACTIVE_LIVE_SNAPSHOT);
env.IS_REACT_ACT_ENVIRONMENT = previous;
}
});
test('new panels wait for their own snapshot and workspace panes accept focus changes within the workspace', async () => {
const previous = env.IS_REACT_ACT_ENVIRONMENT;
env.IS_REACT_ACT_ENVIRONMENT = true;
let observed = SIDE_PANEL_INACTIVE_LIVE_SNAPSHOT;
const workspace = { id: 'workspace-a' } as NonNullable<SidePanelLiveSnapshot['activeWorkspace']>;
const own = { ...SIDE_PANEL_INACTIVE_LIVE_SNAPSHOT, activeWorkspace: workspace, focusedSessionId: 'pane-a' };
let panelTabId = 'workspace-a';
function Probe() { observed = useSnapshot(panelTabId, true); return null; }
sidePanelLiveStore.update({ ...SIDE_PANEL_INACTIVE_LIVE_SNAPSHOT, focusedSessionId: 'unrelated' });
let renderer: ReturnType<typeof create> | undefined;
try {
await act(async () => { renderer = create(React.createElement(Probe)); });
assert.equal(observed, SIDE_PANEL_INACTIVE_LIVE_SNAPSHOT);
await act(async () => { sidePanelLiveStore.update(own); });
assert.equal(observed.focusedSessionId, 'pane-a');
await act(async () => { sidePanelLiveStore.update({ ...own, focusedSessionId: 'pane-b' }); });
assert.equal(observed.focusedSessionId, 'pane-b');
await act(async () => { sidePanelLiveStore.update({ ...SIDE_PANEL_INACTIVE_LIVE_SNAPSHOT, focusedSessionId: 'unrelated' }); });
assert.equal(observed.focusedSessionId, 'pane-b');
panelTabId = 'new-owner';
await act(async () => { renderer?.update(React.createElement(Probe)); });
assert.equal(observed, SIDE_PANEL_INACTIVE_LIVE_SNAPSHOT, 'a reused hook must not retain another owner');
} finally {
await act(async () => renderer?.unmount());
sidePanelLiveStore.update(SIDE_PANEL_INACTIVE_LIVE_SNAPSHOT);
env.IS_REACT_ACT_ENVIRONMENT = previous;
}
});
@@ -7,6 +7,7 @@ import {
createTerminalSelectionAttachment,
decodeTerminalSelectionAttachment,
} from "./terminalSelectionAttachment.ts";
import { createVaultNoteAttachment, formatVaultNoteReferences } from "./vaultNoteAttachment.ts";
test("createTerminalSelectionAttachment returns null for blank selections", () => {
assert.equal(createTerminalSelectionAttachment(" \n\t"), null);
@@ -50,3 +51,13 @@ test("buildPromptWithTerminalSelectionAttachments supports terminal-only prompts
`[Terminal selection: ${attachment.filename}]\nsystemctl status nginx`,
);
});
test("buildPromptWithTerminalSelectionAttachments keeps empty Vault note blocks", () => {
const attachment = createVaultNoteAttachment({ id: "note-1", title: "Runbook" });
assert.ok(attachment);
assert.equal(
buildPromptWithTerminalSelectionAttachments("add deployment steps to this note", [attachment]),
`add deployment steps to this note\n\n${formatVaultNoteReferences([attachment])}`,
);
});
@@ -1,4 +1,8 @@
import type { ChatMessageAttachment, UploadedFile } from "../../infrastructure/ai/types";
import {
formatVaultNoteReferences,
isVaultNoteAttachment,
} from "./vaultNoteAttachment";
export const TERMINAL_SELECTION_ATTACHMENT_MEDIA_TYPE = "text/plain";
@@ -81,6 +85,16 @@ export function isTerminalSelectionAttachment(
return attachment.terminalSelection === true;
}
/**
* Attachments whose text is inlined into the prompt instead of being sent as
* file parts: terminal selections and Vault note mentions.
*/
export function isInlineTextAttachment(
attachment: Pick<ChatMessageAttachment | UploadedFile, "terminalSelection" | "vaultNoteId">,
): boolean {
return isTerminalSelectionAttachment(attachment) || isVaultNoteAttachment(attachment);
}
export function buildPromptWithTerminalSelectionAttachments(
prompt: string,
attachments: Array<ChatMessageAttachment | UploadedFile>,
@@ -95,7 +109,11 @@ export function buildPromptWithTerminalSelectionAttachments(
})
.filter((block): block is string => block !== null);
if (terminalBlocks.length === 0) return prompt;
if (!prompt.trim()) return terminalBlocks.join("").trimStart();
return `${prompt}${terminalBlocks.join("")}`;
const notes = attachments.filter(isVaultNoteAttachment);
const noteBlocks = notes.length ? [`\n\n${formatVaultNoteReferences(notes)}`] : [];
const blocks = [...terminalBlocks, ...noteBlocks];
if (blocks.length === 0) return prompt;
if (!prompt.trim()) return blocks.join("").trimStart();
return `${prompt}${blocks.join("")}`;
}
@@ -0,0 +1,65 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import React from 'react';
import { createDomRenderer, flushEffects, installDomEnvironment } from '../../components/test-support/renderReactDom';
import { EncryptionService } from '../../infrastructure/services/EncryptionService';
import type { SyncManagerState } from '../../infrastructure/services/CloudSyncManager';
import type { SyncPayload } from '../../domain/sync';
test('manual sync does not clear a newly shared password when an older unlock is superseded', async (t) => {
const env = installDomEnvironment();
const previousStorage = Object.getOwnPropertyDescriptor(globalThis, "localStorage");
Object.defineProperty(globalThis, "localStorage", { configurable: true, value: env.window.localStorage });
t.after(() => {
if (previousStorage) Object.defineProperty(globalThis, "localStorage", previousStorage);
else Reflect.deleteProperty(globalThis, "localStorage");
});
let sharedPassword: string | undefined;
let clears = 0;
Object.defineProperty(env.window, 'netcatty', { configurable: true, value: {
cloudSyncGetSessionPassword: async () => sharedPassword,
cloudSyncClearSessionPassword: async () => { clears += 1; sharedPassword = undefined; },
} });
const { getCloudSyncManager } = await import('../../infrastructure/services/CloudSyncManager');
const manager = getCloudSyncManager();
await manager.setupMasterKey('old-fixture-password');
manager.lock();
const newConfig = await EncryptionService.createMasterKeyConfig('new-fixture-password');
const { useCloudSync } = await import('./useCloudSync');
let current!: ReturnType<typeof useCloudSync>;
const Probe = () => { current = useCloudSync(); return null; };
const renderer = await createDomRenderer(env.document);
const originalUnlock = EncryptionService.unlockMasterKey;
let release!: () => void;
let started!: () => void;
const gate = new Promise<void>((resolve) => { release = resolve; });
const entered = new Promise<void>((resolve) => { started = resolve; });
try {
await renderer.render(<Probe />);
await flushEffects();
EncryptionService.unlockMasterKey = async (...args) => {
started();
await gate;
return originalUnlock(...args);
};
sharedPassword = 'old-fixture-password';
const result = current.syncNow({} as SyncPayload).then(() => null, (error: unknown) => error);
await entered;
const state = Reflect.get(manager, 'state') as SyncManagerState;
state.masterKeyConfig = newConfig;
Reflect.get(manager, 'bumpSyncSecurityGeneration').call(manager);
sharedPassword = 'new-fixture-password';
release();
const error = await result;
assert.equal(clears, 0, 'stale work must not erase the new shared password');
assert.equal(sharedPassword, 'new-fixture-password');
assert.ok(error instanceof Error);
assert.match(error.message, /changed while unlocking/i);
} finally {
release();
EncryptionService.unlockMasterKey = originalUnlock;
await renderer.unmount();
manager.destroy();
env.cleanup();
}
});
+9 -36
View File
@@ -6,6 +6,7 @@
* Uses useSyncExternalStore for real-time state synchronization across all components.
*/
import { useCloudSyncAutoUnlock } from './useCloudSyncAutoUnlock';
import { useCallback, useEffect, useMemo, useRef, useSyncExternalStore } from 'react';
import {
type CloudProvider,
@@ -266,42 +267,14 @@ export const useCloudSync = (): CloudSyncHook => {
getConvergentSyncLocalConfigSnapshot,
);
// Auto-unlock: if a master key exists, retrieve the persisted password (Electron safeStorage)
// and unlock silently so users don't have to manage a LOCKED state in the UI.
// Track the master key config hash to detect when a new master key is set up in another window.
const lastMasterKeyHashRef = useRef<string | null>(null);
const attemptedAutoUnlockRef = useRef(false);
useEffect(() => {
// Compute a simple hash of the master key config to detect changes
const currentHash = state.masterKeyConfig
? JSON.stringify({ salt: state.masterKeyConfig.salt, kdf: state.masterKeyConfig.kdf })
: null;
// If master key config changed (e.g., set up in settings window), reset the attempt flag
if (currentHash !== lastMasterKeyHashRef.current) {
lastMasterKeyHashRef.current = currentHash;
attemptedAutoUnlockRef.current = false;
}
if (attemptedAutoUnlockRef.current) return;
if (state.securityState !== 'LOCKED') return;
attemptedAutoUnlockRef.current = true;
void (async () => {
try {
const bridge = netcattyBridge.get();
const password = await bridge?.cloudSyncGetSessionPassword?.();
if (!password) return;
const ok = await manager.unlock(password);
if (!ok) {
void bridge?.cloudSyncClearSessionPassword?.();
}
} catch {
// Ignore auto-unlock errors; manual actions will surface them.
}
})();
}, [state.securityState, state.masterKeyConfig]);
useCloudSyncAutoUnlock({
securityState: state.securityState,
masterKeyIdentity: state.masterKeyConfig
? JSON.stringify(state.masterKeyConfig)
: null,
manager,
bridge: netcattyBridge.get(),
});
useEffect(() => {
if (state.securityState !== 'UNLOCKED') return;
@@ -0,0 +1,253 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import React from 'react';
import { createDomRenderer, flushEffects, installDomEnvironment, runWithAct } from '../../components/test-support/renderReactDom.tsx';
import { useCloudSyncAutoUnlock } from './useCloudSyncAutoUnlock.ts';
for (const strict of [false, true]) {
test(`pending password read is handed off when its owner unmounts (StrictMode=${strict})`, async () => {
const dom = installDomEnvironment();
const renderer = await createDomRenderer(dom.document);
const pending: Array<(value: string | null) => void> = [];
let unlocks = 0;
const manager = { unlock: async () => { unlocks++; return true; } };
const bridge = { cloudSyncGetSessionPassword: () => new Promise<string | null>(resolve => { pending.push(resolve); }) };
function Consumer() {
useCloudSyncAutoUnlock({ securityState: 'LOCKED', masterKeyIdentity: 'handoff-key', manager, bridge });
return null;
}
function tree(owner: boolean) {
return React.createElement(strict ? React.StrictMode : React.Fragment, null,
owner ? React.createElement(Consumer, { key: 'owner' }) : null,
React.createElement(Consumer, { key: 'peer' }));
}
try {
await renderer.render(tree(true));
await flushEffects();
assert.ok(pending.length > 0);
await renderer.render(tree(false));
await flushEffects();
await runWithAct(async () => { for (const resolve of pending) resolve('fixture-only'); });
await flushEffects();
assert.equal(unlocks, 1, 'cancelled owners must not unlock when their old responses arrive');
} finally { await renderer.unmount(); dom.cleanup(); }
});
}
for (const strict of [false, true]) {
test(`peer auto-unlocks when password arrives after the config (StrictMode=${strict})`, async () => {
const dom = installDomEnvironment();
const renderer = await createDomRenderer(dom.document);
let password: string | null = null;
const listeners = new Set<() => void>();
const unlocked: string[] = [];
const manager = { unlock: async (value: string) => { unlocked.push(value); return true; } };
const bridge = {
cloudSyncGetSessionPassword: async () => password,
onCloudSyncSessionPasswordAvailable: (cb: () => void) => { listeners.add(cb); return () => { listeners.delete(cb); }; },
};
function Harness() {
useCloudSyncAutoUnlock({ securityState: 'LOCKED', masterKeyIdentity: 'first-key', manager, bridge });
return null;
}
try {
await renderer.render(strict ? React.createElement(React.StrictMode, null, React.createElement(Harness)) : React.createElement(Harness));
await flushEffects();
assert.deepEqual(unlocked, [], 'the initial read really happened before the password existed');
await runWithAct(async () => { password = 'fixture-only'; for (const cb of listeners) cb(); });
await flushEffects();
assert.deepEqual(unlocked, ['fixture-only']);
} finally { await renderer.unmount(); dom.cleanup(); }
assert.equal(listeners.size, 0);
});
}
for (const strict of [false, true]) {
test(`a failed attempt during key rotation keeps the password for the arriving config (StrictMode=${strict})`, async () => {
const dom = installDomEnvironment();
const renderer = await createDomRenderer(dom.document);
let password: string | null = null;
let cleared = 0;
let currentIdentity = 'old-key';
const listeners = new Set<() => void>();
const unlocked: Array<[string, string]> = [];
const manager = {
unlock: async (value: string) => {
unlocked.push([currentIdentity, value]);
return currentIdentity === 'new-key';
},
};
const bridge = {
cloudSyncGetSessionPassword: async () => password,
cloudSyncClearSessionPassword: async () => { cleared++; return true; },
onCloudSyncSessionPasswordAvailable: (cb: () => void) => { listeners.add(cb); return () => { listeners.delete(cb); }; },
};
function Harness() {
useCloudSyncAutoUnlock({ securityState: 'LOCKED', masterKeyIdentity: currentIdentity, manager, bridge });
return null;
}
try {
await renderer.render(strict ? React.createElement(React.StrictMode, null, React.createElement(Harness)) : React.createElement(Harness));
await flushEffects();
// The password notification overtakes the config event: the peer is
// still locked on the old config when the new password arrives.
await runWithAct(async () => { password = 'rotation-password'; for (const cb of listeners) cb(); });
await flushEffects();
assert.deepEqual(unlocked, [['old-key', 'rotation-password']], 'the new password was tried against the old config');
assert.equal(cleared, 0, 'the failed attempt must not clear the shared password');
// The config event arrives afterwards; the retained password must
// still be available to auto-unlock the rotated key.
currentIdentity = 'new-key';
await renderer.render(strict ? React.createElement(React.StrictMode, null, React.createElement(Harness)) : React.createElement(Harness));
await flushEffects();
assert.deepEqual(unlocked, [
['old-key', 'rotation-password'],
['new-key', 'rotation-password'],
]);
assert.equal(cleared, 0);
} finally { await renderer.unmount(); dom.cleanup(); }
assert.equal(listeners.size, 0);
});
}
test('completed auto-unlock does not undo a later deliberate lock', async () => {
const dom = installDomEnvironment();
const renderer = await createDomRenderer(dom.document);
let calls = 0;
const manager = { unlock: async () => { calls++; return true; } };
let onAvailable: (() => void) | undefined;
const bridge = {
cloudSyncGetSessionPassword: async () => 'fixture-only',
onCloudSyncSessionPasswordAvailable: (cb: () => void) => { onAvailable = cb; return () => { onAvailable = undefined; }; },
};
function Harness({ state, identity = 'key' }: { state: string; identity?: string }) {
useCloudSyncAutoUnlock({ securityState: state, masterKeyIdentity: identity, manager, bridge });
return null;
}
try {
await renderer.render(React.createElement(Harness, { state: 'LOCKED' }));
await flushEffects();
await renderer.render(React.createElement(Harness, { state: 'UNLOCKED' }));
await runWithAct(async () => { onAvailable?.(); });
await renderer.render(React.createElement(Harness, { state: 'LOCKED' }));
await flushEffects();
assert.equal(calls, 1);
await runWithAct(async () => { onAvailable?.(); });
await flushEffects();
assert.equal(calls, 1, 'a peer password event after deliberate lock must not reopen this vault');
await renderer.render(null);
await renderer.render(React.createElement(Harness, { state: 'LOCKED' }));
await runWithAct(async () => { onAvailable?.(); });
await flushEffects();
assert.equal(calls, 1, 'a remounted consumer must retain the manager deliberate lock');
await renderer.render(React.createElement(Harness, { state: 'LOCKED', identity: 'replacement-key' }));
await flushEffects();
assert.equal(calls, 2, 'a genuinely replaced key can still initialize automatically');
} finally { await renderer.unmount(); dom.cleanup(); }
});
for (const strict of [false, true]) {
test(`remount reads a password shared while no consumer was mounted (StrictMode=${strict})`, async () => {
const dom = installDomEnvironment();
const renderer = await createDomRenderer(dom.document);
let password: string | null = null;
let reads = 0;
const unlocked: string[] = [];
const manager = { unlock: async (value: string) => { unlocked.push(value); return true; } };
const bridge = { cloudSyncGetSessionPassword: async () => { reads++; return password; } };
function Consumer() {
useCloudSyncAutoUnlock({ securityState: 'LOCKED', masterKeyIdentity: 'remount-key', manager, bridge });
return null;
}
const tree = () => React.createElement(strict ? React.StrictMode : React.Fragment, null, React.createElement(Consumer));
try {
await renderer.render(tree());
await flushEffects();
assert.ok(reads > 0);
assert.deepEqual(unlocked, []);
await renderer.render(null);
await flushEffects();
password = 'fixture-shared-while-absent';
await renderer.render(tree());
await flushEffects();
assert.deepEqual(unlocked, ['fixture-shared-while-absent']);
} finally { await renderer.unmount(); dom.cleanup(); }
});
}
test('remount during an active derivation does not start a duplicate unlock', async () => {
const dom = installDomEnvironment();
const renderer = await createDomRenderer(dom.document);
let finish!: (value: boolean) => void;
let unlocks = 0;
const manager = { unlock: () => { unlocks++; return new Promise<boolean>(resolve => { finish = resolve; }); } };
const bridge = { cloudSyncGetSessionPassword: async () => 'fixture-only' };
function Consumer() {
useCloudSyncAutoUnlock({ securityState: 'LOCKED', masterKeyIdentity: 'active-key', manager, bridge });
return null;
}
try {
await renderer.render(React.createElement(Consumer));
await flushEffects();
assert.equal(unlocks, 1);
await renderer.render(null);
await renderer.render(React.createElement(Consumer));
await flushEffects();
assert.equal(unlocks, 1);
await runWithAct(async () => finish(true));
await flushEffects();
assert.equal(unlocks, 1);
} finally { await renderer.unmount(); dom.cleanup(); }
});
test('two StrictMode consumers share one derivation after initial effect replay', async () => {
const dom = installDomEnvironment();
const renderer = await createDomRenderer(dom.document);
const pending: Array<(value: string | null) => void> = [];
let unlocks = 0;
const manager = { unlock: async () => { unlocks++; return true; } };
const bridge = { cloudSyncGetSessionPassword: () => new Promise<string | null>(resolve => pending.push(resolve)) };
function Consumer() {
useCloudSyncAutoUnlock({ securityState: 'LOCKED', masterKeyIdentity: 'strict-shared', manager, bridge });
return null;
}
try {
await renderer.render(React.createElement(React.StrictMode, null,
React.createElement(Consumer, { key: 'one' }), React.createElement(Consumer, { key: 'two' })));
await flushEffects();
await runWithAct(async () => { for (const resolve of pending) resolve('fixture-only'); });
await flushEffects();
assert.equal(unlocks, 1);
} finally { await renderer.unmount(); dom.cleanup(); }
});
test('password notification and a later peer mount share the same attempt', async () => {
const dom = installDomEnvironment();
const renderer = await createDomRenderer(dom.document);
let password: string | null = null;
const listeners = new Set<() => void>();
let unlocks = 0;
const manager = { unlock: async () => { unlocks++; return false; } };
const bridge = {
cloudSyncGetSessionPassword: async () => password,
onCloudSyncSessionPasswordAvailable: (cb: () => void) => { listeners.add(cb); return () => { listeners.delete(cb); }; },
};
function Consumer() {
useCloudSyncAutoUnlock({ securityState: 'LOCKED', masterKeyIdentity: 'notification-key', manager, bridge });
return null;
}
const tree = (late: boolean) => React.createElement(React.Fragment, null,
React.createElement(Consumer, { key: 'one' }), React.createElement(Consumer, { key: 'two' }),
late ? React.createElement(Consumer, { key: 'late' }) : null);
try {
await renderer.render(tree(false));
await flushEffects();
assert.equal(unlocks, 0);
await runWithAct(async () => { password = 'fixture-only'; for (const cb of listeners) cb(); });
await flushEffects();
assert.equal(unlocks, 1);
await renderer.render(tree(true));
await flushEffects();
assert.equal(unlocks, 1, 'new consumers share the notification generation');
} finally { await renderer.unmount(); dom.cleanup(); }
});
+115
View File
@@ -0,0 +1,115 @@
import { useEffect, useState } from 'react';
interface AutoUnlockManager {
unlock(password: string): Promise<boolean>;
}
interface AutoUnlockBridge {
cloudSyncGetSessionPassword?(): Promise<string | null>;
onCloudSyncSessionPasswordAvailable?(callback: () => void): () => void;
}
// A renderer can mount several consumers of the same manager. Remember the
// unlocked key across consumer unmounts so reopening settings cannot undo a lock.
const unlockedIdentityByManager = new WeakMap<AutoUnlockManager, string>();
// Dedupe attempts per manager (not per hook instance): every consumer of the
// same manager observes the same events, and without sharing they would each
// run the expensive PBKDF2 derivation inside manager.unlock.
const attemptedAttemptByManager = new WeakMap<AutoUnlockManager, string | null>();
const passwordRevisionByManager = new WeakMap<AutoUnlockManager, number>();
const completedAttemptByManager = new WeakMap<AutoUnlockManager, string>();
// Mounted consumers register a retry trigger here so an attempt can be handed
// off when its owner unmounts (see the cleanup below).
const retryListenersByManager = new WeakMap<AutoUnlockManager, Set<() => void>>();
/** Retry a locked peer window when the setting window finishes sharing its key. */
export function useCloudSyncAutoUnlock(input: {
securityState: string;
masterKeyIdentity: string | null;
manager: AutoUnlockManager;
bridge: AutoUnlockBridge | null | undefined;
}) {
const { securityState, masterKeyIdentity, manager, bridge } = input;
const [retryRevision, setRetryRevision] = useState(0);
useEffect(() => bridge?.onCloudSyncSessionPasswordAvailable?.(() => {
passwordRevisionByManager.set(manager, (passwordRevisionByManager.get(manager) ?? 0) + 1);
setRetryRevision(value => value + 1);
}), [bridge, manager]);
useEffect(() => {
if (!masterKeyIdentity) return;
const attempt = JSON.stringify([masterKeyIdentity, passwordRevisionByManager.get(manager) ?? 0]);
if (securityState === 'UNLOCKED') {
unlockedIdentityByManager.set(manager, masterKeyIdentity);
attemptedAttemptByManager.set(manager, attempt);
return;
}
if (securityState !== 'LOCKED') return;
// Once this key has been unlocked, a later lock is intentional. A peer
// sharing its password must not undo it, regardless of notification order.
if (unlockedIdentityByManager.get(manager) === masterKeyIdentity) return;
// Register before the dedup check: a consumer that only observes an
// in-flight attempt must still be reachable for an ownership handoff.
let listeners = retryListenersByManager.get(manager);
if (!listeners) {
listeners = new Set();
retryListenersByManager.set(manager, listeners);
}
const notifyRetry = () => setRetryRevision(value => value + 1);
listeners.add(notifyRetry);
const removeListener = () => {
listeners.delete(notifyRetry);
if (listeners.size === 0 && completedAttemptByManager.get(manager) === attemptedAttemptByManager.get(manager)) {
attemptedAttemptByManager.delete(manager);
completedAttemptByManager.delete(manager);
}
};
if (attemptedAttemptByManager.get(manager) === attempt) {
return removeListener;
}
attemptedAttemptByManager.set(manager, attempt);
completedAttemptByManager.delete(manager);
let handedOff = false;
let cancelled = false;
let awaitingPassword = true;
void (async () => {
try {
const password = await bridge?.cloudSyncGetSessionPassword?.();
awaitingPassword = false;
if (cancelled || !password) return;
// A failed attempt must not clear the shared password: the config and
// the password travel over unordered channels (localStorage vs IPC),
// so this attempt may have raced a master-key rotation and the
// password may match the config that is still in flight. The attempt
// is deduped per [identity, revision], so keeping the password lets
// the arriving config trigger a retry instead of stranding the vault.
await manager.unlock(password);
} catch {
// Explicit sync actions surface errors; keep auto-unlock silent.
} finally {
if (!handedOff && attemptedAttemptByManager.get(manager) === attempt) {
completedAttemptByManager.set(manager, attempt);
// Availability events can arrive while no consumer is mounted.
// A future mount must read again after this attempt has settled.
if (listeners.size === 0) {
attemptedAttemptByManager.delete(manager);
completedAttemptByManager.delete(manager);
}
}
}
})();
return () => {
cancelled = true;
removeListener();
// React StrictMode can immediately remount the same effect.
if (awaitingPassword && attemptedAttemptByManager.get(manager) === attempt) {
handedOff = true;
attemptedAttemptByManager.set(manager, null);
// Hand the attempt off: this consumer's in-flight password request is
// discarded as cancelled, but other mounted consumers already returned
// at the dedup check above, so they must be nudged to retry.
for (const notify of listeners) notify();
}
};
}, [securityState, masterKeyIdentity, manager, bridge, retryRevision]);
}
@@ -0,0 +1,54 @@
import assert from "node:assert/strict";
import test from "node:test";
import { createVaultNoteAttachment, formatVaultNoteReferences, vaultNoteReferencesFit } from "./vaultNoteAttachment.ts";
import { buildPromptWithTerminalSelectionAttachments, createTerminalSelectionAttachment, isInlineTextAttachment } from "./terminalSelectionAttachment.ts";
test("mentions carry identity without copying even large note bodies", () => {
const note = { id: "note-1", title: "Runbook", content: "secret body".repeat(200_000) };
const attachment = createVaultNoteAttachment(note)!;
assert.equal(attachment.vaultNoteId, note.id);
assert.equal(attachment.base64Data, "");
assert.equal(attachment.dataUrl, "");
assert.ok(JSON.stringify(attachment).length < 400);
assert.ok(isInlineTextAttachment(attachment));
const prompt = buildPromptWithTerminalSelectionAttachments("summarize", [attachment]);
assert.match(prompt, /vault_notes_get/);
assert.match(prompt, /latest content/);
assert.doesNotMatch(prompt, /secret body/);
});
test("same titled notes keep distinct exact IDs including imported special characters", () => {
for (const id of ["note-a", "note-b", ' quoted"\\id\r\n ']) {
const attachment = createVaultNoteAttachment({ id, title: "Same title" })!;
const reference = formatVaultNoteReferences([attachment]).split("\n")[0];
const [metadata] = JSON.parse(reference.slice("[Vault note references: ".length, -1));
assert.equal(metadata.noteId, id);
assert.equal(metadata.title, "Same title");
}
});
test("invalid IDs are rejected and displayed titles remain bounded without broken emoji", () => {
assert.equal(createVaultNoteAttachment({ id: " ", title: "x" }), null);
assert.equal(createVaultNoteAttachment({ id: "a".repeat(201), title: "x" }), null);
const attachment = createVaultNoteAttachment({ id: "ok", title: "😀".repeat(200) })!;
assert.equal(Array.from(attachment.vaultNoteTitle!).length, 120);
});
test("note-only sends and mixed terminal selections retain both kinds of context", () => {
const note = createVaultNoteAttachment({ id: "empty-note", title: "" })!;
const terminal = createTerminalSelectionAttachment("systemctl status nginx")!;
assert.match(buildPromptWithTerminalSelectionAttachments("", [note]), /empty-note/);
const prompt = buildPromptWithTerminalSelectionAttachments("compare", [note, terminal]);
assert.match(prompt, /systemctl status nginx/);
assert.match(prompt, /empty-note/);
assert.match(prompt, /^compare/);
});
test("reference limit reserves replay space and does not count ordinary files", () => {
const notes = Array.from({length: 10}, (_, i) => createVaultNoteAttachment({id: String(i).padEnd(200, "x"), title: "t".repeat(120)})!);
assert.equal(vaultNoteReferencesFit(notes), false);
const accepted = notes.filter((_, index) => vaultNoteReferencesFit(notes.slice(0, index + 1)));
assert.ok(accepted.length > 0);
assert.ok(formatVaultNoteReferences(accepted).length <= 1000);
assert.equal(vaultNoteReferencesFit([...accepted, {id: "file", filename: "file", mediaType: "text/plain", dataUrl: "", base64Data: "x".repeat(2_000_000)}]), true);
});
+38
View File
@@ -0,0 +1,38 @@
import type { VaultNote } from "../../domain/models";
import type { ChatMessageAttachment, UploadedFile } from "../../infrastructure/ai/types";
/** A mention stores only identity; the agent reads current content through the Vault tool. */
export function createVaultNoteAttachment(note: Pick<VaultNote, "id" | "title">): UploadedFile | null {
// Preserve imported IDs exactly. Reject oversized IDs instead of changing their identity.
if (!note.id.trim() || note.id.length > 200) return null;
const title = Array.from(note.title.trim() || "Untitled note").slice(0, 120).join("");
return {
id: crypto.randomUUID(),
filename: title,
mediaType: "text/markdown",
base64Data: "",
dataUrl: "",
vaultNoteId: note.id,
vaultNoteTitle: title,
};
}
export function isVaultNoteAttachment(
attachment: Pick<ChatMessageAttachment | UploadedFile, "vaultNoteId">,
): boolean {
return typeof attachment.vaultNoteId === "string" && attachment.vaultNoteId.length > 0;
}
/** JSON preserves special characters and whitespace in exact note IDs. */
export function formatVaultNoteReferences(
attachments: Array<Pick<ChatMessageAttachment | UploadedFile, "vaultNoteId" | "vaultNoteTitle">>,
): string {
const notes = attachments.map((attachment) => ({ noteId: attachment.vaultNoteId, title: attachment.vaultNoteTitle }));
return `[Vault note references: ${JSON.stringify(notes)}]\nUse vault_notes_get with each exact noteId to read the latest content before summarizing or editing. If unavailable, report that instead of choosing another note.`;
}
/** Reserve at least half of external replay's 2,000 characters for the user's request. */
export function vaultNoteReferencesFit(attachments: UploadedFile[]): boolean {
const notes = attachments.filter(isVaultNoteAttachment);
return notes.length === 0 || formatVaultNoteReferences(notes).length <= 1000;
}
+5
View File
@@ -88,6 +88,8 @@ interface AIChatPanelContentProps {
setGlobalPermissionMode?: (mode: AIPermissionMode) => void;
notes?: VaultNote[];
hosts?: Host[];
/** Mention Note: attach a Vault → Notes entry as inline context. */
onMentionNote?: (note: VaultNote) => void;
onOpenVaultNote?: (noteId: string) => void;
onOpenVaultHost?: (hostId: string) => void;
onOpenVaultSection?: (section: 'notes' | 'hosts') => void;
@@ -157,6 +159,7 @@ export const AIChatPanelContent: React.FC<AIChatPanelContentProps> = ({
setGlobalPermissionMode,
notes = [],
hosts = [],
onMentionNote,
onOpenVaultNote,
onOpenVaultHost,
onOpenVaultSection,
@@ -361,6 +364,8 @@ export const AIChatPanelContent: React.FC<AIChatPanelContentProps> = ({
onAddFiles={addFiles}
onRemoveFile={removeFile}
hosts={mentionHosts}
notes={notes}
onMentionNote={onMentionNote}
selectedUserSkills={selectedUserSkills}
userSkills={userSkillOptions}
quickMessages={quickMessages}
+66 -10
View File
@@ -9,9 +9,11 @@ import type {
AIPanelView,
AgentModelPreset,
AISessionScope,
UploadedFile,
DiscoveredAgent,
ExternalAgentConfig,
} from '../infrastructure/ai/types';
import type { VaultNote } from '../domain/models';
import type { ExecutorContext } from '../infrastructure/ai/cattyAgent/executor';
import {
filterAgentModelPresetsForCliVersion,
@@ -20,6 +22,7 @@ import {
resolveAgentModelSelection,
} from '../infrastructure/ai/types';
import { getExternalAgentSdkBackend, getManualAgentCommand, matchesManagedAgentConfig } from '../infrastructure/ai/managedAgents';
import { toast } from './ui/toast';
import { useAgentDiscovery } from '../application/state/useAgentDiscovery';
import {
getReadyUserSkillOptions,
@@ -42,8 +45,9 @@ import { draftsByScopeEqualIgnoringComposerText, selectDraftForAgentSwitch } fro
import { sanitizeContextWindow } from '../infrastructure/ai/contextCompaction';
import {
buildPromptWithTerminalSelectionAttachments,
isTerminalSelectionAttachment,
isInlineTextAttachment,
} from '../application/state/terminalSelectionAttachment';
import { createVaultNoteAttachment, isVaultNoteAttachment, vaultNoteReferencesFit } from '../application/state/vaultNoteAttachment';
import type { CodexIntegrationStatus } from './settings/tabs/ai/types';
import {
useAIChatStreaming,
@@ -597,6 +601,42 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
removeDraftFile(scopeKey, currentAgentId, fileId);
}, [removeDraftFile, scopeKey, currentAgentId]);
// External turns keep the tools from launch; changed settings cannot enable note reads mid-turn.
const canMentionNotes = currentAgentId === 'catty' || (toolIntegrationMode === 'mcp' && !isStreaming);
const validateNoteMentions = useCallback((attachments: UploadedFile[]) => {
if (!canMentionNotes && attachments.some(isVaultNoteAttachment)) {
toast.warning(t('ai.chat.mentionNoteUnavailable'));
return false;
}
if (!vaultNoteReferencesFit(attachments)) {
toast.warning(t('ai.chat.mentionNoteTooMany'));
return false;
}
return true;
}, [canMentionNotes, t]);
/** Mention Note: attach a Vault → Notes entry as inline context for the next send. */
const mentionNote = useCallback((note: VaultNote) => {
if (!canMentionNotes) return;
const upload = createVaultNoteAttachment({ id: note.id, title: note.title.trim() || t('ai.chat.untitledNote') });
if (!upload) {
toast.error(t('ai.chat.mentionNoteInvalid', {
title: String(note.title || '').trim() || t('ai.chat.untitledNote'),
}));
return;
}
const existing = currentDraftRef.current?.attachments ?? [];
if (!validateNoteMentions([...existing.filter((file) => file.vaultNoteId !== upload.vaultNoteId), upload])) return;
enterScopeDraftMode(currentAgentId, panelViewRef.current.mode === 'session');
updateDraft(scopeKey, currentAgentId, (current) => ({
...current,
attachments: [
...current.attachments.filter((file) => file.vaultNoteId !== upload.vaultNoteId),
upload,
],
}));
}, [canMentionNotes, validateNoteMentions, updateDraft, currentAgentId, enterScopeDraftMode, scopeKey, t]);
useEffect(() => {
if (isVisible) return undefined;
flushDraftText();
@@ -1141,6 +1181,7 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
const draft = currentDraftRef.current;
const currentPanelView = panelViewRef.current;
const currentSessionView = activeSessionRef.current;
if (!validateNoteMentions(draft?.attachments ?? [])) return;
const trimmed = draft?.text.trim() ?? '';
const sendScopeKey = scopeKey;
const attachments = (draft?.attachments ?? []).map((file) => ({
@@ -1149,18 +1190,27 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
filename: file.filename,
filePath: file.filePath,
terminalSelection: file.terminalSelection,
vaultNoteId: file.vaultNoteId,
vaultNoteTitle: file.vaultNoteTitle,
previewText: file.previewText,
lineCount: file.lineCount,
}));
const hasTerminalSelectionAttachments = attachments.some(isTerminalSelectionAttachment);
if ((!trimmed && !hasTerminalSelectionAttachments) || isStreaming) return;
const hasInlineTextAttachments = attachments.some(isInlineTextAttachment);
if ((!trimmed && !hasInlineTextAttachments) || isStreaming) return;
// Note-only sends (empty text + a mentioned note) still need a usable
// session title: fall back to the first mentioned note's title so these
// conversations don't all show up as "Untitled" in history.
const noteOnlyTitle = attachments.find(
(attachment) => isVaultNoteAttachment(attachment) && attachment.vaultNoteTitle,
)?.vaultNoteTitle ?? '';
const titleText = trimmed || noteOnlyTitle.trim();
const sendAgentId = currentSessionView?.agentId ?? draft?.agentId ?? currentAgentId;
const agentConfig = sendAgentId !== 'catty' ? findEnabledExternalAgent(externalAgents, sendAgentId) : undefined;
if (sendAgentId !== 'catty' && !agentConfig) return;
const selectedSkillSlugs = draft?.selectedUserSkillSlugs ?? [];
const modelPrompt = buildPromptWithTerminalSelectionAttachments(trimmed, attachments);
const modelAttachments = attachments.filter((attachment) => !isTerminalSelectionAttachment(attachment));
const modelAttachments = attachments.filter((attachment) => !isInlineTextAttachment(attachment));
const isDraftMode = currentPanelView.mode === 'draft';
flushDraftText();
@@ -1337,7 +1387,7 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
updateLastMessage(sessionId, msg => msg.statusText ? { ...msg, statusText: '' } : msg);
setStreamingForScope(sessionId, false);
abortControllersRef.current.delete(sessionId);
autoTitleSession(sessionId, trimmed);
autoTitleSession(sessionId, titleText);
} else {
const toolScope = {
type: scopeType,
@@ -1360,7 +1410,7 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
getExecutorContext: () => buildExecutorContextForScope(toolScope),
autoTitleSession,
selectedUserSkillSlugs: selectedSkillSlugs,
titleText: trimmed,
titleText,
}, modelAttachments.length > 0 ? modelAttachments : undefined);
}
} finally {
@@ -1371,6 +1421,7 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
}
}
}, [
validateNoteMentions,
isStreaming, activeProvider, effectiveActiveProvider, effectiveActiveModelId, selectedCattyThinking, scopeKey, currentAgentId,
activeModelId, externalAgents,
createSession, addMessageToSession, updateMessageById, updateLastMessage,
@@ -1467,6 +1518,7 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
const draft = currentDraftRef.current;
if (!sessionId || !draft || steeringSessionId || !canSteerCurrentTurn) return;
if (!validateNoteMentions(draft.attachments)) return;
const trimmed = draft.text.trim();
const attachments = draft.attachments.map((file) => ({
base64Data: file.base64Data,
@@ -1474,15 +1526,17 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
filename: file.filename,
filePath: file.filePath,
terminalSelection: file.terminalSelection,
vaultNoteId: file.vaultNoteId,
vaultNoteTitle: file.vaultNoteTitle,
previewText: file.previewText,
lineCount: file.lineCount,
}));
const hasTerminalSelectionAttachments = attachments.some(isTerminalSelectionAttachment);
if (!trimmed && !hasTerminalSelectionAttachments) return;
const hasInlineTextAttachments = attachments.some(isInlineTextAttachment);
if (!trimmed && !hasInlineTextAttachments) return;
const userMessageId = generateId();
const modelPrompt = buildPromptWithTerminalSelectionAttachments(trimmed, attachments);
const modelAttachments = attachments.filter((attachment) => !isTerminalSelectionAttachment(attachment));
const modelAttachments = attachments.filter((attachment) => !isInlineTextAttachment(attachment));
setSteerWarnings(current => {
const next = { ...current };
delete next[sessionId];
@@ -1511,7 +1565,8 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
} finally {
setSteeringSessionId(current => current === sessionId ? null : current);
}
}, [canSteerCurrentTurn, clearScopeDraft, steerExternalAgent, steeringSessionId]);
}, [
validateNoteMentions, canSteerCurrentTurn, clearScopeDraft, steerExternalAgent, steeringSessionId]);
const stopStreamingForSession = useCallback(async (sessionId: string) => {
const controller = abortControllersRef.current.get(sessionId);
@@ -1707,6 +1762,7 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
files={files}
addFiles={addFiles}
removeFile={removeFile}
onMentionNote={canMentionNotes ? mentionNote : undefined}
terminalSessions={terminalSessions}
selectedUserSkills={selectedUserSkills}
userSkillOptions={userSkillOptions}
@@ -0,0 +1,69 @@
import assert from "node:assert/strict";
import test from "node:test";
import React, { act } from "react";
import { createDomRenderer, dispatchDomEvent, flushEffects, installDomEnvironment } from "./test-support/renderReactDom.tsx";
test("resume from another panel is visible while backend verification is pending", async (t) => {
const env = installDomEnvironment();
const previous = new Map<string, PropertyDescriptor | undefined>();
const globals = {
MutationObserver: env.window.MutationObserver,
NodeFilter: env.window.NodeFilter,
HTMLInputElement: env.window.HTMLInputElement,
Element: env.window.Element,
requestAnimationFrame: (callback: FrameRequestCallback) => setTimeout(() => callback(Date.now()), 0),
cancelAnimationFrame: clearTimeout,
};
for (const [key, value] of Object.entries(globals)) {
previous.set(key, Object.getOwnPropertyDescriptor(globalThis, key));
Object.defineProperty(globalThis, key, { configurable: true, writable: true, value });
}
Object.defineProperties(env.window.HTMLElement.prototype, {
offsetWidth: { configurable: true, get: () => 460 },
offsetHeight: { configurable: true, get() { return this.dataset.section === "global-sftp-transfer-list" ? 460 : 112; } },
});
const { I18nProvider } = await import("../application/i18n/I18nProvider.tsx");
const { sftpTransferCenterStore: store } = await import("../application/state/sftpTransferCenterStore.ts");
const { GlobalSftpTransferCenter } = await import("./GlobalSftpTransferCenter.tsx");
const { TooltipProvider } = await import("./ui/tooltip.tsx");
const renderer = await createDomRenderer(env.document);
t.after(async () => {
await renderer.unmount();
for (const [key, descriptor] of previous) {
if (descriptor) Object.defineProperty(globalThis, key, descriptor);
else Reflect.deleteProperty(globalThis, key);
}
env.cleanup();
});
let settleResume!: (result: { success: boolean }) => void;
let resumeCalls = 0;
const gate = new Promise<{ success: boolean }>((resolve) => { settleResume = resolve; });
Object.defineProperty(env.window, "netcatty", { configurable: true, value: {
resumeTransfer: async () => { resumeCalls += 1; return gate; },
} });
store.publishOwner("resume-display-test", [{
id: "resume-display", fileName: "resume-display.bin",
sourcePath: "/source.bin", targetPath: "/target.bin",
sourceConnectionId: "remote", targetConnectionId: "local", direction: "download",
status: "paused", totalBytes: 1000, transferredBytes: 100, speed: 0,
startTime: 1, isDirectory: false, resumable: true,
}]);
await renderer.render(<I18nProvider locale="en"><TooltipProvider><GlobalSftpTransferCenter /></TooltipProvider></I18nProvider>);
const toggle = env.document.querySelector("[data-section=global-sftp-transfer-toggle]");
assert.ok(toggle);
await dispatchDomEvent(toggle, new env.window.MouseEvent("click", { bubbles: true }));
let resumed!: Promise<void>;
await act(async () => { resumed = store.resume("resume-display"); });
try {
await flushEffects();
assert.equal(resumeCalls, 1);
const row = env.document.querySelector('[role=progressbar][aria-label="resume-display.bin"]')?.closest('[data-transfer-status]');
assert.ok(row);
assert.match(row.textContent ?? "", /Reconnecting and resuming/);
assert.equal(row.querySelector('button[aria-label="Resume"]'), null);
} finally {
await act(async () => { settleResume({ success: true }); await resumed; });
await dispatchDomEvent(toggle, new env.window.MouseEvent("click", { bubbles: true }));
await flushEffects();
}
});
+3 -18
View File
@@ -23,6 +23,7 @@ import { useI18n } from "../application/i18n/I18nProvider";
import {
sftpTransferCenterStore,
useSftpTransferCenterBadge,
useSftpTransferResuming,
type SftpTransferCenterSnapshot,
} from "../application/state/sftpTransferCenterStore";
import { transferRuntime } from "../application/state/sftp/transferRuntime";
@@ -355,7 +356,6 @@ function TransferRow({
const { t } = useI18n();
const folderReplaceWarningId = React.useId();
// Optimistic spinner from click until store status moves off paused/interrupted.
const [resumeClicked, setResumeClicked] = useState(false);
const isDirParent = isDirectoryParentTask(task);
const showCollapsedChildren = isDirParent && shouldShowCollapsedActiveChildren(task.status);
const activeChildren = useMemo(
@@ -397,22 +397,8 @@ function TransferRow({
const storeResuming = task.reconnectRequired === true
&& ["pending", "queued", "transferring"].includes(task.status)
&& !task.error;
const isResuming = storeResuming
|| (resumeClicked && ["paused", "interrupted", "attention", "pending", "queued"].includes(task.status) && !task.error);
// Clear optimistic click once the store has left the idle-resume surface or failed.
useEffect(() => {
if (!resumeClicked) return;
if (
storeResuming
|| task.status === "transferring"
|| task.status === "completed"
|| task.status === "failed"
|| task.status === "cancelled"
|| !!task.error
) {
setResumeClicked(false);
}
}, [resumeClicked, storeResuming, task.status, task.error]);
const sharedResuming = useSftpTransferResuming(task.id);
const isResuming = sharedResuming || storeResuming;
const canPause = task.resumable !== false && task.status === "transferring" && canControl && !isResuming;
// Orphaned tasks after app restart (interrupted / attention / paused without a
// live panel owner) must still expose resume/cancel from the global center.
@@ -467,7 +453,6 @@ function TransferRow({
}));
};
const resumeTask = () => {
setResumeClicked(true);
// Dedicated resume opens vault sessions for local↔remote and SFTP↔SFTP.
// Only force-open the panel when the row still needs a live owner/adoption
// (e.g. conflict) — not on every remote-to-remote resume click.
@@ -64,4 +64,6 @@ test("large transfer histories mount only visible rows and can scroll to the las
const lastRow = env.document.querySelector('[role=progressbar][aria-label="file-0.bin"]')?.closest('[data-transfer-status]');
assert.ok(lastRow?.classList.contains("border-b-0"), "only the actual final row omits its divider");
assert.equal(store.getSnapshot().tasks.length, 1_000, "offscreen files must remain queued");
await dispatchDomEvent(toggle, new env.window.MouseEvent("click", { bubbles: true }));
await flushEffects();
});
+49 -15
View File
@@ -1,3 +1,4 @@
import { getHostOsSelection, HOST_OS_SELECTIONS, resolveHostOs } from '../domain/host';
import React from "react";
import { AlertTriangle, ChevronDown, ChevronUp, Forward, Globe, HeartPulse, KeyRound, Link2, Palette, Plus, Router, ShieldAlert, TerminalSquare, Timer, Wifi, X, Variable } from "lucide-react";
import { customThemeStore } from "../application/state/customThemeStore";
@@ -350,26 +351,59 @@ export const HostDetailsAdvancedSections: React.FC<HostDetailsAdvancedSectionsPr
</HostDetailsSection>
)}
{/* Network Device Mode — only for SSH hosts without Mosh / ET (serial already uses raw mode) */}
{(!form.protocol || form.protocol === 'ssh') && !form.moshEnabled && !form.etEnabled && (
{(!form.protocol || ['ssh', 'telnet', 'mosh', 'et'].includes(form.protocol)) && (
<HostDetailsSection
icon={<Router size={14} className="text-muted-foreground" />}
title={t("hostDetails.section.deviceType")}
>
<ToggleRow
label={t("hostDetails.deviceType")}
hint={t("hostDetails.deviceType.desc")}
enabled={effectiveDeviceType === 'network'}
onToggle={() => update("deviceType", effectiveDeviceType === 'network' ? 'general' : 'network')}
/>
{effectiveDeviceType === 'network' && (
<div className="flex items-start gap-2 p-2 rounded-md bg-yellow-500/10 border border-yellow-500/20">
<AlertTriangle size={14} className="text-yellow-500 mt-0.5 flex-shrink-0" />
<p className="text-xs text-yellow-600 dark:text-yellow-400 break-words">
{t("hostDetails.deviceType.warning")}
{(!form.protocol || form.protocol === 'ssh') && !form.moshEnabled && !form.etEnabled && (<>
<ToggleRow
label={t("hostDetails.deviceType")}
hint={t("hostDetails.deviceType.desc")}
enabled={effectiveDeviceType === 'network'}
onToggle={() => update("deviceType", effectiveDeviceType === 'network' ? 'general' : 'network')}
/>
{effectiveDeviceType === 'network' && (
<div className="flex items-start gap-2 p-2 rounded-md bg-yellow-500/10 border border-yellow-500/20">
<AlertTriangle size={14} className="text-yellow-500 mt-0.5 flex-shrink-0" />
<p className="text-xs text-yellow-600 dark:text-yellow-400 break-words">
{t("hostDetails.deviceType.warning")}
</p>
</div>
)}
</>)}
<Collapsible>
<CollapsibleTrigger asChild>
<Button
variant="ghost"
className="group w-full justify-between h-8 px-2 hover:bg-accent/50"
aria-label={t("hostDetails.os.title")}
>
<span className="text-xs font-medium text-muted-foreground">{t("hostDetails.os.title")}</span>
<span className="flex items-center gap-1.5 text-xs text-muted-foreground">
{t(`hostDetails.os.${getHostOsSelection(form)}`)}
<ChevronDown size={14} className="group-data-[state=open]:rotate-180" />
</span>
</Button>
</CollapsibleTrigger>
<CollapsibleContent className="mt-2 space-y-2">
<HostDetailsSettingRow label={t("hostDetails.os.selection")} hint={t("hostDetails.os.desc")}>
<Select value={getHostOsSelection(form)} onValueChange={(value) => update("osOverride", value)}>
<SelectTrigger className="h-8 w-36 text-xs" aria-label={t("hostDetails.os.selection")}>
<SelectValue />
</SelectTrigger>
<SelectContent>
{HOST_OS_SELECTIONS.map((value) => (
<SelectItem key={value} value={value}>{t(`hostDetails.os.${value}`)}</SelectItem>
))}
</SelectContent>
</Select>
</HostDetailsSettingRow>
<p className="text-xs text-muted-foreground">
{t("hostDetails.os.current")}: {t(`hostDetails.os.${resolveHostOs({ ...form, deviceType: effectiveDeviceType })}`)}
</p>
</div>
)}
</CollapsibleContent>
</Collapsible>
</HostDetailsSection>
)}
@@ -11,7 +11,6 @@ import {
applyEffectiveHostAuthMethodSelection,
detachEffectiveHostIdentity,
HOST_AUTH_METHOD_CHOICES,
HOST_OS_CHOICES,
HostDetailsConnectionSections,
removeSelectedHostCredential,
shouldForceAuthMethodReselect,
@@ -112,26 +111,9 @@ test("color and icon settings render for non-Linux hosts", () => {
assert.match(markup, /hostDetails\.icon\.manualLabel/);
});
test("host operating system can be selected in the ordinary host editor", () => {
test("basic connection settings do not require operating system selection", () => {
const markup = renderConnectionSections({ os: "windows" });
assert.deepEqual(
HOST_OS_CHOICES.map(([value, labelKey]) => [value, labelKey]),
[
["linux", "hostDetails.os.linux"],
["windows", "hostDetails.os.windows"],
["macos", "hostDetails.os.macos"],
],
);
assert.match(markup, /hostDetails\.os\.title/);
assert.match(markup, /role="combobox"[^>]*aria-label="hostDetails\.os\.title"/);
assert.match(markup, />hostDetails\.os\.windows<\/span>/);
const source = fs.readFileSync(
path.join(path.dirname(fileURLToPath(import.meta.url)), "HostDetailsConnectionSections.tsx"),
"utf8",
);
assert.match(source, /onValueChange=\{\(value\) => update\("os", value as Host\["os"\]\)\}/);
assert.doesNotMatch(markup, /hostDetails\.os\./);
});
test("host credentials expose automatic and password-only choices", () => {
@@ -23,12 +23,6 @@ export const HOST_AUTH_METHOD_CHOICES = [
["certificate", "hostDetails.auth.certificate"],
] as const;
export const HOST_OS_CHOICES = [
["linux", "hostDetails.os.linux"],
["windows", "hostDetails.os.windows"],
["macos", "hostDetails.os.macos"],
] as const;
export function shouldForceAuthMethodReselect(
nextMethod: "auto" | "password" | "key" | "certificate",
currentMethod: "auto" | "password" | "key" | "certificate",
@@ -131,12 +125,6 @@ export const HostDetailsConnectionSections: React.FC<HostDetailsConnectionSectio
const selectedAuthMethodLabel = t(
HOST_AUTH_METHOD_CHOICES.find(([value]) => value === resolvedAuthMethod)?.[1] ?? "hostDetails.auth.auto",
);
const resolvedHostOs = HOST_OS_CHOICES.some(([value]) => value === form.os)
? form.os as Host["os"]
: "linux";
const selectedHostOsLabel = t(
HOST_OS_CHOICES.find(([value]) => value === resolvedHostOs)?.[1] ?? "hostDetails.os.linux",
);
const effectiveEtEnabled = form.etEnabled ?? groupDefaults?.etEnabled;
const effectiveProtocol = form.protocol ?? groupDefaults?.protocol;
@@ -152,31 +140,6 @@ export const HostDetailsConnectionSections: React.FC<HostDetailsConnectionSectio
onChange={(e) => update("hostname", e.target.value)}
className="h-10 w-full"
/>
<HostDetailsSettingRow
label={t("hostDetails.os.title")}
hint={t("hostDetails.os.desc")}
>
<Select
value={resolvedHostOs}
onValueChange={(value) => update("os", value as Host["os"])}
>
<SelectTrigger
className="h-8 w-32 gap-2"
aria-label={t("hostDetails.os.title")}
>
<span className="min-w-0 flex-1 truncate text-left">
{selectedHostOsLabel}
</span>
</SelectTrigger>
<SelectContent>
{HOST_OS_CHOICES.map(([value, labelKey]) => (
<SelectItem key={value} value={value}>
{t(labelKey)}
</SelectItem>
))}
</SelectContent>
</Select>
</HostDetailsSettingRow>
</HostDetailsSection>
<HostDetailsSection
+48 -2
View File
@@ -1,3 +1,4 @@
import { resolveHostOs } from '../domain/host';
import { Terminal as XTerm } from "@xterm/xterm";
import { FitAddon } from "@xterm/addon-fit";
import { SerializeAddon } from "@xterm/addon-serialize";
@@ -252,6 +253,8 @@ import {
shouldStartTerminalBackend,
} from "./terminal/restoredSessionGate";
import {
alignTerminalViewportScroll,
createSynchronizedOutputFitScheduler,
AUTO_RUN_SNIPPET_LINE_DELAY_MS,
forceSyncRenderAfterResize,
MAX_CONNECTION_LOG_DATA_CHARS,
@@ -564,6 +567,11 @@ const TerminalComponent: React.FC<TerminalProps> = ({
const reconnectWakeInvalidateModeRef = useRef<"dispose" | "keep">("dispose");
const reconnectWakeTokenRef = useRef<symbol | null>(null);
const manualReconnectRequestRef = useRef<() => void>(() => {});
// Once a pane reconnects, every later SSH attempt in it must dial a fresh
// connection: reusing a live/idle pooled transport multiplexes onto the old
// login, so remote supplementary-group changes (e.g. `usermod -aG`) are not
// picked up until the app fully quits (#3293). See createTerminalSessionStarters.
const requireFreshConnectionOnReconnectRef = useRef(false);
const terminalDataCapturedRef = useRef(false);
const connectionLogBufferRef = useRef(createConnectionLogBuffer(MAX_CONNECTION_LOG_DATA_CHARS));
const terminalOutputHistoryRef = useRef(createTerminalOutputHistoryPreview());
@@ -638,6 +646,17 @@ const TerminalComponent: React.FC<TerminalProps> = ({
const fontWeightFixupDoneRef = useRef(false);
const captureTerminalLogData = useCallback((data: string) => {
// Keep the history tracker's viewport size current so cursor-row and
// cursor-column moves are clamped exactly like the terminal clamps them.
const captureTerm = termRef.current;
if (captureTerm) {
terminalOutputHistoryRef.current.setViewportRows(captureTerm.rows);
terminalOutputHistoryRef.current.setViewportCols(captureTerm.cols);
// The tracker's wrap decisions must measure cell widths the way this
// terminal's Unicode provider does, or the preview diverges from what
// xterm actually rendered (e.g. `15-graphemes` emoji widths).
terminalOutputHistoryRef.current.setWidthTerminal(captureTerm);
}
const readableCommandData = commandLogRewriterRef.current.append(data);
// The alternate-screen preview reads the raw display stream (before the
// replay sanitizer, which drops alternate-screen output on purpose) but
@@ -1081,12 +1100,15 @@ const TerminalComponent: React.FC<TerminalProps> = ({
// hosts (TerminalLayer) and saved-host defaults (HostDetailsPanel) both
// stamp os: "linux", which mis-routes the autocomplete clear sequence to
// Ctrl-U on Windows where cmd/PowerShell render it literally (#1112).
const autocompleteHostOs: "linux" | "windows" | "macos" = host.protocol === "local"
const resolvedAutocompleteOs = host.protocol === "local"
? detectLocalOs(navigator.userAgent || navigator.platform)
: (host.os || "linux");
: resolveHostOs(host);
const autocompleteHostOs = resolvedAutocompleteOs === "windows" || resolvedAutocompleteOs === "macos"
? resolvedAutocompleteOs : "linux";
const autocompleteSettings = resolveTerminalAutocompleteSettings({
protocol: effectiveTerminalProtocol,
terminalSettings,
systemUnknown: resolvedAutocompleteOs === 'unknown',
isNetworkDevice: host.deviceType === 'network'
|| classifyDistroId(host.distro) === 'network-device',
});
@@ -2274,6 +2296,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
clearHibernateRetry();
clearAutoReconnect();
void cleanupSession();
synchronizedFitSchedulerRef.current?.dispose();
disposeRuntimeOnly();
};
@@ -2375,6 +2398,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
sessionId,
reuseConnectionFromSessionIdRef: reuseConnectionSourceRef,
requireFreshConnection,
requireFreshConnectionOnReconnectRef,
reuseConnectionSourceAttemptedRef,
setConnectionReuseAttemptSourceId,
shouldUseFreshSshConnection: () => {
@@ -2867,6 +2891,12 @@ const TerminalComponent: React.FC<TerminalProps> = ({
});
}, [reuseConnectionFromSessionId, sessionId, terminalBackend]);
const synchronizedFitSchedulerRef = useRef<ReturnType<typeof createSynchronizedOutputFitScheduler> | null>(null);
if (!synchronizedFitSchedulerRef.current) {
synchronizedFitSchedulerRef.current = createSynchronizedOutputFitScheduler();
}
useEffect(() => () => synchronizedFitSchedulerRef.current?.dispose(), []);
type SafeFitOptions = { force?: boolean; requireVisible?: boolean; immediate?: boolean; allowHidden?: boolean };
const pendingWriteSafeFitRef = useRef<{
term: XTerm;
@@ -2964,6 +2994,14 @@ const TerminalComponent: React.FC<TerminalProps> = ({
return;
}
// Keep the frozen buffer intact until synchronized output finishes.
// Re-enter safeFit to use the latest size, buffer and reading position.
if (synchronizedFitSchedulerRef.current?.defer(term, () => {
if (termRef.current === term) {
safeFitRef.current({ ...options, force: true, immediate: true });
}
})) return;
const buffer = term.buffer.active;
const wasPinnedToBottom = buffer.viewportY >= buffer.baseY;
const savedViewportY = buffer.viewportY;
@@ -2988,6 +3026,10 @@ const TerminalComponent: React.FC<TerminalProps> = ({
forceSyncRenderAfterResize(term);
}
// Reflow changes the buffer row before xterm updates its scroll range.
// Align first so the relative restore below does not apply that delta twice.
alignTerminalViewportScroll(term);
// Preserve scroll position across resize (superset/Tabby pattern).
if (wasPinnedToBottom) {
term.scrollToBottom();
@@ -3627,6 +3669,10 @@ const TerminalComponent: React.FC<TerminalProps> = ({
const retryStillActive = () => retryTokenStillCurrent() && termRef.current === term;
bootEpochRef.current += 1;
// This start is a reconnect: force the bridge to dial a fresh connection
// instead of borrowing a parked/live transport, so the server performs a
// new login and picks up remote supplementary-group changes (#3293).
requireFreshConnectionOnReconnectRef.current = true;
publishBootEpoch();
isBootActiveRef.current = true;
auth.resetForRetry();
+18
View File
@@ -332,3 +332,21 @@ test('ChatInput wires /compact through getSystemSlashCommand and canCompact', ()
assert.match(source, /onCompact\?\.\(\)/);
assert.match(source, /command\.slug !== 'compact' \|\| canCompact/);
});
test('renders a Mention Note picker entry with a searchable note list', () => {
const source = readFileSync(new URL('./ChatInput.tsx', import.meta.url), 'utf8');
assert.match(source, /openInputPanelMenu\('noteMention'\)/);
assert.match(source, /ai\.chat\.menuMentionNote/);
assert.match(source, /aria-activedescendant=\{noteMentionItems\[activeMenuIndex\] \? `\$\{noteListId\}/);
assert.match(source, /createVaultNoteSearchIndex\(notes\)/);
assert.match(source, /onClick=\{\(\) => handleSelectNoteMention\(note\)\}/);
assert.match(source, /onKeyDown=\{handleNoteMentionKeyDown\}/);
});
test('renders vault note attachment chips with the note title', () => {
const source = readFileSync(new URL('./ChatInput.tsx', import.meta.url), 'utf8');
assert.match(source, /file\.vaultNoteId \? \(/);
assert.match(source, /\{file\.vaultNoteTitle \|\| file\.filename\}/);
});
+166 -4
View File
@@ -6,7 +6,7 @@
* and a bottom toolbar with muted controls + subtle send button.
*/
import { ArrowUp, AtSign, Check, ChevronDown, ChevronRight, Cpu, Eye, FileText, ImageIcon, Loader2, MessageSquare, Package, Plus, ShieldCheck, SquareTerminal, X, Zap } from 'lucide-react';
import { ArrowUp, AtSign, BookOpen, Check, ChevronDown, ChevronRight, Cpu, Eye, FileText, ImageIcon, Loader2, MessageSquare, Package, Plus, ShieldCheck, SquareTerminal, X, Zap } from 'lucide-react';
import {
resolveModelSelectionWithThinking,
resolveThinkingSelection,
@@ -54,6 +54,8 @@ import {
} from '../ai-elements/prompt-input';
import type { PromptInputStatus } from '../ai-elements/prompt-input';
import type { AgentModelPreset, AIPermissionMode, ProviderConfig, UploadedFile } from '../../infrastructure/ai/types';
import type { VaultNote } from '../../domain/models';
import { createVaultNoteSearchIndex } from '../../domain/notes';
import { ProviderIconBadge } from '../settings/tabs/ai/ProviderIconBadge';
import { VariableSizeVirtualList, type VariableSizeVirtualListHandle } from '../ui/VariableSizeVirtualList';
import { Tooltip, TooltipContent, TooltipTrigger } from '../ui/tooltip';
@@ -216,6 +218,10 @@ interface ChatInputProps {
onRemoveFile?: (id: string) => void;
/** Available hosts for @ mention */
hosts?: Array<{ sessionId: string; hostname: string; label: string; connected: boolean }>;
/** Available Vault → Notes entries for the Mention Note picker */
notes?: VaultNote[];
/** Callback when the user picks a note to attach as context */
onMentionNote?: (note: VaultNote) => void;
/** User skills currently selected for the next send */
selectedUserSkills?: Array<{ id: string; slug: string; name: string; description: string }>;
/** Available user skills for /skill-slug insertion */
@@ -271,6 +277,8 @@ const ChatInput: React.FC<ChatInputProps> = ({
onAddFiles,
onRemoveFile,
hosts = [],
notes = [],
onMentionNote,
selectedUserSkills = [],
userSkills = [],
quickMessages = [],
@@ -285,7 +293,7 @@ const ChatInput: React.FC<ChatInputProps> = ({
parked = false,
}) => {
const { t } = useI18n();
const hasTerminalSelectionAttachment = files.some((file) => file.terminalSelection);
const hasTerminalSelectionAttachment = files.some((file) => file.terminalSelection || file.vaultNoteId);
const composerDisabled = disabled || isSteering;
const composerTextRef = useRef(value);
const hasTextStoreRef = useRef<ComposerHasTextStore | null>(null);
@@ -298,13 +306,15 @@ const ChatInput: React.FC<ChatInputProps> = ({
const [composerMaxHeight, setComposerMaxHeight] = useState(CHAT_INPUT_MAX_HEIGHT);
const composerDesiredHeightRef = useRef<number | null>(null);
// Consolidate menu state into a single discriminated union to prevent multiple menus open simultaneously
type ActiveMenu = 'model' | 'thinking' | 'attach' | 'atMention' | 'slashCommand' | 'perm' | null;
type ActiveMenu = 'model' | 'thinking' | 'attach' | 'atMention' | 'noteMention' | 'slashCommand' | 'perm' | null;
const [activeMenu, setActiveMenu] = useState<ActiveMenu>(null);
const [menuPos, setMenuPos] = useState<{ left: number; bottom: number } | null>(null);
const [inputPanelPos, setInputPanelPos] = useState<{ left: number; bottom: number; width: number } | null>(null);
const [modelPrefs, setModelPrefs] = useState<ComposerModelPrefs>(() => readComposerModelPrefs(pickerScope));
const [slashQuery, setSlashQuery] = useState('');
const [slashRange, setSlashRange] = useState<{ start: number; end: number } | null>(null);
// Search query for the Mention Note picker
const [noteQuery, setNoteQuery] = useState('');
// Active highlight index for @ mention / slash skill keyboard navigation
const [activeMenuIndex, setActiveMenuIndex] = useState(0);
@@ -313,6 +323,7 @@ const ChatInput: React.FC<ChatInputProps> = ({
const showThinkingPicker = activeMenu === 'thinking';
const showAttachMenu = activeMenu === 'attach';
const showAtMention = activeMenu === 'atMention';
const showNoteMention = activeMenu === 'noteMention';
const showSlashCommandPicker = activeMenu === 'slashCommand';
const showPermPicker = activeMenu === 'perm';
@@ -322,6 +333,7 @@ const ChatInput: React.FC<ChatInputProps> = ({
setInputPanelPos(null);
setSlashQuery('');
setSlashRange(null);
setNoteQuery('');
}, []);
useEffect(() => {
@@ -582,7 +594,12 @@ const ChatInput: React.FC<ChatInputProps> = ({
closeAllMenus();
}, [readComposerText, commitComposerText, closeAllMenus]);
const openInputPanelMenu = useCallback((menu: 'atMention' | 'slashCommand') => {
const handleSelectNoteMention = useCallback((note: VaultNote) => {
onMentionNote?.(note);
closeAllMenus();
}, [onMentionNote, closeAllMenus]);
const openInputPanelMenu = useCallback((menu: 'atMention' | 'noteMention' | 'slashCommand') => {
const pos = getInputPanelMenuPos();
if (!pos) return;
setMenuPos(null);
@@ -746,6 +763,58 @@ const ChatInput: React.FC<ChatInputProps> = ({
if (showSlashCommandPicker) setActiveMenuIndex(0);
}, [showSlashCommandPicker, slashCommandKey]);
// Mention Note picker: filtered notes, highlight reset, search-input focus
const searchMentionNotes = useMemo(
() => showNoteMention ? createVaultNoteSearchIndex(notes) : null,
[notes, showNoteMention],
);
const noteMentionItems = useMemo(
() => searchMentionNotes?.(noteQuery) ?? [],
[searchMentionNotes, noteQuery],
);
const noteMentionKey = useMemo(
() => JSON.stringify(noteMentionItems.map((note) => note.id)),
[noteMentionItems],
);
const noteSearchInputRef = useRef<HTMLInputElement>(null);
const noteListId = React.useId();
useEffect(() => {
if (showNoteMention) setActiveMenuIndex(0);
}, [showNoteMention, noteMentionKey]);
useEffect(() => {
if (!showNoteMention || noteMentionItems.length === 0) return;
atMentionListRef.current?.scrollToIndex(activeMenuIndex);
}, [activeMenuIndex, noteMentionKey, noteMentionItems.length, showNoteMention]);
useEffect(() => {
if (!showNoteMention) return;
noteSearchInputRef.current?.focus();
}, [showNoteMention]);
const handleNoteMentionKeyDown = useCallback((e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.nativeEvent.isComposing) return;
if (e.key === 'Escape') {
e.preventDefault();
closeAllMenus();
return;
}
if (e.key === 'Enter') {
e.preventDefault();
const note = noteMentionItems[Math.min(activeMenuIndex, noteMentionItems.length - 1)];
if (note) handleSelectNoteMention(note);
return;
}
if (noteMentionItems.length === 0) return;
if (e.key === 'ArrowDown') {
e.preventDefault();
setActiveMenuIndex((i) => (i + 1) % noteMentionItems.length);
return;
}
if (e.key === 'ArrowUp') {
e.preventDefault();
setActiveMenuIndex((i) => (i - 1 + noteMentionItems.length) % noteMentionItems.length);
}
}, [activeMenuIndex, closeAllMenus, handleSelectNoteMention, noteMentionItems]);
useEffect(() => {
if (!showSlashCommandPicker || !menuPos || slashCommandItems.length === 0) return;
slashPickerListRef.current?.focus();
@@ -1041,6 +1110,8 @@ const ChatInput: React.FC<ChatInputProps> = ({
>
{file.terminalSelection ? (
<SquareTerminal size={12} className="text-muted-foreground/70 shrink-0" />
) : file.vaultNoteId ? (
<BookOpen size={11} className="text-muted-foreground/60 shrink-0" />
) : file.mediaType.startsWith('image/') ? (
<ImageIcon size={11} className="text-muted-foreground/60 shrink-0" />
) : (
@@ -1053,6 +1124,12 @@ const ChatInput: React.FC<ChatInputProps> = ({
{file.lineCount ? ` · ${t('ai.chat.terminalSelectionLines').replace('{count}', String(file.lineCount))}` : ''}
</span>
</span>
) : file.vaultNoteId ? (
<span className="min-w-0">
<span className="block truncate max-w-[210px] text-foreground/82">
{file.vaultNoteTitle || file.filename}
</span>
</span>
) : (
<span className="truncate max-w-[80px]">{file.filename}</span>
)}
@@ -1209,6 +1286,76 @@ const ChatInput: React.FC<ChatInputProps> = ({
document.body,
)}
{/* Mention Note popover */}
{showNoteMention && inputPanelPos && createPortal(
<>
<div className="fixed inset-0 z-[999]" onClick={closeAllMenus} />
<div
className="fixed z-[1000] overflow-hidden rounded-lg border border-border/50 bg-popover shadow-lg"
style={{ left: inputPanelPos.left, bottom: inputPanelPos.bottom, width: 'auto', minWidth: Math.min(240, inputPanelPos.width), maxWidth: inputPanelPos.width }}
>
<div className="p-1.5 border-b border-border/40">
<input
ref={noteSearchInputRef}
role="combobox"
aria-label={t('ai.chat.menuMentionNote')}
aria-expanded={true}
aria-controls={noteListId}
aria-autocomplete="list"
aria-activedescendant={noteMentionItems[activeMenuIndex] ? `${noteListId}-${activeMenuIndex}` : undefined}
type="text"
value={noteQuery}
onChange={(e) => setNoteQuery(e.target.value)}
onKeyDown={handleNoteMentionKeyDown}
placeholder={t('ai.chat.mentionNoteSearch')}
className="w-full h-6 rounded-md bg-muted/40 px-2 text-[12px] text-foreground outline-none placeholder:text-muted-foreground/50"
/>
</div>
<div id={noteListId} role="listbox" aria-label={t('ai.chat.menuMentionNote')}>
{noteMentionItems.length === 0 ? (
<div className="px-3 py-2 text-[11px] text-muted-foreground/60">{t('ai.chat.mentionNoteEmpty')}</div>
) : (
<div className="max-h-[280px]" style={{ height: Math.min(280, 8 + noteMentionItems.reduce((total, note) => total + (note.group ? 52 : 36), 0)) }}>
<VariableSizeVirtualList
ref={atMentionListRef}
items={noteMentionItems}
getItemHeight={(note) => note.group ? 52 : 36}
getItemKey={(note) => note.id}
className="h-full"
contentClassName="p-1"
renderItem={(note, idx) => {
const isActive = idx === activeMenuIndex;
return (
<button
id={`${noteListId}-${idx}`}
type="button"
role="option"
aria-selected={isActive}
onMouseEnter={() => setActiveMenuIndex(idx)}
onClick={() => handleSelectNoteMention(note)}
className={`h-full w-full rounded-md px-2 py-1 text-left transition-colors cursor-pointer ${isActive ? 'bg-muted/40' : 'hover:bg-muted/30'}`}
>
<div className="flex items-center gap-2 text-[12px] text-foreground/90">
<BookOpen size={11} className="text-muted-foreground/50 shrink-0" />
<span className="truncate">{note.title || t('ai.chat.untitledNote')}</span>
</div>
{note.group ? (
<div className="pl-5 text-[10px] text-muted-foreground/60 truncate">
{note.group}
</div>
) : null}
</button>
);
}}
/>
</div>
)}
</div>
</div>
</>,
document.body,
)}
{/* / command popover */}
{showSlashPickerUI && createPortal(
<>
@@ -1311,6 +1458,19 @@ const ChatInput: React.FC<ChatInputProps> = ({
<ImageIcon size={13} className="text-muted-foreground/60" />
<span className="text-foreground/85">{t('ai.chat.menuImage')}</span>
</button>
<button
type="button"
role="menuitem"
aria-label={t('ai.chat.menuMentionNote')}
disabled={!onMentionNote}
title={!onMentionNote ? t('ai.chat.mentionNoteUnavailable') : undefined}
onClick={() => openInputPanelMenu('noteMention')}
className="w-full flex items-center gap-2.5 px-3 py-1.5 text-left text-[12px] hover:bg-muted/30 transition-colors cursor-pointer whitespace-nowrap disabled:opacity-40 disabled:cursor-not-allowed"
>
<BookOpen size={13} className="text-muted-foreground/60" />
<span className="flex-1 text-foreground/85">{t('ai.chat.menuMentionNote')}</span>
{notes.length > 0 && <ChevronRight size={10} className="text-muted-foreground/50" />}
</button>
<button
type="button"
role="menuitem"
@@ -1640,6 +1800,8 @@ function chatInputPropsAreEqual(prev: ChatInputProps, next: ChatInputProps): boo
&& prev.onAddFiles === next.onAddFiles
&& prev.onRemoveFile === next.onRemoveFile
&& prev.hosts === next.hosts
&& prev.notes === next.notes
&& prev.onMentionNote === next.onMentionNote
&& prev.selectedUserSkills === next.selectedUserSkills
&& prev.userSkills === next.userSkills
&& prev.quickMessages === next.quickMessages
+9 -1
View File
@@ -6,7 +6,7 @@
* No avatars. Thinking blocks are collapsible.
*/
import { AlertCircle, FileText, RotateCcw, SquareTerminal, X, ZoomIn, ZoomOut } from 'lucide-react';
import { AlertCircle, BookOpen, FileText, RotateCcw, SquareTerminal, X, ZoomIn, ZoomOut } from 'lucide-react';
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useI18n } from '../../application/i18n/I18nProvider';
import type { ChatMessage, ToolCall as AgentToolCall } from '../../infrastructure/ai/types';
@@ -701,6 +701,14 @@ const ChatMessageList: React.FC<ChatMessageListProps> = ({
<SquareTerminal size={12} className="text-muted-foreground/60 shrink-0" />
<span className="truncate max-w-[150px]">{att.filename || 'terminal selection'}</span>
</div>
) : att.vaultNoteId ? (
<div
key={att.filename ? `${att.filename}-${i}` : `att-${message.id}-${i}`}
className="inline-flex items-center gap-1.5 h-7 px-2 rounded-md bg-muted/20 border border-border/20 text-[11px] text-foreground/70"
>
<BookOpen size={12} className="text-muted-foreground/60 shrink-0" />
<span className="truncate max-w-[150px]">{att.vaultNoteTitle || att.filename || 'note'}</span>
</div>
) : att.mediaType.startsWith('image/') ? (
<img
key={att.filename ? `${att.filename}-${i}` : `att-${message.id}-${i}`}
+66
View File
@@ -1,5 +1,6 @@
import assert from "node:assert/strict";
import test from "node:test";
import { buildExternalBridgeContextMessages } from "../../infrastructure/ai/harness/externalBridgeContext.ts";
import type { ChatMessageAttachment, ToolCall, ToolResult } from "../../infrastructure/ai/types.ts";
import {
@@ -210,3 +211,68 @@ test("buildHistoricalToolReplayMaps pairs reused tool ids with the nearest prece
assert.equal(maps.resolvedToolCallsByAssistant.get(messages[1]), undefined);
assert.equal(maps.resolvedToolCallsByAssistant.get(messages[2])?.has(messages[2].toolCalls![0]), true);
});
test("buildHistoricalUserReplayContent replaces historical vault note mentions with id metadata", () => {
const attachment: ChatMessageAttachment = {
base64Data: "A".repeat(5_000),
mediaType: "text/markdown",
filename: "runbook.md",
vaultNoteId: "note-123",
vaultNoteTitle: "Runbook",
previewText: "restart nginx",
};
const result = buildHistoricalUserReplayContent("check this note", [attachment]);
assert.match(result, /check this note/);
assert.match(result, /Vault note reference/);
assert.match(result, /"noteId":"note-123"/);
assert.match(result, /"title":"Runbook"/);
assert.doesNotMatch(result, /AAAAAA/);
});
test("external recovery retains note identity before truncating a long user request", () => {
const history = buildExternalBridgeContextMessages([{
id: "long-note-request", role: "user", timestamp: 1, content: "x".repeat(2500),
attachments: [{mediaType: "text/markdown", base64Data: "", vaultNoteId: "note-123", vaultNoteTitle: "Runbook"}],
}]);
const replay = history.find((message) => message.role === "user")!;
assert.match(replay.content, /"noteId":"note-123"/);
assert.match(replay.content, /vault_notes_get/);
assert.match(replay.content, /truncated/);
assert.ok(replay.content.length <= 2000);
});
test("external recovery keeps multiple note IDs and a short user constraint", () => {
const attachments = Array.from({length: 10}, (_, index) => ({
mediaType: "text/markdown", base64Data: "", vaultNoteId: `note-${index}`, vaultNoteTitle: `Runbook ${index}`,
}));
const history = buildExternalBridgeContextMessages([{
id: "many-notes", role: "user", timestamp: 1, content: "Only compare; do not edit.", attachments,
}]);
const replay = history.find((message) => message.role === "user")!;
for (const attachment of attachments) assert.ok(replay.content.includes(JSON.stringify(attachment.vaultNoteId)));
assert.match(replay.content, /Only compare; do not edit/);
assert.equal(replay.content.match(/Use vault_notes_get/g)?.length, 1);
});
for (const terminalSelection of [false, true]) {
test(`compact external recovery retains the request and attachment (terminal=${terminalSelection}) alongside note references`, () => {
const noteId = '550e8400-e29b-41d4-a716-446655440000';
const request = "Only summarize yesterday's deployments; do not edit.";
const messages: ChatMessage[] = [{
id: 'old-note', role: 'user', timestamp: 1, content: request,
attachments: [
{ mediaType: 'text/markdown', base64Data: '', vaultNoteId: noteId, vaultNoteTitle: 'Deployment Runbook' },
{ mediaType: 'text/plain', base64Data: 'YWJj', filename: 'other.txt', terminalSelection },
],
}, ...Array.from({ length: 8 }, (_, i): ChatMessage => ({
id: `later-${i}`, role: i % 2 ? 'assistant' : 'user', timestamp: i + 2, content: i % 2 ? 'Done.' : 'ok',
}))];
const compact = buildExternalBridgeContextMessages(messages)[0].content;
assert.ok(compact.includes(noteId));
assert.ok(compact.includes(request));
assert.ok(compact.includes('other.txt'));
assert.ok(compact.includes(terminalSelection ? 'terminal selection omitted' : 'attachment omitted'));
});
}
+12 -7
View File
@@ -1,5 +1,6 @@
import type { ChatMessage, ChatMessageAttachment, ToolCall, ToolResult } from "../../infrastructure/ai/types";
import { isTerminalSelectionAttachment } from "../../application/state/terminalSelectionAttachment";
import { formatVaultNoteReferences, isVaultNoteAttachment } from "../../application/state/vaultNoteAttachment";
import { redactSecretsForModel } from "../../infrastructure/ai/harness/modelSecretRedaction";
const MAX_ATTACHMENT_PLACEHOLDER_DETAIL_CHARS = 120;
@@ -50,15 +51,19 @@ export function buildHistoricalUserReplayContent(
content: string,
attachments: ChatMessageAttachment[] = [],
): string {
const placeholders = attachments.map((attachment, index) => (
isTerminalSelectionAttachment(attachment)
? formatTerminalSelectionPlaceholder(attachment, index)
: formatAttachmentPlaceholder(attachment, index)
));
if (!attachments.length) return content;
// Keep note identities ahead of prose so bounded external history retains them.
const notes = attachments.filter(isVaultNoteAttachment);
const placeholders = attachments.filter((attachment) => !isVaultNoteAttachment(attachment))
.map((attachment, index) => (
isTerminalSelectionAttachment(attachment)
? formatTerminalSelectionPlaceholder(attachment, index)
: formatAttachmentPlaceholder(attachment, index)
));
if (!placeholders.length) return content;
const attachmentBlock = placeholders.map((line) => `\n\n${line}`).join("");
return content.trim() ? `${content}${attachmentBlock}` : placeholders.join("\n\n");
const body = content.trim() ? `${content}${attachmentBlock}` : placeholders.join("\n\n");
return notes.length ? [formatVaultNoteReferences(notes), body].filter(Boolean).join("\n\n") : body;
}
function getToolCommand(toolCall?: ToolCall): string | undefined {
@@ -0,0 +1,96 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import test from "node:test";
import vm from "node:vm";
import React, { useCallback, useEffect, useRef, useState } from "react";
import TestRenderer, { act } from "react-test-renderer";
import ts from "typescript";
(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
// Exercise the production lifecycle and asynchronous resolver together, without
// loading the unrelated provider forms or making real CLI/authentication calls.
const source = readFileSync(new URL("./SettingsAITab.tsx", import.meta.url), "utf8");
const lifecycleStart = source.indexOf(" const mountedRef = useRef(true);");
const lifecycleEnd = source.indexOf("\n const applyResolvedAgentPath", lifecycleStart);
const resolverStart = source.indexOf(" const resolveAgentPath = useCallback");
const resolverEnd = source.indexOf("\n useEffect(() => {", resolverStart);
assert.ok(lifecycleStart >= 0 && lifecycleEnd > lifecycleStart);
assert.ok(resolverStart >= 0 && resolverEnd > resolverStart);
const declarations = source.slice(lifecycleStart, lifecycleEnd) + source.slice(resolverStart, resolverEnd);
const setters = ["Codex", "Claude", "Copilot", "Cursor", "Codebuddy", "Opencode", "Grok"];
const bindings = ["useRef", "useEffect", "useCallback", "getBridge", "applyResolvedAgentPath", "cursorApiKeyEncrypted",
...setters.map(name => `setIsResolving${name}`)];
const runHooks = vm.runInNewContext(ts.transpile(`(function(ctx) {
const { ${bindings.join(",")} } = ctx;
${declarations}
return resolveAgentPath;
})`, { target: ts.ScriptTarget.ES2022 }), { console }) as
(ctx: Record<string, unknown>) => (key: string) => Promise<unknown>;
function deferred() {
let resolve!: (value: unknown) => void;
const promise = new Promise<unknown>(done => { resolve = done; });
return { promise, resolve };
}
async function mountResolver() {
const calls: ReturnType<typeof deferred>[] = [];
const applied: unknown[] = [];
const state: { busy: boolean; resolve?: (key: string) => Promise<unknown> } = { busy: false };
const bridge = { aiResolveCli: () => { const pending = deferred(); calls.push(pending); return pending.promise; } };
function Harness() {
const [busy, setBusy] = useState(false);
state.busy = busy;
state.resolve = runHooks({
useRef, useEffect, useCallback,
getBridge: () => bridge,
applyResolvedAgentPath: (_key: string, result: unknown) => applied.push(result),
cursorApiKeyEncrypted: "",
...Object.fromEntries(setters.map(name => [`setIsResolving${name}`, setBusy])),
});
return null;
}
let root!: TestRenderer.ReactTestRenderer;
await act(async () => { root = TestRenderer.create(React.createElement(React.StrictMode, null, React.createElement(Harness))); });
return { calls, applied, state, root };
}
test("AI detection accepts completed requests after StrictMode effect replay", async () => {
const h = await mountResolver();
try {
let request!: Promise<unknown>;
await act(async () => { request = h.state.resolve!("cursor"); });
assert.equal(h.state.busy, true);
const result = { installed: true, available: true, cliBinPath: "/fixture/cursor-agent" };
await act(async () => { h.calls[0].resolve(result); await request; });
assert.deepEqual(h.applied, [result]);
assert.equal(h.state.busy, false);
} finally { await act(async () => h.root.unmount()); }
});
test("AI detection still ignores completion after real unmount", async () => {
const h = await mountResolver();
let request!: Promise<unknown>;
await act(async () => { request = h.state.resolve!("cursor"); });
await act(async () => h.root.unmount());
h.calls[0].resolve({ installed: true });
await request;
assert.deepEqual(h.applied, []);
});
test("an older AI detection result cannot replace or finish the newer request", async () => {
const h = await mountResolver();
try {
let old!: Promise<unknown>;
let current!: Promise<unknown>;
await act(async () => { old = h.state.resolve!("cursor"); current = h.state.resolve!("cursor"); });
await act(async () => { h.calls[0].resolve({ installed: false }); await old; });
assert.deepEqual(h.applied, []);
assert.equal(h.state.busy, true);
const result = { installed: true };
await act(async () => { h.calls[1].resolve(result); await current; });
assert.deepEqual(h.applied, [result]);
assert.equal(h.state.busy, false);
} finally { await act(async () => h.root.unmount()); }
});
+10 -6
View File
@@ -342,12 +342,16 @@ const SettingsAITab: React.FC<SettingsAITabProps> = ({
const mountedRef = useRef(true);
const agentPathRequestIdRef = useRef<Partial<Record<ManagedAgentKey, number>>>({});
const codexRequestIdRef = useRef(0);
useEffect(() => () => {
mountedRef.current = false;
codexRequestIdRef.current += 1;
for (const key of ["codex", "claude", "copilot", "cursor", "codebuddy", "opencode", "grok"] as ManagedAgentKey[]) {
agentPathRequestIdRef.current[key] = (agentPathRequestIdRef.current[key] ?? 0) + 1;
}
useEffect(() => {
mountedRef.current = true;
const agentPathRequestIds = agentPathRequestIdRef.current;
return () => {
mountedRef.current = false;
codexRequestIdRef.current += 1;
for (const key of ["codex", "claude", "copilot", "cursor", "codebuddy", "opencode", "grok"] as ManagedAgentKey[]) {
agentPathRequestIds[key] = (agentPathRequestIds[key] ?? 0) + 1;
}
};
}, []);
const applyResolvedAgentPath = useCallback((
+4 -20
View File
@@ -20,10 +20,10 @@ import {
X,
XCircle,
} from 'lucide-react';
import React, { memo, useEffect, useState } from 'react';
import React, { memo } from 'react';
import { useI18n } from '../../application/i18n/I18nProvider';
import { getParentPath } from '../../application/state/sftp/utils';
import { useSftpTransferTask } from '../../application/state/sftpTransferCenterStore';
import { useSftpTransferTask, useSftpTransferResuming } from '../../application/state/sftpTransferCenterStore';
import { cn } from '../../lib/utils';
import { TransferTask } from '../../types';
import {
@@ -136,8 +136,6 @@ const SftpTransferItemInner: React.FC<SftpTransferItemProps> = ({
// panel setTransfersState for every tick — that re-rendered the whole SFTP
// tree and pegged the renderer during large copies.
const task = useSftpTransferTask(propsTask.id, propsTask);
// Optimistic spinner from click until store status moves off paused/interrupted.
const [resumeClicked, setResumeClicked] = useState(false);
// Same progress model as the global transfer center (done · found for folders).
const isDirParent = isDirectoryParentTask(task);
@@ -159,21 +157,8 @@ const SftpTransferItemInner: React.FC<SftpTransferItemProps> = ({
const storeResuming = task.reconnectRequired === true
&& ['pending', 'queued', 'transferring'].includes(task.status)
&& !task.error;
const isResuming = storeResuming
|| (resumeClicked && ['paused', 'interrupted', 'attention', 'pending', 'queued'].includes(task.status) && !task.error);
useEffect(() => {
if (!resumeClicked) return;
if (
storeResuming
|| task.status === 'transferring'
|| task.status === 'completed'
|| task.status === 'failed'
|| task.status === 'cancelled'
|| !!task.error
) {
setResumeClicked(false);
}
}, [resumeClicked, storeResuming, task.status, task.error]);
const sharedResuming = useSftpTransferResuming(task.id);
const isResuming = sharedResuming || storeResuming;
const effectiveSpeed = task.status === 'transferring'
? (Number.isFinite(task.speed) && task.speed > 0 ? task.speed : 0)
: 0;
@@ -417,7 +402,6 @@ const SftpTransferItemInner: React.FC<SftpTransferItemProps> = ({
className={actionButtonClass}
data-action="resume-transfer"
{...oncePerActivationHandlers(() => {
setResumeClicked(true);
onResume();
})}
aria-label={actionAriaLabel(resumeActionLabel)}
@@ -0,0 +1,100 @@
import assert from "node:assert/strict";
import { test } from "node:test";
import type { Terminal as XTerm } from "@xterm/xterm";
import { alignTerminalViewportScroll } from "./terminalHelpers";
type ViewportSpy = {
scrollToLine: (line: number, disableSmoothScroll?: boolean) => void;
_sync?: () => void;
calls: Array<{ line: number; disableSmoothScroll?: boolean }>;
};
const createTerm = (
viewportY: number,
viewport?: ViewportSpy | null,
): XTerm => ({
buffer: { active: { viewportY } },
_core: viewport === null ? undefined : { _viewport: viewport },
}) as unknown as XTerm;
test("alignTerminalViewportScroll snaps the viewport back to the buffer row without smooth scrolling", () => {
const calls: Array<{ line: number; disableSmoothScroll?: boolean }> = [];
const viewport: ViewportSpy = {
scrollToLine: (line, disableSmoothScroll) => {
calls.push({ line, disableSmoothScroll });
},
calls,
};
const term = createTerm(247, viewport);
alignTerminalViewportScroll(term);
assert.deepEqual(calls, [{ line: 247, disableSmoothScroll: true }]);
});
test("alignTerminalViewportScroll syncs viewport dimensions before setting the position", () => {
const calls: Array<{ line: number; disableSmoothScroll?: boolean }> = [];
const order: string[] = [];
const viewport: ViewportSpy = {
_sync: () => {
order.push("sync");
},
scrollToLine: (line, disableSmoothScroll) => {
order.push("scrollToLine");
calls.push({ line, disableSmoothScroll });
},
calls,
};
const term = createTerm(247, viewport);
alignTerminalViewportScroll(term);
assert.deepEqual(order, ["sync", "scrollToLine"]);
assert.deepEqual(calls, [{ line: 247, disableSmoothScroll: true }]);
});
test("alignTerminalViewportScroll survives a failing dimension sync", () => {
const calls: Array<{ line: number; disableSmoothScroll?: boolean }> = [];
const viewport: ViewportSpy = {
_sync: () => {
throw new Error("boom");
},
scrollToLine: (line, disableSmoothScroll) => {
calls.push({ line, disableSmoothScroll });
},
calls,
};
assert.doesNotThrow(() => alignTerminalViewportScroll(createTerm(5, viewport)));
assert.deepEqual(calls, [{ line: 5, disableSmoothScroll: true }]);
});
test("alignTerminalViewportScroll is a no-op when the private viewport is unavailable", () => {
const term = createTerm(10, null);
assert.doesNotThrow(() => alignTerminalViewportScroll(term));
});
test("alignTerminalViewportScroll survives a viewport without scrollToLine", () => {
const term = {
buffer: { active: { viewportY: 5 } },
_core: { _viewport: {} },
} as unknown as XTerm;
assert.doesNotThrow(() => alignTerminalViewportScroll(term));
});
test("alignTerminalViewportScroll swallows viewport errors instead of breaking the fit", () => {
const term = {
buffer: { active: { viewportY: 5 } },
_core: {
_viewport: {
scrollToLine: () => {
throw new Error("boom");
},
},
},
} as unknown as XTerm;
assert.doesNotThrow(() => alignTerminalViewportScroll(term));
});
@@ -18,10 +18,11 @@ export function resolveTerminalAutocompleteSettings(input: {
terminalSettings?: TerminalAutocompleteSettingFields;
/** Vendor CLI / network-device session: skip live-preview PTY rewrites (#1193). */
isNetworkDevice?: boolean;
systemUnknown?: boolean;
}): Partial<AutocompleteSettings> | undefined {
const { protocol, terminalSettings, isNetworkDevice } = input;
const { protocol, terminalSettings, isNetworkDevice, systemUnknown } = input;
if (protocol === "serial") {
if (protocol === "serial" || systemUnknown) {
return {
enabled: terminalSettings?.autocompleteEnabled ?? true,
showGhostText: terminalSettings?.autocompleteGhostText ?? true,
@@ -16,6 +16,11 @@ const armSudoPrompt = (
};
test("startMosh enables sudo autofill with the host saved password", async () => {
let detected = "";
let probes = 0;
let ready: (() => void) | undefined;
let resolveDetected!: () => void;
const detectionDone = new Promise<void>((resolve) => { resolveDetected = resolve; });
let onData: ((data: string) => void) | null = null;
const sent: string[] = [];
const terminalBackend = {
@@ -28,6 +33,8 @@ test("startMosh enables sudo autofill with the host saved password", async () =>
startSSHSession: async () => "ssh-session",
startTelnetSession: async () => "telnet-session",
startMoshSession: async () => "mosh-session",
onMoshSessionReady: (_id: string, callback: () => void) => { ready = callback; return noop; },
getSessionDistroInfo: async () => { probes++; return { success: true, stdout: "ID=ubuntu" }; },
startLocalSession: async () => "local-session",
startSerialSession: async () => "serial-session",
execCommand: async () => ({}),
@@ -49,6 +56,7 @@ test("startMosh enables sudo autofill with the host saved password", async () =>
username: "alice",
password: "saved-secret",
},
onOsDetected: (_id: string, distro: string) => { detected = distro; resolveDetected(); },
keys: [],
identities: [],
resolvedChainHosts: [],
@@ -85,6 +93,11 @@ test("startMosh enables sudo autofill with the host saved password", async () =>
};
await createTerminalSessionStarters(ctx as never).startMosh(term as never);
assert.equal(probes, 0, "wait for the Mosh handshake before probing");
ready?.();
await detectionDone;
assert.equal(detected, "ubuntu");
assert.equal(probes, 1);
onData?.(armSudoPrompt(sudoAutofillRef.current));
sudoAutofillRef.current?.confirmFill();
@@ -481,9 +481,9 @@ test("startSSH requests a fresh transport for ordinary opens with connection aut
assert.equal(captured[0].sourceSessionId, "source-session");
assert.equal(captured[1].reuseTransport, false);
assert.equal(captured[1].sourceSessionId, undefined);
assert.equal(captured[2].reuseTransport, undefined);
assert.equal(captured[2].reuseTransport, false);
assert.equal(captured[2].sourceSessionId, undefined);
assert.equal(captured[3].reuseTransport, undefined);
assert.equal(captured[3].reuseTransport, false);
assert.deepEqual(reuseAttempts, ["source-session", undefined, undefined]);
});
@@ -502,9 +502,7 @@ test("startSSH requests a fresh transport for Duplicate Session clones", async (
resizeSession: noop,
};
// Without the marker an ordinary open stays eligible for general endpoint
// reuse; with it, every attempt (including reconnects) dials fresh instead
// of borrowing the source's live authenticated transport.
// Ordinary opens and Duplicate Session both require a new login.
await createTerminalSessionStarters(createStarterContext({
shouldUseFreshSshConnection: () => false,
terminalBackend,
@@ -522,12 +520,65 @@ test("startSSH requests a fresh transport for Duplicate Session clones", async (
await duplicateStarters.startSSH(createTermStub() as never);
await duplicateStarters.startSSH(createTermStub() as never);
assert.equal(captured[0].reuseTransport, undefined);
assert.equal(captured[0].reuseTransport, false);
assert.equal(captured[0].sourceSessionId, undefined);
assert.equal(captured[1].reuseTransport, false);
assert.equal(captured[2].reuseTransport, false);
});
test("startSSH dials a fresh transport once a pane has reconnected (#3293)", async () => {
const captured: Record<string, unknown>[] = [];
const terminalBackend = {
backendAvailable: () => true,
startSSHSession: async (options: Record<string, unknown>) => {
captured.push(options);
return `ssh-session-${captured.length}`;
},
onSessionData: () => noop,
onSessionExit: () => noop,
onChainProgress: () => noop,
writeToSession: noop,
resizeSession: noop,
};
// An ordinary new tab must refresh groups even before its first reconnect.
await createTerminalSessionStarters(createStarterContext({
shouldUseFreshSshConnection: () => false,
requireFreshConnectionOnReconnectRef: { current: false },
terminalBackend,
}) as never).startSSH(createTermStub() as never);
assert.equal(captured[0].reuseTransport, false);
// After a reconnect (manual retry / auto-reconnect) every attempt must dial
// a brand-new connection so the server performs a fresh login and picks up
// remote supplementary-group changes (e.g. `usermod -aG`).
const reconnectedStarters = createTerminalSessionStarters(createStarterContext({
shouldUseFreshSshConnection: () => false,
requireFreshConnectionOnReconnectRef: { current: true },
terminalBackend,
}) as never);
await reconnectedStarters.startSSH(createTermStub() as never);
await reconnectedStarters.startSSH(createTermStub() as never);
assert.equal(captured[1].reuseTransport, false);
assert.equal(captured[2].reuseTransport, false);
// Copy/Split may still have an unconsumed source while credentials load.
// A reconnect must discard that intent before the next backend attempt.
const pendingSource = { current: "source-session" as string | undefined };
const reuseAttempts: (string | undefined)[] = [];
await createTerminalSessionStarters(createStarterContext({
shouldUseFreshSshConnection: () => false,
requireFreshConnectionOnReconnectRef: { current: true },
reuseConnectionFromSessionIdRef: pendingSource,
setConnectionReuseAttemptSourceId: (id: string | undefined) => reuseAttempts.push(id),
terminalBackend,
}) as never).startSSH(createTermStub() as never);
assert.equal(captured[3].reuseTransport, false);
assert.equal(captured[3].sourceSessionId, undefined);
assert.equal(pendingSource.current, undefined);
assert.deepEqual(reuseAttempts, [undefined]);
});
test("startSSH commits an empty automation snapshot only after the backend session succeeds", async () => {
let resolveStart: ((sessionId: string) => void) | undefined;
let commitCount = 0;
@@ -605,7 +656,7 @@ test("startSSH rechecks the live automation policy before password fallback", as
await createTerminalSessionStarters(ctx as never).startSSH(createTermStub() as never);
assert.equal(captured.length, 2);
assert.equal(captured[0].reuseTransport, undefined);
assert.equal(captured[0].reuseTransport, false);
assert.equal(captured[1].reuseTransport, false);
assert.equal(commitCount, 0);
});
@@ -616,22 +616,20 @@ export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContex
? ctx.host.identityFilePaths
: undefined;
let sourceReuseAttemptedWithinStart = false;
const startAttempt = async (attempt: {
password?: string;
key?: SSHKey;
useIdentityFiles?: boolean;
useSshAgent?: boolean;
}): Promise<string> => {
const sourceSessionId = ctx.reuseConnectionFromSessionIdRef?.current;
const sourceReuseAttempted = ctx.reuseConnectionSourceAttemptedRef?.current
?? sourceReuseAttemptedWithinStart;
const isFallbackAfterSourceReuse = sourceReuseAttempted && !sourceSessionId;
// Reconnect supersedes a Copy/Split intent that was still waiting for credentials.
const sourceSessionId = ctx.requireFreshConnectionOnReconnectRef?.current
? undefined
: ctx.reuseConnectionFromSessionIdRef?.current;
if (ctx.reuseConnectionFromSessionIdRef) {
ctx.reuseConnectionFromSessionIdRef.current = undefined;
}
if (sourceSessionId) {
sourceReuseAttemptedWithinStart = true;
if (ctx.reuseConnectionSourceAttemptedRef) {
ctx.reuseConnectionSourceAttemptedRef.current = true;
}
@@ -704,21 +702,14 @@ export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContex
// bridge silently falls back to a fresh connection if the source is
// gone, so reconnect/retry after the source closed still works.
sourceSessionId,
// Connect-time automation must see the complete login sequence. An
// explicit Copy/Split keeps its source-session reuse contract, while
// an ordinary open bypasses endpoint/idle transport reuse. Duplicate
// Session clones must never borrow the source's live transport
// either, so they send an explicit `reuseTransport: false`.
reuseTransport: !sourceSessionId
&& (requiresFreshSshConnection || isFallbackAfterSourceReuse || ctx.requireFreshConnection === true)
? false
: undefined,
// Only an explicit Copy/Split may share an existing login. Ordinary
// opens and reconnects must authenticate again to refresh remote groups.
reuseTransport: sourceSessionId ? undefined : false,
skipShellPidDiscovery: ctx.isNetworkDevice === true,
});
if (!requiresFreshSshConnection) {
ctx.onConnectAutomationSnapshotCommitted?.();
}
sourceReuseAttemptedWithinStart = false;
if (ctx.reuseConnectionSourceAttemptedRef) {
ctx.reuseConnectionSourceAttemptedRef.current = false;
}
@@ -1217,7 +1208,13 @@ export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContex
cancelPendingStartupCommand?.();
cancelPendingStartupCommand = undefined;
};
const detectMoshSystem = () => {
if (!isCurrentAttempt()) return;
const token = registerConnectionToken(attachedSessionId);
void runDistroDetection(ctx, attachedSessionId, token);
};
const runMoshStartup = () => {
detectMoshSystem();
disposeMoshReady?.();
disposeMoshReady = undefined;
ctx.setIsConnectionAwaitingUserInput?.(false);
@@ -1307,7 +1304,8 @@ export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContex
runMoshStartup();
}
} else {
// Older bridges without the ready event: keep previous behavior.
// Older bridges without the ready event: the start call completed the handshake.
detectMoshSystem();
scheduleStartupCommand(ctx, term, id);
}
} catch (err) {
@@ -154,6 +154,13 @@ export type TerminalSessionStartersContext = {
// SSH attempt must send `reuseTransport: false` so the bridge never borrows
// the source's live or any other pooled transport.
requireFreshConnection?: boolean;
// Set by the reconnect path (manual retry / auto-reconnect) for the rest of
// the pane's lifetime: every SSH attempt must dial a brand-new connection
// instead of borrowing a live or idle pooled transport. Reusing an
// already-authenticated connection skips the server-side login, so remote
// supplementary-group changes (e.g. `usermod -aG`) stay invisible until the
// whole app quits (#3293).
requireFreshConnectionOnReconnectRef?: MutableRefObject<boolean>;
// Persists across renderer auth retries after the one-shot source intent is
// consumed. Cleared only after a backend session starts successfully.
reuseConnectionSourceAttemptedRef?: MutableRefObject<boolean>;
@@ -1,3 +1,4 @@
import { stringCellWidth } from "../autocomplete/terminalStringCellWidth";
import { FitAddon } from "@xterm/addon-fit";
import { ImageAddon } from "@xterm/addon-image";
import { SearchAddon } from "@xterm/addon-search";
@@ -1065,7 +1066,43 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
overlay.style.fontSize = `${currentTerminalFontSize()}px`;
overlay.style.fontFamily = String(term.options.fontFamily ?? fontFamily);
overlay.style.lineHeight = String(term.options.lineHeight ?? lineHeight);
overlay.textContent = previewRows.map((row) => row.text).join("\n");
// Native font advances differ from xterm's device-pixel-rounded cells.
// Fit each row to its terminal cell width, including double-width glyphs,
// rather than applying a single-cell correction once per Unicode glyph.
overlay.style.fontWeight = String(term.options.fontWeight ?? "normal");
overlay.style.fontKerning = "none";
overlay.style.fontVariantLigatures = "none";
const screenRect = term.element?.querySelector(".xterm-screen")?.getBoundingClientRect();
if (screenRect && screenRect.width > 0 && screenRect.height > 0
&& term.cols > 0 && term.rows > 0) {
overlay.style.lineHeight = `${screenRect.height / term.rows}px`;
const rowElements = previewRows.map((row) => {
const span = document.createElement("span");
span.style.display = "inline-block";
span.style.verticalAlign = "top";
span.style.transformOrigin = "left top";
span.textContent = row.text;
return span;
});
const fragment = document.createDocumentFragment();
rowElements.forEach((span, index) => {
if (index > 0) fragment.appendChild(document.createTextNode("\n"));
fragment.appendChild(span);
});
overlay.replaceChildren(fragment);
// Batch all measurements before writing transforms to avoid one forced
// layout per row. Text nodes and explicit newlines stay unchanged for copy.
const nativeWidths = rowElements.map((span) => span.getBoundingClientRect().width);
rowElements.forEach((span, index) => {
const nativeWidth = nativeWidths[index];
const cells = stringCellWidth(previewRows[index].text, term);
if (nativeWidth > 0 && cells > 0) {
span.style.transform = `scaleX(${cells * screenRect.width / term.cols / nativeWidth})`;
}
});
} else {
overlay.textContent = previewRows.map((row) => row.text).join("\n");
}
overlay.setAttribute(HISTORY_PREVIEW_WRAP_ATTR, encodeHistoryPreviewWrapFlags(previewRows));
return true;
};
@@ -1662,6 +1699,13 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
return false;
}
if (e.type === "keypress" && broadcastForwardedKeys.has(kittyKeyIdentity(e))) {
// Space and uppercase letters can emit xterm data from keypress, after
// keydown's zero-delay cleanup. Rejoin that physical broadcast instead
// of sending the same character a second time as unpaired raw text.
markBroadcastLegacyDataPending(kittyKeyIdentity(e));
}
if (e.type !== "keydown") {
return true;
}
@@ -2423,6 +2467,10 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
});
return;
}
// insertText can follow an already-delivered keypress. Its fallback marker
// must not classify the next physical key as a second composition commit.
// Actual composition/input handlers clear the physical-key pairing first.
if (broadcastLegacyDataPending) kittyCompositionPending = false;
if (kittyCompositionPending && !data.startsWith("\u001b")) {
kittyCompositionPending = false;
if (kittyCompositionClearTimer !== undefined) {
@@ -2733,6 +2781,17 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
},
);
term.onResize(({ cols, rows }) => {
// An idle alternate-screen application may emit nothing after resizing.
// Update history now, using xterm's cursor after any normal-buffer reflow.
const buffer = term.buffer.active;
const cursorLine = buffer.getLine(buffer.baseY + buffer.cursorY);
ctx.terminalOutputHistory?.syncViewportAfterResize({
cols, rows,
cursorX: buffer.cursorX,
cursorY: buffer.cursorY,
cursorLine: cursorLine?.translateToString(true, 0, cols) ?? "",
isWrapped: cursorLine?.isWrapped ?? false,
});
// A reflow can leave stale glyphs in the WebGL atlas; clear it so the new
// dimensions re-rasterize cleanly (issue #1049).
clearWebglTextureAtlas();
@@ -0,0 +1,169 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import test from "node:test";
import { runInNewContext } from "node:vm";
import ts from "typescript";
import { resolveKittyKeyboardBroadcastInput, type KittyKeyboardBroadcastInput } from "./kittyKeyboardBroadcast";
import { createKittyKeyboardModeState, setKittyKeyboardModeFlags, shouldEncodeKittyCompositionText, shouldMarkKittyTextInputEvent, encodeKittyCompositionText } from "./kittyKeyboardProtocol";
import { shouldBlockKeyPressForImeTextInput, shouldCommitDeferredImeTextInput } from "./terminalImeTextInput";
import { sanitizeTerminalInput } from "./terminalInputSanitize";
// Execute the actual registered key/data callbacks, without constructing the
// renderer and its WebGL/addon stack. In particular, retain their real timer
// cleanup and raw-broadcast suppression rather than reproducing that logic here.
const source = readFileSync(new URL("./createXTermRuntime.ts", import.meta.url), "utf8");
function section(start: string, end: string): string {
const from = source.indexOf(start);
const to = source.indexOf(end, from);
assert.ok(from >= 0 && to > from, `runtime section missing: ${start}`);
return source.slice(from, to);
}
const declarations = section(" let suppressNextTerminalDataBroadcast =", " const broadcastKittyInput =");
const keyboardCallback = section(" term.attachCustomKeyEventHandler((e: KeyboardEvent) => {", " const handleMiddleClick =");
const dataCallback = section(" term.onData((data) => {", " const handleKittyKeyboardBroadcast =");
const compositionMarker = section(" const markKittyCompositionPending =", " const finishKittyComposition =");
const textInputMarker = section(" const markKittyTextInput =", ' textarea?.addEventListener("compositionstart"');
const suppression = section(" const suppressTerminalBroadcast =", " // skipBroadcast");
const callbackCode = ts.transpileModule(`
${declarations}
let win32InputModePendingEvent = null;
let kittyCompositionPending = false;
let kittyCompositionClearTimer;
const handleTerminalInputData = (data) => {
const inputSource = "terminal";
${suppression}
if (!suppressTerminalBroadcast) raw(data);
};
const textarea = null;
${compositionMarker}
${textInputMarker}
${keyboardCallback}
${dataCallback}
globalThis.controls = { mark: markBroadcastLegacyDataPending, clear: clearBroadcastLegacyDataPending, input: markKittyTextInput };
`, { compilerOptions: { target: ts.ScriptTarget.ES2022 } }).outputText;
function setup(flags = 0) {
const writes: string[] = [];
const timers = new Map<number, () => void>();
let timerId = 0;
let receive!: (data: string) => void;
let keyboard!: (event: Partial<KeyboardEvent>) => boolean;
const forwarded = new Map<string, { targetSessionIds: string[] }>();
const mode = createKittyKeyboardModeState();
setKittyKeyboardModeFlags(mode, flags);
const options = {
kittyProtocolEnabled: flags !== 0, kittyMode: mode, applicationCursorMode: false,
encodedKeys: new Set<string>(), legacySuppressedKeys: new Set<string>(),
};
const normalized = (input: KittyKeyboardBroadcastInput) => {
const result = resolveKittyKeyboardBroadcastInput(input, options);
if (result) writes.push(result.data);
};
const context = {
controls: undefined as unknown as { mark: (identity: string) => void; clear: () => void; input: (event: { data: string; inputType: string }) => void },
window: {
setTimeout(fn: () => void) { const id = ++timerId; timers.set(id, fn); return id; },
clearTimeout(id: number) { timers.delete(id); },
},
term: {
modes: { win32InputMode: false },
onData(fn: typeof receive) { receive = fn; },
attachCustomKeyEventHandler(fn: typeof keyboard) { keyboard = fn; },
},
ctx: { terminalSettingsRef: { current: {} }, isBroadcastEnabledRef: { current: true }, onBroadcastInputRef: { current: () => undefined } },
imeTextInputDeferredKey: null, imeTextInputDeferredKittyEvent: null,
shouldBlockKeyPressForImeTextInput, shouldCommitDeferredImeTextInput, shouldMarkKittyTextInputEvent, shouldEncodeKittyCompositionText, encodeKittyCompositionText,
kittyKeyboardMode: createKittyKeyboardModeState(), shouldSplitImeTextInputForWire: () => false,
kittyKeyIdentity: (event: Partial<KeyboardEvent>) => event.code || event.key,
broadcastForwardedKeys: forwarded,
broadcastKittyInput: normalized,
sanitizeTerminalInput,
shouldSplitRawPasteInputForWire: () => false,
raw: (data: string) => writes.push(data),
};
runInNewContext(callbackCode, context);
return {
writes, receive: (data: string) => receive(data), controls: context.controls,
keypress: (key: string, code: string) => keyboard({ type: "keypress", key, code }),
press(key: string, code: string) {
normalized({ kind: "key", event: { type: "keydown", key, code }, fallbackToLegacy: true });
forwarded.set(code, { targetSessionIds: ["target"] });
context.controls.mark(code);
},
flushTimers() { for (const [id, fn] of timers) { timers.delete(id); fn(); } },
release(key: string, code: string) {
normalized({ kind: "key", event: { type: "keyup", key, code }, fallbackToLegacy: true });
forwarded.delete(code);
context.controls.clear();
},
};
}
for (const flags of [0, 8]) {
for (const [key, code] of [["A", "KeyA"], [" ", "Space"]]) {
test(`broadcast pairs delayed ${code} keypress once with target flags ${flags}`, () => {
const runtime = setup(flags);
runtime.press(key, code);
const firstWrite = runtime.writes.join("");
assert.ok(firstWrite);
runtime.flushTimers();
assert.equal(runtime.keypress(key, code), true);
runtime.receive(key);
assert.equal(runtime.writes.join(""), firstWrite, "the source's later text must not duplicate its physical key broadcast");
});
}
}
test("repeated physical presses each reach the broadcast target once", () => {
const runtime = setup();
for (let i = 0; i < 3; i++) {
runtime.press("A", "KeyA");
runtime.flushTimers();
runtime.keypress("A", "KeyA");
runtime.receive("A");
}
assert.equal(runtime.writes.join(""), "AAA");
});
test("keypress without an earlier broadcast and text after release are not swallowed", () => {
const runtime = setup();
runtime.keypress("B", "KeyB");
runtime.receive("B");
runtime.press("A", "KeyA");
runtime.flushTimers();
runtime.keypress("A", "KeyA");
runtime.receive("A");
runtime.release("A", "KeyA");
runtime.receive("paste");
assert.equal(runtime.writes.join(""), "BApaste");
});
test("unmatched keypress cleanup still permits a later paste", () => {
const runtime = setup();
runtime.press("A", "KeyA");
runtime.flushTimers();
runtime.keypress("A", "KeyA");
runtime.flushTimers();
runtime.receive("paste");
assert.equal(runtime.writes.join(""), "Apaste");
});
test("trailing insertText for Space does not turn the next physical key into duplicate composition text", () => {
const runtime = setup();
for (const [key, code] of [[" ", "Space"], ["A", "KeyA"], ["B", "KeyB"], ["C", "KeyC"]]) {
runtime.press(key, code);
runtime.keypress(key, code);
runtime.receive(key);
runtime.controls.input({ data: key, inputType: "insertText" });
runtime.release(key, code);
// Real trace: the next key can arrive before insertText's zero-delay timer.
}
assert.equal(runtime.writes.join(""), " ABC");
});
test("keyless insertText is still broadcast as actual text", () => {
const runtime = setup();
runtime.controls.input({ data: "中文", inputType: "insertText" });
runtime.receive("中文");
assert.equal(runtime.writes.join(""), "中文");
});
@@ -106,3 +106,43 @@ test("runDistroDetection normalizes FreeBSD uname output", async () => {
assert.deepEqual(detected, ["freebsd"]);
});
test("Windows OpenSSH is identified without sending POSIX probes", async () => {
const detected: string[] = [];
await runDistroDetection({
host: { id: 'windows', os: 'linux' },
terminalBackend: {
getSessionRemoteInfo: async () => ({ success: true, remoteSshVersion: 'SSH-2.0-OpenSSH_for_Windows_9.5' }),
getSessionDistroInfo: async () => { throw new Error('must not probe Windows with POSIX commands'); },
},
onOsDetected: (_id: string, distro: string) => detected.push(distro),
} as never, 'windows', registerConnectionToken('windows'));
assert.deepEqual(detected, ['windows']);
});
test("failed or unrecognized probes do not invent an operating system", async () => {
const detected: string[] = [];
await runDistroDetection({
host: { id: 'unknown', os: 'linux' },
terminalBackend: {
getSessionDistroInfo: async () => ({ success: true, stdout: '', stderr: 'Linux command not found' }),
},
onOsDetected: (_id: string, distro: string) => detected.push(distro),
} as never, 'unknown', registerConnectionToken('unknown'));
assert.deepEqual(detected, []);
});
test("a superseded Windows detection cannot update the newer connection", async () => {
const detected: string[] = [];
await runDistroDetection({
host: { id: 'windows', os: 'linux' },
terminalBackend: {
getSessionRemoteInfo: async () => {
registerConnectionToken('reconnected');
return { success: true, remoteSshVersion: 'OpenSSH_for_Windows_9.5' };
},
},
onOsDetected: (_id: string, distro: string) => detected.push(distro),
} as never, 'reconnected', registerConnectionToken('reconnected'));
assert.deepEqual(detected, []);
});
@@ -63,6 +63,10 @@ export const runDistroDetection = async (
if (ctx.terminalBackend.getSessionRemoteInfo && sessionId) {
const info = await ctx.terminalBackend.getSessionRemoteInfo(sessionId);
if (!isStillCurrent()) return;
if (!isKnownNetworkDevice && /^(?:SSH-(?:2\.0|1\.99)-)?OpenSSH_for_Windows(?:_|$)/i.test(info?.remoteSshVersion || '')) {
ctx.onOsDetected?.(ctx.host.id, 'windows');
return;
}
const vendor = detectVendorFromSshVersion(info?.remoteSshVersion);
if (vendor) {
ctx.onOsDetected?.(ctx.host.id, vendor);
@@ -89,12 +93,13 @@ export const runDistroDetection = async (
const res = await ctx.terminalBackend.getSessionDistroInfo(sessionId);
if (!isStillCurrent()) return;
if (!res?.success) return;
const data = `${res.stdout || ""}\n${res.stderr || ""}`;
const data = (res.stdout || "").trim();
const idMatch = data.match(/^ID="?([\w-]+)"?$/im);
const rawDistro = idMatch
? idMatch[1]
: (data.split(/\s+/)[0] || "").toLowerCase();
const distro = normalizeDistroId(rawDistro) || rawDistro;
: (data.match(/^(Linux|Darwin|FreeBSD)\b/i)?.[1] || "").toLowerCase();
// An os-release ID confirms Linux even for distributions without a dedicated icon.
const distro = normalizeDistroId(rawDistro) || (idMatch ? 'linux' : '');
if (distro) ctx.onOsDetected?.(ctx.host.id, distro);
}
} catch (err) {
@@ -17,10 +17,30 @@ import {
isHistoryPreviewDismissClick,
joinHistoryPreviewSelectionText,
nextHistoryPreviewTop,
selectHistoryPreviewAll,
shouldHideHistoryPreviewOnMouseDown,
shouldKeepHistoryPreviewOnKey,
} from "./terminalHistoryScrollOverride.ts";
test("select all includes every preview row and preserves soft-wrap copying", async () => {
const { JSDOM } = await import("jsdom");
const dom = new JSDOM("<pre><span>中文abc</span>\n<span>def</span>\n<span>last</span></pre>");
try {
const overlay = dom.window.document.querySelector("pre")!;
overlay.setAttribute(HISTORY_PREVIEW_WRAP_ATTR, "010");
assert.equal(selectHistoryPreviewAll(overlay), true);
assert.equal(dom.window.getSelection()!.toString(), "中文abc\ndef\nlast");
assert.equal(getHistoryPreviewSelectionText(overlay, dom.window.getSelection()), "中文abcdef\nlast");
overlay.textContent = "plain\ntext";
assert.equal(selectHistoryPreviewAll(overlay), true);
assert.equal(dom.window.getSelection()!.toString(), "plain\ntext");
overlay.textContent = "";
assert.equal(selectHistoryPreviewAll(overlay), false);
} finally {
dom.window.close();
}
});
const wheel = (
over: Partial<Parameters<typeof forcedHistoryScrollLinesForWheel>[0]> = {},
) => ({
@@ -352,3 +372,23 @@ test("history preview right-click is recognized as an app-menu target", () => {
false,
);
});
test("nested preview rows retain soft-wrap copy and partial selections", async () => {
const { JSDOM } = await import("jsdom");
const dom = new JSDOM("<pre><span>中文abc</span>\n<span>def</span></pre>");
const overlay = dom.window.document.querySelector("pre")!;
overlay.setAttribute(HISTORY_PREVIEW_WRAP_ATTR, "01");
const first = overlay.firstChild!.firstChild!;
const last = overlay.lastChild!.firstChild!;
const selection = {
rangeCount: 1, anchorNode: first, focusNode: last,
anchorOffset: 1, focusOffset: 2, toString: () => "文abc\ndef",
};
assert.equal(getHistoryPreviewSelectionText(overlay, selection), "文abcde");
assert.equal(getHistoryPreviewSelectionText(overlay, {
...selection, anchorNode: overlay, focusNode: overlay,
anchorOffset: 0, focusOffset: overlay.childNodes.length,
}), "中文abcdef");
dom.window.close();
});
@@ -147,6 +147,7 @@ export type HistoryPreviewSelectionLike = {
};
export type HistoryPreviewNodeLike = {
ownerDocument?: Pick<Document, "createRange">;
contains(node: { nodeType?: number } | null): boolean;
firstChild?: { nodeType?: number } | null;
getAttribute?(name: string): string | null;
@@ -271,6 +272,16 @@ const resolveOverlayTextOffset = (
node: { nodeType?: number } | null,
offset: number,
): number | null => {
if (overlay.ownerDocument && node) {
try {
const prefix = overlay.ownerDocument.createRange();
prefix.selectNodeContents(overlay as unknown as Node);
prefix.setEnd(node as Node, offset);
return prefix.toString().length;
} catch {
return null;
}
}
if (node === textNode) return offset;
if (node === overlay) return offset <= 0 ? 0 : overlay.textContent?.length ?? 0;
return null;
@@ -347,9 +358,7 @@ export const selectHistoryPreviewAll = (overlay: HTMLElement | null | undefined)
const selection = overlay.ownerDocument.getSelection();
if (!selection) return false;
const range = overlay.ownerDocument.createRange();
const textNode = overlay.firstChild;
if (textNode) range.selectNodeContents(textNode);
else range.selectNodeContents(overlay);
range.selectNodeContents(overlay);
selection.removeAllRanges();
selection.addRange(range);
return !selection.isCollapsed;
@@ -1,5 +1,6 @@
import assert from "node:assert/strict";
import test from "node:test";
import { spawnSync } from "node:child_process";
import {
DEFAULT_OUTPUT_HISTORY_MAX_LINES,
@@ -159,6 +160,58 @@ test("preview rows wrap long lines and flag the continuation rows", () => {
);
});
test("preview rows flag lines committed by automatic wraps as soft-wrapped", () => {
const history = createTerminalOutputHistoryPreview();
history.setViewportRows(24);
history.setViewportCols(5);
// "abcde" fills the viewport and "f" wraps; the transcript commits both
// lines, but the preview must keep the soft-wrapped join between them.
history.append("abcdef\n");
assert.deepEqual([...history.getLines()], ["abcde", "f"]);
assert.deepEqual(
history.getPreviewRows({ cols: 5, rows: 2, top: 0 }).rows.map((row) => row.isWrapped),
[false, true],
);
});
test("preview reflows adjacent soft-wrapped lines together when widened", () => {
const history = createTerminalOutputHistoryPreview();
history.setViewportRows(24);
history.setViewportCols(5);
// Captured at five columns, the transcript holds two wrap segments; a
// ten-column preview must rejoin them into the single row xterm shows
// after its resize reflow.
history.append("abcdef\n");
assert.deepEqual(
history.getPreviewRows({ cols: 10, rows: 1, top: 0 }).rows.map((row) => row.text),
["abcdef"],
);
// A narrower preview keeps splitting the rejoined run, flagging the
// continuation rows.
assert.deepEqual(
history.getPreviewRows({ cols: 3, rows: 2, top: 0 }).rows.map((row) => row.text),
["abc", "def"],
);
assert.deepEqual(
history.getPreviewRows({ cols: 3, rows: 2, top: 0 }).rows.map((row) => row.isWrapped),
[false, true],
);
});
test("CUB moves from the displayed last column while a wrap is deferred", () => {
const history = createTerminalOutputHistoryPreview();
history.setViewportRows(24);
history.setViewportCols(5);
// "abcde" fills the row and defers the wrap; xterm displays the cursor on
// the last column, so CUB 1 targets it minus one and X overwrites "e".
history.append("abcde\x1b[DX");
assert.deepEqual([...history.getLines()], ["abcXe"]);
history.clear();
// CUF from the same deferred state still clamps to the last column.
history.append("abcde\x1b[CX");
assert.deepEqual([...history.getLines()], ["abcdX"]);
});
test("preview windows clamp to the retained rows and pad short output", () => {
const history = createTerminalOutputHistoryPreview();
history.append("one\ntwo\n");
@@ -184,6 +237,32 @@ test("preview rows keep wide characters intact at a column boundary", () => {
);
});
test("wrap decisions measure with the live terminal's Unicode width provider", () => {
// The configured `15-graphemes` runtime counts `🖥` as one xterm cell while
// the local fallback counts two, so without the injected provider the
// tracker commits `abcd` and puts `🖥X` on the continuation row while xterm
// renders `abcd🖥` with `X` wrapped. The preview must match the terminal.
const widthTerm = {
_core: {
unicodeService: { getStringCellWidth: (s: string) => [...s].length },
},
} as never;
const history = createTerminalOutputHistoryPreview();
history.setViewportRows(24);
history.setViewportCols(5);
history.setWidthTerminal(widthTerm);
history.append("abcd🖥X");
assert.deepEqual(history.getLines(), ["abcd🖥", "X"]);
const preview = createTerminalOutputHistoryPreview();
preview.setWidthTerminal(widthTerm);
preview.append("abcd🖥X\n");
assert.deepEqual(
preview.getPreviewRows({ cols: 5, rows: 2, top: 0 }).rows.map((row) => row.text),
["abcd🖥", "X"],
);
});
test("wheel steps walk the preview from the newest row upwards", () => {
const history = createTerminalOutputHistoryPreview();
for (let index = 0; index < 6; index += 1) history.append(`row ${index}\n`);
@@ -233,4 +312,571 @@ test("default retention bounds the preview history", () => {
const transcript = [...history.getLines()].join("");
assert.ok(transcript.length <= 2 * 6, transcript);
assert.ok(transcript.includes("cc"), transcript);
});
});
test("Vim absolute row moves separate printed rows across arbitrary chunks", () => {
const input = '\x1b[2;1HSMOKE-VIM-002\x1b[2;14H\x1b[K\x1b[3;1HSMOKE-VIM-003\x1b[3;14H\x1b[K\x1b[4;1HSMOKE-VIM-004';
for (let split = 0; split <= input.length; split++) {
const history = createTerminalOutputHistoryPreview();
history.append(input.slice(0, split));
history.append(input.slice(split));
assert.deepEqual(history.getLines(), ['SMOKE-VIM-002', 'SMOKE-VIM-003', 'SMOKE-VIM-004']);
}
});
test("same-row cursor-home redraw stays one progress line", () => {
const history = createTerminalOutputHistoryPreview();
for (let i = 0; i < 100; i++) history.append(`\x1b[1;1Hprogress ${i}\x1b[K`);
assert.deepEqual(history.getLines(), ['progress 99']);
});
test("vertical moves do not insert blank transcript rows or expose control strings", () => {
const history = createTerminalOutputHistoryPreview();
history.append('one\r\n\x1b[2;1Htwo\x1b[1Bthree\x9b4;1Hfour');
history.append('\x1b]titleH\x07\x1b[999999999Bfive');
assert.deepEqual(history.getLines(), ['one', 'two', ' three', 'four', ' five']);
history.clear();
history.append('\x1b[Hnew\x1b[HNEW\x1b[K');
assert.deepEqual(history.getLines(), ['NEW']);
});
test("row controls retain requested and inherited terminal columns", () => {
const history = createTerminalOutputHistoryPreview();
history.append('\x1b[2;10Htext\x1b[1Bnext\x1b[6dlast\x1b[1Eleft');
assert.deepEqual(history.getLines(), [' text', ' next', ' last', 'left']);
history.clear();
history.append('\x9b2;3fAB\x1b[2;3HXY');
assert.deepEqual(history.getLines(), [' XY']);
});
test("positioned output respects wide-cell boundaries and retained size limits", () => {
const history = createTerminalOutputHistoryPreview({ maxChars: 64 });
history.append('\x1b[2;3H中文\x1b[2;5HX\x1b[K');
assert.deepEqual(history.getLines(), [' 中X']);
history.clear();
history.append('\x1b[2;999999999Hend');
assert.ok(history.getLines().join('').length <= 128);
assert.ok(history.getLines().join('').endsWith('end'));
});
test("CHA and relative horizontal moves stay on the tracked row", () => {
const history = createTerminalOutputHistoryPreview();
history.append('abcde\x1b[1GX');
assert.deepEqual(history.getLines(), ['Xbcde']);
history.clear();
history.append('ab\x1b[3CX');
assert.deepEqual(history.getLines(), ['ab X']);
history.clear();
history.append('abcde\x1b[2DX');
assert.deepEqual(history.getLines(), ['abcXe']);
});
test("NEL and IND advance the row; RI moves back up", () => {
const history = createTerminalOutputHistoryPreview();
history.append('foo\x1bEbar');
assert.deepEqual(history.getLines(), ['foo', 'bar']);
history.clear();
history.append('foo\x1bDbar');
assert.deepEqual(history.getLines(), ['foo', ' bar']);
history.clear();
history.append('\x1b[r\x1b[3;1Hfoo\x1bMbar');
assert.deepEqual(history.getLines(), ['foo', ' bar']);
history.clear();
history.append('\x1b[2;3r\x1b[2;1Hfoo\x1bMbar');
assert.deepEqual(history.getLines(), ['foo', ' bar']);
});
test("cursor placement alone does not append blank history rows", () => {
const history = createTerminalOutputHistoryPreview();
history.append('abc\x1b[2;4H\x1b[1B\x1b[1B');
assert.deepEqual(history.getLines(), ['abc']);
history.append('X');
assert.deepEqual(history.getLines(), ['abc', ' X']);
});
test("positioned overwrites replace whole graphemes and preserve cell spacing", () => {
for (const glyph of ['😀', '👩‍💻', '中']) {
const history = createTerminalOutputHistoryPreview();
history.append(`A${glyph}B\x1b[1;2HX`);
assert.deepEqual(history.getLines(), ['AX B']);
history.clear();
history.append(`A${glyph}B\x1b[1;3HX`);
assert.deepEqual(history.getLines(), ['A XB']);
}
const history = createTerminalOutputHistoryPreview();
history.append('AéB\x1b[1;2HX');
assert.deepEqual(history.getLines(), ['AXB']);
});
test("EL 1 erases a wide glyph intersected by the erase boundary", () => {
// The cursor lands on the wide glyph's first cell: xterm blanks both cells
// and keeps the suffix at its columns.
const history = createTerminalOutputHistoryPreview();
history.append('中A\x1b[1G\x1b[1K');
assert.deepEqual(history.getLines(), [' A']);
history.clear();
// The cursor lands on the wide glyph's second cell: the glyph is erased too.
history.append('A中B\x1b[1;3H\x1b[1K');
assert.deepEqual(history.getLines(), [' B']);
});
test("cursor-row moves clamp to the reported terminal viewport", () => {
const history = createTerminalOutputHistoryPreview();
history.setViewportRows(24);
// Bottom-row address + a down move past the viewport: the terminal keeps
// the cursor on row 24, so the status redraw must not split history lines.
history.append("\x1b[24;1Hstatus\x1b[1BNEW");
assert.deepEqual(history.getLines(), ["statusNEW"]);
history.clear();
// Absolute rows beyond the viewport clamp to the bottom row too: the redraw
// overwrites the bottom row in place instead of adding a history line.
history.append("\x1b[24;1Hrow24\x1b[999;1Hredraw");
assert.deepEqual(history.getLines(), ["redraw"]);
history.clear();
// Without a reported viewport the legacy behavior applies (columns are
// retained across row moves).
const unclamped = createTerminalOutputHistoryPreview();
unclamped.append("\x1b[24;1Hstatus\x1b[1BNEW");
assert.deepEqual(unclamped.getLines(), ["status", " NEW"]);
});
test("absolute cursor columns clamp to the reported viewport width", () => {
const history = createTerminalOutputHistoryPreview();
history.setViewportRows(24);
history.setViewportCols(80);
// CUP past the last column clamps to it on screen; without the clamp the
// preview would pad 998 spaces and fabricate wrapped rows.
history.append("\x1b[1;999HX");
assert.deepEqual(history.getLines(), [" ".repeat(79) + "X"]);
});
test("line feeds on the bottom row stay clamped to it", () => {
const history = createTerminalOutputHistoryPreview();
history.setViewportRows(24);
history.append("\x1b[24;1Hstatus\r\nnew\x1b[24;1HNEW");
assert.deepEqual(history.getLines(), ["status", "NEW"]);
});
test("shrinking the viewport clamps the tracked cursor row", () => {
const history = createTerminalOutputHistoryPreview();
history.setViewportRows(24);
history.append("\x1b[24;1Hbottom");
history.setViewportRows(10);
// The terminal moved the cursor to the new bottom row, so a relative move
// from there must stay a same-row redraw instead of committing a line.
history.append("\x1b[1BX");
assert.deepEqual(history.getLines(), ["bottomX"]);
});
test("relative row moves stop at the scroll region's bottom margin", () => {
const history = createTerminalOutputHistoryPreview();
history.setViewportRows(24);
history.append("\x1b[1;20r\x1b[20;1Hstatus\x1b[1BNEW");
assert.deepEqual(history.getLines(), ["statusNEW"]);
});
test("invalid DECSTBM ranges are ignored", () => {
const history = createTerminalOutputHistoryPreview();
history.setViewportRows(24);
// Bottom not greater than top: xterm ignores the sequence, so the existing
// text must stay on its row and later text must not home the cursor.
history.append("\x1b[5;1Hbefore\x1b[20;10rafter");
assert.deepEqual(history.getLines(), ["beforeafter"]);
history.clear();
// A top past the viewport clamps to it, leaving no valid region either.
history.append("\x1b[5;1Hbefore\x1b[30;40rafter");
assert.deepEqual(history.getLines(), ["beforeafter"]);
history.clear();
// A bottom past the viewport clamps to it; the valid region still applies.
history.append("\x1b[1;999r\x1b[24;1Hstatus\x1b[999BNEW");
assert.deepEqual(history.getLines(), ["statusNEW"]);
});
test("growing the viewport resets the scroll margins", () => {
const history = createTerminalOutputHistoryPreview();
history.setViewportRows(24);
history.append("\x1b[1;24r");
history.setViewportRows(40);
// xterm resets the scroll region on resize, so a relative move past row 24
// reaches row 25 instead of stopping at the stale bottom margin.
history.append("\x1b[24;1Hrow24\x1b[1Brow25");
assert.deepEqual(history.getLines(), ["row24", " row25"]);
});
test("clear resets retained scroll margins between terminal boots", () => {
const history = createTerminalOutputHistoryPreview();
history.setViewportRows(24);
history.append("\x1b[1;20robsolete");
history.clear();
// The fresh xterm instance has a full-viewport region; the new session must
// not inherit the previous boot's bottom margin.
history.setViewportRows(24);
history.append("\x1b[20;1Hrow20\x1b[1Brow21");
assert.deepEqual(history.getLines(), ["row20", " row21"]);
});
test("narrowing the viewport clamps the tracked cursor column", () => {
const history = createTerminalOutputHistoryPreview();
history.setViewportRows(24);
history.setViewportCols(80);
history.append("\x1b[1;80H");
history.setViewportCols(10);
// xterm clamps the cursor to the new last column; the next printable span
// must be written there, not padded out to the old column 80.
history.append("X");
assert.deepEqual(history.getLines(), [" ".repeat(9) + "X"]);
});
test("narrowing the viewport trims the tracked cursor row like xterm", () => {
const history = createTerminalOutputHistoryPreview();
history.setViewportRows(24);
history.setViewportCols(10);
history.append("abcdefgh");
history.setViewportCols(5);
// xterm trims the cursor row to the new width on a narrowing resize
// (`reflowCursorLine` defaults to false, so `fgh` is discarded instead of
// re-wrapped), so X overwrites the last retained cell rather than leaving
// a phantom `fgh` tail plus an extra wrapped preview row.
history.append("X");
assert.deepEqual(history.getLines(), ["abcdX"]);
assert.deepEqual(
history.getPreviewRows({ cols: 5, rows: 1, top: 0 }).rows.map((row) => row.text),
["abcdX"],
);
});
test("narrowing the viewport drops the trimmed wide-glyph tail", () => {
const history = createTerminalOutputHistoryPreview();
history.setViewportRows(24);
history.setViewportCols(6);
history.append("中文");
history.setViewportCols(3);
// The second glyph spans cells 2-3 past the new width; xterm discards it
// whole, so the open row keeps only the glyph that still fits and the next
// printable span lands on the new last column.
assert.deepEqual(history.getLines(), ["中"]);
history.append("X");
assert.deepEqual(history.getLines(), ["中X"]);
});
test("printable output past the viewport width wraps the tracked cursor", () => {
// xterm wraps `f` onto the second row, so `CSI 2;1H` overwrites it there
// instead of adding a separate line below a stale wrapped row.
const input = "abcdef\x1b[2;1HXY";
for (let split = 0; split <= input.length; split++) {
const history = createTerminalOutputHistoryPreview();
history.setViewportRows(24);
history.setViewportCols(5);
history.append(input.slice(0, split));
history.append(input.slice(split));
assert.deepEqual(history.getLines(), ["abcde", "XY"]);
}
});
test("a carriage return cancels the deferred wrap instead of splitting the row", () => {
const history = createTerminalOutputHistoryPreview();
history.setViewportRows(24);
history.setViewportCols(5);
history.append("abcde\rXYZ");
assert.deepEqual(history.getLines(), ["XYZde"]);
});
test("wide glyphs wrap whole at the viewport width", () => {
const history = createTerminalOutputHistoryPreview();
history.setViewportRows(24);
history.setViewportCols(4);
history.append("中文中文中文\n");
assert.deepEqual(history.getLines(), ["中文", "中文", "中文"]);
});
test("origin mode resolves absolute rows against the scroll region", () => {
const history = createTerminalOutputHistoryPreview();
history.setViewportRows(24);
// In origin mode both addresses target row 20 (top margin + 15, then the
// bottom-margin clamp), so the redraw overwrites `status` in place instead
// of retaining it as a stale separate line.
history.append("\x1b[5;20r\x1b[?6h\x1b[16;1Hstatus\x1b[99;1HNEW");
assert.deepEqual(history.getLines(), ["NEWtus"]);
history.clear();
// clear() forgets origin mode: rows are absolute again.
history.setViewportRows(24);
history.append("\x1b[5;20r\x1b[?6h\x1b[?6l\x1b[5;1Habsolute");
assert.deepEqual(history.getLines(), ["absolute"]);
});
test("non-wrapping wide glyph at the right edge cannot block terminal capture", () => {
const moduleUrl = new URL("./terminalOutputHistory.ts", import.meta.url).href;
const script = `
import { createTerminalOutputHistoryPreview } from ${JSON.stringify(moduleUrl)};
const results = [];
for (const glyph of ["中", "👩‍💻", "界界"]) {
const history = createTerminalOutputHistoryPreview();
history.setViewportRows(24);
history.setViewportCols(5);
history.append(String.fromCharCode(27) + "[?7labcd" + glyph + "Z");
results.push(history.getLines());
}
console.log(JSON.stringify(results));
`;
const result = spawnSync(process.execPath, ["--import", "tsx", "--input-type=module", "-e", script], {
encoding: "utf8",
timeout: 5000,
});
assert.equal(result.error, undefined, String(result.error));
assert.equal(result.status, 0, result.stderr);
assert.deepEqual(JSON.parse(result.stdout), [["abcdZ"], ["abcdZ"], ["abcdZ"]]);
});
test("unchanged viewport reports preserve pending wrap across display chunks", () => {
const history = createTerminalOutputHistoryPreview();
history.setViewportRows(24);
history.setViewportCols(5);
history.append("abcde");
history.setViewportRows(24);
history.setViewportCols(5);
history.append("f");
assert.deepEqual(history.getLines(), ["abcde", "f"]);
});
test("a zero-width mark arriving after the wrap column joins the final cell", () => {
const history = createTerminalOutputHistoryPreview();
history.setViewportRows(24);
history.setViewportCols(5);
// The base character fills the last column; its combining mark arrives in
// the next chunk. xterm attaches the mark to that cell without wrapping.
history.append("abcde");
history.append("́Z\n");
assert.deepEqual([...history.getLines()], ["abcdé", "Z"]);
});
test("a standalone zero-width mark after a control starts the wrapped row", () => {
const history = createTerminalOutputHistoryPreview();
history.setViewportRows(24);
history.setViewportCols(5);
// The SGR control xterm parses resets its preceding-join state, so the
// combining mark that follows is a standalone grapheme: it must not attach
// to the previous row's final cell; the pending wrap happens and the mark
// begins the next row (like xterm's `15-graphemes` runtime).
history.append("abcde\x1b[31m");
history.append("́Z\n");
assert.deepEqual([...history.getLines()], ["abcde", "́Z"]);
// The same holds when the control rides inside one display chunk.
history.clear();
history.setViewportRows(24);
history.setViewportCols(5);
history.append("abcde\x1b[31ḿZ\n");
assert.deepEqual([...history.getLines()], ["abcde", "́Z"]);
});
test("combined private-mode controls apply every parameter", () => {
const history = createTerminalOutputHistoryPreview();
history.setViewportRows(24);
history.setViewportCols(5);
// `CSI ?6;7l` resets both origin mode and DECAWM in one control; dropping
// the control whole would leave autowrap on and wrap the overflow into a
// second row (xterm keeps `abcdef` on one row as `abcdf`).
history.append("\x1b[5;20r\x1b[?6;7l\x1b[5;1Habcdef");
assert.deepEqual(history.getLines(), ["abcdf"]);
// `CSI ?6;7h` re-enables both: absolute rows clamp to the margins again and
// printable output wraps once more.
history.append("\x1b[?6;7h\x1b[99;1HNEW");
assert.deepEqual(history.getLines(), ["abcdf", "NEW"]);
// Unrelated parameters ride along: xterm applies both `CSI ?6;25h` params
// (25 is DECTCEM, untracked here), so dropping the control whole would
// resolve the later CUP row absolutely instead of against the top margin.
const combined = createTerminalOutputHistoryPreview();
combined.setViewportRows(24);
combined.append("\x1b[5;20r\x1b[?6;25h\x1b[16;1Hstatus\x1b[99;1HOLD");
assert.deepEqual(combined.getLines(), ["OLDtus"]);
});
test("a grapheme longer than the per-row piece cap is not dropped", () => {
const history = createTerminalOutputHistoryPreview();
history.setViewportRows(24);
history.setViewportCols(5);
// One base character trailed by more combining marks than the piece cap
// holds: cutting at a grapheme boundary yields an empty slice for it, and
// returning early would silently drop this grapheme and everything after.
const mark = "́";
history.append("abcd" + "e" + mark.repeat(80) + "Z");
assert.deepEqual(history.getLines(), ["abcde" + mark.repeat(80), "Z"]);
});
test("discarding a non-fitting wide glyph does not re-pad the deferred gap", () => {
const history = createTerminalOutputHistoryPreview();
history.setViewportRows(24);
history.setViewportCols(5);
// Cursor on the last column, DECAWM off: the wide glyph is discarded and the
// next narrow character overwrites the last cell without padding it again.
history.append("\x1b[?7l\x1b[1;5H中Z");
assert.deepEqual(history.getLines(), [" Z"]);
});
test("restoring a cursor saved above a shrunken viewport clamps to the bottom row", () => {
const history = createTerminalOutputHistoryPreview();
history.setViewportRows(24);
history.append("\x1b[24;1Hsaved\x1b7");
history.setViewportRows(10);
history.append("\x1b8X");
// The saved row 24 no longer exists; the restore lands on the bottom row so
// the later bottom-row address writes `Y` on the same line as `X` instead of
// keeping an obsolete extra line (the saved column is preserved).
history.append("\x1b[10;1HY");
assert.deepEqual(history.getLines(), ["saved", "Y X"]);
});
test("RIS resets the tracked modes, margins, and cursor", () => {
const history = createTerminalOutputHistoryPreview();
history.setViewportRows(24);
history.setViewportCols(5);
// DECAWM off, then RIS: xterm re-enables autowrap and homes the cursor, so
// `abcdef` wraps to `abcde`/`f` exactly like a fresh terminal.
history.append("\x1b[?7l\x1bcabcdef");
assert.deepEqual(history.getLines(), ["abcde", "f"]);
// RIS also clears the scroll region, DECOM, and the saved cursor.
const reset = createTerminalOutputHistoryPreview();
reset.setViewportRows(24);
reset.append("\x1b[5;20r\x1b[?6h\x1b7\x1bc\x1b[99;1HOLD");
// Without the reset, the CUP row would be relative to the top margin and
// clamp to its bottom (row 20), rewriting the same tracked line as before.
assert.deepEqual(reset.getLines(), ["OLD"]);
});
test("RIS commits the open row even when the cursor is already homed", () => {
const history = createTerminalOutputHistoryPreview();
history.setViewportRows(24);
history.setViewportCols(20);
// xterm clears the display before homing the cursor, so output after RIS
// must not overwrite a stale suffix of the cleared screen row.
history.append("LONG\x1bcX");
assert.deepEqual(history.getLines(), ["LONG", "X"]);
});
test("a grapheme split across display chunks is rejoined", () => {
const history = createTerminalOutputHistoryPreview();
history.setViewportRows(5);
history.setViewportCols(4);
// The backend delivered the ZWJ sequence's base in one chunk and its
// continuation in the next; xterm's grapheme provider joins them across
// writes, so the preview must measure the joined cluster, not the chunks.
history.append("👩");
history.append("‍💻Z");
assert.deepEqual(history.getLines(), ["👩‍💻Z"]);
// The unsplit input is the reference behavior.
const joined = createTerminalOutputHistoryPreview();
joined.setViewportRows(5);
joined.setViewportCols(4);
joined.append("👩‍💻Z");
assert.deepEqual(joined.getLines(), ["👩‍💻Z"]);
});
test("a control between chunks resets the split-grapheme join", () => {
const history = createTerminalOutputHistoryPreview();
history.setViewportRows(5);
history.setViewportCols(5);
// xterm resets its preceding-join state at every control it parses, so an
// SGR between the base chunk and the continuation leaves two graphemes:
// 👩 (2 cells) + ‍💻 (2 cells) + Z fill the row before Q wraps.
history.append("👩");
history.append("\x1b[31m");
history.append("‍💻ZQ");
assert.deepEqual(history.getLines(), ["👩‍💻Z", "Q"]);
// The unsplit input with the same control is the reference behavior.
const joined = createTerminalOutputHistoryPreview();
joined.setViewportRows(5);
joined.setViewportCols(5);
joined.append("👩\x1b[31m‍💻ZQ");
assert.deepEqual(joined.getLines(), ["👩‍💻Z", "Q"]);
});
test("a width-only resize resets the tracked scroll margins", () => {
const history = createTerminalOutputHistoryPreview();
history.setViewportRows(24);
history.setViewportCols(80);
history.append("\x1b[1;20r");
history.append("row20content");
// xterm resets its scroll region on any buffer resize; a width-only resize
// must clear the old DECSTBM margins so relative moves clamp to the live
// bottom row instead of the region's stale one.
history.setViewportRows(24);
history.setViewportCols(40);
history.append("\x1b[20;1HX\x1b[BY");
assert.deepEqual(history.getLines(), ["row20content", "X", " Y"]);
});
test("preview reflow retains leading, consecutive and trailing blank hard lines", () => {
const history = createTerminalOutputHistoryPreview();
history.setViewportCols(5);
history.append("\nabcdef\n\n\nend\n\n");
assert.deepEqual([...history.getLines()], ["", "abcde", "f", "", "", "end", ""]);
const preview = history.getPreviewRows({ cols: 10, rows: 6, top: 0 });
assert.equal(preview.totalRows, 6);
assert.deepEqual(preview.rows.map((row) => row.text), ["", "abcdef", "", "", "end", ""]);
});
test("blank-only history remains distinct from empty history", () => {
const history = createTerminalOutputHistoryPreview();
assert.equal(history.getPreviewRowCount(10), 0);
history.append("\n\n");
assert.equal(history.getPreviewRowCount(10), 2);
});
test("reflow keeps an erase-blanked wrapped predecessor as its own row", () => {
const history = createTerminalOutputHistoryPreview();
history.setViewportRows(24);
history.setViewportCols(5);
// At five columns `abcde` fills row one and defers the wrap; `CSI 2K`
// blanks it and `X` wraps to row two, so xterm shows a blank first row and
// a wrapped `X` on the second. The preview must keep both rows instead of
// joining the erased predecessor into its continuation.
history.append("abcde\x1b[2KX");
assert.deepEqual([...history.getLines()], ["", "X"]);
const preview = history.getPreviewRows({ cols: 5, rows: 2, top: 0 });
assert.equal(preview.totalRows, 2);
assert.deepEqual(preview.rows.map((row) => row.text), ["", "X"]);
assert.deepEqual(preview.rows.map((row) => row.isWrapped), [false, true]);
});
test("a grapheme wider than the viewport wraps the following characters", () => {
const history = createTerminalOutputHistoryPreview();
history.setViewportRows(24);
history.setViewportCols(1);
// xterm renders the wide glyph on the first row and wraps `X` to the
// second; retaining the whole tail as one row would shift every later
// cursor-row transition one row behind the terminal.
history.append("中X");
assert.deepEqual([...history.getLines()], ["中", "X"]);
});
test("resizing invalidates cached preview text even when returning to its old width", () => {
const history = createTerminalOutputHistoryPreview();
history.setViewportRows(24);
history.setViewportCols(10);
history.append("abcdefgh");
assert.deepEqual(history.getPreviewRows({ cols: 10, rows: 1, top: 0 }).rows,
[{ text: "abcdefgh", isWrapped: false }]);
// A hidden preview is not read between these resizes. Returning to the
// cached width must not resurrect characters trimmed from the live row.
history.setViewportCols(5);
history.setViewportCols(10);
assert.deepEqual(history.getLines(), ["abcde"]);
assert.deepEqual(history.getPreviewRows({ cols: 10, rows: 1, top: 0 }).rows,
[{ text: "abcde", isWrapped: false }]);
});
test("resizing invalidates cached preview row counts after trimming a wide line", () => {
const history = createTerminalOutputHistoryPreview();
history.setViewportRows(24);
history.setViewportCols(10);
history.append("abcdefgh");
assert.equal(history.getPreviewRowCount(4), 2);
history.setViewportCols(3);
assert.equal(history.getPreviewRowCount(4), 1);
assert.deepEqual(history.getPreviewRows({ cols: 4, rows: 1, top: 0 }).rows,
[{ text: "abc", isWrapped: false }]);
});
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,84 @@
import assert from "node:assert/strict";
import { createRequire } from "node:module";
import { readFileSync } from "node:fs";
import test from "node:test";
import type { Terminal } from "@xterm/xterm";
import { createTerminalOutputHistoryPreview } from "./terminalOutputHistory.ts";
const { Terminal: XTerm } = createRequire(import.meta.url)("@xterm/xterm") as { Terminal: typeof Terminal };
// Exercise the production resize listener without mounting the WebGL/UI runtime.
// The terminal buffer and history collector are real; only unrelated effects
// (atlas clearing and backend resize scheduling) are inert in this harness.
const runtime = readFileSync(new URL("./createXTermRuntime.ts", import.meta.url), "utf8");
const start = runtime.indexOf(" term.onResize(({ cols, rows }) => {");
assert.notEqual(start, -1);
const listenerSource = runtime.slice(start, runtime.indexOf("\n });", start) + "\n });".length);
function createHarness(cols: number) {
const term = new XTerm({ cols, rows: 24, allowProposedApi: true, convertEol: true });
const history = createTerminalOutputHistoryPreview();
new Function("term", "ctx", "clearWebglTextureAtlas", "resizeScheduler", listenerSource)(
term, { terminalOutputHistory: history, sessionRef: { current: null } }, () => {}, { schedule() {} },
);
const write = async (data: string) => {
history.setViewportRows(term.rows);
history.setViewportCols(term.cols);
history.append(data);
await new Promise<void>((resolve) => term.write(data, resolve));
};
return { term, history, write };
}
for (const alternate of [false, true]) test(`idle ${alternate ? "alternate" : "normal"}-screen resize updates history before any new output`, async () => {
const { term, history, write } = createHarness(10);
try {
await write(`${alternate ? "\x1b[?1049h" : ""}abcdefgh`);
history.getPreviewRows({ cols: 10, rows: 1, top: 0 });
term.resize(5, 24);
assert.equal(history.getPreviewRowCount(5), 1);
assert.equal(history.getPreviewRows({ cols: 5, rows: 1, top: 0 }).rows[0].text,
term.buffer.active.getLine(0)?.translateToString(true, 0, term.cols));
term.resize(10, 24);
assert.equal(history.getPreviewRows({ cols: 10, rows: 1, top: 0 }).rows[0].text,
term.buffer.active.getLine(0)?.translateToString(true, 0, term.cols));
} finally { term.dispose(); }
});
test("resize uses xterm's reflowed cursor for later same-row redraws", async () => {
const { term, history, write } = createHarness(5);
try {
await write("abcdef\n");
term.resize(10, 24);
assert.equal(term.buffer.active.cursorY, 1);
await write("X\x1b[2;2HY");
assert.deepEqual(history.getPreviewRows({ cols: 10, rows: 2, top: 0 }).rows.map(row => row.text),
[term.buffer.active.getLine(0)?.translateToString(true), term.buffer.active.getLine(1)?.translateToString(true)]);
} finally { term.dispose(); }
});
for (const chunks of [["abc中X"], ["abc中XYZ"], ["abc中文X"], ["abc中文", "X"], ["abc中\x1b[5GZ"]]) {
test(`no-autowrap final wide cell matches xterm: ${JSON.stringify(chunks)}`, async () => {
const { term, history, write } = createHarness(5);
try {
await write("\x1b[?7l");
for (const chunk of chunks) await write(chunk);
assert.equal(history.getLines().at(-1), term.buffer.active.getLine(0)?.translateToString(true));
} finally { term.dispose(); }
});
}
for (const [save, restore] of [["\x1b7", "\x1b8"], ["\x1b[s", "\x1b[u"]]) {
test(`cursor restore also restores wrapping mode: ${JSON.stringify(save)}`, async () => {
const { term, history, write } = createHarness(5);
try {
await write(`\x1b[?7l${save}\x1b[?7h${restore}abcdef`);
assert.deepEqual(history.getLines(), [term.buffer.active.getLine(0)?.translateToString(true)]);
} finally { term.dispose(); }
});
test(`cursor restore also restores origin mode: ${JSON.stringify(save)}`, async () => {
const { term, history, write } = createHarness(5);
try {
await write(`\x1b[2;5r\x1b[?6h${save}\x1b[?6lA${restore}X\x1b[1;2HY`);
assert.deepEqual(history.getLines(), [term.buffer.active.getLine(0)?.translateToString(true), term.buffer.active.getLine(1)?.translateToString(true)]);
} finally { term.dispose(); }
});
}
@@ -126,3 +126,12 @@ test("falls back to raised maxSuggestions default when unset", () => {
50,
);
});
test('unknown systems do not rewrite the command line while retaining suggestions', () => {
for (const terminalSettings of [undefined, { autocompleteEnabled: true }]) {
const settings = resolveTerminalAutocompleteSettings({protocol:'ssh', systemUnknown:true, terminalSettings});
assert.equal(settings?.enabled, true);
assert.equal(settings?.livePreview, false);
assert.equal(settings?.allowLineReplacement, false);
}
});
+81
View File
@@ -539,3 +539,84 @@ export function forceSyncRenderAfterResize(term: XTerm): void {
logger.warn("Sync render after resize failed", err);
}
}
type XTermWithPrivateViewport = XTerm & {
_core?: {
_viewport?: {
scrollToLine?: (line: number, disableSmoothScroll?: boolean) => void;
_sync?: () => void;
};
};
};
/**
* Re-align the DOM scroll position with the buffer's viewport row.
*
* xterm's reflow adjusts the buffer's viewport row (ydisp) during resize, but
* the scrollable viewport keeps its stale pixel offset. Any subsequent
* relative scroll (wheel, scrollToLine) then applies its delta twice — once
* against the buffer and once against the stale DOM offset — drifting the
* reading position (all the way to the top while shrinking, #3299). Snapping
* the viewport back to the buffer row before a relative restore removes the
* desync.
*/
export function alignTerminalViewportScroll(term: XTerm): void {
const viewport = (term as XTermWithPrivateViewport)._core?._viewport;
const scrollToLine = viewport?.scrollToLine;
if (typeof scrollToLine !== "function") return;
// After a resize, xterm only refreshes the viewport's scroll dimensions on
// its queued render callback. Setting a scroll position against the stale
// dimensions gets clamped to the old maximum while xterm records the
// requested row, so the queued sync then assumes the position was already
// applied and the DOM offset stays stale — the next wheel scroll jumps
// upward by the resize delta. Sync the dimensions now, before positioning.
// If synchronized output (DECSET 2026) is active, _sync() above is a no-op
// that merely defers DOM scroll updates until the mode ends; positioning
// here would still record the requested row as _latestYDisp against the
// stale dimensions, and the deferred sync would then see
// ydisp === _latestYDisp and skip repositioning, leaving a stale DOM
// offset. Leave positioning to that deferred sync instead: after reflow the
// buffer's ydisp differs from the recorded _latestYDisp, so it repositions
// with fresh dimensions on its own.
if (typeof viewport._sync === "function") {
try {
viewport._sync.call(viewport);
} catch (err) {
logger.warn("Sync viewport dimensions after resize failed", err);
}
}
if (term.modes?.synchronizedOutputMode) return;
try {
scrollToLine.call(viewport, term.buffer.active.viewportY, true);
} catch (err) {
logger.warn("Align viewport scroll after resize failed", err);
}
}
/** Defer the whole fit while xterm keeps the visible frame frozen (DECSET 2026). */
export function createSynchronizedOutputFitScheduler() {
let timer: ReturnType<typeof setTimeout> | undefined;
const dispose = () => {
if (timer !== undefined) clearTimeout(timer);
timer = undefined;
};
return {
dispose,
defer(term: XTerm, fit: () => void): boolean {
dispose();
if (!term.modes.synchronizedOutputMode) return false;
// Arms xterm's own synchronized-output timeout even if the program
// only enabled the mode without writing visible content afterward.
term.refresh(0, Math.max(0, term.rows - 1));
timer = setTimeout(() => {
timer = undefined;
// The caller re-enters safeFit and checks the mode again, including
// when another synchronized frame started before this retry.
fit();
}, 32);
return true;
},
};
}
@@ -1,11 +1,12 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import React, { memo, useCallback, useSyncExternalStore } from 'react';
import React, { memo, useCallback, useRef, useSyncExternalStore } from 'react';
import { createPortal } from 'react-dom';
import { activeTabStore } from '../../application/state/activeTabStore';
import { getSftpCurrentPathMemoryKey } from '../../application/state/sftp/sftpReopenLocation';
import {
getSidePanelLiveSnapshot,
SIDE_PANEL_INACTIVE_LIVE_SNAPSHOT,
subscribeSidePanelLiveSnapshot,
} from '../../application/state/sidePanelLiveStore';
import {
@@ -49,10 +50,18 @@ const subscribeShellHistoryNoop = () => () => {};
const getEmptyShellHistorySnapshot = () => EMPTY_SHELL_HISTORY;
function useSidePanelLiveSnapshotForTab(tabId: string, subscribe: boolean) {
const getSnapshot = useCallback(
() => getSidePanelLiveSnapshot(subscribe),
[subscribe],
);
const retainedSnapshot = useRef({ tabId, snapshot: SIDE_PANEL_INACTIVE_LIVE_SNAPSHOT });
const getSnapshot = useCallback(() => {
if (!subscribe) return SIDE_PANEL_INACTIVE_LIVE_SNAPSHOT;
const snapshot = getSidePanelLiveSnapshot(true);
const snapshotTabId = snapshot.activeWorkspace?.id ?? snapshot.focusedSessionId;
// Tab visibility changes before the live publisher commits its next snapshot.
// Keep this panel's last context until its own terminal/workspace catches up.
if (snapshotTabId === tabId) retainedSnapshot.current = { tabId, snapshot };
return retainedSnapshot.current.tabId === tabId
? retainedSnapshot.current.snapshot
: SIDE_PANEL_INACTIVE_LIVE_SNAPSHOT;
}, [subscribe, tabId]);
return useSyncExternalStore(
(listener) => subscribeSidePanelLiveSnapshot(subscribe, listener),
getSnapshot,
+12 -11
View File
@@ -29,19 +29,20 @@
<text x="85" y="85.0" fill="#e6edf3" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="end">6K</text>
<text x="43" y="271.5" fill="#e6edf3" font-size="17" font-family="Handlee, cursive" font-weight="bold" transform="rotate(-90, 43, 271.5)">GitHub Stars</text>
<text x="90.0" y="488" fill="#e6edf3" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Jan 2026</text>
<text x="171.9" y="488" fill="#e6edf3" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Feb 2026</text>
<text x="251.0" y="488" fill="#e6edf3" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Mar 2026</text>
<text x="338.5" y="488" fill="#e6edf3" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Apr 2026</text>
<text x="423.3" y="488" fill="#e6edf3" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">May 2026</text>
<text x="510.8" y="488" fill="#e6edf3" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Jun 2026</text>
<text x="595.5" y="488" fill="#e6edf3" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Jul 2026</text>
<text x="683.1" y="488" fill="#e6edf3" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Aug 2026</text>
<text x="169.6" y="488" fill="#e6edf3" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Feb 2026</text>
<text x="246.4" y="488" fill="#e6edf3" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Mar 2026</text>
<text x="331.5" y="488" fill="#e6edf3" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Apr 2026</text>
<text x="413.8" y="488" fill="#e6edf3" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">May 2026</text>
<text x="498.8" y="488" fill="#e6edf3" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Jun 2026</text>
<text x="581.2" y="488" fill="#e6edf3" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Jul 2026</text>
<text x="666.2" y="488" fill="#e6edf3" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Aug 2026</text>
<text x="751.3" y="488" fill="#e6edf3" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Sep 2026</text>
<text x="50%" y="525" fill="#e6edf3" font-size="17" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Date</text>
<line x1="90" y1="80" x2="90" y2="463" stroke="#30363d" stroke-width="2.5" filter="url(#xkcdify)"/>
<line x1="90" y1="463" x2="765" y2="463" stroke="#30363d" stroke-width="2.5" filter="url(#xkcdify)"/>
<path d="M 90.0,462.0 C 90.5,462.0 92.4,461.6 92.8,461.5 C 93.8,461.4 94.7,461.1 95.6,461.0 C 96.6,460.8 97.5,460.5 98.5,460.4 C 99.4,460.3 100.4,460.4 101.3,460.3 C 102.2,460.3 103.2,460.2 104.1,460.2 C 105.1,460.1 106.0,460.1 106.9,460.0 C 107.9,459.9 108.4,459.9 109.8,459.8 C 111.2,459.7 114.0,459.7 115.4,459.6 C 116.8,459.6 117.3,459.5 118.2,459.5 C 119.2,459.5 120.1,459.4 121.1,459.4 C 122.0,459.4 122.9,459.4 123.9,459.4 C 124.8,459.3 125.8,459.3 126.7,459.3 C 127.7,459.3 128.6,459.2 129.5,459.1 C 130.5,459.0 131.4,459.0 132.4,458.9 C 133.3,458.7 134.2,458.4 135.2,458.0 C 136.1,457.5 137.1,456.7 138.0,456.4 C 139.0,456.1 139.9,456.2 140.8,456.2 C 141.8,456.1 142.7,456.1 143.7,456.0 C 144.6,456.0 145.1,455.9 146.5,455.9 C 147.9,455.9 150.3,455.9 152.1,455.9 C 154.0,455.8 156.4,455.8 157.8,455.7 C 159.2,455.7 159.7,455.6 160.6,455.6 C 161.5,455.6 162.5,455.5 163.4,455.5 C 164.4,455.4 165.3,455.4 166.3,455.3 C 167.2,455.3 168.1,455.3 169.1,455.3 C 170.0,455.3 170.5,455.2 171.9,455.2 C 173.3,455.2 175.2,455.1 177.6,455.1 C 179.9,455.1 183.7,455.0 186.0,455.0 C 188.4,455.0 188.8,455.0 191.7,455.0 C 194.5,454.9 200.6,454.8 203.0,454.7 C 205.3,454.6 204.4,454.7 205.8,454.6 C 207.2,454.6 209.1,454.6 211.4,454.6 C 213.8,454.5 215.2,454.5 219.9,454.4 C 224.6,454.4 235.9,454.3 239.7,454.3 C 243.5,454.2 241.6,454.2 242.5,454.2 C 243.5,454.2 243.9,454.9 245.3,454.1 C 246.7,453.3 249.6,452.6 251.0,449.3 C 252.4,445.9 252.9,436.9 253.8,434.1 C 254.7,431.3 255.7,432.9 256.6,432.5 C 257.6,432.0 258.5,431.8 259.5,431.5 C 260.4,431.2 261.3,430.9 262.3,430.8 C 263.2,430.6 264.2,430.5 265.1,430.4 C 266.0,430.3 267.0,430.1 267.9,430.0 C 268.9,429.9 269.8,430.0 270.8,429.9 C 271.7,429.8 272.6,429.6 273.6,429.5 C 274.5,429.4 275.5,429.4 276.4,429.3 C 277.3,429.1 278.3,428.7 279.2,428.6 C 280.2,428.4 281.1,428.5 282.1,428.4 C 283.0,428.3 283.9,428.0 284.9,428.0 C 285.8,427.9 286.8,427.9 287.7,427.9 C 288.6,427.8 289.6,427.8 290.5,427.7 C 291.5,427.6 292.4,427.6 293.3,427.5 C 294.3,427.4 295.2,427.4 296.2,427.3 C 297.1,427.2 298.1,426.9 299.0,426.7 C 299.9,426.6 300.9,427.4 301.8,426.3 C 302.8,425.2 303.7,421.5 304.6,420.3 C 305.6,419.1 306.5,419.2 307.5,419.0 C 308.4,418.8 309.4,419.1 310.3,419.0 C 311.2,418.8 312.2,418.4 313.1,418.1 C 314.1,417.7 315.0,417.5 315.9,416.9 C 316.9,416.3 317.8,415.2 318.8,414.5 C 319.7,413.8 320.6,413.1 321.6,412.6 C 322.5,412.1 323.5,411.7 324.4,411.4 C 325.4,411.1 326.3,410.9 327.2,410.8 C 328.2,410.6 329.1,410.7 330.1,410.5 C 331.0,410.4 331.9,410.1 332.9,410.0 C 333.8,409.9 334.8,410.0 335.7,409.9 C 336.7,409.8 337.6,409.8 338.5,409.6 C 339.5,409.5 340.4,409.5 341.4,409.1 C 342.3,408.6 343.2,407.3 344.2,406.8 C 345.1,406.2 346.1,406.0 347.0,405.6 C 347.9,405.1 348.9,406.0 349.8,404.1 C 350.8,402.3 351.7,396.7 352.7,394.6 C 353.6,392.4 354.5,392.1 355.5,391.2 C 356.4,390.3 357.4,389.7 358.3,389.1 C 359.2,388.4 360.2,387.8 361.1,387.3 C 362.1,386.8 363.0,386.4 364.0,386.1 C 364.9,385.8 365.8,385.7 366.8,385.4 C 367.7,385.2 368.7,384.9 369.6,384.5 C 370.5,384.2 371.5,383.6 372.4,383.3 C 373.4,383.0 374.3,383.1 375.3,382.8 C 376.2,382.5 377.1,381.9 378.1,381.5 C 379.0,381.2 380.0,380.9 380.9,380.6 C 381.8,380.3 382.8,380.1 383.7,379.9 C 384.7,379.7 385.6,379.7 386.5,379.5 C 387.5,379.3 388.4,379.1 389.4,378.9 C 390.3,378.7 391.3,378.5 392.2,378.3 C 393.1,378.0 394.1,377.8 395.0,377.5 C 396.0,377.1 396.9,376.4 397.8,376.1 C 398.8,375.7 399.7,375.6 400.7,375.4 C 401.6,375.2 402.6,375.1 403.5,375.0 C 404.4,374.8 405.4,374.7 406.3,374.6 C 407.3,374.5 408.2,374.3 409.1,374.2 C 410.1,374.1 411.0,374.2 412.0,374.0 C 412.9,373.9 413.8,373.6 414.8,373.3 C 415.7,373.0 416.7,372.4 417.6,372.0 C 418.6,371.6 419.5,371.2 420.4,371.0 C 421.4,370.7 422.3,370.7 423.3,370.5 C 424.2,370.3 425.1,370.3 426.1,370.0 C 427.0,369.7 428.0,369.1 428.9,368.8 C 429.9,368.6 430.8,368.7 431.7,368.5 C 432.7,368.4 433.6,368.1 434.6,367.9 C 435.5,367.7 436.4,367.5 437.4,367.2 C 438.3,367.0 439.3,366.8 440.2,366.5 C 441.2,366.3 442.1,366.2 443.0,366.0 C 444.0,365.7 444.9,365.3 445.9,365.0 C 446.8,364.7 447.7,364.6 448.7,364.3 C 449.6,364.0 450.6,363.7 451.5,363.4 C 452.4,363.0 453.4,362.7 454.3,362.4 C 455.3,362.1 456.2,362.0 457.2,361.6 C 458.1,361.1 459.0,360.0 460.0,359.6 C 460.9,359.2 461.9,359.1 462.8,359.0 C 463.7,358.8 464.7,358.8 465.6,358.7 C 466.6,358.6 467.5,358.4 468.5,358.2 C 469.4,358.1 470.3,358.4 471.3,357.9 C 472.2,357.5 473.2,356.2 474.1,355.7 C 475.0,355.2 476.0,355.3 476.9,355.0 C 477.9,354.7 478.8,354.1 479.7,353.9 C 480.7,353.7 481.6,353.8 482.6,353.6 C 483.5,353.4 484.5,352.7 485.4,352.5 C 486.3,352.3 487.3,352.3 488.2,352.2 C 489.2,352.1 490.1,352.1 491.0,351.8 C 492.0,351.5 492.9,351.2 493.9,350.6 C 494.8,350.0 495.8,348.8 496.7,348.2 C 497.6,347.5 498.6,347.3 499.5,346.8 C 500.5,346.4 501.4,346.3 502.3,345.4 C 503.3,344.6 504.2,342.6 505.2,341.7 C 506.1,340.7 507.1,340.3 508.0,339.7 C 508.9,339.2 509.9,339.1 510.8,338.2 C 511.8,337.4 512.7,335.5 513.6,334.6 C 514.6,333.8 515.5,333.7 516.5,333.2 C 517.4Line truncated
<path d="M 90.0,462.0 C 90.5,462.0 92.4,461.6 92.8,461.5 C 93.8,461.4 94.7,461.1 95.6,461.0 C 96.6,460.8 97.5,460.5 98.5,460.4 C 99.4,460.3 100.4,460.4 101.3,460.3 C 102.2,460.3 103.2,460.2 104.1,460.2 C 105.1,460.1 106.0,460.1 106.9,460.0 C 107.9,459.9 108.4,459.9 109.8,459.8 C 111.2,459.7 114.0,459.7 115.4,459.6 C 116.8,459.6 117.3,459.5 118.2,459.5 C 119.2,459.5 120.1,459.4 121.1,459.4 C 122.0,459.4 122.9,459.4 123.9,459.4 C 124.8,459.3 125.8,459.3 126.7,459.3 C 127.7,459.3 128.6,459.2 129.5,459.1 C 130.5,459.0 131.4,459.0 132.4,458.9 C 133.3,458.7 134.2,458.4 135.2,458.0 C 136.1,457.5 137.1,456.7 138.0,456.4 C 139.0,456.1 139.9,456.2 140.8,456.2 C 141.8,456.1 142.7,456.1 143.7,456.0 C 144.6,456.0 145.1,455.9 146.5,455.9 C 147.9,455.9 150.3,455.9 152.1,455.9 C 154.0,455.8 156.4,455.8 157.8,455.7 C 159.2,455.7 159.7,455.6 160.6,455.6 C 161.5,455.6 162.5,455.5 163.4,455.5 C 164.4,455.4 165.3,455.4 166.3,455.3 C 167.2,455.3 168.1,455.3 169.1,455.3 C 170.0,455.3 170.5,455.2 171.9,455.2 C 173.3,455.2 175.2,455.1 177.6,455.1 C 179.9,455.1 183.7,455.0 186.0,455.0 C 188.4,455.0 188.8,455.0 191.7,455.0 C 194.5,454.9 200.6,454.8 203.0,454.7 C 205.3,454.6 204.4,454.7 205.8,454.6 C 207.2,454.6 209.1,454.6 211.4,454.6 C 213.8,454.5 215.2,454.5 219.9,454.4 C 224.6,454.4 235.9,454.3 239.7,454.3 C 243.5,454.2 241.6,454.2 242.5,454.2 C 243.5,454.2 243.9,454.9 245.3,454.1 C 246.7,453.3 249.6,452.6 251.0,449.3 C 252.4,445.9 252.9,436.9 253.8,434.1 C 254.7,431.3 255.7,432.9 256.6,432.5 C 257.6,432.0 258.5,431.8 259.5,431.5 C 260.4,431.2 261.3,430.9 262.3,430.8 C 263.2,430.6 264.2,430.5 265.1,430.4 C 266.0,430.3 267.0,430.1 267.9,430.0 C 268.9,429.9 269.8,430.0 270.8,429.9 C 271.7,429.8 272.6,429.6 273.6,429.5 C 274.5,429.4 275.5,429.4 276.4,429.3 C 277.3,429.1 278.3,428.7 279.2,428.6 C 280.2,428.4 281.1,428.5 282.1,428.4 C 283.0,428.3 283.9,428.0 284.9,428.0 C 285.8,427.9 286.8,427.9 287.7,427.9 C 288.6,427.8 289.6,427.8 290.5,427.7 C 291.5,427.6 292.4,427.6 293.3,427.5 C 294.3,427.4 295.2,427.4 296.2,427.3 C 297.1,427.2 298.1,426.9 299.0,426.7 C 299.9,426.6 300.9,427.4 301.8,426.3 C 302.8,425.2 303.7,421.5 304.6,420.3 C 305.6,419.1 306.5,419.2 307.5,419.0 C 308.4,418.8 309.4,419.1 310.3,419.0 C 311.2,418.8 312.2,418.4 313.1,418.1 C 314.1,417.7 315.0,417.5 315.9,416.9 C 316.9,416.3 317.8,415.2 318.8,414.5 C 319.7,413.8 320.6,413.1 321.6,412.6 C 322.5,412.1 323.5,411.7 324.4,411.4 C 325.4,411.1 326.3,410.9 327.2,410.8 C 328.2,410.6 329.1,410.7 330.1,410.5 C 331.0,410.4 331.9,410.1 332.9,410.0 C 333.8,409.9 334.8,410.0 335.7,409.9 C 336.7,409.8 337.6,409.8 338.5,409.6 C 339.5,409.5 340.4,409.5 341.4,409.1 C 342.3,408.6 343.2,407.3 344.2,406.8 C 345.1,406.2 346.1,406.0 347.0,405.6 C 347.9,405.1 348.9,406.0 349.8,404.1 C 350.8,402.3 351.7,396.7 352.7,394.6 C 353.6,392.4 354.5,392.1 355.5,391.2 C 356.4,390.3 357.4,389.7 358.3,389.1 C 359.2,388.4 360.2,387.8 361.1,387.3 C 362.1,386.8 363.0,386.4 364.0,386.1 C 364.9,385.8 365.8,385.7 366.8,385.4 C 367.7,385.2 368.7,384.9 369.6,384.5 C 370.5,384.2 371.5,383.6 372.4,383.3 C 373.4,383.0 374.3,383.1 375.3,382.8 C 376.2,382.5 377.1,381.9 378.1,381.5 C 379.0,381.2 380.0,380.9 380.9,380.6 C 381.8,380.3 382.8,380.1 383.7,379.9 C 384.7,379.7 385.6,379.7 386.5,379.5 C 387.5,379.3 388.4,379.1 389.4,378.9 C 390.3,378.7 391.3,378.5 392.2,378.3 C 393.1,378.0 394.1,377.8 395.0,377.5 C 396.0,377.1 396.9,376.4 397.8,376.1 C 398.8,375.7 399.7,375.6 400.7,375.4 C 401.6,375.2 402.6,375.1 403.5,375.0 C 404.4,374.8 405.4,374.7 406.3,374.6 C 407.3,374.5 408.2,374.3 409.1,374.2 C 410.1,374.1 411.0,374.2 412.0,374.0 C 412.9,373.9 413.8,373.6 414.8,373.3 C 415.7,373.0 416.7,372.4 417.6,372.0 C 418.6,371.6 419.5,371.2 420.4,371.0 C 421.4,370.7 422.3,370.7 423.3,370.5 C 424.2,370.3 425.1,370.3 426.1,370.0 C 427.0,369.7 428.0,369.1 428.9,368.8 C 429.9,368.6 430.8,368.7 431.7,368.5 C 432.7,368.4 433.6,368.1 434.6,367.9 C 435.5,367.7 436.4,367.5 437.4,367.2 C 438.3,367.0 439.3,366.8 440.2,366.5 C 441.2,366.3 442.1,366.2 443.0,366.0 C 444.0,365.7 444.9,365.3 445.9,365.0 C 446.8,364.7 447.7,364.6 448.7,364.3 C 449.6,364.0 450.6,363.7 451.5,363.4 C 452.4,363.0 453.4,362.7 454.3,362.4 C 455.3,362.1 456.2,362.0 457.2,361.6 C 458.1,361.1 459.0,360.0 460.0,359.6 C 460.9,359.2 461.9,359.1 462.8,359.0 C 463.7,358.8 464.7,358.8 465.6,358.7 C 466.6,358.6 467.5,358.4 468.5,358.2 C 469.4,358.1 470.3,358.4 471.3,357.9 C 472.2,357.5 473.2,356.2 474.1,355.7 C 475.0,355.2 476.0,355.3 476.9,355.0 C 477.9,354.7 478.8,354.1 479.7,353.9 C 480.7,353.7 481.6,353.8 482.6,353.6 C 483.5,353.4 484.5,352.7 485.4,352.5 C 486.3,352.3 487.3,352.3 488.2,352.2 C 489.2,352.1 490.1,352.1 491.0,351.8 C 492.0,351.5 492.9,351.2 493.9,350.6 C 494.8,350.0 495.8,348.8 496.7,348.2 C 497.6,347.5 498.6,347.3 499.5,346.8 C 500.5,346.4 501.4,346.3 502.3,345.4 C 503.3,344.6 504.2,342.6 505.2,341.7 C 506.1,340.7 507.1,340.3 508.0,339.7 C 508.9,339.2 509.9,339.1 510.8,338.2 C 511.8,337.4 512.7,335.5 513.6,334.6 C 514.6,333.8 515.5,333.7 516.5,333.2 C 517.4Line truncated
<circle cx="765.0" cy="111.8" r="5" fill="#dd4528" stroke="#0d1117" stroke-width="2" filter="url(#xkcdify)"/>
<text x="753.0" y="77.0" fill="#dd4528" font-size="18" font-family="Handlee, cursive" font-weight="bold" text-anchor="end">5502</text>
<path d="M 90.0,462.0 C 90.5,462.0 92.3,461.6 92.7,461.5 C 93.7,461.4 94.6,461.1 95.5,461.0 C 96.4,460.8 97.3,460.5 98.2,460.4 C 99.1,460.3 100.1,460.4 101.0,460.3 C 101.9,460.3 102.8,460.2 103.7,460.2 C 104.6,460.1 105.5,460.1 106.5,460.0 C 107.4,459.9 107.8,459.9 109.2,459.8 C 110.6,459.7 113.3,459.7 114.7,459.6 C 116.1,459.6 116.5,459.5 117.4,459.5 C 118.4,459.5 119.3,459.4 120.2,459.4 C 121.1,459.4 122.0,459.4 122.9,459.4 C 123.8,459.3 124.8,459.3 125.7,459.3 C 126.6,459.3 127.5,459.2 128.4,459.1 C 129.3,459.0 130.2,459.0 131.2,458.9 C 132.1,458.7 133.0,458.4 133.9,458.0 C 134.8,457.5 135.7,456.7 136.6,456.4 C 137.6,456.1 138.5,456.2 139.4,456.2 C 140.3,456.1 141.2,456.1 142.1,456.0 C 143.0,456.0 143.5,455.9 144.9,455.9 C 146.2,455.9 148.5,455.9 150.4,455.9 C 152.2,455.8 154.5,455.8 155.9,455.7 C 157.2,455.7 157.7,455.6 158.6,455.6 C 159.5,455.6 160.4,455.5 161.3,455.5 C 162.3,455.4 163.2,455.4 164.1,455.3 C 165.0,455.3 165.9,455.3 166.8,455.3 C 167.7,455.3 168.2,455.2 169.6,455.2 C 170.9,455.2 172.8,455.1 175.1,455.1 C 177.3,455.1 181.0,455.0 183.3,455.0 C 185.6,455.0 186.0,455.0 188.8,455.0 C 191.5,454.9 197.5,454.8 199.8,454.7 C 202.0,454.6 201.1,454.7 202.5,454.6 C 203.9,454.6 205.7,454.6 208.0,454.6 C 210.3,454.5 211.6,454.5 216.2,454.4 C 220.8,454.4 231.8,454.3 235.4,454.3 C 239.1,454.2 237.3,454.2 238.2,454.2 C 239.1,454.2 239.5,454.9 240.9,454.1 C 242.3,453.3 245.0,452.6 246.4,449.3 C 247.8,445.9 248.2,436.9 249.1,434.1 C 250.1,431.3 251.0,432.9 251.9,432.5 C 252.8,432.0 253.7,431.8 254.6,431.5 C 255.5,431.2 256.5,430.9 257.4,430.8 C 258.3,430.6 259.2,430.5 260.1,430.4 C 261.0,430.3 262.0,430.1 262.9,430.0 C 263.8,429.9 264.7,430.0 265.6,429.9 C 266.5,429.8 267.4,429.6 268.4,429.5 C 269.3,429.4 270.2,429.4 271.1,429.3 C 272.0,429.1 272.9,428.7 273.8,428.6 C 274.8,428.4 275.7,428.5 276.6,428.4 C 277.5,428.3 278.4,428.0 279.3,428.0 C 280.2,427.9 281.2,427.9 282.1,427.9 C 283.0,427.8 283.9,427.8 284.8,427.7 C 285.7,427.6 286.6,427.6 287.6,427.5 C 288.5,427.4 289.4,427.4 290.3,427.3 C 291.2,427.2 292.1,426.9 293.0,426.7 C 294.0,426.6 294.9,427.4 295.8,426.3 C 296.7,425.2 297.6,421.5 298.5,420.3 C 299.5,419.1 300.4,419.2 301.3,419.0 C 302.2,418.8 303.1,419.1 304.0,419.0 C 304.9,418.8 305.9,418.4 306.8,418.1 C 307.7,417.7 308.6,417.5 309.5,416.9 C 310.4,416.3 311.3,415.2 312.3,414.5 C 313.2,413.8 314.1,413.1 315.0,412.6 C 315.9,412.1 316.8,411.7 317.7,411.4 C 318.7,411.1 319.6,410.9 320.5,410.8 C 321.4,410.6 322.3,410.7 323.2,410.5 C 324.1,410.4 325.1,410.1 326.0,410.0 C 326.9,409.9 327.8,410.0 328.7,409.9 C 329.6,409.8 330.5,409.8 331.5,409.6 C 332.4,409.5 333.3,409.5 334.2,409.1 C 335.1,408.6 336.0,407.3 337.0,406.8 C 337.9,406.2 338.8,406.0 339.7,405.6 C 340.6,405.1 341.5,406.0 342.4,404.1 C 343.4,402.3 344.3,396.7 345.2,394.6 C 346.1,392.4 347.0,392.1 347.9,391.2 C 348.8,390.3 349.8,389.7 350.7,389.1 C 351.6,388.4 352.5,387.8 353.4,387.3 C 354.3,386.8 355.2,386.4 356.2,386.1 C 357.1,385.8 358.0,385.7 358.9,385.4 C 359.8,385.2 360.7,384.9 361.6,384.5 C 362.6,384.2 363.5,383.6 364.4,383.3 C 365.3,383.0 366.2,383.1 367.1,382.8 C 368.0,382.5 369.0,381.9 369.9,381.5 C 370.8,381.2 371.7,380.9 372.6,380.6 C 373.5,380.3 374.5,380.1 375.4,379.9 C 376.3,379.7 377.2,379.7 378.1,379.5 C 379.0,379.3 379.9,379.1 380.9,378.9 C 381.8,378.7 382.7,378.5 383.6,378.3 C 384.5,378.0 385.4,377.8 386.3,377.5 C 387.3,377.1 388.2,376.4 389.1,376.1 C 390.0,375.7 390.9,375.6 391.8,375.4 C 392.7,375.2 393.7,375.1 394.6,375.0 C 395.5,374.8 396.4,374.7 397.3,374.6 C 398.2,374.5 399.1,374.3 400.1,374.2 C 401.0,374.1 401.9,374.2 402.8,374.0 C 403.7,373.9 404.6,373.6 405.5,373.3 C 406.5,373.0 407.4,372.4 408.3,372.0 C 409.2,371.6 410.1,371.2 411.0,371.0 C 412.0,370.7 412.9,370.7 413.8,370.5 C 414.7,370.3 415.6,370.3 416.5,370.0 C 417.4,369.7 418.4,369.1 419.3,368.8 C 420.2,368.6 421.1,368.7 422.0,368.5 C 422.9,368.4 423.8,368.1 424.8,367.9 C 425.7,367.7 426.6,367.5 427.5,367.2 C 428.4,367.0 429.3,366.8 430.2,366.5 C 431.2,366.3 432.1,366.2 433.0,366.0 C 433.9,365.7 434.8,365.3 435.7,365.0 C 436.6,364.7 437.6,364.6 438.5,364.3 C 439.4,364.0 440.3,363.7 441.2,363.4 C 442.1,363.0 443.0,362.7 444.0,362.4 C 444.9,362.1 445.8,362.0 446.7,361.6 C 447.6,361.1 448.5,360.0 449.5,359.6 C 450.4,359.2 451.3,359.1 452.2,359.0 C 453.1,358.8 454.0,358.8 454.9,358.7 C 455.9,358.6 456.8,358.4 457.7,358.2 C 458.6,358.1 459.5,358.4 460.4,357.9 C 461.3,357.5 462.3,356.2 463.2,355.7 C 464.1,355.2 465.0,355.3 465.9,355.0 C 466.8,354.7 467.7,354.1 468.7,353.9 C 469.6,353.7 470.5,353.8 471.4,353.6 C 472.3,353.4 473.2,352.7 474.1,352.5 C 475.1,352.3 476.0,352.3 476.9,352.2 C 477.8,352.1 478.7,352.1 479.6,351.8 C 480.5,351.5 481.5,351.2 482.4,350.6 C 483.3,350.0 484.2,348.8 485.1,348.2 C 486.0,347.5 487.0,347.3 487.9,346.8 C 488.8,346.4 489.7,346.3 490.6,345.4 C 491.5,344.6 492.4,342.6 493.4,341.7 C 494.3,340.7 495.2,340.3 496.1,339.7 C 497.0,339.2 497.9,339.1 498.8,338.2 C 499.8,337.4 500.7,335.5 501.6,334.6 C 502.5,333.8 503.4,333.7 504.3,333.2 C 505.2Line truncated
<path d="M 90.0,462.0 C 90.5,462.0 92.3,461.6 92.7,461.5 C 93.7,461.4 94.6,461.1 95.5,461.0 C 96.4,460.8 97.3,460.5 98.2,460.4 C 99.1,460.3 100.1,460.4 101.0,460.3 C 101.9,460.3 102.8,460.2 103.7,460.2 C 104.6,460.1 105.5,460.1 106.5,460.0 C 107.4,459.9 107.8,459.9 109.2,459.8 C 110.6,459.7 113.3,459.7 114.7,459.6 C 116.1,459.6 116.5,459.5 117.4,459.5 C 118.4,459.5 119.3,459.4 120.2,459.4 C 121.1,459.4 122.0,459.4 122.9,459.4 C 123.8,459.3 124.8,459.3 125.7,459.3 C 126.6,459.3 127.5,459.2 128.4,459.1 C 129.3,459.0 130.2,459.0 131.2,458.9 C 132.1,458.7 133.0,458.4 133.9,458.0 C 134.8,457.5 135.7,456.7 136.6,456.4 C 137.6,456.1 138.5,456.2 139.4,456.2 C 140.3,456.1 141.2,456.1 142.1,456.0 C 143.0,456.0 143.5,455.9 144.9,455.9 C 146.2,455.9 148.5,455.9 150.4,455.9 C 152.2,455.8 154.5,455.8 155.9,455.7 C 157.2,455.7 157.7,455.6 158.6,455.6 C 159.5,455.6 160.4,455.5 161.3,455.5 C 162.3,455.4 163.2,455.4 164.1,455.3 C 165.0,455.3 165.9,455.3 166.8,455.3 C 167.7,455.3 168.2,455.2 169.6,455.2 C 170.9,455.2 172.8,455.1 175.1,455.1 C 177.3,455.1 181.0,455.0 183.3,455.0 C 185.6,455.0 186.0,455.0 188.8,455.0 C 191.5,454.9 197.5,454.8 199.8,454.7 C 202.0,454.6 201.1,454.7 202.5,454.6 C 203.9,454.6 205.7,454.6 208.0,454.6 C 210.3,454.5 211.6,454.5 216.2,454.4 C 220.8,454.4 231.8,454.3 235.4,454.3 C 239.1,454.2 237.3,454.2 238.2,454.2 C 239.1,454.2 239.5,454.9 240.9,454.1 C 242.3,453.3 245.0,452.6 246.4,449.3 C 247.8,445.9 248.2,436.9 249.1,434.1 C 250.1,431.3 251.0,432.9 251.9,432.5 C 252.8,432.0 253.7,431.8 254.6,431.5 C 255.5,431.2 256.5,430.9 257.4,430.8 C 258.3,430.6 259.2,430.5 260.1,430.4 C 261.0,430.3 262.0,430.1 262.9,430.0 C 263.8,429.9 264.7,430.0 265.6,429.9 C 266.5,429.8 267.4,429.6 268.4,429.5 C 269.3,429.4 270.2,429.4 271.1,429.3 C 272.0,429.1 272.9,428.7 273.8,428.6 C 274.8,428.4 275.7,428.5 276.6,428.4 C 277.5,428.3 278.4,428.0 279.3,428.0 C 280.2,427.9 281.2,427.9 282.1,427.9 C 283.0,427.8 283.9,427.8 284.8,427.7 C 285.7,427.6 286.6,427.6 287.6,427.5 C 288.5,427.4 289.4,427.4 290.3,427.3 C 291.2,427.2 292.1,426.9 293.0,426.7 C 294.0,426.6 294.9,427.4 295.8,426.3 C 296.7,425.2 297.6,421.5 298.5,420.3 C 299.5,419.1 300.4,419.2 301.3,419.0 C 302.2,418.8 303.1,419.1 304.0,419.0 C 304.9,418.8 305.9,418.4 306.8,418.1 C 307.7,417.7 308.6,417.5 309.5,416.9 C 310.4,416.3 311.3,415.2 312.3,414.5 C 313.2,413.8 314.1,413.1 315.0,412.6 C 315.9,412.1 316.8,411.7 317.7,411.4 C 318.7,411.1 319.6,410.9 320.5,410.8 C 321.4,410.6 322.3,410.7 323.2,410.5 C 324.1,410.4 325.1,410.1 326.0,410.0 C 326.9,409.9 327.8,410.0 328.7,409.9 C 329.6,409.8 330.5,409.8 331.5,409.6 C 332.4,409.5 333.3,409.5 334.2,409.1 C 335.1,408.6 336.0,407.3 337.0,406.8 C 337.9,406.2 338.8,406.0 339.7,405.6 C 340.6,405.1 341.5,406.0 342.4,404.1 C 343.4,402.3 344.3,396.7 345.2,394.6 C 346.1,392.4 347.0,392.1 347.9,391.2 C 348.8,390.3 349.8,389.7 350.7,389.1 C 351.6,388.4 352.5,387.8 353.4,387.3 C 354.3,386.8 355.2,386.4 356.2,386.1 C 357.1,385.8 358.0,385.7 358.9,385.4 C 359.8,385.2 360.7,384.9 361.6,384.5 C 362.6,384.2 363.5,383.6 364.4,383.3 C 365.3,383.0 366.2,383.1 367.1,382.8 C 368.0,382.5 369.0,381.9 369.9,381.5 C 370.8,381.2 371.7,380.9 372.6,380.6 C 373.5,380.3 374.5,380.1 375.4,379.9 C 376.3,379.7 377.2,379.7 378.1,379.5 C 379.0,379.3 379.9,379.1 380.9,378.9 C 381.8,378.7 382.7,378.5 383.6,378.3 C 384.5,378.0 385.4,377.8 386.3,377.5 C 387.3,377.1 388.2,376.4 389.1,376.1 C 390.0,375.7 390.9,375.6 391.8,375.4 C 392.7,375.2 393.7,375.1 394.6,375.0 C 395.5,374.8 396.4,374.7 397.3,374.6 C 398.2,374.5 399.1,374.3 400.1,374.2 C 401.0,374.1 401.9,374.2 402.8,374.0 C 403.7,373.9 404.6,373.6 405.5,373.3 C 406.5,373.0 407.4,372.4 408.3,372.0 C 409.2,371.6 410.1,371.2 411.0,371.0 C 412.0,370.7 412.9,370.7 413.8,370.5 C 414.7,370.3 415.6,370.3 416.5,370.0 C 417.4,369.7 418.4,369.1 419.3,368.8 C 420.2,368.6 421.1,368.7 422.0,368.5 C 422.9,368.4 423.8,368.1 424.8,367.9 C 425.7,367.7 426.6,367.5 427.5,367.2 C 428.4,367.0 429.3,366.8 430.2,366.5 C 431.2,366.3 432.1,366.2 433.0,366.0 C 433.9,365.7 434.8,365.3 435.7,365.0 C 436.6,364.7 437.6,364.6 438.5,364.3 C 439.4,364.0 440.3,363.7 441.2,363.4 C 442.1,363.0 443.0,362.7 444.0,362.4 C 444.9,362.1 445.8,362.0 446.7,361.6 C 447.6,361.1 448.5,360.0 449.5,359.6 C 450.4,359.2 451.3,359.1 452.2,359.0 C 453.1,358.8 454.0,358.8 454.9,358.7 C 455.9,358.6 456.8,358.4 457.7,358.2 C 458.6,358.1 459.5,358.4 460.4,357.9 C 461.3,357.5 462.3,356.2 463.2,355.7 C 464.1,355.2 465.0,355.3 465.9,355.0 C 466.8,354.7 467.7,354.1 468.7,353.9 C 469.6,353.7 470.5,353.8 471.4,353.6 C 472.3,353.4 473.2,352.7 474.1,352.5 C 475.1,352.3 476.0,352.3 476.9,352.2 C 477.8,352.1 478.7,352.1 479.6,351.8 C 480.5,351.5 481.5,351.2 482.4,350.6 C 483.3,350.0 484.2,348.8 485.1,348.2 C 486.0,347.5 487.0,347.3 487.9,346.8 C 488.8,346.4 489.7,346.3 490.6,345.4 C 491.5,344.6 492.4,342.6 493.4,341.7 C 494.3,340.7 495.2,340.3 496.1,339.7 C 497.0,339.2 497.9,339.1 498.8,338.2 C 499.8,337.4 500.7,335.5 501.6,334.6 C 502.5,333.8 503.4,333.7 504.3,333.2 C 505.2Line truncated
<circle cx="765.0" cy="100.2" r="5" fill="#dd4528" stroke="#0d1117" stroke-width="2" filter="url(#xkcdify)"/>
<text x="753.0" y="77.0" fill="#dd4528" font-size="18" font-family="Handlee, cursive" font-weight="bold" text-anchor="end">5683</text>
<a href="https://github.com/carsteneu/mystarhistory" target="_blank" rel="noopener"><text x="763" y="527" font-size="13" font-family="Handlee, cursive" fill="#fff" text-anchor="end">Made with carsteneu/mystarhistory</text></a>
</svg>

Before

Width:  |  Height:  |  Size: 32 KiB

After

Width:  |  Height:  |  Size: 33 KiB

+12 -11
View File
@@ -29,19 +29,20 @@
<text x="85" y="85.0" fill="#000" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="end">6K</text>
<text x="43" y="271.5" fill="#000" font-size="17" font-family="Handlee, cursive" font-weight="bold" transform="rotate(-90, 43, 271.5)">GitHub Stars</text>
<text x="90.0" y="488" fill="#000" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Jan 2026</text>
<text x="171.9" y="488" fill="#000" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Feb 2026</text>
<text x="251.0" y="488" fill="#000" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Mar 2026</text>
<text x="338.5" y="488" fill="#000" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Apr 2026</text>
<text x="423.3" y="488" fill="#000" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">May 2026</text>
<text x="510.8" y="488" fill="#000" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Jun 2026</text>
<text x="595.5" y="488" fill="#000" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Jul 2026</text>
<text x="683.1" y="488" fill="#000" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Aug 2026</text>
<text x="169.6" y="488" fill="#000" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Feb 2026</text>
<text x="246.4" y="488" fill="#000" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Mar 2026</text>
<text x="331.5" y="488" fill="#000" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Apr 2026</text>
<text x="413.8" y="488" fill="#000" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">May 2026</text>
<text x="498.8" y="488" fill="#000" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Jun 2026</text>
<text x="581.2" y="488" fill="#000" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Jul 2026</text>
<text x="666.2" y="488" fill="#000" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Aug 2026</text>
<text x="751.3" y="488" fill="#000" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Sep 2026</text>
<text x="50%" y="525" fill="#000" font-size="17" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Date</text>
<line x1="90" y1="80" x2="90" y2="463" stroke="#222" stroke-width="2.5" filter="url(#xkcdify)"/>
<line x1="90" y1="463" x2="765" y2="463" stroke="#222" stroke-width="2.5" filter="url(#xkcdify)"/>
<path d="M 90.0,462.0 C 90.5,462.0 92.4,461.6 92.8,461.5 C 93.8,461.4 94.7,461.1 95.6,461.0 C 96.6,460.8 97.5,460.5 98.5,460.4 C 99.4,460.3 100.4,460.4 101.3,460.3 C 102.2,460.3 103.2,460.2 104.1,460.2 C 105.1,460.1 106.0,460.1 106.9,460.0 C 107.9,459.9 108.4,459.9 109.8,459.8 C 111.2,459.7 114.0,459.7 115.4,459.6 C 116.8,459.6 117.3,459.5 118.2,459.5 C 119.2,459.5 120.1,459.4 121.1,459.4 C 122.0,459.4 122.9,459.4 123.9,459.4 C 124.8,459.3 125.8,459.3 126.7,459.3 C 127.7,459.3 128.6,459.2 129.5,459.1 C 130.5,459.0 131.4,459.0 132.4,458.9 C 133.3,458.7 134.2,458.4 135.2,458.0 C 136.1,457.5 137.1,456.7 138.0,456.4 C 139.0,456.1 139.9,456.2 140.8,456.2 C 141.8,456.1 142.7,456.1 143.7,456.0 C 144.6,456.0 145.1,455.9 146.5,455.9 C 147.9,455.9 150.3,455.9 152.1,455.9 C 154.0,455.8 156.4,455.8 157.8,455.7 C 159.2,455.7 159.7,455.6 160.6,455.6 C 161.5,455.6 162.5,455.5 163.4,455.5 C 164.4,455.4 165.3,455.4 166.3,455.3 C 167.2,455.3 168.1,455.3 169.1,455.3 C 170.0,455.3 170.5,455.2 171.9,455.2 C 173.3,455.2 175.2,455.1 177.6,455.1 C 179.9,455.1 183.7,455.0 186.0,455.0 C 188.4,455.0 188.8,455.0 191.7,455.0 C 194.5,454.9 200.6,454.8 203.0,454.7 C 205.3,454.6 204.4,454.7 205.8,454.6 C 207.2,454.6 209.1,454.6 211.4,454.6 C 213.8,454.5 215.2,454.5 219.9,454.4 C 224.6,454.4 235.9,454.3 239.7,454.3 C 243.5,454.2 241.6,454.2 242.5,454.2 C 243.5,454.2 243.9,454.9 245.3,454.1 C 246.7,453.3 249.6,452.6 251.0,449.3 C 252.4,445.9 252.9,436.9 253.8,434.1 C 254.7,431.3 255.7,432.9 256.6,432.5 C 257.6,432.0 258.5,431.8 259.5,431.5 C 260.4,431.2 261.3,430.9 262.3,430.8 C 263.2,430.6 264.2,430.5 265.1,430.4 C 266.0,430.3 267.0,430.1 267.9,430.0 C 268.9,429.9 269.8,430.0 270.8,429.9 C 271.7,429.8 272.6,429.6 273.6,429.5 C 274.5,429.4 275.5,429.4 276.4,429.3 C 277.3,429.1 278.3,428.7 279.2,428.6 C 280.2,428.4 281.1,428.5 282.1,428.4 C 283.0,428.3 283.9,428.0 284.9,428.0 C 285.8,427.9 286.8,427.9 287.7,427.9 C 288.6,427.8 289.6,427.8 290.5,427.7 C 291.5,427.6 292.4,427.6 293.3,427.5 C 294.3,427.4 295.2,427.4 296.2,427.3 C 297.1,427.2 298.1,426.9 299.0,426.7 C 299.9,426.6 300.9,427.4 301.8,426.3 C 302.8,425.2 303.7,421.5 304.6,420.3 C 305.6,419.1 306.5,419.2 307.5,419.0 C 308.4,418.8 309.4,419.1 310.3,419.0 C 311.2,418.8 312.2,418.4 313.1,418.1 C 314.1,417.7 315.0,417.5 315.9,416.9 C 316.9,416.3 317.8,415.2 318.8,414.5 C 319.7,413.8 320.6,413.1 321.6,412.6 C 322.5,412.1 323.5,411.7 324.4,411.4 C 325.4,411.1 326.3,410.9 327.2,410.8 C 328.2,410.6 329.1,410.7 330.1,410.5 C 331.0,410.4 331.9,410.1 332.9,410.0 C 333.8,409.9 334.8,410.0 335.7,409.9 C 336.7,409.8 337.6,409.8 338.5,409.6 C 339.5,409.5 340.4,409.5 341.4,409.1 C 342.3,408.6 343.2,407.3 344.2,406.8 C 345.1,406.2 346.1,406.0 347.0,405.6 C 347.9,405.1 348.9,406.0 349.8,404.1 C 350.8,402.3 351.7,396.7 352.7,394.6 C 353.6,392.4 354.5,392.1 355.5,391.2 C 356.4,390.3 357.4,389.7 358.3,389.1 C 359.2,388.4 360.2,387.8 361.1,387.3 C 362.1,386.8 363.0,386.4 364.0,386.1 C 364.9,385.8 365.8,385.7 366.8,385.4 C 367.7,385.2 368.7,384.9 369.6,384.5 C 370.5,384.2 371.5,383.6 372.4,383.3 C 373.4,383.0 374.3,383.1 375.3,382.8 C 376.2,382.5 377.1,381.9 378.1,381.5 C 379.0,381.2 380.0,380.9 380.9,380.6 C 381.8,380.3 382.8,380.1 383.7,379.9 C 384.7,379.7 385.6,379.7 386.5,379.5 C 387.5,379.3 388.4,379.1 389.4,378.9 C 390.3,378.7 391.3,378.5 392.2,378.3 C 393.1,378.0 394.1,377.8 395.0,377.5 C 396.0,377.1 396.9,376.4 397.8,376.1 C 398.8,375.7 399.7,375.6 400.7,375.4 C 401.6,375.2 402.6,375.1 403.5,375.0 C 404.4,374.8 405.4,374.7 406.3,374.6 C 407.3,374.5 408.2,374.3 409.1,374.2 C 410.1,374.1 411.0,374.2 412.0,374.0 C 412.9,373.9 413.8,373.6 414.8,373.3 C 415.7,373.0 416.7,372.4 417.6,372.0 C 418.6,371.6 419.5,371.2 420.4,371.0 C 421.4,370.7 422.3,370.7 423.3,370.5 C 424.2,370.3 425.1,370.3 426.1,370.0 C 427.0,369.7 428.0,369.1 428.9,368.8 C 429.9,368.6 430.8,368.7 431.7,368.5 C 432.7,368.4 433.6,368.1 434.6,367.9 C 435.5,367.7 436.4,367.5 437.4,367.2 C 438.3,367.0 439.3,366.8 440.2,366.5 C 441.2,366.3 442.1,366.2 443.0,366.0 C 444.0,365.7 444.9,365.3 445.9,365.0 C 446.8,364.7 447.7,364.6 448.7,364.3 C 449.6,364.0 450.6,363.7 451.5,363.4 C 452.4,363.0 453.4,362.7 454.3,362.4 C 455.3,362.1 456.2,362.0 457.2,361.6 C 458.1,361.1 459.0,360.0 460.0,359.6 C 460.9,359.2 461.9,359.1 462.8,359.0 C 463.7,358.8 464.7,358.8 465.6,358.7 C 466.6,358.6 467.5,358.4 468.5,358.2 C 469.4,358.1 470.3,358.4 471.3,357.9 C 472.2,357.5 473.2,356.2 474.1,355.7 C 475.0,355.2 476.0,355.3 476.9,355.0 C 477.9,354.7 478.8,354.1 479.7,353.9 C 480.7,353.7 481.6,353.8 482.6,353.6 C 483.5,353.4 484.5,352.7 485.4,352.5 C 486.3,352.3 487.3,352.3 488.2,352.2 C 489.2,352.1 490.1,352.1 491.0,351.8 C 492.0,351.5 492.9,351.2 493.9,350.6 C 494.8,350.0 495.8,348.8 496.7,348.2 C 497.6,347.5 498.6,347.3 499.5,346.8 C 500.5,346.4 501.4,346.3 502.3,345.4 C 503.3,344.6 504.2,342.6 505.2,341.7 C 506.1,340.7 507.1,340.3 508.0,339.7 C 508.9,339.2 509.9,339.1 510.8,338.2 C 511.8,337.4 512.7,335.5 513.6,334.6 C 514.6,333.8 515.5,333.7 516.5,333.2 C 517.4Line truncated
<path d="M 90.0,462.0 C 90.5,462.0 92.4,461.6 92.8,461.5 C 93.8,461.4 94.7,461.1 95.6,461.0 C 96.6,460.8 97.5,460.5 98.5,460.4 C 99.4,460.3 100.4,460.4 101.3,460.3 C 102.2,460.3 103.2,460.2 104.1,460.2 C 105.1,460.1 106.0,460.1 106.9,460.0 C 107.9,459.9 108.4,459.9 109.8,459.8 C 111.2,459.7 114.0,459.7 115.4,459.6 C 116.8,459.6 117.3,459.5 118.2,459.5 C 119.2,459.5 120.1,459.4 121.1,459.4 C 122.0,459.4 122.9,459.4 123.9,459.4 C 124.8,459.3 125.8,459.3 126.7,459.3 C 127.7,459.3 128.6,459.2 129.5,459.1 C 130.5,459.0 131.4,459.0 132.4,458.9 C 133.3,458.7 134.2,458.4 135.2,458.0 C 136.1,457.5 137.1,456.7 138.0,456.4 C 139.0,456.1 139.9,456.2 140.8,456.2 C 141.8,456.1 142.7,456.1 143.7,456.0 C 144.6,456.0 145.1,455.9 146.5,455.9 C 147.9,455.9 150.3,455.9 152.1,455.9 C 154.0,455.8 156.4,455.8 157.8,455.7 C 159.2,455.7 159.7,455.6 160.6,455.6 C 161.5,455.6 162.5,455.5 163.4,455.5 C 164.4,455.4 165.3,455.4 166.3,455.3 C 167.2,455.3 168.1,455.3 169.1,455.3 C 170.0,455.3 170.5,455.2 171.9,455.2 C 173.3,455.2 175.2,455.1 177.6,455.1 C 179.9,455.1 183.7,455.0 186.0,455.0 C 188.4,455.0 188.8,455.0 191.7,455.0 C 194.5,454.9 200.6,454.8 203.0,454.7 C 205.3,454.6 204.4,454.7 205.8,454.6 C 207.2,454.6 209.1,454.6 211.4,454.6 C 213.8,454.5 215.2,454.5 219.9,454.4 C 224.6,454.4 235.9,454.3 239.7,454.3 C 243.5,454.2 241.6,454.2 242.5,454.2 C 243.5,454.2 243.9,454.9 245.3,454.1 C 246.7,453.3 249.6,452.6 251.0,449.3 C 252.4,445.9 252.9,436.9 253.8,434.1 C 254.7,431.3 255.7,432.9 256.6,432.5 C 257.6,432.0 258.5,431.8 259.5,431.5 C 260.4,431.2 261.3,430.9 262.3,430.8 C 263.2,430.6 264.2,430.5 265.1,430.4 C 266.0,430.3 267.0,430.1 267.9,430.0 C 268.9,429.9 269.8,430.0 270.8,429.9 C 271.7,429.8 272.6,429.6 273.6,429.5 C 274.5,429.4 275.5,429.4 276.4,429.3 C 277.3,429.1 278.3,428.7 279.2,428.6 C 280.2,428.4 281.1,428.5 282.1,428.4 C 283.0,428.3 283.9,428.0 284.9,428.0 C 285.8,427.9 286.8,427.9 287.7,427.9 C 288.6,427.8 289.6,427.8 290.5,427.7 C 291.5,427.6 292.4,427.6 293.3,427.5 C 294.3,427.4 295.2,427.4 296.2,427.3 C 297.1,427.2 298.1,426.9 299.0,426.7 C 299.9,426.6 300.9,427.4 301.8,426.3 C 302.8,425.2 303.7,421.5 304.6,420.3 C 305.6,419.1 306.5,419.2 307.5,419.0 C 308.4,418.8 309.4,419.1 310.3,419.0 C 311.2,418.8 312.2,418.4 313.1,418.1 C 314.1,417.7 315.0,417.5 315.9,416.9 C 316.9,416.3 317.8,415.2 318.8,414.5 C 319.7,413.8 320.6,413.1 321.6,412.6 C 322.5,412.1 323.5,411.7 324.4,411.4 C 325.4,411.1 326.3,410.9 327.2,410.8 C 328.2,410.6 329.1,410.7 330.1,410.5 C 331.0,410.4 331.9,410.1 332.9,410.0 C 333.8,409.9 334.8,410.0 335.7,409.9 C 336.7,409.8 337.6,409.8 338.5,409.6 C 339.5,409.5 340.4,409.5 341.4,409.1 C 342.3,408.6 343.2,407.3 344.2,406.8 C 345.1,406.2 346.1,406.0 347.0,405.6 C 347.9,405.1 348.9,406.0 349.8,404.1 C 350.8,402.3 351.7,396.7 352.7,394.6 C 353.6,392.4 354.5,392.1 355.5,391.2 C 356.4,390.3 357.4,389.7 358.3,389.1 C 359.2,388.4 360.2,387.8 361.1,387.3 C 362.1,386.8 363.0,386.4 364.0,386.1 C 364.9,385.8 365.8,385.7 366.8,385.4 C 367.7,385.2 368.7,384.9 369.6,384.5 C 370.5,384.2 371.5,383.6 372.4,383.3 C 373.4,383.0 374.3,383.1 375.3,382.8 C 376.2,382.5 377.1,381.9 378.1,381.5 C 379.0,381.2 380.0,380.9 380.9,380.6 C 381.8,380.3 382.8,380.1 383.7,379.9 C 384.7,379.7 385.6,379.7 386.5,379.5 C 387.5,379.3 388.4,379.1 389.4,378.9 C 390.3,378.7 391.3,378.5 392.2,378.3 C 393.1,378.0 394.1,377.8 395.0,377.5 C 396.0,377.1 396.9,376.4 397.8,376.1 C 398.8,375.7 399.7,375.6 400.7,375.4 C 401.6,375.2 402.6,375.1 403.5,375.0 C 404.4,374.8 405.4,374.7 406.3,374.6 C 407.3,374.5 408.2,374.3 409.1,374.2 C 410.1,374.1 411.0,374.2 412.0,374.0 C 412.9,373.9 413.8,373.6 414.8,373.3 C 415.7,373.0 416.7,372.4 417.6,372.0 C 418.6,371.6 419.5,371.2 420.4,371.0 C 421.4,370.7 422.3,370.7 423.3,370.5 C 424.2,370.3 425.1,370.3 426.1,370.0 C 427.0,369.7 428.0,369.1 428.9,368.8 C 429.9,368.6 430.8,368.7 431.7,368.5 C 432.7,368.4 433.6,368.1 434.6,367.9 C 435.5,367.7 436.4,367.5 437.4,367.2 C 438.3,367.0 439.3,366.8 440.2,366.5 C 441.2,366.3 442.1,366.2 443.0,366.0 C 444.0,365.7 444.9,365.3 445.9,365.0 C 446.8,364.7 447.7,364.6 448.7,364.3 C 449.6,364.0 450.6,363.7 451.5,363.4 C 452.4,363.0 453.4,362.7 454.3,362.4 C 455.3,362.1 456.2,362.0 457.2,361.6 C 458.1,361.1 459.0,360.0 460.0,359.6 C 460.9,359.2 461.9,359.1 462.8,359.0 C 463.7,358.8 464.7,358.8 465.6,358.7 C 466.6,358.6 467.5,358.4 468.5,358.2 C 469.4,358.1 470.3,358.4 471.3,357.9 C 472.2,357.5 473.2,356.2 474.1,355.7 C 475.0,355.2 476.0,355.3 476.9,355.0 C 477.9,354.7 478.8,354.1 479.7,353.9 C 480.7,353.7 481.6,353.8 482.6,353.6 C 483.5,353.4 484.5,352.7 485.4,352.5 C 486.3,352.3 487.3,352.3 488.2,352.2 C 489.2,352.1 490.1,352.1 491.0,351.8 C 492.0,351.5 492.9,351.2 493.9,350.6 C 494.8,350.0 495.8,348.8 496.7,348.2 C 497.6,347.5 498.6,347.3 499.5,346.8 C 500.5,346.4 501.4,346.3 502.3,345.4 C 503.3,344.6 504.2,342.6 505.2,341.7 C 506.1,340.7 507.1,340.3 508.0,339.7 C 508.9,339.2 509.9,339.1 510.8,338.2 C 511.8,337.4 512.7,335.5 513.6,334.6 C 514.6,333.8 515.5,333.7 516.5,333.2 C 517.4Line truncated
<circle cx="765.0" cy="111.8" r="5" fill="#dd4528" stroke="#fff" stroke-width="2" filter="url(#xkcdify)"/>
<text x="753.0" y="77.0" fill="#dd4528" font-size="18" font-family="Handlee, cursive" font-weight="bold" text-anchor="end">5502</text>
<path d="M 90.0,462.0 C 90.5,462.0 92.3,461.6 92.7,461.5 C 93.7,461.4 94.6,461.1 95.5,461.0 C 96.4,460.8 97.3,460.5 98.2,460.4 C 99.1,460.3 100.1,460.4 101.0,460.3 C 101.9,460.3 102.8,460.2 103.7,460.2 C 104.6,460.1 105.5,460.1 106.5,460.0 C 107.4,459.9 107.8,459.9 109.2,459.8 C 110.6,459.7 113.3,459.7 114.7,459.6 C 116.1,459.6 116.5,459.5 117.4,459.5 C 118.4,459.5 119.3,459.4 120.2,459.4 C 121.1,459.4 122.0,459.4 122.9,459.4 C 123.8,459.3 124.8,459.3 125.7,459.3 C 126.6,459.3 127.5,459.2 128.4,459.1 C 129.3,459.0 130.2,459.0 131.2,458.9 C 132.1,458.7 133.0,458.4 133.9,458.0 C 134.8,457.5 135.7,456.7 136.6,456.4 C 137.6,456.1 138.5,456.2 139.4,456.2 C 140.3,456.1 141.2,456.1 142.1,456.0 C 143.0,456.0 143.5,455.9 144.9,455.9 C 146.2,455.9 148.5,455.9 150.4,455.9 C 152.2,455.8 154.5,455.8 155.9,455.7 C 157.2,455.7 157.7,455.6 158.6,455.6 C 159.5,455.6 160.4,455.5 161.3,455.5 C 162.3,455.4 163.2,455.4 164.1,455.3 C 165.0,455.3 165.9,455.3 166.8,455.3 C 167.7,455.3 168.2,455.2 169.6,455.2 C 170.9,455.2 172.8,455.1 175.1,455.1 C 177.3,455.1 181.0,455.0 183.3,455.0 C 185.6,455.0 186.0,455.0 188.8,455.0 C 191.5,454.9 197.5,454.8 199.8,454.7 C 202.0,454.6 201.1,454.7 202.5,454.6 C 203.9,454.6 205.7,454.6 208.0,454.6 C 210.3,454.5 211.6,454.5 216.2,454.4 C 220.8,454.4 231.8,454.3 235.4,454.3 C 239.1,454.2 237.3,454.2 238.2,454.2 C 239.1,454.2 239.5,454.9 240.9,454.1 C 242.3,453.3 245.0,452.6 246.4,449.3 C 247.8,445.9 248.2,436.9 249.1,434.1 C 250.1,431.3 251.0,432.9 251.9,432.5 C 252.8,432.0 253.7,431.8 254.6,431.5 C 255.5,431.2 256.5,430.9 257.4,430.8 C 258.3,430.6 259.2,430.5 260.1,430.4 C 261.0,430.3 262.0,430.1 262.9,430.0 C 263.8,429.9 264.7,430.0 265.6,429.9 C 266.5,429.8 267.4,429.6 268.4,429.5 C 269.3,429.4 270.2,429.4 271.1,429.3 C 272.0,429.1 272.9,428.7 273.8,428.6 C 274.8,428.4 275.7,428.5 276.6,428.4 C 277.5,428.3 278.4,428.0 279.3,428.0 C 280.2,427.9 281.2,427.9 282.1,427.9 C 283.0,427.8 283.9,427.8 284.8,427.7 C 285.7,427.6 286.6,427.6 287.6,427.5 C 288.5,427.4 289.4,427.4 290.3,427.3 C 291.2,427.2 292.1,426.9 293.0,426.7 C 294.0,426.6 294.9,427.4 295.8,426.3 C 296.7,425.2 297.6,421.5 298.5,420.3 C 299.5,419.1 300.4,419.2 301.3,419.0 C 302.2,418.8 303.1,419.1 304.0,419.0 C 304.9,418.8 305.9,418.4 306.8,418.1 C 307.7,417.7 308.6,417.5 309.5,416.9 C 310.4,416.3 311.3,415.2 312.3,414.5 C 313.2,413.8 314.1,413.1 315.0,412.6 C 315.9,412.1 316.8,411.7 317.7,411.4 C 318.7,411.1 319.6,410.9 320.5,410.8 C 321.4,410.6 322.3,410.7 323.2,410.5 C 324.1,410.4 325.1,410.1 326.0,410.0 C 326.9,409.9 327.8,410.0 328.7,409.9 C 329.6,409.8 330.5,409.8 331.5,409.6 C 332.4,409.5 333.3,409.5 334.2,409.1 C 335.1,408.6 336.0,407.3 337.0,406.8 C 337.9,406.2 338.8,406.0 339.7,405.6 C 340.6,405.1 341.5,406.0 342.4,404.1 C 343.4,402.3 344.3,396.7 345.2,394.6 C 346.1,392.4 347.0,392.1 347.9,391.2 C 348.8,390.3 349.8,389.7 350.7,389.1 C 351.6,388.4 352.5,387.8 353.4,387.3 C 354.3,386.8 355.2,386.4 356.2,386.1 C 357.1,385.8 358.0,385.7 358.9,385.4 C 359.8,385.2 360.7,384.9 361.6,384.5 C 362.6,384.2 363.5,383.6 364.4,383.3 C 365.3,383.0 366.2,383.1 367.1,382.8 C 368.0,382.5 369.0,381.9 369.9,381.5 C 370.8,381.2 371.7,380.9 372.6,380.6 C 373.5,380.3 374.5,380.1 375.4,379.9 C 376.3,379.7 377.2,379.7 378.1,379.5 C 379.0,379.3 379.9,379.1 380.9,378.9 C 381.8,378.7 382.7,378.5 383.6,378.3 C 384.5,378.0 385.4,377.8 386.3,377.5 C 387.3,377.1 388.2,376.4 389.1,376.1 C 390.0,375.7 390.9,375.6 391.8,375.4 C 392.7,375.2 393.7,375.1 394.6,375.0 C 395.5,374.8 396.4,374.7 397.3,374.6 C 398.2,374.5 399.1,374.3 400.1,374.2 C 401.0,374.1 401.9,374.2 402.8,374.0 C 403.7,373.9 404.6,373.6 405.5,373.3 C 406.5,373.0 407.4,372.4 408.3,372.0 C 409.2,371.6 410.1,371.2 411.0,371.0 C 412.0,370.7 412.9,370.7 413.8,370.5 C 414.7,370.3 415.6,370.3 416.5,370.0 C 417.4,369.7 418.4,369.1 419.3,368.8 C 420.2,368.6 421.1,368.7 422.0,368.5 C 422.9,368.4 423.8,368.1 424.8,367.9 C 425.7,367.7 426.6,367.5 427.5,367.2 C 428.4,367.0 429.3,366.8 430.2,366.5 C 431.2,366.3 432.1,366.2 433.0,366.0 C 433.9,365.7 434.8,365.3 435.7,365.0 C 436.6,364.7 437.6,364.6 438.5,364.3 C 439.4,364.0 440.3,363.7 441.2,363.4 C 442.1,363.0 443.0,362.7 444.0,362.4 C 444.9,362.1 445.8,362.0 446.7,361.6 C 447.6,361.1 448.5,360.0 449.5,359.6 C 450.4,359.2 451.3,359.1 452.2,359.0 C 453.1,358.8 454.0,358.8 454.9,358.7 C 455.9,358.6 456.8,358.4 457.7,358.2 C 458.6,358.1 459.5,358.4 460.4,357.9 C 461.3,357.5 462.3,356.2 463.2,355.7 C 464.1,355.2 465.0,355.3 465.9,355.0 C 466.8,354.7 467.7,354.1 468.7,353.9 C 469.6,353.7 470.5,353.8 471.4,353.6 C 472.3,353.4 473.2,352.7 474.1,352.5 C 475.1,352.3 476.0,352.3 476.9,352.2 C 477.8,352.1 478.7,352.1 479.6,351.8 C 480.5,351.5 481.5,351.2 482.4,350.6 C 483.3,350.0 484.2,348.8 485.1,348.2 C 486.0,347.5 487.0,347.3 487.9,346.8 C 488.8,346.4 489.7,346.3 490.6,345.4 C 491.5,344.6 492.4,342.6 493.4,341.7 C 494.3,340.7 495.2,340.3 496.1,339.7 C 497.0,339.2 497.9,339.1 498.8,338.2 C 499.8,337.4 500.7,335.5 501.6,334.6 C 502.5,333.8 503.4,333.7 504.3,333.2 C 505.2Line truncated
<path d="M 90.0,462.0 C 90.5,462.0 92.3,461.6 92.7,461.5 C 93.7,461.4 94.6,461.1 95.5,461.0 C 96.4,460.8 97.3,460.5 98.2,460.4 C 99.1,460.3 100.1,460.4 101.0,460.3 C 101.9,460.3 102.8,460.2 103.7,460.2 C 104.6,460.1 105.5,460.1 106.5,460.0 C 107.4,459.9 107.8,459.9 109.2,459.8 C 110.6,459.7 113.3,459.7 114.7,459.6 C 116.1,459.6 116.5,459.5 117.4,459.5 C 118.4,459.5 119.3,459.4 120.2,459.4 C 121.1,459.4 122.0,459.4 122.9,459.4 C 123.8,459.3 124.8,459.3 125.7,459.3 C 126.6,459.3 127.5,459.2 128.4,459.1 C 129.3,459.0 130.2,459.0 131.2,458.9 C 132.1,458.7 133.0,458.4 133.9,458.0 C 134.8,457.5 135.7,456.7 136.6,456.4 C 137.6,456.1 138.5,456.2 139.4,456.2 C 140.3,456.1 141.2,456.1 142.1,456.0 C 143.0,456.0 143.5,455.9 144.9,455.9 C 146.2,455.9 148.5,455.9 150.4,455.9 C 152.2,455.8 154.5,455.8 155.9,455.7 C 157.2,455.7 157.7,455.6 158.6,455.6 C 159.5,455.6 160.4,455.5 161.3,455.5 C 162.3,455.4 163.2,455.4 164.1,455.3 C 165.0,455.3 165.9,455.3 166.8,455.3 C 167.7,455.3 168.2,455.2 169.6,455.2 C 170.9,455.2 172.8,455.1 175.1,455.1 C 177.3,455.1 181.0,455.0 183.3,455.0 C 185.6,455.0 186.0,455.0 188.8,455.0 C 191.5,454.9 197.5,454.8 199.8,454.7 C 202.0,454.6 201.1,454.7 202.5,454.6 C 203.9,454.6 205.7,454.6 208.0,454.6 C 210.3,454.5 211.6,454.5 216.2,454.4 C 220.8,454.4 231.8,454.3 235.4,454.3 C 239.1,454.2 237.3,454.2 238.2,454.2 C 239.1,454.2 239.5,454.9 240.9,454.1 C 242.3,453.3 245.0,452.6 246.4,449.3 C 247.8,445.9 248.2,436.9 249.1,434.1 C 250.1,431.3 251.0,432.9 251.9,432.5 C 252.8,432.0 253.7,431.8 254.6,431.5 C 255.5,431.2 256.5,430.9 257.4,430.8 C 258.3,430.6 259.2,430.5 260.1,430.4 C 261.0,430.3 262.0,430.1 262.9,430.0 C 263.8,429.9 264.7,430.0 265.6,429.9 C 266.5,429.8 267.4,429.6 268.4,429.5 C 269.3,429.4 270.2,429.4 271.1,429.3 C 272.0,429.1 272.9,428.7 273.8,428.6 C 274.8,428.4 275.7,428.5 276.6,428.4 C 277.5,428.3 278.4,428.0 279.3,428.0 C 280.2,427.9 281.2,427.9 282.1,427.9 C 283.0,427.8 283.9,427.8 284.8,427.7 C 285.7,427.6 286.6,427.6 287.6,427.5 C 288.5,427.4 289.4,427.4 290.3,427.3 C 291.2,427.2 292.1,426.9 293.0,426.7 C 294.0,426.6 294.9,427.4 295.8,426.3 C 296.7,425.2 297.6,421.5 298.5,420.3 C 299.5,419.1 300.4,419.2 301.3,419.0 C 302.2,418.8 303.1,419.1 304.0,419.0 C 304.9,418.8 305.9,418.4 306.8,418.1 C 307.7,417.7 308.6,417.5 309.5,416.9 C 310.4,416.3 311.3,415.2 312.3,414.5 C 313.2,413.8 314.1,413.1 315.0,412.6 C 315.9,412.1 316.8,411.7 317.7,411.4 C 318.7,411.1 319.6,410.9 320.5,410.8 C 321.4,410.6 322.3,410.7 323.2,410.5 C 324.1,410.4 325.1,410.1 326.0,410.0 C 326.9,409.9 327.8,410.0 328.7,409.9 C 329.6,409.8 330.5,409.8 331.5,409.6 C 332.4,409.5 333.3,409.5 334.2,409.1 C 335.1,408.6 336.0,407.3 337.0,406.8 C 337.9,406.2 338.8,406.0 339.7,405.6 C 340.6,405.1 341.5,406.0 342.4,404.1 C 343.4,402.3 344.3,396.7 345.2,394.6 C 346.1,392.4 347.0,392.1 347.9,391.2 C 348.8,390.3 349.8,389.7 350.7,389.1 C 351.6,388.4 352.5,387.8 353.4,387.3 C 354.3,386.8 355.2,386.4 356.2,386.1 C 357.1,385.8 358.0,385.7 358.9,385.4 C 359.8,385.2 360.7,384.9 361.6,384.5 C 362.6,384.2 363.5,383.6 364.4,383.3 C 365.3,383.0 366.2,383.1 367.1,382.8 C 368.0,382.5 369.0,381.9 369.9,381.5 C 370.8,381.2 371.7,380.9 372.6,380.6 C 373.5,380.3 374.5,380.1 375.4,379.9 C 376.3,379.7 377.2,379.7 378.1,379.5 C 379.0,379.3 379.9,379.1 380.9,378.9 C 381.8,378.7 382.7,378.5 383.6,378.3 C 384.5,378.0 385.4,377.8 386.3,377.5 C 387.3,377.1 388.2,376.4 389.1,376.1 C 390.0,375.7 390.9,375.6 391.8,375.4 C 392.7,375.2 393.7,375.1 394.6,375.0 C 395.5,374.8 396.4,374.7 397.3,374.6 C 398.2,374.5 399.1,374.3 400.1,374.2 C 401.0,374.1 401.9,374.2 402.8,374.0 C 403.7,373.9 404.6,373.6 405.5,373.3 C 406.5,373.0 407.4,372.4 408.3,372.0 C 409.2,371.6 410.1,371.2 411.0,371.0 C 412.0,370.7 412.9,370.7 413.8,370.5 C 414.7,370.3 415.6,370.3 416.5,370.0 C 417.4,369.7 418.4,369.1 419.3,368.8 C 420.2,368.6 421.1,368.7 422.0,368.5 C 422.9,368.4 423.8,368.1 424.8,367.9 C 425.7,367.7 426.6,367.5 427.5,367.2 C 428.4,367.0 429.3,366.8 430.2,366.5 C 431.2,366.3 432.1,366.2 433.0,366.0 C 433.9,365.7 434.8,365.3 435.7,365.0 C 436.6,364.7 437.6,364.6 438.5,364.3 C 439.4,364.0 440.3,363.7 441.2,363.4 C 442.1,363.0 443.0,362.7 444.0,362.4 C 444.9,362.1 445.8,362.0 446.7,361.6 C 447.6,361.1 448.5,360.0 449.5,359.6 C 450.4,359.2 451.3,359.1 452.2,359.0 C 453.1,358.8 454.0,358.8 454.9,358.7 C 455.9,358.6 456.8,358.4 457.7,358.2 C 458.6,358.1 459.5,358.4 460.4,357.9 C 461.3,357.5 462.3,356.2 463.2,355.7 C 464.1,355.2 465.0,355.3 465.9,355.0 C 466.8,354.7 467.7,354.1 468.7,353.9 C 469.6,353.7 470.5,353.8 471.4,353.6 C 472.3,353.4 473.2,352.7 474.1,352.5 C 475.1,352.3 476.0,352.3 476.9,352.2 C 477.8,352.1 478.7,352.1 479.6,351.8 C 480.5,351.5 481.5,351.2 482.4,350.6 C 483.3,350.0 484.2,348.8 485.1,348.2 C 486.0,347.5 487.0,347.3 487.9,346.8 C 488.8,346.4 489.7,346.3 490.6,345.4 C 491.5,344.6 492.4,342.6 493.4,341.7 C 494.3,340.7 495.2,340.3 496.1,339.7 C 497.0,339.2 497.9,339.1 498.8,338.2 C 499.8,337.4 500.7,335.5 501.6,334.6 C 502.5,333.8 503.4,333.7 504.3,333.2 C 505.2Line truncated
<circle cx="765.0" cy="100.2" r="5" fill="#dd4528" stroke="#fff" stroke-width="2" filter="url(#xkcdify)"/>
<text x="753.0" y="77.0" fill="#dd4528" font-size="18" font-family="Handlee, cursive" font-weight="bold" text-anchor="end">5683</text>
<a href="https://github.com/carsteneu/mystarhistory" target="_blank" rel="noopener"><text x="763" y="527" font-size="13" font-family="Handlee, cursive" fill="#000" text-anchor="end">Made with carsteneu/mystarhistory</text></a>
</svg>

Before

Width:  |  Height:  |  Size: 32 KiB

After

Width:  |  Height:  |  Size: 32 KiB

+24
View File
@@ -0,0 +1,24 @@
# Folder completion after same-ID ownership handoff
Both normal directory transfer and dedicated directory recovery can receive a
superseded stream result: another invocation now owns the same child transfer.
The old caller must wait for that owner rather than report premature completion.
Previously it polled the visible child row. Completed rows are compacted into
parent checkpoints, so a completion arriving before the superseded reply removes
the row first. Polling then waits forever; a stale panel row can mask this further.
Two regressions use the actual React directory hook / dedicated recovery entrypoint
and actual store compaction. Each records one completed file in the parent while
the transfer operation remains pending on the baseline.
The fix registers a bounded settlement observation before starting the stream.
The store captures terminal state for exact observed file identities before
history compaction. One shared helper waits for the actual owner in both paths,
then releases its observation on success, failure or cancellation. There is no
persistent per-file tombstone list and no inference that a missing row means
success. Reused IDs with different indexed file identities are not evidence.
This addresses a separately reproduced folder-never-settles condition relevant
to #2568 and #3155. It does not establish the original reporters' precise cause.
Large-history recovery follow-up: stream lifecycle events carry the current child hierarchy identity. Before dispatch, the store admits the explicit retry into the current row without a full history compaction, so a batched old failed row cannot reject the new completion. Admission distinguishes pause waiting, cancellation, identity conflict and exact prior completion; active lifecycle epochs and newer pause/cancel intent remain protected.
@@ -0,0 +1,43 @@
# Local transfer publication safety audit
## Confirmed defect
The completed download moves an existing destination to a backup, checks that
its pathname is unoccupied, then renames the prepared download into place.
Another program can save to that pathname between the check and rename. The
rename overwrites those bytes and successful cleanup deletes the original
backup. Backup restoration and post-publication rollback have equivalent races.
Real filesystem regressions inject concurrent creation at the actual final
publication and restoration boundary. Both lose concurrent contents on the
baseline. An explicitly absent destination can also appear after validation and
be incorrectly moved aside. These tests fail before the change.
## Contract and design
- Prepared data and an original backup remain private sibling files.
- Publication and backup restoration use the same no-overwrite primitive.
- On hardlink-capable filesystems, linking publishes complete bytes atomically.
- On filesystems without that operation, exclusive open plus writes through the
owned handle preserves compatibility without overwriting another destination.
This fallback exposes partial contents during copying; it does not promise
atomic visibility. It never deletes the destination pathname on failure.
- Failed fallback copies retain complete prepared data and original backup, with
their locations in the error. A concurrent replacement is never removed.
- Successful publication is the commit boundary. Cancellation checked before it
restores the original where possible. Cancellation arriving after publication
does not attempt unsafe pathname-based rollback.
- A validated absent target is distinct from an omitted validation callback.
- This change does not claim to prevent another program writing through an
already-open handle or provide durable power-loss transactions.
## Validation
The focused tests cover publication and restoration boundary races, absent
validation, normal mode-preserving replacement, early/late cancellation,
unsupported-hardlink fallback and write failure with concurrent replacement.
Real unsupported-filesystem hardware has not been exercised; the fallback uses
real files with hardlink capability failure injected.
Related audit themes: #3186 replacement attributes and #3213 interrupted recovery.
These reports do not establish the cause of this separately reproduced defect.
@@ -0,0 +1,41 @@
# SFTP restart recovery must not guess another server
Audit baseline: `7b964ead21c1e176999557147914f4975a63f5c8`.
## Confirmed defect
`resolveHostForTransferEndpoint` used a display-name/hostname search when a
recorded host id no longer existed, and returned the first match when multiple
hosts shared a name. Dedicated resume used those credentials to open the target
and start uploading. A saved task for a deleted server could therefore send
its source file to an unrelated same-named server. Both endpoint directions and
folder recovery use this resolver.
Two regressions invoke `resumeTransferWithDedicatedSession`, with the real
resolver, scheduler, credential construction, and resume path but instrumented
bridge I/O. Before the fix, both selected an unrelated endpoint and called
`startStreamTransfer`: a deleted recorded id with one same-named replacement,
and a legacy task without an id with two same-named hosts. Both now stop before
opening or uploading.
## Fix contract
- A recorded host id is authoritative; absence must not fall back to a name.
- Legacy tasks without an id may resolve a name only if exactly one host matches.
- Preserve exact-id recovery, unique-name legacy recovery, and a still-live
original session when vault credentials are absent.
- Never select an arbitrary member of an ambiguous name match.
This does not freeze future edits to the connection options of an existing host
id; a persisted immutable endpoint snapshot would be a separate schema and
migration change. It also does not claim to explain all historical restart
failures from issue #3213 or #2638.
## Verification
`node --test --import tsx application/state/sftp/dedicatedTransferResume.test.ts`:
39 passed, including both regressions and existing single-file/folder restart,
remote-to-remote, source-validation, and live-session fallback tests.
Broader SFTP audit remains in progress in the sibling `codex/sftp-transfer-audit`
worktree; its first control-ordering fix is PR #3284.
@@ -0,0 +1,20 @@
# Shared SSH connection cancellation audit
Cancelling a transfer while its isolated SFTP channel is opening calls the
bounded-open abort path. Previously that path ended/destroyed the physical SSH
transport. Production pooled SFTP sessions normally share a terminal SSH
connection, so cancelling one transfer can disconnect terminals, browsing and
other transfers. The same teardown happened on the channel-open deadline.
The real startTransfer/cancelTransfer path reproduces physical end/destroy calls;
its old test double lacked those methods and could not detect the problem.
The repair rejects the abandoned caller promptly, retains the shared transport,
closes a channel arriving after abandonment, and blocks further channel-open
allocation on that transport while any abandoned request remains unresolved.
This bounds retry accumulation without queuing controls behind a transfer.
Healthy parallel opens remain allowed; callback settlement or transport closure
releases the abandoned-open bookkeeping. Existing active channels remain usable.
This separately reproduced mechanism is relevant to the connection-loss themes
in #2973 and #2832, but does not establish the cause of their VPN/jump-host reports.
@@ -0,0 +1,248 @@
# SFTP transfer audit (September 2026)
Baseline: `7b964ead21c1e176999557147914f4975a63f5c8`.
Status: scoped audit completed, with five independently reproduced defects
fixed and submitted. This ledger distinguishes verified paths from reporter
environments unavailable to this audit.
## Completion requirements
Review the global transfer center, live and restored execution, admission and
connection ownership, pause/resume/cancel ordering, durable checkpoints, source
and destination integrity, folder replacement/traversal, history retention,
transfer responsiveness, and architectural duplication. Cross-reference actual
issue reports and mature open-source clients. Reproduce material findings before
fixing them. Submit verified fixes as PRs and retain evidence for unresolved
reporter-specific conditions.
## Confirmed finding: stale transfer control can defeat a newer pause
Priority: high. The user can pause a transfer and have an older request silently
restart it, or see the state return to transferring while the pause latch remains
set. File transfers and folder children share the affected control path.
Reproductions (each failed on the baseline before the implementation change):
- Two overlapping pauses: the first reply incorrectly compensates by calling
resume, even though the second pause is still intended.
- Resume reply delayed until after a newer pause: old success overwrites the row
and its lifecycle epoch.
- Dedicated single-file recovery held for the stream lifetime: its duplicated
soft-resume implementation independently has the same stale reply problem.
- Pipelined upload: resume waits for outstanding writes, receives a newer pause,
then resumes anyway when draining finishes.
- Worker reply fan-out: old successful resume, failed pause, or rejected pause
emits a new resumed event after a later pause succeeded.
Fix contract: the newest pause/cancel decision wins over older replies; no late
result can revive a terminal task. Keep checkpoints and source verification.
Do not serialize controls behind a stream lifetime. Reuse the shared soft-resume
path for held single-file recovery instead of maintaining another state writer.
Verification so far:
- Original selected SFTP suite: 747 passed.
- Initial fixes with the three new regressions: 750 passed.
- Store and control suite after dedicated-path consolidation: 107 passed.
- Worker fan-out suite including three out-of-order cases: 9 passed.
- Loopback SSH/SFTP: 12 files of 128 MiB, two file jobs concurrently, each starts
from a 32 MiB saved checkpoint and pauses/resumes during the remaining download.
All final SHA-256 digests match. Pause acknowledgements 6-15 ms, resume checks
1391-1493 ms. These are fixture measurements, not WAN throughput claims.
- Independent review found another held-run failure path: rejection or a dead
stream response followed by a newer pause during wind-down could start a fresh
resume. Two regressions reproduced it; shared rejection handling and epoch
checks after wind-down now pass (109 store/control tests).
- First full suite: 11377 passed, 7 plugin archive failures traced to missing
worktree-local nested dependency (yauzl 3.x). Reinstalled with npm ci; all 29
plugin CLI tests then pass. Full rerun passed: 11386 passed, 0 failed,
18 skipped.
- GitHub review found a cross-window gap: the obsolete worker result was a hard
failure to a renderer whose local control epoch had not changed. Explicit
superseded outcomes now bypass rollback, compensation and dedicated recovery,
including rejected worker requests. Updated focused suite: 122 passed.
- Production build and lint pass after that follow-up; two independent reviewers
found no actionable follow-up issues.
- Browser fixture exercised the actual transfer-center component/store with
simulated transport: pause all, resume all, individual pause, paused filter,
and cancel. State/buttons matched; no browser console errors. This is not a
full Electron connection-path test.
- Separate real loopback SSH/SFTP audit experiments killed the transferring child
process with SIGKILL and resumed the saved checkpoint in a fresh child process.
Both 32 MiB download and upload completed with matching SHA-256. Persisted
checkpoints were 30670848 and 14024704 bytes respectively. This validates the
transfer engine and disk staging across process death, not the renderer's
automatic history restoration or a real VPN/jump-host environment.
## Submitted fixes
| PR | Confirmed failure | Architectural change |
| --- | --- | --- |
| [3284](https://github.com/binaricat/Netcatty/pull/3284) | Late pause/resume replies revive a newer paused/cancelled task or overwrite its visible state, including cross-window and folder watcher paths | Shared held-file resume handling; obsolete controls carry the winning action to reconcile local barriers; compensation checks current intent rather than assuming every epoch change means resume. |
| [3285](https://github.com/binaricat/Netcatty/pull/3285) | Deleted recorded host or duplicate legacy display names can resume an upload against another saved server | Exact host-ID recovery; unique-match-only legacy resolution; preserve live-session recovery. |
| [3286](https://github.com/binaricat/Netcatty/pull/3286) | Publication/restoration can overwrite a concurrently saved local file; rollback can delete a replacement | One exclusive publication helper, a clear commit boundary, preserved recovery artifacts on conflicts or incomplete fallback copying. |
| [3287](https://github.com/binaricat/Netcatty/pull/3287) | Folder stays active after child completion was compacted out of visible history | One bounded settlement observer/helper for both live transfer and recovery; no persistent tombstone history. |
| [3288](https://github.com/binaricat/Netcatty/pull/3288) | Cancelling or timing out channel initialization disconnects shared SSH users | Channel cancellation is separated from shared-transport ownership; abandoned initialization is bounded until settlement. |
Additional engine experiments used actual loopback SSH/SFTP, killed the child
process, and resumed in a fresh process for remote-to-remote transfers. Both the
download phase and upload phase passed final 32 MiB SHA-256 comparison. The
upload-phase checkpoint was 16777216 bytes. These complement direct upload and
download recovery, not full-app history restoration or reporter confirmation.
## Historical issue evidence fetched in this audit
| Issue | Reported condition | Audit treatment |
| --- | --- | --- |
| [3213](https://github.com/binaricat/Netcatty/issues/3213) | macOS, 10+ files of 100-200 MB; pause/resume and force-quit recovery unreliable | Control ordering reproduced separately; source direction and server still absent from report. Do not claim reporter confirmation. |
| [3155](https://github.com/binaricat/Netcatty/issues/3155) | Windows, many-file transfer freezes; no count or logs | Recheck bounded discovery, scheduling, publication and history work. |
| [2973](https://github.com/binaricat/Netcatty/issues/2973) | VPN uploads disconnect SSH and SFTP; transfer spinner continues; later inode VPN report | Check transport loss and settlement. Network/security cause not established by the available logs. |
| [3186](https://github.com/binaricat/Netcatty/issues/3186) | Replacement changes permissions on 1.1.82 | Check mode/owner behavior on each replacement path; existing bot explanations are not proof. |
| [3149](https://github.com/binaricat/Netcatty/issues/3149) | Windows proxy + terminal drag-upload reports No such file | Check target pinning, path encoding, session and retry behavior. |
| [2832](https://github.com/binaricat/Netcatty/issues/2832) | Browsing works through VPN/jump host, transfers wait indefinitely, cancel works | Check dedicated connection admission/authentication/timeout. |
| [2568](https://github.com/binaricat/Netcatty/issues/2568) | Folder copy reaches 100% but remains active; pause ineffective | Check parent settlement and directory checkpoints. |
| [2458](https://github.com/binaricat/Netcatty/issues/2458) | Windows 1 GiB upload continues after Pause/Pause all from both terminal sidebar and SFTP tab | Motivates transport-plus-visible-state regressions; current delayed-control defects independently reproduced. |
| [3031](https://github.com/binaricat/Netcatty/issues/3031) | macOS jump-host/proxy drag upload: no such file | Missing full error, protocol, target path and direct-connect comparison prevent attribution. |
| [2556](https://github.com/binaricat/Netcatty/issues/2556) | Windows download of 1.9 GiB from local Linux VM; separate many-small-files progress complaint | Preserve verification correctness; distinguish network payload from verification and incremental discovery. No comparative reporter throughput available. |
| [2886](https://github.com/binaricat/Netcatty/issues/2886) | sudo terminal drop denied while SFTP upload succeeds | Existing terminal fallback fix is separate; contradictory bot explanations are not evidence of identity or permission correctness. |
| [2638](https://github.com/binaricat/Netcatty/issues/2638) | Recovery after network failure | Verify restore end to end; UI availability alone is insufficient. |
Issue state (open/closed) and automated comments do not substitute for runtime
proof. Initial title search hit its 100-result cap. Expanded SFTP search returned
308 issue matches, including reports without SFTP in the title. The reports
listed above were read as representative symptom clusters; this was not a claim
to have investigated all 308 matching issues individually.
## External reference points
- [Tabby SFTP implementation](https://github.com/Eugeny/tabby/blob/master/tabby-ssh/src/session/sftp.ts):
stream transfer and temporary upload destination before rename. Useful as a
separation-of-concerns comparison; this file does not establish durable
restart recovery and must not be treated as a complete replacement design.
- [WinSCP resume documentation](https://winscp.net/eng/docs/resume): partial-file
discovery and temporary filenames support interruption recovery; temporary
creation can be unavailable under some permission layouts.
- [Electerm transfer implementation](https://github.com/electerm/electerm/blob/master/src/app/server/transfer.js):
separates a per-transfer object from queue/UI state, opens a separate SFTP
channel on the existing SSH connection when available, and uses 32 KiB chunks
with 64 requests. Its live pause flag stops scheduling additional reads. Its
ordinary transfer opens the destination with `w`; it is not evidence for
durable checkpoint recovery. Retain Netcatty's staging and contiguous-offset
protections when simplifying ownership.
- [Electerm action store](https://github.com/electerm/electerm/blob/master/src/client/components/file-transfer/transports-action-store.jsx)
counts pending initializations toward admission; its
[mutation queue](https://github.com/electerm/electerm/blob/master/src/client/components/file-transfer/transfer-queue.jsx)
distinguishes completion of a state update from completion of transfer I/O.
This supports keeping control requests independent of long-lived transfer runs.
## Coverage and practical limits
| Area | Evidence and result |
| --- | --- |
| Transfer controls and global center | Actual component/store browser interactions; delayed replies, direct/worker, folder watcher and cross-window action regressions. PR 3284 fixes confirmed ordering failures, including root-state reconciliation and failed resume settlement. |
| Upload/download and remote-to-remote durability | Four real SSH/SFTP fresh-process recovery experiments, including both remote-to-remote phases, all compare final bytes by SHA-256. Existing transfer tests cover changed source, sparse ranges, cancellation and publication. |
| Local/SCP publication | Real filesystem conflict regressions and SCP abort tests; one shared no-overwrite publication helper. Copy fallback retains mode/timestamps and recovery files on failure. Actual FAT/exFAT hardware was unavailable. |
| Folder traversal and final state | Discovery concurrency, replacement/rollback, manifest, pause latch, skip/conflict and history tests; actual live/recovery entrypoints reproduce compacted-child hang and pass after PR 3287. |
| History, restart and ownership | Store history/large-manifest and dedicated recovery tests; PR 3285 rejects missing or ambiguous saved host identity. Full Electron quit/relaunch with restored user credentials was not exercised; engine-process recovery was. Editing endpoint details under an unchanged saved host ID remains a documented identity-model limitation. |
| Connection sharing and cleanup | Connection pool, lease and initialization tests; real delayed SFTP OPEN followed by cancellation leaves browsing alive and closes the late channel after PR 3288. VPN/MFA/security-product and original jump-host environments were unavailable. |
| Responsiveness | Actual browser controls, list virtualization, bounded directory discovery and 50k history cases; scheduler smoke figures below are diagnostic, not end-user throughput or Windows runtime proof. |
No additional data-loss defect was established by these checks. User reports of
VPN-specific hangs, proxy drag-upload path errors and Windows throughput/freezes
still require their original environments and discriminating logs. They are not
marked resolved solely because a related mechanism was repaired here.
## Validation summary
Each fix received two independent local reviews. Review-discovered gaps were
corrected and checked again. PR 3284 latest focused control/worker tests: 35 pass;
two actual direct-resume tests include the full undrained timeout and pass.
PR 3285 full suite: 11,379 pass, 18 skip. PR 3286 full suite before its metadata
follow-up: 11,386 pass, 18 skip; latest publication/abort tests: 34 pass.
PR 3287 serial full suite: 11,383 pass, 18 skip; production build passes.
PR 3288 real SSH cancellation experiment and nine channel tests pass; its full
suite has 11,380 passes and one unrelated terminal write-queue timing failure,
independently reproduced on the unchanged baseline. That entire test file passes
alone (81 tests). A separate integration worktree combines all five branches;
one test-only insertion conflict was resolved by retaining both regressions.
The folder test was subsequently relocated in its own PR; a fresh merge-tree
check confirms it combines with the other control tests without a conflict.
## Architectural assessment
The staged-file and contiguous-checkpoint design is worth retaining. A simpler
client that writes directly into the destination is not an equivalent safety
reference. The most important simplification is ownership: a long-lived stream
must not block its own control requests, and one authoritative lifecycle must
inform every window. PR 3284 removes a duplicate state-writing recovery path
and makes obsolete controls explicit.
Publication is a second useful module boundary. PR 3286 replaces repeated
check/rename/check/rollback branches with one no-overwrite operation shared by
publication and restoration. On hardlink-capable filesystems its commit is
atomic; the documented exclusive-copy fallback trades atomic visibility for
compatibility while preserving recoverable data and cancellation.
The current transfer list virtualizes beyond 20 visible tasks, directory listing
has a tree-wide concurrency gate, and history migration yields cooperatively.
A scheduler-only smoke workload of 1000 and 10000 immediately completing jobs
finished in 87 ms and 4362 ms, with maximum timer gaps of 15 ms and 111 ms during
other validation activity. The queue still scans for eligibility/priority/fairness;
this is a follow-up performance lead, not proof of the Windows freeze reports or
a standalone throughput benchmark. Folder fan-out bounds ordinary queue growth.
Do not weaken full saved-prefix verification merely to improve resume timings.
The loopback experiments include extra verification reads; those bytes are not
payload throughput. A future optimization needs proof that changed sources and
out-of-order durable ranges still cannot produce mixed file contents.
PR 3287 makes final transfer ownership independent of visible history retention:
observers capture terminal settlement before compaction and are disposed after
the waiting invocation exits. PR 3288 keeps per-channel cancellation from
claiming ownership of a shared SSH connection. These targeted boundaries reduce
duplicated policy without replacing the working durable-transfer engine.
Combined-engine live checks also passed: shared-connection cancellation retains
working browsing and closes the late channel; a 32 MiB download killed at a
16809984-byte checkpoint resumes in a new process to the expected SHA-256. An
initial 8 MiB fixture completed before an intermediate checkpoint was sampled;
the larger paced fixture supplies the intended process-death evidence.
## Final combined validation
The combined full suite passed: 11,439 passed, 0 failed, 18 skipped. Lint and
production build passed. This run includes all five fixes and the failed-child
admission follow-up. A final control-aggregation follow-up then corrected mixed
success/superseded resume outcomes and current IPC rejection handling; both
independent reviewers approved it. After bringing that follow-up and the
test-only relocation into the integration checkout, all 202 affected control,
store, folder, observation, recovery and worker tests passed. Final lint/build
are recorded in the PR descriptions. No production changes were made in the
integration checkout; the only merge edits preserve independent regression tests.
The initial combined run stopped after an older exact-result assertion rejected
the newly structured superseded response and left its fixture alive. The
assertion was updated, its fixture completed, and the successful full run above
supersedes that aborted run. Tests were not removed or weakened.
## Delivery follow-ups
The remote publication branch received an automated patch during final review.
It was fetched and checked rather than assuming earlier validation covered it.
Two actual filesystem regressions showed lost normal mtime preservation and a
remaining final-path stamping race. The repair prepares staged timestamps before
publication and restrictive chmod, skips post-commit stamping of that pathname,
and uses a verified file handle on non-promoted local paths. Fallback close
errors also retain recovery artifacts. Publication/bridge/SCP checks: 31 pass;
independent targeted review: 10 pass.
Fresh dedicated recovery now recognizes only the exact unchanged paused child
rows captured at its entry as old pauses eligible to resume. A newer row,
pausing state, root/child latch or cancellation still blocks admission. This
avoids a 4096-row batching deadlock without removing later pause protection.
Actual batched paused-history and later-pause regressions pass; 163 relevant
tests and an independent 60-test review pass.
The delivery rerun includes all follow-ups above: 11,452 tests passed, 0 failed,
18 skipped (182.9 seconds, four test processes). This is the final combined
full-suite result and supersedes the earlier intermediate counts.
+2 -2
View File
@@ -1,4 +1,4 @@
import { classifyDistroId } from './host';
import { classifyDistroId, resolveHostOs } from './host';
import type { PortForwardingRule } from './models';
import type { Host, TerminalSession } from '../types';
@@ -83,7 +83,7 @@ export const buildAITerminalSessionInfo = (
hostId: session?.hostId || '',
hostname: host?.hostname || session?.hostname || '',
label: host?.label || session?.hostLabel || '',
os: host?.os || (isLocalSession ? localOs : undefined),
os: isLocalSession ? localOs : resolveHostOs(host),
username: host?.username || session?.username,
protocol,
shellType: session?.shellType && session.shellType !== 'unknown' ? session.shellType : undefined,
+16 -1
View File
@@ -1,7 +1,7 @@
import assert from "node:assert/strict";
import test from "node:test";
import { applyEphemeralHostsUpdate, isSavedVaultHost, splitHostsUpdateByEphemeral } from "./ephemeralHosts";
import { applyEphemeralHostDistroUpdate, applyEphemeralHostsUpdate, isSavedVaultHost, splitHostsUpdateByEphemeral } from "./ephemeralHosts";
import type { Host } from "./models";
const makeHost = (id: string, overrides: Partial<Host> = {}): Host => ({
@@ -60,3 +60,18 @@ test("isSavedVaultHost is false for missing or ephemeral hosts", () => {
assert.equal(isSavedVaultHost(null), false);
assert.equal(isSavedVaultHost(undefined), false);
});
test("temporary host detection reaches AI without changing other hosts or reviving closed sessions", async () => {
const { buildAITerminalSessionInfo } = await import('./buildAITerminalSessionInfo');
const other = makeHost('other');
for (const [distro, os] of [['ubuntu', 'linux'], ['darwin', 'macos'], ['windows', 'windows']]) {
const target = makeHost('quick-connect', { ephemeral: true });
const next = applyEphemeralHostDistroUpdate([target, other], target.id, distro);
assert.equal(buildAITerminalSessionInfo(undefined, next[0], 'macos').os, os);
assert.equal(next[0].ephemeral, true);
assert.equal(next[1], other);
assert.equal(applyEphemeralHostDistroUpdate(next, target.id, distro), next);
const closed = [other];
assert.equal(applyEphemeralHostDistroUpdate(closed, target.id, distro), closed);
}
});
+8
View File
@@ -1,3 +1,4 @@
import { normalizeDistroId } from "./host";
import type { Host } from "./models";
/**
@@ -37,3 +38,10 @@ export const applyEphemeralHostsUpdate = (
const updatedById = new Map(updated.map((host) => [host.id, host]));
return previous.map((host) => updatedById.get(host.id) ?? host);
};
/** Detection updates stay in memory and never create a host after its session closes. */
export const applyEphemeralHostDistroUpdate = (previous: Host[], hostId: string, distro: string): Host[] => {
const normalized = normalizeDistroId(distro);
if (!previous.some((host) => host.id === hostId && host.distro !== normalized)) return previous;
return previous.map((host) => host.id === hostId ? { ...host, distro: normalized } : host);
};
+28
View File
@@ -1,4 +1,5 @@
import { Host, Snippet, TerminalSettings } from './models';
import type { HostOperatingSystem, HostOsSelection } from './models/connection';
import { sanitizeHostIconFields } from './hostIcon';
import { migrateHostConnectScriptIds } from './hostConnectScripts.ts';
import { migrateDeprecatedFontOverride } from '../infrastructure/config/fonts';
@@ -105,6 +106,7 @@ export const normalizeDistroId = (value?: string) => {
) {
return 'macos';
}
if (v === 'windows' || v === 'win32' || /^openssh_for_windows(?:_|$)/.test(v)) return 'windows';
if (v.includes('freebsd')) return 'freebsd';
if (v.includes('ubuntu')) return 'ubuntu';
if (v.includes('debian')) return 'debian';
@@ -215,6 +217,29 @@ export const classifyDistroId = (distroId?: string): DeviceClass => {
return 'other';
};
export const HOST_OS_SELECTIONS = ['auto', 'linux', 'windows', 'macos', 'freebsd', 'unknown'] as const;
export function getHostOsSelection(host?: Pick<Host, 'os' | 'osOverride'> | null): HostOsSelection {
if (host?.osOverride && (HOST_OS_SELECTIONS as readonly string[]).includes(host.osOverride)) {
return host.osOverride;
}
return host?.os === 'windows' || host?.os === 'macos' ? host.os : 'auto';
}
/** Resolve system facts separately from cosmetic manualDistro/icon choices. */
export function resolveHostOs(
host?: Pick<Host, 'os' | 'osOverride' | 'distro' | 'deviceType' | 'protocol'> | null,
): HostOperatingSystem {
if (host?.protocol === 'local') return host.os;
const selection = getHostOsSelection(host);
if (selection !== 'auto') return selection;
if (host?.deviceType === 'network' || classifyDistroId(host?.distro) === 'network-device') return 'unknown';
const distro = normalizeDistroId(host?.distro);
if (distro === 'windows' || distro === 'macos' || distro === 'freebsd') return distro;
if ((LINUX_DISTRO_OPTIONS as readonly string[]).includes(distro)) return 'linux';
return 'unknown';
}
/**
* Decide whether to offer the "enable Network Device Mode" suggestion after
* distro/vendor detection. We only nag once per host, and never when the user
@@ -430,6 +455,7 @@ export const sanitizeHost = (host: Host, snippets: Snippet[] = []): Host => {
? 'auto'
: undefined;
const cleanHostIcon = sanitizeHostIconFields(host);
const osSelection = getHostOsSelection(host);
const migrated = migrateDeprecatedFontOverride(host);
// Before explicit per-host authentication modes existed, new hosts were
// persisted with authMethod="password" even though an empty password still
@@ -468,6 +494,8 @@ export const sanitizeHost = (host: Host, snippets: Snippet[] = []): Host => {
: migrated.authMethod ?? inferredLegacyAuthMethod,
authPolicyVersion: 1,
hostname: cleanHostname,
osOverride: osSelection,
os: osSelection === 'linux' || osSelection === 'windows' || osSelection === 'macos' ? osSelection : host.os,
distro: cleanDistro,
distroMode: cleanDistroMode,
manualDistro: cleanManualDistro || undefined,
+64
View File
@@ -0,0 +1,64 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { getHostOsSelection, resolveHostOs, sanitizeHost } from './host';
import { buildAITerminalSessionInfo } from './buildAITerminalSessionInfo';
import { applyVaultHostUpdate, buildVaultHostFromDraft } from './vaultHostCreate';
import type { Host } from './models';
const host = (changes: Partial<Host> = {}): Host => ({
id: 'host', label: 'Host', hostname: 'example.test', username: 'user', tags: [], os: 'linux', ...changes,
});
test('legacy Linux defaults are not evidence; Windows and macOS corrections survive loading', () => {
for (const os of ['linux', 'windows', 'macos'] as const) {
const restored = sanitizeHost(host({ os }));
assert.equal(getHostOsSelection(restored), os === 'linux' ? 'auto' : os);
assert.equal(resolveHostOs(restored), os === 'linux' ? 'unknown' : os);
assert.deepEqual(sanitizeHost(restored), restored);
}
});
test('detected facts drive AI and ignore cosmetic icon overrides', () => {
for (const [distro, expected] of [['ubuntu','linux'],['windows','windows'],['darwin','macos'],['freebsd','freebsd'],['cisco','unknown']]) {
const target = host({ distro, manualDistro: 'macos', distroMode: 'manual' });
assert.equal(resolveHostOs(target), expected);
assert.equal(buildAITerminalSessionInfo(undefined, target, 'macos').os, expected);
}
});
test('explicit corrections survive detection and can return to automatic', () => {
const target = host({ os: 'windows', osOverride: 'linux', distro: 'windows' });
assert.equal(resolveHostOs(target), 'linux');
assert.equal(resolveHostOs({ ...target, osOverride: 'auto' }), 'windows');
assert.equal(resolveHostOs({ ...target, osOverride: 'unknown' }), 'unknown');
});
test('network device protection is independent of a chosen operating system', () => {
const target = host({ deviceType: 'network', distro: 'ubuntu' });
assert.equal(resolveHostOs(target), 'unknown');
const info = buildAITerminalSessionInfo(undefined, {...target, osOverride: 'linux'}, 'macos');
assert.equal(info.os, 'linux');
assert.equal(info.deviceType, 'network');
});
test('local sessions report the actual local OS rather than a saved default', () => {
assert.equal(buildAITerminalSessionInfo(undefined, host({protocol:'local'}), 'windows').os, 'windows');
});
test('vault create and update share explicit selection and auto reset', () => {
const created = buildVaultHostFromDraft({hostname:'example.test',username:'user',os:'freebsd'});
assert.equal(created.ok, true);
if (!created.ok) return;
assert.equal(resolveHostOs(created.host), 'freebsd');
const updated = applyVaultHostUpdate([created.host], [], created.host.id, {os:'auto'});
assert.equal(updated.ok, true);
if (!updated.ok) return;
assert.equal(resolveHostOs(updated.updatedHost), 'unknown');
});
test('manual selections retain compatible OS values for older readers', () => {
assert.equal(sanitizeHost(host({osOverride:'windows'})).os, 'windows');
const restored = sanitizeHost(host({os:'windows',osOverride:'auto'}));
assert.equal(getHostOsSelection(restored), 'auto');
assert.equal(resolveHostOs(restored), 'unknown');
});
+6
View File
@@ -151,6 +151,9 @@ export interface SftpBookmark {
export type HostAuthMethod = 'auto' | 'password' | 'key' | 'certificate';
export type HostOperatingSystem = 'linux' | 'windows' | 'macos' | 'freebsd' | 'unknown';
export type HostOsSelection = 'auto' | HostOperatingSystem;
export interface Host {
id: string;
label: string;
@@ -161,7 +164,10 @@ export interface Host {
identityId?: string;
group?: string;
tags: string[];
// Legacy compatibility value; use resolveHostOs for runtime decisions.
os: 'linux' | 'windows' | 'macos';
// Absent on old records: preserve Windows/macOS, treat old Linux defaults as auto.
osOverride?: HostOsSelection;
// Device type: 'general' for standard servers, 'network' for switches/routers/firewalls.
// Network devices use raw command execution (no shell wrapping) for AI agent compatibility.
deviceType?: 'general' | 'network';
+16
View File
@@ -15,6 +15,7 @@ import {
importMarkdownFilesToVaultNotes,
importMarkdownPayloadsToVaultNotes,
matchesVaultNoteSearch,
createVaultNoteSearchIndex,
normalizeNoteGroups,
normalizeVaultNotes,
remapExpandedNoteGroupPaths,
@@ -617,3 +618,18 @@ test("formatMarkdownListSelection prefixes every non-empty selected line", () =>
test("formatMarkdownQuoteSelection prefixes every selected line", () => {
assert.equal(formatMarkdownQuoteSelection("one\n\ntwo"), "> one\n>\n> two");
});
test("note mention index preserves search fields, entity identity and edited snapshots", () => {
const notes = [
{ id: "a", title: "Same", content: "Alpha body", group: "Production", tags: ["API"], createdAt: 1, updatedAt: 1 },
{ id: "b", title: "Same", content: "Beta body", createdAt: 1, updatedAt: 1 },
];
const search = createVaultNoteSearchIndex(notes);
for (const query of ["", " SAME ", "ALPHA", "beta", "production", "api", "missing", "same alpha"]) {
assert.deepEqual(search(query), notes.filter((note) => matchesVaultNoteSearch(note, query)));
}
assert.equal(search("beta")[0], notes[1]);
const edited = [{ ...notes[0], content: "Replacement" }, notes[1]];
assert.deepEqual(createVaultNoteSearchIndex(edited)("alpha"), []);
assert.equal(createVaultNoteSearchIndex(edited)("replacement")[0], edited[0]);
});
+15
View File
@@ -284,6 +284,21 @@ export const matchesVaultNoteSearch = (
].some((value) => value.toLowerCase().includes(needle));
};
/** Build once per note-list snapshot, so typing does not lowercase full bodies. */
export const createVaultNoteSearchIndex = (notes: readonly VaultNote[]) => {
const entries = notes.map((note) => ({
note,
fields: [note.title, note.content, note.group ?? "", ...(note.tags ?? [])]
.map((value) => value.toLowerCase()),
}));
return (query: string): VaultNote[] => {
const needle = query.trim().toLowerCase();
return entries
.filter((entry) => !needle || entry.fields.some((field) => field.includes(needle)))
.map((entry) => entry.note);
};
};
const isSanitizedRenderedLink = (value: string): boolean => {
try {
const url = new URL(value);
@@ -131,6 +131,7 @@ test("system overview stats run for Linux and macOS targets", () => {
{
id: "host-1",
label: "Linux",
distro: "ubuntu",
hostname: "linux.local",
username: "root",
tags: [],
@@ -172,3 +173,11 @@ test("FreeBSD icon detection does not enable unsupported system features", () =>
assert.equal(shouldCollectServerStats(host, undefined, null), false);
assert.deepEqual(buildSystemManagerTabs(host, undefined, null), ["overview", "processes"]);
});
test('default Linux and cosmetic icons cannot enable Linux commands before detection', () => {
const host = {id:'unknown', label:'Host', hostname:'host', username:'user', tags:[], os:'linux' as const, manualDistro:'ubuntu', distroMode:'manual' as const};
assert.equal(shouldCollectServerStats(host, undefined, null), false);
assert.deepEqual(buildSystemManagerTabs(host, undefined, null), ['overview','processes']);
assert.equal(shouldCollectServerStats({...host,distro:'ubuntu'},undefined,null),true);
assert.equal(shouldCollectServerStats({...host,distro:'ubuntu',osOverride:'windows'},undefined,null),false);
});
+10 -19
View File
@@ -1,13 +1,8 @@
import { classifyDistroId, getEffectiveHostDistro, normalizeDistroId } from '../host';
import { classifyDistroId, resolveHostOs } from '../host';
import type { Host } from '../models/connection';
import type { TerminalSession } from '../../types';
import type { SessionCapabilities, SystemManagerSubTab } from './types';
const hasKnownUnsupportedSystemDistro = (host: Host | null | undefined): boolean => {
const detectedDistro = normalizeDistroId(host?.distro);
return !!detectedDistro && classifyDistroId(detectedDistro) === 'other';
};
export function isNetworkDeviceTarget(host: Host | null | undefined): boolean {
if (host?.deviceType === 'network') return true;
return classifyDistroId(host?.distro) === 'network-device';
@@ -16,14 +11,11 @@ export function isNetworkDeviceTarget(host: Host | null | undefined): boolean {
export function isDefiniteLinuxTarget(
host: Host | null | undefined,
capabilities: SessionCapabilities | undefined,
session: TerminalSession | null | undefined,
_session: TerminalSession | null | undefined,
): boolean {
if (capabilities?.targetOs === 'linux') return true;
if (capabilities?.targetOs && capabilities.targetOs !== 'unknown') return capabilities.targetOs === 'linux';
if (isNetworkDeviceTarget(host)) return false;
if (hasKnownUnsupportedSystemDistro(host)) return false;
if (host?.os === 'linux') return true;
if (classifyDistroId(getEffectiveHostDistro(host)) === 'linux-like') return true;
if (session?.protocol === 'local' && host?.os === 'linux') return true;
if (resolveHostOs(host) === 'linux') return true;
return false;
}
@@ -48,7 +40,7 @@ export function shouldShowTmuxTab(
if (isNetworkDeviceTarget(host)) return capabilities?.hasTmux === true;
if (isDefiniteLinuxTarget(host, capabilities, session)) return true;
if (capabilities?.targetOs === 'darwin') return true;
if (host?.os === 'macos') return true;
if (resolveHostOs(host) === 'macos') return true;
return false;
}
@@ -98,15 +90,14 @@ export function shouldShowServicesTab(
export function shouldCollectServerStats(
host: Host | null | undefined,
capabilities: SessionCapabilities | undefined,
session: TerminalSession | null | undefined,
_session: TerminalSession | null | undefined,
): boolean {
const detectedDeviceClass = classifyDistroId(host?.distro);
if (isNetworkDeviceTarget(host) || detectedDeviceClass === 'network-device') return false;
if (capabilities?.targetOs === 'linux' || capabilities?.targetOs === 'darwin') return true;
if (hasKnownUnsupportedSystemDistro(host)) return false;
if (host?.os === 'linux' || host?.os === 'macos') return true;
if (detectedDeviceClass === 'linux-like') return true;
if (session?.protocol === 'local' && host?.os === 'linux') return true;
if (capabilities?.targetOs && capabilities.targetOs !== 'unknown') {
return capabilities.targetOs === 'linux' || capabilities.targetOs === 'darwin';
}
if (resolveHostOs(host) === 'linux' || resolveHostOs(host) === 'macos') return true;
return false;
}
+9 -5
View File
@@ -1,5 +1,6 @@
import type { GroupConfig, Host, HostProtocol, Identity, ManagedSource, ProxyProfile } from './models';
import { sanitizeHost } from './host';
import type { HostOsSelection } from './models/connection';
import {
findIntroducedVaultJumpGraphIssue,
findVaultGroupConfigJumpReference,
@@ -93,9 +94,10 @@ const normalizeProtocol = (raw: unknown): VaultHostDraftProtocol | undefined =>
return undefined;
};
const normalizeOs = (raw: unknown): Host['os'] | undefined => {
const normalizeOs = (raw: unknown): HostOsSelection | undefined => {
if (typeof raw !== 'string') return undefined;
const value = raw.trim().toLowerCase();
if (value === 'auto' || value === 'freebsd' || value === 'unknown') return value;
if (value === 'linux') return 'linux';
if (value === 'windows' || value === 'win') return 'windows';
if (value === 'macos' || value === 'mac' || value === 'osx' || value === 'darwin') return 'macos';
@@ -229,9 +231,9 @@ export function buildVaultHostFromDraft(
&& draft.os !== null
&& !(typeof draft.os === 'string' && !draft.os.trim());
if (hasOsInput && parsedOs === undefined) {
return { ok: false, error: 'os must be linux, windows, or macos.' };
return { ok: false, error: 'os must be auto, linux, windows, macos, freebsd, or unknown.' };
}
const os = parsedOs ?? 'linux';
const os = parsedOs === 'windows' || parsedOs === 'macos' ? parsedOs : 'linux';
const parsedPort = parsePort(draft.port);
const hasPortInput = draft.port !== undefined
&& draft.port !== null
@@ -308,6 +310,7 @@ export function buildVaultHostFromDraft(
group: normalizeGroupPath(draft.group),
tags: tags.tags,
os,
osOverride: parsedOs ?? 'auto',
protocol,
createdAt: now,
...(keyPath
@@ -438,9 +441,10 @@ export function applyVaultHostUpdate(
if (os.provided) {
const nextOs = normalizeOs(os.value);
if (!nextOs) {
return { ok: false, error: 'os must be linux, windows, or macos.' };
return { ok: false, error: 'os must be auto, linux, windows, macos, freebsd, or unknown.' };
}
updated.os = nextOs;
updated.osOverride = nextOs;
updated.os = nextOs === 'windows' || nextOs === 'macos' ? nextOs : 'linux';
}
if (identityId.provided) {
+62
View File
@@ -0,0 +1,62 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { readVaultNote } from './vaultNoteRead';
const note = { id: 'note-1', title: 'Runbook', content: '', createdAt: 1, updatedAt: 2 };
function read(...args: Parameters<typeof readVaultNote>) {
const result = readVaultNote(...args);
assert.ok('note' in result, JSON.stringify(result));
return result;
}
test('bounded pages reconstruct long Unicode notes without gaps or broken characters', () => {
const content = `${'a'.repeat(5999)}\u{1F408}中文\n`.repeat(20);
let offset: number | null = 0;
let collected = '';
while (offset !== null) {
const result = read({ ...note, content }, { offset, maxChars: 1_000_000, expectedUpdatedAt: 2 });
assert.ok(result.note);
assert.equal(result.offset, offset);
assert.ok(result.note.content.length <= 6000);
assert.doesNotMatch(result.note.content, /^[\uDC00-\uDFFF]|[\uD800-\uDBFF]$/);
collected += result.note.content;
offset = result.nextOffset;
}
assert.equal(collected, content);
});
test('small/empty notes and EOF are complete, and oversized metadata stays out', () => {
for (const content of ['', 'small note']) {
const result = read({ ...note, content, title: 'x'.repeat(100_000), tags: ['x'.repeat(100_000)] }, {});
assert.ok(result.note);
assert.equal(result.note.content, content);
assert.equal(result.nextOffset, null);
assert.ok(JSON.stringify(result).length < 1000);
}
assert.equal(read({ ...note, content: 'abc' }, { offset: 3 }).note?.content, '');
});
test('literal search locates later and overlapping hits without skipping nearby matches', () => {
const source = { ...note, content: `${'x'.repeat(9000)}ababa\u{1F408}tail` };
const first = read(source, { query: 'aba', maxChars: 4 });
assert.equal(first.matchOffset, 9000);
assert.equal(first.note?.content, 'abab');
const second = read(source, { query: 'aba', offset: first.nextOffset, expectedUpdatedAt: 2 });
assert.equal(second.matchOffset, 9002);
const missing = read(source, { query: 'aba', offset: second.nextOffset });
assert.equal(missing.matchOffset, null);
assert.equal(missing.nextOffset, null);
assert.equal(missing.note?.content, '');
assert.equal(read(source, { query: 'ABA' }).matchOffset, null);
assert.equal(read(source, { query: '.*' }).matchOffset, null);
});
test('invalid ranges, queries and changed notes fail explicitly', () => {
const source = { ...note, content: 'a\u{1F408}b' };
for (const params of [
{ offset: -1 }, { offset: 0.5 }, { offset: 9 }, { offset: 2 }, { offset: '1' },
{ maxChars: 0 }, { maxChars: NaN }, { maxChars: Infinity },
{ query: '' }, { query: 'a'.repeat(201) }, { query: 1 }, { expectedUpdatedAt: 1 },
]) assert.equal(readVaultNote(source, params).ok, false, JSON.stringify(params));
});
+52
View File
@@ -0,0 +1,52 @@
import type { VaultNote } from './models';
// Stay below the shared tool fitter's 8,000-character string limit.
export const MAX_NOTE_READ_CHARS = 6000;
/** Bounded reads use JavaScript (UTF-16) offsets, including for literal search. */
export function readVaultNote(note: VaultNote, params: Record<string, unknown>) {
const offset = params.offset ?? 0;
const maxChars = params.maxChars ?? MAX_NOTE_READ_CHARS;
const query = params.query;
if (typeof offset !== 'number' || !Number.isSafeInteger(offset) || offset < 0 || offset > note.content.length) {
return { ok: false, error: 'offset must be an integer within the note content.' };
}
if (typeof maxChars !== 'number' || !Number.isSafeInteger(maxChars) || maxChars < 2) {
return { ok: false, error: 'maxChars must be an integer of at least 2 (capped at 6000).' };
}
if (query !== undefined && (typeof query !== 'string' || query.length === 0 || query.length > 200)) {
return { ok: false, error: 'query must be a nonempty literal string of at most 200 characters.' };
}
if (params.expectedUpdatedAt !== undefined && params.expectedUpdatedAt !== note.updatedAt) {
return { ok: false, error: 'The note changed. Restart reading from offset 0 without expectedUpdatedAt.' };
}
const splitsPair = (position: number) => position > 0
&& /[\uD800-\uDBFF]/.test(note.content[position - 1] ?? '')
&& /[\uDC00-\uDFFF]/.test(note.content[position] ?? '');
if (splitsPair(offset)) return { ok: false, error: 'offset splits a Unicode character. Use the returned nextOffset.' };
const matchOffset = typeof query === 'string' ? note.content.indexOf(query, offset) : undefined;
let start = matchOffset === undefined ? offset : matchOffset < 0 ? note.content.length : matchOffset;
if (splitsPair(start)) start -= 1;
let end = Math.min(note.content.length, start + Math.min(maxChars, MAX_NOTE_READ_CHARS));
if (splitsPair(end)) end -= 1;
let nextOffset: number | null = end < note.content.length ? end : null;
if (matchOffset !== undefined) {
// Advance past the match start, not the excerpt, so nearby/overlapping hits remain discoverable.
let searchFrom = matchOffset + 1;
if (splitsPair(searchFrom)) searchFrom += 1;
nextOffset = matchOffset >= 0 && searchFrom < note.content.length ? searchFrom : null;
}
return {
ok: true,
// Large optional metadata must not defeat the body limit.
note: { id: note.id, title: Array.from(note.title).slice(0, 120).join(''),
content: note.content.slice(start, end), createdAt: note.createdAt, updatedAt: note.updatedAt },
offset: start,
endOffset: end,
totalChars: note.content.length,
nextOffset,
hasMore: nextOffset !== null,
...(matchOffset === undefined ? {} : { matchOffset: matchOffset < 0 ? null : matchOffset }),
};
}
+13 -6
View File
@@ -1,16 +1,23 @@
"use strict";
const { buildBashHistoryCleanup, bashHistoryScratchNames } = require("./ptyExecHelpers.cjs");
// Both fish and POSIX shells accept this command. Inspect the parent of a
// short-lived sh in the interactive PTY, rather than the SSH login shell.
function buildLiveShellProbe(marker) {
const script = 'if test -r "/proc/$PPID/comm"; then IFS= read -r name < "/proc/$PPID/comm"; else name=$(ps -p "$PPID" -o comm= 2>/dev/null); fi; '
+ `printf "${marker}_P:%s\\n" "$name"`;
// Put the job marker near the start of the echo, as the existing wrappers
// do, so preload can suppress it before a long command is split into chunks.
// The builtin completion marker still runs if sh cannot launch. Leave it
// unterminated so preload hides the intermediate prompt and wrapper echo
// on the same marker-bearing line, including across output chunks.
return ` true ${marker}; command sh -c '${script}' 2>/dev/null; printf '%s' '${marker}_Q'\n`;
// command eval bypasses an eval customization; plain eval is the fallback
// when command itself is shadowed. Never invoke a shadowed builtin after the
// command path already succeeded. Both eval bodies remain Bash-guarded.
const cleanup = buildBashHistoryCleanup(marker, true);
const { dispatcher } = bashHistoryScratchNames(marker);
const clear = `[ -z "\${${dispatcher}-}" ]||$${dispatcher} unset ${dispatcher}`;
const fallback = `[ "\${${dispatcher}-}" = command ]||{ ${cleanup}; };${clear}`;
// Continuation lines stay within canonical input limits. Each echo carries
// the marker so the renderer also hides continuation prompts.
return ` true ${marker}; command sh -c '${script}' 2>/dev/null; \\\n: '${marker}'; \\command eval '${cleanup}' 2>/dev/null || true; \\\n: '${marker}'; \\eval '${fallback}' 2>/dev/null || true; \\\n: '${marker}'; \\command eval '${clear}' 2>/dev/null || true; printf '%s' '${marker}_Q'\n`;
}
function parseLiveShellProbe(output, marker) {
+256 -1
View File
@@ -34,10 +34,14 @@ test('probe wrapper keeps the start marker separate when terminal echo is disabl
const { spawnSync } = require('node:child_process');
const pty = new EventEmitter();
let job;
let pendingInput = '';
pty.write = (data) => {
if (String(data).includes('command sh -c')) return;
if (data === '\x03') return;
const script = String(data).replace(/^\x15\x0b/, '');
pendingInput += String(data);
if (!pendingInput.endsWith('\n')) return;
const script = pendingInput.replace(/^\x0b\x15/, '');
pendingInput = '';
const result = spawnSync('/bin/sh', ['-c', script], { encoding: 'utf8' });
queueMicrotask(() => pty.emit('data', `${job.marker}_QPROMPT> ${result.stdout}`));
};
@@ -162,3 +166,254 @@ test('real PTY: echo-disabled bash completes output without a trailing newline',
pty.kill();
}
});
for (const editing of [true, false]) {
test(`real PTY: pending input clearing leaves no control command (editing=${editing})`, {
skip: process.env.NETCATTY_LIVE_FISH_TEST !== '1', timeout: 5000,
}, async () => {
const pty = require('node-pty').spawn('/bin/bash', ['--noprofile', '--norc', ...(editing ? [] : ['--noediting'])], {
name: 'dumb', cols: 240, rows: 24,
env: { ...process.env, TERM: 'dumb', PS1: 'CLEAR_READY> ', HISTFILE: '/dev/null', BASH_SILENCE_DEPRECATION_WARNING: '1' },
});
let output = '';
pty.onData(data => { output += data; });
try {
const waitForPrompt = async () => {
const deadline = Date.now() + 3000;
while (!output.includes('CLEAR_READY>')) {
if (Date.now() > deadline) throw new Error(`Missing prompt: ${output}`);
await new Promise(resolve => setTimeout(resolve, 10));
}
};
await waitForPrompt();
output = '';
// Leave text on both sides of the readline cursor. Without editing, all
// bytes remain pending canonical input and must still be discarded.
pty.write('left-right\x1b[5D');
await new Promise(resolve => setTimeout(resolve, 50));
const { buildPendingInputClearPrefix } = require('./ptyExecHelpers.cjs');
pty.write(buildPendingInputClearPrefix('posix') + "printf 'CLEAR_SUCCESS\\n'\n");
await waitForPrompt();
assert.match(output, /CLEAR_SUCCESS\r\n/);
assert.doesNotMatch(output, /command not found|syntax error/);
} finally {
pty.kill();
}
});
}
test('real PTY: canonical bash executes a long literal command without truncation', {
skip: process.env.NETCATTY_LIVE_FISH_TEST !== '1', timeout: 15000,
}, async () => {
const pty = require('node-pty').spawn('/bin/bash', ['--noprofile', '--norc', '--noediting'], {
name: 'dumb', cols: 240, rows: 24,
env: { ...process.env, TERM: 'dumb', PS1: 'CANONICAL_READY> ', HISTFILE: '/dev/null', BASH_SILENCE_DEPRECATION_WARNING: '1' },
});
let output = '';
pty.onData((data) => { output += data; });
try {
const deadline = Date.now() + 3000;
while (!output.includes('CANONICAL_READY>')) {
if (Date.now() > deadline) throw new Error('Missing canonical shell prompt');
await new Promise((resolve) => setTimeout(resolve, 10));
}
const literal = 'x'.repeat(12000);
const result = await require('./ptyExec.cjs').execViaPty(pty, `printf '%s' '${literal}'`, {
shellKind: 'posix', probeLiveShell: true, timeoutMs: 1500,
});
assert.equal(result.exitCode, 0, JSON.stringify({ ...result, stdout: result.stdout?.slice(-200) }));
assert.equal(result.stdout.trim(), literal);
} finally {
pty.kill();
}
});
// The second element is a history-listing invocation that still reaches the
// real history builtin under that customization.
for (const [customization, listHistory] of [
[':', 'command builtin history'],
['HISTCONTROL=ignorespace', 'command builtin history'],
["alias history='history 10'", 'command builtin history'],
['history() { :; }', 'command builtin history'],
['history() { builtin history "$@" | cat; }', 'command builtin history'],
["alias eval=':'", 'command builtin history'],
['eval() { :; }', 'command builtin history'],
['PATH=/nonexistent', 'command builtin history'],
["alias builtin=':'", 'command builtin history'],
['builtin() { :; }', 'command builtin history'],
["alias command=':'", '\\builtin history'],
['command() { :; }', '\\builtin history'],
]) {
for (const executeCommand of [false, true]) {
test(`bash probe keeps user history clean (${customization}, wrapper=${executeCommand})`, () => {
const { spawnSync } = require('node:child_process');
const { buildWrappedCommand } = require('./ptyExecHelpers.cjs');
const marker = '__NCMCP_HISTORY_PROBE__';
const input = `HISTFILE=/dev/null; HISTCONTROL=; PS1=; PS2=\n${customization}\n${listHistory} -c\necho user_one\necho user_two\n`
+ buildLiveShellProbe(marker)
+ (executeCommand ? buildWrappedCommand('echo command_ok', 'posix', marker, true) : '')
+ '\nprintf \"\\n\"\n' + listHistory + '\nexit\n';
const result = spawnSync('/bin/bash', ['--noprofile', '--norc', '-i'], {
input, encoding: 'utf8', env: { ...process.env, TERM: 'dumb' }, timeout: 5000,
});
assert.equal(result.status, 0, result.stderr);
assert.ok(result.stdout.includes(`${marker}_Q`), result.stdout);
const entries = result.stdout.split('\n').filter(line => /^\s*\d+\s/.test(line));
assert.ok(entries.some(line => line.includes('echo user_one')), entries.join('\n'));
assert.ok(entries.some(line => line.includes('echo user_two')), entries.join('\n'));
assert.ok(entries.every(line => !line.includes(marker)), entries.join('\n'));
});
}
}
test('real PTY: probe and execution leave only user commands for arrow recall', {
skip: process.env.NETCATTY_LIVE_FISH_TEST !== '1', timeout: 10000,
}, async () => {
const terminal = require('node-pty').spawn('/bin/bash', ['--noprofile', '--norc'], {
name: 'dumb', cols: 240, rows: 24,
env: { ...process.env, TERM: 'dumb', HISTFILE: '/dev/null', HISTCONTROL: '',
PS1: 'HISTORY_READY> ', BASH_SILENCE_DEPRECATION_WARNING: '1' },
});
let output = '';
terminal.onData(data => { output += data; });
const waitFor = async text => {
const deadline = Date.now() + 3000;
while (!output.includes(text)) {
if (Date.now() > deadline) throw new Error(`Missing ${text}: ${output.slice(-1500)}`);
await new Promise(resolve => setTimeout(resolve, 10));
}
const result = output;
output = '';
return result;
};
try {
await waitFor('HISTORY_READY>');
terminal.write('builtin history -c\r');
await waitFor('HISTORY_READY>');
terminal.write('echo user_history_one\r');
await waitFor('HISTORY_READY>');
terminal.write('echo user_history_two\r');
await waitFor('HISTORY_READY>');
const result = await require('./ptyExec.cjs').execViaPty(terminal, 'printf agent_success', {
shellKind: 'posix', probeLiveShell: true, timeoutMs: 2000,
});
assert.equal(result.exitCode, 0, JSON.stringify(result));
assert.match(result.stdout, /agent_success/);
await waitFor('HISTORY_READY>');
terminal.write('\x1b[A\r');
const recalled = await waitFor('HISTORY_READY>');
assert.match(recalled, /user_history_two/);
assert.doesNotMatch(recalled, /__NCMCP_/);
terminal.write('builtin history\r');
const history = await waitFor('HISTORY_READY>');
assert.match(history, /echo user_history_one/);
assert.match(history, /echo user_history_two/);
assert.doesNotMatch(history, /__NCMCP_/);
} finally {
terminal.kill();
}
});
for (const invocationName of ['sh', 'renamed-bash']) {
test(`Bash invoked as ${invocationName} cleans probe history`, () => {
const fs = require('node:fs');
const path = require('node:path');
const { spawnSync } = require('node:child_process');
const directory = require('../tempDirBridge.cjs').getTempFilePath('probe-bash');
fs.mkdirSync(directory, { mode: 0o700 });
try {
const shell = path.join(directory, invocationName);
fs.symlinkSync('/bin/bash', shell);
const marker = '__NCMCP_RENAMED_BASH__';
const result = spawnSync(shell, ['--noprofile', '--norc', '-i'], {
input: 'HISTFILE=/dev/null; HISTCONTROL=; PS1=; PS2=\nbuiltin history -c\necho preserve_user_history\n'
+ buildLiveShellProbe(marker) + '\nprintf "\\n"\ncommand builtin history\nexit\n',
encoding: 'utf8', env: { ...process.env, TERM: 'dumb' }, timeout: 5000,
});
assert.equal(result.status, 0, result.stderr);
assert.ok(result.stdout.includes(`${marker}_Q`), result.stdout);
const entries = result.stdout.split('\n').filter(line => /^\s*\d+\s/.test(line));
assert.ok(entries.some(line => line.includes('echo preserve_user_history')), result.stdout);
assert.ok(entries.every(line => !line.includes(marker)), result.stdout);
} finally {
fs.rmSync(directory, { recursive: true, force: true });
}
});
}
for (const [shell, args] of [
['/bin/dash', ['-i']],
['/bin/zsh', ['-f', '-i']],
['/bin/bash', ['--noprofile', '--norc', '-i']],
]) {
test(`probe preserves an interactive errexit session in ${shell}`, (t) => {
const { spawnSync } = require('node:child_process');
const marker = '__NCMCP_ERREXIT_PROBE__';
const result = spawnSync(shell, args, {
input: 'set -e\n' + buildLiveShellProbe(marker) + '\necho shell_survived\nexit\n',
encoding: 'utf8', env: { ...process.env, TERM: 'dumb', HISTFILE: '/dev/null' }, timeout: 5000,
});
if (result.error?.code === 'ENOENT') return t.skip(`${shell} is unavailable`);
assert.ifError(result.error);
assert.equal(result.status, 0, result.stderr);
assert.ok(result.stdout.includes(`${marker}_Q`), result.stdout);
assert.ok(result.stdout.includes('shell_survived'), result.stdout);
});
}
for (const shell of ['/bin/dash', '/bin/zsh']) {
test(`history cleanup keeps the execution wrapper portable in ${shell}`, { skip: !require('node:fs').existsSync(shell) }, () => {
const { spawnSync } = require('node:child_process');
const { buildWrappedCommand } = require('./ptyExecHelpers.cjs');
const marker = '__NCMCP_PORTABLE_HISTORY__';
const result = spawnSync(shell, ['-c', buildWrappedCommand('echo portable-history-ok', 'posix', marker, true)], { encoding: 'utf8', timeout: 5000 });
assert.equal(result.status, 0, result.stderr);
assert.match(result.stdout, /portable-history-ok/);
assert.ok(result.stdout.includes(`${marker}_E:0`));
});
}
for (const historyControl of ['', 'ignorespace']) {
test(`successful cleanup does not invoke a shadowed builtin (${historyControl || 'default'})`, () => {
const { spawnSync } = require('node:child_process');
const { buildWrappedCommand } = require('./ptyExecHelpers.cjs');
const marker = '__NCMCP_FALLBACK_GUARD__';
const input = `HISTFILE=/dev/null; HISTCONTROL=${historyControl}; PS1=; PS2=\nbuiltin() { printf UNEXPECTED_FALLBACK; }\ncommand history -c\necho user_one\n`
+ buildLiveShellProbe(marker)
+ buildWrappedCommand('echo command_ok', 'posix', marker, true)
+ '\ncommand history\nexit\n';
const result = spawnSync('/bin/bash', ['--noprofile', '--norc', '-i'], {
input, encoding: 'utf8', env: { ...process.env, TERM: 'dumb' }, timeout: 5000,
});
assert.equal(result.status, 0, result.stderr);
assert.ok(result.stdout.includes(`${marker}_Q`), result.stdout);
assert.match(result.stdout, /command_ok/);
assert.doesNotMatch(result.stdout, /UNEXPECTED_FALLBACK/);
const entries = result.stdout.split('\n').filter(line => /^\s*\d+\s/.test(line));
assert.ok(entries.some(line => line.includes('echo user_one')), entries.join('\n'));
assert.ok(entries.every(line => !line.includes(marker)), entries.join('\n'));
});
}
for (const stop of ['cancel', 'timeout']) {
test(`paced probe stops writing after ${stop}`, async () => {
const pty = new EventEmitter();
const writes = [];
pty.write = data => writes.push(data);
const job = startPtyJob(pty, 'echo must_not_run', {
shellKind: 'posix', probeLiveShell: true, timeoutMs: stop === 'timeout' ? 70 : 1000,
enforceWallTimeout: stop === 'timeout',
});
await new Promise(resolve => setTimeout(resolve, 45));
assert.ok(writes.length >= 2, 'probe must have started a later chunk');
if (stop === 'cancel') {
job.cancel();
pty.emit('close');
}
await job.resultPromise;
const count = writes.length;
await new Promise(resolve => setTimeout(resolve, 100));
assert.equal(writes.length, count);
assert.ok(writes.every(data => !data.includes('must_not_run')));
});
}
+55 -4
View File
@@ -94,6 +94,7 @@ function startPtyJob(ptyStream, command, options) {
const usesLiveShellProbe = probeLiveShell && ["posix", "fish"].includes(resolvedShellKind);
let probingShell = usesLiveShellProbe;
let deliveringInput = false;
let probeOutput = "";
let output = "";
@@ -200,6 +201,9 @@ function startPtyJob(ptyStream, command, options) {
// Foreground execs use the configured timeoutMs as the deadline (matching
// the pre-PR behavior); background jobs use a fixed 30s since their main
// timeout is much longer (1 hour) and meant for the actual command.
// The timer is armed once input delivery completes (see writeInput) so the
// paced-typing time for oversized probes/wrappers is excluded from the
// startup budget instead of counting against it.
const BG_STARTUP_TIMEOUT_MS = 30000;
function armStartupTimeout() {
const startupMs = maxBufferedChars > 0 ? BG_STARTUP_TIMEOUT_MS : timeoutMs;
@@ -526,7 +530,7 @@ function startPtyJob(ptyStream, command, options) {
: Buffer.from(String(data ?? ""));
const text = outputDecoder.write(bytes);
if (!text) return;
if (!pendingEnd) armOutputTimeout();
if (!pendingEnd && !deliveringInput) armOutputTimeout();
if (probingShell) {
probeOutput = (probeOutput + text).slice(-16384);
@@ -652,7 +656,6 @@ function startPtyJob(ptyStream, command, options) {
armOutputTimeout();
armWallTimeout();
armStartupTimeout();
unsubscribe = subscribeToPtyData(ptyStream, onData);
@@ -729,13 +732,61 @@ function startPtyJob(ptyStream, command, options) {
}
}
let inputWriteTimer = null;
let inputWriteGeneration = 0;
function stopInputWrite() {
inputWriteGeneration += 1;
clearTimeout(inputWriteTimer);
inputWriteTimer = null;
}
cleanupFns.push(stopInputWrite);
function writeInput(text) {
stopInputWrite();
// Each delivery gets a fresh wait budget, including the transition from
// probe to wrapper. Echo may be disabled while we are still typing.
clearStartupTimeout();
clearTimeout(timeoutId);
deliveringInput = true;
const generation = inputWriteGeneration;
let offset = 0;
// Readline may overrun its input queue when long probes arrive in one
// write. Yield between bounded chunks; cancelled/replaced jobs must never
// finish typing an old command into a subsequent prompt.
const chunkSize = usesLiveShellProbe && text.length > 1024 ? 128 : text.length;
const writeNext = () => {
if (finished || cancelRequested || generation !== inputWriteGeneration) return;
let end = Math.min(offset + chunkSize, text.length);
// Do not split a UTF-16 surrogate pair across independently encoded writes.
if (end < text.length && /[\uD800-\uDBFF]/.test(text[end - 1])) end -= 1;
ptyStream.write(text.slice(offset, end));
offset = end;
if (offset < text.length && generation === inputWriteGeneration && !finished && !cancelRequested) {
inputWriteTimer = setTimeout(() => {
try { writeNext(); } catch (error) {
finish(preStartOutput, -1, `Terminal input failed: ${error.message}`);
}
}, 30);
} else {
// Input delivery is complete: only now does the startup deadline
// begin, so paced typing time never consumes the startup budget.
if (!finished && !cancelRequested && generation === inputWriteGeneration) {
deliveringInput = false;
armOutputTimeout();
if (!foundStart) armStartupTimeout();
}
}
};
writeNext();
}
function writeWrappedCommand() {
const wrapped = buildWrappedCommand(command, resolvedShellKind, marker, probeLiveShell);
ptyStream.write(`${buildPendingInputClearPrefix(resolvedShellKind)}${wrapped}`);
writeInput(`${buildPendingInputClearPrefix(resolvedShellKind)}${wrapped}`);
}
if (probingShell) {
ptyStream.write(`${buildPendingInputClearPrefix(resolvedShellKind)}${buildLiveShellProbe(marker)}`);
writeInput(`${buildPendingInputClearPrefix(resolvedShellKind)}${buildLiveShellProbe(marker)}`);
} else {
writeWrappedCommand();
}
+16 -5
View File
@@ -26,7 +26,7 @@ const {
class ShellBackedPty extends EventEmitter {
write(data) {
if (data === "\x03") return;
const script = String(data).replace(/^\x15\x0b/, "");
const script = String(data).replace(/^\x0b\x15/, "");
const result = spawnSync("sh", ["-c", script], { encoding: "utf8" });
queueMicrotask(() => {
this.emit("data", Buffer.from(result.stdout));
@@ -365,8 +365,8 @@ test("loginShellHint selects fish/posix/powershell/cmd without pinning confirmed
});
test("pending-input clear prefix covers interactive shells and skips raw devices", () => {
assert.equal(buildPendingInputClearPrefix("posix"), "\x15\x0b");
assert.equal(buildPendingInputClearPrefix("fish"), "\x15\x0b");
assert.equal(buildPendingInputClearPrefix("posix"), "\x0b\x15");
assert.equal(buildPendingInputClearPrefix("fish"), "\x0b\x15");
assert.equal(buildPendingInputClearPrefix("powershell"), "\x1bggd2147483647d\x1br\x1b\x1bi\x08");
assert.equal(buildPendingInputClearPrefix("cmd"), "\x1b");
assert.equal(buildPendingInputClearPrefix("raw"), "");
@@ -849,7 +849,7 @@ test("startPtyJob keeps the clear prefix for non-PowerShell sessions", async ()
expectedPrompt: "$ ",
});
assert.equal(writes.length, 1);
assert.ok(writes[0].startsWith("\x15\x0b"));
assert.ok(writes[0].startsWith("\x0b\x15"));
assert.match(writes[0], /__NCMCP_/);
job.cancel();
pty.emit("data", Buffer.from("$ "));
@@ -921,6 +921,17 @@ test("posix wrapper types multi-line commands as one physical line (no PS2 leak)
assert.match(result.stdout, new RegExp(`${marker}_E:0`));
});
test("long POSIX assignments preserve quotes, Unicode, and heredoc newlines on bounded input lines", () => {
const marker = "__NCMCP_LONG_LITERAL_TEST__";
const literal = "quote' \\ $HOME `false` " + "\u4e2d\u6587\ud83d\ude42".repeat(600);
const command = `cat <<'SMOKE_END'\n${literal}\n\nsecond line\nSMOKE_END\nprintf tail`;
const wrapped = buildWrappedCommand(command, "posix", marker, true);
assert.ok(wrapped.split('\n').every(line => Buffer.byteLength(line, 'utf8') < 1000));
const result = spawnSync('sh', ['-c', wrapped], { encoding: 'utf8' });
assert.equal(result.status, 0, result.stderr);
assert.equal(result.stdout, `\n${marker}_S\n${literal}\n\nsecond line\ntail${marker}_E:0\n`);
});
test("posix wrapper isolates explicit exit from the active shell and reports its code", () => {
const marker = "__NCMCP_TEST__";
const wrapped = buildWrappedCommand("exit 7", "posix", marker);
@@ -1295,7 +1306,7 @@ test("execViaChannel cancellation never exposes an incomplete UTF-8 character",
assert.equal(result.error, "Cancelled");
});
for (const customization of [":", "HISTCONTROL=ignorespace", "alias history='history 10'", "history() { :; }"]) {
for (const customization of [":", "HISTCONTROL=ignorespace", "alias history='history 10'", 'history() { builtin history "$@" | cat; }', "history() { :; }", "alias command=':'", "command() { :; }", "readonly __nc_h=USER", "readonly __nc_d=USER"]) {
test(`posix wrapper bypasses history customization: ${customization}`, () => {
const marker = "__NCMCP_CUSTOM_HISTORY__";
const wrapped = buildWrappedCommand("echo HISTORY_PROBE", "posix", marker);
+54 -22
View File
@@ -194,34 +194,64 @@ function buildPendingInputClearPrefix(shellKind) {
// i+Backspace leaves every mode on an empty editable line.
return "\x1bggd2147483647d\x1br\x1b\x1bi\x08";
default:
return "\x15\x0b";
// Kill the suffix before the prefix. Canonical/no-editing terminals do
// not bind Ctrl+K; the trailing Ctrl+U must erase that literal byte too.
return "\x0b\x15";
}
}
function bashHistoryScratchNames(marker) {
const suffix = String(marker || "").toLowerCase().replace(/[^a-z0-9]/g, "").slice(-12) || "dflt";
return { entry: `__nc_h_${suffix}`, dispatcher: `__nc_d_${suffix}` };
}
function buildBashHistoryCleanup(marker, keepDispatcher = false) {
// Expanded dispatcher names bypass aliases. Verify that a dispatcher really
// executes builtins before trusting an empty history read from a no-op function.
// After deletion, verify the entry is gone: a shadowed history function may
// delete only in a subshell. Stop as soon as the real dispatcher succeeds.
// Invocation-specific scratch names avoid readonly user variables. Clear the
// history-bearing scratch through the verified dispatcher, never plain unset.
const { entry, dispatcher } = bashHistoryScratchNames(marker);
const unsetNames = keepDispatcher ? entry : `${entry} ${dispatcher}`;
return `[ "\${BASH_VERSION-}" ]&&{ for ${dispatcher} in command builtin;do ${entry}=$($${dispatcher} printf x);[ "$${entry}" = x ]||continue;${entry}=$($${dispatcher} history 1);case "$${entry}" in *${marker}*) ${entry}=\${${entry}#"\${${entry}%%[^[:space:]]*}"};$${dispatcher} history -d "\${${entry}%%[[:space:]]*}";${entry}=$($${dispatcher} history 1);case "$${entry}" in *${marker}*) continue;;esac;;esac;$${dispatcher} unset ${unsetNames};break;done; } 2>/dev/null`;
}
function buildPosixWrapperBody(command, marker, startFormat) {
const noPager = "PAGER=cat SYSTEMD_PAGER= GIT_PAGER=cat LESS= ";
const commandLines = String(command || "").replace(/\r\n?/g, "\n").split("\n");
const cmdAssign = commandLines.length > 1
let cmdAssign = commandLines.length > 1
? `${marker}_cmd=$(printf '%s\\n' ${commandLines.map((line) => `'${escapePosixSingleQuoted(line)}'`).join(" ")})`
: `${marker}_cmd='${escapePosixSingleQuoted(command)}'`;
// Issue #3265: without HISTCONTROL=ignorespace the wrapper line is recorded
// in bash history. Pressing arrow-up then makes readline redraw that huge
// marker-containing line, and the preload __NCMCP_ filter suppresses the
// redraw fragments that contain the marker — readline's row accounting
// diverges from what the terminal actually rendered, so every subsequent
// history-navigation redraw leaves fragments of the AI command on screen.
// Match the latest entry before deleting it, preserving user history when
// HISTCONTROL=ignorespace skips the wrapper. Read the entry's actual number:
// older Bash versions can expose HISTCMD as the next history number.
// builtin history bypasses user aliases and functions. The unset-safe guard
// leaves non-Bash shells alone, including shells with nounset enabled.
// Use ^ for the Bash bracket negation: ! triggers interactive zsh history
// expansion before the Bash-only guard can run.
const historyCleanup =
`[ -n "\${BASH_VERSION-}" ] && { ${marker}_h=$(builtin history 1 2>/dev/null); case "$${marker}_h" in *${marker}*) ${marker}_h=\${${marker}_h#"\${${marker}_h%%[^[:space:]]*}"}; builtin history -d "\${${marker}_h%%[[:space:]]*}" 2>/dev/null ;; esac; }`;
return (
`${marker}=0; ${cmdAssign}; { printf '${startFormat}' '${marker}_S'; trap ':' INT; ( ${noPager}eval "$${marker}_cmd" ); __NCMCP_rc=$?; trap - INT; printf '%s\\n' '${marker}_E:'\"$__NCMCP_rc\"; ${historyCleanup}; (exit $__NCMCP_rc); }`
);
if (Buffer.byteLength(cmdAssign, 'utf8') > 650) {
// Canonical PTYs limit bytes per physical input line, regardless of write
// pacing. Emit bounded quoted pieces inside one command substitution; each
// continuation keeps the marker visible to the terminal echo filter.
const writes = [];
for (const [index, line] of commandLines.entries()) {
let chunk = '';
let bytes = 0;
for (const character of line) {
const quoted = escapePosixSingleQuoted(character);
const size = Buffer.byteLength(quoted, 'utf8');
if (bytes + size > 512) {
writes.push(`printf '%s' '${chunk}'`);
chunk = '';
bytes = 0;
}
chunk += quoted;
bytes += size;
}
writes.push(`printf '${index < commandLines.length - 1 ? '%s\\n' : '%s'}' '${chunk}'`);
}
cmdAssign = `${marker}_cmd=$(${writes.join(`; \\\n: '${marker}'; `)})`;
}
const historyCleanup = buildBashHistoryCleanup(marker);
const prefix = `${marker}=0; ${cmdAssign}; { printf '${startFormat}' '${marker}_S'; trap ':' INT; ( ${noPager}eval "$${marker}_cmd" ); __NCMCP_rc=$?; trap - INT; printf '%s\\n' '${marker}_E:'\"$__NCMCP_rc\"`;
const suffix = `${historyCleanup}; (exit $__NCMCP_rc); }`;
const separator = prefix.length + suffix.length + 2 > 1000
? `; \\\n: '${marker}'; ` : "; ";
return `${prefix}${separator}${suffix}`;
}
function buildWrappedCommand(command, shellKind, marker, separateStartMarker = false) {
@@ -259,7 +289,7 @@ function buildWrappedCommand(command, shellKind, marker, separateStartMarker = f
case "posix":
default: {
// Single-line compound command with early marker.
// Compound command with an early marker on each physical line.
//
// Layout: __NCMCP_xxx=0; { ... MARKER_S; eval command; MARKER_E; }
//
@@ -276,7 +306,7 @@ function buildWrappedCommand(command, shellKind, marker, separateStartMarker = f
// keeps shell syntax errors inside the eval call so the wrapper
// can still emit the end marker and return a non-zero exit code.
//
// 3) Single-line { ... } is parsed fully before execution, so SIGINT
// 3) The complete { ... } group is parsed before execution, so SIGINT
// cannot cause bash to flush the end marker from the input buffer.
// trap ':' INT lets child processes receive SIGINT normally while
// preventing the shell from aborting the compound command.
@@ -470,6 +500,8 @@ module.exports = {
resolveEffectiveShellKind,
buildPendingInputClearPrefix,
buildWrappedCommand,
buildBashHistoryCleanup,
bashHistoryScratchNames,
findEndMarker,
normalizePtyOutput,
appendBoundedOutput,
@@ -0,0 +1,62 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const { EventEmitter } = require('node:events');
const { startPtyJob } = require('./ptyExec.cjs');
const { buildLiveShellProbe } = require('./liveShellProbe.cjs');
const { buildWrappedCommand } = require('./ptyExecHelpers.cjs');
for (const background of [true, false]) {
test(`paced ${background ? 'background' : 'silent foreground'} delivery does not consume startup time`, async (t) => {
t.mock.timers.enable({ apis: ['setTimeout'] });
const pty = new EventEmitter();
const writes = [];
pty.write = (data) => writes.push(String(data));
const command = `echo ${'x'.repeat(background ? 150000 : 12000)}`;
const job = startPtyJob(pty, command, {
shellKind: 'posix', probeLiveShell: true,
timeoutMs: background ? 3600000 : 500,
maxBufferedChars: background ? 1024 : 0,
});
const advance = (ms) => {
for (let elapsed = 0; elapsed < ms; elapsed += 30) t.mock.timers.tick(30);
};
try {
const probeLength = 2 + buildLiveShellProbe(job.marker).length;
while (writes.join('').length < probeLength && !writes.includes('\x03')) advance(30);
assert.ok(!writes.includes('\x03'), 'probe delivery was interrupted');
pty.emit('data', `${job.marker}_P:sh\n${job.marker}_Q`);
// The background case crosses the old probe timer; the foreground
// case has no echo to refresh its inactivity timer during delivery.
advance(background ? 32010 : 1200);
assert.ok(!writes.includes('\x03'), 'wrapper interrupted before delivery completed');
const totalLength = probeLength + 2 + buildWrappedCommand(command, 'posix', job.marker, true).length;
while (writes.join('').length < totalLength && !writes.includes('\x03')) advance(30);
assert.ok(!writes.includes('\x03'), 'delivery did not finish');
pty.emit('data', `${job.marker}_S\nOK\n${job.marker}_E:0\n`);
assert.equal((await job.resultPromise).exitCode, 0);
} finally {
pty.emit('close');
t.mock.timers.reset();
}
});
}
test('completed delivery still has a bounded wait for a missing probe reply', async (t) => {
t.mock.timers.enable({ apis: ['setTimeout'] });
const pty = new EventEmitter();
const writes = [];
pty.write = (data) => writes.push(String(data));
const job = startPtyJob(pty, 'echo never', {
shellKind: 'posix', probeLiveShell: true, timeoutMs: 500,
});
const length = 2 + buildLiveShellProbe(job.marker).length;
while (writes.join('').length < length) t.mock.timers.tick(30);
// Unrelated output must not keep a never-started command alive forever.
for (let i = 0; i < 6; i++) {
pty.emit('data', 'unrelated output\n');
t.mock.timers.tick(100);
}
const result = await job.resultPromise;
assert.match(result.error, /Command startup timed out/);
assert.ok(writes.includes('\x03'));
});
+42 -5
View File
@@ -1,6 +1,30 @@
"use strict";
const { invalidateSshTransport } = require("./sshTransportInvalidation.cjs");
// An abandoned OPEN cannot be cancelled in SSH, but its physical connection
// may own unrelated terminals. Block only additional opens until its callback
// or transport closure settles; retain no unbounded retry queue.
const abandonedOpens = new WeakMap();
function abandonOpen(client, token) {
let state = abandonedOpens.get(client);
if (!state) {
state = { tokens: new Set(), onClose: null };
state.onClose = () => {
if (abandonedOpens.get(client) === state) abandonedOpens.delete(client);
state.tokens.clear();
};
abandonedOpens.set(client, state);
client.once?.("close", state.onClose);
}
state.tokens.add(token);
}
function settleAbandonedOpen(client, token) {
const state = abandonedOpens.get(client);
if (!state || !state.tokens.delete(token) || state.tokens.size > 0) return;
abandonedOpens.delete(client);
client.removeListener?.("close", state.onClose);
}
const DEFAULT_SFTP_CHANNEL_OPEN_TIMEOUT_MS = 10_000;
@@ -24,6 +48,12 @@ function openBoundedSftpChannel(sshClient, options = {}) {
return Promise.resolve(null);
}
const signal = options.signal || null;
if (signal?.aborted) return Promise.reject(createSftpOpenAbortError(signal));
if (abandonedOpens.has(sshClient)) {
const error = new Error("A previous SFTP channel open is still pending on this connection");
error.code = "SFTP_CHANNEL_OPEN_PENDING";
return Promise.reject(error);
}
const setTimeoutFn = options.setTimeoutFn || setTimeout;
const clearTimeoutFn = options.clearTimeoutFn || clearTimeout;
const timeoutMs = Math.max(
@@ -33,13 +63,15 @@ function openBoundedSftpChannel(sshClient, options = {}) {
return new Promise((resolve, reject) => {
let settled = false;
let requestPending = false;
const token = Symbol("sftp-open");
let timer = null;
const cleanup = () => {
if (timer) clearTimeoutFn(timer);
timer = null;
signal?.removeEventListener?.("abort", onAbort);
};
const finish = (error, channel = null, { invalidateTransport = false } = {}) => {
const finish = (error, channel = null, { abandon = false } = {}) => {
if (settled) {
closeSftpChannel(channel);
return false;
@@ -47,13 +79,13 @@ function openBoundedSftpChannel(sshClient, options = {}) {
settled = true;
cleanup();
if (error && channel) closeSftpChannel(channel);
if (invalidateTransport) invalidateSshTransport(sshClient);
if (abandon && requestPending) abandonOpen(sshClient, token);
if (error) reject(error);
else resolve(channel);
return true;
};
const onAbort = () => finish(createSftpOpenAbortError(signal), null, {
invalidateTransport: true,
abandon: true,
});
if (signal?.aborted) {
@@ -66,15 +98,20 @@ function openBoundedSftpChannel(sshClient, options = {}) {
timer = setTimeoutFn(() => {
const error = new Error(`SFTP channel open timed out after ${timeoutMs}ms`);
error.code = "SFTP_CHANNEL_OPEN_TIMEOUT";
finish(error, null, { invalidateTransport: true });
finish(error, null, { abandon: true });
}, timeoutMs);
try {
requestPending = true;
sshClient.sftp((error, channel) => {
requestPending = false;
settleAbandonedOpen(sshClient, token);
if (error) finish(error, channel || null);
else finish(null, channel || null);
});
} catch (error) {
requestPending = false;
settleAbandonedOpen(sshClient, token);
finish(error);
}
});
+73 -23
View File
@@ -46,7 +46,7 @@ test("bounded SFTP open times out and closes a late channel", async () => {
assert.equal(timers.active.size, 1);
assert.equal([...timers.active][0].hasRef(), true);
await assert.rejects(result, (error) => error.code === "SFTP_CHANNEL_OPEN_TIMEOUT");
assert.equal(invalidations, 1);
assert.equal(invalidations, 0, "one channel request must not destroy the shared SSH transport");
assert.equal(timers.active.size, 0);
const channel = createChannel();
@@ -72,7 +72,7 @@ test("bounded SFTP open cancellation settles immediately and closes a late chann
assert.equal([...timers.active][0].hasRef(), true);
controller.abort(new Error("cancelled"));
await assert.rejects(result, /cancelled/);
assert.equal(invalidations, 1);
assert.equal(invalidations, 0, "one channel request must not destroy the shared SSH transport");
assert.equal(timers.active.size, 0);
assert.equal(getEventListeners(controller.signal, "abort").length, 0);
@@ -82,28 +82,44 @@ test("bounded SFTP open cancellation settles immediately and closes a late chann
assert.equal(timers.active.size, 0);
});
test("repeated unresponsive SFTP opens cannot retain channel callbacks", async () => {
const sshClient = {
destroyed: false,
pendingChannelCallbacks: [],
sftp(callback) {
if (this.destroyed) throw new Error("Not connected");
this.pendingChannelCallbacks.push(callback);
},
end() {},
destroy() {
this.destroyed = true;
this.pendingChannelCallbacks.length = 0;
},
};
for (const reason of ["timeout", "cancel"]) {
test(`abandoned ${reason} opens cannot accumulate requests on a shared transport`, async () => {
const callbacks = [];
let physicalCloses = 0;
const sshClient = new EventEmitter();
sshClient.sftp = callback => { callbacks.push(callback); };
sshClient.end = sshClient.destroy = () => { physicalCloses++; };
const controller = new AbortController();
const opening = openBoundedSftpChannel(sshClient, { timeoutMs: 5, signal: controller.signal });
if (reason === "cancel") controller.abort(new Error("cancelled"));
await assert.rejects(opening);
for (let attempt = 0; attempt < 3; attempt++) {
await assert.rejects(openBoundedSftpChannel(sshClient, { timeoutMs: 5 }));
}
assert.equal(callbacks.length, 1, "retries must not allocate more abandoned SSH channel requests");
assert.equal(physicalCloses, 0, "existing terminal and SFTP channels remain connected");
for (let attempt = 0; attempt < 3; attempt += 1) {
await assert.rejects(
openBoundedSftpChannel(sshClient, { timeoutMs: 2 }),
/timed out|Not connected/,
);
}
assert.equal(sshClient.pendingChannelCallbacks.length, 0);
const late = createChannel();
callbacks[0](null, late);
assert.ok(late.endCalls > 0 || late.closeCalls > 0);
const next = openBoundedSftpChannel(sshClient, { timeoutMs: 50 });
assert.equal(callbacks.length, 2, "a settled abandoned request must release admission");
const healthy = createChannel();
callbacks[1](null, healthy);
assert.equal(await next, healthy);
assert.equal(physicalCloses, 0);
});
}
test("healthy SFTP channel openings remain parallel", async () => {
const callbacks = [];
const sshClient = { sftp(callback) { callbacks.push(callback); } };
const first = openBoundedSftpChannel(sshClient, { timeoutMs: 50 });
const second = openBoundedSftpChannel(sshClient, { timeoutMs: 50 });
assert.equal(callbacks.length, 2);
const channels = [createChannel(), createChannel()];
callbacks.forEach((callback, index) => callback(null, channels[index]));
assert.deepEqual(await Promise.all([first, second]), channels);
});
test("bounded SFTP open removes cancellation listeners after success and failure", async () => {
@@ -131,3 +147,37 @@ test("bounded SFTP open converts synchronous setup errors into rejections", asyn
const sshClient = { sftp() { throw new Error("sync failure"); } };
await assert.rejects(openBoundedSftpChannel(sshClient), /sync failure/);
});
test("transport closure releases abandoned-open bookkeeping without a late callback", async () => {
const sshClient = new EventEmitter();
const callbacks = [];
sshClient.sftp = callback => callbacks.push(callback);
await assert.rejects(openBoundedSftpChannel(sshClient, { timeoutMs: 5 }));
assert.equal(sshClient.listenerCount("close"), 1);
sshClient.emit("close");
assert.equal(sshClient.listenerCount("close"), 0);
const next = openBoundedSftpChannel(sshClient, { timeoutMs: 50 });
const channel = createChannel();
callbacks[1](null, channel);
assert.equal(await next, channel);
const late = createChannel();
callbacks[0](null, late);
assert.ok(late.endCalls > 0);
});
test("all abandoned parallel opens must settle before admitting a new one", async () => {
const sshClient = new EventEmitter();
const callbacks = [];
sshClient.sftp = callback => callbacks.push(callback);
const first = openBoundedSftpChannel(sshClient, { timeoutMs: 5 });
const second = openBoundedSftpChannel(sshClient, { timeoutMs: 5 });
await Promise.all([assert.rejects(first), assert.rejects(second)]);
assert.equal(sshClient.listenerCount("close"), 1);
callbacks[0](new Error("first failed"));
await assert.rejects(openBoundedSftpChannel(sshClient), error => error.code === "SFTP_CHANNEL_OPEN_PENDING");
callbacks[1](new Error("second failed"));
assert.equal(sshClient.listenerCount("close"), 0);
const next = openBoundedSftpChannel(sshClient);
callbacks[2](null, createChannel());
await next;
});
Loaded 100 of 135 files, more files were not shown because too many files have changed in this diff. Show more