mirror of
https://github.com/binaricat/Netcatty.git
synced 2026-11-10 16:21:22 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7451fed016 | ||
|
|
f2ec2a4842 | ||
|
|
a1fce3ad7c | ||
|
|
7db5a21e3e | ||
|
|
10f1c02c5c | ||
|
|
ffb4d94aec | ||
|
|
b818db5f0c | ||
|
|
ce1cdab559 | ||
|
|
d79643d31c | ||
|
|
4bb8a224b6 | ||
|
|
8788803fb5 | ||
|
|
a7342ff7b8 | ||
|
|
e5f2519433 | ||
|
|
6e56fe5a8d | ||
|
|
2c35040e79 | ||
|
|
55ceaa3290 | ||
|
|
74abfe28ef | ||
|
|
d92406a9fd | ||
|
|
ccacb69bb9 | ||
|
|
7c419f41de | ||
|
|
151364ce4b | ||
|
|
e6df190b98 | ||
|
|
e5c750398d | ||
|
|
d4bfe02dc4 | ||
|
|
ee47c9e15c | ||
|
|
0068180204 | ||
|
|
513b2d66af | ||
|
|
715487b2a0 | ||
|
|
8f34d2a31f | ||
|
|
fb034f08bb |
No files matched your search
@@ -197,6 +197,12 @@ export const enCoreMessages: Messages = {
|
||||
'settings.appLock.validation.mismatch': 'The new passwords do not match.',
|
||||
'settings.appLock.validation.incorrect': 'The current lock password is incorrect.',
|
||||
|
||||
// Settings > System > Auto Launch
|
||||
'settings.autoLaunch.title': 'Launch at Login',
|
||||
'settings.autoLaunch.enabled': 'Launch at system startup',
|
||||
'settings.autoLaunch.enabledDesc': 'Automatically start Netcatty when you log in, hidden in the system tray.',
|
||||
'settings.autoLaunch.unsupportedDesc': 'Not available on this platform or in a development build.',
|
||||
|
||||
// Settings > System > Network Proxy
|
||||
'settings.system.networkProxy.title': 'Network Proxy',
|
||||
'settings.system.networkProxy.description': 'HTTP(S) proxy for cloud sync and AI providers. This is separate from SSH ProxyJump / ProxyCommand profiles.',
|
||||
|
||||
@@ -117,6 +117,9 @@ export const enTerminalMessages: Messages = {
|
||||
'terminal.dragDrop.notConnected': 'Cannot drop files - terminal is not connected',
|
||||
'terminal.dragDrop.errorTitle': 'Drop Error',
|
||||
'terminal.dragDrop.errorMessage': 'Failed to process dropped files',
|
||||
'terminal.dragDrop.destinationUnknown': 'Could not determine the current terminal folder. Enable directory tracking, or open SFTP and choose an upload folder first.',
|
||||
'terminal.dragDrop.uploadCancelled': 'The upload was cancelled because the terminal connection changed or could not be reused. Drop the files again after reconnecting.',
|
||||
'terminal.dragDrop.needsSudoElevation': 'This folder is not writable as the login user. Enable Sudo elevation in host settings, or go back to your user directory and drop again.',
|
||||
'terminal.search.placeholder': 'Search...',
|
||||
'terminal.search.noResults': 'No results',
|
||||
'terminal.search.prevMatch': 'Previous match (Shift+Enter)',
|
||||
|
||||
@@ -117,6 +117,9 @@ export const esTerminalMessages: Messages = {
|
||||
'terminal.dragDrop.notConnected': 'No se pueden soltar archivos: la terminal no está conectada',
|
||||
'terminal.dragDrop.errorTitle': 'Error al soltar',
|
||||
'terminal.dragDrop.errorMessage': 'No se pudieron procesar los archivos soltados',
|
||||
'terminal.dragDrop.destinationUnknown': 'No se pudo determinar la carpeta actual del terminal. Active el seguimiento de directorios o abra SFTP y elija primero una carpeta de carga.',
|
||||
'terminal.dragDrop.uploadCancelled': 'La carga se canceló porque la conexión del terminal cambió o no se pudo reutilizar. Vuelva a soltar los archivos después de reconectar.',
|
||||
'terminal.dragDrop.needsSudoElevation': 'Esta carpeta no es escribible con el usuario de inicio de sesión. Active la elevación Sudo en la configuración del host, o vuelva al directorio de usuario y suelte de nuevo.',
|
||||
'terminal.search.placeholder': 'Buscar...',
|
||||
'terminal.search.noResults': 'Sin resultados',
|
||||
'terminal.search.prevMatch': 'Coincidencia anterior (Shift+Enter)',
|
||||
|
||||
@@ -196,6 +196,12 @@ export const ruCoreMessages: Messages = {
|
||||
'settings.appLock.validation.mismatch': 'Новые пароли не совпадают.',
|
||||
'settings.appLock.validation.incorrect': 'Текущий пароль блокировки неверен.',
|
||||
|
||||
// Settings > System > Auto Launch
|
||||
'settings.autoLaunch.title': 'Запуск при входе в систему',
|
||||
'settings.autoLaunch.enabled': 'Запускать при старте системы',
|
||||
'settings.autoLaunch.enabledDesc': 'Автоматически запускать Netcatty при входе в систему, свернутым в системный трей.',
|
||||
'settings.autoLaunch.unsupportedDesc': 'Недоступно на этой платформе или в сборке для разработки.',
|
||||
|
||||
// Settings > System > Network Proxy
|
||||
'settings.system.networkProxy.title': 'Сетевой прокси',
|
||||
'settings.system.networkProxy.description': 'HTTP(S)-прокси для облачной синхронизации и AI-провайдеров. Не связан с SSH ProxyJump / ProxyCommand.',
|
||||
|
||||
@@ -138,6 +138,9 @@ export const ruTerminalMessages: Messages = {
|
||||
'terminal.dragDrop.notConnected': 'Нельзя перетащить файлы — терминал не подключён',
|
||||
'terminal.dragDrop.errorTitle': 'Ошибка перетаскивания',
|
||||
'terminal.dragDrop.errorMessage': 'Не удалось обработать перетащенные файлы',
|
||||
'terminal.dragDrop.destinationUnknown': 'Не удалось определить текущую папку терминала. Включите отслеживание каталогов или сначала откройте SFTP и выберите папку загрузки.',
|
||||
'terminal.dragDrop.uploadCancelled': 'Загрузка отменена: соединение терминала изменилось или его не удалось повторно использовать. После переподключения перетащите файлы снова.',
|
||||
'terminal.dragDrop.needsSudoElevation': 'Эта папка недоступна для записи пользователю входа. Включите повышение прав Sudo в настройках хоста или вернитесь в домашний каталог и перетащите файлы снова.',
|
||||
'terminal.search.placeholder': 'Поиск...',
|
||||
'terminal.search.noResults': 'Ничего не найдено',
|
||||
'terminal.search.prevMatch': 'Предыдущее совпадение (Shift+Enter)',
|
||||
|
||||
@@ -194,6 +194,12 @@ export const zhCNCoreMessages: Messages = {
|
||||
'settings.appLock.validation.mismatch': '两次输入的新密码不一致。',
|
||||
'settings.appLock.validation.incorrect': '当前锁定密码不正确。',
|
||||
|
||||
// Settings > System > Auto Launch
|
||||
'settings.autoLaunch.title': '开机自启动',
|
||||
'settings.autoLaunch.enabled': '开机自动启动',
|
||||
'settings.autoLaunch.enabledDesc': '系统启动时自动运行 Netcatty,并隐藏到系统托盘。',
|
||||
'settings.autoLaunch.unsupportedDesc': '当前平台不支持此功能,或处于开发环境。',
|
||||
|
||||
// Settings > System > Network Proxy
|
||||
'settings.system.networkProxy.title': '网络代理',
|
||||
'settings.system.networkProxy.description': '用于云同步与 AI 服务商的 HTTP(S) 代理。与 SSH 的 ProxyJump / ProxyCommand 配置无关。',
|
||||
|
||||
@@ -519,6 +519,9 @@ export const zhCNVaultMessages: Messages = {
|
||||
'terminal.dragDrop.notConnected': '无法拖放文件 - 终端未连接',
|
||||
'terminal.dragDrop.errorTitle': '拖放错误',
|
||||
'terminal.dragDrop.errorMessage': '处理拖放文件失败',
|
||||
'terminal.dragDrop.destinationUnknown': '无法确定当前终端目录。请启用目录跟踪,或先打开 SFTP 选择上传目录。',
|
||||
'terminal.dragDrop.uploadCancelled': '终端连接已变化或无法继续复用,本次上传已取消。请重新连接后再次拖放文件。',
|
||||
'terminal.dragDrop.needsSudoElevation': '当前目录登录用户无法写入。请在主机设置中开启 Sudo 提权,或回到用户目录后再拖放。',
|
||||
'terminal.search.placeholder': '搜索…',
|
||||
'terminal.search.noResults': '无结果',
|
||||
'terminal.search.prevMatch': '上一个匹配 (Shift+Enter)',
|
||||
|
||||
@@ -197,6 +197,12 @@ export const zhTWCoreMessages: Messages = {
|
||||
'settings.appLock.validation.mismatch': '兩次輸入的新密碼不一致。',
|
||||
'settings.appLock.validation.incorrect': '目前的鎖定密碼不正確。',
|
||||
|
||||
// Settings > System > Auto Launch
|
||||
'settings.autoLaunch.title': '開機自動啟動',
|
||||
'settings.autoLaunch.enabled': '開機時自動啟動',
|
||||
'settings.autoLaunch.enabledDesc': '系統啟動時自動執行 Netcatty,並隱藏到系統匣。',
|
||||
'settings.autoLaunch.unsupportedDesc': '目前平台不支援此功能,或處於開發環境。',
|
||||
|
||||
// Settings > System > Network Proxy
|
||||
'settings.system.networkProxy.title': '網路代理',
|
||||
'settings.system.networkProxy.description': '用於雲端同步與 AI 服務商的 HTTP(S) 代理。與 SSH 的 ProxyJump / ProxyCommand 設定無關。',
|
||||
|
||||
@@ -519,6 +519,9 @@ export const zhTWVaultMessages: Messages = {
|
||||
'terminal.dragDrop.notConnected': '無法拖放檔案 - 終端未連線',
|
||||
'terminal.dragDrop.errorTitle': '拖放錯誤',
|
||||
'terminal.dragDrop.errorMessage': '處理拖放檔案失敗',
|
||||
'terminal.dragDrop.destinationUnknown': '無法確定目前終端目錄。請啟用目錄追蹤,或先開啟 SFTP 選擇上傳目錄。',
|
||||
'terminal.dragDrop.uploadCancelled': '終端連線已變更或無法繼續重用,本次上傳已取消。請重新連線後再次拖放檔案。',
|
||||
'terminal.dragDrop.needsSudoElevation': '目前目錄登入使用者無法寫入。請在主機設定中開啟 Sudo 提權,或回到使用者目錄後再拖放。',
|
||||
'terminal.search.placeholder': '搜尋…',
|
||||
'terminal.search.noResults': '無結果',
|
||||
'terminal.search.prevMatch': '上一個相符 (Shift+Enter)',
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import test from 'node:test';
|
||||
|
||||
/**
|
||||
* useSettingsIpcSync is a React hook (useEffect-driven); without
|
||||
* react-test-renderer available in this environment we can't mount it and
|
||||
* assert on live cross-window broadcast behavior. These structural checks
|
||||
* instead verify the source wiring end-to-end: every other window's
|
||||
* useSettingsState instance (mounted via AppLockGate, even though the
|
||||
* auto-launch toggle only ever renders in the Settings window) must consume
|
||||
* the STORAGE_KEY_AUTO_LAUNCH_ENABLED broadcast, matching the established
|
||||
* pattern used by every other synchronized boolean setting in this file.
|
||||
*/
|
||||
|
||||
test('settingsIpcSync handles the auto-launch broadcast key like other synced booleans', () => {
|
||||
const source = fs.readFileSync(new URL('./settingsIpcSync.ts', import.meta.url), 'utf8');
|
||||
|
||||
assert.match(
|
||||
source,
|
||||
/STORAGE_KEY_AUTO_LAUNCH_ENABLED/,
|
||||
'must import the storage key',
|
||||
);
|
||||
assert.match(
|
||||
source,
|
||||
/if \(key === STORAGE_KEY_AUTO_LAUNCH_ENABLED && typeof value === 'boolean'\) \{\s*[\s\S]*?setAutoLaunchEnabled\(/,
|
||||
'must apply the broadcast value to local state, same as e.g. STORAGE_KEY_GLOBAL_HOTKEY_ENABLED',
|
||||
);
|
||||
assert.match(
|
||||
source,
|
||||
/setAutoLaunchEnabled: Dispatch<SetStateAction<boolean>>/,
|
||||
'the setter prop must be declared on the params interface',
|
||||
);
|
||||
|
||||
// The dependency array of the effect must list the setter, or the
|
||||
// listener could close over a stale setter reference after a re-render.
|
||||
const effectStart = source.indexOf('useEffect(() => {');
|
||||
const depsStart = source.indexOf('}, [', effectStart);
|
||||
assert.ok(effectStart > -1 && depsStart > effectStart, 'expected to find the sync effect and its deps array');
|
||||
const depsSlice = source.slice(depsStart, source.indexOf(']);', depsStart));
|
||||
assert.match(depsSlice, /setAutoLaunchEnabled/, 'setAutoLaunchEnabled must be listed in the effect deps array');
|
||||
});
|
||||
|
||||
test('useSettingsState wires the real autoLaunchEnabled setter into useSettingsIpcSync (not a no-op)', () => {
|
||||
const source = fs.readFileSync(new URL('./useSettingsState.ts', import.meta.url), 'utf8');
|
||||
|
||||
const hookCallStart = source.indexOf('useSettingsIpcSync({');
|
||||
assert.notEqual(hookCallStart, -1, 'expected to find the useSettingsIpcSync(...) call');
|
||||
const hookCallEnd = source.indexOf('});', hookCallStart);
|
||||
const callSlice = source.slice(hookCallStart, hookCallEnd);
|
||||
|
||||
assert.match(
|
||||
callSlice,
|
||||
/setAutoLaunchEnabled,/,
|
||||
'useSettingsIpcSync must receive the real setAutoLaunchEnabled state setter, ' +
|
||||
'or a broadcast from another window silently does nothing in this one',
|
||||
);
|
||||
});
|
||||
@@ -4,6 +4,7 @@ import { parseCustomKeyBindingsStorageRecord } from '../../domain/customKeyBindi
|
||||
import { resolveSupportedLocale } from '../../infrastructure/config/i18n';
|
||||
import {
|
||||
STORAGE_KEY_ACCENT_MODE,
|
||||
STORAGE_KEY_AUTO_LAUNCH_ENABLED,
|
||||
STORAGE_KEY_AUTO_UPDATE_ENABLED,
|
||||
STORAGE_KEY_COLOR,
|
||||
STORAGE_KEY_CUSTOM_CSS,
|
||||
@@ -93,6 +94,7 @@ interface UseSettingsIpcSyncParams {
|
||||
setWindowOpacity: (raw: unknown) => void;
|
||||
setAppIconVariant: Dispatch<SetStateAction<AppIconVariant>>;
|
||||
setAutoUpdateEnabled: Dispatch<SetStateAction<boolean>>;
|
||||
setAutoLaunchEnabled: Dispatch<SetStateAction<boolean>>;
|
||||
setHttpNetworkProxy: Dispatch<SetStateAction<HttpNetworkProxySettings>>;
|
||||
setSftpAutoOpenSidebar: Dispatch<SetStateAction<boolean>>;
|
||||
setSftpFollowTerminalCwd: Dispatch<SetStateAction<boolean>>;
|
||||
@@ -138,6 +140,7 @@ export function useSettingsIpcSync({
|
||||
setWindowOpacity,
|
||||
setAppIconVariant,
|
||||
setAutoUpdateEnabled,
|
||||
setAutoLaunchEnabled,
|
||||
setHttpNetworkProxy,
|
||||
setSftpAutoOpenSidebar,
|
||||
setSftpFollowTerminalCwd,
|
||||
@@ -270,6 +273,13 @@ export function useSettingsIpcSync({
|
||||
if (key === STORAGE_KEY_AUTO_UPDATE_ENABLED && typeof value === 'boolean') {
|
||||
setAutoUpdateEnabled((prev) => (prev === value ? prev : value));
|
||||
}
|
||||
if (key === STORAGE_KEY_AUTO_LAUNCH_ENABLED && typeof value === 'boolean') {
|
||||
// Peer windows (main, tray panel, terminal popups) all mount
|
||||
// useSettingsState via AppLockGate even though the toggle is only
|
||||
// ever rendered in the Settings window — keep their in-memory copy
|
||||
// in sync so it's not stale if anything reads it later.
|
||||
setAutoLaunchEnabled((prev) => (prev === value ? prev : value));
|
||||
}
|
||||
if (key === STORAGE_KEY_HTTP_NETWORK_PROXY) {
|
||||
const next = normalizeHttpNetworkProxySettings(value);
|
||||
setHttpNetworkProxy((prev) => (
|
||||
@@ -330,6 +340,7 @@ export function useSettingsIpcSync({
|
||||
applyIncomingCustomKeyBindings,
|
||||
mergeIncomingTerminalSettings,
|
||||
setAutoUpdateEnabled,
|
||||
setAutoLaunchEnabled,
|
||||
setHttpNetworkProxy,
|
||||
setEditorWordWrapState,
|
||||
setFollowAppTerminalThemeState,
|
||||
|
||||
@@ -84,3 +84,49 @@ test("queued work stays paused until resumed and can be cancelled", async () =>
|
||||
await paused;
|
||||
assert.deepEqual(order, ["b1"]);
|
||||
});
|
||||
|
||||
test("enqueuing a large blocked batch does not repeatedly inspect the existing queue", async () => {
|
||||
const scheduler = createGlobalSftpTransferScheduler();
|
||||
let release: (() => void) | undefined;
|
||||
let limitReads = 0;
|
||||
const readLimit = () => { limitReads += 1; return 1; };
|
||||
const blocker = scheduler.run("panel", "active", ["host"], readLimit, () => (
|
||||
new Promise<void>((resolve) => { release = resolve; })
|
||||
));
|
||||
const count = 2_000;
|
||||
const completed: number[] = [];
|
||||
const jobs = Array.from({ length: count }, (_, index) => scheduler.run(
|
||||
"panel", `queued-${index}`, ["host"], readLimit,
|
||||
async () => { completed.push(index); },
|
||||
));
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
const readsWhileBlocked = limitReads;
|
||||
release?.();
|
||||
await Promise.all([blocker, ...jobs]);
|
||||
|
||||
assert.deepEqual(completed, Array.from({ length: count }, (_, index) => index));
|
||||
assert.ok(readsWhileBlocked <= count * 3,
|
||||
`a blocked batch should be inspected linearly, got ${readsWhileBlocked} limit reads for ${count} files`);
|
||||
});
|
||||
|
||||
test("large batches of immediately completed files yield to user input", async () => {
|
||||
const scheduler = createGlobalSftpTransferScheduler();
|
||||
let completed = 0;
|
||||
const count = 1_000;
|
||||
const inputTurn = new Promise<number>((resolve) => setTimeout(() => resolve(completed), 0));
|
||||
const jobs = Array.from({ length: count }, (_, index) => scheduler.run(
|
||||
"panel", `tiny-${index}`, ["host"], () => 2, async () => { completed += 1; },
|
||||
));
|
||||
const completedAtInput = await inputTurn;
|
||||
await Promise.all(jobs);
|
||||
assert.ok(completedAtInput < count, "input must run before the entire batch drains");
|
||||
assert.equal(completed, count);
|
||||
});
|
||||
|
||||
test("a synchronous job failure releases its slot for queued work", async () => {
|
||||
const scheduler = createGlobalSftpTransferScheduler();
|
||||
const failed = scheduler.run("panel", "failed", ["host"], () => 1, () => { throw new Error("read failed"); });
|
||||
const next = scheduler.run("panel", "next", ["host"], () => 1, async () => "completed");
|
||||
await assert.rejects(failed, /read failed/);
|
||||
assert.equal(await next, "completed");
|
||||
});
|
||||
@@ -44,6 +44,25 @@ export function createGlobalSftpTransferScheduler(): GlobalSftpTransferScheduler
|
||||
let lastOwnerId: string | null = null;
|
||||
let prioritySequence = 0;
|
||||
const pausedJobs = new Map<string, ScheduledJob<unknown>>();
|
||||
let pumpScheduled = false;
|
||||
let startsSinceYield = 0;
|
||||
|
||||
const schedulePump = () => {
|
||||
if (pumpScheduled || queue.length === 0) return;
|
||||
pumpScheduled = true;
|
||||
const run = () => {
|
||||
pumpScheduled = false;
|
||||
pump();
|
||||
};
|
||||
// Coalesce a discovery/enqueue burst into one scan. Periodically yield to
|
||||
// input/paint as well, including when many tiny jobs resolve immediately.
|
||||
if (startsSinceYield >= 64) {
|
||||
startsSinceYield = 0;
|
||||
setTimeout(run, 0);
|
||||
} else {
|
||||
queueMicrotask(run);
|
||||
}
|
||||
};
|
||||
|
||||
const normalizeResourceKeys = (keys: readonly string[]) => [...new Set(keys.length > 0 ? keys : ["local"])];
|
||||
const canRun = (job: ScheduledJob<unknown>) => {
|
||||
@@ -60,24 +79,31 @@ export function createGlobalSftpTransferScheduler(): GlobalSftpTransferScheduler
|
||||
|
||||
const pump = () => {
|
||||
while (queue.length > 0) {
|
||||
const runnable = queue.map((job, index) => ({ job, index })).filter(({ job }) => canRun(job));
|
||||
if (runnable.length === 0) return;
|
||||
const highestPriority = runnable.reduce((max, { job }) => Math.max(max, job.priority), 0);
|
||||
const prioritizedIndexes = queue
|
||||
.map((job, index) => ({ job, index }))
|
||||
.filter(({ job }) => job.priority === highestPriority && canRun(job));
|
||||
const alternate = lastOwnerId === null
|
||||
? undefined
|
||||
: prioritizedIndexes.find(({ job }) => job.ownerId !== lastOwnerId);
|
||||
const index = alternate?.index ?? prioritizedIndexes[0]?.index ?? 0;
|
||||
let index = -1;
|
||||
for (let candidateIndex = 0; candidateIndex < queue.length; candidateIndex += 1) {
|
||||
const candidate = queue[candidateIndex];
|
||||
if (!canRun(candidate)) continue;
|
||||
const selected = queue[index];
|
||||
if (!selected || candidate.priority > selected.priority || (
|
||||
candidate.priority === selected.priority
|
||||
&& selected.ownerId === lastOwnerId
|
||||
&& candidate.ownerId !== lastOwnerId
|
||||
)) index = candidateIndex;
|
||||
}
|
||||
if (index < 0) return;
|
||||
const [job] = queue.splice(index, 1);
|
||||
if (!job) return;
|
||||
adjustActive(job, 1);
|
||||
lastOwnerId = job.ownerId;
|
||||
void job.work().then(job.resolve, job.reject).finally(() => {
|
||||
startsSinceYield += 1;
|
||||
void (async () => job.work())().then(job.resolve, job.reject).finally(() => {
|
||||
adjustActive(job, -1);
|
||||
pump();
|
||||
schedulePump();
|
||||
});
|
||||
if (startsSinceYield >= 64) {
|
||||
schedulePump();
|
||||
return;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -85,7 +111,8 @@ export function createGlobalSftpTransferScheduler(): GlobalSftpTransferScheduler
|
||||
run<T>(ownerId: string, taskId: string, resourceKeys: readonly string[], readLimit: LimitReader, work: () => Promise<T>): Promise<T> {
|
||||
return new Promise<T>((resolve, reject) => {
|
||||
queue.push({ ownerId, taskId, resourceKeys: normalizeResourceKeys(resourceKeys), priority: 0, readLimit, work, resolve, reject } as ScheduledJob<unknown>);
|
||||
pump();
|
||||
if (queue.length === 1 && !pumpScheduled && startsSinceYield < 64) pump();
|
||||
else schedulePump();
|
||||
});
|
||||
},
|
||||
prioritize(taskId: string) {
|
||||
@@ -93,7 +120,7 @@ export function createGlobalSftpTransferScheduler(): GlobalSftpTransferScheduler
|
||||
if (!job) return;
|
||||
prioritySequence += 1;
|
||||
job.priority = prioritySequence;
|
||||
pump();
|
||||
schedulePump();
|
||||
},
|
||||
pause(taskId: string) {
|
||||
const index = queue.findIndex((job) => job.taskId === taskId);
|
||||
@@ -108,7 +135,7 @@ export function createGlobalSftpTransferScheduler(): GlobalSftpTransferScheduler
|
||||
if (!job) return false;
|
||||
pausedJobs.delete(taskId);
|
||||
queue.push(job);
|
||||
pump();
|
||||
schedulePump();
|
||||
return true;
|
||||
},
|
||||
cancel(taskId: string) {
|
||||
@@ -117,7 +144,7 @@ export function createGlobalSftpTransferScheduler(): GlobalSftpTransferScheduler
|
||||
if (!job) return false;
|
||||
pausedJobs.delete(taskId);
|
||||
job.reject(new Error("Transfer cancelled"));
|
||||
pump();
|
||||
schedulePump();
|
||||
return true;
|
||||
},
|
||||
};
|
||||
|
||||
@@ -102,6 +102,49 @@ test("assertUploadEndpointUnchanged allows same-host reconnect with new connecti
|
||||
));
|
||||
});
|
||||
|
||||
test("a strict upload pin rejects a same-endpoint connection replacement", () => {
|
||||
const map = new Map<string, string>([
|
||||
["conn-old", "key-a"],
|
||||
["conn-new", "key-a"],
|
||||
]);
|
||||
const expected = captureUploadEndpoint(
|
||||
pane({ id: "t", connectionId: "conn-old", hostId: "host-a" }).connection!,
|
||||
map,
|
||||
{ pinConnectionId: true },
|
||||
);
|
||||
assert.throws(() => assertUploadEndpointUnchanged(
|
||||
pane({ id: "t", connectionId: "conn-new", hostId: "host-a" }).connection!,
|
||||
expected,
|
||||
map,
|
||||
), /Upload target changed/);
|
||||
});
|
||||
|
||||
test("a slow strict upload probe cannot continue after its tab is rebound", async () => {
|
||||
const map = new Map<string, string>([
|
||||
["conn-old", "key-a"],
|
||||
["conn-new", "key-a"],
|
||||
]);
|
||||
let livePane = pane({ id: "t", connectionId: "conn-old", hostId: "host-a" });
|
||||
const expected = captureUploadEndpoint(livePane.connection!, map, {
|
||||
pinConnectionId: true,
|
||||
});
|
||||
let resolveProbe!: () => void;
|
||||
const probe = new Promise<void>((resolve) => { resolveProbe = resolve; });
|
||||
let uploadCreated = false;
|
||||
const start = async () => {
|
||||
await probe;
|
||||
assertUploadEndpointUnchanged(livePane.connection!, expected, map);
|
||||
uploadCreated = true;
|
||||
};
|
||||
|
||||
const pending = start();
|
||||
livePane = pane({ id: "t", connectionId: "conn-new", hostId: "host-a" });
|
||||
resolveProbe();
|
||||
|
||||
await assert.rejects(pending, /Upload target changed/);
|
||||
assert.equal(uploadCreated, false);
|
||||
});
|
||||
|
||||
test("carried endpoint pin rejects a retargeted tab mid multi-folder upload", () => {
|
||||
// Simulate paste-time pin for host-a; later folder call resolves tab on host-b.
|
||||
const mapAtPaste = new Map<string, string>([["conn-1", "key-a"]]);
|
||||
|
||||
@@ -4,11 +4,14 @@ export type UploadEndpointPin = {
|
||||
isLocal: boolean;
|
||||
hostId: string | null;
|
||||
cacheKey: string | null;
|
||||
/** When present, reconnecting the same tab is still a target change. */
|
||||
connectionId?: string;
|
||||
};
|
||||
|
||||
export function captureUploadEndpoint(
|
||||
connection: NonNullable<SftpPane["connection"]>,
|
||||
connectionCacheKeyMap: Map<string, string>,
|
||||
options?: { pinConnectionId?: boolean },
|
||||
): UploadEndpointPin {
|
||||
return {
|
||||
isLocal: connection.isLocal,
|
||||
@@ -16,6 +19,7 @@ export function captureUploadEndpoint(
|
||||
cacheKey: connection.isLocal
|
||||
? "local"
|
||||
: (connectionCacheKeyMap.get(connection.id) ?? null),
|
||||
...(options?.pinConnectionId ? { connectionId: connection.id } : undefined),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -24,6 +28,9 @@ export function assertUploadEndpointUnchanged(
|
||||
expected: UploadEndpointPin,
|
||||
connectionCacheKeyMap: Map<string, string>,
|
||||
): void {
|
||||
if (expected.connectionId && connection.id !== expected.connectionId) {
|
||||
throw new Error("Upload target changed before the transfer started");
|
||||
}
|
||||
if (connection.isLocal !== expected.isLocal) {
|
||||
throw new Error("Upload target changed before the transfer started");
|
||||
}
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test, { after } from "node:test";
|
||||
import React from "react";
|
||||
import { act, create, type ReactTestRenderer } from "react-test-renderer";
|
||||
|
||||
import { usePendingSftpUploadRebind } from "./usePendingSftpUploadRebind";
|
||||
|
||||
const actEnvironment = globalThis as typeof globalThis & {
|
||||
IS_REACT_ACT_ENVIRONMENT?: boolean;
|
||||
};
|
||||
const previousActEnvironment = actEnvironment.IS_REACT_ACT_ENVIRONMENT;
|
||||
actEnvironment.IS_REACT_ACT_ENVIRONMENT = true;
|
||||
after(() => { actEnvironment.IS_REACT_ACT_ENVIRONMENT = previousActEnvironment; });
|
||||
|
||||
test("a repeated drop can share a slow strict connect without losing completion", async () => {
|
||||
let resolveConnect!: () => void;
|
||||
const sharedConnect = new Promise<void>((resolve) => {
|
||||
resolveConnect = resolve;
|
||||
});
|
||||
let latest: ReturnType<typeof usePendingSftpUploadRebind> | null = null;
|
||||
let renderer: ReactTestRenderer | null = null;
|
||||
|
||||
function Probe() {
|
||||
latest = usePendingSftpUploadRebind();
|
||||
return null;
|
||||
}
|
||||
|
||||
await act(async () => {
|
||||
renderer = create(React.createElement(Probe));
|
||||
});
|
||||
|
||||
act(() => {
|
||||
latest!.start({
|
||||
requestId: "drop-1",
|
||||
previousConnectionId: "old-connection",
|
||||
connect: () => sharedConnect,
|
||||
});
|
||||
latest!.start({
|
||||
requestId: "drop-2",
|
||||
previousConnectionId: "connecting-connection",
|
||||
connect: () => sharedConnect,
|
||||
});
|
||||
latest!.bindTarget("drop-2", {
|
||||
tabId: "new-tab",
|
||||
connectionId: "new-connection",
|
||||
});
|
||||
});
|
||||
|
||||
assert.equal(latest!.startedRequestIdRef.current, "drop-2");
|
||||
assert.equal(latest!.settledRequestId, null);
|
||||
assert.deepEqual(latest!.barrierRef.current, {
|
||||
requestId: "drop-2",
|
||||
previousConnectionId: "connecting-connection",
|
||||
targetTabId: "new-tab",
|
||||
targetConnectionId: "new-connection",
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
resolveConnect();
|
||||
await sharedConnect;
|
||||
});
|
||||
|
||||
assert.equal(latest!.settledRequestId, "drop-2");
|
||||
act(() => renderer!.unmount());
|
||||
});
|
||||
@@ -0,0 +1,91 @@
|
||||
import { useCallback, useRef, useState } from "react";
|
||||
|
||||
export interface PendingSftpUploadRebindBarrier {
|
||||
requestId: string;
|
||||
previousConnectionId: string | null;
|
||||
targetTabId?: string;
|
||||
targetConnectionId?: string;
|
||||
}
|
||||
|
||||
export interface PendingSftpUploadRebindTarget {
|
||||
tabId: string;
|
||||
connectionId: string;
|
||||
}
|
||||
|
||||
interface StartPendingSftpUploadRebindParams {
|
||||
requestId: string;
|
||||
previousConnectionId: string | null;
|
||||
connect: () => Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Tracks the strict SFTP connect attempt owned by the latest terminal drop.
|
||||
* A later drop may share the same in-flight connect promise, so completion is
|
||||
* tracked by request generation instead of relying only on connection-id churn.
|
||||
*/
|
||||
export function usePendingSftpUploadRebind() {
|
||||
const startedRequestIdRef = useRef<string | null>(null);
|
||||
const barrierRef = useRef<PendingSftpUploadRebindBarrier | null>(null);
|
||||
const generationRef = useRef(0);
|
||||
const [settledRequestId, setSettledRequestId] = useState<string | null>(null);
|
||||
|
||||
const start = useCallback((params: StartPendingSftpUploadRebindParams) => {
|
||||
const generation = generationRef.current + 1;
|
||||
generationRef.current = generation;
|
||||
startedRequestIdRef.current = params.requestId;
|
||||
barrierRef.current = {
|
||||
requestId: params.requestId,
|
||||
previousConnectionId: params.previousConnectionId,
|
||||
};
|
||||
setSettledRequestId(null);
|
||||
|
||||
const settle = () => {
|
||||
if (
|
||||
generationRef.current === generation
|
||||
&& startedRequestIdRef.current === params.requestId
|
||||
) {
|
||||
setSettledRequestId(params.requestId);
|
||||
}
|
||||
};
|
||||
void params.connect().then(settle, settle);
|
||||
}, []);
|
||||
|
||||
const bindTarget = useCallback((
|
||||
requestId: string,
|
||||
target: PendingSftpUploadRebindTarget,
|
||||
) => {
|
||||
const barrier = barrierRef.current;
|
||||
if (
|
||||
!barrier
|
||||
|| barrier.requestId !== requestId
|
||||
|| startedRequestIdRef.current !== requestId
|
||||
) return;
|
||||
barrierRef.current = {
|
||||
...barrier,
|
||||
targetTabId: target.tabId,
|
||||
targetConnectionId: target.connectionId,
|
||||
};
|
||||
}, []);
|
||||
|
||||
const clearBarrier = useCallback((requestId?: string) => {
|
||||
if (requestId && barrierRef.current?.requestId !== requestId) return;
|
||||
barrierRef.current = null;
|
||||
}, []);
|
||||
|
||||
const reset = useCallback(() => {
|
||||
generationRef.current += 1;
|
||||
startedRequestIdRef.current = null;
|
||||
barrierRef.current = null;
|
||||
setSettledRequestId(null);
|
||||
}, []);
|
||||
|
||||
return {
|
||||
barrierRef,
|
||||
bindTarget,
|
||||
clearBarrier,
|
||||
reset,
|
||||
settledRequestId,
|
||||
start,
|
||||
startedRequestIdRef,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test, { after } from "node:test";
|
||||
import React, { Suspense } from "react";
|
||||
import { act, create, type ReactTestRenderer } from "react-test-renderer";
|
||||
|
||||
import { useSftpBrowseConnectionLifecycle } from "./useSftpBrowseConnectionLifecycle";
|
||||
|
||||
const actEnvironment = globalThis as typeof globalThis & {
|
||||
IS_REACT_ACT_ENVIRONMENT?: boolean;
|
||||
};
|
||||
const previousActEnvironment = actEnvironment.IS_REACT_ACT_ENVIRONMENT;
|
||||
actEnvironment.IS_REACT_ACT_ENVIRONMENT = true;
|
||||
after(() => { actEnvironment.IS_REACT_ACT_ENVIRONMENT = previousActEnvironment; });
|
||||
|
||||
test("an uncommitted hidden render cannot invalidate live browse connections", async () => {
|
||||
const never = new Promise<void>(() => {});
|
||||
let lifecycleRef: ReturnType<typeof useSftpBrowseConnectionLifecycle> | null = null;
|
||||
let renderer: ReactTestRenderer | null = null;
|
||||
|
||||
function Probe({ interactive }: { interactive: boolean }) {
|
||||
const nextRef = useSftpBrowseConnectionLifecycle(interactive);
|
||||
if (!interactive) throw never;
|
||||
lifecycleRef = nextRef;
|
||||
return null;
|
||||
}
|
||||
|
||||
await act(async () => {
|
||||
renderer = create(React.createElement(
|
||||
Suspense,
|
||||
{ fallback: null },
|
||||
React.createElement(Probe, { interactive: true }),
|
||||
));
|
||||
});
|
||||
assert.deepEqual(lifecycleRef!.current, { generation: 0, interactive: true });
|
||||
|
||||
await act(async () => {
|
||||
renderer!.update(React.createElement(
|
||||
Suspense,
|
||||
{ fallback: null },
|
||||
React.createElement(Probe, { interactive: false }),
|
||||
));
|
||||
});
|
||||
|
||||
assert.deepEqual(lifecycleRef!.current, { generation: 0, interactive: true });
|
||||
act(() => renderer!.unmount());
|
||||
});
|
||||
@@ -0,0 +1,26 @@
|
||||
import { useLayoutEffect, useRef } from "react";
|
||||
import type { MutableRefObject } from "react";
|
||||
|
||||
export interface SftpBrowseConnectionLifecycle {
|
||||
generation: number;
|
||||
interactive: boolean;
|
||||
}
|
||||
|
||||
export function useSftpBrowseConnectionLifecycle(
|
||||
interactive: boolean,
|
||||
): MutableRefObject<SftpBrowseConnectionLifecycle> {
|
||||
const lifecycleRef = useRef<SftpBrowseConnectionLifecycle>({
|
||||
generation: 0,
|
||||
interactive,
|
||||
});
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (lifecycleRef.current.interactive === interactive) return;
|
||||
lifecycleRef.current = {
|
||||
generation: lifecycleRef.current.generation + 1,
|
||||
interactive,
|
||||
};
|
||||
}, [interactive]);
|
||||
|
||||
return lifecycleRef;
|
||||
}
|
||||
@@ -3,20 +3,37 @@
|
||||
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import type { Host } from "../../../domain/models";
|
||||
import type { SftpPane } from "./types";
|
||||
|
||||
import {
|
||||
applyToLiveSftpTabSide,
|
||||
beginSftpTabConnectRequest,
|
||||
buildSftpConnectInFlightKey,
|
||||
buildSftpHomeDirCandidates,
|
||||
clearSftpConnectInFlightForTab,
|
||||
closeSftpTabLifecycle,
|
||||
runSftpConnectOnceByKey,
|
||||
createSftpConnectionId,
|
||||
createPinnedReconnectSideResolver,
|
||||
finishSftpTabConnectRequest,
|
||||
invalidateSftpTabConnectRequest,
|
||||
isSftpHostKeySessionCurrent,
|
||||
isSftpTabConnectRequestCurrent,
|
||||
openSftpConnectionOnce,
|
||||
openSftpWithSessionPreference,
|
||||
rejectHostKeyVerificationRequest,
|
||||
registerOpenedSftpSession,
|
||||
resolveSftpPaneEndpointKey,
|
||||
resolveSftpReconnectAttempt,
|
||||
takeSftpConnectionMetadataForClose,
|
||||
releaseSftpConnectionMetadata,
|
||||
resolvePinnedReconnectSide,
|
||||
resolveSftpReconnectOptions,
|
||||
resolveSftpReconnectSchedule,
|
||||
resolveSftpReconnectHost,
|
||||
runSftpTabDisconnectIfLatest,
|
||||
settleFailedSftpConnectIfCurrent,
|
||||
} from "./useSftpConnections.ts";
|
||||
|
||||
test("home dir candidates prefer user home then root", () => {
|
||||
@@ -54,6 +71,18 @@ test("runSftpConnectOnceByKey shares an in-flight connect for the same tab and e
|
||||
assert.equal(inFlight.size, 0);
|
||||
});
|
||||
|
||||
test("disconnect detaches only that tab's in-flight connects", () => {
|
||||
const pending = Promise.resolve();
|
||||
const inFlight = new Map<string, Promise<void>>([
|
||||
[`tab-a\u0000host-a\u0000`, pending],
|
||||
[`tab-b\u0000host-a\u0000`, pending],
|
||||
]);
|
||||
|
||||
clearSftpConnectInFlightForTab(inFlight, "tab-a");
|
||||
|
||||
assert.deepEqual([...inFlight.keys()], ["tab-b\u0000host-a\u0000"]);
|
||||
});
|
||||
|
||||
test("buildSftpConnectInFlightKey uses the allocated tab id for forced new tabs", () => {
|
||||
const first = buildSftpConnectInFlightKey({
|
||||
side: "left",
|
||||
@@ -75,6 +104,260 @@ test("buildSftpConnectInFlightKey uses the allocated tab id for forced new tabs"
|
||||
assert.notEqual(first, second);
|
||||
});
|
||||
|
||||
test("moving an in-flight reconnect does not create a second connection", () => {
|
||||
const base = {
|
||||
tabId: "moving-tab",
|
||||
targetConnectionKey: "host-key",
|
||||
sourceSessionId: "ssh-session-1",
|
||||
initialPath: "/home",
|
||||
};
|
||||
assert.equal(
|
||||
buildSftpConnectInFlightKey({ ...base, side: "left" }),
|
||||
buildSftpConnectInFlightKey({ ...base, side: "right" }),
|
||||
);
|
||||
});
|
||||
|
||||
test("the newest request for a moved tab wins after an older close finishes", () => {
|
||||
const requests = new Map<string, symbol>();
|
||||
const oldRequest = beginSftpTabConnectRequest(requests, "tab-moving");
|
||||
const newRequest = beginSftpTabConnectRequest(requests, "tab-moving");
|
||||
|
||||
assert.equal(isSftpTabConnectRequestCurrent(requests, "tab-moving", oldRequest), false);
|
||||
assert.equal(isSftpTabConnectRequestCurrent(requests, "tab-moving", newRequest), true);
|
||||
|
||||
finishSftpTabConnectRequest(requests, "tab-moving", oldRequest);
|
||||
assert.equal(isSftpTabConnectRequestCurrent(requests, "tab-moving", newRequest), true);
|
||||
|
||||
invalidateSftpTabConnectRequest(requests, "tab-moving");
|
||||
assert.equal(isSftpTabConnectRequestCurrent(requests, "tab-moving", newRequest), false);
|
||||
});
|
||||
|
||||
test("a slow disconnect cannot clear a newer connection on the same tab", async () => {
|
||||
const requests = new Map<string, symbol>();
|
||||
let releaseClose: (() => void) | undefined;
|
||||
let clears = 0;
|
||||
const disconnect = runSftpTabDisconnectIfLatest({
|
||||
requests,
|
||||
tabId: "tab-a",
|
||||
disconnect: async () => new Promise<void>((resolve) => {
|
||||
releaseClose = resolve;
|
||||
}),
|
||||
clear: () => {
|
||||
clears += 1;
|
||||
},
|
||||
});
|
||||
|
||||
await Promise.resolve();
|
||||
const newerConnect = beginSftpTabConnectRequest(requests, "tab-a");
|
||||
finishSftpTabConnectRequest(requests, "tab-a", newerConnect);
|
||||
releaseClose?.();
|
||||
|
||||
assert.equal(await disconnect, false);
|
||||
assert.equal(clears, 0);
|
||||
});
|
||||
|
||||
test("a slow disconnect clears a tab after it moves to the other pane", async () => {
|
||||
const requests = new Map<string, symbol>();
|
||||
let leftTabs: ReadonlyArray<{ id: string }> = [{ id: "tab-moving" }];
|
||||
let rightTabs: ReadonlyArray<{ id: string }> = [];
|
||||
let releaseClose: (() => void) | undefined;
|
||||
let clearedSide: "left" | "right" | null = null;
|
||||
const disconnect = runSftpTabDisconnectIfLatest({
|
||||
requests,
|
||||
tabId: "tab-moving",
|
||||
disconnect: async () => new Promise<void>((resolve) => {
|
||||
releaseClose = resolve;
|
||||
}),
|
||||
clear: () => {
|
||||
applyToLiveSftpTabSide({
|
||||
requestedSide: "left",
|
||||
tabId: "tab-moving",
|
||||
leftTabs,
|
||||
rightTabs,
|
||||
apply: (side) => {
|
||||
clearedSide = side;
|
||||
},
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
await Promise.resolve();
|
||||
leftTabs = [];
|
||||
rightTabs = [{ id: "tab-moving" }];
|
||||
releaseClose?.();
|
||||
|
||||
assert.equal(await disconnect, true);
|
||||
assert.equal(clearedSide, "right");
|
||||
});
|
||||
|
||||
test("closing a tab removes it before a slow connection release", async () => {
|
||||
const requests = new Map<string, symbol>();
|
||||
beginSftpTabConnectRequest(requests, "tab-a");
|
||||
const pending = Promise.resolve();
|
||||
const inFlight = new Map<string, Promise<void>>([
|
||||
["tab-a\u0000host-a", pending],
|
||||
["tab-b\u0000host-b", pending],
|
||||
]);
|
||||
const connectedHosts = new Map<string, Host | "local">([["tab-a", "local"]]);
|
||||
let releaseClose: (() => void) | undefined;
|
||||
let closedSide: "left" | "right" | null = null;
|
||||
const closing = closeSftpTabLifecycle({
|
||||
requestedSide: "left",
|
||||
tabId: "tab-a",
|
||||
leftTabs: [{ id: "tab-a" }],
|
||||
rightTabs: [],
|
||||
connectRequests: requests,
|
||||
connectInFlight: inFlight,
|
||||
connectedHosts,
|
||||
closeTab: (side) => {
|
||||
closedSide = side;
|
||||
},
|
||||
releaseConnection: async () => new Promise<void>((resolve) => {
|
||||
releaseClose = resolve;
|
||||
}),
|
||||
});
|
||||
|
||||
assert.equal(closedSide, "left");
|
||||
assert.equal(requests.has("tab-a"), false);
|
||||
assert.deepEqual([...inFlight.keys()], ["tab-b\u0000host-b"]);
|
||||
assert.equal(connectedHosts.has("tab-a"), false);
|
||||
releaseClose?.();
|
||||
await closing;
|
||||
});
|
||||
|
||||
test("a slow failed-request cleanup cannot overwrite a newer successful connection", async () => {
|
||||
let current = true;
|
||||
let releaseClose: (() => void) | undefined;
|
||||
let failuresWritten = 0;
|
||||
const settlement = settleFailedSftpConnectIfCurrent({
|
||||
isCurrent: () => current,
|
||||
close: async () => new Promise<void>((resolve) => {
|
||||
releaseClose = resolve;
|
||||
}),
|
||||
updateFailure: () => {
|
||||
failuresWritten += 1;
|
||||
},
|
||||
});
|
||||
|
||||
await Promise.resolve();
|
||||
current = false;
|
||||
releaseClose?.();
|
||||
|
||||
assert.equal(await settlement, false);
|
||||
assert.equal(failuresWritten, 0);
|
||||
});
|
||||
|
||||
test("stale host-key prompts do not belong to a replacement connection", () => {
|
||||
const requests = new Map<string, symbol>();
|
||||
const oldToken = beginSftpTabConnectRequest(requests, "tab-a");
|
||||
const oldOwner = { tabId: "tab-a", connectRequestToken: oldToken };
|
||||
assert.equal(isSftpHostKeySessionCurrent(requests, oldOwner), true);
|
||||
|
||||
beginSftpTabConnectRequest(requests, "tab-a");
|
||||
assert.equal(isSftpHostKeySessionCurrent(requests, oldOwner), false);
|
||||
});
|
||||
|
||||
test("reconnect state only follows the endpoint that originally failed", () => {
|
||||
assert.equal(resolveSftpReconnectAttempt({
|
||||
isPinnedBackgroundReconnect: false,
|
||||
previousPaneReconnecting: true,
|
||||
previousConnectionKey: "host-a",
|
||||
targetConnectionKey: "host-a",
|
||||
}), true);
|
||||
assert.equal(resolveSftpReconnectAttempt({
|
||||
isPinnedBackgroundReconnect: false,
|
||||
previousPaneReconnecting: true,
|
||||
previousConnectionKey: "host-a",
|
||||
targetConnectionKey: "host-b",
|
||||
}), false);
|
||||
assert.equal(resolveSftpReconnectAttempt({
|
||||
isPinnedBackgroundReconnect: true,
|
||||
initialPath: "/srv/app",
|
||||
previousPaneReconnecting: false,
|
||||
previousConnectionKey: null,
|
||||
targetConnectionKey: "host-a",
|
||||
}), true);
|
||||
});
|
||||
|
||||
test("reconnect keeps its endpoint identity after released connection metadata is gone", () => {
|
||||
const hostA = {
|
||||
id: "host-a",
|
||||
label: "A",
|
||||
hostname: "a.example",
|
||||
port: 22,
|
||||
username: "alice",
|
||||
protocol: "ssh",
|
||||
} as Host;
|
||||
const hostB = {
|
||||
id: "host-b",
|
||||
label: "B",
|
||||
hostname: "b.example",
|
||||
port: 22,
|
||||
username: "bob",
|
||||
protocol: "ssh",
|
||||
} as Host;
|
||||
const pane = {
|
||||
id: "tab-a",
|
||||
reconnecting: true,
|
||||
connection: {
|
||||
id: "released-connection-a",
|
||||
hostId: hostA.id,
|
||||
hostLabel: hostA.label,
|
||||
isLocal: false,
|
||||
status: "disconnected",
|
||||
currentPath: "/srv/app",
|
||||
},
|
||||
} as SftpPane;
|
||||
|
||||
const previousConnectionKey = resolveSftpPaneEndpointKey({
|
||||
connection: pane.connection,
|
||||
cachedConnectionKey: null,
|
||||
connectedHost: hostA,
|
||||
});
|
||||
const hostAKey = resolveSftpPaneEndpointKey({
|
||||
connection: pane.connection,
|
||||
cachedConnectionKey: null,
|
||||
connectedHost: hostA,
|
||||
});
|
||||
const hostBKey = resolveSftpPaneEndpointKey({
|
||||
connection: {
|
||||
...pane.connection!,
|
||||
hostId: hostB.id,
|
||||
},
|
||||
cachedConnectionKey: null,
|
||||
connectedHost: hostB,
|
||||
});
|
||||
|
||||
assert.equal(resolveSftpReconnectAttempt({
|
||||
isPinnedBackgroundReconnect: false,
|
||||
previousPaneReconnecting: pane.reconnecting,
|
||||
previousConnectionKey,
|
||||
targetConnectionKey: hostAKey!,
|
||||
}), true);
|
||||
assert.equal(resolveSftpReconnectAttempt({
|
||||
isPinnedBackgroundReconnect: false,
|
||||
previousPaneReconnecting: pane.reconnecting,
|
||||
previousConnectionKey,
|
||||
targetConnectionKey: hostBKey!,
|
||||
}), false);
|
||||
});
|
||||
|
||||
test("forced terminal rebinds never merge into an older in-flight route", () => {
|
||||
const base = {
|
||||
side: "left" as const,
|
||||
tabId: "tab-1",
|
||||
targetConnectionKey: "host-key",
|
||||
initialPath: "/srv/app",
|
||||
};
|
||||
const oldRoute = buildSftpConnectInFlightKey(base);
|
||||
const newRouteDrop = buildSftpConnectInFlightKey({
|
||||
...base,
|
||||
connectRequestKey: "drop-route-b",
|
||||
});
|
||||
|
||||
assert.notEqual(oldRoute, newRouteDrop);
|
||||
});
|
||||
|
||||
const openOptions = {
|
||||
sessionId: "sftp-request-1",
|
||||
hostname: "192.168.9.138",
|
||||
@@ -127,6 +410,33 @@ test("openSftpWithSessionPreference falls back to normal SFTP when session reuse
|
||||
assert.deepEqual(calls, ["openForSession:ssh-session-1", "openSftp:sftp-request-1"]);
|
||||
});
|
||||
|
||||
test("strict source-session reuse never dials a different route after reuse fails", async () => {
|
||||
const calls: string[] = [];
|
||||
let receivedOptions: NetcattySSHOptions | undefined;
|
||||
await assert.rejects(
|
||||
openSftpWithSessionPreference({
|
||||
bridge: {
|
||||
openSftpForSession: async (sessionId: string, options?: NetcattySSHOptions) => {
|
||||
calls.push(`openForSession:${sessionId}`);
|
||||
receivedOptions = options;
|
||||
throw new Error("channel unavailable");
|
||||
},
|
||||
openSftp: async () => {
|
||||
calls.push("openSftp");
|
||||
return "fresh-sftp";
|
||||
},
|
||||
},
|
||||
sourceSessionId: "ssh-session-1",
|
||||
requireSourceSessionReuse: true,
|
||||
openOptions,
|
||||
}),
|
||||
/channel unavailable/,
|
||||
);
|
||||
|
||||
assert.deepEqual(calls, ["openForSession:ssh-session-1"]);
|
||||
assert.equal(receivedOptions?.requireExactSourceSession, true);
|
||||
});
|
||||
|
||||
test("openSftpWithSessionPreference tries session reuse for sudo SFTP before fresh auth", async () => {
|
||||
const calls: string[] = [];
|
||||
let passedOptions: NetcattySSHOptions | undefined;
|
||||
@@ -156,14 +466,6 @@ test("openSftpWithSessionPreference tries session reuse for sudo SFTP before fre
|
||||
assert.equal(passedOptions?.password, "sudo-pass");
|
||||
});
|
||||
|
||||
test("remote browse connect does not discard sourceSessionId for sudo hosts", () => {
|
||||
const source = readFileSync(new URL("./useSftpConnections.ts", import.meta.url), "utf8");
|
||||
assert.doesNotMatch(
|
||||
source,
|
||||
/host\.sftpSudo\s*\?\s*undefined\s*:\s*options\?\.sourceSessionId/,
|
||||
);
|
||||
});
|
||||
|
||||
test("openSftpWithSessionPreference opens normal SFTP without a source session", async () => {
|
||||
const calls: string[] = [];
|
||||
const sftpId = await openSftpWithSessionPreference({
|
||||
@@ -248,6 +550,51 @@ test("release metadata closes the backend before a failed connection is retained
|
||||
assert.equal(cacheKeys.size, 0);
|
||||
});
|
||||
|
||||
test("a connection that finishes after its owner unmounts is closed instead of registered", async () => {
|
||||
const sftpSessions = new Map<string, string>();
|
||||
const closed: string[] = [];
|
||||
const notified: string[] = [];
|
||||
const disposedRef = { current: true };
|
||||
|
||||
const registered = await registerOpenedSftpSession({
|
||||
disposedRef,
|
||||
connectionId: "connection-late",
|
||||
sftpId: "sftp-late",
|
||||
sftpSessions,
|
||||
closeSftp: async (sftpId) => { closed.push(sftpId); },
|
||||
onRemoteSessionClosed: (sftpId) => { notified.push(sftpId); },
|
||||
});
|
||||
|
||||
assert.equal(registered, false);
|
||||
assert.equal(sftpSessions.size, 0);
|
||||
assert.deepEqual(closed, ["sftp-late"]);
|
||||
assert.deepEqual(notified, ["sftp-late"]);
|
||||
});
|
||||
|
||||
test("a connection that finishes after browse parking is closed instead of registered", async () => {
|
||||
const sftpSessions = new Map<string, string>();
|
||||
const closed: string[] = [];
|
||||
const lifecycle = { generation: 1, interactive: true };
|
||||
const openedGeneration = lifecycle.generation;
|
||||
lifecycle.generation += 1;
|
||||
lifecycle.interactive = false;
|
||||
|
||||
const registered = await registerOpenedSftpSession({
|
||||
disposedRef: { current: false },
|
||||
canRegister: () => (
|
||||
lifecycle.interactive && lifecycle.generation === openedGeneration
|
||||
),
|
||||
connectionId: "connection-parked",
|
||||
sftpId: "sftp-parked",
|
||||
sftpSessions,
|
||||
closeSftp: async (sftpId) => { closed.push(sftpId); },
|
||||
});
|
||||
|
||||
assert.equal(registered, false);
|
||||
assert.equal(sftpSessions.size, 0);
|
||||
assert.deepEqual(closed, ["sftp-parked"]);
|
||||
});
|
||||
|
||||
test("releaseSftpConnectionMetadata notifies after a remote session closes", async () => {
|
||||
const sessions = new Map([["connection-1", "sftp-1"]]);
|
||||
const cacheKeys = new Map([["connection-1", "cache-1"]]);
|
||||
@@ -301,7 +648,7 @@ test("rejectHostKeyVerificationRequest rejects an orphaned verification", () =>
|
||||
assert.deepEqual(responses, [["hostkey-1", false, false]]);
|
||||
});
|
||||
|
||||
test("pinned reconnect side resolver follows moves across async boundaries", async () => {
|
||||
test("a newly allocated forced-connect tab follows moves across async boundaries", async () => {
|
||||
let leftTabs: ReadonlyArray<{ id: string }> = [{ id: "tab-1" }];
|
||||
let rightTabs: ReadonlyArray<{ id: string }> = [];
|
||||
const resolveSide = createPinnedReconnectSideResolver(
|
||||
@@ -322,3 +669,104 @@ test("pinned reconnect side resolver follows moves across async boundaries", asy
|
||||
rightTabs = [];
|
||||
assert.equal(resolveSide(), "right");
|
||||
});
|
||||
|
||||
test("a just-allocated connect tab uses its known side until React state commits", () => {
|
||||
let leftTabs: ReadonlyArray<{ id: string }> = [];
|
||||
let rightTabs: ReadonlyArray<{ id: string }> = [];
|
||||
const resolveSide = createPinnedReconnectSideResolver(
|
||||
"left",
|
||||
"new-tab",
|
||||
() => leftTabs,
|
||||
() => rightTabs,
|
||||
);
|
||||
|
||||
assert.equal(resolveSide(), "left");
|
||||
leftTabs = [{ id: "new-tab" }];
|
||||
assert.equal(resolveSide(), "left");
|
||||
leftTabs = [];
|
||||
rightTabs = [{ id: "new-tab" }];
|
||||
assert.equal(resolveSide(), "right");
|
||||
});
|
||||
|
||||
test("reconnect uses the active tab host instead of the side's previous host", () => {
|
||||
const hostA = {
|
||||
id: "host-a",
|
||||
label: "A",
|
||||
hostname: "a.example",
|
||||
port: 22,
|
||||
username: "alice",
|
||||
protocol: "ssh",
|
||||
} as Host;
|
||||
const hostB = {
|
||||
id: "host-b",
|
||||
label: "B",
|
||||
hostname: "b.example",
|
||||
port: 22,
|
||||
username: "bob",
|
||||
protocol: "ssh",
|
||||
} as Host;
|
||||
const activePane = {
|
||||
id: "tab-a-moved-right",
|
||||
connection: {
|
||||
id: "connection-a",
|
||||
hostId: "host-a",
|
||||
hostLabel: "A",
|
||||
isLocal: false,
|
||||
status: "disconnected",
|
||||
currentPath: "/home/alice",
|
||||
},
|
||||
} as SftpPane;
|
||||
|
||||
assert.equal(resolveSftpReconnectHost({
|
||||
pane: activePane,
|
||||
lastHost: hostB,
|
||||
connectedHostByTabId: new Map([[activePane.id, hostA]]),
|
||||
hosts: [hostA, hostB],
|
||||
}), hostA);
|
||||
});
|
||||
|
||||
test("a reconnecting tab moved to the other pane still schedules recovery", () => {
|
||||
const movedPane = {
|
||||
id: "tab-a",
|
||||
reconnecting: true,
|
||||
connection: {
|
||||
id: "connection-a",
|
||||
hostId: "host-a",
|
||||
hostLabel: "A",
|
||||
isLocal: false,
|
||||
status: "disconnected",
|
||||
currentPath: "/home/alice",
|
||||
},
|
||||
} as SftpPane;
|
||||
|
||||
assert.deepEqual(resolveSftpReconnectSchedule({
|
||||
requestedSide: "left",
|
||||
pane: movedPane,
|
||||
leftTabs: [],
|
||||
rightTabs: [movedPane],
|
||||
}), { side: "right", tabId: "tab-a" });
|
||||
});
|
||||
|
||||
test("automatic reconnect retries the source session then allows a fresh open", () => {
|
||||
const pane = {
|
||||
id: "tab-a",
|
||||
connection: {
|
||||
id: "connection-a",
|
||||
hostId: "host-a",
|
||||
hostLabel: "A",
|
||||
isLocal: false,
|
||||
status: "disconnected",
|
||||
currentPath: "/home/alice",
|
||||
sourceSessionId: "ssh-a",
|
||||
},
|
||||
} as SftpPane;
|
||||
|
||||
assert.deepEqual(resolveSftpReconnectOptions(pane), {
|
||||
tabId: "tab-a",
|
||||
sourceSessionId: "ssh-a",
|
||||
});
|
||||
assert.equal(
|
||||
"requireSourceSessionReuse" in resolveSftpReconnectOptions(pane),
|
||||
false,
|
||||
);
|
||||
});
|
||||
@@ -34,10 +34,16 @@ interface UseSftpConnectionsParams {
|
||||
navSeqRef: MutableRefObject<{ left: number; right: number }>;
|
||||
dirCacheRef: MutableRefObject<DirectoryListingCache>;
|
||||
sftpSessionsRef: MutableRefObject<Map<string, string>>;
|
||||
disposedRef: MutableRefObject<boolean>;
|
||||
browseConnectionLifecycleRef: MutableRefObject<{
|
||||
generation: number;
|
||||
interactive: boolean;
|
||||
}>;
|
||||
lastConnectedHostRef: MutableRefObject<{ left: Host | "local" | null; right: Host | "local" | null }>;
|
||||
connectionCacheKeyMapRef: MutableRefObject<Map<string, string>>;
|
||||
connectedHostByTabIdRef: MutableRefObject<Map<string, Host | "local">>;
|
||||
reconnectingRef: MutableRefObject<{ left: boolean; right: boolean }>;
|
||||
connectInFlightRef: MutableRefObject<Map<string, Promise<void>>>;
|
||||
connectRequestByTabIdRef: MutableRefObject<Map<string, symbol>>;
|
||||
makeCacheKey: (connectionId: string, path: string, encoding?: SftpFilenameEncoding) => string;
|
||||
clearCacheForConnection: (connectionId: string) => void;
|
||||
createEmptyPane: (id?: string, showHiddenFiles?: boolean) => SftpPane;
|
||||
@@ -54,6 +60,12 @@ export interface SftpConnectOptions {
|
||||
tabId?: string;
|
||||
onTabCreated?: (tabId: string) => void;
|
||||
sourceSessionId?: string;
|
||||
/** Fail instead of opening a new route when the requested terminal transport cannot be reused. */
|
||||
requireSourceSessionReuse?: boolean;
|
||||
/** Prevent a forced terminal-drop route rebind from sharing an older open. */
|
||||
connectRequestKey?: string;
|
||||
/** Identifies the exact tab and connection created for a route-bound request. */
|
||||
onConnectionCreated?: (target: { tabId: string; connectionId: string }) => void;
|
||||
}
|
||||
|
||||
type SftpOpenBridge = Pick<NetcattyBridge, "openSftp"> &
|
||||
@@ -62,6 +74,7 @@ type SftpOpenBridge = Pick<NetcattyBridge, "openSftp"> &
|
||||
interface OpenSftpWithSessionPreferenceParams {
|
||||
bridge: SftpOpenBridge | null | undefined;
|
||||
sourceSessionId?: string;
|
||||
requireSourceSessionReuse?: boolean;
|
||||
openOptions: NetcattySSHOptions;
|
||||
}
|
||||
|
||||
@@ -97,6 +110,30 @@ export async function releaseSftpConnectionMetadata(params: {
|
||||
params.onRemoteSessionClosed?.(sftpId);
|
||||
}
|
||||
|
||||
/** Register a completed open, or close it immediately if its owner unmounted. */
|
||||
export async function registerOpenedSftpSession(params: {
|
||||
disposedRef: MutableRefObject<boolean>;
|
||||
canRegister?: () => boolean;
|
||||
connectionId: string;
|
||||
sftpId: string;
|
||||
sftpSessions: Map<string, string>;
|
||||
closeSftp: (sftpId: string) => Promise<unknown>;
|
||||
onRemoteSessionClosed?: (sftpId: string) => void;
|
||||
}): Promise<boolean> {
|
||||
if (!params.disposedRef.current && (params.canRegister?.() ?? true)) {
|
||||
params.sftpSessions.set(params.connectionId, params.sftpId);
|
||||
return true;
|
||||
}
|
||||
|
||||
try {
|
||||
await params.closeSftp(params.sftpId);
|
||||
} catch {
|
||||
// Best-effort: backend owner cleanup remains the final safety net.
|
||||
}
|
||||
params.onRemoteSessionClosed?.(params.sftpId);
|
||||
return false;
|
||||
}
|
||||
|
||||
/** Hardcoded home-path candidates when SSH exec / listable realpath fail. */
|
||||
export function buildSftpHomeDirCandidates(username?: string | null): string[] {
|
||||
if (username === "root") return ["/root"];
|
||||
@@ -115,13 +152,20 @@ export function createSftpConnectionId(
|
||||
export async function openSftpWithSessionPreference({
|
||||
bridge,
|
||||
sourceSessionId,
|
||||
requireSourceSessionReuse = false,
|
||||
openOptions,
|
||||
}: OpenSftpWithSessionPreferenceParams): Promise<string> {
|
||||
if (!bridge?.openSftp) throw new Error("SFTP bridge unavailable");
|
||||
if (requireSourceSessionReuse && (!sourceSessionId || !bridge.openSftpForSession)) {
|
||||
throw new Error("The requested terminal connection is no longer available");
|
||||
}
|
||||
if (sourceSessionId && bridge.openSftpForSession) {
|
||||
try {
|
||||
return await bridge.openSftpForSession(sourceSessionId, openOptions);
|
||||
} catch {
|
||||
return await bridge.openSftpForSession(sourceSessionId, requireSourceSessionReuse
|
||||
? { ...openOptions, requireExactSourceSession: true }
|
||||
: openOptions);
|
||||
} catch (error) {
|
||||
if (requireSourceSessionReuse) throw error;
|
||||
// Fall through to the existing SFTP open path so users still get a usable
|
||||
// file browser when the live SSH transport cannot provide an SFTP channel.
|
||||
}
|
||||
@@ -161,12 +205,18 @@ export function createPinnedReconnectSideResolver(
|
||||
getLeftTabs: () => ReadonlyArray<{ id: string }>,
|
||||
getRightTabs: () => ReadonlyArray<{ id: string }>,
|
||||
): () => "left" | "right" {
|
||||
let lastResolvedSide = resolvePinnedReconnectSide(
|
||||
requestedSide,
|
||||
tabId,
|
||||
getLeftTabs(),
|
||||
getRightTabs(),
|
||||
);
|
||||
let lastResolvedSide = requestedSide;
|
||||
try {
|
||||
lastResolvedSide = resolvePinnedReconnectSide(
|
||||
requestedSide,
|
||||
tabId,
|
||||
getLeftTabs(),
|
||||
getRightTabs(),
|
||||
);
|
||||
} catch {
|
||||
// A newly allocated tab is not visible in React refs until the state update
|
||||
// commits. Its requested side is authoritative during that brief window.
|
||||
}
|
||||
return () => {
|
||||
try {
|
||||
lastResolvedSide = resolvePinnedReconnectSide(
|
||||
@@ -183,6 +233,202 @@ export function createPinnedReconnectSideResolver(
|
||||
};
|
||||
}
|
||||
|
||||
export function applyToLiveSftpTabSide(params: {
|
||||
requestedSide: "left" | "right";
|
||||
tabId: string;
|
||||
leftTabs: ReadonlyArray<{ id: string }>;
|
||||
rightTabs: ReadonlyArray<{ id: string }>;
|
||||
apply: (side: "left" | "right") => void;
|
||||
}): boolean {
|
||||
try {
|
||||
const liveSide = resolvePinnedReconnectSide(
|
||||
params.requestedSide,
|
||||
params.tabId,
|
||||
params.leftTabs,
|
||||
params.rightTabs,
|
||||
);
|
||||
params.apply(liveSide);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function resolveSftpReconnectSchedule(params: {
|
||||
requestedSide: "left" | "right";
|
||||
pane: SftpPane | null | undefined;
|
||||
leftTabs: ReadonlyArray<{ id: string }>;
|
||||
rightTabs: ReadonlyArray<{ id: string }>;
|
||||
}): { side: "left" | "right"; tabId: string } | null {
|
||||
if (!params.pane?.reconnecting) return null;
|
||||
try {
|
||||
return {
|
||||
side: resolvePinnedReconnectSide(
|
||||
params.requestedSide,
|
||||
params.pane.id,
|
||||
params.leftTabs,
|
||||
params.rightTabs,
|
||||
),
|
||||
tabId: params.pane.id,
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Retry the last terminal transport, then fall through to a fresh/sudo open. */
|
||||
export function resolveSftpReconnectOptions(
|
||||
pane: SftpPane,
|
||||
): SftpConnectOptions {
|
||||
const sourceSessionId = pane.connection?.sourceSessionId;
|
||||
return {
|
||||
tabId: pane.id,
|
||||
...(sourceSessionId ? { sourceSessionId } : undefined),
|
||||
};
|
||||
}
|
||||
|
||||
export function resolveSftpReconnectAttempt(params: {
|
||||
isPinnedBackgroundReconnect: boolean;
|
||||
initialPath?: string;
|
||||
previousPaneReconnecting?: boolean;
|
||||
previousConnectionKey: string | null;
|
||||
targetConnectionKey: string;
|
||||
}): boolean {
|
||||
if (params.isPinnedBackgroundReconnect) return !!params.initialPath;
|
||||
return Boolean(
|
||||
params.previousPaneReconnecting
|
||||
&& params.previousConnectionKey === params.targetConnectionKey,
|
||||
);
|
||||
}
|
||||
|
||||
export function resolveSftpPaneEndpointKey(params: {
|
||||
connection: SftpConnection | null | undefined;
|
||||
cachedConnectionKey?: string | null;
|
||||
connectedHost?: Host | "local" | null;
|
||||
}): string | null {
|
||||
const { connection } = params;
|
||||
if (!connection) return null;
|
||||
if (connection.isLocal) return "local";
|
||||
if (params.cachedConnectionKey) return params.cachedConnectionKey;
|
||||
|
||||
const connectedHost = params.connectedHost;
|
||||
if (!connectedHost || connectedHost === "local" || connectedHost.id !== connection.hostId) {
|
||||
return null;
|
||||
}
|
||||
return buildCacheKey(
|
||||
connectedHost.id,
|
||||
connectedHost.hostname,
|
||||
connectedHost.port,
|
||||
connectedHost.protocol,
|
||||
connectedHost.sftpSudo,
|
||||
connectedHost.username,
|
||||
connectedHost.sftpFileProtocol,
|
||||
);
|
||||
}
|
||||
|
||||
export function beginSftpTabConnectRequest(
|
||||
requests: Map<string, symbol>,
|
||||
tabId: string,
|
||||
): symbol {
|
||||
const token = Symbol(tabId);
|
||||
requests.set(tabId, token);
|
||||
return token;
|
||||
}
|
||||
|
||||
export function isSftpTabConnectRequestCurrent(
|
||||
requests: ReadonlyMap<string, symbol>,
|
||||
tabId: string,
|
||||
token: symbol,
|
||||
): boolean {
|
||||
return requests.get(tabId) === token;
|
||||
}
|
||||
|
||||
export function finishSftpTabConnectRequest(
|
||||
requests: Map<string, symbol>,
|
||||
tabId: string,
|
||||
token: symbol,
|
||||
): void {
|
||||
if (isSftpTabConnectRequestCurrent(requests, tabId, token)) {
|
||||
requests.delete(tabId);
|
||||
}
|
||||
}
|
||||
|
||||
export function invalidateSftpTabConnectRequest(
|
||||
requests: Map<string, symbol>,
|
||||
tabId: string,
|
||||
): void {
|
||||
requests.delete(tabId);
|
||||
}
|
||||
|
||||
export function isSftpHostKeySessionCurrent(
|
||||
requests: ReadonlyMap<string, symbol>,
|
||||
owner: { tabId: string; connectRequestToken: symbol },
|
||||
): boolean {
|
||||
return isSftpTabConnectRequestCurrent(
|
||||
requests,
|
||||
owner.tabId,
|
||||
owner.connectRequestToken,
|
||||
);
|
||||
}
|
||||
|
||||
export async function settleFailedSftpConnectIfCurrent(params: {
|
||||
isCurrent: () => boolean;
|
||||
close: () => Promise<void>;
|
||||
updateFailure: () => void;
|
||||
}): Promise<boolean> {
|
||||
if (!params.isCurrent()) {
|
||||
await params.close();
|
||||
return false;
|
||||
}
|
||||
await params.close();
|
||||
if (!params.isCurrent()) return false;
|
||||
params.updateFailure();
|
||||
return true;
|
||||
}
|
||||
|
||||
export async function runSftpTabDisconnectIfLatest(params: {
|
||||
requests: Map<string, symbol>;
|
||||
tabId: string;
|
||||
disconnect: () => Promise<void>;
|
||||
clear: () => void;
|
||||
}): Promise<boolean> {
|
||||
const token = beginSftpTabConnectRequest(params.requests, params.tabId);
|
||||
try {
|
||||
await params.disconnect();
|
||||
if (!isSftpTabConnectRequestCurrent(params.requests, params.tabId, token)) {
|
||||
return false;
|
||||
}
|
||||
params.clear();
|
||||
return true;
|
||||
} finally {
|
||||
finishSftpTabConnectRequest(params.requests, params.tabId, token);
|
||||
}
|
||||
}
|
||||
|
||||
export async function closeSftpTabLifecycle(params: {
|
||||
requestedSide: "left" | "right";
|
||||
tabId: string;
|
||||
leftTabs: ReadonlyArray<{ id: string }>;
|
||||
rightTabs: ReadonlyArray<{ id: string }>;
|
||||
connectRequests: Map<string, symbol>;
|
||||
connectInFlight: Map<string, Promise<void>>;
|
||||
connectedHosts: { delete: (tabId: string) => boolean };
|
||||
closeTab: (side: "left" | "right") => void;
|
||||
releaseConnection: () => Promise<void>;
|
||||
}): Promise<void> {
|
||||
invalidateSftpTabConnectRequest(params.connectRequests, params.tabId);
|
||||
clearSftpConnectInFlightForTab(params.connectInFlight, params.tabId);
|
||||
params.connectedHosts.delete(params.tabId);
|
||||
applyToLiveSftpTabSide({
|
||||
requestedSide: params.requestedSide,
|
||||
tabId: params.tabId,
|
||||
leftTabs: params.leftTabs,
|
||||
rightTabs: params.rightTabs,
|
||||
apply: params.closeTab,
|
||||
});
|
||||
await params.releaseConnection();
|
||||
}
|
||||
|
||||
export function runSftpConnectOnceByKey(
|
||||
inFlight: Map<string, Promise<void>>,
|
||||
key: string,
|
||||
@@ -200,24 +446,68 @@ export function runSftpConnectOnceByKey(
|
||||
return promise;
|
||||
}
|
||||
|
||||
export function clearSftpConnectInFlightForTab(
|
||||
inFlight: Map<string, Promise<void>>,
|
||||
tabId: string,
|
||||
): void {
|
||||
const prefix = `${tabId}\u0000`;
|
||||
for (const key of inFlight.keys()) {
|
||||
if (key.startsWith(prefix)) inFlight.delete(key);
|
||||
}
|
||||
}
|
||||
|
||||
export function buildSftpConnectInFlightKey(params: {
|
||||
side: "left" | "right";
|
||||
tabId: string;
|
||||
targetConnectionKey: string;
|
||||
sourceSessionId?: string;
|
||||
requireSourceSessionReuse?: boolean;
|
||||
initialPath?: string;
|
||||
forceNewTab?: boolean;
|
||||
browseLifecycleGeneration?: number;
|
||||
connectRequestKey?: string;
|
||||
}): string {
|
||||
return [
|
||||
params.side,
|
||||
params.tabId,
|
||||
params.targetConnectionKey,
|
||||
params.sourceSessionId ?? "",
|
||||
params.requireSourceSessionReuse ? "strict-source" : "",
|
||||
params.initialPath ?? "",
|
||||
params.forceNewTab ? "force-new-tab" : "",
|
||||
params.browseLifecycleGeneration ?? 0,
|
||||
params.connectRequestKey ?? "",
|
||||
].join("\u0000");
|
||||
}
|
||||
|
||||
/** Resolve reconnect identity from the active tab before side-wide history. */
|
||||
export function resolveSftpReconnectHost(params: {
|
||||
pane: SftpPane | null | undefined;
|
||||
lastHost: Host | "local" | null;
|
||||
connectedHostByTabId: ReadonlyMap<string, Host | "local">;
|
||||
hosts: ReadonlyArray<Host>;
|
||||
}): Host | "local" | null {
|
||||
const connection = params.pane?.connection;
|
||||
if (!connection) return params.lastHost;
|
||||
|
||||
const tabHost = params.pane
|
||||
? params.connectedHostByTabId.get(params.pane.id) ?? null
|
||||
: null;
|
||||
if (connection.isLocal) {
|
||||
return tabHost === "local" ? tabHost : "local";
|
||||
}
|
||||
if (tabHost && tabHost !== "local" && tabHost.id === connection.hostId) {
|
||||
return tabHost;
|
||||
}
|
||||
const vaultHost = params.hosts.find((candidate) => candidate.id === connection.hostId) ?? null;
|
||||
if (vaultHost) return vaultHost;
|
||||
if (
|
||||
params.lastHost
|
||||
&& params.lastHost !== "local"
|
||||
&& params.lastHost.id === connection.hostId
|
||||
) return params.lastHost;
|
||||
return null;
|
||||
}
|
||||
|
||||
interface UseSftpConnectionsResult {
|
||||
connect: (side: "left" | "right", host: Host | "local", options?: SftpConnectOptions) => Promise<void>;
|
||||
disconnect: (side: "left" | "right") => Promise<void>;
|
||||
@@ -234,6 +524,19 @@ type HostKeyVerificationRequest = SftpHostKeyInfo & {
|
||||
sessionId?: string;
|
||||
};
|
||||
|
||||
type ActiveHostKeySession = {
|
||||
side: "left" | "right";
|
||||
tabId: string;
|
||||
connectRequestToken: symbol;
|
||||
};
|
||||
|
||||
type PendingHostKeyVerification = SftpHostKeyVerificationState & {
|
||||
requestId: string;
|
||||
sessionId: string;
|
||||
tabId: string;
|
||||
connectRequestToken: symbol;
|
||||
};
|
||||
|
||||
const toSftpHostKeyInfo = (request: HostKeyVerificationRequest): SftpHostKeyInfo => ({
|
||||
hostname: request.hostname,
|
||||
port: request.port || 22,
|
||||
@@ -271,10 +574,13 @@ export const useSftpConnections = ({
|
||||
navSeqRef,
|
||||
dirCacheRef,
|
||||
sftpSessionsRef,
|
||||
disposedRef,
|
||||
browseConnectionLifecycleRef,
|
||||
lastConnectedHostRef,
|
||||
connectionCacheKeyMapRef,
|
||||
connectedHostByTabIdRef,
|
||||
reconnectingRef,
|
||||
connectInFlightRef,
|
||||
connectRequestByTabIdRef,
|
||||
makeCacheKey,
|
||||
clearCacheForConnection,
|
||||
createEmptyPane,
|
||||
@@ -289,12 +595,11 @@ export const useSftpConnections = ({
|
||||
const getHostCredentials = useSftpHostCredentials({ hosts, keys, identities, knownHosts, terminalSettings });
|
||||
const { listLocalFiles, listRemoteFiles } = useSftpDirectoryListing();
|
||||
const [hostKeyVerification, setHostKeyVerification] = useState<SftpHostKeyVerificationState | null>(null);
|
||||
const hostKeyVerificationRef = useRef<(SftpHostKeyVerificationState & { requestId: string; sessionId: string }) | null>(null);
|
||||
const activeHostKeySessionsRef = useRef<Map<string, { side: "left" | "right"; tabId: string }>>(new Map());
|
||||
const connectInFlightRef = useRef<Map<string, Promise<void>>>(new Map());
|
||||
const hostKeyVerificationRef = useRef<PendingHostKeyVerification | null>(null);
|
||||
const activeHostKeySessionsRef = useRef<Map<string, ActiveHostKeySession>>(new Map());
|
||||
|
||||
const setPendingHostKeyVerification = useCallback((
|
||||
next: (SftpHostKeyVerificationState & { requestId: string; sessionId: string }) | null,
|
||||
next: PendingHostKeyVerification | null,
|
||||
) => {
|
||||
hostKeyVerificationRef.current = next;
|
||||
setHostKeyVerification(next ? {
|
||||
@@ -309,6 +614,10 @@ export const useSftpConnections = ({
|
||||
if (!sessionId) return;
|
||||
const activeSession = activeHostKeySessionsRef.current.get(sessionId);
|
||||
if (!activeSession) return;
|
||||
if (!isSftpHostKeySessionCurrent(connectRequestByTabIdRef.current, activeSession)) {
|
||||
rejectHostKeyVerificationRequest(netcattyBridge.get(), request.requestId);
|
||||
return;
|
||||
}
|
||||
|
||||
const hostKeyInfo = toSftpHostKeyInfo(request);
|
||||
const logLine = request.status === "changed"
|
||||
@@ -334,6 +643,8 @@ export const useSftpConnections = ({
|
||||
setPendingHostKeyVerification({
|
||||
requestId: request.requestId,
|
||||
sessionId,
|
||||
tabId: activeSession.tabId,
|
||||
connectRequestToken: activeSession.connectRequestToken,
|
||||
hostKeyInfo,
|
||||
progressLogs: [logLine],
|
||||
});
|
||||
@@ -342,11 +653,17 @@ export const useSftpConnections = ({
|
||||
return () => {
|
||||
dispose?.();
|
||||
};
|
||||
}, [leftTabsRef, rightTabsRef, setPendingHostKeyVerification, updateTab]);
|
||||
}, [connectRequestByTabIdRef, leftTabsRef, rightTabsRef, setPendingHostKeyVerification, updateTab]);
|
||||
|
||||
const respondToHostKeyVerification = useCallback((accept: boolean, addToKnownHosts = false) => {
|
||||
const pending = hostKeyVerificationRef.current;
|
||||
if (!pending) return;
|
||||
const owner = activeHostKeySessionsRef.current.get(pending.sessionId);
|
||||
if (!owner || !isSftpHostKeySessionCurrent(connectRequestByTabIdRef.current, owner)) {
|
||||
rejectHostKeyVerificationRequest(netcattyBridge.get(), pending.requestId);
|
||||
setPendingHostKeyVerification(null);
|
||||
return;
|
||||
}
|
||||
if (accept && addToKnownHosts) {
|
||||
onAddKnownHost?.(createKnownHostFromSftpHostKeyInfo(pending.hostKeyInfo));
|
||||
}
|
||||
@@ -356,7 +673,7 @@ export const useSftpConnections = ({
|
||||
addToKnownHosts,
|
||||
);
|
||||
setPendingHostKeyVerification(null);
|
||||
}, [onAddKnownHost, setPendingHostKeyVerification]);
|
||||
}, [connectRequestByTabIdRef, onAddKnownHost, setPendingHostKeyVerification]);
|
||||
|
||||
const rejectHostKeyVerification = useCallback(() => {
|
||||
respondToHostKeyVerification(false);
|
||||
@@ -373,13 +690,13 @@ export const useSftpConnections = ({
|
||||
const connect = useCallback(
|
||||
async (requestedSide: "left" | "right", host: Host | "local", options?: SftpConnectOptions) => {
|
||||
// Follow pinned tabs that were dragged to the other side mid-reconnect.
|
||||
const resolveTargetSide = createPinnedReconnectSideResolver(
|
||||
const resolveRequestedSide = createPinnedReconnectSideResolver(
|
||||
requestedSide,
|
||||
options?.tabId,
|
||||
() => leftTabsRef.current.tabs,
|
||||
() => rightTabsRef.current.tabs,
|
||||
);
|
||||
const side = resolveTargetSide();
|
||||
const side = resolveRequestedSide();
|
||||
const setTabs = side === "left" ? setLeftTabs : setRightTabs;
|
||||
|
||||
let activeTabId: string | null = null;
|
||||
@@ -401,18 +718,21 @@ export const useSftpConnections = ({
|
||||
|
||||
if (!activeTabId) return;
|
||||
|
||||
// Once a tab has been selected or allocated, follow that exact tab if it
|
||||
// is dragged across panes while the asynchronous connect is in flight.
|
||||
const resolveTargetSide = createPinnedReconnectSideResolver(
|
||||
side,
|
||||
activeTabId,
|
||||
() => leftTabsRef.current.tabs,
|
||||
() => rightTabsRef.current.tabs,
|
||||
);
|
||||
|
||||
// Pinned reconnect of a non-active tab must not clobber the active tab's
|
||||
// lastConnectedHost / reconnecting recovery state on this side.
|
||||
const isPinnedBackgroundReconnect =
|
||||
!!options?.tabId
|
||||
&& !!sideTabs.activeTabId
|
||||
&& options.tabId !== sideTabs.activeTabId;
|
||||
const clearSideReconnecting = () => {
|
||||
if (!isPinnedBackgroundReconnect) {
|
||||
reconnectingRef.current[side] = false;
|
||||
}
|
||||
};
|
||||
|
||||
const getTargetPaneEarly = () => {
|
||||
const targetSide = resolveTargetSide();
|
||||
const tabs = targetSide === "left" ? leftTabsRef.current.tabs : rightTabsRef.current.tabs;
|
||||
@@ -426,13 +746,16 @@ export const useSftpConnections = ({
|
||||
// auto-reconnect can land back where the user was browsing instead of home.
|
||||
// Do not inherit path across endpoints (including same hostId with different
|
||||
// hostname/port/user) if a reconnect flag is still set while switching.
|
||||
const previousConnection = getTargetPaneEarly()?.connection;
|
||||
const previousPane = getTargetPaneEarly();
|
||||
const previousConnection = previousPane?.connection;
|
||||
const previousPath = previousConnection?.currentPath;
|
||||
const previousConnectionKey = !previousConnection
|
||||
? null
|
||||
: previousConnection.isLocal
|
||||
? "local"
|
||||
: (connectionCacheKeyMapRef.current.get(previousConnection.id) ?? null);
|
||||
const previousConnectionKey = resolveSftpPaneEndpointKey({
|
||||
connection: previousConnection,
|
||||
cachedConnectionKey: previousConnection
|
||||
? connectionCacheKeyMapRef.current.get(previousConnection.id)
|
||||
: null,
|
||||
connectedHost: connectedHostByTabIdRef.current.get(activeTabId) ?? null,
|
||||
});
|
||||
const targetConnectionKey = host === "local"
|
||||
? "local"
|
||||
: buildCacheKey(
|
||||
@@ -444,18 +767,14 @@ export const useSftpConnections = ({
|
||||
host.username,
|
||||
host.sftpFileProtocol,
|
||||
);
|
||||
if (
|
||||
!isPinnedBackgroundReconnect
|
||||
&& reconnectingRef.current[side]
|
||||
&& previousConnectionKey
|
||||
&& previousConnectionKey !== targetConnectionKey
|
||||
) {
|
||||
clearSideReconnecting();
|
||||
}
|
||||
// Background pin reconnects resume via options.initialPath, not the side-wide flag.
|
||||
const isReconnectAttempt = isPinnedBackgroundReconnect
|
||||
? !!options?.initialPath
|
||||
: reconnectingRef.current[side];
|
||||
// Reconnect state belongs to the exact tab, not the side it occupies.
|
||||
const isReconnectAttempt = resolveSftpReconnectAttempt({
|
||||
isPinnedBackgroundReconnect,
|
||||
initialPath: options?.initialPath,
|
||||
previousPaneReconnecting: previousPane?.reconnecting,
|
||||
previousConnectionKey,
|
||||
targetConnectionKey,
|
||||
});
|
||||
const sameEndpointReconnect =
|
||||
isReconnectAttempt
|
||||
&& !!previousPath
|
||||
@@ -474,12 +793,35 @@ export const useSftpConnections = ({
|
||||
tabId: activeTabId,
|
||||
targetConnectionKey,
|
||||
sourceSessionId: host === "local" ? undefined : options?.sourceSessionId,
|
||||
requireSourceSessionReuse: host === "local" ? false : options?.requireSourceSessionReuse,
|
||||
initialPath: effectiveInitialPath,
|
||||
forceNewTab: options?.forceNewTab,
|
||||
browseLifecycleGeneration: browseConnectionLifecycleRef.current.generation,
|
||||
connectRequestKey: options?.connectRequestKey,
|
||||
});
|
||||
|
||||
return runSftpConnectOnceByKey(connectInFlightRef.current, connectInFlightKey, async () => {
|
||||
const connectRequestToken = beginSftpTabConnectRequest(
|
||||
connectRequestByTabIdRef.current,
|
||||
activeTabId,
|
||||
);
|
||||
const isConnectRequestCurrent = () => isSftpTabConnectRequestCurrent(
|
||||
connectRequestByTabIdRef.current,
|
||||
activeTabId,
|
||||
connectRequestToken,
|
||||
);
|
||||
const pendingHostKey = hostKeyVerificationRef.current;
|
||||
if (
|
||||
pendingHostKey?.tabId === activeTabId
|
||||
&& pendingHostKey.connectRequestToken !== connectRequestToken
|
||||
) {
|
||||
rejectHostKeyVerificationRequest(netcattyBridge.get(), pendingHostKey.requestId);
|
||||
setPendingHostKeyVerification(null);
|
||||
}
|
||||
try {
|
||||
const browseLifecycleGeneration = browseConnectionLifecycleRef.current.generation;
|
||||
const connectionId = createSftpConnectionId(side);
|
||||
options?.onConnectionCreated?.({ tabId: activeTabId, connectionId });
|
||||
|
||||
navSeqRef.current[side] += 1;
|
||||
const connectRequestId = navSeqRef.current[side];
|
||||
@@ -489,12 +831,14 @@ export const useSftpConnections = ({
|
||||
return tabs.find((tab) => tab.id === activeTabId) ?? null;
|
||||
};
|
||||
const isTargetConnectionCurrent = () => {
|
||||
if (!isConnectRequestCurrent()) return false;
|
||||
const pane = getTargetPane();
|
||||
if (!pane) return false;
|
||||
if (pane.connection?.id === connectionId) return true;
|
||||
return !pane.connection && navSeqRef.current[side] === connectRequestId;
|
||||
};
|
||||
const isTargetConnectionAtPath = (path: string) => {
|
||||
if (!isConnectRequestCurrent()) return false;
|
||||
const connection = getTargetPane()?.connection;
|
||||
if (!connection) return navSeqRef.current[side] === connectRequestId;
|
||||
return connection?.id === connectionId && connection.currentPath === path;
|
||||
@@ -553,8 +897,17 @@ export const useSftpConnections = ({
|
||||
}
|
||||
}
|
||||
|
||||
if (!isConnectRequestCurrent()) {
|
||||
await closeSftpSessionForConnection();
|
||||
return;
|
||||
}
|
||||
|
||||
if (host === "local") {
|
||||
let homeDir = await netcattyBridge.get()?.getHomeDir?.();
|
||||
if (!isConnectRequestCurrent()) {
|
||||
await closeSftpSessionForConnection();
|
||||
return;
|
||||
}
|
||||
if (!homeDir) {
|
||||
const isWindows = navigator.platform.toLowerCase().includes("win");
|
||||
homeDir = isWindows ? "C:\\Users\\damao" : "/Users/damao";
|
||||
@@ -592,7 +945,6 @@ export const useSftpConnections = ({
|
||||
files,
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
clearSideReconnecting();
|
||||
updateTargetTab((prev) => ({
|
||||
...prev,
|
||||
files,
|
||||
@@ -601,7 +953,6 @@ export const useSftpConnections = ({
|
||||
}));
|
||||
} catch (err) {
|
||||
if (!isTargetConnectionAtPath(startPath)) return;
|
||||
clearSideReconnecting();
|
||||
updateTargetTab((prev) => ({
|
||||
...prev,
|
||||
error: err instanceof Error ? err.message : "Failed to list directory",
|
||||
@@ -627,6 +978,7 @@ export const useSftpConnections = ({
|
||||
);
|
||||
|
||||
const sourceSessionId = options?.sourceSessionId;
|
||||
const requireSourceSessionReuse = options?.requireSourceSessionReuse === true;
|
||||
|
||||
const connection: SftpConnection = {
|
||||
id: connectionId,
|
||||
@@ -640,6 +992,7 @@ export const useSftpConnections = ({
|
||||
// non-interactive (loading=true) with stale cached files visible —
|
||||
// no worse than the previous UX of always showing a spinner.
|
||||
reusedConnection: !!sourceSessionId,
|
||||
sourceSessionId: requireSourceSessionReuse ? sourceSessionId : undefined,
|
||||
fileProtocol: host.sftpFileProtocol ?? 'auto',
|
||||
};
|
||||
|
||||
@@ -650,16 +1003,20 @@ export const useSftpConnections = ({
|
||||
// The cached file list is shown as a preview, but the pane stays
|
||||
// non-interactive until the SFTP session is actually established.
|
||||
loading: true,
|
||||
reconnecting: prev.reconnecting,
|
||||
reconnecting: isReconnectAttempt,
|
||||
error: null,
|
||||
connectionLogs: [],
|
||||
files: prev.reconnecting ? prev.files : (sharedHostCache?.files ?? []),
|
||||
files: isReconnectAttempt ? prev.files : (sharedHostCache?.files ?? []),
|
||||
filenameEncoding, // Reset encoding for new connection
|
||||
}));
|
||||
|
||||
// Subscribe to SFTP connection progress events for auth logging
|
||||
const sftpSessionId = `sftp-${connectionId}`;
|
||||
activeHostKeySessionsRef.current.set(sftpSessionId, { side, tabId: activeTabId });
|
||||
activeHostKeySessionsRef.current.set(sftpSessionId, {
|
||||
side,
|
||||
tabId: activeTabId,
|
||||
connectRequestToken,
|
||||
});
|
||||
let unsubSftpProgress: (() => void) | undefined;
|
||||
const bridge = netcattyBridge.get();
|
||||
if (bridge?.onSftpConnectionProgress) {
|
||||
@@ -713,6 +1070,7 @@ export const useSftpConnections = ({
|
||||
const sftpId = await openSftpConnectionOnce({
|
||||
bridge,
|
||||
sourceSessionId,
|
||||
requireSourceSessionReuse,
|
||||
openOptions: {
|
||||
sessionId: sftpSessionId,
|
||||
...credentials,
|
||||
@@ -721,7 +1079,19 @@ export const useSftpConnections = ({
|
||||
|
||||
if (!sftpId) throw new Error("Failed to open SFTP session");
|
||||
|
||||
sftpSessionsRef.current.set(connectionId, sftpId);
|
||||
const registered = await registerOpenedSftpSession({
|
||||
disposedRef,
|
||||
canRegister: () => (
|
||||
browseConnectionLifecycleRef.current.interactive
|
||||
&& browseConnectionLifecycleRef.current.generation === browseLifecycleGeneration
|
||||
),
|
||||
connectionId,
|
||||
sftpId,
|
||||
sftpSessions: sftpSessionsRef.current,
|
||||
closeSftp: async (openedSftpId) => bridge.closeSftp(openedSftpId),
|
||||
onRemoteSessionClosed: notifyRemoteSessionClosed,
|
||||
});
|
||||
if (!registered) return;
|
||||
if (!isTargetConnectionCurrent()) {
|
||||
await closeSftpSessionForConnection();
|
||||
return;
|
||||
@@ -863,8 +1233,6 @@ export const useSftpConnections = ({
|
||||
filenameEncoding,
|
||||
});
|
||||
|
||||
clearSideReconnecting();
|
||||
|
||||
updateTargetTab((prev) => ({
|
||||
...prev,
|
||||
connection: prev.connection
|
||||
@@ -882,31 +1250,29 @@ export const useSftpConnections = ({
|
||||
connectionLogs: [], // Clear after successful connect to avoid replay during navigation
|
||||
}));
|
||||
} catch (err) {
|
||||
if (!isTargetConnectionCurrent()) {
|
||||
await closeSftpSessionForConnection();
|
||||
return;
|
||||
}
|
||||
// A backend may already be open when initial directory discovery
|
||||
// fails. Never leave that handle mapped behind an error pane.
|
||||
await closeSftpSessionForConnection();
|
||||
clearSideReconnecting();
|
||||
updateTargetTab((prev) => ({
|
||||
...prev,
|
||||
connection: prev.connection
|
||||
? {
|
||||
...prev.connection,
|
||||
status: "error",
|
||||
error: err instanceof Error ? err.message : "Connection failed",
|
||||
}
|
||||
: null,
|
||||
files: isReconnectAttempt ? [] : prev.files,
|
||||
selectedFiles: isReconnectAttempt ? new Set<string>() : prev.selectedFiles,
|
||||
error: isReconnectAttempt
|
||||
? "sftp.error.reconnectFailed"
|
||||
: (err instanceof Error ? err.message : "Connection failed"),
|
||||
loading: false,
|
||||
reconnecting: false,
|
||||
}));
|
||||
await settleFailedSftpConnectIfCurrent({
|
||||
isCurrent: isTargetConnectionCurrent,
|
||||
close: closeSftpSessionForConnection,
|
||||
updateFailure: () => {
|
||||
updateTargetTab((prev) => ({
|
||||
...prev,
|
||||
connection: prev.connection
|
||||
? {
|
||||
...prev.connection,
|
||||
status: "error",
|
||||
error: err instanceof Error ? err.message : "Connection failed",
|
||||
}
|
||||
: null,
|
||||
files: isReconnectAttempt ? [] : prev.files,
|
||||
selectedFiles: isReconnectAttempt ? new Set<string>() : prev.selectedFiles,
|
||||
error: isReconnectAttempt
|
||||
? "sftp.error.reconnectFailed"
|
||||
: (err instanceof Error ? err.message : "Connection failed"),
|
||||
loading: false,
|
||||
reconnecting: false,
|
||||
}));
|
||||
},
|
||||
});
|
||||
} finally {
|
||||
activeHostKeySessionsRef.current.delete(sftpSessionId);
|
||||
if (hostKeyVerificationRef.current?.sessionId === sftpSessionId) {
|
||||
@@ -915,6 +1281,13 @@ export const useSftpConnections = ({
|
||||
unsubSftpProgress?.();
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
finishSftpTabConnectRequest(
|
||||
connectRequestByTabIdRef.current,
|
||||
activeTabId,
|
||||
connectRequestToken,
|
||||
);
|
||||
}
|
||||
});
|
||||
},
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
@@ -928,6 +1301,7 @@ export const useSftpConnections = ({
|
||||
listLocalFiles,
|
||||
listRemoteFiles,
|
||||
setPendingHostKeyVerification,
|
||||
browseConnectionLifecycleRef,
|
||||
],
|
||||
);
|
||||
|
||||
@@ -950,56 +1324,72 @@ export const useSftpConnections = ({
|
||||
useEffect(() => {
|
||||
const reconnectTimers: number[] = [];
|
||||
|
||||
/** Prefer lastConnectedHostRef; fall back to vault host via connection.hostId. */
|
||||
const resolveReconnectHost = (side: "left" | "right"): Host | "local" | null => {
|
||||
const lastHost = lastConnectedHostRef.current[side];
|
||||
if (lastHost) return lastHost;
|
||||
|
||||
const connection = getActivePane(side)?.connection;
|
||||
if (!connection) return null;
|
||||
if (connection.isLocal) {
|
||||
lastConnectedHostRef.current[side] = "local";
|
||||
return "local";
|
||||
}
|
||||
if (!connection.hostId) return null;
|
||||
const host = hosts.find((candidate) => candidate.id === connection.hostId) ?? null;
|
||||
const resolveReconnectHost = (
|
||||
side: "left" | "right",
|
||||
pane: SftpPane | null = getActivePane(side),
|
||||
): Host | "local" | null => {
|
||||
const host = resolveSftpReconnectHost({
|
||||
pane,
|
||||
lastHost: lastConnectedHostRef.current[side],
|
||||
connectedHostByTabId: connectedHostByTabIdRef.current,
|
||||
hosts,
|
||||
});
|
||||
if (host) {
|
||||
// Seed the ref so later refresh/session-error paths do not depend on tab races.
|
||||
// Keep legacy side-wide recovery state aligned with the active tab.
|
||||
lastConnectedHostRef.current[side] = host;
|
||||
}
|
||||
return host;
|
||||
};
|
||||
|
||||
const scheduleReconnect = (side: "left" | "right") => {
|
||||
if (!reconnectingRef.current[side]) return;
|
||||
const host = resolveReconnectHost(side);
|
||||
if (!host) return;
|
||||
const scheduleReconnect = (requestedSide: "left" | "right", pane: SftpPane | null) => {
|
||||
const schedule = resolveSftpReconnectSchedule({
|
||||
requestedSide,
|
||||
pane,
|
||||
leftTabs: leftTabsRef.current.tabs,
|
||||
rightTabs: rightTabsRef.current.tabs,
|
||||
});
|
||||
if (!schedule) return;
|
||||
|
||||
const timer = window.setTimeout(() => {
|
||||
if (!reconnectingRef.current[side]) return;
|
||||
void connect(side, host);
|
||||
const livePane = [
|
||||
...leftTabsRef.current.tabs,
|
||||
...rightTabsRef.current.tabs,
|
||||
].find((candidate) => candidate.id === schedule.tabId) ?? null;
|
||||
const liveSchedule = resolveSftpReconnectSchedule({
|
||||
requestedSide: schedule.side,
|
||||
pane: livePane,
|
||||
leftTabs: leftTabsRef.current.tabs,
|
||||
rightTabs: rightTabsRef.current.tabs,
|
||||
});
|
||||
if (!liveSchedule) return;
|
||||
const host = resolveReconnectHost(liveSchedule.side, livePane);
|
||||
if (!host) return;
|
||||
if (!livePane) return;
|
||||
void connect(
|
||||
liveSchedule.side,
|
||||
host,
|
||||
resolveSftpReconnectOptions(livePane),
|
||||
);
|
||||
}, 1000);
|
||||
reconnectTimers.push(timer);
|
||||
};
|
||||
|
||||
if (leftPane.reconnecting && reconnectingRef.current.left) {
|
||||
scheduleReconnect("left");
|
||||
}
|
||||
if (rightPane.reconnecting && reconnectingRef.current.right) {
|
||||
scheduleReconnect("right");
|
||||
}
|
||||
scheduleReconnect("left", leftPane);
|
||||
scheduleReconnect("right", rightPane);
|
||||
|
||||
return () => {
|
||||
reconnectTimers.forEach((timer) => window.clearTimeout(timer));
|
||||
};
|
||||
}, [
|
||||
leftPane.reconnecting,
|
||||
rightPane.reconnecting,
|
||||
leftPane,
|
||||
rightPane,
|
||||
leftTabsRef,
|
||||
rightTabsRef,
|
||||
connect,
|
||||
connectedHostByTabIdRef,
|
||||
getActivePane,
|
||||
hosts,
|
||||
lastConnectedHostRef,
|
||||
reconnectingRef,
|
||||
]);
|
||||
|
||||
const disconnect = useCallback(
|
||||
@@ -1010,33 +1400,51 @@ export const useSftpConnections = ({
|
||||
|
||||
if (!pane || !activeTabId) return;
|
||||
|
||||
navSeqRef.current[side] += 1;
|
||||
|
||||
const sftpId = pane.connection
|
||||
? takeSftpConnectionMetadataForClose({
|
||||
connectionId: pane.connection.id,
|
||||
sftpSessions: sftpSessionsRef.current,
|
||||
connectionCacheKeys: connectionCacheKeyMapRef.current,
|
||||
clearCacheForConnection,
|
||||
})
|
||||
: undefined;
|
||||
|
||||
reconnectingRef.current[side] = false;
|
||||
lastConnectedHostRef.current[side] = null;
|
||||
connectedHostByTabIdRef.current.delete(activeTabId);
|
||||
|
||||
if (pane.connection && !pane.connection.isLocal) {
|
||||
if (sftpId) {
|
||||
try {
|
||||
await netcattyBridge.get()?.closeSftp(sftpId);
|
||||
} catch {
|
||||
// Ignore errors when closing SFTP session during disconnect
|
||||
await runSftpTabDisconnectIfLatest({
|
||||
requests: connectRequestByTabIdRef.current,
|
||||
tabId: activeTabId,
|
||||
disconnect: async () => {
|
||||
clearSftpConnectInFlightForTab(connectInFlightRef.current, activeTabId);
|
||||
const pendingHostKey = hostKeyVerificationRef.current;
|
||||
if (pendingHostKey?.tabId === activeTabId) {
|
||||
rejectHostKeyVerificationRequest(netcattyBridge.get(), pendingHostKey.requestId);
|
||||
setPendingHostKeyVerification(null);
|
||||
}
|
||||
notifyRemoteSessionClosed(sftpId);
|
||||
}
|
||||
}
|
||||
navSeqRef.current[side] += 1;
|
||||
|
||||
updateTab(side, activeTabId, () => createEmptyPane(activeTabId, pane.showHiddenFiles));
|
||||
const sftpId = pane.connection
|
||||
? takeSftpConnectionMetadataForClose({
|
||||
connectionId: pane.connection.id,
|
||||
sftpSessions: sftpSessionsRef.current,
|
||||
connectionCacheKeys: connectionCacheKeyMapRef.current,
|
||||
clearCacheForConnection,
|
||||
})
|
||||
: undefined;
|
||||
|
||||
lastConnectedHostRef.current[side] = null;
|
||||
connectedHostByTabIdRef.current.delete(activeTabId);
|
||||
|
||||
if (pane.connection && !pane.connection.isLocal && sftpId) {
|
||||
try {
|
||||
await netcattyBridge.get()?.closeSftp(sftpId);
|
||||
} catch {
|
||||
// Ignore errors when closing SFTP session during disconnect
|
||||
}
|
||||
notifyRemoteSessionClosed(sftpId);
|
||||
}
|
||||
},
|
||||
clear: () => {
|
||||
applyToLiveSftpTabSide({
|
||||
requestedSide: side,
|
||||
tabId: activeTabId,
|
||||
leftTabs: leftTabsRef.current.tabs,
|
||||
rightTabs: rightTabsRef.current.tabs,
|
||||
apply: (liveSide) => {
|
||||
updateTab(liveSide, activeTabId, () => createEmptyPane(activeTabId, pane.showHiddenFiles));
|
||||
},
|
||||
});
|
||||
},
|
||||
});
|
||||
},
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[getActivePane, clearCacheForConnection, updateTab],
|
||||
|
||||
@@ -1682,6 +1682,7 @@ export const useSftpExternalOperations = (
|
||||
connectionId?: string;
|
||||
tabId?: string;
|
||||
endpointPin?: UploadEndpointPin;
|
||||
strictConnectionPin?: boolean;
|
||||
},
|
||||
): Promise<UploadResult[]> => {
|
||||
// Pin before any await so tab switches cannot retarget the upload.
|
||||
@@ -1697,6 +1698,7 @@ export const useSftpExternalOperations = (
|
||||
const originatingEndpoint = options?.endpointPin ?? captureUploadEndpoint(
|
||||
originatingPane.connection,
|
||||
connectionCacheKeyMapRef.current,
|
||||
{ pinConnectionId: options?.strictConnectionPin },
|
||||
);
|
||||
assertUploadEndpointUnchanged(
|
||||
originatingPane.connection,
|
||||
|
||||
@@ -92,7 +92,14 @@ export interface SftpExternalOperationsResult {
|
||||
uploadExternalEntries: (
|
||||
side: "left" | "right",
|
||||
entries: DropEntry[],
|
||||
options?: { targetPath?: string; connectionId?: string; tabId?: string; endpointPin?: UploadEndpointPin },
|
||||
options?: {
|
||||
targetPath?: string;
|
||||
connectionId?: string;
|
||||
tabId?: string;
|
||||
endpointPin?: UploadEndpointPin;
|
||||
/** Fail closed if the selected tab is rebound before upload work starts. */
|
||||
strictConnectionPin?: boolean;
|
||||
},
|
||||
) => Promise<UploadResult[]>;
|
||||
cancelExternalUpload: (taskId?: string) => Promise<void>;
|
||||
selectApplication: () => Promise<{ path: string; name: string } | null>;
|
||||
|
||||
@@ -0,0 +1,554 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test, { after } from "node:test";
|
||||
import React from "react";
|
||||
import { act, create, type ReactTestRenderer } from "react-test-renderer";
|
||||
|
||||
import type { Host } from "../../../domain/models";
|
||||
import { useSftpFollowTerminalCwd } from "./useSftpFollowTerminalCwd";
|
||||
|
||||
const actEnvironment = globalThis as typeof globalThis & {
|
||||
IS_REACT_ACT_ENVIRONMENT?: boolean;
|
||||
};
|
||||
const previousActEnvironment = actEnvironment.IS_REACT_ACT_ENVIRONMENT;
|
||||
actEnvironment.IS_REACT_ACT_ENVIRONMENT = true;
|
||||
after(() => { actEnvironment.IS_REACT_ACT_ENVIRONMENT = previousActEnvironment; });
|
||||
|
||||
const host = {
|
||||
id: "host-1",
|
||||
label: "Host",
|
||||
hostname: "example.com",
|
||||
port: 22,
|
||||
username: "alice",
|
||||
protocol: "ssh",
|
||||
} as Host;
|
||||
|
||||
test("first-open follow uses a fresh terminal cwd instead of the cached home path", async () => {
|
||||
const requestedOptions: unknown[] = [];
|
||||
const navigatedPaths: string[] = [];
|
||||
const connection = {
|
||||
id: "conn-1",
|
||||
hostId: "host-1",
|
||||
currentPath: "/home/alice",
|
||||
status: "connected",
|
||||
isLocal: false,
|
||||
};
|
||||
const sftpRef = {
|
||||
current: {
|
||||
leftPane: { connection, loading: false },
|
||||
navigateTo: async (_side: "left", path: string, options?: { shouldApply?: () => boolean }) => {
|
||||
if (options?.shouldApply && !options.shouldApply()) return "aborted" as const;
|
||||
navigatedPaths.push(path);
|
||||
connection.currentPath = path;
|
||||
return "reached" as const;
|
||||
},
|
||||
},
|
||||
};
|
||||
let renderer: ReactTestRenderer | null = null;
|
||||
|
||||
function Probe() {
|
||||
useSftpFollowTerminalCwd({
|
||||
activeSessionId: "session-1",
|
||||
activeTerminalCwd: "/home/alice",
|
||||
canFollowTerminalCwd: true,
|
||||
connectionId: connection.id,
|
||||
connectionIsLocal: connection.isLocal,
|
||||
connectionLoading: false,
|
||||
connectionPath: connection.currentPath,
|
||||
connectionStatus: connection.status,
|
||||
effectiveFollowTerminalCwd: true,
|
||||
followTerminalCwdHost: host,
|
||||
hasActiveWork: false,
|
||||
isVisible: true,
|
||||
ownerPanelOpen: true,
|
||||
onGetTerminalCwd: async (options) => {
|
||||
requestedOptions.push(options);
|
||||
return "/root/releases";
|
||||
},
|
||||
onPendingFollowOverride: () => {},
|
||||
sftpRef,
|
||||
});
|
||||
return null;
|
||||
}
|
||||
|
||||
await act(async () => {
|
||||
renderer = create(React.createElement(Probe));
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
});
|
||||
|
||||
assert.deepEqual(requestedOptions, [{
|
||||
preferFreshBackend: true,
|
||||
allowRendererFallback: false,
|
||||
requireActiveShellCwd: true,
|
||||
}]);
|
||||
assert.deepEqual(navigatedPaths, ["/root/releases"]);
|
||||
|
||||
await act(async () => renderer?.unmount());
|
||||
});
|
||||
|
||||
test("first-open follow does not use a stale cached cwd while the fresh probe is pending", async () => {
|
||||
let resolveCwd: ((cwd: string) => void) | null = null;
|
||||
const navigatedPaths: string[] = [];
|
||||
const connection = {
|
||||
id: "conn-1",
|
||||
hostId: "host-1",
|
||||
currentPath: "/srv/manual",
|
||||
status: "connected",
|
||||
isLocal: false,
|
||||
};
|
||||
const sftpRef = {
|
||||
current: {
|
||||
leftPane: { connection, loading: false },
|
||||
navigateTo: async (_side: "left", path: string) => {
|
||||
navigatedPaths.push(path);
|
||||
connection.currentPath = path;
|
||||
return "reached" as const;
|
||||
},
|
||||
},
|
||||
};
|
||||
let renderer: ReactTestRenderer | null = null;
|
||||
|
||||
function Probe() {
|
||||
useSftpFollowTerminalCwd({
|
||||
activeSessionId: "session-1",
|
||||
activeTerminalCwd: "/home/alice",
|
||||
canFollowTerminalCwd: true,
|
||||
connectionId: connection.id,
|
||||
connectionIsLocal: connection.isLocal,
|
||||
connectionLoading: false,
|
||||
connectionPath: connection.currentPath,
|
||||
connectionStatus: connection.status,
|
||||
effectiveFollowTerminalCwd: true,
|
||||
followTerminalCwdHost: host,
|
||||
hasActiveWork: false,
|
||||
isVisible: true,
|
||||
ownerPanelOpen: true,
|
||||
onGetTerminalCwd: () => new Promise<string>((resolve) => { resolveCwd = resolve; }),
|
||||
onPendingFollowOverride: () => {},
|
||||
sftpRef,
|
||||
});
|
||||
return null;
|
||||
}
|
||||
|
||||
await act(async () => {
|
||||
renderer = create(React.createElement(Probe));
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
});
|
||||
assert.deepEqual(navigatedPaths, []);
|
||||
|
||||
await act(async () => {
|
||||
resolveCwd?.("/root/releases");
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
});
|
||||
assert.deepEqual(navigatedPaths, ["/root/releases"]);
|
||||
await act(async () => renderer?.unmount());
|
||||
});
|
||||
|
||||
test("an in-flight first-open probe cannot navigate after Mosh focus changes", async () => {
|
||||
let resolveCwd: ((cwd: string) => void) | null = null;
|
||||
const navigatedPaths: string[] = [];
|
||||
const connection = {
|
||||
id: "conn-1",
|
||||
hostId: "host-1",
|
||||
currentPath: "/home/alice",
|
||||
status: "connected",
|
||||
isLocal: false,
|
||||
};
|
||||
const sftpRef = {
|
||||
current: {
|
||||
leftPane: { connection, loading: false },
|
||||
navigateTo: async (_side: "left", path: string, options?: { shouldApply?: () => boolean }) => {
|
||||
if (options?.shouldApply && !options.shouldApply()) return "aborted" as const;
|
||||
navigatedPaths.push(path);
|
||||
return "reached" as const;
|
||||
},
|
||||
},
|
||||
};
|
||||
let focusedSessionId: string | null = "mosh-a";
|
||||
let renderer: ReactTestRenderer | null = null;
|
||||
const onGetTerminalCwd = () => new Promise<string>((resolve) => { resolveCwd = resolve; });
|
||||
|
||||
function Probe() {
|
||||
useSftpFollowTerminalCwd({
|
||||
activeSessionId: null,
|
||||
focusedSessionId,
|
||||
activeTerminalCwd: "/home/alice",
|
||||
canFollowTerminalCwd: true,
|
||||
connectionId: connection.id,
|
||||
connectionIsLocal: connection.isLocal,
|
||||
connectionLoading: false,
|
||||
connectionPath: connection.currentPath,
|
||||
connectionStatus: connection.status,
|
||||
effectiveFollowTerminalCwd: true,
|
||||
followTerminalCwdHost: host,
|
||||
hasActiveWork: false,
|
||||
isVisible: true,
|
||||
ownerPanelOpen: true,
|
||||
onGetTerminalCwd,
|
||||
onPendingFollowOverride: () => {},
|
||||
sftpRef,
|
||||
});
|
||||
return null;
|
||||
}
|
||||
|
||||
await act(async () => {
|
||||
renderer = create(React.createElement(Probe));
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
});
|
||||
assert.ok(resolveCwd);
|
||||
|
||||
focusedSessionId = "mosh-b";
|
||||
await act(async () => renderer?.update(React.createElement(Probe)));
|
||||
await act(async () => {
|
||||
resolveCwd?.("/root/from-mosh-a");
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
});
|
||||
|
||||
assert.deepEqual(navigatedPaths, []);
|
||||
await act(async () => renderer?.unmount());
|
||||
});
|
||||
|
||||
test("an in-flight first-open probe cannot move a pane after a hidden tab is restored", async () => {
|
||||
let resolveCwd: ((cwd: string) => void) | null = null;
|
||||
const navigatedPaths: string[] = [];
|
||||
const connection = {
|
||||
id: "conn-1",
|
||||
hostId: "host-1",
|
||||
currentPath: "/home/alice",
|
||||
status: "connected",
|
||||
isLocal: false,
|
||||
};
|
||||
const sftpRef = {
|
||||
current: {
|
||||
leftPane: { connection, loading: false },
|
||||
navigateTo: async (_side: "left", path: string) => {
|
||||
navigatedPaths.push(path);
|
||||
return "reached" as const;
|
||||
},
|
||||
},
|
||||
};
|
||||
let visible = true;
|
||||
let renderer: ReactTestRenderer | null = null;
|
||||
|
||||
function Probe() {
|
||||
useSftpFollowTerminalCwd({
|
||||
activeSessionId: "session-1",
|
||||
activeTerminalCwd: "/home/alice",
|
||||
canFollowTerminalCwd: true,
|
||||
connectionId: connection.id,
|
||||
connectionIsLocal: connection.isLocal,
|
||||
connectionLoading: false,
|
||||
connectionPath: connection.currentPath,
|
||||
connectionStatus: connection.status,
|
||||
effectiveFollowTerminalCwd: true,
|
||||
followTerminalCwdHost: host,
|
||||
hasActiveWork: false,
|
||||
isVisible: visible,
|
||||
ownerPanelOpen: true,
|
||||
onGetTerminalCwd: () => new Promise<string>((resolve) => { resolveCwd = resolve; }),
|
||||
onPendingFollowOverride: () => {},
|
||||
sftpRef,
|
||||
});
|
||||
return null;
|
||||
}
|
||||
|
||||
await act(async () => {
|
||||
renderer = create(React.createElement(Probe));
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
});
|
||||
assert.ok(resolveCwd);
|
||||
|
||||
visible = false;
|
||||
await act(async () => renderer?.update(React.createElement(Probe)));
|
||||
visible = true;
|
||||
await act(async () => renderer?.update(React.createElement(Probe)));
|
||||
await act(async () => {
|
||||
resolveCwd?.("/root/releases");
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
});
|
||||
|
||||
assert.deepEqual(navigatedPaths, []);
|
||||
await act(async () => renderer?.unmount());
|
||||
});
|
||||
|
||||
test("an in-flight first-open probe cannot overwrite a manually browsed path", async () => {
|
||||
let resolveCwd: ((cwd: string) => void) | null = null;
|
||||
const navigatedPaths: string[] = [];
|
||||
const connection = {
|
||||
id: "conn-1",
|
||||
hostId: "host-1",
|
||||
currentPath: "/home/alice",
|
||||
status: "connected",
|
||||
isLocal: false,
|
||||
};
|
||||
const sftpRef = {
|
||||
current: {
|
||||
leftPane: { connection, loading: false },
|
||||
navigateTo: async (_side: "left", path: string) => {
|
||||
navigatedPaths.push(path);
|
||||
return "reached" as const;
|
||||
},
|
||||
},
|
||||
};
|
||||
let renderer: ReactTestRenderer | null = null;
|
||||
|
||||
function Probe() {
|
||||
useSftpFollowTerminalCwd({
|
||||
activeSessionId: "session-1",
|
||||
activeTerminalCwd: "/home/alice",
|
||||
canFollowTerminalCwd: true,
|
||||
connectionId: connection.id,
|
||||
connectionIsLocal: connection.isLocal,
|
||||
connectionLoading: false,
|
||||
connectionPath: connection.currentPath,
|
||||
connectionStatus: connection.status,
|
||||
effectiveFollowTerminalCwd: true,
|
||||
followTerminalCwdHost: host,
|
||||
hasActiveWork: false,
|
||||
isVisible: true,
|
||||
ownerPanelOpen: true,
|
||||
onGetTerminalCwd: () => new Promise<string>((resolve) => { resolveCwd = resolve; }),
|
||||
onPendingFollowOverride: () => {},
|
||||
sftpRef,
|
||||
});
|
||||
return null;
|
||||
}
|
||||
|
||||
await act(async () => {
|
||||
renderer = create(React.createElement(Probe));
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
});
|
||||
assert.ok(resolveCwd);
|
||||
|
||||
connection.currentPath = "/srv/manual";
|
||||
await act(async () => {
|
||||
resolveCwd?.("/root/releases");
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
});
|
||||
|
||||
assert.deepEqual(navigatedPaths, []);
|
||||
await act(async () => renderer?.unmount());
|
||||
});
|
||||
|
||||
test("manual go-to-terminal-cwd cannot move a replacement connection", async () => {
|
||||
let resolveCwd: ((cwd: string) => void) | null = null;
|
||||
const navigatedPaths: string[] = [];
|
||||
let activeSessionId = "session-1";
|
||||
let connection = {
|
||||
id: "conn-1",
|
||||
hostId: "host-1",
|
||||
currentPath: "/home/alice",
|
||||
status: "connected",
|
||||
isLocal: false,
|
||||
};
|
||||
const sftpRef = {
|
||||
current: {
|
||||
leftPane: { connection, loading: false },
|
||||
navigateTo: async (_side: "left", path: string, options?: { shouldApply?: () => boolean }) => {
|
||||
if (options?.shouldApply && !options.shouldApply()) return "aborted" as const;
|
||||
navigatedPaths.push(path);
|
||||
return "reached" as const;
|
||||
},
|
||||
},
|
||||
};
|
||||
let actions: ReturnType<typeof useSftpFollowTerminalCwd> | null = null;
|
||||
let renderer: ReactTestRenderer | null = null;
|
||||
|
||||
function Probe() {
|
||||
actions = useSftpFollowTerminalCwd({
|
||||
activeSessionId,
|
||||
activeTerminalCwd: null,
|
||||
canFollowTerminalCwd: false,
|
||||
connectionId: connection.id,
|
||||
connectionIsLocal: connection.isLocal,
|
||||
connectionLoading: false,
|
||||
connectionPath: connection.currentPath,
|
||||
connectionStatus: connection.status,
|
||||
effectiveFollowTerminalCwd: false,
|
||||
followTerminalCwdHost: host,
|
||||
hasActiveWork: false,
|
||||
isVisible: true,
|
||||
ownerPanelOpen: true,
|
||||
onGetTerminalCwd: () => new Promise<string>((resolve) => { resolveCwd = resolve; }),
|
||||
onPendingFollowOverride: () => {},
|
||||
sftpRef,
|
||||
});
|
||||
return null;
|
||||
}
|
||||
|
||||
await act(async () => { renderer = create(React.createElement(Probe)); });
|
||||
assert.ok(actions);
|
||||
let navigationPromise: Promise<void> | undefined;
|
||||
await act(async () => {
|
||||
navigationPromise = actions?.handleGoToTerminalCwd();
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
});
|
||||
assert.ok(resolveCwd);
|
||||
|
||||
activeSessionId = "session-2";
|
||||
connection = { ...connection, id: "conn-2" };
|
||||
sftpRef.current.leftPane.connection = connection;
|
||||
await act(async () => renderer?.update(React.createElement(Probe)));
|
||||
await act(async () => {
|
||||
resolveCwd?.("/root/releases");
|
||||
await navigationPromise;
|
||||
});
|
||||
|
||||
assert.deepEqual(navigatedPaths, []);
|
||||
await act(async () => renderer?.unmount());
|
||||
});
|
||||
|
||||
test("only trusted live terminal cwd recovery resumes follow after initial probes are exhausted", async () => {
|
||||
const navigatedPaths: string[] = [];
|
||||
const connection = {
|
||||
id: "conn-1",
|
||||
hostId: "host-1",
|
||||
currentPath: "/srv/manual",
|
||||
status: "connected",
|
||||
isLocal: false,
|
||||
};
|
||||
const sftpRef = {
|
||||
current: {
|
||||
leftPane: { connection, loading: false },
|
||||
navigateTo: async (_side: "left", path: string) => {
|
||||
navigatedPaths.push(path);
|
||||
connection.currentPath = path;
|
||||
return "reached" as const;
|
||||
},
|
||||
},
|
||||
};
|
||||
let activeTerminalCwd = "/home/alice";
|
||||
let activeTerminalCwdTrusted = false;
|
||||
let probeCalls = 0;
|
||||
let renderer: ReactTestRenderer | null = null;
|
||||
|
||||
function Probe() {
|
||||
useSftpFollowTerminalCwd({
|
||||
activeSessionId: "session-1",
|
||||
activeTerminalCwd,
|
||||
activeTerminalCwdTrusted,
|
||||
canFollowTerminalCwd: true,
|
||||
connectionId: connection.id,
|
||||
connectionIsLocal: connection.isLocal,
|
||||
connectionLoading: false,
|
||||
connectionPath: connection.currentPath,
|
||||
connectionStatus: connection.status,
|
||||
effectiveFollowTerminalCwd: true,
|
||||
followTerminalCwdHost: host,
|
||||
hasActiveWork: false,
|
||||
isVisible: true,
|
||||
ownerPanelOpen: true,
|
||||
onGetTerminalCwd: async () => {
|
||||
probeCalls += 1;
|
||||
return null;
|
||||
},
|
||||
onPendingFollowOverride: () => {},
|
||||
sftpRef,
|
||||
});
|
||||
return null;
|
||||
}
|
||||
|
||||
await act(async () => {
|
||||
renderer = create(React.createElement(Probe));
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
});
|
||||
for (let attempt = 1; attempt < 3; attempt += 1) {
|
||||
await act(async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 300));
|
||||
});
|
||||
}
|
||||
assert.equal(probeCalls, 3);
|
||||
assert.deepEqual(navigatedPaths, []);
|
||||
|
||||
activeTerminalCwd = null;
|
||||
await act(async () => renderer?.update(React.createElement(Probe)));
|
||||
activeTerminalCwd = "/home/alice";
|
||||
await act(async () => renderer?.update(React.createElement(Probe)));
|
||||
assert.deepEqual(navigatedPaths, [], "an untrusted restored snapshot must stay blocked");
|
||||
|
||||
activeTerminalCwd = "/srv/new";
|
||||
activeTerminalCwdTrusted = true;
|
||||
await act(async () => {
|
||||
renderer?.update(React.createElement(Probe));
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
});
|
||||
|
||||
assert.deepEqual(navigatedPaths, ["/srv/new"]);
|
||||
await act(async () => renderer?.unmount());
|
||||
});
|
||||
|
||||
test("an in-flight follow probe cannot navigate after the focused session changes", async () => {
|
||||
const navigatedPaths: string[] = [];
|
||||
const connection = {
|
||||
id: "conn-1",
|
||||
hostId: "host-1",
|
||||
currentPath: "/home/alice",
|
||||
status: "connected",
|
||||
isLocal: false,
|
||||
};
|
||||
const sftpRef = {
|
||||
current: {
|
||||
leftPane: { connection, loading: false },
|
||||
navigateTo: async (_side: "left", path: string, options?: { shouldApply?: () => boolean }) => {
|
||||
if (options?.shouldApply && !options.shouldApply()) return "aborted" as const;
|
||||
navigatedPaths.push(path);
|
||||
connection.currentPath = path;
|
||||
return "reached" as const;
|
||||
},
|
||||
},
|
||||
};
|
||||
let activeSessionId = "session-a";
|
||||
let activeTerminalCwd: string | null = "/home/alice";
|
||||
let resolveCwd: ((cwd: string) => void) | null = null;
|
||||
let deferProbe = false;
|
||||
let renderer: ReactTestRenderer | null = null;
|
||||
const onGetTerminalCwd = () => {
|
||||
if (!deferProbe) return Promise.resolve("/root/session-a");
|
||||
return new Promise<string>((resolve) => { resolveCwd = resolve; });
|
||||
};
|
||||
|
||||
function Probe() {
|
||||
useSftpFollowTerminalCwd({
|
||||
activeSessionId,
|
||||
activeTerminalCwd,
|
||||
canFollowTerminalCwd: true,
|
||||
connectionId: connection.id,
|
||||
connectionIsLocal: connection.isLocal,
|
||||
connectionLoading: false,
|
||||
connectionPath: connection.currentPath,
|
||||
connectionStatus: connection.status,
|
||||
effectiveFollowTerminalCwd: true,
|
||||
followTerminalCwdHost: host,
|
||||
hasActiveWork: false,
|
||||
isVisible: true,
|
||||
ownerPanelOpen: true,
|
||||
onGetTerminalCwd,
|
||||
onPendingFollowOverride: () => {},
|
||||
sftpRef,
|
||||
});
|
||||
return null;
|
||||
}
|
||||
|
||||
await act(async () => {
|
||||
renderer = create(React.createElement(Probe));
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
});
|
||||
assert.deepEqual(navigatedPaths, ["/root/session-a"]);
|
||||
|
||||
deferProbe = true;
|
||||
activeTerminalCwd = null;
|
||||
await act(async () => {
|
||||
renderer?.update(React.createElement(Probe));
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
});
|
||||
assert.ok(resolveCwd);
|
||||
|
||||
activeSessionId = "session-b";
|
||||
await act(async () => renderer?.update(React.createElement(Probe)));
|
||||
await act(async () => {
|
||||
resolveCwd?.("/root/from-session-a");
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
});
|
||||
|
||||
assert.deepEqual(navigatedPaths, ["/root/session-a"]);
|
||||
await act(async () => renderer?.unmount());
|
||||
});
|
||||
@@ -0,0 +1,565 @@
|
||||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
type MutableRefObject,
|
||||
} from "react";
|
||||
|
||||
import {
|
||||
isFollowOriginStillCurrent,
|
||||
shouldApplyFollowTerminalCwdSyncResult,
|
||||
shouldClearBlockedFollowOnReach,
|
||||
shouldFollowTerminalCwdNavigate,
|
||||
shouldInvalidateFollowBookkeepingOnCwdChange,
|
||||
shouldLatchInitialFollowInterruption,
|
||||
shouldReleaseInitialFollowSyncAttempt,
|
||||
shouldResetInitialFollowTerminalCwdSync,
|
||||
type SftpFollowTerminalCwdBlock,
|
||||
} from "../../../domain/sftpFollowTerminalCwd";
|
||||
import type { Host } from "../../../types";
|
||||
import type { SftpNavigateOptions, SftpNavigateResult } from "./useSftpPaneActions";
|
||||
|
||||
type FollowConnection = {
|
||||
id: string;
|
||||
currentPath?: string | null;
|
||||
status: string;
|
||||
isLocal?: boolean;
|
||||
};
|
||||
|
||||
type SftpFollowApi = {
|
||||
leftPane: {
|
||||
connection?: FollowConnection | null;
|
||||
loading: boolean;
|
||||
};
|
||||
navigateTo: (
|
||||
side: "left" | "right",
|
||||
path: string,
|
||||
options?: SftpNavigateOptions,
|
||||
) => Promise<SftpNavigateResult>;
|
||||
};
|
||||
|
||||
type GetTerminalCwd = (options?: {
|
||||
preferFreshBackend?: boolean;
|
||||
allowRendererFallback?: boolean;
|
||||
requireActiveShellCwd?: boolean;
|
||||
}) => Promise<string | null>;
|
||||
|
||||
type InitialFollowConnection = {
|
||||
id: string;
|
||||
currentPath?: string | null;
|
||||
status: string;
|
||||
isLocal?: boolean;
|
||||
};
|
||||
|
||||
type InitialFollowSyncOptions = {
|
||||
expectedConnectionId: string;
|
||||
staleTerminalCwd?: string | null;
|
||||
getFreshTerminalCwd: () => Promise<string | null | undefined>;
|
||||
isEligible: () => boolean;
|
||||
getConnection: () => InitialFollowConnection | null | undefined;
|
||||
navigate: (
|
||||
cwd: string,
|
||||
shouldApply: () => boolean,
|
||||
) => Promise<"reached" | "failed" | "aborted" | "superseded">;
|
||||
setHandled: (value: SftpFollowTerminalCwdBlock) => void;
|
||||
setBlocked: (value: SftpFollowTerminalCwdBlock | null) => void;
|
||||
};
|
||||
|
||||
/** Run one guarded first-open sync. False means the caller may retry. */
|
||||
const runInitialFollowTerminalCwdSync = async ({
|
||||
expectedConnectionId,
|
||||
staleTerminalCwd,
|
||||
getFreshTerminalCwd,
|
||||
isEligible,
|
||||
getConnection,
|
||||
navigate,
|
||||
setHandled,
|
||||
setBlocked,
|
||||
}: InitialFollowSyncOptions): Promise<boolean> => {
|
||||
const cwd = await getFreshTerminalCwd();
|
||||
if (!cwd || !isEligible()) return false;
|
||||
|
||||
const live = getConnection();
|
||||
if (!live || live.id !== expectedConnectionId || live.status !== "connected" || live.isLocal) {
|
||||
return false;
|
||||
}
|
||||
|
||||
setHandled({
|
||||
connectionId: expectedConnectionId,
|
||||
terminalCwd: staleTerminalCwd && staleTerminalCwd !== cwd ? staleTerminalCwd : cwd,
|
||||
});
|
||||
if (live.currentPath === cwd) return true;
|
||||
|
||||
const navigateResult = await navigate(cwd, isEligible);
|
||||
if (!isEligible()) return false;
|
||||
const current = getConnection();
|
||||
if (!current || current.id !== expectedConnectionId || current.status !== "connected") {
|
||||
return false;
|
||||
}
|
||||
if (navigateResult === "failed") {
|
||||
setBlocked({ connectionId: expectedConnectionId, terminalCwd: cwd });
|
||||
return true;
|
||||
}
|
||||
if (navigateResult === "reached") {
|
||||
setBlocked(null);
|
||||
return true;
|
||||
}
|
||||
return navigateResult === "superseded";
|
||||
};
|
||||
|
||||
type UseSftpFollowTerminalCwdOptions = {
|
||||
activeSessionId?: string | null;
|
||||
/** Focused terminal including Mosh/ET, used when SSH reuse id is absent. */
|
||||
focusedSessionId?: string | null;
|
||||
activeTerminalCwd: string | null;
|
||||
activeTerminalCwdTrusted?: boolean;
|
||||
canFollowTerminalCwd: boolean;
|
||||
connectionId: string | null;
|
||||
connectionIsLocal?: boolean;
|
||||
connectionLoading: boolean;
|
||||
connectionPath: string | null;
|
||||
connectionStatus?: string;
|
||||
effectiveFollowTerminalCwd: boolean;
|
||||
followTerminalCwdHost: Host | null;
|
||||
hasActiveWork: boolean;
|
||||
isVisible: boolean;
|
||||
ownerPanelOpen: boolean;
|
||||
onGetTerminalCwd?: GetTerminalCwd;
|
||||
onPendingFollowOverride: (override: { hostId: string; value: boolean }) => void;
|
||||
onSftpFollowTerminalCwdChange?: (enabled: boolean, host?: Host | null) => void;
|
||||
sftpRef: MutableRefObject<SftpFollowApi>;
|
||||
};
|
||||
|
||||
export type SftpFollowTerminalCwdActions = {
|
||||
handleGoToTerminalCwd: () => Promise<void>;
|
||||
handleToggleFollowTerminalCwd: () => void;
|
||||
};
|
||||
|
||||
const MAX_INITIAL_FOLLOW_ATTEMPTS = 3;
|
||||
const INITIAL_FOLLOW_RETRY_DELAY_MS = 250;
|
||||
|
||||
/** Owns terminal-cwd follow state so the SFTP view remains presentation-only. */
|
||||
export function useSftpFollowTerminalCwd({
|
||||
activeSessionId,
|
||||
focusedSessionId = null,
|
||||
activeTerminalCwd,
|
||||
activeTerminalCwdTrusted = false,
|
||||
canFollowTerminalCwd,
|
||||
connectionId,
|
||||
connectionIsLocal,
|
||||
connectionLoading,
|
||||
connectionPath,
|
||||
connectionStatus,
|
||||
effectiveFollowTerminalCwd,
|
||||
followTerminalCwdHost,
|
||||
hasActiveWork,
|
||||
isVisible,
|
||||
ownerPanelOpen,
|
||||
onGetTerminalCwd,
|
||||
onPendingFollowOverride,
|
||||
onSftpFollowTerminalCwdChange,
|
||||
sftpRef,
|
||||
}: UseSftpFollowTerminalCwdOptions): SftpFollowTerminalCwdActions {
|
||||
const blockedFollowRef = useRef<SftpFollowTerminalCwdBlock | null>(null);
|
||||
const handledFollowRef = useRef<SftpFollowTerminalCwdBlock | null>(null);
|
||||
const followSyncGenerationRef = useRef(0);
|
||||
const effectiveFollowTerminalCwdRef = useRef(effectiveFollowTerminalCwd);
|
||||
const canFollowTerminalCwdRef = useRef(canFollowTerminalCwd);
|
||||
const activeTerminalCwdRef = useRef(activeTerminalCwd);
|
||||
const activeSessionIdRef = useRef(activeSessionId);
|
||||
const focusedSessionIdRef = useRef(focusedSessionId);
|
||||
const connectionIdRef = useRef(connectionId);
|
||||
const isVisibleRef = useRef(isVisible);
|
||||
const ownerPanelOpenRef = useRef(ownerPanelOpen);
|
||||
const hasActiveWorkRef = useRef(hasActiveWork);
|
||||
const initialFollowReadyConnectionRef = useRef<string | null>(null);
|
||||
|
||||
effectiveFollowTerminalCwdRef.current = effectiveFollowTerminalCwd;
|
||||
canFollowTerminalCwdRef.current = canFollowTerminalCwd;
|
||||
activeTerminalCwdRef.current = activeTerminalCwd;
|
||||
activeSessionIdRef.current = activeSessionId;
|
||||
focusedSessionIdRef.current = focusedSessionId;
|
||||
connectionIdRef.current = connectionId;
|
||||
isVisibleRef.current = isVisible;
|
||||
ownerPanelOpenRef.current = ownerPanelOpen;
|
||||
hasActiveWorkRef.current = hasActiveWork;
|
||||
|
||||
const invalidateInFlightFollowSync = useCallback(() => {
|
||||
followSyncGenerationRef.current += 1;
|
||||
blockedFollowRef.current = null;
|
||||
handledFollowRef.current = null;
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
invalidateInFlightFollowSync();
|
||||
}, [connectionId, followTerminalCwdHost?.id, invalidateInFlightFollowSync]);
|
||||
|
||||
const lastLiveTerminalCwdRef = useRef<string | null>(activeTerminalCwd);
|
||||
const lastLiveTerminalCwdTrustedRef = useRef(activeTerminalCwdTrusted);
|
||||
useEffect(() => {
|
||||
const cwdInvalidates = shouldInvalidateFollowBookkeepingOnCwdChange({
|
||||
nextCwd: activeTerminalCwd,
|
||||
lastCwd: lastLiveTerminalCwdRef.current,
|
||||
isVisible,
|
||||
});
|
||||
const trustInvalidates = activeTerminalCwd !== null
|
||||
&& activeTerminalCwdTrusted !== lastLiveTerminalCwdTrustedRef.current;
|
||||
if (!cwdInvalidates && !trustInvalidates) return;
|
||||
lastLiveTerminalCwdRef.current = activeTerminalCwd;
|
||||
lastLiveTerminalCwdTrustedRef.current = activeTerminalCwdTrusted;
|
||||
if (activeTerminalCwd && activeTerminalCwdTrusted && connectionIdRef.current) {
|
||||
// A concrete live cwd is stronger evidence than the exhausted initial
|
||||
// backend probes. Let the ordinary follow path resume immediately.
|
||||
initialFollowReadyConnectionRef.current = connectionIdRef.current;
|
||||
}
|
||||
invalidateInFlightFollowSync();
|
||||
}, [activeTerminalCwd, activeTerminalCwdTrusted, invalidateInFlightFollowSync, isVisible]);
|
||||
|
||||
useEffect(() => {
|
||||
if (effectiveFollowTerminalCwd) return;
|
||||
invalidateInFlightFollowSync();
|
||||
}, [effectiveFollowTerminalCwd, invalidateInFlightFollowSync]);
|
||||
|
||||
useEffect(() => {
|
||||
const blockedFollow = blockedFollowRef.current;
|
||||
if (shouldClearBlockedFollowOnReach(
|
||||
blockedFollow,
|
||||
connectionId,
|
||||
connectionPath,
|
||||
connectionLoading,
|
||||
)) {
|
||||
blockedFollowRef.current = null;
|
||||
handledFollowRef.current = blockedFollow;
|
||||
}
|
||||
}, [connectionId, connectionLoading, connectionPath]);
|
||||
|
||||
const handleGoToTerminalCwd = useCallback(async () => {
|
||||
if (!onGetTerminalCwd) return;
|
||||
const expectedConnectionId = sftpRef.current.leftPane.connection?.id ?? null;
|
||||
const expectedSessionId = focusedSessionIdRef.current ?? activeSessionIdRef.current ?? null;
|
||||
const syncGeneration = followSyncGenerationRef.current;
|
||||
if (!expectedConnectionId) return;
|
||||
const shouldApply = () => (
|
||||
syncGeneration === followSyncGenerationRef.current
|
||||
&& isFollowOriginStillCurrent({
|
||||
expectedOriginId: expectedSessionId,
|
||||
liveOriginId: focusedSessionIdRef.current ?? activeSessionIdRef.current ?? null,
|
||||
})
|
||||
&& connectionIdRef.current === expectedConnectionId
|
||||
&& sftpRef.current.leftPane.connection?.id === expectedConnectionId
|
||||
&& sftpRef.current.leftPane.connection?.status === "connected"
|
||||
);
|
||||
const cwd = await onGetTerminalCwd({
|
||||
preferFreshBackend: true,
|
||||
allowRendererFallback: false,
|
||||
requireActiveShellCwd: true,
|
||||
});
|
||||
if (!cwd) return;
|
||||
if (!shouldApply()) return;
|
||||
const navigateResult = await sftpRef.current.navigateTo("left", cwd, { shouldApply });
|
||||
if (navigateResult !== "reached" || !shouldApply()) return;
|
||||
blockedFollowRef.current = null;
|
||||
const connection = sftpRef.current.leftPane.connection;
|
||||
if (connection?.id) {
|
||||
handledFollowRef.current = { connectionId: connection.id, terminalCwd: cwd };
|
||||
}
|
||||
}, [onGetTerminalCwd, sftpRef]);
|
||||
|
||||
const syncFollowToTerminalCwd = useCallback(async () => {
|
||||
if (!onGetTerminalCwd || !effectiveFollowTerminalCwd || !canFollowTerminalCwd) return;
|
||||
|
||||
const liveConnectionId = sftpRef.current.leftPane.connection?.id ?? null;
|
||||
if (!liveConnectionId || initialFollowReadyConnectionRef.current !== liveConnectionId) return;
|
||||
|
||||
const syncGeneration = followSyncGenerationRef.current;
|
||||
const expectedSessionId = focusedSessionIdRef.current ?? activeSessionIdRef.current ?? null;
|
||||
const expectedConnectionIdAtStart = connectionIdRef.current ?? liveConnectionId;
|
||||
const usesLiveTerminalCwd = Boolean(activeTerminalCwd && activeTerminalCwdTrusted);
|
||||
let terminalCwd = usesLiveTerminalCwd ? activeTerminalCwd : null;
|
||||
if (!terminalCwd) {
|
||||
terminalCwd = await onGetTerminalCwd({
|
||||
preferFreshBackend: true,
|
||||
allowRendererFallback: false,
|
||||
requireActiveShellCwd: true,
|
||||
});
|
||||
}
|
||||
if (!terminalCwd) return;
|
||||
if (!shouldApplyFollowTerminalCwdSyncResult({
|
||||
syncGeneration,
|
||||
currentGeneration: followSyncGenerationRef.current,
|
||||
followEnabled: effectiveFollowTerminalCwdRef.current,
|
||||
canFollow: canFollowTerminalCwdRef.current,
|
||||
expectedSessionId,
|
||||
liveSessionId: focusedSessionIdRef.current ?? activeSessionIdRef.current ?? null,
|
||||
expectedConnectionId: expectedConnectionIdAtStart,
|
||||
liveConnectionId: connectionIdRef.current,
|
||||
paneConnectionId: sftpRef.current.leftPane.connection?.id ?? null,
|
||||
})) return;
|
||||
|
||||
const connection = sftpRef.current.leftPane.connection;
|
||||
if (!shouldFollowTerminalCwdNavigate({
|
||||
followEnabled: effectiveFollowTerminalCwdRef.current,
|
||||
isVisible,
|
||||
terminalCwd,
|
||||
currentPath: connection?.currentPath,
|
||||
connectionId: connection?.id,
|
||||
hasActiveWork,
|
||||
isConnected: Boolean(connection && !connection.isLocal && connection.status === "connected"),
|
||||
blockedFollow: blockedFollowRef.current,
|
||||
handledFollow: handledFollowRef.current,
|
||||
})) {
|
||||
if (
|
||||
connection?.id
|
||||
&& !connection.isLocal
|
||||
&& connection.status === "connected"
|
||||
&& connection.currentPath === terminalCwd
|
||||
) {
|
||||
handledFollowRef.current = { connectionId: connection.id, terminalCwd };
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const expectedConnectionId = connection?.id ?? null;
|
||||
const shouldApplyCurrentFollowSync = () => shouldApplyFollowTerminalCwdSyncResult({
|
||||
syncGeneration,
|
||||
currentGeneration: followSyncGenerationRef.current,
|
||||
followEnabled: effectiveFollowTerminalCwdRef.current,
|
||||
canFollow: canFollowTerminalCwdRef.current,
|
||||
expectedSessionId,
|
||||
liveSessionId: focusedSessionIdRef.current ?? activeSessionIdRef.current ?? null,
|
||||
expectedConnectionId,
|
||||
liveConnectionId: connectionIdRef.current,
|
||||
paneConnectionId: sftpRef.current.leftPane.connection?.id ?? null,
|
||||
expectedTerminalCwd: terminalCwd,
|
||||
liveTerminalCwd: activeTerminalCwdRef.current,
|
||||
requireLiveTerminalCwd: usesLiveTerminalCwd,
|
||||
});
|
||||
const navigateResult = await sftpRef.current.navigateTo("left", terminalCwd, {
|
||||
shouldApply: shouldApplyCurrentFollowSync,
|
||||
});
|
||||
if (!shouldApplyCurrentFollowSync()) return;
|
||||
|
||||
const currentConnection = sftpRef.current.leftPane.connection;
|
||||
if (!currentConnection || currentConnection.id !== connection?.id) return;
|
||||
if (navigateResult === "failed") {
|
||||
blockedFollowRef.current = { connectionId: currentConnection.id, terminalCwd };
|
||||
} else if (navigateResult === "superseded") {
|
||||
handledFollowRef.current = { connectionId: currentConnection.id, terminalCwd };
|
||||
} else if (navigateResult === "reached") {
|
||||
blockedFollowRef.current = null;
|
||||
handledFollowRef.current = { connectionId: currentConnection.id, terminalCwd };
|
||||
}
|
||||
}, [
|
||||
activeTerminalCwd,
|
||||
activeTerminalCwdTrusted,
|
||||
canFollowTerminalCwd,
|
||||
effectiveFollowTerminalCwd,
|
||||
hasActiveWork,
|
||||
isVisible,
|
||||
onGetTerminalCwd,
|
||||
sftpRef,
|
||||
]);
|
||||
|
||||
const handleToggleFollowTerminalCwd = useCallback(() => {
|
||||
const nextEnabled = !effectiveFollowTerminalCwd;
|
||||
invalidateInFlightFollowSync();
|
||||
if (followTerminalCwdHost?.id) {
|
||||
onPendingFollowOverride({ hostId: followTerminalCwdHost.id, value: nextEnabled });
|
||||
}
|
||||
onSftpFollowTerminalCwdChange?.(nextEnabled, followTerminalCwdHost);
|
||||
}, [
|
||||
effectiveFollowTerminalCwd,
|
||||
followTerminalCwdHost,
|
||||
invalidateInFlightFollowSync,
|
||||
onPendingFollowOverride,
|
||||
onSftpFollowTerminalCwdChange,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!effectiveFollowTerminalCwd || !canFollowTerminalCwd || !isVisible || hasActiveWork) return;
|
||||
void syncFollowToTerminalCwd();
|
||||
}, [
|
||||
activeTerminalCwd,
|
||||
activeTerminalCwdTrusted,
|
||||
canFollowTerminalCwd,
|
||||
connectionId,
|
||||
connectionIsLocal,
|
||||
connectionStatus,
|
||||
effectiveFollowTerminalCwd,
|
||||
hasActiveWork,
|
||||
isVisible,
|
||||
syncFollowToTerminalCwd,
|
||||
]);
|
||||
|
||||
const initialFollowSyncedConnRef = useRef<string | null>(null);
|
||||
const initialFollowRetryRef = useRef<{ connectionId: string | null; attempts: number }>({
|
||||
connectionId: null,
|
||||
attempts: 0,
|
||||
});
|
||||
const initialFollowRetryTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const initialFollowInterruptedRef = useRef(false);
|
||||
const initialFollowProbeSeqRef = useRef(0);
|
||||
const initialFollowProbeAttemptRef = useRef<number | null>(null);
|
||||
const initialFollowMountedRef = useRef(true);
|
||||
const [initialFollowRetryNonce, setInitialFollowRetryNonce] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
initialFollowMountedRef.current = true;
|
||||
return () => {
|
||||
initialFollowMountedRef.current = false;
|
||||
if (initialFollowRetryTimerRef.current) clearTimeout(initialFollowRetryTimerRef.current);
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (initialFollowProbeAttemptRef.current === null) return;
|
||||
if (!shouldLatchInitialFollowInterruption({ isVisible, ownerPanelOpen })) return;
|
||||
initialFollowInterruptedRef.current = true;
|
||||
}, [isVisible, ownerPanelOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isVisible || !ownerPanelOpen || !initialFollowRetryTimerRef.current) return;
|
||||
clearTimeout(initialFollowRetryTimerRef.current);
|
||||
initialFollowRetryTimerRef.current = null;
|
||||
}, [isVisible, ownerPanelOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!shouldResetInitialFollowTerminalCwdSync({
|
||||
isVisible,
|
||||
ownerPanelOpen,
|
||||
connectionId,
|
||||
trackedConnectionId: initialFollowRetryRef.current.connectionId,
|
||||
})) return;
|
||||
|
||||
initialFollowSyncedConnRef.current = null;
|
||||
initialFollowReadyConnectionRef.current = null;
|
||||
initialFollowInterruptedRef.current = false;
|
||||
initialFollowRetryRef.current = { connectionId, attempts: 0 };
|
||||
followSyncGenerationRef.current += 1;
|
||||
initialFollowProbeAttemptRef.current = null;
|
||||
if (initialFollowRetryTimerRef.current) {
|
||||
clearTimeout(initialFollowRetryTimerRef.current);
|
||||
initialFollowRetryTimerRef.current = null;
|
||||
}
|
||||
}, [connectionId, isVisible, ownerPanelOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!effectiveFollowTerminalCwd || !canFollowTerminalCwd || !isVisible || hasActiveWork) return;
|
||||
const connection = sftpRef.current.leftPane.connection;
|
||||
if (!connection || connection.isLocal || connection.status !== "connected" || !connection.id) return;
|
||||
if (initialFollowSyncedConnRef.current === connection.id) return;
|
||||
if (initialFollowRetryRef.current.connectionId !== connection.id) {
|
||||
initialFollowRetryRef.current = { connectionId: connection.id, attempts: 0 };
|
||||
}
|
||||
if (initialFollowRetryRef.current.attempts >= MAX_INITIAL_FOLLOW_ATTEMPTS) return;
|
||||
initialFollowRetryRef.current.attempts += 1;
|
||||
initialFollowSyncedConnRef.current = connection.id;
|
||||
|
||||
const expectedConnectionId = connection.id;
|
||||
const expectedPanePath = connection.currentPath ?? null;
|
||||
const expectedSessionId = focusedSessionId ?? activeSessionId ?? null;
|
||||
const staleTerminalCwd = activeTerminalCwdRef.current;
|
||||
const syncGeneration = followSyncGenerationRef.current;
|
||||
let navigationStarted = false;
|
||||
const followCurrentlyEligible = () => (
|
||||
initialFollowMountedRef.current
|
||||
&& effectiveFollowTerminalCwdRef.current
|
||||
&& canFollowTerminalCwdRef.current
|
||||
&& isVisibleRef.current
|
||||
&& !hasActiveWorkRef.current
|
||||
&& isFollowOriginStillCurrent({
|
||||
expectedOriginId: expectedSessionId,
|
||||
liveOriginId: focusedSessionIdRef.current ?? activeSessionIdRef.current ?? null,
|
||||
})
|
||||
&& sftpRef.current.leftPane.connection?.id === expectedConnectionId
|
||||
&& (
|
||||
navigationStarted
|
||||
|| (sftpRef.current.leftPane.connection?.currentPath ?? null) === expectedPanePath
|
||||
)
|
||||
&& !sftpRef.current.leftPane.connection?.isLocal
|
||||
&& sftpRef.current.leftPane.connection?.status === "connected"
|
||||
);
|
||||
const followStillEligible = () => (
|
||||
syncGeneration === followSyncGenerationRef.current
|
||||
&& !initialFollowInterruptedRef.current
|
||||
&& followCurrentlyEligible()
|
||||
);
|
||||
const clearAttemptAndRetry = () => {
|
||||
if (initialFollowInterruptedRef.current) return;
|
||||
if (
|
||||
initialFollowSyncedConnRef.current === expectedConnectionId
|
||||
&& !shouldReleaseInitialFollowSyncAttempt({
|
||||
isVisible: isVisibleRef.current,
|
||||
ownerPanelOpen: ownerPanelOpenRef.current,
|
||||
})
|
||||
) return;
|
||||
if (
|
||||
!initialFollowMountedRef.current
|
||||
|| !followCurrentlyEligible()
|
||||
|| initialFollowRetryRef.current.attempts >= MAX_INITIAL_FOLLOW_ATTEMPTS
|
||||
) {
|
||||
if (initialFollowSyncedConnRef.current === expectedConnectionId) {
|
||||
initialFollowSyncedConnRef.current = null;
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (initialFollowRetryTimerRef.current) clearTimeout(initialFollowRetryTimerRef.current);
|
||||
initialFollowRetryTimerRef.current = setTimeout(() => {
|
||||
initialFollowRetryTimerRef.current = null;
|
||||
if (!shouldReleaseInitialFollowSyncAttempt({
|
||||
isVisible: isVisibleRef.current,
|
||||
ownerPanelOpen: ownerPanelOpenRef.current,
|
||||
})) return;
|
||||
if (initialFollowSyncedConnRef.current === expectedConnectionId) {
|
||||
initialFollowSyncedConnRef.current = null;
|
||||
}
|
||||
setInitialFollowRetryNonce((value) => value + 1);
|
||||
}, INITIAL_FOLLOW_RETRY_DELAY_MS);
|
||||
};
|
||||
|
||||
initialFollowProbeSeqRef.current += 1;
|
||||
const probeAttempt = initialFollowProbeSeqRef.current;
|
||||
initialFollowProbeAttemptRef.current = probeAttempt;
|
||||
void runInitialFollowTerminalCwdSync({
|
||||
expectedConnectionId,
|
||||
staleTerminalCwd,
|
||||
getFreshTerminalCwd: () => onGetTerminalCwd?.({
|
||||
preferFreshBackend: true,
|
||||
allowRendererFallback: false,
|
||||
requireActiveShellCwd: true,
|
||||
}),
|
||||
isEligible: followStillEligible,
|
||||
getConnection: () => sftpRef.current.leftPane.connection,
|
||||
navigate: (cwd, shouldApply) => {
|
||||
navigationStarted = true;
|
||||
return sftpRef.current.navigateTo("left", cwd, { shouldApply });
|
||||
},
|
||||
setHandled: (value) => { handledFollowRef.current = value; },
|
||||
setBlocked: (value) => { blockedFollowRef.current = value; },
|
||||
}).then((completed) => {
|
||||
const isCurrentAttempt = initialFollowProbeAttemptRef.current === probeAttempt;
|
||||
if (isCurrentAttempt) initialFollowProbeAttemptRef.current = null;
|
||||
if (completed && isCurrentAttempt) {
|
||||
initialFollowReadyConnectionRef.current = expectedConnectionId;
|
||||
}
|
||||
if (!completed && isCurrentAttempt) clearAttemptAndRetry();
|
||||
});
|
||||
}, [
|
||||
activeSessionId,
|
||||
focusedSessionId,
|
||||
activeTerminalCwd,
|
||||
canFollowTerminalCwd,
|
||||
connectionId,
|
||||
connectionIsLocal,
|
||||
connectionStatus,
|
||||
effectiveFollowTerminalCwd,
|
||||
hasActiveWork,
|
||||
initialFollowRetryNonce,
|
||||
isVisible,
|
||||
onGetTerminalCwd,
|
||||
sftpRef,
|
||||
]);
|
||||
|
||||
return { handleGoToTerminalCwd, handleToggleFollowTerminalCwd };
|
||||
}
|
||||
@@ -39,7 +39,6 @@ interface UseSftpPaneActionsParams {
|
||||
sftpSessionsRef: React.MutableRefObject<Map<string, string>>;
|
||||
lastConnectedHostRef: React.MutableRefObject<{ left: Host | "local" | null; right: Host | "local" | null }>;
|
||||
connectionCacheKeyMapRef: React.MutableRefObject<Map<string, string>>;
|
||||
reconnectingRef: React.MutableRefObject<{ left: boolean; right: boolean }>;
|
||||
makeCacheKey: (connectionId: string, path: string, encoding?: SftpFilenameEncoding) => string;
|
||||
clearCacheForConnection: (connectionId: string) => void;
|
||||
listLocalFiles: (path: string) => Promise<SftpFileEntry[]>;
|
||||
@@ -98,7 +97,6 @@ export const useSftpPaneActions = ({
|
||||
sftpSessionsRef,
|
||||
lastConnectedHostRef,
|
||||
connectionCacheKeyMapRef,
|
||||
reconnectingRef,
|
||||
makeCacheKey,
|
||||
clearCacheForConnection,
|
||||
listLocalFiles,
|
||||
@@ -464,8 +462,7 @@ export const useSftpPaneActions = ({
|
||||
|| pane.connection.isLocal
|
||||
|| (!pane.connection.isLocal && pane.connection.hostId)
|
||||
);
|
||||
if (canReconnect && !reconnectingRef.current[side]) {
|
||||
reconnectingRef.current[side] = true;
|
||||
if (canReconnect) {
|
||||
updateActiveTab(side, (prev) => ({
|
||||
...prev,
|
||||
reconnecting: true,
|
||||
@@ -486,8 +483,7 @@ export const useSftpPaneActions = ({
|
||||
// when they switch back to that tab.
|
||||
if (options?.tabId) return;
|
||||
const lastHost = lastConnectedHostRef.current[side];
|
||||
if (lastHost && !reconnectingRef.current[side]) {
|
||||
reconnectingRef.current[side] = true;
|
||||
if (lastHost) {
|
||||
updateActiveTab(side, (prev) => ({
|
||||
...prev,
|
||||
reconnecting: true,
|
||||
@@ -501,7 +497,7 @@ export const useSftpPaneActions = ({
|
||||
}
|
||||
}
|
||||
},
|
||||
[getActivePane, leftTabsRef, rightTabsRef, navigateTo, updateActiveTab, lastConnectedHostRef, reconnectingRef, sftpSessionsRef],
|
||||
[getActivePane, leftTabsRef, rightTabsRef, navigateTo, updateActiveTab, lastConnectedHostRef, sftpSessionsRef],
|
||||
);
|
||||
|
||||
const navigateUp = useCallback(
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test, { after } from "node:test";
|
||||
import React from "react";
|
||||
import { act, create, type ReactTestRenderer } from "react-test-renderer";
|
||||
|
||||
import { useSftpSessionCleanup } from "./useSftpSessionCleanup";
|
||||
|
||||
const actEnvironment = globalThis as typeof globalThis & {
|
||||
IS_REACT_ACT_ENVIRONMENT?: boolean;
|
||||
};
|
||||
const previousActEnvironment = actEnvironment.IS_REACT_ACT_ENVIRONMENT;
|
||||
actEnvironment.IS_REACT_ACT_ENVIRONMENT = true;
|
||||
after(() => { actEnvironment.IS_REACT_ACT_ENVIRONMENT = previousActEnvironment; });
|
||||
|
||||
test("cleanup marks the SFTP owner disposed before a late open can register", async () => {
|
||||
const sessionsRef = { current: new Map<string, string>() };
|
||||
let disposedRef: { current: boolean } | null = null;
|
||||
let renderer: ReactTestRenderer | null = null;
|
||||
|
||||
function Probe() {
|
||||
disposedRef = useSftpSessionCleanup(sessionsRef);
|
||||
return null;
|
||||
}
|
||||
|
||||
await act(async () => {
|
||||
renderer = create(React.createElement(Probe));
|
||||
});
|
||||
assert.equal(disposedRef?.current, false);
|
||||
|
||||
await act(async () => renderer?.unmount());
|
||||
assert.equal(disposedRef?.current, true);
|
||||
});
|
||||
@@ -1,19 +1,27 @@
|
||||
import { useEffect } from "react";
|
||||
import { useEffect, useRef } from "react";
|
||||
import type { MutableRefObject } from "react";
|
||||
import { netcattyBridge } from "../../../infrastructure/services/netcattyBridge";
|
||||
|
||||
export const useSftpSessionCleanup = (sftpSessionsRef: MutableRefObject<Map<string, string>>) => {
|
||||
export const useSftpSessionCleanup = (
|
||||
sftpSessionsRef: MutableRefObject<Map<string, string>>,
|
||||
): MutableRefObject<boolean> => {
|
||||
const disposedRef = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
disposedRef.current = false;
|
||||
const sessionsRef = sftpSessionsRef.current;
|
||||
|
||||
return () => {
|
||||
sessionsRef.forEach(async (sftpId) => {
|
||||
try {
|
||||
await netcattyBridge.get()?.closeSftp(sftpId);
|
||||
} catch {
|
||||
// Ignore errors when closing SFTP sessions during cleanup
|
||||
}
|
||||
});
|
||||
disposedRef.current = true;
|
||||
const sftpIds = [...sessionsRef.values()];
|
||||
sessionsRef.clear();
|
||||
for (const sftpId of sftpIds) {
|
||||
void netcattyBridge.get()?.closeSftp(sftpId).catch(() => {
|
||||
// Ignore errors when closing SFTP sessions during cleanup.
|
||||
});
|
||||
}
|
||||
};
|
||||
}, [sftpSessionsRef]);
|
||||
|
||||
return disposedRef;
|
||||
};
|
||||
@@ -13,7 +13,6 @@ interface UseSftpSessionErrorsParams {
|
||||
) => void;
|
||||
navSeqRef: MutableRefObject<{ left: number; right: number }>;
|
||||
lastConnectedHostRef: MutableRefObject<{ left: Host | "local" | null; right: Host | "local" | null }>;
|
||||
reconnectingRef: MutableRefObject<{ left: boolean; right: boolean }>;
|
||||
releaseConnection: (connectionId: string) => Promise<void>;
|
||||
}
|
||||
|
||||
@@ -42,7 +41,6 @@ export const useSftpSessionErrors = ({
|
||||
updateActiveTab,
|
||||
navSeqRef,
|
||||
lastConnectedHostRef,
|
||||
reconnectingRef,
|
||||
releaseConnection,
|
||||
}: UseSftpSessionErrorsParams) =>
|
||||
useCallback(
|
||||
@@ -64,22 +62,10 @@ export const useSftpSessionErrors = ({
|
||||
connection: pane.connection,
|
||||
});
|
||||
|
||||
if (canReconnect && !reconnectingRef.current[side]) {
|
||||
if (canReconnect) {
|
||||
// Keep the connection object (host identity + path) so the UI does not
|
||||
// collapse into the empty host picker when listing root (or any path)
|
||||
// fails with a transient session error on some servers.
|
||||
reconnectingRef.current[side] = true;
|
||||
updateActiveTab(side, (prev) => ({
|
||||
...prev,
|
||||
reconnecting: true,
|
||||
loading: false,
|
||||
error: "sftp.error.connectionLostReconnecting",
|
||||
}));
|
||||
return;
|
||||
}
|
||||
|
||||
if (canReconnect && reconnectingRef.current[side]) {
|
||||
// Already reconnecting — keep connection, avoid blank host picker.
|
||||
updateActiveTab(side, (prev) => ({
|
||||
...prev,
|
||||
reconnecting: true,
|
||||
@@ -108,7 +94,6 @@ export const useSftpSessionErrors = ({
|
||||
updateActiveTab,
|
||||
navSeqRef,
|
||||
lastConnectedHostRef,
|
||||
reconnectingRef,
|
||||
releaseConnection,
|
||||
],
|
||||
);
|
||||
@@ -0,0 +1,58 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import { createEmptyPane } from "./types.ts";
|
||||
import {
|
||||
closeSftpTabStateImmediately,
|
||||
removeSftpTabFromState,
|
||||
} from "./useSftpTabsState.ts";
|
||||
|
||||
test("closing the active SFTP tab selects its nearest remaining neighbor", () => {
|
||||
const left = createEmptyPane("left");
|
||||
const middle = createEmptyPane("middle");
|
||||
const right = createEmptyPane("right");
|
||||
|
||||
assert.deepEqual(removeSftpTabFromState({
|
||||
tabs: [left, middle, right],
|
||||
activeTabId: middle.id,
|
||||
}, middle.id), {
|
||||
tabs: [left, right],
|
||||
activeTabId: right.id,
|
||||
});
|
||||
});
|
||||
|
||||
test("closing a background SFTP tab preserves the active tab", () => {
|
||||
const active = createEmptyPane("active");
|
||||
const background = createEmptyPane("background");
|
||||
|
||||
assert.deepEqual(removeSftpTabFromState({
|
||||
tabs: [active, background],
|
||||
activeTabId: active.id,
|
||||
}, background.id), {
|
||||
tabs: [active],
|
||||
activeTabId: active.id,
|
||||
});
|
||||
});
|
||||
|
||||
test("closing a tab updates the live ref before React commits the state update", () => {
|
||||
const closing = createEmptyPane("closing");
|
||||
const remaining = createEmptyPane("remaining");
|
||||
const initial = {
|
||||
tabs: [closing, remaining],
|
||||
activeTabId: closing.id,
|
||||
};
|
||||
const tabsRef = { current: initial };
|
||||
let queuedUpdate: ((prev: typeof initial) => typeof initial) | undefined;
|
||||
|
||||
closeSftpTabStateImmediately({
|
||||
tabsRef,
|
||||
tabId: closing.id,
|
||||
setTabs: (update) => {
|
||||
queuedUpdate = update;
|
||||
},
|
||||
});
|
||||
|
||||
assert.deepEqual(tabsRef.current.tabs, [remaining]);
|
||||
assert.equal(tabsRef.current.activeTabId, remaining.id);
|
||||
assert.deepEqual(queuedUpdate?.(initial), tabsRef.current);
|
||||
});
|
||||
@@ -37,6 +37,39 @@ interface SftpTabsState {
|
||||
|
||||
const EMPTY_SELECTION = new Set<string>();
|
||||
|
||||
export function removeSftpTabFromState(prev: SftpSideTabs, tabId: string): SftpSideTabs {
|
||||
const tabIndex = prev.tabs.findIndex((tab) => tab.id === tabId);
|
||||
if (tabIndex === -1) return prev;
|
||||
|
||||
let activeTabId: string | null = null;
|
||||
if (prev.tabs.length > 1) {
|
||||
if (prev.activeTabId === tabId) {
|
||||
const nextIndex = tabIndex < prev.tabs.length - 1 ? tabIndex + 1 : tabIndex - 1;
|
||||
activeTabId = prev.tabs[nextIndex]?.id ?? null;
|
||||
} else {
|
||||
activeTabId = prev.activeTabId;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
tabs: prev.tabs.filter((tab) => tab.id !== tabId),
|
||||
activeTabId,
|
||||
};
|
||||
}
|
||||
|
||||
export function closeSftpTabStateImmediately(params: {
|
||||
tabsRef: { current: SftpSideTabs };
|
||||
tabId: string;
|
||||
setTabs: (updater: (prev: SftpSideTabs) => SftpSideTabs) => void;
|
||||
}): void {
|
||||
params.tabsRef.current = removeSftpTabFromState(params.tabsRef.current, params.tabId);
|
||||
params.setTabs((prev) => {
|
||||
const next = removeSftpTabFromState(prev, params.tabId);
|
||||
params.tabsRef.current = next;
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
export const useSftpTabsState = ({
|
||||
defaultShowHiddenFiles = false,
|
||||
}: {
|
||||
@@ -154,24 +187,14 @@ export const useSftpTabsState = ({
|
||||
const closeTab = useCallback(
|
||||
(side: "left" | "right", tabId: string) => {
|
||||
const setTabs = side === "left" ? setLeftTabs : setRightTabs;
|
||||
setTabs((prev) => {
|
||||
const tabIndex = prev.tabs.findIndex((t) => t.id === tabId);
|
||||
if (tabIndex === -1) return prev;
|
||||
|
||||
let newActiveTabId: string | null = null;
|
||||
if (prev.tabs.length > 1) {
|
||||
if (prev.activeTabId === tabId) {
|
||||
const nextIndex = tabIndex < prev.tabs.length - 1 ? tabIndex + 1 : tabIndex - 1;
|
||||
newActiveTabId = prev.tabs[nextIndex]?.id || null;
|
||||
} else {
|
||||
newActiveTabId = prev.activeTabId;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
tabs: prev.tabs.filter((t) => t.id !== tabId),
|
||||
activeTabId: newActiveTabId,
|
||||
};
|
||||
const tabsRef = side === "left" ? leftTabsRef : rightTabsRef;
|
||||
// Make the removal visible to in-flight SFTP work immediately, before
|
||||
// React commits the state update, so a late connection cannot register
|
||||
// itself to a tab the user has already closed.
|
||||
closeSftpTabStateImmediately({
|
||||
tabsRef,
|
||||
tabId,
|
||||
setTabs,
|
||||
});
|
||||
},
|
||||
[],
|
||||
|
||||
@@ -4,6 +4,7 @@ export type SidePanelLiveSnapshot = {
|
||||
sftpActiveHost: Host | null;
|
||||
activeTerminalSessionIdForSftp: string | null;
|
||||
activeTerminalCwd: string | null;
|
||||
activeTerminalCwdTrusted: boolean;
|
||||
activeWorkspace: Workspace | undefined;
|
||||
activeTerminalSessionForSystem: TerminalSession | null;
|
||||
activeSystemSessionHost: Host | null;
|
||||
@@ -25,6 +26,7 @@ const EMPTY_SNAPSHOT: SidePanelLiveSnapshot = {
|
||||
sftpActiveHost: null,
|
||||
activeTerminalSessionIdForSftp: null,
|
||||
activeTerminalCwd: null,
|
||||
activeTerminalCwdTrusted: false,
|
||||
activeWorkspace: undefined,
|
||||
activeTerminalSessionForSystem: null,
|
||||
activeSystemSessionHost: null,
|
||||
@@ -50,6 +52,7 @@ function liveSnapshotEqual(a: SidePanelLiveSnapshot, b: SidePanelLiveSnapshot):
|
||||
return a.sftpActiveHost === b.sftpActiveHost
|
||||
&& a.activeTerminalSessionIdForSftp === b.activeTerminalSessionIdForSftp
|
||||
&& a.activeTerminalCwd === b.activeTerminalCwd
|
||||
&& a.activeTerminalCwdTrusted === b.activeTerminalCwdTrusted
|
||||
&& a.activeWorkspace === b.activeWorkspace
|
||||
&& a.activeTerminalSessionForSystem === b.activeTerminalSessionForSystem
|
||||
&& a.activeSystemSessionHost === b.activeSystemSessionHost
|
||||
|
||||
@@ -0,0 +1,191 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
import { isAutoLaunchResultTrustworthy } from './systemSettingsEffects.ts';
|
||||
|
||||
test('isAutoLaunchResultTrustworthy trusts a successful read/write result', () => {
|
||||
assert.equal(isAutoLaunchResultTrustworthy({ success: true }), true);
|
||||
});
|
||||
|
||||
test('isAutoLaunchResultTrustworthy rejects a transient failure result', () => {
|
||||
assert.equal(
|
||||
isAutoLaunchResultTrustworthy({ success: false }),
|
||||
false,
|
||||
'success:false means the OS state is unknown, not confirmed disabled',
|
||||
);
|
||||
});
|
||||
|
||||
test('isAutoLaunchResultTrustworthy defaults to trusting a result with no success field', () => {
|
||||
// Defensive default for any future/alternate caller that omits the field
|
||||
// entirely — only an explicit false should withhold trust.
|
||||
assert.equal(isAutoLaunchResultTrustworthy({}), true);
|
||||
});
|
||||
|
||||
/**
|
||||
* Minimal model of the hydration effect's decision: given a getAutoLaunch()
|
||||
* result, should it overwrite the cached autoLaunchEnabled value? This is
|
||||
* the exact regression scenario from the review — a transient read failure
|
||||
* must not overwrite a cached `true` with a "successful-looking" `false`,
|
||||
* which would otherwise cascade into an unwanted disable write via the
|
||||
* adjacent push effect.
|
||||
*/
|
||||
function simulateHydration(cachedEnabled: boolean, result: { success: boolean; enabled: boolean }): boolean {
|
||||
if (!isAutoLaunchResultTrustworthy(result)) return cachedEnabled;
|
||||
return result.enabled;
|
||||
}
|
||||
|
||||
test('a transient read failure during hydration keeps the cached enabled value', () => {
|
||||
const next = simulateHydration(true, { success: false, enabled: false });
|
||||
assert.equal(next, true, 'must not silently flip a cached true to false on an unrelated read failure');
|
||||
});
|
||||
|
||||
test('a successful read during hydration applies the real OS state, even when it flips the cache', () => {
|
||||
const next = simulateHydration(true, { success: true, enabled: false });
|
||||
assert.equal(next, false, 'a confirmed read must still win over a stale cache');
|
||||
});
|
||||
|
||||
/**
|
||||
* Model of the actual hook: a mount-time hydration request and a
|
||||
* user-triggered write can race. This mirrors the real ordering the review
|
||||
* flagged — hydration starts, the user toggles before it resolves (which
|
||||
* starts a real write), then the stale hydration response arrives.
|
||||
*/
|
||||
function simulateMountToggleRace(
|
||||
initialCachedEnabled: boolean,
|
||||
userToggleTo: boolean,
|
||||
staleHydrationResult: { success: boolean; enabled: boolean },
|
||||
): boolean {
|
||||
let state = initialCachedEnabled;
|
||||
let writeStarted = false;
|
||||
|
||||
// Hydration request is issued here (pending) — resolution happens later.
|
||||
|
||||
// The user toggles before hydration resolves; this is what the real push
|
||||
// effect does: apply optimistically and flag that a real write started.
|
||||
state = userToggleTo;
|
||||
writeStarted = true;
|
||||
|
||||
// The stale hydration response now arrives.
|
||||
if (!writeStarted && isAutoLaunchResultTrustworthy(staleHydrationResult)) {
|
||||
state = staleHydrationResult.enabled;
|
||||
}
|
||||
|
||||
return state;
|
||||
}
|
||||
|
||||
test('a user toggle during in-flight hydration is not clobbered by the stale hydration response', () => {
|
||||
const result = simulateMountToggleRace(false, true, { success: true, enabled: false });
|
||||
|
||||
assert.equal(
|
||||
result,
|
||||
true,
|
||||
'the stale response (enabled:false, read before the user toggled) must not overwrite the user\'s fresh true — ' +
|
||||
'otherwise the adjacent push effect reacts to the overwrite and disables an item the user just enabled',
|
||||
);
|
||||
});
|
||||
|
||||
test('a user toggle to false during in-flight hydration is not clobbered by a stale enabled:true response', () => {
|
||||
const result = simulateMountToggleRace(true, false, { success: true, enabled: true });
|
||||
|
||||
assert.equal(result, false, 'symmetric case: disabling must also win over a stale hydration response');
|
||||
});
|
||||
|
||||
test('hydration still applies normally when it resolves before any write starts', () => {
|
||||
// No race: writeStarted stays false throughout, so trustworthy hydration
|
||||
// results must still be applied — the guard must not disable hydration
|
||||
// unconditionally, only once a real write has actually begun.
|
||||
let state = false;
|
||||
let writeStarted = false;
|
||||
const result = { success: true, enabled: true };
|
||||
|
||||
if (!writeStarted && isAutoLaunchResultTrustworthy(result)) {
|
||||
state = result.enabled;
|
||||
}
|
||||
|
||||
assert.equal(state, true);
|
||||
void writeStarted;
|
||||
});
|
||||
|
||||
/**
|
||||
* Model of the push effect's own overlap guard: rapid double (or triple)
|
||||
* toggles start multiple bridge.setAutoLaunch(...) calls that can resolve
|
||||
* out of order. Each response is only reconciled into state if no newer
|
||||
* request has started since — this is the exact scenario the review
|
||||
* flagged: "each callback closes over its render's autoLaunchEnabled value
|
||||
* ... without checking whether a newer request exists".
|
||||
*/
|
||||
class AutoLaunchWriteSimulator {
|
||||
state: boolean;
|
||||
private generation = 0;
|
||||
|
||||
constructor(initial: boolean) {
|
||||
this.state = initial;
|
||||
}
|
||||
|
||||
/** Simulates the user toggling the switch, starting a new write. */
|
||||
startWrite(requestedValue: boolean): { requestGeneration: number; requestedValue: boolean } {
|
||||
this.state = requestedValue;
|
||||
this.generation += 1;
|
||||
return { requestGeneration: this.generation, requestedValue };
|
||||
}
|
||||
|
||||
/** Simulates that write's bridge.setAutoLaunch(...) response arriving. */
|
||||
resolveWrite(request: { requestGeneration: number; requestedValue: boolean }, result: { success: boolean; enabled: boolean }) {
|
||||
if (this.generation !== request.requestGeneration) return; // superseded — ignore
|
||||
if (!isAutoLaunchResultTrustworthy(result)) return;
|
||||
if (result.enabled !== request.requestedValue) this.state = result.enabled;
|
||||
}
|
||||
}
|
||||
|
||||
test('a stale write response is ignored once a newer request has started', () => {
|
||||
const sim = new AutoLaunchWriteSimulator(false);
|
||||
|
||||
const first = sim.startWrite(true); // user enables
|
||||
const second = sim.startWrite(false); // user immediately disables again, before `first` resolves
|
||||
assert.equal(sim.state, false, 'the latest toggle is reflected optimistically right away');
|
||||
|
||||
// `first`'s response arrives late, reporting a mismatch against ITS OWN
|
||||
// request (e.g. macOS pending approval reported enabled:false for a
|
||||
// requested true) — without the generation guard this would incorrectly
|
||||
// "correct" state to false right as `second` is still in flight, and
|
||||
// with a mismatch reported the OTHER way it could just as easily stomp
|
||||
// a subsequent true.
|
||||
sim.resolveWrite(first, { success: true, enabled: false });
|
||||
assert.equal(sim.state, false, 'the stale response for the superseded first request must not touch state');
|
||||
|
||||
// `second`'s own (current) response arrives and is applied normally.
|
||||
sim.resolveWrite(second, { success: true, enabled: false });
|
||||
assert.equal(sim.state, false);
|
||||
});
|
||||
|
||||
test('a stale write response would have incorrectly overridden a later successful write, without the guard', () => {
|
||||
const sim = new AutoLaunchWriteSimulator(false);
|
||||
|
||||
const first = sim.startWrite(true); // request enable — this one will report a mismatch
|
||||
const second = sim.startWrite(true); // user toggles off and back on again quickly; also requests true
|
||||
assert.equal(sim.state, true);
|
||||
|
||||
// second's response arrives first (network/OS timing is not ordered) and
|
||||
// confirms the real, current state.
|
||||
sim.resolveWrite(second, { success: true, enabled: true });
|
||||
assert.equal(sim.state, true);
|
||||
|
||||
// first's stale response finally arrives, reporting a mismatch (its own
|
||||
// request was true but the OS said false at the time) — must not undo
|
||||
// the now-confirmed true state from the newer request.
|
||||
sim.resolveWrite(first, { success: true, enabled: false });
|
||||
assert.equal(
|
||||
sim.state,
|
||||
true,
|
||||
'a delayed response from a superseded request must not overwrite the newer, already-confirmed state',
|
||||
);
|
||||
});
|
||||
|
||||
test('the only in-flight write still reconciles a genuine mismatch normally', () => {
|
||||
const sim = new AutoLaunchWriteSimulator(false);
|
||||
|
||||
const only = sim.startWrite(true);
|
||||
sim.resolveWrite(only, { success: true, enabled: false }); // e.g. blocked by Windows Startup Apps
|
||||
|
||||
assert.equal(sim.state, false, 'with no overlap, a real mismatch must still correct the optimistic toggle');
|
||||
});
|
||||
@@ -2,6 +2,7 @@ import { useEffect, useRef, type MutableRefObject } from 'react';
|
||||
import {
|
||||
STORAGE_KEY_AUTO_UPDATE_ENABLED,
|
||||
STORAGE_KEY_CLOSE_TO_TRAY,
|
||||
STORAGE_KEY_AUTO_LAUNCH_ENABLED,
|
||||
STORAGE_KEY_GLOBAL_HOTKEY_ENABLED,
|
||||
STORAGE_KEY_TOGGLE_WINDOW_HOTKEY,
|
||||
STORAGE_KEY_WINDOW_OPACITY,
|
||||
@@ -24,11 +25,24 @@ import {
|
||||
type WindowOpacityRecord,
|
||||
} from './windowOpacitySync';
|
||||
|
||||
/**
|
||||
* A getAutoLaunch/setAutoLaunch IPC result is only safe to apply to state
|
||||
* when the main process could actually determine the OS's login-item state.
|
||||
* success:false means "unknown" (a transient read/write failure), not a
|
||||
* confirmed disabled state — applying enabled:false in that case would
|
||||
* overwrite a valid cached value and, via the push effect, cascade into an
|
||||
* unwanted disable write that could remove a working login item.
|
||||
*/
|
||||
export function isAutoLaunchResultTrustworthy(result: { success?: boolean }): boolean {
|
||||
return result.success !== false;
|
||||
}
|
||||
|
||||
interface UseSystemSettingsEffectsParams {
|
||||
enabled?: boolean;
|
||||
toggleWindowHotkey: string;
|
||||
globalHotkeyEnabled: boolean;
|
||||
closeToTray: boolean;
|
||||
autoLaunchEnabled: boolean;
|
||||
windowOpacityRecord: WindowOpacityRecord;
|
||||
windowOpacityMutationSourceRef: MutableRefObject<WindowOpacityMutationSource>;
|
||||
appIconVariant: AppIconVariant;
|
||||
@@ -38,6 +52,8 @@ interface UseSystemSettingsEffectsParams {
|
||||
setHotkeyRegistrationError: (error: string | null) => void;
|
||||
setAutoUpdateEnabled: (enabled: boolean | ((prev: boolean) => boolean)) => void;
|
||||
setAppIconVariant: (variant: AppIconVariant | ((prev: AppIconVariant) => AppIconVariant)) => void;
|
||||
setAutoLaunchEnabled: (enabled: boolean | ((prev: boolean) => boolean)) => void;
|
||||
setAutoLaunchSupported: (supported: boolean) => void;
|
||||
notifySettingsChanged: (key: string, value: unknown) => void;
|
||||
}
|
||||
|
||||
@@ -46,6 +62,7 @@ export function useSystemSettingsEffects({
|
||||
toggleWindowHotkey,
|
||||
globalHotkeyEnabled,
|
||||
closeToTray,
|
||||
autoLaunchEnabled,
|
||||
windowOpacityRecord,
|
||||
windowOpacityMutationSourceRef,
|
||||
appIconVariant,
|
||||
@@ -55,9 +72,26 @@ export function useSystemSettingsEffects({
|
||||
setHotkeyRegistrationError,
|
||||
setAutoUpdateEnabled,
|
||||
setAppIconVariant,
|
||||
setAutoLaunchEnabled,
|
||||
setAutoLaunchSupported,
|
||||
notifySettingsChanged,
|
||||
}: UseSystemSettingsEffectsParams) {
|
||||
const appIconApplyRequestIdRef = useRef(0);
|
||||
// True once the push effect has issued a real OS write (i.e. after mount,
|
||||
// when persistMountedRef is set — see below). If the user toggles
|
||||
// auto-launch while the mount-time hydration read is still in flight, the
|
||||
// hydration effect must not let its now-stale response overwrite that
|
||||
// choice, or the adjacent push effect reacts to the overwrite and issues
|
||||
// the opposite OS write right after the user's own request lands.
|
||||
const autoLaunchWriteStartedRef = useRef(false);
|
||||
// Incremented on every push-effect invocation. If the user toggles again
|
||||
// before an in-flight bridge.setAutoLaunch(...) resolves, that older
|
||||
// call's response is stale by the time it arrives — comparing its own
|
||||
// request against its own result is self-consistent but says nothing
|
||||
// about whether a newer request has since superseded it, so a delayed
|
||||
// response could otherwise "correct" state the user has already moved
|
||||
// past (undoing a toggle they made after this specific request started).
|
||||
const autoLaunchWriteGenerationRef = useRef(0);
|
||||
|
||||
// Persist and sync toggle window hotkey setting
|
||||
useEffect(() => {
|
||||
@@ -139,6 +173,61 @@ export function useSystemSettingsEffects({
|
||||
notifySettingsChanged(STORAGE_KEY_CLOSE_TO_TRAY, closeToTray);
|
||||
}, [enabled, closeToTray, notifySettingsChanged, persistMountedRef]);
|
||||
|
||||
// Hydrate auto-launch from the main process on mount — the OS login item
|
||||
// is the real source of truth (the user may have toggled it outside the
|
||||
// app), localStorage is only an optimistic cache for first paint.
|
||||
useEffect(() => {
|
||||
if (!enabled) return;
|
||||
const bridge = netcattyBridge.get();
|
||||
if (!bridge?.getAutoLaunch) return;
|
||||
let cancelled = false;
|
||||
bridge.getAutoLaunch().then((result) => {
|
||||
if (cancelled) return;
|
||||
setAutoLaunchSupported(result.supported);
|
||||
// The user (or an already-issued write) may have moved state on while
|
||||
// this request was in flight — a stale hydration response must not
|
||||
// clobber it, or the push effect below reacts to the clobber and
|
||||
// fires an unwanted OS write right after the user's own request.
|
||||
if (autoLaunchWriteStartedRef.current) return;
|
||||
if (!isAutoLaunchResultTrustworthy(result)) return;
|
||||
setAutoLaunchEnabled(result.enabled);
|
||||
localStorageAdapter.writeString(STORAGE_KEY_AUTO_LAUNCH_ENABLED, result.enabled ? 'true' : 'false');
|
||||
}).catch((err) => {
|
||||
console.warn('[AutoLaunch] Failed to read login item state:', err);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
// Runs once per mount — this is a one-shot hydration, not a sync loop.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [enabled]);
|
||||
|
||||
// Push auto-launch changes to the main process and cache the result.
|
||||
useEffect(() => {
|
||||
if (!enabled) return;
|
||||
if (!persistMountedRef.current) return;
|
||||
const bridge = netcattyBridge.get();
|
||||
if (!bridge?.setAutoLaunch) return;
|
||||
// A real write is now in flight — permanently disqualify the mount-time
|
||||
// hydration effect above from applying a (now possibly stale) response.
|
||||
autoLaunchWriteStartedRef.current = true;
|
||||
const requestGeneration = ++autoLaunchWriteGenerationRef.current;
|
||||
bridge.setAutoLaunch(autoLaunchEnabled).then((result) => {
|
||||
setAutoLaunchSupported(result.supported);
|
||||
// The user toggled again before this request resolved — a newer
|
||||
// request (and its own response, once it arrives) is authoritative;
|
||||
// reconciling this stale one could undo a choice made after it fired.
|
||||
if (autoLaunchWriteGenerationRef.current !== requestGeneration) return;
|
||||
if (!isAutoLaunchResultTrustworthy(result)) return;
|
||||
if (result.enabled !== autoLaunchEnabled) setAutoLaunchEnabled(result.enabled);
|
||||
localStorageAdapter.writeString(STORAGE_KEY_AUTO_LAUNCH_ENABLED, result.enabled ? 'true' : 'false');
|
||||
}).catch((err) => {
|
||||
console.warn('[AutoLaunch] Failed to update login item state:', err);
|
||||
});
|
||||
notifySettingsChanged(STORAGE_KEY_AUTO_LAUNCH_ENABLED, autoLaunchEnabled);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [enabled, autoLaunchEnabled]);
|
||||
|
||||
// Persist and apply app-level HTTP(S) network proxy (cloud sync / AI)
|
||||
useEffect(() => {
|
||||
if (!enabled) return;
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
createCopiedTerminalSessionClone,
|
||||
createSplitTerminalSessionClone,
|
||||
isTerminalSessionEligibleForSftpReuse,
|
||||
resolveSftpReuseSourceSessionId,
|
||||
} from "./terminalConnectionReuse";
|
||||
|
||||
const session = (overrides: Partial<TerminalSession> = {}): TerminalSession => ({
|
||||
@@ -33,6 +34,18 @@ test("SSH sessions stay SFTP-linkable while reconnecting", () => {
|
||||
assert.equal(isTerminalSessionEligibleForSftpReuse(session({ etEnabled: true })), false);
|
||||
});
|
||||
|
||||
test("Mosh and ET drops keep their origin but do not request SSH session reuse", () => {
|
||||
assert.equal(resolveSftpReuseSourceSessionId(session(), "session-1"), "session-1");
|
||||
assert.equal(
|
||||
resolveSftpReuseSourceSessionId(session({ moshEnabled: true }), "session-1"),
|
||||
undefined,
|
||||
);
|
||||
assert.equal(
|
||||
resolveSftpReuseSourceSessionId(session({ etEnabled: true }), "session-1"),
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
|
||||
test("non-SSH or unavailable sessions do not reuse a connection", () => {
|
||||
assert.equal(canReuseTerminalConnection(session({ status: "connecting" })), false);
|
||||
assert.equal(canReuseTerminalConnection(session({ status: "disconnected" })), false);
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
import type { TerminalSession } from "../../domain/models";
|
||||
|
||||
type SftpReuseCandidate = Pick<
|
||||
TerminalSession,
|
||||
"protocol" | "moshEnabled" | "etEnabled"
|
||||
>;
|
||||
|
||||
/** SSH (non-mosh/et) sessions that can back SFTP once the transport is up. */
|
||||
export function isTerminalSessionEligibleForSftpReuse(session: TerminalSession): boolean {
|
||||
export function isTerminalSessionEligibleForSftpReuse(session: SftpReuseCandidate): boolean {
|
||||
return (
|
||||
(session.protocol === "ssh" || session.protocol === undefined)
|
||||
&& !session.moshEnabled
|
||||
@@ -9,6 +14,15 @@ export function isTerminalSessionEligibleForSftpReuse(session: TerminalSession):
|
||||
);
|
||||
}
|
||||
|
||||
export function resolveSftpReuseSourceSessionId(
|
||||
session: SftpReuseCandidate,
|
||||
sessionId: string,
|
||||
): string | undefined {
|
||||
return isTerminalSessionEligibleForSftpReuse(session)
|
||||
? sessionId
|
||||
: undefined;
|
||||
}
|
||||
|
||||
export function canReuseTerminalConnection(session: TerminalSession): boolean {
|
||||
return isTerminalSessionEligibleForSftpReuse(session) && session.status === "connected";
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@ import test from 'node:test';
|
||||
|
||||
import { terminalCwdStore } from './terminalCwdStore.ts';
|
||||
|
||||
test('terminalCwdStore bumps version only when cwd changes', () => {
|
||||
test('terminalCwdStore bumps version only when cwd or provenance changes', () => {
|
||||
const versions: number[] = [];
|
||||
const unsubscribe = terminalCwdStore.subscribe(() => {
|
||||
versions.push(terminalCwdStore.getVersion());
|
||||
@@ -20,6 +20,10 @@ test('terminalCwdStore bumps version only when cwd changes', () => {
|
||||
assert.equal(terminalCwdStore.setCwd('s1', '/var'), true);
|
||||
assert.equal(terminalCwdStore.getCwd('s1'), '/var');
|
||||
|
||||
assert.equal(terminalCwdStore.setCwd('s1', '/var', 'backend-strict'), true);
|
||||
assert.equal(terminalCwdStore.getSource('s1'), 'backend-strict');
|
||||
assert.equal(terminalCwdStore.setCwd('s1', '/var', 'backend-strict'), false);
|
||||
|
||||
terminalCwdStore.prune(new Set());
|
||||
assert.equal(terminalCwdStore.getCwd('s1'), null);
|
||||
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
type Listener = () => void;
|
||||
|
||||
export type TerminalCwdSource = "osc7" | "backend-strict" | "backend" | "snapshot" | "stale" | "unknown";
|
||||
|
||||
/**
|
||||
* Live terminal CWD map + version token.
|
||||
* OSC 7 / cwd probes update this store without setState on TerminalLayer,
|
||||
@@ -7,6 +9,7 @@ type Listener = () => void;
|
||||
*/
|
||||
class TerminalCwdStore {
|
||||
private cwdBySession = new Map<string, string>();
|
||||
private sourceBySession = new Map<string, TerminalCwdSource>();
|
||||
private version = 0;
|
||||
private listeners = new Set<Listener>();
|
||||
|
||||
@@ -17,6 +20,11 @@ class TerminalCwdStore {
|
||||
return this.cwdBySession.get(sessionId) ?? null;
|
||||
};
|
||||
|
||||
getSource = (sessionId: string | null | undefined): TerminalCwdSource | undefined => {
|
||||
if (!sessionId) return undefined;
|
||||
return this.sourceBySession.get(sessionId);
|
||||
};
|
||||
|
||||
subscribe = (listener: Listener): (() => void) => {
|
||||
this.listeners.add(listener);
|
||||
return () => {
|
||||
@@ -24,15 +32,21 @@ class TerminalCwdStore {
|
||||
};
|
||||
};
|
||||
|
||||
setCwd(sessionId: string, cwd: string | null): boolean {
|
||||
setCwd(sessionId: string, cwd: string | null, source?: TerminalCwdSource): boolean {
|
||||
const current = this.cwdBySession.get(sessionId) ?? null;
|
||||
const currentSource = this.sourceBySession.get(sessionId);
|
||||
const next = cwd && cwd.trim().length > 0 ? cwd : null;
|
||||
if (current === next) return false;
|
||||
const nextSource = next
|
||||
? (source ?? (current === next ? currentSource : undefined) ?? "unknown")
|
||||
: undefined;
|
||||
if (current === next && currentSource === nextSource) return false;
|
||||
|
||||
if (next) {
|
||||
this.cwdBySession.set(sessionId, next);
|
||||
this.sourceBySession.set(sessionId, nextSource!);
|
||||
} else {
|
||||
this.cwdBySession.delete(sessionId);
|
||||
this.sourceBySession.delete(sessionId);
|
||||
}
|
||||
this.version += 1;
|
||||
for (const listener of this.listeners) {
|
||||
@@ -46,6 +60,7 @@ class TerminalCwdStore {
|
||||
for (const sessionId of this.cwdBySession.keys()) {
|
||||
if (!validSessionIds.has(sessionId)) {
|
||||
this.cwdBySession.delete(sessionId);
|
||||
this.sourceBySession.delete(sessionId);
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -67,6 +67,7 @@ import {
|
||||
STORAGE_KEY_EXPLORER_CONTEXT_MENU_ENABLED,
|
||||
STORAGE_KEY_TOGGLE_WINDOW_HOTKEY,
|
||||
STORAGE_KEY_CLOSE_TO_TRAY,
|
||||
STORAGE_KEY_AUTO_LAUNCH_ENABLED,
|
||||
STORAGE_KEY_HTTP_NETWORK_PROXY,
|
||||
STORAGE_KEY_GLOBAL_HOTKEY_ENABLED,
|
||||
STORAGE_KEY_WINDOW_OPACITY,
|
||||
@@ -539,6 +540,13 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
|
||||
if (stored === null) return true;
|
||||
return stored === 'true';
|
||||
});
|
||||
// Optimistic cache for first paint; useSystemSettingsEffects hydrates this
|
||||
// from app.getLoginItemSettings() (the real source of truth) on mount.
|
||||
const [autoLaunchEnabled, setAutoLaunchEnabled] = useState<boolean>(() => {
|
||||
const stored = readStoredString(STORAGE_KEY_AUTO_LAUNCH_ENABLED);
|
||||
return stored === 'true';
|
||||
});
|
||||
const [autoLaunchSupported, setAutoLaunchSupported] = useState<boolean>(true);
|
||||
const [httpNetworkProxy, setHttpNetworkProxyState] = useState<HttpNetworkProxySettings>(() => {
|
||||
const stored = localStorageAdapter.read<unknown>(STORAGE_KEY_HTTP_NETWORK_PROXY);
|
||||
return normalizeHttpNetworkProxySettings(stored ?? DEFAULT_HTTP_NETWORK_PROXY);
|
||||
@@ -1184,6 +1192,7 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
|
||||
setWindowOpacity: applyIncomingWindowOpacity,
|
||||
setAppIconVariant,
|
||||
setAutoUpdateEnabled,
|
||||
setAutoLaunchEnabled,
|
||||
setHttpNetworkProxy,
|
||||
setSftpAutoOpenSidebar,
|
||||
setSftpFollowTerminalCwd,
|
||||
@@ -1747,6 +1756,7 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
|
||||
toggleWindowHotkey,
|
||||
globalHotkeyEnabled,
|
||||
closeToTray,
|
||||
autoLaunchEnabled,
|
||||
windowOpacityRecord,
|
||||
windowOpacityMutationSourceRef,
|
||||
appIconVariant,
|
||||
@@ -1756,6 +1766,8 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
|
||||
setHotkeyRegistrationError,
|
||||
setAutoUpdateEnabled,
|
||||
setAppIconVariant,
|
||||
setAutoLaunchEnabled,
|
||||
setAutoLaunchSupported,
|
||||
notifySettingsChanged,
|
||||
});
|
||||
|
||||
@@ -2101,6 +2113,9 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
|
||||
setToggleWindowHotkey,
|
||||
closeToTray,
|
||||
setCloseToTray,
|
||||
autoLaunchEnabled,
|
||||
setAutoLaunchEnabled,
|
||||
autoLaunchSupported,
|
||||
httpNetworkProxy,
|
||||
setHttpNetworkProxy,
|
||||
autoUpdateEnabled,
|
||||
|
||||
@@ -18,11 +18,13 @@ import {
|
||||
joinPath,
|
||||
} from "./sftp/utils";
|
||||
import { useSftpTabsState } from "./sftp/useSftpTabsState";
|
||||
import { useSftpBrowseConnectionLifecycle } from "./sftp/useSftpBrowseConnectionLifecycle";
|
||||
import { isSessionError } from "./sftp/errors";
|
||||
import { useSftpExternalOperations } from "./sftp/useSftpExternalOperations";
|
||||
import { useSftpTransfers } from "./sftp/useSftpTransfers";
|
||||
import { useSftpPaneActions } from "./sftp/useSftpPaneActions";
|
||||
import {
|
||||
closeSftpTabLifecycle,
|
||||
releaseSftpConnectionMetadata,
|
||||
useSftpConnections,
|
||||
} from "./sftp/useSftpConnections";
|
||||
@@ -181,12 +183,6 @@ export const useSftpState = (
|
||||
return null;
|
||||
}, [leftTabsRef, rightTabsRef]);
|
||||
|
||||
// Ref to track pending reconnections to avoid multiple reconnect attempts
|
||||
const reconnectingRef = useRef<{ left: boolean; right: boolean }>({
|
||||
left: false,
|
||||
right: false,
|
||||
});
|
||||
|
||||
// Map connectionId → cache key, set at connect time so each tab's
|
||||
// navigateTo can use the correct cache key even when multiple tabs
|
||||
// share the same hostId with different session-time overrides.
|
||||
@@ -195,6 +191,8 @@ export const useSftpState = (
|
||||
// Full Host used when each tab connected (includes session-time overrides).
|
||||
// Tab ids are stable across reconnect; connection ids are not.
|
||||
const connectedHostByTabIdRef = useRef<Map<string, Host | "local">>(new Map());
|
||||
const connectInFlightRef = useRef<Map<string, Promise<void>>>(new Map());
|
||||
const connectRequestByTabIdRef = useRef<Map<string, symbol>>(new Map());
|
||||
|
||||
// Full endpoint key captured at connect time (hostId:hostname:port:…).
|
||||
const getConnectionCacheKey = useCallback((connectionId: string) => {
|
||||
@@ -234,20 +232,29 @@ export const useSftpState = (
|
||||
|
||||
const closeTabAndClearHost = useCallback(async (side: "left" | "right", tabId: string) => {
|
||||
const pane = getPaneByTabId(tabId);
|
||||
connectedHostByTabIdRef.current.delete(tabId);
|
||||
if (pane?.connection) {
|
||||
await releaseSftpTabConnection({
|
||||
connectionId: pane.connection.id,
|
||||
isLocal: pane.connection.isLocal,
|
||||
sftpSessions: sftpSessionsRef.current,
|
||||
connectionCacheKeys: connectionCacheKeyMapRef.current,
|
||||
clearCacheForConnection,
|
||||
closeSftp: async (sftpId) => netcattyBridge.get()?.closeSftp(sftpId),
|
||||
onRemoteSessionClosed: (sftpId) => forgetExternalEditTempsForSftpRef.current(sftpId),
|
||||
});
|
||||
}
|
||||
closeTab(side, tabId);
|
||||
}, [clearCacheForConnection, closeTab, getPaneByTabId]);
|
||||
await closeSftpTabLifecycle({
|
||||
requestedSide: side,
|
||||
tabId,
|
||||
leftTabs: leftTabsRef.current.tabs,
|
||||
rightTabs: rightTabsRef.current.tabs,
|
||||
connectRequests: connectRequestByTabIdRef.current,
|
||||
connectInFlight: connectInFlightRef.current,
|
||||
connectedHosts: connectedHostByTabIdRef.current,
|
||||
closeTab: (liveSide) => closeTab(liveSide, tabId),
|
||||
releaseConnection: async () => {
|
||||
if (!pane?.connection) return;
|
||||
await releaseSftpTabConnection({
|
||||
connectionId: pane.connection.id,
|
||||
isLocal: pane.connection.isLocal,
|
||||
sftpSessions: sftpSessionsRef.current,
|
||||
connectionCacheKeys: connectionCacheKeyMapRef.current,
|
||||
clearCacheForConnection,
|
||||
closeSftp: async (sftpId) => netcattyBridge.get()?.closeSftp(sftpId),
|
||||
onRemoteSessionClosed: (sftpId) => forgetExternalEditTempsForSftpRef.current(sftpId),
|
||||
});
|
||||
},
|
||||
});
|
||||
}, [clearCacheForConnection, closeTab, getPaneByTabId, leftTabsRef, rightTabsRef]);
|
||||
|
||||
const releaseConnection = useCallback(async (connectionId: string) => {
|
||||
await releaseSftpConnectionMetadata({
|
||||
@@ -267,11 +274,10 @@ export const useSftpState = (
|
||||
updateActiveTab,
|
||||
navSeqRef,
|
||||
lastConnectedHostRef,
|
||||
reconnectingRef,
|
||||
releaseConnection,
|
||||
});
|
||||
|
||||
useSftpSessionCleanup(sftpSessionsRef);
|
||||
const sftpDisposedRef = useSftpSessionCleanup(sftpSessionsRef);
|
||||
useSftpFileWatch(options);
|
||||
|
||||
// Transfer channel pool (max concurrent sftpIds per host, short idle reuse).
|
||||
@@ -398,6 +404,8 @@ export const useSftpState = (
|
||||
);
|
||||
|
||||
/** True after browse channels were soft-closed while this owner stayed mounted. */
|
||||
const interactive = options?.interactive !== false;
|
||||
const browseConnectionLifecycleRef = useSftpBrowseConnectionLifecycle(interactive);
|
||||
const browseParkedRef = useRef(false);
|
||||
const browseLifecycleGenRef = useRef(0);
|
||||
|
||||
@@ -430,10 +438,13 @@ export const useSftpState = (
|
||||
navSeqRef,
|
||||
dirCacheRef,
|
||||
sftpSessionsRef,
|
||||
disposedRef: sftpDisposedRef,
|
||||
browseConnectionLifecycleRef,
|
||||
lastConnectedHostRef,
|
||||
connectionCacheKeyMapRef,
|
||||
connectedHostByTabIdRef,
|
||||
reconnectingRef,
|
||||
connectInFlightRef,
|
||||
connectRequestByTabIdRef,
|
||||
makeCacheKey,
|
||||
clearCacheForConnection,
|
||||
createEmptyPane: createPane,
|
||||
@@ -474,7 +485,6 @@ export const useSftpState = (
|
||||
sftpSessionsRef,
|
||||
lastConnectedHostRef,
|
||||
connectionCacheKeyMapRef,
|
||||
reconnectingRef,
|
||||
makeCacheKey,
|
||||
clearCacheForConnection,
|
||||
listLocalFiles,
|
||||
@@ -637,7 +647,6 @@ export const useSftpState = (
|
||||
// has unfinished transfers so pre-lease prep (conflict/stat) cannot race a
|
||||
// hard-close of the browse id. In-flight streams also soft-close via leases;
|
||||
// pool handles bulk I/O.
|
||||
const interactive = options?.interactive !== false;
|
||||
useEffect(() => {
|
||||
const gen = ++browseLifecycleGenRef.current;
|
||||
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import React, { useEffect, useMemo, useRef, useState, useSyncExternalStore } from "react";
|
||||
import { useVirtualizer } from "@tanstack/react-virtual";
|
||||
|
||||
import { useI18n } from "../application/i18n/I18nProvider";
|
||||
import {
|
||||
@@ -341,12 +342,17 @@ function formatTransferPathLine(task: Pick<TransferTask, "sourcePath" | "targetP
|
||||
function TransferRow({
|
||||
task,
|
||||
childTasks = [],
|
||||
expanded,
|
||||
onToggleExpanded,
|
||||
isLast,
|
||||
}: {
|
||||
task: TransferTask;
|
||||
childTasks?: readonly TransferTask[];
|
||||
expanded: boolean;
|
||||
onToggleExpanded: () => void;
|
||||
isLast: boolean;
|
||||
}) {
|
||||
const { t } = useI18n();
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const folderReplaceWarningId = React.useId();
|
||||
// Optimistic spinner from click until store status moves off paused/interrupted.
|
||||
const [resumeClicked, setResumeClicked] = useState(false);
|
||||
@@ -478,7 +484,7 @@ function TransferRow({
|
||||
|
||||
return (
|
||||
<div
|
||||
className="border-b border-border/40 px-3 py-2.5 last:border-b-0 hover:bg-muted/30"
|
||||
className={cn("border-border/40 px-3 py-2.5 hover:bg-muted/30", isLast ? "border-b-0" : "border-b")}
|
||||
data-section="global-sftp-transfer-row"
|
||||
data-transfer-status={task.status}
|
||||
data-directory-parent={isDirParent ? "true" : undefined}
|
||||
@@ -506,7 +512,7 @@ function TransferRow({
|
||||
{canToggleChildren && (
|
||||
<TransferAction
|
||||
label={expanded ? t("sftp.transfers.collapseChildren") : t("sftp.transfers.expandChildren")}
|
||||
onClick={() => setExpanded((value) => !value)}
|
||||
onClick={onToggleExpanded}
|
||||
>
|
||||
{expanded ? <ChevronUp size={13} /> : <ChevronDown size={13} />}
|
||||
</TransferAction>
|
||||
@@ -755,6 +761,59 @@ function TransferRow({
|
||||
);
|
||||
}
|
||||
|
||||
function TransferList({ tasks, childrenByParent, empty }: {
|
||||
tasks: readonly TransferTask[];
|
||||
childrenByParent: ReadonlyMap<string, TransferTask[]>;
|
||||
empty: React.ReactNode;
|
||||
}) {
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
// Keep folder expansion when a row scrolls out of the mounted viewport.
|
||||
const [expandedIds, setExpandedIds] = useState<ReadonlySet<string>>(() => new Set());
|
||||
const virtual = tasks.length > 20;
|
||||
const virtualizer = useVirtualizer({
|
||||
count: tasks.length,
|
||||
getScrollElement: () => scrollRef.current,
|
||||
estimateSize: () => 112,
|
||||
getItemKey: (index) => tasks[index].id,
|
||||
overscan: 4,
|
||||
enabled: virtual,
|
||||
});
|
||||
const renderRow = (task: TransferTask, index: number) => (
|
||||
<TransferRow
|
||||
key={task.id}
|
||||
task={task}
|
||||
childTasks={childrenByParent.get(task.id) ?? []}
|
||||
expanded={expandedIds.has(task.id)}
|
||||
isLast={index === tasks.length - 1}
|
||||
onToggleExpanded={() => setExpandedIds((previous) => {
|
||||
const next = new Set(previous);
|
||||
if (next.has(task.id)) next.delete(task.id);
|
||||
else next.add(task.id);
|
||||
return next;
|
||||
})}
|
||||
/>
|
||||
);
|
||||
return (
|
||||
<div ref={scrollRef} className="max-h-[460px] overflow-auto" data-section="global-sftp-transfer-list">
|
||||
{tasks.length === 0 ? empty : !virtual ? tasks.map(renderRow) : (
|
||||
<div className="relative w-full" style={{ height: virtualizer.getTotalSize() }}>
|
||||
{virtualizer.getVirtualItems().map((row) => (
|
||||
<div
|
||||
key={row.key}
|
||||
ref={virtualizer.measureElement}
|
||||
data-index={row.index}
|
||||
className="absolute left-0 top-0 w-full"
|
||||
style={{ transform: `translateY(${row.start}px)` }}
|
||||
>
|
||||
{renderRow(tasks[row.index], row.index)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function GlobalSftpTransferCenter() {
|
||||
const { t } = useI18n();
|
||||
// Badge is a stable store snapshot (identity unchanged on pure progress).
|
||||
@@ -866,20 +925,17 @@ export function GlobalSftpTransferCenter() {
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="max-h-[460px] overflow-auto">
|
||||
{displayed.length === 0 ? (
|
||||
<TransferList
|
||||
key={`${bucket}-${showBackground}`}
|
||||
tasks={displayed}
|
||||
childrenByParent={childrenByParent}
|
||||
empty={(
|
||||
<div className="flex h-40 flex-col items-center justify-center text-muted-foreground">
|
||||
{badge.hasAttention && bucket !== "attention" && bucket !== "all" ? <AlertCircle size={22} /> : <ArrowDownUp size={22} />}
|
||||
<span className="mt-2 text-xs">{t("sftp.transferCenter.empty")}</span>
|
||||
</div>
|
||||
) : displayed.map((task) => (
|
||||
<TransferRow
|
||||
key={task.id}
|
||||
task={task}
|
||||
childTasks={childrenByParent.get(task.id) ?? []}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
|
||||
{(() => {
|
||||
const showBackgroundToggle = collapsed.length > 0;
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import React from "react";
|
||||
import { createDomRenderer, dispatchDomEvent, flushEffects, installDomEnvironment } from "./test-support/renderReactDom.tsx";
|
||||
|
||||
test("large transfer histories mount only visible rows and can scroll to the last file", 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();
|
||||
});
|
||||
store.publishOwner("virtual-list-test", Array.from({ length: 1_000 }, (_, index) => ({
|
||||
id: `virtual-file-${index}`, fileName: `file-${index}.bin`,
|
||||
sourcePath: `/source/${index}`, targetPath: `/target/${index}`,
|
||||
sourceConnectionId: "source", targetConnectionId: "local", direction: "download" as const,
|
||||
status: "queued" as const, totalBytes: 1_000, transferredBytes: 0, speed: 0,
|
||||
startTime: index + 1, isDirectory: false,
|
||||
})));
|
||||
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 }));
|
||||
await flushEffects();
|
||||
const rows = () => env.document.querySelectorAll("[role=progressbar]");
|
||||
assert.ok(rows().length > 0 && rows().length < 40, `rendered ${rows().length} rows`);
|
||||
assert.ok(env.document.querySelector('[role=progressbar][aria-label="file-999.bin"]'));
|
||||
const firstRow = env.document.querySelector('[data-transfer-status="queued"]');
|
||||
assert.ok(firstRow);
|
||||
assert.equal(firstRow.matches('.last\\:border-b-0:last-child'), false, "a virtual row must not lose its divider just because it has a wrapper");
|
||||
|
||||
const scroll = env.document.querySelector<HTMLElement>("[data-section=global-sftp-transfer-list]");
|
||||
assert.ok(scroll);
|
||||
scroll.scrollTop = 112 * 1_000 - 460;
|
||||
await dispatchDomEvent(scroll, new env.window.Event("scroll"));
|
||||
await flushEffects();
|
||||
assert.ok(rows().length > 0 && rows().length < 40);
|
||||
assert.ok(env.document.querySelector('[role=progressbar][aria-label="file-0.bin"]'));
|
||||
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");
|
||||
});
|
||||
@@ -661,6 +661,9 @@ const SettingsPageContent: React.FC<{ settings: SettingsState; appLock?: AppLock
|
||||
setToggleWindowHotkey={settings.setToggleWindowHotkey}
|
||||
closeToTray={settings.closeToTray}
|
||||
setCloseToTray={settings.setCloseToTray}
|
||||
autoLaunchEnabled={settings.autoLaunchEnabled}
|
||||
setAutoLaunchEnabled={settings.setAutoLaunchEnabled}
|
||||
autoLaunchSupported={settings.autoLaunchSupported}
|
||||
httpNetworkProxy={settings.httpNetworkProxy}
|
||||
setHttpNetworkProxy={settings.setHttpNetworkProxy}
|
||||
hotkeyRegistrationError={settings.hotkeyRegistrationError}
|
||||
|
||||
+364
-330
@@ -28,6 +28,8 @@ import { useSftpState } from "../application/state/useSftpState";
|
||||
import {
|
||||
useReportSftpTransferOwnerActivity,
|
||||
} from "../application/state/sftp/useSftpTransferLifecycle";
|
||||
import { useSftpFollowTerminalCwd } from "../application/state/sftp/useSftpFollowTerminalCwd";
|
||||
import { usePendingSftpUploadRebind } from "../application/state/sftp/usePendingSftpUploadRebind";
|
||||
import { registerEditorSftpWriterScoped } from "../application/state/editorSftpBridge";
|
||||
import {
|
||||
editorTabStore,
|
||||
@@ -65,13 +67,8 @@ import { keepOnlyPaneSelections } from "./sftp/hooks/selectionScope";
|
||||
import { KeyBinding, HotkeyScheme } from "../domain/models";
|
||||
import {
|
||||
mergeLatestFollowTerminalCwdHostSetting,
|
||||
runInitialFollowTerminalCwdSync,
|
||||
resolveHostFollowTerminalCwd,
|
||||
shouldApplyFollowTerminalCwdSyncResult,
|
||||
shouldClearBlockedFollowOnReach,
|
||||
shouldFollowTerminalCwdNavigate,
|
||||
type SftpFollowTerminalCwdBlock,
|
||||
} from "./sftp/sftpFollowTerminalCwd";
|
||||
} from "../domain/sftpFollowTerminalCwd";
|
||||
import {
|
||||
canLocateSftpPathInTerminal,
|
||||
resolveLocateSftpPathInTerminalAction,
|
||||
@@ -85,17 +82,26 @@ import { getNextSftpToolbarDisplayPath } from "./sftp/SftpPaneToolbar";
|
||||
import { scheduleDeferredTerminalFocus } from "./systemManager/tmuxActionFocus";
|
||||
import {
|
||||
connectionKeyMatchesHost,
|
||||
findPendingSftpRebindTargetPane,
|
||||
findReusableSftpSidePanelTab,
|
||||
isPendingSameEndpointSshSession,
|
||||
rememberSftpSidePanelSourceStatus,
|
||||
resolvePendingSftpUploadCancellation,
|
||||
resolveSftpSidePanelTrackedSourceStatusUpdate,
|
||||
shouldAcceptPendingSftpUpload,
|
||||
shouldBlockPendingSftpUploadForSourceRebind,
|
||||
shouldCancelPendingSftpUpload,
|
||||
shouldCancelSettledPendingSftpRebindWithoutTarget,
|
||||
shouldDeferPendingSftpUploadForOriginFocus,
|
||||
shouldDeferSftpSidePanelAutoConnectForSession,
|
||||
shouldRebindSftpSidePanelSourceSession,
|
||||
shouldSkipSftpSidePanelAutoConnect,
|
||||
shouldStartPendingSftpUploadRebind,
|
||||
shouldWaitForPendingSftpRebind,
|
||||
} from "./sftp/sftpSidePanelAutoConnect";
|
||||
import {
|
||||
pruneSftpSidePanelTabConnectionKeys,
|
||||
canApplySftpSidePanelInitialLocation,
|
||||
pruneSftpSidePanelState,
|
||||
recallSftpSidePanelPath,
|
||||
rememberSftpSidePanelPath,
|
||||
} from "./sftp/sftpSidePanelConnectionMemory";
|
||||
@@ -126,7 +132,11 @@ interface SftpSidePanelProps {
|
||||
focusedSessionId?: string | null;
|
||||
initialLocation?: { hostId: string; path: string } | null;
|
||||
onInitialLocationApplied?: (location: { hostId: string; path: string }) => void;
|
||||
onCurrentPathChange?: (location: { hostId: string; connectionKey: string; path: string }) => void;
|
||||
onCurrentPathChange?: (location: {
|
||||
hostId: string;
|
||||
connectionKey: string;
|
||||
path: string;
|
||||
}) => void;
|
||||
onActiveTransfersChange?: (count: number) => void;
|
||||
/** External-editor temps that must keep this owner mounted after panel close. */
|
||||
onActiveExternalEditsChange?: (count: number) => void;
|
||||
@@ -142,6 +152,8 @@ interface SftpSidePanelProps {
|
||||
requestId: string;
|
||||
hostId: string;
|
||||
connectionKey: string;
|
||||
originSessionId?: string;
|
||||
sourceSessionId?: string;
|
||||
targetPath?: string;
|
||||
entries: DropEntry[];
|
||||
} | null;
|
||||
@@ -157,8 +169,10 @@ interface SftpSidePanelProps {
|
||||
onGetTerminalCwd?: (options?: {
|
||||
preferFreshBackend?: boolean;
|
||||
allowRendererFallback?: boolean;
|
||||
requireActiveShellCwd?: boolean;
|
||||
}) => Promise<string | null>;
|
||||
activeTerminalCwd?: string | null;
|
||||
activeTerminalCwdTrusted?: boolean;
|
||||
sftpFollowTerminalCwd?: boolean;
|
||||
onSftpFollowTerminalCwdChange?: (enabled: boolean, host?: Host | null) => void;
|
||||
onRequestTerminalFocus?: () => void;
|
||||
@@ -200,6 +214,7 @@ const SftpSidePanelInner: React.FC<SftpSidePanelProps> = ({
|
||||
setEditorWordWrap,
|
||||
onGetTerminalCwd,
|
||||
activeTerminalCwd = null,
|
||||
activeTerminalCwdTrusted = false,
|
||||
sftpFollowTerminalCwd = false,
|
||||
onSftpFollowTerminalCwdChange,
|
||||
onRequestTerminalFocus,
|
||||
@@ -504,6 +519,20 @@ const SftpSidePanelInner: React.FC<SftpSidePanelProps> = ({
|
||||
const lastSourceSessionStatusRef = useRef<string | null>(null);
|
||||
const lastAppliedInitialLocationKeyRef = useRef<string | null>(null);
|
||||
const handledPendingUploadIdRef = useRef<string | null>(null);
|
||||
const pendingUploadFocusSeenRef = useRef<{
|
||||
requestId: string;
|
||||
originFocused: boolean;
|
||||
sourceActive: boolean;
|
||||
} | null>(null);
|
||||
const {
|
||||
barrierRef: pendingUploadRebindBarrierRef,
|
||||
bindTarget: bindPendingUploadRebindTarget,
|
||||
clearBarrier: clearPendingUploadRebindBarrier,
|
||||
reset: resetPendingUploadRebind,
|
||||
settledRequestId: pendingUploadRebindSettledRequestId,
|
||||
start: startPendingUploadRebind,
|
||||
startedRequestIdRef: pendingUploadRebindStartedIdRef,
|
||||
} = usePendingSftpUploadRebind();
|
||||
const tabConnectionKeyMapRef = useRef<Map<string, string>>(new Map());
|
||||
/** Last browsed path per endpoint — survives session switches while the panel stays open. */
|
||||
const lastBrowsedPathByConnectionKeyRef = useRef<Map<string, string>>(new Map());
|
||||
@@ -511,7 +540,7 @@ const SftpSidePanelInner: React.FC<SftpSidePanelProps> = ({
|
||||
const [sftpUiReady, setSftpUiReady] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
pruneSftpSidePanelTabConnectionKeys(
|
||||
pruneSftpSidePanelState(
|
||||
tabConnectionKeyMapRef.current,
|
||||
[
|
||||
...sftp.leftTabs.tabs.map((tab) => tab.id),
|
||||
@@ -569,6 +598,39 @@ const SftpSidePanelInner: React.FC<SftpSidePanelProps> = ({
|
||||
activeHost.username,
|
||||
activeHost.sftpFileProtocol,
|
||||
);
|
||||
const pendingMatchesTarget = Boolean(
|
||||
pendingUpload?.hostId === activeHost.id
|
||||
&& pendingUpload.connectionKey === connectionKey
|
||||
);
|
||||
const pendingStrictSourceSessionId = (
|
||||
pendingMatchesTarget
|
||||
&& pendingUpload?.sourceSessionId
|
||||
&& pendingUpload.sourceSessionId === activeSessionId
|
||||
) ? pendingUpload.sourceSessionId : undefined;
|
||||
if (
|
||||
pendingUpload?.hostId === activeHost.id
|
||||
&& pendingUpload.connectionKey === connectionKey
|
||||
&& pendingUpload.sourceSessionId
|
||||
&& pendingUpload.sourceSessionId !== activeSessionId
|
||||
) return;
|
||||
if (shouldDeferPendingSftpUploadForOriginFocus({
|
||||
originSessionId: pendingUpload?.originSessionId,
|
||||
focusedSessionId,
|
||||
})) return;
|
||||
if (
|
||||
pendingUpload
|
||||
&& pendingUploadRebindStartedIdRef.current === pendingUpload.requestId
|
||||
&& pendingUploadRebindSettledRequestId !== pendingUpload.requestId
|
||||
) return;
|
||||
const pendingRequiresForcedRebind = pendingUpload
|
||||
? shouldStartPendingSftpUploadRebind({
|
||||
pendingMatchesTarget,
|
||||
requestId: pendingUpload.requestId,
|
||||
startedRequestId: pendingUploadRebindStartedIdRef.current,
|
||||
originSessionId: pendingUpload.originSessionId,
|
||||
sourceSessionId: pendingUpload.sourceSessionId,
|
||||
})
|
||||
: false;
|
||||
const pendingSameEndpointSession = sessions.find((session) => (
|
||||
isPendingSameEndpointSshSession(session, activeHost)
|
||||
));
|
||||
@@ -611,11 +673,12 @@ const SftpSidePanelInner: React.FC<SftpSidePanelProps> = ({
|
||||
if (activeTab && activeTabConnectionKey) {
|
||||
tabConnectionKeyMapRef.current.set(activeTab.id, activeTabConnectionKey);
|
||||
}
|
||||
// Rebind when the focused terminal session changes: saved host keys can lag
|
||||
// live session endpoints (edited host / unsaved user). Still keep the
|
||||
// Rebind when the focused terminal SSH session changes: saved host keys can
|
||||
// lag live session endpoints (edited host / unsaved user). Still keep the
|
||||
// browsed path sticky via remembered initialPath below.
|
||||
if (
|
||||
!sessionChanged
|
||||
&& !pendingRequiresForcedRebind
|
||||
&& shouldSkipSftpSidePanelAutoConnect(
|
||||
connectionKey,
|
||||
connectedKeyRef.current,
|
||||
@@ -659,7 +722,7 @@ const SftpSidePanelInner: React.FC<SftpSidePanelProps> = ({
|
||||
// (proxy/jump path can differ even when hostId/hostname/port/user match).
|
||||
// Same-endpoint rebind happens in place below with remembered initialPath so
|
||||
// we keep the browsed directory without stacking tabs.
|
||||
const existingTab = sessionChanged
|
||||
const existingTab = sessionChanged || pendingRequiresForcedRebind
|
||||
? null
|
||||
: findReusableSftpSidePanelTab(
|
||||
tabs,
|
||||
@@ -697,7 +760,7 @@ const SftpSidePanelInner: React.FC<SftpSidePanelProps> = ({
|
||||
// Capture the visible path before rebind so session switches keep it even
|
||||
// if the path-memory effect has not written this endpoint yet.
|
||||
if (
|
||||
sessionChanged
|
||||
(sessionChanged || pendingRequiresForcedRebind)
|
||||
&& activeTab?.connection
|
||||
&& !activeTab.connection.isLocal
|
||||
&& activeTab.connection.status === "connected"
|
||||
@@ -757,7 +820,7 @@ const SftpSidePanelInner: React.FC<SftpSidePanelProps> = ({
|
||||
// Same-endpoint rebind closes the old connection in place; keep a tab
|
||||
// when editors or in-flight transfers still depend on that connection id.
|
||||
|| (
|
||||
sessionChanged
|
||||
(sessionChanged || pendingRequiresForcedRebind)
|
||||
&& (hasEditorBoundToCurrentConnection || hasActiveTransferOnCurrentConnection)
|
||||
)
|
||||
)
|
||||
@@ -774,17 +837,46 @@ const SftpSidePanelInner: React.FC<SftpSidePanelProps> = ({
|
||||
|
||||
connectedKeyRef.current = connectionKey;
|
||||
connectedHostObjRef.current = activeHost;
|
||||
s.connect("left", activeHost, {
|
||||
sourceSessionId: activeSessionStatus === "connected"
|
||||
? (activeSessionId ?? undefined)
|
||||
: undefined,
|
||||
const connect = (
|
||||
connectRequestKey?: string,
|
||||
onConnectionCreated?: (target: { tabId: string; connectionId: string }) => void,
|
||||
) => s.connect("left", activeHost, {
|
||||
sourceSessionId: pendingStrictSourceSessionId
|
||||
?? (activeSessionStatus === "connected" ? (activeSessionId ?? undefined) : undefined),
|
||||
requireSourceSessionReuse: Boolean(pendingStrictSourceSessionId),
|
||||
...(connectRequestKey ? { connectRequestKey } : undefined),
|
||||
onConnectionCreated,
|
||||
...(initialPath ? { initialPath } : undefined),
|
||||
...(needsNewTab ? { forceNewTab: true } : undefined),
|
||||
onTabCreated: (tabId) => {
|
||||
tabConnectionKeyMapRef.current.set(tabId, connectionKey);
|
||||
},
|
||||
});
|
||||
}, [activeHost, activeSessionId, initialLocation, interactiveWorkActive, sessions]);
|
||||
if (pendingRequiresForcedRebind && pendingUpload) {
|
||||
startPendingUploadRebind({
|
||||
requestId: pendingUpload.requestId,
|
||||
previousConnectionId: currentConn?.id ?? null,
|
||||
connect: () => connect(
|
||||
pendingUpload.requestId,
|
||||
(target) => bindPendingUploadRebindTarget(pendingUpload.requestId, target),
|
||||
),
|
||||
});
|
||||
return;
|
||||
}
|
||||
void connect();
|
||||
}, [
|
||||
activeHost,
|
||||
activeSessionId,
|
||||
bindPendingUploadRebindTarget,
|
||||
focusedSessionId,
|
||||
initialLocation,
|
||||
interactiveWorkActive,
|
||||
pendingUpload,
|
||||
pendingUploadRebindSettledRequestId,
|
||||
pendingUploadRebindStartedIdRef,
|
||||
sessions,
|
||||
startPendingUploadRebind,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!activeHost || !isVisible) return;
|
||||
@@ -822,14 +914,55 @@ const SftpSidePanelInner: React.FC<SftpSidePanelProps> = ({
|
||||
|
||||
useEffect(() => {
|
||||
if (!activeHost || !initialLocation) return;
|
||||
if (initialLocation.hostId !== activeHost.id || !initialLocation.path) return;
|
||||
const s = sftpRef.current;
|
||||
const activePendingUpload = pendingUpload;
|
||||
const pendingRequiresExactTarget = Boolean(
|
||||
activePendingUpload
|
||||
&& (activePendingUpload.originSessionId || activePendingUpload.sourceSessionId)
|
||||
);
|
||||
const rebindBarrier = pendingUploadRebindBarrierRef.current;
|
||||
const pendingTargetPane = pendingRequiresExactTarget
|
||||
&& rebindBarrier?.requestId === activePendingUpload?.requestId
|
||||
? findPendingSftpRebindTargetPane(
|
||||
s.leftTabs.tabs,
|
||||
s.rightTabs.tabs,
|
||||
rebindBarrier.targetTabId,
|
||||
rebindBarrier.targetConnectionId,
|
||||
)
|
||||
: null;
|
||||
const targetPane = pendingRequiresExactTarget ? pendingTargetPane : s.leftPane;
|
||||
const connection = targetPane?.connection;
|
||||
const targetSide = targetPane && s.rightTabs.tabs.some((tab) => tab.id === targetPane.id)
|
||||
? "right"
|
||||
: "left";
|
||||
const expectedConnectionKey = buildCacheKey(
|
||||
activeHost.id,
|
||||
activeHost.hostname,
|
||||
activeHost.port,
|
||||
activeHost.protocol,
|
||||
activeHost.sftpSudo,
|
||||
activeHost.username,
|
||||
activeHost.sftpFileProtocol,
|
||||
);
|
||||
const actualConnectionKey = connection
|
||||
? (
|
||||
s.getConnectionCacheKey?.(connection.id)
|
||||
?? (targetPane ? tabConnectionKeyMapRef.current.get(targetPane.id) ?? null : null)
|
||||
)
|
||||
: null;
|
||||
if (!canApplySftpSidePanelInitialLocation({
|
||||
activeHostId: activeHost.id,
|
||||
initialLocation,
|
||||
expectedConnectionKey,
|
||||
actualConnectionKey,
|
||||
pendingRequiresExactTarget,
|
||||
pendingTargetConnectionId: pendingRequiresExactTarget
|
||||
? rebindBarrier?.targetConnectionId ?? null
|
||||
: null,
|
||||
connection,
|
||||
})) return;
|
||||
|
||||
const activePane = sftpRef.current.leftPane;
|
||||
const connection = activePane.connection;
|
||||
if (!connection || connection.isLocal || connection.hostId !== activeHost.id) return;
|
||||
if (connection.status !== "connected") return;
|
||||
|
||||
const locationKey = `${connectedKeyRef.current}:${initialLocation.path}`;
|
||||
const locationKey = `${connection!.id}:${initialLocation.path}`;
|
||||
if (lastAppliedInitialLocationKeyRef.current === locationKey) return;
|
||||
|
||||
lastAppliedInitialLocationKeyRef.current = locationKey;
|
||||
@@ -839,12 +972,16 @@ const SftpSidePanelInner: React.FC<SftpSidePanelProps> = ({
|
||||
return;
|
||||
}
|
||||
|
||||
sftpRef.current.navigateTo("left", initialLocation.path);
|
||||
void s.navigateTo(targetSide, initialLocation.path, { tabId: targetPane!.id });
|
||||
}, [
|
||||
activeHost,
|
||||
initialLocation,
|
||||
onInitialLocationApplied,
|
||||
pendingUpload,
|
||||
pendingUploadRebindBarrierRef,
|
||||
sftp.leftPane,
|
||||
sftp.leftTabs.tabs,
|
||||
sftp.rightTabs.tabs,
|
||||
]);
|
||||
|
||||
const onCurrentPathChangeRef = useRef(onCurrentPathChange);
|
||||
@@ -898,14 +1035,136 @@ const SftpSidePanelInner: React.FC<SftpSidePanelProps> = ({
|
||||
sftp.leftPane.connection?.hostId,
|
||||
sftp.leftPane.connection?.status,
|
||||
sftp.leftPane.id,
|
||||
onCurrentPathChange,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!pendingUpload || !activeHost) return;
|
||||
if (!pendingUpload) return;
|
||||
if (handledPendingUploadIdRef.current === pendingUpload.requestId) return;
|
||||
|
||||
const activePane = leftPane;
|
||||
const rebindBarrier = pendingUploadRebindBarrierRef.current;
|
||||
const forcedRebindTargetPane = (
|
||||
rebindBarrier?.requestId === pendingUpload.requestId
|
||||
&& pendingUploadRebindSettledRequestId === pendingUpload.requestId
|
||||
)
|
||||
? findPendingSftpRebindTargetPane(
|
||||
sftpRef.current.leftTabs.tabs,
|
||||
sftpRef.current.rightTabs.tabs,
|
||||
rebindBarrier.targetTabId,
|
||||
rebindBarrier.targetConnectionId,
|
||||
)
|
||||
: null;
|
||||
const activePane = forcedRebindTargetPane ?? leftPane;
|
||||
const connection = activePane.connection;
|
||||
const activeSessionStatus = activeSessionId
|
||||
? sessions.find((session) => session.id === activeSessionId)?.status ?? null
|
||||
: null;
|
||||
const originSessionStatus = pendingUpload.originSessionId
|
||||
? sessions.find((session) => session.id === pendingUpload.originSessionId)?.status ?? null
|
||||
: undefined;
|
||||
if (pendingUploadFocusSeenRef.current?.requestId !== pendingUpload.requestId) {
|
||||
pendingUploadFocusSeenRef.current = {
|
||||
requestId: pendingUpload.requestId,
|
||||
originFocused: false,
|
||||
sourceActive: false,
|
||||
};
|
||||
}
|
||||
const focusSeen = pendingUploadFocusSeenRef.current;
|
||||
if (
|
||||
pendingUpload.originSessionId
|
||||
&& focusedSessionId === pendingUpload.originSessionId
|
||||
) {
|
||||
focusSeen.originFocused = true;
|
||||
}
|
||||
if (
|
||||
pendingUpload.sourceSessionId
|
||||
&& activeSessionId === pendingUpload.sourceSessionId
|
||||
) {
|
||||
focusSeen.sourceActive = true;
|
||||
}
|
||||
const waitingForOriginFocus = Boolean(
|
||||
pendingUpload.originSessionId
|
||||
&& focusedSessionId
|
||||
&& focusedSessionId !== pendingUpload.originSessionId
|
||||
&& !focusSeen.originFocused
|
||||
&& originSessionStatus !== "disconnected"
|
||||
&& originSessionStatus !== null
|
||||
);
|
||||
const waitingForSourceSession = Boolean(
|
||||
pendingUpload.sourceSessionId
|
||||
&& activeSessionId
|
||||
&& activeSessionId !== pendingUpload.sourceSessionId
|
||||
&& !focusSeen.sourceActive
|
||||
);
|
||||
const cancellationReason = resolvePendingSftpUploadCancellation({
|
||||
pendingHostId: pendingUpload.hostId,
|
||||
pendingOriginSessionId: pendingUpload.originSessionId,
|
||||
pendingSourceSessionId: pendingUpload.sourceSessionId,
|
||||
originSessionStatus,
|
||||
activeHostId: activeHost?.id ?? null,
|
||||
activeSessionId: activeSessionId ?? null,
|
||||
focusedSessionId: focusedSessionId ?? null,
|
||||
panelVisible: isVisible,
|
||||
waitingForOriginFocus,
|
||||
waitingForSourceSession,
|
||||
connection,
|
||||
});
|
||||
const cancelPendingUpload = () => {
|
||||
handledPendingUploadIdRef.current = pendingUpload.requestId;
|
||||
clearPendingUploadRebindBarrier(pendingUpload.requestId);
|
||||
toast.error(t("terminal.dragDrop.uploadCancelled"), "SFTP");
|
||||
onPendingUploadHandled?.(pendingUpload.requestId);
|
||||
};
|
||||
if (shouldCancelPendingSftpUpload(cancellationReason, true)) {
|
||||
cancelPendingUpload();
|
||||
return;
|
||||
}
|
||||
if (waitingForOriginFocus || waitingForSourceSession) return;
|
||||
if (shouldCancelSettledPendingSftpRebindWithoutTarget({
|
||||
pendingRequiresRebind: Boolean(
|
||||
pendingUpload.originSessionId || pendingUpload.sourceSessionId
|
||||
),
|
||||
requestId: pendingUpload.requestId,
|
||||
startedRequestId: pendingUploadRebindStartedIdRef.current,
|
||||
settledRequestId: pendingUploadRebindSettledRequestId,
|
||||
barrierRequestId: rebindBarrier?.requestId,
|
||||
targetTabId: rebindBarrier?.targetTabId,
|
||||
targetConnectionId: rebindBarrier?.targetConnectionId,
|
||||
targetExists: Boolean(forcedRebindTargetPane),
|
||||
})) {
|
||||
cancelPendingUpload();
|
||||
return;
|
||||
}
|
||||
if (!activeHost) return;
|
||||
if (shouldBlockPendingSftpUploadForSourceRebind({
|
||||
pendingSourceSessionId: pendingUpload.sourceSessionId,
|
||||
previousSessionId: lastSourceSessionIdRef.current,
|
||||
activeSessionId,
|
||||
previousStatus: lastSourceSessionStatusRef.current,
|
||||
activeStatus: activeSessionStatus,
|
||||
})) return;
|
||||
const waitingForForcedRebind = shouldWaitForPendingSftpRebind({
|
||||
pendingRequiresRebind: Boolean(
|
||||
pendingUpload.originSessionId || pendingUpload.sourceSessionId
|
||||
),
|
||||
pendingSourceSessionId: pendingUpload.sourceSessionId,
|
||||
requestId: pendingUpload.requestId,
|
||||
startedRequestId: pendingUploadRebindStartedIdRef.current,
|
||||
settledRequestId: pendingUploadRebindSettledRequestId,
|
||||
tabId: activePane.id,
|
||||
connectionId: connection?.id,
|
||||
barrierRequestId: rebindBarrier?.requestId,
|
||||
previousConnectionId: rebindBarrier?.previousConnectionId,
|
||||
targetTabId: rebindBarrier?.targetTabId,
|
||||
targetConnectionId: rebindBarrier?.targetConnectionId,
|
||||
});
|
||||
if (shouldCancelPendingSftpUpload(cancellationReason, waitingForForcedRebind)) {
|
||||
// Only fail a strict drop after its replacement connection (not the old
|
||||
// disconnected pane) is the connection being evaluated.
|
||||
cancelPendingUpload();
|
||||
return;
|
||||
}
|
||||
if (waitingForForcedRebind) return;
|
||||
// Prefer the live connection cache key (includes session overrides). Fall
|
||||
// back to the tab map only when the connect-time stamp is not yet readable.
|
||||
const paneConnectionKey = connection && !connection.isLocal
|
||||
@@ -916,8 +1175,10 @@ const SftpSidePanelInner: React.FC<SftpSidePanelProps> = ({
|
||||
)
|
||||
: null;
|
||||
if (!shouldAcceptPendingSftpUpload({
|
||||
ownerPanelOpen,
|
||||
pendingHostId: pendingUpload.hostId,
|
||||
pendingConnectionKey: pendingUpload.connectionKey,
|
||||
pendingSourceSessionId: pendingUpload.sourceSessionId,
|
||||
activeHostId: activeHost.id,
|
||||
connection,
|
||||
paneConnectionKey,
|
||||
@@ -925,6 +1186,10 @@ const SftpSidePanelInner: React.FC<SftpSidePanelProps> = ({
|
||||
return;
|
||||
}
|
||||
|
||||
if (rebindBarrier?.requestId === pendingUpload.requestId) {
|
||||
clearPendingUploadRebindBarrier(pendingUpload.requestId);
|
||||
}
|
||||
|
||||
handledPendingUploadIdRef.current = pendingUpload.requestId;
|
||||
|
||||
const pinnedConnectionId = connection.id;
|
||||
@@ -935,8 +1200,14 @@ const SftpSidePanelInner: React.FC<SftpSidePanelProps> = ({
|
||||
targetPath: pendingUpload.targetPath,
|
||||
connectionId: pinnedConnectionId,
|
||||
tabId: pinnedTabId,
|
||||
strictConnectionPin: true,
|
||||
});
|
||||
reportSftpUploadResults({
|
||||
results,
|
||||
targetPath: pendingUpload.targetPath,
|
||||
t,
|
||||
toast,
|
||||
});
|
||||
reportSftpUploadResults({ results, t, toast });
|
||||
} catch (error) {
|
||||
logger.error("[SftpSidePanel] Failed to upload dropped files:", error);
|
||||
handledPendingUploadIdRef.current = null;
|
||||
@@ -953,10 +1224,42 @@ const SftpSidePanelInner: React.FC<SftpSidePanelProps> = ({
|
||||
void runUpload();
|
||||
}, [
|
||||
activeHost,
|
||||
activeSessionId,
|
||||
clearPendingUploadRebindBarrier,
|
||||
focusedSessionId,
|
||||
getConnectionCacheKey,
|
||||
isVisible,
|
||||
leftPane,
|
||||
onPendingUploadHandled,
|
||||
ownerPanelOpen,
|
||||
pendingUpload,
|
||||
pendingUploadRebindBarrierRef,
|
||||
pendingUploadRebindSettledRequestId,
|
||||
pendingUploadRebindStartedIdRef,
|
||||
sessions,
|
||||
t,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!pendingUpload) {
|
||||
pendingUploadFocusSeenRef.current = null;
|
||||
resetPendingUploadRebind();
|
||||
return;
|
||||
}
|
||||
if (!ownerPanelOpen) {
|
||||
if (handledPendingUploadIdRef.current !== pendingUpload.requestId) {
|
||||
handledPendingUploadIdRef.current = pendingUpload.requestId;
|
||||
clearPendingUploadRebindBarrier(pendingUpload.requestId);
|
||||
toast.error(t("terminal.dragDrop.uploadCancelled"), "SFTP");
|
||||
onPendingUploadHandled?.(pendingUpload.requestId);
|
||||
}
|
||||
}
|
||||
}, [
|
||||
clearPendingUploadRebindBarrier,
|
||||
onPendingUploadHandled,
|
||||
ownerPanelOpen,
|
||||
pendingUpload,
|
||||
resetPendingUploadRebind,
|
||||
t,
|
||||
]);
|
||||
|
||||
@@ -984,10 +1287,12 @@ const SftpSidePanelInner: React.FC<SftpSidePanelProps> = ({
|
||||
setEditorWordWrap={setEditorWordWrap}
|
||||
onGetTerminalCwd={onGetTerminalCwd}
|
||||
activeTerminalCwd={activeTerminalCwd}
|
||||
activeTerminalCwdTrusted={activeTerminalCwdTrusted}
|
||||
sftpFollowTerminalCwd={sftpFollowTerminalCwd}
|
||||
onSftpFollowTerminalCwdChange={onSftpFollowTerminalCwdChange}
|
||||
onRequestTerminalFocus={onRequestTerminalFocus}
|
||||
isVisible={isVisible}
|
||||
ownerPanelOpen={ownerPanelOpen}
|
||||
behaviorRef={behaviorRef}
|
||||
autoSyncRef={autoSyncRef}
|
||||
connectedHostObjRef={connectedHostObjRef}
|
||||
@@ -1030,12 +1335,16 @@ type SftpSidePanelInteractiveBodyProps = {
|
||||
onGetTerminalCwd?: (options?: {
|
||||
preferFreshBackend?: boolean;
|
||||
allowRendererFallback?: boolean;
|
||||
requireActiveShellCwd?: boolean;
|
||||
}) => Promise<string | null>;
|
||||
activeTerminalCwd?: string | null;
|
||||
activeTerminalCwdTrusted: boolean;
|
||||
sftpFollowTerminalCwd: boolean;
|
||||
onSftpFollowTerminalCwdChange?: (enabled: boolean, host?: Host | null) => void;
|
||||
onRequestTerminalFocus?: () => void;
|
||||
isVisible: boolean;
|
||||
/** Side panel still open for this terminal tab (another tool/tab may have focus). */
|
||||
ownerPanelOpen: boolean;
|
||||
behaviorRef: MutableRefObject<"open" | "transfer">;
|
||||
autoSyncRef: MutableRefObject<boolean>;
|
||||
connectedHostObjRef: MutableRefObject<Host | null>;
|
||||
@@ -1072,10 +1381,12 @@ const SftpSidePanelInteractiveBody: React.FC<SftpSidePanelInteractiveBodyProps>
|
||||
setEditorWordWrap,
|
||||
onGetTerminalCwd,
|
||||
activeTerminalCwd = null,
|
||||
activeTerminalCwdTrusted,
|
||||
sftpFollowTerminalCwd,
|
||||
onSftpFollowTerminalCwdChange,
|
||||
onRequestTerminalFocus,
|
||||
isVisible,
|
||||
ownerPanelOpen,
|
||||
behaviorRef,
|
||||
autoSyncRef,
|
||||
connectedHostObjRef,
|
||||
@@ -1275,59 +1586,33 @@ const SftpSidePanelInteractiveBody: React.FC<SftpSidePanelInteractiveBodyProps>
|
||||
const hasActiveWork = showTextEditor || !!permissionsState || showFileOpenerDialog
|
||||
|| (sftp.activeFileWatchCountRef?.current ?? 0) > 0
|
||||
|| (sftp.activeExternalEditCount ?? 0) > 0;
|
||||
|
||||
const blockedFollowRef = useRef<SftpFollowTerminalCwdBlock | null>(null);
|
||||
const handledFollowRef = useRef<SftpFollowTerminalCwdBlock | null>(null);
|
||||
const followSyncGenerationRef = useRef(0);
|
||||
const effectiveFollowTerminalCwdRef = useRef(effectiveFollowTerminalCwd);
|
||||
const canFollowTerminalCwdRef = useRef(canFollowTerminalCwd);
|
||||
const activeTerminalCwdRef = useRef(activeTerminalCwd);
|
||||
const connectionId = sftp.leftPane.connection?.id ?? null;
|
||||
const connectionIdRef = useRef(connectionId);
|
||||
const connectionPath = sftp.leftPane.connection?.currentPath ?? null;
|
||||
const isVisibleRef = useRef(isVisible);
|
||||
const hasActiveWorkRef = useRef(hasActiveWork);
|
||||
effectiveFollowTerminalCwdRef.current = effectiveFollowTerminalCwd;
|
||||
canFollowTerminalCwdRef.current = canFollowTerminalCwd;
|
||||
activeTerminalCwdRef.current = activeTerminalCwd;
|
||||
connectionIdRef.current = connectionId;
|
||||
isVisibleRef.current = isVisible;
|
||||
hasActiveWorkRef.current = hasActiveWork;
|
||||
|
||||
const invalidateInFlightFollowSync = useCallback(() => {
|
||||
followSyncGenerationRef.current += 1;
|
||||
blockedFollowRef.current = null;
|
||||
handledFollowRef.current = null;
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
invalidateInFlightFollowSync();
|
||||
}, [
|
||||
const {
|
||||
handleGoToTerminalCwd,
|
||||
handleToggleFollowTerminalCwd,
|
||||
} = useSftpFollowTerminalCwd({
|
||||
activeSessionId,
|
||||
focusedSessionId,
|
||||
activeTerminalCwd,
|
||||
followTerminalCwdHost?.id,
|
||||
activeTerminalCwdTrusted,
|
||||
canFollowTerminalCwd,
|
||||
connectionId,
|
||||
invalidateInFlightFollowSync,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
if (effectiveFollowTerminalCwd) return;
|
||||
invalidateInFlightFollowSync();
|
||||
}, [effectiveFollowTerminalCwd, invalidateInFlightFollowSync]);
|
||||
|
||||
useEffect(() => {
|
||||
const blockedFollow = blockedFollowRef.current;
|
||||
if (
|
||||
shouldClearBlockedFollowOnReach(
|
||||
blockedFollow,
|
||||
connectionId,
|
||||
connectionPath,
|
||||
sftp.leftPane.loading,
|
||||
)
|
||||
) {
|
||||
blockedFollowRef.current = null;
|
||||
handledFollowRef.current = blockedFollow;
|
||||
}
|
||||
}, [connectionId, connectionPath, sftp.leftPane.loading]);
|
||||
connectionIsLocal: sftp.leftPane.connection?.isLocal,
|
||||
connectionLoading: sftp.leftPane.loading,
|
||||
connectionPath,
|
||||
connectionStatus: sftp.leftPane.connection?.status,
|
||||
effectiveFollowTerminalCwd,
|
||||
followTerminalCwdHost,
|
||||
hasActiveWork,
|
||||
isVisible,
|
||||
ownerPanelOpen,
|
||||
onGetTerminalCwd,
|
||||
onPendingFollowOverride: setPendingFollowOverride,
|
||||
onSftpFollowTerminalCwdChange,
|
||||
sftpRef,
|
||||
});
|
||||
|
||||
// Match toolbar path semantics: keep the last confirmed path while navigateTo
|
||||
// has optimistically replaced connection.currentPath during an uncached load.
|
||||
@@ -1350,20 +1635,6 @@ const SftpSidePanelInteractiveBody: React.FC<SftpSidePanelInteractiveBodyProps>
|
||||
});
|
||||
}, [connectionId, connectionPath, sftp.leftPane.loading]);
|
||||
|
||||
const handleGoToTerminalCwd = useCallback(async () => {
|
||||
if (!onGetTerminalCwd) return;
|
||||
const cwd = await onGetTerminalCwd({ preferFreshBackend: true });
|
||||
if (!cwd) return;
|
||||
const navigateResult = await sftpRef.current.navigateTo("left", cwd);
|
||||
if (navigateResult === "reached") {
|
||||
blockedFollowRef.current = null;
|
||||
const connection = sftpRef.current.leftPane.connection;
|
||||
if (connection?.id) {
|
||||
handledFollowRef.current = { connectionId: connection.id, terminalCwd: cwd };
|
||||
}
|
||||
}
|
||||
}, [onGetTerminalCwd, sftpRef]);
|
||||
|
||||
const locatePathInTerminalContext = useMemo(() => {
|
||||
const connection = sftp.leftPane.connection;
|
||||
const locateSessionId = resolveLocateSftpPathSessionId({
|
||||
@@ -1453,244 +1724,6 @@ const SftpSidePanelInteractiveBody: React.FC<SftpSidePanelInteractiveBodyProps>
|
||||
terminalBackend,
|
||||
]);
|
||||
|
||||
const syncFollowToTerminalCwd = useCallback(async () => {
|
||||
if (!onGetTerminalCwd || !effectiveFollowTerminalCwd || !canFollowTerminalCwd) {
|
||||
return;
|
||||
}
|
||||
|
||||
const syncGeneration = followSyncGenerationRef.current;
|
||||
|
||||
const usesLiveTerminalCwd = Boolean(activeTerminalCwd);
|
||||
let terminalCwd = activeTerminalCwd;
|
||||
if (!terminalCwd) {
|
||||
terminalCwd = await onGetTerminalCwd({ preferFreshBackend: true });
|
||||
}
|
||||
if (!terminalCwd) return;
|
||||
if (!shouldApplyFollowTerminalCwdSyncResult({
|
||||
syncGeneration,
|
||||
currentGeneration: followSyncGenerationRef.current,
|
||||
followEnabled: effectiveFollowTerminalCwdRef.current,
|
||||
canFollow: canFollowTerminalCwdRef.current,
|
||||
})) {
|
||||
return;
|
||||
}
|
||||
|
||||
const connection = sftpRef.current.leftPane.connection;
|
||||
if (!shouldFollowTerminalCwdNavigate({
|
||||
followEnabled: effectiveFollowTerminalCwdRef.current,
|
||||
isVisible,
|
||||
terminalCwd,
|
||||
currentPath: connection?.currentPath,
|
||||
connectionId: connection?.id,
|
||||
hasActiveWork,
|
||||
isConnected: Boolean(connection && !connection.isLocal && connection.status === "connected"),
|
||||
blockedFollow: blockedFollowRef.current,
|
||||
handledFollow: handledFollowRef.current,
|
||||
})) {
|
||||
if (
|
||||
connection?.id
|
||||
&& !connection.isLocal
|
||||
&& connection.status === "connected"
|
||||
&& connection.currentPath === terminalCwd
|
||||
) {
|
||||
handledFollowRef.current = { connectionId: connection.id, terminalCwd };
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const expectedConnectionId = connection?.id ?? null;
|
||||
const shouldApplyCurrentFollowSync = () => (
|
||||
shouldApplyFollowTerminalCwdSyncResult({
|
||||
syncGeneration,
|
||||
currentGeneration: followSyncGenerationRef.current,
|
||||
followEnabled: effectiveFollowTerminalCwdRef.current,
|
||||
canFollow: canFollowTerminalCwdRef.current,
|
||||
expectedConnectionId,
|
||||
liveConnectionId: connectionIdRef.current,
|
||||
paneConnectionId: sftpRef.current.leftPane.connection?.id ?? null,
|
||||
expectedTerminalCwd: terminalCwd,
|
||||
liveTerminalCwd: activeTerminalCwdRef.current,
|
||||
requireLiveTerminalCwd: usesLiveTerminalCwd,
|
||||
})
|
||||
);
|
||||
const navigateResult = await sftpRef.current.navigateTo("left", terminalCwd, {
|
||||
shouldApply: shouldApplyCurrentFollowSync,
|
||||
});
|
||||
if (!shouldApplyFollowTerminalCwdSyncResult({
|
||||
syncGeneration,
|
||||
currentGeneration: followSyncGenerationRef.current,
|
||||
followEnabled: effectiveFollowTerminalCwdRef.current,
|
||||
canFollow: canFollowTerminalCwdRef.current,
|
||||
})) {
|
||||
return;
|
||||
}
|
||||
|
||||
const currentConnection = sftpRef.current.leftPane.connection;
|
||||
if (!currentConnection || currentConnection.id !== connection?.id) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (navigateResult === "failed" && currentConnection.id) {
|
||||
blockedFollowRef.current = { connectionId: currentConnection.id, terminalCwd };
|
||||
} else if (navigateResult === "superseded" && currentConnection.id) {
|
||||
handledFollowRef.current = { connectionId: currentConnection.id, terminalCwd };
|
||||
} else if (navigateResult === "reached") {
|
||||
blockedFollowRef.current = null;
|
||||
handledFollowRef.current = { connectionId: currentConnection.id, terminalCwd };
|
||||
}
|
||||
}, [
|
||||
activeTerminalCwd,
|
||||
canFollowTerminalCwd,
|
||||
effectiveFollowTerminalCwd,
|
||||
hasActiveWork,
|
||||
isVisible,
|
||||
onGetTerminalCwd,
|
||||
sftpRef,
|
||||
]);
|
||||
|
||||
const handleToggleFollowTerminalCwd = useCallback(() => {
|
||||
const nextEnabled = !effectiveFollowTerminalCwd;
|
||||
invalidateInFlightFollowSync();
|
||||
if (followTerminalCwdHost?.id) {
|
||||
setPendingFollowOverride({ hostId: followTerminalCwdHost.id, value: nextEnabled });
|
||||
}
|
||||
onSftpFollowTerminalCwdChange?.(nextEnabled, followTerminalCwdHost);
|
||||
}, [effectiveFollowTerminalCwd, followTerminalCwdHost, invalidateInFlightFollowSync, onSftpFollowTerminalCwdChange]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!effectiveFollowTerminalCwd || !canFollowTerminalCwd || !isVisible || hasActiveWork) return;
|
||||
void syncFollowToTerminalCwd();
|
||||
}, [
|
||||
activeTerminalCwd,
|
||||
canFollowTerminalCwd,
|
||||
effectiveFollowTerminalCwd,
|
||||
hasActiveWork,
|
||||
isVisible,
|
||||
connectionId,
|
||||
sftp.leftPane.connection?.status,
|
||||
sftp.leftPane.connection?.isLocal,
|
||||
syncFollowToTerminalCwd,
|
||||
]);
|
||||
|
||||
// First open resync (#2335). While the SFTP panel is closed, the per-command
|
||||
// cwd probe does not run, so `activeTerminalCwd` can be stale (it still points
|
||||
// at the login home even though the terminal has since `cd`-ed elsewhere). On
|
||||
// that stale value the normal follow sync sees currentPath === terminalCwd and
|
||||
// does nothing, leaving the panel at home. When the panel first becomes
|
||||
// visible for a connected remote, force one fresh backend probe (bypassing the
|
||||
// stale cache) and navigate to the terminal's real cwd. Reset on hide so
|
||||
// reopening after another `cd` resyncs again.
|
||||
const initialFollowSyncedConnRef = useRef<string | null>(null);
|
||||
const initialFollowRetryRef = useRef<{ connectionId: string | null; attempts: number }>({
|
||||
connectionId: null,
|
||||
attempts: 0,
|
||||
});
|
||||
const initialFollowRetryTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const initialFollowMountedRef = useRef(true);
|
||||
const [initialFollowRetryNonce, setInitialFollowRetryNonce] = useState(0);
|
||||
useEffect(() => {
|
||||
initialFollowMountedRef.current = true;
|
||||
return () => {
|
||||
initialFollowMountedRef.current = false;
|
||||
if (initialFollowRetryTimerRef.current) clearTimeout(initialFollowRetryTimerRef.current);
|
||||
};
|
||||
}, []);
|
||||
useEffect(() => {
|
||||
if (!isVisible || initialFollowRetryRef.current.connectionId !== connectionId) {
|
||||
initialFollowSyncedConnRef.current = null;
|
||||
initialFollowRetryRef.current = { connectionId, attempts: 0 };
|
||||
if (initialFollowRetryTimerRef.current) {
|
||||
clearTimeout(initialFollowRetryTimerRef.current);
|
||||
initialFollowRetryTimerRef.current = null;
|
||||
}
|
||||
}
|
||||
}, [connectionId, isVisible]);
|
||||
useEffect(() => {
|
||||
if (!effectiveFollowTerminalCwd || !canFollowTerminalCwd || !isVisible || hasActiveWork) return;
|
||||
const connection = sftpRef.current.leftPane.connection;
|
||||
if (
|
||||
!connection
|
||||
|| connection.isLocal
|
||||
|| connection.status !== "connected"
|
||||
|| !connection.id
|
||||
) {
|
||||
return;
|
||||
}
|
||||
if (initialFollowSyncedConnRef.current === connection.id) return;
|
||||
if (initialFollowRetryRef.current.connectionId !== connection.id) {
|
||||
initialFollowRetryRef.current = { connectionId: connection.id, attempts: 0 };
|
||||
}
|
||||
if (initialFollowRetryRef.current.attempts >= 3) return;
|
||||
initialFollowRetryRef.current.attempts += 1;
|
||||
initialFollowSyncedConnRef.current = connection.id;
|
||||
const expectedConnectionId = connection.id;
|
||||
// Snapshot the (possibly stale) cached cwd so we can neutralize it below.
|
||||
const staleTerminalCwd = activeTerminalCwdRef.current;
|
||||
const syncGeneration = followSyncGenerationRef.current;
|
||||
// Follow is still eligible: same generation, still enabled/allowed, still
|
||||
// visible, and no interactive work has begun. Re-checked live via refs so a
|
||||
// probe that resolves after the panel is hidden or an editor/dialog opens
|
||||
// does not move the pane while follow should be paused (#2335).
|
||||
const followCurrentlyEligible = () => (
|
||||
initialFollowMountedRef.current
|
||||
&& effectiveFollowTerminalCwdRef.current
|
||||
&& canFollowTerminalCwdRef.current
|
||||
&& isVisibleRef.current
|
||||
&& !hasActiveWorkRef.current
|
||||
&& sftpRef.current.leftPane.connection?.id === expectedConnectionId
|
||||
&& !sftpRef.current.leftPane.connection?.isLocal
|
||||
&& sftpRef.current.leftPane.connection?.status === "connected"
|
||||
);
|
||||
const followStillEligible = () => (
|
||||
syncGeneration === followSyncGenerationRef.current
|
||||
&& followCurrentlyEligible()
|
||||
);
|
||||
const clearAttemptAndRetry = () => {
|
||||
if (initialFollowSyncedConnRef.current === expectedConnectionId) {
|
||||
initialFollowSyncedConnRef.current = null;
|
||||
}
|
||||
if (
|
||||
!initialFollowMountedRef.current
|
||||
|| !followCurrentlyEligible()
|
||||
|| initialFollowRetryRef.current.attempts >= 3
|
||||
) {
|
||||
return;
|
||||
}
|
||||
if (initialFollowRetryTimerRef.current) clearTimeout(initialFollowRetryTimerRef.current);
|
||||
initialFollowRetryTimerRef.current = setTimeout(() => {
|
||||
initialFollowRetryTimerRef.current = null;
|
||||
setInitialFollowRetryNonce((value) => value + 1);
|
||||
}, 250);
|
||||
};
|
||||
void runInitialFollowTerminalCwdSync({
|
||||
expectedConnectionId,
|
||||
staleTerminalCwd,
|
||||
getFreshTerminalCwd: () => onGetTerminalCwd?.({
|
||||
preferFreshBackend: true,
|
||||
allowRendererFallback: false,
|
||||
}),
|
||||
isEligible: followStillEligible,
|
||||
getConnection: () => sftpRef.current.leftPane.connection,
|
||||
navigate: (cwd, shouldApply) => sftpRef.current.navigateTo("left", cwd, { shouldApply }),
|
||||
setHandled: (value) => { handledFollowRef.current = value; },
|
||||
setBlocked: (value) => { blockedFollowRef.current = value; },
|
||||
}).then((completed) => {
|
||||
if (!completed) clearAttemptAndRetry();
|
||||
});
|
||||
}, [
|
||||
canFollowTerminalCwd,
|
||||
effectiveFollowTerminalCwd,
|
||||
hasActiveWork,
|
||||
initialFollowRetryNonce,
|
||||
isVisible,
|
||||
onGetTerminalCwd,
|
||||
sftpRef,
|
||||
activeTerminalCwd,
|
||||
sftp.leftPane.connection?.id,
|
||||
sftp.leftPane.connection?.isLocal,
|
||||
sftp.leftPane.connection?.status,
|
||||
]);
|
||||
|
||||
const MAX_VISIBLE_TRANSFERS = 5;
|
||||
const visibleTransfers = useMemo(() => {
|
||||
const connection = sftp.leftPane.connection;
|
||||
@@ -1946,6 +1979,7 @@ const sidePanelAreEqual = (prev: SftpSidePanelProps, next: SftpSidePanelProps):
|
||||
prev.setEditorWordWrap === next.setEditorWordWrap &&
|
||||
prev.onGetTerminalCwd === next.onGetTerminalCwd &&
|
||||
prev.activeTerminalCwd === next.activeTerminalCwd &&
|
||||
prev.activeTerminalCwdTrusted === next.activeTerminalCwdTrusted &&
|
||||
prev.sftpFollowTerminalCwd === next.sftpFollowTerminalCwd &&
|
||||
prev.onSftpFollowTerminalCwdChange === next.onSftpFollowTerminalCwdChange &&
|
||||
prev.onRequestTerminalFocus === next.onRequestTerminalFocus &&
|
||||
|
||||
+46
-11
@@ -75,6 +75,7 @@ import {
|
||||
import { usePluginTerminalProviders } from "../application/state/usePluginTerminalProviders";
|
||||
import type { PluginTerminalDecorationRule } from "../domain/pluginTerminalProviders";
|
||||
import { terminalReconnectRegistry } from "../application/state/terminalReconnectRegistry";
|
||||
import { resolveSftpReuseSourceSessionId } from "../application/state/terminalConnectionReuse";
|
||||
// SFTPModal removed - SFTP is now handled by SftpSidePanel in TerminalLayer
|
||||
import { Button } from "./ui/button";
|
||||
import { HoverCard, HoverCardContent, HoverCardTrigger } from "./ui/hover-card";
|
||||
@@ -186,7 +187,12 @@ import {
|
||||
getRemoteClipboardImageUploadErrorMessageKey,
|
||||
type RemoteClipboardImageUploadResult,
|
||||
} from "./terminal/clipboardImagePaste";
|
||||
import { createTerminalCwdTracker, resolvePreferredTerminalCwd } from "./terminal/sftpCwd";
|
||||
import {
|
||||
createTerminalCwdTracker,
|
||||
invalidateTerminalCwdAfterCommand,
|
||||
resolvePreferredTerminalCwd,
|
||||
type TerminalCwdChangeMeta,
|
||||
} from "./terminal/sftpCwd";
|
||||
import { useTerminalEffects } from "./terminal/useTerminalEffects";
|
||||
import { useTerminalHibernateEffect } from "./terminal/useTerminalHibernateEffect";
|
||||
import { readActiveTerminalBufferTextRange } from "./terminal/terminalContextBuffer";
|
||||
@@ -872,6 +878,10 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
);
|
||||
const sudoAutofillPasswordRef = useRef(resolvedSudoAutofillPassword);
|
||||
sudoAutofillPasswordRef.current = resolvedSudoAutofillPassword;
|
||||
const resolvedLoginUsername = useMemo(
|
||||
() => resolveHostAuth({ host, keys, identities }).username,
|
||||
[host, keys, identities],
|
||||
);
|
||||
const sudoAutofillCandidatesRef = useRef(resolvedSudoAutofillCandidates);
|
||||
sudoAutofillCandidatesRef.current = resolvedSudoAutofillCandidates;
|
||||
const [passwordPickerState, setPasswordPickerState] = useState<PasswordPromptPickerState | null>(null);
|
||||
@@ -1043,7 +1053,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
host,
|
||||
sessionId,
|
||||
onCommandExecuted,
|
||||
onCommandSubmitted,
|
||||
onCommandSubmitted: cwdAwareOnCommandSubmitted,
|
||||
onTrustedCommandSubmitted: pluginAwareOnCommandSubmitted,
|
||||
commandBufferRef,
|
||||
promptLineBreakStateRef,
|
||||
@@ -1083,13 +1093,16 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
const resolveSftpInitialPath = useCallback(async (options?: {
|
||||
preferFreshBackend?: boolean;
|
||||
allowRendererFallback?: boolean;
|
||||
requireActiveShellCwd?: boolean;
|
||||
}): Promise<string | undefined> => {
|
||||
const cwd = await resolvePreferredTerminalCwd({
|
||||
rendererCwd: terminalCwdTracker.getRendererCwd(),
|
||||
rendererCwdSource: terminalCwdTracker.getRendererCwdSource(),
|
||||
sessionId: sessionRef.current,
|
||||
getSessionPwd: (id, options) => terminalBackend.getSessionPwd(id, options),
|
||||
preferFreshBackend: options?.preferFreshBackend,
|
||||
allowRendererFallback: options?.allowRendererFallback,
|
||||
requireActiveShellCwd: options?.requireActiveShellCwd,
|
||||
});
|
||||
return cwd ?? undefined;
|
||||
}, [terminalBackend, terminalCwdTracker]);
|
||||
@@ -1600,10 +1613,10 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
passwordPromptActiveRef.current = payload.passwordPromptActive;
|
||||
}
|
||||
if (payload.cwd !== undefined) {
|
||||
const cwd = terminalCwdTracker.setRendererCwd(payload.cwd);
|
||||
const cwd = terminalCwdTracker.setRendererCwd(payload.cwd, "snapshot");
|
||||
knownCwdRef.current = cwd;
|
||||
pluginTerminalLifecycleRef.current?.onCwdChanged(cwd ?? null);
|
||||
onTerminalCwdChange?.(sessionId, cwd ?? null);
|
||||
onTerminalCwdChange?.(sessionId, cwd ?? null, { source: "snapshot" });
|
||||
}
|
||||
if (payload.title !== undefined) {
|
||||
const title = payload.title || null;
|
||||
@@ -2307,6 +2320,17 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
pluginTerminalLifecycle.onCommandSubmitted();
|
||||
void xtermRuntimeRef.current?.pluginProviderHost?.commandSubmitted(command);
|
||||
}, [pluginTerminalLifecycle]);
|
||||
const cwdAwareOnCommandSubmitted = useCallback((
|
||||
...args: Parameters<NonNullable<typeof onCommandSubmitted>>
|
||||
) => {
|
||||
invalidateTerminalCwdAfterCommand(
|
||||
terminalCwdTracker,
|
||||
sessionId,
|
||||
() => { knownCwdRef.current = undefined; },
|
||||
onTerminalCwdChange,
|
||||
);
|
||||
onCommandSubmitted?.(...args);
|
||||
}, [onCommandSubmitted, onTerminalCwdChange, sessionId, terminalCwdTracker]);
|
||||
const pluginAwareOnCommandCompleted = useCallback(() => {
|
||||
pluginTerminalLifecycle.onCommandCompleted();
|
||||
void xtermRuntimeRef.current?.pluginProviderHost?.commandCompleted();
|
||||
@@ -2314,16 +2338,19 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
const pluginAwareOnTerminalCwdChange = useCallback((
|
||||
changedSessionId: string,
|
||||
cwd: string | null,
|
||||
meta?: { source?: 'osc7' },
|
||||
meta?: TerminalCwdChangeMeta,
|
||||
) => {
|
||||
pluginTerminalLifecycle.onCwdChanged(cwd);
|
||||
onTerminalCwdChange?.(changedSessionId, cwd, meta);
|
||||
}, [onTerminalCwdChange, pluginTerminalLifecycle]);
|
||||
const pluginAwareOnRuntimeCwdChange = useCallback((
|
||||
cwd: string,
|
||||
meta?: { source?: 'osc7' },
|
||||
meta?: TerminalCwdChangeMeta,
|
||||
) => {
|
||||
const normalizedCwd = terminalCwdTracker.setRendererCwd(cwd);
|
||||
const normalizedCwd = terminalCwdTracker.setRendererCwd(
|
||||
cwd,
|
||||
meta?.source ?? "backend",
|
||||
);
|
||||
knownCwdRef.current = normalizedCwd;
|
||||
pluginAwareOnTerminalCwdChange(sessionId, normalizedCwd ?? null, meta);
|
||||
void refreshProviderOutputs('cwd-changed');
|
||||
@@ -2476,7 +2503,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
onProgrammaticCommandLogRewrite: queueProgrammaticCommandLogRewrite,
|
||||
onOsDetected,
|
||||
onCommandExecuted,
|
||||
onCommandSubmitted,
|
||||
onCommandSubmitted: cwdAwareOnCommandSubmitted,
|
||||
onCommandCompleted: pluginAwareOnCommandCompleted,
|
||||
sessionLog,
|
||||
sshDebugLogEnabled,
|
||||
@@ -3223,7 +3250,13 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
if (onOpenSftp) {
|
||||
// Delegate to parent (TerminalLayer) for shared SFTP side panel
|
||||
const initialPath = await resolveSftpInitialPath();
|
||||
onOpenSftp(host, initialPath, undefined, sessionId);
|
||||
onOpenSftp(
|
||||
host,
|
||||
initialPath,
|
||||
undefined,
|
||||
sessionId,
|
||||
resolveSftpReuseSourceSessionId(host, sessionId),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -3715,6 +3748,8 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
isDraggingOver,
|
||||
} = useTerminalDragDrop({
|
||||
host,
|
||||
resolvedLoginUsername,
|
||||
resolvedSudoPassword: resolvedSudoAutofillPassword,
|
||||
isLocalConnection,
|
||||
isNetworkDevice,
|
||||
onOpenSftp,
|
||||
@@ -3983,7 +4018,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
sessionId,
|
||||
statusRef,
|
||||
onCommandExecuted,
|
||||
onCommandSubmitted,
|
||||
onCommandSubmitted: cwdAwareOnCommandSubmitted,
|
||||
onTrustedCommandSubmitted: pluginAwareOnCommandSubmitted,
|
||||
onCommandCompleted: pluginAwareOnCommandCompleted,
|
||||
requestPluginTerminalProviders,
|
||||
@@ -4283,7 +4318,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
onWake: wakeFromHibernateRuntime,
|
||||
});
|
||||
|
||||
useTerminalEffects({ CONNECTION_TIMEOUT, Error, XTERM_PERFORMANCE_CONFIG, applyUserCursorPreference, auth, autocompleteCloseRef, autocompleteInputRef, autocompleteKeyEventRef, autocompleteRepositionRef, captureTerminalLogData, chainHosts: resolvedChainHosts, chainProgress, clearTerminalCwd, commandBufferRef, connectionLogBufferRef, containerRef, createPromptLineBreakState, createReplaySafeTerminalLogSanitizer, createXTermRuntime, deferTerminalResizeRef, disableTerminalFontZoomRef, effectiveFontSize, effectiveFontWeight, effectiveTheme, error, executeSnippetCommand, finalizeTerminalLogData, fitAddonRef, fontFamilyId, fontSize, fontWeightFixupDoneRef, forceCloseHibernatedSession, forceSyncRenderAfterResize, handleOsc52ReadRequest, handleTerminalDataCaptureOnce, hasConnectedRef, hasRuntimeRef, host, hotkeySchemeRef, hibernatedRef, identities, inWorkspace, isBootActiveRef, bootEpochRef, isBroadcastEnabledRef, isComposeBarOpen: effectiveComposeBarOpen, isConnectionAwaitingUserInput, isConnectionPastTcpDial, isFocusMode, isFocused, isLocalConnection, isNetworkDevice, isResizing: deferTerminalResize, isRestoringSelectionRef, isSearchOpen, isSerialConnection, isVisible, isVisibleRef, keyBindingsRef, keys, kittyKeyboardProtocolEnabledForSession, knownCwdRef, lastFittedSizeRef, lastToastedErrorRef, logger, mouseTrackingRef, needsHostKeyVerification, onBroadcastInputRef, onBroadcastInterruptPriorityChange, onCommandExecuted, onCommandSubmitted, onHotkeyActionRef, onOpenExternalError, onOutputTriggerUserInputRef: noteOutputTriggerUserInputRef, onPluginRuntimeCwdChange: pluginAwareOnRuntimeCwdChange, onSnippetShortkeyRef, onSnippetExecutorChange, onTerminalCwdChange, onTerminalTitleChange, onTerminalBell, onTerminalFontSizeChange, paneLayoutKey, passwordPromptActiveRef, pendingAuthRef, pendingOutputScrollRef, pluginDecorationRefreshRef, pluginDecorationRules, pluginDecorationRulesRef, pluginTerminalLifecycle, pluginTerminalProviderRevision, isPluginTerminalProviderAvailable, requestPluginTerminalProviders, prepareRestoredReconnect, prepareInitialCwdIntent, prevIsResizingRef, promptLineBreakStateRef, resizeSession, resolveHostAuth, resolvedFontFamily, safeFit, scriptRecorderRef: recorderRef, searchAddonRef, serialConfig, serialLineBufferRef, serializeAddonRef, sessionId, sessionRef, sessionStarters, setError, setHasMouseTracking, setIsCancelling, setIsDisconnectedDialogDismissed, requestSearchFocus, setNeedsHostKeyVerification, setPendingHostKeyInfo, setPendingHostKeyRequestId, setProgressLogs, setProgressValue, setShowLogs, setStatus, setTimeLeft, shellType, shouldEnableNativeUserInputAutoScroll, shouldProbeSessionCwd, shouldStartTerminalBackend, vaultInitialized, attachExistingSession, attachAuthorization, attachHomeWebContentsIdRef, snippetsRef, splitResizeActive: isResizing, status, statusRef, sudoAutofillRef, t, teardown, telnetLocalEchoRef, termRef, terminalAltKeyOptions, terminalBackend, terminalContextActionsRef, terminalCwdTracker, terminalDataCapturedRef, terminalLogSanitizerRef, terminalOutputHistory: terminalOutputHistoryRef.current, terminalSettings, terminalSettingsRef, terminalTitleRef, toHostKeyInfo, toast, updateStatus, useEffect, useLayoutEffect, workspaceId, xtermRuntimeRef, zmodem, zmodemToastedRef, restoreState });
|
||||
useTerminalEffects({ CONNECTION_TIMEOUT, Error, XTERM_PERFORMANCE_CONFIG, applyUserCursorPreference, auth, autocompleteCloseRef, autocompleteInputRef, autocompleteKeyEventRef, autocompleteRepositionRef, captureTerminalLogData, chainHosts: resolvedChainHosts, chainProgress, clearTerminalCwd, commandBufferRef, connectionLogBufferRef, containerRef, createPromptLineBreakState, createReplaySafeTerminalLogSanitizer, createXTermRuntime, deferTerminalResizeRef, disableTerminalFontZoomRef, effectiveFontSize, effectiveFontWeight, effectiveTheme, error, executeSnippetCommand, finalizeTerminalLogData, fitAddonRef, fontFamilyId, fontSize, fontWeightFixupDoneRef, forceCloseHibernatedSession, forceSyncRenderAfterResize, handleOsc52ReadRequest, handleTerminalDataCaptureOnce, hasConnectedRef, hasRuntimeRef, host, hotkeySchemeRef, hibernatedRef, identities, inWorkspace, isBootActiveRef, bootEpochRef, isBroadcastEnabledRef, isComposeBarOpen: effectiveComposeBarOpen, isConnectionAwaitingUserInput, isConnectionPastTcpDial, isFocusMode, isFocused, isLocalConnection, isNetworkDevice, isResizing: deferTerminalResize, isRestoringSelectionRef, isSearchOpen, isSerialConnection, isVisible, isVisibleRef, keyBindingsRef, keys, kittyKeyboardProtocolEnabledForSession, knownCwdRef, lastFittedSizeRef, lastToastedErrorRef, logger, mouseTrackingRef, needsHostKeyVerification, onBroadcastInputRef, onBroadcastInterruptPriorityChange, onCommandExecuted, onCommandSubmitted: cwdAwareOnCommandSubmitted, onHotkeyActionRef, onOpenExternalError, onOutputTriggerUserInputRef: noteOutputTriggerUserInputRef, onPluginRuntimeCwdChange: pluginAwareOnRuntimeCwdChange, onSnippetShortkeyRef, onSnippetExecutorChange, onTerminalCwdChange, onTerminalTitleChange, onTerminalBell, onTerminalFontSizeChange, paneLayoutKey, passwordPromptActiveRef, pendingAuthRef, pendingOutputScrollRef, pluginDecorationRefreshRef, pluginDecorationRules, pluginDecorationRulesRef, pluginTerminalLifecycle, pluginTerminalProviderRevision, isPluginTerminalProviderAvailable, requestPluginTerminalProviders, prepareRestoredReconnect, prepareInitialCwdIntent, prevIsResizingRef, promptLineBreakStateRef, resizeSession, resolveHostAuth, resolvedFontFamily, safeFit, scriptRecorderRef: recorderRef, searchAddonRef, serialConfig, serialLineBufferRef, serializeAddonRef, sessionId, sessionRef, sessionStarters, setError, setHasMouseTracking, setIsCancelling, setIsDisconnectedDialogDismissed, requestSearchFocus, setNeedsHostKeyVerification, setPendingHostKeyInfo, setPendingHostKeyRequestId, setProgressLogs, setProgressValue, setShowLogs, setStatus, setTimeLeft, shellType, shouldEnableNativeUserInputAutoScroll, shouldProbeSessionCwd, shouldStartTerminalBackend, vaultInitialized, attachExistingSession, attachAuthorization, attachHomeWebContentsIdRef, snippetsRef, splitResizeActive: isResizing, status, statusRef, sudoAutofillRef, t, teardown, telnetLocalEchoRef, termRef, terminalAltKeyOptions, terminalBackend, terminalContextActionsRef, terminalCwdTracker, terminalDataCapturedRef, terminalLogSanitizerRef, terminalOutputHistory: terminalOutputHistoryRef.current, terminalSettings, terminalSettingsRef, terminalTitleRef, toHostKeyInfo, toast, updateStatus, useEffect, useLayoutEffect, workspaceId, xtermRuntimeRef, zmodem, zmodemToastedRef, restoreState });
|
||||
|
||||
return (
|
||||
<>
|
||||
|
||||
@@ -106,7 +106,12 @@ import {
|
||||
resolveTerminalSidePanelAutoOpen,
|
||||
} from '../domain/terminalSidePanelAutoOpen';
|
||||
import { shouldProbeCommandCwd } from './terminalLayer/commandCwdProbe';
|
||||
import { resolvePreferredTerminalCwd, scheduleBackendCwdProbeAfterCommand } from './terminal/sftpCwd';
|
||||
import {
|
||||
resolvePreferredTerminalCwd,
|
||||
scheduleBackendCwdProbeAfterCommand,
|
||||
type RendererCwdSource,
|
||||
type TerminalCwdChangeMeta,
|
||||
} from './terminal/sftpCwd';
|
||||
import { classifyDistroId, shouldProbeSessionCwd } from '../domain/host';
|
||||
import {
|
||||
collectSidePanelPanes,
|
||||
@@ -308,6 +313,7 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
});
|
||||
}, [sessions, sidePanelLayouts]);
|
||||
const terminalRendererCwdBySessionRef = useRef<Map<string, string>>(new Map());
|
||||
const terminalRendererCwdSourceBySessionRef = useRef<Map<string, RendererCwdSource>>(new Map());
|
||||
const stableRef = useRef<Record<string, unknown>>({});
|
||||
const activeTabIdRef = useRef(activeTabStore.getActiveTabId());
|
||||
const activeWorkspaceRef = useRef<Workspace | undefined>(undefined);
|
||||
@@ -321,6 +327,7 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
const liveSessionIds = new Set(sessions.map((session) => session.id));
|
||||
pruneTerminalSessionRuntimeState({
|
||||
terminalRendererCwdBySessionRef,
|
||||
terminalRendererCwdSourceBySessionRef,
|
||||
terminalOsc7SignalBySessionRef,
|
||||
cwdProbeGenerationRef,
|
||||
cwdProbeCancelersRef,
|
||||
@@ -341,7 +348,7 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
const handleTerminalCwdChange = useCallback((
|
||||
sessionId: string,
|
||||
cwd: string | null,
|
||||
meta?: { source?: 'osc7' },
|
||||
meta?: TerminalCwdChangeMeta,
|
||||
) => {
|
||||
if (meta?.source === 'osc7') {
|
||||
// Bump on every OSC 7 report, even when the decoded path is unchanged.
|
||||
@@ -354,23 +361,27 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
|
||||
const currentCwd = terminalRendererCwdBySessionRef.current.get(sessionId) ?? null;
|
||||
const nextCwd = cwd && cwd.trim().length > 0 ? cwd : null;
|
||||
const nextCwdSource: RendererCwdSource = meta?.source ?? 'backend';
|
||||
if (currentCwd === nextCwd) {
|
||||
// Heal store drift if ref already has this path but the store does not.
|
||||
if (terminalCwdStore.getCwd(sessionId) !== nextCwd) {
|
||||
terminalCwdStore.setCwd(sessionId, nextCwd);
|
||||
if (nextCwd) {
|
||||
terminalRendererCwdSourceBySessionRef.current.set(sessionId, nextCwdSource);
|
||||
}
|
||||
// Heal store drift and publish provenance changes even when the path is unchanged.
|
||||
terminalCwdStore.setCwd(sessionId, nextCwd, nextCwd ? nextCwdSource : undefined);
|
||||
return;
|
||||
}
|
||||
|
||||
if (nextCwd) {
|
||||
terminalRendererCwdBySessionRef.current.set(sessionId, nextCwd);
|
||||
terminalRendererCwdSourceBySessionRef.current.set(sessionId, nextCwdSource);
|
||||
} else {
|
||||
terminalRendererCwdBySessionRef.current.delete(sessionId);
|
||||
terminalRendererCwdSourceBySessionRef.current.delete(sessionId);
|
||||
}
|
||||
onUpdateSessionRestoreCwd?.(sessionId, nextCwd);
|
||||
// External store: side-panel live snapshot subscribers update without
|
||||
// re-rendering TerminalLayerInner.
|
||||
terminalCwdStore.setCwd(sessionId, nextCwd);
|
||||
terminalCwdStore.setCwd(sessionId, nextCwd, nextCwd ? nextCwdSource : undefined);
|
||||
}, [onUpdateSessionRestoreCwd]);
|
||||
|
||||
// Stable while only presentation fields (title/provider) change.
|
||||
@@ -610,7 +621,7 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
Map<string, { hostId: string; path: string }>
|
||||
>(new Map());
|
||||
const [sftpPendingUploadsForTab, setSftpPendingUploadsForTab] = useState<
|
||||
Map<string, PendingSftpUpload>
|
||||
Map<string, PendingSftpUpload[]>
|
||||
>(new Map());
|
||||
const [pendingTerminalSelectionForAI, setPendingTerminalSelectionForAI] =
|
||||
useState<PendingTerminalSelectionForAI | null>(null);
|
||||
@@ -631,11 +642,11 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
host: Host;
|
||||
initialPath?: string;
|
||||
pendingUploadEntries?: DropEntry[];
|
||||
sourceSessionId?: string | null;
|
||||
originSessionId?: string | null;
|
||||
}) => {
|
||||
const { tabId, host, initialPath, pendingUploadEntries, sourceSessionId } = params;
|
||||
const { tabId, host, initialPath, pendingUploadEntries, originSessionId } = params;
|
||||
const connectionKey = buildCacheKey(host.id, host.hostname, host.port, host.protocol, host.sftpSudo, host.username, host.sftpFileProtocol);
|
||||
const memoryKey = getSftpReopenMemoryKey({ tabId, sourceSessionId });
|
||||
const memoryKey = getSftpReopenMemoryKey({ tabId, sourceSessionId: originSessionId });
|
||||
const effectiveInitialPath = resolveSftpOpenLocation({
|
||||
hostId: host.id,
|
||||
connectionKey,
|
||||
@@ -823,17 +834,23 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
setIsComposeBarOpen(prev => !prev);
|
||||
}, [setIsComposeBarOpen]);
|
||||
|
||||
const handleOpenSftp = useCallback((host: Host, initialPath?: string, pendingUploadEntries?: DropEntry[], sourceSessionId?: string) => {
|
||||
const handleOpenSftp = useCallback((
|
||||
host: Host,
|
||||
initialPath?: string,
|
||||
pendingUploadEntries?: DropEntry[],
|
||||
originSessionId?: string,
|
||||
sourceSessionId?: string,
|
||||
) => {
|
||||
const tabId = activeTabIdRef.current;
|
||||
if (!tabId) return;
|
||||
|
||||
// When SFTP is opened from a non-focused workspace pane (toolbar click
|
||||
// or drag-drop), switch focus first so the SFTP panel binds to the
|
||||
// correct host.
|
||||
if (sourceSessionId) {
|
||||
// originating terminal.
|
||||
if (originSessionId) {
|
||||
const ws = activeWorkspaceRef.current;
|
||||
if (ws && ws.focusedSessionId !== sourceSessionId) {
|
||||
onSetWorkspaceFocusedSessionRef.current?.(ws.id, sourceSessionId);
|
||||
if (ws && ws.focusedSessionId !== originSessionId) {
|
||||
onSetWorkspaceFocusedSessionRef.current?.(ws.id, originSessionId);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -894,7 +911,7 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
host,
|
||||
initialPath,
|
||||
pendingUploadEntries,
|
||||
sourceSessionId,
|
||||
originSessionId,
|
||||
});
|
||||
|
||||
setSftpInitialLocationForTab(prev => {
|
||||
@@ -908,31 +925,47 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
});
|
||||
|
||||
setSftpPendingUploadsForTab(prev => {
|
||||
const next = new Map(prev);
|
||||
if (!pendingUploadEntries?.length) {
|
||||
// Clear any stale pending upload when opening without new files.
|
||||
next.delete(tabId);
|
||||
} else {
|
||||
next.set(tabId, {
|
||||
// Opening the panel without new files must not discard drops that are
|
||||
// still queued and waiting to upload; leave the queue untouched.
|
||||
// Stale entries are cancelled by the panel itself (with a toast) when
|
||||
// their host/connection no longer matches.
|
||||
return prev;
|
||||
}
|
||||
const next = new Map(prev);
|
||||
// Queue the drop behind any earlier pending request for this tab so a
|
||||
// second drop that arrives before the first has started uploading
|
||||
// cannot silently discard the first batch.
|
||||
const queue = prev.get(tabId) ?? [];
|
||||
next.set(tabId, [
|
||||
...queue,
|
||||
{
|
||||
requestId: crypto.randomUUID(),
|
||||
hostId: host.id,
|
||||
connectionKey,
|
||||
originSessionId,
|
||||
sourceSessionId,
|
||||
targetPath: initialPath,
|
||||
entries: pendingUploadEntries,
|
||||
});
|
||||
}
|
||||
},
|
||||
]);
|
||||
return next;
|
||||
});
|
||||
}, [closeTerminalSidePanelTab, resolveSftpOpenTarget, setSidePanelOpenTabs, sidePanelLayoutsRef, sidePanelOpenTabsRef]);
|
||||
|
||||
const handlePendingUploadHandled = useCallback((tabId: string, requestId: string) => {
|
||||
setSftpPendingUploadsForTab(prev => {
|
||||
const current = prev.get(tabId);
|
||||
if (!current || current.requestId !== requestId) {
|
||||
return prev;
|
||||
}
|
||||
const queue = prev.get(tabId);
|
||||
if (!queue?.length) return prev;
|
||||
const index = queue.findIndex(item => item.requestId === requestId);
|
||||
if (index === -1) return prev;
|
||||
const next = new Map(prev);
|
||||
next.delete(tabId);
|
||||
const remaining = queue.filter((_, itemIndex) => itemIndex !== index);
|
||||
if (remaining.length) {
|
||||
next.set(tabId, remaining);
|
||||
} else {
|
||||
next.delete(tabId);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
@@ -1119,7 +1152,7 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
if (cwdProbeGenerationRef.current.get(sessionId) !== probeGeneration) return;
|
||||
const existing = terminalRendererCwdBySessionRef.current.get(sessionId);
|
||||
if (existing === cwd) return;
|
||||
handleTerminalCwdChange(sessionId, cwd);
|
||||
handleTerminalCwdChange(sessionId, cwd, { source: 'backend-strict' });
|
||||
},
|
||||
});
|
||||
cwdProbeCancelersRef.current.set(sessionId, cancelProbe);
|
||||
@@ -1132,6 +1165,7 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
useEffect(() => () => {
|
||||
pruneTerminalSessionRuntimeState({
|
||||
terminalRendererCwdBySessionRef,
|
||||
terminalRendererCwdSourceBySessionRef,
|
||||
terminalOsc7SignalBySessionRef,
|
||||
cwdProbeGenerationRef,
|
||||
cwdProbeCancelersRef,
|
||||
@@ -1313,14 +1347,19 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
const getTerminalCwd = useCallback(async (options?: {
|
||||
preferFreshBackend?: boolean;
|
||||
allowRendererFallback?: boolean;
|
||||
requireActiveShellCwd?: boolean;
|
||||
}): Promise<string | null> => {
|
||||
const sessionId = getActiveTerminalSessionId();
|
||||
return resolvePreferredTerminalCwd({
|
||||
rendererCwd: sessionId ? terminalRendererCwdBySessionRef.current.get(sessionId) : undefined,
|
||||
rendererCwdSource: sessionId
|
||||
? terminalRendererCwdSourceBySessionRef.current.get(sessionId)
|
||||
: undefined,
|
||||
sessionId,
|
||||
getSessionPwd: (id, options) => terminalBackend.getSessionPwd(id, options),
|
||||
preferFreshBackend: options?.preferFreshBackend,
|
||||
allowRendererFallback: options?.allowRendererFallback,
|
||||
requireActiveShellCwd: options?.requireActiveShellCwd,
|
||||
});
|
||||
}, [getActiveTerminalSessionId, terminalBackend]);
|
||||
|
||||
@@ -1396,7 +1435,7 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
const { effectiveInitialPath } = resolveSftpOpenTarget({
|
||||
tabId,
|
||||
host,
|
||||
sourceSessionId,
|
||||
originSessionId: sourceSessionId,
|
||||
});
|
||||
setSftpInitialLocationForTab(prev => {
|
||||
const next = new Map(prev);
|
||||
@@ -2372,6 +2411,7 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
TerminalPanesHost,
|
||||
terminalFontFamilyId,
|
||||
terminalRendererCwdBySessionRef,
|
||||
terminalRendererCwdSourceBySessionRef,
|
||||
terminalSettings,
|
||||
terminalTheme,
|
||||
terminalThemeId,
|
||||
|
||||
@@ -121,6 +121,9 @@ interface SettingsSystemTabProps {
|
||||
setToggleWindowHotkey: (hotkey: string) => void;
|
||||
closeToTray: boolean;
|
||||
setCloseToTray: (enabled: boolean) => void;
|
||||
autoLaunchEnabled: boolean;
|
||||
setAutoLaunchEnabled: (enabled: boolean) => void;
|
||||
autoLaunchSupported: boolean;
|
||||
httpNetworkProxy: HttpNetworkProxySettings;
|
||||
setHttpNetworkProxy: (settings: HttpNetworkProxySettings | ((prev: HttpNetworkProxySettings) => HttpNetworkProxySettings)) => void;
|
||||
hotkeyRegistrationError: string | null;
|
||||
@@ -170,6 +173,9 @@ const SettingsSystemTab: React.FC<SettingsSystemTabProps> = ({
|
||||
setToggleWindowHotkey,
|
||||
closeToTray,
|
||||
setCloseToTray,
|
||||
autoLaunchEnabled,
|
||||
setAutoLaunchEnabled,
|
||||
autoLaunchSupported,
|
||||
httpNetworkProxy,
|
||||
setHttpNetworkProxy,
|
||||
hotkeyRegistrationError,
|
||||
@@ -603,6 +609,25 @@ const SettingsSystemTab: React.FC<SettingsSystemTabProps> = ({
|
||||
{t('settings.update.hint')}
|
||||
</SettingHint>
|
||||
|
||||
<SectionHeader title={t("settings.autoLaunch.title")} />
|
||||
<SettingCard className="py-4">
|
||||
<SettingRow
|
||||
anchorId="system-auto-launch"
|
||||
label={t("settings.autoLaunch.enabled")}
|
||||
description={
|
||||
autoLaunchSupported
|
||||
? t("settings.autoLaunch.enabledDesc")
|
||||
: t("settings.autoLaunch.unsupportedDesc")
|
||||
}
|
||||
>
|
||||
<Toggle
|
||||
checked={autoLaunchEnabled}
|
||||
onChange={setAutoLaunchEnabled}
|
||||
disabled={!autoLaunchSupported}
|
||||
/>
|
||||
</SettingRow>
|
||||
</SettingCard>
|
||||
|
||||
<SectionHeader title={t("settings.system.networkProxy.title")} />
|
||||
<SettingCard className="space-y-4 py-4">
|
||||
<SettingRow
|
||||
|
||||
@@ -20,7 +20,10 @@ export interface SftpTransferSource {
|
||||
}
|
||||
|
||||
export type SftpConnectTarget = Host | "local";
|
||||
export type SftpConnectHostOptions = Pick<SftpConnectOptions, "sourceSessionId">;
|
||||
export type SftpConnectHostOptions = Pick<
|
||||
SftpConnectOptions,
|
||||
"sourceSessionId" | "requireSourceSessionReuse"
|
||||
>;
|
||||
|
||||
// Types for the context
|
||||
export interface SftpPaneCallbacks {
|
||||
|
||||
@@ -72,6 +72,15 @@ interface UseSftpViewPaneActionsResult {
|
||||
onReceiveFromOtherPaneRight: (files: SftpTransferSource[]) => void;
|
||||
}
|
||||
|
||||
export async function disconnectSftpPaneAfterConfirmation(params: {
|
||||
confirmClose: () => Promise<boolean>;
|
||||
disconnect: () => Promise<void>;
|
||||
}): Promise<boolean> {
|
||||
if (!await params.confirmClose()) return false;
|
||||
await params.disconnect();
|
||||
return true;
|
||||
}
|
||||
|
||||
export const useSftpViewPaneActions = ({
|
||||
sftpRef,
|
||||
t,
|
||||
@@ -181,16 +190,16 @@ export const useSftpViewPaneActions = ({
|
||||
);
|
||||
}, [sftpRef]);
|
||||
const onDisconnectLeft = useCallback(async (): Promise<boolean> => {
|
||||
const ok = await confirmCloseActivePaneEditors("left");
|
||||
if (!ok) return false;
|
||||
sftpRef.current.disconnect("left");
|
||||
return true;
|
||||
return disconnectSftpPaneAfterConfirmation({
|
||||
confirmClose: () => confirmCloseActivePaneEditors("left"),
|
||||
disconnect: () => sftpRef.current.disconnect("left"),
|
||||
});
|
||||
}, [confirmCloseActivePaneEditors, sftpRef]);
|
||||
const onDisconnectRight = useCallback(async (): Promise<boolean> => {
|
||||
const ok = await confirmCloseActivePaneEditors("right");
|
||||
if (!ok) return false;
|
||||
sftpRef.current.disconnect("right");
|
||||
return true;
|
||||
return disconnectSftpPaneAfterConfirmation({
|
||||
confirmClose: () => confirmCloseActivePaneEditors("right"),
|
||||
disconnect: () => sftpRef.current.disconnect("right"),
|
||||
});
|
||||
}, [confirmCloseActivePaneEditors, sftpRef]);
|
||||
const onPrepareSelectionLeft = useCallback(() => {
|
||||
keepOnlyActivePaneSelections(sftpRef.current, "left");
|
||||
|
||||
@@ -58,6 +58,25 @@ test("toasts multi-file success count", () => {
|
||||
assert.deepEqual(calls, [{ type: "success", message: "sftp.uploadFiles: 2" }]);
|
||||
});
|
||||
|
||||
test("terminal drop success names the exact remote destination", () => {
|
||||
const calls: Array<{ type: string; message: string }> = [];
|
||||
reportSftpUploadResults({
|
||||
results: [{ fileName: "release.tgz", success: true }],
|
||||
targetPath: "/srv/releases",
|
||||
t,
|
||||
toast: {
|
||||
success: (message) => calls.push({ type: "success", message }),
|
||||
error: (message) => calls.push({ type: "error", message }),
|
||||
info: (message) => calls.push({ type: "info", message }),
|
||||
},
|
||||
});
|
||||
|
||||
assert.deepEqual(calls, [{
|
||||
type: "success",
|
||||
message: "sftp.upload: release.tgz (/srv/releases)",
|
||||
}]);
|
||||
});
|
||||
|
||||
test("toasts failures and partial success separately", () => {
|
||||
const calls: Array<{ type: string; message: string }> = [];
|
||||
reportSftpUploadResults({
|
||||
@@ -76,3 +95,25 @@ test("toasts failures and partial success separately", () => {
|
||||
assert.equal(calls[1]?.type, "info");
|
||||
assert.match(calls[1]?.message ?? "", /partial 1\/1/);
|
||||
});
|
||||
|
||||
test("partial terminal drop success names the exact remote destination", () => {
|
||||
const calls: Array<{ type: string; message: string }> = [];
|
||||
reportSftpUploadResults({
|
||||
results: [
|
||||
{ fileName: "a", success: true },
|
||||
{ fileName: "b", success: false, error: "boom" },
|
||||
],
|
||||
targetPath: "/srv/releases",
|
||||
t,
|
||||
toast: {
|
||||
success: (message) => calls.push({ type: "success", message }),
|
||||
error: (message) => calls.push({ type: "error", message }),
|
||||
info: (message) => calls.push({ type: "info", message }),
|
||||
},
|
||||
});
|
||||
|
||||
assert.deepEqual(calls[1], {
|
||||
type: "info",
|
||||
message: "partial 1/1 (/srv/releases)",
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { UploadResult } from "../../../lib/uploadService.types";
|
||||
import type { UploadResult } from "../../lib/uploadService.types";
|
||||
|
||||
export type ReportSftpUploadResultsOptions = {
|
||||
results: readonly UploadResult[];
|
||||
@@ -10,6 +10,8 @@ export type ReportSftpUploadResultsOptions = {
|
||||
};
|
||||
/** Optional single-item success message override (e.g. folder name). */
|
||||
successMessage?: string;
|
||||
/** Exact remote directory selected for a terminal-drop upload. */
|
||||
targetPath?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -21,7 +23,11 @@ export function reportSftpUploadResults({
|
||||
t,
|
||||
toast,
|
||||
successMessage,
|
||||
targetPath,
|
||||
}: ReportSftpUploadResultsOptions): void {
|
||||
const withTargetPath = (message: string): string => (
|
||||
targetPath ? `${message} (${targetPath})` : message
|
||||
);
|
||||
if (results.some((result) => result.cancelled)) {
|
||||
toast.info(t("sftp.upload.cancelled"), "SFTP");
|
||||
return;
|
||||
@@ -41,7 +47,9 @@ export function reportSftpUploadResults({
|
||||
// Also mention partial success when some files made it.
|
||||
if (succeeded.length > 0) {
|
||||
toast.info(
|
||||
t("sftp.upload.partialSuccess", { success: succeeded.length, failed: failed.length }),
|
||||
withTargetPath(
|
||||
t("sftp.upload.partialSuccess", { success: succeeded.length, failed: failed.length }),
|
||||
),
|
||||
"SFTP",
|
||||
);
|
||||
}
|
||||
@@ -55,12 +63,12 @@ export function reportSftpUploadResults({
|
||||
}
|
||||
|
||||
if (successMessage) {
|
||||
toast.success(successMessage, "SFTP");
|
||||
toast.success(withTargetPath(successMessage), "SFTP");
|
||||
return;
|
||||
}
|
||||
|
||||
const message = succeeded.length === 1
|
||||
? `${t("sftp.upload")}: ${succeeded[0]?.fileName ?? ""}`
|
||||
: `${t("sftp.uploadFiles")}: ${succeeded.length}`;
|
||||
toast.success(message, "SFTP");
|
||||
toast.success(withTargetPath(message), "SFTP");
|
||||
}
|
||||
@@ -1,201 +0,0 @@
|
||||
export type SftpFollowTerminalCwdBlock = {
|
||||
connectionId: string;
|
||||
terminalCwd: string;
|
||||
};
|
||||
|
||||
export type SftpFollowTerminalCwdContext = {
|
||||
followEnabled: boolean;
|
||||
isVisible: boolean;
|
||||
terminalCwd?: string | null;
|
||||
currentPath?: string | null;
|
||||
connectionId?: string | null;
|
||||
hasActiveWork: boolean;
|
||||
isConnected: boolean;
|
||||
/** Skip auto-follow while this terminal cwd cannot be reached on SFTP. */
|
||||
blockedFollow?: SftpFollowTerminalCwdBlock | null;
|
||||
/** Skip auto-follow after this terminal cwd was already handled for the connection. */
|
||||
handledFollow?: SftpFollowTerminalCwdBlock | null;
|
||||
};
|
||||
|
||||
export type SftpFollowTerminalCwdSyncResultContext = {
|
||||
syncGeneration: number;
|
||||
currentGeneration: number;
|
||||
followEnabled: boolean;
|
||||
canFollow: boolean;
|
||||
expectedConnectionId?: string | null;
|
||||
liveConnectionId?: string | null;
|
||||
paneConnectionId?: string | null;
|
||||
expectedTerminalCwd?: string | null;
|
||||
liveTerminalCwd?: string | null;
|
||||
requireLiveTerminalCwd?: boolean;
|
||||
};
|
||||
|
||||
type InitialFollowConnection = {
|
||||
id: string;
|
||||
currentPath?: string | null;
|
||||
status: string;
|
||||
isLocal?: boolean;
|
||||
};
|
||||
|
||||
type InitialFollowSyncOptions = {
|
||||
expectedConnectionId: string;
|
||||
staleTerminalCwd?: string | null;
|
||||
getFreshTerminalCwd: () => Promise<string | null | undefined>;
|
||||
isEligible: () => boolean;
|
||||
getConnection: () => InitialFollowConnection | null | undefined;
|
||||
navigate: (
|
||||
cwd: string,
|
||||
shouldApply: () => boolean,
|
||||
) => Promise<"reached" | "failed" | "aborted" | "superseded">;
|
||||
setHandled: (value: SftpFollowTerminalCwdBlock) => void;
|
||||
setBlocked: (value: SftpFollowTerminalCwdBlock | null) => void;
|
||||
};
|
||||
|
||||
/** Run one guarded first-open sync. False means the caller may retry. */
|
||||
export const runInitialFollowTerminalCwdSync = async ({
|
||||
expectedConnectionId,
|
||||
staleTerminalCwd,
|
||||
getFreshTerminalCwd,
|
||||
isEligible,
|
||||
getConnection,
|
||||
navigate,
|
||||
setHandled,
|
||||
setBlocked,
|
||||
}: InitialFollowSyncOptions): Promise<boolean> => {
|
||||
const cwd = await getFreshTerminalCwd();
|
||||
if (!cwd || !isEligible()) return false;
|
||||
|
||||
const live = getConnection();
|
||||
if (!live || live.id !== expectedConnectionId || live.status !== "connected" || live.isLocal) {
|
||||
return false;
|
||||
}
|
||||
|
||||
setHandled({
|
||||
connectionId: expectedConnectionId,
|
||||
terminalCwd: staleTerminalCwd && staleTerminalCwd !== cwd ? staleTerminalCwd : cwd,
|
||||
});
|
||||
if (live.currentPath === cwd) return true;
|
||||
|
||||
const navigateResult = await navigate(cwd, isEligible);
|
||||
if (!isEligible()) return false;
|
||||
const current = getConnection();
|
||||
if (!current || current.id !== expectedConnectionId || current.status !== "connected") {
|
||||
return false;
|
||||
}
|
||||
if (navigateResult === "failed") {
|
||||
setBlocked({ connectionId: expectedConnectionId, terminalCwd: cwd });
|
||||
return true;
|
||||
}
|
||||
if (navigateResult === "reached") {
|
||||
setBlocked(null);
|
||||
return true;
|
||||
}
|
||||
return navigateResult === "superseded";
|
||||
};
|
||||
|
||||
export const resolveHostFollowTerminalCwd = (
|
||||
hostFollowTerminalCwd: boolean | undefined,
|
||||
globalFollowTerminalCwd: boolean,
|
||||
): boolean => hostFollowTerminalCwd ?? globalFollowTerminalCwd;
|
||||
|
||||
export const resolveSftpFollowTerminalCwdTargetHost = <T>(
|
||||
visibleHost: T | null | undefined,
|
||||
fallbackHost: T | null | undefined,
|
||||
): T | null => visibleHost ?? fallbackHost ?? null;
|
||||
|
||||
export const mergeLatestFollowTerminalCwdHostSetting = <
|
||||
T extends { id?: string; sftpFollowTerminalCwd?: boolean },
|
||||
>(
|
||||
displayHost: T | null | undefined,
|
||||
latestHost: T | null | undefined,
|
||||
pendingFollowOverride?: boolean,
|
||||
): T | null => {
|
||||
if (!displayHost) return latestHost ?? null;
|
||||
if (!latestHost || latestHost.id !== displayHost.id) return displayHost;
|
||||
|
||||
return {
|
||||
...latestHost,
|
||||
...displayHost,
|
||||
sftpFollowTerminalCwd:
|
||||
latestHost.sftpFollowTerminalCwd !== undefined
|
||||
? latestHost.sftpFollowTerminalCwd
|
||||
: pendingFollowOverride,
|
||||
};
|
||||
};
|
||||
|
||||
/** Clear a follow block once the user reaches the blocked cwd through any navigation. */
|
||||
export const shouldClearBlockedFollowOnReach = (
|
||||
blockedFollow: SftpFollowTerminalCwdBlock | null | undefined,
|
||||
connectionId: string | null | undefined,
|
||||
currentPath: string | null | undefined,
|
||||
loading: boolean,
|
||||
): boolean => {
|
||||
if (loading || !blockedFollow || !connectionId || !currentPath) return false;
|
||||
return (
|
||||
blockedFollow.connectionId === connectionId
|
||||
&& blockedFollow.terminalCwd === currentPath
|
||||
);
|
||||
};
|
||||
|
||||
/** Whether an async follow result still belongs to the current terminal/connection state. */
|
||||
export const shouldApplyFollowTerminalCwdSyncResult = ({
|
||||
syncGeneration,
|
||||
currentGeneration,
|
||||
followEnabled,
|
||||
canFollow,
|
||||
expectedConnectionId,
|
||||
liveConnectionId,
|
||||
paneConnectionId,
|
||||
expectedTerminalCwd,
|
||||
liveTerminalCwd,
|
||||
requireLiveTerminalCwd = false,
|
||||
}: SftpFollowTerminalCwdSyncResultContext): boolean => {
|
||||
if (syncGeneration !== currentGeneration || !followEnabled || !canFollow) {
|
||||
return false;
|
||||
}
|
||||
if (expectedConnectionId !== undefined) {
|
||||
if (!expectedConnectionId) return false;
|
||||
if (liveConnectionId !== undefined && liveConnectionId !== expectedConnectionId) return false;
|
||||
if (paneConnectionId !== undefined && paneConnectionId !== expectedConnectionId) return false;
|
||||
}
|
||||
if (expectedTerminalCwd !== undefined) {
|
||||
if (requireLiveTerminalCwd && !liveTerminalCwd) return false;
|
||||
if (liveTerminalCwd && liveTerminalCwd !== expectedTerminalCwd) return false;
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
/** Whether SFTP should auto-navigate to match the linked terminal cwd. */
|
||||
export const shouldFollowTerminalCwdNavigate = ({
|
||||
followEnabled,
|
||||
isVisible,
|
||||
terminalCwd,
|
||||
currentPath,
|
||||
connectionId,
|
||||
hasActiveWork,
|
||||
isConnected,
|
||||
blockedFollow,
|
||||
handledFollow,
|
||||
}: SftpFollowTerminalCwdContext): boolean => {
|
||||
if (!followEnabled || !isVisible || !isConnected) return false;
|
||||
if (hasActiveWork) return false;
|
||||
if (!terminalCwd || terminalCwd.trim().length === 0) return false;
|
||||
if (
|
||||
handledFollow
|
||||
&& connectionId
|
||||
&& handledFollow.connectionId === connectionId
|
||||
&& handledFollow.terminalCwd === terminalCwd
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
if (
|
||||
blockedFollow
|
||||
&& connectionId
|
||||
&& blockedFollow.connectionId === connectionId
|
||||
&& blockedFollow.terminalCwd === terminalCwd
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
if (!currentPath || currentPath === terminalCwd) return false;
|
||||
return true;
|
||||
};
|
||||
@@ -42,6 +42,6 @@ test("locate-path uses focused session fallback when SFTP cannot reuse the termi
|
||||
assert.match(sidePanelSource, /resolveLocateSftpPathSessionId\(\{\s*activeSessionId,\s*focusedSessionId,/);
|
||||
assert.match(
|
||||
slotSource,
|
||||
/focusedSessionId=\{isVisible \? live\.focusedSessionId : null\}/,
|
||||
/focusedSessionId=\{panelFocusedSessionId\}/,
|
||||
);
|
||||
});
|
||||
@@ -3,16 +3,24 @@ import assert from "node:assert/strict";
|
||||
import type { SftpPane } from "../../application/state/sftp/types";
|
||||
import {
|
||||
connectionKeyMatchesHost,
|
||||
findPendingSftpRebindTargetPane,
|
||||
findReusableSftpSidePanelTab,
|
||||
isPendingSameEndpointSshSession,
|
||||
isRemoteSftpTabHealthy,
|
||||
rememberSftpSidePanelSourceStatus,
|
||||
resolvePendingSftpUploadCancellation,
|
||||
resolveSftpSidePanelTrackedSourceStatusUpdate,
|
||||
shouldAcceptPendingSftpUpload,
|
||||
shouldBlockPendingSftpUploadForSourceRebind,
|
||||
shouldCancelPendingSftpUpload,
|
||||
shouldCancelSettledPendingSftpRebindWithoutTarget,
|
||||
shouldDeferPendingSftpUploadForOriginFocus,
|
||||
shouldDeferSftpSidePanelAutoConnectForSession,
|
||||
shouldRebindSftpSidePanelSourceSession,
|
||||
shouldResetSftpSidePanelSourceSession,
|
||||
shouldSkipSftpSidePanelAutoConnect,
|
||||
shouldStartPendingSftpUploadRebind,
|
||||
shouldWaitForPendingSftpRebind,
|
||||
} from "./sftpSidePanelAutoConnect";
|
||||
|
||||
const remoteConnectedTab = (overrides: Partial<SftpPane> = {}): SftpPane => ({
|
||||
@@ -125,6 +133,7 @@ test("shouldAcceptPendingSftpUpload waits until the pane endpoint matches the dr
|
||||
};
|
||||
assert.equal(
|
||||
shouldAcceptPendingSftpUpload({
|
||||
ownerPanelOpen: true,
|
||||
pendingHostId: "host-1",
|
||||
pendingConnectionKey: "host-1:b.example:22:ssh::root",
|
||||
activeHostId: "host-1",
|
||||
@@ -135,6 +144,7 @@ test("shouldAcceptPendingSftpUpload waits until the pane endpoint matches the dr
|
||||
);
|
||||
assert.equal(
|
||||
shouldAcceptPendingSftpUpload({
|
||||
ownerPanelOpen: true,
|
||||
pendingHostId: "host-1",
|
||||
pendingConnectionKey: "host-1:b.example:22:ssh::root",
|
||||
activeHostId: "host-1",
|
||||
@@ -145,6 +155,7 @@ test("shouldAcceptPendingSftpUpload waits until the pane endpoint matches the dr
|
||||
);
|
||||
assert.equal(
|
||||
shouldAcceptPendingSftpUpload({
|
||||
ownerPanelOpen: true,
|
||||
pendingHostId: "host-1",
|
||||
pendingConnectionKey: "host-1:b.example:22:ssh::root",
|
||||
activeHostId: "host-1",
|
||||
@@ -155,6 +166,393 @@ test("shouldAcceptPendingSftpUpload waits until the pane endpoint matches the dr
|
||||
);
|
||||
});
|
||||
|
||||
test("shouldAcceptPendingSftpUpload waits for the terminal session that requested the upload", () => {
|
||||
const endpointKey = "host-1:prod.internal:22:ssh::deploy:";
|
||||
const connection = {
|
||||
hostId: "host-1",
|
||||
isLocal: false,
|
||||
status: "connected",
|
||||
sourceSessionId: "session-through-jump-a",
|
||||
};
|
||||
|
||||
assert.equal(
|
||||
shouldAcceptPendingSftpUpload({
|
||||
ownerPanelOpen: true,
|
||||
pendingHostId: "host-1",
|
||||
pendingConnectionKey: endpointKey,
|
||||
pendingSourceSessionId: "session-through-jump-b",
|
||||
activeHostId: "host-1",
|
||||
connection,
|
||||
paneConnectionKey: endpointKey,
|
||||
}),
|
||||
false,
|
||||
);
|
||||
|
||||
assert.equal(
|
||||
shouldAcceptPendingSftpUpload({
|
||||
ownerPanelOpen: true,
|
||||
pendingHostId: "host-1",
|
||||
pendingConnectionKey: endpointKey,
|
||||
pendingSourceSessionId: "session-through-jump-a",
|
||||
activeHostId: "host-1",
|
||||
connection,
|
||||
paneConnectionKey: endpointKey,
|
||||
}),
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
test("a closed owner panel never starts a pending terminal upload", () => {
|
||||
assert.equal(shouldAcceptPendingSftpUpload({
|
||||
ownerPanelOpen: false,
|
||||
pendingHostId: "host-1",
|
||||
pendingConnectionKey: "host-1:target.example:22:ssh::alice",
|
||||
pendingSourceSessionId: "session-a",
|
||||
activeHostId: "host-1",
|
||||
connection: {
|
||||
hostId: "host-1",
|
||||
isLocal: false,
|
||||
status: "connected",
|
||||
sourceSessionId: "session-a",
|
||||
},
|
||||
paneConnectionKey: "host-1:target.example:22:ssh::alice",
|
||||
}), false);
|
||||
});
|
||||
|
||||
test("pending terminal upload is cancelled when its source terminal changes", () => {
|
||||
assert.equal(resolvePendingSftpUploadCancellation({
|
||||
pendingHostId: "host-1",
|
||||
pendingSourceSessionId: "session-a",
|
||||
activeHostId: "host-1",
|
||||
activeSessionId: "session-b",
|
||||
connection: null,
|
||||
}), "source-changed");
|
||||
});
|
||||
|
||||
test("pending drop does not cancel while waiting for its own origin focus", () => {
|
||||
assert.equal(resolvePendingSftpUploadCancellation({
|
||||
pendingHostId: "host-1",
|
||||
pendingOriginSessionId: "session-b",
|
||||
pendingSourceSessionId: "session-b",
|
||||
originSessionStatus: "connected",
|
||||
activeHostId: "host-1",
|
||||
activeSessionId: "session-a",
|
||||
focusedSessionId: "session-a",
|
||||
panelVisible: true,
|
||||
waitingForOriginFocus: true,
|
||||
waitingForSourceSession: true,
|
||||
connection: null,
|
||||
}), null);
|
||||
});
|
||||
|
||||
test("Mosh/ET drops wait for origin focus before binding an SSH route", () => {
|
||||
assert.equal(shouldDeferPendingSftpUploadForOriginFocus({
|
||||
originSessionId: "mosh-b",
|
||||
focusedSessionId: "ssh-a",
|
||||
}), true);
|
||||
assert.equal(shouldDeferPendingSftpUploadForOriginFocus({
|
||||
originSessionId: "mosh-b",
|
||||
focusedSessionId: "mosh-b",
|
||||
}), false);
|
||||
assert.equal(shouldDeferPendingSftpUploadForOriginFocus({
|
||||
originSessionId: undefined,
|
||||
focusedSessionId: "ssh-a",
|
||||
}), false);
|
||||
});
|
||||
|
||||
test("pending drop still cancels after origin focus landed and the user leaves", () => {
|
||||
assert.equal(resolvePendingSftpUploadCancellation({
|
||||
pendingHostId: "host-1",
|
||||
pendingOriginSessionId: "session-b",
|
||||
pendingSourceSessionId: "session-b",
|
||||
originSessionStatus: "connected",
|
||||
activeHostId: "host-1",
|
||||
activeSessionId: "session-a",
|
||||
focusedSessionId: "session-a",
|
||||
panelVisible: true,
|
||||
waitingForOriginFocus: false,
|
||||
waitingForSourceSession: false,
|
||||
connection: null,
|
||||
}), "source-changed");
|
||||
});
|
||||
|
||||
test("visible panel cancels an SSH drop when focus moves to same-host mosh or ET", () => {
|
||||
const params = {
|
||||
pendingHostId: "host-1",
|
||||
pendingOriginSessionId: "ssh-session",
|
||||
pendingSourceSessionId: undefined,
|
||||
activeHostId: "host-1",
|
||||
activeSessionId: null,
|
||||
focusedSessionId: "mosh-session",
|
||||
connection: {
|
||||
hostId: "host-1",
|
||||
sourceSessionId: "ssh-session",
|
||||
status: "connected",
|
||||
},
|
||||
};
|
||||
|
||||
assert.equal(resolvePendingSftpUploadCancellation({
|
||||
...params,
|
||||
panelVisible: true,
|
||||
}), "source-changed");
|
||||
assert.equal(resolvePendingSftpUploadCancellation({
|
||||
...params,
|
||||
panelVisible: false,
|
||||
}), null);
|
||||
});
|
||||
|
||||
test("pending terminal upload tolerates a transient missing focused session", () => {
|
||||
assert.equal(resolvePendingSftpUploadCancellation({
|
||||
pendingHostId: "host-1",
|
||||
pendingSourceSessionId: "session-a",
|
||||
activeHostId: "host-1",
|
||||
activeSessionId: null,
|
||||
connection: null,
|
||||
}), null);
|
||||
});
|
||||
|
||||
test("pending Mosh/ET origin upload is cancelled when its origin session disconnects", () => {
|
||||
const params = {
|
||||
pendingHostId: "host-1",
|
||||
pendingOriginSessionId: "mosh-session",
|
||||
pendingSourceSessionId: undefined,
|
||||
activeHostId: "host-1",
|
||||
activeSessionId: null,
|
||||
focusedSessionId: "mosh-session",
|
||||
panelVisible: true,
|
||||
connection: {
|
||||
hostId: "host-1",
|
||||
status: "connected",
|
||||
},
|
||||
};
|
||||
|
||||
assert.equal(resolvePendingSftpUploadCancellation({
|
||||
...params,
|
||||
originSessionStatus: "disconnected",
|
||||
}), "source-changed");
|
||||
assert.equal(resolvePendingSftpUploadCancellation({
|
||||
...params,
|
||||
originSessionStatus: null,
|
||||
}), "source-changed");
|
||||
// Still pending origin routes must not cancel.
|
||||
assert.equal(resolvePendingSftpUploadCancellation({
|
||||
...params,
|
||||
originSessionStatus: "connected",
|
||||
}), null);
|
||||
assert.equal(resolvePendingSftpUploadCancellation({
|
||||
...params,
|
||||
originSessionStatus: "connecting",
|
||||
}), null);
|
||||
});
|
||||
|
||||
test("pending SSH origin upload keeps waiting through a same-tab reconnect", () => {
|
||||
assert.equal(resolvePendingSftpUploadCancellation({
|
||||
pendingHostId: "host-1",
|
||||
pendingOriginSessionId: "ssh-session",
|
||||
pendingSourceSessionId: "ssh-session",
|
||||
activeHostId: "host-1",
|
||||
activeSessionId: "ssh-session",
|
||||
focusedSessionId: "ssh-session",
|
||||
panelVisible: true,
|
||||
originSessionStatus: "disconnected",
|
||||
connection: {
|
||||
hostId: "host-1",
|
||||
sourceSessionId: "ssh-session",
|
||||
status: "connected",
|
||||
},
|
||||
}), null);
|
||||
});
|
||||
|
||||
test("pending terminal upload is cancelled after its matching connection fails", () => {
|
||||
assert.equal(resolvePendingSftpUploadCancellation({
|
||||
pendingHostId: "host-1",
|
||||
pendingSourceSessionId: "session-a",
|
||||
activeHostId: "host-1",
|
||||
activeSessionId: "session-a",
|
||||
connection: {
|
||||
hostId: "host-1",
|
||||
sourceSessionId: "session-a",
|
||||
status: "error",
|
||||
},
|
||||
}), "connection-failed");
|
||||
});
|
||||
|
||||
test("pending terminal upload survives an old connection while strict reconnect is starting", () => {
|
||||
assert.equal(resolvePendingSftpUploadCancellation({
|
||||
pendingHostId: "host-1",
|
||||
pendingSourceSessionId: "session-a",
|
||||
activeHostId: "host-1",
|
||||
activeSessionId: "session-a",
|
||||
connection: {
|
||||
hostId: "host-1",
|
||||
sourceSessionId: "session-old",
|
||||
status: "connected",
|
||||
},
|
||||
}), null);
|
||||
assert.equal(resolvePendingSftpUploadCancellation({
|
||||
pendingHostId: "host-1",
|
||||
pendingSourceSessionId: "session-a",
|
||||
activeHostId: "host-1",
|
||||
activeSessionId: "session-a",
|
||||
connection: {
|
||||
hostId: "host-1",
|
||||
sourceSessionId: "session-a",
|
||||
status: "connecting",
|
||||
},
|
||||
}), null);
|
||||
});
|
||||
|
||||
test("an old disconnected pane does not cancel a pending strict rebind", () => {
|
||||
assert.equal(shouldCancelPendingSftpUpload("connection-failed", true), false);
|
||||
assert.equal(shouldCancelPendingSftpUpload("connection-failed", false), true);
|
||||
assert.equal(shouldCancelPendingSftpUpload("source-changed", true), true);
|
||||
});
|
||||
|
||||
test("pending terminal upload is blocked while the same terminal tab changes routes", () => {
|
||||
assert.equal(shouldBlockPendingSftpUploadForSourceRebind({
|
||||
pendingSourceSessionId: "session-a",
|
||||
previousSessionId: "session-a",
|
||||
activeSessionId: "session-a",
|
||||
previousStatus: "connecting",
|
||||
activeStatus: "connected",
|
||||
}), true);
|
||||
assert.equal(shouldBlockPendingSftpUploadForSourceRebind({
|
||||
pendingSourceSessionId: "session-a",
|
||||
previousSessionId: "session-a",
|
||||
activeSessionId: "session-a",
|
||||
previousStatus: "connected",
|
||||
activeStatus: "connected",
|
||||
}), false);
|
||||
});
|
||||
|
||||
test("terminal drop waits until the exact forced rebind settles", () => {
|
||||
assert.equal(shouldWaitForPendingSftpRebind({
|
||||
pendingSourceSessionId: "session-a",
|
||||
requestId: "drop-1",
|
||||
startedRequestId: null,
|
||||
connectionId: "old-connection",
|
||||
}), true);
|
||||
assert.equal(shouldWaitForPendingSftpRebind({
|
||||
pendingSourceSessionId: "session-a",
|
||||
requestId: "drop-1",
|
||||
startedRequestId: "drop-1",
|
||||
barrierRequestId: "drop-1",
|
||||
previousConnectionId: "old-connection",
|
||||
connectionId: "old-connection",
|
||||
}), true);
|
||||
assert.equal(shouldWaitForPendingSftpRebind({
|
||||
pendingSourceSessionId: "session-a",
|
||||
requestId: "drop-1",
|
||||
startedRequestId: "drop-1",
|
||||
barrierRequestId: "drop-1",
|
||||
previousConnectionId: "old-connection",
|
||||
connectionId: "unrelated-connection",
|
||||
}), true);
|
||||
assert.equal(shouldWaitForPendingSftpRebind({
|
||||
pendingSourceSessionId: "session-a",
|
||||
requestId: "drop-1",
|
||||
startedRequestId: "drop-1",
|
||||
settledRequestId: "drop-1",
|
||||
barrierRequestId: "drop-1",
|
||||
targetTabId: "new-tab",
|
||||
targetConnectionId: "new-connection",
|
||||
tabId: "old-tab",
|
||||
connectionId: "unrelated-connection",
|
||||
}), true);
|
||||
assert.equal(shouldWaitForPendingSftpRebind({
|
||||
pendingSourceSessionId: "session-a",
|
||||
requestId: "drop-1",
|
||||
startedRequestId: "drop-1",
|
||||
settledRequestId: "drop-1",
|
||||
barrierRequestId: "drop-1",
|
||||
targetTabId: "new-tab",
|
||||
targetConnectionId: "new-connection",
|
||||
tabId: "new-tab",
|
||||
connectionId: "new-connection",
|
||||
}), false);
|
||||
});
|
||||
|
||||
test("a repeated terminal drop stops waiting when its shared strict connect settles", () => {
|
||||
assert.equal(shouldWaitForPendingSftpRebind({
|
||||
pendingSourceSessionId: "session-a",
|
||||
requestId: "drop-2",
|
||||
startedRequestId: "drop-2",
|
||||
settledRequestId: "drop-2",
|
||||
barrierRequestId: "drop-2",
|
||||
previousConnectionId: "connecting-connection",
|
||||
targetTabId: "connecting-tab",
|
||||
targetConnectionId: "connecting-connection",
|
||||
tabId: "connecting-tab",
|
||||
connectionId: "connecting-connection",
|
||||
}), false);
|
||||
});
|
||||
|
||||
test("a terminal drop is cancelled when its settled forced target was closed", () => {
|
||||
assert.equal(shouldCancelSettledPendingSftpRebindWithoutTarget({
|
||||
pendingRequiresRebind: true,
|
||||
requestId: "drop-1",
|
||||
startedRequestId: "drop-1",
|
||||
settledRequestId: "drop-1",
|
||||
barrierRequestId: "drop-1",
|
||||
targetTabId: "closed-tab",
|
||||
targetConnectionId: "closed-connection",
|
||||
targetExists: false,
|
||||
}), true);
|
||||
assert.equal(shouldCancelSettledPendingSftpRebindWithoutTarget({
|
||||
pendingRequiresRebind: true,
|
||||
requestId: "drop-1",
|
||||
startedRequestId: "drop-1",
|
||||
settledRequestId: null,
|
||||
barrierRequestId: "drop-1",
|
||||
targetTabId: "closed-tab",
|
||||
targetConnectionId: "closed-connection",
|
||||
targetExists: false,
|
||||
}), false);
|
||||
assert.equal(shouldCancelSettledPendingSftpRebindWithoutTarget({
|
||||
pendingRequiresRebind: true,
|
||||
requestId: "drop-1",
|
||||
startedRequestId: "drop-1",
|
||||
settledRequestId: "drop-1",
|
||||
barrierRequestId: "drop-1",
|
||||
targetTabId: "live-tab",
|
||||
targetConnectionId: "live-connection",
|
||||
targetExists: true,
|
||||
}), false);
|
||||
});
|
||||
|
||||
test("a forced upload target remains valid after moving to the other SFTP pane", () => {
|
||||
const movedTarget = remoteConnectedTab({
|
||||
id: "moved-tab",
|
||||
connection: {
|
||||
...remoteConnectedTab().connection!,
|
||||
id: "moved-connection",
|
||||
},
|
||||
});
|
||||
assert.equal(findPendingSftpRebindTargetPane(
|
||||
[],
|
||||
[movedTarget],
|
||||
"moved-tab",
|
||||
"moved-connection",
|
||||
), movedTarget);
|
||||
});
|
||||
|
||||
test("Mosh and ET drops force a fresh SFTP route even when an old tab is healthy", () => {
|
||||
assert.equal(shouldStartPendingSftpUploadRebind({
|
||||
pendingMatchesTarget: true,
|
||||
requestId: "mosh-drop",
|
||||
startedRequestId: null,
|
||||
originSessionId: "mosh-session",
|
||||
sourceSessionId: undefined,
|
||||
}), true);
|
||||
assert.equal(shouldStartPendingSftpUploadRebind({
|
||||
pendingMatchesTarget: true,
|
||||
requestId: "mosh-drop",
|
||||
startedRequestId: "mosh-drop",
|
||||
originSessionId: "mosh-session",
|
||||
sourceSessionId: undefined,
|
||||
}), false);
|
||||
});
|
||||
|
||||
test("findReusableSftpSidePanelTab ignores tabs stuck in loading after SSH disconnect", () => {
|
||||
const tab = remoteConnectedTab({ loading: true });
|
||||
const map = new Map([[tab.id, "host-key"]]);
|
||||
|
||||
@@ -80,30 +80,218 @@ export function connectionKeyMatchesHost(
|
||||
* looks connected.
|
||||
*/
|
||||
export function shouldAcceptPendingSftpUpload(params: {
|
||||
ownerPanelOpen: boolean;
|
||||
pendingHostId: string;
|
||||
pendingConnectionKey: string;
|
||||
pendingSourceSessionId?: string;
|
||||
activeHostId: string | null | undefined;
|
||||
connection: {
|
||||
hostId?: string | null;
|
||||
isLocal?: boolean;
|
||||
status?: string;
|
||||
sourceSessionId?: string;
|
||||
} | null | undefined;
|
||||
paneConnectionKey: string | null | undefined;
|
||||
}): boolean {
|
||||
const {
|
||||
ownerPanelOpen,
|
||||
pendingHostId,
|
||||
pendingConnectionKey,
|
||||
pendingSourceSessionId,
|
||||
activeHostId,
|
||||
connection,
|
||||
paneConnectionKey,
|
||||
} = params;
|
||||
if (!ownerPanelOpen) return false;
|
||||
if (!activeHostId || pendingHostId !== activeHostId) return false;
|
||||
if (!connection || connection.isLocal || connection.hostId !== activeHostId) return false;
|
||||
if (connection.status !== "connected") return false;
|
||||
if (!paneConnectionKey || paneConnectionKey !== pendingConnectionKey) return false;
|
||||
if (pendingSourceSessionId && connection.sourceSessionId !== pendingSourceSessionId) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Wait until the drop's origin pane is focused before binding SFTP. */
|
||||
export function shouldDeferPendingSftpUploadForOriginFocus(params: {
|
||||
originSessionId?: string;
|
||||
focusedSessionId?: string | null;
|
||||
}): boolean {
|
||||
if (!params.originSessionId || !params.focusedSessionId) return false;
|
||||
return params.focusedSessionId !== params.originSessionId;
|
||||
}
|
||||
|
||||
export type PendingSftpUploadCancellationReason = "source-changed" | "connection-failed";
|
||||
|
||||
/** Old-pane failures are ignored until a strict replacement has finished binding. */
|
||||
export function shouldCancelPendingSftpUpload(
|
||||
reason: PendingSftpUploadCancellationReason | null,
|
||||
waitingForStrictRebind: boolean,
|
||||
): boolean {
|
||||
if (reason === "source-changed") return true;
|
||||
return reason === "connection-failed" && !waitingForStrictRebind;
|
||||
}
|
||||
|
||||
/** Return a terminal-drop cancellation only for terminal or connection changes that cannot recover. */
|
||||
export function resolvePendingSftpUploadCancellation(params: {
|
||||
pendingHostId: string;
|
||||
pendingOriginSessionId?: string;
|
||||
pendingSourceSessionId?: string;
|
||||
/**
|
||||
* Status of the origin terminal session while the drop is pending: the
|
||||
* session's live status, `null` once it no longer exists, or `undefined`
|
||||
* when the caller does not track it.
|
||||
*/
|
||||
originSessionStatus?: string | null;
|
||||
activeHostId: string | null | undefined;
|
||||
activeSessionId: string | null | undefined;
|
||||
focusedSessionId?: string | null;
|
||||
panelVisible?: boolean;
|
||||
/**
|
||||
* True while this drop's own focus switch has not landed yet. A live focus
|
||||
* still sitting on another same-host session is then lag, not a user leave.
|
||||
*/
|
||||
waitingForOriginFocus?: boolean;
|
||||
/**
|
||||
* True while the drop's originating SSH session has not yet become the
|
||||
* panel's active session. Distinguishes scheduled rebind from a real switch.
|
||||
*/
|
||||
waitingForSourceSession?: boolean;
|
||||
connection: {
|
||||
hostId?: string | null;
|
||||
sourceSessionId?: string;
|
||||
status?: string;
|
||||
} | null | undefined;
|
||||
}): PendingSftpUploadCancellationReason | null {
|
||||
if (params.activeHostId !== params.pendingHostId) return "source-changed";
|
||||
if (
|
||||
params.panelVisible
|
||||
&& params.pendingOriginSessionId
|
||||
&& params.focusedSessionId
|
||||
&& params.focusedSessionId !== params.pendingOriginSessionId
|
||||
&& !params.waitingForOriginFocus
|
||||
) return "source-changed";
|
||||
if (
|
||||
params.pendingSourceSessionId
|
||||
&& params.activeSessionId
|
||||
&& params.activeSessionId !== params.pendingSourceSessionId
|
||||
&& !params.waitingForSourceSession
|
||||
) return "source-changed";
|
||||
// A Mosh/ET (or local) origin has no reusable SSH source session, so the
|
||||
// origin terminal itself is the only evidence of the drop's destination
|
||||
// route. If it disconnects or disappears while the standalone SFTP rebind
|
||||
// is pending, cancel instead of uploading into a route that is gone. SSH
|
||||
// origins are excluded: their same-tab reconnect rebind is expected to
|
||||
// pass through non-connected statuses.
|
||||
if (params.pendingOriginSessionId && !params.pendingSourceSessionId) {
|
||||
if (
|
||||
params.originSessionStatus === null
|
||||
|| params.originSessionStatus === "disconnected"
|
||||
) return "source-changed";
|
||||
}
|
||||
if (
|
||||
params.connection?.hostId === params.pendingHostId
|
||||
&& (!params.pendingSourceSessionId
|
||||
|| params.connection.sourceSessionId === params.pendingSourceSessionId)
|
||||
&& (params.connection.status === "error" || params.connection.status === "disconnected")
|
||||
) return "connection-failed";
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Block an old SFTP connection while the same terminal tab is replacing its SSH route. */
|
||||
export function shouldBlockPendingSftpUploadForSourceRebind(params: {
|
||||
pendingSourceSessionId?: string;
|
||||
previousSessionId?: string | null;
|
||||
activeSessionId?: string | null;
|
||||
previousStatus?: string | null;
|
||||
activeStatus?: string | null;
|
||||
}): boolean {
|
||||
if (!params.pendingSourceSessionId) return false;
|
||||
if (params.activeSessionId !== params.pendingSourceSessionId) return false;
|
||||
return shouldRebindSftpSidePanelSourceSession({
|
||||
previousSessionId: params.previousSessionId,
|
||||
nextSessionId: params.activeSessionId,
|
||||
previousStatus: params.previousStatus,
|
||||
nextStatus: params.activeStatus,
|
||||
});
|
||||
}
|
||||
|
||||
export function shouldStartPendingSftpUploadRebind(params: {
|
||||
pendingMatchesTarget: boolean;
|
||||
requestId: string;
|
||||
startedRequestId?: string | null;
|
||||
originSessionId?: string;
|
||||
sourceSessionId?: string;
|
||||
}): boolean {
|
||||
return Boolean(
|
||||
params.pendingMatchesTarget
|
||||
&& (params.originSessionId || params.sourceSessionId)
|
||||
&& params.startedRequestId !== params.requestId
|
||||
);
|
||||
}
|
||||
|
||||
/** A terminal drop must wait until its forced route rebind has finished. */
|
||||
export function shouldWaitForPendingSftpRebind(params: {
|
||||
pendingRequiresRebind?: boolean;
|
||||
pendingSourceSessionId?: string;
|
||||
requestId: string;
|
||||
startedRequestId?: string | null;
|
||||
settledRequestId?: string | null;
|
||||
connectionId?: string | null;
|
||||
tabId?: string | null;
|
||||
barrierRequestId?: string | null;
|
||||
previousConnectionId?: string | null;
|
||||
targetTabId?: string | null;
|
||||
targetConnectionId?: string | null;
|
||||
}): boolean {
|
||||
const pendingRequiresRebind = params.pendingRequiresRebind
|
||||
?? Boolean(params.pendingSourceSessionId);
|
||||
if (!pendingRequiresRebind) return false;
|
||||
if (params.startedRequestId !== params.requestId) return true;
|
||||
if (params.barrierRequestId !== params.requestId) return true;
|
||||
if (params.settledRequestId !== params.requestId) return true;
|
||||
if (!params.targetTabId || !params.targetConnectionId) return true;
|
||||
return params.tabId !== params.targetTabId
|
||||
|| params.connectionId !== params.targetConnectionId;
|
||||
}
|
||||
|
||||
/** A completed forced connect cannot recover after its exact target was closed. */
|
||||
export function shouldCancelSettledPendingSftpRebindWithoutTarget(params: {
|
||||
pendingRequiresRebind: boolean;
|
||||
requestId: string;
|
||||
startedRequestId?: string | null;
|
||||
settledRequestId?: string | null;
|
||||
barrierRequestId?: string | null;
|
||||
targetTabId?: string | null;
|
||||
targetConnectionId?: string | null;
|
||||
targetExists: boolean;
|
||||
}): boolean {
|
||||
return Boolean(
|
||||
params.pendingRequiresRebind
|
||||
&& params.startedRequestId === params.requestId
|
||||
&& params.settledRequestId === params.requestId
|
||||
&& params.barrierRequestId === params.requestId
|
||||
&& (
|
||||
!params.targetTabId
|
||||
|| !params.targetConnectionId
|
||||
|| !params.targetExists
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/** Locate an exact forced-connect target even if its tab moved between panes. */
|
||||
export function findPendingSftpRebindTargetPane(
|
||||
leftTabs: ReadonlyArray<SftpPane>,
|
||||
rightTabs: ReadonlyArray<SftpPane>,
|
||||
targetTabId?: string | null,
|
||||
targetConnectionId?: string | null,
|
||||
): SftpPane | null {
|
||||
if (!targetTabId || !targetConnectionId) return null;
|
||||
return [...leftTabs, ...rightTabs].find((pane) => (
|
||||
pane.id === targetTabId
|
||||
&& pane.connection?.id === targetConnectionId
|
||||
)) ?? null;
|
||||
}
|
||||
|
||||
export function findReusableSftpSidePanelTab(
|
||||
tabs: SftpPane[],
|
||||
hostId: string,
|
||||
|
||||
@@ -2,18 +2,70 @@ import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import {
|
||||
MAX_SFTP_SIDE_PANEL_REMEMBERED_PATHS,
|
||||
pruneSftpSidePanelTabConnectionKeys,
|
||||
canApplySftpSidePanelInitialLocation,
|
||||
pruneSftpSidePanelState,
|
||||
recallSftpSidePanelPath,
|
||||
rememberSftpSidePanelPath,
|
||||
} from "./sftpSidePanelConnectionMemory";
|
||||
|
||||
const initialLocationParams = {
|
||||
activeHostId: "host-1",
|
||||
initialLocation: { hostId: "host-1", path: "/srv/b" },
|
||||
expectedConnectionKey: "host-1:endpoint",
|
||||
actualConnectionKey: "host-1:endpoint",
|
||||
pendingRequiresExactTarget: true,
|
||||
pendingTargetConnectionId: "connection-b",
|
||||
connection: {
|
||||
id: "connection-b",
|
||||
hostId: "host-1",
|
||||
isLocal: false,
|
||||
status: "connected",
|
||||
},
|
||||
};
|
||||
|
||||
test("initial location waits for the exact endpoint and pending target", () => {
|
||||
assert.equal(canApplySftpSidePanelInitialLocation(initialLocationParams), true);
|
||||
assert.equal(canApplySftpSidePanelInitialLocation({
|
||||
...initialLocationParams,
|
||||
connection: {
|
||||
...initialLocationParams.connection,
|
||||
id: "connection-a",
|
||||
},
|
||||
}), false);
|
||||
assert.equal(canApplySftpSidePanelInitialLocation({
|
||||
...initialLocationParams,
|
||||
pendingTargetConnectionId: null,
|
||||
}), false);
|
||||
assert.equal(canApplySftpSidePanelInitialLocation({
|
||||
...initialLocationParams,
|
||||
actualConnectionKey: "host-1:other-endpoint",
|
||||
}), false);
|
||||
});
|
||||
|
||||
test("ordinary initial locations apply once the matching pane is connected", () => {
|
||||
assert.equal(canApplySftpSidePanelInitialLocation({
|
||||
...initialLocationParams,
|
||||
pendingRequiresExactTarget: false,
|
||||
pendingTargetConnectionId: null,
|
||||
}), true);
|
||||
assert.equal(canApplySftpSidePanelInitialLocation({
|
||||
...initialLocationParams,
|
||||
pendingRequiresExactTarget: false,
|
||||
pendingTargetConnectionId: null,
|
||||
connection: {
|
||||
...initialLocationParams.connection,
|
||||
status: "connecting",
|
||||
},
|
||||
}), false);
|
||||
});
|
||||
|
||||
test("closed SFTP side-panel tabs release their remembered connection keys", () => {
|
||||
const connectionKeys = new Map<string, string>();
|
||||
for (let index = 0; index < 100; index += 1) {
|
||||
connectionKeys.set(`tab-${index}`, `connection-${index}`);
|
||||
}
|
||||
|
||||
pruneSftpSidePanelTabConnectionKeys(connectionKeys, ["tab-97", "tab-98", "tab-99"]);
|
||||
pruneSftpSidePanelState(connectionKeys, ["tab-97", "tab-98", "tab-99"]);
|
||||
|
||||
assert.deepEqual([...connectionKeys], [
|
||||
["tab-97", "connection-97"],
|
||||
|
||||
@@ -1,13 +1,43 @@
|
||||
export const MAX_SFTP_SIDE_PANEL_REMEMBERED_PATHS = 32;
|
||||
|
||||
export function pruneSftpSidePanelTabConnectionKeys(
|
||||
connectionKeys: Map<string, string>,
|
||||
activeTabIds: Iterable<string>,
|
||||
export function canApplySftpSidePanelInitialLocation(params: {
|
||||
activeHostId: string;
|
||||
initialLocation: { hostId: string; path: string };
|
||||
expectedConnectionKey: string;
|
||||
actualConnectionKey: string | null;
|
||||
pendingRequiresExactTarget: boolean;
|
||||
pendingTargetConnectionId: string | null;
|
||||
connection: {
|
||||
id: string;
|
||||
hostId: string;
|
||||
isLocal: boolean;
|
||||
status: string;
|
||||
} | null | undefined;
|
||||
}): boolean {
|
||||
const { connection } = params;
|
||||
if (!params.initialLocation.path) return false;
|
||||
if (params.initialLocation.hostId !== params.activeHostId) return false;
|
||||
if (!connection || connection.isLocal || connection.status !== "connected") return false;
|
||||
if (connection.hostId !== params.activeHostId) return false;
|
||||
if (params.actualConnectionKey !== params.expectedConnectionKey) return false;
|
||||
if (
|
||||
params.pendingRequiresExactTarget
|
||||
&& (
|
||||
!params.pendingTargetConnectionId
|
||||
|| connection.id !== params.pendingTargetConnectionId
|
||||
)
|
||||
) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
export function pruneSftpSidePanelState<Value>(
|
||||
valuesById: Map<string, Value>,
|
||||
activeIdsInput: Iterable<string>,
|
||||
): void {
|
||||
const activeIds = new Set(activeTabIds);
|
||||
for (const tabId of connectionKeys.keys()) {
|
||||
if (!activeIds.has(tabId)) {
|
||||
connectionKeys.delete(tabId);
|
||||
const activeIds = new Set(activeIdsInput);
|
||||
for (const id of valuesById.keys()) {
|
||||
if (!activeIds.has(id)) {
|
||||
valuesById.delete(id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,8 +19,9 @@ test("SFTP side panel rebinds after same-tab SSH start-over", () => {
|
||||
// Reuse only after SSH is connected; linked id may arrive while reconnecting.
|
||||
assert.match(
|
||||
sidePanelSource,
|
||||
/sourceSessionId:\s*activeSessionStatus === "connected"/,
|
||||
/activeSessionStatus === "connected" \? \(activeSessionId \?\? undefined\) : undefined/,
|
||||
);
|
||||
assert.match(sidePanelSource, /requireSourceSessionReuse:\s*Boolean\(pendingStrictSourceSessionId\)/);
|
||||
assert.match(sidePanelSource, /resolveSftpSidePanelTrackedSourceStatusUpdate\(/);
|
||||
assert.match(
|
||||
sidePanelSource,
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import { disconnectSftpPaneAfterConfirmation } from "./hooks/useSftpViewPaneActions.ts";
|
||||
|
||||
test("host switching waits for the previous SFTP disconnect to finish", async () => {
|
||||
let releaseDisconnect: (() => void) | undefined;
|
||||
let settled = false;
|
||||
const result = disconnectSftpPaneAfterConfirmation({
|
||||
confirmClose: async () => true,
|
||||
disconnect: async () => new Promise<void>((resolve) => {
|
||||
releaseDisconnect = resolve;
|
||||
}),
|
||||
}).then((value) => {
|
||||
settled = true;
|
||||
return value;
|
||||
});
|
||||
|
||||
await Promise.resolve();
|
||||
assert.equal(settled, false);
|
||||
releaseDisconnect?.();
|
||||
assert.equal(await result, true);
|
||||
});
|
||||
|
||||
test("a cancelled editor prompt never disconnects the current SFTP pane", async () => {
|
||||
let disconnects = 0;
|
||||
const result = await disconnectSftpPaneAfterConfirmation({
|
||||
confirmClose: async () => false,
|
||||
disconnect: async () => {
|
||||
disconnects += 1;
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(result, false);
|
||||
assert.equal(disconnects, 0);
|
||||
});
|
||||
@@ -14,6 +14,11 @@ import {
|
||||
} from "../../../lib/zmodemDragDrop";
|
||||
import { extractDropEntries, type DropEntry } from "../../../lib/sftpFileUtils";
|
||||
import type { Host, TerminalSession } from "../../../types";
|
||||
import { resolveSftpReuseSourceSessionId } from "../../../application/state/terminalConnectionReuse";
|
||||
import {
|
||||
resolveTerminalDropSftpHost,
|
||||
TerminalDropNeedsSudoError,
|
||||
} from "../../../domain/sftpDropElevation";
|
||||
import { toast } from "../../ui/toast";
|
||||
import {
|
||||
extractRootPathsFromDropEntries,
|
||||
@@ -22,10 +27,17 @@ import {
|
||||
|
||||
interface UseTerminalDragDropOptions {
|
||||
host: Host;
|
||||
/** Password already resolved through host auth (host or Keychain identity). */
|
||||
resolvedSudoPassword?: string;
|
||||
/** Login username already resolved through host auth (host or Keychain identity). */
|
||||
resolvedLoginUsername?: string;
|
||||
isLocalConnection: boolean;
|
||||
isNetworkDevice?: boolean;
|
||||
onOpenSftp?: TerminalProps["onOpenSftp"];
|
||||
resolveSftpInitialPath: (options?: { preferFreshBackend?: boolean }) => Promise<string | undefined>;
|
||||
resolveSftpInitialPath: (options?: {
|
||||
preferFreshBackend?: boolean;
|
||||
requireActiveShellCwd?: boolean;
|
||||
}) => Promise<string | undefined>;
|
||||
scrollToBottomAfterProgrammaticInput: (data: string) => void;
|
||||
sessionId: string;
|
||||
sessionRef: React.MutableRefObject<string | null>;
|
||||
@@ -50,19 +62,82 @@ interface UseTerminalDragDropOptions {
|
||||
termRef: React.MutableRefObject<XTerm | null>;
|
||||
}
|
||||
|
||||
const RZ_MISSING_FALLBACK_TIMEOUT_MS = 2500;
|
||||
// Keep this aligned with the main-process drag-drop start watchdog. Falling
|
||||
// back sooner interrupts valid rz handshakes on slow shells and jump routes.
|
||||
export const DEFAULT_RZ_MISSING_FALLBACK_TIMEOUT_MS = 15_000;
|
||||
|
||||
export class ActiveTerminalCwdUnavailableError extends Error {
|
||||
constructor() {
|
||||
super("Could not determine the active terminal directory");
|
||||
this.name = "ActiveTerminalCwdUnavailableError";
|
||||
}
|
||||
}
|
||||
|
||||
export function resolveTerminalDropErrorMessage(
|
||||
error: unknown,
|
||||
t: UseTerminalDragDropOptions["t"],
|
||||
): string {
|
||||
if (error instanceof ActiveTerminalCwdUnavailableError) {
|
||||
return t("terminal.dragDrop.destinationUnknown");
|
||||
}
|
||||
if (error instanceof TerminalDropNeedsSudoError) {
|
||||
return t("terminal.dragDrop.needsSudoElevation");
|
||||
}
|
||||
if (error instanceof Error && error.message === "No files to upload") {
|
||||
return t("terminal.dragDrop.noFiles");
|
||||
}
|
||||
return t("terminal.dragDrop.errorMessage");
|
||||
}
|
||||
|
||||
async function openSftpForTerminalDrop({
|
||||
dropEntries,
|
||||
host,
|
||||
onOpenSftp,
|
||||
resolveSftpInitialPath,
|
||||
resolvedLoginUsername,
|
||||
resolvedSudoPassword,
|
||||
sessionId,
|
||||
}: {
|
||||
dropEntries: DropEntry[];
|
||||
host: Host;
|
||||
onOpenSftp: NonNullable<UseTerminalDragDropOptions["onOpenSftp"]>;
|
||||
resolveSftpInitialPath: UseTerminalDragDropOptions["resolveSftpInitialPath"];
|
||||
resolvedLoginUsername?: string;
|
||||
resolvedSudoPassword?: string;
|
||||
sessionId: string;
|
||||
}): Promise<void> {
|
||||
const initialPath = await resolveTerminalDropUploadInitialPath(resolveSftpInitialPath);
|
||||
const uploadHost = resolveTerminalDropSftpHost(host, initialPath, {
|
||||
password: resolvedSudoPassword ?? host.password,
|
||||
username: resolvedLoginUsername ?? host.username,
|
||||
});
|
||||
onOpenSftp(
|
||||
uploadHost,
|
||||
initialPath,
|
||||
dropEntries,
|
||||
sessionId,
|
||||
resolveSftpReuseSourceSessionId(host, sessionId),
|
||||
);
|
||||
}
|
||||
|
||||
export async function resolveTerminalDropUploadInitialPath(
|
||||
resolveSftpInitialPath: UseTerminalDragDropOptions["resolveSftpInitialPath"],
|
||||
): Promise<string | undefined> {
|
||||
return resolveSftpInitialPath({ preferFreshBackend: true });
|
||||
const initialPath = await resolveSftpInitialPath({
|
||||
preferFreshBackend: true,
|
||||
requireActiveShellCwd: true,
|
||||
});
|
||||
if (!initialPath) {
|
||||
throw new ActiveTerminalCwdUnavailableError();
|
||||
}
|
||||
return initialPath;
|
||||
}
|
||||
|
||||
function createRzMissingWatcher({
|
||||
sessionId,
|
||||
terminalBackend,
|
||||
token,
|
||||
timeoutMs = RZ_MISSING_FALLBACK_TIMEOUT_MS,
|
||||
timeoutMs = DEFAULT_RZ_MISSING_FALLBACK_TIMEOUT_MS,
|
||||
}: {
|
||||
sessionId: string;
|
||||
terminalBackend: Pick<UseTerminalDragDropOptions["terminalBackend"], "onSessionData" | "onZmodemEvent">;
|
||||
@@ -122,6 +197,8 @@ export async function handleTerminalDropEntries({
|
||||
isNetworkDevice = false,
|
||||
onOpenSftp,
|
||||
resolveSftpInitialPath,
|
||||
resolvedLoginUsername,
|
||||
resolvedSudoPassword,
|
||||
scrollToBottomAfterProgrammaticInput,
|
||||
sessionId,
|
||||
sessionRef,
|
||||
@@ -132,6 +209,8 @@ export async function handleTerminalDropEntries({
|
||||
}: Pick<
|
||||
UseTerminalDragDropOptions,
|
||||
| "host"
|
||||
| "resolvedLoginUsername"
|
||||
| "resolvedSudoPassword"
|
||||
| "isLocalConnection"
|
||||
| "isNetworkDevice"
|
||||
| "onOpenSftp"
|
||||
@@ -173,8 +252,15 @@ export async function handleTerminalDropEntries({
|
||||
&& onOpenSftp
|
||||
&& supportsZmodemDragDropSftpFallback(host)
|
||||
) {
|
||||
const initialPath = await resolveTerminalDropUploadInitialPath(resolveSftpInitialPath);
|
||||
onOpenSftp(host, initialPath, dropEntries, sessionId);
|
||||
await openSftpForTerminalDrop({
|
||||
dropEntries,
|
||||
host,
|
||||
onOpenSftp,
|
||||
resolveSftpInitialPath,
|
||||
resolvedLoginUsername,
|
||||
resolvedSudoPassword,
|
||||
sessionId,
|
||||
});
|
||||
} else if (supportsZmodemTerminalDragDrop(host, isNetworkDevice)) {
|
||||
const files = await buildZmodemDragDropFiles(dropEntries);
|
||||
if (files.length === 0) {
|
||||
@@ -221,17 +307,35 @@ export async function handleTerminalDropEntries({
|
||||
const fallbackResult = rzMissingWatcher ? await rzMissingWatcher.promise : "detected";
|
||||
if (fallbackResult === "missing" || fallbackResult === "timeout") {
|
||||
terminalBackend.cancelZmodem?.(sessionId, { interrupt: fallbackResult === "timeout" });
|
||||
const initialPath = await resolveTerminalDropUploadInitialPath(resolveSftpInitialPath);
|
||||
onOpenSftp?.(host, initialPath, dropEntries, sessionId);
|
||||
if (onOpenSftp) {
|
||||
await openSftpForTerminalDrop({
|
||||
dropEntries,
|
||||
host,
|
||||
onOpenSftp,
|
||||
resolveSftpInitialPath,
|
||||
resolvedLoginUsername,
|
||||
resolvedSudoPassword,
|
||||
sessionId,
|
||||
});
|
||||
}
|
||||
}
|
||||
} else if (onOpenSftp) {
|
||||
const initialPath = await resolveTerminalDropUploadInitialPath(resolveSftpInitialPath);
|
||||
onOpenSftp(host, initialPath, dropEntries, sessionId);
|
||||
await openSftpForTerminalDrop({
|
||||
dropEntries,
|
||||
host,
|
||||
onOpenSftp,
|
||||
resolveSftpInitialPath,
|
||||
resolvedLoginUsername,
|
||||
resolvedSudoPassword,
|
||||
sessionId,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export function useTerminalDragDrop({
|
||||
host,
|
||||
resolvedLoginUsername,
|
||||
resolvedSudoPassword,
|
||||
isLocalConnection,
|
||||
isNetworkDevice = false,
|
||||
onOpenSftp,
|
||||
@@ -295,6 +399,8 @@ export function useTerminalDragDrop({
|
||||
await handleTerminalDropEntries({
|
||||
dropEntries,
|
||||
host,
|
||||
resolvedLoginUsername,
|
||||
resolvedSudoPassword,
|
||||
isLocalConnection,
|
||||
isNetworkDevice,
|
||||
onOpenSftp,
|
||||
@@ -309,9 +415,7 @@ export function useTerminalDragDrop({
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error("Failed to handle file drop", error);
|
||||
const message = error instanceof Error && error.message === "No files to upload"
|
||||
? t("terminal.dragDrop.noFiles")
|
||||
: t("terminal.dragDrop.errorMessage");
|
||||
const message = resolveTerminalDropErrorMessage(error, t);
|
||||
toast.error(message, t("terminal.dragDrop.errorTitle"));
|
||||
}
|
||||
};
|
||||
|
||||
@@ -82,6 +82,7 @@ import {
|
||||
createKittyKeyboardBroadcastHandler,
|
||||
flushKittyKeyboardBroadcastReleases,
|
||||
registerKittyKeyboardBroadcastHandler,
|
||||
resolveWin32InputLogicalData,
|
||||
upsertKittyKeyboardForwardedPress,
|
||||
type KittyKeyboardBroadcastInput,
|
||||
type KittyKeyboardForwardedPress,
|
||||
@@ -91,6 +92,7 @@ import { terminalAltKeyOptions } from "./altKeyOptions";
|
||||
import { optionArrowWordJumpSequence } from "./optionArrowWordJump";
|
||||
import { optionYankLastArgSequence } from "./optionYankLastArg";
|
||||
import { watchDevicePixelRatio } from "./rendererDprWatch";
|
||||
import { dispatchWin32InputModeEvent } from "./win32InputMode";
|
||||
import { shouldDeferWebglUntilVisible } from "./webglRendererPolicy";
|
||||
import { createWebglRendererController } from "./webglRendererController";
|
||||
import {
|
||||
@@ -102,7 +104,7 @@ import { handleSerialLineModeInput } from "./serialLineInput";
|
||||
import {
|
||||
doesKittyEncodingPreserveShiftEnter,
|
||||
getShiftEnterSubmittedInput,
|
||||
resolveShiftEnterPayload,
|
||||
resolveShiftEnterText,
|
||||
shouldSendShiftEnterText,
|
||||
} from "./shiftEnterText";
|
||||
import {
|
||||
@@ -548,6 +550,12 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
|
||||
const term = new XTerm({
|
||||
...performanceConfig.options,
|
||||
...(windowsPty ? { windowsPty } : {}),
|
||||
// ConPTY asks the frontend for Win32 INPUT_RECORD encoding with DECSET
|
||||
// 9001. Preserve browser key modifiers for native Windows applications
|
||||
// instead of collapsing them into legacy VT bytes.
|
||||
vtExtensions: {
|
||||
win32InputMode: windowsPty?.backend === "conpty",
|
||||
},
|
||||
// Override ignoreBracketedPasteMode if user explicitly disables bracketed paste
|
||||
ignoreBracketedPasteMode: settings?.disableBracketedPaste ?? performanceConfig.options.ignoreBracketedPasteMode,
|
||||
// Rescale glyphs that would visually overlap into the next cell (CJK compliance)
|
||||
@@ -1099,6 +1107,12 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
|
||||
data: string,
|
||||
options?: {
|
||||
source?: "terminal" | "shift-enter" | "kitty";
|
||||
/**
|
||||
* User-facing input represented by a transport-specific wire payload.
|
||||
* `null` means the payload has no text/editing semantics (for example a
|
||||
* Win32 key-up record). The wire `data` is still written unchanged.
|
||||
*/
|
||||
logicalData?: string | null;
|
||||
/** Skip string broadcast when peers will re-resolve from a key chord. */
|
||||
skipBroadcast?: boolean;
|
||||
/**
|
||||
@@ -1117,11 +1131,16 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
|
||||
// cause the executed command to fail (#3138).
|
||||
data = sanitizeTerminalInput(data);
|
||||
if (!data) return;
|
||||
const logicalData = options?.logicalData === null
|
||||
? null
|
||||
: sanitizeTerminalInput(options?.logicalData ?? data);
|
||||
|
||||
// Clipboard paste / typed password while assist is open must dismiss the
|
||||
// hint first. Otherwise Enter is still hijacked for confirmFill and can
|
||||
// append the host session password after the user's pasted secret (#2198).
|
||||
ctx.sudoAutofillRef?.current?.dismissOnUserContentInput(data);
|
||||
if (logicalData) {
|
||||
ctx.sudoAutofillRef?.current?.dismissOnUserContentInput(logicalData);
|
||||
}
|
||||
|
||||
const inputSource = options?.source ?? "terminal";
|
||||
const id = ctx.sessionRef.current;
|
||||
@@ -1129,13 +1148,18 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
|
||||
const sensitive = ctx.passwordPromptActiveRef?.current === true;
|
||||
let handledSubmittedInput = false;
|
||||
const submittedInput: { text: string; lineEnding: "\r\n" | "\r" | "\n" } | null =
|
||||
inputSource === "shift-enter"
|
||||
? getShiftEnterSubmittedInput(data)
|
||||
: data === "\r" || data === "\n"
|
||||
? { text: "", lineEnding: data as "\r" | "\n" }
|
||||
logicalData === null
|
||||
? null
|
||||
: inputSource === "shift-enter"
|
||||
? getShiftEnterSubmittedInput(logicalData)
|
||||
: logicalData === "\r" || logicalData === "\n"
|
||||
? { text: "", lineEnding: logicalData as "\r" | "\n" }
|
||||
: null;
|
||||
const onBroadcastInput = ctx.onBroadcastInputRef.current;
|
||||
const broadcastDataBeforeSudo = mapTerminalBackspaceInput(data, ctx.host.backspaceBehavior);
|
||||
const broadcastDataBeforeSudo = mapTerminalBackspaceInput(
|
||||
logicalData ?? "",
|
||||
ctx.host.backspaceBehavior,
|
||||
);
|
||||
const suppressTerminalBroadcast = inputSource === "terminal" && suppressNextTerminalDataBroadcast;
|
||||
if (suppressTerminalBroadcast) suppressNextTerminalDataBroadcast = false;
|
||||
// skipBroadcast only suppresses the raw-string fan-out. Peers still receive
|
||||
@@ -1182,7 +1206,7 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
|
||||
!handlingKittyBroadcast &&
|
||||
inputSource !== "shift-enter"
|
||||
) {
|
||||
const pastedCommand = getSinglePastedCommand(data);
|
||||
const pastedCommand = logicalData === null ? null : getSinglePastedCommand(logicalData);
|
||||
if (pastedCommand) {
|
||||
if (ctx.passwordPromptActiveRef) ctx.passwordPromptActiveRef.current = false;
|
||||
const recordedCommand = recordTerminalCommandExecution(
|
||||
@@ -1246,43 +1270,50 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
|
||||
}
|
||||
}
|
||||
|
||||
// Use remapped data so broadcast peers also receive the correct byte
|
||||
const broadcastData = mapTerminalBackspaceInput(dataToWrite, ctx.host.backspaceBehavior);
|
||||
// Use logical data for raw-string broadcast. Transport-specific wire
|
||||
// records are dispatched separately from their normalized key event.
|
||||
const broadcastData = mapTerminalBackspaceInput(
|
||||
logicalData ?? "",
|
||||
ctx.host.backspaceBehavior,
|
||||
);
|
||||
if (willBroadcastInput) {
|
||||
onBroadcastInput?.(broadcastData, ctx.sessionId);
|
||||
}
|
||||
|
||||
if (!shouldSuppressTerminalInputScrollForUserPaste(term, data)) {
|
||||
scrollToBottomAfterInput(data);
|
||||
if (
|
||||
logicalData !== null &&
|
||||
!shouldSuppressTerminalInputScrollForUserPaste(term, logicalData)
|
||||
) {
|
||||
scrollToBottomAfterInput(logicalData);
|
||||
}
|
||||
|
||||
// Notify autocomplete of input
|
||||
ctx.onAutocompleteInput?.(data);
|
||||
if (logicalData !== null) ctx.onAutocompleteInput?.(logicalData);
|
||||
|
||||
if (ctx.statusRef.current === "connected") {
|
||||
if (ctx.statusRef.current === "connected" && logicalData !== null) {
|
||||
if (handledSubmittedInput || submittedInput) {
|
||||
// Command recording and sudo command preparation happen before the
|
||||
// input is written so sudo can receive a one-time prompt marker.
|
||||
} else if (data === "\x7f" || data === "\b") {
|
||||
} else if (logicalData === "\x7f" || logicalData === "\b") {
|
||||
ctx.commandBufferRef.current = ctx.commandBufferRef.current.slice(0, -1);
|
||||
ctx.scriptRecorderRef?.current?.recordBackspace();
|
||||
} else if (data === "\x03") {
|
||||
} else if (logicalData === "\x03") {
|
||||
ctx.commandBufferRef.current = "";
|
||||
ctx.scriptRecorderRef?.current?.recordClearLine();
|
||||
// Hard-abort password assist when Ctrl+C reaches the input path
|
||||
// (e.g. broadcast peers) so a later su re-arms cleanly (#2191).
|
||||
ctx.sudoAutofillRef?.current?.abort();
|
||||
} else if (data === "\x15") {
|
||||
} else if (logicalData === "\x15") {
|
||||
ctx.commandBufferRef.current = "";
|
||||
ctx.scriptRecorderRef?.current?.recordClearLine();
|
||||
} else if (data.length === 1 && data.charCodeAt(0) >= 32) {
|
||||
ctx.commandBufferRef.current += data;
|
||||
ctx.scriptRecorderRef?.current?.recordInput(data);
|
||||
} else if (data.length > 1 && !data.startsWith("\x1b")) {
|
||||
ctx.commandBufferRef.current += data;
|
||||
ctx.scriptRecorderRef?.current?.recordInput(data);
|
||||
} else if (logicalData.length === 1 && logicalData.charCodeAt(0) >= 32) {
|
||||
ctx.commandBufferRef.current += logicalData;
|
||||
ctx.scriptRecorderRef?.current?.recordInput(logicalData);
|
||||
} else if (logicalData.length > 1 && !logicalData.startsWith("\x1b")) {
|
||||
ctx.commandBufferRef.current += logicalData;
|
||||
ctx.scriptRecorderRef?.current?.recordInput(logicalData);
|
||||
} else {
|
||||
const pastedLine = getSingleBracketedPasteLine(data);
|
||||
const pastedLine = getSingleBracketedPasteLine(logicalData);
|
||||
if (pastedLine) {
|
||||
ctx.commandBufferRef.current += pastedLine;
|
||||
ctx.scriptRecorderRef?.current?.recordInput(pastedLine);
|
||||
@@ -1296,8 +1327,14 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
|
||||
let kittyCompositionClearTimer: number | undefined;
|
||||
let imeTextInputDeferredKey: string | null = null;
|
||||
let imeTextInputDeferredKittyEvent: KittyKeyboardEvent | null = null;
|
||||
let win32InputModePendingEvent: {
|
||||
event: KittyKeyboardEvent;
|
||||
logicalData: string | null;
|
||||
} | null = null;
|
||||
const win32InputModeForwardedKeys = new Map<string, KittyKeyboardForwardedPress>();
|
||||
const kittyForwardedKeys = new Map<string, KittyKeyboardForwardedPress>();
|
||||
const broadcastForwardedKeys = new Map<string, KittyKeyboardForwardedPress>();
|
||||
const win32BroadcastForwardedKeys = new Map<string, KittyKeyboardForwardedPress>();
|
||||
const broadcastEncodedKeys = new Set<string>();
|
||||
const broadcastLegacySuppressedKeys = new Set<string>();
|
||||
const kittyKeyIdentity = (event: KeyboardEvent): string => event.code || event.key;
|
||||
@@ -1357,6 +1394,7 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
|
||||
if (
|
||||
isUnchangedDeferredImeTextInput(deferredKey, text) &&
|
||||
deferredKittyEvent &&
|
||||
!term.modes.win32InputMode &&
|
||||
kittyKeyboardProtocolEnabled
|
||||
) {
|
||||
const pressEvent: KittyKeyboardEvent = {
|
||||
@@ -1408,6 +1446,7 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
|
||||
// on the text path, but still emits a paired :3 release on keyup.
|
||||
// Mirror the ordinary keydown handler so that release is not dropped.
|
||||
if (
|
||||
!term.modes.win32InputMode &&
|
||||
kittyKeyboardProtocolEnabled &&
|
||||
shouldTrackKittyKeyRelease(kittyKeyboardMode, pressEvent)
|
||||
) {
|
||||
@@ -1443,7 +1482,9 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
|
||||
// Kitty escape sequences that bypass the handleTerminalInputData guard (#3138).
|
||||
const sanitizedText = sanitizeTerminalInput(text);
|
||||
if (!sanitizedText) return;
|
||||
const encoded = encodeKittyCompositionText(kittyKeyboardMode, sanitizedText);
|
||||
const encoded = term.modes.win32InputMode
|
||||
? null
|
||||
: encodeKittyCompositionText(kittyKeyboardMode, sanitizedText);
|
||||
if (encoded) {
|
||||
handleTerminalInputData(encoded, { source: "kitty" });
|
||||
} else {
|
||||
@@ -1491,6 +1532,7 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
|
||||
);
|
||||
}
|
||||
if (!kittyForwardedKeys.delete(identity)) return false;
|
||||
if (term.modes.win32InputMode) return false;
|
||||
const sequence = kittyKeyboardProtocolEnabled
|
||||
? encodeKittyKeyEvent(kittyKeyboardMode, event)
|
||||
: null;
|
||||
@@ -1578,7 +1620,27 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
|
||||
}
|
||||
}
|
||||
const identity = kittyKeyIdentity(releaseEvent);
|
||||
const hasForwardedWin32KeyDown = win32InputModeForwardedKeys.delete(identity);
|
||||
if (broadcastLegacyDataPending === identity) clearBroadcastLegacyDataPending();
|
||||
if (term.modes.win32InputMode) {
|
||||
// Broadcast peers may still need a paired Kitty release for a keydown
|
||||
// consumed by a Netcatty action (notably the urgent Ctrl+C path).
|
||||
releaseForwardedKittyPress(toKittyKeyboardEvent(releaseEvent));
|
||||
kittyForwardedKeys.delete(identity);
|
||||
// Only let xterm emit a Win32 key-up when its matching keydown was
|
||||
// previously handed to xterm. Netcatty shortcuts, sudo controls and
|
||||
// autocomplete consume their keydown and must not leak an orphaned
|
||||
// native release into the PTY.
|
||||
if (!hasForwardedWin32KeyDown) {
|
||||
win32InputModePendingEvent = null;
|
||||
return false;
|
||||
}
|
||||
win32InputModePendingEvent = {
|
||||
event: toKittyKeyboardEvent(releaseEvent),
|
||||
logicalData: null,
|
||||
};
|
||||
return true;
|
||||
}
|
||||
if (releaseForwardedKittyPress(toKittyKeyboardEvent(releaseEvent))) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
@@ -1746,6 +1808,7 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
|
||||
}
|
||||
|
||||
const kittySequenceForKeyDown =
|
||||
!term.modes.win32InputMode &&
|
||||
kittyKeyboardProtocolEnabled
|
||||
? encodeKittyKeyEvent(kittyKeyboardMode, toKittyKeyboardEvent(e))
|
||||
: null;
|
||||
@@ -1805,6 +1868,7 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
|
||||
const kittyEvent = toKittyKeyboardEvent(e);
|
||||
const identity = kittyKeyIdentity(e);
|
||||
if (
|
||||
!term.modes.win32InputMode &&
|
||||
kittyKeyboardProtocolEnabled &&
|
||||
shouldTrackKittyKeyRelease(kittyKeyboardMode, kittyEvent)
|
||||
) {
|
||||
@@ -1971,11 +2035,15 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
|
||||
return false;
|
||||
}
|
||||
|
||||
// Before Kitty encoding that collapses Shift+Enter to bare CR/LF (flags=0,
|
||||
// or non-preserving sets like alternate-key / associated-text alone). Remap
|
||||
// first so alternate-screen TUIs still receive CSI-u Shift+Enter.
|
||||
// ConPTY's negotiated Win32 input mode keeps the native modifier and owns
|
||||
// this event. Otherwise, before Kitty encoding that collapses Shift+Enter
|
||||
// to bare CR/LF (flags=0, or non-preserving sets like alternate-key /
|
||||
// associated-text alone), keep the configured send-text fallback. Only
|
||||
// negotiated preserving modes may emit CSI-u; alternate-screen activation
|
||||
// is not a capability signal.
|
||||
if (
|
||||
shouldSendShiftEnterText(e, ctx.terminalSettingsRef.current) &&
|
||||
!term.modes.win32InputMode &&
|
||||
!doesKittyEncodingPreserveShiftEnter(kittySequenceForKeyDown)
|
||||
) {
|
||||
const id = ctx.sessionRef.current;
|
||||
@@ -1983,24 +2051,16 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
const kittyEvent = toKittyKeyboardEvent(e);
|
||||
// Local payload uses this session's buffer. Broadcast targets re-resolve
|
||||
// from the key chord via resolveKittyKeyboardBroadcastInput.
|
||||
const shiftEnterPayload = resolveShiftEnterPayload(
|
||||
const shiftEnterText = resolveShiftEnterText(
|
||||
ctx.terminalSettingsRef.current,
|
||||
{ alternateScreen: term.buffer.active.type === "alternate" },
|
||||
);
|
||||
if (shiftEnterPayload.data) {
|
||||
if (shiftEnterPayload.kind === "key") {
|
||||
// Local CSI-u as kitty-sourced input so raw bytes are not broadcast.
|
||||
handleTerminalInputData(shiftEnterPayload.data, { source: "kitty" });
|
||||
} else {
|
||||
// Skip string broadcast: peers resolve Shift+Enter from their own
|
||||
// buffer + keyboard mode via the key chord below.
|
||||
handleTerminalInputData(shiftEnterPayload.data, {
|
||||
source: "shift-enter",
|
||||
skipBroadcast: true,
|
||||
});
|
||||
}
|
||||
if (shiftEnterText) {
|
||||
// Skip string broadcast: peers resolve Shift+Enter from their own
|
||||
// negotiated keyboard mode via the key chord below.
|
||||
handleTerminalInputData(shiftEnterText, {
|
||||
source: "shift-enter",
|
||||
skipBroadcast: true,
|
||||
});
|
||||
const forwarded = broadcastKittyInput({
|
||||
kind: "key",
|
||||
event: kittyEvent,
|
||||
@@ -2087,6 +2147,19 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
|
||||
}
|
||||
|
||||
const normalizedKittyEvent = toKittyKeyboardEvent(e);
|
||||
if (term.modes.win32InputMode) {
|
||||
// The following xterm onData callback carries the lossless record.
|
||||
// Keep its normalized event alongside it for non-Win32 broadcast peers
|
||||
// and for local command/autocomplete bookkeeping.
|
||||
win32InputModePendingEvent = {
|
||||
event: normalizedKittyEvent,
|
||||
logicalData: resolveWin32InputLogicalData(
|
||||
normalizedKittyEvent,
|
||||
term.modes.applicationCursorKeysMode,
|
||||
),
|
||||
};
|
||||
return true;
|
||||
}
|
||||
if (!shouldDeferKittyKeyEvent(normalizedKittyEvent)) {
|
||||
const identity = kittyKeyIdentity(e);
|
||||
if (
|
||||
@@ -2186,7 +2259,7 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
|
||||
kittyCompositionClearTimer = undefined;
|
||||
}
|
||||
if (
|
||||
shouldEncodeKittyCompositionText(kittyKeyboardMode) ||
|
||||
(!term.modes.win32InputMode && shouldEncodeKittyCompositionText(kittyKeyboardMode)) ||
|
||||
(ctx.isBroadcastEnabledRef.current && ctx.onBroadcastInputRef.current)
|
||||
) {
|
||||
kittyCompositionPending = true;
|
||||
@@ -2205,16 +2278,30 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
|
||||
kittyCompositionClearTimer = undefined;
|
||||
}, 0);
|
||||
};
|
||||
const writeWin32InputModeEvent = (
|
||||
event: KittyKeyboardEvent,
|
||||
logicalData: string | null,
|
||||
) => {
|
||||
const pending = { event, logicalData };
|
||||
win32InputModePendingEvent = pending;
|
||||
dispatchWin32InputModeEvent(term, event);
|
||||
if (win32InputModePendingEvent === pending) {
|
||||
win32InputModePendingEvent = null;
|
||||
}
|
||||
};
|
||||
const clearKittyConnectionInputState = () => {
|
||||
// Drop deferred IME punctuation on session reset; do not write into the
|
||||
// next connection. Focus loss uses clearKittyTransientInputState instead.
|
||||
clearImeTextInputDeferral();
|
||||
kittyCompositionPending = false;
|
||||
win32InputModePendingEvent = null;
|
||||
win32InputModeForwardedKeys.clear();
|
||||
kittyForwardedKeys.clear();
|
||||
clearKittyKeyboardBroadcastPairingState(
|
||||
broadcastEncodedKeys,
|
||||
broadcastLegacySuppressedKeys,
|
||||
);
|
||||
win32BroadcastForwardedKeys.clear();
|
||||
clearBroadcastLegacyDataPending();
|
||||
if (kittyCompositionClearTimer !== undefined) {
|
||||
window.clearTimeout(kittyCompositionClearTimer);
|
||||
@@ -2223,17 +2310,40 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
|
||||
};
|
||||
const clearKittyTransientInputState = () => {
|
||||
flushImeTextInputDeferral();
|
||||
if (term.modes.win32InputMode) {
|
||||
// Browsers do not deliver key-up after focus leaves the terminal. Release
|
||||
// every Win32 key that xterm actually handed to ConPTY so native TUI key
|
||||
// state cannot remain stuck after switching tabs or windows.
|
||||
flushKittyKeyboardBroadcastReleases(
|
||||
win32InputModeForwardedKeys,
|
||||
(input) => {
|
||||
if (input.kind === "key") {
|
||||
writeWin32InputModeEvent(input.event, null);
|
||||
}
|
||||
},
|
||||
kittyKeyboardLockState,
|
||||
);
|
||||
kittyForwardedKeys.clear();
|
||||
win32InputModePendingEvent = null;
|
||||
} else {
|
||||
win32InputModeForwardedKeys.clear();
|
||||
flushKittyKeyboardBroadcastReleases(
|
||||
kittyForwardedKeys,
|
||||
(input) => {
|
||||
if (input.kind !== "key" || !kittyKeyboardProtocolEnabled) return;
|
||||
const sequence = encodeKittyKeyEvent(kittyKeyboardMode, input.event);
|
||||
if (sequence) handleTerminalInputData(sequence, { source: "kitty" });
|
||||
},
|
||||
kittyKeyboardLockState,
|
||||
);
|
||||
}
|
||||
flushKittyKeyboardBroadcastReleases(
|
||||
kittyForwardedKeys,
|
||||
(input) => {
|
||||
if (input.kind !== "key" || !kittyKeyboardProtocolEnabled) return;
|
||||
const sequence = encodeKittyKeyEvent(kittyKeyboardMode, input.event);
|
||||
if (sequence) handleTerminalInputData(sequence, { source: "kitty" });
|
||||
},
|
||||
broadcastForwardedKeys,
|
||||
broadcastKittyInput,
|
||||
kittyKeyboardLockState,
|
||||
);
|
||||
flushKittyKeyboardBroadcastReleases(
|
||||
broadcastForwardedKeys,
|
||||
win32BroadcastForwardedKeys,
|
||||
broadcastKittyInput,
|
||||
kittyKeyboardLockState,
|
||||
);
|
||||
@@ -2263,6 +2373,56 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
|
||||
textarea?.addEventListener("blur", clearKittyTransientInputState);
|
||||
|
||||
term.onData((data) => {
|
||||
const win32Input = win32InputModePendingEvent;
|
||||
if (
|
||||
win32Input &&
|
||||
term.modes.win32InputMode &&
|
||||
data.startsWith("\u001b[") &&
|
||||
/^\[\d+;\d+;\d+;[01];\d+;\d+_$/.test(data.slice(1))
|
||||
) {
|
||||
win32InputModePendingEvent = null;
|
||||
if (win32Input.event.type === "keydown") {
|
||||
upsertKittyKeyboardForwardedPress(
|
||||
win32InputModeForwardedKeys,
|
||||
win32Input.event.code || win32Input.event.key,
|
||||
win32Input.event,
|
||||
[],
|
||||
);
|
||||
}
|
||||
const identity = win32Input.event.code || win32Input.event.key;
|
||||
const broadcastInput: KittyKeyboardBroadcastInput = {
|
||||
kind: "win32",
|
||||
data,
|
||||
event: win32Input.event,
|
||||
fallbackToLegacy: true,
|
||||
};
|
||||
if (win32Input.event.type === "keyup") {
|
||||
const forwardedPress = win32BroadcastForwardedKeys.get(identity);
|
||||
win32BroadcastForwardedKeys.delete(identity);
|
||||
if (forwardedPress) {
|
||||
broadcastKittyInput(
|
||||
broadcastInput,
|
||||
true,
|
||||
forwardedPress.targetSessionIds,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
const forwarded = broadcastKittyInput(broadcastInput);
|
||||
if (forwarded) {
|
||||
upsertKittyKeyboardForwardedPress(
|
||||
win32BroadcastForwardedKeys,
|
||||
identity,
|
||||
win32Input.event,
|
||||
forwarded.targetSessionIds,
|
||||
);
|
||||
}
|
||||
}
|
||||
handleTerminalInputData(data, {
|
||||
logicalData: win32Input.logicalData,
|
||||
skipBroadcast: true,
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (kittyCompositionPending && !data.startsWith("\u001b")) {
|
||||
kittyCompositionPending = false;
|
||||
if (kittyCompositionClearTimer !== undefined) {
|
||||
@@ -2273,7 +2433,9 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
|
||||
// Kitty escape sequences that bypass the handleTerminalInputData guard (#3138).
|
||||
const sanitizedData = sanitizeTerminalInput(data);
|
||||
if (!sanitizedData) return;
|
||||
const encoded = encodeKittyCompositionText(kittyKeyboardMode, sanitizedData);
|
||||
const encoded = term.modes.win32InputMode
|
||||
? null
|
||||
: encodeKittyCompositionText(kittyKeyboardMode, sanitizedData);
|
||||
if (encoded) {
|
||||
handleTerminalInputData(encoded, { source: "kitty" });
|
||||
} else {
|
||||
@@ -2318,7 +2480,7 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
|
||||
applicationCursorMode: term.modes.applicationCursorKeysMode,
|
||||
encodedKeys: broadcastEncodedKeys,
|
||||
legacySuppressedKeys: broadcastLegacySuppressedKeys,
|
||||
alternateScreen: term.buffer.active.type === "alternate",
|
||||
win32InputMode: term.modes.win32InputMode,
|
||||
shiftEnterSettings: ctx.terminalSettingsRef.current,
|
||||
}),
|
||||
getSessionId: () => ctx.sessionRef.current,
|
||||
@@ -2333,7 +2495,13 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
|
||||
mapTerminalBackspaceInput(data, ctx.host.backspaceBehavior),
|
||||
{ sensitive: ctx.passwordPromptActiveRef?.current === true },
|
||||
),
|
||||
writeActive: (data) => handleTerminalInputData(data, { source: "kitty" }),
|
||||
writeActive: (data, logicalData) => handleTerminalInputData(data, {
|
||||
source: "kitty",
|
||||
logicalData,
|
||||
}),
|
||||
writeWin32Event: (event, logicalData) => {
|
||||
writeWin32InputModeEvent(event, logicalData);
|
||||
},
|
||||
});
|
||||
registerKittyKeyboardBroadcastHandler(
|
||||
ctx.sessionId,
|
||||
|
||||
@@ -521,7 +521,7 @@ test("enhanced sources can fall back to reliable legacy data for plain targets",
|
||||
}
|
||||
});
|
||||
|
||||
test("Shift+Enter broadcast remaps from each target buffer when Kitty collapses the chord", () => {
|
||||
test("Shift+Enter broadcast uses CSI-u only after the target negotiated a preserving mode", () => {
|
||||
const alternateOnly = createKittyKeyboardModeState();
|
||||
setKittyKeyboardModeFlags(alternateOnly, 0b00100);
|
||||
const shiftEnter = {
|
||||
@@ -540,10 +540,9 @@ test("Shift+Enter broadcast remaps from each target buffer when Kitty collapses
|
||||
kittyMode: alternateOnly,
|
||||
applicationCursorMode: false,
|
||||
encodedKeys: new Set<string>(),
|
||||
alternateScreen: true,
|
||||
}), {
|
||||
data: SHIFT_ENTER_CSI_U_SEQUENCE,
|
||||
kittyEncoded: true,
|
||||
data: "\n",
|
||||
kittyEncoded: false,
|
||||
urgentInterrupt: false,
|
||||
});
|
||||
|
||||
@@ -552,7 +551,6 @@ test("Shift+Enter broadcast remaps from each target buffer when Kitty collapses
|
||||
kittyMode: createKittyKeyboardModeState(),
|
||||
applicationCursorMode: false,
|
||||
encodedKeys: new Set<string>(),
|
||||
alternateScreen: false,
|
||||
}), {
|
||||
data: "\n",
|
||||
kittyEncoded: false,
|
||||
@@ -566,7 +564,6 @@ test("Shift+Enter broadcast remaps from each target buffer when Kitty collapses
|
||||
kittyMode: disambiguate,
|
||||
applicationCursorMode: false,
|
||||
encodedKeys: new Set<string>(),
|
||||
alternateScreen: true,
|
||||
}), {
|
||||
data: SHIFT_ENTER_CSI_U_SEQUENCE,
|
||||
kittyEncoded: true,
|
||||
@@ -574,6 +571,178 @@ test("Shift+Enter broadcast remaps from each target buffer when Kitty collapses
|
||||
});
|
||||
});
|
||||
|
||||
test("Win32 input broadcast preserves native records for Win32 targets", () => {
|
||||
const shiftEnterRecord = "\u001b[13;28;13;1;16;1_";
|
||||
const shiftEnter = {
|
||||
kind: "win32" as const,
|
||||
data: shiftEnterRecord,
|
||||
fallbackToLegacy: true,
|
||||
event: {
|
||||
type: "keydown" as const,
|
||||
key: "Enter",
|
||||
code: "Enter",
|
||||
shiftKey: true,
|
||||
},
|
||||
};
|
||||
|
||||
const win32TargetOptions = {
|
||||
kittyProtocolEnabled: false,
|
||||
kittyMode: createKittyKeyboardModeState(),
|
||||
applicationCursorMode: false,
|
||||
encodedKeys: new Set<string>(),
|
||||
win32InputMode: true,
|
||||
};
|
||||
assert.deepEqual(resolveKittyKeyboardBroadcastInput(shiftEnter, win32TargetOptions), {
|
||||
data: shiftEnterRecord,
|
||||
kittyEncoded: false,
|
||||
urgentInterrupt: false,
|
||||
logicalData: null,
|
||||
});
|
||||
|
||||
const normalizedShiftEnter = {
|
||||
kind: "key" as const,
|
||||
fallbackToLegacy: true,
|
||||
event: shiftEnter.event,
|
||||
};
|
||||
const normalizedTargetOptions = {
|
||||
...win32TargetOptions,
|
||||
encodedKeys: new Set<string>(),
|
||||
};
|
||||
assert.deepEqual(
|
||||
resolveKittyKeyboardBroadcastInput(normalizedShiftEnter, normalizedTargetOptions),
|
||||
{
|
||||
data: "",
|
||||
kittyEncoded: false,
|
||||
urgentInterrupt: false,
|
||||
logicalData: null,
|
||||
win32Event: shiftEnter.event,
|
||||
},
|
||||
);
|
||||
|
||||
const disambiguate = createKittyKeyboardModeState();
|
||||
setKittyKeyboardModeFlags(disambiguate, 0b00001);
|
||||
assert.deepEqual(resolveKittyKeyboardBroadcastInput(shiftEnter, {
|
||||
kittyProtocolEnabled: true,
|
||||
kittyMode: disambiguate,
|
||||
applicationCursorMode: false,
|
||||
encodedKeys: new Set<string>(),
|
||||
win32InputMode: false,
|
||||
}), {
|
||||
data: SHIFT_ENTER_CSI_U_SEQUENCE,
|
||||
kittyEncoded: true,
|
||||
urgentInterrupt: false,
|
||||
});
|
||||
});
|
||||
|
||||
test("Win32 input keeps plain Enter bookkeeping without misclassifying modified Enter", () => {
|
||||
const options = () => ({
|
||||
kittyProtocolEnabled: false,
|
||||
kittyMode: createKittyKeyboardModeState(),
|
||||
applicationCursorMode: false,
|
||||
encodedKeys: new Set<string>(),
|
||||
win32InputMode: true,
|
||||
});
|
||||
const resolveEnter = (modifiers: Partial<{
|
||||
shiftKey: boolean;
|
||||
altKey: boolean;
|
||||
ctrlKey: boolean;
|
||||
metaKey: boolean;
|
||||
}>) => resolveKittyKeyboardBroadcastInput({
|
||||
kind: "win32",
|
||||
data: "\u001b[13;28;13;1;0;1_",
|
||||
event: {
|
||||
type: "keydown",
|
||||
key: "Enter",
|
||||
code: "Enter",
|
||||
...modifiers,
|
||||
},
|
||||
}, options())?.logicalData;
|
||||
|
||||
assert.equal(resolveEnter({}), "\r");
|
||||
assert.equal(resolveEnter({ shiftKey: true }), null);
|
||||
assert.equal(resolveEnter({ ctrlKey: true }), null);
|
||||
assert.equal(resolveEnter({ altKey: true }), null);
|
||||
});
|
||||
|
||||
test("Win32 key-up broadcast remains a native release with no editing semantics", () => {
|
||||
const options = {
|
||||
kittyProtocolEnabled: true,
|
||||
kittyMode: createKittyKeyboardModeState(),
|
||||
applicationCursorMode: false,
|
||||
encodedKeys: new Set<string>(),
|
||||
win32InputMode: true,
|
||||
};
|
||||
assert.ok(resolveKittyKeyboardBroadcastInput({
|
||||
kind: "win32",
|
||||
data: "\u001b[13;28;13;1;16;1_",
|
||||
event: {
|
||||
type: "keydown",
|
||||
key: "Enter",
|
||||
code: "Enter",
|
||||
shiftKey: true,
|
||||
},
|
||||
}, options));
|
||||
const releaseRecord = "\u001b[13;28;13;0;16;1_";
|
||||
assert.deepEqual(resolveKittyKeyboardBroadcastInput({
|
||||
kind: "win32",
|
||||
data: releaseRecord,
|
||||
fallbackToLegacy: true,
|
||||
event: {
|
||||
type: "keyup",
|
||||
key: "Enter",
|
||||
code: "Enter",
|
||||
shiftKey: true,
|
||||
},
|
||||
}, options), {
|
||||
data: releaseRecord,
|
||||
kittyEncoded: false,
|
||||
urgentInterrupt: false,
|
||||
logicalData: null,
|
||||
});
|
||||
});
|
||||
|
||||
test("Win32 broadcast targets encode normalized keys and release them during sensitive input", () => {
|
||||
const encodedKeys = new Set<string>();
|
||||
const delivered: Array<{ type?: string; logicalData: string | null }> = [];
|
||||
const writes: string[] = [];
|
||||
let sensitive = false;
|
||||
const handler = createKittyKeyboardBroadcastHandler({
|
||||
resolveOptions: () => ({
|
||||
kittyProtocolEnabled: false,
|
||||
kittyMode: createKittyKeyboardModeState(),
|
||||
applicationCursorMode: false,
|
||||
encodedKeys,
|
||||
win32InputMode: true,
|
||||
}),
|
||||
getSessionId: () => "win32-target",
|
||||
isSensitiveInput: () => sensitive,
|
||||
isConnected: () => true,
|
||||
isRuntimeDisposed: () => false,
|
||||
writeDisposed: (_sessionId, data) => writes.push(data),
|
||||
writeActive: (data) => writes.push(data),
|
||||
writeWin32Event: (event, logicalData) => {
|
||||
delivered.push({ type: event.type, logicalData });
|
||||
},
|
||||
});
|
||||
|
||||
handler({
|
||||
kind: "key",
|
||||
event: { type: "keydown", key: "Enter", code: "Enter", shiftKey: true },
|
||||
});
|
||||
sensitive = true;
|
||||
handler({
|
||||
kind: "key",
|
||||
event: { type: "keyup", key: "Enter", code: "Enter", shiftKey: true },
|
||||
});
|
||||
|
||||
assert.deepEqual(delivered, [
|
||||
{ type: "keydown", logicalData: null },
|
||||
{ type: "keyup", logicalData: null },
|
||||
]);
|
||||
assert.deepEqual(writes, []);
|
||||
assert.equal(encodedKeys.size, 0);
|
||||
});
|
||||
|
||||
test("plain Kitty targets preserve modified non-ASCII keys without a legacy fallback", () => {
|
||||
const plain = createKittyKeyboardModeState();
|
||||
assert.deepEqual(resolveKittyKeyboardBroadcastInput({
|
||||
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
import type { TerminalSettings } from "../../../domain/models";
|
||||
import {
|
||||
isBareShiftEnterLineEnding,
|
||||
resolveShiftEnterPayload,
|
||||
resolveShiftEnterText,
|
||||
shouldSendShiftEnterText,
|
||||
} from "./shiftEnterText";
|
||||
|
||||
@@ -20,6 +20,13 @@ export type KittyKeyboardBroadcastInput =
|
||||
fallbackToLegacy?: boolean;
|
||||
urgentInterrupt?: boolean;
|
||||
}
|
||||
| {
|
||||
kind: "win32";
|
||||
data: string;
|
||||
event: KittyKeyboardEvent;
|
||||
fallbackToLegacy?: boolean;
|
||||
urgentInterrupt?: boolean;
|
||||
}
|
||||
| { kind: "legacy"; data: string; keyIdentity: string; urgentInterrupt?: boolean }
|
||||
| { kind: "text"; text: string };
|
||||
|
||||
@@ -36,6 +43,9 @@ export type ResolvedKittyKeyboardBroadcastInput = {
|
||||
data: string;
|
||||
kittyEncoded: boolean;
|
||||
urgentInterrupt: boolean;
|
||||
logicalData?: string | null;
|
||||
/** Let the target xterm encode this event with its active Win32 mode. */
|
||||
win32Event?: KittyKeyboardEvent;
|
||||
};
|
||||
|
||||
export const createKittyKeyboardBroadcastForwarder = (options: {
|
||||
@@ -202,14 +212,30 @@ type ResolveKittyKeyboardBroadcastOptions = {
|
||||
applicationCursorMode: boolean;
|
||||
encodedKeys: Set<string>;
|
||||
legacySuppressedKeys?: Set<string>;
|
||||
/** Target buffer: remap Shift+Enter per peer, not from the broadcast source. */
|
||||
alternateScreen?: boolean;
|
||||
win32InputMode?: boolean;
|
||||
shiftEnterSettings?: Pick<
|
||||
TerminalSettings,
|
||||
"shiftEnterNewlineEnabled" | "shiftEnterNewlineText"
|
||||
>;
|
||||
};
|
||||
|
||||
export const resolveWin32InputLogicalData = (
|
||||
event: KittyKeyboardEvent,
|
||||
applicationCursorMode: boolean,
|
||||
): string | null => {
|
||||
if (event.type === "keyup") return null;
|
||||
if (
|
||||
event.key === "Enter" &&
|
||||
(event.shiftKey || event.altKey || event.ctrlKey || event.metaKey)
|
||||
) {
|
||||
// The native record carries semantics that a legacy CR cannot express.
|
||||
// Treating a modified Enter as CR would make Netcatty record a command
|
||||
// submission even when the TUI only inserted a line break.
|
||||
return null;
|
||||
}
|
||||
return encodeLegacyKeyboardEvent(event, applicationCursorMode);
|
||||
};
|
||||
|
||||
const resolveShiftEnterBroadcastPayload = (
|
||||
input: Extract<KittyKeyboardBroadcastInput, { kind: "key" }>,
|
||||
options: ResolveKittyKeyboardBroadcastOptions,
|
||||
@@ -222,13 +248,11 @@ const resolveShiftEnterBroadcastPayload = (
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
const payload = resolveShiftEnterPayload(options.shiftEnterSettings, {
|
||||
alternateScreen: options.alternateScreen === true,
|
||||
});
|
||||
if (!payload.data) return null;
|
||||
const data = resolveShiftEnterText(options.shiftEnterSettings);
|
||||
if (!data) return null;
|
||||
return {
|
||||
data: payload.data,
|
||||
kittyEncoded: payload.kind === "key",
|
||||
data,
|
||||
kittyEncoded: false,
|
||||
urgentInterrupt: false,
|
||||
};
|
||||
};
|
||||
@@ -255,6 +279,36 @@ export const resolveKittyKeyboardBroadcastInput = (
|
||||
};
|
||||
}
|
||||
|
||||
if (input.kind === "win32") {
|
||||
if (options.win32InputMode) {
|
||||
const identity = input.event.code || input.event.key;
|
||||
const legacySuppressedKeys = options.legacySuppressedKeys ?? options.encodedKeys;
|
||||
if (input.event.type === "keyup") {
|
||||
const hasPairedKeyDown = options.encodedKeys.delete(identity);
|
||||
legacySuppressedKeys.delete(identity);
|
||||
if (!hasPairedKeyDown) return null;
|
||||
} else {
|
||||
options.encodedKeys.add(identity);
|
||||
legacySuppressedKeys.add(identity);
|
||||
}
|
||||
return {
|
||||
data: input.data,
|
||||
kittyEncoded: false,
|
||||
urgentInterrupt: input.urgentInterrupt === true,
|
||||
logicalData: resolveWin32InputLogicalData(
|
||||
input.event,
|
||||
options.applicationCursorMode,
|
||||
),
|
||||
};
|
||||
}
|
||||
return resolveKittyKeyboardBroadcastInput({
|
||||
kind: "key",
|
||||
event: input.event,
|
||||
fallbackToLegacy: input.fallbackToLegacy,
|
||||
urgentInterrupt: input.urgentInterrupt,
|
||||
}, options);
|
||||
}
|
||||
|
||||
const identity = input.event.code || input.event.key;
|
||||
const legacySuppressedKeys = options.legacySuppressedKeys ?? options.encodedKeys;
|
||||
const hasPairedKeyDown = input.event.type === "keyup"
|
||||
@@ -262,6 +316,22 @@ export const resolveKittyKeyboardBroadcastInput = (
|
||||
: false;
|
||||
if (input.event.type === "keyup") legacySuppressedKeys.delete(identity);
|
||||
if (input.event.type === "keyup" && !hasPairedKeyDown) return null;
|
||||
if (options.win32InputMode) {
|
||||
if (input.event.type !== "keyup") {
|
||||
options.encodedKeys.add(identity);
|
||||
legacySuppressedKeys.add(identity);
|
||||
}
|
||||
return {
|
||||
data: "",
|
||||
kittyEncoded: false,
|
||||
urgentInterrupt: false,
|
||||
logicalData: resolveWin32InputLogicalData(
|
||||
input.event,
|
||||
options.applicationCursorMode,
|
||||
),
|
||||
win32Event: input.event,
|
||||
};
|
||||
}
|
||||
const encoded = options.kittyProtocolEnabled
|
||||
? encodeKittyKeyEvent(options.kittyMode, {
|
||||
...input.event,
|
||||
@@ -300,11 +370,14 @@ export const createKittyKeyboardBroadcastHandler = (options: {
|
||||
isRuntimeDisposed: () => boolean;
|
||||
interruptSession?: (sessionId: string) => void;
|
||||
writeDisposed: (sessionId: string, data: string) => void;
|
||||
writeActive: (data: string) => void;
|
||||
writeActive: (data: string, logicalData?: string | null) => void;
|
||||
writeWin32Event?: (event: KittyKeyboardEvent, logicalData: string | null) => void;
|
||||
}): KittyKeyboardBroadcastHandler => (input, dispatchOptions) => {
|
||||
const sessionId = options.getSessionId();
|
||||
if (!sessionId || !options.isConnected()) return;
|
||||
const isPairedRelease = input.kind === "key" && input.event.type === "keyup";
|
||||
const isPairedRelease =
|
||||
(input.kind === "key" || input.kind === "win32") &&
|
||||
input.event.type === "keyup";
|
||||
if (!isPairedRelease && options.isSensitiveInput?.() === true) return;
|
||||
const resolved = resolveKittyKeyboardBroadcastInput(input, options.resolveOptions());
|
||||
if (!resolved) return;
|
||||
@@ -313,11 +386,20 @@ export const createKittyKeyboardBroadcastHandler = (options: {
|
||||
options.interruptSession(sessionId);
|
||||
return;
|
||||
}
|
||||
if (resolved.win32Event) {
|
||||
if (!options.isRuntimeDisposed()) {
|
||||
options.writeWin32Event?.(
|
||||
resolved.win32Event,
|
||||
resolved.logicalData ?? null,
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (options.isRuntimeDisposed()) {
|
||||
options.writeDisposed(sessionId, resolved.data);
|
||||
return;
|
||||
}
|
||||
options.writeActive(resolved.data);
|
||||
options.writeActive(resolved.data, resolved.logicalData);
|
||||
};
|
||||
|
||||
const handlers = new Map<string, KittyKeyboardBroadcastHandler>();
|
||||
|
||||
@@ -8,7 +8,6 @@ import {
|
||||
getShiftEnterSubmittedInput,
|
||||
isBareShiftEnterLineEnding,
|
||||
isShiftEnterLineContinuationText,
|
||||
resolveShiftEnterPayload,
|
||||
resolveShiftEnterText,
|
||||
SHIFT_ENTER_CSI_U_SEQUENCE,
|
||||
shouldSendShiftEnterText,
|
||||
@@ -100,44 +99,6 @@ test("Kitty encodings that collapse Shift+Enter to CR/LF do not preserve the cho
|
||||
assert.equal(doesKittyEncodingPreserveShiftEnter(SHIFT_ENTER_CSI_U_SEQUENCE), true);
|
||||
});
|
||||
|
||||
test("main-buffer Shift+Enter keeps configured send text", () => {
|
||||
assert.deepEqual(resolveShiftEnterPayload(), {
|
||||
kind: "text",
|
||||
data: "\n",
|
||||
});
|
||||
assert.deepEqual(
|
||||
resolveShiftEnterPayload(
|
||||
{ shiftEnterNewlineText: " \\\\\\n" },
|
||||
{ alternateScreen: false },
|
||||
),
|
||||
{ kind: "text", data: " \\\n" },
|
||||
);
|
||||
});
|
||||
|
||||
test("alternate-screen Shift+Enter sends CSI-u when configured text is a bare line ending", () => {
|
||||
assert.deepEqual(
|
||||
resolveShiftEnterPayload(undefined, { alternateScreen: true }),
|
||||
{ kind: "key", data: SHIFT_ENTER_CSI_U_SEQUENCE },
|
||||
);
|
||||
assert.deepEqual(
|
||||
resolveShiftEnterPayload(
|
||||
{ shiftEnterNewlineText: "\\r\\n" },
|
||||
{ alternateScreen: true },
|
||||
),
|
||||
{ kind: "key", data: SHIFT_ENTER_CSI_U_SEQUENCE },
|
||||
);
|
||||
});
|
||||
|
||||
test("alternate-screen Shift+Enter keeps custom non-line-ending send text", () => {
|
||||
assert.deepEqual(
|
||||
resolveShiftEnterPayload(
|
||||
{ shiftEnterNewlineText: " \\\\\\n" },
|
||||
{ alternateScreen: true },
|
||||
),
|
||||
{ kind: "text", data: " \\\n" },
|
||||
);
|
||||
});
|
||||
|
||||
test("runtime routes Shift+Enter text through the shared input handler", () => {
|
||||
const source = readFileSync(
|
||||
new URL("./createXTermRuntime.ts", import.meta.url),
|
||||
@@ -151,11 +112,19 @@ test("runtime routes Shift+Enter text through the shared input handler", () => {
|
||||
// Remap when Kitty encoding does not preserve Shift+Enter (not merely flags===0).
|
||||
assert.match(
|
||||
source,
|
||||
/if \(\s*shouldSendShiftEnterText\([\s\S]*?\) &&\s*!doesKittyEncodingPreserveShiftEnter\(kittySequenceForKeyDown\)\s*\) \{[\s\S]*?const shiftEnterPayload = resolveShiftEnterPayload\([\s\S]*?\)[\s\S]*?\}\s*if \(kittySequenceForKeyDown\)/s,
|
||||
/if \(\s*shouldSendShiftEnterText\([\s\S]*?\) &&\s*!term\.modes\.win32InputMode &&\s*!doesKittyEncodingPreserveShiftEnter\(kittySequenceForKeyDown\)\s*\) \{[\s\S]*?const shiftEnterText = resolveShiftEnterText\([\s\S]*?\)[\s\S]*?\}\s*if \(kittySequenceForKeyDown\)/s,
|
||||
);
|
||||
assert.match(
|
||||
source,
|
||||
/if \(shiftEnterPayload\.kind === "key"\) \{[\s\S]*?handleTerminalInputData\(shiftEnterPayload\.data, \{ source: "kitty" \}\);[\s\S]*?\} else \{[\s\S]*?handleTerminalInputData\(shiftEnterPayload\.data, \{\s*source: "shift-enter",\s*skipBroadcast: true,\s*\}\);[\s\S]*?\}\s*const forwarded = broadcastKittyInput\(\{\s*kind: "key",\s*event: kittyEvent,\s*fallbackToLegacy: true,\s*\}\);/s,
|
||||
/vtExtensions: \{\s*win32InputMode: windowsPty\?\.backend === "conpty",\s*\},/s,
|
||||
);
|
||||
assert.match(
|
||||
source,
|
||||
/const kittySequenceForKeyDown =\s*!term\.modes\.win32InputMode &&\s*kittyKeyboardProtocolEnabled/s,
|
||||
);
|
||||
assert.match(
|
||||
source,
|
||||
/const shiftEnterText = resolveShiftEnterText\([\s\S]*?if \(shiftEnterText\) \{[\s\S]*?handleTerminalInputData\(shiftEnterText, \{\s*source: "shift-enter",\s*skipBroadcast: true,\s*\}\);\s*const forwarded = broadcastKittyInput\(\{\s*kind: "key",\s*event: kittyEvent,\s*fallbackToLegacy: true,\s*\}\);/s,
|
||||
);
|
||||
assert.match(
|
||||
source,
|
||||
@@ -163,9 +132,10 @@ test("runtime routes Shift+Enter text through the shared input handler", () => {
|
||||
);
|
||||
assert.match(
|
||||
source,
|
||||
/alternateScreen: term\.buffer\.active\.type === "alternate",\s*shiftEnterSettings: ctx\.terminalSettingsRef\.current,/s,
|
||||
/resolveOptions: \(\) => \(\{[\s\S]*?shiftEnterSettings: ctx\.terminalSettingsRef\.current,[\s\S]*?\}\),/s,
|
||||
);
|
||||
assert.match(source, /getShiftEnterSubmittedInput\(data\)/);
|
||||
assert.doesNotMatch(source, /resolveShiftEnterText\([\s\S]*?alternateScreen:/s);
|
||||
assert.match(source, /getShiftEnterSubmittedInput\(logicalData\)/);
|
||||
assert.match(source, /inputSource !== "shift-enter"/);
|
||||
assert.match(
|
||||
source,
|
||||
@@ -177,7 +147,40 @@ test("runtime routes Shift+Enter text through the shared input handler", () => {
|
||||
);
|
||||
assert.match(
|
||||
source,
|
||||
/const sanitizedData = sanitizeTerminalInput\(data\);[\s\S]*const encoded = encodeKittyCompositionText\(kittyKeyboardMode, sanitizedData\);[\s\S]*if \(encoded\) \{[\s\S]*handleTerminalInputData\(encoded, \{ source: "kitty" \}\);[\s\S]*\} else \{[\s\S]*handleTerminalInputData\(sanitizedData, \{\s*perCharacterWrites: shouldSplitImeTextInputForWire\(sanitizedData\),?\s*\}\);[\s\S]*broadcastKittyInput\(\{ kind: "text", text: sanitizedData \}\);/,
|
||||
/const sanitizedData = sanitizeTerminalInput\(data\);[\s\S]*const encoded = term\.modes\.win32InputMode\s*\? null\s*:\s*encodeKittyCompositionText\(kittyKeyboardMode, sanitizedData\);[\s\S]*if \(encoded\) \{[\s\S]*handleTerminalInputData\(encoded, \{ source: "kitty" \}\);[\s\S]*\} else \{[\s\S]*handleTerminalInputData\(sanitizedData, \{\s*perCharacterWrites: shouldSplitImeTextInputForWire\(sanitizedData\),?\s*\}\);[\s\S]*broadcastKittyInput\(\{ kind: "text", text: sanitizedData \}\);/,
|
||||
);
|
||||
assert.match(
|
||||
source,
|
||||
/if \(term\.modes\.win32InputMode\) \{[\s\S]*win32InputModePendingEvent = \{\s*event: normalizedKittyEvent,\s*logicalData: resolveWin32InputLogicalData\(/s,
|
||||
);
|
||||
assert.match(
|
||||
source,
|
||||
/const broadcastInput: KittyKeyboardBroadcastInput = \{\s*kind: "win32",\s*data,\s*event: win32Input\.event,[\s\S]*handleTerminalInputData\(data, \{\s*logicalData: win32Input\.logicalData,\s*skipBroadcast: true,/s,
|
||||
);
|
||||
assert.match(
|
||||
source,
|
||||
/const hasForwardedWin32KeyDown = win32InputModeForwardedKeys\.delete\(identity\);[\s\S]*if \(term\.modes\.win32InputMode\) \{[\s\S]*releaseForwardedKittyPress\([\s\S]*if \(!hasForwardedWin32KeyDown\) \{[\s\S]*win32InputModePendingEvent = null;[\s\S]*return false;[\s\S]*logicalData: null,[\s\S]*return true;[\s\S]*releaseForwardedKittyPress/s,
|
||||
);
|
||||
assert.match(
|
||||
source,
|
||||
/if \(win32Input\.event\.type === "keydown"\) \{\s*upsertKittyKeyboardForwardedPress\(\s*win32InputModeForwardedKeys,\s*win32Input\.event\.code \|\| win32Input\.event\.key,\s*win32Input\.event,\s*\[\],/s,
|
||||
);
|
||||
assert.match(
|
||||
source,
|
||||
/if \(term\.modes\.win32InputMode\) \{[\s\S]*flushKittyKeyboardBroadcastReleases\(\s*win32InputModeForwardedKeys,[\s\S]*writeWin32InputModeEvent\(input\.event, null\);/s,
|
||||
);
|
||||
assert.match(source, /win32InputMode: term\.modes\.win32InputMode,/);
|
||||
assert.match(
|
||||
source,
|
||||
/const win32BroadcastForwardedKeys = new Map<string, KittyKeyboardForwardedPress>\(\);/,
|
||||
);
|
||||
assert.match(
|
||||
source,
|
||||
/const forwardedPress = win32BroadcastForwardedKeys\.get\(identity\);[\s\S]*broadcastKittyInput\(\s*broadcastInput,\s*true,\s*forwardedPress\.targetSessionIds,/s,
|
||||
);
|
||||
assert.match(
|
||||
source,
|
||||
/upsertKittyKeyboardForwardedPress\(\s*win32BroadcastForwardedKeys,[\s\S]*forwarded\.targetSessionIds,/s,
|
||||
);
|
||||
assert.match(source, /ctx\.container\.addEventListener\("input", markKittyTextInput, true\);/);
|
||||
assert.match(
|
||||
|
||||
@@ -91,30 +91,6 @@ export function doesKittyEncodingPreserveShiftEnter(
|
||||
&& !isBareShiftEnterLineEnding(encoded);
|
||||
}
|
||||
|
||||
export type ShiftEnterPayload =
|
||||
| { kind: "text"; data: string }
|
||||
| { kind: "key"; data: string };
|
||||
|
||||
/**
|
||||
* Resolve what Shift+Enter should write.
|
||||
*
|
||||
* On the main buffer, keep the configured send-text (default LF) so shell /
|
||||
* Claude-style multiline prompts keep working. On the alternate screen (full-
|
||||
* screen TUIs), bare line-ending remaps collapse Shift+Enter into Ctrl+Enter /
|
||||
* LF for apps like Codex; send CSI-u Shift+Enter instead so the TUI can see
|
||||
* the real chord. Custom send-text (e.g. shell continuation) is unchanged.
|
||||
*/
|
||||
export function resolveShiftEnterPayload(
|
||||
settings?: Pick<TerminalSettings, "shiftEnterNewlineText">,
|
||||
options?: { alternateScreen?: boolean },
|
||||
): ShiftEnterPayload {
|
||||
const text = resolveShiftEnterText(settings);
|
||||
if (options?.alternateScreen && isBareShiftEnterLineEnding(text)) {
|
||||
return { kind: "key", data: SHIFT_ENTER_CSI_U_SEQUENCE };
|
||||
}
|
||||
return { kind: "text", data: text };
|
||||
}
|
||||
|
||||
export function isShiftEnterLineContinuationText(text: string): boolean {
|
||||
return /\\(?:\r\n|\r|\n)$/.test(text);
|
||||
}
|
||||
|
||||
@@ -472,7 +472,7 @@ test("createXTermRuntime recovers a stuck IME punctuation deferral (#3103)", ()
|
||||
// Any real key release ends the deferral, not just an exact key match.
|
||||
const keyupIdx = runtimeSource.indexOf('if (e.type === "keyup")');
|
||||
assert.ok(keyupIdx >= 0);
|
||||
const keyupSlice = runtimeSource.slice(keyupIdx, keyupIdx + 2000);
|
||||
const keyupSlice = runtimeSource.slice(keyupIdx, keyupIdx + 2800);
|
||||
assert.match(
|
||||
keyupSlice,
|
||||
/shouldFlushDeferredImeTextInputOnKeyUp\(imeTextInputDeferredKey, e\)/,
|
||||
|
||||
@@ -0,0 +1,221 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { createRequire } from "node:module";
|
||||
import test from "node:test";
|
||||
|
||||
import { JSDOM } from "jsdom";
|
||||
import type { Terminal as XTermType } from "@xterm/xterm";
|
||||
|
||||
import { dispatchWin32InputModeEvent } from "./win32InputMode";
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
const { Terminal: XTerm } = require("@xterm/xterm") as {
|
||||
Terminal: typeof XTermType;
|
||||
};
|
||||
|
||||
const write = (term: XTermType, data: string): Promise<void> => new Promise((resolve) => {
|
||||
term.write(data, resolve);
|
||||
});
|
||||
|
||||
test("xterm ignores ConPTY's Win32 input request unless the extension is enabled", async () => {
|
||||
const term = new XTerm({ allowProposedApi: true });
|
||||
|
||||
await write(term, "\u001b[?9001h");
|
||||
|
||||
assert.equal(term.modes.win32InputMode, false);
|
||||
term.dispose();
|
||||
});
|
||||
|
||||
test("xterm preserves modified Enter keys as Win32 input records for ConPTY", async () => {
|
||||
const dom = new JSDOM(
|
||||
"<!doctype html><html><body><div id=terminal></div><div id=source></div></body></html>",
|
||||
{
|
||||
pretendToBeVisual: true,
|
||||
},
|
||||
);
|
||||
const window = dom.window;
|
||||
window.matchMedia = () => ({
|
||||
matches: true,
|
||||
media: "",
|
||||
onchange: null,
|
||||
addListener() {},
|
||||
removeListener() {},
|
||||
addEventListener() {},
|
||||
removeEventListener() {},
|
||||
dispatchEvent: () => true,
|
||||
});
|
||||
window.HTMLCanvasElement.prototype.getContext = (() => ({
|
||||
createLinearGradient: () => ({}),
|
||||
measureText: () => ({
|
||||
width: 10,
|
||||
actualBoundingBoxDescent: 2,
|
||||
actualBoundingBoxAscent: 8,
|
||||
}),
|
||||
fillRect() {},
|
||||
clearRect() {},
|
||||
getImageData: () => ({ data: new Uint8ClampedArray([0, 0, 0, 255]) }),
|
||||
})) as typeof window.HTMLCanvasElement.prototype.getContext;
|
||||
|
||||
const globals = [
|
||||
"window",
|
||||
"document",
|
||||
"navigator",
|
||||
"HTMLElement",
|
||||
"HTMLCanvasElement",
|
||||
"KeyboardEvent",
|
||||
"Event",
|
||||
"MouseEvent",
|
||||
"CompositionEvent",
|
||||
"InputEvent",
|
||||
] as const;
|
||||
const previous = new Map<string, PropertyDescriptor | undefined>();
|
||||
for (const name of globals) {
|
||||
previous.set(name, Object.getOwnPropertyDescriptor(globalThis, name));
|
||||
Object.defineProperty(globalThis, name, {
|
||||
value: window[name],
|
||||
configurable: true,
|
||||
});
|
||||
}
|
||||
previous.set("devicePixelRatio", Object.getOwnPropertyDescriptor(globalThis, "devicePixelRatio"));
|
||||
Object.defineProperty(globalThis, "devicePixelRatio", { value: 1, configurable: true });
|
||||
previous.set("ResizeObserver", Object.getOwnPropertyDescriptor(globalThis, "ResizeObserver"));
|
||||
Object.defineProperty(globalThis, "ResizeObserver", {
|
||||
value: class {
|
||||
observe() {}
|
||||
disconnect() {}
|
||||
},
|
||||
configurable: true,
|
||||
});
|
||||
|
||||
const term = new XTerm({
|
||||
allowProposedApi: true,
|
||||
cols: 80,
|
||||
rows: 24,
|
||||
vtExtensions: { kittyKeyboard: true, win32InputMode: true },
|
||||
});
|
||||
try {
|
||||
term.open(window.document.getElementById("terminal") as HTMLElement);
|
||||
const sent: string[] = [];
|
||||
term.onData((data) => sent.push(data));
|
||||
|
||||
await write(term, "\u001b[?9001h");
|
||||
await write(term, "\u001b[>2u");
|
||||
assert.equal(term.modes.win32InputMode, true);
|
||||
|
||||
for (const modifiers of [
|
||||
{},
|
||||
{ shiftKey: true },
|
||||
{ ctrlKey: true },
|
||||
{ altKey: true },
|
||||
]) {
|
||||
term.textarea?.dispatchEvent(new window.KeyboardEvent("keydown", {
|
||||
key: "Enter",
|
||||
code: "Enter",
|
||||
keyCode: 13,
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
...modifiers,
|
||||
}));
|
||||
}
|
||||
term.textarea?.dispatchEvent(new window.KeyboardEvent("keyup", {
|
||||
key: "Enter",
|
||||
code: "Enter",
|
||||
keyCode: 13,
|
||||
shiftKey: true,
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
}));
|
||||
|
||||
assert.deepEqual(sent, [
|
||||
"\u001b[13;28;13;1;0;1_",
|
||||
"\u001b[13;28;13;1;16;1_",
|
||||
"\u001b[13;28;10;1;8;1_",
|
||||
"\u001b[13;28;13;1;2;1_",
|
||||
"\u001b[13;28;13;0;16;1_",
|
||||
]);
|
||||
|
||||
const sourceTerm = new XTerm({ allowProposedApi: true, cols: 80, rows: 24 });
|
||||
try {
|
||||
sourceTerm.open(window.document.getElementById("source") as HTMLElement);
|
||||
sourceTerm.focus();
|
||||
assert.equal(window.document.activeElement, sourceTerm.textarea);
|
||||
sent.length = 0;
|
||||
const shiftEnter = {
|
||||
key: "Enter",
|
||||
code: "Enter",
|
||||
keyCode: 13,
|
||||
shiftKey: true,
|
||||
};
|
||||
dispatchWin32InputModeEvent(term, { type: "keydown", ...shiftEnter });
|
||||
dispatchWin32InputModeEvent(term, { type: "keyup", ...shiftEnter });
|
||||
assert.equal(window.document.activeElement, sourceTerm.textarea);
|
||||
assert.deepEqual(sent, [
|
||||
"\u001b[13;28;13;1;16;1_",
|
||||
"\u001b[13;28;13;0;16;1_",
|
||||
]);
|
||||
} finally {
|
||||
sourceTerm.dispose();
|
||||
}
|
||||
|
||||
// Netcatty consumes shortcuts and local controls before xterm. Their
|
||||
// releases must be consumed too, otherwise ConPTY receives an orphaned
|
||||
// native key-up after a Ctrl+C interrupt or sudo/autocomplete Enter.
|
||||
sent.length = 0;
|
||||
const forwardedKeys = new Set<string>();
|
||||
let pendingKeyDownIdentity: string | null = null;
|
||||
term.onData((data) => {
|
||||
if (
|
||||
pendingKeyDownIdentity &&
|
||||
data.startsWith("\u001b[") &&
|
||||
data.endsWith("_")
|
||||
) {
|
||||
forwardedKeys.add(pendingKeyDownIdentity);
|
||||
pendingKeyDownIdentity = null;
|
||||
}
|
||||
});
|
||||
term.attachCustomKeyEventHandler((event) => {
|
||||
const identity = event.code || event.key;
|
||||
if (event.type === "keyup") {
|
||||
pendingKeyDownIdentity = null;
|
||||
return forwardedKeys.delete(identity);
|
||||
}
|
||||
if (event.type !== "keydown") return true;
|
||||
const consumed =
|
||||
(event.ctrlKey && event.key.toLowerCase() === "c") ||
|
||||
(event.key === "Enter" && !event.shiftKey);
|
||||
if (consumed) return false;
|
||||
pendingKeyDownIdentity = identity;
|
||||
return true;
|
||||
});
|
||||
|
||||
for (const event of [
|
||||
new window.KeyboardEvent("keydown", {
|
||||
key: "c", code: "KeyC", keyCode: 67, ctrlKey: true, bubbles: true, cancelable: true,
|
||||
}),
|
||||
new window.KeyboardEvent("keyup", {
|
||||
key: "c", code: "KeyC", keyCode: 67, ctrlKey: true, bubbles: true, cancelable: true,
|
||||
}),
|
||||
new window.KeyboardEvent("keydown", {
|
||||
key: "Enter", code: "Enter", keyCode: 13, bubbles: true, cancelable: true,
|
||||
}),
|
||||
new window.KeyboardEvent("keyup", {
|
||||
key: "Enter", code: "Enter", keyCode: 13, bubbles: true, cancelable: true,
|
||||
}),
|
||||
new window.KeyboardEvent("keydown", {
|
||||
key: "Process", code: "KeyA", keyCode: 229, bubbles: true, cancelable: true,
|
||||
}),
|
||||
new window.KeyboardEvent("keyup", {
|
||||
key: "Process", code: "KeyA", keyCode: 229, bubbles: true, cancelable: true,
|
||||
}),
|
||||
]) {
|
||||
term.textarea?.dispatchEvent(event);
|
||||
}
|
||||
assert.deepEqual(sent, []);
|
||||
} finally {
|
||||
term.dispose();
|
||||
dom.window.close();
|
||||
for (const [name, descriptor] of previous) {
|
||||
if (descriptor) Object.defineProperty(globalThis, name, descriptor);
|
||||
else Reflect.deleteProperty(globalThis, name);
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,50 @@
|
||||
import type { Terminal as XTerm } from "@xterm/xterm";
|
||||
|
||||
import type { KittyKeyboardEvent } from "./kittyKeyboardProtocol";
|
||||
|
||||
export const dispatchWin32InputModeEvent = (
|
||||
term: Pick<XTerm, "textarea">,
|
||||
event: KittyKeyboardEvent,
|
||||
): boolean => {
|
||||
const textarea = term.textarea;
|
||||
const KeyboardEventConstructor = textarea?.ownerDocument.defaultView?.KeyboardEvent;
|
||||
if (!textarea || !KeyboardEventConstructor) return false;
|
||||
|
||||
const syntheticEvent = new KeyboardEventConstructor(event.type ?? "keydown", {
|
||||
key: event.key,
|
||||
code: event.code,
|
||||
location: event.location,
|
||||
repeat: event.repeat,
|
||||
isComposing: event.isComposing,
|
||||
shiftKey: event.shiftKey,
|
||||
altKey: event.altKey,
|
||||
ctrlKey: event.ctrlKey,
|
||||
metaKey: event.metaKey,
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
});
|
||||
if (event.keyCode !== undefined && syntheticEvent.keyCode !== event.keyCode) {
|
||||
Object.defineProperty(syntheticEvent, "keyCode", { value: event.keyCode });
|
||||
}
|
||||
if (event.getModifierState) {
|
||||
Object.defineProperty(syntheticEvent, "getModifierState", {
|
||||
value: (key: string) => event.getModifierState?.(key) === true,
|
||||
});
|
||||
}
|
||||
|
||||
// xterm's internal CoreBrowserTerminal focuses its textarea for key-up
|
||||
// events. A broadcast is transport, not user focus navigation, so suppress
|
||||
// that synchronous DOM focus side effect while the target encodes the event.
|
||||
const ownFocus = Object.getOwnPropertyDescriptor(textarea, "focus");
|
||||
Object.defineProperty(textarea, "focus", {
|
||||
value: () => undefined,
|
||||
configurable: true,
|
||||
});
|
||||
try {
|
||||
textarea.dispatchEvent(syntheticEvent);
|
||||
} finally {
|
||||
if (ownFocus) Object.defineProperty(textarea, "focus", ownFocus);
|
||||
else delete (textarea as HTMLTextAreaElement & { focus?: () => void }).focus;
|
||||
}
|
||||
return true;
|
||||
};
|
||||
@@ -3,6 +3,7 @@ import assert from "node:assert/strict";
|
||||
|
||||
import {
|
||||
createTerminalCwdTracker,
|
||||
invalidateTerminalCwdAfterCommand,
|
||||
probeBackendSessionCwdAfterCommand,
|
||||
resolvePreferredTerminalCwd,
|
||||
} from "./sftpCwd";
|
||||
@@ -73,6 +74,66 @@ test("resolvePreferredTerminalCwd can require a backend-confirmed cwd", async ()
|
||||
assert.equal(cwd, null);
|
||||
});
|
||||
|
||||
test("active-shell cwd resolution disables backend directory fallbacks", async () => {
|
||||
let receivedOptions: unknown;
|
||||
const cwd = await resolvePreferredTerminalCwd({
|
||||
rendererCwd: "/home/alice",
|
||||
rendererCwdSource: "backend",
|
||||
sessionId: "session-1",
|
||||
preferFreshBackend: true,
|
||||
requireActiveShellCwd: true,
|
||||
getSessionPwd: async (_sessionId, options) => {
|
||||
receivedOptions = options;
|
||||
return { success: true, cwd: "/root/releases" };
|
||||
},
|
||||
});
|
||||
|
||||
assert.deepEqual(receivedOptions, {
|
||||
allowHomeFallback: false,
|
||||
allowLoginShellFallback: false,
|
||||
});
|
||||
assert.equal(cwd, "/root/releases");
|
||||
});
|
||||
|
||||
test("active-shell cwd resolution never overwrites trusted OSC 7 with the login shell cwd", async () => {
|
||||
let backendCalls = 0;
|
||||
const cwd = await resolvePreferredTerminalCwd({
|
||||
rendererCwd: "/root/releases",
|
||||
rendererCwdSource: "osc7",
|
||||
sessionId: "session-1",
|
||||
preferFreshBackend: true,
|
||||
requireActiveShellCwd: true,
|
||||
getSessionPwd: async () => {
|
||||
backendCalls += 1;
|
||||
return { success: true, cwd: "/home/alice" };
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(cwd, "/root/releases");
|
||||
assert.equal(backendCalls, 0);
|
||||
});
|
||||
|
||||
test("active-shell cwd resolution fails closed when only an untrusted cached cwd remains", async () => {
|
||||
let receivedOptions: unknown;
|
||||
const cwd = await resolvePreferredTerminalCwd({
|
||||
rendererCwd: "/home/alice",
|
||||
rendererCwdSource: "backend",
|
||||
sessionId: "session-1",
|
||||
preferFreshBackend: true,
|
||||
requireActiveShellCwd: true,
|
||||
getSessionPwd: async (_sessionId, options) => {
|
||||
receivedOptions = options;
|
||||
return { success: false, error: "Could not determine cwd" };
|
||||
},
|
||||
});
|
||||
|
||||
assert.deepEqual(receivedOptions, {
|
||||
allowHomeFallback: false,
|
||||
allowLoginShellFallback: false,
|
||||
});
|
||||
assert.equal(cwd, null);
|
||||
});
|
||||
|
||||
test("resolvePreferredTerminalCwd falls back to backend pwd when no renderer cwd is known", async () => {
|
||||
const cwd = await resolvePreferredTerminalCwd({
|
||||
rendererCwd: undefined,
|
||||
@@ -111,6 +172,59 @@ test("terminal cwd tracker clears stale renderer cwd before falling back to back
|
||||
assert.equal(cwd, "/home/fresh-session");
|
||||
});
|
||||
|
||||
test("terminal cwd tracker preserves whether the cwd came from OSC 7", () => {
|
||||
const tracker = createTerminalCwdTracker();
|
||||
|
||||
tracker.setRendererCwd("/home/alice", "backend");
|
||||
assert.equal(tracker.getRendererCwdSource(), "backend");
|
||||
|
||||
tracker.setRendererCwd("/root/releases", "osc7");
|
||||
assert.equal(tracker.getRendererCwd(), "/root/releases");
|
||||
assert.equal(tracker.getRendererCwdSource(), "osc7");
|
||||
|
||||
tracker.clearRendererCwd();
|
||||
assert.equal(tracker.getRendererCwdSource(), undefined);
|
||||
});
|
||||
|
||||
test("terminal cwd tracker invalidates an old OSC 7 cwd when a command is submitted", async () => {
|
||||
const tracker = createTerminalCwdTracker();
|
||||
|
||||
tracker.setRendererCwd("/home/alice", "osc7");
|
||||
tracker.markRendererCwdStale();
|
||||
|
||||
assert.equal(tracker.getRendererCwd(), "/home/alice");
|
||||
assert.equal(tracker.getRendererCwdSource(), "stale");
|
||||
|
||||
const cwd = await resolvePreferredTerminalCwd({
|
||||
rendererCwd: tracker.getRendererCwd(),
|
||||
rendererCwdSource: tracker.getRendererCwdSource(),
|
||||
sessionId: "session-1",
|
||||
preferFreshBackend: true,
|
||||
requireActiveShellCwd: true,
|
||||
getSessionPwd: async () => ({ success: false, error: "Could not determine cwd" }),
|
||||
});
|
||||
|
||||
assert.equal(cwd, null);
|
||||
});
|
||||
|
||||
test("command submission clears snapshot cwd and the cwd shared with SFTP follow", () => {
|
||||
const tracker = createTerminalCwdTracker();
|
||||
const changes: Array<[string, string | null]> = [];
|
||||
let snapshotCwd = "/home/alice";
|
||||
tracker.setRendererCwd("/home/alice", "osc7");
|
||||
|
||||
invalidateTerminalCwdAfterCommand(
|
||||
tracker,
|
||||
"session-1",
|
||||
() => { snapshotCwd = ""; },
|
||||
(sessionId, cwd) => changes.push([sessionId, cwd]),
|
||||
);
|
||||
|
||||
assert.equal(snapshotCwd, "");
|
||||
assert.equal(tracker.getRendererCwdSource(), "stale");
|
||||
assert.deepEqual(changes, [["session-1", null]]);
|
||||
});
|
||||
|
||||
test("probeBackendSessionCwdAfterCommand skips when OSC 7 already reported after command", async () => {
|
||||
let backendCalls = 0;
|
||||
const cwd = await probeBackendSessionCwdAfterCommand({
|
||||
@@ -132,8 +246,12 @@ test("probeBackendSessionCwdAfterCommand probes backend when OSC 7 did not repor
|
||||
sessionId: "session-1",
|
||||
osc7SignalAtCommand: 3,
|
||||
getOsc7Signal: () => 3,
|
||||
getSessionPwd: async (sessionId) => {
|
||||
getSessionPwd: async (sessionId, options) => {
|
||||
assert.equal(sessionId, "session-1");
|
||||
assert.deepEqual(options, {
|
||||
allowHomeFallback: false,
|
||||
allowLoginShellFallback: false,
|
||||
});
|
||||
return { success: true, cwd: "/var/log" };
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import type { TerminalCwdSource } from "../../application/state/terminalCwdStore";
|
||||
|
||||
type SessionPwdResult = {
|
||||
success: boolean;
|
||||
cwd?: string | null;
|
||||
@@ -12,14 +14,20 @@ type SessionPwdOptions = {
|
||||
allowLoginShellFallback?: boolean;
|
||||
};
|
||||
|
||||
export type RendererCwdSource = TerminalCwdSource;
|
||||
export type TerminalCwdChangeMeta = { source?: RendererCwdSource };
|
||||
|
||||
type ResolvePreferredTerminalCwdOptions = {
|
||||
rendererCwd?: string | null;
|
||||
rendererCwdSource?: RendererCwdSource;
|
||||
sessionId?: string | null;
|
||||
getSessionPwd: (sessionId: string, options?: SessionPwdOptions) => Promise<SessionPwdResult>;
|
||||
/** When true, always probe the backend instead of trusting renderer cwd. */
|
||||
preferFreshBackend?: boolean;
|
||||
/** When false, a failed backend probe must not return a cached renderer cwd. */
|
||||
allowRendererFallback?: boolean;
|
||||
/** Require the active shell cwd; never substitute the login shell or home directory. */
|
||||
requireActiveShellCwd?: boolean;
|
||||
};
|
||||
|
||||
const normalizeCwd = (cwd?: string | null): string | null => {
|
||||
@@ -29,50 +37,85 @@ const normalizeCwd = (cwd?: string | null): string | null => {
|
||||
|
||||
export type TerminalCwdTracker = {
|
||||
getRendererCwd: () => string | undefined;
|
||||
setRendererCwd: (cwd?: string | null) => string | undefined;
|
||||
getRendererCwdSource: () => RendererCwdSource | undefined;
|
||||
setRendererCwd: (
|
||||
cwd?: string | null,
|
||||
source?: RendererCwdSource,
|
||||
) => string | undefined;
|
||||
markRendererCwdStale: () => void;
|
||||
clearRendererCwd: () => void;
|
||||
};
|
||||
|
||||
export const createTerminalCwdTracker = (): TerminalCwdTracker => {
|
||||
let rendererCwd: string | undefined;
|
||||
let rendererCwdSource: RendererCwdSource | undefined;
|
||||
|
||||
return {
|
||||
getRendererCwd: () => rendererCwd,
|
||||
setRendererCwd: (cwd) => {
|
||||
getRendererCwdSource: () => rendererCwdSource,
|
||||
setRendererCwd: (cwd, source = "unknown") => {
|
||||
rendererCwd = normalizeCwd(cwd) ?? undefined;
|
||||
rendererCwdSource = rendererCwd ? source : undefined;
|
||||
return rendererCwd;
|
||||
},
|
||||
markRendererCwdStale: () => {
|
||||
if (rendererCwd) rendererCwdSource = "stale";
|
||||
},
|
||||
clearRendererCwd: () => {
|
||||
rendererCwd = undefined;
|
||||
rendererCwdSource = undefined;
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
/** Invalidate both the terminal-local provenance and the shared SFTP-follow cwd. */
|
||||
export const invalidateTerminalCwdAfterCommand = (
|
||||
tracker: TerminalCwdTracker,
|
||||
sessionId: string,
|
||||
onSnapshotCwdInvalidated: () => void,
|
||||
onTerminalCwdChange?: (sessionId: string, cwd: string | null) => void,
|
||||
): void => {
|
||||
onSnapshotCwdInvalidated();
|
||||
tracker.markRendererCwdStale();
|
||||
onTerminalCwdChange?.(sessionId, null);
|
||||
};
|
||||
|
||||
export const resolvePreferredTerminalCwd = async ({
|
||||
rendererCwd,
|
||||
rendererCwdSource = "unknown",
|
||||
sessionId,
|
||||
getSessionPwd,
|
||||
preferFreshBackend = false,
|
||||
allowRendererFallback = true,
|
||||
requireActiveShellCwd = false,
|
||||
}: ResolvePreferredTerminalCwdOptions): Promise<string | null> => {
|
||||
const knownCwd = normalizeCwd(rendererCwd);
|
||||
if (!preferFreshBackend && knownCwd) return knownCwd;
|
||||
if (!sessionId) return allowRendererFallback ? knownCwd : null;
|
||||
if (requireActiveShellCwd && knownCwd && rendererCwdSource === "osc7") {
|
||||
return knownCwd;
|
||||
}
|
||||
const canUseRendererFallback = allowRendererFallback && (
|
||||
!requireActiveShellCwd || rendererCwdSource === "osc7"
|
||||
);
|
||||
if (!preferFreshBackend && knownCwd && canUseRendererFallback) return knownCwd;
|
||||
if (!sessionId) return canUseRendererFallback ? knownCwd : null;
|
||||
|
||||
try {
|
||||
const result = await getSessionPwd(
|
||||
sessionId,
|
||||
// Disable ~ guessing so we do not open SFTP on a fabricated home path,
|
||||
// but keep login-shell fallback after su/sudo so drag-drop still
|
||||
// resolves a writable cwd (#2886).
|
||||
// while retaining the legacy login-shell fallback only for callers that
|
||||
// do not require proof of the active shell directory (#2886).
|
||||
preferFreshBackend
|
||||
? { allowHomeFallback: false, allowLoginShellFallback: true }
|
||||
? {
|
||||
allowHomeFallback: false,
|
||||
allowLoginShellFallback: !requireActiveShellCwd,
|
||||
}
|
||||
: undefined,
|
||||
);
|
||||
const backendCwd = result.success ? normalizeCwd(result.cwd) : null;
|
||||
return backendCwd ?? (allowRendererFallback ? knownCwd : null);
|
||||
return backendCwd ?? (canUseRendererFallback ? knownCwd : null);
|
||||
} catch {
|
||||
return allowRendererFallback ? knownCwd : null;
|
||||
return canUseRendererFallback ? knownCwd : null;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -99,7 +142,13 @@ export const probeBackendSessionCwdAfterCommand = async ({
|
||||
if (!allowed || getOsc7Signal() !== osc7SignalAtCommand) return null;
|
||||
|
||||
try {
|
||||
const result = await getSessionPwd(sessionId);
|
||||
// This result is published to SFTP follow as the active shell cwd. Do not
|
||||
// let the backend substitute the login shell or home directory after a
|
||||
// command such as sudo/su changed the interactive shell identity.
|
||||
const result = await getSessionPwd(sessionId, {
|
||||
allowHomeFallback: false,
|
||||
allowLoginShellFallback: false,
|
||||
});
|
||||
if (getOsc7Signal() !== osc7SignalAtCommand) return null;
|
||||
return result.success ? normalizeCwd(result.cwd) : null;
|
||||
} catch {
|
||||
|
||||
@@ -3,7 +3,13 @@ import assert from "node:assert/strict";
|
||||
|
||||
import type { DropEntry } from "../../lib/sftpFileUtils";
|
||||
import type { Host } from "../../types";
|
||||
import { handleTerminalDropEntries } from "./hooks/useTerminalDragDrop";
|
||||
import { TerminalDropNeedsSudoError } from "../../domain/sftpDropElevation";
|
||||
import {
|
||||
ActiveTerminalCwdUnavailableError,
|
||||
DEFAULT_RZ_MISSING_FALLBACK_TIMEOUT_MS,
|
||||
handleTerminalDropEntries,
|
||||
resolveTerminalDropErrorMessage,
|
||||
} from "./hooks/useTerminalDragDrop";
|
||||
import { resolvePreferredTerminalCwd } from "./sftpCwd";
|
||||
|
||||
const host = {
|
||||
@@ -23,6 +29,38 @@ const dropEntries: DropEntry[] = [
|
||||
},
|
||||
];
|
||||
|
||||
test("terminal drag-drop allows the full ZMODEM startup window before falling back", () => {
|
||||
assert.equal(DEFAULT_RZ_MISSING_FALLBACK_TIMEOUT_MS, 15_000);
|
||||
});
|
||||
|
||||
test("terminal drag-drop shows actionable guidance when the active directory is unknown", () => {
|
||||
const requestedKeys: string[] = [];
|
||||
const message = resolveTerminalDropErrorMessage(
|
||||
new ActiveTerminalCwdUnavailableError(),
|
||||
(key) => {
|
||||
requestedKeys.push(key);
|
||||
return `translated:${key}`;
|
||||
},
|
||||
);
|
||||
|
||||
assert.equal(message, "translated:terminal.dragDrop.destinationUnknown");
|
||||
assert.deepEqual(requestedKeys, ["terminal.dragDrop.destinationUnknown"]);
|
||||
});
|
||||
|
||||
test("terminal drag-drop asks to enable sudo when /root is not writable as the login user", () => {
|
||||
const requestedKeys: string[] = [];
|
||||
const message = resolveTerminalDropErrorMessage(
|
||||
new TerminalDropNeedsSudoError(),
|
||||
(key) => {
|
||||
requestedKeys.push(key);
|
||||
return `translated:${key}`;
|
||||
},
|
||||
);
|
||||
|
||||
assert.equal(message, "translated:terminal.dragDrop.needsSudoElevation");
|
||||
assert.deepEqual(requestedKeys, ["terminal.dragDrop.needsSudoElevation"]);
|
||||
});
|
||||
|
||||
test("remote SSH terminal drop triggers ZMODEM drag-drop upload", async () => {
|
||||
let uploadedFiles: unknown;
|
||||
let uploadedSessionId: string | undefined;
|
||||
@@ -110,6 +148,47 @@ test("remote SSH terminal drop stays on ZMODEM when rz starts", async () => {
|
||||
assert.equal(openedSftp, false);
|
||||
});
|
||||
|
||||
test("remote SSH terminal drop still waits for rz after the old 2.5 second deadline", async () => {
|
||||
let openedSftp = false;
|
||||
let zmodemCallback: ((event: { type: string; transferType?: string }) => void) | undefined;
|
||||
|
||||
await handleTerminalDropEntries({
|
||||
dropEntries: [{
|
||||
file: {
|
||||
name: "report.txt",
|
||||
arrayBuffer: async () => new Uint8Array([1, 2, 3]).buffer,
|
||||
} as File,
|
||||
relativePath: "report.txt",
|
||||
isDirectory: false,
|
||||
}],
|
||||
host,
|
||||
isLocalConnection: false,
|
||||
onOpenSftp: () => { openedSftp = true; },
|
||||
resolveSftpInitialPath: async () => "/srv/app/current",
|
||||
scrollToBottomAfterProgrammaticInput: () => {},
|
||||
sessionId: "session-1",
|
||||
sessionRef: { current: "session-1" },
|
||||
terminalBackend: {
|
||||
writeToSession: () => {},
|
||||
cancelZmodem: () => {},
|
||||
onSessionData: () => () => {},
|
||||
onZmodemEvent: (_sessionId, cb) => {
|
||||
zmodemCallback = cb;
|
||||
return () => { zmodemCallback = undefined; };
|
||||
},
|
||||
startZmodemDragDropUpload: async () => {
|
||||
setTimeout(() => {
|
||||
zmodemCallback?.({ type: "detect", transferType: "upload" });
|
||||
}, 2_600);
|
||||
return { success: true };
|
||||
},
|
||||
},
|
||||
termRef: { current: null },
|
||||
});
|
||||
|
||||
assert.equal(openedSftp, false);
|
||||
});
|
||||
|
||||
test("serial terminal drop does not wrap rz with an SSH shell fallback", async () => {
|
||||
let uploadCommandSeen: string | undefined;
|
||||
|
||||
@@ -211,7 +290,10 @@ test("network device drop falls back to SFTP upload with a freshly resolved cwd"
|
||||
termRef: { current: null },
|
||||
});
|
||||
|
||||
assert.deepEqual(receivedOptions, { preferFreshBackend: true });
|
||||
assert.deepEqual(receivedOptions, {
|
||||
preferFreshBackend: true,
|
||||
requireActiveShellCwd: true,
|
||||
});
|
||||
assert.equal(openedPath, "/srv/app/current");
|
||||
assert.equal(openedEntries, dropEntries);
|
||||
assert.equal(openedSessionId, "session-1");
|
||||
@@ -273,7 +355,10 @@ test("remote SSH terminal drop falls back to SFTP when rz is unavailable", async
|
||||
termRef: { current: null },
|
||||
});
|
||||
|
||||
assert.deepEqual(receivedOptions, { preferFreshBackend: true });
|
||||
assert.deepEqual(receivedOptions, {
|
||||
preferFreshBackend: true,
|
||||
requireActiveShellCwd: true,
|
||||
});
|
||||
assert.equal(openedPath, "/srv/app/current");
|
||||
assert.equal(openedEntries?.length, 1);
|
||||
assert.equal(openedEntries?.[0].relativePath, "report.txt");
|
||||
@@ -323,7 +408,10 @@ test("remote SSH terminal drop falls back to SFTP when rz never starts", async (
|
||||
termRef: { current: null },
|
||||
});
|
||||
|
||||
assert.deepEqual(receivedOptions, { preferFreshBackend: true });
|
||||
assert.deepEqual(receivedOptions, {
|
||||
preferFreshBackend: true,
|
||||
requireActiveShellCwd: true,
|
||||
});
|
||||
assert.equal(openedPath, "/srv/app/current");
|
||||
assert.equal(openedEntries?.length, 1);
|
||||
assert.deepEqual(cancelled, { sessionId: "session-1", interrupt: true });
|
||||
@@ -331,6 +419,8 @@ test("remote SSH terminal drop falls back to SFTP when rz never starts", async (
|
||||
|
||||
test("remote SSH folder drop uses SFTP to preserve directory structure", async () => {
|
||||
let openedEntries: DropEntry[] | undefined;
|
||||
let originSessionId: string | undefined;
|
||||
let sourceSessionId: string | undefined;
|
||||
let zmodemStarted = false;
|
||||
|
||||
const folderEntries: DropEntry[] = [
|
||||
@@ -353,8 +443,10 @@ test("remote SSH folder drop uses SFTP to preserve directory structure", async (
|
||||
dropEntries: folderEntries,
|
||||
host,
|
||||
isLocalConnection: false,
|
||||
onOpenSftp: (_host, _initialPath, pendingUploadEntries) => {
|
||||
onOpenSftp: (_host, _initialPath, pendingUploadEntries, origin, source) => {
|
||||
openedEntries = pendingUploadEntries;
|
||||
originSessionId = origin;
|
||||
sourceSessionId = source;
|
||||
},
|
||||
resolveSftpInitialPath: async () => "/srv/app/current",
|
||||
scrollToBottomAfterProgrammaticInput: () => {},
|
||||
@@ -372,6 +464,235 @@ test("remote SSH folder drop uses SFTP to preserve directory structure", async (
|
||||
|
||||
assert.equal(zmodemStarted, false);
|
||||
assert.equal(openedEntries, folderEntries);
|
||||
assert.equal(originSessionId, "session-1");
|
||||
assert.equal(sourceSessionId, "session-1");
|
||||
});
|
||||
|
||||
test("Mosh folder drop keeps its origin but opens a fresh SFTP route", async () => {
|
||||
let originSessionId: string | undefined;
|
||||
let sourceSessionId: string | undefined;
|
||||
|
||||
await handleTerminalDropEntries({
|
||||
dropEntries: [{ file: null, relativePath: "docs", isDirectory: true }],
|
||||
host: { ...host, moshEnabled: true },
|
||||
isLocalConnection: false,
|
||||
onOpenSftp: (_host, _path, _entries, origin, source) => {
|
||||
originSessionId = origin;
|
||||
sourceSessionId = source;
|
||||
},
|
||||
resolveSftpInitialPath: async () => "/srv/app/current",
|
||||
scrollToBottomAfterProgrammaticInput: () => {},
|
||||
sessionId: "mosh-session",
|
||||
sessionRef: { current: "mosh-session" },
|
||||
terminalBackend: { writeToSession: () => {} },
|
||||
termRef: { current: null },
|
||||
});
|
||||
|
||||
assert.equal(originSessionId, "mosh-session");
|
||||
assert.equal(sourceSessionId, undefined);
|
||||
});
|
||||
|
||||
test("ET rz fallback keeps its origin but opens a fresh SFTP route", async () => {
|
||||
let originSessionId: string | undefined;
|
||||
let sourceSessionId: string | undefined;
|
||||
let dataCallback: ((chunk: string) => void) | undefined;
|
||||
|
||||
await handleTerminalDropEntries({
|
||||
dropEntries: [{
|
||||
file: {
|
||||
name: "report.txt",
|
||||
arrayBuffer: async () => new Uint8Array([1]).buffer,
|
||||
} as File,
|
||||
relativePath: "report.txt",
|
||||
isDirectory: false,
|
||||
}],
|
||||
host: { ...host, etEnabled: true },
|
||||
isLocalConnection: false,
|
||||
onOpenSftp: (_host, _path, _entries, origin, source) => {
|
||||
originSessionId = origin;
|
||||
sourceSessionId = source;
|
||||
},
|
||||
resolveSftpInitialPath: async () => "/srv/app/current",
|
||||
scrollToBottomAfterProgrammaticInput: () => {},
|
||||
sessionId: "et-session",
|
||||
sessionRef: { current: "et-session" },
|
||||
terminalBackend: {
|
||||
writeToSession: () => {},
|
||||
onSessionData: (_sessionId, callback) => {
|
||||
dataCallback = callback;
|
||||
return () => { dataCallback = undefined; };
|
||||
},
|
||||
cancelZmodem: () => {},
|
||||
startZmodemDragDropUpload: async (_sessionId, _files, uploadCommand) => {
|
||||
const token = uploadCommand?.match(/NetcattyRzMissing=([A-Za-z0-9_-]+)/)?.[1];
|
||||
assert.ok(token);
|
||||
dataCallback?.(`\u001b]1337;NetcattyRzMissing=${token}\u0007`);
|
||||
return { success: true };
|
||||
},
|
||||
},
|
||||
termRef: { current: null },
|
||||
});
|
||||
|
||||
assert.equal(originSessionId, "et-session");
|
||||
assert.equal(sourceSessionId, undefined);
|
||||
});
|
||||
|
||||
test("remote SSH folder drop to /root reuses the saved host password for sudo SFTP", async () => {
|
||||
let openedHost: Host | undefined;
|
||||
let openedPath: string | undefined;
|
||||
|
||||
await handleTerminalDropEntries({
|
||||
dropEntries: [{ file: null, relativePath: "docs", isDirectory: true }],
|
||||
host: { ...host, password: "secret" },
|
||||
isLocalConnection: false,
|
||||
onOpenSftp: (nextHost, initialPath) => {
|
||||
openedHost = nextHost;
|
||||
openedPath = initialPath;
|
||||
},
|
||||
resolveSftpInitialPath: async () => "/root",
|
||||
scrollToBottomAfterProgrammaticInput: () => {},
|
||||
sessionId: "session-1",
|
||||
sessionRef: { current: "session-1" },
|
||||
terminalBackend: { writeToSession: () => {} },
|
||||
termRef: { current: null },
|
||||
});
|
||||
|
||||
assert.equal(openedPath, "/root");
|
||||
assert.equal(openedHost?.sftpSudo, true);
|
||||
assert.equal(openedHost?.password, "secret");
|
||||
assert.equal(host.sftpSudo, undefined);
|
||||
});
|
||||
|
||||
test("remote SSH folder drop to /root uses a resolved identity username over a stale host username", async () => {
|
||||
let openedHost: Host | undefined;
|
||||
|
||||
await handleTerminalDropEntries({
|
||||
dropEntries: [{ file: null, relativePath: "docs", isDirectory: true }],
|
||||
host: { ...host, username: "root", password: "secret" },
|
||||
resolvedLoginUsername: "alice",
|
||||
resolvedSudoPassword: "secret",
|
||||
isLocalConnection: false,
|
||||
onOpenSftp: (nextHost) => {
|
||||
openedHost = nextHost;
|
||||
},
|
||||
resolveSftpInitialPath: async () => "/root",
|
||||
scrollToBottomAfterProgrammaticInput: () => {},
|
||||
sessionId: "session-1",
|
||||
sessionRef: { current: "session-1" },
|
||||
terminalBackend: { writeToSession: () => {} },
|
||||
termRef: { current: null },
|
||||
});
|
||||
|
||||
assert.equal(openedHost?.sftpSudo, true);
|
||||
});
|
||||
|
||||
test("remote SSH folder drop to /root uses a resolved identity password", async () => {
|
||||
let openedHost: Host | undefined;
|
||||
|
||||
await handleTerminalDropEntries({
|
||||
dropEntries: [{ file: null, relativePath: "docs", isDirectory: true }],
|
||||
host: { ...host, identityId: "id-1" },
|
||||
resolvedSudoPassword: "identity-secret",
|
||||
isLocalConnection: false,
|
||||
onOpenSftp: (nextHost) => {
|
||||
openedHost = nextHost;
|
||||
},
|
||||
resolveSftpInitialPath: async () => "/root",
|
||||
scrollToBottomAfterProgrammaticInput: () => {},
|
||||
sessionId: "session-1",
|
||||
sessionRef: { current: "session-1" },
|
||||
terminalBackend: { writeToSession: () => {} },
|
||||
termRef: { current: null },
|
||||
});
|
||||
|
||||
assert.equal(openedHost?.sftpSudo, true);
|
||||
assert.equal(openedHost?.password, undefined);
|
||||
assert.equal(openedHost?.identityId, "id-1");
|
||||
});
|
||||
|
||||
test("remote SSH folder drop to /root fails closed without a saved sudo password", async () => {
|
||||
let openedSftp = false;
|
||||
|
||||
await assert.rejects(
|
||||
handleTerminalDropEntries({
|
||||
dropEntries: [{ file: null, relativePath: "docs", isDirectory: true }],
|
||||
host,
|
||||
isLocalConnection: false,
|
||||
onOpenSftp: () => {
|
||||
openedSftp = true;
|
||||
},
|
||||
resolveSftpInitialPath: async () => "/root",
|
||||
scrollToBottomAfterProgrammaticInput: () => {},
|
||||
sessionId: "session-1",
|
||||
sessionRef: { current: "session-1" },
|
||||
terminalBackend: { writeToSession: () => {} },
|
||||
termRef: { current: null },
|
||||
}),
|
||||
TerminalDropNeedsSudoError,
|
||||
);
|
||||
|
||||
assert.equal(openedSftp, false);
|
||||
});
|
||||
|
||||
test("remote SSH folder drop to the login home does not enable sudo", async () => {
|
||||
let openedHost: Host | undefined;
|
||||
|
||||
await handleTerminalDropEntries({
|
||||
dropEntries: [{ file: null, relativePath: "docs", isDirectory: true }],
|
||||
host: { ...host, password: "secret" },
|
||||
isLocalConnection: false,
|
||||
onOpenSftp: (nextHost) => {
|
||||
openedHost = nextHost;
|
||||
},
|
||||
resolveSftpInitialPath: async () => "/home/alice",
|
||||
scrollToBottomAfterProgrammaticInput: () => {},
|
||||
sessionId: "session-1",
|
||||
sessionRef: { current: "session-1" },
|
||||
terminalBackend: { writeToSession: () => {} },
|
||||
termRef: { current: null },
|
||||
});
|
||||
|
||||
assert.equal(openedHost?.sftpSudo, undefined);
|
||||
});
|
||||
|
||||
test("remote SSH folder drop refuses to guess a destination when the active shell cwd is unknown", async () => {
|
||||
let openedSftp = false;
|
||||
let receivedOptions: unknown;
|
||||
|
||||
await assert.rejects(
|
||||
handleTerminalDropEntries({
|
||||
dropEntries: [
|
||||
{
|
||||
file: null,
|
||||
relativePath: "docs",
|
||||
isDirectory: true,
|
||||
},
|
||||
],
|
||||
host,
|
||||
isLocalConnection: false,
|
||||
onOpenSftp: () => {
|
||||
openedSftp = true;
|
||||
},
|
||||
resolveSftpInitialPath: async (options) => {
|
||||
receivedOptions = options;
|
||||
return undefined;
|
||||
},
|
||||
scrollToBottomAfterProgrammaticInput: () => {},
|
||||
sessionId: "session-1",
|
||||
sessionRef: { current: "session-1" },
|
||||
terminalBackend: {
|
||||
writeToSession: () => {},
|
||||
},
|
||||
termRef: { current: null },
|
||||
}),
|
||||
/Could not determine the active terminal directory/,
|
||||
);
|
||||
|
||||
assert.deepEqual(receivedOptions, {
|
||||
preferFreshBackend: true,
|
||||
requireActiveShellCwd: true,
|
||||
});
|
||||
assert.equal(openedSftp, false);
|
||||
});
|
||||
|
||||
test("fresh cwd resolution falls back to the renderer cwd when backend probe has no real cwd", async () => {
|
||||
|
||||
@@ -21,6 +21,7 @@ import type {
|
||||
TerminalTheme,
|
||||
} from "../../types";
|
||||
import type { KittyKeyboardBroadcastInput } from "./runtime/kittyKeyboardBroadcast";
|
||||
import type { TerminalCwdChangeMeta } from "./sftpCwd";
|
||||
|
||||
export const MAX_CONNECTION_LOG_DATA_CHARS = 1_000_000;
|
||||
export const AUTO_RUN_SNIPPET_LINE_DELAY_MS = 250;
|
||||
@@ -198,9 +199,10 @@ export interface TerminalProps {
|
||||
host: Host,
|
||||
initialPath?: string,
|
||||
pendingUploadEntries?: DropEntry[],
|
||||
originSessionId?: string,
|
||||
sourceSessionId?: string,
|
||||
) => void;
|
||||
onTerminalCwdChange?: (sessionId: string, cwd: string | null, meta?: { source?: 'osc7' }) => void;
|
||||
onTerminalCwdChange?: (sessionId: string, cwd: string | null, meta?: TerminalCwdChangeMeta) => void;
|
||||
onTerminalTitleChange?: (sessionId: string, title: string | null) => void;
|
||||
onTerminalBell?: (sessionId: string) => void;
|
||||
onTerminalOutput?: (sessionId: string, chunk: string) => void;
|
||||
|
||||
@@ -146,7 +146,7 @@ test('session launch paths use the same effective protocol as Provider snapshots
|
||||
|
||||
test('trusted command delivery reaches plugin providers without duplicating the host callback', () => {
|
||||
const callbackStart = terminalSource.indexOf('const pluginAwareOnCommandSubmitted = useCallback');
|
||||
const callbackEnd = terminalSource.indexOf('const pluginAwareOnCommandCompleted = useCallback', callbackStart);
|
||||
const callbackEnd = terminalSource.indexOf('const cwdAwareOnCommandSubmitted = useCallback', callbackStart);
|
||||
assert.notEqual(callbackStart, -1);
|
||||
assert.notEqual(callbackEnd, -1);
|
||||
|
||||
|
||||
@@ -650,7 +650,7 @@ export function useTerminalEffects(ctx: TerminalEffectsContext) {
|
||||
passwordPromptActiveRef.current = snap.passwordPromptActive;
|
||||
}
|
||||
if (snap.cwd !== undefined) {
|
||||
const cwd = terminalCwdTracker.setRendererCwd(snap.cwd);
|
||||
const cwd = terminalCwdTracker.setRendererCwd(snap.cwd, "snapshot");
|
||||
knownCwdRef.current = cwd;
|
||||
publishPluginTerminalRuntimeLifecycleEvent(
|
||||
pluginTerminalLifecycle,
|
||||
|
||||
@@ -28,6 +28,7 @@ import {
|
||||
resolveTerminalHibernateEnabledForProtocol,
|
||||
} from '../../domain/terminalHibernate';
|
||||
import { KeyBinding, TerminalSettings } from '../../domain/models';
|
||||
import type { TerminalCwdChangeMeta } from '../terminal/sftpCwd';
|
||||
import { STORAGE_KEY_AI_SHOW_TERMINAL_SELECTION_ACTION } from '../../infrastructure/config/storageKeys';
|
||||
import { cn } from '../../lib/utils';
|
||||
import { LazyLoadBoundary } from '../ui/lazy-load-boundary';
|
||||
@@ -96,6 +97,10 @@ export type PendingSftpUpload = {
|
||||
hostId: string;
|
||||
/** Full connection identity (id:hostname:port:protocol) for session-override awareness */
|
||||
connectionKey: string;
|
||||
/** Terminal session where the drop originated, including Mosh and ET. */
|
||||
originSessionId?: string;
|
||||
/** Terminal session whose active route must own the accepting SFTP connection. */
|
||||
sourceSessionId?: string;
|
||||
targetPath?: string;
|
||||
entries: DropEntry[];
|
||||
};
|
||||
@@ -781,9 +786,10 @@ interface TerminalPaneProps {
|
||||
host: Host,
|
||||
initialPath?: string,
|
||||
pendingUploadEntries?: DropEntry[],
|
||||
originSessionId?: string,
|
||||
sourceSessionId?: string,
|
||||
) => void;
|
||||
onTerminalCwdChange: (sessionId: string, cwd: string | null, meta?: { source?: 'osc7' }) => void;
|
||||
onTerminalCwdChange: (sessionId: string, cwd: string | null, meta?: TerminalCwdChangeMeta) => void;
|
||||
onTerminalTitleChange?: (sessionId: string, title: string | null) => void;
|
||||
onTerminalBell?: (sessionId: string) => void;
|
||||
onTerminalOutput?: (sessionId: string, chunk: string) => void;
|
||||
|
||||
@@ -181,6 +181,14 @@ export function TerminalLayerTabBridge({ stableRef }: { stableRef: StableRef })
|
||||
?? null
|
||||
)
|
||||
: null;
|
||||
const activeTerminalCwdSource = linkedTerminalSessionIdForSftp
|
||||
? (
|
||||
terminalCwdStore.getSource(linkedTerminalSessionIdForSftp)
|
||||
?? s.terminalRendererCwdSourceBySessionRef.current.get(linkedTerminalSessionIdForSftp)
|
||||
)
|
||||
: undefined;
|
||||
const activeTerminalCwdTrusted = activeTerminalCwdSource === 'osc7'
|
||||
|| activeTerminalCwdSource === 'backend-strict';
|
||||
void terminalCwdVersion;
|
||||
|
||||
const historySessionId = effectiveFocusedSessionId;
|
||||
@@ -252,6 +260,7 @@ export function TerminalLayerTabBridge({ stableRef }: { stableRef: StableRef })
|
||||
sftpActiveHost,
|
||||
activeTerminalSessionIdForSftp,
|
||||
activeTerminalCwd,
|
||||
activeTerminalCwdTrusted,
|
||||
activeWorkspace,
|
||||
activeTerminalSessionForSystem: activeTerminalSessionForSystem ?? null,
|
||||
activeSystemSessionHost,
|
||||
@@ -270,6 +279,7 @@ export function TerminalLayerTabBridge({ stableRef }: { stableRef: StableRef })
|
||||
}), [
|
||||
activeSystemSessionHost,
|
||||
activeTerminalCwd,
|
||||
activeTerminalCwdTrusted,
|
||||
activeTerminalSessionForSystem,
|
||||
activeTerminalSessionIdForSftp,
|
||||
activeWorkspace,
|
||||
@@ -405,6 +415,7 @@ export function TerminalLayerTabBridge({ stableRef }: { stableRef: StableRef })
|
||||
activeSidePanelLayout,
|
||||
activeTabId,
|
||||
activeTerminalCwd,
|
||||
activeTerminalCwdTrusted,
|
||||
activeTerminalSessionIdForSftp,
|
||||
activeWorkspace,
|
||||
AIChatPanelsHost: s.AIChatPanelsHost,
|
||||
@@ -643,6 +654,7 @@ export function TerminalLayerTabBridge({ stableRef }: { stableRef: StableRef })
|
||||
activeSidePanelTab,
|
||||
activeTabId,
|
||||
activeTerminalCwd,
|
||||
activeTerminalCwdTrusted,
|
||||
activeTerminalSessionIdForSftp,
|
||||
activeWorkspace,
|
||||
aiContextsByTabId,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { resolveHostFollowTerminalCwd, resolveSftpFollowTerminalCwdTargetHost } from "../sftp/sftpFollowTerminalCwd";
|
||||
import { resolveHostFollowTerminalCwd, resolveSftpFollowTerminalCwdTargetHost } from "../../domain/sftpFollowTerminalCwd";
|
||||
|
||||
type FollowTerminalCwdHost = {
|
||||
sftpFollowTerminalCwd?: boolean;
|
||||
|
||||
@@ -26,7 +26,7 @@ import { shouldKeepTerminalBackgroundWorkActive } from '../../domain/terminalHib
|
||||
import { resolveTerminalFontFamilyId } from '../../infrastructure/config/fonts';
|
||||
import type { Host, TerminalSession, Workspace } from '../../types';
|
||||
import { SystemManagerSidePanel } from '../systemManager/SystemManagerSidePanel';
|
||||
import { resolveSftpFollowTerminalCwdTargetHost } from '../sftp/sftpFollowTerminalCwd';
|
||||
import { resolveSftpFollowTerminalCwdTargetHost } from '../../domain/sftpFollowTerminalCwd';
|
||||
import { AI_PANEL_FORCE_HIDE_SHELL } from '../ai/aiPanelDiagnostics';
|
||||
import type { SidePanelTab } from './TerminalLayerSupport';
|
||||
import {
|
||||
@@ -112,6 +112,11 @@ function SidePanelSftpSlotInner({
|
||||
const panelActiveHost = isVisible
|
||||
? (live.sftpActiveHost ?? storedSftpHost)
|
||||
: storedSftpHost;
|
||||
const panelActiveSessionId = isVisible ? live.activeTerminalSessionIdForSftp : null;
|
||||
const panelFocusedSessionId = isVisible ? live.focusedSessionId : null;
|
||||
// Only the head of the per-tab pending upload queue is surfaced at a time;
|
||||
// the panel advances the queue by reporting each request as handled.
|
||||
const pendingUpload = sftpPendingUploadsForTab.get(tabId)?.[0] ?? null;
|
||||
|
||||
const handleFollowTerminalCwdChange = useCallback((enabled: boolean, visibleHost?: Host | null) => {
|
||||
const isActive = activeTabStore.getActiveTabId() === tabId;
|
||||
@@ -155,13 +160,13 @@ function SidePanelSftpSlotInner({
|
||||
handleSftpCurrentPathChange(
|
||||
getSftpCurrentPathMemoryKey({
|
||||
tabId,
|
||||
activeTerminalSessionIdForSftp: live.activeTerminalSessionIdForSftp,
|
||||
focusedSessionId: live.focusedSessionId,
|
||||
activeTerminalSessionIdForSftp: panelActiveSessionId,
|
||||
focusedSessionId: panelFocusedSessionId,
|
||||
}),
|
||||
location,
|
||||
);
|
||||
},
|
||||
[handleSftpCurrentPathChange, live.activeTerminalSessionIdForSftp, live.focusedSessionId, tabId],
|
||||
[handleSftpCurrentPathChange, panelActiveSessionId, panelFocusedSessionId, tabId],
|
||||
);
|
||||
|
||||
const handleActiveTransfersChange = useCallback(
|
||||
@@ -192,8 +197,8 @@ function SidePanelSftpSlotInner({
|
||||
onAddKnownHost={handleAddKnownHost}
|
||||
sftpDefaultViewMode={sftpDefaultViewMode}
|
||||
activeHost={panelActiveHost}
|
||||
activeSessionId={isVisible ? live.activeTerminalSessionIdForSftp : null}
|
||||
focusedSessionId={isVisible ? live.focusedSessionId : null}
|
||||
activeSessionId={panelActiveSessionId}
|
||||
focusedSessionId={panelFocusedSessionId}
|
||||
initialLocation={isVisible ? (sftpInitialLocationForTab.get(tabId) ?? null) : null}
|
||||
onInitialLocationApplied={handleInitialLocationApplied}
|
||||
onCurrentPathChange={handleCurrentPathChange}
|
||||
@@ -203,7 +208,7 @@ function SidePanelSftpSlotInner({
|
||||
isVisible={isVisible}
|
||||
ownerPanelOpen={ownerPanelOpen}
|
||||
renderOverlays={isVisible}
|
||||
pendingUpload={sftpPendingUploadsForTab.get(tabId) ?? null}
|
||||
pendingUpload={pendingUpload}
|
||||
onPendingUploadHandled={handlePendingUploadHandledForTab}
|
||||
sftpDoubleClickBehavior={sftpDoubleClickBehavior}
|
||||
sftpAutoSync={isVisible ? sftpAutoSync : false}
|
||||
@@ -215,6 +220,7 @@ function SidePanelSftpSlotInner({
|
||||
setEditorWordWrap={setEditorWordWrap}
|
||||
onGetTerminalCwd={getTerminalCwd}
|
||||
activeTerminalCwd={isVisible ? live.activeTerminalCwd : null}
|
||||
activeTerminalCwdTrusted={isVisible ? live.activeTerminalCwdTrusted : false}
|
||||
sftpFollowTerminalCwd={sftpFollowTerminalCwd}
|
||||
onSftpFollowTerminalCwdChange={handleFollowTerminalCwdChange}
|
||||
onRequestTerminalFocus={refocusActiveTerminalSession}
|
||||
|
||||
@@ -213,6 +213,7 @@ const SIDE_PANEL_LIVE_CTX_KEYS = [
|
||||
'sftpActiveHost',
|
||||
'activeTerminalSessionIdForSftp',
|
||||
'activeTerminalCwd',
|
||||
'activeTerminalCwdTrusted',
|
||||
'activeWorkspace',
|
||||
'focusedFontFamilyId',
|
||||
'focusedFontFamilyOverridden',
|
||||
|
||||
@@ -65,6 +65,12 @@ const createSessionRuntimeState = () => ({
|
||||
["live", "/live"],
|
||||
]),
|
||||
},
|
||||
terminalRendererCwdSourceBySessionRef: {
|
||||
current: new Map([
|
||||
["closed", "backend" as const],
|
||||
["live", "osc7" as const],
|
||||
]),
|
||||
},
|
||||
terminalOsc7SignalBySessionRef: {
|
||||
current: new Map([
|
||||
["closed", 4],
|
||||
@@ -95,6 +101,7 @@ test("closing a terminal session cancels its probe and deletes only its runtime
|
||||
assert.equal(liveCancelCount, 0);
|
||||
for (const runtimeMap of [
|
||||
state.terminalRendererCwdBySessionRef.current,
|
||||
state.terminalRendererCwdSourceBySessionRef.current,
|
||||
state.terminalOsc7SignalBySessionRef.current,
|
||||
state.cwdProbeGenerationRef.current,
|
||||
state.cwdProbeCancelersRef.current,
|
||||
@@ -123,6 +130,7 @@ test("session pruning preserves reconnecting sessions and unmount cleanup remove
|
||||
assert.equal(liveCancelCount, 1);
|
||||
for (const runtimeMap of [
|
||||
state.terminalRendererCwdBySessionRef.current,
|
||||
state.terminalRendererCwdSourceBySessionRef.current,
|
||||
state.terminalOsc7SignalBySessionRef.current,
|
||||
state.cwdProbeGenerationRef.current,
|
||||
state.cwdProbeCancelersRef.current,
|
||||
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
import { AI_PANEL_FORCE_HIDE_SHELL } from '../ai/aiPanelDiagnostics';
|
||||
import { toast } from '../ui/toast';
|
||||
import { getTerminalSidePanelShellWidth } from './TerminalLayerSidePanelSection';
|
||||
import type { RendererCwdSource } from '../terminal/sftpCwd';
|
||||
|
||||
type TerminalLayerEffectsContext = Record<string, any> & {
|
||||
sftpPaneClosedTabIdsRef: MutableRefObject<Set<string>>;
|
||||
@@ -33,6 +34,7 @@ type RuntimeStateRef<T> = { current: Map<string, T> };
|
||||
|
||||
export type TerminalSessionRuntimeState = {
|
||||
terminalRendererCwdBySessionRef: RuntimeStateRef<string>;
|
||||
terminalRendererCwdSourceBySessionRef?: RuntimeStateRef<RendererCwdSource>;
|
||||
terminalOsc7SignalBySessionRef: RuntimeStateRef<number>;
|
||||
cwdProbeGenerationRef: RuntimeStateRef<number>;
|
||||
cwdProbeCancelersRef: RuntimeStateRef<() => void>;
|
||||
@@ -48,6 +50,7 @@ export function clearTerminalSessionRuntimeState(
|
||||
state.cwdProbeGenerationRef.current.delete(sessionId);
|
||||
state.terminalOsc7SignalBySessionRef.current.delete(sessionId);
|
||||
state.terminalRendererCwdBySessionRef.current.delete(sessionId);
|
||||
state.terminalRendererCwdSourceBySessionRef?.current.delete(sessionId);
|
||||
|
||||
// Keep terminalCwdStore in sync so SFTP follow does not reuse a closed session path.
|
||||
terminalCwdStore.setCwd(sessionId, null);
|
||||
@@ -67,6 +70,7 @@ export function pruneTerminalSessionRuntimeState(
|
||||
): void {
|
||||
const trackedSessionIds = new Set<string>([
|
||||
...state.terminalRendererCwdBySessionRef.current.keys(),
|
||||
...(state.terminalRendererCwdSourceBySessionRef?.current.keys() ?? []),
|
||||
...state.terminalOsc7SignalBySessionRef.current.keys(),
|
||||
...state.cwdProbeGenerationRef.current.keys(),
|
||||
...state.cwdProbeCancelersRef.current.keys(),
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
# Issue #2974: Shift+Enter 的终端输入链路与开源 TUI 对照
|
||||
|
||||
> 研究日期:2026-09-02
|
||||
> 范围:Netcatty PR #3247、Kitty keyboard protocol、Windows ConPTY/win32-input-mode、xterm.js,以及 Codex、Grok Build、OpenCode、Gemini CLI、Qwen Code 当前开源源码。所有外部结论只使用官方规范、官方仓库与项目自身源码。
|
||||
|
||||
## 结论
|
||||
|
||||
1. **本轮研究开始时的 PR 头 `f2ec2a4` 不是 #2974 的彻底修复。** 它修正了一个必要的兼容性回退:不能把“进入全屏/备用屏幕”误当成 TUI 已协商 Kitty 键盘协议,否则 Grok 一类未协商的程序会把 `CSI 13;2u` 显示成字面文本。PR 改为只有协商成功时才发送 CSI-u,否则继续发送配置的换行文本。这个改动能消除 `[13;2u` 泄漏,但在报告者的本机 Windows PowerShell → Codex 路径上仍会丢失 Shift,因此旧头单独合并不能关闭 issue。[PR #3247 说明](https://github.com/binaricat/Netcatty/pull/3247);[旧 PR 头部的回退分支](https://github.com/binaricat/Netcatty/blob/f2ec2a4842dc4e1fc50b489df45f77bff79eb9d8/components/terminal/runtime/createXTermRuntime.ts#L1974-L2015)
|
||||
2. **报告者原始路径的根因在 Windows ConPTY 输入层,不是 Codex 热键本身。** ConPTY 启动时会主动发送 `CSI ? 9001 h`,要求前端用无损的 Win32 `INPUT_RECORD` 格式回送键盘事件;xterm.js 已实现这个模式和 Shift+Enter,但该能力默认关闭。Netcatty 在补丁前的依赖已经包含实现,却没有开启它,所以 Enter、Shift+Enter、Ctrl+Enter 在到达 PowerShell/Codex 前已经被旧式输入编码折叠。[ConPTY 当前启动代码](https://github.com/microsoft/terminal/blob/62711fd73ef9733fa83108bfa7cea22528d2dbb9/src/host/VtIo.cpp#L196-L204);[xterm.js 开关默认值](https://github.com/xtermjs/xterm.js/blob/c58ea3637f3968e0e6e79cd92cf9aace7ef89ee2/typings/xterm.d.ts#L485-L492);[Netcatty 的 xterm.js 版本](https://github.com/binaricat/Netcatty/blob/f2ec2a4842dc4e1fc50b489df45f77bff79eb9d8/package.json#L103-L110)
|
||||
3. **覆盖报告者复现的最小正确修法**是:仅对 Netcatty 本机 Windows 的 ConPTY 终端开启 xterm.js `vtExtensions.win32InputMode`,并在 `term.modes.win32InputMode` 生效时让 Netcatty 自己的 Shift+Enter 文本回退和 Kitty 编码让路。这样浏览器中的 Shift 状态会被 ConPTY 重建成原生键盘记录,Codex 和 Grok 都能从各自现有的 Windows 输入栈读到真正的 Shift,无需按应用名判断,也无需强塞 CSI-u。
|
||||
4. **本次不需要改 Codex 或 Grok。** 两者已经把 Shift+Enter 绑定为换行;Codex 在 Windows 明确选择原生 `INPUT_RECORD` 模式,Grok 所用的 crossterm 也会把 Win32 `SHIFT_PRESSED` 解成 Shift。缺失的是 Netcatty/xterm.js 到 ConPTY 的无损传输。[Codex Windows 模式](https://github.com/openai/codex/blob/eb10d91e48ccbd0930427461fb392337addb1ac0/codex-rs/tui/src/tui/windows_console.rs#L1-L17);[Codex 换行键](https://github.com/openai/codex/blob/eb10d91e48ccbd0930427461fb392337addb1ac0/codex-rs/tui/src/keymap.rs#L1462-L1479);[Grok 换行处理](https://github.com/xai-org/grok-build/blob/72a61251fcffb464bcc687aeb5a998e5a98ec0c9/crates/codegen/xai-grok-pager-render/src/input/terminal_support.rs#L45-L55)
|
||||
5. **关闭 issue 的边界**:把上述 ConPTY 补丁纳入 PR 后,代码因果链和字节级测试足以证明修法正确;但最好仍用真实 Windows 跑一次 Netcatty 本机 PowerShell → Codex/Grok。远端 Windows SSH 不是报告者原始复现,不能据此阻止本 issue 关闭;它应作为后续兼容范围单列。
|
||||
|
||||
## 1. 报告者说的 Grok 是哪个项目
|
||||
|
||||
这里不是根据同名项目猜测。Netcatty 自己把命令 `grok` 登记为 **Grok Build**,描述为 xAI 的 coding agent CLI:[agent discovery](https://github.com/binaricat/Netcatty/blob/f2ec2a4842dc4e1fc50b489df45f77bff79eb9d8/electron/bridges/aiBridge/agentDiscoveryHandlers.cjs#L98-L101) 和 [设置类型](https://github.com/binaricat/Netcatty/blob/f2ec2a4842dc4e1fc50b489df45f77bff79eb9d8/components/settings/tabs/ai/types.ts#L221-L231) 都指向同一产品。[xAI 官方公告](https://x.ai/news/grok-build-open-source)称其开放的是 Grok Build 的 agent 与 TUI 源码,[官方仓库 README](https://github.com/xai-org/grok-build/blob/72a61251fcffb464bcc687aeb5a998e5a98ec0c9/README.md#L10-L17) 也明确该仓库包含 `grok` CLI/TUI。因此本报告审查的是 `xai-org/grok-build`,不是其他同名 Grok CLI。
|
||||
|
||||
## 2. 为什么这个问题容易被“修坏”
|
||||
|
||||
旧式终端协议没有“键盘对象”,只有字节流。Kitty 官方规范的旧式编码表明确写出:普通 Enter、Ctrl+Enter、Shift+Enter、Ctrl+Shift+Enter 都是同一个 `0x0d`;只有 Alt 会多一个 ESC。[官方规范旧式 C0 表](https://github.com/kovidgoyal/kitty/blob/0dbbedfb5e316dfa23ce61fd23e6ea7e3e91d88b/docs/keyboard-protocol.rst#L523-L534)
|
||||
|
||||
因此一旦浏览器按键在终端模拟器中被编码成 CR/LF,后面的 PowerShell、Codex 或 Grok 无法反推它原来是否带 Shift。发送 `\n` 只是“让某些输入框换行”的内容映射,不是传递 Shift+Enter;反过来,无条件发送 `CSI 13;2u` 也不对,因为不理解 CSI-u 的程序会把它当作普通输入。报告者先看到修饰键全部变成 `Mods=0`,后来又在 Grok 看到 `[13;2u`,正好对应这两个失败方向:[原始按键记录](https://github.com/binaricat/Netcatty/issues/2974#issuecomment-5289958145);[最新补充](https://github.com/binaricat/Netcatty/issues/2974#issuecomment-5506053970)。
|
||||
|
||||
Kitty 协议的正确模型是**应用协商**:应用先查询 `CSI ? u`,终端回复当前 flags;应用再 push 所需 flags,主屏与备用屏各有独立栈。[查询、push/pop 与双屏栈](https://github.com/kovidgoyal/kitty/blob/0dbbedfb5e316dfa23ce61fd23e6ea7e3e91d88b/docs/keyboard-protocol.rst#L285-L334) 应用应把 Kitty 查询和 DA 查询一起发;若只收到 DA,则判定终端不支持。[检测规则](https://github.com/kovidgoyal/kitty/blob/0dbbedfb5e316dfa23ce61fd23e6ea7e3e91d88b/docs/keyboard-protocol.rst#L437-L456) Shift 的修饰值为 `1 + 1 = 2`,所以 Shift+Enter 才是 `CSI 13;2u`。[修饰值编码](https://github.com/kovidgoyal/kitty/blob/0dbbedfb5e316dfa23ce61fd23e6ea7e3e91d88b/docs/keyboard-protocol.rst#L182-L210)
|
||||
|
||||
这也说明 PR #3247 删除“只要进入备用屏就发送 CSI-u”的判断是对的:备用屏只决定协议状态栈存在哪里,不代表应用支持或已经启用协议。
|
||||
|
||||
## 3. Windows 上还有一条更直接的标准路径
|
||||
|
||||
Windows ConPTY 不是普通 Unix PTY。Microsoft 的规范专门把 Shift+Enter 列为没有唯一 VT 编码、需要保真的按键,并定义了 `win32-input-mode`:[设计目标与 Shift+Enter](https://github.com/microsoft/terminal/blob/62711fd73ef9733fa83108bfa7cea22528d2dbb9/doc/specs/%234999%20-%20Improved%20keyboard%20handling%20in%20Conpty.md#L40-L69)。
|
||||
|
||||
链路如下:
|
||||
|
||||
```text
|
||||
浏览器 KeyboardEvent
|
||||
→ xterm.js(收到 ConPTY 的 CSI ?9001h 后切换)
|
||||
→ CSI Vk;Sc;Uc;Kd;Cs;Rc _
|
||||
→ ConPTY 重建 INPUT_RECORD
|
||||
→ PowerShell / Codex / Grok 读取原生键盘记录
|
||||
```
|
||||
|
||||
官方格式包含虚拟键码、扫描码、Unicode 字符、按下/抬起、修饰键状态和重复次数,因此不是只为 Shift+Enter 打补丁,而是完整传递键盘记录。[请求和格式](https://github.com/microsoft/terminal/blob/62711fd73ef9733fa83108bfa7cea22528d2dbb9/doc/specs/%234999%20-%20Improved%20keyboard%20handling%20in%20Conpty.md#L100-L179) ConPTY 会主动向承载它的终端请求该模式,不支持的终端忽略即可。[ConPTY 场景说明](https://github.com/microsoft/terminal/blob/62711fd73ef9733fa83108bfa7cea22528d2dbb9/doc/specs/%234999%20-%20Improved%20keyboard%20handling%20in%20Conpty.md#L278-L295)
|
||||
|
||||
xterm.js PR [#5603](https://github.com/xtermjs/xterm.js/pull/5603) 已于 2026-01-10 合并该能力。当前实现只在选项允许时接受 `DECSET 9001`,[模式开关](https://github.com/xtermjs/xterm.js/blob/c58ea3637f3968e0e6e79cd92cf9aace7ef89ee2/src/common/InputHandler.ts#L2043-L2047);模式激活后优先于 Kitty 和旧式编码,[键盘分派](https://github.com/xtermjs/xterm.js/blob/c58ea3637f3968e0e6e79cd92cf9aace7ef89ee2/src/browser/services/KeyboardService.ts#L36-L65);它把 Shift 记录为 Win32 flag 16,并输出完整序列。[编码实现](https://github.com/xtermjs/xterm.js/blob/c58ea3637f3968e0e6e79cd92cf9aace7ef89ee2/src/common/input/Win32InputMode.ts#L20-L33) [输出格式](https://github.com/xtermjs/xterm.js/blob/c58ea3637f3968e0e6e79cd92cf9aace7ef89ee2/src/common/input/Win32InputMode.ts#L275-L296) 上游测试还直接覆盖了 Shift+Enter 与 Ctrl+Enter 的区别。[xterm.js 测试](https://github.com/xtermjs/xterm.js/blob/c58ea3637f3968e0e6e79cd92cf9aace7ef89ee2/src/common/input/Win32InputMode.test.ts#L176-L197)
|
||||
|
||||
Netcatty 使用的 `@xterm/xterm 6.1.0-beta.292` 已带这个接口,但默认 `false`。VS Code 的当前源码也把它接到 xterm.js,却仍标为 restricted、experimental、advanced 且默认关闭:[VS Code 配置](https://github.com/microsoft/vscode/blob/0eb8ec268728142ee7665e7e07cf6bdf9379cc7e/src/vs/workbench/contrib/terminal/common/terminalConfiguration.ts#L592-L607) [传给 xterm.js](https://github.com/microsoft/vscode/blob/0eb8ec268728142ee7665e7e07cf6bdf9379cc7e/src/vs/workbench/contrib/terminal/browser/xterm/xtermTerminal.ts#L265-L278)。这为本 PR 只在已知的“本机 Windows + ConPTY”路径最小开启提供了兼容性依据,而不是把实验能力无条件扩大到所有会话。
|
||||
|
||||
## 4. 开源 TUI 的实际做法
|
||||
|
||||
| 项目 | 输入栈与协商 | Shift+Enter 处理 | 对 #2974 的含义 |
|
||||
|---|---|---|---|
|
||||
| OpenAI Codex | Rust + crossterm 0.29 fork。Unix 会按 Kitty 规范发 `CSI ?u` + DA 并解析回复;Windows 则先清除 `ENABLE_VIRTUAL_TERMINAL_INPUT`,明确选择原生 `INPUT_RECORD`。 | 默认把 Shift+Enter、Alt+Enter、Ctrl+J、Ctrl+M 绑定为插入换行。 | 报告者本机 Windows 路径不依赖 Kitty;Netcatty 应把浏览器修饰键无损交给 ConPTY。 |
|
||||
| Grok Build | Rust + crossterm 0.28。先按终端品牌做兼容性门禁,再调用 crossterm 探测;Windows 的 crossterm 探测恒为 false。Grok 明确跳过 Windows Terminal、VS Code/xterm.js 家族和无正面证据的未知终端。 | 源码已经接受 Shift+Enter/Alt+Enter;判定 Shift 不可靠时 UI 改提示 Alt+Enter。 | 强塞 Kitty 必然与 Grok 的门禁冲突;Win32 INPUT_RECORD 才是本机 Windows 的共同路径。 |
|
||||
| OpenCode | TypeScript + OpenTUI 0.4.5,启用 `useKittyKeyboard`。OpenTUI 会查询 Kitty;未用 Kitty 时尝试 `modifyOtherKeys`,并能解析 `CSI 27;2;13~`。 | 默认 Shift/Ctrl/Alt+Enter 和 Ctrl+J 都是换行。 | 说明成熟 TUI 会协商并保留多种回退,但终端侧不能替应用擅自假定协议。 |
|
||||
| Gemini CLI | TypeScript/Ink。启动时同时查询 Kitty、`modifyOtherKeys`、终端名和 DA;收到 Kitty 回复优先启用,否则只在确认 `modifyOtherKeys` level 2 后启用。 | Shift/Ctrl/Cmd/Alt+Enter 和 Ctrl+J 都是换行。 | 是“探测后启用”的直接范例,不支持无条件 CSI-u。 |
|
||||
| Qwen Code | TypeScript,Ink 与 OpenTUI 两条路径都先发 Kitty query + DA;无回复/仅 DA/超时即保持 legacy,备用屏重新 push。解析器也认识 `modifyOtherKeys`。 | Shift/Ctrl/Cmd+Enter 和 Ctrl+J 是换行。 | 同样把能力协商与备用屏状态分开,支持 PR #3247 的方向。 |
|
||||
|
||||
### Codex
|
||||
|
||||
Codex Unix 启动探针发 `CSI ?u` 和 DA,[探针发送](https://github.com/openai/codex/blob/eb10d91e48ccbd0930427461fb392337addb1ac0/codex-rs/tui/src/terminal_probe.rs#L268-L289) 并把“只收到 DA”判为不支持。[探针解析](https://github.com/openai/codex/blob/eb10d91e48ccbd0930427461fb392337addb1ac0/codex-rs/tui/src/terminal_probe.rs#L437-L499) 但 Windows 初始化先执行 `set_input_record_mode()`,[TUI 初始化](https://github.com/openai/codex/blob/eb10d91e48ccbd0930427461fb392337addb1ac0/codex-rs/tui/src/tui.rs#L227-L247);其 crossterm fork 在 Windows 上也明确让 Kitty 支持探测恒为 false。[crossterm fork](https://github.com/openai-oss-forks/crossterm/blob/45fecb9508105988f42fe6ff0441783ed3717f92/src/terminal/sys/windows.rs#L71-L77) 这与报告者“打开 Netcatty Kitty 设置仍无效”完全一致:Codex 的 Windows 路径本来就不靠 Kitty。
|
||||
|
||||
### Grok Build
|
||||
|
||||
Grok 使用 crossterm 0.28,[依赖声明](https://github.com/xai-org/grok-build/blob/72a61251fcffb464bcc687aeb5a998e5a98ec0c9/Cargo.toml#L140-L149)。它在 VS Code/xterm.js、Windows Terminal、旧 VTE、未知且无 multiplexer 的环境主动跳过 Kitty,[兼容性门禁](https://github.com/xai-org/grok-build/blob/72a61251fcffb464bcc687aeb5a998e5a98ec0c9/crates/codegen/xai-grok-pager-render/src/terminal/mod.rs#L313-L358);只有门禁通过且 crossterm 探测成功才 push flags。[启动协商](https://github.com/xai-org/grok-build/blob/72a61251fcffb464bcc687aeb5a998e5a98ec0c9/crates/codegen/xai-grok-pager/src/app/mod.rs#L1466-L1497) crossterm 0.28 在 Windows 的 `supports_keyboard_enhancement()` 恒为 false,[crossterm 0.28](https://github.com/crossterm-rs/crossterm/blob/5d50d8da62c5e034ef8b2787a771a2c0f9b3b2f9/src/terminal/sys/windows.rs#L71-L77),但它读取 Win32 控制键状态时会把 `SHIFT_PRESSED` 转成 `KeyModifiers::SHIFT`。[Windows 按键解析](https://github.com/crossterm-rs/crossterm/blob/5d50d8da62c5e034ef8b2787a771a2c0f9b3b2f9/src/event/sys/windows/parse.rs#L79-L98)
|
||||
|
||||
所以 Grok 当前源码并不要求 Netcatty“伪装成 Kitty 终端”;它要求 Windows 输入链路不要在进入 crossterm 前丢掉 Shift。Grok 已经在输入框中处理 Shift/Alt+Enter,[输入框逻辑](https://github.com/xai-org/grok-build/blob/72a61251fcffb464bcc687aeb5a998e5a98ec0c9/crates/codegen/xai-grok-pager/src/views/prompt_widget/mod.rs#L1680-L1686),并在不可区分时提示 Alt+Enter。[提示逻辑](https://github.com/xai-org/grok-build/blob/72a61251fcffb464bcc687aeb5a998e5a98ec0c9/crates/codegen/xai-grok-pager/src/views/agent.rs#L952-L973)
|
||||
|
||||
### OpenCode / OpenTUI
|
||||
|
||||
OpenCode 当前创建 OpenTUI renderer 时启用 `useKittyKeyboard`,[OpenCode TUI](https://github.com/sst/opencode/blob/69c172e8a7c0086887b1f93ed5a162f14b6aa0c5/packages/tui/src/app.tsx#L186-L206),换行键包括 Shift/Ctrl/Alt+Return 与 Ctrl+J。[按键配置](https://github.com/sst/opencode/blob/69c172e8a7c0086887b1f93ed5a162f14b6aa0c5/packages/tui/src/config/keybind.ts#L161-L165) 它的框架 OpenTUI 0.4.5 会发 `CSI ?u`,[能力查询](https://github.com/sst/opentui/blob/0c8c4f7cff2927e3df63a9757a45eff9a343611c/packages/core/src/zig/ansi.zig#L311-L336),只有检测到 Kitty 回复才 push flags;否则先尝试 `modifyOtherKeys`。[模式选择](https://github.com/sst/opentui/blob/0c8c4f7cff2927e3df63a9757a45eff9a343611c/packages/core/src/zig/terminal.zig#L362-L381) 解析器同时支持 Kitty 与 `modifyOtherKeys` 的 Shift+Enter。[解析器](https://github.com/sst/opentui/blob/0c8c4f7cff2927e3df63a9757a45eff9a343611c/packages/core/src/lib/parse.keypress.ts#L311-L339)
|
||||
|
||||
### Gemini CLI
|
||||
|
||||
Gemini 同时定义 Kitty、`modifyOtherKeys` 与 DA 查询,[查询定义](https://github.com/google-gemini/gemini-cli/blob/4963a4456a886bb6af7dcfb807ad6e3e46ce46fc/packages/cli/src/ui/utils/terminalCapabilityManager.ts#L41-L85),把 DA 当作所有查询已经处理的哨兵,[探测流程](https://github.com/google-gemini/gemini-cli/blob/4963a4456a886bb6af7dcfb807ad6e3e46ce46fc/packages/cli/src/ui/utils/terminalCapabilityManager.ts#L182-L250),并且只启用已经得到正面回复的协议。[启用决策](https://github.com/google-gemini/gemini-cli/blob/4963a4456a886bb6af7dcfb807ad6e3e46ce46fc/packages/cli/src/ui/utils/terminalCapabilityManager.ts#L258-L272) 其解析器兼容两种编码,[按键解析](https://github.com/google-gemini/gemini-cli/blob/4963a4456a886bb6af7dcfb807ad6e3e46ce46fc/packages/cli/src/ui/contexts/KeypressContext.tsx#L565-L588),并把所有常见 modified Enter 绑定为换行。[按键配置](https://github.com/google-gemini/gemini-cli/blob/4963a4456a886bb6af7dcfb807ad6e3e46ce46fc/packages/cli/src/ui/key/keyBindings.ts#L359-L371)
|
||||
|
||||
### Qwen Code
|
||||
|
||||
Qwen 的 Ink 路径按规范发 Kitty query + DA,只有收到 Kitty 回复才 push,超时则保持 legacy。[检测器](https://github.com/QwenLM/qwen-code/blob/83c4e7ea84d5c5dde9b6fb14d3a5ab4aa7afaf94/packages/cli/src/ui/utils/kittyProtocolDetector.ts#L27-L124) 它也明确处理了主屏/备用屏各自有协议栈的问题。[备用屏重新 push](https://github.com/QwenLM/qwen-code/blob/83c4e7ea84d5c5dde9b6fb14d3a5ab4aa7afaf94/packages/cli/src/ui/utils/kittyProtocolDetector.ts#L134-L151) OpenTUI 路径先做同样的 200ms 探测,无回复就不启用 Kitty。[OpenTUI 协商](https://github.com/QwenLM/qwen-code/blob/83c4e7ea84d5c5dde9b6fb14d3a5ab4aa7afaf94/packages/cli/src/ui/opentui/kitty-negotiation.ts#L76-L147) 它的按键配置已经支持 Shift/Ctrl/Cmd+Enter 和 Ctrl+J 换行。[按键配置](https://github.com/QwenLM/qwen-code/blob/83c4e7ea84d5c5dde9b6fb14d3a5ab4aa7afaf94/packages/cli/src/config/keyBindings.ts#L208-L230)
|
||||
|
||||
## 5. 为什么不能把 Windows 修法改成“ConPTY 内部统一转 Kitty”
|
||||
|
||||
Windows Terminal 已通过 PR [#19817](https://github.com/microsoft/terminal/pull/19817) 实现 Kitty keyboard protocol,但当前 ConPTY 源码明确临时禁止把 win32-input-mode 输入转成 Kitty,因为这会绕过 Windows Terminal 自己的开关。[当前 `VtIo.cpp`](https://github.com/microsoft/terminal/blob/62711fd73ef9733fa83108bfa7cea22528d2dbb9/src/host/VtIo.cpp#L125-L131) 对应的 Microsoft issue [#19847](https://github.com/microsoft/terminal/issues/19847) 截至研究时仍 open。
|
||||
|
||||
因此现在让 Netcatty 在 ConPTY 路径强发 Kitty,不仅与 Codex/Grok 的 Windows 输入模型不合,还押注了一个 Microsoft 尚未放开的转换。让 xterm.js 按 ConPTY 已经发出的 `?9001h` 请求回送 Win32 输入记录,才是当前标准定义且已落地的路径。
|
||||
|
||||
## 6. Netcatty 能独立修什么,哪些必须由 TUI 配合
|
||||
|
||||
### Netcatty 可以独立修复
|
||||
|
||||
- 本机 Windows + ConPTY:开启 xterm.js `win32InputMode` capability;实际切换仍由 ConPTY 的 `CSI ?9001h` 触发。
|
||||
- 模式生效时,不再由 Netcatty 的 Shift+Enter 文本映射或 Kitty 编码截获按键,让 xterm.js 输出 Win32 记录。
|
||||
- 保留 PR #3247 的协商门槛:未协商 Kitty 的非 ConPTY 程序不能收到强制 CSI-u。
|
||||
- 保持现有 Kitty query/set/push/pop 支持。Netcatty 当前已经处理这些序列,[协议处理器](https://github.com/binaricat/Netcatty/blob/f2ec2a4842dc4e1fc50b489df45f77bff79eb9d8/components/terminal/runtime/kittyKeyboardRuntime.ts#L58-L118),只是默认设置关闭。[默认设置](https://github.com/binaricat/Netcatty/blob/f2ec2a4842dc4e1fc50b489df45f77bff79eb9d8/domain/models/terminal.ts#L483-L488)
|
||||
|
||||
### 必须由 TUI 协商或支持
|
||||
|
||||
- 普通 Unix/VT 链路要区分 Shift+Enter,TUI 必须启用 Kitty、`modifyOtherKeys` 或其他双方都理解的增强协议。Netcatty 只能提供能力和回复查询,不能凭“它看起来像 TUI”强制发送。
|
||||
- 未协商增强协议的旧程序只能收到传统 CR/LF;Netcatty 的“Shift+Enter 发送文本”可以作为内容级兼容选项,但不能称为修饰键直传。
|
||||
- Grok 在非 Windows、xterm.js/未知品牌的 VT 路径中主动跳过 Kitty,这是 Grok 的兼容策略。若以后要让这类路径也原生支持 Shift+Enter,应由 Grok 放宽门禁或由双方增加可靠的正面能力识别;这不属于 #2974 的本机 ConPTY 复现。
|
||||
|
||||
### 暂不需要做
|
||||
|
||||
- 不做 Codex/Grok/Claude/OpenCode 应用名识别。
|
||||
- 不根据主屏/备用屏猜协议能力。
|
||||
- 不为关闭 #2974 新增 `modifyOtherKeys`。它对部分 Unix TUI 有兼容价值,但 xterm.js 当前源码没有该模式的实现,而且本机 ConPTY 已有更直接的无损路径。
|
||||
- 不把 win32-input-mode 默认扩大到所有会话。VS Code 仍把它视为实验能力;先覆盖已知本机 ConPTY 路径更稳妥。
|
||||
|
||||
## 7. PR #3247 的最终判断与关闭条件
|
||||
|
||||
### 对补入 ConPTY 修复前的 PR 头 `f2ec2a4` 的判断
|
||||
|
||||
**不能单独用它关闭 #2974。** 它是必要的安全修正,不是回退到“问题已解决前”的旧代码:它消除了错误的能力猜测和 Grok 字面 CSI-u 泄漏。但它在未协商 Kitty 时仍发送默认 `\n`,所以报告者的 Codex 仍拿不到 Shift。[默认回退文本](https://github.com/binaricat/Netcatty/blob/f2ec2a4842dc4e1fc50b489df45f77bff79eb9d8/components/terminal/runtime/shiftEnterText.ts#L52-L74) [PR 分支](https://github.com/binaricat/Netcatty/blob/f2ec2a4842dc4e1fc50b489df45f77bff79eb9d8/components/terminal/runtime/createXTermRuntime.ts#L1974-L2015)
|
||||
|
||||
### 纳入 ConPTY 补丁后的判断
|
||||
|
||||
对报告者明确的“Netcatty 本机启动 PowerShell → Codex/Grok”路径,这是针对根因的修复,不是应用级 workaround。当前工作树的字节级验证已经证明:
|
||||
|
||||
- xterm.js 默认忽略 `CSI ?9001h`;
|
||||
- 开启 capability 后,模式会激活;
|
||||
- Enter、Shift+Enter、Ctrl+Enter、Alt+Enter 分别产生不同 Win32 输入记录;
|
||||
- Shift+Enter 的控制状态为 16,Ctrl+Enter 为 8,不再互相混淆;
|
||||
- Shift+Enter 文本回退和 Netcatty Kitty 编码在 Win32 模式下让路。
|
||||
- 原始 Win32 传输内容不会被误记为命令、自动补全文本或密码输入;修饰回车和按键松开没有文本提交含义。
|
||||
- 被 Netcatty 自己消费的快捷键不会留下孤立的松键;切走焦点时会补齐已经发出的松键,避免 TUI 认为 Shift 等按键一直按住。
|
||||
- 多终端广播按每个目标实际支持的协议分别编码,且不会把输入焦点从当前终端抢走。
|
||||
|
||||
本地执行:
|
||||
|
||||
```text
|
||||
node --test --import tsx \
|
||||
components/terminal/runtime/shiftEnterText.test.ts \
|
||||
components/terminal/runtime/win32InputMode.test.ts \
|
||||
components/terminal/runtime/kittyKeyboardBroadcast.test.ts \
|
||||
components/terminal/runtime/kittyKeyboardProtocol.test.ts \
|
||||
components/terminal/runtime/terminalImeTextInput.test.ts
|
||||
|
||||
90 passed, 0 failed
|
||||
```
|
||||
|
||||
交付前的项目级验证也已通过:`npm run lint` 无错误,`npm test` 共 11,206 项(11,192 通过、0 失败、14 项环境限定跳过),`npm run build` 成功。两轮独立对抗审查均未发现 #2974 范围内仍可达的问题;弹窗迁移或远端 Windows 等额外覆盖面保留为范围外建议。
|
||||
|
||||
### 建议的关闭门槛
|
||||
|
||||
1. 把 ConPTY capability + 让路逻辑及测试提交到 PR #3247,而不是只保留当前 `f2ec2a4`。
|
||||
2. 在真实 Windows 上用本机终端复验:
|
||||
- PowerShell 按键记录:Enter=`Mods=0`、Shift+Enter=`Mods=Shift`、Ctrl+Enter=`Mods=Control`;
|
||||
- Codex:Shift+Enter 插入新行,不再被识别为 Ctrl+Enter;
|
||||
- Grok:Shift+Enter 插入新行,不出现 `[13;2u` 字面文本。
|
||||
3. 上述真实链路通过后可以关闭 #2974;发布版让报告者复测仍是最理想的最后确认,但不应把“远端 Windows SSH 尚未专门验证”混入本 issue 的原始范围。
|
||||
|
||||
## 8. 剩余不确定性
|
||||
|
||||
- 本报告的源码和 JSDOM 字节测试确认了因果链,但没有代替真实 Windows UI/ConPTY 运行验证。
|
||||
- `win32-input-mode` 在 xterm.js 与 VS Code 中仍属较新的实验能力;本机 ConPTY 的窄范围启用降低了外溢风险。
|
||||
- 若未来收到“Netcatty → SSH → 远端 Windows ConPTY”同类报告,需要单独确认 `CSI ?9001h` 是否穿过该 SSH/PTY 实现,并决定是否在已识别的远端 Windows 会话启用 capability。那是后续覆盖面,不是当前 #2974 是否修复的否定条件。
|
||||
@@ -0,0 +1,114 @@
|
||||
# SFTP responsiveness: issues #3213 and #3155
|
||||
|
||||
Investigated on 2026-08-31 against `39d7c38a6acea2f59524566d117345e1ced21fbd`.
|
||||
|
||||
## Evidence and scope
|
||||
|
||||
[#3213](https://github.com/binaricat/Netcatty/issues/3213) reports slow/unstable
|
||||
large-file transfers, ineffective pause/resume and failed recovery after killing
|
||||
the app. [#3155](https://github.com/binaricat/Netcatty/issues/3155) reports a freeze
|
||||
with many files. Neither report supplies a direction, server configuration or
|
||||
transfer log. The following are reproduced code defects, not proof that every
|
||||
reported symptom has one cause. Keep both issues open for reporter confirmation.
|
||||
|
||||
1. `globalTransferScheduler.run` scanned the entire waiting queue on every
|
||||
insertion. Enqueuing 10,000 jobs behind two active jobs took 1,237 ms and
|
||||
49,985,005 limit checks locally. Coalescing queue pumps reduces the same case
|
||||
to 10,001 checks (3-6 ms in local runs). Priority, owner fairness and per-host
|
||||
limits remain unchanged; immediately completed batches also yield to input.
|
||||
2. The transfer-center popover mounted every top-level row, even far below the
|
||||
viewport. The actual Electron component took about 1,885 ms to display 1,000
|
||||
rows. A measured, bounded viewport mounts around nine rows instead. All jobs
|
||||
remain in the store; scrolling and bucket selection still expose them.
|
||||
Folder expansion is held outside the recycled row.
|
||||
3. Remote source/prefix verification used ssh2's serial `createReadStream`,
|
||||
even though body downloads already used pipelined reads. Pause captures a
|
||||
complete identity; resume verifies it before continuing. A 128 MiB loopback
|
||||
SFTP test with delayed READ replies spent 41,493 ms in resume on the old code.
|
||||
Reuse the existing 64-request, 32 KiB, ordered SHA-256 verification helper.
|
||||
This still checks every required byte with bounded memory, cancellation and
|
||||
inactivity deadlines. It does not substitute metadata, sampling or file size
|
||||
for content verification.
|
||||
|
||||
## Correctness boundaries retained
|
||||
|
||||
- Only contiguous acknowledged ranges are checkpoints; aggregate displayed
|
||||
progress and sparse file length are not safe offsets.
|
||||
- First-run force-kill recovery still restarts at zero when no complete source
|
||||
identity was captured. Recovery persistence is unchanged: progress since the
|
||||
last lifecycle save can still be lost. This PR improves the verified resume
|
||||
path's latency, not every whole-app force-kill recovery scenario.
|
||||
- SCP and legacy fastPut paths do not gain unsupported pause/resume.
|
||||
- Source changes, staged-prefix mismatch, missing staging, cancellation,
|
||||
replacement, permissions and conflict handling retain their existing checks.
|
||||
- No claim is made about arbitrary server power-loss durability, every Windows
|
||||
server, or a universal throughput multiplier.
|
||||
|
||||
## Mature-client comparison
|
||||
|
||||
- **FileZilla:** official SVN revision 11556 consumes directory results in
|
||||
batches and posts a continuation to the UI loop; its queue fills available
|
||||
transfer slots under total/directional/site limits. Adopt cooperative work
|
||||
and bounded admission, not an unrestricted `Promise.all` or a larger packet.
|
||||
Sources: [directory consumption, lines 69-129](https://svn.filezilla-project.org/svn/!svn/bc/11556/FileZilla3/trunk/src/interface/local_recursive_operation.cpp),
|
||||
[queue admission, lines 544-632 and 2452-2493](https://svn.filezilla-project.org/svn/!svn/bc/11556/FileZilla3/trunk/src/interface/QueueView.cpp).
|
||||
- **OpenSSH:** 32 KiB requests and a 64-request default window; interruption
|
||||
stops new requests, drains replies and tracks the contiguous acknowledged
|
||||
prefix separately from the highest acknowledged position. Its manual warns
|
||||
that mismatched partial content can corrupt a resumed file. Netcatty keeps
|
||||
its stronger content checks. Sources: [defaults and transfer loops](https://github.com/openssh/openssh-portable/blob/0ef0f5a839831c213f24e3f2ae434765c607fb50/sftp-client.c#L59-L63),
|
||||
[resume warning](https://man.openbsd.org/sftp.1).
|
||||
- **WinSCP:** two simultaneous background operations is also its default;
|
||||
eligible transfers use temporary files before publication. These choices do
|
||||
not establish that two files is Netcatty's bottleneck, or that every killed
|
||||
process can safely resume. Sources: [background queue](https://winscp.net/eng/docs/transfer_queue),
|
||||
[resume requirements and temporary-file tradeoffs](https://winscp.net/eng/docs/resume).
|
||||
- **rclone:** documents the same 32 KiB/64-request defaults, server compatibility
|
||||
concerns and possible deadlock when checks/transfers compete for a capped
|
||||
connection pool. Future adaptive windows or a server compatibility matrix
|
||||
should be separate measured work. Source: [SFTP documentation](https://rclone.org/sftp/).
|
||||
|
||||
## Reproduction and verification
|
||||
|
||||
The regression tests exercise scheduler admission/order/yielding, complete remote prefix
|
||||
verification, and actual transfer-center DOM bounds/scrolling. Each new defect
|
||||
was observed failing before its fix.
|
||||
|
||||
The opt-in real SSH/SFTP fixture listens only on loopback, uses generated test
|
||||
credentials and isolates all temp/home state. It creates a saved prefix, checks
|
||||
resume and (for sufficiently long transfers) live pause/resume, then checks the
|
||||
complete output SHA-256. It is not a simulation of a whole-app force-kill.
|
||||
|
||||
```sh
|
||||
NETCATTY_SFTP_LIVE=1 SFTP_LIVE_MIB=128 SFTP_LIVE_FILES=12 \
|
||||
node scripts/sftp-transfer-resume.live.test.cjs
|
||||
NETCATTY_SFTP_LIVE=1 SFTP_LIVE_MIB=128 \
|
||||
SFTP_LIVE_BASELINE_REF=39d7c38a6 \
|
||||
node scripts/sftp-transfer-resume.live.test.cjs
|
||||
```
|
||||
|
||||
Twelve 128 MiB files completed with matching hashes, two submitted at a time.
|
||||
Each exercised pause/resume: pause acknowledgements took 6-15 ms; resume took
|
||||
about 1.45-3.18 seconds in this run. These are fixture-specific observations,
|
||||
not performance promises. Electron verification also exercised the production
|
||||
popover, pause-all/resume-all, scrolling to the last file, bucket switching and
|
||||
20,000-job admission while the popover was open.
|
||||
|
||||
## Review follow-up
|
||||
|
||||
- Preserve serial prefix verification when a server rejects range OPEN/READ
|
||||
with an ordinary protocol error. Cancellation and request timeouts do not
|
||||
fall back; timed-out channels are abandoned before another attempt.
|
||||
- Count every positive short READ as inactivity-watchdog activity, without
|
||||
changing ordered full-range hashing. Finished windows cannot publish late
|
||||
progress, rearm their watchdog or issue another partial READ.
|
||||
- Allocate the live fixture inside the managed temp directory, then isolate
|
||||
its own staging/home state. Cleanup removes only that unique fixture child,
|
||||
including when fixture setup fails.
|
||||
- Keep transfer-row separators based on the actual list position, not the
|
||||
temporary viewport wrappers. The final list row alone omits its separator.
|
||||
- Remove the proposed periodic full-history save: with 20,000 queued files,
|
||||
serialization alone occupied about 80 ms every five seconds. A compact journal
|
||||
would additionally need cross-window ownership and retry-attempt coordination.
|
||||
Those recovery semantics are not changed in this focused responsiveness PR.
|
||||
There is no new persistence timer, storage key or schema migration.
|
||||
@@ -26,6 +26,8 @@ export interface SftpConnection {
|
||||
homeDir?: string;
|
||||
/** True when this SFTP connection reuses an existing terminal SSH session */
|
||||
reusedConnection?: boolean;
|
||||
/** Terminal session whose confirmed SSH transport backs this connection. */
|
||||
sourceSessionId?: string;
|
||||
fileProtocol?: 'auto' | 'sftp' | 'scp';
|
||||
}
|
||||
|
||||
|
||||
@@ -810,6 +810,14 @@ export const SETTINGS_SEARCH_CATALOG: readonly SettingsSearchEntry[] = [
|
||||
descriptionKey: "settings.update.autoUpdateEnabledDesc",
|
||||
sectionKey: "settings.update.title",
|
||||
},
|
||||
{
|
||||
id: "system-auto-launch",
|
||||
tab: "system",
|
||||
labelKey: "settings.autoLaunch.enabled",
|
||||
descriptionKey: "settings.autoLaunch.enabledDesc",
|
||||
sectionKey: "settings.autoLaunch.title",
|
||||
keywords: ["startup", "login", "autostart", "boot", "开机", "自启动", "开机启动"],
|
||||
},
|
||||
{
|
||||
id: "system-network-proxy-mode",
|
||||
tab: "system",
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import type { Host } from "./models";
|
||||
import {
|
||||
canElevateSftpForTerminalDrop,
|
||||
hasUsableSftpSudoPassword,
|
||||
normalizePosixAbsolutePath,
|
||||
posixPathNeedsLoginUserElevation,
|
||||
resolveTerminalDropSftpHost,
|
||||
TerminalDropNeedsSudoError,
|
||||
} from "./sftpDropElevation";
|
||||
|
||||
const host = {
|
||||
id: "host-1",
|
||||
label: "Host",
|
||||
hostname: "example.com",
|
||||
port: 22,
|
||||
username: "alice",
|
||||
protocol: "ssh",
|
||||
} as Host;
|
||||
|
||||
const encryptedPassword = (() => {
|
||||
const blob = Buffer.alloc(19, 0);
|
||||
Buffer.from("v10", "utf8").copy(blob, 0);
|
||||
return `enc:v1:${blob.toString("base64")}`;
|
||||
})();
|
||||
|
||||
test("normalizePosixAbsolutePath collapses slashes and trailing separators", () => {
|
||||
assert.equal(normalizePosixAbsolutePath("/root/"), "/root");
|
||||
assert.equal(normalizePosixAbsolutePath("/root//bin/"), "/root/bin");
|
||||
assert.equal(normalizePosixAbsolutePath("/"), "/");
|
||||
assert.equal(normalizePosixAbsolutePath("root"), null);
|
||||
assert.equal(normalizePosixAbsolutePath(" "), null);
|
||||
});
|
||||
|
||||
test("posixPathNeedsLoginUserElevation only flags /root for non-root logins", () => {
|
||||
assert.equal(posixPathNeedsLoginUserElevation("/root", "alice"), true);
|
||||
assert.equal(posixPathNeedsLoginUserElevation("/root/bin", "alice"), true);
|
||||
assert.equal(posixPathNeedsLoginUserElevation("/root/", "alice"), true);
|
||||
assert.equal(posixPathNeedsLoginUserElevation("/home/alice", "alice"), false);
|
||||
assert.equal(posixPathNeedsLoginUserElevation("/tmp", "alice"), false);
|
||||
assert.equal(posixPathNeedsLoginUserElevation("/root", "root"), false);
|
||||
assert.equal(posixPathNeedsLoginUserElevation("/root", " "), false);
|
||||
assert.equal(posixPathNeedsLoginUserElevation("/root", undefined), false);
|
||||
});
|
||||
|
||||
test("hasUsableSftpSudoPassword ignores missing and encrypted placeholders", () => {
|
||||
assert.equal(hasUsableSftpSudoPassword("secret"), true);
|
||||
assert.equal(hasUsableSftpSudoPassword(""), false);
|
||||
assert.equal(hasUsableSftpSudoPassword(undefined), false);
|
||||
assert.equal(hasUsableSftpSudoPassword(encryptedPassword), false);
|
||||
});
|
||||
|
||||
test("canElevateSftpForTerminalDrop rejects SCP and missing passwords", () => {
|
||||
assert.equal(canElevateSftpForTerminalDrop({ sftpSudo: true }), true);
|
||||
assert.equal(canElevateSftpForTerminalDrop({}, "secret"), true);
|
||||
assert.equal(canElevateSftpForTerminalDrop({ sftpFileProtocol: "scp" }, "secret"), false);
|
||||
assert.equal(canElevateSftpForTerminalDrop({}), false);
|
||||
});
|
||||
|
||||
test("resolveTerminalDropSftpHost clones sudo only for unelevated /root drops", () => {
|
||||
const elevated = resolveTerminalDropSftpHost(host, "/root", { password: "secret" });
|
||||
assert.equal(elevated.sftpSudo, true);
|
||||
assert.notEqual(elevated, host);
|
||||
|
||||
const alreadySudo = { ...host, sftpSudo: true };
|
||||
assert.equal(resolveTerminalDropSftpHost(alreadySudo, "/root"), alreadySudo);
|
||||
|
||||
assert.equal(resolveTerminalDropSftpHost(host, "/home/alice", { password: "secret" }), host);
|
||||
});
|
||||
|
||||
test("resolveTerminalDropSftpHost uses a resolved identity password when host.password is empty", () => {
|
||||
const identityHost = { ...host, identityId: "id-1", password: undefined };
|
||||
const elevated = resolveTerminalDropSftpHost(identityHost, "/root", { password: "identity-secret" });
|
||||
assert.equal(elevated.sftpSudo, true);
|
||||
assert.equal(elevated.password, undefined);
|
||||
assert.equal(elevated.identityId, "id-1");
|
||||
});
|
||||
|
||||
test("resolveTerminalDropSftpHost uses the resolved identity username over a stale host username", () => {
|
||||
const staleRootHost = { ...host, username: "root" };
|
||||
const elevated = resolveTerminalDropSftpHost(staleRootHost, "/root", {
|
||||
password: "secret",
|
||||
username: "alice",
|
||||
});
|
||||
assert.equal(elevated.sftpSudo, true);
|
||||
|
||||
const staleAliceHost = { ...host, username: "alice" };
|
||||
assert.equal(
|
||||
resolveTerminalDropSftpHost(staleAliceHost, "/root", {
|
||||
password: "secret",
|
||||
username: "root",
|
||||
}),
|
||||
staleAliceHost,
|
||||
);
|
||||
});
|
||||
|
||||
test("resolveTerminalDropSftpHost asks the user to enable sudo when no password is saved", () => {
|
||||
assert.throws(
|
||||
() => resolveTerminalDropSftpHost(host, "/root"),
|
||||
TerminalDropNeedsSudoError,
|
||||
);
|
||||
assert.throws(
|
||||
() => resolveTerminalDropSftpHost({ ...host, sftpFileProtocol: "scp" }, "/root", { password: "secret" }),
|
||||
TerminalDropNeedsSudoError,
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,61 @@
|
||||
import type { Host } from "./models";
|
||||
import { sanitizeCredentialValue } from "./credentials";
|
||||
|
||||
/** Thrown when a drop lands in `/root` but the host has no usable sudo password. */
|
||||
export class TerminalDropNeedsSudoError extends Error {
|
||||
constructor() {
|
||||
super("Terminal drop needs saved sudo elevation");
|
||||
this.name = "TerminalDropNeedsSudoError";
|
||||
}
|
||||
}
|
||||
|
||||
export function normalizePosixAbsolutePath(
|
||||
cwd: string | null | undefined,
|
||||
): string | null {
|
||||
if (typeof cwd !== "string") return null;
|
||||
const trimmed = cwd.trim();
|
||||
if (!trimmed.startsWith("/")) return null;
|
||||
const collapsed = trimmed.replace(/\/+/g, "/");
|
||||
if (collapsed === "/") return "/";
|
||||
return collapsed.replace(/\/+$/, "");
|
||||
}
|
||||
|
||||
export function posixPathNeedsLoginUserElevation(
|
||||
cwd: string | null | undefined,
|
||||
username: string | null | undefined,
|
||||
): boolean {
|
||||
const user = username?.trim() ?? "";
|
||||
if (!user || user === "root") return false;
|
||||
const path = normalizePosixAbsolutePath(cwd);
|
||||
if (!path) return false;
|
||||
return path === "/root" || path.startsWith("/root/");
|
||||
}
|
||||
|
||||
export function hasUsableSftpSudoPassword(
|
||||
password: string | undefined,
|
||||
): boolean {
|
||||
const value = sanitizeCredentialValue(password);
|
||||
return typeof value === "string" && value.length > 0;
|
||||
}
|
||||
|
||||
export function canElevateSftpForTerminalDrop(
|
||||
host: Pick<Host, "sftpSudo" | "sftpFileProtocol">,
|
||||
resolvedPassword?: string,
|
||||
): boolean {
|
||||
if (host.sftpFileProtocol === "scp") return false;
|
||||
if (host.sftpSudo) return true;
|
||||
return hasUsableSftpSudoPassword(resolvedPassword);
|
||||
}
|
||||
|
||||
export function resolveTerminalDropSftpHost<T extends Host>(
|
||||
host: T,
|
||||
cwd: string,
|
||||
resolved?: { password?: string; username?: string },
|
||||
): T {
|
||||
if (!posixPathNeedsLoginUserElevation(cwd, resolved?.username ?? host.username)) return host;
|
||||
if (host.sftpSudo) return host;
|
||||
if (!canElevateSftpForTerminalDrop(host, resolved?.password)) {
|
||||
throw new TerminalDropNeedsSudoError();
|
||||
}
|
||||
return { ...host, sftpSudo: true };
|
||||
}
|
||||
+204
-86
@@ -1,14 +1,17 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import {
|
||||
isFollowOriginStillCurrent,
|
||||
mergeLatestFollowTerminalCwdHostSetting,
|
||||
resolveHostFollowTerminalCwd,
|
||||
resolveSftpFollowTerminalCwdTargetHost,
|
||||
runInitialFollowTerminalCwdSync,
|
||||
shouldApplyFollowTerminalCwdSyncResult,
|
||||
shouldClearBlockedFollowOnReach,
|
||||
shouldFollowTerminalCwdNavigate,
|
||||
shouldInvalidateFollowBookkeepingOnCwdChange,
|
||||
shouldLatchInitialFollowInterruption,
|
||||
shouldReleaseInitialFollowSyncAttempt,
|
||||
shouldResetInitialFollowTerminalCwdSync,
|
||||
} from "./sftpFollowTerminalCwd";
|
||||
|
||||
const base = {
|
||||
@@ -21,10 +24,6 @@ const base = {
|
||||
isConnected: true,
|
||||
};
|
||||
|
||||
const readComponentSource = (relativePath: string) => (
|
||||
readFileSync(new URL(relativePath, import.meta.url), "utf8")
|
||||
);
|
||||
|
||||
test("shouldFollowTerminalCwdNavigate returns true when follow is on and paths differ", () => {
|
||||
assert.equal(shouldFollowTerminalCwdNavigate(base), true);
|
||||
});
|
||||
@@ -268,6 +267,75 @@ test("shouldApplyFollowTerminalCwdSyncResult rejects results after follow is una
|
||||
);
|
||||
});
|
||||
|
||||
test("isFollowOriginStillCurrent treats a missing origin as bound to the live focused terminal", () => {
|
||||
assert.equal(isFollowOriginStillCurrent({
|
||||
expectedOriginId: null,
|
||||
liveOriginId: null,
|
||||
}), true);
|
||||
assert.equal(isFollowOriginStillCurrent({
|
||||
expectedOriginId: null,
|
||||
liveOriginId: "mosh-b",
|
||||
}), false);
|
||||
assert.equal(isFollowOriginStillCurrent({
|
||||
expectedOriginId: "mosh-a",
|
||||
liveOriginId: "mosh-b",
|
||||
}), false);
|
||||
assert.equal(isFollowOriginStillCurrent({
|
||||
expectedOriginId: "mosh-a",
|
||||
liveOriginId: "mosh-a",
|
||||
}), true);
|
||||
});
|
||||
|
||||
test("shouldApplyFollowTerminalCwdSyncResult rejects a null origin after focus appears", () => {
|
||||
assert.equal(
|
||||
shouldApplyFollowTerminalCwdSyncResult({
|
||||
syncGeneration: 2,
|
||||
currentGeneration: 2,
|
||||
followEnabled: true,
|
||||
canFollow: true,
|
||||
expectedSessionId: null,
|
||||
liveSessionId: null,
|
||||
}),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
shouldApplyFollowTerminalCwdSyncResult({
|
||||
syncGeneration: 2,
|
||||
currentGeneration: 2,
|
||||
followEnabled: true,
|
||||
canFollow: true,
|
||||
expectedSessionId: null,
|
||||
liveSessionId: "mosh-b",
|
||||
}),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test("shouldApplyFollowTerminalCwdSyncResult rejects results after the focused session changes", () => {
|
||||
assert.equal(
|
||||
shouldApplyFollowTerminalCwdSyncResult({
|
||||
syncGeneration: 2,
|
||||
currentGeneration: 2,
|
||||
followEnabled: true,
|
||||
canFollow: true,
|
||||
expectedSessionId: "session-a",
|
||||
liveSessionId: "session-b",
|
||||
}),
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
shouldApplyFollowTerminalCwdSyncResult({
|
||||
syncGeneration: 2,
|
||||
currentGeneration: 2,
|
||||
followEnabled: true,
|
||||
canFollow: true,
|
||||
expectedSessionId: "session-a",
|
||||
liveSessionId: "session-a",
|
||||
}),
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
test("shouldApplyFollowTerminalCwdSyncResult rejects results for an old connection", () => {
|
||||
assert.equal(
|
||||
shouldApplyFollowTerminalCwdSyncResult({
|
||||
@@ -335,99 +403,149 @@ test("shouldApplyFollowTerminalCwdSyncResult allows missing live cwd when target
|
||||
);
|
||||
});
|
||||
|
||||
test("SftpSidePanel follow effect is not keyed by SFTP path changes", () => {
|
||||
const source = readComponentSource("../SftpSidePanel.tsx");
|
||||
const followEffect = source.match(
|
||||
/useEffect\(\(\) => \{\n\s+if \(!effectiveFollowTerminalCwd[\s\S]*?void syncFollowToTerminalCwd\(\);\n\s+\}, \[\n(?<deps>[\s\S]*?)\n\s+\]\);/,
|
||||
test("follow bookkeeping keeps handled state across hidden-panel null cwd transitions", () => {
|
||||
// Hidden panels receive activeTerminalCwd={null} and get the last live value
|
||||
// back on reshow: the visibility transitions may not drop handled follow
|
||||
// bookkeeping when the cwd did not actually change.
|
||||
assert.equal(
|
||||
shouldInvalidateFollowBookkeepingOnCwdChange({
|
||||
nextCwd: null,
|
||||
lastCwd: "/home/user/project",
|
||||
isVisible: false,
|
||||
}),
|
||||
false,
|
||||
);
|
||||
|
||||
assert.ok(followEffect?.groups?.deps);
|
||||
assert.match(followEffect.groups.deps, /activeTerminalCwd/);
|
||||
assert.match(followEffect.groups.deps, /connectionId/);
|
||||
assert.doesNotMatch(followEffect.groups.deps, /currentPath|connectionPath/);
|
||||
});
|
||||
|
||||
test("SftpSidePanel passes a stale-result guard into automatic follow navigation", () => {
|
||||
const source = readComponentSource("../SftpSidePanel.tsx");
|
||||
|
||||
assert.match(
|
||||
source,
|
||||
/navigateTo\("left", terminalCwd, \{\n\s+shouldApply: shouldApplyCurrentFollowSync,\n\s+\}\)/,
|
||||
assert.equal(
|
||||
shouldInvalidateFollowBookkeepingOnCwdChange({
|
||||
nextCwd: "/home/user/project",
|
||||
lastCwd: "/home/user/project",
|
||||
isVisible: false,
|
||||
}),
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
shouldInvalidateFollowBookkeepingOnCwdChange({
|
||||
nextCwd: "/home/user/project",
|
||||
lastCwd: "/home/user/project",
|
||||
isVisible: true,
|
||||
}),
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
shouldInvalidateFollowBookkeepingOnCwdChange({
|
||||
nextCwd: null,
|
||||
lastCwd: null,
|
||||
isVisible: false,
|
||||
}),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test("SftpSidePanel invalidates follow state whenever the follow toggle changes", () => {
|
||||
const source = readComponentSource("../SftpSidePanel.tsx");
|
||||
const toggleHandler = source.match(
|
||||
/const handleToggleFollowTerminalCwd = useCallback\(\(\) => \{[\s\S]*?\}, \[effectiveFollowTerminalCwd/,
|
||||
test("follow bookkeeping invalidates on a real terminal cwd change", () => {
|
||||
assert.equal(
|
||||
shouldInvalidateFollowBookkeepingOnCwdChange({
|
||||
nextCwd: "/home/user/other",
|
||||
lastCwd: "/home/user/project",
|
||||
isVisible: true,
|
||||
}),
|
||||
true,
|
||||
);
|
||||
// A cwd that changed while the panel was hidden still invalidates on reshow.
|
||||
assert.equal(
|
||||
shouldInvalidateFollowBookkeepingOnCwdChange({
|
||||
nextCwd: "/home/user/other",
|
||||
lastCwd: null,
|
||||
isVisible: true,
|
||||
}),
|
||||
true,
|
||||
);
|
||||
// A `null` while the surface is visible means the linked terminal session
|
||||
// changed (or closed) and its cwd cache is empty: in-flight follow results
|
||||
// of the previous session must be invalidated.
|
||||
assert.equal(
|
||||
shouldInvalidateFollowBookkeepingOnCwdChange({
|
||||
nextCwd: null,
|
||||
lastCwd: "/home/user/project",
|
||||
isVisible: true,
|
||||
}),
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
assert.ok(toggleHandler);
|
||||
assert.match(toggleHandler[0], /invalidateInFlightFollowSync\(\);/);
|
||||
assert.doesNotMatch(toggleHandler[0], /if \(!nextEnabled\)/);
|
||||
test("first-open sync reset re-arms on a replaced connection", () => {
|
||||
assert.equal(
|
||||
shouldResetInitialFollowTerminalCwdSync({
|
||||
isVisible: true,
|
||||
ownerPanelOpen: true,
|
||||
connectionId: "conn-2",
|
||||
trackedConnectionId: "conn-1",
|
||||
}),
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
test("first-open sync navigates from stale home to a backend-confirmed cwd", async () => {
|
||||
let handled = null;
|
||||
let blocked = null;
|
||||
let navigatedTo = null;
|
||||
const connection = { id: "conn-1", currentPath: "/home/alice", status: "connected" };
|
||||
test("first-open sync reset re-arms after a fresh open on the same connection", () => {
|
||||
assert.equal(
|
||||
shouldResetInitialFollowTerminalCwdSync({
|
||||
isVisible: false,
|
||||
ownerPanelOpen: false,
|
||||
connectionId: "conn-1",
|
||||
trackedConnectionId: "conn-1",
|
||||
}),
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
const completed = await runInitialFollowTerminalCwdSync({
|
||||
expectedConnectionId: "conn-1",
|
||||
staleTerminalCwd: "/home/alice",
|
||||
getFreshTerminalCwd: async () => "/srv/project",
|
||||
isEligible: () => true,
|
||||
getConnection: () => connection,
|
||||
navigate: async (cwd, shouldApply) => {
|
||||
assert.equal(shouldApply(), true);
|
||||
navigatedTo = cwd;
|
||||
connection.currentPath = cwd;
|
||||
return "reached";
|
||||
},
|
||||
setHandled: (value) => { handled = value; },
|
||||
setBlocked: (value) => { blocked = value; },
|
||||
});
|
||||
test("first-open sync reset survives hiding the surface while the owner panel stays open", () => {
|
||||
// Terminal tab switches / side-panel tool switches keep the panel mounted
|
||||
// and open: the user's browsed directory and filename filter must survive.
|
||||
assert.equal(
|
||||
shouldResetInitialFollowTerminalCwdSync({
|
||||
isVisible: false,
|
||||
ownerPanelOpen: true,
|
||||
connectionId: "conn-1",
|
||||
trackedConnectionId: "conn-1",
|
||||
}),
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
shouldResetInitialFollowTerminalCwdSync({
|
||||
isVisible: true,
|
||||
ownerPanelOpen: true,
|
||||
connectionId: "conn-1",
|
||||
trackedConnectionId: "conn-1",
|
||||
}),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
assert.equal(completed, true);
|
||||
assert.equal(navigatedTo, "/srv/project");
|
||||
assert.deepEqual(handled, { connectionId: "conn-1", terminalCwd: "/home/alice" });
|
||||
assert.equal(blocked, null);
|
||||
test("first-open sync attempt is released while visible or after the owner panel closed", () => {
|
||||
assert.equal(shouldReleaseInitialFollowSyncAttempt({ isVisible: true, ownerPanelOpen: true }), true);
|
||||
assert.equal(shouldReleaseInitialFollowSyncAttempt({ isVisible: true, ownerPanelOpen: false }), true);
|
||||
assert.equal(shouldReleaseInitialFollowSyncAttempt({ isVisible: false, ownerPanelOpen: false }), true);
|
||||
});
|
||||
|
||||
test("first-open sync can retry a failed probe and cancels stale results", async () => {
|
||||
let attempts = 0;
|
||||
let eligible = true;
|
||||
let navigations = 0;
|
||||
const connection = { id: "conn-1", currentPath: "/home/alice", status: "connected" };
|
||||
const run = () => runInitialFollowTerminalCwdSync({
|
||||
expectedConnectionId: "conn-1",
|
||||
staleTerminalCwd: "/home/alice",
|
||||
getFreshTerminalCwd: async () => (++attempts === 1 ? null : "/srv/project"),
|
||||
isEligible: () => eligible,
|
||||
getConnection: () => connection,
|
||||
navigate: async () => { navigations += 1; return "reached"; },
|
||||
setHandled: () => {},
|
||||
setBlocked: () => {},
|
||||
});
|
||||
|
||||
assert.equal(await run(), false);
|
||||
assert.equal(await run(), true);
|
||||
assert.equal(attempts, 2);
|
||||
assert.equal(navigations, 1);
|
||||
|
||||
eligible = false;
|
||||
assert.equal(await run(), false);
|
||||
assert.equal(navigations, 1);
|
||||
test("first-open sync attempt stays consumed while hidden with the owner panel open", () => {
|
||||
// Terminal tab switch while the fresh-CWD probe is still pending: the
|
||||
// interrupted attempt must not re-arm, or returning to the tab re-runs the
|
||||
// first-open sync and navigates away from the browsed directory.
|
||||
assert.equal(shouldReleaseInitialFollowSyncAttempt({ isVisible: false, ownerPanelOpen: true }), false);
|
||||
});
|
||||
|
||||
test("SftpSidePanel bounds first-open retries and disables cached fallback", () => {
|
||||
const source = readComponentSource("../SftpSidePanel.tsx");
|
||||
|
||||
assert.match(
|
||||
source,
|
||||
/preferFreshBackend: true,\n\s+allowRendererFallback: false/,
|
||||
test("first-open probe is latched as interrupted on a hide with the owner panel open", () => {
|
||||
// Terminal-tab switch while the fresh-CWD probe is pending: the probe must be
|
||||
// latched as interrupted so it cannot navigate once the tab is reshown.
|
||||
assert.equal(
|
||||
shouldLatchInitialFollowInterruption({ isVisible: false, ownerPanelOpen: true }),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
shouldLatchInitialFollowInterruption({ isVisible: true, ownerPanelOpen: true }),
|
||||
false,
|
||||
);
|
||||
// A panel close is not an interruption latch: the reset guard re-arms there.
|
||||
assert.equal(
|
||||
shouldLatchInitialFollowInterruption({ isVisible: false, ownerPanelOpen: false }),
|
||||
false,
|
||||
);
|
||||
assert.match(source, /initialFollowRetryRef\.current\.attempts >= 3/);
|
||||
assert.match(source, /setInitialFollowRetryNonce\(\(value\) => value \+ 1\)/);
|
||||
});
|
||||
@@ -0,0 +1,270 @@
|
||||
/** Pure state and decision helpers for keeping an SFTP pane aligned with a terminal. */
|
||||
export type SftpFollowTerminalCwdBlock = {
|
||||
connectionId: string;
|
||||
terminalCwd: string;
|
||||
};
|
||||
|
||||
export type SftpFollowTerminalCwdContext = {
|
||||
followEnabled: boolean;
|
||||
isVisible: boolean;
|
||||
terminalCwd?: string | null;
|
||||
currentPath?: string | null;
|
||||
connectionId?: string | null;
|
||||
hasActiveWork: boolean;
|
||||
isConnected: boolean;
|
||||
/** Skip auto-follow while this terminal cwd cannot be reached on SFTP. */
|
||||
blockedFollow?: SftpFollowTerminalCwdBlock | null;
|
||||
/** Skip auto-follow after this terminal cwd was already handled for the connection. */
|
||||
handledFollow?: SftpFollowTerminalCwdBlock | null;
|
||||
};
|
||||
|
||||
export type SftpFollowTerminalCwdSyncResultContext = {
|
||||
syncGeneration: number;
|
||||
currentGeneration: number;
|
||||
followEnabled: boolean;
|
||||
canFollow: boolean;
|
||||
expectedSessionId?: string | null;
|
||||
liveSessionId?: string | null;
|
||||
expectedConnectionId?: string | null;
|
||||
liveConnectionId?: string | null;
|
||||
paneConnectionId?: string | null;
|
||||
expectedTerminalCwd?: string | null;
|
||||
liveTerminalCwd?: string | null;
|
||||
requireLiveTerminalCwd?: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* Whether a change of the live `activeTerminalCwd` input must invalidate the
|
||||
* handled/blocked follow bookkeeping.
|
||||
*
|
||||
* Hidden surfaces receive `activeTerminalCwd={null}`
|
||||
* (terminalLayerSidePanelSlots) and get the live value back when the tab
|
||||
* becomes visible again. That visibility-induced `null` is synthetic and must
|
||||
* not invalidate: invalidating on it would drop `handledFollowRef` and make
|
||||
* the regular follow sync navigate the user's browsed pane back to the
|
||||
* terminal cwd (clearing the filename filter) on every terminal-tab switch.
|
||||
*
|
||||
* While the surface is visible, though, a `null` is a real transition: the
|
||||
* linked terminal session moved to (or closed on) a session whose cwd cache is
|
||||
* empty. Passing it through would leave the generation valid, and the
|
||||
* first-open eligibility checks do not bind to the terminal session identity,
|
||||
* so an in-flight probe of the previous session could navigate the visible
|
||||
* pane. Any live value change while visible invalidates — including to `null`.
|
||||
*/
|
||||
export const shouldInvalidateFollowBookkeepingOnCwdChange = ({
|
||||
nextCwd,
|
||||
lastCwd,
|
||||
isVisible,
|
||||
}: {
|
||||
nextCwd: string | null;
|
||||
lastCwd: string | null;
|
||||
isVisible: boolean;
|
||||
}): boolean => {
|
||||
if (nextCwd === lastCwd) return false;
|
||||
// Only the hidden synthetic `null` pass-through is ignored; a caller that
|
||||
// still forwards live values while hidden keeps the concrete-change rule.
|
||||
if (!isVisible) return nextCwd !== null;
|
||||
return true;
|
||||
};
|
||||
|
||||
/**
|
||||
* Whether the one-shot "first open" terminal cwd sync must re-arm.
|
||||
*
|
||||
* Hiding the surface while the owner side panel stays open (switching terminal
|
||||
* tabs, or focusing another side-panel tool in the same tab) must NOT re-arm
|
||||
* the sync: re-running it would navigate the pane away from the user's browsed
|
||||
* directory back to the terminal cwd and clear the filename filter on every
|
||||
* switch. A changed terminal cwd is still followed by the regular follow sync
|
||||
* through its handled/blocked bookkeeping. The sync re-arms only when the
|
||||
* owning panel actually closed (fresh open resyncs, #2335) or the linked
|
||||
* connection replaced itself (Start over).
|
||||
*/
|
||||
export const shouldResetInitialFollowTerminalCwdSync = ({
|
||||
isVisible,
|
||||
ownerPanelOpen,
|
||||
connectionId,
|
||||
trackedConnectionId,
|
||||
}: {
|
||||
isVisible: boolean;
|
||||
/** Side panel still open for this terminal tab (another tool/tab may have focus). */
|
||||
ownerPanelOpen: boolean;
|
||||
connectionId: string | null;
|
||||
trackedConnectionId: string | null;
|
||||
}): boolean => {
|
||||
if (connectionId !== trackedConnectionId) return true;
|
||||
return !isVisible && !ownerPanelOpen;
|
||||
};
|
||||
|
||||
/**
|
||||
* Whether a hide transition with the owning panel still open (terminal-tab
|
||||
* switch, or focusing another side-panel tool in the same tab) must latch the
|
||||
* pending first-open fresh-CWD probe as interrupted.
|
||||
*
|
||||
* When the surface is hidden while a probe is still in flight, the probe's
|
||||
* eligibility is only re-checked live when it resolves. If the tab becomes
|
||||
* visible again before then, the live checks pass again (generation unchanged,
|
||||
* visible, no active work) and the stale probe would navigate the pane back to
|
||||
* the terminal cwd, clearing the user's browsed directory and filename filter.
|
||||
* The hide transition must latch the attempt as interrupted while keeping its
|
||||
* one-shot slot consumed (no re-arm on return — see
|
||||
* {@link shouldReleaseInitialFollowSyncAttempt}).
|
||||
*/
|
||||
export const shouldLatchInitialFollowInterruption = ({
|
||||
isVisible,
|
||||
ownerPanelOpen,
|
||||
}: {
|
||||
isVisible: boolean;
|
||||
ownerPanelOpen: boolean;
|
||||
}): boolean => !isVisible && ownerPanelOpen;
|
||||
|
||||
/**
|
||||
* Whether an unfinished one-shot "first open" sync attempt may release its
|
||||
* consumed slot so a retry can re-arm.
|
||||
*
|
||||
* If the fresh-CWD probe resolves while the surface is hidden but the owning
|
||||
* panel is still open (terminal-tab switch), the attempt must stay consumed:
|
||||
* releasing it would re-run the first-open sync when the tab becomes visible
|
||||
* again and navigate the pane away from the directory the user browsed to
|
||||
* while the original probe was pending (clearing its filename filter). The
|
||||
* attempt is released once the surface is visible again or the owning panel
|
||||
* closed (fresh open resyncs, #2335).
|
||||
*/
|
||||
export const shouldReleaseInitialFollowSyncAttempt = ({
|
||||
isVisible,
|
||||
ownerPanelOpen,
|
||||
}: {
|
||||
isVisible: boolean;
|
||||
ownerPanelOpen: boolean;
|
||||
}): boolean => isVisible || !ownerPanelOpen;
|
||||
|
||||
export const resolveHostFollowTerminalCwd = (
|
||||
hostFollowTerminalCwd: boolean | undefined,
|
||||
globalFollowTerminalCwd: boolean,
|
||||
): boolean => hostFollowTerminalCwd ?? globalFollowTerminalCwd;
|
||||
|
||||
export const resolveSftpFollowTerminalCwdTargetHost = <T>(
|
||||
visibleHost: T | null | undefined,
|
||||
fallbackHost: T | null | undefined,
|
||||
): T | null => visibleHost ?? fallbackHost ?? null;
|
||||
|
||||
export const mergeLatestFollowTerminalCwdHostSetting = <
|
||||
T extends { id?: string; sftpFollowTerminalCwd?: boolean },
|
||||
>(
|
||||
displayHost: T | null | undefined,
|
||||
latestHost: T | null | undefined,
|
||||
pendingFollowOverride?: boolean,
|
||||
): T | null => {
|
||||
if (!displayHost) return latestHost ?? null;
|
||||
if (!latestHost || latestHost.id !== displayHost.id) return displayHost;
|
||||
|
||||
return {
|
||||
...latestHost,
|
||||
...displayHost,
|
||||
sftpFollowTerminalCwd:
|
||||
latestHost.sftpFollowTerminalCwd !== undefined
|
||||
? latestHost.sftpFollowTerminalCwd
|
||||
: pendingFollowOverride,
|
||||
};
|
||||
};
|
||||
|
||||
/** Clear a follow block once the user reaches the blocked cwd through any navigation. */
|
||||
export const shouldClearBlockedFollowOnReach = (
|
||||
blockedFollow: SftpFollowTerminalCwdBlock | null | undefined,
|
||||
connectionId: string | null | undefined,
|
||||
currentPath: string | null | undefined,
|
||||
loading: boolean,
|
||||
): boolean => {
|
||||
if (loading || !blockedFollow || !connectionId || !currentPath) return false;
|
||||
return (
|
||||
blockedFollow.connectionId === connectionId
|
||||
&& blockedFollow.terminalCwd === currentPath
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Whether a first-open or in-flight follow still belongs to the terminal that
|
||||
* started it. A missing origin id is not a skipped guard: the live focused
|
||||
* terminal must stay absent too, otherwise the probe belongs to a later pane.
|
||||
*/
|
||||
export const isFollowOriginStillCurrent = ({
|
||||
expectedOriginId,
|
||||
liveOriginId,
|
||||
}: {
|
||||
expectedOriginId: string | null | undefined;
|
||||
liveOriginId?: string | null;
|
||||
}): boolean => {
|
||||
if (liveOriginId === undefined) return true;
|
||||
if (expectedOriginId == null) return liveOriginId == null;
|
||||
return liveOriginId === expectedOriginId;
|
||||
};
|
||||
|
||||
/** Whether an async follow result still belongs to the current terminal/connection state. */
|
||||
export const shouldApplyFollowTerminalCwdSyncResult = ({
|
||||
syncGeneration,
|
||||
currentGeneration,
|
||||
followEnabled,
|
||||
canFollow,
|
||||
expectedSessionId,
|
||||
liveSessionId,
|
||||
expectedConnectionId,
|
||||
liveConnectionId,
|
||||
paneConnectionId,
|
||||
expectedTerminalCwd,
|
||||
liveTerminalCwd,
|
||||
requireLiveTerminalCwd = false,
|
||||
}: SftpFollowTerminalCwdSyncResultContext): boolean => {
|
||||
if (syncGeneration !== currentGeneration || !followEnabled || !canFollow) {
|
||||
return false;
|
||||
}
|
||||
if (expectedSessionId !== undefined) {
|
||||
if (!isFollowOriginStillCurrent({
|
||||
expectedOriginId: expectedSessionId,
|
||||
liveOriginId: liveSessionId,
|
||||
})) return false;
|
||||
}
|
||||
if (expectedConnectionId !== undefined) {
|
||||
if (!expectedConnectionId) return false;
|
||||
if (liveConnectionId !== undefined && liveConnectionId !== expectedConnectionId) return false;
|
||||
if (paneConnectionId !== undefined && paneConnectionId !== expectedConnectionId) return false;
|
||||
}
|
||||
if (expectedTerminalCwd !== undefined) {
|
||||
if (requireLiveTerminalCwd && !liveTerminalCwd) return false;
|
||||
if (liveTerminalCwd && liveTerminalCwd !== expectedTerminalCwd) return false;
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
/** Whether SFTP should auto-navigate to match the linked terminal cwd. */
|
||||
export const shouldFollowTerminalCwdNavigate = ({
|
||||
followEnabled,
|
||||
isVisible,
|
||||
terminalCwd,
|
||||
currentPath,
|
||||
connectionId,
|
||||
hasActiveWork,
|
||||
isConnected,
|
||||
blockedFollow,
|
||||
handledFollow,
|
||||
}: SftpFollowTerminalCwdContext): boolean => {
|
||||
if (!followEnabled || !isVisible || !isConnected) return false;
|
||||
if (hasActiveWork) return false;
|
||||
if (!terminalCwd || terminalCwd.trim().length === 0) return false;
|
||||
if (
|
||||
handledFollow
|
||||
&& connectionId
|
||||
&& handledFollow.connectionId === connectionId
|
||||
&& handledFollow.terminalCwd === terminalCwd
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
if (
|
||||
blockedFollow
|
||||
&& connectionId
|
||||
&& blockedFollow.connectionId === connectionId
|
||||
&& blockedFollow.terminalCwd === terminalCwd
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
if (!currentPath || currentPath === terminalCwd) return false;
|
||||
return true;
|
||||
};
|
||||
@@ -0,0 +1,195 @@
|
||||
/**
|
||||
* Auto Launch - Registers Netcatty to start at system login, hidden to the
|
||||
* tray. Thin wrapper around Electron's app.setLoginItemSettings/
|
||||
* getLoginItemSettings so main.cjs and the settings IPC handlers share one
|
||||
* source of truth.
|
||||
*
|
||||
* Development runs (`electron .`) are unsupported: process.execPath points
|
||||
* at the transient electron.exe, so a login item registered there would
|
||||
* break (or silently do nothing) after the dev process exits.
|
||||
*
|
||||
* Platform support is further limited to macOS and Windows: Electron's
|
||||
* login-item API is a no-op on Linux, which ships as AppImage/deb/rpm/pacman
|
||||
* with no first-party autostart hook, so reporting it as supported there
|
||||
* would show an enabled toggle that does nothing.
|
||||
*/
|
||||
|
||||
const HIDDEN_LAUNCH_ARG = "--hidden";
|
||||
|
||||
function isAutoLaunchSupported({ defaultApp = process.defaultApp, platform = process.platform } = {}) {
|
||||
if (defaultApp) return false;
|
||||
return platform === "darwin" || platform === "win32";
|
||||
}
|
||||
|
||||
function argsEqual(a, b) {
|
||||
return Array.isArray(a) && Array.isArray(b) && a.length === b.length && a.every((value, index) => value === b[index]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the specific Windows run-key entry our own writes create (path +
|
||||
* --hidden args), not just any entry for this executable.
|
||||
*/
|
||||
function findMatchingLaunchItem(settings, execPath) {
|
||||
if (!Array.isArray(settings?.launchItems)) return null;
|
||||
return settings.launchItems.find(
|
||||
(item) => item?.path === execPath && argsEqual(item?.args, [HIDDEN_LAUNCH_ARG]),
|
||||
) ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the OS's effective auto-launch state, not just whether a
|
||||
* registration exists.
|
||||
*
|
||||
* Windows can retain the run-key entry while the user disables it via Task
|
||||
* Manager's Startup Apps UI. Electron's executableWillLaunchAtLogin surfaces
|
||||
* an effective state too, but it deliberately ignores the args option and
|
||||
* reports true if the executable would launch with ANY arguments — so it
|
||||
* can false-positive on an unrelated no-argument entry for the same exe.
|
||||
* launchItems[].enabled is scoped to one specific path+args registration
|
||||
* (the same combination buildLoginItemQueryOptions queries), so look up our
|
||||
* own --hidden entry there instead and use its enabled flag.
|
||||
*/
|
||||
function resolveEffectiveLoginState(settings, platform, execPath) {
|
||||
if (platform === "win32") {
|
||||
const matchingItem = findMatchingLaunchItem(settings, execPath);
|
||||
if (matchingItem) return Boolean(matchingItem.enabled);
|
||||
// No matching --hidden entry: openAtLogin was queried with the same
|
||||
// path+args (see buildLoginItemQueryOptions), so it is still correctly
|
||||
// scoped to "is that exact entry registered" — false if absent.
|
||||
return Boolean(settings?.openAtLogin);
|
||||
}
|
||||
if (platform === "darwin" && settings?.status === "requires-approval") {
|
||||
// macOS 13+ (SMAppService): a freshly registered login item sits in
|
||||
// "requires-approval" until the user approves it in System Settings,
|
||||
// during which openAtLogin reports false even though registration
|
||||
// itself succeeded. Reporting false here would make the renderer's
|
||||
// push effect see a mismatch against what the user just requested and
|
||||
// immediately fire an opposite write, unregistering the pending item
|
||||
// before the user ever gets a chance to approve it. Treat pending
|
||||
// approval as enabled; a later read naturally reflects the real state
|
||||
// once the user approves it (or the OS drops the pending request).
|
||||
return true;
|
||||
}
|
||||
return Boolean(settings?.openAtLogin);
|
||||
}
|
||||
|
||||
/**
|
||||
* Electron's getLoginItemSettings() only reports openAtLogin for the
|
||||
* specific path+args combination you ask about — it does not mean "is
|
||||
* anything registered for this app". Our code only ever registers a login
|
||||
* item with args:[HIDDEN_LAUNCH_ARG], so every read must query that exact
|
||||
* combination (matching what setAutoLaunchEnabled writes) or Windows
|
||||
* reports a false negative.
|
||||
*/
|
||||
function buildLoginItemQueryOptions(execPath) {
|
||||
return { path: execPath, args: [HIDDEN_LAUNCH_ARG] };
|
||||
}
|
||||
|
||||
function getAutoLaunchEnabled({
|
||||
app,
|
||||
execPath = process.execPath,
|
||||
defaultApp = process.defaultApp,
|
||||
platform = process.platform,
|
||||
} = {}) {
|
||||
if (!isAutoLaunchSupported({ defaultApp, platform })) {
|
||||
return { success: true, enabled: false, supported: false };
|
||||
}
|
||||
try {
|
||||
const settings = app.getLoginItemSettings(buildLoginItemQueryOptions(execPath));
|
||||
return { success: true, enabled: resolveEffectiveLoginState(settings, platform, execPath), supported: true };
|
||||
} catch (err) {
|
||||
console.warn("[AutoLaunch] Failed to read login item settings:", err?.message || err);
|
||||
// success:false signals "OS state unknown" (as opposed to a confirmed
|
||||
// disabled state) so callers — notably the renderer's mount-time
|
||||
// hydration — know not to trust `enabled` here. Blindly applying
|
||||
// enabled:false on a transient read failure would overwrite the
|
||||
// renderer's cached value and cascade into an unwanted disable write.
|
||||
return { success: false, enabled: false, supported: true };
|
||||
}
|
||||
}
|
||||
|
||||
function setAutoLaunchEnabled(enabled, {
|
||||
app,
|
||||
execPath = process.execPath,
|
||||
defaultApp = process.defaultApp,
|
||||
platform = process.platform,
|
||||
} = {}) {
|
||||
if (!isAutoLaunchSupported({ defaultApp, platform })) {
|
||||
// enabled:false is a confirmed fact here (the feature genuinely isn't
|
||||
// available), not an unknown state — matches getAutoLaunchEnabled's
|
||||
// equivalent branch so both functions agree on what `success` means.
|
||||
return { success: true, enabled: false, supported: false };
|
||||
}
|
||||
const wantEnabled = Boolean(enabled);
|
||||
try {
|
||||
app.setLoginItemSettings({
|
||||
openAtLogin: wantEnabled,
|
||||
// openAsHidden only applies on macOS App Store builds; Windows relies
|
||||
// on the --hidden arg below, which main.cjs checks on cold start.
|
||||
openAsHidden: wantEnabled,
|
||||
path: execPath,
|
||||
args: wantEnabled ? [HIDDEN_LAUNCH_ARG] : [],
|
||||
});
|
||||
const settings = app.getLoginItemSettings(buildLoginItemQueryOptions(execPath));
|
||||
return { success: true, enabled: resolveEffectiveLoginState(settings, platform, execPath), supported: true };
|
||||
} catch (err) {
|
||||
console.warn("[AutoLaunch] Failed to update login item settings:", err?.message || err);
|
||||
// `success` means "is `enabled` trustworthy", not "did the write
|
||||
// succeed" — the write itself failing does not make a subsequent,
|
||||
// independent read failing too. Surface whatever the fallback read
|
||||
// determines: if it succeeds, `enabled` reflects the real (unwritten)
|
||||
// state and the renderer's push effect correctly rolls the optimistic
|
||||
// toggle back instead of leaving it stuck on a change that never
|
||||
// actually happened. Only a genuine double failure (write AND fallback
|
||||
// read both throw) reports success:false, so the renderer preserves its
|
||||
// last-known state instead of guessing.
|
||||
const fallback = getAutoLaunchEnabled({ app, execPath, defaultApp, platform });
|
||||
return { success: fallback.success, enabled: fallback.enabled, supported: true };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* True when this process was launched by the OS login item (cold start
|
||||
* only). Windows relies on the --hidden arg (macOS never puts args from
|
||||
* setLoginItemSettings() into argv for a login launch, so it needs a
|
||||
* different signal).
|
||||
*
|
||||
* macOS's own hidden-launch flags (openAsHidden/wasOpenedAsHidden) are
|
||||
* deprecated and stop working on macOS 13+ per Electron's docs, so they
|
||||
* cannot be trusted to detect an actual hidden launch there. wasOpenedAtLogin
|
||||
* still works on 13+ and is not deprecated; since Netcatty only ever
|
||||
* registers a macOS login item to satisfy this "launch hidden" feature (there
|
||||
* is no scenario where it registers one for a normal, visible startup), any
|
||||
* automatic login launch should apply our own hidden-window behavior.
|
||||
*/
|
||||
function wasLaunchedHidden({ argv = process.argv, app, platform = process.platform } = {}) {
|
||||
if (Array.isArray(argv) && argv.includes(HIDDEN_LAUNCH_ARG)) return true;
|
||||
if (platform !== "darwin" || typeof app?.getLoginItemSettings !== "function") return false;
|
||||
try {
|
||||
return Boolean(app.getLoginItemSettings().wasOpenedAtLogin);
|
||||
} catch (err) {
|
||||
console.warn("[AutoLaunch] Failed to read macOS login-item launch state:", err?.message || err);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function registerHandlers(ipcMain, { app, platform = process.platform }) {
|
||||
ipcMain.handle("netcatty:autoLaunch:get", async () => {
|
||||
return getAutoLaunchEnabled({ app, platform });
|
||||
});
|
||||
|
||||
ipcMain.handle("netcatty:autoLaunch:set", async (_event, { enabled }) => {
|
||||
return setAutoLaunchEnabled(enabled, { app, platform });
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
HIDDEN_LAUNCH_ARG,
|
||||
isAutoLaunchSupported,
|
||||
resolveEffectiveLoginState,
|
||||
buildLoginItemQueryOptions,
|
||||
getAutoLaunchEnabled,
|
||||
setAutoLaunchEnabled,
|
||||
wasLaunchedHidden,
|
||||
registerHandlers,
|
||||
};
|
||||
@@ -0,0 +1,404 @@
|
||||
const test = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
|
||||
const {
|
||||
HIDDEN_LAUNCH_ARG,
|
||||
isAutoLaunchSupported,
|
||||
resolveEffectiveLoginState,
|
||||
buildLoginItemQueryOptions,
|
||||
getAutoLaunchEnabled,
|
||||
setAutoLaunchEnabled,
|
||||
wasLaunchedHidden,
|
||||
registerHandlers,
|
||||
} = require("./autoLaunch.cjs");
|
||||
|
||||
const EXEC_PATH = "C:\\Netcatty\\Netcatty.exe";
|
||||
|
||||
function hiddenLaunchItem(overrides = {}) {
|
||||
return { name: "Netcatty", path: EXEC_PATH, args: [HIDDEN_LAUNCH_ARG], scope: "user", enabled: true, ...overrides };
|
||||
}
|
||||
|
||||
test("isAutoLaunchSupported is false when running unpackaged (electron .)", () => {
|
||||
assert.equal(isAutoLaunchSupported({ defaultApp: true, platform: "win32" }), false);
|
||||
assert.equal(isAutoLaunchSupported({ defaultApp: true, platform: "darwin" }), false);
|
||||
});
|
||||
|
||||
test("isAutoLaunchSupported is true on macOS and Windows when packaged", () => {
|
||||
assert.equal(isAutoLaunchSupported({ defaultApp: false, platform: "darwin" }), true);
|
||||
assert.equal(isAutoLaunchSupported({ defaultApp: false, platform: "win32" }), true);
|
||||
});
|
||||
|
||||
test("isAutoLaunchSupported is false on Linux — Electron's login-item API is a no-op there", () => {
|
||||
assert.equal(isAutoLaunchSupported({ defaultApp: false, platform: "linux" }), false);
|
||||
});
|
||||
|
||||
test("resolveEffectiveLoginState uses the matching launchItems entry's enabled flag on Windows", () => {
|
||||
const settings = { openAtLogin: true, launchItems: [hiddenLaunchItem({ enabled: false })] };
|
||||
|
||||
assert.equal(
|
||||
resolveEffectiveLoginState(settings, "win32", EXEC_PATH),
|
||||
false,
|
||||
"Task Manager can disable the specific --hidden entry while openAtLogin stays true",
|
||||
);
|
||||
});
|
||||
|
||||
test("resolveEffectiveLoginState reports true when the matching launchItems entry is enabled", () => {
|
||||
const settings = { openAtLogin: true, launchItems: [hiddenLaunchItem({ enabled: true })] };
|
||||
|
||||
assert.equal(resolveEffectiveLoginState(settings, "win32", EXEC_PATH), true);
|
||||
});
|
||||
|
||||
test("resolveEffectiveLoginState ignores unrelated launchItems entries (different path or args)", () => {
|
||||
const settings = {
|
||||
openAtLogin: false,
|
||||
launchItems: [
|
||||
hiddenLaunchItem({ path: "C:\\Other\\App.exe", enabled: true }),
|
||||
hiddenLaunchItem({ args: [], enabled: true }),
|
||||
],
|
||||
};
|
||||
|
||||
assert.equal(
|
||||
resolveEffectiveLoginState(settings, "win32", EXEC_PATH),
|
||||
false,
|
||||
"a differently-scoped entry (wrong path or wrong args) must not count as our own registration",
|
||||
);
|
||||
});
|
||||
|
||||
test("resolveEffectiveLoginState falls back to openAtLogin when no matching launchItems entry exists", () => {
|
||||
assert.equal(resolveEffectiveLoginState({ openAtLogin: true, launchItems: [] }, "win32", EXEC_PATH), true);
|
||||
assert.equal(resolveEffectiveLoginState({ openAtLogin: false, launchItems: [] }, "win32", EXEC_PATH), false);
|
||||
assert.equal(resolveEffectiveLoginState({ openAtLogin: true }, "win32", EXEC_PATH), true, "no launchItems array at all");
|
||||
});
|
||||
|
||||
test("resolveEffectiveLoginState does not consult launchItems on non-Windows platforms", () => {
|
||||
const settings = { openAtLogin: true, launchItems: [hiddenLaunchItem({ enabled: false })] };
|
||||
|
||||
assert.equal(
|
||||
resolveEffectiveLoginState(settings, "darwin", EXEC_PATH),
|
||||
true,
|
||||
"launchItems is Windows-only; macOS must read openAtLogin directly",
|
||||
);
|
||||
});
|
||||
|
||||
test("resolveEffectiveLoginState treats a macOS pending-approval registration as enabled", () => {
|
||||
const settings = { openAtLogin: false, status: "requires-approval" };
|
||||
|
||||
assert.equal(
|
||||
resolveEffectiveLoginState(settings, "darwin", EXEC_PATH),
|
||||
true,
|
||||
"openAtLogin reports false while SMAppService awaits System Settings approval, even though registration succeeded; " +
|
||||
"reporting false here would make the renderer's push effect immediately unregister the pending item",
|
||||
);
|
||||
});
|
||||
|
||||
test("resolveEffectiveLoginState ignores the requires-approval special-case on non-macOS platforms", () => {
|
||||
const settings = { openAtLogin: false, status: "requires-approval" };
|
||||
|
||||
assert.equal(resolveEffectiveLoginState(settings, "win32", EXEC_PATH), false);
|
||||
});
|
||||
|
||||
test("resolveEffectiveLoginState reads openAtLogin normally for other macOS statuses", () => {
|
||||
assert.equal(resolveEffectiveLoginState({ openAtLogin: true, status: "enabled" }, "darwin", EXEC_PATH), true);
|
||||
assert.equal(resolveEffectiveLoginState({ openAtLogin: false, status: "not-registered" }, "darwin", EXEC_PATH), false);
|
||||
assert.equal(resolveEffectiveLoginState({ openAtLogin: false, status: "not-found" }, "darwin", EXEC_PATH), false);
|
||||
});
|
||||
|
||||
test("buildLoginItemQueryOptions matches the path+args our own writes always register", () => {
|
||||
assert.deepEqual(
|
||||
buildLoginItemQueryOptions(EXEC_PATH),
|
||||
{ path: EXEC_PATH, args: [HIDDEN_LAUNCH_ARG] },
|
||||
);
|
||||
});
|
||||
|
||||
test("getAutoLaunchEnabled reports unsupported without touching app in dev", () => {
|
||||
let called = false;
|
||||
const app = { getLoginItemSettings: () => { called = true; return { openAtLogin: true }; } };
|
||||
|
||||
const result = getAutoLaunchEnabled({ app, defaultApp: true, platform: "win32" });
|
||||
|
||||
assert.deepEqual(result, { success: true, enabled: false, supported: false });
|
||||
assert.equal(called, false);
|
||||
});
|
||||
|
||||
test("getAutoLaunchEnabled queries the same path+args the login item was registered with", () => {
|
||||
let capturedOptions = null;
|
||||
const app = {
|
||||
getLoginItemSettings: (options) => { capturedOptions = options; return { openAtLogin: true }; },
|
||||
};
|
||||
|
||||
getAutoLaunchEnabled({ app, execPath: EXEC_PATH, defaultApp: false, platform: "win32" });
|
||||
|
||||
assert.deepEqual(capturedOptions, { path: EXEC_PATH, args: [HIDDEN_LAUNCH_ARG] });
|
||||
});
|
||||
|
||||
test("getAutoLaunchEnabled reports unsupported on Linux without touching app", () => {
|
||||
let called = false;
|
||||
const app = { getLoginItemSettings: () => { called = true; return { openAtLogin: true }; } };
|
||||
|
||||
const result = getAutoLaunchEnabled({ app, defaultApp: false, platform: "linux" });
|
||||
|
||||
assert.deepEqual(result, { success: true, enabled: false, supported: false });
|
||||
assert.equal(called, false);
|
||||
});
|
||||
|
||||
test("getAutoLaunchEnabled reflects the current login item state on macOS", () => {
|
||||
const app = { getLoginItemSettings: () => ({ openAtLogin: true }) };
|
||||
|
||||
const result = getAutoLaunchEnabled({ app, defaultApp: false, platform: "darwin" });
|
||||
|
||||
assert.deepEqual(result, { success: true, enabled: true, supported: true });
|
||||
});
|
||||
|
||||
test("getAutoLaunchEnabled reports disabled when Windows Startup Apps has disabled the matching entry", () => {
|
||||
const app = {
|
||||
getLoginItemSettings: () => ({ openAtLogin: true, launchItems: [hiddenLaunchItem({ enabled: false })] }),
|
||||
};
|
||||
|
||||
const result = getAutoLaunchEnabled({ app, execPath: EXEC_PATH, defaultApp: false, platform: "win32" });
|
||||
|
||||
assert.deepEqual(result, { success: true, enabled: false, supported: true });
|
||||
});
|
||||
|
||||
test("getAutoLaunchEnabled is not fooled by an unrelated no-argument entry for the same executable", () => {
|
||||
const app = {
|
||||
getLoginItemSettings: () => ({
|
||||
openAtLogin: false,
|
||||
launchItems: [hiddenLaunchItem({ args: [], enabled: true })],
|
||||
}),
|
||||
};
|
||||
|
||||
const result = getAutoLaunchEnabled({ app, execPath: EXEC_PATH, defaultApp: false, platform: "win32" });
|
||||
|
||||
assert.deepEqual(
|
||||
result,
|
||||
{ success: true, enabled: false, supported: true },
|
||||
"executableWillLaunchAtLogin-style any-args matching would wrongly report true here",
|
||||
);
|
||||
});
|
||||
|
||||
test("getAutoLaunchEnabled reports success:false (not a confirmed disabled state) when the app API throws", () => {
|
||||
const app = { getLoginItemSettings: () => { throw new Error("boom"); } };
|
||||
|
||||
const result = getAutoLaunchEnabled({ app, defaultApp: false, platform: "win32" });
|
||||
|
||||
assert.deepEqual(
|
||||
result,
|
||||
{ success: false, enabled: false, supported: true },
|
||||
"callers (renderer hydration) must be able to tell a transient read failure apart from a confirmed enabled:false, or they will overwrite a cached true and cascade into an unwanted disable write",
|
||||
);
|
||||
});
|
||||
|
||||
test("setAutoLaunchEnabled(true) registers the hidden launch arg", () => {
|
||||
let capturedSettings = null;
|
||||
const app = {
|
||||
setLoginItemSettings: (settings) => { capturedSettings = settings; },
|
||||
getLoginItemSettings: () => ({ openAtLogin: true, launchItems: [hiddenLaunchItem({ enabled: true })] }),
|
||||
};
|
||||
|
||||
const result = setAutoLaunchEnabled(true, {
|
||||
app,
|
||||
execPath: EXEC_PATH,
|
||||
defaultApp: false,
|
||||
platform: "win32",
|
||||
});
|
||||
|
||||
assert.deepEqual(capturedSettings, {
|
||||
openAtLogin: true,
|
||||
openAsHidden: true,
|
||||
path: EXEC_PATH,
|
||||
args: [HIDDEN_LAUNCH_ARG],
|
||||
});
|
||||
assert.deepEqual(result, { success: true, enabled: true, supported: true });
|
||||
});
|
||||
|
||||
test("setAutoLaunchEnabled(true) verifies with the same path+args it just wrote", () => {
|
||||
let capturedQueryOptions = null;
|
||||
const app = {
|
||||
setLoginItemSettings: () => {},
|
||||
getLoginItemSettings: (options) => {
|
||||
capturedQueryOptions = options;
|
||||
// Electron 42 contract: openAtLogin only reflects true for the exact
|
||||
// path+args queried — a bare getLoginItemSettings() call (no args)
|
||||
// would incorrectly report false right after enabling with --hidden.
|
||||
return options?.args?.includes(HIDDEN_LAUNCH_ARG)
|
||||
? { openAtLogin: true, launchItems: [hiddenLaunchItem({ enabled: true })] }
|
||||
: { openAtLogin: false, launchItems: [] };
|
||||
},
|
||||
};
|
||||
|
||||
const result = setAutoLaunchEnabled(true, {
|
||||
app,
|
||||
execPath: EXEC_PATH,
|
||||
defaultApp: false,
|
||||
platform: "win32",
|
||||
});
|
||||
|
||||
assert.deepEqual(capturedQueryOptions, { path: EXEC_PATH, args: [HIDDEN_LAUNCH_ARG] });
|
||||
assert.equal(result.enabled, true, "must not report false just because the query omitted matching args");
|
||||
});
|
||||
|
||||
test("setAutoLaunchEnabled(true) reports disabled when Windows Startup Apps blocks the matching entry", () => {
|
||||
const app = {
|
||||
setLoginItemSettings: () => {},
|
||||
getLoginItemSettings: () => ({ openAtLogin: true, launchItems: [hiddenLaunchItem({ enabled: false })] }),
|
||||
};
|
||||
|
||||
const result = setAutoLaunchEnabled(true, { app, execPath: EXEC_PATH, defaultApp: false, platform: "win32" });
|
||||
|
||||
assert.deepEqual(result, { success: true, enabled: false, supported: true });
|
||||
});
|
||||
|
||||
test("setAutoLaunchEnabled(true) on macOS does not report disabled while approval is pending", () => {
|
||||
const app = {
|
||||
setLoginItemSettings: () => {},
|
||||
getLoginItemSettings: () => ({ openAtLogin: false, status: "requires-approval" }),
|
||||
};
|
||||
|
||||
const result = setAutoLaunchEnabled(true, { app, defaultApp: false, platform: "darwin" });
|
||||
|
||||
assert.deepEqual(
|
||||
result,
|
||||
{ success: true, enabled: true, supported: true },
|
||||
"must match what the user requested, or the renderer's push effect fires an unregistering write before approval",
|
||||
);
|
||||
});
|
||||
|
||||
test("setAutoLaunchEnabled(false) clears the hidden launch arg", () => {
|
||||
let capturedSettings = null;
|
||||
const app = {
|
||||
setLoginItemSettings: (settings) => { capturedSettings = settings; },
|
||||
getLoginItemSettings: () => ({ openAtLogin: false, launchItems: [] }),
|
||||
};
|
||||
|
||||
const result = setAutoLaunchEnabled(false, { app, defaultApp: false, platform: "win32" });
|
||||
|
||||
assert.deepEqual(capturedSettings.args, []);
|
||||
assert.equal(capturedSettings.openAtLogin, false);
|
||||
assert.deepEqual(result, { success: true, enabled: false, supported: true });
|
||||
});
|
||||
|
||||
test("setAutoLaunchEnabled is a no-op in dev and does not call the app API", () => {
|
||||
let called = false;
|
||||
const app = { setLoginItemSettings: () => { called = true; } };
|
||||
|
||||
const result = setAutoLaunchEnabled(true, { app, defaultApp: true, platform: "win32" });
|
||||
|
||||
assert.equal(called, false);
|
||||
assert.deepEqual(
|
||||
result,
|
||||
{ success: true, enabled: false, supported: false },
|
||||
"enabled:false is a confirmed fact when unsupported, not an unknown state — matches getAutoLaunchEnabled",
|
||||
);
|
||||
});
|
||||
|
||||
test("setAutoLaunchEnabled is a no-op on Linux and does not call the app API", () => {
|
||||
let called = false;
|
||||
const app = { setLoginItemSettings: () => { called = true; } };
|
||||
|
||||
const result = setAutoLaunchEnabled(true, { app, defaultApp: false, platform: "linux" });
|
||||
|
||||
assert.equal(called, false);
|
||||
assert.deepEqual(result, { success: true, enabled: false, supported: false });
|
||||
});
|
||||
|
||||
test("setAutoLaunchEnabled reports the real (unwritten) state when the write fails but a fallback read succeeds", () => {
|
||||
const app = {
|
||||
setLoginItemSettings: () => { throw new Error("registry locked"); },
|
||||
getLoginItemSettings: () => ({ openAtLogin: false, launchItems: [] }),
|
||||
};
|
||||
|
||||
const result = setAutoLaunchEnabled(true, { app, execPath: EXEC_PATH, defaultApp: false, platform: "win32" });
|
||||
|
||||
assert.deepEqual(
|
||||
result,
|
||||
{ success: true, enabled: false, supported: true },
|
||||
"success means \"enabled is trustworthy\", not \"the write succeeded\" — the renderer's push effect relies on " +
|
||||
"this to roll an optimistic toggle back to the real state instead of leaving it stuck on a failed write",
|
||||
);
|
||||
});
|
||||
|
||||
test("setAutoLaunchEnabled reports success:false only when the write AND the fallback read both fail", () => {
|
||||
const app = {
|
||||
setLoginItemSettings: () => { throw new Error("registry locked"); },
|
||||
getLoginItemSettings: () => { throw new Error("registry unreadable"); },
|
||||
};
|
||||
|
||||
const result = setAutoLaunchEnabled(true, { app, defaultApp: false, platform: "win32" });
|
||||
|
||||
assert.deepEqual(
|
||||
result,
|
||||
{ success: false, enabled: false, supported: true },
|
||||
"a genuine double failure leaves the real state unknown — the renderer must preserve its last-known value",
|
||||
);
|
||||
});
|
||||
|
||||
test("wasLaunchedHidden detects the --hidden cold-start flag", () => {
|
||||
assert.equal(wasLaunchedHidden({ argv: ["node", "main.js", "--hidden"], platform: "win32" }), true);
|
||||
assert.equal(wasLaunchedHidden({ argv: ["node", "main.js"], platform: "win32" }), false);
|
||||
assert.equal(wasLaunchedHidden({ argv: undefined, platform: "win32" }), false);
|
||||
});
|
||||
|
||||
test("wasLaunchedHidden detects a macOS login-item launch via wasOpenedAtLogin", () => {
|
||||
const app = { getLoginItemSettings: () => ({ wasOpenedAtLogin: true }) };
|
||||
|
||||
const result = wasLaunchedHidden({ argv: ["node", "main.js"], app, platform: "darwin" });
|
||||
|
||||
assert.equal(
|
||||
result,
|
||||
true,
|
||||
"openAsHidden/wasOpenedAsHidden are deprecated and stop working on macOS 13+, so wasOpenedAtLogin is the only reliable signal",
|
||||
);
|
||||
});
|
||||
|
||||
test("wasLaunchedHidden does not consult macOS login-item state on other platforms", () => {
|
||||
let called = false;
|
||||
const app = { getLoginItemSettings: () => { called = true; return { wasOpenedAtLogin: true }; } };
|
||||
|
||||
const result = wasLaunchedHidden({ argv: ["node", "main.js"], app, platform: "win32" });
|
||||
|
||||
assert.equal(result, false);
|
||||
assert.equal(called, false);
|
||||
});
|
||||
|
||||
test("wasLaunchedHidden tolerates a throwing macOS login-item lookup", () => {
|
||||
const app = { getLoginItemSettings: () => { throw new Error("boom"); } };
|
||||
|
||||
const result = wasLaunchedHidden({ argv: [], app, platform: "darwin" });
|
||||
|
||||
assert.equal(result, false);
|
||||
});
|
||||
|
||||
test("registerHandlers wires get/set IPC channels", async () => {
|
||||
const handlers = new Map();
|
||||
const ipcMain = { handle: (channel, fn) => handlers.set(channel, fn) };
|
||||
const app = {
|
||||
getLoginItemSettings: () => ({ openAtLogin: false, launchItems: [] }),
|
||||
setLoginItemSettings: () => {},
|
||||
};
|
||||
|
||||
registerHandlers(ipcMain, { app, platform: "win32" });
|
||||
|
||||
assert.ok(handlers.has("netcatty:autoLaunch:get"));
|
||||
assert.ok(handlers.has("netcatty:autoLaunch:set"));
|
||||
|
||||
const getResult = await handlers.get("netcatty:autoLaunch:get")();
|
||||
assert.deepEqual(getResult, { success: true, enabled: false, supported: true });
|
||||
|
||||
app.getLoginItemSettings = () => ({ openAtLogin: true, launchItems: [hiddenLaunchItem({ enabled: true })] });
|
||||
const setResult = await handlers.get("netcatty:autoLaunch:set")(null, { enabled: true });
|
||||
assert.deepEqual(setResult, { success: true, enabled: true, supported: true });
|
||||
});
|
||||
|
||||
test("registerHandlers respects the real process.platform when no override is given", async () => {
|
||||
const handlers = new Map();
|
||||
const ipcMain = { handle: (channel, fn) => handlers.set(channel, fn) };
|
||||
const app = { getLoginItemSettings: () => ({ openAtLogin: false }) };
|
||||
|
||||
registerHandlers(ipcMain, { app });
|
||||
const result = await handlers.get("netcatty:autoLaunch:get")();
|
||||
|
||||
assert.equal(
|
||||
result.supported,
|
||||
process.platform === "darwin" || process.platform === "win32",
|
||||
);
|
||||
});
|
||||
@@ -21,6 +21,11 @@ let tray = null;
|
||||
let closeToTray = false;
|
||||
let currentHotkey = null;
|
||||
let hotkeyEnabled = false;
|
||||
// True while a hidden auto-launch cold start has no visible window yet.
|
||||
// Keeps the tray alive even if the user's separate close-to-tray preference
|
||||
// is off, so a --hidden login-item launch is never a windowless, trayless
|
||||
// zombie process. Released once the main window is actually shown.
|
||||
let hiddenLaunchTrayPinned = false;
|
||||
|
||||
const STATUS_TEXT = {
|
||||
session: {
|
||||
@@ -1141,13 +1146,41 @@ function setCloseToTray(enabled) {
|
||||
}
|
||||
} else {
|
||||
clearPendingFullscreenHide(getMainWindow());
|
||||
// Destroy tray if it exists
|
||||
destroyTray();
|
||||
// A hidden auto-launch cold start pins the tray regardless of this
|
||||
// preference until its window is actually shown once — otherwise a user
|
||||
// with close-to-tray off would get a windowless, trayless zombie process.
|
||||
if (!hiddenLaunchTrayPinned) {
|
||||
destroyTray();
|
||||
}
|
||||
}
|
||||
|
||||
return { success: true, enabled: closeToTray };
|
||||
}
|
||||
|
||||
/**
|
||||
* Force-create the tray for a hidden auto-launch cold start and keep it
|
||||
* alive even if close-to-tray is later disabled, until the pin is released.
|
||||
*/
|
||||
function pinTrayForHiddenLaunch() {
|
||||
hiddenLaunchTrayPinned = true;
|
||||
if (!tray) {
|
||||
createTray();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Release the hidden-launch tray pin once its window has been shown. If the
|
||||
* user's close-to-tray preference is off, the tray is destroyed now instead
|
||||
* of lingering until the next close-to-tray toggle.
|
||||
*/
|
||||
function releaseHiddenLaunchTrayPin() {
|
||||
if (!hiddenLaunchTrayPinned) return;
|
||||
hiddenLaunchTrayPinned = false;
|
||||
if (!closeToTray) {
|
||||
destroyTray();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if close-to-tray is enabled
|
||||
*/
|
||||
@@ -1305,6 +1338,9 @@ module.exports = {
|
||||
handleWindowClose,
|
||||
clearPendingFullscreenHide,
|
||||
cleanup,
|
||||
createTray,
|
||||
pinTrayForHiddenLaunch,
|
||||
releaseHiddenLaunchTrayPin,
|
||||
getTray: () => tray,
|
||||
getTrayPanelWindow: () => trayPanelWindow,
|
||||
// Test helpers
|
||||
|
||||
@@ -1669,3 +1669,104 @@ test("macOS tray click still opens the panel below a top menu-bar icon", async (
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
test("pinTrayForHiddenLaunch creates a tray even when close-to-tray is off", () => {
|
||||
const bridge = loadBridge();
|
||||
const electronModule = createElectronStub();
|
||||
bridge.init({ electronModule, getMainWindow: () => null });
|
||||
|
||||
try {
|
||||
assert.equal(bridge.getTray(), null);
|
||||
bridge.pinTrayForHiddenLaunch();
|
||||
assert.notEqual(bridge.getTray(), null);
|
||||
} finally {
|
||||
bridge.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("a hidden-launch tray pin survives close-to-tray being turned off", async () => {
|
||||
const bridge = loadBridge();
|
||||
const electronModule = createElectronStub();
|
||||
bridge.init({ electronModule, getMainWindow: () => null });
|
||||
const ipcMain = createIpcMainStub();
|
||||
bridge.registerHandlers(ipcMain);
|
||||
|
||||
try {
|
||||
bridge.pinTrayForHiddenLaunch();
|
||||
assert.notEqual(bridge.getTray(), null, "hidden-launch cold start must have a tray");
|
||||
|
||||
// The renderer's close-to-tray effect runs on mount and can disable the
|
||||
// preference before the pin is released — the tray must not disappear,
|
||||
// or a hidden auto-launch becomes an unreachable zombie process.
|
||||
await ipcMain.handlers.get("netcatty:tray:setCloseToTray")(null, { enabled: false });
|
||||
assert.notEqual(bridge.getTray(), null, "the pin must outlive close-to-tray being disabled");
|
||||
} finally {
|
||||
bridge.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("releasing the hidden-launch tray pin destroys the tray if close-to-tray is off", async () => {
|
||||
const bridge = loadBridge();
|
||||
const electronModule = createElectronStub();
|
||||
bridge.init({ electronModule, getMainWindow: () => null });
|
||||
const ipcMain = createIpcMainStub();
|
||||
bridge.registerHandlers(ipcMain);
|
||||
|
||||
try {
|
||||
bridge.pinTrayForHiddenLaunch();
|
||||
await ipcMain.handlers.get("netcatty:tray:setCloseToTray")(null, { enabled: false });
|
||||
assert.notEqual(bridge.getTray(), null);
|
||||
|
||||
bridge.releaseHiddenLaunchTrayPin();
|
||||
|
||||
assert.equal(bridge.getTray(), null, "once the window is shown, close-to-tray=off should win");
|
||||
} finally {
|
||||
bridge.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("releasing the hidden-launch tray pin keeps the tray if close-to-tray is on", async () => {
|
||||
const bridge = loadBridge();
|
||||
const electronModule = createElectronStub();
|
||||
bridge.init({ electronModule, getMainWindow: () => null });
|
||||
const { ipcMain } = await enableCloseToTray(bridge, electronModule);
|
||||
|
||||
try {
|
||||
bridge.pinTrayForHiddenLaunch();
|
||||
assert.notEqual(bridge.getTray(), null);
|
||||
|
||||
bridge.releaseHiddenLaunchTrayPin();
|
||||
|
||||
assert.notEqual(bridge.getTray(), null, "close-to-tray=on should keep the tray regardless of the pin");
|
||||
void ipcMain;
|
||||
} finally {
|
||||
bridge.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("releaseHiddenLaunchTrayPin is a no-op when the pin was never set", () => {
|
||||
const bridge = loadBridge();
|
||||
const electronModule = createElectronStub();
|
||||
bridge.init({ electronModule, getMainWindow: () => null });
|
||||
|
||||
try {
|
||||
assert.doesNotThrow(() => bridge.releaseHiddenLaunchTrayPin());
|
||||
assert.equal(bridge.getTray(), null);
|
||||
} finally {
|
||||
bridge.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("setCloseToTray(false) still destroys an unpinned tray as before", async () => {
|
||||
const bridge = loadBridge();
|
||||
const electronModule = createElectronStub();
|
||||
const { ipcMain } = await enableCloseToTray(bridge, electronModule);
|
||||
|
||||
try {
|
||||
assert.notEqual(bridge.getTray(), null);
|
||||
await ipcMain.handlers.get("netcatty:tray:setCloseToTray")(null, { enabled: false });
|
||||
assert.equal(bridge.getTray(), null);
|
||||
} finally {
|
||||
bridge.cleanup();
|
||||
}
|
||||
});
|
||||
@@ -749,7 +749,7 @@ function ensureRemoteSftpSupport(sessionId) {
|
||||
return { session, sshClient };
|
||||
}
|
||||
|
||||
function findRemoteSftpSourceByEndpoint(sourceSessionId, expectedEndpoint) {
|
||||
function findRemoteSftpSourceByEndpoint(sourceSessionId, expectedEndpoint, requireExactSession = false) {
|
||||
const requested = sessions?.get(sourceSessionId);
|
||||
const matchesExpectedEndpoint = (session) => {
|
||||
const actualEndpoint = session?._reuseEndpoint || session?.connRef?.endpoint;
|
||||
@@ -767,6 +767,8 @@ function findRemoteSftpSourceByEndpoint(sourceSessionId, expectedEndpoint) {
|
||||
return { sessionId: sourceSessionId, session: requested, sshClient: requested.conn || requested.sshClient };
|
||||
}
|
||||
|
||||
if (requireExactSession) return null;
|
||||
|
||||
// The renderer only has endpoint fields when it chooses a sourceSessionId;
|
||||
// route, proxy, credential and host-key fingerprints live in the main
|
||||
// process. Treat its id as a hint, then choose the newest live session whose
|
||||
@@ -1913,7 +1915,11 @@ async function openSftpForSession(_event, payload) {
|
||||
let source;
|
||||
if (payload?.expectedEndpoint) {
|
||||
const expectedEndpoint = buildConnectionReuseEndpoint(payload.expectedEndpoint);
|
||||
source = findRemoteSftpSourceByEndpoint(sessionId, expectedEndpoint);
|
||||
source = findRemoteSftpSourceByEndpoint(
|
||||
sessionId,
|
||||
expectedEndpoint,
|
||||
payload?.requireExactSourceSession === true,
|
||||
);
|
||||
if (!source) {
|
||||
const err = new Error("Source session SSH route does not match the requested SFTP endpoint");
|
||||
err.code = "ERR_SFTP_SOURCE_ROUTE_MISMATCH";
|
||||
@@ -1986,34 +1992,11 @@ async function openSftpForSession(_event, payload) {
|
||||
}
|
||||
|
||||
if (sudoRequested) {
|
||||
let sftpWrapper;
|
||||
let sudoActive = true;
|
||||
try {
|
||||
sftpWrapper = await connectSudoSftp(sshClient, payload?.password || "");
|
||||
} catch (e) {
|
||||
// Fallback: if sftp-server binary is missing (exit code 127), try the
|
||||
// standard SFTP subsystem instead of failing completely. Mirrors openSftp
|
||||
// (ESXi / hosts without a standalone sftp-server). Keeps the reused SSH
|
||||
// transport so MFA is not repeated.
|
||||
if (e?.message && e.message.includes("exit code 127")) {
|
||||
console.warn(
|
||||
"[SFTP] openSftpForSession sftp-server not found, falling back to standard SFTP subsystem",
|
||||
);
|
||||
sudoActive = false;
|
||||
sftpWrapper = await requireSftpChannel(client, {
|
||||
signal: payload?.abortSignal,
|
||||
timeoutMs: payload?.timeoutMs,
|
||||
});
|
||||
} else {
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
const sftpWrapper = await connectSudoSftp(sshClient, payload?.password || "");
|
||||
client.sftp = sftpWrapper;
|
||||
client.__netcattyFileProtocol = "sftp";
|
||||
client.__netcattySudoMode = sudoActive;
|
||||
if (sudoActive) {
|
||||
sftpWrapper.on?.("close", () => client.end());
|
||||
}
|
||||
client.__netcattySudoMode = true;
|
||||
sftpWrapper.on?.("close", () => client.end());
|
||||
throwIfAborted(payload?.abortSignal);
|
||||
copySftpEncodingState(payload?.encodingStateKey, sftpId);
|
||||
sftpClients.set(sftpId, client);
|
||||
|
||||
@@ -352,7 +352,7 @@ test("openSftpForSession rejects sudo with forced SCP before opening a channel",
|
||||
assert.equal(sftpClients.size, 0);
|
||||
});
|
||||
|
||||
test("openSftpForSession falls back to standard SFTP when sudo sftp-server exits 127", async () => {
|
||||
test("openSftpForSession fails clearly instead of silently dropping sudo mode", async () => {
|
||||
let sudoCalls = 0;
|
||||
let sftpCalls = 0;
|
||||
const bridge = loadSftpBridgeWithProxySocket(null, {
|
||||
@@ -377,22 +377,17 @@ test("openSftpForSession falls back to standard SFTP when sudo sftp-server exits
|
||||
electronModule: {},
|
||||
});
|
||||
|
||||
const opened = await bridge.openSftpForSession(null, {
|
||||
sessionId: "session-sudo-127",
|
||||
sudo: true,
|
||||
password: "secret",
|
||||
});
|
||||
|
||||
assert.equal(opened.ok, true);
|
||||
assert.equal(opened.fileProtocol, "sftp");
|
||||
assert.equal(opened.sourceSessionId, "session-sudo-127");
|
||||
await assert.rejects(
|
||||
() => bridge.openSftpForSession(null, {
|
||||
sessionId: "session-sudo-127",
|
||||
sudo: true,
|
||||
password: "secret",
|
||||
}),
|
||||
/exit code 127.*sftp-server not found/i,
|
||||
);
|
||||
assert.equal(sudoCalls, 1);
|
||||
assert.equal(sftpCalls, 1);
|
||||
const client = sftpClients.get(opened.sftpId);
|
||||
assert.equal(client?.__netcattyFileProtocol, "sftp");
|
||||
assert.equal(client?.__netcattySudoMode, false);
|
||||
assert.equal(client?.sftp, fakeSftp);
|
||||
await bridge.closeSftp(null, { sftpId: opened.sftpId });
|
||||
assert.equal(sftpCalls, 0);
|
||||
assert.equal(sftpClients.size, 0);
|
||||
});
|
||||
|
||||
test("openSftpForSession does not fall back to standard SFTP on non-127 sudo errors", async () => {
|
||||
|
||||
@@ -301,6 +301,45 @@ test("openSftpForSession skips a mismatched source hint and reuses another sessi
|
||||
await sftpBridge.closeSftp(null, { sftpId: opened.sftpId });
|
||||
});
|
||||
|
||||
test("strict openSftpForSession never substitutes another matching live session", async () => {
|
||||
let alternateOpened = false;
|
||||
const connection = {
|
||||
sftp(callback) {
|
||||
alternateOpened = true;
|
||||
callback(null, createSessionChannel().channel);
|
||||
},
|
||||
};
|
||||
const options = {
|
||||
hostId: "same-host",
|
||||
hostname: "target.example",
|
||||
port: 22,
|
||||
username: "alice",
|
||||
authMethod: "password",
|
||||
password: "target-password",
|
||||
verifyHostKeys: false,
|
||||
};
|
||||
const endpoint = normalizeEndpoint(buildConnectionReuseEndpoint(options));
|
||||
const sftpClients = new Map();
|
||||
sftpBridge.init({
|
||||
electronModule: { webContents: { fromId: () => null } },
|
||||
sessions: new Map([["matching-alternate", { conn: connection, _reuseEndpoint: endpoint }]]),
|
||||
sftpClients,
|
||||
});
|
||||
|
||||
await assert.rejects(
|
||||
sftpBridge.openSftpForSession(null, {
|
||||
sessionId: "missing-requested-session",
|
||||
expectedEndpoint: options,
|
||||
requireExactSourceSession: true,
|
||||
fileProtocol: "sftp",
|
||||
}),
|
||||
(error) => error?.code === "ERR_SFTP_SOURCE_ROUTE_MISMATCH",
|
||||
);
|
||||
|
||||
assert.equal(alternateOpened, false);
|
||||
assert.equal(sftpClients.size, 0);
|
||||
});
|
||||
|
||||
test("openSftpForSession reuses the hinted session when its full route and security identity match", async () => {
|
||||
let openedChannel = false;
|
||||
const connection = {
|
||||
|
||||
@@ -72,6 +72,21 @@ function shouldRegisterFreshSftpTransport(options) {
|
||||
return options?.reuseTransport !== false && !options?.sudo;
|
||||
}
|
||||
|
||||
/** Sudo is part of the requested connection contract; never downgrade it. */
|
||||
async function openRequiredSudoSftp({
|
||||
connectSudoSftp,
|
||||
sshClient,
|
||||
password,
|
||||
closeFreshClient,
|
||||
}) {
|
||||
try {
|
||||
return await connectSudoSftp(sshClient, password);
|
||||
} catch (error) {
|
||||
closeFreshClient();
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
function createOpenConnectionApi(ctx) {
|
||||
with (ctx) {
|
||||
/**
|
||||
@@ -1235,28 +1250,19 @@ function createOpenConnectionApi(ctx) {
|
||||
(async () => {
|
||||
try {
|
||||
const sudoPass = options.password || "";
|
||||
const sftpWrapper = await connectSudoSftp(sshClient, sudoPass);
|
||||
const sftpWrapper = await openRequiredSudoSftp({
|
||||
connectSudoSftp,
|
||||
sshClient,
|
||||
password: sudoPass,
|
||||
closeFreshClient,
|
||||
});
|
||||
sftpWrapper.on('close', () => client.end());
|
||||
finishSftp(sftpWrapper);
|
||||
} catch (e) {
|
||||
// Fallback: if sftp-server binary is missing (exit code 127),
|
||||
// try standard SFTP subsystem instead of failing completely.
|
||||
// This handles systems like ESXi that don't have sftp-server
|
||||
// but support the SFTP subsystem natively.
|
||||
if (e.message && e.message.includes('exit code 127')) {
|
||||
console.warn('[SFTP] sftp-server not found, falling back to standard SFTP subsystem');
|
||||
options.sudo = false; // Mark as non-sudo for downstream logic
|
||||
openBoundedSftpChannel(sshClient).then((sftp) => {
|
||||
finishSftp(sftp);
|
||||
}).catch((sftpErr) => {
|
||||
// Do not drop to SCP after a sudo-mode open: elevation was requested.
|
||||
closeFreshClient();
|
||||
reject(sftpErr);
|
||||
});
|
||||
} else {
|
||||
closeFreshClient();
|
||||
reject(e);
|
||||
}
|
||||
// Elevation is part of the requested connection contract. A
|
||||
// standard SFTP fallback would look successful while silently
|
||||
// losing access to the requested destination.
|
||||
reject(e);
|
||||
}
|
||||
})();
|
||||
} else {
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
const test = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const { EventEmitter } = require("node:events");
|
||||
const Module = require("node:module");
|
||||
|
||||
function makeExecStream() {
|
||||
const stream = new EventEmitter();
|
||||
stream.stderr = new EventEmitter();
|
||||
stream.write = () => {};
|
||||
stream.close = () => {};
|
||||
stream.end = () => {};
|
||||
stream.destroy = () => {};
|
||||
return stream;
|
||||
}
|
||||
|
||||
function loadSftpBridgeWithFailingFreshSudo(t) {
|
||||
const bridgePath = require.resolve("../sftpBridge.cjs");
|
||||
const openConnectionPath = require.resolve("./openConnection.cjs");
|
||||
const authHelperPath = require.resolve("../sshAuthHelper.cjs");
|
||||
const realAuthHelper = require(authHelperPath);
|
||||
const originalLoad = Module._load;
|
||||
|
||||
class MockSftpClient extends EventEmitter {
|
||||
constructor() {
|
||||
super();
|
||||
MockSftpClient.instances.push(this);
|
||||
this.sftp = null;
|
||||
this.client = new EventEmitter();
|
||||
this.client._sock = { setTimeout() {} };
|
||||
this.client.setMaxListeners = () => {};
|
||||
this.client.connect = () => {
|
||||
setImmediate(() => {
|
||||
this.client.emit("connect");
|
||||
this.client.emit("handshake");
|
||||
this.client.emit("ready");
|
||||
});
|
||||
};
|
||||
this.client.exec = (command, execOptions, callback) => {
|
||||
const done = typeof execOptions === "function" ? execOptions : callback;
|
||||
this.execCommands.push(command);
|
||||
const stream = makeExecStream();
|
||||
done(null, stream);
|
||||
setImmediate(() => {
|
||||
if (command.startsWith("test -x ")) stream.emit("close", 1);
|
||||
else stream.emit("exit", 127);
|
||||
});
|
||||
};
|
||||
this.client.sftp = (callback) => {
|
||||
this.standardSftpCalls += 1;
|
||||
callback(null, new EventEmitter());
|
||||
};
|
||||
this.client.end = () => { this.sshEnded = true; };
|
||||
this.client.destroy = () => { this.sshDestroyed = true; };
|
||||
this.execCommands = [];
|
||||
this.standardSftpCalls = 0;
|
||||
this.sshEnded = false;
|
||||
this.sshDestroyed = false;
|
||||
}
|
||||
|
||||
end() {
|
||||
this.highLevelEnded = true;
|
||||
}
|
||||
}
|
||||
MockSftpClient.instances = [];
|
||||
|
||||
Module._load = function patchedLoad(request, parent, isMain) {
|
||||
if (request === "ssh2-sftp-client") return MockSftpClient;
|
||||
if (request === "./sshAuthHelper.cjs") {
|
||||
return {
|
||||
...realAuthHelper,
|
||||
findAllDefaultPrivateKeys: async () => [],
|
||||
getAvailableAgentSocket: async () => null,
|
||||
prepareSystemSshAgentForAuth: async () => null,
|
||||
};
|
||||
}
|
||||
return originalLoad.call(this, request, parent, isMain);
|
||||
};
|
||||
|
||||
delete require.cache[bridgePath];
|
||||
delete require.cache[openConnectionPath];
|
||||
const bridge = require("../sftpBridge.cjs");
|
||||
t.after(() => {
|
||||
delete require.cache[bridgePath];
|
||||
delete require.cache[openConnectionPath];
|
||||
Module._load = originalLoad;
|
||||
});
|
||||
return { bridge, MockSftpClient };
|
||||
}
|
||||
|
||||
test("fresh sudo SFTP failure rejects without ordinary SFTP or SCP downgrade", async (t) => {
|
||||
const { bridge, MockSftpClient } = loadSftpBridgeWithFailingFreshSudo(t);
|
||||
const sftpClients = new Map();
|
||||
bridge.init({ sftpClients, sessions: new Map(), electronModule: {} });
|
||||
|
||||
await assert.rejects(
|
||||
bridge.openSftp(
|
||||
{ sender: { id: 1, isDestroyed: () => false, send: () => {} } },
|
||||
{
|
||||
sessionId: "fresh-sudo-failure",
|
||||
hostname: "target.example",
|
||||
port: 22,
|
||||
username: "alice",
|
||||
password: "secret",
|
||||
authMethod: "password",
|
||||
useSshAgent: false,
|
||||
verifyHostKeys: false,
|
||||
fileProtocol: "auto",
|
||||
sudo: true,
|
||||
},
|
||||
),
|
||||
/SFTP sudo failed with exit code 127/,
|
||||
);
|
||||
|
||||
const client = MockSftpClient.instances[0];
|
||||
assert.ok(client.execCommands.some((command) => command.startsWith("sudo -S")));
|
||||
assert.equal(client.standardSftpCalls, 0);
|
||||
assert.equal(sftpClients.size, 0);
|
||||
assert.equal(client.sshEnded, true);
|
||||
assert.equal(client.sshDestroyed, true);
|
||||
});
|
||||
@@ -20,7 +20,12 @@ const {
|
||||
// Load sshBridge with a mocked ssh2 module so we can observe whether a *new*
|
||||
// SSH client is constructed (a fresh connection) versus an existing connection
|
||||
// being reused for a new shell channel (issue #1204).
|
||||
function loadBridgeWithMockedSsh2(t, { connectReady = false, remoteVer = "OpenSSH_9.0" } = {}) {
|
||||
function loadBridgeWithMockedSsh2(t, {
|
||||
connectReady = false,
|
||||
remoteVer = "OpenSSH_9.0",
|
||||
trackShellPids = false,
|
||||
beforeShellPidScanResult,
|
||||
} = {}) {
|
||||
const bridgePath = require.resolve("./sshBridge.cjs");
|
||||
const authHelperPath = require.resolve("./sshAuthHelper.cjs");
|
||||
const originalLoad = Module._load;
|
||||
@@ -54,7 +59,24 @@ function loadBridgeWithMockedSsh2(t, { connectReady = false, remoteVer = "OpenSS
|
||||
}
|
||||
end() { this.ended += 1; }
|
||||
destroy() {}
|
||||
exec(_command, callback) { callback?.(new Error("exec unavailable")); }
|
||||
exec(_command, callback) {
|
||||
if (!trackShellPids) {
|
||||
callback?.(new Error("exec unavailable"));
|
||||
return;
|
||||
}
|
||||
const stream = new EventEmitter();
|
||||
stream.stderr = new EventEmitter();
|
||||
stream.close = () => {};
|
||||
const pids = this.openedShells
|
||||
.map((_shell, index) => `${(index + 1) * 111} 1`)
|
||||
.join("\n");
|
||||
setImmediate(() => {
|
||||
beforeShellPidScanResult?.();
|
||||
stream.emit("data", Buffer.from(`${pids}\n__NETCATTY_SHELL_SCAN_COMPLETE__\n`));
|
||||
stream.emit("close", 0);
|
||||
});
|
||||
callback(null, stream);
|
||||
}
|
||||
shell(_pty, _options, callback) {
|
||||
const stream = makeStream();
|
||||
this.openedShells.push(stream);
|
||||
@@ -114,10 +136,13 @@ function loadBridgeWithMockedSsh2(t, { connectReady = false, remoteVer = "OpenSS
|
||||
return { bridge, getClientConstructCount: () => clientConstructCount };
|
||||
}
|
||||
|
||||
test("simultaneous normal opens of the same host make one physical SSH dial", async (t) => {
|
||||
test("simultaneous normal opens identify both shells before sharing one SSH connection", async (t) => {
|
||||
resetSshTransportRegistryForTests({ defaultIdleTtlMs: 0 });
|
||||
t.after(() => resetSshTransportRegistryForTests({ defaultIdleTtlMs: 0 }));
|
||||
const { bridge, getClientConstructCount } = loadBridgeWithMockedSsh2(t, { connectReady: true });
|
||||
const { bridge, getClientConstructCount } = loadBridgeWithMockedSsh2(t, {
|
||||
connectReady: true,
|
||||
trackShellPids: true,
|
||||
});
|
||||
const sessions = new Map();
|
||||
const start = registerStartHandler(bridge, sessions);
|
||||
const options = {
|
||||
@@ -140,6 +165,70 @@ test("simultaneous normal opens of the same host make one physical SSH dial", as
|
||||
assert.equal(getClientConstructCount(), 1);
|
||||
assert.equal(sessions.get("normal-1").conn, sessions.get("normal-2").conn);
|
||||
assert.equal(sessions.get("normal-1").connRef.count, 2);
|
||||
assert.equal(sessions.get("normal-1").shellPid, "111");
|
||||
assert.equal(sessions.get("normal-2").shellPid, "222");
|
||||
});
|
||||
|
||||
test("simultaneous normal opens use separate connections when shell identity is unavailable", async (t) => {
|
||||
resetSshTransportRegistryForTests({ defaultIdleTtlMs: 0 });
|
||||
t.after(() => resetSshTransportRegistryForTests({ defaultIdleTtlMs: 0 }));
|
||||
const { bridge, getClientConstructCount } = loadBridgeWithMockedSsh2(t, {
|
||||
connectReady: true,
|
||||
});
|
||||
const sessions = new Map();
|
||||
const start = registerStartHandler(bridge, sessions);
|
||||
const options = {
|
||||
hostname: "10.0.0.54",
|
||||
username: "alice",
|
||||
port: 22,
|
||||
authMethod: "password",
|
||||
password: "secret",
|
||||
useSshAgent: false,
|
||||
verifyHostKeys: false,
|
||||
};
|
||||
|
||||
await Promise.all([
|
||||
start({ sender: makeSender() }, { ...options, sessionId: "normal-1" }),
|
||||
start({ sender: makeSender() }, { ...options, sessionId: "normal-2" }),
|
||||
]);
|
||||
|
||||
assert.equal(getClientConstructCount(), 2);
|
||||
assert.notEqual(sessions.get("normal-1").conn, sessions.get("normal-2").conn);
|
||||
});
|
||||
|
||||
test("simultaneous normal opens do not trust a replacement session after identity discovery", async (t) => {
|
||||
resetSshTransportRegistryForTests({ defaultIdleTtlMs: 0 });
|
||||
t.after(() => resetSshTransportRegistryForTests({ defaultIdleTtlMs: 0 }));
|
||||
const sessions = new Map();
|
||||
let replaced = false;
|
||||
const { bridge, getClientConstructCount } = loadBridgeWithMockedSsh2(t, {
|
||||
connectReady: true,
|
||||
trackShellPids: true,
|
||||
beforeShellPidScanResult: () => {
|
||||
if (replaced) return;
|
||||
replaced = true;
|
||||
const original = sessions.get("normal-1");
|
||||
sessions.set("normal-1", { ...original, shellPid: "999" });
|
||||
},
|
||||
});
|
||||
const start = registerStartHandler(bridge, sessions);
|
||||
const options = {
|
||||
hostname: "10.0.0.55",
|
||||
username: "alice",
|
||||
port: 22,
|
||||
authMethod: "password",
|
||||
password: "secret",
|
||||
useSshAgent: false,
|
||||
verifyHostKeys: false,
|
||||
};
|
||||
|
||||
await Promise.all([
|
||||
start({ sender: makeSender() }, { ...options, sessionId: "normal-1" }),
|
||||
start({ sender: makeSender() }, { ...options, sessionId: "normal-2" }),
|
||||
]);
|
||||
|
||||
assert.equal(getClientConstructCount(), 2);
|
||||
assert.notEqual(sessions.get("normal-1").conn, sessions.get("normal-2").conn);
|
||||
});
|
||||
|
||||
test("reuseTransport false makes simultaneous same-host opens dial independently", async (t) => {
|
||||
|
||||
@@ -217,6 +217,38 @@ function createStartSessionApi(ctx) {
|
||||
return last;
|
||||
};
|
||||
|
||||
const ensureConcurrentJoinShellIdentity = async (connRef, options) => {
|
||||
if (!connRef || options.skipShellPidDiscovery) return true;
|
||||
|
||||
const current = [...sessions.entries()].filter(([, candidate]) => (
|
||||
candidate?.connRef === connRef
|
||||
&& candidate?.stream
|
||||
));
|
||||
if (current.length === 0) return false;
|
||||
if (current.every(([, candidate]) => candidate.shellPid)) return true;
|
||||
if (current.length !== 1) return false;
|
||||
|
||||
const [[sessionId, candidate]] = current;
|
||||
const discovery = await listInteractiveShellPids(connRef.conn);
|
||||
const live = sessions.get(sessionId);
|
||||
if (
|
||||
discovery.available
|
||||
&& discovery.pids.length === 1
|
||||
&& live === candidate
|
||||
&& live.connRef === connRef
|
||||
&& live.stream === candidate.stream
|
||||
&& !live.shellPid
|
||||
) {
|
||||
live.shellPid = String(discovery.pids[0]);
|
||||
}
|
||||
return Boolean(
|
||||
live === candidate
|
||||
&& live.connRef === connRef
|
||||
&& live.stream === candidate.stream
|
||||
&& live.shellPid
|
||||
);
|
||||
};
|
||||
|
||||
const waitForNewInteractiveShellPid = async (conn, previousPids, opts = {}) => {
|
||||
const previous = new Set(previousPids);
|
||||
const initialDelayMs = Math.max(0, Number(opts.initialDelayMs) || 0);
|
||||
@@ -1124,6 +1156,10 @@ function createStartSessionApi(ctx) {
|
||||
|
||||
function reuseShellSession(event, options, sourceSession, sessionId, log, reuseOpts = {}) {
|
||||
const connRef = sourceSession.connRef;
|
||||
const {
|
||||
ensureConcurrentJoinIdentity = false,
|
||||
...openReuseOpts
|
||||
} = reuseOpts;
|
||||
const refHolder = {};
|
||||
// Pin while queued as well as while opening: the source tab may close
|
||||
// before this copy reaches the front of the per-connection queue.
|
||||
@@ -1132,16 +1168,31 @@ function createStartSessionApi(ctx) {
|
||||
const previous = connRef.shellOpenQueue || Promise.resolve();
|
||||
const operation = previous
|
||||
.catch(() => {})
|
||||
.then(() => openReusedShellSerialized(
|
||||
event,
|
||||
options,
|
||||
sourceSession,
|
||||
sessionId,
|
||||
log,
|
||||
connRef,
|
||||
refHolder,
|
||||
reuseOpts,
|
||||
));
|
||||
.then(async () => {
|
||||
try {
|
||||
if (
|
||||
ensureConcurrentJoinIdentity
|
||||
&& !await ensureConcurrentJoinShellIdentity(connRef, options)
|
||||
) {
|
||||
const error = new Error("Concurrent terminal shell identity is unavailable");
|
||||
error.code = "SSH_CONCURRENT_SHELL_IDENTITY_UNSAFE";
|
||||
throw error;
|
||||
}
|
||||
} catch (error) {
|
||||
releaseConnectionRef(refHolder);
|
||||
throw error;
|
||||
}
|
||||
return openReusedShellSerialized(
|
||||
event,
|
||||
options,
|
||||
sourceSession,
|
||||
sessionId,
|
||||
log,
|
||||
connRef,
|
||||
refHolder,
|
||||
openReuseOpts,
|
||||
);
|
||||
});
|
||||
const tail = operation.then(() => undefined, () => undefined);
|
||||
connRef.shellOpenQueue = tail;
|
||||
|
||||
@@ -1301,22 +1352,33 @@ function createStartSessionApi(ctx) {
|
||||
{ conn: transport.conn, connRef: transport, stream: {} },
|
||||
sessionId,
|
||||
log,
|
||||
{
|
||||
ensureConcurrentJoinIdentity: coordination.role === "join"
|
||||
&& coordination._record?.kind === "shell",
|
||||
},
|
||||
);
|
||||
} catch (coordinationErr) {
|
||||
if (options._passphraseSignal?.aborted) {
|
||||
throw options._passphraseSignal.reason instanceof Error
|
||||
? options._passphraseSignal.reason
|
||||
: coordinationErr;
|
||||
if (coordinationErr?.code === "SSH_CONCURRENT_SHELL_IDENTITY_UNSAFE") {
|
||||
log("concurrent transport shell identity unavailable; connecting fresh", {
|
||||
sessionId,
|
||||
hostname: options.hostname,
|
||||
});
|
||||
} else {
|
||||
if (options._passphraseSignal?.aborted) {
|
||||
throw options._passphraseSignal.reason instanceof Error
|
||||
? options._passphraseSignal.reason
|
||||
: coordinationErr;
|
||||
}
|
||||
// A waiter must observe the leader's real failure instead of
|
||||
// immediately starting a second authentication prompt. Existing
|
||||
// transport reuse keeps its historical fresh-dial fallback.
|
||||
if (coordination.role === "join") throw coordinationErr;
|
||||
log("coordinated transport reuse failed, connecting fresh", {
|
||||
sessionId,
|
||||
hostname: options.hostname,
|
||||
error: coordinationErr?.message,
|
||||
});
|
||||
}
|
||||
// A waiter must observe the leader's real failure instead of
|
||||
// immediately starting a second authentication prompt. Existing
|
||||
// transport reuse keeps its historical fresh-dial fallback.
|
||||
if (coordination.role === "join") throw coordinationErr;
|
||||
log("coordinated transport reuse failed, connecting fresh", {
|
||||
sessionId,
|
||||
hostname: options.hostname,
|
||||
error: coordinationErr?.message,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
pendingDialCoordination = coordination;
|
||||
|
||||
Loaded 100 of 114 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user