Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
62eb8cbf42 | ||
|
|
f56cc29afe | ||
|
|
0a50705bb5 | ||
|
|
24253d6406 | ||
|
|
17b113091c | ||
|
|
e47378614a | ||
|
|
3a190e6a9e | ||
|
|
c9e22779a1 | ||
|
|
7c278f1c55 | ||
|
|
0ce1eb88be | ||
|
|
ceea6f2758 | ||
|
|
6d911712a6 | ||
|
|
69a8b1a0a2 | ||
|
|
24f390c332 |
No files matched your search
@@ -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&utm_medium=badge&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" />
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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&utm_medium=badge&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">
|
||||
A beautiful, feature-rich SSH workspace built with Electron, React, and xterm.js.<br/>
|
||||
🔥 Built-in AI Agent · Split terminals · Vault views · SFTP workflows · Custom themes — all in one.
|
||||
@@ -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,14 +63,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 +153,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 +165,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 +177,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 +189,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" />
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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&utm_medium=badge&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" />
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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&utm_medium=badge&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");
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -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]);
|
||||
|
||||
@@ -384,22 +384,6 @@ export const enAiMessages: Messages = {
|
||||
'ai.externalMcp.help.ariaLabel': 'External MCP help',
|
||||
'ai.externalMcp.security': 'Security',
|
||||
'ai.externalMcp.security.description': 'Listens on 127.0.0.1 with a rotating token, reuses AI Permission Mode for writes, and removes discovery when disabled. No OAuth — this is a local desktop bridge.',
|
||||
"ai.privacy.title": "AI privacy",
|
||||
"ai.privacy.description": "Filters known sensitive text patterns in Netcatty-managed context and tool results for Catty, integrated agents and external MCP clients. Off by default. It cannot detect every secret. Binary tool responses are blocked while enabled.",
|
||||
"ai.privacy.boundary": "Only Netcatty-managed data and tools are covered. Third-party agents' native tools, direct file access and other MCP servers remain outside this boundary.",
|
||||
"ai.privacy.enable": "Enable text redaction",
|
||||
"ai.privacy.privateIps": "Redact private IP addresses",
|
||||
"ai.privacy.privateIps.description": "Also hide private and local IP addresses in returned text.",
|
||||
"ai.privacy.cidrs": "Additional IP ranges (CIDR)",
|
||||
"ai.privacy.cidrs.description": "One IPv4 or IPv6 CIDR per line. Leave empty to use only built-in rules.",
|
||||
"ai.privacy.patterns": "Custom regular expressions",
|
||||
"ai.privacy.patterns.description": "One RE2-compatible pattern per line, without / delimiters or flags. Unsupported or unsafe patterns are rejected.",
|
||||
"ai.privacy.connectionScope": "Placeholders stay stable within one managed conversation or external MCP connection. Original values are restored only through Netcatty-managed tool calls in that same scope.",
|
||||
"ai.privacy.reconnect": "Apply changes, then start a new managed conversation and reconnect external MCP clients. Existing conversations and connections must not reuse placeholders.",
|
||||
"ai.privacy.apply": "Apply",
|
||||
"ai.privacy.applying": "Applying...",
|
||||
"ai.privacy.applied": "AI privacy settings applied. Start a new managed conversation and reconnect external MCP clients.",
|
||||
"ai.privacy.failed": "AI privacy settings were not applied.",
|
||||
'ai.externalMcp.http.title': 'Streamable HTTP',
|
||||
'ai.externalMcp.http.description': 'Serve the same External MCP tools over Streamable HTTP for clients that cannot launch the stdio CLI, such as Codex inside WSL. Off by default.',
|
||||
'ai.externalMcp.http.enable': 'Enable HTTP',
|
||||
|
||||
@@ -864,8 +864,8 @@ Highlight the focused split pane:
|
||||
'settings.terminal.systemManager.processRefreshInterval.desc': 'How often to refresh the process list in the system manager side panel.',
|
||||
'settings.terminal.systemManager.tmuxRefreshInterval': 'tmux session refresh',
|
||||
'settings.terminal.systemManager.tmuxRefreshInterval.desc': 'How often to refresh the tmux session list.',
|
||||
'settings.terminal.systemManager.dockerListRefreshInterval': 'Container list refresh',
|
||||
'settings.terminal.systemManager.dockerListRefreshInterval.desc': 'How often to refresh container and pod lists.',
|
||||
'settings.terminal.systemManager.dockerListRefreshInterval': 'Docker container list refresh',
|
||||
'settings.terminal.systemManager.dockerListRefreshInterval.desc': 'How often to refresh the Docker container list.',
|
||||
'settings.terminal.systemManager.dockerStatsRefreshInterval': 'Docker stats refresh',
|
||||
'settings.terminal.systemManager.dockerStatsRefreshInterval.desc': 'How often to refresh Docker container CPU/memory/network stats.',
|
||||
'settings.terminal.serverStats.show': 'Show Server Stats',
|
||||
@@ -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',
|
||||
|
||||
@@ -26,11 +26,6 @@ export const enSystemManagerMessages: Messages = {
|
||||
'systemManager.errors.loadDocker': 'Failed to load containers',
|
||||
'systemManager.errors.loadDockerStats': 'Failed to load container stats',
|
||||
'systemManager.errors.loadDockerImages': 'Failed to load images',
|
||||
'systemManager.errors.loadKubernetes': 'Failed to load Kubernetes resources',
|
||||
'systemManager.errors.loadPods': 'Failed to load pods',
|
||||
'systemManager.errors.nerdctlUnreachable': "nerdctl can't reach containerd",
|
||||
'systemManager.errors.clusterUnreachable': "Can't reach the cluster for context {{context}}",
|
||||
'systemManager.errors.clusterUnreachableGeneric': "Can't reach the cluster",
|
||||
'systemManager.errors.loadOverview': 'Failed to load system overview',
|
||||
'systemManager.errors.loadGpu': 'Failed to load GPU / NPU stats',
|
||||
'systemManager.errors.loadPorts': 'Failed to load listening ports',
|
||||
@@ -137,7 +132,7 @@ export const enSystemManagerMessages: Messages = {
|
||||
'systemManager.tmux.newWindowPlaceholder': 'Window name (optional)',
|
||||
'systemManager.tmux.noWindows': 'No windows',
|
||||
'systemManager.tmux.unavailable': 'tmux is not available on this host',
|
||||
'systemManager.docker.unavailable': 'No container runtime (Docker, Podman, nerdctl, or kubectl) was detected on this host',
|
||||
'systemManager.docker.unavailable': 'Docker is not available on this host',
|
||||
'systemManager.tmux.windowsMismatch': 'Session reports {{count}} window(s) but list-windows returned none',
|
||||
'systemManager.tmux.lastCommand': 'last command: {{command}}',
|
||||
'systemManager.tmux.noPanes': 'No panes',
|
||||
@@ -199,36 +194,6 @@ export const enSystemManagerMessages: Messages = {
|
||||
'systemManager.docker.imagesMeta': '{{count}} image(s)',
|
||||
'systemManager.docker.start': 'Start',
|
||||
'systemManager.docker.stop': 'Stop',
|
||||
'systemManager.runtime.switch': 'Container runtime',
|
||||
'systemManager.runtime.docker': 'Docker',
|
||||
'systemManager.runtime.podman': 'Podman',
|
||||
'systemManager.runtime.nerdctl': 'nerdctl',
|
||||
'systemManager.runtime.kubernetes': 'Kubernetes',
|
||||
'systemManager.k8s.context': 'Context',
|
||||
'systemManager.k8s.namespace': 'Namespace',
|
||||
'systemManager.k8s.empty': 'No pods found',
|
||||
'systemManager.k8s.search': 'Search pods…',
|
||||
'systemManager.k8s.meta': '{{count}} pod(s)',
|
||||
'systemManager.k8s.describe': 'Describe',
|
||||
'systemManager.k8s.delete': 'Delete pod',
|
||||
'systemManager.k8s.confirmDelete': 'Delete pod "{{name}}"?',
|
||||
'systemManager.k8s.restarts': '{{count}} restarts',
|
||||
'systemManager.k8s.chooseContainer': 'Choose a container',
|
||||
'systemManager.k8s.noContext': 'No Kubernetes context on this host',
|
||||
'systemManager.k8s.status.Running': 'Running',
|
||||
'systemManager.k8s.status.Pending': 'Pending',
|
||||
'systemManager.k8s.status.Completed': 'Completed',
|
||||
'systemManager.k8s.status.Succeeded': 'Succeeded',
|
||||
'systemManager.k8s.status.Failed': 'Failed',
|
||||
'systemManager.k8s.status.Unknown': 'Unknown',
|
||||
'systemManager.k8s.status.CrashLoopBackOff': 'CrashLoopBackOff',
|
||||
'systemManager.k8s.status.ContainerCreating': 'ContainerCreating',
|
||||
'systemManager.k8s.status.Terminating': 'Terminating',
|
||||
'systemManager.k8s.status.ImagePullBackOff': 'ImagePullBackOff',
|
||||
'systemManager.k8s.status.ErrImagePull': 'ErrImagePull',
|
||||
'systemManager.k8s.status.Error': 'Error',
|
||||
'systemManager.k8s.status.Evicted': 'Evicted',
|
||||
'systemManager.k8s.status.OOMKilled': 'OOMKilled',
|
||||
|
||||
'systemManager.ports.unavailable': 'No listening-port tools (ss / netstat) detected on this host.',
|
||||
'systemManager.ports.loading': 'Loading listening ports…',
|
||||
|
||||
@@ -367,22 +367,6 @@ export const esAiMessages: Messages = {
|
||||
'ai.externalMcp.help.ariaLabel': 'Ayuda del MCP externo',
|
||||
'ai.externalMcp.security': 'Seguridad',
|
||||
'ai.externalMcp.security.description': 'Escucha en 127.0.0.1 con un token rotativo, reutiliza el modo de permisos de IA para las escrituras y elimina el descubrimiento al desactivarse. Sin OAuth: este es un puente local de escritorio.',
|
||||
"ai.privacy.title": "Privacidad de IA",
|
||||
"ai.privacy.description": "Filtra patrones de texto sensibles conocidos en el contexto y los resultados de herramientas gestionados por Netcatty para Catty, agentes integrados y clientes MCP externos. Desactivado de forma predeterminada. No detecta todos los secretos. Las respuestas binarias de herramientas se bloquean mientras está activo.",
|
||||
"ai.privacy.boundary": "Solo cubre los datos y las herramientas gestionados por Netcatty. Las herramientas nativas de agentes de terceros, su acceso directo a archivos y otros servidores MCP quedan fuera de esta protección.",
|
||||
"ai.privacy.enable": "Activar ocultación de texto",
|
||||
"ai.privacy.privateIps": "Ocultar direcciones IP privadas",
|
||||
"ai.privacy.privateIps.description": "Oculta también las direcciones IP privadas y locales en el texto devuelto.",
|
||||
"ai.privacy.cidrs": "Rangos IP adicionales (CIDR)",
|
||||
"ai.privacy.cidrs.description": "Un CIDR IPv4 o IPv6 por línea. Déjalo vacío para usar solo las reglas integradas.",
|
||||
"ai.privacy.patterns": "Expresiones regulares personalizadas",
|
||||
"ai.privacy.patterns.description": "Un patrón compatible con RE2 por línea, sin delimitadores / ni indicadores. Los patrones incompatibles o inseguros se rechazan.",
|
||||
"ai.privacy.connectionScope": "Los marcadores son estables dentro de una conversación gestionada o una conexión MCP externa. Los valores originales solo se restauran mediante llamadas a herramientas gestionadas por Netcatty dentro del mismo ámbito.",
|
||||
"ai.privacy.reconnect": "Aplica los cambios, inicia una nueva conversación gestionada y vuelve a conectar los clientes MCP externos. Las conversaciones y conexiones existentes no deben reutilizar marcadores.",
|
||||
"ai.privacy.apply": "Aplicar",
|
||||
"ai.privacy.applying": "Aplicando...",
|
||||
"ai.privacy.applied": "Configuración de privacidad de IA aplicada. Inicia una nueva conversación gestionada y vuelve a conectar los clientes MCP externos.",
|
||||
"ai.privacy.failed": "No se aplicó la configuración de privacidad de IA.",
|
||||
'ai.externalMcp.http.title': 'Streamable HTTP',
|
||||
'ai.externalMcp.http.description': 'Expone las mismas herramientas de MCP externo por Streamable HTTP para clientes que no pueden iniciar el CLI stdio, como Codex dentro de WSL. Desactivado de forma predeterminada.',
|
||||
'ai.externalMcp.http.enable': 'Activar HTTP',
|
||||
|
||||
@@ -779,8 +779,8 @@ Resalta el panel dividido enfocado:
|
||||
'settings.terminal.systemManager.processRefreshInterval.desc': 'Con qué frecuencia actualizar la lista de procesos en el panel lateral del administrador del sistema.',
|
||||
'settings.terminal.systemManager.tmuxRefreshInterval': 'Actualización de sesiones tmux',
|
||||
'settings.terminal.systemManager.tmuxRefreshInterval.desc': 'Con qué frecuencia actualizar la lista de sesiones tmux.',
|
||||
'settings.terminal.systemManager.dockerListRefreshInterval': 'Actualización de la lista de contenedores',
|
||||
'settings.terminal.systemManager.dockerListRefreshInterval.desc': 'Con qué frecuencia actualizar las listas de contenedores y pods.',
|
||||
'settings.terminal.systemManager.dockerListRefreshInterval': 'Actualización de la lista de contenedores Docker',
|
||||
'settings.terminal.systemManager.dockerListRefreshInterval.desc': 'Con qué frecuencia actualizar la lista de contenedores Docker.',
|
||||
'settings.terminal.systemManager.dockerStatsRefreshInterval': 'Actualización de estadísticas Docker',
|
||||
'settings.terminal.systemManager.dockerStatsRefreshInterval.desc': 'Con qué frecuencia actualizar las estadísticas de CPU/memoria/red de los contenedores Docker.',
|
||||
'settings.terminal.serverStats.show': 'Mostrar estadísticas del servidor',
|
||||
@@ -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',
|
||||
|
||||
@@ -26,8 +26,6 @@ export const esSystemManagerMessages: Messages = {
|
||||
'systemManager.errors.loadDocker': 'No se pudieron cargar los contenedores',
|
||||
'systemManager.errors.loadDockerStats': 'No se pudieron cargar las estadísticas de los contenedores',
|
||||
'systemManager.errors.loadDockerImages': 'No se pudieron cargar las imágenes',
|
||||
'systemManager.errors.loadKubernetes': 'No se pudieron cargar los recursos de Kubernetes',
|
||||
'systemManager.errors.loadPods': 'No se pudieron cargar los pods',
|
||||
'systemManager.errors.loadOverview': 'No se pudo cargar el resumen del sistema',
|
||||
'systemManager.errors.loadGpu': 'No se pudieron cargar las estadísticas de GPU / NPU',
|
||||
'systemManager.errors.loadPorts': 'No se pudieron cargar los puertos en escucha',
|
||||
@@ -134,7 +132,7 @@ export const esSystemManagerMessages: Messages = {
|
||||
'systemManager.tmux.newWindowPlaceholder': 'Nombre de la ventana (opcional)',
|
||||
'systemManager.tmux.noWindows': 'No hay ventanas',
|
||||
'systemManager.tmux.unavailable': 'tmux no está disponible en este host',
|
||||
'systemManager.docker.unavailable': 'No se detectó ningún runtime de contenedores (Docker, Podman, nerdctl o kubectl) en este host',
|
||||
'systemManager.docker.unavailable': 'Docker no está disponible en este host',
|
||||
'systemManager.tmux.windowsMismatch': 'La sesión reporta {{count}} ventana(s), pero list-windows no devolvió ninguna',
|
||||
'systemManager.tmux.lastCommand': 'último comando: {{command}}',
|
||||
'systemManager.tmux.noPanes': 'No hay paneles',
|
||||
@@ -196,22 +194,6 @@ export const esSystemManagerMessages: Messages = {
|
||||
'systemManager.docker.imagesMeta': '{{count}} imagen(es)',
|
||||
'systemManager.docker.start': 'Iniciar',
|
||||
'systemManager.docker.stop': 'Detener',
|
||||
'systemManager.runtime.switch': 'Runtime de contenedores',
|
||||
'systemManager.runtime.docker': 'Docker',
|
||||
'systemManager.runtime.podman': 'Podman',
|
||||
'systemManager.runtime.nerdctl': 'nerdctl',
|
||||
'systemManager.runtime.kubernetes': 'Kubernetes',
|
||||
'systemManager.k8s.context': 'Contexto',
|
||||
'systemManager.k8s.namespace': 'Espacio de nombres',
|
||||
'systemManager.k8s.empty': 'No se encontraron pods',
|
||||
'systemManager.k8s.search': 'Buscar pods…',
|
||||
'systemManager.k8s.meta': '{{count}} pod(s)',
|
||||
'systemManager.k8s.describe': 'Describir',
|
||||
'systemManager.k8s.delete': 'Eliminar pod',
|
||||
'systemManager.k8s.confirmDelete': '¿Eliminar el pod "{{name}}"?',
|
||||
'systemManager.k8s.restarts': '{{count}} reinicios',
|
||||
'systemManager.k8s.chooseContainer': 'Elegir un contenedor',
|
||||
'systemManager.k8s.noContext': 'No hay un contexto de Kubernetes en este host',
|
||||
|
||||
'systemManager.ports.unavailable': 'No se detectaron herramientas de puertos en escucha (ss / netstat) en este host.',
|
||||
'systemManager.ports.loading': 'Cargando puertos en escucha…',
|
||||
|
||||
@@ -1,23 +0,0 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import en from './en.ts';
|
||||
import es from './es.ts';
|
||||
import ru from './ru.ts';
|
||||
import zhCN from './zh-CN.ts';
|
||||
import zhTW from './zh-TW.ts';
|
||||
|
||||
const KEYS = [
|
||||
'title', 'description', 'boundary', 'enable', 'privateIps', 'privateIps.description',
|
||||
'cidrs', 'cidrs.description', 'patterns', 'patterns.description',
|
||||
'connectionScope', 'reconnect', 'apply', 'applying', 'applied', 'failed',
|
||||
];
|
||||
|
||||
test('Shared AI privacy settings are localized in every supported locale', () => {
|
||||
for (const [name, messages] of Object.entries({ en, es, ru, 'zh-CN': zhCN, 'zh-TW': zhTW })) {
|
||||
assert.deepEqual(
|
||||
KEYS.filter((key) => !messages[`ai.privacy.${key}`]),
|
||||
[],
|
||||
`${name} is missing privacy settings labels`,
|
||||
);
|
||||
}
|
||||
});
|
||||
@@ -323,22 +323,6 @@ export const ruAiMessages: Messages = {
|
||||
'ai.externalMcp.help.ariaLabel': 'Справка External MCP',
|
||||
'ai.externalMcp.security': 'Безопасность',
|
||||
'ai.externalMcp.security.description': 'Слушает только 127.0.0.1 с ротацией token, использует AI Permission Mode для записи и удаляет discovery при отключении.',
|
||||
"ai.privacy.title": "Конфиденциальность ИИ",
|
||||
"ai.privacy.description": "Фильтрует известные шаблоны конфиденциального текста в контексте и результатах инструментов, управляемых Netcatty, для Catty, встроенных агентов и внешних MCP-клиентов. По умолчанию отключено. Не обнаруживает все секреты. При включении двоичные ответы инструментов блокируются.",
|
||||
"ai.privacy.boundary": "Защищаются только данные и инструменты, управляемые Netcatty. Собственные инструменты сторонних агентов, их прямой доступ к файлам и другие MCP-серверы не входят в эту область.",
|
||||
"ai.privacy.enable": "Включить скрытие текста",
|
||||
"ai.privacy.privateIps": "Скрывать частные IP-адреса",
|
||||
"ai.privacy.privateIps.description": "Также скрывает частные и локальные IP-адреса в возвращаемом тексте.",
|
||||
"ai.privacy.cidrs": "Дополнительные диапазоны IP (CIDR)",
|
||||
"ai.privacy.cidrs.description": "Один CIDR IPv4 или IPv6 на строку. Оставьте пустым для использования только встроенных правил.",
|
||||
"ai.privacy.patterns": "Пользовательские регулярные выражения",
|
||||
"ai.privacy.patterns.description": "Один RE2-совместимый шаблон на строку, без разделителей / и флагов. Неподдерживаемые или небезопасные шаблоны отклоняются.",
|
||||
"ai.privacy.connectionScope": "Маркеры постоянны в пределах одного управляемого диалога или внешнего MCP-подключения. Исходные значения восстанавливаются только через вызовы инструментов, управляемых Netcatty, в той же области.",
|
||||
"ai.privacy.reconnect": "Примените изменения, начните новый управляемый диалог и переподключите внешние MCP-клиенты. Существующие диалоги и подключения не должны повторно использовать маркеры.",
|
||||
"ai.privacy.apply": "Применить",
|
||||
"ai.privacy.applying": "Применение...",
|
||||
"ai.privacy.applied": "Настройки конфиденциальности ИИ применены. Начните новый управляемый диалог и переподключите внешние MCP-клиенты.",
|
||||
"ai.privacy.failed": "Настройки конфиденциальности ИИ не применены.",
|
||||
'ai.externalMcp.http.title': 'Streamable HTTP',
|
||||
'ai.externalMcp.http.description': 'Отдает те же инструменты External MCP по Streamable HTTP клиентам, которые не могут запустить stdio CLI, например Codex в WSL. По умолчанию выключено.',
|
||||
'ai.externalMcp.http.enable': 'Включить HTTP',
|
||||
|
||||
@@ -817,8 +817,8 @@ export const ruCoreMessages: Messages = {
|
||||
'settings.terminal.systemManager.processRefreshInterval.desc': 'Как часто обновлять список процессов в боковой панели системного менеджера.',
|
||||
'settings.terminal.systemManager.tmuxRefreshInterval': 'Обновление сессий tmux',
|
||||
'settings.terminal.systemManager.tmuxRefreshInterval.desc': 'Как часто обновлять список сессий tmux.',
|
||||
'settings.terminal.systemManager.dockerListRefreshInterval': 'Обновление списка контейнеров',
|
||||
'settings.terminal.systemManager.dockerListRefreshInterval.desc': 'Как часто обновлять списки контейнеров и подов.',
|
||||
'settings.terminal.systemManager.dockerListRefreshInterval': 'Обновление списка контейнеров Docker',
|
||||
'settings.terminal.systemManager.dockerListRefreshInterval.desc': 'Как часто обновлять список контейнеров Docker.',
|
||||
'settings.terminal.systemManager.dockerStatsRefreshInterval': 'Обновление статистики Docker',
|
||||
'settings.terminal.systemManager.dockerStatsRefreshInterval.desc': 'Как часто обновлять CPU/память/сеть контейнеров Docker.',
|
||||
'settings.terminal.serverStats.show': 'Показывать статистику сервера',
|
||||
|
||||
@@ -26,8 +26,6 @@ export const ruSystemManagerMessages: Messages = {
|
||||
'systemManager.errors.loadDocker': 'Не удалось загрузить контейнеры',
|
||||
'systemManager.errors.loadDockerStats': 'Не удалось загрузить статистику контейнеров',
|
||||
'systemManager.errors.loadDockerImages': 'Не удалось загрузить образы',
|
||||
'systemManager.errors.loadKubernetes': 'Не удалось загрузить ресурсы Kubernetes',
|
||||
'systemManager.errors.loadPods': 'Не удалось загрузить поды',
|
||||
'systemManager.errors.loadOverview': 'Не удалось загрузить обзор системы',
|
||||
'systemManager.errors.loadGpu': 'Не удалось загрузить статистику GPU / NPU',
|
||||
'systemManager.errors.loadPorts': 'Не удалось загрузить слушающие порты',
|
||||
@@ -134,7 +132,7 @@ export const ruSystemManagerMessages: Messages = {
|
||||
'systemManager.tmux.newWindowPlaceholder': 'Имя окна (необязательно)',
|
||||
'systemManager.tmux.noWindows': 'Нет окон',
|
||||
'systemManager.tmux.unavailable': 'tmux недоступен на этом хосте',
|
||||
'systemManager.docker.unavailable': 'На этом хосте не обнаружена среда контейнеров (Docker, Podman, nerdctl или kubectl)',
|
||||
'systemManager.docker.unavailable': 'Docker недоступен на этом хосте',
|
||||
'systemManager.tmux.windowsMismatch': 'В сессии указано {{count}} окон, но list-windows ничего не вернул',
|
||||
'systemManager.tmux.lastCommand': 'последняя команда: {{command}}',
|
||||
'systemManager.tmux.noPanes': 'Нет панелей',
|
||||
@@ -196,22 +194,6 @@ export const ruSystemManagerMessages: Messages = {
|
||||
'systemManager.docker.imagesMeta': '{{count}} образов',
|
||||
'systemManager.docker.start': 'Запустить',
|
||||
'systemManager.docker.stop': 'Остановить',
|
||||
'systemManager.runtime.switch': 'Среда выполнения контейнеров',
|
||||
'systemManager.runtime.docker': 'Docker',
|
||||
'systemManager.runtime.podman': 'Podman',
|
||||
'systemManager.runtime.nerdctl': 'nerdctl',
|
||||
'systemManager.runtime.kubernetes': 'Kubernetes',
|
||||
'systemManager.k8s.context': 'Контекст',
|
||||
'systemManager.k8s.namespace': 'Пространство имён',
|
||||
'systemManager.k8s.empty': 'Поды не найдены',
|
||||
'systemManager.k8s.search': 'Поиск подов…',
|
||||
'systemManager.k8s.meta': '{{count}} под(ов)',
|
||||
'systemManager.k8s.describe': 'Описание',
|
||||
'systemManager.k8s.delete': 'Удалить под',
|
||||
'systemManager.k8s.confirmDelete': 'Удалить под «{{name}}»?',
|
||||
'systemManager.k8s.restarts': '{{count}} перезапусков',
|
||||
'systemManager.k8s.chooseContainer': 'Выберите контейнер',
|
||||
'systemManager.k8s.noContext': 'На этом хосте нет контекста Kubernetes',
|
||||
|
||||
'systemManager.ports.unavailable': 'На этом хосте не обнаружены инструменты портов (ss / netstat).',
|
||||
'systemManager.ports.loading': 'Загрузка слушающих портов…',
|
||||
|
||||
@@ -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': 'Сохраняйте хосты, чтобы быстро подключаться к серверам, виртуальным машинам и контейнерам.',
|
||||
|
||||
@@ -384,22 +384,6 @@ export const zhCNAiMessages: Messages = {
|
||||
'ai.externalMcp.help.ariaLabel': '对外 MCP 使用说明',
|
||||
'ai.externalMcp.security': '安全',
|
||||
'ai.externalMcp.security.description': '仅监听 127.0.0.1,使用轮换 token,写操作复用 AI 权限模式,关闭时删除 discovery。不做 OAuth——这是本机桌面桥接。',
|
||||
"ai.privacy.title": "AI 隐私",
|
||||
"ai.privacy.description": "为 Catty、集成 Agent 和外部 MCP 客户端过滤 Netcatty 管理的上下文及工具结果中已知的敏感文本模式。默认关闭,无法识别所有机密。启用时会阻止二进制工具响应。",
|
||||
"ai.privacy.boundary": "仅覆盖 Netcatty 管理的数据和工具。第三方 Agent 的原生工具、直接读取的文件及其他 MCP 服务器不在保护范围内。",
|
||||
"ai.privacy.enable": "启用文本脱敏",
|
||||
"ai.privacy.privateIps": "隐藏私有 IP 地址",
|
||||
"ai.privacy.privateIps.description": "同时隐藏返回文本中的私有和本地 IP 地址。",
|
||||
"ai.privacy.cidrs": "附加 IP 网段(CIDR)",
|
||||
"ai.privacy.cidrs.description": "每行一个 IPv4 或 IPv6 CIDR。留空则仅使用内置规则。",
|
||||
"ai.privacy.patterns": "自定义正则表达式",
|
||||
"ai.privacy.patterns.description": "每行一个兼容 RE2 的模式,不含 / 分隔符或标志。不支持或不安全的模式会被拒绝。",
|
||||
"ai.privacy.connectionScope": "占位符在同一个受管对话或外部 MCP 连接内保持稳定。只有同一范围内的 Netcatty 受管工具调用才能还原原始值。",
|
||||
"ai.privacy.reconnect": "应用更改后,请新建受管对话并重新连接外部 MCP 客户端。已有对话和连接不得复用占位符。",
|
||||
"ai.privacy.apply": "应用",
|
||||
"ai.privacy.applying": "正在应用...",
|
||||
"ai.privacy.applied": "AI 隐私设置已应用。请新建受管对话并重新连接外部 MCP 客户端。",
|
||||
"ai.privacy.failed": "AI 隐私设置未应用。",
|
||||
'ai.externalMcp.http.title': 'Streamable HTTP',
|
||||
'ai.externalMcp.http.description': '用 Streamable HTTP 提供同一套对外 MCP 工具,方便无法启动 stdio CLI 的客户端,例如 WSL 里的 Codex。默认关闭。',
|
||||
'ai.externalMcp.http.enable': '启用 HTTP',
|
||||
|
||||
@@ -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': '设置你的主机',
|
||||
|
||||
@@ -26,11 +26,6 @@ export const zhCnSystemManagerMessages: Messages = {
|
||||
'systemManager.errors.loadDocker': '加载容器列表失败',
|
||||
'systemManager.errors.loadDockerStats': '加载容器性能数据失败',
|
||||
'systemManager.errors.loadDockerImages': '加载镜像列表失败',
|
||||
'systemManager.errors.loadKubernetes': '无法加载 Kubernetes 资源',
|
||||
'systemManager.errors.loadPods': '无法加载 Pod',
|
||||
'systemManager.errors.nerdctlUnreachable': 'nerdctl 无法连接 containerd',
|
||||
'systemManager.errors.clusterUnreachable': '无法连接到上下文 {{context}} 的集群',
|
||||
'systemManager.errors.clusterUnreachableGeneric': '无法连接到集群',
|
||||
'systemManager.errors.loadOverview': '加载系统概览失败',
|
||||
'systemManager.errors.loadGpu': '加载 GPU / NPU 状态失败',
|
||||
'systemManager.errors.loadPorts': '加载监听端口失败',
|
||||
@@ -137,7 +132,7 @@ export const zhCnSystemManagerMessages: Messages = {
|
||||
'systemManager.tmux.newWindowPlaceholder': '窗口名称(可选)',
|
||||
'systemManager.tmux.noWindows': '没有窗口',
|
||||
'systemManager.tmux.unavailable': '此主机未检测到 tmux',
|
||||
'systemManager.docker.unavailable': '此主机未检测到容器运行时(Docker、Podman、nerdctl 或 kubectl)',
|
||||
'systemManager.docker.unavailable': '此主机未检测到 Docker',
|
||||
'systemManager.tmux.windowsMismatch': '会话显示有 {{count}} 个窗口,但 list-windows 未返回任何窗口',
|
||||
'systemManager.tmux.lastCommand': '最后执行的命令:{{command}}',
|
||||
'systemManager.tmux.noPanes': '没有面板',
|
||||
@@ -199,36 +194,6 @@ export const zhCnSystemManagerMessages: Messages = {
|
||||
'systemManager.docker.imagesMeta': '{{count}} 个镜像',
|
||||
'systemManager.docker.start': '启动',
|
||||
'systemManager.docker.stop': '停止',
|
||||
'systemManager.runtime.switch': '容器运行时',
|
||||
'systemManager.runtime.docker': 'Docker',
|
||||
'systemManager.runtime.podman': 'Podman',
|
||||
'systemManager.runtime.nerdctl': 'nerdctl',
|
||||
'systemManager.runtime.kubernetes': 'Kubernetes',
|
||||
'systemManager.k8s.context': '上下文',
|
||||
'systemManager.k8s.namespace': '命名空间',
|
||||
'systemManager.k8s.empty': '未找到 Pod',
|
||||
'systemManager.k8s.search': '搜索 Pod…',
|
||||
'systemManager.k8s.meta': '{{count}} 个 Pod',
|
||||
'systemManager.k8s.describe': '描述',
|
||||
'systemManager.k8s.delete': '删除 Pod',
|
||||
'systemManager.k8s.confirmDelete': '确定删除 Pod「{{name}}」?',
|
||||
'systemManager.k8s.restarts': '重启 {{count}} 次',
|
||||
'systemManager.k8s.chooseContainer': '选择容器',
|
||||
'systemManager.k8s.noContext': '此主机没有 Kubernetes 上下文',
|
||||
'systemManager.k8s.status.Running': '运行中',
|
||||
'systemManager.k8s.status.Pending': '等待中',
|
||||
'systemManager.k8s.status.Completed': '已完成',
|
||||
'systemManager.k8s.status.Succeeded': '已成功',
|
||||
'systemManager.k8s.status.Failed': '失败',
|
||||
'systemManager.k8s.status.Unknown': '未知',
|
||||
'systemManager.k8s.status.CrashLoopBackOff': '崩溃循环',
|
||||
'systemManager.k8s.status.ContainerCreating': '创建中',
|
||||
'systemManager.k8s.status.Terminating': '终止中',
|
||||
'systemManager.k8s.status.ImagePullBackOff': '镜像拉取退避',
|
||||
'systemManager.k8s.status.ErrImagePull': '拉取镜像失败',
|
||||
'systemManager.k8s.status.Error': '错误',
|
||||
'systemManager.k8s.status.Evicted': '已驱逐',
|
||||
'systemManager.k8s.status.OOMKilled': '内存不足',
|
||||
|
||||
'systemManager.ports.unavailable': '此主机未检测到端口工具(ss / netstat)。',
|
||||
'systemManager.ports.loading': '正在加载监听端口…',
|
||||
|
||||
@@ -487,8 +487,8 @@ export const zhCNTerminalMessages: Messages = {
|
||||
'settings.terminal.systemManager.processRefreshInterval.desc': '系统管理侧栏中进程列表的刷新频率。',
|
||||
'settings.terminal.systemManager.tmuxRefreshInterval': 'tmux 会话刷新间隔',
|
||||
'settings.terminal.systemManager.tmuxRefreshInterval.desc': 'tmux 会话列表的刷新频率。',
|
||||
'settings.terminal.systemManager.dockerListRefreshInterval': '容器列表刷新间隔',
|
||||
'settings.terminal.systemManager.dockerListRefreshInterval.desc': '容器与 Pod 列表的刷新频率。',
|
||||
'settings.terminal.systemManager.dockerListRefreshInterval': 'Docker 容器列表刷新间隔',
|
||||
'settings.terminal.systemManager.dockerListRefreshInterval.desc': 'Docker 容器列表的刷新频率。',
|
||||
'settings.terminal.systemManager.dockerStatsRefreshInterval': 'Docker 性能数据刷新间隔',
|
||||
'settings.terminal.systemManager.dockerStatsRefreshInterval.desc': 'Docker 容器 CPU/内存/网络指标的刷新频率。',
|
||||
|
||||
|
||||
@@ -374,22 +374,6 @@ export const zhTWAiMessages: Messages = {
|
||||
'ai.externalMcp.help.ariaLabel': '對外 MCP 使用說明',
|
||||
'ai.externalMcp.security': '安全',
|
||||
'ai.externalMcp.security.description': '僅監聽 127.0.0.1,使用輪替 token,寫入操作重用 AI 權限模式,關閉時刪除 discovery。',
|
||||
"ai.privacy.title": "AI 隱私",
|
||||
"ai.privacy.description": "為 Catty、整合 Agent 和外部 MCP 用戶端過濾 Netcatty 管理的上下文及工具結果中已知的敏感文字模式。預設關閉,無法識別所有機密。啟用時會封鎖二進位工具回應。",
|
||||
"ai.privacy.boundary": "僅涵蓋 Netcatty 管理的資料和工具。第三方 Agent 的原生工具、直接讀取的檔案及其他 MCP 伺服器不在保護範圍內。",
|
||||
"ai.privacy.enable": "啟用文字去識別化",
|
||||
"ai.privacy.privateIps": "隱藏私有 IP 位址",
|
||||
"ai.privacy.privateIps.description": "同時隱藏回傳文字中的私有和本機 IP 位址。",
|
||||
"ai.privacy.cidrs": "額外 IP 網段(CIDR)",
|
||||
"ai.privacy.cidrs.description": "每行一個 IPv4 或 IPv6 CIDR。留空則僅使用內建規則。",
|
||||
"ai.privacy.patterns": "自訂正規表示式",
|
||||
"ai.privacy.patterns.description": "每行一個相容 RE2 的模式,不含 / 分隔符號或旗標。不支援或不安全的模式會遭拒絕。",
|
||||
"ai.privacy.connectionScope": "預留位置在同一個受管對話或外部 MCP 連線內保持穩定。只有同一範圍內的 Netcatty 受管工具呼叫才能還原原始值。",
|
||||
"ai.privacy.reconnect": "套用變更後,請建立新的受管對話並重新連接外部 MCP 用戶端。現有對話和連線不得重複使用預留位置。",
|
||||
"ai.privacy.apply": "套用",
|
||||
"ai.privacy.applying": "正在套用...",
|
||||
"ai.privacy.applied": "AI 隱私設定已套用。請建立新的受管對話並重新連接外部 MCP 用戶端。",
|
||||
"ai.privacy.failed": "AI 隱私設定未套用。",
|
||||
'ai.externalMcp.http.title': 'Streamable HTTP',
|
||||
'ai.externalMcp.http.description': '以 Streamable HTTP 提供同一套對外 MCP 工具,給無法啟動 stdio CLI 的用戶端使用,例如 WSL 裡的 Codex。預設關閉。',
|
||||
'ai.externalMcp.http.enable': '啟用 HTTP',
|
||||
|
||||
@@ -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': '設定你的主機',
|
||||
|
||||
@@ -26,11 +26,6 @@ export const zhTwSystemManagerMessages: Messages = {
|
||||
'systemManager.errors.loadDocker': '載入容器列表失敗',
|
||||
'systemManager.errors.loadDockerStats': '載入容器效能資料失敗',
|
||||
'systemManager.errors.loadDockerImages': '載入映像列表失敗',
|
||||
'systemManager.errors.loadKubernetes': '無法載入 Kubernetes 資源',
|
||||
'systemManager.errors.loadPods': '無法載入 Pod',
|
||||
'systemManager.errors.nerdctlUnreachable': 'nerdctl 無法連線到 containerd',
|
||||
'systemManager.errors.clusterUnreachable': '無法連到內容 {{context}} 的叢集',
|
||||
'systemManager.errors.clusterUnreachableGeneric': '無法連到叢集',
|
||||
'systemManager.errors.loadOverview': '載入系統概覽失敗',
|
||||
'systemManager.errors.loadGpu': '載入 GPU / NPU 狀態失敗',
|
||||
'systemManager.errors.loadPorts': '載入監聽連接埠失敗',
|
||||
@@ -137,7 +132,7 @@ export const zhTwSystemManagerMessages: Messages = {
|
||||
'systemManager.tmux.newWindowPlaceholder': '視窗名稱(可選)',
|
||||
'systemManager.tmux.noWindows': '沒有視窗',
|
||||
'systemManager.tmux.unavailable': '此主機未偵測到 tmux',
|
||||
'systemManager.docker.unavailable': '此主機未偵測到容器執行環境(Docker、Podman、nerdctl 或 kubectl)',
|
||||
'systemManager.docker.unavailable': '此主機未偵測到 Docker',
|
||||
'systemManager.tmux.windowsMismatch': '工作階段顯示有 {{count}} 個視窗,但 list-windows 未傳回任何視窗',
|
||||
'systemManager.tmux.lastCommand': '最後執行的指令:{{command}}',
|
||||
'systemManager.tmux.noPanes': '沒有窗格',
|
||||
@@ -199,36 +194,6 @@ export const zhTwSystemManagerMessages: Messages = {
|
||||
'systemManager.docker.imagesMeta': '{{count}} 個映像',
|
||||
'systemManager.docker.start': '啟動',
|
||||
'systemManager.docker.stop': '停止',
|
||||
'systemManager.runtime.switch': '容器執行環境',
|
||||
'systemManager.runtime.docker': 'Docker',
|
||||
'systemManager.runtime.podman': 'Podman',
|
||||
'systemManager.runtime.nerdctl': 'nerdctl',
|
||||
'systemManager.runtime.kubernetes': 'Kubernetes',
|
||||
'systemManager.k8s.context': '內容',
|
||||
'systemManager.k8s.namespace': '命名空間',
|
||||
'systemManager.k8s.empty': '沒有 Pod',
|
||||
'systemManager.k8s.search': '搜尋 Pod…',
|
||||
'systemManager.k8s.meta': '{{count}} 個 Pod',
|
||||
'systemManager.k8s.describe': '描述',
|
||||
'systemManager.k8s.delete': '刪除 Pod',
|
||||
'systemManager.k8s.confirmDelete': '確定刪除 Pod「{{name}}」?',
|
||||
'systemManager.k8s.restarts': '重新啟動 {{count}} 次',
|
||||
'systemManager.k8s.chooseContainer': '選擇容器',
|
||||
'systemManager.k8s.noContext': '此主機沒有 Kubernetes 內容',
|
||||
'systemManager.k8s.status.Running': '執行中',
|
||||
'systemManager.k8s.status.Pending': '等待中',
|
||||
'systemManager.k8s.status.Completed': '已完成',
|
||||
'systemManager.k8s.status.Succeeded': '已成功',
|
||||
'systemManager.k8s.status.Failed': '失敗',
|
||||
'systemManager.k8s.status.Unknown': '未知',
|
||||
'systemManager.k8s.status.CrashLoopBackOff': '當機重試',
|
||||
'systemManager.k8s.status.ContainerCreating': '建立中',
|
||||
'systemManager.k8s.status.Terminating': '終止中',
|
||||
'systemManager.k8s.status.ImagePullBackOff': '映像拉取退避',
|
||||
'systemManager.k8s.status.ErrImagePull': '拉取映像失敗',
|
||||
'systemManager.k8s.status.Error': '錯誤',
|
||||
'systemManager.k8s.status.Evicted': '已驅逐',
|
||||
'systemManager.k8s.status.OOMKilled': '記憶體不足',
|
||||
|
||||
'systemManager.ports.unavailable': '此主機未偵測到連接埠工具(ss / netstat)。',
|
||||
'systemManager.ports.loading': '正在載入監聽連接埠…',
|
||||
|
||||
@@ -487,8 +487,8 @@ export const zhTWTerminalMessages: Messages = {
|
||||
'settings.terminal.systemManager.processRefreshInterval.desc': '系統管理側欄中程序列表的重新整理頻率。',
|
||||
'settings.terminal.systemManager.tmuxRefreshInterval': 'tmux 工作階段重新整理間隔',
|
||||
'settings.terminal.systemManager.tmuxRefreshInterval.desc': 'tmux 工作階段列表的重新整理頻率。',
|
||||
'settings.terminal.systemManager.dockerListRefreshInterval': '容器列表重新整理間隔',
|
||||
'settings.terminal.systemManager.dockerListRefreshInterval.desc': '容器與 Pod 列表的重新整理頻率。',
|
||||
'settings.terminal.systemManager.dockerListRefreshInterval': 'Docker 容器列表重新整理間隔',
|
||||
'settings.terminal.systemManager.dockerListRefreshInterval.desc': 'Docker 容器列表的重新整理頻率。',
|
||||
'settings.terminal.systemManager.dockerStatsRefreshInterval': 'Docker 效能資料重新整理間隔',
|
||||
'settings.terminal.systemManager.dockerStatsRefreshInterval.desc': 'Docker 容器 CPU/記憶體/網路指標的重新整理頻率。',
|
||||
|
||||
|
||||
@@ -1,21 +1,10 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { createAiPrivacyService } from '../../electron/shared/aiPrivacyService.cjs';
|
||||
import {
|
||||
hasReversibleModelPrivacy,
|
||||
setReversibleModelPrivacy,
|
||||
} from '../../infrastructure/ai/harness/modelSecretRedaction';
|
||||
import type { AISession } from '../../infrastructure/ai/types';
|
||||
import { localStorageAdapter } from '../../infrastructure/persistence/localStorageAdapter';
|
||||
import {
|
||||
cleanupClosedTerminalSessions,
|
||||
cleanupDeletedAIChatSessions,
|
||||
cleanupOrphanedAISessions,
|
||||
cleanupSdkAgentSessions,
|
||||
latestAIActiveSessionMapSnapshot,
|
||||
latestAISessionsSnapshot,
|
||||
setLatestAIActiveSessionMapSnapshot,
|
||||
setLatestAISessionsSnapshot,
|
||||
} from './aiStateSnapshots';
|
||||
|
||||
test('orphan cleanup keeps durable Catty output while explicit deletion removes it', async () => {
|
||||
@@ -72,92 +61,6 @@ function makeSession(id: string, updatedAt: number, messages: unknown[]): AISess
|
||||
} as never;
|
||||
}
|
||||
|
||||
test('orphan cleanup preserves resumable privacy maps until explicit chat deletion', (t) => {
|
||||
const service = createAiPrivacyService({ maxSessions: 3 });
|
||||
service.configure({ enabled: true });
|
||||
const sessions = [
|
||||
{ ...makeSession('privacy-orphan', 1, []), scope: { type: 'terminal' as const, targetId: 'closed' } },
|
||||
{ ...makeSession('privacy-live', 2, []), scope: { type: 'terminal' as const, targetId: 'open' } },
|
||||
{ ...makeSession('privacy-resumed', 3, []), scope: { type: 'terminal' as const, targetId: 'closed' } },
|
||||
];
|
||||
const aliases = sessions.map(session => {
|
||||
setReversibleModelPrivacy(session.id, true);
|
||||
return service.redact(session.id, '8.8.8.8');
|
||||
});
|
||||
const sdkCleanups: string[] = [];
|
||||
const privacyCleanups: string[] = [];
|
||||
const outputCleanups: string[] = [];
|
||||
const previousWindow = globalThis.window;
|
||||
const previousSessions = latestAISessionsSnapshot;
|
||||
const previousActiveSessionMap = latestAIActiveSessionMapSnapshot;
|
||||
t.mock.method(localStorageAdapter, 'write', () => true);
|
||||
Object.defineProperty(globalThis, 'window', {
|
||||
configurable: true,
|
||||
value: {
|
||||
dispatchEvent: () => true,
|
||||
netcatty: {
|
||||
aiSdkAgentCleanup: async (id: string) => {
|
||||
sdkCleanups.push(id);
|
||||
return { ok: true };
|
||||
},
|
||||
aiPrivacyClearSession: async (id: string) => {
|
||||
privacyCleanups.push(id);
|
||||
service.clearSession(id);
|
||||
return { ok: true };
|
||||
},
|
||||
deleteChatToolOutputsTemp: async (id: string) => {
|
||||
outputCleanups.push(id);
|
||||
return { deletedCount: 1 };
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
setLatestAISessionsSnapshot(sessions);
|
||||
setLatestAIActiveSessionMapSnapshot({
|
||||
'terminal:closed': 'privacy-orphan',
|
||||
'terminal:open': 'privacy-live',
|
||||
'terminal:resumed-into': 'privacy-resumed',
|
||||
});
|
||||
|
||||
try {
|
||||
cleanupOrphanedAISessions(new Set(['open', 'resumed-into']));
|
||||
|
||||
assert.equal(latestAISessionsSnapshot, sessions);
|
||||
assert.deepEqual(latestAIActiveSessionMapSnapshot, {
|
||||
'terminal:open': 'privacy-live',
|
||||
'terminal:resumed-into': 'privacy-resumed',
|
||||
});
|
||||
assert.deepEqual(sdkCleanups, ['privacy-orphan']);
|
||||
assert.deepEqual(privacyCleanups, []);
|
||||
assert.deepEqual(outputCleanups, []);
|
||||
for (const [index, session] of sessions.entries()) {
|
||||
assert.equal(hasReversibleModelPrivacy(session.id), true);
|
||||
assert.equal(service.redact(session.id, '8.8.8.8'), aliases[index]);
|
||||
assert.equal(service.restore(session.id, aliases[index]), '8.8.8.8');
|
||||
}
|
||||
assert.throws(() => service.checkSession('privacy-next'), /session limit reached/);
|
||||
|
||||
cleanupDeletedAIChatSessions(['privacy-orphan']);
|
||||
|
||||
assert.deepEqual(privacyCleanups, ['privacy-orphan']);
|
||||
assert.deepEqual(outputCleanups, ['privacy-orphan']);
|
||||
assert.equal(hasReversibleModelPrivacy('privacy-orphan'), false);
|
||||
assert.equal(service.hasSession('privacy-orphan'), false);
|
||||
assert.throws(() => service.checkSession('privacy-orphan'), /start a new chat/);
|
||||
assert.equal(service.checkSession('privacy-next'), true);
|
||||
for (const [index, session] of sessions.entries()) {
|
||||
if (session.id === 'privacy-orphan') continue;
|
||||
assert.equal(hasReversibleModelPrivacy(session.id), true);
|
||||
assert.equal(service.restore(session.id, aliases[index]), '8.8.8.8');
|
||||
}
|
||||
} finally {
|
||||
for (const session of sessions) setReversibleModelPrivacy(session.id, false);
|
||||
setLatestAISessionsSnapshot(previousSessions ?? []);
|
||||
setLatestAIActiveSessionMapSnapshot(previousActiveSessionMap ?? {});
|
||||
Object.defineProperty(globalThis, 'window', { configurable: true, value: previousWindow });
|
||||
}
|
||||
});
|
||||
|
||||
test('serializeSessionsForStorage strips oldest ciphertext before dropping visible sessions', async () => {
|
||||
const { serializeSessionsForStorage } = await import('./aiStateSnapshots');
|
||||
const ciphertext = 'gAAAA'.repeat(50000); // ~250 KB of ciphertext per message
|
||||
@@ -717,103 +620,3 @@ test('writeSessionsForStorage makes a final attempt with only the newest session
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
function freezeHistory<T>(value: T): T {
|
||||
if (value && typeof value === 'object') {
|
||||
Object.values(value).forEach(freezeHistory);
|
||||
Object.freeze(value);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function makePersistenceHistory(count: number, activeMessages = 6): AISession[] {
|
||||
return Array.from({ length: count }, (_, index) => makeSession(
|
||||
`session-${index}`,
|
||||
count - index,
|
||||
Array.from({ length: index === 0 ? activeMessages : 6 }, (_, messageIndex) => ({
|
||||
id: `message-${index}-${messageIndex}`,
|
||||
role: messageIndex % 2 ? 'assistant' : 'user',
|
||||
content: 'ordinary text '.repeat(8),
|
||||
timestamp: messageIndex,
|
||||
...(messageIndex % 2 ? {
|
||||
toolResults: [{ toolCallId: `tool-${messageIndex}`, content: 'sample output '.repeat(700) }],
|
||||
providerContinuation: {
|
||||
reasoningParts: [{
|
||||
text: 'reasoning',
|
||||
providerOptions: { openai: { reasoningEncryptedContent: 'x'.repeat(256) } },
|
||||
}],
|
||||
},
|
||||
} : {}),
|
||||
})),
|
||||
));
|
||||
}
|
||||
|
||||
for (const [count, activeMessages] of [[20, 6], [200, 6], [20, 200]]) {
|
||||
test(`fitting history serializes only retained full sessions (${count} chats, ${activeMessages} active messages)`, async (t) => {
|
||||
const { serializeSessionsForStorage } = await import('./aiStateSnapshots');
|
||||
const sessions = freezeHistory(makePersistenceHistory(count, activeMessages));
|
||||
const expected = sessions.slice(0, 50);
|
||||
const expectedJson = JSON.stringify(expected);
|
||||
const stringify = t.mock.method(JSON, 'stringify');
|
||||
|
||||
const result = serializeSessionsForStorage(sessions);
|
||||
|
||||
assert.equal(stringify.mock.callCount(), expected.length);
|
||||
assert.equal(result.json, expectedJson);
|
||||
assert.deepEqual(result.sessions, expected);
|
||||
assert.equal(result.sessions[0], sessions[0]);
|
||||
});
|
||||
}
|
||||
|
||||
test('fit-first budget keeps string-length units and exact-fit replay data without mutating input', async () => {
|
||||
const { serializeSessionsForStorage } = await import('./aiStateSnapshots');
|
||||
const sessions = makePersistenceHistory(1);
|
||||
sessions[0].messages[0].content = '\u4e2d\u6587\ud83d\ude00'.repeat(100);
|
||||
freezeHistory(sessions);
|
||||
const fullJson = JSON.stringify(sessions);
|
||||
assert.ok(Buffer.byteLength(fullJson) > fullJson.length);
|
||||
for (const budget of [fullJson.length, fullJson.length + 1]) {
|
||||
assert.equal(serializeSessionsForStorage(sessions, budget).json, fullJson);
|
||||
}
|
||||
const overflow = serializeSessionsForStorage(sessions, fullJson.length - 1);
|
||||
assert.ok(overflow.json.length < fullJson.length);
|
||||
assert.equal(JSON.stringify(sessions), fullJson);
|
||||
});
|
||||
|
||||
test('fit-first empty history preserves output even below the minimum array budget', async () => {
|
||||
const { serializeSessionsForStorage } = await import('./aiStateSnapshots');
|
||||
for (const budget of [0, 1, 2, 3]) {
|
||||
assert.deepEqual(serializeSessionsForStorage([], budget), { json: '[]', sessions: [] });
|
||||
}
|
||||
});
|
||||
|
||||
test('synced storage bypasses fit-first pruning and non-quota failures still propagate', async (t) => {
|
||||
const { writeSessionsForStorage } = await import('./aiStateSnapshots');
|
||||
const sessions = freezeHistory(makePersistenceHistory(51, 201));
|
||||
const read = t.mock.method(localStorageAdapter, 'read', () => ({ enabled: true }));
|
||||
const write = t.mock.method(localStorageAdapter, 'write', () => false);
|
||||
const writeString = t.mock.method(localStorageAdapter, 'writeString', () => true);
|
||||
assert.equal(writeSessionsForStorage(sessions), false);
|
||||
assert.equal(write.mock.callCount(), 1);
|
||||
assert.equal(write.mock.calls[0].arguments[1], sessions);
|
||||
assert.equal(writeString.mock.callCount(), 0);
|
||||
|
||||
read.mock.mockImplementation(() => ({ enabled: false }));
|
||||
const error = new Error('non-quota storage failure');
|
||||
writeString.mock.mockImplementation(() => { throw error; });
|
||||
assert.throws(() => writeSessionsForStorage(sessions), candidate => candidate === error);
|
||||
assert.equal(writeString.mock.callCount(), 1);
|
||||
});
|
||||
|
||||
for (const [budget, expectedCalls] of [[128 * 1024, 160], [0, 161]]) {
|
||||
test(`overflow reuses prepared full JSON at budget ${budget}`, async (t) => {
|
||||
const { serializeSessionsForStorage } = await import('./aiStateSnapshots');
|
||||
const sessions = freezeHistory(makePersistenceHistory(20));
|
||||
const stringify = t.mock.method(JSON, 'stringify');
|
||||
const result = serializeSessionsForStorage(sessions, budget);
|
||||
// Same work as the original ladder: no second full-session serialization
|
||||
// after the fit check, even when a last-resort degraded chat is needed.
|
||||
assert.equal(stringify.mock.callCount(), expectedCalls);
|
||||
assert.equal(result.sessions.length, budget === 0 ? 1 : 4);
|
||||
});
|
||||
}
|
||||
@@ -363,30 +363,12 @@ export function serializeSessionsForStorage(
|
||||
sessions: AISession[],
|
||||
budgetBytes: number = MAX_SESSIONS_JSON_BYTES,
|
||||
): { json: string; sessions: AISession[] } {
|
||||
const prepared = pruneSessionsForStorage(sessions).map(rawSession => {
|
||||
const serialized = pruneSessionsForStorage(sessions).map(rawSession => {
|
||||
// These messages are replaced by the durable summary before every later
|
||||
// request, so their replay ciphertext can never be used again. Remove it
|
||||
// before deciding whether the newest session deserves full protection;
|
||||
// otherwise dead metadata can evict other visible conversations.
|
||||
const session = stripCompactedEncryptedReasoningFromSession(rawSession);
|
||||
return { session, json: JSON.stringify(session) };
|
||||
});
|
||||
|
||||
// Check whether retained history fits before preparing degradation. Use
|
||||
// the existing JSON string-length budget including array delimiters, and
|
||||
// avoid preparing unused stripped messages and serializing payloads again.
|
||||
const fullLength = 2 + prepared.reduce((total, entry) => total + entry.json.length, 0)
|
||||
+ Math.max(0, prepared.length - 1);
|
||||
if (fullLength <= budgetBytes) {
|
||||
return {
|
||||
json: `[${prepared.map(entry => entry.json).join(',')}]`,
|
||||
sessions: prepared.map(entry => entry.session),
|
||||
};
|
||||
}
|
||||
|
||||
// Reuse the full representation when the unchanged degradation ladder is
|
||||
// needed, including on quota retries with a smaller budget.
|
||||
const serialized = prepared.map(({ session, json }) => {
|
||||
const degradableMessages = session.messages.flatMap((message, index) => {
|
||||
const degradedMessage = stripEncryptedReasoningFromMessage(message);
|
||||
if (degradedMessage === message) return [];
|
||||
@@ -397,6 +379,7 @@ export function serializeSessionsForStorage(
|
||||
}];
|
||||
});
|
||||
const strippedSession = stripEncryptedReasoningFromSession(session);
|
||||
const json = JSON.stringify(session);
|
||||
return {
|
||||
session,
|
||||
json,
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -1,39 +0,0 @@
|
||||
import type { ContainerRuntimeId, ContainerSourceId } from '../../../domain/systemManager/containerRuntime';
|
||||
import { isContainerSource } from '../../../domain/systemManager/containerRuntime';
|
||||
import { STORAGE_KEY_CONTAINER_RUNTIME } from '../../../infrastructure/config/storageKeys';
|
||||
import { localStorageAdapter } from '../../../infrastructure/persistence/localStorageAdapter';
|
||||
|
||||
export interface ContainerRuntimePreference {
|
||||
source: ContainerSourceId;
|
||||
engine?: ContainerRuntimeId;
|
||||
context?: string;
|
||||
namespace?: string;
|
||||
}
|
||||
|
||||
type PreferenceStore = Record<string, ContainerRuntimePreference>;
|
||||
|
||||
const CONTAINER_ENGINES = new Set<ContainerRuntimeId>(['docker', 'podman', 'nerdctl']);
|
||||
|
||||
function readStore(): PreferenceStore {
|
||||
const value = localStorageAdapter.read<PreferenceStore>(STORAGE_KEY_CONTAINER_RUNTIME);
|
||||
return value && typeof value === 'object' ? value : {};
|
||||
}
|
||||
|
||||
export function readContainerRuntimePreference(hostId: string): ContainerRuntimePreference | null {
|
||||
if (!hostId) return null;
|
||||
const entry = readStore()[hostId];
|
||||
if (!entry || !isContainerSource(entry.source)) return null;
|
||||
return {
|
||||
source: entry.source,
|
||||
engine: entry.engine && CONTAINER_ENGINES.has(entry.engine) ? entry.engine : undefined,
|
||||
context: typeof entry.context === 'string' ? entry.context : undefined,
|
||||
namespace: typeof entry.namespace === 'string' ? entry.namespace : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
export function writeContainerRuntimePreference(hostId: string, entry: ContainerRuntimePreference): void {
|
||||
if (!hostId || !isContainerSource(entry.source)) return;
|
||||
const store = readStore();
|
||||
store[hostId] = entry;
|
||||
localStorageAdapter.write(STORAGE_KEY_CONTAINER_RUNTIME, store);
|
||||
}
|
||||
@@ -1,225 +0,0 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import type { useSystemManagerBackend } from '../useSystemManagerBackend';
|
||||
import { useStableTranslate } from '../useSystemManager';
|
||||
import type {
|
||||
ContainerRuntimeId,
|
||||
ContainerSourceId,
|
||||
KubeContextInfo,
|
||||
} from '../../../domain/systemManager/containerRuntime';
|
||||
import {
|
||||
describeRuntimeFailure,
|
||||
firstContainerRuntime,
|
||||
isAutomaticRuntimeFallback,
|
||||
isKubectlForbiddenError,
|
||||
reconcileContainerSource,
|
||||
sourcesFromCapabilities,
|
||||
} from '../../../domain/systemManager/containerRuntime';
|
||||
import type { SessionCapabilities } from '../../../domain/systemManager/types';
|
||||
import {
|
||||
readContainerRuntimePreference,
|
||||
writeContainerRuntimePreference,
|
||||
} from './containerRuntimePreference';
|
||||
|
||||
type Backend = ReturnType<typeof useSystemManagerBackend>;
|
||||
|
||||
interface UseContainerRuntimeSelectionOptions {
|
||||
sessionId: string;
|
||||
hostKey: string;
|
||||
capabilities?: SessionCapabilities;
|
||||
backend: Backend;
|
||||
}
|
||||
|
||||
export function useContainerRuntimeSelection({
|
||||
sessionId,
|
||||
hostKey,
|
||||
capabilities,
|
||||
backend,
|
||||
}: UseContainerRuntimeSelectionOptions) {
|
||||
const stableT = useStableTranslate();
|
||||
const detected = sourcesFromCapabilities(capabilities);
|
||||
const detectedKey = detected.join('|');
|
||||
const [source, setSource] = useState<ContainerSourceId | null>(() => {
|
||||
const remembered = readContainerRuntimePreference(hostKey);
|
||||
return reconcileContainerSource(detected, null, remembered);
|
||||
});
|
||||
const [contextName, setContextName] = useState<string | null>(() => (
|
||||
readContainerRuntimePreference(hostKey)?.context ?? null
|
||||
));
|
||||
const [namespaceName, setNamespaceName] = useState<string | null>(() => (
|
||||
readContainerRuntimePreference(hostKey)?.namespace ?? null
|
||||
));
|
||||
const [contexts, setContexts] = useState<KubeContextInfo[]>([]);
|
||||
const [namespaces, setNamespaces] = useState<string[]>([]);
|
||||
const [scopeError, setScopeError] = useState<string | null>(null);
|
||||
const [scopeNonce, setScopeNonce] = useState(0);
|
||||
const skipPersist = useRef(true);
|
||||
const namespaceNameRef = useRef(namespaceName);
|
||||
namespaceNameRef.current = namespaceName;
|
||||
|
||||
useEffect(() => {
|
||||
skipPersist.current = true;
|
||||
const remembered = readContainerRuntimePreference(hostKey);
|
||||
setSource(reconcileContainerSource(detected, null, remembered));
|
||||
setContextName(remembered?.context ?? null);
|
||||
setNamespaceName(remembered?.namespace ?? null);
|
||||
setContexts([]);
|
||||
setNamespaces([]);
|
||||
setScopeError(null);
|
||||
// Detection is reconciled by the next effect so a host switch does not
|
||||
// keep the previous host's in-memory selection.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [hostKey]);
|
||||
|
||||
useEffect(() => {
|
||||
const available = sourcesFromCapabilities(capabilities);
|
||||
setSource((current) => reconcileContainerSource(
|
||||
available,
|
||||
current,
|
||||
readContainerRuntimePreference(hostKey),
|
||||
));
|
||||
}, [capabilities, detectedKey, hostKey]);
|
||||
|
||||
useEffect(() => {
|
||||
if (skipPersist.current) {
|
||||
skipPersist.current = false;
|
||||
return;
|
||||
}
|
||||
if (!source) return;
|
||||
const remembered = readContainerRuntimePreference(hostKey);
|
||||
const available = sourcesFromCapabilities(capabilities);
|
||||
if (isAutomaticRuntimeFallback(remembered, source, available)) return;
|
||||
const engine = source === 'kubernetes'
|
||||
? (remembered?.engine ?? firstContainerRuntime(available) ?? undefined)
|
||||
: source;
|
||||
writeContainerRuntimePreference(hostKey, {
|
||||
source,
|
||||
engine,
|
||||
context: contextName || undefined,
|
||||
namespace: namespaceName || undefined,
|
||||
});
|
||||
}, [capabilities, contextName, hostKey, namespaceName, source]);
|
||||
|
||||
useEffect(() => {
|
||||
if (source !== 'kubernetes') return undefined;
|
||||
let cancelled = false;
|
||||
void backend.kubectl({ sessionId, op: 'contexts' }).then((result) => {
|
||||
if (cancelled) return;
|
||||
if (!result.success || !result.contexts) {
|
||||
setContexts([]);
|
||||
setScopeError(describeRuntimeFailure(
|
||||
result,
|
||||
stableT,
|
||||
stableT('systemManager.errors.loadKubernetes'),
|
||||
contextName,
|
||||
));
|
||||
return;
|
||||
}
|
||||
setScopeError(null);
|
||||
const available = result.contexts;
|
||||
const kept = contextName && available.some((item) => item.name === contextName)
|
||||
? contextName
|
||||
: null;
|
||||
const next = kept
|
||||
?? available.find((item) => item.current)?.name
|
||||
?? available[0]?.name
|
||||
?? null;
|
||||
setContexts(available);
|
||||
setContextName(next);
|
||||
if (!kept) {
|
||||
const configured = available.find((item) => item.name === next)?.namespace || 'default';
|
||||
setNamespaceName(next ? configured : null);
|
||||
setNamespaces([]);
|
||||
}
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [backend, contextName, sessionId, source, scopeNonce, stableT]);
|
||||
|
||||
useEffect(() => {
|
||||
if (source !== 'kubernetes' || !contextName) return undefined;
|
||||
let cancelled = false;
|
||||
void backend.kubectl({
|
||||
sessionId,
|
||||
op: 'namespaces',
|
||||
context: contextName,
|
||||
}).then((result) => {
|
||||
if (cancelled) return;
|
||||
if (!result.success || !result.namespaces) {
|
||||
const message = result.error || '';
|
||||
if (isKubectlForbiddenError(message)) {
|
||||
const configured = contexts.find((item) => item.name === contextName)?.namespace || 'default';
|
||||
const fallback = namespaceNameRef.current || configured;
|
||||
setScopeError(null);
|
||||
setNamespaceName((current) => current || fallback);
|
||||
setNamespaces([fallback]);
|
||||
return;
|
||||
}
|
||||
setScopeError(describeRuntimeFailure(
|
||||
result,
|
||||
stableT,
|
||||
stableT('systemManager.errors.loadKubernetes'),
|
||||
contextName,
|
||||
));
|
||||
return;
|
||||
}
|
||||
setScopeError(null);
|
||||
setNamespaces(result.namespaces);
|
||||
setNamespaceName((current) => {
|
||||
if (current && result.namespaces?.includes(current)) return current;
|
||||
const configured = contexts.find((item) => item.name === contextName)?.namespace;
|
||||
if (configured && result.namespaces?.includes(configured)) return configured;
|
||||
if (result.namespaces?.includes('default')) return 'default';
|
||||
return result.namespaces?.[0] ?? null;
|
||||
});
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [backend, contextName, contexts, sessionId, source, scopeNonce, stableT]);
|
||||
|
||||
const selectSource = useCallback((value: string) => {
|
||||
if (value !== 'docker' && value !== 'podman' && value !== 'nerdctl' && value !== 'kubernetes') return;
|
||||
setSource(value);
|
||||
const remembered = readContainerRuntimePreference(hostKey);
|
||||
const available = sourcesFromCapabilities(capabilities);
|
||||
const engine = value === 'kubernetes'
|
||||
? (remembered?.engine ?? firstContainerRuntime(available) ?? undefined)
|
||||
: value;
|
||||
writeContainerRuntimePreference(hostKey, {
|
||||
source: value,
|
||||
engine,
|
||||
context: contextName || undefined,
|
||||
namespace: namespaceName || undefined,
|
||||
});
|
||||
}, [capabilities, contextName, hostKey, namespaceName]);
|
||||
|
||||
const selectContext = useCallback((value: string) => {
|
||||
setContextName(value);
|
||||
const configured = contexts.find((item) => item.name === value)?.namespace ?? null;
|
||||
setNamespaceName(configured);
|
||||
}, [contexts]);
|
||||
|
||||
const reloadScopes = useCallback(() => {
|
||||
setScopeNonce((value) => value + 1);
|
||||
}, []);
|
||||
|
||||
const runtime: ContainerRuntimeId = source && source !== 'kubernetes' ? source : 'docker';
|
||||
const showRuntimeSwitcher = detected.length > 1;
|
||||
|
||||
return {
|
||||
source,
|
||||
selectSource,
|
||||
contextName,
|
||||
selectContext,
|
||||
namespaceName,
|
||||
setNamespaceName,
|
||||
contexts,
|
||||
namespaces,
|
||||
scopeError,
|
||||
reloadScopes,
|
||||
runtime,
|
||||
detected,
|
||||
showRuntimeSwitcher,
|
||||
};
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { describe, it } from 'node:test';
|
||||
import { createSessionIdleManager } from '../../electron/bridges/mcpServerBridge/sessionIdleManager.cjs';
|
||||
import { STORAGE_KEY_AI_SESSION_IDLE_TIMEOUT_MINUTES, STORAGE_KEY_AI_PRIVACY, STORAGE_KEY_AI_EXTERNAL_MCP_REDACTION, STORAGE_KEY_AI_EXTERNAL_MCP_ENABLED, STORAGE_KEY_AI_EXTERNAL_MCP_MODE } from '../../infrastructure/config/storageKeys';
|
||||
import { STORAGE_KEY_AI_SESSION_IDLE_TIMEOUT_MINUTES } from '../../infrastructure/config/storageKeys';
|
||||
import { localStorageAdapter } from '../../infrastructure/persistence/localStorageAdapter';
|
||||
import {
|
||||
readExternalMcpHttpAllowCustomBind,
|
||||
@@ -16,10 +16,6 @@ import {
|
||||
} from "./useExternalMcpToggleState.ts";
|
||||
import {
|
||||
EXTERNAL_MCP_RUNTIME_STATUS_POLL_MS,
|
||||
applyAIPrivacySettings,
|
||||
readAIPrivacySettings,
|
||||
applyExternalMcpRedactionSettings,
|
||||
readExternalMcpRedactionSettings,
|
||||
bumpExternalMcpEnableGenerationForTests,
|
||||
createExternalMcpStartupSyncPlan,
|
||||
getExternalMcpEnableGenerationForTests,
|
||||
@@ -120,12 +116,11 @@ describe('useExternalMcpToggleState helpers', () => {
|
||||
externalMcpSetConfig: (config: { sessionIdleTimeoutMinutes: number }) => {
|
||||
received.push(config.sessionIdleTimeoutMinutes);
|
||||
manager.setTimeoutMinutes(config.sessionIdleTimeoutMinutes);
|
||||
return { ok: true };
|
||||
},
|
||||
};
|
||||
await syncExternalMcpStartupState(bridge);
|
||||
assert.equal(manager.getTimeoutMinutes(), expected, 'startup keeps the intended timeout');
|
||||
await syncExternalMcpConfig(bridge);
|
||||
syncExternalMcpConfig(bridge);
|
||||
assert.deepEqual(received, [expected, expected]);
|
||||
assert.equal(manager.getTimeoutMinutes(), expected, 'config sync keeps the intended timeout');
|
||||
assert.equal(
|
||||
@@ -457,334 +452,3 @@ describe("external MCP HTTP settings", () => {
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
describe('Shared AI privacy settings', () => {
|
||||
const settings = {
|
||||
enabled: true,
|
||||
redactPrivateIps: true,
|
||||
cidrs: ['192.0.2.0/24'],
|
||||
patterns: ['INTERNAL_[A-Z0-9]+'],
|
||||
};
|
||||
|
||||
it('defaults off and persists only after successful bridge validation', async () => {
|
||||
const restore = installMemoryLocalStorage();
|
||||
try {
|
||||
assert.deepEqual(readExternalMcpRedactionSettings(), {
|
||||
enabled: false, redactPrivateIps: false, cidrs: [], patterns: [],
|
||||
});
|
||||
let resolveConfig: (value: unknown) => void;
|
||||
const apply = applyExternalMcpRedactionSettings(settings, {
|
||||
externalMcpSetConfig: async (config) => {
|
||||
assert.deepEqual(config, { redaction: settings });
|
||||
return new Promise((resolve) => { resolveConfig = resolve; });
|
||||
},
|
||||
});
|
||||
assert.equal(localStorageAdapter.readString(STORAGE_KEY_AI_PRIVACY), null);
|
||||
resolveConfig!({ ok: true });
|
||||
await apply;
|
||||
assert.deepEqual(readExternalMcpRedactionSettings(), settings);
|
||||
assert.deepEqual(localStorageAdapter.read(STORAGE_KEY_AI_PRIVACY), settings);
|
||||
assert.equal(localStorageAdapter.readString(STORAGE_KEY_AI_EXTERNAL_MCP_REDACTION), null);
|
||||
} finally {
|
||||
restore();
|
||||
}
|
||||
});
|
||||
|
||||
it('reads legacy privacy preferences until a validated shared preference is applied', async () => {
|
||||
const restore = installMemoryLocalStorage();
|
||||
try {
|
||||
localStorageAdapter.write(STORAGE_KEY_AI_EXTERNAL_MCP_REDACTION, settings);
|
||||
assert.deepEqual(readAIPrivacySettings(), settings);
|
||||
assert.equal(localStorageAdapter.readString(STORAGE_KEY_AI_PRIVACY), null, 'reads must not migrate or discard data');
|
||||
const changed = { ...settings, redactPrivateIps: false };
|
||||
await applyAIPrivacySettings(changed, { externalMcpSetConfig: async () => ({ ok: true }) });
|
||||
assert.deepEqual(readAIPrivacySettings(), changed);
|
||||
assert.deepEqual(readExternalMcpRedactionSettings(), changed, 'legacy API must read the same shared policy');
|
||||
assert.deepEqual(localStorageAdapter.read(STORAGE_KEY_AI_PRIVACY), changed);
|
||||
assert.deepEqual(localStorageAdapter.read(STORAGE_KEY_AI_EXTERNAL_MCP_REDACTION), settings, 'keep legacy storage intact');
|
||||
} finally {
|
||||
restore();
|
||||
}
|
||||
});
|
||||
|
||||
it('never falls back to legacy settings when a shared preference exists, even if corrupt', () => {
|
||||
const restore = installMemoryLocalStorage();
|
||||
try {
|
||||
localStorageAdapter.write(STORAGE_KEY_AI_EXTERNAL_MCP_REDACTION, settings);
|
||||
const disabled = { enabled: false, redactPrivateIps: false, cidrs: [], patterns: [] };
|
||||
localStorageAdapter.write(STORAGE_KEY_AI_PRIVACY, disabled);
|
||||
assert.deepEqual(readAIPrivacySettings(), disabled);
|
||||
localStorageAdapter.writeString(STORAGE_KEY_AI_PRIVACY, '{');
|
||||
assert.throws(readAIPrivacySettings, /Invalid saved AI privacy settings/);
|
||||
localStorageAdapter.remove(STORAGE_KEY_AI_PRIVACY);
|
||||
localStorageAdapter.writeString(STORAGE_KEY_AI_EXTERNAL_MCP_REDACTION, '{');
|
||||
assert.throws(readAIPrivacySettings, /Invalid saved AI privacy settings/);
|
||||
} finally {
|
||||
restore();
|
||||
}
|
||||
});
|
||||
|
||||
it('restores the shared policy even when External MCP is disabled', async () => {
|
||||
const restore = installMemoryLocalStorage();
|
||||
try {
|
||||
resetExternalMcpStartupReadyForTests();
|
||||
localStorageAdapter.writeBoolean(STORAGE_KEY_AI_EXTERNAL_MCP_ENABLED, false);
|
||||
localStorageAdapter.write(STORAGE_KEY_AI_PRIVACY, settings);
|
||||
const calls: string[] = [];
|
||||
const plan = await syncExternalMcpStartupState({
|
||||
externalMcpSetConfig: async (config) => {
|
||||
assert.deepEqual(config.redaction, settings);
|
||||
calls.push('shared-policy');
|
||||
return { ok: true };
|
||||
},
|
||||
externalMcpSetEnabled: async (enabled) => { calls.push(`external:${enabled}`); },
|
||||
});
|
||||
assert.deepEqual(calls, ['shared-policy', 'external:false']);
|
||||
assert.equal(plan.runtimeEnabled, false);
|
||||
assert.deepEqual(readAIPrivacySettings(), settings);
|
||||
} finally {
|
||||
restore();
|
||||
}
|
||||
});
|
||||
|
||||
it('does not persist rejected, unverified or failed privacy changes', async () => {
|
||||
const restore = installMemoryLocalStorage();
|
||||
try {
|
||||
localStorageAdapter.write(STORAGE_KEY_AI_PRIVACY, settings);
|
||||
const changed = { ...settings, patterns: ['['] };
|
||||
for (const result of [{ ok: false, error: 'Invalid regular expression' }, { ok: false, error: '' }, undefined, {}]) {
|
||||
await assert.rejects(applyExternalMcpRedactionSettings(changed, {
|
||||
externalMcpSetConfig: async () => result,
|
||||
}));
|
||||
assert.deepEqual(readExternalMcpRedactionSettings(), settings);
|
||||
}
|
||||
await assert.rejects(applyExternalMcpRedactionSettings(changed, {
|
||||
externalMcpSetConfig: async () => { throw new Error('Disconnected'); },
|
||||
}), /Disconnected/);
|
||||
assert.deepEqual(readExternalMcpRedactionSettings(), settings);
|
||||
} finally {
|
||||
restore();
|
||||
}
|
||||
});
|
||||
|
||||
it('syncs saved privacy settings before persistent startup enable', async () => {
|
||||
const restore = installMemoryLocalStorage();
|
||||
try {
|
||||
resetExternalMcpStartupReadyForTests();
|
||||
localStorageAdapter.writeBoolean(STORAGE_KEY_AI_EXTERNAL_MCP_ENABLED, true);
|
||||
localStorageAdapter.writeString(STORAGE_KEY_AI_EXTERNAL_MCP_MODE, 'persistent');
|
||||
localStorageAdapter.write(STORAGE_KEY_AI_PRIVACY, settings);
|
||||
const calls: string[] = [];
|
||||
await syncExternalMcpStartupState({
|
||||
externalMcpSetConfig: async (config) => {
|
||||
assert.deepEqual(config.redaction, settings);
|
||||
calls.push('config');
|
||||
return { ok: true };
|
||||
},
|
||||
externalMcpSetEnabled: async (enabled) => { calls.push(`enabled:${enabled}`); },
|
||||
});
|
||||
assert.deepEqual(calls, ['config', 'enabled:true']);
|
||||
} finally {
|
||||
restore();
|
||||
}
|
||||
});
|
||||
|
||||
it('fails closed at startup when saved privacy settings are malformed', async () => {
|
||||
const restore = installMemoryLocalStorage();
|
||||
try {
|
||||
for (const saved of ['{', 'null', '{}', '{"enabled":"true"}', '{"enabled":false,"redactPrivateIps":false,"cidrs":[],"patterns":[2]}', '{"enabled":false,"redactPrivateIps":false,"cidrs":[],"patterns":[],"unexpected":true}']) {
|
||||
resetExternalMcpStartupReadyForTests();
|
||||
localStorageAdapter.writeBoolean(STORAGE_KEY_AI_EXTERNAL_MCP_ENABLED, true);
|
||||
localStorageAdapter.writeString(STORAGE_KEY_AI_EXTERNAL_MCP_MODE, 'persistent');
|
||||
localStorageAdapter.writeString(STORAGE_KEY_AI_PRIVACY, saved);
|
||||
const enabledCalls: boolean[] = [];
|
||||
let configCalls = 0;
|
||||
const bridge = {
|
||||
externalMcpSetConfig: async (config: unknown) => {
|
||||
assert.deepEqual(config, { redaction: null });
|
||||
configCalls += 1;
|
||||
return { ok: false, error: 'Shared AI privacy configuration is unavailable.' };
|
||||
},
|
||||
externalMcpSetEnabled: async (enabled: boolean) => { enabledCalls.push(enabled); },
|
||||
};
|
||||
const plan = await syncExternalMcpStartupState(bridge);
|
||||
assert.equal(plan.runtimeEnabled, false);
|
||||
assert.match(plan.configError!, /Invalid saved/);
|
||||
assert.equal(configCalls, 1, 'invalidate shared privacy instead of applying unprotected defaults');
|
||||
assert.deepEqual(enabledCalls, [false]);
|
||||
assert.equal(readExternalMcpStoredEnabled(), false);
|
||||
await assert.rejects(syncExternalMcpConfig(bridge), /Invalid saved/);
|
||||
assert.equal(configCalls, 2);
|
||||
}
|
||||
} finally {
|
||||
restore();
|
||||
}
|
||||
});
|
||||
|
||||
it('fails closed when the backend rejects or cannot verify startup config', async () => {
|
||||
const restore = installMemoryLocalStorage();
|
||||
try {
|
||||
for (const result of [{ ok: false, error: 'Invalid CIDR' }, { ok: false, error: '' }, undefined, {}]) {
|
||||
resetExternalMcpStartupReadyForTests();
|
||||
localStorageAdapter.writeBoolean(STORAGE_KEY_AI_EXTERNAL_MCP_ENABLED, true);
|
||||
localStorageAdapter.writeString(STORAGE_KEY_AI_EXTERNAL_MCP_MODE, 'persistent');
|
||||
localStorageAdapter.write(STORAGE_KEY_AI_PRIVACY, settings);
|
||||
const enabledCalls: boolean[] = [];
|
||||
const plan = await syncExternalMcpStartupState({
|
||||
externalMcpSetConfig: async () => result,
|
||||
externalMcpSetEnabled: async (enabled) => { enabledCalls.push(enabled); },
|
||||
});
|
||||
assert.equal(plan.runtimeEnabled, false);
|
||||
assert.ok(plan.configError);
|
||||
assert.deepEqual(enabledCalls, [false]);
|
||||
assert.deepEqual(readExternalMcpRedactionSettings(), settings);
|
||||
}
|
||||
} finally {
|
||||
restore();
|
||||
}
|
||||
});
|
||||
|
||||
it('preserves the corrupt-settings error if shared policy invalidation cannot reach the bridge', async () => {
|
||||
const restore = installMemoryLocalStorage();
|
||||
try {
|
||||
resetExternalMcpStartupReadyForTests();
|
||||
localStorageAdapter.writeString(STORAGE_KEY_AI_PRIVACY, '{');
|
||||
const calls: boolean[] = [];
|
||||
const bridge = {
|
||||
externalMcpSetConfig: async (config: unknown) => {
|
||||
assert.deepEqual(config, { redaction: null });
|
||||
throw new Error('Disconnected');
|
||||
},
|
||||
externalMcpSetEnabled: async (enabled: boolean) => { calls.push(enabled); },
|
||||
};
|
||||
const plan = await syncExternalMcpStartupState(bridge);
|
||||
assert.match(plan.configError!, /Invalid saved AI privacy settings/);
|
||||
assert.deepEqual(calls, [false]);
|
||||
await assert.rejects(syncExternalMcpConfig(bridge), /Invalid saved AI privacy settings/);
|
||||
assert.equal(localStorageAdapter.readString(STORAGE_KEY_AI_PRIVACY), '{');
|
||||
} finally {
|
||||
restore();
|
||||
}
|
||||
});
|
||||
|
||||
it('does not enable from a stale privacy snapshot changed during config sync', async () => {
|
||||
const restore = installMemoryLocalStorage();
|
||||
try {
|
||||
resetExternalMcpStartupReadyForTests();
|
||||
localStorageAdapter.writeBoolean(STORAGE_KEY_AI_EXTERNAL_MCP_ENABLED, true);
|
||||
localStorageAdapter.writeString(STORAGE_KEY_AI_EXTERNAL_MCP_MODE, 'persistent');
|
||||
const calls: boolean[] = [];
|
||||
const plan = await syncExternalMcpStartupState({
|
||||
externalMcpSetConfig: async () => {
|
||||
localStorageAdapter.write(STORAGE_KEY_AI_PRIVACY, settings);
|
||||
return { ok: true };
|
||||
},
|
||||
externalMcpSetEnabled: async (enabled) => { calls.push(enabled); },
|
||||
});
|
||||
assert.deepEqual(calls, [false]);
|
||||
assert.match(plan.configError!, /changed during startup/);
|
||||
await assert.rejects(syncExternalMcpConfig({
|
||||
externalMcpSetConfig: async () => {
|
||||
localStorageAdapter.write(STORAGE_KEY_AI_PRIVACY, { ...settings, cidrs: [] });
|
||||
return { ok: true };
|
||||
},
|
||||
}), /settings changed/);
|
||||
} finally {
|
||||
restore();
|
||||
}
|
||||
});
|
||||
|
||||
it('fails closed on a startup config transport failure', async () => {
|
||||
const restore = installMemoryLocalStorage();
|
||||
try {
|
||||
resetExternalMcpStartupReadyForTests();
|
||||
localStorageAdapter.writeBoolean(STORAGE_KEY_AI_EXTERNAL_MCP_ENABLED, true);
|
||||
localStorageAdapter.writeString(STORAGE_KEY_AI_EXTERNAL_MCP_MODE, 'persistent');
|
||||
const calls: boolean[] = [];
|
||||
const plan = await syncExternalMcpStartupState({
|
||||
externalMcpSetConfig: async () => { throw new Error('Disconnected'); },
|
||||
externalMcpSetEnabled: async (enabled) => { calls.push(enabled); },
|
||||
});
|
||||
assert.deepEqual(calls, [false]);
|
||||
assert.equal(plan.configError, 'Disconnected');
|
||||
} finally {
|
||||
restore();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('External MCP manual enable privacy gate', () => {
|
||||
it('waits for validated config and keeps rejected or superseded enables off', async () => {
|
||||
const React = await import('react');
|
||||
const { act, create } = await import('react-test-renderer');
|
||||
const { useExternalMcpToggleState } = await import('./useExternalMcpToggleState.ts');
|
||||
const restoreStorage = installMemoryLocalStorage();
|
||||
const previousWindow = Object.getOwnPropertyDescriptor(globalThis, 'window');
|
||||
const previousAct = Object.getOwnPropertyDescriptor(globalThis, 'IS_REACT_ACT_ENVIRONMENT');
|
||||
const events = new EventTarget();
|
||||
let resolveConfig!: (value: unknown) => void;
|
||||
const calls: string[] = [];
|
||||
const bridge = {
|
||||
externalMcpSetConfig: async () => {
|
||||
calls.push('config');
|
||||
return new Promise((resolve) => { resolveConfig = resolve; });
|
||||
},
|
||||
externalMcpSetEnabled: async (enabled: boolean) => {
|
||||
calls.push(`enabled:${enabled}`);
|
||||
return { ok: true, enabled };
|
||||
},
|
||||
};
|
||||
Object.defineProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT', { value: true, configurable: true });
|
||||
Object.defineProperty(globalThis, 'window', {
|
||||
value: {
|
||||
netcatty: bridge,
|
||||
location: { hash: '#/settings' },
|
||||
addEventListener: events.addEventListener.bind(events),
|
||||
removeEventListener: events.removeEventListener.bind(events),
|
||||
dispatchEvent: events.dispatchEvent.bind(events),
|
||||
setInterval,
|
||||
clearInterval,
|
||||
},
|
||||
configurable: true,
|
||||
});
|
||||
let hook!: ReturnType<typeof useExternalMcpToggleState>;
|
||||
const Capture = () => { hook = useExternalMcpToggleState(); return null; };
|
||||
let renderer: ReturnType<typeof create> | undefined;
|
||||
try {
|
||||
resetExternalMcpStartupReadyForTests();
|
||||
await act(async () => { renderer = create(React.createElement(Capture)); });
|
||||
let pending!: Promise<void>;
|
||||
await act(async () => { pending = hook.setEnabled(true); });
|
||||
assert.deepEqual(calls, ['config']);
|
||||
assert.equal(hook.enabled, false);
|
||||
assert.equal(readExternalMcpStoredEnabled(), false);
|
||||
await act(async () => { resolveConfig({ ok: false, error: 'Invalid privacy rules' }); await pending; });
|
||||
assert.deepEqual(calls, ['config', 'enabled:false']);
|
||||
assert.equal(hook.enabled, false);
|
||||
assert.match(hook.error!, /Invalid privacy rules/);
|
||||
|
||||
calls.length = 0;
|
||||
await act(async () => { pending = hook.setEnabled(true); });
|
||||
await act(async () => { await hook.setEnabled(false); });
|
||||
await act(async () => { resolveConfig({ ok: true }); await pending; });
|
||||
assert.deepEqual(calls, ['config', 'enabled:false']);
|
||||
assert.equal(hook.enabled, false, 'a newer disable wins over a pending enable');
|
||||
|
||||
calls.length = 0;
|
||||
await act(async () => { pending = hook.setEnabled(true); });
|
||||
await act(async () => { resolveConfig({ ok: true }); await pending; });
|
||||
assert.deepEqual(calls, ['config', 'enabled:true']);
|
||||
assert.equal(hook.enabled, true);
|
||||
assert.equal(readExternalMcpStoredEnabled(), true);
|
||||
assert.equal(hook.error, null);
|
||||
} finally {
|
||||
if (renderer) await act(async () => renderer!.unmount());
|
||||
if (previousWindow) Object.defineProperty(globalThis, 'window', previousWindow);
|
||||
else Reflect.deleteProperty(globalThis, 'window');
|
||||
if (previousAct) Object.defineProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT', previousAct);
|
||||
else Reflect.deleteProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT');
|
||||
restoreStorage();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -10,10 +10,8 @@ import {
|
||||
STORAGE_KEY_AI_EXTERNAL_MCP_HTTP_PORT,
|
||||
STORAGE_KEY_AI_EXTERNAL_MCP_IDLE_TIMEOUT_MINUTES,
|
||||
STORAGE_KEY_AI_EXTERNAL_MCP_MODE,
|
||||
STORAGE_KEY_AI_EXTERNAL_MCP_REDACTION,
|
||||
STORAGE_KEY_AI_EXTERNAL_MCP_SILENT_SESSIONS,
|
||||
STORAGE_KEY_AI_PERMISSION_MODE,
|
||||
STORAGE_KEY_AI_PRIVACY,
|
||||
STORAGE_KEY_AI_SESSION_IDLE_TIMEOUT_MINUTES,
|
||||
} from '../../infrastructure/config/storageKeys';
|
||||
import type { AIPermissionMode } from '../../infrastructure/ai/types';
|
||||
@@ -41,7 +39,6 @@ let externalMcpStartupReadyWaitPromise: Promise<void> | null = null;
|
||||
let externalMcpStartupReadyWaitResolve: (() => void) | null = null;
|
||||
/** Bumps on every intentional enable/disable so stale startup applies lose. */
|
||||
let externalMcpEnableGeneration = 0;
|
||||
let externalMcpConfigError: string | null = null;
|
||||
|
||||
function getWindowHash(hash?: string): string {
|
||||
if (typeof hash === 'string') return hash;
|
||||
@@ -79,7 +76,6 @@ export function resetExternalMcpStartupReadyForTests(): void {
|
||||
externalMcpStartupReadyWaitPromise = null;
|
||||
externalMcpStartupReadyWaitResolve = null;
|
||||
externalMcpEnableGeneration = 0;
|
||||
externalMcpConfigError = null;
|
||||
externalMcpStartupSyncPromise = null;
|
||||
}
|
||||
|
||||
@@ -114,138 +110,6 @@ export function waitForExternalMcpStartupReady(hash?: string): Promise<void> {
|
||||
return externalMcpStartupReadyWaitPromise;
|
||||
}
|
||||
|
||||
export type AIPrivacySettings = {
|
||||
enabled: boolean;
|
||||
redactPrivateIps: boolean;
|
||||
cidrs: string[];
|
||||
patterns: string[];
|
||||
};
|
||||
|
||||
export function defaultAIPrivacySettings(): AIPrivacySettings {
|
||||
return { enabled: false, redactPrivateIps: false, cidrs: [], patterns: [] };
|
||||
}
|
||||
|
||||
/** Missing settings are opt-out; corrupt settings must never silently opt out. */
|
||||
export function readAIPrivacySettings(): AIPrivacySettings {
|
||||
// The shared key wins, including invalid data: never fall back to a weaker legacy policy.
|
||||
// Leave the legacy value intact for older installs; Apply writes only the validated shared key.
|
||||
const stored = localStorageAdapter.readString(STORAGE_KEY_AI_PRIVACY)
|
||||
?? localStorageAdapter.readString(STORAGE_KEY_AI_EXTERNAL_MCP_REDACTION);
|
||||
if (stored === null) return defaultAIPrivacySettings();
|
||||
let value: unknown;
|
||||
try {
|
||||
value = JSON.parse(stored);
|
||||
} catch {
|
||||
throw new Error('Invalid saved AI privacy settings. Review and apply the settings again.');
|
||||
}
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
||||
throw new Error('Invalid saved AI privacy settings. Review and apply the settings again.');
|
||||
}
|
||||
const settings = value as Record<string, unknown>;
|
||||
if (
|
||||
Object.keys(settings).some((key) => !['enabled', 'redactPrivateIps', 'cidrs', 'patterns'].includes(key))
|
||||
|| typeof settings.enabled !== 'boolean'
|
||||
|| typeof settings.redactPrivateIps !== 'boolean'
|
||||
|| !Array.isArray(settings.cidrs) || !settings.cidrs.every((item) => typeof item === 'string')
|
||||
|| !Array.isArray(settings.patterns) || !settings.patterns.every((item) => typeof item === 'string')
|
||||
) {
|
||||
throw new Error('Invalid saved AI privacy settings. Review and apply the settings again.');
|
||||
}
|
||||
return {
|
||||
enabled: settings.enabled,
|
||||
redactPrivateIps: settings.redactPrivateIps,
|
||||
cidrs: [...settings.cidrs],
|
||||
patterns: [...settings.patterns],
|
||||
};
|
||||
}
|
||||
|
||||
function configResponseError(
|
||||
result: unknown,
|
||||
unverifiedMessage = 'External MCP settings could not be verified. The bridge has not been enabled.',
|
||||
): string | null {
|
||||
if (result && typeof result === 'object' && 'ok' in result && result.ok === true) return null;
|
||||
if (result && typeof result === 'object' && 'error' in result && typeof result.error === 'string' && result.error) {
|
||||
return result.error;
|
||||
}
|
||||
return unverifiedMessage;
|
||||
}
|
||||
|
||||
/** Validate in the main process before committing any privacy preference. */
|
||||
export async function applyAIPrivacySettings(
|
||||
settings: AIPrivacySettings,
|
||||
bridge: ExternalMcpBridge | undefined = netcattyBridge.get(),
|
||||
): Promise<void> {
|
||||
const result = await bridge?.externalMcpSetConfig?.({ redaction: settings });
|
||||
const error = configResponseError(result, 'AI privacy settings could not be verified. Try applying them again.');
|
||||
if (error) throw new Error(error);
|
||||
if (!localStorageAdapter.write(STORAGE_KEY_AI_PRIVACY, settings)) {
|
||||
throw new Error('AI privacy settings could not be saved. Apply them again before restarting.');
|
||||
}
|
||||
externalMcpConfigError = null;
|
||||
emitAIStateChanged(STORAGE_KEY_AI_PRIVACY);
|
||||
}
|
||||
|
||||
function readAIPrivacyState() {
|
||||
try {
|
||||
return { settings: readAIPrivacySettings(), error: null as string | null };
|
||||
} catch (error) {
|
||||
return {
|
||||
settings: defaultAIPrivacySettings(),
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export function useAIPrivacyState() {
|
||||
const [state, setState] = useState(readAIPrivacyState);
|
||||
const [applying, setApplying] = useState(false);
|
||||
const apply = useCallback(async (settings: AIPrivacySettings): Promise<boolean> => {
|
||||
setApplying(true);
|
||||
try {
|
||||
await applyAIPrivacySettings(settings);
|
||||
setState({ settings, error: null });
|
||||
return true;
|
||||
} catch (error) {
|
||||
setState((current) => ({
|
||||
...current,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
}));
|
||||
return false;
|
||||
} finally {
|
||||
setApplying(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const onStorage = (event: StorageEvent) => {
|
||||
if (event.key === STORAGE_KEY_AI_PRIVACY || event.key === STORAGE_KEY_AI_EXTERNAL_MCP_REDACTION) {
|
||||
setState(readAIPrivacyState());
|
||||
}
|
||||
};
|
||||
const onLocalChange = (event: Event) => {
|
||||
const key = (event as CustomEvent<{ key?: string }>).detail?.key;
|
||||
if (key === STORAGE_KEY_AI_PRIVACY || key === STORAGE_KEY_AI_EXTERNAL_MCP_REDACTION) {
|
||||
setState(readAIPrivacyState());
|
||||
}
|
||||
};
|
||||
window.addEventListener('storage', onStorage);
|
||||
window.addEventListener(AI_STATE_CHANGED_EVENT, onLocalChange);
|
||||
return () => {
|
||||
window.removeEventListener('storage', onStorage);
|
||||
window.removeEventListener(AI_STATE_CHANGED_EVENT, onLocalChange);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return { ...state, applying, apply };
|
||||
}
|
||||
|
||||
/** Compatibility exports for existing External MCP configuration callers. One shared policy only. */
|
||||
export type ExternalMcpRedactionSettings = AIPrivacySettings;
|
||||
export const defaultExternalMcpRedactionSettings = defaultAIPrivacySettings;
|
||||
export const readExternalMcpRedactionSettings = readAIPrivacySettings;
|
||||
export const applyExternalMcpRedactionSettings = applyAIPrivacySettings;
|
||||
export const useExternalMcpRedactionState = useAIPrivacyState;
|
||||
|
||||
export type ExternalMcpHttpSettings = {
|
||||
enabled: boolean;
|
||||
port: number;
|
||||
@@ -255,11 +119,9 @@ export type ExternalMcpHttpSettings = {
|
||||
};
|
||||
|
||||
type ExternalMcpConfig = {
|
||||
mode?: ExternalMcpMode;
|
||||
idleTimeoutMinutes?: number;
|
||||
sessionIdleTimeoutMinutes?: number;
|
||||
/** null invalidates shared privacy when persisted settings cannot be read safely. */
|
||||
redaction?: AIPrivacySettings | null;
|
||||
mode: ExternalMcpMode;
|
||||
idleTimeoutMinutes: number;
|
||||
sessionIdleTimeoutMinutes: number;
|
||||
httpEnabled?: boolean;
|
||||
httpPort?: number;
|
||||
httpAllowCustomBind?: boolean;
|
||||
@@ -284,7 +146,6 @@ export type ExternalMcpStartupSyncPlan = {
|
||||
runtimeEnabled: boolean;
|
||||
storedEnabled: boolean;
|
||||
shouldPersistStoredEnabled: boolean;
|
||||
configError?: string;
|
||||
};
|
||||
|
||||
export function normalizeExternalMcpMode(value: string | null): ExternalMcpMode {
|
||||
@@ -524,7 +385,6 @@ export function createExternalMcpStartupSyncPlan({
|
||||
mode,
|
||||
idleTimeoutMinutes,
|
||||
sessionIdleTimeoutMinutes,
|
||||
redaction = defaultAIPrivacySettings(),
|
||||
http = {
|
||||
enabled: false,
|
||||
port: DEFAULT_EXTERNAL_MCP_HTTP_PORT,
|
||||
@@ -538,7 +398,6 @@ export function createExternalMcpStartupSyncPlan({
|
||||
idleTimeoutMinutes: number;
|
||||
sessionIdleTimeoutMinutes: number;
|
||||
http?: ExternalMcpHttpSettings;
|
||||
redaction?: AIPrivacySettings;
|
||||
}): ExternalMcpStartupSyncPlan {
|
||||
const runtimeEnabled = shouldStartExternalMcpOnStartup({ enabled, mode });
|
||||
const storedEnabled = runtimeEnabled;
|
||||
@@ -547,7 +406,6 @@ export function createExternalMcpStartupSyncPlan({
|
||||
mode,
|
||||
idleTimeoutMinutes,
|
||||
sessionIdleTimeoutMinutes,
|
||||
redaction,
|
||||
...httpConfigFromSettings(http),
|
||||
},
|
||||
runtimeEnabled,
|
||||
@@ -557,44 +415,23 @@ export function createExternalMcpStartupSyncPlan({
|
||||
}
|
||||
|
||||
export function readExternalMcpStartupSyncPlan(): ExternalMcpStartupSyncPlan {
|
||||
let redaction = defaultAIPrivacySettings();
|
||||
let configError: string | undefined;
|
||||
try {
|
||||
redaction = readAIPrivacySettings();
|
||||
} catch (error) {
|
||||
configError = error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
const plan = createExternalMcpStartupSyncPlan({
|
||||
return createExternalMcpStartupSyncPlan({
|
||||
enabled: readExternalMcpStoredEnabled(),
|
||||
mode: readExternalMcpMode(),
|
||||
idleTimeoutMinutes: readExternalMcpIdleTimeoutMinutes(),
|
||||
sessionIdleTimeoutMinutes: readSessionIdleTimeoutMinutes(),
|
||||
http: readExternalMcpHttpSettings(),
|
||||
redaction,
|
||||
});
|
||||
return configError ? { ...plan, runtimeEnabled: false, configError } : plan;
|
||||
}
|
||||
|
||||
export async function syncExternalMcpConfig(
|
||||
bridge: ExternalMcpBridge | undefined = netcattyBridge.get(),
|
||||
): Promise<void> {
|
||||
const plan = readExternalMcpStartupSyncPlan();
|
||||
if (plan.configError) {
|
||||
// A corrupt saved policy must block all managed agents, not only External MCP.
|
||||
// Keep the original recovery error even if notifying the main process fails.
|
||||
try {
|
||||
await bridge?.externalMcpSetConfig?.({ redaction: null });
|
||||
} catch {
|
||||
// The saved policy still needs user repair regardless of bridge availability.
|
||||
}
|
||||
throw new Error(plan.configError);
|
||||
}
|
||||
const result = await bridge?.externalMcpSetConfig?.(plan.config);
|
||||
const error = configResponseError(result);
|
||||
if (error) throw new Error(error);
|
||||
if (JSON.stringify(readAIPrivacySettings()) !== JSON.stringify(plan.config.redaction)) {
|
||||
throw new Error('AI privacy settings changed. Try enabling the bridge again.');
|
||||
}
|
||||
export function syncExternalMcpConfig(bridge: ExternalMcpBridge | undefined = netcattyBridge.get()): void {
|
||||
const http = readExternalMcpHttpSettings();
|
||||
void bridge?.externalMcpSetConfig?.({
|
||||
mode: readExternalMcpMode(),
|
||||
idleTimeoutMinutes: readExternalMcpIdleTimeoutMinutes(),
|
||||
sessionIdleTimeoutMinutes: readSessionIdleTimeoutMinutes(),
|
||||
...httpConfigFromSettings(http),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -616,18 +453,10 @@ export async function syncExternalMcpStartupState(
|
||||
} catch {
|
||||
// Permission sync is best-effort; the main process keeps its safe default.
|
||||
}
|
||||
let configError = initialPlan.configError;
|
||||
try {
|
||||
if (configError) {
|
||||
// Explicit invalidation is distinct from a rejected draft Apply, which keeps
|
||||
// the previous working policy. Never replace corrupt saved data with defaults.
|
||||
await Promise.resolve(bridge?.externalMcpSetConfig?.({ redaction: null }));
|
||||
} else {
|
||||
const result = await Promise.resolve(bridge?.externalMcpSetConfig?.(initialPlan.config));
|
||||
configError = configResponseError(result) ?? undefined;
|
||||
}
|
||||
} catch (error) {
|
||||
configError ??= error instanceof Error ? error.message : String(error);
|
||||
await Promise.resolve(bridge?.externalMcpSetConfig?.(initialPlan.config));
|
||||
} catch {
|
||||
// Config sync is best-effort; continue with enable/disable reconcile.
|
||||
}
|
||||
|
||||
// A user toggle during config await invalidates this startup apply.
|
||||
@@ -636,18 +465,6 @@ export async function syncExternalMcpStartupState(
|
||||
}
|
||||
|
||||
const plan = readExternalMcpStartupSyncPlan();
|
||||
if (JSON.stringify(plan.config.redaction) !== JSON.stringify(initialPlan.config.redaction)) {
|
||||
configError = 'AI privacy settings changed during startup. Enable the bridge again.';
|
||||
}
|
||||
if (configError || plan.configError) {
|
||||
// A rejected or unverified config must never expose an unprotected runtime.
|
||||
plan.configError = configError || plan.configError;
|
||||
plan.runtimeEnabled = false;
|
||||
plan.storedEnabled = false;
|
||||
plan.shouldPersistStoredEnabled = readExternalMcpStoredEnabled();
|
||||
}
|
||||
externalMcpConfigError = plan.configError ?? null;
|
||||
emitAIStateChanged(STORAGE_KEY_AI_PRIVACY);
|
||||
if (plan.shouldPersistStoredEnabled) {
|
||||
localStorageAdapter.writeBoolean(STORAGE_KEY_AI_EXTERNAL_MCP_ENABLED, plan.storedEnabled);
|
||||
emitAIStateChanged(STORAGE_KEY_AI_EXTERNAL_MCP_ENABLED);
|
||||
@@ -699,47 +516,24 @@ export function useExternalMcpToggleState() {
|
||||
emitAIStateChanged(STORAGE_KEY_AI_EXTERNAL_MCP_ENABLED);
|
||||
}, []);
|
||||
|
||||
const [error, setError] = useState<string | null>(() => externalMcpConfigError);
|
||||
|
||||
const setEnabled = useCallback(async (nextEnabled: boolean) => {
|
||||
// Peer session windows mirror storage, but never own the main lifecycle.
|
||||
if (isPeerSessionWindow) {
|
||||
persistEnabled(nextEnabled);
|
||||
return;
|
||||
}
|
||||
if (!nextEnabled) persistEnabled(false);
|
||||
const generation = ++externalMcpEnableGeneration;
|
||||
setError(null);
|
||||
externalMcpConfigError = null;
|
||||
const bridge = netcattyBridge.get();
|
||||
try {
|
||||
// Even a manual enable must apply and verify saved privacy settings first.
|
||||
if (nextEnabled) await syncExternalMcpConfig(bridge);
|
||||
if (generation !== externalMcpEnableGeneration) return;
|
||||
const result = await bridge?.externalMcpSetEnabled?.(nextEnabled);
|
||||
if (nextEnabled && result && typeof result === 'object' && 'ok' in result && result.ok === false) {
|
||||
throw new Error('error' in result && typeof result.error === 'string' && result.error
|
||||
? result.error : 'External MCP could not be enabled.');
|
||||
}
|
||||
if (generation === externalMcpEnableGeneration) persistEnabled(nextEnabled);
|
||||
} catch (cause) {
|
||||
if (generation !== externalMcpEnableGeneration) return;
|
||||
externalMcpConfigError = cause instanceof Error ? cause.message : String(cause);
|
||||
setError(externalMcpConfigError);
|
||||
if (nextEnabled) {
|
||||
persistEnabled(false);
|
||||
await Promise.resolve(bridge?.externalMcpSetEnabled?.(false)).catch(() => undefined);
|
||||
}
|
||||
} finally {
|
||||
const setEnabled = useCallback((nextEnabled: boolean) => {
|
||||
persistEnabled(nextEnabled);
|
||||
// Peer session windows can mirror the stored switch, but they must not own
|
||||
// the main-process External MCP lifecycle.
|
||||
if (isPeerSessionWindow) return;
|
||||
externalMcpEnableGeneration += 1;
|
||||
// Re-emit after the lifecycle IPC settles so consumers that refetch runtime
|
||||
// status (e.g. Tool Access guidance) do not act on the pre-IPC storage
|
||||
// event, which can still observe the previous enabled state.
|
||||
void Promise.resolve(netcattyBridge.get()?.externalMcpSetEnabled?.(nextEnabled)).finally(() => {
|
||||
emitAIStateChanged(STORAGE_KEY_AI_EXTERNAL_MCP_ENABLED);
|
||||
}
|
||||
});
|
||||
}, [isPeerSessionWindow, persistEnabled]);
|
||||
|
||||
useEffect(() => {
|
||||
const syncFromStorage = () => {
|
||||
const nextEnabled = readExternalMcpStoredEnabled();
|
||||
setEnabledRaw(nextEnabled);
|
||||
setError(externalMcpConfigError);
|
||||
};
|
||||
|
||||
const handleStorage = (event: StorageEvent) => {
|
||||
@@ -748,7 +542,7 @@ export function useExternalMcpToggleState() {
|
||||
};
|
||||
const handleLocalStateChanged = (event: Event) => {
|
||||
const key = (event as CustomEvent<{ key?: string }>).detail?.key;
|
||||
if (key !== STORAGE_KEY_AI_EXTERNAL_MCP_ENABLED && key !== STORAGE_KEY_AI_EXTERNAL_MCP_REDACTION) return;
|
||||
if (key !== STORAGE_KEY_AI_EXTERNAL_MCP_ENABLED) return;
|
||||
syncFromStorage();
|
||||
};
|
||||
|
||||
@@ -793,5 +587,5 @@ export function useExternalMcpToggleState() {
|
||||
};
|
||||
}, [enabled, isPeerSessionWindow, persistEnabled]);
|
||||
|
||||
return { enabled, setEnabled, error };
|
||||
return { enabled, setEnabled };
|
||||
}
|
||||
@@ -125,55 +125,6 @@ test('overlapping nonmodal menus yield with a picture; modal dialogs yield even
|
||||
await f.tick(); assert.equal(f.layouts.at(-1)?.occluded, true);
|
||||
});
|
||||
|
||||
function panel(f: Awaited<ReturnType<typeof fixture>>, x: number, y: number, width: number, height: number) {
|
||||
const node = f.overlay('<div data-section="app-host-editor-layer"><div data-native-view-occluder></div></div>').firstElementChild!;
|
||||
node.getBoundingClientRect = () => new f.env.window.DOMRect(x, y, width, height);
|
||||
return node;
|
||||
}
|
||||
|
||||
test('an overlay host editor panel narrows the live page beside it, then restores the pane', async (t) => {
|
||||
const f = await fixture(t);
|
||||
panel(f, 900, 0, 400, 900); await f.tick();
|
||||
assert.deepEqual(f.layouts.at(-1)?.bounds, { x: 200, y: 100, width: 500, height: 600 }, 'a panel beside the pane changes nothing');
|
||||
const editor = panel(f, 450, 0, 600, 900); await f.tick();
|
||||
assert.equal(f.layouts.at(-1)?.visible, true);
|
||||
assert.equal(f.layouts.at(-1)?.occluded, false, 'the page stays live instead of becoming a picture');
|
||||
assert.deepEqual(f.layouts.at(-1)?.bounds, { x: 200, y: 100, width: 250, height: 600 });
|
||||
assert.deepEqual(f.panel().snapshot, []);
|
||||
editor.getBoundingClientRect = () => new f.env.window.DOMRect(400, 0, 650, 900);
|
||||
f.resize(); await f.tick();
|
||||
assert.deepEqual(f.layouts.at(-1)?.bounds, { x: 200, y: 100, width: 200, height: 600 }, 'resizing the panel follows it');
|
||||
editor.parentElement!.remove(); await f.tick();
|
||||
assert.deepEqual(f.layouts.at(-1)?.bounds, { x: 200, y: 100, width: 500, height: 600 });
|
||||
assert.equal(f.layouts.at(-1)?.occluded, false);
|
||||
});
|
||||
|
||||
test('a popper inside the panel leaves the narrowed page live; one over the page keeps aligned pictures', async (t) => {
|
||||
const f = await fixture(t);
|
||||
panel(f, 0, 0, 300, 900); await f.tick();
|
||||
assert.deepEqual(f.layouts.at(-1)?.bounds, { x: 300, y: 100, width: 400, height: 600 });
|
||||
const select = f.overlay(tooltip, 140, 110); await f.tick();
|
||||
assert.equal(f.layouts.at(-1)?.occluded, false, 'a select inside the left panel does not reach the page');
|
||||
select.remove();
|
||||
f.overlay(tooltip, 320, 110); await f.tick();
|
||||
assert.equal(f.layouts.at(-1)?.occluded, true);
|
||||
assert.deepEqual(f.layouts.at(-1)?.bounds, { x: 300, y: 100, width: 400, height: 600 });
|
||||
assert.deepEqual(f.panel().snapshot.map((layer) => layer.bounds), [{ x: 100, y: 0, width: 500, height: 600 }],
|
||||
'pictures are placed where the narrowed page sits inside the pane');
|
||||
});
|
||||
|
||||
test('a panel over the middle of the pane or leaving too little room falls back to a picture', async (t) => {
|
||||
const f = await fixture(t);
|
||||
const middle = panel(f, 300, 0, 100, 900); await f.tick();
|
||||
assert.equal(f.layouts.at(-1)?.occluded, true);
|
||||
assert.deepEqual(f.layouts.at(-1)?.bounds, { x: 200, y: 100, width: 500, height: 600 });
|
||||
assert.deepEqual(f.panel().snapshot, layers);
|
||||
middle.parentElement!.remove(); await f.tick();
|
||||
assert.equal(f.layouts.at(-1)?.occluded, false);
|
||||
panel(f, 250, 0, 600, 900); await f.tick();
|
||||
assert.equal(f.layouts.at(-1)?.occluded, true, 'a 50px strip is not worth a live page');
|
||||
});
|
||||
|
||||
test('closing a menu while capture is pending cannot restore stale pictures', async (t) => {
|
||||
const f = await fixture(t);
|
||||
let complete: (result: Result) => void = () => assert.fail('capture was not started');
|
||||
|
||||
@@ -1,17 +1,16 @@
|
||||
import { useEffect, useLayoutEffect, useRef, useState } from 'react';
|
||||
import { currentBrowserThemeTokens, mountHostBrowser } from '../hostBrowser';
|
||||
import { resolveHostBrowserPaneBesidePanels, type HostBrowserPaneRect } from '../../domain/hostBrowser';
|
||||
import type { Host, Identity, KnownHost, SSHKey, TerminalSettings } from '../../domain/models';
|
||||
import { netcattyBridge } from '../../infrastructure/services/netcattyBridge';
|
||||
import type { HostBrowserPanelSnapshotLayer } from '../../types/global/netcatty-bridge-sync';
|
||||
|
||||
function overlapsBrowser(rect: HostBrowserPaneRect, element: Element): boolean {
|
||||
function overlapsBrowser(rect: DOMRect, element: Element): boolean {
|
||||
const style = getComputedStyle(element);
|
||||
if (style.display === 'none' || style.visibility === 'hidden' || element.closest('[hidden], [inert], [data-state="closed"]')) return false;
|
||||
const overlay = element.getBoundingClientRect();
|
||||
return overlay.width > 0 && overlay.height > 0
|
||||
&& overlay.x < rect.x + rect.width && overlay.right > rect.x
|
||||
&& overlay.y < rect.y + rect.height && overlay.bottom > rect.y;
|
||||
&& overlay.x < rect.right && overlay.right > rect.x
|
||||
&& overlay.y < rect.bottom && overlay.bottom > rect.y;
|
||||
}
|
||||
|
||||
interface BrowserPanelOptions {
|
||||
@@ -71,21 +70,12 @@ export function useHostBrowserPanel(options: BrowserPanelOptions) {
|
||||
const rect = element?.getBoundingClientRect();
|
||||
// Native views sit above renderer portals. Only overlapping poppers need
|
||||
// to yield; dialogs are modal even when their content is beside the pane.
|
||||
// Overlay panels stay open while the user works, so the live page moves
|
||||
// beside them when it can and only falls back to a picture otherwise.
|
||||
const locked = !!document.querySelector('[data-app-lock-overlay]');
|
||||
const modal = !!document.querySelector('[role="dialog"][aria-modal="true"], [role="alertdialog"]');
|
||||
const overlays = Array.from(document.querySelectorAll(
|
||||
'[role="dialog"], [role="alertdialog"], [role="menu"][data-state="open"], [data-radix-popper-content-wrapper] > *, '
|
||||
+ '[data-native-view-occluder]',
|
||||
'[role="dialog"], [role="alertdialog"], [role="menu"][data-state="open"], [data-radix-popper-content-wrapper] > *',
|
||||
));
|
||||
const isPanel = (overlay: Element) => overlay.matches('[data-native-view-occluder]');
|
||||
const panels = rect ? overlays.filter((overlay) => isPanel(overlay) && overlapsBrowser(rect, overlay)) : [];
|
||||
const live = rect && resolveHostBrowserPaneBesidePanels(rect, panels.map((panel) => panel.getBoundingClientRect()));
|
||||
// Poppers over the part of the pane hidden behind a panel (e.g. a select
|
||||
// inside the host editor) no longer touch the live page.
|
||||
const covered = modal || (!!rect && !live)
|
||||
|| (!!live && overlays.some((overlay) => !isPanel(overlay) && overlapsBrowser(live, overlay)));
|
||||
const covered = modal || (!!rect && overlays.some((overlay) => overlapsBrowser(rect, overlay)));
|
||||
const targets = new Set<Element>(overlays);
|
||||
for (let target: Element | null = element; target; target = target.parentElement) targets.add(target);
|
||||
for (const target of observed) {
|
||||
@@ -100,11 +90,7 @@ export function useHostBrowserPanel(options: BrowserPanelOptions) {
|
||||
const visible = isVisible && !!element?.isConnected && !!rect
|
||||
&& rect.width > 0 && rect.height > 0 && !element?.closest('[inert]') && !locked;
|
||||
if (!visible) setSnapshot(null);
|
||||
const pane = live || rect;
|
||||
const bounds = pane ? { x: pane.x, y: pane.y, width: pane.width, height: pane.height } : undefined;
|
||||
// Pictures are relative to the native page, which may sit beside a panel.
|
||||
const offsetX = rect && bounds ? bounds.x - rect.x : 0;
|
||||
const offsetY = rect && bounds ? bounds.y - rect.y : 0;
|
||||
const bounds = rect ? { x: rect.x, y: rect.y, width: rect.width, height: rect.height } : undefined;
|
||||
const theme = element && getComputedStyle(element).colorScheme === 'light' ? 'light' : 'dark';
|
||||
const themeTokens = currentBrowserThemeTokens(element ?? undefined);
|
||||
const layout: Parameters<NonNullable<NetcattyBridge['setHostBrowserPanelLayout']>>[0] = {
|
||||
@@ -116,9 +102,7 @@ export function useHostBrowserPanel(options: BrowserPanelOptions) {
|
||||
const revision = ++layoutRevision;
|
||||
void bridge?.setHostBrowserPanelLayout?.(layout).then((result) => {
|
||||
if (disposed || layoutRevision !== revision) return;
|
||||
setSnapshot(visible && covered && typeof result === 'object' ? { browserId, layers: result.layers.map((layer) => ({
|
||||
...layer, bounds: { ...layer.bounds, x: layer.bounds.x + offsetX, y: layer.bounds.y + offsetY },
|
||||
})) } : null);
|
||||
setSnapshot(visible && covered && typeof result === 'object' ? { browserId, layers: result.layers } : null);
|
||||
}).catch(() => {});
|
||||
}
|
||||
};
|
||||
@@ -135,15 +119,14 @@ export function useHostBrowserPanel(options: BrowserPanelOptions) {
|
||||
if (doc) mutationObserver?.observe(doc.documentElement, {
|
||||
subtree: true, childList: true, attributes: true,
|
||||
attributeFilter: ['style', 'class', 'hidden', 'inert', 'role', 'aria-modal', 'data-state',
|
||||
'data-app-lock-overlay', 'data-radix-popper-content-wrapper', 'data-native-view-occluder',
|
||||
'href', 'media', 'disabled'],
|
||||
'data-app-lock-overlay', 'data-radix-popper-content-wrapper', 'href', 'media', 'disabled'],
|
||||
});
|
||||
const onMotion = (event: Event) => {
|
||||
const target = event.target;
|
||||
if (!element || !view || !(target instanceof view.Element)) return;
|
||||
if (!target.contains(element) && !target.matches(
|
||||
'[role="dialog"], [role="alertdialog"], [role="menu"], [data-app-lock-overlay], '
|
||||
+ '[data-radix-popper-content-wrapper], [data-radix-popper-content-wrapper] > *, [data-native-view-occluder]',
|
||||
+ '[data-radix-popper-content-wrapper], [data-radix-popper-content-wrapper] > *',
|
||||
)) return;
|
||||
trackAnimations(target);
|
||||
schedule();
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { useCallback, useMemo } from 'react';
|
||||
import type { ContainerRuntimeId } from '../../domain/systemManager/containerRuntime';
|
||||
import type {
|
||||
DockerContainerAction,
|
||||
DockerImageManageAction,
|
||||
@@ -95,27 +94,23 @@ export function useSystemManagerBackend() {
|
||||
return bridge.tmuxAction(options);
|
||||
}, []);
|
||||
|
||||
const listDockerContainers = useCallback(async (sessionId: string, runtime?: ContainerRuntimeId) => {
|
||||
const listDockerContainers = useCallback(async (sessionId: string) => {
|
||||
const bridge = netcattyBridge.get();
|
||||
if (!bridge?.listDockerContainers) {
|
||||
return { success: false as const, error: 'listDockerContainers unavailable' };
|
||||
}
|
||||
return bridge.listDockerContainers(sessionId, runtime);
|
||||
return bridge.listDockerContainers(sessionId);
|
||||
}, []);
|
||||
|
||||
const listDockerImages = useCallback(async (sessionId: string, runtime?: ContainerRuntimeId) => {
|
||||
const listDockerImages = useCallback(async (sessionId: string) => {
|
||||
const bridge = netcattyBridge.get();
|
||||
if (!bridge?.listDockerImages) {
|
||||
return { success: false as const, error: 'listDockerImages unavailable' };
|
||||
}
|
||||
return bridge.listDockerImages(sessionId, runtime);
|
||||
return bridge.listDockerImages(sessionId);
|
||||
}, []);
|
||||
|
||||
const getDockerStats = useCallback(async (options: {
|
||||
sessionId: string;
|
||||
ids?: string[];
|
||||
runtime?: ContainerRuntimeId;
|
||||
}) => {
|
||||
const getDockerStats = useCallback(async (options: { sessionId: string; ids?: string[] }) => {
|
||||
const bridge = netcattyBridge.get();
|
||||
if (!bridge?.getDockerStats) {
|
||||
return { success: false as const, error: 'getDockerStats unavailable' };
|
||||
@@ -160,11 +155,7 @@ export function useSystemManagerBackend() {
|
||||
return bridge.systemServiceAction(options);
|
||||
}, []);
|
||||
|
||||
const dockerInspect = useCallback(async (options: {
|
||||
sessionId: string;
|
||||
containerId: string;
|
||||
runtime?: ContainerRuntimeId;
|
||||
}) => {
|
||||
const dockerInspect = useCallback(async (options: { sessionId: string; containerId: string }) => {
|
||||
const bridge = netcattyBridge.get();
|
||||
if (!bridge?.dockerInspect) {
|
||||
return { success: false as const, error: 'dockerInspect unavailable' };
|
||||
@@ -172,11 +163,7 @@ export function useSystemManagerBackend() {
|
||||
return bridge.dockerInspect(options);
|
||||
}, []);
|
||||
|
||||
const dockerImageInspect = useCallback(async (options: {
|
||||
sessionId: string;
|
||||
imageId: string;
|
||||
runtime?: ContainerRuntimeId;
|
||||
}) => {
|
||||
const dockerImageInspect = useCallback(async (options: { sessionId: string; imageId: string }) => {
|
||||
const bridge = netcattyBridge.get();
|
||||
if (!bridge?.dockerImageInspect) {
|
||||
return { success: false as const, error: 'dockerImageInspect unavailable' };
|
||||
@@ -189,7 +176,6 @@ export function useSystemManagerBackend() {
|
||||
containerId: string;
|
||||
action: DockerContainerAction;
|
||||
newName?: string;
|
||||
runtime?: ContainerRuntimeId;
|
||||
}) => {
|
||||
const bridge = netcattyBridge.get();
|
||||
if (!bridge?.dockerAction) {
|
||||
@@ -198,10 +184,7 @@ export function useSystemManagerBackend() {
|
||||
return bridge.dockerAction(options);
|
||||
}, []);
|
||||
|
||||
const dockerImageAction = useCallback(async (options: {
|
||||
sessionId: string;
|
||||
runtime?: ContainerRuntimeId;
|
||||
} & DockerImageManageAction) => {
|
||||
const dockerImageAction = useCallback(async (options: { sessionId: string } & DockerImageManageAction) => {
|
||||
const bridge = netcattyBridge.get();
|
||||
if (!bridge?.dockerImageAction) {
|
||||
return { success: false as const, error: 'dockerImageAction unavailable' };
|
||||
@@ -209,21 +192,6 @@ export function useSystemManagerBackend() {
|
||||
return bridge.dockerImageAction(options);
|
||||
}, []);
|
||||
|
||||
const kubectl = useCallback(async (options: {
|
||||
sessionId: string;
|
||||
op: 'contexts' | 'namespaces' | 'pods' | 'delete';
|
||||
context?: string;
|
||||
namespace?: string;
|
||||
pod?: string;
|
||||
shell?: string;
|
||||
}) => {
|
||||
const bridge = netcattyBridge.get();
|
||||
if (!bridge?.kubectl) {
|
||||
return { success: false as const, error: 'kubectl unavailable' };
|
||||
}
|
||||
return bridge.kubectl(options);
|
||||
}, []);
|
||||
|
||||
const openTerminalPopup = useCallback(async (
|
||||
payload: Parameters<NonNullable<NetcattyBridge['openTerminalPopup']>>[0],
|
||||
) => {
|
||||
@@ -255,7 +223,6 @@ export function useSystemManagerBackend() {
|
||||
dockerImageInspect,
|
||||
dockerAction,
|
||||
dockerImageAction,
|
||||
kubectl,
|
||||
openTerminalPopup,
|
||||
}), [
|
||||
probeSystemCapabilities,
|
||||
@@ -278,7 +245,6 @@ export function useSystemManagerBackend() {
|
||||
dockerImageInspect,
|
||||
dockerAction,
|
||||
dockerImageAction,
|
||||
kubectl,
|
||||
openTerminalPopup,
|
||||
]);
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -64,8 +64,6 @@ import {
|
||||
mergeConnectionLogsFromStorage,
|
||||
mergeTerminalDataIntoLogs,
|
||||
mergeTerminalDataMapsForStorage,
|
||||
pruneInMemoryUnsavedTerminalData,
|
||||
writeTerminalDataMapWithQuotaRetry,
|
||||
type ConnectionLogTerminalDataMap,
|
||||
} from "../../domain/connectionLogTerminalData";
|
||||
import { getNextVaultOrder, normalizeVaultOrder } from "../../domain/vaultOrder";
|
||||
@@ -278,57 +276,23 @@ const buildPersistedLogIds = (
|
||||
return ids;
|
||||
};
|
||||
|
||||
/**
|
||||
* Same result as comparing `JSON.stringify` of both maps (own keys in the
|
||||
* same order, equal values) without serializing the multi-MB replay data.
|
||||
*/
|
||||
const isSameTerminalDataMap = (
|
||||
a: ConnectionLogTerminalDataMap,
|
||||
b: ConnectionLogTerminalDataMap,
|
||||
): boolean => {
|
||||
if (typeof a !== "object" || a === null || Array.isArray(a)) {
|
||||
return JSON.stringify(a) === JSON.stringify(b);
|
||||
}
|
||||
const aKeys = Object.keys(a);
|
||||
const bKeys = Object.keys(b);
|
||||
if (aKeys.length !== bKeys.length) return false;
|
||||
for (let i = 0; i < aKeys.length; i += 1) {
|
||||
if (aKeys[i] !== bKeys[i] || a[aKeys[i]] !== b[bKeys[i]]) return false;
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
const writeConnectionLogTerminalDataMap = (
|
||||
existing: ConnectionLogTerminalDataMap,
|
||||
pruned: ConnectionLogTerminalDataMap,
|
||||
logs: ConnectionLog[],
|
||||
quotaFailedCharsRef: { current: number | null },
|
||||
localMaps: ConnectionLogTerminalDataMap[],
|
||||
usePersistedLogIdsFromDisk: boolean,
|
||||
): boolean => {
|
||||
if (isSameTerminalDataMap(existing, pruned)) return true;
|
||||
// On quota failure keep the newest replay buffers instead of none.
|
||||
// The hint stays on the hook so the domain helper remains a pure calculation.
|
||||
const result = writeTerminalDataMapWithQuotaRetry(
|
||||
const existing = readConnectionLogTerminalDataMap();
|
||||
const pruned = mergeTerminalDataMapsForStorage(
|
||||
logs,
|
||||
pruned,
|
||||
(next) => localStorageAdapter.write(STORAGE_KEY_CONNECTION_LOG_TERMINAL_DATA, next),
|
||||
quotaFailedCharsRef.current,
|
||||
existing,
|
||||
localMaps,
|
||||
buildPersistedLogIds(logs, usePersistedLogIdsFromDisk),
|
||||
);
|
||||
quotaFailedCharsRef.current = result.quotaFailedChars;
|
||||
return result.persisted;
|
||||
if (JSON.stringify(existing) === JSON.stringify(pruned)) return true;
|
||||
return localStorageAdapter.write(STORAGE_KEY_CONNECTION_LOG_TERMINAL_DATA, pruned);
|
||||
};
|
||||
|
||||
export type UseVaultStateOptions = {
|
||||
/**
|
||||
* When false, skip loading and cross-window sync of the bulky
|
||||
* connection-log terminal-data side store (~30–50 MB in localStorage).
|
||||
* Pass false from windows that never show connection-log replay
|
||||
* (Settings, tray panel, terminal popup). Default true.
|
||||
*/
|
||||
syncConnectionLogTerminalData?: boolean;
|
||||
};
|
||||
|
||||
export const useVaultState = (options?: UseVaultStateOptions) => {
|
||||
const syncConnectionLogTerminalData = options?.syncConnectionLogTerminalData !== false;
|
||||
export const useVaultState = () => {
|
||||
const [isInitialized, setIsInitialized] = useState(false);
|
||||
const [hosts, setHosts] = useState<Host[]>([]);
|
||||
const [keys, setKeys] = useState<SSHKey[]>([]);
|
||||
@@ -455,7 +419,6 @@ export const useVaultState = (options?: UseVaultStateOptions) => {
|
||||
const proxyProfilesReadSeq = useRef(0);
|
||||
const groupConfigsReadSeq = useRef(0);
|
||||
const connectionLogTerminalDataRef = useRef<ConnectionLogTerminalDataMap>({});
|
||||
const connectionLogQuotaFailedCharsRef = useRef<number | null>(null);
|
||||
const pluginCredentialCatalogVersion = useRef(0);
|
||||
|
||||
const syncConnectionLogTerminalDataMap = useCallback((
|
||||
@@ -475,15 +438,18 @@ export const useVaultState = (options?: UseVaultStateOptions) => {
|
||||
buildPersistedLogIds(logs, usePersistedLogIdsFromDisk),
|
||||
);
|
||||
const persisted = writeConnectionLogTerminalDataMap(
|
||||
existing,
|
||||
merged,
|
||||
logs,
|
||||
connectionLogQuotaFailedCharsRef,
|
||||
localMaps,
|
||||
usePersistedLogIdsFromDisk,
|
||||
);
|
||||
// On a failed write (e.g. QuotaExceeded once the side store nears ~50 MB)
|
||||
// keep the same capped map instead of accumulating every log's replay
|
||||
// buffer in memory; anything outside it would be pruned on the next write.
|
||||
connectionLogTerminalDataRef.current = merged;
|
||||
if (persisted) {
|
||||
connectionLogTerminalDataRef.current = merged;
|
||||
} else {
|
||||
connectionLogTerminalDataRef.current = {
|
||||
...connectionLogTerminalDataRef.current,
|
||||
...buildTerminalDataMapFromLogs(logs),
|
||||
};
|
||||
}
|
||||
return { map: connectionLogTerminalDataRef.current, persisted };
|
||||
}, []);
|
||||
|
||||
@@ -1331,10 +1297,8 @@ export const useVaultState = (options?: UseVaultStateOptions) => {
|
||||
const savedLogs = prev.filter((l) => l.saved);
|
||||
const unsavedLogs = prev.filter((l) => !l.saved);
|
||||
const updated = [newLog, ...unsavedLogs].slice(0, 500);
|
||||
const final = pruneInMemoryUnsavedTerminalData(
|
||||
[...updated, ...savedLogs].sort(
|
||||
(a, b) => b.startTime - a.startTime
|
||||
),
|
||||
const final = [...updated, ...savedLogs].sort(
|
||||
(a, b) => b.startTime - a.startTime
|
||||
);
|
||||
persistConnectionLogState(final, { pruneMainBlob: true });
|
||||
return final;
|
||||
@@ -1347,8 +1311,8 @@ export const useVaultState = (options?: UseVaultStateOptions) => {
|
||||
const updateConnectionLog = useCallback(
|
||||
(id: string, updates: Partial<ConnectionLog>) => {
|
||||
setConnectionLogs((prev) => {
|
||||
const updated = pruneInMemoryUnsavedTerminalData(
|
||||
prev.map((log) => (log.id === id ? { ...log, ...updates } : log)),
|
||||
const updated = prev.map((log) =>
|
||||
log.id === id ? { ...log, ...updates } : log
|
||||
);
|
||||
persistConnectionLogState(updated, { pruneMainBlob: true });
|
||||
return updated;
|
||||
@@ -1359,10 +1323,8 @@ export const useVaultState = (options?: UseVaultStateOptions) => {
|
||||
|
||||
const toggleConnectionLogSaved = useCallback((id: string) => {
|
||||
setConnectionLogs((prev) => {
|
||||
const updated = pruneInMemoryUnsavedTerminalData(
|
||||
prev.map((log) =>
|
||||
log.id === id ? { ...log, saved: !log.saved } : log
|
||||
),
|
||||
const updated = prev.map((log) =>
|
||||
log.id === id ? { ...log, saved: !log.saved } : log
|
||||
);
|
||||
persistConnectionLogState(updated, { pruneMainBlob: false });
|
||||
return updated;
|
||||
@@ -1666,11 +1628,7 @@ export const useVaultState = (options?: UseVaultStateOptions) => {
|
||||
const savedConnectionLogs = localStorageAdapter.read<ConnectionLog[]>(
|
||||
STORAGE_KEY_CONNECTION_LOGS,
|
||||
);
|
||||
// Windows that never render connection-log replay skip the ~30–50 MB
|
||||
// side-store parse on boot (Settings / tray / popup).
|
||||
const terminalDataMap = syncConnectionLogTerminalData
|
||||
? readConnectionLogTerminalDataMap()
|
||||
: {};
|
||||
const terminalDataMap = readConnectionLogTerminalDataMap();
|
||||
connectionLogTerminalDataRef.current = terminalDataMap;
|
||||
if (savedConnectionLogs) {
|
||||
setConnectionLogs(mergeTerminalDataIntoLogs(savedConnectionLogs, terminalDataMap));
|
||||
@@ -1715,7 +1673,7 @@ export const useVaultState = (options?: UseVaultStateOptions) => {
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [commitEncryptedHostsUnderVaultLock, syncConnectionLogTerminalData, updateHosts, updateSnippets]);
|
||||
}, [commitEncryptedHostsUnderVaultLock, updateHosts, updateSnippets]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isInitialized) return;
|
||||
@@ -1924,9 +1882,6 @@ export const useVaultState = (options?: UseVaultStateOptions) => {
|
||||
}
|
||||
|
||||
if (key === STORAGE_KEY_CONNECTION_LOG_TERMINAL_DATA) {
|
||||
// Other windows still receive the storage event; skip the multi-MB
|
||||
// JSON.parse when this mount does not show connection-log replay.
|
||||
if (!syncConnectionLogTerminalData) return;
|
||||
const next = safeParse<ConnectionLogTerminalDataMap>(event.newValue) ?? {};
|
||||
connectionLogTerminalDataRef.current = next;
|
||||
setConnectionLogs((prev) => mergeConnectionLogsFromStorage(prev, prev, next));
|
||||
@@ -1967,7 +1922,6 @@ export const useVaultState = (options?: UseVaultStateOptions) => {
|
||||
return;
|
||||
}
|
||||
if (key === STORAGE_KEY_CONNECTION_LOG_TERMINAL_DATA) {
|
||||
if (!syncConnectionLogTerminalData) return;
|
||||
const next = readConnectionLogTerminalDataMap();
|
||||
connectionLogTerminalDataRef.current = next;
|
||||
setConnectionLogs((prev) => mergeConnectionLogsFromStorage(prev, prev, next));
|
||||
@@ -1980,7 +1934,7 @@ export const useVaultState = (options?: UseVaultStateOptions) => {
|
||||
window.removeEventListener("storage", handleStorage);
|
||||
window.removeEventListener(LOCAL_STORAGE_ADAPTER_CHANGED_EVENT, handleLocalStorageAdapterChanged);
|
||||
};
|
||||
}, [applyConnectionLogsFromStorage, syncConnectionLogTerminalData]);
|
||||
}, [applyConnectionLogsFromStorage]);
|
||||
|
||||
const updateHostLastConnected = useCallback((hostId: string) => {
|
||||
setHosts((prev) => {
|
||||
|
||||
@@ -1,98 +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 { normalizeVaultOrder, sortByVaultOrder } from "../domain/vaultOrder.ts";
|
||||
import type { GroupConfig } from "../types.ts";
|
||||
import GroupDetailsPanel from "./GroupDetailsPanel.tsx";
|
||||
import { HostNotesEditor } from "./host/HostNotesEditor.tsx";
|
||||
import { AsidePanel } from "./ui/aside-panel.tsx";
|
||||
import { Input } from "./ui/input.tsx";
|
||||
import { TooltipProvider } from "./ui/tooltip.tsx";
|
||||
import { installDomEnvironment } from "./test-support/renderReactDom.tsx";
|
||||
|
||||
async function saveGroup(config: GroupConfig | undefined, newName?: string) {
|
||||
const environment = globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean };
|
||||
const previous = environment.IS_REACT_ACT_ENVIRONMENT;
|
||||
const dom = installDomEnvironment();
|
||||
const previousRaf = globalThis.requestAnimationFrame;
|
||||
const previousCancelRaf = globalThis.cancelAnimationFrame;
|
||||
globalThis.requestAnimationFrame = (callback) => setTimeout(() => callback(0), 0) as unknown as number;
|
||||
globalThis.cancelAnimationFrame = (id) => clearTimeout(id);
|
||||
environment.IS_REACT_ACT_ENVIRONMENT = true;
|
||||
let renderer: ReactTestRenderer | undefined;
|
||||
let saved: GroupConfig | undefined;
|
||||
let savedName: string | undefined;
|
||||
try {
|
||||
await act(async () => {
|
||||
renderer = create(
|
||||
<TooltipProvider>
|
||||
<GroupDetailsPanel
|
||||
groupPath={config?.path ?? "new-group"}
|
||||
config={config}
|
||||
availableKeys={[]}
|
||||
identities={[]}
|
||||
allHosts={[]}
|
||||
groups={[]}
|
||||
terminalThemeId="default"
|
||||
terminalFontSize={14}
|
||||
onSave={(value, name) => { saved = value; savedName = name; }}
|
||||
onCancel={() => {}}
|
||||
/>
|
||||
</TooltipProvider>,
|
||||
);
|
||||
});
|
||||
await act(async () => {
|
||||
renderer!.root.findByType(HostNotesEditor).props.onChange(" Updated note ");
|
||||
if (newName) {
|
||||
renderer!.root.findAllByType(Input)[0].props.onChange({ target: { value: newName } });
|
||||
}
|
||||
});
|
||||
await act(async () => {
|
||||
renderer!.root.findByType(AsidePanel).props.actions.props.onClick();
|
||||
});
|
||||
assert.ok(saved, "the actual panel save handler must emit a config");
|
||||
return { saved, savedName };
|
||||
} finally {
|
||||
if (renderer) await act(async () => { renderer!.unmount(); });
|
||||
globalThis.requestAnimationFrame = previousRaf;
|
||||
globalThis.cancelAnimationFrame = previousCancelRaf;
|
||||
dom.cleanup();
|
||||
environment.IS_REACT_ACT_ENVIRONMENT = previous;
|
||||
}
|
||||
}
|
||||
|
||||
test("editing group notes retains its manual position through write normalization", async () => {
|
||||
const original = { path: "Fleet-0999", order: 1000 };
|
||||
const sibling = { path: "Fleet-0998", order: 2000 };
|
||||
const { saved, savedName } = await saveGroup(original);
|
||||
assert.deepEqual(saved, { ...original, notes: "Updated note" });
|
||||
assert.equal(savedName, undefined);
|
||||
// VaultView removes the old config and appends the panel's replacement.
|
||||
const written = normalizeVaultOrder([sibling, saved]);
|
||||
assert.deepEqual(sortByVaultOrder(written).map((config) => config.path), [original.path, sibling.path]);
|
||||
assert.deepEqual(original, { path: "Fleet-0999", order: 1000 });
|
||||
});
|
||||
|
||||
test("renaming a group in the details panel retains its manual position", async () => {
|
||||
const { saved, savedName } = await saveGroup({ path: "parent/old", order: 2500 }, "renamed");
|
||||
assert.equal(saved.path, "parent/renamed");
|
||||
assert.equal(saved.order, 2500);
|
||||
assert.equal(savedName, "renamed");
|
||||
});
|
||||
|
||||
test("group saves preserve zero and negative manual order values", async () => {
|
||||
for (const order of [0, -1000]) {
|
||||
const { saved } = await saveGroup({ path: "group", order });
|
||||
assert.equal(saved.order, order);
|
||||
}
|
||||
});
|
||||
|
||||
test("saving a new or unordered group leaves order assignment to the write path", async () => {
|
||||
for (const config of [undefined, { path: "existing-unordered" }]) {
|
||||
const { saved } = await saveGroup(config);
|
||||
assert.equal(Object.hasOwn(saved, "order"), false);
|
||||
assert.equal(normalizeVaultOrder([saved])[0].order, 1000);
|
||||
}
|
||||
});
|
||||
@@ -559,8 +559,6 @@ const GroupDetailsPanel: React.FC<GroupDetailsPanelPropsWithResize> = ({
|
||||
|
||||
const result: GroupConfig = {
|
||||
path: newPath,
|
||||
// Editing defaults or notes must not change the group's manual position.
|
||||
...(config?.order !== undefined && { order: config.order }),
|
||||
notes: form.notes?.trim() || undefined,
|
||||
// Only include SSH fields if SSH section is enabled
|
||||
...(sshEnabled && {
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -8,7 +8,6 @@ import {
|
||||
Search,
|
||||
Terminal,
|
||||
TerminalSquare,
|
||||
Zap,
|
||||
} from "lucide-react";
|
||||
import React, { memo, useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useI18n } from "../application/i18n/I18nProvider";
|
||||
@@ -610,8 +609,8 @@ const QuickSwitcherInner: React.FC<QuickSwitcherProps> = ({
|
||||
className={rowClass}
|
||||
onClick={() => handleItemSelect(item)}
|
||||
>
|
||||
<div className="shrink-0 flex items-center justify-center bg-primary text-primary-foreground h-5 w-5 rounded-[5px]">
|
||||
<Zap className="h-3 w-3" fill="currentColor" />
|
||||
<div className="flex h-6 w-6 shrink-0 items-center justify-center rounded text-muted-foreground">
|
||||
<Terminal size={16} />
|
||||
</div>
|
||||
<span className="min-w-0 truncate text-sm font-medium">{item.title}</span>
|
||||
</div>
|
||||
|
||||
@@ -270,7 +270,7 @@ const SettingsSyncTabWithVault: React.FC<{ onSettingsApplied?: () => void }> = (
|
||||
groupConfigs,
|
||||
importDataFromString,
|
||||
clearVaultData,
|
||||
} = useVaultState({ syncConnectionLogTerminalData: false });
|
||||
} = useVaultState();
|
||||
|
||||
const { rules: portForwardingRules, importRules: importPortForwardingRules } = usePortForwardingState();
|
||||
|
||||
|
||||
@@ -286,7 +286,7 @@ function TerminalPopupPageInner({
|
||||
groupConfigs,
|
||||
updateKnownHosts,
|
||||
deleteSelectedSnippets,
|
||||
} = useVaultState({ syncConnectionLogTerminalData: false });
|
||||
} = useVaultState();
|
||||
const [config, setConfig] = useState<TerminalPopupPayload | null>(null);
|
||||
const [terminalReady, setTerminalReady] = useState(false);
|
||||
const [startupError, setStartupError] = useState<string | null>(null);
|
||||
|
||||
@@ -148,7 +148,7 @@ const TrayPanelContent: React.FC = () => {
|
||||
onTrayPanelMenuData,
|
||||
} = useTrayPanelBackend();
|
||||
|
||||
const { hosts, knownHosts, updateKnownHosts } = useVaultState({ syncConnectionLogTerminalData: false });
|
||||
const { hosts, knownHosts, updateKnownHosts } = useVaultState();
|
||||
// TrayPanel runs in its own BrowserWindow, so this hook's session state is
|
||||
// independent from (and typically empty compared to) the main App's — it's
|
||||
// used here only for its storage-sync side effects, never for closeSession.
|
||||
|
||||
@@ -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;/);
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import React from "react";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
|
||||
import { AsidePanel, invokeAsideActionMenuItemClick } from "./ui/aside-panel.tsx";
|
||||
import { invokeAsideActionMenuItemClick } from "./ui/aside-panel.tsx";
|
||||
|
||||
test("AsideActionMenuItem closes its menu before running the action", () => {
|
||||
const events: string[] = [];
|
||||
@@ -23,12 +21,3 @@ test("AsideActionMenuItem still closes when no action is provided", () => {
|
||||
|
||||
assert.deepEqual(events, ["close"]);
|
||||
});
|
||||
|
||||
test("overlay aside panels ask native views to yield; inline panels do not", () => {
|
||||
const render = (layout: "overlay" | "inline") => renderToStaticMarkup(
|
||||
React.createElement(AsidePanel, { open: true, onClose: () => {}, layout, children: "content" }),
|
||||
);
|
||||
|
||||
assert.match(render("overlay"), /data-native-view-occluder=""/);
|
||||
assert.doesNotMatch(render("inline"), /data-native-view-occluder/);
|
||||
});
|
||||
@@ -51,7 +51,6 @@ import { CopilotCliCard } from "./ai/CopilotCliCard";
|
||||
import { CodebuddyCard } from "./ai/CodebuddyCard";
|
||||
import { SafetySettings } from "./ai/SafetySettings";
|
||||
import { ExternalMcpCard } from "./ai/ExternalMcpCard";
|
||||
import { AIPrivacySection } from "./ai/AIPrivacySection";
|
||||
import { ToolAccessGuidance } from "./ai/ToolAccessGuidance";
|
||||
import { PermissionGrantsSettings } from "./ai/PermissionGrantsSettings";
|
||||
import { useAIPermissionGrantsState } from "../../../application/state/useAIPermissionGrantsState";
|
||||
@@ -1298,8 +1297,6 @@ const SettingsAITab: React.FC<SettingsAITabProps> = ({
|
||||
</SettingCard>
|
||||
</SettingsSection>
|
||||
|
||||
<AIPrivacySection />
|
||||
|
||||
<SettingsSection anchorId="ai-external-mcp" title={t('ai.externalMcp.title')}>
|
||||
<ExternalMcpCard />
|
||||
</SettingsSection>
|
||||
|
||||
@@ -1,183 +0,0 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import test from 'node:test';
|
||||
import React from 'react';
|
||||
import { act, create, type ReactTestRenderer } from 'react-test-renderer';
|
||||
import { I18nProvider } from '../../../../application/i18n/I18nProvider';
|
||||
import { STORAGE_KEY_AI_PRIVACY } from '../../../../infrastructure/config/storageKeys';
|
||||
import { AIPrivacySection } from './AIPrivacySection';
|
||||
|
||||
function installEnvironment(setConfig: (config: unknown) => Promise<unknown>) {
|
||||
const values = new Map<string, string>();
|
||||
const events = new EventTarget();
|
||||
const globals = {
|
||||
IS_REACT_ACT_ENVIRONMENT: true,
|
||||
localStorage: {
|
||||
getItem: (key: string) => values.get(key) ?? null,
|
||||
setItem: (key: string, value: string) => values.set(key, value),
|
||||
},
|
||||
window: {
|
||||
netcatty: { externalMcpSetConfig: setConfig },
|
||||
addEventListener: events.addEventListener.bind(events),
|
||||
removeEventListener: events.removeEventListener.bind(events),
|
||||
dispatchEvent: events.dispatchEvent.bind(events),
|
||||
},
|
||||
};
|
||||
const previous = new Map<string, PropertyDescriptor | undefined>();
|
||||
for (const [name, value] of Object.entries(globals)) {
|
||||
previous.set(name, Object.getOwnPropertyDescriptor(globalThis, name));
|
||||
Object.defineProperty(globalThis, name, { value, configurable: true, writable: true });
|
||||
}
|
||||
return {
|
||||
values,
|
||||
restore: () => {
|
||||
for (const [name, descriptor] of previous) {
|
||||
if (descriptor) Object.defineProperty(globalThis, name, descriptor);
|
||||
else Reflect.deleteProperty(globalThis, name);
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function view() {
|
||||
return <I18nProvider locale="en"><AIPrivacySection /></I18nProvider>;
|
||||
}
|
||||
|
||||
test('privacy settings default off and explain their limits and connection scope', async () => {
|
||||
const env = installEnvironment(async () => ({ ok: true }));
|
||||
let renderer: ReactTestRenderer | undefined;
|
||||
try {
|
||||
await act(async () => { renderer = create(view()); });
|
||||
const markup = JSON.stringify(renderer!.toJSON());
|
||||
assert.equal(renderer!.root.findAllByType('button').filter((node) => node.props['aria-checked'] === false).length, 2);
|
||||
assert.match(markup, /cannot detect every secret/);
|
||||
assert.match(markup, /AI privacy/);
|
||||
assert.match(markup, /Catty, integrated agents and external MCP clients/);
|
||||
assert.match(markup, /Only Netcatty-managed data and tools are covered/);
|
||||
assert.match(markup, /native tools, direct file access and other MCP servers/);
|
||||
assert.match(markup, /Binary tool responses are blocked/);
|
||||
assert.match(markup, /one managed conversation or external MCP connection/);
|
||||
assert.match(markup, /reconnect external MCP clients/);
|
||||
assert.match(markup, /start a new managed conversation/);
|
||||
assert.match(markup, /Additional IP ranges/);
|
||||
assert.match(markup, /Custom regular expressions/);
|
||||
} finally {
|
||||
if (renderer) await act(async () => { renderer!.unmount(); });
|
||||
env.restore();
|
||||
}
|
||||
});
|
||||
|
||||
test('AI privacy is shared Tool Access UI, not nested under External MCP', async () => {
|
||||
const settingsTab = await readFile(new URL('../SettingsAITab.tsx', import.meta.url), 'utf8');
|
||||
const externalCard = await readFile(new URL('./ExternalMcpCard.tsx', import.meta.url), 'utf8');
|
||||
const toolsTab = settingsTab.split('<TabsContent value="tools"')[1].split('</TabsContent>')[0];
|
||||
assert.match(toolsTab, /<\/SettingsSection>\s*<AIPrivacySection \/>\s*<SettingsSection anchorId="ai-external-mcp"/);
|
||||
assert.doesNotMatch(externalCard, /AIPrivacySection|ExternalMcpRedactionSection/);
|
||||
});
|
||||
|
||||
test('privacy drafts apply together, retain rejected input and persist only validated settings', async () => {
|
||||
const calls: unknown[] = [];
|
||||
let accepted = false;
|
||||
const env = installEnvironment(async (config) => {
|
||||
calls.push(config);
|
||||
return accepted ? { ok: true } : { ok: false, error: 'Invalid privacy rules.' };
|
||||
});
|
||||
let renderer: ReactTestRenderer | undefined;
|
||||
try {
|
||||
await act(async () => { renderer = create(view()); });
|
||||
const root = renderer!.root;
|
||||
const privacySwitch = () => root.findAllByType('button').find((node) => (
|
||||
node.props['aria-label'] === 'Enable text redaction'
|
||||
))!;
|
||||
const apply = () => root.findAllByType('button').find((node) => node.props.children === 'Apply')!;
|
||||
await act(async () => { privacySwitch().props.onClick(); });
|
||||
await act(async () => {
|
||||
root.findAllByType('textarea')[0].props.onChange({ currentTarget: { value: '192.0.2.0/24\n\n2001:db8::/32' } });
|
||||
root.findAllByType('textarea')[1].props.onChange({ currentTarget: { value: '[' } });
|
||||
});
|
||||
assert.equal(calls.length, 0, 'draft edits must not change runtime or storage');
|
||||
assert.equal(env.values.get(STORAGE_KEY_AI_PRIVACY), undefined);
|
||||
await act(async () => { apply().props.onClick(); });
|
||||
assert.equal(root.findAllByType('textarea')[1].props.value, '[');
|
||||
assert.match(root.findByProps({ role: 'alert' }).children.join(''), /Invalid privacy rules/);
|
||||
assert.equal(env.values.get(STORAGE_KEY_AI_PRIVACY), undefined);
|
||||
|
||||
accepted = true;
|
||||
await act(async () => {
|
||||
root.findAllByType('textarea')[1].props.onChange({ currentTarget: { value: 'INTERNAL_[A-Z0-9]+' } });
|
||||
});
|
||||
await act(async () => { apply().props.onClick(); });
|
||||
const saved = JSON.parse(env.values.get(STORAGE_KEY_AI_PRIVACY)!);
|
||||
assert.deepEqual(saved, {
|
||||
enabled: true,
|
||||
redactPrivateIps: false,
|
||||
cidrs: ['192.0.2.0/24', '2001:db8::/32'],
|
||||
patterns: ['INTERNAL_[A-Z0-9]+'],
|
||||
});
|
||||
assert.deepEqual(calls[1], { redaction: saved });
|
||||
assert.equal(root.findAllByProps({ role: 'alert' }).length, 0);
|
||||
assert.match(root.findByProps({ role: 'status' }).children.join(''), /new managed conversation/);
|
||||
} finally {
|
||||
if (renderer) await act(async () => { renderer!.unmount(); });
|
||||
env.restore();
|
||||
}
|
||||
});
|
||||
|
||||
test('malformed saved privacy settings remain visible as a recoverable error', async () => {
|
||||
const env = installEnvironment(async () => ({ ok: true }));
|
||||
let renderer: ReactTestRenderer | undefined;
|
||||
try {
|
||||
env.values.set(STORAGE_KEY_AI_PRIVACY, '{');
|
||||
await act(async () => { renderer = create(view()); });
|
||||
assert.match(renderer!.root.findByProps({ role: 'alert' }).children.join(''), /Invalid saved AI privacy settings/);
|
||||
assert.ok(renderer!.root.findAllByType('button').some((node) => node.props.children === 'Apply'));
|
||||
assert.equal(env.values.get(STORAGE_KEY_AI_PRIVACY), '{');
|
||||
} finally {
|
||||
if (renderer) await act(async () => { renderer!.unmount(); });
|
||||
env.restore();
|
||||
}
|
||||
});
|
||||
|
||||
test('privacy apply disables draft controls until shared validation settles', async () => {
|
||||
let resolveConfig!: (result: unknown) => void;
|
||||
const env = installEnvironment(() => new Promise((resolve) => { resolveConfig = resolve; }));
|
||||
let renderer: ReactTestRenderer | undefined;
|
||||
try {
|
||||
await act(async () => { renderer = create(view()); });
|
||||
const root = renderer!.root;
|
||||
await act(async () => {
|
||||
root.findAllByType('button').find((node) => node.props['aria-label'] === 'Enable text redaction')!.props.onClick();
|
||||
});
|
||||
await act(async () => {
|
||||
root.findAllByType('button').find((node) => node.props.children === 'Apply')!.props.onClick();
|
||||
});
|
||||
assert.ok(root.findAllByType('button').every((node) => node.props.disabled));
|
||||
assert.ok(root.findAllByType('textarea').every((node) => node.props.disabled));
|
||||
assert.equal(env.values.get(STORAGE_KEY_AI_PRIVACY), undefined);
|
||||
await act(async () => { resolveConfig({ ok: true }); });
|
||||
assert.ok(root.findAllByType('button').every((node) => !node.props.disabled));
|
||||
assert.equal(JSON.parse(env.values.get(STORAGE_KEY_AI_PRIVACY)!).enabled, true);
|
||||
} finally {
|
||||
if (renderer) await act(async () => { renderer!.unmount(); });
|
||||
env.restore();
|
||||
}
|
||||
});
|
||||
|
||||
test('shared privacy controls refresh when another window changes the shared preference', async () => {
|
||||
const env = installEnvironment(async () => ({ ok: true }));
|
||||
let renderer: ReactTestRenderer | undefined;
|
||||
try {
|
||||
await act(async () => { renderer = create(view()); });
|
||||
const settings = { enabled: true, redactPrivateIps: true, cidrs: ['192.0.2.0/24'], patterns: ['INTERNAL_.*'] };
|
||||
env.values.set(STORAGE_KEY_AI_PRIVACY, JSON.stringify(settings));
|
||||
const event = new Event('storage');
|
||||
Object.defineProperty(event, 'key', { value: STORAGE_KEY_AI_PRIVACY });
|
||||
await act(async () => { window.dispatchEvent(event); });
|
||||
assert.equal(renderer!.root.findAllByType('button').filter((node) => node.props['aria-checked'] === true).length, 2);
|
||||
assert.equal(renderer!.root.findAllByType('textarea')[0].props.value, '192.0.2.0/24');
|
||||
assert.equal(renderer!.root.findAllByType('textarea')[1].props.value, 'INTERNAL_.*');
|
||||
} finally {
|
||||
if (renderer) await act(async () => { renderer!.unmount(); });
|
||||
env.restore();
|
||||
}
|
||||
});
|
||||
@@ -1,105 +0,0 @@
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { useI18n } from "../../../../application/i18n/I18nProvider";
|
||||
import { useAIPrivacyState } from "../../../../application/state/useExternalMcpToggleState";
|
||||
import { Button } from "../../../ui/button";
|
||||
import { SettingCard, SettingRow, SettingsSection, Toggle } from "../../settings-ui";
|
||||
|
||||
export const AIPrivacySection: React.FC = () => {
|
||||
const { t } = useI18n();
|
||||
const { settings, error, applying, apply } = useAIPrivacyState();
|
||||
const [enabled, setEnabled] = useState(settings.enabled);
|
||||
const [privateIps, setPrivateIps] = useState(settings.redactPrivateIps);
|
||||
const [cidrs, setCidrs] = useState(settings.cidrs.join("\n"));
|
||||
const [patterns, setPatterns] = useState(settings.patterns.join("\n"));
|
||||
const [applied, setApplied] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setEnabled(settings.enabled);
|
||||
setPrivateIps(settings.redactPrivateIps);
|
||||
setCidrs(settings.cidrs.join("\n"));
|
||||
setPatterns(settings.patterns.join("\n"));
|
||||
}, [settings]);
|
||||
|
||||
const onApply = async () => {
|
||||
setApplied(false);
|
||||
const saved = await apply({
|
||||
enabled,
|
||||
redactPrivateIps: privateIps,
|
||||
cidrs: cidrs.split(/\r?\n/).map((line) => line.trim()).filter(Boolean),
|
||||
patterns: patterns.split(/\r?\n/).filter((line) => line.trim().length > 0),
|
||||
});
|
||||
setApplied(saved);
|
||||
};
|
||||
|
||||
return (
|
||||
<SettingsSection anchorId="ai-privacy" title={t("ai.privacy.title")}>
|
||||
<p className="text-xs text-muted-foreground leading-5">{t("ai.privacy.description")}</p>
|
||||
<p className="text-xs text-muted-foreground leading-5">{t("ai.privacy.boundary")}</p>
|
||||
<SettingCard divided className="rounded-md border-border/60 bg-background/70">
|
||||
<SettingRow label={t("ai.privacy.enable")}>
|
||||
<Toggle
|
||||
checked={enabled}
|
||||
onChange={(next) => { setEnabled(next); setApplied(false); }}
|
||||
ariaLabel={t("ai.privacy.enable")}
|
||||
disabled={applying}
|
||||
/>
|
||||
</SettingRow>
|
||||
<SettingRow
|
||||
label={t("ai.privacy.privateIps")}
|
||||
description={t("ai.privacy.privateIps.description")}
|
||||
>
|
||||
<Toggle
|
||||
checked={privateIps}
|
||||
onChange={(next) => { setPrivateIps(next); setApplied(false); }}
|
||||
ariaLabel={t("ai.privacy.privateIps")}
|
||||
disabled={applying}
|
||||
/>
|
||||
</SettingRow>
|
||||
</SettingCard>
|
||||
<div className="space-y-3 rounded-md border border-border/60 bg-background/50 p-3">
|
||||
<label className="block space-y-1.5">
|
||||
<span className="text-xs font-medium">{t("ai.privacy.cidrs")}</span>
|
||||
<span className="block text-xs text-muted-foreground">{t("ai.privacy.cidrs.description")}</span>
|
||||
<textarea
|
||||
value={cidrs}
|
||||
onChange={(event) => { setCidrs(event.currentTarget.value); setApplied(false); }}
|
||||
aria-label={t("ai.privacy.cidrs")}
|
||||
placeholder={"192.0.2.0/24\n2001:db8::/32"}
|
||||
rows={3}
|
||||
spellCheck={false}
|
||||
disabled={applying}
|
||||
className="w-full resize-y rounded-md border border-border/60 bg-background px-2 py-1.5 font-mono text-xs focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
||||
/>
|
||||
</label>
|
||||
<label className="block space-y-1.5">
|
||||
<span className="text-xs font-medium">{t("ai.privacy.patterns")}</span>
|
||||
<span className="block text-xs text-muted-foreground">{t("ai.privacy.patterns.description")}</span>
|
||||
<textarea
|
||||
value={patterns}
|
||||
onChange={(event) => { setPatterns(event.currentTarget.value); setApplied(false); }}
|
||||
aria-label={t("ai.privacy.patterns")}
|
||||
placeholder="INTERNAL_[A-Z0-9]+"
|
||||
rows={3}
|
||||
spellCheck={false}
|
||||
disabled={applying}
|
||||
className="w-full resize-y rounded-md border border-border/60 bg-background px-2 py-1.5 font-mono text-xs focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
||||
/>
|
||||
</label>
|
||||
<p className="text-xs text-muted-foreground leading-5">{t("ai.privacy.connectionScope")}</p>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<p className="text-xs text-amber-500">{t("ai.privacy.reconnect")}</p>
|
||||
<Button size="sm" variant="outline" onClick={() => { void onApply(); }} disabled={applying}>
|
||||
{t(applying ? "ai.privacy.applying" : "ai.privacy.apply")}
|
||||
</Button>
|
||||
</div>
|
||||
{error ? (
|
||||
<p role="alert" className="text-xs text-destructive">
|
||||
{t("ai.privacy.failed")} {error}
|
||||
</p>
|
||||
) : applied ? (
|
||||
<p role="status" className="text-xs text-emerald-500">{t("ai.privacy.applied")}</p>
|
||||
) : null}
|
||||
</div>
|
||||
</SettingsSection>
|
||||
);
|
||||
};
|
||||
@@ -252,7 +252,7 @@ export function buildCursorSnippet(launcherPath: string, discoveryPath?: string
|
||||
|
||||
export const ExternalMcpCard: React.FC = () => {
|
||||
const { t } = useI18n();
|
||||
const { enabled, setEnabled, error: configError } = useExternalMcpToggleState();
|
||||
const { enabled, setEnabled } = useExternalMcpToggleState();
|
||||
const [mode, setModeRaw] = useState<ExternalMcpMode>(() => readExternalMcpMode());
|
||||
const [idleTimeoutMinutes, setIdleTimeoutRaw] = useState<number>(() => readExternalMcpIdleTimeoutMinutes());
|
||||
const [focusOnHostOpen, setFocusOnHostOpenRaw] = useState<boolean>(() => readExternalMcpFocusOnHostOpen());
|
||||
@@ -681,7 +681,8 @@ export const ExternalMcpCard: React.FC = () => {
|
||||
checked={enabled}
|
||||
onChange={(nextEnabled) => {
|
||||
setActionMessage(null);
|
||||
void setEnabled(nextEnabled).then(() => refreshStatus());
|
||||
setEnabled(nextEnabled);
|
||||
window.setTimeout(() => { void refreshStatus(); }, 0);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
@@ -988,8 +989,8 @@ export const ExternalMcpCard: React.FC = () => {
|
||||
{actionMessage.text}
|
||||
</div>
|
||||
) : null}
|
||||
{configError || status?.error ? (
|
||||
<div role="alert" className="text-xs text-destructive">{configError || status?.error}</div>
|
||||
{status?.error ? (
|
||||
<div className="text-xs text-destructive">{status.error}</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -177,7 +177,6 @@ export interface NetcattyAiBridge {
|
||||
mode?: 'temporary' | 'persistent';
|
||||
idleTimeoutMinutes?: number;
|
||||
sessionIdleTimeoutMinutes?: number;
|
||||
redaction?: import("../../../../application/state/useExternalMcpToggleState").ExternalMcpRedactionSettings;
|
||||
httpEnabled?: boolean;
|
||||
httpPort?: number;
|
||||
httpAllowCustomBind?: boolean;
|
||||
|
||||
@@ -7,8 +7,7 @@ import type { TerminalSession } from '../../types';
|
||||
import type { DockerContainerAction, DockerContainerInfo, DockerStatInfo, TerminalPopupIcon } from '../../domain/systemManager/types';
|
||||
import { dockerContainerInfoEqual } from '../../domain/systemManager/pollEquals';
|
||||
import { getContainerFlags, getContainerTone } from '../../domain/systemManager/containerState';
|
||||
import type { ContainerRuntimeId } from '../../domain/systemManager/containerRuntime';
|
||||
import { buildContainerExecShellCommand, buildContainerLogsCommand, describeRuntimeFailure } from '../../domain/systemManager/containerRuntime';
|
||||
import { buildDockerExecShellCommand, buildDockerLogsCommand } from '../../domain/systemManager/dockerShell';
|
||||
import { DockerContainerDetail } from './DockerContainerDetail';
|
||||
import { DockerImageIcon } from './DockerImageIcon';
|
||||
import { useStableListOrder, mergePollListByKey } from './listStable';
|
||||
@@ -60,7 +59,6 @@ async function buildContainerPopupIcon(image: string): Promise<TerminalPopupIcon
|
||||
interface DockerContainersPanelProps {
|
||||
sessionId: string;
|
||||
parentSession: TerminalSession;
|
||||
runtime?: ContainerRuntimeId;
|
||||
isVisible: boolean;
|
||||
warmupEnabled?: boolean;
|
||||
backend: Backend;
|
||||
@@ -159,7 +157,6 @@ const DockerContainerRow = memo(function DockerContainerRow({
|
||||
export const DockerContainersPanel = memo(function DockerContainersPanel({
|
||||
sessionId,
|
||||
parentSession,
|
||||
runtime = 'docker',
|
||||
isVisible,
|
||||
warmupEnabled = false,
|
||||
backend,
|
||||
@@ -182,15 +179,15 @@ export const DockerContainersPanel = memo(function DockerContainersPanel({
|
||||
setConfirmAction(null);
|
||||
setPendingAction(null);
|
||||
setSelectedId(null);
|
||||
}, [runtime, sessionId]);
|
||||
}, [sessionId]);
|
||||
|
||||
const containersFetcher = useCallback(async () => {
|
||||
const result = await backend.listDockerContainers(sessionId, runtime);
|
||||
const result = await backend.listDockerContainers(sessionId);
|
||||
if (!result.success || !result.containers) {
|
||||
throw new Error(describeRuntimeFailure(result, stableT, stableT('systemManager.errors.loadDocker')));
|
||||
throw new Error(result.error || stableT('systemManager.errors.loadDocker'));
|
||||
}
|
||||
return result.containers;
|
||||
}, [backend, runtime, sessionId, stableT]);
|
||||
}, [backend, sessionId, stableT]);
|
||||
|
||||
const listIntervalMs = Math.max(3, listRefreshIntervalSec) * 1000;
|
||||
const { data: containers, error, loading, refresh } = usePolling<DockerContainerInfo[]>(
|
||||
@@ -198,7 +195,7 @@ export const DockerContainersPanel = memo(function DockerContainersPanel({
|
||||
listIntervalMs,
|
||||
isVisible || warmupEnabled,
|
||||
(prev, next) => mergePollListByKey(prev, next, (c) => c.id, dockerContainerInfoEqual),
|
||||
{ poll: isVisible, resetKey: `${sessionId}:${runtime}` },
|
||||
{ poll: isVisible, resetKey: sessionId },
|
||||
);
|
||||
|
||||
const matched = useMemo<DockerContainerInfo[]>(() => {
|
||||
@@ -243,12 +240,12 @@ export const DockerContainersPanel = memo(function DockerContainersPanel({
|
||||
);
|
||||
const statsFetcher = useCallback(async () => {
|
||||
if (statContainerIds.length === 0) return [];
|
||||
const result = await backend.getDockerStats({ sessionId, ids: statContainerIds, runtime });
|
||||
const result = await backend.getDockerStats({ sessionId, ids: statContainerIds });
|
||||
if (!result.success || !result.stats) {
|
||||
throw new Error(describeRuntimeFailure(result, stableT, stableT('systemManager.errors.loadDockerStats')));
|
||||
throw new Error(result.error || stableT('systemManager.errors.loadDockerStats'));
|
||||
}
|
||||
return result.stats;
|
||||
}, [backend, runtime, sessionId, stableT, statContainerIds]);
|
||||
}, [backend, sessionId, stableT, statContainerIds]);
|
||||
|
||||
const statsIntervalMs = Math.max(2, statsRefreshIntervalSec) * 1000;
|
||||
const { data: stats, loading: statsLoading } = usePolling<DockerStatInfo[]>(
|
||||
@@ -269,19 +266,18 @@ export const DockerContainersPanel = memo(function DockerContainersPanel({
|
||||
}, [stats]);
|
||||
|
||||
const getContainerInspectKey = useCallback((container: DockerContainerInfo) => (
|
||||
`${sessionId}:${runtime}:${container.id}`
|
||||
), [runtime, sessionId]);
|
||||
`${sessionId}:${container.id}`
|
||||
), [sessionId]);
|
||||
const fetchContainerInspect = useCallback(async (container: DockerContainerInfo) => {
|
||||
const result = await backend.dockerInspect({
|
||||
sessionId,
|
||||
containerId: container.id.slice(0, 12),
|
||||
runtime,
|
||||
});
|
||||
if (!result.success) {
|
||||
throw new Error(describeRuntimeFailure(result, stableT, stableT('systemManager.errors.actionFailed')));
|
||||
throw new Error(result.error || stableT('systemManager.errors.actionFailed'));
|
||||
}
|
||||
return result.inspect ?? null;
|
||||
}, [backend, runtime, sessionId, stableT]);
|
||||
}, [backend, sessionId, stableT]);
|
||||
const {
|
||||
records: inspectByContainerId,
|
||||
loadRecord: loadContainerInspect,
|
||||
@@ -309,12 +305,9 @@ export const DockerContainersPanel = memo(function DockerContainersPanel({
|
||||
}
|
||||
setPendingAction({ id: containerId, action });
|
||||
try {
|
||||
const result = await backend.dockerAction({ sessionId, containerId, action, newName, runtime });
|
||||
const result = await backend.dockerAction({ sessionId, containerId, action, newName });
|
||||
if (!result.success) {
|
||||
showSystemManagerError(
|
||||
describeRuntimeFailure(result, t, t('systemManager.errors.actionFailed')),
|
||||
t('common.error'),
|
||||
);
|
||||
showSystemManagerError(result.error || t('systemManager.errors.actionFailed'), t('common.error'));
|
||||
return;
|
||||
}
|
||||
const affectedContainer = (containers ?? []).find((container) => (
|
||||
@@ -339,7 +332,6 @@ export const DockerContainersPanel = memo(function DockerContainersPanel({
|
||||
invalidateContainerInspectMatching,
|
||||
refresh,
|
||||
refreshContainerInspect,
|
||||
runtime,
|
||||
sessionId,
|
||||
t,
|
||||
]);
|
||||
@@ -368,7 +360,7 @@ export const DockerContainersPanel = memo(function DockerContainersPanel({
|
||||
backend,
|
||||
parentSession,
|
||||
`docker: ${container.name || id}`,
|
||||
buildContainerExecShellCommand(runtime, id, parentSession.shellType),
|
||||
buildDockerExecShellCommand(id),
|
||||
{ icon: await buildContainerPopupIcon(container.image) },
|
||||
);
|
||||
if (!result.success) {
|
||||
@@ -380,7 +372,7 @@ export const DockerContainersPanel = memo(function DockerContainersPanel({
|
||||
});
|
||||
showSystemManagerError(result.error || t('systemManager.errors.actionFailed'), t('common.error'));
|
||||
}
|
||||
}, [backend, parentSession, runtime, sessionId, t]);
|
||||
}, [backend, parentSession, sessionId, t]);
|
||||
|
||||
const openLogs = useCallback(async (container: DockerContainerInfo) => {
|
||||
const id = container.id.slice(0, 12);
|
||||
@@ -395,7 +387,7 @@ export const DockerContainersPanel = memo(function DockerContainersPanel({
|
||||
backend,
|
||||
parentSession,
|
||||
`logs: ${container.name || id}`,
|
||||
buildContainerLogsCommand(runtime, id, parentSession.shellType),
|
||||
buildDockerLogsCommand(id),
|
||||
{ icon: await buildContainerPopupIcon(container.image) },
|
||||
);
|
||||
if (!result.success) {
|
||||
@@ -407,7 +399,7 @@ export const DockerContainersPanel = memo(function DockerContainersPanel({
|
||||
});
|
||||
showSystemManagerError(result.error || t('systemManager.errors.actionFailed'), t('common.error'));
|
||||
}
|
||||
}, [backend, parentSession, runtime, sessionId, t]);
|
||||
}, [backend, parentSession, sessionId, t]);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col flex-1 min-h-0 overflow-hidden" data-section="docker-containers">
|
||||
|
||||
@@ -2,7 +2,6 @@ import { Layers, Loader2, Tag, Trash2 } from 'lucide-react';
|
||||
import React, { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useI18n } from '../../application/i18n/I18nProvider';
|
||||
import type { useSystemManagerBackend } from '../../application/state/useSystemManagerBackend';
|
||||
import { describeRuntimeFailure, type ContainerRuntimeId } from '../../domain/systemManager/containerRuntime';
|
||||
import { dockerImageRowKey, type DockerImageInfo } from '../../domain/systemManager/types';
|
||||
import { dockerImageInfoEqual } from '../../domain/systemManager/pollEquals';
|
||||
import { DockerImageIcon } from './DockerImageIcon';
|
||||
@@ -36,7 +35,6 @@ type PendingImageConfirm =
|
||||
|
||||
interface DockerImagesPanelProps {
|
||||
sessionId: string;
|
||||
runtime?: ContainerRuntimeId;
|
||||
isVisible: boolean;
|
||||
warmupEnabled?: boolean;
|
||||
backend: Backend;
|
||||
@@ -91,7 +89,6 @@ const DockerImageRow = memo(function DockerImageRow({
|
||||
|
||||
export const DockerImagesPanel = memo(function DockerImagesPanel({
|
||||
sessionId,
|
||||
runtime = 'docker',
|
||||
isVisible,
|
||||
warmupEnabled = false,
|
||||
backend,
|
||||
@@ -114,15 +111,15 @@ export const DockerImagesPanel = memo(function DockerImagesPanel({
|
||||
// Clear busy so a hung/in-flight action from the previous session cannot
|
||||
// leave the new session's confirm dialog permanently disabled.
|
||||
setActionBusy(false);
|
||||
}, [runtime, sessionId]);
|
||||
}, [sessionId]);
|
||||
|
||||
const imagesFetcher = useCallback(async () => {
|
||||
const result = await backend.listDockerImages(sessionId, runtime);
|
||||
const result = await backend.listDockerImages(sessionId);
|
||||
if (!result.success || !result.images) {
|
||||
throw new Error(describeRuntimeFailure(result, stableT, stableT('systemManager.errors.loadDockerImages')));
|
||||
throw new Error(result.error || stableT('systemManager.errors.loadDockerImages'));
|
||||
}
|
||||
return result.images;
|
||||
}, [backend, runtime, sessionId, stableT]);
|
||||
}, [backend, sessionId, stableT]);
|
||||
|
||||
const listIntervalMs = Math.max(3, listRefreshIntervalSec) * 1000;
|
||||
const { data: images, error, loading, refresh } = usePolling<DockerImageInfo[]>(
|
||||
@@ -130,7 +127,7 @@ export const DockerImagesPanel = memo(function DockerImagesPanel({
|
||||
listIntervalMs,
|
||||
isVisible || warmupEnabled,
|
||||
(prev, next) => mergePollListByKey(prev, next, dockerImageRowKey, dockerImageInfoEqual),
|
||||
{ poll: isVisible, resetKey: `${sessionId}:${runtime}` },
|
||||
{ poll: isVisible, resetKey: sessionId },
|
||||
);
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
@@ -157,19 +154,18 @@ export const DockerImagesPanel = memo(function DockerImagesPanel({
|
||||
const displayList = useStableListOrder(filtered, dockerImageRowKey, query, compareImages);
|
||||
|
||||
const getImageInspectKey = useCallback((image: DockerImageInfo) => (
|
||||
`${sessionId}:${runtime}:${dockerImageRowKey(image)}`
|
||||
), [runtime, sessionId]);
|
||||
`${sessionId}:${dockerImageRowKey(image)}`
|
||||
), [sessionId]);
|
||||
const fetchImageInspect = useCallback(async (image: DockerImageInfo) => {
|
||||
const result = await backend.dockerImageInspect({
|
||||
sessionId,
|
||||
imageId: image.id.slice(0, 12),
|
||||
runtime,
|
||||
});
|
||||
if (!result.success) {
|
||||
throw new Error(describeRuntimeFailure(result, stableT, stableT('systemManager.errors.actionFailed')));
|
||||
throw new Error(result.error || stableT('systemManager.errors.actionFailed'));
|
||||
}
|
||||
return result.inspect ?? null;
|
||||
}, [backend, runtime, sessionId, stableT]);
|
||||
}, [backend, sessionId, stableT]);
|
||||
const {
|
||||
records: inspectByImageKey,
|
||||
loadRecord: loadImageInspect,
|
||||
@@ -188,26 +184,15 @@ export const DockerImagesPanel = memo(function DockerImagesPanel({
|
||||
const actionGeneration = actionGenerationRef.current;
|
||||
setActionBusy(true);
|
||||
try {
|
||||
const tagged = Boolean(
|
||||
image.repository
|
||||
&& image.tag
|
||||
&& image.repository !== '<none>'
|
||||
&& image.tag !== '<none>',
|
||||
);
|
||||
const result = await backend.dockerImageAction({
|
||||
sessionId,
|
||||
runtime,
|
||||
action: 'rm',
|
||||
imageId: image.id.slice(0, 12),
|
||||
force: !tagged,
|
||||
...(tagged ? { repository: image.repository, tag: image.tag } : {}),
|
||||
force: image.tag === '<none>',
|
||||
});
|
||||
if (actionGenerationRef.current !== actionGeneration) return;
|
||||
if (!result.success) {
|
||||
showSystemManagerError(
|
||||
describeRuntimeFailure(result, t, t('systemManager.errors.actionFailed')),
|
||||
t('common.error'),
|
||||
);
|
||||
showSystemManagerError(result.error || t('systemManager.errors.actionFailed'), t('common.error'));
|
||||
return;
|
||||
}
|
||||
if (selectedId === dockerImageRowKey(image)) {
|
||||
@@ -220,7 +205,7 @@ export const DockerImagesPanel = memo(function DockerImagesPanel({
|
||||
setActionBusy(false);
|
||||
}
|
||||
}
|
||||
}, [backend, getImageInspectKey, invalidateImageInspect, refresh, runtime, selectedId, sessionId, t]);
|
||||
}, [backend, getImageInspectKey, invalidateImageInspect, refresh, selectedId, sessionId, t]);
|
||||
|
||||
const handleRemove = useCallback((image: DockerImageInfo) => {
|
||||
const label = image.name || image.id.slice(0, 12);
|
||||
@@ -231,13 +216,10 @@ export const DockerImagesPanel = memo(function DockerImagesPanel({
|
||||
const actionGeneration = actionGenerationRef.current;
|
||||
setActionBusy(true);
|
||||
try {
|
||||
const result = await backend.dockerImageAction({ sessionId, runtime, action: 'prune', all });
|
||||
const result = await backend.dockerImageAction({ sessionId, action: 'prune', all });
|
||||
if (actionGenerationRef.current !== actionGeneration) return;
|
||||
if (!result.success) {
|
||||
showSystemManagerError(
|
||||
describeRuntimeFailure(result, t, t('systemManager.errors.actionFailed')),
|
||||
t('common.error'),
|
||||
);
|
||||
showSystemManagerError(result.error || t('systemManager.errors.actionFailed'), t('common.error'));
|
||||
return;
|
||||
}
|
||||
await refresh();
|
||||
@@ -246,7 +228,7 @@ export const DockerImagesPanel = memo(function DockerImagesPanel({
|
||||
setActionBusy(false);
|
||||
}
|
||||
}
|
||||
}, [backend, refresh, runtime, sessionId, t]);
|
||||
}, [backend, refresh, sessionId, t]);
|
||||
|
||||
const handlePrune = useCallback((all: boolean) => {
|
||||
setConfirmTarget({ kind: 'prune', all });
|
||||
@@ -255,17 +237,13 @@ export const DockerImagesPanel = memo(function DockerImagesPanel({
|
||||
const handleTagSubmit = async (image: DockerImageInfo, repository: string, tag: string) => {
|
||||
const result = await backend.dockerImageAction({
|
||||
sessionId,
|
||||
runtime,
|
||||
action: 'tag',
|
||||
imageId: image.id.slice(0, 12),
|
||||
repository,
|
||||
tag: tag || 'latest',
|
||||
});
|
||||
if (!result.success) {
|
||||
showSystemManagerError(
|
||||
describeRuntimeFailure(result, t, t('systemManager.errors.actionFailed')),
|
||||
t('common.error'),
|
||||
);
|
||||
showSystemManagerError(result.error || t('systemManager.errors.actionFailed'), t('common.error'));
|
||||
return;
|
||||
}
|
||||
await refresh();
|
||||
|
||||
@@ -1,32 +1,19 @@
|
||||
import { Box, Layers } from 'lucide-react';
|
||||
import React, { memo, useState } from 'react';
|
||||
import { useI18n } from '../../application/i18n/I18nProvider';
|
||||
import { useContainerRuntimeSelection } from '../../application/state/systemManager/useContainerRuntimeSelection';
|
||||
import type { useSystemManagerBackend } from '../../application/state/useSystemManagerBackend';
|
||||
import type { ContainerSourceId } from '../../domain/systemManager/containerRuntime';
|
||||
import type { SessionCapabilities } from '../../domain/systemManager/types';
|
||||
import type { TerminalSession } from '../../types';
|
||||
import { cn } from '../../lib/utils';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../ui/select';
|
||||
import { DockerContainersPanel } from './DockerContainersPanel';
|
||||
import { DockerImagesPanel } from './DockerImagesPanel';
|
||||
import { KubernetesPodsPanel } from './KubernetesPodsPanel';
|
||||
import { SystemPanelShell } from './SystemPanelUi';
|
||||
|
||||
type Backend = ReturnType<typeof useSystemManagerBackend>;
|
||||
type DockerSubTab = 'containers' | 'images';
|
||||
|
||||
const RUNTIME_LABEL: Record<ContainerSourceId, string> = {
|
||||
docker: 'systemManager.runtime.docker',
|
||||
podman: 'systemManager.runtime.podman',
|
||||
nerdctl: 'systemManager.runtime.nerdctl',
|
||||
kubernetes: 'systemManager.runtime.kubernetes',
|
||||
};
|
||||
|
||||
interface DockerManagerTabProps {
|
||||
sessionId: string;
|
||||
parentSession: TerminalSession;
|
||||
capabilities?: SessionCapabilities;
|
||||
isVisible: boolean;
|
||||
warmupEnabled?: boolean;
|
||||
backend: Backend;
|
||||
@@ -34,48 +21,9 @@ interface DockerManagerTabProps {
|
||||
statsRefreshIntervalSec: number;
|
||||
}
|
||||
|
||||
function CompactSelect({
|
||||
value,
|
||||
options,
|
||||
ariaLabel,
|
||||
section,
|
||||
onChange,
|
||||
className,
|
||||
}: {
|
||||
value: string;
|
||||
options: { id: string; label: string }[];
|
||||
ariaLabel: string;
|
||||
section: string;
|
||||
onChange: (value: string) => void;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<Select value={value} onValueChange={onChange}>
|
||||
<SelectTrigger
|
||||
aria-label={ariaLabel}
|
||||
data-section={section}
|
||||
className={cn(
|
||||
"h-6 w-auto max-w-[9.5rem] gap-1 rounded border-border/40 bg-transparent px-2 py-0 text-[11px] shadow-none",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{options.map((option) => (
|
||||
<SelectItem key={option.id} value={option.id} className="text-xs">
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
);
|
||||
}
|
||||
|
||||
export const DockerManagerTab = memo(function DockerManagerTab({
|
||||
sessionId,
|
||||
parentSession,
|
||||
capabilities,
|
||||
isVisible,
|
||||
warmupEnabled = false,
|
||||
backend,
|
||||
@@ -84,26 +32,6 @@ export const DockerManagerTab = memo(function DockerManagerTab({
|
||||
}: DockerManagerTabProps) {
|
||||
const { t } = useI18n();
|
||||
const [subTab, setSubTab] = useState<DockerSubTab>('containers');
|
||||
const {
|
||||
source,
|
||||
selectSource,
|
||||
contextName,
|
||||
selectContext,
|
||||
namespaceName,
|
||||
setNamespaceName,
|
||||
contexts,
|
||||
namespaces,
|
||||
scopeError,
|
||||
reloadScopes,
|
||||
runtime,
|
||||
detected,
|
||||
showRuntimeSwitcher,
|
||||
} = useContainerRuntimeSelection({
|
||||
sessionId,
|
||||
hostKey: parentSession.hostId || sessionId,
|
||||
capabilities,
|
||||
backend,
|
||||
});
|
||||
|
||||
const tabs: { id: DockerSubTab; icon: typeof Box; label: string }[] = [
|
||||
{ id: 'containers', icon: Box, label: t('systemManager.docker.subTabs.containers') },
|
||||
@@ -112,8 +40,8 @@ export const DockerManagerTab = memo(function DockerManagerTab({
|
||||
|
||||
return (
|
||||
<SystemPanelShell section="system-manager-docker">
|
||||
<div className="shrink-0 flex items-center gap-1 px-2 py-1 border-b border-border/30">
|
||||
{source !== 'kubernetes' && tabs.map(({ id, icon: Icon, label }) => (
|
||||
<div className="shrink-0 flex items-center gap-0.5 px-2 py-1 border-b border-border/30">
|
||||
{tabs.map(({ id, icon: Icon, label }) => (
|
||||
<button
|
||||
key={id}
|
||||
type="button"
|
||||
@@ -129,79 +57,29 @@ export const DockerManagerTab = memo(function DockerManagerTab({
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
{showRuntimeSwitcher && source && detected.includes(source) && (
|
||||
<CompactSelect
|
||||
value={source}
|
||||
ariaLabel={t('systemManager.runtime.switch')}
|
||||
section="container-runtime-switcher"
|
||||
className="shrink-0"
|
||||
options={detected.map((id) => ({ id, label: t(RUNTIME_LABEL[id]) }))}
|
||||
onChange={selectSource}
|
||||
/>
|
||||
)}
|
||||
{source === 'kubernetes' && contextName && contexts.some((item) => item.name === contextName) && (
|
||||
<CompactSelect
|
||||
value={contextName}
|
||||
ariaLabel={t('systemManager.k8s.context')}
|
||||
section="k8s-context-select"
|
||||
className="w-full min-w-0 max-w-none flex-1"
|
||||
options={contexts.map((item) => ({ id: item.name, label: item.name }))}
|
||||
onChange={selectContext}
|
||||
/>
|
||||
)}
|
||||
{source === 'kubernetes' && namespaceName && namespaces.includes(namespaceName) && (
|
||||
<CompactSelect
|
||||
value={namespaceName}
|
||||
ariaLabel={t('systemManager.k8s.namespace')}
|
||||
section="k8s-namespace-select"
|
||||
className="shrink-0"
|
||||
options={namespaces.map((name) => ({ id: name, label: name }))}
|
||||
onChange={setNamespaceName}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex-1 min-h-0 flex flex-col">
|
||||
{source && source !== 'kubernetes' && (
|
||||
<>
|
||||
<div className={cn('flex-1 min-h-0 flex flex-col', subTab !== 'containers' && 'hidden')}>
|
||||
<DockerContainersPanel
|
||||
sessionId={sessionId}
|
||||
parentSession={parentSession}
|
||||
runtime={runtime}
|
||||
isVisible={isVisible && subTab === 'containers'}
|
||||
warmupEnabled={warmupEnabled || (isVisible && subTab !== 'containers')}
|
||||
backend={backend}
|
||||
listRefreshIntervalSec={listRefreshIntervalSec}
|
||||
statsRefreshIntervalSec={statsRefreshIntervalSec}
|
||||
/>
|
||||
</div>
|
||||
<div className={cn('flex-1 min-h-0 flex flex-col', subTab !== 'images' && 'hidden')}>
|
||||
<DockerImagesPanel
|
||||
sessionId={sessionId}
|
||||
runtime={runtime}
|
||||
isVisible={isVisible && subTab === 'images'}
|
||||
warmupEnabled={warmupEnabled || (isVisible && subTab !== 'images')}
|
||||
backend={backend}
|
||||
listRefreshIntervalSec={listRefreshIntervalSec}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{source === 'kubernetes' && (
|
||||
<KubernetesPodsPanel
|
||||
<div className={cn('flex-1 min-h-0 flex flex-col', subTab !== 'containers' && 'hidden')}>
|
||||
<DockerContainersPanel
|
||||
sessionId={sessionId}
|
||||
parentSession={parentSession}
|
||||
contextName={contextName}
|
||||
namespaceName={namespaceName}
|
||||
scopeError={scopeError}
|
||||
isVisible={isVisible}
|
||||
warmupEnabled={warmupEnabled}
|
||||
isVisible={isVisible && subTab === 'containers'}
|
||||
warmupEnabled={warmupEnabled || (isVisible && subTab !== 'containers')}
|
||||
backend={backend}
|
||||
listRefreshIntervalSec={listRefreshIntervalSec}
|
||||
onReloadScopes={reloadScopes}
|
||||
statsRefreshIntervalSec={statsRefreshIntervalSec}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className={cn('flex-1 min-h-0 flex flex-col', subTab !== 'images' && 'hidden')}>
|
||||
<DockerImagesPanel
|
||||
sessionId={sessionId}
|
||||
isVisible={isVisible && subTab === 'images'}
|
||||
warmupEnabled={warmupEnabled || (isVisible && subTab !== 'images')}
|
||||
backend={backend}
|
||||
listRefreshIntervalSec={listRefreshIntervalSec}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</SystemPanelShell>
|
||||
);
|
||||
|
||||
@@ -1,361 +0,0 @@
|
||||
import { Box, FileText, Info, Terminal, Trash2 } from 'lucide-react';
|
||||
import React, { memo, useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useI18n } from '../../application/i18n/I18nProvider';
|
||||
import type { useSystemManagerBackend } from '../../application/state/useSystemManagerBackend';
|
||||
import { usePolling, useStableTranslate } from '../../application/state/useSystemManager';
|
||||
import type { KubectlPodInfo } from '../../domain/systemManager/containerRuntime';
|
||||
import {
|
||||
buildKubectlDescribeCommand,
|
||||
buildKubectlExecShellCommand,
|
||||
buildKubectlLogsCommand,
|
||||
describeRuntimeFailure,
|
||||
podStatusMessageKey,
|
||||
} from '../../domain/systemManager/containerRuntime';
|
||||
import type { TerminalSession } from '../../types';
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '../ui/dialog';
|
||||
import { useStableListOrder } from './listStable';
|
||||
import { openInteractiveTerminal } from './openInteractiveTerminal';
|
||||
import { SystemPanelConfirmDialog } from './SystemPanelConfirmDialog';
|
||||
import {
|
||||
SystemPanelEmpty,
|
||||
SystemPanelError,
|
||||
SystemPanelList,
|
||||
SystemPanelLoading,
|
||||
SystemPanelMetaBar,
|
||||
SystemPanelRefreshButton,
|
||||
SystemPanelRoundButton,
|
||||
SystemPanelRow,
|
||||
SystemPanelSearch,
|
||||
SystemPanelStatusBadge,
|
||||
SystemPanelToolbar,
|
||||
} from './SystemPanelUi';
|
||||
import { showSystemManagerError } from './systemManagerToast';
|
||||
|
||||
type Backend = ReturnType<typeof useSystemManagerBackend>;
|
||||
|
||||
interface KubernetesPodsPanelProps {
|
||||
sessionId: string;
|
||||
parentSession: TerminalSession;
|
||||
contextName: string | null;
|
||||
namespaceName: string | null;
|
||||
scopeError?: string | null;
|
||||
isVisible: boolean;
|
||||
warmupEnabled?: boolean;
|
||||
backend: Backend;
|
||||
listRefreshIntervalSec: number;
|
||||
onReloadScopes: () => void;
|
||||
}
|
||||
|
||||
function podTone(status: string): 'success' | 'warning' | 'muted' {
|
||||
if (status === 'Running') return 'success';
|
||||
if (status === 'Pending' || status === 'Completed' || status === 'Succeeded') return 'muted';
|
||||
return 'warning';
|
||||
}
|
||||
|
||||
const KubernetesPodRow = memo(function KubernetesPodRow({
|
||||
pod,
|
||||
selected,
|
||||
pendingDelete,
|
||||
onSelect,
|
||||
onShell,
|
||||
onLogs,
|
||||
onDescribe,
|
||||
onDelete,
|
||||
}: {
|
||||
pod: KubectlPodInfo;
|
||||
selected: boolean;
|
||||
pendingDelete: boolean;
|
||||
onSelect: (pod: KubectlPodInfo) => void;
|
||||
onShell: (pod: KubectlPodInfo) => void;
|
||||
onLogs: (pod: KubectlPodInfo) => void;
|
||||
onDescribe: (pod: KubectlPodInfo) => void;
|
||||
onDelete: (pod: KubectlPodInfo) => void;
|
||||
}) {
|
||||
const { t } = useI18n();
|
||||
const statusKey = podStatusMessageKey(pod.status);
|
||||
const statusLabel = statusKey ? t(statusKey) : pod.status;
|
||||
return (
|
||||
<SystemPanelRow
|
||||
selected={selected}
|
||||
onClick={() => onSelect(pod)}
|
||||
leading={<Box size={14} className="text-muted-foreground" />}
|
||||
title={pod.name}
|
||||
subtitle={`${pod.containers.join(', ') || '—'} · ${t('systemManager.k8s.restarts', { count: String(pod.restarts) })}${pod.age ? ` · ${pod.age}` : ''}`}
|
||||
trailing={(
|
||||
<div className="flex shrink-0 items-center gap-1">
|
||||
<SystemPanelStatusBadge tone={podTone(pod.status)}>{statusLabel}</SystemPanelStatusBadge>
|
||||
{pod.shellContainers.length > 0 && (
|
||||
<SystemPanelRoundButton title={t('systemManager.docker.shell')} onClick={() => onShell(pod)}>
|
||||
<Terminal size={12} />
|
||||
</SystemPanelRoundButton>
|
||||
)}
|
||||
<SystemPanelRoundButton title={t('systemManager.docker.logs')} onClick={() => onLogs(pod)}>
|
||||
<FileText size={12} />
|
||||
</SystemPanelRoundButton>
|
||||
<SystemPanelRoundButton title={t('systemManager.k8s.describe')} onClick={() => onDescribe(pod)}>
|
||||
<Info size={12} />
|
||||
</SystemPanelRoundButton>
|
||||
<SystemPanelRoundButton
|
||||
title={t('systemManager.k8s.delete')}
|
||||
destructive
|
||||
loading={pendingDelete}
|
||||
onClick={() => onDelete(pod)}
|
||||
>
|
||||
<Trash2 size={12} />
|
||||
</SystemPanelRoundButton>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
export const KubernetesPodsPanel = memo(function KubernetesPodsPanel({
|
||||
sessionId,
|
||||
parentSession,
|
||||
contextName,
|
||||
namespaceName,
|
||||
scopeError = null,
|
||||
isVisible,
|
||||
warmupEnabled = false,
|
||||
backend,
|
||||
listRefreshIntervalSec,
|
||||
onReloadScopes,
|
||||
}: KubernetesPodsPanelProps) {
|
||||
const { t } = useI18n();
|
||||
const stableT = useStableTranslate();
|
||||
const [query, setQuery] = useState('');
|
||||
const [selectedName, setSelectedName] = useState<string | null>(null);
|
||||
const [pendingDelete, setPendingDelete] = useState<string | null>(null);
|
||||
const [confirmPod, setConfirmPod] = useState<string | null>(null);
|
||||
const [containerPrompt, setContainerPrompt] = useState<{
|
||||
pod: KubectlPodInfo;
|
||||
action: 'shell' | 'logs';
|
||||
} | null>(null);
|
||||
|
||||
const scopeKey = `${sessionId}:${contextName ?? ''}:${namespaceName ?? ''}`;
|
||||
|
||||
useEffect(() => {
|
||||
setSelectedName(null);
|
||||
setPendingDelete(null);
|
||||
setConfirmPod(null);
|
||||
setContainerPrompt(null);
|
||||
}, [scopeKey]);
|
||||
|
||||
const podsFetcher = useCallback(async () => {
|
||||
if (!contextName || !namespaceName) return [];
|
||||
const result = await backend.kubectl({
|
||||
sessionId,
|
||||
op: 'pods',
|
||||
context: contextName,
|
||||
namespace: namespaceName,
|
||||
});
|
||||
if (!result.success || !result.pods) {
|
||||
throw new Error(describeRuntimeFailure(
|
||||
result,
|
||||
stableT,
|
||||
stableT('systemManager.errors.loadPods'),
|
||||
contextName,
|
||||
));
|
||||
}
|
||||
return result.pods;
|
||||
}, [backend, contextName, namespaceName, sessionId, stableT]);
|
||||
|
||||
const listIntervalMs = Math.max(3, listRefreshIntervalSec) * 1000;
|
||||
const { data: pods, error, loading, refresh } = usePolling<KubectlPodInfo[]>(
|
||||
podsFetcher,
|
||||
listIntervalMs,
|
||||
(isVisible || warmupEnabled) && !!contextName && !!namespaceName,
|
||||
undefined,
|
||||
{ poll: isVisible, resetKey: scopeKey },
|
||||
);
|
||||
|
||||
const matched = useMemo(() => {
|
||||
const q = query.trim().toLowerCase();
|
||||
return (pods ?? []).filter((pod) => {
|
||||
if (!q) return true;
|
||||
const statusKey = podStatusMessageKey(pod.status);
|
||||
const statusLabel = (statusKey ? t(statusKey) : pod.status).toLowerCase();
|
||||
return pod.name.toLowerCase().includes(q)
|
||||
|| pod.status.toLowerCase().includes(q)
|
||||
|| statusLabel.includes(q)
|
||||
|| pod.containers.some((name) => name.toLowerCase().includes(q));
|
||||
});
|
||||
}, [pods, query, t]);
|
||||
|
||||
const displayList = useStableListOrder<KubectlPodInfo, string>(
|
||||
matched,
|
||||
(pod) => pod.name,
|
||||
`${scopeKey}|${query}`,
|
||||
(a, b) => a.name.localeCompare(b.name),
|
||||
);
|
||||
|
||||
const openCommand = useCallback(async (title: string, command: string) => {
|
||||
const result = await openInteractiveTerminal(backend, parentSession, title, command);
|
||||
if (!result.success) {
|
||||
showSystemManagerError(result.error || t('systemManager.errors.actionFailed'), t('common.error'));
|
||||
}
|
||||
}, [backend, parentSession, t]);
|
||||
|
||||
const runPodCommand = useCallback((pod: KubectlPodInfo, action: 'shell' | 'logs' | 'describe', container?: string) => {
|
||||
if (!contextName || !namespaceName) return;
|
||||
if (action === 'describe') {
|
||||
void openCommand(
|
||||
`describe: ${pod.name}`,
|
||||
buildKubectlDescribeCommand(contextName, namespaceName, pod.name, parentSession.shellType),
|
||||
);
|
||||
return;
|
||||
}
|
||||
const containers = action === 'shell' ? pod.shellContainers : pod.containers;
|
||||
if (action === 'shell' && containers.length === 0) return;
|
||||
if (!container && containers.length > 1) {
|
||||
setContainerPrompt({ pod, action });
|
||||
return;
|
||||
}
|
||||
const chosen = container || containers[0];
|
||||
if (action === 'shell') {
|
||||
void openCommand(
|
||||
`pod: ${pod.name}`,
|
||||
buildKubectlExecShellCommand(contextName, namespaceName, pod.name, chosen, parentSession.shellType),
|
||||
);
|
||||
return;
|
||||
}
|
||||
void openCommand(
|
||||
`logs: ${pod.name}`,
|
||||
buildKubectlLogsCommand(contextName, namespaceName, pod.name, chosen, parentSession.shellType),
|
||||
);
|
||||
}, [contextName, namespaceName, openCommand, parentSession.shellType]);
|
||||
|
||||
const deletePod = useCallback(async (name: string) => {
|
||||
if (!contextName || !namespaceName) return;
|
||||
setPendingDelete(name);
|
||||
try {
|
||||
const result = await backend.kubectl({
|
||||
sessionId,
|
||||
op: 'delete',
|
||||
context: contextName,
|
||||
namespace: namespaceName,
|
||||
pod: name,
|
||||
});
|
||||
if (!result.success) {
|
||||
showSystemManagerError(
|
||||
describeRuntimeFailure(result, t, t('systemManager.errors.actionFailed'), contextName),
|
||||
t('common.error'),
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (selectedName === name) setSelectedName(null);
|
||||
await refresh();
|
||||
} finally {
|
||||
setPendingDelete(null);
|
||||
}
|
||||
}, [backend, contextName, namespaceName, refresh, selectedName, sessionId, t]);
|
||||
|
||||
const visibleError = scopeError || error;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col flex-1 min-h-0 overflow-hidden" data-section="kubernetes-pods">
|
||||
<SystemPanelToolbar
|
||||
trailing={(
|
||||
<SystemPanelRefreshButton
|
||||
title={t('history.action.refresh')}
|
||||
loading={loading}
|
||||
onClick={() => {
|
||||
onReloadScopes();
|
||||
void refresh();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
>
|
||||
<SystemPanelSearch
|
||||
value={query}
|
||||
onChange={setQuery}
|
||||
placeholder={t('systemManager.k8s.search')}
|
||||
/>
|
||||
</SystemPanelToolbar>
|
||||
|
||||
<SystemPanelMetaBar>
|
||||
{t('systemManager.k8s.meta', { count: String(displayList.length) })}
|
||||
</SystemPanelMetaBar>
|
||||
|
||||
<SystemPanelList>
|
||||
{!contextName && !visibleError && (
|
||||
<SystemPanelEmpty icon={Box} message={t('systemManager.k8s.noContext')} />
|
||||
)}
|
||||
{visibleError && (
|
||||
<SystemPanelError
|
||||
message={visibleError}
|
||||
onRetry={() => {
|
||||
onReloadScopes();
|
||||
void refresh();
|
||||
}}
|
||||
retryLabel={t('history.action.retry')}
|
||||
loading={loading}
|
||||
/>
|
||||
)}
|
||||
{!visibleError && contextName && displayList.length === 0 && loading && (
|
||||
<SystemPanelLoading message={t('systemManager.common.loading')} />
|
||||
)}
|
||||
{!visibleError && contextName && namespaceName && displayList.length === 0 && !loading && (
|
||||
<SystemPanelEmpty icon={Box} message={t('systemManager.k8s.empty')} />
|
||||
)}
|
||||
{displayList.map((pod) => (
|
||||
<KubernetesPodRow
|
||||
key={pod.name}
|
||||
pod={pod}
|
||||
selected={selectedName === pod.name}
|
||||
pendingDelete={pendingDelete === pod.name}
|
||||
onSelect={(item) => setSelectedName((current) => current === item.name ? null : item.name)}
|
||||
onShell={(item) => runPodCommand(item, 'shell')}
|
||||
onLogs={(item) => runPodCommand(item, 'logs')}
|
||||
onDescribe={(item) => runPodCommand(item, 'describe')}
|
||||
onDelete={(item) => setConfirmPod(item.name)}
|
||||
/>
|
||||
))}
|
||||
</SystemPanelList>
|
||||
|
||||
<SystemPanelConfirmDialog
|
||||
open={confirmPod !== null}
|
||||
title={t('systemManager.k8s.delete')}
|
||||
message={t('systemManager.k8s.confirmDelete', { name: confirmPod ?? '' })}
|
||||
confirmLabel={t('systemManager.k8s.delete')}
|
||||
destructive
|
||||
busy={pendingDelete !== null}
|
||||
onOpenChange={(open) => { if (!open) setConfirmPod(null); }}
|
||||
onConfirm={() => {
|
||||
const name = confirmPod;
|
||||
setConfirmPod(null);
|
||||
if (!name) return;
|
||||
void deletePod(name);
|
||||
}}
|
||||
/>
|
||||
|
||||
<Dialog open={containerPrompt !== null} onOpenChange={(open) => { if (!open) setContainerPrompt(null); }}>
|
||||
<DialogContent className="sm:max-w-[380px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t('systemManager.k8s.chooseContainer')}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="flex flex-col gap-1">
|
||||
{(containerPrompt?.action === 'shell'
|
||||
? containerPrompt.pod.shellContainers
|
||||
: containerPrompt?.pod.containers
|
||||
)?.map((name) => (
|
||||
<button
|
||||
key={name}
|
||||
type="button"
|
||||
className="rounded-sm px-2 py-1.5 text-left text-sm outline-none transition-colors hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground"
|
||||
onClick={() => {
|
||||
const prompt = containerPrompt;
|
||||
setContainerPrompt(null);
|
||||
if (!prompt) return;
|
||||
runPodCommand(prompt.pod, prompt.action, name);
|
||||
}}
|
||||
>
|
||||
{name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
@@ -14,7 +14,6 @@ import { resolveCapabilityPanelState } from '../../domain/systemManagerPanelStat
|
||||
import {
|
||||
allowSystemManagerMutations,
|
||||
buildSystemManagerTabs,
|
||||
hasContainerSource,
|
||||
shouldCollectServerStats,
|
||||
} from '../../domain/systemManager/systemTarget';
|
||||
|
||||
@@ -285,14 +284,13 @@ export const SystemManagerSidePanel = memo(function SystemManagerSidePanel({
|
||||
}, [isConnectedSession, isVisible, availableTabsKey, tabBarEl]);
|
||||
|
||||
// Must be defined before early returns to comply with React rules of hooks.
|
||||
const containerSourceReady = hasContainerSource(capabilities);
|
||||
const prevTabRef = React.useRef(resolvedTab);
|
||||
const probingRef = React.useRef(false);
|
||||
React.useEffect(() => {
|
||||
const prev = prevTabRef.current;
|
||||
prevTabRef.current = resolvedTab;
|
||||
if (prev === resolvedTab) return;
|
||||
if (resolvedTab === 'docker' && !containerSourceReady) {
|
||||
if (resolvedTab === 'docker' && capabilities?.hasDocker !== true) {
|
||||
if (!probingRef.current) {
|
||||
probingRef.current = true;
|
||||
refreshCapabilities().finally(() => { probingRef.current = false; });
|
||||
@@ -300,7 +298,7 @@ export const SystemManagerSidePanel = memo(function SystemManagerSidePanel({
|
||||
} else if (resolvedTab === 'tmux' && capabilities?.hasTmux !== true) {
|
||||
void refreshCapabilities();
|
||||
}
|
||||
}, [resolvedTab, capabilities, containerSourceReady, refreshCapabilities]);
|
||||
}, [resolvedTab, capabilities, refreshCapabilities]);
|
||||
|
||||
// Auto-poll for Docker capabilities while Docker tab is active and Docker not yet detected.
|
||||
// Use setTimeout recursion so the next probe only starts after the previous one finishes,
|
||||
@@ -317,7 +315,7 @@ export const SystemManagerSidePanel = memo(function SystemManagerSidePanel({
|
||||
// preventing stale probes from surviving cleanup (unlike cancelledRef which is shared).
|
||||
// First poll is delayed by one interval to avoid overlapping with the tab-switch probe.
|
||||
React.useEffect(() => {
|
||||
if (!isVisible || resolvedTab !== 'docker' || containerSourceReady) return;
|
||||
if (!isVisible || resolvedTab !== 'docker' || capabilities?.hasDocker === true) return;
|
||||
|
||||
let cancelled = false;
|
||||
let timerId: ReturnType<typeof setTimeout>;
|
||||
@@ -346,7 +344,7 @@ export const SystemManagerSidePanel = memo(function SystemManagerSidePanel({
|
||||
cancelled = true;
|
||||
if (timerId) clearTimeout(timerId);
|
||||
};
|
||||
}, [isVisible, resolvedTab, containerSourceReady, capabilitiesTtlMs]);
|
||||
}, [isVisible, resolvedTab, capabilities?.hasDocker, capabilitiesTtlMs]);
|
||||
|
||||
const selectTab = useCallback((id: SystemManagerSubTab) => {
|
||||
setActiveTab(id);
|
||||
@@ -400,7 +398,7 @@ export const SystemManagerSidePanel = memo(function SystemManagerSidePanel({
|
||||
}
|
||||
|
||||
const tmuxReady = capabilities?.hasTmux === true;
|
||||
const dockerReady = containerSourceReady;
|
||||
const dockerReady = capabilities?.hasDocker === true;
|
||||
const gpuReady = capabilities?.hasNvidiaSmi === true || capabilities?.hasNpuSmi === true;
|
||||
const portsReady = (
|
||||
capabilities?.hasSs === true
|
||||
@@ -623,10 +621,8 @@ export const SystemManagerSidePanel = memo(function SystemManagerSidePanel({
|
||||
) : dockerPanelState === 'ready' ? (
|
||||
<div className={cn('flex-1 min-h-0 flex flex-col', resolvedTab !== 'docker' && 'hidden')}>
|
||||
<DockerManagerTab
|
||||
key={session.hostId || sessionId}
|
||||
sessionId={sessionId}
|
||||
parentSession={session}
|
||||
capabilities={capabilities}
|
||||
isVisible={isVisible && resolvedTab === 'docker'}
|
||||
warmupEnabled={isVisible && resolvedTab !== 'docker'}
|
||||
backend={backend}
|
||||
|
||||
@@ -1,25 +0,0 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { readFileSync } from 'node:fs';
|
||||
|
||||
test('kubernetes keeps the shared runtime select and drops the Containers back button', () => {
|
||||
const tab = readFileSync(new URL('./DockerManagerTab.tsx', import.meta.url), 'utf8');
|
||||
const hook = readFileSync(
|
||||
new URL('../../application/state/systemManager/useContainerRuntimeSelection.ts', import.meta.url),
|
||||
'utf8',
|
||||
);
|
||||
|
||||
assert.equal(tab.includes('container-runtime-back'), false);
|
||||
assert.match(tab, /data-section=\{section\}/);
|
||||
assert.match(tab, /section="container-runtime-switcher"/);
|
||||
assert.match(tab, /section="k8s-context-select"/);
|
||||
assert.match(tab, /section="k8s-namespace-select"/);
|
||||
assert.doesNotMatch(tab, /<div className="flex-1" \/>/);
|
||||
assert.match(tab, /section="k8s-context-select"[\s\S]*className="w-full min-w-0 max-w-none flex-1"/);
|
||||
assert.match(tab, /section="container-runtime-switcher"[\s\S]*className="shrink-0"/);
|
||||
assert.match(tab, /section="k8s-namespace-select"[\s\S]*className="shrink-0"/);
|
||||
assert.match(hook, /const showRuntimeSwitcher = detected\.length > 1;/);
|
||||
assert.doesNotMatch(hook, /source !== 'kubernetes' && detected\.length/);
|
||||
assert.match(hook, /reconcileContainerSource\(/);
|
||||
assert.match(hook, /isAutomaticRuntimeFallback\(remembered, source, available\)/);
|
||||
});
|
||||
@@ -93,71 +93,3 @@ test('openInteractiveTerminal keeps SSH connection reuse for ordinary connected
|
||||
assert.equal(payload.sourceSession.protocol, 'ssh');
|
||||
assert.equal(payload.sourceSession.reuseConnectionFromSessionId, 'session-1');
|
||||
});
|
||||
|
||||
async function withSystemPanels(
|
||||
panels: Array<{ getBoundingClientRect: () => { x: number; y: number; width: number; height: number } }>,
|
||||
run: () => Promise<void>,
|
||||
): Promise<void> {
|
||||
const previous = globalThis.document;
|
||||
globalThis.document = {
|
||||
querySelectorAll(selector: string) {
|
||||
return selector === '[data-section="system-manager-panel"]' ? panels : [];
|
||||
},
|
||||
} as unknown as Document;
|
||||
try {
|
||||
await run();
|
||||
} finally {
|
||||
if (previous === undefined) {
|
||||
delete (globalThis as { document?: Document }).document;
|
||||
} else {
|
||||
globalThis.document = previous;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
test('openInteractiveTerminal passes the system panel rectangle so the shell can avoid it', async () => {
|
||||
const panel = {
|
||||
getBoundingClientRect: () => ({ x: 1000, y: 40, width: 440, height: 860 }),
|
||||
};
|
||||
const payloads: Array<{ avoidClientRect?: { x: number; width: number } }> = [];
|
||||
await withSystemPanels([panel], async () => {
|
||||
await openInteractiveTerminal(
|
||||
{
|
||||
openTerminalPopup: async (payload: { avoidClientRect?: { x: number; width: number } }) => {
|
||||
payloads.push(payload);
|
||||
return { success: true };
|
||||
},
|
||||
} as never,
|
||||
parentSession(),
|
||||
'pod: multi-box',
|
||||
'kubectl exec -it multi-box -- sh',
|
||||
);
|
||||
});
|
||||
|
||||
assert.deepEqual(payloads[0]?.avoidClientRect, { x: 1000, y: 40, width: 440, height: 860 });
|
||||
});
|
||||
|
||||
test('openInteractiveTerminal skips a parked zero-size system panel and avoids the visible one', async () => {
|
||||
const parked = {
|
||||
getBoundingClientRect: () => ({ x: 0, y: 0, width: 0, height: 0 }),
|
||||
};
|
||||
const visible = {
|
||||
getBoundingClientRect: () => ({ x: 1000, y: 40, width: 440, height: 860 }),
|
||||
};
|
||||
const payloads: Array<{ avoidClientRect?: { x: number; y: number; width: number; height: number } }> = [];
|
||||
await withSystemPanels([parked, visible], async () => {
|
||||
await openInteractiveTerminal(
|
||||
{
|
||||
openTerminalPopup: async (payload: { avoidClientRect?: { x: number; y: number; width: number; height: number } }) => {
|
||||
payloads.push(payload);
|
||||
return { success: true };
|
||||
},
|
||||
} as never,
|
||||
parentSession(),
|
||||
'pod: multi-box',
|
||||
'kubectl exec -it multi-box -- sh',
|
||||
);
|
||||
});
|
||||
|
||||
assert.deepEqual(payloads[0]?.avoidClientRect, { x: 1000, y: 40, width: 440, height: 860 });
|
||||
});
|
||||
@@ -6,18 +6,6 @@ import type { useSystemManagerBackend } from '../../application/state/useSystemM
|
||||
|
||||
type Backend = ReturnType<typeof useSystemManagerBackend>;
|
||||
|
||||
function readSystemPanelClientRect(): { x: number; y: number; width: number; height: number } | undefined {
|
||||
if (typeof document === "undefined" || typeof document.querySelectorAll !== "function") return undefined;
|
||||
const panels = document.querySelectorAll('[data-section="system-manager-panel"]');
|
||||
for (const panel of panels) {
|
||||
if (typeof panel.getBoundingClientRect !== "function") continue;
|
||||
const rect = panel.getBoundingClientRect();
|
||||
if (rect.width < 1 || rect.height < 1) continue;
|
||||
return { x: rect.x, y: rect.y, width: rect.width, height: rect.height };
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function buildPopupTitle(parentSession: TerminalSession, title: string): string {
|
||||
const hostLabel = parentSession.hostLabel.trim();
|
||||
const cleanTitle = title.trim();
|
||||
@@ -74,13 +62,11 @@ export async function openInteractiveTerminal(
|
||||
canReuseConnection,
|
||||
hasIcon: !!options?.icon,
|
||||
});
|
||||
const avoidClientRect = readSystemPanelClientRect();
|
||||
const result = await backend.openTerminalPopup({
|
||||
title: popupTitle,
|
||||
icon: options?.icon,
|
||||
parentSessionId: parentSession.id,
|
||||
startupCommand,
|
||||
...(avoidClientRect ? { avoidClientRect } : {}),
|
||||
sourceSession: buildCommandPopupSourceSession(parentSession, startupCommand, canReuseConnection),
|
||||
});
|
||||
await writeSystemManagerDiagnostic('openInteractiveTerminal result', {
|
||||
|
||||
@@ -49,11 +49,11 @@ test("process and docker confirm dialogs reset when sessionId changes", () => {
|
||||
assert.match(processSource, /}, \[sessionId\]\);/);
|
||||
|
||||
assert.match(containersSource, /setConfirmAction\(null\)/);
|
||||
assert.match(containersSource, /}, \[runtime, sessionId\]\);/);
|
||||
assert.match(containersSource, /}, \[sessionId\]\);/);
|
||||
|
||||
assert.match(imagesSource, /setConfirmTarget\(null\)/);
|
||||
assert.match(imagesSource, /setActionBusy\(false\)/);
|
||||
assert.match(imagesSource, /}, \[runtime, sessionId\]\);/);
|
||||
assert.match(imagesSource, /}, \[sessionId\]\);/);
|
||||
});
|
||||
|
||||
test("ports and services confirm dialogs reset when sessionId changes", () => {
|
||||
|
||||
@@ -475,10 +475,6 @@ export const createTerminalOutputHistoryPreview = (options?: {
|
||||
// mark / trailing surrogate in the next) while xterm's grapheme provider
|
||||
// joins them across writes, so the next span must be able to rejoin it.
|
||||
let openGrapheme: string | null = null;
|
||||
// When set, `openGrapheme` still holds the whole non-ASCII piece the last
|
||||
// append wrote; its last grapheme is only segmented out when a following
|
||||
// span actually tries to rejoin it (rare), not on every append.
|
||||
let openGraphemeUnresolved = false;
|
||||
let openGraphemeCell = 0;
|
||||
let cacheDirty = true;
|
||||
let cacheCols = -1;
|
||||
@@ -555,10 +551,6 @@ export const createTerminalOutputHistoryPreview = (options?: {
|
||||
&& cursor === current.length
|
||||
&& cursorCell === openGraphemeCell
|
||||
) {
|
||||
if (openGraphemeUnresolved) {
|
||||
openGrapheme = lastGraphemeUnits(openGrapheme);
|
||||
openGraphemeUnresolved = false;
|
||||
}
|
||||
const probeEnd = Math.min(span.length, WRAP_PROBE_UTF16_UNITS);
|
||||
const head = openGrapheme + span.slice(0, probeEnd);
|
||||
let first = firstGraphemeUnits(head);
|
||||
@@ -711,9 +703,7 @@ export const createTerminalOutputHistoryPreview = (options?: {
|
||||
// clusters and corrupt the transcript, while a boundary-aligned cut
|
||||
// keeps every piece's segmentation identical to the span's.
|
||||
let pieceEnd = offset + (maxChars - current.length);
|
||||
// A remainder no longer than the cap needs no boundary search: the
|
||||
// segment walk below would consume all of it and cut at its end.
|
||||
if (pieceEnd - offset > pieceCap && span.length - offset > pieceCap) {
|
||||
if (pieceEnd - offset > pieceCap) {
|
||||
const window = span.slice(offset, offset + pieceCap + WRAP_PROBE_UTF16_UNITS);
|
||||
let cutLength = 0;
|
||||
let end = window.length;
|
||||
@@ -804,8 +794,7 @@ export const createTerminalOutputHistoryPreview = (options?: {
|
||||
// continuing it (a split cluster) can be rejoined (see writeSpan).
|
||||
// An empty open grapheme would corrupt the join, so track null then.
|
||||
openGrapheme = !fitting ? null
|
||||
: pieceAscii ? fitting.slice(-1) : fitting;
|
||||
openGraphemeUnresolved = !!fitting && !pieceAscii;
|
||||
: isAsciiOnly(fitting) ? fitting.slice(-1) : lastGraphemeUnits(fitting);
|
||||
openGraphemeCell = cursorCell;
|
||||
}
|
||||
cursor = appending ? current.length
|
||||
|
||||
@@ -330,8 +330,7 @@ export const AsidePanelStack: React.FC<AsidePanelStackProps> = ({
|
||||
className
|
||||
)}
|
||||
style={inlineStyle}
|
||||
data-section={dataSection}
|
||||
data-native-view-occluder={layout === 'overlay' ? '' : undefined}>
|
||||
data-section={dataSection}>
|
||||
<AsidePanelHeader
|
||||
title={currentItem.title}
|
||||
subtitle={currentItem.subtitle}
|
||||
@@ -430,10 +429,7 @@ export const AsidePanel: React.FC<AsidePanelProps> = ({
|
||||
className,
|
||||
)}
|
||||
style={panelStyle}
|
||||
data-section={dataSection}
|
||||
// Overlay panels float above content; native views (host browser)
|
||||
// must yield where they overlap instead of drawing over the panel.
|
||||
data-native-view-occluder={layout === 'overlay' ? '' : undefined}>
|
||||
data-section={dataSection}>
|
||||
{resizable ? (
|
||||
<div
|
||||
role="separator"
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 => {
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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[] = [];
|
||||
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
|
Before Width: | Height: | Size: 985 KiB |
|
Before Width: | Height: | Size: 959 KiB |
|
Before Width: | Height: | Size: 931 KiB |
|
Before Width: | Height: | Size: 794 KiB |
|
Before Width: | Height: | Size: 1.1 MiB |
|
Before Width: | Height: | Size: 1.0 MiB |
|
Before Width: | Height: | Size: 1.1 MiB |
|
Before Width: | Height: | Size: 287 KiB |
|
Before Width: | Height: | Size: 757 KiB |
|
Before Width: | Height: | Size: 892 KiB |
@@ -1,66 +0,0 @@
|
||||
# Shared AI privacy and reversible masking
|
||||
|
||||
AI privacy masking is **off by default**. Enable it in Settings > AI > Tool Access >
|
||||
AI privacy. This shared policy applies to Netcatty-managed context and tools used by
|
||||
Catty, integrated agents and external MCP clients. It is independent of the External
|
||||
MCP switch. External stdio and Streamable HTTP clients use the same policy, including
|
||||
for the environment resource.
|
||||
|
||||
This is not a system-wide AI traffic filter. Third-party agents' native tools,
|
||||
direct file access and other MCP servers are outside the Netcatty-managed boundary.
|
||||
Local terminal display, saved terminal output and actual host data are not changed.
|
||||
|
||||
## Working loop
|
||||
|
||||
A recognized address or credential in managed context or a tool response becomes an
|
||||
opaque token, for example `[NC_<session>_IP_1]`. The same exact value has the same token
|
||||
within its managed conversation or external MCP connection. A later managed command
|
||||
or file-write argument containing that token is restored locally before permission
|
||||
checks and the approval dialog. The host receives the original value; the agent
|
||||
receives masked managed results.
|
||||
|
||||
Mappings stay in memory and are isolated by managed conversation or external MCP
|
||||
connection. No mapping is saved to disk or shared through the app-wide external chat
|
||||
scope. New conversations and reconnects start a new mapping. Unknown, stale, or
|
||||
other-session tokens are rejected rather than sent to the host.
|
||||
|
||||
After changing privacy settings, start a new managed conversation and reconnect
|
||||
external MCP clients. Existing scopes fail closed instead of silently changing
|
||||
their protection. Settings apply only after main-process validation succeeds. A
|
||||
saved legacy External MCP privacy preference is used only if no shared AI privacy
|
||||
preference exists; applying valid settings saves the shared preference. Corrupt
|
||||
saved settings must be repaired rather than silently reverting to unprotected
|
||||
operation.
|
||||
|
||||
## What is covered
|
||||
|
||||
- Recognized credential assignments, authorization bearer values, URL passwords,
|
||||
familiar API token forms, structured secret fields and complete private-key blocks.
|
||||
- IPv4 and IPv6 addresses, with private/local ranges retained by default. Enable
|
||||
**Redact private IP addresses** to hide them too, or select additional CIDRs.
|
||||
Documentation/test addresses are also masked.
|
||||
- Structured host names and user-supplied regular-expression matches.
|
||||
- Netcatty-managed text context, tool results, nested structured results and errors.
|
||||
|
||||
Custom expressions match whole values and use the existing RE2 engine. Unsupported
|
||||
expressions, malformed CIDRs, or empty matches are rejected. Rules and mapping
|
||||
sizes are bounded; exhaustion blocks the response instead of evicting active tokens.
|
||||
|
||||
Binary/base64 tool attachments are blocked while masking is enabled. This feature
|
||||
does not scan images, archives, encoded/encrypted content, files accessed directly
|
||||
by third-party agents, or other MCP servers' results. It cannot guarantee detection
|
||||
of arbitrary credentials, all hostnames, or intentionally obfuscated data. It is an
|
||||
accidental-disclosure safeguard, not a DLP system or a substitute for the normal
|
||||
tool permission policy.
|
||||
|
||||
## Verification
|
||||
|
||||
Keep External MCP disabled and enable AI privacy. Use synthetic values in a new
|
||||
Catty conversation and a new integrated-agent conversation, then repeat through
|
||||
external stdio and HTTP clients after enabling External MCP: read `192.0.2.10`, read
|
||||
`198.51.100.7`, then read the first again. Tokens should be different for different
|
||||
values and stable for repeated reads within the same scope. Write a returned token
|
||||
back through a managed tool or use it in a managed terminal command; the actual
|
||||
file/terminal should contain the original value, never the token. A token from
|
||||
another conversation/client or an earlier scope must fail without dispatching a
|
||||
command. No protection should be implied for third-party native tool/file access.
|
||||
@@ -8,9 +8,7 @@ import {
|
||||
mergeConnectionLogsFromStorage,
|
||||
mergeTerminalDataIntoLogs,
|
||||
mergeTerminalDataMapsForStorage,
|
||||
pruneInMemoryUnsavedTerminalData,
|
||||
pruneTerminalDataMapForStorage,
|
||||
writeTerminalDataMapWithQuotaRetry,
|
||||
} from "./connectionLogTerminalData.ts";
|
||||
|
||||
const baseLog: ConnectionLog = {
|
||||
@@ -151,44 +149,6 @@ test("pruneTerminalDataMapForStorage caps unsaved replay buffers", () => {
|
||||
assert.equal(pruned["log-0"], undefined);
|
||||
});
|
||||
|
||||
test("pruneInMemoryUnsavedTerminalData drops oldest unsaved replay buffers beyond the persist cap", () => {
|
||||
const logs: ConnectionLog[] = Array.from({ length: 60 }, (_, index) => ({
|
||||
...baseLog,
|
||||
id: `log-${index}`,
|
||||
startTime: index,
|
||||
saved: false,
|
||||
terminalData: `data-${index}`,
|
||||
}));
|
||||
// One bookmarked entry must keep its replay buffer even when unsaved count is high.
|
||||
logs.push({
|
||||
...baseLog,
|
||||
id: "saved-keep",
|
||||
startTime: 0,
|
||||
saved: true,
|
||||
terminalData: "saved-data",
|
||||
});
|
||||
|
||||
const pruned = pruneInMemoryUnsavedTerminalData(logs);
|
||||
const unsavedWithData = pruned.filter((log) => !log.saved && log.terminalData !== undefined);
|
||||
assert.equal(unsavedWithData.length, MAX_PERSISTED_UNSAVED_TERMINAL_DATA_ENTRIES);
|
||||
assert.equal(pruned.find((log) => log.id === "log-59")?.terminalData, "data-59");
|
||||
assert.equal(pruned.find((log) => log.id === "log-0")?.terminalData, undefined);
|
||||
assert.equal(pruned.find((log) => log.id === "log-0")?.id, "log-0"); // metadata kept
|
||||
assert.equal(pruned.find((log) => log.id === "saved-keep")?.terminalData, "saved-data");
|
||||
assert.equal(pruned.length, logs.length);
|
||||
});
|
||||
|
||||
test("pruneInMemoryUnsavedTerminalData is a no-op at or under the cap", () => {
|
||||
const logs: ConnectionLog[] = Array.from({ length: 10 }, (_, index) => ({
|
||||
...baseLog,
|
||||
id: `log-${index}`,
|
||||
startTime: index,
|
||||
terminalData: `data-${index}`,
|
||||
}));
|
||||
const pruned = pruneInMemoryUnsavedTerminalData(logs);
|
||||
assert.equal(pruned, logs);
|
||||
});
|
||||
|
||||
test("mergeTerminalDataMapsForStorage keeps replay data from other windows", () => {
|
||||
const logs: ConnectionLog[] = [
|
||||
{ ...baseLog, id: "local", startTime: 2000 },
|
||||
@@ -233,132 +193,3 @@ test("mergeTerminalDataMapsForStorage drops deleted log replay buffers", () => {
|
||||
assert.equal(merged.local, "keep");
|
||||
assert.equal(merged.deleted, undefined);
|
||||
});
|
||||
|
||||
const quotaLogs = (count: number): ConnectionLog[] =>
|
||||
Array.from({ length: count }, (_, index) => ({
|
||||
...baseLog,
|
||||
id: `log-${index}`,
|
||||
startTime: index,
|
||||
saved: false,
|
||||
}));
|
||||
|
||||
const quotaWriter = (limitChars: number) => {
|
||||
const calls: number[] = [];
|
||||
const write = (next: Record<string, string>) => {
|
||||
const size = Object.values(next).reduce((sum, value) => sum + value.length, 0);
|
||||
calls.push(Object.keys(next).length);
|
||||
return size <= limitChars;
|
||||
};
|
||||
return { calls, write };
|
||||
};
|
||||
|
||||
test("writeTerminalDataMapWithQuotaRetry writes once when it fits", () => {
|
||||
const logs = quotaLogs(5);
|
||||
const map = Object.fromEntries(logs.map((log) => [log.id, "x".repeat(10)]));
|
||||
const { calls, write } = quotaWriter(1000);
|
||||
const result = writeTerminalDataMapWithQuotaRetry(logs, map, write, null);
|
||||
assert.equal(result.persisted, true);
|
||||
assert.equal(result.written, map);
|
||||
assert.deepEqual(calls, [5]);
|
||||
});
|
||||
|
||||
test("writeTerminalDataMapWithQuotaRetry drops the oldest unsaved entries until it fits", () => {
|
||||
const logs = quotaLogs(50);
|
||||
// Newest first, matching pruneTerminalDataMapForStorage order.
|
||||
const map = Object.fromEntries(
|
||||
[...logs].reverse().map((log) => [log.id, "x".repeat(10)]),
|
||||
);
|
||||
const { calls, write } = quotaWriter(10 * 47);
|
||||
const result = writeTerminalDataMapWithQuotaRetry(logs, map, write, null);
|
||||
assert.equal(result.persisted, true);
|
||||
// 50 fails, drop 1 → 49 fails, drop 2 → 48 fails, drop 4 → 46 fits.
|
||||
assert.deepEqual(calls, [50, 49, 48, 46]);
|
||||
assert.equal(result.written["log-49"], map["log-49"]);
|
||||
assert.equal(result.written["log-4"], map["log-4"]);
|
||||
for (const id of ["log-0", "log-1", "log-2", "log-3"]) {
|
||||
assert.equal(result.written[id], undefined);
|
||||
}
|
||||
// Key order (newest first) is preserved.
|
||||
assert.deepEqual(Object.keys(result.written), Object.keys(map).filter((id) => id in result.written));
|
||||
});
|
||||
|
||||
test("writeTerminalDataMapWithQuotaRetry never drops saved or unknown entries and stops after all unsaved are gone", () => {
|
||||
const logs: ConnectionLog[] = [
|
||||
...quotaLogs(20),
|
||||
{ ...baseLog, id: "saved", startTime: -1, saved: true },
|
||||
];
|
||||
const map: Record<string, string> = Object.fromEntries(
|
||||
logs.map((log) => [log.id, "x".repeat(10)]),
|
||||
);
|
||||
map["other-window"] = "y".repeat(10);
|
||||
const { calls, write } = quotaWriter(5); // nothing fits
|
||||
const result = writeTerminalDataMapWithQuotaRetry(logs, map, write, null);
|
||||
assert.equal(result.persisted, false);
|
||||
assert.equal(result.written, map);
|
||||
// 22, then drop 1, 2, 4, 8, 16, then all 20 unsaved: bounded attempts.
|
||||
assert.deepEqual(calls, [22, 21, 20, 18, 14, 6, 2]);
|
||||
assert.ok(result.attempts <= 2 + Math.ceil(Math.log2(20)));
|
||||
});
|
||||
|
||||
const newestFirstMap = (logs: ConnectionLog[], chars: number) =>
|
||||
Object.fromEntries([...logs].reverse().map((log) => [log.id, "x".repeat(chars)]));
|
||||
|
||||
test("writeTerminalDataMapWithQuotaRetry starts the next write from the newest set that last fit", () => {
|
||||
const logs = quotaLogs(50);
|
||||
const quota = quotaWriter(10 * 47);
|
||||
const first = writeTerminalDataMapWithQuotaRetry(logs, newestFirstMap(logs, 10), quota.write, null);
|
||||
// Next close: oldest drops out of the 50, a newer session arrives.
|
||||
const nextLogs = [...logs.slice(1), { ...baseLog, id: "log-50", startTime: 50 }];
|
||||
const second = quotaWriter(10 * 47);
|
||||
const result = writeTerminalDataMapWithQuotaRetry(
|
||||
nextLogs,
|
||||
newestFirstMap(nextLogs, 10),
|
||||
second.write,
|
||||
first.quotaFailedChars,
|
||||
);
|
||||
assert.equal(result.persisted, true);
|
||||
assert.deepEqual(second.calls, [47]);
|
||||
assert.equal(result.written["log-50"] !== undefined, true);
|
||||
assert.equal(result.written["log-3"], undefined);
|
||||
});
|
||||
|
||||
test("writeTerminalDataMapWithQuotaRetry grows back when entries get smaller", () => {
|
||||
const logs = quotaLogs(50);
|
||||
const first = writeTerminalDataMapWithQuotaRetry(
|
||||
logs,
|
||||
newestFirstMap(logs, 10),
|
||||
quotaWriter(10 * 47).write,
|
||||
null,
|
||||
);
|
||||
const small = quotaWriter(10 * 47);
|
||||
const result = writeTerminalDataMapWithQuotaRetry(
|
||||
logs,
|
||||
newestFirstMap(logs, 5),
|
||||
small.write,
|
||||
first.quotaFailedChars,
|
||||
);
|
||||
assert.deepEqual(small.calls, [50]);
|
||||
assert.equal(Object.keys(result.written).length, 50);
|
||||
});
|
||||
|
||||
test("writeTerminalDataMapWithQuotaRetry falls back to dropping when the hint is too optimistic", () => {
|
||||
const logs = quotaLogs(50);
|
||||
const first = writeTerminalDataMapWithQuotaRetry(
|
||||
logs,
|
||||
newestFirstMap(logs, 10),
|
||||
quotaWriter(10 * 47).write,
|
||||
null,
|
||||
);
|
||||
const tighter = quotaWriter(10 * 30); // quota shrank (other keys grew)
|
||||
const result = writeTerminalDataMapWithQuotaRetry(
|
||||
logs,
|
||||
newestFirstMap(logs, 10),
|
||||
tighter.write,
|
||||
first.quotaFailedChars,
|
||||
);
|
||||
assert.equal(result.persisted, true);
|
||||
assert.equal(tighter.calls[0], 47);
|
||||
assert.ok(Object.keys(result.written).length <= 30);
|
||||
assert.equal(result.written["log-49"] !== undefined, true);
|
||||
assert.ok(result.attempts <= 2 + Math.ceil(Math.log2(50)));
|
||||
});
|
||||
@@ -88,90 +88,6 @@ export const pruneTerminalDataMapForStorage = (
|
||||
return next;
|
||||
};
|
||||
|
||||
|
||||
/**
|
||||
* Drop in-memory `terminalData` from the oldest unsaved logs beyond the
|
||||
* persisted-entry cap. List metadata stays; only replay buffers are removed.
|
||||
* Matches what survives a restart (`pruneTerminalDataMapForStorage`).
|
||||
*/
|
||||
export const pruneInMemoryUnsavedTerminalData = (
|
||||
logs: ConnectionLog[],
|
||||
maxEntries: number = MAX_PERSISTED_UNSAVED_TERMINAL_DATA_ENTRIES,
|
||||
): ConnectionLog[] => {
|
||||
const unsavedWithData = logs
|
||||
.filter((log) => !log.saved && log.terminalData !== undefined)
|
||||
.sort((a, b) => b.startTime - a.startTime);
|
||||
if (unsavedWithData.length <= maxEntries) return logs;
|
||||
|
||||
const keep = new Set(unsavedWithData.slice(0, maxEntries).map((log) => log.id));
|
||||
let changed = false;
|
||||
const next = logs.map((log) => {
|
||||
if (log.saved || log.terminalData === undefined || keep.has(log.id)) return log;
|
||||
changed = true;
|
||||
const { terminalData: _omitted, ...rest } = log;
|
||||
return rest;
|
||||
});
|
||||
return changed ? next : logs;
|
||||
};
|
||||
|
||||
/**
|
||||
* Write the side store; if the write is rejected (localStorage quota), retry
|
||||
* without the oldest unsaved replay buffers so the newest sessions are kept.
|
||||
* Drops 1, 2, 4, … oldest entries per retry, so attempts stay O(log n).
|
||||
* Saved logs and ids not in `logs` are never dropped.
|
||||
*
|
||||
* `quotaFailedChars` is owned by the caller. It is the summed entry length of
|
||||
* the smallest payload that last hit the quota, so the next write can start
|
||||
* from the newest set that fits. A successful write of the full map returns
|
||||
* null. The helper does not keep that hint itself.
|
||||
*/
|
||||
export const writeTerminalDataMapWithQuotaRetry = (
|
||||
logs: ConnectionLog[],
|
||||
map: ConnectionLogTerminalDataMap,
|
||||
write: (next: ConnectionLogTerminalDataMap) => boolean,
|
||||
quotaFailedChars: number | null,
|
||||
): { persisted: boolean; written: ConnectionLogTerminalDataMap; attempts: number; quotaFailedChars: number | null } => {
|
||||
const droppable = logs
|
||||
.filter((log) => !log.saved && map[log.id] !== undefined)
|
||||
.sort((a, b) => a.startTime - b.startTime)
|
||||
.map((log) => log.id);
|
||||
let chars = 0;
|
||||
for (const data of Object.values(map)) chars += data.length;
|
||||
|
||||
let hint = quotaFailedChars;
|
||||
let dropCount = 0;
|
||||
if (hint !== null) {
|
||||
while (dropCount < droppable.length && chars >= hint) {
|
||||
chars -= map[droppable[dropCount]].length;
|
||||
dropCount += 1;
|
||||
}
|
||||
}
|
||||
|
||||
let attempts = 0;
|
||||
for (;;) {
|
||||
let next = map;
|
||||
if (dropCount > 0) {
|
||||
const dropped = new Set(droppable.slice(0, dropCount));
|
||||
next = {};
|
||||
for (const [id, data] of Object.entries(map)) {
|
||||
if (!dropped.has(id)) next[id] = data;
|
||||
}
|
||||
}
|
||||
attempts += 1;
|
||||
if (write(next)) {
|
||||
if (dropCount === 0) hint = null;
|
||||
return { persisted: true, written: next, attempts, quotaFailedChars: hint };
|
||||
}
|
||||
hint = hint === null ? chars : Math.min(hint, chars);
|
||||
if (dropCount >= droppable.length) {
|
||||
return { persisted: false, written: map, attempts, quotaFailedChars: hint };
|
||||
}
|
||||
const target = Math.min(Math.max(dropCount * 2, 1), droppable.length);
|
||||
for (let i = dropCount; i < target; i += 1) chars -= map[droppable[i]].length;
|
||||
dropCount = target;
|
||||
}
|
||||
};
|
||||
|
||||
/** Fold side-store maps together, then cap to the allowed unsaved/saved set. */
|
||||
export const mergeTerminalDataMapsForStorage = (
|
||||
logs: ConnectionLog[],
|
||||
|
||||
@@ -1,31 +0,0 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { HOST_BROWSER_MIN_LIVE_EXTENT, resolveHostBrowserPaneBesidePanels } from './hostBrowser';
|
||||
|
||||
const pane = { x: 200, y: 100, width: 500, height: 600 };
|
||||
|
||||
test('panels beside the pane leave it unchanged, even when the pane itself is small', () => {
|
||||
assert.equal(resolveHostBrowserPaneBesidePanels(pane, [{ x: 700, y: 0, width: 300, height: 900 }]), pane);
|
||||
const small = { x: 0, y: 0, width: HOST_BROWSER_MIN_LIVE_EXTENT - 1, height: 80 };
|
||||
assert.equal(resolveHostBrowserPaneBesidePanels(small, []), small);
|
||||
});
|
||||
|
||||
test('full-height or full-width edge panels shrink the pane to the remaining rectangle', () => {
|
||||
assert.deepEqual(resolveHostBrowserPaneBesidePanels(pane, [{ x: 450, y: 0, width: 600, height: 900 }]),
|
||||
{ x: 200, y: 100, width: 250, height: 600 });
|
||||
assert.deepEqual(resolveHostBrowserPaneBesidePanels(pane, [{ x: 0, y: 0, width: 300, height: 900 }]),
|
||||
{ x: 300, y: 100, width: 400, height: 600 });
|
||||
assert.deepEqual(resolveHostBrowserPaneBesidePanels(pane, [{ x: 0, y: 500, width: 900, height: 400 }]),
|
||||
{ x: 200, y: 100, width: 500, height: 400 });
|
||||
assert.deepEqual(resolveHostBrowserPaneBesidePanels(pane, [
|
||||
{ x: 0, y: 0, width: 300, height: 900 },
|
||||
{ x: 550, y: 0, width: 400, height: 900 },
|
||||
]), { x: 300, y: 100, width: 250, height: 600 });
|
||||
});
|
||||
|
||||
test('panels over the middle, a corner, or leaving too little room cannot keep a live page', () => {
|
||||
assert.equal(resolveHostBrowserPaneBesidePanels(pane, [{ x: 300, y: 0, width: 100, height: 900 }]), null);
|
||||
assert.equal(resolveHostBrowserPaneBesidePanels(pane, [{ x: 600, y: 0, width: 300, height: 300 }]), null);
|
||||
assert.equal(resolveHostBrowserPaneBesidePanels(pane, [{ x: 0, y: 0, width: 900, height: 900 }]), null);
|
||||
assert.equal(resolveHostBrowserPaneBesidePanels(pane, [{ x: 250, y: 0, width: 600, height: 900 }]), null);
|
||||
});
|
||||
@@ -63,39 +63,3 @@ export type HostBrowserPanelEvent = { browserId: string } & (
|
||||
| { type: 'bookmarks' }
|
||||
| { type: 'shortcut'; shortcut: HostBrowserShortcut }
|
||||
);
|
||||
|
||||
export interface HostBrowserPaneRect { x: number; y: number; width: number; height: number }
|
||||
|
||||
/** Below this extent a page beside an overlay panel is too cramped to keep live. */
|
||||
export const HOST_BROWSER_MIN_LIVE_EXTENT = 160;
|
||||
|
||||
/**
|
||||
* Native browser views draw above the renderer and cannot be clipped, so a
|
||||
* panel covering a full-height or full-width edge strip of the pane is avoided
|
||||
* by shrinking the page to the remaining rectangle. Returns null when a panel
|
||||
* covers the middle or leaves too little room; the pane then shows a picture.
|
||||
*/
|
||||
export function resolveHostBrowserPaneBesidePanels(
|
||||
pane: HostBrowserPaneRect,
|
||||
panels: readonly HostBrowserPaneRect[],
|
||||
): HostBrowserPaneRect | null {
|
||||
let left = pane.x;
|
||||
let top = pane.y;
|
||||
let right = pane.x + pane.width;
|
||||
let bottom = pane.y + pane.height;
|
||||
for (const panel of panels) {
|
||||
const panelRight = panel.x + panel.width;
|
||||
const panelBottom = panel.y + panel.height;
|
||||
if (panel.x >= right || panelRight <= left || panel.y >= bottom || panelBottom <= top) continue;
|
||||
const spansHeight = panel.y <= top && panelBottom >= bottom;
|
||||
const spansWidth = panel.x <= left && panelRight >= right;
|
||||
if (spansHeight && panel.x > left && panelRight >= right) right = panel.x;
|
||||
else if (spansHeight && panel.x <= left && panelRight < right) left = panelRight;
|
||||
else if (spansWidth && panel.y > top && panelBottom >= bottom) bottom = panel.y;
|
||||
else if (spansWidth && panel.y <= top && panelBottom < bottom) top = panelBottom;
|
||||
else return null;
|
||||
}
|
||||
if (left === pane.x && top === pane.y && right === pane.x + pane.width && bottom === pane.y + pane.height) return pane;
|
||||
if (right - left < HOST_BROWSER_MIN_LIVE_EXTENT || bottom - top < HOST_BROWSER_MIN_LIVE_EXTENT) return null;
|
||||
return { x: left, y: top, width: right - left, height: bottom - top };
|
||||
}
|
||||
@@ -11,13 +11,6 @@ import {
|
||||
isNetcattyManagedStartupHistoryCommand,
|
||||
} from './remoteHistory.ts';
|
||||
import { buildDockerExecShellCommand, buildDockerLogsCommand } from './systemManager/dockerShell.ts';
|
||||
import {
|
||||
buildContainerExecShellCommand,
|
||||
buildContainerLogsCommand,
|
||||
buildKubectlDescribeCommand,
|
||||
buildKubectlExecShellCommand,
|
||||
buildKubectlLogsCommand,
|
||||
} from './systemManager/containerRuntime.ts';
|
||||
import { buildTmuxAttachCommand } from './systemManager/tmuxShell.ts';
|
||||
|
||||
test('parseBashHistory: plain lines', () => {
|
||||
@@ -206,44 +199,8 @@ test('isNetcattyAiHistoryCommand: detects AI PTY marker lines', () => {
|
||||
test('isNetcattyManagedStartupHistoryCommand: detects Docker and tmux terminal launch commands', () => {
|
||||
assert.equal(isNetcattyManagedStartupHistoryCommand(buildDockerExecShellCommand('587abcdef123')), true);
|
||||
assert.equal(isNetcattyManagedStartupHistoryCommand(buildDockerLogsCommand('587abcdef123')), true);
|
||||
assert.equal(isNetcattyManagedStartupHistoryCommand(buildContainerLogsCommand('podman', '587abcdef123')), true);
|
||||
assert.equal(
|
||||
isNetcattyManagedStartupHistoryCommand(buildKubectlLogsCommand('kind-dev', 'default', 'api-0')),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
isNetcattyManagedStartupHistoryCommand(
|
||||
"printf '\\033[H\\033[2J\\033[3J'; _nc_docker_err=$(docker inspect 587abcdef123 2>&1 >/dev/null);",
|
||||
),
|
||||
true,
|
||||
);
|
||||
assert.equal(isNetcattyManagedStartupHistoryCommand(buildTmuxAttachCommand('my-session')), true);
|
||||
assert.equal(isNetcattyManagedStartupHistoryCommand(buildTmuxAttachCommand('my-session', 2)), true);
|
||||
assert.equal(isNetcattyManagedStartupHistoryCommand(buildContainerLogsCommand('podman', 'abc123', 'powershell')), true);
|
||||
assert.equal(isNetcattyManagedStartupHistoryCommand(buildContainerExecShellCommand('nerdctl', 'abc123', 'cmd')), true);
|
||||
assert.equal(
|
||||
isNetcattyManagedStartupHistoryCommand(buildKubectlLogsCommand('kind-kind', 'default', 'api', undefined, 'powershell')),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
isNetcattyManagedStartupHistoryCommand(buildKubectlDescribeCommand('kind-kind', 'default', 'api', 'cmd')),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
isNetcattyManagedStartupHistoryCommand(buildKubectlExecShellCommand('kind-kind', 'default', 'api', 'app', 'powershell')),
|
||||
true,
|
||||
);
|
||||
assert.equal(isNetcattyManagedStartupHistoryCommand('podman logs -f abc123'), false);
|
||||
assert.equal(isNetcattyManagedStartupHistoryCommand('kubectl logs api'), false);
|
||||
assert.equal(isNetcattyManagedStartupHistoryCommand('kubectl describe pod api'), false);
|
||||
assert.equal(
|
||||
isNetcattyManagedStartupHistoryCommand('kubectl --context prod -n default logs -f --tail 200 api'),
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
isNetcattyManagedStartupHistoryCommand('kubectl --context prod describe pod api'),
|
||||
false,
|
||||
);
|
||||
assert.equal(isNetcattyManagedStartupHistoryCommand('docker ps -a'), false);
|
||||
assert.equal(isNetcattyManagedStartupHistoryCommand('docker logs -f 587abcdef123'), false);
|
||||
assert.equal(isNetcattyManagedStartupHistoryCommand('docker exec -it 587abcdef123 bash'), false);
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { RemoteHistoryEntry, RemoteHistorySource } from './models';
|
||||
import { NETCATTY_POPUP_HISTORY_MARKER } from './systemManager/containerRuntime.ts';
|
||||
|
||||
/** Marker prefix Netcatty AI uses when executing commands via the PTY bridge. */
|
||||
export const NETCATTY_AI_HISTORY_MARKER = '__NCMCP_';
|
||||
@@ -10,17 +9,11 @@ export function isNetcattyAiHistoryCommand(command: string): boolean {
|
||||
}
|
||||
|
||||
const NETCATTY_MANAGED_STARTUP_COMMAND =
|
||||
/^(?:sh\s+-c\s+.*printf .*\\033\[H\\033\[2J\\033\[3J.*(?:_nc_(?:docker|rt)_err=.*\b(?:docker|podman|nerdctl)\s+inspect\b|exec\s+kubectl\b)|printf '\\033\[H\\033\[2J\\033\[3J';\s*(?:_nc_(?:docker|rt)_err=.*\b(?:docker|podman|nerdctl)\s+inspect\b|exec\s+(?:(?:docker|podman|nerdctl)\s+(?:exec|logs)\b|tmux\s+attach\b)))/;
|
||||
|
||||
const NETCATTY_WINDOWS_CONTAINER_POPUP_COMMAND =
|
||||
/^(?:\$__nc_popup='__NC_POPUP__'; |set "__NC_POPUP__=1"& )?(?:docker|podman|nerdctl) inspect [a-zA-Z0-9]+ (?:>\$null; if \(\$LASTEXITCODE -eq 0\) \{|>nul && )/;
|
||||
/^(?:sh\s+-c\s+.*printf .*\\033\[H\\033\[2J\\033\[3J.*_nc_docker_err=.*\bdocker\s+inspect\b|printf '\\033\[H\\033\[2J\\033\[3J';\s*(?:_nc_docker_err=.*\bdocker\s+inspect\b|exec\s+(?:docker\s+(?:exec|logs)\b|tmux\s+attach\b)))/;
|
||||
|
||||
/** True when a shell history line came from a Netcatty-managed terminal launch. */
|
||||
export function isNetcattyManagedStartupHistoryCommand(command: string): boolean {
|
||||
const text = command.trim();
|
||||
return text.includes(NETCATTY_POPUP_HISTORY_MARKER)
|
||||
|| NETCATTY_MANAGED_STARTUP_COMMAND.test(text)
|
||||
|| NETCATTY_WINDOWS_CONTAINER_POPUP_COMMAND.test(text);
|
||||
return NETCATTY_MANAGED_STARTUP_COMMAND.test(command.trim());
|
||||
}
|
||||
|
||||
const ZSH_EXTENDED_RECORD = /^: (\d+):\d+;([\s\S]*)$/;
|
||||
|
||||