mirror of
https://github.com/binaricat/Netcatty.git
synced 2026-10-31 02:42:31 +00:00
Compare commits
26
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e57ec0b476 | ||
|
|
999f85763f | ||
|
|
70a68900ce | ||
|
|
361dc6117f | ||
|
|
fe2f14f032 | ||
|
|
1a9c57b0a9 | ||
|
|
058727b0d9 | ||
|
|
35ed0b6dbd | ||
|
|
ab80d04039 | ||
|
|
aaac962179 | ||
|
|
5d61462b20 | ||
|
|
8cc8d938b8 | ||
|
|
1babda6fa9 | ||
|
|
0eebc77b1b | ||
|
|
7069bdb1c8 | ||
|
|
10ad14223f | ||
|
|
f7f0d804af | ||
|
|
4ba196510d | ||
|
|
fd54339367 | ||
|
|
28b9a8db89 | ||
|
|
cabdcdf31c | ||
|
|
e2a9e54a90 | ||
|
|
c9d22b3b4d | ||
|
|
2dea078c6d | ||
|
|
446e82180b | ||
|
|
2eaddf0baf |
No files matched your search
@@ -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\)/);
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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': 'Команды /',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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': '快捷命令',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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': '快捷指令',
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
@@ -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(); }
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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
@@ -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();
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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}`}
|
||||
|
||||
@@ -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'));
|
||||
});
|
||||
}
|
||||
@@ -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()); }
|
||||
});
|
||||
@@ -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((
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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 |
@@ -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 |
@@ -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.
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
@@ -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);
|
||||
};
|
||||
@@ -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,
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
@@ -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';
|
||||
|
||||
@@ -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]);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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));
|
||||
});
|
||||
@@ -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 }),
|
||||
};
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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')));
|
||||
});
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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'));
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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
Reference in new issue
Block a user