Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
02d77d26af | ||
|
|
b036b023bb | ||
|
|
4c82424adf | ||
|
|
439f981c67 | ||
|
|
3d593b206b | ||
|
|
f95e490707 | ||
|
|
bc36f6a085 | ||
|
|
f7a166c3e9 | ||
|
|
7c82ec15d0 | ||
|
|
2849871111 | ||
|
|
36057a97d8 | ||
|
|
41c66f76fa | ||
|
|
54cedb09b2 | ||
|
|
1956513a5e | ||
|
|
5374b981ec | ||
|
|
864b8d46eb | ||
|
|
acebaa858c | ||
|
|
e7f4a37ec7 | ||
|
|
eeaf941778 | ||
|
|
38ff20ca80 | ||
|
|
ea376bd0ca | ||
|
|
de3d679a36 | ||
|
|
5690442eb3 | ||
|
|
c4bb1d1d64 | ||
|
|
1559739792 | ||
|
|
1ec954b424 | ||
|
|
e03b72ded2 | ||
|
|
07f422eb99 | ||
|
|
5c7750ca7e | ||
|
|
b939ec258e | ||
|
|
091ffb06a9 | ||
|
|
b91b00ee6b |
No files matched your search
@@ -9,6 +9,12 @@
|
||||
<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 ワークフロー · カスタムテーマ — すべてが一つに。
|
||||
@@ -40,7 +46,7 @@
|
||||
|
||||
---
|
||||
|
||||
<img width="3358" height="1984" alt="image" src="https://github.com/user-attachments/assets/e1302660-dece-46be-9857-1678a9b13d00" />
|
||||
<img width="3358" height="1984" alt="image" src="docs/assets/readme/overview.webp" />
|
||||
|
||||
---
|
||||
|
||||
@@ -63,13 +69,13 @@
|
||||
|
||||
Catty Agent にサーバーの健全性チェックを依頼すると、適切なコマンドを実行し、出力を分析して数秒で明確なサマリーを提示します。
|
||||
|
||||
<img width="3302" height="1978" alt="image" src="https://github.com/user-attachments/assets/3ec30145-d226-4e26-834b-52ee75b5e205" />
|
||||
<img width="3302" height="1978" alt="image" src="docs/assets/readme/catty-single-host.webp" />
|
||||
|
||||
#### 🚀 マルチホスト — Docker Swarm クラスターのセットアップ
|
||||
|
||||
1 つの会話で 2 台のサーバーをまたいで Docker Swarm クラスターをオーケストレーションする Catty Agent をご覧ください。初期化、トークン交換、ノード参加まですべてこなします — あなたは望む結果を伝えるだけ。
|
||||
|
||||
<img width="3294" height="1974" alt="image" src="https://github.com/user-attachments/assets/ca17e857-6163-4067-a615-cacdfd5be4fc" />
|
||||
<img width="3294" height="1974" alt="image" src="docs/assets/readme/catty-multi-host.webp" />
|
||||
|
||||
---
|
||||
|
||||
@@ -150,34 +156,34 @@ Catty Agent にサーバーの健全性チェックを依頼すると、適切
|
||||
|
||||
メインウィンドウは、長時間の SSH 作業を前提に設計されています。セッション、ナビゲーション、主要ツールへ素早くアクセスできます。
|
||||
|
||||
<img width="3290" height="1988" alt="image" src="https://github.com/user-attachments/assets/ab3afd3e-a819-46fc-9251-40acb592ba69" />
|
||||
<img width="3290" height="1988" alt="image" src="docs/assets/readme/main-window.webp" />
|
||||
|
||||
<img width="3324" height="1992" alt="image" src="https://github.com/user-attachments/assets/7c7f6e7f-fb20-4574-8e7f-74397682b873" />
|
||||
<img width="3324" height="1992" alt="image" src="docs/assets/readme/main-window-terminal.webp" />
|
||||
|
||||
<a name="vault-ビュー"></a>
|
||||
## Vault ビュー
|
||||
|
||||
作業に合わせて見え方を切り替え:グリッドで全体像、リストでスキャン、ツリーで整理と階層ナビゲーション。
|
||||
|
||||
<img width="3292" height="1984" alt="image" src="https://github.com/user-attachments/assets/29642143-3fd6-4693-a30c-b7f656053ca3" />
|
||||
<img width="3292" height="1984" alt="image" src="docs/assets/readme/vault-grid.webp" />
|
||||
|
||||
<img width="3322" height="1998" alt="image" src="https://github.com/user-attachments/assets/6041efe2-607e-4314-838c-eba3db4c852c" />
|
||||
<img width="3322" height="1998" alt="image" src="docs/assets/readme/vault-list.webp" />
|
||||
|
||||
<a name="モニタリングビュー"></a>
|
||||
## モニタリングビュー
|
||||
|
||||
CPU、メモリ、ディスク、ネットワーク、レイテンシーを確認でき、対応する環境では GPU の詳細も表示されます。表示する列の選択、列幅や更新設定の調整ができ、監視対象のホストから直接ターミナルを開くこともできます。
|
||||
|
||||
<img width="3364" height="1982" alt="image" src="https://github.com/user-attachments/assets/d0f495b5-25c8-494f-bb9c-8c20b0a921b6" />
|
||||
<img width="3364" height="1982" alt="image" src="docs/assets/readme/monitoring.webp" />
|
||||
|
||||
<img width="3362" height="1980" alt="image" src="https://github.com/user-attachments/assets/245c234e-2b22-43f2-a4fd-8057d229c56f" />
|
||||
<img width="3362" height="1980" alt="image" src="docs/assets/readme/monitoring-columns.webp" />
|
||||
|
||||
<a name="分割ターミナル"></a>
|
||||
## 分割ターミナル
|
||||
|
||||
分割ペインで複数のサーバー/タスクを同時に扱えます(例:デプロイ + ログ + 監視)。
|
||||
|
||||
<img width="1560" height="871" alt="split" src="https://github.com/user-attachments/assets/c93a8523-6256-4bb3-8b6e-d599831f2f9f" />
|
||||
<img width="1560" height="871" alt="split" src="docs/assets/readme/split-terminals.webp" />
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -46,7 +46,7 @@
|
||||
|
||||
---
|
||||
|
||||
<img width="3358" height="1984" alt="image" src="https://github.com/user-attachments/assets/e1302660-dece-46be-9857-1678a9b13d00" />
|
||||
<img width="3358" height="1984" alt="image" src="docs/assets/readme/overview.webp" />
|
||||
|
||||
|
||||
---
|
||||
@@ -69,14 +69,14 @@
|
||||
|
||||
Ask Catty Agent to check a server's health, and it runs the right commands, analyzes the output, and gives you a clear summary — all in seconds.
|
||||
|
||||
<img width="3302" height="1978" alt="image" src="https://github.com/user-attachments/assets/3ec30145-d226-4e26-834b-52ee75b5e205" />
|
||||
<img width="3302" height="1978" alt="image" src="docs/assets/readme/catty-single-host.webp" />
|
||||
|
||||
|
||||
#### 🚀 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="https://github.com/user-attachments/assets/ca17e857-6163-4067-a615-cacdfd5be4fc" />
|
||||
<img width="3294" height="1974" alt="image" src="docs/assets/readme/catty-multi-host.webp" />
|
||||
|
||||
|
||||
---
|
||||
@@ -159,10 +159,10 @@ If you regularly work with a fleet of servers, Netcatty is built for speed and f
|
||||
|
||||
The main window is designed for long-running SSH workflows: quick access to sessions, navigation, and core tools in one place.
|
||||
|
||||
<img width="3290" height="1988" alt="image" src="https://github.com/user-attachments/assets/ab3afd3e-a819-46fc-9251-40acb592ba69" />
|
||||
<img width="3290" height="1988" alt="image" src="docs/assets/readme/main-window.webp" />
|
||||
|
||||
|
||||
<img width="3324" height="1992" alt="image" src="https://github.com/user-attachments/assets/7c7f6e7f-fb20-4574-8e7f-74397682b873" />
|
||||
<img width="3324" height="1992" alt="image" src="docs/assets/readme/main-window-terminal.webp" />
|
||||
|
||||
|
||||
|
||||
@@ -171,10 +171,10 @@ The main window is designed for long-running SSH workflows: quick access to sess
|
||||
|
||||
Organize and navigate your hosts using the view that best fits the moment: grid for overview, list for scanning, tree for structure.
|
||||
|
||||
<img width="3292" height="1984" alt="image" src="https://github.com/user-attachments/assets/29642143-3fd6-4693-a30c-b7f656053ca3" />
|
||||
<img width="3292" height="1984" alt="image" src="docs/assets/readme/vault-grid.webp" />
|
||||
|
||||
|
||||
<img width="3322" height="1998" alt="image" src="https://github.com/user-attachments/assets/6041efe2-607e-4314-838c-eba3db4c852c" />
|
||||
<img width="3322" height="1998" alt="image" src="docs/assets/readme/vault-list.webp" />
|
||||
|
||||
|
||||
|
||||
@@ -183,9 +183,9 @@ Organize and navigate your hosts using the view that best fits the moment: grid
|
||||
|
||||
Inspect CPU, memory, disk, network, and latency readings, with GPU details where available. Choose visible columns, resize them, adjust refresh controls, and open a terminal directly from a monitored host.
|
||||
|
||||
<img width="3364" height="1982" alt="image" src="https://github.com/user-attachments/assets/d0f495b5-25c8-494f-bb9c-8c20b0a921b6" />
|
||||
<img width="3364" height="1982" alt="image" src="docs/assets/readme/monitoring.webp" />
|
||||
|
||||
<img width="3362" height="1980" alt="image" src="https://github.com/user-attachments/assets/245c234e-2b22-43f2-a4fd-8057d229c56f" />
|
||||
<img width="3362" height="1980" alt="image" src="docs/assets/readme/monitoring-columns.webp" />
|
||||
|
||||
|
||||
|
||||
@@ -195,7 +195,7 @@ Inspect CPU, memory, disk, network, and latency readings, with GPU details where
|
||||
|
||||
Split panes help you monitor multiple servers/services at the same time (deploy + logs + metrics) without juggling windows.
|
||||
|
||||
<img width="1560" height="871" alt="split" src="https://github.com/user-attachments/assets/c93a8523-6256-4bb3-8b6e-d599831f2f9f" />
|
||||
<img width="1560" height="871" alt="split" src="docs/assets/readme/split-terminals.webp" />
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -9,6 +9,12 @@
|
||||
<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 工作流 · 自定义主题 —— 一应俱全。
|
||||
@@ -40,7 +46,7 @@
|
||||
|
||||
---
|
||||
|
||||
<img width="3358" height="1984" alt="image" src="https://github.com/user-attachments/assets/e1302660-dece-46be-9857-1678a9b13d00" />
|
||||
<img width="3358" height="1984" alt="image" src="docs/assets/readme/overview.webp" />
|
||||
|
||||
---
|
||||
|
||||
@@ -63,13 +69,13 @@
|
||||
|
||||
让 Catty Agent 检查服务器健康状态,它会自动运行合适的命令、分析输出并给出清晰总结 —— 几秒内完成。
|
||||
|
||||
<img width="3302" height="1978" alt="image" src="https://github.com/user-attachments/assets/3ec30145-d226-4e26-834b-52ee75b5e205" />
|
||||
<img width="3302" height="1978" alt="image" src="docs/assets/readme/catty-single-host.webp" />
|
||||
|
||||
#### 🚀 多主机 —— Docker Swarm 集群搭建
|
||||
|
||||
看 Catty Agent 在一次对话中跨两台服务器编排 Docker Swarm 集群。它负责初始化、令牌交换和节点加入 —— 你只需说出想要的结果。
|
||||
|
||||
<img width="3294" height="1974" alt="image" src="https://github.com/user-attachments/assets/ca17e857-6163-4067-a615-cacdfd5be4fc" />
|
||||
<img width="3294" height="1974" alt="image" src="docs/assets/readme/catty-multi-host.webp" />
|
||||
|
||||
---
|
||||
|
||||
@@ -150,34 +156,34 @@
|
||||
|
||||
主界面围绕长期 SSH 工作流设计:把会话、导航和常用工具集中到同一处,减少切换成本。
|
||||
|
||||
<img width="3290" height="1988" alt="image" src="https://github.com/user-attachments/assets/ab3afd3e-a819-46fc-9251-40acb592ba69" />
|
||||
<img width="3290" height="1988" alt="image" src="docs/assets/readme/main-window.webp" />
|
||||
|
||||
<img width="3324" height="1992" alt="image" src="https://github.com/user-attachments/assets/7c7f6e7f-fb20-4574-8e7f-74397682b873" />
|
||||
<img width="3324" height="1992" alt="image" src="docs/assets/readme/main-window-terminal.webp" />
|
||||
|
||||
<a name="vault-视图"></a>
|
||||
## Vault 视图
|
||||
|
||||
用更适合当前任务的方式管理与浏览主机:网格看全局,列表做筛选,树形做整理与层级导航。
|
||||
|
||||
<img width="3292" height="1984" alt="image" src="https://github.com/user-attachments/assets/29642143-3fd6-4693-a30c-b7f656053ca3" />
|
||||
<img width="3292" height="1984" alt="image" src="docs/assets/readme/vault-grid.webp" />
|
||||
|
||||
<img width="3322" height="1998" alt="image" src="https://github.com/user-attachments/assets/6041efe2-607e-4314-838c-eba3db4c852c" />
|
||||
<img width="3322" height="1998" alt="image" src="docs/assets/readme/vault-list.webp" />
|
||||
|
||||
<a name="监控视图"></a>
|
||||
## 监控视图
|
||||
|
||||
查看 CPU、内存、磁盘、网络和延迟数据;支持的环境还可显示 GPU 详情。你可以选择显示的列、调整列宽和刷新设置,并直接从受监控的主机打开终端。
|
||||
|
||||
<img width="3364" height="1982" alt="image" src="https://github.com/user-attachments/assets/d0f495b5-25c8-494f-bb9c-8c20b0a921b6" />
|
||||
<img width="3364" height="1982" alt="image" src="docs/assets/readme/monitoring.webp" />
|
||||
|
||||
<img width="3362" height="1980" alt="image" src="https://github.com/user-attachments/assets/245c234e-2b22-43f2-a4fd-8057d229c56f" />
|
||||
<img width="3362" height="1980" alt="image" src="docs/assets/readme/monitoring-columns.webp" />
|
||||
|
||||
<a name="分屏终端"></a>
|
||||
## 分屏终端
|
||||
|
||||
分屏适合同时处理多个任务(例如部署 + 日志 + 排障),不用频繁切换窗口。
|
||||
|
||||
<img width="1560" height="871" alt="split" src="https://github.com/user-attachments/assets/c93a8523-6256-4bb3-8b6e-d599831f2f9f" />
|
||||
<img width="1560" height="871" alt="split" src="docs/assets/readme/split-terminals.webp" />
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -9,6 +9,12 @@
|
||||
<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 工作流 · 自訂主題 —— 全部整合在一起。
|
||||
@@ -40,7 +46,7 @@
|
||||
|
||||
---
|
||||
|
||||
<img width="3358" height="1984" alt="image" src="https://github.com/user-attachments/assets/e1302660-dece-46be-9857-1678a9b13d00" />
|
||||
<img width="3358" height="1984" alt="image" src="docs/assets/readme/overview.webp" />
|
||||
|
||||
---
|
||||
|
||||
@@ -63,13 +69,13 @@
|
||||
|
||||
請 Catty Agent 檢查伺服器健康狀況,它會自動執行合適的指令、分析輸出,並在幾秒內給你一份清楚的總結。
|
||||
|
||||
<img width="3302" height="1978" alt="image" src="https://github.com/user-attachments/assets/3ec30145-d226-4e26-834b-52ee75b5e205" />
|
||||
<img width="3302" height="1978" alt="image" src="docs/assets/readme/catty-single-host.webp" />
|
||||
|
||||
#### 🚀 多主機 —— 建立 Docker Swarm 叢集
|
||||
|
||||
看 Catty Agent 在一次對話裡,跨兩台伺服器編排出一個 Docker Swarm 叢集。初始化、權杖交換、節點加入都由它處理 —— 你只要說出你想要的結果。
|
||||
|
||||
<img width="3294" height="1974" alt="image" src="https://github.com/user-attachments/assets/ca17e857-6163-4067-a615-cacdfd5be4fc" />
|
||||
<img width="3294" height="1974" alt="image" src="docs/assets/readme/catty-multi-host.webp" />
|
||||
|
||||
---
|
||||
|
||||
@@ -150,34 +156,34 @@
|
||||
|
||||
主視窗是為了長時間的 SSH 工作流程而設計:工作階段、導覽與常用工具都集中在同一個地方。
|
||||
|
||||
<img width="3290" height="1988" alt="image" src="https://github.com/user-attachments/assets/ab3afd3e-a819-46fc-9251-40acb592ba69" />
|
||||
<img width="3290" height="1988" alt="image" src="docs/assets/readme/main-window.webp" />
|
||||
|
||||
<img width="3324" height="1992" alt="image" src="https://github.com/user-attachments/assets/7c7f6e7f-fb20-4574-8e7f-74397682b873" />
|
||||
<img width="3324" height="1992" alt="image" src="docs/assets/readme/main-window-terminal.webp" />
|
||||
|
||||
<a name="vault-檢視"></a>
|
||||
## Vault 檢視
|
||||
|
||||
用最適合當下情境的方式整理與瀏覽主機:網格看全局、清單快速掃視、樹狀理層級。
|
||||
|
||||
<img width="3292" height="1984" alt="image" src="https://github.com/user-attachments/assets/29642143-3fd6-4693-a30c-b7f656053ca3" />
|
||||
<img width="3292" height="1984" alt="image" src="docs/assets/readme/vault-grid.webp" />
|
||||
|
||||
<img width="3322" height="1998" alt="image" src="https://github.com/user-attachments/assets/6041efe2-607e-4314-838c-eba3db4c852c" />
|
||||
<img width="3322" height="1998" alt="image" src="docs/assets/readme/vault-list.webp" />
|
||||
|
||||
<a name="監控檢視"></a>
|
||||
## 監控檢視
|
||||
|
||||
查看 CPU、記憶體、磁碟、網路與延遲資料;支援的環境還可顯示 GPU 詳情。你可以選擇顯示的欄位、調整欄寬與重新整理設定,並直接從受監控的主機開啟終端機。
|
||||
|
||||
<img width="3364" height="1982" alt="image" src="https://github.com/user-attachments/assets/d0f495b5-25c8-494f-bb9c-8c20b0a921b6" />
|
||||
<img width="3364" height="1982" alt="image" src="docs/assets/readme/monitoring.webp" />
|
||||
|
||||
<img width="3362" height="1980" alt="image" src="https://github.com/user-attachments/assets/245c234e-2b22-43f2-a4fd-8057d229c56f" />
|
||||
<img width="3362" height="1980" alt="image" src="docs/assets/readme/monitoring-columns.webp" />
|
||||
|
||||
<a name="分割終端"></a>
|
||||
## 分割終端
|
||||
|
||||
分割窗格讓你同時盯著多台伺服器或多個服務(部署 + 日誌 + 監控指標),不用一直切視窗。
|
||||
|
||||
<img width="1560" height="871" alt="split" src="https://github.com/user-attachments/assets/c93a8523-6256-4bb3-8b6e-d599831f2f9f" />
|
||||
<img width="1560" height="871" alt="split" src="docs/assets/readme/split-terminals.webp" />
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -272,7 +272,7 @@ function AppViewInner({ domains }: AppViewProps) {
|
||||
handleEndSessionDrag, handleFollowAppTerminalThemeChange, handleHostConnectWithProtocolCheck, handleHotkeyAction, handleKeyboardInteractiveCancel, handleKeyboardInteractiveSubmit,
|
||||
handleOpenHostFromVaultNote, handleOpenQuickSwitcher, handleOpenSettings, handleOpenVaultHostFromChat, handleOpenVaultNoteFromChat, handleOpenVaultSectionFromChat, handleOpenVaultSnippetFromChat, handleRootContextMenu, handlePassphraseCancel, handlePassphraseSkip, handlePassphraseSubmit, handleFidoPromptSubmit, handleFidoPromptCancel, handleProtocolSelect,
|
||||
handleRequestCloseEditorTabRef, handleSessionStatusChange, handleSyncNowManual, handleTerminalDataCapture, handleUpdateHostFromTerminal,
|
||||
hostById, hosts, terminalHosts, updateTerminalHosts, hotkeyScheme, identities, importOrReuseKey, isBroadcastEnabled, isGlobalBroadcastEnabled, onToggleGlobalBroadcast, canUseGlobalBroadcast, isCreateWorkspaceOpen, isMacClient, isQuickSwitcherOpen,
|
||||
hostById, hosts, terminalHosts, updateTerminalHosts, hotkeyScheme, identities, importOrReuseKey, isBroadcastEnabled, isComposeSendToAll, onToggleComposeSendTarget, isGlobalBroadcastEnabled, isGlobalComposeSendToAll, onToggleGlobalBroadcast, onToggleGlobalComposeSendTarget, canUseGlobalBroadcast, isCreateWorkspaceOpen, isMacClient, isQuickSwitcherOpen,
|
||||
keyBindings, keyboardInteractiveQueue, keys, logViews, managedSources, navigateToSection, openLogView, openNoteRequest, orderedTabsWithEditors, orphanSessions,
|
||||
passphraseQueue, fidoPromptQueue, protocolSelectHost, proxyProfiles, portForwardingRules, quickResults, quickSearch, removeSessionFromWorkspace, reorderWorkTabs, reorderWorkspaceSessions,
|
||||
resolveEmptyVaultConflict, resolveSessionAppearance, runSnippet, sessionLogsDir, sessionLogsEnabled, sessionLogsFormat, sessionLogsTimestampsEnabled, sessionRenameTarget, sshDebugLogsEnabled,
|
||||
@@ -784,8 +784,12 @@ function AppViewInner({ domains }: AppViewProps) {
|
||||
onCreateLocalTerminal={handleCreateLocalTerminal}
|
||||
isBroadcastEnabled={isBroadcastEnabled}
|
||||
onToggleBroadcast={toggleBroadcast}
|
||||
isComposeSendToAll={isComposeSendToAll}
|
||||
onToggleComposeSendTarget={onToggleComposeSendTarget}
|
||||
isGlobalBroadcastEnabled={isGlobalBroadcastEnabled}
|
||||
isGlobalComposeSendToAll={isGlobalComposeSendToAll}
|
||||
onToggleGlobalBroadcast={onToggleGlobalBroadcast}
|
||||
onToggleGlobalComposeSendTarget={onToggleGlobalComposeSendTarget}
|
||||
canUseGlobalBroadcast={canUseGlobalBroadcast}
|
||||
updateHosts={updateTerminalHosts}
|
||||
updateSnippets={updateSnippets}
|
||||
|
||||
@@ -205,7 +205,11 @@ export function TerminalHost() {
|
||||
updateTerminalHosts: handlers.updateTerminalHosts,
|
||||
hotkeyScheme: terminalSettings.hotkeyScheme,
|
||||
isBroadcastEnabled: sessionActions?.isBroadcastEnabled,
|
||||
isComposeSendToAll: sessionActions?.isComposeSendToAll,
|
||||
onToggleComposeSendTarget: sessionActions?.toggleComposeSendTarget,
|
||||
isGlobalBroadcastEnabled: sessionActions?.isGlobalBroadcastEnabled,
|
||||
isGlobalComposeSendToAll: sessionActions?.isGlobalComposeSendToAll,
|
||||
onToggleGlobalComposeSendTarget: sessionActions?.toggleGlobalComposeSendTarget,
|
||||
canUseGlobalBroadcast: sessionActions?.canUseGlobalBroadcast,
|
||||
keyBindings: terminalSettings.keyBindings,
|
||||
openNoteRequest: local.openNoteRequest,
|
||||
|
||||
@@ -70,8 +70,12 @@ export function SessionPublisher({ persistSessionRestore, children }: SessionPub
|
||||
reorderTabs,
|
||||
toggleBroadcast,
|
||||
isBroadcastEnabled,
|
||||
toggleComposeSendTarget,
|
||||
isComposeSendToAll,
|
||||
toggleGlobalBroadcast,
|
||||
isGlobalBroadcastEnabled,
|
||||
toggleGlobalComposeSendTarget,
|
||||
isGlobalComposeSendToAll,
|
||||
canUseGlobalBroadcast,
|
||||
copySession,
|
||||
copyWorkspace,
|
||||
@@ -150,8 +154,12 @@ export function SessionPublisher({ persistSessionRestore, children }: SessionPub
|
||||
reorderTabs,
|
||||
toggleBroadcast,
|
||||
isBroadcastEnabled,
|
||||
toggleComposeSendTarget,
|
||||
isComposeSendToAll,
|
||||
toggleGlobalBroadcast,
|
||||
isGlobalBroadcastEnabled,
|
||||
toggleGlobalComposeSendTarget,
|
||||
isGlobalComposeSendToAll,
|
||||
canUseGlobalBroadcast,
|
||||
copySession,
|
||||
copyWorkspace,
|
||||
@@ -181,7 +189,9 @@ export function SessionPublisher({ persistSessionRestore, children }: SessionPub
|
||||
getOrderedWorkTabs,
|
||||
getSessionRestoreCwd,
|
||||
isBroadcastEnabled,
|
||||
isComposeSendToAll,
|
||||
isGlobalBroadcastEnabled,
|
||||
isGlobalComposeSendToAll,
|
||||
moveFocusInWorkspace,
|
||||
openLogView,
|
||||
canUseGlobalBroadcast,
|
||||
@@ -201,7 +211,9 @@ export function SessionPublisher({ persistSessionRestore, children }: SessionPub
|
||||
submitSessionRename,
|
||||
submitWorkspaceRename,
|
||||
toggleBroadcast,
|
||||
toggleComposeSendTarget,
|
||||
toggleGlobalBroadcast,
|
||||
toggleGlobalComposeSendTarget,
|
||||
toggleWorkspaceViewMode,
|
||||
updateSessionCodingCliProvider,
|
||||
updateSessionDynamicTitle,
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
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,30 +356,51 @@ 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) toast.warning(t('sftp.editor.quitBlockedByDirty'), 'SFTP');
|
||||
if (hasDirty) {
|
||||
reason = "editors";
|
||||
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);
|
||||
bridge.reportDirtyEditorsResult?.(hasDirty, reason);
|
||||
} 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]);
|
||||
|
||||
@@ -1,6 +1,15 @@
|
||||
import type { Messages } from '../types';
|
||||
|
||||
export const enTerminalMessages: Messages = {
|
||||
'terminal.toolbar.openSessionLog': 'Open session log',
|
||||
'terminal.toolbar.sessionLogActions': 'Session log actions',
|
||||
'terminal.toolbar.sessionLog.menu': 'Session log',
|
||||
'terminal.toolbar.sessionLog.open': 'Open',
|
||||
'terminal.toolbar.sessionLog.reveal': 'Show in folder',
|
||||
'terminal.toolbar.sessionLog.chooseApplication': 'Open with…',
|
||||
'terminal.toolbar.sessionLog.systemDefault': 'Open with system default',
|
||||
'terminal.toolbar.openSessionLogFailed': 'Failed to open session log.',
|
||||
|
||||
'terminal.menu.saveScreen': 'Save current screen',
|
||||
'terminal.saveScreen.failed': 'Failed to save terminal screen.',
|
||||
// Multi-line paste confirmation dialog (#3398)
|
||||
@@ -68,6 +77,8 @@ export const enTerminalMessages: Messages = {
|
||||
'terminal.composeBar.send': 'Send',
|
||||
'terminal.composeBar.close': 'Close compose bar',
|
||||
'terminal.composeBar.broadcasting': 'Broadcasting to all sessions',
|
||||
'terminal.composeBar.sendToAll': 'Send to all sessions',
|
||||
'terminal.composeBar.sendToActive': 'Send to the active session only',
|
||||
'terminal.composeBar.broadcastAllowPassword': 'Broadcast without password protection',
|
||||
'terminal.composeBar.resize': 'Resize compose bar height',
|
||||
'terminal.composeBar.manageSnippets': 'Manage quick snippets',
|
||||
|
||||
@@ -1,6 +1,15 @@
|
||||
import type { Messages } from '../types';
|
||||
|
||||
export const esTerminalMessages: Messages = {
|
||||
'terminal.toolbar.openSessionLog': 'Abrir registro de sesión',
|
||||
'terminal.toolbar.sessionLogActions': 'Acciones del registro',
|
||||
'terminal.toolbar.sessionLog.menu': 'Registro de sesión',
|
||||
'terminal.toolbar.sessionLog.open': 'Abrir',
|
||||
'terminal.toolbar.sessionLog.reveal': 'Mostrar en carpeta',
|
||||
'terminal.toolbar.sessionLog.chooseApplication': 'Abrir con…',
|
||||
'terminal.toolbar.sessionLog.systemDefault': 'Abrir con la aplicación predeterminada',
|
||||
'terminal.toolbar.openSessionLogFailed': 'No se pudo abrir el registro.',
|
||||
|
||||
'terminal.menu.saveScreen': 'Guardar pantalla actual',
|
||||
'terminal.saveScreen.failed': 'No se pudo guardar la pantalla del terminal.',
|
||||
// Diálogo de confirmación de pegado multilínea (#3398)
|
||||
@@ -68,6 +77,8 @@ export const esTerminalMessages: Messages = {
|
||||
'terminal.composeBar.send': 'Enviar',
|
||||
'terminal.composeBar.close': 'Cerrar barra de redacción',
|
||||
'terminal.composeBar.broadcasting': 'Difundiendo a todas las sesiones',
|
||||
'terminal.composeBar.sendToAll': 'Enviar a todas las sesiones',
|
||||
'terminal.composeBar.sendToActive': 'Enviar solo a la sesión activa',
|
||||
'terminal.composeBar.broadcastAllowPassword': 'Difundir sin protección de contraseña',
|
||||
'terminal.composeBar.resize': 'Cambiar la altura de la barra de redacción',
|
||||
'terminal.composeBar.manageSnippets': 'Administrar snippets rápidos',
|
||||
|
||||
@@ -1,6 +1,15 @@
|
||||
import type { Messages } from '../types';
|
||||
|
||||
export const ruTerminalMessages: Messages = {
|
||||
'terminal.toolbar.openSessionLog': 'Открыть журнал сессии',
|
||||
'terminal.toolbar.sessionLogActions': 'Действия с журналом',
|
||||
'terminal.toolbar.sessionLog.menu': 'Журнал сессии',
|
||||
'terminal.toolbar.sessionLog.open': 'Открыть',
|
||||
'terminal.toolbar.sessionLog.reveal': 'Показать в папке',
|
||||
'terminal.toolbar.sessionLog.chooseApplication': 'Открыть с помощью…',
|
||||
'terminal.toolbar.sessionLog.systemDefault': 'Открыть по умолчанию',
|
||||
'terminal.toolbar.openSessionLogFailed': 'Не удалось открыть журнал.',
|
||||
|
||||
'terminal.menu.saveScreen': 'Сохранить текущий экран',
|
||||
'terminal.saveScreen.failed': 'Не удалось сохранить экран терминала.',
|
||||
// Диалог подтверждения многострочной вставки (#3398)
|
||||
@@ -89,6 +98,8 @@ export const ruTerminalMessages: Messages = {
|
||||
'terminal.composeBar.send': 'Отправить',
|
||||
'terminal.composeBar.close': 'Закрыть строку ввода',
|
||||
'terminal.composeBar.broadcasting': 'Трансляция во все сессии',
|
||||
'terminal.composeBar.sendToAll': 'Отправить во все сессии',
|
||||
'terminal.composeBar.sendToActive': 'Отправить только в активную сессию',
|
||||
'terminal.composeBar.broadcastAllowPassword': 'Транслировать без защиты пароля',
|
||||
'terminal.composeBar.resize': 'Изменить высоту строки ввода',
|
||||
'terminal.composeBar.manageSnippets': 'Управление быстрыми сниппетами',
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import en from './en';
|
||||
import es from './es';
|
||||
import ru from './ru';
|
||||
import zhCN from './zh-CN';
|
||||
import zhTW from './zh-TW';
|
||||
|
||||
const keys = [
|
||||
'terminal.toolbar.openSessionLog',
|
||||
'terminal.toolbar.sessionLogActions',
|
||||
'terminal.toolbar.sessionLog.menu',
|
||||
'terminal.toolbar.sessionLog.open',
|
||||
'terminal.toolbar.sessionLog.reveal',
|
||||
'terminal.toolbar.sessionLog.chooseApplication',
|
||||
'terminal.toolbar.sessionLog.systemDefault',
|
||||
'terminal.toolbar.openSessionLogFailed',
|
||||
] as const;
|
||||
|
||||
test('saved session log actions are translated in every shipped locale', () => {
|
||||
for (const [locale, messages] of Object.entries({ en, es, ru, 'zh-CN': zhCN, 'zh-TW': zhTW })) {
|
||||
for (const key of keys) assert.ok(messages[key], `${locale} is missing ${key}`);
|
||||
assert.equal(messages['terminal.toolbar.sessionLog.rememberApplication'], undefined);
|
||||
}
|
||||
assert.equal(en['terminal.toolbar.sessionLog.menu'], 'Session log');
|
||||
assert.equal(en['terminal.toolbar.sessionLog.open'], 'Open');
|
||||
assert.equal(en['terminal.toolbar.sessionLog.reveal'], 'Show in folder');
|
||||
assert.equal(en['terminal.toolbar.sessionLog.chooseApplication'], 'Open with…');
|
||||
assert.equal(en['terminal.toolbar.sessionLog.systemDefault'], 'Open with system default');
|
||||
assert.equal(zhCN['terminal.toolbar.sessionLog.menu'], '会话日志');
|
||||
assert.equal(zhTW['terminal.toolbar.sessionLog.menu'], '工作階段日誌');
|
||||
});
|
||||
@@ -1,6 +1,15 @@
|
||||
import type { Messages } from '../types';
|
||||
|
||||
export const zhCNTerminalMessages: Messages = {
|
||||
'terminal.toolbar.openSessionLog': '打开会话日志',
|
||||
'terminal.toolbar.sessionLogActions': '会话日志操作',
|
||||
'terminal.toolbar.sessionLog.menu': '会话日志',
|
||||
'terminal.toolbar.sessionLog.open': '打开',
|
||||
'terminal.toolbar.sessionLog.reveal': '在文件夹中显示',
|
||||
'terminal.toolbar.sessionLog.chooseApplication': '打开方式…',
|
||||
'terminal.toolbar.sessionLog.systemDefault': '用系统默认应用打开',
|
||||
'terminal.toolbar.openSessionLogFailed': '无法打开会话日志。',
|
||||
|
||||
'terminal.menu.saveScreen': '保存当前屏幕',
|
||||
'terminal.saveScreen.failed': '保存终端屏幕失败。',
|
||||
// 多行粘贴确认对话框(#3398)
|
||||
|
||||
@@ -524,6 +524,8 @@ export const zhCNVaultMessages: Messages = {
|
||||
'terminal.composeBar.send': '发送',
|
||||
'terminal.composeBar.close': '关闭撰写栏',
|
||||
'terminal.composeBar.broadcasting': '正在广播到所有会话',
|
||||
'terminal.composeBar.sendToAll': '发送到所有会话',
|
||||
'terminal.composeBar.sendToActive': '仅发送到当前会话',
|
||||
'terminal.composeBar.broadcastAllowPassword': '广播时不对密码进行保护',
|
||||
'terminal.composeBar.resize': '拖拽调整撰写栏高度',
|
||||
'terminal.composeBar.manageSnippets': '管理快捷代码片段',
|
||||
|
||||
@@ -1,6 +1,15 @@
|
||||
import type { Messages } from '../types';
|
||||
|
||||
export const zhTWTerminalMessages: Messages = {
|
||||
'terminal.toolbar.openSessionLog': '開啟工作階段日誌',
|
||||
'terminal.toolbar.sessionLogActions': '工作階段日誌操作',
|
||||
'terminal.toolbar.sessionLog.menu': '工作階段日誌',
|
||||
'terminal.toolbar.sessionLog.open': '開啟',
|
||||
'terminal.toolbar.sessionLog.reveal': '在資料夾中顯示',
|
||||
'terminal.toolbar.sessionLog.chooseApplication': '開啟方式…',
|
||||
'terminal.toolbar.sessionLog.systemDefault': '用系統預設應用程式開啟',
|
||||
'terminal.toolbar.openSessionLogFailed': '無法開啟工作階段日誌。',
|
||||
|
||||
'terminal.menu.saveScreen': '儲存目前畫面',
|
||||
'terminal.saveScreen.failed': '儲存終端畫面失敗。',
|
||||
// 多行貼上確認對話框(#3398)
|
||||
|
||||
@@ -524,6 +524,8 @@ export const zhTWVaultMessages: Messages = {
|
||||
'terminal.composeBar.send': '傳送',
|
||||
'terminal.composeBar.close': '關閉撰寫欄',
|
||||
'terminal.composeBar.broadcasting': '正在廣播到所有工作階段',
|
||||
'terminal.composeBar.sendToAll': '傳送到所有工作階段',
|
||||
'terminal.composeBar.sendToActive': '僅傳送到目前工作階段',
|
||||
'terminal.composeBar.broadcastAllowPassword': '廣播時不對密碼進行保護',
|
||||
'terminal.composeBar.resize': '拖曳調整撰寫欄高度',
|
||||
'terminal.composeBar.manageSnippets': '管理快捷程式碼片段',
|
||||
|
||||
@@ -717,3 +717,103 @@ 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,12 +363,30 @@ export function serializeSessionsForStorage(
|
||||
sessions: AISession[],
|
||||
budgetBytes: number = MAX_SESSIONS_JSON_BYTES,
|
||||
): { json: string; sessions: AISession[] } {
|
||||
const serialized = pruneSessionsForStorage(sessions).map(rawSession => {
|
||||
const prepared = 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 [];
|
||||
@@ -379,7 +397,6 @@ export function serializeSessionsForStorage(
|
||||
}];
|
||||
});
|
||||
const strippedSession = stripEncryptedReasoningFromSession(session);
|
||||
const json = JSON.stringify(session);
|
||||
return {
|
||||
session,
|
||||
json,
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { JSDOM } from 'jsdom';
|
||||
import { performSessionLogAction } from './sessionLogActions';
|
||||
import { STORAGE_KEY_SESSION_LOG_APPLICATION } from '../../infrastructure/config/storageKeys';
|
||||
|
||||
test('session log actions resolve and flush the selected session, remember apps and reset to default', async () => {
|
||||
const dom = new JSDOM('', { url: 'http://localhost' });
|
||||
const previousWindow = Object.getOwnPropertyDescriptor(globalThis, 'window');
|
||||
const previousStorage = Object.getOwnPropertyDescriptor(globalThis, 'localStorage');
|
||||
Object.defineProperty(globalThis, 'window', { configurable: true, value: dom.window });
|
||||
Object.defineProperty(globalThis, 'localStorage', { configurable: true, value: dom.window.localStorage });
|
||||
const calls: unknown[][] = [];
|
||||
let picked: { path: string; name: string } | null = { path: '/Applications/My Editor.app', name: 'My Editor' };
|
||||
let available = true;
|
||||
let failOpen = false;
|
||||
const bridge = {
|
||||
prepareSessionLog: async (payload: { sessionId: string }) => {
|
||||
calls.push(['prepare', payload]);
|
||||
return available ? { success: true, filePath: `/logs/${payload.sessionId}.html` }
|
||||
: { success: false, error: 'No saved log for this session' };
|
||||
},
|
||||
revealSessionLog: async (payload: { sessionId: string }) => {
|
||||
calls.push(['reveal', payload]);
|
||||
return { success: true };
|
||||
},
|
||||
selectApplication: async () => { calls.push(['choose']); return picked; },
|
||||
openWithApplication: async (file: string, app: string) => {
|
||||
calls.push(['application', file, app]);
|
||||
if (failOpen) throw new Error('Launch failed');
|
||||
return true;
|
||||
},
|
||||
openWithSystemDefault: async (file: string) => { calls.push(['default', file]); return { success: true }; },
|
||||
};
|
||||
Object.assign(dom.window, { netcatty: bridge });
|
||||
try {
|
||||
await performSessionLogAction('first', 'open');
|
||||
assert.deepEqual(calls.splice(0), [['prepare', { sessionId: 'first' }], ['default', '/logs/first.html']]);
|
||||
await performSessionLogAction('second', 'chooseApplication');
|
||||
assert.deepEqual(calls.splice(0), [
|
||||
['prepare', { sessionId: 'second' }], ['choose'], ['prepare', { sessionId: 'second' }],
|
||||
['application', '/logs/second.html', picked!.path],
|
||||
]);
|
||||
assert.equal(JSON.parse(dom.window.localStorage.getItem(STORAGE_KEY_SESSION_LOG_APPLICATION)!).name, 'My Editor');
|
||||
await performSessionLogAction('first', 'open');
|
||||
assert.deepEqual(calls.splice(0), [['prepare', { sessionId: 'first' }], ['application', '/logs/first.html', picked!.path]]);
|
||||
await performSessionLogAction('first', 'reveal');
|
||||
assert.deepEqual(calls.splice(0), [['reveal', { sessionId: 'first' }]]);
|
||||
await performSessionLogAction('first', 'systemDefault');
|
||||
assert.equal(dom.window.localStorage.getItem(STORAGE_KEY_SESSION_LOG_APPLICATION), null);
|
||||
calls.length = 0;
|
||||
picked = null;
|
||||
await performSessionLogAction('first', 'chooseApplication');
|
||||
assert.deepEqual(calls.splice(0), [['prepare', { sessionId: 'first' }], ['choose']]);
|
||||
available = false;
|
||||
await assert.rejects(performSessionLogAction('missing', 'chooseApplication'), /No saved log/);
|
||||
assert.deepEqual(calls.splice(0), [['prepare', { sessionId: 'missing' }]]);
|
||||
available = true;
|
||||
picked = { path: '/missing-editor', name: 'Missing' };
|
||||
failOpen = true;
|
||||
await assert.rejects(performSessionLogAction('first', 'chooseApplication'), /Launch failed/);
|
||||
assert.equal(dom.window.localStorage.getItem(STORAGE_KEY_SESSION_LOG_APPLICATION), null);
|
||||
} finally {
|
||||
if (previousWindow) Object.defineProperty(globalThis, 'window', previousWindow);
|
||||
else Reflect.deleteProperty(globalThis, 'window');
|
||||
if (previousStorage) Object.defineProperty(globalThis, 'localStorage', previousStorage);
|
||||
else Reflect.deleteProperty(globalThis, 'localStorage');
|
||||
dom.window.close();
|
||||
}
|
||||
});
|
||||
|
||||
// The integration boundary must translate backend errors, including ordinary
|
||||
// Error instances. Raw filesystem/app-launch details belong only in diagnostics.
|
||||
test('terminal log action errors use the translated toast instead of raw backend text', () => {
|
||||
const source = readFileSync(new URL('../../components/Terminal.tsx', import.meta.url), 'utf8');
|
||||
const handler = source.slice(source.indexOf(' const handleSessionLogAction ='),
|
||||
source.indexOf(' const handleToggleSessionLog ='));
|
||||
assert.match(handler, /logger\.error\("\[Terminal\] Failed to open or reveal session log:", err\)/);
|
||||
assert.match(handler, /toast\.error\(t\('terminal\.toolbar\.openSessionLogFailed'\)\)/);
|
||||
assert.doesNotMatch(handler, /err\.message/);
|
||||
});
|
||||
@@ -0,0 +1,48 @@
|
||||
import { STORAGE_KEY_SESSION_LOG_APPLICATION } from '../../infrastructure/config/storageKeys';
|
||||
import { localStorageAdapter } from '../../infrastructure/persistence/localStorageAdapter';
|
||||
import { netcattyBridge } from '../../infrastructure/services/netcattyBridge';
|
||||
|
||||
export type SessionLogAction = 'open' | 'reveal' | 'chooseApplication' | 'systemDefault';
|
||||
type LogApplication = { path: string; name: string };
|
||||
|
||||
/** Resolve the session's actual log in its owning process before opening it. */
|
||||
export async function performSessionLogAction(sessionId: string, action: SessionLogAction): Promise<void> {
|
||||
const bridge = netcattyBridge.get();
|
||||
if (!bridge?.prepareSessionLog) throw new Error('Session log bridge unavailable');
|
||||
if (action === 'reveal') {
|
||||
const result = await bridge.revealSessionLog?.({ sessionId });
|
||||
if (!result?.success) throw new Error(result?.error || 'Failed to reveal session log');
|
||||
return;
|
||||
}
|
||||
|
||||
// Check availability before presenting an application picker. Resolve again
|
||||
// afterwards so any output received while the picker was open is flushed.
|
||||
let result = await bridge.prepareSessionLog({ sessionId });
|
||||
if (!result.success || !result.filePath) throw new Error(result.error || 'No saved log for this session');
|
||||
let application = localStorageAdapter.read<LogApplication>(STORAGE_KEY_SESSION_LOG_APPLICATION);
|
||||
if (action === 'chooseApplication') {
|
||||
if (!bridge.selectApplication) throw new Error('Application picker unavailable');
|
||||
application = await bridge.selectApplication();
|
||||
if (!application) return;
|
||||
result = await bridge.prepareSessionLog({ sessionId });
|
||||
if (!result.success || !result.filePath) throw new Error(result.error || 'No saved log for this session');
|
||||
} else if (action === 'systemDefault') {
|
||||
application = null;
|
||||
}
|
||||
|
||||
if (application?.path) {
|
||||
if (!bridge.openWithApplication) throw new Error('Application opening unavailable');
|
||||
const opened = await bridge.openWithApplication(result.filePath, application.path);
|
||||
if (!opened) throw new Error('Failed to open session log');
|
||||
} else {
|
||||
const opened = await bridge.openWithSystemDefault?.(result.filePath);
|
||||
if (!opened?.success) throw new Error(opened?.error || 'Failed to open session log');
|
||||
}
|
||||
// Only remember a choice after a successful launch; failed launches leave
|
||||
// the previous preference intact and can be retried or reset from the menu.
|
||||
if (action === 'chooseApplication') {
|
||||
localStorageAdapter.write(STORAGE_KEY_SESSION_LOG_APPLICATION, application);
|
||||
} else if (action === 'systemDefault') {
|
||||
localStorageAdapter.remove(STORAGE_KEY_SESSION_LOG_APPLICATION);
|
||||
}
|
||||
}
|
||||
@@ -102,6 +102,10 @@ test('session actions register and unregister', () => {
|
||||
reorderTabs: noop,
|
||||
toggleBroadcast: noop,
|
||||
isBroadcastEnabled: noop,
|
||||
toggleComposeSendTarget: noop,
|
||||
isComposeSendToAll: noop,
|
||||
toggleGlobalComposeSendTarget: noop,
|
||||
isGlobalComposeSendToAll: false,
|
||||
copySession: noop,
|
||||
copyWorkspace: noop,
|
||||
createSessionFromCloneSource: noop,
|
||||
|
||||
@@ -68,8 +68,12 @@ export type SessionSnapshotActions = Pick<
|
||||
| 'reorderTabs'
|
||||
| 'toggleBroadcast'
|
||||
| 'isBroadcastEnabled'
|
||||
| 'toggleComposeSendTarget'
|
||||
| 'isComposeSendToAll'
|
||||
| 'toggleGlobalBroadcast'
|
||||
| 'isGlobalBroadcastEnabled'
|
||||
| 'toggleGlobalComposeSendTarget'
|
||||
| 'isGlobalComposeSendToAll'
|
||||
| 'canUseGlobalBroadcast'
|
||||
| 'copySession'
|
||||
| 'copyWorkspace'
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
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);
|
||||
});
|
||||
@@ -0,0 +1,25 @@
|
||||
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,6 +334,111 @@ 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,6 +23,7 @@ import {
|
||||
DEFAULT_SFTP_DIRECTORY_LISTING_CONCURRENCY,
|
||||
resolveSftpDirectoryListingConcurrency,
|
||||
resolveSftpSkipUnchangedEnabled,
|
||||
resolveSftpTransferConcurrency,
|
||||
runSftpTransferWorkers,
|
||||
} from "./transferConcurrency";
|
||||
import { getSftpTransferResourceKeys, globalSftpTransferScheduler } from "./globalTransferScheduler";
|
||||
@@ -78,11 +79,30 @@ 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(
|
||||
@@ -527,7 +547,7 @@ export function useSftpDirectoryTransferOps({
|
||||
};
|
||||
|
||||
/** Returns number of failed child file transfers */
|
||||
const transferDirectory = async (
|
||||
const walkDirectory = async (
|
||||
task: TransferTask,
|
||||
sourceSftpId: string | null,
|
||||
targetSftpId: string | null,
|
||||
@@ -541,7 +561,8 @@ 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");
|
||||
@@ -552,11 +573,12 @@ 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 = discoveryProgress ?? {
|
||||
const progress: DirectoryDiscoveryProgress = discoveryProgress ?? rootProgress ?? {
|
||||
discoveredFiles: 0,
|
||||
nextEntryIndex: 0,
|
||||
listingGate: createDirectoryListingGate(resolveSftpDirectoryListingConcurrency()),
|
||||
};
|
||||
if (!progress.pendingFileBatches) progress.pendingFileBatches = [];
|
||||
if (!progress.listingGate) {
|
||||
progress.listingGate = createDirectoryListingGate(resolveSftpDirectoryListingConcurrency());
|
||||
}
|
||||
@@ -684,7 +706,7 @@ export function useSftpDirectoryTransferOps({
|
||||
};
|
||||
|
||||
const isSymlink = dir.type === "symlink";
|
||||
totalErrors += await transferDirectory(
|
||||
totalErrors += await walkDirectory(
|
||||
childTask,
|
||||
sourceSftpId,
|
||||
targetSftpId,
|
||||
@@ -721,11 +743,21 @@ 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(
|
||||
regularFiles,
|
||||
directoryFiles,
|
||||
() => localStorageAdapter.readNumber(STORAGE_KEY_SFTP_TRANSFER_CONCURRENCY),
|
||||
async (file, fileIndex) => {
|
||||
async (file, fileIndex) => fileGate.run(async () => {
|
||||
if (!sessionLostError && progress.sessionLostError) sessionLostError = progress.sessionLostError;
|
||||
if (sessionLostError) throw sessionLostError;
|
||||
if (cancelledTasksRef.current.has(task.id) || cancelledTasksRef.current.has(rootTaskId)) {
|
||||
throw new Error("Transfer cancelled");
|
||||
@@ -993,6 +1025,7 @@ 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
|
||||
@@ -1015,10 +1048,10 @@ export function useSftpDirectoryTransferOps({
|
||||
await waitWhileTransferPaused(rootTaskId);
|
||||
}
|
||||
}
|
||||
},
|
||||
}),
|
||||
{
|
||||
beforeClaim: async () => {
|
||||
if (sessionLostError) return;
|
||||
if (sessionLostError || progress.sessionLostError) return;
|
||||
await waitWhileTransferPaused(rootTaskId);
|
||||
},
|
||||
},
|
||||
@@ -1030,17 +1063,69 @@ 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 };
|
||||
}
|
||||
@@ -0,0 +1,226 @@
|
||||
/** @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();
|
||||
}
|
||||
});
|
||||
@@ -440,6 +440,9 @@ 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");
|
||||
|
||||
@@ -34,6 +34,7 @@ import {
|
||||
resolveDirectDirectoryDownloadFinalStatus,
|
||||
} from "./downloadTransferTask";
|
||||
import {
|
||||
isTransferOrRootPauseLatched,
|
||||
releaseTransferPauseTree,
|
||||
waitUntilTransferPauseReleased,
|
||||
} from "./transferPauseLatch";
|
||||
@@ -158,6 +159,89 @@ 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">,
|
||||
@@ -237,29 +321,85 @@ 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;
|
||||
sftpTransferCenterStore.publishOwner(ownerId, next);
|
||||
const reconciled = reconcilePanelRowsWithStore(
|
||||
prev,
|
||||
next,
|
||||
sftpTransferCenterStore.getOwnerTasks(ownerId),
|
||||
);
|
||||
if (reconciled === prev) return;
|
||||
sftpTransferCenterStore.publishOwner(ownerId, reconciled);
|
||||
const canonical = sftpTransferCenterStore.getOwnerTasks(ownerId);
|
||||
if (shouldPaintTransferControlNow(prev, canonical)) {
|
||||
paintTransfersNow(canonical);
|
||||
return;
|
||||
}
|
||||
transfersRef.current = canonical;
|
||||
setTransfersState(canonical);
|
||||
}, [ownerId]);
|
||||
scheduleTransfersStateFlush();
|
||||
}, [ownerId, paintTransfersNow, scheduleTransfersStateFlush]);
|
||||
|
||||
// Coalesce progress-only patches to one paint per animation frame. Without
|
||||
// this, each stream tick maps the full transfer list + publishOwner + store
|
||||
@@ -291,9 +431,19 @@ 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.
|
||||
transfersRef.current = transfersRef.current.map((row) => (
|
||||
row.id === taskId ? { ...row, ...updates } : row
|
||||
));
|
||||
// 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;
|
||||
});
|
||||
scheduleProgressFlush();
|
||||
}, [scheduleProgressFlush]);
|
||||
useEffect(() => () => {
|
||||
@@ -1072,8 +1222,9 @@ export const useSftpTransfers = ({
|
||||
const next = transfersRef.current.map((candidate) => (
|
||||
candidate.id === task.id ? canonicalTask : candidate
|
||||
));
|
||||
transfersRef.current = next;
|
||||
setTransfersState(next);
|
||||
// Terminal rows used to setState directly and leave a child frame
|
||||
// queued. That frame later painted the pre-terminal ref.
|
||||
paintTransfersNow(next);
|
||||
},
|
||||
);
|
||||
|
||||
@@ -2124,11 +2275,16 @@ 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;
|
||||
setTransfersState(ownerRows);
|
||||
if (shouldPaintTransferControlNow(previous, ownerRows)) {
|
||||
paintTransfersNow(ownerRows);
|
||||
return;
|
||||
}
|
||||
scheduleTransfersStateFlush();
|
||||
});
|
||||
}, [ownerId]);
|
||||
}, [ownerId, paintTransfersNow, scheduleTransfersStateFlush]);
|
||||
|
||||
useEffect(() => sftpTransferCenterStore.subscribeProgress(() => {
|
||||
const liveById = new Map(sftpTransferCenterStore.getSnapshot().tasks.map((task) => [task.id, task]));
|
||||
|
||||
@@ -97,6 +97,85 @@ 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,6 +42,7 @@ import {
|
||||
} from "./sftp/globalSftpTransferControl";
|
||||
import { restoreSftpTransferHistoryCooperatively } from "./sftp/transferHistoryRestoreMigration";
|
||||
import { cancelExternalUploadRuntime } from "./sftp/externalUploadRuntime";
|
||||
import { canRetryFromCheckpoint } from "./sftp/retryFromCheckpoint";
|
||||
|
||||
type Listener = () => void;
|
||||
type TaskSettlementObserver = {
|
||||
@@ -365,9 +366,13 @@ export function mergeOwnerPublishedTask(
|
||||
} else if (
|
||||
existingPaused
|
||||
&& (incoming.status === "transferring" || incoming.status === "pending" || incoming.status === "queued")
|
||||
// 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
|
||||
// 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)
|
||||
)
|
||||
) {
|
||||
// Soft-drain / late progress publish must not un-pause without resume epoch.
|
||||
merged.status = existing.status;
|
||||
@@ -2052,6 +2057,24 @@ 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;
|
||||
|
||||
@@ -125,6 +125,55 @@ 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,16 +1,17 @@
|
||||
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: DOMRect, element: Element): boolean {
|
||||
function overlapsBrowser(rect: HostBrowserPaneRect, 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.right && overlay.right > rect.x
|
||||
&& overlay.y < rect.bottom && overlay.bottom > rect.y;
|
||||
&& overlay.x < rect.x + rect.width && overlay.right > rect.x
|
||||
&& overlay.y < rect.y + rect.height && overlay.bottom > rect.y;
|
||||
}
|
||||
|
||||
interface BrowserPanelOptions {
|
||||
@@ -70,12 +71,21 @@ 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] > *',
|
||||
'[role="dialog"], [role="alertdialog"], [role="menu"][data-state="open"], [data-radix-popper-content-wrapper] > *, '
|
||||
+ '[data-native-view-occluder]',
|
||||
));
|
||||
const covered = modal || (!!rect && overlays.some((overlay) => overlapsBrowser(rect, overlay)));
|
||||
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 targets = new Set<Element>(overlays);
|
||||
for (let target: Element | null = element; target; target = target.parentElement) targets.add(target);
|
||||
for (const target of observed) {
|
||||
@@ -90,7 +100,11 @@ 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 bounds = rect ? { x: rect.x, y: rect.y, width: rect.width, height: rect.height } : undefined;
|
||||
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 theme = element && getComputedStyle(element).colorScheme === 'light' ? 'light' : 'dark';
|
||||
const themeTokens = currentBrowserThemeTokens(element ?? undefined);
|
||||
const layout: Parameters<NonNullable<NetcattyBridge['setHostBrowserPanelLayout']>>[0] = {
|
||||
@@ -102,7 +116,9 @@ 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 } : null);
|
||||
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);
|
||||
}).catch(() => {});
|
||||
}
|
||||
};
|
||||
@@ -119,14 +135,15 @@ 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', 'href', 'media', 'disabled'],
|
||||
'data-app-lock-overlay', 'data-radix-popper-content-wrapper', 'data-native-view-occluder',
|
||||
'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-radix-popper-content-wrapper], [data-radix-popper-content-wrapper] > *, [data-native-view-occluder]',
|
||||
)) return;
|
||||
trackAnimations(target);
|
||||
schedule();
|
||||
|
||||
@@ -50,7 +50,7 @@ export const useSessionLogBackend = () => {
|
||||
|
||||
const getManualSessionLogStatus = useCallback(async (payload: ManualStatusPayload) => {
|
||||
const bridge = netcattyBridge.get();
|
||||
return bridge?.getManualSessionLogStatus?.(payload) ?? { success: false, isLogging: false, error: "Session log bridge unavailable" };
|
||||
return bridge?.getManualSessionLogStatus?.(payload) ?? { success: false, isLogging: false, hasSessionLog: false, error: "Session log bridge unavailable" };
|
||||
}, []);
|
||||
|
||||
return {
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
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 { useSessionState } from "./useSessionState.ts";
|
||||
|
||||
type SessionState = ReturnType<typeof useSessionState>;
|
||||
|
||||
test("compose send target follows toolbar broadcast and then flips independently", async () => {
|
||||
const previousWindow = Object.getOwnPropertyDescriptor(globalThis, "window");
|
||||
const eventTarget = new EventTarget() as EventTarget & Record<string, unknown>;
|
||||
Object.assign(eventTarget, { setTimeout, clearTimeout });
|
||||
Object.defineProperty(globalThis, "window", {
|
||||
configurable: true,
|
||||
value: eventTarget,
|
||||
});
|
||||
const actEnvironment = globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean };
|
||||
const previousActEnvironment = actEnvironment.IS_REACT_ACT_ENVIRONMENT;
|
||||
actEnvironment.IS_REACT_ACT_ENVIRONMENT = true;
|
||||
|
||||
let state: SessionState | null = null;
|
||||
let renderer: ReactTestRenderer | null = null;
|
||||
const current = (): SessionState => {
|
||||
assert.ok(state);
|
||||
return state;
|
||||
};
|
||||
|
||||
function Probe() {
|
||||
state = useSessionState({ persistSessionRestore: false });
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
await act(async () => {
|
||||
renderer = create(React.createElement(Probe));
|
||||
});
|
||||
await act(async () => { current().createLocalTerminal({ shellName: "01" }); });
|
||||
await act(async () => { current().createLocalTerminal({ shellName: "02" }); });
|
||||
const [first, second] = current().orphanSessions;
|
||||
await act(async () => {
|
||||
current().createWorkspaceFromSessions(first.id, second.id, "right");
|
||||
});
|
||||
const workspaceId = current().workspaces[0]?.id;
|
||||
assert.ok(workspaceId);
|
||||
assert.equal(current().isBroadcastEnabled(workspaceId), false);
|
||||
assert.equal(current().isComposeSendToAll(workspaceId), false);
|
||||
|
||||
await act(async () => { current().toggleBroadcast(workspaceId); });
|
||||
assert.equal(current().isBroadcastEnabled(workspaceId), true);
|
||||
assert.equal(current().isComposeSendToAll(workspaceId), true);
|
||||
|
||||
await act(async () => { current().toggleComposeSendTarget(workspaceId); });
|
||||
assert.equal(current().isBroadcastEnabled(workspaceId), true);
|
||||
assert.equal(current().isComposeSendToAll(workspaceId), false);
|
||||
|
||||
await act(async () => { current().toggleComposeSendTarget(workspaceId); });
|
||||
assert.equal(current().isComposeSendToAll(workspaceId), true);
|
||||
assert.equal(current().isBroadcastEnabled(workspaceId), true);
|
||||
|
||||
await act(async () => { current().toggleBroadcast(workspaceId); });
|
||||
assert.equal(current().isBroadcastEnabled(workspaceId), false);
|
||||
assert.equal(current().isComposeSendToAll(workspaceId), false);
|
||||
|
||||
await act(async () => { current().toggleComposeSendTarget(workspaceId); });
|
||||
assert.equal(current().isBroadcastEnabled(workspaceId), false);
|
||||
assert.equal(current().isComposeSendToAll(workspaceId), true);
|
||||
|
||||
await act(async () => { current().toggleGlobalBroadcast(); });
|
||||
assert.equal(current().isGlobalBroadcastEnabled, false);
|
||||
assert.equal(current().isGlobalComposeSendToAll, false);
|
||||
assert.equal(current().canUseGlobalBroadcast, false);
|
||||
} finally {
|
||||
await act(async () => { renderer?.unmount(); });
|
||||
actEnvironment.IS_REACT_ACT_ENVIRONMENT = previousActEnvironment;
|
||||
if (previousWindow) Object.defineProperty(globalThis, "window", previousWindow);
|
||||
else Reflect.deleteProperty(globalThis, "window");
|
||||
}
|
||||
});
|
||||
|
||||
test("global compose send target follows the toolbar switch for orphan sessions", async () => {
|
||||
const previousWindow = Object.getOwnPropertyDescriptor(globalThis, "window");
|
||||
const eventTarget = new EventTarget() as EventTarget & Record<string, unknown>;
|
||||
Object.assign(eventTarget, { setTimeout, clearTimeout });
|
||||
Object.defineProperty(globalThis, "window", {
|
||||
configurable: true,
|
||||
value: eventTarget,
|
||||
});
|
||||
const actEnvironment = globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean };
|
||||
const previousActEnvironment = actEnvironment.IS_REACT_ACT_ENVIRONMENT;
|
||||
actEnvironment.IS_REACT_ACT_ENVIRONMENT = true;
|
||||
|
||||
let state: SessionState | null = null;
|
||||
let renderer: ReactTestRenderer | null = null;
|
||||
const current = (): SessionState => {
|
||||
assert.ok(state);
|
||||
return state;
|
||||
};
|
||||
|
||||
function Probe() {
|
||||
state = useSessionState({ persistSessionRestore: false });
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
await act(async () => {
|
||||
renderer = create(React.createElement(Probe));
|
||||
});
|
||||
await act(async () => { current().createLocalTerminal({ shellName: "01" }); });
|
||||
await act(async () => { current().createLocalTerminal({ shellName: "02" }); });
|
||||
assert.equal(current().canUseGlobalBroadcast, true);
|
||||
assert.equal(current().isGlobalComposeSendToAll, false);
|
||||
|
||||
await act(async () => { current().toggleGlobalBroadcast(); });
|
||||
assert.equal(current().isGlobalBroadcastEnabled, true);
|
||||
assert.equal(current().isGlobalComposeSendToAll, true);
|
||||
|
||||
await act(async () => { current().toggleGlobalComposeSendTarget(); });
|
||||
assert.equal(current().isGlobalBroadcastEnabled, true);
|
||||
assert.equal(current().isGlobalComposeSendToAll, false);
|
||||
|
||||
await act(async () => { current().toggleGlobalBroadcast(); });
|
||||
assert.equal(current().isGlobalBroadcastEnabled, false);
|
||||
assert.equal(current().isGlobalComposeSendToAll, false);
|
||||
|
||||
const closedId = current().orphanSessions[1].id;
|
||||
await act(async () => { current().toggleGlobalComposeSendTarget(); });
|
||||
assert.equal(current().isGlobalComposeSendToAll, true);
|
||||
await act(async () => { current().closeSession(closedId); });
|
||||
assert.equal(current().canUseGlobalBroadcast, false);
|
||||
assert.equal(current().isGlobalBroadcastEnabled, false);
|
||||
assert.equal(current().isGlobalComposeSendToAll, false);
|
||||
} finally {
|
||||
await act(async () => { renderer?.unmount(); });
|
||||
actEnvironment.IS_REACT_ACT_ENVIRONMENT = previousActEnvironment;
|
||||
if (previousWindow) Object.defineProperty(globalThis, "window", previousWindow);
|
||||
else Reflect.deleteProperty(globalThis, "window");
|
||||
}
|
||||
});
|
||||
@@ -25,6 +25,10 @@ SplitHint,
|
||||
updateWorkspaceSplitSizes,
|
||||
} from '../../domain/workspace';
|
||||
import { clearSessionFontSizeOverride as clearSessionFontSizeOverrideFields } from '../../domain/terminalAppearance';
|
||||
import {
|
||||
composeSendTargetAfterToolbarToggle,
|
||||
flipComposeSendTarget,
|
||||
} from '../../domain/terminalBroadcast';
|
||||
import { buildOrderedWorkTabIds, reorderWorkTabIds } from '../app/workTabSurface';
|
||||
import { activeTabStore } from './activeTabStore';
|
||||
import {
|
||||
@@ -235,8 +239,12 @@ export const useSessionState = ({
|
||||
const [tabOrder, setTabOrder] = useState<string[]>(initialRestoreState.tabOrder);
|
||||
// Broadcast mode: stores workspace IDs that have broadcast enabled
|
||||
const [broadcastWorkspaceIds, setBroadcastWorkspaceIds] = useState<Set<string>>(new Set());
|
||||
// Compose-bar send target. Toolbar toggles reset it; the compose bar can
|
||||
// then flip it without changing keystroke fan-out.
|
||||
const [composeSendAllWorkspaceIds, setComposeSendAllWorkspaceIds] = useState<Set<string>>(new Set());
|
||||
// Global broadcast mode: enables broadcast for all orphan sessions (not in workspaces)
|
||||
const [globalBroadcastEnabled, setGlobalBroadcastEnabled] = useState<boolean>(false);
|
||||
const [globalComposeSendToAll, setGlobalComposeSendToAll] = useState<boolean>(false);
|
||||
// Log views: stores open log replay tabs
|
||||
const [logViews, setLogViews] = useState<LogView[]>([]);
|
||||
const [restorePreviousSessionRevision, setRestorePreviousSessionRevision] = useState(0);
|
||||
@@ -1137,8 +1145,10 @@ export const useSessionState = ({
|
||||
const canUseGlobalBroadcast = orphanSessions.length >= 2;
|
||||
|
||||
// Losing the group must disarm broadcast before another tab can join it.
|
||||
if (!canUseGlobalBroadcast && globalBroadcastEnabled) {
|
||||
setGlobalBroadcastEnabled(false);
|
||||
// Compose-bar send target follows the toolbar switch back to active-only.
|
||||
if (!canUseGlobalBroadcast && (globalBroadcastEnabled || globalComposeSendToAll)) {
|
||||
if (globalBroadcastEnabled) setGlobalBroadcastEnabled(false);
|
||||
if (globalComposeSendToAll) setGlobalComposeSendToAll(false);
|
||||
}
|
||||
|
||||
const openLogView = useCallback((log: ConnectionLog) => {
|
||||
@@ -1273,31 +1283,61 @@ export const useSessionState = ({
|
||||
return newSessionId;
|
||||
}, [setActiveTabId]);
|
||||
|
||||
// Toggle broadcast mode for a workspace
|
||||
const toggleBroadcast = useCallback((workspaceId: string) => {
|
||||
setBroadcastWorkspaceIds(prev => {
|
||||
const setWorkspaceComposeSendTarget = useCallback((workspaceId: string, target: 'all' | 'active') => {
|
||||
setComposeSendAllWorkspaceIds((prev) => {
|
||||
const hasAll = prev.has(workspaceId);
|
||||
if ((target === 'all') === hasAll) return prev;
|
||||
const next = new Set(prev);
|
||||
if (next.has(workspaceId)) {
|
||||
next.delete(workspaceId);
|
||||
} else {
|
||||
next.add(workspaceId);
|
||||
}
|
||||
if (target === 'all') next.add(workspaceId);
|
||||
else next.delete(workspaceId);
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
// Toggle broadcast mode for a workspace. Compose-bar send target follows.
|
||||
const toggleBroadcast = useCallback((workspaceId: string) => {
|
||||
const enabling = !broadcastWorkspaceIds.has(workspaceId);
|
||||
setBroadcastWorkspaceIds(prev => {
|
||||
const next = new Set(prev);
|
||||
if (prev.has(workspaceId)) next.delete(workspaceId);
|
||||
else next.add(workspaceId);
|
||||
return next;
|
||||
});
|
||||
setWorkspaceComposeSendTarget(workspaceId, composeSendTargetAfterToolbarToggle(enabling));
|
||||
}, [broadcastWorkspaceIds, setWorkspaceComposeSendTarget]);
|
||||
|
||||
// Check if a workspace has broadcast enabled
|
||||
const isBroadcastEnabled = useCallback((workspaceId: string) => {
|
||||
return broadcastWorkspaceIds.has(workspaceId);
|
||||
}, [broadcastWorkspaceIds]);
|
||||
|
||||
const toggleComposeSendTarget = useCallback((workspaceId: string) => {
|
||||
setWorkspaceComposeSendTarget(
|
||||
workspaceId,
|
||||
flipComposeSendTarget(composeSendAllWorkspaceIds.has(workspaceId) ? 'all' : 'active'),
|
||||
);
|
||||
}, [composeSendAllWorkspaceIds, setWorkspaceComposeSendTarget]);
|
||||
|
||||
const isComposeSendToAll = useCallback((workspaceId: string) => {
|
||||
return composeSendAllWorkspaceIds.has(workspaceId);
|
||||
}, [composeSendAllWorkspaceIds]);
|
||||
|
||||
// Toggle global broadcast mode for orphan sessions
|
||||
const toggleGlobalBroadcast = useCallback(() => {
|
||||
setGlobalBroadcastEnabled(prev => !prev);
|
||||
setGlobalBroadcastEnabled(prev => {
|
||||
const next = !prev;
|
||||
setGlobalComposeSendToAll(composeSendTargetAfterToolbarToggle(next) === 'all');
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const toggleGlobalComposeSendTarget = useCallback(() => {
|
||||
setGlobalComposeSendToAll((prev) => flipComposeSendTarget(prev ? 'all' : 'active') === 'all');
|
||||
}, []);
|
||||
|
||||
// Global broadcast enabled state - direct boolean value
|
||||
const isGlobalBroadcastEnabled = globalBroadcastEnabled;
|
||||
const isGlobalComposeSendToAll = globalComposeSendToAll;
|
||||
|
||||
const baseWorkTabIds = useMemo(() => [
|
||||
...orphanSessions.map(s => s.id),
|
||||
@@ -1426,8 +1466,12 @@ export const useSessionState = ({
|
||||
// Broadcast mode
|
||||
toggleBroadcast,
|
||||
isBroadcastEnabled,
|
||||
toggleComposeSendTarget,
|
||||
isComposeSendToAll,
|
||||
toggleGlobalBroadcast,
|
||||
isGlobalBroadcastEnabled,
|
||||
toggleGlobalComposeSendTarget,
|
||||
isGlobalComposeSendToAll,
|
||||
orderedTabs,
|
||||
getOrderedWorkTabs,
|
||||
reorderTabs,
|
||||
|
||||
@@ -33,6 +33,7 @@ import {
|
||||
filterVaultImportKeyPassphrasesAgainstExisting,
|
||||
mergeVaultImportIssues,
|
||||
resolveVaultImportKeyPassphraseConflicts,
|
||||
toVaultImportExistingHostRef,
|
||||
type VaultImportFormat,
|
||||
} from "../../domain/vaultImport";
|
||||
import type { GroupConfig, Host, ManagedSource, SSHKey } from "../../types";
|
||||
@@ -175,6 +176,7 @@ export function useVaultImportHandlers({
|
||||
files,
|
||||
encoding: options?.encoding,
|
||||
masterPassword: options?.masterPassword,
|
||||
existingHosts: hostsRef.current.map(toVaultImportExistingHostRef),
|
||||
signal,
|
||||
onProgress: (progress) => {
|
||||
if (!signal.aborted) updateProgress(progress);
|
||||
|
||||
@@ -64,6 +64,8 @@ import {
|
||||
mergeConnectionLogsFromStorage,
|
||||
mergeTerminalDataIntoLogs,
|
||||
mergeTerminalDataMapsForStorage,
|
||||
pruneInMemoryUnsavedTerminalData,
|
||||
writeTerminalDataMapWithQuotaRetry,
|
||||
type ConnectionLogTerminalDataMap,
|
||||
} from "../../domain/connectionLogTerminalData";
|
||||
import { getNextVaultOrder, normalizeVaultOrder } from "../../domain/vaultOrder";
|
||||
@@ -276,23 +278,57 @@ const buildPersistedLogIds = (
|
||||
return ids;
|
||||
};
|
||||
|
||||
const writeConnectionLogTerminalDataMap = (
|
||||
logs: ConnectionLog[],
|
||||
localMaps: ConnectionLogTerminalDataMap[],
|
||||
usePersistedLogIdsFromDisk: boolean,
|
||||
/**
|
||||
* 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 => {
|
||||
const existing = readConnectionLogTerminalDataMap();
|
||||
const pruned = mergeTerminalDataMapsForStorage(
|
||||
logs,
|
||||
existing,
|
||||
localMaps,
|
||||
buildPersistedLogIds(logs, usePersistedLogIdsFromDisk),
|
||||
);
|
||||
if (JSON.stringify(existing) === JSON.stringify(pruned)) return true;
|
||||
return localStorageAdapter.write(STORAGE_KEY_CONNECTION_LOG_TERMINAL_DATA, pruned);
|
||||
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;
|
||||
};
|
||||
|
||||
export const useVaultState = () => {
|
||||
const writeConnectionLogTerminalDataMap = (
|
||||
existing: ConnectionLogTerminalDataMap,
|
||||
pruned: ConnectionLogTerminalDataMap,
|
||||
logs: ConnectionLog[],
|
||||
quotaFailedCharsRef: { current: number | null },
|
||||
): 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(
|
||||
logs,
|
||||
pruned,
|
||||
(next) => localStorageAdapter.write(STORAGE_KEY_CONNECTION_LOG_TERMINAL_DATA, next),
|
||||
quotaFailedCharsRef.current,
|
||||
);
|
||||
quotaFailedCharsRef.current = result.quotaFailedChars;
|
||||
return result.persisted;
|
||||
};
|
||||
|
||||
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;
|
||||
const [isInitialized, setIsInitialized] = useState(false);
|
||||
const [hosts, setHosts] = useState<Host[]>([]);
|
||||
const [keys, setKeys] = useState<SSHKey[]>([]);
|
||||
@@ -419,6 +455,7 @@ export const useVaultState = () => {
|
||||
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((
|
||||
@@ -438,18 +475,15 @@ export const useVaultState = () => {
|
||||
buildPersistedLogIds(logs, usePersistedLogIdsFromDisk),
|
||||
);
|
||||
const persisted = writeConnectionLogTerminalDataMap(
|
||||
existing,
|
||||
merged,
|
||||
logs,
|
||||
localMaps,
|
||||
usePersistedLogIdsFromDisk,
|
||||
connectionLogQuotaFailedCharsRef,
|
||||
);
|
||||
if (persisted) {
|
||||
connectionLogTerminalDataRef.current = merged;
|
||||
} else {
|
||||
connectionLogTerminalDataRef.current = {
|
||||
...connectionLogTerminalDataRef.current,
|
||||
...buildTerminalDataMapFromLogs(logs),
|
||||
};
|
||||
}
|
||||
// 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;
|
||||
return { map: connectionLogTerminalDataRef.current, persisted };
|
||||
}, []);
|
||||
|
||||
@@ -1297,8 +1331,10 @@ export const useVaultState = () => {
|
||||
const savedLogs = prev.filter((l) => l.saved);
|
||||
const unsavedLogs = prev.filter((l) => !l.saved);
|
||||
const updated = [newLog, ...unsavedLogs].slice(0, 500);
|
||||
const final = [...updated, ...savedLogs].sort(
|
||||
(a, b) => b.startTime - a.startTime
|
||||
const final = pruneInMemoryUnsavedTerminalData(
|
||||
[...updated, ...savedLogs].sort(
|
||||
(a, b) => b.startTime - a.startTime
|
||||
),
|
||||
);
|
||||
persistConnectionLogState(final, { pruneMainBlob: true });
|
||||
return final;
|
||||
@@ -1311,8 +1347,8 @@ export const useVaultState = () => {
|
||||
const updateConnectionLog = useCallback(
|
||||
(id: string, updates: Partial<ConnectionLog>) => {
|
||||
setConnectionLogs((prev) => {
|
||||
const updated = prev.map((log) =>
|
||||
log.id === id ? { ...log, ...updates } : log
|
||||
const updated = pruneInMemoryUnsavedTerminalData(
|
||||
prev.map((log) => (log.id === id ? { ...log, ...updates } : log)),
|
||||
);
|
||||
persistConnectionLogState(updated, { pruneMainBlob: true });
|
||||
return updated;
|
||||
@@ -1323,8 +1359,10 @@ export const useVaultState = () => {
|
||||
|
||||
const toggleConnectionLogSaved = useCallback((id: string) => {
|
||||
setConnectionLogs((prev) => {
|
||||
const updated = prev.map((log) =>
|
||||
log.id === id ? { ...log, saved: !log.saved } : log
|
||||
const updated = pruneInMemoryUnsavedTerminalData(
|
||||
prev.map((log) =>
|
||||
log.id === id ? { ...log, saved: !log.saved } : log
|
||||
),
|
||||
);
|
||||
persistConnectionLogState(updated, { pruneMainBlob: false });
|
||||
return updated;
|
||||
@@ -1628,7 +1666,11 @@ export const useVaultState = () => {
|
||||
const savedConnectionLogs = localStorageAdapter.read<ConnectionLog[]>(
|
||||
STORAGE_KEY_CONNECTION_LOGS,
|
||||
);
|
||||
const terminalDataMap = readConnectionLogTerminalDataMap();
|
||||
// Windows that never render connection-log replay skip the ~30–50 MB
|
||||
// side-store parse on boot (Settings / tray / popup).
|
||||
const terminalDataMap = syncConnectionLogTerminalData
|
||||
? readConnectionLogTerminalDataMap()
|
||||
: {};
|
||||
connectionLogTerminalDataRef.current = terminalDataMap;
|
||||
if (savedConnectionLogs) {
|
||||
setConnectionLogs(mergeTerminalDataIntoLogs(savedConnectionLogs, terminalDataMap));
|
||||
@@ -1673,7 +1715,7 @@ export const useVaultState = () => {
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [commitEncryptedHostsUnderVaultLock, updateHosts, updateSnippets]);
|
||||
}, [commitEncryptedHostsUnderVaultLock, syncConnectionLogTerminalData, updateHosts, updateSnippets]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isInitialized) return;
|
||||
@@ -1882,6 +1924,9 @@ export const useVaultState = () => {
|
||||
}
|
||||
|
||||
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));
|
||||
@@ -1922,6 +1967,7 @@ export const useVaultState = () => {
|
||||
return;
|
||||
}
|
||||
if (key === STORAGE_KEY_CONNECTION_LOG_TERMINAL_DATA) {
|
||||
if (!syncConnectionLogTerminalData) return;
|
||||
const next = readConnectionLogTerminalDataMap();
|
||||
connectionLogTerminalDataRef.current = next;
|
||||
setConnectionLogs((prev) => mergeConnectionLogsFromStorage(prev, prev, next));
|
||||
@@ -1934,7 +1980,7 @@ export const useVaultState = () => {
|
||||
window.removeEventListener("storage", handleStorage);
|
||||
window.removeEventListener(LOCAL_STORAGE_ADAPTER_CHANGED_EVENT, handleLocalStorageAdapterChanged);
|
||||
};
|
||||
}, [applyConnectionLogsFromStorage]);
|
||||
}, [applyConnectionLogsFromStorage, syncConnectionLogTerminalData]);
|
||||
|
||||
const updateHostLastConnected = useCallback((hostId: string) => {
|
||||
setHosts((prev) => {
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
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,6 +559,8 @@ 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 && {
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import { JSDOM } from "jsdom";
|
||||
import type { GroupNode } from "../types";
|
||||
|
||||
test("HostTreeView React updates invalidate order when configs or sort mode change", async () => {
|
||||
const dom = new JSDOM('<!doctype html><html><body><div id="scroll"><div id="root"></div></div></body></html>', {
|
||||
url: "http://localhost", pretendToBeVisual: true,
|
||||
});
|
||||
const previous = new Map<string, PropertyDescriptor | undefined>();
|
||||
const install = (key: string, value: unknown) => {
|
||||
previous.set(key, Object.getOwnPropertyDescriptor(globalThis, key));
|
||||
Object.defineProperty(globalThis, key, { configurable: true, writable: true, value });
|
||||
};
|
||||
class ResizeObserverStub {
|
||||
observe() {}
|
||||
unobserve() {}
|
||||
disconnect() {}
|
||||
}
|
||||
for (const key of ["window", "document", "navigator", "HTMLElement", "Element", "Node", "MutationObserver", "Event", "CustomEvent", "MouseEvent", "KeyboardEvent", "FocusEvent", "localStorage"] as const) {
|
||||
install(key, key === "window" ? dom.window : dom.window[key]);
|
||||
}
|
||||
install("getComputedStyle", dom.window.getComputedStyle.bind(dom.window));
|
||||
install("requestAnimationFrame", dom.window.requestAnimationFrame.bind(dom.window));
|
||||
install("cancelAnimationFrame", dom.window.cancelAnimationFrame.bind(dom.window));
|
||||
install("ResizeObserver", ResizeObserverStub);
|
||||
install("IS_REACT_ACT_ENVIRONMENT", true);
|
||||
const scroller = dom.window.document.getElementById("scroll")!;
|
||||
Object.defineProperties(scroller, { offsetHeight: { value: 800 }, offsetWidth: { value: 1280 } });
|
||||
const { act, createElement } = await import("react");
|
||||
const { createRoot } = await import("react-dom/client");
|
||||
const { HostTreeView } = await import("./HostTreeView.tsx");
|
||||
const noop = () => undefined;
|
||||
const groupTree: GroupNode[] = ["alpha", "beta"].map((path) => ({ name: path, path, hosts: [], children: {} }));
|
||||
const common = {
|
||||
groupTree, hosts: [], expandedPaths: new Set<string>(), scrollRef: { current: scroller },
|
||||
onConnect: noop, onEditHost: noop, onDuplicateHost: noop, onDeleteHost: noop,
|
||||
onCopyCredentials: noop, onNewGroup: noop, onRenameGroup: noop, onEditGroup: noop,
|
||||
onDeleteGroup: noop, moveHostToGroup: noop, moveGroup: noop,
|
||||
};
|
||||
const root = createRoot(dom.window.document.getElementById("root")!);
|
||||
const paths = () => [...dom.window.document.querySelectorAll("[data-group-path]")].map((node) => node.getAttribute("data-group-path"));
|
||||
try {
|
||||
await act(async () => {
|
||||
root.render(createElement(HostTreeView, { ...common, sortMode: "manual", groupConfigs: [{ path: "beta", order: -1 }] }));
|
||||
});
|
||||
assert.deepEqual(paths(), ["beta", "alpha"]);
|
||||
await act(async () => {
|
||||
root.render(createElement(HostTreeView, { ...common, sortMode: "manual", groupConfigs: [{ path: "alpha", order: -1 }] }));
|
||||
});
|
||||
assert.deepEqual(paths(), ["alpha", "beta"]);
|
||||
const groupConfigs = [{ path: "beta", order: -1 }];
|
||||
await act(async () => {
|
||||
root.render(createElement(HostTreeView, { ...common, sortMode: "az", groupConfigs }));
|
||||
});
|
||||
assert.deepEqual(paths(), ["alpha", "beta"]);
|
||||
await act(async () => {
|
||||
root.render(createElement(HostTreeView, { ...common, sortMode: "manual", groupConfigs }));
|
||||
});
|
||||
assert.deepEqual(paths(), ["beta", "alpha"]);
|
||||
} finally {
|
||||
await act(async () => { root.unmount(); });
|
||||
dom.window.close();
|
||||
for (const [key, descriptor] of previous) {
|
||||
if (descriptor) Object.defineProperty(globalThis, key, descriptor);
|
||||
else delete (globalThis as Record<string, unknown>)[key];
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -498,3 +498,75 @@ test("HostTreeView IP sorting stays inside each group and leaves manual order in
|
||||
assert.deepEqual(keys("ip"), ["group:prod", "group:prod/east", "host:east-two", "host:east-ten", "host:prod-two", "host:prod-ten", "host:root-two", "host:root-ten"]);
|
||||
assert.deepEqual(keys("manual"), ["group:prod", "group:prod/east", "host:east-ten", "host:east-two", "host:prod-ten", "host:prod-two", "host:root-ten", "host:root-two"]);
|
||||
});
|
||||
|
||||
// Deterministic work-count assertions, not a wall-clock benchmark.
|
||||
test("HostTreeView scans manual group order only once across expanded leaf groups", () => {
|
||||
const groupTree: GroupNode[] = ["c", "b", "a"].map((path) => ({
|
||||
name: path, path, children: {}, hosts: [],
|
||||
}));
|
||||
const groupConfigs: GroupConfig[] = groupTree.map((node, order) => ({ path: node.path, order }));
|
||||
let configVisits = 0;
|
||||
Object.defineProperty(groupConfigs, "filter", {
|
||||
value: (predicate: (value: GroupConfig, index: number, array: GroupConfig[]) => unknown) =>
|
||||
Array.prototype.filter.call(groupConfigs, (value: GroupConfig, index: number, array: GroupConfig[]) => {
|
||||
configVisits += 1;
|
||||
return predicate(value, index, array);
|
||||
}),
|
||||
});
|
||||
for (const expandedPaths of [new Set<string>(), new Set(["a", "b", "c"])]) {
|
||||
configVisits = 0;
|
||||
const manual = buildVisibleHostTreeItems({ groupTree, groupConfigs, expandedPaths, ungroupedHosts: [], sortMode: "manual" });
|
||||
assert.equal(configVisits, 3);
|
||||
assert.deepEqual(manual.map((item) => item.key), ["group:c", "group:b", "group:a"]);
|
||||
for (const sortMode of ["az", "za", "newest", "oldest", "group", "ip"] as const) {
|
||||
configVisits = 0;
|
||||
const items = buildVisibleHostTreeItems({ groupTree, groupConfigs, expandedPaths, ungroupedHosts: [], sortMode });
|
||||
assert.equal(configVisits, 0, `${sortMode} must not construct a manual order index`);
|
||||
assert.deepEqual(items.map((item) => item.key), sortMode === "za"
|
||||
? ["group:c", "group:b", "group:a"]
|
||||
: ["group:a", "group:b", "group:c"]);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test("HostTreeView shared order index preserves valid duplicates, ties and nested sibling metadata", () => {
|
||||
const leaf = (path: string): GroupNode => ({ name: path.split("/").at(-1)!, path, children: {}, hosts: [] });
|
||||
const parent: GroupNode = {
|
||||
...leaf("parent"),
|
||||
children: { z: leaf("parent/z"), a: leaf("parent/a"), b: leaf("parent/b") },
|
||||
hosts: [{ ...baseHost, id: "child-host", group: "parent" }],
|
||||
};
|
||||
const groupTree = [leaf("unordered-z"), leaf("tie-b"), parent, leaf("tie-a"), leaf("unordered-a")];
|
||||
const groupConfigs: GroupConfig[] = [
|
||||
{ path: "parent", order: 10 }, { path: "parent", order: -1 },
|
||||
{ path: "parent", order: Number.NaN }, { path: "parent", order: Infinity },
|
||||
{ path: "tie-b", order: 0 }, { path: "tie-a", order: 0 },
|
||||
{ path: "unordered-a", order: Number.NaN },
|
||||
{ path: "parent/a", order: 0 }, { path: "parent/b", order: 0 },
|
||||
{ path: "parent/z", order: -2 },
|
||||
];
|
||||
const ungroupedHosts = [{ ...baseHost, id: "root-host", group: "" }];
|
||||
const expandedPaths = new Set(["parent", "parent/z", "parent/a", "parent/b"]);
|
||||
const inputBefore = structuredClone({ groupTree, groupConfigs, ungroupedHosts, expandedPaths });
|
||||
const items = buildVisibleHostTreeItems({ groupTree, groupConfigs, ungroupedHosts, expandedPaths, sortMode: "manual" });
|
||||
assert.deepEqual(items.map(({ key, depth, posInSet, setSize }) => ({ key, depth, posInSet, setSize })), [
|
||||
{ key: "group:parent", depth: 0, posInSet: 1, setSize: 6 },
|
||||
{ key: "group:parent/z", depth: 1, posInSet: 1, setSize: 4 },
|
||||
{ key: "group:parent/b", depth: 1, posInSet: 2, setSize: 4 },
|
||||
{ key: "group:parent/a", depth: 1, posInSet: 3, setSize: 4 },
|
||||
{ key: "host:child-host", depth: 1, posInSet: 4, setSize: 4 },
|
||||
{ key: "group:tie-a", depth: 0, posInSet: 2, setSize: 6 },
|
||||
{ key: "group:tie-b", depth: 0, posInSet: 3, setSize: 6 },
|
||||
{ key: "group:unordered-z", depth: 0, posInSet: 4, setSize: 6 },
|
||||
{ key: "group:unordered-a", depth: 0, posInSet: 5, setSize: 6 },
|
||||
{ key: "host:root-host", depth: 0, posInSet: 6, setSize: 6 },
|
||||
]);
|
||||
assert.equal(items[0].kind === "group" && items[0].node, parent);
|
||||
assert.equal(items[4].kind === "host" && items[4].host, parent.hosts[0]);
|
||||
assert.deepEqual({ groupTree, groupConfigs, ungroupedHosts, expandedPaths }, inputBefore);
|
||||
const changed = buildVisibleHostTreeItems({
|
||||
groupTree, groupConfigs: [...groupConfigs, { path: "tie-a", order: -10 }],
|
||||
ungroupedHosts, expandedPaths, sortMode: "manual",
|
||||
});
|
||||
assert.equal(changed[0].key, "group:tie-a");
|
||||
});
|
||||
@@ -64,22 +64,25 @@ export type VisibleHostTreeItem =
|
||||
setSize: number;
|
||||
};
|
||||
|
||||
const sortHostTreeGroups = (
|
||||
nodes: GroupNode[],
|
||||
sortMode: HostTreeSortMode,
|
||||
groupConfigs: GroupConfig[],
|
||||
): GroupNode[] => {
|
||||
const originalIndex = new Map(nodes.map((node, index) => [node.path, index]));
|
||||
const orderByPath = new Map(
|
||||
const buildHostTreeGroupOrder = (groupConfigs: GroupConfig[]): Map<string, number> => (
|
||||
new Map(
|
||||
groupConfigs
|
||||
.filter((config) => typeof config.order === 'number' && Number.isFinite(config.order))
|
||||
.map((config) => [config.path, config.order as number]),
|
||||
);
|
||||
)
|
||||
);
|
||||
|
||||
const sortHostTreeGroups = (
|
||||
nodes: GroupNode[],
|
||||
sortMode: HostTreeSortMode,
|
||||
orderByPath?: ReadonlyMap<string, number>,
|
||||
): GroupNode[] => {
|
||||
const originalIndex = new Map(nodes.map((node, index) => [node.path, index]));
|
||||
return [...nodes].sort((a, b) => {
|
||||
if (sortMode === 'za') return b.name.localeCompare(a.name);
|
||||
if (sortMode === 'manual') {
|
||||
const orderA = orderByPath.get(a.path);
|
||||
const orderB = orderByPath.get(b.path);
|
||||
const orderA = orderByPath?.get(a.path);
|
||||
const orderB = orderByPath?.get(b.path);
|
||||
if (orderA !== undefined && orderB !== undefined && orderA !== orderB) return orderA - orderB;
|
||||
if (orderA !== undefined) return -1;
|
||||
if (orderB !== undefined) return 1;
|
||||
@@ -115,8 +118,9 @@ export function buildVisibleHostTreeItems({
|
||||
groupConfigs: GroupConfig[];
|
||||
}): VisibleHostTreeItem[] {
|
||||
const items: VisibleHostTreeItem[] = [];
|
||||
const orderByPath = sortMode === 'manual' ? buildHostTreeGroupOrder(groupConfigs) : undefined;
|
||||
const visitGroups = (nodes: GroupNode[], depth: number) => {
|
||||
const sortedGroups = sortHostTreeGroups(nodes, sortMode, groupConfigs);
|
||||
const sortedGroups = sortHostTreeGroups(nodes, sortMode, orderByPath);
|
||||
// Root-level ungrouped hosts are siblings of top-level groups.
|
||||
const rootUngrouped = depth === 0 ? sortHostTreeHosts(ungroupedHosts, sortMode) : [];
|
||||
const siblingSetSize = sortedGroups.length + rootUngrouped.length;
|
||||
@@ -133,7 +137,7 @@ export function buildVisibleHostTreeItems({
|
||||
const childGroups = sortHostTreeGroups(
|
||||
Object.values(node.children ?? {}) as GroupNode[],
|
||||
sortMode,
|
||||
groupConfigs,
|
||||
orderByPath,
|
||||
);
|
||||
const childHosts = sortHostTreeHosts(node.hosts, sortMode);
|
||||
const childSetSize = childGroups.length + childHosts.length;
|
||||
@@ -182,7 +186,7 @@ export function buildVisibleHostTreeItems({
|
||||
const childGroups = sortHostTreeGroups(
|
||||
Object.values(node.children ?? {}) as GroupNode[],
|
||||
sortMode,
|
||||
groupConfigs,
|
||||
orderByPath,
|
||||
);
|
||||
const childHosts = sortHostTreeHosts(node.hosts, sortMode);
|
||||
const childSetSize = childGroups.length + childHosts.length;
|
||||
@@ -439,7 +443,7 @@ const TreeNode: React.FC<TreeNodeProps> = ({
|
||||
const childNodes = useMemo(() => sortHostTreeGroups(
|
||||
Object.values(node.children ?? {}) as GroupNode[],
|
||||
sortMode,
|
||||
groupConfigs,
|
||||
sortMode === 'manual' ? buildHostTreeGroupOrder(groupConfigs) : undefined,
|
||||
), [groupConfigs, node.children, sortMode]);
|
||||
const sortedHosts = useMemo(
|
||||
() => sortHostTreeHosts(node.hosts, sortMode),
|
||||
|
||||
@@ -270,7 +270,7 @@ const SettingsSyncTabWithVault: React.FC<{ onSettingsApplied?: () => void }> = (
|
||||
groupConfigs,
|
||||
importDataFromString,
|
||||
clearVaultData,
|
||||
} = useVaultState();
|
||||
} = useVaultState({ syncConnectionLogTerminalData: false });
|
||||
|
||||
const { rules: portForwardingRules, importRules: importPortForwardingRules } = usePortForwardingState();
|
||||
|
||||
|
||||
@@ -73,6 +73,7 @@ import {
|
||||
} from "../application/state/terminalAutoReconnect";
|
||||
import { useStoredBoolean } from "../application/state/useStoredBoolean";
|
||||
import { readOptionalStoredStringValue, useStoredString } from "../application/state/useStoredString";
|
||||
import { performSessionLogAction, type SessionLogAction } from "../application/state/sessionLogActions";
|
||||
import { useSessionLogBackend } from "../application/state/useSessionLogBackend";
|
||||
import { useTerminalLayoutSuppressActive } from "../application/state/terminalLayoutSuppressStore";
|
||||
import { useAppearanceChromeStore } from "../application/state/appearanceChromeStore";
|
||||
@@ -363,6 +364,8 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
onOpenTheme,
|
||||
onOpenSystem,
|
||||
isBroadcastEnabled,
|
||||
composeSendToAll,
|
||||
onToggleComposeSendTarget,
|
||||
onToggleBroadcast,
|
||||
onToggleComposeBar,
|
||||
isWorkspaceComposeBarOpen,
|
||||
@@ -906,6 +909,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
const [isCancelling, setIsCancelling] = useState(false);
|
||||
const [showSFTP, setShowSFTP] = useState(false);
|
||||
const [isSessionLogging, setIsSessionLogging] = useState(false);
|
||||
const [canOpenSessionLog, setCanOpenSessionLog] = useState(false);
|
||||
const [progressValue, setProgressValue] = useState(CONNECTION_PROGRESS_START);
|
||||
const [isDisconnectedDialogDismissed, setIsDisconnectedDialogDismissed] = useState(false);
|
||||
const [connectionReuseFellBack, setConnectionReuseFellBack] = useState(false);
|
||||
@@ -3433,13 +3437,15 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
// A bypassed password fan-out (#3488) forces the sensitive marker even
|
||||
// when this target has not itself classified its prompt as sensitive.
|
||||
const sensitive = passwordPromptActiveRef.current || options?.sensitive === true;
|
||||
const broadcastThisSend = options?.broadcast !== undefined
|
||||
? options.broadcast === true
|
||||
: isBroadcastEnabledRef.current;
|
||||
if (
|
||||
shouldBroadcastDuringSensitivePrompt({
|
||||
sensitivePromptActive: sensitive,
|
||||
broadcastPasswordBypass: broadcastPasswordBypassRef.current,
|
||||
})
|
||||
&& options?.broadcast !== false
|
||||
&& isBroadcastEnabledRef.current
|
||||
&& broadcastThisSend
|
||||
&& onBroadcastInputRef.current
|
||||
) {
|
||||
const broadcastDeliveredSessionIds = onBroadcastInputRef.current(data, sessionId, {
|
||||
@@ -3449,6 +3455,11 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
// peer writes keep skipping input interceptors.
|
||||
...(sensitive ? { sourceSensitive: true } : {}),
|
||||
...(lineDelayMs ? { lineDelayMs } : {}),
|
||||
// Compose-bar send-to-all while the toolbar switch is off. Keystrokes
|
||||
// and snippets leave this unset and stay on the toolbar gate.
|
||||
...(options?.broadcast === true && !isBroadcastEnabledRef.current
|
||||
? { composePeerFanOut: true }
|
||||
: {}),
|
||||
});
|
||||
// #3491: report the sessions that received the fan-out so callers (the
|
||||
// solo compose bar) can detect a payload delivered into a sensitive
|
||||
@@ -4127,6 +4138,15 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
onClipboardImageUploadResult: handleClipboardImageUploadResult,
|
||||
});
|
||||
|
||||
const handleSessionLogAction = useCallback(async (action: SessionLogAction) => {
|
||||
try {
|
||||
await performSessionLogAction(sessionRef.current ?? sessionId, action);
|
||||
} catch (err) {
|
||||
logger.error("[Terminal] Failed to open or reveal session log:", err);
|
||||
toast.error(t('terminal.toolbar.openSessionLogFailed'));
|
||||
}
|
||||
}, [sessionId, t]);
|
||||
|
||||
const handleToggleSessionLog = useCallback(async () => {
|
||||
const currentSessionId = sessionRef.current ?? sessionId;
|
||||
if (!currentSessionId) {
|
||||
@@ -4176,6 +4196,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
if (startResult?.success) {
|
||||
if (!startResult?.started && startResult?.canceled) return;
|
||||
setIsSessionLogging(!!startResult?.started);
|
||||
if (startResult?.started) setCanOpenSessionLog(true);
|
||||
} else {
|
||||
toast.error(startResult?.error || "Failed to start session log");
|
||||
}
|
||||
@@ -4200,14 +4221,21 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
const currentSessionId = sessionRef.current ?? sessionId;
|
||||
if (!currentSessionId) {
|
||||
setIsSessionLogging(false);
|
||||
setCanOpenSessionLog(false);
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
void getManualSessionLogStatus({ sessionId: currentSessionId }).then((result) => {
|
||||
if (!cancelled) setIsSessionLogging(!!result?.isLogging);
|
||||
if (!cancelled) {
|
||||
setIsSessionLogging(!!result?.isLogging);
|
||||
setCanOpenSessionLog(!!result?.hasSessionLog);
|
||||
}
|
||||
}).catch(() => {
|
||||
if (!cancelled) setIsSessionLogging(false);
|
||||
if (!cancelled) {
|
||||
setIsSessionLogging(false);
|
||||
setCanOpenSessionLog(false);
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
@@ -4283,6 +4311,8 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
isSearchOpen={isSearchOpen}
|
||||
onToggleSearch={handleToggleSearch}
|
||||
showLogButton
|
||||
onSessionLogAction={handleSessionLogAction}
|
||||
canOpenSessionLog={canOpenSessionLog}
|
||||
onToggleSessionLog={handleToggleSessionLog}
|
||||
isSessionLogging={isSessionLogging}
|
||||
isSessionLogDisabled={status !== "connected" && !isSessionLogging}
|
||||
@@ -4315,6 +4345,8 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
handleSendYmodem,
|
||||
handleSetTerminalEncoding,
|
||||
handleToggleSessionLog,
|
||||
handleSessionLogAction,
|
||||
canOpenSessionLog,
|
||||
handleToggleSearch,
|
||||
handleToolbarRecordingToggle,
|
||||
host,
|
||||
@@ -4689,7 +4721,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
|
||||
return (
|
||||
<>
|
||||
<TerminalView ctx={{ Activity, ArrowDownToLine, ArrowUpFromLine, Button, Clock3, Copy, Cpu, HardDrive, HoverCard, HoverCardContent, HoverCardTrigger, Maximize2, MemoryStick, Radio, RefreshCcw, Sparkles, SquareArrowOutUpRight, Unplug, TerminalAutocomplete, TerminalComposeBar, TerminalConnectionDialog, TerminalContextMenu, TerminalSearchBar, Tooltip, TooltipContent, TooltipTrigger, ZmodemOverwriteDialog, ZmodemProgressIndicator, auth, autocompleteAcceptTextRef, autocompleteCloseRef, autocompleteHostOs, autocompleteInputRef, autocompleteKeyEventRef, autocompleteRepositionRef, autocompleteSettings, canUpdateHost: !!onUpdateHost, chainProgress, cn, compactToolbar, lineTimestampsAvailable, containerRef, effectiveFontSize, effectiveFontWeight, effectiveTheme, error, executeSnippet, executeSnippetCommand, handleAddSelectionToAI: onAddSelectionToAI ? handleAddSelectionToAI : undefined, handleCancelConnect, handleCloseDisconnectedSession, handleCloseSearch, handleDisconnect: (attachExistingSession || compactToolbar) ? undefined : handleDisconnect, handleDismissDisconnectedDialog, handleDragEnter, handleDragLeave, handleDragOver, handleDrop, handleFindNext, handleFindPrevious, handleHostKeyAddAndContinue, handleHostKeyClose, handleHostKeyContinue, handleOpenHostBrowser, handleOsc52ReadResponse, handleOsc7SetupConfirm, handleOsc7SetupOpenChange, handleReceiveYmodem, handleRetry, handleSearch, handleSendYmodem, handleTopOverlayMouseDownCapture, hasMouseTracking, host, hotkeyScheme, inWorkspace, isBroadcastEnabled, isCancelling, isComposeBarOpen: effectiveComposeBarOpen, isConnectionAwaitingUserInput, isDraggingOver, isFocusMode, isFocusedPane, isLocalConnection, remoteDragDropUsesZmodem, isPluginTerminalProviderAvailable, isReconnectActive, isSerialConnection, isSearchOpen, isSupportedOs, isSystemSidebarEligible, isVisible, keyBindings, keys, identities, knownCwdRef, needsHostKeyVerification, onAddSelectionToAI, onBroadcastInput, onCloseSession, onDetach, onDetachDragEnd, onDetachDragStart, onDetachPointerDown, onEndSessionDrag, onExpandToFocus, onTogglePaneMagnification, onOpenSystem, onRename, onSplitHorizontal, onSplitVertical, onStartSessionDrag, onToggleBroadcast, onUpdateHost: handleUpdateHostFromTerminal, osc52ReadPromptVisible, osc7SetupOpen, osc7SetupRunning, passwordPromptActiveRef, pendingHostKeyInfo, progressLogs, progressValue, renderControls, resolvedFontFamily, restoreState, scrollToBottomAfterProgrammaticInput, searchMatchCount, searchFocusToken, scriptExecutionOverlay: activeScriptRun ? (
|
||||
<TerminalView ctx={{ Activity, ArrowDownToLine, ArrowUpFromLine, Button, Clock3, Copy, Cpu, HardDrive, HoverCard, HoverCardContent, HoverCardTrigger, Maximize2, MemoryStick, Radio, RefreshCcw, Sparkles, SquareArrowOutUpRight, Unplug, TerminalAutocomplete, TerminalComposeBar, TerminalConnectionDialog, TerminalContextMenu, TerminalSearchBar, Tooltip, TooltipContent, TooltipTrigger, ZmodemOverwriteDialog, ZmodemProgressIndicator, auth, autocompleteAcceptTextRef, autocompleteCloseRef, autocompleteHostOs, autocompleteInputRef, autocompleteKeyEventRef, autocompleteRepositionRef, autocompleteSettings, canUpdateHost: !!onUpdateHost, chainProgress, cn, compactToolbar, lineTimestampsAvailable, containerRef, effectiveFontSize, effectiveFontWeight, effectiveTheme, error, executeSnippet, executeSnippetCommand, handleAddSelectionToAI: onAddSelectionToAI ? handleAddSelectionToAI : undefined, handleCancelConnect, handleCloseDisconnectedSession, handleCloseSearch, handleDisconnect: (attachExistingSession || compactToolbar) ? undefined : handleDisconnect, handleDismissDisconnectedDialog, handleDragEnter, handleDragLeave, handleDragOver, handleDrop, handleFindNext, handleFindPrevious, handleHostKeyAddAndContinue, handleHostKeyClose, handleHostKeyContinue, handleOpenHostBrowser, handleOsc52ReadResponse, handleOsc7SetupConfirm, handleOsc7SetupOpenChange, handleReceiveYmodem, handleRetry, handleSearch, handleSendYmodem, handleTopOverlayMouseDownCapture, hasMouseTracking, host, hotkeyScheme, inWorkspace, isBroadcastEnabled, composeSendToAll, onToggleComposeSendTarget, isCancelling, isComposeBarOpen: effectiveComposeBarOpen, isConnectionAwaitingUserInput, isDraggingOver, isFocusMode, isFocusedPane, isLocalConnection, remoteDragDropUsesZmodem, isPluginTerminalProviderAvailable, isReconnectActive, isSerialConnection, isSearchOpen, isSupportedOs, isSystemSidebarEligible, isVisible, keyBindings, keys, identities, knownCwdRef, needsHostKeyVerification, onAddSelectionToAI, onBroadcastInput, onCloseSession, onDetach, onDetachDragEnd, onDetachDragStart, onDetachPointerDown, onEndSessionDrag, onExpandToFocus, onTogglePaneMagnification, onOpenSystem, onRename, onSplitHorizontal, onSplitVertical, onStartSessionDrag, onToggleBroadcast, onUpdateHost: handleUpdateHostFromTerminal, osc52ReadPromptVisible, osc7SetupOpen, osc7SetupRunning, passwordPromptActiveRef, pendingHostKeyInfo, progressLogs, progressValue, renderControls, resolvedFontFamily, restoreState, scrollToBottomAfterProgrammaticInput, searchMatchCount, searchFocusToken, scriptExecutionOverlay: activeScriptRun ? (
|
||||
<ScriptExecutionOverlay
|
||||
run={activeScriptRun}
|
||||
onPause={() => { void pauseScriptRun(activeScriptRun.runId); }}
|
||||
|
||||
@@ -260,8 +260,12 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
onCreateLocalTerminal,
|
||||
isBroadcastEnabled,
|
||||
onToggleBroadcast,
|
||||
isComposeSendToAll,
|
||||
onToggleComposeSendTarget,
|
||||
isGlobalBroadcastEnabled,
|
||||
isGlobalComposeSendToAll,
|
||||
onToggleGlobalBroadcast,
|
||||
onToggleGlobalComposeSendTarget,
|
||||
canUseGlobalBroadcast,
|
||||
updateHosts,
|
||||
updateSnippets,
|
||||
@@ -1277,6 +1281,7 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
sourceSessionId,
|
||||
globalBroadcastEnabled: isGlobalBroadcastEnabled,
|
||||
directTargetSessionIds: options?.kittyKeyboardTargetSessionIds,
|
||||
includeGlobalPeersWithoutToolbar: options?.composePeerFanOut === true,
|
||||
}).filter(id => !paced?.targets || paced.targets.some(target => target.sessionId === id));
|
||||
if (options?.preparePacedBroadcast && paced) {
|
||||
paced.targets = targetSessionIds.filter(id => passwordBypass || !isTerminalSensitiveInputActive(id)).map(id => {
|
||||
@@ -2463,14 +2468,19 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
let anyRecipientSensitive = false;
|
||||
const pendingSends: Promise<boolean>[] = [];
|
||||
const payload = text + '\r';
|
||||
const broadcastEnabled = isBroadcastEnabled?.(activeWorkspace.id);
|
||||
// Compose Enter follows the compose-bar target. Toolbar broadcast stays
|
||||
// the keystroke switch and is only the fallback when no compose target
|
||||
// has been published (older callers).
|
||||
const composeSendToAll = isComposeSendToAll
|
||||
? isComposeSendToAll(activeWorkspace.id) === true
|
||||
: Boolean(isBroadcastEnabled?.(activeWorkspace.id));
|
||||
const focusedSessionId = activeWorkspace.focusedSessionId;
|
||||
const broadcastPasswordBypass = broadcastPasswordBypassRef.current;
|
||||
const focusedSensitive = focusedSessionId
|
||||
? isTerminalSensitiveInputActive(focusedSessionId)
|
||||
: false;
|
||||
|
||||
if (broadcastEnabled && (broadcastPasswordBypass || !focusedSensitive)) {
|
||||
if (composeSendToAll && (broadcastPasswordBypass || !focusedSensitive)) {
|
||||
const allSessionIds = sessionsRef.current
|
||||
.filter((session) => session.workspaceId === activeWorkspace.id)
|
||||
.map((session) => session.id);
|
||||
@@ -2518,7 +2528,9 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
const executor = snippetExecutorsRef.current.get(targetId);
|
||||
if (executor) {
|
||||
const sensitive = isTerminalSensitiveInputActive(targetId);
|
||||
pendingSends.push(Promise.resolve(executor(text, false)).then(
|
||||
// Explicitly local: an omitted broadcast option falls back to the
|
||||
// toolbar switch, which can still be on while compose is active-only.
|
||||
pendingSends.push(Promise.resolve(executor(text, false, { broadcast: false })).then(
|
||||
(sent) => sent && !sensitive && !isTerminalSensitiveInputActive(targetId),
|
||||
));
|
||||
} else {
|
||||
@@ -2539,7 +2551,7 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
// eligibility for it.
|
||||
if (focusedSensitive || anyRecipientSensitive) return false;
|
||||
return delivered;
|
||||
}, [isBroadcastEnabled, terminalBackend]);
|
||||
}, [isBroadcastEnabled, isComposeSendToAll, terminalBackend]);
|
||||
|
||||
const sessionLogConfig = useMemo(
|
||||
() => ({
|
||||
@@ -2664,7 +2676,11 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
restoreTerminalCwd,
|
||||
identities,
|
||||
isBroadcastEnabled,
|
||||
isComposeSendToAll,
|
||||
onToggleComposeSendTarget,
|
||||
isGlobalBroadcastEnabled,
|
||||
isGlobalComposeSendToAll,
|
||||
onToggleGlobalComposeSendTarget,
|
||||
canUseGlobalBroadcast,
|
||||
isComposeBarOpen,
|
||||
keyBindings,
|
||||
|
||||
@@ -286,7 +286,7 @@ function TerminalPopupPageInner({
|
||||
groupConfigs,
|
||||
updateKnownHosts,
|
||||
deleteSelectedSnippets,
|
||||
} = useVaultState();
|
||||
} = useVaultState({ syncConnectionLogTerminalData: false });
|
||||
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();
|
||||
const { hosts, knownHosts, updateKnownHosts } = useVaultState({ syncConnectionLogTerminalData: false });
|
||||
// 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.
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import React from "react";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
|
||||
import { invokeAsideActionMenuItemClick } from "./ui/aside-panel.tsx";
|
||||
import { AsidePanel, invokeAsideActionMenuItemClick } from "./ui/aside-panel.tsx";
|
||||
|
||||
test("AsideActionMenuItem closes its menu before running the action", () => {
|
||||
const events: string[] = [];
|
||||
@@ -21,3 +23,12 @@ 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/);
|
||||
});
|
||||
@@ -3,6 +3,7 @@ import test from 'node:test';
|
||||
import React from 'react';
|
||||
import { JSDOM } from 'jsdom';
|
||||
import { act, create, type ReactTestRenderer } from 'react-test-renderer';
|
||||
import { I18nProvider } from '../../application/i18n/I18nProvider';
|
||||
import { TerminalComposeBar } from './TerminalComposeBar';
|
||||
import { TooltipProvider } from '../ui/tooltip';
|
||||
import { getComposeBarHistory, pruneComposeBarHistory, recordComposeBarHistory } from '../../application/state/composeBarHistoryStore';
|
||||
@@ -195,3 +196,65 @@ test('empty compose draft Enter reconnects; a typed draft still sends', async (t
|
||||
|
||||
await act(async () => { root.unmount(); });
|
||||
});
|
||||
|
||||
test('compose send target toggle shows all vs active without looking like broadcast when active', async (t) => {
|
||||
const dom = new JSDOM('<!doctype html><html><body></body></html>', { url: 'http://localhost' });
|
||||
const originals = new Map<string, PropertyDescriptor | undefined>();
|
||||
for (const name of ['window', 'document', 'localStorage']) {
|
||||
originals.set(name, Object.getOwnPropertyDescriptor(globalThis, name));
|
||||
Object.defineProperty(globalThis, name, { configurable: true, value: dom.window[name as keyof Window] });
|
||||
}
|
||||
t.after(() => {
|
||||
dom.window.close();
|
||||
for (const [name, descriptor] of originals) {
|
||||
if (descriptor) Object.defineProperty(globalThis, name, descriptor);
|
||||
else Reflect.deleteProperty(globalThis, name);
|
||||
}
|
||||
});
|
||||
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
|
||||
const toggles: string[] = [];
|
||||
const render = (composeSendToAll: boolean) => (
|
||||
<I18nProvider locale="en">
|
||||
<TooltipProvider>
|
||||
<TerminalComposeBar
|
||||
sessionId="compose-target"
|
||||
onSend={() => true}
|
||||
onClose={() => {}}
|
||||
isBroadcastEnabled
|
||||
composeSendToAll={composeSendToAll}
|
||||
onToggleComposeSendTarget={() => { toggles.push('toggle'); }}
|
||||
/>
|
||||
</TooltipProvider>
|
||||
</I18nProvider>
|
||||
);
|
||||
let root!: ReactTestRenderer;
|
||||
await act(async () => {
|
||||
root = create(render(true), { createNodeMock: (el) => (el.type === 'textarea' ? { focus() {} } : null) });
|
||||
});
|
||||
const button = root.root.findAll(
|
||||
(node) => node.props?.['data-compose-send-target'] === 'all' || node.props?.['data-compose-send-target'] === 'active',
|
||||
)[0];
|
||||
assert.ok(button);
|
||||
assert.equal(button.props['data-compose-send-target'], 'all');
|
||||
assert.equal(button.props['aria-pressed'], true);
|
||||
assert.match(String(button.props['aria-label']), /all sessions/i);
|
||||
const pulsing = root.root.findAll((node) => typeof node.props?.className === 'string' && node.props.className.includes('animate-pulse'));
|
||||
assert.ok(pulsing.length > 0);
|
||||
await act(async () => {
|
||||
button.props.onClick({
|
||||
preventDefault() {},
|
||||
stopPropagation() {},
|
||||
defaultPrevented: false,
|
||||
nativeEvent: { defaultPrevented: false },
|
||||
});
|
||||
});
|
||||
assert.deepEqual(toggles, ['toggle']);
|
||||
await act(async () => { root.update(render(false)); });
|
||||
const active = root.root.findAll((node) => node.props?.['data-compose-send-target'] === 'active')[0];
|
||||
assert.ok(active);
|
||||
assert.equal(active.props['aria-pressed'], false);
|
||||
assert.match(String(active.props['aria-label']), /active session/i);
|
||||
const stillPulsing = root.root.findAll((node) => typeof node.props?.className === 'string' && node.props.className.includes('animate-pulse'));
|
||||
assert.equal(stillPulsing.length, 0);
|
||||
await act(async () => { root.unmount(); });
|
||||
});
|
||||
@@ -280,6 +280,9 @@ export interface TerminalComposeBarProps {
|
||||
onSnippetClick?: (snippet: Snippet) => void;
|
||||
snippets?: Snippet[];
|
||||
isBroadcastEnabled?: boolean;
|
||||
/** Compose-bar send target. Undefined follows toolbar broadcast. */
|
||||
composeSendToAll?: boolean;
|
||||
onToggleComposeSendTarget?: () => void;
|
||||
themeColors?: {
|
||||
background: string;
|
||||
foreground: string;
|
||||
@@ -293,6 +296,8 @@ export const TerminalComposeBar: React.FC<TerminalComposeBarProps> = ({
|
||||
onSnippetClick,
|
||||
snippets = [],
|
||||
isBroadcastEnabled,
|
||||
composeSendToAll,
|
||||
onToggleComposeSendTarget,
|
||||
themeColors,
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
@@ -350,8 +355,9 @@ export const TerminalComposeBar: React.FC<TerminalComposeBarProps> = ({
|
||||
};
|
||||
}, [broadcastMenuAnchor]);
|
||||
|
||||
const sendToAll = composeSendToAll ?? Boolean(isBroadcastEnabled);
|
||||
const handleBroadcastContextMenu = useCallback((event: React.MouseEvent) => {
|
||||
if (!isBroadcastEnabled) return;
|
||||
if (!isBroadcastEnabled && !sendToAll) return;
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
setBroadcastMenuAnchor({
|
||||
@@ -359,7 +365,7 @@ export const TerminalComposeBar: React.FC<TerminalComposeBarProps> = ({
|
||||
y: event.clientY,
|
||||
inTextarea: event.target instanceof Element && Boolean(event.target.closest('textarea')),
|
||||
});
|
||||
}, [isBroadcastEnabled]);
|
||||
}, [isBroadcastEnabled, sendToAll]);
|
||||
|
||||
const handleComposeMenuEdit = useCallback(async (action: 'cut' | 'copy' | 'paste' | 'selectAll') => {
|
||||
setBroadcastMenuAnchor(null);
|
||||
@@ -607,14 +613,33 @@ export const TerminalComposeBar: React.FC<TerminalComposeBarProps> = ({
|
||||
|
||||
<div className="flex-1 min-h-0 px-3 pt-1.5 pb-2 flex flex-col">
|
||||
<div className="flex flex-1 min-h-0 items-start gap-1.5">
|
||||
{isBroadcastEnabled && (
|
||||
{(onToggleComposeSendTarget || sendToAll) && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div className="flex items-center cursor-default pt-0.5 flex-shrink-0">
|
||||
<Radio size={14} className="text-amber-400 animate-pulse" />
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
data-compose-send-target={sendToAll ? 'all' : 'active'}
|
||||
aria-pressed={sendToAll}
|
||||
aria-label={sendToAll
|
||||
? t('terminal.composeBar.sendToAll')
|
||||
: t('terminal.composeBar.sendToActive')}
|
||||
className="flex items-center pt-0.5 flex-shrink-0 rounded-sm"
|
||||
onMouseDown={(event) => event.preventDefault()}
|
||||
onClick={() => onToggleComposeSendTarget?.()}
|
||||
onContextMenu={handleBroadcastContextMenu}
|
||||
>
|
||||
<Radio
|
||||
size={14}
|
||||
className={sendToAll ? 'text-amber-400 animate-pulse' : undefined}
|
||||
style={sendToAll ? undefined : { color: theme.mutedFg }}
|
||||
/>
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{t('terminal.composeBar.broadcasting')}</TooltipContent>
|
||||
<TooltipContent>
|
||||
{sendToAll
|
||||
? t('terminal.composeBar.broadcasting')
|
||||
: t('terminal.composeBar.sendToActive')}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ import { renderToStaticMarkup } from "react-dom/server";
|
||||
|
||||
import { I18nProvider } from "../../application/i18n/I18nProvider.tsx";
|
||||
import type { Host } from "../../types.ts";
|
||||
import { TerminalToolbar } from "./TerminalToolbar.tsx";
|
||||
import { TERMINAL_TOOLBAR_LAYOUT_DEFAULTS, TerminalToolbar } from "./TerminalToolbar.tsx";
|
||||
|
||||
const toolbarSource = readFileSync(new URL("./TerminalToolbar.tsx", import.meta.url), "utf8");
|
||||
|
||||
@@ -213,3 +213,26 @@ for (const host of [serialHost, pluginHost]) {
|
||||
assert.equal(renderToolbar(host).includes('aria-label="Open SFTP"'), false);
|
||||
});
|
||||
}
|
||||
|
||||
test('open session log stays in the overflow menu until logging is active', () => {
|
||||
assert.equal(TERMINAL_TOOLBAR_LAYOUT_DEFAULTS.placement.openSessionLog, 'collapse');
|
||||
const hidden = renderToolbar(sshHost, 'disconnected', { onSessionLogAction: () => {} });
|
||||
assert.doesNotMatch(hidden, /aria-label="Open session log"/);
|
||||
assert.doesNotMatch(hidden, /Open session log/);
|
||||
|
||||
for (const status of ['connected', 'disconnected'] as const) {
|
||||
const markup = renderToolbar(sshHost, status, {
|
||||
onSessionLogAction: () => {},
|
||||
canOpenSessionLog: true,
|
||||
});
|
||||
assert.doesNotMatch(markup, /aria-label="Open session log"/);
|
||||
assert.doesNotMatch(markup, /aria-label="Session log actions"/);
|
||||
assert.match(markup, /aria-label="More actions"/);
|
||||
}
|
||||
assert.match(toolbarSource, /case 'openSessionLog':/);
|
||||
assert.match(toolbarSource, /data-session-log-submenu="true"/);
|
||||
assert.match(toolbarSource, /terminal\.toolbar\.sessionLog\.menu/);
|
||||
assert.match(toolbarSource, /terminal\.toolbar\.sessionLog\.\$\{action\}/);
|
||||
assert.equal(toolbarSource.includes('rememberApplication'), false);
|
||||
assert.equal(toolbarSource.includes('Open session log'), false);
|
||||
});
|
||||
@@ -6,6 +6,8 @@
|
||||
import {
|
||||
Check,
|
||||
ChevronRight,
|
||||
ChevronDown,
|
||||
FileSearch,
|
||||
Circle,
|
||||
Download,
|
||||
FileText,
|
||||
@@ -25,6 +27,7 @@ import {
|
||||
} from 'lucide-react';
|
||||
import React, { useMemo, useState } from 'react';
|
||||
import { useI18n } from '../../application/i18n/I18nProvider';
|
||||
import type { SessionLogAction } from '../../application/state/sessionLogActions';
|
||||
import { useToolbarItemLayout } from '../../application/state/useToolbarItemLayout';
|
||||
import type { ToolbarItemLayoutDefaults } from '../../domain/toolbarItemLayout';
|
||||
import { STORAGE_KEY_TERMINAL_TOOLBAR_LAYOUT } from '../../infrastructure/config/storageKeys';
|
||||
@@ -51,6 +54,7 @@ export const TERMINAL_TOOLBAR_ITEM_IDS = [
|
||||
'compose',
|
||||
'search',
|
||||
'sessionLog',
|
||||
'openSessionLog',
|
||||
'scripts',
|
||||
'history',
|
||||
'configureOsc7',
|
||||
@@ -73,6 +77,7 @@ export const TERMINAL_TOOLBAR_LAYOUT_DEFAULTS: ToolbarItemLayoutDefaults = {
|
||||
compose: 'show',
|
||||
search: 'show',
|
||||
sessionLog: 'show',
|
||||
openSessionLog: 'collapse',
|
||||
scripts: 'show',
|
||||
history: 'collapse',
|
||||
configureOsc7: 'collapse',
|
||||
@@ -114,6 +119,9 @@ export interface TerminalToolbarProps {
|
||||
// Manual session log
|
||||
showLogButton?: boolean;
|
||||
onToggleSessionLog?: () => void;
|
||||
onSessionLogAction?: (action: SessionLogAction) => void;
|
||||
/** Automatic logging is enabled, or this session has started a manual log. */
|
||||
canOpenSessionLog?: boolean;
|
||||
isSessionLogging?: boolean;
|
||||
isSessionLogDisabled?: boolean;
|
||||
// Compose bar
|
||||
@@ -152,6 +160,8 @@ export const TerminalToolbar: React.FC<TerminalToolbarProps> = ({
|
||||
onToggleSearch,
|
||||
showLogButton = false,
|
||||
onToggleSessionLog,
|
||||
onSessionLogAction,
|
||||
canOpenSessionLog = false,
|
||||
isSessionLogging = false,
|
||||
isSessionLogDisabled = false,
|
||||
isComposeBarOpen,
|
||||
@@ -196,12 +206,13 @@ export const TerminalToolbar: React.FC<TerminalToolbarProps> = ({
|
||||
const pendingScriptDeleteCount = (pendingScriptDeleteIds ?? []).filter((id) =>
|
||||
snippets.some((snippet) => snippet.id === id),
|
||||
).length;
|
||||
// Overflow popover + encoding submenu are both controlled so that
|
||||
// picking an encoding closes the whole chain, and so the parent popover
|
||||
// can ignore clicks that land in the submenu portal (otherwise the
|
||||
// submenu click would read as "outside" and dismiss the parent).
|
||||
// Overflow popover and its submenus are controlled so that picking an
|
||||
// item closes the whole chain, and so the parent popover can ignore
|
||||
// clicks that land in a submenu portal (otherwise the submenu click
|
||||
// would read as "outside" and dismiss the parent).
|
||||
const [overflowOpen, setOverflowOpen] = useState(false);
|
||||
const [encodingSubmenuOpen, setEncodingSubmenuOpen] = useState(false);
|
||||
const [sessionLogSubmenuOpen, setSessionLogSubmenuOpen] = useState(false);
|
||||
const buttonBase =
|
||||
'h-6 w-6 p-0 shadow-none border-none text-[color:var(--terminal-toolbar-fg)] bg-transparent hover:bg-transparent';
|
||||
|
||||
@@ -248,6 +259,7 @@ export const TerminalToolbar: React.FC<TerminalToolbarProps> = ({
|
||||
ids.push('ymodemSend', 'ymodemReceive');
|
||||
}
|
||||
if (showLogButton) ids.push('sessionLog');
|
||||
if (onSessionLogAction && canOpenSessionLog) ids.push('openSessionLog');
|
||||
if (historySupported) ids.push('history');
|
||||
if (onConfigureOsc7 && !hidesSftp) ids.push('configureOsc7');
|
||||
if (onStartRecording) ids.push('recording');
|
||||
@@ -264,6 +276,8 @@ export const TerminalToolbar: React.FC<TerminalToolbarProps> = ({
|
||||
onSetTerminalEncoding,
|
||||
onStartRecording,
|
||||
showLogButton,
|
||||
onSessionLogAction,
|
||||
canOpenSessionLog,
|
||||
]);
|
||||
|
||||
const itemLabels = useMemo(
|
||||
@@ -278,6 +292,7 @@ export const TerminalToolbar: React.FC<TerminalToolbarProps> = ({
|
||||
sessionLog: isSessionLogging
|
||||
? t('terminal.toolbar.stopSessionLog')
|
||||
: t('terminal.toolbar.startSessionLog'),
|
||||
openSessionLog: t('terminal.toolbar.sessionLog.menu'),
|
||||
scripts: t('terminal.toolbar.scripts'),
|
||||
history: t('terminal.toolbar.history'),
|
||||
configureOsc7: t('terminal.toolbar.configureOsc7'),
|
||||
@@ -298,6 +313,7 @@ export const TerminalToolbar: React.FC<TerminalToolbarProps> = ({
|
||||
compose: <TextCursorInput size={14} />,
|
||||
search: <Search size={14} />,
|
||||
sessionLog: <FileText size={14} />,
|
||||
openSessionLog: <FileSearch size={14} />,
|
||||
scripts: <Zap size={14} />,
|
||||
history: <History size={14} />,
|
||||
configureOsc7: <FolderSync size={14} />,
|
||||
@@ -437,6 +453,52 @@ export const TerminalToolbar: React.FC<TerminalToolbarProps> = ({
|
||||
);
|
||||
}
|
||||
|
||||
const renderSessionLogSubmenu = () => {
|
||||
if (!onSessionLogAction) return null;
|
||||
return (
|
||||
<Popover open={sessionLogSubmenuOpen} onOpenChange={setSessionLogSubmenuOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className={menuItemClass}
|
||||
aria-haspopup="menu"
|
||||
aria-expanded={sessionLogSubmenuOpen}
|
||||
>
|
||||
<FileSearch size={12} className="shrink-0" />
|
||||
<span className="flex-1 text-left truncate">{t('terminal.toolbar.sessionLog.menu')}</span>
|
||||
<ChevronRight size={12} className="shrink-0 text-muted-foreground" />
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
data-session-log-submenu="true"
|
||||
data-section="terminal-toolbar-menu"
|
||||
className="w-56 p-1"
|
||||
side="right"
|
||||
align="start"
|
||||
sideOffset={6}
|
||||
>
|
||||
{(['open', 'reveal', 'chooseApplication', 'systemDefault'] as const).map((action) => (
|
||||
<button
|
||||
key={action}
|
||||
type="button"
|
||||
className={menuItemClass}
|
||||
onClick={() => {
|
||||
onSessionLogAction(action);
|
||||
setSessionLogSubmenuOpen(false);
|
||||
setOverflowOpen(false);
|
||||
}}
|
||||
>
|
||||
<FileSearch size={12} className="shrink-0" />
|
||||
<span className="flex-1 text-left truncate">
|
||||
{t(`terminal.toolbar.sessionLog.${action}`)}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
};
|
||||
|
||||
const renderEncodingSubmenu = () => {
|
||||
if (!encodingSwitchSupported || !onSetTerminalEncoding) return null;
|
||||
return (
|
||||
@@ -631,6 +693,38 @@ export const TerminalToolbar: React.FC<TerminalToolbarProps> = ({
|
||||
<TooltipContent side="bottom">{t('terminal.toolbar.searchTerminal')}</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
case 'openSessionLog':
|
||||
return (
|
||||
<div key={id} className="flex items-center gap-0.5">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button type="button" variant="secondary" size="icon" className={buttonBase}
|
||||
aria-label={t('terminal.toolbar.openSessionLog')}
|
||||
onClick={() => onSessionLogAction?.('open')}>
|
||||
<FileSearch size={12} />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">{t('terminal.toolbar.openSessionLog')}</TooltipContent>
|
||||
</Tooltip>
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button type="button" variant="secondary" size="icon" className={cn(buttonBase, 'w-4')}
|
||||
aria-label={t('terminal.toolbar.sessionLogActions')}>
|
||||
<ChevronDown size={10} />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent align="end" className="w-56 p-1">
|
||||
{(['reveal', 'chooseApplication', 'systemDefault'] as const).map(action => (
|
||||
<PopoverClose asChild key={action}>
|
||||
<button type="button" className={menuItemClass} onClick={() => onSessionLogAction?.(action)}>
|
||||
{t(`terminal.toolbar.sessionLog.${action}`)}
|
||||
</button>
|
||||
</PopoverClose>
|
||||
))}
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
);
|
||||
case 'sessionLog':
|
||||
return (
|
||||
<Tooltip key={id}>
|
||||
@@ -903,6 +997,8 @@ export const TerminalToolbar: React.FC<TerminalToolbarProps> = ({
|
||||
</button>
|
||||
</PopoverClose>
|
||||
);
|
||||
case 'openSessionLog':
|
||||
return <React.Fragment key={id}>{renderSessionLogSubmenu()}</React.Fragment>;
|
||||
case 'sessionLog':
|
||||
return (
|
||||
<PopoverClose asChild key={id}>
|
||||
@@ -1020,7 +1116,10 @@ export const TerminalToolbar: React.FC<TerminalToolbarProps> = ({
|
||||
open={overflowOpen}
|
||||
onOpenChange={(open) => {
|
||||
setOverflowOpen(open);
|
||||
if (!open) setEncodingSubmenuOpen(false);
|
||||
if (!open) {
|
||||
setEncodingSubmenuOpen(false);
|
||||
setSessionLogSubmenuOpen(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Tooltip>
|
||||
@@ -1048,7 +1147,7 @@ export const TerminalToolbar: React.FC<TerminalToolbarProps> = ({
|
||||
// "outside" this popover; without this guard a click
|
||||
// in the submenu would dismiss the parent.
|
||||
const target = e.target as Element | null;
|
||||
if (target?.closest('[data-encoding-submenu="true"]')) {
|
||||
if (target?.closest('[data-encoding-submenu="true"], [data-session-log-submenu="true"]')) {
|
||||
e.preventDefault();
|
||||
}
|
||||
}}
|
||||
|
||||
@@ -390,7 +390,7 @@ function terminalViewCtxEqual(
|
||||
}
|
||||
|
||||
function TerminalViewInner({ ctx, isPaneMagnified = false }: { ctx: TerminalViewContext; isPaneMagnified?: boolean }) {
|
||||
const { Activity, Button, Clock3, Copy, Maximize2, Radio, RefreshCcw, SquareArrowOutUpRight, TerminalAutocomplete, TerminalComposeBar, TerminalConnectionDialog, TerminalContextMenu, TerminalSearchBar, Tooltip, TooltipContent, TooltipTrigger, Unplug, ZmodemOverwriteDialog, ZmodemProgressIndicator, auth, autocompleteAcceptTextRef, autocompleteCloseRef, autocompleteHostOs, autocompleteInputRef, autocompleteKeyEventRef, autocompleteRepositionRef, autocompleteSettings, canUpdateHost, chainProgress, cn, compactToolbar, lineTimestampsAvailable, containerRef, effectiveFontSize, effectiveFontWeight, effectiveTerminalProtocol, effectiveTheme, error, executeSnippet, executeSnippetCommand, handleAddSelectionToAI, handleCancelConnect, handleCloseDisconnectedSession, handleCloseSearch, handleDisconnect, handleDismissDisconnectedDialog, handleDragEnter, handleDragLeave, handleDragOver, handleDrop, handleFindNext, handleFindPrevious, handleHostKeyAddAndContinue, handleHostKeyClose, handleHostKeyContinue, handleOsc52ReadResponse, handleOsc7SetupConfirm, handleOsc7SetupOpenChange, handleReceiveYmodem, handleRetry, handleSearch, handleSendYmodem, handleTopOverlayMouseDownCapture, hasMouseTracking, host, hotkeyScheme, inWorkspace, isBroadcastEnabled, isCancelling, isComposeBarOpen, isConnectionAwaitingUserInput, isDraggingOver, isFocusMode, isFocusedPane, isLocalConnection, remoteDragDropUsesZmodem, isPluginTerminalProviderAvailable, isReconnectActive, isSerialConnection, isSearchOpen, isSupportedOs, isSystemSidebarEligible, isVisible, keyBindings, keys, identities, knownCwdRef, needsHostKeyVerification, onCloseSession, onDetach, onDetachPointerDown, onExpandToFocus, onTogglePaneMagnification, onOpenSystem, onRename, onSplitHorizontal, onSplitVertical, onToggleBroadcast, onUpdateHost, osc52ReadPromptVisible, osc7SetupOpen, osc7SetupRunning, passwordPromptActiveRef, pendingHostKeyInfo, progressLogs, progressValue, renderControls, resolvedFontFamily, restoreState, scriptExecutionOverlay, searchMatchCount, searchFocusToken, sessionDisplayName, sessionId, workspaceId, sessionRef, setIsComposeBarOpen, setShowLogs, shouldShowConnectionDialog, showDisconnectedTerminalNotice, showConnectionControls, showLogs, showSelectionAIAction, isRestoringSelectionRef, snippets, status, sudoHintRef, sudoHintText, passwordPickerState, onPasswordPickerSelect, passwordPickerTitle, passwordPickerEmptyText, t, termRef, terminalContextActions, terminalCwdTracker, terminalPreviewVars, terminalSettings, terminalReconnectAvailable, reconnectNoticeMessage, timeLeft, toast, zmodem } = ctx;
|
||||
const { Activity, Button, Clock3, Copy, Maximize2, Radio, RefreshCcw, SquareArrowOutUpRight, TerminalAutocomplete, TerminalComposeBar, TerminalConnectionDialog, TerminalContextMenu, TerminalSearchBar, Tooltip, TooltipContent, TooltipTrigger, Unplug, ZmodemOverwriteDialog, ZmodemProgressIndicator, auth, autocompleteAcceptTextRef, autocompleteCloseRef, autocompleteHostOs, autocompleteInputRef, autocompleteKeyEventRef, autocompleteRepositionRef, autocompleteSettings, canUpdateHost, chainProgress, cn, compactToolbar, lineTimestampsAvailable, containerRef, effectiveFontSize, effectiveFontWeight, effectiveTerminalProtocol, effectiveTheme, error, executeSnippet, executeSnippetCommand, handleAddSelectionToAI, handleCancelConnect, handleCloseDisconnectedSession, handleCloseSearch, handleDisconnect, handleDismissDisconnectedDialog, handleDragEnter, handleDragLeave, handleDragOver, handleDrop, handleFindNext, handleFindPrevious, handleHostKeyAddAndContinue, handleHostKeyClose, handleHostKeyContinue, handleOsc52ReadResponse, handleOsc7SetupConfirm, handleOsc7SetupOpenChange, handleReceiveYmodem, handleRetry, handleSearch, handleSendYmodem, handleTopOverlayMouseDownCapture, hasMouseTracking, host, hotkeyScheme, inWorkspace, isBroadcastEnabled, composeSendToAll, onToggleComposeSendTarget, isCancelling, isComposeBarOpen, isConnectionAwaitingUserInput, isDraggingOver, isFocusMode, isFocusedPane, isLocalConnection, remoteDragDropUsesZmodem, isPluginTerminalProviderAvailable, isReconnectActive, isSerialConnection, isSearchOpen, isSupportedOs, isSystemSidebarEligible, isVisible, keyBindings, keys, identities, knownCwdRef, needsHostKeyVerification, onCloseSession, onDetach, onDetachPointerDown, onExpandToFocus, onTogglePaneMagnification, onOpenSystem, onRename, onSplitHorizontal, onSplitVertical, onToggleBroadcast, onUpdateHost, osc52ReadPromptVisible, osc7SetupOpen, osc7SetupRunning, passwordPromptActiveRef, pendingHostKeyInfo, progressLogs, progressValue, renderControls, resolvedFontFamily, restoreState, scriptExecutionOverlay, searchMatchCount, searchFocusToken, sessionDisplayName, sessionId, workspaceId, sessionRef, setIsComposeBarOpen, setShowLogs, shouldShowConnectionDialog, showDisconnectedTerminalNotice, showConnectionControls, showLogs, showSelectionAIAction, isRestoringSelectionRef, snippets, status, sudoHintRef, sudoHintText, passwordPickerState, onPasswordPickerSelect, passwordPickerTitle, passwordPickerEmptyText, t, termRef, terminalContextActions, terminalCwdTracker, terminalPreviewVars, terminalSettings, terminalReconnectAvailable, reconnectNoticeMessage, timeLeft, toast, zmodem } = ctx;
|
||||
// Context menu only needs a snapshot at open; avoid selection state lifting into Terminal.
|
||||
const [contextMenuHasSelection, setContextMenuHasSelection] = useState(false);
|
||||
const isNetworkDevice = host.deviceType === 'network'
|
||||
@@ -1328,6 +1328,10 @@ function TerminalViewInner({ ctx, isPaneMagnified = false }: { ctx: TerminalView
|
||||
let anyRecipientSensitive = false;
|
||||
let broadcastRecipientIds: readonly string[] = [];
|
||||
const sent = await executeSnippetCommand(text, false, {
|
||||
// Explicit target: active-only stays local while toolbar
|
||||
// broadcast (keystrokes) can remain on, and send-to-all
|
||||
// still fans out when the toolbar switch is off.
|
||||
broadcast: composeSendToAll ?? Boolean(isBroadcastEnabled),
|
||||
onBroadcastDelivered: (sessionIds) => {
|
||||
broadcastRecipientIds = sessionIds;
|
||||
if (sessionIds.some((id) => isTerminalSensitiveInputActive(id))) {
|
||||
@@ -1352,6 +1356,8 @@ function TerminalViewInner({ ctx, isPaneMagnified = false }: { ctx: TerminalView
|
||||
termRef.current?.focus();
|
||||
}}
|
||||
isBroadcastEnabled={isBroadcastEnabled}
|
||||
composeSendToAll={composeSendToAll}
|
||||
onToggleComposeSendTarget={onToggleComposeSendTarget}
|
||||
themeColors={effectiveTheme.colors}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import test from "node:test";
|
||||
|
||||
import { shouldBroadcastTerminalUserInput } from "./terminalUserPaste.ts";
|
||||
|
||||
test("toolbar broadcast still fans terminal keystrokes out to peers", () => {
|
||||
const term = {};
|
||||
assert.equal(
|
||||
shouldBroadcastTerminalUserInput(term, "ls", {
|
||||
isBroadcastEnabled: true,
|
||||
hasBroadcastInputHandler: true,
|
||||
}),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
shouldBroadcastTerminalUserInput(term, "ls", {
|
||||
isBroadcastEnabled: false,
|
||||
hasBroadcastInputHandler: true,
|
||||
}),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test("keystroke fan-out stays on the toolbar gate and ignores the compose-bar target", () => {
|
||||
const runtime = readFileSync(new URL("./createXTermRuntime.ts", import.meta.url), "utf8");
|
||||
assert.match(runtime, /isBroadcastEnabled: ctx\.isBroadcastEnabledRef\.current/);
|
||||
assert.match(
|
||||
runtime,
|
||||
/onBroadcastInput\?\.\(broadcastData, ctx\.sessionId, sensitive \? \{ sourceSensitive: true \} : undefined\)/,
|
||||
);
|
||||
assert.doesNotMatch(runtime, /composeSendToAll|composePeerFanOut/);
|
||||
|
||||
const layer = readFileSync(new URL("../../TerminalLayer.tsx", import.meta.url), "utf8");
|
||||
assert.match(layer, /includeGlobalPeersWithoutToolbar: options\?\.composePeerFanOut === true/);
|
||||
const keystrokeCall = layer.slice(
|
||||
layer.indexOf("const handleBroadcastInput"),
|
||||
layer.indexOf("const handleCommandSubmitted"),
|
||||
);
|
||||
assert.doesNotMatch(keystrokeCall, /isComposeSendToAll/);
|
||||
});
|
||||
@@ -475,6 +475,10 @@ 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;
|
||||
@@ -551,6 +555,10 @@ 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);
|
||||
@@ -703,7 +711,9 @@ 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);
|
||||
if (pieceEnd - offset > pieceCap) {
|
||||
// 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) {
|
||||
const window = span.slice(offset, offset + pieceCap + WRAP_PROBE_UTF16_UNITS);
|
||||
let cutLength = 0;
|
||||
let end = window.length;
|
||||
@@ -794,7 +804,8 @@ 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
|
||||
: isAsciiOnly(fitting) ? fitting.slice(-1) : lastGraphemeUnits(fitting);
|
||||
: pieceAscii ? fitting.slice(-1) : fitting;
|
||||
openGraphemeUnresolved = !!fitting && !pieceAscii;
|
||||
openGraphemeCell = cursorCell;
|
||||
}
|
||||
cursor = appending ? current.length
|
||||
|
||||
@@ -48,6 +48,11 @@ export interface TerminalBroadcastInputOptions {
|
||||
* the dispatcher tags the payload sourceSensitive.
|
||||
*/
|
||||
sensitive?: boolean;
|
||||
/**
|
||||
* Compose-bar send-to-all for orphan sessions while the toolbar broadcast
|
||||
* switch stays off. Keystroke fan-out never sets this.
|
||||
*/
|
||||
composePeerFanOut?: boolean;
|
||||
/**
|
||||
* Observer for the sessions that actually received the payload (#3491):
|
||||
* the solo compose bar uses it to detect a fan-out delivered into a peer's
|
||||
@@ -236,6 +241,9 @@ export interface TerminalProps {
|
||||
onOpenTheme?: () => void;
|
||||
onOpenSystem?: () => void;
|
||||
isBroadcastEnabled?: boolean;
|
||||
/** Compose-bar send target for solo sessions. Workspace compose lives on the layer. */
|
||||
composeSendToAll?: boolean;
|
||||
onToggleComposeSendTarget?: () => void;
|
||||
onToggleBroadcast?: () => void;
|
||||
onToggleComposeBar?: () => void;
|
||||
isWorkspaceComposeBarOpen?: boolean;
|
||||
|
||||
@@ -54,6 +54,8 @@ export const terminalPropsAreEqual = (
|
||||
&& prev.disableTerminalFontZoom === next.disableTerminalFontZoom
|
||||
&& prev.keyBindings === next.keyBindings
|
||||
&& prev.isBroadcastEnabled === next.isBroadcastEnabled
|
||||
&& prev.composeSendToAll === next.composeSendToAll
|
||||
&& prev.onToggleComposeSendTarget === next.onToggleComposeSendTarget
|
||||
&& prev.isWorkspaceComposeBarOpen === next.isWorkspaceComposeBarOpen
|
||||
&& prev.sessionLog === next.sessionLog
|
||||
&& prev.sshDebugLogEnabled === next.sshDebugLogEnabled
|
||||
|
||||
@@ -719,10 +719,14 @@ export interface TerminalLayerProps {
|
||||
onCreateLocalTerminal?: () => void;
|
||||
// Broadcast mode
|
||||
isBroadcastEnabled?: (workspaceId: string) => boolean;
|
||||
isComposeSendToAll?: (workspaceId: string) => boolean;
|
||||
onToggleComposeSendTarget?: (workspaceId: string) => void;
|
||||
isGlobalBroadcastEnabled?: boolean;
|
||||
isGlobalComposeSendToAll?: boolean;
|
||||
canUseGlobalBroadcast?: boolean;
|
||||
onToggleBroadcast?: (workspaceId: string) => void;
|
||||
onToggleGlobalBroadcast?: () => void;
|
||||
onToggleGlobalComposeSendTarget?: () => void;
|
||||
// SFTP side panel
|
||||
updateHosts: (hosts: Host[]) => void;
|
||||
updateSnippets?: (snippets: Snippet[]) => void;
|
||||
@@ -824,9 +828,12 @@ interface TerminalPaneProps {
|
||||
onSetWorkspaceFocusedSession?: (workspaceId: string, sessionId: string) => void;
|
||||
onSplitSession?: (sessionId: string, direction: SplitDirection) => void;
|
||||
isBroadcastEnabled?: (workspaceId: string) => boolean;
|
||||
isComposeSendToAll?: (workspaceId: string) => boolean;
|
||||
isGlobalBroadcastEnabled?: boolean;
|
||||
isGlobalComposeSendToAll?: boolean;
|
||||
canUseGlobalBroadcast?: boolean;
|
||||
onToggleGlobalBroadcast?: () => void;
|
||||
onToggleGlobalComposeSendTarget?: () => void;
|
||||
onBroadcastInput: (
|
||||
data: string,
|
||||
sourceSessionId: string,
|
||||
@@ -952,9 +959,12 @@ const terminalPanePropsAreEqual = (
|
||||
prev.onSetWorkspaceFocusedSession === next.onSetWorkspaceFocusedSession &&
|
||||
prev.onSplitSession === next.onSplitSession &&
|
||||
prev.isBroadcastEnabled === next.isBroadcastEnabled &&
|
||||
prev.isComposeSendToAll === next.isComposeSendToAll &&
|
||||
prev.isGlobalBroadcastEnabled === next.isGlobalBroadcastEnabled &&
|
||||
prev.isGlobalComposeSendToAll === next.isGlobalComposeSendToAll &&
|
||||
prev.canUseGlobalBroadcast === next.canUseGlobalBroadcast &&
|
||||
prev.onToggleGlobalBroadcast === next.onToggleGlobalBroadcast &&
|
||||
prev.onToggleGlobalComposeSendTarget === next.onToggleGlobalComposeSendTarget &&
|
||||
prev.onBroadcastInput === next.onBroadcastInput &&
|
||||
prev.onBroadcastInterruptPriorityChange === next.onBroadcastInterruptPriorityChange &&
|
||||
prev.onToggleWorkspaceComposeBar === next.onToggleWorkspaceComposeBar &&
|
||||
@@ -1253,9 +1263,12 @@ const TerminalPane: React.FC<TerminalPaneProps> = memo(({
|
||||
onSetWorkspaceFocusedSession,
|
||||
onSplitSession,
|
||||
isBroadcastEnabled,
|
||||
isComposeSendToAll,
|
||||
isGlobalBroadcastEnabled,
|
||||
isGlobalComposeSendToAll,
|
||||
canUseGlobalBroadcast,
|
||||
onToggleGlobalBroadcast,
|
||||
onToggleGlobalComposeSendTarget,
|
||||
onBroadcastInput,
|
||||
onBroadcastInterruptPriorityChange,
|
||||
onToggleWorkspaceComposeBar,
|
||||
@@ -1485,6 +1498,12 @@ const TerminalPane: React.FC<TerminalPaneProps> = memo(({
|
||||
const broadcastEnabled = activeWorkspaceId
|
||||
? !!isBroadcastEnabled?.(activeWorkspaceId)
|
||||
: (isGlobalBroadcastEnabled ?? false);
|
||||
const composeSendToAll = activeWorkspaceId
|
||||
? !!isComposeSendToAll?.(activeWorkspaceId)
|
||||
: !!isGlobalComposeSendToAll;
|
||||
const toggleComposeSendTarget = !activeWorkspaceId && canUseGlobalBroadcast
|
||||
? onToggleGlobalComposeSendTarget
|
||||
: undefined;
|
||||
const isHostEphemeral = !isSavedVaultHost(hostMap.get(host.id));
|
||||
const sessionAppearance = useMemo(
|
||||
() => resolveSessionAppearance({ host, isEphemeral: isHostEphemeral }),
|
||||
@@ -1630,6 +1649,8 @@ const TerminalPane: React.FC<TerminalPaneProps> = memo(({
|
||||
onSplitHorizontal={onSplitSession ? splitHorizontalHandler : undefined}
|
||||
onSplitVertical={onSplitSession ? splitVerticalHandler : undefined}
|
||||
isBroadcastEnabled={broadcastEnabled}
|
||||
composeSendToAll={inActiveWorkspace ? undefined : composeSendToAll}
|
||||
onToggleComposeSendTarget={toggleComposeSendTarget}
|
||||
onToggleBroadcast={
|
||||
inActiveWorkspace
|
||||
? workspaceBroadcastHandler
|
||||
@@ -1637,7 +1658,7 @@ const TerminalPane: React.FC<TerminalPaneProps> = memo(({
|
||||
}
|
||||
onToggleComposeBar={inActiveWorkspace ? onToggleWorkspaceComposeBar : undefined}
|
||||
isWorkspaceComposeBarOpen={inActiveWorkspace ? isComposeBarOpen : undefined}
|
||||
onBroadcastInput={broadcastEnabled ? onBroadcastInput : undefined}
|
||||
onBroadcastInput={onBroadcastInput}
|
||||
onBroadcastInterruptPriorityChange={onBroadcastInterruptPriorityChange}
|
||||
onSnippetExecutorChange={onSnippetExecutorChange}
|
||||
onProgrammaticCommandLogRewriteChange={onProgrammaticCommandLogRewriteChange}
|
||||
@@ -1726,9 +1747,12 @@ interface TerminalPanesHostProps {
|
||||
onSetWorkspaceFocusedSession?: (workspaceId: string, sessionId: string) => void;
|
||||
onSplitSession?: (sessionId: string, direction: SplitDirection) => void;
|
||||
isBroadcastEnabled?: (workspaceId: string) => boolean;
|
||||
isComposeSendToAll?: (workspaceId: string) => boolean;
|
||||
isGlobalBroadcastEnabled?: boolean;
|
||||
isGlobalComposeSendToAll?: boolean;
|
||||
canUseGlobalBroadcast?: boolean;
|
||||
onToggleGlobalBroadcast?: () => void;
|
||||
onToggleGlobalComposeSendTarget?: () => void;
|
||||
onBroadcastInput: (
|
||||
data: string,
|
||||
sourceSessionId: string,
|
||||
@@ -1816,9 +1840,12 @@ const terminalPanesHostPropsAreEqual = (
|
||||
if (prev.onSetWorkspaceFocusedSession !== next.onSetWorkspaceFocusedSession) return false;
|
||||
if (prev.onSplitSession !== next.onSplitSession) return false;
|
||||
if (prev.isBroadcastEnabled !== next.isBroadcastEnabled) return false;
|
||||
if (prev.isComposeSendToAll !== next.isComposeSendToAll) return false;
|
||||
if (prev.isGlobalBroadcastEnabled !== next.isGlobalBroadcastEnabled) return false;
|
||||
if (prev.isGlobalComposeSendToAll !== next.isGlobalComposeSendToAll) return false;
|
||||
if (prev.canUseGlobalBroadcast !== next.canUseGlobalBroadcast) return false;
|
||||
if (prev.onToggleGlobalBroadcast !== next.onToggleGlobalBroadcast) return false;
|
||||
if (prev.onToggleGlobalComposeSendTarget !== next.onToggleGlobalComposeSendTarget) return false;
|
||||
if (prev.onBroadcastInput !== next.onBroadcastInput) return false;
|
||||
if (prev.onBroadcastInterruptPriorityChange !== next.onBroadcastInterruptPriorityChange) return false;
|
||||
if (prev.onToggleWorkspaceComposeBar !== next.onToggleWorkspaceComposeBar) return false;
|
||||
|
||||
@@ -547,6 +547,8 @@ export function TerminalLayerTabBridge({ stableRef }: { stableRef: StableRef })
|
||||
restoreTerminalCwd: s.restoreTerminalCwd,
|
||||
identities: s.identities,
|
||||
isBroadcastEnabled: s.isBroadcastEnabled,
|
||||
isComposeSendToAll: s.isComposeSendToAll,
|
||||
onToggleComposeSendTarget: s.onToggleComposeSendTarget,
|
||||
isComposeBarOpen: s.isComposeBarOpen,
|
||||
isFocusMode,
|
||||
isSidePanelOpenForCurrentTab,
|
||||
@@ -659,6 +661,8 @@ export function TerminalLayerTabBridge({ stableRef }: { stableRef: StableRef })
|
||||
workspaceBroadcastHandlersRef: s.workspaceBroadcastHandlersRef,
|
||||
workspaceById,
|
||||
isGlobalBroadcastEnabled: s.isGlobalBroadcastEnabled,
|
||||
isGlobalComposeSendToAll: s.isGlobalComposeSendToAll,
|
||||
onToggleGlobalComposeSendTarget: s.onToggleGlobalComposeSendTarget,
|
||||
canUseGlobalBroadcast: s.canUseGlobalBroadcast,
|
||||
onToggleGlobalBroadcast: s.onToggleGlobalBroadcastRef.current,
|
||||
// AI scope maintenance (merge/dissolve handoff) needs the full list; do not
|
||||
|
||||
@@ -101,6 +101,8 @@ function TerminalLayerWorkspaceSectionInner({ ctx }: { ctx: WorkspaceContext })
|
||||
onSetWorkspaceFocusedSession,
|
||||
onSplitSession,
|
||||
isBroadcastEnabled,
|
||||
isComposeSendToAll,
|
||||
onToggleComposeSendTarget,
|
||||
handleBroadcastInput,
|
||||
handleBroadcastInterruptPriorityChange,
|
||||
handleToggleWorkspaceComposeBar,
|
||||
@@ -124,8 +126,10 @@ function TerminalLayerWorkspaceSectionInner({ ctx }: { ctx: WorkspaceContext })
|
||||
onStartSessionDrag,
|
||||
onEndSessionDrag,
|
||||
isGlobalBroadcastEnabled,
|
||||
isGlobalComposeSendToAll,
|
||||
canUseGlobalBroadcast,
|
||||
onToggleGlobalBroadcast,
|
||||
onToggleGlobalComposeSendTarget,
|
||||
t,
|
||||
} = ctx;
|
||||
|
||||
@@ -247,6 +251,9 @@ function TerminalLayerWorkspaceSectionInner({ ctx }: { ctx: WorkspaceContext })
|
||||
onSetWorkspaceFocusedSession={onSetWorkspaceFocusedSession}
|
||||
onSplitSession={onSplitSession}
|
||||
isBroadcastEnabled={isBroadcastEnabled}
|
||||
isComposeSendToAll={isComposeSendToAll}
|
||||
isGlobalComposeSendToAll={isGlobalComposeSendToAll}
|
||||
onToggleGlobalComposeSendTarget={onToggleGlobalComposeSendTarget}
|
||||
onBroadcastInput={handleBroadcastInput}
|
||||
onBroadcastInterruptPriorityChange={handleBroadcastInterruptPriorityChange}
|
||||
onToggleWorkspaceComposeBar={handleToggleWorkspaceComposeBar}
|
||||
@@ -349,6 +356,10 @@ function TerminalLayerWorkspaceSectionInner({ ctx }: { ctx: WorkspaceContext })
|
||||
refocusTerminalSession(focusedSessionId);
|
||||
}}
|
||||
isBroadcastEnabled={isBroadcastEnabled?.(activeWorkspace.id)}
|
||||
composeSendToAll={isComposeSendToAll?.(activeWorkspace.id) ?? false}
|
||||
onToggleComposeSendTarget={onToggleComposeSendTarget
|
||||
? () => onToggleComposeSendTarget(activeWorkspace.id)
|
||||
: undefined}
|
||||
themeColors={composeBarThemeColors}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -23,6 +23,7 @@ function setup(
|
||||
sensitive = new Set<string>(),
|
||||
broadcastPasswordBypass = false,
|
||||
restoredDisconnected = new Set<string>(),
|
||||
composeSendToAll?: boolean,
|
||||
) {
|
||||
const writes: string[] = [];
|
||||
const context = {
|
||||
@@ -34,6 +35,7 @@ function setup(
|
||||
restoreState: restoredDisconnected.has(String(index)) ? 'restored-disconnected' : undefined,
|
||||
})) },
|
||||
isBroadcastEnabled: () => broadcast,
|
||||
isComposeSendToAll: composeSendToAll === undefined ? undefined : () => composeSendToAll,
|
||||
isTerminalSensitiveInputActive: (id: string) => sensitive.has(id),
|
||||
broadcastPasswordBypassRef: { current: broadcastPasswordBypass },
|
||||
snippetExecutorsRef: { current: executors },
|
||||
@@ -60,6 +62,53 @@ for (const broadcast of [false, true]) {
|
||||
}
|
||||
}
|
||||
|
||||
test('compose active-only sends to the focused session while toolbar broadcast stays on', async () => {
|
||||
const { send, writes } = setup(
|
||||
['connected', 'connected'], true, new Map(), new Set(), false, new Set(), false,
|
||||
);
|
||||
assert.equal(await send('command'), true);
|
||||
assert.deepEqual(writes, ['0']);
|
||||
});
|
||||
|
||||
test('compose active-only does not run peer executors while toolbar broadcast stays on', async () => {
|
||||
const calls: Array<{ id: string; broadcast: boolean | undefined }> = [];
|
||||
const executors = new Map<string, Executor>([
|
||||
['0', async (_text, _immediate, options) => {
|
||||
calls.push({ id: '0', broadcast: options?.broadcast });
|
||||
return true;
|
||||
}],
|
||||
['1', async () => { calls.push({ id: '1', broadcast: undefined }); return true; }],
|
||||
]);
|
||||
assert.equal(
|
||||
await setup(
|
||||
['connected', 'connected'], true, executors, new Set(), false, new Set(), false,
|
||||
).send('command'),
|
||||
true,
|
||||
);
|
||||
assert.deepEqual(calls, [{ id: '0', broadcast: false }]);
|
||||
});
|
||||
|
||||
test('compose send-to-all fans out while toolbar broadcast stays off', async () => {
|
||||
const { send, writes } = setup(
|
||||
['connected', 'connected'], false, new Map(), new Set(), false, new Set(), true,
|
||||
);
|
||||
assert.equal(await send('command'), true);
|
||||
assert.deepEqual(writes, ['0', '1']);
|
||||
});
|
||||
|
||||
test('compose send-to-all keeps a bypassed sensitive peer out of history', async () => {
|
||||
const executors = new Map<string, Executor>([
|
||||
['0', async () => true],
|
||||
['1', async () => true],
|
||||
]);
|
||||
assert.equal(
|
||||
await setup(
|
||||
['connected', 'connected'], false, executors, new Set(['1']), true, new Set(), true,
|
||||
).send('secret'),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test('a disconnected broadcast peer cannot undo another successful fallback', async () => {
|
||||
assert.equal(await setup(['connected', 'disconnected'], true).send('command'), true);
|
||||
});
|
||||
@@ -169,18 +218,43 @@ for (const result of [true, false, Promise.resolve(true), Promise.resolve(false)
|
||||
sessionId: 'a', sessionRef: { current: 'a' },
|
||||
executeSnippetCommand: () => result,
|
||||
isTerminalSensitiveInputActive: () => false,
|
||||
composeSendToAll: undefined as boolean | undefined,
|
||||
isBroadcastEnabled: false,
|
||||
};
|
||||
runInNewContext(soloCode, context);
|
||||
assert.equal(await context.send('command'), await result);
|
||||
});
|
||||
}
|
||||
|
||||
test('solo compose send uses the compose-bar target while toolbar broadcast stays on', async () => {
|
||||
const calls: Array<boolean | undefined> = [];
|
||||
const context = {
|
||||
send: undefined as unknown as (text: string) => Promise<boolean>,
|
||||
sessionId: 'a', sessionRef: { current: 'a' },
|
||||
executeSnippetCommand: (_text: string, _noAutoRun?: boolean, options?: { broadcast?: boolean }) => {
|
||||
calls.push(options?.broadcast);
|
||||
return true;
|
||||
},
|
||||
isTerminalSensitiveInputActive: () => false,
|
||||
composeSendToAll: false,
|
||||
isBroadcastEnabled: true,
|
||||
};
|
||||
runInNewContext(soloCode, context);
|
||||
assert.equal(await context.send('command'), true);
|
||||
context.composeSendToAll = true;
|
||||
context.isBroadcastEnabled = false;
|
||||
assert.equal(await context.send('command'), true);
|
||||
assert.deepEqual(calls, [false, true]);
|
||||
});
|
||||
|
||||
test('solo missing sessions and sensitive input are excluded; rejection propagates to the compose handler', async () => {
|
||||
const context = {
|
||||
send: undefined as unknown as (text: string) => Promise<boolean>,
|
||||
sessionId: 'a', sessionRef: { current: null as string | null },
|
||||
executeSnippetCommand: async () => true,
|
||||
isTerminalSensitiveInputActive: () => false,
|
||||
composeSendToAll: undefined as boolean | undefined,
|
||||
isBroadcastEnabled: false,
|
||||
};
|
||||
runInNewContext(soloCode, context);
|
||||
assert.equal(await context.send('command'), false);
|
||||
|
||||
@@ -414,6 +414,8 @@ const WORKSPACE_CTX_KEYS = [
|
||||
'onSetWorkspaceFocusedSession',
|
||||
'onSplitSession',
|
||||
'isBroadcastEnabled',
|
||||
'isComposeSendToAll',
|
||||
'onToggleComposeSendTarget',
|
||||
'handleBroadcastInput',
|
||||
'handleBroadcastInterruptPriorityChange',
|
||||
'handleToggleWorkspaceComposeBar',
|
||||
@@ -440,8 +442,10 @@ const WORKSPACE_CTX_KEYS = [
|
||||
'onStartSessionDrag',
|
||||
'onEndSessionDrag',
|
||||
'isGlobalBroadcastEnabled',
|
||||
'isGlobalComposeSendToAll',
|
||||
'canUseGlobalBroadcast',
|
||||
'onToggleGlobalBroadcast',
|
||||
'onToggleGlobalComposeSendTarget',
|
||||
] as const;
|
||||
|
||||
export function terminalLayerSidePanelStableCtxEqual(prev: Ctx, next: Ctx): boolean {
|
||||
@@ -477,6 +481,10 @@ export function terminalLayerViewCtxEqual(prev: Ctx, next: Ctx): boolean {
|
||||
if (prev.refocusTerminalSession !== next.refocusTerminalSession) return false;
|
||||
if (prev.setIsComposeBarOpen !== next.setIsComposeBarOpen) return false;
|
||||
if (prev.isBroadcastEnabled !== next.isBroadcastEnabled) return false;
|
||||
if (prev.isComposeSendToAll !== next.isComposeSendToAll) return false;
|
||||
if (prev.onToggleComposeSendTarget !== next.onToggleComposeSendTarget) return false;
|
||||
if (prev.isGlobalComposeSendToAll !== next.isGlobalComposeSendToAll) return false;
|
||||
if (prev.onToggleGlobalComposeSendTarget !== next.onToggleGlobalComposeSendTarget) return false;
|
||||
if (prev.composeBarThemeColors !== next.composeBarThemeColors) return false;
|
||||
if (prev.workspaceOuterRef !== next.workspaceOuterRef) return false;
|
||||
// Use stable side-panel equal only: LIVE fields (cwd, theme focus, etc.) flow
|
||||
|
||||
@@ -76,9 +76,13 @@ export const terminalLayerAreEqual = (
|
||||
prev.onCreateLocalTerminal === next.onCreateLocalTerminal &&
|
||||
prev.isBroadcastEnabled === next.isBroadcastEnabled &&
|
||||
prev.onToggleBroadcast === next.onToggleBroadcast &&
|
||||
prev.isComposeSendToAll === next.isComposeSendToAll &&
|
||||
prev.onToggleComposeSendTarget === next.onToggleComposeSendTarget &&
|
||||
prev.isGlobalBroadcastEnabled === next.isGlobalBroadcastEnabled &&
|
||||
prev.isGlobalComposeSendToAll === next.isGlobalComposeSendToAll &&
|
||||
prev.canUseGlobalBroadcast === next.canUseGlobalBroadcast &&
|
||||
prev.onToggleGlobalBroadcast === next.onToggleGlobalBroadcast &&
|
||||
prev.onToggleGlobalComposeSendTarget === next.onToggleGlobalComposeSendTarget &&
|
||||
prev.updateSnippets === next.updateSnippets &&
|
||||
prev.updateSnippetPackages === next.updateSnippetPackages &&
|
||||
prev.toggleScriptsSidePanelRef === next.toggleScriptsSidePanelRef &&
|
||||
|
||||
@@ -330,7 +330,8 @@ export const AsidePanelStack: React.FC<AsidePanelStackProps> = ({
|
||||
className
|
||||
)}
|
||||
style={inlineStyle}
|
||||
data-section={dataSection}>
|
||||
data-section={dataSection}
|
||||
data-native-view-occluder={layout === 'overlay' ? '' : undefined}>
|
||||
<AsidePanelHeader
|
||||
title={currentItem.title}
|
||||
subtitle={currentItem.subtitle}
|
||||
@@ -429,7 +430,10 @@ export const AsidePanel: React.FC<AsidePanelProps> = ({
|
||||
className,
|
||||
)}
|
||||
style={panelStyle}
|
||||
data-section={dataSection}>
|
||||
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}>
|
||||
{resizable ? (
|
||||
<div
|
||||
role="separator"
|
||||
|
||||
|
After Width: | Height: | Size: 985 KiB |
|
After Width: | Height: | Size: 959 KiB |
|
After Width: | Height: | Size: 931 KiB |
|
After Width: | Height: | Size: 794 KiB |
|
After Width: | Height: | Size: 1.1 MiB |
|
After Width: | Height: | Size: 1.0 MiB |
|
After Width: | Height: | Size: 1.1 MiB |
|
After Width: | Height: | Size: 287 KiB |
|
After Width: | Height: | Size: 757 KiB |
|
After Width: | Height: | Size: 892 KiB |
@@ -0,0 +1,27 @@
|
||||
# Saved session logs
|
||||
|
||||
Recording and opening a session log stay separate.
|
||||
|
||||
- **Start / Stop session log** controls manual recording.
|
||||
- **Session log** lives in the terminal toolbar's existing **More actions**
|
||||
menu, as one submenu with **Open**, **Show in folder**, **Open with…**,
|
||||
and **Open with system default**. It is not a default toolbar button.
|
||||
Toolbar customization can show the shortcut on the bar or hide it. When it
|
||||
is shown, the arrow beside it offers the same reveal and open-with actions.
|
||||
|
||||
The submenu appears when this session has a saved log, and it stays available
|
||||
after recording stops. A successful reconnect drops a stopped log from the
|
||||
old connection, and the submenu goes with it. A failed reconnect keeps it.
|
||||
Pending terminal output is flushed before opening; recording continues
|
||||
normally. Automatic logs and manually recorded logs both open this way.
|
||||
|
||||
A successfully selected application is remembered locally for subsequent log
|
||||
opens, independently of SFTP file associations. Choosing the system default
|
||||
clears this preference. Text, raw, and HTML logs retain their original format.
|
||||
|
||||
The last log remains accessible after recording stops or a session disconnects.
|
||||
After a successful reconnect, a stopped log from the old connection is not reused.
|
||||
If reconnecting fails, the previous saved log remains available.
|
||||
Manual recording that is still active remains the current log. If the current session has no saved log,
|
||||
or the file was moved or deleted, the action reports an error rather than
|
||||
opening an unrelated file. The action does not enable logging automatically.
|
||||
@@ -8,7 +8,9 @@ import {
|
||||
mergeConnectionLogsFromStorage,
|
||||
mergeTerminalDataIntoLogs,
|
||||
mergeTerminalDataMapsForStorage,
|
||||
pruneInMemoryUnsavedTerminalData,
|
||||
pruneTerminalDataMapForStorage,
|
||||
writeTerminalDataMapWithQuotaRetry,
|
||||
} from "./connectionLogTerminalData.ts";
|
||||
|
||||
const baseLog: ConnectionLog = {
|
||||
@@ -149,6 +151,44 @@ 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 },
|
||||
@@ -193,3 +233,132 @@ 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,6 +88,90 @@ 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[],
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
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,3 +63,39 @@ 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 };
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { runtimeFailureMessage } from "./containerRuntime.shared.cjs";
|
||||
import { runtimeFailureMessage } from "@/domain/systemManager/containerRuntime.shared.cjs";
|
||||
|
||||
export type {
|
||||
ContainerRuntimeId,
|
||||
@@ -6,7 +6,7 @@ export type {
|
||||
KubeContextInfo,
|
||||
KubectlPodInfo,
|
||||
RuntimeProbeFlags,
|
||||
} from "./containerRuntime.shared.cjs";
|
||||
} from "@/domain/systemManager/containerRuntime.shared.cjs";
|
||||
|
||||
export {
|
||||
NETCATTY_POPUP_HISTORY_MARKER,
|
||||
@@ -24,7 +24,7 @@ export {
|
||||
reconcileContainerSource,
|
||||
rememberedContainerSource,
|
||||
sourcesFromCapabilities,
|
||||
} from "./containerRuntime.shared.cjs";
|
||||
} from "@/domain/systemManager/containerRuntime.shared.cjs";
|
||||
|
||||
export function describeRuntimeFailure(
|
||||
result: { error?: string; errorCode?: string } | null | undefined,
|
||||
|
||||
@@ -2,6 +2,8 @@ import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import {
|
||||
composeSendTargetAfterToolbarToggle,
|
||||
flipComposeSendTarget,
|
||||
resolveTerminalBroadcastTargetIds,
|
||||
shouldBroadcastDuringSensitivePrompt,
|
||||
} from './terminalBroadcast.ts';
|
||||
@@ -93,6 +95,49 @@ test('missing or hidden global sources do not fan out', () => {
|
||||
);
|
||||
});
|
||||
|
||||
test('compose send target follows toolbar broadcast, then flips on its own', () => {
|
||||
assert.equal(composeSendTargetAfterToolbarToggle(true), 'all');
|
||||
assert.equal(composeSendTargetAfterToolbarToggle(false), 'active');
|
||||
assert.equal(flipComposeSendTarget('all'), 'active');
|
||||
assert.equal(flipComposeSendTarget('active'), 'all');
|
||||
// An independent flip does not rewrite the toolbar-derived default.
|
||||
assert.equal(composeSendTargetAfterToolbarToggle(true), 'all');
|
||||
});
|
||||
|
||||
test('compose peer fan-out can reach orphan peers while toolbar broadcast is off', () => {
|
||||
const sessions = [
|
||||
session('source'),
|
||||
session('peer'),
|
||||
session('hidden-orphan', { hiddenFromTabs: true }),
|
||||
];
|
||||
assert.deepEqual(
|
||||
resolveTerminalBroadcastTargetIds({
|
||||
sessions,
|
||||
sourceSessionId: 'source',
|
||||
globalBroadcastEnabled: false,
|
||||
includeGlobalPeersWithoutToolbar: true,
|
||||
}),
|
||||
['peer'],
|
||||
);
|
||||
assert.deepEqual(
|
||||
resolveTerminalBroadcastTargetIds({
|
||||
sessions,
|
||||
sourceSessionId: 'source',
|
||||
globalBroadcastEnabled: false,
|
||||
}),
|
||||
[],
|
||||
);
|
||||
assert.deepEqual(
|
||||
resolveTerminalBroadcastTargetIds({
|
||||
sessions: [session('hidden-source', { hiddenFromTabs: true }), session('peer')],
|
||||
sourceSessionId: 'hidden-source',
|
||||
globalBroadcastEnabled: false,
|
||||
includeGlobalPeersWithoutToolbar: true,
|
||||
}),
|
||||
[],
|
||||
);
|
||||
});
|
||||
|
||||
test('sensitive prompts pause broadcasting unless the password bypass is on', () => {
|
||||
assert.equal(
|
||||
shouldBroadcastDuringSensitivePrompt({
|
||||
|
||||
@@ -15,16 +15,37 @@ export function shouldBroadcastDuringSensitivePrompt(input: {
|
||||
return !input.sensitivePromptActive || input.broadcastPasswordBypass;
|
||||
}
|
||||
|
||||
export type ComposeSendTarget = 'all' | 'active';
|
||||
|
||||
/**
|
||||
* Toolbar broadcast is the keystroke fan-out switch. Turning it on or off
|
||||
* resets the compose-bar send target to match. A later compose-bar flip does
|
||||
* not move the toolbar switch.
|
||||
*/
|
||||
export function composeSendTargetAfterToolbarToggle(toolbarEnabled: boolean): ComposeSendTarget {
|
||||
return toolbarEnabled ? 'all' : 'active';
|
||||
}
|
||||
|
||||
export function flipComposeSendTarget(target: ComposeSendTarget): ComposeSendTarget {
|
||||
return target === 'all' ? 'active' : 'all';
|
||||
}
|
||||
|
||||
export function resolveTerminalBroadcastTargetIds({
|
||||
sessions,
|
||||
sourceSessionId,
|
||||
globalBroadcastEnabled,
|
||||
directTargetSessionIds,
|
||||
includeGlobalPeersWithoutToolbar = false,
|
||||
}: {
|
||||
sessions: readonly BroadcastSession[];
|
||||
sourceSessionId: string;
|
||||
globalBroadcastEnabled: boolean;
|
||||
directTargetSessionIds?: readonly string[];
|
||||
/**
|
||||
* Compose-bar "send to all" can target orphan peers while the toolbar
|
||||
* (global) broadcast switch stays off. Keystroke fan-out leaves this false.
|
||||
*/
|
||||
includeGlobalPeersWithoutToolbar?: boolean;
|
||||
}): string[] {
|
||||
if (directTargetSessionIds) {
|
||||
const directTargetIds = new Set(directTargetSessionIds);
|
||||
@@ -45,7 +66,8 @@ export function resolveTerminalBroadcastTargetIds({
|
||||
.map((session) => session.id);
|
||||
}
|
||||
|
||||
if (!globalBroadcastEnabled || sourceSession.hiddenFromTabs) return [];
|
||||
if (sourceSession.hiddenFromTabs) return [];
|
||||
if (!globalBroadcastEnabled && !includeGlobalPeersWithoutToolbar) return [];
|
||||
|
||||
return sessions
|
||||
.filter((session) => (
|
||||
|
||||
@@ -14,8 +14,9 @@ export {
|
||||
buildVaultHostMergeKey,
|
||||
} from "./vaultHostCreate";
|
||||
import { parseQuickConnectInput } from "./quickConnect";
|
||||
import { canServeAsSshJumpHost } from "./terminalProtocol";
|
||||
import { findExactHeaderIndex, findHeaderIndex, parseCsv } from "./vaultImport/csvUtils";
|
||||
import { decodeCsvKeyPath, decodeCsvLocalShell, decodeCsvPassphrase } from "./vaultImport/csvCredentialFields";
|
||||
import { decodeCsvKeyPath, decodeCsvLocalShell, decodeCsvPassphrase, decodeCsvProxyJump } from "./vaultImport/csvCredentialFields";
|
||||
import { isCsvDirectProxyValue, parseCsvProxy } from "./vaultImport/csvProxy";
|
||||
import { attachMobaXtermPasswords } from "./vaultImport/mobaXtermPasswords";
|
||||
import { decodeFinalShellPassword } from "./finalShellPassword";
|
||||
@@ -100,6 +101,29 @@ const parseProxyJump = (value: string): ParsedJumpHost[] => {
|
||||
.filter((h): h is ParsedJumpHost => h !== null);
|
||||
};
|
||||
|
||||
/** Vault host fields needed to bind a CSV ProxyJump hop without copying secrets. */
|
||||
export interface VaultImportExistingHostRef {
|
||||
id: string;
|
||||
label: string;
|
||||
hostname: string;
|
||||
port?: number;
|
||||
username?: string;
|
||||
protocol?: Host["protocol"];
|
||||
group?: string;
|
||||
}
|
||||
|
||||
export const toVaultImportExistingHostRef = (
|
||||
host: Pick<Host, "id" | "label" | "hostname" | "port" | "username" | "protocol" | "group">,
|
||||
): VaultImportExistingHostRef => ({
|
||||
id: host.id,
|
||||
label: host.label,
|
||||
hostname: host.hostname,
|
||||
port: host.port,
|
||||
username: host.username,
|
||||
protocol: host.protocol,
|
||||
group: host.group,
|
||||
});
|
||||
|
||||
export type VaultImportFormat =
|
||||
| "putty"
|
||||
| "mobaxterm"
|
||||
@@ -254,9 +278,32 @@ export function applyVaultImportDestination(
|
||||
return remapped;
|
||||
};
|
||||
|
||||
// Collapsing two group copies must not leave a chain pointed at the dropped id.
|
||||
// Ids that are not part of this import (an existing vault hop) stay as they are.
|
||||
const remappedHosts = hosts.map((host) => {
|
||||
const currentIds = host.hostChain?.hostIds;
|
||||
if (!currentIds?.length) return host;
|
||||
const hostIds: string[] = [];
|
||||
for (const hostId of currentIds) {
|
||||
const mapped = remapHostId(hostId);
|
||||
const nextId = mapped ?? (originalById.has(hostId) ? undefined : hostId);
|
||||
if (!nextId || nextId === host.id || hostIds.includes(nextId)) continue;
|
||||
hostIds.push(nextId);
|
||||
}
|
||||
if (hostIds.length === currentIds.length && hostIds.every((id, index) => id === currentIds[index])) {
|
||||
return host;
|
||||
}
|
||||
if (hostIds.length === 0) {
|
||||
const next = { ...host };
|
||||
delete next.hostChain;
|
||||
return next;
|
||||
}
|
||||
return { ...host, hostChain: { hostIds } };
|
||||
});
|
||||
|
||||
return {
|
||||
...result,
|
||||
hosts,
|
||||
hosts: remappedHosts,
|
||||
groups: [group],
|
||||
keyPassphrases: remapKeyPassphrases(result.keyPassphrases, { matchSelectedKey: true }),
|
||||
// Keep remapped candidates for alias conflict checks (same as CSV same-group
|
||||
@@ -421,8 +468,176 @@ const parseTarget = (
|
||||
return null;
|
||||
};
|
||||
|
||||
const CSV_JUMP_PORT = 22;
|
||||
|
||||
const importFromCsv = (text: string): VaultImportResult => {
|
||||
const csvJumpPort = (port: number | undefined): number => (
|
||||
port !== undefined && Number.isInteger(port) && port >= 1 && port <= 65535 ? port : CSV_JUMP_PORT
|
||||
);
|
||||
|
||||
type CsvJumpCandidate = {
|
||||
id: string;
|
||||
label: string;
|
||||
hostname: string;
|
||||
port?: number;
|
||||
username?: string;
|
||||
protocol?: Host["protocol"];
|
||||
group?: string;
|
||||
};
|
||||
|
||||
const csvJumpEndpointMatches = (hop: ParsedJumpHost, candidate: CsvJumpCandidate): boolean => {
|
||||
if (!canServeAsSshJumpHost(candidate)) return false;
|
||||
if (candidate.hostname.trim().toLowerCase() !== hop.hostname.toLowerCase()) return false;
|
||||
if (csvJumpPort(candidate.port) !== csvJumpPort(hop.port)) return false;
|
||||
if (hop.username && (candidate.username ?? "").trim().toLowerCase() !== hop.username.toLowerCase()) return false;
|
||||
return true;
|
||||
};
|
||||
|
||||
const csvJumpLabelMatches = (hop: ParsedJumpHost, candidate: CsvJumpCandidate): boolean => {
|
||||
if (!canServeAsSshJumpHost(candidate)) return false;
|
||||
const label = candidate.label.trim().toLowerCase();
|
||||
if (!label || label !== hop.hostname.toLowerCase()) return false;
|
||||
if (csvJumpPort(candidate.port) !== csvJumpPort(hop.port)) return false;
|
||||
if (hop.username && (candidate.username ?? "").trim().toLowerCase() !== hop.username.toLowerCase()) return false;
|
||||
return true;
|
||||
};
|
||||
|
||||
const pickCsvJumpCandidate = <T extends { group?: string }>(matches: readonly T[], group: string | undefined): T => (
|
||||
matches.find((candidate) => (candidate.group ?? "") === (group ?? "")) ?? matches[0]
|
||||
);
|
||||
|
||||
const attachCsvProxyJumpChains = (
|
||||
hosts: Host[],
|
||||
proxyJumpByHost: ReadonlyMap<Host, { value: string; row: number }>,
|
||||
issues: VaultImportIssue[],
|
||||
existingHosts: readonly VaultImportExistingHostRef[],
|
||||
): Host[] => {
|
||||
const inlineHosts: Host[] = [];
|
||||
const matchable: CsvJumpCandidate[] = [...hosts];
|
||||
|
||||
const resolveHop = (hop: ParsedJumpHost, group: string | undefined): string | null => {
|
||||
const endpointMatches = [
|
||||
...matchable.filter((candidate) => csvJumpEndpointMatches(hop, candidate)),
|
||||
...existingHosts.filter((candidate) => csvJumpEndpointMatches(hop, candidate)),
|
||||
];
|
||||
if (endpointMatches.length > 0) return pickCsvJumpCandidate(endpointMatches, group).id;
|
||||
const labelMatches = [
|
||||
...matchable.filter((candidate) => csvJumpLabelMatches(hop, candidate)),
|
||||
...existingHosts.filter((candidate) => csvJumpLabelMatches(hop, candidate)),
|
||||
];
|
||||
if (labelMatches.length > 0) return pickCsvJumpCandidate(labelMatches, group).id;
|
||||
|
||||
const inlineKey = `${hop.hostname.toLowerCase()}|${csvJumpPort(hop.port)}|${(hop.username ?? "").toLowerCase()}`;
|
||||
const existingInline = inlineHosts.find((host) => (
|
||||
`${host.hostname.toLowerCase()}|${csvJumpPort(host.port)}|${(host.username ?? "").toLowerCase()}` === inlineKey
|
||||
));
|
||||
if (existingInline) return existingInline.id;
|
||||
if (!looksLikeHostnameToken(hop.hostname)) return null;
|
||||
try {
|
||||
const inlineHost = createHost({
|
||||
label: hop.hostname,
|
||||
hostname: hop.hostname,
|
||||
username: hop.username,
|
||||
port: hop.port,
|
||||
protocol: "ssh",
|
||||
});
|
||||
inlineHosts.push(inlineHost);
|
||||
matchable.push(inlineHost);
|
||||
return inlineHost.id;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
for (const host of hosts) {
|
||||
const entry = proxyJumpByHost.get(host);
|
||||
if (!entry) continue;
|
||||
const specs = entry.value
|
||||
.split(",")
|
||||
.map((part) => part.trim())
|
||||
.filter((part) => part && part.toLowerCase() !== "none");
|
||||
if (specs.length === 0) continue;
|
||||
|
||||
const resolvedIds: string[] = [];
|
||||
const unresolved: string[] = [];
|
||||
const inlineCountBefore = inlineHosts.length;
|
||||
let chainRejected = false;
|
||||
for (const spec of specs) {
|
||||
const jump = parseJumpHostSpec(spec);
|
||||
if (!jump) {
|
||||
issues.push({
|
||||
level: "warning",
|
||||
message: `CSV row ${entry.row}: ProxyJump hop "${spec}" was ignored because it is not a valid jump host.`,
|
||||
});
|
||||
chainRejected = true;
|
||||
continue;
|
||||
}
|
||||
const inlineCount = inlineHosts.length;
|
||||
const resolvedId = resolveHop(jump, host.group);
|
||||
if (!resolvedId) {
|
||||
issues.push({
|
||||
level: "warning",
|
||||
message: `CSV row ${entry.row}: ProxyJump hop "${spec}" was ignored because it is not a valid jump host.`,
|
||||
});
|
||||
chainRejected = true;
|
||||
continue;
|
||||
}
|
||||
if (inlineHosts.length > inlineCount) unresolved.push(jump.hostname);
|
||||
if (!resolvedIds.includes(resolvedId)) resolvedIds.push(resolvedId);
|
||||
}
|
||||
|
||||
if (chainRejected || resolvedIds.includes(host.id)) {
|
||||
if (inlineHosts.length > inlineCountBefore) {
|
||||
const droppedIds = new Set(inlineHosts.splice(inlineCountBefore).map((item) => item.id));
|
||||
for (let index = matchable.length - 1; index >= 0; index -= 1) {
|
||||
if (droppedIds.has(matchable[index].id)) matchable.splice(index, 1);
|
||||
}
|
||||
}
|
||||
if (resolvedIds.includes(host.id)) {
|
||||
issues.push({
|
||||
level: "warning",
|
||||
message: `CSV: detected circular reference in ProxyJump for "${host.label}", skipping chain.`,
|
||||
});
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (resolvedIds.length > 0) host.hostChain = { hostIds: resolvedIds };
|
||||
if (unresolved.length > 0) {
|
||||
issues.push({
|
||||
level: "warning",
|
||||
message: `CSV: created inline jump host(s) for "${host.label}": ${unresolved.join(", ")}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const allHosts = [...hosts, ...inlineHosts];
|
||||
const participatesInCycle = (hostId: string, visited: Set<string>): boolean => {
|
||||
if (visited.has(hostId)) return true;
|
||||
visited.add(hostId);
|
||||
const current = allHosts.find((item) => item.id === hostId);
|
||||
for (const chainId of current?.hostChain?.hostIds ?? []) {
|
||||
if (!allHosts.some((item) => item.id === chainId)) continue;
|
||||
if (participatesInCycle(chainId, visited)) return true;
|
||||
}
|
||||
visited.delete(hostId);
|
||||
return false;
|
||||
};
|
||||
const cyclicHosts = allHosts.filter((host) => (
|
||||
Boolean(host.hostChain?.hostIds?.length) && participatesInCycle(host.id, new Set())
|
||||
));
|
||||
for (const host of cyclicHosts) {
|
||||
issues.push({
|
||||
level: "warning",
|
||||
message: `CSV: detected circular dependency in jump chain for "${host.label}", removing chain.`,
|
||||
});
|
||||
delete host.hostChain;
|
||||
}
|
||||
return allHosts;
|
||||
};
|
||||
|
||||
const importFromCsv = (
|
||||
text: string,
|
||||
existingHosts: readonly VaultImportExistingHostRef[] = [],
|
||||
): VaultImportResult => {
|
||||
const issues: VaultImportIssue[] = [];
|
||||
const rows = parseCsv(text).filter((r) => r.some((c) => c.trim() !== ""));
|
||||
if (rows.length === 0) {
|
||||
@@ -448,6 +663,7 @@ const importFromCsv = (text: string): VaultImportResult => {
|
||||
const keyPathIdx = findExactHeaderIndex(header, ["keypath", "key path", "identityfile", "identity file"]);
|
||||
const explicitPassphraseIdx = findExactHeaderIndex(header, ["passphrase", "keypassphrase", "key passphrase"]);
|
||||
const proxyIdx = findExactHeaderIndex(header, ["proxy", "proxyserver", "proxy server"]);
|
||||
const proxyJumpIdx = findExactHeaderIndex(header, ["proxyjump"]);
|
||||
const localShellIdx = findExactHeaderIndex(header, ["localshell"]);
|
||||
const passphraseIdx = keyPathIdx >= 0 ? explicitPassphraseIdx : -1;
|
||||
const exactPasswordIdx = findExactHeaderIndex(header, ["password", "pass", "passwd"]);
|
||||
@@ -482,6 +698,7 @@ const importFromCsv = (text: string): VaultImportResult => {
|
||||
}
|
||||
|
||||
const parsedHosts: Host[] = [];
|
||||
const proxyJumpByHost = new Map<Host, { value: string; row: number }>();
|
||||
const keyPassphraseCandidates: Array<{
|
||||
hostKey: string;
|
||||
keyPathKey: string;
|
||||
@@ -549,6 +766,8 @@ const importFromCsv = (text: string): VaultImportResult => {
|
||||
message: `CSV row ${i + 2}: LocalShell was ignored because it is not a valid encoded local-shell value.`,
|
||||
});
|
||||
}
|
||||
const proxyJumpRaw = (proxyJumpIdx >= 0 ? row[proxyJumpIdx] : undefined)?.trim();
|
||||
const proxyJump = proxyJumpRaw ? decodeCsvProxyJump(proxyJumpRaw).trim() : "";
|
||||
|
||||
if (decodedPassphrase && isEncryptedCredentialPlaceholder(decodedPassphrase)) {
|
||||
issues.push({
|
||||
@@ -586,6 +805,9 @@ const importFromCsv = (text: string): VaultImportResult => {
|
||||
if (localShellSpec.startDir) host.localStartDir = localShellSpec.startDir;
|
||||
if (localShellSpec.os) host.os = localShellSpec.os;
|
||||
}
|
||||
if (proxyJump && proxyJump.toLowerCase() !== "none") {
|
||||
proxyJumpByHost.set(host, { value: proxyJump, row: i + 2 });
|
||||
}
|
||||
parsedHosts.push(host);
|
||||
if (keyPath && passphrase) {
|
||||
const keyPathKey = normalizeKeyPathKey(keyPath);
|
||||
@@ -612,7 +834,8 @@ const importFromCsv = (text: string): VaultImportResult => {
|
||||
// the shell identity is part of buildVaultHostMergeKey; otherwise importing a
|
||||
// backup with CMD and PowerShell in the same group silently keeps only the
|
||||
// first shell and flags the rest as duplicates.
|
||||
const { hosts, duplicates } = dedupeHosts(parsedHosts);
|
||||
const { hosts: dedupedHosts, duplicates } = dedupeHosts(parsedHosts);
|
||||
const hosts = attachCsvProxyJumpChains(dedupedHosts, proxyJumpByHost, issues, existingHosts);
|
||||
const keyPassphrases = hosts.flatMap((host) => {
|
||||
const selectedKeyPath = host.identityFilePaths?.find((path) => path.trim())?.trim();
|
||||
if (!selectedKeyPath) return [];
|
||||
@@ -1409,15 +1632,22 @@ const importFromMobaXterm = (
|
||||
};
|
||||
};
|
||||
|
||||
export interface VaultImportTextOptions {
|
||||
fileName?: string;
|
||||
masterPassword?: string;
|
||||
/** Hosts already in the vault. CSV ProxyJump hops bind to these by endpoint. */
|
||||
existingHosts?: readonly VaultImportExistingHostRef[];
|
||||
}
|
||||
|
||||
export const importVaultHostsFromText = (
|
||||
format: VaultImportFormat,
|
||||
text: string,
|
||||
options?: { fileName?: string; masterPassword?: string },
|
||||
options?: VaultImportTextOptions,
|
||||
): VaultImportResult => {
|
||||
const input = text ?? "";
|
||||
switch (format) {
|
||||
case "csv":
|
||||
return importFromCsv(input);
|
||||
return importFromCsv(input, options?.existingHosts);
|
||||
case "putty":
|
||||
return importFromPuttyReg(input);
|
||||
case "ssh_config":
|
||||
@@ -1508,15 +1738,40 @@ export function applyVaultHostImport(
|
||||
addedHosts: Host[];
|
||||
} {
|
||||
const skipDuplicates = options?.skipDuplicates !== false;
|
||||
const existingKeys = new Set(existingHosts.map(buildVaultHostMergeKey));
|
||||
const existingByKey = new Map(existingHosts.map((host) => [buildVaultHostMergeKey(host), host]));
|
||||
const droppedIdToExistingId = new Map<string, string>();
|
||||
let newHosts = importResult.hosts;
|
||||
let skippedExistingCount = 0;
|
||||
|
||||
if (skipDuplicates) {
|
||||
newHosts = importResult.hosts.filter((host) => {
|
||||
const duplicate = existingKeys.has(buildVaultHostMergeKey(host));
|
||||
if (duplicate) skippedExistingCount++;
|
||||
return !duplicate;
|
||||
const existing = existingByKey.get(buildVaultHostMergeKey(host));
|
||||
if (!existing) return true;
|
||||
skippedExistingCount++;
|
||||
if (existing.id !== host.id) droppedIdToExistingId.set(host.id, existing.id);
|
||||
return false;
|
||||
});
|
||||
}
|
||||
|
||||
if (droppedIdToExistingId.size > 0) {
|
||||
newHosts = newHosts.map((host) => {
|
||||
const currentIds = host.hostChain?.hostIds;
|
||||
if (!currentIds?.length) return host;
|
||||
const hostIds: string[] = [];
|
||||
for (const hostId of currentIds) {
|
||||
const mapped = droppedIdToExistingId.get(hostId) ?? hostId;
|
||||
if (mapped === host.id || hostIds.includes(mapped)) continue;
|
||||
hostIds.push(mapped);
|
||||
}
|
||||
if (hostIds.length === currentIds.length && hostIds.every((id, index) => id === currentIds[index])) {
|
||||
return host;
|
||||
}
|
||||
if (hostIds.length === 0) {
|
||||
const next = { ...host };
|
||||
delete next.hostChain;
|
||||
return next;
|
||||
}
|
||||
return { ...host, hostChain: { hostIds } };
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ const FORMULA_PREFIX = /^[=+\-@\t\r]/u;
|
||||
const KEY_PATH_MARKER = "__netcatty_csv_keypath_v1__:";
|
||||
const PASSPHRASE_MARKER = "__netcatty_csv_passphrase_v1__:";
|
||||
const PROXY_MARKER = "__netcatty_csv_proxy_v1__:";
|
||||
const PROXY_JUMP_MARKER = "__netcatty_csv_proxyjump_v1__:";
|
||||
const LOCAL_SHELL_MARKER = "__netcatty_csv_localshell_v1__:";
|
||||
|
||||
const encodeMarkedField = (value: string, marker: string): string => (
|
||||
@@ -43,6 +44,14 @@ export const decodeCsvProxy = (value: string): string => (
|
||||
decodeMarkedField(value, PROXY_MARKER)
|
||||
);
|
||||
|
||||
export const encodeCsvProxyJump = (value: string): string => (
|
||||
encodeMarkedField(value, PROXY_JUMP_MARKER)
|
||||
);
|
||||
|
||||
export const decodeCsvProxyJump = (value: string): string => (
|
||||
decodeMarkedField(value, PROXY_JUMP_MARKER)
|
||||
);
|
||||
|
||||
export interface CsvLocalShellSpec {
|
||||
shell?: string;
|
||||
shellArgs?: string[];
|
||||
|
||||
@@ -3,8 +3,9 @@ import test from "node:test";
|
||||
|
||||
import { publishActiveGlobalDefaultProxyProfileId } from "../globalDefaultProxy.ts";
|
||||
import { applyGroupDefaults } from "../groupConfig.ts";
|
||||
import { importVaultHostsFromText } from "../vaultImport.ts";
|
||||
import { applyVaultHostImport, applyVaultImportDestination, importVaultHostsFromText } from "../vaultImport.ts";
|
||||
import { exportHostsToCsvWithStats, getVaultCsvTemplate } from "./csvExport.ts";
|
||||
import { parseCsv } from "./csvUtils.ts";
|
||||
import type { Host } from "../models.ts";
|
||||
|
||||
const hostDefaults = { username: "root", tags: [], os: "linux" as const };
|
||||
@@ -606,6 +607,230 @@ test("CSV round-trips local-shell vault host settings", () => {
|
||||
assert.equal(roundTrippedDefault.localStartDir, undefined);
|
||||
});
|
||||
|
||||
const proxyJumpCell = (csv: string, hostname: string): string => {
|
||||
const rows = parseCsv(csv.replace(/^\uFEFF/, "")).filter((row) => row.some((cell) => cell.trim() !== ""));
|
||||
const header = rows[0] ?? [];
|
||||
const jumpIndex = header.indexOf("ProxyJump");
|
||||
const hostIndex = header.findIndex((cell) => cell.toLowerCase().includes("hostname"));
|
||||
const row = rows.slice(1).find((cells) => cells[hostIndex] === hostname);
|
||||
return jumpIndex >= 0 ? (row?.[jumpIndex] ?? "") : "";
|
||||
};
|
||||
|
||||
const chainHost = (id: string, label: string, hostname: string, username: string, port = 22): Host => ({
|
||||
...hostDefaults,
|
||||
id,
|
||||
label,
|
||||
hostname,
|
||||
username,
|
||||
port,
|
||||
});
|
||||
|
||||
test("CSV export writes one-hop and two-hop ProxyJump chains", () => {
|
||||
const bastion = chainHost("jump-1", "Bastion", "bastion.example.com", "alice");
|
||||
const second = chainHost("jump-2", "Second hop", "jump.example.com", "bob", 2222);
|
||||
const v6 = chainHost("jump-v6", "IPv6 hop", "2001:db8::1", "carol", 2222);
|
||||
const oneHop = chainHost("app-1", "One hop", "one.example.com", "root");
|
||||
oneHop.hostChain = { hostIds: [bastion.id] };
|
||||
const twoHop = chainHost("app-2", "Two hop", "two.example.com", "root");
|
||||
twoHop.hostChain = { hostIds: [bastion.id, second.id] };
|
||||
const viaV6 = chainHost("app-v6", "Via IPv6", "v6.example.com", "root");
|
||||
viaV6.hostChain = { hostIds: [v6.id] };
|
||||
const direct = chainHost("app-3", "Direct", "direct.example.com", "root");
|
||||
|
||||
const { csv } = exportHostsToCsvWithStats([bastion, second, v6, oneHop, twoHop, viaV6, direct]);
|
||||
|
||||
assert.equal(proxyJumpCell(csv, bastion.hostname), "");
|
||||
assert.equal(proxyJumpCell(csv, oneHop.hostname), "alice@bastion.example.com");
|
||||
assert.equal(proxyJumpCell(csv, twoHop.hostname), "alice@bastion.example.com,bob@jump.example.com:2222");
|
||||
assert.equal(proxyJumpCell(csv, viaV6.hostname), "carol@[2001:db8::1]:2222");
|
||||
assert.equal(proxyJumpCell(csv, direct.hostname), "");
|
||||
assert.match(getVaultCsvTemplate().split(/\r?\n/, 1)[0] ?? "", /(^|,)ProxyJump(,|$)/);
|
||||
});
|
||||
|
||||
test("CSV round-trips a jump host chain onto the imported hosts", () => {
|
||||
const bastion = chainHost("jump-1", "Bastion", "bastion.example.com", "alice");
|
||||
const second = chainHost("jump-2", "Second hop", "jump.example.com", "bob", 2222);
|
||||
const target = chainHost("app", "App", "app.example.com", "root");
|
||||
target.group = "Prod";
|
||||
target.hostChain = { hostIds: [bastion.id, second.id] };
|
||||
|
||||
const { csv } = exportHostsToCsvWithStats([bastion, second, target]);
|
||||
const imported = importVaultHostsFromText("csv", csv);
|
||||
const byHostname = new Map(imported.hosts.map((host) => [host.hostname, host]));
|
||||
const importedTarget = byHostname.get(target.hostname);
|
||||
const importedBastion = byHostname.get(bastion.hostname);
|
||||
const importedSecond = byHostname.get(second.hostname);
|
||||
|
||||
assert.equal(imported.issues.length, 0);
|
||||
assert.deepEqual(importedTarget?.hostChain?.hostIds, [importedBastion?.id, importedSecond?.id]);
|
||||
assert.equal(importedBastion?.hostChain, undefined);
|
||||
assert.equal(importedSecond?.port, 2222);
|
||||
assert.equal(importedSecond?.username, "bob");
|
||||
});
|
||||
|
||||
test("CSV import creates an inline jump host when the hop is not in the file", () => {
|
||||
const imported = importVaultHostsFromText("csv", [
|
||||
"Hostname,Username,ProxyJump",
|
||||
"app.example.com,root,alice@bastion.example.com:2222",
|
||||
].join("\n"));
|
||||
const target = imported.hosts.find((host) => host.hostname === "app.example.com");
|
||||
const jump = imported.hosts.find((host) => host.hostname === "bastion.example.com");
|
||||
|
||||
assert.equal(imported.hosts.length, 2);
|
||||
assert.equal(jump?.username, "alice");
|
||||
assert.equal(jump?.port, 2222);
|
||||
assert.equal(jump?.protocol, "ssh");
|
||||
assert.deepEqual(target?.hostChain?.hostIds, jump ? [jump.id] : []);
|
||||
assert.ok(imported.issues.some((issue) => issue.level === "warning" && /inline jump host/i.test(issue.message)));
|
||||
});
|
||||
|
||||
test("CSV import binds a missing hop to an existing vault host by endpoint", () => {
|
||||
const existing = chainHost("vault-jump", "Old bastion", "bastion.example.com", "alice", 2222);
|
||||
const imported = importVaultHostsFromText("csv", [
|
||||
"Hostname,Username,ProxyJump",
|
||||
"app.example.com,root,alice@bastion.example.com:2222",
|
||||
].join("\n"), { existingHosts: [existing] });
|
||||
|
||||
assert.equal(imported.hosts.length, 1);
|
||||
assert.equal(imported.hosts[0]?.hostname, "app.example.com");
|
||||
assert.deepEqual(imported.hosts[0]?.hostChain, { hostIds: [existing.id] });
|
||||
assert.deepEqual(imported.issues, []);
|
||||
|
||||
const moved = applyVaultImportDestination(imported, { mode: "group", group: "Moved" });
|
||||
assert.deepEqual(moved.hosts[0]?.hostChain, { hostIds: [existing.id] });
|
||||
});
|
||||
|
||||
test("CSV import keeps an old file without a ProxyJump column unchanged", () => {
|
||||
const imported = importVaultHostsFromText("csv", [
|
||||
"Hostname,Username,Password",
|
||||
"legacy.example.com,root,secret",
|
||||
].join("\n"));
|
||||
|
||||
assert.equal(imported.hosts.length, 1);
|
||||
assert.equal(imported.hosts[0]?.hostname, "legacy.example.com");
|
||||
assert.equal(imported.hosts[0]?.username, "root");
|
||||
assert.equal(imported.hosts[0]?.password, "secret");
|
||||
assert.equal(imported.hosts[0]?.hostChain, undefined);
|
||||
assert.deepEqual(imported.issues, []);
|
||||
});
|
||||
|
||||
test("CSV import retargets a chain when the jump host is already in the vault", () => {
|
||||
const bastion = chainHost("jump-1", "Bastion", "bastion.example.com", "alice");
|
||||
bastion.group = "Prod";
|
||||
const target = chainHost("app", "App", "app.example.com", "root");
|
||||
target.group = "Prod";
|
||||
target.hostChain = { hostIds: [bastion.id] };
|
||||
const existing = chainHost("vault-jump", "Bastion", "bastion.example.com", "alice");
|
||||
existing.group = "Prod";
|
||||
|
||||
const { csv } = exportHostsToCsvWithStats([bastion, target]);
|
||||
const imported = importVaultHostsFromText("csv", csv, { existingHosts: [existing] });
|
||||
const merged = applyVaultHostImport([existing], ["Prod"], imported);
|
||||
|
||||
assert.equal(merged.addedHosts.length, 1);
|
||||
assert.equal(merged.addedHosts[0]?.hostname, "app.example.com");
|
||||
assert.deepEqual(merged.addedHosts[0]?.hostChain, { hostIds: [existing.id] });
|
||||
assert.equal(merged.hosts.some((host) => host.id !== existing.id && host.hostname === bastion.hostname), false);
|
||||
});
|
||||
|
||||
test("CSV export materializes a jump chain inherited from group defaults", () => {
|
||||
const bastion = chainHost("jump-1", "Bastion", "bastion.example.com", "alice");
|
||||
const target = chainHost("app", "App", "app.example.com", "root");
|
||||
target.group = "Corp";
|
||||
|
||||
const { csv } = exportHostsToCsvWithStats([bastion, target], {
|
||||
groupConfigs: [{ path: "Corp", hostChain: { hostIds: [bastion.id] } }],
|
||||
});
|
||||
|
||||
assert.equal(proxyJumpCell(csv, target.hostname), "alice@bastion.example.com");
|
||||
});
|
||||
|
||||
test("CSV import warns and drops a circular ProxyJump chain", () => {
|
||||
const imported = importVaultHostsFromText("csv", [
|
||||
"Hostname,Username,ProxyJump",
|
||||
"a.example.com,root,b.example.com",
|
||||
"b.example.com,root,a.example.com",
|
||||
].join("\n"));
|
||||
|
||||
assert.equal(imported.hosts.length, 2);
|
||||
assert.equal(imported.hosts.every((host) => host.hostChain === undefined), true);
|
||||
assert.ok(imported.issues.some((issue) => issue.level === "warning" && /circular/i.test(issue.message)));
|
||||
});
|
||||
|
||||
test("CSV import drops inline hops created only for a rejected self-referential ProxyJump", () => {
|
||||
const imported = importVaultHostsFromText("csv", [
|
||||
"Hostname,Username,ProxyJump",
|
||||
"ok.example.com,root,kept-hop.example.com",
|
||||
"app.example.com,root,\"new-hop.example.com,app.example.com\"",
|
||||
].join("\n"));
|
||||
|
||||
const hostnames = imported.hosts.map((host) => host.hostname).sort();
|
||||
assert.deepEqual(hostnames, ["app.example.com", "kept-hop.example.com", "ok.example.com"]);
|
||||
const app = imported.hosts.find((host) => host.hostname === "app.example.com");
|
||||
const ok = imported.hosts.find((host) => host.hostname === "ok.example.com");
|
||||
const kept = imported.hosts.find((host) => host.hostname === "kept-hop.example.com");
|
||||
assert.equal(app?.hostChain, undefined);
|
||||
assert.deepEqual(ok?.hostChain?.hostIds, kept ? [kept.id] : []);
|
||||
assert.ok(imported.issues.some((issue) => issue.level === "warning" && /circular/i.test(issue.message)));
|
||||
});
|
||||
|
||||
test("CSV import prefers a same-group existing hop over a cross-group imported match", () => {
|
||||
const existing = chainHost("vault-jump", "Bastion", "bastion.example.com", "alice");
|
||||
existing.group = "Prod";
|
||||
const imported = importVaultHostsFromText("csv", [
|
||||
"Groups,Hostname,Username,ProxyJump",
|
||||
"Other,bastion.example.com,alice,",
|
||||
"Prod,app.example.com,root,alice@bastion.example.com",
|
||||
].join("\n"), { existingHosts: [existing] });
|
||||
|
||||
const app = imported.hosts.find((host) => host.hostname === "app.example.com");
|
||||
const importedBastion = imported.hosts.find((host) => host.hostname === "bastion.example.com");
|
||||
assert.equal(importedBastion?.group, "Other");
|
||||
assert.notEqual(importedBastion?.id, existing.id);
|
||||
assert.deepEqual(app?.hostChain, { hostIds: [existing.id] });
|
||||
});
|
||||
|
||||
test("CSV import prefers a same-group existing label hop over a cross-group imported label", () => {
|
||||
const existing = chainHost("vault-jump", "Bastion", "vault-bastion.internal", "alice");
|
||||
existing.group = "Prod";
|
||||
const imported = importVaultHostsFromText("csv", [
|
||||
"Groups,Label,Hostname,Username,ProxyJump",
|
||||
"Other,Bastion,other-bastion.internal,alice,",
|
||||
"Prod,App,app.example.com,root,alice@Bastion",
|
||||
].join("\n"), { existingHosts: [existing] });
|
||||
|
||||
const app = imported.hosts.find((host) => host.hostname === "app.example.com");
|
||||
const importedBastion = imported.hosts.find((host) => host.hostname === "other-bastion.internal");
|
||||
assert.equal(importedBastion?.group, "Other");
|
||||
assert.equal(importedBastion?.label, "Bastion");
|
||||
assert.notEqual(importedBastion?.id, existing.id);
|
||||
assert.deepEqual(app?.hostChain, { hostIds: [existing.id] });
|
||||
});
|
||||
|
||||
test("CSV import warns and ignores a ProxyJump value that is not a jump host", () => {
|
||||
const imported = importVaultHostsFromText("csv", [
|
||||
"Hostname,Username,ProxyJump",
|
||||
"app.example.com,root,not a hop",
|
||||
].join("\n"));
|
||||
|
||||
assert.equal(imported.hosts.length, 1);
|
||||
assert.equal(imported.hosts[0]?.hostChain, undefined);
|
||||
assert.ok(imported.issues.some((issue) => issue.level === "warning" && issue.message.includes("ProxyJump")));
|
||||
});
|
||||
|
||||
test("CSV import rejects a ProxyJump chain that contains a malformed hop", () => {
|
||||
const imported = importVaultHostsFromText("csv", [
|
||||
"Hostname,Username,ProxyJump",
|
||||
"app.example.com,root,\"bastion.example.com,not a hop\"",
|
||||
].join("\n"));
|
||||
|
||||
assert.deepEqual(imported.hosts.map((host) => host.hostname), ["app.example.com"]);
|
||||
assert.equal(imported.hosts[0]?.hostChain, undefined);
|
||||
assert.ok(imported.issues.some((issue) => (
|
||||
issue.level === "warning" && issue.message.includes("not a hop")
|
||||
)));
|
||||
});
|
||||
|
||||
test("CSV import warns and ignores a malformed LocalShell value", () => {
|
||||
const csv = [
|
||||
"Groups,Label,Tags,Notes,Hostname/IP,Protocol,Port,Username,Password,KeyPath,Passphrase,Proxy,LocalShell",
|
||||
|
||||
@@ -2,7 +2,8 @@ import type { GroupConfig, Host, Identity, ProxyProfile } from '../models';
|
||||
import { applyGroupDefaults, resolveGroupDefaults } from '../groupConfig';
|
||||
import { isPluginHostProtocol } from '../pluginConnection';
|
||||
import { hasUnreadableProxyCredential, materializeHostProxyProfile, resolveProxyConfigAuth } from '../proxyProfiles';
|
||||
import { CsvLocalShellSpec, encodeCsvKeyPath, encodeCsvLocalShell, encodeCsvPassphrase, encodeCsvProxy } from './csvCredentialFields';
|
||||
import { canServeAsSshJumpHost } from '../terminalProtocol';
|
||||
import { CsvLocalShellSpec, encodeCsvKeyPath, encodeCsvLocalShell, encodeCsvPassphrase, encodeCsvProxy, encodeCsvProxyJump } from './csvCredentialFields';
|
||||
import { CSV_DIRECT_PROXY_VALUE, formatCsvProxy } from './csvProxy';
|
||||
|
||||
const UTF8_BOM = "\uFEFF";
|
||||
@@ -36,7 +37,7 @@ export const getVaultCsvTemplate = (
|
||||
opts: VaultCsvTemplateOptions = {},
|
||||
): string => {
|
||||
const includeExampleRows = opts.includeExampleRows !== false;
|
||||
const header = ["Groups", "Label", "Tags", "Notes", "Hostname/IP", "Protocol", "Port", "Username", "Password", "KeyPath", "Passphrase", "Proxy", "LocalShell"];
|
||||
const header = ["Groups", "Label", "Tags", "Notes", "Hostname/IP", "Protocol", "Port", "Username", "Password", "KeyPath", "Passphrase", "Proxy", "ProxyJump", "LocalShell"];
|
||||
const rows: string[][] = [header];
|
||||
if (includeExampleRows) {
|
||||
rows.push(["Project/Dev", "Web Server (dev)", "dev,web", "Dev web tier", "192.168.1.10", "ssh", "22", "root", "", "~/.ssh/id_ed25519", "", "socks5://127.0.0.1:1080"]);
|
||||
@@ -77,8 +78,44 @@ const getLocalShellSpec = (host: Host): CsvLocalShellSpec | null => (
|
||||
: null
|
||||
);
|
||||
|
||||
const UNSAFE_PROXY_JUMP_USERNAME = /[\s,#@]/;
|
||||
const UNSAFE_PROXY_JUMP_HOSTNAME = /[\s,#@]/;
|
||||
const DEFAULT_PROXY_JUMP_PORT = 22;
|
||||
|
||||
// OpenSSH ProxyJump: [user@]host[:port], repeated. Port 22 is omitted.
|
||||
// IPv6 is bracketed so the port separator stays unambiguous.
|
||||
const formatCsvProxyJumpHop = (host: Host): string | null => {
|
||||
if (!canServeAsSshJumpHost(host)) return null;
|
||||
const username = host.username?.trim() ?? "";
|
||||
const hostname = host.hostname.trim().replace(/^\[(.*)\]$/u, "$1");
|
||||
if (!hostname) return null;
|
||||
if (username && UNSAFE_PROXY_JUMP_USERNAME.test(username)) return null;
|
||||
if (UNSAFE_PROXY_JUMP_HOSTNAME.test(hostname)) return null;
|
||||
const port = host.port ?? DEFAULT_PROXY_JUMP_PORT;
|
||||
if (!Number.isInteger(port) || port < 1 || port > 65535) return null;
|
||||
const hostPart = hostname.includes(":") ? `[${hostname}]` : hostname;
|
||||
const withUser = username ? `${username}@${hostPart}` : hostPart;
|
||||
return port === DEFAULT_PROXY_JUMP_PORT ? withUser : `${withUser}:${port}`;
|
||||
};
|
||||
|
||||
// An empty cell means "no chain". A chain is omitted entirely when any hop
|
||||
// cannot be represented, so a shortened list is not written back as the truth.
|
||||
const formatCsvProxyJump = (host: Host, allHosts: readonly Host[]): string => {
|
||||
const ids = host.hostChain?.hostIds ?? [];
|
||||
if (ids.length === 0) return "";
|
||||
const byId = new Map(allHosts.map((item) => [item.id, item]));
|
||||
const parts: string[] = [];
|
||||
for (const id of ids) {
|
||||
const hop = byId.get(id);
|
||||
const formatted = hop ? formatCsvProxyJumpHop(hop) : null;
|
||||
if (!formatted) return "";
|
||||
parts.push(formatted);
|
||||
}
|
||||
return parts.join(",");
|
||||
};
|
||||
|
||||
const exportHostsToCsv = (hosts: Host[], options: VaultCsvExportOptions) => {
|
||||
const header = ["Groups", "Label", "Tags", "Notes", "Hostname/IP", "Protocol", "Port", "Username", "Password", "KeyPath", "Passphrase", "Proxy", "LocalShell"];
|
||||
const header = ["Groups", "Label", "Tags", "Notes", "Hostname/IP", "Protocol", "Port", "Username", "Password", "KeyPath", "Passphrase", "Proxy", "ProxyJump", "LocalShell"];
|
||||
const rows: string[][] = [header];
|
||||
let unreadableProxyCredentialCount = 0;
|
||||
|
||||
@@ -161,6 +198,9 @@ const exportHostsToCsv = (hosts: Host[], options: VaultCsvExportOptions) => {
|
||||
}
|
||||
}
|
||||
const localShellSpec = getLocalShellSpec(host);
|
||||
// Group defaults are already on effectiveHost, same as Proxy, so a chain
|
||||
// inherited from the group survives a file that has no group-config section.
|
||||
const proxyJumpValue = formatCsvProxyJump(effectiveHost, exportableHosts);
|
||||
|
||||
rows.push([
|
||||
host.group ?? "",
|
||||
@@ -175,6 +215,7 @@ const exportHostsToCsv = (hosts: Host[], options: VaultCsvExportOptions) => {
|
||||
encodeCsvKeyPath(keyPath),
|
||||
encodeCsvPassphrase(passphrase),
|
||||
encodeCsvProxy(proxyValue),
|
||||
encodeCsvProxyJump(proxyJumpValue),
|
||||
localShellSpec ? encodeCsvLocalShell(localShellSpec) : "",
|
||||
]);
|
||||
}
|
||||
@@ -183,9 +224,10 @@ const exportHostsToCsv = (hosts: Host[], options: VaultCsvExportOptions) => {
|
||||
const keyPathColIdx = header.indexOf("KeyPath");
|
||||
const passphraseColIdx = header.indexOf("Passphrase");
|
||||
const proxyColIdx = header.indexOf("Proxy");
|
||||
const proxyJumpColIdx = header.indexOf("ProxyJump");
|
||||
const csv = rows.map((r, rowIdx) => r.map((c, i) => escapeCsv(
|
||||
c,
|
||||
rowIdx > 0 && (i === passwordColIdx || i === keyPathColIdx || i === passphraseColIdx || i === proxyColIdx),
|
||||
rowIdx > 0 && (i === passwordColIdx || i === keyPathColIdx || i === passphraseColIdx || i === proxyColIdx || i === proxyJumpColIdx),
|
||||
)).join(",")).join("\r\n") + "\r\n";
|
||||
return { csv, unreadableProxyCredentialCount };
|
||||
};
|
||||
|
||||
@@ -82,6 +82,9 @@ module.exports = {
|
||||
'lib/**/*.json',
|
||||
'skills/**/*',
|
||||
'!public/**/*',
|
||||
// README screenshots live under docs/ and are not app resources.
|
||||
// Keep them out of app.asar if the files globs ever become permissive.
|
||||
'!docs/assets/readme/**/*',
|
||||
'!**/*.map',
|
||||
'!**/*.d.ts',
|
||||
'!**/*.d.mts',
|
||||
|
||||
@@ -63,7 +63,10 @@ function queryDirtyEditors(webContents, timeoutMs, options = {}) {
|
||||
// consume the listener slot and let the real reply fall through. A
|
||||
// missing/falsy sender is anomalous and treated as a wrong-window reply.
|
||||
if (evt?.sender !== webContents) return;
|
||||
settle(payload?.hasDirty === true);
|
||||
// "transfers": the user already declined the renderer's unfinished-
|
||||
// transfer quit prompt. Still truthy, so existing callers keep quit
|
||||
// cancelled; handleBeforeQuit uses it to skip the editor dialog.
|
||||
settle(payload?.hasDirty === true ? (payload?.reason === "transfers" ? "transfers" : true) : false);
|
||||
}
|
||||
ipcMain.on("app:dirty-editors-result", onResult);
|
||||
|
||||
|
||||
@@ -221,3 +221,15 @@ test("resolves false when no ipcMain can be resolved", async () => {
|
||||
assert.equal(await queryDirtyEditors(wc, 5000), false);
|
||||
assert.deepEqual(wc.sent, []);
|
||||
});
|
||||
|
||||
test("carries a declined unfinished-transfer prompt as a truthy \"transfers\" result", async () => {
|
||||
const ipcMain = makeIpcMain();
|
||||
const wc = makeWebContents();
|
||||
const promise = queryDirtyEditors(wc, 5000, { ipcMain });
|
||||
ipcMain.emit("app:dirty-editors-result", { sender: wc }, { hasDirty: true, reason: "transfers" });
|
||||
assert.equal(await promise, "transfers");
|
||||
// A clean reply that names a reason is still clean.
|
||||
const clean = queryDirtyEditors(wc, 5000, { ipcMain });
|
||||
ipcMain.emit("app:dirty-editors-result", { sender: wc }, { hasDirty: false, reason: "transfers" });
|
||||
assert.equal(await clean, false);
|
||||
});
|
||||
@@ -201,6 +201,8 @@ const requestHostKeyVerification = (sender, info) => new Promise((resolve) => {
|
||||
}
|
||||
});
|
||||
|
||||
const toKeyBuffer = (rawKey) => (Buffer.isBuffer(rawKey) ? rawKey : Buffer.from(rawKey || ""));
|
||||
|
||||
const createHostVerifier = ({
|
||||
sender,
|
||||
sessionId,
|
||||
@@ -213,64 +215,79 @@ const createHostVerifier = ({
|
||||
// TTL leaves the upload on "Waiting..." with no dialog and no error.
|
||||
unattended = false,
|
||||
onAccepted,
|
||||
}) => (rawKey, callback) => {
|
||||
const keyInfo = describeHostKey(rawKey);
|
||||
const noteAccepted = () => {
|
||||
try {
|
||||
onAccepted?.({
|
||||
hostname,
|
||||
port,
|
||||
keyType: keyInfo.keyType,
|
||||
fingerprint: keyInfo.fingerprint,
|
||||
publicKey: keyInfo.publicKey,
|
||||
});
|
||||
} catch {
|
||||
// Recording the accepted key must not change the verifier result.
|
||||
}) => {
|
||||
// ssh2 calls the verifier again on every rekey. A prompt there is async,
|
||||
// and while it waits ssh2 keeps sending channel data ahead of its NEWKEYS,
|
||||
// so the server drops the connection mid-transfer ("Bad packet length").
|
||||
// Like OpenSSH, a rekey only has to present the key already accepted for
|
||||
// this connection: answer it synchronously and never prompt again.
|
||||
let acceptedKey = null;
|
||||
|
||||
return (rawKey, callback) => {
|
||||
if (verifyHostKeys !== false && acceptedKey) {
|
||||
callback(acceptedKey.equals(toKeyBuffer(rawKey)));
|
||||
return;
|
||||
}
|
||||
|
||||
const keyInfo = describeHostKey(rawKey);
|
||||
const noteAccepted = () => {
|
||||
acceptedKey = Buffer.from(toKeyBuffer(rawKey));
|
||||
try {
|
||||
onAccepted?.({
|
||||
hostname,
|
||||
port,
|
||||
keyType: keyInfo.keyType,
|
||||
fingerprint: keyInfo.fingerprint,
|
||||
publicKey: keyInfo.publicKey,
|
||||
});
|
||||
} catch {
|
||||
// Recording the accepted key must not change the verifier result.
|
||||
}
|
||||
};
|
||||
|
||||
if (verifyHostKeys === false) {
|
||||
noteAccepted();
|
||||
callback(true);
|
||||
return;
|
||||
}
|
||||
|
||||
const decision = classifyHostKey({
|
||||
knownHosts,
|
||||
hostname,
|
||||
port,
|
||||
keyType: keyInfo.keyType,
|
||||
fingerprint: keyInfo.fingerprint,
|
||||
});
|
||||
|
||||
if (decision.status === "trusted") {
|
||||
noteAccepted();
|
||||
callback(true);
|
||||
return;
|
||||
}
|
||||
|
||||
if (unattended) {
|
||||
callback(false);
|
||||
return;
|
||||
}
|
||||
|
||||
void requestHostKeyVerification(sender, {
|
||||
sessionId,
|
||||
hostname,
|
||||
port,
|
||||
status: decision.status,
|
||||
keyType: keyInfo.keyType,
|
||||
fingerprint: keyInfo.fingerprint,
|
||||
publicKey: keyInfo.publicKey,
|
||||
knownHostId: decision.knownHost?.id,
|
||||
knownFingerprint: decision.expectedFingerprint,
|
||||
...(Number.isFinite(bootEpoch) ? { bootEpoch: Number(bootEpoch) } : {}),
|
||||
}).then((response) => {
|
||||
if (response?.accept) noteAccepted();
|
||||
callback(Boolean(response?.accept));
|
||||
}).catch(() => {
|
||||
callback(false);
|
||||
});
|
||||
};
|
||||
|
||||
if (verifyHostKeys === false) {
|
||||
noteAccepted();
|
||||
callback(true);
|
||||
return;
|
||||
}
|
||||
|
||||
const decision = classifyHostKey({
|
||||
knownHosts,
|
||||
hostname,
|
||||
port,
|
||||
keyType: keyInfo.keyType,
|
||||
fingerprint: keyInfo.fingerprint,
|
||||
});
|
||||
|
||||
if (decision.status === "trusted") {
|
||||
noteAccepted();
|
||||
callback(true);
|
||||
return;
|
||||
}
|
||||
|
||||
if (unattended) {
|
||||
callback(false);
|
||||
return;
|
||||
}
|
||||
|
||||
void requestHostKeyVerification(sender, {
|
||||
sessionId,
|
||||
hostname,
|
||||
port,
|
||||
status: decision.status,
|
||||
keyType: keyInfo.keyType,
|
||||
fingerprint: keyInfo.fingerprint,
|
||||
publicKey: keyInfo.publicKey,
|
||||
knownHostId: decision.knownHost?.id,
|
||||
knownFingerprint: decision.expectedFingerprint,
|
||||
...(Number.isFinite(bootEpoch) ? { bootEpoch: Number(bootEpoch) } : {}),
|
||||
}).then((response) => {
|
||||
if (response?.accept) noteAccepted();
|
||||
callback(Boolean(response?.accept));
|
||||
}).catch(() => {
|
||||
callback(false);
|
||||
});
|
||||
};
|
||||
|
||||
const handleResponse = (_event, payload) => {
|
||||
|
||||
@@ -356,6 +356,100 @@ test("createHostVerifier prompts for unknown host keys and waits for user respon
|
||||
assert.equal(await acceptedPromise, true);
|
||||
});
|
||||
|
||||
test("createHostVerifier answers rekeys synchronously with the key accepted at connect", async () => {
|
||||
const rawKey = Buffer.from("new server key");
|
||||
const sent = [];
|
||||
const sender = {
|
||||
id: 1,
|
||||
isDestroyed: () => false,
|
||||
send: (channel, payload) => sent.push({ channel, payload }),
|
||||
};
|
||||
// The knownHosts snapshot stays empty for the life of the connection, as it
|
||||
// does when the user picks "Continue" or "Add and continue" on first connect.
|
||||
const verifier = createHostVerifier({
|
||||
sender,
|
||||
sessionId: "session-1",
|
||||
hostname: "switch.local",
|
||||
port: 22,
|
||||
knownHosts: [],
|
||||
});
|
||||
|
||||
const acceptedPromise = new Promise((resolve) => verifier(rawKey, resolve));
|
||||
assert.equal(sent.length, 1);
|
||||
handleResponse({ sender: { id: sender.id } }, {
|
||||
requestId: sent[0].payload.requestId,
|
||||
accept: true,
|
||||
addToKnownHosts: false,
|
||||
});
|
||||
assert.equal(await acceptedPromise, true);
|
||||
|
||||
// ssh2 re-runs the verifier on every rekey. It must answer before returning
|
||||
// (ssh2 keeps sending channel data while an async answer is pending) and
|
||||
// must not show the host-key dialog again.
|
||||
const rekeyResults = [];
|
||||
verifier(Buffer.from(rawKey), (ok) => rekeyResults.push(ok));
|
||||
assert.deepEqual(rekeyResults, [true]);
|
||||
assert.equal(sent.length, 1);
|
||||
});
|
||||
|
||||
test("createHostVerifier rejects a different host key on rekey without prompting", async () => {
|
||||
const rawKey = Buffer.from("trusted server key");
|
||||
const fingerprint = crypto.createHash("sha256").update(rawKey).digest("base64").replace(/=+$/g, "");
|
||||
const sent = [];
|
||||
const sender = {
|
||||
id: 1,
|
||||
isDestroyed: () => false,
|
||||
send: (channel, payload) => sent.push({ channel, payload }),
|
||||
};
|
||||
const verifier = createHostVerifier({
|
||||
sender,
|
||||
sessionId: "session-1",
|
||||
hostname: "switch.local",
|
||||
port: 22,
|
||||
knownHosts: [{
|
||||
id: "kh-1",
|
||||
hostname: "switch.local",
|
||||
port: 22,
|
||||
keyType: "unknown",
|
||||
publicKey: `SHA256:${fingerprint}`,
|
||||
discoveredAt: 1,
|
||||
}],
|
||||
});
|
||||
|
||||
assert.equal(await new Promise((resolve) => verifier(rawKey, resolve)), true);
|
||||
|
||||
const rekeyResults = [];
|
||||
verifier(Buffer.from("other server key"), (ok) => rekeyResults.push(ok));
|
||||
assert.deepEqual(rekeyResults, [false]);
|
||||
assert.deepEqual(sent, []);
|
||||
});
|
||||
|
||||
test("createHostVerifier still prompts on the next connect after a rejected key", async () => {
|
||||
const rawKey = Buffer.from("new server key");
|
||||
const sent = [];
|
||||
const sender = {
|
||||
id: 1,
|
||||
isDestroyed: () => false,
|
||||
send: (channel, payload) => sent.push({ channel, payload }),
|
||||
};
|
||||
const verifier = createHostVerifier({
|
||||
sender,
|
||||
sessionId: "session-1",
|
||||
hostname: "switch.local",
|
||||
port: 22,
|
||||
knownHosts: [],
|
||||
});
|
||||
|
||||
const first = new Promise((resolve) => verifier(rawKey, resolve));
|
||||
handleResponse({ sender: { id: sender.id } }, { requestId: sent[0].payload.requestId, accept: false });
|
||||
assert.equal(await first, false);
|
||||
|
||||
const second = new Promise((resolve) => verifier(rawKey, resolve));
|
||||
assert.equal(sent.length, 2);
|
||||
handleResponse({ sender: { id: sender.id } }, { requestId: sent[1].payload.requestId, accept: true });
|
||||
assert.equal(await second, true);
|
||||
});
|
||||
|
||||
test("createHostVerifier forwards bootEpoch on host-key prompts", async () => {
|
||||
const rawKey = Buffer.from("new server key");
|
||||
const sent = [];
|
||||
|
||||
@@ -1978,11 +1978,11 @@ function registerHandlers(ipcMain, options = {}) {
|
||||
if (!tunnelId) return;
|
||||
trackedTunnelIds.delete(tunnelId);
|
||||
tunnelRuleIds.delete(tunnelId);
|
||||
for (const [webContentsId, entry] of subscriptionsBySender) {
|
||||
for (const entry of subscriptionsBySender.values()) {
|
||||
entry.tunnelIds.delete(tunnelId);
|
||||
if (entry.tunnelIds.size > 0) continue;
|
||||
entry.sender.removeListener?.("destroyed", entry.onDestroyed);
|
||||
subscriptionsBySender.delete(webContentsId);
|
||||
// Runtime subscriptions outlive individual tunnels. Keep their owner
|
||||
// destruction hook until both subscription kinds have been released.
|
||||
releaseEmptySenderLifecycle(entry.sender, entry);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -238,3 +238,91 @@ test("worker crashes notify subscribed renderers and permit a replacement worker
|
||||
);
|
||||
assert.equal(starts, 2);
|
||||
});
|
||||
|
||||
|
||||
for (const removal of ["stop", "stopAll", "stopByRuleId", "remove", "inactive", "error"]) {
|
||||
test(`worker ${removal} preserves runtime owner cleanup after the final tunnel`, async () => {
|
||||
const ipcMain = createIpcMain();
|
||||
const requests = [];
|
||||
let onRendererEvent;
|
||||
const terminalWorkerManager = {
|
||||
request(channel, payload, options) {
|
||||
requests.push({ channel, options });
|
||||
return Promise.resolve({ tunnelId: payload?.tunnelId, success: true, failed: 0 });
|
||||
},
|
||||
onWorkerRendererEvent(listener) { onRendererEvent = listener; },
|
||||
};
|
||||
registerHandlers(ipcMain, { terminalWorkerManager });
|
||||
const sender = createSender(40);
|
||||
const invoke = (method, payload = {}) => ipcMain.handlers.get(`netcatty:portforward:${method}`)({ sender }, payload);
|
||||
await invoke("subscribeRuntime");
|
||||
await invoke("start", { tunnelId: "last", ruleId: "rule-last" });
|
||||
if (["stop", "stopAll", "stopByRuleId"].includes(removal)) {
|
||||
await invoke(removal, { tunnelId: "last", ruleId: "rule-last" });
|
||||
} else {
|
||||
onRendererEvent({
|
||||
channel: removal === "remove" ? "netcatty:portforward:runtime" : "netcatty:portforward:status",
|
||||
payload: { tunnelId: "last", kind: removal, status: removal },
|
||||
});
|
||||
}
|
||||
assert.equal(sender.listenerCount("destroyed"), 1);
|
||||
sender.emit("destroyed");
|
||||
await Promise.resolve();
|
||||
assert.equal(requests.filter(({ channel }) => channel === "netcatty:portforward:unsubscribeSender").length, 1);
|
||||
assert.equal(sender.listenerCount("destroyed"), 0);
|
||||
});
|
||||
}
|
||||
|
||||
test("worker last-tunnel removal releases lifecycle after explicit runtime unsubscribe", async () => {
|
||||
const ipcMain = createIpcMain();
|
||||
registerHandlers(ipcMain, { terminalWorkerManager: {
|
||||
request(_channel, payload) { return Promise.resolve({ tunnelId: payload?.tunnelId, success: true }); },
|
||||
} });
|
||||
const sender = createSender(41);
|
||||
const invoke = (method, payload = {}) => ipcMain.handlers.get(`netcatty:portforward:${method}`)({ sender }, payload);
|
||||
await invoke("subscribeRuntime");
|
||||
await invoke("start", { tunnelId: "last" });
|
||||
await invoke("stop", { tunnelId: "last" });
|
||||
assert.equal(sender.listenerCount("destroyed"), 1);
|
||||
await invoke("unsubscribeRuntime");
|
||||
assert.equal(sender.listenerCount("destroyed"), 0);
|
||||
});
|
||||
|
||||
test("worker final-tunnel stop then renderer destruction prevents stale runtime event fanout", async (t) => {
|
||||
const bridge = require("./portForwardingBridge.cjs");
|
||||
const { createTerminalWorkerRuntime } = require("../terminalWorker/runtime.cjs");
|
||||
bridge._resetPortForwardRuntimeMetaForTests();
|
||||
bridge._clearPortForwardTunnelsForTests();
|
||||
t.after(() => {
|
||||
bridge._resetPortForwardRuntimeMetaForTests();
|
||||
bridge._clearPortForwardTunnelsForTests();
|
||||
});
|
||||
const events = [];
|
||||
let onRendererEvent;
|
||||
const runtime = createTerminalWorkerRuntime({ parentPort: {
|
||||
postMessage(message) { events.push(message); onRendererEvent?.(message); },
|
||||
on() {},
|
||||
} });
|
||||
const workerIpc = createIpcMain();
|
||||
bridge.registerHandlers(workerIpc);
|
||||
const mainIpc = createIpcMain();
|
||||
bridge.registerHandlers(mainIpc, { terminalWorkerManager: {
|
||||
request(channel, payload, options) {
|
||||
return workerIpc.handlers.get(channel)({ sender: runtime.createSender(options.webContentsId) }, payload);
|
||||
},
|
||||
onWorkerRendererEvent(listener) { onRendererEvent = listener; },
|
||||
} });
|
||||
const sender = createSender(42);
|
||||
const invoke = (method, payload = {}) => mainIpc.handlers.get(`netcatty:portforward:${method}`)({ sender }, payload);
|
||||
await invoke("subscribeRuntime");
|
||||
bridge._seedPortForwardTunnelForTests("fixture", { ruleId: "fixture", status: "active", type: "local" });
|
||||
await invoke("subscribe", { tunnelId: "fixture" });
|
||||
const stopped = await invoke("stop", { tunnelId: "fixture" });
|
||||
assert.equal(stopped.success, true);
|
||||
sender.emit("destroyed");
|
||||
await Promise.resolve();
|
||||
events.length = 0;
|
||||
bridge.publishTunnelStatus("another", { ruleId: "another", status: "active" }, "active");
|
||||
assert.deepEqual(events, []);
|
||||
assert.equal((await bridge.unsubscribePortForwardSender(null, { webContentsId: sender.id })).removed, 0);
|
||||
});
|
||||
@@ -34,10 +34,11 @@ function createProcessTree({ platform, listPosix, listWindows } = {}) {
|
||||
|
||||
function defaultListPosix(ppid, { execFileFn = execFile } = {}) {
|
||||
return new Promise((resolve) => {
|
||||
// `ps -A -o pid=,ppid=,args=` works on both BSD (macOS) and GNU (Linux).
|
||||
// `args=` shows the full command line (not truncated like `comm=`).
|
||||
// The trailing `=` on each column suppresses the header row.
|
||||
execFileFn("ps", ["-A", "-o", "pid=,ppid=,args="], {
|
||||
// Separate `-o col=` options. A single `-o pid=,ppid=,args=` spec is
|
||||
// misread by procps-ng 3.3.x: the text after the first `=` becomes the
|
||||
// header, so only pid is printed. The split form works on BSD (macOS),
|
||||
// modern procps, and BusyBox. `args=` is the full command line.
|
||||
execFileFn("ps", ["-A", "-o", "pid=", "-o", "ppid=", "-o", "args="], {
|
||||
timeout: POSIX_PROCESS_LIST_TIMEOUT_MS,
|
||||
maxBuffer: POSIX_PROCESS_LIST_MAX_BUFFER_BYTES,
|
||||
windowsHide: true,
|
||||
|
||||
@@ -101,7 +101,7 @@ test("default posix process listing bounds the child lifetime and captured outpu
|
||||
{ pid: 2003, command: "python worker.py" },
|
||||
]);
|
||||
assert.equal(invocation.command, "ps");
|
||||
assert.deepEqual(invocation.args, ["-A", "-o", "pid=,ppid=,args="]);
|
||||
assert.deepEqual(invocation.args, ["-A", "-o", "pid=", "-o", "ppid=", "-o", "args="]);
|
||||
assert.equal(invocation.options.timeout, POSIX_PROCESS_LIST_TIMEOUT_MS);
|
||||
assert.equal(invocation.options.maxBuffer, POSIX_PROCESS_LIST_MAX_BUFFER_BYTES);
|
||||
assert.equal(invocation.options.windowsHide, true);
|
||||
|
||||
@@ -41,6 +41,8 @@ function createBridgeRegistrarForTest({
|
||||
tempDir,
|
||||
sftpClients = new Map(),
|
||||
transferBridge = createNoopBridge(),
|
||||
sshBridge = null,
|
||||
terminalBridge = null,
|
||||
electronModuleOverrides = {},
|
||||
}) {
|
||||
const noopBridge = createNoopBridge();
|
||||
@@ -81,12 +83,12 @@ function createBridgeRegistrarForTest({
|
||||
sftpClients,
|
||||
CLOUD_SYNC_PASSWORD_FILE: "cloud-sync-password",
|
||||
getCliDiscoveryFilePath: () => path.join(tempDir, "cli-discovery.json"),
|
||||
sshBridge: { ...noopBridge, ensureMoshStatsConnection() {} },
|
||||
sshBridge: sshBridge || { ...noopBridge, ensureMoshStatsConnection() {} },
|
||||
sftpBridge,
|
||||
localFsBridge: noopBridge,
|
||||
transferBridge,
|
||||
portForwardingBridge: noopBridge,
|
||||
terminalBridge: { ...noopBridge, execOnEtSession() {} },
|
||||
terminalBridge: terminalBridge || { ...noopBridge, execOnEtSession() {} },
|
||||
crashLogBridge: noopBridge,
|
||||
ptyProcessTree: { getChildProcesses: () => [] },
|
||||
getOauthBridge: () => ({ setupOAuthBridge() {} }),
|
||||
@@ -250,3 +252,33 @@ test("downloadToTempWithProgress proxies transfer work to the terminal worker in
|
||||
cancelled: false,
|
||||
});
|
||||
});
|
||||
|
||||
for (const mode of ["disabled", "unavailable", "worker"]) {
|
||||
test(`registrar enables saved-log restart guards only for main fallback (${mode})`, async (t) => {
|
||||
const tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "netcatty-log-fallback-test-"));
|
||||
const previousWorkerSetting = process.env.NETCATTY_TERMINAL_WORKER;
|
||||
process.env.NETCATTY_TERMINAL_WORKER = mode === "disabled" ? "0" : "1";
|
||||
t.after(async () => {
|
||||
if (previousWorkerSetting === undefined) delete process.env.NETCATTY_TERMINAL_WORKER;
|
||||
else process.env.NETCATTY_TERMINAL_WORKER = previousWorkerSetting;
|
||||
await fs.promises.rm(tempDir, { recursive: true, force: true });
|
||||
});
|
||||
const observed = {};
|
||||
const bridgeSpy = (name) => ({
|
||||
...createNoopBridge(),
|
||||
registerHandlers(_ipcMain, options) { observed[name] = options; },
|
||||
});
|
||||
const registerBridges = createBridgeRegistrarForTest({
|
||||
ipcMain: createIpcMainStub(), tempDir,
|
||||
sshBridge: bridgeSpy("ssh"), terminalBridge: bridgeSpy("terminal"),
|
||||
electronModuleOverrides: mode === "unavailable" ? {} : {
|
||||
utilityProcess: { fork() { throw new Error("registration must not cold-start the worker"); } },
|
||||
},
|
||||
});
|
||||
registerBridges({});
|
||||
for (const name of ["ssh", "terminal"]) {
|
||||
assert.equal(observed[name].manageSessionLogRestart, mode !== "worker");
|
||||
assert.equal(Boolean(observed[name].terminalWorkerManager), mode === "worker");
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
"use strict";
|
||||
|
||||
// In-process terminal backends share one registry. Remember the first renderer
|
||||
// for each session object, not its mutable output route: an attached popup is
|
||||
// only a display, and a replacement boot is a different object.
|
||||
const owners = new WeakMap();
|
||||
const trackedRegistries = new WeakMap();
|
||||
|
||||
function trackRendererSessionLifetime(sessions, sessionId, contents, closeSession, transferOwnership = false) {
|
||||
const session = sessions?.get(sessionId);
|
||||
// Worker/agent senders are synthetic and have no renderer destruction event.
|
||||
if (!session || typeof contents?.once !== "function") return;
|
||||
if (owners.has(session) && !transferOwnership) return;
|
||||
owners.set(session, contents);
|
||||
let tracked = trackedRegistries.get(sessions);
|
||||
if (!tracked) trackedRegistries.set(sessions, tracked = new WeakSet());
|
||||
const closeOwnedSessions = () => {
|
||||
for (const [id, current] of [...sessions]) {
|
||||
if (owners.get(current) !== contents || sessions.get(id) !== current) continue;
|
||||
closeSession({ sender: contents }, { sessionId: id, bootEpoch: current.bootEpoch });
|
||||
}
|
||||
};
|
||||
if (!tracked.has(contents)) {
|
||||
tracked.add(contents);
|
||||
contents.once("destroyed", closeOwnedSessions);
|
||||
}
|
||||
// An asynchronous start may finish after its window disappeared. Let its
|
||||
// synchronous setup finish (including acquiring SSH leases), then close only
|
||||
// the still-owned object through the same path as an explicit tab close.
|
||||
if (contents.isDestroyed?.()) queueMicrotask(closeOwnedSessions);
|
||||
}
|
||||
|
||||
module.exports = { trackRendererSessionLifetime };
|
||||
@@ -0,0 +1,78 @@
|
||||
const assert = require("node:assert/strict");
|
||||
const EventEmitter = require("node:events");
|
||||
const test = require("node:test");
|
||||
const { trackRendererSessionLifetime: track } = require("./rendererSessionLifetime.cjs");
|
||||
|
||||
function renderer(id) {
|
||||
const contents = new EventEmitter();
|
||||
contents.id = id;
|
||||
contents.destroyed = false;
|
||||
contents.isDestroyed = () => contents.destroyed;
|
||||
contents.destroy = () => { contents.destroyed = true; contents.emit("destroyed"); };
|
||||
return contents;
|
||||
}
|
||||
|
||||
function fixture() {
|
||||
const sessions = new Map();
|
||||
const closed = [];
|
||||
const close = (_event, payload) => { closed.push(payload); sessions.delete(payload.sessionId); };
|
||||
const own = (id, contents, bootEpoch = 1) => {
|
||||
sessions.set(id, { bootEpoch });
|
||||
track(sessions, id, contents, close);
|
||||
};
|
||||
return { sessions, closed, close, own };
|
||||
}
|
||||
|
||||
test("fallback closes only session objects owned by the destroyed renderer", () => {
|
||||
const f = fixture();
|
||||
const first = renderer(7), second = renderer(8);
|
||||
f.own("a", first);
|
||||
f.own("b", first);
|
||||
f.own("c", second);
|
||||
assert.equal(first.listenerCount("destroyed"), 1);
|
||||
first.destroy();
|
||||
assert.deepEqual(f.closed, [{ sessionId: "a", bootEpoch: 1 }, { sessionId: "b", bootEpoch: 1 }]);
|
||||
assert.equal(f.sessions.has("c"), true);
|
||||
});
|
||||
|
||||
test("fallback display rebind is not an ownership handoff; explicit home restore is", () => {
|
||||
const f = fixture();
|
||||
const home = renderer(7), popup = renderer(9), nextHome = renderer(8);
|
||||
f.own("a", home);
|
||||
track(f.sessions, "a", popup, f.close);
|
||||
popup.destroy();
|
||||
assert.equal(f.closed.length, 0);
|
||||
track(f.sessions, "a", nextHome, f.close, true);
|
||||
home.destroy();
|
||||
assert.equal(f.closed.length, 0);
|
||||
nextHome.destroy();
|
||||
assert.equal(f.closed.length, 1);
|
||||
});
|
||||
|
||||
test("fallback old owner cannot close a same-ID replacement or synthetic agent session", () => {
|
||||
const f = fixture();
|
||||
const home = renderer(7), nextHome = renderer(8);
|
||||
f.own("a", home);
|
||||
f.own("a", nextHome, 2);
|
||||
f.own("agent", { id: 7 });
|
||||
home.destroy();
|
||||
assert.equal(f.closed.length, 0);
|
||||
nextHome.destroy();
|
||||
assert.deepEqual(f.closed, [{ sessionId: "a", bootEpoch: 2 }]);
|
||||
assert.equal(f.sessions.has("agent"), true);
|
||||
});
|
||||
|
||||
test("fallback late start into a destroyed renderer closes after setup, not a replacement", async () => {
|
||||
const f = fixture();
|
||||
const home = renderer(7), nextHome = renderer(8);
|
||||
home.destroy();
|
||||
f.own("late", home);
|
||||
assert.equal(f.closed.length, 0);
|
||||
await Promise.resolve();
|
||||
assert.deepEqual(f.closed, [{ sessionId: "late", bootEpoch: 1 }]);
|
||||
f.own("replacement", home);
|
||||
f.own("replacement", nextHome, 2);
|
||||
await Promise.resolve();
|
||||
assert.equal(f.closed.length, 1);
|
||||
assert.equal(f.sessions.has("replacement"), true);
|
||||
});
|
||||