Compare commits

..
103 changed files with 380 additions and 9313 deletions

No files matched your search

+10 -16
View File
@@ -9,12 +9,6 @@
<a href="https://netcatty.app"><strong>netcatty.app</strong></a>
</p>
<p align="center">
<a href="https://trendshift.io/repositories/18391?utm_source=trendshift-badge&amp;utm_medium=badge&amp;utm_campaign=badge-trendshift-18391" target="_blank" rel="noopener noreferrer">
<img src="https://trendshift.io/api/badge/trendshift/repositories/18391/daily?language=TypeScript" alt="binaricat/Netcatty | Trendshift" width="250" height="55"/>
</a>
</p>
<p align="center">
Electron、React、xterm.js で構築された機能豊富な SSH ワークスペース。<br/>
🔥 内蔵 AI Agent · 分割ターミナル · Vault ビュー · SFTP ワークフロー · カスタムテーマ — すべてが一つに。
@@ -46,7 +40,7 @@
---
<img width="3358" height="1984" alt="image" src="docs/assets/readme/overview.webp" />
<img width="3358" height="1984" alt="image" src="https://github.com/user-attachments/assets/e1302660-dece-46be-9857-1678a9b13d00" />
---
@@ -69,13 +63,13 @@
Catty Agent にサーバーの健全性チェックを依頼すると、適切なコマンドを実行し、出力を分析して数秒で明確なサマリーを提示します。
<img width="3302" height="1978" alt="image" src="docs/assets/readme/catty-single-host.webp" />
<img width="3302" height="1978" alt="image" src="https://github.com/user-attachments/assets/3ec30145-d226-4e26-834b-52ee75b5e205" />
#### 🚀 マルチホスト — Docker Swarm クラスターのセットアップ
1 つの会話で 2 台のサーバーをまたいで Docker Swarm クラスターをオーケストレーションする Catty Agent をご覧ください。初期化、トークン交換、ノード参加まですべてこなします — あなたは望む結果を伝えるだけ。
<img width="3294" height="1974" alt="image" src="docs/assets/readme/catty-multi-host.webp" />
<img width="3294" height="1974" alt="image" src="https://github.com/user-attachments/assets/ca17e857-6163-4067-a615-cacdfd5be4fc" />
---
@@ -156,34 +150,34 @@ Catty Agent にサーバーの健全性チェックを依頼すると、適切
メインウィンドウは、長時間の SSH 作業を前提に設計されています。セッション、ナビゲーション、主要ツールへ素早くアクセスできます。
<img width="3290" height="1988" alt="image" src="docs/assets/readme/main-window.webp" />
<img width="3290" height="1988" alt="image" src="https://github.com/user-attachments/assets/ab3afd3e-a819-46fc-9251-40acb592ba69" />
<img width="3324" height="1992" alt="image" src="docs/assets/readme/main-window-terminal.webp" />
<img width="3324" height="1992" alt="image" src="https://github.com/user-attachments/assets/7c7f6e7f-fb20-4574-8e7f-74397682b873" />
<a name="vault-ビュー"></a>
## Vault ビュー
作業に合わせて見え方を切り替え:グリッドで全体像、リストでスキャン、ツリーで整理と階層ナビゲーション。
<img width="3292" height="1984" alt="image" src="docs/assets/readme/vault-grid.webp" />
<img width="3292" height="1984" alt="image" src="https://github.com/user-attachments/assets/29642143-3fd6-4693-a30c-b7f656053ca3" />
<img width="3322" height="1998" alt="image" src="docs/assets/readme/vault-list.webp" />
<img width="3322" height="1998" alt="image" src="https://github.com/user-attachments/assets/6041efe2-607e-4314-838c-eba3db4c852c" />
<a name="モニタリングビュー"></a>
## モニタリングビュー
CPU、メモリ、ディスク、ネットワーク、レイテンシーを確認でき、対応する環境では GPU の詳細も表示されます。表示する列の選択、列幅や更新設定の調整ができ、監視対象のホストから直接ターミナルを開くこともできます。
<img width="3364" height="1982" alt="image" src="docs/assets/readme/monitoring.webp" />
<img width="3364" height="1982" alt="image" src="https://github.com/user-attachments/assets/d0f495b5-25c8-494f-bb9c-8c20b0a921b6" />
<img width="3362" height="1980" alt="image" src="docs/assets/readme/monitoring-columns.webp" />
<img width="3362" height="1980" alt="image" src="https://github.com/user-attachments/assets/245c234e-2b22-43f2-a4fd-8057d229c56f" />
<a name="分割ターミナル"></a>
## 分割ターミナル
分割ペインで複数のサーバー/タスクを同時に扱えます(例:デプロイ + ログ + 監視)。
<img width="1560" height="871" alt="split" src="docs/assets/readme/split-terminals.webp" />
<img width="1560" height="871" alt="split" src="https://github.com/user-attachments/assets/c93a8523-6256-4bb3-8b6e-d599831f2f9f" />
---
+10 -10
View File
@@ -46,7 +46,7 @@
---
<img width="3358" height="1984" alt="image" src="docs/assets/readme/overview.webp" />
<img width="3358" height="1984" alt="image" src="https://github.com/user-attachments/assets/e1302660-dece-46be-9857-1678a9b13d00" />
---
@@ -69,14 +69,14 @@
Ask Catty Agent to check a server's health, and it runs the right commands, analyzes the output, and gives you a clear summary — all in seconds.
<img width="3302" height="1978" alt="image" src="docs/assets/readme/catty-single-host.webp" />
<img width="3302" height="1978" alt="image" src="https://github.com/user-attachments/assets/3ec30145-d226-4e26-834b-52ee75b5e205" />
#### 🚀 Multi-Host — Docker Swarm Cluster Setup
Watch Catty Agent orchestrate a Docker Swarm cluster across two servers in one conversation. It handles the init, token exchange, and node joining — you just tell it what you want.
<img width="3294" height="1974" alt="image" src="docs/assets/readme/catty-multi-host.webp" />
<img width="3294" height="1974" alt="image" src="https://github.com/user-attachments/assets/ca17e857-6163-4067-a615-cacdfd5be4fc" />
---
@@ -159,10 +159,10 @@ If you regularly work with a fleet of servers, Netcatty is built for speed and f
The main window is designed for long-running SSH workflows: quick access to sessions, navigation, and core tools in one place.
<img width="3290" height="1988" alt="image" src="docs/assets/readme/main-window.webp" />
<img width="3290" height="1988" alt="image" src="https://github.com/user-attachments/assets/ab3afd3e-a819-46fc-9251-40acb592ba69" />
<img width="3324" height="1992" alt="image" src="docs/assets/readme/main-window-terminal.webp" />
<img width="3324" height="1992" alt="image" src="https://github.com/user-attachments/assets/7c7f6e7f-fb20-4574-8e7f-74397682b873" />
@@ -171,10 +171,10 @@ The main window is designed for long-running SSH workflows: quick access to sess
Organize and navigate your hosts using the view that best fits the moment: grid for overview, list for scanning, tree for structure.
<img width="3292" height="1984" alt="image" src="docs/assets/readme/vault-grid.webp" />
<img width="3292" height="1984" alt="image" src="https://github.com/user-attachments/assets/29642143-3fd6-4693-a30c-b7f656053ca3" />
<img width="3322" height="1998" alt="image" src="docs/assets/readme/vault-list.webp" />
<img width="3322" height="1998" alt="image" src="https://github.com/user-attachments/assets/6041efe2-607e-4314-838c-eba3db4c852c" />
@@ -183,9 +183,9 @@ Organize and navigate your hosts using the view that best fits the moment: grid
Inspect CPU, memory, disk, network, and latency readings, with GPU details where available. Choose visible columns, resize them, adjust refresh controls, and open a terminal directly from a monitored host.
<img width="3364" height="1982" alt="image" src="docs/assets/readme/monitoring.webp" />
<img width="3364" height="1982" alt="image" src="https://github.com/user-attachments/assets/d0f495b5-25c8-494f-bb9c-8c20b0a921b6" />
<img width="3362" height="1980" alt="image" src="docs/assets/readme/monitoring-columns.webp" />
<img width="3362" height="1980" alt="image" src="https://github.com/user-attachments/assets/245c234e-2b22-43f2-a4fd-8057d229c56f" />
@@ -195,7 +195,7 @@ Inspect CPU, memory, disk, network, and latency readings, with GPU details where
Split panes help you monitor multiple servers/services at the same time (deploy + logs + metrics) without juggling windows.
<img width="1560" height="871" alt="split" src="docs/assets/readme/split-terminals.webp" />
<img width="1560" height="871" alt="split" src="https://github.com/user-attachments/assets/c93a8523-6256-4bb3-8b6e-d599831f2f9f" />
---
+10 -16
View File
@@ -9,12 +9,6 @@
<a href="https://netcatty.app"><strong>netcatty.app</strong></a>
</p>
<p align="center">
<a href="https://trendshift.io/repositories/18391?utm_source=trendshift-badge&amp;utm_medium=badge&amp;utm_campaign=badge-trendshift-18391" target="_blank" rel="noopener noreferrer">
<img src="https://trendshift.io/api/badge/trendshift/repositories/18391/daily?language=TypeScript" alt="binaricat/Netcatty | Trendshift" width="250" height="55"/>
</a>
</p>
<p align="center">
一个基于 Electron、React 和 xterm.js 构建的功能丰富的 SSH 工作空间。<br/>
🔥 内置 AI Agent · 分屏终端 · Vault 多视图 · SFTP 工作流 · 自定义主题 —— 一应俱全。
@@ -46,7 +40,7 @@
---
<img width="3358" height="1984" alt="image" src="docs/assets/readme/overview.webp" />
<img width="3358" height="1984" alt="image" src="https://github.com/user-attachments/assets/e1302660-dece-46be-9857-1678a9b13d00" />
---
@@ -69,13 +63,13 @@
让 Catty Agent 检查服务器健康状态,它会自动运行合适的命令、分析输出并给出清晰总结 —— 几秒内完成。
<img width="3302" height="1978" alt="image" src="docs/assets/readme/catty-single-host.webp" />
<img width="3302" height="1978" alt="image" src="https://github.com/user-attachments/assets/3ec30145-d226-4e26-834b-52ee75b5e205" />
#### 🚀 多主机 —— Docker Swarm 集群搭建
看 Catty Agent 在一次对话中跨两台服务器编排 Docker Swarm 集群。它负责初始化、令牌交换和节点加入 —— 你只需说出想要的结果。
<img width="3294" height="1974" alt="image" src="docs/assets/readme/catty-multi-host.webp" />
<img width="3294" height="1974" alt="image" src="https://github.com/user-attachments/assets/ca17e857-6163-4067-a615-cacdfd5be4fc" />
---
@@ -156,34 +150,34 @@
主界面围绕长期 SSH 工作流设计:把会话、导航和常用工具集中到同一处,减少切换成本。
<img width="3290" height="1988" alt="image" src="docs/assets/readme/main-window.webp" />
<img width="3290" height="1988" alt="image" src="https://github.com/user-attachments/assets/ab3afd3e-a819-46fc-9251-40acb592ba69" />
<img width="3324" height="1992" alt="image" src="docs/assets/readme/main-window-terminal.webp" />
<img width="3324" height="1992" alt="image" src="https://github.com/user-attachments/assets/7c7f6e7f-fb20-4574-8e7f-74397682b873" />
<a name="vault-视图"></a>
## Vault 视图
用更适合当前任务的方式管理与浏览主机:网格看全局,列表做筛选,树形做整理与层级导航。
<img width="3292" height="1984" alt="image" src="docs/assets/readme/vault-grid.webp" />
<img width="3292" height="1984" alt="image" src="https://github.com/user-attachments/assets/29642143-3fd6-4693-a30c-b7f656053ca3" />
<img width="3322" height="1998" alt="image" src="docs/assets/readme/vault-list.webp" />
<img width="3322" height="1998" alt="image" src="https://github.com/user-attachments/assets/6041efe2-607e-4314-838c-eba3db4c852c" />
<a name="监控视图"></a>
## 监控视图
查看 CPU、内存、磁盘、网络和延迟数据;支持的环境还可显示 GPU 详情。你可以选择显示的列、调整列宽和刷新设置,并直接从受监控的主机打开终端。
<img width="3364" height="1982" alt="image" src="docs/assets/readme/monitoring.webp" />
<img width="3364" height="1982" alt="image" src="https://github.com/user-attachments/assets/d0f495b5-25c8-494f-bb9c-8c20b0a921b6" />
<img width="3362" height="1980" alt="image" src="docs/assets/readme/monitoring-columns.webp" />
<img width="3362" height="1980" alt="image" src="https://github.com/user-attachments/assets/245c234e-2b22-43f2-a4fd-8057d229c56f" />
<a name="分屏终端"></a>
## 分屏终端
分屏适合同时处理多个任务(例如部署 + 日志 + 排障),不用频繁切换窗口。
<img width="1560" height="871" alt="split" src="docs/assets/readme/split-terminals.webp" />
<img width="1560" height="871" alt="split" src="https://github.com/user-attachments/assets/c93a8523-6256-4bb3-8b6e-d599831f2f9f" />
---
+10 -16
View File
@@ -9,12 +9,6 @@
<a href="https://netcatty.app"><strong>netcatty.app</strong></a>
</p>
<p align="center">
<a href="https://trendshift.io/repositories/18391?utm_source=trendshift-badge&amp;utm_medium=badge&amp;utm_campaign=badge-trendshift-18391" target="_blank" rel="noopener noreferrer">
<img src="https://trendshift.io/api/badge/trendshift/repositories/18391/daily?language=TypeScript" alt="binaricat/Netcatty | Trendshift" width="250" height="55"/>
</a>
</p>
<p align="center">
以 Electron、React 與 xterm.js 打造,介面精緻、功能完整的 SSH 工作區。<br/>
🔥 內建 AI Agent · 分割終端 · Vault 檢視 · SFTP 工作流 · 自訂主題 —— 全部整合在一起。
@@ -46,7 +40,7 @@
---
<img width="3358" height="1984" alt="image" src="docs/assets/readme/overview.webp" />
<img width="3358" height="1984" alt="image" src="https://github.com/user-attachments/assets/e1302660-dece-46be-9857-1678a9b13d00" />
---
@@ -69,13 +63,13 @@
請 Catty Agent 檢查伺服器健康狀況,它會自動執行合適的指令、分析輸出,並在幾秒內給你一份清楚的總結。
<img width="3302" height="1978" alt="image" src="docs/assets/readme/catty-single-host.webp" />
<img width="3302" height="1978" alt="image" src="https://github.com/user-attachments/assets/3ec30145-d226-4e26-834b-52ee75b5e205" />
#### 🚀 多主機 —— 建立 Docker Swarm 叢集
看 Catty Agent 在一次對話裡,跨兩台伺服器編排出一個 Docker Swarm 叢集。初始化、權杖交換、節點加入都由它處理 —— 你只要說出你想要的結果。
<img width="3294" height="1974" alt="image" src="docs/assets/readme/catty-multi-host.webp" />
<img width="3294" height="1974" alt="image" src="https://github.com/user-attachments/assets/ca17e857-6163-4067-a615-cacdfd5be4fc" />
---
@@ -156,34 +150,34 @@
主視窗是為了長時間的 SSH 工作流程而設計:工作階段、導覽與常用工具都集中在同一個地方。
<img width="3290" height="1988" alt="image" src="docs/assets/readme/main-window.webp" />
<img width="3290" height="1988" alt="image" src="https://github.com/user-attachments/assets/ab3afd3e-a819-46fc-9251-40acb592ba69" />
<img width="3324" height="1992" alt="image" src="docs/assets/readme/main-window-terminal.webp" />
<img width="3324" height="1992" alt="image" src="https://github.com/user-attachments/assets/7c7f6e7f-fb20-4574-8e7f-74397682b873" />
<a name="vault-檢視"></a>
## Vault 檢視
用最適合當下情境的方式整理與瀏覽主機:網格看全局、清單快速掃視、樹狀理層級。
<img width="3292" height="1984" alt="image" src="docs/assets/readme/vault-grid.webp" />
<img width="3292" height="1984" alt="image" src="https://github.com/user-attachments/assets/29642143-3fd6-4693-a30c-b7f656053ca3" />
<img width="3322" height="1998" alt="image" src="docs/assets/readme/vault-list.webp" />
<img width="3322" height="1998" alt="image" src="https://github.com/user-attachments/assets/6041efe2-607e-4314-838c-eba3db4c852c" />
<a name="監控檢視"></a>
## 監控檢視
查看 CPU、記憶體、磁碟、網路與延遲資料;支援的環境還可顯示 GPU 詳情。你可以選擇顯示的欄位、調整欄寬與重新整理設定,並直接從受監控的主機開啟終端機。
<img width="3364" height="1982" alt="image" src="docs/assets/readme/monitoring.webp" />
<img width="3364" height="1982" alt="image" src="https://github.com/user-attachments/assets/d0f495b5-25c8-494f-bb9c-8c20b0a921b6" />
<img width="3362" height="1980" alt="image" src="docs/assets/readme/monitoring-columns.webp" />
<img width="3362" height="1980" alt="image" src="https://github.com/user-attachments/assets/245c234e-2b22-43f2-a4fd-8057d229c56f" />
<a name="分割終端"></a>
## 分割終端
分割窗格讓你同時盯著多台伺服器或多個服務(部署 + 日誌 + 監控指標),不用一直切視窗。
<img width="1560" height="871" alt="split" src="docs/assets/readme/split-terminals.webp" />
<img width="1560" height="871" alt="split" src="https://github.com/user-attachments/assets/c93a8523-6256-4bb3-8b6e-d599831f2f9f" />
---
@@ -1,87 +0,0 @@
import assert from "node:assert/strict";
import test from "node:test";
import React from "react";
import { act, create, type ReactTestRenderer } from "react-test-renderer";
import { useAppStartupEffects } from "./useAppStartupEffects.ts";
import { sftpTransferCenterStore } from "../state/sftpTransferCenterStore.ts";
import { netcattyBridge } from "../../infrastructure/services/netcattyBridge.ts";
for (const accept of [false, true]) {
test(`app Quit with an unfinished transfer: ${accept ? "confirm quits" : "declining reports \"transfers\" before resuming only transferring and queued tasks"}`, async (t) => {
const previous = Object.getOwnPropertyDescriptor(globalThis, "IS_REACT_ACT_ENVIRONMENT");
Object.defineProperty(globalThis, "IS_REACT_ACT_ENVIRONMENT", { configurable: true, value: true });
const g = globalThis as unknown as { window?: { confirm?: (m: string) => boolean } };
const hadWindow = "window" in g; const previousConfirm = g.window?.confirm;
if (!g.window) g.window = {};
g.window.confirm = () => accept;
let renderer: ReactTestRenderer | undefined;
let releaseResume!: () => void;
const resumeGate = new Promise<void>((resolve) => { releaseResume = resolve; });
t.after(async () => {
releaseResume();
await act(async () => renderer?.unmount());
if (hadWindow && g.window) g.window.confirm = previousConfirm; else delete g.window;
if (previous) Object.defineProperty(globalThis, "IS_REACT_ACT_ENVIRONMENT", previous);
else Reflect.deleteProperty(globalThis, "IS_REACT_ACT_ENVIRONMENT");
});
const calls: string[] = [];
const reports: Array<[boolean, string | undefined]> = [];
let resumeSettled = false;
let query!: () => Promise<void>;
t.mock.method(sftpTransferCenterStore, "getSnapshot", () => ({
tasks: [
{ id: "running", status: "transferring" },
{ id: "queued", status: "queued" },
{ id: "already-paused", status: "paused" },
{ id: "pausing", status: "pausing" },
{ id: "interrupted", status: "interrupted" },
{ id: "child", parentTaskId: "running", status: "transferring" },
],
}) as unknown as ReturnType<typeof sftpTransferCenterStore.getSnapshot>);
t.mock.method(sftpTransferCenterStore, "pause", async (id: string) => { calls.push(`pause:${id}`); });
t.mock.method(sftpTransferCenterStore, "resume", async (id: string) => {
calls.push(`resume:${id}`);
await resumeGate;
resumeSettled = true;
});
t.mock.method(netcattyBridge, "get", () => ({
onCheckDirtyEditors: (callback: typeof query) => { query = callback; return () => {}; },
reportDirtyEditorsResult: (dirty: boolean, reason?: string) => {
calls.push("report");
reports.push([dirty, reason]);
},
}));
function Probe() {
useAppStartupEffects({ enabled: false, updateState: {}, sessions: [], hosts: [], keys: [], identities: [], knownHosts: [], proxyProfiles: [], groupConfigs: [], workspaces: [], portForwardingRules: [], t: (key: string) => key });
return null;
}
await act(async () => { renderer = create(React.createElement(Probe)); });
const paused = [
"pause:running",
"pause:queued",
"pause:already-paused",
"pause:pausing",
"pause:interrupted",
];
let timer: ReturnType<typeof setTimeout> | undefined;
try {
await Promise.race([
query(),
new Promise((_, reject) => {
timer = setTimeout(() => reject(new Error("dirty check waited on resume")), 1000);
}),
]);
} finally {
clearTimeout(timer);
}
assert.equal(resumeSettled, false, "the dirty-check reply must not wait for resume");
if (accept) {
assert.deepEqual(reports, [[false, undefined]]);
assert.deepEqual(calls, [...paused, "report"], "accepted quit leaves every prompted task paused");
} else {
assert.deepEqual(reports, [[true, "transfers"]], "main must be told the prompt was already answered");
assert.deepEqual(calls, [...paused, "report", "resume:running", "resume:queued"],
"report precedes resume, and only transferring/queued rows the prompt paused are resumed");
}
});
}
+2 -23
View File
@@ -356,51 +356,30 @@ export function useAppStartupEffects(ctx: StartupEffectsContext) {
// on a slow quit and then quitting anyway after the timeout is
// exactly the same outcome.
let hasDirty = false;
let reason: "editors" | "transfers" | undefined;
// Captured before pause. Only transferring/queued rows were active
// because of this prompt.
let tasksToResume: { id: string }[] = [];
try {
// Pending handoffs retain source contents; accepted handoffs clear them.
hasDirty = editorTabStore.getTabs().some((tab) => tab.content !== tab.baselineContent);
if (hasDirty) {
reason = "editors";
toast.warning(t('sftp.editor.quitBlockedByDirty'), 'SFTP');
}
if (hasDirty) toast.warning(t('sftp.editor.quitBlockedByDirty'), 'SFTP');
if (!hasDirty) {
const unfinishedTasks = sftpTransferCenterStore.getSnapshot().tasks.filter((task) => (
!task.parentTaskId && !["completed", "failed", "cancelled"].includes(task.status)
));
if (unfinishedTasks.length > 0) {
const resumable = unfinishedTasks.filter((task) => (
task.status === "transferring" || task.status === "queued"
));
await Promise.allSettled(unfinishedTasks.map((task) => sftpTransferCenterStore.pause(task.id)));
hasDirty = !window.confirm(t('sftp.transferCenter.quitConfirm', { count: unfinishedTasks.length }));
if (hasDirty) {
// The user chose to stay. Main must hear that before any resume:
// resume can outlive the 5s dirty-check timeout, and a late reply
// lets the app quit anyway. The synchronous dialog is also skipped
// because this prompt was already answered.
reason = "transfers";
tasksToResume = resumable;
}
}
}
} catch (err) {
console.error('[App] dirty-editors check failed:', err);
}
try {
bridge.reportDirtyEditorsResult?.(hasDirty, reason);
bridge.reportDirtyEditorsResult?.(hasDirty);
} catch (err) {
// Reporting itself shouldn't throw, but if the IPC bridge is in a
// bad state we'd rather log than bubble out of the listener and
// disable the quit guard for the rest of the session.
console.error('[App] reportDirtyEditorsResult failed:', err);
}
if (tasksToResume.length > 0) {
void Promise.allSettled(tasksToResume.map((task) => sftpTransferCenterStore.resume(task.id)));
}
});
return unsub;
}, [enabled, t]);
-8
View File
@@ -289,14 +289,6 @@ export const enAiMessages: Messages = {
'ai.opencode.customPathPlaceholder': 'e.g. /usr/local/bin/opencode',
'ai.opencode.check': 'Check',
'ai.opencode.resetPath': 'Reset',
'ai.opencode.v2.startupTimeout': 'OpenCode 2 did not become ready in time. Confirm `opencode serve` works in a terminal, then retry.',
'ai.opencode.v2.exited': 'OpenCode 2 stopped before the chat could start.',
'ai.opencode.v2.unauthorized': 'OpenCode 2 refused the connection. Retry the chat.',
'ai.opencode.v2.unavailable': 'OpenCode 2 could not be reached. Check that it is installed and try again.',
'ai.opencode.v2.sessionFailed': 'OpenCode 2 could not finish this turn.',
'ai.opencode.v2.empty': 'OpenCode 2 returned an empty response. Run `opencode` in a terminal to configure authentication and models.',
'ai.opencode.v2.contextSkipped': 'OpenCode 2 could not apply the Netcatty conversation context.',
'ai.opencode.v2.mcpSkipped': 'OpenCode 2 could not attach an MCP tool. The chat will continue without it.',
// AI MiMo Code (Xiaomi MiMo Code CLI — OpenCode fork)
'ai.mimo.title': 'MiMo Code',
-1
View File
@@ -1180,7 +1180,6 @@ Highlight the focused split pane:
'vault.hosts.moveSelected': 'Move to group ({count})',
'vault.hosts.moveSelected.description': 'Move the selected hosts to an existing group, or leave them ungrouped. Selected groups are not moved.',
'vault.hosts.moveMultiple.success': 'Moved {count} hosts to {group}',
'vault.hosts.dragSelectionCount': '{count} hosts',
'vault.hosts.moveToGroup.success': 'Moved {host} to {group}',
'vault.hosts.errors.nameRequired': 'Host name is required.',
'vault.hosts.empty.title': 'Set up your hosts',
-1
View File
@@ -1139,7 +1139,6 @@ Resalta el panel dividido enfocado:
'vault.hosts.moveSelected': 'Mover al grupo ({count})',
'vault.hosts.moveSelected.description': 'Mueve los hosts seleccionados a un grupo existente o los deja sin grupo. Los grupos seleccionados no se mueven.',
'vault.hosts.moveMultiple.success': 'Se movieron {count} hosts a {group}',
'vault.hosts.dragSelectionCount': '{count} hosts',
'vault.hosts.moveToGroup.success': 'Se movió {host} a {group}',
'vault.hosts.errors.nameRequired': 'Se requiere el nombre del host.',
'vault.hosts.empty.title': 'Configura tus hosts',
@@ -1,39 +0,0 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import en from './en';
import zhCN from './zh-CN';
import zhTW from './zh-TW';
const KEYS = [
'ai.opencode.v2.startupTimeout',
'ai.opencode.v2.exited',
'ai.opencode.v2.unauthorized',
'ai.opencode.v2.unavailable',
'ai.opencode.v2.sessionFailed',
'ai.opencode.v2.empty',
'ai.opencode.v2.contextSkipped',
'ai.opencode.v2.mcpSkipped',
];
test('OpenCode 2 strings exist in English, Simplified Chinese, and Taiwan Chinese', () => {
for (const key of KEYS) {
assert.equal(typeof en[key], 'string', `en missing ${key}`);
assert.equal(typeof zhCN[key], 'string', `zh-CN missing ${key}`);
assert.equal(typeof zhTW[key], 'string', `zh-TW missing ${key}`);
assert.notEqual(en[key], zhCN[key]);
assert.notEqual(en[key], zhTW[key]);
assert.notEqual(zhCN[key], zhTW[key]);
}
});
test('Taiwan OpenCode 2 wording uses Taiwan terms', () => {
const text = KEYS.map((key) => zhTW[key]).join('\n');
assert.match(text, /終端機/);
assert.match(text, /連線/);
assert.match(text, /設定/);
assert.match(text, /傳回/);
assert.equal(text.includes('终端'), false);
assert.equal(text.includes('连接'), false);
assert.equal(text.includes('设置'), false);
assert.equal(text.includes('返回'), false);
});
-1
View File
@@ -131,7 +131,6 @@ export const ruVaultMessages: Messages = {
'vault.hosts.moveSelected': 'Переместить в группу ({count})',
'vault.hosts.moveSelected.description': 'Переместить выбранные хосты в существующую группу или оставить без группы. Выбранные группы не перемещаются.',
'vault.hosts.moveMultiple.success': 'Перемещено {count} хостов в {group}',
'vault.hosts.dragSelectionCount': '{count} хостов',
'vault.hosts.moveToGroup.success': 'Хост {host} перемещён в {group}',
'vault.hosts.empty.title': 'Настройте свои хосты',
'vault.hosts.empty.desc': 'Сохраняйте хосты, чтобы быстро подключаться к серверам, виртуальным машинам и контейнерам.',
-8
View File
@@ -289,14 +289,6 @@ export const zhCNAiMessages: Messages = {
'ai.opencode.customPathPlaceholder': '例如 /usr/local/bin/opencode',
'ai.opencode.check': '检查',
'ai.opencode.resetPath': '重置',
'ai.opencode.v2.startupTimeout': 'OpenCode 2 未能及时就绪。请先在终端确认 `opencode serve` 可以启动,然后重试。',
'ai.opencode.v2.exited': 'OpenCode 2 在对话开始前已退出。',
'ai.opencode.v2.unauthorized': 'OpenCode 2 拒绝了连接。请重试这次对话。',
'ai.opencode.v2.unavailable': '无法连接到 OpenCode 2。请确认已安装后再试。',
'ai.opencode.v2.sessionFailed': 'OpenCode 2 未能完成这一轮对话。',
'ai.opencode.v2.empty': 'OpenCode 2 没有返回内容。请在终端运行 `opencode` 完成登录和模型配置。',
'ai.opencode.v2.contextSkipped': 'OpenCode 2 无法应用 Netcatty 的对话上下文。',
'ai.opencode.v2.mcpSkipped': 'OpenCode 2 无法接入 MCP 工具,对话会继续,但没有该工具。',
// AI MiMo Code(小米 MiMo Code CLI,OpenCode 分支)
'ai.mimo.title': 'MiMo Code',
-1
View File
@@ -785,7 +785,6 @@ Vaults 标签保持扁平,不使用这块背景。
'vault.hosts.moveSelected': '移动到分组 ({count})',
'vault.hosts.moveSelected.description': '将选中的主机移动到现有分组,也可移出分组。选中的分组本身不会移动。',
'vault.hosts.moveMultiple.success': '已将 {count} 个主机移动到 {group}',
'vault.hosts.dragSelectionCount': '{count} 个主机',
'vault.hosts.moveToGroup.success': '已将 {host} 移动到 {group}',
'vault.hosts.errors.nameRequired': '主机名称不能为空。',
'vault.hosts.empty.title': '设置你的主机',
-8
View File
@@ -280,14 +280,6 @@ export const zhTWAiMessages: Messages = {
'ai.opencode.customPathPlaceholder': '例如 /usr/local/bin/opencode',
'ai.opencode.check': '檢查',
'ai.opencode.resetPath': '重設',
'ai.opencode.v2.startupTimeout': 'OpenCode 2 未能及時就緒。請先在終端機確認 `opencode serve` 可以啟動,然後重試。',
'ai.opencode.v2.exited': 'OpenCode 2 在對話開始前已結束。',
'ai.opencode.v2.unauthorized': 'OpenCode 2 拒絕了連線。請重試這次對話。',
'ai.opencode.v2.unavailable': '無法連線到 OpenCode 2。請確認已安裝後再試。',
'ai.opencode.v2.sessionFailed': 'OpenCode 2 未能完成這一輪對話。',
'ai.opencode.v2.empty': 'OpenCode 2 沒有傳回內容。請在終端機執行 `opencode` 完成登入和模型設定。',
'ai.opencode.v2.contextSkipped': 'OpenCode 2 無法套用 Netcatty 的對話內容。',
'ai.opencode.v2.mcpSkipped': 'OpenCode 2 無法掛接 MCP 工具,對話會繼續,但沒有該工具。',
// AI MiMo Code(小米 MiMo Code CLI,OpenCode 分支)
'ai.mimo.title': 'MiMo Code',
-1
View File
@@ -787,7 +787,6 @@ Vaults 分頁維持扁平,不使用這塊背景。
'vault.hosts.moveSelected': '移動到群組 ({count})',
'vault.hosts.moveSelected.description': '將選取的主機移動到現有群組,也可移出群組。選取的群組本身不會移動。',
'vault.hosts.moveMultiple.success': '已將 {count} 個主機移動到 {group}',
'vault.hosts.dragSelectionCount': '{count} 個主機',
'vault.hosts.moveToGroup.success': '已將 {host} 移動到 {group}',
'vault.hosts.errors.nameRequired': '主機名稱不能為空。',
'vault.hosts.empty.title': '設定你的主機',
@@ -30,7 +30,6 @@ import {
isMissingDirectoryReplacePathError,
promoteDirectoryReplaceStage as promoteDirectoryReplacePaths,
} from "./directoryReplacePromotion";
import { unwrapSftpIpcError } from "./errors";
import { sftpTransferCenterStore } from "../sftpTransferCenterStore";
export interface DedicatedResumeDeps {
@@ -180,11 +179,9 @@ export async function openTransferSftpSession(
// Restart/resume owns an independent SSH dial. Normal pooled transfer opens
// leave reuseTransport enabled so the main-process registry can reuse an
// authenticated endpoint even when no terminal tab exists.
// No SFTP-page host-key dialog owns this session id. An untrusted key must
// fail the transfer immediately instead of sitting on the verifier TTL.
const openOpts = wantDedicated
? { ...credentials, reuseTransport: false as const, hostKeyUnattended: true as const }
: { ...credentials, hostKeyUnattended: true as const };
? { ...credentials, reuseTransport: false as const }
: credentials;
// The main-process auth driver owns method ordering, fallback, MFA and
// retry. A renderer-side key-then-password retry performs a second full SSH
@@ -743,7 +740,7 @@ async function resumeSingleFileWithDedicatedSession(
const err = error as Error & { dedicatedAttention?: boolean; resetCheckpoint?: boolean };
return {
success: false,
error: unwrapSftpIpcError(error),
error: err instanceof Error ? err.message : String(error),
needsAttention: !!err.dedicatedAttention,
resetCheckpoint: !!err.resetCheckpoint,
};
@@ -1,26 +0,0 @@
import assert from "node:assert/strict";
import test from "node:test";
import type { TransferTask } from "../../../domain/models";
import { canRetryFromCheckpoint } from "./retryFromCheckpoint";
const failed = (over: Partial<TransferTask> = {}): TransferTask => ({
id: "t", fileName: "big.bin", sourcePath: "/l/big.bin", targetPath: "/r/big.bin",
sourceConnectionId: "local", targetConnectionId: "remote", direction: "upload",
status: "failed", totalBytes: 100, transferredBytes: 40, checkpointBytes: 40,
speed: 0, startTime: 1, isDirectory: false, error: "SFTP channel closed", ...over,
});
test("canRetryFromCheckpoint only for a top-level file that lost its connection with progress", () => {
assert.equal(canRetryFromCheckpoint(failed()), true);
assert.equal(canRetryFromCheckpoint(failed({ error: "read ECONNRESET" })), true);
assert.equal(canRetryFromCheckpoint(failed({ checkpointBytes: 0 })), false, "nothing to resume");
assert.equal(canRetryFromCheckpoint(failed({ error: "Permission denied" })), false, "content errors restart");
assert.equal(canRetryFromCheckpoint(failed({ isDirectory: true })), false);
assert.equal(canRetryFromCheckpoint(failed({ parentTaskId: "dir" })), false);
assert.equal(canRetryFromCheckpoint(failed({ status: "cancelled" })), false);
assert.equal(canRetryFromCheckpoint(failed({ retryable: false })), false);
assert.equal(canRetryFromCheckpoint(failed({ direction: "download" })), false, "downloads keep the owner retry");
assert.equal(canRetryFromCheckpoint(failed({ direction: "remote-to-remote" })), false, "remote copies keep the owner retry");
assert.equal(canRetryFromCheckpoint(failed({ direction: "local-copy" })), false);
assert.equal(canRetryFromCheckpoint(undefined), false);
});
@@ -1,25 +0,0 @@
import type { TransferTask } from "../../../domain/models";
// Failures that only mean "the SSH/SFTP connection went away". The staged
// .part is intact up to the checkpoint, so Retry can continue from it instead
// of restarting at byte 0 (which also strands the old stage on the server,
// because the new attempt stages under a fresh transfer id).
const SESSION_LOSS_ERROR = /SFTP (channel closed|session lost)|ECONNRESET|EPIPE|Not connected|No response from server|Keepalive timeout|connection (lost|closed|reset)/i;
/**
* True when Retry should resume the same task from its checkpoint.
* Only local-to-remote uploads (`direction === "upload"`) do this. Downloads
* and remote copies have no unfingerprinted stage to keep, so they stay on
* the owner's fresh retry.
*/
export function canRetryFromCheckpoint(task: TransferTask | undefined): boolean {
if (!task) return false;
return task.status === "failed"
&& !task.isDirectory
&& !task.parentTaskId
&& task.retryable !== false
&& !task.conflict
&& task.direction === "upload"
&& (task.checkpointBytes ?? 0) > 0
&& SESSION_LOSS_ERROR.test(task.error ?? "");
}
@@ -334,111 +334,6 @@ test("live directory download rejects a Windows backslash traversal entry", asyn
}
});
test("a file batch that rejects mid-walk does not emit unhandledRejection", async () => {
const previousWindow = (globalThis as { window?: unknown }).window;
const previousLocalStorage = (globalThis as { localStorage?: unknown }).localStorage;
const previousActEnvironment = (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT;
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
const root = {
...rootTask(),
targetPath: "C:\\Users\\alice\\Downloads\\folder",
};
let tasks: TransferTask[] = [root];
const transfersRef = { current: tasks };
const setTransfers = (update: React.SetStateAction<TransferTask[]>) => {
tasks = typeof update === "function" ? update(tasks) : update;
transfersRef.current = tasks;
};
let releaseLater!: () => void;
const laterGate = new Promise<void>((resolve) => {
releaseLater = resolve;
});
let laterListingStarted!: () => void;
const laterListing = new Promise<void>((resolve) => {
laterListingStarted = resolve;
});
(globalThis as { window?: unknown }).window = {
netcatty: {
mkdirLocal: async () => undefined,
statLocal: async () => ({ type: "directory" }),
},
};
(globalThis as { localStorage?: unknown }).localStorage = {
getItem: () => null,
setItem: () => undefined,
removeItem: () => undefined,
};
let operations: ReturnType<typeof useSftpDirectoryTransferOps> | undefined;
let renderer: ReactTestRenderer | null = null;
const Probe = () => {
operations = useSftpDirectoryTransferOps({
ownerId: "owner",
cancelledTasksRef: { current: new Set() },
pausedTasksRef: { current: new Set() },
waitUntilTransferResumed: async () => undefined,
activeChildIdsRef: { current: new Map() },
transfersRef,
setTransfers,
listLocalFiles: async () => [],
listRemoteFiles: async (_sftpId: string, path: string) => {
if (path === "/source") return [directoryEntry("bad"), directoryEntry("later")];
if (path === "/source/bad") return [fileEntry("..\\outside.txt")];
laterListingStarted();
await laterGate;
return [];
},
});
return null;
};
const unhandled: unknown[] = [];
const onUnhandled = (reason: unknown) => {
unhandled.push(reason);
};
const rejectionText = (reason: unknown) => (
reason instanceof Error ? reason.message : String(reason)
);
process.on("unhandledRejection", onUnhandled);
let running: Promise<number> | undefined;
try {
await act(async () => {
renderer = create(React.createElement(Probe));
});
assert.ok(operations);
running = operations.transferDirectory(
root,
"source-sftp",
null,
false,
true,
"auto",
"auto",
root.id,
);
await laterListing;
// The bad directory's batch is already queued. End this turn while the
// sibling listing is still blocked so an unhandled rejection can surface.
await new Promise((resolve) => setImmediate(resolve));
assert.deepEqual(unhandled.map(rejectionText), []);
releaseLater();
await assert.rejects(running, /unsafe transfer path/i);
await new Promise((resolve) => setImmediate(resolve));
assert.deepEqual(unhandled.map(rejectionText), []);
} finally {
releaseLater();
await running?.catch(() => {});
process.off("unhandledRejection", onUnhandled);
await act(async () => {
renderer?.unmount();
});
(globalThis as { window?: unknown }).window = previousWindow;
(globalThis as { localStorage?: unknown }).localStorage = previousLocalStorage;
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = previousActEnvironment;
}
});
for (const ownerChange of ["same", "changed-active", "changed-completed"] as const) {
test(`live folder settles superseded ownership without clobbering its replacement: ${ownerChange}`, async () => {
const { sftpTransferCenterStore } = await import("../sftpTransferCenterStore");
+9 -94
View File
@@ -23,7 +23,6 @@ import {
DEFAULT_SFTP_DIRECTORY_LISTING_CONCURRENCY,
resolveSftpDirectoryListingConcurrency,
resolveSftpSkipUnchangedEnabled,
resolveSftpTransferConcurrency,
runSftpTransferWorkers,
} from "./transferConcurrency";
import { getSftpTransferResourceKeys, globalSftpTransferScheduler } from "./globalTransferScheduler";
@@ -79,30 +78,11 @@ function createDirectoryListingGate(concurrency = DEFAULT_SFTP_DIRECTORY_LISTING
};
}
/** Observed at queue time so a mid-walk rejection is not unhandled. */
type QueuedFileBatch =
| { ok: true; errors: number }
| { ok: false; error: unknown };
type DirectoryDiscoveryProgress = {
discoveredFiles: number;
nextEntryIndex: number;
/** Tree-wide semaphore so nested parallel walks cannot multiply listings. */
listingGate?: ReturnType<typeof createDirectoryListingGate>;
/**
* Tree-wide file-transfer gate (transfer concurrency setting). Each directory
* hands its files to workers that share this gate, and the walk does not wait
* for a directory's files before moving on, so there is no per-directory
* barrier: trees of many small directories keep N files in flight.
*/
fileGate?: ReturnType<typeof createDirectoryListingGate>;
/**
* Per-directory file batches. Each promise is settled when queued; the root
* walk inspects the outcomes after discovery.
*/
pendingFileBatches?: Promise<QueuedFileBatch>[];
/** First session loss seen by any batch; later batches stop claiming files. */
sessionLostError?: Error | null;
};
function isCancelledLocalOrGlobal(
@@ -547,7 +527,7 @@ export function useSftpDirectoryTransferOps({
};
/** Returns number of failed child file transfers */
const walkDirectory = async (
const transferDirectory = async (
task: TransferTask,
sourceSftpId: string | null,
targetSftpId: string | null,
@@ -561,8 +541,7 @@ export function useSftpDirectoryTransferOps({
followSymlinks = false, // Only true for downloadToLocal — uploads/copies treat symlinks as files
discoveryProgress?: DirectoryDiscoveryProgress,
traversalBudget?: SftpDirectoryTraversalBudget,
rootProgress?: DirectoryDiscoveryProgress,
): Promise<number> => {
) => {
// Check if task or root task was cancelled before starting
if (cancelledTasksRef.current.has(task.id) || cancelledTasksRef.current.has(rootTaskId)) {
throw new Error("Transfer cancelled");
@@ -573,12 +552,11 @@ export function useSftpDirectoryTransferOps({
// as soon as each directory is listed; compressed upload is a separate path.
// UI should treat totalBytes as "discovered so far", not a fixed grand total.
let totalErrors = 0;
const progress: DirectoryDiscoveryProgress = discoveryProgress ?? rootProgress ?? {
const progress = discoveryProgress ?? {
discoveredFiles: 0,
nextEntryIndex: 0,
listingGate: createDirectoryListingGate(resolveSftpDirectoryListingConcurrency()),
};
if (!progress.pendingFileBatches) progress.pendingFileBatches = [];
if (!progress.listingGate) {
progress.listingGate = createDirectoryListingGate(resolveSftpDirectoryListingConcurrency());
}
@@ -706,7 +684,7 @@ export function useSftpDirectoryTransferOps({
};
const isSymlink = dir.type === "symlink";
totalErrors += await walkDirectory(
totalErrors += await transferDirectory(
childTask,
sourceSftpId,
targetSftpId,
@@ -743,21 +721,11 @@ export function useSftpDirectoryTransferOps({
let sessionLostError: Error | null = null;
const directoryEntryBase = progress.nextEntryIndex;
progress.nextEntryIndex += regularFiles.length;
const directoryFiles = regularFiles;
if (!progress.fileGate) {
// Same setting runSftpTransferWorkers reads, created on first file.
progress.fileGate = createDirectoryListingGate(resolveSftpTransferConcurrency(
() => localStorageAdapter.readNumber(STORAGE_KEY_SFTP_TRANSFER_CONCURRENCY),
));
}
const fileGate = progress.fileGate;
const fileBatch = (async (): Promise<number> => {
await runSftpTransferWorkers(
directoryFiles,
regularFiles,
() => localStorageAdapter.readNumber(STORAGE_KEY_SFTP_TRANSFER_CONCURRENCY),
async (file, fileIndex) => fileGate.run(async () => {
if (!sessionLostError && progress.sessionLostError) sessionLostError = progress.sessionLostError;
async (file, fileIndex) => {
if (sessionLostError) throw sessionLostError;
if (cancelledTasksRef.current.has(task.id) || cancelledTasksRef.current.has(rootTaskId)) {
throw new Error("Transfer cancelled");
@@ -1025,7 +993,6 @@ export function useSftpDirectoryTransferOps({
);
if (isSessionError(err) && !sessionLostError) {
sessionLostError = err instanceof Error ? err : new Error(message);
progress.sessionLostError = progress.sessionLostError ?? sessionLostError;
// Fail remaining queued siblings quickly with a clear cause.
setTransfers((prev) => prev.map((t) => (
t.parentTaskId === rootTaskId
@@ -1048,10 +1015,10 @@ export function useSftpDirectoryTransferOps({
await waitWhileTransferPaused(rootTaskId);
}
}
}),
},
{
beforeClaim: async () => {
if (sessionLostError || progress.sessionLostError) return;
if (sessionLostError) return;
await waitWhileTransferPaused(rootTaskId);
},
},
@@ -1063,69 +1030,17 @@ export function useSftpDirectoryTransferOps({
throw err;
});
totalErrors += errors.length;
if (sessionLostError) {
logger.warn("[SFTP] Directory transfer stopped early: session lost", sessionLostError.message);
} else if (errors.length > 0) {
logger.debug?.("[SFTP] Some files in directory transfer failed", errors);
}
return errors.length;
})();
// Do not wait here: the walk continues into sibling directories while
// this directory's files drain through the shared fileGate. Settle the
// batch now so a failure during that walk (unsafe filename, unexpected
// worker error) is handled. The root drain still surfaces the error.
progress.pendingFileBatches.push(fileBatch.then(
(errors): QueuedFileBatch => ({ ok: true, errors }),
(error: unknown): QueuedFileBatch => ({ ok: false, error }),
));
}
return totalErrors;
};
const transferDirectory = async (
...args: Parameters<typeof walkDirectory>
): Promise<number> => {
const [, , , , , , , , , , , discoveryProgress, traversalBudget] = args;
if (discoveryProgress) return walkDirectory(...args);
// Root of a folder task: own the tree-wide progress so we can drain every
// directory's file batch, even when the walk itself throws, before the
// caller decides the folder's final state.
const rootProgress: DirectoryDiscoveryProgress = {
discoveredFiles: 0,
nextEntryIndex: 0,
listingGate: createDirectoryListingGate(resolveSftpDirectoryListingConcurrency()),
pendingFileBatches: [],
};
let walkErrors = 0;
let walkError: unknown = null;
try {
const [task, sourceSftpId, targetSftpId, sourceIsLocal, targetIsLocal, sourceEncoding,
targetEncoding, rootTaskId, sameHost, symlinkDepth, followSymlinks] = args;
walkErrors = await walkDirectory(
task, sourceSftpId, targetSftpId, sourceIsLocal, targetIsLocal, sourceEncoding,
targetEncoding, rootTaskId, sameHost, symlinkDepth, followSymlinks,
undefined, traversalBudget, rootProgress,
);
} catch (error) {
walkError = error;
}
let fileErrors = 0;
let batchError: unknown = null;
const batches = rootProgress.pendingFileBatches!;
for (let index = 0; index < batches.length; index += 1) {
const outcome = await batches[index];
if (outcome.ok) {
fileErrors += outcome.errors;
} else {
batchError = batchError ?? outcome.error;
}
}
if (walkError) throw walkError;
if (batchError) throw batchError;
return walkErrors + fileErrors;
};
return { transferFile, transferDirectory };
}
@@ -38,20 +38,6 @@ test("runWithTransferRetry does not retry permanent errors", async () => {
assert.equal(attempts, 1);
});
test("a dead SFTP channel is not retried into another dial (#3310)", async () => {
assert.equal(isTransientTransferError(new Error("SFTP channel closed")), false);
assert.equal(isTransientTransferError(new Error("SFTP session not found")), true);
let attempts = 0;
await assert.rejects(
() => runWithTransferRetry(async () => {
attempts += 1;
throw new Error("SFTP channel closed");
}, { retries: 1, delayMs: 0 }),
/SFTP channel closed/,
);
assert.equal(attempts, 1, "channel death must not schedule a reconnect");
});
test("runWithTransferRetry does not retry cancel", async () => {
let attempts = 0;
await assert.rejects(
-3
View File
@@ -6,9 +6,6 @@ import { isSessionError } from "./errors";
* path) should not be retried.
*/
export function isTransientTransferError(err: unknown): boolean {
// The channel is gone. Retrying opens a new dial, and an OTP host then
// sits on keyboard-interactive while the transfer stays active.
if (err instanceof Error && /sftp channel closed/i.test(err.message)) return false;
if (isSessionError(err)) return true;
if (!(err instanceof Error)) return false;
const msg = err.message.toLowerCase();
+3 -4
View File
@@ -4,7 +4,6 @@ import { netcattyBridge } from "../../../infrastructure/services/netcattyBridge"
import type { Host, Identity, KnownHost, SftpConnection, SftpFileEntry, SftpFilenameEncoding, SSHKey } from "../../../domain/models";
import { createKnownHostFromHostKeyInfo } from "../../../domain/knownHosts";
import type { SftpHostKeyInfo, SftpHostKeyVerificationState, SftpPane } from "./types";
import { unwrapSftpIpcError } from "./errors";
import { useSftpDirectoryListing } from "./useSftpDirectoryListing";
import { useSftpHostCredentials } from "./useSftpHostCredentials";
import { buildCacheKey, getSharedRemoteHostCache, setSharedRemoteHostCache } from "./sharedRemoteHostCache";
@@ -958,7 +957,7 @@ export const useSftpConnections = ({
if (!isTargetConnectionAtPath(startPath)) return;
updateTargetTab((prev) => ({
...prev,
error: unwrapSftpIpcError(err) || "Failed to list directory",
error: err instanceof Error ? err.message : "Failed to list directory",
loading: false,
reconnecting: false,
}));
@@ -1264,14 +1263,14 @@ export const useSftpConnections = ({
? {
...prev.connection,
status: "error",
error: unwrapSftpIpcError(err) || "Connection failed",
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"
: (unwrapSftpIpcError(err) || "Connection failed"),
: (err instanceof Error ? err.message : "Connection failed"),
loading: false,
reconnecting: false,
}));
@@ -1,226 +0,0 @@
/** @test lifecycle repaints coalesce to one frame and still settle the final status */
import assert from "node:assert/strict";
import test from "node:test";
import React from "react";
import { act, create, type ReactTestRenderer } from "react-test-renderer";
import type { TransferTask } from "../../../domain/models";
import { sftpTransferCenterStore } from "../sftpTransferCenterStore";
import { latchTransferPauseTree, releaseTransferPauseTree } from "./transferPauseLatch";
import { useSftpTransfers } from "./useSftpTransfers";
type Frame = { id: number; callback: (time: number) => void; cancelled: boolean };
function installFrameQueue() {
const previousWindow = (globalThis as { window?: unknown }).window;
const previousActFlag = (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT;
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
const frames: Frame[] = [];
let nextId = 1;
const requestAnimationFrame = (callback: (time: number) => void) => {
const frame: Frame = { id: nextId++, callback, cancelled: false };
frames.push(frame);
return frame.id;
};
const cancelAnimationFrame = (id: number) => {
const frame = frames.find((candidate) => candidate.id === id);
if (frame) frame.cancelled = true;
};
(globalThis as { window?: unknown }).window = {
netcatty: {},
requestAnimationFrame,
cancelAnimationFrame,
};
return {
frames,
flush: async () => {
const pending = frames.splice(0, frames.length).filter((frame) => !frame.cancelled);
await act(async () => {
for (const frame of pending) frame.callback(0);
});
return pending.length;
},
restore: () => {
(globalThis as { window?: unknown }).window = previousWindow;
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = previousActFlag;
},
};
}
function makeTask(id: string, status: TransferTask["status"], error?: string): TransferTask {
return {
id,
fileName: `${id}.txt`,
sourcePath: `/source/${id}.txt`,
targetPath: `/target/${id}.txt`,
sourceConnectionId: "local",
targetConnectionId: "remote",
direction: "upload",
status,
error,
totalBytes: 10,
transferredBytes: status === "completed" ? 10 : 2,
speed: status === "transferring" ? 1 : 0,
startTime: 1,
isDirectory: false,
resumable: true,
};
}
function mount(ownerId: string) {
let ops: ReturnType<typeof useSftpTransfers> | undefined;
let renderer: ReactTestRenderer | undefined;
function Probe() {
ops = useSftpTransfers({
ownerId,
getActivePane: () => null,
getPaneByConnectionId: () => null,
getTabByConnectionId: () => null,
updateTab: () => undefined,
refresh: async () => undefined,
clearCacheForConnection: () => undefined,
handleSessionError: () => undefined,
sftpSessionsRef: { current: new Map() },
connectionCacheKeyMapRef: { current: new Map() },
listLocalFiles: async () => [],
listRemoteFiles: async () => [],
});
return null;
}
return {
get ops() {
return ops;
},
render: async () => {
await act(async () => { renderer = create(React.createElement(Probe)); });
},
unmount: async () => {
await act(async () => { renderer?.unmount(); });
renderer = undefined;
},
};
}
test("lifecycle frames keep the final completion, pause, cancel, and error status", async () => {
const ownerId = "lifecycle-flush-final-status";
const queue = installFrameQueue();
const view = mount(ownerId);
const taskId = "lifecycle-row";
try {
await view.render();
const stages: Array<{ status: TransferTask["status"]; error?: string }> = [
{ status: "transferring" },
{ status: "paused" },
{ status: "cancelled" },
{ status: "failed", error: "connection reset" },
{ status: "completed" },
];
for (const stage of stages) {
await act(async () => {
sftpTransferCenterStore.publishOwner(ownerId, [makeTask(taskId, stage.status, stage.error)]);
});
const paintsNow = stage.status !== "transferring";
if (paintsNow) {
assert.equal(queue.frames.filter((frame) => !frame.cancelled).length, 0);
} else {
assert.notEqual(view.ops?.transfers.find((row) => row.id === taskId)?.status, stage.status);
assert.equal(queue.frames.filter((frame) => !frame.cancelled).length, 1);
assert.equal(await queue.flush(), 1);
}
const painted = view.ops?.transfers.find((row) => row.id === taskId);
assert.equal(painted?.status, stage.status);
assert.equal(painted?.error, stage.error);
assert.equal(
sftpTransferCenterStore.getOwnerTasks(ownerId).find((row) => row.id === taskId)?.status,
stage.status,
);
}
} finally {
sftpTransferCenterStore.dismiss(taskId);
await view.unmount();
queue.restore();
}
});
test("parent pause paints while a child frame is pending and a stale publish cannot reopen it", async () => {
const ownerId = "lifecycle-flush-parent-pause";
const queue = installFrameQueue();
const view = mount(ownerId);
const parentId = "folder-parent";
const childId = "folder-child";
const parent = (status: TransferTask["status"]): TransferTask => ({
...makeTask(parentId, status),
isDirectory: true,
progressMode: "files",
lifecycleEpoch: 4,
});
const child = (status: TransferTask["status"]): TransferTask => ({
...makeTask(childId, status),
parentTaskId: parentId,
lifecycleEpoch: 4,
});
try {
await view.render();
sftpTransferCenterStore.publishOwner(ownerId, [parent("transferring"), child("transferring")]);
assert.equal(view.ops?.transfers.length, 0, "child lifecycle churn stays on the pending frame");
assert.equal(queue.frames.filter((frame) => !frame.cancelled).length, 1);
// Pause lands while that frame is still queued. Latch afterwards: the
// control-epoch guard drops a panel publish that arrives already latched,
// and Resume is the equal-epoch republish that happens only after unlatch.
await act(async () => {
sftpTransferCenterStore.publishOwner(ownerId, [parent("paused"), child("paused")]);
});
assert.equal(view.ops?.transfers.find((row) => row.id === parentId)?.status, "paused");
assert.equal(view.ops?.transfers.find((row) => row.id === childId)?.status, "paused");
assert.equal(queue.frames.filter((frame) => !frame.cancelled).length, 0, "pause cancels the pre-pause frame");
latchTransferPauseTree(parentId, [childId]);
await act(async () => {
sftpTransferCenterStore.publishOwner(ownerId, [parent("transferring"), child("paused")]);
});
await queue.flush();
assert.equal(sftpTransferCenterStore.getTask(parentId)?.status, "paused");
assert.equal(sftpTransferCenterStore.getTask(childId)?.status, "paused");
const paintedParent = view.ops?.transfers.find((row) => row.id === parentId);
assert.equal(paintedParent?.status, "paused");
assert.equal(
view.ops?.transfers.filter((row) => (
!row.parentTaskId && ["pending", "queued", "transferring", "pausing"].includes(row.status)
)).length,
0,
);
} finally {
releaseTransferPauseTree(parentId, [childId]);
sftpTransferCenterStore.dismiss(parentId);
await view.unmount();
queue.restore();
}
});
test("unmount cancels the pending lifecycle frame and remount reads the settled store row", async () => {
const ownerId = "lifecycle-flush-unmount";
const queue = installFrameQueue();
const view = mount(ownerId);
const taskId = "lifecycle-unmount-row";
try {
await view.render();
sftpTransferCenterStore.publishOwner(ownerId, [makeTask(taskId, "failed", "write failed")]);
assert.equal(view.ops?.transfers.length, 0);
assert.equal(queue.frames.filter((frame) => !frame.cancelled).length, 1);
await view.unmount();
assert.equal(queue.frames.filter((frame) => !frame.cancelled).length, 0);
assert.equal(await queue.flush(), 0);
await view.render();
const painted = view.ops?.transfers.find((row) => row.id === taskId);
assert.equal(painted?.status, "failed");
assert.equal(painted?.error, "write failed");
assert.equal(queue.frames.filter((frame) => !frame.cancelled).length, 0);
} finally {
sftpTransferCenterStore.dismiss(taskId);
await view.unmount();
queue.restore();
}
});
@@ -56,166 +56,6 @@ test(`failed ${direction} connection replaces Waiting with the actual error (#36
});
}
test("upload open uses known hosts trusted after the transfer callback was created (#3310)", async () => {
const seen: string[] = [];
let fingerprint = "";
let ops: ReturnType<typeof useSftpTransfers> | undefined;
let renderer: ReactTestRenderer | undefined;
let firstStart: ReturnType<typeof useSftpTransfers>["startTransfer"] | undefined;
const restore = installGlobals({});
function Probe() {
const captured = fingerprint;
const acquireTransferSession = React.useCallback(async () => {
seen.push(captured);
throw new Error(`known-hosts:${captured || "empty"}`);
}, [captured]);
ops = useSftpTransfers({
ownerId: "known-hosts-after-trust-3310",
getActivePane: (side) => makePane(side === "left" ? "right" : "left"),
getPaneByConnectionId: () => null,
getTabByConnectionId: () => null,
updateTab: () => undefined,
refresh: async () => undefined,
clearCacheForConnection: () => undefined,
handleSessionError: () => undefined,
sftpSessionsRef: { current: new Map() },
connectionCacheKeyMapRef: { current: new Map() },
listLocalFiles: async () => [],
listRemoteFiles: async () => [],
acquireTransferSession,
});
if (!firstStart) firstStart = ops.startTransfer;
return null;
}
try {
await act(async () => { renderer = create(React.createElement(Probe)); });
fingerprint = "trusted-this-session";
await act(async () => { renderer?.update(React.createElement(Probe)); });
await act(async () => {
await firstStart!(
[{ name: "upload.txt", isDirectory: false }],
"left",
"right",
);
});
assert.deepEqual(seen, ["trusted-this-session"]);
const task = sftpTransferCenterStore.getOwnerTasks("known-hosts-after-trust-3310")[0];
assert.equal(task?.error, "known-hosts:trusted-this-session");
} finally {
await act(async () => { renderer?.unmount(); });
restore();
}
});
test("channel death fails the upload without a second session open (#3310)", async () => {
let acquires = 0;
let streams = 0;
const restore = installGlobals({
statLocal: async () => ({ size: 48, lastModified: 1 }),
statSftp: async () => null,
startStreamTransfer: async () => {
streams += 1;
return { error: "SFTP channel closed" };
},
pauseTransfer: async () => ({ success: false, reason: "Transfer is no longer active" }),
resumeTransfer: async () => ({ success: false, reason: "Transfer is no longer active" }),
});
let ops: ReturnType<typeof useSftpTransfers> | undefined;
let renderer: ReactTestRenderer | undefined;
const ownerId = "channel-death-no-reconnect-3310";
function Probe() {
ops = useSftpTransfers({
ownerId,
getActivePane: (side) => makePane(side === "left" ? "right" : "left"),
getPaneByConnectionId: () => null,
getTabByConnectionId: () => null,
updateTab: () => undefined,
refresh: async () => undefined,
clearCacheForConnection: () => undefined,
handleSessionError: () => undefined,
sftpSessionsRef: { current: new Map() },
connectionCacheKeyMapRef: { current: new Map() },
listLocalFiles: async () => [],
listRemoteFiles: async () => [],
acquireTransferSession: async () => {
acquires += 1;
return {
sftpId: "sftp-borrowed",
release: () => undefined,
discard: () => undefined,
};
},
});
return null;
}
try {
await act(async () => { renderer = create(React.createElement(Probe)); });
await act(async () => {
await ops!.startTransfer(
[{ name: "upload.txt", isDirectory: false }],
"left",
"right",
);
});
assert.equal(streams, 1, "channel death must not start another transfer dial");
assert.equal(acquires, 2, "work lease plus the file lease, and no retry open");
const task = sftpTransferCenterStore.getOwnerTasks(ownerId)[0];
assert.equal(task?.status, "failed");
assert.equal(task?.error, "SFTP channel closed");
assert.equal(
sftpTransferCenterStore.getOwnerTasks(ownerId)
.filter((row) => ["pending", "queued", "transferring", "pausing"].includes(row.status))
.length,
0,
);
} finally {
await act(async () => { renderer?.unmount(); });
restore();
}
});
test("transfer failure strips the Electron IPC wrapper (#3310)", async () => {
const restore = installGlobals({});
let ops: ReturnType<typeof useSftpTransfers> | undefined;
let renderer: ReactTestRenderer | undefined;
const ownerId = "ipc-prefix-3310";
function Probe() {
ops = useSftpTransfers({
ownerId,
getActivePane: (side) => makePane(side === "left" ? "right" : "left"),
getPaneByConnectionId: () => null,
getTabByConnectionId: () => null,
updateTab: () => undefined,
refresh: async () => undefined,
clearCacheForConnection: () => undefined,
handleSessionError: () => undefined,
sftpSessionsRef: { current: new Map() },
connectionCacheKeyMapRef: { current: new Map() },
listLocalFiles: async () => [],
listRemoteFiles: async () => [],
acquireTransferSession: async () => {
throw new Error("Error invoking remote method 'netcatty:sftp:open': Error: Host key verification required for 127.0.0.1");
},
});
return null;
}
try {
await act(async () => { renderer = create(React.createElement(Probe)); });
await act(async () => {
await ops!.startTransfer(
[{ name: "upload.txt", isDirectory: false }],
"left",
"right",
);
});
const task = sftpTransferCenterStore.getOwnerTasks(ownerId)[0];
assert.equal(task?.error, "Host key verification required for 127.0.0.1");
} finally {
await act(async () => { renderer?.unmount(); });
restore();
}
});
type StartOptions = {
transferId: string;
sourcePath: string;
@@ -440,9 +280,6 @@ test("stat-less remote source keeps the listing size in the overwrite dialog and
"left",
"right",
);
// Lifecycle paints coalesce to one frame. The plan lives on the ref
// immediately; React state catches up on the queued animation frame.
await new Promise((resolve) => setTimeout(resolve, 0));
});
assert.equal(started.length, 0, "an unresolved conflict must not start the transfer");
+29 -203
View File
@@ -24,7 +24,7 @@ import { netcattyBridge } from "../../../infrastructure/services/netcattyBridge"
import { logger } from "../../../lib/logger";
import { sftpTransferCenterStore } from "../sftpTransferCenterStore";
import { SftpPane } from "./types";
import { isMissingStatError, unwrapSftpIpcError } from "./errors";
import { isMissingStatError } from "./errors";
import { useSftpDirectoryTransferOps } from "./transferDirectoryOps";
import { useSftpTransferConflictOps } from "./transferConflictOps";
import { useSftpTransferTaskOps } from "./transferTaskOps";
@@ -34,7 +34,6 @@ import {
resolveDirectDirectoryDownloadFinalStatus,
} from "./downloadTransferTask";
import {
isTransferOrRootPauseLatched,
releaseTransferPauseTree,
waitUntilTransferPauseReleased,
} from "./transferPauseLatch";
@@ -159,89 +158,6 @@ export function finishTransferTask(
return status;
}
const STICKY_CONTROL_STATUSES = new Set<TransferStatus>([
"paused",
"pausing",
"cancelled",
"completed",
"failed",
"interrupted",
"attention",
]);
const LIVE_QUEUE_STATUSES = new Set<TransferStatus>([
"transferring",
"pending",
"queued",
]);
/**
* Pause, resume, and cancel paint on the same turn. Per-file complete/queue
* churn stays on the animation frame so a folder upload does not commit once
* per child.
*/
export function shouldPaintTransferControlNow(
previous: readonly TransferTask[],
next: readonly TransferTask[],
): boolean {
const prevById = new Map(previous.map((row) => [row.id, row]));
return next.some((row) => {
const prevRow = prevById.get(row.id);
if (prevRow?.status === row.status) return false;
if (row.status === "paused" || row.status === "pausing" || row.status === "cancelled") return true;
if (prevRow && (prevRow.status === "paused" || prevRow.status === "pausing" || prevRow.status === "cancelled")) {
return true;
}
if (
!row.parentTaskId
&& prevRow
&& (STICKY_CONTROL_STATUSES.has(row.status) || STICKY_CONTROL_STATUSES.has(prevRow.status))
) {
return true;
}
return false;
});
}
/**
* Child lifecycle updates clone the ref they saw. If that clone still says the
* folder is transferring after the store already paused it, publishing the
* clone reopens the parent. An explicit status edit (resume, retry) is kept.
*/
export function reconcilePanelRowsWithStore(
previous: readonly TransferTask[],
next: readonly TransferTask[],
storeRows: readonly TransferTask[],
): TransferTask[] {
const prevById = new Map(previous.map((row) => [row.id, row]));
const storeById = new Map(storeRows.map((row) => [row.id, row]));
let changed = false;
const reconciled = next.map((row) => {
const storeRow = storeById.get(row.id);
const prevRow = prevById.get(row.id);
if (!storeRow || !STICKY_CONTROL_STATUSES.has(storeRow.status) || !LIVE_QUEUE_STATUSES.has(row.status)) {
return row;
}
// A full-list write can disagree with the ref only because the ref already
// moved to the store's pause. That is a stale snapshot, not Resume.
// Resume releases the latch before it republishes transferring.
const rootId = row.parentTaskId ?? row.id;
const latchedReopen = isTransferOrRootPauseLatched(rootId, row.id);
if (prevRow && prevRow.status !== row.status && !latchedReopen) return row;
if (row.status === storeRow.status) return row;
changed = true;
return {
...row,
status: storeRow.status,
speed: 0,
error: storeRow.error,
endTime: storeRow.endTime,
pauseUnavailableReason: storeRow.pauseUnavailableReason,
};
});
return changed ? reconciled : next as TransferTask[];
}
/** User-facing path exclusivity notice (toast + logs). */
function notifyPathConflict(
existing: Pick<TransferTask, "fileName" | "status">,
@@ -275,21 +191,6 @@ export const useSftpTransfers = ({
const { t } = useI18n();
const tRef = useRef(t);
tRef.current = t;
// startTransfer keeps the processTransfer closure from its first render.
// Read the latest acquire fn so a host key trusted after that render is
// included in the transfer open, instead of the empty list from mount.
const acquireTransferSessionRef = useRef(acquireTransferSession);
acquireTransferSessionRef.current = acquireTransferSession;
const acquireTransferSessionLive = useCallback<NonNullable<UseSftpTransfersParams["acquireTransferSession"]>>(
(hostId, transferId, connectHost) => {
const acquire = acquireTransferSessionRef.current;
if (!acquire) {
return Promise.reject(new Error("SFTP transfer session acquisition is unavailable"));
}
return acquire(hostId, transferId, connectHost);
},
[],
);
// Ref so progress flushes always see the latest visibility without rebinding.
const surfaceVisibleRef = useRef(surfaceVisible);
surfaceVisibleRef.current = surfaceVisible;
@@ -321,85 +222,29 @@ export const useSftpTransfers = ({
const transfersRef = useRef(transfers);
const conflictsRef = useRef(conflicts);
conflictsRef.current = conflicts;
// When the retained panel is re-opened, catch up React state from the ref that
// kept receiving progress while the surface was hidden.
useEffect(() => {
if (!surfaceVisible) return;
setTransfersState(transfersRef.current);
}, [surfaceVisible]);
// Keep owner lifecycle changes in the global transfer store. Byte progress
// arrives independently through the unified process-wide event stream.
//
// Do not pre-assign transfersRef before publishing the same array reference —
// identity equality skips the store merge (avoids store↔listener loops).
// Lifecycle writers (child add/start/complete) fire several times per file.
// Repainting the whole SFTP view synchronously on each one saturates the
// renderer on folders with many small files, which then throttles the
// transfer itself. transfersRef/store stay authoritative; React state
// catches up once per frame, except pause/resume/cancel and folder terminal
// rows, which paint immediately.
//
// A frame queued for child churn must not commit a pre-pause ref after the
// store has already paused the parent. The flush re-reads the store, and a
// synchronous control paint bumps paintEpoch so that queued frame no-ops.
const stateFlushHandleRef = useRef<{ cancel: () => void } | null>(null);
const paintEpochRef = useRef(0);
const cancelScheduledTransferPaint = useCallback(() => {
stateFlushHandleRef.current?.cancel();
stateFlushHandleRef.current = null;
}, []);
const paintTransfersNow = useCallback((rows: TransferTask[]) => {
cancelScheduledTransferPaint();
paintEpochRef.current += 1;
transfersRef.current = rows;
setTransfersState(rows);
}, [cancelScheduledTransferPaint]);
const scheduleTransfersStateFlush = useCallback(() => {
if (stateFlushHandleRef.current) return;
const epochAtSchedule = paintEpochRef.current;
const flush = () => {
stateFlushHandleRef.current = null;
// A pause/resume/cancel/terminal paint landed after this frame was queued.
if (paintEpochRef.current !== epochAtSchedule) return;
const storeRows = sftpTransferCenterStore.getOwnerTasks(ownerId);
const painted = reconcilePanelRowsWithStore(transfersRef.current, transfersRef.current, storeRows);
paintEpochRef.current += 1;
transfersRef.current = painted;
setTransfersState(painted);
};
if (typeof window !== "undefined" && typeof window.requestAnimationFrame === "function") {
const id = window.requestAnimationFrame(flush);
stateFlushHandleRef.current = { cancel: () => window.cancelAnimationFrame(id) };
} else {
const id = setTimeout(flush, 16);
stateFlushHandleRef.current = { cancel: () => clearTimeout(id) };
}
}, [ownerId]);
useEffect(() => () => {
cancelScheduledTransferPaint();
}, [cancelScheduledTransferPaint]);
// When the retained panel is re-opened, catch up React state from the ref that
// kept receiving progress while the surface was hidden. Cancel any frame that
// was queued against the hidden snapshot so it cannot paint over this catch-up.
useEffect(() => {
if (!surfaceVisible) return;
paintTransfersNow(transfersRef.current);
}, [paintTransfersNow, surfaceVisible]);
const setTransfers = useCallback((update: SetStateAction<TransferTask[]>) => {
const prev = transfersRef.current;
const next = typeof update === "function"
? (update as (value: TransferTask[]) => TransferTask[])(prev)
: update;
if (next === prev) return;
const reconciled = reconcilePanelRowsWithStore(
prev,
next,
sftpTransferCenterStore.getOwnerTasks(ownerId),
);
if (reconciled === prev) return;
sftpTransferCenterStore.publishOwner(ownerId, reconciled);
sftpTransferCenterStore.publishOwner(ownerId, next);
const canonical = sftpTransferCenterStore.getOwnerTasks(ownerId);
if (shouldPaintTransferControlNow(prev, canonical)) {
paintTransfersNow(canonical);
return;
}
transfersRef.current = canonical;
scheduleTransfersStateFlush();
}, [ownerId, paintTransfersNow, scheduleTransfersStateFlush]);
setTransfersState(canonical);
}, [ownerId]);
// Coalesce progress-only patches to one paint per animation frame. Without
// this, each stream tick maps the full transfer list + publishOwner + store
@@ -431,19 +276,9 @@ export const useSftpTransfers = ({
const prevPending = pendingProgressByIdRef.current.get(taskId) || {};
pendingProgressByIdRef.current.set(taskId, { ...prevPending, ...updates });
// Keep ref hot for cancel/pause checks without waiting for paint.
// A byte tick that still says "transferring" must not reopen a latched pause.
transfersRef.current = transfersRef.current.map((row) => {
if (row.id !== taskId) return row;
const merged = { ...row, ...updates };
const latched = isTransferOrRootPauseLatched(row.parentTaskId ?? row.id, row.id);
if (
(latched || row.status === "paused" || row.status === "pausing")
&& (updates.status === "transferring" || updates.status === "pending" || updates.status === "queued")
) {
return { ...merged, status: row.status, speed: 0 };
}
return merged;
});
transfersRef.current = transfersRef.current.map((row) => (
row.id === taskId ? { ...row, ...updates } : row
));
scheduleProgressFlush();
}, [scheduleProgressFlush]);
useEffect(() => () => {
@@ -633,7 +468,7 @@ export const useSftpTransfers = ({
setTransfers,
listLocalFiles,
listRemoteFiles,
acquireTransferSession: acquireTransferSession ? acquireTransferSessionLive : undefined,
acquireTransferSession,
});
const processTransfer = async (
@@ -767,9 +602,8 @@ export const useSftpTransfers = ({
// survive browse park and tab hide (pool holders, not panel map ids).
if (!sourcePane.connection?.isLocal) {
const sourceHostId = task.sourceHostId || sourcePane.connection?.hostId;
const acquireSourceSession = acquireTransferSessionRef.current;
if (acquireSourceSession && sourceHostId) {
sourceWorkLease = await acquireSourceSession(sourceHostId, `${task.id}:work-source`);
if (acquireTransferSession && sourceHostId) {
sourceWorkLease = await acquireTransferSession(sourceHostId, `${task.id}:work-source`);
sourceSftpId = sourceWorkLease.sftpId;
}
if (!sourceSftpId) {
@@ -781,9 +615,8 @@ export const useSftpTransfers = ({
if (!targetPane.connection?.isLocal) {
const targetHostId = task.targetHostId || targetPane.connection?.hostId;
const acquireTargetSession = acquireTransferSessionRef.current;
if (acquireTargetSession && targetHostId) {
targetWorkLease = await acquireTargetSession(targetHostId, `${task.id}:work-target`);
if (acquireTransferSession && targetHostId) {
targetWorkLease = await acquireTransferSession(targetHostId, `${task.id}:work-target`);
targetSftpId = targetWorkLease.sftpId;
}
if (!targetSftpId) {
@@ -1222,9 +1055,8 @@ export const useSftpTransfers = ({
const next = transfersRef.current.map((candidate) => (
candidate.id === task.id ? canonicalTask : candidate
));
// Terminal rows used to setState directly and leave a child frame
// queued. That frame later painted the pre-terminal ref.
paintTransfersNow(next);
transfersRef.current = next;
setTransfersState(next);
},
);
@@ -1290,7 +1122,7 @@ export const useSftpTransfers = ({
updateTask({
status: "failed",
error: unwrapSftpIpcError(err) || "Transfer failed",
error: err instanceof Error ? err.message : "Transfer failed",
endTime: Date.now(),
speed: 0,
});
@@ -1675,8 +1507,8 @@ export const useSftpTransfers = ({
try {
const result = await retryExternalDragDropFileUpload(task, {
getBrowseSftpId: (connectionId) => sftpSessionsRef.current.get(connectionId),
acquireTransferSession: acquireTransferSessionRef.current
? (hostId, id) => acquireTransferSessionLive(hostId, id)
acquireTransferSession: acquireTransferSession
? (hostId, id) => acquireTransferSession(hostId, id)
: undefined,
startStreamTransfer: async (options) => {
const bridge = netcattyBridge.get();
@@ -2168,9 +2000,8 @@ export const useSftpTransfers = ({
let sourceWorkLease: TransferConnectionLease | null = null;
let workingSourceSftpId = params.sftpId;
try {
const acquireDownloadSession = acquireTransferSessionRef.current;
if (acquireDownloadSession && params.sourceHostId) {
sourceWorkLease = await acquireDownloadSession(
if (acquireTransferSession && params.sourceHostId) {
sourceWorkLease = await acquireTransferSession(
params.sourceHostId,
`${task.id}:work-source`,
);
@@ -2236,7 +2067,7 @@ export const useSftpTransfers = ({
const isCancelled = cancelledTasksRef.current.has(task.id);
// Clean up cancelled task tracking to prevent memory leak
if (isCancelled) cancelledTasksRef.current.delete(task.id);
const errMsg = unwrapSftpIpcError(err);
const errMsg = err instanceof Error ? err.message : String(err);
patchDownload({
status: isCancelled ? "cancelled" : "failed",
error: isCancelled ? undefined : errMsg,
@@ -2275,16 +2106,11 @@ export const useSftpTransfers = ({
// panel list; byte-only ticks update refs without repainting the whole panel.
useEffect(() => {
return sftpTransferCenterStore.subscribe(() => {
const previous = transfersRef.current;
const ownerRows = sftpTransferCenterStore.getSnapshot().tasks.filter((task) => task.ownerId === ownerId);
transfersRef.current = ownerRows;
if (shouldPaintTransferControlNow(previous, ownerRows)) {
paintTransfersNow(ownerRows);
return;
}
scheduleTransfersStateFlush();
setTransfersState(ownerRows);
});
}, [ownerId, paintTransfersNow, scheduleTransfersStateFlush]);
}, [ownerId]);
useEffect(() => sftpTransferCenterStore.subscribeProgress(() => {
const liveById = new Map(sftpTransferCenterStore.getSnapshot().tasks.map((task) => [task.id, task]));
@@ -97,85 +97,6 @@ test("background-agent retry after restart uses fresh dedicated recovery instead
assert.equal(store.getTask("background-retry")?.status, "completed");
});
for (const [label, error, expectResume] of [
["dead channel", "SFTP channel closed", true],
["lost session", "SFTP session lost. Please reconnect.", true],
["content failure", "Permission denied", false],
] as const) {
test(`panel Retry after ${label} ${expectResume ? "resumes the same task from its checkpoint" : "keeps the restart-from-0 retry"}`, async () => {
const controllerRetries: string[] = [];
const dedicatedTasks: TransferTask[] = [];
const store = createSftpTransferCenterStore();
store.registerOwner("panel-a", {
pause: async () => {},
resume: async () => {},
cancel: async () => {},
retry: async (id) => { controllerRetries.push(id); },
prioritize: async () => {},
dismiss: () => {},
});
store.setDedicatedResumeHandler(async (task) => {
dedicatedTasks.push(task);
return { success: true };
});
store.publishOwner("panel-a", [{
...makeTask("dropped", "failed"),
totalBytes: 100,
checkpointBytes: 37,
transferredBytes: 37,
error,
endTime: Date.now(),
}]);
await store.retry("dropped");
if (expectResume) {
assert.deepEqual(controllerRetries, [], "no fresh-id restart");
assert.equal(dedicatedTasks.length, 1);
assert.equal(dedicatedTasks[0]?.id, "dropped", "same id keeps the same staged .part");
assert.equal(dedicatedTasks[0]?.checkpointBytes, 37);
assert.equal(dedicatedTasks[0]?.reconnectRequired, true);
} else {
assert.deepEqual(controllerRetries, ["dropped"]);
assert.equal(dedicatedTasks.length, 0);
}
});
}
test("panel Retry after a dropped download or remote copy keeps the owner restart", async () => {
for (const direction of ["download", "remote-to-remote"] as const) {
const controllerRetries: string[] = [];
const dedicatedTasks: TransferTask[] = [];
const store = createSftpTransferCenterStore();
store.registerOwner("panel-a", {
pause: async () => {},
resume: async () => {},
cancel: async () => {},
retry: async (id) => { controllerRetries.push(id); },
prioritize: async () => {},
dismiss: () => {},
});
store.setDedicatedResumeHandler(async (task) => {
dedicatedTasks.push(task);
return { success: true };
});
store.publishOwner("panel-a", [{
...makeTask("dropped", "failed"),
direction,
totalBytes: 100,
checkpointBytes: 37,
transferredBytes: 37,
error: "SFTP channel closed",
endTime: Date.now(),
}]);
await store.retry("dropped");
assert.deepEqual(controllerRetries, ["dropped"], direction);
assert.equal(dedicatedTasks.length, 0, direction);
}
});
test("resume without an owner uses a live backend transfer session when available", async (t) => {
const resumeCalls: string[] = [];
const previousWindow = Object.getOwnPropertyDescriptor(globalThis, "window");
+3 -26
View File
@@ -42,7 +42,6 @@ import {
} from "./sftp/globalSftpTransferControl";
import { restoreSftpTransferHistoryCooperatively } from "./sftp/transferHistoryRestoreMigration";
import { cancelExternalUploadRuntime } from "./sftp/externalUploadRuntime";
import { canRetryFromCheckpoint } from "./sftp/retryFromCheckpoint";
type Listener = () => void;
type TaskSettlementObserver = {
@@ -366,13 +365,9 @@ export function mergeOwnerPublishedTask(
} else if (
existingPaused
&& (incoming.status === "transferring" || incoming.status === "pending" || incoming.status === "queued")
// A strictly older epoch is a stale snapshot. Equal epochs are how Resume
// republishes after the latch drops. While the pause latch is still held,
// that republish is a pre-pause panel snapshot and must not reopen the row.
&& (
incomingLifecycleEpoch < existingLifecycleEpoch
|| isTransferOrRootPauseLatched(existing.parentTaskId ?? existing.id, existing.id)
)
// Only a strictly older epoch is a stale snapshot. Equal/missing epochs must
// allow intentional Resume (publishOwner after unlatch) to re-open the row.
&& incomingLifecycleEpoch < existingLifecycleEpoch
) {
// Soft-drain / late progress publish must not un-pause without resume epoch.
merged.status = existing.status;
@@ -2057,24 +2052,6 @@ export function createSftpTransferCenterStore(persistence?: StorePersistence): S
const controller = ownerId === "background-agent"
? undefined
: controllers.get(ownerId ?? "");
// Live panel task whose connection dropped mid-file: the stage is valid
// up to the checkpoint. Continue through the reconnect-resume path a
// restored task uses (same id, so the same staged .part) instead of a
// fresh id from byte 0, which strands the old stage on the server.
// Orphaned rows (no live owner) keep the restart-from-0 contract below.
if (controller && canRetryFromCheckpoint(task)) {
tasks = tasks.map((candidate) => candidate.id === taskId ? {
...candidate,
status: "interrupted" as const,
error: undefined,
reconnectRequired: true,
speed: 0,
endTime: undefined,
} : candidate);
emit();
await this.resume(taskId);
return;
}
if (controller) {
await controller.retry(taskId);
return;
@@ -33,7 +33,6 @@ import {
filterVaultImportKeyPassphrasesAgainstExisting,
mergeVaultImportIssues,
resolveVaultImportKeyPassphraseConflicts,
toVaultImportExistingHostRef,
type VaultImportFormat,
} from "../../domain/vaultImport";
import type { GroupConfig, Host, ManagedSource, SSHKey } from "../../types";
@@ -176,7 +175,6 @@ export function useVaultImportHandlers({
files,
encoding: options?.encoding,
masterPassword: options?.masterPassword,
existingHosts: hostsRef.current.map(toVaultImportExistingHostRef),
signal,
onProgress: (progress) => {
if (!signal.aborted) updateProgress(progress);
-1
View File
@@ -235,7 +235,6 @@ test("HostTreeView exposes selected hosts to keyboard and assistive technology",
assert.match(markup, /data-host-id="host-1"[^>]*data-tree-depth="0"/);
assert.match(markup, /data-host-id="host-1"[^>]*aria-level="1"/);
assert.match(markup, /data-host-id="host-1"[^>]*tabindex="0"/);
assert.match(markup, /data-host-id="host-1"[^>]*draggable="true"/);
});
test("HostTreeView exposes only one tree item in the tab order", () => {
+6 -26
View File
@@ -17,7 +17,6 @@ import {
} from '../domain/hostClickBehavior';
import { sortByVaultOrder } from '../domain/vaultOrder';
import { getVaultHostRowSubtitle } from '../domain/localShellHost';
import { isVaultHostDraggable } from '../domain/vaultHostDrag';
import { STORAGE_KEY_VAULT_HOSTS_TREE_EXPANDED } from '../infrastructure/config/storageKeys';
import { GroupConfig, GroupNode, Host } from '../types';
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from './ui/collapsible';
@@ -26,8 +25,6 @@ import { ContextMenu, ContextMenuTrigger } from './ui/context-menu';
import { DistroAvatar } from './DistroAvatar';
import { HostNotesIndicator } from './host/HostNotesIndicator';
import { Button } from './ui/button';
import { handleVaultHostDropToGroup } from './vault/vaultReorderDrag';
import { beginVaultHostCardDrag } from './vault/vaultHostDragImage';
import { VaultTreeGroupRow, VaultTreeItemRow } from './vault/VaultTreeRow';
const getTreeGroupDropIntent = (
@@ -296,7 +293,6 @@ interface HostTreeViewProps {
onEditGroup: (groupPath: string) => void;
onDeleteGroup: (groupPath: string) => void;
moveHostToGroup: (hostId: string, groupPath: string | null) => void;
moveHostsToGroup?: (hostIds: ReadonlySet<string>, groupPath: string | null) => void;
moveGroup: (sourcePath: string, targetParent: string | null) => void;
commitInlineGroupRename?: (name: string) => boolean | void | Promise<boolean | void>;
cancelInlineGroupEdit?: () => void;
@@ -338,7 +334,6 @@ interface TreeNodeProps {
onEditGroup: (groupPath: string) => void;
onDeleteGroup: (groupPath: string) => void;
moveHostToGroup: (hostId: string, groupPath: string | null) => void;
moveHostsToGroup?: (hostIds: ReadonlySet<string>, groupPath: string | null) => void;
moveGroup: (sourcePath: string, targetParent: string | null) => void;
commitInlineGroupRename?: (name: string) => boolean | void | Promise<boolean | void>;
cancelInlineGroupEdit?: () => void;
@@ -386,7 +381,6 @@ const TreeNode: React.FC<TreeNodeProps> = ({
onEditGroup,
onDeleteGroup,
moveHostToGroup,
moveHostsToGroup,
moveGroup,
managedGroupPaths,
onUnmanageGroup,
@@ -532,14 +526,9 @@ const TreeNode: React.FC<TreeNodeProps> = ({
e.preventDefault();
e.stopPropagation();
setDragOverDropTarget?.(null);
if (handleVaultHostDropToGroup({
dataTransfer: e.dataTransfer,
groupPath: node.path,
moveHostToGroup,
moveHostsToGroup,
resetHostDragState: () => {},
})) return;
const hostId = e.dataTransfer.getData("host-id");
const groupPath = e.dataTransfer.getData("group-path");
if (hostId) moveHostToGroup(hostId, node.path);
if (groupPath && getTreeGroupDropIntent(e.currentTarget, e.clientY) === "inside") {
moveGroup(groupPath, node.path);
}
@@ -609,7 +598,6 @@ const TreeNode: React.FC<TreeNodeProps> = ({
onEditGroup={onEditGroup}
onDeleteGroup={onDeleteGroup}
moveHostToGroup={moveHostToGroup}
moveHostsToGroup={moveHostsToGroup}
moveGroup={moveGroup}
managedGroupPaths={managedGroupPaths}
onUnmanageGroup={onUnmanageGroup}
@@ -786,16 +774,11 @@ const HostTreeItem: React.FC<HostTreeItemProps> = ({
? 0
: -1}
onFocus={() => onActiveTreeItemChange(`host:${host.id}`)}
draggable={isVaultHostDraggable(Boolean(isMultiSelectMode), selectedHostIds, host.id)}
draggable={!isMultiSelectMode}
onDragStart={(e) => {
const ids = beginVaultHostCardDrag({
dataTransfer: e.dataTransfer,
hostId: host.id,
isMultiSelectMode: Boolean(isMultiSelectMode),
selectedHostIds,
countLabel: (count) => t("vault.hosts.dragSelectionCount", { count }),
});
if (!ids) e.preventDefault();
// copyMove: vault reorder uses move; focus-sidebar append uses copy.
e.dataTransfer.effectAllowed = "copyMove";
e.dataTransfer.setData("host-id", host.id);
}}
onClick={() => {
const action = resolveHostActivateAction({
@@ -926,7 +909,6 @@ const HostTreeViewInner: React.FC<HostTreeViewProps> = ({
onEditGroup,
onDeleteGroup,
moveHostToGroup,
moveHostsToGroup,
moveGroup,
managedGroupPaths,
onUnmanageGroup,
@@ -1237,7 +1219,6 @@ const HostTreeViewInner: React.FC<HostTreeViewProps> = ({
onEditGroup={onEditGroup}
onDeleteGroup={onDeleteGroup}
moveHostToGroup={moveHostToGroup}
moveHostsToGroup={moveHostsToGroup}
moveGroup={moveGroup}
managedGroupPaths={managedGroupPaths}
onUnmanageGroup={onUnmanageGroup}
@@ -1330,7 +1311,6 @@ function hostTreeViewAreEqual(prev: HostTreeViewProps, next: HostTreeViewProps):
&& prev.onEditGroup === next.onEditGroup
&& prev.onDeleteGroup === next.onDeleteGroup
&& prev.moveHostToGroup === next.moveHostToGroup
&& prev.moveHostsToGroup === next.moveHostsToGroup
&& prev.moveGroup === next.moveGroup
&& prev.commitInlineGroupRename === next.commitInlineGroupRename
&& prev.cancelInlineGroupEdit === next.cancelInlineGroupEdit
-9
View File
@@ -47,15 +47,6 @@ test("plugin importer merge and commit workflow is owned by application state",
assert.match(pluginImporterCommitHookSource, /buildPluginImporterSafePreview/);
});
test("host list section receives marquee selection and batched group move", () => {
const sectionStart = vaultViewLayoutSource.indexOf("<VaultHostListSection");
const sectionEnd = vaultViewLayoutSource.indexOf("/>", sectionStart);
const section = vaultViewLayoutSource.slice(sectionStart, sectionEnd);
assert.match(section, /\bmoveHostsToGroup,/);
assert.match(section, /\bsetIsMultiSelectMode,/);
assert.match(section, /\bsetSelectedHostIds,/);
});
test("Vault import entry is disabled while an import is running", () => {
assert.match(vaultViewLayoutSource, /disabled=\{importProgress\?\.status === "running"\}/);
assert.match(vaultViewLayoutSource, /if \(importProgress\?\.status === "running"\) return;/);
@@ -360,36 +360,6 @@ test("VaultHostListSection exposes normal group cards to keyboard", () => {
assert.match(markup, /data-group-path="production"[^>]*tabindex="0"/);
});
test("VaultHostListSection keeps selected host cards draggable and leaves unselected cards in place", () => {
const otherHost = makeHost("other-host", "Other Router");
const markup = renderHostList({
viewMode: "list",
displayedGroups: [],
displayedHosts: [mainHost, otherHost],
visibleDisplayedHosts: [mainHost, otherHost],
isMultiSelectMode: true,
selectedHostIds: new Set([mainHost.id]),
});
assert.match(markup, /data-host-id="main-host"[^>]*draggable="true"/);
assert.match(markup, /data-host-id="other-host"[^>]*draggable="false"/);
const source = readFileSync(new URL("./VaultHostListSection.tsx", import.meta.url), "utf8");
assert.match(source, /useVaultHostMarquee/);
assert.match(source, /data-vault-marquee/);
assert.match(source, /moveHostsToGroup/);
});
test("VaultHostListSection keeps a normal host card draggable outside multi-select", () => {
const markup = renderHostList({
viewMode: "grid",
displayedGroups: [],
displayedHosts: [mainHost],
visibleDisplayedHosts: [mainHost],
});
assert.match(markup, /data-host-id="main-host"[^>]*draggable="true"/);
});
test("VaultHostListSection exposes ungrouped hosts to keyboard and assistive technology", () => {
const markup = renderHostList({
viewMode: "list",
+18 -78
View File
@@ -27,10 +27,7 @@ import {
import type { GroupNode, Host } from "../../domain/models";
import { isPluginHostProtocol } from "../../domain/pluginConnection";
import { OpenDualPaneSftpMenuItem, OpenHostBrowserMenuItem } from "../host/HostTreeContextMenus";
import { VAULT_HOST_DRAG_IDS, isVaultHostDraggable } from "../../domain/vaultHostDrag";
import { getVaultHostRowSubtitle } from "../../domain/localShellHost";
import { beginVaultHostCardDrag } from "./vaultHostDragImage";
import { useVaultHostMarquee } from "./useVaultHostMarquee";
type VaultHostListSectionContext = Record<string, any>;
@@ -57,15 +54,12 @@ const isRelatedTargetInside = (
};
const EMPTY_GROUP_PATH_SET = new Set<string>();
const EMPTY_HOST_ID_SET: ReadonlySet<string> = new Set();
const noopSetMultiSelectMode = (_enabled: boolean) => {};
const noopSetSelectedHostIds = (_ids: Set<string>) => {};
export function VaultHostListSection({ ctx }: { ctx: VaultHostListSectionContext }) {
const { Badge, Boolean, Button, cancelInlineGroupEdit, CheckSquare, ClipboardCopy, Clock, cn, commitInlineGroupRename, ContextMenu, ContextMenuContent, ContextMenuItem, ContextMenuTrigger, Copy, displayedGroups, displayedHosts, DistroAvatar, Edit2, FileSymlink, FolderPlus, FolderTree, getDropTargetClasses, getEffectiveHostDistro, groupConfigs, groupedDisplayHosts, handleCopyCredentials, handleCopyHostname, handleDuplicateHost, handleEditGroupConfig, handleEditHost, handleHostConnect, handleOpenHostBrowser, hostClickBehavior: hostClickBehaviorProp, handleUnmanageGroup, hasHostsSidePanel, hostListScrollRef, HostTreeView, isHostsSectionActive, isMultiSelectMode, lastPinnedId, LayoutGrid, managedGroupPaths, moveGroup, moveHostToGroup, moveHostsToGroup, onDeleteHost, Pin, pinnedHosts, Plug, recentHosts, reorderGroup, reorderHost, sanitizeHost, search, selectedGroupPath, selectedGroupPaths, selectedHostIds, selectedTags, sessionCount, setDeleteTargetPath, setDragOverDropTarget, setGroupDragOverDropTarget, setIsDeleteGroupOpen, setIsMultiSelectMode = noopSetMultiSelectMode, setIsNewFolderOpen, setLastPinnedId, setNewFolderName, setSelectedGroupPath, setSelectedHostIds = noopSetSelectedHostIds, setTargetParentPath, shouldHideEmptyRootHostsSection, showRecentHosts, sortMode, Square, Star, startInlineDeleteGroup, startInlineNewGroup, startInlineRenameGroup, t, toggleGroupSelection, toggleHostPinned, toggleHostSelection, Trash2, treeExpandedState, treeViewGroupTree, treeViewHosts, viewMode, visibleDisplayedHosts } = ctx;
const { Badge, Boolean, Button, cancelInlineGroupEdit, CheckSquare, ClipboardCopy, Clock, cn, commitInlineGroupRename, ContextMenu, ContextMenuContent, ContextMenuItem, ContextMenuTrigger, Copy, displayedGroups, displayedHosts, DistroAvatar, Edit2, FileSymlink, FolderPlus, FolderTree, getDropTargetClasses, getEffectiveHostDistro, groupConfigs, groupedDisplayHosts, handleCopyCredentials, handleCopyHostname, handleDuplicateHost, handleEditGroupConfig, handleEditHost, handleHostConnect, handleOpenHostBrowser, hostClickBehavior: hostClickBehaviorProp, handleUnmanageGroup, hasHostsSidePanel, hostListScrollRef, HostTreeView, isHostsSectionActive, isMultiSelectMode, lastPinnedId, LayoutGrid, managedGroupPaths, moveGroup, moveHostToGroup, onDeleteHost, Pin, pinnedHosts, Plug, recentHosts, reorderGroup, reorderHost, sanitizeHost, search, selectedGroupPath, selectedGroupPaths, selectedHostIds, selectedTags, sessionCount, setDeleteTargetPath, setDragOverDropTarget, setGroupDragOverDropTarget, setIsDeleteGroupOpen, setIsNewFolderOpen, setLastPinnedId, setNewFolderName, setSelectedGroupPath, setTargetParentPath, shouldHideEmptyRootHostsSection, showRecentHosts, sortMode, Square, Star, startInlineDeleteGroup, startInlineNewGroup, startInlineRenameGroup, t, toggleGroupSelection, toggleHostPinned, toggleHostSelection, Trash2, treeExpandedState, treeViewGroupTree, treeViewHosts, viewMode, visibleDisplayedHosts } = ctx;
const hostClickBehavior: HostClickBehavior = hostClickBehaviorProp === 'select' ? 'select' : 'connect';
const multiSelectedGroupPaths: Set<string> = selectedGroupPaths ?? EMPTY_GROUP_PATH_SET;
const [draggingHostIds, setDraggingHostIds] = React.useState<ReadonlySet<string> | null>(null);
const [draggingHostId, setDraggingHostId] = React.useState<string | null>(null);
const draggingHostIdRef = React.useRef<string | null>(null);
const lastPreviewReorderRef = React.useRef<string | null>(null);
const prepareGridLayoutAnimation = useVaultGridLayoutAnimation(hostListScrollRef);
@@ -126,19 +120,6 @@ export function VaultHostListSection({ ctx }: { ctx: VaultHostListSectionContext
setFocusedGroupPath(null);
}, [selectedGroupPath, viewMode, hostClickBehavior]);
const {
marqueeRect,
onPointerDown: onMarqueePointerDown,
onClickCapture: onMarqueeClickCapture,
} = useVaultHostMarquee({
containerRef: hostListScrollRef,
viewMode,
isMultiSelectMode: Boolean(isMultiSelectMode),
selectedHostIds: selectedHostIds ?? EMPTY_HOST_ID_SET,
setIsMultiSelectMode,
setSelectedHostIds,
});
const activateHost = React.useCallback((host: Host) => {
const action = resolveHostActivateAction({
behavior: hostClickBehavior,
@@ -303,25 +284,17 @@ export function VaultHostListSection({ ctx }: { ctx: VaultHostListSectionContext
const resetHostDragState = React.useCallback(() => {
draggingHostIdRef.current = null;
setDraggingHostIds(null);
setDraggingHostId(null);
lastPreviewReorderRef.current = null;
setDragOverDropTarget(null);
}, [setDragOverDropTarget]);
const handleHostDragStart = React.useCallback((e: React.DragEvent, hostId: string) => {
const ids = beginVaultHostCardDrag({
dataTransfer: e.dataTransfer,
hostId,
isMultiSelectMode: !!isMultiSelectMode,
selectedHostIds,
countLabel: (count) => t("vault.hosts.dragSelectionCount", { count }),
});
if (!ids) {
e.preventDefault();
return;
}
// copyMove: vault reorder uses move; focus-sidebar append uses copy.
e.dataTransfer.effectAllowed = "copyMove";
e.dataTransfer.setData("host-id", hostId);
draggingHostIdRef.current = hostId;
setDraggingHostIds(new Set(ids));
setDraggingHostId(hostId);
lastPreviewReorderRef.current = null;
// Grid preview reorder can move the card into another virtual row, and rows
@@ -335,7 +308,7 @@ export function VaultHostListSection({ ctx }: { ctx: VaultHostListSectionContext
resetHostDragState();
};
sourceNode.addEventListener("dragend", handleNativeDragEnd);
}, [isMultiSelectMode, resetHostDragState, selectedHostIds, t]);
}, [resetHostDragState]);
const renderHostEditButton = (host: any, compact = false) => (
<Button
@@ -378,21 +351,14 @@ export function VaultHostListSection({ ctx }: { ctx: VaultHostListSectionContext
return <div
ref={hostListScrollRef}
className={cn(
"relative flex-1 overflow-auto px-4 pb-4 [&>:not([data-vault-marquee])~:not([data-vault-marquee])]:mt-3",
"flex-1 overflow-auto px-4 pb-4 space-y-3",
viewMode === "tree" ? "pt-1.5" : "pt-0",
!isHostsSectionActive && "hidden",
marqueeRect && "select-none",
)}
data-section="vault-host-list"
onClick={handleHostListClick}
onClickCapture={onMarqueeClickCapture}
onPointerDown={onMarqueePointerDown}
onDragOverCapture={(e) => {
const target = (e.target as Element | null)?.closest("[data-host-id], [data-group-path]");
if (hasVaultDragType(e.dataTransfer, VAULT_HOST_DRAG_IDS)) {
const groupTarget = target instanceof HTMLElement && target.hasAttribute("data-group-path");
if (!groupTarget) return;
}
if (target) e.preventDefault();
if (!(target instanceof HTMLElement)) return;
const draggedGroupPath = e.dataTransfer.getData("group-path");
@@ -426,10 +392,6 @@ export function VaultHostListSection({ ctx }: { ctx: VaultHostListSectionContext
}}
onDragOver={(e) => {
const target = (e.target as Element | null)?.closest("[data-host-id], [data-group-path]");
if (hasVaultDragType(e.dataTransfer, VAULT_HOST_DRAG_IDS)) {
const groupTarget = target instanceof HTMLElement && target.hasAttribute("data-group-path");
if (!groupTarget) return;
}
if (!(target instanceof HTMLElement) || viewMode === "grid") return;
const draggedGroupPath = e.dataTransfer.getData("group-path");
const isDraggingGroup = hasVaultDragType(e.dataTransfer, "group-path");
@@ -457,12 +419,7 @@ export function VaultHostListSection({ ctx }: { ctx: VaultHostListSectionContext
if (!(target instanceof HTMLElement)) return;
const targetHostId = target.getAttribute("data-host-id");
const targetGroupPath = target.getAttribute("data-group-path");
if (
draggedHostId
&& targetHostId
&& draggedHostId !== targetHostId
&& !hasVaultDragType(e.dataTransfer, VAULT_HOST_DRAG_IDS)
) {
if (draggedHostId && targetHostId && draggedHostId !== targetHostId) {
e.preventDefault();
e.stopPropagation();
const position = getVaultDropPosition(target, e.clientX, e.clientY, viewMode === "grid");
@@ -490,19 +447,6 @@ export function VaultHostListSection({ ctx }: { ctx: VaultHostListSectionContext
resetHostDragState();
}}
>
{marqueeRect && (
<div
data-vault-marquee="true"
aria-hidden="true"
className="pointer-events-none absolute z-20 border border-primary/80 bg-primary/15"
style={{
left: marqueeRect.left,
top: marqueeRect.top,
width: marqueeRect.right - marqueeRect.left,
height: marqueeRect.bottom - marqueeRect.top,
}}
/>
)}
{viewMode !== "tree" && (
<section className="space-y-2 pt-2">
<div className="flex items-center gap-2 text-sm font-semibold">
@@ -532,7 +476,6 @@ export function VaultHostListSection({ ctx }: { ctx: VaultHostListSectionContext
setDragOverDropTarget,
moveGroup,
moveHostToGroup,
moveHostsToGroup,
resetHostDragState,
});
}}
@@ -603,7 +546,7 @@ export function VaultHostListSection({ ctx }: { ctx: VaultHostListSectionContext
viewMode === "grid"
? cn(
"soft-card elevate rounded-xl h-[68px] px-3 py-2 will-change-transform transition-[opacity,box-shadow,border-color,background-color] duration-150",
draggingHostIds?.has(host.id) && "opacity-45",
draggingHostId === host.id && "opacity-45",
hostCardFocusClassName(viewMode, isHostFocusSelected(host.id)),
)
: cn(
@@ -622,7 +565,7 @@ export function VaultHostListSection({ ctx }: { ctx: VaultHostListSectionContext
tabIndex={getHostTabIndex(host.id)}
style={lastPinnedId === host.id ? { animation: "pop-in 0.4s cubic-bezier(0.34, 1.56, 0.64, 1) both" } : undefined}
onAnimationEnd={() => { if (lastPinnedId === host.id) setLastPinnedId(null); }}
draggable={isVaultHostDraggable(Boolean(isMultiSelectMode), selectedHostIds, host.id)}
draggable={!isMultiSelectMode}
onDragStart={(e) => handleHostDragStart(e, host.id)}
onClick={() => {
activateHost(safeHost);
@@ -729,7 +672,7 @@ export function VaultHostListSection({ ctx }: { ctx: VaultHostListSectionContext
viewMode === "grid"
? cn(
"soft-card elevate rounded-xl h-[68px] px-3 py-2 will-change-transform transition-[opacity,box-shadow,border-color,background-color] duration-150",
draggingHostIds?.has(host.id) && "opacity-45",
draggingHostId === host.id && "opacity-45",
hostCardFocusClassName(viewMode, isHostFocusSelected(host.id)),
)
: cn(
@@ -746,7 +689,7 @@ export function VaultHostListSection({ ctx }: { ctx: VaultHostListSectionContext
? selectedHostIds.has(host.id)
: undefined}
tabIndex={getHostTabIndex(host.id)}
draggable={isVaultHostDraggable(Boolean(isMultiSelectMode), selectedHostIds, host.id)}
draggable={!isMultiSelectMode}
onDragStart={(e) => handleHostDragStart(e, host.id)}
onClick={() => {
activateHost(safeHost);
@@ -851,7 +794,6 @@ export function VaultHostListSection({ ctx }: { ctx: VaultHostListSectionContext
dataTransfer: e.dataTransfer,
groupPath: selectedGroupPath,
moveHostToGroup,
moveHostsToGroup,
resetHostDragState,
})) return;
const groupPath = e.dataTransfer.getData("group-path");
@@ -932,7 +874,6 @@ export function VaultHostListSection({ ctx }: { ctx: VaultHostListSectionContext
dataTransfer: e.dataTransfer,
groupPath: node.path,
moveHostToGroup,
moveHostsToGroup,
resetHostDragState,
})) return;
const groupPath =
@@ -1042,7 +983,6 @@ export function VaultHostListSection({ ctx }: { ctx: VaultHostListSectionContext
cancelInlineGroupEdit={cancelInlineGroupEdit}
onDeleteGroup={startInlineDeleteGroup}
moveHostToGroup={moveHostToGroup}
moveHostsToGroup={moveHostsToGroup}
moveGroup={moveGroup}
managedGroupPaths={managedGroupPaths}
onUnmanageGroup={handleUnmanageGroup}
@@ -1101,7 +1041,7 @@ export function VaultHostListSection({ ctx }: { ctx: VaultHostListSectionContext
viewMode === "grid"
? cn(
"soft-card elevate rounded-xl h-[68px] px-3 py-2 will-change-transform transition-[opacity,box-shadow,border-color,background-color] duration-150",
draggingHostIds?.has(host.id) && "opacity-45",
draggingHostId === host.id && "opacity-45",
hostCardFocusClassName(viewMode, isHostFocusSelected(host.id)),
)
: cn(
@@ -1118,7 +1058,7 @@ export function VaultHostListSection({ ctx }: { ctx: VaultHostListSectionContext
? selectedHostIds.has(host.id)
: undefined}
tabIndex={getHostTabIndex(host.id)}
draggable={isVaultHostDraggable(Boolean(isMultiSelectMode), selectedHostIds, host.id)}
draggable={!isMultiSelectMode}
onDragStart={(e) => handleHostDragStart(e, host.id)}
onClick={() => {
activateHost(safeHost);
@@ -1252,7 +1192,7 @@ export function VaultHostListSection({ ctx }: { ctx: VaultHostListSectionContext
viewMode === "grid"
? cn(
"soft-card elevate rounded-xl h-[68px] px-3 py-2 will-change-transform transition-[opacity,box-shadow,border-color,background-color] duration-150",
draggingHostIds?.has(host.id) && "opacity-45",
draggingHostId === host.id && "opacity-45",
hostCardFocusClassName(viewMode, isHostFocusSelected(host.id)),
)
: cn(
@@ -1269,7 +1209,7 @@ export function VaultHostListSection({ ctx }: { ctx: VaultHostListSectionContext
? selectedHostIds.has(host.id)
: undefined}
tabIndex={getHostTabIndex(host.id)}
draggable={isVaultHostDraggable(Boolean(isMultiSelectMode), selectedHostIds, host.id)}
draggable={!isMultiSelectMode}
onDragStart={(e) => handleHostDragStart(e, host.id)}
onClick={() => {
activateHost(safeHost);
-3
View File
@@ -1237,7 +1237,6 @@ export function VaultViewLayout({ ctx }: { ctx: VaultViewLayoutContext }) {
managedGroupPaths,
moveGroup,
moveHostToGroup,
moveHostsToGroup,
onDeleteHost,
Pin,
pinnedHosts,
@@ -1257,12 +1256,10 @@ export function VaultViewLayout({ ctx }: { ctx: VaultViewLayoutContext }) {
setDragOverDropTarget,
setGroupDragOverDropTarget,
setIsDeleteGroupOpen,
setIsMultiSelectMode,
setIsNewFolderOpen,
setLastPinnedId,
setNewFolderName,
setSelectedGroupPath,
setSelectedHostIds,
setTargetParentPath,
shouldHideEmptyRootHostsSection,
showRecentHosts,
@@ -3,7 +3,6 @@ import test from 'node:test';
import React from 'react';
import { act, create, type ReactTestRenderer } from 'react-test-renderer';
import type { Host, ManagedSource } from '../../types';
import { handleVaultHostDropToGroup } from './vaultReorderDrag';
import { useVaultGroupDragHandlers } from './useVaultGroupDragHandlers';
const host = (id: string, extra: Partial<Host> = {}): Host => ({
@@ -45,37 +44,6 @@ test('mixed protocols entering and leaving managed groups retain single-host mov
}, writes);
});
test('a multi-id group drop publishes the whole selection through the batched move', async () => {
const original = [host('ssh'), host('telnet', { protocol: 'telnet' }), host('local', { protocol: 'local' })];
const sources = [{ id: 'parent', groupName: 'Managed' }, { id: 'child', groupName: 'Managed/Nested' }] as ManagedSource[];
const writes: Host[][] = [];
await withActions(original, sources, actions => {
let handled = false;
act(() => {
handled = handleVaultHostDropToGroup({
dataTransfer: {
getData: (type: string) => type === 'host-ids' ? JSON.stringify(['ssh', 'telnet', 'local']) : '',
},
groupPath: 'Managed/Nested',
moveHostToGroup: () => {
throw new Error('multi-id drop must not move hosts one at a time');
},
moveHostsToGroup: actions.moveHostsToGroup,
resetHostDragState: () => {},
});
});
assert.equal(handled, true);
assert.equal(writes.length, 1);
assert.deepEqual(writes[0].map(item => item.group), ['Managed/Nested', 'Managed/Nested', 'Managed/Nested']);
assert.equal(writes[0][0].managedSourceId, 'child');
assert.equal(writes[0][0].label, 'Hostssh');
assert.equal(writes[0][1].managedSourceId, undefined);
assert.equal(writes[0][1].label, 'Host telnet');
assert.equal(writes[0][2].managedSourceId, undefined);
assert.equal(writes[0][2].label, 'Host local');
}, writes);
});
test('same-group, missing and empty selections do not publish changes', async () => {
const writes: Host[][] = [];
await withActions([host('a')], [], actions => {
-277
View File
@@ -1,277 +0,0 @@
import React, { useCallback, useEffect, useRef, useState } from "react";
import {
clientPointToContent,
contentRectFromClientBox,
exceedsMarqueeThreshold,
hostIdsIntersectingMarquee,
isPointerOnNativeScrollbar,
marqueeAutoScrollDelta,
marqueeCancelSelection,
marqueeCommitSelection,
marqueeRectFromPoints,
marqueeSelectionMode,
mergeMarqueeSelection,
rememberHostPlacements,
shouldStartVaultMarquee,
type HostPlacement,
type MarqueeContentFrame,
type MarqueeRect,
} from "../../domain/vaultMarqueeSelection";
const MARQUEE_BLOCKED_SELECTOR = [
"[data-host-id]",
"[data-group-path]",
"button",
"input",
"textarea",
"select",
"a",
"label",
"[role='scrollbar']",
].join(",");
const selectionSignature = (ids: ReadonlySet<string>) => [...ids].sort().join("\0");
const elementFromEventTarget = (target: EventTarget | null): Element | null => {
if (target instanceof Element) return target;
if (typeof Node !== "undefined" && target instanceof Node) return target.parentElement;
return null;
};
const isBlockedMarqueeTarget = (target: EventTarget | null): boolean => {
const element = elementFromEventTarget(target);
if (!element) return true;
return Boolean(element.closest(MARQUEE_BLOCKED_SELECTOR));
};
const readFrame = (container: HTMLElement): MarqueeContentFrame => {
const rect = container.getBoundingClientRect();
return {
left: rect.left,
top: rect.top,
scrollLeft: container.scrollLeft,
scrollTop: container.scrollTop,
clientLeft: container.clientLeft,
clientTop: container.clientTop,
};
};
const readVisibleHostPlacements = (container: HTMLElement): HostPlacement[] => {
const frame = readFrame(container);
const placements: HostPlacement[] = [];
container.querySelectorAll<HTMLElement>("[data-host-id]").forEach((node, index) => {
const id = node.dataset.hostId;
if (!id) return;
const box = node.getBoundingClientRect();
placements.push({
key: node.dataset.vaultGridItem || `${id}:${index}`,
id,
rect: contentRectFromClientBox(box, frame),
});
});
return placements;
};
interface UseVaultHostMarqueeOptions {
containerRef: React.RefObject<HTMLElement | null>;
viewMode: string;
isMultiSelectMode: boolean;
selectedHostIds: ReadonlySet<string>;
setIsMultiSelectMode?: (value: boolean) => void;
setSelectedHostIds?: (ids: Set<string>) => void;
}
export function useVaultHostMarquee({
containerRef,
viewMode,
isMultiSelectMode,
selectedHostIds,
setIsMultiSelectMode,
setSelectedHostIds,
}: UseVaultHostMarqueeOptions) {
const [marqueeRect, setMarqueeRect] = useState<MarqueeRect | null>(null);
const suppressClickRef = useRef(false);
const sessionRef = useRef(0);
const stopGestureRef = useRef<(() => void) | null>(null);
useEffect(() => () => stopGestureRef.current?.(), []);
const onPointerDown = useCallback((event: React.PointerEvent<HTMLElement>) => {
if (suppressClickRef.current) suppressClickRef.current = false;
const container = containerRef.current;
if (!container || !setSelectedHostIds || !setIsMultiSelectMode) return;
const bounds = container.getBoundingClientRect();
if (!shouldStartVaultMarquee({
viewMode,
button: event.button,
pointerType: event.pointerType,
onScrollbar: isPointerOnNativeScrollbar({
rectLeft: bounds.left,
rectTop: bounds.top,
clientLeft: container.clientLeft,
clientTop: container.clientTop,
clientWidth: container.clientWidth,
clientHeight: container.clientHeight,
}, event.clientX, event.clientY),
blockedTarget: isBlockedMarqueeTarget(event.target),
})) return;
stopGestureRef.current?.();
const startContent = clientPointToContent(
{ x: event.clientX, y: event.clientY },
readFrame(container),
);
const baseline = new Set(selectedHostIds);
const wasMultiSelectMode = Boolean(isMultiSelectMode);
const mode = marqueeSelectionMode(event);
const cache = new Map<string, HostPlacement>();
const session = sessionRef.current + 1;
sessionRef.current = session;
let active = false;
let scrollDelta = 0;
let frameId = 0;
let lastClientX = event.clientX;
let lastClientY = event.clientY;
let lastSignature = selectionSignature(baseline);
let lastMode = wasMultiSelectMode;
const publish = (nextIds: Set<string>, modeEnabled: boolean) => {
const signature = selectionSignature(nextIds);
if (signature !== lastSignature) {
lastSignature = signature;
setSelectedHostIds(nextIds);
}
if (modeEnabled !== lastMode) {
lastMode = modeEnabled;
setIsMultiSelectMode(modeEnabled);
}
};
const applyAt = (clientX: number, clientY: number) => {
if (sessionRef.current !== session) return;
const current = clientPointToContent({ x: clientX, y: clientY }, readFrame(container));
if (!active && !exceedsMarqueeThreshold(startContent, current)) return;
active = true;
const rect = marqueeRectFromPoints(startContent, current);
rememberHostPlacements(cache, readVisibleHostPlacements(container));
const hits = hostIdsIntersectingMarquee(rect, cache.values());
const next = marqueeCommitSelection({
wasMultiSelectMode,
nextIds: mergeMarqueeSelection(baseline, hits, mode),
});
setMarqueeRect(rect);
publish(next.selectedHostIds, next.isMultiSelectMode);
};
const listeners: {
move?: (event: PointerEvent) => void;
up?: () => void;
cancel?: () => void;
key?: (event: KeyboardEvent) => void;
scroll?: () => void;
} = {};
let stopGesture = () => {};
const finishListeners = () => {
if (listeners.move) window.removeEventListener("pointermove", listeners.move);
if (listeners.up) window.removeEventListener("pointerup", listeners.up);
if (listeners.cancel) window.removeEventListener("pointercancel", listeners.cancel);
if (listeners.key) window.removeEventListener("keydown", listeners.key);
if (listeners.scroll) container.removeEventListener("scroll", listeners.scroll);
if (frameId) cancelAnimationFrame(frameId);
frameId = 0;
scrollDelta = 0;
if (stopGestureRef.current === stopGesture) stopGestureRef.current = null;
};
stopGesture = () => {
if (sessionRef.current !== session) return;
sessionRef.current += 1;
finishListeners();
setMarqueeRect(null);
};
const end = (cancel: boolean) => {
if (sessionRef.current !== session) return;
const wasActive = active;
stopGesture();
if (!wasActive) return;
suppressClickRef.current = true;
if (!cancel) return;
const restored = marqueeCancelSelection({
wasMultiSelectMode,
baselineIds: baseline,
});
lastSignature = "";
lastMode = !restored.isMultiSelectMode;
publish(restored.selectedHostIds, restored.isMultiSelectMode);
};
const tick = () => {
frameId = 0;
if (!scrollDelta || sessionRef.current !== session) return;
container.scrollTop += scrollDelta;
frameId = window.requestAnimationFrame(tick);
};
listeners.move = (pointerEvent: PointerEvent) => {
lastClientX = pointerEvent.clientX;
lastClientY = pointerEvent.clientY;
applyAt(pointerEvent.clientX, pointerEvent.clientY);
if (active) {
pointerEvent.preventDefault();
window.getSelection?.()?.removeAllRanges();
}
const nextBounds = container.getBoundingClientRect();
scrollDelta = active
? marqueeAutoScrollDelta(pointerEvent.clientY, nextBounds.top, nextBounds.bottom)
: 0;
if (scrollDelta && !frameId) frameId = window.requestAnimationFrame(tick);
if (!scrollDelta && frameId) {
cancelAnimationFrame(frameId);
frameId = 0;
}
};
listeners.scroll = () => {
if (!active) return;
applyAt(lastClientX, lastClientY);
};
listeners.up = () => end(false);
listeners.cancel = () => end(true);
listeners.key = (keyEvent: KeyboardEvent) => {
if (keyEvent.key !== "Escape") return;
if (!active) {
end(false);
return;
}
keyEvent.preventDefault();
keyEvent.stopPropagation();
end(true);
};
stopGestureRef.current = stopGesture;
window.addEventListener("pointermove", listeners.move);
window.addEventListener("pointerup", listeners.up);
window.addEventListener("pointercancel", listeners.cancel);
window.addEventListener("keydown", listeners.key);
container.addEventListener("scroll", listeners.scroll, { passive: true });
}, [
containerRef,
isMultiSelectMode,
selectedHostIds,
setIsMultiSelectMode,
setSelectedHostIds,
viewMode,
]);
const onClickCapture = useCallback((event: React.MouseEvent<HTMLElement>) => {
if (!suppressClickRef.current) return;
suppressClickRef.current = false;
event.preventDefault();
event.stopPropagation();
}, []);
return { marqueeRect, onPointerDown, onClickCapture };
}
-42
View File
@@ -1,42 +0,0 @@
import {
applyVaultHostDragData,
resolveVaultHostDragIds,
} from "../../domain/vaultHostDrag";
export function attachVaultHostDragBadge(dataTransfer: DataTransfer, label: string): void {
if (typeof document === "undefined") return;
const badge = document.createElement("div");
badge.textContent = label;
badge.setAttribute("data-vault-host-drag-badge", "true");
badge.style.position = "fixed";
badge.style.top = "-1000px";
badge.style.left = "0";
badge.style.zIndex = "2147483647";
badge.style.padding = "2px 8px";
badge.style.borderRadius = "999px";
badge.style.background = "#111827";
badge.style.color = "#ffffff";
badge.style.font = "12px/16px sans-serif";
badge.style.pointerEvents = "none";
document.body.appendChild(badge);
dataTransfer.setDragImage(badge, badge.offsetWidth / 2, badge.offsetHeight / 2);
window.setTimeout(() => badge.remove(), 0);
}
export function beginVaultHostCardDrag(input: {
dataTransfer: DataTransfer;
hostId: string;
isMultiSelectMode: boolean;
selectedHostIds: ReadonlySet<string> | undefined;
countLabel: (count: number) => string;
}): string[] | null {
const ids = resolveVaultHostDragIds({
hostId: input.hostId,
isMultiSelectMode: input.isMultiSelectMode,
selectedHostIds: input.selectedHostIds ?? new Set<string>(),
});
if (!ids) return null;
applyVaultHostDragData(input.dataTransfer, ids);
if (ids.length > 1) attachVaultHostDragBadge(input.dataTransfer, input.countLabel(ids.length));
return ids;
}
-52
View File
@@ -115,58 +115,6 @@ test("group host drop resets host drag state after moving the host to the group"
]);
});
test("group drop of a multi-id payload batches every selected host once", () => {
const singleCalls: string[] = [];
const batchCalls: Array<{ ids: string[]; group: string | null }> = [];
const handled = handleVaultHostDropToGroup({
dataTransfer: {
getData: (type: string) => (
type === "host-ids" ? JSON.stringify(["a", "b", "c"]) : type === "host-id" ? "a" : ""
),
},
groupPath: "team/nested",
moveHostToGroup: (hostId, targetPath) => {
singleCalls.push(`${hostId}:${String(targetPath)}`);
},
moveHostsToGroup: (hostIds, targetPath) => {
batchCalls.push({ ids: [...hostIds], group: targetPath });
},
resetHostDragState: () => {
singleCalls.push("reset");
},
});
assert.equal(handled, true);
assert.deepEqual(singleCalls, ["reset"]);
assert.deepEqual(batchCalls, [{ ids: ["a", "b", "c"], group: "team/nested" }]);
});
test("root drop of a multi-id payload batches every selected host into all hosts", () => {
const calls: string[] = [];
handleVaultRootDrop({
dataTransfer: {
getData: (type: string) => (type === "host-ids" ? JSON.stringify(["a", "b"]) : ""),
},
preventDefault: () => calls.push("preventDefault"),
setDragOverDropTarget: () => calls.push("drop:null"),
moveGroup: () => calls.push("group"),
moveHostToGroup: () => calls.push("single"),
moveHostsToGroup: (hostIds, targetPath) => {
calls.push(`batch:${[...hostIds].join(",")}:${String(targetPath)}`);
},
resetHostDragState: () => calls.push("resetHostDragState"),
});
assert.deepEqual(calls, [
"preventDefault",
"drop:null",
"batch:a,b:null",
"resetHostDragState",
]);
});
test("group drop without a host leaves host drag state alone", () => {
const calls: string[] = [];
+7 -24
View File
@@ -1,10 +1,5 @@
import React from "react";
import {
dispatchVaultHostGroupMove,
readVaultDraggedHostIds,
} from "../../domain/vaultHostDrag";
export type VaultDropPosition = "before" | "after";
export type VaultDropIntent = VaultDropPosition | "inside";
export type VaultDropAxis = "x" | "y";
@@ -52,7 +47,6 @@ export const handleVaultRootDrop = ({
setDragOverDropTarget,
moveGroup,
moveHostToGroup,
moveHostsToGroup,
resetHostDragState,
}: {
dataTransfer: Pick<DataTransfer, "getData">;
@@ -60,20 +54,15 @@ export const handleVaultRootDrop = ({
setDragOverDropTarget: (target: null) => void;
moveGroup: (groupPath: string, targetParentPath: string | null) => void;
moveHostToGroup: (hostId: string, targetPath: string | null) => void;
moveHostsToGroup?: (hostIds: ReadonlySet<string>, targetPath: string | null) => void;
resetHostDragState: () => void;
}) => {
preventDefault();
setDragOverDropTarget(null);
const groupPath = dataTransfer.getData("group-path");
const hostIds = readVaultDraggedHostIds((type) => dataTransfer.getData(type));
const hostId = dataTransfer.getData("host-id");
if (groupPath) moveGroup(groupPath, null);
if (dispatchVaultHostGroupMove({
hostIds,
groupPath: null,
moveHostToGroup,
moveHostsToGroup,
})) {
if (hostId) {
moveHostToGroup(hostId, null);
resetHostDragState();
}
};
@@ -82,23 +71,17 @@ export const handleVaultHostDropToGroup = ({
dataTransfer,
groupPath,
moveHostToGroup,
moveHostsToGroup,
resetHostDragState,
}: {
dataTransfer: Pick<DataTransfer, "getData">;
groupPath: string | null;
moveHostToGroup?: (hostId: string, targetPath: string | null) => void;
moveHostsToGroup?: (hostIds: ReadonlySet<string>, targetPath: string | null) => void;
moveHostToGroup: (hostId: string, targetPath: string | null) => void;
resetHostDragState: () => void;
}) => {
const moved = dispatchVaultHostGroupMove({
hostIds: readVaultDraggedHostIds((type) => dataTransfer.getData(type)),
groupPath,
moveHostToGroup,
moveHostsToGroup,
});
if (!moved) return false;
const hostId = dataTransfer.getData("host-id");
if (!hostId) return false;
moveHostToGroup(hostId, groupPath);
resetHostDragState();
return true;
};
Binary file not shown.

Before

Width:  |  Height:  |  Size: 985 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 959 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 931 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 794 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.0 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 287 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 757 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 892 KiB

+3 -3
View File
@@ -1,4 +1,4 @@
import { runtimeFailureMessage } from "@/domain/systemManager/containerRuntime.shared.cjs";
import { runtimeFailureMessage } from "./containerRuntime.shared.cjs";
export type {
ContainerRuntimeId,
@@ -6,7 +6,7 @@ export type {
KubeContextInfo,
KubectlPodInfo,
RuntimeProbeFlags,
} from "@/domain/systemManager/containerRuntime.shared.cjs";
} from "./containerRuntime.shared.cjs";
export {
NETCATTY_POPUP_HISTORY_MARKER,
@@ -24,7 +24,7 @@ export {
reconcileContainerSource,
rememberedContainerSource,
sourcesFromCapabilities,
} from "@/domain/systemManager/containerRuntime.shared.cjs";
} from "./containerRuntime.shared.cjs";
export function describeRuntimeFailure(
result: { error?: string; errorCode?: string } | null | undefined,
-111
View File
@@ -1,111 +0,0 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
VAULT_HOST_DRAG_ID,
VAULT_HOST_DRAG_IDS,
applyVaultHostDragData,
dispatchVaultHostGroupMove,
isVaultHostDraggable,
readVaultDraggedHostIds,
resolveVaultHostDragIds,
} from './vaultHostDrag.ts';
test('outside multi-select a host drag carries only that host', () => {
const ids = resolveVaultHostDragIds({
hostId: 'a',
isMultiSelectMode: false,
selectedHostIds: new Set(['a', 'b']),
});
assert.deepEqual(ids, ['a']);
assert.equal(isVaultHostDraggable(false, new Set(), 'a'), true);
const written: Record<string, string> = {};
applyVaultHostDragData({
effectAllowed: '',
setData: (type, value) => {
written[type] = value;
},
}, ids!);
assert.deepEqual(written, { [VAULT_HOST_DRAG_ID]: 'a' });
});
test('a multi-select drag starts only on an already selected host and carries every selected id', () => {
assert.equal(resolveVaultHostDragIds({
hostId: 'c',
isMultiSelectMode: true,
selectedHostIds: new Set(['a', 'b']),
}), null);
assert.equal(isVaultHostDraggable(true, new Set(['a', 'b']), 'c'), false);
const ids = resolveVaultHostDragIds({
hostId: 'b',
isMultiSelectMode: true,
selectedHostIds: new Set(['a', 'b', 'c']),
});
assert.deepEqual(ids, ['b', 'a', 'c']);
const written: Record<string, string> = {};
applyVaultHostDragData({
effectAllowed: 'none',
setData: (type, value) => {
written[type] = value;
},
}, ids!);
assert.equal(written[VAULT_HOST_DRAG_ID], 'b');
assert.deepEqual(JSON.parse(written[VAULT_HOST_DRAG_IDS]), ['b', 'a', 'c']);
});
test('reading a drag payload prefers the multi-id list and falls back to one host id', () => {
assert.deepEqual(readVaultDraggedHostIds((type) => (
type === VAULT_HOST_DRAG_IDS ? JSON.stringify(['a', 'b', 'a']) : 'a'
)), ['a', 'b', 'a']);
assert.deepEqual(readVaultDraggedHostIds((type) => (
type === VAULT_HOST_DRAG_ID ? 'solo' : ''
)), ['solo']);
assert.deepEqual(readVaultDraggedHostIds((type) => (
type === VAULT_HOST_DRAG_IDS ? '{nope' : 'solo'
)), ['solo']);
assert.deepEqual(readVaultDraggedHostIds(() => ''), []);
});
test('multi-id drag payload plans one batched group move', () => {
const singleCalls: string[] = [];
const batchCalls: Array<{ ids: string[]; group: string | null }> = [];
const moved = dispatchVaultHostGroupMove({
hostIds: readVaultDraggedHostIds((type) => (
type === VAULT_HOST_DRAG_IDS ? JSON.stringify(['a', 'b', 'c']) : 'a'
)),
groupPath: 'Ops/Nested',
moveHostToGroup: (hostId, groupPath) => {
singleCalls.push(`${hostId}:${String(groupPath)}`);
},
moveHostsToGroup: (hostIds, groupPath) => {
batchCalls.push({ ids: [...hostIds], group: groupPath });
},
});
assert.equal(moved, true);
assert.deepEqual(singleCalls, []);
assert.deepEqual(batchCalls, [{ ids: ['a', 'b', 'c'], group: 'Ops/Nested' }]);
});
test('a single host id still moves through the single-host path when no batch handler is present', () => {
const calls: string[] = [];
const moved = dispatchVaultHostGroupMove({
hostIds: ['host-1'],
groupPath: null,
moveHostToGroup: (hostId, groupPath) => {
calls.push(`${hostId}:${String(groupPath)}`);
},
});
assert.equal(moved, true);
assert.deepEqual(calls, ['host-1:null']);
assert.equal(dispatchVaultHostGroupMove({
hostIds: [],
groupPath: 'Ops',
moveHostsToGroup: () => {
throw new Error('empty selection must not move');
},
}), false);
});
-83
View File
@@ -1,83 +0,0 @@
export const VAULT_HOST_DRAG_ID = 'host-id';
export const VAULT_HOST_DRAG_IDS = 'host-ids';
export interface VaultHostDragWriter {
effectAllowed: string;
setData: (type: string, value: string) => void;
}
export function resolveVaultHostDragIds(input: {
hostId: string;
isMultiSelectMode: boolean;
selectedHostIds: ReadonlySet<string>;
}): string[] | null {
if (!input.isMultiSelectMode) return [input.hostId];
if (!input.selectedHostIds.has(input.hostId)) return null;
return [
input.hostId,
...input.selectedHostIds,
].filter((id, index, ids) => id.length > 0 && ids.indexOf(id) === index);
}
export function isVaultHostDraggable(
isMultiSelectMode: boolean,
selectedHostIds: ReadonlySet<string> | undefined,
hostId: string,
): boolean {
return resolveVaultHostDragIds({
hostId,
isMultiSelectMode,
selectedHostIds: selectedHostIds ?? new Set(),
}) !== null;
}
export function encodeVaultHostDragIds(ids: readonly string[]): string {
return JSON.stringify(ids);
}
export function decodeVaultHostDragIds(raw: string | null | undefined): string[] {
if (!raw) return [];
try {
const parsed = JSON.parse(raw) as unknown;
if (!Array.isArray(parsed)) return [];
return parsed.filter((id): id is string => typeof id === 'string' && id.length > 0);
} catch {
return [];
}
}
export function applyVaultHostDragData(dataTransfer: VaultHostDragWriter, ids: readonly string[]): void {
if (ids.length === 0) return;
dataTransfer.effectAllowed = 'copyMove';
dataTransfer.setData(VAULT_HOST_DRAG_ID, ids[0]);
if (ids.length > 1) dataTransfer.setData(VAULT_HOST_DRAG_IDS, encodeVaultHostDragIds(ids));
}
export function readVaultDraggedHostIds(getData: (type: string) => string): string[] {
const many = decodeVaultHostDragIds(getData(VAULT_HOST_DRAG_IDS));
if (many.length > 0) return many;
const single = getData(VAULT_HOST_DRAG_ID)?.trim() ?? '';
return single ? [single] : [];
}
export function dispatchVaultHostGroupMove(input: {
hostIds: readonly string[];
groupPath: string | null;
moveHostToGroup?: (hostId: string, groupPath: string | null) => void;
moveHostsToGroup?: (hostIds: ReadonlySet<string>, groupPath: string | null) => void;
}): boolean {
const ids: string[] = [];
for (const id of input.hostIds) {
if (id && !ids.includes(id)) ids.push(id);
}
if (ids.length === 0) return false;
if (input.moveHostsToGroup) {
input.moveHostsToGroup(new Set(ids), input.groupPath);
return true;
}
if (ids.length === 1 && input.moveHostToGroup) {
input.moveHostToGroup(ids[0], input.groupPath);
return true;
}
return false;
}
+10 -265
View File
@@ -14,9 +14,8 @@ export {
buildVaultHostMergeKey,
} from "./vaultHostCreate";
import { parseQuickConnectInput } from "./quickConnect";
import { canServeAsSshJumpHost } from "./terminalProtocol";
import { findExactHeaderIndex, findHeaderIndex, parseCsv } from "./vaultImport/csvUtils";
import { decodeCsvKeyPath, decodeCsvLocalShell, decodeCsvPassphrase, decodeCsvProxyJump } from "./vaultImport/csvCredentialFields";
import { decodeCsvKeyPath, decodeCsvLocalShell, decodeCsvPassphrase } from "./vaultImport/csvCredentialFields";
import { isCsvDirectProxyValue, parseCsvProxy } from "./vaultImport/csvProxy";
import { attachMobaXtermPasswords } from "./vaultImport/mobaXtermPasswords";
import { decodeFinalShellPassword } from "./finalShellPassword";
@@ -101,29 +100,6 @@ const parseProxyJump = (value: string): ParsedJumpHost[] => {
.filter((h): h is ParsedJumpHost => h !== null);
};
/** Vault host fields needed to bind a CSV ProxyJump hop without copying secrets. */
export interface VaultImportExistingHostRef {
id: string;
label: string;
hostname: string;
port?: number;
username?: string;
protocol?: Host["protocol"];
group?: string;
}
export const toVaultImportExistingHostRef = (
host: Pick<Host, "id" | "label" | "hostname" | "port" | "username" | "protocol" | "group">,
): VaultImportExistingHostRef => ({
id: host.id,
label: host.label,
hostname: host.hostname,
port: host.port,
username: host.username,
protocol: host.protocol,
group: host.group,
});
export type VaultImportFormat =
| "putty"
| "mobaxterm"
@@ -278,32 +254,9 @@ export function applyVaultImportDestination(
return remapped;
};
// Collapsing two group copies must not leave a chain pointed at the dropped id.
// Ids that are not part of this import (an existing vault hop) stay as they are.
const remappedHosts = hosts.map((host) => {
const currentIds = host.hostChain?.hostIds;
if (!currentIds?.length) return host;
const hostIds: string[] = [];
for (const hostId of currentIds) {
const mapped = remapHostId(hostId);
const nextId = mapped ?? (originalById.has(hostId) ? undefined : hostId);
if (!nextId || nextId === host.id || hostIds.includes(nextId)) continue;
hostIds.push(nextId);
}
if (hostIds.length === currentIds.length && hostIds.every((id, index) => id === currentIds[index])) {
return host;
}
if (hostIds.length === 0) {
const next = { ...host };
delete next.hostChain;
return next;
}
return { ...host, hostChain: { hostIds } };
});
return {
...result,
hosts: remappedHosts,
hosts,
groups: [group],
keyPassphrases: remapKeyPassphrases(result.keyPassphrases, { matchSelectedKey: true }),
// Keep remapped candidates for alias conflict checks (same as CSV same-group
@@ -468,176 +421,8 @@ const parseTarget = (
return null;
};
const CSV_JUMP_PORT = 22;
const csvJumpPort = (port: number | undefined): number => (
port !== undefined && Number.isInteger(port) && port >= 1 && port <= 65535 ? port : CSV_JUMP_PORT
);
type CsvJumpCandidate = {
id: string;
label: string;
hostname: string;
port?: number;
username?: string;
protocol?: Host["protocol"];
group?: string;
};
const csvJumpEndpointMatches = (hop: ParsedJumpHost, candidate: CsvJumpCandidate): boolean => {
if (!canServeAsSshJumpHost(candidate)) return false;
if (candidate.hostname.trim().toLowerCase() !== hop.hostname.toLowerCase()) return false;
if (csvJumpPort(candidate.port) !== csvJumpPort(hop.port)) return false;
if (hop.username && (candidate.username ?? "").trim().toLowerCase() !== hop.username.toLowerCase()) return false;
return true;
};
const csvJumpLabelMatches = (hop: ParsedJumpHost, candidate: CsvJumpCandidate): boolean => {
if (!canServeAsSshJumpHost(candidate)) return false;
const label = candidate.label.trim().toLowerCase();
if (!label || label !== hop.hostname.toLowerCase()) return false;
if (csvJumpPort(candidate.port) !== csvJumpPort(hop.port)) return false;
if (hop.username && (candidate.username ?? "").trim().toLowerCase() !== hop.username.toLowerCase()) return false;
return true;
};
const pickCsvJumpCandidate = <T extends { group?: string }>(matches: readonly T[], group: string | undefined): T => (
matches.find((candidate) => (candidate.group ?? "") === (group ?? "")) ?? matches[0]
);
const attachCsvProxyJumpChains = (
hosts: Host[],
proxyJumpByHost: ReadonlyMap<Host, { value: string; row: number }>,
issues: VaultImportIssue[],
existingHosts: readonly VaultImportExistingHostRef[],
): Host[] => {
const inlineHosts: Host[] = [];
const matchable: CsvJumpCandidate[] = [...hosts];
const resolveHop = (hop: ParsedJumpHost, group: string | undefined): string | null => {
const endpointMatches = [
...matchable.filter((candidate) => csvJumpEndpointMatches(hop, candidate)),
...existingHosts.filter((candidate) => csvJumpEndpointMatches(hop, candidate)),
];
if (endpointMatches.length > 0) return pickCsvJumpCandidate(endpointMatches, group).id;
const labelMatches = [
...matchable.filter((candidate) => csvJumpLabelMatches(hop, candidate)),
...existingHosts.filter((candidate) => csvJumpLabelMatches(hop, candidate)),
];
if (labelMatches.length > 0) return pickCsvJumpCandidate(labelMatches, group).id;
const inlineKey = `${hop.hostname.toLowerCase()}|${csvJumpPort(hop.port)}|${(hop.username ?? "").toLowerCase()}`;
const existingInline = inlineHosts.find((host) => (
`${host.hostname.toLowerCase()}|${csvJumpPort(host.port)}|${(host.username ?? "").toLowerCase()}` === inlineKey
));
if (existingInline) return existingInline.id;
if (!looksLikeHostnameToken(hop.hostname)) return null;
try {
const inlineHost = createHost({
label: hop.hostname,
hostname: hop.hostname,
username: hop.username,
port: hop.port,
protocol: "ssh",
});
inlineHosts.push(inlineHost);
matchable.push(inlineHost);
return inlineHost.id;
} catch {
return null;
}
};
for (const host of hosts) {
const entry = proxyJumpByHost.get(host);
if (!entry) continue;
const specs = entry.value
.split(",")
.map((part) => part.trim())
.filter((part) => part && part.toLowerCase() !== "none");
if (specs.length === 0) continue;
const resolvedIds: string[] = [];
const unresolved: string[] = [];
const inlineCountBefore = inlineHosts.length;
let chainRejected = false;
for (const spec of specs) {
const jump = parseJumpHostSpec(spec);
if (!jump) {
issues.push({
level: "warning",
message: `CSV row ${entry.row}: ProxyJump hop "${spec}" was ignored because it is not a valid jump host.`,
});
chainRejected = true;
continue;
}
const inlineCount = inlineHosts.length;
const resolvedId = resolveHop(jump, host.group);
if (!resolvedId) {
issues.push({
level: "warning",
message: `CSV row ${entry.row}: ProxyJump hop "${spec}" was ignored because it is not a valid jump host.`,
});
chainRejected = true;
continue;
}
if (inlineHosts.length > inlineCount) unresolved.push(jump.hostname);
if (!resolvedIds.includes(resolvedId)) resolvedIds.push(resolvedId);
}
if (chainRejected || resolvedIds.includes(host.id)) {
if (inlineHosts.length > inlineCountBefore) {
const droppedIds = new Set(inlineHosts.splice(inlineCountBefore).map((item) => item.id));
for (let index = matchable.length - 1; index >= 0; index -= 1) {
if (droppedIds.has(matchable[index].id)) matchable.splice(index, 1);
}
}
if (resolvedIds.includes(host.id)) {
issues.push({
level: "warning",
message: `CSV: detected circular reference in ProxyJump for "${host.label}", skipping chain.`,
});
}
continue;
}
if (resolvedIds.length > 0) host.hostChain = { hostIds: resolvedIds };
if (unresolved.length > 0) {
issues.push({
level: "warning",
message: `CSV: created inline jump host(s) for "${host.label}": ${unresolved.join(", ")}`,
});
}
}
const allHosts = [...hosts, ...inlineHosts];
const participatesInCycle = (hostId: string, visited: Set<string>): boolean => {
if (visited.has(hostId)) return true;
visited.add(hostId);
const current = allHosts.find((item) => item.id === hostId);
for (const chainId of current?.hostChain?.hostIds ?? []) {
if (!allHosts.some((item) => item.id === chainId)) continue;
if (participatesInCycle(chainId, visited)) return true;
}
visited.delete(hostId);
return false;
};
const cyclicHosts = allHosts.filter((host) => (
Boolean(host.hostChain?.hostIds?.length) && participatesInCycle(host.id, new Set())
));
for (const host of cyclicHosts) {
issues.push({
level: "warning",
message: `CSV: detected circular dependency in jump chain for "${host.label}", removing chain.`,
});
delete host.hostChain;
}
return allHosts;
};
const importFromCsv = (
text: string,
existingHosts: readonly VaultImportExistingHostRef[] = [],
): VaultImportResult => {
const importFromCsv = (text: string): VaultImportResult => {
const issues: VaultImportIssue[] = [];
const rows = parseCsv(text).filter((r) => r.some((c) => c.trim() !== ""));
if (rows.length === 0) {
@@ -663,7 +448,6 @@ const importFromCsv = (
const keyPathIdx = findExactHeaderIndex(header, ["keypath", "key path", "identityfile", "identity file"]);
const explicitPassphraseIdx = findExactHeaderIndex(header, ["passphrase", "keypassphrase", "key passphrase"]);
const proxyIdx = findExactHeaderIndex(header, ["proxy", "proxyserver", "proxy server"]);
const proxyJumpIdx = findExactHeaderIndex(header, ["proxyjump"]);
const localShellIdx = findExactHeaderIndex(header, ["localshell"]);
const passphraseIdx = keyPathIdx >= 0 ? explicitPassphraseIdx : -1;
const exactPasswordIdx = findExactHeaderIndex(header, ["password", "pass", "passwd"]);
@@ -698,7 +482,6 @@ const importFromCsv = (
}
const parsedHosts: Host[] = [];
const proxyJumpByHost = new Map<Host, { value: string; row: number }>();
const keyPassphraseCandidates: Array<{
hostKey: string;
keyPathKey: string;
@@ -766,8 +549,6 @@ const importFromCsv = (
message: `CSV row ${i + 2}: LocalShell was ignored because it is not a valid encoded local-shell value.`,
});
}
const proxyJumpRaw = (proxyJumpIdx >= 0 ? row[proxyJumpIdx] : undefined)?.trim();
const proxyJump = proxyJumpRaw ? decodeCsvProxyJump(proxyJumpRaw).trim() : "";
if (decodedPassphrase && isEncryptedCredentialPlaceholder(decodedPassphrase)) {
issues.push({
@@ -805,9 +586,6 @@ const importFromCsv = (
if (localShellSpec.startDir) host.localStartDir = localShellSpec.startDir;
if (localShellSpec.os) host.os = localShellSpec.os;
}
if (proxyJump && proxyJump.toLowerCase() !== "none") {
proxyJumpByHost.set(host, { value: proxyJump, row: i + 2 });
}
parsedHosts.push(host);
if (keyPath && passphrase) {
const keyPathKey = normalizeKeyPathKey(keyPath);
@@ -834,8 +612,7 @@ const importFromCsv = (
// the shell identity is part of buildVaultHostMergeKey; otherwise importing a
// backup with CMD and PowerShell in the same group silently keeps only the
// first shell and flags the rest as duplicates.
const { hosts: dedupedHosts, duplicates } = dedupeHosts(parsedHosts);
const hosts = attachCsvProxyJumpChains(dedupedHosts, proxyJumpByHost, issues, existingHosts);
const { hosts, duplicates } = dedupeHosts(parsedHosts);
const keyPassphrases = hosts.flatMap((host) => {
const selectedKeyPath = host.identityFilePaths?.find((path) => path.trim())?.trim();
if (!selectedKeyPath) return [];
@@ -1632,22 +1409,15 @@ const importFromMobaXterm = (
};
};
export interface VaultImportTextOptions {
fileName?: string;
masterPassword?: string;
/** Hosts already in the vault. CSV ProxyJump hops bind to these by endpoint. */
existingHosts?: readonly VaultImportExistingHostRef[];
}
export const importVaultHostsFromText = (
format: VaultImportFormat,
text: string,
options?: VaultImportTextOptions,
options?: { fileName?: string; masterPassword?: string },
): VaultImportResult => {
const input = text ?? "";
switch (format) {
case "csv":
return importFromCsv(input, options?.existingHosts);
return importFromCsv(input);
case "putty":
return importFromPuttyReg(input);
case "ssh_config":
@@ -1738,40 +1508,15 @@ export function applyVaultHostImport(
addedHosts: Host[];
} {
const skipDuplicates = options?.skipDuplicates !== false;
const existingByKey = new Map(existingHosts.map((host) => [buildVaultHostMergeKey(host), host]));
const droppedIdToExistingId = new Map<string, string>();
const existingKeys = new Set(existingHosts.map(buildVaultHostMergeKey));
let newHosts = importResult.hosts;
let skippedExistingCount = 0;
if (skipDuplicates) {
newHosts = importResult.hosts.filter((host) => {
const existing = existingByKey.get(buildVaultHostMergeKey(host));
if (!existing) return true;
skippedExistingCount++;
if (existing.id !== host.id) droppedIdToExistingId.set(host.id, existing.id);
return false;
});
}
if (droppedIdToExistingId.size > 0) {
newHosts = newHosts.map((host) => {
const currentIds = host.hostChain?.hostIds;
if (!currentIds?.length) return host;
const hostIds: string[] = [];
for (const hostId of currentIds) {
const mapped = droppedIdToExistingId.get(hostId) ?? hostId;
if (mapped === host.id || hostIds.includes(mapped)) continue;
hostIds.push(mapped);
}
if (hostIds.length === currentIds.length && hostIds.every((id, index) => id === currentIds[index])) {
return host;
}
if (hostIds.length === 0) {
const next = { ...host };
delete next.hostChain;
return next;
}
return { ...host, hostChain: { hostIds } };
const duplicate = existingKeys.has(buildVaultHostMergeKey(host));
if (duplicate) skippedExistingCount++;
return !duplicate;
});
}
@@ -2,7 +2,6 @@ const FORMULA_PREFIX = /^[=+\-@\t\r]/u;
const KEY_PATH_MARKER = "__netcatty_csv_keypath_v1__:";
const PASSPHRASE_MARKER = "__netcatty_csv_passphrase_v1__:";
const PROXY_MARKER = "__netcatty_csv_proxy_v1__:";
const PROXY_JUMP_MARKER = "__netcatty_csv_proxyjump_v1__:";
const LOCAL_SHELL_MARKER = "__netcatty_csv_localshell_v1__:";
const encodeMarkedField = (value: string, marker: string): string => (
@@ -44,14 +43,6 @@ export const decodeCsvProxy = (value: string): string => (
decodeMarkedField(value, PROXY_MARKER)
);
export const encodeCsvProxyJump = (value: string): string => (
encodeMarkedField(value, PROXY_JUMP_MARKER)
);
export const decodeCsvProxyJump = (value: string): string => (
decodeMarkedField(value, PROXY_JUMP_MARKER)
);
export interface CsvLocalShellSpec {
shell?: string;
shellArgs?: string[];
+1 -226
View File
@@ -3,9 +3,8 @@ import test from "node:test";
import { publishActiveGlobalDefaultProxyProfileId } from "../globalDefaultProxy.ts";
import { applyGroupDefaults } from "../groupConfig.ts";
import { applyVaultHostImport, applyVaultImportDestination, importVaultHostsFromText } from "../vaultImport.ts";
import { importVaultHostsFromText } from "../vaultImport.ts";
import { exportHostsToCsvWithStats, getVaultCsvTemplate } from "./csvExport.ts";
import { parseCsv } from "./csvUtils.ts";
import type { Host } from "../models.ts";
const hostDefaults = { username: "root", tags: [], os: "linux" as const };
@@ -607,230 +606,6 @@ test("CSV round-trips local-shell vault host settings", () => {
assert.equal(roundTrippedDefault.localStartDir, undefined);
});
const proxyJumpCell = (csv: string, hostname: string): string => {
const rows = parseCsv(csv.replace(/^\uFEFF/, "")).filter((row) => row.some((cell) => cell.trim() !== ""));
const header = rows[0] ?? [];
const jumpIndex = header.indexOf("ProxyJump");
const hostIndex = header.findIndex((cell) => cell.toLowerCase().includes("hostname"));
const row = rows.slice(1).find((cells) => cells[hostIndex] === hostname);
return jumpIndex >= 0 ? (row?.[jumpIndex] ?? "") : "";
};
const chainHost = (id: string, label: string, hostname: string, username: string, port = 22): Host => ({
...hostDefaults,
id,
label,
hostname,
username,
port,
});
test("CSV export writes one-hop and two-hop ProxyJump chains", () => {
const bastion = chainHost("jump-1", "Bastion", "bastion.example.com", "alice");
const second = chainHost("jump-2", "Second hop", "jump.example.com", "bob", 2222);
const v6 = chainHost("jump-v6", "IPv6 hop", "2001:db8::1", "carol", 2222);
const oneHop = chainHost("app-1", "One hop", "one.example.com", "root");
oneHop.hostChain = { hostIds: [bastion.id] };
const twoHop = chainHost("app-2", "Two hop", "two.example.com", "root");
twoHop.hostChain = { hostIds: [bastion.id, second.id] };
const viaV6 = chainHost("app-v6", "Via IPv6", "v6.example.com", "root");
viaV6.hostChain = { hostIds: [v6.id] };
const direct = chainHost("app-3", "Direct", "direct.example.com", "root");
const { csv } = exportHostsToCsvWithStats([bastion, second, v6, oneHop, twoHop, viaV6, direct]);
assert.equal(proxyJumpCell(csv, bastion.hostname), "");
assert.equal(proxyJumpCell(csv, oneHop.hostname), "alice@bastion.example.com");
assert.equal(proxyJumpCell(csv, twoHop.hostname), "alice@bastion.example.com,bob@jump.example.com:2222");
assert.equal(proxyJumpCell(csv, viaV6.hostname), "carol@[2001:db8::1]:2222");
assert.equal(proxyJumpCell(csv, direct.hostname), "");
assert.match(getVaultCsvTemplate().split(/\r?\n/, 1)[0] ?? "", /(^|,)ProxyJump(,|$)/);
});
test("CSV round-trips a jump host chain onto the imported hosts", () => {
const bastion = chainHost("jump-1", "Bastion", "bastion.example.com", "alice");
const second = chainHost("jump-2", "Second hop", "jump.example.com", "bob", 2222);
const target = chainHost("app", "App", "app.example.com", "root");
target.group = "Prod";
target.hostChain = { hostIds: [bastion.id, second.id] };
const { csv } = exportHostsToCsvWithStats([bastion, second, target]);
const imported = importVaultHostsFromText("csv", csv);
const byHostname = new Map(imported.hosts.map((host) => [host.hostname, host]));
const importedTarget = byHostname.get(target.hostname);
const importedBastion = byHostname.get(bastion.hostname);
const importedSecond = byHostname.get(second.hostname);
assert.equal(imported.issues.length, 0);
assert.deepEqual(importedTarget?.hostChain?.hostIds, [importedBastion?.id, importedSecond?.id]);
assert.equal(importedBastion?.hostChain, undefined);
assert.equal(importedSecond?.port, 2222);
assert.equal(importedSecond?.username, "bob");
});
test("CSV import creates an inline jump host when the hop is not in the file", () => {
const imported = importVaultHostsFromText("csv", [
"Hostname,Username,ProxyJump",
"app.example.com,root,alice@bastion.example.com:2222",
].join("\n"));
const target = imported.hosts.find((host) => host.hostname === "app.example.com");
const jump = imported.hosts.find((host) => host.hostname === "bastion.example.com");
assert.equal(imported.hosts.length, 2);
assert.equal(jump?.username, "alice");
assert.equal(jump?.port, 2222);
assert.equal(jump?.protocol, "ssh");
assert.deepEqual(target?.hostChain?.hostIds, jump ? [jump.id] : []);
assert.ok(imported.issues.some((issue) => issue.level === "warning" && /inline jump host/i.test(issue.message)));
});
test("CSV import binds a missing hop to an existing vault host by endpoint", () => {
const existing = chainHost("vault-jump", "Old bastion", "bastion.example.com", "alice", 2222);
const imported = importVaultHostsFromText("csv", [
"Hostname,Username,ProxyJump",
"app.example.com,root,alice@bastion.example.com:2222",
].join("\n"), { existingHosts: [existing] });
assert.equal(imported.hosts.length, 1);
assert.equal(imported.hosts[0]?.hostname, "app.example.com");
assert.deepEqual(imported.hosts[0]?.hostChain, { hostIds: [existing.id] });
assert.deepEqual(imported.issues, []);
const moved = applyVaultImportDestination(imported, { mode: "group", group: "Moved" });
assert.deepEqual(moved.hosts[0]?.hostChain, { hostIds: [existing.id] });
});
test("CSV import keeps an old file without a ProxyJump column unchanged", () => {
const imported = importVaultHostsFromText("csv", [
"Hostname,Username,Password",
"legacy.example.com,root,secret",
].join("\n"));
assert.equal(imported.hosts.length, 1);
assert.equal(imported.hosts[0]?.hostname, "legacy.example.com");
assert.equal(imported.hosts[0]?.username, "root");
assert.equal(imported.hosts[0]?.password, "secret");
assert.equal(imported.hosts[0]?.hostChain, undefined);
assert.deepEqual(imported.issues, []);
});
test("CSV import retargets a chain when the jump host is already in the vault", () => {
const bastion = chainHost("jump-1", "Bastion", "bastion.example.com", "alice");
bastion.group = "Prod";
const target = chainHost("app", "App", "app.example.com", "root");
target.group = "Prod";
target.hostChain = { hostIds: [bastion.id] };
const existing = chainHost("vault-jump", "Bastion", "bastion.example.com", "alice");
existing.group = "Prod";
const { csv } = exportHostsToCsvWithStats([bastion, target]);
const imported = importVaultHostsFromText("csv", csv, { existingHosts: [existing] });
const merged = applyVaultHostImport([existing], ["Prod"], imported);
assert.equal(merged.addedHosts.length, 1);
assert.equal(merged.addedHosts[0]?.hostname, "app.example.com");
assert.deepEqual(merged.addedHosts[0]?.hostChain, { hostIds: [existing.id] });
assert.equal(merged.hosts.some((host) => host.id !== existing.id && host.hostname === bastion.hostname), false);
});
test("CSV export materializes a jump chain inherited from group defaults", () => {
const bastion = chainHost("jump-1", "Bastion", "bastion.example.com", "alice");
const target = chainHost("app", "App", "app.example.com", "root");
target.group = "Corp";
const { csv } = exportHostsToCsvWithStats([bastion, target], {
groupConfigs: [{ path: "Corp", hostChain: { hostIds: [bastion.id] } }],
});
assert.equal(proxyJumpCell(csv, target.hostname), "alice@bastion.example.com");
});
test("CSV import warns and drops a circular ProxyJump chain", () => {
const imported = importVaultHostsFromText("csv", [
"Hostname,Username,ProxyJump",
"a.example.com,root,b.example.com",
"b.example.com,root,a.example.com",
].join("\n"));
assert.equal(imported.hosts.length, 2);
assert.equal(imported.hosts.every((host) => host.hostChain === undefined), true);
assert.ok(imported.issues.some((issue) => issue.level === "warning" && /circular/i.test(issue.message)));
});
test("CSV import drops inline hops created only for a rejected self-referential ProxyJump", () => {
const imported = importVaultHostsFromText("csv", [
"Hostname,Username,ProxyJump",
"ok.example.com,root,kept-hop.example.com",
"app.example.com,root,\"new-hop.example.com,app.example.com\"",
].join("\n"));
const hostnames = imported.hosts.map((host) => host.hostname).sort();
assert.deepEqual(hostnames, ["app.example.com", "kept-hop.example.com", "ok.example.com"]);
const app = imported.hosts.find((host) => host.hostname === "app.example.com");
const ok = imported.hosts.find((host) => host.hostname === "ok.example.com");
const kept = imported.hosts.find((host) => host.hostname === "kept-hop.example.com");
assert.equal(app?.hostChain, undefined);
assert.deepEqual(ok?.hostChain?.hostIds, kept ? [kept.id] : []);
assert.ok(imported.issues.some((issue) => issue.level === "warning" && /circular/i.test(issue.message)));
});
test("CSV import prefers a same-group existing hop over a cross-group imported match", () => {
const existing = chainHost("vault-jump", "Bastion", "bastion.example.com", "alice");
existing.group = "Prod";
const imported = importVaultHostsFromText("csv", [
"Groups,Hostname,Username,ProxyJump",
"Other,bastion.example.com,alice,",
"Prod,app.example.com,root,alice@bastion.example.com",
].join("\n"), { existingHosts: [existing] });
const app = imported.hosts.find((host) => host.hostname === "app.example.com");
const importedBastion = imported.hosts.find((host) => host.hostname === "bastion.example.com");
assert.equal(importedBastion?.group, "Other");
assert.notEqual(importedBastion?.id, existing.id);
assert.deepEqual(app?.hostChain, { hostIds: [existing.id] });
});
test("CSV import prefers a same-group existing label hop over a cross-group imported label", () => {
const existing = chainHost("vault-jump", "Bastion", "vault-bastion.internal", "alice");
existing.group = "Prod";
const imported = importVaultHostsFromText("csv", [
"Groups,Label,Hostname,Username,ProxyJump",
"Other,Bastion,other-bastion.internal,alice,",
"Prod,App,app.example.com,root,alice@Bastion",
].join("\n"), { existingHosts: [existing] });
const app = imported.hosts.find((host) => host.hostname === "app.example.com");
const importedBastion = imported.hosts.find((host) => host.hostname === "other-bastion.internal");
assert.equal(importedBastion?.group, "Other");
assert.equal(importedBastion?.label, "Bastion");
assert.notEqual(importedBastion?.id, existing.id);
assert.deepEqual(app?.hostChain, { hostIds: [existing.id] });
});
test("CSV import warns and ignores a ProxyJump value that is not a jump host", () => {
const imported = importVaultHostsFromText("csv", [
"Hostname,Username,ProxyJump",
"app.example.com,root,not a hop",
].join("\n"));
assert.equal(imported.hosts.length, 1);
assert.equal(imported.hosts[0]?.hostChain, undefined);
assert.ok(imported.issues.some((issue) => issue.level === "warning" && issue.message.includes("ProxyJump")));
});
test("CSV import rejects a ProxyJump chain that contains a malformed hop", () => {
const imported = importVaultHostsFromText("csv", [
"Hostname,Username,ProxyJump",
"app.example.com,root,\"bastion.example.com,not a hop\"",
].join("\n"));
assert.deepEqual(imported.hosts.map((host) => host.hostname), ["app.example.com"]);
assert.equal(imported.hosts[0]?.hostChain, undefined);
assert.ok(imported.issues.some((issue) => (
issue.level === "warning" && issue.message.includes("not a hop")
)));
});
test("CSV import warns and ignores a malformed LocalShell value", () => {
const csv = [
"Groups,Label,Tags,Notes,Hostname/IP,Protocol,Port,Username,Password,KeyPath,Passphrase,Proxy,LocalShell",
+4 -46
View File
@@ -2,8 +2,7 @@ import type { GroupConfig, Host, Identity, ProxyProfile } from '../models';
import { applyGroupDefaults, resolveGroupDefaults } from '../groupConfig';
import { isPluginHostProtocol } from '../pluginConnection';
import { hasUnreadableProxyCredential, materializeHostProxyProfile, resolveProxyConfigAuth } from '../proxyProfiles';
import { canServeAsSshJumpHost } from '../terminalProtocol';
import { CsvLocalShellSpec, encodeCsvKeyPath, encodeCsvLocalShell, encodeCsvPassphrase, encodeCsvProxy, encodeCsvProxyJump } from './csvCredentialFields';
import { CsvLocalShellSpec, encodeCsvKeyPath, encodeCsvLocalShell, encodeCsvPassphrase, encodeCsvProxy } from './csvCredentialFields';
import { CSV_DIRECT_PROXY_VALUE, formatCsvProxy } from './csvProxy';
const UTF8_BOM = "\uFEFF";
@@ -37,7 +36,7 @@ export const getVaultCsvTemplate = (
opts: VaultCsvTemplateOptions = {},
): string => {
const includeExampleRows = opts.includeExampleRows !== false;
const header = ["Groups", "Label", "Tags", "Notes", "Hostname/IP", "Protocol", "Port", "Username", "Password", "KeyPath", "Passphrase", "Proxy", "ProxyJump", "LocalShell"];
const header = ["Groups", "Label", "Tags", "Notes", "Hostname/IP", "Protocol", "Port", "Username", "Password", "KeyPath", "Passphrase", "Proxy", "LocalShell"];
const rows: string[][] = [header];
if (includeExampleRows) {
rows.push(["Project/Dev", "Web Server (dev)", "dev,web", "Dev web tier", "192.168.1.10", "ssh", "22", "root", "", "~/.ssh/id_ed25519", "", "socks5://127.0.0.1:1080"]);
@@ -78,44 +77,8 @@ const getLocalShellSpec = (host: Host): CsvLocalShellSpec | null => (
: null
);
const UNSAFE_PROXY_JUMP_USERNAME = /[\s,#@]/;
const UNSAFE_PROXY_JUMP_HOSTNAME = /[\s,#@]/;
const DEFAULT_PROXY_JUMP_PORT = 22;
// OpenSSH ProxyJump: [user@]host[:port], repeated. Port 22 is omitted.
// IPv6 is bracketed so the port separator stays unambiguous.
const formatCsvProxyJumpHop = (host: Host): string | null => {
if (!canServeAsSshJumpHost(host)) return null;
const username = host.username?.trim() ?? "";
const hostname = host.hostname.trim().replace(/^\[(.*)\]$/u, "$1");
if (!hostname) return null;
if (username && UNSAFE_PROXY_JUMP_USERNAME.test(username)) return null;
if (UNSAFE_PROXY_JUMP_HOSTNAME.test(hostname)) return null;
const port = host.port ?? DEFAULT_PROXY_JUMP_PORT;
if (!Number.isInteger(port) || port < 1 || port > 65535) return null;
const hostPart = hostname.includes(":") ? `[${hostname}]` : hostname;
const withUser = username ? `${username}@${hostPart}` : hostPart;
return port === DEFAULT_PROXY_JUMP_PORT ? withUser : `${withUser}:${port}`;
};
// An empty cell means "no chain". A chain is omitted entirely when any hop
// cannot be represented, so a shortened list is not written back as the truth.
const formatCsvProxyJump = (host: Host, allHosts: readonly Host[]): string => {
const ids = host.hostChain?.hostIds ?? [];
if (ids.length === 0) return "";
const byId = new Map(allHosts.map((item) => [item.id, item]));
const parts: string[] = [];
for (const id of ids) {
const hop = byId.get(id);
const formatted = hop ? formatCsvProxyJumpHop(hop) : null;
if (!formatted) return "";
parts.push(formatted);
}
return parts.join(",");
};
const exportHostsToCsv = (hosts: Host[], options: VaultCsvExportOptions) => {
const header = ["Groups", "Label", "Tags", "Notes", "Hostname/IP", "Protocol", "Port", "Username", "Password", "KeyPath", "Passphrase", "Proxy", "ProxyJump", "LocalShell"];
const header = ["Groups", "Label", "Tags", "Notes", "Hostname/IP", "Protocol", "Port", "Username", "Password", "KeyPath", "Passphrase", "Proxy", "LocalShell"];
const rows: string[][] = [header];
let unreadableProxyCredentialCount = 0;
@@ -198,9 +161,6 @@ const exportHostsToCsv = (hosts: Host[], options: VaultCsvExportOptions) => {
}
}
const localShellSpec = getLocalShellSpec(host);
// Group defaults are already on effectiveHost, same as Proxy, so a chain
// inherited from the group survives a file that has no group-config section.
const proxyJumpValue = formatCsvProxyJump(effectiveHost, exportableHosts);
rows.push([
host.group ?? "",
@@ -215,7 +175,6 @@ const exportHostsToCsv = (hosts: Host[], options: VaultCsvExportOptions) => {
encodeCsvKeyPath(keyPath),
encodeCsvPassphrase(passphrase),
encodeCsvProxy(proxyValue),
encodeCsvProxyJump(proxyJumpValue),
localShellSpec ? encodeCsvLocalShell(localShellSpec) : "",
]);
}
@@ -224,10 +183,9 @@ const exportHostsToCsv = (hosts: Host[], options: VaultCsvExportOptions) => {
const keyPathColIdx = header.indexOf("KeyPath");
const passphraseColIdx = header.indexOf("Passphrase");
const proxyColIdx = header.indexOf("Proxy");
const proxyJumpColIdx = header.indexOf("ProxyJump");
const csv = rows.map((r, rowIdx) => r.map((c, i) => escapeCsv(
c,
rowIdx > 0 && (i === passwordColIdx || i === keyPathColIdx || i === passphraseColIdx || i === proxyColIdx || i === proxyJumpColIdx),
rowIdx > 0 && (i === passwordColIdx || i === keyPathColIdx || i === passphraseColIdx || i === proxyColIdx),
)).join(",")).join("\r\n") + "\r\n";
return { csv, unreadableProxyCredentialCount };
};
-175
View File
@@ -1,175 +0,0 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
MARQUEE_MOVEMENT_THRESHOLD_PX,
clientPointToContent,
contentRectFromClientBox,
exceedsMarqueeThreshold,
hostIdsIntersectingMarquee,
isPointerOnNativeScrollbar,
marqueeAutoScrollDelta,
marqueeCancelSelection,
marqueeCommitSelection,
marqueeRectFromPoints,
marqueeSelectionMode,
mergeMarqueeSelection,
rememberHostPlacements,
shouldStartVaultMarquee,
type HostPlacement,
} from './vaultMarqueeSelection.ts';
const box = (left: number, top: number, right: number, bottom: number) => ({
left, top, right, bottom,
});
test('marquee rect normalizes a drag that moves up and left', () => {
assert.deepEqual(
marqueeRectFromPoints({ x: 40, y: 50 }, { x: 10, y: 20 }),
box(10, 20, 40, 50),
);
});
test('marquee movement threshold keeps a plain click below 4px', () => {
const start = { x: 10, y: 10 };
assert.equal(exceedsMarqueeThreshold(start, { x: 13, y: 12 }), false);
assert.equal(
exceedsMarqueeThreshold(start, { x: 10 + MARQUEE_MOVEMENT_THRESHOLD_PX, y: 10 }),
true,
);
assert.equal(exceedsMarqueeThreshold(start, { x: 10, y: 14 }), true);
});
test('marquee modifiers replace, add with shift, and toggle with ctrl or meta', () => {
assert.equal(marqueeSelectionMode({ shiftKey: false, ctrlKey: false, metaKey: false }), 'replace');
assert.equal(marqueeSelectionMode({ shiftKey: true, ctrlKey: false, metaKey: false }), 'add');
assert.equal(marqueeSelectionMode({ shiftKey: false, ctrlKey: true, metaKey: false }), 'toggle');
assert.equal(marqueeSelectionMode({ shiftKey: false, ctrlKey: false, metaKey: true }), 'toggle');
assert.equal(marqueeSelectionMode({ shiftKey: true, ctrlKey: true, metaKey: false }), 'toggle');
});
test('marquee selection merge uses the selection from drag start', () => {
const baseline = new Set(['a', 'b']);
const hits = new Set(['b', 'c']);
assert.deepEqual([...mergeMarqueeSelection(baseline, hits, 'replace')].sort(), ['b', 'c']);
assert.deepEqual([...mergeMarqueeSelection(baseline, hits, 'add')].sort(), ['a', 'b', 'c']);
assert.deepEqual([...mergeMarqueeSelection(baseline, hits, 'toggle')].sort(), ['a', 'c']);
assert.deepEqual([...baseline].sort(), ['a', 'b']);
});
test('host cards that intersect the marquee are selected, including partial overlap', () => {
const placements = [
{ id: 'inside', rect: box(10, 10, 30, 30) },
{ id: 'partial', rect: box(28, 28, 50, 50) },
{ id: 'touch', rect: box(40, 10, 55, 20) },
{ id: 'miss', rect: box(41, 10, 60, 20) },
];
assert.deepEqual(
[...hostIdsIntersectingMarquee(box(0, 0, 40, 40), placements)].sort(),
['inside', 'partial'],
);
});
test('placement cache keeps hosts that scroll out of the mounted set', () => {
const cache = new Map<string, HostPlacement>();
rememberHostPlacements(cache, [
{ key: 'main:a', id: 'a', rect: box(0, 0, 10, 10) },
{ key: 'pinned:a', id: 'a', rect: box(0, 100, 10, 110) },
{ key: 'main:b', id: 'b', rect: box(0, 20, 10, 30) },
]);
rememberHostPlacements(cache, [
{ key: 'main:b', id: 'b', rect: box(0, 20, 12, 30) },
{ key: 'main:c', id: 'c', rect: box(0, 40, 10, 50) },
]);
const hits = hostIdsIntersectingMarquee(box(0, 0, 10, 50), cache.values());
assert.deepEqual([...hits].sort(), ['a', 'b', 'c']);
assert.equal(cache.get('main:b')?.rect.right, 12);
});
test('content coordinates stay stable when the scroll container moves', () => {
const frame = {
left: 100,
top: 40,
scrollLeft: 0,
scrollTop: 25,
clientLeft: 0,
clientTop: 0,
};
assert.deepEqual(clientPointToContent({ x: 130, y: 80 }, frame), { x: 30, y: 65 });
const beforeScroll = contentRectFromClientBox(
{ left: 120, top: 70, width: 40, height: 20 },
frame,
);
const afterScroll = contentRectFromClientBox(
{ left: 120, top: 50, width: 40, height: 20 },
{ ...frame, scrollTop: 45 },
);
assert.deepEqual(beforeScroll, afterScroll);
assert.deepEqual(beforeScroll, box(20, 55, 60, 75));
});
test('auto-scroll only steps when the pointer is near a vertical edge', () => {
assert.equal(marqueeAutoScrollDelta(10, 0, 200), -16);
assert.equal(marqueeAutoScrollDelta(190, 0, 200), 16);
assert.equal(marqueeAutoScrollDelta(100, 0, 200), 0);
});
test('native scrollbar gutter does not start a marquee', () => {
const metrics = {
rectLeft: 0,
rectTop: 0,
clientLeft: 0,
clientTop: 0,
clientWidth: 200,
clientHeight: 300,
};
assert.equal(isPointerOnNativeScrollbar(metrics, 190, 40), false);
assert.equal(isPointerOnNativeScrollbar(metrics, 210, 40), true);
assert.equal(isPointerOnNativeScrollbar(metrics, 20, 310), true);
});
test('marquee starts on primary grid or list presses in empty space only', () => {
const ready = {
viewMode: 'grid',
button: 0,
pointerType: 'mouse',
onScrollbar: false,
blockedTarget: false,
};
assert.equal(shouldStartVaultMarquee(ready), true);
assert.equal(shouldStartVaultMarquee({ ...ready, viewMode: 'list' }), true);
assert.equal(shouldStartVaultMarquee({ ...ready, viewMode: 'tree' }), false);
assert.equal(shouldStartVaultMarquee({ ...ready, button: 2 }), false);
assert.equal(shouldStartVaultMarquee({ ...ready, pointerType: 'touch' }), false);
assert.equal(shouldStartVaultMarquee({ ...ready, onScrollbar: true }), false);
assert.equal(shouldStartVaultMarquee({ ...ready, blockedTarget: true }), false);
});
test('committing a marquee enters multi-select only when hosts are selected', () => {
assert.deepEqual(
marqueeCommitSelection({ wasMultiSelectMode: false, nextIds: new Set(['a']) }),
{ isMultiSelectMode: true, selectedHostIds: new Set(['a']) },
);
assert.deepEqual(
marqueeCommitSelection({ wasMultiSelectMode: false, nextIds: new Set() }),
{ isMultiSelectMode: false, selectedHostIds: new Set() },
);
assert.deepEqual(
marqueeCommitSelection({ wasMultiSelectMode: true, nextIds: new Set() }),
{ isMultiSelectMode: true, selectedHostIds: new Set() },
);
});
test('escape restores the selection captured at drag start', () => {
assert.deepEqual(
marqueeCancelSelection({
wasMultiSelectMode: false,
baselineIds: new Set(['kept']),
}),
{ isMultiSelectMode: false, selectedHostIds: new Set(['kept']) },
);
});
-182
View File
@@ -1,182 +0,0 @@
export const MARQUEE_MOVEMENT_THRESHOLD_PX = 4;
export const MARQUEE_AUTOSCROLL_EDGE_PX = 28;
export const MARQUEE_AUTOSCROLL_STEP_PX = 16;
export interface MarqueePoint {
x: number;
y: number;
}
export interface MarqueeRect {
left: number;
top: number;
right: number;
bottom: number;
}
export interface MarqueeContentFrame {
left: number;
top: number;
scrollLeft: number;
scrollTop: number;
clientLeft?: number;
clientTop?: number;
}
export interface ScrollbarMetrics {
rectLeft: number;
rectTop: number;
clientLeft: number;
clientTop: number;
clientWidth: number;
clientHeight: number;
}
export type MarqueeSelectionMode = 'replace' | 'add' | 'toggle';
export interface HostPlacement {
key: string;
id: string;
rect: MarqueeRect;
}
export function marqueeRectFromPoints(start: MarqueePoint, current: MarqueePoint): MarqueeRect {
return {
left: Math.min(start.x, current.x),
top: Math.min(start.y, current.y),
right: Math.max(start.x, current.x),
bottom: Math.max(start.y, current.y),
};
}
export function exceedsMarqueeThreshold(
start: MarqueePoint,
current: MarqueePoint,
threshold = MARQUEE_MOVEMENT_THRESHOLD_PX,
): boolean {
return Math.abs(current.x - start.x) >= threshold || Math.abs(current.y - start.y) >= threshold;
}
export function marqueeSelectionMode(modifiers: {
shiftKey: boolean;
ctrlKey: boolean;
metaKey: boolean;
}): MarqueeSelectionMode {
if (modifiers.ctrlKey || modifiers.metaKey) return 'toggle';
if (modifiers.shiftKey) return 'add';
return 'replace';
}
export function rectsIntersect(a: MarqueeRect, b: MarqueeRect): boolean {
return a.left < b.right && a.right > b.left && a.top < b.bottom && a.bottom > b.top;
}
export function mergeMarqueeSelection(
baseline: ReadonlySet<string>,
hitIds: ReadonlySet<string>,
mode: MarqueeSelectionMode,
): Set<string> {
if (mode === 'replace') return new Set(hitIds);
const next = new Set(baseline);
for (const id of hitIds) {
if (mode === 'toggle' && next.has(id)) next.delete(id);
else next.add(id);
}
return next;
}
export function hostIdsIntersectingMarquee(
marquee: MarqueeRect,
placements: Iterable<{ id: string; rect: MarqueeRect }>,
): Set<string> {
const hits = new Set<string>();
for (const placement of placements) {
if (rectsIntersect(marquee, placement.rect)) hits.add(placement.id);
}
return hits;
}
export function rememberHostPlacements(
cache: Map<string, HostPlacement>,
visible: readonly HostPlacement[],
): Map<string, HostPlacement> {
for (const placement of visible) cache.set(placement.key, placement);
return cache;
}
export function clientPointToContent(client: MarqueePoint, frame: MarqueeContentFrame): MarqueePoint {
return {
x: client.x - frame.left - (frame.clientLeft ?? 0) + frame.scrollLeft,
y: client.y - frame.top - (frame.clientTop ?? 0) + frame.scrollTop,
};
}
export function contentRectFromClientBox(
box: { left: number; top: number; width: number; height: number },
frame: MarqueeContentFrame,
): MarqueeRect {
const origin = clientPointToContent({ x: box.left, y: box.top }, frame);
return {
left: origin.x,
top: origin.y,
right: origin.x + box.width,
bottom: origin.y + box.height,
};
}
export function marqueeAutoScrollDelta(
client: number,
start: number,
end: number,
edge = MARQUEE_AUTOSCROLL_EDGE_PX,
step = MARQUEE_AUTOSCROLL_STEP_PX,
): number {
if (client < start + edge) return -step;
if (client > end - edge) return step;
return 0;
}
export function isPointerOnNativeScrollbar(
metrics: ScrollbarMetrics,
clientX: number,
clientY: number,
): boolean {
const x = clientX - metrics.rectLeft - metrics.clientLeft;
const y = clientY - metrics.rectTop - metrics.clientTop;
return x > metrics.clientWidth || y > metrics.clientHeight;
}
export function shouldStartVaultMarquee(input: {
viewMode: string;
button: number;
pointerType?: string;
onScrollbar: boolean;
blockedTarget: boolean;
}): boolean {
if (input.viewMode !== 'grid' && input.viewMode !== 'list') return false;
if (input.button !== 0) return false;
if (input.pointerType === 'touch') return false;
if (input.onScrollbar || input.blockedTarget) return false;
return true;
}
export function marqueeCommitSelection(input: {
wasMultiSelectMode: boolean;
nextIds: ReadonlySet<string>;
}): { isMultiSelectMode: boolean; selectedHostIds: Set<string> } {
const selectedHostIds = new Set(input.nextIds);
return {
isMultiSelectMode: selectedHostIds.size > 0 || input.wasMultiSelectMode,
selectedHostIds,
};
}
export function marqueeCancelSelection(input: {
wasMultiSelectMode: boolean;
baselineIds: ReadonlySet<string>;
}): { isMultiSelectMode: boolean; selectedHostIds: Set<string> } {
return {
isMultiSelectMode: input.wasMultiSelectMode,
selectedHostIds: new Set(input.baselineIds),
};
}
-3
View File
@@ -82,9 +82,6 @@ module.exports = {
'lib/**/*.json',
'skills/**/*',
'!public/**/*',
// README screenshots live under docs/ and are not app resources.
// Keep them out of app.asar if the files globs ever become permissive.
'!docs/assets/readme/**/*',
'!**/*.map',
'!**/*.d.ts',
'!**/*.d.mts',
@@ -552,7 +552,7 @@ function createStopWait() {
};
}
async function runOpenCodeV1Turn({
async function runOpenCodeTurn({
prompt, systemPrompt, attachments, cwd, model, injectedMcpServers, toolIntegrationMode,
skillsPathAllowlist, resumeSessionId, env, binPath, emitter, abortController, openCodeFactory, assertSessionValid,
}) {
@@ -940,21 +940,10 @@ function resetOpenCodeListServerPool() {
disposeOpenCodeListServer(key, entry);
}
openCodeListServers.clear();
require("./opencodeV2.cjs").resetOpenCodeV2ListServerPool();
}
async function runOpenCodeTurn(args) {
const { shouldUseOpenCodeV2, runOpenCodeV2Turn } = require("./opencodeV2.cjs");
if (await shouldUseOpenCodeV2(args)) return runOpenCodeV2Turn(args);
return runOpenCodeV1Turn(args);
}
async function listOpenCodeModels({ env, binPath, openCodeFactory, abortController, signal, ...rest } = {}) {
async function listOpenCodeModels({ env, binPath, openCodeFactory, abortController, signal } = {}) {
const effectiveSignal = signal || abortController?.signal;
if (effectiveSignal?.aborted) return emptyOpenCodeModelCatalog();
const { shouldUseOpenCodeV2, listOpenCodeV2Models } = require("./opencodeV2.cjs");
const selection = { env, binPath, openCodeFactory, abortController, signal, ...rest };
if (await shouldUseOpenCodeV2(selection)) return listOpenCodeV2Models(selection);
let acquired = null;
try {
if (effectiveSignal?.aborted) return emptyOpenCodeModelCatalog();
@@ -24,10 +24,6 @@ const fs = require('node:fs');
const http = require('node:http');
const path = require('node:path');
const root = ${JSON.stringify(root)};
if (process.argv.includes('--version')) {
console.log('1.19.3');
process.exit(0);
}
const profile = path.basename(process.env.HOME);
fs.appendFileSync(path.join(root, 'started'), profile + '\\n');
if (profile === 'profile-failed') process.exit(7);
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+1 -4
View File
@@ -63,10 +63,7 @@ function queryDirtyEditors(webContents, timeoutMs, options = {}) {
// consume the listener slot and let the real reply fall through. A
// missing/falsy sender is anomalous and treated as a wrong-window reply.
if (evt?.sender !== webContents) return;
// "transfers": the user already declined the renderer's unfinished-
// transfer quit prompt. Still truthy, so existing callers keep quit
// cancelled; handleBeforeQuit uses it to skip the editor dialog.
settle(payload?.hasDirty === true ? (payload?.reason === "transfers" ? "transfers" : true) : false);
settle(payload?.hasDirty === true);
}
ipcMain.on("app:dirty-editors-result", onResult);
@@ -221,15 +221,3 @@ test("resolves false when no ipcMain can be resolved", async () => {
assert.equal(await queryDirtyEditors(wc, 5000), false);
assert.deepEqual(wc.sent, []);
});
test("carries a declined unfinished-transfer prompt as a truthy \"transfers\" result", async () => {
const ipcMain = makeIpcMain();
const wc = makeWebContents();
const promise = queryDirtyEditors(wc, 5000, { ipcMain });
ipcMain.emit("app:dirty-editors-result", { sender: wc }, { hasDirty: true, reason: "transfers" });
assert.equal(await promise, "transfers");
// A clean reply that names a reason is still clean.
const clean = queryDirtyEditors(wc, 5000, { ipcMain });
ipcMain.emit("app:dirty-editors-result", { sender: wc }, { hasDirty: false, reason: "transfers" });
assert.equal(await clean, false);
});
+1 -27
View File
@@ -209,32 +209,13 @@ const createHostVerifier = ({
knownHosts = [],
verifyHostKeys = true,
bootEpoch,
// Transfer dials have no SFTP-page prompt owner. Waiting out the request
// TTL leaves the upload on "Waiting..." with no dialog and no error.
unattended = false,
onAccepted,
}) => (rawKey, callback) => {
const keyInfo = describeHostKey(rawKey);
const noteAccepted = () => {
try {
onAccepted?.({
hostname,
port,
keyType: keyInfo.keyType,
fingerprint: keyInfo.fingerprint,
publicKey: keyInfo.publicKey,
});
} catch {
// Recording the accepted key must not change the verifier result.
}
};
if (verifyHostKeys === false) {
noteAccepted();
callback(true);
return;
}
const keyInfo = describeHostKey(rawKey);
const decision = classifyHostKey({
knownHosts,
hostname,
@@ -244,16 +225,10 @@ const createHostVerifier = ({
});
if (decision.status === "trusted") {
noteAccepted();
callback(true);
return;
}
if (unattended) {
callback(false);
return;
}
void requestHostKeyVerification(sender, {
sessionId,
hostname,
@@ -266,7 +241,6 @@ const createHostVerifier = ({
knownFingerprint: decision.expectedFingerprint,
...(Number.isFinite(bootEpoch) ? { bootEpoch: Number(bootEpoch) } : {}),
}).then((response) => {
if (response?.accept) noteAccepted();
callback(Boolean(response?.accept));
}).catch(() => {
callback(false);
+4 -5
View File
@@ -34,11 +34,10 @@ function createProcessTree({ platform, listPosix, listWindows } = {}) {
function defaultListPosix(ppid, { execFileFn = execFile } = {}) {
return new Promise((resolve) => {
// Separate `-o col=` options. A single `-o pid=,ppid=,args=` spec is
// misread by procps-ng 3.3.x: the text after the first `=` becomes the
// header, so only pid is printed. The split form works on BSD (macOS),
// modern procps, and BusyBox. `args=` is the full command line.
execFileFn("ps", ["-A", "-o", "pid=", "-o", "ppid=", "-o", "args="], {
// `ps -A -o pid=,ppid=,args=` works on both BSD (macOS) and GNU (Linux).
// `args=` shows the full command line (not truncated like `comm=`).
// The trailing `=` on each column suppresses the header row.
execFileFn("ps", ["-A", "-o", "pid=,ppid=,args="], {
timeout: POSIX_PROCESS_LIST_TIMEOUT_MS,
maxBuffer: POSIX_PROCESS_LIST_MAX_BUFFER_BYTES,
windowsHide: true,
+1 -1
View File
@@ -101,7 +101,7 @@ test("default posix process listing bounds the child lifetime and captured outpu
{ pid: 2003, command: "python worker.py" },
]);
assert.equal(invocation.command, "ps");
assert.deepEqual(invocation.args, ["-A", "-o", "pid=", "-o", "ppid=", "-o", "args="]);
assert.deepEqual(invocation.args, ["-A", "-o", "pid=,ppid=,args="]);
assert.equal(invocation.options.timeout, POSIX_PROCESS_LIST_TIMEOUT_MS);
assert.equal(invocation.options.maxBuffer, POSIX_PROCESS_LIST_MAX_BUFFER_BYTES);
assert.equal(invocation.options.windowsHide, true);
+8 -25
View File
@@ -1576,32 +1576,17 @@ async function runRemoteUploadTransaction(client, localPath, remotePath, options
}
const cleanupStage = async () => {
// The owner channel is already gone. unlink/delete would wait forever for
// a callback ssh2 never sends, and the transfer would stay on screen.
const channel = client?.sftp;
if (channel && (channel.readable === false || channel.destroyed === true)) return;
const removeStage = scpMode
? getScpBackendForClient(client).remove(stagedLogical, {
recursive: false,
encoding,
})
: typeof client.delete === "function"
? client.delete(encodedStagedPath)
: null;
if (!removeStage) return;
let timer = null;
try {
await Promise.race([
Promise.resolve(removeStage),
new Promise((_, reject) => {
timer = setTimeout(() => reject(new Error("SFTP channel closed")), 2000);
if (typeof timer.unref === "function") timer.unref();
}),
]);
if (scpMode) {
await getScpBackendForClient(client).remove(stagedLogical, {
recursive: false,
encoding,
});
} else if (typeof client.delete === "function") {
await client.delete(encodedStagedPath);
}
} catch {
// Best-effort cleanup of a partial stage.
} finally {
if (timer) clearTimeout(timer);
}
};
@@ -2400,7 +2385,6 @@ const {
completeTransportDial,
failTransportDial,
buildEndpointKey,
buildChannelAdoptionKey,
buildConnectionReuseEndpoint,
resolveConnectionKeepalivePolicy,
endpointAllowsReuse,
@@ -2423,7 +2407,6 @@ const openConnectionApi = createOpenConnectionApi({
createTransport, borrowTransport, findTransportByEndpoint,
beginTransportDial, waitForTransportDial, completeTransportDial, failTransportDial,
buildEndpointKey,
buildChannelAdoptionKey,
buildConnectionReuseEndpoint,
resolveConnectionKeepalivePolicy,
});
@@ -120,12 +120,6 @@ test("openSftp clears its authentication timer when SSH becomes ready", () => {
);
});
test("openSftp disables Nagle on direct and jump-hop SSH sockets", () => {
const source = readFileSync(require.resolve("./sftpBridge/openConnection.cjs"), "utf8");
assert.match(source, /sshClient\.once\('ready', \(\) => \{\s*clearAuthReadyTimer\(\);\s*cleanup\(\);[\s\S]{0,200}enableSshNoDelay\(sshClient\);/);
assert.match(source, /conn\.once\('ready', \(\) => \{[\s\S]{0,400}enableSshNoDelay\(conn\);/);
});
test("openSftp forwards target hostId to keyboard-interactive prompts", () => {
const source = readFileSync(require.resolve("./sftpBridge/openConnection.cjs"), "utf8");
assert.match(
File diff suppressed because it is too large. Load diff
+2 -45
View File
@@ -555,41 +555,6 @@ function createFileOpsApi(ctx) {
return true;
}
/**
* Drop the alias's lease on the login client. If the SFTP page already
* asked to close that client, end it only after the last borrower leaves.
*/
function releaseSharedChannelOwner(client) {
const ownerId = client?.__netcattySharedChannelOwnerId;
const aliasId = client?.__netcattySharedChannelAliasId;
if (!ownerId || !aliasId || client.__netcattyOwnerLeaseReleased) return;
client.__netcattyOwnerLeaseReleased = true;
let store;
let result;
try {
const lease = require("../sftpTransferSessionLease.cjs");
store = lease.sftpTransferSessionLeaseStore;
result = store.release(ownerId, lease.channelAliasLeaseId(aliasId));
} catch {
return;
}
if (!result?.shouldHardClose) return;
const closeToken = store.getPendingHardCloseToken(ownerId);
setImmediate(() => {
if (store.isHeld(ownerId)) {
store.markSoftClosed(ownerId);
return;
}
if (!store.commitHardClose(ownerId, closeToken)) return;
void closeSftp(null, { sftpId: ownerId, force: true }).catch((err) => {
console.warn(
`[SFTP] Deferred close of borrowed owner ${ownerId} failed:`,
err?.message || err,
);
});
});
}
/**
* Close an SFTP connection.
* Also cleans up any jump host connections and file watchers if present.
@@ -701,16 +666,8 @@ function createFileOpsApi(ctx) {
};
}
} catch { /* optional */ }
// Alias handles share the login channel. Closing the transfer id must
// not end that channel or the SFTP page listing dies with the upload.
// Releasing the owner lease lets a deferred closeSftp(owner) finish
// only after every borrower is gone.
if (client.__netcattySharedChannelAlias) {
client.__netcattyDisposed = true;
client.sftp = null;
try { await client.end?.(); } catch { /* alias end only detaches */ }
releaseSharedChannelOwner(client);
} else if (client.__netcattyRefHolder || client.__netcattyTransportManaged || client.__netcattySessionBacked) {
// Transport-managed / session-backed: close channel + return lease only.
if (client.__netcattyRefHolder || client.__netcattyTransportManaged || client.__netcattySessionBacked) {
try {
if (client.sftp && typeof client.sftp.end === "function") client.sftp.end();
else if (client.sftp && typeof client.sftp.close === "function") client.sftp.close();
@@ -9,7 +9,6 @@ const {
terminateSshExecStream,
} = require("../boundedSshExec.cjs");
const { openBoundedSftpChannel } = require("../boundedSftpOpen.cjs");
const { enableSshNoDelay } = require("../tcpNoDelay.cjs");
const { openBoundedForwardOutCallback } = require("../boundedSshChannelOpen.cjs");
const { remoteSoftwareRequiresSingleChannel } = require("../../../domain/singleChannelSshBanner.shared.cjs");
@@ -78,17 +77,6 @@ function shouldRegisterFreshSftpTransport(options) {
return allowsSharedSftpTransport(options);
}
function sftpChannelIsLive(sftp) {
// A closed ssh2 SFTP stream still has readdir/stat/open. readable/destroyed
// are what change when the subsystem or the socket has already ended.
return !!sftp
&& sftp.readable !== false
&& sftp.destroyed !== true
&& typeof sftp.readdir === "function"
&& typeof sftp.stat === "function"
&& typeof sftp.open === "function";
}
/** Sudo is part of the requested connection contract; never downgrade it. */
async function openRequiredSudoSftp({
connectSudoSftp,
@@ -251,12 +239,6 @@ function createOpenConnectionApi(ctx) {
port: jump.port || 22,
knownHosts: options.knownHosts,
verifyHostKeys: jump.verifyHostKeys ?? options.verifyHostKeys,
unattended: options.hostKeyUnattended === true,
onAccepted: (info) => {
if (!info) return;
if (!Array.isArray(options._acceptedHostKeys)) options._acceptedHostKeys = [];
options._acceptedHostKeys.push(info);
},
});
// Auth - support agent (certificate), key, and password fallback
@@ -421,10 +403,6 @@ function createOpenConnectionApi(ctx) {
if (settled) return;
settled = true;
clearAuthReadyTimer();
// SFTP RPCs are small request/response packets; with Nagle on, a
// second request behind an unacked one waits for the peer's
// delayed ACK (~40 ms per round trip on Linux, up to 200 ms on Windows).
enableSshNoDelay(conn);
console.log(`[SFTP Chain] Hop ${i + 1}/${jumpHosts.length}: ${hopLabel} connected`);
sendSftpProgress(sender, connId, hopLabel, 'connected');
resolve();
@@ -790,142 +768,6 @@ function createOpenConnectionApi(ctx) {
});
}
/**
* A live SFTP channel that was not parked in the SSH transport registry
* (single-channel bastion, or sudo elevation). Another open must borrow
* that channel. A second subsystem drops TERM-SSHD / similar logins, and
* a fresh SSH dial would ask for OTP again.
*/
function hopsRequiringHostKeyCheck(request) {
const hops = [];
const add = (hostname, port) => {
const normalized = String(hostname || "").trim().toLowerCase();
if (!normalized) return;
hops.push({ hostname: normalized, port: Number(port) || 22 });
};
if (request?.verifyHostKeys !== false) add(request?.hostname, request?.port);
for (const hop of Array.isArray(request?.jumpHosts) ? request.jumpHosts : []) {
if ((hop?.verifyHostKeys ?? request?.verifyHostKeys) === false) continue;
add(hop?.hostname, hop?.port);
}
return hops;
}
function acceptedHostKeysStillTrusted(source, request) {
// Every hop whose effective policy still verifies must have an accepted
// key that classifies as trusted. Disabling the target does not skip a
// jump host that explicitly verifies.
const required = hopsRequiringHostKeyCheck(request);
if (required.length === 0) return true;
const keys = source?.__netcattyAcceptedHostKeys;
if (!Array.isArray(keys) || keys.length === 0) return false;
const knownHosts = Array.isArray(request?.knownHosts) ? request.knownHosts : [];
return required.every((hop) => keys.some((key) => {
const hostname = String(key?.hostname || "").trim().toLowerCase();
const port = Number(key?.port) || 22;
if (hostname !== hop.hostname || port !== hop.port) return false;
return hostKeyVerifier.classifyHostKey({
knownHosts,
hostname: key.hostname,
port: key.port || 22,
keyType: key.keyType,
fingerprint: key.fingerprint,
}).status === "trusted";
}));
}
function authenticatedChannelMatches(client, endpoint, request) {
const exact = buildEndpointKey(endpoint);
const exactMatch = !!(exact && client.__netcattyEndpointKey === exact);
if (!exactMatch) {
// The known-host digest can change when a still-valid key is saved, or
// when an unrelated record is added. That may reuse the live channel.
if (typeof buildChannelAdoptionKey !== "function") return false;
const adoption = buildChannelAdoptionKey(endpoint);
if (!adoption || client.__netcattyChannelAdoptionKey !== adoption) return false;
}
// Exact endpoint keys are not enough. A session trusted from an empty
// list and a later open that is also empty (the key was removed) share
// a digest, and must still be checked against the caller's records.
return acceptedHostKeysStillTrusted(client, request);
}
function channelContractMatches(options, source) {
if (Boolean(options?.sudo) !== Boolean(source?.__netcattySudoMode)) return false;
const requested = normalizeFileProtocol(options?.fileProtocol);
const owner = source?.__netcattyFileProtocol === "scp" ? "scp" : "sftp";
// auto follows the live channel. A forced protocol must be the same one
// already open, or SCP would silently come back as SFTP.
if (requested === "auto") return true;
return requested === owner;
}
function findAuthenticatedChannelOwner(endpoint, request) {
if (!endpoint || !sftpClients || typeof sftpClients.entries !== "function") return null;
for (const [sftpId, client] of sftpClients.entries()) {
if (!sftpId || !client || client.__netcattyDisposed || client.__netcattySharedChannelAlias) continue;
if (client.__netcattyTransportManaged) continue;
if (!authenticatedChannelMatches(client, endpoint, request)) continue;
if (!client.__netcattySingleChannelSsh && !client.__netcattySudoMode) continue;
if (!sftpChannelIsLive(client.sftp)) continue;
return { sftpId, client };
}
return null;
}
function adoptAuthenticatedSftpChannel(endpoint, connId, options) {
const found = findAuthenticatedChannelOwner(endpoint, options);
if (!found || typeof createSessionBackedSftpClient !== "function") return null;
const { sftpId: ownerId, client: source } = found;
if (!channelContractMatches(options, source)) return null;
// A pane reconnect can ask for the owner's own id while a transfer still
// holds that soft-closed session. Storing the alias under that id would
// drop the SSH client and lease the id to itself, so closeSftp could
// never hard-close it.
const aliasId = connId === ownerId ? `${ownerId}:reconnect:${randomUUID()}` : connId;
let leaseStore = null;
let leaseId = null;
try {
const lease = require("../sftpTransferSessionLease.cjs");
leaseStore = lease.sftpTransferSessionLeaseStore;
leaseId = lease.channelAliasLeaseId(aliasId);
// Hold the owner until this alias is torn down. closeSftp(owner) then
// soft-closes instead of ending the only SFTP channel mid-transfer.
if (!leaseStore.acquire(ownerId, leaseId)) return null;
} catch {
return null;
}
try {
const alias = createSessionBackedSftpClient(aliasId, source.client, {
singleChannelSsh: true,
});
alias.sftp = source.sftp;
alias.__netcattyFileProtocol = source.__netcattyFileProtocol || "sftp";
alias.__netcattySudoMode = !!source.__netcattySudoMode;
// Keep uploads on this channel. A disposable subsystem would either drop
// the single-channel login or bypass sudo elevation.
alias.__netcattySingleChannelSsh = true;
alias.__netcattySharedChannelAlias = true;
alias.__netcattySharedChannelOwnerId = ownerId;
alias.__netcattySharedChannelAliasId = aliasId;
alias.__netcattyEndpointKey = source.__netcattyEndpointKey;
alias.__netcattyChannelAdoptionKey = source.__netcattyChannelAdoptionKey;
alias.__netcattyTransportManaged = false;
alias.end = async () => {
alias.__netcattyDisposed = true;
alias.sftp = null;
};
sftpClients.set(aliasId, alias);
return {
sftpId: aliasId,
fileProtocol: alias.__netcattyFileProtocol === "scp" ? "scp" : "sftp",
};
} catch (err) {
try { leaseStore?.release(ownerId, leaseId); } catch { /* ignore */ }
throw err;
}
}
/**
* Open a new SFTP connection
* Supports jump host connections when options.jumpHosts is provided
@@ -937,32 +779,6 @@ function createOpenConnectionApi(ctx) {
});
let pendingDialCoordination = options._pendingDialCoordination || null;
// Same rejection as openSftpForSession / the post-ready fresh path.
// Doing it before adoption stops {sudo, fileProtocol:'scp'} from
// borrowing an elevated SFTP channel and reporting success.
if (Boolean(options.sudo) && normalizeFileProtocol(options.fileProtocol) === "scp") {
throw new Error(
"Sudo Mode is not supported with File Protocol set to SCP. Disable Sudo Mode or use Auto/SFTP.",
);
}
// Do this before dial coordination. An in-flight single-channel login is
// the dial leader and never registers a transport; joining it waits
// forever and never shows keyboard-interactive.
if (options.reuseTransport !== false && !pendingDialCoordination) {
const adopted = adoptAuthenticatedSftpChannel(reuseEndpoint, connId, options);
if (adopted) {
sendSftpProgress(
event.sender,
connId,
options.hostname,
"connected",
"reused authenticated SFTP session",
);
return adopted;
}
}
const openOnSharedTransport = async (transport, detail = "reusing shared SSH transport") => {
if (
options.singleChannelSsh
@@ -1055,20 +871,6 @@ function createOpenConnectionApi(ctx) {
: await waitForTransportDial(coordination);
return await openOnSharedTransport(transport, "reused coordinated transport");
} catch (coordinationErr) {
// The leader may have published a live channel and then rejected
// this dial (single-channel bastion, or sudo which is not parked).
// Adopt that channel before failing the transfer or dialing again.
const adopted = adoptAuthenticatedSftpChannel(reuseEndpoint, connId, options);
if (adopted) {
sendSftpProgress(
event.sender,
connId,
options.hostname,
"connected",
"reused authenticated SFTP session",
);
return adopted;
}
if (
coordination.role === "join"
&& coordinationErr?.code !== "ERR_SFTP_SINGLE_CHANNEL_BASTION"
@@ -1096,9 +898,6 @@ function createOpenConnectionApi(ctx) {
const client = new SftpClient();
let peerSingleChannelSsh = options.singleChannelSsh === true;
client.__netcattyEndpointKey = buildEndpointKey(reuseEndpoint);
if (typeof buildChannelAdoptionKey === "function") {
client.__netcattyChannelAdoptionKey = buildChannelAdoptionKey(reuseEndpoint);
}
let freshClientClosed = false;
const closeFreshClient = () => {
if (freshClientClosed) return;
@@ -1195,12 +994,6 @@ function createOpenConnectionApi(ctx) {
port: options.port || 22,
knownHosts: options.knownHosts,
verifyHostKeys: options.verifyHostKeys,
unattended: options.hostKeyUnattended === true,
onAccepted: (info) => {
if (!info) return;
if (!Array.isArray(options._acceptedHostKeys)) options._acceptedHostKeys = [];
options._acceptedHostKeys.push(info);
},
});
// Use the tunneled socket if we have one
@@ -1457,8 +1250,6 @@ function createOpenConnectionApi(ctx) {
sshClient.once('ready', () => {
clearAuthReadyTimer();
cleanup();
// See jump-hop note above: SFTP is RPC-bound, so disable Nagle.
enableSshNoDelay(sshClient);
if (remoteSoftwareRequiresSingleChannel(sshClient._remoteVer)) {
peerSingleChannelSsh = true;
}
@@ -1609,25 +1400,7 @@ function createOpenConnectionApi(ctx) {
if (client.client) {
client.client.__netcattySingleChannelSsh = peerSingleChannelSsh;
}
if (Array.isArray(options._acceptedHostKeys) && options._acceptedHostKeys.length > 0) {
client.__netcattyAcceptedHostKeys = options._acceptedHostKeys.slice();
}
sftpClients.set(connId, client);
// Publish the live channel before rejecting waiters. A single-channel
// login cannot join the transport registry; leaving the dial pending
// makes the next SFTP-page transfer wait forever with no OTP prompt.
if (pendingDialCoordination && !client.__netcattyTransportManaged) {
const unshared = new Error(
peerSingleChannelSsh || options.singleChannelSsh === true
? "This host allows one SSH channel. Reuse the authenticated SFTP session instead of opening another."
: "SFTP connection was not added to the shared SSH transport registry",
);
if (peerSingleChannelSsh || options.singleChannelSsh === true) {
unshared.code = "ERR_SFTP_SINGLE_CHANNEL_BASTION";
}
failTransportDial(pendingDialCoordination, unshared);
}
// Store jump connections for cleanup when SFTP is closed (legacy path
// only — transport-managed connections park chain on the registry).
@@ -1644,16 +1417,6 @@ function createOpenConnectionApi(ctx) {
// Cleanup jump connections on error
cleanupPendingConnection();
closeFreshClient();
if (
options.hostKeyUnattended === true
&& /Host denied \(verification failed\)|Host key rejected/i.test(String(err?.message || ""))
) {
const denied = new Error(`Host key verification required for ${options.hostname || "host"}`);
denied.code = "ERR_HOST_KEY_UNVERIFIED";
denied.cause = err;
if (pendingDialCoordination) failTransportDial(pendingDialCoordination, denied);
throw denied;
}
if (shouldRetrySftpKeyboardInteractiveFirst(options, authConfig, err)) {
console.log("[SFTP] Password auth removed keyboard-interactive; retrying with keyboard-interactive first...");
return openSftp(event, {
@@ -16,18 +16,8 @@
*
* This is intentionally independent of UI panels. Browse can go away; transfers
* keep their leased sessions until they complete.
*
* A borrowed SFTP handle (single-channel / sudo alias) also acquires a lease
* on the owner sftpId for the alias lifetime. closeSftp(owner) then soft-closes
* until the last borrower releases, instead of ending the shared channel.
*/
const CHANNEL_ALIAS_LEASE_PREFIX = "sftp-channel-alias:";
function channelAliasLeaseId(aliasId) {
return `${CHANNEL_ALIAS_LEASE_PREFIX}${aliasId}`;
}
/**
* @typedef {object} SftpTransferSessionLeaseStore
* @property {(sftpId: string, transferId: string) => boolean} acquire
@@ -206,5 +196,4 @@ const sftpTransferSessionLeaseStore = createSftpTransferSessionLeaseStore();
module.exports = {
createSftpTransferSessionLeaseStore,
sftpTransferSessionLeaseStore,
channelAliasLeaseId,
};
+8 -8
View File
@@ -105,7 +105,7 @@ function withRemoteWatchdog(command, timeoutMs) {
// exec replaces the command-substitution shell: without it Bash may
// report that intermediate PID instead of the watchdog subshell.
`&& nc_self=$(exec sh -c 'echo $PPID' 2>/dev/null)`,
`&& nc_tree=$(ps -e -o pid= -o ppid= 2>/dev/null | awk -v root="$$" -v self="$nc_self" '${descendantTreeAwk}')`,
`&& nc_tree=$(ps -e -o pid=,ppid= 2>/dev/null | awk -v root="$$" -v self="$nc_self" '${descendantTreeAwk}')`,
`&& kill -9 $nc_tree "$$" 2>/dev/null ) </dev/null >/dev/null 2>&1 & nc_watchdog_pid=$!`,
].join(' ');
return [
@@ -115,7 +115,7 @@ function withRemoteWatchdog(command, timeoutMs) {
// Reap the watchdog's pending `sleep` before killing the subshell: after
// the subshell is SIGKILLed its children are reparented to init and a
// PPID walk can no longer find them.
'nc_kids=$(ps -e -o pid= -o ppid= 2>/dev/null | awk -v root="$nc_watchdog_pid" -v self="-1" \'' +
'nc_kids=$(ps -e -o pid=,ppid= 2>/dev/null | awk -v root="$nc_watchdog_pid" -v self="-1" \'' +
descendantTreeAwk +
"')",
'kill -9 $nc_kids "$nc_watchdog_pid" 2>/dev/null',
@@ -589,7 +589,7 @@ function createSessionOpsApi(ctx) {
# different parent) of this exec session, so the sibling search misses it
# entirely (#1123).
find_login_shell() {
_shell=$(ps -e -o pid= -o ppid= -o tty= -o comm= 2>/dev/null | awk -v pp="$1" -v self="$SELF" '
_shell=$(ps -e -o pid=,ppid=,tty=,comm= 2>/dev/null | awk -v pp="$1" -v self="$SELF" '
function isshell(c) { sub(/^.*\\//, "", c); sub(/^-/, "", c); return c ~ /^(ba|z|fi|k|da|a|c|tc)?sh$/ }
$1 != self && $2 == pp && isshell($4) {
if ($3 !~ /^\\?+$/) { print $1; found=1; exit }
@@ -638,7 +638,7 @@ function createSessionOpsApi(ctx) {
# su'd / sudo'd shell instead of stopping at the login shell (#1065). Falls
# back to the login shell when no foreground shell is found.
find_active_shell() {
ps -e -o pid= -o ppid= -o stat= -o comm= 2>/dev/null | awk -v start="$1" '
ps -e -o pid=,ppid=,stat=,comm= 2>/dev/null | awk -v start="$1" '
{ pp[$1]=$2; st[$1]=$3; cm[$1]=$4; ord[NR]=$1 }
function isshell(c) { sub(/^.*\\//, "", c); sub(/^-/, "", c); return c ~ /^(ba|z|fi|k|da|a|c|tc)?sh$/ }
function depth(p, d) { d=0; while (p != "" && d < 64) { if (p == start) return d; p=pp[p]; d++ } return -1 }
@@ -826,7 +826,7 @@ function createSessionOpsApi(ctx) {
if (session.singleChannelSsh) return null;
const script = `SELF=$$
find_login_shell() {
ps -e -o pid= -o ppid= -o tty= -o comm= 2>/dev/null | awk -v pp="$1" -v self="$SELF" '
ps -e -o pid=,ppid=,tty=,comm= 2>/dev/null | awk -v pp="$1" -v self="$SELF" '
$1 != self && $2 == pp && $4 ~ /^-?(ba|z|fi|k|da|a)?sh$/ {
if ($3 != "?") { print $1; found=1; exit }
if (any == "") any=$1
@@ -834,7 +834,7 @@ function createSessionOpsApi(ctx) {
END { if (!found && any != "") print any }'
}
find_fg_leaf() {
ps -e -o pid= -o ppid= -o stat= -o comm= 2>/dev/null | awk -v start="$1" '
ps -e -o pid=,ppid=,stat=,comm= 2>/dev/null | awk -v start="$1" '
{ pp[$1]=$2; st[$1]=$3; ord[NR]=$1 }
function depth(p, d){ d=0; while(p!="" && d<64){ if(p==start) return d; p=pp[p]; d++ } return -1 }
END { best=-1; bp=""; for(i=1;i<=NR;i++){ p=ord[i];
@@ -988,7 +988,7 @@ function createSessionOpsApi(ctx) {
// so that relative paths like "." or "src" are resolved correctly.
const needsCwdResolve = !dirPath.startsWith('/') && dirPath !== '~' && !dirPath.startsWith('~/');
const cwdResolveCmd = needsCwdResolve
? `_sc_p=$(ps --ppid $PPID -o pid= -o comm= 2>/dev/null | awk -v self=$$ '$1!=self && $2~/^(ba|z|fi|k|da)?sh$/{pid=$1}END{print pid}'); [ -z "$_sc_p" ] && _sc_p=$(ps -e -o pid= -o ppid= -o comm= 2>/dev/null | awk -v pp=$PPID -v self=$$ '$1!=self && $2==pp && $3~/^(ba|z|fi|k|da)?sh$/{pid=$1}END{print pid}'); [ -n "$_sc_p" ] && { _sc_d=$(readlink /proc/$_sc_p/cwd 2>/dev/null); [ -n "$_sc_d" ] && cd "$_sc_d" 2>/dev/null; }; `
? `_sc_p=$(ps --ppid $PPID -o pid=,comm= 2>/dev/null | awk -v self=$$ '$1!=self && $2~/^(ba|z|fi|k|da)?sh$/{pid=$1}END{print pid}'); [ -z "$_sc_p" ] && _sc_p=$(ps -e -o pid=,ppid=,comm= 2>/dev/null | awk -v pp=$PPID -v self=$$ '$1!=self && $2==pp && $3~/^(ba|z|fi|k|da)?sh$/{pid=$1}END{print pid}'); [ -n "$_sc_p" ] && { _sc_d=$(readlink /proc/$_sc_p/cwd 2>/dev/null); [ -n "$_sc_d" ] && cd "$_sc_d" 2>/dev/null; }; `
: '';
const cmd = `${cwdResolveCmd}find ${pathExpr} -mindepth 1 -maxdepth 1 -exec sh -c '
prefix="$1"
@@ -1210,7 +1210,7 @@ function createSessionOpsApi(ctx) {
`swapused=$(echo "$swapraw" | awk '{for(i=1;i<=NF;i++){if($i=="used"&&$(i+1)=="="){v=$(i+2);m=1;if(v~/G/)m=1024;gsub(/[MmGg]/,"",v);su=v*m}};printf "%.0f",su+0}')`,
`swapfree=$(echo "$swaptotal $swapused" | awk '{printf "%.0f",$1-$2}')`,
// Top processes by memory%
`procs=$(ps -A -o pid= -o %mem= -o comm= 2>/dev/null | sort -k2 -rn | head -10 | awk '{gsub(/;/,"_",$3);printf "%s;%.1f;%s,",$1,$2,$3}' | sed 's/,$//')`,
`procs=$(ps -A -o pid=,%mem=,comm= 2>/dev/null | sort -k2 -rn | head -10 | awk '{gsub(/;/,"_",$3);printf "%s;%.1f;%s,",$1,$2,$3}' | sed 's/,$//')`,
// Disk: -P keeps a stable six-column POSIX layout on macOS.
`mounts=$(mount 2>/dev/null || true)`,
`disks=$({ printf "%s\n" "$mounts"; printf "__NETCATTY_DF__\n"; df -kP 2>/dev/null; } | awk '$0=="__NETCATTY_DF__"{in_df=1;next} !in_df{if(index($1,"/dev/disk")==1&&(index($0,"(apfs,")||index($0,"(apfs)"))){key=$1;sub(/s[0-9].*$/,"",key);capacity[$1]="apfs:" key}next} $1=="Filesystem"{next} index($1,"/dev/")==1{m=$6;if(m=="/"||index(m,"/Volumes/")==1){t=$2/1048576;key=(($1 in capacity)?capacity[$1]:$1);if(key~/^apfs:/){u=($2-$4)/1048576;p=(t>0?int(u*100/t+0.5):0)}else{u=$3/1048576;p=$5;gsub(/%/,"",p)}printf "%s:%.6f:%.6f:%s:%s,",m,u,t,p,key}}' | sed 's/,$//')`,
@@ -333,9 +333,6 @@ test("cwd probe forces POSIX sh, captures sshd PPID, and keeps the watchdog", as
assert.ok(command.includes(") </dev/null >/dev/null 2>&1 & nc_watchdog_pid=$!"));
assert.ok(command.includes('kill -9 $nc_kids "$nc_watchdog_pid"'));
assert.ok(command.includes('find_login_shell "${NC_SSHD_PPID:-$PPID}"'));
assert.match(command, /ps -e -o pid= -o ppid= -o tty= -o comm=/);
assert.match(command, /ps -e -o pid= -o ppid= -o stat= -o comm=/);
assert.equal(command.includes("pid=,"), false);
});
test("immediate parked reconnect does not guess cwd from an exiting shell", async () => {
+3 -25
View File
@@ -369,13 +369,7 @@ function parseKnownHostTarget(value) {
* text or the full known-hosts vault.
*/
function fingerprintKnownHostTrust(endpoint) {
if (!endpoint) return "disabled";
const jumps = Array.isArray(endpoint.jumpHosts) ? endpoint.jumpHosts : [];
// A jump host can force verification while the target turns it off. The
// target flag alone must not drop that hop's pin from the digest.
const anyVerification = endpoint.verifyHostKeys !== false
|| jumps.some((hop) => (hop?.verifyHostKeys ?? endpoint.verifyHostKeys) !== false);
if (!anyVerification) return "disabled";
if (!endpoint || endpoint.verifyHostKeys === false) return "disabled";
const targets = [];
const addTarget = (hostname, port, verifyHostKeys = true) => {
if (verifyHostKeys === false) return;
@@ -384,7 +378,7 @@ function fingerprintKnownHostTrust(endpoint) {
targets.push(`${normalizedHostname}|${Number(port) || 22}`);
};
addTarget(endpoint.hostname, endpoint.port, endpoint.verifyHostKeys);
for (const hop of jumps) {
for (const hop of Array.isArray(endpoint.jumpHosts) ? endpoint.jumpHosts : []) {
addTarget(
hop?.hostname,
hop?.port,
@@ -487,12 +481,7 @@ function fingerprintAuth(endpoint) {
certificate: endpoint.certificate ? secureDigest(endpoint.certificate) : "none",
requiresMfa: Boolean(endpoint.requiresMfa),
verifyHostKeys: endpoint.verifyHostKeys !== false,
// Channel adoption may compare a sibling key that ignores this digest, then
// checks the connection's accepted host key against the caller's records.
// Parked transport reuse still uses the full key.
knownHostTrust: endpoint.ignoreKnownHostTrustForReuse
? "ignored"
: fingerprintKnownHostTrust(endpoint),
knownHostTrust: fingerprintKnownHostTrust(endpoint),
authPolicyVersion: endpoint.authPolicyVersion ?? null,
useSshAgent: endpoint.useSshAgent ?? null,
identityAgent: endpoint.identityAgent ?? null,
@@ -639,16 +628,6 @@ function buildEndpointKey(endpoint) {
].join("|");
}
/**
* Same route as buildEndpointKey, but the known-host trust digest is ignored.
* Used only to find an already-authenticated SFTP channel after the renderer
* known-host list changes (trust during this app session).
*/
function buildChannelAdoptionKey(endpoint) {
if (!endpoint || typeof endpoint !== "object") return null;
return buildEndpointKey({ ...endpoint, ignoreKnownHostTrustForReuse: true });
}
function sameEndpoint(a, b) {
const left = normalizeEndpoint(a);
const right = normalizeEndpoint(b);
@@ -1565,7 +1544,6 @@ module.exports = {
setDefaultTransportIdleTtlMs,
getDefaultTransportIdleTtlMs,
buildEndpointKey,
buildChannelAdoptionKey,
buildConnectionReuseEndpoint,
resolveConnectionKeepalivePolicy,
normalizeEndpoint,
@@ -27,7 +27,6 @@ const {
setDefaultTransportIdleTtlMs,
getDefaultTransportIdleTtlMs,
buildEndpointKey,
buildChannelAdoptionKey,
buildConnectionReuseEndpoint,
normalizeEndpoint,
endpointAllowsReuse,
@@ -906,46 +905,6 @@ test("endpoint key changes when known-host trust content changes", () => {
);
});
test("a jump host pin changes the endpoint key when the target disables verification", () => {
const base = {
hostId: "jump-pin-target-disabled",
hostname: "target.example",
port: 22,
username: "alice",
authMethod: "password",
password: "secret",
verifyHostKeys: false,
jumpHosts: [{ hostname: "bastion.example", port: 2222, verifyHostKeys: true }],
};
const pinned = (publicKey) => buildConnectionReuseEndpoint({
...base,
knownHosts: [{
hostname: "[bastion.example]:2222",
port: 2222,
keyType: "ssh-ed25519",
publicKey,
}],
});
const first = pinned("ssh-ed25519 AAAA_JUMP_FIRST");
const rotated = pinned("ssh-ed25519 AAAA_JUMP_ROTATED");
assert.notEqual(buildEndpointKey(rotated), buildEndpointKey(first));
assert.equal(buildChannelAdoptionKey(rotated), buildChannelAdoptionKey(first));
const bothOff = {
...base,
jumpHosts: [{ hostname: "bastion.example", port: 2222, verifyHostKeys: false }],
};
const offFirst = buildEndpointKey(buildConnectionReuseEndpoint({
...bothOff,
knownHosts: [{ hostname: "[bastion.example]:2222", port: 2222, publicKey: "ssh-ed25519 AAAA_JUMP_FIRST" }],
}));
const offRotated = buildEndpointKey(buildConnectionReuseEndpoint({
...bothOff,
knownHosts: [{ hostname: "[bastion.example]:2222", port: 2222, publicKey: "ssh-ed25519 AAAA_JUMP_ROTATED" }],
}));
assert.equal(offRotated, offFirst);
});
test("endpoint key changes when an identity file changes in place", (t) => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-pool-key-"));
t.after(() => fs.rmSync(dir, { recursive: true, force: true }));
+1 -1
View File
@@ -8,7 +8,7 @@ const discoveryDisabledConnections = new WeakSet();
function buildInteractiveShellPidCommand(quoteShellArg) {
const script = `SELF=$$
ps_output=$(ps -e -o pid= -o ppid= -o tty= -o comm= -o etimes= 2>/dev/null) || ps_output=$(ps -e -o pid= -o ppid= -o tty= -o comm= 2>/dev/null) || exit 69
ps_output=$(ps -e -o pid=,ppid=,tty=,comm=,etimes= 2>/dev/null) || ps_output=$(ps -e -o pid=,ppid=,tty=,comm= 2>/dev/null) || exit 69
{
printf '%s\n' "$ps_output" | awk -v pp="$PPID" -v self="$SELF" '
function isshell(c) { sub(/^.*\\//, "", c); sub(/^-/, "", c); return c ~ /^(ba|z|fi|k|da|a|c|tc)?sh$/ }
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
-16
View File
@@ -28,24 +28,8 @@ const SFTP_REQUEST_TIMEOUT_MS = 30_000;
// channel pool caps - they must never fall back to serial body streams.
const FAST_DOWNLOAD_CHANNELS_PER_SESSION = 1;
// A file that fits in one READ fanout window (64 x 32KB) cannot hog the
// session the way a multi-GB download can, so it skips the per-session slot
// above and reads on the shared browse channel. Without this, folder downloads
// of small files ran strictly one file at a time regardless of the transfer
// concurrency setting.
const SMALL_DOWNLOAD_SLOT_EXEMPT_BYTES = DOWNLOAD_TRANSFER_CONCURRENCY * TRANSFER_CHUNK_SIZE;
// A file that fits in one fanout window finishes in a couple of round trips.
// Opening a dedicated sftp-server channel for it (channel open + subsystem +
// SFTP init) costs more than the body itself on WAN links and spawns one
// remote process per file in folder uploads, so small files use the shared
// session's pipelined WRITE path instead.
const SMALL_UPLOAD_SHARED_MAX_BYTES = UPLOAD_TRANSFER_CONCURRENCY * TRANSFER_CHUNK_SIZE;
module.exports = {
DOWNLOAD_TRANSFER_CONCURRENCY,
SMALL_DOWNLOAD_SLOT_EXEMPT_BYTES,
SMALL_UPLOAD_SHARED_MAX_BYTES,
FAST_DOWNLOAD_CHANNELS_PER_SESSION,
SFTP_OPEN_TIMEOUT_MS,
SFTP_REQUEST_TIMEOUT_MS,
-12
View File
@@ -3,9 +3,7 @@ const assert = require("node:assert/strict");
const {
FAST_DOWNLOAD_CHANNELS_PER_SESSION,
SMALL_DOWNLOAD_SLOT_EXEMPT_BYTES,
DOWNLOAD_TRANSFER_CONCURRENCY,
SMALL_UPLOAD_SHARED_MAX_BYTES,
UPLOAD_TRANSFER_CONCURRENCY,
TRANSFER_CHUNK_SIZE,
} = require("./transferLimits.cjs");
@@ -20,18 +18,8 @@ test("SFTP transfer limits preserve upload safety and the pre-regression downloa
// #2449 / Electerm-class: upload window is 2MB (64 × 32KB). Raising only
// concurrency keeps the safe packet size while matching ssh2/Electerm defaults.
assert.equal(UPLOAD_TRANSFER_CONCURRENCY * TRANSFER_CHUNK_SIZE, 2 * 1024 * 1024);
// One fanout window. Files at or under this size upload on the shared browse
// channel instead of opening a dedicated sftp-server per file.
assert.equal(SMALL_UPLOAD_SHARED_MAX_BYTES, UPLOAD_TRANSFER_CONCURRENCY * TRANSFER_CHUNK_SIZE);
// #2030 reduced the shared chunk size from 512KB to 32KB. Downloads need
// enough parallel reads to preserve the 2MB in-flight window that existed
// after #1533, otherwise high-latency proxy paths collapse to KB/s speeds.
assert.equal(DOWNLOAD_TRANSFER_CONCURRENCY * TRANSFER_CHUNK_SIZE, 2 * 1024 * 1024);
});
test("small downloads (one READ window) are exempt from the per-session fast slot", () => {
// Big files still serialize on FAST_DOWNLOAD_CHANNELS_PER_SESSION (#1507).
assert.equal(FAST_DOWNLOAD_CHANNELS_PER_SESSION, 1);
assert.equal(SMALL_DOWNLOAD_SLOT_EXEMPT_BYTES, DOWNLOAD_TRANSFER_CONCURRENCY * TRANSFER_CHUNK_SIZE);
assert.equal(SMALL_DOWNLOAD_SLOT_EXEMPT_BYTES, 2 * 1024 * 1024);
});
+12 -20
View File
@@ -155,7 +155,6 @@ async function handleBeforeQuit({
setQuitConfirmed,
commitQuit,
cancelPendingUpdateInstall,
dialog: injectedDialog,
}) {
if (quitConfirmed) return { committed: false, skipped: "quit-confirmed" };
if (quitGuardChannelBusy) {
@@ -208,7 +207,6 @@ async function handleBeforeQuit({
queryDirtyEditors(win.webContents, timeoutMs, { ipcMain }).then((hasDirty) => ({
win,
hasDirty: Boolean(hasDirty),
transfersOnly: hasDirty === "transfers",
})),
),
);
@@ -251,24 +249,18 @@ async function handleBeforeQuit({
// App Lock overlay sits above renderer toasts (z-index). When the app is
// already locked, focusing the dirty window does not make the unsaved
// warning visible — surface a native dialog above the lock screen (Codex P2).
// Skip it when every refusal came from the renderer's unfinished-transfer
// prompt: the user already answered, and this synchronous box would block
// the main process (and every running transfer) behind a wrong message.
const dirtyEditorsRefused = dirtyResults.some((result) => result.hasDirty && !result.transfersOnly);
if (dirtyEditorsRefused) {
try {
const dialog = injectedDialog || require("electron").dialog;
dialog.showMessageBoxSync({
type: "warning",
buttons: ["OK"],
defaultId: 0,
message: "Unsaved changes",
detail:
"One or more editors have unsaved changes. If the app is locked, unlock it, then save or discard before quitting.",
});
} catch {
// ignore missing dialog in tests
}
try {
const { dialog } = require("electron");
dialog.showMessageBoxSync({
type: "warning",
buttons: ["OK"],
defaultId: 0,
message: "Unsaved changes",
detail:
"One or more editors have unsaved changes. If the app is locked, unlock it, then save or discard before quitting.",
});
} catch {
// ignore missing dialog in tests
}
if (windowManager?.isQuittingForUpdate?.()) {
-32
View File
@@ -444,35 +444,3 @@ test("handleBeforeQuit focuses only dirty windows when multiple renderers reply"
"focus",
]);
});
test("handleBeforeQuit does not raise the blocking editor dialog when only the transfer quit prompt was declined", async () => {
const run = async (dirtyValue) => {
const calls = [];
const win = { isDestroyed: () => false, webContents: { isDestroyed: () => false, isCrashed: () => false, id: 1 } };
const result = await handleBeforeQuit({
event: { preventDefault() { calls.push("preventDefault"); } },
mainWindows: [win],
queryDirtyEditors: async () => dirtyValue,
appLockController: { setLocked(reason) { calls.push(`lock:${reason}`); } },
windowManager: { showAndFocusMainWindow() { calls.push("showAndFocus"); } },
app: { quit() { calls.push("app.quit"); } },
ipcMain: {},
quitConfirmed: false,
quitGuardChannelBusy: false,
timeoutMs: 10,
setQuitGuardChannelBusy() {},
setQuitConfirmed(value) { calls.push(`quitConfirmed:${value}`); },
dialog: { showMessageBoxSync() { calls.push("dialog"); return 0; } },
});
return { calls, result };
};
const declined = await run("transfers");
assert.equal(declined.result.skipped, "dirty", "the declined prompt still cancels quit");
assert.ok(!declined.calls.includes("dialog"), "no synchronous native dialog after the user already answered");
assert.ok(!declined.calls.includes("app.quit"));
const dirty = await run(true);
assert.equal(dirty.result.skipped, "dirty");
assert.deepEqual(dirty.calls.filter((c) => c === "dialog"), ["dialog"], "real unsaved editors keep the dialog");
});
+1 -1
View File
@@ -1462,7 +1462,7 @@ function createPreloadApi(ctx) {
return () => ipcRenderer.removeListener("app:query-dirty-editors", handler);
},
// Renderer reports the dirty-check result back to the main process.
reportDirtyEditorsResult: (hasDirty, reason) => ipcRenderer.send("app:dirty-editors-result", { hasDirty, reason }),
reportDirtyEditorsResult: (hasDirty) => ipcRenderer.send("app:dirty-editors-result", { hasDirty }),
// Port Forwarding API
startPortForward: async (options) => {
Vendored
-7
View File
@@ -128,13 +128,6 @@ declare global {
passphrase?: string;
knownHosts?: import("./domain/models").KnownHost[];
verifyHostKeys?: boolean;
/**
* When true, an untrusted host key fails the dial instead of waiting for
* a renderer prompt. Transfer opens set this: their session id is not the
* SFTP page's host-key dialog, so an unanswered prompt leaves the upload
* on "Waiting...".
*/
hostKeyUnattended?: boolean;
// Environment variables to set in the remote shell
env?: Record<string, string>;
// Proxy configuration
+14 -6
View File
@@ -680,13 +680,21 @@ html[data-theme-transition] *::after {
box-shadow: 0 4px 12px hsl(var(--foreground) / 0.06);
}
/* Unread activity remains visible until acknowledged without keeping the
compositor animating after terminal output stops. The 8px status dot plus
its 2px gap and 1px outline fits inside the existing 20px tab content line.
Keep the dot's connection-status color; the extra outline marks unread output. */
@keyframes session-activity-breathe {
0%,
100% {
transform: scale(1);
opacity: 1;
}
50% {
transform: scale(1.1);
opacity: 0.18;
}
}
.session-activity-dot {
outline: 1px solid color-mix(in srgb, var(--top-tabs-fg, hsl(var(--foreground))) 75%, transparent);
outline-offset: 2px;
animation: session-activity-breathe 1.4s ease-in-out infinite;
}
@keyframes coding-cli-icon-pulse {
@@ -4,7 +4,7 @@ import type { AISession, AgentActivity, ChatMessage, ExternalAgentConfig } from
import { SessionStateStore } from '../sessionState';
import { ToolOutputStore } from '../toolOutputStore';
import { ToolResultDedup } from '../toolResultDedup';
import { externalSdkTurnDriver, localizeSdkAgentMessage } from './externalSdkTurnDriver';
import { externalSdkTurnDriver } from './externalSdkTurnDriver';
import { resolveEstimatedUsageFallback, upsertAgentActivity } from './externalSdkEventState';
import type { TurnUiCallbacks } from './types';
@@ -16,16 +16,6 @@ async function waitFor(predicate: () => boolean): Promise<void> {
throw new Error('condition not reached');
}
test('OpenCode 2 error keys are localized and other errors stay unchanged', () => {
const translate = (key: string) => key === 'ai.opencode.v2.startupTimeout' ? '未能及时就绪' : key;
assert.equal(
localizeSdkAgentMessage('ai.opencode.v2.startupTimeout', translate),
'未能及时就绪',
);
assert.equal(localizeSdkAgentMessage('server password hunter2', translate), 'server password hunter2');
assert.equal(localizeSdkAgentMessage('ai.opencode.v2.startupTimeout'), 'ai.opencode.v2.startupTimeout');
});
test('upsertAgentActivity replaces streaming updates by stable item id', () => {
const running: AgentActivity = {
id: 'plan-1',
@@ -25,17 +25,6 @@ import {
registerCodebuddyElicitation,
} from '../../shared/codebuddyElicitations';
const OPENCODE_V2_MESSAGE_KEY = /^ai\.opencode\.v2\.[A-Za-z0-9]+$/;
export function localizeSdkAgentMessage(
message: string,
translate?: (key: string) => string,
): string {
if (!translate || !OPENCODE_V2_MESSAGE_KEY.test(message)) return message;
const localized = translate(message);
return localized || message;
}
interface LiveExternalTurn {
requestId: string;
sessionId: string;
@@ -344,8 +333,7 @@ async function runExternalTurn(
},
onWarning: (activity) => {
flushTextBeforeNonTextEvent();
const message = localizeSdkAgentMessage(activity.message, ui.translate);
runOrBufferUiOperation(() => updateActivity({ ...activity, message }));
runOrBufferUiOperation(() => updateActivity(activity));
},
onUsage: (usage: AgentUsage) => {
flushTextBeforeNonTextEvent();
@@ -403,7 +391,7 @@ async function runExternalTurn(
},
onError: (error: string) => {
flushTextBeforeNonTextEvent();
ui.reportStreamError(sessionId, signal, localizeSdkAgentMessage(error, ui.translate));
ui.reportStreamError(sessionId, signal, error);
ui.setStreamingForScope(sessionId, false);
},
onDone: () => {
+1 -5
View File
@@ -51,7 +51,6 @@ import {
importVaultHostsFromText,
mergeVaultImportIssues,
resolveVaultImportKeyPassphraseConflicts,
toVaultImportExistingHostRef,
VAULT_IMPORT_FORMATS,
type VaultImportFormat,
} from '../../domain/vaultImport';
@@ -947,10 +946,7 @@ export async function handleVaultAgentOp(
? params.fileName.trim()
: undefined;
const importResult = importVaultHostsFromText(resolvedFormat, text, {
fileName,
existingHosts: deps.getHosts().map(toVaultImportExistingHostRef),
});
const importResult = importVaultHostsFromText(resolvedFormat, text, { fileName });
const previewHosts = importResult.hosts.map((host) => sanitizeHostForAgent(host));
const merged = applyVaultHostImport(
deps.getHosts(),
@@ -3,7 +3,6 @@ import {
buildVaultHostMergeKey,
importVaultHostsFromText,
mergeVaultImportIssues,
type VaultImportExistingHostRef,
type VaultImportFormat,
type VaultImportIssue,
type VaultImportResult,
@@ -25,7 +24,6 @@ interface ImportVaultHostFilesOptions {
relativePaths?: string[];
encoding?: VaultImportFileEncoding;
masterPassword?: string;
existingHosts?: readonly VaultImportExistingHostRef[];
onProgress?: (progress: VaultImportBatchProgress) => void;
}
@@ -80,7 +78,6 @@ export async function importVaultHostFiles({
relativePaths,
encoding,
masterPassword,
existingHosts,
onProgress,
}: ImportVaultHostFilesOptions): Promise<VaultImportResult> {
const sourceFiles = files.map((file, index) => ({
@@ -107,7 +104,6 @@ export async function importVaultHostFiles({
const result = importVaultHostsFromText(format, text, {
fileName: file.name,
masterPassword,
existingHosts,
});
const group = format === "securecrt"
? secureCrtGroupFromFile(file, relativePath)
@@ -1,5 +1,4 @@
import type {
VaultImportExistingHostRef,
VaultImportFormat,
VaultImportResult,
} from "../../domain/vaultImport";
@@ -23,7 +22,6 @@ export type VaultImportWorkerRequest = {
relativePaths: string[];
encoding: VaultImportFileEncoding | undefined;
masterPassword?: string;
existingHosts?: VaultImportExistingHostRef[];
};
export type VaultImportWorkerResponse =
@@ -46,7 +44,6 @@ interface ImportVaultHostsInWorkerOptions {
files: File[];
encoding?: VaultImportFileEncoding;
masterPassword?: string;
existingHosts?: readonly VaultImportExistingHostRef[];
signal?: AbortSignal;
createWorker?: () => VaultImportWorkerLike;
onProgress?: (progress: VaultImportWorkerProgress) => void;
@@ -63,7 +60,6 @@ export function importVaultHostsInWorker({
files,
encoding,
masterPassword,
existingHosts,
signal,
createWorker = createVaultImportWorker,
onProgress,
@@ -118,7 +114,6 @@ export function importVaultHostsInWorker({
relativePaths: files.map((file) => file.webkitRelativePath),
encoding,
...(masterPassword ? { masterPassword } : {}),
...(existingHosts && existingHosts.length > 0 ? { existingHosts: [...existingHosts] } : {}),
});
});
}
@@ -23,7 +23,6 @@ workerScope.addEventListener("message", async (event: MessageEvent<VaultImportWo
relativePaths: event.data.relativePaths,
encoding: event.data.encoding,
masterPassword: event.data.masterPassword,
existingHosts: event.data.existingHosts,
onProgress: ({ completedFiles, totalFiles, fileName }) => {
post({
type: "progress",
+2 -1
View File
@@ -1 +1,2 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M23.994 15.133c.079 1.061-.668 1.927-1.69 2.005a1.8 1.8 0 0 1-1.928-1.651c-.078-1.062.63-1.849 1.691-1.967 1.023-.078 1.849.59 1.927 1.613m-12.623 4.955c-.944 0-1.73.786-1.73 1.809 0 1.14.747 1.848 1.887 1.848.904-.04 1.691-.865 1.691-1.809 0-.983-.904-1.848-1.848-1.848m1.061-9.675c-.039-.865-.078-1.73.08-2.556.156-.944.314-1.887.904-2.674.707-.983 1.809-.944 2.399.118.314.511.432 1.062.471 1.652 0 .354.158.432.472.393.944-.157 1.888-.157 2.792.197.118.039.236.118.394 0 .314-.276.393-1.652.196-2.006-.354-.63-.904-.55-1.455-.55-.629.039-1.18-.158-1.612-.67-.393-.471-.511-1.06-.59-1.65-.04-.276-.079-.512-.315-.709-.55-.55-1.809-.432-2.477.118-2.556 2.045-2.989 5.467-1.534 8.18.04.118.118.236.275.157m7.984 3.658c.354-.511.865-.747 1.415-.983a.97.97 0 0 0 .59-.472c.354-.669-.078-1.81-.747-2.36-2.595-2.006-5.938-1.612-8.18.433-.118.078-.157.196-.078.314.786-.236 1.612-.472 2.477-.51.905-.08 1.848-.158 2.753.235 1.14.472 1.337 1.534.472 2.36-.393.393-.905.668-1.455.825-.315.08-.354.236-.236.551.354.865.59 1.77.472 2.753-.04.157-.079.275.078.393.354.236 1.691 0 1.967-.275.511-.472.314-1.023.196-1.534-.157-.63-.078-1.219.276-1.73m-7.197-2.045c-.118-.079-.197-.118-.315 0 .472.708.905 1.455 1.259 2.241.314.866.668 1.73.55 2.714-.118 1.18-1.1 1.69-2.123 1.101-.511-.275-.905-.669-1.22-1.14-.196-.276-.393-.276-.629-.08-.747.63-1.533 1.102-2.516 1.26-.158 0-.315 0-.394.157-.118.393.472 1.612.826 1.809.59.354 1.062 0 1.534-.276.55-.314 1.101-.432 1.73-.236.59.197.983.63 1.337 1.102.158.196.315.353.63.432.747.197 1.77-.59 2.084-1.376 1.18-3.028-.157-6.135-2.753-7.708m-2.556 2.438c.472-.669.826-1.416.983-2.202-.157-.04-.197.04-.315.078-.904.944-1.848 1.849-3.067 2.478-.472.236-.983.433-1.534.433-.865 0-1.376-.551-1.298-1.416a2.92 2.92 0 0 1 .787-1.849c.236-.275.236-.432-.04-.668-.786-.55-1.494-1.22-1.848-2.124-.078-.275-.275-.275-.51-.157a4 4 0 0 0-.434.236c-1.022.63-1.14 1.416-.275 2.28.63.63.944 1.338.708 2.203-.118.433-.354.747-.63 1.101a.95.95 0 0 0-.235.787c.079.747.826 1.494 1.73 1.573 2.517.236 4.562-.63 5.978-2.753m-4.68-5.152c1.376 1.18 3.067 1.455 4.837 1.377.157 0 .315 0 .354-.118.04-.197-.157-.197-.275-.236-.826-.354-1.691-.63-2.438-1.14S6.848 8.25 6.534 7.266c-.236-.747.078-1.415.825-1.651.669-.236 1.337-.236 1.967 0 .393.157.55.078.629-.354.118-.747.354-1.455.826-2.085.55-.786.55-.865-.354-1.376-.04 0-.04-.04-.079-.04-.865-.471-1.534-.196-1.848.709-.472 1.376-1.377 1.887-2.832 1.612a4 4 0 0 0-.472-.079c-.747.118-1.18.55-1.297 1.14-.158 1.81.786 3.107 2.084 4.17m-2.32 3.658c-.079-.944-1.023-1.652-2.045-1.534-.905.079-1.691 1.022-1.613 1.966.08.983 1.023 1.77 1.967 1.652 1.14-.079 1.73-1.18 1.69-2.084zm15.18-8.298c.943-.079 1.73-.983 1.651-1.927-.078-.983-1.022-1.77-2.005-1.691-1.023.079-1.73.983-1.652 1.966s.983 1.73 2.006 1.652m-12.27-.826c1.062-.157 1.77-1.023 1.652-2.045C8.107.897 7.163.149 6.18.267c-1.062.118-1.691.944-1.573 2.085.118.865 1.061 1.612 1.966 1.494"/></svg>
<svg role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><title>AlmaLinux</title><path d="M23.994 15.133c.079 1.061-.668 1.927-1.69 2.005a1.8 1.8 0 0 1-1.928-1.651c-.078-1.062.63-1.849 1.691-1.967c1.023-.078 1.849.59 1.927 1.613m-12.623 4.955c-.944 0-1.73.786-1.73 1.809c0 1.14.747 1.848 1.887 1.848c.904-.04 1.691-.865 1.691-1.809c0-.983-.904-1.848-1.848-1.848m1.061-9.675c-.039-.865-.078-1.73.08-2.556c.156-.944.314-1.887.904-2.674c.707-.983 1.809-.944 2.399.118c.314.511.432 1.062.471 1.652c0 .354.158.432.472.393c.944-.157 1.888-.157 2.792.197c.118.039.236.118.394 0c.314-.276.393-1.652.196-2.006c-.354-.63-.904-.55-1.455-.55c-.629.039-1.18-.158-1.612-.67c-.393-.471-.511-1.06-.59-1.65c-.04-.276-.079-.512-.315-.709c-.55-.55-1.809-.432-2.477.118c-2.556 2.045-2.989 5.467-1.534 8.18c.04.118.118.236.275.157m7.984 3.658c.354-.511.865-.747 1.415-.983a.97.97 0 0 0 .59-.472c.354-.669-.078-1.81-.747-2.36c-2.595-2.006-5.938-1.612-8.18.433c-.118.078-.157.196-.078.314c.786-.236 1.612-.472 2.477-.51c.905-.08 1.848-.158 2.753.235c1.14.472 1.337 1.534.472 2.36c-.393.393-.905.668-1.455.825c-.315.08-.354.236-.236.551c.354.865.59 1.77.472 2.753c-.04.157-.079.275.078.393c.354.236 1.691 0 1.967-.275c.511-.472.314-1.023.196-1.534c-.157-.63-.078-1.219.276-1.73m-7.197-2.045c-.118-.079-.197-.118-.315 0c.472.708.905 1.455 1.259 2.241c.314.866.668 1.73.55 2.714c-.118 1.18-1.1 1.69-2.123 1.101c-.511-.275-.905-.669-1.22-1.14c-.196-.276-.393-.276-.629-.08c-.747.63-1.533 1.102-2.516 1.26c-.158 0-.315 0-.394.157c-.118.393.472 1.612.826 1.809c.59.354 1.062 0 1.534-.276c.55-.314 1.101-.432 1.73-.236c.59.197.983.63 1.337 1.102c.158.196.315.353.63.432c.747.197 1.77-.59 2.084-1.376c1.18-3.028-.157-6.135-2.753-7.708m-2.556 2.438c.472-.669.826-1.416.983-2.202c-.157-.04-.197.04-.315.078c-.904.944-1.848 1.849-3.067 2.478c-.472.236-.983.433-1.534.433c-.865 0-1.376-.551-1.298-1.416a2.92 2.92 0 0 1 .787-1.849c.236-.275.236-.432-.04-.668c-.786-.55-1.494-1.22-1.848-2.124c-.078-.275-.275-.275-.51-.157a4 4 0 0 0-.434.236c-1.022.63-1.14 1.416-.275 2.28c.63.63.944 1.338.708 2.203c-.118.433-.354.747-.63 1.101a.95.95 0 0 0-.235.787c.079.747.826 1.494 1.73 1.573c2.517.236 4.562-.63 5.978-2.753m-4.68-5.152c1.376 1.18 3.067 1.455 4.837 1.377c.157 0 .315 0 .354-.118c.04-.197-.157-.197-.275-.236c-.826-.354-1.691-.63-2.438-1.14S6.848 8.25 6.534 7.266c-.236-.747.078-1.415.825-1.651c.669-.236 1.337-.236 1.967 0c.393.157.55.078.629-.354c.118-.747.354-1.455.826-2.085c.55-.786.55-.865-.354-1.376c-.04 0-.04-.04-.079-.04c-.865-.471-1.534-.196-1.848.709c-.472 1.376-1.377 1.887-2.832 1.612a4 4 0 0 0-.472-.079c-.747.118-1.18.55-1.297 1.14c-.158 1.81.786 3.107 2.084 4.17m-2.32 3.658c-.079-.944-1.023-1.652-2.045-1.534c-.905.079-1.691 1.022-1.613 1.966c.08.983 1.023 1.77 1.967 1.652c1.14-.079 1.73-1.18 1.69-2.084zm15.18-8.298c.943-.079 1.73-.983 1.651-1.927c-.078-.983-1.022-1.77-2.005-1.691c-1.023.079-1.73.983-1.652 1.966s.983 1.73 2.006 1.652m-12.27-.826c1.062-.157 1.77-1.023 1.652-2.045C8.107.897 7.163.149 6.18.267c-1.062.118-1.691.944-1.573 2.085c.118.865 1.061 1.612 1.966 1.494"/>
</svg>

Before

Width:  |  Height:  |  Size: 2.9 KiB

After

Width:  |  Height:  |  Size: 3.0 KiB

+1 -1
View File
@@ -1 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M12.076.066 8.883 3.28H3.348v5.434L0 12.01l3.349 3.298v5.39h5.374l3.285 3.236 3.285-3.236h5.43v-5.374L24 12.026l-3.232-3.252V3.321H15.31zm0 .749 2.49 2.506h-1.69v6.441l-.8.805-.81-.815V3.28H9.627zm-8.2 2.991h4.483L6.485 5.692l4.253 4.279v.654H9.94L5.674 6.423l-1.798 1.77zm5.227 0h1.635v5.415l-3.509-3.53zm4.302.043h1.687l1.83 1.842-3.517 3.539zm2.431 0h4.404v4.394l-1.83-1.842-4.241 4.267h-.764v-.69l4.261-4.287zm2.574 3.3 1.83 1.843v1.676h-5.327zm-12.735.013 3.515 3.462H3.876v-1.69zM3.348 9.454v1.697h6.377l.871.858-.782.77H3.35v1.786L.753 12.01zm17.42.068 2.488 2.503-2.533 2.55v-1.796h-6.41l-.75-.754.825-.83h6.38zm-9.502.978.81.815.186-.188.614-.618v.686h.768l-.825.83.75.754h-.719v.808l-.842-.83-.741.73v-.707h-.7l.781-.77-.188-.186-.682-.672h.788zm-7.39 2.807h5.402l-3.603 3.55-1.798-1.772zm6.154 0h.708v.7l-4.404 4.338 1.852 1.824h-4.31v-4.342l1.798 1.77zm3.348 0h.715l4.317 4.343.186-.187 1.599-1.61v4.316h-4.366l1.853-1.825-.188-.185-4.116-4.054zm1.46 0h5.357v1.798l-1.785 1.796zm-2.83.191.842.829v6.37h1.691l-2.532 2.495-2.533-2.495h1.79V14.23zm-1.27 1.251v5.42H8.939l-1.852-1.823zm2.64.097 3.552 3.499-1.853 1.825h-1.7z"/></svg>
<svg role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><title>CentOS</title><path d="M12.076.066L8.883 3.28H3.348v5.434L0 12.01l3.349 3.298v5.39h5.374l3.285 3.236 3.285-3.236h5.43v-5.374L24 12.026l-3.232-3.252V3.321H15.31zm0 .749l2.49 2.506h-1.69v6.441l-.8.805-.81-.815V3.28H9.627zm-8.2 2.991h4.483L6.485 5.692l4.253 4.279v.654H9.94L5.674 6.423l-1.798 1.77zm5.227 0h1.635v5.415l-3.509-3.53zm4.302.043h1.687l1.83 1.842-3.517 3.539zm2.431 0h4.404v4.394l-1.83-1.842-4.241 4.267h-.764v-.69l4.261-4.287zm2.574 3.3l1.83 1.843v1.676h-5.327zm-12.735.013l3.515 3.462H3.876v-1.69zM3.348 9.454v1.697h6.377l.871.858-.782.77H3.35v1.786L.753 12.01zm17.42.068l2.488 2.503-2.533 2.55v-1.796h-6.41l-.75-.754.825-.83h6.38zm-9.502.978l.81.815.186-.188.614-.618v.686h.768l-.825.83.75.754h-.719v.808l-.842-.83-.741.73v-.707h-.7l.781-.77-.188-.186-.682-.672h.788zm-7.39 2.807h5.402l-3.603 3.55-1.798-1.772zm6.154 0h.708v.7l-4.404 4.338 1.852 1.824h-4.31v-4.342l1.798 1.77zm3.348 0h.715l4.317 4.343.186-.187 1.599-1.61v4.316h-4.366l1.853-1.825-.188-.185-4.116-4.054zm1.46 0h5.357v1.798l-1.785 1.796zm-2.83.191l.842.829v6.37h1.691l-2.532 2.495-2.533-2.495h1.79V14.23zm-1.27 1.251v5.42H8.939l-1.852-1.823zm2.64.097l3.552 3.499-1.853 1.825h-1.7z"/></svg>

Before

Width:  |  Height:  |  Size: 1.2 KiB

After

Width:  |  Height:  |  Size: 1.2 KiB

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 43 KiB

After

Width:  |  Height:  |  Size: 56 KiB

-62
View File
@@ -1,62 +0,0 @@
"use strict";
const test = require("node:test");
const assert = require("node:assert/strict");
const { spawnSync } = require("node:child_process");
const path = require("node:path");
// Use native ESM linking, rather than Node's CommonJS interop or a bundler,
// so a missing browser export fails exactly as it does in the renderer.
async function checkDevImport() {
const { createServer } = await import("vite");
const { SourceTextModule, createContext } = await import("node:vm");
const { mkdtempSync, rmSync } = await import("node:fs");
const { createRequire } = await import("node:module");
const assert = (await import("node:assert/strict")).default;
const require = createRequire(`${process.cwd()}/package.json`);
const temp = require("./electron/bridges/tempDirBridge.cjs");
const cacheDir = mkdtempSync(`${temp.getTempFilePath("container-runtime-vite")}-`);
let server;
try {
server = await createServer({
cacheDir,
logLevel: "error",
server: { port: 0, strictPort: false, open: false },
optimizeDeps: { entries: [], noDiscovery: true },
});
await server.listen();
const origin = server.resolvedUrls.local[0];
const context = createContext({});
const modules = new Map();
async function load(url) {
if (!modules.has(url)) {
modules.set(url, (async () => {
const response = await fetch(url);
assert.equal(response.status, 200, url);
return new SourceTextModule(await response.text(), { context, identifier: url });
})());
}
return modules.get(url);
}
const entry = await load(new URL("/domain/systemManager/containerRuntime.ts", origin).href);
await entry.link((specifier, importer) => load(new URL(specifier, importer.identifier).href));
await entry.evaluate();
const runtime = entry.namespace;
assert.equal(runtime.firstContainerRuntime(["podman"]), "podman");
assert.equal(runtime.buildContainerExecShellCommand("podman", "abc").includes("podman exec"), true);
assert.equal(runtime.describeRuntimeFailure({ errorCode: "nerdctlUnreachable" }, key => key, "fallback"), "systemManager.errors.nerdctlUnreachable");
assert.equal(runtime.describeRuntimeFailure({ error: "original error" }, key => key, "fallback"), "original error");
assert.equal(runtime.describeRuntimeFailure(null, key => key, "fallback"), "fallback");
} finally {
await server?.close();
rmSync(cacheDir, { recursive: true, force: true });
}
}
test("Vite serves the renderer container runtime with browser-compatible exports", { timeout: 60_000 }, () => {
const result = spawnSync(process.execPath, [
"--experimental-vm-modules", "--input-type=module", "-e",
`(${checkDevImport.toString()})().catch(error => { console.error(error); process.exitCode = 1; });`,
], { cwd: path.resolve(__dirname, ".."), encoding: "utf8", timeout: 55_000 });
assert.equal(result.status, 0, result.error?.message || `${result.stdout}\n${result.stderr}`);
});
-16
View File
@@ -203,22 +203,6 @@ test("build.files trims release-only dependency payloads", () => {
}
});
test("build.files keeps README screenshot WebP out of the package", () => {
assert.ok(
config.files.includes("!docs/assets/readme/**/*"),
"README WebP screenshots must stay out of app.asar",
);
const { FileMatcher } = require("app-builder-lib/out/fileMatcher");
const root = path.resolve(__dirname, "..");
const matcher = new FileMatcher(root, path.join(root, "release"), (value) => value);
for (const pattern of config.files) matcher.addPattern(pattern);
const filter = matcher.createFilter();
const stat = { isDirectory: () => false };
assert.equal(filter(path.join(root, "docs/assets/readme/overview.webp"), stat), false);
assert.equal(filter(path.join(root, "electron/main.cjs"), stat), true);
assert.equal(filter(path.join(root, "public/icon.png"), stat), false);
});
test("build.files excludes Vite-bundled renderer-only packages", () => {
const files = config.files;
for (const glob of [
Loaded 100 of 103 files, more files were not shown because too many files have changed in this diff. Show more