Compare commits

...
Author SHA1 Message Date
bincxz 7451fed016 fix(terminal): preserve modified keys in Windows ConPTY 2026-09-02 17:58:52 +08:00
bincxz f2ec2a4842 fix(terminal): respect Shift+Enter negotiation 2026-09-02 16:01:10 +08:00
陈大猫 a1fce3ad7c Merge pull request #3238 from ice616/feat/auto-launch-at-login
feat(settings): 支持开机自启动并最小化到系统托盘
2026-09-02 13:30:55 +08:00
陈大猫 7db5a21e3e Merge pull request #3241 from binaricat/fix/sftp-sudo-i-drag-and-reconnect
fix: elevate /root drops after sudo -i
2026-09-02 13:12:59 +08:00
bincxz 10f1c02c5c fix(sftp): use resolved identity username for /root elevation
Keychain identities can change after the host copied username. Use the
same resolveHostAuth username as sudo autofill so a stale host.username
does not skip or force elevation.
2026-09-02 11:58:09 +08:00
bincxz ffb4d94aec fix(sftp): use resolved identity passwords for /root drops
Keychain identities clear host.password. Reuse the same host-auth
resolution as sudo autofill so those saved identity passwords can
elevate a terminal drop into /root.
2026-09-02 11:53:00 +08:00
bincxz b818db5f0c fix(sftp): elevate /root drops and allow sudo reconnect fallback
After sudo -i, terminal drag into /root uses the host's saved password for
SFTP sudo. If no usable password is saved, the drop fails with guidance to
enable host Sudo elevation or return to the user directory.

Automatic SFTP reconnect still prefers the originating session, then falls
through to a fresh open so a dead channel after sudo -i can re-elevate.
2026-09-02 11:46:27 +08:00
陈大猫 ce1cdab559 Merge pull request #3239 from binaricat/codex/fix-sftp-drag-drop-routes
fix: make terminal drag uploads route-safe
2026-09-02 11:37:37 +08:00
bincxz d79643d31c fix(sftp): wait for origin focus before drop cancel or bind
Do not treat a lagging split-pane focus as the user leaving the drop
origin, and do not bind a Mosh/ET drop to the still-focused SSH session.
First-open follow treats a missing SSH id as bound to the live focused
terminal so a probe from A cannot apply after focus moves to B.
2026-09-02 11:17:22 +08:00
bincxz 4bb8a224b6 fix(sftp): reject follow probes after focused session changes
A follow sync that started probing session A must not navigate after
focus moves to session B. Capture the expected session before the
await and reuse the same guard already used for go-to-terminal-cwd.
2026-09-02 10:24:49 +08:00
netcatty-bot 8788803fb5 fix: address Codex review on PR #3239 2026-09-01 15:49:34 +00:00
bincxz a7342ff7b8 fix(sftp): drop extra drag-upload routing machinery
Keep confirmed active-shell destinations, originating-session bind, sudo
fidelity, and the longer ZMODEM start wait. Remove the dormant queue,
routeSessionId identity, and source-regex contracts that only locked
that extra routing in.
2026-09-01 22:54:16 +08:00
bincxz e5f2519433 fix(sftp): preserve queued upload routes 2026-09-01 22:33:00 +08:00
netcatty-bot 6e56fe5a8d fix: address Codex review on PR #3239 2026-09-01 13:58:44 +00:00
netcatty-bot 2c35040e79 fix: address Codex review on PR #3239 2026-09-01 13:35:51 +00:00
netcatty-bot 55ceaa3290 fix: address Codex review on PR #3239 2026-09-01 13:13:23 +00:00
netcatty-bot 74abfe28ef fix: address Codex review on PR #3239 2026-09-01 12:48:51 +00:00
axe006andClaude Sonnet 5 d92406a9fd fix(auto-launch): 补齐跨窗口同步、修正连续切换的陈旧响应竞态
针对 PR #3238 的 2 条 Codex review 意见修复:

- application/state/settingsIpcSync.ts / useSettingsState.ts:新增
  STORAGE_KEY_AUTO_LAUNCH_ENABLED 的跨窗口广播处理。AppLockGate 会
  在每个窗口(主窗口、托盘面板、终端弹窗等)都挂载一份
  useSettingsState,即使开机自启动开关只在设置窗口的 UI 里展示和
  交互。此前该键缺失在 settingsIpcSync.ts 的广播处理里,其他窗口
  收到设置窗口推送的 notifySettingsChanged 广播后不会更新自己
  内存里的 autoLaunchEnabled,与项目里几乎所有其它布尔类设置的
  跨窗口同步约定不一致。新增结构断言测试验证键处理与端到端接线
  都已补齐。

- application/state/systemSettingsEffects.ts:新增
  autoLaunchWriteGenerationRef,推送 effect 的写入响应只在"没有
  更新的写入请求已经发起"时才被采纳。此前连续快速切换开关会启动
  多个并发的 bridge.setAutoLaunch 调用,每个回调只跟自己请求时的
  闭包值比较,不知道是否已经有更晚的请求覆盖了它——一个陈旧请求
  的延迟响应(比如报告了与自己请求不符的状态)可能在更新的请求
  已经成功落地之后才到达,用过时的"纠正"把已经确认生效的最新
  状态覆盖掉。新增单测覆盖乱序到达、连续三次切换等场景。

新增/更新单测覆盖上述两个场景,199 项相关测试通过。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-01 20:12:36 +08:00
bincxz ccacb69bb9 fix(sftp): bind terminal drops to active route 2026-09-01 19:58:55 +08:00
axe006andClaude Sonnet 5 7c419f41de fix(auto-launch): 修正写入失败回滚、挂载竞态、隐藏启动焦点竞态
针对 PR #3238 的 3 条 Codex review 意见修复:

- electron/autoLaunch.cjs:setAutoLaunchEnabled 写入失败时,success
  字段改为反映"回退读取本身是否成功"(即返回的 enabled 值是否可信),
  而不是"写入操作是否成功",与 getAutoLaunchEnabled 已有的语义保持
  一致。此前写入失败一律标记 success:false,导致渲染进程的推送
  effect 直接丢弃回退读取到的真实状态(哪怕这次读取本身成功),
  乐观开关状态停留在"已开启",即使操作系统层面从未真正创建
  登录项。同时修正未支持平台分支的 success 语义(enabled:false
  是确定事实,不是未知状态),与 getAutoLaunchEnabled 对齐。

- application/state/systemSettingsEffects.ts:新增
  autoLaunchWriteStartedRef,挂载时的水合请求还在等待期间,如果
  用户手动切换了开关(触发推送 effect 发起真实写入),水合的
  陈旧响应落地时会跳过应用,不再覆盖用户刚做的选择——此前会用
  这个陈旧值覆盖用户状态,并连锁触发推送 effect 把陈旧值写回
  操作系统,实质上撤销用户刚成功完成的操作。

- electron/main.cjs:second-instance(含 open-terminal-path 无效
  路径兜底分支)与 activate(macOS Dock 重新打开/点击)三处调用
  createAndShowMainWindow() 后新增 focusMainWindow() 跟随调用。
  consumeColdStartHiddenLaunch 是一次性锁存标志,谁先调用
  createAndShowMainWindow() 谁就消费掉它——app.whenReady() 之后、
  默认冷启动流程真正调用 createAndShowMainWindow() 之前还有若干
  await(如应用锁设置加载),期间到达的真实第二实例或 Dock 重新
  打开事件可能抢先消费隐藏标志,创建一个隐藏窗口且没有后续动作
  让它显示,导致用户显式的重新打开操作看起来毫无反应。深链接和
  打开终端路径的四处调用点已经在创建后无条件调用 focusMainWindow()
  (因此不受影响),这次是给另外三处缺失该调用的地方补上。

新增/更新单测覆盖上述三个场景,194 项相关测试通过。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-01 19:49:27 +08:00
axe006andClaude Sonnet 5 151364ce4b fix(auto-launch): 修正 macOS 待审批状态误触发禁用与 Linux 文案
针对 PR #3238 的 2 条 Codex review 意见修复:

- electron/autoLaunch.cjs:resolveEffectiveLoginState 新增对 macOS
  status: "requires-approval" 的处理。macOS 13+ 用 SMAppService
  注册登录项后,用户在"系统设置"里批准前会处于待审批状态,此时
  openAtLogin 报告 false,即使注册本身已经成功。此前代码把这个
  false 当作真实状态返回给渲染进程,推送 effect 一看到与用户刚
  请求的值不一致就会立刻发起相反的写入,在用户还没来得及批准前
  就把这个待审批的登录项注销掉。现在待审批状态统一视为 enabled,
  与用户请求保持一致,后续读取会在用户批准或系统丢弃待审批请求后
  自然反映真实状态。

- i18n(en/zh-CN/zh-TW/ru):unsupportedDesc 文案此前只提到"开发
  环境下不支持",但 isAutoLaunchSupported 在打包后的 Linux 构建
  (AppImage/deb/rpm/pacman)上同样会返回 false,对 Linux 用户
  显示这句话是错误的。改为同时覆盖平台不支持和开发环境两种情况
  的中性表述。

新增单测覆盖 macOS 待审批场景,189 项相关测试通过。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-01 19:19:15 +08:00
axe006andClaude Sonnet 5 e6df190b98 fix(auto-launch): 隐藏启动的崩溃保护与读取失败状态误覆盖问题
针对 PR #3238 的 2 条 Codex review 意见修复:

- electron/main.cjs:隐藏启动成功后不再传 windowShown: !startHidden,
  改为固定传 true。processErrorGuards.cjs 的 hasShownMainWindow 是
  一次性锁存标志,只要传 false 就会让运行时保护在整个进程生命周期
  内永远处于"启动期严格模式"——任何非网络类未捕获异常都会被判定
  为致命错误并可能杀掉进程。隐藏启动时 createWindow() 已经成功
  创建窗口并加载完页面,是一次完整的启动,不是失败,理应解除
  严格模式。新增结构断言测试覆盖成功/失败两条分支。

- electron/autoLaunch.cjs:getAutoLaunchEnabled 读取失败时新增
  success: false 字段,区分"确认关闭"与"状态未知"。此前失败时
  返回的 { enabled: false, supported: true } 与正常读到关闭状态
  完全无法区分,渲染进程挂载时读取一次失败就会把本地缓存的
  true 覆盖成 false,并触发相邻的推送 effect 把这个"假关闭"
  写回操作系统,可能误删一个原本有效的登录项。

- application/state/systemSettingsEffects.ts:挂载水合与推送两个
  effect 都改为通过新导出的纯函数 isAutoLaunchResultTrustworthy
  判断,success:false 时保留原状态、不发起写入。该函数已用普通
  单测覆盖(react-test-renderer 在当前环境未安装,无法测试完整
  hook 生命周期,故将判断逻辑抽成不依赖 React 的纯函数单独验证)。

新增/更新单测覆盖上述场景,177 项相关测试通过。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-01 19:01:30 +08:00
axe006andClaude Sonnet 5 e5c750398d fix(auto-launch): 修正 macOS 隐藏启动检测与 Windows 有效状态判定
针对 PR #3238 的 2 条 Codex review 意见修复,均已用仓库内绑定的
node_modules/electron/electron.d.ts 核实:

- wasLaunchedHidden 的 macOS 分支改用 wasOpenedAtLogin:openAsHidden/
  wasOpenedAsHidden 在 Electron 类型声明里标注为 @deprecated 且
  "does not work on macOS 13 and up",继续用它会导致 macOS 13+ 上
  真实的开机自启动被误判为非隐藏启动,窗口被错误显示。
  wasOpenedAtLogin 未被标记弃用,且 Netcatty 只在"开机自启动并
  最小化"这一个场景下才会注册 macOS 登录项,因此把开机启动本身
  当作隐藏启动的判定依据是安全的。

- resolveEffectiveLoginState 不再使用 executableWillLaunchAtLogin:
  该字段按文档明确会忽略 args,只要该可执行文件以*任意*参数被
  设置为登录启动就返回 true,与 buildLoginItemQueryOptions 按
  path+args 精确查询的语义相悖,会被同一程序的其他登录项注册
  (如历史遗留的无参数项)污染成假阳性。改为在 launchItems 数组
  里查找 path+args 完全匹配的那一条目,读取其 enabled 字段
  (该字段按文档明确是针对具体条目、反映任务管理器"启动应用"
  开关状态的)。

新增/更新单测覆盖上述场景,155 项相关测试通过。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-01 18:47:54 +08:00
axe006andClaude Sonnet 5 d4bfe02dc4 fix(auto-launch): 查询登录项状态时带上注册时的 path/args
针对 PR #3238 的 Codex review 意见修复:getAutoLaunchEnabled 和
setAutoLaunchEnabled 写入后的校验查询此前调用
app.getLoginItemSettings() 不带任何参数,而按 Electron 的行为
约定,openAtLogin/executableWillLaunchAtLogin 只针对查询时传入的
同一个 path+args 组合生效。写入时始终使用 path: execPath、
args: ['--hidden'](开启时),查询时不带参数会查到不匹配的登录项,
导致 Windows 上开启开关后立刻读回 enabled: false,UI 又把开关
弹回关闭状态。

新增 buildLoginItemQueryOptions 统一查询参数构造,getAutoLaunchEnabled
和 setAutoLaunchEnabled 的验证查询均已接入。新增/更新单测覆盖,
152 项相关测试通过。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-01 18:39:32 +08:00
axe006andClaude Sonnet 5 ee47c9e15c fix(auto-launch): 修复隐藏启动托盘保活、macOS 检测与跨平台支持
针对 PR #3238 的 4 条 Codex review 意见逐一修复:

- 隐藏启动时新增 pinTrayForHiddenLaunch/releaseHiddenLaunchTrayPin
  机制:托盘在窗口首次显示前始终保持存在,不再被渲染进程挂载时
  下发的 close-to-tray=false 提前销毁,避免开机自启+关闭托盘最小化
  组合下出现无窗口无托盘的僵尸进程。窗口首次 show 后自动释放钉住,
  交还给用户的正常 close-to-tray 偏好。
- wasLaunchedHidden 补充 macOS 检测:--hidden 启动参数只在 Windows
  生效(openAsHidden 是 macOS 专属设置,不会体现在 argv 里),macOS
  上改为读取 app.getLoginItemSettings().wasOpenedAsHidden。
- isAutoLaunchSupported 收紧为仅 macOS/Windows:Linux 打包(AppImage/
  deb/rpm/pacman)没有对应的系统登录项钩子,setLoginItemSettings 在
  该平台上静默失效,之前会误报"支持"。
- 新增 resolveEffectiveLoginState:Windows 上以
  executableWillLaunchAtLogin(考虑任务管理器"启动应用"里被禁用的
  情况)作为真实生效状态,而非只看 openAtLogin(仅反映注册表项
  是否存在)。

新增/更新单测覆盖以上全部场景,共 149 项相关测试通过。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-01 18:35:21 +08:00
axe006 0068180204 Merge branch 'main' of https://github.com/binaricat/Netcatty into feat/auto-launch-at-login 2026-09-01 18:24:53 +08:00
axe006andClaude Sonnet 5 513b2d66af feat(settings): 支持开机自启动并最小化到系统托盘
在「设置 → 系统 → 网络代理」卡片上方新增开机自启动开关,开启后
系统启动时自动运行 Netcatty,并隐藏到系统托盘(不占用任务栏)。

- 新增 electron/autoLaunch.cjs 封装 app.setLoginItemSettings,
  通过 --hidden 启动参数标记本次是登录项冷启动;开发环境
  (process.defaultApp)下判定为不支持,避免误操作系统登录项。
- 主窗口首次冷启动读取该标志后跳过 win.show(),并在托管桥注册
  完成后强制创建托盘图标(不依赖用户是否开启「关闭时最小化到
  托盘」),确保隐藏窗口始终可被找到;后续任何窗口复用/深链接/
  托盘点击路径不受影响。
- 开关真实状态以主进程 app.getLoginItemSettings() 为准,设置页
  挂载时向主进程查询校准,而非只信任本地缓存。
- 新增单测覆盖 autoLaunch 纯逻辑及 startHidden 透传/托盘保底
  行为,过程中发现并修复一处因未走依赖注入直接 require 模块
  导致的隐患。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-01 18:16:42 +08:00
Netcatty-Botandnetcatty-bot 715487b2a0 fix(sftp): keep side panel directory and filter across terminal tab switches (#3232)
* fix(#3230): automated AI fix

* fix: address Codex review on PR #3232

* fix: address Codex review on PR #3232

* fix: address Codex review on PR #3232

* fix: address Codex review on PR #3232

* fix: address Codex review on PR #3232

* fix: address Codex review on PR #3232

* fix: address Codex review on PR #3232

* fix: address Codex review on PR #3232

* fix: address Codex review on PR #3232

* fix: address Codex review on PR #3232

---------

Co-authored-by: netcatty-bot <308658023+netcatty-bot@users.noreply.github.com>
2026-09-01 16:46:21 +08:00
陈大猫 8f34d2a31f fix(ssh): identify concurrent terminal joins (#3234) 2026-09-01 14:37:28 +08:00
陈大猫andnetcatty-bot fb034f08bb fix(sftp): keep bulk queues and large-file resume responsive (#3226)
* fix(sftp): keep bulk queues and large-file resume responsive

* fix(sftp): preserve resume compatibility and timeout cleanup

* test(sftp): verify fixture cleanup in fresh environments

* fix: address Codex review on PR #3226

* fix(sftp): remove blocking periodic history persistence

---------

Co-authored-by: netcatty-bot <308658023+netcatty-bot@users.noreply.github.com>
2026-08-31 19:27:41 +08:00
114 changed files with 8946 additions and 1222 deletions

No files matched your search

+6
View File
@@ -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.',
+3
View File
@@ -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)',
+3
View File
@@ -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)',
+6
View File
@@ -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.',
+3
View File
@@ -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)',
+6
View File
@@ -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 配置无关。',
+3
View File
@@ -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)',
+6
View File
@@ -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 設定無關。',
+3
View File
@@ -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',
);
});
+11
View File
@@ -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;
}
+458 -10
View File
@@ -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,
);
});
+538 -130
View File
@@ -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 };
}
+3 -7
View File
@@ -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;
};
+1 -16
View File
@@ -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);
});
+41 -18
View File
@@ -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,
});
},
[],
+3
View File
@@ -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);
+15 -1
View File
@@ -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";
}
+5 -1
View File
@@ -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);
+17 -2
View File
@@ -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;
}
}
+15
View File
@@ -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,
+34 -25
View File
@@ -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;
+69 -13
View File
@@ -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");
});
+3
View File
@@ -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
View File
@@ -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
View File
@@ -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 (
<>
+70 -30
View File
@@ -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
+4 -1
View File
@@ -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)",
});
});
+12 -4
View File
@@ -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");
}
-201
View File
@@ -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"]]);
+188
View File
@@ -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);
});
+117 -13
View File
@@ -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"));
}
};
+223 -55
View File
@@ -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;
};
+119 -1
View File
@@ -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" };
},
});
+59 -10
View File
@@ -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 {
+326 -5
View File
@@ -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 () => {
+3 -1
View File
@@ -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);
+1 -1
View File
@@ -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 -1
View File
@@ -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.
+2
View File
@@ -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';
}
+8
View File
@@ -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",
+107
View File
@@ -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,
);
});
+61
View File
@@ -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 };
}
@@ -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\)/);
});
+270
View File
@@ -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;
};
+195
View File
@@ -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,
};
+404
View File
@@ -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",
);
});
+38 -2
View File
@@ -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();
}
});
+11 -28
View File
@@ -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);
+11 -16
View File
@@ -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 = {
+25 -19
View File
@@ -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) => {
+85 -23
View File
@@ -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